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.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877
//! Voiceover-specific HTTP handlers.
//!
//! Plugs into the shared `webapp` module via `impl Routes for
//! VoiceoverRoutes`. The built-in webapp routes (UI, console, /eval,
//! /shutdown, favicon) are handled by `webapp::server`; this file
//! only contains the voiceover-specific surface: audio start/stop,
//! normalize, finalize/mux, take history, waveform, timeline,
//! reveal-in-explorer, and the /video + /final-video range streams.
//!
//! Voiceover is audio-only: Hydrogen's first-class audio API
//! (`adom-cli hydrogen audio start/stop`) owns mic capture. Our
//! role is to orchestrate start/stop, mux the resulting .webm
//! against the input video, and expose a UI for reviewing takes.
use super::{mux, VoiceoverConfig};
use crate::webapp::{video as webapp_video, RouteOutcome, Routes};
use std::path::PathBuf;
use std::process::Command;
use std::sync::{Arc, Mutex};
use tiny_http::{Header, Method, Request, Response};
#[derive(Default)]
pub struct AppState {
recording: bool,
audio_path: Option<PathBuf>,
audio_size: u64,
finalize_path: Option<PathBuf>,
/// Sidecar preview path produced by /normalize. The UI's /final-video
/// endpoint serves this if set, so users can audition the loudness-
/// normalized version before committing. /save-final copies this
/// over finalize_path and clears the preview.
preview_path: Option<PathBuf>,
phase: String,
}
pub type SharedState = Arc<Mutex<AppState>>;
/// The voiceover subcommand's `impl Routes`. Captures the config,
/// the shared AppState, and the result slot that `run()` waits on.
pub struct VoiceoverRoutes {
pub cfg: VoiceoverConfig,
pub state: SharedState,
pub result_slot: Arc<Mutex<Option<Result<PathBuf, String>>>>,
}
const UI_HTML: &str = include_str!("ui.html");
const FAVICON_SVG: &str = include_str!("icon.svg");
impl VoiceoverRoutes {
pub fn new(
cfg: VoiceoverConfig,
result_slot: Arc<Mutex<Option<Result<PathBuf, String>>>>,
) -> Self {
Self {
cfg,
state: Arc::new(Mutex::new(AppState {
phase: "idle".to_string(),
..Default::default()
})),
result_slot,
}
}
}
impl Routes for VoiceoverRoutes {
fn ui_html(&self) -> &str {
UI_HTML
}
fn favicon_svg(&self) -> &str {
FAVICON_SVG
}
fn handle(&self, request: Request, method: &Method, url: &str) -> RouteOutcome {
let res: Result<(), String> = match (method, url) {
(Method::Get, "/video") => {
webapp_video::serve_file_range(request, &self.cfg.input, "video/webm")
}
(Method::Get, "/final-video") => {
// Prefer the preview (normalized) version if one exists,
// otherwise fall back to the committed final output.
let path = self
.state
.lock()
.unwrap()
.preview_path
.clone()
.unwrap_or_else(|| self.cfg.output.clone());
webapp_video::serve_file_range(request, &path, "video/webm")
}
(Method::Post, "/normalize") => handle_normalize(request, &self.state, &self.cfg.output),
(Method::Post, "/revert-to-raw") => handle_revert_to_raw(request, &self.state),
(Method::Post, "/save-final") => handle_save_final(request, &self.state, &self.cfg.output),
(Method::Post, "/reveal") => handle_reveal(request, &self.state, &self.cfg.output),
(Method::Post, "/adopt-latest-audio") => handle_adopt_latest_audio(request, &self.state),
(Method::Get, "/takes") => handle_list_takes(request, &self.state, &self.cfg.input, &self.cfg.output),
(Method::Get, "/timeline") => handle_timeline(request, &self.cfg.input, self.cfg.markers_file.as_deref()),
(Method::Post, "/select-take") => handle_select_take(request, &self.state),
(Method::Get, path)
if path == "/waveform" || path == "/waveform-raw" || path == "/waveform-preview" =>
{
let which = if path.ends_with("-raw") {
"raw"
} else {
"preview"
};
handle_waveform(request, &self.state, &self.cfg.output, which)
}
(Method::Get, "/state") => handle_get_state(request, &self.state),
(Method::Post, "/start-recording") => handle_start_recording(request, &self.state),
(Method::Post, "/stop-recording") => handle_stop_recording(request, &self.state),
(Method::Post, "/discard") => handle_discard(request, &self.state),
(Method::Post, "/replay") => handle_replay(request, &self.state, &self.cfg.output),
(Method::Post, "/finalize") => {
let r = handle_finalize(request, &self.cfg.input, &self.state, &self.cfg.output);
if let Some(path) = r {
*self.result_slot.lock().unwrap() = Some(Ok(path));
}
Ok(())
}
_ => return RouteOutcome::NotHandled(request),
};
RouteOutcome::Handled(res)
}
}
fn handle_start_recording(request: Request, state: &SharedState) -> Result<(), String> {
println!("starting voiceover audio-only recording with caption countdown");
// `hydrogen audio start` is audio-only (does NOT capture the screen),
// but has no native countdown primitive. We drive a 3-2-1 overlay
// server-side via `hydrogen caption show` so both the UI-click path
// and the external curl/AI path get the same countdown.
let _ = Command::new("adom-cli")
.args(["hydrogen", "audio", "enable", "--reason", "Voiceover recording"])
.output();
for digit in ["3", "2", "1"] {
let _ = Command::new("adom-cli")
.args(["hydrogen", "caption", "show", digit, "-d", "1", "-s", "large", "-p", "center"])
.output();
std::thread::sleep(std::time::Duration::from_millis(1000));
}
let _ = Command::new("adom-cli")
.args(["hydrogen", "caption", "show", "GO", "-d", "1", "-s", "large", "-p", "center"])
.output();
std::thread::sleep(std::time::Duration::from_millis(400));
let result = Command::new("adom-cli")
.args(["hydrogen", "audio", "start"])
.output()
.map_err(|e| format!("adom-cli spawn failed: {}", e))?;
if !result.status.success() {
let stderr = String::from_utf8_lossy(&result.stderr);
let _ = request.respond(json_response(
500,
&format!(r#"{{"ok":false,"error":"audio start failed: {}"}}"#, stderr.replace('"', "\\\"")),
));
return Ok(());
}
{
let mut s = state.lock().unwrap();
s.recording = true;
s.phase = "recording".to_string();
s.audio_path = None;
s.audio_size = 0;
}
let _ = request.respond(json_response(200, r#"{"ok":true}"#));
Ok(())
}
fn handle_stop_recording(request: Request, state: &SharedState) -> Result<(), String> {
println!("stopping Hydrogen audio recording...");
let result = Command::new("adom-cli")
.args(["hydrogen", "audio", "stop"])
.output()
.map_err(|e| format!("adom-cli spawn failed: {}", e))?;
if !result.status.success() {
let stderr = String::from_utf8_lossy(&result.stderr);
let _ = request.respond(json_response(
500,
&format!(r#"{{"ok":false,"error":"audio stop failed: {}"}}"#, stderr.replace('"', "\\\"")),
));
return Ok(());
}
let stdout = String::from_utf8_lossy(&result.stdout);
let parsed: serde_json::Value = serde_json::from_str(&stdout)
.map_err(|e| format!("parse audio stop response: {} ({})", e, stdout))?;
let file_rel = parsed
.get("filePath")
.and_then(|v| v.as_str())
.ok_or_else(|| format!("audio stop response missing filePath: {}", stdout))?;
let home = std::env::var("HOME").unwrap_or_else(|_| "/root".to_string());
let absolute = PathBuf::from(home).join("project").join(file_rel);
let size = parsed.get("size").and_then(|v| v.as_u64()).unwrap_or(0);
println!("Hydrogen saved audio: {} ({} bytes)", absolute.display(), size);
{
let mut s = state.lock().unwrap();
s.recording = false;
s.phase = "recorded".to_string();
s.audio_path = Some(absolute.clone());
s.audio_size = size;
}
let body = format!(
r#"{{"ok":true,"path":"{}","size":{}}}"#,
absolute.display(),
size
);
let _ = request.respond(json_response(200, &body));
Ok(())
}
fn next_take_number(output: &std::path::Path) -> u32 {
let parent = output.parent().unwrap_or_else(|| std::path::Path::new("."));
let stem = output.file_stem().map(|s| s.to_string_lossy().to_string()).unwrap_or_default();
let prefix = format!("{}-take-", stem);
let mut max_n = 0u32;
if let Ok(read) = std::fs::read_dir(parent) {
for e in read.flatten() {
if let Some(name) = e.file_name().to_str().map(|s| s.to_string()) {
if let Some(rest) = name.strip_prefix(&prefix) {
let num: String = rest.chars().take_while(|c| c.is_ascii_digit()).collect();
if let Ok(n) = num.parse::<u32>() { if n > max_n { max_n = n; } }
}
}
}
}
max_n + 1
}
fn take_raw_path(output: &std::path::Path, n: u32) -> PathBuf {
let parent = output.parent().unwrap_or_else(|| std::path::Path::new("."));
let stem = output.file_stem().map(|s| s.to_string_lossy().to_string()).unwrap_or_default();
let ext = output.extension().map(|e| e.to_string_lossy().to_string()).unwrap_or_else(|| "webm".to_string());
parent.join(format!("{}-take-{}.{}", stem, n, ext))
}
fn take_norm_path(output: &std::path::Path, n: u32) -> PathBuf {
let parent = output.parent().unwrap_or_else(|| std::path::Path::new("."));
let stem = output.file_stem().map(|s| s.to_string_lossy().to_string()).unwrap_or_default();
let ext = output.extension().map(|e| e.to_string_lossy().to_string()).unwrap_or_else(|| "webm".to_string());
parent.join(format!("{}-take-{}-norm.{}", stem, n, ext))
}
fn run_loudnorm(source: &std::path::Path, dest: &std::path::Path) -> Result<(), String> {
let result = Command::new("ffmpeg")
.args(["-y", "-i"])
.arg(source)
.args([
"-af", "loudnorm=I=-16:TP=-1.5:LRA=11",
"-c:v", "copy",
"-c:a", "libopus",
"-b:a", "128k",
])
.arg(dest)
.output()
.map_err(|e| format!("ffmpeg spawn failed: {}", e))?;
if !result.status.success() {
let stderr = String::from_utf8_lossy(&result.stderr);
return Err(format!("ffmpeg loudnorm failed: {}", stderr.lines().rev().take(5).collect::<Vec<_>>().join("; ")));
}
Ok(())
}
fn handle_finalize(
request: Request,
input_video: &std::path::Path,
state: &SharedState,
output: &std::path::Path,
) -> Option<PathBuf> {
let audio = state.lock().unwrap().audio_path.clone();
let audio = match audio {
Some(p) if p.exists() => p,
_ => {
let _ = request.respond(json_response(
400,
r#"{"ok":false,"error":"no audio recorded yet, click Record first"}"#,
));
return None;
}
};
state.lock().unwrap().phase = "finalizing".to_string();
// Versioned raw mux: output-take-N.webm. Then auto-loudnorm to
// output-take-N-norm.webm so every finalize produces both tabs
// (raw and auto-leveled) at once, no extra clicks.
let take_n = next_take_number(output);
let raw = take_raw_path(output, take_n);
let norm = take_norm_path(output, take_n);
println!("muxing take {}: {} + {} -> {}", take_n, input_video.display(), audio.display(), raw.display());
if let Err(e) = mux(input_video, &audio, &raw) {
state.lock().unwrap().phase = "error".to_string();
let body = format!(r#"{{"ok":false,"error":"{}"}}"#, e.replace('"', "\\\""));
let _ = request.respond(json_response(500, &body));
return None;
}
println!("mux complete: {}", raw.display());
let norm_ok = run_loudnorm(&raw, &norm).is_ok();
if norm_ok {
println!("auto-loudnorm complete: {}", norm.display());
} else {
println!("auto-loudnorm failed; falling back to raw only for take {}", take_n);
}
// Default selection: auto-leveled version (usually the user's preferred).
{
let mut s = state.lock().unwrap();
s.phase = "done".to_string();
s.finalize_path = Some(raw.clone());
s.preview_path = Some(if norm_ok { norm.clone() } else { raw.clone() });
}
let body = format!(
r#"{{"ok":true,"take":{},"rawPath":"{}","normalizedPath":"{}","normalizedOk":{}}}"#,
take_n, raw.display(), norm.display(), norm_ok
);
let _ = request.respond(json_response(200, &body));
Some(raw)
}
fn normalized_sidecar_path(output: &std::path::Path) -> PathBuf {
use std::time::{SystemTime, UNIX_EPOCH};
let stem = output.file_stem().map(|s| s.to_string_lossy().to_string()).unwrap_or_default();
let ext = output.extension().map(|e| e.to_string_lossy().to_string()).unwrap_or_else(|| "webm".to_string());
let parent = output.parent().unwrap_or_else(|| std::path::Path::new("."));
let ts = SystemTime::now().duration_since(UNIX_EPOCH).map(|d| d.as_secs()).unwrap_or(0);
parent.join(format!("{}-norm-{}.{}", stem, ts, ext))
}
fn handle_normalize(request: Request, state: &SharedState, output: &std::path::Path) -> Result<(), String> {
let source = {
let s = state.lock().unwrap();
s.preview_path.clone().unwrap_or_else(|| {
s.finalize_path.clone().unwrap_or_else(|| output.to_path_buf())
})
};
if !source.exists() {
let _ = request.respond(json_response(
400,
r#"{"ok":false,"error":"nothing to normalize; finalize a recording first"}"#,
));
return Ok(());
}
let preview = normalized_sidecar_path(output);
println!("normalizing {} -> {} (loudnorm I=-16 TP=-1.5 LRA=11)", source.display(), preview.display());
let result = Command::new("ffmpeg")
.args(["-y", "-i"])
.arg(&source)
.args([
"-af", "loudnorm=I=-16:TP=-1.5:LRA=11",
"-c:v", "copy",
"-c:a", "libopus",
"-b:a", "128k",
])
.arg(&preview)
.output();
match result {
Ok(out) if out.status.success() => {
state.lock().unwrap().preview_path = Some(preview.clone());
let body = format!(r#"{{"ok":true,"previewPath":"{}"}}"#, preview.display());
let _ = request.respond(json_response(200, &body));
}
Ok(out) => {
let stderr = String::from_utf8_lossy(&out.stderr);
let tail: Vec<&str> = stderr.lines().rev().take(10).collect();
let tail: Vec<&str> = tail.into_iter().rev().collect();
let err = tail.join("\\n").replace('"', "\\\"");
let body = format!(r#"{{"ok":false,"error":"ffmpeg loudnorm failed: {}"}}"#, err);
let _ = request.respond(json_response(500, &body));
}
Err(e) => {
let body = format!(r#"{{"ok":false,"error":"ffmpeg spawn failed: {}"}}"#, e);
let _ = request.respond(json_response(500, &body));
}
}
Ok(())
}
fn handle_waveform(
request: Request,
state: &SharedState,
output: &std::path::Path,
which: &str,
) -> Result<(), String> {
// For "raw" we want the most recent RAW take. That's the
// `finalize_path` from state (e.g. output-take-1.webm) if a take
// has been finalized this session, falling back to the canonical
// `cfg.output` only when it already exists on disk from a previous
// save-final. For "preview" we want the auto-leveled version —
// `preview_path` if set, same fallback otherwise.
let source = {
let s = state.lock().unwrap();
match which {
"raw" => s
.finalize_path
.clone()
.unwrap_or_else(|| output.to_path_buf()),
_ => s
.preview_path
.clone()
.or_else(|| s.finalize_path.clone())
.unwrap_or_else(|| output.to_path_buf()),
}
};
if !source.exists() {
let _ = request.respond(text_response(404, "audio file not found"));
return Ok(());
}
let tmp_png = std::env::temp_dir().join(format!("adom-video-post-waveform-{}.png", which));
let result = Command::new("ffmpeg")
.args(["-y", "-i"])
.arg(&source)
.args([
"-filter_complex",
"showwavespic=s=1024x144:colors=0x00b8b0|0x8C6BF7:split_channels=0",
"-frames:v", "1",
])
.arg(&tmp_png)
.output();
match result {
Ok(out) if out.status.success() => {
let bytes = match std::fs::read(&tmp_png) {
Ok(b) => b,
Err(e) => {
let _ = request.respond(text_response(500, &format!("waveform read failed: {}", e)));
return Ok(());
}
};
let resp = Response::from_data(bytes).with_header(
Header::from_bytes(&b"Content-Type"[..], &b"image/png"[..]).unwrap(),
);
let _ = request.respond(resp);
}
Ok(out) => {
let stderr = String::from_utf8_lossy(&out.stderr);
let _ = request.respond(text_response(500, &format!("ffmpeg showwavespic failed: {}", stderr)));
}
Err(e) => {
let _ = request.respond(text_response(500, &format!("ffmpeg spawn failed: {}", e)));
}
}
Ok(())
}
fn handle_timeline(
request: Request,
input: &std::path::Path,
markers_file: Option<&std::path::Path>,
) -> Result<(), String> {
let markers_path = match markers_file {
Some(p) => p,
None => {
let _ = request.respond(json_response(
200,
r#"{"ok":true,"available":false,"reason":"no markers file passed to --markers"}"#,
));
return Ok(());
}
};
if !markers_path.exists() {
let body = format!(
r#"{{"ok":true,"available":false,"reason":"markers file not found: {}"}}"#,
markers_path.display()
);
let _ = request.respond(json_response(200, &body));
return Ok(());
}
let events = match crate::markers::read_all(markers_path) {
Ok(ev) => ev,
Err(e) => {
let body = format!(
r#"{{"ok":false,"error":"could not parse markers: {}"}}"#,
e.replace('"', "\\\"")
);
let _ = request.respond(json_response(500, &body));
return Ok(());
}
};
let parsed = match crate::markers::parse(&events) {
Ok(p) => p,
Err(e) => {
let body = format!(
r#"{{"ok":false,"error":"could not parse markers: {}"}}"#,
e.replace('"', "\\\"")
);
let _ = request.respond(json_response(500, &body));
return Ok(());
}
};
let probed_input_duration = match crate::ffmpeg::probe_duration(input) {
Ok(d) => d,
Err(e) => {
let body = format!(
r#"{{"ok":false,"error":"could not probe input duration: {}"}}"#,
e.replace('"', "\\\"")
);
let _ = request.respond(json_response(500, &body));
return Ok(());
}
};
let mut saved_from_speedups = 0.0_f64;
for seg in &parsed.segments {
saved_from_speedups += seg.duration() - seg.sped_duration();
}
let raw_duration = probed_input_duration + saved_from_speedups;
let mut sections: Vec<String> = Vec::new();
let mut cursor = 0.0_f64;
let mut speedup_count = 0usize;
let mut original_speedup_dur = 0.0_f64;
let mut compressed_speedup_dur = 0.0_f64;
for seg in &parsed.segments {
if seg.start_offset > cursor + 0.001 {
sections.push(format!(
r#"{{"kind":"normal","start":{:.3},"end":{:.3},"duration":{:.3}}}"#,
cursor, seg.start_offset, seg.start_offset - cursor
));
}
let dur = seg.duration();
let sped = seg.sped_duration();
original_speedup_dur += dur;
compressed_speedup_dur += sped;
speedup_count += 1;
let label = seg.label.replace('"', "\\\"");
sections.push(format!(
r#"{{"kind":"speedup","start":{:.3},"end":{:.3},"duration":{:.3},"speed":{},"label":"{}","compressedDuration":{:.3}}}"#,
seg.start_offset, seg.end_offset, dur, seg.speed, label, sped
));
cursor = seg.end_offset;
}
if cursor < raw_duration - 0.001 {
sections.push(format!(
r#"{{"kind":"normal","start":{:.3},"end":{:.3},"duration":{:.3}}}"#,
cursor, raw_duration, raw_duration - cursor
));
}
let normal_dur = raw_duration - original_speedup_dur;
let output_duration = normal_dur + compressed_speedup_dur;
let input_duration = raw_duration;
let saved = input_duration - output_duration;
let reduction_pct = if input_duration > 0.0 { (saved / input_duration) * 100.0 } else { 0.0 };
let body = format!(
r#"{{"ok":true,"available":true,"inputDuration":{:.3},"outputDuration":{:.3},"savedSeconds":{:.3},"reductionPct":{:.2},"speedupSegmentCount":{},"segments":[{}]}}"#,
input_duration, output_duration, saved, reduction_pct, speedup_count, sections.join(",")
);
let _ = request.respond(json_response(200, &body));
Ok(())
}
fn handle_adopt_latest_audio(request: Request, state: &SharedState) -> Result<(), String> {
let home = std::env::var("HOME").unwrap_or_else(|_| "/root".to_string());
let audio_dir = PathBuf::from(home).join("project").join("audio");
let mut entries: Vec<(std::time::SystemTime, PathBuf, u64)> = Vec::new();
if let Ok(read) = std::fs::read_dir(&audio_dir) {
for e in read.flatten() {
let path = e.path();
if path.extension().and_then(|s| s.to_str()) != Some("webm") {
continue;
}
if let Ok(md) = e.metadata() {
if let Ok(mt) = md.modified() {
entries.push((mt, path, md.len()));
}
}
}
}
entries.sort_by(|a, b| b.0.cmp(&a.0));
match entries.first() {
Some((_, path, size)) => {
let mut s = state.lock().unwrap();
s.audio_path = Some(path.clone());
s.audio_size = *size;
s.phase = "recorded".to_string();
let body = format!(
r#"{{"ok":true,"path":"{}","size":{}}}"#,
path.display(),
size
);
let _ = request.respond(json_response(200, &body));
}
None => {
let _ = request.respond(json_response(
404,
&format!(r#"{{"ok":false,"error":"no audio files found in {}"}}"#, audio_dir.display()),
));
}
}
Ok(())
}
fn handle_list_takes(
request: Request,
state: &SharedState,
input: &std::path::Path,
output: &std::path::Path,
) -> Result<(), String> {
let parent = output.parent().unwrap_or_else(|| std::path::Path::new("."));
let stem = output.file_stem().map(|s| s.to_string_lossy().to_string()).unwrap_or_default();
let committed_name = format!("{}.webm", stem);
let mut takes_by_n: std::collections::BTreeMap<u32, (Option<PathBuf>, Option<PathBuf>)> = std::collections::BTreeMap::new();
let mut legacy_norms: Vec<(PathBuf, u64)> = Vec::new();
let mut committed: Option<PathBuf> = None;
if let Ok(read) = std::fs::read_dir(parent) {
for e in read.flatten() {
let path = e.path();
let name = match path.file_name().and_then(|n| n.to_str()) {
Some(s) => s.to_string(),
None => continue,
};
if path.extension().and_then(|s| s.to_str()) != Some("webm") { continue; }
if !name.starts_with(&stem) { continue; }
let take_prefix = format!("{}-take-", stem);
if let Some(rest) = name.strip_prefix(&take_prefix) {
let digits: String = rest.chars().take_while(|c| c.is_ascii_digit()).collect();
if let Ok(n) = digits.parse::<u32>() {
let after = &rest[digits.len()..];
let entry = takes_by_n.entry(n).or_insert((None, None));
if after == ".webm" {
entry.0 = Some(path);
} else if after == "-norm.webm" {
entry.1 = Some(path);
}
continue;
}
}
if name.starts_with(&format!("{}-norm-", stem)) {
if let Ok(md) = e.metadata() {
let mtime = md.modified().ok()
.and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
.map(|d| d.as_secs())
.unwrap_or(0);
legacy_norms.push((path, mtime));
}
continue;
}
if name == committed_name {
committed = Some(path);
}
}
}
let selected = {
let s = state.lock().unwrap();
s.preview_path.clone().or_else(|| s.finalize_path.clone())
};
let mut items: Vec<String> = Vec::new();
// 1. Original (synthetic) — points at cfg.input so the user can always
// go back to the silent source. input is now available via self.cfg.
let input_size = std::fs::metadata(input).map(|m| m.len()).unwrap_or(0);
items.push(format!(
r#"{{"label":"Original","kind":"original","path":"{}","size":{},"selected":false}}"#,
input.display(), input_size
));
for (n, (raw, norm)) in takes_by_n.iter() {
if let Some(p) = raw {
let is_sel = selected.as_ref().map(|s| s == p).unwrap_or(false);
let size = std::fs::metadata(p).map(|m| m.len()).unwrap_or(0);
let mtime = std::fs::metadata(p).ok()
.and_then(|m| m.modified().ok())
.and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
.map(|d| d.as_secs())
.unwrap_or(0);
items.push(format!(
r#"{{"label":"Take {}","kind":"raw","take":{},"path":"{}","size":{},"mtime":{},"selected":{}}}"#,
n, n, p.display(), size, mtime, is_sel
));
}
if let Some(p) = norm {
let is_sel = selected.as_ref().map(|s| s == p).unwrap_or(false);
let size = std::fs::metadata(p).map(|m| m.len()).unwrap_or(0);
let mtime = std::fs::metadata(p).ok()
.and_then(|m| m.modified().ok())
.and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
.map(|d| d.as_secs())
.unwrap_or(0);
items.push(format!(
r#"{{"label":"Take {} (auto-leveled)","kind":"normalized","take":{},"path":"{}","size":{},"mtime":{},"selected":{}}}"#,
n, n, p.display(), size, mtime, is_sel
));
}
}
if let Some(p) = &committed {
let is_sel = selected.as_ref().map(|s| s == p).unwrap_or(false);
let size = std::fs::metadata(p).map(|m| m.len()).unwrap_or(0);
let mtime = std::fs::metadata(p).ok()
.and_then(|m| m.modified().ok())
.and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
.map(|d| d.as_secs())
.unwrap_or(0);
items.push(format!(
r#"{{"label":"Recovered mux","kind":"committed","path":"{}","size":{},"mtime":{},"selected":{}}}"#,
p.display(), size, mtime, is_sel
));
}
legacy_norms.sort_by(|a, b| a.1.cmp(&b.1));
for (i, (p, mtime)) in legacy_norms.iter().enumerate() {
let is_sel = selected.as_ref().map(|s| s == p).unwrap_or(false);
let size = std::fs::metadata(p).map(|m| m.len()).unwrap_or(0);
items.push(format!(
r#"{{"label":"Legacy #{}","kind":"legacy","path":"{}","size":{},"mtime":{},"selected":{}}}"#,
i + 1, p.display(), size, mtime, is_sel
));
}
let body = format!(r#"{{"ok":true,"takes":[{}]}}"#, items.join(","));
let _ = request.respond(json_response(200, &body));
Ok(())
}
fn handle_select_take(mut request: Request, state: &SharedState) -> Result<(), String> {
let mut body = String::new();
request.as_reader().read_to_string(&mut body).map_err(|e| e.to_string())?;
let parsed: serde_json::Value = match serde_json::from_str(&body) {
Ok(v) => v,
Err(e) => {
let _ = request.respond(json_response(
400,
&format!(r#"{{"ok":false,"error":"invalid json: {}"}}"#, e),
));
return Ok(());
}
};
let path_str = match parsed.get("path").and_then(|v| v.as_str()) {
Some(s) => s.to_string(),
None => {
let _ = request.respond(json_response(
400,
r#"{"ok":false,"error":"missing 'path' field"}"#,
));
return Ok(());
}
};
let path = PathBuf::from(&path_str);
if !path.exists() {
let _ = request.respond(json_response(
404,
&format!(r#"{{"ok":false,"error":"file not found: {}"}}"#, path_str),
));
return Ok(());
}
state.lock().unwrap().preview_path = Some(path);
let _ = request.respond(json_response(200, r#"{"ok":true}"#));
Ok(())
}
fn handle_reveal(request: Request, state: &SharedState, output: &std::path::Path) -> Result<(), String> {
let target = {
let s = state.lock().unwrap();
s.preview_path.clone().unwrap_or_else(|| {
s.finalize_path.clone().unwrap_or_else(|| output.to_path_buf())
})
};
if !target.exists() {
let _ = request.respond(json_response(
404,
&format!(r#"{{"ok":false,"error":"no file to reveal at {}"}}"#, target.display()),
));
return Ok(());
}
let code_bin = "/usr/lib/code-server/lib/vscode/bin/remote-cli/code-server";
let result = Command::new(code_bin)
.args(["--reuse-window"])
.arg(&target)
.output();
match result {
Ok(_) => {
let body = format!(r#"{{"ok":true,"path":"{}"}}"#, target.display());
let _ = request.respond(json_response(200, &body));
}
Err(e) => {
let body = format!(r#"{{"ok":false,"error":"code-server spawn failed: {}"}}"#, e);
let _ = request.respond(json_response(500, &body));
}
}
Ok(())
}
fn handle_revert_to_raw(request: Request, state: &SharedState) -> Result<(), String> {
state.lock().unwrap().preview_path = None;
let _ = request.respond(json_response(200, r#"{"ok":true}"#));
Ok(())
}
fn handle_save_final(request: Request, state: &SharedState, output: &std::path::Path) -> Result<(), String> {
let preview = state.lock().unwrap().preview_path.clone();
match preview {
Some(p) if p.exists() => {
if let Err(e) = std::fs::copy(&p, output) {
let body = format!(r#"{{"ok":false,"error":"save failed: {}"}}"#, e);
let _ = request.respond(json_response(500, &body));
return Ok(());
}
let body = format!(r#"{{"ok":true,"path":"{}","preservedSidecar":"{}"}}"#, output.display(), p.display());
let _ = request.respond(json_response(200, &body));
}
_ => {
let body = format!(r#"{{"ok":true,"path":"{}","note":"nothing to save, output already holds the raw mux"}}"#, output.display());
let _ = request.respond(json_response(200, &body));
}
}
Ok(())
}
fn handle_replay(request: Request, state: &SharedState, output: &std::path::Path) -> Result<(), String> {
if !output.exists() {
let _ = request.respond(json_response(
404,
&format!(r#"{{"ok":false,"error":"final file not found: {}"}}"#, output.display()),
));
return Ok(());
}
{
let mut s = state.lock().unwrap();
s.phase = "done".to_string();
s.finalize_path = Some(output.to_path_buf());
}
let _ = request.respond(json_response(200, r#"{"ok":true}"#));
Ok(())
}
fn handle_discard(request: Request, state: &SharedState) -> Result<(), String> {
println!("discarding voiceover take");
let mut s = state.lock().unwrap();
s.recording = false;
s.phase = "idle".to_string();
s.audio_path = None;
s.audio_size = 0;
s.finalize_path = None;
drop(s);
let _ = request.respond(json_response(200, r#"{"ok":true}"#));
Ok(())
}
fn handle_get_state(request: Request, state: &SharedState) -> Result<(), String> {
let s = state.lock().unwrap();
let audio_path = s.audio_path.as_ref().map(|p| p.display().to_string()).unwrap_or_default();
let final_path = s.finalize_path.as_ref().map(|p| p.display().to_string()).unwrap_or_default();
let body = format!(
r#"{{"phase":"{}","recording":{},"audioPath":"{}","audioSize":{},"finalizePath":"{}"}}"#,
s.phase, s.recording, audio_path, s.audio_size, final_path
);
let _ = request.respond(json_response(200, &body));
Ok(())
}
fn text_response(status: u16, body: &str) -> Response<std::io::Cursor<Vec<u8>>> {
Response::from_string(body)
.with_status_code(status)
.with_header(
Header::from_bytes(&b"Content-Type"[..], &b"text/plain"[..]).unwrap(),
)
}
fn json_response(status: u16, body: &str) -> Response<std::io::Cursor<Vec<u8>>> {
Response::from_string(body)
.with_status_code(status)
.with_header(
Header::from_bytes(&b"Content-Type"[..], &b"application/json"[..]).unwrap(),
)
}