From 3694505ed4716143e0ae5bd5fa5121c88f59ca54 Mon Sep 17 00:00:00 2001 From: zitann2020 Date: Fri, 30 Oct 2026 12:39:53 +0900 Subject: [PATCH 1/7] ui: a drop that holds the target composition lands the rest of the selection (#2589) Co-Authored-By: DeepSeek V4.1 Flash --- crates/ui-egui/src/panels.rs | 9 ++- crates/ui-egui/src/panels/project.rs | 11 +++- crates/ui-egui/src/panels/timeline.rs | 4 +- crates/ui-egui/src/panels/viewer/frame.rs | 2 +- crates/ui-egui/src/panels/viewer_drop.rs | 6 +- crates/ui-egui/tests/it/main.rs | 1 + crates/ui-egui/tests/it/ui_self_drop.rs | 75 +++++++++++++++++++++++ 7 files changed, 99 insertions(+), 9 deletions(-) create mode 100644 crates/ui-egui/tests/it/ui_self_drop.rs diff --git a/crates/ui-egui/src/panels.rs b/crates/ui-egui/src/panels.rs index f6a5b5321..c4a6f4d3a 100644 --- a/crates/ui-egui/src/panels.rs +++ b/crates/ui-egui/src/panels.rs @@ -124,8 +124,10 @@ impl PresetRef { /// Layers carried by a Project item drag. Dragging a selected item uses the /// complete selection, while dragging an unselected item uses only that item. -/// Folders and data files are not composition layers. -pub(crate) fn project_drop_items(app: &EffectcraftApp, dragged: u64) -> Vec { +/// Folders and data files are not composition layers, and the composition the +/// drop lands in is left out of a selection: it cannot contain itself, and one +/// such item would make the engine refuse the whole drop (#2589). +pub(crate) fn project_drop_items(app: &EffectcraftApp, dragged: u64, target: Option) -> Vec { use effectcraft_engine::project::{FootageKind, ItemId, ItemKind}; let selected = &app.session.state.project_selection; @@ -139,6 +141,9 @@ pub(crate) fn project_drop_items(app: &EffectcraftApp, dragged: u64) -> Vec candidates .into_iter() .filter(|id| { + if Some(ItemId(*id)) == target { + return false; + } app.session.project.item(ItemId(*id)).is_some_and(|item| match &item.kind { ItemKind::Comp(_) | ItemKind::Solid(_) => true, ItemKind::Footage(f) => f.kind != FootageKind::Data, diff --git a/crates/ui-egui/src/panels/project.rs b/crates/ui-egui/src/panels/project.rs index cdf57de27..05657ee07 100644 --- a/crates/ui-egui/src/panels/project.rs +++ b/crates/ui-egui/src/panels/project.rs @@ -618,9 +618,14 @@ fn item_menu( actions.push(("project.move".into(), json!({"items": targets(app, id, selected), "folder": null}))); ui.close(); } - if !it.is_folder() && ui.button(crate::i18n::tr("Add to Composition")).clicked() { - actions.push(("layer.addItem".into(), json!({"item": id.0}))); - ui.close(); + // A composition cannot contain itself, so its own row offers nothing to add to (#2589). + if !it.is_folder() { + let add = ui.add_enabled(app.session.state.active_comp != Some(id), egui::Button::new(crate::i18n::tr("Add to Composition"))); + app.auto.add("project.context.addToComposition", add.rect, "Add to Composition"); + if add.clicked() { + 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). diff --git a/crates/ui-egui/src/panels/timeline.rs b/crates/ui-egui/src/panels/timeline.rs index cb61e6f08..5fccd39c2 100644 --- a/crates/ui-egui/src/panels/timeline.rs +++ b/crates/ui-egui/src/panels/timeline.rs @@ -1778,7 +1778,7 @@ fn empty_timeline(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { if let Some(crate::panels::DragPayload::Item(dragged)) = egui::DragAndDrop::payload::(&ctx).as_deref() && ui.rect_contains_pointer(rect) { - let items: Vec = crate::panels::project_drop_items(app, *dragged) + let items: Vec = crate::panels::project_drop_items(app, *dragged, None) .into_iter() .map(ItemId) .filter(|id| { @@ -4945,7 +4945,7 @@ fn drop_insert_layers(app: &mut EffectcraftApp, g: &mut Rw, payload: &std::sync: match payload.as_ref() { crate::panels::DragPayload::Item(id) => { // Insert each selected item at the drop point, in selection order. - for (offset, item) in crate::panels::project_drop_items(app, *id).into_iter().enumerate() { + for (offset, item) in crate::panels::project_drop_items(app, *id, Some(g.tl.cid)).into_iter().enumerate() { actions.push(("layer.addItem".into(), json!({"item": item, "index": index.saturating_add(offset), "time": time.clone()}))); } } diff --git a/crates/ui-egui/src/panels/viewer/frame.rs b/crates/ui-egui/src/panels/viewer/frame.rs index 65095b399..e0d2a3bc8 100644 --- a/crates/ui-egui/src/panels/viewer/frame.rs +++ b/crates/ui-egui/src/panels/viewer/frame.rs @@ -25,7 +25,7 @@ pub(super) fn empty_state(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rec if let Some(crate::panels::DragPayload::Item(dragged)) = egui::DragAndDrop::payload::(&ctx).as_deref() && ui.rect_contains_pointer(rect) { - let items: Vec = crate::panels::project_drop_items(app, *dragged) + let items: Vec = crate::panels::project_drop_items(app, *dragged, None) .into_iter() .map(effectcraft_engine::project::ItemId) .filter(|id| { diff --git a/crates/ui-egui/src/panels/viewer_drop.rs b/crates/ui-egui/src/panels/viewer_drop.rs index ac56d9acb..aadd2c122 100644 --- a/crates/ui-egui/src/panels/viewer_drop.rs +++ b/crates/ui-egui/src/panels/viewer_drop.rs @@ -54,7 +54,11 @@ pub(crate) fn show(app: &mut EffectcraftApp, ui: &egui::Ui, painter: &egui::Pain } // New layers go above the current selection, so insert in reverse // to preserve the order of the dragged Project items. - crate::panels::project_drop_items(app, *item).into_iter().rev().map(|item| ("layer.addItem", json!({"item": item, "position": at}))).collect() + crate::panels::project_drop_items(app, *item, app.session.state.active_comp) + .into_iter() + .rev() + .map(|item| ("layer.addItem", json!({"item": item, "position": at}))) + .collect() } DragPayload::Files(paths) => vec![("mediaBrowser.import", json!({"paths": paths, "addToComp": true, "position": at}))], DragPayload::Property { .. } => vec![], diff --git a/crates/ui-egui/tests/it/main.rs b/crates/ui-egui/tests/it/main.rs index 29456b2a1..00533f15a 100644 --- a/crates/ui-egui/tests/it/main.rs +++ b/crates/ui-egui/tests/it/main.rs @@ -68,6 +68,7 @@ mod ui_render_queue; mod ui_script_console; mod ui_scriptui; mod ui_scrolling; +mod ui_self_drop; mod ui_settings_info; mod ui_tabs; mod ui_tail; diff --git a/crates/ui-egui/tests/it/ui_self_drop.rs b/crates/ui-egui/tests/it/ui_self_drop.rs new file mode 100644 index 000000000..a371cc80b --- /dev/null +++ b/crates/ui-egui/tests/it/ui_self_drop.rs @@ -0,0 +1,75 @@ +//! #2589: a Project selection that contains the composition being dropped into must not fail the +//! whole drop. Rendering writes a file named after the composition, so the Project panel holds +//! both and selects both; the composition itself is not a layer it can hold. + +use effectcraft_engine::Session; +use effectcraft_ui_egui::EffectcraftApp; +use egui::{Event, Modifiers, Pos2, Rect, pos2, vec2}; +use egui_kittest::Harness; +use serde_json::json; + +/// Compositions "Main" (active, 4 s at 30 fps, one solid) and "Clip". +fn harness() -> (Harness<'static, EffectcraftApp>, u64, u64) { + let mut s = Session::default(); + let clip = s.execute("comp.new", json!({"name": "Clip", "width": 160, "height": 90, "frameRate": 30, "duration": 1})).unwrap()["comp"].as_u64().unwrap(); + let main = s.execute("comp.new", json!({"name": "Main", "width": 320, "height": 180, "frameRate": 30, "duration": 4})).unwrap()["comp"].as_u64().unwrap(); + s.execute("layer.newSolid", json!({"name": "Bottom", "color": "#406080"})).unwrap(); + let mut h = Harness::builder().with_size(vec2(1600.0, 1000.0)).build_eframe(|_| EffectcraftApp::new(s)); + h.run_steps(3); + (h, main, clip) +} + +fn rect(h: &Harness<'_, EffectcraftApp>, id: &str) -> Rect { + let e = h.state().auto.find(id).unwrap_or_else(|| panic!("no {id}")).clone(); + Rect::from_min_size(pos2(e.rect[0], e.rect[1]), vec2(e.rect[2], e.rect[3])) +} + +/// Press on `from`, move to `to` in steps, release there. +fn drag(h: &mut Harness<'_, EffectcraftApp>, from: Pos2, to: Pos2) { + 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..=10 { + h.event(Event::PointerMoved(from + (to - from) * (k as f32 / 10.0))); + h.step(); + } + h.event(Event::PointerButton { pos: to, button: egui::PointerButton::Primary, pressed: false, modifiers: Modifiers::NONE }); + h.run_steps(3); +} + +fn stack(h: &Harness<'_, EffectcraftApp>) -> Vec { + h.state().session.active_comp().unwrap().layers.iter().map(|l| l.name.clone()).collect() +} + +fn layer_id(h: &Harness<'_, EffectcraftApp>, name: &str) -> u64 { + h.state().session.active_comp().unwrap().layers.iter().find(|l| l.name == name).unwrap().id.0 +} + +/// Both the composition and the render output are selected: dragging the file onto the Timeline +/// still lands the file, and the composition is not carried along into itself. +#[test] +fn a_selection_holding_the_drop_target_does_not_fail_the_drop() { + let (mut h, main, clip) = harness(); + h.state_mut().session.execute("project.select", json!({"items": [main, clip]})).unwrap(); + h.run_steps(2); + let from = rect(&h, &format!("project.item.{clip}.name")).center(); + let row = rect(&h, &format!("timeline.layer.{}.row", layer_id(&h, "Bottom"))); + drag(&mut h, from, row.center()); + assert_eq!(stack(&h), ["Bottom", "Clip"], "the composition the drop lands in is left out"); + assert!(!h.state().ui.status.contains("contain itself"), "the drop did not fail: {}", h.state().ui.status); +} + +/// Dragging the composition's own row onto its Timeline adds nothing and reports nothing. +#[test] +fn the_composition_dropped_on_itself_adds_nothing() { + let (mut h, main, _clip) = harness(); + h.state_mut().session.execute("project.select", json!({"items": [main]})).unwrap(); + h.run_steps(2); + let before = stack(&h); + let from = rect(&h, &format!("project.item.{main}.name")).center(); + let row = rect(&h, &format!("timeline.layer.{}.row", layer_id(&h, "Bottom"))); + drag(&mut h, from, row.center()); + assert_eq!(stack(&h), before, "nothing is added"); + assert!(!h.state().ui.status.contains("contain itself"), "no error: {}", h.state().ui.status); +} From e8c2813afb5b2e5188b63fceaf586b2b9191239b Mon Sep 17 00:00:00 2001 From: zitann2020 Date: Fri, 30 Oct 2026 12:39:59 +0900 Subject: [PATCH 2/7] ui: a file dropped on the Timeline joins that composition (#2588) Co-Authored-By: DeepSeek V4.1 Flash --- crates/ui-egui/src/prefs_live.rs | 14 ++++++++++++-- crates/ui-egui/tests/it/ui_drag_drop.rs | 13 +++++++++++++ 2 files changed, 25 insertions(+), 2 deletions(-) diff --git a/crates/ui-egui/src/prefs_live.rs b/crates/ui-egui/src/prefs_live.rs index ee222c892..cc283635f 100644 --- a/crates/ui-egui/src/prefs_live.rs +++ b/crates/ui-egui/src/prefs_live.rs @@ -86,7 +86,17 @@ pub fn dropped_files(app: &mut EffectcraftApp, ui: &egui::Ui, bounds: Rect) { .find(|e| e.id == "viewer.area") .map(|e| Rect::from_min_size(egui::pos2(e.rect[0], e.rect[1]), egui::vec2(e.rect[2], e.rect[3]))); - let at = if proj.is_empty() { + // Dropping a file on the Timeline adds it to the composition the panel shows, as it does + // on the viewer (#2588); the import decides the layer's position. + let over_timeline = ctx.input(|i| i.pointer.hover_pos()).is_some_and(|p| { + app.auto + .elements + .iter() + .find(|e| e.id == "panel.Timeline") + .is_some_and(|e| Rect::from_min_size(egui::pos2(e.rect[0], e.rect[1]), egui::vec2(e.rect[2], e.rect[3])).contains(p)) + }); + + let at = if proj.is_empty() && !over_timeline { ctx.input(|i| i.pointer.hover_pos()).filter(|p| viewer.is_some_and(|v| v.contains(*p))).and_then(|p| crate::panels::viewer::screen_to_comp(&ctx, p)) } else { None @@ -101,7 +111,7 @@ pub fn dropped_files(app: &mut EffectcraftApp, ui: &egui::Ui, bounds: Rect) { let mut params = json!({ "paths": rest, "drag": true, - "addToComp": at.is_some(), + "addToComp": at.is_some() || over_timeline, "position": at, "background": true }); diff --git a/crates/ui-egui/tests/it/ui_drag_drop.rs b/crates/ui-egui/tests/it/ui_drag_drop.rs index 5608dfcf3..42d0273f8 100644 --- a/crates/ui-egui/tests/it/ui_drag_drop.rs +++ b/crates/ui-egui/tests/it/ui_drag_drop.rs @@ -501,6 +501,19 @@ fn dropped_file_on_viewer_imports_and_adds_layer_at_pointer() { assert!((p[0] - 80.0).abs() <= tol && (p[1] - 45.0).abs() <= tol, "{p:?}"); } +/// #2588: a file dropped on the Timeline joins the composition the panel shows, as it does on the +/// viewer; the import places the layer. +#[test] +fn dropped_file_on_the_timeline_becomes_a_layer() { + let (mut h, _) = harness(); + h.state_mut().session.importer = Some(Arc::new(Gated { started: AtomicUsize::new(0), allow: Arc::new(AtomicUsize::new(usize::MAX)) })); + h.event(Event::PointerMoved(rect(&h, "panel.Timeline").center())); + h.step(); + h.input_mut().dropped_files.push(Arc::new(Dropped(std::env::temp_dir().join("timeline-drop.mp4")))); + h.run_steps(2); + step_until(&mut h, |h| h.state().session.active_comp().is_some_and(|c| c.layers.iter().any(|l| l.name == "timeline-drop.mp4"))); +} + /// #678: a dropped Photoshop file asks how to import it, as File ▸ Import ▸ File… does; the /// Import Kind starts at Settings ▸ Import ▸ Default Drag Import As, and OK imports the drop. #[test] From daab98d5c48dd391d4f74d5c8329f6ff4fa8f3a1 Mon Sep 17 00:00:00 2001 From: zitann2020 Date: Fri, 30 Oct 2026 13:26:21 +0900 Subject: [PATCH 3/7] i18n(ja): the panel catalog's last 65 rows Co-Authored-By: DeepSeek V4.1 Flash --- crates/ui-egui/src/i18n/ui.rs | 71 +++++++++++++++++++++++++++++++++++ 1 file changed, 71 insertions(+) diff --git a/crates/ui-egui/src/i18n/ui.rs b/crates/ui-egui/src/i18n/ui.rs index 1b7900bcb..b54938412 100644 --- a/crates/ui-egui/src/i18n/ui.rs +++ b/crates/ui-egui/src/i18n/ui.rs @@ -913,9 +913,45 @@ const JAPANESE: &[(&str, &str)] = &[ "トラッカーパネルのフェイストラッキング(輪郭のみと詳細機能)はここで選んだモデルを使用します。モデルはオープンソースで、要求したときだけダウンロードされ、公開されているSHA-256と照合されます。", ), ("Match System", "システムに合わせる"), + ("Default", "デフォルト"), + ("Stored, but not used by EffectCraft yet", "保存されますが、EffectCraftではまだ使用されません"), ("Total Offset", "合計オフセット"), ("Per Layer", "レイヤーごと"), ("Track", "トラック"), + ("Selection Tool", "選択ツール"), + ("Hand Tool", "手のひらツール"), + ("Zoom Tool", "ズームツール"), + ("Orbit Around Cursor Tool", "カーソルのまわりを周回ツール"), + ("Pan Under Cursor Tool", "カーソルの下でパンツール"), + ("Dolly Towards Cursor Tool", "カーソルに向かってドリーツール"), + ("Rotation Tool", "回転ツール"), + ("Pan Behind (Anchor Point) Tool", "アンカーポイントツール"), + ("Rectangle Tool", "長方形ツール"), + ("Rounded Rectangle Tool", "角丸長方形ツール"), + ("Ellipse Tool", "楕円形ツール"), + ("Polygon Tool", "多角形ツール"), + ("Star Tool", "スターツール"), + ("Pen Tool", "ペンツール"), + ("Add Vertex Tool", "頂点を追加ツール"), + ("Delete Vertex Tool", "頂点を削除ツール"), + ("Convert Vertex Tool", "頂点を切り替えツール"), + ("Mask Feather Tool", "マスクの境界のぼかしツール"), + ("Horizontal Type Tool", "横書き文字ツール"), + ("Vertical Type Tool", "縦書き文字ツール"), + ("Brush Tool", "ブラシツール"), + ("Clone Stamp Tool", "コピースタンプツール"), + ("Eraser Tool", "消しゴムツール"), + ("Roto Brush Tool", "ロトブラシツール"), + ("Refine Edge Tool", "エッジを調整ツール"), + ("Object Matte Tool", "オブジェクトマットツール"), + ("Puppet Position Pin Tool", "パペット位置ピンツール"), + ("Puppet Starch Pin Tool", "パペットスターチピンツール"), + ("Puppet Bend Pin Tool", "パペットベンドピンツール"), + ("Puppet Advanced Pin Tool", "パペット詳細ピンツール"), + ("Puppet Overlap Pin Tool", "パペット重なりピンツール"), + ("Shortcut", "ショートカット"), + ("Range", "範囲"), + ("Play From", "再生開始位置"), ("Force alphabetical order", "アルファベット順を強制"), ("Frame rate (fps)", "フレームレート (fps)"), ("Import Kind", "読み込みの種類"), @@ -1066,6 +1102,16 @@ const JAPANESE: &[(&str, &str)] = &[ ("VR Comp Editor", "VRコンポジションエディター"), ("Media Duration", "メディアの長さ"), ("Frame Rate", "フレームレート"), + ("Resolution", "解像度"), + ("Overlays", "オーバーレイ"), + ("Layer Controls", "レイヤーコントロール"), + ("Loop", "ループ"), + ("Cache Before Playback", "再生前にキャッシュ"), + ("Pre-roll", "プリロール"), + ("Post-roll", "ポストロール"), + ("If caching, play cached frames", "キャッシュ中はキャッシュ済みフレームを再生"), + ("Apply", "適用"), + ("Analyze", "解析"), ("File Path", "ファイルパス"), ("Interpret Footage", "フッテージの解釈"), ("Create a new Folder", "新しいフォルダーを作成"), @@ -1088,6 +1134,9 @@ const JAPANESE: &[(&str, &str)] = &[ ("ArtCraft website", "ArtCraftのウェブサイト"), ("Source code on GitHub", "GitHubのソースコード"), ("3D Renderer", "3Dレンダラー"), + ("Black", "黒"), + ("White", "白"), + ("Composition Settings", "コンポジション設定"), ("Preset Name", "プリセット名"), ("Save Preset…", "プリセットを保存…"), ("Delete Preset", "プリセットを削除"), @@ -1183,6 +1232,23 @@ const JAPANESE: &[(&str, &str)] = &[ ("* Animation Presets", "* アニメーションプリセット"), ("Select a layer to apply to", "適用するレイヤーを選択してください"), ("Search effects and presets", "エフェクトとプリセットを検索"), + ("Search project", "プロジェクトを検索"), + ("Search", "検索"), + ("(no composition)", "(コンポジションなし)"), + ("per second", "/秒"), + ("Waveform", "波形"), + ("steps", "ステップ"), + ("Building waveform…", "波形を生成中…"), + ("No effects. Drag one from Effects & Presets.", "エフェクトがありません。エフェクト&プリセットからドラッグしてください。"), + ( + "Click the ring to add a colour; Alt-click a stop (or drag it off) to remove it", + "リングをクリックするとカラーを追加します。Altを押しながらストップをクリック(または外へドラッグ)すると削除します", + ), + ( + "Start from a ready-made project. Opening a template makes an untitled copy.", + "既成のプロジェクトから開始します。テンプレートを開くと無題のコピーが作成されます。", + ), + ("Square Pixels", "スクエアピクセル"), ("Alpha Expansion:", "アルファの拡張:"), ("Fill Method:", "塗りの方法:"), ("Edge Blend", "エッジのブレンド"), @@ -1198,6 +1264,11 @@ const JAPANESE: &[(&str, &str)] = &[ ("Unable to solve camera", "カメラを解析できません"), ("Waiting to solve", "解析待ち"), ("Create Camera", "カメラを作成"), + ("Add Point", "ポイントを追加"), + ("Remove Point", "ポイントを削除"), + ("Render", "レンダリング"), + ("Save Current as Template…", "現在の設定をテンプレートとして保存…"), + ("New Composition From Footage", "フッテージから新規コンポジション"), ("track point", "トラックポイント"), ("Ruler zero point", "ルーラーの原点"), ("Vertical ruler", "垂直ルーラー"), From 94c8bde240ab0a4da34310fd486292f6b2ba025c Mon Sep 17 00:00:00 2001 From: bflatastic Date: Fri, 30 Oct 2026 11:43:19 +0700 Subject: [PATCH 4/7] Adjustment layers blend in their blend mode (#2618) An adjustment layer replaced the comp below with its effects' result whatever its blend mode. As in After Effects, a mode other than Normal now blends the adjusted comp onto the comp below in that mode, inside the footprint, track matte and opacity (an adjustment layer with no effects in Screen screens the comp with itself). CPU and GPU paths. Co-Authored-By: Claude Opus 5.5 --- crates/gpu/src/tests.rs | 6 ++++++ crates/gpu/src/walk.rs | 12 +++++++++--- crates/render/src/layers.rs | 14 +++++++++++++- crates/render/src/tests.rs | 26 ++++++++++++++++++++++++++ 4 files changed, 54 insertions(+), 4 deletions(-) diff --git a/crates/gpu/src/tests.rs b/crates/gpu/src/tests.rs index c57c1ef84..44e8922a6 100644 --- a/crates/gpu/src/tests.rs +++ b/crates/gpu/src/tests.rs @@ -510,6 +510,12 @@ fn layer_styles_collapsed_precomps_and_cpu_fallbacks() { adj.switches.adjustment = true; s.effect(&mut adj, "ec.channel.invert", &[]); s.push(adj); + // An adjustment layer in another blend mode blends the adjusted comp in that mode (#2618). + let mut adj = s.solid([1.0, 1.0, 1.0], 70, 40); + adj.switches.adjustment = true; + adj.blend_mode = BlendMode::Screen; + set(&mut adj, "transform/opacity", Value::Scalar(60.0)); + s.push(adj); check(&format!("collapse + adjustment {depth:?}"), compare_at(&s, opts(), Tick::ZERO), 0.0); } } diff --git a/crates/gpu/src/walk.rs b/crates/gpu/src/walk.rs index d4d7a62a1..fdcb58dd4 100644 --- a/crates/gpu/src/walk.rs +++ b/crates/gpu/src/walk.rs @@ -352,8 +352,8 @@ fn draw_precomp(e: &mut Enc, r: &Renderer, ctx: &EvalCtx, layer: &Layer, canvas: /// Adjustment layer (`Renderer::draw_adjustment`): the effect stack runs on the comp below, /// resident on the GPU (non-GPU effects read back, run on the CPU and upload), and the result -/// replaces the canvas inside the layer's footprint × track matte × opacity. `None` = draw it -/// on the CPU. +/// replaces the canvas inside the layer's footprint × track matte × opacity, or blends onto it in +/// the layer's blend mode when that isn't Normal. `None` = draw it on the CPU. fn draw_adjustment(e: &mut Enc, r: &Renderer, ctx: &EvalCtx, layer: &Layer, canvas: &mut GpuImage) -> Option<()> { let opacity = r.layer_opacity(ctx, layer); let Some(foot) = r.adjustment_footprint(ctx, layer) else { return Some(()) }; @@ -379,7 +379,13 @@ fn draw_adjustment(e: &mut Enc, r: &Renderer, ctx: &EvalCtx, layer: &Layer, canv if let Some(c) = pipe.to_blend() { adjusted = ops::convert(e, &adjusted, &c); } - *canvas = ops::adjust_mix(e, canvas, &adjusted, &matte, opacity); + *canvas = if layer.blend_mode == BlendMode::Normal { + ops::adjust_mix(e, canvas, &adjusted, &matte, opacity) + } else { + // The adjusted comp is a layer in that mode over the comp below (#2618). + let iso = ops::matte(e, &adjusted, &matte, effectcraft_project::MatteKind::Alpha); + ops::blend_full(e, canvas, &iso, layer.blend_mode, opacity, layer.id.0 as u32, None) + }; Some(()) } diff --git a/crates/render/src/layers.rs b/crates/render/src/layers.rs index ce0a2dcde..d105c6f13 100644 --- a/crates/render/src/layers.rs +++ b/crates/render/src/layers.rs @@ -522,7 +522,7 @@ impl<'a> Renderer<'a> { } /// Adjustment layer: effects applied to the comp below, limited to the layer's footprint - /// and its track matte. + /// and its track matte, and blended back in the layer's blend mode. fn draw_adjustment(&self, ctx: &EvalCtx, layer: &Layer, canvas: &mut Image, opacity: f32) { let Some(mut foot) = self.source(ctx, layer) else { return }; let _ = masks::apply(ctx, layer, &mut foot, self.mask_blur(ctx, layer)); @@ -548,6 +548,18 @@ impl<'a> Renderer<'a> { if adjusted.img.width != canvas.width || adjusted.img.height != canvas.height { return; } + if layer.blend_mode != BlendMode::Normal { + // The adjusted comp is a layer in that mode over the comp below, as in After Effects: + // an adjustment layer with no effects in Screen screens the comp with itself (#2618). + let mut iso = adjusted.img; + iso.data.par_iter_mut().zip(matte.data.par_iter()).for_each(|(p, m)| { + for c in p.iter_mut() { + *c *= m[3]; + } + }); + canvas.blend_from(&iso, layer.blend_mode, opacity, layer.id.0 as u32); + return; + } canvas.data.par_iter_mut().zip(adjusted.img.data.par_iter()).zip(matte.data.par_iter()).for_each(|((c, a), m)| { let k = m[3] * opacity; for i in 0..4 { diff --git a/crates/render/src/tests.rs b/crates/render/src/tests.rs index 488f38ec7..3d2134e89 100644 --- a/crates/render/src/tests.rs +++ b/crates/render/src/tests.rs @@ -206,6 +206,32 @@ fn track_matte_limits_an_adjustment_layer() { } } +/// An adjustment layer's blend mode blends the adjusted comp onto the comp below, inside its +/// footprint: with no effects, Screen screens a 0.25 grey with itself, Multiply darkens it, +/// Normal leaves it, and Opacity mixes toward the result (#2618). +#[test] +fn adjustment_layer_blends_in_its_mode() { + for (mode, opacity, want) in + [(BlendMode::Normal, 100.0, 0.25), (BlendMode::Screen, 100.0, 0.4375), (BlendMode::Multiply, 100.0, 0.0625), (BlendMode::Screen, 50.0, 0.34375)] + { + let (mut p, cid, comp) = setup(); + let mut adj = solid(&mut p, &comp, [1.0, 1.0, 1.0], 100, 100); + adj.props.prop_mut("transform/position").unwrap().value = Value::Vec3([50.0, 50.0, 0.0]); + adj.props.prop_mut("transform/opacity").unwrap().value = Value::Scalar(opacity); + adj.switches.adjustment = true; + adj.blend_mode = mode; + let bg = solid(&mut p, &comp, [0.25, 0.25, 0.25], 200, 100); + let c = p.comp_mut(cid).unwrap(); + c.layers.push(adj); + c.layers.push(bg); + let img = render_frame(&p, cid, Tick::ZERO, 1.0); + let (inside, outside) = (img.get(50, 50), img.get(150, 50)); + assert!((inside[0] - want).abs() < 1e-4, "{mode:?} {opacity}: {inside:?}"); + assert!((outside[0] - 0.25).abs() < 1e-4, "{mode:?} {opacity}: outside the footprint {outside:?}"); + assert_eq!(inside[3], 1.0, "{mode:?} keeps the comp opaque"); + } +} + #[test] fn shape_layer_draws_fill_and_stroke() { let (mut p, cid, comp) = setup(); From 561c2a8e48d2fa1c51be349d8c35b3658d4fccb2 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Fri, 30 Oct 2026 11:48:41 +0700 Subject: [PATCH 5/7] Settings: older settings files split layers above the original (#2545) Create Split Layers Above Original Layer became on by default in #2083, but a settings file saved before then still holds `false` (every later save keeps it), so Split Layer kept stacking the new layer below. Settings version 4 moves a stored `false` to the new default once, like version 3 did for the old disk cache size; a choice made since stays. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/settings/prefs.rs | 2 +- crates/engine/src/settings/prefs/document.rs | 8 ++++++++ crates/engine/src/tests/settings.rs | 9 +++++++++ 3 files changed, 18 insertions(+), 1 deletion(-) diff --git a/crates/engine/src/settings/prefs.rs b/crates/engine/src/settings/prefs.rs index d2db7cd55..94c90c3a3 100644 --- a/crates/engine/src/settings/prefs.rs +++ b/crates/engine/src/settings/prefs.rs @@ -36,7 +36,7 @@ pub use sections::{ use sections::{Extra, default_labels, lenient_comp_presets}; /// Current settings layout version. -pub const PREFS_VERSION: u32 = 3; +pub const PREFS_VERSION: u32 = 4; /// Settings file name in the config store. pub const PREFS_FILE: &str = "prefs.json"; diff --git a/crates/engine/src/settings/prefs/document.rs b/crates/engine/src/settings/prefs/document.rs index d82205352..1f68c4a06 100644 --- a/crates/engine/src/settings/prefs/document.rs +++ b/crates/engine/src/settings/prefs/document.rs @@ -9,6 +9,8 @@ use super::{Appearance, General, Map, PREFS_VERSION, Prefs, Startup, Value, json /// `autoSaveMinutes`, `autoSaveVersions`, `labelNames` (array) and `theme` at the top level. /// - version 2 defaulted the disk cache to 100 GB, more than most laptops have free; the stored /// old default becomes the new one (a size the user chose is kept). +/// - version 3 and earlier could still hold `createSplitLayersAbove: false`, the default before +/// #2083 (every save kept it); it becomes After Effects' default, above (#2545). pub fn migrate(mut v: Value) -> Value { let Some(obj) = v.as_object_mut() else { return json!({}) }; let version = obj.get("version").and_then(Value::as_u64).unwrap_or(0); @@ -42,6 +44,12 @@ pub fn migrate(mut v: Value) -> Value { { d.insert("diskCacheMaxGb".into(), json!(20)); } + if version < 4 + && let Some(g) = obj.get_mut("general").and_then(Value::as_object_mut) + && g.get("createSplitLayersAbove") == Some(&json!(false)) + { + g.insert("createSplitLayersAbove".into(), json!(true)); + } // Settings saved before appearance modes had one theme: keep showing it, as a fixed Dark or // Light mode, instead of switching established users to Auto. if let Some(a) = obj.get_mut("appearance").and_then(Value::as_object_mut) diff --git a/crates/engine/src/tests/settings.rs b/crates/engine/src/tests/settings.rs index f2fd5a3b7..79e9fa727 100644 --- a/crates/engine/src/tests/settings.rs +++ b/crates/engine/src/tests/settings.rs @@ -79,6 +79,15 @@ fn prefs_migrate_old_disk_cache_default() { assert_eq!(Prefs::from_json(r#"{"version": 3, "disk": {"diskCacheMaxGb": 100}}"#).disk.disk_cache_max_gb, 100); } +#[test] +fn prefs_migrate_old_split_layers_default() { + // Settings saved before #2083 kept "below"; they move to After Effects' "above" once (#2545). + let above = |doc: &str| Prefs::from_json(doc).general.create_split_layers_above; + assert!(above(r#"{"version": 3, "general": {"createSplitLayersAbove": false}}"#)); + assert!(above(r#"{"version": 2, "general": {"createSplitLayersAbove": false}}"#)); + assert!(!above(r#"{"version": 4, "general": {"createSplitLayersAbove": false}}"#), "a choice made since stays"); +} + #[test] fn prefs_set_validates_keys_types_and_ranges() { let mut p = Prefs::default(); From 50769d5cb696446437078af54ea1039a37ba5ac8 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Fri, 30 Oct 2026 12:03:48 +0700 Subject: [PATCH 6/7] Keep main's Japanese rows: drop the 13 #1772 already added (one source string per table) --- crates/ui-egui/src/i18n/ui.rs | 13 ------------- 1 file changed, 13 deletions(-) diff --git a/crates/ui-egui/src/i18n/ui.rs b/crates/ui-egui/src/i18n/ui.rs index 192c547cc..ed4378271 100644 --- a/crates/ui-egui/src/i18n/ui.rs +++ b/crates/ui-egui/src/i18n/ui.rs @@ -1109,8 +1109,6 @@ const JAPANESE: &[(&str, &str)] = &[ ("Pre-roll", "プリロール"), ("Post-roll", "ポストロール"), ("If caching, play cached frames", "キャッシュ中はキャッシュ済みフレームを再生"), - ("Apply", "適用"), - ("Analyze", "解析"), ("File Path", "ファイルパス"), ("Interpret Footage", "フッテージの解釈"), ("Create a new Folder", "新しいフォルダーを作成"), @@ -1231,12 +1229,6 @@ const JAPANESE: &[(&str, &str)] = &[ ("* Animation Presets", "* アニメーションプリセット"), ("Select a layer to apply to", "適用するレイヤーを選択してください"), ("Search effects and presets", "エフェクトとプリセットを検索"), - ("Search project", "プロジェクトを検索"), - ("Search", "検索"), - ("(no composition)", "(コンポジションなし)"), - ("per second", "/秒"), - ("Waveform", "波形"), - ("steps", "ステップ"), ("Building waveform…", "波形を生成中…"), ("No effects. Drag one from Effects & Presets.", "エフェクトがありません。エフェクト&プリセットからドラッグしてください。"), ( @@ -1263,11 +1255,6 @@ const JAPANESE: &[(&str, &str)] = &[ ("Unable to solve camera", "カメラを解析できません"), ("Waiting to solve", "解析待ち"), ("Create Camera", "カメラを作成"), - ("Add Point", "ポイントを追加"), - ("Remove Point", "ポイントを削除"), - ("Render", "レンダリング"), - ("Save Current as Template…", "現在の設定をテンプレートとして保存…"), - ("New Composition From Footage", "フッテージから新規コンポジション"), ("track point", "トラックポイント"), ("Ruler zero point", "ルーラーの原点"), ("Vertical ruler", "垂直ルーラー"), From e136844b531af69f546fdefcf58d6320f157420f Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Fri, 30 Oct 2026 01:29:03 -0400 Subject: [PATCH 7/7] Update prompt shows the app accent color (#2656) UPDATE_APP gains our accent (#e0368f, our colour in ArtCraft Launcher). The prompt draws it on the UPDATE chip, the buttons and the What's new underline, and says "by ArtCraft" (craft-update's default developer); #2646 gave it our icon. craft-update is pinned to storytold/craft-libs main at 1065653e7 (#434). --- Cargo.lock | 5 +++-- Cargo.toml | 3 ++- crates/ui-egui/src/lib.rs | 5 +++-- 3 files changed, 8 insertions(+), 5 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index e55969e6f..6ebd53b78 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1121,13 +1121,14 @@ dependencies = [ [[package]] name = "craft-update" version = "0.1.0" -source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0" +source = "git+https://github.com/storytold/craft-libs?rev=1065653e75854e9c17e1ff563acdd344c1b185f0#1065653e75854e9c17e1ff563acdd344c1b185f0" dependencies = [ "chrono", "dunce", "egui", "flate2", "getrandom 0.3.4", + "image", "log", "plist", "semver", @@ -4293,7 +4294,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7d8fae84b431384b68627d0f9b3b1245fcf9f46f6c0e3dc902e9dce64edd1967" dependencies = [ "libc", - "windows-sys 0.61.2", + "windows-sys 0.45.0", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index 24791c08d..772f62ee4 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -14,7 +14,8 @@ repository = "https://github.com/storytold/effectcraft" craft-pdf = { git = "https://github.com/storytold/craft-libs", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0", version = "=0.1.0", default-features = false, features = ["read"] } craft-docpath = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0" } craft-quit = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0" } -craft-update = { git = "https://github.com/storytold/craft-libs", version = "=0.1.0", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0", features = ["egui"] } +# craft-update with storytold/craft-libs#434: the update prompt shows the app's icon, accent and developer. +craft-update = { git = "https://github.com/storytold/craft-libs", version = "=0.1.0", rev = "1065653e75854e9c17e1ff563acdd344c1b185f0", features = ["egui"] } # internal effectcraft-time = { path = "crates/time" } effectcraft-geom = { path = "crates/geom" } diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 32fa8cafa..9dc19e322 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -7,11 +7,12 @@ #![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. +/// EffectCraft as craft-update knows it, with its 128 px icon and accent colour: the update +/// prompt shows both, and an AppImage's menu entry gets the icon. #[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")), + accent: Some([0xe0, 0x36, 0x8f]), ..craft_update::suite::EFFECTCRAFT };