mirror of
https://github.com/storytold/effectcraft.git
synced 2026-11-15 14:52:15 +00:00
Merge remote-tracking branch 'upstream/main' into colstab-sample-size-3125
# Conflicts: # docs/aep-import.md
This commit is contained in:
commit
c229c19ab0
48 files changed
+1809
-98
No files matched your search
@@ -37,7 +37,9 @@ mod version;
|
||||
mod write;
|
||||
|
||||
pub use aepx::{is_aepx, parse_any, read_aepx, read_aepx_with_limits};
|
||||
pub use arbitrary::{CURVE_IDENTITY, CryptomatteData, CurvesData, HueSaturationData, LevelsData, MeshData, curve_lut};
|
||||
pub use arbitrary::{
|
||||
CURVE_IDENTITY, CryptomatteData, CurvesData, ExtractorChannels, HueSaturationData, LevelsData, MeshData, curve_lut, identifier_channel_type,
|
||||
};
|
||||
pub use camera::{CameraPosition, DEFAULT_CAMERA_ZOOM};
|
||||
pub use chunk::{AepFile, Body, Chunk, LIST, NESTED_IDS, Walk};
|
||||
pub use effects::{DefinitionSource, Effect, EffectDefinitions, EffectParam, LayerRef, ParamKind, ParamValue};
|
||||
|
||||
@@ -308,6 +308,82 @@ impl MeshData {
|
||||
}
|
||||
}
|
||||
|
||||
/// EXtractoR's Channel Info data (`EXtractoR-0013`, 688 bytes, the plug-in's own little-endian
|
||||
/// layout; read from the instances of the importer block of 2026-11-05: the defaults and a preset
|
||||
/// of our own that reads `Z` into Red, Green and Blue): a `u32` 1 and 28 bytes, then four records
|
||||
/// of 164 bytes (Red, Green, Blue, Alpha), each a kind in byte 0 (1 a channel of the footage by
|
||||
/// name, 2 the input's own channel: `(copy)`; bytes 1..4 are not always zero, `0x32` at 3 in one
|
||||
/// definition's default, so only byte 0 is read) and the channel name from byte 35,
|
||||
/// NUL-terminated. The other bytes are not read (they differ between instances with the same
|
||||
/// channels; the `u32` at 4 of a chosen channel looks like its position in the footage's sorted
|
||||
/// channel list, 13 of 14 for `Z`, where the defaults hold 0). Only the name is read.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct ExtractorChannels {
|
||||
/// Per output channel (red, green, blue, alpha): the kind and the name.
|
||||
pub channels: [(u32, String); 4],
|
||||
}
|
||||
|
||||
impl ExtractorChannels {
|
||||
/// The data's length.
|
||||
pub const LEN: usize = 688;
|
||||
const RECORD: usize = 164;
|
||||
|
||||
/// `None` when the data does not have this layout.
|
||||
pub fn parse(data: &[u8]) -> Option<Self> {
|
||||
if data.len() != Self::LEN || data.get(..4)? != 1u32.to_le_bytes() {
|
||||
return None;
|
||||
}
|
||||
let mut channels: [(u32, String); 4] = Default::default();
|
||||
for (k, slot) in channels.iter_mut().enumerate() {
|
||||
let at = 32usize.checked_add(k.checked_mul(Self::RECORD)?)?;
|
||||
let rec = data.get(at..at.checked_add(Self::RECORD)?)?;
|
||||
let kind = u32::from(*rec.first()?);
|
||||
let name = rec.get(35..)?;
|
||||
let end = name.iter().position(|b| *b == 0)?;
|
||||
let name = std::str::from_utf8(name.get(..end)?).ok()?;
|
||||
*slot = (kind, name.to_string());
|
||||
}
|
||||
Some(Self { channels })
|
||||
}
|
||||
|
||||
/// The channels of a fresh EXtractoR: Red, Green and Blue from the channels of those names,
|
||||
/// Alpha copied from the input.
|
||||
pub fn is_default(&self) -> bool {
|
||||
let want = [(1, "R"), (1, "G"), (1, "B"), (2, "(copy)")];
|
||||
self.channels.iter().zip(want).all(|((k, n), (wk, wn))| *k == wk && n == wn)
|
||||
}
|
||||
|
||||
/// Per output channel (red, green, blue, alpha) the channel it reads, `Err` with the
|
||||
/// position of the first record of another kind or without a name: a named channel by its
|
||||
/// name, `(copy)` as EffectCraft's [`EXTRACTOR_COPY`](effectcraft_effects::EXTRACTOR_COPY),
|
||||
/// the effect input's own channel of the same position. It is not `R`, `G`, `B` or `A`:
|
||||
/// EffectCraft's EXtractoR reads those from an OpenEXR footage's channels of that name, the
|
||||
/// file's own, which loses what an earlier effect did to the channel.
|
||||
pub fn sources(&self) -> Result<[String; 4], usize> {
|
||||
let mut out: [String; 4] = Default::default();
|
||||
for (k, ((kind, name), slot)) in self.channels.iter().zip(out.iter_mut()).enumerate() {
|
||||
*slot = match (kind, name.trim()) {
|
||||
(1, n) if !n.is_empty() => n.to_string(),
|
||||
(2, _) => effectcraft_effects::EXTRACTOR_COPY.to_string(),
|
||||
_ => return Err(k),
|
||||
};
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
}
|
||||
|
||||
/// IDentifier's Channel Info data (`IDentifier-0005`, 148 bytes): the chosen channel's name,
|
||||
/// NUL-terminated from byte 19, or `Object ID` when none is chosen (importer blocks 1300 and 1310
|
||||
/// of 2026-11-05: `objectId` in a preset of our own that chose that UINT channel, `Object ID` in
|
||||
/// every other instance). The bytes around it differ between instances with the same settings
|
||||
/// and are not read (a chosen channel has 15 at byte 4, which looks like its position in the
|
||||
/// EXR's sorted channel list, as for EXtractoR).
|
||||
pub fn identifier_channel_type(data: &[u8]) -> Option<&str> {
|
||||
let s = data.get(19..)?;
|
||||
let end = s.iter().position(|b| *b == 0)?;
|
||||
std::str::from_utf8(s.get(..end)?).ok()
|
||||
}
|
||||
|
||||
/// The Cryptomatte plug-in's Settings data (`Cryptomatte-0001`, the plug-in's own layout,
|
||||
/// little-endian; read from 88 instances of one project and from our own sample 1400, fresh
|
||||
/// instances and two presets that pick a layer and an object, docs/aep-format.md 3.7): the magic
|
||||
|
||||
@@ -141,6 +141,10 @@ pub enum Extra {
|
||||
Curves,
|
||||
/// Mesh Warp's Distortion Mesh (965, 985, `MeshData`).
|
||||
MeshWarp,
|
||||
/// EXtractoR's Channel Info (`ExtractorChannels`): the channels by name.
|
||||
Extractor,
|
||||
/// IDentifier's Channel Info (`identifier_channel_type`): the chosen channel by name.
|
||||
Identifier,
|
||||
/// The Cryptomatte plug-in: the layer and selection in its Settings data
|
||||
/// (`CryptomatteData`), Output split over EffectCraft's Display and Matte Only; every
|
||||
/// parameter is handled by the importer's Cryptomatte step, none by the table.
|
||||
@@ -361,6 +365,8 @@ pub const SHUTTER_CONTROL: &[&str] = &["Manual", "Automatic"];
|
||||
pub const ECHO_OPERATOR: &[&str] = &["Add", "Maximum", "Minimum", "Screen", "Composite In Back", "Composite In Front", "Blend"];
|
||||
/// Color Stabilizer's Stabilize (`colstab-02`, `colstab-03`; the fresh default is Levels).
|
||||
pub const STABILIZE: &[&str] = &["Brightness", "Levels", "Curves"];
|
||||
/// IDentifier's Display (`identifier-04` ... `identifier-06`, `1300-thirdparty.aep`).
|
||||
pub const IDENTIFIER_DISPLAY: &[&str] = &["Colors", "Luma Matte", "Alpha Matte", "Raw"];
|
||||
|
||||
/// The effects the importer converts.
|
||||
pub const EFFECTS: &[EffectMap] = &[
|
||||
@@ -1076,6 +1082,40 @@ pub const EFFECTS: &[EffectMap] = &[
|
||||
pm(5, K::FixedSlider, "sampleSize", Conv::Plain { valid_max: 50.0 }),
|
||||
],
|
||||
),
|
||||
// CC Color Neutralizer (importer block 2026-11-05, `ccneutral-*`): Pinning and Blend w.
|
||||
// Original are float sliders in percent (valid 0..100). After Effects' correction is the Red /
|
||||
// Green / Blue slider of each range (3-5, 9-11, 15-17; the Unbalance colours are pickers that
|
||||
// set them and keep no record), then View (22) and Black / White Point (23, 24):
|
||||
// EffectCraft's CC Color Neutralizer has none of them, so they are reported when stored.
|
||||
fx(
|
||||
"CS Color Neutralizer",
|
||||
"ec.color.cccolorneutralizer",
|
||||
&[pm(19, K::FloatSlider, "pinning", Conv::Plain { valid_max: 100.0 }), pm(20, K::FloatSlider, "blendWOriginal", Conv::Plain { valid_max: 100.0 })],
|
||||
),
|
||||
// EXtractoR (`extractor-*`): 1 Channel Info (arbitrary data, `Extra::Extractor`), 2 / 6 the
|
||||
// Process group, Black and White Point float sliders (valid ±3.4e38, the float32 range),
|
||||
// UnMult. EffectCraft's Clip has no counterpart and stays off.
|
||||
EffectMap {
|
||||
match_name: "EXtractoR",
|
||||
effect: "ec.3d.extractor",
|
||||
params: &[
|
||||
pm(3, K::FloatSlider, "blackPoint", Conv::Plain { valid_max: F32_MAX }),
|
||||
pm(4, K::FloatSlider, "whitePoint", Conv::Plain { valid_max: F32_MAX }),
|
||||
pm(5, K::Checkbox, "unMult", Conv::Checkbox),
|
||||
],
|
||||
extra: Extra::Extractor,
|
||||
},
|
||||
// IDentifier (`identifier-*`): 1 Channel Info (arbitrary data, `Extra::Identifier`), 2 Display,
|
||||
// 3 ID an integer slider (valid 0..2 147 483 647).
|
||||
EffectMap {
|
||||
match_name: "IDentifier",
|
||||
effect: "ec.3d.identifier",
|
||||
params: &[
|
||||
pm(2, K::Popup, "display", Conv::Popup { default: 1, items: IDENTIFIER_DISPLAY }),
|
||||
pm(3, K::IntSlider, "id", Conv::Plain { valid_max: 2_147_483_647.0 }),
|
||||
],
|
||||
extra: Extra::Identifier,
|
||||
},
|
||||
// Layer Control (076, 220, 320, 321): the default is the owning layer, None is unset.
|
||||
fx("ADBE Layer Control", "ec.control.layer", &[pm(1, K::Layer, "layer", Conv::LayerRef { own: true })]),
|
||||
fx("ADBE Slider Control", "ec.control.slider", &[pm(1, K::FloatSlider, "slider", Conv::Plain { valid_max: 1_000_000.0 })]),
|
||||
@@ -1089,6 +1129,8 @@ pub const EFFECTS: &[EffectMap] = &[
|
||||
pub const EXPOSURE: Conv = Conv::Plain { valid_max: 100.0 };
|
||||
pub const OFFSET: Conv = Conv::Plain { valid_max: 2.0 };
|
||||
pub const GAMMA: Conv = Conv::Plain { valid_max: 10.0 };
|
||||
/// The largest float32, the valid maximum of EXtractoR's Black and White Point.
|
||||
pub const F32_MAX: f64 = f32::MAX as f64;
|
||||
/// The valid maximum of Glow's A & B Midpoint (16.16 of 0.99, 427).
|
||||
pub const GLOW_MIDPOINT_MAX: f64 = 0.990_005_493_164_062_5;
|
||||
|
||||
|
||||
@@ -461,7 +461,7 @@ fn plan_layer_param(layer: &Layer, g: &PropGroup, target: &str, own: bool, prop:
|
||||
fn plan_extra(spec: &EffectMap, g: &PropGroup, pdefs: &HashMap<u16, ParDef>, here: &str, col: &mut Collector) -> Option<(u16, Vec<u8>)> {
|
||||
let ids: &[&str] = match spec.extra {
|
||||
// (the Cryptomatte plug-in's settings are its own binary layout: not written, reported below)
|
||||
Extra::None | Extra::Curves | Extra::MeshWarp | Extra::Cryptomatte => return None,
|
||||
Extra::None | Extra::Curves | Extra::MeshWarp | Extra::Cryptomatte | Extra::Extractor | Extra::Identifier => return None,
|
||||
Extra::HueSaturation => &HUE_SATURATION_MASTER,
|
||||
Extra::Levels => &LEVELS_RGB,
|
||||
};
|
||||
@@ -643,7 +643,9 @@ fn report_unmapped(g: &PropGroup, (effect, spec): (&str, &EffectMap), size: (f64
|
||||
// effect was added at is not known here.
|
||||
let (fresh, other) = (fresh([size.0, size.1], &mut next), fresh([size.0 * 2.0 + 7.0, size.1 * 3.0 + 5.0], &mut next));
|
||||
let extra: &[&str] = match spec.extra {
|
||||
Extra::None | Extra::Cryptomatte => &[],
|
||||
// EXtractoR and IDentifier have no prototype to write from, so they never get this far
|
||||
// (they are reported).
|
||||
Extra::None | Extra::Cryptomatte | Extra::Extractor | Extra::Identifier => &[],
|
||||
Extra::HueSaturation => &HUE_SATURATION_MASTER,
|
||||
Extra::Levels => &LEVELS_RGB,
|
||||
Extra::Curves => &["channel", "rgb", "red", "green", "blue", "alpha"],
|
||||
|
||||
@@ -1047,9 +1047,10 @@ fn plan_layer(cx: &PlanCx<'_>, at: CompAt<'_>, cp: &CompPlan, layer: &Layer, col
|
||||
let props = plan_transform(layer, &src, comp, source_size, cp, comp_name, col);
|
||||
let remap = remap_prop.filter(|_| remapped).map(|p| plan_remap(p, comp, cp, &layer.name, comp_name, col));
|
||||
let mut name = cut_name(&layer.name, 255, &what("layer"), col);
|
||||
// A text layer named after its text stores no name (110, 113: After Effects shows the text).
|
||||
// A text layer named after its text stores no name (110, 113: After Effects shows the text):
|
||||
// the layer's own rule decides (#3233).
|
||||
if let LayerSrc::Text(tp) = &src
|
||||
&& tp.text.split('\r').next().is_some_and(|first| first.chars().take(64).collect::<String>() == name)
|
||||
&& layer.name_follows_text(&effectcraft_project::text_layer_name(&tp.text))
|
||||
{
|
||||
name.clear();
|
||||
}
|
||||
|
||||
@@ -30,6 +30,7 @@ mod effects;
|
||||
mod markers;
|
||||
mod masks;
|
||||
mod props;
|
||||
mod puppet;
|
||||
mod report;
|
||||
mod settings;
|
||||
mod shapes;
|
||||
|
||||
@@ -497,7 +497,12 @@ fn build_layers(project: &mut Project, cx: &mut Ctx<'_>, defs: &EffectDefinition
|
||||
.map(|d| crate::import::text::first_paragraph(&d.raw_text))
|
||||
.filter(|t| !t.trim().is_empty())
|
||||
};
|
||||
let name = aep_name.clone().or_else(|| src.as_ref().map(|s| s.name.clone())).or_else(text_name).unwrap_or_else(|| "Layer".to_string());
|
||||
let name = aep_name
|
||||
.clone()
|
||||
.or_else(|| src.as_ref().map(|s| s.name.clone()))
|
||||
.or_else(text_name)
|
||||
// An empty text layer without a name is named as a new one is (#3233).
|
||||
.unwrap_or_else(|| if h.layer_type == LayerType::Text { "Text" } else { "Layer" }.to_string());
|
||||
let skip = |cx: &mut Ctx<'_>, skipped: &mut HashMap<u32, String>, category: LossCategory, what: &str| {
|
||||
cx.col.lose(category, format!("{comp_name}: {what} '{name}'"));
|
||||
skipped.insert(h.id, what.to_string());
|
||||
@@ -555,6 +560,10 @@ fn build_layers(project: &mut Project, cx: &mut Ctx<'_>, defs: &EffectDefinition
|
||||
};
|
||||
let mut l: Layer = build::layer(project, &comp, &name, source.clone(), size, None);
|
||||
l.name = name.clone();
|
||||
// A text layer without a stored name follows its text; a stored name is the user's (#3233).
|
||||
if is_text {
|
||||
l.name_follows_text = Some(aep_name.is_none());
|
||||
}
|
||||
l.label = Label::ALL.get(usize::from(h.label)).copied().unwrap_or(Label::None);
|
||||
apply_timing(&mut l, &h, &tm, &name, &mut cx.col);
|
||||
apply_switches(&mut l, &h);
|
||||
@@ -699,7 +708,14 @@ fn apply_timing(l: &mut Layer, h: &LayerHeader, tm: &TimeMap, name: &str, col: &
|
||||
col.lose(LossCategory::DamagedData, format!("layer '{name}': the time stretch is zero or unreadable, 100 % is used"));
|
||||
Rational { num: 1, den: 1 }
|
||||
});
|
||||
l.stretch = stretch.to_f64().map_or(100.0, |r| r * 100.0);
|
||||
let stored = stretch.to_f64().map_or(100.0, |r| r * 100.0);
|
||||
// A ratio such as 1/2^31 (5e-8 %) or 2^31/1 is outside what After Effects allows (±1 % to
|
||||
// ±9900 %); taken as stored, the layer and audio time would divide by ~0 (#3247). The in and
|
||||
// out points keep the comp times the file stores.
|
||||
l.stretch = effectcraft_project::limits::stretch(stored);
|
||||
if l.stretch.to_bits() != stored.to_bits() {
|
||||
col.lose(LossCategory::DamagedData, format!("layer '{name}': the time stretch of {stored:e} % is outside ±1 % to ±9900 %, {} % is used", l.stretch));
|
||||
}
|
||||
// A stored time with a zero denominator (or a sum that overflows) has no value; it is read as 0
|
||||
// and reported, so the layer's timing never changes silently.
|
||||
let unreadable: Vec<&str> = [("start", h.start), ("in point", h.in_relative), ("out point", h.out_relative)]
|
||||
|
||||
@@ -36,8 +36,8 @@
|
||||
|
||||
use crate::effect_map::{CRYPTOMATTE_OUTPUT, Conv, EFFECTS, EffectMap, Extra, HUE_SATURATION_MASTER, LEVELS_RGB, popup_key};
|
||||
use crate::{
|
||||
CryptomatteData, CurvesData, DefinitionSource, Effect as AepEffect, EffectDefinitions, EffectParam, HueSaturationData, Layer as AepLayer, LayerRef,
|
||||
LevelsData, MeshData, ParamKind, ParamValue,
|
||||
CryptomatteData, CurvesData, DefinitionSource, Effect as AepEffect, EffectDefinitions, EffectParam, ExtractorChannels, HueSaturationData,
|
||||
Layer as AepLayer, LayerRef, LevelsData, MeshData, ParamKind, ParamValue,
|
||||
};
|
||||
use effectcraft_keyframe::Value;
|
||||
use effectcraft_project::build::Ids;
|
||||
@@ -76,6 +76,11 @@ pub(crate) fn import_effects<'f, 'a>(
|
||||
col: &mut Collector,
|
||||
) {
|
||||
for fx in layer.effects() {
|
||||
// Puppet keeps its meshes and pins in nested groups, not in `parT` (its own step).
|
||||
if fx.match_name() == crate::import::puppet::MATCH_NAME {
|
||||
crate::import::puppet::import_puppet(defs, &fx, cx, ids, target, col);
|
||||
continue;
|
||||
}
|
||||
let shown = fx.display_name().unwrap_or_else(|| fx.match_name().to_string());
|
||||
let Some(map) = EFFECTS.iter().find(|m| m.match_name == fx.match_name()) else {
|
||||
col.lose(LossCategory::Effects, format!("{}: '{}' has the effect '{shown}' ({})", cx.comp, cx.layer, fx.match_name()));
|
||||
@@ -128,7 +133,7 @@ pub(crate) fn import_effects<'f, 'a>(
|
||||
}
|
||||
|
||||
/// Parameters that never carry a value to import.
|
||||
fn carries_nothing(p: &EffectParam<'_, '_>) -> bool {
|
||||
pub(crate) fn carries_nothing(p: &EffectParam<'_, '_>) -> bool {
|
||||
matches!(p.kind, ParamKind::Button | ParamKind::NoData | ParamKind::GroupStart | ParamKind::GroupEnd) || (p.index == 0 && p.kind == ParamKind::Layer)
|
||||
}
|
||||
|
||||
@@ -438,7 +443,15 @@ fn import_extra(fx: &AepEffect<'_, '_>, extra: Extra, p: &EffectParam<'_, '_>, h
|
||||
col.lose(LossCategory::Effects, format!("{here}: keyframes or an expression on {} are not imported", p.name));
|
||||
}
|
||||
let Some(data) = fx.arbitrary_data(&p.match_name) else {
|
||||
// No data: every value is at its default.
|
||||
// No record: every value is at its default. A Channel Info value record without its
|
||||
// aRbs/aRbp payload (every sampled record has it) is damaged: the payload may have chosen
|
||||
// other channels (`Z`), so the defaults are not what After Effects renders.
|
||||
if p.stored().is_some() && matches!(extra, Extra::Extractor | Extra::Identifier) {
|
||||
col.lose(
|
||||
LossCategory::DamagedData,
|
||||
format!("{here}: the Channel Info data is missing (a value record without its data), the default channels used"),
|
||||
);
|
||||
}
|
||||
return;
|
||||
};
|
||||
let mut set = |id: &str, v: f64| {
|
||||
@@ -489,6 +502,60 @@ fn import_extra(fx: &AepEffect<'_, '_>, extra: Extra, p: &EffectParam<'_, '_>, h
|
||||
Some(m) => import_mesh(&m, here, size, group, col),
|
||||
None => col.lose(LossCategory::DamagedData, format!("{here}: the Distortion Mesh is unreadable, defaults used")),
|
||||
},
|
||||
Extra::Extractor => match ExtractorChannels::parse(data).map(|c| (c.sources(), c)) {
|
||||
// Each output channel reads the footage's channel of the stored name, which
|
||||
// EffectCraft's EXtractoR finds by the same name (`Z`); After Effects' `(copy)` is the
|
||||
// input's own channel, EffectCraft's `(copy)` (not `A`, which EffectCraft reads from an
|
||||
// OpenEXR footage's alpha plane, losing what earlier effects did to it).
|
||||
Some((Ok(names), _)) => {
|
||||
for (id, name) in ["red", "green", "blue", "alpha"].into_iter().zip(names) {
|
||||
if let Some(prop) = group.prop_mut(id) {
|
||||
prop.value = Value::Str(name);
|
||||
}
|
||||
}
|
||||
}
|
||||
Some((Err(k), c)) => {
|
||||
let names: Vec<&str> = c.channels.iter().map(|(_, n)| n.as_str()).collect();
|
||||
col.lose(
|
||||
LossCategory::Effects,
|
||||
format!(
|
||||
"{here}: the channels {} are not imported (record {} is of an unknown kind or names no channel), R, G, B, A used",
|
||||
names.join(", "),
|
||||
k.saturating_add(1)
|
||||
),
|
||||
);
|
||||
}
|
||||
None => col.lose(LossCategory::Effects, format!("{here}: the Channel Info data is not in the sampled layout, R, G, B, A used")),
|
||||
},
|
||||
Extra::Identifier => match crate::identifier_channel_type(data) {
|
||||
// No channel chosen (the type's label): After Effects lists only the footage's integer
|
||||
// (UINT) channels and, with none chosen, leaves the layer unchanged, where EffectCraft
|
||||
// draws the footage's object ID channel.
|
||||
Some("Object ID") => {
|
||||
if let Some(prop) = group.prop_mut("channelType") {
|
||||
prop.value = Value::Enum(0);
|
||||
}
|
||||
col.lose(
|
||||
LossCategory::Effects,
|
||||
format!("{here}: no ID channel is chosen (Channel Info), so After Effects leaves the layer unchanged; EffectCraft draws the footage's object ID channel"),
|
||||
);
|
||||
}
|
||||
// A chosen channel, by name. EffectCraft's IDentifier has no channel of its own: it
|
||||
// reads the footage's first `ObjectID` (`MaterialID`) channel, names compared without
|
||||
// regard to case, so a channel of that name is the one After Effects reads.
|
||||
Some(name) => match IDENTIFIER_OWN.iter().position(|own| own.eq_ignore_ascii_case(name.trim())) {
|
||||
Some(kind) => {
|
||||
if let Some(prop) = group.prop_mut("channelType") {
|
||||
prop.value = Value::Enum(u32::try_from(kind).unwrap_or(0));
|
||||
}
|
||||
}
|
||||
None => col.lose(
|
||||
LossCategory::Effects,
|
||||
format!("{here}: the ID channel '{name}' (Channel Info) is not imported; EffectCraft reads the footage's object ID channel (ObjectID, object_id, objectId, id)"),
|
||||
),
|
||||
},
|
||||
None => col.lose(LossCategory::Effects, format!("{here}: the Channel Info data is not in the sampled layout, the object ID channel used")),
|
||||
},
|
||||
Extra::None | Extra::Cryptomatte => {}
|
||||
}
|
||||
}
|
||||
@@ -630,6 +697,10 @@ fn crypto_output(p: &EffectParam<'_, '_>, here: &str, label: &str, col: &mut Col
|
||||
}
|
||||
}
|
||||
|
||||
/// The channels EffectCraft's IDentifier reads first for its Channel Type Object ID and Material
|
||||
/// ID (`AuxChannels::object_id`, `material_id`).
|
||||
const IDENTIFIER_OWN: [&str; 2] = ["ObjectID", "MaterialID"];
|
||||
|
||||
/// Curves' EffectCraft parameters, in the stored order (RGB, Red, Green, Blue, Alpha).
|
||||
const CURVE_IDS: [&str; 5] = ["rgb", "red", "green", "blue", "alpha"];
|
||||
|
||||
|
||||
@@ -0,0 +1,234 @@
|
||||
//! After Effects' Puppet effect (`ADBE FreePin3`) arrives as EffectCraft's Puppet
|
||||
//! (`ec.distort.puppet`) in its place in the stack, with its name and fx switch, but **without
|
||||
//! its meshes and pins**: how After Effects stores a pin's rest place (a vertex index and offset
|
||||
//! into the hidden mesh data) and its Position (pixels or a fraction of the layer) is not decoded
|
||||
//! yet, and a guessed rest place would deform the layer wrongly without a word. What the instance
|
||||
//! holds is reported instead, mesh by mesh: Triangles, Expansion, and per pin container (Deform,
|
||||
//! Stiffness, Overlap) the number of pins, how many are keyed or carry an expression, and the
|
||||
//! match names of their atoms (Advanced and Bend pins show up there). Any entry this reader does
|
||||
//! not know is named verbatim.
|
||||
//!
|
||||
//! The tree read here (docs/aep-format.md 3.7, Puppet) comes from an external scripting listing
|
||||
//! of the effect's property tree, not from the corpus (no corpus file has a Puppet): **?** until a
|
||||
//! byte sample exists. It is read with the same verified group reader as masks, shapes and layer
|
||||
//! styles; a file whose tree differs is reported, never guessed at.
|
||||
|
||||
use effectcraft_project::build::Ids;
|
||||
use effectcraft_project::{Node, PropGroup};
|
||||
|
||||
use crate::import::effects::{LayerCtx, carries_nothing};
|
||||
use crate::import::report::{Collector, LossCategory};
|
||||
use crate::{Effect as AepEffect, EffectDefinitions, ParamValue, PropGroup as AepGroup, PropNode, Property as AepProperty};
|
||||
|
||||
/// After Effects' Puppet effect.
|
||||
pub(crate) const MATCH_NAME: &str = "ADBE FreePin3";
|
||||
/// The hidden group that holds the meshes (`arap`).
|
||||
const ARAP: &str = "ADBE FreePin3 ARAP Group";
|
||||
/// Auto-traced Shapes, beside the meshes.
|
||||
const OUTLINES: &str = "ADBE FreePin3 Outlines";
|
||||
/// The hidden group of the meshes.
|
||||
const MESH_GROUP: &str = "ADBE FreePin3 Mesh Group";
|
||||
/// One mesh (`Mesh 1`).
|
||||
const MESH_ATOM: &str = "ADBE FreePin3 Mesh Atom";
|
||||
/// A mesh's hidden triangulation.
|
||||
const MESH_DATA: &str = "ADBE FreePin3 Mesh";
|
||||
const TRIANGLES: &str = "ADBE FreePin3 Mesh Tri Count";
|
||||
const EXPANSION: &str = "ADBE FreePin3 Mesh Expansion";
|
||||
/// At most this many meshes, pin atom names or unrecognised names are listed in a report line;
|
||||
/// the rest are counted (`and 3 more`).
|
||||
const LIST_CAP: usize = 10;
|
||||
/// The pin containers of a mesh: match name and the name After Effects shows.
|
||||
const PIN_GROUPS: [(&str, &str); 3] = [("ADBE FreePin3 PosPins", "Deform"), ("ADBE FreePin3 HghtPins", "Overlap"), ("ADBE FreePin3 StarchPins", "Stiffness")];
|
||||
|
||||
/// Imports a Puppet instance (the caller checked the match name) into `target`.
|
||||
pub(crate) fn import_puppet<'f, 'a>(
|
||||
defs: &EffectDefinitions<'f, 'a>,
|
||||
fx: &AepEffect<'f, 'a>,
|
||||
cx: &LayerCtx<'_>,
|
||||
ids: &mut Ids<'_>,
|
||||
target: &mut PropGroup,
|
||||
col: &mut Collector,
|
||||
) {
|
||||
let shown = fx.display_name().unwrap_or_else(|| fx.match_name().to_string());
|
||||
let here = format!("{}: '{}' effect '{shown}' ({MATCH_NAME})", cx.comp, cx.layer);
|
||||
let Some(spec) = effectcraft_effects::find(effectcraft_effects::puppet::ID) else {
|
||||
col.lose(LossCategory::Effects, format!("{}: '{}' has the effect '{shown}' ({MATCH_NAME})", cx.comp, cx.layer));
|
||||
return;
|
||||
};
|
||||
let size = [f64::from(cx.size.0), f64::from(cx.size.1)];
|
||||
let mut group = effectcraft_effects::instantiate(spec, ids, &shown, size);
|
||||
match fx.enabled() {
|
||||
Some(on) => group.enabled = on,
|
||||
None => col.lose(LossCategory::DamagedData, format!("{}: '{}': the fx switch of '{shown}' is unreadable, imported switched on", cx.comp, cx.layer)),
|
||||
}
|
||||
// The ordinary parameters (`parT`): none is mapped yet, so a stored one is reported.
|
||||
let params = defs.params(fx);
|
||||
for p in ¶ms {
|
||||
if carries_nothing(p) {
|
||||
continue;
|
||||
}
|
||||
if p.stored().is_some() && !matches!(p.stored_value(), Some(ParamValue::Layer(0))) {
|
||||
let label = if p.name.is_empty() { p.match_name.clone() } else { format!("{} ({})", p.name, p.match_name) };
|
||||
col.lose(LossCategory::Effects, format!("{here}: parameter {label} not imported"));
|
||||
}
|
||||
}
|
||||
report_meshes(fx, ¶ms.iter().map(|p| p.match_name.as_str()).collect::<Vec<_>>(), &here, col);
|
||||
// The effect itself arrives (an EffectCraft Puppet the user can pin again), so it counts as
|
||||
// imported; what it held is in the losses above.
|
||||
col.imported.effects = col.imported.effects.saturating_add(1);
|
||||
target.children.push(Node::Group(group));
|
||||
}
|
||||
|
||||
/// Reports the meshes and pins of `fx` and every entry of its value group that is neither a
|
||||
/// `parT` parameter (`param_names`) nor the mesh tree.
|
||||
fn report_meshes(fx: &AepEffect<'_, '_>, param_names: &[&str], here: &str, col: &mut Collector) {
|
||||
let mut unknown: Vec<String> = Vec::new();
|
||||
let values = fx.chunk().child_list(b"tdgp").and_then(AepGroup::from_chunk);
|
||||
let mut arap = None;
|
||||
for e in values.map(|g| g.entries()).unwrap_or_default() {
|
||||
if e.match_name == ARAP {
|
||||
if let PropNode::Group(g) = e.node {
|
||||
if arap.is_none() {
|
||||
arap = Some(g);
|
||||
}
|
||||
} else {
|
||||
push_unique(&mut unknown, e.match_name);
|
||||
}
|
||||
} else if !param_names.contains(&e.match_name.as_str()) {
|
||||
match e.node {
|
||||
// A value outside `parT` (On Transparent, ...): reported with what it holds.
|
||||
PropNode::Property(p) => col.lose(LossCategory::Effects, format!("{here}: property {} ({}) not imported", e.match_name, describe(&p))),
|
||||
PropNode::Group(_) | PropNode::Other(_) => push_unique(&mut unknown, e.match_name),
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut meshes: Vec<String> = Vec::new();
|
||||
let mut found = false;
|
||||
if let Some(arap) = arap {
|
||||
for e in arap.entries() {
|
||||
match (e.match_name.as_str(), e.node) {
|
||||
(MESH_GROUP, PropNode::Group(g)) if !found => {
|
||||
found = true;
|
||||
for m in g.entries() {
|
||||
match (m.match_name.as_str(), m.node) {
|
||||
(MESH_ATOM, PropNode::Group(mesh)) => {
|
||||
let n = meshes.len().saturating_add(1);
|
||||
meshes.push(mesh_summary(&mesh, n, &mut unknown));
|
||||
}
|
||||
_ => push_unique(&mut unknown, m.match_name.clone()),
|
||||
}
|
||||
}
|
||||
}
|
||||
// Auto-traced Shapes are made from the layer again (EffectCraft traces the alpha).
|
||||
(OUTLINES, _) => {}
|
||||
_ => push_unique(&mut unknown, e.match_name.clone()),
|
||||
}
|
||||
}
|
||||
}
|
||||
let extra = if unknown.is_empty() { String::new() } else { format!("; not recognised: {}", capped(&unknown, ", ")) };
|
||||
if !found {
|
||||
col.lose(
|
||||
LossCategory::Effects,
|
||||
format!("{here}: imported without meshes or pins: its mesh group ({ARAP} / {MESH_GROUP}) was not found or is not a group{extra}"),
|
||||
);
|
||||
} else if !meshes.is_empty() {
|
||||
col.lose(
|
||||
LossCategory::Effects,
|
||||
format!(
|
||||
"{here}: imported without its meshes and pins (After Effects' pin rest places and positions are not decoded yet): {}{extra}",
|
||||
capped(&meshes, "; ")
|
||||
),
|
||||
);
|
||||
} else if !unknown.is_empty() {
|
||||
col.lose(LossCategory::Effects, format!("{here}: no mesh{extra}"));
|
||||
}
|
||||
}
|
||||
|
||||
/// `Mesh n (Triangles t, Expansion e): Deform 3 pins (1 keyed) [atom names], Overlap no pins, ...`
|
||||
fn mesh_summary(mesh: &AepGroup<'_, '_>, n: usize, unknown: &mut Vec<String>) -> String {
|
||||
let name = mesh.user_name().unwrap_or_else(|| format!("Mesh {n}"));
|
||||
let mut settings: Vec<String> = Vec::new();
|
||||
let mut containers: Vec<String> = Vec::new();
|
||||
for e in mesh.entries() {
|
||||
let label = match e.match_name.as_str() {
|
||||
TRIANGLES => Some("Triangles"),
|
||||
EXPANSION => Some("Expansion"),
|
||||
_ => None,
|
||||
};
|
||||
match (label, e.node) {
|
||||
(Some(label), PropNode::Property(p)) => settings.push(format!("{label} {}", describe(&p))),
|
||||
// The triangulation After Effects keeps; EffectCraft builds its own.
|
||||
_ if e.match_name == MESH_DATA => {}
|
||||
_ => match (PIN_GROUPS.iter().find(|(m, _)| *m == e.match_name), e.node) {
|
||||
(Some((_, shown)), PropNode::Group(g)) => containers.push(format!("{shown} {}", pins_summary(&g, unknown))),
|
||||
_ => push_unique(unknown, e.match_name),
|
||||
},
|
||||
}
|
||||
}
|
||||
let settings = if settings.is_empty() { String::new() } else { format!(" ({})", settings.join(", ")) };
|
||||
let containers = if containers.is_empty() { "no pin groups".to_string() } else { containers.join(", ") };
|
||||
format!("{name}{settings}: {containers}")
|
||||
}
|
||||
|
||||
/// `3 pins (1 keyed, 1 with an expression) [ADBE FreePin3 PosPin Atom]`, or `no pins`. An entry
|
||||
/// of the container that is not a group (a pin is one) goes to `unknown`.
|
||||
fn pins_summary(container: &AepGroup<'_, '_>, unknown: &mut Vec<String>) -> String {
|
||||
let mut count = 0usize;
|
||||
let mut keyed = 0usize;
|
||||
let mut expressions = 0usize;
|
||||
let mut atoms: Vec<String> = Vec::new();
|
||||
for e in container.entries() {
|
||||
let PropNode::Group(pin) = e.node else {
|
||||
push_unique(unknown, e.match_name);
|
||||
continue;
|
||||
};
|
||||
count = count.saturating_add(1);
|
||||
push_unique(&mut atoms, e.match_name);
|
||||
let props: Vec<AepProperty<'_, '_>> =
|
||||
pin.entries().into_iter().filter_map(|p| if let PropNode::Property(p) = p.node { Some(p) } else { None }).collect();
|
||||
if props.iter().any(AepProperty::is_animated) {
|
||||
keyed = keyed.saturating_add(1);
|
||||
}
|
||||
if props.iter().any(|p| p.expression().is_some()) {
|
||||
expressions = expressions.saturating_add(1);
|
||||
}
|
||||
}
|
||||
if count == 0 {
|
||||
return "no pins".to_string();
|
||||
}
|
||||
let mut what = vec![format!("{keyed} keyed")];
|
||||
if expressions > 0 {
|
||||
what.push(format!("{expressions} with an expression"));
|
||||
}
|
||||
format!("{count} pin{} ({}) [{}]", if count == 1 { "" } else { "s" }, what.join(", "), capped(&atoms, ", "))
|
||||
}
|
||||
|
||||
/// A stored property's value for the report: `keyed`, the static value, or `unreadable`.
|
||||
fn describe(p: &AepProperty<'_, '_>) -> String {
|
||||
if p.is_animated() {
|
||||
return "keyed".to_string();
|
||||
}
|
||||
match p.static_value() {
|
||||
Some(v) if v.len() == 1 => v.first().map(|x| format!("{x}")).unwrap_or_default(),
|
||||
Some(v) => format!("[{}]", v.iter().map(|x| format!("{x}")).collect::<Vec<_>>().join(", ")),
|
||||
None => "unreadable".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Adds `name` to `names` unless it is there already.
|
||||
fn push_unique(names: &mut Vec<String>, name: String) {
|
||||
if !names.contains(&name) {
|
||||
names.push(name);
|
||||
}
|
||||
}
|
||||
|
||||
/// The first [`LIST_CAP`] of `items` joined by `sep`, and how many more there are.
|
||||
fn capped(items: &[String], sep: &str) -> String {
|
||||
let shown: Vec<&str> = items.iter().take(LIST_CAP).map(String::as_str).collect();
|
||||
let mut out = shown.join(sep);
|
||||
let more = items.len().saturating_sub(LIST_CAP);
|
||||
if more > 0 {
|
||||
out.push_str(&format!("{sep}and {more} more"));
|
||||
}
|
||||
out
|
||||
}
|
||||
@@ -26,10 +26,11 @@ pub(crate) const MAX_TEXT_CHARS: usize = 1 << 20;
|
||||
/// U+0003 (END OF TEXT): After Effects' other paragraph break besides `\r`.
|
||||
const ETX: char = '\u{3}';
|
||||
|
||||
/// The first paragraph of a stored text, at most 64 characters: the name After Effects shows for
|
||||
/// a text layer without a name of its own (U+0003 ends a paragraph like `\r`).
|
||||
/// The name of a text layer without a name of its own: its first paragraph, cut as EffectCraft
|
||||
/// cuts a following name ([`effectcraft_project::text_layer_name`]), so a following name that
|
||||
/// export writes as no name comes back unchanged (#3233). U+0003 ends a paragraph like `\r`.
|
||||
pub(crate) fn first_paragraph(raw: &str) -> String {
|
||||
raw.split(['\r', ETX]).next().unwrap_or_default().chars().take(64).collect()
|
||||
effectcraft_project::text_layer_name(raw.split(ETX).next().unwrap_or_default())
|
||||
}
|
||||
|
||||
/// The Source Text value of `doc`, with what cannot be mapped reported for the layer `what`.
|
||||
|
||||
@@ -16,11 +16,12 @@ pub mod import;
|
||||
|
||||
pub use container::{
|
||||
AeVersion, AepError, AepFile, AutoKern, BlendMode, Body, CURVE_IDENTITY, CameraPosition, Caps, Chunk, Comp, CompHeader, CryptomatteData, CueType,
|
||||
CurvesData, DEFAULT_CAMERA_ZOOM, DefinitionSource, Effect, EffectDefinitions, EffectParam, ExpressionFlags, FontBaseline, Footage, GradientType,
|
||||
HueSaturationData, Interpolation, Item, ItemKind, Justification, KernRun, KeyEase, KeyLayout, KeyList, Keyframe, KeyframeKind, LIST, Layer, LayerHeader,
|
||||
LayerRef, LayerType, LevelsData, LightType, Limits, ListHeader, Marker, Mask, MaskAtom, MaskMode, MatteMode, MeshData, NESTED_IDS, Orientation,
|
||||
OrientationKey, ParagraphRun, ParamKind, ParamValue, PathVertex, PolystarType, Project, ProjectSettings, PropEntry, PropGroup, PropNode, Property, Quality,
|
||||
Rational, Renderer, SequenceInfo, ShapeDetail, ShapeGroup, ShapeItem, ShapePath, SpatialKey, Stroke, StrokeCap, StrokeJoin, StyleRun, TextBox,
|
||||
TextDocument, TextValue, TimeBase, TimeDisplay, TimeRemap, TimeRemapKey, TrackMatte, Tracked, Transform, ValueSource, Walk, WorkingSpace, curve_lut,
|
||||
effect_map, is_aepx, parse, parse_any, parse_text_dict, parse_with_limits, read_aepx, read_aepx_with_limits, style_map, write, write_to,
|
||||
CurvesData, DEFAULT_CAMERA_ZOOM, DefinitionSource, Effect, EffectDefinitions, EffectParam, ExpressionFlags, ExtractorChannels, FontBaseline, Footage,
|
||||
GradientType, HueSaturationData, Interpolation, Item, ItemKind, Justification, KernRun, KeyEase, KeyLayout, KeyList, Keyframe, KeyframeKind, LIST, Layer,
|
||||
LayerHeader, LayerRef, LayerType, LevelsData, LightType, Limits, ListHeader, Marker, Mask, MaskAtom, MaskMode, MatteMode, MeshData, NESTED_IDS,
|
||||
Orientation, OrientationKey, ParagraphRun, ParamKind, ParamValue, PathVertex, PolystarType, Project, ProjectSettings, PropEntry, PropGroup, PropNode,
|
||||
Property, Quality, Rational, Renderer, SequenceInfo, ShapeDetail, ShapeGroup, ShapeItem, ShapePath, SpatialKey, Stroke, StrokeCap, StrokeJoin, StyleRun,
|
||||
TextBox, TextDocument, TextValue, TimeBase, TimeDisplay, TimeRemap, TimeRemapKey, TrackMatte, Tracked, Transform, ValueSource, Walk, WorkingSpace,
|
||||
curve_lut, effect_map, identifier_channel_type, is_aepx, parse, parse_any, parse_text_dict, parse_with_limits, read_aepx, read_aepx_with_limits, style_map,
|
||||
write, write_to,
|
||||
};
|
||||
@@ -364,6 +364,25 @@ fn what_cannot_be_written_is_reported() {
|
||||
parse(&r.bytes).expect("still a valid file");
|
||||
}
|
||||
|
||||
/// CC Color Neutralizer, EXtractoR and IDentifier are in the import table but have no prototype
|
||||
/// to write from: each is reported, not written.
|
||||
#[test]
|
||||
fn imported_plug_in_effects_are_reported_not_written() {
|
||||
for effect in ["ec.color.cccolorneutralizer", "ec.3d.extractor", "ec.3d.identifier"] {
|
||||
let mut p = skip!(tint(|_| {}));
|
||||
let c = p.items.values().find(|i| i.name == "C").map(|i| i.id).expect("C");
|
||||
with_layer(&mut p, c, 0, |l| {
|
||||
if let Some(Node::Group(g)) = l.props.sub_mut("effects").and_then(|e| e.children.first_mut()) {
|
||||
g.kind = effectcraft_project::GroupKind::Effect { effect: effect.into() };
|
||||
}
|
||||
});
|
||||
let r = export_all(&p);
|
||||
assert_eq!(r.report.count(LossCategory::Effects), 1, "{effect}: {:?}", r.report.losses);
|
||||
assert_eq!(r.report.exported.effects, 0, "{effect}");
|
||||
parse(&r.bytes).expect("still a valid file");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hostile_effect_values_never_panic() {
|
||||
for v in [Value::Scalar(f64::NAN), Value::Scalar(1e300), Value::Color([f32::NAN as f64, 2.0, -1.0, 1.0]), Value::Bool(true), Value::Text(Box::default())] {
|
||||
|
||||
@@ -196,6 +196,69 @@ fn documents_built_in_code_are_written_as_their_fields() {
|
||||
assert_eq!(layer.header().map(|h| h.layer_type), Some(effectcraft_aep::LayerType::Text));
|
||||
}
|
||||
|
||||
/// #3233: whether a text layer's name follows its text survives export and import. A followed
|
||||
/// name is written as no name (After Effects shows the text) and comes back following; a name the
|
||||
/// user gave is written, even when it equals the text, and comes back as the user's. Without a
|
||||
/// recorded state (older projects) a name equal to the text follows.
|
||||
#[test]
|
||||
fn a_text_layer_name_follows_its_text_through_export_and_import() {
|
||||
for (name, follows, stored) in [
|
||||
("Hello", Some(false), Some("Hello")),
|
||||
("Hello 2", Some(true), None),
|
||||
("Hello", None, None),
|
||||
("Text 2", None, None),
|
||||
("Text Title", None, Some("Text Title")),
|
||||
("Text Title", Some(false), Some("Text Title")),
|
||||
] {
|
||||
let case = format!("{name:?} {follows:?}");
|
||||
let (mut p, c) = text_project(vec![TextDoc::plain("Hello")]);
|
||||
with_layer(&mut p, c, 0, |l| {
|
||||
l.name = name.into();
|
||||
l.name_follows_text = follows;
|
||||
});
|
||||
let r = export_all(&p);
|
||||
let file = parse(&r.bytes).expect("parse");
|
||||
let view = effectcraft_aep::Project::new(&file).expect("project");
|
||||
let layer = view.all_items().into_iter().find_map(|i| i.comp()).and_then(|c| c.layers().next()).expect("layer");
|
||||
assert_eq!(layer.name().as_deref(), stored, "stored name ({case})");
|
||||
let back = reimport(&r.bytes);
|
||||
let l = back.comps().flat_map(|(_, c)| c.layers.iter()).find(|l| matches!(l.source, LayerSource::Text)).expect("text layer");
|
||||
assert_eq!(l.name, stored.unwrap_or("Hello"), "imported name ({case})");
|
||||
assert_eq!(l.name_follows_text, Some(stored.is_none()), "imported state ({case})");
|
||||
}
|
||||
// An empty text layer that follows its text is written without a name, as After Effects
|
||||
// writes it, and comes back named "Text", still following.
|
||||
let (mut p, c) = text_project(vec![TextDoc::plain("")]);
|
||||
with_layer(&mut p, c, 0, |l| {
|
||||
l.name = "Text".into();
|
||||
l.name_follows_text = Some(true);
|
||||
});
|
||||
let back = reimport(&export_all(&p).bytes);
|
||||
let l = back.comps().flat_map(|(_, c)| c.layers.iter()).find(|l| matches!(l.source, LayerSource::Text)).expect("text layer");
|
||||
assert_eq!((l.name.as_str(), l.name_follows_text), ("Text", Some(true)), "an empty text layer");
|
||||
}
|
||||
|
||||
/// #3233: a following name cut from a first line longer than 40 characters is written as no name
|
||||
/// and comes back as the same cut name, still following; a long name the user gave stays whole.
|
||||
#[test]
|
||||
fn a_long_first_line_names_a_text_layer_the_same_after_export_and_import() {
|
||||
let line = "A headline that runs past forty characters, to 64";
|
||||
assert!((41..=64).contains(&line.chars().count()));
|
||||
let text = format!("{line}\rSecond");
|
||||
let cut = effectcraft_project::text_layer_name(&text);
|
||||
assert_eq!(cut.chars().count(), 40);
|
||||
for (name, follows, back_name) in [(cut.as_str(), Some(true), cut.as_str()), (line, Some(false), line)] {
|
||||
let (mut p, c) = text_project(vec![TextDoc::plain(&text)]);
|
||||
with_layer(&mut p, c, 0, |l| {
|
||||
l.name = name.into();
|
||||
l.name_follows_text = follows;
|
||||
});
|
||||
let back = reimport(&export_all(&p).bytes);
|
||||
let l = back.comps().flat_map(|(_, c)| c.layers.iter()).find(|l| matches!(l.source, LayerSource::Text)).expect("text layer");
|
||||
assert_eq!((l.name.as_str(), l.name_follows_text), (back_name, follows), "{name:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn what_a_text_document_cannot_hold_is_reported() {
|
||||
let mut d = TextDoc::plain("x");
|
||||
|
||||
@@ -15,10 +15,12 @@ mod effects_batch5;
|
||||
mod effects_quickwins;
|
||||
mod effects_round2;
|
||||
mod effects_round3;
|
||||
mod effects_thirdparty;
|
||||
mod fx_inject;
|
||||
mod markers;
|
||||
mod masks;
|
||||
mod mattes;
|
||||
mod puppet;
|
||||
mod resaved_defaults;
|
||||
mod robustness;
|
||||
mod shapes;
|
||||
|
||||
@@ -72,7 +72,7 @@ fn the_table_names_tint_gradient_ramp_and_exposure() {
|
||||
] {
|
||||
assert!(t.contains(&want), "{want:?}");
|
||||
}
|
||||
assert_eq!(t.len(), 63);
|
||||
assert_eq!(t.len(), 66);
|
||||
for (_, id) in t {
|
||||
assert!(effectcraft_effects::find(id).is_some(), "{id}");
|
||||
}
|
||||
|
||||
@@ -0,0 +1,296 @@
|
||||
//! CC Color Neutralizer, EXtractoR and IDentifier (#3112) arrive as EffectCraft's effects of the
|
||||
//! same name. Each is built into `synthetic.aep` (`fx_inject`) with the definitions After Effects
|
||||
//! 2026 wrote for it in the importer block of 2026-11-05 (`1300-thirdparty.aep`) and the values
|
||||
//! of that block's compositions; the Channel Info data is laid out as those instances store it.
|
||||
|
||||
use crate::import::fx_inject::{Default, Fx, losses, near, other, popup, run, slider, v};
|
||||
use effectcraft_aep::import::LossCategory;
|
||||
use effectcraft_aep::{ExtractorChannels, ParamKind as K, identifier_channel_type};
|
||||
use effectcraft_keyframe::Value;
|
||||
|
||||
fn neutralizer(values: Vec<(usize, f64)>) -> Fx {
|
||||
let range = |name| slider(K::FloatSlider, name, 0.0, (-100.0, 100.0));
|
||||
let group = |name| other(K::GroupStart, name, Default::None);
|
||||
let end = || other(K::GroupEnd, "", Default::None);
|
||||
Fx {
|
||||
match_name: "CS Color Neutralizer",
|
||||
name: "CC Color Neutralizer",
|
||||
defs: vec![
|
||||
other(K::Color, "Shadows Unbalance", Default::Color([0, 0, 0, 0])),
|
||||
group("Shadows"),
|
||||
range("Red - Shadows"),
|
||||
range("Green - Shadows"),
|
||||
range("Blue - Shadows"),
|
||||
end(),
|
||||
other(K::Color, "Midtones Unbalance", Default::Color([255, 127, 127, 127])),
|
||||
group("Midtones"),
|
||||
range("Red - Midtones"),
|
||||
range("Green - Midtones"),
|
||||
range("Blue - Midtones"),
|
||||
end(),
|
||||
other(K::Color, "Highlights Unbalance", Default::Color([255, 255, 255, 255])),
|
||||
group("Highlights"),
|
||||
range("Red - Highlights"),
|
||||
range("Green - Highlights"),
|
||||
range("Blue - Highlights"),
|
||||
end(),
|
||||
slider(K::FloatSlider, "Pinning", 0.0, (0.0, 100.0)),
|
||||
slider(K::FloatSlider, "Blend w. Original", 0.0, (0.0, 100.0)),
|
||||
group("Special"),
|
||||
popup("View", 1, &["Final Result", "Correction B to W", "Correction Stair"]),
|
||||
slider(K::FloatSlider, "Black Point", 0.0, (0.0, 49.9)),
|
||||
slider(K::FloatSlider, "White Point", 100.0, (50.1, 100.0)),
|
||||
end(),
|
||||
],
|
||||
values,
|
||||
data: vec![],
|
||||
keys: vec![],
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cc_color_neutralizer_pinning_and_blend() {
|
||||
// `ccneutral-00-defaults` and the Pinning / Blend of `ccneutral-01-set-all`.
|
||||
let (g, r) = run(&neutralizer(vec![(19, 16.0), (20, 19.0)]), "ec.color.cccolorneutralizer");
|
||||
assert_eq!(losses(&r, LossCategory::Effects, "CS Color Neutralizer"), Vec::<String>::new());
|
||||
let g = g.expect("imported");
|
||||
near(&g, "pinning", 16.0);
|
||||
near(&g, "blendWOriginal", 19.0);
|
||||
// EffectCraft's own (identity) balance stays.
|
||||
assert_eq!(v(&g, "midtonesUnbalance"), v(&g, "midtonesBalance"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cc_color_neutralizer_corrections_without_a_counterpart_are_reported() {
|
||||
// `ccneutral-03-sec-shadows`, `ccneutral-09-pop22-2`, `ccneutral-06-sec-special`.
|
||||
let (g, r) = run(&neutralizer(vec![(3, 13.0), (4, 16.0), (5, 19.0), (22, 2.0), (23, 10.0), (24, 87.0)]), "ec.color.cccolorneutralizer");
|
||||
let found = losses(&r, LossCategory::Effects, "CS Color Neutralizer");
|
||||
for name in ["Red - Shadows", "Green - Shadows", "Blue - Shadows", "View", "Black Point", "White Point"] {
|
||||
assert!(found.iter().any(|d| d.contains(&format!("parameter {name} (")) && d.contains("not imported")), "{name}: {found:?}");
|
||||
}
|
||||
assert_eq!(found.len(), 6, "{found:?}");
|
||||
assert!(g.is_some(), "the effect itself is imported");
|
||||
}
|
||||
|
||||
/// EXtractoR's Channel Info data with these record kinds and names.
|
||||
fn channels(recs: [(u32, &str); 4]) -> Vec<u8> {
|
||||
let mut d = vec![0u8; ExtractorChannels::LEN];
|
||||
d[..4].copy_from_slice(&1u32.to_le_bytes());
|
||||
for (k, (kind, name)) in recs.iter().enumerate() {
|
||||
let at = 32 + k * 164;
|
||||
d[at..at + 4].copy_from_slice(&kind.to_le_bytes());
|
||||
d[at + 35..at + 35 + name.len()].copy_from_slice(name.as_bytes());
|
||||
}
|
||||
d
|
||||
}
|
||||
|
||||
const DEFAULT_CHANNELS: [(u32, &str); 4] = [(1, "R"), (1, "G"), (1, "B"), (2, "(copy)")];
|
||||
|
||||
fn extractor(values: Vec<(usize, f64)>, data: Vec<u8>) -> Fx {
|
||||
let mut values = values;
|
||||
values.insert(0, (1, 0.0));
|
||||
Fx {
|
||||
match_name: "EXtractoR",
|
||||
name: "EXtractoR",
|
||||
defs: vec![
|
||||
other(K::ArbitraryData, "Channel Info", Default::None),
|
||||
other(K::GroupStart, "Process", Default::None),
|
||||
slider(K::FloatSlider, "Black Point", 0.0, (-f64::from(f32::MAX), f64::from(f32::MAX))),
|
||||
slider(K::FloatSlider, "White Point", 1.0, (-f64::from(f32::MAX), f64::from(f32::MAX))),
|
||||
other(K::Checkbox, "", Default::Number(0.0)),
|
||||
other(K::GroupEnd, "", Default::None),
|
||||
],
|
||||
values,
|
||||
data: vec![(1, data)],
|
||||
keys: vec![],
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extractor_channel_info_layout() {
|
||||
let c = ExtractorChannels::parse(&channels(DEFAULT_CHANNELS)).expect("the default layout");
|
||||
assert!(c.is_default());
|
||||
let c = ExtractorChannels::parse(&channels([(1, "Z"), (1, "G"), (1, "B"), (2, "(copy)")])).expect("parses");
|
||||
assert!(!c.is_default());
|
||||
assert_eq!(c.channels[0], (1, "Z".to_string()));
|
||||
assert_eq!(ExtractorChannels::parse(&[0u8; 10]), None);
|
||||
// The kind is byte 0 of a record: the definition default of `extractor-00-defaults` has
|
||||
// `02 00 00 32` on Alpha, still the input's own channel.
|
||||
let c = ExtractorChannels::parse(&channels([(1, "R"), (1, "G"), (1, "B"), (0x3200_0002, "(copy)")])).expect("parses");
|
||||
assert!(c.is_default());
|
||||
assert_eq!(c.sources(), Ok(["R", "G", "B", "(copy)"].map(String::from)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extractor_points_unmult_and_default_channels() {
|
||||
// `extractor-01-set-all`: Black Point 10, White Point 14, UnMult on.
|
||||
let (g, r) = run(&extractor(vec![(3, 10.0), (4, 14.0), (5, 1.0)], channels(DEFAULT_CHANNELS)), "ec.3d.extractor");
|
||||
assert_eq!(losses(&r, LossCategory::Effects, "EXtractoR"), Vec::<String>::new());
|
||||
let g = g.expect("imported");
|
||||
near(&g, "blackPoint", 10.0);
|
||||
near(&g, "whitePoint", 14.0);
|
||||
assert_eq!((v(&g, "unMult"), v(&g, "clip")), (Value::Bool(true), Value::Bool(false)));
|
||||
// After Effects' `(copy)` Alpha is the input's alpha: EffectCraft's `(copy)`, not `A` (an
|
||||
// OpenEXR's own alpha plane).
|
||||
for (id, want) in [("red", "R"), ("green", "G"), ("blue", "B"), ("alpha", "(copy)")] {
|
||||
assert_eq!(v(&g, id), Value::Str(want.into()), "{id}");
|
||||
}
|
||||
}
|
||||
|
||||
/// `extractor-07-preset-channel-z` (a preset of our own): `Z` into Red, Green and Blue, Alpha
|
||||
/// `(copy)`. EffectCraft's EXtractoR reads the footage's channel by the same name.
|
||||
#[test]
|
||||
fn extractor_channels_are_imported_by_name() {
|
||||
let (g, r) = run(&extractor(vec![], channels([(1, "Z"), (1, "Z"), (1, "Z"), (2, "(copy)")])), "ec.3d.extractor");
|
||||
assert_eq!(losses(&r, LossCategory::Effects, "EXtractoR"), Vec::<String>::new());
|
||||
let g = g.expect("imported");
|
||||
for (id, want) in [("red", "Z"), ("green", "Z"), ("blue", "Z"), ("alpha", "(copy)")] {
|
||||
assert_eq!(v(&g, id), Value::Str(want.into()), "{id}");
|
||||
}
|
||||
// `(copy)` on another channel is that channel of the input (EffectCraft's `(copy)` there).
|
||||
let (g, _) = run(&extractor(vec![], channels([(2, "(copy)"), (1, "CryptoObject00.R"), (2, "(copy)"), (1, "Z")])), "ec.3d.extractor");
|
||||
let g = g.expect("imported");
|
||||
for (id, want) in [("red", "(copy)"), ("green", "CryptoObject00.R"), ("blue", "(copy)"), ("alpha", "Z")] {
|
||||
assert_eq!(v(&g, id), Value::Str(want.into()), "{id}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extractor_channels_of_an_unknown_kind_are_reported() {
|
||||
for recs in [[(1, "Z"), (3, "Z"), (1, "Z"), (2, "(copy)")], [(1, "Z"), (1, ""), (1, "Z"), (2, "(copy)")]] {
|
||||
let (g, r) = run(&extractor(vec![], channels(recs)), "ec.3d.extractor");
|
||||
let found = losses(&r, LossCategory::Effects, "EXtractoR");
|
||||
assert!(found.len() == 1 && found[0].contains("are not imported (record 2 is of an unknown kind or names no channel)"), "{recs:?}: {found:?}");
|
||||
assert_eq!(v(&g.expect("imported"), "red"), Value::Str("R".into()), "{recs:?}");
|
||||
}
|
||||
}
|
||||
|
||||
/// IDentifier's Channel Info: the channel type's name at 19 among bytes that are not read.
|
||||
fn id_info(name: &str) -> Vec<u8> {
|
||||
let mut d: Vec<u8> = (0..148u32).map(|i| (i * 37 % 251) as u8 | 1).collect();
|
||||
d[19..19 + name.len()].copy_from_slice(name.as_bytes());
|
||||
d[19 + name.len()] = 0;
|
||||
d
|
||||
}
|
||||
|
||||
fn identifier(values: Vec<(usize, f64)>, data: Vec<u8>) -> Fx {
|
||||
let mut values = values;
|
||||
values.insert(0, (1, 0.0));
|
||||
Fx {
|
||||
match_name: "IDentifier",
|
||||
name: "IDentifier",
|
||||
defs: vec![
|
||||
other(K::ArbitraryData, "Channel Info", Default::None),
|
||||
popup("Display", 1, &["Colors", "Luma Matte", "Alpha Matte", "Raw"]),
|
||||
slider(K::IntSlider, "ID", 0.0, (0.0, 2_147_483_647.0)),
|
||||
],
|
||||
values,
|
||||
data: vec![(1, data)],
|
||||
keys: vec![],
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn identifier_display_id_and_channel_type() {
|
||||
assert_eq!(identifier_channel_type(&id_info("Object ID")), Some("Object ID"));
|
||||
// `identifier-01-set-all` (ID 10) and `identifier-05-pop2-3` (Alpha Matte): no channel chosen
|
||||
// (After Effects found none or none was picked), so After Effects leaves the layer unchanged.
|
||||
let (g, r) = run(&identifier(vec![(2, 3.0), (3, 10.0)], id_info("Object ID")), "ec.3d.identifier");
|
||||
let found = losses(&r, LossCategory::Effects, "IDentifier");
|
||||
assert!(found.len() == 1 && found[0].contains("no ID channel is chosen"), "{found:?}");
|
||||
let g = g.expect("imported");
|
||||
assert_eq!((v(&g, "display"), v(&g, "channelType")), (Value::Enum(2), Value::Enum(0)));
|
||||
near(&g, "id", 10.0);
|
||||
}
|
||||
|
||||
/// `identifier-09-preset-channel-objectid-id2` (1310, a preset of our own): the UINT channel
|
||||
/// `objectId` chosen and ID 2. EffectCraft's IDentifier reads that channel itself (`ObjectID`
|
||||
/// without regard to case), so nothing is lost; another name is reported.
|
||||
#[test]
|
||||
fn identifier_chosen_channels_by_name() {
|
||||
for (name, kind) in [("objectId", 0), ("ObjectID", 0), ("materialid", 1)] {
|
||||
let (g, r) = run(&identifier(vec![(3, 2.0)], id_info(name)), "ec.3d.identifier");
|
||||
assert_eq!(losses(&r, LossCategory::Effects, "IDentifier"), Vec::<String>::new(), "{name}");
|
||||
let g = g.expect("imported");
|
||||
assert_eq!(v(&g, "channelType"), Value::Enum(kind), "{name}");
|
||||
near(&g, "id", 2.0);
|
||||
}
|
||||
for name in ["IndexOB.X", "ID", "Material ID"] {
|
||||
let (_, r) = run(&identifier(vec![], id_info(name)), "ec.3d.identifier");
|
||||
let found = losses(&r, LossCategory::Effects, "IDentifier");
|
||||
assert!(found.len() == 1 && found[0].contains(&format!("the ID channel '{name}' (Channel Info) is not imported")), "{name}: {found:?}");
|
||||
}
|
||||
}
|
||||
|
||||
/// EXtractoR's data with the name of record `k` from byte 35 replaced by `name` (no NUL added; the
|
||||
/// rest of the record's name field is left as `fill`).
|
||||
fn channels_with_raw_name(k: usize, name: &[u8], fill: u8) -> Vec<u8> {
|
||||
let mut d = channels(DEFAULT_CHANNELS);
|
||||
let at = 32 + k * 164 + 35;
|
||||
d[at..at + 129].fill(fill);
|
||||
d[at..at + name.len()].copy_from_slice(name);
|
||||
d
|
||||
}
|
||||
|
||||
/// A channel name that never ends (no NUL up to the end of its record) or is not UTF-8 is
|
||||
/// refused by the decoder and reported at the importer; the defaults R, G, B, A stay.
|
||||
#[test]
|
||||
fn extractor_names_without_a_terminator_or_in_invalid_utf8_are_refused() {
|
||||
let unterminated = channels_with_raw_name(2, b"Z", b'Z');
|
||||
let invalid = channels_with_raw_name(1, &[0x5a, 0xff, 0xfe, 0x00], 0);
|
||||
for (what, data) in [("unterminated", unterminated), ("invalid UTF-8", invalid)] {
|
||||
assert_eq!(ExtractorChannels::parse(&data), None, "{what}");
|
||||
let (g, r) = run(&extractor(vec![], data), "ec.3d.extractor");
|
||||
let found = losses(&r, LossCategory::Effects, "EXtractoR");
|
||||
assert!(found.len() == 1 && found[0].contains("not in the sampled layout"), "{what}: {found:?}");
|
||||
let g = g.expect("imported");
|
||||
for (id, want) in [("red", "R"), ("green", "G"), ("blue", "B"), ("alpha", "A")] {
|
||||
assert_eq!(v(&g, id), Value::Str(want.into()), "{what}: {id}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The same for IDentifier: a channel name without NUL up to the end of the data, or in invalid
|
||||
/// UTF-8, is refused and reported; the Object ID channel stays.
|
||||
#[test]
|
||||
fn identifier_names_without_a_terminator_or_in_invalid_utf8_are_refused() {
|
||||
let mut unterminated = id_info("objectId");
|
||||
unterminated[19..].fill(b'x');
|
||||
let mut invalid = id_info("objectId");
|
||||
invalid[19..22].copy_from_slice(&[b'o', 0xff, 0]);
|
||||
for (what, data) in [("unterminated", unterminated), ("invalid UTF-8", invalid)] {
|
||||
assert_eq!(identifier_channel_type(&data), None, "{what}");
|
||||
let (g, r) = run(&identifier(vec![], data), "ec.3d.identifier");
|
||||
let found = losses(&r, LossCategory::Effects, "IDentifier");
|
||||
assert!(found.len() == 1 && found[0].contains("not in the sampled layout"), "{what}: {found:?}");
|
||||
assert_eq!(v(&g.expect("imported"), "channelType"), Value::Enum(0), "{what}");
|
||||
}
|
||||
// Data too short to reach the name.
|
||||
assert_eq!(identifier_channel_type(&[1; 10]), None);
|
||||
}
|
||||
|
||||
/// A Channel Info value record without its arbitrary data (no `aRbs`): the effect still imports,
|
||||
/// with the defaults, nothing panics, and the missing data is reported as damaged (it may have
|
||||
/// chosen other channels, so the defaults are not what After Effects renders).
|
||||
#[test]
|
||||
fn a_missing_channel_info_payload_is_reported_and_leaves_the_defaults() {
|
||||
let mut fx = extractor(vec![], vec![]);
|
||||
fx.data.clear();
|
||||
let (g, r) = run(&fx, "ec.3d.extractor");
|
||||
assert_eq!(losses(&r, LossCategory::Effects, "EXtractoR"), Vec::<String>::new());
|
||||
let found = losses(&r, LossCategory::DamagedData, "EXtractoR");
|
||||
assert!(found.len() == 1 && found[0].contains("Channel Info data is missing"), "{found:?}");
|
||||
let g = g.expect("imported");
|
||||
for (id, want) in [("red", "R"), ("green", "G"), ("blue", "B"), ("alpha", "A")] {
|
||||
assert_eq!(v(&g, id), Value::Str(want.into()), "{id}");
|
||||
}
|
||||
let mut fx = identifier(vec![(3, 5.0)], vec![]);
|
||||
fx.data.clear();
|
||||
let (g, r) = run(&fx, "ec.3d.identifier");
|
||||
assert_eq!(losses(&r, LossCategory::Effects, "IDentifier"), Vec::<String>::new());
|
||||
let found = losses(&r, LossCategory::DamagedData, "IDentifier");
|
||||
assert!(found.len() == 1 && found[0].contains("Channel Info data is missing"), "{found:?}");
|
||||
let g = g.expect("imported");
|
||||
assert_eq!(v(&g, "channelType"), Value::Enum(0));
|
||||
near(&g, "id", 5.0);
|
||||
}
|
||||
@@ -103,18 +103,18 @@ pub fn tdmn(name: &str) -> Chunk<'static> {
|
||||
}
|
||||
|
||||
/// A UTF-8 string chunk (`Utf8`).
|
||||
fn utf8(s: &str) -> Chunk<'static> {
|
||||
pub fn utf8(s: &str) -> Chunk<'static> {
|
||||
Chunk::data(*b"Utf8", s.as_bytes().to_vec())
|
||||
}
|
||||
|
||||
/// A chunk whose body is chunks without being a `LIST` (`pdnm`, `fnam`: `NESTED_IDS`).
|
||||
fn nested(id: [u8; 4], children: Vec<Chunk<'static>>) -> Chunk<'static> {
|
||||
pub fn nested(id: [u8; 4], children: Vec<Chunk<'static>>) -> Chunk<'static> {
|
||||
Chunk { id, body: Body::Chunks(children), pad: None }
|
||||
}
|
||||
|
||||
/// A static value record holding `v`, after the one After Effects wrote for Glow's A & B Midpoint
|
||||
/// (its header; without the expression).
|
||||
fn record(template: &Chunk<'_>, v: f64) -> Chunk<'static> {
|
||||
pub fn record(template: &Chunk<'_>, v: f64) -> Chunk<'static> {
|
||||
let mut kids = Vec::new();
|
||||
for c in template.children() {
|
||||
match &c.body {
|
||||
@@ -131,7 +131,7 @@ fn record(template: &Chunk<'_>, v: f64) -> Chunk<'static> {
|
||||
}
|
||||
|
||||
/// A keyframed value record: `record`'s header with a key list of linear one-component keys.
|
||||
fn keyed_record(template: &Chunk<'_>, keys: &[(i32, f64)]) -> Chunk<'static> {
|
||||
pub fn keyed_record(template: &Chunk<'_>, keys: &[(i32, f64)]) -> Chunk<'static> {
|
||||
let Body::List { children, .. } = record(template, keys.first().map_or(0.0, |k| k.1)).body else { unreachable!("record builds a LIST") };
|
||||
let mut children = children;
|
||||
let mut lhd3 = vec![0u8; 52];
|
||||
@@ -229,39 +229,51 @@ fn find_glow_template(chunks: &[Chunk<'_>]) -> Option<Chunk<'static>> {
|
||||
None
|
||||
}
|
||||
|
||||
/// Replaces every Glow (`ADBE Glo2` with its `sspc`) below `chunks` with `fx`; the number replaced.
|
||||
fn replace_glow(chunks: &mut [Chunk<'_>], fx: &Fx, template: &Chunk<'_>) -> usize {
|
||||
/// Replaces every Glow (`ADBE Glo2` with its `sspc`) below `chunks` with the effect `match_name`
|
||||
/// and its `sspc`; the number replaced.
|
||||
fn replace_glow(chunks: &mut [Chunk<'_>], match_name: &str, sspc: &Chunk<'static>) -> usize {
|
||||
let mut n = 0;
|
||||
for ch in chunks {
|
||||
let kids = ch.children_mut();
|
||||
if let Some(at) = kids.iter().position(|k| k.is(b"tdmn") && k.bytes().is_some_and(|b| b.starts_with(b"ADBE Glo2\0")))
|
||||
&& kids.get(at + 1).is_some_and(|k| k.is_list(b"sspc"))
|
||||
{
|
||||
kids[at] = tdmn(fx.match_name);
|
||||
kids[at + 1] = sspc(fx, template);
|
||||
kids[at] = tdmn(match_name);
|
||||
kids[at + 1] = sspc.clone();
|
||||
n += 1;
|
||||
}
|
||||
n += replace_glow(kids, fx, template);
|
||||
n += replace_glow(kids, match_name, sspc);
|
||||
}
|
||||
n
|
||||
}
|
||||
|
||||
/// `synthetic.aep` with `fx` in place of the Glow effect.
|
||||
pub fn bytes_with(fx: &Fx) -> Vec<u8> {
|
||||
/// `synthetic.aep` with the effect `match_name` in place of the Glow effect; `make` builds its
|
||||
/// `sspc` from the Glow's value record (the template of every record).
|
||||
pub fn bytes_with_effect(match_name: &str, make: impl Fn(&Chunk<'_>) -> Chunk<'static>) -> Vec<u8> {
|
||||
let file = parse(SYNTHETIC).unwrap();
|
||||
let template = find_glow_template(&file.chunks).expect("Glow's A & B Midpoint record");
|
||||
let sspc = make(&template);
|
||||
let mut file = parse(SYNTHETIC).unwrap();
|
||||
assert_eq!(replace_glow(&mut file.chunks, fx, &template), 2, "the Glow effect and its shared definitions");
|
||||
assert_eq!(replace_glow(&mut file.chunks, match_name, &sspc), 2, "the Glow effect and its shared definitions");
|
||||
write(&file).unwrap()
|
||||
}
|
||||
|
||||
/// `synthetic.aep` with `fx` in place of the Glow effect.
|
||||
pub fn bytes_with(fx: &Fx) -> Vec<u8> {
|
||||
bytes_with_effect(fx.match_name, |t| sspc(fx, t))
|
||||
}
|
||||
|
||||
/// Imports `synthetic.aep` with `fx` in place of its Glow: the EffectCraft effect `effect` (if
|
||||
/// imported) and the result.
|
||||
pub fn run(fx: &Fx, effect: &str) -> (Option<PropGroup>, ImportResult) {
|
||||
let bytes = bytes_with(fx);
|
||||
import_bytes(&bytes_with(fx), effect)
|
||||
}
|
||||
|
||||
/// Imports `bytes`: the first EffectCraft effect `effect` (if imported) and the result.
|
||||
pub fn import_bytes(bytes: &[u8], effect: &str) -> (Option<PropGroup>, ImportResult) {
|
||||
let mut project = Project::default();
|
||||
let none = |_: &str| false;
|
||||
let r = import(&mut project, &bytes, &ImportOptions { allow_losses: true, ..ImportOptions::new(&none) }).unwrap();
|
||||
let r = import(&mut project, bytes, &ImportOptions { allow_losses: true, ..ImportOptions::new(&none) }).unwrap();
|
||||
let g = project
|
||||
.comps()
|
||||
.flat_map(|(_, c)| c.layers.iter())
|
||||
|
||||
@@ -0,0 +1,171 @@
|
||||
//! After Effects' Puppet (`ADBE FreePin3`, #3093) arrives as EffectCraft's Puppet
|
||||
//! (`ec.distort.puppet`) in its place in the stack, and what its meshes and pins hold is reported
|
||||
//! mesh by mesh instead of the one line "has the effect 'Puppet'". The effect is built into the
|
||||
//! committed `synthetic.aep` in place of its Glow (`fx_inject`, in memory), in the tree
|
||||
//! docs/aep-format.md 3.7 gives for Puppet. That tree comes from an external scripting listing,
|
||||
//! not a byte sample: these tests prove the reader's walk and its report, not the format.
|
||||
|
||||
use effectcraft_aep::Chunk;
|
||||
use effectcraft_aep::import::{ImportOptions, LossCategory, import};
|
||||
use effectcraft_project::Project;
|
||||
|
||||
use crate::import::fx_inject::{bytes_with_effect, import_bytes, keyed_record, losses, nested, record, tdmn, utf8};
|
||||
|
||||
const SYNTHETIC: &[u8] = include_bytes!("../../fixtures/synthetic.aep");
|
||||
const PUPPET: &str = "ADBE FreePin3";
|
||||
const POS_ATOM: &str = "ADBE FreePin3 PosPin Atom";
|
||||
|
||||
fn tdsb(v: u32) -> Chunk<'static> {
|
||||
Chunk::data(*b"tdsb", v.to_be_bytes().to_vec())
|
||||
}
|
||||
|
||||
/// A property group of `entries` (match name, node).
|
||||
fn group(entries: Vec<(&str, Chunk<'static>)>) -> Chunk<'static> {
|
||||
let mut kids = vec![tdsb(1)];
|
||||
for (name, node) in entries {
|
||||
kids.push(tdmn(name));
|
||||
kids.push(node);
|
||||
}
|
||||
Chunk::list(*b"tdgp", kids)
|
||||
}
|
||||
|
||||
/// A pin atom with its hidden vertex records and Position (keyed when `keys`). The values are
|
||||
/// one-component records: the reader only asks whether a pin is keyed.
|
||||
fn pin(t: &Chunk<'_>, atom: &'static str, keys: bool) -> (&'static str, Chunk<'static>) {
|
||||
let position = if keys { keyed_record(t, &[(0, 1.0), (25_600, 2.0)]) } else { record(t, 1.0) };
|
||||
(
|
||||
atom,
|
||||
group(vec![
|
||||
("ADBE FreePin3 PosPin Vtx Offset", record(t, 0.0)),
|
||||
("ADBE FreePin3 PosPin Vtx Index", record(t, 7.0)),
|
||||
("ADBE FreePin3 PosPin Position", position),
|
||||
]),
|
||||
)
|
||||
}
|
||||
|
||||
/// `Mesh 1`: Triangles 500, Expansion 5, four Deform pins (one keyed, one with an atom name
|
||||
/// nobody knows) and a property among them, no Overlap pin, one Stiffness pin, and a group
|
||||
/// nobody knows.
|
||||
fn full_mesh(t: &Chunk<'_>) -> Chunk<'static> {
|
||||
let mesh_data =
|
||||
Chunk::list(*b"tdbs", vec![tdsb(1), Chunk::data(*b"cdat", vec![0u8; 4]), Chunk::list(*b"aRbs", vec![Chunk::data(*b"aRbp", vec![1, 2, 3, 4])])]);
|
||||
let deform = group(vec![
|
||||
pin(t, POS_ATOM, false),
|
||||
pin(t, POS_ATOM, true),
|
||||
pin(t, POS_ATOM, false),
|
||||
pin(t, "ADBE FreePin3 Test Atom", false),
|
||||
("ADBE FreePin3 Test Prop", record(t, 3.0)),
|
||||
]);
|
||||
let starch = group(vec![("ADBE FreePin3 StarchPin Atom", group(vec![("ADBE FreePin3 StarchPin Amount", record(t, 0.5))]))]);
|
||||
group(vec![
|
||||
("ADBE FreePin3 Mesh", mesh_data),
|
||||
("ADBE FreePin3 Mesh Tri Count", record(t, 500.0)),
|
||||
("ADBE FreePin3 Mesh Expansion", record(t, 5.0)),
|
||||
("ADBE FreePin3 PosPins", deform),
|
||||
("ADBE FreePin3 HghtPins", group(vec![])),
|
||||
("ADBE FreePin3 StarchPins", starch),
|
||||
("ADBE FreePin3 Test Group", group(vec![])),
|
||||
])
|
||||
}
|
||||
|
||||
/// The Puppet instance: `fx_switch` is the value group's `tdsb`, `arap` its mesh tree (`None`:
|
||||
/// none), `extra` more entries of its value group.
|
||||
fn sspc(fx_switch: u32, arap: Option<Chunk<'static>>, extra: Vec<(&str, Chunk<'static>)>) -> Chunk<'static> {
|
||||
let pard = |kind: u8| {
|
||||
let mut b = vec![0u8; 148];
|
||||
b[15] = kind;
|
||||
Chunk::data(*b"pard", b)
|
||||
};
|
||||
let part = vec![Chunk::data(*b"parn", 2u32.to_be_bytes().to_vec()), tdmn("ADBE FreePin3-0000"), pard(0), tdmn("ADBE Effect Built In Params"), pard(9)];
|
||||
let mut values = vec![tdsb(fx_switch)];
|
||||
if let Some(arap) = arap {
|
||||
values.push(tdmn("ADBE FreePin3 ARAP Group"));
|
||||
values.push(arap);
|
||||
}
|
||||
for (name, node) in extra {
|
||||
values.push(tdmn(name));
|
||||
values.push(node);
|
||||
}
|
||||
Chunk::list(*b"sspc", vec![nested(*b"fnam", vec![utf8("Puppet")]), Chunk::list(*b"parT", part), Chunk::list(*b"tdgp", values)])
|
||||
}
|
||||
|
||||
/// The layer that holds `effect`, the effect's place in that layer's stack and the stack's size.
|
||||
fn stack_place(bytes: &[u8], effect: &str) -> Option<(String, usize, usize)> {
|
||||
let mut project = Project::default();
|
||||
let none = |_: &str| false;
|
||||
import(&mut project, bytes, &ImportOptions { allow_losses: true, ..ImportOptions::new(&none) }).unwrap();
|
||||
project.comps().flat_map(|(_, c)| c.layers.iter()).find_map(|l| {
|
||||
let fx: Vec<_> = l.props.sub("effects")?.children.iter().filter_map(|n| n.as_group()).collect();
|
||||
let at = fx.iter().position(|g| g.match_id == effect)?;
|
||||
Some((l.name.clone(), at, fx.len()))
|
||||
})
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn puppet_arrives_and_its_pins_are_reported_mesh_by_mesh() {
|
||||
let bytes = bytes_with_effect(PUPPET, |t| {
|
||||
let arap = group(vec![("ADBE FreePin3 Outlines", group(vec![])), ("ADBE FreePin3 Mesh Group", group(vec![("ADBE FreePin3 Mesh Atom", full_mesh(t))]))]);
|
||||
sspc(1, Some(arap), vec![("ADBE FreePin3 On Transparent", record(t, 1.0))])
|
||||
});
|
||||
let (g, r) = import_bytes(&bytes, effectcraft_effects::puppet::ID);
|
||||
let g = g.expect("an EffectCraft Puppet in the Glow's place");
|
||||
assert!(g.enabled);
|
||||
assert_eq!(g.name, "Puppet");
|
||||
// No pin is made up: the rest places are not decoded.
|
||||
assert_eq!(effectcraft_effects::puppet::pin_count(&g), 0);
|
||||
// In the Glow's place in its layer's stack (the second of two effects there).
|
||||
assert!(stack_place(SYNTHETIC, "ec.stylize.glow").is_some_and(|(_, at, n)| at > 0 && n > 1));
|
||||
assert_eq!(stack_place(&bytes, effectcraft_effects::puppet::ID), stack_place(SYNTHETIC, "ec.stylize.glow"));
|
||||
let l = losses(&r, LossCategory::Effects, PUPPET);
|
||||
assert_eq!(l.len(), 2, "{l:#?}");
|
||||
assert!(!l.iter().any(|d| d.contains("has the effect")), "not the old one-line report: {l:#?}");
|
||||
assert!(l.iter().any(|d| d.ends_with("property ADBE FreePin3 On Transparent (1) not imported")), "{l:#?}");
|
||||
let mesh = l.iter().find(|d| d.contains("imported without its meshes and pins")).expect("the mesh report");
|
||||
assert!(
|
||||
mesh.ends_with(
|
||||
": Mesh 1 (Triangles 500, Expansion 5): Deform 4 pins (1 keyed) [ADBE FreePin3 PosPin Atom, ADBE FreePin3 Test Atom], \
|
||||
Overlap no pins, Stiffness 1 pin (0 keyed) [ADBE FreePin3 StarchPin Atom]; not recognised: ADBE FreePin3 Test Prop, ADBE FreePin3 Test Group"
|
||||
),
|
||||
"{mesh}"
|
||||
);
|
||||
assert_eq!(losses(&r, LossCategory::DamagedData, PUPPET), Vec::<String>::new());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn many_meshes_are_listed_up_to_ten_and_names_once() {
|
||||
let bytes = bytes_with_effect(PUPPET, |_| {
|
||||
let mesh = || group(vec![("ADBE FreePin3 Test Group", group(vec![]))]);
|
||||
let meshes = (0..12).map(|_| ("ADBE FreePin3 Mesh Atom", mesh())).collect();
|
||||
sspc(1, Some(group(vec![("ADBE FreePin3 Mesh Group", group(meshes))])), vec![])
|
||||
});
|
||||
let (_, r) = import_bytes(&bytes, effectcraft_effects::puppet::ID);
|
||||
let l = losses(&r, LossCategory::Effects, PUPPET);
|
||||
assert_eq!(l.len(), 1, "{l:#?}");
|
||||
assert!(l[0].ends_with("Mesh 9: no pin groups; Mesh 10: no pin groups; and 2 more; not recognised: ADBE FreePin3 Test Group"), "{}", l[0]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_puppet_without_a_mesh_loses_nothing_and_keeps_its_fx_switch() {
|
||||
let bytes = bytes_with_effect(PUPPET, |_| sspc(0, Some(group(vec![("ADBE FreePin3 Mesh Group", group(vec![]))])), vec![]));
|
||||
let (g, r) = import_bytes(&bytes, effectcraft_effects::puppet::ID);
|
||||
let g = g.expect("an EffectCraft Puppet");
|
||||
assert!(!g.enabled, "switched off in the Effect Controls");
|
||||
assert_eq!(losses(&r, LossCategory::Effects, PUPPET), Vec::<String>::new());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_puppet_whose_mesh_tree_is_not_found_is_reported() {
|
||||
let bytes = bytes_with_effect(PUPPET, |t| sspc(1, None, vec![("ADBE FreePin3 Other Group", group(vec![("x", record(t, 1.0))]))]));
|
||||
let (g, r) = import_bytes(&bytes, effectcraft_effects::puppet::ID);
|
||||
assert!(g.is_some(), "the effect still arrives");
|
||||
let l = losses(&r, LossCategory::Effects, PUPPET);
|
||||
assert_eq!(l.len(), 1, "{l:#?}");
|
||||
assert!(
|
||||
l[0].ends_with(
|
||||
"imported without meshes or pins: its mesh group (ADBE FreePin3 ARAP Group / ADBE FreePin3 Mesh Group) was not found or is not a group; \
|
||||
not recognised: ADBE FreePin3 Other Group"
|
||||
),
|
||||
"{}",
|
||||
l[0]
|
||||
);
|
||||
}
|
||||
@@ -434,6 +434,47 @@ fn layer_times_with_a_zero_denominator_are_reported() {
|
||||
assert!(losses(&r.unwrap(), LossCategory::DamagedData).is_empty());
|
||||
}
|
||||
|
||||
/// The time stretch of every imported layer.
|
||||
fn stretches(p: &Project) -> Vec<f64> {
|
||||
p.items.values().filter_map(|i| if let ItemKind::Comp(c) = &i.kind { Some(c) } else { None }).flat_map(|c| c.layers.iter().map(|l| l.stretch)).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_time_stretch_outside_one_to_9900_percent_is_clamped_and_reported() {
|
||||
// ldta byte 8 is the stretch numerator (i32), byte 108 the denominator (u32). 1/(2^31 - 1) is
|
||||
// about 5e-8 %: imported as stored, the layer and audio time divided by ~0 (#3247).
|
||||
for (num, den, want) in [(1i32, i32::MAX as u32, 1.0), (-1, i32::MAX as u32, -1.0), (i32::MAX, 1, 9900.0), (i32::MIN, 1, -9900.0)] {
|
||||
let bytes = mutate(&mut |c| {
|
||||
if c.id == *b"ldta" {
|
||||
set_data(c, 8, &num.to_be_bytes());
|
||||
set_data(c, 108, &den.to_be_bytes());
|
||||
}
|
||||
});
|
||||
let (p, r) = run(&bytes, true);
|
||||
let r = r.unwrap();
|
||||
let found = losses(&r, LossCategory::DamagedData);
|
||||
assert!(found.iter().any(|d| d.contains("time stretch") && d.contains("outside")), "{num}/{den}: {found:?}");
|
||||
let s = stretches(&p);
|
||||
assert!(!s.is_empty() && s.iter().all(|v| *v == want), "{num}/{den}: {s:?}");
|
||||
let (p, r) = run(&bytes, false);
|
||||
assert!(r.is_err(), "{num}/{den}: strict import must refuse");
|
||||
assert!(p.items.is_empty());
|
||||
}
|
||||
// A small stretch within the range (1/64: 1.5625 %) and -9900 % itself are no damage.
|
||||
for (num, den, want) in [(1i32, 64u32, 1.5625), (-99, 1, -9900.0)] {
|
||||
let bytes = mutate(&mut |c| {
|
||||
if c.id == *b"ldta" {
|
||||
set_data(c, 8, &num.to_be_bytes());
|
||||
set_data(c, 108, &den.to_be_bytes());
|
||||
}
|
||||
});
|
||||
let (p, r) = run(&bytes, true);
|
||||
let r = r.unwrap();
|
||||
assert!(!losses(&r, LossCategory::DamagedData).iter().any(|d| d.contains("time stretch")), "{num}/{den}: {:?}", r.report.losses);
|
||||
assert!(stretches(&p).iter().all(|v| *v == want), "{num}/{den}: {:?}", stretches(&p));
|
||||
}
|
||||
}
|
||||
|
||||
/// The project with `nhed` bytes from `at` replaced.
|
||||
fn header(at: usize, bytes: &[u8]) -> Vec<u8> {
|
||||
mutate(&mut |c| {
|
||||
|
||||
@@ -619,6 +619,39 @@ fn cryptomatte(ctx: &EffectCtx, mut b: Buf) -> Buf {
|
||||
|
||||
// ---------------------------------------------------------------- EXtractoR
|
||||
|
||||
/// EXtractoR's channel name for After Effects' `(copy)`: the effect input's own channel of the
|
||||
/// same position (red's red ... alpha's alpha), as the earlier effects left it, never a footage
|
||||
/// channel of some name (an OpenEXR's `A` is the file's alpha, not the input's).
|
||||
pub const EXTRACTOR_COPY: &str = "(copy)";
|
||||
|
||||
/// Whether an EXtractoR channel name is [`EXTRACTOR_COPY`].
|
||||
pub fn is_extractor_copy(name: &str) -> bool {
|
||||
name.trim().eq_ignore_ascii_case(EXTRACTOR_COPY)
|
||||
}
|
||||
|
||||
/// The layer's own channel an EXtractoR output channel at position `k` reads when no footage
|
||||
/// channel of that name exists: `R`/`G`/`B`/`A` (or the long names) by name, [`EXTRACTOR_COPY`]
|
||||
/// its own position.
|
||||
pub fn extractor_own_channel(k: usize, name: &str) -> Option<usize> {
|
||||
if is_extractor_copy(name) {
|
||||
return (k < 4).then_some(k);
|
||||
}
|
||||
match name.trim().to_ascii_uppercase().as_str() {
|
||||
"R" | "RED" => Some(0),
|
||||
"G" | "GREEN" => Some(1),
|
||||
"B" | "BLUE" => Some(2),
|
||||
"A" | "ALPHA" => Some(3),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// The footage channel an EXtractoR output channel reads by name: none for an empty name or
|
||||
/// [`EXTRACTOR_COPY`] (the input's own channel).
|
||||
pub fn extractor_aux_channel<'a>(a: &'a AuxChannels, name: &str) -> Option<&'a [f32]> {
|
||||
let n = name.trim();
|
||||
if n.is_empty() || is_extractor_copy(n) { None } else { a.get(n) }
|
||||
}
|
||||
|
||||
fn extractor(ctx: &EffectCtx, mut b: Buf) -> Buf {
|
||||
let a = aux(ctx);
|
||||
let names = [ctx.params.s("red"), ctx.params.s("green"), ctx.params.s("blue"), ctx.params.s("alpha")];
|
||||
@@ -626,17 +659,11 @@ fn extractor(ctx: &EffectCtx, mut b: Buf) -> Buf {
|
||||
let unmult = ctx.params.b("unMult");
|
||||
let clip = ctx.params.b("clip");
|
||||
let idx = a.as_ref().map(|a| index_map(&b, a));
|
||||
// Each output channel: an aux channel by name, or the layer's own R/G/B/A.
|
||||
let own = |n: &str| match n.trim().to_ascii_uppercase().as_str() {
|
||||
"R" | "RED" => Some(0),
|
||||
"G" | "GREEN" => Some(1),
|
||||
"B" | "BLUE" => Some(2),
|
||||
"A" | "ALPHA" => Some(3),
|
||||
"" => None,
|
||||
_ => None,
|
||||
};
|
||||
let src_ch: Vec<Option<&[f32]>> = names.iter().map(|n| a.as_ref().and_then(|a| if n.trim().is_empty() { None } else { a.get(n.trim()) })).collect();
|
||||
if src_ch.iter().all(Option::is_none) && bp == 0.0 && wp == 1.0 && !unmult && names.iter().enumerate().all(|(k, n)| own(n) == Some(k)) {
|
||||
// Each output channel: an aux channel by name, or the layer's own R/G/B/A (`(copy)`: its
|
||||
// own position, never an aux channel).
|
||||
let own = |k: usize, n: &str| extractor_own_channel(k, n);
|
||||
let src_ch: Vec<Option<&[f32]>> = names.iter().map(|n| a.as_ref().and_then(|a| extractor_aux_channel(a, n))).collect();
|
||||
if src_ch.iter().all(Option::is_none) && bp == 0.0 && wp == 1.0 && !unmult && names.iter().enumerate().all(|(k, n)| own(k, n) == Some(k)) {
|
||||
return b;
|
||||
}
|
||||
let src = b.img.clone();
|
||||
@@ -652,7 +679,7 @@ fn extractor(ctx: &EffectCtx, mut b: Buf) -> Buf {
|
||||
o[k] = match (src_ch[k], idx.as_ref().and_then(|m| m[i])) {
|
||||
(Some(ch), Some(j)) => ch[j],
|
||||
(Some(_), None) => 0.0,
|
||||
(None, _) => match own(names[k]) {
|
||||
(None, _) => match own(k, names[k]) {
|
||||
Some(c) => straight[c],
|
||||
None => {
|
||||
if k == 3 {
|
||||
|
||||
@@ -457,6 +457,38 @@ fn extractor_maps_named_channels() {
|
||||
assert_eq!(s.img.get(0, 0), [1.0, 0.3, 0.4, 1.0]);
|
||||
}
|
||||
|
||||
/// `(copy)` (After Effects' EXtractoR choice, imported from .aep) is the effect input's own
|
||||
/// channel as earlier effects left it, never the OpenEXR's plane of the same name: here the input
|
||||
/// buffer differs from the footage's R, G, B and A planes, as after an earlier effect.
|
||||
#[test]
|
||||
fn extractor_copy_reads_the_current_input_not_the_footage_plane() {
|
||||
let (w, h) = (4usize, 3usize);
|
||||
let mut a = AuxChannels::new(w as u32, h as u32, 1.0);
|
||||
// The file's own planes (what the footage held before any effect) and a depth channel.
|
||||
a.channels = ["R", "G", "B", "A"].iter().map(|n| (n.to_string(), vec![0.9; w * h])).collect();
|
||||
a.channels.push(("Z".into(), vec![0.7; w * h]));
|
||||
let host = AuxHost(Arc::new(a));
|
||||
let env = || EffectEnv { host: Some(&host), ..Default::default() };
|
||||
// The input after an earlier effect: straight (0.2, 0.3, 0.4) at alpha 0.5, premultiplied.
|
||||
let input = Image::filled(w as u32, h as u32, [0.1, 0.15, 0.2, 0.5]);
|
||||
let copy = || Value::Str(EXTRACTOR_COPY.into());
|
||||
// All four `(copy)`: the input unchanged.
|
||||
let all = [("red", copy()), ("green", copy()), ("blue", copy()), ("alpha", copy())];
|
||||
let o = run_fx("ec.3d.extractor", &all, input.clone(), 0.0, env()).img;
|
||||
assert_eq!(o.data, input.data);
|
||||
// Mixed with a named channel (no pass-through): red from Z, green and alpha copied, blue the
|
||||
// footage's B plane.
|
||||
let vals = [("red", Value::Str("Z".into())), ("green", copy()), ("blue", Value::Str("B".into())), ("alpha", copy())];
|
||||
let px = run_fx("ec.3d.extractor", &vals, input.clone(), 0.0, env()).img.get(1, 1);
|
||||
let want = [0.7 * 0.5, 0.3 * 0.5, 0.9 * 0.5, 0.5];
|
||||
for (c, (got, want)) in px.iter().zip(want).enumerate() {
|
||||
assert!((got - want).abs() < 1e-6, "channel {c}: {got} != {want} ({px:?})");
|
||||
}
|
||||
// `A` by name is the footage's alpha plane (0.9), not the input's alpha (0.5).
|
||||
let named = [("alpha", Value::Str("A".into())), ("red", Value::Str("Z".into()))];
|
||||
assert!((run_fx("ec.3d.extractor", &named, input, 0.0, env()).img.get(1, 1)[3] - 0.9).abs() < 1e-6);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn channel_extract_antialias_averages_sub_pixels() {
|
||||
// Depth at twice the layer resolution, alternating 0 / 1000 per aux column.
|
||||
|
||||
@@ -116,8 +116,9 @@ pub use time_fx::{PixelMotion, TimewarpPlan, TwStep, pmb_plan, posterized_time,
|
||||
pub use ocio::color_stabilizer_maps;
|
||||
// GPU effects, part B (3D Channel, Immersive Video, colour management, simulation render passes).
|
||||
pub use channel3d::{
|
||||
CRYPTO_LAYERS, CryptoAux, MATTE_ONLY_IN_ALPHA, aux_point, crypto_aux_image, crypto_aux_image_with, crypto_layer_choices, crypto_layer_menu,
|
||||
crypto_layer_name, crypto_layer_named, id_color, matte_only_px, reads_aux, selection_hashes,
|
||||
CRYPTO_LAYERS, CryptoAux, EXTRACTOR_COPY, MATTE_ONLY_IN_ALPHA, aux_point, crypto_aux_image, crypto_aux_image_with, crypto_layer_choices, crypto_layer_menu,
|
||||
crypto_layer_name, crypto_layer_named, extractor_aux_channel, extractor_own_channel, id_color, is_extractor_copy, matte_only_px, reads_aux,
|
||||
selection_hashes,
|
||||
};
|
||||
pub use ocio::{ColorOp, Straight, Tf, color_program};
|
||||
pub use sim::{Acc, Blob, Bubble, Post, Shape, Sprite, SpritePlan, Tint, bubble_list, bubble_sample_bounds, drizzle_drops, mercury_blobs, sprite_plan};
|
||||
|
||||
@@ -49,7 +49,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Text",
|
||||
["Layer", "New"],
|
||||
Some("Cmd+Alt+Shift+T"),
|
||||
"{text?, name? (layer name; default the text), position? [x,y], box? [x,y,w,h] (paragraph text, comp space), vertical?, edit? (start editing), font?, style?, size?, fill?, stroke?, applyFill?, applyStroke?, strokeWidth?, tracking?, leading?, baselineShift?, hScale?, vScale?, tsume?, fauxBold?, fauxItalic?, allCaps?, smallCaps?, baseline?, superscript?, subscript?, kerning?, ligatures?, justify?, indentLeft?, indentRight?, indentFirst?, spaceBefore?, spaceAfter?, direction?, composer?, hangingPunctuation?, strokeOverFill?, lineJoin?} (attributes as layer.setText)",
|
||||
"{text?, name? (layer name; default the text, which the name then follows; a given name stays), position? [x,y], box? [x,y,w,h] (paragraph text, comp space), vertical?, edit? (start editing), font?, style?, size?, fill?, stroke?, applyFill?, applyStroke?, strokeWidth?, tracking?, leading?, baselineShift?, hScale?, vScale?, tsume?, fauxBold?, fauxItalic?, allCaps?, smallCaps?, baseline?, superscript?, subscript?, kerning?, ligatures?, justify?, indentLeft?, indentRight?, indentFirst?, spaceBefore?, spaceAfter?, direction?, composer?, hangingPunctuation?, strokeOverFill?, lineJoin?} (attributes as layer.setText)",
|
||||
has_comp,
|
||||
new_text
|
||||
),
|
||||
@@ -70,7 +70,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Layer Settings...",
|
||||
["Layer"],
|
||||
Some("Cmd+Shift+Y"),
|
||||
"{layer?, name?, color?, width?, height?, pixelAspect?, affectAll?: bool (default true; false gives this layer its own copy of a solid other layers use)} → {layer, solid?}",
|
||||
"{layer?, name? (an empty name makes a text layer's name follow its text again), color?, width?, height?, pixelAspect?, affectAll?: bool (default true; false gives this layer its own copy of a solid other layers use)} → {layer, solid?}",
|
||||
has_layer_settings,
|
||||
layer_settings
|
||||
),
|
||||
@@ -114,7 +114,15 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
has_layers,
|
||||
set_switch
|
||||
),
|
||||
cmd!("layer.rename", "Rename", [], Some("Enter"), "{layer?, name}", has_layers, rename),
|
||||
cmd!(
|
||||
"layer.rename",
|
||||
"Rename",
|
||||
[],
|
||||
Some("Enter"),
|
||||
"{layer?, name} — a text layer's name follows its text until renamed; an empty name follows it again",
|
||||
has_layers,
|
||||
rename
|
||||
),
|
||||
cmd!("layer.setComment", "Layer Comment", [], None, "{layers?, comment}", has_layers, set_comment),
|
||||
cmd!("layer.setBlendMode", "Blending Mode", [], None, "{layers?, mode?: Normal|Multiply|Screen|…, step?: ±1}", has_layers, blend),
|
||||
cmd!(
|
||||
@@ -210,7 +218,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Edit Text",
|
||||
[],
|
||||
None,
|
||||
"{layer?, range?: [start, end] (characters; default all), text?, font?, style?, size?, fill?, stroke?, applyFill?, applyStroke?, strokeWidth?, tracking?, leading?: px|\"auto\", baselineShift? px, hScale? %, vScale? %, tsume? %, fauxBold?, fauxItalic?, allCaps?, smallCaps?, baseline?: normal|superscript|subscript, superscript?, subscript?, kerning?: metrics|optical|number, ligatures?, discretionaryLigatures? (OpenType), contextualAlternates?, stylisticAlternates?, stylisticSets?: [1–20], ss01?, ss02?, ss03?, ss04?, ss05?, ss06?, ss07?, ss08?, ss09?, ss10?, ss11?, ss12?, ss13?, ss14?, ss15?, ss16?, ss17?, ss18?, ss19?, ss20?, swash?, titling?, ordinals?, fractions?, allSmallCaps?, figureStyle?: default|lining|oldStyle, figureWidth?: default|proportional|tabular, figures?, variations?: {tag: value} (variable font axes; null resets), justify?: left|center|right|justifyLeft|justifyCenter|justifyRight|justifyAll, indentLeft?, indentRight?, indentFirst?, spaceBefore?, spaceAfter?, direction?: ltr|rtl, composer?: everyLine|singleLine, hangingPunctuation?, strokeOverFill?, lineJoin?: miter|round|bevel, box?: [x,y,w,h]|null (layer space), vertical?}",
|
||||
"{layer?, range?: [start, end] (characters; default all), text? (also renames a text layer whose name still follows its text), font?, style?, size?, fill?, stroke?, applyFill?, applyStroke?, strokeWidth?, tracking?, leading?: px|\"auto\", baselineShift? px, hScale? %, vScale? %, tsume? %, fauxBold?, fauxItalic?, allCaps?, smallCaps?, baseline?: normal|superscript|subscript, superscript?, subscript?, kerning?: metrics|optical|number, ligatures?, discretionaryLigatures? (OpenType), contextualAlternates?, stylisticAlternates?, stylisticSets?: [1–20], ss01?, ss02?, ss03?, ss04?, ss05?, ss06?, ss07?, ss08?, ss09?, ss10?, ss11?, ss12?, ss13?, ss14?, ss15?, ss16?, ss17?, ss18?, ss19?, ss20?, swash?, titling?, ordinals?, fractions?, allSmallCaps?, figureStyle?: default|lining|oldStyle, figureWidth?: default|proportional|tabular, figures?, variations?: {tag: value} (variable font axes; null resets), justify?: left|center|right|justifyLeft|justifyCenter|justifyRight|justifyAll, indentLeft?, indentRight?, indentFirst?, spaceBefore?, spaceAfter?, direction?: ltr|rtl, composer?: everyLine|singleLine, hangingPunctuation?, strokeOverFill?, lineJoin?: miter|round|bevel, box?: [x,y,w,h]|null (layer space), vertical?}",
|
||||
has_layers,
|
||||
set_text
|
||||
),
|
||||
|
||||
@@ -207,14 +207,14 @@ pub(super) fn set_text(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
KV::Str(t) => TextDoc::plain(&t),
|
||||
_ => TextDoc::default(),
|
||||
};
|
||||
let before = effectcraft_project::text_layer_name(&doc.text);
|
||||
apply_text_params("layer.setText", p, &mut doc, range.clone())?;
|
||||
pr.set_value_at(lt, KV::Text(Box::new(doc.clone())));
|
||||
if let Some(first) = doc.text.lines().next()
|
||||
&& str_p(p, "text").is_some()
|
||||
&& !first.is_empty()
|
||||
&& (l.name.starts_with("Text") || l.name.is_empty())
|
||||
{
|
||||
l.name = first.chars().take(40).collect();
|
||||
// Text layers are named after their text until renamed (#3233).
|
||||
let after = effectcraft_project::text_layer_name(&doc.text);
|
||||
if str_p(p, "text").is_some() && !after.is_empty() && l.name_follows_text(&before) {
|
||||
l.name = after;
|
||||
l.name_follows_text = Some(true);
|
||||
}
|
||||
Ok(())
|
||||
})?;
|
||||
|
||||
@@ -187,8 +187,10 @@ pub(super) fn new_text(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
let edit = b_p(p, "edit").unwrap_or(false);
|
||||
let id = s.edit("New Text Layer", None, |proj, st| {
|
||||
let name: String = layer_name.clone().unwrap_or_else(|| doc.text.lines().next().unwrap_or("").chars().take(40).collect());
|
||||
let name: String = layer_name.clone().unwrap_or_else(|| effectcraft_project::text_layer_name(&doc.text));
|
||||
let mut l = build::layer(proj, &comp, if name.is_empty() { "Text" } else { &name }, LayerSource::Text, (comp.width, comp.height), None);
|
||||
// Named after its text, the name follows the text until renamed; a given name does not.
|
||||
l.name_follows_text = Some(layer_name.as_deref().is_none_or(str::is_empty));
|
||||
if let Some(pr) = l.props.prop_mut("text/sourceText") {
|
||||
pr.value = KV::Text(Box::new(doc.clone()));
|
||||
}
|
||||
|
||||
@@ -139,13 +139,37 @@ fn drop_3d_values(l: &mut Layer) {
|
||||
pub(super) fn rename(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let (cid, lid) = layer_p(s, p, "layer.rename")?;
|
||||
let name = str_p(p, "name").ok_or_else(|| bad("layer.rename", "missing `name`"))?.to_string();
|
||||
let t = s.time();
|
||||
s.edit("Rename Layer", None, |proj, _| {
|
||||
layer_mut(proj, cid, lid)?.name = name.clone();
|
||||
set_layer_name(layer_mut(proj, cid, lid)?, &name, t);
|
||||
Ok(())
|
||||
})?;
|
||||
Ok(Value::Null)
|
||||
}
|
||||
|
||||
/// Give layer `l` the name `name` the user typed (comp time `t` picks the text). A text layer's
|
||||
/// name follows its text until the user renames it; an empty name makes it follow the text again
|
||||
/// (After Effects, #3233). Committing the unchanged name changes nothing.
|
||||
pub(super) fn set_layer_name(l: &mut Layer, name: &str, t: effectcraft_time::Tick) {
|
||||
let text_name =
|
||||
l.props.prop("text/sourceText").filter(|_| matches!(l.source, effectcraft_project::LayerSource::Text)).map(|pr| match pr.value_at(l.layer_time(t)) {
|
||||
KV::Text(d) => effectcraft_project::text_layer_name(&d.text),
|
||||
KV::Str(txt) => effectcraft_project::text_layer_name(&txt),
|
||||
_ => String::new(),
|
||||
});
|
||||
match text_name {
|
||||
Some(first) if name.trim().is_empty() => {
|
||||
l.name = if first.is_empty() { "Text".to_string() } else { first };
|
||||
l.name_follows_text = Some(true);
|
||||
}
|
||||
Some(_) if name != l.name => {
|
||||
l.name = name.to_string();
|
||||
l.name_follows_text = Some(false);
|
||||
}
|
||||
_ => l.name = name.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
/// The Timeline's Comment column.
|
||||
pub(super) fn set_comment(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let (cid, ids) = layers_p(s, p)?;
|
||||
|
||||
@@ -157,7 +157,7 @@ pub(super) fn transform_op(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
/// layers, which are solids) edit their Solid Settings. With `affectAll: false`, a solid other
|
||||
/// layers also use is copied into a new solid for this layer instead of being changed for all of
|
||||
/// them (After Effects' "Affect all layers that use this solid"). `name` renames the layer and
|
||||
/// its solid.
|
||||
/// its solid; an empty one makes a text layer's name follow its text again.
|
||||
pub(super) fn layer_settings(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let (cid, lid) = layer_p(s, p, "layer.settings")?;
|
||||
// Cameras and lights open their own settings (Camera Settings / Light Settings).
|
||||
@@ -171,11 +171,18 @@ pub(super) fn layer_settings(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let h = p.get("height").and_then(Value::as_u64).map(limits::size);
|
||||
let pixel_aspect = pixel_aspect_p(p)?;
|
||||
let name = str_p(p, "name").map(str::trim).filter(|n| !n.is_empty()).map(str::to_string);
|
||||
// An empty name gives a text layer its text's name again, as `layer.rename` does (#3233);
|
||||
// other layers keep theirs.
|
||||
let follow_text = str_p(p, "name").is_some_and(|n| n.trim().is_empty());
|
||||
let affect_all = p.get("affectAll").and_then(Value::as_bool).unwrap_or(true);
|
||||
let t = s.time();
|
||||
let solid = s.edit("Layer Settings", None, |proj, _| {
|
||||
let src = layer_mut(proj, cid, lid)?.source.clone();
|
||||
if let Some(n) = &name {
|
||||
layer_mut(proj, cid, lid)?.name = n.clone();
|
||||
// A user name: a text layer's name stops following its text (#3233).
|
||||
super::switches::set_layer_name(layer_mut(proj, cid, lid)?, n, t);
|
||||
} else if follow_text && matches!(src, LayerSource::Text) {
|
||||
super::switches::set_layer_name(layer_mut(proj, cid, lid)?, "", t);
|
||||
}
|
||||
let LayerSource::Solid { item } = src else { return Ok(None) };
|
||||
let shared = proj.comps().flat_map(|(_, c)| &c.layers).filter(|l| l.source == src).count() > 1;
|
||||
|
||||
@@ -59,9 +59,16 @@ fn time_stretch(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
(None, Some(d)) if span > 0.0 => old * d / span,
|
||||
_ => old,
|
||||
};
|
||||
if !new.is_finite() {
|
||||
return Err(bad("layer.timeStretch", "the stretch factor must be a number"));
|
||||
}
|
||||
if new.abs() < 1.0 {
|
||||
return Err(bad("layer.timeStretch", "the stretch factor must be at least 1 %"));
|
||||
}
|
||||
// After Effects' largest stretch; a project file holds no more either (#3247).
|
||||
if new.abs() > effectcraft_project::limits::MAX_STRETCH {
|
||||
return Err(bad("layer.timeStretch", "the stretch factor must be at most 9900 %"));
|
||||
}
|
||||
// The hold point keeps showing the same layer time.
|
||||
let h = match hold {
|
||||
"current" => cti,
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
//! Positions are character indices. The selection is `anchor..caret` (either order).
|
||||
|
||||
use effectcraft_keyframe::{CharStyle, TextDoc, Value as KV};
|
||||
use effectcraft_project::{ItemId, LayerId, LayerSource};
|
||||
use effectcraft_project::{ItemId, LayerId, LayerSource, text_layer_name};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
@@ -288,10 +288,6 @@ fn session_on(s: &mut Session, lid: LayerId) -> TextEdit {
|
||||
}
|
||||
}
|
||||
|
||||
fn first_line_name(text: &str) -> String {
|
||||
text.lines().next().unwrap_or("").chars().take(40).collect()
|
||||
}
|
||||
|
||||
/// Edit a layer's Source Text at the CTI (one undo step, or merged by `merge`), then set the
|
||||
/// session's selection.
|
||||
pub(crate) fn edit_doc(
|
||||
@@ -319,12 +315,13 @@ pub(crate) fn edit_doc(
|
||||
// already has) must not turn a static Str into Text or add a keyframe at the CTI: that
|
||||
// would change the project and record an empty undo step.
|
||||
if doc != original {
|
||||
let before = first_line_name(&original.text);
|
||||
let after = first_line_name(&doc.text);
|
||||
let before = text_layer_name(&original.text);
|
||||
let after = text_layer_name(&doc.text);
|
||||
pr.set_value_at(lt, KV::Text(Box::new(doc)));
|
||||
// Text layers are named after their text until renamed.
|
||||
if before != after && !after.is_empty() && (l.name == before || l.name.starts_with("Text") || l.name.is_empty()) {
|
||||
// Text layers are named after their text until renamed (#3233).
|
||||
if before != after && !after.is_empty() && l.name_follows_text(&before) {
|
||||
l.name = after;
|
||||
l.name_follows_text = Some(true);
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
|
||||
@@ -103,6 +103,24 @@ fn commands_cap_sizes_at_30000_px() {
|
||||
assert_eq!((f.width, f.height, f.frame_rate), (limits::MAX_SIZE, 8, FrameRate::FPS_29_97));
|
||||
}
|
||||
|
||||
/// Time Stretch takes After Effects' range, ±1 % to ±9900 %: before, 1e300 % was set, and a
|
||||
/// project file holding it opened at 9900 % (#3247).
|
||||
#[test]
|
||||
fn time_stretch_refuses_more_than_9900_percent() {
|
||||
let mut s = Session::default();
|
||||
s.execute("comp.new", json!({"name": "Main", "width": 64, "height": 32, "frameRate": 30, "duration": 2})).unwrap();
|
||||
let layer = s.execute("layer.newSolid", json!({"color": "#ff0000"})).unwrap()["layer"].as_u64().unwrap();
|
||||
let stretch = |s: &Session| s.active_comp().unwrap().layers.iter().find(|l| l.id.0 == layer).unwrap().stretch;
|
||||
for pct in [1e300, -1e300, 9900.5] {
|
||||
assert!(s.execute("layer.timeStretch", json!({"layers": [layer], "percent": pct})).is_err(), "{pct}");
|
||||
assert_eq!(stretch(&s), 100.0, "{pct}");
|
||||
}
|
||||
for pct in [limits::MAX_STRETCH, -limits::MAX_STRETCH] {
|
||||
s.execute("layer.timeStretch", json!({"layers": [layer], "percent": pct})).unwrap();
|
||||
assert_eq!(stretch(&s), pct);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_project_with_a_precomp_cycle_opens_adds_and_renders_without_running_out_of_stack() {
|
||||
// Comps A and B nest each other (a damaged file); C is unrelated. Checking whether A
|
||||
|
||||
@@ -1417,3 +1417,107 @@ fn duplicate_of_an_empty_type_tool_layer_ends_the_edit_and_copies_it() {
|
||||
assert_eq!(layer_count(&s), before + 1, "the empty layer is kept, as text.endEdit keeps it, and copied");
|
||||
assert_eq!(s.state.selected_layers, vec![LayerId(copy)], "and the copy is selected");
|
||||
}
|
||||
|
||||
fn layer_name(s: &Session, t: u64) -> String {
|
||||
s.active_comp().unwrap().layer(effectcraft_project::LayerId(t)).unwrap().name.clone()
|
||||
}
|
||||
|
||||
/// Replace the text with `text` in the on-canvas editor (everything selected, typed, committed).
|
||||
fn retype(s: &mut Session, t: u64, text: &str) {
|
||||
begin_edit(s, t);
|
||||
s.execute("text.insert", json!({"text": text})).unwrap();
|
||||
s.execute("text.endEdit", json!({})).unwrap();
|
||||
}
|
||||
|
||||
/// #3233: a text layer's name follows its Source Text, edited on canvas or by `layer.setText`,
|
||||
/// until the user renames it (After Effects). A user name stays, even one that starts with
|
||||
/// "Text", and so does a copy's; renaming to an empty name follows the text again.
|
||||
#[test]
|
||||
fn a_text_layer_name_follows_its_text_until_renamed() {
|
||||
let (mut s, t) = session("Hello");
|
||||
assert_eq!(layer_name(&s, t), "Hello");
|
||||
retype(&mut s, t, "World");
|
||||
assert_eq!(layer_name(&s, t), "World", "a new text layer follows its text");
|
||||
s.execute("layer.setText", json!({"layer": t, "text": "Again"})).unwrap();
|
||||
assert_eq!(layer_name(&s, t), "Again");
|
||||
s.execute("layer.rename", json!({"layer": t, "name": "Text Title"})).unwrap();
|
||||
s.execute("layer.setText", json!({"layer": t, "text": "Changed"})).unwrap();
|
||||
assert_eq!(layer_name(&s, t), "Text Title", "layer.setText keeps the user's name");
|
||||
retype(&mut s, t, "Typed");
|
||||
assert_eq!(layer_name(&s, t), "Text Title", "typing keeps the user's name");
|
||||
s.execute("layer.select", json!({"layers": [t]})).unwrap();
|
||||
let copy = s.execute("edit.duplicate", json!({})).unwrap()[0].as_u64().unwrap();
|
||||
let copy_name = layer_name(&s, copy);
|
||||
s.execute("layer.setText", json!({"layer": copy, "text": "Copied"})).unwrap();
|
||||
assert_eq!(layer_name(&s, copy), copy_name, "a copy keeps the user's name too");
|
||||
// Renaming to an empty name: the name is the text's again, and follows it.
|
||||
s.execute("layer.rename", json!({"layer": t, "name": ""})).unwrap();
|
||||
assert_eq!(layer_name(&s, t), "Typed", "an empty name shows the text");
|
||||
retype(&mut s, t, "Followed");
|
||||
assert_eq!(layer_name(&s, t), "Followed", "and follows it again");
|
||||
// Committing the unchanged name (Enter, Enter) does not stop the following.
|
||||
s.execute("layer.rename", json!({"layer": t, "name": "Followed"})).unwrap();
|
||||
s.execute("layer.setText", json!({"layer": t, "text": "Still"})).unwrap();
|
||||
assert_eq!(layer_name(&s, t), "Still");
|
||||
// A name given in Layer Settings is the user's too. The UI offers Layer Settings only for
|
||||
// solids, nulls, cameras and lights (as After Effects does); this guards the command, which
|
||||
// agents can call on any layer.
|
||||
s.execute("layer.settings", json!({"layer": t, "name": "Settings Name"})).unwrap();
|
||||
s.execute("layer.setText", json!({"layer": t, "text": "Later"})).unwrap();
|
||||
assert_eq!(layer_name(&s, t), "Settings Name", "Layer Settings names it for good");
|
||||
// An empty Layer Settings name follows the text again, as `layer.rename` does.
|
||||
s.execute("layer.settings", json!({"layer": t, "name": ""})).unwrap();
|
||||
assert_eq!(layer_name(&s, t), "Later", "an empty Layer Settings name shows the text");
|
||||
s.execute("layer.setText", json!({"layer": t, "text": "Follows"})).unwrap();
|
||||
assert_eq!(layer_name(&s, t), "Follows", "and follows it again");
|
||||
// A layer given a name when it is made does not follow.
|
||||
let named = s.execute("layer.newText", json!({"text": "Body", "name": "Caption"})).unwrap()["layer"].as_u64().unwrap();
|
||||
s.execute("layer.setText", json!({"layer": named, "text": "Other"})).unwrap();
|
||||
assert_eq!(layer_name(&s, named), "Caption");
|
||||
}
|
||||
|
||||
/// #3233: After Effects breaks paragraphs with a bare carriage return; the name is the first
|
||||
/// paragraph, without the break, and an older layer named after it counts as following.
|
||||
#[test]
|
||||
fn a_text_layer_name_ends_at_a_bare_carriage_return() {
|
||||
let (mut s, t) = session("A\rB");
|
||||
assert_eq!(layer_name(&s, t), "A");
|
||||
s.execute("layer.setText", json!({"layer": t, "text": "C\rD"})).unwrap();
|
||||
assert_eq!(layer_name(&s, t), "C");
|
||||
retype(&mut s, t, "E\rF");
|
||||
assert_eq!(layer_name(&s, t), "E");
|
||||
let cid = s.active_comp_id().unwrap();
|
||||
let l = std::sync::Arc::make_mut(&mut s.project).comp_mut(cid).unwrap().layer_mut(effectcraft_project::LayerId(t)).unwrap();
|
||||
l.name = "Para1".into();
|
||||
l.name_follows_text = None;
|
||||
l.props.prop_mut("text/sourceText").unwrap().value = effectcraft_keyframe::Value::Text(Box::new(TextDoc::plain("Para1\rPara2")));
|
||||
s.execute("layer.setText", json!({"layer": t, "text": "Next\rLine"})).unwrap();
|
||||
assert_eq!(layer_name(&s, t), "Next", "an older layer named after its first paragraph follows");
|
||||
}
|
||||
|
||||
/// #3233: projects saved before the state was recorded load without it; there a name that is
|
||||
/// the text's, empty, or the default "Text"/"Text 2"… follows the text, any other name stays.
|
||||
#[test]
|
||||
fn older_text_layers_without_the_name_state_follow_as_before() {
|
||||
let (mut s, t) = session("Hello");
|
||||
let mut v = serde_json::to_value(s.active_comp().unwrap().layer(effectcraft_project::LayerId(t)).unwrap()).unwrap();
|
||||
v.as_object_mut().unwrap().remove("name_follows_text");
|
||||
let old: effectcraft_project::Layer = serde_json::from_value(v).unwrap();
|
||||
assert_eq!(old.name_follows_text, None, "an older project has no state");
|
||||
for (name, follows) in [("Hello", true), ("", true), ("Text", true), ("Text 3", true), ("Text Title", false), ("Text 3b", false), ("Title", false)] {
|
||||
let l = effectcraft_project::Layer { name: name.into(), ..old.clone() };
|
||||
assert_eq!(l.name_follows_text("Hello"), follows, "{name:?}");
|
||||
}
|
||||
let legacy = |s: &mut Session, name: &str| {
|
||||
let cid = s.active_comp_id().unwrap();
|
||||
let l = std::sync::Arc::make_mut(&mut s.project).comp_mut(cid).unwrap().layer_mut(effectcraft_project::LayerId(t)).unwrap();
|
||||
l.name = name.into();
|
||||
l.name_follows_text = None;
|
||||
};
|
||||
legacy(&mut s, "Hello");
|
||||
s.execute("layer.setText", json!({"layer": t, "text": "World"})).unwrap();
|
||||
assert_eq!(layer_name(&s, t), "World", "a name equal to the text follows it");
|
||||
legacy(&mut s, "Title");
|
||||
retype(&mut s, t, "Typed");
|
||||
assert_eq!(layer_name(&s, t), "Title", "another name stays");
|
||||
}
|
||||
@@ -292,19 +292,14 @@ fn extractor(e: &mut Enc, ctx: &EffectCtx, b: GBuf, a: Option<&AuxChannels>) ->
|
||||
let names = [pr.s("red"), pr.s("green"), pr.s("blue"), pr.s("alpha")];
|
||||
let (bp, wp) = (pr.f("blackPoint") as f32, pr.f("whitePoint") as f32);
|
||||
let unmult = pr.b("unMult");
|
||||
let own = |n: &str| match n.trim().to_ascii_uppercase().as_str() {
|
||||
"R" | "RED" => Some(0u32),
|
||||
"G" | "GREEN" => Some(1),
|
||||
"B" | "BLUE" => Some(2),
|
||||
"A" | "ALPHA" => Some(3),
|
||||
_ => None,
|
||||
};
|
||||
let src_ch: Vec<Option<&[f32]>> = names.iter().map(|n| a.and_then(|a| if n.trim().is_empty() { None } else { a.get(n.trim()) })).collect();
|
||||
if src_ch.iter().all(Option::is_none) && bp == 0.0 && wp == 1.0 && !unmult && names.iter().enumerate().all(|(k, n)| own(n) == Some(k as u32)) {
|
||||
// `(copy)`: the input's own channel of the same position, never an aux channel.
|
||||
let own = |k: usize, n: &str| effectcraft_effects::extractor_own_channel(k, n).and_then(|c| u32::try_from(c).ok());
|
||||
let src_ch: Vec<Option<&[f32]>> = names.iter().map(|n| a.and_then(|a| effectcraft_effects::extractor_aux_channel(a, n))).collect();
|
||||
if src_ch.iter().all(Option::is_none) && bp == 0.0 && wp == 1.0 && !unmult && names.iter().enumerate().all(|(k, n)| own(k, n) == Some(k as u32)) {
|
||||
return Some(b);
|
||||
}
|
||||
// Per output channel: 0..3 = the layer's own channel, 4 = aux, 5 = default (0, alpha 1).
|
||||
let sources: [u32; 4] = std::array::from_fn(|k| if src_ch[k].is_some() { 4 } else { own(names[k]).unwrap_or(5) });
|
||||
let sources: [u32; 4] = std::array::from_fn(|k| if src_ch[k].is_some() { 4 } else { own(k, names[k]).unwrap_or(5) });
|
||||
let plane = match a {
|
||||
Some(a) if src_ch.iter().any(Option::is_some) => {
|
||||
let tex = aux_texture(e, a, [src_ch[0], src_ch[1], src_ch[2], src_ch[3]])?;
|
||||
|
||||
@@ -288,4 +288,7 @@ fn extractor() {
|
||||
);
|
||||
case("ec.3d.extractor", &[("red", Value::Str("A".into())), ("blackPoint", n(0.2)), ("whitePoint", n(0.8)), ("clip", on())]);
|
||||
case("ec.3d.extractor", &[("green", Value::Str("".into())), ("alpha", Value::Str("nothing".into()))]);
|
||||
// `(copy)` reads the input's own channel, not the footage's R plane of aux_scene.
|
||||
let copy = || Value::Str(effectcraft_effects::EXTRACTOR_COPY.into());
|
||||
case("ec.3d.extractor", &[("red", copy()), ("green", Value::Str("Coverage".into())), ("alpha", copy())]);
|
||||
}
|
||||
@@ -522,11 +522,31 @@ impl Importer<'_> {
|
||||
// ---- timing
|
||||
let start = tick_in(ti.start);
|
||||
let dur = tick_in(ti.duration);
|
||||
let speed = if ti.speed.abs() < 1e-9 { 1.0 } else { ti.speed.abs() };
|
||||
// A zero, tiny (below 1e-9) or non-finite speed plays at 100 %. Otherwise the layer's
|
||||
// stretch (100 / speed) is held to the range the app supports (±1 % to ±9900 %): a speed
|
||||
// of 1e300 or 1e-300 from a damaged file made the layer and audio time divide by ~0
|
||||
// (#3247). The speed follows the stretch so the start time below matches it.
|
||||
let raw = ti.speed.abs();
|
||||
let (speed, stretch) = if !raw.is_finite() || raw < 1e-9 {
|
||||
// A speed of exactly 0 keeps its old silent 100 %; a tiny or non-finite one is named.
|
||||
if !raw.is_finite() || raw > 0.0 {
|
||||
self.warn(format!("clip \"{}\": a speed of {} is not supported (100 % used)", ti.name, ti.speed));
|
||||
}
|
||||
(1.0, 100.0)
|
||||
} else {
|
||||
let stretch = effectcraft_project::limits::stretch(100.0 / raw);
|
||||
if stretch.to_bits() == (100.0 / raw).to_bits() {
|
||||
(raw, stretch)
|
||||
} else {
|
||||
let speed = 100.0 / stretch;
|
||||
self.warn(format!("clip \"{}\": a speed of {:e} % is outside what a layer supports ({} % used)", ti.name, raw * 100.0, speed * 100.0));
|
||||
(speed, stretch)
|
||||
}
|
||||
};
|
||||
let reverse = ti.reverse || ti.speed < 0.0;
|
||||
let src_in = tick_in(ti.source_in);
|
||||
let span = Tick((dur.0 as f64 * speed).round() as i64);
|
||||
l.stretch = if reverse { -100.0 / speed } else { 100.0 / speed };
|
||||
l.stretch = if reverse { -stretch } else { stretch };
|
||||
l.start_time =
|
||||
if reverse { start + Tick(((src_in + span).0 as f64 / speed).round() as i64) } else { start - Tick((src_in.0 as f64 / speed).round() as i64) };
|
||||
l.in_point = start;
|
||||
|
||||
@@ -314,6 +314,24 @@ fn imports_fcp7_xml_fcpxml_and_otio() {
|
||||
}
|
||||
}
|
||||
|
||||
/// A clip speed past what a layer's stretch supports (±1 % to ±9900 %) is held to it and named.
|
||||
/// Before, a speed of 1e6 gave a stretch of 1e-4 % and the layer and audio time divided by ~0
|
||||
/// (#3247).
|
||||
#[test]
|
||||
fn an_extreme_clip_speed_is_held_to_the_stretch_range() {
|
||||
for (speed, stretch) in [(1e6, 1.0), (1e-6, 9900.0)] {
|
||||
let (mut doc, main) = edit();
|
||||
doc.sequence_mut(main).unwrap().video_tracks[1].items[0].speed = speed;
|
||||
let (bytes, _) = fc::export(&doc, main, fc::Format::Otio, &Default::default()).unwrap();
|
||||
let (p, r) = import_doc(&bytes, "Main.otio", &mut probe);
|
||||
let main_comp = r.comps.iter().copied().find(|c| p.item(*c).unwrap().name == "Main").expect("Main comp");
|
||||
let c = p.comp(main_comp).unwrap();
|
||||
let stretched: Vec<f64> = c.layers.iter().map(|l| l.stretch).filter(|s| (s - 100.0).abs() > 1e-9).collect();
|
||||
assert_eq!(stretched, [stretch], "speed {speed}");
|
||||
assert!(r.warnings.iter().any(|w| w.contains("speed") && w.contains("outside")), "speed {speed}: {:?}", r.warnings);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn imports_aaf_and_omf() {
|
||||
let (doc, main) = edit();
|
||||
|
||||
@@ -431,7 +431,12 @@ fn build_layers(cx: &mut Ctx, project: &mut Project, comp: &mut Comp, layers: &[
|
||||
// Below 1 % (or 0) the layer time divides by ~0, and layer.timeStretch refuses it too.
|
||||
let sr = f(j, "sr").unwrap_or(1.0);
|
||||
l.stretch = if sr.is_finite() && sr.abs() >= 0.01 {
|
||||
sr * 100.0
|
||||
// Past ±9900 % too (#3247): the largest stretch a layer supports, sign kept.
|
||||
let pct = effectcraft_project::limits::stretch(sr * 100.0);
|
||||
if pct.to_bits() != (sr * 100.0).to_bits() {
|
||||
cx.im.warn(format!("{name}: a time stretch of {} % is not supported ({pct} % used)", sr * 100.0));
|
||||
}
|
||||
pct
|
||||
} else {
|
||||
cx.im.warn(format!("{name}: a time stretch of {} % is not supported (100 % used)", sr * 100.0));
|
||||
100.0
|
||||
|
||||
@@ -520,6 +520,21 @@ fn zero_time_stretch_is_not_imported() {
|
||||
assert!(imp.warnings.iter().any(|w| w.contains("time stretch")), "{:?}", imp.warnings);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_time_stretch_past_9900_percent_is_held_to_it() {
|
||||
// `sr` 1e300 was imported as 1e302 % (#3247); the sign of a reversed layer is kept.
|
||||
for (sr, want) in [(1e300, 9900.0), (-1e300, -9900.0), (99.0, 9900.0)] {
|
||||
let doc = json!({"w": 20, "h": 20, "fr": 30, "ip": 0, "op": 30, "assets": [],
|
||||
"layers": [{"ty": 1, "ind": 1, "sc": "#ff0000", "sw": 20, "sh": 20, "sr": sr, "ip": 0, "op": 30, "st": 0, "ks": {}}]});
|
||||
let mut q = Project::default();
|
||||
let imp = import(&mut q, doc.to_string().as_bytes(), "Imported", "", &mut |_| None).unwrap();
|
||||
let l = &q.comp(imp.comp).unwrap().layers[0];
|
||||
assert_eq!(l.stretch, want, "sr {sr}");
|
||||
let warned = imp.warnings.iter().any(|w| w.contains("time stretch"));
|
||||
assert_eq!(warned, sr != 99.0, "sr {sr}: {:?}", imp.warnings);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn embedded_glyphs_are_reported_when_dropped() {
|
||||
let glyph = |ch: &str, fam: &str| json!({"ch": ch, "size": 100, "style": "Regular", "w": 60, "fFamily": fam, "data": {"shapes": []}});
|
||||
|
||||
@@ -935,6 +935,7 @@ pub fn layer(project: &mut Project, comp: &Comp, name: &str, source: LayerSource
|
||||
Layer {
|
||||
id,
|
||||
name: comp.unique_layer_name(name),
|
||||
name_follows_text: None,
|
||||
source,
|
||||
label,
|
||||
comment: String::new(),
|
||||
|
||||
@@ -299,6 +299,8 @@ impl Layer {
|
||||
let Layer {
|
||||
id,
|
||||
name,
|
||||
// Whether the name follows the text changes no pixels until the name changes.
|
||||
name_follows_text: _,
|
||||
source,
|
||||
label,
|
||||
comment,
|
||||
@@ -470,10 +472,29 @@ pub enum AutoOrient {
|
||||
TowardsPointOfInterest,
|
||||
}
|
||||
|
||||
/// A stretch written as `null` (a NaN or infinite stretch: JSON holds neither, and auto-saves
|
||||
/// write it so) reads as NaN, so the project still opens and loading brings it to 100 % with a
|
||||
/// note ([`crate::limits::stretch`], #3247) instead of the whole file being refused. An infinite
|
||||
/// stretch therefore also reopens at 100 %, not at ±9900 %.
|
||||
fn stretch_or_nan<'de, D: serde::Deserializer<'de>>(d: D) -> Result<f64, D::Error> {
|
||||
Ok(Option::<f64>::deserialize(d)?.unwrap_or(f64::NAN))
|
||||
}
|
||||
|
||||
/// `t × mul / div` in ticks, within [`Tick::MIN`]..=[`Tick::MAX`] (the cast saturates, NaN is 0),
|
||||
/// so a layer time of an extreme stretch can't overflow later tick arithmetic.
|
||||
fn scaled(t: Tick, mul: f64, div: f64) -> Tick {
|
||||
Tick(((t.0 as f64 * mul / div) as i64).clamp(Tick::MIN.0, Tick::MAX.0))
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct Layer {
|
||||
pub id: LayerId,
|
||||
pub name: String,
|
||||
/// A text layer's name follows the first line of its Source Text until the user renames it
|
||||
/// (After Effects). `Some(true)`: it follows, `Some(false)`: the user named it. `None` (older
|
||||
/// projects, layers made before this was recorded): see [`Layer::name_follows_text()`].
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub name_follows_text: Option<bool>,
|
||||
pub source: LayerSource,
|
||||
pub label: Label,
|
||||
#[serde(default)]
|
||||
@@ -484,6 +505,7 @@ pub struct Layer {
|
||||
pub in_point: Tick,
|
||||
pub out_point: Tick,
|
||||
/// Time stretch in percent (100 = normal, negative = reversed).
|
||||
#[serde(deserialize_with = "stretch_or_nan")]
|
||||
pub stretch: f64,
|
||||
pub switches: Switches,
|
||||
pub blend_mode: BlendMode,
|
||||
@@ -512,7 +534,26 @@ pub struct Layer {
|
||||
pub props: PropGroup,
|
||||
}
|
||||
|
||||
/// The name a text layer takes from its text: the first 40 characters of the first line. A line
|
||||
/// ends at a line feed or at a bare carriage return (After Effects' paragraph break).
|
||||
pub fn text_layer_name(text: &str) -> String {
|
||||
text.split(['\r', '\n']).next().unwrap_or("").chars().take(40).collect()
|
||||
}
|
||||
|
||||
/// A text layer's default name when its text gives none: "Text", numbered "Text 2", "Text 3"…
|
||||
fn is_default_text_name(name: &str) -> bool {
|
||||
name == "Text" || name.strip_prefix("Text ").is_some_and(|n| !n.is_empty() && n.chars().all(|c| c.is_ascii_digit()))
|
||||
}
|
||||
|
||||
impl Layer {
|
||||
/// Whether this text layer's name follows its Source Text, whose first line gives the name
|
||||
/// `current` (see [`text_layer_name`]). Without a recorded state (an older project), a name
|
||||
/// that is empty, the text's own, or the default "Text"/"Text 2"… follows; any other name was
|
||||
/// given by the user.
|
||||
pub fn name_follows_text(&self, current: &str) -> bool {
|
||||
self.name_follows_text.unwrap_or_else(|| self.name.is_empty() || self.name == current || is_default_text_name(&self.name))
|
||||
}
|
||||
|
||||
/// The layers this layer reads: its parent, track matte, layer parameters (Displacement Map,
|
||||
/// Audio Spectrum's Audio Layer…) and tracker targets.
|
||||
pub fn referenced_layers(&self) -> Vec<LayerId> {
|
||||
@@ -555,15 +596,24 @@ impl Layer {
|
||||
t >= self.in_point && t < self.out_point
|
||||
}
|
||||
|
||||
/// The time stretch the layer plays at: [`Layer::stretch`] within
|
||||
/// [`limits::stretch`](crate::limits::stretch) (a damaged value that got past loading, 0 or
|
||||
/// NaN, never divides the layer's time by zero).
|
||||
pub fn effective_stretch(&self) -> f64 {
|
||||
crate::limits::stretch(self.stretch)
|
||||
}
|
||||
|
||||
/// Layer time for a comp time (stretch-aware).
|
||||
pub fn layer_time(&self, comp_t: Tick) -> Tick {
|
||||
let d = comp_t - self.start_time;
|
||||
if (self.stretch - 100.0).abs() < 1e-9 { d } else { Tick((d.0 as f64 * 100.0 / self.stretch) as i64) }
|
||||
let s = self.effective_stretch();
|
||||
if (s - 100.0).abs() < 1e-9 { d } else { scaled(d, 100.0, s) }
|
||||
}
|
||||
|
||||
/// Comp time for a layer time.
|
||||
pub fn comp_time(&self, layer_t: Tick) -> Tick {
|
||||
if (self.stretch - 100.0).abs() < 1e-9 { self.start_time + layer_t } else { self.start_time + Tick((layer_t.0 as f64 * self.stretch / 100.0) as i64) }
|
||||
let s = self.effective_stretch();
|
||||
if (s - 100.0).abs() < 1e-9 { self.start_time + layer_t } else { self.start_time + scaled(layer_t, s, 100.0) }
|
||||
}
|
||||
|
||||
pub fn transform(&self) -> Option<&PropGroup> {
|
||||
|
||||
@@ -44,7 +44,7 @@ pub use item::NO_LAYER;
|
||||
pub use item::{Heard, Item, ItemId, ItemKind, LayerId};
|
||||
pub use layer::{
|
||||
AutoOrient, FrameBlend, Layer, LayerSource, LightKind, MatteKind, PrimitiveKind, Quality, Sampling, Switches, TrackMatte, is_audio_effect,
|
||||
reads_other_frames,
|
||||
reads_other_frames, text_layer_name,
|
||||
};
|
||||
pub use layers::Layers;
|
||||
pub use reach::{ExprReach, PixelReach, expression_reach};
|
||||
|
||||
@@ -27,6 +27,30 @@ pub const MAX_PIXEL_ASPECT: f64 = 10.0;
|
||||
pub const MOTION_BLUR_SAMPLES: (u32, u32) = (2, 64);
|
||||
/// Composition Settings ▸ Advanced ▸ Adaptive Sample Limit.
|
||||
pub const ADAPTIVE_SAMPLE_LIMIT: (u32, u32) = (16, 256);
|
||||
/// The smallest time stretch, in percent, either way (After Effects sets a stretch between 0 and
|
||||
/// 1 % to 1 %, between -1 % and 0 to -1 %; `layer.timeStretch` refuses less).
|
||||
pub const MIN_STRETCH: f64 = 1.0;
|
||||
/// The largest time stretch, in percent, either way (After Effects' `Layer.stretch` range is
|
||||
/// -9900 to 9900).
|
||||
pub const MAX_STRETCH: f64 = 9900.0;
|
||||
|
||||
/// A layer's time stretch from [`MIN_STRETCH`] to [`MAX_STRETCH`] % either way, its sign (a
|
||||
/// reversed layer) kept; 100 % for 0 or NaN. Layer and comp time divide by the stretch, and the
|
||||
/// audio mix reads `100 / stretch` source samples per sample, so a stretch of 1e-300 % or NaN
|
||||
/// from a damaged file must not reach them (#3247).
|
||||
pub fn stretch(v: f64) -> f64 {
|
||||
if v.is_nan() || v == 0.0 {
|
||||
return 100.0;
|
||||
}
|
||||
// Infinity clamps to the largest stretch with its sign.
|
||||
v.signum() * v.abs().clamp(MIN_STRETCH, MAX_STRETCH)
|
||||
}
|
||||
|
||||
/// A stretch for a note: a plain number in a readable range, scientific notation beyond it (a
|
||||
/// plain 1e-300 would print 300 digits).
|
||||
fn percent(v: f64) -> String {
|
||||
if v == 0.0 || (1e-3..1e7).contains(&v.abs()) { format!("{v} %") } else { format!("{v:e} %") }
|
||||
}
|
||||
|
||||
/// A width or height from 1 to [`MAX_SIZE`].
|
||||
pub fn size(v: u64) -> u32 {
|
||||
@@ -97,9 +121,11 @@ fn repair_comp(name: &str, c: &Comp, notes: &mut Vec<String>) -> Option<Comp> {
|
||||
let duration = c.duration.max(rate.frame_duration());
|
||||
let samples = c.motion_blur_samples.clamp(MOTION_BLUR_SAMPLES.0, MOTION_BLUR_SAMPLES.1);
|
||||
let adaptive = c.motion_blur_adaptive_limit.clamp(ADAPTIVE_SAMPLE_LIMIT.0, ADAPTIVE_SAMPLE_LIMIT.1);
|
||||
let stretch_ok = |l: &crate::Layer| stretch(l.stretch).to_bits() == l.stretch.to_bits();
|
||||
let unchanged = (width, height, rate, duration, samples, adaptive)
|
||||
== (c.width, c.height, c.frame_rate, c.duration, c.motion_blur_samples, c.motion_blur_adaptive_limit)
|
||||
&& pa.to_bits() == c.pixel_aspect.to_bits();
|
||||
&& pa.to_bits() == c.pixel_aspect.to_bits()
|
||||
&& c.layers.iter().all(stretch_ok);
|
||||
if unchanged {
|
||||
return None;
|
||||
}
|
||||
@@ -125,6 +151,12 @@ fn repair_comp(name: &str, c: &Comp, notes: &mut Vec<String>) -> Option<Comp> {
|
||||
}
|
||||
(n.width, n.height, n.pixel_aspect, n.frame_rate, n.duration) = (width, height, pa, rate, duration);
|
||||
(n.motion_blur_samples, n.motion_blur_adaptive_limit) = (samples, adaptive);
|
||||
// Only the layers out of range are copied (the others stay shared).
|
||||
for l in n.layers.iter_mut_where(|l| !stretch_ok(l)) {
|
||||
let fixed = stretch(l.stretch);
|
||||
note(notes, name, &format!("layer '{}' time stretch", l.name), percent(l.stretch), percent(fixed));
|
||||
l.stretch = fixed;
|
||||
}
|
||||
Some(n)
|
||||
}
|
||||
|
||||
@@ -217,6 +249,85 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_stretch_is_held_to_one_to_9900_percent_with_its_sign() {
|
||||
assert_eq!(stretch(f64::NAN), 100.0);
|
||||
assert_eq!(stretch(0.0), 100.0);
|
||||
assert_eq!(stretch(-0.0), 100.0);
|
||||
assert_eq!(stretch(1e-300), MIN_STRETCH);
|
||||
assert_eq!(stretch(-1e-300), -MIN_STRETCH);
|
||||
assert_eq!(stretch(0.5), MIN_STRETCH);
|
||||
assert_eq!(stretch(1e300), MAX_STRETCH);
|
||||
assert_eq!(stretch(f64::INFINITY), MAX_STRETCH);
|
||||
assert_eq!(stretch(f64::NEG_INFINITY), -MAX_STRETCH);
|
||||
for v in [1.0, -1.0, 33.3, 100.0, -100.0, 200.0, 9900.0, -9900.0] {
|
||||
assert_eq!(stretch(v).to_bits(), v.to_bits(), "{v} is in range");
|
||||
}
|
||||
}
|
||||
|
||||
/// A layer whose stretch got past loading (0, NaN, 1e-300 %, set in memory) still maps time
|
||||
/// through a stretch in range: before, 0 and 1e-300 % saturated every layer time to the end of
|
||||
/// time and NaN collapsed it to 0 (#3247).
|
||||
#[test]
|
||||
fn layer_and_comp_time_use_a_stretch_in_range() {
|
||||
let mut p = crate::Project::default();
|
||||
let comp = Comp::new(64, 64, FrameRate::FPS_25, Tick::from_seconds_f64(10.0));
|
||||
let mut l = crate::build::layer(&mut p, &comp, "L", crate::LayerSource::Camera, (64, 64), None);
|
||||
l.start_time = Tick::from_seconds_f64(1.0);
|
||||
let at = |s: f64| Tick::from_seconds_f64(s);
|
||||
for (bad, effective) in [(0.0, 100.0), (f64::NAN, 100.0), (1e-300, 1.0), (-1e-300, -1.0), (1e300, 9900.0), (f64::NEG_INFINITY, -9900.0)] {
|
||||
l.stretch = bad;
|
||||
assert_eq!(l.effective_stretch(), effective, "{bad}");
|
||||
// One comp second after the start is 100 / stretch layer seconds.
|
||||
let want = Tick((at(1.0).0 as f64 * 100.0 / effective) as i64);
|
||||
assert_eq!(l.layer_time(at(2.0)), want, "{bad}");
|
||||
// And back, within the rounding of the two conversions.
|
||||
assert!((l.comp_time(l.layer_time(at(2.0))) - at(2.0)).abs() <= Tick(100), "{bad}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_stretch_out_of_range_in_a_file_is_corrected_and_reported() {
|
||||
let mut p = crate::Project::default();
|
||||
let comp = Comp::new(64, 64, FrameRate::FPS_25, Tick::from_seconds_f64(2.0));
|
||||
let mut c = comp.clone();
|
||||
for (name, s) in [("tiny", 1e-300), ("reversed tiny", -1e-300), ("zero", 0.0), ("huge", 1e300), ("reversed huge", -5e4), ("fine", -50.0)] {
|
||||
let mut l = crate::build::layer(&mut p, &comp, name, crate::LayerSource::Camera, (64, 64), None);
|
||||
l.stretch = s;
|
||||
c.layers.push(l);
|
||||
}
|
||||
// Added through `add_item`, the comp is held to the limits too: write the values back in.
|
||||
let id = p.add_item("Comp", effectcraft_color::Label::None, None, ItemKind::Comp(Arc::new(c.clone())));
|
||||
assert!(p.comp(id).unwrap().layers.iter().all(|l| l.stretch.abs() >= MIN_STRETCH && l.stretch.abs() <= MAX_STRETCH));
|
||||
let mut v: serde_json::Value = serde_json::from_str(&p.to_json()).unwrap();
|
||||
for (k, l) in c.layers.iter().enumerate() {
|
||||
v["items"][id.0.to_string()]["kind"]["layers"][k]["stretch"] = serde_json::json!(l.stretch);
|
||||
}
|
||||
let (back, notes) = crate::Project::from_json_repaired(&v.to_string()).unwrap();
|
||||
let got: Vec<f64> = back.comp(id).unwrap().layers.iter().map(|l| l.stretch).collect();
|
||||
assert_eq!(got, [1.0, -1.0, 100.0, 9900.0, -9900.0, -50.0]);
|
||||
assert_eq!(
|
||||
notes,
|
||||
[
|
||||
"Comp: layer 'tiny' time stretch 1e-300 % → 1 %",
|
||||
"Comp: layer 'reversed tiny' time stretch -1e-300 % → -1 %",
|
||||
"Comp: layer 'zero' time stretch 0 % → 100 %",
|
||||
"Comp: layer 'huge' time stretch 1e300 % → 9900 %",
|
||||
"Comp: layer 'reversed huge' time stretch -50000 % → -9900 %",
|
||||
]
|
||||
);
|
||||
// A NaN stretch is written as null (an auto-save): the file opens, at 100 %, and says so.
|
||||
// Before, the whole project was refused.
|
||||
v["items"][id.0.to_string()]["kind"]["layers"][0]["stretch"] = serde_json::Value::Null;
|
||||
let (back, notes) = crate::Project::from_json_repaired(&v.to_string()).unwrap();
|
||||
assert_eq!(back.comp(id).unwrap().layers.iter().next().unwrap().stretch, 100.0);
|
||||
assert_eq!(notes.first().map(String::as_str), Some("Comp: layer 'tiny' time stretch NaN % → 100 %"));
|
||||
let mut nan = p.clone();
|
||||
nan.comp_mut(id).unwrap().layers.iter_mut().for_each(|l| l.stretch = f64::NAN);
|
||||
let (back, _) = crate::Project::from_json_repaired(&nan.to_json_compact()).unwrap();
|
||||
assert!(back.comp(id).unwrap().layers.iter().all(|l| l.stretch == 100.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_comp_in_range_is_not_copied() {
|
||||
let c = Comp::new(1920, 1080, FrameRate::FPS_24, Tick::from_seconds_f64(2.0));
|
||||
|
||||
@@ -155,7 +155,9 @@ impl MixEnv<'_> {
|
||||
return out;
|
||||
}
|
||||
let m = b - a;
|
||||
let speed = 100.0 / l.stretch;
|
||||
// Within ±1 % to ±9900 % (#3247): a stretch of 1e-300 % set in memory read about
|
||||
// usize::MAX source samples for one block.
|
||||
let speed = 100.0 / l.effective_stretch();
|
||||
let src_n = ((m as f64 * speed).ceil() as usize).max(1);
|
||||
let lt = l.layer_time(sample_tick(i0 + a as i64, rate));
|
||||
let buf = match &l.source {
|
||||
@@ -291,7 +293,8 @@ fn mix_into(sources: &AudioSources<'_>, comp_id: ItemId, start: Tick, out: &mut
|
||||
let any_solo = comp.layers.iter().any(|l| l.switches.solo && audible(project, l));
|
||||
let s0 = start.to_units_floor(rate as i64);
|
||||
for l in &comp.layers {
|
||||
if !audible(project, l) || (any_solo && !l.switches.solo) || l.stretch <= 0.0 {
|
||||
// The stretch the layer's time uses (#3247): 0 and NaN play at 100 %, as `layer_time`.
|
||||
if !audible(project, l) || (any_solo && !l.switches.solo) || l.effective_stretch() <= 0.0 {
|
||||
continue;
|
||||
}
|
||||
// Sample range of the block that falls inside the layer's [in, out).
|
||||
|
||||
@@ -22,6 +22,7 @@ mod nested;
|
||||
mod paint;
|
||||
mod remap;
|
||||
mod roi;
|
||||
mod stretch_limits;
|
||||
mod styles;
|
||||
mod time;
|
||||
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
//! A project file whose layers carry a time stretch outside ±1 % to ±9900 % (1e-300 %, 0,
|
||||
//! 1e300 %…) opens with the stretch brought into that range, and a frame of it draws and its audio
|
||||
//! mixes with bounded source reads. Before, a stretch of 1e-300 % asked the footage for about
|
||||
//! `usize::MAX` samples and the mix overflowed or tried a huge allocation, aborting the app (#3247).
|
||||
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
|
||||
use effectcraft_color::Label;
|
||||
use effectcraft_project::{Footage, FootageKind, ItemId, ItemKind, LayerSource, Project, build, limits};
|
||||
use effectcraft_time::Tick;
|
||||
|
||||
use super::{setup, solid};
|
||||
use crate::{FootageSource, Image, audio::mix_comp, render_frame};
|
||||
|
||||
/// Silent audio footage that records the longest read asked of it and refuses a read past a
|
||||
/// thousand seconds (so a runaway read can't take the machine's memory: the mix then fills the
|
||||
/// read with silence itself).
|
||||
#[derive(Default)]
|
||||
struct Probe {
|
||||
longest: AtomicUsize,
|
||||
}
|
||||
|
||||
impl FootageSource for Probe {
|
||||
fn frame(&self, _: ItemId, _: &Footage, _: Tick) -> Option<Arc<Image>> {
|
||||
None
|
||||
}
|
||||
fn audio(&self, _: ItemId, _: &Footage, _: Tick, frames: usize, rate: u32) -> Option<Vec<f32>> {
|
||||
self.longest.fetch_max(frames, Ordering::Relaxed);
|
||||
(frames <= rate as usize * 1000).then(|| vec![0.0; frames * 2])
|
||||
}
|
||||
}
|
||||
|
||||
/// The project file of a comp holding a red solid and a sound, both with their stretch set to
|
||||
/// `stretch` in the file text.
|
||||
fn file_with_stretch(stretch: f64) -> (String, ItemId) {
|
||||
let (mut p, cid, comp) = setup();
|
||||
let red = solid(&mut p, &comp, [1.0, 0.0, 0.0], 200, 100);
|
||||
let f = Footage {
|
||||
path: "tone.wav".into(),
|
||||
kind: FootageKind::Audio,
|
||||
has_audio: true,
|
||||
frame_rate: comp.frame_rate,
|
||||
duration: Tick::from_seconds_f64(10.0),
|
||||
..Default::default()
|
||||
};
|
||||
let fid = p.add_item("tone.wav", Label::SeaFoam, None, ItemKind::Footage(f));
|
||||
let sound = build::layer(&mut p, &comp, "sound", LayerSource::Footage { item: fid }, (0, 0), None);
|
||||
let c = p.comp_mut(cid).unwrap();
|
||||
c.layers.push(red);
|
||||
c.layers.push(sound);
|
||||
// Both layers are at 100 %; `{:e}` writes a JSON number for any finite value, and NaN is
|
||||
// written as null, as serializing the project writes it.
|
||||
let text = p.to_json();
|
||||
assert_eq!(text.matches("\"stretch\": 100.0").count(), 2);
|
||||
let value = if stretch.is_nan() { "null".to_string() } else { format!("{stretch:e}") };
|
||||
(text.replace("\"stretch\": 100.0", &format!("\"stretch\": {value}")), cid)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_file_with_an_extreme_stretch_opens_in_range_draws_and_mixes_with_bounded_reads() {
|
||||
for (stored, opened) in
|
||||
[(1e-300, 1.0), (-1e-300, -1.0), (0.0, 100.0), (1e300, limits::MAX_STRETCH), (-1e300, -limits::MAX_STRETCH), (0.5, 1.0), (f64::NAN, 100.0)]
|
||||
{
|
||||
let (text, cid) = file_with_stretch(stored);
|
||||
let (p, notes) = Project::from_json_repaired(&text).unwrap();
|
||||
assert!(notes.iter().filter(|n| n.contains("time stretch")).count() == 2, "{stored}: {notes:?}");
|
||||
let c = p.comp(cid).unwrap();
|
||||
assert!(c.layers.iter().all(|l| l.stretch == opened), "{stored}: {:?}", c.layers.iter().map(|l| l.stretch).collect::<Vec<_>>());
|
||||
// A frame draws: the solid has no keys and keeps its in and out points, so it is red.
|
||||
let img = render_frame(&p, cid, Tick::ZERO, 1.0);
|
||||
assert_eq!(img.get(10, 10), [1.0, 0.0, 0.0, 1.0], "{stored}");
|
||||
// The audio mixes, reading at most 100 source samples per comp sample (a stretch of 1 %).
|
||||
let (rate, n) = (8000, 4000);
|
||||
let probe = Probe::default();
|
||||
let mixed = mix_comp(&p, &probe, None, cid, Tick::from_seconds_f64(0.5), n, rate);
|
||||
assert_eq!(mixed.len(), n * 2, "{stored}");
|
||||
assert!(probe.longest.load(Ordering::Relaxed) <= 100 * n + 1, "{stored}: a read of {} samples", probe.longest.load(Ordering::Relaxed));
|
||||
}
|
||||
}
|
||||
|
||||
/// A stretch set in memory (not through a file, so not repaired) mixes as the clamped stretch the
|
||||
/// layer's time uses: before, 1e-300 % read about `usize::MAX` source samples and overflowed.
|
||||
#[test]
|
||||
fn an_in_memory_extreme_stretch_mixes_with_bounded_reads() {
|
||||
for stretch in [1e-300, 0.0, f64::NAN, 1e300] {
|
||||
let (text, cid) = file_with_stretch(100.0);
|
||||
let (mut p, _) = Project::from_json_repaired(&text).unwrap();
|
||||
p.comp_mut(cid).unwrap().layers.iter_mut().for_each(|l| l.stretch = stretch);
|
||||
let (rate, n) = (8000, 4000);
|
||||
let probe = Probe::default();
|
||||
let mixed = mix_comp(&p, &probe, None, cid, Tick::from_seconds_f64(0.5), n, rate);
|
||||
assert_eq!(mixed.len(), n * 2, "{stretch}");
|
||||
assert!(probe.longest.load(Ordering::Relaxed) <= 100 * n + 1, "{stretch}: a read of {} samples", probe.longest.load(Ordering::Relaxed));
|
||||
}
|
||||
}
|
||||
@@ -262,7 +262,14 @@ pub fn time_stretch(app: &mut EffectcraftApp, ctx: &egui::Context, t: &Tokens) {
|
||||
ui.end_row();
|
||||
ui.label(tr("Stretch Factor:"));
|
||||
let before = d.percent;
|
||||
drag(ui, app, "dialog.timeStretch.percent", &mut d.percent, DragSpec { speed: 0.5, range: -10000.0..=10000.0, suffix: " %" }, true);
|
||||
drag(
|
||||
ui,
|
||||
app,
|
||||
"dialog.timeStretch.percent",
|
||||
&mut d.percent,
|
||||
DragSpec { speed: 0.5, range: -effectcraft_project::limits::MAX_STRETCH..=effectcraft_project::limits::MAX_STRETCH, suffix: " %" },
|
||||
true,
|
||||
);
|
||||
if (before - d.percent).abs() > 1e-9 && d.orig_percent.abs() > 1e-9 {
|
||||
d.duration = d.orig_duration * d.percent.abs() / d.orig_percent.abs();
|
||||
}
|
||||
@@ -271,7 +278,12 @@ pub fn time_stretch(app: &mut EffectcraftApp, ctx: &egui::Context, t: &Tokens) {
|
||||
let before = d.duration;
|
||||
drag(ui, app, "dialog.timeStretch.duration", &mut d.duration, DragSpec { speed: 0.05, range: 0.01..=100000.0, suffix: " s" }, true);
|
||||
if (before - d.duration).abs() > 1e-9 && d.orig_duration > 1e-9 {
|
||||
d.percent = d.orig_percent.signum() * d.orig_percent.abs() * d.duration / d.orig_duration;
|
||||
let wanted = d.orig_percent.signum() * d.orig_percent.abs() * d.duration / d.orig_duration;
|
||||
// Within what Time Stretch accepts (±1 % to ±9900 %, #3247); the duration follows.
|
||||
d.percent = effectcraft_project::limits::stretch(wanted);
|
||||
if d.percent.to_bits() != wanted.to_bits() && d.orig_percent.abs() > 1e-9 {
|
||||
d.duration = d.orig_duration * d.percent.abs() / d.orig_percent.abs();
|
||||
}
|
||||
}
|
||||
ui.end_row();
|
||||
});
|
||||
|
||||
Reference in new issue
Block a user