app
Adom Video Post-Production
Public Made by Adomby adom
AI-driven studio for finishing demo videos: review every captured clip, flag and re-record the weak ones, speed up dead air, narrate, mux and auto-level, validate the final cut, and publish straight to the wiki, driven end to end by your AI.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197
//! Marker file reader/writer.
//!
//! The markers file is JSONL: one event per line. The post-processor reads it
//! to figure out where the speedup segments live in the source video.
//!
//! Event types:
//! recording_start - written by `mark init`. Must be the first event.
//! speedup_start - written by `mark start` or `wrap`. Has speed + label.
//! speedup_end - written by `mark end` or `wrap`. Closes the previous start.
use serde::{Deserialize, Serialize};
use std::fs::{File, OpenOptions};
use std::io::{BufRead, BufReader, Write};
use std::path::Path;
use std::time::{SystemTime, UNIX_EPOCH};
#[derive(Debug, Serialize, Deserialize, Clone)]
#[serde(tag = "action", rename_all = "snake_case")]
pub enum Event {
RecordingStart {
epoch: f64,
},
SpeedupStart {
epoch: f64,
speed: u32,
label: String,
},
SpeedupEnd {
epoch: f64,
},
}
impl Event {
pub fn epoch(&self) -> f64 {
match self {
Event::RecordingStart { epoch } => *epoch,
Event::SpeedupStart { epoch, .. } => *epoch,
Event::SpeedupEnd { epoch } => *epoch,
}
}
}
/// Current epoch with millisecond precision.
pub fn now_epoch() -> f64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_secs_f64())
.unwrap_or(0.0)
}
/// Default markers file path. Honors $VIDEO_SPEEDUP_MARKERS env var.
pub fn default_path() -> String {
std::env::var("VIDEO_SPEEDUP_MARKERS")
.unwrap_or_else(|_| "/tmp/adom-video-post-markers.jsonl".to_string())
}
/// Initialize the markers file: clear it and write a recording_start event.
pub fn init(path: &str) -> Result<f64, String> {
let epoch = now_epoch();
let mut f = File::create(path).map_err(|e| format!("create {}: {}", path, e))?;
let event = Event::RecordingStart { epoch };
writeln!(f, "{}", serde_json::to_string(&event).unwrap())
.map_err(|e| format!("write {}: {}", path, e))?;
Ok(epoch)
}
/// Append an event to an existing markers file.
pub fn append(path: &str, event: &Event) -> Result<(), String> {
let mut f = OpenOptions::new()
.create(true)
.append(true)
.open(path)
.map_err(|e| format!("open {}: {}", path, e))?;
writeln!(f, "{}", serde_json::to_string(event).unwrap())
.map_err(|e| format!("write {}: {}", path, e))?;
Ok(())
}
/// Read all events from a markers file.
pub fn read_all<P: AsRef<Path>>(path: P) -> Result<Vec<Event>, String> {
let path = path.as_ref();
let f = File::open(path).map_err(|e| format!("open {}: {}", path.display(), e))?;
let reader = BufReader::new(f);
let mut events = Vec::new();
for (i, line) in reader.lines().enumerate() {
let line = line.map_err(|e| format!("read line {}: {}", i + 1, e))?;
let line = line.trim();
if line.is_empty() {
continue;
}
let event: Event = serde_json::from_str(line)
.map_err(|e| format!("parse line {}: {} ({})", i + 1, e, line))?;
events.push(event);
}
Ok(events)
}
/// A speedup segment paired up from start/end events. Offset is relative to
/// the recording_start epoch (so 0.0 = first frame of the video).
#[derive(Debug, Clone)]
pub struct Segment {
pub start_offset: f64,
pub end_offset: f64,
pub speed: u32,
pub label: String,
}
impl Segment {
pub fn duration(&self) -> f64 {
self.end_offset - self.start_offset
}
pub fn sped_duration(&self) -> f64 {
self.duration() / self.speed as f64
}
}
/// Parse the events into a list of speedup segments. Returns the recording
/// start epoch and the segments. Unclosed speedup_start markers are warned
/// about and treated as ending at the end of the video (caller decides where
/// the end is).
pub struct ParseResult {
pub recording_start: f64,
pub segments: Vec<Segment>,
pub warnings: Vec<String>,
pub unclosed: Vec<(f64, u32, String)>, // for unclosed starts that need an end injected
}
pub fn parse(events: &[Event]) -> Result<ParseResult, String> {
let mut iter = events.iter();
let recording_start = match iter.next() {
Some(Event::RecordingStart { epoch }) => *epoch,
Some(other) => {
return Err(format!(
"first event must be recording_start, got {:?}. Hint: call `adom-video-post mark init` before any other markers.",
other
));
}
None => return Err("markers file is empty. Hint: call `adom-video-post mark init` first.".to_string()),
};
let mut segments = Vec::new();
let mut warnings = Vec::new();
let mut unclosed = Vec::new();
let mut pending_start: Option<(f64, u32, String)> = None;
for ev in iter {
match ev {
Event::RecordingStart { .. } => {
warnings.push("duplicate recording_start event ignored".to_string());
}
Event::SpeedupStart { epoch, speed, label } => {
if let Some((prev_epoch, prev_speed, prev_label)) = pending_start.take() {
warnings.push(format!(
"speedup_start at {:.3} found while previous speedup_start ({}, '{}') at {:.3} was unclosed; previous one will end at this start time",
epoch - recording_start, prev_speed, prev_label, prev_epoch - recording_start
));
segments.push(Segment {
start_offset: prev_epoch - recording_start,
end_offset: epoch - recording_start,
speed: prev_speed,
label: prev_label,
});
}
pending_start = Some((*epoch, *speed, label.clone()));
}
Event::SpeedupEnd { epoch } => {
if let Some((start_epoch, speed, label)) = pending_start.take() {
segments.push(Segment {
start_offset: start_epoch - recording_start,
end_offset: epoch - recording_start,
speed,
label,
});
} else {
warnings.push(format!(
"speedup_end at {:.3} with no matching speedup_start (ignored)",
epoch - recording_start
));
}
}
}
}
if let Some((epoch, speed, label)) = pending_start {
unclosed.push((epoch - recording_start, speed, label));
}
// Sort segments by start time (defensive — they should already be in order)
segments.sort_by(|a, b| a.start_offset.partial_cmp(&b.start_offset).unwrap());
Ok(ParseResult {
recording_start,
segments,
warnings,
unclosed,
})
}