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.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899
//! Voiceover subcommand — phase 2 of adom-video-post.
//!
//! Plays the input (sped-up) video back in a Hydrogen webview tab,
//! captures mic audio via Hydrogen's first-class audio API while
//! the user narrates, then muxes the audio with the video to produce
//! a narrated output. The HTTP plumbing is now in `crate::webapp`;
//! this file is the thin wrapper that owns the voiceover-specific
//! config + result slot and hands everything over to `run_webapp`.
pub mod server;
use crate::webapp::{run_webapp, WebappConfig};
use std::path::PathBuf;
use std::process::Command;
use std::sync::{Arc, Mutex};
/// Pre-load placeholder icon. Shown on the Hydrogen tab for ~100ms
/// while the UI HTML loads, then swapped for `src/voiceover/icon.svg`
/// via the `<link rel="icon">` the page ships (see the
/// hydrogen-tab-icons skill). MDI gets the right theme color
/// automatically so the placeholder and the served favicon look
/// nearly identical and the swap is invisible.
const TAB_ICON_PLACEHOLDER: &str = "mdi:microphone-variant";
pub struct VoiceoverConfig {
pub input: PathBuf,
pub output: PathBuf,
pub port: u16,
pub tab_name: String,
pub panel_id_override: Option<String>,
pub markers_file: Option<PathBuf>,
/// Skip the Hydrogen webview tab open; HTTP server only.
pub no_open: bool,
}
/// Run the voiceover flow end-to-end. Blocks until the user finalizes
/// a take and hits /shutdown (or an error occurs), then returns the
/// path to the final muxed video.
pub fn run(cfg: VoiceoverConfig) -> Result<PathBuf, String> {
if !cfg.input.exists() {
return Err(format!(
"input video not found: {}. Hint: run `adom-video-post process` first to produce a sped-up video.",
cfg.input.display()
));
}
let title = cfg
.input
.file_name()
.map(|n| n.to_string_lossy().to_string())
.unwrap_or_else(|| "voiceover".to_string());
let webapp_cfg = WebappConfig {
port: cfg.port,
tab_name: cfg.tab_name.clone(),
panel_id_override: cfg.panel_id_override.clone(),
display_icon: TAB_ICON_PLACEHOLDER.to_string(),
subcommand: "voiceover".to_string(),
title,
no_open: cfg.no_open,
};
let result_slot: Arc<Mutex<Option<Result<PathBuf, String>>>> = Arc::new(Mutex::new(None));
let routes = Arc::new(server::VoiceoverRoutes::new(cfg, result_slot.clone()));
run_webapp(webapp_cfg, routes)?;
let final_result = result_slot.lock().unwrap().take();
match final_result {
Some(Ok(path)) => Ok(path),
Some(Err(e)) => Err(e),
None => Err("voiceover session ended without producing a final video".to_string()),
}
}
/// Mux the recorded voiceover audio with the input video.
/// Output is video-only with the new audio track.
pub fn mux(
input_video: &std::path::Path,
voiceover_audio: &std::path::Path,
output: &std::path::Path,
) -> Result<(), String> {
let result = Command::new("ffmpeg")
.args(["-y", "-i"])
.arg(input_video)
.args(["-i"])
.arg(voiceover_audio)
.args(["-c:v", "copy", "-c:a", "libopus", "-shortest"])
.arg(output)
.output()
.map_err(|e| format!("ffmpeg spawn failed: {}", e))?;
if !result.status.success() {
let stderr = String::from_utf8_lossy(&result.stderr);
let tail: Vec<&str> = stderr.lines().rev().take(20).collect();
let tail: Vec<&str> = tail.into_iter().rev().collect();
return Err(format!("ffmpeg mux failed:\n{}", tail.join("\n")));
}
Ok(())
}