1use 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 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 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
78fn cell(s: &str) -> String {
80 s.replace('|', "\\|").replace(['\n', '\r'], " ")
81}
82
83pub 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 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
119fn 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
137struct Build {
140 project: Project,
141 issues: Vec<Issue>,
142 dir: PathBuf,
143 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 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 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
192fn 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
210fn 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
221fn 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
234fn 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
257fn 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 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 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
381fn 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
436fn 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 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
538struct Event {
541 line: usize,
542 channel: String,
544 kind: char,
546 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 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 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 let mut assets: HashMap<String, Id> = HashMap::new();
623 let mut source: Vec<(String, f64, bool, bool)> = Vec::new(); 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
723fn 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
739fn 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
753fn 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#[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 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
834const MAX_XML_DEPTH: usize = 256;
838
839fn 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
912fn close(stack: &mut Vec<El>, root: &mut Option<El>, name: &str) {
914 if !stack.iter().any(|e| e.name == name) {
915 return; }
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
926fn 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
1004fn 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
1038static PROBING: Mutex<Vec<String>> = Mutex::new(Vec::new());
1048
1049pub fn is_probing(path: &str) -> bool {
1051 PROBING.lock().map(|v| v.iter().any(|p| p == path)).unwrap_or(false)
1052}
1053
1054pub struct Probed {
1056 pub id: Id,
1057 pub path: String,
1058 pub asset: Result<Asset, String>,
1059}
1060
1061pub 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
1089pub 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 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; }
1108 }
1109 });
1110 rx
1111}
1112
1113pub fn adopt(project: &mut Project, id: Id, probed: Asset) -> bool {
1117 let Some(a) = project.asset_mut(id).filter(|a| a.path == probed.path) else { return false };
1120 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 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 #[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 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 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 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 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 #[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 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 assert!(r.project.assets.iter().any(|a| a.path.ends_with("red.mp4") && (a.duration - 7.0).abs() < 1e-9));
1277 assert!(r.issues.iter().any(|i| i.detail.contains("stores no frame rate")));
1279 }
1280
1281 #[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 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 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 let x = parse_xml(r#"<a t="x>y"><b><c>1 & 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 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 #[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 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 #[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 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}