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.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194
//! Storyboard manifest schema.
//!
//! A manifest is the single source of truth for a multi-clip video
//! review session. It's written incrementally by the user's demo
//! script (via `adom-video-post manifest add`, called once per step), and
//! read by every post-processing subcommand (`storyboard`, `process`,
//! `voiceover`, `publish`, `concat`).
//!
//! The demo script stays 100% custom bash/python — adom-video-post does
//! not try to execute recording logic itself. Only the handoff
//! artifact is structured.
use serde::{Deserialize, Serialize};
use std::fs;
use std::path::{Path, PathBuf};
/// Top-level manifest. `clips` is ordered — `concat` stitches them
/// in this order, `storyboard` renders them top-to-bottom, etc.
#[derive(Serialize, Deserialize, Clone, Debug, Default)]
pub struct Manifest {
/// Shown in the storyboard header and anywhere a human-readable
/// label is needed. Defaults to "Untitled" when `init` is called
/// without --title.
pub title: String,
/// Ordered list of clips. Append-only in normal usage; `remove`
/// is available for re-shoots.
#[serde(default)]
pub clips: Vec<Clip>,
/// Absolute path to the raw concat produced by `adom-video-post concat
/// --kind raw`. Set by that subcommand when it runs.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub final_raw_path: Option<PathBuf>,
/// Absolute path to the fast concat produced by `adom-video-post concat
/// --kind fast`.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub final_fast_path: Option<PathBuf>,
/// Optional publish target — where the final narrated video
/// should land when the user eventually runs `adom-video-post publish`.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub publish: Option<PublishTarget>,
}
/// One clip in the review. All paths are absolute. Any missing
/// file generates a UI warning on the affected card, never a hard
/// error, so partial / in-progress manifests work.
#[derive(Serialize, Deserialize, Clone, Debug, Default)]
pub struct Clip {
/// Stable string id — used in URLs for the storyboard's per-clip
/// `/video/:id/raw` etc. endpoints. Typically `step-01`, `step-02`.
pub id: String,
/// Human-readable title shown in the card header and timeline strip.
pub title: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
/// Raw silent recording for this step (absolute path).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub raw_path: Option<PathBuf>,
/// Post-speedup version of `raw_path` (absolute path). Produced
/// by `adom-video-post process`.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub fast_path: Option<PathBuf>,
/// Optional pre-generated poster JPG for the video card. When
/// unset, the storyboard subcommand generates one lazily on load
/// and writes the path back here.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub poster_path: Option<PathBuf>,
/// Optional screenshot (e.g. a Fusion grid capture) shown as a
/// thumbnail alongside the clip. Click-to-zoom lightbox.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub image_path: Option<PathBuf>,
/// Optional console-log PNG — a screenshot of a cmdline output
/// panel. Useful for data-heavy steps where the sped-up video
/// blurs through the actual exported values.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub console_png_path: Option<PathBuf>,
/// Zero or more speedup events that were applied (or will be
/// applied) inside this step's clip. `adom-video-post process` uses
/// them to build its ffmpeg filter graph; the storyboard UI
/// surfaces them as colored badges on the card.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub speedup_events: Vec<SpeedupEvent>,
/// Opaque action log bullets for the card's "what happened here"
/// list. Free-form strings; the script writer decides what to
/// put in them.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub actions: Vec<String>,
/// Per-card warnings surfaced in the UI (missing file, speedup
/// out of range, etc.). Populated automatically by the
/// storyboard loader when it notices problems, plus any strings
/// the manifest writer injected via `--warning`.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub warnings: Vec<String>,
/// The TTS narration script for this clip — the exact text that
/// was rendered via adom-tts / edge-tts and muxed into the audio
/// track. Surfaced in the storyboard UI under a collapsible
/// "▸ TTS narration" disclosure. Distinct from `description`,
/// which is the demo-author's INTENT for the clip; narration is
/// the spoken text the viewer will hear.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub narration: Option<String>,
}
#[derive(Serialize, Deserialize, Clone, Debug)]
pub struct SpeedupEvent {
pub speed: u32,
pub label: String,
}
#[derive(Serialize, Deserialize, Clone, Debug)]
pub struct PublishTarget {
/// Wiki page ref like `apps/adom-desktop`.
pub page: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub caption: Option<String>,
}
impl Manifest {
pub fn load(path: &Path) -> Result<Self, String> {
let body = fs::read_to_string(path)
.map_err(|e| format!("read {}: {}", path.display(), e))?;
serde_json::from_str(&body)
.map_err(|e| format!("parse {}: {}", path.display(), e))
}
/// Atomic write via `<path>.tmp` + rename. Concurrent readers
/// either see the old file or the new one, never a half-written
/// one — critical for the storyboard's file-watcher thread.
pub fn save(&self, path: &Path) -> Result<(), String> {
let tmp = path.with_extension("json.tmp");
let body = serde_json::to_string_pretty(self)
.map_err(|e| format!("serialize manifest: {}", e))?;
if let Some(parent) = path.parent() {
if !parent.as_os_str().is_empty() {
fs::create_dir_all(parent)
.map_err(|e| format!("mkdir {}: {}", parent.display(), e))?;
}
}
fs::write(&tmp, body)
.map_err(|e| format!("write {}: {}", tmp.display(), e))?;
fs::rename(&tmp, path)
.map_err(|e| format!("rename {} -> {}: {}", tmp.display(), path.display(), e))?;
Ok(())
}
pub fn init(title: &str) -> Self {
Self {
title: title.to_string(),
clips: Vec::new(),
final_raw_path: None,
final_fast_path: None,
publish: None,
}
}
}
/// Parse a `speed:label` pair as supplied on the command line
/// (e.g. `--speedup "20:Exporting gerbers"`). Returns an error
/// with a hint if the format is wrong.
pub fn parse_speedup_arg(s: &str) -> Result<SpeedupEvent, String> {
let (speed_str, label) = s
.split_once(':')
.ok_or_else(|| format!(
"--speedup must be formatted as 'SPEED:LABEL', got {:?}. Hint: try '20:Exporting gerbers'",
s
))?;
let speed: u32 = speed_str
.parse()
.map_err(|_| format!("--speedup speed must be an integer, got {:?}", speed_str))?;
if !(2..=100).contains(&speed) {
return Err(format!(
"--speedup speed must be in [2, 100], got {}. Hint: anything above 100x looks like a glitch",
speed
));
}
Ok(SpeedupEvent {
speed,
label: label.to_string(),
})
}