From 291e2fbc3cb638773842849b57fc54e8f91246dd Mon Sep 17 00:00:00 2001 From: sloemodzn Date: Wed, 14 Oct 2026 11:15:46 +0530 Subject: [PATCH 1/2] feat(cli): add durationSeconds to project validate and inspect Emits durationSeconds for studio recordings in project validate/inspect JSON and text output, and lists export-preview in cap guide. Part of #2059 --- Cargo.lock | 1 + apps/cli/Cargo.toml | 1 + apps/cli/src/guide.rs | 6 ++++++ apps/cli/src/project.rs | 30 ++++++++++++++++++++++++++++++ 4 files changed, 38 insertions(+) diff --git a/Cargo.lock b/Cargo.lock index 31774499228..c877eb4f628 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1270,6 +1270,7 @@ dependencies = [ "cap-media-info", "cap-project", "cap-recording", + "cap-rendering", "cap-timestamp", "cap-utils", "chrono", diff --git a/apps/cli/Cargo.toml b/apps/cli/Cargo.toml index 19553497883..dc94645e5ee 100644 --- a/apps/cli/Cargo.toml +++ b/apps/cli/Cargo.toml @@ -8,6 +8,7 @@ clap = { version = "4.5.23", features = ["derive"] } clap_complete = "4.5.38" cap-project = { path = "../../crates/project" } cap-recording = { path = "../../crates/recording" } +cap-rendering = { path = "../../crates/rendering" } cap-editor = { path = "../../crates/editor" } cap-export = { path = "../../crates/export" } cap-media-info = { path = "../../crates/media-info" } diff --git a/apps/cli/src/guide.rs b/apps/cli/src/guide.rs index 5a99d462234..92e4cdcb786 100644 --- a/apps/cli/src/guide.rs +++ b/apps/cli/src/guide.rs @@ -203,6 +203,12 @@ fn build() -> Guide { &["Progress", "Completed", "Error"], ) }, + cmd( + "export-preview", + "Render a still frame from a .cap project at a given timestamp. JSON emits jpeg_base64 plus frame metadata.", + OutputMode::SingleJson, + &[], + ), cmd( "screenshot", "Capture a still of a screen/window. JSON emits {path,width,height}.", diff --git a/apps/cli/src/project.rs b/apps/cli/src/project.rs index 2d61494583f..5b0f8cdfb68 100644 --- a/apps/cli/src/project.rs +++ b/apps/cli/src/project.rs @@ -4,6 +4,7 @@ use cap_project::{ InstantRecordingMeta, RecordingMeta, RecordingMetaInner, StudioRecordingMeta, StudioRecordingStatus, }; +use cap_rendering::ProjectRecordingsMeta; use serde::Serialize; use crate::{OutputFormat, write_json}; @@ -16,6 +17,8 @@ pub struct ProjectInspection { /// camelCase convenience fields so agents never reach into the snake_case `meta` passthrough. pub name: String, pub recording_type: &'static str, + #[serde(skip_serializing_if = "Option::is_none")] + pub duration_seconds: Option, pub meta: RecordingMeta, pub config: cap_project::ProjectConfiguration, } @@ -54,6 +57,7 @@ pub fn inspect(project_path: PathBuf, format: OutputFormat) -> Result<(), String .map_err(|e| format!("Failed to load recording meta: {e}"))?; let output_path = meta.output_path(); let config = meta.project_config(); + let duration_seconds = project_duration_seconds(&project_path, &meta); match format { OutputFormat::Text => { @@ -61,6 +65,9 @@ pub fn inspect(project_path: PathBuf, format: OutputFormat) -> Result<(), String println!("name: {}", meta.pretty_name); println!("type: {}", recording_type(&meta)); println!("output: {}", output_path.display()); + if let Some(duration) = duration_seconds { + println!("duration: {duration:.2}s"); + } Ok(()) } OutputFormat::Json => write_json(&ProjectInspection { @@ -68,6 +75,7 @@ pub fn inspect(project_path: PathBuf, format: OutputFormat) -> Result<(), String output_path, name: meta.pretty_name.clone(), recording_type: recording_type(&meta), + duration_seconds, meta, config, }), @@ -89,6 +97,8 @@ struct ValidationReport { project_path: PathBuf, valid: bool, #[serde(skip_serializing_if = "Option::is_none")] + duration_seconds: Option, + #[serde(skip_serializing_if = "Option::is_none")] recording_type: Option<&'static str>, #[serde(skip_serializing_if = "Option::is_none")] error: Option, @@ -105,6 +115,21 @@ fn recording_type(meta: &RecordingMeta) -> &'static str { } } +fn project_duration_seconds(project_path: &Path, meta: &RecordingMeta) -> Option { + let RecordingMetaInner::Studio(studio) = &meta.inner else { + return None; + }; + + let recordings = ProjectRecordingsMeta::new(&project_path.to_path_buf(), studio).ok()?; + + Some(cap_rendering::get_duration( + &recordings, + meta, + studio, + &meta.project_config(), + )) +} + fn required_check(role: &'static str, path: PathBuf) -> FileCheck { FileCheck { exists: path.exists(), @@ -250,6 +275,7 @@ fn build_report(project_path: &Path, meta: &RecordingMeta) -> ValidationReport { ValidationReport { project_path: project_path.to_path_buf(), valid, + duration_seconds: project_duration_seconds(project_path, meta), recording_type: Some(recording_type(meta)), error, checks, @@ -283,6 +309,7 @@ pub fn validate(project_path: PathBuf, format: OutputFormat) -> Result<(), Strin missing: vec![project_path.join("recording-meta.json")], project_path: project_path.clone(), valid: false, + duration_seconds: None, recording_type: None, error: Some(format!("Failed to load recording meta: {e}")), problems: Vec::new(), @@ -295,6 +322,9 @@ pub fn validate(project_path: PathBuf, format: OutputFormat) -> Result<(), Strin OutputFormat::Json => write_json(&report)?, OutputFormat::Text => { println!("project: {}", report.project_path.display()); + if let Some(duration) = report.duration_seconds { + println!("duration: {duration:.2}s"); + } if let Some(error) = &report.error { println!("error: {error}"); } From cc386114afed6971275cdc412e5f9d6ef20aaf2b Mon Sep 17 00:00:00 2001 From: sloemodzn Date: Wed, 14 Oct 2026 12:20:18 +0530 Subject: [PATCH 2/2] fix(cli): report export-preview as always-JSON; pin duration tests The guide listed export-preview as SingleJson even though the command always writes JSON, and no test checked durationSeconds, so duration reporting could break silently. The entry now uses AlwaysJson. New CLI tests encode a one-second fixture and assert the reported durationSeconds on both validate and inspect, plus that unreadable media omits the field. --- apps/cli/src/guide.rs | 2 +- apps/cli/tests/cli.rs | 114 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 115 insertions(+), 1 deletion(-) diff --git a/apps/cli/src/guide.rs b/apps/cli/src/guide.rs index 92e4cdcb786..2e8899be78b 100644 --- a/apps/cli/src/guide.rs +++ b/apps/cli/src/guide.rs @@ -206,7 +206,7 @@ fn build() -> Guide { cmd( "export-preview", "Render a still frame from a .cap project at a given timestamp. JSON emits jpeg_base64 plus frame metadata.", - OutputMode::SingleJson, + OutputMode::AlwaysJson, &[], ), cmd( diff --git a/apps/cli/tests/cli.rs b/apps/cli/tests/cli.rs index 69243786bdc..bf837ceb68f 100644 --- a/apps/cli/tests/cli.rs +++ b/apps/cli/tests/cli.rs @@ -237,6 +237,67 @@ fn project_inspect_missing_fails() { assert!(stderr(&output).to_lowercase().contains("failed to load")); } +#[test] +fn project_commands_report_duration_for_readable_media() { + let dir = tempfile::tempdir().unwrap(); + let project = dir.path().join("recording.cap"); + write_test_media(&project.join("content/display.mp4")); + write_single_segment_meta(&project); + + for command in ["validate", "inspect"] { + let output = run(&[ + "project", + command, + project.to_str().unwrap(), + "--format", + "json", + ]); + assert!( + output.status.success(), + "{command} stderr: {}", + stderr(&output) + ); + let json = parse_json(&output); + let duration = json["durationSeconds"] + .as_f64() + .unwrap_or_else(|| panic!("{command} did not report durationSeconds: {json}")); + assert!( + (duration - 1.0).abs() < 0.25, + "{command} reported unexpected duration {duration}" + ); + } +} + +#[test] +fn project_commands_omit_duration_for_unreadable_media() { + let dir = tempfile::tempdir().unwrap(); + let project = dir.path().join("recording.cap"); + let content = project.join("content"); + std::fs::create_dir_all(&content).unwrap(); + std::fs::write(content.join("display.mp4"), b"fake").unwrap(); + write_single_segment_meta(&project); + + for command in ["validate", "inspect"] { + let output = run(&[ + "project", + command, + project.to_str().unwrap(), + "--format", + "json", + ]); + assert!( + output.status.success(), + "{command} stderr: {}", + stderr(&output) + ); + let json = parse_json(&output); + assert!( + json.get("durationSeconds").is_none(), + "{command} reported a duration for unreadable media: {json}" + ); + } +} + #[test] fn project_validate_missing_meta_reports_invalid() { let dir = tempfile::tempdir().unwrap(); @@ -1258,6 +1319,59 @@ fn auth_login_defaults_to_the_creator_profile() { assert!(stdout(&output).contains("[default: creator]")); } +fn write_test_media(path: &Path) { + use ffmpeg::format::Pixel; + ffmpeg::init().unwrap(); + std::fs::create_dir_all(path.parent().unwrap()).unwrap(); + let mut output = ffmpeg::format::output(path).unwrap(); + let codec = ffmpeg::encoder::find(ffmpeg::codec::Id::MPEG4).unwrap(); + let mut encoder = ffmpeg::codec::Context::new_with_codec(codec) + .encoder() + .video() + .unwrap(); + encoder.set_width(32); + encoder.set_height(32); + encoder.set_format(Pixel::YUV420P); + encoder.set_time_base((1, 30)); + encoder.set_frame_rate(Some((30, 1))); + encoder.set_flags(ffmpeg::codec::Flags::GLOBAL_HEADER); + let mut encoder = encoder.open_as(codec).unwrap(); + output.add_stream(codec).unwrap().set_parameters(&encoder); + output.write_header().unwrap(); + let output_time_base = output.stream(0).unwrap().time_base(); + { + let mut drain = |encoder: &mut ffmpeg::encoder::video::Encoder, finished: bool| loop { + let mut packet = ffmpeg::Packet::empty(); + match encoder.receive_packet(&mut packet) { + Ok(()) => { + packet.set_stream(0); + packet.rescale_ts((1, 30), output_time_base); + packet.write_interleaved(&mut output).unwrap(); + } + Err(ffmpeg::Error::Other { errno }) + if !finished && errno == ffmpeg::ffi::EAGAIN => + { + break; + } + Err(ffmpeg::Error::Eof) if finished => break, + Err(error) => panic!("test media encoding failed: {error}"), + } + }; + for index in 0..30 { + let mut frame = ffmpeg::frame::Video::new(Pixel::YUV420P, 32, 32); + frame.data_mut(0).fill(32 + index as u8); + frame.data_mut(1).fill(128); + frame.data_mut(2).fill(128); + frame.set_pts(Some(index)); + encoder.send_frame(&frame).unwrap(); + drain(&mut encoder, false); + } + encoder.send_eof().unwrap(); + drain(&mut encoder, true); + } + output.write_trailer().unwrap(); +} + fn write_single_segment_meta(project: &Path) { std::fs::create_dir_all(project).unwrap(); let meta = serde_json::json!({