mirror of
https://github.com/storytold/effectcraft.git
synced 2026-10-31 07:38:24 +00:00
Merge remote-tracking branch 'origin/main' into HEAD
This commit is contained in:
commit
687af205c7
11 files changed
+205
-53
No files matched your search
@@ -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
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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", "ეფექტებისა და პრესეტების პარამეტრები"),
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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}));
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(×, 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() };
|
||||
|
||||
@@ -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"))]
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in new issue
Block a user