Merge pull request #645 from storytold/claude/community-pr-review-lwtrlr

Fix 28 community-reported issues (crash, security, AE parity)
This commit is contained in:
BFlat authored and GitHub committed 2026-10-11 03:25:25 +07:00
commit f8b2552d1c
68 files changed
+2166 -209

No files matched your search

+120 -8
View File
@@ -15,7 +15,7 @@ use effectcraft_raster::{Image, Px};
use rayon::prelude::*;
use crate::util::{Plane, gauss_plane, hash1, layer_or_self, morph_frac, poly_length, premul, smoothstep, unpremul};
use crate::{Buf, EffectCtx, EffectSpec, col, num, p, popup, slider};
use crate::{Buf, EffectCtx, EffectSpec, Params, col, num, p, popup, slider};
fn spec(id: &'static str, name: &'static str, params: Vec<crate::ParamSpec>, render: crate::RenderFn) -> EffectSpec {
EffectSpec { id, name, category: "Generate", params, render, gpu: false, float: true }
@@ -1074,23 +1074,66 @@ fn vegas_plan(ctx: &EffectCtx, b: &Buf) -> PaintPlan {
// ---------------------------------------------------------------- Write-on / Glue Gun
/// Brush dab history: only the brush position at the current time is known to an effect
/// (parameter histories are not passed in), so the stroke is drawn as the dabs laid along
/// the current position — a single dab when the brush is static. Deterministic.
/// Write-on draws a stroke along the animated Brush Position, as in After Effects: a dab every
/// Brush Spacing seconds from the layer's start up to now, each one lasting Stroke Length
/// seconds (0: for ever). The dabs' positions come from the host's parameters at their times;
/// with Paint Time Properties ▸ Opacity or Brush Time Properties ▸ Size each dab keeps its own
/// opacity or size, else the current ones apply to every dab. (Colour and hardness are one per
/// stroke: the current ones.) Without a host (or dabs) the current dab draws. Deterministic.
fn write_on(ctx: &EffectCtx, mut b: Buf) -> Buf {
let plan = write_on_plan(ctx, &b);
draw_paint(&mut b, &plan);
b
}
/// The most dabs one Write-on frame draws; longer strokes lay them further apart, joined.
const WRITE_ON_MAX_DABS: i64 = 4096;
fn write_on_plan(ctx: &EffectCtx, b: &Buf) -> PaintPlan {
let pr = ctx.params;
let q = b.to_px(pr.v2("brushPosition"));
let r = (pr.f("brushSize") * b.scale * 0.5).max(0.05);
let own_opacity = pr.e("paintTimeProperties") == 1;
let own_size = matches!(pr.e("brushTimeProperties"), 1 | 3);
let opacity = |p: &Params| (p.f("brushOpacity") / 100.0).clamp(0.0, 1.0) as f32;
let radius = |p: &Params| (p.f("brushSize") * b.scale * 0.5).max(0.05);
let dab = |p: &Params| {
let q = b.to_px(p.v2("brushPosition"));
([q.0, q.1], radius(if own_size { p } else { pr }), if own_opacity { opacity(p) } else { 1.0 })
};
let mut segs = vec![];
let t = ctx.time;
let spacing = pr.f("brushSpacing").max(0.001);
let life = pr.f("strokeLength").max(0.0);
if let Some(host) = ctx.env.host
&& t.is_finite()
&& t >= 0.0
{
let start = if life > 0.0 { (t - life).max(0.0) } else { 0.0 };
let k0 = (start / spacing - 1e-9).ceil().max(0.0) as i64;
let k1 = (t / spacing + 1e-9).floor().max(0.0) as i64;
let n = k1.saturating_sub(k0).saturating_add(1);
let stride = (n / WRITE_ON_MAX_DABS).saturating_add(1).max(1);
let mut prev: Option<[f64; 2]> = None;
let mut k = k0;
while k <= k1 {
let tk = k as f64 * spacing;
let at = if (tk - t).abs() < 1e-9 { Some(dab(pr)) } else { host.params_at(tk).map(|p| dab(&p)) };
if let Some((q, r, v)) = at {
// Dabs laid further apart than the brush spacing are joined into a line.
let a = if stride > 1 { prev.unwrap_or(q) } else { q };
segs.push(Seg { a, b: q, r, v });
prev = Some(q);
}
k = k.saturating_add(stride);
}
}
if segs.is_empty() {
let (q, r, v) = dab(pr);
segs.push(Seg { a: q, b: q, r, v });
}
PaintPlan {
cov: Coverage::Segs { segs: vec![Seg { a: [q.0, q.1], b: [q.0, q.1], r, v: 1.0 }], hardness: pr.f("brushHardness") / 100.0 },
cov: Coverage::Segs { segs, hardness: pr.f("brushHardness") / 100.0 },
color: pr.color("color"),
opacity: (pr.f("brushOpacity") / 100.0) as f32,
opacity: if own_opacity { 1.0 } else { opacity(pr) },
style: pr.e("paintStyle"),
}
}
@@ -2204,6 +2247,75 @@ mod tests {
assert_eq!(g.img.get(0, 0), src.get(0, 0));
}
/// #536: an animated Brush Position draws a stroke that stays (Stroke Length 0), expires
/// after Stroke Length, and lays its dabs Brush Spacing seconds apart.
#[test]
fn write_on_accumulates_a_stroke_along_the_animated_brush() {
// Brush Position from [8, 16] at 0 s to [56, 16] at 1 s; red, hard, 6 px.
struct Brush(Vec<(&'static str, Value)>);
impl Brush {
fn at(&self, t: f64) -> Vec<(&'static str, Value)> {
let mut v = self.0.clone();
v.push(("brushPosition", Value::Vec2([8.0 + 48.0 * t.clamp(0.0, 1.0), 16.0])));
v
}
}
impl EffectHost for Brush {
fn layer(&self, _: u64, _: bool) -> Option<LayerPixels> {
None
}
fn audio(&self, _: u64, _: f64, _: usize, _: u32) -> Option<Vec<f32>> {
None
}
fn params_at(&self, t: f64) -> Option<Params> {
let s = crate::find("ec.generate.writeon").unwrap();
let mut p = Params { values: s.params.iter().map(|p| (p.id.to_string(), p.default.clone())).collect() };
for (k, v) in self.at(t) {
p.values.insert(k.to_string(), v);
}
Some(p)
}
}
let render = |extra: &[(&'static str, Value)], t: f64| {
let mut set = vec![("color", col(1.0, 0.0, 0.0)), ("brushSize", num(6.0)), ("brushHardness", num(100.0)), ("brushSpacing", num(0.05))];
set.extend(extra.iter().cloned());
let host = Brush(set);
let env = EffectEnv { host: Some(&host), ..Default::default() };
let at = host.at(t);
run_env("ec.generate.writeon", &at, Image::new(64, 32), t, env).img
};
let on = |img: &Image, x: i64| img.get(x, 16)[3] > 0.9;
let half = render(&[], 0.5);
assert!(on(&half, 8) && on(&half, 20) && on(&half, 32) && !on(&half, 44) && !on(&half, 56));
let end = render(&[], 1.0);
assert!([8, 20, 32, 44, 56].iter().all(|x| on(&end, *x)));
// Stroke Length 0.25 s: only the last quarter second's trail.
let trail = render(&[("strokeLength", num(0.25))], 1.0);
assert!(!on(&trail, 8) && !on(&trail, 32) && on(&trail, 48) && on(&trail, 56));
// Brush Spacing 0.5 s: three separate marks.
let spaced = render(&[("brushSpacing", num(0.5))], 1.0);
assert!(on(&spaced, 8) && on(&spaced, 32) && on(&spaced, 56) && !on(&spaced, 20) && !on(&spaced, 44));
// Paint Time Properties ▸ Opacity: each dab keeps the opacity it was laid with.
struct Fade;
impl EffectHost for Fade {
fn layer(&self, _: u64, _: bool) -> Option<LayerPixels> {
None
}
fn audio(&self, _: u64, _: f64, _: usize, _: u32) -> Option<Vec<f32>> {
None
}
fn params_at(&self, t: f64) -> Option<Params> {
let mut p = Brush(vec![("brushHardness", num(100.0)), ("brushSpacing", num(0.05)), ("paintTimeProperties", Value::Enum(1))]).params_at(t)?;
p.values.insert("brushOpacity".into(), num(if t < 0.5 { 100.0 } else { 20.0 }));
Some(p)
}
}
let p1 = Fade.params_at(1.0).unwrap();
let set: Vec<(&str, Value)> = p1.values.iter().map(|(k, v)| (k.as_str(), v.clone())).collect();
let faded = run_env("ec.generate.writeon", &set, Image::new(64, 32), 1.0, EffectEnv { host: Some(&Fade), ..Default::default() }).img;
assert!(faded.get(8, 16)[3] > 0.9 && (faded.get(56, 16)[3] - 0.2).abs() < 0.05, "{:?} {:?}", faded.get(8, 16), faded.get(56, 16));
}
#[test]
fn paint_bucket_fills_enclosed_region_only() {
let mut img = Image::filled(32, 32, [1.0, 1.0, 1.0, 1.0]);
+4
View File
@@ -430,6 +430,10 @@ pub(crate) fn p(id: &'static str, name: &'static str, default: Value, ui: ParamU
pub(crate) fn slider(min: f64, max: f64, smin: f64, smax: f64, decimals: u8) -> ParamUi {
ParamUi::Slider { min, max, slider_min: smin, slider_max: smax, decimals }
}
/// A file parameter offering files with these extensions (Effect Controls' Choose…).
pub(crate) fn file_ui(exts: &[&str]) -> ParamUi {
ParamUi::File { filters: exts.iter().map(|s| s.to_string()).collect() }
}
pub(crate) fn popup(opts: &[&str]) -> ParamUi {
ParamUi::Popup { options: opts.iter().map(|s| s.to_string()).collect() }
}
+20
View File
@@ -236,6 +236,9 @@ fn refresh(pr: &mut Property, ps: &ParamSpec, spec: &EffectSpec) {
}
// A parameter that became internal (superseded by a new one) is hidden.
(_, ParamUi::Hidden) => pr.ui = ParamUi::Hidden,
// A path typed as text, or set only by commands, that got a Choose… button (Apply Color
// LUT's LUT, the OCIO files, #514).
(ParamUi::Hidden | ParamUi::Text, ParamUi::File { .. }) => pr.ui = ps.ui.clone(),
// Same kind of value: take the spec's hints (units, ranges).
(a, b) if std::mem::discriminant(a) == std::mem::discriminant(b) || is_numeric_ui(a) && is_numeric_ui(b) => pr.ui = ps.ui.clone(),
_ => {}
@@ -494,6 +497,23 @@ mod tests {
assert_eq!(pr.ui, popup.ui);
}
/// #514: Apply Color LUT's LUT was hidden (set only by commands) and the OCIO files were
/// plain text; old projects get the Choose… file parameter, keeping the path.
#[test]
fn hidden_and_text_paths_become_file_parameters() {
for (fx, pid, old) in [("ec.utility.applylut", "lut", ParamUi::Hidden), ("ec.color.ociofile", "file", ParamUi::Text)] {
let mut next = 1;
let (spec, mut g) = instance(fx, &mut next);
let pr = g.get_mut(pid).unwrap();
pr.ui = old;
pr.value = Value::Str("/luts/look.cube".into());
assert!(upgrade_instance(spec, &mut g, &mut Ids(&mut next), [100.0, 100.0]), "{fx}");
let pr = g.get(pid).unwrap();
assert!(matches!(&pr.ui, ParamUi::File { filters } if filters.iter().any(|f| f == "cube")), "{fx}: {:?}", pr.ui);
assert_eq!(pr.value, Value::Str("/luts/look.cube".into()));
}
}
#[test]
fn upgrade_moves_params_into_twirl_down_groups() {
let spec = find(crate::warp_stab::ID).unwrap();
+3 -3
View File
@@ -1427,7 +1427,7 @@ pub fn specs() -> Vec<EffectSpec> {
cc,
vec![
p("config", "Configuration", Value::Enum(0), popup(CONFIGS)),
p("configFile", "Config File (.ocio)", Value::Str(String::new()), ParamUi::Text),
p("configFile", "Config File (.ocio)", Value::Str(String::new()), crate::file_ui(&["ocio"])),
space_param("source", "Source", lin709),
p("sourceName", "Source (config name)", Value::Str(String::new()), ParamUi::Text),
space_param("destination", "Destination", srgb),
@@ -1442,7 +1442,7 @@ pub fn specs() -> Vec<EffectSpec> {
cc,
vec![
p("config", "Configuration", Value::Enum(0), popup(CONFIGS)),
p("configFile", "Config File (.ocio)", Value::Str(String::new()), ParamUi::Text),
p("configFile", "Config File (.ocio)", Value::Str(String::new()), crate::file_ui(&["ocio"])),
space_param("source", "Input Color Space", lin709),
p("sourceName", "Source (config name)", Value::Str(String::new()), ParamUi::Text),
p("display", "Display Device", Value::Enum(0), popup(&DISPLAYS.iter().map(|d| d.0).collect::<Vec<_>>())),
@@ -1459,7 +1459,7 @@ pub fn specs() -> Vec<EffectSpec> {
"OCIO File Transform",
cc,
vec![
p("file", "File", Value::Str(String::new()), ParamUi::Text),
p("file", "File", Value::Str(String::new()), crate::file_ui(&["cube", "3dl", "csp", "cc", "ccc", "cdl", "spi1d", "spi3d", "spimtx"])),
p("cccId", "CCC ID", Value::Str(String::new()), ParamUi::Text),
p("interpolation", "Interpolation", Value::Enum(3), popup(&["Nearest", "Linear", "Tetrahedral", "Best"])),
direction(),
+3 -2
View File
@@ -385,8 +385,9 @@ pub fn specs() -> Vec<EffectSpec> {
],
cineon,
),
// `lut`: a path to a `.cube` file, or the `.cube` text itself (set via commands/automation).
spec("ec.utility.applylut", "Apply Color LUT", vec![p("lut", "LUT", Value::Str(String::new()), ParamUi::Hidden)], apply_lut),
// `lut`: a path to a `.cube` file (Effect Controls' Choose…, asked for when the effect is
// applied, as in After Effects), or the `.cube` text itself (commands/automation).
spec("ec.utility.applylut", "Apply Color LUT", vec![p("lut", "LUT", Value::Str(String::new()), crate::file_ui(&["cube"]))], apply_lut),
]
}
+21
View File
@@ -265,6 +265,27 @@ mod tests {
assert!(s.execute("layer.align", json!({"edge": "diagonal", "layers": [a]})).is_err());
}
/// #352: shape layers align and snap by their visual edges (half the stroke, no
/// anti-aliasing padding), so two rectangles lined up edge to edge touch with no gap.
#[test]
fn shape_layers_align_by_their_visual_edges() {
let mut s = session();
let shape = |s: &mut Session, extra: Value| {
let mut p = json!({"kind": "rect", "size": [100, 100], "fill": "#ff0000"});
p.as_object_mut().unwrap().extend(extra.as_object().unwrap().clone());
s.execute("layer.newShape", p).unwrap()["layer"].as_u64().unwrap()
};
let a = shape(&mut s, json!({}));
let b = shape(&mut s, json!({"stroke": "#ffffff", "strokeWidth": 10}));
let width = |s: &Session, id| {
let r = bounds(s, id);
r[2] - r[0]
};
assert!((width(&s, a) - 100.0).abs() < 1e-6 && (width(&s, b) - 110.0).abs() < 1e-6, "{} {}", width(&s, a), width(&s, b));
s.execute("layer.align", json!({"edge": "left", "layers": [a, b]})).unwrap();
assert!(bounds(&s, a)[0].abs() < 1e-6 && bounds(&s, b)[0].abs() < 1e-6);
}
#[test]
fn align_honours_parent_scale_and_rotation() {
let mut s = session();
+1 -1
View File
@@ -105,7 +105,7 @@ fn paste(s: &mut Session, p: &Value) -> Result<Value> {
if plan.is_empty() {
return Err(bad("keys.paste", "no compatible property to paste into (select a property of the same type)"));
}
s.edit("Paste Keyframes", None, |proj, st| {
s.edit("Paste Keyframes", merge_p(p), |proj, st| {
let comp = proj.comp_mut(cid).ok_or(EngineError::NoComp)?;
let mut sel = vec![];
for (lid, uid, ci) in &plan {
+14 -2
View File
@@ -328,7 +328,10 @@ pub fn system_report(s: &Session) -> Value {
let budgets = s.prefs.cache_budgets(mem.or(s.sys_memory));
let mut issues = vec![];
if gpu.is_none() {
issues.push("No GPU compositor: rendering uses the CPU (all features still work).".to_string());
issues.push(match &s.accel_note {
Some(why) => format!("No GPU compositor ({why}): rendering uses the CPU (all features still work)."),
None => "No GPU compositor: rendering uses the CPU (all features still work).".to_string(),
});
}
if mem.is_some_and(|m| m.total < 8 << 30) {
issues.push("Less than 8 GB of memory: use lower preview resolutions for long or large comps.".to_string());
@@ -344,6 +347,8 @@ pub fn system_report(s: &Session) -> Value {
"cores": crate::sysinfo::cpu_cores(),
"memory": mem.map(|m| json!({"total": gb(m.total), "available": gb(m.available)})),
"gpu": gpu,
"gpuNote": if gpu.is_none() { s.accel_note.clone() } else { None },
"windowGraphics": s.window_adapter,
"caches": {"layer": gb(budgets.layer as u64), "footage": gb(budgets.media as u64), "preview": gb(budgets.preview as u64)},
"import": IMPORT_FORMATS,
"export": export,
@@ -369,7 +374,14 @@ pub fn report_text(r: &Value) -> String {
Some(m) => t.push_str(&format!("Memory: {} ({} available)\n", m["total"].as_str().unwrap_or("?"), m["available"].as_str().unwrap_or("?"))),
None => t.push_str("Memory: unknown\n"),
}
t.push_str(&format!("GPU: {}\n", r.get("gpu").and_then(Value::as_str).unwrap_or("none (CPU compositing)")));
match (r.get("gpu").and_then(Value::as_str), r.get("gpuNote").and_then(Value::as_str)) {
(Some(gpu), _) => t.push_str(&format!("GPU: {gpu}\n")),
(None, Some(why)) => t.push_str(&format!("GPU: none (CPU compositing): {why}\n")),
(None, None) => t.push_str("GPU: none (CPU compositing)\n"),
}
if let Some(w) = r.get("windowGraphics").and_then(Value::as_str) {
t.push_str(&format!("Window graphics: {w}\n"));
}
let c = &r["caches"];
t.push_str(&format!(
"Cache budgets: layer {}, footage {}, preview {}\n",
+108 -2
View File
@@ -6,7 +6,7 @@ use effectcraft_time::Tick;
use serde_json::{Value, json};
use super::{CommandSpec, has_comp, has_layers, layers_p, match_path_of, selected_leaf_props, str_p};
use crate::{EngineError, LinkClip, Result, Session, cmd};
use crate::{EngineError, KeyRef, LinkClip, Result, Session, cmd};
fn can_undo(s: &Session) -> std::result::Result<(), String> {
if s.history.undo.is_empty() { Err("nothing to undo".into()) } else { Ok(()) }
@@ -25,6 +25,7 @@ fn has_clip(s: &Session) -> std::result::Result<(), String> {
&& s.state.effect_clipboard.is_empty()
&& s.state.contents_clipboard.is_empty()
&& s.state.link_clipboard.is_none()
&& s.state.value_clipboard.is_empty()
{
Err("the clipboard is empty".into())
} else {
@@ -210,14 +211,21 @@ fn clear_clipboards(s: &mut Session) {
s.state.effect_clipboard.clear();
s.state.contents_clipboard.clear();
s.state.link_clipboard = None;
s.state.value_clipboard.clear();
s.state.clip_is_keys = false;
}
/// Edit ▸ Copy: selected keyframes (when any) go to the keyframe clipboard, else layers.
/// Edit ▸ Copy: selected properties copy their keyframes and values, selected keyframes go
/// to the keyframe clipboard, else layers.
fn copy(s: &mut Session, p: &Value) -> Result<Value> {
if s.state.text_edit.is_some() && p.get("layers").is_none() {
return super::text_edit::copy(s);
}
if p.get("layers").is_none()
&& let Some(r) = copy_properties(s)?
{
return Ok(r);
}
// Keyframes selected → copy keys (pasted at the CTI).
if !s.state.selected_keys.is_empty() && p.get("layers").is_none() {
s.state.link_clipboard = None;
@@ -247,6 +255,101 @@ fn copy(s: &mut Session, p: &Value) -> Result<Value> {
Ok(json!(s.state.clipboard.len()))
}
/// Edit ▸ Copy with properties selected (Position, or the Transform group), as in After
/// Effects: an animated property copies its keyframes (the selected ones, else all of them) and
/// an unanimated one its value, for Paste onto the same properties of other layers (#493).
/// `None` when no property (or Transform group) is selected.
fn copy_properties(s: &mut Session) -> Result<Option<Value>> {
let Some(comp) = s.active_comp() else { return Ok(None) };
let mut leaves = vec![];
for (lid, uid) in &s.state.selected_props {
let Some(l) = comp.layer(*lid) else { continue };
if l.props.find(*uid).is_some() {
leaves.push((*lid, *uid));
} else if let Some(g) = l.props.find_group(*uid)
&& l.props.match_path_of(*uid).as_deref() == Some("transform")
{
g.walk("", &mut |_, pr| leaves.push((*lid, pr.uid)));
}
}
if leaves.is_empty() {
return Ok(None);
}
let mut keys = s.state.selected_keys.clone();
let mut values = vec![];
for (lid, uid) in &leaves {
let Some(l) = comp.layer(*lid) else { continue };
let Some(pr) = l.props.find(*uid) else { continue };
if pr.keys.is_empty() {
if let Some(path) = l.props.match_path_of(*uid)
&& !values.iter().any(|(p, _)| *p == path)
{
values.push((path, pr.value.clone()));
}
} else if !keys.iter().any(|k| k.layer == *lid && k.prop == *uid) {
keys.extend(pr.keys.iter().map(|k| KeyRef { layer: *lid, prop: *uid, time: k.time }));
}
}
clear_clipboards(s);
let mut n = 0;
if !keys.is_empty() {
let selected = std::mem::replace(&mut s.state.selected_keys, keys);
let r = s.execute("keys.copy", json!({}));
s.state.selected_keys = selected;
n = r?.as_u64().unwrap_or(0);
}
let v = values.len();
s.state.value_clipboard = values;
// Keys alone answer as `keys.copy` does (their count).
Ok(Some(if v == 0 { json!(n) } else { json!({"keys": n, "values": v}) }))
}
/// Paste copied property values (and keyframes, at the CTI) onto the same properties of the
/// selected layers, as one undo step.
fn paste_properties(s: &mut Session, p: &Value) -> Result<Value> {
let cid = s.active_comp_id().ok_or(EngineError::NoComp)?;
let ids = s.state.selected_layers.clone();
if ids.is_empty() {
return Err(EngineError::Other("select a layer to paste into".into()));
}
let values = s.state.value_clipboard.clone();
let keys = s.state.clip_is_keys && !s.state.key_clipboard.is_empty();
let t = s.time();
// The values and the keys are one step: the keys' paste merges into the values' step.
let merge = "edit.paste.properties";
s.history.merge_key = None;
let n = s.edit("Paste", keys.then_some(merge), |proj, _| {
let mut n = 0;
for lid in &ids {
let l = super::layer_mut(proj, cid, *lid)?;
if l.switches.locked {
continue;
}
let lt = l.layer_time(t);
for (path, v) in &values {
if let Some(pr) = l.props.prop_mut(path)
&& let Some(v) = pr.value.coerce_json(&v.to_json())
{
pr.set_value_at(lt, v);
n += 1;
}
}
}
Ok(n)
})?;
let mut r = json!({"values": n});
if keys {
let mut kp = p.clone();
if let Some(o) = kp.as_object_mut() {
o.insert("merge".into(), json!(merge));
}
let pasted = s.execute("keys.paste", kp);
s.history.merge_key = None;
r["keys"] = pasted?;
}
Ok(r)
}
/// The expression accessor for a property, e.g. `comp("Main").layer("Solid").transform("Position")`.
pub(crate) fn link_expression(s: &Session, lid: LayerId, uid: effectcraft_project::Uid, relative: bool) -> Option<String> {
let cid = s.active_comp_id()?;
@@ -392,6 +495,9 @@ fn paste(s: &mut Session, p: &Value) -> Result<Value> {
if let Some(clip) = s.state.link_clipboard.clone() {
return paste_links(s, clip);
}
if !s.state.value_clipboard.is_empty() {
return paste_properties(s, p);
}
if s.state.clip_is_keys && !s.state.key_clipboard.is_empty() {
return s.execute("keys.paste", p.clone());
}
+11 -3
View File
@@ -376,7 +376,7 @@ fn prepare_import(s: &mut Session, p: &Value) -> Result<PendingImport> {
}
// Numbered stills as image sequences (the Import dialog's "<format> Sequence" and Force
// Alphabetical Order), at `frameRate` or Settings ▸ Import ▸ Sequence Footage.
let opts = SequenceOptions::from_params(p);
let opts = SequenceOptions::for_import(p);
let fps = match p.get("frameRate") {
None | Some(Value::Null) => s.prefs.import.sequence_fps,
Some(v) => v.as_f64().filter(|x| *x > 0.0 && *x <= 999.0).ok_or_else(|| bad("file.import", "frameRate: frames per second, up to 999"))?,
@@ -577,6 +577,14 @@ fn project_settings(s: &mut Session, p: &Value) -> Result<Value> {
),
};
}
if let Some(g) = p.get("workingGamma") {
// After Effects takes 2.2 or 2.4 only (`app.project.workingGamma`).
proj.settings.working_gamma = match g.as_f64() {
Some(g) if (g - 2.2).abs() < 1e-6 => 2.2,
Some(g) if (g - 2.4).abs() < 1e-6 => 2.4,
_ => return Err(bad(cmd, format!("workingGamma: 2.2|2.4, not `{g}`"))),
};
}
if proj.settings.color_engine == ColorEngine::Ocio && !proj.settings.working_space.is_some_and(|w| w.is_linear()) {
return Err(bad(cmd, "the OCIO built-in config's working spaces are acescg and aces2065"));
}
@@ -680,7 +688,7 @@ pub fn specs() -> Vec<CommandSpec> {
"File...",
["File", "Import"],
Some("Cmd+I"),
"{paths: [string] (files or folders), sequence?: bool (default true: numbered stills of one run import as one image sequence; one picked file brings its whole run, several picked files that range), alphabetical?: bool (Force Alphabetical Order: every image of that type in the folder, by name), frameRate?: fps (image sequences; default Settings ▸ Import ▸ Sequence Footage), importAs?: footage|composition|compositionLayerSizes (Photoshop, PDF, Illustrator and EPS files), layer?: name|index (footage of one Photoshop layer), page?: number from 1 (PDF / Illustrator page), drag?: bool (dropped files: Settings ▸ Import ▸ Default Drag Import As), addToComp?: bool (also add them to the active comp, at time?, index?, position? as in layer.addItem), background?: bool (probe the files in a background job, jobs.list / jobs.wait; returns {job})}",
"{paths: [string] (files or folders), sequence?: bool (the Sequence checkbox: true imports numbered stills of one run as one image sequence, one picked file bringing its whole run and several picked files that range; default: a numbered still brings its run, except dropped files (drag), which import as stills as in After Effects; folders' runs import as sequences; false imports stills and a folder's files individually), alphabetical?: bool (Force Alphabetical Order: every image of that type in the folder, by name), frameRate?: fps (image sequences; default Settings ▸ Import ▸ Sequence Footage), importAs?: footage|composition|compositionLayerSizes (Photoshop, PDF, Illustrator and EPS files), layer?: name|index (footage of one Photoshop layer), page?: number from 1 (PDF / Illustrator page), drag?: bool (dropped files: Settings ▸ Import ▸ Default Drag Import As; files import as stills unless sequence), addToComp?: bool (also add them to the active comp, at time?, index?, position? as in layer.addItem), background?: bool (probe the files in a background job, jobs.list / jobs.wait; returns {job})}",
always,
import_cmd
),
@@ -689,7 +697,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Project Settings...",
["File"],
Some("Cmd+Alt+Shift+K"),
"{bitDepth?: 8|16|32, colorEngine?: adobe|ocio, workingSpace?: none|srgb|rec709|rec2020|p3|acescg|aces2065, linearize?, blendLinear?, hdr?: clip|compand|toneMap, outputSpace?: srgb|rec709|rec2020|p3|rec2100pq|rec2100hlg, renderer?: gpu|software, timeDisplay?: timecode|frames|feet35|feet16}",
"{bitDepth?: 8|16|32, colorEngine?: adobe|ocio, workingSpace?: none|srgb|rec709|rec2020|p3|acescg|aces2065, workingGamma?: 2.2|2.4 (with no working space), linearize?, blendLinear?, hdr?: clip|compand|toneMap, outputSpace?: srgb|rec709|rec2020|p3|rec2100pq|rec2100hlg, renderer?: gpu|software, timeDisplay?: timecode|frames|feet35|feet16}",
always,
project_settings
),
+4 -1
View File
@@ -465,6 +465,8 @@ fn run_script(s: &mut Session, p: &Value) -> Result<Value> {
}
_ => return Err(bad("file.runScript", "missing `path` (or inline `steps`)")),
};
// A command script read from a file is a script: it can't change what scripts may do.
let from_file = p.get("steps").is_none();
let mut results = vec![];
for (i, step) in steps.iter().enumerate() {
let (id, params) = match (step.get("command").or(step.get("id")).and_then(Value::as_str), step.get("method").and_then(Value::as_str)) {
@@ -478,7 +480,8 @@ fn run_script(s: &mut Session, p: &Value) -> Result<Value> {
if id == "file.runScript" || id == "script.run" {
return Err(bad("file.runScript", "scripts can't run scripts"));
}
let r = s.execute(&id, params).map_err(|e| EngineError::Other(format!("step {} ({id}): {e}", i + 1)))?;
let r = if from_file { s.execute_for_script(&id, params) } else { s.execute(&id, params) };
let r = r.map_err(|e| EngineError::Other(format!("step {} ({id}): {e}", i + 1)))?;
results.push(r);
}
Ok(json!({"steps": results.len(), "results": results}))
+5 -2
View File
@@ -105,7 +105,10 @@ fn browser_import(s: &mut Session, p: &Value) -> Result<Value> {
return Ok(json!({"pending": true, "paths": list}));
}
let target = s.active_comp_id();
let mut r = s.execute("file.import", json!({"paths": paths}))?;
// A numbered still picked in the browser still brings its image sequence (`sequence: false`
// for the picked stills alone); `file.import` alone imports stills.
let sequence = p.get("sequence").and_then(Value::as_bool).unwrap_or(true);
let mut r = s.execute("file.import", json!({"paths": paths, "sequence": sequence}))?;
// Dragged into the Timeline or the Composition viewer: into the comp, where they were dropped.
let errors = super::file::add_to_comp(s, &r, target, p);
if !errors.is_empty()
@@ -236,7 +239,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Import",
[],
None,
"{paths, addToComp?, time?, index?, position? (where the layers go, as in layer.addItem)}",
"{paths, sequence? (default true: a numbered still brings its image sequence), addToComp?, time?, index?, position? (where the layers go, as in layer.addItem)}",
browser_enabled,
browser_import
),
+26 -6
View File
@@ -21,6 +21,10 @@ use serde_json::{Value, json};
use super::{CommandSpec, bad, f_p, has_comp, layer_mut, layer_p, merge_p, str_p};
use crate::{EngineError, Result, Session, cmd};
fn dist2(a: [f64; 2], b: [f64; 2]) -> f64 {
(a[0] - b[0]).powi(2) + (a[1] - b[1]).powi(2)
}
fn pt(v: Option<&Value>) -> Option<[f64; 2]> {
let a = v?.as_array()?;
Some([a.first()?.as_f64()?, a.get(1)?.as_f64()?])
@@ -77,7 +81,8 @@ fn add_pin(s: &mut Session, p: &Value) -> Result<Value> {
let fx_uid = find_puppet(s, cid, lid);
let want_mesh = p.get("mesh").and_then(Value::as_u64);
let new_mesh = p.get("newMesh").and_then(Value::as_bool).unwrap_or(false);
let mut target: Option<(Uid, [f64; 2])> = None;
// (mesh, the pin's rest point, where it is now)
let mut target: Option<(Uid, [f64; 2], [f64; 2])> = None;
if let Some(fu) = fx_uid
&& !new_mesh
&& let Some((buf, params)) = puppet_eval(s, cid, lid, fu, t)
@@ -90,7 +95,22 @@ fn add_pin(s: &mut Session, p: &Value) -> Result<Value> {
let inside = puppet::contains(mesh, def, at);
let near = def.iter().map(|v| (v[0] - at[0]).hypot(v[1] - at[1])).fold(f64::MAX, f64::min) < 12.0;
if inside || near || want_mesh.is_some() {
target = Some((*uid, puppet::unmap(mesh, def, at)));
let rest = puppet::unmap(mesh, def, at);
// A moving pin holds its nearest mesh vertex at `position + (vertex - rest)`
// (puppet::solve). Choose the position that keeps that vertex where it is now, so
// adding a pin to a bent mesh moves nothing (After Effects, #318); on an unbent
// mesh that is the click itself.
let now = (0..mesh.verts.len().min(def.len()))
.filter(|_| kind.moves())
.min_by(|a, b| dist2(mesh.verts[*a], rest).total_cmp(&dist2(mesh.verts[*b], rest)))
.and_then(|v| {
let (r, d) = (mesh.verts.get(v)?, def.get(v)?);
Some([d[0] - (r[0] - rest[0]), d[1] - (r[1] - rest[1])])
})
// Round-off only (an unbent mesh): exactly the click.
.filter(|n| dist2(*n, at) > 1e-12)
.unwrap_or(at);
target = Some((*uid, rest, now));
break;
}
}
@@ -131,14 +151,14 @@ fn add_pin(s: &mut Session, p: &Value) -> Result<Value> {
}
};
let pg = fxg.find_group_mut(fx_uid).ok_or_else(|| bad(cmd, "Puppet effect gone"))?;
let (mesh_uid, rest) = match target {
Some((m, r)) => (m, r),
let (mesh_uid, rest, now) = match target {
Some(t) => t,
None => {
let n = puppet::meshes(pg).count();
let g = puppet::mesh_group(&mut ids, &format!("Mesh {}", n + 1), at, &opts);
let u = g.uid;
pg.children.push(g.into());
(u, at)
(u, at, at)
}
};
let name = puppet::next_pin_name(pg, kind);
@@ -146,7 +166,7 @@ fn add_pin(s: &mut Session, p: &Value) -> Result<Value> {
let mut pin = puppet::pin_group(&mut ids, &name, kind, rest);
if let Some(pos) = pin.get_mut("position") {
if kind.moves() {
pos.value = KV::Vec2(at);
pos.value = KV::Vec2(now);
pos.set_animated(true, lt);
} else {
pos.value = KV::Vec2(rest);
+12
View File
@@ -9,6 +9,10 @@ use crate::{EngineError, Result, Session, cmd, query};
// ---------------------------------------------------------------- prefs
/// Why a script can't change Application Scripting settings.
const SCRIPT_PERMISSIONS: &str =
"Scripts can't change what scripts are allowed to do. Change it yourself in Settings ▸ Scripting & Expressions ▸ Application Scripting.";
fn prefs_get(s: &mut Session, p: &Value) -> Result<Value> {
let key = str_p(p, "key").unwrap_or("");
s.prefs.get(key).ok_or_else(|| bad("prefs.get", format!("unknown setting `{key}`")))
@@ -30,6 +34,9 @@ fn prefs_set(s: &mut Session, p: &Value) -> Result<Value> {
for (k, v) in &pairs {
next.set(k, v.clone()).map_err(|e| bad("prefs.set", e))?;
}
if s.running_for_script() && !next.scripting.same_permissions(&s.prefs.scripting) {
return Err(EngineError::Other(SCRIPT_PERMISSIONS.into()));
}
s.prefs = next;
s.prefs_changed();
s.save_prefs();
@@ -39,7 +46,12 @@ fn prefs_set(s: &mut Session, p: &Value) -> Result<Value> {
fn prefs_reset(s: &mut Session, p: &Value) -> Result<Value> {
let page = str_p(p, "page");
let before = s.prefs.scripting.clone();
s.prefs.reset(page).map_err(|e| bad("prefs.reset", e))?;
// A script's reset leaves what scripts may do as the user set it.
if s.running_for_script() {
s.prefs.scripting.keep_permissions(&before);
}
s.prefs_changed();
s.save_prefs();
s.toast(match page {
+33
View File
@@ -216,6 +216,10 @@ pub struct EditorState {
/// The last copy was keyframes (Paste pastes keys at the CTI).
#[serde(skip)]
pub clip_is_keys: bool,
/// Values of selected unanimated properties (match path, value), copied with Edit ▸ Copy;
/// Paste sets the same properties of the selected layers.
#[serde(skip)]
pub value_clipboard: Vec<(String, effectcraft_keyframe::Value)>,
/// Copied effect instances (Edit ▸ Copy with effects selected); Paste adds them to the
/// selected layers.
#[serde(skip)]
@@ -368,6 +372,9 @@ pub struct Session {
/// Why there is no [`Session::accel`] (`render.backend`'s `why`): the host's reason, e.g. a
/// headless start without `--gpu` or the compositor's setup error.
pub accel_note: Option<String>,
/// The graphics adapter and backend the window draws with (`NVIDIA GeForce RTX 4070
/// (Vulkan)`), as the host reports it: the System Compatibility Report shows it.
pub window_adapter: Option<String>,
/// Expression syntax checker (set by the host that links the expression engine).
pub expr_check: Option<fn(&str) -> std::result::Result<(), String>>,
pub importer: Option<Arc<dyn Importer>>,
@@ -472,8 +479,16 @@ pub struct Session {
pub footage_stamps: std::collections::HashMap<String, footage_reload::Stamp>,
/// The stamps being taken on a worker thread ([`footage_reload::start_scan`]).
pub footage_scan: Option<footage_reload::Scan>,
/// How many script-issued commands are running ([`Session::execute_for_script`]): while one
/// is, settings that guard what scripts may do can't change (see
/// [`prefs::Scripting::same_permissions`]).
pub script_calls: ScriptCalls,
}
/// The count behind [`Session::running_for_script`]; only the engine changes it.
#[derive(Clone, Copy, Debug, Default)]
pub struct ScriptCalls(u32);
/// A thumbnail render ([`Session::thumbnail_job`]): (width, height, RGBA8).
pub type ThumbnailJob = Box<dyn FnOnce() -> Option<(u32, u32, Vec<u8>)> + Send>;
@@ -511,6 +526,7 @@ impl Default for Session {
expr: None,
accel: None,
accel_note: None,
window_adapter: None,
expr_check: None,
importer: None,
exporter: None,
@@ -559,6 +575,7 @@ impl Default for Session {
check_footage_on_open: false,
footage_stamps: Default::default(),
footage_scan: None,
script_calls: ScriptCalls::default(),
}
}
}
@@ -601,6 +618,22 @@ impl Session {
self.execute(id, params)
}
/// [`Session::execute`] for a command a script asked for (the JavaScript engine's
/// `app.run`, ScriptUI callbacks…). The command and everything it runs count as the script's:
/// it may not change the settings that limit scripts (Settings ▸ Scripting & Expressions ▸
/// Application Scripting), which only the user can, from the UI, CLI or control channel.
pub fn execute_for_script(&mut self, id: &str, params: Value) -> Result<Value> {
self.script_calls.0 = self.script_calls.0.saturating_add(1);
let r = self.execute(id, params);
self.script_calls.0 = self.script_calls.0.saturating_sub(1);
r
}
/// Whether the command running was issued by a script ([`Session::execute_for_script`]).
pub fn running_for_script(&self) -> bool {
self.script_calls.0 > 0
}
pub fn is_enabled(&self, id: &str) -> bool {
commands::find(id).is_some_and(|c| (c.enabled)(self).is_ok())
}
+19
View File
@@ -309,6 +309,25 @@ page!(Scripting {
error_banner: bool = true,
});
impl Scripting {
/// Whether `other` grants scripts the same as this: Application Scripting (file and network
/// access, the warning before executing files, the debugger). Scripts can't change these
/// ([`crate::Session::execute_for_script`]): a script could otherwise give itself the
/// access the user withheld.
pub fn same_permissions(&self, other: &Scripting) -> bool {
self.allow_scripts_write_files == other.allow_scripts_write_files
&& self.warn_executing_files == other.warn_executing_files
&& self.enable_js_debugger == other.enable_js_debugger
}
/// Take `from`'s Application Scripting settings (see [`Scripting::same_permissions`]).
pub fn keep_permissions(&mut self, from: &Scripting) {
self.allow_scripts_write_files = from.allow_scripts_write_files;
self.warn_executing_files = from.warn_executing_files;
self.enable_js_debugger = from.enable_js_debugger;
}
}
/// One editable label: name and `#rrggbb` colour.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct LabelPref {
+54 -17
View File
@@ -20,15 +20,17 @@ use crate::{Importer, Services};
/// How an import treats numbered stills.
#[derive(Clone, Copy, Debug)]
pub struct SequenceOptions {
/// The "<format> Sequence" checkbox: numbered stills of one run import as a sequence.
/// The "<format> Sequence" checkbox: picked numbered stills of one run import as a sequence.
pub sequence: bool,
/// The numbered stills inside a picked or dropped folder import as sequences.
pub folders: bool,
/// Force Alphabetical Order.
pub alphabetical: bool,
}
impl Default for SequenceOptions {
fn default() -> Self {
SequenceOptions { sequence: true, alphabetical: false }
SequenceOptions { sequence: true, folders: true, alphabetical: false }
}
}
@@ -36,7 +38,21 @@ impl SequenceOptions {
/// From command parameters `sequence?` (default on) and `alphabetical?`.
pub fn from_params(p: &serde_json::Value) -> SequenceOptions {
let flag = |k: &str| p.get(k).and_then(serde_json::Value::as_bool);
SequenceOptions { sequence: flag("sequence").unwrap_or(true), alphabetical: flag("alphabetical").unwrap_or(false) }
let sequence = flag("sequence").unwrap_or(true);
SequenceOptions { sequence, folders: sequence, alphabetical: flag("alphabetical").unwrap_or(false) }
}
/// `file.import`'s options. Dropped files (`drag: true`) import as stills, as in After
/// Effects, while a folder's numbered stills import as sequences unless `sequence: false`
/// (Alt-drag of a folder). The Import dialog passes its "<format> Sequence" checkbox (off
/// unless ticked) as `sequence`. Without `sequence` or `drag` (scripts, agents) a numbered
/// still brings its run, as it always has.
pub fn for_import(p: &serde_json::Value) -> SequenceOptions {
let flag = |k: &str| p.get(k).and_then(serde_json::Value::as_bool);
let (sequence, alphabetical) = (flag("sequence"), flag("alphabetical").unwrap_or(false));
let dropped = flag("drag").unwrap_or(false);
// Force Alphabetical Order is part of the Sequence option.
SequenceOptions { sequence: sequence.unwrap_or(alphabetical || !dropped), folders: sequence.unwrap_or(true), alphabetical }
}
}
@@ -120,28 +136,27 @@ fn run_files(services: &dyn Services, path: &str, alphabetical: bool) -> Vec<Str
}
/// What importing `paths` makes, in the order picked: folders become their importable files,
/// and (with the Sequence option) numbered stills of one run one [`Source::Sequence`].
/// and (with the Sequence option, or [`SequenceOptions::folders`] for a folder's files) numbered
/// stills of one run one [`Source::Sequence`].
pub fn group(services: &dyn Services, paths: &[String], opts: SequenceOptions) -> Vec<Source> {
let mut files: Vec<String> = vec![];
// Each file, and whether it may join a sequence.
let mut files: Vec<(String, bool)> = vec![];
for p in paths {
if services.is_dir(p) {
let mut inside: Vec<String> =
services.list_dir(p).into_iter().filter(|f| matches!(crate::media_browser::kind_of(f), Some(k) if k != "project")).collect();
inside.sort_by_key(|f| file_name(f).to_lowercase());
files.extend(inside);
files.extend(inside.into_iter().map(|f| (f, opts.folders)));
} else {
files.push(p.clone());
files.push((p.clone(), opts.sequence));
}
}
if !opts.sequence {
return files.into_iter().map(Source::File).collect();
}
// Runs in the order their first file was picked.
let mut runs: Vec<((String, String, String), Vec<String>)> = vec![];
let mut out: Vec<Option<Source>> = vec![];
let mut slot: Vec<usize> = vec![];
for f in files {
match run_key(&f, opts.alphabetical) {
for (f, joins) in files {
match run_key(&f, opts.alphabetical).filter(|_| joins) {
Some(key) => match runs.iter_mut().find(|(k, _)| *k == key) {
Some((_, run)) => run.push(f),
None => {
@@ -155,8 +170,10 @@ pub fn group(services: &dyn Services, paths: &[String], opts: SequenceOptions) -
}
for ((_, mut picked), at) in runs.into_iter().zip(slot) {
sort_run(&mut picked, opts.alphabetical);
// One file: its whole run from the folder.
if let [one] = picked.as_slice() {
// One picked file: its whole run from the folder.
if let [one] = picked.as_slice()
&& opts.sequence
{
let all = run_files(services, one, opts.alphabetical);
if all.len() >= 2 {
picked = all;
@@ -176,7 +193,7 @@ pub fn group(services: &dyn Services, paths: &[String], opts: SequenceOptions) -
/// The image sequences importing `paths` would make (the Import dialog asks about them).
pub fn sequences(services: &dyn Services, paths: &[String], alphabetical: bool) -> Vec<Vec<String>> {
group(services, paths, SequenceOptions { sequence: true, alphabetical })
group(services, paths, SequenceOptions { sequence: true, folders: true, alphabetical })
.into_iter()
.filter_map(|s| match s {
Source::Sequence { files, .. } => Some(files),
@@ -282,7 +299,10 @@ mod tests {
]
);
// Sequence off: every file on its own.
assert_eq!(group(&t, &strs(&["/s/a_0001.png"]), SequenceOptions { sequence: false, alphabetical: false }), [Source::File("/s/a_0001.png".into())]);
assert_eq!(
group(&t, &strs(&["/s/a_0001.png"]), SequenceOptions { sequence: false, folders: false, alphabetical: false }),
[Source::File("/s/a_0001.png".into())]
);
// A folder: its media files, runs as sequences.
let g = group(&t, &strs(&["/s"]), on);
assert_eq!(g.len(), 4, "{g:?}");
@@ -290,12 +310,29 @@ mod tests {
assert_eq!(g[0].name(), "a_[0001-0010].png");
assert!(!g.iter().any(|s| s.path().ends_with(".txt")));
// Force Alphabetical Order: every PNG in the folder, by name.
let a = group(&t, &strs(&["/s/c.png"]), SequenceOptions { sequence: true, alphabetical: true });
let a = group(&t, &strs(&["/s/c.png"]), SequenceOptions { sequence: true, folders: true, alphabetical: true });
assert_eq!(a, [Source::Sequence { files: strs(&["/s/a_0001.png", "/s/a_0002.png", "/s/a_0010.png", "/s/b_1.png", "/s/c.png"]), alphabetical: true }]);
assert_eq!(a[0].name(), "a_0001.png");
assert_eq!(sequences(&t, &strs(&["/s/a_0001.png", "/s/c.png"]), false).len(), 1);
}
/// #431: `file.import` brings picked or dropped numbered stills in as stills; only a folder or
/// an explicit `sequence: true` makes a sequence, as After Effects' Sequence checkbox does.
#[test]
fn import_takes_stills_unless_asked_for_a_sequence() {
let t = Table(vec!["/s/a_0001.png", "/s/a_0002.png", "/s/a_0003.png", "/s/IMG_0001.jpg", "/s/IMG_0042.jpg"]);
let opts = |p: serde_json::Value| SequenceOptions::for_import(&p);
let none = opts(serde_json::json!({"drag": true}));
assert_eq!(group(&t, &strs(&["/s/a_0002.png"]), none), [Source::File("/s/a_0002.png".into())]);
assert_eq!(group(&t, &strs(&["/s/IMG_0001.jpg", "/s/IMG_0042.jpg"]), none).len(), 2);
// A dropped folder: its runs as sequences (Alt-drag: `sequence: false`, files on their own).
assert_eq!(group(&t, &strs(&["/s"]), none).len(), 2);
assert_eq!(group(&t, &strs(&["/s"]), opts(serde_json::json!({"sequence": false}))).len(), 5);
// The Sequence checkbox: one picked file brings its whole run.
let on = group(&t, &strs(&["/s/a_0002.png"]), opts(serde_json::json!({"sequence": true})));
assert!(matches!(on.as_slice(), [Source::Sequence { files, .. }] if files.len() == 3), "{on:?}");
}
#[test]
fn windows_paths_and_reports() {
let t = Table(vec!["C:\\r\\f_01.exr", "C:\\r\\f_02.exr", "C:\\r\\f_05.exr"]);
+9
View File
@@ -455,6 +455,15 @@ fn compatibility_report_and_logging() {
}
let text = crate::commands::report_text_for_tests(&r);
assert!(text.contains("Operating system") && text.contains("Import:"));
// #596: without a GPU compositor the report says why, and names the window's adapter.
s.accel_note = Some("GPU compositor unavailable: no storage textures".into());
s.window_adapter = Some("NVIDIA GeForce RTX 4070 (Gl, DiscreteGpu)".into());
let r = s.execute("help.systemReport", json!({"quiet": true})).unwrap();
assert_eq!(r["gpuNote"], "GPU compositor unavailable: no storage textures");
let text = crate::commands::report_text_for_tests(&r);
assert!(text.contains("GPU: none (CPU compositing): GPU compositor unavailable: no storage textures"), "{text}");
assert!(text.contains("Window graphics: NVIDIA GeForce RTX 4070 (Gl, DiscreteGpu)"), "{text}");
assert!(r["issues"][0].as_str().is_some_and(|i| i.contains("no storage textures")), "{r}");
s.drain_events();
s.execute("help.systemReport", json!({})).unwrap();
assert!(s.drain_events().iter().any(|e| matches!(e, crate::Event::Frontend { command, .. } if command == "app.showReport")));
+47
View File
@@ -346,6 +346,53 @@ fn keyframe_clipboard_paste_and_paste_reversed() {
assert_eq!((keys[0].value.as_f64(), keys[1].value.as_f64()), (0.0, 100.0));
}
/// #493: Edit ▸ Copy with a property selected copies that property (its value when it isn't
/// animated, its keys when it is), and Paste sets the same property of the selected layer, as
/// in After Effects, instead of copying the whole layer.
#[test]
fn copy_a_selected_property_pastes_onto_the_same_property() {
let mut s = comp();
let a = solid(&mut s, "#ff0000");
let b = solid(&mut s, "#00ff00");
let n_layers = s.active_comp().unwrap().layers.len();
s.execute("prop.set", json!({"layer": a, "path": "transform/position", "value": [10, 20]})).unwrap();
s.execute("prop.set", json!({"layer": a, "path": "transform/rotation", "value": 30})).unwrap();
s.execute("prop.select", json!({"layer": a, "path": "transform/position"})).unwrap();
s.execute("edit.copy", json!({})).unwrap();
assert!(s.state.clipboard.is_empty(), "the layer isn't copied");
s.execute("layer.select", json!({"layers": [b]})).unwrap();
let undo = s.history.undo.len();
s.execute("edit.paste", json!({})).unwrap();
assert_eq!(s.active_comp().unwrap().layers.len(), n_layers);
assert_eq!(layer(&s, b).props.prop("transform/position").unwrap().value.as_vec3()[..2], [10.0, 20.0]);
assert_eq!(layer(&s, b).props.prop("transform/rotation").unwrap().value.as_f64(), 0.0, "only the copied property");
s.execute("edit.undo", json!({})).unwrap();
assert_ne!(layer(&s, b).props.prop("transform/position").unwrap().value.as_vec3()[..2], [10.0, 20.0]);
assert_eq!(s.history.undo.len(), undo);
// The Transform group with an animated Opacity: its keys (at the CTI) and the other values,
// in one undo step.
s.execute("prop.toggleAnimation", json!({"layer": a, "path": "transform/opacity"})).unwrap();
s.execute("time.set", json!({"time": 1.0})).unwrap();
s.execute("prop.set", json!({"layer": a, "path": "transform/opacity", "value": 0})).unwrap();
let transform = layer(&s, a).props.sub("transform").unwrap().uid;
s.execute("prop.select", json!({"layer": a, "prop": transform})).unwrap();
s.execute("edit.copy", json!({})).unwrap();
s.execute("layer.select", json!({"layers": [b]})).unwrap();
s.execute("time.set", json!({"time": 2.0})).unwrap();
let undo = s.history.undo.len();
s.execute("edit.paste", json!({})).unwrap();
assert_eq!(s.history.undo.len(), undo + 1);
let l = layer(&s, b);
let keys = &l.props.prop("transform/opacity").unwrap().keys;
assert_eq!(keys.len(), 2);
assert!((keys[0].time.seconds() - 2.0).abs() < 1e-6 && keys[1].value.as_f64() == 0.0);
assert_eq!(l.props.prop("transform/rotation").unwrap().value.as_f64(), 30.0);
assert_eq!(s.active_comp().unwrap().layers.len(), n_layers);
s.execute("edit.undo", json!({})).unwrap();
let l = layer(&s, b);
assert!(l.props.prop("transform/opacity").unwrap().keys.is_empty() && l.props.prop("transform/rotation").unwrap().value.as_f64() == 0.0);
}
#[test]
fn property_links_and_expression_only() {
let mut s = comp();
+22
View File
@@ -203,6 +203,28 @@ fn first_puppet_pin_leaves_the_layer_still() {
}
}
/// #318: a pin added to an already-bent mesh holds the mesh where it is: nothing jumps.
#[test]
fn a_pin_added_to_a_bent_mesh_moves_nothing() {
let (mut s, id) = setup();
s.execute("puppet.addPin", json!({"layer": id, "position": [5, 30]})).unwrap();
s.execute("puppet.addPin", json!({"layer": id, "position": [95, 30]})).unwrap();
s.set_time(Tick::from_seconds_f64(1.0));
s.execute("puppet.movePin", json!({"layer": id, "pin": "Puppet Pin 2", "position": [80, 70]})).unwrap();
let cid = s.active_comp_id().unwrap();
let t = Tick::from_seconds_f64(1.0);
let before = s.render(cid, t, RenderOpts::default());
for at in [[50, 54], [30, 42], [70, 62]] {
s.execute("puppet.addPin", json!({"layer": id, "position": at})).unwrap();
let after = s.render(cid, t, RenderOpts::default());
let moved = after.data.iter().zip(&before.data).filter(|(a, b)| (a[3] - b[3]).abs() > 0.25).count();
// (Before #318 the pin at [50, 54] moved about 250 edge pixels; the solver's round-off
// still touches a few.)
assert!(moved <= 16, "a pin at {at:?} moved {moved} pixels");
s.undo();
}
}
#[test]
fn puppet_pin_kinds_and_mesh_options() {
let (mut s, id) = setup();
+25 -5
View File
@@ -54,7 +54,7 @@ const RUN: [&str; 5] = ["shot_0001.png", "shot_0002.png", "shot_0004.png", "shot
fn one_picked_numbered_file_imports_its_run_as_one_sequence() {
let dir = folder(&RUN);
let mut s = session();
let r = s.execute("file.import", json!({"paths": [at(&dir, "shot_0002.png")]})).unwrap();
let r = s.execute("file.import", json!({"paths": [at(&dir, "shot_0002.png")], "sequence": true})).unwrap();
assert_eq!(r["items"].as_array().unwrap().len(), 1, "{r}");
let (name, f) = footage(&s, &r["items"][0]);
assert_eq!(name, "shot_[0001-0005].png");
@@ -75,17 +75,37 @@ fn picking_every_frame_imports_one_sequence() {
let dir = folder(&RUN);
let mut s = session();
let all: Vec<String> = RUN[..4].iter().map(|n| at(&dir, n)).collect();
let r = s.execute("file.import", json!({"paths": all, "frameRate": 24})).unwrap();
let r = s.execute("file.import", json!({"paths": all, "sequence": true, "frameRate": 24})).unwrap();
assert_eq!(r["items"].as_array().unwrap().len(), 1, "{r}");
let (_, f) = footage(&s, &r["items"][0]);
assert_eq!(f.sequence.len(), 4);
// `frameRate` sets the sequence's rate on import.
assert_eq!((f.frame_rate, f.duration), (FrameRate::FPS_24, FrameRate::FPS_24.tick_of(5)));
// A picked range is just that range.
let r = s.execute("file.import", json!({"paths": [at(&dir, "shot_0004.png"), at(&dir, "shot_0005.png")]})).unwrap();
let r = s.execute("file.import", json!({"paths": [at(&dir, "shot_0004.png"), at(&dir, "shot_0005.png")], "sequence": true})).unwrap();
let (name, f) = footage(&s, &r["items"][0]);
assert_eq!((name.as_str(), f.sequence.len()), ("shot_[0004-0005].png", 2));
assert!(s.execute("file.import", json!({"paths": all, "frameRate": 0})).is_err());
assert!(s.execute("file.import", json!({"paths": all, "sequence": true, "frameRate": 0})).is_err());
}
/// #431: without the Sequence option, picked or dropped numbered stills import as stills (one
/// item each), as in After Effects; a dropped folder still makes its runs sequences.
#[test]
fn numbered_stills_import_as_stills_by_default() {
let dir = folder(&RUN);
let mut s = session();
let r = s.execute("file.import", json!({"paths": [at(&dir, "shot_0002.png")], "drag": true})).unwrap();
let (name, f) = footage(&s, &r["items"][0]);
assert_eq!((name.as_str(), f.kind), ("shot_0002.png", FootageKind::Still));
let r = s.execute("file.import", json!({"paths": [at(&dir, "shot_0001.png"), at(&dir, "shot_0004.png")], "drag": true})).unwrap();
assert_eq!(r["items"].as_array().unwrap().len(), 2, "{r}");
let r = s.execute("file.import", json!({"paths": [dir.clone()], "drag": true})).unwrap();
let (name, f) = footage(&s, &r["items"][0]);
assert_eq!((name.as_str(), f.kind), ("shot_[0001-0005].png", FootageKind::Sequence));
// Scripts and agents naming one frame without `sequence` still get its run.
let r = s.execute("file.import", json!({"paths": [at(&dir, "shot_0003.png")]})).unwrap();
let (_, f) = footage(&s, &r["items"][0]);
assert_eq!(f.kind, FootageKind::Sequence);
}
#[test]
@@ -127,7 +147,7 @@ fn force_alphabetical_order_takes_every_image_of_the_type() {
fn interpret_footage_start_timecode_relabels_source_time() {
let dir = folder(&RUN);
let mut s = session();
let item = s.execute("file.import", json!({"paths": [at(&dir, "shot_0001.png")]})).unwrap()["items"][0].clone();
let item = s.execute("file.import", json!({"paths": [at(&dir, "shot_0001.png")], "sequence": true})).unwrap()["items"][0].clone();
s.execute("comp.new", json!({"name": "Main", "width": 64, "height": 32, "frameRate": 30, "duration": 10})).unwrap();
let layer = s.execute("layer.addItem", json!({"item": item})).unwrap()["layer"].as_u64().unwrap();
let out = |s: &Session| s.active_comp().unwrap().layer(effectcraft_project::LayerId(layer)).unwrap().out_point;
+12
View File
@@ -37,6 +37,18 @@ pub fn parse_manifest(json: &str) -> Vec<(String, u32)> {
v
}
/// Why an OpenEXR file's pixels can't be decoded when its headers name a compression this build
/// can't decompress (HTJ2K, OpenEXR 3.4's High-Throughput JPEG 2000): the decoders only said
/// "no non-deep rgb channels" or showed a transparent frame (#482). `None` for other files.
pub fn unsupported_compression(bytes: &[u8]) -> Option<&'static str> {
use exr::compression::Compression;
let meta = exr::meta::MetaData::read_from_buffered(std::io::Cursor::new(bytes), false).ok()?;
meta.headers
.iter()
.any(|h| matches!(h.compression, Compression::HTJ2K32 | Compression::HTJ2K256))
.then_some("OpenEXR HTJ2K compression isn't supported yet: re-save the file with ZIP, PIZ or DWAA compression")
}
/// Read all channels of an EXR file's bytes. `None` when it is not a readable EXR.
pub fn read_exr_channels(bytes: &[u8]) -> Option<AuxChannels> {
use exr::prelude::*;
+112 -27
View File
@@ -53,8 +53,10 @@ struct Entry {
}
/// Conformed audio files written by builds before this version are ignored (and written again):
/// version 1 files of sources longer than about 12:36 at 48 kHz were silent from there on (#172).
const CONFORM_VERSION: u32 = 2;
/// version 1 files of sources longer than about 12:36 at 48 kHz were silent from there on (#172);
/// version 2 files were written silent where the source failed to decode, and kept the footage
/// silent from then on (#324).
const CONFORM_VERSION: u32 = 3;
/// Footage longer than this (24 hours) is read from its decoder rather than conformed: its
/// duration comes from the file's header, and a damaged one claiming days of audio had the
@@ -200,6 +202,10 @@ struct Inner {
/// Conformed files being written right now.
#[cfg_attr(target_arch = "wasm32", allow(dead_code))]
conforming: Mutex<HashSet<std::path::PathBuf>>,
/// Conformed files that failed to write (the source didn't decode) this session: their
/// audio is read from the decoder instead of trying again on every read.
#[cfg_attr(target_arch = "wasm32", allow(dead_code))]
conform_failed: Mutex<HashSet<std::path::PathBuf>>,
}
/// Decodes footage for the renderer: stills, image sequences and movies (FilmCraft codecs), plus
@@ -266,6 +272,7 @@ impl MediaPool {
opened: AtomicU64::new(0),
conform: Mutex::default(),
conforming: Mutex::default(),
conform_failed: Mutex::default(),
}),
}
}
@@ -397,18 +404,23 @@ impl MediaPool {
/// thread (once).
#[cfg(not(target_arch = "wasm32"))]
fn conform(&self, footage: &Footage, file: std::path::PathBuf, frames: usize, rate: u32) {
if !lock(&self.inner.conforming).insert(file.clone()) {
if lock(&self.inner.conform_failed).contains(&file) || !lock(&self.inner.conforming).insert(file.clone()) {
return;
}
let (pool, f) = (self.clone(), footage.clone());
let spawned = std::thread::Builder::new().name("ec-conform-audio".into()).spawn(move || {
let tmp = file.with_extension("tmp");
let ok = file.parent().is_some_and(|d| std::fs::create_dir_all(d).is_ok())
&& pool.write_conformed(&f, &tmp, frames, rate).is_ok()
&& std::fs::rename(&tmp, &file).is_ok();
if !ok {
let written = match file.parent().map(std::fs::create_dir_all) {
Some(Ok(())) => pool.write_conformed(&f, &tmp, frames, rate).and_then(|()| std::fs::rename(&tmp, &file)),
Some(Err(e)) => Err(e),
None => Err(std::io::Error::other("no folder")),
};
// A failed decode leaves no file: one written silent would keep the footage silent
// in every later session (#324).
if let Err(e) = written {
let _ = std::fs::remove_file(&tmp);
log::warn!("media: could not write conformed audio {}", file.display());
log::warn!("media: could not write conformed audio {} of {}: {e}", file.display(), f.path);
lock(&pool.inner.conform_failed).insert(file.clone());
}
lock(&pool.inner.conforming).remove(&file);
});
@@ -419,6 +431,7 @@ impl MediaPool {
/// Write `frames` sample frames of `footage`'s audio at `rate` to `path` (raw interleaved
/// stereo `f32`) a chunk at a time: the whole track was held in memory before it was written.
/// Fails when any chunk doesn't decode.
#[cfg(not(target_arch = "wasm32"))]
fn write_conformed(&self, footage: &Footage, path: &std::path::Path, frames: usize, rate: u32) -> std::io::Result<()> {
use std::io::Write;
@@ -426,7 +439,7 @@ impl MediaPool {
let mut at = 0usize;
while at < frames {
let n = (frames - at).min(1 << 16);
for v in self.decode_audio(footage, sample_time(at, rate), n, rate) {
for v in self.try_decode_audio(footage, sample_time(at, rate), n, rate).map_err(std::io::Error::other)? {
out.write_all(&v.to_le_bytes())?;
}
at += n;
@@ -470,19 +483,27 @@ impl MediaPool {
/// applied the edit list's priming offset twice and ramped the start of every read: AAC
/// footage not at the mix rate stuttered in previews and exports (#274).
fn decode_audio(&self, footage: &Footage, start: Tick, frames: usize, rate: u32) -> Vec<f32> {
self.try_decode_audio(footage, start, frames, rate).unwrap_or_else(|e| {
log::warn!("media: audio of {}: {e}", footage.path);
vec![0.0; frames * 2]
})
}
/// [`MediaPool::decode_audio`], failing when the source can't be opened or read.
fn try_decode_audio(&self, footage: &Footage, start: Tick, frames: usize, rate: u32) -> std::result::Result<Vec<f32>, String> {
let path: Arc<str> = footage.path.as_str().into();
let Some(src) = self.inner.source(&path) else { return vec![0.0; frames * 2] };
let src = self.inner.source(&path).ok_or("the file can't be opened")?;
let s0 = start.to_units_floor(rate as i64);
let native = src.info().audio.as_ref().map_or(rate, |a| a.sample_rate);
if native == 0 || rate == 0 || native == rate {
return read_stereo(&src, &path, s0, frames, rate);
return read_stereo(&src, s0, frames, rate);
}
// Output sample `n` sits at source position n × native / rate.
let (native_i, rate_i) = (i128::from(native), i128::from(rate));
let first = (i128::from(s0) * native_i).div_euclid(rate_i);
let last = ((i128::from(s0) + frames as i128) * native_i).div_euclid(rate_i) + 1;
let (Ok(first64), Ok(count)) = (i64::try_from(first), usize::try_from(last - first + 1)) else { return vec![0.0; frames * 2] };
let buf = read_stereo(&src, &path, first64, count, native);
let (Ok(first64), Ok(count)) = (i64::try_from(first), usize::try_from(last - first + 1)) else { return Ok(vec![0.0; frames * 2]) };
let buf = read_stereo(&src, first64, count, native)?;
let mut out = Vec::with_capacity(frames * 2);
for k in 0..frames as i128 {
let pos = (i128::from(s0) + k) * native_i;
@@ -494,7 +515,7 @@ impl MediaPool {
out.push(a + (b - a) * f);
}
}
out
Ok(out)
}
/// A thumbnail of `footage` (its first frame) fitting in `max_side` × `max_side`, aspect kept.
@@ -710,6 +731,13 @@ impl Inner {
if let Some(img) = crate::layered::decode(path, &bytes, footage, op)? {
return Ok(img);
}
// (an OpenEXR compression the decoder lacks came out transparent or as a missing
// channel error)
if path.to_ascii_lowercase().ends_with(".exr")
&& let Some(why) = crate::exr_channels::unsupported_compression(&bytes)
{
return Err(MediaError::Decode(format!("{path}: {why}")));
}
let img = match image::load_from_memory(&bytes) {
Ok(img) => img,
// A multi-layer OpenEXR file without an unnamed RGB layer: its colour layer.
@@ -800,27 +828,21 @@ impl FootageSource for MediaPool {
/// `frames` stereo sample frames (interleaved) of `src` from sample `s0` at `rate` Hz, which
/// FilmCraft serves as decoded when it is the source's own rate. Mono is duplicated to both
/// channels; silence before the start and where decoding fails.
fn read_stereo(src: &SharedSource, path: &str, s0: i64, frames: usize, rate: u32) -> Vec<f32> {
/// channels; silence before the start. An error where decoding fails.
fn read_stereo(src: &SharedSource, s0: i64, frames: usize, rate: u32) -> std::result::Result<Vec<f32>, String> {
let mut out = vec![0.0; frames * 2];
let skip = usize::try_from(s0.saturating_neg()).unwrap_or(0);
if skip >= frames {
return out;
return Ok(out);
}
let buf = match src.audio(s0.max(0), frames - skip, rate) {
Ok(b) => b,
Err(e) => {
log::warn!("media: audio of {path}: {e}");
return out;
}
};
let (Some(l), Some(r)) = (buf.channels.first(), buf.channels.get(1).or(buf.channels.first())) else { return out };
let Some(dst) = skip.checked_mul(2).and_then(|i| out.get_mut(i..)) else { return out };
let buf = src.audio(s0.max(0), frames - skip, rate).map_err(|e| e.to_string())?;
let (Some(l), Some(r)) = (buf.channels.first(), buf.channels.get(1).or(buf.channels.first())) else { return Ok(out) };
let Some(dst) = skip.checked_mul(2).and_then(|i| out.get_mut(i..)) else { return Ok(out) };
for ([o0, o1], (a, b)) in dst.as_chunks_mut::<2>().0.iter_mut().zip(l.iter().zip(r)) {
*o0 = *a;
*o1 = *b;
}
out
Ok(out)
}
fn lock<T>(m: &Mutex<T>) -> std::sync::MutexGuard<'_, T> {
@@ -880,6 +902,43 @@ mod tests {
let _ = std::fs::remove_dir_all(&dir);
}
/// #324: audio that fails to decode isn't conformed. A conformed file of silence was written
/// instead, and as it is found by the file's path, size and date the footage stayed silent
/// in every later session (a renamed copy played).
#[test]
fn audio_that_fails_to_decode_is_not_conformed() {
let dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../target/test-conform-fail");
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("song.mp3");
std::fs::write(&path, b"ID3, not really").unwrap();
let footage = Footage {
path: path.to_string_lossy().into(),
kind: FootageKind::Audio,
duration: Tick::from_seconds_f64(2.0),
has_audio: true,
..Default::default()
};
let pool = MediaPool::new();
let conformed = dir.join("conformed");
pool.set_conform_folder(Some(conformed.clone()));
let file = pool.conformed_path(&footage.path, 48_000).unwrap();
assert!(pool.audio_samples(&footage, Tick::ZERO, 1024, 48_000).iter().all(|v| *v == 0.0));
for _ in 0..500 {
if lock(&pool.inner.conforming).is_empty() {
break;
}
std::thread::sleep(std::time::Duration::from_millis(10));
}
assert!(lock(&pool.inner.conforming).is_empty(), "the conform thread finished");
assert!(!file.exists() && !file.with_extension("tmp").exists(), "no conformed file was left");
assert!(lock(&pool.inner.conform_failed).contains(&file));
// Later reads go to the decoder without trying again.
pool.audio_samples(&footage, Tick::ZERO, 1024, 48_000);
assert!(lock(&pool.inner.conforming).is_empty());
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn frame_index_loops_and_clamps() {
let r = FrameRate::FPS_30;
@@ -911,6 +970,32 @@ mod tests {
assert!(c.take_spare(17).is_empty());
}
/// #482: an OpenEXR file with HTJ2K compression (which the decoder lacks) says so on import
/// and when its frame is read, instead of "no non-deep rgb channels" or a transparent frame.
#[test]
fn htj2k_exr_is_reported_as_unsupported() {
use exr::prelude::*;
let channels =
AnyChannels::sort(vec![AnyChannel::new("R", FlatSamples::F32(vec![0.5; 4])), AnyChannel::new("G", FlatSamples::F32(vec![0.5; 4]))].into());
let image = exr::image::Image::from_layer(Layer::new((2, 2), LayerAttributes::default(), Encoding::UNCOMPRESSED, channels));
let mut bytes = std::io::Cursor::new(Vec::new());
image.write().to_buffered(&mut bytes).unwrap();
let mut bytes = bytes.into_inner();
assert_eq!(crate::exr_channels::unsupported_compression(&bytes), None);
// The header's compression attribute (name, type, size 1, value) says HTJ2K32 (11).
let tag = b"compression\0compression\0\x01\0\0\0";
let at = bytes.windows(tag.len()).position(|w| w == tag).unwrap() + tag.len();
bytes[at] = 11;
let b: Arc<[u8]> = bytes.into();
let e = crate::probe_bytes("/shot.exr", b.clone()).unwrap_err().to_string();
assert!(e.contains("/shot.exr: OpenEXR HTJ2K compression isn't supported yet"), "{e}");
let pool = MediaPool::new();
pool.add_bytes("/shot.exr", b);
let f = Footage { path: "/shot.exr".into(), kind: FootageKind::Still, width: 2, height: 2, has_video: true, ..Default::default() };
let e = pool.frame_at(&f, Tick::ZERO).unwrap_err().to_string();
assert!(e.contains("HTJ2K compression isn't supported yet"), "{e}");
}
/// #411: Interpret Footage ▸ Preserve RGB reads a float OpenEXR's values as the file stores
/// them (no automatic linear → sRGB encode), so an OCIO effect is the only conversion; the
/// default interpretation still encodes. Both interpretations of one file decode separately.
+5
View File
@@ -158,6 +158,11 @@ pub(crate) fn still_footage(path: &str, w: u32, h: u32, fmt: image::ImageFormat,
fn probe_still_bytes(path: &str, bytes: &[u8], fmt: image::ImageFormat) -> Result<Footage> {
use image::ImageDecoder;
if fmt == image::ImageFormat::OpenExr
&& let Some(why) = crate::exr_channels::unsupported_compression(bytes)
{
return Err(MediaError::Decode(format!("{path}: {why}")));
}
let reader = image::ImageReader::with_format(std::io::Cursor::new(bytes), fmt);
let (w, h, has_alpha) = match reader.into_decoder() {
Ok(dec) => (dec.dimensions().0, dec.dimensions().1, dec.color_type().has_alpha()),
+18
View File
@@ -219,6 +219,23 @@ pub struct ProjectSettings {
/// sRGB; Rec. 2100 PQ / HLG give HDR output.
#[serde(default)]
pub output_space: Option<ColorSpace>,
/// Working Gamma, 2.2 or 2.4 (`app.project.workingGamma`): with no working space, the curve
/// Interpret As Linear Light footage is encoded with and Blend Colors Using 1.0 Gamma
/// linearizes with: the sRGB curve at 2.2, a pure 2.4 power curve at 2.4. Ignored with a
/// working space (as in After Effects).
#[serde(default = "gamma_22")]
pub working_gamma: f64,
}
fn gamma_22() -> f64 {
2.2
}
impl ProjectSettings {
/// Working Gamma is 2.4 (anything else reads as 2.2).
pub fn working_gamma_24(&self) -> bool {
(self.working_gamma - 2.4).abs() < 0.05
}
}
impl Default for ProjectSettings {
@@ -236,6 +253,7 @@ impl Default for ProjectSettings {
color_engine: ColorEngine::Adobe,
hdr: HdrMode::Clip,
output_space: None,
working_gamma: 2.2,
}
}
}
+5
View File
@@ -37,6 +37,11 @@ pub enum ParamUi {
Mask,
Path,
Text,
/// A file path (a string value) chosen with a "Choose…" button, offering files with these
/// extensions: Apply Color LUT's LUT, an OCIO config.
File {
filters: Vec<String>,
},
Gradient,
/// Not shown in Effect Controls / the timeline (internal data).
Hidden,
+15 -3
View File
@@ -14,7 +14,8 @@
//! * **Blend Colors Using 1.0 Gamma**: only blending is linear. Each layer's finished pixels
//! are linearised just before they are transformed and blended, the comp accumulates in linear
//! and is encoded back to the working space when the comp is done (so precomps hand encoded
//! pixels to their parent). Without a working space the sRGB curve is used.
//! pixels to their parent). Without a working space the Working Gamma's curve is used (sRGB
//! at 2.2, a pure 2.4 power curve at 2.4); so is it for Interpret As Linear Light footage.
use effectcraft_color::{ColorSpace, Conversion};
use effectcraft_project::{Footage, HdrMode, ProjectSettings};
@@ -36,6 +37,8 @@ pub struct Pipe {
pub out: ColorSpace,
/// HDR handling for standard-dynamic-range output.
pub hdr: HdrMode,
/// Unmanaged with Working Gamma 2.4: the pure 2.4 power curve instead of sRGB's.
pub gamma24: bool,
}
impl Pipe {
@@ -49,12 +52,20 @@ impl Pipe {
linear_blend: s.blend_linear && !linear,
out: s.output_space.unwrap_or(ColorSpace::Srgb),
hdr: s.hdr,
gamma24: s.working_space.is_none() && s.working_gamma_24(),
}
}
/// The space whose curve encodes working-space pixels (sRGB when unmanaged).
/// The space whose curve encodes working-space pixels (unmanaged: the Working Gamma's, sRGB
/// at 2.2 and Rec. 709's pure 2.4 power curve at 2.4).
fn curve(&self) -> ColorSpace {
self.space.unwrap_or(ColorSpace::Srgb)
self.space.unwrap_or(if self.gamma24 { ColorSpace::Rec709 } else { ColorSpace::Srgb })
}
/// The space Interpret As Linear Light encodes footage `f` in: its colour profile, else sRGB
/// (managed; the working space converts from there) or the Working Gamma's curve (unmanaged).
pub fn linear_footage_space(&self, f: &Footage) -> ColorSpace {
f.color_profile.unwrap_or(if self.space.is_some() { ColorSpace::Srgb } else { self.curve() })
}
/// Footage `f` from its colour profile (`None` = sRGB) → working space; nothing for
@@ -149,6 +160,7 @@ impl Pipe {
k = k.wrapping_mul(31).wrapping_add(self.space.map_or(7, |s| s as u64 + 11));
k = k.wrapping_mul(31).wrapping_add(self.linear as u64 * 2 + self.linear_blend as u64);
k = k.wrapping_mul(31).wrapping_add(self.out as u64 * 4 + self.hdr as u64);
k = k.wrapping_mul(31).wrapping_add(self.gamma24 as u64);
k
}
}
+4 -5
View File
@@ -1200,12 +1200,12 @@ impl<'a> Renderer<'a> {
let dominant_upper = f.fields == effectcraft_project::FieldOrder::UpperFirst;
// Upper field = even lines (0, 2, …).
let parity = if (i % 2 == 0) == dominant_upper { 0 } else { 1 };
return Some(Arc::new(interpret_pixels(&field_frame(&img, parity), f)));
return Some(Arc::new(interpret_pixels(&field_frame(&img, parity), f, self.pipe.linear_footage_space(f))));
}
// Interpretation is per pixel but not linear: it runs on the frame before resampling.
let size = size.filter(|_| !(f.invert_alpha || f.linear_light_applies()));
let img = self.footage_frame_raw(ctx, layer, item, f, t, size)?;
if f.invert_alpha || f.linear_light_applies() { Some(Arc::new(interpret_pixels(&img, f))) } else { Some(img) }
if f.invert_alpha || f.linear_light_applies() { Some(Arc::new(interpret_pixels(&img, f, self.pipe.linear_footage_space(f)))) } else { Some(img) }
}
fn footage_frame_raw(&self, ctx: &EvalCtx, layer: &Layer, item: ItemId, f: &Footage, t: Tick, size: Option<(u32, u32)>) -> Option<Arc<Image>> {
@@ -2074,13 +2074,12 @@ pub fn field_frame(img: &Image, parity: usize) -> Image {
}
/// Interpret Footage ▸ Invert Alpha and Interpret As Linear Light on decoded (premultiplied)
/// pixels.
pub fn interpret_pixels(img: &Image, f: &Footage) -> Image {
/// pixels; linear light is encoded in `space` ([`color::Pipe::linear_footage_space`]).
pub fn interpret_pixels(img: &Image, f: &Footage, space: effectcraft_color::ColorSpace) -> Image {
let linear = f.linear_light_applies();
if !f.invert_alpha && !linear {
return img.clone();
}
let space = f.color_profile.unwrap_or(effectcraft_color::ColorSpace::Srgb);
let mut out = img.clone();
for p in out.data.iter_mut() {
let a = p[3];
+18 -4
View File
@@ -517,6 +517,20 @@ fn draw_bounds(d: &Draw, paths: &[BezPath]) -> Option<kurbo::Rect> {
Some(b.inflate(grow, grow))
}
/// Local-space visual bounds of a draw, as After Effects' layer bounds (`sourceRectAtTime` with
/// extents): the path bounds, plus half the stroke width for strokes. No anti-aliasing or miter
/// padding, so snapping and Align put edges exactly together (#352).
fn visual_bounds(d: &Draw, paths: &[BezPath]) -> Option<kurbo::Rect> {
let b = effectcraft_path::bounds(paths)?;
let stroke = match &d.paint {
Paint::Stroke { style, .. } => Some(style),
Paint::Gradient { stroke: Some(style), .. } => Some(style),
_ => None,
};
let grow = stroke.map_or(0.0, |style| style.width * 0.5 * d.xf.mean_scale());
Some(if grow.is_finite() && grow > 0.0 { b.inflate(grow, grow) } else { b })
}
/// Maximum number of coverage pixels rasterised concurrently (bounds peak memory).
const BATCH_PIXELS: usize = 8 << 20;
@@ -639,16 +653,16 @@ pub fn stroke_region(paths: &[BezPath], style: &StrokeStyle) -> Option<BezPath>
(!r.elements().is_empty()).then_some(r)
}
/// Layer-space bounds of a shape layer's painted contents (strokes included), without
/// rasterising.
/// Layer-space visual bounds of a shape layer's painted contents (half the stroke width
/// included, no anti-aliasing padding), without rasterising.
pub fn content_bounds(ctx: &EvalCtx, layer: &Layer, contents: &PropGroup) -> Option<kurbo::Rect> {
let mut arena = Vec::new();
let (draws, _) = collect(ctx, layer, contents, &mut arena);
draws
.iter()
.filter_map(|d| {
let paths: Vec<BezPath> = d.slots.iter().map(|&i| arena[i].clone()).filter(|p| !p.elements().is_empty()).collect();
draw_bounds(d, &paths)
let paths: Vec<BezPath> = d.slots.iter().filter_map(|&i| arena.get(i).cloned()).filter(|p| !p.elements().is_empty()).collect();
visual_bounds(d, &paths)
})
.reduce(|a, b| a.union(b))
}
+23
View File
@@ -808,3 +808,26 @@ fn precomp_dependencies_hash_nested_sources_and_evaluated_animation() {
assert_same(&render_cached(&p, cid, Tick::ZERO, Some(&cache)), &fresh, "precomp source edit");
assert_ne!(before.get(50, 50), fresh.get(50, 50));
}
/// #352: a shape layer's bounds (snapping, Align) are its visual edges: a 100×100 rectangle is
/// exactly ±50, and a 10 px stroke adds half its width, with no anti-aliasing padding.
#[test]
fn shape_content_bounds_are_the_visual_edges() {
for (stroke, half) in [(None, 50.0), (Some(10.0), 55.0)] {
let (mut p, cid, comp) = setup();
let mut l = build::layer(&mut p, &comp, "Shape Layer 1", LayerSource::Shape, (200, 100), None);
let mut next = p.next_id;
let mut ids = Ids(&mut next);
let mut items = vec![build::shape_rect(&mut ids, [100.0, 100.0], [0.0, 0.0], 0.0), build::shape_fill(&mut ids, [1.0, 0.0, 0.0, 1.0])];
if let Some(w) = stroke {
items.push(build::shape_stroke(&mut ids, [1.0, 1.0, 1.0, 1.0], w));
}
let g = build::shape_group(&mut ids, "Rectangle 1", items);
p.next_id = next;
l.props.sub_mut("contents").unwrap().children.push(g.into());
p.comp_mut(cid).unwrap().layers.push(l.clone());
let ctx = crate::EvalCtx::new(&p, cid, p.comp(cid).unwrap(), Tick::ZERO);
let b = crate::content_bounds(&ctx, &l).unwrap();
assert!(b.iter().zip([-half, -half, half, half]).all(|(a, e)| (a - e).abs() < 1e-6), "{stroke:?}: {b:?}");
}
}
+40
View File
@@ -254,3 +254,43 @@ fn layer_cache_is_keyed_by_colour_settings() {
assert!((frame(&p) - a).abs() < 1e-9);
assert_eq!(cache.stats().hits, hits + 1);
}
/// #495: with no working space, Interpret As Linear Light footage is encoded with the project's
/// Working Gamma: the sRGB curve at 2.2, a pure 2.4 power curve at 2.4 (as After Effects does);
/// Blend Colors Using 1.0 Gamma linearizes with the same curve. A working space ignores it.
#[test]
fn working_gamma_24_encodes_linear_footage_with_a_power_curve() {
let render = |gamma: f64, ws: Option<ColorSpace>| {
let (mut p, cid, comp) = setup(BitDepth::Bpc32);
p.settings.working_gamma = gamma;
p.settings.working_space = ws;
let fid = p.add_item("F", Label::Aqua, None, ItemKind::Footage(Footage { linear_light: true, ..footage(None) }));
let l = build::layer(&mut p, &comp, "F", LayerSource::Footage { item: fid }, (40, 20), None);
p.comp_mut(cid).unwrap().layers.push(l);
let src = Flat([0.051, 0.18, 0.5]);
Renderer::new(&p, &src, RenderOpts::default()).comp_frame(cid, Tick::ZERO).get(5, 5)
};
let near = |o: [f32; 4], want: [f32; 3]| (0..3).all(|i| (o[i] - want[i]).abs() < 2e-3);
let o = render(2.4, None);
assert!(near(o, [0.290, 0.490, 0.749]), "2.4: {o:?}");
let o = render(2.2, None);
assert!(near(o, [0.250, 0.461, 0.735]), "2.2: {o:?}");
// A working space ignores Working Gamma.
assert_eq!(render(2.4, Some(ColorSpace::Srgb)), render(2.2, Some(ColorSpace::Srgb)));
// Blending with 1.0 gamma: 50% white over black is 0.5 linear, encoded with the curve.
let blend = |gamma: f64| {
let (mut p, cid, _) = setup(BitDepth::Bpc32);
p.settings.working_gamma = gamma;
p.settings.blend_linear = true;
add_solid(&mut p, cid, [0.0, 0.0, 0.0], BlendMode::Normal, 100.0);
add_solid(&mut p, cid, [1.0, 1.0, 1.0], BlendMode::Normal, 50.0);
px(&p, cid)[0]
};
assert!((blend(2.4) - 0.5f32.powf(1.0 / 2.4)).abs() < 2e-3, "{}", blend(2.4));
assert!((blend(2.2) - 0.7354).abs() < 2e-3, "{}", blend(2.2));
assert_ne!(crate::color::Pipe::of(&Project::default().settings).key(), {
let mut s = Project::default().settings;
s.working_gamma = 2.4;
crate::color::Pipe::of(&s).key()
});
}
+1
View File
@@ -82,6 +82,7 @@ fn project(s: &Session) -> J {
"linearBlending": p.settings.blend_linear,
"linearizeWorkingSpace": p.settings.linearize,
"workingSpace": p.settings.working_space.map(|w| format!("{w:?}")).unwrap_or_default(),
"workingGamma": if p.settings.working_gamma_24() { 2.4 } else { 2.2 },
"timeDisplayFrames": p.settings.time_display == effectcraft_project::TimeDisplayStyle::Frames,
"renderQueue": p.render_queue.len(),
"revision": s.revision,
+2
View File
@@ -1696,6 +1696,8 @@ Project.prototype = {
set linearizeWorkingSpace(v) { __call("file.projectSettings", { linearize: !!v }); },
get workingSpace() { return this.__info().workingSpace; },
set workingSpace(v) { __call("file.projectSettings", { workingSpace: String(v) || "none" }); },
get workingGamma() { return this.__info().workingGamma; },
set workingGamma(v) { __call("file.projectSettings", { workingGamma: __num(v) }); },
get timeDisplayType() { return this.__info().timeDisplayFrames ? TimeDisplayType.FRAMES : TimeDisplayType.TIMECODE; },
set timeDisplayType(v) { __call("file.projectSettings", { timeDisplay: v === TimeDisplayType.FRAMES ? "frames" : "timecode" }); },
get expressionEngine() { return "javascript-1.0"; },
+1 -1
View File
@@ -152,7 +152,7 @@ pub(crate) fn exec(s: &mut Session, id: &str, params: J) -> Result<J, String> {
if CREATES_LAYER.contains(&id) {
s.state.selected_layers.clear();
}
let r = s.execute(id, params);
let r = s.execute_for_script(id, params);
if let Some(c) = target
&& prev != Some(c)
&& s.state.active_comp == Some(c)
+41
View File
@@ -362,6 +362,47 @@ fn security_gate_for_files() {
let _ = std::fs::remove_dir_all(&dir);
}
/// #590: a script can't give itself file and network access (or turn off the warning before
/// executing files) by changing Settings ▸ Scripting & Expressions; the user still can.
#[test]
fn scripts_cant_change_what_scripts_may_do() {
let mut s = session();
let attempts = [
"app.run('prefs.set', {key: 'scripting.allowScriptsWriteFiles', value: true})",
"app.run('prefs.set', {values: {'scripting.warnExecutingFiles': false}})",
"app.run('prefs.set', {key: 'scripting', value: {allowScriptsWriteFiles: true}})",
"app.run('engine.batch', {steps: [{command: 'prefs.set', params: {key: 'scripting.enableJsDebugger', value: true}}]})",
];
for code in attempts {
let o = run_code(&mut s, code, "grant.jsx");
let e = o.error.unwrap_or_else(|| panic!("`{code}` was allowed"));
assert!(e.message.contains("Scripts can't change what scripts are allowed to do"), "{code}: {e:?}");
let sc = &s.prefs.scripting;
assert!(!sc.allow_scripts_write_files && sc.warn_executing_files && !sc.enable_js_debugger, "{code}");
}
assert!(run_code(&mut s, "new Socket()", "net.jsx").error.is_some());
// Other settings, and the expression editor's, are still the script's to change.
ok(&mut s, "app.run('prefs.set', {values: {'general.undoLevels': 40, 'scripting.editorFontSize': 16}})");
assert_eq!((s.prefs.general.undo_levels, s.prefs.scripting.editor_font_size), (40, 16));
// The user grants access; a script's Reset Settings doesn't take it back or change it.
s.execute("prefs.set", json!({"key": "scripting.allowScriptsWriteFiles", "value": true})).unwrap();
ok(&mut s, "app.run('prefs.reset', {})");
assert!(s.prefs.scripting.allow_scripts_write_files);
assert_eq!(s.prefs.general.undo_levels, effectcraft_engine::prefs::Prefs::default().general.undo_levels);
// A command script file is a script too.
let dir = std::env::temp_dir().join(format!("ec-script-grant-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let f = dir.join("grant.json");
std::fs::write(&f, r#"[{"command": "prefs.set", "params": {"key": "scripting.allowScriptsWriteFiles", "value": false}}]"#).unwrap();
let e = s.execute("file.runScript", json!({"path": f.to_string_lossy()})).unwrap_err().to_string();
assert!(e.contains("Scripts can't change"), "{e}");
assert!(s.prefs.scripting.allow_scripts_write_files);
// The user (UI, CLI, control channel) can take it back.
s.execute("prefs.set", json!({"key": "scripting.allowScriptsWriteFiles", "value": false})).unwrap();
assert!(!s.prefs.scripting.allow_scripts_write_files);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn engine_entry_points() {
let mut s = session();
+70 -7
View File
@@ -897,12 +897,7 @@ pub fn layout(ui: &mut egui::Ui, node: &mut DockNode, rect: Rect, t: &Tokens, pa
let g = t.gap;
let total = if *vertical { rect.height() } else { rect.width() };
let avail = (total - g).max(0.0);
let first = match *size {
SplitSize::Ratio(r) => avail * r,
SplitSize::FixedA(px) => px.min(avail - 20.0),
SplitSize::FixedB(px) => avail - px.min(avail - 20.0),
}
.clamp(20.0_f32.min(avail), (avail - 20.0).max(0.0));
let first = split_first(*size, avail);
let (ra, gutter_rect, rb) = if *vertical {
(
Rect::from_min_max(rect.min, pos2(rect.max.x, rect.min.y + first)),
@@ -917,7 +912,7 @@ pub fn layout(ui: &mut egui::Ui, node: &mut DockNode, rect: Rect, t: &Tokens, pa
)
};
if let Some(d) = gutter(ui, gutter_rect, *vertical, path, t, reg) {
let nf = (first + d).clamp(40.0, (avail - 40.0).max(40.0));
let nf = clamp_ordered(first + d, 40.0, avail - 40.0);
*size = match *size {
SplitSize::Ratio(_) => SplitSize::Ratio(nf / avail.max(1.0)),
SplitSize::FixedA(_) => SplitSize::FixedA(nf),
@@ -984,6 +979,27 @@ fn stack_bodies(entries: &[StackEntry], avail: f32) -> Vec<f32> {
.collect()
}
/// `v` held to `lo..=hi`, never panicking: when the room is too small for both minimums
/// (`hi < lo`), `lo` wins; a non-finite `v` gives `lo`.
fn clamp_ordered(v: f32, lo: f32, hi: f32) -> f32 {
let lo = if lo.is_finite() { lo } else { 0.0 };
let hi = if hi.is_finite() { hi.max(lo) } else { lo };
if v.is_finite() { v.max(lo).min(hi) } else { lo }
}
/// The size of a split's first side in `avail` points (both sides keep 20 pt when there is room).
/// `f32::clamp` panics when its bounds cross, which a split shorter than 40 pt did (#306).
fn split_first(size: SplitSize, avail: f32) -> f32 {
let avail = if avail.is_finite() { avail.max(0.0) } else { 0.0 };
let first = match size {
SplitSize::Ratio(r) => avail * r,
SplitSize::FixedA(px) => px.min(avail - 20.0),
SplitSize::FixedB(px) => avail - px.min(avail - 20.0),
};
let lo = 20.0_f32.min(avail);
clamp_ordered(first, lo, avail - 20.0).min(avail)
}
/// Drag the gap between the open stacked panels `above` and `below` (laid out at `bodies`) by
/// `d` points: one grows as much as the other shrinks, neither below [`STACK_MIN`]. The new sizes
/// become the panels' heights, except that the last panel without one keeps sharing the rest
@@ -1180,6 +1196,21 @@ pub fn draw_group_chrome(ui: &mut egui::Ui, g: &Group, t: &Tokens, reg: &mut cra
}
x += w + 8.0;
}
// A stacked panel's whole header bar expands/collapses it, not just its name (as in After
// Effects). A stack group holds one panel, so the rest of the strip is unambiguous.
if g.stacked.is_some()
&& let Some(e) = entries.first()
{
let rest = Rect::from_min_max(pos2((x - 8.0).max(strip.min.x), strip.min.y), strip.max);
if rest.width() > 0.0 {
let resp = ui.interact(rest, egui::Id::new(("tab-rest", g.path.clone())), Sense::click());
reg.add(&format!("panel.header.{}", e.panel.id()), rest, &e.label);
if resp.clicked() {
out.actions.push(DockAction::ToggleStacked(e.panel));
out.actions.push(DockAction::Focus(e.panel));
}
}
}
}
// clicking anywhere in the panel focuses it
if let Some(p) = active_panel
@@ -1365,6 +1396,38 @@ mod tests {
}
}
#[test]
fn short_splits_lay_out_without_panicking() {
// #306: a split 0 < avail < 40 pt tall made `f32::clamp` panic (its bounds crossed).
for avail in [0.0, 5.0, 19.0, 20.0, 30.0, 39.0, 40.0, 41.0, -10.0, f32::NAN, f32::INFINITY] {
for size in [SplitSize::Ratio(0.5), SplitSize::Ratio(f32::NAN), SplitSize::FixedA(300.0), SplitSize::FixedB(300.0), SplitSize::FixedA(-5.0)] {
let f = split_first(size, avail);
assert!(f.is_finite() && f >= 0.0, "{size:?} {avail}: {f}");
if avail.is_finite() && avail > 0.0 {
assert!(f <= avail, "{size:?} {avail}: {f}");
}
}
}
assert_eq!(split_first(SplitSize::FixedA(300.0), 30.0), 20.0);
assert_eq!(split_first(SplitSize::Ratio(0.5), 200.0), 100.0);
assert_eq!(clamp_ordered(5.0, 40.0, -4.0), 40.0);
// Every workspace, laid out in rects too short and too narrow for its minimums.
let ctx = egui::Context::default();
let t = Tokens::for_kind(crate::theme::ThemeKind::Dark);
for w in WORKSPACES {
for (wd, ht) in [(30.0, 30.0), (800.0, 36.0), (800.0, 45.0), (36.0, 600.0), (1.0, 1.0), (0.0, 0.0)] {
let mut node = workspace(w);
let mut frame = ctx.run_ui(egui::RawInput::default(), |ui| {
let mut out = vec![];
let mut reg = crate::automation::Registry::default();
layout(ui, &mut node, Rect::from_min_size(pos2(0.0, 0.0), vec2(wd, ht)), &t, "", &mut out, &mut reg);
assert!(!out.is_empty(), "{w}");
});
frame.textures_delta.clear();
}
}
}
#[test]
fn drop_zone_math() {
let r = Rect::from_min_size(pos2(100.0, 100.0), vec2(400.0, 300.0));
+4 -1
View File
@@ -280,8 +280,11 @@ fn comp_content_with(project: &Project, comp: ItemId, addr: impl Fn(ItemId, &Arc
h.finish()
}
/// Hash of render options (part of [`FrameKey`] and of the disk key).
/// Hash of render options (part of [`FrameKey`] and of the disk key). The scale counts as the
/// key's thousandths ([`FrameKey::scale`]): a series rebuilt from the key (the cache bars) has
/// the same options as the frames rendered at 1/3 = 0.333… (#595).
pub fn opts_hash(o: &RenderOpts) -> u64 {
let o = RenderOpts { scale: (o.scale * 1000.0).round() / 1000.0, ..*o };
let mut h = disk_cache::Hash128::default();
h.write(format!("{o:?}").as_bytes());
h.finish() as u64
+29 -3
View File
@@ -164,6 +164,9 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
if app.ui.tool.puppet_kind().is_some() {
x = puppet_options(app, ui, &p, x, cy);
}
if app.ui.tool.roto_kind(false).is_some() {
x = roto_options(app, ui, &p, x, cy);
}
let snap = Rect::from_min_size(pos2(x, cy - 10.0), vec2(20.0, 20.0));
// The engine owns snapping (View ▸ Snapping); the checkbox mirrors it.
app.ui.snapping = app.session.state.snapping;
@@ -177,12 +180,15 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
// Right side: community buttons, workspaces.
let mut rx = rect.max.x - 10.0;
let discord = Rect::from_min_max(pos2(rx - 104.0, cy - 13.0), pos2(rx, cy + 13.0));
// The pill fits its label (icon, gap, text) with even padding: it was a fixed 104 px with the
// text at its left, which left a wide gap on the right (#581).
let label = p.layout_no_wrap(tr("Discord").to_string(), Tokens::semibold(12.0), Color32::WHITE);
let discord = Rect::from_min_max(pos2(rx - (10.0 + 14.0 + 6.0 + label.size().x + 12.0), cy - 13.0), pos2(rx, cy + 13.0));
let dresp = ui.interact(discord, egui::Id::new("hdr-discord"), Sense::click());
let dc = Color32::from_rgb(0x58, 0x65, 0xf2);
p.rect_filled(discord, 13.0, if dresp.hovered() { dc.gamma_multiply(1.2) } else { dc });
icons::paint(&p, Rect::from_center_size(pos2(discord.min.x + 16.0, cy), vec2(14.0, 14.0)), Icon::Chat, Color32::WHITE);
p.text(pos2(discord.min.x + 28.0, cy), Align2::LEFT_CENTER, tr("Discord"), Tokens::semibold(12.0), Color32::WHITE);
icons::paint(&p, Rect::from_center_size(pos2(discord.min.x + 17.0, cy), vec2(14.0, 14.0)), Icon::Chat, Color32::WHITE);
p.galley(pos2(discord.min.x + 30.0, cy - label.size().y / 2.0), label, Color32::WHITE);
app.auto.add("header.discord", discord, "Join the ArtCraft Discord");
if dresp.on_hover_text(tr("Join the ArtCraft community on Discord")).clicked() {
let _ = app.session.execute("help.discord", json!({}));
@@ -478,6 +484,26 @@ fn paint_swatch(p: &egui::Painter, r: Rect, paint: &ToolPaint, hovered: bool, t:
p.rect_stroke(r, 2.0, Stroke::new(1.0, if hovered { t.text } else { t.field_border }), StrokeKind::Inside);
}
/// Roto Brush / Refine Edge tool options: the brush Diameter (⌘/Ctrl-drag in the Layer panel
/// sets it too, as in After Effects). Returns the next x.
fn roto_options(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painter, mut x: f32, cy: f32) -> f32 {
let t = app.tokens;
let refine = app.ui.tool.roto_kind(false).is_some_and(|k| k.starts_with("refine"));
let o = &app.session.state.roto;
let (key, v) = if refine { ("refineDiameter", o.refine_diameter) } else { ("diameter", o.diameter) };
let label = tr("Diameter:");
let lr = p.text(pos2(x, cy), Align2::LEFT_CENTER, label, Tokens::ui(12.0), t.text_dim);
x = lr.max.x + 6.0;
let (r, nv, _) = widgets::hot_number_at(ui, pos2(x, cy - 9.0), egui::Id::new(("roto-opt", key)), v, 0.5, (1.0, 5000.0), 0, " px", &t);
app.auto.add("header.roto.diameter", r, label);
if let Some(nv) = nv
&& let Err(e) = app.session.execute("roto.options", json!({key: nv}))
{
app.ui.status = e.to_string();
}
r.max.x + 12.0
}
/// Puppet tool options: Mesh: Show, Expansion, Density (for new meshes and the selected
/// layer's meshes). Returns the next x.
fn puppet_options(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painter, mut x: f32, cy: f32) -> f32 {
+67 -1
View File
@@ -286,6 +286,7 @@ const JAPANESE: &[(&str, &str)] = &[
("Maximize Panel", "パネルを最大化"),
("Media Replacement", "メディアの置き換え"),
("Mesh:", "メッシュ:"),
("Diameter:", "直径:"),
("Method Used: {} Average Error: {} pixels", "使用した手法:{} 平均誤差:{}ピクセル"),
("Models folder: {}", "モデルフォルダー:{}"),
("Modifiers:", "修飾キー:"),
@@ -721,6 +722,7 @@ const SIMPLIFIED_CHINESE: &[(&str, &str)] = &[
("Maximize Panel", "最大化面板"),
("Media Replacement", "媒体替换"),
("Mesh:", "网格:"),
("Diameter:", "直径:"),
("Method Used: {} Average Error: {} pixels", "使用方法:{} 平均误差:{} 像素"),
("Models folder: {}", "模型文件夹:{}"),
("Modifiers:", "修饰键:"),
@@ -998,6 +1000,37 @@ const SIMPLIFIED_CHINESE: &[(&str, &str)] = &[
("Use In-Window Menu Bar on macOS", "在 macOS 上使用窗口内菜单栏"),
("Wireframe", "线框"),
("sRGB IEC61966-2.1 (default)", "sRGB IEC61966-2.1(默认)"),
// The Home screen (#574).
("New Project", "新建项目"),
("Open Project…", "打开项目…"),
("Templates", "模板"),
("EffectCraft home page", "EffectCraft 主页"),
("EffectCraft on GitHub", "GitHub 上的 EffectCraft"),
("Motion graphics and visual effects · version {}", "动态图形和视觉效果 · 版本 {}"),
("New Composition", "新建合成"),
("Open Demo Project", "打开示例项目"),
("Import Footage…", "导入素材…"),
("Filter recent files", "筛选最近文件"),
("NAME", "名称"),
("OPENED", "打开时间"),
("SIZE", "大小"),
("KIND", "类型"),
("Your recent files will show here", "你最近使用的文件会显示在这里"),
("Create a new project, or open the demo project to look around.", "新建一个项目,或打开示例项目先熟悉一下界面。"),
("No recent files match the filter", "没有与筛选条件匹配的最近文件"),
("Try another name or folder.", "请尝试其他名称或文件夹。"),
("Just now", "刚刚"),
("Yesterday", "昨天"),
("{} minute ago", "{} 分钟前"),
("{} minutes ago", "{} 分钟前"),
("{} hour ago", "{} 小时前"),
("{} hours ago", "{} 小时前"),
("{} day ago", "{} 天前"),
("{} days ago", "{} 天前"),
("EffectCraft project", "EffectCraft 项目"),
("After Effects project", "After Effects 项目"),
("Lottie animation", "Lottie 动画"),
("Project", "项目"),
];
const TRADITIONAL_CHINESE: &[(&str, &str)] = &[
@@ -1213,6 +1246,7 @@ const TRADITIONAL_CHINESE: &[(&str, &str)] = &[
("Maximize Panel", "最大化面板"),
("Media Replacement", "媒體替換"),
("Mesh:", "格點:"),
("Diameter:", "直徑:"),
("Method Used: {} Average Error: {} pixels", "使用方法:{} 平均誤差:{} 像素"),
("Models folder: {}", "模型資料夾:{}"),
("Modifiers:", "修飾鍵:"),
@@ -1472,7 +1506,7 @@ const TRADITIONAL_CHINESE: &[(&str, &str)] = &[
("Import PDF / Illustrator File", "Import PDF / Illustrator File"),
("Import Photoshop File", "Import Photoshop File"),
("Interpret Unlabeled Alpha As", "Interpret Unlabeled Alpha As"),
("Join the ArtCraft Discord", "Join the ArtCraft Discord"),
("Join the ArtCraft Discord", "加入 ArtCraft Discord"),
("Mercury GPU Acceleration ({})", "Mercury GPU Acceleration ({})"),
("Mercury Software Only", "Mercury Software Only"),
("OCIO (built-in ACES config)", "OCIO (built-in ACES config)"),
@@ -1485,6 +1519,37 @@ const TRADITIONAL_CHINESE: &[(&str, &str)] = &[
("Use In-Window Menu Bar on macOS", "Use In-Window Menu Bar on macOS"),
("Wireframe", "Wireframe"),
("sRGB IEC61966-2.1 (default)", "sRGB IEC61966-2.1 (default)"),
// The Home screen (#574).
("New Project", "新增專案"),
("Open Project…", "開啟專案…"),
("Templates", "範本"),
("EffectCraft home page", "EffectCraft 首頁"),
("EffectCraft on GitHub", "GitHub 上的 EffectCraft"),
("Motion graphics and visual effects · version {}", "動態圖形與視覺效果 · 版本 {}"),
("New Composition", "新增合成"),
("Open Demo Project", "開啟範例專案"),
("Import Footage…", "匯入素材…"),
("Filter recent files", "篩選最近的檔案"),
("NAME", "名稱"),
("OPENED", "開啟時間"),
("SIZE", "大小"),
("KIND", "類型"),
("Your recent files will show here", "你最近使用的檔案會顯示在這裡"),
("Create a new project, or open the demo project to look around.", "新增一個專案,或開啟範例專案先熟悉一下介面。"),
("No recent files match the filter", "沒有符合篩選條件的最近檔案"),
("Try another name or folder.", "請嘗試其他名稱或資料夾。"),
("Just now", "剛剛"),
("Yesterday", "昨天"),
("{} minute ago", "{} 分鐘前"),
("{} minutes ago", "{} 分鐘前"),
("{} hour ago", "{} 小時前"),
("{} hours ago", "{} 小時前"),
("{} day ago", "{} 天前"),
("{} days ago", "{} 天前"),
("EffectCraft project", "EffectCraft 專案"),
("After Effects project", "After Effects 專案"),
("Lottie animation", "Lottie 動畫"),
("Project", "專案"),
];
/// Ukrainian (uk): original translations of the same interface rows as the Chinese catalogs.
@@ -1695,6 +1760,7 @@ const UKRAINIAN: &[(&str, &str)] = &[
("Maximize Panel", "Розгорнути панель"),
("Media Replacement", "Заміна матеріалу"),
("Mesh:", "Сітка:"),
("Diameter:", "Діаметр:"),
("Method Used: {} Average Error: {} pixels", "Використаний метод: {} Середня похибка: {} пікселів"),
("Models folder: {}", "Тека моделей: {}"),
("Modifiers:", "Модифікатори:"),
+48
View File
@@ -297,6 +297,17 @@ pub struct EffectcraftApp {
pub(crate) template_list: Option<(Vec<String>, Vec<effectcraft_engine::templates::TemplateInfo>)>,
}
/// The saved workspaces in the settings store ([`effectcraft_engine::config::ConfigStore`]).
pub const WORKSPACES_FILE: &str = "workspaces.json";
#[derive(serde::Serialize, serde::Deserialize)]
struct SavedWorkspaces {
#[serde(default)]
workspaces: std::collections::BTreeMap<String, dock::DockNode>,
#[serde(default)]
floating: std::collections::BTreeMap<String, Vec<dock::Floating>>,
}
impl EffectcraftApp {
pub fn new(session: Session) -> Self {
EffectcraftApp {
@@ -355,6 +366,34 @@ impl EffectcraftApp {
template_list: None,
}
.with_ui_commands()
.with_saved_workspaces()
}
/// The workspaces saved in an earlier session (Window ▸ Workspace ▸ Save as New Workspace,
/// Save Changes to this Workspace), from the settings store. A file that doesn't read is
/// ignored.
fn with_saved_workspaces(mut self) -> Self {
let text = self.session.config.as_ref().and_then(|c| c.read(WORKSPACES_FILE));
match text.map(|t| serde_json::from_str::<SavedWorkspaces>(&t)) {
Some(Ok(w)) => {
self.ui.saved_workspaces = w.workspaces;
self.ui.saved_floating = w.floating;
}
Some(Err(e)) => log::warn!("{WORKSPACES_FILE}: {e}"),
None => {}
}
self
}
/// Keep the saved workspaces for the next session (see [`EffectcraftApp::new`]): After
/// Effects' saved workspaces last until they are deleted (#320).
pub(crate) fn store_saved_workspaces(&self) {
let Some(c) = &self.session.config else { return };
let w = SavedWorkspaces { workspaces: self.ui.saved_workspaces.clone(), floating: self.ui.saved_floating.clone() };
let written = serde_json::to_string(&w).map_err(std::io::Error::other).and_then(|t| c.write(WORKSPACES_FILE, &t));
if let Err(e) = written {
log::warn!("{WORKSPACES_FILE}: {e}");
}
}
/// Offer the frontend's commands (tools, timeline navigation…) for keyboard shortcuts.
@@ -614,6 +653,15 @@ impl EffectcraftApp {
return;
}
let Some(rs) = frame.wgpu_render_state() else { return };
// Help ▸ System Compatibility Report names the adapter and backend the window got.
let info = rs.adapter.get_info();
self.session.window_adapter = Some(format!(
"{} ({:?}, {:?}{})",
info.name,
info.backend,
info.device_type,
if info.driver.is_empty() { String::new() } else { format!(", {} {}", info.driver, info.driver_info) }
));
// Backend validation can panic on shader compilation even when device creation
// succeeded. Keep the document and the egui renderer, and use CPU compositing.
let gpu = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| effectcraft_gpu::Gpu::new(&rs.adapter, rs.device.clone(), rs.queue.clone())));
+45 -1
View File
@@ -122,6 +122,7 @@ pub const UI_COMMANDS: &[UiCommand] = &[
uic!("app.home", "Home", [], None),
uic!("markers.dialog", "Marker Settings...", [], None),
uic!("window.maximizePanel", "Maximize Panel Under Pointer", [], Some("`")),
uic!("window.maximizeApp", "Maximize or Restore Application Window", [], Some("Cmd+\\")),
uic!("window.dockPanel", "Dock Panel", [], None),
uic!("window.floatPanel", "Undock Panel", [], None),
uic!("window.closePanel", "Close Panel", [], None),
@@ -416,6 +417,10 @@ pub fn invoke(app: &mut EffectcraftApp, ctx: &egui::Context, id: &str, params: V
_ => {}
}
}
// A UI-only command (not in the engine's menus), so `frontend` gets it from here.
if id == "window.maximizeApp" {
return frontend(app, ctx, id, params);
}
// Docking: {panel, anchor, zone: center|left|right|top|bottom}, {panel, rect?}, {panel?}.
if let Some(op) = id.strip_prefix("window.").filter(|o| matches!(*o, "maximizePanel" | "dockPanel" | "floatPanel" | "closePanel")) {
let panel_of = |k: &str| params.get(k).and_then(Value::as_str).map(|s| PanelKind::from_name(s).ok_or(format!("unknown panel `{s}`")));
@@ -635,8 +640,9 @@ pub fn invoke(app: &mut EffectcraftApp, ctx: &egui::Context, id: &str, params: V
// up Effect Controls on that layer, as in After Effects.
if matches!(id, "effect.apply" | "effect.applyLast") {
let r = run_engine(app, ctx, id, params.clone());
if r.is_ok() {
if let Ok(applied) = &r {
crate::panels::effect_controls::reveal_applied(app, &params);
crate::panels::effect_controls::choose_files_after_apply(app, applied);
}
return r;
}
@@ -909,6 +915,12 @@ pub fn frontend(app: &mut EffectcraftApp, ctx: &egui::Context, id: &str, p: Valu
};
Value::Null
}
// Ctrl+\ / Cmd+\, as in After Effects (#336): `{maximized}`, the window's new state.
"window.maximizeApp" => {
let maximized = !ctx.input(|i| i.viewport().maximized.unwrap_or(false));
ctx.send_viewport_cmd(egui::ViewportCommand::Maximized(maximized));
json!({"maximized": maximized})
}
"view.fullScreen" => {
let full = ctx.input(|i| i.viewport().fullscreen.unwrap_or(false));
ctx.send_viewport_cmd(egui::ViewportCommand::Fullscreen(!full));
@@ -943,6 +955,7 @@ pub fn frontend(app: &mut EffectcraftApp, ctx: &egui::Context, id: &str, p: Valu
let name = app.ui.workspace.clone();
app.ui.saved_workspaces.insert(name.clone(), app.ui.dock.clone());
app.ui.saved_floating.insert(name.clone(), app.ui.floating.clone());
app.store_saved_workspaces();
json!({"workspace": name})
}
"window.saveWorkspaceAs" => {
@@ -959,6 +972,7 @@ pub fn frontend(app: &mut EffectcraftApp, ctx: &egui::Context, id: &str, p: Valu
app.ui.saved_workspaces.insert(name.clone(), app.ui.dock.clone());
app.ui.saved_floating.insert(name.clone(), app.ui.floating.clone());
app.ui.workspace = name.clone();
app.store_saved_workspaces();
json!({"workspace": name})
}
"window.editWorkspaces" => {
@@ -983,6 +997,7 @@ pub fn frontend(app: &mut EffectcraftApp, ctx: &egui::Context, id: &str, p: Valu
if app.ui.saved_workspaces.remove(&name).is_none() && !builtin {
return Err(format!("no workspace `{name}`"));
}
app.store_saved_workspaces();
if app.ui.workspace == name {
app.set_workspace("Default");
}
@@ -1000,6 +1015,7 @@ pub fn frontend(app: &mut EffectcraftApp, ctx: &egui::Context, id: &str, p: Valu
if app.ui.workspace == name {
app.ui.workspace = new.to_string();
}
app.store_saved_workspaces();
return Ok(json!({"renamed": new}));
}
json!({"workspaces": app.workspace_names()})
@@ -1866,6 +1882,34 @@ mod tests {
}
}
/// #336: Ctrl+\ (Cmd+\ on macOS) maximizes the application window, and again restores
/// it, as in After Effects.
#[test]
fn ctrl_backslash_maximizes_or_restores_the_window() {
let mut app = EffectcraftApp::new(effectcraft_engine::Session::default());
let (m, k) = parse_shortcut("Cmd+\\").unwrap();
assert!(m.command && k == egui::Key::Backslash, "{m:?} {k:?}");
assert!(bindings(&app.session).iter().any(|b| (b.0, b.1) == (m, k) && b.2 == "window.maximizeApp"));
let ctx = egui::Context::default();
let run = |app: &mut EffectcraftApp, maximized: bool| {
let input = egui::RawInput {
viewports: std::iter::once((egui::ViewportId::ROOT, egui::ViewportInfo { maximized: Some(maximized), ..Default::default() })).collect(),
..Default::default()
};
let mut r = Value::Null;
let mut out = ctx.run_ui(input, |ui| r = invoke(app, ui.ctx(), "window.maximizeApp", json!({})).unwrap());
out.textures_delta.clear();
let cmds = out.viewport_output.get(&egui::ViewportId::ROOT).map(|v| v.commands.clone()).unwrap_or_default();
(r, cmds)
};
let (r, cmds) = run(&mut app, false);
assert_eq!(r, json!({"maximized": true}));
assert!(cmds.contains(&egui::ViewportCommand::Maximized(true)), "{cmds:?}");
let (r, cmds) = run(&mut app, true);
assert_eq!(r, json!({"maximized": false}));
assert!(cmds.contains(&egui::ViewportCommand::Maximized(false)), "{cmds:?}");
}
#[test]
fn dispatch_uses_the_active_custom_preset() {
let mut app = EffectcraftApp::new(effectcraft_engine::Session::default());
@@ -79,6 +79,46 @@ pub fn reveal_applied(app: &mut EffectcraftApp, params: &serde_json::Value) {
app.raise_panel(crate::dock::PanelKind::EffectControls);
}
/// After Effects asks for an effect's file as it applies it (Apply Color LUT opens the file
/// dialog for its LUT). For the instances `effect.apply` made (`applied`, its result), open the
/// file dialog for their first empty file parameter and set the file on each; cancelling
/// leaves the effect without one. Does nothing without a file dialog (tests, agents).
pub fn choose_files_after_apply(app: &mut EffectcraftApp, applied: &serde_json::Value) {
let Some(pick) = app.hooks.pick_files.as_ref() else { return };
let Some(comp) = app.session.active_comp() else { return };
let uids: Vec<u64> = applied.get("effects").and_then(|v| v.as_array()).map(|a| a.iter().filter_map(|v| v.as_u64()).collect()).unwrap_or_default();
let mut targets = vec![];
let mut filters: Vec<String> = vec![];
for uid in uids {
for l in &comp.layers {
let Some(g) = l.props.find_group(uid) else { continue };
let mut found = None;
g.walk("", &mut |_, pr| {
if found.is_none()
&& let ParamUi::File { filters } = &pr.ui
&& matches!(&pr.value, Value::Str(s) if s.trim().is_empty())
{
found = Some((pr.uid, filters.clone()));
}
});
if let Some((prop, f)) = found {
targets.push((l.id.0, prop));
filters = f;
}
}
}
if targets.is_empty() {
return;
}
let exts: Vec<&str> = filters.iter().map(String::as_str).collect();
let Some(path) = pick(&exts).into_iter().next() else { return };
for (layer, prop) in targets {
if let Err(e) = app.session.execute("prop.set", json!({"layer": layer, "prop": prop, "value": path})) {
app.ui.status = e.to_string();
}
}
}
/// An undo-merge key for one gesture on `id`: a new key each time a drag starts, so separate
/// drags are separate undo steps while one drag is one step.
pub(super) fn gesture_key(ui: &egui::Ui, id: egui::Id, started: bool) -> String {
@@ -417,6 +457,59 @@ fn prop_row(
let dr = Rect::from_min_size(pos2(vx, cy - 9.0), vec2((r.max.x - vx - 56.0).clamp(80.0, 200.0), 18.0));
super::fx_editors::channel_popup(app, ui, layer, prop, s, dr, actions);
}
// A file parameter (Apply Color LUT's LUT, an OCIO config): its file name and Choose…,
// as in After Effects.
Value::Str(s) if matches!(prop.ui, ParamUi::File { .. }) => {
let bw = 70.0;
let br = Rect::from_min_size(pos2(vx, cy - 9.0), vec2(bw, 18.0));
if widgets::text_button(ui, br, crate::i18n::tr("Choose..."), false, &t, egui::Id::new(("ec-file", uid))).clicked() {
let filters: Vec<&str> = match &prop.ui {
ParamUi::File { filters } => filters.iter().map(String::as_str).collect(),
_ => vec![],
};
match app.hooks.pick_files.as_ref() {
Some(pick) => {
if let Some(path) = pick(&filters).into_iter().next() {
actions.push(("prop.set".into(), json!({"layer": layer.id.0, "prop": uid, "value": path})));
}
}
None => app.ui.status = "no file dialog available: set the path with prop.set".into(),
}
}
app.auto.add(&format!("effectControls.prop.{uid}.choose"), br, &prop.name);
let shown = if s.trim().is_empty() {
crate::i18n::tr("None").to_string()
} else if s.contains('\n') {
prop.name.clone()
} else {
effectcraft_engine::sequence::file_name(s)
};
let nr = Rect::from_min_max(pos2(br.max.x + 8.0, r.min.y), pos2(r.max.x - 8.0, r.max.y));
let np = p.with_clip_rect(nr.intersect(p.clip_rect()));
np.text(pos2(nr.min.x, cy), Align2::LEFT_CENTER, &shown, Tokens::ui(12.0), if s.trim().is_empty() { t.text_dim } else { t.text });
let hover = ui.interact(nr, egui::Id::new(("ec-file-name", uid)), Sense::hover());
if !s.trim().is_empty() && !s.contains('\n') {
hover.on_hover_text(s.as_str());
}
app.auto.add(&format!("effectControls.prop.{uid}.value"), nr, &shown);
}
// A text parameter (Cryptomatte's Selection, Colorama's Output Cycle, config names):
// typed in a field, set when it loses the focus (Esc keeps the old text).
Value::Str(s) if matches!(prop.ui, ParamUi::Text) => {
let fr = Rect::from_min_size(pos2(vx, cy - 9.0), vec2((r.max.x - vx - 12.0).clamp(80.0, 320.0), 18.0));
let bid = egui::Id::new(("ec-text", uid));
let mut buf: String = ui.data(|d| d.get_temp(bid)).unwrap_or_else(|| s.clone());
let resp = widgets::text_field(ui, fr, &mut buf, "", &t);
app.auto.add(&format!("effectControls.prop.{uid}.value"), fr, &prop.name);
if resp.lost_focus() {
if buf != *s && !ui.input(|i| i.key_pressed(egui::Key::Escape)) {
actions.push(("prop.set".into(), json!({"layer": layer.id.0, "prop": uid, "value": buf})));
}
ui.data_mut(|d| d.remove::<String>(bid));
} else if resp.has_focus() {
ui.data_mut(|d| d.insert_temp(bid, buf));
}
}
Value::Gradient(g) => {
let gr = Rect::from_min_size(pos2(vx, cy - 7.0), vec2((r.max.x - vx - 56.0).clamp(60.0, 180.0), 14.0));
let n = 48;
+2 -1
View File
@@ -422,11 +422,12 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
};
match app.session.execute(cmd, params) {
Err(e) => app.ui.status = e.to_string(),
Ok(_) => {
Ok(applied) => {
if let Apply::Effect(id) = &apply {
push_recent(&mut app.ui.effects_recent, id);
app.show_panel(crate::dock::PanelKind::EffectControls);
app.ui.focused = crate::dock::PanelKind::EffectsPresets;
crate::panels::effect_controls::choose_files_after_apply(app, &applied);
}
}
}
+31
View File
@@ -374,6 +374,37 @@ fn value_widget(app: &mut EffectcraftApp, ui: &mut egui::Ui, auto: &str, ty: Opt
});
r.response.rect
}
// Scale keeps its Constrain Proportions link, on by default, as in After Effects (#338).
// The link is the property's own, shared with the Timeline's chain link.
ControlType::Point if p.match_id == "scale" && matches!(p.ui, ParamUi::Percent) => {
let comps = v.components();
let linked = !app.ui.timeline.unlinked.contains(&p.uid);
let (lr, link) = ui.allocate_exact_size(egui::vec2(14.0, 14.0), egui::Sense::click());
let t = app.tokens;
crate::icons::paint(ui.painter(), lr, crate::icons::Icon::Link, if linked { t.accent } else { t.text_faint });
let link = link.on_hover_text(tr("Constrain Proportions"));
app.auto.add(&format!("{auto}.link"), lr, "Constrain Proportions");
if link.clicked() && !app.ui.timeline.unlinked.remove(&p.uid) {
app.ui.timeline.unlinked.insert(p.uid);
}
let mut rect = lr;
for (d, &old) in comps.iter().enumerate() {
let mut x = old;
let r = ui.add(egui::DragValue::new(&mut x).speed(0.5).suffix(if d + 1 == comps.len() { "%" } else { "" }).max_decimals(1));
app.auto.add(&format!("{auto}.{d}"), r.rect, &p.name);
rect = rect.union(r.rect);
if r.changed() {
// Alt edits one value of a linked pair, as in the Timeline.
let next = if linked && !ui.input(|i| i.modifiers.alt) {
widgets::constrained(&comps, d, x)
} else {
comps.iter().enumerate().map(|(e, &c)| if e == d { x } else { c }).collect()
};
out = Some(json!(next));
}
}
rect
}
ControlType::Point => {
let mut a = v.as_vec2();
let r1 = ui.add(crate::widgets::drag_value(egui::DragValue::new(&mut a[0]).speed(1.0).max_decimals(1)));
+6 -3
View File
@@ -154,8 +154,9 @@ fn is_pdf(p: &Value) -> bool {
/// File ▸ Import ▸ File… picked numbered stills that import as image sequences: ask, as After
/// Effects' "<format> Sequence" checkbox in its Import dialog does (rfd's native dialog can't
/// carry a checkbox), with Force Alphabetical Order and the frame rate. `p`: the import's
/// parameters. Returns whether the dialog opened. Dropped files and agents' `file.import`
/// don't ask (`sequence` defaults to on).
/// parameters. Returns whether the dialog opened. The checkbox starts off, as in After Effects:
/// unticked, the picked files import as stills. Dropped files and agents' `file.import` don't
/// ask (files import as stills, folders' runs as sequences).
pub fn open_import_sequence(app: &mut EffectcraftApp, p: &Value) -> bool {
if has(p, &["sequence", "alphabetical"]) {
return false;
@@ -187,7 +188,7 @@ fn sequence_fields(app: &EffectcraftApp, runs: &[Vec<String>]) -> Vec<Field> {
};
vec![
Field::note("note", &note),
Field::bool("sequence", &format!("{ext} Sequence"), true),
Field::bool("sequence", &format!("{ext} Sequence"), false),
Field::bool("alphabetical", "Force alphabetical order", false),
Field::num("frameRate", "Frame rate (fps)", app.session.prefs.import.sequence_fps),
Field::note("after", "Change the frame rate, alpha and start frame later with File ▸ Interpret Footage."),
@@ -546,6 +547,8 @@ pub fn open_form(app: &mut EffectcraftApp, id: &str, p: &Value) -> bool {
],
st.working_space.map_or(0, |c| 1 + effectcraft_engine::project::ColorSpace::WORKING.iter().position(|x| *x == c).unwrap_or(0)),
),
// With Working Space None (After Effects' Working Gamma, 2.2 or 2.4).
Field::choice("workingGamma", "Working gamma", &[("2.2", json!(2.2)), ("2.4", json!(2.4))], usize::from(st.working_gamma_24())),
Field::bool("linearize", "Linearize working space", st.linearize),
Field::bool("blendLinear", "Blend colors using 1.0 gamma", st.blend_linear),
Field::choice(
+100 -21
View File
@@ -14,8 +14,8 @@ use egui::{Align2, Color32, Pos2, Rect, Sense, Stroke, StrokeKind, pos2, vec2};
use serde_json::json;
use super::timeline::TMap;
use crate::EffectcraftApp;
use crate::theme::Tokens;
use crate::{EffectcraftApp, widgets};
type Actions = Vec<(String, serde_json::Value)>;
@@ -162,10 +162,88 @@ fn curves<'a>(app: &EffectcraftApp, comp: &'a Comp, ectx: &EvalCtx, tm: TMap, pl
out
}
fn text_button(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painter, x: &mut f32, y: f32, label: &str, on: bool, id: &str, tip: &str) -> bool {
/// The Graph Editor's button bar, laid out left to right. Buttons that would run past the right
/// edge (a Timeline narrower than the whole row, #457) go to the "»" menu at its end instead of
/// being clipped, so every one stays reachable.
struct ButtonBar {
x: f32,
/// Where the next button must end to stay on the bar (the "»" button's room is kept free).
limit: f32,
/// Buttons moved to the "»" menu: (automation id, label, on, tooltip).
hidden: Vec<(String, String, bool, String)>,
/// The id picked from the "»" menu last frame, run by its button this frame.
chosen: Option<String>,
}
/// The "»" menu's popup and the id it picked (read by the next frame's buttons).
const MORE_POPUP: &str = "graph-more";
impl ButtonBar {
const MORE_W: f32 = 24.0;
fn new(ctx: &egui::Context, bar: Rect) -> Self {
let chosen = ctx.data_mut(|d| d.remove_temp::<String>(egui::Id::new(MORE_POPUP).with("chosen")));
ButtonBar { x: bar.min.x + 8.0, limit: bar.max.x - 8.0 - Self::MORE_W - 4.0, hidden: Vec::new(), chosen }
}
/// The "»" button and its menu, when buttons did not fit.
fn more(&mut self, app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painter, bar: Rect) {
if self.hidden.is_empty() {
return;
}
let t = app.tokens;
let r = Rect::from_min_size(pos2(bar.max.x - 8.0 - Self::MORE_W, bar.center().y - 9.0), vec2(Self::MORE_W, 18.0));
let pid = egui::Id::new(MORE_POPUP);
let resp = ui.interact(r, pid.with("button"), Sense::click()).on_hover_text(crate::i18n::tr("More"));
p.rect_filled(r, 3.0, if resp.hovered() { t.hover } else { Color32::from_rgb(0x2c, 0x2c, 0x2c) });
p.text(r.center(), Align2::CENTER_CENTER, "»", Tokens::ui(12.0), t.text);
app.auto.add("timeline.graph.more", r, "More");
if resp.clicked() {
let open = widgets::popup_is_open(ui, pid);
ui.data_mut(|d| d.insert_temp(pid.with("open"), !open));
}
if !widgets::popup_is_open(ui, pid) {
return;
}
let rows = self.hidden.len();
let pos = r.left_top() - vec2(0.0, rows as f32 * 22.0 + 18.0);
let chosen = widgets::popup_list(ui, pid, pos, r, rows, 0, |ui| {
let mut chosen = None;
for (id, label, on, tip) in &self.hidden {
let item = ui.selectable_label(*on, label.as_str()).on_hover_text(tip.as_str());
app.auto.add(&format!("timeline.graph.more.{id}"), item.rect, tip);
if item.clicked() {
chosen = Some(id.clone());
}
}
chosen
});
if let Some(id) = chosen {
ui.data_mut(|d| d.insert_temp(pid.with("chosen"), id));
ui.ctx().request_repaint();
}
}
}
fn text_button(
app: &mut EffectcraftApp,
ui: &mut egui::Ui,
p: &egui::Painter,
bar: &mut ButtonBar,
y: f32,
label: &str,
on: bool,
id: &str,
tip: &str,
) -> bool {
let t = app.tokens;
let w = p.layout_no_wrap(label.to_string(), Tokens::ui(11.0), t.text).size().x + 14.0;
let r = Rect::from_min_size(pos2(*x, y - 9.0), vec2(w, 18.0));
// Once one button overflows, the rest follow it into the menu (they keep their order).
if !bar.hidden.is_empty() || bar.x + w > bar.limit {
bar.hidden.push((id.to_string(), label.to_string(), on, tip.to_string()));
return bar.chosen.as_deref() == Some(id);
}
let r = Rect::from_min_size(pos2(bar.x, y - 9.0), vec2(w, 18.0));
let resp = ui.interact(r, egui::Id::new(("graph-btn", id)), Sense::click()).on_hover_text(tip);
p.rect_filled(
r,
@@ -180,7 +258,7 @@ fn text_button(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painter, x
);
p.text(r.center(), Align2::CENTER_CENTER, label, Tokens::ui(11.0), if on { Color32::WHITE } else { t.text });
app.auto.add(&format!("timeline.graph.{id}"), r, tip);
*x += w + 4.0;
bar.x += w + 4.0;
resp.clicked()
}
@@ -446,14 +524,14 @@ pub(crate) fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painte
p.rect_filled(bar, 0.0, t.panel_bg);
p.line_segment([bar.left_top(), bar.right_top()], Stroke::new(1.0, t.separator));
let y = bar.center().y;
let mut x = bar.min.x + 8.0;
let mut buttons = ButtonBar::new(&ctx, bar);
let tl = &app.ui.timeline;
let (auto_type, show_sel, auto_on) = (tl.graph_mode == "auto", tl.graph_show_selected, tl.graph_auto_zoom);
if text_button(
app,
ui,
p,
&mut x,
&mut buttons,
y,
"Auto",
auto_type,
@@ -463,45 +541,45 @@ pub(crate) fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painte
app.ui.timeline.graph_mode = if auto_type { if speed { "speed" } else { "value" } } else { "auto" }.into();
}
// Choosing a graph type turns Auto-Select off, as in After Effects.
if text_button(app, ui, p, &mut x, y, "Value", !speed, "valueGraph", "Edit Value Graph") {
if text_button(app, ui, p, &mut buttons, y, "Value", !speed, "valueGraph", "Edit Value Graph") {
app.ui.timeline.graph_mode = "value".into();
}
if text_button(app, ui, p, &mut x, y, "Speed", speed, "speedGraph", "Edit Speed Graph") {
if text_button(app, ui, p, &mut buttons, y, "Speed", speed, "speedGraph", "Edit Speed Graph") {
app.ui.timeline.graph_mode = "speed".into();
}
x += 6.0;
if text_button(app, ui, p, &mut x, y, "Selected", show_sel, "showSelected", "Show Selected Properties (else all animated properties)") {
buttons.x += 6.0;
if text_button(app, ui, p, &mut buttons, y, "Selected", show_sel, "showSelected", "Show Selected Properties (else all animated properties)") {
app.ui.timeline.graph_show_selected = !show_sel;
}
if text_button(app, ui, p, &mut x, y, "Auto-zoom", auto_on, "autoZoom", "Auto-zoom graph height") {
if text_button(app, ui, p, &mut buttons, y, "Auto-zoom", auto_on, "autoZoom", "Auto-zoom graph height") {
app.ui.timeline.graph_auto_zoom = !auto_on;
}
if text_button(app, ui, p, &mut x, y, "Fit", false, "fit", "Fit all graphs to view") {
if text_button(app, ui, p, &mut buttons, y, "Fit", false, "fit", "Fit all graphs to view") {
app.ui.timeline.graph_auto_zoom = true;
app.ui.timeline.pps = None;
}
if text_button(app, ui, p, &mut x, y, "Fit Selection", false, "fitSelection", "Fit selection to view") {
if text_button(app, ui, p, &mut buttons, y, "Fit Selection", false, "fitSelection", "Fit selection to view") {
fit_selection(app, &cs, tm, plot);
}
x += 6.0;
buttons.x += 6.0;
let (snap, reference, tbox) = (app.ui.timeline.graph_snap, app.ui.timeline.graph_reference, app.ui.timeline.graph_transform_box);
if text_button(app, ui, p, &mut x, y, "Snap", snap, "snap", "Snap (key drags snap to the current time and other keys)") {
if text_button(app, ui, p, &mut buttons, y, "Snap", snap, "snap", "Snap (key drags snap to the current time and other keys)") {
app.ui.timeline.graph_snap = !snap;
}
if text_button(app, ui, p, &mut x, y, "Reference", reference, "reference", "Show Reference Graph") {
if text_button(app, ui, p, &mut buttons, y, "Reference", reference, "reference", "Show Reference Graph") {
app.ui.timeline.graph_reference = !reference;
}
if text_button(app, ui, p, &mut x, y, "Transform Box", tbox, "transformBox", "Show Transform Box when multiple keys are selected") {
if text_button(app, ui, p, &mut buttons, y, "Transform Box", tbox, "transformBox", "Show Transform Box when multiple keys are selected") {
app.ui.timeline.graph_transform_box = !tbox;
}
x += 6.0;
buttons.x += 6.0;
let pos_sel = app.session.state.selected_props.iter().find_map(|(l, u)| super::timeline::is_position(comp.layer(*l)?, *u).then_some(l.0));
if text_button(app, ui, p, &mut x, y, "Separate Dimensions", false, "separateDimensions", "Separate Dimensions (Position)")
if text_button(app, ui, p, &mut buttons, y, "Separate Dimensions", false, "separateDimensions", "Separate Dimensions (Position)")
&& let Some(l) = pos_sel.or(app.session.state.selected_layers.first().map(|l| l.0))
{
actions.push(("prop.separateDimensions".into(), json!({"layer": l})));
}
x += 6.0;
buttons.x += 6.0;
for (label, cmd, params, tip) in [
("Hold", "keys.interpolation", json!({"interpolation": "hold"}), "Convert selected keyframes to Hold"),
("Linear", "keys.interpolation", json!({"interpolation": "linear"}), "Convert selected keyframes to Linear"),
@@ -511,10 +589,11 @@ pub(crate) fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painte
("Ease Out", "keys.easyEaseOut", json!({}), "Easy Ease Out (Ctrl+Shift+F9)"),
] {
let id = cmd.trim_start_matches("keys.").to_string() + label.split_whitespace().next().unwrap_or("");
if text_button(app, ui, p, &mut x, y, label, false, &id, tip) {
if text_button(app, ui, p, &mut buttons, y, label, false, &id, tip) {
actions.push((cmd.into(), params));
}
}
buttons.more(app, ui, p, bar);
}
/// Zoom time and value to the selected keys (and their handles).
+40 -24
View File
@@ -15,6 +15,7 @@ use egui::{Align2, Color32, Rect, Sense, Stroke, StrokeKind, pos2, vec2};
use serde::Serialize;
use serde_json::json;
use crate::i18n::{tr, tr_args};
use crate::icons::{self, Icon};
use crate::theme::Tokens;
use crate::{EffectcraftApp, widgets};
@@ -87,13 +88,13 @@ pub fn format_utc(secs: i64) -> String {
/// date after a week).
pub fn format_ago(now: i64, then: i64) -> String {
let d = now.saturating_sub(then);
let plural = |n: i64, unit: &str| format!("{n} {unit}{} ago", if n == 1 { "" } else { "s" });
let plural = |n: i64, one: &str, many: &str| tr_args(if n == 1 { one } else { many }, &[&n]);
match d {
i64::MIN..60 => "Just now".to_string(),
60..3_600 => plural(d / 60, "minute"),
3_600..86_400 => plural(d / 3_600, "hour"),
86_400..172_800 => "Yesterday".to_string(),
172_800..604_800 => plural(d / 86_400, "day"),
i64::MIN..60 => tr("Just now").to_string(),
60..3_600 => plural(d / 60, "{} minute ago", "{} minutes ago"),
3_600..86_400 => plural(d / 3_600, "{} hour ago", "{} hours ago"),
86_400..172_800 => tr("Yesterday").to_string(),
172_800..604_800 => plural(d / 86_400, "{} day ago", "{} days ago"),
_ => format_utc(then).get(..10).unwrap_or_default().to_string(),
}
}
@@ -112,14 +113,14 @@ pub fn format_size(bytes: u64) -> String {
}
}
/// The Kind column: what a recent file is, by its extension.
/// The Kind column: what a recent file is, by its extension, in the interface language.
fn kind_of(path: &str) -> &'static str {
match std::path::Path::new(path).extension().and_then(|e| e.to_str()).map(str::to_ascii_lowercase).as_deref() {
tr(match std::path::Path::new(path).extension().and_then(|e| e.to_str()).map(str::to_ascii_lowercase).as_deref() {
Some("ecproj") => "EffectCraft project",
Some("aep" | "aepx") => "After Effects project",
Some("json" | "lottie") => "Lottie animation",
_ => "Project",
}
})
}
/// The config-store name of a project's thumbnail.
@@ -237,7 +238,7 @@ fn rail_ui(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painter, rail:
let mut y = rail.min.y + 20.0;
for (label, id, primary) in [("New Project", "file.newProject", true), ("Open Project…", "file.open", false)] {
let r = Rect::from_min_size(pos2(x0, y), vec2(w, 34.0));
if widgets::text_button(ui, r, label, primary, &t, egui::Id::new(("home", id))).clicked() {
if widgets::text_button(ui, r, tr(label), primary, &t, egui::Id::new(("home", id))).clicked() {
actions.push((id, json!({})));
}
app.auto.add(&format!("home.{id}"), r, label);
@@ -261,7 +262,7 @@ fn rail_ui(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painter, rail:
}
let col = if on { t.tab_text_active } else { t.text_dim };
icons::paint(p, Rect::from_center_size(pos2(r.min.x + 20.0, r.center().y), vec2(16.0, 16.0)), icon, col);
p.text(pos2(r.min.x + 40.0, r.center().y), Align2::LEFT_CENTER, label, Tokens::medium(13.0), col);
p.text(pos2(r.min.x + 40.0, r.center().y), Align2::LEFT_CENTER, tr(label), Tokens::medium(13.0), col);
app.auto.add(&format!("home.tab.{}", label.to_ascii_lowercase()), r, label);
if resp.clicked() {
app.ui.home_learn = k == 2;
@@ -281,7 +282,7 @@ fn rail_ui(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painter, rail:
let foot_h = 22.0 + links.len() as f32 * 30.0 + 22.0 + sib_rows * 26.0;
let mut cy = (rail.max.y - 16.0 - foot_h).max(y + 16.0);
p.line_segment([pos2(x0, cy - 10.0), pos2(x0 + w, cy - 10.0)], Stroke::new(1.0, t.separator));
p.text(pos2(x0, cy + 6.0), Align2::LEFT_CENTER, crate::i18n::tr("Community"), Tokens::semibold(12.0), t.text_dim);
p.text(pos2(x0, cy + 6.0), Align2::LEFT_CENTER, tr("Community"), Tokens::semibold(12.0), t.text_dim);
cy += 22.0;
for (icon, label, cmd) in links {
let r = Rect::from_min_size(pos2(x0, cy), vec2(w, 26.0));
@@ -294,14 +295,14 @@ fn rail_ui(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painter, rail:
}
let col = if discord { Color32::WHITE } else { t.text };
icons::paint(p, Rect::from_center_size(pos2(r.min.x + 16.0, r.center().y), vec2(13.0, 13.0)), icon, col);
p.text(pos2(r.min.x + 30.0, r.center().y), Align2::LEFT_CENTER, label, Tokens::medium(12.0), col);
p.text(pos2(r.min.x + 30.0, r.center().y), Align2::LEFT_CENTER, tr(label), Tokens::medium(12.0), col);
app.auto.add(&format!("home.{cmd}"), r, label);
if resp.clicked() {
let _ = app.session.execute(cmd, json!({}));
}
cy += 30.0;
}
p.text(pos2(x0, cy + 8.0), Align2::LEFT_CENTER, crate::i18n::tr("More ArtCraft apps"), Tokens::ui(11.0), t.text_faint);
p.text(pos2(x0, cy + 8.0), Align2::LEFT_CENTER, tr("More ArtCraft apps"), Tokens::ui(11.0), t.text_faint);
cy += 20.0;
let sw = (w - 6.0) / 2.0;
for (i, (name, slug)) in sib.iter().enumerate() {
@@ -328,12 +329,12 @@ fn home_page(
let t = app.tokens;
let (x0, w) = (area.min.x, area.width());
let mut y = area.min.y;
p.text(pos2(x0, y + 14.0), Align2::LEFT_CENTER, crate::i18n::tr("Welcome to EffectCraft"), Tokens::semibold(26.0), t.tab_text_active);
p.text(pos2(x0, y + 14.0), Align2::LEFT_CENTER, tr("Welcome to EffectCraft"), Tokens::semibold(26.0), t.tab_text_active);
y += 40.0;
p.text(
pos2(x0, y + 8.0),
Align2::LEFT_CENTER,
format!("Motion graphics and visual effects · version {}", env!("CARGO_PKG_VERSION")),
tr_args("Motion graphics and visual effects · version {}", &[&env!("CARGO_PKG_VERSION")]),
Tokens::ui(12.5),
t.text_dim,
);
@@ -355,7 +356,7 @@ fn home_page(
let ir = Rect::from_center_size(pos2(r.min.x + 30.0, r.center().y), vec2(32.0, 32.0));
p.rect_filled(ir, 7.0, t.accent.gamma_multiply(0.18));
icons::paint(p, ir.shrink(8.0), icon, t.accent);
p.with_clip_rect(r.shrink(2.0)).text(pos2(ir.max.x + 12.0, r.center().y), Align2::LEFT_CENTER, label, Tokens::medium(12.5), t.text);
p.with_clip_rect(r.shrink(2.0)).text(pos2(ir.max.x + 12.0, r.center().y), Align2::LEFT_CENTER, tr(label), Tokens::medium(12.5), t.text);
app.auto.add(&format!("home.{id}"), r, label);
if resp.clicked() {
if id == "templates" {
@@ -369,18 +370,18 @@ fn home_page(
y += 64.0 + 36.0;
// Recent: heading, filter, clear.
p.text(pos2(x0, y + 12.0), Align2::LEFT_CENTER, crate::i18n::tr("Recent"), Tokens::semibold(17.0), t.tab_text_active);
p.text(pos2(x0, y + 12.0), Align2::LEFT_CENTER, tr("Recent"), Tokens::semibold(17.0), t.tab_text_active);
let entries = recent_entries(&app.session.prefs);
let fid = egui::Id::new("home-filter");
let mut filter: String = ctx.data(|d| d.get_temp(fid)).unwrap_or_default();
let fr = Rect::from_min_size(pos2(x0 + w - 240.0, y), vec2(240.0, 26.0));
widgets::search_field(ui, fr, &mut filter, "Filter recent files", &t);
widgets::search_field(ui, fr, &mut filter, tr("Filter recent files"), &t);
app.auto.add("home.filter", fr, "Filter recent files");
ctx.data_mut(|d| d.insert_temp(fid, filter.clone()));
if !entries.is_empty() {
let cr = Rect::from_min_size(pos2(fr.min.x - 96.0, y + 3.0), vec2(84.0, 20.0));
let resp = ui.interact(cr, egui::Id::new("home-clear-recent"), Sense::click());
p.text(cr.right_center(), Align2::RIGHT_CENTER, crate::i18n::tr("Clear list"), Tokens::ui(11.5), if resp.hovered() { t.text } else { t.text_faint });
p.text(cr.right_center(), Align2::RIGHT_CENTER, tr("Clear list"), Tokens::ui(11.5), if resp.hovered() { t.text } else { t.text_faint });
app.auto.add("home.clearRecent", cr, "Clear Recent Projects");
if resp.clicked() {
actions.push(("file.clearRecent", json!({})));
@@ -390,7 +391,7 @@ fn home_page(
// Column headings (Name / Opened / Size / Kind, as in After Effects).
let (opened_x, size_x, kind_x) = (x0 + w * 0.52, x0 + w * 0.68, x0 + w * 0.80);
for (label, x) in [("NAME", x0 + 76.0), ("OPENED", opened_x), ("SIZE", size_x), ("KIND", kind_x)] {
p.text(pos2(x, y), Align2::LEFT_CENTER, label, Tokens::medium(10.5), t.text_faint);
p.text(pos2(x, y), Align2::LEFT_CENTER, tr(label), Tokens::medium(10.5), t.text_faint);
}
y += 12.0;
p.line_segment([pos2(x0, y), pos2(x0 + w, y)], Stroke::new(1.0, t.separator));
@@ -406,8 +407,8 @@ fn home_page(
} else {
("No recent files match the filter", "Try another name or folder.")
};
p.text(c + vec2(0.0, 40.0), Align2::CENTER_CENTER, head, Tokens::semibold(14.0), t.text);
p.text(c + vec2(0.0, 62.0), Align2::CENTER_CENTER, sub, Tokens::ui(12.0), t.text_faint);
p.text(c + vec2(0.0, 40.0), Align2::CENTER_CENTER, tr(head), Tokens::semibold(14.0), t.text);
p.text(c + vec2(0.0, 62.0), Align2::CENTER_CENTER, tr(sub), Tokens::ui(12.0), t.text_faint);
}
let now = web_time::SystemTime::now().duration_since(web_time::UNIX_EPOCH).map_or(0, |d| i64::try_from(d.as_secs()).unwrap_or(i64::MAX));
let row_h = 48.0;
@@ -439,7 +440,7 @@ fn home_page(
Tokens::medium(13.0),
if e.exists { t.tab_text_active } else { t.text_dim },
);
let sub = if e.exists { e.folder.clone() } else { format!("{} (missing)", e.folder) };
let sub = if e.exists { e.folder.clone() } else { format!("{} {}", e.folder, crate::i18n::tr("(missing)")) };
name_clip.text(pos2(tr.max.x + 6.0, r.center().y + 9.0), Align2::LEFT_CENTER, sub, Tokens::ui(11.0), t.text_faint);
let cell = |x: f32, x1: f32, text: String| {
p.with_clip_rect(Rect::from_min_max(pos2(x, r.min.y), pos2(x1 - 8.0, r.max.y))).text(
@@ -534,6 +535,21 @@ mod tests {
assert_eq!((kind_of("/a/B.ecproj"), kind_of("c.AEP"), kind_of("x")), ("EffectCraft project", "After Effects project", "Project"));
}
/// The Home screen's own text goes through the interface catalog (#574): the Opened and Kind
/// columns read in Chinese, and English stays English.
#[test]
fn home_text_follows_the_interface_language() {
let now = 1_790_000_000;
crate::i18n::set_current("zh-hans");
let zh = (format_ago(now, now - 5), format_ago(now, now - 3 * 3_600), format_ago(now, now - 90_000), kind_of("/a/B.ecproj"));
crate::i18n::set_current("zh-hant");
let tw = (format_ago(now, now - 60), kind_of("x"));
crate::i18n::set_current("en");
assert_eq!(zh, ("刚刚".into(), "3 小时前".into(), "昨天".into(), "EffectCraft 项目"));
assert_eq!(tw, ("1 分鐘前".into(), "專案"));
assert_eq!(tr("Filter recent files"), "Filter recent files");
}
#[test]
fn home_screen_registers_its_controls() {
let mut s = effectcraft_engine::Session::default();
+3 -5
View File
@@ -599,12 +599,10 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
actions.push(("layer.addItem".into(), json!({"item": id.0})));
ui.close();
}
// File ▸ New Comp from Selection on the selection (the right-clicked row joins it
// above): several items ask how in its dialog, as in After Effects (#360).
if ui.button(crate::i18n::tr("New Comp from Selection")).clicked() {
if let Some((w, h)) = it.dimensions() {
let d = it.duration().map(|d| d.seconds()).unwrap_or(10.0);
actions.push(("comp.new".into(), json!({"name": format!("{} Comp", it.name), "width": w, "height": h, "duration": d})));
actions.push(("layer.addItem".into(), json!({"item": id.0})));
}
actions.push(("file.newCompFromSelection".into(), json!({})));
ui.close();
}
ui.separator();
+26
View File
@@ -168,6 +168,32 @@ pub fn interact(
let sign = if kind == "bg" || kind == "refineErase" { "−" } else { "+" };
painter.text(hp, Align2::CENTER_CENTER, sign, Tokens::semibold(12.0), col);
}
// ⌘/Ctrl-drag resizes the brush (After Effects): the circle stays at the press point and its
// edge follows the pointer. No stroke is painted.
let rid = egui::Id::new("layer-panel-roto-resize");
if resp.drag_started()
&& mods.command
&& let Some(pos) = ui.input(|i| i.pointer.press_origin()).or(resp.interact_pointer_pos())
{
ctx.data_mut(|d| d.insert_temp(rid, pos));
}
if let Some(origin) = ctx.data(|d| d.get_temp::<Pos2>(rid)) {
if let Some(pos) = resp.interact_pointer_pos()
&& zoom > 0.0
{
let dia = (2.0 * f64::from(origin.distance(pos)) / zoom).clamp(1.0, 5000.0).round();
let key = if refine { "refineDiameter" } else { "diameter" };
if let Err(e) = app.session.execute("roto.options", json!({key: dia})) {
app.ui.status = e.to_string();
}
painter.circle_stroke(origin, (dia * 0.5 * zoom) as f32, Stroke::new(1.5, col));
painter.text(origin + vec2(0.0, -14.0), Align2::CENTER_BOTTOM, format!("{dia:.0} px"), Tokens::ui(11.0), col);
}
if resp.drag_stopped() || !resp.dragged() {
ctx.data_mut(|d| d.remove::<Pos2>(rid));
}
return true;
}
let did = egui::Id::new("layer-panel-roto-drag");
if resp.drag_started()
&& let Some(pos) = ui.input(|i| i.pointer.press_origin()).or(resp.interact_pointer_pos())
+69 -4
View File
@@ -682,10 +682,15 @@ pub(super) fn font_picker(app: &mut EffectcraftApp, ui: &mut egui::Ui, id: egui:
l
}
};
let o = pos2(resp.rect.max.x - 90.0, resp.rect.center().y + 5.0);
for poly in lines.iter() {
let pts: Vec<egui::Pos2> = poly.iter().map(|p| o + vec2(p[0], p[1])).collect();
ui.painter().add(egui::Shape::line(pts, egui::Stroke::new(1.0, t.text_dim)));
// Scaled and placed to fit the row's preview column, and clipped to the row (#577).
let room = Rect::from_min_max(pos2(resp.rect.max.x - 95.0, resp.rect.min.y + 1.0), pos2(resp.rect.max.x - 6.0, resp.rect.max.y - 1.0));
if let Some((k, o)) = fit_preview(&lines, room, resp.rect.center().y + 5.0) {
let painter = ui.painter().with_clip_rect(resp.rect.intersect(ui.clip_rect()));
for poly in lines.iter() {
let pts: Vec<egui::Pos2> =
poly.iter().filter(|p| p[0].is_finite() && p[1].is_finite()).map(|p| (o + vec2(p[0], p[1]) * k).to_pos2()).collect();
painter.add(egui::Shape::line(pts, egui::Stroke::new(1.0, t.text_dim)));
}
}
}
if resp.clicked() {
@@ -837,3 +842,63 @@ fn align_glyph(p: &egui::Painter, r: Rect, op: &str, distribute: bool, t: &Token
p.rect_filled(Rect::from_min_size(pos2(c.x + 3.0, off(9.0)), vec2(5.0, 9.0)), 1.0, t.text_dim);
}
}
/// Where a font's "Sample" outlines (`lines`, baseline at y = 0) go in `room`: `(scale, origin)`,
/// a point `p` landing at `origin + p * scale`. At their natural size on `baseline` when they fit;
/// fonts with large or odd metrics are scaled down (never up) and moved inside, so no preview
/// spills out of its row (#577). `None`: nothing to draw.
fn fit_preview(lines: &[Vec<[f32; 2]>], room: Rect, baseline: f32) -> Option<(f32, egui::Vec2)> {
let b = lines
.iter()
.flatten()
.filter(|p| p[0].is_finite() && p[1].is_finite())
.fold(Rect::NOTHING, |b, p| b.union(Rect::from_min_max(pos2(p[0], p[1]), pos2(p[0], p[1]))));
if !(b.min.x <= b.max.x && b.min.y <= b.max.y) || !room.is_positive() {
return None;
}
let k = (room.width() / b.width().max(1e-3)).min(room.height() / b.height().max(1e-3)).min(1.0);
let mut o = vec2(room.min.x - b.min.x * k, baseline);
let (top, bottom) = (o.y + b.min.y * k, o.y + b.max.y * k);
if top < room.min.y {
o.y += room.min.y - top;
} else if bottom > room.max.y {
o.y -= bottom - room.max.y;
}
Some((k, o))
}
#[cfg(test)]
mod tests {
use super::*;
/// #577: every font's "Sample" preview lands inside its row's preview box: at its natural size
/// when it fits, else scaled down; nothing for no outlines.
#[test]
fn font_previews_fit_their_row() {
let room = Rect::from_min_max(pos2(180.0, 101.0), pos2(294.0, 119.0));
let baseline = 115.0;
let inside = |lines: &[Vec<[f32; 2]>]| {
let (k, o) = fit_preview(lines, room, baseline).expect("a preview");
for p in lines.iter().flatten() {
let q = o + vec2(p[0], p[1]) * k;
assert!(room.expand(0.01).contains(pos2(q.x, q.y)), "{p:?} → {q:?} outside {room:?}");
}
k
};
// A plain 14 px sample fits as it is, on the baseline.
let plain = vec![vec![[0.0, -10.0], [60.0, -10.0], [60.0, 3.0], [0.0, 3.0]]];
assert_eq!(inside(&plain), 1.0);
assert_eq!(fit_preview(&plain, room, baseline).map(|(_, o)| o.y), Some(baseline));
// Huge, wide, tall, offset and negative-bearing outlines are scaled into the box.
for lines in [
vec![vec![[0.0, -80.0], [400.0, 30.0]]],
vec![vec![[-50.0, -5.0], [500.0, 2.0]]],
vec![vec![[10.0, -200.0], [12.0, 0.0]]],
vec![vec![[0.0, 20.0], [30.0, 40.0]]],
] {
assert!(inside(&lines) <= 1.0);
}
assert!(fit_preview(&[], room, baseline).is_none());
assert!(fit_preview(&[vec![[f32::NAN, 1.0]]], room, baseline).is_none());
}
}
+67 -24
View File
@@ -212,6 +212,29 @@ fn edge_scroll_speed(rows: Rect, y: f32, zone: f32) -> f32 {
(depth / zone).clamp(-3.0, 3.0) * 400.0
}
/// Autoscroll speed (points per second, left negative) of a ruler scrub held at `x`, past the
/// left (`x0`) or right (`x1`) end of the visible time span: faster the further past.
fn edge_scroll_speed_x(x0: f32, x1: f32, x: f32) -> f32 {
let depth = if x < x0 {
x - x0
} else if x > x1 {
x - x1
} else {
return 0.0;
};
(depth / 40.0).clamp(-3.0, 3.0) * 400.0 + depth.signum() * 100.0
}
/// The timeline's first visible time (`start`, showing `span` seconds of a `duration` comp)
/// paged so the CTI at `cti` is in view: unchanged while it is, else the page that starts at
/// the CTI (as After Effects pages the Timeline during a preview).
fn page_to_cti(start: f64, span: f64, duration: f64, cti: f64) -> f64 {
if !(start.is_finite() && span.is_finite() && cti.is_finite()) || (cti >= start && cti <= start + span) {
return start;
}
cti.min(duration - span).max(0.0)
}
/// The text of an expression being edited when its pick whip was pressed, and the selection in
/// it (characters): the pick whip's reference goes in there.
type WhipInto = (String, [usize; 2]);
@@ -1217,6 +1240,10 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
let pps = app.ui.timeline.pps.unwrap_or(fit_pps);
if app.ui.timeline.pps.is_none() {
app.ui.timeline.start = 0.0;
} else if app.playback.playing {
// During playback the view pages to keep the CTI in sight (After Effects).
let span = ((graph_x1 - graph_x0 - 6.0) as f64 / pps).max(0.0);
app.ui.timeline.start = page_to_cti(app.ui.timeline.start, span, comp.duration.seconds(), app.session.time().seconds());
}
let tm = TMap { x0: graph_x0 + 6.0, start: app.ui.timeline.start, pps };
ctx.data_mut(|d| d.insert_temp(tl_map_id(), (tm.x0, tm.start, tm.pps)));
@@ -1482,7 +1509,24 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
if ((rresp.is_pointer_button_down_on() && ui.input(|i| i.pointer.primary_down())) || rresp.clicked())
&& let Some(pt) = rresp.interact_pointer_pos()
{
let mut secs = tm.t(pt.x).max(0.0);
let mut x = pt.x;
// Zoomed in, a scrub held past either end of the ruler scrolls the timeline that way
// (faster the further), with the CTI at the edge (After Effects).
let mut tm = tm;
if rresp.dragged() && app.ui.timeline.pps.is_some() {
let speed = edge_scroll_speed_x(tm.x0, graph_x1, x);
if speed != 0.0 {
let dt = f64::from(ui.input(|i| i.stable_dt).clamp(0.0, 0.1));
let span = f64::from(graph_x1 - tm.x0) / pps;
let cur = app.ui.timeline.start;
let hi = (comp.duration.seconds() - span).max(0.0).max(cur);
app.ui.timeline.start = (cur + f64::from(speed) * dt / pps).clamp(0.0, hi);
tm.start = app.ui.timeline.start;
ui.ctx().request_repaint();
}
x = x.clamp(tm.x0, graph_x1);
}
let mut secs = tm.t(x).max(0.0);
// Shift-drag: snap to keyframes, in/out points, markers and the work area (8 px).
if ui.input(|i| i.modifiers.shift) {
let cands = snap_candidates(&comp, &build_rows(app, &comp));
@@ -1614,9 +1658,14 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
let max_scroll = (total_h - rows_rect.height() + rh).max(0.0);
if ui.rect_contains_pointer(rows_rect) {
let (dy, dx, zoom) = ui.input(|i| (i.smooth_scroll_delta.y, i.smooth_scroll_delta.x, i.modifiers.alt));
if zoom && dy.abs() > 0.0 {
// Ctrl/Cmd+wheel and a trackpad pinch reach egui as a zoom factor rather than a scroll
// (#428); like Alt+wheel they zoom time.
let pinch = ui.input(|i| i.zoom_delta()) as f64;
let at_x = ui.input(|i| i.pointer.hover_pos()).map(|p| p.x).unwrap_or(graph_x0);
if (pinch - 1.0).abs() > 1e-4 {
set_zoom(&mut app.ui.timeline, &comp, tm.pps * pinch, fit_pps, tm.t(at_x), (at_x - tm.x0) as f64);
} else if zoom && dy.abs() > 0.0 {
// Alt+wheel zooms around the pointer, out until the whole comp shows.
let at_x = ui.input(|i| i.pointer.hover_pos()).map(|p| p.x).unwrap_or(graph_x0);
set_zoom(&mut app.ui.timeline, &comp, tm.pps * (dy as f64 / 200.0).exp(), fit_pps, tm.t(at_x), (at_x - tm.x0) as f64);
} else {
let over_outline = ui.input(|i| i.pointer.hover_pos()).is_some_and(|p| p.x < graph_x0);
@@ -3445,26 +3494,6 @@ fn fmt_num(v: f64, d: usize) -> String {
format!("{v:.d$}")
}
/// Linked values after component `d` of `c` became `v`: the others scale by the same ratio
/// (Constrain Proportions); from 0 the others that are 0 too follow the value.
fn constrained(c: &[f64], d: usize, v: f64) -> Vec<f64> {
let Some(&old) = c.get(d) else { return c.to_vec() };
c.iter()
.enumerate()
.map(|(e, &x)| {
if e == d {
v
} else if old.abs() > 1e-9 {
x * v / old
} else if x.abs() <= 1e-9 {
v
} else {
x
}
})
.collect()
}
/// Inline value editor for a property row; pushes `prop.set` actions. Returns the rects of a
/// multi-dimension value's fields, one per dimension (none for other values).
fn value_editor(
@@ -3540,7 +3569,7 @@ fn value_editor(
// Alt edits one value of a linked pair.
let shown = if linked && !ui.input(|i| i.modifiers.alt) { n.min(c.len()) } else { 1 };
let from = if shown == 1 { d } else { 0 };
for (e, v) in constrained(c.get(from..from + shown).unwrap_or_default(), d - from, nv).into_iter().enumerate() {
for (e, v) in widgets::constrained(c.get(from..from + shown).unwrap_or_default(), d - from, nv).into_iter().enumerate() {
if let Some(slot) = nc.get_mut(from + e) {
*slot = v;
}
@@ -3624,6 +3653,20 @@ fn value_editor(
mod tests {
use super::*;
/// #468: playback pages the view to the CTI; a scrub past either end scrolls that way.
#[test]
fn the_view_pages_to_the_cti() {
assert_eq!(page_to_cti(10.0, 5.0, 60.0, 12.0), 10.0, "in view: unchanged");
assert_eq!(page_to_cti(10.0, 5.0, 60.0, 16.0), 16.0, "past the right end: the page from the CTI");
assert_eq!(page_to_cti(10.0, 5.0, 60.0, 3.0), 3.0, "before the start");
assert_eq!(page_to_cti(10.0, 5.0, 60.0, 58.0), 55.0, "not past the comp's end");
assert_eq!(page_to_cti(10.0, 80.0, 60.0, 95.0), 0.0);
assert_eq!(page_to_cti(10.0, f64::NAN, 60.0, 95.0), 10.0);
assert_eq!(edge_scroll_speed_x(100.0, 500.0, 300.0), 0.0);
assert!(edge_scroll_speed_x(100.0, 500.0, 501.0) > 0.0 && edge_scroll_speed_x(100.0, 500.0, 99.0) < 0.0);
assert!(edge_scroll_speed_x(100.0, 500.0, 600.0) > edge_scroll_speed_x(100.0, 500.0, 520.0));
}
/// A comp with two solids; "Box" has an animated Position and a Gaussian Blur.
fn app() -> EffectcraftApp {
let mut s = effectcraft_engine::Session::default();
+4
View File
@@ -1644,6 +1644,10 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
_ => {
if pick(app, &ectx, cpt, mods.shift).is_none() {
let _ = app.session.execute("edit.deselectAll", json!({}));
} else if !mods.shift && !app.session.state.selected_vertices.is_empty() && ov::segment_at(&paths, &map, pos, 6.0).is_none() {
// A click away from the paths, even on their layer, deselects the mask
// points (the layer stays selected), as in After Effects (#513).
let _ = app.session.execute("mask.selectVertices", json!({"vertices": []}));
}
}
}
+16 -2
View File
@@ -726,9 +726,23 @@ pub(crate) fn bottom_bar(app: &mut EffectcraftApp, ui: &mut egui::Ui, bar: Rect,
x += 4.0;
// 3D renderer + view.
if comp.has_3d() {
// The comp's 3D renderer, as After Effects' Composition panel shows it: the menu switches
// it through Composition Settings (undoable), and the automation label is the renderer.
use effectcraft_engine::project::Renderer;
let renderers = [(Renderer::Classic3D, "Classic 3D", "classic3D"), (Renderer::Advanced3D, "Advanced 3D", "advanced3D")];
let current = renderers.iter().find(|(r, ..)| *r == comp.renderer).map_or("Classic 3D", |(_, label, _)| *label);
let r = Rect::from_min_size(pos2(x, cy - 10.0), vec2(96.0, 20.0));
let _ = widgets::dropdown(ui, r, "Classic 3D", &t, egui::Id::new("vw-3d"));
app.auto.add("viewer.renderer3d", r, "3D Renderer");
if widgets::dropdown(ui, r, crate::i18n::tr(current), &t, egui::Id::new("vw-3d")).clicked() {
toggle_popup(ui, "vw-3d-pop");
}
app.auto.add("viewer.renderer3d", r, current);
let items: Vec<(String, bool)> = renderers.iter().map(|(r, label, _)| (label.to_string(), *r == comp.renderer)).collect();
if let Some((renderer, _, id)) = popup(app, ui, "vw-3d-pop", r, &items, "renderer3dItem").and_then(|i| renderers.get(i))
&& *renderer != comp.renderer
&& let Err(e) = app.session.execute("comp.settings", json!({"renderer": id}))
{
app.ui.status = e.to_string();
}
x = r.max.x + 4.0;
let cid = app.session.active_comp_id();
let cur = cid.and_then(|c| app.session.state.views3d.get(&c)).map(|v| v.current).unwrap_or_default();
+7 -1
View File
@@ -88,7 +88,10 @@ pub fn dropped_files(app: &mut EffectcraftApp, ui: &egui::Ui, bounds: Rect) {
None
};
let params = json!({
// Dropped files import as stills and folders' numbered stills as sequences; Alt-drag
// imports a folder's files one by one (After Effects).
let alt = ctx.input(|i| i.modifiers.alt);
let mut params = json!({
"paths": rest,
"drag": true,
"importAs": app.session.prefs.drag_import_as(),
@@ -96,6 +99,9 @@ pub fn dropped_files(app: &mut EffectcraftApp, ui: &egui::Ui, bounds: Rect) {
"position": at,
"background": true
});
if alt {
params["sequence"] = json!(false);
}
if let Err(e) = crate::menus::invoke(app, &ctx, "file.import", params) {
errors.push(crate::i18n::tr_args("Cannot import dropped files: {}", &[&e]));
+46
View File
@@ -278,6 +278,8 @@ fn field(ui: &mut Ui, rect: Rect, text: &mut String, hint: &str, t: &Tokens, sea
.hint_text(hint)
.frame(egui::Frame::NONE)
.desired_width(inner.width())
// As tall as the field, so the text and the hint sit on its middle (they sat high, #581).
.min_size(inner.size())
.font(Tokens::ui(12.0))
.vertical_align(egui::Align::Center),
)
@@ -565,3 +567,47 @@ pub fn popup_is_open(ui: &Ui, id: egui::Id) -> bool {
pub fn open_popup(ui: &Ui, id: egui::Id) {
ui.data_mut(|d| d.insert_temp(id.with("open"), true));
}
/// Linked values after component `d` of `c` became `v`: the others scale by the same ratio
/// (Constrain Proportions); from 0 the others that are 0 too follow the value.
pub(crate) fn constrained(c: &[f64], d: usize, v: f64) -> Vec<f64> {
let Some(&old) = c.get(d) else { return c.to_vec() };
c.iter()
.enumerate()
.map(|(e, &x)| {
if e == d {
v
} else if old.abs() > 1e-9 {
x * v / old
} else if x.abs() <= 1e-9 {
v
} else {
x
}
})
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
/// #581: a field's text edit fills it, so its text and hint sit on the field's middle line
/// (it was a row at the top of the field, its text high).
#[test]
fn field_text_is_vertically_centred() {
let ctx = egui::Context::default();
let t = Tokens::for_kind(Default::default());
for h in [22.0, 26.0, 30.0] {
let field = Rect::from_min_size(pos2(20.0, 40.0), vec2(220.0, h));
let mut text = String::new();
let mut got = None;
let mut out = ctx.run_ui(Default::default(), |ui| {
got = Some(search_field(ui, field, &mut text, "Filter recent files", &t).rect);
});
out.textures_delta.clear();
let r = got.expect("the field was drawn");
assert!((r.center().y - field.center().y).abs() < 0.5 && r.height() >= h - 2.5, "{h}: {r:?} in {field:?}");
}
}
}
+31
View File
@@ -47,6 +47,37 @@ fn viewer_registers_3d_controls() {
assert!(ids.iter().any(|i| i.starts_with("viewer.camera.")), "camera wireframe registered");
}
/// Click the centre of the automation element `id`.
fn click_id(h: &mut Harness<'_, EffectcraftApp>, id: &str) {
let [x, y, w, hh] = h.state().auto.find(id).unwrap_or_else(|| panic!("{id} is registered")).rect;
let p = egui::pos2(x + w / 2.0, y + hh / 2.0);
h.input_mut().events.push(egui::Event::PointerMoved(p));
h.input_mut().events.push(egui::Event::PointerButton { pos: p, button: egui::PointerButton::Primary, pressed: true, modifiers: egui::Modifiers::NONE });
h.step();
h.input_mut().events.push(egui::Event::PointerButton { pos: p, button: egui::PointerButton::Primary, pressed: false, modifiers: egui::Modifiers::NONE });
h.run_steps(3);
}
/// #307: the viewer's 3D Renderer menu shows the comp's renderer (it always read "Classic 3D")
/// and switches it, as After Effects' Composition panel does.
#[test]
fn renderer_menu_shows_and_switches_the_comp_renderer() {
let mut h = Harness::builder().with_size(egui::vec2(1600.0, 1000.0)).build_eframe(|_| app());
h.state_mut().session.execute("comp.settings", json!({"renderer": "classic3D"})).unwrap();
h.run_steps(3);
let label = |h: &Harness<'_, EffectcraftApp>| h.state().auto.find("viewer.renderer3d").map(|e| e.label.clone());
assert_eq!(label(&h).as_deref(), Some("Classic 3D"));
click_id(&mut h, "viewer.renderer3d");
click_id(&mut h, "viewer.renderer3dItem.1");
let renderer = h.state().session.active_comp().unwrap().renderer;
assert_eq!(renderer, effectcraft_engine::project::Renderer::Advanced3D);
h.run_steps(2);
assert_eq!(label(&h).as_deref(), Some("Advanced 3D"));
// One undoable Composition Settings step.
h.state_mut().session.execute("edit.undo", json!({})).unwrap();
assert_eq!(h.state().session.active_comp().unwrap().renderer, effectcraft_engine::project::Renderer::Classic3D);
}
#[test]
fn camera_and_light_dialogs_create_layers() {
let mut h = Harness::builder().with_size(egui::vec2(1600.0, 1000.0)).build_eframe(|_| app());
+17 -11
View File
@@ -408,7 +408,7 @@ fn footage_items(h: &Harness<'_, EffectcraftApp>) -> Vec<(String, usize)> {
/// #297: File ▸ Import ▸ File… on one numbered still asks, as After Effects' "PNG Sequence"
/// checkbox does, whether to import its whole run as one image sequence (and at what frame
/// rate); unticked, the file imports as a still.
/// rate); unticked (the default, as in After Effects), the file imports as a still.
#[test]
fn import_dialog_offers_the_image_sequence_option() {
let (mut h, _) = harness();
@@ -424,35 +424,41 @@ fn import_dialog_offers_the_image_sequence_option() {
assert_eq!(label(&h, "form.field.sequence").as_deref(), Some("PNG Sequence"));
assert!(label(&h, "form.field.alphabetical").is_some() && label(&h, "form.field.frameRate").is_some());
assert!(footage_items(&h).is_empty(), "nothing imported before OK");
// Unticked (the default, #431): the picked file alone, as a still.
click_id(&mut h, "form.ok");
step_until(&mut h, |h| !footage_items(h).is_empty());
assert_eq!(footage_items(&h), [("frame_[0001-0003].png".to_string(), 3)]);
assert_eq!(footage_items(&h), [("frame_0002.png".to_string(), 0)]);
// Unticked: the picked file alone, as a still.
// Ticked: the whole run as one sequence.
effectcraft_ui_egui::menus::invoke(h.state_mut(), &ctx, "file.import", json!({})).unwrap();
h.run_steps(2);
click_id(&mut h, "form.field.sequence");
click_id(&mut h, "form.ok");
step_until(&mut h, |h| footage_items(h).len() == 2);
assert_eq!(footage_items(&h)[1], ("frame_0002.png".to_string(), 0));
assert_eq!(footage_items(&h)[1], ("frame_[0001-0003].png".to_string(), 3));
let _ = std::fs::remove_dir_all(&dir);
}
/// #297: every frame of a sequence dropped on the window arrives as one image sequence (it used
/// to make one item per frame), and Interpret Footage shows its frames and Start Timecode (missing frames
/// always show colour bars, as in After Effects: there is no Missing Frames choice).
/// #297: a folder of frames dropped on the window arrives as one image sequence, and Interpret
/// Footage shows its frames and Start Timecode (missing frames always show colour bars, as in
/// After Effects: there is no Missing Frames choice). #431: dropped files import as stills, one
/// item each, as in After Effects.
#[test]
fn dropped_frames_import_as_one_sequence_with_sequence_interpretation() {
fn a_dropped_folder_imports_as_one_sequence_with_sequence_interpretation() {
let (mut h, _) = harness();
let dir = frames("drop", 4);
h.state_mut().session.importer = Some(Arc::new(Stills));
for k in 1..=4 {
for k in 1..=2 {
h.input_mut().dropped_files.push(Arc::new(Dropped(dir.join(format!("frame_{k:04}.png")))));
}
h.run_steps(2);
step_until(&mut h, |h| !footage_items(h).is_empty());
step_until(&mut h, |h| footage_items(h).len() == 2);
assert_eq!(footage_items(&h), [("frame_0001.png".to_string(), 0), ("frame_0002.png".to_string(), 0)]);
h.input_mut().dropped_files.push(Arc::new(Dropped(dir.clone())));
h.run_steps(2);
assert_eq!(footage_items(&h), [("frame_[0001-0004].png".to_string(), 4)]);
step_until(&mut h, |h| footage_items(h).len() == 3);
h.run_steps(2);
assert_eq!(footage_items(&h)[2], ("frame_[0001-0004].png".to_string(), 4));
assert!(h.state().dialog.is_none(), "drops don't ask");
// Interpret Footage on it (the import selected it).
let ctx = h.ctx.clone();
@@ -574,3 +574,85 @@ fn custom_effect_editors_activate_their_owner() {
click(&mut h, graph);
assert!(h.state().session.state.selected_props.contains(&(LayerId(x.small), x.curves)));
}
/// #514: Apply Color LUT asks for its LUT as it is applied (as in After Effects), Effect
/// Controls shows the file with a Choose… button that picks another, and the LUT changes the
/// pixels. #473: a text parameter (Cryptomatte's Selection) has a field that sets it.
#[test]
fn file_and_text_parameters_have_widgets() {
let dir = std::env::temp_dir().join(format!("ec-ui-lut-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
// A 2×2×2 LUT that inverts, and one that does nothing.
let cube = |inv: bool| {
let mut t = String::from("LUT_3D_SIZE 2\n");
for b in 0..2 {
for g in 0..2 {
for r in 0..2 {
let f = |c: i32| if inv { 1 - c } else { c };
t += &format!("{} {} {}\n", f(r), f(g), f(b));
}
}
}
t
};
let (invert, identity) = (dir.join("invert.cube"), dir.join("identity.cube"));
std::fs::write(&invert, cube(true)).unwrap();
std::fs::write(&identity, cube(false)).unwrap();
let mut s = Session::default();
s.execute("comp.new", json!({"name": "LUT", "width": 64, "height": 32, "frameRate": 30, "duration": 1})).unwrap();
let l = s.execute("layer.newSolid", json!({"name": "Plate", "color": "#ff0000", "width": 64, "height": 32})).unwrap()["layer"].as_u64().unwrap();
s.state.selected_layers = vec![LayerId(l)];
let mut app = EffectcraftApp::new(s);
app.show_panel(PanelKind::EffectControls);
let picked = std::sync::Arc::new(std::sync::Mutex::new(invert.to_string_lossy().to_string()));
let pick = picked.clone();
app.hooks.pick_files = Some(Box::new(move |exts: &[&str]| {
assert_eq!(exts, ["cube"]);
vec![pick.lock().unwrap().clone()]
}));
let mut h = Harness::builder().with_size(egui::vec2(1700.0, 1100.0)).build_eframe(|_| app);
settle(&mut h);
let ctx = h.ctx.clone();
let r = effectcraft_ui_egui::menus::invoke(h.state_mut(), &ctx, "effect.apply", json!({"effect": "Apply Color LUT"})).unwrap();
let fx = r["effects"][0].as_u64().unwrap();
let lut = |h: &Harness<'_, EffectcraftApp>| {
let g = layer(&h.state().session, l).effects().and_then(|f| f.groups().find(|g| g.uid == fx).cloned()).unwrap();
let p = g.get("lut").unwrap().clone();
(
p.uid,
match p.value {
KV::Str(s) => s,
_ => String::new(),
},
)
};
assert_eq!(lut(&h).1, invert.to_string_lossy(), "the LUT was asked for on apply");
let px = |h: &Harness<'_, EffectcraftApp>| {
let s = &h.state().session;
s.render(s.active_comp_id().unwrap(), effectcraft_engine::time::Tick::ZERO, Default::default()).get(10, 10)
};
assert!(px(&h)[0] < 0.05 && px(&h)[1] > 0.95, "red inverted to cyan: {:?}", px(&h));
settle(&mut h);
let uid = lut(&h).0;
assert!(h.state().auto.find(&format!("effectControls.prop.{uid}.value")).is_some_and(|e| e.label == "invert.cube"));
*picked.lock().unwrap() = identity.to_string_lossy().to_string();
let at = rect_of(&h, &format!("effectControls.prop.{uid}.choose")).center();
click(&mut h, at);
assert_eq!(lut(&h).1, identity.to_string_lossy());
assert!(px(&h)[0] > 0.95, "{:?}", px(&h));
// A text parameter: type into its field; it is set when the field loses the focus.
let crypto = h.state_mut().session.execute("effect.apply", json!({"layers": [l], "effect": "ec.3d.cryptomatte"})).unwrap()["effects"][0].as_u64().unwrap();
settle(&mut h);
let sel =
layer(&h.state().session, l).effects().and_then(|f| f.groups().find(|g| g.uid == crypto).and_then(|g| g.get("selection")).map(|p| p.uid)).unwrap();
let at = rect_of(&h, &format!("effectControls.prop.{sel}.value")).center();
click(&mut h, at);
h.event(egui::Event::Text("left*".into()));
h.step();
h.key_press(egui::Key::Enter);
h.run_steps(3);
let v = layer(&h.state().session, l).props.find(sel).unwrap().value.clone();
assert_eq!(v, KV::Str("left*".into()));
let _ = std::fs::remove_dir_all(dir);
}
+55
View File
@@ -293,3 +293,58 @@ fn a_graph_handle_drag_moves_the_handles_of_every_selected_key() {
assert_eq!(h.state().session.history.undo.len(), undo + 1, "one undo step");
assert!((handle(&h, 0).x - k0.x - px_per_s * 0.2).abs() < 2.0, "key 0's handle moved on screen");
}
/// #457: on a Timeline narrower than the Graph Editor's whole button row, the buttons that don't
/// fit go to a "»" menu at the end of the bar instead of being cut off; each stays reachable and
/// works from there.
#[test]
fn graph_editor_buttons_that_do_not_fit_move_to_the_more_menu() {
let (mut h, _, _) = harness();
h.set_size(egui::vec2(1280.0, 800.0));
h.state_mut().ui.timeline.graph_editor = true;
h.run_steps(4);
let ids = [
"autoSelectGraphType",
"valueGraph",
"speedGraph",
"showSelected",
"autoZoom",
"fit",
"fitSelection",
"snap",
"reference",
"transformBox",
"separateDimensions",
"interpolationHold",
"interpolationLinear",
"interpolationAuto",
"easyEaseEasy",
"easyEaseInEase",
"easyEaseOutEase",
];
let graph = h.state().auto.find("timeline.graph").expect("the Graph Editor is shown").rect;
let right = graph[0] + graph[2];
let shown: Vec<&str> = ids.iter().copied().filter(|id| h.state().auto.find(&format!("timeline.graph.{id}")).is_some()).collect();
for id in &shown {
let r = h.state().auto.find(&format!("timeline.graph.{id}")).unwrap().rect;
assert!(r[0] + r[2] <= right, "{id} is drawn past the bar's right edge");
}
let more = h.state().auto.find("timeline.graph.more").expect("the buttons that don't fit are in a menu").rect;
assert!(more[0] + more[2] <= right);
let p = pos2(more[0] + more[2] / 2.0, more[1] + more[3] / 2.0);
let click = |h: &mut Harness<'_, EffectcraftApp>, p: Pos2| {
h.input_mut().events.push(Event::PointerMoved(p));
h.input_mut().events.push(Event::PointerButton { pos: p, button: egui::PointerButton::Primary, pressed: true, modifiers: Default::default() });
h.step();
h.input_mut().events.push(Event::PointerButton { pos: p, button: egui::PointerButton::Primary, pressed: false, modifiers: Default::default() });
h.run_steps(3);
};
click(&mut h, p);
let hidden: Vec<&str> = ids.iter().copied().filter(|id| h.state().auto.find(&format!("timeline.graph.more.{id}")).is_some()).collect();
assert_eq!(shown.len() + hidden.len(), ids.len(), "every button is on the bar or in the menu: {shown:?} + {hidden:?}");
assert!(hidden.contains(&"transformBox"), "{hidden:?}");
let before = h.state().ui.timeline.graph_transform_box;
let item = h.state().auto.find("timeline.graph.more.transformBox").unwrap().rect;
click(&mut h, pos2(item[0] + item[2] / 2.0, item[1] + item[3] / 2.0));
assert_eq!(h.state().ui.timeline.graph_transform_box, !before, "the menu item runs the button");
}
+91
View File
@@ -35,6 +35,17 @@ fn click(h: &mut Harness<'_, EffectcraftApp>, id: &str) {
h.run_steps(2);
}
/// #581: the header's Discord pill fits its label with even padding (it was a fixed 104 px with
/// the text at its left).
#[test]
fn discord_pill_fits_its_label() {
let h = harness();
let e = h.state().auto.find("header.discord").expect("the Discord pill").clone();
let text = h.ctx.fonts_mut(|f| f.layout_no_wrap("Discord".into(), effectcraft_ui_egui::theme::Tokens::semibold(12.0), egui::Color32::WHITE).size().x);
// Padding 10, the 14 px icon, a 6 px gap, the text, padding 12.
assert!((e.rect[2] - (text + 42.0)).abs() < 0.5, "{} for a {text} px label", e.rect[2]);
}
#[test]
fn window_menu_opens_the_new_panels() {
let mut h = harness();
@@ -117,6 +128,42 @@ fn hover(h: &mut Harness<'_, EffectcraftApp>, p: Pos2) {
h.run_steps(3);
}
/// Issue #320: saved workspaces last between sessions (they were only kept in memory): a new
/// app on the same settings store lists them and brings their layout back; renamed and deleted
/// ones stay that way.
#[test]
fn saved_workspaces_persist_across_sessions() {
use effectcraft_engine::config::{ConfigStore, MemoryConfig};
use effectcraft_ui_egui::menus::invoke;
let store = std::sync::Arc::new(MemoryConfig::default());
let launch = || {
let s = Session { config: Some(store.clone()), ..Default::default() };
let mut h = Harness::builder().with_size(egui::vec2(1600.0, 1000.0)).build_eframe(|_| EffectcraftApp::new(s));
h.run_steps(2);
h
};
let mut h = launch();
let ctx = h.ctx.clone();
invoke(h.state_mut(), &ctx, "window.workspace", json!({"name": "Minimal"})).unwrap();
invoke(h.state_mut(), &ctx, "window.saveWorkspaceAs", json!({"name": "My Layout"})).unwrap();
invoke(h.state_mut(), &ctx, "window.saveWorkspaceAs", json!({"name": "Scratch"})).unwrap();
let saved = h.state().ui.dock.clone();
drop(h);
let mut h = launch();
assert_eq!(h.state().saved_workspace_names(), ["My Layout", "Scratch"]);
assert_eq!(h.state().ui.workspace, "Default");
let ctx = h.ctx.clone();
invoke(h.state_mut(), &ctx, "window.workspace", json!({"name": "My Layout"})).unwrap();
assert_eq!(h.state().ui.dock, saved);
invoke(h.state_mut(), &ctx, "window.editWorkspaces", json!({"name": "Scratch", "delete": true})).unwrap();
invoke(h.state_mut(), &ctx, "window.editWorkspaces", json!({"name": "My Layout", "rename": "Edit Bay"})).unwrap();
drop(h);
assert_eq!(launch().state().saved_workspace_names(), ["Edit Bay"]);
// A damaged file is ignored.
store.write(effectcraft_ui_egui::WORKSPACES_FILE, "{not json").unwrap();
assert!(launch().state().saved_workspace_names().is_empty());
}
/// Issue #191: a workspace saved with Save as New Workspace is listed in Window ▸ Workspace
/// (below the built-ins, where the submenu used to be cut off) and choosing it there brings its
/// layout back.
@@ -271,3 +318,47 @@ fn panels_snapshot() {
img.save(format!("{dir}/{panel}{scope}.png")).unwrap();
}
}
/// Drag the automation element `id` horizontally by `dx` points.
fn drag_x(h: &mut Harness<'_, EffectcraftApp>, id: &str, dx: f32) {
let e = h.state().auto.find(id).unwrap_or_else(|| panic!("no {id}")).clone();
let from: Pos2 = pos2(e.rect[0] + e.rect[2] / 2.0, e.rect[1] + e.rect[3] / 2.0);
h.input_mut().events.push(Event::PointerMoved(from));
h.step();
h.input_mut().events.push(Event::PointerButton { pos: from, button: egui::PointerButton::Primary, pressed: true, modifiers: Default::default() });
h.step();
for k in 1..=10 {
h.input_mut().events.push(Event::PointerMoved(from + egui::vec2(dx * k as f32 / 10.0, 0.0)));
h.step();
}
let to = from + egui::vec2(dx, 0.0);
h.input_mut().events.push(Event::PointerButton { pos: to, button: egui::PointerButton::Primary, pressed: false, modifiers: Default::default() });
h.run_steps(3);
}
/// #338: an Essential Graphics Scale control keeps After Effects' Constrain Proportions link, on
/// by default: dragging X scales Y with it; unlinked, X moves alone.
#[test]
fn essential_graphics_scale_keeps_constrain_proportions() {
let mut h = harness();
let layer = h.state().session.active_comp().unwrap().layers[0].id.0;
h.state_mut().session.execute("prop.set", json!({"layer": layer, "path": "transform/scale", "value": [100.0, 50.0]})).unwrap();
let c =
h.state_mut().session.execute("essential.addProperty", json!({"layer": layer, "path": "transform/scale"})).unwrap()["controls"][0].as_u64().unwrap();
open(&mut h, "essentialGraphics");
let value = format!("essential.control.{c}.value");
let scale = |h: &Harness<'_, EffectcraftApp>| {
let comp = h.state().session.active_comp().unwrap();
let l = comp.layers.iter().find(|l| l.id.0 == layer).unwrap();
l.props.prop("transform/scale").unwrap().value.components()
};
assert!(h.state().auto.find(&format!("{value}.link")).is_some(), "the chain link is drawn");
drag_x(&mut h, &format!("{value}.0"), 40.0);
let linked = scale(&h);
assert!(linked[0] > 100.0, "{linked:?}");
assert!((linked[1] / linked[0] - 0.5).abs() < 1e-6, "Y follows X in proportion: {linked:?}");
click(&mut h, &format!("{value}.link"));
drag_x(&mut h, &format!("{value}.0"), 40.0);
let unlinked = scale(&h);
assert!(unlinked[0] > linked[0] && unlinked[1] == linked[1], "unlinked, X moves alone: {unlinked:?}");
}
+26
View File
@@ -148,6 +148,32 @@ fn footage_context_menu_interprets_the_footage() {
assert_eq!(h.state().dialog, Some(Dialog::Form), "the Interpret Footage dialog opened");
}
/// #360: the row menu's New Comp from Selection works on the whole selection, as File ▸ New
/// Comp from Selection does: with several items selected it asks how in After Effects' New
/// Composition from Selection dialog, and Multiple Compositions makes one per item.
#[test]
fn context_menu_new_comp_from_selection_uses_the_selection() {
let (mut h, _, comp, solid) = harness();
h.state_mut().session.state.project_selection = vec![comp, solid];
h.run_steps(2);
let at = center(&h, &format!("project.item.{}.name", solid.0));
h.input_mut().events.push(Event::PointerMoved(at));
h.step();
for pressed in [true, false] {
h.input_mut().events.push(Event::PointerButton { pos: at, button: egui::PointerButton::Secondary, pressed, modifiers: Modifiers::NONE });
h.step();
}
h.run_steps(2);
assert_eq!(h.state().session.state.project_selection, vec![comp, solid], "the selection stays");
let item = h.query_by_label_contains("New Comp from Selection").expect("in the menu").rect();
click_at(&mut h, item.center());
assert_eq!(h.state().dialog, Some(Dialog::Form));
assert!(h.state().auto.find("form.field.single").is_some(), "the New Composition from Selection dialog");
let comps = h.state().session.project.comps().count();
h.state_mut().session.execute_checked("file.newCompFromSelection", json!({"single": false})).unwrap();
assert_eq!(h.state().session.project.comps().count(), comps + 2, "one per item");
}
/// Dragging from the Project panel's empty area draws a selection box that selects the rows it
/// touches; a click there deselects (#203).
#[test]
+20
View File
@@ -125,3 +125,23 @@ fn prefetch_waits_while_edits_keep_coming() {
let series = h.state().shown_series(ItemId(c));
assert!(h.state().frames.cached_frames(&series).len() > 1, "prefetch resumed");
}
/// #595: at Third (and other resolutions that aren't a whole number of thousandths) the cache
/// bars find the rendered frames. The series they count was rebuilt from the frame key's scale
/// (333 → 0.333), whose render options hashed apart from the frames rendered at 0.3333….
#[test]
fn cached_frames_are_counted_at_one_third_resolution() {
use effectcraft_ui_egui::state::Resolution;
for res in [Resolution::Third, Resolution::Custom(7)] {
let mut s = Session::default();
let c = s.execute("comp.new", json!({"name": "Third", "width": 64, "height": 36, "duration": 1})).unwrap()["comp"].as_u64().unwrap();
s.execute("layer.newSolid", json!({"color": "#3080ff"})).unwrap();
let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_eframe(|_| EffectcraftApp::new(s));
h.state_mut().ui.viewer.res = res;
settle(&mut h);
let series = h.state().shown_series(ItemId(c));
assert_eq!(series.scale, (res.scale(1.0, 1.0) * 1000.0).round() as u32, "{res:?}");
assert!(shown_cached(&h, c), "{res:?}: the shown frame counts as cached");
assert!(!h.state().frames.cached_frames(&series).is_empty(), "{res:?}");
}
}
+47
View File
@@ -298,3 +298,50 @@ fn project_empty_area_double_click_and_dropped_files_import() {
h.run_steps(2);
assert_eq!(imports(&h), 2, "the dropped file was imported");
}
/// Issue #306: dragging the viewer/Timeline divider far up made a nested split shorter than its
/// two 20 pt minimums, and `f32::clamp` panicked with crossed bounds, closing the app.
#[test]
fn dragging_the_viewer_timeline_divider_far_up_does_not_crash() {
let (mut h, _, _) = harness();
// The horizontal gutter right above the Timeline.
let tl = rect(&h, "panel.Timeline");
let gutter = |h: &Harness<'_, EffectcraftApp>| {
h.state()
.auto
.previous
.iter()
.filter(|e| e.id.starts_with("dock.gutter.") && e.rect[2] > e.rect[3])
.map(|e| (e.id.clone(), egui::Rect::from_min_size(pos2(e.rect[0], e.rect[1]), egui::vec2(e.rect[2], e.rect[3]))))
.filter(|(_, g)| g.x_range().contains(tl.center().x) && g.max.y <= tl.min.y + 1.0)
.min_by(|a, b| (tl.min.y - a.1.max.y).total_cmp(&(tl.min.y - b.1.max.y)))
.expect("the gutter above the Timeline")
};
let (id, g) = gutter(&h);
drag(&mut h, g.center(), g.center() - egui::vec2(0.0, 2000.0));
let g2 = rect(&h, &id);
assert!(g2.center().y < g.center().y - 100.0, "the divider moved up: {g:?} → {g2:?}");
// And back down past the bottom.
drag(&mut h, g2.center(), g2.center() + egui::vec2(0.0, 3000.0));
h.run_steps(2);
}
/// Issue #301: clicking anywhere on a stacked panel's header bar expands/collapses it, not only
/// its name; the panel menu button keeps its own click.
#[test]
fn a_stacked_panels_whole_header_bar_toggles_it() {
let (mut h, _, _) = harness();
let open = |h: &Harness<'_, EffectcraftApp>| h.state().ui.dock.is_visible(PanelKind::Preview);
assert!(open(&h));
let bar = rect(&h, "panel.header.Preview");
let tab = rect(&h, "panel.tab.Preview");
assert!(bar.min.x >= tab.max.x - 1.0 && bar.width() > 40.0, "{bar:?} {tab:?}");
click_n(&mut h, pos2(bar.max.x - 6.0, bar.center().y), 1);
assert!(!open(&h), "a click at the bar's right end collapses Preview");
let bar = rect(&h, "panel.header.Preview");
click_n(&mut h, bar.center(), 1);
assert!(open(&h), "and expands it again");
let menu = rect(&h, "panel.menu.Preview");
click_n(&mut h, menu.center(), 1);
assert!(open(&h), "the panel menu button doesn't toggle the panel");
}
+93
View File
@@ -59,6 +59,34 @@ fn alt_wheel_zooms_out_until_the_whole_comp_shows() {
assert!(h.state().ui.timeline.pps.is_some());
}
/// #428: Ctrl/Cmd+wheel and a trackpad pinch zoom time over the layers, as Alt+wheel does (egui
/// hands them over as a zoom factor, which the Timeline ignored).
#[test]
fn ctrl_wheel_and_pinch_zoom_time() {
let (mut h, layer) = harness();
h.state_mut().ui.timeline.pps = Some(200.0);
h.run_steps(2);
let pps = |h: &Harness<'_, EffectcraftApp>| h.state().ui.timeline.pps.unwrap_or(0.0);
let start = pps(&h);
assert!(start > 0.0);
let at = over_bar(&h, layer);
h.event(Event::PointerMoved(at));
h.event(Event::Zoom(0.5));
h.step();
let pinched = pps(&h);
assert!(pinched > 0.0 && pinched < start * 0.75, "a pinch in zooms out: {start} -> {pinched}");
let at = over_bar(&h, layer);
h.event(Event::PointerMoved(at));
h.event(Event::ModifiersChanged(Modifiers::COMMAND));
h.event(Event::MouseWheel { unit: MouseWheelUnit::Point, delta: vec2(0.0, 240.0), modifiers: Modifiers::COMMAND, phase: egui::TouchPhase::Move });
h.step();
h.event(Event::ModifiersChanged(Modifiers::NONE));
h.step();
assert!(pps(&h) > pinched, "Ctrl/Cmd+wheel up zooms in: {pinched} -> {}", pps(&h));
// Over the layers, so the rows did not scroll.
assert_eq!(h.state().ui.timeline.scroll_y, 0.0);
}
fn rect(h: &Harness<'_, EffectcraftApp>, id: &str) -> egui::Rect {
let e = h.state().auto.find(id).unwrap_or_else(|| panic!("no {id}")).clone();
egui::Rect::from_min_size(pos2(e.rect[0], e.rect[1]), vec2(e.rect[2], e.rect[3]))
@@ -311,3 +339,68 @@ fn dragging_out_of_the_marker_bin_adds_a_comp_marker() {
h.run_steps(2);
assert_eq!(markers(&h), vec![13.0, 20.0]);
}
/// #468: zoomed in, a CTI scrub held past the right end of the ruler scrolls the timeline (the
/// CTI rides the edge), past the left end scrolls it back; during playback the view pages to
/// keep the CTI in sight.
#[test]
fn the_timeline_follows_the_cti_past_its_edges() {
let (mut h, _) = harness();
// (Opening the comp showed all of it.)
h.state_mut().ui.timeline.pps = Some(200.0);
h.run_steps(2);
let ruler = rect(&h, "timeline.ruler");
let (s0, e0) = visible(&h);
assert_eq!(s0, 0.0);
let y = ruler.max.y - 4.0;
let from = pos2(ruler.center().x, y);
let past = pos2(ruler.max.x + 60.0, y);
h.event(Event::PointerMoved(from));
h.step();
h.event(Event::PointerButton { pos: from, button: egui::PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE });
h.step();
for k in 1..=4 {
h.event(Event::PointerMoved(from + (past - from) * (k as f32 / 4.0)));
h.step();
}
// Held still past the edge: it keeps scrolling (the view is read once the button is up).
h.run_steps(30);
let release = |h: &mut Harness<'_, EffectcraftApp>, at: Pos2| {
h.event(Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed: false, modifiers: Modifiers::NONE });
h.run_steps(2);
};
release(&mut h, past);
let (s1, e1) = visible(&h);
let cti = h.state().session.time().seconds();
assert!(s1 > 0.5, "the view scrolled right: {s0}..{e0} → {s1}..{e1}");
assert!(cti > e0 && cti >= s1 && cti <= e1 + 0.05, "the CTI rides the right edge: {cti} in {s1}..{e1}");
// Pressed again and held past the left end: back to the left.
let left = pos2(ruler.min.x - 60.0, y);
h.event(Event::PointerMoved(from));
h.step();
h.event(Event::PointerButton { pos: from, button: egui::PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE });
h.step();
for k in 1..=4 {
h.event(Event::PointerMoved(from + (left - from) * (k as f32 / 4.0)));
h.step();
}
h.run_steps(5);
release(&mut h, left);
let (s2, _) = visible(&h);
let cti = h.state().session.time().seconds();
assert!(s2 < s1 && (cti - s2).abs() < 0.05, "the view scrolled back left: {s1} → {s2}, CTI {cti}");
// Playback past the visible span pages the view to the CTI.
let (s3, e3) = visible(&h);
let t = e3 + 20.0;
h.state_mut().session.set_time(effectcraft_engine::time::Tick::from_seconds_f64(t));
let now = h.ctx.input(|i| i.time);
h.state_mut().play(now);
assert!(h.state().playback.playing);
h.step();
h.state_mut().stop();
h.step();
let cti = h.state().session.time().seconds();
let (s4, e4) = visible(&h);
assert!(s4 > s3 && cti >= s4 && cti <= e4, "paged from {s3}..{e3} to {s4}..{e4} for the CTI at {cti}");
}
+57
View File
@@ -750,6 +750,26 @@ fn pen_mask_undoes_step_by_step_and_double_click_moves_the_whole_mask() {
assert!(back.vertices.iter().zip(&moved.vertices).all(|(v, c)| near(*v, *c)), "{back:?}");
}
/// #513: clicking away from a mask's path, even on its own layer, deselects the mask's points;
/// the layer stays selected.
#[test]
fn clicking_off_a_mask_deselects_its_points() {
let mut h = harness();
let plate = layer_id(&h, "Plate");
let s = &mut h.state_mut().session;
s.execute("layer.select", json!({"layers": [plate]})).unwrap();
s.execute("mask.new", json!({"layer": plate, "vertices": [[100.0, 60.0], [500.0, 60.0], [500.0, 300.0], [100.0, 300.0]], "closed": true})).unwrap();
h.state_mut().ui.tool = Tool::Selection;
h.run_steps(2);
let at = screen(&h, [500.0, 60.0]);
click(&mut h, at);
assert_eq!(h.state().session.state.selected_vertices.len(), 1, "a click on a point selects it");
let away = screen(&h, [200.0, 250.0]);
click(&mut h, away);
assert!(h.state().session.state.selected_vertices.is_empty(), "a click off the path deselects its points");
assert_eq!(h.state().session.state.selected_layers, vec![LayerId(plate)], "the layer stays selected");
}
/// A straight two-key motion path shows Bezier handles at both keys (it had none), and dragging
/// one pulls the path into a curve (#290).
#[test]
@@ -1817,3 +1837,40 @@ fn full_resolution_frames_wider_than_the_texture_limit_fit() {
assert!(full(&mut h), "the full-size frame is shown (and stays readable): {:?}", h.state().ui.viewer.res);
assert_eq!(h.state().viewer_texture_size(), Some([800, 134]), "2400×400 averaged by 3");
}
/// #509: ⌘/Ctrl-drag in the Layer panel resizes the Roto Brush (as in After Effects) without
/// painting, the next stroke uses the new size, and the tool options show the Diameter.
#[test]
fn roto_brush_ctrl_drag_resizes_the_brush() {
let mut h = harness();
let id = h.state().session.active_comp().unwrap().layers.iter().find(|l| l.name == "Box").unwrap().id.0;
h.state_mut().session.execute("layer.select", json!({"layers": [id]})).unwrap();
h.state_mut().ui.layer_panel = Some(id);
h.state_mut().show_panel(effectcraft_ui_egui::dock::PanelKind::Layer);
h.state_mut().ui.tool = Tool::RotoBrush;
h.run_steps(3);
assert!(h.state().auto.find("header.roto.diameter").is_some(), "the Diameter field");
let canvas = rect(&h, "layerPanel.canvas");
let zoom = canvas.width() / 80.0;
let c = canvas.center();
h.input_mut().events.push(Event::ModifiersChanged(egui::Modifiers::COMMAND));
drag(&mut h, c, c + vec2(20.0 * zoom, 0.0));
h.input_mut().events.push(Event::ModifiersChanged(egui::Modifiers::NONE));
h.run_steps(2);
let dia = h.state().session.state.roto.diameter;
assert!((dia - 40.0).abs() <= 1.0, "a 20 px drag makes a 40 px brush: {dia}");
let strokes = |h: &Harness<'_, EffectcraftApp>| {
let l = h.state().session.active_comp().unwrap().layer(LayerId(id)).unwrap().clone();
let roto = effectcraft_engine::effects::roto::ID;
let g = l
.effects()
.and_then(|fx| fx.groups().find(|g| matches!(&g.kind, effectcraft_engine::project::GroupKind::Effect { effect } if effect == roto)).cloned());
g.map(|g| effectcraft_engine::effects::roto::data(&effectcraft_engine::effects::flatten_params(&g, &mut |p| p.value.clone())).strokes.clone())
.unwrap_or_default()
};
assert!(strokes(&h).is_empty(), "resizing paints nothing");
drag(&mut h, c - vec2(10.0 * zoom, 0.0), c + vec2(10.0 * zoom, 0.0));
let s = strokes(&h);
assert_eq!(s.len(), 1);
assert!((s[0].radius - dia / 2.0).abs() < 1e-6, "{} vs {dia}", s[0].radius);
}
+2 -1
View File
@@ -253,7 +253,8 @@ build real projects through these interfaces; they are worked examples of everyt
`{"colors":[[0,"#0080ff"],[1,[1,1,0]]],"opacities":[[0,1],[1,0.5]]}`.
* **Image sequences**: `file.import {"paths":["/r/shot_0001.png"]}` imports the file's whole
numbered run as one item (several picked frames: that range; a folder: each run in it;
`"sequence": false`: one still; `"alphabetical": true`: Force Alphabetical Order, the files
`"sequence": false`: one still; dropped files, `"drag": true`, import as stills unless
`"sequence": true`, as in After Effects; `"alphabetical": true`: Force Alphabetical Order, the files
play one after another) at `frameRate` or 30 fps (Settings ▸ Import). Missing frames show
colour bars, as in After Effects. `file.interpretFootage {"items":[id], "frameRate":12,
"startTimecode":1001}` conforms them and sets Override Start (the item and layers that ran to its end get the new
+1 -1
View File
@@ -412,7 +412,7 @@ Effects are addressed by id (`ec.<category>.<name>`) or display name in commands
| Effect | Id | Params | GPU | 32 | Status |
|---|---|---:|:-:|:-:|---|
| Apply Color LUT | `ec.utility.applylut` | 0 | GPU | 32 | Implemented |
| Apply Color LUT | `ec.utility.applylut` | 1 | GPU | 32 | Implemented |
| CC Overbrights | `ec.utility.ccoverbrights` | 2 | GPU | 32 | Implemented |
| Cineon Converter | `ec.utility.cineon` | 7 | GPU | 32 | Implemented |
| Color Profile Converter | `ec.utility.colorprofileconverter` | 7 | GPU | 32 | Implemented |
+13 -6
View File
@@ -26,7 +26,7 @@ dialog shows the run it found (and any missing frames) with these options:
| Option | What it does |
|---|---|
| **PNG Sequence** (EXR, TIFF, JPG… after the file type) | On: the whole run imports as one sequence. Off: just the picked file imports, as a still. |
| **PNG Sequence** (EXR, TIFF, JPG… after the file type) | On: the whole run imports as one sequence. Off (the default, as in After Effects): the picked files import as stills. |
| **Force alphabetical order** | Imports every image of that type in the folder in name order, whatever the numbering, and plays the files one after another (gaps in the numbering don't count). Use it for files that aren't numbered consistently. |
| **Frame rate (fps)** | The sequence's frame rate. The default comes from Settings ▸ Import ▸ Sequence Footage (30 fps). |
@@ -34,10 +34,14 @@ Other ways in:
- **Pick part of a run** (Shift-click the first and the last frame in the file dialog) to import
just that range as one sequence.
- **Pick or drop every frame** of the run: it arrives as one sequence, not one item per frame.
- **Pick every frame** of the run with the Sequence option on: it arrives as one sequence, not
one item per frame.
- **Drop a folder**: its media files import, and each numbered run in it becomes a sequence.
- **In the browser** (the web app) there are no folders to look in, so pick or drop all the
frames of the sequence at once.
Alt-drag the folder to import its files one by one.
- **Dropped files** import as stills, one item each, as in After Effects; drop the folder (or use
File ▸ Import ▸ File… with the Sequence option) for a sequence.
- **In the browser** (the web app) there are no folders to look in, so pick all the frames of
the sequence at once with the Sequence option on.
Stills of any format EffectCraft reads can form a sequence: PNG, JPEG, TIFF, OpenEXR, BMP and
WebP. Layered and vector documents (PSD, SVG, PDF) import one by one.
@@ -48,8 +52,11 @@ Effects does, so every other frame stays at its number. Render or copy the missi
folder and choose File ▸ Reload Footage to fill the gaps.
Agents: `file.import {"paths": ["/shots/shot_0001.png"], "sequence": true, "frameRate": 24}`.
`sequence` is on by default; `"sequence": false` imports the file as a still, and
`"alphabetical": true` is Force Alphabetical Order.
Without `sequence`, a numbered file brings its whole run and a folder's numbered runs import as
sequences, except dropped files (`"drag": true`), which import as stills as in After Effects.
`"sequence": true` is the Sequence option (one file brings its whole run), `"sequence": false`
imports stills and a folder's files one by one, and `"alphabetical": true` is Force Alphabetical
Order.
## Interpret Footage