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

# Conflicts:
#	crates/ui-egui/src/lib.rs
This commit is contained in:
bflatastic committed 2026-10-30 12:38:03 +07:00
commit 5b056fcb30
20 files changed
+262 -21

No files matched your search

Generated
+3 -2
View File
@@ -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]]
+2 -1
View File
@@ -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" }
+1 -1
View File
@@ -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";
@@ -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)
+9
View File
@@ -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();
+6
View File
@@ -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);
}
}
+9 -3
View File
@@ -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(())
}
+13 -1
View File
@@ -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 {
+26
View File
@@ -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();
+58
View File
@@ -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", "エッジのブレンド"),
+3 -2
View File
@@ -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
};
+7 -2
View File
@@ -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<u64> {
/// 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<effectcraft_engine::project::ItemId>) -> Vec<u64> {
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<u64>
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,
+8 -3
View File
@@ -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).
+2 -2
View File
@@ -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::<crate::panels::DragPayload>(&ctx).as_deref()
&& ui.rect_contains_pointer(rect)
{
let items: Vec<ItemId> = crate::panels::project_drop_items(app, *dragged)
let items: Vec<ItemId> = 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()})));
}
}
+1 -1
View File
@@ -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::<crate::panels::DragPayload>(&ctx).as_deref()
&& ui.rect_contains_pointer(rect)
{
let items: Vec<effectcraft_engine::project::ItemId> = crate::panels::project_drop_items(app, *dragged)
let items: Vec<effectcraft_engine::project::ItemId> = crate::panels::project_drop_items(app, *dragged, None)
.into_iter()
.map(effectcraft_engine::project::ItemId)
.filter(|id| {
+5 -1
View File
@@ -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![],
+12 -2
View File
@@ -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
});
+1
View File
@@ -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;
+13
View File
@@ -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]
+75
View File
@@ -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<String> {
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);
}