From ccae8411b5d6d30410637619ddf03058d527c196 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 30 Oct 2026 01:41:06 +0000 Subject: [PATCH 1/5] i18n(ja): fix one mistranslation and unify terms the menu and the panels spell differently "Warn User When Executing Files" said the cursor, not the user, was warned. The other 27 rows give the same English term one Japanese spelling in the menu table and another in the UI table (track/tracking, Region of Interest, Time Remapping, Separate Dimensions, Stabilize Motion, Hold, Brightness, Vector Layer, Small Screen, Straight, Expression and a few more), or read awkwardly ("({}) Selected Objects", "Interpret Unlabeled Alpha As"). Each now follows the spelling the rest of the file already uses. Only Japanese values change; no key or English text does. Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01K5NRZNWfaSLBWVzV8hivyS --- crates/ui-egui/src/i18n.rs | 30 +++++++++++++++--------------- crates/ui-egui/src/i18n/ui.rs | 26 +++++++++++++------------- 2 files changed, 28 insertions(+), 28 deletions(-) diff --git a/crates/ui-egui/src/i18n.rs b/crates/ui-egui/src/i18n.rs index 14afe59cc..7f4a77989 100644 --- a/crates/ui-egui/src/i18n.rs +++ b/crates/ui-egui/src/i18n.rs @@ -1874,7 +1874,7 @@ const JAPANESE: &[(&str, &str, &str)] = &[ ("comp.settings", "Composition Settings...", "コンポジション設定..."), ("comp.setPosterTime", "Set Poster Time", "ポスター時間を設定"), ("comp.trimToWorkArea", "Trim Comp to Work Area", "コンポジションをワークエリアにトリミング"), - ("comp.cropToRegionOfInterest", "Crop Comp to Region of Interest", "コンポジションを目標範囲にクロップ"), + ("comp.cropToRegionOfInterest", "Crop Comp to Region of Interest", "コンポジションを関心領域にクロップ"), ("comp.cropToLayerBounds", "Crop Comp to Selected Layer(s) Bounds", "コンポジションを選択レイヤーの境界にクロップ"), ("render.addOutputModule", "Add Output Module", "出力モジュールを追加"), ("", "Preview", "プレビュー"), @@ -1927,7 +1927,7 @@ const JAPANESE: &[(&str, &str, &str)] = &[ ("layer.mask.reset", "Reset Mask", "マスクをリセット"), ("layer.mask.remove", "Remove Mask", "マスクを削除"), ("layer.mask.removeAll", "Remove All Masks", "すべてのマスクを削除"), - ("track.mask", "Track Mask", "マスクをトラック"), + ("track.mask", "Track Mask", "マスクをトラッキング"), ("", "Mode", "モード"), ("layer.mask.mode", "None", "なし"), ("layer.mask.mode", "Add", "加算"), @@ -1992,8 +1992,8 @@ const JAPANESE: &[(&str, &str, &str)] = &[ ("layer.transform", "Fit to Comp Height", "コンポジションの高さに合わせる"), ("layer.autoOrient", "Auto-Orient...", "自動方向..."), ("", "Time", "時間"), - ("layer.enableTimeRemap", "Enable Time Remapping", "タイムリマップ使用可能"), - ("layer.timeReverse", "Time-Reverse Layer", "時間反転レイヤー"), + ("layer.enableTimeRemap", "Enable Time Remapping", "時間リマップを有効化"), + ("layer.timeReverse", "Time-Reverse Layer", "レイヤーを時間反転"), ("layer.timeStretch", "Time Stretch...", "時間伸縮..."), ("layer.freezeFrame", "Freeze Frame", "フレームを固定"), ("layer.freezeOnLastFrame", "Freeze On Last Frame", "最後のフレームで固定"), @@ -2061,7 +2061,7 @@ const JAPANESE: &[(&str, &str, &str)] = &[ ("layer.trackMatte", "Matte with Layer Above", "上のレイヤーをマットにする"), ("layer.trackMatte", "Matte with Layer Below", "下のレイヤーをマットにする"), ("", "Layer Styles", "レイヤースタイル"), - ("layer.style.options", "Layer Style Options...", "レイヤースタイルオプション..."), + ("layer.style.options", "Layer Style Options...", "レイヤースタイルのオプション..."), ("layer.style.convertToEditable", "Convert to Editable Styles", "編集可能なスタイルに変換"), ("layer.style.showAll", "Show All", "すべてを表示"), ("layer.style.removeAll", "Remove All", "すべてを削除"), @@ -2090,7 +2090,7 @@ const JAPANESE: &[(&str, &str, &str)] = &[ ("layer.create", "Convert to Editable Text", "編集可能なテキストに変換"), ("layer.create", "Create Shapes from Text", "テキストからシェイプを作成"), ("layer.create", "Create Masks from Text", "テキストからマスクを作成"), - ("layer.create", "Create Shapes from Vector Layer", "ベクトルレイヤーからシェイプを作成"), + ("layer.create", "Create Shapes from Vector Layer", "ベクターレイヤーからシェイプを作成"), ("layer.create", "Create Keyframes from Data", "データからキーフレームを作成"), ("layer.create", "Null Controllers for Positional Points", "位置のポイント用ヌルコントローラー"), ("layer.create", "Null Controllers for Path Points", "パスのポイント用ヌルコントローラー"), @@ -2140,7 +2140,7 @@ const JAPANESE: &[(&str, &str, &str)] = &[ ("layer.applyTextPreset", "Jitter", "ジッター"), ("layer.applyTextPreset", "Drop In Lines", "行をドロップイン"), ("anim.addKeyframe", "Add Keyframe", "キーフレームを追加"), - ("keys.toggleHold", "Toggle Hold Keyframe", "停止したキーフレームの切り替え"), + ("keys.toggleHold", "Toggle Hold Keyframe", "ホールドキーフレームの切り替え"), ("keys.setMuted", "Mute Keyframes", "キーフレームをミュート"), ("keys.setMuted", "Unmute Keyframes", "キーフレームのミュートを解除"), ("keys.interpolation", "Keyframe Interpolation...", "キーフレーム補間法..."), @@ -2156,7 +2156,7 @@ const JAPANESE: &[(&str, &str, &str)] = &[ ("layer.sequence", "Sequence Layers...", "シーケンスレイヤー..."), ("keys.timeReverse", "Time-Reverse Keyframes", "時間反転キーフレーム"), ("", "Animate Text", "テキストをアニメート"), - ("layer.enablePerChar3D", "Enable Per-character 3D", "文字ごとの3Dを使用可能にする"), + ("layer.enablePerChar3D", "Enable Per-character 3D", "文字ごとの3Dを有効化"), ("layer.addTextAnimator", "Anchor Point", "アンカーポイント"), ("layer.addTextAnimator", "Position", "位置"), ("layer.addTextAnimator", "Scale", "スケール"), @@ -2168,7 +2168,7 @@ const JAPANESE: &[(&str, &str, &str)] = &[ ("layer.addTextAnimator", "RGB", "RGB"), ("layer.addTextAnimator", "Hue", "色相"), ("layer.addTextAnimator", "Saturation", "彩度"), - ("layer.addTextAnimator", "Brightness", "明度"), + ("layer.addTextAnimator", "Brightness", "明るさ"), ("", "Stroke Color", "線のカラー"), ("layer.addTextAnimator", "Stroke Width", "線幅"), ("", "Front", "前面"), @@ -2190,12 +2190,12 @@ const JAPANESE: &[(&str, &str, &str)] = &[ ("text.removeAllAnimators", "Remove All Text Animators", "すべてのテキストアニメーターを削除"), ("prop.setExpression", "Add Expression", "エクスプレッションを追加"), ("essential.addProperty", "Add Property to Essential Graphics", "エッセンシャルグラフィックスにプロパティを追加"), - ("prop.separateDimensions", "Separate Dimensions", "次元に分割"), - ("track.camera", "Track Camera", "カメラをトラック"), + ("prop.separateDimensions", "Separate Dimensions", "次元を分離"), + ("track.camera", "Track Camera", "カメラをトラッキング"), ("track.warpStabilizer", "Warp Stabilizer VFX", "ワープスタビライザーVFX"), - ("track.motion", "Track Motion", "モーションをトラック"), - ("track.stabilize", "Stabilize Motion", "モーションをスタビライズ"), - ("track.property", "Track this Property", "このプロパティをトラック"), + ("track.motion", "Track Motion", "モーションをトラッキング"), + ("track.stabilize", "Stabilize Motion", "モーションを安定化"), + ("track.property", "Track this Property", "このプロパティをトラッキング"), ("anim.reveal", "Reveal Properties with Keyframes", "キーフレームのあるプロパティを表示"), ("anim.reveal", "Reveal Properties with Animation", "アニメーションのあるプロパティを表示"), ("anim.reveal", "Reveal All Modified Properties", "変更されたすべてのプロパティを表示"), @@ -2275,7 +2275,7 @@ const JAPANESE: &[(&str, &str, &str)] = &[ ("window.workspace", "Default", "初期設定"), ("window.workspace", "Review", "レビュー"), ("window.workspace", "Learn", "学習"), - ("window.workspace", "Small Screen", "小画面"), + ("window.workspace", "Small Screen", "小さい画面"), ("window.workspace", "Standard", "標準"), ("window.workspace", "All Panels", "すべてのパネル"), ("window.workspace", "Animation", "アニメーション"), diff --git a/crates/ui-egui/src/i18n/ui.rs b/crates/ui-egui/src/i18n/ui.rs index 1da662aad..ab9b0eba2 100644 --- a/crates/ui-egui/src/i18n/ui.rs +++ b/crates/ui-egui/src/i18n/ui.rs @@ -455,7 +455,7 @@ const JAPANESE: &[(&str, &str)] = &[ ("None", "なし"), ("Not applied: tick it in the list to add it.", "未適用:リストでチェックを入れると追加されます。"), ("Not assigned", "未割り当て"), - ("Note: Shadows are only cast from layers with Cast Shadows enabled.", "注意:シャドウは「シャドウを投影」が有効なレイヤーからのみ投影されます。"), + ("Note: Shadows are only cast from layers with Cast Shadows enabled.", "注意:「シャドウを落とす」が有効なレイヤーだけがシャドウを落とします。"), ("Notify", "通知"), ("OK", "OK"), ("Offset:", "オフセット:"), @@ -537,7 +537,7 @@ const JAPANESE: &[(&str, &str)] = &[ ("Select a command to change its shortcut.", "ショートカットを変更するコマンドを選択してください。"), ("Select a layer to see its effects", "レイヤーを選択するとエフェクトが表示されます"), ("Select a layer to see its properties", "レイヤーを選択するとプロパティが表示されます"), - ("({}) Selected Objects", "({}) 個の選択されたオブジェクト"), + ("({}) Selected Objects", "選択したオブジェクト({})"), ( "Select a layer with a mask or shape path (or the path property), or Puppet pins.", "マスクまたはシェイプパスのあるレイヤー(またはパスプロパティ)か、パペットピンを選択してください。", @@ -685,7 +685,7 @@ const JAPANESE: &[(&str, &str)] = &[ ("Import Image Sequence", "画像シーケンスを読み込み"), ("Import PDF / Illustrator File", "PDF / Illustratorファイルを読み込み"), ("Import Photoshop File", "Photoshopファイルを読み込み"), - ("Interpret Unlabeled Alpha As", "ラベルのないアルファを変換"), + ("Interpret Unlabeled Alpha As", "ラベルのないアルファの解釈"), ("Join the ArtCraft Discord", "ArtCraftのDiscordに参加"), ("Mercury GPU Acceleration ({})", "Mercury GPUアクセラレーション({})"), ("Mercury Software Only", "Mercuryソフトウェアのみ"), @@ -803,7 +803,7 @@ const JAPANESE: &[(&str, &str)] = &[ ("Cycle Mask Colors", "新規マスクに別のカラーを使用"), ("Use Gradients", "グラデーションを使用"), ("Menu Bar", "メニューバー"), - ("Grids & Guides", "グリッド&ガイド"), + ("Grids & Guides", "グリッドとガイド"), ("Gridline Every", "グリッドの間隔"), ("Subdivisions", "分割数"), ("Style", "スタイル"), @@ -835,7 +835,7 @@ const JAPANESE: &[(&str, &str)] = &[ ("Ask User", "ユーザーに確認"), ("Guess", "推測"), ("Ignore", "無視"), - ("Straight", "そのまま"), + ("Straight", "ストレート"), ("Premultiplied", "プリマルチプライ"), ("Default Drag Import As", "複数アイテムのドラッグ読み込みの初期設定"), ("Footage", "フッテージ"), @@ -885,7 +885,7 @@ const JAPANESE: &[(&str, &str)] = &[ ("Scripting & Expressions", "スクリプトとエクスプレッション"), ("Application Scripting", "アプリケーションのスクリプト"), ("Allow Scripts to Write Files and Access Network", "スクリプトによるファイルへの書き込みとネットワークへのアクセスを許可"), - ("Warn User When Executing Files", "ファイルの実行時にカーソルに警告"), + ("Warn User When Executing Files", "ファイルの実行時にユーザーに警告"), ("Enable JavaScript Debugger", "JavaScript デバッガーを使用"), ("Expressions Editor", "エクスプレッションエディター"), ("Font Size", "フォントサイズ"), @@ -894,7 +894,7 @@ const JAPANESE: &[(&str, &str)] = &[ ("Auto-complete", "オートコンプリート"), ("Bracket Matching", "対応する括弧をハイライト"), ("Word Wrap", "折り返し"), - ("Show Expression Error Banner", "式のエラーがある場合に警告バナーを表示"), + ("Show Expression Error Banner", "エクスプレッションのエラーがある場合に警告バナーを表示"), ( "Expressions use the JavaScript engine. Script errors report their file and line in the Script Console; there is no step debugger.", "エクスプレッションはJavaScriptエンジンを使用します。スクリプトのエラーはファイルと行をスクリプトコンソールに報告します。ステップ実行デバッガーはありません。", @@ -1003,8 +1003,8 @@ const JAPANESE: &[(&str, &str)] = &[ ("Create Shapes from Vector Layer", "ベクターレイヤーからシェイプを作成"), ("Create Shapes from Text", "テキストからシェイプを作成"), ("Create Masks from Text", "テキストからマスクを作成"), - ("New Mask", "新しいマスク"), - ("Center In View", "ビュー内で中央揃え"), + ("New Mask", "新規マスク"), + ("Center In View", "ビューの中央に配置"), ("Fit to Comp", "コンポジションに合わせる"), ("Flip Horizontal", "水平方向に反転"), ("Flip Vertical", "垂直方向に反転"), @@ -1033,8 +1033,8 @@ const JAPANESE: &[(&str, &str)] = &[ ("All Transform Properties", "すべてのトランスフォームプロパティ"), ("Line Anchor", "行のアンカー"), ("Line Spacing", "行間"), - ("Character Offset", "文字オフセット"), - ("Character Value", "文字値"), + ("Character Offset", "文字のオフセット"), + ("Character Value", "文字の値"), ("Fill Color: RGB", "塗りの色: RGB"), ("Fill Color: Hue", "塗りの色: 色相"), ("Fill Color: Saturation", "塗りの色: 彩度"), @@ -1060,7 +1060,7 @@ const JAPANESE: &[(&str, &str)] = &[ ("ScriptUI Panel", "ScriptUI パネル"), ("Lumetri Scopes", "Lumetri スコープ"), ("Media Browser", "メディアブラウザー"), - ("Content-Aware Fill", "コンテンツに応じた塗り"), + ("Content-Aware Fill", "コンテンツに応じた塗りつぶし"), ("Create Nulls From Paths", "パスからヌルを作成"), ("VR Comp Editor", "VR コンポジションエディター"), ("Media Duration", "メディアの長さ"), @@ -1095,7 +1095,7 @@ const JAPANESE: &[(&str, &str)] = &[ ("Background Color", "背景色"), ("Basic", "基本"), ("Shutter Angle", "シャッター角度"), - ("Shutter Phase", "シャッター位相"), + ("Shutter Phase", "シャッターフェーズ"), ("Samples Per Frame", "フレームあたりのサンプル数"), ("Adaptive Sample Limit", "適応サンプル数の上限"), ("X and Y", "X と Y"), From 8a317537fa5332ba2c10f69e422f2dcd04ba1fc6 Mon Sep 17 00:00:00 2001 From: Kristian Myram Date: Fri, 30 Oct 2026 02:44:56 +0100 Subject: [PATCH 2/5] #1805: keep marker names and duration handles visible with shared GPU test gating --- crates/ui-egui/src/panels/markers_ui.rs | 57 +++++++++++++++---- crates/ui-egui/tests/it/gpu_test.rs | 43 ++++++++++++++ crates/ui-egui/tests/it/main.rs | 1 + .../ui-egui/tests/it/render_queue_snapshot.rs | 5 +- crates/ui-egui/tests/it/ui_3d.rs | 10 ++-- .../tests/it/ui_blank_context_menus.rs | 3 +- crates/ui-egui/tests/it/ui_camera_tracker.rs | 6 +- .../tests/it/ui_character_eyedropper.rs | 5 +- crates/ui-egui/tests/it/ui_dock_resize.rs | 4 +- crates/ui-egui/tests/it/ui_env_light_gpu.rs | 1 + crates/ui-egui/tests/it/ui_font_search.rs | 6 +- crates/ui-egui/tests/it/ui_gpu.rs | 1 + crates/ui-egui/tests/it/ui_guide_layers.rs | 3 +- .../tests/it/ui_layer_source_picker.rs | 4 +- crates/ui-egui/tests/it/ui_marker_names.rs | 33 +++++++++-- .../tests/it/ui_media_browser_cache.rs | 3 +- .../tests/it/ui_media_browser_copy_path.rs | 3 +- crates/ui-egui/tests/it/ui_menus.rs | 3 +- crates/ui-egui/tests/it/ui_panels.rs | 3 +- crates/ui-egui/tests/it/ui_presets_folder.rs | 5 +- crates/ui-egui/tests/it/ui_script_console.rs | 3 +- crates/ui-egui/tests/it/ui_scriptui.rs | 4 +- crates/ui-egui/tests/it/ui_tabs.rs | 3 +- crates/ui-egui/tests/it/ui_theme_favorites.rs | 3 +- crates/ui-egui/tests/it/ui_viewer.rs | 11 ++-- crates/ui-egui/tests/it/ui_viewers.rs | 3 +- crates/ui-egui/tests/it/ui_visual_review.rs | 9 +-- crates/ui-egui/tests/it/ui_web_gpu_storage.rs | 1 + crates/ui-egui/tests/it/ui_web_job_gpu.rs | 8 +-- .../ui-egui/tests/it/ui_workflow_selection.rs | 5 +- crates/ui-egui/tests/it/viewer_geometry.rs | 5 +- 31 files changed, 194 insertions(+), 60 deletions(-) create mode 100644 crates/ui-egui/tests/it/gpu_test.rs diff --git a/crates/ui-egui/src/panels/markers_ui.rs b/crates/ui-egui/src/panels/markers_ui.rs index cc08f223d..15bfa0f72 100644 --- a/crates/ui-egui/src/panels/markers_ui.rs +++ b/crates/ui-egui/src/panels/markers_ui.rs @@ -296,21 +296,13 @@ fn marker(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painter, comp: hx += 5.0; } } - p.line_segment([pos2(xe, y0), pos2(xe, y0 + h)], Stroke::new(1.0, col)); } - p.add(egui::Shape::convex_polygon(marker_shape(x, y0, h), col, Stroke::NONE)); if !m.comment.is_empty() { // The name sits in a small outlined box beside the flag, as in After Effects. It is drawn in the theme's field // colours, so it follows Dark, Darker and Light. let galley = p.layout_no_wrap(m.comment.lines().next().unwrap_or_default().to_string(), Tokens::ui(10.0), t.text); - let (box_w, mut box_x) = (galley.size().x + 8.0, x + 6.0); - // A duration that ends under the box would be hidden by its fill: the box steps past the end cap instead. - if let Some(xe) = end - && xe > box_x - && xe < box_x + box_w - { - box_x = xe + 3.0; - } + let box_w = galley.size().x + 8.0; + let box_x = name_box_x(x, box_w, end, p.clip_rect()); let box_rect = Rect::from_min_size(pos2(box_x, y0), vec2(box_w, h.max(galley.size().y + 2.0))); p.rect(box_rect, t.radius_sm, t.field_bg, Stroke::new(1.0, t.field_border), egui::StrokeKind::Inside); p.galley(pos2(box_rect.min.x + 4.0, box_rect.center().y - galley.size().y / 2.0), galley, t.text); @@ -385,6 +377,35 @@ fn marker(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painter, comp: }); } +fn name_box_x(x: f32, width: f32, end: Option, clip: Rect) -> f32 { + let start = x + 6.0; + if let Some(end) = end + && end > start + && end < start + width + { + let right = end + 3.0; + if right + width <= clip.right() { + return right; + } + let left = x - 6.0 - width; + if left >= clip.left() { + return left; + } + } + start +} + +/// Flags and duration handles sit above every name box in their marker strip. +fn marker_glyphs(p: &egui::Painter, tokens: &Tokens, tm: TMap, marker: &Marker, time: f64, y0: f32, y1: f32) { + let color = marker_color(tokens, marker.label); + let h = (y1 - y0).min(10.0); + p.add(egui::Shape::convex_polygon(marker_shape(tm.x(time), y0, h), color, Stroke::NONE)); + if marker.duration > Tick(0) { + let end = tm.x(time + marker.duration.seconds()); + p.line_segment([pos2(end, y0), pos2(end, y0 + h)], Stroke::new(1.0, color)); + } +} + /// Composition markers in the ruler strip `[y0, y1]`, protected regions over `rows`. pub(crate) fn comp_markers(app: &mut EffectcraftApp, ui: &mut egui::Ui, comp: &Comp, tm: TMap, strip: Rect, rows: Rect) { let p = ui.painter().with_clip_rect(strip); @@ -398,6 +419,9 @@ pub(crate) fn comp_markers(app: &mut EffectcraftApp, ui: &mut egui::Ui, comp: &C for (i, m) in comp.markers.iter().enumerate() { marker(app, ui, &p, comp, tm, m, m.time.seconds(), MarkerRef { layer: None, index: i }, strip.min.y, strip.max.y); } + for m in &comp.markers { + marker_glyphs(&p, &app.tokens, tm, m, m.time.seconds(), strip.min.y, strip.max.y); + } } /// The composition marker bin at the right end of the time ruler (`bin`), as in After Effects @@ -439,6 +463,9 @@ pub(crate) fn layer_markers(app: &mut EffectcraftApp, ui: &mut egui::Ui, clip: R for (i, m) in layer.markers.iter().enumerate() { marker(app, ui, &p, comp, tm, m, layer.comp_time(m.time).seconds(), MarkerRef { layer: Some(layer.id.0), index: i }, r.min.y + 3.0, r.max.y - 3.0); } + for m in &layer.markers { + marker_glyphs(&p, &app.tokens, tm, m, layer.comp_time(m.time).seconds(), r.min.y + 3.0, r.max.y - 3.0); + } } /// The nested comp's markers on a precomp layer's bar (`markers.nested`). @@ -488,6 +515,16 @@ fn nested_markers(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painter mod tests { use super::*; + #[test] + fn duration_name_box_stays_visible_near_the_right_clip_edge() { + let clip = Rect::from_min_max(pos2(0.0, 0.0), pos2(100.0, 20.0)); + let x = name_box_x(70.0, 20.0, Some(85.0), clip); + assert!(x >= clip.left() && x + 20.0 <= clip.right()); + assert!(x + 20.0 < 70.0 - 4.0, "the left-hand box must not cover its own marker flag"); + assert!(85.0 < x || 85.0 > x + 20.0, "the end cap must remain outside the box"); + assert_eq!(name_box_x(20.0, 20.0, Some(35.0), clip), 38.0); + } + #[test] fn marker_dialog_reads_and_writes_every_field() { let mut s = effectcraft_engine::Session::default(); diff --git a/crates/ui-egui/tests/it/gpu_test.rs b/crates/ui-egui/tests/it/gpu_test.rs new file mode 100644 index 000000000..0156fbb4d --- /dev/null +++ b/crates/ui-egui/tests/it/gpu_test.rs @@ -0,0 +1,43 @@ +//! Shared device-creation gate for the integration-test executable. + +static GPU_TEST_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(()); + +thread_local! { + static HELD: std::cell::Cell = const { std::cell::Cell::new(false) }; +} + +pub(crate) struct Guard { + held: Option>, +} + +impl Drop for Guard { + fn drop(&mut self) { + if self.held.is_some() { + HELD.with(|held| held.set(false)); + } + } +} + +/// GPU-backed harnesses hold this for their whole test; screenshots may enter it again. +pub(crate) fn lock() -> Guard { + let held = if HELD.with(std::cell::Cell::get) { + None + } else { + let guard = GPU_TEST_LOCK.lock().unwrap_or_else(std::sync::PoisonError::into_inner); + HELD.with(|held| held.set(true)); + Some(guard) + }; + Guard { held } +} + +/// Plain harnesses create their GPU renderer lazily on the first screenshot. +pub(crate) trait RenderSerial { + fn render_serialized(&mut self) -> Result; +} + +impl RenderSerial for egui_kittest::Harness<'_, State> { + fn render_serialized(&mut self) -> Result { + let _device = lock(); + self.render().map_err(|error| format!("{error:?}")) + } +} diff --git a/crates/ui-egui/tests/it/main.rs b/crates/ui-egui/tests/it/main.rs index 7d38e3089..17fe06cae 100644 --- a/crates/ui-egui/tests/it/main.rs +++ b/crates/ui-egui/tests/it/main.rs @@ -1,5 +1,6 @@ //! The crate's integration tests, one binary (each module is a former `tests/.rs`). +mod gpu_test; mod native_menu_text_keys; mod render_queue_snapshot; mod shared_docking_ui; diff --git a/crates/ui-egui/tests/it/render_queue_snapshot.rs b/crates/ui-egui/tests/it/render_queue_snapshot.rs index d054511af..27054119d 100644 --- a/crates/ui-egui/tests/it/render_queue_snapshot.rs +++ b/crates/ui-egui/tests/it/render_queue_snapshot.rs @@ -2,6 +2,7 @@ //! adapter); run with //! `RQ_SNAPSHOT=/abs/out.png cargo test -p effectcraft-ui-egui --test it render_queue_snapshot:: -- --ignored`. +use crate::gpu_test::RenderSerial; use effectcraft_engine::Session; use effectcraft_engine::project::render_queue::RenderStatus; use effectcraft_ui_egui::EffectcraftApp; @@ -34,7 +35,7 @@ fn render_queue_panel_snapshot() { h.ctx.data_mut(|d| d.insert_temp(egui::Id::unique("rq-ui"), (ids, None::))); h.state_mut().show_panel(effectcraft_ui_egui::dock::PanelKind::RenderQueue); h.run_steps(4); - let img = h.render().expect("render"); + let img = h.render_serialized().expect("render"); let out = std::env::var("RQ_SNAPSHOT").unwrap_or_else(|_| concat!(env!("CARGO_MANIFEST_DIR"), "/../../target/test-out/render_queue.png").into()); if let Some(d) = std::path::Path::new(&out).parent() { std::fs::create_dir_all(d).unwrap(); @@ -57,7 +58,7 @@ fn templates_dialog_snapshot() { let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_eframe(|_| app.take().expect("app")); h.run_steps(4); assert!(h.state().dialog.is_some(), "the Templates dialog is open"); - let img = h.render().expect("render"); + let img = h.render_serialized().expect("render"); let out = std::env::var("RQ_TEMPLATES_SNAPSHOT").unwrap_or_else(|_| concat!(env!("CARGO_MANIFEST_DIR"), "/../../target/test-out/rq_templates.png").into()); if let Some(d) = std::path::Path::new(&out).parent() { std::fs::create_dir_all(d).unwrap(); diff --git a/crates/ui-egui/tests/it/ui_3d.rs b/crates/ui-egui/tests/it/ui_3d.rs index a654f745c..0f7a10b7e 100644 --- a/crates/ui-egui/tests/it/ui_3d.rs +++ b/crates/ui-egui/tests/it/ui_3d.rs @@ -2,6 +2,7 @@ //! with wgpu and writes PNGs when `EC_SNAPSHOT_DIR` is set (`cargo test -p effectcraft-ui-egui //! --test it ui_3d:: -- --ignored`); the other test only runs the UI logic. +use crate::gpu_test::RenderSerial; use effectcraft_engine::Session; use effectcraft_engine::render::three_d::View3D; use effectcraft_ui_egui::EffectcraftApp; @@ -109,6 +110,7 @@ fn camera_and_light_dialogs_create_layers() { #[test] #[ignore = "renders with wgpu; run with --ignored (set EC_SNAPSHOT_DIR to keep PNGs)"] fn snapshot() { + let _device = crate::gpu_test::lock(); let dir = std::env::var("EC_SNAPSHOT_DIR").ok(); let mut h = Harness::builder().with_size(egui::vec2(1600.0, 1000.0)).with_pixels_per_point(1.0).wgpu().build_eframe(|_| app()); let shots: [(&str, Option<&str>); 4] = @@ -118,7 +120,7 @@ fn snapshot() { h.state_mut().session.execute(c, json!({})).unwrap(); } settle(&mut h); - let img = h.render().expect("render"); + let img = h.render_serialized().expect("render"); if let Some(d) = &dir { img.save(format!("{d}/ui3d_{name}.png")).unwrap(); } @@ -126,14 +128,14 @@ fn snapshot() { let ctx = h.ctx.clone(); effectcraft_ui_egui::menus::invoke(h.state_mut(), &ctx, "layer.newCamera", json!({})).unwrap(); h.run_steps(3); - let img = h.render().expect("render"); + let img = h.render_serialized().expect("render"); if let Some(d) = &dir { img.save(format!("{d}/ui3d_camera_dialog.png")).unwrap(); } h.state_mut().dialog = None; effectcraft_ui_egui::menus::invoke(h.state_mut(), &ctx, "layer.newLight", json!({})).unwrap(); h.run_steps(3); - let img = h.render().expect("render"); + let img = h.render_serialized().expect("render"); if let Some(d) = &dir { img.save(format!("{d}/ui3d_light_dialog.png")).unwrap(); } @@ -143,7 +145,7 @@ fn snapshot() { h.state_mut().session.execute("layer.select", json!({"layers": ["Card Blue"]})).unwrap(); effectcraft_ui_egui::menus::invoke(h.state_mut(), &ctx, "timeline.reveal.rotation", json!({})).unwrap(); settle(&mut h); - let img = h.render().expect("render"); + let img = h.render_serialized().expect("render"); if let Some(d) = &dir { img.save(format!("{d}/ui3d_reveal_rotation.png")).unwrap(); } diff --git a/crates/ui-egui/tests/it/ui_blank_context_menus.rs b/crates/ui-egui/tests/it/ui_blank_context_menus.rs index 52417326f..84080be08 100644 --- a/crates/ui-egui/tests/it/ui_blank_context_menus.rs +++ b/crates/ui-egui/tests/it/ui_blank_context_menus.rs @@ -1,4 +1,5 @@ //! Right-click actions in the blank Project and Timeline areas, and on Timeline property names. +use crate::gpu_test::RenderSerial; use effectcraft_engine::Session; use effectcraft_engine::project::ItemKind; use effectcraft_ui_egui::dock::WorkspaceExt as _; @@ -20,7 +21,7 @@ fn rect(h: &Harness<'_, EffectcraftApp>, id: &str) -> Rect { } fn snapshot(h: &mut Harness<'_, EffectcraftApp>, name: &str) { if let Ok(dir) = std::env::var("BLANK_CONTEXT_MENU_SNAPSHOTS") { - h.render().unwrap().save(format!("{dir}/{name}.png")).unwrap(); + h.render_serialized().unwrap().save(format!("{dir}/{name}.png")).unwrap(); } } fn right_click(h: &mut Harness<'_, EffectcraftApp>, p: Pos2) { diff --git a/crates/ui-egui/tests/it/ui_camera_tracker.rs b/crates/ui-egui/tests/it/ui_camera_tracker.rs index f7d349342..1f42879a9 100644 --- a/crates/ui-egui/tests/it/ui_camera_tracker.rs +++ b/crates/ui-egui/tests/it/ui_camera_tracker.rs @@ -3,6 +3,7 @@ //! The solve is a synthetic one written into the effect (the analysis itself is tested in the //! engine and track crates). +use crate::gpu_test::RenderSerial; use effectcraft_engine::Session; use effectcraft_engine::effects::camera_tracker as ct; use effectcraft_engine::track::camtrack::{CameraSolve, ShotType, SolveMethod, SolvedFrame, SolvedPoint}; @@ -217,6 +218,7 @@ fn settle(h: &mut Harness<'_, EffectcraftApp>) { #[test] #[ignore = "analyses the demo's 3D Showcase and renders with wgpu; run with --ignored (set EC_SNAPSHOT_DIR to keep PNGs)"] fn snapshot_showcase_track() { + let _device = crate::gpu_test::lock(); let dir = std::env::var("EC_SNAPSHOT_DIR").ok(); let mut s = Session::default(); s.execute("file.openDemoProject", json!({})).unwrap(); @@ -235,7 +237,7 @@ fn snapshot_showcase_track() { h.input_mut().events.push(egui::Event::PointerMoved(r.center() + egui::vec2(12.0, 12.0))); h.run_steps(2); if let Some(d) = &dir { - h.render().expect("render").save(format!("{d}/camera_tracker_hover.png")).unwrap(); + h.render_serialized().expect("render").save(format!("{d}/camera_tracker_hover.png")).unwrap(); } let sel: Vec = pts.iter().take(5).filter_map(|i| i.rsplit('.').next()?.parse().ok()).collect(); h.state_mut().session.execute("camera.selectPoints", json!({"points": sel})).unwrap(); @@ -243,6 +245,6 @@ fn snapshot_showcase_track() { h.state_mut().session.execute("layer.select", json!({"layers": ["#3"]})).unwrap(); settle(&mut h); if let Some(d) = &dir { - h.render().expect("render").save(format!("{d}/camera_tracker_solid.png")).unwrap(); + h.render_serialized().expect("render").save(format!("{d}/camera_tracker_solid.png")).unwrap(); } } diff --git a/crates/ui-egui/tests/it/ui_character_eyedropper.rs b/crates/ui-egui/tests/it/ui_character_eyedropper.rs index ea2578a0d..ea4eecd99 100644 --- a/crates/ui-egui/tests/it/ui_character_eyedropper.rs +++ b/crates/ui-egui/tests/it/ui_character_eyedropper.rs @@ -1,4 +1,5 @@ //! Character Fill and Stroke can sample a displayed Composition pixel. +use crate::gpu_test::RenderSerial; use effectcraft_engine::{Session, commands::text_edit::layer_doc, project::LayerId}; use effectcraft_ui_egui::{ EffectcraftApp, @@ -37,6 +38,7 @@ fn settle(h: &mut Harness<'_, EffectcraftApp>) { #[test] fn character_fill_eyedropper_samples_viewer_and_keeps_text_selection() { + let _device = crate::gpu_test::lock(); let mut s = Session::default(); s.execute("comp.new", json!({"width": 320, "height": 180, "duration": 2})).unwrap(); s.execute("layer.newSolid", json!({"name": "Background", "color": "#20c040"})).unwrap(); @@ -64,6 +66,7 @@ fn character_fill_eyedropper_samples_viewer_and_keeps_text_selection() { #[test] fn character_stroke_eyedropper_samples_viewer() { + let _device = crate::gpu_test::lock(); let mut s = Session::default(); s.execute("comp.new", json!({"width": 320, "height": 180, "duration": 2})).unwrap(); s.execute("layer.newSolid", json!({"name": "brat green", "color": "#8ACE00"})).unwrap(); @@ -79,7 +82,7 @@ fn character_stroke_eyedropper_samples_viewer() { settle(&mut h); click_auto(&mut h, "character.stroke.eyedropper"); if let Ok(dir) = std::env::var("EC_SNAPSHOT_DIR") { - h.render().unwrap().save(format!("{dir}/character-eyedropper.png")).unwrap(); + h.render_serialized().unwrap().save(format!("{dir}/character-eyedropper.png")).unwrap(); } let sample = comp_to_screen(&h.ctx, [20.0, 20.0]).unwrap(); click(&mut h, sample); diff --git a/crates/ui-egui/tests/it/ui_dock_resize.rs b/crates/ui-egui/tests/it/ui_dock_resize.rs index 863fe697e..11ca740cb 100644 --- a/crates/ui-egui/tests/it/ui_dock_resize.rs +++ b/crates/ui-egui/tests/it/ui_dock_resize.rs @@ -1,5 +1,6 @@ //! Shared pane resizing in the full application, including application shortcut ownership. +use crate::gpu_test::RenderSerial; use craft_ui::docking::{Divider, DockArea, Hit}; use effectcraft_engine::Session; use effectcraft_ui_egui::EffectcraftApp; @@ -16,7 +17,7 @@ fn snapshots(h: &mut Harness<'_, EffectcraftApp>, directory: Option<&std::path:: let ctx = h.ctx.clone(); h.state_mut().set_theme(&ctx, theme); h.run_steps(3); - h.render().unwrap().save(directory.join(format!("effectcraft-{phase}-{name}.png"))).unwrap(); + h.render_serialized().unwrap().save(directory.join(format!("effectcraft-{phase}-{name}.png"))).unwrap(); } let ctx = h.ctx.clone(); h.state_mut().set_theme(&ctx, ThemeKind::Dark); @@ -32,6 +33,7 @@ fn first_size(h: &Harness<'_, EffectcraftApp>) -> f32 { #[test] fn focused_dock_resize_owns_arrows_and_keeps_pointer_dragging() { + let _device = crate::gpu_test::lock(); let mut session = Session::default(); session.execute("comp.new", json!({"name": "Split test", "width": 160, "height": 90, "duration": 1})).unwrap(); session.execute("layer.newSolid", json!({"name": "Selected", "color": [1.0, 0.0, 0.0, 1.0]})).unwrap(); diff --git a/crates/ui-egui/tests/it/ui_env_light_gpu.rs b/crates/ui-egui/tests/it/ui_env_light_gpu.rs index 171cc5728..71e4fae67 100644 --- a/crates/ui-egui/tests/it/ui_env_light_gpu.rs +++ b/crates/ui-egui/tests/it/ui_env_light_gpu.rs @@ -42,6 +42,7 @@ fn same_adapter(name: String) -> eframe::egui_wgpu::WgpuSetup { /// viewer's GPU frames change when the light is added. #[test] fn environment_light_lights_the_scene_on_the_gpu() { + let _device = crate::gpu_test::lock(); let Some(probe) = effectcraft_gpu::Gpu::headless() else { eprintln!("no GPU adapter: skipping"); return; diff --git a/crates/ui-egui/tests/it/ui_font_search.rs b/crates/ui-egui/tests/it/ui_font_search.rs index 77984cf6b..44b650be4 100644 --- a/crates/ui-egui/tests/it/ui_font_search.rs +++ b/crates/ui-egui/tests/it/ui_font_search.rs @@ -1,4 +1,5 @@ //! Real keyboard/pointer coverage for the font menus in both text panels. +use crate::gpu_test::RenderSerial; use effectcraft_engine::{Session, commands::text_edit::layer_doc, project::LayerId}; use effectcraft_ui_egui::{EffectcraftApp, dock::PanelKind}; use egui::{Event, Key, Modifiers, PointerButton}; @@ -29,6 +30,7 @@ fn type_text(h: &mut Harness<'_, EffectcraftApp>, text: &str) { #[test] fn font_search_filters_selects_and_undoes_in_both_panels() { + let _device = crate::gpu_test::lock(); for (panel, prefix) in [(PanelKind::Character, "character.font"), (PanelKind::Properties, "properties.text.font")] { let mut s = Session::default(); s.execute("comp.new", json!({"width": 320, "height": 180})).unwrap(); @@ -56,7 +58,7 @@ fn font_search_filters_selects_and_undoes_in_both_panels() { assert!(!rows.is_empty()); assert!(rows.iter().all(|r| r.label.to_lowercase().contains("jetbrains"))); if let Ok(dir) = std::env::var("EC_SNAPSHOT_DIR") { - h.render().unwrap().save(format!("{dir}/{prefix}-search.png")).unwrap(); + h.render_serialized().unwrap().save(format!("{dir}/{prefix}-search.png")).unwrap(); } assert_eq!(layer_doc(&h.state().session, LayerId(lid)).unwrap().font, "Inter", "typing only filters"); key(&mut h, Key::Enter); @@ -76,7 +78,7 @@ fn font_search_filters_selects_and_undoes_in_both_panels() { // Reopening clears the previous query, and mouse selection still works. h.run_steps(2); if let Ok(dir) = std::env::var("EC_SNAPSHOT_DIR") { - h.render().unwrap().save(format!("{dir}/{prefix}-reopened.png")).unwrap(); + h.render_serialized().unwrap().save(format!("{dir}/{prefix}-reopened.png")).unwrap(); } assert!(h.state().auto.query(&format!("{prefix}.option.")).len() > 1); type_text(&mut h, "JetBrains"); diff --git a/crates/ui-egui/tests/it/ui_gpu.rs b/crates/ui-egui/tests/it/ui_gpu.rs index 355c35bf5..c8cdc428a 100644 --- a/crates/ui-egui/tests/it/ui_gpu.rs +++ b/crates/ui-egui/tests/it/ui_gpu.rs @@ -41,6 +41,7 @@ fn same_adapter(name: String) -> eframe::egui_wgpu::WgpuSetup { #[test] fn viewer_draws_gpu_frames_that_match_the_cpu() { + let _device = crate::gpu_test::lock(); let Some(probe) = effectcraft_gpu::Gpu::headless() else { eprintln!("no GPU adapter: skipping"); return; diff --git a/crates/ui-egui/tests/it/ui_guide_layers.rs b/crates/ui-egui/tests/it/ui_guide_layers.rs index 37158658f..92196f5f1 100644 --- a/crates/ui-egui/tests/it/ui_guide_layers.rs +++ b/crates/ui-egui/tests/it/ui_guide_layers.rs @@ -1,6 +1,7 @@ //! Guide Layer in the layer context menus (Timeline rows and the Composition viewer), a plain item //! (no check mark) that toggles the switch on the selection, and the guide mark by a guide layer's //! name. +use crate::gpu_test::RenderSerial; use effectcraft_engine::Session; use effectcraft_engine::project::LayerId; use effectcraft_ui_egui::EffectcraftApp; @@ -45,7 +46,7 @@ fn guide(h: &Harness<'_, EffectcraftApp>, id: LayerId) -> bool { fn snapshot(h: &mut Harness<'_, EffectcraftApp>, name: &str) { if let Ok(dir) = std::env::var("GUIDE_LAYER_SNAPSHOTS") { - h.render().unwrap().save(format!("{dir}/{name}.png")).unwrap(); + h.render_serialized().unwrap().save(format!("{dir}/{name}.png")).unwrap(); } } diff --git a/crates/ui-egui/tests/it/ui_layer_source_picker.rs b/crates/ui-egui/tests/it/ui_layer_source_picker.rs index 78a0f8821..ca10e4249 100644 --- a/crates/ui-egui/tests/it/ui_layer_source_picker.rs +++ b/crates/ui-egui/tests/it/ui_layer_source_picker.rs @@ -3,6 +3,7 @@ //! Effect Controls' layer parameters (a Displacement Map's layer) pick through the same widget, //! and both write an undoable `prop.set`. +use crate::gpu_test::RenderSerial; use effectcraft_engine::Session; use effectcraft_engine::keyframe::Value; use effectcraft_engine::project::LayerId; @@ -183,6 +184,7 @@ fn effect_controls_layer_param_picks_through_the_shared_widget() { #[test] #[ignore = "renders with wgpu; run with --ignored (set EC_SNAPSHOT_DIR to keep PNGs)"] fn snapshot_source_dropdown_open_and_chosen() { + let _device = crate::gpu_test::lock(); let mut s = Session::default(); s.execute("comp.new", json!({"name": "Main", "width": 1280, "height": 720, "frameRate": 30, "duration": 5})).unwrap(); s.execute("comp.settings", json!({"renderer": "advanced3D"})).unwrap(); @@ -200,7 +202,7 @@ fn snapshot_source_dropdown_open_and_chosen() { h.run_steps(4); let save = |h: &mut Harness<'_, EffectcraftApp>, name: &str| { if let Ok(dir) = std::env::var("EC_SNAPSHOT_DIR") { - h.render().expect("render").save(format!("{dir}/{name}")).expect("save"); + h.render_serialized().expect("render").save(format!("{dir}/{name}")).expect("save"); } }; save(&mut h, "source_unset.png"); diff --git a/crates/ui-egui/tests/it/ui_marker_names.rs b/crates/ui-egui/tests/it/ui_marker_names.rs index 99adf41f3..190bd77bb 100644 --- a/crates/ui-egui/tests/it/ui_marker_names.rs +++ b/crates/ui-egui/tests/it/ui_marker_names.rs @@ -8,16 +8,13 @@ use effectcraft_ui_egui::theme::ThemeKind; use egui_kittest::Harness; use serde_json::json; -/// One render at a time: two tests creating a GPU device together crash some Vulkan loaders. -static RENDER: std::sync::Mutex<()> = std::sync::Mutex::new(()); - fn close(got: [u8; 4], want: egui::Color32) -> bool { got[..3].iter().zip(want.to_array()).all(|(a, b)| a.abs_diff(b) <= 3) } #[test] fn the_name_box_follows_the_theme() { - let _one_at_a_time = RENDER.lock().unwrap_or_else(std::sync::PoisonError::into_inner); + let _one_at_a_time = crate::gpu_test::lock(); let mut s = Session::default(); s.execute("comp.new", json!({"name": "Song", "width": 640, "height": 360, "duration": 4})).unwrap(); s.execute("layer.newSolid", json!({"name": "Bed", "color": "#204060", "width": 640, "height": 360})).unwrap(); @@ -58,7 +55,7 @@ fn the_name_box_follows_the_theme() { /// stays visible: the cap is drawn, the bar shows left of it, and the box starts after it. #[test] fn a_short_duration_stays_visible_beside_the_name_box() { - let _one_at_a_time = RENDER.lock().unwrap_or_else(std::sync::PoisonError::into_inner); + let _one_at_a_time = crate::gpu_test::lock(); let mut s = Session::default(); s.execute("comp.new", json!({"name": "Song", "width": 640, "height": 360, "duration": 4})).unwrap(); s.execute("layer.newSolid", json!({"name": "Bed", "color": "#204060", "width": 640, "height": 360})).unwrap(); @@ -95,3 +92,29 @@ fn a_short_duration_stays_visible_beside_the_name_box() { let fill = at(xe + 3.0 + 2.5, mid); assert!(close(fill, tokens.field_bg), "the box begins after the end cap: {fill:?}, field_bg {:?}", tokens.field_bg); } + +#[test] +fn a_later_name_box_does_not_hide_an_earlier_duration_cap() { + let _one_at_a_time = crate::gpu_test::lock(); + let mut s = Session::default(); + s.execute("comp.new", json!({"width": 640, "height": 360, "duration": 4})).unwrap(); + s.execute("markers.set", json!({"new": true, "time": 1.0, "duration": 0.5, "label": "Red"})).unwrap(); + s.execute("markers.set", json!({"new": true, "time": 1.3, "comment": "This name overlaps the earlier endpoint", "label": "Blue"})).unwrap(); + let mut h = Harness::builder().with_size(egui::vec2(1600.0, 1000.0)).build_eframe(move |_| EffectcraftApp::new(s)); + h.run_steps(5); + let a = h.state().auto.find("timeline.marker.0").unwrap().rect; + let b = h.state().auto.find("timeline.marker.1").unwrap().rect; + let x = |r: [f32; 4]| r[0] + r[2] / 2.0; + let end = x(a) + (x(b) - x(a)) * 0.5 / 0.3; + let y = a[1] + 1.0 + (a[3] - 1.0).min(10.0) / 2.0; + let image = h.render().unwrap(); + if let Some(dir) = std::env::var_os("MARKER_NAMES_SNAPSHOT") { + image.save(std::path::Path::new(&dir).join("marker-overlapping-cap.png")).unwrap(); + } + let scale = image.width() as f32 / 1600.0; + let cap = (-1..=1) + .map(|offset| image.get_pixel(((end + offset as f32) * scale) as u32, (y * scale) as u32).0) + .max_by_key(|pixel| i32::from(pixel[0]) - i32::from(pixel[1])) + .unwrap(); + assert!(i32::from(cap[0]) - i32::from(cap[1]) > 40, "the earlier red duration cap stays above the later name: {cap:?}"); +} diff --git a/crates/ui-egui/tests/it/ui_media_browser_cache.rs b/crates/ui-egui/tests/it/ui_media_browser_cache.rs index ddfc53e22..44ca9dd81 100644 --- a/crates/ui-egui/tests/it/ui_media_browser_cache.rs +++ b/crates/ui-egui/tests/it/ui_media_browser_cache.rs @@ -1,3 +1,4 @@ +use crate::gpu_test::RenderSerial; use std::sync::{ Arc, Mutex, atomic::{AtomicBool, Ordering}, @@ -140,6 +141,6 @@ fn browser_cache_refreshes_filters_navigates_and_recovers_from_errors() { assert_eq!(names(&listing(&h, false).1), ["Sub", "Added.csv", "Clip.csv", "From action.csv"]); assert!(h.state().auto.find("mediaBrowser.entry.Clip.csv").is_some()); if let Ok(path) = std::env::var("MEDIA_BROWSER_SNAPSHOT") { - h.render().unwrap().save(path).unwrap(); + h.render_serialized().unwrap().save(path).unwrap(); } } diff --git a/crates/ui-egui/tests/it/ui_media_browser_copy_path.rs b/crates/ui-egui/tests/it/ui_media_browser_copy_path.rs index dff60ddd0..8372b2be5 100644 --- a/crates/ui-egui/tests/it/ui_media_browser_copy_path.rs +++ b/crates/ui-egui/tests/it/ui_media_browser_copy_path.rs @@ -1,3 +1,4 @@ +use crate::gpu_test::RenderSerial; use std::sync::Arc; use effectcraft_engine::{ @@ -74,7 +75,7 @@ fn file_folder_and_virtual_paths_copy_from_the_context_menu_without_document_cha if name == "作品 clip.csv" && let Ok(file) = std::env::var("MEDIA_BROWSER_COPY_SNAPSHOT") { - h.render().unwrap().save(file).unwrap(); + h.render_serialized().unwrap().save(file).unwrap(); } assert_eq!(click(&mut h, &id, PointerButton::Primary), [path]); h.run_steps(3); diff --git a/crates/ui-egui/tests/it/ui_menus.rs b/crates/ui-egui/tests/it/ui_menus.rs index 424b75c48..ce0cbab1a 100644 --- a/crates/ui-egui/tests/it/ui_menus.rs +++ b/crates/ui-egui/tests/it/ui_menus.rs @@ -1,6 +1,7 @@ //! The menu bar from the keyboard, and the app's shortcuts waiting while a menu is open (#279) //! (egui_kittest, UI logic only). +use crate::gpu_test::RenderSerial; use effectcraft_engine::Session; use effectcraft_ui_egui::EffectcraftApp; use egui::{Event, Key, Modifiers, Pos2, Rect, pos2, vec2}; @@ -226,7 +227,7 @@ fn relink_is_reachable_through_the_dependencies_menu() { h.run_steps(8); let relink = h.query_by_label_contains("Relink Missing Footage").unwrap().rect(); if let Some(path) = std::env::var_os("EFFECTCRAFT_RELINK_MENU_SNAPSHOT") { - h.render().unwrap().save(path).unwrap(); + h.render_serialized().unwrap().save(path).unwrap(); } click_at(&mut h, relink.center()); assert!(picked.get()); diff --git a/crates/ui-egui/tests/it/ui_panels.rs b/crates/ui-egui/tests/it/ui_panels.rs index db9ef7373..a29d3e358 100644 --- a/crates/ui-egui/tests/it/ui_panels.rs +++ b/crates/ui-egui/tests/it/ui_panels.rs @@ -2,6 +2,7 @@ //! registers its automation ids; the Footage panel's buttons edit into the comp; the Progress //! panel lists and cancels a job; Lumetri Scopes switch scope through their dropdown. +use crate::gpu_test::RenderSerial; use effectcraft_ui_egui::dock::WorkspaceExt as _; use std::sync::Arc; @@ -448,7 +449,7 @@ fn panels_snapshot() { } open(&mut h, panel); h.run_steps(3); - let img = h.render().expect("render"); + let img = h.render_serialized().expect("render"); img.save(format!("{dir}/{panel}{scope}.png")).unwrap(); } } diff --git a/crates/ui-egui/tests/it/ui_presets_folder.rs b/crates/ui-egui/tests/it/ui_presets_folder.rs index e11efe944..1bcd9189c 100644 --- a/crates/ui-egui/tests/it/ui_presets_folder.rs +++ b/crates/ui-egui/tests/it/ui_presets_folder.rs @@ -1,5 +1,6 @@ //! Effects & Presets: copy the configured folder path through the contents menu. +use crate::gpu_test::RenderSerial; use effectcraft_engine::Session; use effectcraft_ui_egui::panels::effects_presets::PresetFile; use effectcraft_ui_egui::{ControlRequest, EffectcraftApp, control, dock::PanelKind}; @@ -52,7 +53,7 @@ fn presets_menu_copies_the_configured_folder_without_editing_the_project() { click(&mut h, "effects.menu"); h.run_steps(3); if let Ok(path) = std::env::var("PRESETS_FOLDER_SNAPSHOT") { - h.render().unwrap().save(path).unwrap(); + h.render_serialized().unwrap().save(path).unwrap(); } assert_eq!(click(&mut h, "effects.copyPresetsFolderPath"), [expected.as_str()]); h.run_steps(2); @@ -156,7 +157,7 @@ fn shared_preset_rows_search_apply_and_undo_the_selected_definition() { assert_eq!(h.state().session.active_comp().unwrap().layers[0].props.prop("transform/opacity").unwrap().value.as_f64(), expected); assert_eq!(h.state().session.history.undo.len(), history + 1); if let Ok(dir) = std::env::var("PRESET_CACHE_SNAPSHOTS") { - h.render().unwrap().save(format!("{dir}/{name}.png")).unwrap(); + h.render_serialized().unwrap().save(format!("{dir}/{name}.png")).unwrap(); } h.state_mut().session.execute("edit.undo", json!({})).unwrap(); h.run_steps(2); diff --git a/crates/ui-egui/tests/it/ui_script_console.rs b/crates/ui-egui/tests/it/ui_script_console.rs index e705a20b7..9430bc7a2 100644 --- a/crates/ui-egui/tests/it/ui_script_console.rs +++ b/crates/ui-egui/tests/it/ui_script_console.rs @@ -1,6 +1,7 @@ //! Window ▸ Script Console: the panel shows, runs JavaScript through `script.run` (persistent //! context) and logs output, results and errors (egui_kittest, UI logic only). +use crate::gpu_test::RenderSerial; use effectcraft_ui_egui::EffectcraftApp; use effectcraft_ui_egui::dock::PanelKind; use egui_kittest::Harness; @@ -59,7 +60,7 @@ fn script_console_snapshot() { } h.state_mut().ui.script_console.input = "app.project.numItems".into(); h.run_steps(3); - let img = h.render().expect("render"); + let img = h.render_serialized().expect("render"); let out = std::env::var("SC_SNAPSHOT").unwrap_or_else(|_| concat!(env!("CARGO_MANIFEST_DIR"), "/../../target/test-out/script_console.png").into()); if let Some(d) = std::path::Path::new(&out).parent() { std::fs::create_dir_all(d).unwrap(); diff --git a/crates/ui-egui/tests/it/ui_scriptui.rs b/crates/ui-egui/tests/it/ui_scriptui.rs index 09986f6d1..6199462d0 100644 --- a/crates/ui-egui/tests/it/ui_scriptui.rs +++ b/crates/ui-egui/tests/it/ui_scriptui.rs @@ -2,6 +2,7 @@ //! windows (clicks reach the script's handlers), the branching History panel, and the Window / //! File ▸ Scripts menus listing scripts (egui_kittest, UI logic only). +use crate::gpu_test::RenderSerial; use effectcraft_ui_egui::EffectcraftApp; use effectcraft_ui_egui::dock::PanelKind; use effectcraft_ui_egui::dock::WorkspaceExt as _; @@ -210,6 +211,7 @@ fn history_panel_jumps_between_branches() { /// onDraw fills of concave paths and real images (drawImage, image controls), rendered. #[test] fn on_draw_concave_fills_and_images_render() { + let _device = crate::gpu_test::lock(); let dir = std::env::temp_dir().join(format!("effectcraft-scriptui-img-{}", std::process::id())); std::fs::create_dir_all(&dir).unwrap(); let png = dir.join("green.png"); @@ -244,7 +246,7 @@ fn on_draw_concave_fills_and_images_render() { let g = &win.root.children[0]; assert!(matches!(&g.draw[1], effectcraft_engine::scriptui::DrawOp::Image { image: Some(i), .. } if i.src.as_deref() == Some(png.as_str()))); assert_eq!(win.root.children[1].image.as_ref().and_then(|i| i.src.clone()).as_deref(), Some(png.as_str())); - let img = h.render().expect("render"); + let img = h.render_serialized().expect("render"); if let Ok(d) = std::env::var("EC_SNAPSHOT_DIR") { img.save(format!("{d}/scriptui_paint.png")).unwrap(); } diff --git a/crates/ui-egui/tests/it/ui_tabs.rs b/crates/ui-egui/tests/it/ui_tabs.rs index 5fc3d8598..cc9e56041 100644 --- a/crates/ui-egui/tests/it/ui_tabs.rs +++ b/crates/ui-egui/tests/it/ui_tabs.rs @@ -2,6 +2,7 @@ //! as its own Timeline tab (not a rename, and not in place of the comp shown), the tabs switch and //! close comps, and a dragged tab lands where its insertion mark shows. +use crate::gpu_test::RenderSerial; use effectcraft_engine::Session; use effectcraft_engine::project::ItemId; use effectcraft_ui_egui::EffectcraftApp; @@ -135,7 +136,7 @@ fn tab_drag_snapshot() { } let dir = concat!(env!("CARGO_MANIFEST_DIR"), "/../../target/test-out"); std::fs::create_dir_all(dir).unwrap(); - h.render().expect("render").save(format!("{dir}/tab-drag.png")).unwrap(); + h.render_serialized().expect("render").save(format!("{dir}/tab-drag.png")).unwrap(); } /// A precomp layer's bar shows its comp's markers; a double-click opens that comp at the marker. diff --git a/crates/ui-egui/tests/it/ui_theme_favorites.rs b/crates/ui-egui/tests/it/ui_theme_favorites.rs index 1b700f51f..424b28c49 100644 --- a/crates/ui-egui/tests/it/ui_theme_favorites.rs +++ b/crates/ui-egui/tests/it/ui_theme_favorites.rs @@ -1,3 +1,4 @@ +use crate::gpu_test::RenderSerial; use effectcraft_engine::{Session, config::MemoryConfig, prefs::Prefs}; use effectcraft_ui_egui::{EffectcraftApp, menus}; use egui::{Event, Modifiers, PointerButton, Pos2, pos2, vec2}; @@ -49,7 +50,7 @@ fn star_controls_save_and_favorite_menu_selects_the_family() { assert_eq!(h.state().session.prefs.appearance.favorite_themes, vec!["light"]); if let Some(path) = std::env::var_os("FAVORITES_SCREENSHOT_DIR") { std::fs::create_dir_all(&path).unwrap(); - h.render().unwrap().save(std::path::Path::new(&path).join("effectcraft-favorites.png")).unwrap(); + h.render_serialized().unwrap().save(std::path::Path::new(&path).join("effectcraft-favorites.png")).unwrap(); } assert_eq!(h.state().session.prefs.appearance.appearance_mode, "dark"); let p = at(&h, "settings.ok"); diff --git a/crates/ui-egui/tests/it/ui_viewer.rs b/crates/ui-egui/tests/it/ui_viewer.rs index 41cf5fe43..48bc36a44 100644 --- a/crates/ui-egui/tests/it/ui_viewer.rs +++ b/crates/ui-egui/tests/it/ui_viewer.rs @@ -2,6 +2,7 @@ //! interest drawing, rulers and guides, snapping, the shape Pen and motion-path key drags //! (egui_kittest, UI logic only). +use crate::gpu_test::RenderSerial; use effectcraft_engine::Session; use effectcraft_engine::commands::shape_tool::PaintKind; use effectcraft_engine::project::LayerId; @@ -448,7 +449,7 @@ fn mid_drag(h: &mut Harness<'_, EffectcraftApp>, from: Pos2, to: Pos2, mods: egu h.input_mut().events.push(Event::PointerMoved(from + (to - from) * (i as f32 / 8.0))); h.step(); } - h.render().expect("the mid-drag frame") + h.render_serialized().expect("the mid-drag frame") } /// Release the button where the drag left it. @@ -490,7 +491,7 @@ fn shape_drag_ghost_shows_the_tools_own_shape_while_dragging() { h.state_mut().ui.tool = tool; settle_viewer(&mut h); let (a, b) = (screen(&h, from), screen(&h, to)); - let before = h.render().expect("the frame before the drag"); + let before = h.render_serialized().expect("the frame before the drag"); let mid = mid_drag(&mut h, a, b, egui::Modifiers::default()); let fill = fill_rgb(&h); @@ -526,7 +527,7 @@ fn shape_drag_ghost_normalises_the_same_way_as_the_release() { h.state_mut().ui.tool = Tool::Ellipse; settle_viewer(&mut h); let (a, b) = (screen(&h, from.map(|v| v as f32)), screen(&h, to.map(|v| v as f32))); - let before = h.render().expect("the frame before the drag"); + let before = h.render_serialized().expect("the frame before the drag"); // Measured with the button down, so this is the ghost and not the committed shape. let mid = mid_drag(&mut h, a, b, mods); let on = [centre[0] as f32 + size[0] as f32 / 4.0, centre[1] as f32 + size[1] as f32 / 4.0]; @@ -595,7 +596,7 @@ fn a_layer_drag_moves_the_picture_while_the_button_is_down() { h.input_mut().events.push(Event::PointerButton { pos: from, button: egui::PointerButton::Primary, pressed: true, modifiers: Default::default() }); h.step(); // Where the layer is not (yet): the plate behind it. - let plate = px_at(&h.render().unwrap(), &h, [440.0, 200.0]); + let plate = px_at(&h.render_serialized().unwrap(), &h, [440.0, 200.0]); // Drag in small steps, still holding the button, and give the renders a chance to land. let mut moved = false; @@ -606,7 +607,7 @@ fn a_layer_drag_moves_the_picture_while_the_button_is_down() { std::thread::sleep(std::time::Duration::from_millis(15)); // 30 comp px behind the pointer: inside the layer, well away from its outline, its anchor // icon and where it started. - let at = px_at(&h.render().expect("mid-drag frame"), &h, [320.0 + 200.0 * (i as f32 / 40.0) - 30.0, 200.0]); + let at = px_at(&h.render_serialized().expect("mid-drag frame"), &h, [320.0 + 200.0 * (i as f32 / 40.0) - 30.0, 200.0]); if i >= 30 && delta(at, plate) > 30 { moved = true; break; diff --git a/crates/ui-egui/tests/it/ui_viewers.rs b/crates/ui-egui/tests/it/ui_viewers.rs index 833ab0a60..bfbcad51e 100644 --- a/crates/ui-egui/tests/it/ui_viewers.rs +++ b/crates/ui-egui/tests/it/ui_viewers.rs @@ -2,6 +2,7 @@ //! comp goes to an unlocked viewer (a new one when every viewer is locked); a click on another //! viewer makes it the active one; closing a viewer forgets it. +use crate::gpu_test::RenderSerial; use effectcraft_engine::Session; use effectcraft_engine::project::ItemId; use effectcraft_ui_egui::EffectcraftApp; @@ -171,5 +172,5 @@ fn viewers_snapshot() { h.run_steps(4); let dir = concat!(env!("CARGO_MANIFEST_DIR"), "/../../target/test-out"); std::fs::create_dir_all(dir).unwrap(); - h.render().unwrap().save(format!("{dir}/viewers.png")).unwrap(); + h.render_serialized().unwrap().save(format!("{dir}/viewers.png")).unwrap(); } diff --git a/crates/ui-egui/tests/it/ui_visual_review.rs b/crates/ui-egui/tests/it/ui_visual_review.rs index 70cc5ed84..6d079f1f5 100644 --- a/crates/ui-egui/tests/it/ui_visual_review.rs +++ b/crates/ui-egui/tests/it/ui_visual_review.rs @@ -1,5 +1,6 @@ //! Opt-in visual review of every built-in panel, workspaces and common dialogs. //! Original fixture; captures are review outputs, not shipped assets. +use crate::gpu_test::RenderSerial; use effectcraft_engine::Session; use effectcraft_ui_egui::{Dialog, EffectcraftApp, dock::PanelKind}; use egui_kittest::Harness; @@ -19,18 +20,18 @@ fn all_ui_review_captures() { std::sync::Arc::make_mut(&mut s.project).settings.gpu_acceleration = false; let mut h = Harness::builder().with_size(size).build_eframe(|_| EffectcraftApp::new(s)); h.run_steps(4); - h.render().unwrap().save(out.join(format!("{label}-workspace.png"))).unwrap(); + h.render_serialized().unwrap().save(out.join(format!("{label}-workspace.png"))).unwrap(); let ctx = h.ctx.clone(); h.state_mut().set_theme(&ctx, effectcraft_ui_egui::theme::ThemeKind::Light); h.run_steps(4); - h.render().unwrap().save(out.join(format!("{label}-workspace-light.png"))).unwrap(); + h.render_serialized().unwrap().save(out.join(format!("{label}-workspace-light.png"))).unwrap(); h.state_mut().set_theme(&ctx, effectcraft_ui_egui::theme::ThemeKind::Dark); for p in PanelKind::ALL { h.state_mut().show_panel(p); h.state_mut().ui.maximized = Some(p); h.run_steps(4); assert!(h.state().auto.find(&format!("panel.{}", p.id())).is_some(), "{} opens", p.title()); - h.render().unwrap().save(out.join(format!("{label}-{}.png", p.id()))).unwrap(); + h.render_serialized().unwrap().save(out.join(format!("{label}-{}.png", p.id()))).unwrap(); } h.state_mut().ui.maximized = None; for (name, dialog) in [ @@ -42,7 +43,7 @@ fn all_ui_review_captures() { ] { h.state_mut().dialog = Some(dialog); h.run_steps(4); - h.render().unwrap().save(out.join(format!("{label}-dialog-{name}.png"))).unwrap(); + h.render_serialized().unwrap().save(out.join(format!("{label}-dialog-{name}.png"))).unwrap(); h.state_mut().dialog = None; h.run_steps(2); } diff --git a/crates/ui-egui/tests/it/ui_web_gpu_storage.rs b/crates/ui-egui/tests/it/ui_web_gpu_storage.rs index a742226de..024bd1cd0 100644 --- a/crates/ui-egui/tests/it/ui_web_gpu_storage.rs +++ b/crates/ui-egui/tests/it/ui_web_gpu_storage.rs @@ -122,6 +122,7 @@ fn cpu_pixels(f: &FrameImage) -> Vec { #[test] fn gpu_worker_renders_effect_frames_and_the_disk_cache_survives_a_reload() { + let _device = crate::gpu_test::lock(); let (Some(page_gpu), Some(worker_gpu)) = (Gpu::headless(), Gpu::headless_deferred()) else { eprintln!("no GPU adapter: skipped"); return; diff --git a/crates/ui-egui/tests/it/ui_web_job_gpu.rs b/crates/ui-egui/tests/it/ui_web_job_gpu.rs index 9060e1067..8b8914abc 100644 --- a/crates/ui-egui/tests/it/ui_web_job_gpu.rs +++ b/crates/ui-egui/tests/it/ui_web_job_gpu.rs @@ -10,10 +10,6 @@ use effectcraft_engine::offload::{Inbox, Offload, Post, WorkerReply, WorkerReque use effectcraft_gpu::Gpu; use serde_json::json; -/// One device at a time in this test binary: wgpu's OpenGL backend (Mesa llvmpipe on FreeBSD, -/// Linux without Vulkan) panics or crashes when tests create and drive devices concurrently. -static GPU_LOCK: Mutex<()> = Mutex::new(()); - /// Holds offloaded jobs until the test runs them. #[derive(Default)] struct Manual(Mutex)>>); @@ -63,7 +59,7 @@ fn page() -> (Session, Arc) { #[test] fn render_queue_jobs_render_on_the_worker_gpu() { - let _gpu = GPU_LOCK.lock().unwrap_or_else(std::sync::PoisonError::into_inner); + let _gpu = crate::gpu_test::lock(); let Some(g) = Gpu::headless_deferred() else { eprintln!("no GPU adapter: skipped"); return; @@ -102,7 +98,7 @@ fn render_queue_jobs_render_on_the_worker_gpu() { #[test] fn analyses_render_their_input_on_the_worker_gpu() { - let _gpu = GPU_LOCK.lock().unwrap_or_else(std::sync::PoisonError::into_inner); + let _gpu = crate::gpu_test::lock(); let Some(g) = Gpu::headless_deferred() else { eprintln!("no GPU adapter: skipped"); return; diff --git a/crates/ui-egui/tests/it/ui_workflow_selection.rs b/crates/ui-egui/tests/it/ui_workflow_selection.rs index 121c2b888..e7bcedb60 100644 --- a/crates/ui-egui/tests/it/ui_workflow_selection.rs +++ b/crates/ui-egui/tests/it/ui_workflow_selection.rs @@ -1,4 +1,5 @@ //! User-reported selection, Project deletion, and render-queue drop regressions. +use crate::gpu_test::RenderSerial; use effectcraft_engine::{Session, project::LayerId}; use effectcraft_ui_egui::{Dialog, EffectcraftApp, dock::PanelKind}; use egui::{Event, Modifiers, PointerButton, Pos2, Rect, pos2, vec2}; @@ -273,7 +274,7 @@ fn viewer_right_click_targets_hit_and_duplicate_preserves_group() { } assert!(h.state().frames.is_cached(&key)); h.run_steps(3); - h.render().expect("offscreen context menu render").save(out).unwrap(); + h.render_serialized().expect("offscreen context menu render").save(out).unwrap(); } h.get_by_label("Duplicate").click(); h.run_steps(3); @@ -433,7 +434,7 @@ fn workflow_snapshot() { } assert!(h.state().frames.is_cached(&key), "current composition rendered before capture"); h.run_steps(3); - h.render().expect("offscreen UI render").save(out).unwrap(); + h.render_serialized().expect("offscreen UI render").save(out).unwrap(); } /// Filled rects painted this frame (flattening nested shape lists). diff --git a/crates/ui-egui/tests/it/viewer_geometry.rs b/crates/ui-egui/tests/it/viewer_geometry.rs index e3e4128e8..d57a8aa9e 100644 --- a/crates/ui-egui/tests/it/viewer_geometry.rs +++ b/crates/ui-egui/tests/it/viewer_geometry.rs @@ -1,5 +1,6 @@ //! Inspect the actual viewer's painted colors and pointer-driven control geometry. +use crate::gpu_test::RenderSerial; use effectcraft_engine::Session; use effectcraft_ui_egui::{ EffectcraftApp, @@ -269,10 +270,10 @@ fn viewer_theme_and_width_visual_matrix() { } fn save_stable_snapshot(h: &mut Harness<'_, EffectcraftApp>, path: &str, width: f32) { - let image = h.render().unwrap(); + let image = h.render_serialized().unwrap(); image.save(path).unwrap(); h.run_steps(3); - let repeated = h.render().unwrap(); + let repeated = h.render_serialized().unwrap(); assert!(image == repeated, "settled synthetic viewer pixels must repeat exactly: {path}"); if width == 900.0 { let times: Vec<_> = h From 528a9ff7430743a7e2999bad05b7832ba2678edf Mon Sep 17 00:00:00 2001 From: Kristian Myram Date: Fri, 30 Oct 2026 02:58:56 +0100 Subject: [PATCH 3/5] #1805: keep ordinary marker names inside the viewport too --- crates/ui-egui/src/panels/markers_ui.rs | 27 ++++++++++++++----------- 1 file changed, 15 insertions(+), 12 deletions(-) diff --git a/crates/ui-egui/src/panels/markers_ui.rs b/crates/ui-egui/src/panels/markers_ui.rs index 15bfa0f72..03c45d32b 100644 --- a/crates/ui-egui/src/panels/markers_ui.rs +++ b/crates/ui-egui/src/panels/markers_ui.rs @@ -379,18 +379,16 @@ fn marker(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painter, comp: fn name_box_x(x: f32, width: f32, end: Option, clip: Rect) -> f32 { let start = x + 6.0; - if let Some(end) = end - && end > start - && end < start + width - { - let right = end + 3.0; - if right + width <= clip.right() { - return right; - } - let left = x - 6.0 - width; - if left >= clip.left() { - return left; - } + let right = match end { + Some(end) if end > start && end < start + width => end + 3.0, + _ => start, + }; + if right + width <= clip.right() { + return right; + } + let left = x - 6.0 - width; + if left >= clip.left() { + return left; } start } @@ -523,6 +521,11 @@ mod tests { assert!(x + 20.0 < 70.0 - 4.0, "the left-hand box must not cover its own marker flag"); assert!(85.0 < x || 85.0 > x + 20.0, "the end cap must remain outside the box"); assert_eq!(name_box_x(20.0, 20.0, Some(35.0), clip), 38.0); + for end in [None, Some(72.0), Some(110.0)] { + let x = name_box_x(90.0, 20.0, end, clip); + assert!(x >= clip.left() && x + 20.0 <= clip.right(), "all right-edge names must remain visible: {end:?}"); + assert!(x + 20.0 < 90.0 - 4.0); + } } #[test] From f3d46b0fa3a1ce9a871802ee1a2d96c83979e498 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Thu, 29 Oct 2026 22:21:27 -0400 Subject: [PATCH 4/5] deps: bump tar, zip, roxmltree, rustls-webpki and symphonia to their latest versions (#2633) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - `roxmltree`: 0.21.1 → 0.21.2 - `zip`: 9.1.0 → 9.1.3 Lockfile only. `symphonia` deliberately stays 0.5.5: effectcraft names it only to enable AIFF in filmcraft-codecs' symphonia, which is 0.5.5 at the pinned filmcraft rev. Moving to 0.6 waits for that pin to move. --- Cargo.lock | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 629fc65e9..5f4551c19 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4292,7 +4292,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7d8fae84b431384b68627d0f9b3b1245fcf9f46f6c0e3dc902e9dce64edd1967" dependencies = [ "libc", - "windows-sys 0.45.0", + "windows-sys 0.61.2", ] [[package]] @@ -4884,9 +4884,9 @@ dependencies = [ [[package]] name = "roxmltree" -version = "0.21.1" +version = "0.21.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f1964b10c76125c36f8afe190065a4bf9a87bf324842c05701330bba9f1cacbb" +checksum = "9da7f59328b09405b181fee1018925fad50f157afb43c0c0f5c522c256dd5907" dependencies = [ "memchr", ] @@ -7469,9 +7469,9 @@ dependencies = [ [[package]] name = "zip" -version = "9.1.0" +version = "9.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2886b00003a72c4ccee3f42cfc34908d33b2508a74d827dc2a4244706475c704" +checksum = "1156b39d142a34b999c2310a50455e0cb3760bf7efad0122f796d813bee26ccb" dependencies = [ "crc32fast", "flate2", From 36e18cfe6addf8d465a0bf5602d2b1ba1b301325 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Thu, 29 Oct 2026 22:25:26 -0400 Subject: [PATCH 5/5] CI: workflows only call craft-common-actions at @main (#2625) AGENTS.md (and CLAUDE.md where present) gets a CI workflows section: workflows only call craft-common-actions at @main, with no other jobs; feature and fix PRs don't touch CI and link or open a craft-common-actions issue (a PR only for a small fix that is safe for every app and does not increase CI time); legacy jobs aren't added to and move only as their own task, staying until a shared workflow runs them. windows-arm64.yml moves from its SHA pin to @main. The pinned revision is on craft-common-actions main and the workflow (and the actions it calls) is identical there, so nothing changes in behaviour. --- .github/workflows/windows-arm64.yml | 4 ++-- AGENTS.md | 5 +++++ CLAUDE.md | 5 +++++ 3 files changed, 12 insertions(+), 2 deletions(-) diff --git a/.github/workflows/windows-arm64.yml b/.github/workflows/windows-arm64.yml index 2a8144a5b..2cedc1a57 100644 --- a/.github/workflows/windows-arm64.yml +++ b/.github/workflows/windows-arm64.yml @@ -28,8 +28,8 @@ permissions: jobs: windows-arm64: - # Immutable supporting workflow revision until the new install contract lands upstream. - uses: storytold/craft-common-actions/.github/workflows/windows-arm64.yml@fa5ef18780cf5e7d5d3b8de068de56cffc83cac5 + # Intentionally tracked at @main (not SHA-pinned) so org-wide CI improvements, security fixes, and build optimizations propagate without PR churn across all craftapp repos. + uses: storytold/craft-common-actions/.github/workflows/windows-arm64.yml@main with: app-name: effectcraft app-dir: EffectCraft diff --git a/AGENTS.md b/AGENTS.md index 24abee6a1..24287ff9a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -144,3 +144,8 @@ as PhotoCraft does with `cargo xtask corpus`. GitHub profiles). Never hand-edit `contributors.json`. - Maintainers refresh the stats with `python3 ../../craftrules/scripts/contributors.py .` (it also re-verifies who wrote each `people.toml` entry; `--check` only verifies). + +## CI workflows +- Workflows in `.github/workflows/` call the shared workflows in [`storytold/craft-common-actions`](https://github.com/storytold/craft-common-actions) at `@main`, with their inputs and secrets. No other jobs: if no shared workflow does what's needed, it doesn't go here. +- Don't add or change CI in a feature or fix PR. If the existing workflows can't build or test a change, link an existing craft-common-actions issue or PR, or open a new issue, or a PR only for a small fix that is safe for every app and does not increase CI time. +- Some workflow files here still run their own jobs. Don't add to them. Moving a job into craft-common-actions is its own task, never part of another PR, and the job stays here until a shared workflow runs it. diff --git a/CLAUDE.md b/CLAUDE.md index 21f2df36b..7bd0e7422 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -30,3 +30,8 @@ EffectCraft is a clean-room, open-source, pure-Rust motion graphics and visual e - For UI work, **look at the result**. Headless (no window, works while the user is busy on the machine): `cargo run -p effectcraft-ui-egui --example snapshot -- --out ui.png --step '{"method":"engine.execute","params":{"command":"layer.select","params":{"layers":["#2"]}}}'` (steps are control-channel requests; `--script steps.jsonl`; `{"method":"snap","params":{"path":…}}` for intermediate shots). Or drive the live app via the control channel and take `ui.screenshot` (needs the window visible on screen). - After Effects reference: drive AE via ExtendScript (`osascript -e 'tell application "Adobe After Effects 2026" to DoScriptFile "x.jsx"'`) and capture only AE's window (`screencapture -l `); keep captures in `plan/aftereffects/ref/` (gitignored), never commit them, never capture the Home screen. - Parallel agents: separate git worktrees and `CARGO_TARGET_DIR=target/agent-`; keep every `Cargo.toml` valid (the `crates/*` glob). + +## CI workflows +- Workflows in `.github/workflows/` call the shared workflows in [`storytold/craft-common-actions`](https://github.com/storytold/craft-common-actions) at `@main`, with their inputs and secrets. No other jobs: if no shared workflow does what's needed, it doesn't go here. +- Don't add or change CI in a feature or fix PR. If the existing workflows can't build or test a change, link an existing craft-common-actions issue or PR, or open a new issue, or a PR only for a small fix that is safe for every app and does not increase CI time. +- Some workflow files here still run their own jobs. Don't add to them. Moving a job into craft-common-actions is its own task, never part of another PR, and the job stays here until a shared workflow runs it.