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.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402
//! `impl Routes` for the storyboard subcommand.
//!
//! Application-specific endpoints:
//! GET /state — version counter + clip count + title (for UI polling)
//! GET /manifest — the full manifest JSON (UI fetches on load + on version bump)
//! GET /video/:id/raw — range-streamed raw clip
//! GET /video/:id/fast — range-streamed fast clip
//! GET /poster/:id — first-frame JPG (generated lazily if missing)
//! GET /image/:id — optional screenshot PNG
//! GET /console-png/:id — optional console-output PNG
//! GET /final-video/raw — concatenated final raw webm
//! GET /final-video/fast — concatenated final fast webm
use super::manifest::{Clip, Manifest};
use crate::ffmpeg;
use crate::webapp::{video as webapp_video, RouteOutcome, Routes};
use std::path::PathBuf;
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::{Arc, RwLock};
use tiny_http::{Header, Method, Request, Response};
const UI_HTML: &str = include_str!("ui.html");
const FAVICON_SVG: &str = include_str!("icon.svg");
pub struct StoryboardRoutes {
pub manifest: Arc<RwLock<Manifest>>,
pub version: Arc<AtomicU64>,
/// Path to the backing manifest file on disk. Needed because
/// `POST /concat` mutates the manifest and persists the new
/// `final_fast_path` / `final_raw_path` back to the same file
/// the file-watcher is monitoring.
pub manifest_path: std::path::PathBuf,
}
impl StoryboardRoutes {
/// Look up a clip by id and return a clone (so the lock can be
/// released immediately).
fn find_clip(&self, id: &str) -> Option<Clip> {
self.manifest
.read()
.unwrap()
.clips
.iter()
.find(|c| c.id == id)
.cloned()
}
}
impl Routes for StoryboardRoutes {
fn ui_html(&self) -> &str {
UI_HTML
}
fn favicon_svg(&self) -> &str {
FAVICON_SVG
}
fn handle(&self, request: Request, method: &Method, url: &str) -> RouteOutcome {
// Exact matches first.
match (method, url) {
(Method::Get, "/state") => return RouteOutcome::Handled(self.handle_state(request)),
(Method::Get, "/manifest") => {
return RouteOutcome::Handled(self.handle_manifest(request));
}
(Method::Post, "/concat") => {
return RouteOutcome::Handled(self.handle_concat(request));
}
_ => {}
}
// Dynamic (prefix-based) GET routes. Every one below takes
// ownership of `request`, so we commit to the branch as soon
// as its prefix matches.
if let Method::Get = method {
if let Some(rest) = url.strip_prefix("/video/") {
if let Some((id, kind)) = rest.rsplit_once('/') {
if kind == "raw" || kind == "fast" {
return RouteOutcome::Handled(self.handle_video_for(request, id, kind));
}
}
}
if let Some(kind) = url.strip_prefix("/final-video/") {
if kind == "raw" || kind == "fast" {
return RouteOutcome::Handled(self.handle_final_video(request, kind));
}
}
if let Some(id) = url.strip_prefix("/poster/") {
return RouteOutcome::Handled(self.handle_poster_for(request, id));
}
if let Some(id) = url.strip_prefix("/image/") {
return RouteOutcome::Handled(self.handle_image_for(request, id));
}
if let Some(id) = url.strip_prefix("/console-png/") {
return RouteOutcome::Handled(self.handle_console_png_for(request, id));
}
}
RouteOutcome::NotHandled(request)
}
}
impl StoryboardRoutes {
fn handle_state(&self, request: Request) -> Result<(), String> {
let m = self.manifest.read().unwrap();
let version = self.version.load(Ordering::Relaxed);
let body = format!(
r#"{{"version":{},"clipCount":{},"title":{}}}"#,
version,
m.clips.len(),
serde_json::to_string(&m.title).unwrap()
);
request
.respond(json_response(200, &body))
.map_err(|e| e.to_string())
}
fn handle_manifest(&self, request: Request) -> Result<(), String> {
let m = self.manifest.read().unwrap();
// Serialize manifest, then enrich each clip with `mtime_ms` of its
// raw_path / fast_path so the UI can highlight "NEW since you last
// saw this page" — fresh re-shoots stand out without forcing the
// human reviewer to re-watch every clip.
let mut v = serde_json::to_value(&*m).map_err(|e| e.to_string())?;
if let Some(clips) = v.get_mut("clips").and_then(|c| c.as_array_mut()) {
for clip in clips {
let raw_mtime = clip
.get("raw_path")
.and_then(|p| p.as_str())
.and_then(|p| std::fs::metadata(p).ok())
.and_then(|md| md.modified().ok())
.and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
.map(|d| d.as_millis() as u64);
let fast_mtime = clip
.get("fast_path")
.and_then(|p| p.as_str())
.and_then(|p| std::fs::metadata(p).ok())
.and_then(|md| md.modified().ok())
.and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
.map(|d| d.as_millis() as u64);
if let Some(t) = raw_mtime {
clip["raw_mtime_ms"] = serde_json::json!(t);
}
if let Some(t) = fast_mtime {
clip["fast_mtime_ms"] = serde_json::json!(t);
}
}
}
let body = serde_json::to_string(&v).map_err(|e| e.to_string())?;
request
.respond(json_response(200, &body))
.map_err(|e| e.to_string())
}
/// `POST /concat` — stitch every clip's `fast_path` into a single
/// `final_fast_path`, write the result back to the manifest file,
/// and bump the version so the UI re-fetches. Output lands next
/// to the manifest file, named after the manifest title slug.
/// Returns `{ok, path, clipCount}` on success, `{ok:false, error}`
/// on failure.
///
/// This is the endpoint the "Prepare for narration" button in
/// the unified `adom-video-post app` UI hits to enable the Narrate
/// phase without the user having to drop back to the CLI.
fn handle_concat(&self, request: Request) -> Result<(), String> {
let inputs: Vec<std::path::PathBuf> = {
let m = self.manifest.read().unwrap();
m.clips
.iter()
.filter_map(|c| c.fast_path.clone())
.filter(|p| p.exists())
.collect()
};
if inputs.is_empty() {
let body = r#"{"ok":false,"error":"no fast clips in manifest — every clip needs a fast_path pointing at an existing file"}"#;
return request
.respond(json_response(400, body))
.map_err(|e| e.to_string());
}
// Derive an output path next to the manifest file so it
// lives in a predictable location without the caller having
// to pick one.
let parent = self
.manifest_path
.parent()
.unwrap_or_else(|| std::path::Path::new("."));
let title_slug: String = {
let m = self.manifest.read().unwrap();
m.title
.chars()
.map(|c| if c.is_ascii_alphanumeric() { c.to_ascii_lowercase() } else { '-' })
.collect::<String>()
.trim_matches('-')
.to_string()
};
let slug = if title_slug.is_empty() {
"adom-video-post".to_string()
} else {
title_slug
};
let output = parent.join(format!("{}-final-fast.webm", slug));
println!(
"concat: {} clips -> {}",
inputs.len(),
output.display()
);
if let Err(e) = crate::ffmpeg::concat_videos(&inputs, &output) {
let body = format!(
r#"{{"ok":false,"error":"ffmpeg concat failed: {}"}}"#,
e.replace('"', "\\\"")
);
return request
.respond(json_response(500, &body))
.map_err(|e| e.to_string());
}
// Persist to the manifest and bump the version.
{
let mut m = self.manifest.write().unwrap();
m.final_fast_path = Some(output.clone());
if let Err(e) = m.save(&self.manifest_path) {
let body = format!(
r#"{{"ok":false,"error":"manifest save failed: {}"}}"#,
e.replace('"', "\\\"")
);
return request
.respond(json_response(500, &body))
.map_err(|e| e.to_string());
}
}
self.version
.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
let body = format!(
r#"{{"ok":true,"path":"{}","clipCount":{}}}"#,
output.display().to_string().replace('"', "\\\""),
inputs.len()
);
request
.respond(json_response(200, &body))
.map_err(|e| e.to_string())
}
fn handle_video_for(&self, request: Request, id: &str, kind: &str) -> Result<(), String> {
let clip = match self.find_clip(id) {
Some(c) => c,
None => {
let _ = request.respond(text_response(404, &format!("no clip with id {:?}", id)));
return Ok(());
}
};
let path = match kind {
"raw" => clip.raw_path,
"fast" => clip.fast_path,
_ => None,
};
let Some(path) = path else {
let _ = request.respond(text_response(
404,
&format!("clip {:?} has no {} path", id, kind),
));
return Ok(());
};
if !path.exists() {
let _ = request.respond(text_response(
404,
&format!("clip {:?} {} file missing on disk: {}", id, kind, path.display()),
));
return Ok(());
}
webapp_video::serve_file_range(request, &path, "video/webm")
}
fn handle_poster_for(&self, request: Request, id: &str) -> Result<(), String> {
// Look up the clip and figure out which source we can use
// to generate a poster from. Prefer fast_path (already
// processed, smaller, faster to seek), fall back to raw.
let clip = match self.find_clip(id) {
Some(c) => c,
None => {
let _ = request.respond(text_response(404, &format!("no clip with id {:?}", id)));
return Ok(());
}
};
if let Some(p) = &clip.poster_path {
if p.exists() {
return webapp_video::serve_file_range(request, p, "image/jpeg");
}
}
let source = clip
.fast_path
.as_ref()
.filter(|p| p.exists())
.or_else(|| clip.raw_path.as_ref().filter(|p| p.exists()));
let Some(source) = source else {
let _ = request.respond(text_response(
404,
&format!("clip {:?} has no video path to generate poster from", id),
));
return Ok(());
};
// Generate lazily next to the source video.
let dest: PathBuf = source.with_extension("poster.jpg");
if !dest.exists() {
if let Err(e) = ffmpeg::generate_poster(source, &dest) {
let _ = request.respond(text_response(500, &format!("poster generation failed: {}", e)));
return Ok(());
}
}
// Write the generated path back into the manifest so a reload
// picks it up. Best-effort — a write failure is not fatal.
{
let mut m = self.manifest.write().unwrap();
if let Some(c) = m.clips.iter_mut().find(|c| c.id == id) {
c.poster_path = Some(dest.clone());
}
}
webapp_video::serve_file_range(request, &dest, "image/jpeg")
}
fn handle_image_for(&self, request: Request, id: &str) -> Result<(), String> {
let clip = match self.find_clip(id) {
Some(c) => c,
None => {
let _ = request.respond(text_response(404, &format!("no clip with id {:?}", id)));
return Ok(());
}
};
let Some(path) = clip.image_path else {
let _ = request.respond(text_response(404, &format!("clip {:?} has no image_path", id)));
return Ok(());
};
if !path.exists() {
let _ = request.respond(text_response(
404,
&format!("clip {:?} image missing: {}", id, path.display()),
));
return Ok(());
}
webapp_video::serve_file_range(request, &path, "image/png")
}
fn handle_console_png_for(&self, request: Request, id: &str) -> Result<(), String> {
let clip = match self.find_clip(id) {
Some(c) => c,
None => {
let _ = request.respond(text_response(404, &format!("no clip with id {:?}", id)));
return Ok(());
}
};
let Some(path) = clip.console_png_path else {
let _ = request.respond(text_response(404, &format!("clip {:?} has no console_png_path", id)));
return Ok(());
};
if !path.exists() {
let _ = request.respond(text_response(
404,
&format!("clip {:?} console-png missing: {}", id, path.display()),
));
return Ok(());
}
webapp_video::serve_file_range(request, &path, "image/png")
}
fn handle_final_video(&self, request: Request, kind: &str) -> Result<(), String> {
let m = self.manifest.read().unwrap();
let path = match kind {
"raw" => m.final_raw_path.clone(),
"fast" => m.final_fast_path.clone(),
_ => None,
};
drop(m);
let Some(path) = path else {
let _ = request.respond(text_response(
404,
&format!("no final_{}_path in manifest", kind),
));
return Ok(());
};
if !path.exists() {
let _ = request.respond(text_response(
404,
&format!("final {} file missing: {}", kind, path.display()),
));
return Ok(());
}
webapp_video::serve_file_range(request, &path, "video/webm")
}
}
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())
}
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())
}