diff --git a/crates/lib/video_info/src/bin/video_info_parser.rs b/crates/lib/video_info/src/bin/video_info_parser.rs index b62ed27fe5..f4ac8d4af3 100644 --- a/crates/lib/video_info/src/bin/video_info_parser.rs +++ b/crates/lib/video_info/src/bin/video_info_parser.rs @@ -86,14 +86,28 @@ fn parse_args(args: impl Iterator) -> Result { // ── Single-file mode ── fn run_single_file(filename: &str) -> ExitCode { - match VideoInfo::from_path(filename) { + let bytes = match fs::read(filename) { + Ok(bytes) => bytes, + Err(err) => { + eprintln!("error: cannot read {filename:?}: {err}"); + return ExitCode::FAILURE; + } + }; + match VideoInfo::from_bytes(&bytes) { Ok(VideoInfo::Seedance(info)) => print_seedance(filename, &info), Ok(VideoInfo::Veo(info)) => print_veo(filename, &info), Ok(VideoInfo::Sora(info)) => print_sora(filename, &info), Ok(VideoInfo::Dreamina(info)) => print_dreamina(filename, &info), Ok(VideoInfo::Kling(info)) => print_kling(filename, &info), Err(VideoInfoError::Unrecognized) => { - println!("No recognized provenance (not Seedance, Veo, Sora, Dreamina, or Kling)"); + print!("No recognized provenance (not Seedance, Veo, Sora, Dreamina, or Kling)"); + match video_info::encoder_tag(&bytes) { + Some(enc) if enc.starts_with("Lavf") => { + println!(" — re-encoded through ffmpeg (encoder: {enc}), which strips provenance"); + } + Some(enc) => println!(" — encoder: {enc}"), + None => println!(), + } } Err(err) => { eprintln!("error: {err}"); @@ -124,7 +138,13 @@ fn run_directory(dir: &str, truncate_names: bool) -> ExitCode { let mut tally: std::collections::BTreeMap = std::collections::BTreeMap::new(); for file in &files { let name = file.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default(); - let row = summary_row(&name, &VideoInfo::from_path(file), truncate_names); + let row = match fs::read(file) { + Ok(bytes) => { + let encoder = video_info::encoder_tag(&bytes); + summary_row(&name, &VideoInfo::from_bytes(&bytes), encoder.as_deref(), truncate_names) + } + Err(err) => summary_row(&name, &Err(VideoInfoError::Io(err)), None, truncate_names), + }; *tally.entry(row[1].clone()).or_default() += 1; rows.push(row); } @@ -156,20 +176,25 @@ fn collect_mp4s(dir: &Path) -> std::io::Result> { } /// Reduce a parse result to one table row: `[file, kind, model, detail, generated, signer]`. -/// Filenames are shown in full unless `truncate_names` is set. +/// Filenames are shown in full unless `truncate_names` is set. `encoder` is the +/// `©too` tag, shown in DETAIL for unrecognized files to explain the absence. fn summary_row( name: &str, result: &Result, + encoder: Option<&str>, truncate_names: bool, ) -> [String; 6] { let file = if truncate_names { truncate(name, MAX_CELL) } else { name.to_string() }; let (kind, model, detail, generated, signer) = match result { Ok(VideoInfo::Seedance(i)) => { - let detail = if i.is_fast { - format!("{} (fast)", i.platform.as_str()) + let variant = if i.is_fast { + " (fast)" + } else if i.is_lite { + " (lite)" } else { - i.platform.as_str().to_string() + "" }; + let detail = format!("{}{variant}", i.platform.as_str()); ("Seedance", i.model_name.clone(), detail, i.generated_at.clone(), signer_line(&i.signer_country, &i.cert_serial)) } Ok(VideoInfo::Veo(i)) => { @@ -201,7 +226,14 @@ fn summary_row( }); ("Kling", model, detail, String::new(), i.content_producer.clone().unwrap_or_default()) } - Err(VideoInfoError::Unrecognized) => ("—", String::new(), String::new(), String::new(), String::new()), + Err(VideoInfoError::Unrecognized) => { + let detail = match encoder { + Some(enc) if enc.starts_with("Lavf") => format!("re-encoded: {enc}"), + Some(enc) => format!("encoder: {enc}"), + None => "no provenance".to_string(), + }; + ("—", String::new(), detail, String::new(), String::new()) + } Err(err) => ("error", err.to_string(), String::new(), String::new(), String::new()), }; [ @@ -291,6 +323,7 @@ fn print_seedance(filename: &str, info: &SeedanceInfo) { opt("model brand", &info.model_brand); opt("model version", &info.model_version); row("fast variant", if info.is_fast { "yes" } else { "no" }); + row("lite variant", if info.is_lite { "yes" } else { "no" }); row("generated at", &info.generated_at); row( "generated at (parsed)", diff --git a/crates/lib/video_info/src/lib.rs b/crates/lib/video_info/src/lib.rs index 25269a67fa..47de32d6d6 100644 --- a/crates/lib/video_info/src/lib.rs +++ b/crates/lib/video_info/src/lib.rs @@ -28,3 +28,11 @@ pub use seedance_info::{SeedanceInfo, SeedancePlatform}; pub use sora_info::SoraInfo; pub use veo_info::VeoInfo; pub use video::VideoInfo; + +/// Read the container's `©too` encoder tag (e.g. `"Lavf60.16.100"`, `"Google"`), +/// if present. Useful context for videos with no recognized provenance: an +/// `Lavf…` value means the file was re-encoded through ffmpeg, which strips C2PA +/// manifests and AIGC metadata — explaining why provenance is absent. +pub fn encoder_tag(data: &[u8]) -> Option { + scan::find_encoder_tag(data) +} diff --git a/crates/lib/video_info/src/seedance_info.rs b/crates/lib/video_info/src/seedance_info.rs index af9204f8dc..6e7e01b358 100644 --- a/crates/lib/video_info/src/seedance_info.rs +++ b/crates/lib/video_info/src/seedance_info.rs @@ -59,18 +59,24 @@ pub struct SeedanceInfo { pub software_agent_version: Option, /// Full model identifier, e.g. `"doubao-seedance-2-0-fast"`, - /// `"doubao-seedance-2-0"`, `"dreamina-seedance-2-0"`. + /// `"doubao-seedance-2-0"`, `"dreamina-seedance-2-0"`, + /// `"doubao-seedance-1-0-lite"`. pub model_name: String, /// Brand prefix of the model, e.g. `"doubao"` (Volcengine) / `"dreamina"` (BytePlus). pub model_brand: Option, - /// Model version parsed from the name, e.g. `"2.0"` (from `…-2-0`). + /// Model version parsed from the name, e.g. `"2.0"` (from `…-2-0`) or `"1.0"` + /// (from `…-1-0-lite`). The parser is version-agnostic — Seedance 1.0 and 2.0 + /// share the same Ark C2PA shape and both land here. pub model_version: Option, /// Whether this is the `-fast` variant of the model. pub is_fast: bool, + /// Whether this is the `-lite` variant of the model (e.g. Seedance 1.0 Lite). + pub is_lite: bool, + /// Raw generation timestamp string (RFC 3339, e.g. `"2026-06-19T01:32:58Z"`). pub generated_at: String, @@ -165,7 +171,7 @@ impl SeedanceInfo { .ok() .map(|dt| dt.with_timezone(&Utc)); - let (model_brand, model_version, is_fast) = parse_model_name(&model_name); + let (model_brand, model_version, is_fast, is_lite) = parse_model_name(&model_name); let log_id = text_after_key(data, b"log_id"); let log_id_decoded_hex = log_id @@ -192,6 +198,7 @@ impl SeedanceInfo { model_brand, model_version, is_fast, + is_lite, generated_at, generated_at_utc, log_id, @@ -211,22 +218,42 @@ impl SeedanceInfo { // ── Model name parsing ── -/// Split `"{brand}-seedance-{maj}-{min}[-fast]"` into (brand, version, is_fast). -/// e.g. `"doubao-seedance-2-0-fast"` → (`"doubao"`, `"2.0"`, `true`). -fn parse_model_name(model_name: &str) -> (Option, Option, bool) { - let is_fast = model_name.ends_with("-fast"); - let core = model_name.strip_suffix("-fast").unwrap_or(model_name); - - let brand = core.split("-seedance").next() - .filter(|s| !s.is_empty() && *s != core) +/// Split `"{brand}-seedance-{maj}-{min}[-{variant}]"` into +/// (brand, version, is_fast, is_lite). +/// +/// e.g. `"doubao-seedance-2-0-fast"` → (`"doubao"`, `"2.0"`, `true`, `false`); +/// `"doubao-seedance-1-0-lite"` → (`"doubao"`, `"1.0"`, `false`, `true`). +/// The version is the leading run of numeric segments after `seedance-`, so a +/// trailing variant token (`fast`/`lite`) doesn't leak into it. +fn parse_model_name(model_name: &str) -> (Option, Option, bool, bool) { + let brand = model_name.split("-seedance").next() + .filter(|s| !s.is_empty() && *s != model_name) .map(|s| s.to_string()); - let version = core.split("seedance-").nth(1) - .filter(|v| !v.is_empty()) - // "2-0" → "2.0" - .map(|v| v.replace('-', ".")); + let mut version = None; + let mut is_fast = false; + let mut is_lite = false; + if let Some(after) = model_name.split("seedance-").nth(1) { + let segments: Vec<&str> = after.split('-').collect(); + // Leading numeric segments form the version: ["1","0"] → "1.0". + let nums: Vec<&str> = segments + .iter() + .take_while(|s| !s.is_empty() && s.bytes().all(|b| b.is_ascii_digit())) + .copied() + .collect(); + if !nums.is_empty() { + version = Some(nums.join(".")); + } + for segment in &segments { + match *segment { + "fast" => is_fast = true, + "lite" => is_lite = true, + _ => {} + } + } + } - (brand, version, is_fast) + (brand, version, is_fast, is_lite) } // ── Seedance-specific scanners ── @@ -320,6 +347,7 @@ mod tests { assert_eq!(info.model_brand.as_deref(), Some("doubao")); assert_eq!(info.model_version.as_deref(), Some("2.0")); assert!(info.is_fast); + assert!(!info.is_lite); assert_eq!(info.generated_at, "2026-06-19T01:32:58Z"); assert!(info.generated_at_utc.is_some()); assert_eq!(info.log_id.as_deref(), Some("ATIAA7b8D_iKjF32GukAAAAA")); @@ -351,6 +379,22 @@ mod tests { assert_eq!(info.log_id_decoded_hex.as_deref(), Some("01330003b6f7e89f1c2d93b6894900000000")); } + #[test] + fn parses_seedance_1_0_lite() { + let data = synth_manifest( + "Volcengine_Ark_CN", "doubao-seedance-1-0-lite", + "2026-06-19T01:30:00Z", "ATIAA7b8D_iKjF32GukAAAAA", + "certificate_center@volcengine.com", "NTRCN-91110108MA01R70K8D", + ); + let info = SeedanceInfo::from_bytes(&data).expect("should parse"); + assert_eq!(info.model_name, "doubao-seedance-1-0-lite"); + assert_eq!(info.model_brand.as_deref(), Some("doubao")); + // The `-lite` token must not leak into the version. + assert_eq!(info.model_version.as_deref(), Some("1.0")); + assert!(info.is_lite); + assert!(!info.is_fast); + } + #[test] fn non_seedance_returns_not_seedance() { let data = b"....ftypisom....just a normal mp4 with no provenance....";