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.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420
//! `adom-video-post list` and `adom-video-post ctrl` — AI control surface for
//! running webapp instances.
//!
//! Every `run_webapp` invocation writes a `~/.adom/adom-video-post/instances/
//! {pid}.json` file on startup and deletes it on shutdown. `list` reads
//! that directory, filters out stale PIDs (processes that died without
//! cleaning up), and prints a table. `ctrl` resolves a symbolic target
//! (`voiceover` / `storyboard` / `latest` / port / pid) to a concrete
//! port and dispatches one of a handful of actions against that
//! instance's HTTP surface.
//!
//! Supported `ctrl` actions:
//! state GET /state → print JSON
//! console GET /console[?since=N&wait=MS] → print messages
//! --tail N print only the last N
//! --since SEQ only messages whose seq > SEQ
//! --follow long-poll (blocking, exits on Ctrl-C)
//! eval <code> POST /eval + poll result (same as `adom-video-post eval`)
//! shutdown POST /shutdown
//! call M PATH raw HTTP call, optional --body JSON
//!
//! All HTTP calls are done via curl so there's no extra dep.
use crate::webapp::registry;
use std::fs;
use std::process::Command;
/// One live instance's registry record.
#[derive(Debug, Clone)]
pub struct Instance {
pub pid: u32,
pub port: u16,
pub subcommand: String,
pub title: String,
pub started_at: u64,
}
/// Read every `*.json` in the instances directory, parse it, filter
/// out stale entries whose PID is dead, remove those stale files
/// from disk, and return the survivors sorted by started_at
/// ascending (oldest first).
pub fn list_instances() -> Result<Vec<Instance>, String> {
let dir = registry::instances_dir()?;
if !dir.exists() {
return Ok(Vec::new());
}
let mut out: Vec<Instance> = Vec::new();
let entries = fs::read_dir(&dir)
.map_err(|e| format!("read_dir {}: {}", dir.display(), e))?;
for entry in entries.flatten() {
let path = entry.path();
if path.extension().and_then(|s| s.to_str()) != Some("json") {
continue;
}
let body = match fs::read_to_string(&path) {
Ok(b) => b,
Err(_) => continue,
};
let parsed: serde_json::Value = match serde_json::from_str(&body) {
Ok(v) => v,
Err(_) => continue,
};
let pid = parsed.get("pid").and_then(|v| v.as_u64()).unwrap_or(0) as u32;
if pid == 0 {
continue;
}
if !pid_alive(pid) {
// Stale entry — clean it up so the next list() is faster.
let _ = fs::remove_file(&path);
continue;
}
out.push(Instance {
pid,
port: parsed.get("port").and_then(|v| v.as_u64()).unwrap_or(0) as u16,
subcommand: parsed
.get("subcommand")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string(),
title: parsed
.get("title")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string(),
started_at: parsed
.get("startedAt")
.and_then(|v| v.as_u64())
.unwrap_or(0),
});
}
out.sort_by_key(|i| i.started_at);
Ok(out)
}
/// Check whether a process with the given PID is still alive.
/// Uses `kill -0 <pid>` which sends the null signal — it doesn't
/// actually signal the process, it just validates whether the
/// signal COULD be delivered (i.e., the PID exists and we have
/// permission).
fn pid_alive(pid: u32) -> bool {
Command::new("kill")
.args(["-0", &pid.to_string()])
.output()
.map(|o| o.status.success())
.unwrap_or(false)
}
/// Resolve a user-supplied target string to a concrete instance.
///
/// Resolution order:
/// 1. "latest" → most recently started instance
/// 2. "<subcommand>" → first instance whose subcommand matches
/// 3. "<port>" → instance listening on that port
/// 4. "pid:<pid>" → instance with that exact PID
/// 5. numeric 0..=65535 → treated as a port (case 3)
/// 6. numeric > 65535 → treated as a pid (case 4)
pub fn resolve_target(target: &str) -> Result<Instance, String> {
let instances = list_instances()?;
if instances.is_empty() {
return Err(format!(
"no running adom-video-post instances found. Hint: start one with `adom-video-post voiceover --input ...` or `adom-video-post storyboard <manifest>`."
));
}
if target == "latest" {
return instances.last().cloned().ok_or_else(|| "no instances".to_string());
}
if let Some(pid_str) = target.strip_prefix("pid:") {
let pid: u32 = pid_str
.parse()
.map_err(|_| format!("invalid pid in target {:?}", target))?;
return instances
.into_iter()
.find(|i| i.pid == pid)
.ok_or_else(|| format!("no running instance with pid {}", pid));
}
// Numeric: port (<=65535) or pid (>65535).
if let Ok(n) = target.parse::<u64>() {
if n <= 65535 {
let port = n as u16;
return instances
.into_iter()
.find(|i| i.port == port)
.ok_or_else(|| format!("no running instance on port {}", port));
}
let pid = n as u32;
return instances
.into_iter()
.find(|i| i.pid == pid)
.ok_or_else(|| format!("no running instance with pid {}", pid));
}
// String: subcommand match (case-insensitive).
let t = target.to_lowercase();
instances
.into_iter()
.find(|i| i.subcommand.to_lowercase() == t)
.ok_or_else(|| {
format!(
"no running instance matching {:?}. Hint: run `adom-video-post list` to see what is running.",
target
)
})
}
/// `adom-video-post list` — print the registry as a human-readable table.
pub fn cmd_list() -> Result<(), String> {
let instances = list_instances()?;
if instances.is_empty() {
println!("no adom-video-post instances running");
return Ok(());
}
println!(
"{:<7} {:<6} {:<12} {:<40} STARTED",
"PID", "PORT", "SUBCOMMAND", "TITLE"
);
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0);
for i in &instances {
let age = now.saturating_sub(i.started_at);
let age_str = human_duration(age);
let title = if i.title.len() > 38 {
format!("{}…", &i.title[..37])
} else {
i.title.clone()
};
println!(
"{:<7} {:<6} {:<12} {:<40} {} ago",
i.pid, i.port, i.subcommand, title, age_str
);
}
Ok(())
}
fn human_duration(secs: u64) -> String {
if secs < 60 {
format!("{}s", secs)
} else if secs < 3600 {
format!("{}m", secs / 60)
} else if secs < 86400 {
format!("{}h", secs / 3600)
} else {
format!("{}d", secs / 86400)
}
}
/// `adom-video-post ctrl <target> <action> [args...]` dispatcher.
pub fn cmd_ctrl(target: &str, action: &CtrlAction) -> Result<(), String> {
let instance = resolve_target(target)?;
let base = format!("http://127.0.0.1:{}", instance.port);
eprintln!(
"→ {} (pid {}, port {})",
instance.subcommand, instance.pid, instance.port
);
match action {
CtrlAction::State => ctrl_state(&base),
CtrlAction::Console { tail, since, follow } => ctrl_console(&base, *tail, *since, *follow),
CtrlAction::Eval { code, timeout } => ctrl_eval(&base, code, *timeout),
CtrlAction::Shutdown => ctrl_shutdown(&base),
CtrlAction::Call { method, path, body } => ctrl_call(&base, method, path, body.as_deref()),
}
}
pub enum CtrlAction {
State,
Console {
tail: Option<usize>,
since: Option<u64>,
follow: bool,
},
Eval {
code: String,
timeout: u64,
},
Shutdown,
Call {
method: String,
path: String,
body: Option<String>,
},
}
fn ctrl_state(base: &str) -> Result<(), String> {
let out = curl_text("GET", &format!("{}/state", base), None, 5)?;
let parsed: serde_json::Value =
serde_json::from_str(&out).map_err(|e| format!("parse /state: {} ({})", e, out))?;
println!("{}", serde_json::to_string_pretty(&parsed).unwrap_or(out));
Ok(())
}
fn ctrl_console(
base: &str,
tail: Option<usize>,
since: Option<u64>,
follow: bool,
) -> Result<(), String> {
let mut since = since.unwrap_or(0);
loop {
let qs = if follow {
format!("?since={}&wait=25000", since)
} else {
format!("?since={}", since)
};
let url = format!("{}/console{}", base, qs);
// When following we allow the long-poll to block for ~25s.
let timeout = if follow { 30 } else { 10 };
let body = curl_text("GET", &url, None, timeout)?;
let parsed: serde_json::Value =
serde_json::from_str(&body).map_err(|e| format!("parse /console: {} ({})", e, body))?;
let seq = parsed.get("seq").and_then(|v| v.as_u64()).unwrap_or(since);
let msgs = parsed
.get("messages")
.and_then(|v| v.as_array())
.cloned()
.unwrap_or_default();
let start = match tail {
Some(n) if msgs.len() > n && !follow => msgs.len() - n,
_ => 0,
};
for m in &msgs[start..] {
println!("{}", m);
}
if !follow {
return Ok(());
}
// In follow mode, advance `since` past what we just printed so
// the next long-poll only wakes on truly-new messages. If the
// long-poll returned 204 (empty), curl_text returns "" and we
// just loop.
if seq > since {
since = seq;
}
}
}
fn ctrl_eval(base: &str, code: &str, timeout: u64) -> Result<(), String> {
// POST the snippet.
let post_body = serde_json::json!({ "code": code }).to_string();
let post = curl_text("POST", &format!("{}/eval", base), Some(&post_body), 5)?;
let parsed: serde_json::Value =
serde_json::from_str(&post).map_err(|e| format!("parse /eval response: {} ({})", e, post))?;
let id = parsed
.get("id")
.and_then(|v| v.as_str())
.ok_or_else(|| format!("/eval missing 'id': {}", post))?
.to_string();
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(timeout);
loop {
let (body, status) = curl_text_with_status(
"GET",
&format!("{}/eval/{}/result", base, id),
None,
30,
)?;
if status == 200 {
let result: serde_json::Value = serde_json::from_str(&body)
.map_err(|e| format!("parse result: {} ({})", e, body))?;
println!("{}", serde_json::to_string_pretty(&result).unwrap_or(body));
let ok = result.get("ok").and_then(|v| v.as_bool()).unwrap_or(false);
if ok {
return Ok(());
}
return Err(result
.get("error")
.and_then(|v| v.as_str())
.unwrap_or("eval returned ok:false")
.to_string());
}
if status != 204 {
return Err(format!("unexpected status {}: {}", status, body));
}
if std::time::Instant::now() >= deadline {
return Err(format!("timed out after {}s waiting for eval result", timeout));
}
}
}
fn ctrl_shutdown(base: &str) -> Result<(), String> {
let body = curl_text("POST", &format!("{}/shutdown", base), None, 5)?;
println!("{}", body.trim());
Ok(())
}
fn ctrl_call(
base: &str,
method: &str,
path: &str,
body: Option<&str>,
) -> Result<(), String> {
let full = if path.starts_with('/') {
format!("{}{}", base, path)
} else {
format!("{}/{}", base, path)
};
let out = curl_text(method, &full, body, 30)?;
println!("{}", out);
Ok(())
}
/// Shell out to curl for a simple HTTP call. Returns the response
/// body on a 2xx, errors otherwise.
fn curl_text(
method: &str,
url: &str,
body: Option<&str>,
timeout_secs: u64,
) -> Result<String, String> {
let (resp, status) = curl_text_with_status(method, url, body, timeout_secs)?;
if !(200..300).contains(&status) && status != 204 {
return Err(format!("HTTP {} from {}: {}", status, url, resp));
}
Ok(resp)
}
/// Like `curl_text` but returns (body, status_code) so the caller
/// can handle 204 vs 200 branching explicitly (used by /eval polling).
fn curl_text_with_status(
method: &str,
url: &str,
body: Option<&str>,
timeout_secs: u64,
) -> Result<(String, u16), String> {
let mut cmd = Command::new("curl");
cmd.args([
"-s",
"-X",
method,
"-w",
"\n%{http_code}",
"--max-time",
&timeout_secs.to_string(),
]);
if let Some(b) = body {
cmd.args(["-H", "Content-Type: application/json", "-d", b]);
}
cmd.arg(url);
let out = cmd
.output()
.map_err(|e| format!("curl spawn failed: {}", e))?;
let raw = String::from_utf8_lossy(&out.stdout).to_string();
let (body, status_line) = raw
.rsplit_once('\n')
.map(|(b, s)| (b.to_string(), s.to_string()))
.unwrap_or_else(|| (raw.clone(), "000".to_string()));
let status: u16 = status_line.trim().parse().unwrap_or(0);
if status == 0 {
return Err(format!(
"curl {} {} failed: {}",
method,
url,
String::from_utf8_lossy(&out.stderr)
));
}
Ok((body, status))
}