diff --git a/README.md b/README.md index 8247345..0791cec 100644 --- a/README.md +++ b/README.md @@ -12,7 +12,8 @@ Current DVD authoring capabilities include: - titleset-aware project editing with drag-and-drop title organisation - chapter seeding from source media plus chapter-targeted menu and end actions -- menu editing with authored scene documents, layers, motion timing support, and still-image composition nodes +- menu editing with authored scene documents, layers, still-image composition nodes, and motion menus with intro/loop timing, audio beds, and timeout actions +- animated button highlights authored as keyframe tracks on a timeline strip and compiled into the disc's subpicture stream - semantic interaction graphs and remote-navigation preview simulation - asset inspection with embedded metadata title surfacing, compatibility explanations, fix-oriented validation, and still-image imports for menu authoring - DVD build planning and execution with diagnostics export and toolchain checks diff --git a/SPEC.md b/SPEC.md index 08b2708..adc2143 100644 --- a/SPEC.md +++ b/SPEC.md @@ -182,6 +182,9 @@ Someone who understands FFmpeg and DVD authoring basics and wants a better front - **Scene-Driven Authoring**: Support for a full scene graph with layers, groups, and non-interactive nodes (Text, Image, Shape, Video). - **Still-Image Authoring Assets**: Imported still-image assets can be used in menu backgrounds and image nodes without being exposed as valid title-source selections. - **Integrated Motion Model**: Menus support an authored timing model (intro, loop, timeout) and background audio from day one. +- **Motion Menu Compilation**: Motion backgrounds compile end-to-end — intro and loop cells trimmed from the background video, an audio bed (authored asset, background audio, or silence), and loop-count/timeout behaviour authored into the disc's navigation commands. +- **Animated Highlights**: Keyframe tracks on button highlight/activate colour and opacity, lowered to timestamped subpicture schedules on DVD. The disc can only change a subpicture bitmap at a keyframe timestamp and holds each value until the next one, so a `linear`/`ease-in`/`ease-out`/`ease-in-out` track is evaluated identically in the editor and the build only for Hold easing (or in 'Honest Preview' — see below); the normal editor preview interpolates continuously between keyframes as a friendlier authoring view, and authoring a non-hold easing on a highlight/activate track of a DVD motion menu raises the `menu.animation-easing-quantised` warning (including on a track the planner ultimately drops as uncompiled — that track additionally gets `menu.animation-node-not-compiled`). +- **Timeline Editing**: A timeline strip under the canvas for scrubbing the motion background, retiming intro/loop regions, and editing keyframes. - **Semantic Interaction**: Explicit focus routing and navigation mapping with remote simulation. - **Advanced Action Model**: Support for stream selection (audio/subtitle) and action sequencing. - **Target-Aware Compilation**: Honest compilation into DVD-safe assets with visible downgrade reporting and 'Honest Preview' diagnostics. @@ -625,6 +628,7 @@ Examples include: - Scene-driven authoring with layers and non-interactive nodes. - Integrated motion model with timing, animation tracks, and background audio. +- Timeline-based editing of intro/loop regions and highlight keyframes against in-canvas video playback. - Interactive hotspots with semantic playback actions. - Theme-driven components (HeroTitleButton, ChapterThumbnailTile). - Target-specific compile variant preview (DVD 4:3/16:9 safe-areas). @@ -654,17 +658,15 @@ Each button should support: The preview is not merely visual. It must also simulate remote-navigation logic so users can detect bad directional mappings. -Motion menus should be treated as a future expansion area rather than a v1 requirement. +Motion menus are implemented as an extension of the menu asset and compilation model rather than as a separate system. Current support includes: -Future support may include: +- motion-video backgrounds trimmed from an imported video asset +- looping menu audio from an authored audio bed, the background video's own audio, or synthesised silence +- clip-based menu intros that transition into looping states, with loop count and timeout actions +- animated button highlights driven by keyframe tracks (highlight and activated-state colour and opacity on DVD) +- motion-aware preview: in-canvas video playback, a timeline strip, and highlight animation sampled from the same tracks the build compiles -- motion-video backgrounds -- animated button states or menu elements where feasible -- looping menu audio -- clip-based menu intros that transition into looping states -- motion-aware preview and timing simulation - -This should be architected as an extension of the menu asset and compilation model rather than as a completely separate system. +Remaining future motion work includes per-button video regions (motion thumbnails) and animated per-state button bitmaps where a target format supports them. The application should plan for future assisted menu generation for common DVD structures. @@ -1177,6 +1179,7 @@ This is a local-first desktop tool. - Builds occur locally. - No account is required. - External tool execution should be constrained to known commands and validated arguments. +- The webview's media access is scoped: at project open, asset import, and relink, the app grants the embedded webview access to exactly the imported assets' source paths — these runtime grants add nothing broader on top of the static asset-protocol scope, which remains confined to `$APPCACHE/**` and `$APPDATA/**` (`apps/spindle/src-tauri/tauri.conf.json`). These grants are cumulative (nothing revokes an earlier path when a project closes, an asset is relinked, or a different project opens in the same process) and persisted across restarts (`tauri_plugin_persisted_scope`, registered in `apps/spindle/src-tauri/src/lib.rs`), so the scope is the union of every path ever granted, not a per-session allowlist. Revoking stale grants and dropping persistence is tracked as a follow-up (issue #129). ### 24.2 User trust @@ -1205,6 +1208,8 @@ Because this app orchestrates binaries, the product should clearly communicate: - chapter editing - chapter seeding from source media - Scene-driven menu system with authored documents and motion support +- motion menu authoring end-to-end: video backgrounds, audio beds, intro and loop segments, loop count and timeout actions +- animated button highlights via keyframe tracks, with a timeline editor for regions, scrubbing, and keyframes - semantic navigation mapping with remote simulation - chapter-targeted menu and title end actions - direct titleset editing with compatibility guidance @@ -1484,7 +1489,7 @@ To keep scope healthy, v1 should avoid overexposing deeper DVD command logic. Recommended v1 scope: -- still menus only +- still and motion menus with explicit intro/loop timing (deeper temporal logic stays out of scope) - one clear action per button - basic end-action routing - no deep VM command authoring UI @@ -1652,7 +1657,7 @@ Any future support in this area should include: ## 30. Future Expansion -- motion menus +- motion button thumbnails: per-button video regions composited into motion backgrounds - autogenerated title, chapter, audio-track, and subtitle-track menus - theme-aware menu generation - smarter bitrate optimisation diff --git a/docs/motion-menus.md b/docs/motion-menus.md index 1da8a49..f83f067 100644 --- a/docs/motion-menus.md +++ b/docs/motion-menus.md @@ -1,15 +1,15 @@ -# Motion Menus and Motion Buttons — Design Document +# Motion Menus and Animated Highlights ## Overview -DVD-Video supports two types of menu presentation: +DVD-Video supports two types of menu presentation, and Spindle now builds both: -1. **Still menus** — a single MPEG-2 still frame displayed indefinitely (current implementation) -2. **Motion menus** — a looping MPEG-2 video clip with multiplexed subpicture highlights +1. **Still menus** — a single MPEG-2 still frame displayed indefinitely +2. **Motion menus** — a looping MPEG-2 video background (optionally preceded by a one-shot intro segment) with an audio bed and multiplexed subpicture highlights -Motion _buttons_ extend this further: instead of a static subpicture overlay, the highlight layer can be a sequence of frames that animates when a button is selected or activated. +On top of motion menus, **animated button highlights** replace the single static subpicture overlay with a keyframed schedule of overlay images, lowered to multiple timestamped `` entries (spumux's DCSQ route — see `docs/dcsq-player-compat.md` for the player-compatibility research that picked this route over a raw DCSQ writer). -This document describes the model, build pipeline changes, and UI additions needed to support both features. +This document describes the implemented model, build pipeline, editor UI, and validation rules, and explicitly records what is _not_ built yet (see [Known gaps](#known-gaps-and-deferred-work)). The implementation lives in `plugins/tauri-plugin-spindle-project` (`src/build/menu_motion.rs`, `src/build/planner/animation.rs`, `src/build/authoring/menu.rs`, `src/models/animation.rs`) and `apps/spindle/src/components/menus/timeline/`. --- @@ -17,279 +17,237 @@ This document describes the model, build pipeline changes, and UI additions need ### Motion Menu Structure -A DVD motion menu is a standard VOB containing: +A DVD motion menu is a standard menu VOB domain containing: -- **Video stream**: a looping MPEG-2 clip (typically 10–30 seconds). The player loops playback using a `post` command that jumps back to the start of the cell. -- **Audio stream** (optional): background music or ambient sound, encoded as AC-3 or LPCM. +- **Video stream**: a looping MPEG-2 clip. The player loops playback via a PGC `` command that jumps back to the loop cell when the cell finishes. +- **Audio stream**: background music or ambient sound. Spindle always encodes AC-3, even when the bed is synthesised silence — dvdauthor requires every cell in a PGC to share an identical stream layout. - **Subpicture stream**: the button highlight overlay, composited by `spumux` with timing synchronised to the video. -The key difference from a still menu is that `dvdauthor` receives a video file rather than a still frame, and the `` element includes a ` jump cell 1; ` command for looping. - -### Motion Button Highlights - -There are two approaches to motion buttons on DVD-Video: - -**Approach 1 — Animated subpicture overlay**: the highlight layer itself changes over time. Standard highlights use a single subpicture image for all frames; animated highlights replace this with a sequence of subpicture images that change at specific timestamps, creating effects like pulsing glow, sliding underline, or colour cycling. `spumux` supports this via multiple `` elements with different `start` and `end` timestamps. - -**Approach 2 — Static overlay on localised video**: the button region contains encoded video (e.g., a looping thumbnail preview, animated icon, or visual effect) composited into the menu's background video stream at the button's bounding rectangle. The subpicture highlight overlay remains a standard static 4-colour image drawn on top. This is the more common technique in commercial DVDs — the "motion" comes from the video layer beneath a conventional highlight. - -Both approaches can be combined: a button with localised video underneath _and_ animated highlights on top. The data model supports both independently. +Spindle authors an optional **intro cell** ahead of the loop cell: the same PGC carries two `` elements (intro = cell 1, loop = cell 2), the intro plays once, and the `` loops only the loop cell. The intro cell carries no subpicture stream — buttons appear when the loop cell starts, which is standard commercial-DVD behaviour, and dvdauthor tolerates the missing SPU stream on the intro cell. ### Constraints -- The subpicture overlay is still limited to the 4-colour CLUT palette per display set. -- Total subpicture bitrate must stay within DVD spec limits (~3.36 Mbit/s peak). -- Motion menu video + audio + subpicture must fit within the disc's capacity budget. -- Loop points should be frame-accurate to avoid visible jumps. +- The subpicture overlay is limited to a 4-colour CLUT per display set — one highlight colour for the whole menu at any instant, not one per button. +- Total subpicture bitrate must stay within DVD spec limits (~3.36 Mbit/s peak); every animated-highlight keyframe is a full re-rendered overlay image, so dense schedules are flagged by validation. +- Loop cuts happen at GOP granularity, so perfectly seamless loops are impossible; closed GOPs (`-flags +cgop`) keep the cut clean. --- ## Data Model -### Menu-Level Fields - -```typescript -interface Menu { - // ... existing fields ... - - /** Whether this menu uses a still frame or looping video background. */ - backgroundMode: 'still' | 'motion'; - - /** For motion menus: duration of the loop in seconds. */ - motionDurationSecs: number | null; +All motion and animation state lives on `MenuDocument` (the single authored menu model — see `CLAUDE.md`). The legacy flat fields this document once specified (`Menu.backgroundMode`, `Menu.motionDurationSecs`, `Menu.motionAudioAssetId`, `Menu.motionLoopCount`, `Menu.timeoutAction`, and per-button `highlightMode`/`highlightKeyframes`) are deserialise-only compatibility shims, lifted into the document on load. - /** For motion menus: optional audio asset for background music/sound. */ - motionAudioAssetId: string | null; - - /** For motion menus: number of times to loop before executing the timeout action (0 = infinite). */ - motionLoopCount: number; - - /** Action to execute if the menu times out (motion menus only). */ - timeoutAction: PlaybackAction | null; -} -``` - -### Button-Level Fields +### Timing — `MenuDocument.timing` ```typescript -interface MenuButton { - // ... existing fields ... - - /** Whether button highlights are static or animated. */ - highlightMode: 'static' | 'animated'; - - /** - * For animated highlights: keyframes defining highlight appearance at - * specific timestamps within the motion loop. Each keyframe can override - * the button's highlight colour and opacity. - */ - highlightKeyframes: HighlightKeyframe[]; - - /** - * Optional video asset whose content is composited into the menu's - * background video at this button's bounding rectangle. Used for - * motion buttons where the "animation" comes from a localised video - * region (e.g., a looping thumbnail preview) beneath a standard - * static highlight overlay. - * - * Only meaningful when the parent menu's backgroundMode is 'motion'. - * The video is cropped/scaled to fit the button bounds and composited - * into the menu background during the render step. - */ - videoAssetId: string | null; +/** Timing and motion rules for the menu. */ +interface MenuTiming { + introStartSecs: number; + introDurationSecs: number; // 0 = no intro segment + loopStartSecs: number; + loopDurationSecs: number; // <= 0 = unset (motion menus must author one) + loopCount: number; // 0 = infinite + /** Optional audio asset for motion menu background music. */ + audioAssetId: string | null; } +``` -interface HighlightKeyframe { - /** Timestamp within the motion loop (seconds from start). */ - timestampSecs: number; - - /** Override select colour at this keyframe (null = use menu default). */ - selectColour: string | null; +- `MenuDocument.backgroundMode` (`'still' | 'motion'`) selects the presentation; the background video is the scene's background asset (`scene.background.assetId`), not a separate field. +- The intro and loop windows are both source-relative offsets into the background video (`introStartSecs`/`introDurationSecs`, `loopStartSecs`/`loopDurationSecs`). +- The timeout action lives on the interaction graph (`interaction.timeoutAction`), reusing the standard `PlaybackAction` model. - /** Override select opacity at this keyframe (null = use menu default). */ - selectOpacity: number | null; +The Rust twin is `MenuTiming` in `src/models/menu.rs`. - /** Override activate colour at this keyframe (null = use menu default). */ - activateColour: string | null; +### Animation tracks — `MenuDocument.animation` - /** Override activate opacity at this keyframe (null = use menu default). */ - activateOpacity: number | null; +```typescript +interface AnimationTrack { + nodeId: string; // a scene-node id, typically a Button + target: AnimatableProperty; + keyframes: Keyframe[]; } -``` -### Rust Equivalents - -The Rust `models.rs` types mirror the TypeScript interfaces: - -```rust -/// Whether a menu background is a still frame or looping video. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "kebab-case")] -pub enum BackgroundMode { - Still, - Motion, +type AnimatableProperty = + | 'highlight-colour' + | 'highlight-opacity' + | 'activate-colour' // DVD: spumux's "select" (activated/pressed) state colour + | 'activate-opacity' // the activated-state counterpart to activate-colour + | 'opacity' + | 'position'; + +interface Keyframe { + timestampSecs: number; // loop-relative seconds + value: KeyValue; + easing: Easing; // applied to the segment FOLLOWING this keyframe } -impl Default for BackgroundMode { - fn default() -> Self { - Self::Still - } -} +type KeyValue = + | { kind: 'colour'; hex: string } // #rrggbb or #rrggbbaa + | { kind: 'scalar'; value: number } + | { kind: 'point'; x: number; y: number }; + +type Easing = 'linear' | 'hold' | 'ease-in' | 'ease-out' | 'ease-in-out'; ``` -The `Menu` struct gains: +The Rust model and evaluator live in `src/models/animation.rs` (`evaluate_track`, `sample_at_keyframes`); the TypeScript twin is `apps/spindle/src/utils/animation.ts`. The two implementations are pinned bit-for-bit equal by the shared fixture `fixtures/animation-parity.json`, exercised by both a Rust test and a vitest suite. -```rust -pub background_mode: BackgroundMode, -pub motion_duration_secs: Option, -pub motion_audio_asset_id: Option, -pub motion_loop_count: u32, -pub timeout_action: Option, -``` +Evaluator semantics: -The `MenuButton` struct gains: +- An empty track has no value; before the first keyframe the first value is clamped; after the last keyframe the last value is clamped. +- Between two keyframes, the _earlier_ keyframe's easing reshapes `u = (t − t0) / (t1 − t0)`: `linear` lerps, `ease-in` is `u²`, `ease-out` is `1 − (1 − u)²`, `ease-in-out` is the smoothstep `3u² − 2u³`. +- `hold` steps: the segment keeps the earlier keyframe's value with no interpolation. The DCSQ lowering is stepped sampling over the _global_ timestamp union: `build_overlay_keyframe_schedule` evaluates every track at the union of every relevant track's keyframe timestamps and holds the result until the next boundary. At a track's _own_ keyframes every easing yields the keyframe's exact value, but at a timestamp another track contributed, an eased track is evaluated mid-segment and its _interpolated_ value is baked into that overlay frame — only all-`hold` tracks are exact-by-construction at every boundary. +- Colour interpolation is a componentwise sRGB u8 lerp, round-half-up per channel; alpha is carried iff either endpoint has it. -```rust -pub highlight_mode: HighlightMode, -pub highlight_keyframes: Vec, -/// Optional video asset composited into the menu background at this button's bounds. -pub video_asset_id: Option, -``` +`highlight-colour`/`highlight-opacity` and `activate-colour`/`activate-opacity` tracks are lowered to a DVD build today — the highlight pair drives spumux's "highlight" (focused/selected) state, the activate pair drives spumux's "select" (activated/pressed) state; `opacity` and `position` are modelled (and previewed nowhere yet) but draw a validation warning on DVD projects because the subpicture overlay model cannot express them. ---- +### Migration -## Build Pipeline Changes +`MenuDocument::lift_highlight_keyframes` lifts the legacy per-button `highlightKeyframes` arrays into `AnimationTrack`s (one `highlight-colour` track per animated button, plus a `highlight-opacity` track when any keyframe overrode select opacity — and, mirroring that pair, an `activate-colour` track plus an `activate-opacity` track when any keyframe overrode activate opacity — all with `hold` easing), then clears the source arrays. It runs from `SpindleProjectFile::migrate_all_menus` — the idempotent load hook invoked on every IPC entry — so by the time validation or the planner sees a document, tracks are the only place animation lives. New optional fields default cleanly via `#[serde(default)]`, so no schema version bump was needed. -### Still Menu (Current) +--- -1. Render background image → MPEG-2 still via `ffmpeg -loop 1 -t 1 ...` -2. Generate subpicture overlay from button bounds + highlight colours -3. Composite with `spumux` -4. dvdauthor `` with `pause="inf"` +## Build Pipeline -### Motion Menu (New) +### No new job type -1. Transcode background video asset → DVD-compliant MPEG-2 (same as title transcoding) -2. If any buttons have `videoAssetId` set, composite their video clips into the background at the button bounds using FFmpeg's `overlay` filter (scaled/cropped to fit) -3. Optionally transcode audio asset → AC-3 -4. Mux video + audio into a single MPEG-PS -5. Generate subpicture overlay(s): - - **Static highlights**: single `` element (same as still menus) - - **Animated highlights**: multiple `` elements with `start`/`end` timestamps derived from keyframes -6. Composite with `spumux` -7. dvdauthor `` with ` jump cell 1; ` for looping +Motion menus ride the existing `BuildJob::RenderMenu` job: it gains `introCommand: Option>` and `durationSecs: Option` (both `#[serde(default)]`, mirroring `TranscodeTitle`'s `pass1_command` pattern). When `durationSecs` is set, the executor runs the intro command (if any) and then the loop command through the progress-reporting ffmpeg runner; when it is `None`, the still path runs unchanged. The hypothetical `transcodeMotionMenu` job type from the original design was never built — one job per menu keeps the plan shape stable. -Step 2 uses an FFmpeg filter graph like: +### Segment composition (`build/menu_motion.rs`) -``` -ffmpeg -i menu_bg.mpg -i button_video.mpg \ - -filter_complex "[1:v]scale=200:40[btn];[0:v][btn]overlay=260:100" \ - -c:v mpeg2video ... menu_composed.mpg -``` +`plan_motion_segments` resolves the loop segment (always) and the intro segment (only when `introDurationSecs > 0`), then `build_ffmpeg_motion_segment_command` builds **one ffmpeg command per segment** that does trim + scene overlay + audio + encode + DVD mux with no intermediate files: -Multiple button videos are chained with additional overlay filters. +- **Trim**: input-side `-ss {start} -t {dur}` _before_ `-i {bg_video}` — frame-accurate with re-encode; never the `trim` filter. +- **Video**: `fps={fps}`, `scale={active}:{active}:out_color_matrix=bt601`, `pad` into the DVD raster, scene-PNG `overlay=0:0`, `setsar`. +- **Audio bed** — a three-way fallback chain: + 1. the authored bed asset (`timing.audioAssetId`), looped with `-stream_loop -1` and windowed with `atrim=start={off}:duration={dur},asetpts=PTS-STARTPTS,apad`; + 2. the background video's own audio, already time-aligned by the same input-side trim; + 3. synthesised silence (`-f lavfi -i anullsrc=r=48000:cl=stereo`). +- **Bed windows are continuous across intro+loop — for an authored bed**: with an `audioAssetId`, the intro plays bed `[0..introDur)` and the loop plays `[introDur..introDur+loopDur)`, so first-play audio doesn't hiccup at the intro/loop boundary. (On the second and later loop passes the bed restarts at its loop-window offset — an accepted artefact of cell-based looping.) Fallback (2) instead trims the background video's own audio to each segment's _source-relative_ window, so its continuity across the first intro-to-loop transition holds only when the intro window ends where the loop window starts (`introStartSecs + introDurationSecs == loopStartSecs`); non-contiguous windows will jump in audio there, matching their jump in video. +- **AC-3 always**: `-c:a ac3 -b:a 192k -ar 48000` in _both_ cells, silence included — dvdauthor rejects a PGC whose cells have differing stream layouts. +- **Encode**: `mpeg2video`, `-b:v 4000k -maxrate 7000k -bufsize 1835k`, `-g 18` (NTSC) / `-g 12` (PAL), `-flags +cgop` for a clean loop cut. ffmpeg's mpeg2video encoder cannot combine closed GOPs with scene-change-triggered GOP breaks, so scene-cut detection is disabled with `-sc_threshold 1000000000` — the workaround the encoder itself suggests, which also keeps every GOP exactly `-g` frames long. +- **Colour**: `dvd_colour_flags()` (`build/ffmpeg.rs`) tags `-color_primaries/-color_trc/-colorspace` as `smpte170m` (NTSC) or `bt470bg` (PAL), paired with `out_color_matrix=bt601` on the scale filter. _These flags are currently applied to motion composes only — the still-menu and title transcode retrofit is a pending follow-up._ +- **Mux**: `-t {dur} -f dvd -muxrate 10080000`. -### Build Plan Job Types +Outputs: the loop segment writes `{id}_base.mpg` (spumux input), the intro writes directly to the final `{id}_intro.mpg`. -New `BuildJob` variant: +The module deliberately does **not** introduce a `MenuCompiler` trait — a trait with one implementor would have forced still-path rewiring for zero value. `build/menu_motion.rs`'s module doc-comment is the seam: it maps each function to the future trait stage (`compose_background` ↔ `build_ffmpeg_motion_segment_command`; `mux` ↔ the spumux/dvdauthor emission). -```typescript -| { - type: 'transcodeMotionMenu'; - menuId: string; - menuName: string; - videoAssetPath: string; - audioAssetPath: string | null; - outputPath: string; - command: string[]; - label: string; - } -``` +### Subpicture pass -### dvdauthor XML Changes +`spumux` runs on the **loop cell only**: the existing `ComposeMenuHighlights` job takes `{id}_base.mpg` and writes `{id}.mpg`, exactly as for still menus. The intro cell gets no subpicture pass at all. -Still menu PGC (current): +### dvdauthor XML (`build/authoring/menu.rs`) -```xml - - - -``` +`append_menu_pgc` derives everything from the menu document — `MenuPgcSpec` needed no new fields. -Motion menu PGC (new): +Still menu PGC (unchanged): ```xml - - jump cell 1; + ``` -With timeout (plays first title after 3 loops of a 15-second menu = 45 seconds): +Motion menu with intro, loop count K = 3 and a timeout action: ```xml - +
+    g1 = 0;
+  
+ + - g1 = g1 + 1; - if (g1 ge 3) { jump title 1; } - jump cell 1; + g1 = g1 + 1; if (g1 lt 3) { jump cell 2; } g1 = 0; jump title 1;
``` +- Motion ``s carry **no** `pause` attribute. With an intro the loop target is `cell 2`; without one it is `cell 1`. +- `loopCount == 0` lowers to a bare ` jump cell N; ` — infinite loop, no counting. +- The loop counter is **`g1`** — `g0` is already taken by the titleset menu-dispatch mechanism (`build/dvd_navigation.rs`). When counting, `g1 = 0;` is prepended to `
` so re-entering the menu resets the counter.
+- The timeout command comes from the same `PlaybackAction` resolver as button actions, with the same compound-command/semicolon handling.
+- `loopCount > 0` with **no** timeout action degrades to the infinite `jump cell N;` form — a `` must never fall off the end — and `menu.motion-loop-count-without-timeout` warns about the authored mismatch.
+
 ---
 
-## UI Changes
-
-### Menu Editor
-
-1. **Background mode toggle**: right-inspector controls for "Still" vs "Motion" alongside asset-backed background editing.
-2. **Motion settings panel** (always visible in the inspector, disabled when mode = still):
-   - Video asset selector (reuses the background asset dropdown — the selected asset's video stream becomes the loop)
-   - Audio asset selector (optional, for background music)
-   - Loop duration input or derived display
-   - Loop count input (0 = infinite)
-   - Timeout action dropdown (same as end-action: play title, show menu, stop)
-3. **Canvas preview**:
-   - when in motion mode, preserve a visible motion-oriented control surface in the inspector
-   - the centre stage supports 4:3 versus anamorphic 16:9 display simulation for authored review
-   - full moving-video playback in the stage remains a future enhancement
-
-### Button Properties
-
-1. **Highlight mode toggle**: "Static" (default) or "Animated" per button.
-2. **Keyframe editor** (visible when mode = animated):
-   - Timeline bar showing the motion loop duration
-   - Add/remove keyframe markers at specific timestamps
-   - Per-keyframe colour + opacity overrides
-   - Preview swatch strip showing interpolated colours across the timeline
-
-### Validation
-
-- Motion menu must have a video asset assigned (error)
-- Motion menu video asset must have a video stream (error)
-- Motion menu audio asset must have an audio stream (warning if video has no audio and no separate audio assigned)
-- Animated button keyframes must be within the motion duration (error)
-- Animated button keyframes should be ordered by timestamp (auto-sort)
-- Loop count of 0 (infinite) with no timeout action raises a warning (user may get stuck)
-- Button `videoAssetId` on a still menu is ignored with a warning (only applies to motion menus)
-- Button `videoAssetId` asset must have a video stream (error)
-- Button video should have duration ≥ motion loop duration (warning if shorter — will freeze on last frame)
+## Animated Button Highlights (DCSQ lowering)
+
+### Schedule (`build/planner/animation.rs`)
+
+For each menu, the planner builds an `overlayKeyframes` schedule of `OverlayKeyframeSpec` entries (`{ startSecs, endSecs, highlightImagePath, selectImagePath, highlightColour, selectColour }`) carried on `RenderMenu`:
+
+- The **relevant tracks** split into two groups by target: `highlight-colour`/`highlight-opacity` tracks (spumux's "highlight" state) and `activate-colour`/`activate-opacity` tracks (spumux's "select" state), each restricted to keyframes whose `nodeId` is one of the menu's buttons.
+- The schedule's instants are the **union of every relevant track's keyframe timestamps, across both groups**, each individually clamped to `loopDuration − 1 frame` (`frame_duration_secs = 1 / standard.frame_rate()` — the last timestamp a frame can actually start displaying at, since playback wraps back to `0` before a later one would ever be reached), then sorted, deduped, always including `0.0`.
+- At each instant, the highlight-group tracks are sampled with `evaluate_track` and folded onto the menu's default highlight colour/opacity, and the activate-group tracks are sampled and folded onto the default activate colour/opacity; each resulting opacity is baked into its own alpha channel (`#rrggbbaa`).
+- Each frame's `end` is the next instant; the **last** frame's `end` is the full, unclamped `loopDuration` — because its `start` is always at most `loopDuration − 1 frame`, this guarantees at least one frame's worth of length, so a spumux `` can never come out zero-length even for a keyframe authored exactly at `loopDuration`.
+- Per-frame overlay image paths are `{base}_hl_k{i}.png` / `{base}_sel_k{i}.png`; the executor renders one highlight/select PNG pair per frame through the existing Skia overlay renderer (`generate_menu_overlay_images_for_keyframes`). Anti-aliasing stays off for every frame on the normal path — spumux's ≤16-colour palette limit applies to each one. The exception is the `quantize_overlay_palette` developer option: `render_menu_overlay_image_skia_quantized` deliberately renders WITH anti-aliasing and then reduces the result to a 4-entry palette, so AA'd and normal overlays are not byte-comparable across that flag.
+
+**No tracks → byte-identical output.** A menu without relevant tracks gets a single trivial frame reusing today's `{base}_highlight.png`/`{base}_select.png` paths and the menu's default colours, and the spumux XML for that case is pinned by test to be byte-identical to a build with no animation support at all.
+
+### spumux XML (`build/menu.rs::generate_spumux_xml`)
+
+A multi-frame schedule emits one `` per frame, with **identical `