Merge pull request #1739 from tylerjenningsw/enhance/avi-import

Media: enable AVI import across supported entry points
This commit is contained in:
BFlat authored and GitHub committed 2026-10-30 09:01:53 +07:00
commit 1c8824c422
17 files changed
+310 -68

No files matched your search

+1 -4
View File
@@ -213,10 +213,7 @@ fn info(s: &mut Session, _: &Value) -> Result<Value> {
let item = s.project.item(v.item);
let name = item.map(|i| i.name.clone()).unwrap_or_default();
let frame = fr.frame_at(v.time);
let problem = match item.map(|i| &i.kind) {
Some(ItemKind::Footage(f)) => s.footage.problem(f),
_ => None,
};
let problem = item.and_then(|i| s.footage.item_problem(i));
Ok(
json!({"item": v.item.0, "name": name, "time": v.time.seconds(), "frame": frame, "timecode": timecode(item, fr, frame), "in": v.in_point.map(|t| t.seconds()), "out": v.out_point.map(|t| t.seconds()), "duration": dur.map(|d| d.seconds()), "frameRate": fr.as_f64(), "problem": problem}),
)
+3 -5
View File
@@ -46,11 +46,9 @@ fn summary(s: &mut Session, _: &Value) -> Result<Value> {
o["layers"] = json!(c.layers.len());
o["frameRate"] = json!(c.frame_rate.as_f64());
}
// Footage that shows no picture although its file is there (a codec or profile the
// decoders lack): why, so an agent can tell the user.
if let ItemKind::Footage(f) = &i.kind
&& let Some(why) = s.footage.problem(f)
{
// Footage (or the proxy in use) that shows no picture although its file is there (a
// codec or profile the decoders lack): why, so an agent can tell the user.
if let Some(why) = s.footage.item_problem(i) {
o["problem"] = json!(why);
}
o
+11 -2
View File
@@ -197,7 +197,6 @@ fn sort_entries(entries: &mut [Entry]) {
entries.sort_by_cached_key(|e| (!e.is_dir, e.name.to_lowercase()));
}
const VIDEO: &[&str] = &["mp4", "m4v", "mov", "webm", "mkv", "avi", "mxf", "gif"];
const IMAGE: &[&str] = &["png", "jpg", "jpeg", "tif", "tiff", "exr", "bmp", "webp", "psd", "psb", "svg", "tga", "dpx", "cin", "hdr"];
const AUDIO: &[&str] = &["wav", "aif", "aiff", "mp3", "m4a", "aac", "flac", "ogg", "opus"];
const DATA: &[&str] = &["json", "csv", "tsv", "mgjson"];
@@ -207,7 +206,8 @@ const MODEL: &[&str] = &["gltf", "glb", "obj", "stl", "fbx"];
pub fn kind_of(path: &str) -> Option<&'static str> {
let ext = std::path::Path::new(path).extension()?.to_str()?.to_ascii_lowercase();
let e = ext.as_str();
if VIDEO.contains(&e) {
// (An animated GIF plays as a movie.)
if e == "gif" || effectcraft_project::VIDEO_EXTENSIONS.contains(&e) {
Some("video")
} else if IMAGE.contains(&e) {
Some("image")
@@ -431,6 +431,15 @@ mod tests {
std::fs::remove_dir_all(&dir).unwrap();
}
/// Every movie type the importer reads lists as video, as an animated GIF does.
#[test]
fn movies_list_as_video() {
for ext in effectcraft_project::VIDEO_EXTENSIONS.iter().chain(&["gif"]) {
assert_eq!(kind_of(&format!("/clips/A.{}", ext.to_uppercase())), Some("video"), "{ext}");
}
assert_eq!(kind_of("/clips/capture.avi"), Some("video"));
}
#[test]
fn virtual_tree_lists_like_a_folder() {
let mut t = VirtualTree::default();
+44
View File
@@ -672,3 +672,47 @@ fn footage_that_cannot_be_decoded_reports_why() {
s.execute("footage.open", json!({"item": item.0})).unwrap();
assert_eq!(s.execute("footage.info", json!({})).unwrap()["problem"], it["problem"]);
}
/// A proxy in use that can't be decoded leaves its item without a picture although the item's
/// own file is fine: `project.summary` and `footage.info` give the proxy's reason, said to be the
/// proxy's, for footage and compositions alike, and nothing once Use Proxy is off.
#[test]
fn a_proxy_in_use_that_cannot_be_decoded_reports_why() {
struct BrokenProxies;
impl FootageSource for BrokenProxies {
fn frame(&self, _: ItemId, _: &Footage, _: Tick) -> Option<Arc<Image>> {
None
}
fn problem(&self, f: &Footage) -> Option<String> {
f.path.ends_with(".avi").then(|| format!("{} isn't supported by the built-in decoder", f.codec))
}
}
let mut s = Session { footage: Arc::new(BrokenProxies), ..Default::default() };
let comp = s.execute("comp.new", json!({"name": "C", "width": 64, "height": 36, "duration": 2})).unwrap()["comp"].as_u64().unwrap();
let still = Footage { kind: FootageKind::Still, path: "a.png".into(), width: 64, height: 36, has_video: true, ..Default::default() };
let item = std::sync::Arc::make_mut(&mut s.project).add_item("Plate", Default::default(), None, ItemKind::Footage(still));
let proxy = Footage {
kind: FootageKind::Video,
path: "plate.avi".into(),
codec: "MPEG-4 Part 2".into(),
width: 32,
height: 18,
has_video: true,
..Default::default()
};
let want = json!("Proxy: MPEG-4 Part 2 isn't supported by the built-in decoder");
let problem = |s: &mut Session, id: u64| {
let sum = s.execute("project.summary", json!({})).unwrap();
sum["items"].as_array().unwrap().iter().find(|i| i["id"] == json!(id)).unwrap().get("problem").cloned()
};
for id in [item.0, comp] {
let p = std::sync::Arc::make_mut(&mut s.project);
p.items.get_mut(&ItemId(id)).unwrap().proxy = Some(Box::new(effectcraft_project::Proxy { footage: proxy.clone(), enabled: true }));
assert_eq!(problem(&mut s, id), Some(want.clone()), "item {id}");
std::sync::Arc::make_mut(&mut s.project).items.get_mut(&ItemId(id)).unwrap().proxy.as_mut().unwrap().enabled = false;
assert_eq!(problem(&mut s, id), None, "item {id}: its own file shows, and is fine");
}
std::sync::Arc::make_mut(&mut s.project).items.get_mut(&item).unwrap().proxy.as_mut().unwrap().enabled = true;
s.execute("footage.open", json!({"item": item.0})).unwrap();
assert_eq!(s.execute("footage.info", json!({})).unwrap()["problem"], want);
}
+1 -2
View File
@@ -65,8 +65,7 @@ pub const STILL_EXTENSIONS: &[&str] =
&["png", "jpg", "jpeg", "gif", "webp", "tif", "tiff", "bmp", "exr", "tga", "hdr", "psd", "psb", "svg", "pdf", "ai", "eps"];
/// File extensions recognised as audio-only files.
pub const AUDIO_EXTENSIONS: &[&str] = filmcraft_media::AUDIO_EXTENSIONS;
/// File extensions recognised as movies.
pub const VIDEO_EXTENSIONS: &[&str] = &["mp4", "m4v", "mov", "mkv", "webm", "mxf"];
pub use effectcraft_project::VIDEO_EXTENSIONS;
/// File extensions recognised as 3D models (Advanced 3D model layers).
pub const MODEL_EXTENSIONS: &[&str] = &["gltf", "glb", "obj", "stl", "fbx"];
+129 -6
View File
@@ -206,9 +206,14 @@ fn tone_wav() -> Option<PathBuf> {
/// Frame `n` of `src` as RGBA PNG extracted by ffmpeg (Y'CbCr → RGB with the BT.709 matrix).
fn reference_frame(src: &Path, n: usize) -> Image {
reference_frame_in(src, n, "bt709")
}
/// [`reference_frame`] of a movie whose Y'CbCr is in `matrix` (`bt601` for Motion JPEG).
fn reference_frame_in(src: &Path, n: usize, matrix: &str) -> Image {
let stem = src.file_stem().expect("stem").to_string_lossy().to_string();
let name = format!("ref_{stem}_{n}.png");
let vf = format!("select='eq(n\\,{n})',scale=in_color_matrix=bt709:in_range=auto:out_range=pc:flags=bilinear+accurate_rnd+full_chroma_int,format=rgba");
let vf = format!("select='eq(n\\,{n})',scale=in_color_matrix={matrix}:in_range=auto:out_range=pc:flags=bilinear+accurate_rnd+full_chroma_int,format=rgba");
let p = ffmpeg_fixture(&name, &["-i", src.to_str().expect("utf8"), "-vf", &vf, "-fps_mode", "passthrough", "-frames:v", "1"]).expect("ffmpeg");
load_png(&p)
}
@@ -513,12 +518,12 @@ fn dnxhd_mxf() -> Option<PathBuf> {
)
}
/// MXF is importable footage (After Effects imports it), whatever the extension's case.
/// MXF and AVI are importable footage (After Effects imports them), whatever the extension's case.
#[test]
fn mxf_is_importable() {
assert!(effectcraft_media::is_importable(Path::new("/media/A001C003.mxf")));
assert!(effectcraft_media::is_importable(Path::new("/media/A001C003.MXF")));
assert!(!effectcraft_media::is_importable(Path::new("/media/A001C003.avi")), "no AVI demuxer yet");
fn mxf_and_avi_are_importable() {
for name in ["A001C003.mxf", "A001C003.MXF", "capture.avi", "CAPTURE.AVI"] {
assert!(effectcraft_media::is_importable(&Path::new("/media").join(name)), "{name}");
}
}
#[test]
@@ -564,6 +569,124 @@ fn mxf_dnxhd_frames() {
}
}
fn mjpeg_avi() -> Option<PathBuf> {
ffmpeg_fixture(
"testsrc2_mjpeg_pcm.avi",
&[
"-f",
"lavfi",
"-i",
"testsrc2=size=640x360:rate=25:duration=2",
"-f",
"lavfi",
"-i",
"sine=frequency=440:sample_rate=48000:duration=2",
"-c:v",
"mjpeg",
"-q:v",
"2",
"-pix_fmt",
"yuvj420p",
"-c:a",
"pcm_s16le",
"-ac",
"2",
],
)
}
#[test]
fn avi_mjpeg_with_pcm_audio() {
let Some(p) = mjpeg_avi() else { return };
let f = probe(&p).expect("probe");
assert_eq!(f.kind, FootageKind::Video);
assert_eq!((f.width, f.height), (640, 360));
assert_eq!(f.frame_rate, FrameRate::FPS_25);
assert!((f.duration.seconds() - 2.0).abs() < 0.1, "duration {:?}", f.duration.seconds());
assert!(f.has_video && f.has_audio);
assert_eq!(f.codec, "Motion JPEG");
let pool = MediaPool::new();
for n in [0usize, 1, 30, 7, 49] {
let img = pool.frame_at(&f, t_of(f.frame_rate, n as i64)).expect("frame");
let db = psnr(&img, &reference_frame_in(&p, n, "bt601"));
assert!(db > 35.0, "frame {n}: {db:.2} dB");
}
let s = pool.audio_samples(&f, Tick(TICKS_PER_SECOND / 2), 4800, 48_000);
assert_eq!(s.len(), 9600);
let rms = (s.iter().map(|v| v * v).sum::<f32>() / s.len() as f32).sqrt();
assert!((rms - 0.0625).abs() < 0.01, "rms {rms}");
}
/// An AVI whose video FilmCraft can't decode imports, and says why it shows no picture (as an
/// unsupported stream in any other container does), rather than failing as a broken file.
#[test]
fn avi_with_an_unsupported_codec_says_so() {
let Some(p) = ffmpeg_fixture("testsrc2_xvid.avi", &["-f", "lavfi", "-i", "testsrc2=size=320x180:rate=25:duration=1", "-c:v", "mpeg4", "-vtag", "XVID"])
else {
return;
};
let f = probe(&p).expect("a valid AVI imports");
assert_eq!((f.kind, f.width, f.height), (FootageKind::Video, 320, 180));
let pool = MediaPool::new();
if pool.frame(ItemId(1), &f, Tick::ZERO).is_some() {
// FilmCraft decodes MPEG-4 Part 2 now: nothing to report
assert_eq!(pool.problem(&f), None);
return;
}
let why = pool.problem(&f).expect("a failed frame leaves a reason");
assert!(why.contains("MPEG-4 Part 2") && why.contains("isn't supported"), "{why}");
}
/// A file that only looks like an AVI is an import error that says so.
#[test]
fn an_invalid_avi_is_an_import_error() {
let e = probe_bytes("broken.avi", Arc::from(&b"RIFF\x10\0\0\0AVI LIST\0\0\0\0garbage"[..])).expect_err("not an AVI");
assert!(e.to_string().contains("invalid AVI"), "{e}");
}
/// A damaged AVI imports: a truncated recording as far as it goes, and a packet that doesn't
/// decode loses its own frame only, with a reason that says the file is damaged rather than
/// unsupported.
#[test]
fn a_damaged_avi_loses_only_what_is_damaged() {
let Some(p) = mjpeg_avi() else { return };
let bytes = std::fs::read(&p).expect("read");
let full = probe(&p).expect("probe");
let pool = MediaPool::new();
let cut: Arc<[u8]> = bytes[..bytes.len() / 3].into();
let f = probe_bytes("cut.avi", cut.clone()).expect("a truncated AVI imports");
assert!(f.duration > Tick::ZERO && f.duration < full.duration, "{:?} of {:?}", f.duration, full.duration);
pool.add_bytes("cut.avi", cut);
let last = f.frame_rate.frame_at(f.duration - Tick(1));
assert!(pool.frame(ItemId(1), &f, t_of(f.frame_rate, last)).is_some(), "its last frame");
// Garble the JPEG data of the video chunk of frame 10.
let mut bad = bytes.clone();
let mut at = bad.windows(4).position(|w| w == b"movi").expect("movi") + 4;
let mut video = 0;
while let Some(head) = bad.get(at..at + 8) {
let size = u32::from_le_bytes([head[4], head[5], head[6], head[7]]) as usize;
if &head[2..4] == b"dc" {
if video == 10 {
bad[at + 8 + 200..at + 8 + size.min(3000)].iter_mut().enumerate().for_each(|(i, b)| *b = if i % 7 == 0 { 0xFF } else { 0 });
break;
}
video += 1;
}
at += 8 + size + size % 2;
}
assert_eq!(video, 10, "found frame 10's chunk");
let f = probe_bytes("bad.avi", bad.clone().into()).expect("a damaged AVI imports");
pool.add_bytes("bad.avi", bad.into());
assert!(pool.frame(ItemId(2), &f, t_of(f.frame_rate, 10)).is_none(), "the damaged frame");
for n in [9, 11, 30] {
assert!(pool.frame(ItemId(2), &f, t_of(f.frame_rate, n)).is_some(), "frame {n}");
}
let why = pool.problem(&f).expect("a failed frame leaves a reason");
assert!(why.contains("can't be decoded") && !why.contains("isn't supported"), "{why}");
}
#[test]
fn png_sequence_import() {
let Some(first) = png_sequence() else { return };
+5
View File
@@ -44,6 +44,11 @@ impl FieldOrder {
}
}
/// File extensions of the movie containers footage is imported from (FilmCraft reads them). The
/// import and proxy file dialogs, the Media Browser and `effectcraft_media::is_importable` all go
/// by this list.
pub const VIDEO_EXTENSIONS: &[&str] = &["mp4", "m4v", "mov", "mkv", "webm", "mxf", "avi"];
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
pub enum AlphaMode {
#[default]
+1 -1
View File
@@ -37,7 +37,7 @@ mod layers;
mod settings;
pub use comp::{Comp, Guide, Renderer, Resolution};
pub use footage::{AlphaMode, CineonSettings, CineonUnits, FieldOrder, Footage, FootageKind, Proxy, Solid, SourceLayer, cineon_log_to_lin};
pub use footage::{AlphaMode, CineonSettings, CineonUnits, FieldOrder, Footage, FootageKind, Proxy, Solid, SourceLayer, VIDEO_EXTENSIONS, cineon_log_to_lin};
pub use item::{Item, ItemId, ItemKind, LayerId};
pub use layer::{AutoOrient, FrameBlend, Layer, LayerSource, LightKind, MatteKind, PrimitiveKind, Quality, Sampling, Switches, TrackMatte};
pub use layers::Layers;
+13 -1
View File
@@ -1,4 +1,4 @@
use super::{Arc, AtomicBool, Footage, Image, ItemId, Ordering, Tick};
use super::{Arc, AtomicBool, Footage, Image, ItemId, ItemKind, Ordering, Tick};
/// How a [`FootageSource`] hands over float footage (OpenEXR, float TIFF), whose values are
/// linear light: encoded with a curve, as the compositor takes footage, or as stored. Other
@@ -93,6 +93,18 @@ pub trait FootageSource: Send + Sync {
fn problem(&self, _footage: &Footage) -> Option<String> {
None
}
/// [`Self::problem`] of what `item` shows: its proxy's while Use Proxy shows the proxy
/// instead (the reason then says it is the proxy's), else the footage item's own. A
/// composition has one only through its proxy.
fn item_problem(&self, item: &effectcraft_project::Item) -> Option<String> {
match item.proxy.as_deref().filter(|px| px.enabled && px.footage.has_video) {
Some(px) => self.problem(&px.footage).map(|why| format!("Proxy: {why}")),
None => match &item.kind {
ItemKind::Footage(f) => self.problem(f),
_ => None,
},
}
}
}
/// A render's view of a [`FootageSource`] whose frame reads can be called off with `cancel`
+14 -7
View File
@@ -546,6 +546,8 @@ pub(super) fn file_dialog(app: &mut EffectcraftApp, id: &str, params: &Value) ->
OpenProject,
Save(&'static str),
Open(&'static [&'static str]),
/// A movie, or a file with one of these extensions.
OpenFootage(&'static [&'static str]),
}
let (key, ask) = match id {
"file.import" | "file.importMultiple" => ("paths", Ask::Import),
@@ -576,9 +578,7 @@ pub(super) fn file_dialog(app: &mut EffectcraftApp, id: &str, params: &Value) ->
"keys.rpfCameraImport" => ("path", Ask::Open(&["json", "csv", "txt"])),
"essential.exportTemplate" => ("path", Ask::Save("Template.ectemplate")),
"essential.importTemplate" => ("path", Ask::Open(&["ectemplate"])),
"file.setProxy" => {
("path", Ask::Open(&["mp4", "mov", "m4v", "mkv", "webm", "mxf", "png", "jpg", "jpeg", "gif", "webp", "tif", "tiff", "bmp", "tga", "exr"]))
}
"file.setProxy" => ("path", Ask::OpenFootage(&["png", "jpg", "jpeg", "gif", "webp", "tif", "tiff", "bmp", "tga", "exr"])),
_ => return None,
};
if params.get(key).is_some()
@@ -589,10 +589,12 @@ pub(super) fn file_dialog(app: &mut EffectcraftApp, id: &str, params: &Value) ->
{
return None;
}
// Importable files besides movies.
const MEDIA: &[&str] = &[
"mp4", "mov", "m4v", "mkv", "webm", "mxf", "png", "jpg", "jpeg", "gif", "webp", "tif", "tiff", "bmp", "exr", "dpx", "cin", "wav", "aif", "aiff", "mp3",
"flac", "ogg", "opus", "svg", "pdf", "ai", "eps", "psd", "psb", "tga", "hdr", "gltf", "glb", "obj", "stl", "fbx", "json", "csv", "tsv",
"png", "jpg", "jpeg", "gif", "webp", "tif", "tiff", "bmp", "exr", "dpx", "cin", "wav", "aif", "aiff", "mp3", "flac", "ogg", "opus", "svg", "pdf", "ai",
"eps", "psd", "psb", "tga", "hdr", "gltf", "glb", "obj", "stl", "fbx", "json", "csv", "tsv",
];
let footage = |more: &[&'static str]| effectcraft_project::VIDEO_EXTENSIONS.iter().chain(more).copied().collect::<Vec<_>>();
// A host whose blocking dialogs are unsafe inside this frame shows the dialog without
// blocking — macOS: the nested event loop re-entered winit's handler on a drag over the
// window and aborted the app (#511); Linux: the desktop portal's answer never gets pumped,
@@ -601,9 +603,10 @@ pub(super) fn file_dialog(app: &mut EffectcraftApp, id: &str, params: &Value) ->
if let Some(spawn) = app.hooks.file_dialog_async.as_ref() {
let request = match ask {
Ask::Folder => crate::FileDialog::Folder,
Ask::Import => crate::FileDialog::Open { exts: MEDIA.iter().map(|e| e.to_string()).collect(), many: key == "paths" },
Ask::Import => crate::FileDialog::Open { exts: footage(MEDIA).iter().map(|e| e.to_string()).collect(), many: key == "paths" },
Ask::OpenProject => crate::FileDialog::OpenProject,
Ask::Open(exts) => crate::FileDialog::Open { exts: exts.iter().map(|e| e.to_string()).collect(), many: false },
Ask::OpenFootage(more) => crate::FileDialog::Open { exts: footage(more).iter().map(|e| e.to_string()).collect(), many: false },
Ask::Save(default) => crate::FileDialog::save(default),
};
let tx = app.dialog_answers.0.clone();
@@ -624,7 +627,7 @@ pub(super) fn file_dialog(app: &mut EffectcraftApp, id: &str, params: &Value) ->
}
Ask::Import => {
let Some(f) = app.hooks.pick_files.as_ref() else { return Some(Err("no file dialog available (pass `paths`)".into())) };
picked_value(key, f(MEDIA))
picked_value(key, f(&footage(MEDIA)))
}
Ask::OpenProject => {
let Some(f) = app.hooks.pick_open_project.as_ref() else { return Some(Err("no file dialog available (pass `path`)".into())) };
@@ -634,6 +637,10 @@ pub(super) fn file_dialog(app: &mut EffectcraftApp, id: &str, params: &Value) ->
let Some(f) = app.hooks.pick_files.as_ref() else { return Some(Err("no file dialog available (pass `path`)".into())) };
f(exts).first().map(|s| json!(s))
}
Ask::OpenFootage(more) => {
let Some(f) = app.hooks.pick_files.as_ref() else { return Some(Err("no file dialog available (pass `path`)".into())) };
f(&footage(more)).first().map(|s| json!(s))
}
Ask::Save(default) => {
let Some(picked) = app.hooks.save_dialog(default) else { return Some(Err("no file dialog available (pass `path`)".into())) };
picked.map(|s| json!(s))
+3 -6
View File
@@ -1230,12 +1230,9 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
}
icons::paint(&lp, Rect::from_center_size(pos2(x0 + 18.0, r.center().y), vec2(14.0, 14.0)), item_icon(it), t.text_dim);
proxy_indicator(app, ui, &g, it, &mut actions);
// Footage that can't be decoded (no decoder for its codec / profile): a warning marker
// at the end of the name cell, the reason on hover.
let problem = match &it.kind {
ItemKind::Footage(f) => app.session.footage.problem(f),
_ => None,
};
// Footage (or the proxy in use) that can't be decoded (no decoder for its codec /
// profile): a warning marker at the end of the name cell, the reason on hover.
let problem = app.session.footage.item_problem(it);
let name_clip =
Rect::from_min_max(pos2(x0 + 30.0, r.min.y), pos2(r.min.x + name_w + 4.0 - if problem.is_some() { 16.0 } else { 0.0 }, r.max.y)).intersect(list);
if let Some(why) = &problem {
+17 -11
View File
@@ -699,16 +699,9 @@ fn phase_scene(app: &mut EffectcraftApp, ectx: &EvalCtx, v: &mut Vw<'_>) {
app.auto.add("viewer.warpBanner", r, &text);
}
// Footage that can't be decoded (no decoder for its codec / profile): say why on the layer
// instead of leaving a black picture.
let mut notices = 0;
for layer in comp.layers.iter().filter(|l| l.is_active_at(time) && l.switches.video) {
let effectcraft_engine::project::LayerSource::Footage { item } = &layer.source else { continue };
let Some(effectcraft_engine::project::ItemKind::Footage(f)) = app.session.project.item(*item).map(|i| &i.kind) else { continue };
let Some(why) = app.session.footage.problem(f) else { continue };
if notices >= 3 {
break;
}
// Footage (or the proxy in use for footage or a precomp) that can't be decoded (no decoder
// for its codec / profile): say why on the layer instead of leaving a black picture.
for (notices, (layer, why)) in footage_problems(app, ectx, comp, time).into_iter().enumerate() {
let (m, _) = l2c(ectx, layer);
let (w, h) = effectcraft_engine::render::source_size(&app.session.project, layer);
let c = m.apply(effectcraft_engine::geom::vec2(w as f64 / 2.0, h as f64 / 2.0));
@@ -720,7 +713,6 @@ fn phase_scene(app: &mut EffectcraftApp, ectx: &EvalCtx, v: &mut Vw<'_>) {
painter.rect_stroke(r, 3.0, Stroke::new(1.0, t.warning), StrokeKind::Inside);
painter.galley(pos2(r.min.x + 12.0, r.center().y - galley.size().y / 2.0), galley, Color32::WHITE);
app.auto.add(&format!("viewer.footageProblem.{}", layer.id.0), r, &text);
notices += 1;
}
v.vertex_marks = vertex_marks;
v.track_hits = track_hits;
@@ -728,6 +720,20 @@ fn phase_scene(app: &mut EffectcraftApp, ectx: &EvalCtx, v: &mut Vw<'_>) {
v.has_3d = has_3d;
}
/// Up to three layers active at `time` whose footage, or the proxy in use for footage or a
/// precomp, can't be decoded, each with the reason.
fn footage_problems<'c>(app: &EffectcraftApp, ectx: &EvalCtx, comp: &'c Comp, time: Tick) -> Vec<(&'c Layer, String)> {
comp.layers
.iter()
.filter(|l| l.is_active_at(time) && l.switches.video)
.filter_map(|layer| {
let (LayerSource::Footage { item } | LayerSource::Comp { item }) = &layer.source else { return None };
Some((layer, ectx.project.item(*item).and_then(|i| app.session.footage.item_problem(i))?))
})
.take(3)
.collect()
}
fn phase_rigs(app: &mut EffectcraftApp, ui: &mut egui::Ui, ectx: &EvalCtx, v: &mut Vw<'_>) {
let t = v.t;
let cid = v.cid;
+12 -8
View File
@@ -265,10 +265,10 @@ fn relink_restores_decoded_pixels_and_invalidates_viewer_content() {
std::fs::remove_dir_all(dir).unwrap();
}
/// #767: File ▸ Import ▸ File… offers MXF footage (OP1a / OP-Atom from broadcast cameras and
/// Avid), which the Media Browser lists and the importer reads.
/// #767, #1701: File ▸ Import ▸ File… and the proxy dialog offer every movie type the importer
/// reads (MXF from broadcast cameras and Avid, AVI, …), the list the Media Browser goes by too.
#[test]
fn import_dialog_offers_mxf() {
fn import_and_proxy_dialogs_offer_every_movie_type() {
use std::cell::RefCell;
use std::rc::Rc;
@@ -279,11 +279,15 @@ fn import_dialog_offers_mxf() {
*seen.borrow_mut() = exts.iter().map(|e| e.to_string()).collect();
Vec::new()
}));
let result = effectcraft_ui_egui::menus::invoke(&mut app, &egui::Context::default(), "file.import", json!({})).unwrap();
assert!(result.is_null(), "cancelled: nothing imported");
let offered = offered.borrow();
assert!(offered.iter().any(|e| e == "mxf"), "offered {offered:?}");
assert!(offered.iter().any(|e| e == "mov"));
for (command, still) in [("file.import", "psd"), ("file.setProxy", "png")] {
let result = effectcraft_ui_egui::menus::invoke(&mut app, &egui::Context::default(), command, json!({})).unwrap();
assert!(result.is_null(), "{command} cancelled: nothing done");
let offered = offered.borrow();
for ext in effectcraft_project::VIDEO_EXTENSIONS.iter().chain([&still]) {
assert!(offered.iter().any(|e| e == ext), "{command} offers {ext}: {offered:?}");
}
assert!(offered.iter().any(|e| e == "avi"), "{command}: {offered:?}");
}
}
/// #1059: opening the demo from the start screen and immediately pressing Spacebar previews
@@ -1,10 +1,11 @@
//! Footage whose codec the decoders lack says so: a warning marker in the Project panel and a
//! notice on the layer in the Composition viewer, instead of a black picture with no reason.
//! Footage (or the proxy in use for it) whose codec the decoders lack says so: a warning marker in
//! the Project panel and a notice on the layer in the Composition viewer, instead of a black
//! picture with no reason.
use std::sync::Arc;
use effectcraft_engine::Session;
use effectcraft_engine::project::{Footage, FootageKind, ItemId, ItemKind};
use effectcraft_engine::project::{Footage, FootageKind, ItemId, ItemKind, Proxy};
use effectcraft_engine::render::FootageSource;
use effectcraft_render::Image;
use effectcraft_time::Tick;
@@ -14,19 +15,19 @@ use serde_json::json;
const WHY: &str = "H.264 (High 4:2:2) isn't supported by the built-in decoder: chroma_format_idc 2 (only 4:2:0 is implemented)";
/// A source that decodes nothing and, for `broken` movies, says why.
struct Stub(bool);
/// A source that decodes nothing and says why for the files `broken` picks.
struct Stub(fn(&Footage) -> bool);
impl FootageSource for Stub {
fn frame(&self, _: ItemId, _: &Footage, _: Tick) -> Option<Arc<Image>> {
None
}
fn problem(&self, _: &Footage) -> Option<String> {
self.0.then(|| WHY.to_string())
fn problem(&self, f: &Footage) -> Option<String> {
(self.0)(f).then(|| WHY.to_string())
}
}
fn harness(broken: bool) -> (Harness<'static, EffectcraftApp>, ItemId) {
fn harness(broken: fn(&Footage) -> bool, proxy: Option<Footage>) -> (Harness<'static, EffectcraftApp>, ItemId) {
let mut s = Session { footage: Arc::new(Stub(broken)), ..Default::default() };
s.execute("comp.new", json!({"name": "Main", "width": 320, "height": 180, "duration": 4})).unwrap();
let footage = Footage {
@@ -38,17 +39,25 @@ fn harness(broken: bool) -> (Harness<'static, EffectcraftApp>, ItemId) {
duration: Tick::from_seconds_f64(2.0),
..Default::default()
};
let clip = std::sync::Arc::make_mut(&mut s.project).add_item("Clip", Default::default(), None, ItemKind::Footage(footage));
let p = std::sync::Arc::make_mut(&mut s.project);
let clip = p.add_item("Clip", Default::default(), None, ItemKind::Footage(footage));
if let (Some(item), Some(footage)) = (p.items.get_mut(&clip), proxy) {
item.proxy = Some(Box::new(Proxy { footage, enabled: true }));
}
s.execute("layer.addItem", json!({"item": clip.0})).unwrap();
(open(s), clip)
}
fn open(mut s: Session) -> Harness<'static, EffectcraftApp> {
s.execute("project.select", json!({"items": []})).unwrap();
let mut h = Harness::builder().with_size(egui::vec2(1600.0, 1000.0)).build_eframe(|_| EffectcraftApp::new(s));
h.run_steps(4);
(h, clip)
h
}
#[test]
fn undecodable_footage_is_marked_in_the_project_panel_and_the_viewer() {
let (h, clip) = harness(true);
let (h, clip) = harness(|_| true, None);
let marker = h.state().auto.find(&format!("project.item.{}.problem", clip.0)).expect("a warning on the footage item").clone();
assert!(marker.label.contains("4:2:2"), "{marker:?}");
let notice = h.state().auto.query("viewer.footageProblem.").first().copied().expect("a notice in the viewer").clone();
@@ -57,7 +66,39 @@ fn undecodable_footage_is_marked_in_the_project_panel_and_the_viewer() {
#[test]
fn readable_footage_has_no_notice() {
let (h, clip) = harness(false);
let (h, clip) = harness(|_| false, None);
assert!(h.state().auto.find(&format!("project.item.{}.problem", clip.0)).is_none());
assert!(h.state().auto.query("viewer.footageProblem.").is_empty());
}
/// The clip's own file is fine, but the proxy in use for it can't be decoded: the item and the
/// layer say why, and that it is the proxy.
#[test]
fn an_undecodable_proxy_in_use_is_marked_as_the_proxy() {
let proxy = Footage { kind: FootageKind::Video, path: "clip_proxy.avi".into(), width: 32, height: 18, has_video: true, ..Default::default() };
let (h, clip) = harness(|f| f.path.ends_with(".avi"), Some(proxy));
let marker = h.state().auto.find(&format!("project.item.{}.problem", clip.0)).expect("a warning on the footage item").clone();
assert!(marker.label.starts_with("Proxy: ") && marker.label.contains("4:2:2"), "{marker:?}");
let notice = h.state().auto.query("viewer.footageProblem.").first().copied().expect("a notice in the viewer").clone();
assert!(notice.label.contains(&format!("Proxy: {WHY}")), "{notice:?}");
}
/// A precomp whose proxy can't be decoded: the layer says so with Collapse Transformations off
/// or on, since a proxy in use keeps the precomp on the flattened path that draws the proxy.
#[test]
fn a_precomp_with_an_undecodable_proxy_has_a_notice_collapsed_or_not() {
for collapse in [false, true] {
let mut s = Session { footage: Arc::new(Stub(|f| f.path.ends_with(".avi"))), ..Default::default() };
s.execute("comp.new", json!({"name": "Inner", "width": 64, "height": 36, "duration": 4})).unwrap();
s.execute("comp.new", json!({"name": "Main", "width": 320, "height": 180, "duration": 4})).unwrap();
let p = Arc::make_mut(&mut s.project);
let inner = p.items.values_mut().find(|i| i.name == "Inner").unwrap();
let proxy = Footage { kind: FootageKind::Video, path: "inner_proxy.avi".into(), width: 64, height: 36, has_video: true, ..Default::default() };
inner.proxy = Some(Box::new(Proxy { footage: proxy, enabled: true }));
let inner = inner.id;
s.execute("layer.addItem", json!({"item": inner.0})).unwrap();
s.execute("layer.setSwitch", json!({"layers": ["#1"], "switch": "collapse", "value": collapse})).unwrap();
let h = open(s);
assert!(!h.state().auto.query("viewer.footageProblem.").is_empty(), "collapse {collapse}");
}
}
+1 -1
View File
@@ -65,7 +65,7 @@ trips and renders, checked by tests, not against After Effects), **partial** (na
| Apple ProRes (422 family, 4444) | R/W | yes | yes | good; 4444 colour tag wrong (#340); no ProRes RAW |
| WebM / Matroska (VP9, AV1, Opus) | R (limited) | yes, incl. VP9 alpha | yes | beyond After Effects |
| MXF (OP1a / OP-Atom) | R/W | yes (FilmCraft's demuxer: AVC, MPEG-2 incl. XDCAM / D-10, VC-3 / DNxHD, ProRes; PCM audio) | none | partial: import (#767), checked against ffmpeg; no MXF output |
| AVI | R | none | none | Media Browser lists it |
| AVI | R | yes (FilmCraft's demuxer: AVI 1.0 / OpenDML; Motion JPEG, H.264, HEVC, uncompressed; PCM, MP3, MP2, AC-3 audio) | none | partial: import (#1701), checked against ffmpeg; H.264 needs its parameter sets in the chunks (global headers and copies with an `avcC` record don't decode until storytold/filmcraft#2303), and a damaged video index loses the video (storytold/filmcraft#2302); DV, MPEG-4 Part 2 (DivX / Xvid) and other legacy codecs import but show why they have no picture; no AVI output |
| DNxHD / DNxHR | R/W | unverified (FilmCraft decodes it; reachable only inside `.mov`) | none | — |
| BRAW, R3D, ARRIRAW | R (plug-ins / SDKs) | none | none | proprietary SDKs |
| Variable-frame-rate phone video | R | unverified | — | needs a real-media corpus |
+1 -1
View File
@@ -73,7 +73,7 @@ are grouped by area; the issue tracker has the rest.
| 14 | **Object Matte** (26.2, disk cache in 26.5) | First slice (#783): the Object Matte effect (Effect ▸ Matte) and `objectMatte.apply` turn a point (and optional box) on a reference frame into a matte and follow the object up to 600 frames each way, or segment every frame on its own; Roto Brush's engine, cache and model (MobileSAM from Settings ▸ Roto Brush, classical graph cut without it). Since then: the Object Matte tool in the viewer (click / drag a box, hover crosshair), the box as the model's box prompt, background tracking with progress and cancel, Freeze, and segmentations kept in the disk cache (26.5). Missing: a comparison with After Effects ([plan](fidelity/object-matte.md)) | The new one-click roto | 10–20 h | G7 · [target-app-parity.md](target-app-parity.md) |
| 15 | **Hardware video decode and encode** | No VideoToolbox, NVENC, Quick Sync or VA-API; #498 asks for it (answered: CUDA / NVENC need Nvidia's proprietary SDK through FFI, which the no-`unsafe` rule forbids); open PR #819 adds hardware decoding via `filmcraft-platform`; FilmCraft has VideoToolbox / NVENC / VA-API paths to borrow | 4K playback and export speed | 25–45 h | G5/G6 · [hardware-parity.md](hardware-parity.md) |
| 16 | **Localization depth** | Menus in 11 languages (ja, zh-hans, zh-hant, uk, es, pt-br, ru, fr, de, cs, ka: 630 rows each, 629 in Georgian); panels in 7 (ja 996 rows, zh-hans 998, zh-hant 951, uk 524, pt-br 695, cs 518, ka 885); effect names translated in Simplified Chinese (307), Japanese (297) and Georgian (307, plus the generated Effect menu), their ~1,650 parameter names untranslated; Korean and Italian (After Effects ships them) absent; Hangul probe added (#599 closed) | After Effects is used heavily in Japan, China, Korea, Brazil, Europe | 60–110 h + native review | G9 · [localization-parity.md](localization-parity.md) |
| 17 | **Media Browser lists files import refuses** | `media_browser.rs` lists AVI; `media::is_importable` has none of them (Targa and Radiance HDR import since #745; DPX and Cineon since #756, log shown as stored unless Interpret Footage ▸ Cineon Settings converts it; MXF OP1a / OP-Atom since #767; FilmCraft has no AVI demuxer); #431 and #324 closed | Confusing failures on common VFX formats | 15–30 h | G6 · [file-format-parity.md](file-format-parity.md) |
| 17 | **Media Browser lists files import refuses** | The Media Browser, the import and proxy dialogs and `media::is_importable` share one movie list since #1701, AVI included (Targa and Radiance HDR import since #745; DPX and Cineon since #756, log shown as stored unless Interpret Footage ▸ Cineon Settings converts it; MXF OP1a / OP-Atom since #767; AVI since #1701, without DV, MPEG-4 Part 2 or other legacy codecs yet); #431 and #324 closed | Confusing failures on common VFX formats | 15–30 h | G6 · [file-format-parity.md](file-format-parity.md) |
| 18 | **Relative paths and relinking** | Projects store absolute footage paths; no relink when a project moves | Moving or sharing a project breaks it | 6–12 h | G4 · [file-format-parity.md](file-format-parity.md) |
| 19 | **Plug-in ecosystem** | No After Effects SDK hosting (#600, #327 OpenFX, #334, #376 plug-in manager); our WebAssembly API has no third-party plug-ins | Many pro projects depend on Trapcode, Element, Saber… | 40–80 h for API growth; SDK hosting needs an owner decision | G8 · [scripting-parity.md](scripting-parity.md) |
| 20 | **Multi-monitor and video output** | #486: on Windows, macOS and X11 every floating panel is an OS window of its own (Undock Panel, a drag out of the main window, panel menu ▸ Move Panel to New Window, `window.panelWindow`) and so can go onto another screen (on Wayland, the web and in embedded viewports floating panels stay in the app window, since Wayland reports no window positions); tabs and groups drag between windows, shortcuts work in them, and the current and named workspaces restore their places; still missing: Mercury Transmit to video hardware (we have a second preview window only) | Two-screen setups are standard for professionals | 10–20 h | G2 · [hardware-parity.md](hardware-parity.md) |
+1 -1
View File
@@ -114,7 +114,7 @@ compositing and effects dominate; audio is light). Hours are to full parity for
| Area | Weight | Breadth | Ready | Hours | Biggest gaps (details in [gaps.md](gaps.md)) |
|---|---:|---:|---:|---:|---|
| Project, footage and import | 8% | 92% | 55% | 25–45 | No relative paths or relinking when a project moves; Media Browser lists AVI, which it can't import (TGA/HDR/DPX/MXF import since #745, #756, #767); #431, #324, #552 and #351 are closed |
| Project, footage and import | 8% | 92% | 55% | 25–45 | No relative paths or relinking when a project moves; TGA/HDR/DPX/MXF/AVI import since #745, #756, #767, #1701 (no DV or MPEG-4 Part 2 AVI yet); #431, #324, #552 and #351 are closed |
| Compositions, layers and compositing | 13% | 98% | 60% | 30–55 | 32-bpc blend modes differed from After Effects (#494, closed); expand precomps in place (#601); #527, #510, #513, #539 and #540 are closed |
| Animation: keyframes, graph editor, motion paths, puppet | 15% | 97% | 55% | 35–60 | Only scalar ease is measured against After Effects (168 samples); puppet lag (#586); #456, #457, #355, #553 and #318 are closed; Proportional Scrubbing (26.2) |
| Expressions and scripting | 7% | 93% | 65% | 20–40 | `.jsxbin`, a global scope shared between scripts, script shortcuts (`#include` landed, #759; Startup/Shutdown folders run); external editor (#364); see [scripting-parity.md](scripting-parity.md) |