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/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(); 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(); diff --git a/crates/ui-egui/src/i18n/ui.rs b/crates/ui-egui/src/i18n/ui.rs index 245593143..ed4378271 100644 --- a/crates/ui-egui/src/i18n/ui.rs +++ b/crates/ui-egui/src/i18n/ui.rs @@ -912,9 +912,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", "読み込みの種類"), @@ -1065,6 +1101,14 @@ 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", "キャッシュ中はキャッシュ済みフレームを再生"), ("File Path", "ファイルパス"), ("Interpret Footage", "フッテージの解釈"), ("Create a new Folder", "新しいフォルダーを作成"), @@ -1087,6 +1131,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", "プリセットを削除"), @@ -1182,6 +1229,17 @@ const JAPANESE: &[(&str, &str)] = &[ ("* Animation Presets", "* アニメーションプリセット"), ("Select a layer to apply to", "適用するレイヤーを選択してください"), ("Search effects and presets", "エフェクトとプリセットを検索"), + ("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", "エッジのブレンド"), diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 3b85c84cd..ad1842e3d 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -9,11 +9,12 @@ pub(crate) mod a11y; -/// 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 }; 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 2da839fe3..f84a49d84 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 19a8d9fc2..4fa75436b 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| { @@ -4964,7 +4964,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/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/main.rs b/crates/ui-egui/tests/it/main.rs index e07382434..180b56232 100644 --- a/crates/ui-egui/tests/it/main.rs +++ b/crates/ui-egui/tests/it/main.rs @@ -69,6 +69,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_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] 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); +}