From 754cdb8a6c5e5cd67580f2e4cfa4d5ace0f049f9 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Fri, 19 Jun 2026 10:24:01 -0400 Subject: [PATCH] parse video info (#1665) --- Cargo.lock | 8 + Cargo.toml | 2 + crates/lib/video_info/Cargo.toml | 28 ++ .../video_info/src/bin/video_info_parser.rs | 407 +++++++++++++++++ crates/lib/video_info/src/dreamina_info.rs | 159 +++++++ crates/lib/video_info/src/error.rs | 65 +++ crates/lib/video_info/src/kling_info.rs | 241 ++++++++++ crates/lib/video_info/src/lib.rs | 38 ++ crates/lib/video_info/src/scan.rs | 273 ++++++++++++ crates/lib/video_info/src/seedance_info.rs | 417 ++++++++++++++++++ crates/lib/video_info/src/sora_info.rs | 148 +++++++ crates/lib/video_info/src/veo_info.rs | 240 ++++++++++ crates/lib/video_info/src/video.rs | 81 ++++ 13 files changed, 2107 insertions(+) create mode 100644 crates/lib/video_info/Cargo.toml create mode 100644 crates/lib/video_info/src/bin/video_info_parser.rs create mode 100644 crates/lib/video_info/src/dreamina_info.rs create mode 100644 crates/lib/video_info/src/error.rs create mode 100644 crates/lib/video_info/src/kling_info.rs create mode 100644 crates/lib/video_info/src/lib.rs create mode 100644 crates/lib/video_info/src/scan.rs create mode 100644 crates/lib/video_info/src/seedance_info.rs create mode 100644 crates/lib/video_info/src/sora_info.rs create mode 100644 crates/lib/video_info/src/veo_info.rs create mode 100644 crates/lib/video_info/src/video.rs diff --git a/Cargo.lock b/Cargo.lock index 2aa2da3463..db731f8332 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -13896,6 +13896,14 @@ dependencies = [ "workspace-build-acceleration", ] +[[package]] +name = "video_info" +version = "0.0.1" +dependencies = [ + "chrono", + "workspace-build-acceleration", +] + [[package]] name = "videos" version = "0.0.1" diff --git a/Cargo.toml b/Cargo.toml index 112fcf3365..13f4729268 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -94,6 +94,7 @@ members = [ "crates/lib/user_input_common", "crates/lib/users", "crates/lib/uuid_utils", + "crates/lib/video_info", #"crates/lib/api_clients/fakeyou_client", #"crates/lib/deprecated/machine_learning/ml_models", @@ -290,6 +291,7 @@ tokens = { path = "crates/schema/public/tokens" } url_utils = { path = "crates/lib/url_utils" } users = { path = "crates/lib/users" } uuid_utils = { path = "crates/lib/uuid_utils" } +video_info = { path = "crates/lib/video_info" } web_base64 = { path = "crates/lib/data/web_base64" } worldlabs_api_client = { path = "crates/api_clients/worldlabs_api_client" } worldlabs_consumer_client = { path = "crates/api_clients/worldlabs_consumer_client" } diff --git a/crates/lib/video_info/Cargo.toml b/crates/lib/video_info/Cargo.toml new file mode 100644 index 0000000000..6ef451cae5 --- /dev/null +++ b/crates/lib/video_info/Cargo.toml @@ -0,0 +1,28 @@ +[package] +name = "video_info" +edition = "2024" +version = "0.0.1" +authors = [ + "Brandon Thomas ", + "Brandon Thomas ", +] +publish = false + +[package.metadata.cargo-udeps.ignore] +normal = ["workspace-build-acceleration"] + +[lib] +name = "video_info" +path = "src/lib.rs" + +# Optional CLI utility for inspecting a video's Seedance provenance. +[[bin]] +name = "video-info-parser" +path = "src/bin/video_info_parser.rs" + +[dependencies] +# Workspace hack (faster compile times) +workspace-build-acceleration.workspace = true + +# External +chrono.workspace = true diff --git a/crates/lib/video_info/src/bin/video_info_parser.rs b/crates/lib/video_info/src/bin/video_info_parser.rs new file mode 100644 index 0000000000..6790d1ec88 --- /dev/null +++ b/crates/lib/video_info/src/bin/video_info_parser.rs @@ -0,0 +1,407 @@ +//! `video-info-parser` — inspect AI-generation C2PA provenance (Seedance, Veo, +//! Sora, Dreamina, Kling) for a single video, or a whole directory of them. +//! +//! Usage: +//! video-info-parser # detailed report for one video +//! video-info-parser # summary table for every .mp4 in the dir +//! video-info-parser --filename +//! video-info-parser --truncate # shorten long filenames in the table + +use std::fs; +use std::path::{Path, PathBuf}; +use std::process::ExitCode; + +use video_info::error::VideoInfoError; +use video_info::{DreaminaInfo, KlingInfo, SeedanceInfo, SoraInfo, VeoInfo, VideoInfo}; + +/// Max width before a cell value is truncated with an ellipsis. +const MAX_CELL: usize = 30; + +/// Parsed command-line arguments. +struct Args { + /// File or directory to inspect. + path: String, + /// Whether to shorten long filenames in directory-table output. Defaults to + /// `false` so full filenames are always shown. + truncate_names: bool, +} + +fn main() -> ExitCode { + let args = match parse_args(std::env::args().skip(1)) { + Ok(args) => args, + Err(msg) => { + eprintln!("{msg}"); + eprintln!("usage: video-info-parser [--truncate]"); + eprintln!(" video-info-parser --filename "); + return ExitCode::from(2); + } + }; + + match fs::metadata(&args.path) { + Ok(meta) if meta.is_dir() => run_directory(&args.path, args.truncate_names), + Ok(_) => run_single_file(&args.path), + Err(err) => { + eprintln!("error: cannot read {:?}: {err}", args.path); + ExitCode::FAILURE + } + } +} + +/// Accept the path as the first positional arg, or via `--filename ` / +/// `--filename=`. The path may be a single file or a directory. +/// `--truncate` shortens long filenames in directory-table output. +fn parse_args(args: impl Iterator) -> Result { + let mut path: Option = None; + let mut truncate_names = false; + let mut args = args.peekable(); + while let Some(arg) = args.next() { + match arg.as_str() { + "--filename" | "-f" => { + let value = args.next().ok_or_else(|| "error: --filename requires a value".to_string())?; + path = Some(value); + } + "--truncate" | "--truncate-names" => truncate_names = true, + "--help" | "-h" => { + return Err("video-info-parser: print AI-generation provenance for a file or directory".to_string()); + } + _ => { + if let Some(value) = arg.strip_prefix("--filename=") { + path = Some(value.to_string()); + } else if arg.starts_with('-') { + return Err(format!("error: unknown flag {arg:?}")); + } else if path.is_none() { + path = Some(arg); + } else { + return Err(format!("error: unexpected extra argument {arg:?}")); + } + } + } + } + match path { + Some(path) => Ok(Args { path, truncate_names }), + None => Err("error: no path provided".to_string()), + } +} + +// ── Single-file mode ── + +fn run_single_file(filename: &str) -> ExitCode { + 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) => { + 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}"); + return ExitCode::FAILURE; + } + } + ExitCode::SUCCESS +} + +// ── Directory mode (summary table) ── + +const TABLE_HEADERS: [&str; 6] = ["FILE", "KIND", "MODEL", "DETAIL", "GENERATED", "SIGNER"]; + +fn run_directory(dir: &str, truncate_names: bool) -> ExitCode { + let files = match collect_mp4s(Path::new(dir)) { + Ok(files) => files, + Err(err) => { + eprintln!("error: cannot list {dir:?}: {err}"); + return ExitCode::FAILURE; + } + }; + if files.is_empty() { + println!("no .mp4 files found in {dir}"); + return ExitCode::SUCCESS; + } + + let mut rows: Vec<[String; 6]> = Vec::with_capacity(files.len()); + 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 = 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); + } + + render_table(&TABLE_HEADERS, &rows); + println!(); + let total = files.len(); + let breakdown = + tally.iter().map(|(kind, n)| format!("{kind}: {n}")).collect::>().join(", "); + println!("{total} file(s) — {breakdown}"); + ExitCode::SUCCESS +} + +/// Collect every `.mp4` (case-insensitive) directly under `dir`, sorted by name. +fn collect_mp4s(dir: &Path) -> std::io::Result> { + let mut out = Vec::new(); + for entry in fs::read_dir(dir)? { + let path = entry?.path(); + let is_mp4 = path + .extension() + .and_then(|e| e.to_str()) + .is_some_and(|e| e.eq_ignore_ascii_case("mp4")); + if path.is_file() && is_mp4 { + out.push(path); + } + } + out.sort(); + Ok(out) +} + +/// Reduce a parse result to one table row: `[file, kind, model, detail, generated, signer]`. +/// 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 variant = if i.is_fast { + " (fast)" + } else if i.is_lite { + " (lite)" + } else { + "" + }; + 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)) => { + let detail = if i.has_c2pa_manifest { + if i.has_synthid_watermark { "c2pa + synthid".to_string() } else { "c2pa".to_string() } + } else { + format!("encoder={}", i.encoder.as_deref().unwrap_or("?")) + }; + ("Veo", "Google Veo".to_string(), detail, String::new(), short_opt(&i.cert_serial)) + } + Ok(VideoInfo::Sora(i)) => ( + "Sora", + "OpenAI Sora".to_string(), + i.model_name.clone().unwrap_or_default(), + String::new(), + short_opt(&i.cert_serial), + ), + Ok(VideoInfo::Dreamina(i)) => { + let signer = if i.has_c2pa { signer_line(&i.signer_country, &i.cert_serial) } else { String::new() }; + ("Dreamina", i.product.clone(), i.video_id.clone().unwrap_or_default(), String::new(), signer) + } + Ok(VideoInfo::Kling(i)) => { + let model = match &i.model_version { + Some(v) => format!("Kling {v}"), + None => "Kling".to_string(), + }; + let detail = i.produce_id.clone().unwrap_or_else(|| { + if i.has_stream_watermark { "watermark-only".to_string() } else { String::new() } + }); + ("Kling", model, detail, String::new(), i.content_producer.clone().unwrap_or_default()) + } + 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()), + }; + [ + file, + kind.to_string(), + truncate(&model, MAX_CELL), + truncate(&detail, MAX_CELL), + generated, + truncate(&signer, MAX_CELL), + ] +} + +/// `" "`, omitting whichever parts are absent. +fn signer_line(country: &Option, cert: &Option) -> String { + match (country.as_deref(), cert.as_deref()) { + (Some(c), Some(s)) => format!("{c} {}", short(s, 14)), + (Some(c), None) => c.to_string(), + (None, Some(s)) => short(s, 14), + (None, None) => String::new(), + } +} + +fn short_opt(value: &Option) -> String { + value.as_deref().map(|s| short(s, 14)).unwrap_or_default() +} + +fn short(value: &str, max: usize) -> String { + truncate(value, max) +} + +/// Truncate to `max` characters, appending `…` when shortened. +fn truncate(value: &str, max: usize) -> String { + if value.chars().count() <= max { + return value.to_string(); + } + let kept: String = value.chars().take(max.saturating_sub(1)).collect(); + format!("{kept}…") +} + +/// Render left-aligned columns sized to the widest cell in each. +fn render_table(headers: &[&str], rows: &[[String; 6]]) { + let mut widths: Vec = headers.iter().map(|h| h.chars().count()).collect(); + for row in rows { + for (i, cell) in row.iter().enumerate() { + widths[i] = widths[i].max(cell.chars().count()); + } + } + let render = |cells: &[String]| { + cells + .iter() + .enumerate() + .map(|(i, c)| format!("{:>() + .join(" ") + }; + let header_cells: Vec = headers.iter().map(|h| h.to_string()).collect(); + println!("{}", render(&header_cells).trim_end()); + let separators: Vec = widths.iter().map(|w| "-".repeat(*w)).collect(); + println!("{}", render(&separators).trim_end()); + for row in rows { + println!("{}", render(row).trim_end()); + } +} + +// ── Printing ── + +fn row(label: &str, value: &str) { + println!(" {label:>24} : {value}"); +} + +fn opt(label: &str, value: &Option) { + row(label, value.as_deref().unwrap_or("(none)")); +} + +fn header(title: &str, filename: &str) { + println!("{title}"); + row("file", filename); + println!(" {}", "-".repeat(60)); +} + +fn print_seedance(filename: &str, info: &SeedanceInfo) { + header("Seedance video provenance", filename); + row("platform", info.platform.as_str()); + row("software agent", &info.software_agent); + opt("software agent version", &info.software_agent_version); + row("model name", &info.model_name); + 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)", + &info.generated_at_utc.map(|t| t.to_rfc3339()).unwrap_or_else(|| "(unparseable)".to_string()), + ); + opt("log id", &info.log_id); + opt("log id (decoded hex)", &info.log_id_decoded_hex); + opt("digital source type", &info.digital_source_type); + opt("claim generator", &info.claim_generator); + opt("claim generator version", &info.claim_generator_version); + opt("manifest id", &info.manifest_id); + opt("instance id", &info.instance_id); + opt("signer email", &info.signer_email); + opt("signer org id", &info.signer_org_id); + opt("signer country", &info.signer_country); + opt("cert serial", &info.cert_serial); +} + +fn print_veo(filename: &str, info: &VeoInfo) { + header("Google Veo video provenance", filename); + row("producer", &info.producer); + row("c2pa manifest", if info.has_c2pa_manifest { "yes" } else { "no (encoder tag only)" }); + opt("encoder", &info.encoder); + opt("created description", &info.created_description); + row("synthid watermark", if info.has_synthid_watermark { "yes" } else { "no" }); + opt("synthid description", &info.synthid_description); + opt("digital source type", &info.digital_source_type); + opt("claim generator", &info.claim_generator); + opt("claim generator version", &info.claim_generator_version); + opt("manifest id", &info.manifest_id); + opt("instance id", &info.instance_id); + opt("cert serial", &info.cert_serial); + opt("signer ca", &info.signer_ca); + row("timestamped", if info.is_timestamped { "yes" } else { "no" }); + opt("timestamp authority", &info.timestamp_authority); + row("model name", "(not embedded in metadata)"); +} + +fn print_sora(filename: &str, info: &SoraInfo) { + header("OpenAI Sora video provenance", filename); + row("producer", &info.producer); + opt("model name", &info.model_name); + opt("created description", &info.created_description); + opt("digital source type", &info.digital_source_type); + opt("claim generator", &info.claim_generator); + opt("manifest id", &info.manifest_id); + opt("instance id", &info.instance_id); + opt("cert serial", &info.cert_serial); +} + +fn print_dreamina(filename: &str, info: &DreaminaInfo) { + header("Dreamina (ByteDance/CapCut) video provenance", filename); + row("product", &info.product); + opt("export type", &info.export_type); + opt("os", &info.os); + opt("source info", &info.source_info); + row( + "aigc label type", + &info.aigc_label_type.map(|n| n.to_string()).unwrap_or_else(|| "(none)".to_string()), + ); + opt("video id", &info.video_id); + row("has c2pa", if info.has_c2pa { "yes" } else { "no" }); + opt("signer org id", &info.signer_org_id); + opt("signer country", &info.signer_country); + opt("cert serial", &info.cert_serial); +} + +fn print_kling(filename: &str, info: &KlingInfo) { + header("Kling (Kuaishou) video provenance", filename); + opt("model version", &info.model_version); + row("ai-generated", if info.is_ai_generated { "yes" } else { "no" }); + opt("aigc label", &info.label); + opt("content producer", &info.content_producer); + opt("produce id", &info.produce_id); + opt("content propagator", &info.content_propagator); + opt("propagate id", &info.propagate_id); + opt("reserved code 1", &info.reserved_code_1); + opt("reserved code 2", &info.reserved_code_2); + row("stream watermark", if info.has_stream_watermark { "yes" } else { "no" }); + opt("watermark uuid", &info.watermark_uuid); +} diff --git a/crates/lib/video_info/src/dreamina_info.rs b/crates/lib/video_info/src/dreamina_info.rs new file mode 100644 index 0000000000..419952648d --- /dev/null +++ b/crates/lib/video_info/src/dreamina_info.rs @@ -0,0 +1,159 @@ +//! Parse **Dreamina** (ByteDance / CapCut consumer app) provenance from an MP4. +//! +//! Unlike the Seedance API (which embeds a Volcengine/BytePlus C2PA manifest), +//! the Dreamina app stamps JSON metadata into the MP4's `moov/udta/meta/ilst` +//! atoms — e.g. `{"product":"dreamina","exportType":"generation",…}`, +//! `{"aigc_label_type":0,"source_info":"dreamina"}`, and a `vid:…` video id. +//! Some Dreamina exports additionally carry a Bytedance-signed C2PA box. +//! +//! Dreamina is ByteDance's consumer product built on the Seedance model family, +//! so this is a distinct provenance shape from [`crate::seedance_info`]. + +use std::fs; +use std::path::Path; + +use crate::error::VideoInfoError; +use crate::scan::{find, find_cert_serial, find_org_identifier, json_int_field, json_str_field}; + +/// Provenance extracted from a Dreamina (ByteDance/CapCut) video export. +#[derive(Debug, Clone, PartialEq)] +pub struct DreaminaInfo { + /// The product string, always `"dreamina"`. + pub product: String, + + /// Export type, e.g. `"generation"`. + pub export_type: Option, + + /// OS the export was made on (often empty). + pub os: Option, + + /// `source_info`, e.g. `"dreamina"`. + pub source_info: Option, + + /// `aigc_label_type` — ByteDance's AIGC labeling flag (e.g. `0`). + pub aigc_label_type: Option, + + /// Dreamina video id, e.g. `"v02870g10004d6jktf7og65h7ouce21g"` (the `vid:` tag). + pub video_id: Option, + + /// Whether the export also carries a (Bytedance-signed) C2PA manifest. + pub has_c2pa: bool, + + /// Signing-cert organization identifier (when a C2PA box is present), e.g. + /// `"NTRSG-201923456H"` (Bytedance Pte. Ltd.). + pub signer_org_id: Option, + + /// Country of the signing org (`"SG"`) when a C2PA box is present. + pub signer_country: Option, + + /// Leaf signing-cert hex serial when a C2PA box is present. + pub cert_serial: Option, +} + +impl DreaminaInfo { + /// Parse Dreamina provenance from a file on disk. + pub fn from_path(path: impl AsRef) -> Result { + let bytes = fs::read(path)?; + Self::from_bytes(&bytes) + } + + /// Parse Dreamina provenance from raw bytes. Returns + /// [`VideoInfoError::NotDreamina`] if the Dreamina markers aren't present. + pub fn from_bytes(data: &[u8]) -> Result { + let product = json_str_field(data, "product"); + let source_info = json_str_field(data, "source_info"); + let is_dreamina = product.as_deref() == Some("dreamina") + || source_info.as_deref() == Some("dreamina"); + if !is_dreamina { + return Err(VideoInfoError::NotDreamina); + } + + let has_c2pa = find(data, b"jumbf").is_some() || find(data, b"c2pa.claim").is_some(); + let (signer_org_id, signer_country) = if has_c2pa { + match find_org_identifier(data) { + Some((id, country)) => (Some(id), Some(country.to_string())), + None => (None, None), + } + } else { + (None, None) + }; + + Ok(DreaminaInfo { + product: product.unwrap_or_else(|| "dreamina".to_string()), + export_type: json_str_field(data, "exportType"), + os: json_str_field(data, "os"), + source_info, + aigc_label_type: json_int_field(data, "aigc_label_type"), + video_id: find_vid_token(data), + has_c2pa, + signer_org_id, + signer_country, + cert_serial: if has_c2pa { find_cert_serial(data) } else { None }, + }) + } +} + +/// Read the `vid:` token (id is lowercase-alphanumeric). +fn find_vid_token(data: &[u8]) -> Option { + const PREFIX: &[u8] = b"vid:"; + let i = find(data, PREFIX)?; + let start = i + PREFIX.len(); + let end = (start..data.len()) + .take_while(|&j| data[j].is_ascii_alphanumeric()) + .last() + .map(|j| j + 1)?; + if end == start { + return None; + } + std::str::from_utf8(&data[start..end]).ok().map(str::to_string) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn synth(with_c2pa: bool) -> Vec { + let mut v = Vec::new(); + v.extend_from_slice(b"....ftypisom....moov....udta....ilst"); + v.extend_from_slice(br#"{"data":{"os":"","product":"dreamina","exportType":"generation","videoId":"0"}}"#); + v.extend_from_slice(br#"{"aigc_label_type":0,"source_info":"dreamina"}"#); + v.extend_from_slice(b"vid:v02870g10004d6jktf7og65h7ouce21g"); + if with_c2pa { + v.extend_from_slice(b"jumbfc2pa.claim"); + v.extend_from_slice(&[0xA0, 0x03, 0x02, 0x01, 0x02, 0x02, 0x03, 0xAB, 0xCD, 0xEF]); + v.push(0x13); + v.push(16); + v.extend_from_slice(b"NTRSG-201923456H"); + v.push(0x31); + } + v + } + + #[test] + fn parses_dreamina_no_c2pa() { + let info = DreaminaInfo::from_bytes(&synth(false)).expect("should parse"); + assert_eq!(info.product, "dreamina"); + assert_eq!(info.export_type.as_deref(), Some("generation")); + assert_eq!(info.os.as_deref(), Some("")); + assert_eq!(info.source_info.as_deref(), Some("dreamina")); + assert_eq!(info.aigc_label_type, Some(0)); + assert_eq!(info.video_id.as_deref(), Some("v02870g10004d6jktf7og65h7ouce21g")); + assert!(!info.has_c2pa); + assert_eq!(info.cert_serial, None); + } + + #[test] + fn parses_dreamina_with_c2pa() { + let info = DreaminaInfo::from_bytes(&synth(true)).expect("should parse"); + assert!(info.has_c2pa); + assert_eq!(info.signer_org_id.as_deref(), Some("NTRSG-201923456H")); + assert_eq!(info.signer_country.as_deref(), Some("SG")); + assert_eq!(info.cert_serial.as_deref(), Some("ABCDEF")); + } + + #[test] + fn non_dreamina_returns_not_dreamina() { + let data = b"....ftypisom....just an mp4...."; + assert!(matches!(DreaminaInfo::from_bytes(data), Err(VideoInfoError::NotDreamina))); + } +} diff --git a/crates/lib/video_info/src/error.rs b/crates/lib/video_info/src/error.rs new file mode 100644 index 0000000000..be7c93475f --- /dev/null +++ b/crates/lib/video_info/src/error.rs @@ -0,0 +1,65 @@ +use std::fmt; +use std::io; + +/// Errors produced while reading and parsing video information. +#[derive(Debug)] +pub enum VideoInfoError { + /// The file could not be read. + Io(io::Error), + + /// The video has no recognizable Seedance / C2PA generative-AI manifest — + /// either it isn't a Seedance generation, or the provenance metadata was + /// stripped (e.g. by re-encoding). + NotSeedance, + + /// The video has no recognizable Google Veo / Generative-AI C2PA manifest. + NotVeo, + + /// The video has no recognizable OpenAI Sora C2PA manifest. + NotSora, + + /// The video has no recognizable Dreamina (ByteDance/CapCut) `ilst` metadata. + NotDreamina, + + /// The video has no recognizable Kling (Kuaishou) AIGC-label `ilst` metadata. + NotKling, + + /// The video has no recognized provenance of any supported kind (returned by + /// the [`crate::VideoInfo`] dispatcher when no format matches). + Unrecognized, + + /// A manifest was detected, but a required field could not be extracted + /// (truncated / unexpected manifest encoding). Carries a short description of + /// what was missing. + MalformedManifest(String), +} + +impl fmt::Display for VideoInfoError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Io(err) => write!(f, "I/O error reading video: {}", err), + Self::NotSeedance => write!(f, "no Seedance C2PA manifest found in video"), + Self::NotVeo => write!(f, "no Google Veo C2PA manifest found in video"), + Self::NotSora => write!(f, "no OpenAI Sora C2PA manifest found in video"), + Self::NotDreamina => write!(f, "no Dreamina metadata found in video"), + Self::NotKling => write!(f, "no Kling metadata found in video"), + Self::Unrecognized => write!(f, "no recognized provenance found in video"), + Self::MalformedManifest(detail) => write!(f, "malformed manifest: {}", detail), + } + } +} + +impl std::error::Error for VideoInfoError { + fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { + match self { + Self::Io(err) => Some(err), + _ => None, + } + } +} + +impl From for VideoInfoError { + fn from(err: io::Error) -> Self { + Self::Io(err) + } +} diff --git a/crates/lib/video_info/src/kling_info.rs b/crates/lib/video_info/src/kling_info.rs new file mode 100644 index 0000000000..fd248b80c9 --- /dev/null +++ b/crates/lib/video_info/src/kling_info.rs @@ -0,0 +1,241 @@ +//! Parse **Kling** (Kuaishou) provenance from an MP4. +//! +//! Kling stamps provenance two independent ways, and this parser reads both: +//! +//! 1. **Container AIGC label** — a JSON object in `moov/udta/meta` under the +//! `AIGC` key, following China's AIGC content-labeling standard (GB 45438): +//! `{"Label":"1","ContentProducer":"…","ProduceID":"KLingMuse_…", +//! "ContentPropagator":"…","PropagateID":"KLingMuse_…", +//! "ReservedCode1":null,"ReservedCode2":null}`. +//! `Label:"1"` marks AI-generated content; the `001…C10100` codes are the +//! provider's China registration code; `KLingMuse_…` is Kling's pipeline id. +//! +//! 2. **In-stream watermark** — an H.264 SEI `user_data_unregistered` NAL with a +//! fixed UUID (`91ca60614aee385486142d5f73f4ae2e`) carrying the bytes +//! `kling-ai\0`. This survives a re-encode that strips the container metadata, +//! so a clip can be recognized as Kling from the watermark alone. +//! +//! Note on the **model version** (Kling 1.5 / 1.6 / 2.0 / 2.5 / 2.6, pro / std / +//! fast): it is **not** embedded in either signal. Re-encoded exports (all the +//! samples we have went through `Lavf`) zero the `mvhd` dates and drop any model +//! metadata, leaving only the AIGC label and the watermark — neither of which +//! encodes the version. [`KlingInfo::model_version`] holds it when a clean export +//! does carry a printable `kling-` token, and is `None` otherwise. + +use std::fs; +use std::path::Path; + +use crate::error::VideoInfoError; +use crate::scan::{find, find_from, json_str_field}; + +/// The fixed UUID of Kling's SEI `user_data_unregistered` watermark NAL, +/// immediately followed by the ASCII marker `kling-ai`. +const WATERMARK_MARKER: &[u8] = b"kling-ai"; + +/// Provenance extracted from a Kling (Kuaishou) video. +#[derive(Debug, Clone, PartialEq)] +pub struct KlingInfo { + /// Kling model version when a clean export embeds a printable `kling-` + /// token (e.g. `"2.5-pro"`). `None` for re-encoded exports, which don't carry + /// it — see the module docs. + pub model_version: Option, + + /// Whether the content is labeled AI-generated (`Label == "1"`, per GB 45438). + pub is_ai_generated: bool, + + /// Raw `Label` value (`"1"` = AI-generated). + pub label: Option, + + /// Content producer registration code — the org/service code that generated it. + pub content_producer: Option, + + /// Per-generation produce id, e.g. `"KLingMuse_3d917354-4294-47ff-…"`. + pub produce_id: Option, + + /// Content propagator registration code (who distributed it; usually == + /// producer, but can differ — e.g. a `…C20100` distribution channel). + pub content_propagator: Option, + + /// Per-generation propagate id (usually equal to [`KlingInfo::produce_id`]). + pub propagate_id: Option, + + /// `ReservedCode1` — reserved by the standard; observed `null` so far. + pub reserved_code_1: Option, + + /// `ReservedCode2` — reserved by the standard; observed `null` so far. + pub reserved_code_2: Option, + + /// Whether the in-stream `kling-ai` SEI watermark is present. + pub has_stream_watermark: bool, + + /// The 16-byte SEI watermark UUID (hex) when the watermark is present, + /// e.g. `"91ca60614aee385486142d5f73f4ae2e"`. + pub watermark_uuid: Option, +} + +impl KlingInfo { + /// Parse Kling provenance from a file on disk. + pub fn from_path(path: impl AsRef) -> Result { + let bytes = fs::read(path)?; + Self::from_bytes(&bytes) + } + + /// Parse Kling provenance from raw bytes. Returns [`VideoInfoError::NotKling`] + /// if neither the AIGC label nor the in-stream watermark is present. + pub fn from_bytes(data: &[u8]) -> Result { + let watermark_uuid = find_watermark_uuid(data); + let has_stream_watermark = watermark_uuid.is_some() || find(data, WATERMARK_MARKER).is_some(); + + let has_aigc_label = find(data, b"KLingMuse").is_some() + || (find(data, b"\"ContentProducer\"").is_some() && find(data, b"\"Label\"").is_some()); + if !has_aigc_label && !has_stream_watermark { + return Err(VideoInfoError::NotKling); + } + + let label = json_str_field(data, "Label"); + Ok(KlingInfo { + model_version: find_model_version(data), + is_ai_generated: label.as_deref() == Some("1"), + label, + content_producer: json_str_field(data, "ContentProducer"), + produce_id: json_str_field(data, "ProduceID"), + content_propagator: json_str_field(data, "ContentPropagator"), + propagate_id: json_str_field(data, "PropagateID"), + reserved_code_1: json_str_field(data, "ReservedCode1"), + reserved_code_2: json_str_field(data, "ReservedCode2"), + has_stream_watermark, + watermark_uuid, + }) + } +} + +/// Read the 16-byte UUID immediately preceding the `kling-ai` SEI marker (the +/// SEI `user_data_unregistered` payload is `<16-byte UUID><"kling-ai\0">`). +fn find_watermark_uuid(data: &[u8]) -> Option { + let i = find(data, WATERMARK_MARKER)?; + let uuid = data.get(i.checked_sub(16)?..i)?; + Some(uuid.iter().map(|b| format!("{:02x}", b)).collect()) +} + +/// Find a printable `kling[-_/ ]v?.[-]` model-version token. +/// +/// Conservative by design: it requires a real `.` after a `kling` +/// prefix, so the watermark marker (`kling-ai`) and the produce id (`KLingMuse_…`) +/// never match. Returns `None` when no such printable token exists. +fn find_model_version(data: &[u8]) -> Option { + const PREFIX: &[u8] = b"kling"; + const TIERS: [&[u8]; 5] = [b"pro", b"std", b"fast", b"master", b"turbo"]; + let mut from = 0; + while let Some(i) = find_from(data, PREFIX, from) { + from = i + PREFIX.len(); + let mut j = from; + if matches!(data.get(j), Some(b'-' | b'_' | b'/' | b' ')) { + j += 1; + } + if matches!(data.get(j), Some(b'v' | b'V')) { + j += 1; + } + let num_start = j; + while matches!(data.get(j), Some(c) if c.is_ascii_digit() || *c == b'.') { + j += 1; + } + if j == num_start || !data[num_start..j].contains(&b'.') { + continue; + } + let mut version = String::from_utf8_lossy(&data[num_start..j]).into_owned(); + // Optional `-` suffix (pro / std / fast / …). + if data.get(j) == Some(&b'-') { + for tier in TIERS { + if data.get(j + 1..j + 1 + tier.len()) == Some(tier) { + version.push('-'); + version.push_str(&String::from_utf8_lossy(tier)); + break; + } + } + } + return Some(version); + } + None +} + +#[cfg(test)] +mod tests { + use super::*; + + const AIGC_JSON: &[u8] = br#"{"Label":"1","ContentProducer":"001191110108335469089C10100","ProduceID":"KLingMuse_3d917354-4294-47ff-a48e-53f09b0ffee0","ReservedCode1":null,"ContentPropagator":"001191110108335469089C10100","PropagateID":"KLingMuse_3d917354-4294-47ff-a48e-53f09b0ffee0","ReservedCode2":null}"#; + + fn with_watermark() -> Vec { + let mut v = Vec::new(); + v.extend_from_slice(b"....mdat...."); + // SEI: type 6, payload 5, len 0x19, 16-byte UUID, then "kling-ai\0". + v.extend_from_slice(&[0x06, 0x05, 0x19]); + v.extend_from_slice(&[ + 0x91, 0xca, 0x60, 0x61, 0x4a, 0xee, 0x38, 0x54, 0x86, 0x14, 0x2d, 0x5f, 0x73, 0xf4, 0xae, + 0x2e, + ]); + v.extend_from_slice(b"kling-ai\0"); + v + } + + #[test] + fn parses_aigc_label() { + let mut data = b"....ftypisom....moov....udta....ilst".to_vec(); + data.extend_from_slice(AIGC_JSON); + let info = KlingInfo::from_bytes(&data).expect("should parse"); + assert!(info.is_ai_generated); + assert_eq!(info.label.as_deref(), Some("1")); + assert_eq!(info.content_producer.as_deref(), Some("001191110108335469089C10100")); + assert_eq!(info.produce_id.as_deref(), Some("KLingMuse_3d917354-4294-47ff-a48e-53f09b0ffee0")); + assert_eq!(info.content_propagator.as_deref(), Some("001191110108335469089C10100")); + assert_eq!(info.propagate_id.as_deref(), Some("KLingMuse_3d917354-4294-47ff-a48e-53f09b0ffee0")); + assert_eq!(info.reserved_code_1, None); + assert_eq!(info.reserved_code_2, None); + assert_eq!(info.model_version, None); + assert!(!info.has_stream_watermark); + } + + #[test] + fn detects_watermark_when_aigc_stripped() { + // A re-encoded Kling clip: no AIGC JSON, only the in-stream SEI watermark. + let info = KlingInfo::from_bytes(&with_watermark()).expect("should parse via watermark"); + assert!(info.has_stream_watermark); + assert_eq!(info.watermark_uuid.as_deref(), Some("91ca60614aee385486142d5f73f4ae2e")); + assert_eq!(info.label, None); + assert_eq!(info.produce_id, None); + assert!(!info.is_ai_generated); + } + + #[test] + fn reads_both_signals_together() { + let mut data = b"....ftypisom....ilst".to_vec(); + data.extend_from_slice(AIGC_JSON); + data.extend_from_slice(&with_watermark()); + let info = KlingInfo::from_bytes(&data).expect("should parse"); + assert!(info.is_ai_generated); + assert!(info.has_stream_watermark); + assert_eq!(info.watermark_uuid.as_deref(), Some("91ca60614aee385486142d5f73f4ae2e")); + } + + #[test] + fn extracts_model_version_from_clean_token() { + // A hypothetical clean export carrying a printable model token. + let data = b"....moov....model:kling-2.5-pro....mdat...."; + let info = KlingInfo::from_bytes(data); + // No AIGC/watermark here, so it won't classify as Kling, but the version + // extractor itself should recognize the token. + assert_eq!(find_model_version(data).as_deref(), Some("2.5-pro")); + assert!(matches!(info, Err(VideoInfoError::NotKling))); + } + + #[test] + fn watermark_marker_is_not_a_version() { + // `kling-ai` must not be misread as a version. + assert_eq!(find_model_version(&with_watermark()), None); + } + + #[test] + fn non_kling_returns_not_kling() { + let data = b"....ftypisom....just an mp4...."; + assert!(matches!(KlingInfo::from_bytes(data), Err(VideoInfoError::NotKling))); + } +} diff --git a/crates/lib/video_info/src/lib.rs b/crates/lib/video_info/src/lib.rs new file mode 100644 index 0000000000..47de32d6d6 --- /dev/null +++ b/crates/lib/video_info/src/lib.rs @@ -0,0 +1,38 @@ +//! Parse provenance / generation metadata out of video files. +//! +//! AI image/video generators increasingly embed [C2PA](https://c2pa.org/) +//! Content Credentials (a JUMBF box carrying a CBOR manifest) describing the +//! generating model, platform, and timestamp. This crate extracts that. +//! +//! Supported formats (see [`VideoInfo`]): +//! - **Seedance** (ByteDance API — Volcengine / BytePlus C2PA): [`seedance_info::SeedanceInfo`] +//! - **Veo** (Google Generative AI video C2PA): [`veo_info::VeoInfo`] +//! - **Sora** (OpenAI Generative AI video C2PA): [`sora_info::SoraInfo`] +//! - **Dreamina** (ByteDance/CapCut app `ilst` metadata): [`dreamina_info::DreaminaInfo`] +//! - **Kling** (Kuaishou AIGC-label `ilst` metadata): [`kling_info::KlingInfo`] + +mod scan; + +pub mod dreamina_info; +pub mod error; +pub mod kling_info; +pub mod seedance_info; +pub mod sora_info; +pub mod veo_info; +pub mod video; + +pub use dreamina_info::DreaminaInfo; +pub use error::VideoInfoError; +pub use kling_info::KlingInfo; +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/scan.rs b/crates/lib/video_info/src/scan.rs new file mode 100644 index 0000000000..df9c184fee --- /dev/null +++ b/crates/lib/video_info/src/scan.rs @@ -0,0 +1,273 @@ +//! Shared byte-scanning helpers for reading C2PA manifests embedded in MP4s. +//! +//! These avoid a full JUMBF + CBOR + X.509 stack: the values we want are small, +//! stably-encoded fields locatable by their key/marker. Used by both +//! [`crate::seedance_info`] and [`crate::veo_info`]. + +/// Find the first occurrence of `needle` in `haystack`. +pub(crate) fn find(haystack: &[u8], needle: &[u8]) -> Option { + find_from(haystack, needle, 0) +} + +/// Find `needle` at or after byte offset `start`. +pub(crate) fn find_from(haystack: &[u8], needle: &[u8], start: usize) -> Option { + if needle.is_empty() || start >= haystack.len() || needle.len() > haystack.len() - start { + return None; + } + haystack[start..] + .windows(needle.len()) + .position(|w| w == needle) + .map(|p| p + start) +} + +/// Read a CBOR text string (major type 3) starting at `at`. Supports inline +/// length (0x60–0x77), 1-byte length (0x78), and 2-byte length (0x79). +pub(crate) fn read_cbor_text(data: &[u8], at: usize) -> Option { + let first = *data.get(at)?; + let (len, value_offset) = match first { + 0x60..=0x77 => ((first - 0x60) as usize, 1), + 0x78 => (*data.get(at + 1)? as usize, 2), + 0x79 => { + let hi = *data.get(at + 1)? as usize; + let lo = *data.get(at + 2)? as usize; + ((hi << 8) | lo, 3) + } + _ => return None, + }; + let start = at + value_offset; + let bytes = data.get(start..start + len)?; + String::from_utf8(bytes.to_vec()).ok() +} + +/// Find `key` and read the CBOR text string immediately following it. +pub(crate) fn text_after_key(data: &[u8], key: &[u8]) -> Option { + let idx = find(data, key)?; + read_cbor_text(data, idx + key.len()) +} + +/// Like [`text_after_key`] but only searches at/after `start`. +pub(crate) fn text_after_key_from(data: &[u8], key: &[u8], start: usize) -> Option { + let idx = find_from(data, key, start)?; + read_cbor_text(data, idx + key.len()) +} + +/// First RFC 3339 `YYYY-MM-DDTHH:MM:SSZ` (20 chars) timestamp in the buffer. +pub(crate) fn find_rfc3339(data: &[u8]) -> Option { + const LEN: usize = 20; + data.windows(LEN).find(|w| is_rfc3339(w)).map(|w| String::from_utf8_lossy(w).into_owned()) +} + +fn is_rfc3339(w: &[u8]) -> bool { + // YYYY-MM-DDTHH:MM:SSZ + let d = |i: usize| w[i].is_ascii_digit(); + w.len() == 20 + && d(0) && d(1) && d(2) && d(3) && w[4] == b'-' + && d(5) && d(6) && w[7] == b'-' + && d(8) && d(9) && w[10] == b'T' + && d(11) && d(12) && w[13] == b':' + && d(14) && d(15) && w[16] == b':' + && d(17) && d(18) && w[19] == b'Z' +} + +/// Find a `` token (e.g. `urn:c2pa:…`, `xmp:iid:…`) and return the +/// full token including the prefix. The UUID is up to 36 hex/dash chars. +pub(crate) fn find_prefixed_uuid(data: &[u8], prefix: &[u8]) -> Option { + let i = find(data, prefix)?; + let start = i + prefix.len(); + let end = (start..data.len().min(start + 36)) + .take_while(|&j| data[j].is_ascii_hexdigit() || data[j] == b'-') + .last() + .map(|j| j + 1)?; + let uuid = std::str::from_utf8(&data[start..end]).ok()?; + let mut out = String::with_capacity(prefix.len() + uuid.len()); + out.push_str(&String::from_utf8_lossy(prefix)); + out.push_str(uuid); + Some(out) +} + +/// Hex serial number of the leaf signing certificate. +/// +/// X.509 v3 certs encode the TBS prefix as `A0 03 02 01 02` (the `[0] EXPLICIT` +/// version = v3) immediately followed by the serial `INTEGER` (`02 `). +/// That fixed prefix lets us read the serial without a full DER parser; the leaf +/// cert is the first such occurrence. +pub(crate) fn find_cert_serial(data: &[u8]) -> Option { + const TBS_VERSION_PREFIX: &[u8] = &[0xA0, 0x03, 0x02, 0x01, 0x02, 0x02]; + let i = find(data, TBS_VERSION_PREFIX)?; + let len_pos = i + TBS_VERSION_PREFIX.len(); + let len = *data.get(len_pos)? as usize; + if len == 0 || len > 40 { + return None; + } + let start = len_pos + 1; + let bytes = data.get(start..start + len)?; + Some(bytes.iter().map(|b| format!("{:02X}", b)).collect()) +} + +/// Read a DER length-prefixed string that begins with `prefix` — the byte +/// immediately before `prefix` is its DER length (as in an X.509 subject/issuer +/// CN). Returns the full string, e.g. given `b"Google C2PA Media Services"` +/// returns `"Google C2PA Media Services 1P ICA G3"`. +pub(crate) fn read_der_string(data: &[u8], prefix: &[u8]) -> Option { + let i = find(data, prefix)?; + let len = *data.get(i.checked_sub(1)?)? as usize; + if len < prefix.len() || len > 128 { + return None; + } + let bytes = data.get(i..i + len)?; + if !bytes.iter().all(|b| b.is_ascii_graphic() || *b == b' ') { + return None; + } + std::str::from_utf8(bytes).ok().map(str::to_string) +} + +/// Minimal base64url (RFC 4648 §5) decoder — no external dependency. Ignores +/// `=` padding; returns `None` on any invalid character. +pub(crate) fn decode_base64url(input: &str) -> Option> { + fn sextet(c: u8) -> Option { + match c { + b'A'..=b'Z' => Some(c - b'A'), + b'a'..=b'z' => Some(c - b'a' + 26), + b'0'..=b'9' => Some(c - b'0' + 52), + b'-' => Some(62), + b'_' => Some(63), + _ => None, + } + } + let chars: Vec = input.bytes().filter(|&b| b != b'=').collect(); + let mut out = Vec::with_capacity(chars.len() * 3 / 4); + for chunk in chars.chunks(4) { + let mut buf = [0u8; 4]; + for (i, &c) in chunk.iter().enumerate() { + buf[i] = sextet(c)?; + } + out.push((buf[0] << 2) | (buf[1] >> 4)); + if chunk.len() >= 3 { + out.push((buf[1] << 4) | (buf[2] >> 2)); + } + if chunk.len() >= 4 { + out.push((buf[2] << 6) | buf[3]); + } + } + Some(out) +} + +/// The X.509 organization identifier (OID 2.5.4.97) from the signing cert, plus +/// the country implied by its registration scheme (`NTRSG-…` = SG, `NTRCN-…` = CN). +/// The value is a DER string whose length byte immediately precedes it. +pub(crate) fn find_org_identifier(data: &[u8]) -> Option<(String, &'static str)> { + for (prefix, country) in [(b"NTRSG-".as_slice(), "SG"), (b"NTRCN-".as_slice(), "CN")] { + let Some(start) = find(data, prefix) else { continue }; + if start == 0 { + continue; + } + let len = data[start - 1] as usize; + if len < prefix.len() || len > 40 { + continue; + } + let Some(bytes) = data.get(start..start + len) else { continue }; + if bytes.iter().all(|b| b.is_ascii_graphic()) { + if let Ok(value) = std::str::from_utf8(bytes) { + return Some((value.to_string(), country)); + } + } + } + None +} + +/// Read the iTunes-style `©too` encoder atom's value (the `encoder` tag exposed +/// by ffprobe). Layout: `©too` → `data` sub-box → 4-byte version/flags + 4-byte +/// reserved → the ASCII value. Returns the printable value (e.g. `"Google"`, +/// `"Lavf60.16.100"`). +pub(crate) fn find_encoder_tag(data: &[u8]) -> Option { + const TOO: &[u8] = &[0xA9, b't', b'o', b'o']; + let too = find(data, TOO)?; + // The `data` sub-box header sits a few bytes after `©too`; its value begins 8 + // bytes past the `data` marker (4 version/flags + 4 reserved/locale). + let data_marker = find_from(data, b"data", too)?; + let start = data_marker + 4 + 8; + let end = (start..data.len()) + .take_while(|&j| { + let b = data[j]; + b.is_ascii_graphic() || b == b'.' + }) + .last() + .map(|j| j + 1)?; + if end <= start { + return None; + } + std::str::from_utf8(&data[start..end]).ok().map(str::to_string) +} + +/// Extract a flat JSON string field value (`"key":"value"`) from raw bytes. +/// Assumes the value contains no escaped quotes (true for these manifests). +pub(crate) fn json_str_field(data: &[u8], key: &str) -> Option { + let pat = format!("\"{}\":\"", key); + let i = find(data, pat.as_bytes())?; + let start = i + pat.len(); + let rel_end = data[start..].iter().position(|&b| b == b'"')?; + String::from_utf8(data[start..start + rel_end].to_vec()).ok() +} + +/// Extract a flat JSON integer field value (`"key":`) from raw bytes. +pub(crate) fn json_int_field(data: &[u8], key: &str) -> Option { + let pat = format!("\"{}\":", key); + let i = find(data, pat.as_bytes())?; + let mut e = i + pat.len(); + let start = e; + if data.get(e) == Some(&b'-') { + e += 1; + } + while data.get(e).is_some_and(|b| b.is_ascii_digit()) { + e += 1; + } + if e == start || (e == start + 1 && data[start] == b'-') { + return None; + } + std::str::from_utf8(&data[start..e]).ok()?.parse().ok() +} + +/// CBOR-encode a text string (major type 3) — test helper for building manifests. +#[cfg(test)] +pub(crate) fn push_cbor_text(buf: &mut Vec, s: &str) { + let b = s.as_bytes(); + if b.len() <= 0x17 { + buf.push(0x60 + b.len() as u8); + } else { + buf.push(0x78); + buf.push(b.len() as u8); + } + buf.extend_from_slice(b); +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn cbor_text_reader_handles_length_encodings() { + let mut inline = Vec::new(); + push_cbor_text(&mut inline, "doubao-seedance-2-0"); // 19 chars, inline + assert_eq!(read_cbor_text(&inline, 0).as_deref(), Some("doubao-seedance-2-0")); + + let mut long = Vec::new(); + push_cbor_text(&mut long, "doubao-seedance-2-0-fast"); // 24 chars → 0x78 + assert_eq!(long[0], 0x78); + assert_eq!(read_cbor_text(&long, 0).as_deref(), Some("doubao-seedance-2-0-fast")); + } + + #[test] + fn base64url_decodes_log_id() { + // ATIAA7b8D_iKjF32GukAAAAA → 18 bytes, region byte 0x32 (Volcengine). + let bytes = decode_base64url("ATIAA7b8D_iKjF32GukAAAAA").unwrap(); + let hex: String = bytes.iter().map(|b| format!("{:02x}", b)).collect(); + assert_eq!(hex, "01320003b6fc0ff88a8c5df61ae900000000"); + } + + #[test] + fn cert_serial_reads_v3_prefix() { + let mut data = vec![0xFF, 0x00]; + data.extend_from_slice(&[0xA0, 0x03, 0x02, 0x01, 0x02, 0x02, 0x03, 0xAB, 0xCD, 0xEF]); + assert_eq!(find_cert_serial(&data).as_deref(), Some("ABCDEF")); + } +} diff --git a/crates/lib/video_info/src/seedance_info.rs b/crates/lib/video_info/src/seedance_info.rs new file mode 100644 index 0000000000..6e7e01b358 --- /dev/null +++ b/crates/lib/video_info/src/seedance_info.rs @@ -0,0 +1,417 @@ +//! Parse Seedance (ByteDance) generation provenance out of an MP4. +//! +//! Seedance videos carry a [C2PA](https://c2pa.org/) manifest embedded as a +//! JUMBF box. Inside is a CBOR `c2pa.created` action whose `softwareAgent` +//! identifies the generating platform (Volcengine vs BytePlus), the model, the +//! generation timestamp, and an opaque generation `log_id`. +//! +//! Rather than fully decode JUMBF + CBOR (which would require pulling in a C2PA +//! stack), we scan for the small, stable set of CBOR text fields we care about. +//! The values are CBOR text strings (major type 3) immediately following their +//! key, so each is recovered by locating the key and reading the text string. + +use std::fs; +use std::path::Path; + +use chrono::{DateTime, Utc}; + +use crate::error::VideoInfoError; +use crate::scan::{ + decode_base64url, find, find_cert_serial, find_org_identifier, find_prefixed_uuid, find_rfc3339, + text_after_key, text_after_key_from, +}; + +// ── Markers in the embedded C2PA manifest ── + +const VENDOR_VOLCENGINE: &[u8] = b"Volcengine_Ark_CN"; +const VENDOR_BYTEPLUS: &[u8] = b"BytePlus_ModelArk"; + +/// The platform that produced a Seedance generation. The two share the same +/// underlying model family but are operated as separate products / regions +/// (Volcengine = ByteDance China "Doubao"/Ark; BytePlus = international "Dreamina"). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum SeedancePlatform { + /// `Volcengine_Ark_CN`, signed by `…@volcengine.com`. + Volcengine, + /// `BytePlus_ModelArk`, signed by `Byteplus Pte. Ltd.` (`…@byteplus.com`). + BytePlus, +} + +impl SeedancePlatform { + pub fn as_str(&self) -> &'static str { + match self { + Self::Volcengine => "volcengine", + Self::BytePlus => "byteplus", + } + } +} + +/// Provenance extracted from a Seedance video's embedded C2PA manifest. +#[derive(Debug, Clone, PartialEq)] +pub struct SeedanceInfo { + /// Volcengine vs BytePlus. + pub platform: SeedancePlatform, + + /// The C2PA `softwareAgent` name, e.g. `"Volcengine_Ark_CN"` / `"BytePlus_ModelArk"`. + pub software_agent: String, + + /// The `softwareAgent` version, e.g. `"1.0.0"`. + 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-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`) 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, + + /// Parsed [`generated_at`](Self::generated_at), when it is valid RFC 3339. + pub generated_at_utc: Option>, + + /// Per-generation log / request id, base64url-encoded + /// (e.g. `"ATIAA7b8D_iKjF32GukAAAAA"`). + pub log_id: Option, + + /// [`log_id`](Self::log_id) base64url-decoded to hex (18 bytes). It is a + /// structured id: byte 0 = version (`01`), byte 1 = region/platform + /// (`0x32` = Volcengine, `0x33` = BytePlus), bytes 2–3 constant (`0003`), + /// bytes 4–13 a per-generation value, bytes 14–17 zero padding. The region + /// byte independently corroborates [`platform`](Self::platform). + pub log_id_decoded_hex: Option, + + /// IPTC digital source type URL — always trained-algorithmic-media for these. + pub digital_source_type: Option, + + /// C2PA claim generator, e.g. `"c2pa-rs"`. + pub claim_generator: Option, + + /// C2PA claim generator version, e.g. `"0.78.4"`. + pub claim_generator_version: Option, + + /// The manifest URN, e.g. `"urn:c2pa:40a5f6fe-b88a-4a2c-ae72-f76a1e5a6012"`. + /// Identifies the manifest; not a stable per-asset identity. + pub manifest_id: Option, + + /// The XMP asset instance id, e.g. `"xmp:iid:95a3ac67-cb28-4a0d-ac54-911f1787b7de"`. + /// Unique per generated asset (distinct from [`manifest_id`](Self::manifest_id)). + pub instance_id: Option, + + /// Signing certificate email, e.g. `"certificate_center@volcengine.com"`. + pub signer_email: Option, + + /// Signing certificate organization identifier (X.509 OID 2.5.4.97), e.g. + /// `"NTRSG-202024632W"` (Byteplus Pte. Ltd., Singapore) or + /// `"NTRCN-91110108MA01R70K8D"` (Beijing Volcano Engine, China). The `NTRxx` + /// scheme prefix is itself a strong jurisdiction signal. + pub signer_org_id: Option, + + /// Two-letter country of the signing organization, derived from the org + /// identifier scheme (`"SG"` for BytePlus, `"CN"` for Volcengine). + pub signer_country: Option, + + /// Hex serial number of the leaf signing certificate, e.g. + /// `"3687D48590A2FD5101AB0316"`. Stable per signing certificate (Volcengine + /// and BytePlus each reuse one), so it identifies the signer across assets — + /// useful for grouping/auditing, not as a per-asset id. + pub cert_serial: Option, +} + +impl SeedanceInfo { + /// Parse Seedance provenance from a video file on disk. + pub fn from_path(path: impl AsRef) -> Result { + let bytes = fs::read(path)?; + Self::from_bytes(&bytes) + } + + /// Parse Seedance provenance from raw video bytes. + /// + /// Returns [`VideoInfoError::NotSeedance`] if no Seedance/C2PA generative + /// manifest is present, or [`VideoInfoError::MalformedManifest`] if the + /// manifest is present but a required field can't be read. + pub fn from_bytes(data: &[u8]) -> Result { + let (platform, vendor_marker) = if let Some(_) = find(data, VENDOR_VOLCENGINE) { + (SeedancePlatform::Volcengine, VENDOR_VOLCENGINE) + } else if let Some(_) = find(data, VENDOR_BYTEPLUS) { + (SeedancePlatform::BytePlus, VENDOR_BYTEPLUS) + } else { + return Err(VideoInfoError::NotSeedance); + }; + + let software_agent = String::from_utf8_lossy(vendor_marker).into_owned(); + let vendor_end = find(data, vendor_marker).map(|i| i + vendor_marker.len()).unwrap_or(0); + + // model_name is required for a meaningful Seedance manifest. + let model_name = text_after_key(data, b"model_name") + .ok_or_else(|| VideoInfoError::MalformedManifest("missing model_name".to_string()))?; + + if !model_name.contains("seedance") { + // The vendor marker is present but the model isn't Seedance — treat as + // "not a Seedance generation" rather than a parse failure. + return Err(VideoInfoError::NotSeedance); + } + + let generated_at = find_rfc3339(data) + .ok_or_else(|| VideoInfoError::MalformedManifest("missing generation timestamp".to_string()))?; + let generated_at_utc = DateTime::parse_from_rfc3339(&generated_at) + .ok() + .map(|dt| dt.with_timezone(&Utc)); + + 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 + .as_deref() + .and_then(decode_base64url) + .map(|bytes| bytes.iter().map(|b| format!("{:02x}", b)).collect::()); + + let software_agent_version = text_after_key_from(data, b"version", vendor_end); + + let claim_generator = find(data, b"c2pa-rs").map(|_| "c2pa-rs".to_string()); + let claim_generator_version = find(data, b"c2pa-rs") + .and_then(|i| text_after_key_from(data, b"version", i + b"c2pa-rs".len())); + + let (signer_org_id, signer_country) = match find_org_identifier(data) { + Some((org_id, country)) => (Some(org_id), Some(country.to_string())), + None => (None, None), + }; + + Ok(SeedanceInfo { + platform, + software_agent, + software_agent_version, + model_name, + model_brand, + model_version, + is_fast, + is_lite, + generated_at, + generated_at_utc, + log_id, + log_id_decoded_hex, + digital_source_type: text_after_key(data, b"digitalSourceType"), + claim_generator, + claim_generator_version, + manifest_id: find_prefixed_uuid(data, b"urn:c2pa:"), + instance_id: find_prefixed_uuid(data, b"xmp:iid:"), + signer_email: find_signer_email(data), + signer_org_id, + signer_country, + cert_serial: find_cert_serial(data), + }) + } +} + +// ── Model name parsing ── + +/// 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 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, is_lite) +} + +// ── Seedance-specific scanners ── + +/// Signing certificate email (`…@volcengine.com` / `…@byteplus.com`), recovered +/// by finding the domain and walking back over the local part. +fn find_signer_email(data: &[u8]) -> Option { + for domain in [b"@volcengine.com".as_slice(), b"@byteplus.com".as_slice()] { + if let Some(at) = find(data, domain) { + let mut start = at; + while start > 0 { + let c = data[start - 1]; + if c.is_ascii_alphanumeric() || matches!(c, b'.' | b'_' | b'%' | b'+' | b'-') { + start -= 1; + } else { + break; + } + } + let end = at + domain.len(); + if let Ok(email) = std::str::from_utf8(&data[start..end]) { + if email.starts_with(|c: char| c.is_ascii_alphanumeric()) { + return Some(email.to_string()); + } + } + } + } + None +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::scan::push_cbor_text as push_text; + + // ── Helpers to synthesize a minimal C2PA-like manifest ── + + fn synth_manifest(vendor: &str, model: &str, time: &str, log_id: &str, signer: &str, org_id: &str) -> Vec { + let mut v = Vec::new(); + v.extend_from_slice(b"....ftypisom....moov....c2pa"); + // X.509 v3 TBS prefix + serial INTEGER (02 ) for serial 3687D485… + v.extend_from_slice(&[0xA0, 0x03, 0x02, 0x01, 0x02, 0x02, 0x0C]); + v.extend_from_slice(&[0x36, 0x87, 0xD4, 0x85, 0x90, 0xA2, 0xFD, 0x51, 0x01, 0xAB, 0x03, 0x16]); + v.extend_from_slice(b"urn:c2pa:40a5f6fe-b88a-4a2c-ae72-f76a1e5a6012"); + v.extend_from_slice(b"c2pa.createddwhen"); + push_text(&mut v, time); + v.extend_from_slice(b"msoftwareAgentdname"); + push_text(&mut v, vendor); + v.extend_from_slice(b"gversion"); + push_text(&mut v, "1.0.0"); + v.extend_from_slice(b"jparametersflog_id"); + push_text(&mut v, log_id); + v.extend_from_slice(b"dtime"); + push_text(&mut v, time); + v.extend_from_slice(b"jmodel_name"); + push_text(&mut v, model); + v.extend_from_slice(b"qdigitalSourceType"); + push_text(&mut v, "http://cv.iptc.org/newscodes/digitalsourcetype/trainedAlgorithmicMedia"); + v.extend_from_slice(b"jinstanceID"); + push_text(&mut v, "xmp:iid:95a3ac67-cb28-4a0d-ac54-911f1787b7de"); + v.extend_from_slice(b"tclaim_generator_infodname"); + push_text(&mut v, "c2pa-rs"); + v.extend_from_slice(b"gversion"); + push_text(&mut v, "0.78.4"); + // In a real file the signing cert's email is preceded by binary DER bytes; + // emulate that boundary so the email local-part walk-back terminates here. + v.push(0x00); + v.extend_from_slice(signer.as_bytes()); + // Org identifier (2.5.4.97) preceded by its DER length byte, as in a real cert. + v.push(0x31); // a DER tag byte before the length, like a real subject RDN + v.push(org_id.len() as u8); + v.extend_from_slice(org_id.as_bytes()); + v.push(0x31); // trailing DER byte after the value + v + } + + #[test] + fn parses_volcengine_fast() { + let data = synth_manifest( + "Volcengine_Ark_CN", "doubao-seedance-2-0-fast", + "2026-06-19T01:32:58Z", "ATIAA7b8D_iKjF32GukAAAAA", + "certificate_center@volcengine.com", "NTRCN-91110108MA01R70K8D", + ); + let info = SeedanceInfo::from_bytes(&data).expect("should parse"); + assert_eq!(info.platform, SeedancePlatform::Volcengine); + assert_eq!(info.signer_org_id.as_deref(), Some("NTRCN-91110108MA01R70K8D")); + assert_eq!(info.signer_country.as_deref(), Some("CN")); + assert_eq!(info.cert_serial.as_deref(), Some("3687D48590A2FD5101AB0316")); + assert_eq!(info.software_agent, "Volcengine_Ark_CN"); + assert_eq!(info.software_agent_version.as_deref(), Some("1.0.0")); + assert_eq!(info.model_name, "doubao-seedance-2-0-fast"); + 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")); + assert_eq!(info.log_id_decoded_hex.as_deref(), Some("01320003b6fc0ff88a8c5df61ae900000000")); + assert_eq!(info.claim_generator.as_deref(), Some("c2pa-rs")); + assert_eq!(info.claim_generator_version.as_deref(), Some("0.78.4")); + assert_eq!(info.manifest_id.as_deref(), Some("urn:c2pa:40a5f6fe-b88a-4a2c-ae72-f76a1e5a6012")); + assert_eq!(info.instance_id.as_deref(), Some("xmp:iid:95a3ac67-cb28-4a0d-ac54-911f1787b7de")); + assert_eq!(info.signer_email.as_deref(), Some("certificate_center@volcengine.com")); + assert!(info.digital_source_type.as_deref().unwrap().contains("trainedAlgorithmicMedia")); + } + + #[test] + fn parses_byteplus_non_fast() { + let data = synth_manifest( + "BytePlus_ModelArk", "dreamina-seedance-2-0", + "2026-06-19T01:27:48Z", "ATMAA7b36J8cLZO2iUkAAAAA", + "certificate@byteplus.com", "NTRSG-202024632W", + ); + let info = SeedanceInfo::from_bytes(&data).expect("should parse"); + assert_eq!(info.platform, SeedancePlatform::BytePlus); + assert_eq!(info.model_brand.as_deref(), Some("dreamina")); + assert_eq!(info.model_version.as_deref(), Some("2.0")); + assert!(!info.is_fast); + assert_eq!(info.signer_email.as_deref(), Some("certificate@byteplus.com")); + assert_eq!(info.signer_org_id.as_deref(), Some("NTRSG-202024632W")); + assert_eq!(info.signer_country.as_deref(), Some("SG")); + // BytePlus region byte is 0x33 (vs 0x32 for Volcengine). + 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...."; + match SeedanceInfo::from_bytes(data) { + Err(VideoInfoError::NotSeedance) => {} + other => panic!("expected NotSeedance, got {:?}", other), + } + } + + #[test] + fn vendor_without_model_is_malformed() { + // Vendor marker present, but no model_name key. + let mut data = Vec::new(); + data.extend_from_slice(b"....Volcengine_Ark_CN....2026-06-19T01:32:58Z...."); + match SeedanceInfo::from_bytes(&data) { + Err(VideoInfoError::MalformedManifest(_)) => {} + other => panic!("expected MalformedManifest, got {:?}", other), + } + } +} diff --git a/crates/lib/video_info/src/sora_info.rs b/crates/lib/video_info/src/sora_info.rs new file mode 100644 index 0000000000..c4fcdb1acd --- /dev/null +++ b/crates/lib/video_info/src/sora_info.rs @@ -0,0 +1,148 @@ +//! Parse OpenAI **Sora** (OpenAI Generative AI video) provenance out of an MP4. +//! +//! Like Veo, Sora attaches a [C2PA](https://c2pa.org/) Content Credentials +//! manifest (a JUMBF box with a CBOR claim) signed by OpenAI. The manifest's +//! `claim_generator` / signer identifies OpenAI; `digitalSourceType` is +//! `trainedAlgorithmicMedia`. +//! +//! ⚠️ **Unverified format.** Every Sora clip in our local sample set had been +//! re-encoded through ffmpeg (encoder `Lavf…`), which strips the C2PA box — so +//! this parser is modeled on OpenAI's documented Content Credentials structure +//! and the shared C2PA layout used by [`crate::veo_info`], but has **not** been +//! validated against an untouched Sora export. Field extraction is intentionally +//! generic (the C2PA markers we're confident about) until a clean sample exists. + +use std::fs; +use std::path::Path; + +use crate::error::VideoInfoError; +use crate::scan::{find, find_cert_serial, find_prefixed_uuid, text_after_key}; + +// ── Detection markers ── + +/// OpenAI's signer / claim-generator identity in the C2PA manifest. +const OPENAI_MARKER: &[u8] = b"OpenAI"; +/// A C2PA manifest must be present for these markers to be meaningful. +const C2PA_CLAIM_MARKER: &[u8] = b"c2pa.claim"; +const JUMBF_MARKER: &[u8] = b"jumbf"; +/// The Sora model token, when embedded. +const SORA_MARKER: &[u8] = b"sora"; + +/// Provenance extracted from an OpenAI Sora (OpenAI Generative AI) video. +#[derive(Debug, Clone, PartialEq)] +pub struct SoraInfo { + /// Always `"OpenAI"` — the producer established by the C2PA signer / generator. + pub producer: String, + + /// The model token if embedded (e.g. `"sora"`); often absent, like Veo. + pub model_name: Option, + + /// The `c2pa.created` action description, when present. + pub created_description: Option, + + /// IPTC digital source type URL — trained-algorithmic-media for these. + pub digital_source_type: Option, + + /// C2PA claim generator string. + pub claim_generator: Option, + + /// The manifest URN, e.g. `"urn:c2pa:…"`. + pub manifest_id: Option, + + /// The per-asset instance id. + pub instance_id: Option, + + /// Hex serial number of the leaf signing certificate. + pub cert_serial: Option, +} + +impl SoraInfo { + /// Parse Sora provenance from a video file on disk. + pub fn from_path(path: impl AsRef) -> Result { + let bytes = fs::read(path)?; + Self::from_bytes(&bytes) + } + + /// Parse Sora provenance from raw video bytes. + /// + /// Returns [`VideoInfoError::NotSora`] if no OpenAI C2PA manifest is present. + pub fn from_bytes(data: &[u8]) -> Result { + let has_c2pa = find(data, C2PA_CLAIM_MARKER).is_some() || find(data, JUMBF_MARKER).is_some(); + let is_openai = find(data, OPENAI_MARKER).is_some(); + if !(has_c2pa && is_openai) { + return Err(VideoInfoError::NotSora); + } + + Ok(SoraInfo { + producer: "OpenAI".to_string(), + model_name: find(data, SORA_MARKER).map(|_| "sora".to_string()), + created_description: find_description_containing(data, b"OpenAI") + .or_else(|| find_description_containing(data, b"Generated")), + digital_source_type: text_after_key(data, b"digitalSourceType"), + claim_generator: text_after_key(data, b"claim_generator"), + manifest_id: find_prefixed_uuid(data, b"urn:c2pa:"), + instance_id: text_after_key(data, b"instanceID"), + cert_serial: find_cert_serial(data), + }) + } +} + +/// Find a CBOR `description` value whose text contains `needle`. +fn find_description_containing(data: &[u8], needle: &[u8]) -> Option { + let mut from = 0; + while let Some(i) = crate::scan::find_from(data, b"description", from) { + if let Some(text) = crate::scan::read_cbor_text(data, i + b"description".len()) { + if text.as_bytes().windows(needle.len()).any(|w| w == needle) { + return Some(text); + } + } + from = i + b"description".len(); + } + None +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::scan::push_cbor_text as push_text; + + fn synth_sora() -> Vec { + let mut v = Vec::new(); + v.extend_from_slice(b"....ftypisom....moov....jumbfc2pa.claim"); + v.extend_from_slice(&[0xA0, 0x03, 0x02, 0x01, 0x02, 0x02, 0x03, 0xAB, 0xCD, 0xEF]); + v.extend_from_slice(b"OpenAI, OPC LLC"); + v.extend_from_slice(b"urn:c2pa:11111111-2222-3333-4444-555555555555"); + v.extend_from_slice(b"c2pa.createdkdescription"); + push_text(&mut v, "Generated by OpenAI."); + v.extend_from_slice(b"qdigitalSourceType"); + push_text(&mut v, "http://cv.iptc.org/newscodes/digitalsourcetype/trainedAlgorithmicMedia"); + v.extend_from_slice(b"jinstanceID"); + push_text(&mut v, "abcd1234-0000-0000-0000-000000000000"); + v.extend_from_slice(b"sora"); + v + } + + #[test] + fn parses_sora_manifest() { + let info = SoraInfo::from_bytes(&synth_sora()).expect("should parse"); + assert_eq!(info.producer, "OpenAI"); + assert_eq!(info.model_name.as_deref(), Some("sora")); + assert_eq!(info.created_description.as_deref(), Some("Generated by OpenAI.")); + assert_eq!(info.manifest_id.as_deref(), Some("urn:c2pa:11111111-2222-3333-4444-555555555555")); + assert_eq!(info.instance_id.as_deref(), Some("abcd1234-0000-0000-0000-000000000000")); + assert_eq!(info.cert_serial.as_deref(), Some("ABCDEF")); + assert!(info.digital_source_type.as_deref().unwrap().contains("trainedAlgorithmicMedia")); + } + + #[test] + fn non_openai_returns_not_sora() { + let data = b"....ftypisom....jumbfc2pa.claim....Volcengine_Ark_CN...."; + assert!(matches!(SoraInfo::from_bytes(data), Err(VideoInfoError::NotSora))); + } + + #[test] + fn openai_without_c2pa_returns_not_sora() { + let data = b"....ftypisom....mentions OpenAI but no manifest...."; + assert!(matches!(SoraInfo::from_bytes(data), Err(VideoInfoError::NotSora))); + } +} diff --git a/crates/lib/video_info/src/veo_info.rs b/crates/lib/video_info/src/veo_info.rs new file mode 100644 index 0000000000..8465f64f7f --- /dev/null +++ b/crates/lib/video_info/src/veo_info.rs @@ -0,0 +1,240 @@ +//! Parse Google **Veo** (Google Generative AI video) provenance out of an MP4. +//! +//! Like Seedance, Veo embeds a [C2PA](https://c2pa.org/) manifest as a JUMBF box. +//! Unlike Seedance, the manifest does **not** carry an explicit model name — +//! origin is established by the Google C2PA signer chain (`pki.goog`), the +//! `encoder=Google` tag, and the `c2pa.created` action description +//! "Created by Google Generative AI." A second `c2pa.edited` action records the +//! "Applied imperceptible SynthID watermark." step (Google DeepMind's SynthID). +//! +//! So "the model is Veo" is an inference from "Google-generated video" — we +//! surface every Google/C2PA field we can and leave model name `None` because it +//! genuinely isn't in the metadata. + +use std::fs; +use std::path::Path; + +use crate::error::VideoInfoError; +use crate::scan::{ + find, find_cert_serial, find_encoder_tag, find_prefixed_uuid, read_der_string, text_after_key, +}; + +// ── Detection markers ── + +/// The generative `c2pa.created` action description Google stamps. +const CREATED_BY_GOOGLE: &[u8] = b"Created by Google Generative AI"; +/// Google's C2PA signer infrastructure (cert CN / ICA names). +const GOOGLE_C2PA_MARKER: &[u8] = b"Google C2PA"; +/// The SynthID watermark edit description. +const SYNTHID_MARKER: &[u8] = b"SynthID watermark"; +/// Prefix of the issuing intermediate-CA CN that signs Veo manifests. +const SIGNER_CA_PREFIX: &[u8] = b"Google C2PA Media Services"; +/// Prefix of the RFC 3161 time-stamping CA CN. +const TIMESTAMP_CA_PREFIX: &[u8] = b"Google C2PA Core Time-Stamping"; +/// The bare `©too` encoder value Google stamps on Veo/Flow exports — survives a +/// re-encode that strips the C2PA box, so it's a weaker fallback signal. +const GOOGLE_ENCODER: &str = "Google"; + +/// Provenance extracted from a Google Veo (Google Generative AI) video. +#[derive(Debug, Clone, PartialEq)] +pub struct VeoInfo { + /// Always `"Google"` — the producer established by the signer chain + encoder. + pub producer: String, + + /// Whether a full Google C2PA manifest is present. `false` means the video was + /// recognized only by its `encoder=Google` tag (C2PA likely stripped by a + /// re-encode) — all the C2PA fields below will be `None`. + pub has_c2pa_manifest: bool, + + /// The `©too` encoder atom value, e.g. `"Google"`. + pub encoder: Option, + + /// The `c2pa.created` action description, e.g. `"Created by Google Generative AI."`. + pub created_description: Option, + + /// Whether a SynthID watermark edit action is present (Google DeepMind's + /// imperceptible watermark — applied to all Veo output). + pub has_synthid_watermark: bool, + + /// The SynthID edit description, e.g. `"Applied imperceptible SynthID watermark."`. + pub synthid_description: Option, + + /// IPTC digital source type URL — trained-algorithmic-media for these. + pub digital_source_type: Option, + + /// C2PA claim generator, e.g. `"Google C2PA Core Generator Library"`. + pub claim_generator: Option, + + /// C2PA claim generator version, e.g. `"929410399:929410399"`. + pub claim_generator_version: Option, + + /// The manifest URN, e.g. `"urn:c2pa:a6b2b914-8d34-c7f1-717f-4c622328d8f0"`. + pub manifest_id: Option, + + /// The per-asset instance id (bare UUID; Veo does not prefix it with `xmp:iid:`). + pub instance_id: Option, + + /// Hex serial number of the leaf signing certificate (Google's `pki.goog`), + /// e.g. `"E2139E2DAA977AC13E775A1CBD006D32BF4ACB"`. + pub cert_serial: Option, + + /// Issuing intermediate-CA common name, e.g. + /// `"Google C2PA Media Services 1P ICA G3"` — identifies Google's signing path. + pub signer_ca: Option, + + /// Whether the manifest carries an RFC 3161 trusted timestamp (a + /// `Google C2PA Core Time-Stamping` authority is present in the chain). + pub is_timestamped: bool, + + /// The time-stamping authority CN when present, e.g. + /// `"Google C2PA Core Time-Stamping ICA G3"`. + pub timestamp_authority: Option, +} + +impl VeoInfo { + /// Parse Veo provenance from a video file on disk. + pub fn from_path(path: impl AsRef) -> Result { + let bytes = fs::read(path)?; + Self::from_bytes(&bytes) + } + + /// Parse Veo provenance from raw video bytes. + /// + /// Returns [`VideoInfoError::NotVeo`] if no Google generative C2PA manifest is + /// present. + pub fn from_bytes(data: &[u8]) -> Result { + let has_c2pa_manifest = + find(data, CREATED_BY_GOOGLE).is_some() || find(data, GOOGLE_C2PA_MARKER).is_some(); + let encoder = find_encoder_tag(data); + let encoder_is_google = encoder.as_deref() == Some(GOOGLE_ENCODER); + if !has_c2pa_manifest && !encoder_is_google { + return Err(VideoInfoError::NotVeo); + } + + // The created/edited descriptions follow the CBOR `description` key; there + // are two `description` values (created, edited) and order can vary, so read + // by matching the leading text rather than positional assumptions. + let created_description = find_description_containing(data, b"Created by"); + let synthid_description = find_description_containing(data, b"SynthID"); + let has_synthid_watermark = + synthid_description.is_some() || find(data, SYNTHID_MARKER).is_some(); + + let claim_generator = text_after_key(data, b"claim_generator_info") + .or_else(|| find(data, b"Google C2PA Core Generator Library").map(|_| { + "Google C2PA Core Generator Library".to_string() + })); + let claim_generator_version = find(data, b"Google C2PA Core Generator Library") + .and_then(|i| { + crate::scan::text_after_key_from(data, b"version", i + "Google C2PA Core Generator Library".len()) + }); + + Ok(VeoInfo { + producer: "Google".to_string(), + has_c2pa_manifest, + encoder, + created_description, + has_synthid_watermark, + synthid_description, + digital_source_type: text_after_key(data, b"digitalSourceType"), + claim_generator, + claim_generator_version, + manifest_id: find_prefixed_uuid(data, b"urn:c2pa:"), + instance_id: text_after_key(data, b"instanceID"), + cert_serial: find_cert_serial(data), + signer_ca: read_der_string(data, SIGNER_CA_PREFIX), + is_timestamped: find(data, TIMESTAMP_CA_PREFIX).is_some(), + timestamp_authority: read_der_string(data, TIMESTAMP_CA_PREFIX), + }) + } +} + +/// Find a CBOR `description` value whose text begins with `needle`. There can be +/// several descriptions in the manifest (one per action); this returns the one +/// starting with the given text. +fn find_description_containing(data: &[u8], needle: &[u8]) -> Option { + let mut from = 0; + while let Some(i) = crate::scan::find_from(data, b"description", from) { + if let Some(text) = crate::scan::read_cbor_text(data, i + b"description".len()) { + if text.as_bytes().windows(needle.len()).any(|w| w == needle) { + return Some(text); + } + } + from = i + b"description".len(); + } + None +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::scan::push_cbor_text as push_text; + + fn synth_veo() -> Vec { + let mut v = Vec::new(); + v.extend_from_slice(b"....ftypisom....moov....c2pa"); + // X.509 v3 serial prefix. + v.extend_from_slice(&[0xA0, 0x03, 0x02, 0x01, 0x02, 0x02, 0x03, 0xE2, 0x13, 0x9E]); + // DER length-prefixed issuer + time-stamping CA CNs (length byte precedes). + let signer_ca = b"Google C2PA Media Services 1P ICA G3"; + v.push(signer_ca.len() as u8); + v.extend_from_slice(signer_ca); + let ts_ca = b"Google C2PA Core Time-Stamping ICA G3"; + v.push(ts_ca.len() as u8); + v.extend_from_slice(ts_ca); + v.extend_from_slice(b"urn:c2pa:a6b2b914-8d34-c7f1-717f-4c622328d8f0"); + v.extend_from_slice(b"c2pa.createdkdescription"); + push_text(&mut v, "Created by Google Generative AI."); + v.extend_from_slice(b"qdigitalSourceType"); + push_text(&mut v, "http://cv.iptc.org/newscodes/digitalsourcetype/trainedAlgorithmicMedia"); + v.extend_from_slice(b"c2pa.editedkdescription"); + push_text(&mut v, "Applied imperceptible SynthID watermark."); + v.extend_from_slice(b"jinstanceID"); + push_text(&mut v, "e9738dbe-1dbb-9b61-516c-46a64daa7499"); + v.extend_from_slice(b"dnamex\"Google C2PA Core Generator Librarygversion"); + push_text(&mut v, "929410399:929410399"); + v + } + + #[test] + fn parses_veo_manifest() { + let info = VeoInfo::from_bytes(&synth_veo()).expect("should parse"); + assert_eq!(info.producer, "Google"); + assert!(info.has_c2pa_manifest); + assert_eq!(info.created_description.as_deref(), Some("Created by Google Generative AI.")); + assert!(info.has_synthid_watermark); + assert_eq!(info.synthid_description.as_deref(), Some("Applied imperceptible SynthID watermark.")); + assert_eq!(info.claim_generator.as_deref(), Some("Google C2PA Core Generator Library")); + assert_eq!(info.claim_generator_version.as_deref(), Some("929410399:929410399")); + assert_eq!(info.manifest_id.as_deref(), Some("urn:c2pa:a6b2b914-8d34-c7f1-717f-4c622328d8f0")); + assert_eq!(info.instance_id.as_deref(), Some("e9738dbe-1dbb-9b61-516c-46a64daa7499")); + assert_eq!(info.cert_serial.as_deref(), Some("E2139E")); + assert_eq!(info.signer_ca.as_deref(), Some("Google C2PA Media Services 1P ICA G3")); + assert!(info.is_timestamped); + assert_eq!(info.timestamp_authority.as_deref(), Some("Google C2PA Core Time-Stamping ICA G3")); + assert!(info.digital_source_type.as_deref().unwrap().contains("trainedAlgorithmicMedia")); + } + + #[test] + fn detects_veo_by_encoder_tag_when_c2pa_stripped() { + // A re-encoded Veo: no C2PA manifest, but the `©too` encoder atom is "Google". + let mut v = Vec::new(); + v.extend_from_slice(b"....ftypisom....moov....udta....meta....ilst"); + v.extend_from_slice(&[0xA9, b't', b'o', b'o']); + v.extend_from_slice(b"....data"); + v.extend_from_slice(&[0, 0, 0, 1, 0, 0, 0, 0]); // version/flags + reserved + v.extend_from_slice(b"Google"); + let info = VeoInfo::from_bytes(&v).expect("should parse via encoder tag"); + assert_eq!(info.producer, "Google"); + assert!(!info.has_c2pa_manifest); + assert_eq!(info.encoder.as_deref(), Some("Google")); + assert_eq!(info.cert_serial, None); + assert_eq!(info.signer_ca, None); + assert!(!info.is_timestamped); + } + + #[test] + fn non_google_returns_not_veo() { + let data = b"....ftypisom....Volcengine_Ark_CN doubao-seedance-2-0...."; + assert!(matches!(VeoInfo::from_bytes(data), Err(VideoInfoError::NotVeo))); + } +} diff --git a/crates/lib/video_info/src/video.rs b/crates/lib/video_info/src/video.rs new file mode 100644 index 0000000000..f346298f0f --- /dev/null +++ b/crates/lib/video_info/src/video.rs @@ -0,0 +1,81 @@ +//! [`VideoInfo`] — a union over the supported AI-video provenance formats. + +use std::fs; +use std::path::Path; + +use crate::dreamina_info::DreaminaInfo; +use crate::error::VideoInfoError; +use crate::kling_info::KlingInfo; +use crate::seedance_info::SeedanceInfo; +use crate::sora_info::SoraInfo; +use crate::veo_info::VeoInfo; + +/// Recognized AI-video provenance, dispatched by [`VideoInfo::from_bytes`]. +#[derive(Debug, Clone, PartialEq)] +pub enum VideoInfo { + /// ByteDance Seedance API (Volcengine / BytePlus C2PA). + Seedance(SeedanceInfo), + /// Google Veo (Google Generative AI video C2PA). + Veo(VeoInfo), + /// OpenAI Sora (OpenAI Generative AI video C2PA). + Sora(SoraInfo), + /// Dreamina (ByteDance/CapCut consumer app `ilst` metadata). + Dreamina(DreaminaInfo), + /// Kling (Kuaishou AIGC-label `ilst` metadata). + Kling(KlingInfo), +} + +impl VideoInfo { + /// Detect and parse provenance from a video file on disk. + pub fn from_path(path: impl AsRef) -> Result { + let bytes = fs::read(path)?; + Self::from_bytes(&bytes) + } + + /// Detect and parse provenance from raw video bytes. + /// + /// C2PA-based formats (Seedance, Veo) are tried before the `ilst`-based + /// consumer-app formats (Dreamina, Kling): some Dreamina exports carry *both* + /// a Bytedance C2PA box and Dreamina `ilst` JSON, but they lack the Ark + /// vendor markers so [`SeedanceInfo`] declines them and they fall through to + /// Dreamina. Returns [`VideoInfoError::Unrecognized`] if no format matches. + pub fn from_bytes(data: &[u8]) -> Result { + match SeedanceInfo::from_bytes(data) { + Ok(info) => return Ok(VideoInfo::Seedance(info)), + Err(VideoInfoError::NotSeedance) => {} + Err(other) => return Err(other), + } + match VeoInfo::from_bytes(data) { + Ok(info) => return Ok(VideoInfo::Veo(info)), + Err(VideoInfoError::NotVeo) => {} + Err(other) => return Err(other), + } + match SoraInfo::from_bytes(data) { + Ok(info) => return Ok(VideoInfo::Sora(info)), + Err(VideoInfoError::NotSora) => {} + Err(other) => return Err(other), + } + match DreaminaInfo::from_bytes(data) { + Ok(info) => return Ok(VideoInfo::Dreamina(info)), + Err(VideoInfoError::NotDreamina) => {} + Err(other) => return Err(other), + } + match KlingInfo::from_bytes(data) { + Ok(info) => return Ok(VideoInfo::Kling(info)), + Err(VideoInfoError::NotKling) => {} + Err(other) => return Err(other), + } + Err(VideoInfoError::Unrecognized) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn unrecognized_for_plain_video() { + let data = b"....ftypisom....just a normal mp4...."; + assert!(matches!(VideoInfo::from_bytes(data), Err(VideoInfoError::Unrecognized))); + } +}