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
# Conflicts: # crates/ui-egui/src/lib.rs
This commit is contained in:
commit
5b056fcb30
20 files changed
+262
-21
No files matched your search
Generated
+3
-2
@@ -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
@@ -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" }
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(())
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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", "エッジのブレンド"),
|
||||
|
||||
@@ -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
|
||||
};
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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()})));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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| {
|
||||
|
||||
@@ -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![],
|
||||
|
||||
@@ -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
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
Reference in new issue
Block a user