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.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161
//! Unified `adom-video-post app` subcommand — opens ONE Hydrogen tab
//! named "Adom Video Post" that hosts all three post-production phases
//! (Review, Narrate, Publish) behind a phase selector at the top of
//! the UI, so the user sees a single tool instead of three.
//!
//! The backend composes `StoryboardRoutes` (review/dailies) and
//! `VoiceoverRoutes` (narration/ADR) under one HTTP server. The
//! storyboard routes live at their normal top-level paths
//! (`/manifest`, `/video/:id/raw`, etc.) because they're the primary
//! data layer; voiceover routes get prefixed with `/voiceover/` so
//! we can disambiguate (`/voiceover/start-recording`,
//! `/voiceover/takes`, `/voiceover/state`, etc.). The UI fetches
//! both surfaces as needed to render the active phase.
pub mod routes;
use crate::storyboard::manifest::Manifest;
use crate::voiceover::{server::VoiceoverRoutes, VoiceoverConfig};
use crate::webapp::{run_webapp, WebappConfig};
use std::path::PathBuf;
use std::sync::atomic::AtomicU64;
use std::sync::{Arc, Mutex, RwLock};
use std::time::{Duration, SystemTime};
/// Icon placeholder shown on the Hydrogen tab for ~100ms while the
/// page loads, then overridden by the served `/favicon.svg` per
/// the hydrogen-tab-icons skill.
const TAB_ICON_PLACEHOLDER: &str = "mdi:movie-edit-outline";
pub struct AppConfig {
pub manifest_path: PathBuf,
pub port: u16,
pub tab_name: String,
pub panel_id_override: Option<String>,
pub watch: bool,
/// Skip the Hydrogen webview tab open; HTTP server only.
pub no_open: bool,
}
pub fn run(cfg: AppConfig) -> Result<(), String> {
if !cfg.manifest_path.exists() {
return Err(format!(
"manifest not found: {}. Hint: create one with `adom-video-post manifest init`, then add clips with `adom-video-post manifest add`.",
cfg.manifest_path.display()
));
}
let initial = Manifest::load(&cfg.manifest_path)?;
let title = if initial.title.is_empty() {
"Adom Video Post".to_string()
} else {
initial.title.clone()
};
// Shared storyboard state — manifest under RwLock so the watcher
// thread can swap it on mtime change, plus a version counter the
// UI polls to decide when to re-fetch.
let manifest = Arc::new(RwLock::new(initial));
let version = Arc::new(AtomicU64::new(1));
let manifest_mtime = Arc::new(Mutex::new(
std::fs::metadata(&cfg.manifest_path)
.and_then(|m| m.modified())
.unwrap_or(SystemTime::UNIX_EPOCH),
));
// File watcher — same pattern as the standalone storyboard
// subcommand.
if cfg.watch {
let manifest_for_thread = Arc::clone(&manifest);
let version_for_thread = Arc::clone(&version);
let mtime_for_thread = Arc::clone(&manifest_mtime);
let path_for_thread = cfg.manifest_path.clone();
std::thread::spawn(move || loop {
std::thread::sleep(Duration::from_millis(500));
let new_mtime = match std::fs::metadata(&path_for_thread)
.and_then(|m| m.modified())
{
Ok(t) => t,
Err(_) => continue,
};
let mut last = mtime_for_thread.lock().unwrap();
if new_mtime == *last {
continue;
}
match Manifest::load(&path_for_thread) {
Ok(fresh) => {
*manifest_for_thread.write().unwrap() = fresh;
version_for_thread
.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
*last = new_mtime;
}
Err(e) => {
eprintln!("app: manifest reload failed: {}", e);
}
}
});
}
// Derive the voiceover input: prefer manifest.final_fast_path
// (the concat output), fall back to final_raw_path. If neither
// is set yet, voiceover features are disabled until the user
// runs `adom-video-post concat` to populate one.
let voiceover_input = {
let m = manifest.read().unwrap();
m.final_fast_path.clone().or_else(|| m.final_raw_path.clone())
};
let voiceover_output_dir = voiceover_input
.as_ref()
.and_then(|p| p.parent().map(|p| p.to_path_buf()))
.unwrap_or_else(|| std::env::temp_dir());
let voiceover_output = voiceover_output_dir.join("adom-video-post-narrated.webm");
// Build a voiceover routes struct pointing at whichever final
// concat is available. The result_slot here is per-app-session,
// separate from storyboard's (storyboard doesn't produce a
// result). If the user runs a narration and finalizes, the
// narrated path lands in this slot and is displayed in the UI.
let voiceover_result_slot: Arc<Mutex<Option<Result<PathBuf, String>>>> =
Arc::new(Mutex::new(None));
let voiceover_cfg = VoiceoverConfig {
input: voiceover_input.unwrap_or_else(|| PathBuf::from("/nonexistent")),
output: voiceover_output,
// These three fields are only consumed by the voiceover
// subcommand's standalone `run()`; AppRoutes never looks at
// them but VoiceoverConfig requires them.
port: cfg.port,
tab_name: cfg.tab_name.clone(),
panel_id_override: cfg.panel_id_override.clone(),
markers_file: None,
no_open: cfg.no_open,
};
let voiceover_routes = Arc::new(VoiceoverRoutes::new(
voiceover_cfg,
voiceover_result_slot,
));
// Storyboard routes for the review phase — uses the same
// manifest + version the watcher updates.
let storyboard_routes = Arc::new(crate::storyboard::routes::StoryboardRoutes {
manifest: Arc::clone(&manifest),
version: Arc::clone(&version),
manifest_path: cfg.manifest_path.clone(),
});
let app_routes = Arc::new(routes::AppRoutes {
storyboard: storyboard_routes,
voiceover: voiceover_routes,
});
let webapp_cfg = WebappConfig {
port: cfg.port,
tab_name: cfg.tab_name,
panel_id_override: cfg.panel_id_override,
display_icon: TAB_ICON_PLACEHOLDER.to_string(),
subcommand: "app".to_string(),
title,
no_open: cfg.no_open,
};
run_webapp(webapp_cfg, app_routes)
}