feat(formats,viewer): movie subtitles, voice decode, and the movie manifest
The movie cutscene subtitle + voice pipeline, driven by the ADVERTISE_MOVIE manifest (the authoritative movie -> subtitle -> voice index). Also flushes several sessions of local WIP (async viewer loading, grouped-pool XBG7/hero-ship decode, drawlog tooling). See docs/HANDOFF-movie-voice-subtitles-2026-07-19.md. Subtitles (movie_subtitle.rs): - Full movie->track->text chain; join multi-line captions sharing one timing (fixes S13A dropped "Look at it father" line); overlap-safe active_cues(); Latin-1 accents preserved. Voice (slb.rs): XACT .slb -> XMA1 RIFF; take the FIRST sub-wave bounded by its declared data size (fixes S10-S16 alternate-take garble); list_voice_clips. Manifest (movie_manifest.rs): parse ADVERTISE_MOVIE (0x5B983A08) for the real movie->voice binding (not always VOICE_<movie>; e.g. hokyu -> VOICE_D_* in etc\). Resolve the token's sound.pak path via sounds.tbl. DIRECT bindings only — the demo-id shared-clip fallback for unbound hokyu movies was verified WRONG in-game and reverted (unbound hokyu stay unvoiced; correct join key is an OPEN problem). Viewer: manifest-driven voice (movie player toggle + solo button), standalone "Voice Lines" browser, stacked caption overlay. Tests: 46 formats-lib + 11 viewer-lib + movie_manifest/movie_subtitle/slb disc tests; full workspace green. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -7,7 +7,7 @@
|
||||
//! Run: `cargo test -p sylpheed-formats --test mesh_disc -- --ignored --nocapture`
|
||||
|
||||
use std::path::PathBuf;
|
||||
use sylpheed_formats::mesh::Xbg7Model;
|
||||
use sylpheed_formats::mesh::{material_groups, node_transforms, submesh_albedos, Xbg7Model};
|
||||
|
||||
fn res3d_dir() -> Option<PathBuf> {
|
||||
if let Ok(p) = std::env::var("SYLPHEED_RES3D") {
|
||||
@@ -21,6 +21,119 @@ fn res3d_dir() -> Option<PathBuf> {
|
||||
default.is_dir().then_some(default)
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "requires extracted disc models — set SYLPHEED_RES3D"]
|
||||
fn hero_ship_submesh_material_graph() {
|
||||
let Some(dir) = res3d_dir() else {
|
||||
eprintln!("SKIP: resource3d dir not found (set SYLPHEED_RES3D)");
|
||||
return;
|
||||
};
|
||||
// The XBG7 node/material graph tags each sub-mesh record with its own albedo
|
||||
// (`rou_…_col` → TX2D name). DeltaSaber_T `f001`: the 7 detail parts use
|
||||
// bdy_04/06/07 — NOT the body's bdy_01a — which is the per-part texturing the
|
||||
// viewer needs so fins aren't painted with the hull map.
|
||||
let bytes = std::fs::read(dir.join("DeltaSaber_T.xpr")).unwrap();
|
||||
let mut map = std::collections::HashMap::new();
|
||||
for (v, i, name) in submesh_albedos(&bytes, "f001") {
|
||||
map.insert((v, i), name);
|
||||
}
|
||||
// (vtx, idx) → expected albedo (rou_ stripped = TX2D name).
|
||||
assert_eq!(map.get(&(314, 645)).map(String::as_str), Some("f001_bdy_04_col"));
|
||||
assert_eq!(map.get(&(48, 84)).map(String::as_str), Some("f001_bdy_04_col"));
|
||||
assert_eq!(map.get(&(96, 180)).map(String::as_str), Some("f001_bdy_06_col"));
|
||||
assert_eq!(map.get(&(60, 108)).map(String::as_str), Some("f001_bdy_07_col"));
|
||||
// Names strip cleanly to real TX2D resources.
|
||||
let tex = sylpheed_formats::texture::X360Texture::texture_names(&bytes);
|
||||
for name in map.values() {
|
||||
assert!(tex.iter().any(|t| t == name), "albedo {name} not a TX2D resource");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "requires extracted disc models — set SYLPHEED_RES3D"]
|
||||
fn hero_ship_body_splits_by_material() {
|
||||
let Some(dir) = res3d_dir() else {
|
||||
eprintln!("SKIP: resource3d dir not found (set SYLPHEED_RES3D)");
|
||||
return;
|
||||
};
|
||||
// The merged body (sub0 = 24561 indices / 8187 tris) is drawn as 9 groups
|
||||
// sharing one index buffer, each with its own albedo (bdy_01a hull, bdy_01b,
|
||||
// bdy_02/03, and the `daiza` hangar stand). `material_groups` recovers the
|
||||
// cumulative-offset chain so the viewer can texture each slice.
|
||||
let bytes = std::fs::read(dir.join("DeltaSaber_T.xpr")).unwrap();
|
||||
let groups = material_groups(&bytes, "f001", 24561);
|
||||
assert_eq!(groups.len(), 9, "body must split into 9 material groups");
|
||||
// Offsets chain from 0 and cover the whole index buffer exactly.
|
||||
let mut cursor = 0usize;
|
||||
for g in &groups {
|
||||
assert_eq!(g.idx_offset, cursor, "group offsets must be contiguous");
|
||||
cursor += g.idx_count;
|
||||
}
|
||||
assert_eq!(cursor, 24561, "groups must cover all body indices");
|
||||
// Expected per-group albedo (verified against the GPU draw log).
|
||||
let albedos: Vec<&str> = groups.iter().map(|g| g.albedo.as_str()).collect();
|
||||
assert_eq!(
|
||||
albedos,
|
||||
[
|
||||
"f001_bdy_01a_col",
|
||||
"f001_bdy_01a_col",
|
||||
"f001_bdy_01a_col",
|
||||
"f001_bdy_01b_col",
|
||||
"f001_bdy_01a_col",
|
||||
"f001_bdy_01b_col",
|
||||
"f001_bdy_02_col",
|
||||
"f001_bdy_03_col",
|
||||
"f001_bdy_daiza_col",
|
||||
]
|
||||
);
|
||||
// A single-material detail part (sub1 = 645 indices) → one group, bdy_04.
|
||||
let fin = material_groups(&bytes, "f001", 645);
|
||||
assert_eq!(fin.len(), 1);
|
||||
assert_eq!(fin[0].albedo, "f001_bdy_04_col");
|
||||
assert_eq!(fin[0].idx_offset, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "requires extracted disc models — set SYLPHEED_RES3D"]
|
||||
fn hero_ship_node_transforms_move_fins_to_tail() {
|
||||
let Some(dir) = res3d_dir() else {
|
||||
eprintln!("SKIP: resource3d dir not found (set SYLPHEED_RES3D)");
|
||||
return;
|
||||
};
|
||||
// For the DeltaSaber, node_transforms returns the MEASURED runtime placements
|
||||
// (Canary draw-log WVP; see mesh.rs::DELTASABER_MEASURED) — the fin assemblies
|
||||
// are mounted OUT on the nacelles (world X ≈ −4.4..−6.6), an offset the static
|
||||
// graph omits. Body identity; each part a left/right mirrored pair. Vertex
|
||||
// space: X right, Y up, Z fore/aft (engines at −Z).
|
||||
let bytes = std::fs::read(dir.join("DeltaSaber_T.xpr")).unwrap();
|
||||
let places = node_transforms(&bytes, "f001");
|
||||
assert!(!places.is_empty(), "expected node placements");
|
||||
// Body (sub 0) identity, drawn once.
|
||||
let body: Vec<_> = places.iter().filter(|p| p.sub_index == 0).collect();
|
||||
assert_eq!(body.len(), 1, "body drawn once");
|
||||
assert!(body[0].t.iter().all(|c| c.abs() < 0.1), "body must not translate");
|
||||
assert!(!body[0].reflect, "body is not mirrored");
|
||||
// Fin bdy_04 (sub 1): mirrored pair near the tail, mounted OUT on the nacelle
|
||||
// (|X| ≈ 5.2, not the graph's inboard ≈1.1).
|
||||
let fins: Vec<_> = places.iter().filter(|p| p.sub_index == 1).collect();
|
||||
assert_eq!(fins.len(), 2, "V-tail is a mirrored pair");
|
||||
assert!(fins.iter().all(|f| (f.t[2] + 9.72).abs() < 0.3), "fin fore/aft ≈ −9.7");
|
||||
assert!(fins.iter().all(|f| f.t[0].abs() > 4.0), "fin mounted out on the nacelle");
|
||||
assert!(fins[0].t[0] * fins[1].t[0] < 0.0, "the pair mirrors across X");
|
||||
assert!(fins.iter().any(|f| f.reflect), "one of the pair is reflected");
|
||||
// Winglet bdy_06 (sub 4): on the nacelle (|X| ≈ 6.6, Z ≈ −15.5).
|
||||
let wings: Vec<_> = places.iter().filter(|p| p.sub_index == 4).collect();
|
||||
assert_eq!(wings.len(), 2, "L/R winglet pair");
|
||||
assert!(wings.iter().all(|p| p.t[0].abs() > 6.0 && (p.t[2] + 15.5).abs() < 0.3),
|
||||
"winglets out on the nacelle");
|
||||
assert!(wings[0].t[0] * wings[1].t[0] < 0.0, "winglets mirror across X");
|
||||
// Small fin bdy_10 (sub 6): inboard nacelle stub (|X| ≈ 4.45, Z ≈ −17).
|
||||
let sfins: Vec<_> = places.iter().filter(|p| p.sub_index == 6).collect();
|
||||
assert_eq!(sfins.len(), 2, "L/R small-fin pair");
|
||||
assert!(sfins.iter().all(|p| (p.t[0].abs() - 4.45).abs() < 0.3), "small fins on the stub");
|
||||
assert!(sfins[0].t[0] * sfins[1].t[0] < 0.0, "small fins mirror across X");
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "requires extracted disc models — set SYLPHEED_RES3D"]
|
||||
fn weapon_model_decodes_to_expected_geometry() {
|
||||
|
||||
102
crates/sylpheed-formats/tests/movie_manifest_disc.rs
Normal file
102
crates/sylpheed-formats/tests/movie_manifest_disc.rs
Normal file
@@ -0,0 +1,102 @@
|
||||
//! Real-disc test for the movie manifest → voice binding. Skipped without
|
||||
//! `SYLPHEED_DISC`.
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
use sylpheed_formats::movie_manifest;
|
||||
use sylpheed_formats::slb::VoiceLang;
|
||||
use sylpheed_formats::PakArchive;
|
||||
|
||||
fn disc_root() -> Option<PathBuf> {
|
||||
let p = PathBuf::from(std::env::var("SYLPHEED_DISC").ok()?);
|
||||
p.join("dat").is_dir().then_some(p)
|
||||
}
|
||||
|
||||
/// Read the manifest + `eng\sounds.tbl` out of `tables.pak`.
|
||||
fn load_manifest_and_sounds(root: &PathBuf) -> (Vec<u8>, Vec<u8>) {
|
||||
let pak = PakArchive::open(root.join("dat/tables.pak")).unwrap();
|
||||
let manifest = pak
|
||||
.entries()
|
||||
.iter()
|
||||
.find_map(|e| pak.read(e).ok().filter(|b| movie_manifest::is_manifest(b)))
|
||||
.expect("movie manifest present in tables.pak");
|
||||
let sounds = pak
|
||||
.read_by_name("eng\\sounds.tbl")
|
||||
.expect("sounds.tbl present")
|
||||
.unwrap();
|
||||
(manifest, sounds)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn binds_and_resolves_movie_voice() {
|
||||
let Some(root) = disc_root() else {
|
||||
eprintln!("SKIP: set SYLPHEED_DISC");
|
||||
return;
|
||||
};
|
||||
let (manifest, sounds) = load_manifest_and_sounds(&root);
|
||||
let entries = movie_manifest::parse(&manifest);
|
||||
assert!(entries.len() > 90, "expected ~101 movies, got {}", entries.len());
|
||||
|
||||
// Standard story movie → VOICE_<movie> in <lang>\Movie\.
|
||||
assert_eq!(
|
||||
movie_manifest::voice_token(&manifest, "S13A").as_deref(),
|
||||
Some("VOICE_S13A")
|
||||
);
|
||||
assert_eq!(
|
||||
movie_manifest::resolve_voice_entry(&manifest, &sounds, "S13A", VoiceLang::English)
|
||||
.as_deref(),
|
||||
Some("eng\\Movie\\VOICE_S13A.slb")
|
||||
);
|
||||
|
||||
// A resupply movie bound to an in-mission radio clip → VOICE_D_* in \etc\.
|
||||
// (This is what a `VOICE_<movie>` guess would miss entirely.)
|
||||
assert_eq!(
|
||||
movie_manifest::voice_token(&manifest, "hokyu_LS_s02A").as_deref(),
|
||||
Some("VOICE_D_450")
|
||||
);
|
||||
assert_eq!(
|
||||
movie_manifest::resolve_voice_entry(
|
||||
&manifest,
|
||||
&sounds,
|
||||
"hokyu_LS_s02A",
|
||||
VoiceLang::English
|
||||
)
|
||||
.as_deref(),
|
||||
Some("eng\\etc\\VOICE_D_450.slb")
|
||||
);
|
||||
|
||||
// A resupply movie the game leaves unbound in the manifest → no DIRECT
|
||||
// binding. (Extending to unbound movies by shared demo line was verified
|
||||
// WRONG against the running game, so we do NOT resolve these.)
|
||||
let e = entries
|
||||
.iter()
|
||||
.find(|e| e.movie == "hokyu_DS_s13A")
|
||||
.expect("hokyu_DS_s13A present in manifest");
|
||||
assert_eq!(e.voice_token, None, "hokyu_DS_s13A has no direct voice binding");
|
||||
assert_eq!(
|
||||
movie_manifest::resolve_voice_entry(&manifest, &sounds, "hokyu_DS_s13A", VoiceLang::English),
|
||||
None
|
||||
);
|
||||
|
||||
// Every resolved voice entry must actually exist in sound.pak.
|
||||
let snd = std::fs::read(root.join("dat/sound.pak")).unwrap();
|
||||
let keys: std::collections::HashSet<u32> = PakArchive::parse_toc(&snd)
|
||||
.unwrap()
|
||||
.iter()
|
||||
.map(|e| e.name_hash)
|
||||
.collect();
|
||||
let mut resolved = 0;
|
||||
let mut missing = Vec::new();
|
||||
for e in &entries {
|
||||
if let Some(full) =
|
||||
movie_manifest::resolve_voice_entry(&manifest, &sounds, &e.movie, VoiceLang::English)
|
||||
{
|
||||
resolved += 1;
|
||||
if !keys.contains(&sylpheed_formats::hash::name_hash(&full)) {
|
||||
missing.push(full);
|
||||
}
|
||||
}
|
||||
}
|
||||
assert!(resolved > 80, "expected 80+ voiced movies, got {resolved}");
|
||||
assert!(missing.is_empty(), "resolved but absent in sound.pak: {missing:?}");
|
||||
}
|
||||
63
crates/sylpheed-formats/tests/movie_subtitle_disc.rs
Normal file
63
crates/sylpheed-formats/tests/movie_subtitle_disc.rs
Normal file
@@ -0,0 +1,63 @@
|
||||
//! Real-disc test for the movie subtitle chain. Skipped when the extracted disc
|
||||
//! is absent (set `SYLPHEED_DISC` to the extract root to enable).
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
use sylpheed_formats::movie_subtitle::{self, SubLang};
|
||||
use sylpheed_formats::PakArchive;
|
||||
|
||||
fn disc_root() -> Option<PathBuf> {
|
||||
let p = PathBuf::from(std::env::var("SYLPHEED_DISC").ok()?);
|
||||
p.join("dat").is_dir().then_some(p)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolves_english_radio_subtitles() {
|
||||
let Some(root) = disc_root() else {
|
||||
eprintln!("SKIP: set SYLPHEED_DISC");
|
||||
return;
|
||||
};
|
||||
let lang = SubLang::English;
|
||||
let lang_pak = PakArchive::open(root.join(format!("dat/movie/{}.pak", lang.pak_code()))).unwrap();
|
||||
let text_pak =
|
||||
PakArchive::open(root.join(format!("dat/GP_MAIN_GAME_{}.pak", lang.game_code()))).unwrap();
|
||||
|
||||
// A radio movie: real timed English dialogue.
|
||||
let cues = movie_subtitle::load("RT01C_1", &lang_pak, &text_pak);
|
||||
assert!(!cues.is_empty(), "RT01C_1 should have timed cues");
|
||||
assert!(cues[0].start >= 0.0 && cues[0].start < 5.0);
|
||||
assert!(
|
||||
cues.iter().any(|c| c.text.contains("Rhino Leader")),
|
||||
"expected recognizable dialogue, got: {:?}",
|
||||
cues.iter().map(|c| &c.text).collect::<Vec<_>>()
|
||||
);
|
||||
|
||||
// No MSG_DEMO keys should leak into resolved caption text.
|
||||
assert!(
|
||||
cues.iter().all(|c| !c.text.contains("MSG_DEMO")),
|
||||
"unresolved key leaked: {:?}",
|
||||
cues.iter().map(|c| &c.text).collect::<Vec<_>>()
|
||||
);
|
||||
|
||||
// A resupply movie: the canonical resupply line.
|
||||
let hokyu = movie_subtitle::load("hokyu_DS_s02A", &lang_pak, &text_pak);
|
||||
assert!(hokyu.iter().any(|c| c.text.contains("Resupply complete")));
|
||||
|
||||
// S00A carries inline narration text (not MSG_DEMO refs) with timecodes.
|
||||
let story = movie_subtitle::load("S00A", &lang_pak, &text_pak);
|
||||
assert!(!story.is_empty() && story.iter().any(|c| c.text.contains("27th century")));
|
||||
|
||||
// S13A stores a two-line caption as two consecutive text tokens sharing one
|
||||
// timing; both lines must survive (the "Look at it father" regression — the
|
||||
// first line used to be dropped).
|
||||
let s13 = movie_subtitle::load("S13A", &lang_pak, &text_pak);
|
||||
let multiline = s13
|
||||
.iter()
|
||||
.find(|c| c.text.contains("Look at it father"))
|
||||
.expect("S13A should contain the 'Look at it father' caption");
|
||||
assert_eq!(
|
||||
multiline.text, "Look at it father\n& beautiful isn't it",
|
||||
"both lines of the caption must be present"
|
||||
);
|
||||
assert!((multiline.start - 74.8).abs() < 0.1, "start {}", multiline.start);
|
||||
}
|
||||
104
crates/sylpheed-formats/tests/slb_disc.rs
Normal file
104
crates/sylpheed-formats/tests/slb_disc.rs
Normal file
@@ -0,0 +1,104 @@
|
||||
//! Real-disc test: read a movie voice `.slb` out of the segmented `sound.pak`
|
||||
//! and rebuild a decodable XMA1 RIFF. Skipped without `SYLPHEED_DISC`.
|
||||
|
||||
use std::fs::File;
|
||||
use std::io::{Read, Seek, SeekFrom};
|
||||
use std::path::PathBuf;
|
||||
|
||||
use sylpheed_formats::hash::name_hash;
|
||||
use sylpheed_formats::slb::{self, VoiceLang};
|
||||
use sylpheed_formats::PakArchive;
|
||||
|
||||
fn disc_root() -> Option<PathBuf> {
|
||||
let p = PathBuf::from(std::env::var("SYLPHEED_DISC").ok()?);
|
||||
p.join("dat").is_dir().then_some(p)
|
||||
}
|
||||
|
||||
/// Read `[off, off+size)` from `dat/sound.p00..` (segments concatenated).
|
||||
fn read_range(root: &PathBuf, mut off: u64, size: usize) -> Vec<u8> {
|
||||
let mut out = Vec::with_capacity(size);
|
||||
let mut need = size;
|
||||
for i in 0..100u32 {
|
||||
if need == 0 {
|
||||
break;
|
||||
}
|
||||
let seg = root.join(format!("dat/sound.p{i:02}"));
|
||||
let Ok(meta) = std::fs::metadata(&seg) else { break };
|
||||
let len = meta.len();
|
||||
if off >= len {
|
||||
off -= len;
|
||||
continue;
|
||||
}
|
||||
let mut f = File::open(&seg).unwrap();
|
||||
f.seek(SeekFrom::Start(off)).unwrap();
|
||||
let take = need.min((len - off) as usize);
|
||||
let start = out.len();
|
||||
out.resize(start + take, 0);
|
||||
f.read_exact(&mut out[start..]).unwrap();
|
||||
need -= take;
|
||||
off = 0;
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reads_and_rebuilds_movie_voice_riff() {
|
||||
let Some(root) = disc_root() else {
|
||||
eprintln!("SKIP: set SYLPHEED_DISC");
|
||||
return;
|
||||
};
|
||||
let toc = std::fs::read(root.join("dat/sound.pak")).unwrap();
|
||||
let entries = PakArchive::parse_toc(&toc).unwrap();
|
||||
assert_eq!(entries.len(), 9519);
|
||||
|
||||
for movie in ["S00A", "RT07A", "RT02D_1"] {
|
||||
// RT02D_1 is a headerless variant; S00A/RT07A embed a RIFF.
|
||||
let key = name_hash(&slb::movie_voice_name(movie, VoiceLang::English));
|
||||
let idx = entries
|
||||
.binary_search_by_key(&key, |e| e.name_hash)
|
||||
.unwrap_or_else(|_| panic!("{movie} voice missing"));
|
||||
let e = &entries[idx];
|
||||
let bank = read_range(&root, e.offset as u64, e.comp_size as usize);
|
||||
|
||||
let riff = slb::to_xma_riff(&bank).expect("rebuild RIFF");
|
||||
assert_eq!(&riff[0..4], b"RIFF", "{movie}");
|
||||
assert_eq!(&riff[8..12], b"WAVE", "{movie}");
|
||||
// fmt chunk advertises XMA1 (tag 0x0165).
|
||||
let fpos = riff.windows(4).position(|w| w == b"fmt ").unwrap();
|
||||
let tag = u16::from_le_bytes([riff[fpos + 8], riff[fpos + 9]]);
|
||||
assert_eq!(tag, 0x0165, "{movie} should be XMA1");
|
||||
// data chunk carries the XMA payload.
|
||||
let dpos = riff.windows(4).position(|w| w == b"data").unwrap();
|
||||
let dsz = u32::from_le_bytes([
|
||||
riff[dpos + 4],
|
||||
riff[dpos + 5],
|
||||
riff[dpos + 6],
|
||||
riff[dpos + 7],
|
||||
]) as usize;
|
||||
assert!(dsz > 10_000, "{movie} data too small: {dsz}");
|
||||
assert_eq!(riff.len(), dpos + 8 + dsz, "{movie} data length consistent");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn enumerates_voice_clips_from_sounds_tbl() {
|
||||
let Some(root) = disc_root() else {
|
||||
eprintln!("SKIP: set SYLPHEED_DISC");
|
||||
return;
|
||||
};
|
||||
let pak = PakArchive::open(root.join("dat/tables.pak")).unwrap();
|
||||
let tbl = pak.read_by_name("eng\\sounds.tbl").unwrap().unwrap();
|
||||
let clips = slb::list_voice_clips(&tbl, VoiceLang::English);
|
||||
assert!(clips.len() > 1000, "expected many voice clips, got {}", clips.len());
|
||||
// Every clip is an eng voice path present in sound.pak.
|
||||
let sound = std::fs::read(root.join("dat/sound.pak")).unwrap();
|
||||
let keys: std::collections::HashSet<u32> =
|
||||
PakArchive::parse_toc(&sound).unwrap().iter().map(|e| e.name_hash).collect();
|
||||
let present = clips.iter().filter(|c| keys.contains(&name_hash(&c.name))).count();
|
||||
assert!(
|
||||
present as f32 / clips.len() as f32 > 0.95,
|
||||
"{present}/{} clips resolve in sound.pak",
|
||||
clips.len()
|
||||
);
|
||||
assert!(clips.iter().any(|c| c.speaker == "ADAN"));
|
||||
}
|
||||
Reference in New Issue
Block a user