Merge main into #1739; keep both footage re-exports (Cineon + VIDEO_EXTENSIONS) and main's gaps.md localization row with the PR's AVI row

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
bflatastic
2026-10-30 07:44:20 +07:00
co-authored by Claude Opus 5.5
181 changed files with 8437 additions and 619 deletions
+15 -11
View File
@@ -155,12 +155,7 @@ fn unsupported(project: &Project, comp: &Comp, l: &Layer, format: TimelineFormat
return Some(format!("blend mode {}", l.blend_mode.label()));
}
// Premiere's Basic Motion in the interchange formats has one (uniform) scale.
if let Some(s) = l.transform().and_then(|t| t.get("scale"))
&& std::iter::once(&s.value).chain(s.keys.iter().map(|k| &k.value)).any(|v| {
let a = v.as_vec3();
(a[0] - a[1]).abs() > 1e-9
})
{
if l.transform().and_then(|t| t.get("scale")).is_some_and(non_uniform_scale) {
return Some("non-uniform scale".into());
}
if l.preserve_transparency {
@@ -581,10 +576,7 @@ impl Exporter<'_> {
}
}
if let Some(p) = tr.get("scale") {
let uniform = std::iter::once(&p.value).chain(p.keys.iter().map(|k| &k.value)).all(|v| {
let a = v.as_vec3();
(a[0] - a[1]).abs() < 1e-9
});
let uniform = !non_uniform_scale(p);
m.params.insert("scale".into(), param_out(p, &to_clip, |v| fp::ParamValue::Float(v.as_vec3()[1])));
if !uniform {
m.params.insert("scale_width".into(), param_out(p, &to_clip, |v| fp::ParamValue::Float(v.as_vec3()[0])));
@@ -636,6 +628,16 @@ fn differs(v: &Value, def: [f64; 2]) -> bool {
(a[0] - def[0]).abs() > 1e-6 || (a[1] - def[1]).abs() > 1e-6
}
/// Whether a scale property is non-uniform at some point: active key values, or the static
/// value when every key is muted (muted keys are not exported, #1894).
fn non_uniform_scale(p: &Property) -> bool {
let non_uniform = |v: &Value| {
let a = v.as_vec3();
(a[0] - a[1]).abs() > 1e-9
};
if p.keys.iter().any(|k| !k.muted) { p.keys.iter().filter(|k| !k.muted).any(|k| non_uniform(&k.value)) } else { non_uniform(&p.value) }
}
/// The clip's `id` effect, or a new instance of it (None if the registry has no such effect).
fn effect_or_new(ti: &fp::TrackItem, id: &str) -> Option<fp::EffectInstance> {
ti.effect(id).cloned().or_else(|| fp::find_effect(id).map(|e| e.instance()))
@@ -651,7 +653,9 @@ fn set_effect(ti: &mut fp::TrackItem, e: fp::EffectInstance) {
/// A property → a FilmCraft parameter (layer-time keys → clip-time keys).
fn param_out(p: &Property, to_clip: &impl Fn(Tick) -> filmcraft_time::Tick, conv: impl Fn(&Value) -> fp::ParamValue) -> fp::Param {
let mut out = fp::Param::new(conv(&p.value));
for k in &p.keys {
// XML timelines have no mute state: leave muted keys out, as the Lottie exporter does, so
// the exported animation matches what the renderer shows (#1894).
for k in p.keys.iter().filter(|k| !k.muted) {
let mut fk = fp::Keyframe::new(to_clip(k.time), conv(&k.value));
fk.interp = match k.out_interp {
Interp::Linear => fp::Interpolation::Linear,
+54 -1
View File
@@ -415,7 +415,7 @@ fn ae_project() -> (Project, ItemId) {
let mut ls = effectcraft_project::build::layer(&mut p, &c, "tone", LayerSource::Footage { item: s }, (0, 0), None);
ls.out_point = t(3.0);
ls.props.prop_mut("audio/levels").unwrap().value = Value::Vec2([-3.0, -3.0]);
c.layers = vec![lt, lp, la, ls];
c.layers = vec![lt, lp, la, ls].into();
let cid = comp_item(&mut p, "Main", c);
(p, cid)
}
@@ -484,6 +484,59 @@ fn tr_orig(l: &Layer) -> &effectcraft_project::Property {
l.transform().unwrap().get("position").unwrap()
}
/// #1894: XML timelines have no mute state, so a muted key must not be written as a keyframe
/// (the export would otherwise animate through it).
#[test]
fn export_skips_muted_keyframes() {
let mut p = Project::default();
let a = footage_item(&mut p, "/media/a.mov");
let mut c = Comp::new(W, H, FrameRate::new(25, 1), t(4.0));
let mut l = effectcraft_project::build::layer(&mut p, &c, "clip", LayerSource::Footage { item: a }, (W, H), None);
{
let pos = l.transform_mut().unwrap().get_mut("position").unwrap();
pos.keys = vec![
Keyframe::new(t(0.0), Value::Vec3([100.0, 120.0, 0.0])),
Keyframe::new(t(1.0), Value::Vec3([500.0, 240.0, 0.0])),
Keyframe::new(t(2.0), Value::Vec3([300.0, 60.0, 0.0])),
];
pos.keys[1].muted = true;
}
c.layers.push(l);
let cid = comp_item(&mut p, "Main", c);
let out = export(&p, cid, &ExportOptions { format: TimelineFormat::Fcp7Xml, ..Default::default() }, &HashMap::new()).unwrap();
let (q, r) = import_doc(&out.bytes, "Main.xml", &mut probe);
let main = r.comps.iter().copied().find(|c| q.item(*c).unwrap().name == "Main").expect("Main");
let pos = layer(q.comp(main).unwrap(), "clip").transform().unwrap().get("position").unwrap();
assert_eq!(pos.keys.len(), 2, "the muted key was exported");
assert_eq!(pos.keys[0].value.as_vec3()[0], 100.0);
assert_eq!(pos.keys[1].value.as_vec3()[0], 300.0);
}
/// #1894: a muted non-uniform scale key must not force a uniform layer to pre-render; only the
/// active keys matter, and the static value when every key is muted.
#[test]
fn muted_non_uniform_scale_does_not_force_prerendering() {
let mut p = Project::default();
let a = footage_item(&mut p, "/media/a.mov");
let mut c = Comp::new(W, H, FrameRate::new(25, 1), t(2.0));
let mut l = effectcraft_project::build::layer(&mut p, &c, "clip", LayerSource::Footage { item: a }, (W, H), None);
{
let scale = l.transform_mut().unwrap().get_mut("scale").unwrap();
scale.keys = vec![Keyframe::new(t(0.0), Value::Vec3([100.0, 100.0, 100.0])), Keyframe::new(t(1.0), Value::Vec3([50.0, 200.0, 100.0]))];
scale.keys[1].muted = true;
}
let lid = l.id;
c.layers.push(l);
let cid = comp_item(&mut p, "Main", c);
assert_eq!(prerender_reason(&p, cid, lid, &ExportOptions::default()), None);
// With every key muted the static value drives evaluation, so a non-uniform static scale
// still takes the flattened path.
let scale = p.comp_mut(cid).unwrap().layer_mut(lid).unwrap().transform_mut().unwrap().get_mut("scale").unwrap();
scale.keys[0].muted = true;
scale.value = Value::Vec3([50.0, 200.0, 100.0]);
assert_eq!(prerender_reason(&p, cid, lid, &ExportOptions::default()).as_deref(), Some("non-uniform scale"));
}
#[test]
fn export_without_prerender_leaves_text_out() {
let (p, cid) = ae_project();