Merge remote-tracking branch 'origin/main' into HEAD

This commit is contained in:
bflatastic committed 2026-10-30 12:04:22 +07:00
commit 687af205c7
11 files changed
+205 -53

No files matched your search

+1 -1
View File
@@ -155,7 +155,7 @@ fn install_file_dialogs(app: &mut EffectcraftApp) -> Option<std::sync::mpsc::Rec
fn main() -> eframe::Result {
// Windows: the update helper is a copy of this program started with `--finish-update`.
#[cfg(windows)]
if let Some(result) = craft_update::self_update::finish(&craft_update::suite::EFFECTCRAFT, &std::env::args_os().skip(1).collect::<Vec<_>>()) {
if let Some(result) = craft_update::self_update::finish(&effectcraft_ui_egui::UPDATE_APP, &std::env::args_os().skip(1).collect::<Vec<_>>()) {
std::process::exit(i32::from(result.is_err()));
}
// First, so the panic hook and every start-up warning are recorded. Records go to stderr
+2 -4
View File
@@ -182,10 +182,8 @@ fn violations(src: &str) -> Vec<(usize, String)> {
let tok = next_token(&s, &mut i);
match tok {
Tok::Open => levels.push(vec![]),
Tok::Close => {
if levels.len() > 1 {
levels.pop();
}
Tok::Close if levels.len() > 1 => {
levels.pop();
}
Tok::Sep => {
if let Some(l) = levels.last_mut() {
+13 -9
View File
@@ -316,7 +316,6 @@ const JAPANESE: &[(&str, &str)] = &[
("Duration:", "デュレーション:"),
("Edit Gradient…", "グラデーションを編集…"),
("Edit Target: {}", "編集対象:{}"),
("Edit in the Timeline", "タイムラインで編集"),
("EffectCraft didn't quit normally last time.", "EffectCraftは前回正常に終了しませんでした。"),
("Effects", "エフェクト"),
("Effects & Presets options", "エフェクト&プリセットのオプション"),
@@ -1220,6 +1219,19 @@ const JAPANESE: &[(&str, &str)] = &[
("Blend Mode", "描画モード"),
("Show mesh", "メッシュを表示"),
("Record Options...", "記録オプション..."),
("Apply", "適用"),
("Analyze", "分析"),
("Add Point", "ポイントを追加"),
("Remove Point", "ポイントを削除"),
("Render", "レンダリング"),
("Save Current as Template…", "テンプレートとして保存…"),
("New Composition From Footage", "フッテージから新規コンポジション"),
("Search project", "プロジェクトを検索"),
("Search", "検索"),
("(no composition)", "(コンポジションなし)"),
("per second", "毎秒"),
("Waveform", "波形"),
("steps", "ステップ"),
];
const SIMPLIFIED_CHINESE: &[(&str, &str)] = &[
@@ -1360,7 +1372,6 @@ const SIMPLIFIED_CHINESE: &[(&str, &str)] = &[
("Duration:", "持续时间:"),
("Edit Gradient…", "编辑渐变…"),
("Edit Target: {}", "编辑目标:{}"),
("Edit in the Timeline", "在时间轴中编辑"),
("EffectCraft didn't quit normally last time.", "EffectCraft 上次未正常退出。"),
("Effects", "效果"),
("Effects & Presets options", "效果和预设选项"),
@@ -2469,7 +2480,6 @@ const TRADITIONAL_CHINESE: &[(&str, &str)] = &[
("Duration:", "持續時間:"),
("Edit Gradient…", "編輯漸變…"),
("Edit Target: {}", "編輯目標:{}"),
("Edit in the Timeline", "在時間軸中編輯"),
("EffectCraft didn't quit normally last time.", "EffectCraft 上次未正常結束。"),
("Effects", "效果"),
("Effects & Presets options", "效果和預設集選項"),
@@ -3531,7 +3541,6 @@ const UKRAINIAN: &[(&str, &str)] = &[
("Duration:", "Тривалість:"),
("Edit Gradient…", "Редагувати градієнт…"),
("Edit Target: {}", "Ціль редагування: {}"),
("Edit in the Timeline", "Редагувати на шкалі часу"),
("EffectCraft didn't quit normally last time.", "Минулого разу EffectCraft завершив роботу некоректно."),
("Effects", "Ефекти"),
("Effects & Presets options", "Параметри ефектів і наборів"),
@@ -4095,7 +4104,6 @@ const PORTUGUESE: &[(&str, &str)] = &[
("Duration:", "Duração:"),
("Edit Gradient…", "Editar gradiente…"),
("Edit Target: {}", "Editar destino: {}"),
("Edit in the Timeline", "Editar na linha do tempo"),
("EffectCraft didn't quit normally last time.", "O EffectCraft não foi encerrado normalmente da última vez."),
("Effects", "Efeitos"),
("Effects & Presets options", "Opções de Efeitos e predefinições"),
@@ -4858,7 +4866,6 @@ const POLISH: &[(&str, &str)] = &[
("Duration:", "Czas trwania:"),
("Edit Gradient…", "Edytuj gradient…"),
("Edit Target: {}", "Cel edycji: {}"),
("Edit in the Timeline", "Edytuj na osi czasu"),
("EffectCraft didn't quit normally last time.", "EffectCraft nie zakończył się poprawnie ostatnim razem."),
("Effects & Presets options", "Opcje efektów i ustawień"),
("Copy Presets Folder Path", "Kopiuj ścieżkę folderu ustawień"),
@@ -5603,7 +5610,6 @@ const CZECH: &[(&str, &str)] = &[
("Duration:", "Délka trvání:"),
("Edit Gradient…", "Upravit přechod…"),
("Edit Target: {}", "Upravit cíl: {}"),
("Edit in the Timeline", "Upravit na časové ose"),
("EffectCraft didn't quit normally last time.", "EffectCraft nebyl při posledním použití ukončen běžným způsobem."),
("Effects", "Efekty"),
("Effects & Presets options", "Možnosti efektů a přednastavení"),
@@ -6171,7 +6177,6 @@ const SPANISH: &[(&str, &str)] = &[
("Duration:", "Duración:"),
("Edit Gradient…", "Editar degradado…"),
("Edit Target: {}", "Destino de la edición: {}"),
("Edit in the Timeline", "Editar en la cronología"),
("EffectCraft didn't quit normally last time.", "EffectCraft no se cerró normalmente la última vez."),
("Effects", "Efectos"),
("Effects & Presets options", "Opciones de Efectos y ajustes preestablecidos"),
@@ -6723,7 +6728,6 @@ const GEORGIAN: &[(&str, &str)] = &[
("Duration:", "ხანგრძლივობა:"),
("Edit Gradient…", "გრადიენტის რედაქტირება…"),
("Edit Target: {}", "რედაქტირების სამიზნე: {}"),
("Edit in the Timeline", "რედაქტირება დროის ხაზზე"),
("EffectCraft didn't quit normally last time.", "EffectCraft ბოლოს ნორმალურად არ დახურულა."),
("Effects", "ეფექტები"),
("Effects & Presets options", "ეფექტებისა და პრესეტების პარამეტრები"),
+8
View File
@@ -7,6 +7,14 @@
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
/// EffectCraft as craft-update knows it, with its 128 px icon: the update prompt shows the icon,
/// and an AppImage's menu entry gets it.
#[cfg(not(target_arch = "wasm32"))]
pub const UPDATE_APP: craft_update::app::App = craft_update::app::App {
icon_png: Some(include_bytes!("../../../assets/app-icon/hicolor/128x128/apps/ai.storyteller.effectcraft.png")),
..craft_update::suite::EFFECTCRAFT
};
pub mod audio;
pub mod automation;
pub mod bench;
+7 -2
View File
@@ -245,7 +245,7 @@ pub fn open(app: &mut EffectcraftApp, p: &Json) -> Result<Json, String> {
/// Set the property to the draft: one `prop.set` at the time the editor opened, in composition
/// time, or nothing when the draft is unchanged (so OK adds no keyframe).
fn commit(app: &mut EffectcraftApp, ctx: &egui::Context) {
app.dialog = None;
close(app);
let d = std::mem::take(&mut app.dialog_state.gradient);
let g = d.gradient();
// The preview already set the property to this draft, in one merged undo step: OK keeps it.
@@ -279,7 +279,12 @@ pub fn finish(app: &mut EffectcraftApp, ok: bool, ctx: &egui::Context) {
preview_merge::rollback(app, cp, d.applied_at.as_ref());
}
app.session.history.merge_key = None;
app.dialog = None;
close(app);
}
/// Close the editor: back to the Layer Style dialog when it opened from there (#2057).
fn close(app: &mut EffectcraftApp) {
app.dialog = std::mem::take(&mut app.dialog_state.layer_style.gradient_open).then_some(Dialog::LayerStyles);
}
/// Keep the property in step with the draft while Preview is on: apply a changed draft with one
@@ -23,6 +23,8 @@ pub struct LayerStyleState {
pub selected: String,
pub undo_mark: usize,
checkpoint: Option<Checkpoint>,
/// The Gradient Editor opened from this dialog: closing it comes back here (#2057).
pub gradient_open: bool,
}
/// The project and undo history as a dialog found them, for a Cancel that rolls its live edits back.
@@ -58,7 +60,8 @@ pub fn open(app: &mut EffectcraftApp, params: &Value) -> Result<(), String> {
return Err(format!("unknown layer style `{style}`"));
}
let checkpoint = checkpoint(app);
app.dialog_state.layer_style = LayerStyleState { layer, selected: style, undo_mark: app.session.history.undo.len(), checkpoint: Some(checkpoint) };
app.dialog_state.layer_style =
LayerStyleState { layer, selected: style, undo_mark: app.session.history.undo.len(), checkpoint: Some(checkpoint), gradient_open: false };
app.dialog = Some(Dialog::LayerStyles);
Ok(())
}
@@ -118,10 +121,6 @@ fn control(ui: &mut egui::Ui, p: &Property, v: &KV) -> Option<Value> {
let changed = crate::widgets::srgb_color_button(ui, &mut rgb).changed();
changed.then(|| json!(rgb))
}
(KV::Gradient(_), _) => {
ui.label(egui::RichText::new(tr("Edit in the Timeline")).weak());
None
}
_ => {
ui.label(egui::RichText::new(format!("{v:?}").chars().take(24).collect::<String>()).weak());
None
@@ -228,6 +227,19 @@ pub fn show(app: &mut EffectcraftApp, ctx: &egui::Context, t: &Tokens) {
}
let lbl = ui.label(&p.name);
let v = p.value_at(lt);
if matches!(v, KV::Gradient(_)) {
// The Gradient Editor, over this dialog (a style the dialog
// only shows has no property to edit yet).
let path = g.match_path_of(p.uid).unwrap_or_default();
let r = ui.add_enabled(missing.is_none(), egui::Button::new(tr("Edit Gradient…")));
if r.clicked() {
actions.push(("prop.editGradient".into(), json!({"layer": lid, "prop": p.uid})));
app.dialog_state.layer_style.gradient_open = true;
}
app.auto.add(&format!("dialog.layerStyle.value.{sel}/{path}"), r.rect, &p.name);
ui.end_row();
return;
}
let edit = ui.scope(|ui| control(ui, p, &v));
let path = g.match_path_of(p.uid).unwrap_or_default();
if let Some(nv) = edit.inner {
+2 -1
View File
@@ -1674,8 +1674,9 @@ fn phase_clicked(app: &mut EffectcraftApp, ectx: &EvalCtx, resp: &egui::Response
let _ = app.session.execute("mask.convertVertex", json!({"layer": h.layer.0, "mask": h.mask, "index": h.index}));
} else if let Some(k) = ov::motion_key_at(&key_hits, pos) {
// A key showing direction handles becomes Linear; one without, Auto Bezier.
// Separated X / Y Position have no spatial interpolation to convert.
let curved = key_hits.iter().any(|h| h.tangent.is_some() && h.layer == k.layer && h.prop == k.prop && h.time == k.time);
if select_motion_key(app, &k) {
if k.axes.is_none() && select_motion_key(app, &k) {
let spatial = if curved { "linear" } else { "autoBezier" };
let _ = app.session.execute("keys.interpolation", json!({"spatial": spatial}));
}
+8 -4
View File
@@ -137,14 +137,18 @@ pub(super) fn draw_rigs(
/// layer, or a new shape layer when nothing is selected. The placed point snaps.
/// Add a Position keyframe at a motion path's frame, keeping the path where it is.
pub(super) fn add_motion_key(app: &mut EffectcraftApp, f: ov::PathFrame) {
if let Err(e) = app.session.execute("prop.addKey", json!({"layer": f.layer.0, "prop": f.prop, "time": f.time.seconds()})) {
app.ui.status = e.to_string();
// Separated dimensions: the key goes on X and Y Position.
for prop in f.axes.map_or_else(|| vec![f.prop], Vec::from) {
if let Err(e) = app.session.execute("prop.addKey", json!({"layer": f.layer.0, "prop": prop, "time": f.time.seconds()})) {
app.ui.status = e.to_string();
}
}
}
/// Select only the motion path's keyframe `k` (what the vertex tools act on).
/// Select only the motion path's keyframe `k` (what the vertex tools act on; with separated
/// dimensions, the X and Y Position keys at its time).
pub(super) fn select_motion_key(app: &mut EffectcraftApp, k: &ov::KeyHit) -> bool {
app.session.execute("keys.select", json!({"keys": [{"layer": k.layer.0, "prop": k.prop, "time": k.time.seconds()}]})).is_ok()
app.session.execute("keys.select", json!({"keys": k.select_entries()})).is_ok()
}
/// The pointer for a Pen slot tool: the tool's own pen, the Pen showing Add Vertex's over a path
+122 -26
View File
@@ -7,7 +7,7 @@
use effectcraft_engine::geom::{Mat3, vec2 as gv2};
use effectcraft_engine::keyframe::ShapePath;
use effectcraft_engine::project::{GroupKind, Layer, LayerId};
use effectcraft_engine::project::{GroupKind, Layer, LayerId, Property};
use effectcraft_engine::render::EvalCtx;
use effectcraft_engine::time::Tick;
use effectcraft_engine::{KeyRef, VertexRef};
@@ -309,6 +309,23 @@ pub(crate) struct KeyHit {
pub value: [f64; 3],
/// Parent space → comp.
pub p2c: Mat3,
/// Separated dimensions: X and Y Position's uids, and whether each has a key at `time`.
pub axes: Option<[(u64, bool); 2]>,
}
impl KeyHit {
/// The keys this dot stands for: Position's, or the X and Y Position keys at its time.
pub(crate) fn key_refs(&self) -> Vec<KeyRef> {
match self.axes {
Some(axes) => axes.iter().filter(|(_, has)| *has).map(|&(prop, _)| KeyRef { layer: self.layer, prop, time: self.time }).collect(),
None => vec![KeyRef { layer: self.layer, prop: self.prop, time: self.time }],
}
}
/// [`Self::key_refs`] as `keys.select` entries.
pub(crate) fn select_entries(&self) -> Vec<serde_json::Value> {
self.key_refs().iter().map(|k| json!({"layer": k.layer.0, "prop": k.prop, "time": k.time.seconds()})).collect()
}
}
/// A frame on a motion path (one of its dots): where the Pen and Add Vertex tools add a
@@ -320,6 +337,21 @@ pub(crate) struct PathFrame {
/// Layer time of the frame.
pub time: Tick,
pub pos: Pos2,
/// Separated dimensions: X and Y Position's uids, which get the key instead of `prop`.
pub axes: Option<[u64; 2]>,
}
/// Layer times of the keys of separated X and Y Position together, in order.
fn separated_key_times(x: &Property, y: &Property) -> Vec<Tick> {
let mut times: Vec<Tick> = x.keys.iter().chain(y.keys.iter()).map(|k| k.time).collect();
times.sort();
times.dedup();
times
}
/// Separated X and Y Position at layer time `t` (z 0: motion paths are for 2D layers).
fn separated_value(x: &Property, y: &Property, t: Tick) -> [f64; 3] {
[x.value_at(t).as_f64(), y.value_at(t).as_f64(), 0.0]
}
/// The keyframe dot on a motion path under `pos`.
@@ -361,44 +393,61 @@ pub(crate) fn motion_path(
hits: &mut Vec<KeyHit>,
frames: &mut Vec<PathFrame>,
) {
let Some(pos) = l.transform().and_then(|tr| tr.get("position")) else { return };
if pos.keys.len() < 2 || l.is_3d() {
let Some(tr) = l.transform() else { return };
let Some(pos) = tr.get("position") else { return };
// Separate Dimensions: the layer follows X / Y Position, so the path is drawn from their keys.
let sep = tr.get("positionX").zip(tr.get("positionY"));
let key_times: Vec<Tick> = match sep {
Some((x, y)) => separated_key_times(x, y),
None => pos.keys.iter().map(|k| k.time).collect(),
};
if key_times.len() < 2 || l.is_3d() {
return;
}
let prop = sep.map_or(pos.uid, |(x, _)| x.uid);
let frame_axes = sep.map(|(x, y)| [x.uid, y.uid]);
let value_at = |t: Tick| match sep {
Some((x, y)) => separated_value(x, y, t),
None => pos.value_at(t).as_vec3(),
};
let p2c = parent_to_comp(ectx, l);
let to_scr = |v: [f64; 3]| {
let c = p2c.apply(gv2(v[0], v[1]));
map.to_screen([c.x, c.y])
};
// Settings ▸ Composition ▸ Motion Path: all keyframes, none, or a window around the CTI.
let times: Vec<Tick> = pos.keys.iter().map(|k| k.time).collect();
let Some((lt0, lt1)) = app.session.prefs.motion_path_span(&times, l.layer_time(ectx.time)) else { return };
let Some((lt0, lt1)) = app.session.prefs.motion_path_span(&key_times, l.layer_time(ectx.time)) else { return };
let fd = ectx.comp.frame_duration();
let mut pts = vec![];
let mut lt = lt0;
while lt <= lt1 && pts.len() < 20_000 {
let q = to_scr(pos.value_at(lt).as_vec3());
frames.push(PathFrame { layer: l.id, prop: pos.uid, time: lt, pos: q });
let q = to_scr(value_at(lt));
frames.push(PathFrame { layer: l.id, prop, time: lt, pos: q, axes: frame_axes });
pts.push(q);
lt += fd;
}
pts.push(to_scr(pos.value_at(lt1).as_vec3()));
pts.push(to_scr(value_at(lt1)));
let faint = col.gamma_multiply(0.8);
painter.add(egui::Shape::line(pts.clone(), Stroke::new(1.0, faint)));
for q in &pts {
painter.circle_filled(*q, 1.2, faint);
}
let sel = &app.session.state.selected_keys;
for (i, k) in pos.keys.iter().enumerate() {
if k.time < lt0 || k.time > lt1 {
for (i, kt) in key_times.iter().enumerate() {
if *kt < lt0 || *kt > lt1 {
continue;
}
let v = k.value.as_vec3();
// The combined Position key at this time; none with separated dimensions.
let key = pos.keys.get(i).filter(|_| sep.is_none());
let v = key.map_or_else(|| value_at(*kt), |k| k.value.as_vec3());
let s = to_scr(v);
let selected = sel.contains(&KeyRef { layer: l.id, prop: pos.uid, time: k.time });
let axes = sep.map(|(x, y)| [x, y].map(|a| (a.uid, a.keys.iter().any(|k| k.time == *kt))));
let hit = KeyHit { layer: l.id, prop, time: *kt, pos: s, tangent: None, value: v, p2c, axes };
let selected = hit.key_refs().iter().any(|k| sel.contains(k));
// A muted position key is off the motion path: no tangents, and a dimmed point.
let kc = if k.muted { col.gamma_multiply(if selected { 0.55 } else { 0.35 }) } else { col };
if !k.muted {
let kc = if key.is_some_and(|k| k.muted) { col.gamma_multiply(if selected { 0.55 } else { 0.35 }) } else { col };
// Separated X / Y have no spatial tangents.
if let Some(k) = key.filter(|k| !k.muted) {
// Auto-Bezier keys show handles on a side the path leaves straight too (a two-key path's
// ends), to drag it into a curve; Linear keys (no tangents) show none.
let (tin, tout) = if k.spatial_auto {
@@ -416,7 +465,7 @@ pub(crate) fn motion_path(
painter.circle_filled(h, 3.0, kc);
let side = if out { "out" } else { "in" };
app.auto.add(&format!("viewer.motionPath.{}.{i}.{side}", l.id.0), Rect::from_center_size(h, vec2(8.0, 8.0)), "Spatial tangent");
hits.push(KeyHit { layer: l.id, prop: pos.uid, time: k.time, pos: h, tangent: Some(out), value: v, p2c });
hits.push(KeyHit { layer: l.id, prop, time: *kt, pos: h, tangent: Some(out), value: v, p2c, axes: None });
}
}
let r = Rect::from_center_size(s, vec2(7.0, 7.0));
@@ -427,7 +476,7 @@ pub(crate) fn motion_path(
painter.rect_stroke(r, 0.0, Stroke::new(1.0, kc), StrokeKind::Inside);
}
app.auto.add(&format!("viewer.motionPath.{}.{i}", l.id.0), r.expand(2.0), "Position keyframe");
hits.push(KeyHit { layer: l.id, prop: pos.uid, time: k.time, pos: s, tangent: None, value: v, p2c });
hits.push(hit);
}
}
@@ -544,7 +593,7 @@ fn point_in_quad(p: Pos2, q: &[Pos2; 4]) -> bool {
#[derive(Clone, Debug)]
pub(crate) enum Drag {
/// A motion-path keyframe: its value moves with the pointer (parent space).
Key { layer: LayerId, prop: u64, time: Tick, start: [f64; 3], press: [f64; 2], inv: Mat3 },
Key { layer: LayerId, prop: u64, time: Tick, start: [f64; 3], press: [f64; 2], inv: Mat3, axes: Option<[u64; 2]> },
/// A spatial tangent handle of a motion-path key.
KeyTangent { layer: LayerId, prop: u64, time: Tick, out: bool, key: [f64; 3], inv: Mat3 },
/// Free transform: 0 move, 1 scale (anchor, axes), 2 rotate about `anchor`.
@@ -569,11 +618,17 @@ pub(crate) fn begin_key_drag(app: &mut EffectcraftApp, hits: &[KeyHit], press: P
match h.tangent {
Some(out) => Some(Drag::KeyTangent { layer: h.layer, prop: h.prop, time: h.time, out, key: h.value, inv }),
None => {
let k = KeyRef { layer: h.layer, prop: h.prop, time: h.time };
if !app.session.state.selected_keys.contains(&k) || shift {
let _ = app.session.execute("keys.select", json!({"keys": [{"layer": h.layer.0, "prop": h.prop, "time": h.time.seconds()}], "add": shift}));
// Separated dimensions: both X and Y need a key here before the drag moves them.
let mut h = *h;
for (uid, has) in h.axes.iter_mut().flatten().filter(|(_, has)| !*has) {
let _ = app.session.execute("prop.addKey", json!({"layer": h.layer.0, "prop": *uid, "time": h.time.seconds()}));
*has = true;
}
Some(Drag::Key { layer: h.layer, prop: h.prop, time: h.time, start: h.value, press: cpt, inv })
if !h.key_refs().iter().all(|k| app.session.state.selected_keys.contains(k)) || shift {
let _ = app.session.execute("keys.select", json!({"keys": h.select_entries(), "add": shift}));
}
let axes = h.axes.map(|a| a.map(|(uid, _)| uid));
Some(Drag::Key { layer: h.layer, prop: h.prop, time: h.time, start: h.value, press: cpt, inv, axes })
}
}
}
@@ -639,17 +694,22 @@ pub(crate) fn update(
snap: &mut dyn FnMut(&EffectcraftApp, [f64; 2], LayerId) -> [f64; 2],
) -> Option<Drag> {
match d.clone() {
Drag::Key { layer, prop, time, start, press, inv } => {
Drag::Key { layer, prop, time, start, press, inv, axes } => {
// Snap the key's on-screen position like a layer drag.
let start_c = inv.inverse().map(|m| m.apply(gv2(start[0], start[1]))).map(|v| [v.x, v.y]).unwrap_or([start[0], start[1]]);
let mut target = [start_c[0] + cpt[0] - press[0], start_c[1] + cpt[1] - press[1]];
let s = snap(app, target, layer);
target = [target[0] + s[0], target[1] + s[1]];
let p = inv.apply(gv2(target[0], target[1]));
let v = json!([p.x, p.y, start[2]]);
let r = app.session.execute("keys.set", json!({"layer": layer.0, "prop": prop, "time": time.seconds(), "value": v, "merge": merge}));
if let Err(e) = r {
app.ui.status = e.to_string();
let edits = match axes {
Some([ux, uy]) => vec![(ux, json!(p.x)), (uy, json!(p.y))],
None => vec![(prop, json!([p.x, p.y, start[2]]))],
};
for (uid, v) in edits {
let r = app.session.execute("keys.set", json!({"layer": layer.0, "prop": uid, "time": time.seconds(), "value": v, "merge": merge}));
if let Err(e) = r {
app.ui.status = e.to_string();
}
}
None
}
@@ -714,6 +774,42 @@ pub(crate) fn update(
mod tests {
use super::*;
fn scalar_prop(uid: u64, keys: &[(i64, f64)]) -> Property {
use effectcraft_engine::keyframe::{Keyframe, Value};
let mut p = Property::new(uid, "positionX", "X Position", Value::Scalar(0.0));
p.keys = keys.iter().map(|&(t, v)| Keyframe::new(Tick(t), Value::Scalar(v))).collect();
p
}
/// #2242: with separated dimensions the motion path follows the keys of X and Y Position.
#[test]
fn separated_motion_path_uses_x_and_y_keys() {
let x = scalar_prop(1, &[(0, 10.0), (100, 30.0)]);
let y = scalar_prop(2, &[(0, 0.0), (50, 20.0), (100, 0.0)]);
assert_eq!(separated_key_times(&x, &y), vec![Tick(0), Tick(50), Tick(100)]);
let mid = separated_value(&x, &y, Tick(50));
assert!((mid[0] - 20.0).abs() < 1e-9 && (mid[1] - 20.0).abs() < 1e-9 && mid[2] == 0.0, "{mid:?}");
assert_eq!(separated_value(&x, &y, Tick(100)), [30.0, 0.0, 0.0]);
}
/// #2242: a separated dot selects the X and Y keys at its time, not the nearest X key.
#[test]
fn separated_key_dot_refers_to_its_axis_keys() {
let mut h = KeyHit {
layer: LayerId(7),
prop: 1,
time: Tick(50),
pos: Pos2::ZERO,
tangent: None,
value: [0.0; 3],
p2c: Mat3::IDENTITY,
axes: Some([(1, false), (2, true)]),
};
assert_eq!(h.key_refs(), vec![KeyRef { layer: LayerId(7), prop: 2, time: Tick(50) }]);
h.axes = None;
assert_eq!(h.key_refs(), vec![KeyRef { layer: LayerId(7), prop: 1, time: Tick(50) }]);
}
fn info(vertices: Vec<[f64; 2]>, closed: bool) -> PathInfo {
let n = vertices.len();
let sp = ShapePath { vertices, in_tangents: vec![[0.0; 2]; n], out_tangents: vec![[0.0; 2]; n], closed, feather: Vec::new() };
+1 -1
View File
@@ -21,7 +21,7 @@ impl Default for Runtime {
fn default() -> Self {
Self {
#[cfg(not(target_arch = "wasm32"))]
updater: Updater::new(&craft_update::suite::EFFECTCRAFT, env!("CARGO_PKG_VERSION")),
updater: Updater::new(&crate::UPDATE_APP, env!("CARGO_PKG_VERSION")),
#[cfg(not(target_arch = "wasm32"))]
startup_check: false,
#[cfg(not(target_arch = "wasm32"))]
+24
View File
@@ -175,6 +175,30 @@ fn layer_style_buttons_stay_in_view() {
}
}
/// #2057: Gradient Overlay's gradient has an Edit Gradient… button in the Layer Style dialog. It
/// opens the Gradient Editor on that property, and closing the editor goes back to the dialog.
#[test]
fn layer_style_gradient_opens_the_gradient_editor() {
for ok in [true, false] {
let mut h = harness(1400.0);
let lid = h.state().session.active_comp().unwrap().layers[0].id.0;
h.state_mut().session.execute("layer.select", json!({"layers": [lid]})).unwrap();
let ctx = h.ctx.clone();
effectcraft_ui_egui::menus::invoke(h.state_mut(), &ctx, "layer.style.gradientOverlay", json!({})).unwrap();
h.run_steps(5);
let edit = in_view(&h, "dialog.layerStyle.value.gradientOverlay/colors");
click(&mut h, edit);
assert_eq!(h.state().dialog, Some(effectcraft_ui_egui::Dialog::GradientEditor), "Edit Gradient… opened the editor");
effectcraft_ui_egui::panels::gradient_editor::finish(h.state_mut(), ok, &ctx);
h.run_steps(2);
assert_eq!(h.state().dialog, Some(effectcraft_ui_egui::Dialog::LayerStyles), "closing the editor (OK {ok}) went back to the dialog");
// The dialog closes as before once the editor is gone.
effectcraft_ui_egui::panels::layer_styles_dialog::finish(h.state_mut(), true);
h.run_steps(2);
assert_eq!(h.state().dialog, None);
}
}
/// #1062: with the buttons kept at the bottom, the style's controls still scroll both ways, so
/// every one of them can be reached in the smallest window (960×600) at 200 % UI Scale, and in a
/// dialog opened in a large window that then narrows: here Noise, near the end of Drop Shadow,