Files
effectcraft/crates/engine/src/commands/timeline.rs
T
bflatastic 50c9549529 Import: image sequences with a Sequence option, ranges, folders and missing frames (#297)
Image sequences were found only by the desktop media prober: one picked
numbered still brought its whole run, with no way to import just that
frame, picking every frame made one sequence item per frame, the browser
couldn't make sequences at all, and a gap in the numbering closed up
silently.

The engine now groups numbered stills itself (engine/src/sequence.rs):
one picked file brings its run from its folder, several picked files of
one run import as that range, a picked or dropped folder imports its
files with each run as a sequence, and Force Alphabetical Order takes
every image of that type in the folder by name. `file.import` takes
`sequence` (default on), `alphabetical` and `frameRate`; Replace Footage,
Reload, proxies and render outputs probe the same way. The media layer
probes one file; the browser lists its file table for the run and
registers every frame with its media pool.

File > Import > File... asks, like After Effects' "PNG Sequence"
checkbox, whether picked numbered stills import as a sequence, with
Force Alphabetical Order and the frame rate. Sequences are named
`shot_[0001-0250].png`.

Interpret Footage gains Missing Frames (Show Placeholder, colour bars as
in After Effects, the new default; Hold Previous Frame; Skip) and Start
Frame (the frame number at the footage's first frame: trims or pads the
head). Saved projects keep Skip, so their sequences play as before.

Form notes keep a readable width and the form fits its rows, so OK stays
in view. docs/footage.md explains importing footage and sequences.

Test: tests_sequence (engine: run, picked range, every frame, Sequence
off, folder, alphabetical, Interpret Footage Missing Frames / Start
Frame, Replace Footage and Reload), project sequence unit tests,
sequence_gaps_show_placeholders_hold_or_close_up (media),
import_dialog_offers_the_image_sequence_option and
dropped_frames_import_as_one_sequence_with_sequence_interpretation (UI).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 18:00:07 +07:00

230 lines
10 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! Premiere Pro interop (`effectcraft-interchange`): File ▸ Import ▸ Adobe Premiere Pro Project /
//! Timeline Interchange… and File ▸ Export ▸ Adobe Premiere Pro Project…, through the public
//! interchange formats Premiere reads and writes. Export writes Final Cut Pro XML (`.xml`) by
//! default, which Premiere Pro imports (File ▸ Import); FCPXML, OpenTimelineIO and EDL on request.
//! The native `.prproj` format has no public specification and is not read or written.
use std::collections::HashMap;
use effectcraft_interchange::{ExportOptions, ImportOptions, PrecompMode, PrerenderMode, Prerendered, TimelineFormat};
use effectcraft_project::render_queue::{AudioOutput, Channels, OutputFormat, OutputModule, ProResProfile, RenderQueueItem, TimeSpan};
use effectcraft_project::{ItemId, LayerId, LayerSource, Project};
use serde_json::{Value, json};
use super::{CommandSpec, always, bad, comp_id, f_p, has_comp, str_p};
use crate::{EngineError, Result, Session, cmd};
fn format_p(p: &Value, cmd: &str) -> Result<Option<TimelineFormat>> {
match str_p(p, "format") {
None | Some("") | Some("auto") => Ok(None),
Some(f) => TimelineFormat::parse(f).map(Some).ok_or_else(|| bad(cmd, format!("unknown format `{f}` (xml, fcpxml, otio, edl, aaf, omf)"))),
}
}
fn import_timeline(s: &mut Session, p: &Value) -> Result<Value> {
const CMD: &str = "file.importTimeline";
let path = str_p(p, "path").ok_or_else(|| bad(CMD, "missing `path`"))?.to_string();
if path.to_ascii_lowercase().ends_with(".prproj") {
return Err(bad(
CMD,
"native Premiere Pro projects (.prproj) are not supported: in Premiere Pro use File ▸ Export ▸ Final Cut Pro XML… and import the .xml",
));
}
let format = format_p(p, CMD)?;
let bytes = s.services.read_file(&path).map_err(|e| EngineError::Other(format!("cannot read {path}: {e}")))?;
let fp = std::path::Path::new(&path);
let opts = ImportOptions {
base_dir: fp.parent().map(|d| d.to_string_lossy().to_string()).filter(|d| !d.is_empty()),
name: fp.file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| "Timeline".into()),
edl_frame_rate: f_p(p, "edlFrameRate"),
};
let importer = s.importer.clone();
let services = s.services.clone();
let rate = s.sequence_rate();
let mut probe = move |m: &str| -> Option<effectcraft_project::Footage> {
if !services.exists(m) {
return None;
}
crate::sequence::probe_path(importer.as_deref()?, services.as_ref(), m, rate).ok()
};
let res = s.edit("Import Timeline", None, |proj, st| {
let r = effectcraft_interchange::import(proj, &bytes, format, &opts, &mut probe).map_err(|e| EngineError::Other(e.to_string()))?;
st.project_selection = r.comps.clone();
Ok(r)
})?;
if let Some(c) = res.comps.first() {
s.open_comp(*c);
}
let n = res.missing.len();
s.toast(match n {
0 => format!("Imported {}", opts.name),
_ => format!("Imported {} ({n} missing file{})", opts.name, if n == 1 { "" } else { "s" }),
});
Ok(json!({
"format": res.format.map(|f| f.id()),
"folder": res.folder.0,
"comps": res.comps.iter().map(|c| c.0).collect::<Vec<_>>(),
"allComps": res.all_comps.iter().map(|c| c.0).collect::<Vec<_>>(),
"items": res.items.iter().map(|c| c.0).collect::<Vec<_>>(),
"missing": res.missing,
"warnings": res.warnings,
}))
}
/// File-name-safe text.
fn safe(s: &str) -> String {
let t: String = s.chars().map(|c| if c.is_alphanumeric() || c == '-' || c == '_' { c } else { '_' }).collect();
if t.is_empty() { "layer".into() } else { t }
}
/// A copy of the project where comp `cid` shows layer `lid` alone (cameras and lights kept, so 3D
/// layers render as they look in the comp).
fn isolate(project: &Project, cid: ItemId, lid: LayerId) -> Option<Project> {
let mut q = project.clone();
let c = q.comp_mut(cid)?;
for l in &mut c.layers {
if l.id == lid {
l.switches.solo = false;
l.switches.video = true;
} else if !matches!(l.source, LayerSource::Camera | LayerSource::Light { .. }) {
l.switches.video = false;
l.switches.audio = false;
l.switches.solo = false;
}
}
Some(q)
}
/// Render one layer to ProRes 4444 with alpha over its in–out span.
fn prerender(s: &Session, cid: ItemId, lid: LayerId, path: &str) -> std::result::Result<Prerendered, String> {
let exporter = s.exporter.clone().ok_or("export is not available in this build")?;
if !exporter.formats().contains(&OutputFormat::ProRes) {
return Err("ProRes export is not available in this build".into());
}
let comp = s.project.comp(cid).ok_or("no composition")?;
let l = comp.layer(lid).ok_or("no layer")?;
let fd = comp.frame_duration();
let start = comp.frame_rate.snap(l.in_point.max(effectcraft_time::Tick::ZERO));
let end = l.out_point.min(comp.duration).max(start + fd);
let q = isolate(&s.project, cid, lid).ok_or("no composition")?;
let mut item = RenderQueueItem::new(0, cid);
item.settings.time_span = TimeSpan::Custom { start, end };
item.output = OutputModule::for_format(OutputFormat::ProRes);
item.output.channels = Channels::Rgba;
item.output.prores_profile = ProResProfile::P4444;
item.output.audio = AudioOutput::Off;
item.output.output = path.to_string();
let job = crate::ExportJob {
project: &q,
footage: s.footage.as_ref(),
expr: s.expr.as_deref(),
accel: s.accel.as_deref(),
item: &item,
path,
storage: None,
label: format!("Pre-render {}", l.name),
nested_switches: s.prefs.general.switches_affect_nested_comps,
};
let r = exporter.export(&job, &mut |_, _| true)?;
Ok(Prerendered { path: r.path, width: comp.width, height: comp.height, frame_rate: comp.frame_rate, duration: end - start })
}
fn export_timeline(s: &mut Session, p: &Value) -> Result<Value> {
const CMD: &str = "file.exportTimeline";
let mut path = str_p(p, "path").ok_or_else(|| bad(CMD, "missing `path`"))?.to_string();
let format = format_p(p, CMD)?.or_else(|| TimelineFormat::from_path(&path)).unwrap_or(TimelineFormat::Fcp7Xml);
if TimelineFormat::from_path(&path).is_none() || path.to_ascii_lowercase().ends_with(".prproj") {
if let Some(stem) = path.strip_suffix(".prproj").map(str::to_string) {
path = stem;
}
path = format!("{path}.{}", format.extension());
}
let cid = comp_id(s, p)?;
let prerender_mode = match str_p(p, "prerender") {
None => match p.get("prerender").and_then(Value::as_bool) {
Some(false) => PrerenderMode::None,
_ => PrerenderMode::Unsupported,
},
Some(m) => PrerenderMode::parse(m).ok_or_else(|| bad(CMD, "prerender: none|unsupported|all"))?,
};
let precomps = match str_p(p, "precomps") {
None | Some("nest") => PrecompMode::Nest,
Some("prerender") => PrecompMode::Prerender,
Some(_) => return Err(bad(CMD, "precomps: nest|prerender")),
};
let fp = std::path::Path::new(&path);
let dir = fp.parent().map(|d| d.to_path_buf()).unwrap_or_default();
let stem = fp.file_stem().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| "Timeline".into());
let relative_to = Some(dir.to_string_lossy().to_string()).filter(|d| !d.is_empty());
let opts = ExportOptions { format, prerender: prerender_mode, precomps, relative_to };
// Pre-render what the timeline can't represent.
let mut pre: HashMap<(ItemId, LayerId), Prerendered> = HashMap::new();
let mut rendered = vec![];
let mut errors = vec![];
for (c, l) in effectcraft_interchange::plan_prerender(&s.project, cid, &opts) {
let comp_name = s.project.item(c).map(|i| i.name.clone()).unwrap_or_default();
let layer_name = s.project.comp(c).and_then(|cc| cc.layer(l)).map(|x| x.name.clone()).unwrap_or_default();
let file = dir.join(format!("{stem}_{}_{}_{}.mov", safe(&comp_name), safe(&layer_name), l.0)).to_string_lossy().to_string();
match prerender(s, c, l, &file) {
Ok(r) => {
rendered.push(r.path.clone());
pre.insert((c, l), r);
}
Err(e) => errors.push(format!("{comp_name} ▸ {layer_name}: {e}")),
}
}
let out = effectcraft_interchange::export(&s.project, cid, &opts, &pre).map_err(|e| EngineError::Other(e.to_string()))?;
s.services.write_file(&path, &out.bytes).map_err(|e| EngineError::Other(format!("cannot write {path}: {e}")))?;
let mut warnings = out.warnings.clone();
warnings.extend(errors.iter().map(|e| format!("pre-render failed: {e}")));
s.toast(match format {
TimelineFormat::Fcp7Xml => format!("Exported {path} (Final Cut Pro XML; import it in Premiere Pro with File ▸ Import)"),
_ => format!("Exported {path} ({})", format.label()),
});
Ok(json!({
"path": path,
"format": format.id(),
"bytes": out.bytes.len(),
"sequences": out.sequences,
"videoTracks": out.video_tracks,
"audioTracks": out.audio_tracks,
"clips": out.clips,
"prerendered": rendered,
"warnings": warnings,
}))
}
/// The formats, for agents and the export dialog.
fn timeline_formats(_: &mut Session, _: &Value) -> Result<Value> {
Ok(json!(
TimelineFormat::ALL
.iter()
.map(|f| json!({"id": f.id(), "label": f.label(), "extension": f.extension(), "import": true, "export": true}))
.collect::<Vec<_>>()
))
}
pub fn specs() -> Vec<CommandSpec> {
vec![
cmd!(
"file.importTimeline",
"Adobe Premiere Pro Project...",
["File", "Import"],
None,
"{path (.xml Final Cut Pro XML from Premiere Pro or .fcpxml .otio .edl .aaf .omf), format?: auto|xml|fcpxml|otio|edl|aaf|omf, edlFrameRate?: number}",
always,
import_timeline
),
cmd!(
"file.exportTimeline",
"Adobe Premiere Pro Project...",
["File", "Export"],
None,
"{comp?, path (Final Cut Pro XML .xml for Premiere Pro or .fcpxml .otio .edl .aaf .omf), format?: xml|fcpxml|otio|edl|aaf|omf, prerender?: none|unsupported|all, precomps?: nest|prerender}",
has_comp,
export_timeline
),
cmd!("file.timelineFormats", "Timeline Interchange Formats", [], None, "{}", always, timeline_formats),
]
}