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>
This commit is contained in:
parent
d5f78f3fc0
commit
736fa4ac3c
|
|
@ -210,6 +210,9 @@ cargo run --release -p effectcraft-cli -- render --out intro.mp4 # render the
|
|||
cargo xtask web --serve 8765 # the browser build on http://127.0.0.1:8765/ (docs/web.md)
|
||||
```
|
||||
|
||||
New to it? [docs/footage.md](docs/footage.md) covers importing footage and image sequences and
|
||||
Interpret Footage.
|
||||
|
||||
To work on it: `cargo test --workspace`, and `cargo xtask ci` before every commit (format, lints,
|
||||
tests, layering, asset attribution and the WebAssembly build). See [CONTRIBUTING.md](CONTRIBUTING.md),
|
||||
[docs/architecture.md](docs/architecture.md) and [docs/testing.md](docs/testing.md).
|
||||
|
|
|
|||
|
|
@ -138,6 +138,15 @@ impl Services for WebServices {
|
|||
}
|
||||
Ok(())
|
||||
}
|
||||
/// The table's files in `dir` (picked and dropped files sit in `/`): picking or dropping a
|
||||
/// numbered file finds the other frames of its image sequence loaded before.
|
||||
fn list_dir(&self, dir: &str) -> Vec<String> {
|
||||
let dir = dir.trim_end_matches('/');
|
||||
list().into_iter().map(|(p, _)| p).filter(|p| p.rsplit_once('/').is_some_and(|(d, _)| d == dir)).collect()
|
||||
}
|
||||
fn is_dir(&self, _path: &str) -> bool {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
/// Media import from the table (the bytes are also handed to the media pool, which decodes them).
|
||||
|
|
@ -154,6 +163,13 @@ impl effectcraft_engine::Importer for WebImporter {
|
|||
fn register(&self, path: &str, data: &[u8]) {
|
||||
self.pool.add_bytes(path, get(path).unwrap_or_else(|| data.into()));
|
||||
}
|
||||
fn add_frames(&self, paths: &[String]) {
|
||||
for p in paths {
|
||||
if let Some(bytes) = get(p) {
|
||||
self.pool.add_bytes(p, bytes);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Render Queue output: files land in the table and are downloaded after the render
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ use effectcraft_project::{FootageKind, ItemId, ItemKind, LayerSource, Project};
|
|||
use serde_json::{Value, json};
|
||||
|
||||
use super::{CommandSpec, always, b_p, bad, str_p};
|
||||
use crate::sequence::{SequenceOptions, Source};
|
||||
use crate::{EngineError, Result, Session, cmd};
|
||||
|
||||
fn has_path(s: &Session) -> std::result::Result<(), String> {
|
||||
|
|
@ -111,23 +112,22 @@ fn revert(s: &mut Session, _: &Value) -> Result<Value> {
|
|||
fn import_cmd(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let target = s.active_comp_id();
|
||||
let mut pending = prepare_import(s, p)?;
|
||||
if b_p(p, "background").unwrap_or(false) && !pending.paths.is_empty() {
|
||||
let paths = std::mem::take(&mut pending.paths);
|
||||
let total = paths.len();
|
||||
let label = match paths.first() {
|
||||
Some(one) if total == 1 => format!("Importing {}", file_name(one)),
|
||||
if b_p(p, "background").unwrap_or(false) && !pending.sources.is_empty() {
|
||||
let sources = std::mem::take(&mut pending.sources);
|
||||
let total = sources.len();
|
||||
let label = match sources.first() {
|
||||
Some(one) if total == 1 => format!("Importing {}", one.name()),
|
||||
_ => format!("Importing {total} files"),
|
||||
};
|
||||
let p = p.clone();
|
||||
return s.spawn_task("import", label, false, move |ctl| {
|
||||
let mut probed = Vec::with_capacity(total);
|
||||
for (k, path) in paths.into_iter().enumerate() {
|
||||
ctl.message(format!("{} ({} of {total})", file_name(&path), k + 1));
|
||||
for (k, src) in sources.iter().enumerate() {
|
||||
ctl.message(format!("{} ({} of {total})", src.name(), k + 1));
|
||||
if !ctl.progress(k as u64, total as u64) {
|
||||
return Err(crate::render_queue::CANCELLED.into());
|
||||
}
|
||||
let r = pending.prober.probe(&path);
|
||||
probed.push(r);
|
||||
probed.push(pending.prober.probe(src));
|
||||
}
|
||||
ctl.progress(total as u64, total as u64);
|
||||
let apply: crate::jobs::Apply = Box::new(move |s: &mut Session| {
|
||||
|
|
@ -147,17 +147,12 @@ fn import_cmd(s: &mut Session, p: &Value) -> Result<Value> {
|
|||
Ok(apply)
|
||||
});
|
||||
}
|
||||
let probed = pending.paths.iter().map(|path| pending.prober.probe(path)).collect();
|
||||
let probed = pending.sources.iter().map(|src| pending.prober.probe(src)).collect();
|
||||
let mut r = add_footage(s, pending, probed)?;
|
||||
finish_import(s, &mut r, target, p);
|
||||
Ok(r)
|
||||
}
|
||||
|
||||
/// The file name of a path, for labels.
|
||||
fn file_name(path: &str) -> String {
|
||||
std::path::Path::new(path).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| path.to_string())
|
||||
}
|
||||
|
||||
/// After an import (its result `r`): with `addToComp`, the new items become layers of `target`;
|
||||
/// what couldn't be added joins the result's errors.
|
||||
fn finish_import(s: &mut Session, r: &mut Value, target: Option<ItemId>, p: &Value) {
|
||||
|
|
@ -206,14 +201,14 @@ pub(crate) fn add_to_comp(s: &mut Session, r: &Value, target: Option<ItemId>, p:
|
|||
/// File ▸ Import, all in one go (scripts and commands built on it); see [`import_cmd`].
|
||||
pub(crate) fn import(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let pending = prepare_import(s, p)?;
|
||||
let probed = pending.paths.iter().map(|path| pending.prober.probe(path)).collect();
|
||||
let probed = pending.sources.iter().map(|src| pending.prober.probe(src)).collect();
|
||||
add_footage(s, pending, probed)
|
||||
}
|
||||
|
||||
/// An import whose layered files are already compositions: the files left to probe as footage,
|
||||
/// how, and what the import made so far.
|
||||
/// An import whose layered files are already compositions: the files and image sequences left
|
||||
/// to probe as footage, how, and what the import made so far.
|
||||
struct PendingImport {
|
||||
paths: Vec<String>,
|
||||
sources: Vec<Source>,
|
||||
prober: Prober,
|
||||
comps: Vec<u64>,
|
||||
items: Vec<u64>,
|
||||
|
|
@ -229,7 +224,8 @@ struct Prober {
|
|||
/// Settings ▸ Import ▸ Interpret Unlabeled Alpha As, for stills and for movies.
|
||||
still_alpha: Option<effectcraft_project::AlphaMode>,
|
||||
movie_alpha: Option<effectcraft_project::AlphaMode>,
|
||||
/// Settings ▸ Import ▸ Sequence Footage frames per second.
|
||||
/// Image sequences' frames per second: the import's `frameRate`, or Settings ▸ Import ▸
|
||||
/// Sequence Footage.
|
||||
sequence_rate: effectcraft_time::FrameRate,
|
||||
/// PDF / Illustrator page (0-based).
|
||||
page: u32,
|
||||
|
|
@ -237,9 +233,10 @@ struct Prober {
|
|||
psd_layer: Option<Value>,
|
||||
}
|
||||
|
||||
/// A file probed as footage.
|
||||
/// A file (or image sequence) probed as footage.
|
||||
struct Probed {
|
||||
path: String,
|
||||
/// The Project panel name.
|
||||
name: String,
|
||||
footage: effectcraft_project::Footage,
|
||||
/// Its alpha is unlabeled and Settings say Ask User: open Interpret Footage.
|
||||
ask_alpha: bool,
|
||||
|
|
@ -247,22 +244,27 @@ struct Probed {
|
|||
}
|
||||
|
||||
impl Prober {
|
||||
/// Probe `path` as footage; the error names the file.
|
||||
fn probe(&self, path: &str) -> std::result::Result<Probed, String> {
|
||||
/// Probe a file or image sequence as footage; the error names the file.
|
||||
fn probe(&self, src: &Source) -> std::result::Result<Probed, String> {
|
||||
let path = src.path();
|
||||
let name = src.name();
|
||||
let fail = |e: String| format!("{path}: {e}");
|
||||
// Data files (JSON, CSV, TSV) for data-driven animation: kept as text in the project.
|
||||
if let Some(f) = data_footage(self.services.as_ref(), path) {
|
||||
return f.map(|footage| Probed { path: path.to_string(), footage, ask_alpha: false, warning: None }).map_err(fail);
|
||||
if let Source::File(_) = src
|
||||
&& let Some(f) = data_footage(self.services.as_ref(), path)
|
||||
{
|
||||
return f.map(|footage| Probed { name, footage, ask_alpha: false, warning: None }).map_err(fail);
|
||||
}
|
||||
let importer = self.importer.as_ref().ok_or_else(|| fail("media import is not available in this build".into()))?;
|
||||
let mut f = importer.probe(path).map_err(fail)?;
|
||||
let mut f = crate::sequence::probe(importer.as_ref(), src, self.sequence_rate).map_err(fail)?;
|
||||
let mut warning = None;
|
||||
// Settings ▸ Import ▸ Report Missing Frames.
|
||||
// Settings ▸ Import ▸ Report Missing Frames (numbered sequences).
|
||||
if f.kind == FootageKind::Sequence
|
||||
&& f.missing_frames != effectcraft_project::MissingFrames::Skip
|
||||
&& self.report_missing_frames
|
||||
&& let Some(gaps) = missing_frames(&f.sequence)
|
||||
&& let Some(gaps) = crate::sequence::missing_report(&f.sequence)
|
||||
{
|
||||
warning = Some(format!("{path}: {gaps}"));
|
||||
warning = Some(format!("{name}: {gaps} (shown as placeholders; File ▸ Interpret Footage ▸ Missing Frames)"));
|
||||
}
|
||||
// Settings ▸ Import ▸ Interpret Unlabeled Alpha As: images only (a 3D model renders its
|
||||
// own alpha, and audio and data have none).
|
||||
|
|
@ -277,13 +279,6 @@ impl Prober {
|
|||
None => ask_alpha = true,
|
||||
}
|
||||
}
|
||||
// Settings ▸ Import ▸ Sequence Footage frames per second.
|
||||
if f.kind == FootageKind::Sequence {
|
||||
let r = self.sequence_rate;
|
||||
let frames = f.frame_rate.frame_at(f.duration);
|
||||
f.frame_rate = r;
|
||||
f.duration = r.tick_of(frames.max(1));
|
||||
}
|
||||
// Page: another page of a PDF / Illustrator file.
|
||||
if self.page > 0 && matches!(f.codec.as_str(), "PDF" | "AI") {
|
||||
let doc = self
|
||||
|
|
@ -308,7 +303,7 @@ impl Prober {
|
|||
.ok_or_else(|| fail(format!("no layer {sel}")))?;
|
||||
f.layer = Some(effectcraft_project::SourceLayer { index: index as u32, name, layer_size: false, embedded: None, placed: false });
|
||||
}
|
||||
Ok(Probed { path: path.to_string(), footage: f, ask_alpha, warning })
|
||||
Ok(Probed { name, footage: f, ask_alpha, warning })
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -379,17 +374,25 @@ fn prepare_import(s: &mut Session, p: &Value) -> Result<PendingImport> {
|
|||
}
|
||||
paths = rest;
|
||||
}
|
||||
// Numbered stills as image sequences (the Import dialog's "<format> Sequence" and Force
|
||||
// Alphabetical Order), at `frameRate` or Settings ▸ Import ▸ Sequence Footage.
|
||||
let opts = SequenceOptions::from_params(p);
|
||||
let fps = match p.get("frameRate") {
|
||||
None | Some(Value::Null) => s.prefs.import.sequence_fps,
|
||||
Some(v) => v.as_f64().filter(|x| *x > 0.0 && *x <= 999.0).ok_or_else(|| bad("file.import", "frameRate: frames per second, up to 999"))?,
|
||||
};
|
||||
let sources = crate::sequence::group(s.services.as_ref(), &paths, opts);
|
||||
let prober = Prober {
|
||||
importer: s.importer.clone(),
|
||||
services: s.services.clone(),
|
||||
report_missing_frames: s.prefs.import.report_missing_frames,
|
||||
still_alpha: s.prefs.unlabeled_alpha(false),
|
||||
movie_alpha: s.prefs.unlabeled_alpha(true),
|
||||
sequence_rate: effectcraft_time::FrameRate::from_f64(s.prefs.import.sequence_fps),
|
||||
sequence_rate: effectcraft_time::FrameRate::from_f64(fps),
|
||||
page,
|
||||
psd_layer: p.get("layer").cloned(),
|
||||
};
|
||||
Ok(PendingImport { paths, prober, comps, items, errors })
|
||||
Ok(PendingImport { sources, prober, comps, items, errors })
|
||||
}
|
||||
|
||||
/// The last part of an import: add the probed files to the project (one undo step) and select
|
||||
|
|
@ -413,7 +416,7 @@ fn add_footage(s: &mut Session, pending: PendingImport, probed: Vec<std::result:
|
|||
s.edit("Import", None, |proj, st| {
|
||||
let mut selection = made;
|
||||
for f in found {
|
||||
let name = file_name(&f.path);
|
||||
let name = f.name;
|
||||
let footage = f.footage;
|
||||
let name = match &footage.layer {
|
||||
Some(l) => format!("{}/{name}", l.name),
|
||||
|
|
@ -457,29 +460,6 @@ fn alpha_is_labeled(path: &str, codec: &str) -> bool {
|
|||
matches!(ext.as_str(), "png" | "gif" | "webp" | "psd" | "psb" | "exr" | "svg" | "jpg" | "jpeg" | "ai" | "pdf" | "eps") || codec.eq_ignore_ascii_case("PSD")
|
||||
}
|
||||
|
||||
/// Gaps in an image sequence's frame numbers ("missing frames 4–6, 9"), from the digit run at the
|
||||
/// end of each file name. `None` when the numbering is continuous (or there is none).
|
||||
pub(crate) fn missing_frames(files: &[String]) -> Option<String> {
|
||||
let num = |f: &String| -> Option<i64> {
|
||||
let stem = std::path::Path::new(f).file_stem()?.to_string_lossy().to_string();
|
||||
let digits: String = stem.chars().rev().take_while(char::is_ascii_digit).collect::<Vec<_>>().into_iter().rev().collect();
|
||||
digits.parse().ok()
|
||||
};
|
||||
let mut ns: Vec<i64> = files.iter().filter_map(num).collect();
|
||||
ns.sort_unstable();
|
||||
ns.dedup();
|
||||
let mut gaps = vec![];
|
||||
let mut total = 0;
|
||||
for w in ns.windows(2) {
|
||||
if w[1] - w[0] > 1 {
|
||||
let (a, b) = (w[0] + 1, w[1] - 1);
|
||||
total += b - a + 1;
|
||||
gaps.push(if a == b { a.to_string() } else { format!("{a}–{b}") });
|
||||
}
|
||||
}
|
||||
(!gaps.is_empty()).then(|| format!("{total} missing frame{} ({})", if total == 1 { "" } else { "s" }, gaps.join(", ")))
|
||||
}
|
||||
|
||||
/// A Photoshop layer by index or name (pixel layers only).
|
||||
fn find_psd_layer(d: &effectcraft_psd::Psd, sel: &Value) -> Option<(usize, String)> {
|
||||
let l = match sel {
|
||||
|
|
@ -700,7 +680,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
|||
"File...",
|
||||
["File", "Import"],
|
||||
Some("Cmd+I"),
|
||||
"{paths: [string], importAs?: footage|composition|compositionLayerSizes (Photoshop, PDF, Illustrator and EPS files), layer?: name|index (footage of one Photoshop layer), page?: number from 1 (PDF / Illustrator page), drag?: bool (dropped files: Settings ▸ Import ▸ Default Drag Import As), addToComp?: bool (also add them to the active comp, at time?, index?, position? as in layer.addItem), background?: bool (probe the files in a background job, jobs.list / jobs.wait; returns {job})}",
|
||||
"{paths: [string] (files or folders), sequence?: bool (default true: numbered stills of one run import as one image sequence; one picked file brings its whole run, several picked files that range), alphabetical?: bool (Force Alphabetical Order: every image of that type in the folder, by name), frameRate?: fps (image sequences; default Settings ▸ Import ▸ Sequence Footage), importAs?: footage|composition|compositionLayerSizes (Photoshop, PDF, Illustrator and EPS files), layer?: name|index (footage of one Photoshop layer), page?: number from 1 (PDF / Illustrator page), drag?: bool (dropped files: Settings ▸ Import ▸ Default Drag Import As), addToComp?: bool (also add them to the active comp, at time?, index?, position? as in layer.addItem), background?: bool (probe the files in a background job, jobs.list / jobs.wait; returns {job})}",
|
||||
always,
|
||||
import_cmd
|
||||
),
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ use effectcraft_time::{FrameRate, Tick};
|
|||
use serde_json::{Value, json};
|
||||
|
||||
use super::{CommandSpec, always, b_p, bad, f_p, has_comp, has_project_selection, str_p};
|
||||
use crate::sequence::SequenceOptions;
|
||||
use crate::{EngineError, Result, Session, cmd};
|
||||
|
||||
fn has_footage_selection(s: &Session) -> std::result::Result<(), String> {
|
||||
|
|
@ -552,6 +553,10 @@ pub(crate) struct Interpretation {
|
|||
fields: Option<effectcraft_project::FieldOrder>,
|
||||
profile: Option<Option<effectcraft_project::ColorSpace>>,
|
||||
linear: Option<bool>,
|
||||
/// Image sequences: Missing Frames.
|
||||
missing: Option<effectcraft_project::MissingFrames>,
|
||||
/// Image sequences: Start Frame (`Some(None)` = the first file's number).
|
||||
start: Option<Option<i64>>,
|
||||
}
|
||||
|
||||
impl Interpretation {
|
||||
|
|
@ -598,12 +603,32 @@ impl Interpretation {
|
|||
None => None,
|
||||
};
|
||||
it.linear = super::b_p(p, "linearLight");
|
||||
it.missing = match str_p(p, "missingFrames") {
|
||||
Some(m) => Some(effectcraft_project::MissingFrames::parse(m).ok_or_else(|| bad(cmd, format!("missingFrames: placeholder|hold|skip, not `{m}`")))?),
|
||||
None => None,
|
||||
};
|
||||
// Start Frame: a frame number, or "file" for the first file's.
|
||||
it.start = match p.get("startFrame") {
|
||||
None | Some(Value::Null) => None,
|
||||
Some(Value::String(f)) if f == "file" => Some(None),
|
||||
Some(v) => Some(Some(
|
||||
v.as_f64()
|
||||
.filter(|x| x.is_finite() && x.abs() <= 1.0e9)
|
||||
.map(|x| x.round() as i64)
|
||||
.ok_or_else(|| bad(cmd, "startFrame: a frame number (up to 1e9), or \"file\""))?,
|
||||
)),
|
||||
};
|
||||
Ok(it)
|
||||
}
|
||||
|
||||
pub(crate) fn apply(&self, f: &mut Footage, guessed: Option<(AlphaMode, [f32; 3])>) {
|
||||
let before = f.frame_rate;
|
||||
self.apply_settings(f, guessed);
|
||||
// A sequence's length is its frames (Missing Frames and Start Frame decide how many).
|
||||
if f.kind == FootageKind::Sequence && !f.sequence.is_empty() {
|
||||
f.sync_sequence_duration();
|
||||
return;
|
||||
}
|
||||
// Conforming keeps the frames and changes how long they last (12 frames at 30 fps are
|
||||
// 0.4 s; conformed to 12 fps, 1 s). Stills have no frame rate of their own.
|
||||
let (old, new) = (before.as_f64(), f.frame_rate.as_f64());
|
||||
|
|
@ -652,6 +677,16 @@ impl Interpretation {
|
|||
if let Some(v) = self.linear {
|
||||
f.linear_light = v;
|
||||
}
|
||||
if f.kind == FootageKind::Sequence {
|
||||
if let Some(m) = self.missing {
|
||||
f.missing_frames = m;
|
||||
}
|
||||
if let Some(start) = self.start {
|
||||
// The first file's own number needn't be stored (it follows the files).
|
||||
let first = f.sequence.first().and_then(|p| effectcraft_project::sequence::frame_number(p));
|
||||
f.start_frame = start.filter(|n| Some(*n) != first);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -728,7 +763,8 @@ fn interpret(s: &mut Session, p: &Value) -> Result<Value> {
|
|||
Some(ItemKind::Footage(f)) => Some(json!({
|
||||
"item": i.0, "alpha": format!("{:?}", f.alpha), "matteColor": f.premul_color, "invertAlpha": f.invert_alpha,
|
||||
"fields": f.fields.label(), "pixelAspect": f.pixel_aspect, "loop": f.loop_count, "frameRate": f.frame_rate.as_f64(),
|
||||
"linearLight": f.linear_light,
|
||||
"linearLight": f.linear_light, "missingFrames": f.missing_frames.id(), "startFrame": f.first_frame_number(),
|
||||
"frames": f.sequence_frames(),
|
||||
})),
|
||||
_ => None,
|
||||
})
|
||||
|
|
@ -775,8 +811,11 @@ fn replace_footage(s: &mut Session, p: &Value) -> Result<Value> {
|
|||
let path = str_p(p, "path").ok_or_else(|| bad("file.replaceFootage", "missing `path`"))?.to_string();
|
||||
let item = *items_p(s, p).first().ok_or_else(|| bad("file.replaceFootage", "select footage"))?;
|
||||
let importer = s.importer.clone().ok_or_else(|| EngineError::Other("media import is not available in this build".into()))?;
|
||||
let f = importer.probe(&path).map_err(|e| EngineError::Other(format!("{path}: {e}")))?;
|
||||
let name = std::path::Path::new(&path).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or(path.clone());
|
||||
// A numbered still brings its image sequence, as on import (`sequence: false` for one frame).
|
||||
let opts = SequenceOptions::from_params(p);
|
||||
let src = crate::sequence::source_of(s.services.as_ref(), &path, opts);
|
||||
let f = crate::sequence::probe(importer.as_ref(), &src, s.sequence_rate()).map_err(|e| EngineError::Other(format!("{path}: {e}")))?;
|
||||
let name = src.name();
|
||||
s.edit("Replace Footage", None, |proj, _| {
|
||||
let it = proj.item_mut(item).ok_or_else(|| bad("file.replaceFootage", "no such item"))?;
|
||||
it.name = name;
|
||||
|
|
@ -843,11 +882,14 @@ fn replace_with_solid(s: &mut Session, p: &Value) -> Result<Value> {
|
|||
|
||||
fn reload(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let items = items_p(s, p);
|
||||
let Some(importer) = s.importer.clone() else { return Err(EngineError::Other("media import is not available in this build".into())) };
|
||||
if s.importer.is_none() {
|
||||
return Err(EngineError::Other("media import is not available in this build".into()));
|
||||
}
|
||||
let mut updates = vec![];
|
||||
for i in &items {
|
||||
if let Some(ItemKind::Footage(f)) = s.project.item(*i).map(|x| &x.kind) {
|
||||
let mut nf = match importer.probe(&f.path) {
|
||||
// An image sequence picks up frames added to (or removed from) its run.
|
||||
let mut nf = match s.probe_footage(&f.path) {
|
||||
Ok(n) => n,
|
||||
Err(_) => Footage { missing: true, ..f.clone() },
|
||||
};
|
||||
|
|
@ -855,6 +897,13 @@ fn reload(s: &mut Session, p: &Value) -> Result<Value> {
|
|||
nf.alpha = f.alpha;
|
||||
nf.loop_count = f.loop_count;
|
||||
nf.pixel_aspect = f.pixel_aspect;
|
||||
if nf.kind == FootageKind::Sequence && f.kind == FootageKind::Sequence {
|
||||
nf.frame_rate = f.frame_rate;
|
||||
nf.native_rate = f.native_rate;
|
||||
nf.missing_frames = f.missing_frames;
|
||||
nf.start_frame = f.start_frame;
|
||||
nf.sync_sequence_duration();
|
||||
}
|
||||
updates.push((*i, nf));
|
||||
}
|
||||
}
|
||||
|
|
@ -954,7 +1003,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
|||
"Main...",
|
||||
["File", "Interpret Footage"],
|
||||
Some("Cmd+Alt+G"),
|
||||
"{items?, frameRate?: fps|\"file\", alpha?: straight|premultiplied|ignore|guess, guessAlpha?, matteColor?, invertAlpha?, loop?, pixelAspect?, fields?: off|upper|lower, colorProfile?: srgb|rec709|rec2020|p3|auto, linearLight?}",
|
||||
"{items?, frameRate?: fps|\"file\", alpha?: straight|premultiplied|ignore|guess, guessAlpha?, matteColor?, invertAlpha?, loop?, pixelAspect?, fields?: off|upper|lower, colorProfile?: srgb|rec709|rec2020|p3|auto, linearLight?, missingFrames?: placeholder|hold|skip (image sequences: what a gap in the numbering shows), startFrame?: number|\"file\" (image sequences: the frame number at the footage's first frame)}",
|
||||
has_footage_selection,
|
||||
interpret
|
||||
),
|
||||
|
|
@ -976,7 +1025,15 @@ pub fn specs() -> Vec<CommandSpec> {
|
|||
has_interpretation,
|
||||
apply_interpretation
|
||||
),
|
||||
cmd!("file.replaceFootage", "File...", ["File", "Replace Footage"], Some("Cmd+H"), "{path, item?}", has_footage_selection, replace_footage),
|
||||
cmd!(
|
||||
"file.replaceFootage",
|
||||
"File...",
|
||||
["File", "Replace Footage"],
|
||||
Some("Cmd+H"),
|
||||
"{path, item?, sequence?: bool (default true: a numbered still brings its image sequence), alphabetical?: bool}",
|
||||
has_footage_selection,
|
||||
replace_footage
|
||||
),
|
||||
cmd!(
|
||||
"file.replaceWithPlaceholder",
|
||||
"Placeholder...",
|
||||
|
|
|
|||
|
|
@ -74,8 +74,6 @@ pub(crate) mod watch_folder;
|
|||
pub(crate) use app_more::report_text as report_text_for_tests;
|
||||
pub(crate) use app_more::view_command;
|
||||
#[cfg(test)]
|
||||
pub(crate) use file::missing_frames as missing_frames_for_tests;
|
||||
#[cfg(test)]
|
||||
pub(crate) use keys_more::amplitudes as amplitudes_for_tests;
|
||||
#[cfg(test)]
|
||||
pub(crate) use mask::split_segment as split_segment_for_tests;
|
||||
|
|
|
|||
|
|
@ -120,9 +120,8 @@ fn file_info(s: &mut Session, p: &Value) -> Result<Value> {
|
|||
let path = str_p(p, "path").ok_or_else(|| bad("mediaBrowser.fileInfo", "missing `path`"))?;
|
||||
let (created, modified, size) = mb::file_dates(path);
|
||||
let mut v = json!({"path": path, "kind": mb::kind_of(path), "size": size, "created": created.map(mb::iso_date), "modified": modified.map(mb::iso_date)});
|
||||
if let Some(imp) = s.importer.clone()
|
||||
&& matches!(mb::kind_of(path), Some("video" | "image" | "audio" | "model"))
|
||||
&& let Ok(f) = imp.probe(path)
|
||||
if matches!(mb::kind_of(path), Some("video" | "image" | "audio" | "model"))
|
||||
&& let Ok(f) = s.probe_footage(path)
|
||||
{
|
||||
v["footage"] = mb::footage_metadata(&f);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -40,8 +40,11 @@ fn has_proxy(s: &Session) -> std::result::Result<(), String> {
|
|||
|
||||
/// Probe a proxy file through the importer.
|
||||
fn probe(s: &Session, path: &str, cmd: &str) -> Result<Footage> {
|
||||
let importer = s.importer.clone().ok_or_else(|| EngineError::Other("media import is not available in this build".into()))?;
|
||||
let f = importer.probe(path).map_err(|e| bad(cmd, format!("{path}: {e}")))?;
|
||||
if s.importer.is_none() {
|
||||
return Err(EngineError::Other("media import is not available in this build".into()));
|
||||
}
|
||||
// The first frame of a proxy sequence (Create Proxy ▸ Movie renders one) brings the rest.
|
||||
let f = s.probe_footage(path).map_err(|e| bad(cmd, format!("{path}: {e}")))?;
|
||||
if !f.has_video {
|
||||
return Err(bad(cmd, format!("{path} has no video to use as a proxy")));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -40,11 +40,12 @@ fn import_timeline(s: &mut Session, p: &Value) -> Result<Value> {
|
|||
};
|
||||
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;
|
||||
}
|
||||
importer.as_ref()?.probe(m).ok()
|
||||
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()))?;
|
||||
|
|
|
|||
|
|
@ -51,6 +51,7 @@ pub fn start(s: &mut Session, items: Option<Vec<ItemId>>, wait: bool) -> crate::
|
|||
}
|
||||
let services = s.services.clone();
|
||||
let importer = s.importer.clone();
|
||||
let rate = s.sequence_rate();
|
||||
let n = jobs.len();
|
||||
s.spawn_task("footageCheck", format!("Checking footage ({n} items)"), wait, move |ctl| {
|
||||
let mut found: Vec<(ItemId, Found)> = Vec::with_capacity(jobs.len());
|
||||
|
|
@ -69,7 +70,23 @@ pub fn start(s: &mut Session, items: Option<Vec<ItemId>>, wait: bool) -> crate::
|
|||
};
|
||||
// Saved without metadata (a hand-written or converted project): probe it now.
|
||||
let needs_probe = present && f.width == 0 && f.height == 0 && f.kind != FootageKind::Audio;
|
||||
match importer.as_ref().filter(|_| needs_probe).map(|imp| imp.probe(&f.path)) {
|
||||
let probed = importer.as_ref().filter(|_| needs_probe).map(|imp| match f.kind {
|
||||
// A sequence keeps its files; its first one gives the metadata.
|
||||
FootageKind::Sequence if !f.sequence.is_empty() => imp.probe(&f.path).map(|one| {
|
||||
let mut nf = Footage {
|
||||
width: one.width,
|
||||
height: one.height,
|
||||
pixel_aspect: one.pixel_aspect,
|
||||
has_video: one.has_video,
|
||||
codec: one.codec,
|
||||
..f.clone()
|
||||
};
|
||||
nf.sync_sequence_duration();
|
||||
nf
|
||||
}),
|
||||
_ => crate::sequence::probe_path(imp.as_ref(), services.as_ref(), &f.path, rate),
|
||||
});
|
||||
match probed {
|
||||
Some(Ok(mut nf)) => {
|
||||
nf.alpha = f.alpha;
|
||||
nf.loop_count = f.loop_count;
|
||||
|
|
|
|||
|
|
@ -37,6 +37,7 @@ pub mod remote;
|
|||
pub mod render_queue;
|
||||
pub mod roto;
|
||||
pub mod scriptui;
|
||||
pub mod sequence;
|
||||
mod session_settings;
|
||||
pub mod shortcuts;
|
||||
pub mod storage;
|
||||
|
|
@ -110,6 +111,19 @@ pub trait Services: Send + Sync {
|
|||
}
|
||||
self.write_file(path, data)
|
||||
}
|
||||
/// The files in folder `dir` (full paths; no folders, no hidden files), for image-sequence
|
||||
/// detection on import. Empty when it can't be read.
|
||||
fn list_dir(&self, dir: &str) -> Vec<String> {
|
||||
let Ok(rd) = std::fs::read_dir(if dir.is_empty() { "." } else { dir }) else { return vec![] };
|
||||
rd.filter_map(|e| e.ok())
|
||||
.filter(|e| e.file_type().is_ok_and(|t| t.is_file()) && !e.file_name().to_string_lossy().starts_with('.'))
|
||||
.map(|e| e.path().to_string_lossy().to_string())
|
||||
.collect()
|
||||
}
|
||||
/// Whether `path` is a folder (dropped or picked folders import their files).
|
||||
fn is_dir(&self, path: &str) -> bool {
|
||||
std::path::Path::new(path).is_dir()
|
||||
}
|
||||
}
|
||||
|
||||
/// Native filesystem.
|
||||
|
|
@ -125,11 +139,15 @@ impl Services for FsServices {
|
|||
|
||||
/// Imports media files into the project (implemented by the media layer).
|
||||
pub trait Importer: Send + Sync {
|
||||
/// Probe a file and return footage metadata.
|
||||
/// Probe one file and return footage metadata. Image sequences are the engine's
|
||||
/// ([`sequence`]): it groups numbered stills and probes their first file here.
|
||||
fn probe(&self, path: &str) -> std::result::Result<effectcraft_project::Footage, String>;
|
||||
/// A footage file's bytes are now at `path` (extracted from a template): hosts whose media
|
||||
/// layer reads from memory (the browser) register them; file-based ones need nothing.
|
||||
fn register(&self, _path: &str, _data: &[u8]) {}
|
||||
/// An image sequence's other frames (its first was probed) are footage now: hosts whose
|
||||
/// media layer reads from memory (the browser) register them; file-based ones need nothing.
|
||||
fn add_frames(&self, _paths: &[String]) {}
|
||||
}
|
||||
|
||||
/// A keyframe reference: layer, property uid, key time (layer time).
|
||||
|
|
@ -1013,6 +1031,8 @@ mod tests_proxy;
|
|||
#[cfg(test)]
|
||||
mod tests_rig3d;
|
||||
#[cfg(test)]
|
||||
mod tests_sequence;
|
||||
#[cfg(test)]
|
||||
mod tests_settings;
|
||||
#[cfg(test)]
|
||||
mod tests_stubs;
|
||||
|
|
|
|||
|
|
@ -608,8 +608,8 @@ impl Session {
|
|||
if item.post_render.is_none() {
|
||||
return Ok(None);
|
||||
}
|
||||
let importer = self.importer.clone().ok_or("media import is not available in this build")?;
|
||||
let footage = importer.probe(path)?;
|
||||
// A rendered image sequence's first file brings the sequence.
|
||||
let footage = self.probe_footage(path)?;
|
||||
if item.post_render == PostRenderAction::SetProxy {
|
||||
crate::commands::proxy_set(self, item.comp, footage).map_err(|e| e.to_string())?;
|
||||
self.toast(format!("Proxy set from {path}"));
|
||||
|
|
|
|||
|
|
@ -0,0 +1,310 @@
|
|||
//! Image sequences on import (File ▸ Import ▸ File…, dropped files and folders, Replace
|
||||
//! Footage, Reload, proxies): numbered stills of one run (`shot_0001.png`, `shot_0002.png`, …)
|
||||
//! become one footage item, as with After Effects' "PNG Sequence" import option.
|
||||
//!
|
||||
//! - One picked numbered file brings its whole run from its folder ([`crate::Services::list_dir`]).
|
||||
//! - Several picked files of one run import as one sequence of just those files (a range).
|
||||
//! - A picked or dropped folder imports its files; each run in it becomes a sequence.
|
||||
//! - Force Alphabetical Order: every image of the same type in the folder, in name order,
|
||||
//! whatever the numbering (gaps don't count).
|
||||
//!
|
||||
//! The browser has no folders: its file table lists the files picked or dropped so far, so
|
||||
//! picking every frame of a sequence imports it there too.
|
||||
|
||||
use effectcraft_project::sequence::{frame_number, missing_frame_ranges, sequence_name, split_frame_number};
|
||||
use effectcraft_project::{Footage, FootageKind, MissingFrames};
|
||||
use effectcraft_time::FrameRate;
|
||||
|
||||
use crate::{Importer, Services};
|
||||
|
||||
/// How an import treats numbered stills.
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub struct SequenceOptions {
|
||||
/// The "<format> Sequence" checkbox: numbered stills of one run import as a sequence.
|
||||
pub sequence: bool,
|
||||
/// Force Alphabetical Order.
|
||||
pub alphabetical: bool,
|
||||
}
|
||||
|
||||
impl Default for SequenceOptions {
|
||||
fn default() -> Self {
|
||||
SequenceOptions { sequence: true, alphabetical: false }
|
||||
}
|
||||
}
|
||||
|
||||
impl SequenceOptions {
|
||||
/// From command parameters `sequence?` (default on) and `alphabetical?`.
|
||||
pub fn from_params(p: &serde_json::Value) -> SequenceOptions {
|
||||
let flag = |k: &str| p.get(k).and_then(serde_json::Value::as_bool);
|
||||
SequenceOptions { sequence: flag("sequence").unwrap_or(true), alphabetical: flag("alphabetical").unwrap_or(false) }
|
||||
}
|
||||
}
|
||||
|
||||
/// What one import unit makes: a file's footage, or an image sequence of files (in order).
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub enum Source {
|
||||
File(String),
|
||||
Sequence { files: Vec<String>, alphabetical: bool },
|
||||
}
|
||||
|
||||
impl Source {
|
||||
/// The path naming it (a sequence's first file).
|
||||
pub fn path(&self) -> &str {
|
||||
match self {
|
||||
Source::File(p) => p,
|
||||
Source::Sequence { files, .. } => files.first().map_or("", String::as_str),
|
||||
}
|
||||
}
|
||||
|
||||
/// The Project panel name: the file name, or a sequence's `shot_[0001-0250].png`.
|
||||
pub fn name(&self) -> String {
|
||||
match self {
|
||||
Source::Sequence { files, alphabetical: false } => sequence_name(files).unwrap_or_else(|| file_name(self.path())),
|
||||
_ => file_name(self.path()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The file name of a path (either separator).
|
||||
pub fn file_name(path: &str) -> String {
|
||||
path.rsplit(['/', '\\']).find(|n| !n.is_empty()).unwrap_or(path).to_string()
|
||||
}
|
||||
|
||||
/// The folder of a path (`""` for a bare name).
|
||||
fn folder(path: &str) -> &str {
|
||||
path.rfind(['/', '\\']).map_or("", |i| path.get(..i.max(1)).unwrap_or(""))
|
||||
}
|
||||
|
||||
fn extension(path: &str) -> String {
|
||||
let name = file_name(path);
|
||||
name.rsplit_once('.').map(|(_, e)| e.to_ascii_lowercase()).unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Stills that can form a sequence (not layered or vector documents, which import as comps).
|
||||
fn sequenceable(path: &str) -> bool {
|
||||
crate::media_browser::kind_of(path) == Some("image") && !matches!(extension(path).as_str(), "svg" | "psd" | "psb")
|
||||
}
|
||||
|
||||
/// The run a still belongs to: (folder, name prefix, extension); alphabetical runs ignore the
|
||||
/// name. `None` for files that can't be part of one.
|
||||
fn run_key(path: &str, alphabetical: bool) -> Option<(String, String, String)> {
|
||||
if !sequenceable(path) {
|
||||
return None;
|
||||
}
|
||||
let ext = extension(path);
|
||||
if alphabetical {
|
||||
return Some((folder(path).to_string(), String::new(), ext));
|
||||
}
|
||||
let name = file_name(path);
|
||||
let stem = name.get(..name.len().saturating_sub(ext.len() + usize::from(!ext.is_empty()))).unwrap_or("");
|
||||
let (prefix, _) = split_frame_number(stem)?;
|
||||
Some((folder(path).to_string(), prefix.to_string(), ext))
|
||||
}
|
||||
|
||||
/// Put a run's files in order: by frame number, or by name (Force Alphabetical Order).
|
||||
fn sort_run(files: &mut Vec<String>, alphabetical: bool) {
|
||||
if alphabetical {
|
||||
files.sort_by_key(|p| file_name(p).to_lowercase());
|
||||
} else {
|
||||
files.sort_by_key(|p| (frame_number(p), p.clone()));
|
||||
}
|
||||
files.dedup();
|
||||
}
|
||||
|
||||
/// The files of the run `path` belongs to, from its folder.
|
||||
fn run_files(services: &dyn Services, path: &str, alphabetical: bool) -> Vec<String> {
|
||||
let Some(key) = run_key(path, alphabetical) else { return vec![] };
|
||||
let mut files: Vec<String> = services.list_dir(&key.0).into_iter().filter(|p| run_key(p, alphabetical).as_ref() == Some(&key)).collect();
|
||||
sort_run(&mut files, alphabetical);
|
||||
files
|
||||
}
|
||||
|
||||
/// What importing `paths` makes, in the order picked: folders become their importable files,
|
||||
/// and (with the Sequence option) numbered stills of one run one [`Source::Sequence`].
|
||||
pub fn group(services: &dyn Services, paths: &[String], opts: SequenceOptions) -> Vec<Source> {
|
||||
let mut files: Vec<String> = vec![];
|
||||
for p in paths {
|
||||
if services.is_dir(p) {
|
||||
let mut inside: Vec<String> =
|
||||
services.list_dir(p).into_iter().filter(|f| matches!(crate::media_browser::kind_of(f), Some(k) if k != "project")).collect();
|
||||
inside.sort_by_key(|f| file_name(f).to_lowercase());
|
||||
files.extend(inside);
|
||||
} else {
|
||||
files.push(p.clone());
|
||||
}
|
||||
}
|
||||
if !opts.sequence {
|
||||
return files.into_iter().map(Source::File).collect();
|
||||
}
|
||||
// Runs in the order their first file was picked.
|
||||
let mut runs: Vec<((String, String, String), Vec<String>)> = vec![];
|
||||
let mut out: Vec<Option<Source>> = vec![];
|
||||
let mut slot: Vec<usize> = vec![];
|
||||
for f in files {
|
||||
match run_key(&f, opts.alphabetical) {
|
||||
Some(key) => match runs.iter_mut().find(|(k, _)| *k == key) {
|
||||
Some((_, run)) => run.push(f),
|
||||
None => {
|
||||
runs.push((key, vec![f]));
|
||||
slot.push(out.len());
|
||||
out.push(None);
|
||||
}
|
||||
},
|
||||
None => out.push(Some(Source::File(f))),
|
||||
}
|
||||
}
|
||||
for ((_, mut picked), at) in runs.into_iter().zip(slot) {
|
||||
sort_run(&mut picked, opts.alphabetical);
|
||||
// One file: its whole run from the folder.
|
||||
if let [one] = picked.as_slice() {
|
||||
let all = run_files(services, one, opts.alphabetical);
|
||||
if all.len() >= 2 {
|
||||
picked = all;
|
||||
}
|
||||
}
|
||||
let src = match picked.len() {
|
||||
0 => None,
|
||||
1 => picked.pop().map(Source::File),
|
||||
_ => Some(Source::Sequence { files: picked, alphabetical: opts.alphabetical }),
|
||||
};
|
||||
if let Some(o) = out.get_mut(at) {
|
||||
*o = src;
|
||||
}
|
||||
}
|
||||
out.into_iter().flatten().collect()
|
||||
}
|
||||
|
||||
/// The image sequences importing `paths` would make (the Import dialog asks about them).
|
||||
pub fn sequences(services: &dyn Services, paths: &[String], alphabetical: bool) -> Vec<Vec<String>> {
|
||||
group(services, paths, SequenceOptions { sequence: true, alphabetical })
|
||||
.into_iter()
|
||||
.filter_map(|s| match s {
|
||||
Source::Sequence { files, .. } => Some(files),
|
||||
Source::File(_) => None,
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Probe what a [`Source`] makes. A sequence is its first file's footage with every file, at
|
||||
/// `rate`, showing gaps in its numbering as placeholders (or closing them up when
|
||||
/// alphabetical).
|
||||
pub fn probe(importer: &dyn Importer, src: &Source, rate: FrameRate) -> Result<Footage, String> {
|
||||
let Source::Sequence { files, alphabetical } = src else { return importer.probe(src.path()) };
|
||||
let mut f = importer.probe(src.path())?;
|
||||
if f.kind != FootageKind::Still {
|
||||
return Ok(f);
|
||||
}
|
||||
f.kind = FootageKind::Sequence;
|
||||
f.sequence = files.clone();
|
||||
f.missing_frames = if *alphabetical { MissingFrames::Skip } else { MissingFrames::Placeholder };
|
||||
f.start_frame = None;
|
||||
f.frame_rate = rate;
|
||||
f.native_rate = None;
|
||||
f.sync_sequence_duration();
|
||||
importer.add_frames(files.get(1..).unwrap_or_default());
|
||||
Ok(f)
|
||||
}
|
||||
|
||||
/// Probe one path the way it imports by default: a numbered still with numbered siblings
|
||||
/// brings its run as a sequence (Replace Footage, Reload, proxies, render outputs).
|
||||
pub fn probe_path(importer: &dyn Importer, services: &dyn Services, path: &str, rate: FrameRate) -> Result<Footage, String> {
|
||||
probe(importer, &source_of(services, path, SequenceOptions::default()), rate)
|
||||
}
|
||||
|
||||
/// What importing the one file `path` makes.
|
||||
pub fn source_of(services: &dyn Services, path: &str, opts: SequenceOptions) -> Source {
|
||||
group(services, &[path.to_string()], opts).into_iter().next().unwrap_or_else(|| Source::File(path.to_string()))
|
||||
}
|
||||
|
||||
impl crate::Session {
|
||||
/// Image sequences' frame rate on import: Settings ▸ Import ▸ Sequence Footage.
|
||||
pub fn sequence_rate(&self) -> FrameRate {
|
||||
FrameRate::from_f64(self.prefs.import.sequence_fps)
|
||||
}
|
||||
|
||||
/// Probe `path` as footage the way it imports by default ([`probe_path`]).
|
||||
pub(crate) fn probe_footage(&self, path: &str) -> Result<Footage, String> {
|
||||
let importer = self.importer.as_ref().ok_or("media import is not available in this build")?;
|
||||
probe_path(importer.as_ref(), self.services.as_ref(), path, self.sequence_rate())
|
||||
}
|
||||
}
|
||||
|
||||
/// Settings ▸ Import ▸ Report Missing Frames: "3 missing frames (3–4, 6)". `None` when the
|
||||
/// numbering is continuous.
|
||||
pub fn missing_report(files: &[String]) -> Option<String> {
|
||||
let gaps = missing_frame_ranges(files);
|
||||
let total: i64 = gaps.iter().map(|(a, b)| b.saturating_sub(*a).saturating_add(1)).fold(0, i64::saturating_add);
|
||||
let list: Vec<String> = gaps.iter().map(|(a, b)| if a == b { a.to_string() } else { format!("{a}–{b}") }).collect();
|
||||
(!gaps.is_empty()).then(|| format!("{total} missing frame{} ({})", if total == 1 { "" } else { "s" }, list.join(", ")))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// A file table: listed folders and their files.
|
||||
struct Table(Vec<&'static str>);
|
||||
impl Services for Table {
|
||||
fn read_file(&self, path: &str) -> std::io::Result<Vec<u8>> {
|
||||
Err(std::io::Error::other(path.to_string()))
|
||||
}
|
||||
fn write_file(&self, _: &str, _: &[u8]) -> std::io::Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
fn list_dir(&self, dir: &str) -> Vec<String> {
|
||||
self.0.iter().filter(|p| folder(p) == dir).map(|p| p.to_string()).collect()
|
||||
}
|
||||
fn is_dir(&self, path: &str) -> bool {
|
||||
self.0.iter().any(|p| folder(p) == path)
|
||||
}
|
||||
}
|
||||
|
||||
fn strs(v: &[&str]) -> Vec<String> {
|
||||
v.iter().map(|s| s.to_string()).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn groups_runs_ranges_folders_and_alphabetical() {
|
||||
let t = Table(vec!["/s/a_0002.png", "/s/a_0001.png", "/s/a_0010.png", "/s/b_1.png", "/s/notes.txt", "/s/c.png", "/s/a_0003.jpg", "/x/one_1.png"]);
|
||||
let on = SequenceOptions::default();
|
||||
// One picked file: its run from the folder, in frame order.
|
||||
assert_eq!(
|
||||
group(&t, &strs(&["/s/a_0010.png"]), on),
|
||||
[Source::Sequence { files: strs(&["/s/a_0001.png", "/s/a_0002.png", "/s/a_0010.png"]), alphabetical: false }]
|
||||
);
|
||||
// A picked range stays that range; other files import as they are.
|
||||
assert_eq!(
|
||||
group(&t, &strs(&["/s/c.png", "/s/a_0010.png", "/s/a_0002.png", "/s/b_1.png", "/x/one_1.png"]), on),
|
||||
[
|
||||
Source::File("/s/c.png".into()),
|
||||
Source::Sequence { files: strs(&["/s/a_0002.png", "/s/a_0010.png"]), alphabetical: false },
|
||||
Source::File("/s/b_1.png".into()),
|
||||
Source::File("/x/one_1.png".into()),
|
||||
]
|
||||
);
|
||||
// Sequence off: every file on its own.
|
||||
assert_eq!(group(&t, &strs(&["/s/a_0001.png"]), SequenceOptions { sequence: false, alphabetical: false }), [Source::File("/s/a_0001.png".into())]);
|
||||
// A folder: its media files, runs as sequences.
|
||||
let g = group(&t, &strs(&["/s"]), on);
|
||||
assert_eq!(g.len(), 4, "{g:?}");
|
||||
assert_eq!(g[0], Source::Sequence { files: strs(&["/s/a_0001.png", "/s/a_0002.png", "/s/a_0010.png"]), alphabetical: false });
|
||||
assert_eq!(g[0].name(), "a_[0001-0010].png");
|
||||
assert!(!g.iter().any(|s| s.path().ends_with(".txt")));
|
||||
// Force Alphabetical Order: every PNG in the folder, by name.
|
||||
let a = group(&t, &strs(&["/s/c.png"]), SequenceOptions { sequence: true, alphabetical: true });
|
||||
assert_eq!(a, [Source::Sequence { files: strs(&["/s/a_0001.png", "/s/a_0002.png", "/s/a_0010.png", "/s/b_1.png", "/s/c.png"]), alphabetical: true }]);
|
||||
assert_eq!(a[0].name(), "a_0001.png");
|
||||
assert_eq!(sequences(&t, &strs(&["/s/a_0001.png", "/s/c.png"]), false).len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn windows_paths_and_reports() {
|
||||
let t = Table(vec!["C:\\r\\f_01.exr", "C:\\r\\f_02.exr", "C:\\r\\f_05.exr"]);
|
||||
let g = group(&t, &strs(&["C:\\r\\f_02.exr"]), SequenceOptions::default());
|
||||
assert_eq!(g, [Source::Sequence { files: strs(&["C:\\r\\f_01.exr", "C:\\r\\f_02.exr", "C:\\r\\f_05.exr"]), alphabetical: false }]);
|
||||
assert_eq!(missing_report(&strs(&["a_001.png", "a_002.png", "a_005.png", "a_007.png"])).as_deref(), Some("3 missing frames (3–4, 6)"));
|
||||
assert_eq!(missing_report(&strs(&["a1.png", "a2.png"])), None);
|
||||
assert_eq!(folder("/x.png"), "/");
|
||||
assert_eq!(file_name("C:\\r\\f_01.exr"), "f_01.exr");
|
||||
}
|
||||
}
|
||||
|
|
@ -237,10 +237,10 @@ fn import_export_helpers() {
|
|||
p.import.drag_import_as = "compLayerSizes".into();
|
||||
assert_eq!(p.drag_import_as(), "compositionLayerSizes");
|
||||
assert_eq!(
|
||||
super::commands::missing_frames_for_tests(&["a_001.png".into(), "a_002.png".into(), "a_005.png".into(), "a_007.png".into()]).unwrap(),
|
||||
crate::sequence::missing_report(&["a_001.png".into(), "a_002.png".into(), "a_005.png".into(), "a_007.png".into()]).unwrap(),
|
||||
"3 missing frames (3–4, 6)"
|
||||
);
|
||||
assert!(super::commands::missing_frames_for_tests(&["a1.png".into(), "a2.png".into()]).is_none());
|
||||
assert!(crate::sequence::missing_report(&["a1.png".into(), "a2.png".into()]).is_none());
|
||||
// Append Bit Depth to File Name.
|
||||
assert_eq!(p.output_name("/out/Comp 1.png", 16), "/out/Comp 1.png");
|
||||
p.export.append_bits_to_name = true;
|
||||
|
|
|
|||
|
|
@ -0,0 +1,179 @@
|
|||
//! Image sequences (#297): File ▸ Import with the "<format> Sequence" option, picked ranges,
|
||||
//! folders, Force Alphabetical Order, the import frame rate, Replace Footage, and Interpret
|
||||
//! Footage ▸ Missing Frames / Start Frame.
|
||||
|
||||
use effectcraft_project::{Footage, FootageKind, ItemId, ItemKind, MissingFrames};
|
||||
use effectcraft_time::{FrameRate, Tick};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::Session;
|
||||
|
||||
/// Probes every file as a 64×32 still (the media layer's single-file probe).
|
||||
struct Stills;
|
||||
impl crate::Importer for Stills {
|
||||
fn probe(&self, path: &str) -> Result<Footage, String> {
|
||||
if !std::path::Path::new(path).is_file() {
|
||||
return Err(format!("{path}: no such file"));
|
||||
}
|
||||
Ok(Footage { path: path.into(), kind: FootageKind::Still, width: 64, height: 32, has_video: true, codec: "PNG".into(), ..Default::default() })
|
||||
}
|
||||
}
|
||||
|
||||
/// A fresh folder with empty files of these names; returns its path.
|
||||
fn folder(names: &[&str]) -> String {
|
||||
use std::sync::atomic::{AtomicU32, Ordering};
|
||||
static N: AtomicU32 = AtomicU32::new(0);
|
||||
let d = std::env::temp_dir().join(format!("ec-seq-{}-{}", std::process::id(), N.fetch_add(1, Ordering::Relaxed)));
|
||||
let _ = std::fs::remove_dir_all(&d);
|
||||
std::fs::create_dir_all(&d).unwrap();
|
||||
for n in names {
|
||||
std::fs::write(d.join(n), b"").unwrap();
|
||||
}
|
||||
d.to_string_lossy().to_string()
|
||||
}
|
||||
|
||||
fn at(dir: &str, name: &str) -> String {
|
||||
std::path::Path::new(dir).join(name).to_string_lossy().to_string()
|
||||
}
|
||||
|
||||
fn session() -> Session {
|
||||
let mut s = Session { importer: Some(std::sync::Arc::new(Stills)), ..Default::default() };
|
||||
s.prefs.import.sequence_fps = 30.0;
|
||||
s
|
||||
}
|
||||
|
||||
fn footage(s: &Session, item: &Value) -> (String, Footage) {
|
||||
let it = s.project.item(ItemId(item.as_u64().unwrap())).unwrap();
|
||||
let ItemKind::Footage(f) = &it.kind else { panic!("footage") };
|
||||
(it.name.clone(), f.clone())
|
||||
}
|
||||
|
||||
const RUN: [&str; 5] = ["shot_0001.png", "shot_0002.png", "shot_0004.png", "shot_0005.png", "notes.txt"];
|
||||
|
||||
#[test]
|
||||
fn one_picked_numbered_file_imports_its_run_as_one_sequence() {
|
||||
let dir = folder(&RUN);
|
||||
let mut s = session();
|
||||
let r = s.execute("file.import", json!({"paths": [at(&dir, "shot_0002.png")]})).unwrap();
|
||||
assert_eq!(r["items"].as_array().unwrap().len(), 1, "{r}");
|
||||
let (name, f) = footage(&s, &r["items"][0]);
|
||||
assert_eq!(name, "shot_[0001-0005].png");
|
||||
assert_eq!((f.kind, f.sequence.len(), f.missing_frames), (FootageKind::Sequence, 4, MissingFrames::Placeholder));
|
||||
assert_eq!(f.path, at(&dir, "shot_0001.png"));
|
||||
// Frames 1–5 at Settings ▸ Import ▸ Sequence Footage (30 fps); frame 3 is a placeholder.
|
||||
assert_eq!(f.duration, FrameRate::FPS_30.tick_of(5));
|
||||
assert_eq!(f.sequence_file(2), None);
|
||||
// Settings ▸ Import ▸ Report Missing Frames.
|
||||
let w = r["warnings"][0].as_str().unwrap();
|
||||
assert!(w.contains("1 missing frame (3)"), "{w}");
|
||||
}
|
||||
|
||||
/// Picking every frame of a sequence used to make one item per frame (each a whole sequence on
|
||||
/// the desktop, a still in the browser).
|
||||
#[test]
|
||||
fn picking_every_frame_imports_one_sequence() {
|
||||
let dir = folder(&RUN);
|
||||
let mut s = session();
|
||||
let all: Vec<String> = RUN[..4].iter().map(|n| at(&dir, n)).collect();
|
||||
let r = s.execute("file.import", json!({"paths": all, "frameRate": 24})).unwrap();
|
||||
assert_eq!(r["items"].as_array().unwrap().len(), 1, "{r}");
|
||||
let (_, f) = footage(&s, &r["items"][0]);
|
||||
assert_eq!(f.sequence.len(), 4);
|
||||
// `frameRate` sets the sequence's rate on import.
|
||||
assert_eq!((f.frame_rate, f.duration), (FrameRate::FPS_24, FrameRate::FPS_24.tick_of(5)));
|
||||
// A picked range is just that range.
|
||||
let r = s.execute("file.import", json!({"paths": [at(&dir, "shot_0004.png"), at(&dir, "shot_0005.png")]})).unwrap();
|
||||
let (name, f) = footage(&s, &r["items"][0]);
|
||||
assert_eq!((name.as_str(), f.sequence.len()), ("shot_[0004-0005].png", 2));
|
||||
assert!(s.execute("file.import", json!({"paths": all, "frameRate": 0})).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_sequence_option_off_imports_stills() {
|
||||
let dir = folder(&RUN);
|
||||
let mut s = session();
|
||||
let r = s.execute("file.import", json!({"paths": [at(&dir, "shot_0002.png")], "sequence": false})).unwrap();
|
||||
let (name, f) = footage(&s, &r["items"][0]);
|
||||
assert_eq!((name.as_str(), f.kind), ("shot_0002.png", FootageKind::Still));
|
||||
let r = s.execute("file.import", json!({"paths": [at(&dir, "shot_0001.png"), at(&dir, "shot_0002.png")], "sequence": false})).unwrap();
|
||||
assert_eq!(r["items"].as_array().unwrap().len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_folder_imports_its_files_with_runs_as_sequences() {
|
||||
let dir = folder(&["a_01.png", "a_02.png", "b.png", "readme.txt"]);
|
||||
let mut s = session();
|
||||
let r = s.execute("file.import", json!({"paths": [dir], "background": true})).unwrap();
|
||||
s.execute("jobs.wait", json!({})).unwrap();
|
||||
assert!(r["job"].is_u64() || r["job"].is_string(), "{r}");
|
||||
let names: Vec<String> = s.project.items.values().map(|i| i.name.clone()).collect();
|
||||
assert_eq!(names, ["a_[01-02].png", "b.png"], "the text file is skipped");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn force_alphabetical_order_takes_every_image_of_the_type() {
|
||||
let dir = folder(&["take_b.png", "take_a.png", "other_7.png", "c.jpg"]);
|
||||
let mut s = session();
|
||||
let r = s.execute("file.import", json!({"paths": [at(&dir, "take_a.png")], "alphabetical": true})).unwrap();
|
||||
let (name, f) = footage(&s, &r["items"][0]);
|
||||
let files: Vec<String> = f.sequence.iter().map(|p| crate::sequence::file_name(p)).collect();
|
||||
assert_eq!(files, ["other_7.png", "take_a.png", "take_b.png"]);
|
||||
assert_eq!((name.as_str(), f.missing_frames), ("other_7.png", MissingFrames::Skip));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn interpret_footage_sets_missing_frames_and_start_frame() {
|
||||
let dir = folder(&RUN);
|
||||
let mut s = session();
|
||||
let item = s.execute("file.import", json!({"paths": [at(&dir, "shot_0001.png")]})).unwrap()["items"][0].clone();
|
||||
s.execute("comp.new", json!({"name": "Main", "width": 64, "height": 32, "frameRate": 30, "duration": 10})).unwrap();
|
||||
let layer = s.execute("layer.addItem", json!({"item": item})).unwrap()["layer"].as_u64().unwrap();
|
||||
let out = |s: &Session| s.active_comp().unwrap().layer(effectcraft_project::LayerId(layer)).unwrap().out_point;
|
||||
assert_eq!(out(&s), FrameRate::FPS_30.tick_of(5));
|
||||
// Hold: the gap shows frame 2; the length stays 5 frames.
|
||||
let r = s.execute("file.interpretFootage", json!({"items": [item], "missingFrames": "hold"})).unwrap();
|
||||
assert_eq!((r["items"][0]["missingFrames"].as_str(), r["items"][0]["frames"].as_i64()), (Some("hold"), Some(5)));
|
||||
assert_eq!(footage(&s, &item).1.sequence_file(2).map(crate::sequence::file_name).as_deref(), Some("shot_0002.png"));
|
||||
// Skip: 4 frames, and the layer that ran to the end still does.
|
||||
s.execute("file.interpretFootage", json!({"items": [item], "missingFrames": "skip"})).unwrap();
|
||||
assert_eq!((footage(&s, &item).1.duration, out(&s)), (FrameRate::FPS_30.tick_of(4), FrameRate::FPS_30.tick_of(4)));
|
||||
// Start Frame 0 with placeholders: frame 0 is missing, 6 frames in all.
|
||||
let r = s.execute("file.interpretFootage", json!({"items": [item], "missingFrames": "placeholder", "startFrame": 0})).unwrap();
|
||||
assert_eq!((r["items"][0]["startFrame"].as_i64(), r["items"][0]["frames"].as_i64()), (Some(0), Some(6)));
|
||||
assert_eq!(footage(&s, &item).1.sequence_file(0), None);
|
||||
// The first file's own number isn't stored; "file" goes back to it.
|
||||
s.execute("file.interpretFootage", json!({"items": [item], "startFrame": 1})).unwrap();
|
||||
assert_eq!(footage(&s, &item).1.start_frame, None);
|
||||
s.execute("file.interpretFootage", json!({"items": [item], "startFrame": 3})).unwrap();
|
||||
s.execute("file.interpretFootage", json!({"items": [item], "startFrame": "file"})).unwrap();
|
||||
assert_eq!(footage(&s, &item).1.start_frame, None);
|
||||
// The frame rate re-times the frames.
|
||||
s.execute("file.interpretFootage", json!({"items": [item], "frameRate": 10})).unwrap();
|
||||
assert_eq!(footage(&s, &item).1.duration, Tick::from_seconds_f64(0.5));
|
||||
for bad in [json!({"missingFrames": "blank"}), json!({"startFrame": "x"}), json!({"startFrame": 1e300})] {
|
||||
let mut p = bad.clone();
|
||||
p["items"] = json!([item]);
|
||||
assert!(s.execute("file.interpretFootage", p).is_err(), "{bad}");
|
||||
}
|
||||
// Undo puts the interpretation back.
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
assert_eq!(footage(&s, &item).1.frame_rate, FrameRate::FPS_30);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn replace_footage_and_reload_keep_sequences() {
|
||||
let dir = folder(&RUN);
|
||||
let mut s = session();
|
||||
let item = s.execute("file.import", json!({"paths": [at(&dir, "shot_0001.png")], "sequence": false})).unwrap()["items"][0].clone();
|
||||
s.state.project_selection = vec![ItemId(item.as_u64().unwrap())];
|
||||
s.execute("file.replaceFootage", json!({"path": at(&dir, "shot_0004.png")})).unwrap();
|
||||
let (name, f) = footage(&s, &item);
|
||||
assert_eq!((name.as_str(), f.sequence.len()), ("shot_[0001-0005].png", 4));
|
||||
// Reload finds a new frame and keeps the interpretation.
|
||||
s.execute("file.interpretFootage", json!({"items": [item], "frameRate": 12, "missingFrames": "hold"})).unwrap();
|
||||
std::fs::write(at(&dir, "shot_0003.png"), b"").unwrap();
|
||||
s.execute("file.reloadFootage", json!({"items": [item]})).unwrap();
|
||||
let (_, f) = footage(&s, &item);
|
||||
assert_eq!((f.sequence.len(), f.frame_rate, f.missing_frames), (5, FrameRate::from_f64(12.0), MissingFrames::Hold));
|
||||
assert_eq!(f.duration, FrameRate::from_f64(12.0).tick_of(5));
|
||||
}
|
||||
|
|
@ -23,7 +23,7 @@ mod pool;
|
|||
mod probe;
|
||||
|
||||
pub use pool::{DEFAULT_BUDGET, MediaPool, PoolStats};
|
||||
pub use probe::{DEFAULT_SEQUENCE_RATE, probe, probe_bytes, probe_model, probe_single, sequence_files};
|
||||
pub use probe::{DEFAULT_SEQUENCE_RATE, probe, probe_bytes, probe_model};
|
||||
|
||||
/// Errors from probing or decoding footage.
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
|
|
|
|||
|
|
@ -16,6 +16,23 @@ use crate::{MediaError, Result};
|
|||
/// Default frame-cache budget: 1 GiB of decoded frames.
|
||||
pub const DEFAULT_BUDGET: usize = 1 << 30;
|
||||
|
||||
/// The cache path of a missing image-sequence frame (no file can have this name).
|
||||
const PLACEHOLDER: &str = "\0missing frame";
|
||||
|
||||
/// What a missing image-sequence frame shows: 75 % colour bars (white, yellow, cyan, green,
|
||||
/// magenta, red, blue), the usual "no picture here" frame.
|
||||
fn colour_bars(w: u32, h: u32) -> Image {
|
||||
const BARS: [[f32; 3]; 7] = [[1.0, 1.0, 1.0], [1.0, 1.0, 0.0], [0.0, 1.0, 1.0], [0.0, 1.0, 0.0], [1.0, 0.0, 1.0], [1.0, 0.0, 0.0], [0.0, 0.0, 1.0]];
|
||||
let (w, h) = (w.clamp(1, 16_384), h.clamp(1, 16_384));
|
||||
let row: Vec<Px> = (0..w)
|
||||
.map(|x| {
|
||||
let c = BARS.get((x as usize * BARS.len()) / w as usize).copied().unwrap_or([0.0; 3]);
|
||||
[c[0] * 0.75, c[1] * 0.75, c[2] * 0.75, 1.0]
|
||||
})
|
||||
.collect();
|
||||
Image { width: w, height: h, data: row.repeat(h as usize) }
|
||||
}
|
||||
|
||||
#[derive(Clone, PartialEq, Eq, Hash)]
|
||||
struct Key {
|
||||
path: Arc<str>,
|
||||
|
|
@ -503,8 +520,12 @@ impl Inner {
|
|||
let key = |path: &str, frame| Key { path: path.into(), frame, alpha, matte };
|
||||
match footage.kind {
|
||||
FootageKind::Sequence if !footage.sequence.is_empty() => {
|
||||
let i = Self::frame_index(footage.frame_rate, t, footage.sequence.len() as i64, footage.loop_count);
|
||||
Loc { key: key(&footage.sequence[i as usize], 0), media_t: None }
|
||||
let i = Self::frame_index(footage.frame_rate, t, footage.sequence_frames(), footage.loop_count);
|
||||
match footage.sequence_file(i) {
|
||||
Some(path) => Loc { key: key(path, 0), media_t: None },
|
||||
// A gap in the numbering (Missing Frames ▸ Show Placeholder).
|
||||
None => Loc { key: key(PLACEHOLDER, ((footage.width as i64) << 32) | footage.height as i64), media_t: None },
|
||||
}
|
||||
}
|
||||
// A layer of a layered still is keyed by its layer index and size mode (smart objects
|
||||
// by their embedded file; PDF pages by their number).
|
||||
|
|
@ -589,6 +610,7 @@ impl Inner {
|
|||
let op = AlphaOp::new(footage.alpha, footage.premul_color);
|
||||
let path = &loc.key.path;
|
||||
match loc.media_t {
|
||||
None if &**path == PLACEHOLDER => Ok(colour_bars(footage.width, footage.height)),
|
||||
None => {
|
||||
let bytes = self.read(path)?;
|
||||
if let Some(img) = crate::layered::decode(path, &bytes, footage, op)? {
|
||||
|
|
|
|||
|
|
@ -1,31 +1,19 @@
|
|||
//! Probing: describe a file as an [`effectcraft_project::Footage`].
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::path::Path;
|
||||
use std::sync::Arc;
|
||||
|
||||
use effectcraft_project::{AlphaMode, Footage, FootageKind};
|
||||
use effectcraft_time::{FrameRate, Tick};
|
||||
|
||||
use crate::{MediaError, Result, STILL_EXTENSIONS, ext_of, rate_from_fc};
|
||||
use crate::{MediaError, Result, ext_of, rate_from_fc};
|
||||
|
||||
/// Frame rate given to image sequences on import (After Effects' default preference).
|
||||
pub const DEFAULT_SEQUENCE_RATE: FrameRate = FrameRate::FPS_30;
|
||||
|
||||
/// Probe a file. Stills whose name ends in a frame number (`shot_0001.png`) with at least one
|
||||
/// numbered sibling are imported as an image sequence; use [`probe_single`] to import one frame.
|
||||
/// Probe one file. Image sequences are made by the engine, which groups numbered stills
|
||||
/// (`effectcraft_engine::sequence`) and probes their first frame here.
|
||||
pub fn probe(path: impl AsRef<Path>) -> Result<Footage> {
|
||||
let path = path.as_ref();
|
||||
if STILL_EXTENSIONS.contains(&ext_of(path).as_str()) {
|
||||
let files = sequence_files(path);
|
||||
if files.len() >= 2 {
|
||||
return probe_sequence(&files);
|
||||
}
|
||||
}
|
||||
probe_single(path)
|
||||
}
|
||||
|
||||
/// Probe a file without image-sequence detection.
|
||||
pub fn probe_single(path: impl AsRef<Path>) -> Result<Footage> {
|
||||
let path = path.as_ref();
|
||||
let bytes = std::fs::read(path).map_err(|e| MediaError::Io(format!("{}: {e}", path.display())))?;
|
||||
if crate::MODEL_EXTENSIONS.contains(&ext_of(path).as_str()) {
|
||||
|
|
@ -176,59 +164,3 @@ fn probe_still_bytes(path: &str, bytes: &[u8], fmt: image::ImageFormat) -> Resul
|
|||
};
|
||||
Ok(still_footage(path, w, h, fmt, has_alpha))
|
||||
}
|
||||
|
||||
fn probe_sequence(files: &[PathBuf]) -> Result<Footage> {
|
||||
let first = &files[0];
|
||||
let mut f = probe_single(first)?;
|
||||
f.kind = FootageKind::Sequence;
|
||||
f.frame_rate = DEFAULT_SEQUENCE_RATE;
|
||||
f.duration = DEFAULT_SEQUENCE_RATE.tick_of(files.len() as i64);
|
||||
f.sequence = files.iter().map(|p| p.to_string_lossy().to_string()).collect();
|
||||
Ok(f)
|
||||
}
|
||||
|
||||
/// Split a file stem into (prefix, frame number digits). `None` when it does not end in digits.
|
||||
fn split_number(stem: &str) -> Option<(&str, &str)> {
|
||||
let digits = stem.bytes().rev().take_while(u8::is_ascii_digit).count();
|
||||
(digits > 0).then(|| stem.split_at(stem.len() - digits))
|
||||
}
|
||||
|
||||
/// The numbered files of the image sequence `path` belongs to (sorted by frame number; empty when
|
||||
/// the name has no frame number). Siblings must share the prefix and extension.
|
||||
pub fn sequence_files(path: impl AsRef<Path>) -> Vec<PathBuf> {
|
||||
let path = path.as_ref();
|
||||
let ext = ext_of(path);
|
||||
let Some(stem) = path.file_stem().and_then(|s| s.to_str()) else { return Vec::new() };
|
||||
let Some((prefix, _)) = split_number(stem) else { return Vec::new() };
|
||||
let dir = path.parent().filter(|d| !d.as_os_str().is_empty()).unwrap_or(Path::new("."));
|
||||
let Ok(rd) = std::fs::read_dir(dir) else { return Vec::new() };
|
||||
let mut out: Vec<(u64, PathBuf)> = rd
|
||||
.filter_map(|e| e.ok())
|
||||
.filter_map(|e| {
|
||||
let p = e.path();
|
||||
if ext_of(&p) != ext || !p.is_file() {
|
||||
return None;
|
||||
}
|
||||
let s = p.file_stem()?.to_str()?;
|
||||
let (pre, num) = split_number(s)?;
|
||||
if pre != prefix {
|
||||
return None;
|
||||
}
|
||||
Some((num.parse().ok()?, p))
|
||||
})
|
||||
.collect();
|
||||
out.sort();
|
||||
out.into_iter().map(|(_, p)| p).collect()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn split_numbers() {
|
||||
assert_eq!(split_number("shot_0012"), Some(("shot_", "0012")));
|
||||
assert_eq!(split_number("shot"), None);
|
||||
assert_eq!(split_number("v2_10"), Some(("v2_", "10")));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,8 +9,8 @@ use std::process::Command;
|
|||
use std::sync::Arc;
|
||||
use std::time::Instant;
|
||||
|
||||
use effectcraft_media::{MediaPool, probe, probe_single};
|
||||
use effectcraft_project::{AlphaMode, FootageKind, ItemId};
|
||||
use effectcraft_media::{MediaPool, probe};
|
||||
use effectcraft_project::{AlphaMode, Footage, FootageKind, ItemId, MissingFrames};
|
||||
use effectcraft_raster::Image;
|
||||
use effectcraft_render::FootageSource;
|
||||
use effectcraft_time::{FrameRate, TICKS_PER_SECOND, Tick};
|
||||
|
|
@ -359,9 +359,12 @@ fn vp9_webm_frames() {
|
|||
#[test]
|
||||
fn png_sequence_import() {
|
||||
let Some(first) = png_sequence() else { return };
|
||||
let f = probe(&first).expect("probe");
|
||||
assert_eq!(f.kind, FootageKind::Sequence);
|
||||
assert_eq!(f.sequence.len(), 10);
|
||||
// One file probes as a still; the engine groups numbered files into the sequence.
|
||||
let mut f = probe(&first).expect("probe");
|
||||
assert_eq!(f.kind, FootageKind::Still);
|
||||
f.kind = FootageKind::Sequence;
|
||||
f.sequence = (1..=10).map(|n| first.with_file_name(format!("seq_{n:04}.png")).to_string_lossy().to_string()).collect();
|
||||
f.sync_sequence_duration();
|
||||
assert_eq!((f.width, f.height), (320, 240));
|
||||
assert_eq!(f.duration, FrameRate::FPS_30.tick_of(10));
|
||||
let pool = MediaPool::new();
|
||||
|
|
@ -374,9 +377,43 @@ fn png_sequence_import() {
|
|||
looped.loop_count = 2;
|
||||
let img = pool.frame_at(&looped, t_of(f.frame_rate, 13)).expect("frame");
|
||||
assert_eq!(*img, load_png(&first.with_file_name("seq_0004.png")));
|
||||
// a single frame of it
|
||||
let one = probe_single(&first).expect("probe");
|
||||
assert_eq!(one.kind, FootageKind::Still);
|
||||
}
|
||||
|
||||
/// A gap in a sequence's numbering shows colour bars (Missing Frames ▸ Show Placeholder), the
|
||||
/// frame before it (Hold) or nothing at all (Skip: the files play back to back) (#297).
|
||||
#[test]
|
||||
fn sequence_gaps_show_placeholders_hold_or_close_up() {
|
||||
let dir = fixtures().join(format!("gap-seq-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).expect("mkdir");
|
||||
// Frames 1, 2 and 4: frame 3 is missing. Each frame is a flat grey of its number.
|
||||
let grey = |n: u8| image::RgbaImage::from_pixel(8, 4, image::Rgba([n * 50, n * 50, n * 50, 255]));
|
||||
let mut files = vec![];
|
||||
for n in [1u8, 2, 4] {
|
||||
let p = dir.join(format!("gap_{n:03}.png"));
|
||||
grey(n).save(&p).expect("write png");
|
||||
files.push(p.to_string_lossy().to_string());
|
||||
}
|
||||
let mut f =
|
||||
Footage { kind: FootageKind::Sequence, sequence: files, missing_frames: MissingFrames::Placeholder, ..probe(dir.join("gap_001.png")).expect("probe") };
|
||||
f.sync_sequence_duration();
|
||||
assert_eq!(f.duration, f.frame_rate.tick_of(4), "frames 1-4");
|
||||
let pool = MediaPool::new();
|
||||
let at = |f: &Footage, n: i64| pool.frame_at(f, t_of(f.frame_rate, n)).expect("frame");
|
||||
let level = |img: &Image| (img.get(0, 0)[0] * 255.0).round() as u32;
|
||||
assert_eq!(level(&at(&f, 1)), 100);
|
||||
// The placeholder: colour bars across the frame, starting with 75 % white.
|
||||
let bars = at(&f, 2);
|
||||
assert_eq!((bars.width, bars.height), (8, 4));
|
||||
assert_eq!(bars.get(0, 0), [0.75, 0.75, 0.75, 1.0]);
|
||||
assert_eq!(bars.get(7, 3), [0.0, 0.0, 0.75, 1.0], "blue on the right");
|
||||
assert_eq!(level(&at(&f, 3)), 200);
|
||||
f.missing_frames = MissingFrames::Hold;
|
||||
assert_eq!(level(&at(&f, 2)), 100, "holds frame 2");
|
||||
f.missing_frames = MissingFrames::Skip;
|
||||
f.sync_sequence_duration();
|
||||
assert_eq!(f.duration, f.frame_rate.tick_of(3));
|
||||
assert_eq!(level(&at(&f, 2)), 200, "frame 4 follows frame 2");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ pub mod essential;
|
|||
pub mod props;
|
||||
pub mod render_queue;
|
||||
pub mod render_templates;
|
||||
pub mod sequence;
|
||||
pub mod styles;
|
||||
pub mod tracking;
|
||||
|
||||
|
|
@ -21,6 +22,7 @@ pub use effectcraft_color::ColorSpace;
|
|||
use effectcraft_color::{BlendMode, Label};
|
||||
use effectcraft_time::{FrameRate, Tick};
|
||||
pub use props::{Expression, FeatherFalloff, GroupKind, MaskMode, MaskMotionBlur, Node, ParamUi, PropGroup, Property, Uid, parse_path};
|
||||
pub use sequence::MissingFrames;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
pub use effectcraft_keyframe as keyframe;
|
||||
|
|
@ -963,6 +965,13 @@ pub struct Footage {
|
|||
/// PDF / Illustrator footage: the page shown (0-based; File ▸ Import ▸ Page).
|
||||
#[serde(default, skip_serializing_if = "is_default")]
|
||||
pub page: u32,
|
||||
/// Image sequences: Interpret Footage ▸ Missing Frames ([`sequence`]).
|
||||
#[serde(default, skip_serializing_if = "is_default")]
|
||||
pub missing_frames: MissingFrames,
|
||||
/// Image sequences: Interpret Footage ▸ Start Frame, the frame number at the footage's first
|
||||
/// frame (`None` = the first file's).
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub start_frame: Option<i64>,
|
||||
}
|
||||
|
||||
fn is_default<T: Default + PartialEq>(v: &T) -> bool {
|
||||
|
|
@ -995,6 +1004,8 @@ impl Default for Footage {
|
|||
data: None,
|
||||
layer: None,
|
||||
page: 0,
|
||||
missing_frames: MissingFrames::Skip,
|
||||
start_frame: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,253 @@
|
|||
//! Image sequences: numbered stills (`shot_0001.png`, `shot_0002.png`, …) played as one footage
|
||||
//! item ([`FootageKind::Sequence`]).
|
||||
//!
|
||||
//! A sequence's files are kept in frame-number order ([`Footage::sequence`]). How its frames map
|
||||
//! to the files follows Interpret Footage: **Start Frame** ([`Footage::start_frame`]) is the
|
||||
//! frame number shown at the footage's first frame, and **Missing Frames**
|
||||
//! ([`Footage::missing_frames`]) says what a gap in the numbering shows.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::{Footage, FootageKind};
|
||||
|
||||
/// The most frames an image sequence's numbering can span (a gap of millions of frames is a
|
||||
/// naming accident, such as dates in file names, not footage).
|
||||
pub const MAX_SEQUENCE_FRAMES: i64 = 10_000_000;
|
||||
|
||||
/// Interpret Footage ▸ Missing Frames: what an image sequence shows where its numbering skips a
|
||||
/// number.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
pub enum MissingFrames {
|
||||
/// The files play one after another and the gaps close up (and a sequence imported with
|
||||
/// Force Alphabetical Order, whose numbering doesn't count).
|
||||
#[default]
|
||||
Skip,
|
||||
/// A missing frame shows colour bars, as in After Effects.
|
||||
Placeholder,
|
||||
/// A missing frame shows the frame before it.
|
||||
Hold,
|
||||
}
|
||||
|
||||
impl MissingFrames {
|
||||
pub const ALL: [MissingFrames; 3] = [MissingFrames::Placeholder, MissingFrames::Hold, MissingFrames::Skip];
|
||||
|
||||
/// The command parameter value.
|
||||
pub fn id(self) -> &'static str {
|
||||
match self {
|
||||
MissingFrames::Skip => "skip",
|
||||
MissingFrames::Placeholder => "placeholder",
|
||||
MissingFrames::Hold => "hold",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn label(self) -> &'static str {
|
||||
match self {
|
||||
MissingFrames::Skip => "Skip (close the gaps)",
|
||||
MissingFrames::Placeholder => "Show Placeholder (color bars)",
|
||||
MissingFrames::Hold => "Hold Previous Frame",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn parse(s: &str) -> Option<MissingFrames> {
|
||||
MissingFrames::ALL.into_iter().find(|m| m.id().eq_ignore_ascii_case(s.trim()))
|
||||
}
|
||||
}
|
||||
|
||||
/// Split a file stem into (prefix, frame number digits): `shot_0012` → (`shot_`, `0012`).
|
||||
/// `None` when it doesn't end in digits.
|
||||
pub fn split_frame_number(stem: &str) -> Option<(&str, &str)> {
|
||||
let digits = stem.bytes().rev().take_while(u8::is_ascii_digit).count();
|
||||
// ASCII digits are one byte each, so the split is on a char boundary.
|
||||
(digits > 0).then(|| stem.split_at(stem.len() - digits))
|
||||
}
|
||||
|
||||
/// The file stem of a path (either separator, so Windows paths split on any host).
|
||||
fn stem_of(path: &str) -> &str {
|
||||
let name = path.rsplit(['/', '\\']).next().unwrap_or(path);
|
||||
match name.rsplit_once('.') {
|
||||
Some((stem, _)) if !stem.is_empty() => stem,
|
||||
_ => name,
|
||||
}
|
||||
}
|
||||
|
||||
/// The frame number at the end of a file name (`shot_0012.png` → 12); `None` without one.
|
||||
pub fn frame_number(path: &str) -> Option<i64> {
|
||||
let (_, digits) = split_frame_number(stem_of(path))?;
|
||||
// Keep the last 15 digits: longer runs would overflow and aren't frame numbers anyway.
|
||||
digits.get(digits.len().saturating_sub(15)..)?.parse().ok()
|
||||
}
|
||||
|
||||
/// The After Effects name of a numbered sequence: `shot_[0001-0250].png`. `None` when the first
|
||||
/// file isn't numbered.
|
||||
pub fn sequence_name(files: &[String]) -> Option<String> {
|
||||
let (first, last) = (files.first()?, files.last()?);
|
||||
let name = |p: &str| p.rsplit(['/', '\\']).next().unwrap_or(p).to_string();
|
||||
let (prefix, a) = split_frame_number(stem_of(first))?;
|
||||
let (_, b) = split_frame_number(stem_of(last))?;
|
||||
let first_name = name(first);
|
||||
let ext = first_name.rsplit_once('.').map(|(_, e)| format!(".{e}")).unwrap_or_default();
|
||||
Some(format!("{prefix}[{a}-{b}]{ext}"))
|
||||
}
|
||||
|
||||
/// Frame numbers missing from a run of numbered files, as `(from, to)` ranges.
|
||||
pub fn missing_frame_ranges(files: &[String]) -> Vec<(i64, i64)> {
|
||||
let mut ns: Vec<i64> = files.iter().filter_map(|p| frame_number(p)).collect();
|
||||
ns.sort_unstable();
|
||||
ns.dedup();
|
||||
ns.windows(2).filter(|w| w[1].saturating_sub(w[0]) > 1).map(|w| (w[0].saturating_add(1), w[1].saturating_sub(1))).collect()
|
||||
}
|
||||
|
||||
impl Footage {
|
||||
/// Whether this is an image sequence with files.
|
||||
fn is_file_sequence(&self) -> bool {
|
||||
self.kind == FootageKind::Sequence && !self.sequence.is_empty()
|
||||
}
|
||||
|
||||
/// Frame number of the sequence's `i`th file.
|
||||
fn file_number(&self, i: usize) -> Option<i64> {
|
||||
self.sequence.get(i).and_then(|p| frame_number(p))
|
||||
}
|
||||
|
||||
/// The numbering is used: Placeholder / Hold and a numbered first file.
|
||||
fn numbered(&self) -> Option<i64> {
|
||||
(self.missing_frames != MissingFrames::Skip).then(|| self.file_number(0)).flatten()
|
||||
}
|
||||
|
||||
/// Index of the first file numbered `n` or later.
|
||||
fn first_file_from(&self, n: i64) -> usize {
|
||||
self.sequence.partition_point(|p| frame_number(p).is_some_and(|x| x < n))
|
||||
}
|
||||
|
||||
/// The frame number at the footage's first frame: Start Frame, or the first file's number
|
||||
/// (0 when unnumbered).
|
||||
pub fn first_frame_number(&self) -> i64 {
|
||||
self.start_frame.or_else(|| self.file_number(0)).unwrap_or(0)
|
||||
}
|
||||
|
||||
/// How many frames an image sequence has: the span of its numbering from Start Frame (with
|
||||
/// Placeholder / Hold), or its files from Start Frame on (Skip). At least 1; 0 for other
|
||||
/// footage.
|
||||
pub fn sequence_frames(&self) -> i64 {
|
||||
if !self.is_file_sequence() {
|
||||
return 0;
|
||||
}
|
||||
let n = match self.numbered() {
|
||||
Some(first) => {
|
||||
let last = self.sequence.len().checked_sub(1).and_then(|i| self.file_number(i)).unwrap_or(first);
|
||||
last.saturating_sub(self.start_frame.unwrap_or(first)).saturating_add(1)
|
||||
}
|
||||
None => {
|
||||
let skip = self.start_frame.map_or(0, |s| self.first_file_from(s));
|
||||
self.sequence.len().saturating_sub(skip) as i64
|
||||
}
|
||||
};
|
||||
n.clamp(1, MAX_SEQUENCE_FRAMES)
|
||||
}
|
||||
|
||||
/// The file an image sequence shows at its frame `i` (0-based, after looping). `None` for a
|
||||
/// missing frame shown as a placeholder (or when there are no files).
|
||||
pub fn sequence_file(&self, i: i64) -> Option<&str> {
|
||||
if !self.is_file_sequence() {
|
||||
return None;
|
||||
}
|
||||
let Some(first) = self.numbered() else {
|
||||
let skip = self.start_frame.map_or(0, |s| self.first_file_from(s));
|
||||
let k = usize::try_from(i).ok()?.saturating_add(skip);
|
||||
// Past the last file (Start Frame after it): the last file.
|
||||
return self.sequence.get(k).or(self.sequence.last()).map(String::as_str);
|
||||
};
|
||||
let n = self.start_frame.unwrap_or(first).saturating_add(i);
|
||||
let k = self.first_file_from(n);
|
||||
match self.sequence.get(k) {
|
||||
Some(p) if frame_number(p) == Some(n) => Some(p),
|
||||
_ if self.missing_frames == MissingFrames::Hold => self.sequence.get(k.saturating_sub(1)).map(String::as_str),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Set an image sequence's duration to its frames at its frame rate (after its files,
|
||||
/// frame rate, Missing Frames or Start Frame changed).
|
||||
pub fn sync_sequence_duration(&mut self) {
|
||||
if self.is_file_sequence() {
|
||||
self.duration = self.frame_rate.tick_of(self.sequence_frames());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn seq(numbers: &[i64], missing: MissingFrames) -> Footage {
|
||||
Footage {
|
||||
kind: FootageKind::Sequence,
|
||||
sequence: numbers.iter().map(|n| format!("C:\\shots\\a_{n:04}.png")).collect(),
|
||||
missing_frames: missing,
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn numbers_and_names() {
|
||||
assert_eq!(split_frame_number("shot_0012"), Some(("shot_", "0012")));
|
||||
assert_eq!(split_frame_number("shot"), None);
|
||||
assert_eq!(split_frame_number("v2_10"), Some(("v2_", "10")));
|
||||
assert_eq!(frame_number("/a/v2/shot_0012.exr"), Some(12));
|
||||
assert_eq!(frame_number("C:\\a.b\\shot.png"), None);
|
||||
assert_eq!(frame_number("/a/99999999999999999999999.png"), Some(999_999_999_999_999));
|
||||
let files: Vec<String> = ["/s/fx_0998.png", "/s/fx_1002.png"].map(String::from).to_vec();
|
||||
assert_eq!(sequence_name(&files).as_deref(), Some("fx_[0998-1002].png"));
|
||||
assert_eq!(sequence_name(&["/s/a.png".to_string()]), None);
|
||||
assert_eq!(MissingFrames::parse("Hold"), Some(MissingFrames::Hold));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_frames_map_frames_to_files() {
|
||||
// Files 1, 2, 5: frames 3 and 4 are missing.
|
||||
let f = seq(&[1, 2, 5], MissingFrames::Placeholder);
|
||||
assert_eq!(f.sequence_frames(), 5);
|
||||
let at = |f: &Footage, i| f.sequence_file(i).map(|p| frame_number(p).unwrap_or(-1));
|
||||
assert_eq!((0..5).map(|i| at(&f, i)).collect::<Vec<_>>(), [Some(1), Some(2), None, None, Some(5)]);
|
||||
let h = seq(&[1, 2, 5], MissingFrames::Hold);
|
||||
assert_eq!((0..5).map(|i| at(&h, i)).collect::<Vec<_>>(), [Some(1), Some(2), Some(2), Some(2), Some(5)]);
|
||||
let s = seq(&[1, 2, 5], MissingFrames::Skip);
|
||||
assert_eq!(s.sequence_frames(), 3);
|
||||
assert_eq!((0..3).map(|i| at(&s, i)).collect::<Vec<_>>(), [Some(1), Some(2), Some(5)]);
|
||||
assert_eq!(missing_frame_ranges(&f.sequence), [(3, 4)]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn start_frame_trims_or_pads_the_head() {
|
||||
let at = |f: &Footage, i| f.sequence_file(i).map(|p| frame_number(p).unwrap_or(-1));
|
||||
let mut f = seq(&[1001, 1002, 1003], MissingFrames::Placeholder);
|
||||
assert_eq!(f.first_frame_number(), 1001);
|
||||
f.start_frame = Some(1002);
|
||||
assert_eq!((f.sequence_frames(), at(&f, 0), at(&f, 1)), (2, Some(1002), Some(1003)));
|
||||
f.start_frame = Some(999);
|
||||
assert_eq!((f.sequence_frames(), at(&f, 0), at(&f, 2)), (5, None, Some(1001)));
|
||||
f.missing_frames = MissingFrames::Hold;
|
||||
assert_eq!(at(&f, 0), Some(1001), "nothing before the first file: hold it");
|
||||
f.missing_frames = MissingFrames::Skip;
|
||||
f.start_frame = Some(1003);
|
||||
assert_eq!((f.sequence_frames(), at(&f, 0)), (1, Some(1003)));
|
||||
// Hostile values never panic and stay bounded.
|
||||
f.missing_frames = MissingFrames::Placeholder;
|
||||
f.start_frame = Some(i64::MIN);
|
||||
assert_eq!(f.sequence_frames(), MAX_SEQUENCE_FRAMES);
|
||||
assert_eq!(f.sequence_file(i64::MAX), None);
|
||||
f.start_frame = Some(i64::MAX);
|
||||
assert_eq!(f.sequence_frames(), 1);
|
||||
f.missing_frames = MissingFrames::Skip;
|
||||
assert_eq!(f.sequence_file(-1), None);
|
||||
assert!(f.sequence_file(5).is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn duration_follows_frames_and_rate() {
|
||||
let mut f = seq(&[1, 2, 5], MissingFrames::Placeholder);
|
||||
f.frame_rate = effectcraft_time::FrameRate::from_f64(10.0);
|
||||
f.sync_sequence_duration();
|
||||
assert!((f.duration.seconds() - 0.5).abs() < 1e-9);
|
||||
assert_eq!(Footage::default().sequence_frames(), 0);
|
||||
}
|
||||
}
|
||||
|
|
@ -1200,8 +1200,12 @@ fn file_dialog(app: &mut EffectcraftApp, id: &str, params: &Value) -> Option<Res
|
|||
if matches!(id, "file.import" | "file.importMultiple") {
|
||||
p.insert("background".into(), Value::Bool(true));
|
||||
}
|
||||
// Photoshop files ask how to import them first.
|
||||
if id == "file.import" && crate::panels::dialogs::open_form(app, id, &Value::Object(p.clone())) {
|
||||
// Photoshop files ask how to import them first; numbered stills whether to import them as an
|
||||
// image sequence.
|
||||
let picked = Value::Object(p.clone());
|
||||
if id == "file.import" && crate::panels::dialogs::open_form(app, id, &picked)
|
||||
|| matches!(id, "file.import" | "file.importMultiple") && crate::panels::forms::open_import_sequence(app, &picked)
|
||||
{
|
||||
return Some(Ok(json!({"dialog": id})));
|
||||
}
|
||||
// Save (untitled) becomes Save As.
|
||||
|
|
|
|||
|
|
@ -126,27 +126,74 @@ fn has(p: &Value, keys: &[&str]) -> bool {
|
|||
keys.iter().any(|k| p.get(*k).is_some())
|
||||
}
|
||||
|
||||
/// If `id` is a dialog command invoked without its parameters, open its form and return true.
|
||||
/// The files of an import's parameters (`paths` or `path`).
|
||||
fn import_paths(p: &Value) -> Vec<String> {
|
||||
match p.get("paths").or(p.get("path")) {
|
||||
Some(Value::Array(a)) => a.iter().filter_map(|v| v.as_str().map(str::to_string)).collect(),
|
||||
Some(Value::String(x)) => vec![x.clone()],
|
||||
_ => vec![],
|
||||
}
|
||||
}
|
||||
|
||||
/// The import includes a file with one of these (lowercase) extensions.
|
||||
fn imports_ext(p: &Value, exts: &[&str]) -> bool {
|
||||
import_paths(p).iter().any(|s| s.rsplit('.').next().is_some_and(|e| exts.contains(&e.to_ascii_lowercase().as_str())))
|
||||
}
|
||||
|
||||
/// The import includes a Photoshop document.
|
||||
fn is_layered(p: &Value) -> bool {
|
||||
let psd = |v: &Value| v.as_str().is_some_and(|s| matches!(s.rsplit('.').next().map(str::to_ascii_lowercase).as_deref(), Some("psd" | "psb")));
|
||||
match p.get("paths").or(p.get("path")) {
|
||||
Some(Value::Array(a)) => a.iter().any(psd),
|
||||
Some(v) => psd(v),
|
||||
None => false,
|
||||
}
|
||||
imports_ext(p, &["psd", "psb"])
|
||||
}
|
||||
|
||||
/// The import includes a PDF or Illustrator file (which may have several pages).
|
||||
fn is_pdf(p: &Value) -> bool {
|
||||
let pdf = |v: &Value| v.as_str().is_some_and(|s| matches!(s.rsplit('.').next().map(str::to_ascii_lowercase).as_deref(), Some("pdf" | "ai")));
|
||||
match p.get("paths").or(p.get("path")) {
|
||||
Some(Value::Array(a)) => a.iter().any(pdf),
|
||||
Some(v) => pdf(v),
|
||||
None => false,
|
||||
}
|
||||
imports_ext(p, &["pdf", "ai"])
|
||||
}
|
||||
|
||||
/// File ▸ Import ▸ File… picked numbered stills that import as image sequences: ask, as After
|
||||
/// Effects' "<format> Sequence" checkbox in its Import dialog does (rfd's native dialog can't
|
||||
/// carry a checkbox), with Force Alphabetical Order and the frame rate. `p`: the import's
|
||||
/// parameters. Returns whether the dialog opened. Dropped files and agents' `file.import`
|
||||
/// don't ask (`sequence` defaults to on).
|
||||
pub fn open_import_sequence(app: &mut EffectcraftApp, p: &Value) -> bool {
|
||||
if has(p, &["sequence", "alphabetical"]) {
|
||||
return false;
|
||||
}
|
||||
let runs = effectcraft_engine::sequence::sequences(app.session.services.as_ref(), &import_paths(p), false);
|
||||
if runs.is_empty() {
|
||||
return false;
|
||||
}
|
||||
let fields = sequence_fields(app, &runs);
|
||||
form(app, "Import Image Sequence", "file.import", p.clone(), fields);
|
||||
true
|
||||
}
|
||||
|
||||
/// The Import Image Sequence dialog's fields for `runs` (each a sequence's files).
|
||||
fn sequence_fields(app: &EffectcraftApp, runs: &[Vec<String>]) -> Vec<Field> {
|
||||
use effectcraft_engine::project::sequence::sequence_name;
|
||||
let first = runs.first().map(Vec::as_slice).unwrap_or_default();
|
||||
let ext = first.first().and_then(|f| f.rsplit_once('.')).map(|(_, e)| e.to_ascii_uppercase()).unwrap_or_default();
|
||||
let what = |files: &[String]| {
|
||||
let name = sequence_name(files).unwrap_or_default();
|
||||
match effectcraft_engine::sequence::missing_report(files) {
|
||||
Some(gaps) => format!("{name}: {} files, {gaps}", files.len()),
|
||||
None => format!("{name}: {} files", files.len()),
|
||||
}
|
||||
};
|
||||
let note = match runs {
|
||||
[one] => format!("{} – numbered stills of one image sequence.", what(one)),
|
||||
_ => format!("{} image sequences: {}.", runs.len(), runs.iter().map(|r| what(r)).collect::<Vec<_>>().join("; ")),
|
||||
};
|
||||
vec![
|
||||
Field::note("note", ¬e),
|
||||
Field::bool("sequence", &format!("{ext} Sequence"), true),
|
||||
Field::bool("alphabetical", "Force alphabetical order", false),
|
||||
Field::num("frameRate", "Frame rate (fps)", app.session.prefs.import.sequence_fps),
|
||||
Field::note("after", "Change the frame rate, alpha, missing frames and start frame later with File ▸ Interpret Footage."),
|
||||
]
|
||||
}
|
||||
|
||||
/// If `id` is a dialog command invoked without its parameters, open its form and return true.
|
||||
pub fn open_form(app: &mut EffectcraftApp, id: &str, p: &Value) -> bool {
|
||||
let s = &app.session;
|
||||
let comp = s.active_comp();
|
||||
|
|
@ -346,7 +393,22 @@ pub fn open_form(app: &mut EffectcraftApp, id: &str, p: &Value) -> bool {
|
|||
],
|
||||
),
|
||||
"file.interpretFootage" | "file.interpretProxy"
|
||||
if !has(p, &["frameRate", "alpha", "loop", "pixelAspect", "colorProfile", "fields", "invertAlpha", "matteColor", "linearLight"]) =>
|
||||
if !has(
|
||||
p,
|
||||
&[
|
||||
"frameRate",
|
||||
"alpha",
|
||||
"loop",
|
||||
"pixelAspect",
|
||||
"colorProfile",
|
||||
"fields",
|
||||
"invertAlpha",
|
||||
"matteColor",
|
||||
"linearLight",
|
||||
"missingFrames",
|
||||
"startFrame",
|
||||
],
|
||||
) =>
|
||||
{
|
||||
let proxy = id == "file.interpretProxy";
|
||||
let f = s.state.project_selection.first().and_then(|i| s.project.item(*i)).and_then(|it| match (&it.kind, &it.proxy) {
|
||||
|
|
@ -356,53 +418,67 @@ pub fn open_form(app: &mut EffectcraftApp, id: &str, p: &Value) -> bool {
|
|||
});
|
||||
let Some(f) = f else { return false };
|
||||
let hex = |c: [f32; 3]| format!("#{:02x}{:02x}{:02x}", (c[0] * 255.0).round() as u8, (c[1] * 255.0).round() as u8, (c[2] * 255.0).round() as u8);
|
||||
(
|
||||
if proxy { "Interpret Footage: Proxy".into() } else { "Interpret Footage".into() },
|
||||
vec![
|
||||
// Main Options ▸ Alpha.
|
||||
Field::choice(
|
||||
"alpha",
|
||||
"Alpha",
|
||||
&[
|
||||
("Interpret Straight - Unmatted", json!("straight")),
|
||||
("Interpret Premultiplied - Matted With Color", json!("premultiplied")),
|
||||
("Ignore", json!("ignore")),
|
||||
("Guess", json!("guess")),
|
||||
],
|
||||
f.alpha as usize,
|
||||
),
|
||||
Field::text("matteColor", "Matte color (premultiplied)", &hex(f.premul_color)),
|
||||
Field::bool("invertAlpha", "Invert Alpha", f.invert_alpha),
|
||||
// Main Options ▸ Frame Rate, Fields and Pulldown, Other Options.
|
||||
Field::num("frameRate", "Assume this frame rate", f.frame_rate.as_f64()),
|
||||
Field::choice(
|
||||
"fields",
|
||||
"Separate Fields",
|
||||
&[("Off", json!("off")), ("Upper Field First", json!("upper")), ("Lower Field First", json!("lower"))],
|
||||
f.fields as usize,
|
||||
),
|
||||
Field::num("pixelAspect", "Pixel Aspect Ratio", f.pixel_aspect),
|
||||
Field::num("loop", "Loop (times)", f.loop_count as f64),
|
||||
// Color.
|
||||
Field::choice(
|
||||
"colorProfile",
|
||||
"Assign Profile",
|
||||
&[
|
||||
("Embedded / sRGB", json!("auto")),
|
||||
("sRGB IEC61966-2.1", json!("srgb")),
|
||||
("HDTV (Rec. 709)", json!("rec709")),
|
||||
("Rec. 2020", json!("rec2020")),
|
||||
("Display P3", json!("p3")),
|
||||
("ACEScg", json!("acescg")),
|
||||
("ACES2065-1", json!("aces2065")),
|
||||
("Rec. 2100 PQ", json!("rec2100pq")),
|
||||
("Rec. 2100 HLG", json!("rec2100hlg")),
|
||||
],
|
||||
f.color_profile.map_or(0, |c| 1 + effectcraft_engine::project::ColorSpace::ALL.iter().position(|x| *x == c).unwrap_or(0)),
|
||||
),
|
||||
Field::bool("linearLight", "Interpret As Linear Light", f.linear_light),
|
||||
],
|
||||
)
|
||||
let mut fields = vec![
|
||||
// Main Options ▸ Alpha.
|
||||
Field::choice(
|
||||
"alpha",
|
||||
"Alpha",
|
||||
&[
|
||||
("Interpret Straight - Unmatted", json!("straight")),
|
||||
("Interpret Premultiplied - Matted With Color", json!("premultiplied")),
|
||||
("Ignore", json!("ignore")),
|
||||
("Guess", json!("guess")),
|
||||
],
|
||||
f.alpha as usize,
|
||||
),
|
||||
Field::text("matteColor", "Matte color (premultiplied)", &hex(f.premul_color)),
|
||||
Field::bool("invertAlpha", "Invert Alpha", f.invert_alpha),
|
||||
// Main Options ▸ Frame Rate, Fields and Pulldown, Other Options.
|
||||
Field::num("frameRate", "Assume this frame rate", f.frame_rate.as_f64()),
|
||||
Field::choice(
|
||||
"fields",
|
||||
"Separate Fields",
|
||||
&[("Off", json!("off")), ("Upper Field First", json!("upper")), ("Lower Field First", json!("lower"))],
|
||||
f.fields as usize,
|
||||
),
|
||||
Field::num("pixelAspect", "Pixel Aspect Ratio", f.pixel_aspect),
|
||||
Field::num("loop", "Loop (times)", f.loop_count as f64),
|
||||
// Color.
|
||||
Field::choice(
|
||||
"colorProfile",
|
||||
"Assign Profile",
|
||||
&[
|
||||
("Embedded / sRGB", json!("auto")),
|
||||
("sRGB IEC61966-2.1", json!("srgb")),
|
||||
("HDTV (Rec. 709)", json!("rec709")),
|
||||
("Rec. 2020", json!("rec2020")),
|
||||
("Display P3", json!("p3")),
|
||||
("ACEScg", json!("acescg")),
|
||||
("ACES2065-1", json!("aces2065")),
|
||||
("Rec. 2100 PQ", json!("rec2100pq")),
|
||||
("Rec. 2100 HLG", json!("rec2100hlg")),
|
||||
],
|
||||
f.color_profile.map_or(0, |c| 1 + effectcraft_engine::project::ColorSpace::ALL.iter().position(|x| *x == c).unwrap_or(0)),
|
||||
),
|
||||
Field::bool("linearLight", "Interpret As Linear Light", f.linear_light),
|
||||
];
|
||||
// Image sequences: what a gap in the numbering shows, and the first frame's number.
|
||||
if f.kind == effectcraft_engine::project::FootageKind::Sequence {
|
||||
use effectcraft_engine::project::MissingFrames;
|
||||
let at = fields.iter().position(|x| x.key == "frameRate").map_or(fields.len(), |i| i + 1);
|
||||
let gaps = effectcraft_engine::sequence::missing_report(&f.sequence).unwrap_or_else(|| "no missing frames".into());
|
||||
let options: Vec<(&str, Value)> = MissingFrames::ALL.iter().map(|m| (m.label(), json!(m.id()))).collect();
|
||||
let sel = MissingFrames::ALL.iter().position(|m| *m == f.missing_frames).unwrap_or(0);
|
||||
fields.splice(
|
||||
at..at,
|
||||
[
|
||||
Field::note("sequenceInfo", &format!("Image sequence: {} files, {} frames; {gaps}.", f.sequence.len(), f.sequence_frames())),
|
||||
Field::choice("missingFrames", "Missing Frames", &options, sel),
|
||||
Field::num("startFrame", "Start Frame", f.first_frame_number() as f64),
|
||||
],
|
||||
);
|
||||
}
|
||||
(if proxy { "Interpret Footage: Proxy".into() } else { "Interpret Footage".into() }, fields)
|
||||
}
|
||||
"file.projectSettings" if p.as_object().is_none_or(|m| m.is_empty()) => {
|
||||
let st = &s.project.settings;
|
||||
|
|
@ -668,10 +744,20 @@ pub fn open_form(app: &mut EffectcraftApp, id: &str, p: &Value) -> bool {
|
|||
pub fn show_form(app: &mut EffectcraftApp, ctx: &egui::Context, t: &Tokens) {
|
||||
let mut f = app.dialog_state.form.clone();
|
||||
let (mut ok, mut close) = (false, false);
|
||||
let h = 120.0 + f.fields.len() as f32 * 30.0;
|
||||
// Rows are 30 px; a note wraps at about 40 characters a line, so OK and Cancel stay in view.
|
||||
let rows: f32 = f
|
||||
.fields
|
||||
.iter()
|
||||
.map(|fl| match &fl.kind {
|
||||
FieldKind::Note(text) => 10.0 + 15.0 * (text.chars().count() as f32 / 40.0).ceil().max(1.0),
|
||||
_ => 30.0,
|
||||
})
|
||||
.sum();
|
||||
let h = 120.0 + rows;
|
||||
let mut regs: Vec<(String, egui::Rect, String)> = vec![];
|
||||
let mut browse: Option<usize> = None;
|
||||
super::dialogs::modal(ctx, &f.title.clone(), vec2(440.0, h), t, |ui| {
|
||||
// Wide enough for a label column and a 240 px popup without a horizontal scroll bar.
|
||||
super::dialogs::modal(ctx, &f.title.clone(), vec2(480.0, h), t, |ui| {
|
||||
egui::Grid::new("form-grid").num_columns(2).spacing([14.0, 10.0]).show(ui, |ui| {
|
||||
for (i, fl) in f.fields.iter_mut().enumerate() {
|
||||
ui.label(&fl.label);
|
||||
|
|
@ -705,7 +791,16 @@ pub fn show_form(app: &mut EffectcraftApp, ctx: &egui::Context, t: &Tokens) {
|
|||
})
|
||||
.inner
|
||||
}
|
||||
FieldKind::Note(text) => ui.add(egui::Label::new(egui::RichText::new(text.as_str()).small()).wrap()).rect,
|
||||
// A fixed width: in the grid's value column a wrapping label would otherwise
|
||||
// shrink to a word per line.
|
||||
FieldKind::Note(text) => {
|
||||
ui.scope(|ui| {
|
||||
ui.set_width(220.0);
|
||||
ui.add(egui::Label::new(egui::RichText::new(text.as_str()).small()).wrap())
|
||||
})
|
||||
.inner
|
||||
.rect
|
||||
}
|
||||
FieldKind::Bool(b) => ui.checkbox(b, "").rect,
|
||||
FieldKind::Choice { options, sel } => {
|
||||
let cur = options.get(*sel).map(|o| o.0.clone()).unwrap_or_default();
|
||||
|
|
|
|||
|
|
@ -257,3 +257,109 @@ fn dropped_files_show_import_progress_then_the_items_they_made() {
|
|||
fn card_id(h: &Harness<'_, EffectcraftApp>) -> String {
|
||||
h.state().auto.query("import.job.").first().map(|e| e.id.clone()).unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Probes any existing file as a 64×32 PNG still.
|
||||
struct Stills;
|
||||
|
||||
impl effectcraft_engine::Importer for Stills {
|
||||
fn probe(&self, path: &str) -> Result<effectcraft_engine::project::Footage, String> {
|
||||
if !std::path::Path::new(path).is_file() {
|
||||
return Err(format!("{path}: no such file"));
|
||||
}
|
||||
Ok(effectcraft_engine::project::Footage { path: path.into(), width: 64, height: 32, has_video: true, codec: "PNG".into(), ..Default::default() })
|
||||
}
|
||||
}
|
||||
|
||||
/// A fresh folder holding empty `frame_0001.png` … `frame_000<n>.png`.
|
||||
fn frames(tag: &str, n: u32) -> std::path::PathBuf {
|
||||
let d = std::env::temp_dir().join(format!("ec-ui-seq-{tag}-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&d);
|
||||
std::fs::create_dir_all(&d).unwrap();
|
||||
for k in 1..=n {
|
||||
std::fs::write(d.join(format!("frame_{k:04}.png")), b"").unwrap();
|
||||
}
|
||||
d
|
||||
}
|
||||
|
||||
fn click_id(h: &mut Harness<'_, EffectcraftApp>, id: &str) {
|
||||
// A dialog that just opened settles its size and place in a frame or two.
|
||||
h.run_steps(3);
|
||||
let p = rect(h, id).center();
|
||||
h.event(Event::PointerMoved(p));
|
||||
h.step();
|
||||
h.event(Event::PointerButton { pos: p, button: egui::PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE });
|
||||
h.step();
|
||||
h.event(Event::PointerButton { pos: p, button: egui::PointerButton::Primary, pressed: false, modifiers: Modifiers::NONE });
|
||||
h.run_steps(2);
|
||||
}
|
||||
|
||||
/// (name, sequence files) of every footage item.
|
||||
fn footage_items(h: &Harness<'_, EffectcraftApp>) -> Vec<(String, usize)> {
|
||||
h.state()
|
||||
.session
|
||||
.project
|
||||
.items
|
||||
.values()
|
||||
.filter_map(|i| match &i.kind {
|
||||
effectcraft_engine::project::ItemKind::Footage(f) => Some((i.name.clone(), f.sequence.len())),
|
||||
_ => None,
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// #297: File ▸ Import ▸ File… on one numbered still asks, as After Effects' "PNG Sequence"
|
||||
/// checkbox does, whether to import its whole run as one image sequence (and at what frame
|
||||
/// rate); unticked, the file imports as a still.
|
||||
#[test]
|
||||
fn import_dialog_offers_the_image_sequence_option() {
|
||||
let (mut h, _) = harness();
|
||||
let dir = frames("dialog", 3);
|
||||
let pick = dir.join("frame_0002.png").to_string_lossy().to_string();
|
||||
h.state_mut().session.importer = Some(Arc::new(Stills));
|
||||
h.state_mut().hooks.pick_files = Some(Box::new(move |_: &[&str]| vec![pick.clone()]));
|
||||
let ctx = h.ctx.clone();
|
||||
effectcraft_ui_egui::menus::invoke(h.state_mut(), &ctx, "file.import", json!({})).unwrap();
|
||||
h.run_steps(2);
|
||||
assert_eq!(h.state().dialog, Some(effectcraft_ui_egui::Dialog::Form));
|
||||
let label = |h: &Harness<'_, EffectcraftApp>, id: &str| h.state().auto.find(id).map(|e| e.label.clone());
|
||||
assert_eq!(label(&h, "form.field.sequence").as_deref(), Some("PNG Sequence"));
|
||||
assert!(label(&h, "form.field.alphabetical").is_some() && label(&h, "form.field.frameRate").is_some());
|
||||
assert!(footage_items(&h).is_empty(), "nothing imported before OK");
|
||||
click_id(&mut h, "form.ok");
|
||||
step_until(&mut h, |h| !footage_items(h).is_empty());
|
||||
assert_eq!(footage_items(&h), [("frame_[0001-0003].png".to_string(), 3)]);
|
||||
|
||||
// Unticked: the picked file alone, as a still.
|
||||
effectcraft_ui_egui::menus::invoke(h.state_mut(), &ctx, "file.import", json!({})).unwrap();
|
||||
h.run_steps(2);
|
||||
click_id(&mut h, "form.field.sequence");
|
||||
click_id(&mut h, "form.ok");
|
||||
step_until(&mut h, |h| footage_items(h).len() == 2);
|
||||
assert_eq!(footage_items(&h)[1], ("frame_0002.png".to_string(), 0));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// #297: every frame of a sequence dropped on the window arrives as one image sequence (it used
|
||||
/// to make one item per frame), and Interpret Footage shows its Missing Frames and Start Frame.
|
||||
#[test]
|
||||
fn dropped_frames_import_as_one_sequence_with_sequence_interpretation() {
|
||||
let (mut h, _) = harness();
|
||||
let dir = frames("drop", 4);
|
||||
h.state_mut().session.importer = Some(Arc::new(Stills));
|
||||
for k in 1..=4 {
|
||||
h.input_mut().dropped_files.push(Arc::new(Dropped(dir.join(format!("frame_{k:04}.png")))));
|
||||
}
|
||||
h.run_steps(2);
|
||||
step_until(&mut h, |h| !footage_items(h).is_empty());
|
||||
h.run_steps(2);
|
||||
assert_eq!(footage_items(&h), [("frame_[0001-0004].png".to_string(), 4)]);
|
||||
assert!(h.state().dialog.is_none(), "drops don't ask");
|
||||
// Interpret Footage on it (the import selected it).
|
||||
let ctx = h.ctx.clone();
|
||||
effectcraft_ui_egui::menus::invoke(h.state_mut(), &ctx, "file.interpretFootage", json!({})).unwrap();
|
||||
h.run_steps(2);
|
||||
for id in ["form.field.missingFrames", "form.field.startFrame", "form.field.frameRate", "form.field.alpha"] {
|
||||
assert!(h.state().auto.find(id).is_some(), "{id}");
|
||||
}
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -205,8 +205,12 @@ build real projects through these interfaces; they are worked examples of everyt
|
|||
then `keys.velocity {"outSpeed":0,"outInfluence":33.33}`.
|
||||
* **Gradients**: `set_property {"path":"contents/gfill/colors","value":["#0080ff","#ffff00"]}` or
|
||||
`{"colors":[[0,"#0080ff"],[1,[1,1,0]]],"opacities":[[0,1],[1,0.5]]}`.
|
||||
* **Image sequences** import at 30 fps (Settings ▸ Import); `file.interpretFootage {"items":[id],
|
||||
"frameRate":12}` conforms them (the item and layers that ran to its end get the new length).
|
||||
* **Image sequences**: `file.import {"paths":["/r/shot_0001.png"]}` imports the file's whole
|
||||
numbered run as one item (several picked frames: that range; a folder: each run in it;
|
||||
`"sequence": false`: one still; `"alphabetical": true`: Force Alphabetical Order) at
|
||||
`frameRate` or 30 fps (Settings ▸ Import). `file.interpretFootage {"items":[id],
|
||||
"frameRate":12, "startFrame":1001}` conforms them (the
|
||||
item and layers that ran to its end get the new length). See [footage.md](footage.md).
|
||||
* **Multi-layer OpenEXR**: `layer.channels {layer}` lists a footage layer's EXR layers and
|
||||
channels (`{"layers":[{"name":"diffuse","rgba":["diffuse.R","diffuse.G","diffuse.B",""]}]}`);
|
||||
EXtractoR shows them when its `red` / `green` / `blue` / `alpha` are set to those names
|
||||
|
|
|
|||
|
|
@ -0,0 +1,79 @@
|
|||
# Footage: importing files and image sequences
|
||||
|
||||
How to bring footage into a project and tell EffectCraft how to read it. Everything here works
|
||||
the way it does in After Effects, and every step is a command an agent can run too (shown in
|
||||
`code`).
|
||||
|
||||
## Importing files
|
||||
|
||||
- **File ▸ Import ▸ File…** (Ctrl+I / Cmd+I) picks one or more files. Photoshop and PDF /
|
||||
Illustrator files first ask whether to import them as footage or as a composition.
|
||||
- **Drop files or folders** on the window. Dropped on the Composition viewer,
|
||||
they also become layers there.
|
||||
- **The Media Browser** (Window ▸ Media Browser) browses folders and imports what you pick.
|
||||
|
||||
Agents: `file.import {"paths": ["/shots/plate.mov", "/shots/logo.psd"]}`.
|
||||
|
||||
## Image sequences
|
||||
|
||||
VFX plates, 3D renders and hand-drawn animation usually come as numbered stills:
|
||||
`shot_0001.png`, `shot_0002.png`, … EffectCraft imports such a run as **one footage item** that
|
||||
plays like a movie. Its name shows the range, for example `shot_[0001-0250].png`, and its type in
|
||||
the Project panel is *Image Sequence*.
|
||||
|
||||
**File ▸ Import ▸ File…:** pick any one numbered file of the run. The *Import Image Sequence*
|
||||
dialog shows the run it found (and any missing frames) with these options:
|
||||
|
||||
| Option | What it does |
|
||||
|---|---|
|
||||
| **PNG Sequence** (EXR, TIFF, JPG… after the file type) | On: the whole run imports as one sequence. Off: just the picked file imports, as a still. |
|
||||
| **Force alphabetical order** | Imports every image of that type in the folder in name order, whatever the numbering. Use it for files that aren't numbered consistently. |
|
||||
| **Frame rate (fps)** | The sequence's frame rate. The default comes from Settings ▸ Import ▸ Sequence Footage (30 fps). |
|
||||
|
||||
Other ways in:
|
||||
|
||||
- **Pick part of a run** (Shift-click the first and the last frame in the file dialog) to import
|
||||
just that range as one sequence.
|
||||
- **Pick or drop every frame** of the run: it arrives as one sequence, not one item per frame.
|
||||
- **Drop a folder**: its media files import, and each numbered run in it becomes a sequence.
|
||||
- **In the browser** (the web app) there are no folders to look in, so pick or drop all the
|
||||
frames of the sequence at once.
|
||||
|
||||
Stills of any format EffectCraft reads can form a sequence: PNG, JPEG, TIFF, OpenEXR, BMP and
|
||||
WebP. Layered and vector documents (PSD, SVG, PDF) import one by one.
|
||||
|
||||
Agents: `file.import {"paths": ["/shots/shot_0001.png"], "sequence": true, "frameRate": 24}`.
|
||||
`sequence` is on by default; `"sequence": false` imports the file as a still, and
|
||||
`"alphabetical": true` is Force Alphabetical Order.
|
||||
|
||||
## Interpret Footage
|
||||
|
||||
Select a footage item in the Project panel and choose **File ▸ Interpret Footage ▸ Main…**
|
||||
(Ctrl+Alt+G / Cmd+Opt+G) to change how it is read. A change applies everywhere the footage is
|
||||
used, and layers that ran to the end of the footage keep doing so.
|
||||
|
||||
| Setting | What it does |
|
||||
|---|---|
|
||||
| **Alpha** | *Straight - Unmatted*, *Premultiplied - Matted With Color* (with the matte colour), *Ignore* (opaque), or *Guess* (EffectCraft looks at the first frame). *Invert Alpha* flips it. |
|
||||
| **Assume this frame rate** | The rate the frames play at. A 250-frame sequence at 25 fps lasts 10 seconds; at 24 fps, 10.4 seconds. |
|
||||
| **Missing Frames** (sequences) | What a gap in the numbering shows: *Show Placeholder* (color bars, as After Effects shows), *Hold Previous Frame*, or *Skip* (the files play back to back and the gap closes up). |
|
||||
| **Start Frame** (sequences) | The frame number at the footage's first frame. It defaults to the first file's number. A higher number trims the head (start a 1001–1250 render at 1010); a lower one adds missing frames before the first file. |
|
||||
| Fields, pixel aspect, loop, colour profile, linear light | As in After Effects. |
|
||||
|
||||
Settings ▸ Import ▸ **Report Missing Frames** (on by default) lists the missing frame numbers
|
||||
when a sequence imports.
|
||||
|
||||
**File ▸ Interpret Footage ▸ Remember Interpretation / Apply Interpretation** copy the alpha,
|
||||
fields, pixel aspect, loop and colour settings to other footage.
|
||||
|
||||
Agents: `file.interpretFootage {"items": [id], "frameRate": 24, "alpha": "premultiplied",
|
||||
"missingFrames": "hold", "startFrame": 1001}` (`"startFrame": "file"` goes back to the first
|
||||
file's number).
|
||||
|
||||
## Replacing and reloading
|
||||
|
||||
- **File ▸ Replace Footage ▸ File…** (Ctrl+H) points the item at another file. Pick a numbered
|
||||
file to replace it with that file's whole sequence (`file.replaceFootage {"path": …,
|
||||
"sequence": false}` for one frame).
|
||||
- **File ▸ Reload Footage** (Ctrl+Alt+L) reads the files again. A sequence picks up frames
|
||||
added to its folder (a render still in progress) and keeps its interpretation.
|
||||
|
|
@ -172,7 +172,7 @@ the disk cache in the formats wave; the 3D Camera Tracker in M12.6.)
|
|||
| 3D | 88% | 5.5 | reflections of other layers (Advanced 3D surfaces reflect only the Environment light; Material Options ▸ Appears in Reflections is stored but has no visible effect yet, #261), multi-view layouts, the Extended Viewer for Advanced 3D comps (Classic 3D Extended Viewer landed in M13.5 UI completion); collapsed precomps of another size seen through the parent's camera render (fixed in M13.2); stereo rigs, orbit nulls, lights controlled by the camera, cameras/lights from glTF models, environment backgrounds, Advanced 3D motion blur, blend modes and track mattes landed in M7.7; Classic 3D iris-shaped bokeh with highlights, progressive depth of field on tilted layers and the focus-link commands landed in M13.5; Advanced 3D depth of field with the iris and highlight options, collapsed precomps as real Advanced 3D geometry and extruded text/shape strokes landed in M13.8; Advanced 3D (glTF/OBJ models, primitives, extruded text and shapes, PBR, image-based light, shadow maps, GPU rasteriser) in M7.4–M7.6; Advanced 3D end to end on the GPU (motion blur, iris depth of field, compositing) in M13.11 |
|
||||
| Effects | ≈ 85% | 3.0 | GPU versions of the remaining effects (280 run on the GPU since M13.29, EFF-5: M13.28 added the remaining pixel effects and the geometry and audio generators (Lightning, Advanced Lightning, Beam, Lens Flare, Radio Waves, Vegas, Stroke, Scribble, Write-on, Paint Bucket, Eyedropper Fill, CC Glue Gun, CC Threads, Audio Spectrum / Waveform, Basic / Path Text); M13.29 added the particle effects (CC Particle World / Systems II, Particle Playground, CC Ball Action, CC Pixel Polly, CC Scatterize, Curl Noise) and removed the fallbacks of Shatter's wireframe views, Foam's extras, Timewarp's Matte Layer, strong Warp Fisheye / Twist bends and custom `.ocio` configs; M13.23 added the 3D Channel and Immersive Video families, Apply Color LUT, the OCIO effects, Color Profile Converter and the simulations' render passes (Card Wipe included); M13.22 added the CC light family, the CC transitions, Block Dissolve, Radial Shadow, CC Bender / Blobbylize / Cylinder / Sphere / Spotlight / Environment, 3D Glasses, Numbers, Timecode and the time effects joined the 166 of M13.13; Fractal and the analysis / tool effects still render on the CPU) and the missing controls listed as partial in [effects.md](effects.md) (every After Effects effect exists since M9.11, M12.5 and M12.6; parameter names, order, twirl-downs, popups, units and defaults were aligned in M9.12) |
|
||||
| Interface | 75% | 5.0 | more Learn tutorials and pixel-level fidelity of dialogs (G2 on 9 October: the in-window menu bar works from the keyboard (Alt, arrows, Enter, Escape) and the app's shortcuts wait while a menu is open, #279; the Timeline's composition marker bin and numpad * add comp markers, #275; Settings ▸ Appearance ▸ UI Scale (75–200 %, beyond After Effects, which follows the system's scaling), #284; G2 on 7 October: Project items, files and effects dropped on the Composition viewer, Project items dropped in the Timeline between layers and at a time, #85, #88, #89; popup menus moved to fit the window take clicks, so Render Queue templates apply, #117; View ▸ New Viewer landed in M13.32; an automation id on every control of Composition and Solid Settings, Group / Ungroup Shapes in the Layer menu, Window ▸ Learn, the flowchart shortcuts and Reveal in Explorer landed in M3.10 and M3.13; the Home ▸ Templates gallery (eight original built-in templates, user templates from File ▸ Save as Template…) and View ▸ Simulate Output ▸ My Custom RGB… landed in M13.25; Timeline layer reordering by drag, a non-snapping viewer pan, a working rename field and twirl arrows, and the full set of property reveal shortcuts — double presses, Alt+Shift keyframes, Ctrl+` — landed in M13.17–M13.20; the Home ▸ Learn tab with interactive tutorials and a UI fidelity pass landed in M13.5 UI completion; visual editors for Lumetri RGB / hue-saturation curves, Colorama's output cycle wheel, Glow's colour map and Reshape's correspondence points (viewer handles), a scrolling Preview panel and AE-style Composition / Timeline tabs (close, label swatch, viewer lock) landed in M13.10; Timeline outline and Project panel columns scroll horizontally, the Layer Style dialog, ROI resize handles, Pan Behind snapping and 3D Reference Axes landed in M13.5; native macOS menu bar, Timeline columns/search/reveal-add, Home screen with recent projects and all AE workspaces landed; viewer rulers/snapping/channels/snapshots landed in M0.13) |
|
||||
| Project | ≈ 68% | 5.5 | OCIO displays beyond the built-in tone map; relative footage paths and relinking when a project moves (unsaved-changes prompts, a modified mark that follows undo and project files that name their version and always reopen landed in M3.9 and M3.14; auto-save and crash recovery in M3.7; Color Engine with OCIO/ACES working spaces, HDR compand/tone mapping, Rec. 2100 PQ/HLG output, Feet + Frames, display colour management, Simulate Output and the locked viewer landed in M7.7; proxies and Interpret Footage fields / pixel aspect / alpha guess landed: PRJ-8, PRJ-3) |
|
||||
| Project | ≈ 68% | 5.5 | OCIO displays beyond the built-in tone map; relative footage paths and relinking when a project moves (unsaved-changes prompts, a modified mark that follows undo and project files that name their version and always reopen landed in M3.9 and M3.14; auto-save and crash recovery in M3.7; Color Engine with OCIO/ACES working spaces, HDR compand/tone mapping, Rec. 2100 PQ/HLG output, Feet + Frames, display colour management, Simulate Output and the locked viewer landed in M7.7; proxies and Interpret Footage fields / pixel aspect / alpha guess landed: PRJ-8, PRJ-3; image sequence import options (Sequence, Force Alphabetical Order, frame rate, picked ranges, folders, the browser) and Interpret Footage Missing Frames / Start Frame landed: #297) |
|
||||
| Masks & roto | 74% | 5.0 | (audit estimates; Roto Brush 2.0 / 3.0's trained model, MobileSAM in the swappable `segment` module, has since landed in M13.35; variable-width mask feather points with the Mask Feather tool landed in M13.5; mask tracking and Mask Interpolation landed in M6.6; Roto Brush & Refine Edge with graph-cut segmentation, flow propagation, edge matting, decontamination and Freeze in M6.7) |
|
||||
| Preview | 77% | 2.8 | GPU effects for the audio-driven effects and the stepped particle systems; content-keyed RAM preview frames (an edit drops every comp's), audio scrubbing, more snapshot slots (G2 on 7 October: Audio, Lock and Shy switches keep the cached frames and playback with audio waits for frames instead of skipping them, #103; running out of video memory renders the frame on the CPU and keeps fewer GPU frames instead of crashing, #106; RAM preview keys with the render options, least-recently-shown eviction, Auto resolution while playing, Cache Before Playback that fits, an achieved-fps readout, Cache Frames When Idle and purges that clear what they name landed in M4.5–M4.8; 3D channel, VR, OCIO, Card Wipe and the simulations' render passes run on the GPU since M13.23; Advanced 3D layers with blend modes / track mattes / Preserve Transparency and environment backgrounds composite on the GPU since M13.22; pooled GPU textures and fused quantisation since M13.13: Lower Third GPU warm 21 → 4 ms/frame, and Auto now picks the CPU or the GPU per comp from measured frame times; Advanced 3D runs — raster, motion blur, iris depth of field, compositing — and wireframes run on the GPU since M13.11: CPU warm 3394 → GPU warm 290 ms/frame at 1920×1080 (11.7×; Half 968 → 85 ms), GPU ≠ CPU on 0.005 % of pixels, on an M4 Pro under load; Classic 3D bokeh depth of field — iris shapes, highlights, fringe, progressive blur on tilted planes — runs in WGSL since M13.6: 3D Showcase GPU warm 205 → 54 ms/frame at full size on an M4 Pro; the Preview panel's five shortcuts with their own Include / Loop / Cache Before Playback / Range / Play From / Frame Rate / Skip / Resolution / Full Screen / stop options and `playback.settings.get/set` landed in M13.8; Classic 3D runs and adjustment layers composite on the GPU since M12.7; persistent disk cache with the blue cache bar landed; region of interest, snapshots, exposure and Fast Previews landed in M0.13) |
|
||||
| Tracking | ≈ 96% | 0.3 | face tracking's trained model (MediaPipe Face Landmarker, M13.36) is an optional download; without it the classical (skin model + feature components + shape model) fitter is weak on profile views and occluded faces; Rolling Shutter Ripple is approximated by Subspace Warp's mesh density (face tracking (Outline Only / Detailed Features with Face Track Points and Extract & Copy Face Measurements), Subspace Warp's content-preserving mesh warp on subspace-smoothed trajectories, and radial lens distortion (k1, k2) in the camera bundle adjustment with Undistort Footage landed in M13.3; Rolling Shutter Repair in M9.11; point tracker, mask tracking, Warp Stabilizer and the 3D Camera Tracker in M6.x / M12.5 / M12.6) |
|
||||
|
|
|
|||
|
|
@ -108,6 +108,7 @@ Commands (CLI, MCP, control channel):
|
|||
- `type.recentFonts`: how many recently used fonts head the font menu
|
||||
- `type.fontNamesInEnglish`: off, fonts with a native-language family name show it
|
||||
- `import.reportMissingFrames`: gaps in an image sequence's numbering are reported on import
|
||||
(they show as placeholders; Interpret Footage ▸ Missing Frames, see [footage.md](footage.md))
|
||||
- `import.unlabeledAlpha`: alpha of TGA / TIFF / movie footage (Ask opens Interpret Footage;
|
||||
Guess takes premultiplied for movies, straight for stills)
|
||||
- `import.dragImportAs`: layered files dropped on the window import as footage or a comp
|
||||
|
|
|
|||
Loading…
Reference in New Issue