more info?

This commit is contained in:
Brandon Thomas committed 2026-06-19 09:53:41 -04:00
1 parent b499055806
commit 77a36f7149
3 files changed
+109 -24

No files matched your search

@@ -86,14 +86,28 @@ fn parse_args(args: impl Iterator<Item = String>) -> Result<Args, String> {
// ── 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<String, usize> = 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<Vec<PathBuf>> {
}
/// 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<VideoInfo, VideoInfoError>,
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)",
+8
View File
@@ -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<String> {
scan::find_encoder_tag(data)
}
+60 -16
View File
@@ -59,18 +59,24 @@ pub struct SeedanceInfo {
pub software_agent_version: Option<String>,
/// 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<String>,
/// 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<String>,
/// 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<String>, Option<String>, 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<String>, Option<String>, 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....";