From 2519fa2b7ea517c2cc1b69905ad419559e0c5620 Mon Sep 17 00:00:00 2001 From: Rufan Date: Wed, 15 Jul 2026 08:27:41 +0200 Subject: [PATCH] feat(reco-io,reco-gui,reco-cli): embed export settings as container metadata Every export now writes a JSON snapshot of the settings actually used (codec, quality, resolution, blend width, and AI/autocam parameters) into the output video's "comment" container tag - readable via `ffprobe -show_entries format_tags` or most media tools. Useful for telling apart a batch of test exports with varying AI/blend settings without a separate sidecar file. Adds EncoderConfig::metadata_comment (set on the output context before write_header, since muxers bake container metadata into the header) and StitchJob::metadata_comment(String) builder; both reco-cli and reco-gui build the JSON automatically for every export, no new flag needed. Co-Authored-By: Claude Sonnet 5 --- crates/reco-cli/src/stitch.rs | 24 ++++++++++++++++ crates/reco-gui/src/export.rs | 32 +++++++++++++++++++++ crates/reco-io/src/adapters.rs | 1 + crates/reco-io/src/ffmpeg/encoder.rs | 16 +++++++++++ crates/reco-io/src/stacked_video/encoder.rs | 1 + crates/reco-io/src/stitch_job.rs | 16 +++++++++++ 6 files changed, 90 insertions(+) diff --git a/crates/reco-cli/src/stitch.rs b/crates/reco-cli/src/stitch.rs index b2490b89c..a9f276305 100644 --- a/crates/reco-cli/src/stitch.rs +++ b/crates/reco-cli/src/stitch.rs @@ -107,6 +107,29 @@ pub fn run_stitch(args: StitchArgs<'_>, interrupted: &Arc) -> anyhow reco_io::stitch_job::InputPath::Single(std::path::PathBuf::from(s)) } }; + // Snapshot the settings actually used for this export into the + // output container's "comment" tag, so a batch of test exports + // with varying AI/blend settings stays self-describing without a + // separate sidecar file (`ffprobe -show_entries format_tags`). + let metadata_comment = serde_json::json!({ + "reco_export": { + "codec": args.codec, + "quality": args.quality, + "quality_value": args.quality_value, + "resolution": format!("{}x{}", args.width, args.height), + "blend_width": args.blend, + "autocam": { + "model_path": args.model_path, + "tracking_mode": args.tracking_mode, + "detection_interval": args.detection_interval, + "lookahead_secs": args.lookahead, + "panner_preset": args.panner_preset, + "panner_config_path": args.panner_config_path, + } + } + }) + .to_string(); + let mut job = reco_io::StitchJob::with_calibration( to_input(args.left), to_input(args.right), @@ -116,6 +139,7 @@ pub fn run_stitch(args: StitchArgs<'_>, interrupted: &Arc) -> anyhow .codec(parse_codec(args.codec)) .quality(parse_quality(args.quality)) .resolution(args.width, args.height) + .metadata_comment(metadata_comment) .on_progress(move |p: &reco_core::session::types::FrameProgress| { // Use the session's own elapsed clock so the reported // rate excludes one-time GPU / encoder / ORT init and diff --git a/crates/reco-gui/src/export.rs b/crates/reco-gui/src/export.rs index e1c8c3b35..db7412a6f 100644 --- a/crates/reco-gui/src/export.rs +++ b/crates/reco-gui/src/export.rs @@ -185,6 +185,37 @@ pub fn run_export( log::info!("Streaming to {}", effective_output.display()); } + // Snapshot the settings actually used for this export into the + // output container's "comment" tag, so a batch of test exports + // with varying AI/blend settings stays self-describing without a + // separate sidecar file (`ffprobe -show_entries format_tags`). + let metadata_comment = serde_json::json!({ + "reco_export": { + "codec": codec_str, + "quality": quality_str, + "resolution": format!("{width}x{height}"), + "blend_width": blend, + "autocam": { + "enabled": autocam.enabled, + "model_path": &autocam.model_path, + "tracking_mode": &autocam.tracking_mode, + "detection_interval": autocam.detection_interval, + "lookahead_secs": autocam.lookahead_secs, + "preset": &autocam.preset, + "framing": &autocam.framing, + "lock_pitch": autocam.lock_pitch, + "cluster_mode": &autocam.cluster_mode, + "cluster_bandwidth_rad": autocam.cluster_bandwidth_rad, + "dead_zone_rad": autocam.dead_zone_rad, + "ball_weight": autocam.ball_weight, + "fov_tight": autocam.fov_tight, + "fov_wide": autocam.fov_wide, + "fov_default": autocam.fov_default, + } + } + }) + .to_string(); + let mut job = reco_io::StitchJob::with_calibration( left.clone(), right.clone(), @@ -196,6 +227,7 @@ pub fn run_export( .format(format) .resolution(width, height) .blend_width(blend) + .metadata_comment(metadata_comment) .on_progress(move |p: &reco_core::session::types::FrameProgress| { let frames = p.frames_completed; let elapsed = progress_start.elapsed().as_secs_f64(); diff --git a/crates/reco-io/src/adapters.rs b/crates/reco-io/src/adapters.rs index 2cc953d4d..c9b6617e2 100644 --- a/crates/reco-io/src/adapters.rs +++ b/crates/reco-io/src/adapters.rs @@ -548,6 +548,7 @@ pub fn create_encoder( container: ffmpeg::encoder::Container::default(), gop_size: None, stream_url: None, + metadata_comment: None, }; let encoder = FfmpegFileEncoder::new(path, width, height, fps, &enc_config)?; let name = encoder.encoder_name().to_string(); diff --git a/crates/reco-io/src/ffmpeg/encoder.rs b/crates/reco-io/src/ffmpeg/encoder.rs index b87b8f201..600675d91 100644 --- a/crates/reco-io/src/ffmpeg/encoder.rs +++ b/crates/reco-io/src/ffmpeg/encoder.rs @@ -458,6 +458,11 @@ pub struct EncoderConfig { /// output file AND this RTMP endpoint. Single encode pass, zero /// extra CPU. Stream failures are non-fatal (recording continues). pub stream_url: Option, + /// Free-form text written to the output container's "comment" + /// metadata tag (visible via `ffprobe`/most media tools). Intended + /// for a JSON snapshot of the export/AI settings actually used, so + /// test exports remain self-describing. `None` writes no tag. + pub metadata_comment: Option, } type OpenedVideoEncoder = ( @@ -691,6 +696,17 @@ impl VideoEncoder { None }; + // Container metadata must be set before + // write_header - the muxer bakes it into the + // header (moov `udta` atom for MP4, Matroska's + // Tags element, etc.) and won't pick up later + // changes. + if let Some(ref comment) = config.metadata_comment { + let mut meta = ffmpeg::Dictionary::new(); + meta.set("comment", comment); + octx.set_metadata(meta); + } + // Fragmented MP4 needs `movflags` so the muxer // writes an `empty_moov` up front and flushes // self-contained fragments on every keyframe. diff --git a/crates/reco-io/src/stacked_video/encoder.rs b/crates/reco-io/src/stacked_video/encoder.rs index 9fa2e721e..de2586c8b 100644 --- a/crates/reco-io/src/stacked_video/encoder.rs +++ b/crates/reco-io/src/stacked_video/encoder.rs @@ -82,6 +82,7 @@ impl Default for StackedEncoderConfig { // default of 250. gop_size: Some(30), stream_url: None, + metadata_comment: None, }, fps: (30, 1), } diff --git a/crates/reco-io/src/stitch_job.rs b/crates/reco-io/src/stitch_job.rs index 9a6350ce9..886d6b51c 100644 --- a/crates/reco-io/src/stitch_job.rs +++ b/crates/reco-io/src/stitch_job.rs @@ -83,6 +83,10 @@ pub struct StitchJob { /// `JsonlSink` to the session that records every detection, /// filter decision, and pan decision for offline analysis. events_path: Option, + + /// Free-form text embedded in the output container's "comment" + /// metadata tag. See [`Self::metadata_comment`]. + metadata_comment: Option, } /// Configuration for optional replay recording (see @@ -258,6 +262,7 @@ impl StitchJob { force_cpu_decode: false, lookahead_secs: 0.0, events_path: None, + metadata_comment: None, } } @@ -305,6 +310,16 @@ impl StitchJob { self } + /// Embed free-form text (e.g. a JSON snapshot of the export/AI + /// settings used) in the output container's "comment" metadata tag, + /// readable via `ffprobe -show_entries format_tags` or most media + /// tools, so a batch of test exports stays self-describing without + /// a separate sidecar file. `None` (default) writes no tag. + pub fn metadata_comment(mut self, comment: impl Into) -> Self { + self.metadata_comment = Some(comment.into()); + self + } + /// Set the audio mode. Default: copy audio from the first input. pub fn audio(mut self, mode: AudioMode) -> Self { self.audio = mode; @@ -697,6 +712,7 @@ impl StitchJob { container: self.format.into(), gop_size: None, stream_url: None, + metadata_comment: self.metadata_comment.clone(), }; let encoder = crate::adapters::FfmpegFileEncoder::new( &self.output,