mirror of
https://github.com/storytold/effectcraft.git
synced 2026-11-15 01:14:30 +00:00
Merge remote-tracking branch 'upstream/main' into aep-audit-3300
This commit is contained in:
@@ -141,6 +141,9 @@ pub enum Extra {
|
||||
Curves,
|
||||
/// Mesh Warp's Distortion Mesh (965, 985, `MeshData`).
|
||||
MeshWarp,
|
||||
/// Lumetri Color: every parameter goes through its own table, keyed by match name
|
||||
/// (`import/lumetri.rs`, 1200).
|
||||
Lumetri,
|
||||
/// EXtractoR's Channel Info (`ExtractorChannels`): the channels by name.
|
||||
Extractor,
|
||||
/// IDentifier's Channel Info (`identifier_channel_type`): the chosen channel by name.
|
||||
@@ -538,6 +541,9 @@ pub const EFFECTS: &[EffectMap] = &[
|
||||
],
|
||||
extra: Extra::MeshWarp,
|
||||
},
|
||||
// Lumetri Color (1200): the parameters are mapped by match name, not by `parT` index
|
||||
// (`import/lumetri.rs`).
|
||||
EffectMap { match_name: "ADBE Lumetri", effect: "ec.color.lumetri", params: &[], extra: Extra::Lumetri },
|
||||
// The Cryptomatte plug-in (docs/aep-format.md 3.7): 1 Settings data, 2 Output, 3 Selection
|
||||
// Mode, imported by the Cryptomatte step (`Extra::Cryptomatte`).
|
||||
EffectMap { match_name: "Cryptomatte", effect: "ec.3d.cryptomatte", params: &[], extra: Extra::Cryptomatte },
|
||||
|
||||
@@ -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 | Extra::Extractor | Extra::Identifier => return None,
|
||||
Extra::None | Extra::Curves | Extra::MeshWarp | Extra::Lumetri | Extra::Cryptomatte | Extra::Extractor | Extra::Identifier => return None,
|
||||
Extra::HueSaturation => &HUE_SATURATION_MASTER,
|
||||
Extra::Levels => &LEVELS_RGB,
|
||||
};
|
||||
@@ -643,9 +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 {
|
||||
// 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 => &[],
|
||||
// Lumetri Color, EXtractoR and IDentifier have no prototype to write from, so they never
|
||||
// get this far (they are reported).
|
||||
Extra::None | Extra::Lumetri | Extra::Cryptomatte | Extra::Extractor | Extra::Identifier => &[],
|
||||
Extra::HueSaturation => &HUE_SATURATION_MASTER,
|
||||
Extra::Levels => &LEVELS_RGB,
|
||||
Extra::Curves => &["channel", "rgb", "red", "green", "blue", "alpha"],
|
||||
|
||||
@@ -27,6 +27,7 @@
|
||||
mod build;
|
||||
mod camera;
|
||||
mod effects;
|
||||
mod lumetri;
|
||||
mod markers;
|
||||
mod masks;
|
||||
mod props;
|
||||
|
||||
@@ -34,7 +34,7 @@
|
||||
//! import. Hue/Saturation's master values and Levels' values are arbitrary data, decoded by the
|
||||
//! reader (444-446, 512-516).
|
||||
|
||||
use crate::effect_map::{CRYPTOMATTE_OUTPUT, Conv, EFFECTS, EffectMap, Extra, HUE_SATURATION_MASTER, LEVELS_RGB, popup_key};
|
||||
use crate::effect_map::{CRYPTOMATTE_OUTPUT, Conv, EFFECTS, EffectMap, Extra, HUE_SATURATION_MASTER, LEVELS_RGB, ParamMap, popup_key};
|
||||
use crate::{
|
||||
CryptomatteData, CurvesData, DefinitionSource, Effect as AepEffect, EffectDefinitions, EffectParam, ExtractorChannels, HueSaturationData,
|
||||
Layer as AepLayer, LayerRef, LevelsData, MeshData, ParamKind, ParamValue,
|
||||
@@ -120,7 +120,9 @@ pub(crate) fn import_effects<'f, 'a>(
|
||||
);
|
||||
}
|
||||
}
|
||||
if map.extra == Extra::Cryptomatte {
|
||||
if map.extra == Extra::Lumetri {
|
||||
crate::import::lumetri::import_lumetri(&fx, ¶ms, cx, &shown, ParamOut { group: &mut group, refs: &mut *refs, col: &mut *col });
|
||||
} else if map.extra == Extra::Cryptomatte {
|
||||
import_cryptomatte(&fx, ¶ms, cx, &shown, &mut group, col);
|
||||
} else {
|
||||
for p in ¶ms {
|
||||
@@ -152,10 +154,15 @@ pub(crate) struct PendingLayerRef {
|
||||
|
||||
/// Where a parameter is imported to: the effect's group, the layer parameters still to resolve
|
||||
/// and the report.
|
||||
struct ParamOut<'a> {
|
||||
group: &'a mut PropGroup,
|
||||
refs: &'a mut Vec<PendingLayerRef>,
|
||||
col: &'a mut Collector,
|
||||
pub(crate) struct ParamOut<'a> {
|
||||
pub group: &'a mut PropGroup,
|
||||
pub refs: &'a mut Vec<PendingLayerRef>,
|
||||
pub col: &'a mut Collector,
|
||||
}
|
||||
|
||||
/// How a parameter is named in the report: its name and match name.
|
||||
pub(crate) fn param_label(p: &EffectParam<'_, '_>) -> String {
|
||||
if p.name.trim().is_empty() { p.match_name.clone() } else { format!("{} ({})", p.name.trim(), p.match_name) }
|
||||
}
|
||||
|
||||
/// Imports one parameter of `fx` into its EffectCraft property: its keyframes, the value in its
|
||||
@@ -165,7 +172,7 @@ fn import_param(fx: &AepEffect<'_, '_>, map: &EffectMap, p: &EffectParam<'_, '_>
|
||||
if carries_nothing(p) {
|
||||
return;
|
||||
}
|
||||
let label = if p.name.is_empty() { p.match_name.clone() } else { format!("{} ({})", p.name, p.match_name) };
|
||||
let label = param_label(p);
|
||||
let here = format!("{}: '{}' effect '{shown}' ({})", cx.comp, cx.layer, fx.match_name());
|
||||
if p.kind == ParamKind::ArbitraryData && map.extra != Extra::None {
|
||||
import_extra(fx, map.extra, p, &here, cx.size, group, col);
|
||||
@@ -179,6 +186,14 @@ fn import_param(fx: &AepEffect<'_, '_>, map: &EffectMap, p: &EffectParam<'_, '_>
|
||||
}
|
||||
return;
|
||||
};
|
||||
import_mapped(p, m, cx, (&here, &label), ParamOut { group, refs, col });
|
||||
}
|
||||
|
||||
/// Imports parameter `p` into the property `m` names, converted by `m.conv`. `names` is
|
||||
/// `(here, label)`, the effect and the parameter for the report.
|
||||
pub(crate) fn import_mapped(p: &EffectParam<'_, '_>, m: &ParamMap, cx: &LayerCtx<'_>, names: (&str, &str), out: ParamOut<'_>) {
|
||||
let (here, label) = names;
|
||||
let ParamOut { group, refs, col } = out;
|
||||
if p.kind != m.kind {
|
||||
col.lose(LossCategory::Effects, format!("{here}: parameter {label} has type {:?}, expected {:?}; not imported", p.kind, m.kind));
|
||||
return;
|
||||
@@ -186,7 +201,7 @@ fn import_param(fx: &AepEffect<'_, '_>, map: &EffectMap, p: &EffectParam<'_, '_>
|
||||
match m.conv {
|
||||
Conv::Skip => return,
|
||||
Conv::LayerRef { own } => {
|
||||
import_layer_ref(p, m.target, own, cx, (&here, &label), ParamOut { group, refs, col });
|
||||
import_layer_ref(p, m.target, own, cx, (here, label), ParamOut { group, refs, col });
|
||||
return;
|
||||
}
|
||||
_ => {}
|
||||
@@ -291,9 +306,9 @@ fn import_param(fx: &AepEffect<'_, '_>, map: &EffectMap, p: &EffectParam<'_, '_>
|
||||
col.lose(LossCategory::DamagedData, format!("{here}: parameter {label} has no readable keyframe, default used"));
|
||||
}
|
||||
if let Some(map) = &popup_map {
|
||||
remap_popup_keys(prop, map, &here, &label, col);
|
||||
remap_popup_keys(prop, map, here, label, col);
|
||||
}
|
||||
report_beyond_limit(m.limit, prop.keys.iter().map(|k| &k.value), &here, &label, col);
|
||||
report_beyond_limit(m.limit, prop.keys.iter().map(|k| &k.value), here, label, col);
|
||||
}
|
||||
return;
|
||||
}
|
||||
@@ -329,7 +344,7 @@ fn import_param(fx: &AepEffect<'_, '_>, map: &EffectMap, p: &EffectParam<'_, '_>
|
||||
};
|
||||
match converted {
|
||||
Some(v) => {
|
||||
report_beyond_limit(m.limit, std::iter::once(&v), &here, &label, col);
|
||||
report_beyond_limit(m.limit, std::iter::once(&v), here, label, col);
|
||||
prop.value = v;
|
||||
}
|
||||
None => col.lose(LossCategory::DamagedData, format!("{here}: parameter {label} holds a value that is not valid ({value:?}), default used")),
|
||||
@@ -556,7 +571,7 @@ fn import_extra(fx: &AepEffect<'_, '_>, extra: Extra, p: &EffectParam<'_, '_>, h
|
||||
},
|
||||
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 => {}
|
||||
Extra::None | Extra::Lumetri | Extra::Cryptomatte => {}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,803 @@
|
||||
//! Lumetri Color (`ADBE Lumetri`) to EffectCraft's Lumetri Color (`ec.color.lumetri`).
|
||||
//!
|
||||
//! The parameters are found by their match name (`ADBE Lumetri-0011` is Exposure), not by their
|
||||
//! place in `parT`: After Effects has added parameters in the middle of the list over the
|
||||
//! versions (`-0127` ... `-0130` sit between older ones), and several sliders share a valid range
|
||||
//! (Contrast, Highlights, Shadows, Whites and Blacks are all -150..150), so a shifted index
|
||||
//! would map silently to the wrong control. Evidence: the 1200 samples (After Effects 2026,
|
||||
//! `docs/aep-format.md` 3.7).
|
||||
//!
|
||||
//! - **Values in any record state.** Lumetri writes the values of its hidden controls (High
|
||||
//! Dynamic Range, HDR Specular, the second Look popup, ...) in records in the default state
|
||||
//! (`tdsb` 3); After Effects reads them back as set, so every record counts here
|
||||
//! ([`EffectParam::recorded_value`]).
|
||||
//! - **Section switches.** Each section (Basic Correction, Creative, Curves, RGB Curves, the five
|
||||
//! hue / saturation curves, Color Wheels, HSL Secondary, Vignette) has an on / off checkbox in
|
||||
//! After Effects; EffectCraft's Lumetri has none. A section that is off does nothing in After
|
||||
//! Effects (1200 `lumetri-03-sec-basic-correction`, `-17-sec-vignette` render as `-00-defaults`),
|
||||
//! so its values are not imported (the effect looks the same) and are listed in the report.
|
||||
//! - **Arbitrary data** (`ParamKind::ArbitraryData`, every instance stores it). Curves are a
|
||||
//! curve type, a point count and points as `f64` x, y ([`curve_points`]).
|
||||
//! - The RGB curve (`-0039`) holds four curves; the first is the master (white) curve (1200
|
||||
//! `lumetri-60-preset-curves-rgb`), imported. The other three are reported when moved: no
|
||||
//! sample tells which is which channel.
|
||||
//! - Hue vs Sat (`-0042`): y is an offset from neutral, 1 doubling the saturation (`-61`), so
|
||||
//! EffectCraft's y is `0.5 + y / 2`. The other four hue / saturation curves have no sample
|
||||
//! of their scale and are reported when moved.
|
||||
//! - Color Wheels (`-0047`): Shadows, Midtones, Highlights and a fourth wheel, each hue,
|
||||
//! brightness slider, distance ([`import_wheels`], `-62`).
|
||||
//! - The HSL Secondary key ([`HslKey`]) is imported; Split Toning and the correction wheels are
|
||||
//! reported when they are not the default (not sampled).
|
||||
//! - **LUTs and looks.** The Input LUT and the Look are files After Effects names (the Look's name
|
||||
//! is UTF-16 text in `-0096`, the popups only index a list of the installed ones); they are not
|
||||
//! in the project, so a set one is reported by name and EffectCraft's popup stays None.
|
||||
//! - **Eyedropper colours** (White Balance, the hue curve selectors, the key's Set / Add /
|
||||
//! Remove colours) only set other controls in After Effects; they are reported when set.
|
||||
//! - The `Blob` (`-0001`, an XML copy of the grade After Effects writes when a Look changes and does
|
||||
//! not keep current: 1200 `-01-set-all` holds the defaults of controls set after the Look) and
|
||||
//! the Auto Tone analysis (`-0121`, `-0122`) are derived data and not read.
|
||||
|
||||
use crate::effect_map::{Conv, ParamMap};
|
||||
use crate::{Effect as AepEffect, EffectParam, ParamKind, ParamValue};
|
||||
use effectcraft_keyframe::Value;
|
||||
|
||||
use crate::import::effects::{LayerCtx, ParamOut, carries_nothing, import_mapped, param_label};
|
||||
use crate::import::report::LossCategory;
|
||||
use ParamKind as K;
|
||||
|
||||
/// After Effects' Show Mask styles (`-0075`, `pdnm`), EffectCraft's in the same order.
|
||||
pub const MASK_STYLE: &[&str] = &["Color/Gray", "Color/Black", "White/Black"];
|
||||
|
||||
/// A part of the effect with its own on / off switch.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
enum Section {
|
||||
/// No switch (High Dynamic Range and the controls outside the sections).
|
||||
Always,
|
||||
BasicCorrection,
|
||||
Creative,
|
||||
RgbCurves,
|
||||
/// The five hue / saturation curves (0 Hue vs Sat ... 4 Sat vs Sat).
|
||||
HueSat(u8),
|
||||
ColorWheels,
|
||||
HslSecondary,
|
||||
Vignette,
|
||||
SpeedGradeCustom,
|
||||
}
|
||||
|
||||
impl Section {
|
||||
fn name(self) -> &'static str {
|
||||
match self {
|
||||
Section::Always => "",
|
||||
Section::BasicCorrection => "Basic Correction",
|
||||
Section::Creative => "Creative",
|
||||
Section::RgbCurves => "RGB Curves",
|
||||
Section::HueSat(0) => "Hue vs Sat",
|
||||
Section::HueSat(1) => "Hue vs Hue",
|
||||
Section::HueSat(2) => "Hue vs Luma",
|
||||
Section::HueSat(3) => "Luma vs Sat",
|
||||
Section::HueSat(_) => "Sat vs Sat",
|
||||
Section::ColorWheels => "Color Wheels",
|
||||
Section::HslSecondary => "HSL Secondary",
|
||||
Section::Vignette => "Vignette",
|
||||
Section::SpeedGradeCustom => "SpeedGrade Custom",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Arbitrary data the importer reads.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
enum Data {
|
||||
/// The four RGB curves (4 x 520 bytes).
|
||||
RgbCurves,
|
||||
/// One hue / saturation curve (520 bytes); the EffectCraft parameter when the scale of its y
|
||||
/// is known (Hue vs Sat).
|
||||
HueSatCurve(Option<&'static str>),
|
||||
/// Color Wheels (4 x 3 `f64`, each wheel 0, 0.5, 0 by default).
|
||||
Wheels,
|
||||
/// HSL Secondary's correction wheels (8 x 3 `f64`, the same default).
|
||||
CorrectionWheels,
|
||||
/// Split Toning (32 zero bytes by default).
|
||||
SplitToning,
|
||||
/// HSL Secondary's key ([`HslKey`]).
|
||||
HslKey,
|
||||
/// Data without a counterpart, empty by default (`fe fe` or zero bytes).
|
||||
Other,
|
||||
}
|
||||
|
||||
/// What a Lumetri parameter is.
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
enum Role {
|
||||
/// An EffectCraft parameter and the conversion.
|
||||
To(&'static str, Conv),
|
||||
/// The on / off switch of a section.
|
||||
Switch,
|
||||
/// No counterpart: reported when not at the default.
|
||||
Missing,
|
||||
/// A checkbox without counterpart whose `pard` default is not reliable: reported when a
|
||||
/// record says on. Legacy Tone And Saturation (`-0097`) is off in a fresh instance, but its
|
||||
/// `pard` default reads off in 1200 as saved by the script and on after the project was
|
||||
/// opened and saved again (`versions/1200-lumetri-26`, `-25x`, `-24x`).
|
||||
MissingWhenOn,
|
||||
/// An eyedropper colour: reported when set.
|
||||
Eyedropper,
|
||||
/// The Input LUT (`lut` true) or the Look: a popup, a name or an asset.
|
||||
Lut {
|
||||
lut: bool,
|
||||
},
|
||||
/// The Look's colour space (its popup and the space's id): goes with the Look.
|
||||
LookSpace,
|
||||
Data(Data),
|
||||
/// Derived data, not read.
|
||||
Derived,
|
||||
}
|
||||
|
||||
struct Row {
|
||||
suffix: u16,
|
||||
section: Section,
|
||||
kind: Option<ParamKind>,
|
||||
role: Role,
|
||||
}
|
||||
|
||||
const fn row(suffix: u16, section: Section, kind: Option<ParamKind>, role: Role) -> Row {
|
||||
Row { suffix, section, kind, role }
|
||||
}
|
||||
|
||||
const fn to(suffix: u16, section: Section, kind: ParamKind, target: &'static str, conv: Conv) -> Row {
|
||||
row(suffix, section, Some(kind), Role::To(target, conv))
|
||||
}
|
||||
|
||||
const fn plain(valid_max: f64) -> Conv {
|
||||
Conv::Plain { valid_max }
|
||||
}
|
||||
|
||||
use Section as S;
|
||||
|
||||
/// Every Lumetri parameter by match-name suffix (1200: `dump.props` and the `pard` of
|
||||
/// `lumetri-00-defaults`). The valid maxima are After Effects 2026's; the stored numbers are the
|
||||
/// values After Effects shows (read back in every variant).
|
||||
const ROWS: &[Row] = &[
|
||||
to(62, S::Always, K::Checkbox, "highDynamicRange", Conv::Checkbox),
|
||||
// Basic Correction: 3 the switch; 4 / 95 the Input LUT's name data, 5 / 127 its popups.
|
||||
row(3, S::BasicCorrection, Some(K::Checkbox), Role::Switch),
|
||||
row(4, S::BasicCorrection, Some(K::ArbitraryData), Role::Lut { lut: true }),
|
||||
row(95, S::BasicCorrection, Some(K::ArbitraryData), Role::Lut { lut: true }),
|
||||
row(5, S::BasicCorrection, Some(K::Popup), Role::Lut { lut: true }),
|
||||
row(127, S::BasicCorrection, Some(K::Popup), Role::Lut { lut: true }),
|
||||
to(63, S::BasicCorrection, K::FloatSlider, "basicCorrection/tone/hdrWhite", plain(1000.0)),
|
||||
// The Auto tone's Intensity (next to the Auto and Reset buttons 120, 119).
|
||||
row(123, S::BasicCorrection, Some(K::FloatSlider), Role::Missing),
|
||||
row(94, S::BasicCorrection, Some(K::Color), Role::Eyedropper),
|
||||
to(7, S::BasicCorrection, K::FloatSlider, "basicCorrection/whiteBalance/temperature", plain(300.0)),
|
||||
to(8, S::BasicCorrection, K::FloatSlider, "basicCorrection/whiteBalance/tint", plain(300.0)),
|
||||
to(20, S::BasicCorrection, K::FloatSlider, "basicCorrection/saturation", plain(300.0)),
|
||||
to(11, S::BasicCorrection, K::FloatSlider, "basicCorrection/tone/exposure", plain(7.0)),
|
||||
to(12, S::BasicCorrection, K::FloatSlider, "basicCorrection/tone/contrast", plain(150.0)),
|
||||
to(13, S::BasicCorrection, K::FloatSlider, "basicCorrection/tone/highlights", plain(150.0)),
|
||||
to(14, S::BasicCorrection, K::FloatSlider, "basicCorrection/tone/shadows", plain(150.0)),
|
||||
to(15, S::BasicCorrection, K::FloatSlider, "basicCorrection/tone/whites", plain(150.0)),
|
||||
to(16, S::BasicCorrection, K::FloatSlider, "basicCorrection/tone/blacks", plain(150.0)),
|
||||
to(64, S::BasicCorrection, K::FloatSlider, "basicCorrection/tone/hdrSpecular", plain(150.0)),
|
||||
// Creative: 23 the switch; 24 / 96 the Look's name, 25 / 128 its popups, 129 / 130 its colour
|
||||
// space.
|
||||
row(23, S::Creative, Some(K::Checkbox), Role::Switch),
|
||||
row(24, S::Creative, Some(K::ArbitraryData), Role::Lut { lut: false }),
|
||||
row(96, S::Creative, Some(K::ArbitraryData), Role::Lut { lut: false }),
|
||||
row(25, S::Creative, Some(K::Popup), Role::Lut { lut: false }),
|
||||
row(128, S::Creative, Some(K::Popup), Role::Lut { lut: false }),
|
||||
row(129, S::Creative, Some(K::ArbitraryData), Role::LookSpace),
|
||||
row(130, S::Creative, Some(K::Popup), Role::LookSpace),
|
||||
to(26, S::Creative, K::FloatSlider, "creative/lookIntensity", plain(200.0)),
|
||||
to(28, S::Creative, K::FloatSlider, "creative/adjustments/fadedFilm", plain(150.0)),
|
||||
to(29, S::Creative, K::FloatSlider, "creative/adjustments/sharpen", plain(100.0)),
|
||||
to(30, S::Creative, K::FloatSlider, "creative/adjustments/vibrance", plain(100.0)),
|
||||
to(31, S::Creative, K::FloatSlider, "creative/adjustments/creativeSaturation", plain(300.0)),
|
||||
row(32, S::Creative, Some(K::ArbitraryData), Role::Data(Data::SplitToning)),
|
||||
to(33, S::Creative, K::FloatSlider, "creative/adjustments/tintBalance", plain(150.0)),
|
||||
// Curves: 37 switches all curves, 118 the RGB curves, 104 ... 111 each hue / saturation curve.
|
||||
row(37, S::RgbCurves, Some(K::Checkbox), Role::Switch),
|
||||
row(118, S::RgbCurves, Some(K::Checkbox), Role::Switch),
|
||||
row(65, S::RgbCurves, Some(K::FloatSlider), Role::Missing),
|
||||
row(39, S::RgbCurves, Some(K::ArbitraryData), Role::Data(Data::RgbCurves)),
|
||||
row(104, S::HueSat(0), Some(K::Checkbox), Role::Switch),
|
||||
row(113, S::HueSat(0), Some(K::Color), Role::Eyedropper),
|
||||
row(42, S::HueSat(0), Some(K::ArbitraryData), Role::Data(Data::HueSatCurve(Some("curves/hueSaturationCurves/hueVsSat")))),
|
||||
row(105, S::HueSat(1), Some(K::Checkbox), Role::Switch),
|
||||
row(114, S::HueSat(1), Some(K::Color), Role::Eyedropper),
|
||||
row(106, S::HueSat(1), Some(K::ArbitraryData), Role::Data(Data::HueSatCurve(None))),
|
||||
row(107, S::HueSat(2), Some(K::Checkbox), Role::Switch),
|
||||
row(115, S::HueSat(2), Some(K::Color), Role::Eyedropper),
|
||||
row(108, S::HueSat(2), Some(K::ArbitraryData), Role::Data(Data::HueSatCurve(None))),
|
||||
row(109, S::HueSat(3), Some(K::Checkbox), Role::Switch),
|
||||
row(116, S::HueSat(3), Some(K::Color), Role::Eyedropper),
|
||||
row(110, S::HueSat(3), Some(K::ArbitraryData), Role::Data(Data::HueSatCurve(None))),
|
||||
row(111, S::HueSat(4), Some(K::Checkbox), Role::Switch),
|
||||
row(117, S::HueSat(4), Some(K::Color), Role::Eyedropper),
|
||||
row(112, S::HueSat(4), Some(K::ArbitraryData), Role::Data(Data::HueSatCurve(None))),
|
||||
// Color Wheels: 46 the switch, 102 Face Detection (on by default), 66 HDR White, 47 the wheels.
|
||||
row(46, S::ColorWheels, Some(K::Checkbox), Role::Switch),
|
||||
row(102, S::ColorWheels, Some(K::Checkbox), Role::Missing),
|
||||
row(66, S::ColorWheels, Some(K::FloatSlider), Role::Missing),
|
||||
row(47, S::ColorWheels, Some(K::ArbitraryData), Role::Data(Data::Wheels)),
|
||||
// HSL Secondary: 68 the switch; the key (70-76), Refine (81, 82), Correction (85-90).
|
||||
row(68, S::HslSecondary, Some(K::Checkbox), Role::Switch),
|
||||
row(70, S::HslSecondary, Some(K::Color), Role::Eyedropper),
|
||||
row(71, S::HslSecondary, Some(K::Color), Role::Eyedropper),
|
||||
row(72, S::HslSecondary, Some(K::Color), Role::Eyedropper),
|
||||
row(73, S::HslSecondary, Some(K::ArbitraryData), Role::Data(Data::HslKey)),
|
||||
to(74, S::HslSecondary, K::Checkbox, "hslSecondary/key/showMask", Conv::Checkbox),
|
||||
to(75, S::HslSecondary, K::Popup, "hslSecondary/key/maskStyle", Conv::Popup { default: 1, items: MASK_STYLE }),
|
||||
to(76, S::HslSecondary, K::Checkbox, "hslSecondary/key/invertMask", Conv::Checkbox),
|
||||
to(81, S::HslSecondary, K::FloatSlider, "hslSecondary/refine/denoise", plain(100.0)),
|
||||
to(82, S::HslSecondary, K::FloatSlider, "hslSecondary/refine/blur", plain(1000.0)),
|
||||
row(85, S::HslSecondary, Some(K::ArbitraryData), Role::Data(Data::CorrectionWheels)),
|
||||
// The correction wheel's mode (4 zero bytes by default).
|
||||
row(93, S::HslSecondary, Some(K::ArbitraryData), Role::Data(Data::Other)),
|
||||
to(86, S::HslSecondary, K::FloatSlider, "hslSecondary/correction/temperature", plain(300.0)),
|
||||
to(87, S::HslSecondary, K::FloatSlider, "hslSecondary/correction/tint", plain(300.0)),
|
||||
to(88, S::HslSecondary, K::FloatSlider, "hslSecondary/correction/contrast", plain(150.0)),
|
||||
to(89, S::HslSecondary, K::FloatSlider, "hslSecondary/correction/sharpen", plain(100.0)),
|
||||
to(90, S::HslSecondary, K::FloatSlider, "hslSecondary/correction/saturation", plain(300.0)),
|
||||
// Vignette: 50 the switch.
|
||||
row(50, S::Vignette, Some(K::Checkbox), Role::Switch),
|
||||
to(51, S::Vignette, K::FloatSlider, "vignette/vignetteAmount", plain(5.0)),
|
||||
to(52, S::Vignette, K::FloatSlider, "vignette/vignetteMidpoint", plain(100.0)),
|
||||
to(53, S::Vignette, K::FloatSlider, "vignette/vignetteRoundness", plain(100.0)),
|
||||
to(54, S::Vignette, K::FloatSlider, "vignette/vignetteFeather", plain(100.0)),
|
||||
// SpeedGrade Custom: 57 Custom Layer (on by default), 60 its data.
|
||||
row(57, S::SpeedGradeCustom, Some(K::Checkbox), Role::Switch),
|
||||
row(60, S::SpeedGradeCustom, Some(K::ArbitraryData), Role::Data(Data::Other)),
|
||||
// Outside the sections: Legacy Tone And Saturation, the embedded LUTs, Auto Tone, Auto Color,
|
||||
// the LUT and Look assets.
|
||||
row(97, S::Always, Some(K::Checkbox), Role::MissingWhenOn),
|
||||
row(98, S::Always, Some(K::ArbitraryData), Role::Data(Data::Other)),
|
||||
row(99, S::Always, Some(K::ArbitraryData), Role::Data(Data::Other)),
|
||||
row(100, S::Always, Some(K::ArbitraryData), Role::Data(Data::Other)),
|
||||
row(124, S::Always, Some(K::Checkbox), Role::Missing),
|
||||
row(125, S::Always, Some(K::ArbitraryData), Role::Lut { lut: true }),
|
||||
row(126, S::Always, Some(K::ArbitraryData), Role::Lut { lut: false }),
|
||||
row(1, S::Always, Some(K::ArbitraryData), Role::Derived),
|
||||
row(121, S::Always, Some(K::FloatSlider), Role::Derived),
|
||||
row(122, S::Always, Some(K::ArbitraryData), Role::Derived),
|
||||
];
|
||||
|
||||
/// The names After Effects shows for the parameters whose `pard` name is blank (`dump.props`).
|
||||
fn blank_name(suffix: u16) -> Option<&'static str> {
|
||||
Some(match suffix {
|
||||
62 => "High Dynamic Range",
|
||||
3 | 23 | 37 | 118 | 104 | 105 | 107 | 109 | 111 | 46 | 68 | 50 => "Active",
|
||||
4 | 95 => "Input LUT data",
|
||||
24 | 96 => "Look name",
|
||||
102 => "Face Detection",
|
||||
74 => "Show Mask",
|
||||
75 => "Mask style",
|
||||
76 => "Invert Mask",
|
||||
97 => "Legacy Tone And Saturation",
|
||||
124 => "Auto Color Active",
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
|
||||
/// The parameter's name for the report: its own, else the one After Effects shows.
|
||||
fn name_of(p: &EffectParam<'_, '_>) -> String {
|
||||
match (p.name.trim(), suffix(&p.match_name).and_then(blank_name)) {
|
||||
("", Some(n)) => n.to_string(),
|
||||
("", None) => p.match_name.clone(),
|
||||
(n, _) => n.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
/// The match-name suffix of a Lumetri parameter (`ADBE Lumetri-0011` is 11).
|
||||
fn suffix(match_name: &str) -> Option<u16> {
|
||||
match_name.strip_prefix("ADBE Lumetri-")?.parse().ok()
|
||||
}
|
||||
|
||||
/// The parameter's value: the recorded one, else the `pard` default.
|
||||
fn value_of(p: &EffectParam<'_, '_>) -> Option<ParamValue> {
|
||||
p.recorded_value().or_else(|| p.pard_default())
|
||||
}
|
||||
|
||||
/// True when the parameter has keyframes, an expression, or a recorded value other than its
|
||||
/// default (a record without a readable default counts as set).
|
||||
fn is_set(p: &EffectParam<'_, '_>) -> bool {
|
||||
if p.is_animated() || p.stored().is_some_and(|s| s.expression().is_some()) {
|
||||
return true;
|
||||
}
|
||||
match (p.recorded_value(), p.pard_default()) {
|
||||
(Some(v), Some(d)) => !same(v, d),
|
||||
(Some(_), None) => true,
|
||||
(None, _) => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Equal within the float noise of a stored `f64` / `f32`.
|
||||
fn same(a: ParamValue, b: ParamValue) -> bool {
|
||||
let close = |x: f64, y: f64| (x - y).abs() <= 1e-6 * x.abs().max(y.abs()).max(1.0);
|
||||
match (a, b) {
|
||||
(ParamValue::Number(x), ParamValue::Number(y)) => close(x, y),
|
||||
(ParamValue::Argb(x), ParamValue::Argb(y)) => x.iter().zip(y).skip(1).all(|(x, y)| close(*x, y)),
|
||||
_ => a == b,
|
||||
}
|
||||
}
|
||||
|
||||
/// A value as the report shows it.
|
||||
fn shown_value(v: Option<ParamValue>) -> String {
|
||||
match v {
|
||||
Some(ParamValue::Number(n)) => {
|
||||
let s = format!("{n:.3}");
|
||||
s.trim_end_matches('0').trim_end_matches('.').to_string()
|
||||
}
|
||||
Some(ParamValue::Bool(b)) => if b { "on" } else { "off" }.to_string(),
|
||||
Some(ParamValue::Index(i)) => format!("item {i}"),
|
||||
Some(ParamValue::Argb([_, r, g, b])) => format!("RGB {:.3} {:.3} {:.3}", r, g, b),
|
||||
Some(other) => format!("{other:?}"),
|
||||
None => "set".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Empty data: absent, `fe fe` (no text, no asset) or only zero bytes.
|
||||
fn is_empty_data(d: Option<&[u8]>) -> bool {
|
||||
match d {
|
||||
None => true,
|
||||
Some(d) => d == [0xfe, 0xfe] || d.iter().all(|b| *b == 0),
|
||||
}
|
||||
}
|
||||
|
||||
fn le_u32(d: &[u8], at: usize) -> Option<u32> {
|
||||
Some(u32::from_le_bytes(*d.get(at..at.checked_add(4)?)?.first_chunk::<4>()?))
|
||||
}
|
||||
|
||||
fn le_f32(d: &[u8], at: usize) -> Option<f32> {
|
||||
Some(f32::from_le_bytes(*d.get(at..at.checked_add(4)?)?.first_chunk::<4>()?))
|
||||
}
|
||||
|
||||
fn le_f64(d: &[u8], at: usize) -> Option<f64> {
|
||||
Some(f64::from_le_bytes(*d.get(at..at.checked_add(8)?)?.first_chunk::<8>()?))
|
||||
}
|
||||
|
||||
/// A UTF-16LE text up to its NUL (the Look's name, `-0096`: `SL BLUE ICE` in 1200 `-01-set-all`);
|
||||
/// `None` for empty data.
|
||||
fn utf16_text(d: &[u8]) -> Option<String> {
|
||||
if is_empty_data(Some(d)) {
|
||||
return None;
|
||||
}
|
||||
let units: Vec<u16> = d.as_chunks::<2>().0.iter().map(|c| u16::from_le_bytes(*c)).take_while(|u| *u != 0).collect();
|
||||
let s = String::from_utf16_lossy(&units);
|
||||
let s = s.trim();
|
||||
(!s.is_empty()).then(|| s.to_string())
|
||||
}
|
||||
|
||||
/// A Lumetri curve (520 bytes): `i32` curve type at 0 (3 for the hue curves, which wrap; 2 for
|
||||
/// Luma vs Sat and Sat vs Sat; 0 for an RGB curve), the `u32` point count at 4, then the points
|
||||
/// as little-endian `f64` pairs x, y from byte 8 (at most 32), sorted by x. Bytes after the
|
||||
/// counted points are stale (a fresh hue curve has count 0 and old points behind it). An RGB
|
||||
/// curve's x and y are 0..1 (2 points 0,0 and 1,1 by default); a hue / saturation curve's x is
|
||||
/// the hue (0 red, 1/3 green) or the luma / saturation, 0..1, and y its offset from neutral.
|
||||
/// `None` when the count does not fit.
|
||||
fn curve_points(d: &[u8]) -> Option<Vec<(f64, f64)>> {
|
||||
let n = usize::try_from(le_u32(d, 4)?).ok()?;
|
||||
if n > d.len().saturating_sub(8) / 16 {
|
||||
return None;
|
||||
}
|
||||
(0..n).map(|i| Some((le_f64(d, 8 + 16 * i)?, le_f64(d, 16 + 16 * i)?))).collect()
|
||||
}
|
||||
|
||||
/// HSL Secondary's key (`-0073`, 40 bytes): a flag byte (1 once a key colour was picked, 0 for a
|
||||
/// fresh effect), then hue, saturation and lightness, each three little-endian `f32` (centre,
|
||||
/// range, tolerance; fractions of the full hue circle / of 0..1) and an enabled byte. A fresh effect
|
||||
/// stores flag 0 and centre 0.5, range 0, tolerance 0, enabled for each (1200 `-00-defaults`);
|
||||
/// After Effects' own copy in `-0001` names the same fields (`middleHLS`, `rangeHLS`,
|
||||
/// `toleranceHLS`, `enableHLS`, hue in degrees: centre 180). Picking Set color RGB 0.02 0.55 0.85
|
||||
/// gives hue centre 0.5605 (that colour's hue, 201.8 degrees) and lightness centre 0.435 (its
|
||||
/// HSL lightness) with range 0.05 and tolerance 0.1 (`-01-set-all`).
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
pub struct HslKey {
|
||||
pub picked: bool,
|
||||
/// Hue, saturation, lightness: centre, range, tolerance, enabled.
|
||||
pub channels: [(f32, f32, f32, bool); 3],
|
||||
}
|
||||
|
||||
impl HslKey {
|
||||
pub fn parse(d: &[u8]) -> Option<HslKey> {
|
||||
if d.len() != 40 {
|
||||
return None;
|
||||
}
|
||||
let picked = match d.first()? {
|
||||
0 => false,
|
||||
1 => true,
|
||||
_ => return None,
|
||||
};
|
||||
let ch = |at: usize| -> Option<(f32, f32, f32, bool)> {
|
||||
let (c, r, t) = (le_f32(d, at)?, le_f32(d, at + 4)?, le_f32(d, at + 8)?);
|
||||
let on = match d.get(at + 12)? {
|
||||
0 => false,
|
||||
1 => true,
|
||||
_ => return None,
|
||||
};
|
||||
(c.is_finite() && r.is_finite() && t.is_finite()).then_some((c, r, t, on))
|
||||
};
|
||||
Some(HslKey { picked, channels: [ch(1)?, ch(14)?, ch(27)?] })
|
||||
}
|
||||
|
||||
fn is_default(&self) -> bool {
|
||||
!self.picked && self.channels.iter().all(|c| *c == (0.5, 0.0, 0.0, true))
|
||||
}
|
||||
}
|
||||
|
||||
/// The colour wheel data: triples of `f64`, each wheel `0, 0.5, 0` by default.
|
||||
fn wheels_default(d: &[u8], wheels: usize) -> bool {
|
||||
d.len() == wheels * 24 && (0..wheels).all(|w| le_f64(d, w * 24) == Some(0.0) && le_f64(d, w * 24 + 8) == Some(0.5) && le_f64(d, w * 24 + 16) == Some(0.0))
|
||||
}
|
||||
|
||||
/// Is the data of `data` at After Effects' default (as far as it can be told)?
|
||||
fn data_default(data: Data, d: Option<&[u8]>) -> bool {
|
||||
let Some(d) = d else { return true };
|
||||
match data {
|
||||
Data::RgbCurves => {
|
||||
d.len() == 4 * 520 && d.as_chunks::<520>().0.iter().all(|c| curve_points(c).is_some_and(|pts| pts.iter().all(|(x, y)| (x - y).abs() < 1e-9)))
|
||||
}
|
||||
Data::HueSatCurve(_) => curve_points(d).is_some_and(|pts| pts.iter().all(|(_, y)| *y == 0.0)),
|
||||
Data::Wheels => wheels_default(d, 4),
|
||||
Data::CorrectionWheels => wheels_default(d, 8),
|
||||
Data::SplitToning | Data::Other => is_empty_data(Some(d)),
|
||||
Data::HslKey => HslKey::parse(d).is_some_and(|k| k.is_default()),
|
||||
}
|
||||
}
|
||||
|
||||
/// What the Input LUT or the Look holds: names, a popup item, an asset.
|
||||
#[derive(Default)]
|
||||
struct LutState {
|
||||
names: Vec<String>,
|
||||
item: Option<String>,
|
||||
asset: bool,
|
||||
}
|
||||
|
||||
/// How a set parameter of a section that does nothing is listed in the report; `None` when it is
|
||||
/// at its default.
|
||||
fn off_text(p: &EffectParam<'_, '_>, role: Role, data: Option<&[u8]>) -> Option<String> {
|
||||
match role {
|
||||
Role::Data(d) => (!data_default(d, data)).then(|| name_of(p)),
|
||||
// A LUT by its name (the popups index the same choice).
|
||||
Role::Lut { lut } if p.kind == K::ArbitraryData => {
|
||||
let what = if lut { "Input LUT" } else { "Look" };
|
||||
data.and_then(utf16_text).map(|n| format!("{what} '{n}'")).or_else(|| (!is_empty_data(data)).then(|| what.to_string()))
|
||||
}
|
||||
// The Look's colour space goes with the Look.
|
||||
Role::Lut { .. } | Role::LookSpace | Role::Derived => None,
|
||||
_ => is_set(p).then(|| format!("{} {}", name_of(p), shown_value(p.recorded_value()))),
|
||||
}
|
||||
}
|
||||
|
||||
/// Notes what an Input LUT or Look parameter holds: a popup item, an asset or a name.
|
||||
fn note_lut(p: &EffectParam<'_, '_>, suffix: u16, data: Option<&[u8]>, st: &mut LutState) {
|
||||
if p.kind == K::Popup {
|
||||
// The popup indexes the LUTs installed with After Effects; item 1 is None.
|
||||
if let Some(ParamValue::Index(i)) = p.recorded_value()
|
||||
&& i > 1
|
||||
{
|
||||
st.item = usize::try_from(i).ok().and_then(|i| p.items.as_ref()?.get(i - 1).cloned()).or(Some(format!("item {i}")));
|
||||
}
|
||||
} else if suffix == 125 || suffix == 126 {
|
||||
st.asset |= !is_empty_data(data);
|
||||
} else if let Some(name) = data.and_then(utf16_text)
|
||||
&& !st.names.contains(&name)
|
||||
{
|
||||
st.names.push(name);
|
||||
}
|
||||
}
|
||||
|
||||
/// Imports a Lumetri Color instance's parameters into `out.group` and reports what does not arrive.
|
||||
pub(crate) fn import_lumetri(fx: &AepEffect<'_, '_>, params: &[EffectParam<'_, '_>], cx: &LayerCtx<'_>, shown: &str, out: ParamOut<'_>) {
|
||||
let ParamOut { group, refs, col } = out;
|
||||
let here = format!("{}: '{}' effect '{shown}' ({})", cx.comp, cx.layer, fx.match_name());
|
||||
let find = |s: u16| params.iter().find(|p| suffix(&p.match_name) == Some(s));
|
||||
// The switches: on unless a record says off. Keys or an expression on a switch are not
|
||||
// imported (the static value counts).
|
||||
let switch_on = |s: u16| -> bool {
|
||||
match find(s) {
|
||||
Some(p) => !matches!(value_of(p), Some(ParamValue::Bool(false))),
|
||||
None => true,
|
||||
}
|
||||
};
|
||||
// HSL Secondary without a picked key colour selects nothing: After Effects applies none of
|
||||
// its correction (1200 `-16-sec-correction`, `-15-sec-refine` render as `-00-defaults`);
|
||||
// EffectCraft's default key is the whole image, so the values are left out too.
|
||||
// Unreadable or missing key data does not count as unpicked (it is reported or older).
|
||||
let key_picked = find(73).and_then(|p| fx.arbitrary_data(&p.match_name)).and_then(HslKey::parse).is_none_or(|k| k.picked);
|
||||
let section_on = |sec: Section| -> bool {
|
||||
match sec {
|
||||
Section::Always => true,
|
||||
Section::BasicCorrection => switch_on(3),
|
||||
Section::Creative => switch_on(23),
|
||||
Section::RgbCurves => switch_on(37) && switch_on(118),
|
||||
Section::HueSat(i) => switch_on(37) && switch_on([104, 105, 107, 109, 111].get(usize::from(i)).copied().unwrap_or(104)),
|
||||
Section::ColorWheels => switch_on(46),
|
||||
Section::HslSecondary => switch_on(68) && key_picked,
|
||||
Section::Vignette => switch_on(50),
|
||||
// Custom Layer off is itself reported (a switch without counterpart).
|
||||
Section::SpeedGradeCustom => true,
|
||||
}
|
||||
};
|
||||
let mut off: Vec<(Section, Vec<String>)> = Vec::new();
|
||||
let mut eyedroppers: Vec<String> = Vec::new();
|
||||
let mut luts = [LutState::default(), LutState::default()];
|
||||
for p in params {
|
||||
if carries_nothing(p) || p.match_name == "ADBE Effect Built In Params" {
|
||||
continue;
|
||||
}
|
||||
let label = if p.name.trim().is_empty() { format!("{} ({})", name_of(p), p.match_name) } else { param_label(p) };
|
||||
let Some(row) = suffix(&p.match_name).and_then(|s| ROWS.iter().find(|r| r.suffix == s)) else {
|
||||
// A parameter this table does not know (a newer version): lost when set.
|
||||
if p.stored().is_some()
|
||||
&& (p.kind != K::ArbitraryData && is_set(p) || p.kind == K::ArbitraryData && !is_empty_data(fx.arbitrary_data(&p.match_name)))
|
||||
{
|
||||
col.lose(LossCategory::Effects, format!("{here}: parameter {label} not imported"));
|
||||
}
|
||||
continue;
|
||||
};
|
||||
if row.kind.is_some_and(|k| k != p.kind) {
|
||||
if p.stored().is_some() {
|
||||
col.lose(LossCategory::Effects, format!("{here}: parameter {label} has type {:?}, expected {:?}; not imported", p.kind, row.kind));
|
||||
}
|
||||
continue;
|
||||
}
|
||||
let data = || fx.arbitrary_data(&p.match_name);
|
||||
// A switched-off section: its set values are listed, not imported.
|
||||
if !matches!(row.role, Role::Switch) && !section_on(row.section) {
|
||||
let text = off_text(p, row.role, data());
|
||||
if let Some(text) = text {
|
||||
match off.iter_mut().find(|(s, _)| *s == row.section) {
|
||||
Some((_, list)) => {
|
||||
if !list.contains(&text) {
|
||||
list.push(text);
|
||||
}
|
||||
}
|
||||
None => off.push((row.section, vec![text])),
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
match row.role {
|
||||
Role::To(target, conv) => {
|
||||
let m = ParamMap { index: p.index, kind: p.kind, target, conv, limit: None };
|
||||
import_mapped(p, &m, cx, (&here, &label), ParamOut { group: &mut *group, refs: &mut *refs, col: &mut *col });
|
||||
}
|
||||
Role::Switch => {
|
||||
if p.is_animated() || p.stored().is_some_and(|s| s.expression().is_some()) {
|
||||
col.lose(LossCategory::Effects, format!("{here}: keyframes or an expression on the switch {label} are not imported"));
|
||||
}
|
||||
if row.section == Section::SpeedGradeCustom && !switch_on(row.suffix) {
|
||||
col.lose(LossCategory::Effects, format!("{here}: parameter {label} is off and has no counterpart"));
|
||||
}
|
||||
}
|
||||
Role::MissingWhenOn => {
|
||||
if p.is_animated() || matches!(p.recorded_value(), Some(ParamValue::Bool(true))) {
|
||||
col.lose(LossCategory::Effects, format!("{here}: parameter {label} is on and has no counterpart, not imported"));
|
||||
}
|
||||
}
|
||||
Role::Missing => {
|
||||
if is_set(p) {
|
||||
col.lose(
|
||||
LossCategory::Effects,
|
||||
format!("{here}: parameter {label} is {} and has no counterpart, not imported", shown_value(p.recorded_value())),
|
||||
);
|
||||
}
|
||||
}
|
||||
Role::Eyedropper => {
|
||||
if is_set(p) {
|
||||
eyedroppers.push(name_of(p));
|
||||
}
|
||||
}
|
||||
Role::Lut { lut } => note_lut(p, row.suffix, data(), &mut luts[usize::from(!lut)]),
|
||||
// The Look's colour space only applies to a Look, which is reported.
|
||||
Role::LookSpace => {}
|
||||
Role::Data(_) if p.stored().is_some() && data().is_none() => {
|
||||
// The value record is there but its aRbs/aRbp payload is not: damaged, not the
|
||||
// default (a parameter without a record is the default).
|
||||
col.lose(LossCategory::DamagedData, format!("{here}: the data of {label} is missing, not imported (the default used)"));
|
||||
}
|
||||
Role::Data(Data::HslKey) => import_hsl_key(data(), &here, &mut *group, &mut *col),
|
||||
Role::Data(d) => {
|
||||
if p.is_animated() || p.stored().is_some_and(|s| s.expression().is_some()) {
|
||||
col.lose(LossCategory::Effects, format!("{here}: keyframes or an expression on {label} are not imported"));
|
||||
}
|
||||
if !data_default(d, data()) {
|
||||
let why = match d {
|
||||
Data::RgbCurves => {
|
||||
import_rgb_curves(data(), (&here, &label), &mut *group, &mut *col);
|
||||
continue;
|
||||
}
|
||||
Data::HueSatCurve(Some(target)) => {
|
||||
import_hue_sat_curve(data(), target, (&here, &label), &mut *group, &mut *col);
|
||||
continue;
|
||||
}
|
||||
Data::Wheels => {
|
||||
import_wheels(data(), (&here, &label), &mut *group, &mut *col);
|
||||
continue;
|
||||
}
|
||||
Data::HueSatCurve(None) => "the curve's points are moved; no sample tells their scale",
|
||||
Data::CorrectionWheels | Data::SplitToning => "the data is not decoded (not sampled)",
|
||||
Data::HslKey | Data::Other => "no counterpart",
|
||||
};
|
||||
col.lose(LossCategory::Effects, format!("{here}: parameter {label} is not imported ({why})"));
|
||||
}
|
||||
}
|
||||
Role::Derived => {}
|
||||
}
|
||||
}
|
||||
for (sec, list) in off {
|
||||
let why = if sec == Section::HslSecondary && switch_on(68) {
|
||||
"has no key colour picked (After Effects applies it to nothing)"
|
||||
} else {
|
||||
"is switched off (EffectCraft's Lumetri has no section switch)"
|
||||
};
|
||||
col.lose(LossCategory::Effects, format!("{here}: section {} {why}, its values are not imported: {}", sec.name(), list.join(", ")));
|
||||
}
|
||||
if !eyedroppers.is_empty() {
|
||||
col.lose(LossCategory::Effects, format!("{here}: the eyedropper colours {} are not imported (they only set other controls)", eyedroppers.join(", ")));
|
||||
}
|
||||
for (st, what) in luts.iter().zip(["Input LUT", "Look"]) {
|
||||
let name = st.names.first().cloned().or_else(|| st.item.clone());
|
||||
if name.is_some() || st.asset {
|
||||
let name = name.map(|n| format!(" '{n}'")).unwrap_or_default();
|
||||
col.lose(
|
||||
LossCategory::Effects,
|
||||
format!("{here}: the {what}{name} is not imported (the LUT file is not in the project; EffectCraft's {what} stays None)"),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// HSL Secondary's key as EffectCraft's hue centre / range / softness (degrees) and saturation and
|
||||
/// lightness bands (percent): range is taken as the half width, tolerance as the softness. A key
|
||||
/// that was never picked keeps EffectCraft's default (the whole image).
|
||||
fn import_hsl_key(d: Option<&[u8]>, here: &str, group: &mut effectcraft_project::PropGroup, col: &mut crate::import::report::Collector) {
|
||||
let Some(d) = d else { return };
|
||||
let Some(key) = HslKey::parse(d) else {
|
||||
col.lose(LossCategory::DamagedData, format!("{here}: the HSL Secondary key data is unreadable, the default key used"));
|
||||
return;
|
||||
};
|
||||
if !key.picked {
|
||||
if !key.is_default() {
|
||||
col.lose(LossCategory::Effects, format!("{here}: the HSL Secondary key holds values without a picked colour, not imported"));
|
||||
}
|
||||
return;
|
||||
}
|
||||
let mut set = |id: &str, v: Value| {
|
||||
if let Some(prop) = group.prop_mut(&format!("hslSecondary/key/{id}")) {
|
||||
prop.value = v;
|
||||
}
|
||||
};
|
||||
let [h, s, l] = key.channels.map(|(c, r, t, on)| (f64::from(c), f64::from(r), f64::from(t), on));
|
||||
set("hueEnabled", Value::Bool(h.3));
|
||||
set("hueCenter", Value::Scalar((h.0 * 360.0).rem_euclid(360.0)));
|
||||
set("hueRange", Value::Scalar((h.1 * 360.0).clamp(0.0, 180.0)));
|
||||
set("hueSoftness", Value::Scalar((h.2 * 360.0).clamp(0.0, 180.0)));
|
||||
for ((c, r, t, on), id) in [(s.0, s.1, s.2, s.3), (l.0, l.1, l.2, l.3)].into_iter().zip(["saturation", "lightness"]) {
|
||||
set(&format!("{id}Enabled"), Value::Bool(on));
|
||||
set(&format!("{id}Low"), Value::Scalar(((c - r) * 100.0).clamp(0.0, 100.0)));
|
||||
set(&format!("{id}High"), Value::Scalar(((c + r) * 100.0).clamp(0.0, 100.0)));
|
||||
set(&format!("{id}Softness"), Value::Scalar((t * 100.0).clamp(0.0, 100.0)));
|
||||
}
|
||||
}
|
||||
|
||||
/// A parameter of `group` set to `v` (ids the spec does not have are skipped).
|
||||
fn set_prop(group: &mut effectcraft_project::PropGroup, id: &str, v: Value) {
|
||||
if let Some(prop) = group.prop_mut(id) {
|
||||
prop.value = v;
|
||||
}
|
||||
}
|
||||
|
||||
/// Curve points as EffectCraft's `x,y x,y ...` text, x and y clamped to 0..1; `None` when a value
|
||||
/// is not finite.
|
||||
fn points_text(pts: impl IntoIterator<Item = (f64, f64)>) -> Option<String> {
|
||||
let mut out: Vec<String> = Vec::new();
|
||||
for (x, y) in pts {
|
||||
if !x.is_finite() || !y.is_finite() {
|
||||
return None;
|
||||
}
|
||||
out.push(format!("{},{}", x.clamp(0.0, 1.0), y.clamp(0.0, 1.0)));
|
||||
}
|
||||
Some(out.join(" "))
|
||||
}
|
||||
|
||||
/// The RGB curve (`-0039`, four curves of 520 bytes). The first is the master curve (1200
|
||||
/// `lumetri-60-preset-curves-rgb`: its midpoint raised to 0.787 lifts R, G and B alike) and
|
||||
/// becomes `curves/rgbCurves/master`. The other three are the channel curves, but no sample moves
|
||||
/// one, so which is red, green or blue is not known: a moved one is reported.
|
||||
fn import_rgb_curves(d: Option<&[u8]>, (here, label): (&str, &str), group: &mut effectcraft_project::PropGroup, col: &mut crate::import::report::Collector) {
|
||||
let Some(d) = d else { return };
|
||||
let curves: Option<Vec<Vec<(f64, f64)>>> = if d.len() == 4 * 520 { d.as_chunks::<520>().0.iter().map(|c| curve_points(c)).collect() } else { None };
|
||||
let Some(curves) = curves else {
|
||||
col.lose(LossCategory::DamagedData, format!("{here}: the data of {label} is unreadable, the curves are not imported"));
|
||||
return;
|
||||
};
|
||||
let identity = |pts: &[(f64, f64)]| pts.iter().all(|(x, y)| (x - y).abs() < 1e-9);
|
||||
if let Some(master) = curves.first().filter(|c| !identity(c)) {
|
||||
match points_text(master.iter().copied()) {
|
||||
Some(text) => set_prop(group, "curves/rgbCurves/master", Value::Str(text)),
|
||||
None => col.lose(LossCategory::DamagedData, format!("{here}: the master curve of {label} has unreadable points, not imported")),
|
||||
}
|
||||
}
|
||||
let moved = curves.iter().skip(1).filter(|c| !identity(c)).count();
|
||||
if moved > 0 {
|
||||
col.lose(
|
||||
LossCategory::Effects,
|
||||
format!("{here}: {moved} channel curve(s) of {label} are not imported (no sample tells which curve is red, green or blue)"),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Hue vs Sat (`-0042`): After Effects' y is an offset from neutral where 1 doubles the
|
||||
/// saturation (1200 `lumetri-61-preset-curves-huesat`: a point at hue 0.0155, y 0.97 scales pure
|
||||
/// red's saturation by about 1.96 in linear light; pins at -0.03 and -0.02 scale green, blue and
|
||||
/// cyan by 0.97). EffectCraft's y = 1 doubles it and 0.5 is neutral, so y becomes `0.5 + y / 2`.
|
||||
/// x is the hue in both (0 red, 1/3 green).
|
||||
fn import_hue_sat_curve(
|
||||
d: Option<&[u8]>,
|
||||
target: &str,
|
||||
(here, label): (&str, &str),
|
||||
group: &mut effectcraft_project::PropGroup,
|
||||
col: &mut crate::import::report::Collector,
|
||||
) {
|
||||
let Some(d) = d else { return };
|
||||
match curve_points(d).and_then(|pts| points_text(pts.into_iter().map(|(x, y)| (x, 0.5 + y / 2.0)))) {
|
||||
Some(text) => set_prop(group, target, Value::Str(text)),
|
||||
None => col.lose(LossCategory::DamagedData, format!("{here}: the data of {label} is unreadable, the curve is not imported")),
|
||||
}
|
||||
}
|
||||
|
||||
/// The colour EffectCraft's wheel takes for After Effects' hue (degrees, 0 red, 120 green) and
|
||||
/// distance from the centre (0..1): the HSL colour of that hue with saturation = distance and
|
||||
/// lightness 0.5 (grey 0.5 at distance 0). EffectCraft's wheel adds that colour's offset from its
|
||||
/// grey, so the direction is After Effects' and the strength grows with the distance.
|
||||
fn wheel_colour(hue: f64, distance: f64) -> [f64; 4] {
|
||||
let c = distance.clamp(0.0, 1.0);
|
||||
let h = hue.rem_euclid(360.0) / 60.0;
|
||||
let x = c * (1.0 - (h.rem_euclid(2.0) - 1.0).abs());
|
||||
let (r, g, b) = match h {
|
||||
h if h < 1.0 => (c, x, 0.0),
|
||||
h if h < 2.0 => (x, c, 0.0),
|
||||
h if h < 3.0 => (0.0, c, x),
|
||||
h if h < 4.0 => (0.0, x, c),
|
||||
h if h < 5.0 => (x, 0.0, c),
|
||||
_ => (c, 0.0, x),
|
||||
};
|
||||
let m = 0.5 - c / 2.0;
|
||||
[r + m, g + m, b + m, 1.0]
|
||||
}
|
||||
|
||||
/// Color Wheels (`-0047`, 96 bytes): four wheels of three little-endian `f64`, the hue in degrees,
|
||||
/// the brightness slider (0..1, 0.5 neutral) and the distance from the centre (0..1); `0, 0.5, 0`
|
||||
/// each by default. The first three are Shadows, Midtones and Highlights (1200
|
||||
/// `lumetri-62-preset-wheels`: Shadows 229 degrees (blue), slider 0.37, distance 0.55; Midtones
|
||||
/// 0.1 degrees (red), 0.5, 0.39; Highlights 93 degrees (yellow-green), 0.73, 0.32); the fourth is
|
||||
/// not known. Hue and distance become EffectCraft's wheel colour ([`wheel_colour`]); the brightness
|
||||
/// sliders and a set fourth wheel have no counterpart and are reported.
|
||||
fn import_wheels(d: Option<&[u8]>, (here, label): (&str, &str), group: &mut effectcraft_project::PropGroup, col: &mut crate::import::report::Collector) {
|
||||
let Some(d) = d else { return };
|
||||
let wheels: Option<Vec<[f64; 3]>> = if d.len() == 4 * 24 {
|
||||
(0..4).map(|w| Some([le_f64(d, w * 24)?, le_f64(d, w * 24 + 8)?, le_f64(d, w * 24 + 16)?]).filter(|v| v.iter().all(|x| x.is_finite()))).collect()
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let Some(wheels) = wheels else {
|
||||
col.lose(LossCategory::DamagedData, format!("{here}: the data of {label} is unreadable, the colour wheels are not imported"));
|
||||
return;
|
||||
};
|
||||
let mut lost: Vec<String> = Vec::new();
|
||||
for (w, (id, name)) in wheels.iter().zip([("shadowsWheel", "Shadows"), ("midtonesWheel", "Midtones"), ("highlightsWheel", "Highlights")]) {
|
||||
let [hue, brightness, distance] = *w;
|
||||
if distance != 0.0 {
|
||||
set_prop(group, &format!("colorWheels/{id}"), Value::Color(wheel_colour(hue, distance)));
|
||||
}
|
||||
if (brightness - 0.5).abs() > 1e-9 {
|
||||
lost.push(format!("{name} brightness {brightness:.3} (0.5 is neutral)"));
|
||||
}
|
||||
}
|
||||
if wheels.get(3).is_some_and(|w| *w != [0.0, 0.5, 0.0]) {
|
||||
lost.push("the fourth wheel (its role is not known)".to_string());
|
||||
}
|
||||
if !lost.is_empty() {
|
||||
col.lose(LossCategory::Effects, format!("{here}: {label}: {} not imported (no counterpart)", lost.join(", ")));
|
||||
}
|
||||
}
|
||||
@@ -364,6 +364,23 @@ fn what_cannot_be_written_is_reported() {
|
||||
parse(&r.bytes).expect("still a valid file");
|
||||
}
|
||||
|
||||
/// Lumetri Color is imported but not written (no prototype to write from): the export reports it
|
||||
/// as a loss and still produces a valid file.
|
||||
#[test]
|
||||
fn lumetri_color_is_reported_not_written() {
|
||||
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: "ec.color.lumetri".into() };
|
||||
}
|
||||
});
|
||||
let r = export_all(&p);
|
||||
assert_eq!(r.report.count(LossCategory::Effects), 1, "{:?}", r.report.losses);
|
||||
assert_eq!(r.report.exported.effects, 0);
|
||||
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]
|
||||
|
||||
@@ -17,6 +17,7 @@ mod effects_round2;
|
||||
mod effects_round3;
|
||||
mod effects_thirdparty;
|
||||
mod fx_inject;
|
||||
mod lumetri;
|
||||
mod markers;
|
||||
mod masks;
|
||||
mod mattes;
|
||||
|
||||
@@ -72,7 +72,7 @@ fn the_table_names_tint_gradient_ramp_and_exposure() {
|
||||
] {
|
||||
assert!(t.contains(&want), "{want:?}");
|
||||
}
|
||||
assert_eq!(t.len(), 66);
|
||||
assert_eq!(t.len(), 67);
|
||||
for (_, id) in t {
|
||||
assert!(effectcraft_effects::find(id).is_some(), "{id}");
|
||||
}
|
||||
|
||||
@@ -0,0 +1,573 @@
|
||||
//! Lumetri Color (`ADBE Lumetri`) arrives as EffectCraft's Lumetri Color (`ec.color.lumetri`),
|
||||
//! its parameters found by match name. The effect is built into the committed `synthetic.aep` in
|
||||
//! place of its Glow, in the layout of the 1200 samples (After Effects 2026, docs/aep-format.md
|
||||
//! 3.7): the definitions are a subset in an order of our own, so a mapping by `parT` index fails.
|
||||
|
||||
use effectcraft_aep::import::{ImportOptions, ImportResult, LossCategory, import};
|
||||
use effectcraft_aep::{Body, Chunk, parse, write};
|
||||
use effectcraft_keyframe::Value;
|
||||
use effectcraft_project::{Project, PropGroup};
|
||||
|
||||
const SYNTHETIC: &[u8] = include_bytes!("../../fixtures/synthetic.aep");
|
||||
|
||||
fn tdmn(name: &str) -> Chunk<'static> {
|
||||
let mut b = name.as_bytes().to_vec();
|
||||
b.resize(40, 0);
|
||||
Chunk::data(*b"tdmn", b)
|
||||
}
|
||||
|
||||
/// A chunk holding chunks (`pdnm`, `fnam`: `NESTED_IDS`) with one `Utf8`.
|
||||
fn nested(id: [u8; 4], text: &[u8]) -> Chunk<'static> {
|
||||
Chunk { id, body: Body::Chunks(vec![Chunk::data(*b"Utf8", text.to_vec())]), pad: None }
|
||||
}
|
||||
|
||||
fn mn(suffix: u16) -> String {
|
||||
format!("ADBE Lumetri-{suffix:04}")
|
||||
}
|
||||
|
||||
/// A parameter definition (`pard`, 148 bytes): the type at byte 15, the name at 16, the default at
|
||||
/// 56 (a float slider's `f64`, a checkbox's or popup's `u32`), a popup's item count at 60, a float
|
||||
/// slider's valid range at 104 / 108 and slider range at 112 / 116 (`f32`).
|
||||
#[derive(Clone)]
|
||||
enum Def {
|
||||
Float { name: &'static str, default: f64, min: f32, max: f32 },
|
||||
Check { default: bool },
|
||||
Popup { name: &'static str, items: &'static [&'static str] },
|
||||
Data { name: &'static str },
|
||||
Group { name: &'static str, end: bool },
|
||||
}
|
||||
|
||||
fn pard(d: &Def) -> Vec<Chunk<'static>> {
|
||||
let mut b = vec![0u8; 148];
|
||||
let name = |b: &mut Vec<u8>, n: &str| b[16..16 + n.len()].copy_from_slice(n.as_bytes());
|
||||
let mut extra = Vec::new();
|
||||
match d {
|
||||
Def::Float { name: n, default, min, max } => {
|
||||
b[15] = 10;
|
||||
name(&mut b, n);
|
||||
b[56..64].copy_from_slice(&default.to_be_bytes());
|
||||
b[104..108].copy_from_slice(&min.to_be_bytes());
|
||||
b[108..112].copy_from_slice(&max.to_be_bytes());
|
||||
b[112..116].copy_from_slice(&min.to_be_bytes());
|
||||
b[116..120].copy_from_slice(&max.to_be_bytes());
|
||||
}
|
||||
Def::Check { default } => {
|
||||
b[15] = 4;
|
||||
name(&mut b, " ");
|
||||
b[56..60].copy_from_slice(&u32::from(*default).to_be_bytes());
|
||||
}
|
||||
Def::Popup { name: n, items } => {
|
||||
b[15] = 7;
|
||||
name(&mut b, n);
|
||||
b[56..60].copy_from_slice(&1u32.to_be_bytes());
|
||||
b[60..62].copy_from_slice(&u16::try_from(items.len()).unwrap().to_be_bytes());
|
||||
extra.push(nested(*b"pdnm", items.join("|").as_bytes()));
|
||||
}
|
||||
Def::Data { name: n } => {
|
||||
b[15] = 11;
|
||||
name(&mut b, n);
|
||||
}
|
||||
Def::Group { name: n, end } => {
|
||||
b[15] = if *end { 14 } else { 13 };
|
||||
name(&mut b, n);
|
||||
}
|
||||
}
|
||||
let mut v = vec![Chunk::data(*b"pard", b)];
|
||||
v.extend(extra);
|
||||
v
|
||||
}
|
||||
|
||||
/// A stored value.
|
||||
#[derive(Clone)]
|
||||
enum Rec {
|
||||
/// A number in a record of state `tdsb` (1 written, 3 the default state).
|
||||
Num(f64, u32),
|
||||
/// Arbitrary data.
|
||||
Data(Vec<u8>),
|
||||
/// A value record whose arbitrary data (`aRbs`) is missing.
|
||||
NoData,
|
||||
}
|
||||
|
||||
/// A static value record after the one After Effects wrote for Glow's A & B Midpoint (its header,
|
||||
/// one component).
|
||||
fn record(template: &Chunk<'_>, tdsb: u32, v: f64) -> Chunk<'static> {
|
||||
let mut kids = Vec::new();
|
||||
for c in template.children() {
|
||||
match &c.body {
|
||||
Body::Data(_) if c.id == *b"tdsb" => kids.push(Chunk::data(*b"tdsb", tdsb.to_be_bytes().to_vec())),
|
||||
Body::Data(d) if c.id == *b"tdb4" => kids.push(Chunk::data(c.id, d.to_vec())),
|
||||
Body::Data(_) if c.id == *b"cdat" => {
|
||||
let mut d = vec![0u8; 40];
|
||||
d[..8].copy_from_slice(&v.to_be_bytes());
|
||||
kids.push(Chunk::data(*b"cdat", d));
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
Chunk::list(*b"tdbs", kids)
|
||||
}
|
||||
|
||||
/// The defaults of a fresh instance's data (1200 `lumetri-00-defaults`).
|
||||
fn empty() -> Vec<u8> {
|
||||
vec![0xfe, 0xfe]
|
||||
}
|
||||
|
||||
/// A Lumetri curve: type, point count, points (`f64` x, y), padded to 520 bytes.
|
||||
fn curve(kind: i32, pts: &[(f64, f64)]) -> Vec<u8> {
|
||||
let mut d = kind.to_le_bytes().to_vec();
|
||||
d.extend(u32::try_from(pts.len()).unwrap().to_le_bytes());
|
||||
for (x, y) in pts {
|
||||
d.extend(x.to_le_bytes());
|
||||
d.extend(y.to_le_bytes());
|
||||
}
|
||||
d.resize(520, 0);
|
||||
d
|
||||
}
|
||||
|
||||
fn rgb_curves() -> Vec<u8> {
|
||||
(0..4).flat_map(|_| curve(0, &[(0.0, 0.0), (1.0, 1.0)])).collect()
|
||||
}
|
||||
|
||||
fn wheels(n: usize) -> Vec<u8> {
|
||||
(0..n).flat_map(|_| [0.0f64, 0.5, 0.0].into_iter().flat_map(f64::to_le_bytes)).collect()
|
||||
}
|
||||
|
||||
fn utf16(s: &str) -> Vec<u8> {
|
||||
s.encode_utf16().chain([0]).flat_map(u16::to_le_bytes).collect()
|
||||
}
|
||||
|
||||
/// HSL Secondary's key data as 1200 `lumetri-01-set-all` stores it (Set color RGB 0.02 0.55 0.85).
|
||||
const HSL_PICKED: &str = "01327f0f3fcdcc4c3dcdcccc3d01d519683f2e57c93dcade173e01dfdede3ecdcc4c3dcdcccc3d01";
|
||||
/// A fresh instance's key data (`-00-defaults`).
|
||||
const HSL_FRESH: &str = "000000003f0000000000000000010000003f0000000000000000010000003f000000000000000001";
|
||||
|
||||
fn hex(s: &str) -> Vec<u8> {
|
||||
(0..s.len()).step_by(2).map(|i| u8::from_str_radix(&s[i..i + 2], 16).unwrap()).collect()
|
||||
}
|
||||
|
||||
const MASK_STYLE: &[&str] = &["Color/Gray", "Color/Black", "White/Black"];
|
||||
const LOOKS: &[&str] = &["None", "[Custom]", "Browse...", "(-", "SL BIG", "SL BLUE ICE"];
|
||||
|
||||
/// The definitions, an order of our own (not After Effects' `parT` order) and without the ones
|
||||
/// After Effects 2026 added late (-0127 ... -0130).
|
||||
fn defs() -> Vec<(u16, Def)> {
|
||||
let f = |name, default, min, max| Def::Float { name, default, min, max };
|
||||
vec![
|
||||
(1, Def::Data { name: "Blob" }),
|
||||
(62, Def::Check { default: false }),
|
||||
(2, Def::Group { name: "Basic Correction", end: false }),
|
||||
(3, Def::Check { default: true }),
|
||||
(95, Def::Data { name: " " }),
|
||||
(64, f("HDR Specular", 0.0, -150.0, 150.0)),
|
||||
(16, f("Blacks", 0.0, -150.0, 150.0)),
|
||||
(11, f("Exposure", 0.0, -7.0, 7.0)),
|
||||
(12, f("Contrast", 0.0, -150.0, 150.0)),
|
||||
(7, f("Temperature", 0.0, -300.0, 300.0)),
|
||||
(21, Def::Group { name: "", end: true }),
|
||||
(22, Def::Group { name: "Creative", end: false }),
|
||||
(23, Def::Check { default: true }),
|
||||
(96, Def::Data { name: " " }),
|
||||
(25, Def::Popup { name: "Look", items: LOOKS }),
|
||||
(26, f("Intensity", 100.0, 0.0, 200.0)),
|
||||
(32, Def::Data { name: "Split Toning" }),
|
||||
(35, Def::Group { name: "", end: true }),
|
||||
(39, Def::Data { name: "RGB Curve" }),
|
||||
(104, Def::Check { default: true }),
|
||||
(42, Def::Data { name: "Hue vs Sat" }),
|
||||
(106, Def::Data { name: "Hue vs Hue" }),
|
||||
(47, Def::Data { name: "Wheels" }),
|
||||
(68, Def::Check { default: true }),
|
||||
(73, Def::Data { name: "HSL Sliders" }),
|
||||
(74, Def::Check { default: false }),
|
||||
(75, Def::Popup { name: " ", items: MASK_STYLE }),
|
||||
(85, Def::Data { name: "Color Wheels" }),
|
||||
(86, f("Temperature", 0.0, -300.0, 300.0)),
|
||||
(50, Def::Check { default: true }),
|
||||
(51, f("Amount", 0.0, -5.0, 5.0)),
|
||||
(54, f("Feather", 50.0, 0.0, 100.0)),
|
||||
(97, Def::Check { default: false }),
|
||||
]
|
||||
}
|
||||
|
||||
/// The data every instance stores, at the defaults.
|
||||
fn default_data() -> Vec<(u16, Rec)> {
|
||||
vec![
|
||||
(1, Rec::Data(empty())),
|
||||
(95, Rec::Data(empty())),
|
||||
(96, Rec::Data(empty())),
|
||||
(32, Rec::Data(vec![0; 32])),
|
||||
(39, Rec::Data(rgb_curves())),
|
||||
(42, Rec::Data(curve(3, &[]))),
|
||||
(106, Rec::Data(curve(3, &[]))),
|
||||
(47, Rec::Data(wheels(4))),
|
||||
(73, Rec::Data(hex(HSL_FRESH))),
|
||||
(85, Rec::Data(wheels(8))),
|
||||
]
|
||||
}
|
||||
|
||||
/// The Lumetri instance: the definitions, then the stored values `recs` (data replaces the
|
||||
/// default data of the same parameter).
|
||||
fn sspc(defs: &[(u16, Def)], recs: &[(u16, Rec)], template: &Chunk<'_>) -> Chunk<'static> {
|
||||
let mut part = vec![Chunk::data(*b"parn", u32::try_from(defs.len() + 2).unwrap().to_be_bytes().to_vec()), tdmn(&mn(0))];
|
||||
let mut layer = vec![0u8; 148];
|
||||
layer[15] = 0;
|
||||
part.push(Chunk::data(*b"pard", layer));
|
||||
for (s, d) in defs {
|
||||
part.push(tdmn(&mn(*s)));
|
||||
part.extend(pard(d));
|
||||
}
|
||||
part.push(tdmn("ADBE Effect Built In Params"));
|
||||
let mut end = vec![0u8; 148];
|
||||
end[15] = 9;
|
||||
part.push(Chunk::data(*b"pard", end));
|
||||
let mut all: Vec<(u16, Rec)> = default_data().into_iter().filter(|(s, _)| !recs.iter().any(|(r, _)| r == s)).collect();
|
||||
all.extend(recs.iter().cloned());
|
||||
let mut values = vec![Chunk::data(*b"tdsb", 1u32.to_be_bytes().to_vec())];
|
||||
for (s, r) in all {
|
||||
values.push(tdmn(&mn(s)));
|
||||
match r {
|
||||
Rec::Num(v, tdsb) => values.push(record(template, tdsb, v)),
|
||||
Rec::Data(d) => {
|
||||
values.push(record(template, 1, 0.0));
|
||||
values.push(Chunk::list(*b"aRbs", vec![Chunk::data(*b"aRbp", d)]));
|
||||
}
|
||||
Rec::NoData => values.push(record(template, 1, 0.0)),
|
||||
}
|
||||
}
|
||||
Chunk::list(*b"sspc", vec![nested(*b"fnam", b"Lumetri Color"), Chunk::list(*b"parT", part), Chunk::list(*b"tdgp", values)])
|
||||
}
|
||||
|
||||
fn find_glow_template<'a>(chunks: &'a [Chunk<'a>]) -> Option<Chunk<'a>> {
|
||||
for c in chunks {
|
||||
let kids = c.children();
|
||||
if let Some(at) = kids.iter().position(|k| k.is(b"tdmn") && k.bytes().is_some_and(|b| b.starts_with(b"ADBE Glo2-0011\0")))
|
||||
&& let Some(record) = kids.get(at + 1).filter(|k| k.is_list(b"tdbs"))
|
||||
{
|
||||
return Some(record.clone());
|
||||
}
|
||||
if let Some(t) = find_glow_template(kids) {
|
||||
return Some(t);
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Puts the instance in place of the Glow effect (in the effect parade and the project's shared
|
||||
/// definitions); the number of places.
|
||||
fn replace_glow(chunks: &mut [Chunk<'_>], instance: &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("ADBE Lumetri");
|
||||
kids[at + 1] = instance.clone();
|
||||
n += 1;
|
||||
}
|
||||
n += replace_glow(kids, instance);
|
||||
}
|
||||
n
|
||||
}
|
||||
|
||||
fn run_with(defs: &[(u16, Def)], recs: &[(u16, Rec)]) -> (PropGroup, ImportResult) {
|
||||
let file = parse(SYNTHETIC).unwrap();
|
||||
let template = find_glow_template(&file.chunks).expect("Glow's A & B Midpoint record");
|
||||
let instance = sspc(defs, recs, &template);
|
||||
let mut file = parse(SYNTHETIC).unwrap();
|
||||
assert_eq!(replace_glow(&mut file.chunks, &instance), 2, "the Glow effect and its shared definitions");
|
||||
let bytes = write(&file).unwrap();
|
||||
let mut project = Project::default();
|
||||
let none = |_: &str| false;
|
||||
let r = import(&mut project, &bytes, &ImportOptions { allow_losses: true, ..ImportOptions::new(&none) }).unwrap();
|
||||
let g = project
|
||||
.comps()
|
||||
.flat_map(|(_, c)| c.layers.iter())
|
||||
.find_map(|l| l.props.sub("effects").and_then(|fx| fx.children.iter().filter_map(|n| n.as_group()).find(|g| g.match_id == "ec.color.lumetri").cloned()))
|
||||
.expect("an EffectCraft Lumetri Color");
|
||||
(g, r)
|
||||
}
|
||||
|
||||
fn run(recs: &[(u16, Rec)]) -> (PropGroup, ImportResult) {
|
||||
run_with(&defs(), recs)
|
||||
}
|
||||
|
||||
fn losses(r: &ImportResult) -> Vec<String> {
|
||||
r.report
|
||||
.losses
|
||||
.iter()
|
||||
.filter(|l| matches!(l.category, LossCategory::Effects | LossCategory::DamagedData))
|
||||
.flat_map(|l| l.details.clone())
|
||||
.filter(|d| d.contains("Lumetri"))
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn num(g: &PropGroup, id: &str) -> f64 {
|
||||
g.prop(id).unwrap_or_else(|| panic!("no {id}")).value.as_f64()
|
||||
}
|
||||
|
||||
fn val(g: &PropGroup, id: &str) -> Value {
|
||||
g.prop(id).unwrap_or_else(|| panic!("no {id}")).value.clone()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_fresh_lumetri_arrives_without_losses() {
|
||||
let (g, r) = run(&[]);
|
||||
assert_eq!(losses(&r), Vec::<String>::new());
|
||||
assert_eq!((num(&g, "basicCorrection/tone/exposure"), num(&g, "creative/lookIntensity"), num(&g, "vignette/vignetteFeather")), (0.0, 100.0, 50.0));
|
||||
assert_eq!(val(&g, "creative/look"), Value::Enum(0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lumetri_values_arrive_by_match_name_also_from_default_state_records() {
|
||||
// 1200 `lumetri-01-set-all` / `-06-sec-light`: High Dynamic Range and HDR Specular are
|
||||
// recorded in the default state (`tdsb` 3) and read back as set.
|
||||
let (g, r) = run(&[
|
||||
(62, Rec::Num(1.0, 3)),
|
||||
(64, Rec::Num(22.0, 3)),
|
||||
(11, Rec::Num(2.589_999_914_169_311_5, 1)),
|
||||
(12, Rec::Num(37.0, 1)),
|
||||
(16, Rec::Num(-150.0, 1)),
|
||||
(7, Rec::Num(300.0, 1)),
|
||||
(26, Rec::Num(134.0, 1)),
|
||||
(51, Rec::Num(1.85, 1)),
|
||||
(74, Rec::Num(1.0, 1)),
|
||||
(75, Rec::Num(3.0, 1)),
|
||||
(73, Rec::Data(hex(HSL_PICKED))),
|
||||
]);
|
||||
assert_eq!(losses(&r), Vec::<String>::new());
|
||||
assert_eq!(val(&g, "highDynamicRange"), Value::Bool(true));
|
||||
for (id, want) in [
|
||||
("basicCorrection/tone/hdrSpecular", 22.0),
|
||||
("basicCorrection/tone/exposure", 2.589_999_914_169_311_5),
|
||||
("basicCorrection/tone/contrast", 37.0),
|
||||
("basicCorrection/tone/blacks", -150.0),
|
||||
// Past EffectCraft's old slider range: kept (the range is After Effects' now).
|
||||
("basicCorrection/whiteBalance/temperature", 300.0),
|
||||
("creative/lookIntensity", 134.0),
|
||||
("vignette/vignetteAmount", 1.85),
|
||||
] {
|
||||
assert_eq!(num(&g, id), want, "{id}");
|
||||
}
|
||||
assert_eq!((val(&g, "hslSecondary/key/showMask"), val(&g, "hslSecondary/key/maskStyle")), (Value::Bool(true), Value::Enum(2)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_switched_off_section_is_reported_and_not_applied() {
|
||||
// 1200 `-03-sec-basic-correction`, `-17-sec-vignette`: a section that is off does nothing.
|
||||
let (g, r) = run(&[(3, Rec::Num(0.0, 1)), (11, Rec::Num(2.0, 1)), (50, Rec::Num(0.0, 1)), (51, Rec::Num(-2.0, 1)), (12, Rec::Num(37.0, 1))]);
|
||||
assert_eq!((num(&g, "basicCorrection/tone/exposure"), num(&g, "basicCorrection/tone/contrast"), num(&g, "vignette/vignetteAmount")), (0.0, 0.0, 0.0));
|
||||
let l = losses(&r);
|
||||
assert_eq!(l.len(), 2, "{l:?}");
|
||||
assert!(l[0].contains("section Basic Correction is switched off") && l[0].contains("Exposure 2, Contrast 37"), "{l:?}");
|
||||
assert!(l[1].contains("section Vignette is switched off") && l[1].contains("Amount -2"), "{l:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_look_is_reported_by_name() {
|
||||
// 1200 `-01-set-all`: the Look's name in -0096, the popup's record in the default state.
|
||||
let (g, r) = run(&[(96, Rec::Data(utf16("SL BLUE ICE"))), (25, Rec::Num(6.0, 3))]);
|
||||
assert_eq!(val(&g, "creative/look"), Value::Enum(0));
|
||||
let l = losses(&r);
|
||||
assert_eq!(l.len(), 1, "{l:?}");
|
||||
assert!(l[0].contains("the Look 'SL BLUE ICE' is not imported"), "{l:?}");
|
||||
// Only the popup: its item.
|
||||
let (_, r) = run(&[(25, Rec::Num(5.0, 1))]);
|
||||
assert!(losses(&r).iter().any(|d| d.contains("the Look 'SL BIG' is not imported")), "{:?}", losses(&r));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hue_vs_sat_arrives_with_its_y_scaled_other_moved_curves_are_reported() {
|
||||
// 1200 `-11-sec-hue-saturation-curves`: the selector put three neutral points on the curve.
|
||||
let neutral = curve(3, &[(0.742_483, 0.0), (0.798_038, 0.0), (0.853_594, 0.0)]);
|
||||
let (g, r) = run(&[(42, Rec::Data(neutral))]);
|
||||
assert_eq!(losses(&r), Vec::<String>::new());
|
||||
assert_eq!(val(&g, "curves/hueSaturationCurves/hueVsSat"), Value::Str(String::new()));
|
||||
// 1200 `-61-preset-curves-huesat`: red raised to 0.97 (After Effects nearly doubles its
|
||||
// saturation), the neighbours pinned near 0. EffectCraft's y: 0.5 + y / 2.
|
||||
let preset = curve(3, &[(0.015_463_917_525_773_196, 0.97), (0.262_886_597_938_144_34, -0.03), (0.764_604_810_996_563_5, -0.02)]);
|
||||
let (g, r) = run(&[(42, Rec::Data(preset))]);
|
||||
assert_eq!(losses(&r), Vec::<String>::new());
|
||||
let Value::Str(text) = val(&g, "curves/hueSaturationCurves/hueVsSat") else { panic!("a point list") };
|
||||
let pts: Vec<(f64, f64)> = text
|
||||
.split(' ')
|
||||
.map(|p| {
|
||||
let (x, y) = p.split_once(',').unwrap();
|
||||
(x.parse().unwrap(), y.parse().unwrap())
|
||||
})
|
||||
.collect();
|
||||
let want = [(0.015_464, 0.985), (0.262_887, 0.485), (0.764_605, 0.49)];
|
||||
assert_eq!(pts.len(), 3, "{text}");
|
||||
for ((x, y), (wx, wy)) in pts.iter().zip(want) {
|
||||
assert!((x - wx).abs() < 1e-5 && (y - wy).abs() < 1e-9, "{text}");
|
||||
}
|
||||
// Hue vs Hue moved: no sample tells its scale, reported and left neutral.
|
||||
let (g, r) = run(&[(106, Rec::Data(curve(3, &[(0.5, 0.25)])))]);
|
||||
let l = losses(&r);
|
||||
assert!(l.len() == 1 && l[0].contains("Hue vs Hue (ADBE Lumetri-0106) is not imported"), "{l:?}");
|
||||
assert_eq!(val(&g, "curves/hueSaturationCurves/hueVsHue"), Value::Str(String::new()));
|
||||
// A point count past the data: not a default, reported as damaged, no panic.
|
||||
let mut bad = curve(3, &[]);
|
||||
bad[4..8].copy_from_slice(&1000u32.to_le_bytes());
|
||||
let (g, r) = run(&[(42, Rec::Data(bad))]);
|
||||
let l = losses(&r);
|
||||
assert!(l.len() == 1 && l[0].contains("Hue vs Sat (ADBE Lumetri-0042) is unreadable"), "{l:?}");
|
||||
assert_eq!(val(&g, "curves/hueSaturationCurves/hueVsSat"), Value::Str(String::new()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_master_rgb_curve_arrives_moved_channel_curves_are_reported() {
|
||||
// 1200 `-60-preset-curves-rgb`: the first of the four curves, its midpoint raised.
|
||||
let master = curve(0, &[(0.0, 0.0), (0.491_408_934_707_903_76, 0.786_885_245_901_639_3), (1.0, 1.0)]);
|
||||
let identity = curve(0, &[(0.0, 0.0), (1.0, 1.0)]);
|
||||
let mut d = master.clone();
|
||||
(0..3).for_each(|_| d.extend(&identity));
|
||||
let (g, r) = run(&[(39, Rec::Data(d))]);
|
||||
assert_eq!(losses(&r), Vec::<String>::new());
|
||||
assert_eq!(val(&g, "curves/rgbCurves/master"), Value::Str("0,0 0.49140893470790376,0.7868852459016393 1,1".into()));
|
||||
assert_eq!(val(&g, "curves/rgbCurves/red"), Value::Str("0,0 1,1".into()));
|
||||
// A moved channel curve: which channel it is is not known, reported.
|
||||
let mut d = identity.clone();
|
||||
d.extend(&master);
|
||||
(0..2).for_each(|_| d.extend(&identity));
|
||||
let (g, r) = run(&[(39, Rec::Data(d))]);
|
||||
let l = losses(&r);
|
||||
assert!(l.len() == 1 && l[0].contains("1 channel curve(s) of RGB Curve (ADBE Lumetri-0039) are not imported"), "{l:?}");
|
||||
assert_eq!(val(&g, "curves/rgbCurves/master"), Value::Str("0,0 1,1".into()));
|
||||
// Damaged: the wrong length.
|
||||
let (_, r) = run(&[(39, Rec::Data(master))]);
|
||||
assert!(losses(&r).iter().any(|x| x.contains("RGB Curve (ADBE Lumetri-0039) is unreadable")), "{:?}", losses(&r));
|
||||
}
|
||||
|
||||
/// A value record without its arbitrary data is damaged, not the default: reported for the RGB
|
||||
/// curve and the HSL key, the neighbours stay as they were.
|
||||
#[test]
|
||||
fn a_value_record_without_its_data_is_reported_as_damaged() {
|
||||
for (suffix, name) in [(39, "RGB Curve (ADBE Lumetri-0039)"), (73, "HSL Sliders (ADBE Lumetri-0073)")] {
|
||||
let (g, r) = run(&[(suffix, Rec::NoData)]);
|
||||
let l = losses(&r);
|
||||
assert!(l.len() == 1 && l[0].contains(name) && l[0].contains("is missing"), "{suffix}: {l:?}");
|
||||
assert!(r.report.losses.iter().any(|x| x.category == LossCategory::DamagedData), "{suffix}");
|
||||
// The grade stays at the default.
|
||||
assert_eq!(val(&g, "curves/rgbCurves/master"), Value::Str("0,0 1,1".into()));
|
||||
assert_eq!(num(&g, "hslSecondary/key/hueRange"), 180.0);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn colour_wheels_arrive_as_colours_their_brightness_is_reported() {
|
||||
// 1200 `-62-preset-wheels`: Shadows towards blue with the slider down, Midtones towards red,
|
||||
// Highlights towards yellow-green with the slider up; the fourth wheel at its default.
|
||||
let w: Vec<u8> = [
|
||||
229.119_895_976_548_18,
|
||||
0.369_892_467_494_294_2,
|
||||
0.547_209_348_004_638,
|
||||
0.104_745_366_878_020_72,
|
||||
0.5,
|
||||
0.394_961_840_132_529_44,
|
||||
93.374_076_744_668_57,
|
||||
0.726_881_718_523_399_9,
|
||||
0.318_976_840_675_999_35,
|
||||
0.0,
|
||||
0.5,
|
||||
0.0,
|
||||
]
|
||||
.into_iter()
|
||||
.flat_map(f64::to_le_bytes)
|
||||
.collect();
|
||||
let (g, r) = run(&[(47, Rec::Data(w.clone()))]);
|
||||
let colour = |id: &str| match val(&g, id) {
|
||||
Value::Color(c) => c,
|
||||
other => panic!("{id}: {other:?}"),
|
||||
};
|
||||
// The hue's direction: blue strongest in Shadows, red in Midtones, green in Highlights.
|
||||
let s = colour("colorWheels/shadowsWheel");
|
||||
assert!(s[2] > 0.77 && s[0] < 0.23 && (s[0] + s[2] - 1.0).abs() < 1e-9, "{s:?}");
|
||||
let m = colour("colorWheels/midtonesWheel");
|
||||
assert!((m[0] - 0.697_480_920_066_264_7).abs() < 1e-9 && m[1] > m[2], "{m:?}");
|
||||
let h = colour("colorWheels/highlightsWheel");
|
||||
assert!(h[1] > h[0] && h[0] > h[2], "{h:?}");
|
||||
let l = losses(&r);
|
||||
assert!(l.len() == 1 && l[0].contains("Shadows brightness 0.370") && l[0].contains("Highlights brightness 0.727"), "{l:?}");
|
||||
assert!(!l[0].contains("Midtones") && !l[0].contains("fourth"), "{l:?}");
|
||||
// The fourth wheel set: reported; damaged data: reported, the wheels stay grey.
|
||||
let mut w4 = w;
|
||||
w4[88..96].copy_from_slice(&0.3f64.to_le_bytes());
|
||||
let (_, r) = run(&[(47, Rec::Data(w4))]);
|
||||
assert!(losses(&r).iter().any(|x| x.contains("the fourth wheel")), "{:?}", losses(&r));
|
||||
let (g, r) = run(&[(47, Rec::Data(vec![1; 95]))]);
|
||||
assert!(losses(&r).iter().any(|x| x.contains("Wheels (ADBE Lumetri-0047) is unreadable")), "{:?}", losses(&r));
|
||||
assert_eq!(val(&g, "colorWheels/shadowsWheel"), Value::Color([0.5, 0.5, 0.5, 1.0]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_picked_hsl_key_becomes_hue_saturation_and_lightness_ranges() {
|
||||
let (g, r) = run(&[(73, Rec::Data(hex(HSL_PICKED)))]);
|
||||
assert_eq!(losses(&r), Vec::<String>::new());
|
||||
let close = |id: &str, want: f64| assert!((num(&g, id) - want).abs() < 0.01, "{id}: {} != {want}", num(&g, id));
|
||||
// Hue centre 0.5605 of the circle, range 0.05, tolerance 0.1.
|
||||
close("hslSecondary/key/hueCenter", 201.79);
|
||||
close("hslSecondary/key/hueRange", 18.0);
|
||||
close("hslSecondary/key/hueSoftness", 36.0);
|
||||
// Lightness centre 0.435 +- 0.05, tolerance 0.1, as percent.
|
||||
close("hslSecondary/key/lightnessLow", 38.53);
|
||||
close("hslSecondary/key/lightnessHigh", 48.53);
|
||||
close("hslSecondary/key/lightnessSoftness", 10.0);
|
||||
assert_eq!(val(&g, "hslSecondary/key/hueEnabled"), Value::Bool(true));
|
||||
// With the key picked the correction applies; without, After Effects applies it to nothing
|
||||
// (1200 `-16-sec-correction` renders as `-00-defaults`): left out and reported.
|
||||
let (g, _) = run(&[(73, Rec::Data(hex(HSL_PICKED))), (86, Rec::Num(19.0, 1))]);
|
||||
assert_eq!(num(&g, "hslSecondary/correction/temperature"), 19.0);
|
||||
let (g, r) = run(&[(86, Rec::Num(19.0, 1))]);
|
||||
assert_eq!(num(&g, "hslSecondary/correction/temperature"), 0.0);
|
||||
let l = losses(&r);
|
||||
assert!(l.len() == 1 && l[0].contains("HSL Secondary has no key colour picked") && l[0].contains("Temperature 19"), "{l:?}");
|
||||
// Damaged key data: reported, the default key kept.
|
||||
let (g, r) = run(&[(73, Rec::Data(vec![7; 40]))]);
|
||||
assert!(losses(&r).iter().any(|d| d.contains("HSL Secondary key data is unreadable")), "{:?}", losses(&r));
|
||||
assert_eq!(num(&g, "hslSecondary/key/hueRange"), 180.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn undecoded_data_and_controls_without_counterpart_are_reported_when_set() {
|
||||
let mut split = vec![0u8; 32];
|
||||
split[0] = 1;
|
||||
let (_, r) = run(&[(32, Rec::Data(split)), (97, Rec::Num(1.0, 3)), (95, Rec::Data(utf16("my.cube")))]);
|
||||
let l = losses(&r);
|
||||
assert_eq!(l.len(), 3, "{l:?}");
|
||||
assert!(l.iter().any(|d| d.contains("Split Toning (ADBE Lumetri-0032) is not imported")), "{l:?}");
|
||||
assert!(l.iter().any(|d| d.contains("Legacy Tone And Saturation (ADBE Lumetri-0097) is on")), "{l:?}");
|
||||
assert!(l.iter().any(|d| d.contains("the Input LUT 'my.cube' is not imported")), "{l:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn legacy_tone_on_is_reported_whatever_the_pard_default_says() {
|
||||
// After the project is opened and saved again, the `pard` default of Legacy Tone And
|
||||
// Saturation reads on (versions/1200-lumetri-26 / -25x / -24x; off in 1200 as the script
|
||||
// saved it): a record that says on is still reported, a missing record is not.
|
||||
let mut d = defs();
|
||||
if let Some((_, Def::Check { default })) = d.iter_mut().find(|(s, _)| *s == 97) {
|
||||
*default = true;
|
||||
}
|
||||
let (_, r) = run_with(&d, &[(97, Rec::Num(1.0, 3))]);
|
||||
let l = losses(&r);
|
||||
assert!(l.len() == 1 && l[0].contains("Legacy Tone And Saturation (ADBE Lumetri-0097) is on"), "{l:?}");
|
||||
let (_, r) = run_with(&d, &[]);
|
||||
assert_eq!(losses(&r), Vec::<String>::new());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_unknown_scale_or_parameter_is_reported_not_guessed() {
|
||||
let mut d = defs();
|
||||
// Exposure with another valid range (an older or newer version): not guessed at.
|
||||
if let Some((_, Def::Float { min, max, .. })) = d.iter_mut().find(|(s, _)| *s == 11) {
|
||||
(*min, *max) = (-100.0, 100.0);
|
||||
}
|
||||
// A parameter this importer does not know, set.
|
||||
d.push((200, Def::Float { name: "Future", default: 0.0, min: -1.0, max: 1.0 }));
|
||||
let (g, r) = run_with(&d, &[(11, Rec::Num(3.0, 1)), (200, Rec::Num(0.5, 1))]);
|
||||
assert_eq!(num(&g, "basicCorrection/tone/exposure"), 0.0);
|
||||
let l = losses(&r);
|
||||
assert!(l.iter().any(|x| x.contains("Exposure (ADBE Lumetri-0011) has the valid maximum Some(100.0), expected 7")), "{l:?}");
|
||||
assert!(l.iter().any(|x| x.contains("Future (ADBE Lumetri-0200) not imported")), "{l:?}");
|
||||
}
|
||||
@@ -705,32 +705,37 @@ fn lumetri(ctx: &EffectCtx, mut b: Buf) -> Buf {
|
||||
|
||||
pub fn specs() -> Vec<EffectSpec> {
|
||||
let grey = || col(0.5, 0.5, 0.5);
|
||||
// After Effects' valid ranges (a value past the slider's drag range is still kept: .aep
|
||||
// import, 1200), the slider over the range After Effects' slider covers.
|
||||
let wb = || slider(-300.0, 300.0, -100.0, 100.0, 1);
|
||||
let tone = || slider(-150.0, 150.0, -100.0, 100.0, 1);
|
||||
let sat = || slider(0.0, 300.0, 0.0, 200.0, 1);
|
||||
let mut lum = vec![
|
||||
p("highDynamicRange", "High Dynamic Range", Value::Bool(false), ParamUi::Checkbox),
|
||||
p("basicCorrection/inputLut", "Input LUT", Value::Enum(0), popup(&["None", "Custom"])),
|
||||
// A .cube file path (or the file's text) for Input LUT = Custom.
|
||||
p("basicCorrection/inputLutFile", "Input LUT File", Value::Str(String::new()), ParamUi::Text),
|
||||
p("basicCorrection/whiteBalance/temperature", "Temperature", num(0.0), bipolar()),
|
||||
p("basicCorrection/whiteBalance/tint", "Tint", num(0.0), bipolar()),
|
||||
p("basicCorrection/tone/exposure", "Exposure", num(0.0), slider(-5.0, 5.0, -5.0, 5.0, 2)),
|
||||
p("basicCorrection/tone/contrast", "Contrast", num(0.0), bipolar()),
|
||||
p("basicCorrection/tone/highlights", "Highlights", num(0.0), bipolar()),
|
||||
p("basicCorrection/tone/shadows", "Shadows", num(0.0), bipolar()),
|
||||
p("basicCorrection/tone/whites", "Whites", num(0.0), bipolar()),
|
||||
p("basicCorrection/tone/blacks", "Blacks", num(0.0), bipolar()),
|
||||
p("basicCorrection/whiteBalance/temperature", "Temperature", num(0.0), wb()),
|
||||
p("basicCorrection/whiteBalance/tint", "Tint", num(0.0), wb()),
|
||||
p("basicCorrection/tone/exposure", "Exposure", num(0.0), slider(-7.0, 7.0, -5.0, 5.0, 2)),
|
||||
p("basicCorrection/tone/contrast", "Contrast", num(0.0), tone()),
|
||||
p("basicCorrection/tone/highlights", "Highlights", num(0.0), tone()),
|
||||
p("basicCorrection/tone/shadows", "Shadows", num(0.0), tone()),
|
||||
p("basicCorrection/tone/whites", "Whites", num(0.0), tone()),
|
||||
p("basicCorrection/tone/blacks", "Blacks", num(0.0), tone()),
|
||||
p("basicCorrection/tone/hdrWhite", "HDR White", num(100.0), slider(100.0, 10000.0, 100.0, 4000.0, 0)),
|
||||
p("basicCorrection/tone/hdrSpecular", "HDR Specular", num(0.0), bipolar()),
|
||||
p("basicCorrection/saturation", "Saturation", num(100.0), slider(0.0, 200.0, 0.0, 200.0, 1)),
|
||||
p("basicCorrection/tone/hdrSpecular", "HDR Specular", num(0.0), tone()),
|
||||
p("basicCorrection/saturation", "Saturation", num(100.0), sat()),
|
||||
p("creative/look", "Look", Value::Enum(0), popup(&LUMETRI_LOOKS)),
|
||||
p("creative/lookFile", "Look File", Value::Str(String::new()), ParamUi::Text),
|
||||
p("creative/lookIntensity", "Intensity", num(100.0), slider(0.0, 200.0, 0.0, 200.0, 1)),
|
||||
p("creative/adjustments/fadedFilm", "Faded Film", num(0.0), pct()),
|
||||
p("creative/adjustments/fadedFilm", "Faded Film", num(0.0), slider(0.0, 150.0, 0.0, 100.0, 1)),
|
||||
p("creative/adjustments/sharpen", "Sharpen", num(0.0), bipolar()),
|
||||
p("creative/adjustments/vibrance", "Vibrance", num(0.0), bipolar()),
|
||||
p("creative/adjustments/creativeSaturation", "Saturation", num(100.0), slider(0.0, 200.0, 0.0, 200.0, 1)),
|
||||
p("creative/adjustments/creativeSaturation", "Saturation", num(100.0), sat()),
|
||||
p("creative/adjustments/shadowTint", "Shadow Tint", grey(), ParamUi::Color),
|
||||
p("creative/adjustments/highlightTint", "Highlight Tint", grey(), ParamUi::Color),
|
||||
p("creative/adjustments/tintBalance", "Tint Balance", num(0.0), bipolar()),
|
||||
p("creative/adjustments/tintBalance", "Tint Balance", num(0.0), tone()),
|
||||
];
|
||||
const CURVES: [(&str, &str); 12] = [
|
||||
("curves/curveMasterShadows", "RGB Curve Shadows"),
|
||||
@@ -786,13 +791,13 @@ pub fn specs() -> Vec<EffectSpec> {
|
||||
p("hslSecondary/key/maskStyle", "Mask Style", Value::Enum(0), popup(&HSL_MASK_STYLES)),
|
||||
p("hslSecondary/key/invertMask", "Invert Mask", Value::Bool(false), ParamUi::Checkbox),
|
||||
p("hslSecondary/refine/denoise", "Denoise", num(0.0), pct()),
|
||||
p("hslSecondary/refine/blur", "Blur", num(0.0), pct()),
|
||||
p("hslSecondary/refine/blur", "Blur", num(0.0), slider(0.0, 1000.0, 0.0, 100.0, 1)),
|
||||
p("hslSecondary/correction/correctionWheel", "Correction", grey(), ParamUi::Color),
|
||||
p("hslSecondary/correction/temperature", "Temperature", num(0.0), bipolar()),
|
||||
p("hslSecondary/correction/tint", "Tint", num(0.0), bipolar()),
|
||||
p("hslSecondary/correction/contrast", "Contrast", num(0.0), bipolar()),
|
||||
p("hslSecondary/correction/temperature", "Temperature", num(0.0), wb()),
|
||||
p("hslSecondary/correction/tint", "Tint", num(0.0), wb()),
|
||||
p("hslSecondary/correction/contrast", "Contrast", num(0.0), tone()),
|
||||
p("hslSecondary/correction/sharpen", "Sharpen", num(0.0), bipolar()),
|
||||
p("hslSecondary/correction/saturation", "Saturation", num(100.0), slider(0.0, 200.0, 0.0, 200.0, 1)),
|
||||
p("hslSecondary/correction/saturation", "Saturation", num(100.0), sat()),
|
||||
p("vignette/vignetteAmount", "Amount", num(0.0), slider(-5.0, 5.0, -5.0, 5.0, 2)),
|
||||
p("vignette/vignetteMidpoint", "Midpoint", num(50.0), pct()),
|
||||
p("vignette/vignetteRoundness", "Roundness", num(0.0), bipolar()),
|
||||
|
||||
@@ -734,7 +734,11 @@ fn stop(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
}
|
||||
|
||||
fn list(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
s.poll_render();
|
||||
// The suspended script's frontend applies post-render edits before resuming it. A
|
||||
// control inspection must not apply those edits while the undo-group guard is active.
|
||||
if s.script_ui.render_host.is_none() {
|
||||
s.poll_render();
|
||||
}
|
||||
let items: Vec<Value> = s.project.render_queue.iter().enumerate().map(|(k, i)| item_json(s, i, k)).collect();
|
||||
Ok(json!({"items": items, "rendering": s.is_rendering(), "progress": s.render_progress()}))
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ use effectcraft_project::render_queue::{OutputFormat, OutputModule, PostRenderAc
|
||||
use effectcraft_project::{ItemId, ItemKind, LayerSource, Project};
|
||||
use effectcraft_render::{ExprHost, FootageSource};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::{Event, Session};
|
||||
|
||||
@@ -113,6 +114,14 @@ pub struct JobShared {
|
||||
pub cancel: AtomicBool,
|
||||
pub state: Mutex<JobState>,
|
||||
pub updates: Mutex<Vec<ItemUpdate>>,
|
||||
/// A waiting control request retains its own items across requeue/removal/project edits.
|
||||
reply: Mutex<Option<RenderReply>>,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct RenderReply {
|
||||
items: Vec<Value>,
|
||||
finished: bool,
|
||||
}
|
||||
|
||||
pub(crate) fn lock<T>(m: &Mutex<T>) -> std::sync::MutexGuard<'_, T> {
|
||||
@@ -120,6 +129,48 @@ pub(crate) fn lock<T>(m: &Mutex<T>) -> std::sync::MutexGuard<'_, T> {
|
||||
}
|
||||
|
||||
impl JobShared {
|
||||
/// Retain the requesting command's items before the UI can edit or replace the queue.
|
||||
pub fn retain_reply_items(&self, items: Vec<Value>) {
|
||||
*lock(&self.reply) = Some(RenderReply { items, finished: false });
|
||||
}
|
||||
|
||||
/// Whether completion and post-render actions have been applied to this run.
|
||||
pub fn reply_finished(&self) -> bool {
|
||||
lock(&self.reply).as_ref().is_some_and(|reply| reply.finished)
|
||||
}
|
||||
|
||||
/// Final items for this run, independent of the current project and any later render.
|
||||
pub fn completed_items(&self) -> Option<Vec<Value>> {
|
||||
lock(&self.reply).as_ref().filter(|reply| reply.finished).map(|reply| reply.items.clone())
|
||||
}
|
||||
|
||||
fn update_reply(&self, updates: &[ItemUpdate]) {
|
||||
let mut reply = lock(&self.reply);
|
||||
let Some(reply) = reply.as_mut() else { return };
|
||||
for update in updates {
|
||||
let id = match update {
|
||||
ItemUpdate::Started { id, .. } | ItemUpdate::Finished { id, .. } => *id,
|
||||
};
|
||||
let Some(item) = reply.items.iter_mut().find(|item| item["id"].as_u64() == Some(id)).and_then(Value::as_object_mut) else { continue };
|
||||
match update {
|
||||
ItemUpdate::Started { unix, .. } => {
|
||||
item.insert("status".into(), json!(RenderStatus::Rendering));
|
||||
item.insert("statusLabel".into(), json!(RenderStatus::Rendering.label()));
|
||||
item.insert("started".into(), json!(unix));
|
||||
item.insert("render_time".into(), Value::Null);
|
||||
}
|
||||
ItemUpdate::Finished { status, seconds, output, .. } => {
|
||||
item.insert("status".into(), json!(status));
|
||||
item.insert("statusLabel".into(), json!(status.label()));
|
||||
item.insert("render_time".into(), json!(seconds));
|
||||
if let Some(output) = output {
|
||||
item.insert("last_output".into(), json!(output));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn snapshot(&self) -> JobState {
|
||||
lock(&self.state).clone()
|
||||
}
|
||||
@@ -626,8 +677,11 @@ impl Session {
|
||||
pub fn poll_render(&mut self) -> bool {
|
||||
self.drain_remote_render();
|
||||
let Some(job) = &self.render_job else { return false };
|
||||
let updates = std::mem::take(&mut *lock(&job.shared.updates));
|
||||
// Observe completion before draining updates so a worker finishing between the two
|
||||
// reads cannot leave its final item statuses behind when this job is removed.
|
||||
let finished = job.is_finished();
|
||||
let updates = std::mem::take(&mut *lock(&job.shared.updates));
|
||||
job.shared.update_reply(&updates);
|
||||
let changed = !updates.is_empty();
|
||||
let mut post: Vec<(u64, String)> = vec![];
|
||||
if changed {
|
||||
@@ -659,6 +713,7 @@ impl Session {
|
||||
}
|
||||
self.bump();
|
||||
}
|
||||
let mut completed = None;
|
||||
if finished && let Some(mut job) = self.render_job.take() {
|
||||
if let Some(t) = job.thread.take() {
|
||||
let _ = t.join();
|
||||
@@ -685,12 +740,18 @@ impl Session {
|
||||
self.events.push(Event::Frontend { command: "renderQueue.notify".into(), params: serde_json::json!({"message": msg, "error": failed > 0}) });
|
||||
}
|
||||
self.events.push(Event::Toast { message: msg, error: failed > 0 });
|
||||
completed = Some(job.shared);
|
||||
}
|
||||
for (id, path) in post {
|
||||
if let Err(e) = self.post_render_action(id, &path) {
|
||||
self.events.push(Event::Toast { message: format!("Post-render action: {e}"), error: true });
|
||||
}
|
||||
}
|
||||
if let Some(shared) = completed
|
||||
&& let Some(reply) = lock(&shared.reply).as_mut()
|
||||
{
|
||||
reply.finished = true;
|
||||
}
|
||||
changed || finished
|
||||
}
|
||||
|
||||
|
||||
@@ -388,6 +388,18 @@ pub type ScriptUiDispatch = fn(&mut Session, &ScriptUiEvent) -> std::result::Res
|
||||
/// and updates [`Session::script_ui`]. Frontends call it every frame (see [`ScriptUi::run_tasks`]).
|
||||
pub type ScriptTaskRunner = fn(&mut Session) -> Value;
|
||||
|
||||
/// Resume a suspended script once its Render Queue worker has finished. `None` means the
|
||||
/// script is still rendering; `Some` is its eventual result (or a real modal-dialog yield).
|
||||
pub type ScriptRenderPoll = fn(&mut Session) -> Option<Value>;
|
||||
|
||||
pub const RENDER_WAIT_MESSAGE: &str = "A script is rendering. Stop the render or wait for it to finish before editing.";
|
||||
|
||||
/// These inspections do not run callbacks or collect unrelated background edits. In
|
||||
/// particular, `journal: false` is not a read-only guarantee (`engine.batch`, `jobs.wait`).
|
||||
pub fn allowed_during_render(command: &str) -> bool {
|
||||
matches!(command, "renderQueue.stop" | "renderQueue.list" | "editor.state" | "app.capabilities" | "command.list" | "scriptui.list" | "scriptui.get")
|
||||
}
|
||||
|
||||
/// The open script windows.
|
||||
#[derive(Clone, Debug, Default, Serialize, Deserialize)]
|
||||
#[serde(default)]
|
||||
@@ -401,6 +413,16 @@ pub struct ScriptUi {
|
||||
/// The scripting engine's scheduled-task runner (set with [`Session::script`]).
|
||||
#[serde(skip)]
|
||||
pub tasks: Option<ScriptTaskRunner>,
|
||||
/// A frontend that services [`Self::poll_render`] may opt a script invocation into yielding
|
||||
/// during a blocking Render Queue call. Headless and lifecycle scripts remain synchronous.
|
||||
#[serde(skip)]
|
||||
pub yield_renders: bool,
|
||||
/// The script host suspended in a Render Queue call. While set, frontends must not allow
|
||||
/// other edits or script callbacks to interleave with its open undo groups.
|
||||
#[serde(skip)]
|
||||
pub render_host: Option<u32>,
|
||||
#[serde(skip)]
|
||||
pub poll_render: Option<ScriptRenderPoll>,
|
||||
/// Bumped whenever the project is closed or replaced (New / Open / Close Project): repeating
|
||||
/// tasks of scripts without an open window stop then, except those of Startup scripts.
|
||||
#[serde(skip)]
|
||||
|
||||
@@ -7,6 +7,9 @@ impl Session {
|
||||
|
||||
/// Run a command by id.
|
||||
pub fn execute(&mut self, id: &str, params: Value) -> Result<Value> {
|
||||
if self.script_ui.render_host.is_some() && !crate::scriptui::allowed_during_render(id) {
|
||||
return Err(EngineError::Other(crate::scriptui::RENDER_WAIT_MESSAGE.into()));
|
||||
}
|
||||
let spec = commands::find(id).ok_or_else(|| EngineError::UnknownCommand(id.to_string()))?;
|
||||
if let Err(why) = (spec.enabled)(self) {
|
||||
// Explicit targets (agents, scripts) don't need a UI selection.
|
||||
@@ -57,6 +60,9 @@ impl Session {
|
||||
/// Whether `id` names a known command whose `enabled` predicate returns `Ok` for this session;
|
||||
/// an unknown id or a predicate error reads as disabled.
|
||||
pub fn is_enabled(&self, id: &str) -> bool {
|
||||
if self.script_ui.render_host.is_some() && !crate::scriptui::allowed_during_render(id) {
|
||||
return false;
|
||||
}
|
||||
commands::find(id).is_some_and(|c| (c.enabled)(self).is_ok())
|
||||
}
|
||||
|
||||
|
||||
@@ -30,6 +30,9 @@ impl Session {
|
||||
/// undo step under `label`, unless it merges with the current merge key (then the redo branch
|
||||
/// is dropped). An `Err` from `f` propagates with the session unchanged.
|
||||
fn edit_inner<T>(&mut self, label: &str, merge: Option<&str>, f: impl FnOnce(&mut Project, &mut EditorState) -> Result<T>) -> Result<T> {
|
||||
if self.script_ui.render_host.is_some() {
|
||||
return Err(EngineError::Other(crate::scriptui::RENDER_WAIT_MESSAGE.into()));
|
||||
}
|
||||
let before = self.project.clone();
|
||||
let mut p = (*self.project).clone();
|
||||
let mut st = self.state.clone();
|
||||
@@ -105,6 +108,9 @@ impl Session {
|
||||
/// modified) but is no undo step, and it changes no pixels: the comp's cached frames stay
|
||||
/// valid ([`effectcraft_project::Comp::same_pixels`]).
|
||||
pub fn set_resolution(&mut self, comp: ItemId, r: effectcraft_project::Resolution) -> Result<()> {
|
||||
if self.script_ui.render_host.is_some() {
|
||||
return Err(EngineError::Other(crate::scriptui::RENDER_WAIT_MESSAGE.into()));
|
||||
}
|
||||
if self.project.comp(comp).ok_or(EngineError::NoComp)?.resolution == r {
|
||||
return Ok(());
|
||||
}
|
||||
@@ -134,6 +140,9 @@ impl Session {
|
||||
/// on-canvas editor gets a new IME epoch only when its own document changed) and the session is
|
||||
/// bumped. Returns `false`, changing nothing, when there is nothing to undo.
|
||||
pub fn undo(&mut self) -> bool {
|
||||
if self.script_ui.render_host.is_some() {
|
||||
return false;
|
||||
}
|
||||
let Some((label, p)) = self.history.undo.pop() else { return false };
|
||||
let edited = commands::text_edit::active_editor_snapshot(self);
|
||||
let probe = commands::kept_crypto_probe(self);
|
||||
@@ -152,6 +161,9 @@ impl Session {
|
||||
/// text document starts a new IME epoch) and the session is bumped. Returns `false`, changing
|
||||
/// nothing, when there is nothing to redo.
|
||||
pub fn redo(&mut self) -> bool {
|
||||
if self.script_ui.render_host.is_some() {
|
||||
return false;
|
||||
}
|
||||
let Some((label, p)) = self.history.redo.pop() else { return false };
|
||||
let edited = commands::text_edit::active_editor_snapshot(self);
|
||||
let probe = commands::kept_crypto_probe(self);
|
||||
|
||||
@@ -79,6 +79,7 @@ pub fn install(s: &mut Session) {
|
||||
s.script = Some(runner);
|
||||
s.script_ui.dispatch = Some(dispatch_ui);
|
||||
s.script_ui.tasks = Some(run_tasks);
|
||||
s.script_ui.poll_render = Some(ui::poll_render_wait);
|
||||
}
|
||||
|
||||
/// Run `code` as a one-off script named `name`.
|
||||
|
||||
@@ -30,6 +30,8 @@ pub struct Outcome {
|
||||
/// The script is waiting in a modal ScriptUI dialog's `show()` (it goes on when the dialog
|
||||
/// closes; see [`crate::dispatch_ui`]).
|
||||
pub waiting: bool,
|
||||
/// The native frontend owns the session while this host waits for its render worker.
|
||||
pub render_host: Option<u32>,
|
||||
}
|
||||
|
||||
/// An uncaught script error.
|
||||
@@ -55,6 +57,9 @@ impl Outcome {
|
||||
if self.waiting {
|
||||
j["waiting"] = json!(true);
|
||||
}
|
||||
if let Some(host) = self.render_host {
|
||||
j["renderHost"] = json!(host);
|
||||
}
|
||||
j
|
||||
}
|
||||
}
|
||||
@@ -74,13 +79,16 @@ pub(crate) struct Active {
|
||||
/// Running on a script-host thread: modal dialogs hand the session back to the caller
|
||||
/// through this link while they wait.
|
||||
pub(crate) link: Option<crate::ui::Link>,
|
||||
/// Captured at invocation start: the frontend resets its opt-in when the caller returns.
|
||||
pub(crate) yield_renders: bool,
|
||||
/// The files `$.evalFile` is evaluating, outermost first.
|
||||
pub(crate) loading: Vec<std::path::PathBuf>,
|
||||
}
|
||||
|
||||
impl Active {
|
||||
pub(crate) fn new(session: Session, name: &str, host: u32) -> Active {
|
||||
Active { session, output: vec![], groups: vec![], name: name.to_string(), host, link: None, loading: vec![] }
|
||||
let yield_renders = session.script_ui.yield_renders;
|
||||
Active { session, output: vec![], groups: vec![], name: name.to_string(), host, link: None, yield_renders, loading: vec![] }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -130,6 +138,13 @@ fn native_exec(_: &JsValue, args: &[JsValue], ctx: &mut Context) -> JsResult<JsV
|
||||
return Err(throw("scripts can't run scripts"));
|
||||
}
|
||||
let keys = params.as_object_mut().and_then(|o| o.remove("__keys"));
|
||||
let yield_render = id == "renderQueue.render"
|
||||
&& params.get("wait").and_then(J::as_bool).unwrap_or(true)
|
||||
&& with_active(|a| Ok(a.yield_renders && a.link.is_some())).unwrap_or(false);
|
||||
if yield_render {
|
||||
let object = params.as_object_mut().ok_or_else(|| throw("renderQueue.render needs an object of parameters"))?;
|
||||
object.insert("wait".into(), json!(false));
|
||||
}
|
||||
let r = with_active(|a| {
|
||||
Ok(match &keys {
|
||||
Some(k) => exec_on_keys(&mut a.session, &id, params, k),
|
||||
@@ -137,7 +152,10 @@ fn native_exec(_: &JsValue, args: &[JsValue], ctx: &mut Context) -> JsResult<JsV
|
||||
})
|
||||
});
|
||||
match r {
|
||||
Ok(Ok(v)) => Ok(js_str(&v.to_string())),
|
||||
Ok(Ok(v)) => {
|
||||
let v = if yield_render { crate::ui::wait_for_render(ctx, v).map_err(throw)? } else { v };
|
||||
Ok(js_str(&v.to_string()))
|
||||
}
|
||||
Ok(Err(e)) | Err(e) => Err(throw(e)),
|
||||
}
|
||||
}
|
||||
@@ -718,6 +736,10 @@ pub fn run(session: &mut Session, req: &ScriptRequest) -> Outcome {
|
||||
};
|
||||
let Some(x) = expanded else { return run_expanded(session, req) };
|
||||
let mut out = run_expanded(session, &ScriptRequest { code: &x.code, name: req.name, console: req.console });
|
||||
if let Some(host) = out.render_host {
|
||||
crate::ui::keep_render_source_map(host, x);
|
||||
return out;
|
||||
}
|
||||
if let Some(e) = out.error.as_mut() {
|
||||
x.locate(e);
|
||||
}
|
||||
|
||||
@@ -208,7 +208,9 @@ pub(crate) fn run_tasks_at(session: &mut Session, now: Instant) -> J {
|
||||
if !running {
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
crate::ui::tick_threaded(session, now, &mut report);
|
||||
crate::ui::tick_inline(session, now, &mut report);
|
||||
if session.script_ui.render_host.is_none() {
|
||||
crate::ui::tick_inline(session, now, &mut report);
|
||||
}
|
||||
crate::ui::prune_task_hosts(session);
|
||||
}
|
||||
let errors: Vec<J> = report.errors.iter().map(|e| json!({"message": e.message, "line": e.line, "column": e.column, "file": e.file})).collect();
|
||||
|
||||
@@ -15,6 +15,8 @@ mod ease_presets;
|
||||
mod evalfile;
|
||||
mod include;
|
||||
mod panels;
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
mod render_wait;
|
||||
#[cfg(not(windows))]
|
||||
mod services;
|
||||
mod startup;
|
||||
|
||||
@@ -0,0 +1,243 @@
|
||||
//! #3289: a native frontend may service the event loop while a script waits for rendering.
|
||||
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use effectcraft_engine::project::render_queue::OutputFormat;
|
||||
use effectcraft_engine::{ExportJob, ExportResult, Exporter, Session};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
/// A slow exporter without expensive frames. A bounded wait makes the old blocking path
|
||||
/// fail the assertions instead of hanging the test process.
|
||||
struct Gate {
|
||||
entered: AtomicUsize,
|
||||
permits: AtomicUsize,
|
||||
failed: bool,
|
||||
}
|
||||
|
||||
impl Exporter for Gate {
|
||||
fn formats(&self) -> Vec<OutputFormat> {
|
||||
vec![OutputFormat::H264]
|
||||
}
|
||||
fn export(&self, job: &ExportJob, progress: &mut dyn FnMut(u64, u64) -> bool) -> Result<ExportResult, String> {
|
||||
let call = self.entered.fetch_add(1, Ordering::SeqCst) + 1;
|
||||
let end = Instant::now() + Duration::from_secs(2);
|
||||
while self.permits.load(Ordering::SeqCst) < call {
|
||||
if !progress(0, 1) {
|
||||
return Err(effectcraft_engine::render_queue::CANCELLED.into());
|
||||
}
|
||||
if Instant::now() > end {
|
||||
return Err("test exporter timed out waiting for the frontend".into());
|
||||
}
|
||||
std::thread::sleep(Duration::from_millis(1));
|
||||
}
|
||||
if self.failed { Err("test encoder failure".into()) } else { Ok(ExportResult { path: job.path.into(), frames: 1, ..Default::default() }) }
|
||||
}
|
||||
}
|
||||
|
||||
fn session(permits: usize, failed: bool) -> (Session, Arc<Gate>) {
|
||||
let gate = Arc::new(Gate { entered: AtomicUsize::new(0), permits: AtomicUsize::new(permits), failed });
|
||||
let mut s = super::session();
|
||||
s.exporter = Some(gate.clone());
|
||||
s.execute("comp.new", json!({"name": "Render", "width": 16, "height": 16, "duration": 1})).unwrap();
|
||||
let path = std::env::temp_dir().join(format!("ec-script-render-{}.mp4", std::process::id()));
|
||||
s.execute("renderQueue.add", json!({"output": path.to_string_lossy()})).unwrap();
|
||||
s.prefs.auto_save.enabled = false;
|
||||
(s, gate)
|
||||
}
|
||||
|
||||
fn finish(s: &mut Session) -> Value {
|
||||
let end = Instant::now() + Duration::from_secs(3);
|
||||
loop {
|
||||
if let Some(result) = crate::ui::poll_render_wait(s) {
|
||||
return result;
|
||||
}
|
||||
assert!(Instant::now() < end, "script did not resume");
|
||||
std::thread::sleep(Duration::from_millis(1));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn render_yields_without_finishing_the_script_or_its_undo_group() {
|
||||
let (mut s, gate) = session(0, false);
|
||||
s.script_ui.yield_renders = true;
|
||||
let before = s.history.undo.len();
|
||||
let out = crate::run_code(
|
||||
&mut s,
|
||||
"app.beginUndoGroup('Render transaction'); app.project.activeItem.layers.addNull().name = 'Before'; $.writeln('before'); app.project.renderQueue.render(); app.project.activeItem.layers.addNull().name = 'After'; $.writeln('after'); app.endUndoGroup(); [42, app.project.renderQueue.rendering];",
|
||||
"render.jsx",
|
||||
);
|
||||
assert!(out.error.is_none(), "{out:?}");
|
||||
assert!(out.render_host.is_some(), "blocking render returned instead of yielding: {out:?}");
|
||||
assert!(!out.waiting, "a render suspension is not a modal dialog");
|
||||
assert!(out.output.is_empty(), "output belongs to the eventual reply");
|
||||
assert_eq!(s.active_comp().unwrap().layers.len(), 1, "the continuation has not run");
|
||||
assert!(crate::ui::poll_render_wait(&mut s).is_none());
|
||||
assert!(!s.undo(), "undo cannot discard the suspended script's live edits");
|
||||
assert!(s.execute("engine.batch", json!({"steps": [{"command": "layer.newNull"}]})).is_err());
|
||||
assert!(s.execute("jobs.list", json!({})).is_err(), "status polling must not apply unrelated jobs");
|
||||
// The frontend resets the invocation-only opt-in after script.run returns.
|
||||
s.script_ui.yield_renders = false;
|
||||
gate.permits.store(1, Ordering::SeqCst);
|
||||
let done = finish(&mut s);
|
||||
assert_eq!(done["ok"], true, "{done}");
|
||||
assert_eq!(done["result"], json!([42, false]));
|
||||
assert_eq!(done["output"], "before\nafter");
|
||||
assert_eq!(s.project.render_queue[0].status.label(), "Done");
|
||||
assert_eq!(s.history.undo.len(), before + 1, "one undo group spans the render");
|
||||
assert_eq!(s.history.undo.last().unwrap().0, "Render transaction");
|
||||
assert!(s.script_ui.render_host.is_none());
|
||||
s.undo();
|
||||
assert!(s.active_comp().unwrap().layers.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_second_render_keeps_the_same_script_pending() {
|
||||
let (mut s, gate) = session(0, false);
|
||||
s.script_ui.yield_renders = true;
|
||||
let out = crate::run_code(
|
||||
&mut s,
|
||||
"app.project.renderQueue.render(); app.project.renderQueue.item(1).render = true; app.project.renderQueue.render(); 17;",
|
||||
"twice.jsx",
|
||||
);
|
||||
let host = out.render_host.unwrap();
|
||||
s.script_ui.yield_renders = false;
|
||||
gate.permits.store(1, Ordering::SeqCst);
|
||||
let end = Instant::now() + Duration::from_secs(3);
|
||||
while gate.entered.load(Ordering::SeqCst) < 2 {
|
||||
assert!(crate::ui::poll_render_wait(&mut s).is_none(), "no premature reply after the first render");
|
||||
assert!(Instant::now() < end);
|
||||
std::thread::sleep(Duration::from_millis(1));
|
||||
}
|
||||
assert_eq!(s.script_ui.render_host, Some(host));
|
||||
gate.permits.store(2, Ordering::SeqCst);
|
||||
assert_eq!(finish(&mut s)["result"], 17);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cancellation_and_encoder_failure_resume_with_terminal_statuses() {
|
||||
for failed in [false, true] {
|
||||
let (mut s, gate) = session(0, failed);
|
||||
s.script_ui.yield_renders = true;
|
||||
let out = crate::run_code(&mut s, "app.project.renderQueue.render(); [9, app.project.renderQueue.rendering];", "stopped.jsx");
|
||||
assert!(out.render_host.is_some());
|
||||
if failed {
|
||||
gate.permits.store(1, Ordering::SeqCst);
|
||||
} else {
|
||||
s.stop_render();
|
||||
}
|
||||
let done = finish(&mut s);
|
||||
assert_eq!(done["ok"], true, "the synchronous command reports status, not a thrown script error: {done}");
|
||||
assert_eq!(done["result"], json!([9, false]));
|
||||
assert_ne!(s.project.render_queue[0].status.label(), "Done");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn headless_scripts_still_wait_and_start_errors_do_not_suspend() {
|
||||
let (mut s, _) = session(1, false);
|
||||
let out = crate::run_code(&mut s, "app.project.renderQueue.render(); [7, app.project.renderQueue.rendering];", "headless.jsx");
|
||||
assert!(out.error.is_none(), "{out:?}");
|
||||
assert_eq!(out.result, json!([7, false]));
|
||||
assert!(out.render_host.is_none());
|
||||
assert_eq!(s.project.render_queue[0].status.label(), "Done");
|
||||
s.script_ui.yield_renders = true;
|
||||
let out = crate::run_code(&mut s, "app.project.renderQueue.render();", "empty.jsx");
|
||||
assert!(out.error.is_some());
|
||||
assert!(out.render_host.is_none());
|
||||
assert!(s.script_ui.render_host.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn retained_palette_callbacks_can_yield_after_the_original_control_invocation() {
|
||||
let (mut s, gate) = session(0, false);
|
||||
s.script_ui.yield_renders = true;
|
||||
let out = crate::run_code(
|
||||
&mut s,
|
||||
"var w = new Window('palette', 'Render'); var b = w.add('button', undefined, 'Go', {name:'go'}); b.onClick = function () { app.project.renderQueue.render(); $.writeln('resumed'); }; w.show();",
|
||||
"palette-render.jsx",
|
||||
);
|
||||
assert!(out.error.is_none(), "{out:?}");
|
||||
s.script_ui.yield_renders = false;
|
||||
let result = s.execute("scriptui.click", json!({"widget": "go"})).unwrap();
|
||||
assert!(result["renderHost"].is_number(), "{result}");
|
||||
gate.permits.store(1, Ordering::SeqCst);
|
||||
assert_eq!(finish(&mut s)["output"], "resumed");
|
||||
let window = s.script_ui.windows[0].id;
|
||||
s.execute("scriptui.close", json!({"window": window})).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn post_render_actions_complete_before_the_script_resumes() {
|
||||
use effectcraft_engine::project::{Footage, FootageKind};
|
||||
struct Probe;
|
||||
impl effectcraft_engine::Importer for Probe {
|
||||
fn probe(&self, path: &str) -> Result<Footage, String> {
|
||||
Ok(Footage { path: path.into(), kind: FootageKind::Still, width: 16, height: 16, has_video: true, ..Default::default() })
|
||||
}
|
||||
}
|
||||
for action in ["import", "setProxy"] {
|
||||
let (mut s, gate) = session(0, false);
|
||||
s.importer = Some(Arc::new(Probe));
|
||||
let id = s.project.render_queue[0].id;
|
||||
s.execute("renderQueue.setOutputModule", json!({"item": id, "postRenderAction": action})).unwrap();
|
||||
let before = s.project.items.len();
|
||||
let comp = s.active_comp_id().unwrap();
|
||||
s.script_ui.yield_renders = true;
|
||||
let out = crate::run_code(&mut s, "app.project.renderQueue.render(); 29;", "post-render.jsx");
|
||||
assert!(out.render_host.is_some(), "{out:?}");
|
||||
gate.permits.store(1, Ordering::SeqCst);
|
||||
// A query must not consume updates while the edit guard is still active.
|
||||
let end = Instant::now() + Duration::from_secs(3);
|
||||
while s.is_rendering() {
|
||||
assert!(Instant::now() < end);
|
||||
std::thread::sleep(Duration::from_millis(1));
|
||||
}
|
||||
s.execute("renderQueue.list", json!({})).unwrap();
|
||||
assert_eq!(s.project.items.len(), before);
|
||||
assert_eq!(finish(&mut s)["result"], 29);
|
||||
if action == "import" {
|
||||
assert_eq!(s.project.items.len(), before + 1);
|
||||
} else {
|
||||
assert!(s.project.item(comp).unwrap().proxy.is_some());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deferred_errors_keep_their_included_source_location() {
|
||||
let (mut s, gate) = session(0, false);
|
||||
let dir = std::env::temp_dir().join(format!("ec-render-include-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
std::fs::write(dir.join("render.jsx"), "app.project.renderQueue.render();\nmissingAfterRender();").unwrap();
|
||||
std::fs::write(dir.join("main.jsx"), "#include 'render.jsx'").unwrap();
|
||||
s.script_ui.yield_renders = true;
|
||||
let out = crate::run_code(&mut s, "#include 'render.jsx'", &dir.join("main.jsx").to_string_lossy());
|
||||
assert!(out.render_host.is_some(), "{out:?}");
|
||||
gate.permits.store(1, Ordering::SeqCst);
|
||||
let done = finish(&mut s);
|
||||
assert_eq!(done["ok"], false);
|
||||
assert_eq!(done["error"]["line"], 2);
|
||||
assert!(done["error"]["file"].as_str().unwrap().ends_with("render.jsx"), "{done}");
|
||||
std::fs::remove_dir_all(dir).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn included_error_mapping_survives_a_modal_yield_after_rendering() {
|
||||
let (mut s, gate) = session(0, false);
|
||||
let dir = std::env::temp_dir().join(format!("ec-render-modal-include-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
std::fs::write(dir.join("render.jsx"), "app.project.renderQueue.render();\nvar d = new Window('dialog', 'Continue'); d.add('button', undefined, 'OK', {name:'ok'}); d.show();\nmissingAfterModal();").unwrap();
|
||||
std::fs::write(dir.join("main.jsx"), "#include 'render.jsx'").unwrap();
|
||||
s.script_ui.yield_renders = true;
|
||||
let out = crate::run_code(&mut s, "#include 'render.jsx'", &dir.join("main.jsx").to_string_lossy());
|
||||
assert!(out.render_host.is_some(), "{out:?}");
|
||||
gate.permits.store(1, Ordering::SeqCst);
|
||||
assert_eq!(finish(&mut s)["waiting"], true);
|
||||
let done = s.execute("scriptui.click", json!({"widget": "ok"})).unwrap();
|
||||
assert_eq!(done["ok"], false);
|
||||
assert_eq!(done["error"]["line"], 3);
|
||||
assert!(done["error"]["file"].as_str().unwrap().ends_with("render.jsx"), "{done}");
|
||||
std::fs::remove_dir_all(dir).unwrap();
|
||||
}
|
||||
+91
-8
@@ -3,7 +3,8 @@
|
||||
//!
|
||||
//! * **Script files** (native builds) run on a *script-host thread*. The caller's session moves
|
||||
//! to that thread while JavaScript runs and comes back whenever the script stops: at the end,
|
||||
//! or while a modal dialog waits in `show()`. Each [`ScriptUiEvent`] moves the session there
|
||||
//! while a modal dialog waits in `show()`, or while an opted-in frontend waits for rendering.
|
||||
//! Each [`ScriptUiEvent`] moves the session there
|
||||
//! again, runs the handler, and brings it back with the windows' new state. The host ends
|
||||
//! when its last window closes. Only one side holds the session at a time, so nothing runs
|
||||
//! concurrently and every edit is still an ordinary undoable engine command.
|
||||
@@ -137,6 +138,8 @@ pub(crate) enum ToHost {
|
||||
/// script-host threads).
|
||||
#[cfg_attr(target_arch = "wasm32", allow(dead_code))]
|
||||
Tick(Session, Instant),
|
||||
/// Resume the suspended native render call, never a callback or scheduled task.
|
||||
ResumeRender(Session),
|
||||
}
|
||||
|
||||
impl ToHost {
|
||||
@@ -145,7 +148,7 @@ impl ToHost {
|
||||
match self {
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
ToHost::Start(s) => s,
|
||||
ToHost::Event(s, _) | ToHost::Tick(s, _) => s,
|
||||
ToHost::Event(s, _) | ToHost::Tick(s, _) | ToHost::ResumeRender(s) => s,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -159,6 +162,7 @@ pub(crate) struct FromHost {
|
||||
done: bool,
|
||||
/// When its next scheduled task is due.
|
||||
next_due: Option<Instant>,
|
||||
render_wait: bool,
|
||||
}
|
||||
|
||||
/// The host thread's end of the hand-off.
|
||||
@@ -177,6 +181,8 @@ struct HostEnd {
|
||||
/// [`effectcraft_engine::scriptui::ScriptUi::project_epoch`] when the host last ran.
|
||||
#[cfg_attr(target_arch = "wasm32", allow(dead_code))]
|
||||
epoch: u64,
|
||||
/// Retained across render yields so a later error still names its included source file.
|
||||
source_map: Option<crate::include::Expanded>,
|
||||
}
|
||||
|
||||
fn hosts() -> &'static std::sync::Mutex<BTreeMap<u32, HostEnd>> {
|
||||
@@ -195,6 +201,7 @@ enum Wake {
|
||||
Event(ScriptUiEvent),
|
||||
#[cfg_attr(target_arch = "wasm32", allow(dead_code))]
|
||||
Tick(Instant),
|
||||
Render,
|
||||
}
|
||||
|
||||
/// On the host thread: hand the session (with the windows' state and what the script printed)
|
||||
@@ -206,18 +213,20 @@ fn yield_wait(outcome: crate::Outcome, windows: Vec<ScriptWindow>, done: bool) -
|
||||
match yield_wake(out, windows.clone(), done, None)? {
|
||||
Wake::Event(ev) => return Some(ev),
|
||||
Wake::Tick(_) => out = crate::Outcome { waiting: true, ..Default::default() },
|
||||
Wake::Render => return None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// [`yield_wait`] that also wakes for the scheduled tasks due at `next_due`.
|
||||
fn yield_wake(outcome: crate::Outcome, windows: Vec<ScriptWindow>, done: bool, next_due: Option<Instant>) -> Option<Wake> {
|
||||
let render_wait = outcome.render_host.is_some();
|
||||
let (session, link, name) = ACTIVE.with(|a| {
|
||||
let mut a = a.borrow_mut();
|
||||
let a = a.as_mut()?;
|
||||
Some((std::mem::take(&mut a.session), a.link.take()?, a.name.clone()))
|
||||
})?;
|
||||
if link.tx.send(FromHost { session, outcome, windows, name, done, next_due }).is_err() {
|
||||
if link.tx.send(FromHost { session, outcome, windows, name, done, next_due, render_wait }).is_err() {
|
||||
return None;
|
||||
}
|
||||
if done {
|
||||
@@ -226,6 +235,7 @@ fn yield_wake(outcome: crate::Outcome, windows: Vec<ScriptWindow>, done: bool, n
|
||||
let (s, wake) = match link.rx.recv() {
|
||||
Ok(ToHost::Event(s, ev)) => (s, Wake::Event(ev)),
|
||||
Ok(ToHost::Tick(s, now)) => (s, Wake::Tick(now)),
|
||||
Ok(ToHost::ResumeRender(s)) => (s, Wake::Render),
|
||||
_ => return None,
|
||||
};
|
||||
ACTIVE.with(|a| {
|
||||
@@ -269,8 +279,9 @@ fn host_main(host: u32, name: String, code: String, link: Link) {
|
||||
}
|
||||
Some(Wake::Tick(now)) => {
|
||||
let (ran, error) = crate::tasks::run_due(&mut ctx, host, now);
|
||||
out = crate::Outcome { result: json!(ran), output: settle(), error, waiting: false };
|
||||
out = crate::Outcome { result: json!(ran), output: settle(), error, ..Default::default() };
|
||||
}
|
||||
Some(Wake::Render) => break,
|
||||
None => break,
|
||||
}
|
||||
}
|
||||
@@ -283,15 +294,30 @@ fn host_main(host: u32, name: String, code: String, link: Link) {
|
||||
{
|
||||
let message = if body.is_err() { "the script engine stopped unexpectedly" } else { "the script ended" };
|
||||
let out = crate::Outcome { error: Some(ScriptError { message: message.into(), file, ..Default::default() }), ..Default::default() };
|
||||
let _ = link.tx.send(FromHost { session: std::mem::take(&mut a.session), outcome: out, windows: vec![], name, done: true, next_due: None });
|
||||
let _ = link.tx.send(FromHost {
|
||||
session: std::mem::take(&mut a.session),
|
||||
outcome: out,
|
||||
windows: vec![],
|
||||
name,
|
||||
done: true,
|
||||
next_due: None,
|
||||
render_wait: false,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// Wait for the host to hand the session back; keep its link while it has windows open.
|
||||
fn receive(session: &mut Session, host: u32, end: HostEnd) -> crate::Outcome {
|
||||
match end.rx.recv() {
|
||||
Ok(FromHost { session: s, outcome, windows, name, done, next_due }) => {
|
||||
Ok(FromHost { session: s, mut outcome, windows, name, done, next_due, render_wait }) => {
|
||||
*session = s;
|
||||
session.script_ui.render_host = render_wait.then_some(host);
|
||||
if !render_wait
|
||||
&& let Some(map) = &end.source_map
|
||||
&& let Some(error) = &mut outcome.error
|
||||
{
|
||||
map.locate(error);
|
||||
}
|
||||
publish(session, host, &name, windows);
|
||||
if done {
|
||||
session.script_ui.windows.retain(|w| w.host != host);
|
||||
@@ -324,7 +350,61 @@ pub(crate) fn run_threaded(session: &mut Session, req: &effectcraft_engine::Scri
|
||||
*session = msg.into_session();
|
||||
return crate::Outcome { error: Some(ScriptError { message: "the script host did not start".into(), ..Default::default() }), ..Default::default() };
|
||||
}
|
||||
receive(session, host, HostEnd { tx: to_tx, rx: from_rx, next_due: None, epoch: 0 })
|
||||
receive(session, host, HostEnd { tx: to_tx, rx: from_rx, next_due: None, epoch: 0, source_map: None })
|
||||
}
|
||||
|
||||
/// Suspend only the JavaScript stack, not the window's event loop (#3289). Keep the output
|
||||
/// and open undo groups in `Active`: settling here would split the script's undo transaction.
|
||||
pub(crate) fn wait_for_render(ctx: &mut Context, mut result: J) -> Result<J, String> {
|
||||
let host = with_active(|a| Ok(a.host))?;
|
||||
let windows = snapshot(ctx);
|
||||
let out = crate::Outcome { render_host: Some(host), ..Default::default() };
|
||||
if !matches!(yield_wake(out, windows, false, None), Some(Wake::Render)) {
|
||||
return Err("the frontend stopped waiting for the script's render".into());
|
||||
}
|
||||
with_active(|a| {
|
||||
let list = a.session.execute_for_script("renderQueue.list", json!({})).map_err(|e| e.to_string())?;
|
||||
let ids: Vec<J> = result["items"].as_array().into_iter().flatten().map(|i| i["id"].clone()).collect();
|
||||
result["items"] = json!(list["items"].as_array().into_iter().flatten().filter(|i| ids.contains(&i["id"])).collect::<Vec<_>>());
|
||||
result["rendering"] = list["rendering"].clone();
|
||||
Ok(result)
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn keep_render_source_map(host: u32, map: crate::include::Expanded) {
|
||||
if let Ok(mut h) = hosts().lock()
|
||||
&& let Some(end) = h.get_mut(&host)
|
||||
{
|
||||
end.source_map = Some(map);
|
||||
}
|
||||
}
|
||||
|
||||
/// Called by the native frontend while its normal editing/callback tick is paused. Polling
|
||||
/// applies terminal statuses and post-render actions before the suspended JS call returns.
|
||||
pub(crate) fn poll_render_wait(session: &mut Session) -> Option<J> {
|
||||
let host = session.script_ui.render_host?;
|
||||
// Only this completion boundary may apply the render's own import/proxy undo steps.
|
||||
session.script_ui.render_host = None;
|
||||
session.poll_render();
|
||||
if session.is_rendering() {
|
||||
session.script_ui.render_host = Some(host);
|
||||
return None;
|
||||
}
|
||||
session.script_ui.render_host = None;
|
||||
let fail = |message: &str| crate::Outcome { error: Some(ScriptError { message: message.into(), ..Default::default() }), ..Default::default() }.to_json();
|
||||
let Some(end) = hosts().lock().ok().and_then(|mut h| h.remove(&host)) else {
|
||||
return Some(fail("the script host stopped while rendering"));
|
||||
};
|
||||
if end.epoch != session.script_ui.project_epoch {
|
||||
return Some(fail("the project changed while the script was rendering"));
|
||||
}
|
||||
if let Err(std::sync::mpsc::SendError(msg)) = end.tx.send(ToHost::ResumeRender(std::mem::take(session))) {
|
||||
*session = msg.into_session();
|
||||
return Some(fail("the script host stopped while rendering"));
|
||||
}
|
||||
let out = receive(session, host, end);
|
||||
// A second render in this invocation keeps the original control reply pending.
|
||||
out.render_host.is_none().then(|| out.to_json())
|
||||
}
|
||||
|
||||
/// Record the windows a host published (named after the script that made them).
|
||||
@@ -429,6 +509,9 @@ pub(crate) fn tick_threaded(session: &mut Session, now: Instant, report: &mut cr
|
||||
}
|
||||
drop(stale);
|
||||
for host in due {
|
||||
if session.script_ui.render_host.is_some() {
|
||||
break;
|
||||
}
|
||||
let Some(end) = hosts().lock().ok().and_then(|mut h| h.remove(&host)) else { continue };
|
||||
if let Err(std::sync::mpsc::SendError(msg)) = end.tx.send(ToHost::Tick(std::mem::take(session), now)) {
|
||||
*session = msg.into_session();
|
||||
@@ -490,7 +573,7 @@ fn tick_inline_host(session: &mut Session, host: u32, name: &str, now: Instant)
|
||||
drop(guard);
|
||||
crate::tasks::set_epoch(host, session.script_ui.project_epoch);
|
||||
publish(session, host, name, windows);
|
||||
crate::Outcome { result: json!(ran), output, error, waiting: false }
|
||||
crate::Outcome { result: json!(ran), output, error, ..Default::default() }
|
||||
}
|
||||
|
||||
/// Forget the session's task hosts that have ended (inline ones: that have no tasks left).
|
||||
|
||||
@@ -68,6 +68,7 @@ impl EffectcraftApp {
|
||||
forwarded: ForwardedInput::default(),
|
||||
input_waiters: vec![],
|
||||
job_waiters: vec![],
|
||||
script_waiters: vec![],
|
||||
pending_screenshots: vec![],
|
||||
shot_bytes: 0,
|
||||
last_activity: (0.0, 0),
|
||||
|
||||
@@ -47,6 +47,10 @@ impl EffectcraftApp {
|
||||
self.job_waiters.push((w, reply));
|
||||
ctx.request_repaint();
|
||||
}
|
||||
control::Outcome::AwaitScript(host) => {
|
||||
self.script_waiters.push((host, reply));
|
||||
ctx.request_repaint();
|
||||
}
|
||||
control::Outcome::Screenshot { path, target } => {
|
||||
// A covered or minimized window presents no frames (macOS skips its redraws), so
|
||||
// a screenshot would never arrive and nothing would tick its timeout. The
|
||||
@@ -70,11 +74,16 @@ impl EffectcraftApp {
|
||||
if self.job_waiters.is_empty() {
|
||||
return;
|
||||
}
|
||||
self.session.poll_render();
|
||||
self.session.poll_offload();
|
||||
let script_busy = self.session.script_ui.render_host.is_some();
|
||||
if !script_busy {
|
||||
self.session.poll_render();
|
||||
self.session.poll_offload();
|
||||
}
|
||||
let waiters = std::mem::take(&mut self.job_waiters);
|
||||
for (w, reply) in waiters {
|
||||
if w.pending(self) {
|
||||
// Completed native snapshots are immutable, so even a later suspended script
|
||||
// cannot delay their replies. Other completions can edit its open undo group.
|
||||
if (script_busy && w.render.is_none()) || w.pending(self) {
|
||||
self.job_waiters.push((w, reply));
|
||||
} else {
|
||||
let v = w.finish(self);
|
||||
@@ -86,6 +95,42 @@ impl EffectcraftApp {
|
||||
}
|
||||
}
|
||||
|
||||
/// A suspended script still owns its undo groups. Only its render, inspection and Stop
|
||||
/// run until the continuation reaches its next real script boundary; no scheduled callback
|
||||
/// or background analysis may edit the document in the middle of the group.
|
||||
fn tick_script_render(&mut self, ctx: &egui::Context) {
|
||||
let Some(host) = self.session.script_ui.render_host else { return };
|
||||
let result = match self.session.script_ui.poll_render {
|
||||
Some(poll) => poll(&mut self.session),
|
||||
None => {
|
||||
self.session.script_ui.render_host = None;
|
||||
Some(json!({"ok": false, "error": {"message": "The script render waiter is unavailable."}}))
|
||||
}
|
||||
};
|
||||
if let Some(result) = result {
|
||||
if let Some(message) = result["error"]["message"].as_str() {
|
||||
self.ui.status = message.to_string();
|
||||
} else if let Some(output) = result["output"].as_str().filter(|s| !s.is_empty()) {
|
||||
self.ui.status = output.lines().last().unwrap_or(output).to_string();
|
||||
}
|
||||
self.script_waiters.retain(|(id, reply)| {
|
||||
if *id == host {
|
||||
let _ = reply.send(json!({"ok": true, "result": result}));
|
||||
false
|
||||
} else {
|
||||
true
|
||||
}
|
||||
});
|
||||
}
|
||||
// Do not interpret a close as permission to abandon the still-running script. Once
|
||||
// Stop finishes it, the normal unsaved-changes prompt can handle a new close request.
|
||||
if ctx.input(|i| i.viewport().close_requested()) {
|
||||
ctx.send_viewport_cmd(egui::ViewportCommand::CancelClose);
|
||||
}
|
||||
self.drain_control(ctx, self.now);
|
||||
ctx.request_repaint_after(std::time::Duration::from_millis(50));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- frame
|
||||
|
||||
/// Apply the engine events drained this tick: open a comp, show a toast, open a URL, run a frontend command,
|
||||
@@ -136,6 +181,10 @@ impl EffectcraftApp {
|
||||
craft_quit::on_close_request(ctx);
|
||||
return;
|
||||
}
|
||||
if self.session.script_ui.render_host.is_some() {
|
||||
self.tick_script_render(ctx);
|
||||
return;
|
||||
}
|
||||
// Focus comes from the fresh viewport copy; a full pass's `InputState::focused` is only a
|
||||
// fallback (a logic-only tick leaves it at the previous pass's value).
|
||||
let focused = ctx.input(|i| i.viewport().focused.or(Some(i.focused)));
|
||||
@@ -195,8 +244,20 @@ impl EffectcraftApp {
|
||||
ctx.request_repaint_after(std::time::Duration::from_millis(100));
|
||||
}
|
||||
self.run_script_tasks(ctx);
|
||||
if self.session.script_ui.render_host.is_some() {
|
||||
self.tick_script_render(ctx);
|
||||
return;
|
||||
}
|
||||
self.run_dialog_answers();
|
||||
if self.session.script_ui.render_host.is_some() {
|
||||
self.tick_script_render(ctx);
|
||||
return;
|
||||
}
|
||||
self.handle_events(ctx);
|
||||
if self.session.script_ui.render_host.is_some() {
|
||||
self.tick_script_render(ctx);
|
||||
return;
|
||||
}
|
||||
if let Some(rx) = self.command_inbox.take() {
|
||||
while let Ok(id) = rx.try_recv() {
|
||||
if let Err(e) = menus::invoke(self, ctx, &id, json!({})) {
|
||||
@@ -205,6 +266,10 @@ impl EffectcraftApp {
|
||||
}
|
||||
self.command_inbox = Some(rx);
|
||||
}
|
||||
if self.session.script_ui.render_host.is_some() {
|
||||
self.tick_script_render(ctx);
|
||||
return;
|
||||
}
|
||||
// A close request (the window's button, Quit, `app.quit`) must be answered while hidden.
|
||||
// An answered file dialog can change the project, so the answers above run first. The host
|
||||
// calls `panels::unsaved::quit_on_close` last in its `logic`.
|
||||
@@ -227,6 +292,12 @@ impl EffectcraftApp {
|
||||
self.advance_playback(ctx, scale, now, active, cid);
|
||||
}
|
||||
self.drain_control(ctx, now);
|
||||
// A clean project may have passed the close guard before a control script created
|
||||
// unsaved edits and suspended. Cancel that same tick's close before the host handles it.
|
||||
if self.session.script_ui.render_host.is_some() {
|
||||
self.tick_script_render(ctx);
|
||||
return;
|
||||
}
|
||||
// Screenshot completion, deadlines and issuance are app-side work: the callback only
|
||||
// stores pixels and wakes this viewport.
|
||||
self.tick_screenshots(ctx);
|
||||
@@ -375,6 +446,23 @@ impl EffectcraftApp {
|
||||
/// The panels, menus, dialogs and overlays of one pass.
|
||||
fn frame(&mut self, ui: &mut egui::Ui) {
|
||||
let ctx = ui.ctx().clone();
|
||||
if self.session.script_ui.render_host.is_some() {
|
||||
self.auto.begin_frame();
|
||||
ui.vertical_centered(|ui| {
|
||||
ui.add_space(32.0);
|
||||
ui.heading("Rendering script");
|
||||
ui.label("The script will continue when rendering finishes. Editing is paused to preserve its undo group.");
|
||||
if let Some(progress) = self.session.render_progress() {
|
||||
ui.label(format!("{} / {} frames", progress.done, progress.total));
|
||||
}
|
||||
let stop = ui.button("Stop Render");
|
||||
self.auto.add("scriptRender.stop", stop.rect, "Stop Render");
|
||||
if stop.clicked() {
|
||||
self.session.stop_render();
|
||||
}
|
||||
});
|
||||
return;
|
||||
}
|
||||
// Only a viewer that draws in *this* pass records a scale: a closed viewer, the Home
|
||||
// screen or another comp must stop idle prefetch at a stale scale.
|
||||
self.last_viewer_scale = None;
|
||||
|
||||
@@ -21,6 +21,7 @@
|
||||
//!
|
||||
//! The full reference with examples is `docs/control-protocol.md`.
|
||||
|
||||
use std::sync::Arc;
|
||||
use std::sync::mpsc::Sender;
|
||||
|
||||
use serde_json::{Value, json};
|
||||
@@ -60,6 +61,8 @@ pub enum Outcome {
|
||||
},
|
||||
/// Reply once background jobs end (a `wait: true` command whose job runs in a worker).
|
||||
AwaitJobs(JobWaiter),
|
||||
/// A script yielded its session for rendering; reply after its continuation returns.
|
||||
AwaitScript(u32),
|
||||
}
|
||||
|
||||
/// A `wait: true` engine command whose job was sent to the session's offload (the browser's
|
||||
@@ -73,6 +76,8 @@ pub struct JobWaiter {
|
||||
pub jobs: Vec<String>,
|
||||
/// Absolute completion-log position when it started (including evicted entries).
|
||||
pub log_cursor: usize,
|
||||
/// The exact native render, not the reusable `jobs.list` identifier.
|
||||
pub render: Option<Arc<effectcraft_engine::render_queue::JobShared>>,
|
||||
}
|
||||
|
||||
/// Commands that block with `wait` (and its default).
|
||||
@@ -81,34 +86,74 @@ fn waits(command: &str, params: &Value) -> bool {
|
||||
params.get("wait").and_then(Value::as_bool).unwrap_or(default)
|
||||
}
|
||||
|
||||
/// Run an engine command. A waiting command (`wait: true`) whose job the session offloads
|
||||
/// runs with `wait: false` instead, and the reply waits for the job ([`Outcome::AwaitJobs`]),
|
||||
/// so the browser's UI keeps drawing while it runs.
|
||||
/// Run an engine command. A waiting offloaded command or native render runs with `wait: false`
|
||||
/// instead, and the reply waits for the job ([`Outcome::AwaitJobs`]), keeping the UI responsive.
|
||||
fn execute_engine(app: &mut EffectcraftApp, id: &str, params: Value) -> Result<Outcome, String> {
|
||||
if !app.session.offloads() || !waits(id, ¶ms) {
|
||||
return app.session.execute_checked(id, params).map(ok).map_err(|e| e.to_string());
|
||||
if !(app.session.offloads() || (cfg!(not(target_arch = "wasm32")) && id == "renderQueue.render")) || !waits(id, ¶ms) {
|
||||
let result = app.session.execute_checked(id, params);
|
||||
return result.map(script_outcome).map_err(|e| e.to_string());
|
||||
}
|
||||
let before: Vec<String> = app.session.jobs().into_iter().map(|j| j.id).collect();
|
||||
let log_cursor = app.session.offload_log_start.saturating_add(app.session.offload_log.len());
|
||||
let mut p = params.clone();
|
||||
p["wait"] = json!(false);
|
||||
p.as_object_mut().ok_or("command params must be an object")?.insert("wait".into(), json!(false));
|
||||
let result = app.session.execute_checked(id, p).map_err(|e| e.to_string())?;
|
||||
let jobs: Vec<String> = app.session.jobs().into_iter().map(|j| j.id).filter(|j| !before.contains(j)).collect();
|
||||
if jobs.is_empty() {
|
||||
return Ok(ok(result));
|
||||
let render = if cfg!(not(target_arch = "wasm32")) && id == "renderQueue.render" && !app.session.offloads() {
|
||||
app.session.render_job.as_ref().map(|job| {
|
||||
job.shared.retain_reply_items(result["items"].as_array().cloned().unwrap_or_default());
|
||||
job.shared.clone()
|
||||
})
|
||||
} else {
|
||||
None
|
||||
};
|
||||
Ok(JobWaiter { command: id.to_string(), params, result, jobs, log_cursor, render }.into_outcome())
|
||||
}
|
||||
|
||||
fn script_outcome(result: Value) -> Outcome {
|
||||
match result.get("renderHost").and_then(Value::as_u64).and_then(|id| u32::try_from(id).ok()) {
|
||||
Some(host) => Outcome::AwaitScript(host),
|
||||
None => ok(result),
|
||||
}
|
||||
}
|
||||
|
||||
/// Suspended scripts retain open undo groups. Let the UI inspect and stop the render, but
|
||||
/// never fold an unrelated edit, another script or a project replacement into that group.
|
||||
pub(crate) fn check_script_render(app: &EffectcraftApp, id: &str) -> Result<(), String> {
|
||||
if app.session.script_ui.render_host.is_some() && !effectcraft_engine::scriptui::allowed_during_render(id) {
|
||||
Err(effectcraft_engine::scriptui::RENDER_WAIT_MESSAGE.into())
|
||||
} else {
|
||||
Ok(())
|
||||
}
|
||||
Ok(Outcome::AwaitJobs(JobWaiter { command: id.to_string(), params, result, jobs, log_cursor }))
|
||||
}
|
||||
|
||||
impl JobWaiter {
|
||||
fn into_outcome(self) -> Outcome {
|
||||
// A finished but unpolled native worker still needs its completion boundary even
|
||||
// when jobs() no longer lists it. Already-polled commands have their final result.
|
||||
if self.jobs.is_empty() && self.render.is_none() { ok(self.result) } else { Outcome::AwaitJobs(self) }
|
||||
}
|
||||
|
||||
/// Whether its jobs are still running.
|
||||
pub fn pending(&self, app: &EffectcraftApp) -> bool {
|
||||
if let Some(render) = &self.render {
|
||||
return !render.reply_finished() && app.session.render_job.as_ref().is_some_and(|job| Arc::ptr_eq(render, &job.shared));
|
||||
}
|
||||
app.session.jobs().iter().any(|j| self.jobs.contains(&j.id))
|
||||
}
|
||||
|
||||
/// The reply: what the blocking command would have returned (`running: false`, the final
|
||||
/// progress, render items and analysis status), or the job's error.
|
||||
pub fn finish(self, app: &mut EffectcraftApp) -> Value {
|
||||
if let Some(render) = self.render {
|
||||
let Some(items) = render.completed_items() else {
|
||||
return json!({"ok": false, "error": "render result unavailable: the project or render job was replaced before completion"});
|
||||
};
|
||||
let mut result = self.result;
|
||||
result["items"] = json!(items);
|
||||
result["rendering"] = json!(false);
|
||||
return json!({"ok": true, "result": result});
|
||||
}
|
||||
let s = &mut app.session;
|
||||
s.poll_render();
|
||||
let error = s
|
||||
@@ -178,6 +223,14 @@ fn point(app: &EffectcraftApp, p: &Value) -> Result<egui::Pos2, String> {
|
||||
/// [`Outcome::AwaitJobs`] instead of replying in this call.
|
||||
pub fn handle(app: &mut EffectcraftApp, ctx: &egui::Context, req: &ControlRequest, now: f64) -> Outcome {
|
||||
let p = &req.params;
|
||||
if app.session.script_ui.render_host.is_some()
|
||||
&& !matches!(
|
||||
req.method.as_str(),
|
||||
"engine.execute" | "ui.menu.invoke" | "engine.commands" | "ui.menu.list" | "ui.inspect" | "ui.elements" | "ui.resize" | "ui.focus"
|
||||
)
|
||||
{
|
||||
return err(effectcraft_engine::scriptui::RENDER_WAIT_MESSAGE);
|
||||
}
|
||||
match req.method.as_str() {
|
||||
"engine.execute" | "ui.menu.invoke" => execute_command(app, ctx, &req.method, p),
|
||||
"engine.commands" => engine_commands(app, p),
|
||||
@@ -211,15 +264,25 @@ pub fn handle(app: &mut EffectcraftApp, ctx: &egui::Context, req: &ControlReques
|
||||
fn execute_command(app: &mut EffectcraftApp, ctx: &egui::Context, method: &str, p: &Value) -> Outcome {
|
||||
let s = |k: &str| p.get(k).and_then(Value::as_str);
|
||||
let Some(id) = s("command").or(s("id")) else { return err("missing `command`") };
|
||||
if let Err(e) = check_script_render(app, id) {
|
||||
return err(e);
|
||||
}
|
||||
let params = p.get("params").cloned().filter(|v| !v.is_null()).unwrap_or(json!({}));
|
||||
// Only this invocation opts in: CLI, lifecycle scripts and the inline console retain
|
||||
// their synchronous contract. Its host remembers the opt-in across successive renders.
|
||||
let previous = app.session.script_ui.yield_renders;
|
||||
app.session.script_ui.yield_renders = cfg!(not(target_arch = "wasm32")) && id == "script.run";
|
||||
// `engine.execute` runs engine commands exactly as headless (never opens a dialog, e.g.
|
||||
// `comp.new {}` creates a default comp); UI-only ids (`tool.*`, `view.*`, `window.*`,
|
||||
// `playback.*` …) and `ui.menu.invoke` go through the menu dispatcher like a click.
|
||||
let r = if method == "engine.execute" && effectcraft_engine::find_command(id).is_some() {
|
||||
let r = if (method == "engine.execute" && effectcraft_engine::find_command(id).is_some())
|
||||
|| (id == "renderQueue.render" && params.get("wait").and_then(Value::as_bool) == Some(true))
|
||||
{
|
||||
execute_engine(app, id, params)
|
||||
} else {
|
||||
crate::menus::invoke(app, ctx, id, params).map(ok)
|
||||
crate::menus::invoke(app, ctx, id, params).map(script_outcome)
|
||||
};
|
||||
app.session.script_ui.yield_renders = previous;
|
||||
match r {
|
||||
Ok(o) => o,
|
||||
Err(e) => {
|
||||
@@ -893,6 +956,26 @@ pub fn encode_png(rgba: &[u8], w: u32, h: u32) -> Result<Vec<u8>, String> {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#[test]
|
||||
fn a_native_render_with_no_running_jobs_still_checks_its_own_completion() {
|
||||
let waiter = super::JobWaiter {
|
||||
command: "renderQueue.render".into(),
|
||||
params: serde_json::json!({}),
|
||||
result: serde_json::json!({"rendering": true, "items": []}),
|
||||
jobs: vec![],
|
||||
log_cursor: 0,
|
||||
render: Some(std::sync::Arc::new(effectcraft_engine::render_queue::JobShared::default())),
|
||||
};
|
||||
let super::Outcome::AwaitJobs(waiter) = waiter.into_outcome() else {
|
||||
panic!("a finished native render must still pass through completion polling");
|
||||
};
|
||||
let mut app = crate::EffectcraftApp::new(effectcraft_engine::Session::default());
|
||||
assert!(!waiter.pending(&app));
|
||||
let reply = waiter.finish(&mut app);
|
||||
assert_eq!(reply["ok"], false, "an abandoned render cannot silently report a replacement project's queue");
|
||||
assert!(reply["error"].as_str().unwrap().contains("replaced before completion"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn base64_rfc4648_vectors() {
|
||||
for (i, o) in [("", ""), ("f", "Zg=="), ("fo", "Zm8="), ("foo", "Zm9v"), ("foob", "Zm9vYg=="), ("fooba", "Zm9vYmE="), ("foobar", "Zm9vYmFy")] {
|
||||
|
||||
@@ -302,6 +302,7 @@ pub struct EffectcraftApp {
|
||||
pub(crate) input_waiters: Vec<Sender<serde_json::Value>>,
|
||||
/// Control requests waiting for an offloaded job (`wait: true` in the browser).
|
||||
pub(crate) job_waiters: Vec<(control::JobWaiter, Sender<serde_json::Value>)>,
|
||||
pub(crate) script_waiters: Vec<(u32, Sender<serde_json::Value>)>,
|
||||
pending_screenshots: Vec<PendingShot>,
|
||||
/// Pixels reserved by queued and in-flight screenshot requests.
|
||||
shot_bytes: usize,
|
||||
|
||||
@@ -185,6 +185,7 @@ fn only_comp(p: &Value) -> Option<effectcraft_engine::project::ItemId> {
|
||||
|
||||
/// Execute a UI or engine command by id.
|
||||
pub fn invoke(app: &mut EffectcraftApp, ctx: &egui::Context, id: &str, params: Value) -> Result<Value, String> {
|
||||
crate::control::check_script_render(app, id)?;
|
||||
if matches!(id, "effects.copyPresetsFolderPath" | "mediaBrowser.copySourcePath") {
|
||||
return frontend(app, ctx, id, params);
|
||||
}
|
||||
|
||||
@@ -82,6 +82,8 @@ mod ui_roto_objects;
|
||||
mod ui_roto_span_confidence;
|
||||
mod ui_script_console;
|
||||
mod ui_script_console_keyboard;
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
mod ui_script_render;
|
||||
mod ui_scriptui;
|
||||
mod ui_scrolling;
|
||||
mod ui_self_drop;
|
||||
|
||||
@@ -0,0 +1,260 @@
|
||||
//! #3289: the control bridge must keep servicing UI frames during scripted and direct renders.
|
||||
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::mpsc::{Receiver, TryRecvError};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use effectcraft_engine::project::render_queue::OutputFormat;
|
||||
use effectcraft_engine::{ExportJob, ExportResult, Exporter};
|
||||
use effectcraft_ui_egui::{ControlRequest, EffectcraftApp};
|
||||
use egui_kittest::Harness;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
struct Gate(AtomicBool);
|
||||
impl Exporter for Gate {
|
||||
fn formats(&self) -> Vec<OutputFormat> {
|
||||
vec![OutputFormat::H264]
|
||||
}
|
||||
fn export(&self, job: &ExportJob, progress: &mut dyn FnMut(u64, u64) -> bool) -> Result<ExportResult, String> {
|
||||
let end = Instant::now() + Duration::from_secs(3);
|
||||
while !self.0.load(Ordering::SeqCst) {
|
||||
if !progress(0, 10) {
|
||||
return Err(effectcraft_engine::render_queue::CANCELLED.into());
|
||||
}
|
||||
if Instant::now() > end {
|
||||
return Err("test exporter waited for a blocked UI".into());
|
||||
}
|
||||
std::thread::sleep(Duration::from_millis(1));
|
||||
}
|
||||
Ok(ExportResult { path: job.path.into(), frames: 10, ..Default::default() })
|
||||
}
|
||||
}
|
||||
|
||||
fn harness() -> (Harness<'static, EffectcraftApp>, Arc<Gate>, std::sync::mpsc::Sender<ControlRequest>) {
|
||||
let gate = Arc::new(Gate(AtomicBool::new(false)));
|
||||
let mut s = effectcraft_host::session();
|
||||
s.exporter = Some(gate.clone());
|
||||
s.prefs.auto_save.enabled = false;
|
||||
s.execute("comp.new", json!({"width": 16, "height": 16, "duration": 1})).unwrap();
|
||||
let path = std::env::temp_dir().join(format!("ec-ui-script-render-{}.mp4", std::process::id()));
|
||||
s.execute("renderQueue.add", json!({"output": path.to_string_lossy()})).unwrap();
|
||||
let (tx, rx) = std::sync::mpsc::channel();
|
||||
let h = Harness::builder().with_size(egui::vec2(1000.0, 700.0)).build_eframe(|_| EffectcraftApp::new(s).with_control(rx));
|
||||
(h, gate, tx)
|
||||
}
|
||||
|
||||
fn send(tx: &std::sync::mpsc::Sender<ControlRequest>, method: &str, params: Value) -> Receiver<Value> {
|
||||
let (request, reply) = ControlRequest::new(method, params);
|
||||
tx.send(request).unwrap();
|
||||
reply
|
||||
}
|
||||
|
||||
fn finish(h: &mut Harness<'_, EffectcraftApp>, reply: &Receiver<Value>) -> Value {
|
||||
let end = Instant::now() + Duration::from_secs(4);
|
||||
loop {
|
||||
h.run_steps(1);
|
||||
if let Ok(result) = reply.try_recv() {
|
||||
return result;
|
||||
}
|
||||
assert!(Instant::now() < end, "control reply did not complete");
|
||||
std::thread::sleep(Duration::from_millis(1));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn script_render_keeps_frames_and_inspection_alive_until_the_continuation_finishes() {
|
||||
let (mut h, gate, tx) = harness();
|
||||
let reply = send(
|
||||
&tx,
|
||||
"engine.execute",
|
||||
json!({"command": "script.run", "params": {"code": "$.writeln('before'); app.project.renderQueue.render(); app.project.activeItem.layers.addNull().name = 'After'; $.writeln('after'); 42;"}}),
|
||||
);
|
||||
h.run_steps(3);
|
||||
assert!(matches!(reply.try_recv(), Err(TryRecvError::Empty)), "script reply must wait for its continuation");
|
||||
assert!(h.state().session.active_comp().unwrap().layers.is_empty());
|
||||
let before = h.state().ui_passes();
|
||||
let inspect = send(&tx, "ui.inspect", json!({}));
|
||||
let edit = send(&tx, "engine.execute", json!({"command": "layer.newNull"}));
|
||||
let batch = send(&tx, "engine.execute", json!({"command": "engine.batch", "params": {"steps": [{"command": "layer.newNull"}]}}));
|
||||
let jobs = send(&tx, "engine.execute", json!({"command": "jobs.list"}));
|
||||
let quit = send(&tx, "app.quit", json!({"force": true}));
|
||||
h.run_steps(3);
|
||||
assert!(h.state().ui_passes() > before, "frames still draw while the render is gated");
|
||||
assert_eq!(inspect.try_recv().unwrap()["ok"], true);
|
||||
assert_eq!(edit.try_recv().unwrap()["ok"], false, "unrelated edits cannot enter the script's undo group");
|
||||
assert_eq!(batch.try_recv().unwrap()["ok"], false, "batches are not pure queries");
|
||||
assert_eq!(jobs.try_recv().unwrap()["ok"], false, "job polling can apply unrelated edits");
|
||||
assert_eq!(quit.try_recv().unwrap()["ok"], false, "force quit cannot abandon the script's live session");
|
||||
assert!(h.state().auto.find("scriptRender.stop").is_some());
|
||||
gate.0.store(true, Ordering::SeqCst);
|
||||
let done = finish(&mut h, &reply);
|
||||
assert_eq!(done["ok"], true, "{done}");
|
||||
assert_eq!(done["result"]["ok"], true, "{done}");
|
||||
assert_eq!(done["result"]["result"], 42);
|
||||
assert_eq!(done["result"]["output"], "before\nafter");
|
||||
assert_eq!(h.state().session.active_comp().unwrap().layers[0].name, "After");
|
||||
assert!(h.state().session.script_ui.render_host.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stop_render_remains_available_while_a_script_waits() {
|
||||
let (mut h, _gate, tx) = harness();
|
||||
let reply = send(&tx, "engine.execute", json!({"command": "script.run", "params": {"code": "app.project.renderQueue.render(); 23;"}}));
|
||||
h.run_steps(2);
|
||||
assert!(matches!(reply.try_recv(), Err(TryRecvError::Empty)));
|
||||
let stop = send(&tx, "engine.execute", json!({"command": "renderQueue.stop"}));
|
||||
h.run_steps(1);
|
||||
assert_eq!(stop.try_recv().unwrap()["ok"], true);
|
||||
assert_eq!(finish(&mut h, &reply)["result"]["result"], 23);
|
||||
assert!(!h.state().session.is_rendering());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn closing_when_a_control_script_starts_cannot_abandon_its_new_unsaved_changes() {
|
||||
let (mut h, gate, tx) = harness();
|
||||
h.state_mut().session.mark_saved();
|
||||
let reply = send(
|
||||
&tx,
|
||||
"engine.execute",
|
||||
json!({"command": "script.run", "params": {"code": "app.project.activeItem.layers.addNull(); app.project.renderQueue.render(); 31;"}}),
|
||||
);
|
||||
h.input_mut().viewports.entry(egui::ViewportId::ROOT).or_default().events.push(egui::ViewportEvent::Close);
|
||||
h.step();
|
||||
let canceled = h.output().viewport_output.get(&egui::ViewportId::ROOT).is_some_and(|v| v.commands.contains(&egui::ViewportCommand::CancelClose));
|
||||
let dirty = h.state().session.is_dirty();
|
||||
let pending = matches!(reply.try_recv(), Err(TryRecvError::Empty));
|
||||
gate.0.store(true, Ordering::SeqCst);
|
||||
assert_eq!(finish(&mut h, &reply)["result"]["result"], 31);
|
||||
assert!(canceled, "a close accepted before the control request must be canceled once the script suspends");
|
||||
assert!(dirty && pending);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn closing_on_the_completion_frame_cannot_skip_the_unsaved_changes_guard() {
|
||||
let (mut h, gate, tx) = harness();
|
||||
let reply = send(&tx, "engine.execute", json!({"command": "script.run", "params": {"code": "app.project.renderQueue.render(); 23;"}}));
|
||||
h.run_steps(2);
|
||||
assert!(h.state().session.script_ui.render_host.is_some());
|
||||
gate.0.store(true, Ordering::SeqCst);
|
||||
let end = Instant::now() + Duration::from_secs(4);
|
||||
while h.state().session.is_rendering() {
|
||||
assert!(Instant::now() < end);
|
||||
std::thread::sleep(Duration::from_millis(1));
|
||||
}
|
||||
h.input_mut().viewports.entry(egui::ViewportId::ROOT).or_default().events.push(egui::ViewportEvent::Close);
|
||||
h.step();
|
||||
assert!(h.output().viewport_output.get(&egui::ViewportId::ROOT).is_some_and(|v| v.commands.contains(&egui::ViewportCommand::CancelClose)));
|
||||
assert_eq!(reply.try_recv().unwrap()["result"]["result"], 23);
|
||||
assert!(h.state().session.is_dirty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn direct_native_render_waits_for_its_worker_without_blocking_frames() {
|
||||
let (mut h, gate, tx) = harness();
|
||||
let reply = send(&tx, "engine.execute", json!({"command": "renderQueue.render"}));
|
||||
h.run_steps(3);
|
||||
assert!(matches!(reply.try_recv(), Err(TryRecvError::Empty)));
|
||||
let inspect = send(&tx, "ui.inspect", json!({}));
|
||||
h.run_steps(2);
|
||||
assert_eq!(inspect.try_recv().unwrap()["ok"], true);
|
||||
gate.0.store(true, Ordering::SeqCst);
|
||||
let done = finish(&mut h, &reply);
|
||||
assert_eq!(done["result"]["rendering"], false);
|
||||
assert_eq!(done["result"]["items"][0]["statusLabel"], "Done", "{done}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn malformed_waiting_render_params_return_an_error() {
|
||||
let (mut h, _gate, tx) = harness();
|
||||
let reply = send(&tx, "engine.execute", json!({"command": "renderQueue.render", "params": []}));
|
||||
h.run_steps(1);
|
||||
assert_eq!(reply.try_recv().unwrap()["ok"], false);
|
||||
assert!(!h.state().session.is_rendering());
|
||||
}
|
||||
|
||||
fn wait_worker(h: &Harness<'_, EffectcraftApp>) {
|
||||
let end = Instant::now() + Duration::from_secs(4);
|
||||
while h.state().session.is_rendering() {
|
||||
assert!(Instant::now() < end, "render worker did not finish");
|
||||
std::thread::sleep(Duration::from_millis(1));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn direct_render_reply_keeps_its_items_after_queue_removal_and_a_new_render() {
|
||||
use effectcraft_ui_egui::control::{Outcome, handle};
|
||||
|
||||
for no_running_jobs in [false, true] {
|
||||
let (mut h, gate, _tx) = harness();
|
||||
let ctx = egui::Context::default();
|
||||
let request = ControlRequest::new("engine.execute", json!({"command": "renderQueue.render"})).0;
|
||||
let Outcome::AwaitJobs(mut first) = handle(h.state_mut(), &ctx, &request, 0.0) else { panic!("expected a deferred render") };
|
||||
let original = first.result["items"][0].clone();
|
||||
// Also cover completion between the command's result and its jobs() snapshot.
|
||||
if no_running_jobs {
|
||||
first.jobs.clear();
|
||||
}
|
||||
gate.0.store(true, Ordering::SeqCst);
|
||||
wait_worker(&h);
|
||||
gate.0.store(false, Ordering::SeqCst);
|
||||
// The worker is done, but no frontend completion pass has run. Removing the item
|
||||
// must not erase the waiting reply, even when the next queue reuses the same id.
|
||||
h.state_mut().session.execute("renderQueue.remove", json!({"item": original["id"]})).unwrap();
|
||||
h.state_mut().session.poll_render();
|
||||
let second_path = std::env::temp_dir().join(format!("ec-ui-second-render-{}.mp4", std::process::id()));
|
||||
h.state_mut().session.execute("renderQueue.add", json!({"output": second_path.to_string_lossy()})).unwrap();
|
||||
let Outcome::AwaitJobs(second) = handle(h.state_mut(), &ctx, &request, 0.0) else { panic!("expected a second deferred render") };
|
||||
assert!(second.pending(h.state()));
|
||||
assert!(!first.pending(h.state()), "the first reply must not wait for a later job with the same jobs.list id");
|
||||
let done = first.finish(h.state_mut());
|
||||
assert_eq!(done["result"]["rendering"], false, "{done}");
|
||||
assert_eq!(done["result"]["items"][0]["statusLabel"], "Done", "{done}");
|
||||
assert_eq!(done["result"]["items"][0]["outputPath"], original["outputPath"]);
|
||||
assert_eq!(done["result"]["items"][0]["last_output"], original["outputPath"]);
|
||||
assert!(h.state().session.is_rendering());
|
||||
gate.0.store(true, Ordering::SeqCst);
|
||||
wait_worker(&h);
|
||||
h.state_mut().session.poll_render();
|
||||
assert!(!second.pending(h.state()));
|
||||
assert_eq!(second.finish(h.state_mut())["result"]["items"][0]["outputPath"], second_path.to_string_lossy().as_ref());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn completed_direct_render_reply_is_not_delayed_by_a_later_script_render() {
|
||||
let (mut h, gate, tx) = harness();
|
||||
let first = send(&tx, "engine.execute", json!({"command": "renderQueue.render"}));
|
||||
h.run_steps(2);
|
||||
assert!(matches!(first.try_recv(), Err(TryRecvError::Empty)));
|
||||
gate.0.store(true, Ordering::SeqCst);
|
||||
wait_worker(&h);
|
||||
gate.0.store(false, Ordering::SeqCst);
|
||||
h.state_mut().session.poll_render();
|
||||
h.state_mut().session.execute("renderQueue.setRender", json!({"index": 1, "render": true})).unwrap();
|
||||
let second = send(&tx, "engine.execute", json!({"command": "script.run", "params": {"code": "app.project.renderQueue.render(); 47;"}}));
|
||||
h.run_steps(1);
|
||||
assert!(h.state().session.script_ui.render_host.is_some());
|
||||
assert!(matches!(second.try_recv(), Err(TryRecvError::Empty)));
|
||||
let done = first.try_recv().expect("an immutable completed reply does not enter the new script's undo group");
|
||||
assert_eq!(done["result"]["rendering"], false);
|
||||
assert_eq!(done["result"]["items"][0]["statusLabel"], "Done");
|
||||
gate.0.store(true, Ordering::SeqCst);
|
||||
assert_eq!(finish(&mut h, &second)["result"]["result"], 47);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn replacing_project_before_render_poll_returns_an_explicit_error() {
|
||||
use effectcraft_ui_egui::control::{Outcome, handle};
|
||||
|
||||
let (mut h, gate, _tx) = harness();
|
||||
let request = ControlRequest::new("engine.execute", json!({"command": "renderQueue.render"})).0;
|
||||
let Outcome::AwaitJobs(waiter) = handle(h.state_mut(), &egui::Context::default(), &request, 0.0) else { panic!("expected a deferred render") };
|
||||
gate.0.store(true, Ordering::SeqCst);
|
||||
wait_worker(&h);
|
||||
h.state_mut().session.replace_project(Default::default(), None);
|
||||
assert!(!waiter.pending(h.state()));
|
||||
let reply = waiter.finish(h.state_mut());
|
||||
assert_eq!(reply["ok"], false, "{reply}");
|
||||
assert!(reply["error"].as_str().unwrap().contains("replaced before completion"));
|
||||
}
|
||||
@@ -241,7 +241,14 @@ fn waiting_commands_report_only_their_own_completion_errors() {
|
||||
let other = if kind == JobKind::Track { JobKind::Warp } else { JobKind::Track };
|
||||
let mut app = EffectcraftApp::new(Session::default());
|
||||
app.session.offload_log = vec![(kind, Some("old error".into())), (other, Some("unrelated failure".into())), (kind, None)];
|
||||
let waiter = || JobWaiter { command: "analysis".into(), params: json!({}), result: json!({"running": true}), jobs: vec![job_id.into()], log_cursor: 1 };
|
||||
let waiter = || JobWaiter {
|
||||
command: "analysis".into(),
|
||||
params: json!({}),
|
||||
result: json!({"running": true}),
|
||||
jobs: vec![job_id.into()],
|
||||
log_cursor: 1,
|
||||
render: None,
|
||||
};
|
||||
let result = waiter().finish(&mut app);
|
||||
assert_eq!(result, json!({"ok": true, "result": {"running": false}}), "{kind:?}");
|
||||
app.session.offload_log.push((kind, Some("nothing was applied".into())));
|
||||
@@ -258,6 +265,6 @@ fn waiting_command_sees_its_error_after_the_completion_log_rolls_over() {
|
||||
app.session.offload_log = vec![(JobKind::Warp, None); 31];
|
||||
app.session.offload_log.push((JobKind::Track, Some("nothing was applied".into())));
|
||||
app.session.offload_log_start = 1;
|
||||
let waiter = JobWaiter { command: "track.analyze".into(), params: json!({}), result: json!({}), jobs: vec!["track".into()], log_cursor: 32 };
|
||||
let waiter = JobWaiter { command: "track.analyze".into(), params: json!({}), result: json!({}), jobs: vec!["track".into()], log_cursor: 32, render: None };
|
||||
assert_eq!(waiter.finish(&mut app), json!({"ok": false, "error": "nothing was applied"}));
|
||||
}
|
||||
|
||||
@@ -610,6 +610,64 @@ suffix is a parameter id, not the `parT` position (Curves -0002 Channel before -
|
||||
-0005 Quality before -0004). API: `CurvesData`, `MeshData`, `curve_lut`. [V; details: corpus FORMAT
|
||||
S]
|
||||
|
||||
**Lumetri Color** (`ADBE Lumetri`, 1200: 63 compositions in After Effects 2026, 32 bpc, working space
|
||||
None; by script: defaults, all set, one per section, valid minima and maxima, every popup item the script
|
||||
can set, checkboxes toggled, keys, an expression; from presets made by hand, `-58` ... `-62`: an Input
|
||||
LUT, a Look, the RGB curve, Hue vs Sat, the colour wheels): 131 `parT` entries. The `-NNNN` suffix is a
|
||||
parameter id, not the position (`-0127` ... `-0130` sit between `-0005` and `-0026`), so the importer
|
||||
keys by suffix. Float sliders (type 10) store the value After Effects shows; the valid ranges (`pard`
|
||||
104 / 108) are wider than the slider's (112 / 116): Temperature / Tint -300..300 (slider -100..100),
|
||||
Saturation 0..300 (0..200), Exposure -7..7 (-5..5), Contrast, Highlights, Shadows, Whites, Blacks,
|
||||
HDR Specular, Tint Balance -150..150 (-100..100), Faded Film 0..150 (0..100), HDR White 100..1000,
|
||||
HDR Range 100..10000, Refine Blur 0..1000 (0..30), Vignette Amount -5..5 (-3..3); the read-backs of
|
||||
`-19-min` / `-20-max` equal the stored numbers. **Hidden controls are recorded in the default state**
|
||||
(`tdsb` 3) and still hold the value After Effects reads back: High Dynamic Range `-0062` (1.0),
|
||||
HDR Specular `-0064` (22), the second Look popup `-0128` (26.16 as set, read back 26), Legacy Tone And
|
||||
Saturation `-0097` (its `pard` default reads off as the script saved the project and on once the
|
||||
project was opened and saved again, `versions/1200-lumetri-26`, `-25x`, `-24x`; a fresh instance reads
|
||||
back off), Auto Color `-0124`, Face Detection `-0102`, the Auto Intensity `-0123`
|
||||
(`EffectParam::recorded_value`). Each section has an Active checkbox (blank `pard` name; `-0003` Basic
|
||||
Correction, `-0023` Creative, `-0037` Curves, `-0118` RGB Curves, `-0104` / `-0105` / `-0107` / `-0109` /
|
||||
`-0111` the hue / saturation curves, `-0046` Color Wheels, `-0068` HSL Secondary, `-0050` Vignette, on
|
||||
by default); off, the section does nothing (`-03-sec-basic-correction`, `-17-sec-vignette` render as
|
||||
`-00-defaults`). Arbitrary data (type 11, a record and `aRbs` in every instance; empty is `fe fe`):
|
||||
`-0001` an XML copy of the grade written only when the Look changes (not kept current: `-01-set-all`
|
||||
holds defaults for controls set after the Look; it names the LUT file, `Fuji F125 Kodak 2393 (by
|
||||
Adobe).cube`); `-0096` the Look's name, UTF-16LE (`SL BLUE ICE`, `Fuji F125 Kodak 2393 (by Adobe)` in
|
||||
`-59-preset-look-lut`; the popup `-0025` itself has no record, `-0128` can disagree with it:
|
||||
`-07-sec-creative` names item 20 while `-0128` holds 23), `-0095` likewise for the Input LUT
|
||||
(`ALEXA_Default_LogC2Rec709`, `-58-preset-input-lut`; the popups `-0005` / `-0127` keep no record,
|
||||
`-0004`, `-0024` and the assets `-0125` / `-0126` stay `fe fe`: the LUTs are After Effects' own files,
|
||||
named, not embedded); `-0129` the Look colour space's 16-byte id (the GUID `LookColorSpace` of `-0001`
|
||||
in Windows layout: the first 4, 2 and 2 bytes little-endian; zero for None; the Color Space popup
|
||||
`-0130` writes no record of its own, `-22-pop38-1` ... `-53-pop38-33`);
|
||||
`-0032` Split Toning (32 zero bytes); `-0039` the four RGB curves and `-0042`, `-0106`, `-0108`,
|
||||
`-0110`, `-0112` the hue / saturation curves, 520 bytes each: `i32` type (3 for the hue curves, 2 for
|
||||
Luma vs Sat and Sat vs Sat, 0 for an RGB curve), `u32` point count, then little-endian `f64` pairs x, y
|
||||
sorted by x (bytes after the counted points are stale). RGB: x and y 0..1, (0, 0), (1, 1) by default;
|
||||
the first of the four is the master curve (`-60-preset-curves-rgb`: (0.491, 0.787) lifts R, G and B
|
||||
alike, and the render follows a natural cubic spline through the points within 0.003); the order of
|
||||
the other three is H (the `-0001` XML names `ToneCurvesLuma`, `Red`, `Green`, `Blue`; no sample moves
|
||||
one). Hue / saturation: x the hue (0 red, 1/3 green) or the luma / saturation 0..1, y the offset from
|
||||
neutral 0; a fresh curve has no points, the selector colours `-0113` ... `-0117` add three at y = 0,
|
||||
the hue picked +- 0.0556. Hue vs Sat's y scales the saturation by 1 + y in linear light
|
||||
(`-61-preset-curves-huesat`: (0.0155, 0.97) takes pure red's linear saturation to 1.96 times, (0.263,
|
||||
-0.03) and (0.765, -0.02) green, cyan and blue to 0.97); the other four curves' scale is H. `-0047`
|
||||
Color Wheels, 4 `f64` triples: hue in degrees (0 red, 120 green), brightness slider 0..1 (0.5
|
||||
neutral), distance from the centre 0..1; 0, 0.5, 0 each by default; Shadows, Midtones, Highlights, then
|
||||
a fourth wheel of unknown role (`-62-preset-wheels`: Shadows 229.1, 0.370, 0.547 towards blue with the
|
||||
slider down; Midtones 0.1, 0.5, 0.395 towards red; Highlights 93.4, 0.727, 0.319 towards yellow-green
|
||||
with the slider up; the fourth at its default); `-0085` the HSL correction wheels (8 triples, the same
|
||||
default; layout H); `-0073` the HSL key, 40 bytes: a flag byte (1 once a colour was picked), then hue,
|
||||
saturation, lightness, each centre, range, tolerance (`f32` LE, fractions) and an enabled byte (fresh:
|
||||
0.5, 0, 0, on; picking RGB 0.02 0.55 0.85 gives hue centre 0.5605 = that colour's hue and lightness
|
||||
centre 0.435 = its HSL lightness, range 0.05, tolerance 0.1: `-01-set-all`; whether range is the half
|
||||
or full width is H); `-0122` the Auto Tone analysis (UTF-16 JSON). The project saved as 25.x and 24.x
|
||||
copies (`versions/1200-lumetri-25x`, `-24x`: the 58 scripted compositions, saved by After Effects
|
||||
2026) stores every Lumetri definition, valid range and value as the 26 save does. [V for the sliders,
|
||||
checkboxes, popups, the `tdsb` 3 values, the master RGB curve, Hue vs Sat, the colour wheels and the
|
||||
LUT names; H where marked]
|
||||
|
||||
**Cryptomatte plug-in** (match name `Cryptomatte`, the free Cryptomatte plug-in, 2.6.6 in our
|
||||
sample; read from 88 instances in one production project, not made by us, and from our own After
|
||||
Effects 2026 sample 1400 (corpus `aep/cryptomatte.aep`: a fresh instance, Selection Mode on, each
|
||||
|
||||
+42
-3
File diff suppressed because one or more lines are too long
@@ -60,6 +60,26 @@ export formats, parity summary), `jobs.list` (Progress panel jobs, e.g. the back
|
||||
Every engine command is reachable through `engine.execute`, and every interactive widget of every
|
||||
panel registers an element id; both are enforced by `crates/ui-egui/tests/it/ui_agent_audit.rs`.
|
||||
|
||||
### Waiting for a render
|
||||
|
||||
On native desktop builds, `engine.execute` with `renderQueue.render` and `wait: true`
|
||||
(the default) keeps the window responsive while its reply waits for the render worker.
|
||||
`script.run` invoked through the control channel also yields the window while
|
||||
`app.project.renderQueue.render()` waits. JavaScript resumes after completion or cancellation;
|
||||
the original request receives its result and accumulated output only after the continuation
|
||||
finishes or shows a modal dialog. Several renders in one script keep the same request pending.
|
||||
Headless scripts retain their synchronous behavior.
|
||||
|
||||
While a script waits for rendering, the window shows progress and a **Stop Render** button
|
||||
(`scriptRender.stop`). Other editing and script callbacks pause so they cannot enter the
|
||||
suspended script's undo group. `ui.inspect`, `ui.elements`, command/menu listings, resize,
|
||||
focus, and the engine commands `renderQueue.list`, `renderQueue.stop`, `editor.state`,
|
||||
`app.capabilities`, `command.list`, `scriptui.list` and `scriptui.get` remain available.
|
||||
Other control requests return an error. Use another connection for inspection or Stop,
|
||||
since requests on one connection are answered in order. A window-close request is canceled
|
||||
during this wait; stop the render and then close normally to use the unsaved-changes prompt.
|
||||
The control transport's 60-second request timeout still applies to long renders.
|
||||
|
||||
## UI state
|
||||
|
||||
| Method | Params | Result |
|
||||
|
||||
@@ -22,7 +22,7 @@ trips and renders, checked by tests, not against After Effects), **partial** (na
|
||||
|
||||
| Format | After Effects | EffectCraft read | Write | Fidelity / notes | Tested by |
|
||||
|---|---|---|---|---|---|
|
||||
| Project `.aep` | R/W (native) | partial | partial | Main file format; the clean-room method is in [aep-format.md](aep-format.md), the maintainers' decision on it is open (#190). `crates/aep` reads and writes the RIFX container losslessly (byte-identical round trip) with a typed view of the decoded fields, from our own generated files only ([aep-format.md](aep-format.md)). File ▸ Import ▸ After Effects Project… (`file.importAep`, `effectcraft_aep::import`): folders, project settings, compositions, every layer kind, Transform, keyframes, expressions, masks, text with animators, shapes, cameras, lights, track mattes, markers, time remapping, layer styles, the built-in effects of a table shared with the exporter (Curves, Levels, Glow, Fractal Noise, Gradient Ramp, … and the expression controls), footage and image sequences; everything else is reported ([aep-import.md](aep-import.md)). File ▸ Export ▸ After Effects Project… (`file.exportAep`, `effectcraft_aep::export`) writes the same scope with every chunk built in code; exports were opened and read back in After Effects 2026 ([aep-export.md](aep-export.md)); everything else is reported. Effects outside the table (plug-ins, Lumetri, …) are not converted | `crates/aep` tests (`tests/container`, `tests/import`, `tests/export`) |
|
||||
| Project `.aep` | R/W (native) | partial | partial | Main file format; the clean-room method is in [aep-format.md](aep-format.md), the maintainers' decision on it is open (#190). `crates/aep` reads and writes the RIFX container losslessly (byte-identical round trip) with a typed view of the decoded fields, from our own generated files only ([aep-format.md](aep-format.md)). File ▸ Import ▸ After Effects Project… (`file.importAep`, `effectcraft_aep::import`): folders, project settings, compositions, every layer kind, Transform, keyframes, expressions, masks, text with animators, shapes, cameras, lights, track mattes, markers, time remapping, layer styles, the built-in effects of a table shared with the exporter (Lumetri Color, Curves, Levels, Glow, Fractal Noise, Gradient Ramp, … and the expression controls), footage and image sequences; everything else is reported ([aep-import.md](aep-import.md)). File ▸ Export ▸ After Effects Project… (`file.exportAep`, `effectcraft_aep::export`) writes the same scope with every chunk built in code; exports were opened and read back in After Effects 2026 ([aep-export.md](aep-export.md)); everything else is reported. Effects outside the table (plug-ins, …) are not converted | `crates/aep` tests (`tests/container`, `tests/import`, `tests/export`) |
|
||||
| XML project `.aepx` | R/W | partial | none | Read into the same chunk tree as `.aep` by `crates/aep` (`read_aepx`; mapping in [aep-format.md](aep-format.md) section 1), so `file.importAep` imports it with the same coverage as `.aep`; not written | `crates/aep` `aepx` tests (container and import) |
|
||||
| Templates `.aet` / `.aetx` | R/W | none | none | Own `.ectemplate` (Home ▸ Templates, Save as Template) | `templates.rs` tests |
|
||||
| Motion Graphics template `.mogrt` | W (and R in Premiere) | none | none | Own `.ectemplate` Essential Graphics templates; FilmCraft rejects our `.fcgt` export (#611) | `essential.rs` tests |
|
||||
|
||||
Reference in New Issue
Block a user