Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions apps/cli/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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" }
Expand Down
6 changes: 6 additions & 0 deletions apps/cli/src/guide.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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::AlwaysJson,
&[],
),
Comment thread
sloemo01 marked this conversation as resolved.
cmd(
"screenshot",
"Capture a still of a screen/window. JSON emits {path,width,height}.",
Expand Down
30 changes: 30 additions & 0 deletions apps/cli/src/project.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ use cap_project::{
InstantRecordingMeta, RecordingMeta, RecordingMetaInner, StudioRecordingMeta,
StudioRecordingStatus,
};
use cap_rendering::ProjectRecordingsMeta;
use serde::Serialize;

use crate::{OutputFormat, write_json};
Expand All @@ -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<f64>,
pub meta: RecordingMeta,
pub config: cap_project::ProjectConfiguration,
}
Expand Down Expand Up @@ -54,20 +57,25 @@ 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 => {
println!("project: {}", project_path.display());
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 {
project_path,
output_path,
name: meta.pretty_name.clone(),
recording_type: recording_type(&meta),
duration_seconds,
meta,
config,
}),
Expand All @@ -89,6 +97,8 @@ struct ValidationReport {
project_path: PathBuf,
valid: bool,
#[serde(skip_serializing_if = "Option::is_none")]
duration_seconds: Option<f64>,
#[serde(skip_serializing_if = "Option::is_none")]
recording_type: Option<&'static str>,
#[serde(skip_serializing_if = "Option::is_none")]
error: Option<String>,
Expand All @@ -105,6 +115,21 @@ fn recording_type(meta: &RecordingMeta) -> &'static str {
}
}

fn project_duration_seconds(project_path: &Path, meta: &RecordingMeta) -> Option<f64> {
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(),
))
Comment thread
sloemo01 marked this conversation as resolved.
}

fn required_check(role: &'static str, path: PathBuf) -> FileCheck {
FileCheck {
exists: path.exists(),
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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(),
Expand All @@ -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}");
}
Expand Down
114 changes: 114 additions & 0 deletions apps/cli/tests/cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -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!({
Expand Down
Loading