mirror of
https://github.com/storytold/artcraft.git
synced 2026-11-03 08:57:54 +00:00
more info?
This commit is contained in:
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)",
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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....";
|
||||
|
||||
Reference in new issue
Block a user