Skip to content

Commit 6dc25be

Browse files
committed
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.
1 parent b388df1 commit 6dc25be

2 files changed

Lines changed: 115 additions & 1 deletion

File tree

‎apps/cli/src/guide.rs‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -206,7 +206,7 @@ fn build() -> Guide {
206206
cmd(
207207
"export-preview",
208208
"Render a still frame from a .cap project at a given timestamp. JSON emits jpeg_base64 plus frame metadata.",
209-
OutputMode::SingleJson,
209+
OutputMode::AlwaysJson,
210210
&[],
211211
),
212212
cmd(

‎apps/cli/tests/cli.rs‎

Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -237,6 +237,67 @@ fn project_inspect_missing_fails() {
237237
assert!(stderr(&output).to_lowercase().contains("failed to load"));
238238
}
239239

240+
#[test]
241+
fn project_commands_report_duration_for_readable_media() {
242+
let dir = tempfile::tempdir().unwrap();
243+
let project = dir.path().join("recording.cap");
244+
write_test_media(&project.join("content/display.mp4"));
245+
write_single_segment_meta(&project);
246+
247+
for command in ["validate", "inspect"] {
248+
let output = run(&[
249+
"project",
250+
command,
251+
project.to_str().unwrap(),
252+
"--format",
253+
"json",
254+
]);
255+
assert!(
256+
output.status.success(),
257+
"{command} stderr: {}",
258+
stderr(&output)
259+
);
260+
let json = parse_json(&output);
261+
let duration = json["durationSeconds"]
262+
.as_f64()
263+
.unwrap_or_else(|| panic!("{command} did not report durationSeconds: {json}"));
264+
assert!(
265+
(duration - 1.0).abs() < 0.25,
266+
"{command} reported unexpected duration {duration}"
267+
);
268+
}
269+
}
270+
271+
#[test]
272+
fn project_commands_omit_duration_for_unreadable_media() {
273+
let dir = tempfile::tempdir().unwrap();
274+
let project = dir.path().join("recording.cap");
275+
let content = project.join("content");
276+
std::fs::create_dir_all(&content).unwrap();
277+
std::fs::write(content.join("display.mp4"), b"fake").unwrap();
278+
write_single_segment_meta(&project);
279+
280+
for command in ["validate", "inspect"] {
281+
let output = run(&[
282+
"project",
283+
command,
284+
project.to_str().unwrap(),
285+
"--format",
286+
"json",
287+
]);
288+
assert!(
289+
output.status.success(),
290+
"{command} stderr: {}",
291+
stderr(&output)
292+
);
293+
let json = parse_json(&output);
294+
assert!(
295+
json.get("durationSeconds").is_none(),
296+
"{command} reported a duration for unreadable media: {json}"
297+
);
298+
}
299+
}
300+
240301
#[test]
241302
fn project_validate_missing_meta_reports_invalid() {
242303
let dir = tempfile::tempdir().unwrap();
@@ -1228,6 +1289,59 @@ fn auth_login_defaults_to_the_creator_profile() {
12281289
assert!(stdout(&output).contains("[default: creator]"));
12291290
}
12301291

1292+
fn write_test_media(path: &Path) {
1293+
use ffmpeg::format::Pixel;
1294+
ffmpeg::init().unwrap();
1295+
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
1296+
let mut output = ffmpeg::format::output(path).unwrap();
1297+
let codec = ffmpeg::encoder::find(ffmpeg::codec::Id::MPEG4).unwrap();
1298+
let mut encoder = ffmpeg::codec::Context::new_with_codec(codec)
1299+
.encoder()
1300+
.video()
1301+
.unwrap();
1302+
encoder.set_width(32);
1303+
encoder.set_height(32);
1304+
encoder.set_format(Pixel::YUV420P);
1305+
encoder.set_time_base((1, 30));
1306+
encoder.set_frame_rate(Some((30, 1)));
1307+
encoder.set_flags(ffmpeg::codec::Flags::GLOBAL_HEADER);
1308+
let mut encoder = encoder.open_as(codec).unwrap();
1309+
output.add_stream(codec).unwrap().set_parameters(&encoder);
1310+
output.write_header().unwrap();
1311+
let output_time_base = output.stream(0).unwrap().time_base();
1312+
{
1313+
let mut drain = |encoder: &mut ffmpeg::encoder::video::Encoder, finished: bool| loop {
1314+
let mut packet = ffmpeg::Packet::empty();
1315+
match encoder.receive_packet(&mut packet) {
1316+
Ok(()) => {
1317+
packet.set_stream(0);
1318+
packet.rescale_ts((1, 30), output_time_base);
1319+
packet.write_interleaved(&mut output).unwrap();
1320+
}
1321+
Err(ffmpeg::Error::Other { errno })
1322+
if !finished && errno == ffmpeg::ffi::EAGAIN =>
1323+
{
1324+
break;
1325+
}
1326+
Err(ffmpeg::Error::Eof) if finished => break,
1327+
Err(error) => panic!("test media encoding failed: {error}"),
1328+
}
1329+
};
1330+
for index in 0..30 {
1331+
let mut frame = ffmpeg::frame::Video::new(Pixel::YUV420P, 32, 32);
1332+
frame.data_mut(0).fill(32 + index as u8);
1333+
frame.data_mut(1).fill(128);
1334+
frame.data_mut(2).fill(128);
1335+
frame.set_pts(Some(index));
1336+
encoder.send_frame(&frame).unwrap();
1337+
drain(&mut encoder, false);
1338+
}
1339+
encoder.send_eof().unwrap();
1340+
drain(&mut encoder, true);
1341+
}
1342+
output.write_trailer().unwrap();
1343+
}
1344+
12311345
fn write_single_segment_meta(project: &Path) {
12321346
std::fs::create_dir_all(project).unwrap();
12331347
let meta = serde_json::json!({

0 commit comments

Comments
 (0)