Browse / github.com/storytold/filmcraft / patch
Add native Premiere Pro project import
Add native Premiere Pro project import FilmCraft currently requires Premiere users to export an interchange format before bringing over an edit. This adds direct `.prproj` import through **File → Import From → Premiere Pro Project…**, the normal Import picker, drag/drop, and `file.importPremiereProject` for CLI/MCP clients. The app imports editable sequences, opens Premiere's first saved top-level sequence tab, and offers Save As to a native `.fcproj`. The conversion report shows each sequence's separate video/audio clip counts, offline media, and fidelity warnings. It is also available through `ui.inspect` and the Events panel. I
- Status
- open
- Score
- 0.00
- Adopters
- 0 other operators
- Author
- LeowFlow (imported from pull request #3428, open)
- Base
4764b1a481be6aed25e2e64aa09e18c04c5f6baf- Size
- +1599 −98 in 25 files
- Review
- pending
- Shared
- 2026-10-28
- Id
c68411bddccba57c4e10c4c1
How it went
No other operator has reported using it yet.
Diff
raw · Cargo.lock ROADMAP.md contributors/people.toml crates/engine/src/aaf_omf.rs crates/engine/src/commands.rs crates/engine/src/interchange.rs crates/engine/src/interchange_auto_points_tests.rs crates/engine/src/lib.rs crates/interchange/Cargo.toml crates/interchange/README.md crates/interchange/src/lib.rs crates/interchange/src/prproj.rs crates/interchange/tests/it/main.rs crates/interchange/tests/it/prproj.rs crates/ui-egui/src/i18n/es.tsv crates/ui-egui/src/lib.rs crates/ui-egui/src/menus.rs crates/ui-egui/src/premiere_import.rs crates/ui-egui/src/state.rs crates/ui-egui/tests/it/native_dialogs_ui.rs docs/file-format-parity.md docs/gaps.md docs/project-files.md docs/roadmap.md docs/target-app-parity.md
@@ -2242,7 +2242,9 @@ dependencies = [ "filmcraft-media", "filmcraft-project", "filmcraft-time",+ "flate2", "proptest",+ "quick-xml 0.41.0", "roxmltree", "serde", "serde_json",@@ -1,11 +1,17 @@ # FilmCraft Roadmap +- Native Premiere project import is available through **File > Import From > Premiere Pro+ Project…**, including saved timeline selection, clip counts, offline-media and conversion+ warnings, and Save As to `.fcproj`. This is a partial subset; bins, markers, graphics, animation+ and full plugin fidelity remain [G2](docs/gaps.md#g2-premieres-prproj-cannot-be-opened) gaps.+ The alpha stage and readiness estimates are unchanged.+ - UI.6: Compact Source and Program monitors separate timecodes from view settings and retain Source drag choices. Transport controls use the shared toolbar sizing helper, with overflow actions in an operable More menu and a scrolling fallback at tiny widths. - UI.3: Panel tabs, edge splits, tab reordering and floating groups use the shared docking model and drop resolver. Floating geometry is saved with named workspaces while legacy layout JSON remains readable. Dock dividers use bounded shared geometry and keyboard-accessible resize handles. Tools and dock tabs expose actual widget focus, paint focus outlines, and support keyboard traversal/activation. Control input reaches focused widgets in background windows; optional input isolation supports unattended validation. Existing workspace trees and saved layout formats are retained. -**Stage: alpha** · next: beta, ~19 points of ready-for-real-work and ~400–700 h away, plus opening Premiere's `.prproj` ([why](docs/target-app-parity.md#stage); passes the [core-workflow gate](docs/roadmap.md#alpha-gate))+**Stage: alpha** · next: beta, ~19 points of ready-for-real-work and ~400–700 h away, plus completing Premiere's `.prproj` import ([why](docs/target-app-parity.md#stage); passes the [core-workflow gate](docs/roadmap.md#alpha-gate)) -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-22 · **Change:** minor (ProRes alpha final-run conformance and native macOS validation) · **Target:** Adobe Premiere Pro 2026 (26.5.2)+> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-27 · **Change:** minor (native partial Premiere project import; readiness unchanged) · **Target:** Adobe Premiere Pro 2026 (26.5.2) Progress toward parity with Adobe Premiere Pro. This page is the summary; the detail is in [docs/target-app-parity.md](docs/target-app-parity.md) (assessment, methodology, calibration),@@ -168,6 +174,7 @@ Ranked; detail and "done when" in [docs/roadmap.md](docs/roadmap.md) and [docs/g | Date | Change | Summary | |---|---|---|+| 2026-10-27 | minor | Native partial Premiere project import, saved timeline selection and conversion report; stage and readiness estimates unchanged | | 2026-10-22 | minor | ProRes alpha: write the final singleton run code; 4444/XQ HD clips that failed native macOS decoding now decode, with syntax regression tests (EffectCraft #293) | | 2026-10-13 | minor | Language table: German covers the whole interface (~100%) | | 2026-10-12 | trivial | Progress log: Transform shutter on adjustment layers (#1088) |@@ -17,6 +17,9 @@ # To remove your names, delete your entry (or set fields empty) in a PR. The next # contributors.py run, and the next build, drop them. +[people.LeowFlow]+sync_github_name = true+ [people.echelon] real_name = "Brandon Thomas" display_name = "Brandon Thomas"@@ -456,5 +456,5 @@ pub fn import_document(s: &mut Session, p: &Value, cmd: &str) -> Result<Value> { let bytes = s.services.read_file(&path).map_err(|e| EngineError::Other(format!("{path}: {e}")))?; let format = crate::interchange::detect(&path, &bytes).filter(|f| matches!(f, filmcraft_interchange::Format::Aaf | filmcraft_interchange::Format::Omf)); let format = format.ok_or_else(|| bad(cmd, format!("{path} is not an AAF or OMF file")))?;- crate::interchange::import(s, &path, &bytes, format)+ crate::interchange::import(s, &path, &bytes, format, None) }@@ -1428,7 +1428,14 @@ fn build() -> Vec<CommandSpec> { } // Media files through the host's reader (no whole-file read) when it has one. let streamed = filmcraft_media::is_importable(std::path::Path::new(&path)) && s.services.reader(&path).is_some();- let read = if streamed { Ok(Vec::new()) } else { s.services.read_file(&path) };+ let premiere = std::path::Path::new(&path).extension().is_some_and(|ext| ext.eq_ignore_ascii_case("prproj"));+ let read = if premiere {+ crate::interchange::read_premiere(s, &path)+ } else if streamed {+ Ok(Vec::new())+ } else {+ s.services.read_file(&path)+ }; match read { Ok(_) if streamed => match import_streamed(s, &path, bin) { Ok(id) => ids.push(id.0),@@ -1441,7 +1448,7 @@ fn build() -> Vec<CommandSpec> { Err(e) => errors.push(format!("{path}: {e}")), } } else if let Some(fmt) = crate::interchange::detect(&path, &b) {- match crate::interchange::import(s, &path, &b, fmt) {+ match crate::interchange::import(s, &path, &b, fmt, bin) { Ok(r) => { sequences.extend(r["sequences"].as_array().cloned().unwrap_or_default()); reports.push(r);@@ -1553,6 +1560,15 @@ fn build() -> Vec<CommandSpec> { has_seq, |s, p| crate::aaf_omf::export_omf(s, p) ),+ cmd!(+ "file.importPremiereProject",+ "Premiere Pro Project…",+ ["File", "Import From"],+ None,+ r#"{"path":str,"bin":binId?}"#,+ always,+ crate::interchange::import_premiere+ ), cmd!("file.importAaf", "Import AAF…", [], None, r#"{"path":str}"#, always, |s, p| crate::aaf_omf::import_document(s, p, "file.importAaf")), cmd!("file.save", "Save", ["File"], Some("Cmd+S"), r#"{"path":str?}"#, always, |s, p| { let path = str_p(p, "path").map(str::to_string).or_else(|| s.path.clone()).ok_or_else(|| bad("file.save", "no path (use Save As)"))?;@@ -3,14 +3,46 @@ use serde_json::{Value, json}; use filmcraft_interchange::{ExportOptions, Format, ImportOptions};-use filmcraft_project::{ItemId, ItemKind, MediaRef};+use filmcraft_project::{BinId, ItemId, ItemKind, MediaRef}; use crate::{EngineError, Result, Session}; +/// Read a Premiere project through the host with a cap before allocating its contents. A range+/// read stays bounded even if a file grows between its metadata and content reads.+pub fn read_premiere(s: &Session, path: &str) -> std::io::Result<Vec<u8>> {+ let size = s.services.file_size(path)?;+ if size > filmcraft_interchange::prproj::MAX_INPUT_BYTES as u64 {+ return Err(std::io::Error::other("Premiere project exceeds the 64 MiB input limit"));+ }+ let size = usize::try_from(size).map_err(|_| std::io::Error::other("Premiere project size is unsupported"))?;+ s.services.read_range(path, 0, size)+}++pub fn import_premiere(s: &mut Session, p: &Value) -> Result<Value> {+ const CMD: &str = "file.importPremiereProject";+ let path = p+ .get("path")+ .and_then(Value::as_str)+ .filter(|path| !path.is_empty() && path.len() <= 32768)+ .ok_or_else(|| crate::commands::bad(CMD, "need a non-empty `path` of at most 32768 bytes"))?;+ if !std::path::Path::new(path).extension().is_some_and(|ext| ext.eq_ignore_ascii_case("prproj")) {+ return Err(crate::commands::bad(CMD, "choose a .prproj file"));+ }+ let mut params = json!({"paths": [path]});+ if let Some(bin) = p.get("bin") {+ let id = bin.as_u64().ok_or_else(|| crate::commands::bad(CMD, "`bin` must be a non-negative integer"))?;+ if s.project.root.find_bin(BinId(id)).is_none() {+ return Err(crate::commands::bad(CMD, "the destination bin does not exist"));+ }+ params["bin"] = bin.clone();+ }+ s.execute("file.import", params)+}+ /// The interchange format of a file, if it is one (by content, with the extension as a hint). pub fn detect(path: &str, bytes: &[u8]) -> Option<Format> { let ext = std::path::Path::new(path).extension().and_then(|e| e.to_str()).map(str::to_ascii_lowercase);- if !matches!(ext.as_deref(), Some("edl" | "xml" | "fcpxml" | "otio" | "aaf" | "omf" | "omfi")) {+ if !matches!(ext.as_deref(), Some("edl" | "xml" | "fcpxml" | "otio" | "aaf" | "omf" | "omfi" | "prproj")) { return None; } filmcraft_interchange::detect(bytes, ext.as_deref())@@ -46,16 +78,23 @@ fn write_embedded_audio(s: &Session, path: &str, wav: &[u8]) -> Result<String> { Err(EngineError::Other(format!("{path}: no unused embedded-audio filename after 1000 attempts"))) } -/// Import a document: merge its bins, media and sequences into the project (one undo step), then-/// link each media file that exists on disk. Returns the new sequences.-pub fn import(s: &mut Session, path: &str, bytes: &[u8], format: Format) -> Result<Value> {+/// Import a document and link its media. Premiere imports group the merge and links into one+/// undo step; existing interchange formats retain their separate link undo steps.+pub fn import(s: &mut Session, path: &str, bytes: &[u8], format: Format, bin: Option<BinId>) -> Result<Value> { let p = std::path::Path::new(path); let opts = ImportOptions { base_dir: p.parent().map(|d| d.to_string_lossy().to_string()), name: p.file_stem().map(|n| n.to_string_lossy().to_string()), ..Default::default() };+ let mut preferred_sequence_index = None; let (fragment, report) = match format {+ Format::Premiere => {+ let (fragment, view, report) =+ filmcraft_interchange::prproj::import_with_view(bytes, &opts).map_err(|e| EngineError::Other(format!("{path}: {e}")))?;+ preferred_sequence_index = fragment.sequences.iter().position(|id| Some(*id) == view.active_sequence);+ (fragment, report)+ } // AAF / OMF may embed audio: write it next to the document (the media items point there) Format::Aaf | Format::Omf => { let r = if format == Format::Aaf { filmcraft_interchange::aaf::import(bytes, &opts) } else { filmcraft_interchange::omf::import(bytes, &opts) };@@ -80,85 +119,113 @@ pub fn import(s: &mut Session, path: &str, bytes: &[u8], format: Format) -> Resu sequence.settings.validate().map_err(|e| EngineError::Other(format!("{path}: invalid imported sequence settings: {e}")))?; } }- let before: std::collections::HashSet<ItemId> = s.project.items.keys().copied().collect(); let name = opts.name.clone().unwrap_or_else(|| format.name().to_string());- let seqs = s.edit(&format!("Import {name}"), |proj, _| {- let seqs = filmcraft_interchange::merge_into(proj, fragment, None);- // A document that sets only some Motion parameters leaves the others at their "auto"- // (NaN) defaults. Resolve them as placing a clip does, for every format. A media file's- // picture size is only what the document says so far (an EDL says nothing: the importer- // assumes the sequence size), so its clips' anchors wait for Link Media below.- proj.resolve_placed_auto_points(|seq| !before.contains(&seq), |source| !is_file_media(source));- Ok(seqs)- })?;- // Link media: probe each new file-backed item that exists, through the host's reader as a- // direct import does (only the container index is read now, not the whole file: #461).- let new_media: Vec<(ItemId, String)> = s- .project- .items- .values()- .filter(|i| !before.contains(&i.id))- .filter_map(|i| match &i.kind {- ItemKind::Media(m) => match &m.media {- MediaRef::File { path } => Some((i.id, path.clone())),+ // Parsing must succeed before the undo group starts: malformed input leaves even the+ // revision untouched. Merge and media links then form one undoable import.+ let label = format!("Import {name}");+ let apply = |s: &mut Session| {+ let before: std::collections::HashSet<ItemId> = s.project.items.keys().copied().collect();+ let seqs = s.edit(&format!("Import {name}"), |proj, _| {+ let seqs = filmcraft_interchange::merge_into(proj, fragment, bin);+ // A document that sets only some Motion parameters leaves the others at their "auto"+ // (NaN) defaults. Resolve them as placing a clip does, for every format. A media file's+ // picture size is only what the document says so far (an EDL says nothing: the importer+ // assumes the sequence size), so its clips' anchors wait for Link Media below.+ proj.resolve_placed_auto_points(|seq| !before.contains(&seq), |source| !is_file_media(source));+ Ok(seqs)+ })?;+ // Link media: probe each new file-backed item that exists, through the host's reader as a+ // direct import does (only the container index is read now, not the whole file: #461).+ let new_media: Vec<(ItemId, String)> = s+ .project+ .items+ .values()+ .filter(|i| !before.contains(&i.id))+ .filter_map(|i| match &i.kind {+ ItemKind::Media(m) => match &m.media {+ MediaRef::File { path } => Some((i.id, path.clone())),+ _ => None,+ }, _ => None,- },- _ => None,- })- .collect();- // The document's media paths are opened as stored paths of the document.- s.media.add_stored_paths(new_media.iter().map(|(_, p)| p.clone()), Some(path));- let mut linked = 0;- let mut offline = Vec::new();- for (id, mpath) in new_media {- let file = match s.media.follow(&mpath) {- Ok(file) => file,- Err(why) => {- log::warn!("{mpath} is not opened: {why}");- offline.push(mpath);- continue;- }- };- match s.media.open_file(&file, &*s.services).ok() {- Some(src) => {- let info = src.info().clone();- let identity = crate::relink::identity_of(&*s.services, &file).ok();- let rebase = (format == Format::Edl).then_some(info.start_timecode).flatten();- s.edit("Link Media", |proj, _| {- if let Some(item) = proj.items.get_mut(&id)- && let ItemKind::Media(m) = &mut item.kind- {- let rate = info.video.as_ref().map(|v| v.frame_rate);- m.info = info.clone();- m.offline = false;- m.identity = identity;- if let (Some(tc), Some(rate)) = (rebase, rate) {- filmcraft_interchange::rebase_source_timecode(proj, id, tc, rate);+ })+ .collect();+ // The document's media paths are opened as stored paths of the document.+ s.media.add_stored_paths(new_media.iter().map(|(_, p)| p.clone()), Some(path));+ let mut linked = 0;+ let mut offline = Vec::new();+ for (id, mpath) in new_media {+ let file = match s.media.follow(&mpath) {+ Ok(file) => file,+ Err(why) => {+ log::warn!("{mpath} is not opened: {why}");+ offline.push(mpath);+ continue;+ }+ };+ match s.media.open_file(&file, &*s.services).ok() {+ Some(src) => {+ let info = src.info().clone();+ let identity = crate::relink::identity_of(&*s.services, &file).ok();+ let rebase = (format == Format::Edl).then_some(info.start_timecode).flatten();+ s.edit("Link Media", |proj, _| {+ if let Some(item) = proj.items.get_mut(&id)+ && let ItemKind::Media(m) = &mut item.kind+ {+ let rate = info.video.as_ref().map(|v| v.frame_rate);+ m.info = info.clone();+ m.offline = false;+ m.identity = identity;+ if let (Some(tc), Some(rate)) = (rebase, rate) {+ filmcraft_interchange::rebase_source_timecode(proj, id, tc, rate);+ } }- }- // the picture size is now the file's own: centre the anchors of its clips in it- proj.resolve_placed_auto_points(|seq| !before.contains(&seq), |source| source.id == id);- Ok(())- })?;- s.media.insert_file(id, &mpath, src);- linked += 1;+ // the picture size is now the file's own: centre the anchors of its clips in it+ proj.resolve_placed_auto_points(|seq| !before.contains(&seq), |source| source.id == id);+ Ok(())+ })?;+ s.media.insert_file(id, &mpath, src);+ linked += 1;+ }+ None => offline.push(mpath), }- None => offline.push(mpath), }- }- if let Some(&first) = seqs.first() {- s.state.active_sequence = Some(first);- if !s.state.open_sequences.contains(&first) {- s.state.open_sequences.push(first);+ if let Some(&first) = seqs.get(preferred_sequence_index.unwrap_or(0)) {+ s.state.active_sequence = Some(first);+ if !s.state.open_sequences.contains(&first) {+ s.state.open_sequences.push(first);+ }+ s.events.push(crate::Event::OpenSequence(first)); }- }- Ok(json!({- "format": format.name(),- "sequences": seqs.iter().map(|i| i.0).collect::<Vec<_>>(),- "linkedMedia": linked,- "offlineMedia": offline,- "report": report_json(&report),- }))+ let mut result = json!({+ "format": format.name(),+ "sequences": seqs.iter().map(|i| i.0).collect::<Vec<_>>(),+ "linkedMedia": linked,+ "offlineMedia": offline,+ "report": report_json(&report),+ });+ if format == Format::Premiere {+ result["documentEpoch"] = json!(s.document_epoch);+ result["sourcePath"] = json!(path);+ result["activeSequence"] = json!(s.state.active_sequence.map(|id| id.0));+ result["sequenceSummaries"] = json!(+ seqs.iter()+ .filter_map(|id| {+ let item = s.project.item(*id)?;+ let seq = item.as_sequence()?;+ Some(json!({"id": id.0, "name": item.name,+ "videoClips": seq.video_tracks.iter().map(|t| t.items.len()).sum::<usize>(),+ "audioClips": seq.audio_tracks.iter().map(|t| t.items.len()).sum::<usize>()}))+ })+ .collect::<Vec<_>>()+ );+ for entry in report_json(&report) {+ s.log.push(crate::panels::Level::Warning, "Premiere import", entry);+ }+ s.events.push(crate::Event::ImportReport(result.clone()));+ }+ Ok(result)+ };+ if format == Format::Premiere { s.edit_group(&label, apply) } else { apply(s) } } /// `file.exportInterchange {format: "edl"|"xml"|"fcpxml"|"otio"|"aaf"|"omf" = "xml", path, sequence?}`@@ -13,6 +13,123 @@ use crate::media_test_util::{make_movie, tmp_dir}; const RATE: &str = "<rate><timebase>24</timebase><ntsc>FALSE</ntsc></rate>"; +#[test]+fn premiere_project_import_is_a_command_and_can_undo_redo_and_save() {+ let d = tmp_dir("prproj-command");+ let path = d.join("edit.prproj");+ let xml = r#"<PremiereData Version="3"><Sequence ObjectUID="seq"><Name>Cut</Name><TrackGroups><TrackGroup Index="0"><Second ObjectRef="v"/></TrackGroup></TrackGroups></Sequence><VideoTrackGroup ObjectID="v"><FrameRect>0,0,1920,1080</FrameRect><TrackGroup><FrameRate>10160640000</FrameRate><Tracks/></TrackGroup></VideoTrackGroup></PremiereData>"#;+ std::fs::write(&path, xml).unwrap();+ let mut session = Session::default();+ let before = filmcraft_format::encode(&session.project, false);+ let result = session.execute("file.import", json!({"paths": [path]})).unwrap();+ assert_eq!(result["sequences"].as_array().unwrap().len(), 1);+ assert_eq!(result["documents"][0]["format"], "Premiere Pro Project");+ let after = filmcraft_format::encode(&session.project, false);+ session.execute("edit.undo", json!({})).unwrap();+ assert_eq!(filmcraft_format::encode(&session.project, false), before);+ session.execute("edit.redo", json!({})).unwrap();+ assert_eq!(filmcraft_format::encode(&session.project, false), after);+ let native = d.join("edit.fcproj");+ session.execute("file.saveAs", json!({"path": native})).unwrap();+ let loaded = filmcraft_format::decode(&std::fs::read(native).unwrap()).unwrap();+ assert_eq!(loaded.project.sequences().count(), 1);+ let saved = filmcraft_format::encode(&session.project, false);+ let revision = session.revision;+ std::fs::write(&path, b"bad gzip/XML").unwrap();+ assert!(session.execute("file.import", json!({"paths": [path]})).is_err());+ assert_eq!(filmcraft_format::encode(&session.project, false), saved);+ assert_eq!(session.revision, revision);+}++#[test]+fn premiere_import_and_all_media_links_are_one_undo_step() {+ let d = tmp_dir("prproj-linked-undo");+ let media = d.join("shot.mov");+ make_movie(&media, DemoScene::Forest, 320, 180, 24);+ let xml = format!(+ r#"<PremiereData Version="3"><Sequence ObjectUID="seq"><Name>Cut</Name><TrackGroups><TrackGroup Index="0"><Second ObjectRef="v"/></TrackGroup></TrackGroups></Sequence><VideoTrackGroup ObjectID="v"><FrameRect>0,0,320,180</FrameRect><TrackGroup><FrameRate>10594584000</FrameRate><Tracks/></TrackGroup></VideoTrackGroup><Media ObjectUID="m1"><FilePath>{path}</FilePath><VideoStream ObjectRef="vs"/></Media><Media ObjectUID="m2"><FilePath>{path}</FilePath><VideoStream ObjectRef="vs"/></Media><VideoStream ObjectID="vs"><FrameRate>10594584000</FrameRate><FrameRect>0,0,320,180</FrameRect><Duration>254016000000</Duration></VideoStream></PremiereData>"#,+ path = media.to_string_lossy()+ );+ let path = d.join("edit.prproj");+ std::fs::write(&path, xml).unwrap();+ let mut s = Session::default();+ let before = filmcraft_format::encode(&s.project, false);+ let r = s.execute("file.importPremiereProject", json!({"path": path})).unwrap();+ assert_eq!(r["documents"][0]["linkedMedia"], 2);+ assert_eq!(s.history.undo.len(), 1);+ let imported = filmcraft_format::encode(&s.project, false);+ s.execute("edit.undo", json!({})).unwrap();+ assert_eq!(filmcraft_format::encode(&s.project, false), before);+ s.execute("edit.redo", json!({})).unwrap();+ assert_eq!(filmcraft_format::encode(&s.project, false), imported);+}++#[test]+fn premiere_import_opens_its_saved_edit_and_native_save_preserves_it() {+ let d = tmp_dir("prproj-saved-timeline");+ let path = d.join("edit.prproj");+ let xml = r#"<PremiereData Version="3"><Project ObjectID="project"><Node><Properties><MZ.PrefixKey.OpenSequenceGuidList.1>edit</MZ.PrefixKey.OpenSequenceGuidList.1></Properties></Node></Project><Sequence ObjectUID="trailer"><Name>Trailer</Name><TrackGroups><TrackGroup Index="0"><Second ObjectRef="v"/></TrackGroup></TrackGroups></Sequence><Sequence ObjectUID="edit"><Name>Main edit</Name><TrackGroups><TrackGroup Index="0"><Second ObjectRef="v"/></TrackGroup></TrackGroups></Sequence><VideoTrackGroup ObjectID="v"><FrameRect>0,0,1920,1080</FrameRect><TrackGroup><FrameRate>10160640000</FrameRate><Tracks/></TrackGroup></VideoTrackGroup></PremiereData>"#;+ std::fs::write(&path, xml).unwrap();+ let mut s = Session::default();+ let bin = s.execute("file.newBin", json!({"name": "Imported projects"})).unwrap()["bin"].as_u64().unwrap();+ let r = s.execute("file.importPremiereProject", json!({"path": path, "bin": bin})).unwrap();+ assert_eq!(r["sequences"].as_array().unwrap().len(), 2);+ let active = s.state.active_sequence.unwrap();+ assert_eq!(s.project.item(active).unwrap().name, "Main edit");+ assert_eq!(s.project.root.parent_of(active), Some(filmcraft_project::BinId(bin)));+ assert!(+ s.drain_events().iter().any(|event| matches!(event, crate::Event::ImportReport(report) if report["sequenceSummaries"].as_array().unwrap().len() == 2))+ );+ let native = d.join("main.fcproj");+ s.execute("file.saveAs", json!({"path": native})).unwrap();+ let mut reopened = Session::default();+ reopened.execute("file.open", json!({"path": native})).unwrap();+ assert_eq!(reopened.state.active_sequence, Some(active));+ assert_eq!(reopened.project.sequences().count(), 2);+}++#[test]+fn premiere_import_rejects_hostile_parameters_without_mutation() {+ let mut s = Session::default();+ let before = filmcraft_format::encode(&s.project, false);+ for params in [+ json!({}),+ json!({"path": null}),+ json!({"path": -1}),+ json!({"path": []}),+ json!({"path": ""}),+ json!({"path": "movie.mp4"}),+ json!({"path": "x".repeat(32769)}),+ json!({"path": "cut.prproj", "bin": -1}),+ json!({"path": "cut.prproj", "bin": "root"}),+ json!({"path": "cut.prproj", "bin": 99999}),+ ] {+ assert!(s.execute("file.importPremiereProject", params).is_err());+ assert_eq!(filmcraft_format::encode(&s.project, false), before);+ assert!(s.history.undo.is_empty());+ }+}++#[test]+fn oversized_premiere_files_are_rejected_before_reading() {+ struct Oversized;+ impl crate::Services for Oversized {+ fn file_size(&self, _: &str) -> std::io::Result<u64> {+ Ok(filmcraft_interchange::prproj::MAX_INPUT_BYTES as u64 + 1)+ }+ fn read_file(&self, _: &str) -> std::io::Result<Vec<u8>> {+ panic!("must not read an oversized project");+ }+ fn write_file(&self, _: &str, _: &[u8]) -> std::io::Result<()> {+ panic!("import must never write the source project");+ }+ }+ let mut s = Session::new(std::sync::Arc::new(Oversized));+ let before = filmcraft_format::encode(&s.project, false);+ assert!(s.execute("file.importPremiereProject", json!({"path": "huge.prproj"})).unwrap_err().to_string().contains("64 MiB"));+ assert_eq!(filmcraft_format::encode(&s.project, false), before);+}+ /// A 1920×1080 FCP7 XML sequence with one clip of `media/shot.mov` (declared 3840×2160) whose /// Basic Motion filter sets only `scale`. fn xml_with_a_scale_only_motion_filter() -> String {@@ -422,6 +422,8 @@ pub enum Event { error: bool, }, OpenSequence(ItemId),+ /// An imported project's fidelity and missing-media report for frontends.+ ImportReport(Value), OpenSource(ItemId), /// Show this item in the Project panel (Reveal in Project). RevealInProject(ItemId),@@ -20,6 +20,8 @@ serde = { workspace = true } serde_json = { workspace = true } thiserror = { workspace = true } roxmltree = { workspace = true }+flate2 = { version = "1", default-features = false, features = ["rust_backend"] }+quick-xml = "0.41" [dev-dependencies] proptest = { workspace = true }@@ -4,6 +4,65 @@ Timeline interchange for FilmCraft: CMX 3600 EDL, Final Cut Pro 7 XML (xmeml), F OpenTimelineIO, AAF and OMF import and export, plus Avid ALE. Layer L2: no file I/O; media references are strings and audio essence is supplied by the caller (the engine). +## Premiere projects (import only)++`prproj::import`, `import_with(..., Format::Premiere, ...)`, and the existing engine command+`file.import {"paths":["Edit.prproj"]}` read gzip-compressed or plain UTF-8+`<PremiereData Version="3">` documents. The desktop Import picker includes `.prproj`.+The native menu **File > Import From > Premiere Pro Project…** uses this parser directly.+The in-app report shows separate video/audio clip counts, missing media and conversion warnings;+its Save As button writes a native project through the normal atomic-save workflow.+No separate converter executable is needed. Import and all media links are one undo step.+++There is no published complete `.prproj` schema. This is a clean-room, explicitly partial mapping+from contributor-supplied project documents, observed on 2026-10-17. No application code, bundled+artwork, presets, or third-party importer/decoder implementations were consulted. Gzip follows+[RFC 1952 (May 1996)](https://www.rfc-editor.org/rfc/rfc1952) using flate2's pure-Rust backend;+XML follows XML 1.0 via quick-xml and roxmltree. Project format exports remain unsupported;+`Format::ALL` lists the six formats supported in both directions.++Supported: all sequences and their ordered video/audio tracks (including empty tracks), file media+with stream descriptions and Unicode/relative paths, exact integer start/end/source-in times,+positive constant playback speeds, clip links, nested sequence references, work areas, static+Motion (position, scale, width, uniform scale, rotation, anchor, anti-flicker), static Volume and+stereo Channel Volume. Premiere frame/sample durations and edit times use the same+254016000000 ticks/second as FilmCraft; no frame/second float conversion is used for placement.+Transition alignment is an integer offset from the transition's start, also in ticks.++Recognized standard transitions use FilmCraft effects. Third-party transitions are explicitly+reported and substituted with Cross Dissolve (video) or Constant Power (audio), retaining the+transition interval and neighbouring clips. Their appearance, easing and private settings differ.+Animated supported parameters retain their initial value and report missing animation. Unknown+clip effects are named in the report. Unsupported source types fail conversion with an actionable+error rather than silently removing the clip.++Not transferred: original bin layout, labels, markers, captions/data tracks, generated graphics,+colour-management metadata, mixer automation, source/master effects, export settings, transition+parameter animation, reverse playback, speed ramps and third-party plugin settings. A summary+warning is always returned: this is not a promise of visual/audio equivalence. Negative/zero+speeds and unsupported source objects are refused. Compare playback against the original.+Native import probes linked media, but full codec and playback compatibility still requires+checking playback in the editor.++Hostile inputs: 64 MiB compressed/plain input, 128 MiB decompressed XML, bounded node/attribute+capacity, a streaming XML depth check **before** the recursive DOM parser (128 levels), 4096+sequences, 1024 tracks per group, no DTD/entities, checked frame sizes, finite parameters and+bounded integer times. Object definitions are indexed separately by ObjectID/ObjectUID;+duplicates and dangling references fail. Sequence cycles and nesting beyond 64 levels fail via+an iterative graph walk. No recursive object-reference resolution is used.+Global budgets additionally cap object-reference visits (100000), constructed model objects+(50000), imported text (32 MiB, charged conservatively), and report entries (4096). Text fields,+paths, identifiers and channel-layout JSON have individual limits. Shared object references cannot+amplify a small XML document into an unbounded import; exceeding a budget returns an error.++Validation: synthetic tests cover gzip/plain equivalence, 29.97 sequence vs 23.976 source timing,+48 kHz audio, source trims, 150% speed, links, Motion, transition substitution and exact alignment,+import-only detection/export refusal, saved timeline selection across conversion/native reopening,+ordered/stale/hostile saved tabs, corrupt numbers/references, nested cycles, 5000-level XML,+and every truncation and bit flip of a small project and its gzip representation under+`catch_unwind`. User projects stay outside the repository; no real project/media fixture ships.+ ## Specifications Clean-room: written from public specifications only. The AAF SDK, pyaaf2, the OMF Toolkit and@@ -33,6 +33,7 @@ pub mod fcp7; pub mod fcpxml; pub mod omf; pub mod otio;+pub mod prproj; mod common; mod comp;@@ -63,9 +64,12 @@ pub enum Format { Aaf, /// OMF Interchange 2.0 (Bento container). Omf,+ /// Premiere Pro project (gzip XML). Import only.+ Premiere, } impl Format {+ /// Formats supported for both import and export. Premiere is import only. pub const ALL: [Format; 6] = [Format::Edl, Format::Fcp7Xml, Format::Fcpxml, Format::Otio, Format::Aaf, Format::Omf]; pub fn name(self) -> &'static str {@@ -76,6 +80,7 @@ impl Format { Format::Otio => "OpenTimelineIO", Format::Aaf => "AAF", Format::Omf => "OMF",+ Format::Premiere => "Premiere Pro Project", } } @@ -88,6 +93,7 @@ impl Format { Format::Otio => "otio", Format::Aaf => "aaf", Format::Omf => "omf",+ Format::Premiere => "prproj", } } @@ -101,6 +107,7 @@ impl Format { "otio" => Some(Format::Otio), "aaf" => Some(Format::Aaf), "omf" | "omfi" => Some(Format::Omf),+ "prproj" => Some(Format::Premiere), _ => None, } }@@ -108,6 +115,9 @@ impl Format { /// Sniff the format of a document from its bytes, using the file extension as a hint. pub fn detect(bytes: &[u8], extension: Option<&str>) -> Option<Format> {+ if extension.is_some_and(|e| Format::from_extension(e) == Some(Format::Premiere)) {+ return Some(Format::Premiere);+ } if aaf::sniff(bytes) { return Some(Format::Aaf); }@@ -117,6 +127,9 @@ pub fn detect(bytes: &[u8], extension: Option<&str>) -> Option<Format> { let head = String::from_utf8_lossy(&bytes[..bytes.len().min(4096)]); let head = head.trim_start_matches('\u{feff}').trim_start(); if head.starts_with('<') {+ if head.contains("<PremiereData") {+ return Some(Format::Premiere);+ } if head.contains("<xmeml") { return Some(Format::Fcp7Xml); }@@ -259,6 +272,9 @@ pub fn import(bytes: &[u8], format: Format, base_dir: Option<&str>) -> Result<(I /// /// AAF and OMF documents may embed audio: [`aaf::import`] / [`omf::import`] also return it. pub fn import_with(bytes: &[u8], format: Format, opts: &ImportOptions) -> Result<(Imported, Report)> {+ if format == Format::Premiere {+ return prproj::import(bytes, opts);+ } if matches!(format, Format::Aaf | Format::Omf) { let (imported, extracted, mut report) = if format == Format::Aaf { aaf::import(bytes, opts)? } else { omf::import(bytes, opts)? }; if !extracted.is_empty() {@@ -273,6 +289,7 @@ pub fn import_with(bytes: &[u8], format: Format, opts: &ImportOptions) -> Result Format::Fcp7Xml => fcp7::import(&text, opts, &mut report)?, Format::Fcpxml => fcpxml::import(&text, opts, &mut report)?, Format::Otio => otio::import(&text, opts, &mut report)?,+ Format::Premiere => return Err(Error::Other("Premiere projects require the binary importer".into())), Format::Aaf | Format::Omf => return Err(Error::Other("AAF / OMF are binary documents".into())), }; Ok((imported, report))@@ -390,6 +407,7 @@ pub fn export(project: &Project, sequence: ItemId, format: Format, opts: &Export Format::Fcp7Xml => fcp7::export(project, sequence, opts, &mut report)?, Format::Fcpxml => fcpxml::export(project, sequence, opts, &mut report)?, Format::Otio => otio::export(project, sequence, opts, &mut report)?,+ Format::Premiere => return Err(Error::Other("Premiere project export is unsupported; save a FilmCraft .fcproj or export Final Cut Pro XML".into())), Format::Aaf | Format::Omf => return Err(Error::Other("AAF / OMF are binary documents".into())), }; Ok((text.into_bytes(), report))new file mode 100644@@ -0,0 +1,733 @@+//! Import-only reader for PremiereData v3 project documents (gzip or plain UTF-8 XML).+//!+//! Clean-room mapping from user-authored project data, never application code or bundled assets.+//! See the crate README for the supported subset and loss reporting.++use std::cell::Cell;+use std::collections::{HashMap, HashSet, VecDeque};+use std::io::Read;++use filmcraft_geom::Vec2;+use filmcraft_project::{ItemId, Param, ParamValue, ProjectView, SequenceSettings, Track, TrackItem, TrackKind, Transition, TransitionAlign, TransitionId};+use filmcraft_time::{FrameRate, TICKS_PER_SECOND, Tick, TimeRange};+use roxmltree::{Document, Node};++use crate::common::{Builder, MediaSpec, empty_sequence, kind_from_ext, resolve_path, set_param, transition_effect};+use crate::xml::{child, child_text, elements};+use crate::{Error, Format, ImportOptions, Imported, Report, Result};++/// Bounds apply before decompression and DOM construction; gzip trailer sizes are not trusted.+pub const MAX_INPUT_BYTES: usize = 64 * 1024 * 1024;+pub const MAX_XML_BYTES: usize = 128 * 1024 * 1024;+const MAX_NODES: u32 = 500_000;+const MAX_TRACKS: usize = 1024;+const MAX_SEQUENCES: usize = 4096;+const MAX_MODEL_OBJECTS: usize = 50_000;+const MAX_REFERENCES: usize = 100_000;+const MAX_IMPORTED_TEXT: usize = 32 * 1024 * 1024;+const MAX_WARNINGS: usize = 4096;++fn bad(message: impl Into<String>) -> Error {+ Error::parse(Format::Premiere, message)+}++/// Convert a Premiere project to a standalone FilmCraft project without accessing media files.+pub fn import(bytes: &[u8], opts: &ImportOptions) -> Result<(Imported, Report)> {+ import_with_view(bytes, opts).map(|(imported, _, report)| (imported, report))+}++/// Also retain the saved open sequence tabs, so opening a conversion shows the edit rather than+/// whichever sequence happens to be stored first. If several tabs were open, select the first+/// saved tab; the source's focused-tab state is not yet mapped.+pub fn import_with_view(bytes: &[u8], opts: &ImportOptions) -> Result<(Imported, ProjectView, Report)> {+ if opts.name.as_ref().is_some_and(|s| s.len() > 1024) || opts.base_dir.as_ref().is_some_and(|s| s.len() > 32768) {+ return Err(bad("import name or base directory exceeds the text budget"));+ }+ if bytes.len() > MAX_INPUT_BYTES {+ return Err(bad("project exceeds the 64 MiB input limit"));+ }+ let decoded;+ let xml = if bytes.starts_with(&[0x1f, 0x8b]) {+ let mut out = Vec::new();+ flate2::read::MultiGzDecoder::new(bytes)+ .take(MAX_XML_BYTES as u64 + 1)+ .read_to_end(&mut out)+ .map_err(|e| bad(format!("cannot decompress project: {e}")))?;+ if out.len() > MAX_XML_BYTES {+ return Err(bad("decompressed project exceeds the 128 MiB limit"));+ }+ decoded = out;+ decoded.as_slice()+ } else {+ bytes+ };+ let text = std::str::from_utf8(xml).map_err(|e| bad(format!("project XML is not UTF-8: {e}")))?;+ preflight_xml(text)?;+ let doc = Document::parse_with_options(text, roxmltree::ParsingOptions { nodes_limit: MAX_NODES, ..Default::default() })+ .map_err(|e| bad(format!("invalid project XML: {e}")))?;+ let root = doc.root_element();+ if !root.has_tag_name("PremiereData") || root.attribute("Version") != Some("3") {+ return Err(bad("expected <PremiereData Version=\"3\">; this project version is unsupported"));+ }+ validate_text_limits(root)?;+ let graph = Graph::new(root)?;+ let mut builder = Builder::new(opts.name.as_deref().unwrap_or("Premiere Project"));+ let mut report = Report::default();+ let mut sources = HashMap::new();+ let sequences: Vec<_> = elements(root).filter(|n| n.has_tag_name("Sequence") && definition(*n).is_some()).collect();+ if sequences.is_empty() {+ return Err(Error::Empty);+ }+ if sequences.len() > MAX_SEQUENCES {+ return Err(bad("too many sequences (limit 4096)"));+ }+ // Reserve every sequence first: nested references do not recurse through the document.+ for &n in &sequences {+ let name = child_text(n, "Name").unwrap_or("Sequence");+ let settings = graph.settings(n)?;+ graph.claim_object()?;+ graph.claim_text(name.len().saturating_mul(4))?;+ let id = builder.reserve_sequence(name, settings, None);+ sources.insert(n.id(), id);+ builder.top.push(id);+ }+ for n in elements(root).filter(|n| n.has_tag_name("Media") && definition(*n).is_some()) {+ let path = ["ActualMediaFilePath", "FilePath", "RelativePath"].into_iter().find_map(|tag| child_text(n, tag).filter(|s| !s.is_empty()));+ let Some(path) = path else {+ warning(&mut report, format!("media {} has no file path and cannot be imported", definition(n).unwrap_or("?")))?;+ continue;+ };+ let path = resolve_path(path, opts.base_dir.as_deref());+ if path.len() > 32768 {+ return Err(bad("resolved media path exceeds the text budget"));+ }+ let video = graph.optional_at(n, &["VideoStream"])?;+ let audio = graph.optional_at(n, &["AudioStream"])?;+ let mut spec = MediaSpec { kind: Some(kind_from_ext(&path)), ..Default::default() };+ if let Some(v) = video {+ let (w, h) = frame_size(v)?;+ spec.video = Some((w, h, rate(v)?));+ spec.duration = Some(ticks(v, "Duration", None)?);+ }+ if let Some(a) = audio {+ let sample_rate = sample_rate(a)?;+ let channels = match child_text(a, "AudioChannelLayout") {+ Some(s) => {+ let layout: Vec<serde_json::Value> = serde_json::from_str(s).map_err(|e| bad(format!("invalid audio channel layout: {e}")))?;+ if layout.is_empty() || layout.len() > 64 {+ return Err(bad("audio channel count must be 1..64"));+ }+ layout.len() as u32+ }+ None => 2,+ };+ spec.audio = Some((sample_rate, channels));+ spec.duration = Some(spec.duration.unwrap_or(Tick::ZERO).max(ticks(a, "Duration", None)?));+ }+ let name = child_text(n, "Title").unwrap_or_else(|| crate::common::file_name(&path));+ graph.claim_object()?;+ graph.claim_text(name.len().saturating_mul(4).saturating_add(path.len().saturating_mul(2)))?;+ let id = builder.file_media(definition(n).unwrap_or(name), name, &path, &spec, None);+ sources.insert(n.id(), id);+ }+ let mut links = HashMap::new();+ for n in elements(root).filter(|n| n.has_tag_name("Link") && definition(*n).is_some()) {+ let id = builder.link_id();+ for r in at(n, &["TrackItemGroup", "TrackItems"]).into_iter().flat_map(elements) {+ let target = graph.resolve(r)?;+ if links.insert(target.id(), id).is_some() {+ return Err(bad("a clip belongs to more than one link group"));+ }+ }+ }+ for n in sequences {+ let id = *sources.get(&n.id()).ok_or_else(|| bad("missing reserved sequence"))?;+ let mut seq = empty_sequence(graph.settings(n)?);+ let name = child_text(n, "Name").unwrap_or("Sequence");+ for group in graph.groups(n)? {+ let kind = match group.tag_name().name() {+ "VideoTrackGroup" => TrackKind::Video,+ "AudioTrackGroup" => TrackKind::Audio,+ _ => {+ warning(&mut report, format!("sequence {name:?}: unsupported {} (captions/data tracks are not imported)", group.tag_name().name()))?;+ continue;+ }+ };+ let refs = ordered(at(group, &["TrackGroup", "Tracks"]))?;+ if refs.len() > MAX_TRACKS {+ return Err(bad("too many tracks (limit 1024 per group)"));+ }+ for r in refs {+ let tr = graph.resolve(r)?;+ graph.claim_object()?;+ let mut track = builder.track(kind, seq.tracks(kind).len());+ let metadata = at(tr, &["ClipTrack", "Track"]).ok_or_else(|| bad("unsupported track layout"))?;+ if let Some(s) = child_text(metadata, "Name") {+ graph.claim_text(s.len())?;+ track.name = s.into();+ }+ track.enabled = !boolean(metadata, "IsDisabled", false)?;+ track.muted = boolean(metadata, "IsMuted", false)?;+ track.locked = boolean(metadata, "IsLocked", false)?;+ for cr in ordered(at(tr, &["ClipTrack", "ClipItems", "TrackItems"]))? {+ let node = graph.resolve(cr)?;+ let base = child(node, "ClipTrackItem").ok_or_else(|| bad(format!("unsupported timeline item {}", node.tag_name().name())))?;+ let timing = child(base, "TrackItem").ok_or_else(|| bad("clip has no TrackItem timing"))?;+ let start = ticks(timing, "Start", Some(Tick::ZERO))?;+ let end = ticks(timing, "End", None)?;+ if end <= start {+ return Err(bad("clip End must be after Start"));+ }+ let sub = graph.required_at(base, &["SubClip"])?;+ let clip = graph.required_at(sub, &["Clip"])?;+ let clip_data = child(clip, "Clip").ok_or_else(|| bad("unsupported source clip layout"))?;+ let source = graph.required_at(clip_data, &["Source"])?;+ let target = match source.tag_name().name() {+ "VideoMediaSource" | "AudioMediaSource" => graph.required_at(source, &["MediaSource", "Media"]),+ "VideoSequenceSource" | "AudioSequenceSource" => graph.required_at(source, &["SequenceSource", "Sequence"]),+ _ => Err(bad(format!("unsupported source {}; export Final Cut Pro XML from Premiere for this project", source.tag_name().name()))),+ }?;+ let item = *sources.get(&target.id()).ok_or_else(|| bad("clip references media without a supported file path"))?;+ let clip_name = child_text(sub, "Name").unwrap_or("Clip");+ let source_in = ticks(clip_data, "InPoint", Some(Tick::ZERO))?;+ graph.claim_object()?;+ graph.claim_text(clip_name.len().saturating_mul(2))?;+ let mut ti = builder.clip(item, kind, clip_name, start, end - start, source_in);+ ti.speed = number(clip_data, "PlaybackSpeed", 1.0)?;+ if ti.speed <= 0.0 || ti.speed > 1000.0 {+ return Err(bad("unsupported playback speed (must be positive and at most 1000)"));+ }+ ti.enabled = !boolean(base, "IsDisabled", false)? && !boolean(timing, "IsDisabled", false)?;+ ti.link = links.get(&node.id()).copied();+ let sw_sh = builder+ .p+ .item(item)+ .and_then(|i| {+ i.as_media()+ .and_then(|m| m.info.video.as_ref())+ .map(|v| (v.width, v.height))+ .or_else(|| i.as_sequence().map(|s| (s.settings.width, s.settings.height)))+ })+ .unwrap_or((seq.settings.width, seq.settings.height));+ graph.effects(base, &mut ti, (seq.settings.width, seq.settings.height), sw_sh, &mut report)?;+ track.items.push(ti);+ }+ track.sort();+ for pair in track.items.windows(2) {+ if let [left, right] = pair+ && left.end() > right.start+ {+ return Err(bad("overlapping clips on one track are unsupported"));+ }+ }+ for r in ordered(at(tr, &["ClipTrack", "TransitionItems", "TrackItems"]))? {+ let transition = graph.resolve(r)?;+ graph.transition(transition, &mut track, &mut builder, &mut report)?;+ }+ seq.tracks_mut(kind).push(track);+ }+ }+ if let Some(props) = at(n, &["Node", "Properties"])+ && child(props, "MZ.WorkInPoint").is_some()+ && child(props, "MZ.WorkOutPoint").is_some()+ {+ let start = ticks(props, "MZ.WorkInPoint", None)?;+ let end = ticks(props, "MZ.WorkOutPoint", None)?;+ if end >= start {+ seq.work_area = Some(TimeRange::from_bounds(start, end));+ }+ }+ builder.put_sequence(id, seq);+ }+ reject_sequence_cycles(&builder)?;+ let nested: HashSet<_> = builder+ .top+ .iter()+ .filter_map(|id| builder.p.sequence(*id))+ .flat_map(|s| s.all_tracks())+ .flat_map(|t| &t.items)+ .filter(|c| builder.p.sequence(c.item).is_some())+ .map(|c| c.item)+ .collect();+ builder.top.retain(|id| !nested.contains(id));+ // Resolve intrinsic Motion defaults to finite centres before serialising the native file.+ builder.p.resolve_placed_auto_points(|_| true, |_| true);+ warning(+ &mut report,+ "Premiere import is a subset: bins, markers, captions, graphics, colour management, mixer automation, source effects and export settings are not transferred; inspect the result before continuing the edit",+ )?;+ let imported = builder.finish();+ let view = project_view(root, &graph, &sources, &imported.sequences)?;+ Ok((imported, view, report))+}++fn project_view(root: Node<'_, '_>, graph: &Graph<'_, '_>, sources: &HashMap<roxmltree::NodeId, ItemId>, top: &[ItemId]) -> Result<ProjectView> {+ let mut tabs = std::collections::BTreeMap::new();+ let mut entries = 0usize;+ for props in elements(root).filter(|n| n.has_tag_name("Project") && definition(*n).is_some()).filter_map(|n| at(n, &["Node", "Properties"])) {+ for n in elements(props) {+ let Some(index) = n.tag_name().name().strip_prefix("MZ.PrefixKey.OpenSequenceGuidList.") else { continue };+ entries = entries.saturating_add(1);+ if entries > MAX_SEQUENCES {+ return Err(bad("too many saved sequence tabs (limit 4096)"));+ }+ // View metadata can be stale. Only known sequence UIDs and bounded numeric indices+ // are used; no reference is allowed to create media or alter the edit.+ let Some(index) = index.parse::<u32>().ok() else { continue };+ let Some(sequence) = n.text().and_then(|uid| graph.uids.get(uid.trim())).filter(|n| n.has_tag_name("Sequence")) else { continue };+ let Some(&id) = sources.get(&sequence.id()) else { continue };+ if tabs.insert(index, id).is_some() {+ return Err(bad("duplicate saved sequence tab index"));+ }+ }+ }+ let mut seen = HashSet::new();+ let mut open_sequences: Vec<_> = tabs.into_values().filter(|id| seen.insert(*id)).collect();+ if open_sequences.is_empty() {+ open_sequences.extend(top.first().copied());+ }+ Ok(ProjectView { active_sequence: open_sequences.first().copied(), open_sequences, ..Default::default() })+}++/// roxmltree's tokenizer recurses into elements. Bound the depth with a streaming parser before+/// constructing the DOM, including for files whose deep branch is otherwise never imported.+fn preflight_xml(text: &str) -> Result<()> {+ // roxmltree estimates its initial node/attribute capacities from literal delimiters, even+ // inside text and comments. Bound these counts as well as actual parsed node counts.+ for delimiter in *b"<=" {+ if text.bytes().filter(|b| *b == delimiter).take(MAX_NODES as usize + 1).count() > MAX_NODES as usize {+ return Err(bad("XML exceeds the node/attribute allocation budget"));+ }+ }+ let mut reader = quick_xml::Reader::from_str(text);+ let mut depth = 0usize;+ loop {+ match reader.read_event().map_err(|e| bad(format!("invalid project XML: {e}")))? {+ quick_xml::events::Event::Start(_) => {+ depth = depth.saturating_add(1);+ if depth > 128 {+ return Err(bad("XML nesting exceeds 128 levels"));+ }+ }+ quick_xml::events::Event::End(_) => depth = depth.saturating_sub(1),+ quick_xml::events::Event::DocType(_) => return Err(bad("XML DTDs and external entities are unsupported")),+ quick_xml::events::Event::Eof => return Ok(()),+ _ => {}+ }+ }+}++fn warning(report: &mut Report, message: impl Into<String>) -> Result<()> {+ if report.entries.len() >= MAX_WARNINGS {+ return Err(bad("import warning budget exceeded"));+ }+ report.warn(message);+ Ok(())+}++fn validate_text_limits(root: Node<'_, '_>) -> Result<()> {+ for n in root.descendants().filter(|n| n.is_element()) {+ let tag = n.tag_name().name();+ if tag.len() > 1024 {+ return Err(bad("XML element name exceeds the text budget"));+ }+ let limit = match tag {+ "Name" | "Title" | "MatchName" | "FilterMatchName" | "StartKeyframe" | "CurrentValue" => Some(1024),+ "ParameterID" => Some(64),+ "FrameRect" => Some(256),+ "AudioChannelLayout" => Some(8192),+ "FilePath" | "ActualMediaFilePath" | "RelativePath" => Some(32768),+ _ if tag.starts_with("MZ.PrefixKey.OpenSequenceGuidList.") => Some(256),+ _ => None,+ };+ if limit.is_some_and(|limit| n.text().is_some_and(|s| s.len() > limit)) {+ return Err(bad(format!("{tag} exceeds the text budget")));+ }+ for a in n.attributes() {+ if matches!(a.name(), "ObjectID" | "ObjectUID" | "ObjectRef" | "ObjectURef") && a.value().len() > 256 {+ return Err(bad("object identifier exceeds the text budget"));+ }+ }+ }+ Ok(())+}++fn definition<'a>(n: Node<'a, '_>) -> Option<&'a str> {+ n.attribute("ObjectID").or_else(|| n.attribute("ObjectUID"))+}++fn at<'a, 'i>(mut n: Node<'a, 'i>, path: &[&str]) -> Option<Node<'a, 'i>> {+ for name in path {+ n = child(n, name)?;+ }+ Some(n)+}++fn ordered<'a, 'i>(parent: Option<Node<'a, 'i>>) -> Result<Vec<Node<'a, 'i>>> {+ let mut entries = Vec::new();+ let mut indices = HashSet::new();+ for n in parent.into_iter().flat_map(elements) {+ let index = n.attribute("Index").ok_or_else(|| bad("list entry lacks Index"))?.parse::<u32>().map_err(|_| bad("invalid list Index"))?;+ if !indices.insert(index) {+ return Err(bad("duplicate list Index"));+ }+ entries.push((index, n));+ }+ entries.sort_by_key(|(i, _)| *i);+ Ok(entries.into_iter().map(|(_, n)| n).collect())+}++struct Graph<'a, 'i> {+ ids: HashMap<&'a str, Node<'a, 'i>>,+ uids: HashMap<&'a str, Node<'a, 'i>>,+ objects_left: Cell<usize>,+ references_left: Cell<usize>,+ text_left: Cell<usize>,+}++impl<'a, 'i> Graph<'a, 'i> {+ fn new(root: Node<'a, 'i>) -> Result<Self> {+ let mut g = Self {+ ids: HashMap::new(),+ uids: HashMap::new(),+ objects_left: Cell::new(MAX_MODEL_OBJECTS),+ references_left: Cell::new(MAX_REFERENCES),+ text_left: Cell::new(MAX_IMPORTED_TEXT),+ };+ for n in elements(root) {+ for (attribute, map) in [("ObjectID", &mut g.ids), ("ObjectUID", &mut g.uids)] {+ if let Some(id) = n.attribute(attribute)+ && (id.is_empty() || map.insert(id, n).is_some())+ {+ return Err(bad(format!("duplicate or empty {attribute}")));+ }+ }+ }+ Ok(g)+ }++ fn resolve(&self, n: Node<'a, 'i>) -> Result<Node<'a, 'i>> {+ consume(&self.references_left, 1, "object-reference work")?;+ let found = if let Some(id) = n.attribute("ObjectRef") {+ self.ids.get(id)+ } else if let Some(id) = n.attribute("ObjectURef") {+ self.uids.get(id)+ } else {+ return Err(bad(format!("{} has no object reference", n.tag_name().name())));+ };+ found.copied().ok_or_else(|| bad(format!("dangling {} reference", n.tag_name().name())))+ }++ fn claim_object(&self) -> Result<()> {+ consume(&self.objects_left, 1, "imported object")+ }+ fn claim_text(&self, bytes: usize) -> Result<()> {+ consume(&self.text_left, bytes, "imported text")+ }++ fn optional_at(&self, n: Node<'a, 'i>, path: &[&str]) -> Result<Option<Node<'a, 'i>>> {+ at(n, path).map(|r| self.resolve(r)).transpose()+ }++ fn required_at(&self, n: Node<'a, 'i>, path: &[&str]) -> Result<Node<'a, 'i>> {+ self.optional_at(n, path)?.ok_or_else(|| bad(format!("{} lacks {}", n.tag_name().name(), path.join("/"))))+ }++ fn groups(&self, sequence: Node<'a, 'i>) -> Result<Vec<Node<'a, 'i>>> {+ ordered(child(sequence, "TrackGroups"))?.into_iter().map(|r| self.required_at(r, &["Second"])).collect()+ }++ fn settings(&self, sequence: Node<'a, 'i>) -> Result<SequenceSettings> {+ let groups = self.groups(sequence)?;+ let video = groups.iter().find(|n| n.has_tag_name("VideoTrackGroup")).copied().ok_or_else(|| bad("sequence has no VideoTrackGroup"))?;+ let (width, height) = frame_size(video)?;+ let vg = child(video, "TrackGroup").ok_or_else(|| bad("missing video TrackGroup"))?;+ let frame_rate = rate(vg)?;+ let sample_rate =+ groups.iter().find(|n| n.has_tag_name("AudioTrackGroup")).and_then(|n| child(*n, "TrackGroup")).map(sample_rate).transpose()?.unwrap_or(48000);+ Ok(SequenceSettings { width, height, frame_rate, sample_rate, preset: "Imported Premiere sequence".into(), ..Default::default() })+ }++ fn effects(&self, base: Node<'a, 'i>, ti: &mut TrackItem, size: (u32, u32), source_size: (u32, u32), report: &mut Report) -> Result<()> {+ let Some(chain) = self.optional_at(base, &["ComponentOwner", "Components"])? else { return Ok(()) };+ for r in ordered(at(chain, &["ComponentChain", "Components"]))? {+ let effect = self.resolve(r)?;+ let name = child_text(effect, "MatchName").or_else(|| child_text(effect, "FilterMatchName")).unwrap_or("unknown effect");+ let component = child(effect, "Component").or_else(|| at(effect, &["AudioComponent", "Component"]));+ let params: Vec<_> = ordered(component.and_then(|n| child(n, "Params")))?.into_iter().map(|r| self.resolve(r)).collect::<Result<_>>()?;+ match name {+ "AE.ADBE Motion" => {+ for p in params {+ let id = child_text(p, "ParameterID").unwrap_or("");+ let value = static_value(p, report)?;+ let Some(value) = value else { continue };+ let mapping = match id {+ "1" | "6" => {+ let (x, y) = value.split_once(':').ok_or_else(|| bad("invalid Motion point"))?;+ let (w, h) = if id == "1" { size } else { source_size };+ let point = Vec2::new(finite(x)? * f64::from(w), finite(y)? * f64::from(h));+ if point.x.abs() > 1e9 || point.y.abs() > 1e9 {+ return Err(bad("Motion point exceeds supported bounds"));+ }+ Some((if id == "1" { "position" } else { "anchor" }, ParamValue::Vec2(point)))+ }+ "2" => Some(("scale", ParamValue::Float(bounded(value, 0.0, 10000.0)?))),+ "3" => Some(("scale_width", ParamValue::Float(bounded(value, 0.0, 10000.0)?))),+ "4" => Some(("uniform_scale", ParamValue::Bool(parse_bool(value)?))),+ "5" => Some(("rotation", ParamValue::Float(bounded(value, -32768.0, 32767.0)?))),+ "7" => Some(("anti_flicker", ParamValue::Float(bounded(value, 0.0, 1.0)?))),+ "8" | "9" | "10" | "11" if finite(value)? == 0.0 => None,+ _ => {+ warning(report, format!("clip {:?}: Motion parameter {} was not imported", ti.name, child_text(p, "Name").unwrap_or(id)))?;+ None+ }+ };+ if let Some((param, value)) = mapping {+ set_param(ti, "motion", param, Param::new(value));+ }+ }+ }+ "Internal Volume Stereo" | "Internal Volume Mono" | "Internal Channel Volume Stereo" => {+ let channel = name == "Internal Channel Volume Stereo";+ for p in params {+ match child_text(p, "Name").unwrap_or("") {+ param @ ("Level" | "Left" | "Right") => {+ if let Some(v) = static_value(p, report)? {+ let linear = bounded(v, 0.0, 1e6)?;+ let db = if linear == 0.0 { -287.5 } else { (20.0 * linear.log10()).max(-287.5) };+ let effect = if channel { "channel_volume" } else { "volume" };+ let param = match param {+ "Left" => "left",+ "Right" => "right",+ _ => "level",+ };+ set_param(ti, effect, param, Param::new(ParamValue::Float(db)));+ }+ }+ "Bypass" => {+ if let Some(v) = static_value(p, report)? {+ set_param(ti, if channel { "channel_volume" } else { "volume" }, "bypass", Param::new(ParamValue::Bool(parse_bool(v)?)));+ }+ }+ "Mute" => {+ if let Some(v) = static_value(p, report)?+ && parse_bool(v)?+ {+ ti.enabled = false;+ }+ }+ // Stereo channel-volume records reserve unnamed slots for channels+ // outside their declared stereo layout. They have no active channel.+ "" if channel => {}+ _ => warning(report, format!("clip {:?}: unsupported Volume parameter", ti.name))?,+ }+ }+ }+ _ => warning(report, format!("clip {:?}: effect {name:?} was not imported", ti.name))?,+ }+ if component.is_some_and(|n| child(n, "IsBypassed").is_some()) {+ warning(report, format!("clip {:?}: effect bypass state needs checking", ti.name))?;+ }+ }+ Ok(())+ }++ fn transition(&self, node: Node<'a, 'i>, track: &mut Track, builder: &mut Builder, report: &mut Report) -> Result<()> {+ self.claim_object()?;+ let base = child(node, "TransitionTrackItem").ok_or_else(|| bad("unsupported transition item"))?;+ let timing = child(base, "TrackItem").ok_or_else(|| bad("transition lacks timing"))?;+ let start = ticks(timing, "Start", Some(Tick::ZERO))?;+ let end = ticks(timing, "End", None)?;+ if end <= start {+ return Err(bad("transition End must be after Start"));+ }+ // Premiere stores the cut's offset within the transition in ticks, not a fraction.+ let alignment = ticks(base, "Alignment", Some((end - start).mul_ratio(1, 2)))?;+ if alignment > end - start {+ return Err(bad("transition Alignment exceeds its duration"));+ }+ let cut = start + alignment;+ let from = if boolean(base, "HasOutgoingClip", true)? {+ track.items.iter().filter(|c| c.start < cut && c.end() >= cut).max_by_key(|c| c.start).map(|c| c.id)+ } else {+ None+ };+ let to = if boolean(base, "HasIncomingClip", true)? {+ track.items.iter().filter(|c| c.start <= cut && c.end() > cut && Some(c.id) != from).min_by_key(|c| c.start).map(|c| c.id)+ } else {+ None+ };+ if from.is_none() && to.is_none() {+ return Err(bad("transition has no adjacent clip"));+ }+ let name = child_text(base, "DisplayName").unwrap_or("Unknown transition");+ let match_name = child_text(base, "MatchName").unwrap_or(name);+ let audio = track.kind == TrackKind::Audio;+ let standard = matches!(match_name, "AE.ADBE Cross Dissolve" | "AE.ADBE Dip To Black" | "AE.ADBE Dip To White" | "Constant Power" | "Constant Gain");+ if !standard {+ warning(+ report,+ format!(+ "transition {match_name:?} ({name}) substituted with {}; third-party appearance and settings are not preserved",+ if audio { "Constant Power" } else { "Cross Dissolve" }+ ),+ )?;+ }+ let effect = transition_effect(+ if standard {+ name+ } else if audio {+ "constant power"+ } else {+ "cross dissolve"+ },+ audio,+ report,+ );+ if report.entries.len() > MAX_WARNINGS {+ return Err(bad("import warning budget exceeded"));+ }+ track.transitions.push(Transition {+ id: TransitionId(builder.alloc()),+ effect,+ start,+ duration: end - start,+ from,+ to,+ align: if alignment == Tick::ZERO {+ TransitionAlign::StartAtCut+ } else if alignment == end - start {+ TransitionAlign::EndAtCut+ } else {+ TransitionAlign::CenterAtCut+ },+ reverse: false,+ });+ Ok(())+ }+}++fn consume(left: &Cell<usize>, count: usize, what: &str) -> Result<()> {+ let next = left.get().checked_sub(count).ok_or_else(|| bad(format!("{what} budget exceeded")))?;+ left.set(next);+ Ok(())+}++fn ticks(n: Node<'_, '_>, field: &str, default: Option<Tick>) -> Result<Tick> {+ let Some(s) = child_text(n, field) else { return default.ok_or_else(|| bad(format!("missing {field}"))) };+ let t = s.parse::<i64>().map_err(|_| bad(format!("invalid integer ticks in {field}")))?;+ if !(0..=Tick::MAX.0).contains(&t) {+ return Err(bad(format!("{field} is outside supported time bounds")));+ }+ Ok(Tick(t))+}++fn frame_size(n: Node<'_, '_>) -> Result<(u32, u32)> {+ let s = child_text(n, "FrameRect").ok_or_else(|| bad("missing FrameRect"))?;+ let rect: Vec<i64> = s.split(',').map(|v| v.trim().parse::<i64>().map_err(|_| bad("invalid FrameRect"))).collect::<Result<_>>()?;+ let [left, top, right, bottom] = rect.as_slice() else { return Err(bad("FrameRect must have four coordinates")) };+ let width = right.checked_sub(*left).and_then(|v| u32::try_from(v).ok()).ok_or_else(|| bad("invalid frame width"))?;+ let height = bottom.checked_sub(*top).and_then(|v| u32::try_from(v).ok()).ok_or_else(|| bad("invalid frame height"))?;+ filmcraft_project::validate_frame_size(width, height).map_err(bad)?;+ Ok((width, height))+}++fn rate(n: Node<'_, '_>) -> Result<FrameRate> {+ let duration = ticks(n, "FrameRate", None)?.0;+ if !(TICKS_PER_SECOND / 1000..=TICKS_PER_SECOND).contains(&duration) {+ return Err(bad("frame duration must represent 1..1000 fps"));+ }+ Ok(FrameRate::new(TICKS_PER_SECOND, duration))+}++fn sample_rate(n: Node<'_, '_>) -> Result<u32> {+ let duration = ticks(n, "FrameRate", None)?.0;+ if duration == 0 || TICKS_PER_SECOND % duration != 0 {+ return Err(bad("invalid audio sample duration"));+ }+ let rate = TICKS_PER_SECOND / duration;+ if !(8000..=384000).contains(&rate) {+ return Err(bad("audio sample rate must be 8000..384000 Hz"));+ }+ u32::try_from(rate).map_err(|_| bad("invalid audio sample rate"))+}++fn finite(s: &str) -> Result<f64> {+ let f = s.parse::<f64>().map_err(|_| bad("invalid numeric parameter"))?;+ if !f.is_finite() {+ return Err(bad("numeric parameter must be finite"));+ }+ Ok(f)+}+fn bounded(s: &str, min: f64, max: f64) -> Result<f64> {+ let f = finite(s)?;+ if f < min || f > max {+ return Err(bad("effect parameter exceeds supported bounds"));+ }+ Ok(f)+}+fn number(n: Node<'_, '_>, field: &str, default: f64) -> Result<f64> {+ child_text(n, field).map(finite).transpose().map(|f| f.unwrap_or(default))+}+fn parse_bool(s: &str) -> Result<bool> {+ match s {+ "true" | "1" => Ok(true),+ "false" | "0" => Ok(false),+ _ => Err(bad("invalid boolean parameter")),+ }+}+fn boolean(n: Node<'_, '_>, field: &str, default: bool) -> Result<bool> {+ child_text(n, field).map(parse_bool).transpose().map(|v| v.unwrap_or(default))+}+fn static_value<'a>(n: Node<'a, '_>, report: &mut Report) -> Result<Option<&'a str>> {+ if child(n, "Keyframes").is_some() || child_text(n, "IsTimeVarying") == Some("true") {+ warning(+ report,+ format!("animated parameter {:?} imported at its initial value; keyframes are not transferred", child_text(n, "Name").unwrap_or("unnamed")),+ )?;+ }+ if let Some(k) = child_text(n, "StartKeyframe") {+ let v = k.split(',').nth(1).ok_or_else(|| bad("invalid StartKeyframe"))?;+ return Ok(Some(v));+ }+ Ok(child_text(n, "CurrentValue"))+}++fn reject_sequence_cycles(builder: &Builder) -> Result<()> {+ let mut degree: HashMap<ItemId, usize> = builder.top.iter().map(|&id| (id, 0)).collect();+ let mut parents: HashMap<ItemId, Vec<ItemId>> = HashMap::new();+ let mut depths: HashMap<ItemId, usize> = builder.top.iter().map(|&id| (id, 1)).collect();+ for &id in &builder.top {+ if let Some(seq) = builder.p.sequence(id) {+ for ti in seq.all_tracks().flat_map(|t| &t.items) {+ if builder.p.sequence(ti.item).is_some() {+ let d = degree.get_mut(&id).ok_or_else(|| bad("missing sequence"))?;+ *d = d.saturating_add(1);+ parents.entry(ti.item).or_default().push(id);+ }+ }+ }+ }+ let mut queue: VecDeque<_> = degree.iter().filter(|(_, d)| **d == 0).map(|(&id, _)| id).collect();+ let mut visited = 0usize;+ while let Some(id) = queue.pop_front() {+ visited = visited.saturating_add(1);+ for p in parents.get(&id).into_iter().flatten() {+ let next_depth = depths.get(&id).copied().unwrap_or(1).saturating_add(1);+ if next_depth > 64 {+ return Err(bad("nested sequences exceed 64 levels"));+ }+ let parent_depth = depths.entry(*p).or_insert(1);+ *parent_depth = (*parent_depth).max(next_depth);+ let d = degree.get_mut(p).ok_or_else(|| bad("missing parent sequence"))?;+ *d = d.saturating_sub(1);+ if *d == 0 {+ queue.push_back(*p);+ }+ }+ }+ if visited != degree.len() {+ return Err(bad("cyclic nested sequences are unsupported"));+ }+ Ok(())+}@@ -8,5 +8,6 @@ mod media_marker_export; mod nested_sequence_markers; mod otio; mod point_markers;+mod prproj; mod support; mod uncarried_clips;new file mode 100644@@ -0,0 +1,266 @@+//! Synthetic original project structures: no Adobe media, presets or bundled assets.++use std::io::Write;++use filmcraft_interchange::{Format, ImportOptions, detect, import_with, prproj};+use filmcraft_project::MediaRef;+use filmcraft_time::{FrameRate, Tick};++fn fixture() -> String {+ r#"<PremiereData Version="3">+<Sequence ObjectUID="seq"><Name>Test sequence</Name><TrackGroups><TrackGroup Index="0"><Second ObjectRef="vgroup"/></TrackGroup><TrackGroup Index="1"><Second ObjectRef="agroup"/></TrackGroup></TrackGroups></Sequence>+<VideoTrackGroup ObjectID="vgroup"><FrameRect>0,0,1920,1080</FrameRect><TrackGroup><FrameRate>8475667200</FrameRate><Tracks><Track Index="0" ObjectURef="vtrack"/></Tracks></TrackGroup></VideoTrackGroup>+<AudioTrackGroup ObjectID="agroup"><TrackGroup><FrameRate>5292000</FrameRate><Tracks><Track Index="0" ObjectURef="atrack"/></Tracks></TrackGroup></AudioTrackGroup>+<VideoClipTrack ObjectUID="vtrack"><ClipTrack><Track/><ClipItems><TrackItems><TrackItem Index="0" ObjectRef="vi"/></TrackItems></ClipItems><TransitionItems><TrackItems><TrackItem Index="0" ObjectRef="tr"/></TrackItems></TransitionItems></ClipTrack></VideoClipTrack>+<AudioClipTrack ObjectUID="atrack"><ClipTrack><Track/><ClipItems><TrackItems><TrackItem Index="0" ObjectRef="ai"/></TrackItems></ClipItems></ClipTrack></AudioClipTrack>+<VideoClipTrackItem ObjectID="vi"><ClipTrackItem><TrackItem><Start>254016000000</Start><End>762048000000</End></TrackItem><SubClip ObjectRef="vs"/><ComponentOwner><Components ObjectRef="chain"/></ComponentOwner></ClipTrackItem></VideoClipTrackItem>+<AudioClipTrackItem ObjectID="ai"><ClipTrackItem><TrackItem><Start>254016000000</Start><End>762048000000</End></TrackItem><SubClip ObjectRef="as"/></ClipTrackItem></AudioClipTrackItem>+<SubClip ObjectID="vs"><Name>Video cut</Name><Clip ObjectRef="vc"/></SubClip><SubClip ObjectID="as"><Name>Audio cut</Name><Clip ObjectRef="ac"/></SubClip>+<VideoClip ObjectID="vc"><Clip><Source ObjectRef="vsource"/><InPoint>508032000000</InPoint><PlaybackSpeed>1.5</PlaybackSpeed></Clip></VideoClip>+<AudioClip ObjectID="ac"><Clip><Source ObjectRef="asource"/><InPoint>508032000000</InPoint></Clip></AudioClip>+<VideoMediaSource ObjectID="vsource"><MediaSource><Media ObjectURef="media"/></MediaSource></VideoMediaSource><AudioMediaSource ObjectID="asource"><MediaSource><Media ObjectURef="media"/></MediaSource></AudioMediaSource>+<Media ObjectUID="media"><FilePath>footage/日本語.mov</FilePath><Title>Footage</Title><VideoStream ObjectRef="video"/><AudioStream ObjectRef="audio"/></Media>+<VideoStream ObjectID="video"><FrameRate>10594584000</FrameRate><FrameRect>0,0,1280,720</FrameRect><Duration>2540160000000</Duration></VideoStream>+<AudioStream ObjectID="audio"><FrameRate>5292000</FrameRate><Duration>2540160000000</Duration><AudioChannelLayout>[{},{}]</AudioChannelLayout></AudioStream>+<Link ObjectID="link"><TrackItemGroup><TrackItems><TrackItem Index="0" ObjectRef="vi"/><TrackItem Index="1" ObjectRef="ai"/></TrackItems></TrackItemGroup></Link>+<VideoComponentChain ObjectID="chain"><ComponentChain><Components><Component Index="0" ObjectRef="motion"/></Components></ComponentChain></VideoComponentChain>+<VideoFilterComponent ObjectID="motion"><MatchName>AE.ADBE Motion</MatchName><Component><Params><Param Index="0" ObjectRef="scale"/><Param Index="1" ObjectRef="position"/></Params></Component></VideoFilterComponent>+<VideoComponentParam ObjectID="scale"><ParameterID>2</ParameterID><Name>Scale</Name><StartKeyframe>-91445760000000000,175.,0</StartKeyframe></VideoComponentParam>+<PointComponentParam ObjectID="position"><ParameterID>1</ParameterID><StartKeyframe>-91445760000000000,0.25:0.75,0</StartKeyframe></PointComponentParam>+<VideoTransitionTrackItem ObjectID="tr"><TransitionTrackItem><TrackItem><Start>254016000000</Start><End>381024000000</End></TrackItem><Alignment>0</Alignment><HasOutgoingClip>false</HasOutgoingClip><HasIncomingClip>true</HasIncomingClip><MatchName>third.party.dissolve</MatchName><DisplayName>Fancy dissolve</DisplayName></TransitionTrackItem></VideoTransitionTrackItem>+</PremiereData>"#.into()+}++fn gzip(bytes: &[u8]) -> Vec<u8> {+ let mut writer = flate2::write::GzEncoder::new(Vec::new(), flate2::Compression::fast());+ writer.write_all(bytes).unwrap();+ writer.finish().unwrap()+}++fn saved_tabs(properties: &str) -> String {+ fixture().replace(+ "<PremiereData Version=\"3\">",+ &format!(r#"<PremiereData Version="3"><Project ObjectID="project"><Node><Properties>{properties}</Properties></Node></Project><Sequence ObjectUID="trailer"><Name>Trailer</Name><TrackGroups><TrackGroup Index="0"><Second ObjectRef="emptygroup"/></TrackGroup></TrackGroups></Sequence><VideoTrackGroup ObjectID="emptygroup"><FrameRect>0,0,1920,1080</FrameRect><TrackGroup><FrameRate>8475667200</FrameRate><Tracks/></TrackGroup></VideoTrackGroup>"#),+ )+}++#[test]+fn saved_open_timeline_is_separate_from_sequence_storage_order() {+ let xml = saved_tabs("<MZ.PrefixKey.OpenSequenceGuidList.1>seq</MZ.PrefixKey.OpenSequenceGuidList.1>");+ let (imported, view, _) = prproj::import_with_view(&gzip(xml.as_bytes()), &Default::default()).unwrap();+ let trailer = imported.sequences[0];+ let edit = imported.sequences[1];+ assert_eq!(imported.project.item(trailer).unwrap().name, "Trailer");+ assert_eq!(view.active_sequence, Some(edit));+ assert_eq!(view.open_sequences, [edit]);+ assert_eq!(imported.project.sequence(trailer).unwrap().all_tracks().map(|t| t.items.len()).sum::<usize>(), 0);+ assert_eq!(imported.project.sequence(edit).unwrap().all_tracks().map(|t| t.items.len()).sum::<usize>(), 2);+}++#[test]+fn saved_tabs_are_ordered_deduplicated_and_ignore_stale_non_sequence_uids() {+ let props = r#"<MZ.PrefixKey.OpenSequenceGuidList.8>trailer</MZ.PrefixKey.OpenSequenceGuidList.8>+<MZ.PrefixKey.OpenSequenceGuidList.2>seq</MZ.PrefixKey.OpenSequenceGuidList.2>+<MZ.PrefixKey.OpenSequenceGuidList.3>seq</MZ.PrefixKey.OpenSequenceGuidList.3>+<MZ.PrefixKey.OpenSequenceGuidList.0>gone</MZ.PrefixKey.OpenSequenceGuidList.0>+<MZ.PrefixKey.OpenSequenceGuidList.1>media</MZ.PrefixKey.OpenSequenceGuidList.1>+<MZ.PrefixKey.OpenSequenceGuidList.999999999999999999999999>trailer</MZ.PrefixKey.OpenSequenceGuidList.999999999999999999999999>"#;+ let (imported, view, _) = prproj::import_with_view(saved_tabs(props).as_bytes(), &Default::default()).unwrap();+ assert_eq!(view.open_sequences, [imported.sequences[1], imported.sequences[0]]);+ assert_eq!(view.active_sequence, Some(imported.sequences[1]));+ for properties in ["", "<MZ.PrefixKey.OpenSequenceGuidList.1>gone</MZ.PrefixKey.OpenSequenceGuidList.1>"] {+ let (p, view, _) = prproj::import_with_view(saved_tabs(properties).as_bytes(), &Default::default()).unwrap();+ assert_eq!(view.open_sequences, [p.sequences[0]]);+ assert_eq!(view.active_sequence, Some(p.sequences[0]));+ }+}++#[test]+fn hostile_saved_tab_metadata_is_bounded() {+ let property = "<MZ.PrefixKey.OpenSequenceGuidList.1>seq</MZ.PrefixKey.OpenSequenceGuidList.1>";+ let too_many: String =+ (0..=4096).map(|index| format!("<MZ.PrefixKey.OpenSequenceGuidList.{index}>seq</MZ.PrefixKey.OpenSequenceGuidList.{index}>")).collect();+ for properties in [too_many, property.replace("seq", &"x".repeat(257)), property.repeat(2)] {+ assert!(prproj::import_with_view(saved_tabs(&properties).as_bytes(), &Default::default()).is_err());+ }+}++#[test]+fn compressed_and_plain_import_preserve_the_edit() {+ let xml = fixture();+ let compressed = gzip(xml.as_bytes());+ let opts = ImportOptions { base_dir: Some("/work".into()), name: Some("Conversion".into()), ..Default::default() };+ let (plain, report) = import_with(xml.as_bytes(), Format::Premiere, &opts).unwrap();+ let (zipped, _) = prproj::import(&compressed, &opts).unwrap();+ assert_eq!(plain, zipped);+ assert_eq!(plain.project.name, "Conversion");+ let seq = plain.project.sequence(plain.sequences[0]).unwrap();+ assert_eq!(seq.settings.frame_rate, FrameRate::FPS_29_97);+ assert_eq!(seq.settings.sample_rate, 48000);+ assert_eq!((seq.settings.width, seq.settings.height), (1920, 1080));+ let video = &seq.video_tracks[0].items[0];+ let audio = &seq.audio_tracks[0].items[0];+ assert_eq!((video.start, video.duration, video.source_in), (Tick(254016000000), Tick(508032000000), Tick(508032000000)));+ assert_eq!(video.speed, 1.5);+ assert_eq!(video.item, audio.item);+ assert!(video.link.is_some());+ assert_eq!(video.link, audio.link);+ assert_eq!(video.effect("motion").unwrap().param("scale").unwrap().value.as_f64(), Some(175.0));+ let pos = video.effect("motion").unwrap().param("position").unwrap().value.as_vec2().unwrap();+ assert_eq!((pos.x, pos.y), (480.0, 810.0));+ let transition = &seq.video_tracks[0].transitions[0];+ assert_eq!(transition.from, None);+ assert_eq!(transition.to, Some(video.id));+ assert_eq!(transition.effect.effect, "cross_dissolve");+ assert!(report.mentions("third.party.dissolve"));+ assert!(report.mentions("not transferred"));+ let m = plain.project.item(video.item).unwrap().as_media().unwrap();+ assert_eq!(m.media, MediaRef::File { path: "/work/footage/日本語.mov".into() });+ assert_eq!(m.info.video.as_ref().unwrap().frame_rate, FrameRate::FPS_23_976);+}++#[test]+fn detects_import_only_format_and_refuses_export() {+ assert_eq!(Format::from_extension(".PRPROJ"), Some(Format::Premiere));+ assert_eq!(detect(b"corrupt", Some("prproj")), Some(Format::Premiere));+ assert_eq!(detect(fixture().as_bytes(), None), Some(Format::Premiere));+ assert!(!Format::ALL.contains(&Format::Premiere));+ let (p, _) = prproj::import(fixture().as_bytes(), &ImportOptions::default()).unwrap();+ let err = filmcraft_interchange::export(&p.project, p.sequences[0], Format::Premiere, &Default::default()).unwrap_err();+ assert!(err.to_string().contains("unsupported"));+}++#[test]+fn static_stereo_volume_and_channel_volume_are_preserved() {+ let xml = fixture().replace("<SubClip ObjectRef=\"as\"/>", "<SubClip ObjectRef=\"as\"/><ComponentOwner><Components ObjectRef=\"audiochain\"/></ComponentOwner>")+ .replace("</PremiereData>", r#"+<AudioComponentChain ObjectID="audiochain"><ComponentChain><Components><Component Index="0" ObjectRef="volume"/><Component Index="1" ObjectRef="channels"/></Components></ComponentChain></AudioComponentChain>+<AudioFilterComponent ObjectID="volume"><FilterMatchName>Internal Volume Stereo</FilterMatchName><AudioComponent><Component><Params><Param Index="0" ObjectRef="level"/></Params></Component></AudioComponent></AudioFilterComponent>+<AudioFilterComponent ObjectID="channels"><FilterMatchName>Internal Channel Volume Stereo</FilterMatchName><AudioComponent><Component><Params><Param Index="0" ObjectRef="left"/><Param Index="1" ObjectRef="right"/></Params></Component></AudioComponent></AudioFilterComponent>+<AudioComponentParam ObjectID="level"><Name>Level</Name><StartKeyframe>-91445760000000000,0.5,0</StartKeyframe></AudioComponentParam>+<AudioComponentParam ObjectID="left"><Name>Left</Name><CurrentValue>0.1778279410038923</CurrentValue></AudioComponentParam>+<AudioComponentParam ObjectID="right"><Name>Right</Name><CurrentValue>0</CurrentValue></AudioComponentParam>+</PremiereData>"#);+ let (p, report) = prproj::import(xml.as_bytes(), &Default::default()).unwrap();+ let audio = &p.project.sequence(p.sequences[0]).unwrap().audio_tracks[0].items[0];+ let level = audio.effect("volume").unwrap().f64_at("level", Tick::ZERO);+ assert!((level + 6.020599913).abs() < 1e-8);+ assert!((audio.effect("channel_volume").unwrap().f64_at("left", Tick::ZERO) + 15.0).abs() < 1e-8);+ assert_eq!(audio.effect("channel_volume").unwrap().f64_at("right", Tick::ZERO), -287.5);+ assert!(!report.mentions("was not imported"));+}++#[test]+fn end_aligned_transition_uses_integer_tick_offset() {+ let xml = fixture()+ .replace("<Start>254016000000</Start><End>381024000000</End>", "<Start>635040000000</Start><End>762048000000</End>")+ .replace("<Alignment>0</Alignment>", "<Alignment>127008000000</Alignment>")+ .replace(+ "<HasOutgoingClip>false</HasOutgoingClip><HasIncomingClip>true</HasIncomingClip>",+ "<HasOutgoingClip>true</HasOutgoingClip><HasIncomingClip>false</HasIncomingClip>",+ );+ let (p, _) = prproj::import(xml.as_bytes(), &Default::default()).unwrap();+ let track = &p.project.sequence(p.sequences[0]).unwrap().video_tracks[0];+ let transition = &track.transitions[0];+ assert_eq!(transition.start, Tick(635040000000));+ assert_eq!(transition.duration, Tick(127008000000));+ assert_eq!(transition.from, Some(track.items[0].id));+ assert_eq!(transition.to, None);+ assert_eq!(transition.align, filmcraft_project::TransitionAlign::EndAtCut);+}++#[test]+fn rejects_hostile_numbers_missing_references_and_duplicates() {+ for (from, to) in [+ ("<PlaybackSpeed>1.5</PlaybackSpeed>", "<PlaybackSpeed>NaN</PlaybackSpeed>"),+ ("<PlaybackSpeed>1.5</PlaybackSpeed>", "<PlaybackSpeed>-1</PlaybackSpeed>"),+ ("<PlaybackSpeed>1.5</PlaybackSpeed>", "<PlaybackSpeed>0</PlaybackSpeed>"),+ ("<FrameRate>8475667200</FrameRate>", "<FrameRate>0</FrameRate>"),+ ("<FrameRate>5292000</FrameRate>", "<FrameRate>0</FrameRate>"),+ ("<FrameRect>0,0,1920,1080</FrameRect>", "<FrameRect>0,0,4294967295,4294967295</FrameRect>"),+ ("<FrameRect>0,0,1920,1080</FrameRect>", "<FrameRect>-9223372036854775808,0,9223372036854775807,1080</FrameRect>"),+ ("<Start>254016000000</Start>", "<Start>-1</Start>"),+ ("<End>762048000000</End>", "<End>0</End>"),+ ("<End>762048000000</End>", "<End>9223372036854775807</End>"),+ ("ObjectRef=\"vsource\"", "ObjectRef=\"missing\""),+ ("ObjectID=\"asource\"", "ObjectID=\"vsource\""),+ ("<Alignment>0</Alignment>", "<Alignment>NaN</Alignment>"),+ ("<Alignment>0</Alignment>", "<Alignment>999999999999</Alignment>"),+ ("175.,0", "NaN,0"),+ ("Version=\"3\"", "Version=\"4\""),+ ] {+ let xml = fixture().replace(from, to);+ let result = std::panic::catch_unwind(|| prproj::import(xml.as_bytes(), &ImportOptions::default()));+ assert!(result.is_ok(), "panicked for {to}");+ assert!(result.unwrap().is_err(), "accepted {to}");+ }+}++#[test]+fn rejects_sequence_cycles_and_deep_xml() {+ let xml = fixture().replace(+ "<VideoMediaSource ObjectID=\"vsource\"><MediaSource><Media ObjectURef=\"media\"/></MediaSource></VideoMediaSource>",+ "<VideoSequenceSource ObjectID=\"vsource\"><SequenceSource><Sequence ObjectURef=\"seq\"/></SequenceSource></VideoSequenceSource>",+ );+ assert!(prproj::import(xml.as_bytes(), &Default::default()).unwrap_err().to_string().contains("cyclic"));+ let xml = format!("<PremiereData Version=\"3\">{}{}</PremiereData>", "<x>".repeat(5000), "</x>".repeat(5000));+ let result = std::panic::catch_unwind(|| prproj::import(xml.as_bytes(), &Default::default()));+ assert!(result.is_ok());+ assert!(result.unwrap().unwrap_err().to_string().contains("nesting"));+ let xml = "<!DOCTYPE PremiereData [<!ENTITY x 'test'>]><PremiereData Version=\"3\">&x;</PremiereData>";+ assert!(prproj::import(xml.as_bytes(), &Default::default()).is_err());+}++#[test]+fn a_nested_sequence_is_retained_but_is_not_a_top_level_timeline() {+ let xml = fixture().replace("<VideoMediaSource ObjectID=\"vsource\"><MediaSource><Media ObjectURef=\"media\"/></MediaSource></VideoMediaSource>",+ "<VideoSequenceSource ObjectID=\"vsource\"><SequenceSource><Sequence ObjectURef=\"nest\"/></SequenceSource></VideoSequenceSource>")+ .replace("</PremiereData>", r#"<Sequence ObjectUID="nest"><Name>Nested</Name><TrackGroups><TrackGroup Index="0"><Second ObjectRef="nested-group"/></TrackGroup></TrackGroups></Sequence>+<VideoTrackGroup ObjectID="nested-group"><FrameRect>0,0,1280,720</FrameRect><TrackGroup><FrameRate>10160640000</FrameRate><Tracks/></TrackGroup></VideoTrackGroup></PremiereData>"#);+ let xml = xml.replace("<ParameterID>1</ParameterID>", "<ParameterID>6</ParameterID>");+ let (p, _) = prproj::import(xml.as_bytes(), &Default::default()).unwrap();+ assert_eq!(p.sequences.len(), 1);+ assert_eq!(p.project.sequences().count(), 2);+ let parent = p.project.sequence(p.sequences[0]).unwrap();+ assert_eq!(p.project.item(parent.video_tracks[0].items[0].item).unwrap().name, "Nested");+ let anchor = parent.video_tracks[0].items[0].effect("motion").unwrap().param("anchor").unwrap().value.as_vec2().unwrap();+ assert_eq!((anchor.x, anchor.y), (320.0, 540.0));+}++#[test]+fn truncation_bit_flips_and_bad_gzip_never_panic() {+ let xml = fixture();+ for bytes in [xml.as_bytes().to_vec(), gzip(xml.as_bytes())] {+ for end in 0..bytes.len() {+ let result = std::panic::catch_unwind(|| prproj::import(&bytes[..end], &Default::default()));+ assert!(result.is_ok(), "truncation {end} panicked");+ assert!(result.unwrap().is_err(), "truncation {end} was accepted");+ }+ for i in 0..bytes.len() {+ let mut corrupt = bytes.clone();+ corrupt[i] ^= 0x80;+ assert!(std::panic::catch_unwind(|| prproj::import(&corrupt, &Default::default())).is_ok(), "bit flip {i} panicked");+ }+ }+}++#[test]+fn repeated_references_and_oversized_fields_have_global_budgets() {+ let references: String = (0..1000).map(|i| format!("<Component Index=\"{i}\" ObjectRef=\"motion\"/>")).collect();+ let clips: String = (0..1000).map(|i| format!("<TrackItem Index=\"{i}\" ObjectRef=\"vi\"/>")).collect();+ let xml = fixture().replace("<Component Index=\"0\" ObjectRef=\"motion\"/>", &references).replace(+ "<ClipItems><TrackItems><TrackItem Index=\"0\" ObjectRef=\"vi\"/></TrackItems></ClipItems>",+ &format!("<ClipItems><TrackItems>{clips}</TrackItems></ClipItems>"),+ );+ let result = std::panic::catch_unwind(|| prproj::import(xml.as_bytes(), &Default::default()));+ assert!(result.is_ok());+ let error = result.unwrap().unwrap_err().to_string();+ assert!(error.contains("budget"), "{error}");+ let xml = fixture().replace("<Name>Video cut</Name>", &format!("<Name>{}</Name>", "x".repeat(1025)));+ assert!(prproj::import(xml.as_bytes(), &Default::default()).unwrap_err().to_string().contains("text budget"));+ let opts = ImportOptions { base_dir: Some("x".repeat(32769)), ..Default::default() };+ assert!(prproj::import(fixture().as_bytes(), &opts).is_err());+ let media: String = (0..4100).map(|i| format!("<Media ObjectID=\"missing-{i}\"/>")).collect();+ let xml = fixture().replace("</PremiereData>", &(media + "</PremiereData>"));+ assert!(prproj::import(xml.as_bytes(), &Default::default()).unwrap_err().to_string().contains("warning budget"));+}@@ -3628,3 +3628,15 @@ No matching text. Ningún texto coincide. H.265 (HEVC) needs a hardware encoder (NVENC on NVIDIA GPUs, VideoToolbox on macOS); none was found on this machine, and FilmCraft has no software H.265 encoder yet. Choose H.264 or ProRes instead. H.265 (HEVC) necesita un codificador por hardware (NVENC en GPU NVIDIA, VideoToolbox en macOS); no se encontró ninguno en este equipo y FilmCraft aún no tiene un codificador H.265 por software. Elija H.264 o ProRes. AV1 needs a hardware encoder; none was found on this machine, and FilmCraft has no software AV1 encoder yet. AV1 necesita un codificador por hardware; no se encontró ninguno en este equipo y FilmCraft aún no tiene un codificador AV1 por software.+ Premiere Pro Project… Proyecto de Premiere Pro…+ Premiere project imported Proyecto de Premiere importado+ The sequences are ready to edit. Save as a FilmCraft project to keep the conversion. Las secuencias están listas para editar. Guarda un proyecto de FilmCraft para conservar la conversión.+ Your Premiere project and source media have not been changed. Tu proyecto de Premiere y los archivos multimedia originales no se han modificado.+ {name}: {video} video clips, {audio} audio clips {name}: {video} clips de vídeo, {audio} clips de audio+ Open timeline: {sequence} Línea de tiempo abierta: {sequence}+ Additional sequences are available in the Project panel. Hay más secuencias en el panel Proyecto.+ Double-click a sequence in the Project panel to open another timeline. Haz doble clic en una secuencia del panel Proyecto para abrir otra línea de tiempo.+ Review the conversion Revisa la conversión+ {count} media files could not be linked. Use Link Media to locate them. No se pudieron vincular {count} archivos multimedia. Usa Vincular medios para localizarlos.+ Save As FilmCraft Project… Guardar como proyecto de FilmCraft…+ Continue Editing Continuar editando@@ -56,6 +56,7 @@ pub mod panels; pub mod perf; #[cfg(not(target_arch = "wasm32"))] pub mod play_ahead;+mod premiere_import; mod rtl; pub mod scrub; mod shuttle;@@ -1669,7 +1670,7 @@ impl FilmcraftApp { .iter() .chain(filmcraft_media::AUDIO_EXTENSIONS) .chain(filmcraft_media::STILL_EXTENSIONS)- .chain(&["srt", "vtt", "scc", "edl", "xml", "fcpxml", "otio", "aaf", "omf"])+ .chain(&["srt", "vtt", "scc", "edl", "xml", "fcpxml", "otio", "aaf", "omf", "prproj"]) .copied() .collect(); let bin = params.get("bin").cloned();@@ -1688,6 +1689,18 @@ impl FilmcraftApp { r }) }+ "file.importPremiereProject" => {+ let bin = params.get("bin").cloned();+ self.pick(FileDialog::open_file("Premiere Pro project", &["prproj"]), move |app, paths| {+ let Some(path) = paths.into_iter().next() else { return Ok(Value::Null) };+ let bin = bin.unwrap_or_else(|| json!(app.import_bin().0));+ let result = app.session.execute("file.importPremiereProject", json!({"path": path, "bin": bin})).map_err(|e| e.to_string())?;+ if let Some(errors) = result.get("errors").and_then(Value::as_array).filter(|errors| !errors.is_empty()) {+ app.ui.status = errors.iter().filter_map(Value::as_str).collect::<Vec<_>>().join("; ");+ }+ Ok(result)+ })+ } // File ▸ Import with Image Sequence: choose the first numbered still "file.importImageSequence" => self.pick(FileDialog::open_files(filmcraft_media::STILL_EXTENSIONS), |app, paths| { let Some(path) = paths.into_iter().next() else { return Ok(Value::Null) };@@ -2212,6 +2225,7 @@ impl FilmcraftApp { // the Timeline has had its chance to place a file drop this frame; anything left stays a bin import self.pending_timeline_drop = None; panels::dialogs::show(self, &ctx);+ premiere_import::show(self, &ctx); updates_ui::show(self, &ctx); if !self.ui.show_status_bar { return;@@ -2559,6 +2573,7 @@ impl FilmcraftApp { let _ = self.apply_dock(craft_ui::docking::Action::Activate { panel: PanelKind::Source }); } filmcraft_engine::Event::RevealInProject(item) => self.reveal_in_project(item),+ filmcraft_engine::Event::ImportReport(report) => self.ui.import_report = Some(report), filmcraft_engine::Event::Toast { message, .. } => self.toast = Some((message, now)), filmcraft_engine::Event::ProjectChanged { .. } => {} }@@ -593,6 +593,7 @@ pub fn invoke(app: &mut FilmcraftApp, ctx: &egui::Context, id: &str, mut params: } // File dialogs for commands that need a path. if (id == "file.import" && params.get("paths").is_none() && params.get("path").is_none())+ || (id == "file.importPremiereProject" && params.get("path").is_none()) || (id == "file.importImageSequence" && params.get("path").is_none()) || (id == "file.saveAs" && params.get("path").is_none()) || (id == "file.saveCopy" && params.get("path").is_none())new file mode 100644@@ -0,0 +1,83 @@+//! Show the conversion result from the engine, regardless of whether import began in a menu,+//! File > Import, drag/drop, the CLI bridge or the control channel.++use serde_json::{Value, json};++use crate::FilmcraftApp;++pub fn show(app: &mut FilmcraftApp, ctx: &egui::Context) {+ let Some(report) = app.ui.import_report.take() else { return };+ if report.get("documentEpoch").and_then(Value::as_u64) != Some(app.session.document_epoch) {+ return;+ }+ let mut open = true;+ let mut close = false;+ let mut save = false;+ let shown = egui::Window::new(tl!("Premiere project imported"))+ .id(egui::Id::unique("premiere-import-report"))+ .open(&mut open)+ .collapsible(false)+ .default_width(600.0)+ .default_pos(crate::widgets::dialog_center(ctx))+ .pivot(egui::Align2::CENTER_CENTER)+ .show(ctx, |ui| {+ ui.label(tl!("The sequences are ready to edit. Save as a FilmCraft project to keep the conversion."));+ ui.label(tl!("Your Premiere project and source media have not been changed."));+ ui.separator();+ if let Some(sequences) = report.get("sequenceSummaries").and_then(Value::as_array) {+ for sequence in sequences.iter().take(64) {+ let name = sequence.get("name").and_then(Value::as_str).unwrap_or("");+ let video = sequence.get("videoClips").and_then(Value::as_u64).unwrap_or(0);+ let audio = sequence.get("audioClips").and_then(Value::as_u64).unwrap_or(0);+ let line = tlf!("{name}: {video} video clips, {audio} audio clips", name, video, audio);+ if sequence.get("id") == report.get("activeSequence") {+ ui.strong(tlf!("Open timeline: {sequence}", sequence = line));+ } else {+ ui.label(line);+ }+ }+ if sequences.len() > 64 {+ ui.label(tl!("Additional sequences are available in the Project panel."));+ }+ }+ ui.label(tl!("Double-click a sequence in the Project panel to open another timeline."));+ ui.separator();+ ui.strong(tl!("Review the conversion"));+ let scrolled = egui::ScrollArea::vertical().id_salt("premiere-import-warnings").max_height(240.0).show(ui, |ui| {+ if let Some(missing) = report.get("offlineMedia").and_then(Value::as_array).filter(|v| !v.is_empty()) {+ ui.label(tlf!("{count} media files could not be linked. Use Link Media to locate them.", count = missing.len()));+ for path in missing.iter().filter_map(Value::as_str).take(40) {+ ui.label(path);+ }+ }+ if let Some(warnings) = report.get("report").and_then(Value::as_array) {+ for warning in warnings.iter().filter_map(Value::as_str) {+ ui.label(warning);+ ui.add_space(4.0);+ }+ }+ });+ app.auto.add("importReport.warnings", scrolled.inner_rect, "Conversion warnings");+ ui.separator();+ ui.horizontal(|ui| {+ let button = ui.button(tl!("Save As FilmCraft Project…"));+ app.auto.add("importReport.saveAs", button.rect, "Save As FilmCraft Project");+ save = button.clicked();+ let button = ui.button(tl!("Continue Editing"));+ app.auto.add("importReport.close", button.rect, "Continue Editing");+ close = button.clicked();+ });+ });+ if let Some(shown) = shown {+ app.auto.add("importReport.window", shown.response.rect, "Premiere project imported");+ }+ if crate::widgets::escape_closes(ctx) {+ close = true;+ }+ if open && !close && !save {+ app.ui.import_report = Some(report);+ }+ if save {+ let _ = crate::menus::invoke(app, ctx, "file.saveAs", json!({}));+ }+}@@ -546,6 +546,9 @@ pub struct UiState { /// Link Media dialog (open when Some). #[serde(default)] pub link_media: Option<LinkMediaDraft>,+ /// Latest Premiere conversion report, including unsupported features and offline media.+ #[serde(default)]+ pub import_report: Option<serde_json::Value>, /// Create Proxies dialog (open when Some). #[serde(default)] pub create_proxies: Option<ProxyDraft>,@@ -1015,6 +1018,7 @@ impl Default for UiState { gfx_edit: None, pen_points: vec![], link_media: None,+ import_report: None, create_proxies: None, project_manager: None, make_offline: None,@@ -122,6 +122,66 @@ fn wav() -> PathBuf { path } +#[test]+fn premiere_picker_imports_separate_cuts_shows_warnings_and_saves_a_native_project() {+ let dir = PathBuf::from(env!("CARGO_TARGET_TMPDIR")).join(format!("premiere-import-{}", std::process::id()));+ std::fs::create_dir_all(&dir).unwrap();+ let path = dir.join("edit.prproj");+ let native = dir.join("converted.fcproj");+ // Original synthetic XML: two separate cuts, an earlier unrelated trailer, saved main tab,+ // and one absent source. No real project, artwork or media fixture is shipped.+ let xml = r#"<PremiereData Version="3">+<Project ObjectID="p"><Node><Properties><MZ.PrefixKey.OpenSequenceGuidList.1>main</MZ.PrefixKey.OpenSequenceGuidList.1></Properties></Node></Project>+<Sequence ObjectUID="trailer"><Name>Trailer</Name><TrackGroups><TrackGroup Index="0"><Second ObjectRef="empty"/></TrackGroup></TrackGroups></Sequence>+<Sequence ObjectUID="main"><Name>Main edit</Name><TrackGroups><TrackGroup Index="0"><Second ObjectRef="group"/></TrackGroup></TrackGroups></Sequence>+<VideoTrackGroup ObjectID="empty"><FrameRect>0,0,1920,1080</FrameRect><TrackGroup><FrameRate>10594584000</FrameRate><Tracks/></TrackGroup></VideoTrackGroup>+<VideoTrackGroup ObjectID="group"><FrameRect>0,0,1920,1080</FrameRect><TrackGroup><FrameRate>10594584000</FrameRate><Tracks><Track Index="0" ObjectURef="track"/></Tracks></TrackGroup></VideoTrackGroup>+<VideoClipTrack ObjectUID="track"><ClipTrack><Track/><ClipItems><TrackItems><TrackItem Index="0" ObjectRef="cut1"/><TrackItem Index="1" ObjectRef="cut2"/></TrackItems></ClipItems></ClipTrack></VideoClipTrack>+<VideoClipTrackItem ObjectID="cut1"><ClipTrackItem><TrackItem><Start>0</Start><End>254016000000</End></TrackItem><SubClip ObjectRef="sub"/></ClipTrackItem></VideoClipTrackItem>+<VideoClipTrackItem ObjectID="cut2"><ClipTrackItem><TrackItem><Start>508032000000</Start><End>762048000000</End></TrackItem><SubClip ObjectRef="sub"/></ClipTrackItem></VideoClipTrackItem>+<SubClip ObjectID="sub"><Name>Cut</Name><Clip ObjectRef="clip"/></SubClip>+<VideoClip ObjectID="clip"><Clip><Source ObjectRef="source"/><InPoint>0</InPoint></Clip></VideoClip>+<VideoMediaSource ObjectID="source"><MediaSource><Media ObjectURef="media"/></MediaSource></VideoMediaSource>+<Media ObjectUID="media"><FilePath>missing-for-premiere-ui-test.mov</FilePath><VideoStream ObjectRef="stream"/></Media>+<VideoStream ObjectID="stream"><FrameRate>10594584000</FrameRate><FrameRect>0,0,1920,1080</FrameRect><Duration>2540160000000</Duration></VideoStream>+</PremiereData>"#;+ std::fs::write(&path, xml).unwrap();+ let mut d = Driver::new(true);+ let before = d.items();+ // Cancel is harmless, and the single-file filter is specific to Premiere projects.+ d.ok("ui.menu.invoke", json!({"id": "file.importPremiereProject"}));+ let (dialog, answer) = d.asked();+ assert!(matches!(dialog, FileDialog::OpenFile { exts, .. } if exts == ["prproj"]));+ answer.send(Vec::new()).unwrap();+ d.frames(4);+ assert_eq!(d.items(), before);+ assert!(d.app().ui.import_report.is_none());+ d.ok("ui.menu.invoke", json!({"id": "file.importPremiereProject"}));+ let (_, answer) = d.asked();+ answer.send(vec![path.to_string_lossy().into_owned()]).unwrap();+ d.frames(8);+ let active = d.app().session.state.active_sequence.unwrap();+ assert_eq!(d.app().session.project.item(active).unwrap().name, "Main edit");+ assert_eq!(d.app().session.project.sequence(active).unwrap().video_tracks[0].items.len(), 2);+ let report = d.app().ui.import_report.as_ref().unwrap();+ assert_eq!(report["offlineMedia"].as_array().unwrap().len(), 1);+ assert!(report["report"].as_array().unwrap().iter().any(|s| s.as_str().unwrap().contains("subset")));+ let inspect = d.ok("ui.inspect", json!({}));+ assert!(inspect["ui"]["import_report"].is_object());+ d.click("importReport.saveAs");+ let (dialog, answer) = d.asked();+ assert!(matches!(dialog, FileDialog::SaveProject { .. }));+ answer.send(vec![native.to_string_lossy().into_owned()]).unwrap();+ d.frames(4);+ let mut reopened = Session::default();+ reopened.execute("file.open", json!({"path": native})).unwrap();+ let active = reopened.state.active_sequence.unwrap();+ assert_eq!(reopened.project.item(active).unwrap().name, "Main edit");+ assert_eq!(reopened.project.sequence(active).unwrap().video_tracks[0].items.len(), 2);+ assert_eq!(std::fs::read_to_string(path).unwrap(), xml);+ assert!(d.app().ui.import_report.is_none());+}+ #[test] fn the_app_keeps_running_while_the_import_dialog_is_open_and_imports_when_it_answers() { let mut d = Driver::new(true);@@ -1,6 +1,6 @@ # File format parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** major (new file: every file type Premiere Pro 26.5.2 reads or writes, against ours) · **Target:** Adobe Premiere Pro 2026 (26.5.2) and Media Encoder 2026+> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-27 · **Change:** minor (native partial Premiere project import; readiness unchanged) · **Target:** Adobe Premiere Pro 2026 (26.5.2) and Media Encoder 2026 Containers, stills, audio files, project and interchange files, captions and presets. Codecs inside the containers are in [codec-parity.md](codec-parity.md). Our import list is@@ -13,7 +13,7 @@ only). "Tested" names the evidence. | Format | Premiere | Read | Write | Tested | % | |---|---|---|---|---|---|-| **Premiere project `.prproj`** | native | **no** | **no** | — | **0%** ([G2](gaps.md#g2-premieres-prproj-cannot-be-opened)) |+| **Premiere project `.prproj`** | native | **partial**, native timeline import and conversion report | **no** | synthetic/mutation, engine undo/save, native picker/report/reopen | partial; not re-scored ([G2](gaps.md#g2-premieres-prproj-cannot-be-opened)) | | Our project `.fcproj` | — | yes, schema migrations | yes, atomic, auto-save, recovery journal | migration tests | — | | FCP7 XML (`xmeml`) | read + write | yes | yes | round-trip tests; Premiere-exported XML slow (#461) | 75% | | FCPXML | read (via XtoCC) | yes | yes | round-trip tests | 80% |@@ -92,4 +92,5 @@ validation in Avid and Pro Tools, DVD / Blu-ray / DCP / broadcast MXF delivery. | Date | Change | Summary | |---|---|---|+| 2026-10-27 | minor | Native partial Premiere project import, saved timeline selection and conversion report; stage and readiness estimates unchanged | | 2026-10-10 | major | Created: format-by-format comparison with Premiere 26.5.2 from the import / export registries, crate READMEs, the installed bundle's exporter listings and open issues |@@ -1,6 +1,6 @@ # Where FilmCraft falls short of Premiere Pro -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-10 · **Change:** major (new file: ROADMAP's "Where we are lacking" re-measured against Premiere Pro 26.5.2 and the open issue list) · **Target:** Adobe Premiere Pro 2026 (26.5.2)+> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-27 · **Change:** minor (native partial Premiere project import; readiness unchanged) · **Target:** Adobe Premiere Pro 2026 (26.5.2) Every known shortfall, one entry each, ranked by what most stops an editor using FilmCraft for real work. This is the work list: agents should prefer it over adding checklist items. Numbers are in@@ -52,9 +52,16 @@ platforms** G4, G9, G17; **performance** G16; **ecosystem** G11; **measurement** ## G2. Premiere's `.prproj` cannot be opened -- **Missing:** import of Premiere project files. `grep -ri prproj crates apps` finds nothing.- Today an editor must export FCP7 XML, AAF or OTIO from Premiere first, losing bins, effects and- most metadata; there is no "Selection as Premiere Project" export either.+- **Partial:** File > Import From > Premiere Pro Project imports the PremiereData v3 subset:+ sequences, ordered tracks, exact cuts/source offsets, positive constant speed, links, static+ Motion/Volume and transition intervals. Saved timeline selection, conversion warnings and+ offline media are shown in the app; Save As writes a native project. The source stays unchanged.+- **Missing:** bins, markers, captions/graphics, full colour management, animation, source effects,+ reverse/speed ramps and plugin settings. Third-party transitions are substituted with warnings.+ There is no `.prproj` export. The beta requirement is still not met.+- **Evidence:** synthetic parser mutation tests, engine import/undo/save tests and the native+ picker/report/save/reopen UI test; a contributor-supplied project was checked locally and is not+ shipped as a fixture. - **Impact:** the beta bar requires opening the target's main format; every switching editor's existing work is behind this. - **Approach:** `.prproj` is gzip-compressed XML. Clean-room from projects we author ourselves in@@ -319,6 +326,7 @@ platforms** G4, G9, G17; **performance** G16; **ecosystem** G11; **measurement** | Date | Change | Summary | |---|---|---|+| 2026-10-27 | minor | Native partial Premiere project import, saved timeline selection and conversion report; stage and readiness estimates unchanged | | 2026-10-10 | major | Created from ROADMAP.md's "Where we are lacking" (2026-10-05); re-ranked against Premiere 26.5.2 and the 188 open issues; added `.prproj`, 10-bit / 4:2:2 camera decode, launch crashes and missing PR CI, localization, delivery formats | | 2026-10-12 | minor | G10: MP1 (`A_MPEG/L1`) in Matroska imports (#1402) | | 2026-10-12 | minor | G10: OpenEXR PXR24 import |@@ -1,5 +1,25 @@ # Project files +## Import a Premiere project++Choose **File > Import From > Premiere Pro Project…** and select a `.prproj` file.+FilmCraft imports its sequences as editable timelines and opens the first sequence tab saved by+Premiere. Other sequences are available by double-clicking their items in the Project panel.+The conversion report shows clip counts, unsupported features and missing media. Use+**Save As FilmCraft Project…** to save the edit as a native `.fcproj`, and Link Media for offline+sources. The source project and media are never written by import.++File > Import and dropping a `.prproj` into the Project panel use the same importer. Agents and+the CLI use `file.importPremiereProject {"path":"Edit.prproj","bin":0}` or the existing+`file.import {"paths":["Edit.prproj"]}`; the result contains sequence ids, clip counts, linked and+offline media, and warnings. The frontend report is readable through `ui.inspect` at+`ui.import_report`, with `importReport.saveAs` and `importReport.close` automation targets.++This is an import-only subset, not visual or audio equivalence: bins, markers, graphics,+captions, animation and plugin settings still have gaps. Third-party transitions are substituted+with reported FilmCraft equivalents. Compare playback with Premiere before continuing a production+edit. See [the supported subset](../crates/interchange/README.md#premiere-projects-import-only).+ FilmCraft projects are `.fcproj` files: UTF-8 JSON with an explicit schema version. Reading and writing them is `crates/format` (`filmcraft-format`); the engine's `file.*` commands use it. @@ -1,6 +1,6 @@ # Roadmap detail -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-22 · **Change:** minor (M8: Fujifilm F-Log / F-Log2 / F-Log2 C colour spaces) · **Target:** Adobe Premiere Pro 2026 (26.5.2)+> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-27 · **Change:** minor (native partial Premiere project import; readiness unchanged) · **Target:** Adobe Premiere Pro 2026 (26.5.2) Forward-looking plan: milestones with status, the current focus and what's next, with estimates in Opus 5.5 agent-hours (calibration in [target-app-parity.md](target-app-parity.md#calibration)).@@ -12,7 +12,7 @@ There is no `cargo xtask scorecard` or `cargo xtask parity` yet ([G7](gaps.md#g7 In order. Each item links to its gap entry, which says what "done" means. 1. **Stop launch crashes and add pull-request CI** ([G4](gaps.md#g4-crashes-at-start-up-and-no-test-ci-on-pull-requests)): `cargo xtask ci` on macOS, Windows and Linux for every PR; old-macOS and Intel-GPU start-up. 40–80 h.-2. **Open `.prproj`** ([G2](gaps.md#g2-premieres-prproj-cannot-be-opened)): the beta blocker. 60–120 h.+2. **Complete `.prproj` import** ([G2](gaps.md#g2-premieres-prproj-cannot-be-opened)): native import of a partial timeline subset now works; bins, markers, graphics, animation and broader fidelity remain beta blockers. 60–120 h (not re-estimated). 3. **10-bit / 4:2:2 camera media in software** ([G3](gaps.md#g3-10-bit-and-422-camera-media-needs-hardware)). 30–50 h. 4. **Fidelity harness against Premiere** ([G1](gaps.md#g1-nothing-compares-our-output-with-premieres)) and **`cargo xtask parity`** ([G7](gaps.md#g7-no-measured-parity-tool-in-ci)): turns estimates into measurements. 50–90 h. 5. **Real-world media corpus and workflow tests** ([G6](gaps.md#g6-no-real-world-media-corpus-or-workflow-acceptance-tests)). 40–70 h.@@ -47,7 +47,7 @@ Beta = ready for real work ≥ ~75% and `.prproj` opens reliably ([target-app-pa | # | Milestone | Status | Est. | |---|---|---|---| | B1 | PR CI on three OSs + launch-crash fixes | ⬜ | 40–80 |-| B2 | `.prproj` import (sequences, clips, transitions, markers, bins, common effect parameters) | ⬜ | 60–120 |+| B2 | `.prproj` import (sequences, clips, transitions, markers, bins, common effect parameters) | 🟡 (native timeline subset; bins/markers/animation remain) | 60–120 | | B3 | Camera media: H.264 High 10 / 4:2:2, HEVC 4:2:2, E-AC-3, AVI, multi-track audio confirmed | 🟡 (multi-stream audio #361) | 50–90 | | B4 | Fidelity harness + `cargo xtask parity` + media corpus + workflow tests | ⬜ | 90–160 | | B5 | Hardware: zero-copy, more GPU effects, Windows vendor encoders, VideoToolbox Main 10 | 🟡 | 60–100 |@@ -87,5 +87,6 @@ the web app shell ([G20](gaps.md#g20-the-web-app-shell)). | Date | Change | Summary | |---|---|---|+| 2026-10-27 | minor | Native partial Premiere project import, saved timeline selection and conversion report; stage and readiness estimates unchanged | | 2026-10-10 | minor | Alpha gate table added (craftrules core-workflow gate): six core workflows, all pass on macOS; stage stays alpha | | 2026-10-10 | major | Created: milestone table moved from ROADMAP.md (M5 and M9 remaining work updated), HW and L10N rows, beta milestones B1–B7, Current focus from the re-measure |@@ -1,6 +1,6 @@ # Parity with Adobe Premiere Pro -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-10 · **Change:** major (full re-measure against Premiere Pro 2026 26.5.2; replaces the scorecard and estimate that lived in ROADMAP.md) · **Target:** Adobe Premiere Pro 2026 (26.5.2, macOS)+> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-27 · **Change:** minor (native partial Premiere project import; readiness unchanged) · **Target:** Adobe Premiere Pro 2026 (26.5.2, macOS) The authoritative assessment of how close FilmCraft is to Premiere Pro, and how much work is left. [ROADMAP.md](../ROADMAP.md) summarizes it; [gaps.md](gaps.md) lists every shortfall one at a time.@@ -115,7 +115,7 @@ agent-hours to close the area to Premiere, sequential. | Colour | 8% | ~90% | ~70% | 40–70 | Lumetri complete incl. scopes, LUTs, colour management, HDR export. Lumetri on the GPU covers basic, creative and vignette only; HDR-aware Lumetri maths, macOS EDR display, D-Log M; a Lumetri report (#628) | | | Graphics, titles, captions | 8% | ~85% | ~65% | 50–90 | Text engine with shaping and bidi, shapes, gradients (#514), our `.fcgt` templates (`.mogrt` refused by design), rolls / crawls, all caption formats. Arabic titles reported broken (#395); no 608/708 embedding in the video stream; speech-to-text off in release builds | | | Export and delivery | 8% | ~80% | ~65% | 60–110 | H.264 (own encoder), HEVC through hardware only, ProRes, DNxHR, APV, MXF OP1a / OP-Atom, image sequences, GIF, WAV / AIFF, presets, queue, GPU export rendering (#421). Missing: AV1, MPEG-2 / DVD / Blu-ray, DCP, JPEG 2000 / XS MXF, AS-10 / AS-11, XDCAM HD, EXR / DPX / Targa sequences, MP3, AAC-only audio, ProRes 4444 in the UI (#342), interlaced, smart render; bitrate target missed (#371) | [file-format-parity.md](file-format-parity.md) |-| Project files and interchange | 8% | ~70% | ~45% | 90–160 | `.fcproj` with migrations, auto-save, recovery; FCP7 XML, FCPXML, AAF, OMF, EDL, OTIO, ALE. **Premiere's own `.prproj` cannot be opened** (the main beta blocker); AAF / OMF never validated in Media Composer / Pro Tools; XML import of a Premiere sequence took 97 s (#461) | [file-format-parity.md](file-format-parity.md) |+| Project files and interchange | 8% | ~70% | ~45% | 90–160 | `.fcproj` with migrations, auto-save, recovery; FCP7 XML, FCPXML, AAF, OMF, EDL, OTIO, ALE. **Partial native `.prproj` import** (bins, markers, graphics and animation still block beta); AAF / OMF never validated in Media Composer / Pro Tools; XML import of a Premiere sequence took 97 s (#461) | [file-format-parity.md](file-format-parity.md) | | Preferences and project management | 4% | ~80% | ~65% | 20–40 | 16 Settings categories, most wired; Project Manager, proxies, Link Media; Media Manager limits (#411); images as proxies (#451) | | | **Features, weighted** | 100% | **~86%** | **~64%** | **740–1,290** | | | @@ -228,7 +228,7 @@ Discounts for what still stops real work: - **Stability on real machines ×0.92:** current macOS on Apple silicon is solid, but launch crashes on macOS 12, Intel UHD and Windows 11 (#468, #512, #380, #582, #687), and no CI runs the tests on pull requests.-- **File exchange with Premiere users ×0.88:** `.prproj` does not open (colleagues send+- **File exchange with Premiere users ×0.88 (assessment baseline, not re-scored):** at the time of assessment, `.prproj` did not open (colleagues send projects, not XML); AAF / OMF never validated in Avid / Pro Tools; FCP7 XML / OTIO do work. 74.8 × 0.90 × 0.92 × 0.88 = **54.5% ≈ ~55%.** It is not higher than the full number, unlike most@@ -297,8 +297,10 @@ niche requests among the bug reports about two to one, and nobody reports having **Alpha.** It passes the core-workflow gate ([roadmap.md](roadmap.md#alpha-gate): all six of Premiere's core daily workflows work end to end on macOS and the project saves and reopens). Core workflows run end to end (import, edit, trim, colour, mix, caption, deliver, round-trip through XML / AAF), but ready-for-real-work is ~56%, below the ~75% beta bar, and the-standard's beta condition "opens and saves the target's main format reliably" fails outright:-FilmCraft cannot open a `.prproj` (it reads Premiere's FCP7 XML / AAF / OTIO exports instead).+standard's beta condition "opens and saves the target's main format reliably" is not yet met:+FilmCraft now imports a partial `.prproj` timeline subset through the native app, with explicit+conversion warnings. Missing bins, markers, graphics, animation and plugin fidelity still prevent+meeting the reliable project-exchange condition. Stage and readiness estimates remain unchanged. Distance to beta: ~19 points of ready-for-real-work plus `.prproj` import, ~400–700 agent-hours: | To reach beta | Hours |@@ -354,6 +356,7 @@ control surfaces), and validation in Avid Media Composer / Pro Tools. | Date | Change | Summary | |---|---|---|+| 2026-10-27 | minor | Native partial Premiere project import, saved timeline selection and conversion report; stage and readiness estimates unchanged | | 2026-10-10 | minor | Readiness table: hours to ~95% for each audience (full ~1,450–2,600, mainstream ~500–850, essentials ~180–350); full number confirmed as an additive weighted sum (no change) | | 2026-10-10 | minor | Added the three readiness numbers: full ready-for-real-work formula written out (~55% -> ~56%, same inputs, now computed rather than rounded down), mainstream practitioner ~55% and essentials user ~60% with written weights and discounts, and counted user evidence (31 people praising, 0 switch reports, ~64 core-path vs ~29 niche open bugs) | | 2026-10-10 | minor | Stage checked against the new core-workflow gate (docs/roadmap.md#alpha-gate): passes, stays alpha |