simple_editor\engine/
import.rs

1//! Import timelines from other editors, with an honest per-item report.
2//!
3//! Supported: Final Cut Pro 7 XML (`.xml` - what Premiere Pro exports and DaVinci Resolve reads/writes),
4//! EDL (CMX 3600) and *uncompressed* Premiere `.prproj` (the same XML reader). A real `.prproj` is
5//! gzip-wrapped and there is no inflate in-tree, so those are accepted by the picker only to answer
6//! with the "export FCP7 XML instead" note below. Anything the reader cannot map (unknown effects,
7//! missing media, speed ramps, colour pages) becomes an `Issue` instead of failing the import.
8
9use crate::media::{self, is_image_path, Backend};
10use crate::model::{Asset, AudioStreamInfo, Clip, ClipKind, Id, Project, TrackKind, TransitionKind, MIN_CLIP};
11use std::collections::HashMap;
12use std::fmt::Write as _;
13use std::path::{Path, PathBuf};
14use std::sync::mpsc::Receiver;
15use std::sync::Mutex;
16
17#[derive(Clone, Copy, PartialEq, Eq, Debug)]
18pub enum Level {
19    Ok,
20    Warning,
21    Skipped,
22}
23
24impl Level {
25    fn name(self) -> &'static str {
26        match self {
27            Level::Ok => "Ok",
28            Level::Warning => "Warning",
29            Level::Skipped => "Skipped",
30        }
31    }
32}
33
34#[derive(Clone, Debug)]
35pub struct Issue {
36    pub level: Level,
37    /// What it was about ("clip 'a.mp4'", "effect 'Gaussian Blur'", "track V2").
38    pub subject: String,
39    pub detail: String,
40}
41
42#[derive(Debug)]
43pub struct ImportReport {
44    pub project: Project,
45    pub issues: Vec<Issue>,
46    pub clips: usize,
47    pub tracks: usize,
48    pub missing_media: usize,
49}
50
51impl ImportReport {
52    pub fn ok(&self) -> usize {
53        self.issues.iter().filter(|i| i.level == Level::Ok).count()
54    }
55    pub fn problems(&self) -> usize {
56        self.issues.iter().filter(|i| i.level != Level::Ok).count()
57    }
58    /// Markdown table for the import dialog / clipboard.
59    pub fn to_markdown(&self) -> String {
60        let mut s = String::with_capacity(256 + self.issues.len() * 80);
61        let _ = writeln!(s, "# Import: {}\n", self.project.name);
62        let _ = writeln!(
63            s,
64            "{} clips on {} tracks, {}×{} @ {:.3} fps.",
65            self.clips, self.tracks, self.project.width, self.project.height, self.project.fps
66        );
67        let _ =
68            writeln!(s, "{} missing files, {} problems, {} notes.\n", self.missing_media, self.problems(), self.ok());
69        let _ = writeln!(s, "| Level | Item | Detail |");
70        let _ = writeln!(s, "| --- | --- | --- |");
71        for i in &self.issues {
72            let _ = writeln!(s, "| {} | {} | {} |", i.level.name(), cell(&i.subject), cell(&i.detail));
73        }
74        s
75    }
76}
77
78/// A markdown table cell: no pipes, no line breaks.
79fn cell(s: &str) -> String {
80    s.replace('|', "\\|").replace(['\n', '\r'], " ")
81}
82
83/// Detect the format by extension + content and import it.
84pub fn import_file(path: &std::path::Path) -> Result<ImportReport, String> {
85    let bytes = std::fs::read(path).map_err(|e| format!("{}: {e}", path.display()))?;
86    let dir = path.parent().unwrap_or_else(|| Path::new(".")).to_path_buf();
87    let stem = path.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default();
88    let mut b = Build::new(dir, &stem);
89    if bytes.starts_with(&[0x1f, 0x8b]) {
90        // ponytail: no inflate in-tree (no new crates) — say so instead of failing silently
91        b.note(
92            Level::Skipped,
93            format!("file '{stem}'"),
94            "Premiere .prproj files are gzip-compressed and cannot be read here. In Premiere Pro use \
95             File → Export → Final Cut Pro XML (or Media Browser → export an EDL) and import that file.",
96        );
97        return Ok(b.finish());
98    }
99    let text = decode(&bytes);
100    if text.contains("<xmeml") {
101        read_xmeml(&text, &mut b)?;
102    } else if text.contains("<fcpxml") {
103        b.note(
104            Level::Skipped,
105            format!("file '{stem}'"),
106            "This is Final Cut Pro X XML. Only the FCP7 / xmeml interchange format is supported — \
107             re-export as \"Final Cut Pro XML\" (version 4/5) or as an EDL.",
108        );
109    } else if crate::engine::export::ext_of(path) == "edl" || looks_like_edl(&text) {
110        read_edl(&text, &mut b);
111    } else {
112        return Err("Not a timeline: expected FCP7 XML (xmeml), EDL or an uncompressed .prproj.".into());
113    }
114    Ok(b.finish())
115}
116
117pub const IMPORT_EXTS: &[&str] = &["xml", "fcpxml", "edl", "prproj"];
118
119/// Bytes → text: strips a UTF-8 BOM, decodes UTF-16 (LE/BE BOM) and never fails.
120fn decode(bytes: &[u8]) -> String {
121    match bytes {
122        [0xef, 0xbb, 0xbf, rest @ ..] => String::from_utf8_lossy(rest).into_owned(),
123        [0xff, 0xfe, rest @ ..] => utf16(rest, true),
124        [0xfe, 0xff, rest @ ..] => utf16(rest, false),
125        _ => String::from_utf8_lossy(bytes).into_owned(),
126    }
127}
128
129fn utf16(bytes: &[u8], le: bool) -> String {
130    let units: Vec<u16> = bytes
131        .chunks_exact(2)
132        .map(|c| if le { u16::from_le_bytes([c[0], c[1]]) } else { u16::from_be_bytes([c[0], c[1]]) })
133        .collect();
134    String::from_utf16_lossy(&units)
135}
136
137// ---------------------------------------------------------------- report building
138
139struct Build {
140    project: Project,
141    issues: Vec<Issue>,
142    dir: PathBuf,
143    /// Asset paths that could not be found on disk.
144    missing: Vec<String>,
145}
146
147impl Build {
148    fn new(dir: PathBuf, name: &str) -> Self {
149        let mut project = Project::new();
150        project.name = Path::new(name).file_stem().map(|s| s.to_string_lossy().into_owned()).unwrap_or_default();
151        Self { project, issues: Vec::new(), dir, missing: Vec::new() }
152    }
153    fn note(&mut self, level: Level, subject: impl Into<String>, detail: impl Into<String>) {
154        self.issues.push(Issue { level, subject: subject.into(), detail: detail.into() });
155    }
156    /// Existing path for `raw` (relinked next to the imported file when missing), plus a note.
157    fn resolve(&mut self, raw: &str, subject: &str) -> String {
158        if raw.is_empty() {
159            return raw.to_string();
160        }
161        if Path::new(raw).is_file() {
162            return raw.to_string();
163        }
164        if let Some(p) = relink(&self.dir, raw) {
165            let p = p.to_string_lossy().into_owned();
166            self.note(Level::Ok, subject, format!("relinked to {p}"));
167            return p;
168        }
169        if !self.missing.iter().any(|m| m.eq_ignore_ascii_case(raw)) {
170            self.missing.push(raw.to_string());
171            self.note(Level::Warning, subject, format!("media not found: {raw} (relink it in the library)"));
172        }
173        raw.to_string()
174    }
175    /// Add an asset, filling the metadata from the real file when it exists.
176    fn add_asset(&mut self, mut a: Asset) -> Id {
177        if Path::new(&a.path).is_file() {
178            if let Ok(p) = crate::media::probe(&a.path, crate::media::Backend::Auto) {
179                a = Asset { folder: a.folder, tags: a.tags, label: a.label, description: a.description, ..p };
180            }
181        }
182        self.project.add_asset(a)
183    }
184    fn finish(mut self) -> ImportReport {
185        self.project.tidy();
186        let clips = self.project.tracks.iter().map(|t| t.clips.len()).sum();
187        let tracks = self.project.tracks.iter().filter(|t| !t.clips.is_empty()).count();
188        ImportReport { issues: self.issues, clips, tracks, missing_media: self.missing.len(), project: self.project }
189    }
190}
191
192/// Look for the file next to the imported timeline, then one level of sub-folders.
193fn relink(dir: &Path, path: &str) -> Option<PathBuf> {
194    let name = Path::new(path).file_name()?;
195    let direct = dir.join(name);
196    if direct.is_file() {
197        return Some(direct);
198    }
199    for e in std::fs::read_dir(dir).ok()?.flatten() {
200        if e.file_type().map(|t| t.is_dir()).unwrap_or(false) {
201            let p = e.path().join(name);
202            if p.is_file() {
203                return Some(p);
204            }
205        }
206    }
207    None
208}
209
210/// Ensure the `n`-th track of `kind` exists and return its index.
211fn track_slot(p: &mut Project, kind: TrackKind, n: usize) -> usize {
212    loop {
213        let list = if kind == TrackKind::Video { p.video_tracks() } else { p.audio_tracks() };
214        if let Some(&i) = list.get(n) {
215            return i;
216        }
217        p.add_track(kind);
218    }
219}
220
221/// Put the clip on `ti`, or on the first track of the same kind with room (a new one if needed).
222fn place(p: &mut Project, ti: usize, c: Clip) -> Id {
223    let id = c.id;
224    let ti = if p.tracks[ti].fits(c.start, c.duration, &[]) {
225        ti
226    } else {
227        p.find_free_track(p.tracks[ti].kind, c.start, c.duration, None)
228    };
229    p.tracks[ti].clips.push(c);
230    p.tracks[ti].sort();
231    id
232}
233
234/// Clips of one asset at the same place on different tracks are one linked group (video + its audio),
235/// which is how `Project::insert_asset_clips` builds them.
236fn link_stacks(p: &mut Project) {
237    let mut groups: HashMap<(Id, i64, i64), Vec<Id>> = HashMap::new();
238    for (_, c) in p.all_clips() {
239        if c.asset != 0 {
240            groups.entry((c.asset, ms(c.start), ms(c.duration))).or_default().push(c.id);
241        }
242    }
243    for ids in groups.into_values().filter(|g| g.len() > 1) {
244        let link = p.new_id();
245        for id in ids {
246            if let Some(c) = p.clip_mut(id) {
247                c.link = link;
248            }
249        }
250    }
251}
252
253fn ms(t: f64) -> i64 {
254    (t * 1000.0).round() as i64
255}
256
257// ---------------------------------------------------------------- FCP7 XML (xmeml)
258
259fn read_xmeml(text: &str, b: &mut Build) -> Result<(), String> {
260    let root = parse_xml(text)?;
261    let mut seqs = Vec::new();
262    root.find_all("sequence", &mut seqs);
263    let seq = *seqs.first().ok_or("no <sequence> in this XML")?;
264    if seqs.len() > 1 {
265        b.note(Level::Warning, "file", format!("{} sequences found; only the first was imported", seqs.len()));
266    }
267    let fps = rate_of(seq)
268        .or_else(|| seq.path(&["media", "video", "format", "samplecharacteristics"]).and_then(rate_of))
269        .unwrap_or(30.0)
270        .max(1.0);
271    let name = seq.text_of("name");
272    if !name.is_empty() {
273        b.project.name = name.to_string();
274    }
275    b.project.fps = fps;
276    if let Some(sc) = seq.path(&["media", "video", "format", "samplecharacteristics"]) {
277        let (w, h) = (sc.num("width").unwrap_or(0.0) as u32, sc.num("height").unwrap_or(0.0) as u32);
278        if w > 0 && h > 0 {
279            b.project.width = w;
280            b.project.height = h;
281        }
282    }
283    b.note(
284        Level::Ok,
285        format!("sequence '{}'", b.project.name),
286        format!("{}×{} @ {fps:.3} fps", b.project.width, b.project.height),
287    );
288
289    b.project.tracks.clear();
290    let mut files: HashMap<String, Id> = HashMap::new();
291    // (track index, cut time, kind, duration, effect name)
292    let mut transitions: Vec<(usize, f64, TransitionKind, f64, String)> = Vec::new();
293
294    for (kind, section) in [(TrackKind::Video, "video"), (TrackKind::Audio, "audio")] {
295        let Some(sec) = seq.path(&["media", section]) else { continue };
296        for (n, tr) in sec.kids_named("track").enumerate() {
297            let ti = track_slot(&mut b.project, kind, n);
298            let label = b.project.tracks[ti].name.clone();
299            if tr.text_of("enabled").eq_ignore_ascii_case("false") {
300                b.project.tracks[ti].muted = true;
301            }
302            if tr.text_of("locked").eq_ignore_ascii_case("true") {
303                b.note(Level::Warning, format!("track {label}"), "locked tracks are imported unlocked");
304            }
305            for item in tr.kids_named("clipitem") {
306                read_clipitem(item, kind, ti, fps, &mut files, b);
307            }
308            for t in tr.kids_named("transitionitem") {
309                if let Some(x) = read_transitionitem(t, ti, fps, b) {
310                    transitions.push(x);
311                }
312            }
313        }
314    }
315    if b.project.tracks.is_empty() {
316        b.project.add_track(TrackKind::Video);
317        b.project.add_track(TrackKind::Audio);
318    }
319    link_stacks(&mut b.project);
320
321    for (ti, cut, kind, dur, name) in transitions {
322        let right = b.project.tracks[ti].clips.iter().find(|c| (c.start - cut).abs() < 1.0 / fps).map(|c| c.id);
323        match right.and_then(|id| b.project.add_transition(id, kind, dur)) {
324            Some(_) => b.note(Level::Ok, format!("transition '{name}'"), format!("{kind:?} over {dur:.2} s")),
325            None => b.note(
326                Level::Warning,
327                format!("transition '{name}'"),
328                "dropped: it does not sit on a cut between two clips",
329            ),
330        }
331    }
332    Ok(())
333}
334
335fn read_clipitem(item: &El, kind: TrackKind, ti: usize, fps: f64, files: &mut HashMap<String, Id>, b: &mut Build) {
336    let name = item.text_of("name").to_string();
337    let subject = format!("clip '{}'", if name.is_empty() { "(unnamed)" } else { name.as_str() });
338    let (Some(start), Some(end)) = (item.num("start"), item.num("end")) else {
339        b.note(Level::Skipped, subject, "no <start>/<end>");
340        return;
341    };
342    if start < 0.0 || end <= start {
343        // FCP writes start -1 for items that live inside a transition rather than on the timeline
344        b.note(Level::Skipped, subject, format!("not placed on the timeline (start {start}, end {end})"));
345        return;
346    }
347    let Some(file) = item.child("file") else {
348        b.note(Level::Skipped, subject, "no <file> — generators and titles are not interchangeable");
349        return;
350    };
351    let Some(aid) = asset_of(file, fps, files, &subject, b) else {
352        b.note(Level::Skipped, subject, "its <file> was never defined in this XML");
353        return;
354    };
355    let size = (b.project.width as f64, b.project.height as f64);
356    let asset_kind = b.project.asset(aid).map(|a| a.kind).unwrap_or(ClipKind::Video);
357    let clip_kind = match kind {
358        TrackKind::Audio => ClipKind::Audio,
359        TrackKind::Video if asset_kind == ClipKind::Image => ClipKind::Image,
360        TrackKind::Video => ClipKind::Video,
361    };
362    let dur = ((end - start) / fps).max(MIN_CLIP);
363    let id = b.project.new_id();
364    let mut c = Clip::new(id, clip_kind, name, start / fps, dur);
365    c.asset = aid;
366    c.src_in = item.num("in").unwrap_or(0.0).max(0.0) / fps;
367    c.enabled = !item.text_of("enabled").eq_ignore_ascii_case("false");
368    if kind == TrackKind::Audio {
369        let idx = item.child("sourcetrack").and_then(|s| s.num("trackindex")).unwrap_or(1.0);
370        c.audio_stream = (idx as usize).saturating_sub(1);
371    }
372    if let Some(sp) = item.num("speed") {
373        if (sp - 100.0).abs() > 0.5 {
374            b.note(Level::Warning, subject.clone(), format!("speed {sp} % not imported (clip plays at 100 %)"));
375        }
376    }
377    read_filters(item, &mut c, size, &subject, b);
378    place(&mut b.project, ti, c);
379}
380
381/// Asset for a `<file>`: defined once, then referenced by id (`<file id="file-3"/>`).
382fn asset_of(file: &El, fps: f64, files: &mut HashMap<String, Id>, subject: &str, b: &mut Build) -> Option<Id> {
383    let fid = file.attr("id").to_string();
384    if let Some(&id) = files.get(&fid) {
385        return Some(id);
386    }
387    if file.kids.is_empty() {
388        return None;
389    }
390    let raw = from_pathurl(file.text_of("pathurl"));
391    let path = b.resolve(&raw, subject);
392    if path.is_empty() {
393        return None;
394    }
395    let file_fps = rate_of(file).unwrap_or(fps).max(1.0);
396    let sc = file.path(&["media", "video", "samplecharacteristics"]);
397    let (w, h) =
398        sc.map(|s| (s.num("width").unwrap_or(0.0) as u32, s.num("height").unwrap_or(0.0) as u32)).unwrap_or_default();
399    let audio = file.path(&["media", "audio"]);
400    let kind = if is_image_path(&path) {
401        ClipKind::Image
402    } else if sc.is_some() || w > 0 {
403        ClipKind::Video
404    } else {
405        ClipKind::Audio
406    };
407    let asset = Asset {
408        id: 0,
409        path,
410        kind,
411        duration: file.num("duration").unwrap_or(0.0) / file_fps,
412        width: w,
413        height: h,
414        fps: sc.and_then(rate_of).unwrap_or(file_fps),
415        audio_streams: audio
416            .map(|a| {
417                vec![AudioStreamInfo {
418                    index: 0,
419                    channels: a.num("channelcount").unwrap_or(2.0) as u32,
420                    sample_rate: a.num("samplerate").unwrap_or(48000.0) as u32,
421                    ..Default::default()
422                }]
423            })
424            .unwrap_or_default(),
425        codec: String::new(),
426        folder: String::new(),
427        tags: Vec::new(),
428        label: 0,
429        description: String::new(),
430    };
431    let id = b.add_asset(asset);
432    files.insert(fid, id);
433    Some(id)
434}
435
436/// Opacity / Basic Motion become clip properties; everything else is reported.
437fn read_filters(item: &El, c: &mut Clip, size: (f64, f64), subject: &str, b: &mut Build) {
438    for f in item.kids_named("filter") {
439        let Some(effect) = f.child("effect") else { continue };
440        let id = effect.text_of("effectid").to_ascii_lowercase();
441        let name = effect.text_of("name").to_string();
442        let param = |key: &str| -> Option<f64> {
443            effect
444                .kids_named("parameter")
445                .find(|p| p.text_of("parameterid").eq_ignore_ascii_case(key))
446                .and_then(|p| p.num("value"))
447        };
448        let animated = effect.kids_named("parameter").any(|p| p.child("keyframe").is_some());
449        match id.as_str() {
450            "opacity" => {
451                if let Some(v) = param("opacity") {
452                    c.opacity.value = (v / 100.0).clamp(0.0, 1.0);
453                }
454            }
455            "basic" => {
456                if let Some(v) = param("scale") {
457                    c.scale.value = (v / 100.0).max(0.0);
458                }
459                if let Some(v) = param("rotation") {
460                    c.rotation.value = v;
461                }
462                if let Some(center) = effect
463                    .kids_named("parameter")
464                    .find(|p| p.text_of("parameterid").eq_ignore_ascii_case("center"))
465                    .and_then(|p| p.child("value"))
466                {
467                    // FCP centre is a fraction of the frame, ours is pixels from the middle
468                    c.x.value = center.num("horiz").unwrap_or(0.0) * size.0;
469                    c.y.value = center.num("vert").unwrap_or(0.0) * size.1;
470                }
471            }
472            _ => {
473                b.note(
474                    Level::Warning,
475                    format!("effect '{}'", if name.is_empty() { id.clone() } else { name.clone() }),
476                    format!("not imported on {subject}"),
477                );
478                continue;
479            }
480        }
481        if animated {
482            b.note(Level::Warning, format!("effect '{name}'"), format!("keyframes flattened on {subject}"));
483        }
484    }
485}
486
487fn read_transitionitem(
488    t: &El,
489    ti: usize,
490    fps: f64,
491    b: &mut Build,
492) -> Option<(usize, f64, TransitionKind, f64, String)> {
493    let effect = t.child("effect");
494    let name = effect
495        .map(|e| e.text_of("name"))
496        .filter(|n| !n.is_empty())
497        .or_else(|| effect.map(|e| e.text_of("effectid")))
498        .unwrap_or("transition")
499        .to_string();
500    let (Some(s), Some(e)) = (t.num("start"), t.num("end")) else {
501        b.note(Level::Warning, format!("transition '{name}'"), "no <start>/<end>");
502        return None;
503    };
504    if e <= s {
505        b.note(Level::Warning, format!("transition '{name}'"), "empty");
506        return None;
507    }
508    let cut = match t.text_of("alignment") {
509        "start" | "start-black" => s,
510        "end" | "end-black" => e,
511        _ => (s + e) / 2.0,
512    } / fps;
513    Some((ti, cut, transition_kind(&name), (e - s) / fps, name))
514}
515
516fn transition_kind(name: &str) -> TransitionKind {
517    let n = name.to_ascii_lowercase();
518    if n.contains("dip") || n.contains("to color") || n.contains("to black") {
519        TransitionKind::FadeToColor
520    } else if n.contains("wipe") || n.contains("iris") || n.contains("band") {
521        TransitionKind::Wipe
522    } else if n.contains("push") || n.contains("slide") {
523        TransitionKind::Push
524    } else {
525        TransitionKind::CrossFade
526    }
527}
528
529fn rate_of(el: &El) -> Option<f64> {
530    let r = el.child("rate")?;
531    let tb = r.num("timebase")?;
532    if tb <= 0.0 {
533        return None;
534    }
535    Some(if r.text_of("ntsc").eq_ignore_ascii_case("true") { tb * 1000.0 / 1001.0 } else { tb })
536}
537
538// ---------------------------------------------------------------- EDL (CMX 3600)
539
540struct Event {
541    line: usize,
542    /// "V", "A", "A2", "AA", "B", "AA/V" …
543    channel: String,
544    /// 'C' cut, 'D' dissolve, 'W' wipe, 'K' key.
545    kind: char,
546    /// Transition length in frames.
547    tdur: f64,
548    src_in: f64,
549    src_out: f64,
550    rec_in: f64,
551    rec_out: f64,
552    reel: String,
553    name: String,
554}
555
556fn looks_like_edl(text: &str) -> bool {
557    text.contains("FCM:")
558        || text.lines().take(200).filter(|l| !l.trim_start().starts_with('*')).any(|l| parse_event(l, 30.0).is_some())
559}
560
561fn read_edl(text: &str, b: &mut Build) {
562    let drop_frame = text.lines().any(|l| {
563        let u = l.to_ascii_uppercase();
564        u.contains("FCM:") && u.contains("DROP") && !u.contains("NON-DROP") && !u.contains("NON DROP")
565    });
566    // ponytail: an EDL carries no frame rate — 30 / 29.97 is the CMX assumption, retime after import
567    let fps = if drop_frame { 30.0 * 1000.0 / 1001.0 } else { 30.0 };
568    b.project.fps = fps;
569    b.note(
570        Level::Warning,
571        "sequence",
572        format!("an EDL stores no frame rate or picture size; assumed {fps:.3} fps at the default size"),
573    );
574
575    let mut events: Vec<Event> = Vec::new();
576    for (n, raw) in text.lines().enumerate() {
577        let line = raw.trim();
578        if line.is_empty() {
579            continue;
580        }
581        if let Some(rest) = line.strip_prefix('*') {
582            let r = rest.trim();
583            let up = r.to_ascii_uppercase();
584            // the source file name always follows the event it belongs to
585            for key in ["FROM CLIP NAME:", "SOURCE FILE:"] {
586                if up.starts_with(key) {
587                    let v = r[key.len()..].trim();
588                    match events.last_mut() {
589                        Some(e) if !v.is_empty() => e.name = v.to_string(),
590                        _ => {}
591                    }
592                    break;
593                }
594            }
595            continue;
596        }
597        let upper = line.to_ascii_uppercase();
598        if upper.starts_with("TITLE:") {
599            let t = line[6..].trim();
600            if !t.is_empty() {
601                b.project.name = t.to_string();
602            }
603            continue;
604        }
605        if upper.starts_with("FCM:") || upper.starts_with("EDL") {
606            continue;
607        }
608        match parse_event(line, fps) {
609            Some(mut e) => {
610                e.line = n + 1;
611                events.push(e);
612            }
613            None => b.note(Level::Skipped, format!("line {}", n + 1), format!("not a CMX 3600 event: {line}")),
614        }
615    }
616    if events.is_empty() {
617        b.note(Level::Skipped, "file", "no usable events");
618        return;
619    }
620
621    // one asset per source name, its length the furthest source-out we saw
622    let mut assets: HashMap<String, Id> = HashMap::new();
623    let mut source: Vec<(String, f64, bool, bool)> = Vec::new(); // name, duration, video, audio
624    for e in &events {
625        let key = source_name(e);
626        let (video, audio) = channels(&e.channel);
627        match source.iter_mut().find(|(n, ..)| *n == key) {
628            Some(s) => {
629                s.1 = s.1.max(e.src_out);
630                s.2 |= video;
631                s.3 |= !audio.is_empty();
632            }
633            None => source.push((key, e.src_out, video, !audio.is_empty())),
634        }
635    }
636    for (name, duration, video, audio) in source {
637        let path = b.resolve(&name, &format!("source '{name}'"));
638        let kind = if is_image_path(&path) {
639            ClipKind::Image
640        } else if video {
641            ClipKind::Video
642        } else {
643            ClipKind::Audio
644        };
645        let id = b.add_asset(Asset {
646            id: 0,
647            path,
648            kind,
649            duration,
650            width: 0,
651            height: 0,
652            fps,
653            audio_streams: if audio {
654                vec![AudioStreamInfo { index: 0, channels: 2, sample_rate: 48000, ..Default::default() }]
655            } else {
656                Vec::new()
657            },
658            codec: String::new(),
659            folder: String::new(),
660            tags: Vec::new(),
661            label: 0,
662            description: String::new(),
663        });
664        assets.insert(name, id);
665    }
666
667    let mut pending: Vec<(Id, TransitionKind, f64, usize)> = Vec::new();
668    for e in &events {
669        let Some(&aid) = assets.get(&source_name(e)) else { continue };
670        let (video, audio) = channels(&e.channel);
671        let dur = (e.rec_out - e.rec_in).max(MIN_CLIP);
672        if e.rec_out <= e.rec_in {
673            b.note(Level::Warning, format!("line {}", e.line), "record out is not after record in");
674            continue;
675        }
676        let name = source_name(e);
677        let title = Path::new(&name).file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or(name);
678        let mut ids = Vec::new();
679        if video {
680            let ti = track_slot(&mut b.project, TrackKind::Video, 0);
681            let id = b.project.new_id();
682            let mut c = Clip::new(id, ClipKind::Video, title.clone(), e.rec_in, dur);
683            c.asset = aid;
684            c.src_in = e.src_in;
685            ids.push(place(&mut b.project, ti, c));
686        }
687        for ch in audio {
688            let ti = track_slot(&mut b.project, TrackKind::Audio, ch);
689            let id = b.project.new_id();
690            let mut c = Clip::new(id, ClipKind::Audio, title.clone(), e.rec_in, dur);
691            c.asset = aid;
692            c.src_in = e.src_in;
693            ids.push(place(&mut b.project, ti, c));
694        }
695        match e.kind {
696            'C' => {}
697            'K' => b.note(Level::Warning, format!("line {}", e.line), "key (superimpose) events are not imported"),
698            k => {
699                let kind = if k == 'W' { TransitionKind::Wipe } else { TransitionKind::CrossFade };
700                for id in &ids {
701                    pending.push((*id, kind, (e.tdur / fps).max(MIN_CLIP), e.line));
702                }
703            }
704        }
705    }
706    link_stacks(&mut b.project);
707    for (id, kind, dur, line) in pending {
708        match b.project.add_transition(id, kind, dur) {
709            Some(_) => b.note(Level::Ok, format!("line {line}"), format!("{kind:?} of {dur:.2} s centred on the cut")),
710            None => b.note(Level::Warning, format!("line {line}"), "transition dropped: no clip abuts this cut"),
711        }
712    }
713}
714
715fn source_name(e: &Event) -> String {
716    if e.name.is_empty() {
717        e.reel.clone()
718    } else {
719        e.name.clone()
720    }
721}
722
723/// `("V", "A", "A2", "AA", "B", "AA/V")` → (has video, audio track indices).
724fn channels(chan: &str) -> (bool, Vec<usize>) {
725    let c = chan.to_ascii_uppercase();
726    let video = c.contains('V') || c.contains('B');
727    let mut audio = Vec::new();
728    if c.contains("AA") {
729        audio.extend([0, 1]);
730    } else if c.contains('B') {
731        audio.push(0);
732    } else if let Some(p) = c.find('A') {
733        let n: usize = c[p + 1..].chars().take_while(char::is_ascii_digit).collect::<String>().parse().unwrap_or(1);
734        audio.push(n.max(1) - 1);
735    }
736    (video, audio)
737}
738
739/// `HH:MM:SS:FF` (`;` = drop frame) → seconds.
740fn timecode(s: &str, fps: f64) -> Option<f64> {
741    let p: Vec<&str> = s.split([':', ';', '.']).collect();
742    if p.len() != 4 || p.iter().any(|x| x.is_empty() || !x.chars().all(|c| c.is_ascii_digit())) {
743        return None;
744    }
745    let n: Vec<i64> = p.iter().filter_map(|x| x.parse().ok()).collect();
746    if n.len() != 4 {
747        return None;
748    }
749    let per = fps.round().max(1.0) as i64;
750    Some((((n[0] * 60 + n[1]) * 60 + n[2]) * per + n[3]) as f64 / fps)
751}
752
753/// `001  AX  V  C  00:00:00:00 00:00:02:00 00:00:00:00 00:00:02:00` (a `D`/`W` adds a frame count).
754fn parse_event(line: &str, fps: f64) -> Option<Event> {
755    let f: Vec<&str> = line.split_whitespace().collect();
756    if f.len() < 8 || !f[0].chars().all(|c| c.is_ascii_digit()) {
757        return None;
758    }
759    let channel = f[2].to_ascii_uppercase();
760    if !channel.chars().all(|c| c.is_ascii_alphanumeric() || c == '/') || !channel.contains(['V', 'A', 'B']) {
761        return None;
762    }
763    let trans = f[3].to_ascii_uppercase();
764    let kind = trans.chars().next()?;
765    if !matches!(kind, 'C' | 'D' | 'W' | 'K') {
766        return None;
767    }
768    let (tdur, times) = match (kind, f.len()) {
769        ('C', _) => (0.0, &f[4..]),
770        (_, n) if n >= 9 => (f[4].parse::<f64>().ok()?, &f[5..]),
771        _ => (0.0, &f[4..]),
772    };
773    if times.len() < 4 {
774        return None;
775    }
776    let t: Vec<f64> = times[..4].iter().filter_map(|x| timecode(x, fps)).collect();
777    if t.len() != 4 {
778        return None;
779    }
780    Some(Event {
781        line: 0,
782        channel,
783        kind,
784        tdur,
785        src_in: t[0],
786        src_out: t[1],
787        rec_in: t[2],
788        rec_out: t[3],
789        reel: f[1].to_string(),
790        name: String::new(),
791    })
792}
793
794// ---------------------------------------------------------------- tiny XML reader
795
796#[derive(Debug, Default)]
797struct El {
798    name: String,
799    attrs: Vec<(String, String)>,
800    text: String,
801    kids: Vec<El>,
802}
803
804impl El {
805    fn child(&self, name: &str) -> Option<&El> {
806        self.kids.iter().find(|k| k.name == name)
807    }
808    fn kids_named<'a>(&'a self, name: &'a str) -> impl Iterator<Item = &'a El> + 'a {
809        self.kids.iter().filter(move |k| k.name == name)
810    }
811    /// Descend by element name.
812    fn path(&self, p: &[&str]) -> Option<&El> {
813        p.iter().try_fold(self, |e, n| e.child(n))
814    }
815    fn attr(&self, name: &str) -> &str {
816        self.attrs.iter().find(|(k, _)| k == name).map(|(_, v)| v.as_str()).unwrap_or("")
817    }
818    fn text_of(&self, name: &str) -> &str {
819        self.child(name).map(|c| c.text.trim()).unwrap_or("")
820    }
821    fn num(&self, name: &str) -> Option<f64> {
822        self.text_of(name).parse().ok()
823    }
824    fn find_all<'a>(&'a self, name: &str, out: &mut Vec<&'a El>) {
825        if self.name == name {
826            out.push(self);
827        }
828        for k in &self.kids {
829            k.find_all(name, out);
830        }
831    }
832}
833
834/// Maximum element nesting. `El` drops recursively and `find_all` recurses, so an adversarial file of
835/// 100k nested tags would overflow the (UI-thread) stack — which no `catch_unwind` can catch. Real
836/// FCP7/EDL exports are under 20 deep.
837const MAX_XML_DEPTH: usize = 256;
838
839/// Minimal well-formed-ish XML reader: elements, attributes, text, CDATA; comments / PIs / DOCTYPE are
840/// skipped. Mismatched or missing close tags unwind instead of failing — exported timelines are machine
841/// written, but a truncated one should still give back what it has. Nesting is capped at
842/// `MAX_XML_DEPTH`.
843fn parse_xml(src: &str) -> Result<El, String> {
844    let b = src.as_bytes();
845    let mut i = 0usize;
846    let mut stack: Vec<El> = Vec::new();
847    let mut root: Option<El> = None;
848    while i < b.len() {
849        if b[i] != b'<' {
850            let start = i;
851            while i < b.len() && b[i] != b'<' {
852                i += 1;
853            }
854            if let Some(top) = stack.last_mut() {
855                let t = &src[start..i];
856                if !t.trim().is_empty() {
857                    top.text.push_str(&unescape(t));
858                }
859            }
860            continue;
861        }
862        let rest = &src[i..];
863        if rest.starts_with("<!--") {
864            i += 4 + rest[4..].find("-->").ok_or("unterminated comment")? + 3;
865        } else if rest.starts_with("<![CDATA[") {
866            let e = rest[9..].find("]]>").ok_or("unterminated CDATA")?;
867            if let Some(top) = stack.last_mut() {
868                top.text.push_str(&rest[9..9 + e]);
869            }
870            i += 9 + e + 3;
871        } else if rest.starts_with("<?") {
872            i += 2 + rest[2..].find("?>").ok_or("unterminated <?…?>")? + 2;
873        } else if rest.starts_with("<!") {
874            i += 2 + rest[2..].find('>').ok_or("unterminated <!…>")? + 1;
875        } else if rest.starts_with("</") {
876            let e = rest.find('>').ok_or("unterminated close tag")?;
877            close(&mut stack, &mut root, rest[2..e].trim());
878            i += e + 1;
879        } else {
880            let e = tag_end(rest).ok_or("unterminated tag")?;
881            let inner = rest[1..e].trim_end();
882            let selfclose = inner.ends_with('/');
883            let el = parse_tag(inner.trim_end_matches('/'));
884            i += e + 1;
885            if selfclose {
886                attach(&mut stack, &mut root, el);
887            } else {
888                if stack.len() >= MAX_XML_DEPTH {
889                    return Err(format!("XML nested deeper than {MAX_XML_DEPTH} elements"));
890                }
891                stack.push(el);
892            }
893        }
894    }
895    while let Some(el) = stack.pop() {
896        attach(&mut stack, &mut root, el);
897    }
898    root.ok_or_else(|| "no XML root element".to_string())
899}
900
901fn attach(stack: &mut [El], root: &mut Option<El>, el: El) {
902    match stack.last_mut() {
903        Some(p) => p.kids.push(el),
904        None => {
905            if root.is_none() {
906                *root = Some(el);
907            }
908        }
909    }
910}
911
912/// Close `name`, unwinding any elements left open inside it.
913fn close(stack: &mut Vec<El>, root: &mut Option<El>, name: &str) {
914    if !stack.iter().any(|e| e.name == name) {
915        return; // stray close tag
916    }
917    while let Some(el) = stack.pop() {
918        let matched = el.name == name;
919        attach(stack, root, el);
920        if matched {
921            return;
922        }
923    }
924}
925
926/// Index of the `>` closing a tag, ignoring quoted attribute values.
927fn tag_end(rest: &str) -> Option<usize> {
928    let mut quote = '\0';
929    for (i, c) in rest.char_indices() {
930        if quote != '\0' {
931            if c == quote {
932                quote = '\0';
933            }
934        } else if c == '"' || c == '\'' {
935            quote = c;
936        } else if c == '>' {
937            return Some(i);
938        }
939    }
940    None
941}
942
943fn parse_tag(inner: &str) -> El {
944    let inner = inner.trim();
945    let (name, mut rest) = match inner.find(char::is_whitespace) {
946        Some(i) => (&inner[..i], &inner[i..]),
947        None => (inner, ""),
948    };
949    let mut attrs = Vec::new();
950    loop {
951        rest = rest.trim_start();
952        let Some(eq) = rest.find('=') else { break };
953        let key = rest[..eq].trim().to_string();
954        let after = rest[eq + 1..].trim_start();
955        let Some(q) = after.chars().next().filter(|c| *c == '"' || *c == '\'') else { break };
956        let after = &after[q.len_utf8()..];
957        let Some(end) = after.find(q) else { break };
958        attrs.push((key, unescape(&after[..end])));
959        rest = &after[end + q.len_utf8()..];
960    }
961    El { name: name.to_string(), attrs, text: String::new(), kids: Vec::new() }
962}
963
964fn unescape(s: &str) -> String {
965    if !s.contains('&') {
966        return s.to_string();
967    }
968    let mut o = String::with_capacity(s.len());
969    let mut rest = s;
970    while let Some(i) = rest.find('&') {
971        o.push_str(&rest[..i]);
972        rest = &rest[i..];
973        let ch = rest.find(';').filter(|&e| e <= 12).and_then(|e| {
974            let ent = &rest[1..e];
975            let c = match ent {
976                "amp" => Some('&'),
977                "lt" => Some('<'),
978                "gt" => Some('>'),
979                "quot" => Some('"'),
980                "apos" => Some('\''),
981                _ if ent.starts_with("#x") || ent.starts_with("#X") => {
982                    u32::from_str_radix(&ent[2..], 16).ok().and_then(char::from_u32)
983                }
984                _ if ent.starts_with('#') => ent[1..].parse::<u32>().ok().and_then(char::from_u32),
985                _ => None,
986            };
987            c.map(|c| (c, e + 1))
988        });
989        match ch {
990            Some((c, n)) => {
991                o.push(c);
992                rest = &rest[n..];
993            }
994            None => {
995                o.push('&');
996                rest = &rest[1..];
997            }
998        }
999    }
1000    o.push_str(rest);
1001    o
1002}
1003
1004/// `file://localhost/C:/a%20b/v.mp4` → `C:\a b\v.mp4`; `file://nas/share/v.mp4` → `\\nas\share\v.mp4`.
1005fn from_pathurl(url: &str) -> String {
1006    let u = url.trim();
1007    let Some(rest) = u.strip_prefix("file://") else { return percent_decode(u).replace('/', "\\") };
1008    let path = if let Some(r) = rest.strip_prefix("localhost/") {
1009        r.to_string()
1010    } else if let Some(r) = rest.strip_prefix('/') {
1011        r.trim_start_matches('/').to_string()
1012    } else {
1013        format!("//{rest}")
1014    };
1015    percent_decode(&path).replace('/', "\\")
1016}
1017
1018fn percent_decode(s: &str) -> String {
1019    if !s.contains('%') {
1020        return s.to_string();
1021    }
1022    let b = s.as_bytes();
1023    let mut out: Vec<u8> = Vec::with_capacity(b.len());
1024    let mut i = 0;
1025    while i < b.len() {
1026        if b[i] == b'%' && i + 2 < b.len() && b[i + 1].is_ascii_hexdigit() && b[i + 2].is_ascii_hexdigit() {
1027            let hex = |c: u8| (c as char).to_digit(16).unwrap_or(0) as u8;
1028            out.push((hex(b[i + 1]) << 4) | hex(b[i + 2]));
1029            i += 3;
1030        } else {
1031            out.push(b[i]);
1032            i += 1;
1033        }
1034    }
1035    String::from_utf8_lossy(&out).into_owned()
1036}
1037
1038// ---------- background asset probing ----------
1039//
1040// `media::probe` spawns ffprobe, ~100 ms a file, so ten dropped files used to stall the UI thread for
1041// a second. An import now drops a `placeholder` asset into the project straight away and one worker
1042// probes the batch; the app polls the receiver and `adopt`s each result on a later frame.
1043
1044/// Paths with a probe still out. By path rather than asset id: a probe outlives the project that
1045/// started it, and paths are what the library has to hand.
1046/// ponytail: linear scan under one lock — an import batch is a handful of files, not a thousand.
1047static PROBING: Mutex<Vec<String>> = Mutex::new(Vec::new());
1048
1049/// Is this asset still waiting for its metadata? (the library says "Loading…" instead of a duration)
1050pub fn is_probing(path: &str) -> bool {
1051    PROBING.lock().map(|v| v.iter().any(|p| p == path)).unwrap_or(false)
1052}
1053
1054/// One finished probe, ready for `adopt`.
1055pub struct Probed {
1056    pub id: Id,
1057    pub path: String,
1058    pub asset: Result<Asset, String>,
1059}
1060
1061/// Stand-in asset for a file whose probe has not come back: the name and a kind guessed from the
1062/// extension are all we know. An audio placeholder claims one stream so that dropping a song on the
1063/// timeline before the probe lands still makes a clip for `adopt` to rebuild.
1064pub fn placeholder(path: &str) -> Asset {
1065    let kind = if is_image_path(path) {
1066        ClipKind::Image
1067    } else if matches!(media::ext(path).as_str(), "mp3" | "wav" | "m4a" | "aac" | "flac" | "ogg" | "opus" | "wma") {
1068        ClipKind::Audio
1069    } else {
1070        ClipKind::Video
1071    };
1072    Asset {
1073        id: 0,
1074        path: path.to_string(),
1075        kind,
1076        duration: 0.0,
1077        width: 0,
1078        height: 0,
1079        fps: 0.0,
1080        audio_streams: if kind == ClipKind::Audio { vec![AudioStreamInfo::default()] } else { Vec::new() },
1081        codec: String::new(),
1082        folder: String::new(),
1083        tags: Vec::new(),
1084        label: 0,
1085        description: String::new(),
1086    }
1087}
1088
1089/// Probe `files` (placeholder asset id + path) on a worker. Returns at once; poll the receiver.
1090pub fn probe_async(files: Vec<(Id, String)>, backend: Backend) -> Receiver<Probed> {
1091    let (tx, rx) = std::sync::mpsc::channel();
1092    if let Ok(mut q) = PROBING.lock() {
1093        q.extend(files.iter().map(|(_, path)| path.clone()));
1094    }
1095    std::thread::spawn(move || {
1096        for (id, path) in files {
1097            // a panicking probe must not strand the rest of the batch as permanent "Loading…"
1098            let asset = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| media::probe(&path, backend)))
1099                .unwrap_or_else(|_| Err("probe crashed".into()));
1100            if let Ok(mut q) = PROBING.lock() {
1101                if let Some(i) = q.iter().position(|p| *p == path) {
1102                    q.remove(i);
1103                }
1104            }
1105            if tx.send(Probed { id, path, asset }).is_err() {
1106                break; // nobody is listening any more
1107            }
1108        }
1109    });
1110    rx
1111}
1112
1113/// Fold a finished probe into `project`. The asset is seconds old, so every clip on it was placed
1114/// against the placeholder (one MIN_CLIP-long clip, wrong audio streams): those are laid down again
1115/// at the same starts now that the real duration is known. False when the asset is already gone.
1116pub fn adopt(project: &mut Project, id: Id, probed: Asset) -> bool {
1117    // the path has to still match: a project closed while its probes were out would otherwise let a
1118    // stale result land on whatever asset inherited the id
1119    let Some(a) = project.asset_mut(id).filter(|a| a.path == probed.path) else { return false };
1120    // whatever the user filed the asset under while it loaded survives; the probe wins on everything else
1121    let keep =
1122        (std::mem::take(&mut a.folder), std::mem::take(&mut a.tags), a.label, std::mem::take(&mut a.description));
1123    *a = Asset { id, folder: keep.0, tags: keep.1, label: keep.2, description: keep.3, ..probed };
1124
1125    let mut spots: Vec<(f64, Option<usize>)> = Vec::new();
1126    for (ti, t) in project.tracks.iter_mut().enumerate() {
1127        let video = t.kind == TrackKind::Video;
1128        let before = t.clips.len();
1129        t.clips.retain(|c| {
1130            let mine = c.uses_asset() && c.asset == id;
1131            if mine {
1132                spots.push((c.start, video.then_some(ti)));
1133            }
1134            !mine
1135        });
1136        if t.clips.len() != before {
1137            t.prune_transitions();
1138        }
1139    }
1140    // one placement made one clip, but re-placing it can make three (video + its audio streams), so
1141    // collapse the spots by start or a second adopt would multiply them.
1142    // ponytail: two placements of the same asset at the same start collapse into one — they would have
1143    // to have been dropped in the sub-second the probe was out.
1144    spots.sort_by(|x, y| x.0.total_cmp(&y.0).then(y.1.is_some().cmp(&x.1.is_some())));
1145    spots.dedup_by(|x, y| x.0 == y.0);
1146    for (start, vt) in spots {
1147        project.insert_asset_clips(id, start, vt);
1148    }
1149    true
1150}
1151
1152#[cfg(test)]
1153mod tests {
1154    use super::*;
1155    use crate::engine::xmeml::export_xmeml;
1156
1157    fn asset(path: &str, streams: usize) -> Asset {
1158        Asset {
1159            id: 0,
1160            path: path.into(),
1161            kind: ClipKind::Video,
1162            duration: 10.0,
1163            width: 1280,
1164            height: 720,
1165            fps: 30.0,
1166            audio_streams: (0..streams)
1167                .map(|i| AudioStreamInfo { index: i, channels: 2, sample_rate: 48000, ..Default::default() })
1168                .collect(),
1169            codec: "h264".into(),
1170            folder: String::new(),
1171            tags: Vec::new(),
1172            label: 0,
1173            description: String::new(),
1174        }
1175    }
1176
1177    fn import_bytes(bytes: &[u8], name: &str) -> ImportReport {
1178        let dir = std::env::temp_dir().join("simple-editor-import-test");
1179        let _ = std::fs::create_dir_all(&dir);
1180        let path = dir.join(format!("{}-{}", std::process::id(), name));
1181        std::fs::write(&path, bytes).expect("write test file");
1182        let r = import_file(&path).expect("import");
1183        let _ = std::fs::remove_file(&path);
1184        r
1185    }
1186
1187    fn import_text(text: &str, name: &str) -> ImportReport {
1188        import_bytes(text.as_bytes(), name)
1189    }
1190
1191    /// What engine::xmeml writes comes back: the same clips, at the same times, on the same track kinds.
1192    #[test]
1193    fn xmeml_round_trip() {
1194        let mut p = Project::from_media(asset(r"C:\media\clip & co.mp4", 2));
1195        p.split_at(4.0, None);
1196        p.tracks[0].clips[0].opacity.value = 0.5;
1197        p.tracks[0].clips[1].scale.value = 1.5;
1198        let want: Vec<(TrackKind, f64, f64, f64)> = p
1199            .tracks
1200            .iter()
1201            .flat_map(|t| t.clips.iter().map(move |c| (t.kind, c.start, c.duration, c.src_in)))
1202            .collect();
1203
1204        let r = import_text(&export_xmeml(&p), "roundtrip.xml");
1205        assert_eq!(r.clips, want.len(), "{}", r.to_markdown());
1206        assert_eq!(r.project.tracks.len(), p.tracks.len());
1207        let frame = 1.0 / p.fps;
1208        let got: Vec<(TrackKind, f64, f64, f64)> = r
1209            .project
1210            .tracks
1211            .iter()
1212            .flat_map(|t| t.clips.iter().map(move |c| (t.kind, c.start, c.duration, c.src_in)))
1213            .collect();
1214        assert_eq!(got.len(), want.len());
1215        for (g, w) in got.iter().zip(&want) {
1216            assert_eq!(g.0, w.0, "track kind");
1217            assert!((g.1 - w.1).abs() < frame, "start {} vs {}", g.1, w.1);
1218            assert!((g.2 - w.2).abs() < frame, "duration {} vs {}", g.2, w.2);
1219            assert!((g.3 - w.3).abs() < frame, "src_in {} vs {}", g.3, w.3);
1220        }
1221        // size / fps / one asset with its escaped path back
1222        assert_eq!((r.project.width, r.project.height), (1280, 720));
1223        assert!((r.project.fps - 30.0).abs() < 0.05, "{}", r.project.fps);
1224        assert_eq!(r.project.assets.len(), 1);
1225        assert_eq!(r.project.assets[0].path, r"C:\media\clip & co.mp4");
1226        // properties the exporter writes as filters
1227        assert!((r.project.tracks[0].clips[0].opacity.value - 0.5).abs() < 1e-6);
1228        assert!((r.project.tracks[0].clips[1].scale.value - 1.5).abs() < 1e-6);
1229        // video + its two audio clips are one linked group again
1230        let v = r.project.tracks[0].clips[0].id;
1231        assert_eq!(r.project.linked(v).len(), 3, "video + 2 audio clips link together");
1232        // the file was never there: counted once, not once per clip
1233        assert_eq!(r.missing_media, 1);
1234        assert_eq!(r.issues.iter().filter(|i| i.detail.contains("media not found")).count(), 1);
1235    }
1236
1237    /// A text clip is a comment in the export (no clipitem), so it simply does not come back.
1238    #[test]
1239    fn xmeml_text_clip_is_not_invented() {
1240        let mut p = Project::from_media(asset(r"C:\media\a.mp4", 0));
1241        p.add_text_clip(1.0, 2.0);
1242        let r = import_text(&export_xmeml(&p), "text.xml");
1243        assert_eq!(r.clips, 1);
1244        assert!(r.project.all_clips().all(|(_, c)| c.kind != ClipKind::Text));
1245    }
1246
1247    const EDL: &str = "TITLE: Cut 03\nFCM: NON-DROP FRAME\n\n\
1248001  AX       V     C        00:00:05:00 00:00:07:00 00:00:00:00 00:00:02:00\n\
1249* FROM CLIP NAME: red.mp4\n\
1250002  AX       AA    C        00:00:05:00 00:00:07:00 00:00:00:00 00:00:02:00\n\
1251* FROM CLIP NAME: red.mp4\n\
1252003  BX       V     D    015 00:00:00:00 00:00:03:00 00:00:02:00 00:00:05:00\n\
1253* FROM CLIP NAME: green.mp4\n";
1254
1255    #[test]
1256    fn edl_parses() {
1257        let r = import_text(EDL, "cut.edl");
1258        assert_eq!(r.project.name, "Cut 03");
1259        assert_eq!(r.clips, 4, "2 video + 2 audio: {}", r.to_markdown());
1260        assert_eq!(r.project.assets.len(), 2);
1261        assert_eq!(r.missing_media, 2);
1262        let v = r.project.video_tracks();
1263        let a = r.project.audio_tracks();
1264        assert_eq!((v.len(), a.len()), (1, 2), "AA fills A1 and A2");
1265        let vclips = &r.project.tracks[v[0]].clips;
1266        assert_eq!(vclips.len(), 2);
1267        assert!((vclips[0].start - 0.0).abs() < 1e-9 && (vclips[0].duration - 2.0).abs() < 1e-9);
1268        assert!((vclips[0].src_in - 5.0).abs() < 1e-9, "source in is kept");
1269        assert!((vclips[1].start - 2.0).abs() < 1e-9 && (vclips[1].duration - 3.0).abs() < 1e-9);
1270        // the dissolve became a transition on the cut at 2 s (15 frames @ 30 = 0.5 s)
1271        let tr = &r.project.tracks[v[0]].transitions;
1272        assert_eq!(tr.len(), 1, "{}", r.to_markdown());
1273        assert_eq!(tr[0].kind, TransitionKind::CrossFade);
1274        assert!((tr[0].duration - 0.5).abs() < 1e-9);
1275        // source length is the furthest source-out
1276        assert!(r.project.assets.iter().any(|a| a.path.ends_with("red.mp4") && (a.duration - 7.0).abs() < 1e-9));
1277        // no frame rate in an EDL → one honest warning
1278        assert!(r.issues.iter().any(|i| i.detail.contains("stores no frame rate")));
1279    }
1280
1281    /// A broken event line is one Issue, the rest of the EDL still imports.
1282    #[test]
1283    fn edl_malformed_block_is_an_issue() {
1284        let bad = format!("{EDL}004  CX  V  C  00:00:0 nonsense here\n005  DX  V  C  ??:??:??:?? 1 2 3\n");
1285        let r = import_text(&bad, "bad.edl");
1286        assert_eq!(r.clips, 4, "the good events survived");
1287        let skipped: Vec<&Issue> = r.issues.iter().filter(|i| i.level == Level::Skipped).collect();
1288        assert_eq!(skipped.len(), 2, "{}", r.to_markdown());
1289        assert!(skipped.iter().all(|i| i.subject.starts_with("line ")), "{:?}", skipped);
1290    }
1291
1292    #[test]
1293    fn report_markdown_has_a_row_per_issue() {
1294        let r = import_text(EDL, "md.edl");
1295        let md = r.to_markdown();
1296        let rows = md.lines().filter(|l| l.starts_with('|')).count();
1297        assert_eq!(rows, r.issues.len() + 2, "header + separator + one row per issue:\n{md}");
1298        assert!(md.starts_with("# Import: Cut 03"));
1299        assert!(md.contains(&format!("{} clips on {} tracks", r.clips, r.tracks)));
1300        // a detail containing a pipe never breaks the table
1301        let mut r2 = r;
1302        r2.issues.push(Issue { level: Level::Ok, subject: "a|b".into(), detail: "c|d\ne".into() });
1303        let md = r2.to_markdown();
1304        assert!(md.contains(r"| Ok | a\|b | c\|d e |"), "{md}");
1305        assert_eq!(md.lines().filter(|l| l.starts_with('|')).count(), r2.issues.len() + 2);
1306    }
1307
1308    #[test]
1309    fn gzip_prproj_reports_how_to_export() {
1310        let r = import_bytes(b"\x1f\x8b\x08\x00binary junk", "seq.prproj");
1311        assert_eq!(r.clips, 0);
1312        assert_eq!(r.issues.len(), 1);
1313        assert_eq!(r.issues[0].level, Level::Skipped);
1314        assert!(r.issues[0].detail.contains("Final Cut Pro XML"));
1315    }
1316
1317    #[test]
1318    fn unknown_effects_and_bad_clipitems_become_issues() {
1319        let xml = r#"<?xml version="1.0" encoding="UTF-8"?><!DOCTYPE xmeml><xmeml version="5">
1320<sequence><name>S</name><rate><timebase>25</timebase><ntsc>FALSE</ntsc></rate>
1321<media><video><format><samplecharacteristics><width>640</width><height>360</height></samplecharacteristics></format>
1322<track>
1323 <clipitem id="c1"><name>a.mp4</name><start>0</start><end>25</end><in>0</in><out>25</out>
1324  <file id="f1"><name>a.mp4</name><pathurl>file://localhost/C:/nope/a.mp4</pathurl>
1325   <rate><timebase>25</timebase></rate><duration>250</duration>
1326   <media><video><samplecharacteristics><width>640</width><height>360</height></samplecharacteristics></video></media></file>
1327  <filter><effect><name>Gaussian Blur</name><effectid>blur</effectid>
1328   <parameter><parameterid>radius</parameterid><value>4</value></parameter></effect></filter>
1329 </clipitem>
1330 <clipitem id="c2"><name>inside-transition</name><start>-1</start><end>-1</end><file id="f1"/></clipitem>
1331 <clipitem id="c3"><name>orphan</name><start>50</start><end>75</end><file id="nope"/></clipitem>
1332 <transitionitem><start>20</start><end>30</end><alignment>center</alignment>
1333  <effect><name>Cross Dissolve</name><effectid>Cross Dissolve</effectid></effect></transitionitem>
1334</track></video></media></sequence></xmeml>"#;
1335        let r = import_text(xml, "eff.xml");
1336        assert_eq!(r.clips, 1, "{}", r.to_markdown());
1337        assert!((r.project.fps - 25.0).abs() < 1e-9);
1338        assert_eq!((r.project.width, r.project.height), (640, 360));
1339        assert_eq!(r.project.assets[0].path, r"C:\nope\a.mp4");
1340        assert!((r.project.assets[0].duration - 10.0).abs() < 1e-9, "250 frames @ 25");
1341        let d: Vec<&str> = r.issues.iter().map(|i| i.detail.as_str()).collect();
1342        assert!(r.issues.iter().any(|i| i.subject == "effect 'Gaussian Blur'"), "{d:?}");
1343        assert!(r.issues.iter().any(|i| i.detail.contains("not placed on the timeline")), "{d:?}");
1344        assert!(r.issues.iter().any(|i| i.detail.contains("never defined")), "{d:?}");
1345        // a transition with only one clip beside it cannot be placed
1346        assert!(r.issues.iter().any(|i| i.detail.contains("does not sit on a cut")), "{d:?}");
1347        assert_eq!(r.missing_media, 1);
1348    }
1349
1350    #[test]
1351    fn xml_reader_survives_junk() {
1352        // unclosed <b>, stray </z>, CDATA, entities, attribute with a '>' inside
1353        let x = parse_xml(r#"<a t="x>y"><b><c>1 &amp; 2</c><d><![CDATA[<raw>]]></d></z></a>"#).unwrap();
1354        assert_eq!(x.name, "a");
1355        assert_eq!(x.attr("t"), "x>y");
1356        assert_eq!(x.path(&["b", "c"]).map(|e| e.text.as_str()), Some("1 & 2"));
1357        assert_eq!(x.path(&["b", "d"]).map(|e| e.text.as_str()), Some("<raw>"));
1358        assert!(parse_xml("no elements here").is_err());
1359        // deep nesting is rejected, not stack-overflowed (El drops recursively)
1360        let deep = "<a>".repeat(MAX_XML_DEPTH + 10);
1361        assert!(parse_xml(&deep).unwrap_err().contains("nested"));
1362        assert!(parse_xml(&"<a>".repeat(MAX_XML_DEPTH - 1)).is_ok());
1363    }
1364
1365    #[test]
1366    fn pathurl_and_timecode() {
1367        assert_eq!(from_pathurl("file://localhost/C:/My%20Videos/a%20&%20b.mp4"), r"C:\My Videos\a & b.mp4");
1368        assert_eq!(from_pathurl("file:///C:/x/y.mp4"), r"C:\x\y.mp4");
1369        assert_eq!(from_pathurl("file://nas/share/v.mp4"), r"\\nas\share\v.mp4");
1370        assert_eq!(from_pathurl("C:/plain/v.mp4"), r"C:\plain\v.mp4");
1371        assert_eq!(timecode("00:01:02:15", 30.0), Some(62.5));
1372        assert_eq!(timecode("00;01;02;15", 30.0), Some(62.5));
1373        assert_eq!(timecode("garbage", 30.0), None);
1374        assert_eq!(channels("AA/V"), (true, vec![0, 1]));
1375        assert_eq!(channels("A2"), (false, vec![1]));
1376        assert_eq!(channels("B"), (true, vec![0]));
1377        assert_eq!(channels("V"), (true, vec![]));
1378    }
1379
1380    /// A placeholder holds the library row until the probe lands; adopting it rebuilds the clip that
1381    /// was dropped on the timeline in the meantime (real duration, real audio streams, same start).
1382    #[test]
1383    fn placeholder_is_adopted_without_losing_the_clip() {
1384        let mut p = Project::new();
1385        let id = p.add_asset(placeholder(r"C:\media\late.mp4"));
1386        assert_eq!(p.asset(id).unwrap().duration, 0.0);
1387        p.insert_asset_clips(id, 3.0, None);
1388        let placed = p.all_clips().count();
1389        assert_eq!(placed, 1, "one MIN_CLIP stand-in while the duration is unknown");
1390
1391        assert!(adopt(&mut p, id, asset(r"C:\media\late.mp4", 2)));
1392        assert_eq!(p.asset(id).unwrap().duration, 10.0);
1393        let clips: Vec<(f64, f64)> = p.all_clips().map(|(_, c)| (c.start, c.duration)).collect();
1394        assert_eq!(clips.len(), 3, "video + its two audio streams: {clips:?}");
1395        assert!(clips.iter().all(|(s, d)| (*s - 3.0).abs() < 1e-9 && (*d - 10.0).abs() < 1e-9), "{clips:?}");
1396        // adopting again (a re-probe of the same file) must not multiply the clips
1397        assert!(adopt(&mut p, id, asset(r"C:\media\late.mp4", 2)));
1398        assert_eq!(p.all_clips().count(), 3);
1399        assert!(!adopt(&mut p, 999, asset(r"C:\media\gone.mp4", 0)), "unknown asset id");
1400    }
1401
1402    /// The UI-thread half of an import is only the placeholders: ten files must hand back a receiver
1403    /// immediately, long before any probe could have run. Backend::Mf keeps the check from spawning
1404    /// ten ffprobe children and skewing the timing-sensitive tests beside it.
1405    #[test]
1406    fn probe_async_does_not_block_the_caller() {
1407        let files: Vec<(Id, String)> = (1..=10).map(|i| (i as Id, format!(r"C:\nope\{i}.mp4"))).collect();
1408        let paths: Vec<String> = files.iter().map(|(_, p)| p.clone()).collect();
1409        let t = std::time::Instant::now();
1410        let rx = probe_async(files, Backend::Mf);
1411        let spent = t.elapsed();
1412        assert!(spent < std::time::Duration::from_millis(50), "import blocked the UI thread for {spent:?}");
1413        assert!(paths.iter().any(|p| is_probing(p)), "the library needs to know these are still loading");
1414        // drain, so the worker is done and PROBING is clean again before anything else looks
1415        for _ in 0..paths.len() {
1416            assert!(rx.recv().unwrap().asset.is_err(), "nothing is at those paths");
1417        }
1418        assert!(paths.iter().all(|p| !is_probing(p)));
1419    }
1420}