diff --git a/.cursor/rules/live-tuning-ui.mdc b/.cursor/rules/live-tuning-ui.mdc index a795001..c237812 100644 --- a/.cursor/rules/live-tuning-ui.mdc +++ b/.cursor/rules/live-tuning-ui.mdc @@ -11,7 +11,7 @@ The focus-driven tree panel ([cleave/viz/tuning_panel_draw.py](cleave/viz/tuning **Collapsible UI lexicon** (full inventory and recipes: [docs/legacy-plans/collapsable-sections-refactor.md](docs/legacy-plans/collapsable-sections-refactor.md)): - **Expandable section** — header with expand arrow; Left/Right toggles a session-only `expanded` flag; children live in `RowLayout` and are omitted when collapsed. -- **Conditional rows** — sibling rows shown or hidden by a value predicate (e.g. `preset_switching_user_defined`, `hard_cut_enabled`); no arrow or expanded flag. +- **Conditional rows** — sibling rows shown or hidden by a value predicate (e.g. `preset_switching_projectm_trigger`, `hard_cut_enabled`); no arrow or expanded flag. - **Panel anchor** — header looks like an expandable section but content lives in a separate host (timeline strip); registered as `PanelAnchorDef` in `row_sections.py`. **Layout structure cache:** `RowLayout` is rebuilt only when `view_state_structure_signature()` in [cleave/viz/tuning_view_state.py](cleave/viz/tuning_view_state.py) changes. Conditional rows are included or omitted at **build time** via `ConditionalRowsDef` predicates in [cleave/viz/row_sections.py](cleave/viz/row_sections.py), not on every frame. When a new conditional group is gated by a session or view field, add that field to the signature (same pattern as `preset_switching` on layers or `highlight_rolloff_mode` under `render_post_fx`). Omit value-only fields that do not add or remove rows (e.g. `threshold_pct`). Add a `test_structure_signature_invalidates_on_*` test in [tests/cleave/viz/test_view_state_structure.py](tests/cleave/viz/test_view_state_structure.py). Expandable sections already invalidate via their `*_expanded` flags in the signature. @@ -26,7 +26,7 @@ Confirm, save-choice, and unsaved-quit prompts are drawn by [cleave/viz/modal_ov **Parameterized actions:** when an action row needs one or more parameters, keep the panel row as a plain green `ACTION` line (no expand arrow, no child parameter rows). On Enter, chain one `prompt_choice` (or yes/no) modal per parameter, then apply. Example: snap to song markers asks for proximity, then layer scope ([cleave/viz/timeline_snap_controls.py](cleave/viz/timeline_snap_controls.py)). -**Exception — timeline preset:** parameters live as child value rows under an expandable `timeline preset` section (character, crescendo, density, reshuffle, conductor); the green **apply timeline preset** action confirms with Yes/Cancel and lists the staged choices as `labeled_lines` in the modal ([cleave/viz/timeline_preset_controls.py](cleave/viz/timeline_preset_controls.py)). +**Exception — timeline preset:** parameters live as child value rows under an expandable `timeline preset` section (character, crescendo, density, re-populate preset lists, conductor); the green **apply timeline preset** action confirms with Yes/Cancel and lists the staged choices as `labeled_lines` in the modal ([cleave/viz/timeline_preset_controls.py](cleave/viz/timeline_preset_controls.py)). ## Track rows section @@ -52,11 +52,11 @@ Below overlays, **Render: POST FX** (`RowKind.RENDER_POST_FX_HEADER`) is always **Section lock** (overlays, post-FX, timeline, and layer tracks share one mechanism in [cleave/viz/row_semantics.py](cleave/viz/row_semantics.py)): persisted `locked` on `RenderOverlaysConfig` / `RenderPostFxConfig` / `TimelineConfig` and the matching runtimes. **l** on `RENDER_OVERLAYS_HEADER`, `RENDER_POST_FX_HEADER`, `RENDER_TIMELINE_HEADER` (or `TRACK_HEADER`) toggles it and draws a red `LOCK_ICON` on the header. When locked: the header still expands/opens and expandable child headers stay navigable, but value and action children are drawn in `LOCKED` and skipped in navigation (`section_locked(state, desc)` with `row_navigable_when_section_locked` / `row_blocked_by_section_lock`); **Ctrl** enable/disable is refused while solo stays allowed. `section_locked` accepts either a `TuningViewState` (tracks / `render_timeline`) or a `TuningSession` (layers / `timeline`). In preset curation mode, `section_locked` always returns false (lock icon, `LOCKED` coloring, navigation skip, and mutation blocks are all ignored) so layer browse and **f** / **b** / **r** stay available. -Below post-FX, **Render: TIMELINE** (`RowKind.RENDER_TIMELINE_HEADER`) is always present. This row is a **panel anchor** (not an expandable section): the timeline strip is hosted separately ([cleave/viz/timeline_overlay.py](cleave/viz/timeline_overlay.py)), not as `RowLayout` children. Eye semantics match post-FX (no solo in v1). **Ctrl+Right** / **Ctrl+Left** sets `session.timeline.enabled`; **Right** opens the timeline strip without entering the submenu; **Left** closes it. **t** toggles the strip: when closed, opens and enters the submenu on row 0; when open, closes and returns focus to this header. Expand arrow reflects `session.timeline.panel_open` (▼ when open). Disable closes the strip; **Right** / **t** can still open it while disabled (same expand-while-disabled semantics as layer and other render headers). State: `RenderTimelineBlock` / `TimelineRuntime` on session ([cleave/viz/session.py](cleave/viz/session.py)). `enabled` and `locked` persist via config snapshot; staged timeline preset character / crescendo / density / reshuffle / conductor persist under `timeline.preset`; visual limiter knobs persist under `timeline.limiter`; `panel_open` is UI-only. When the strip is open, main-panel children under **Render: TIMELINE** are: song markers (expandable; marker items when expanded), beat / bar grid (expandable; placement snap, bar grid, bar phase), snap cues (expandable; **snap to beats**, **snap to bars**, then **snap to song markers**), timeline cuts (expandable; hard cuts and soft cuts each with enabled/disabled and fade in/out duration when enabled, then **apply soft cuts to cues** and **apply hard cuts to cues**), timeline preset (expandable; character, crescendo, density, reshuffle, conductor, and **apply timeline preset** when expanded), visual limiter (expandable; **Right** enables and expands to threshold and release; **Left** disables and collapses; expand follows `timeline.limiter.enabled`; header value shows enabled/disabled), and reset timeline. **snap to beats** / **snap to bars** are green action rows under snap cues (Enter opens Yes/Cancel). **snap to song markers** is a green action row under snap cues (Enter opens proximity then layer-scope choice modals). **apply soft cuts to cues** / **apply hard cuts to cues** are green action rows under timeline cuts (Enter opens a scope choice modal). When the timeline is locked, those children are blocked and skipped in navigation (expandable headers stay navigable), and the strip stays openable and seekable (Seek Left/Right); Space still toggles transport play/pause (session-only preview), but arm, record, override, number-key visibility, and Ctrl+Space record stay blocked ([cleave/viz/timeline_controls.py](cleave/viz/timeline_controls.py)); preset/snap/phase controllers also refuse while locked. **l** on the timeline header is refused while `timeline.recording` is true. +Below post-FX, **Render: TIMELINE** (`RowKind.RENDER_TIMELINE_HEADER`) is always present. This row is a **panel anchor** (not an expandable section): the timeline strip is hosted separately ([cleave/viz/timeline_overlay.py](cleave/viz/timeline_overlay.py)), not as `RowLayout` children. Eye semantics match post-FX (no solo in v1). **Ctrl+Right** / **Ctrl+Left** sets `session.timeline.enabled`; **Right** opens the timeline strip without entering the submenu; **Left** closes it. **t** toggles the strip: when closed, opens and enters the submenu on row 0; when open, closes and returns focus to this header. Expand arrow reflects `session.timeline.panel_open` (▼ when open). Disable closes the strip; **Right** / **t** can still open it while disabled (same expand-while-disabled semantics as layer and other render headers). State: `RenderTimelineBlock` / `TimelineRuntime` on session ([cleave/viz/session.py](cleave/viz/session.py)). `enabled` and `locked` persist via config snapshot; staged timeline preset character / crescendo / density / re-populate / conductor persist under `timeline.preset`; visual limiter knobs persist under `timeline.limiter`; `panel_open` is UI-only. When the strip is open, main-panel children under **Render: TIMELINE** are: song markers (expandable; marker items when expanded), beat / bar grid (expandable; placement snap, bar grid, bar phase), snap cues (expandable; **snap to beats**, **snap to bars**, then **snap to song markers**), timeline cuts (expandable; hard cuts and soft cuts each with enabled/disabled and fade in/out duration when enabled, then **apply soft cuts to cues** and **apply hard cuts to cues**), timeline preset (expandable; character, crescendo, density, re-populate preset lists, conductor, and **apply timeline preset** when expanded), visual limiter (expandable; **Right** enables and expands to threshold and release; **Left** disables and collapses; expand follows `timeline.limiter.enabled`; header value shows enabled/disabled), and reset timeline. **snap to beats** / **snap to bars** are green action rows under snap cues (Enter opens Yes/Cancel). **snap to song markers** is a green action row under snap cues (Enter opens proximity then layer-scope choice modals). **apply soft cuts to cues** / **apply hard cuts to cues** are green action rows under timeline cuts (Enter opens a scope choice modal). When the timeline is locked, those children are blocked and skipped in navigation (expandable headers stay navigable), and the strip stays openable and seekable (Seek Left/Right); Space still toggles transport play/pause (session-only preview), but arm, record, override, number-key visibility, and Ctrl+Space record stay blocked ([cleave/viz/timeline_controls.py](cleave/viz/timeline_controls.py)); preset/snap/phase controllers also refuse while locked. **l** on the timeline header is refused while `timeline.recording` is true. ## Timeline panel -Separate bottom overlay ([cleave/viz/timeline_overlay.py](cleave/viz/timeline_overlay.py), [cleave/viz/timeline_controls.py](cleave/viz/timeline_controls.py)). Open with **t** (opens strip and enters submenu on row 0) or **Right** on **Render: TIMELINE** (strip only; **Down** enters submenu), including when `timeline.enabled` is false. **Left** on that header or **t** when the strip is open closes it and returns focus to **Render: TIMELINE**. Strip open does not hide the main overlay or route keys until `submenu_focused` (**t** from the main tree sets this immediately; **Down** from the header when the strip is already open). When the strip is open, **Up**/**Down** use one focus ring ([cleave/viz/focus_nav.py](cleave/viz/focus_nav.py)): main navigable rows (ending at **Render: TIMELINE**), then timeline rows 0..N-1, with uniform modulo wrap. **Down** from the last timeline row wraps to **Settings**; **Up** from **Settings** wraps to the last timeline row. **Up** at timeline row 0 returns focus to **Render: TIMELINE** without closing the strip. **Up**/**Down** always route through the main tuning controls; other timeline keys route to the timeline strip when `submenu_focused`. **Esc** or **t** while in the submenu closes the strip and returns focus to **Render: TIMELINE**. Rows follow `layer_z_order`; labels use stem abbreviations (D/B/V/O). Each track owns a lane (`TimelineLane`: explicit `baseline` plus ordered `SlotCue(t, level, blend?, role?, cut?)` transitions in [cleave/timeline.py](cleave/timeline.py)); `level` is required; optional `blend` is held state (inherits the layer static blend when `None`); optional `role` (`bed` / `pulse` / `lead` / `accent`) is an on-transition cast into `preset_root/roles//`. Edits (`punch_lane`, snap, presets, cue authoring) are lane-local. Each row shows a monitor eye beside the label, the cue bar, and a committed-timeline eye at the far right: **left** = monitor/output (`effective_layer_enabled`; gold `OVERRIDE_BG` when stem is in `TimelineRuntime.override_slots` or when recording and the row is armed). Armed rows during record use `record_baseline` + per-slot `record_buffer` toggles only, not committed lane cues. Record start baseline is not drawn on the bar; only transition cues in that slot's `record_buffer` show ticks. **right** = committed lane level at playhead (`timeline_committed_level`; eye alpha follows the level; updates on seek while paused preview leaves left eye on `TimelineRuntime.monitor`). **a** arms a row (`ARMED_BG` red fill, distinct from override eye). **Space** pause snapshots current output into `monitor` and sets `preview_active` (resume clears both); while recording and playing, **Space** stops the take and pauses instead. Num keys **1**-**8** (main row and numpad) toggle layer visibility while paused (preview via `monitor` or override via `override_visible`; adds stem to override when needed), write record buffer when recording (armed only), toggle `override_visible` while playing for stems in `override_slots` only. **Shift+Enter** toggles override on the focused row while playing or paused (manual override via `override_slots` / `override_visible`; clears preview on enter; distinct from main-panel `session.solo_slot`; ignored when recording). Timeline strip uses **Shift+Left** / **Shift+Right** for 2s transport seek (not solo). **`,`** / **`.`** select the previous / next cue on the focused lane (all cues, including offs). First press (or when selection is missing) picks the navigable cue nearest the playhead. **Shift+,** / **Shift+.** nudge the selected on cue's timeline opacity by -/+ 1% (`LEVEL_STEP_SMALL`); **Ctrl+,** / **Ctrl+.** by -/+ 10% (`LEVEL_STEP_LARGE`); floored at 10% (`LEVEL_EDIT_MIN`) so nudging cannot erase the cue; Ctrl wins when both modifiers are held. Timeline opacity multiplies into the layer opacity fader (`fbo.opacity`); the YAML field remains `level`. Generative Apply may still quantise to `LEVEL_QUANTUM`. **b** cycles the selected on cue's blend through inherit (`None`) then `BLEND_MODES` (no-op on offs); **o** cycles its role through `None` then `CUE_ROLES` (no-op on offs; requires cast roles rotation set); **c** cycles cut type through `none` / `hard` / `soft` on any cue. Blend and role are authored for the next on / visible period (like preset switching at on-transition); `canonicalize` strips `blend` and `role` from off cues but keeps `cut`. Cue selection, opacity nudges, and blend/role edits are refused while the timeline is locked or recording. Selection is session-only on `TimelineRuntime.selected_cue_t` (keyed by slot; per-track memory). The selected tick, flash, and badge readout draw only for the focused timeline row; other slots keep their remembered `selected_cue_t` and restore the settled yellow highlight when focus returns (flash restarts only when `,` / `.` changes the cue on that track). **r** start captures WYSIWYG baseline for armed stems, preserves override on unarmed stems, clears preview, unpauses if needed; stop punch-overwrites armed lanes in range (playback keeps running). **Ctrl+Space** starts record the same way; while recording it stops the take and pauses (no preview). `preview_active` / `monitor` / `override_slots` / `override_visible` / `selected_cue_t` are session-only on `TimelineRuntime`. Per-lane state persists under root `timeline.lanes` in YAML (written last in snapshots). The strip draws each lane as a variable-height level bar from `lane_level_breakpoints` (bottom-filled polygons between breakpoints; remainder dark so a level-0 lane still reads as a full-height bed; colour lerps by mean segment level). Hard/soft cut fade groups shape the breakpoint slopes; cue ticks and the bar grid are unchanged. The selected cue's tick draws full row height in `HIGHLIGHT` at `SELECTED_CUE_TICK_WIDTH` (thinner than the flash width, thicker than normal ticks) and live-patches a 3s thick HIGHLIGHT/`ARMED_BG` (yellow/red) blink at `SELECTED_CUE_FLASH_TICK_WIDTH` via `selected_cue_flash_start_ms` (same cadence as arm flash); after the flash expires it settles on the thinner yellow tick; on cues with a role draw a bold single-letter glyph (`B` / `P` / `L` / `A`) at the bottom of the level bar, offset left of off edges and right of on edges (inside the enabled segment) via per-pixel RGB XOR (pygame 2.6 has no `BLEND_XOR`), painted last after bars, ticks, markers, and the playhead (never on off cues); the badge strip shows a selected-cue readout (`[mm:ss] opacity: 100% cut: soft blend: add role: lead` for on cues; `[mm:ss] cut: hard` for offs; LABEL prefixes and VALUE for time/values; `-` for unset blend/role; unset cut displays as `none`). +Separate bottom overlay ([cleave/viz/timeline_overlay.py](cleave/viz/timeline_overlay.py), [cleave/viz/timeline_controls.py](cleave/viz/timeline_controls.py)). Open with **t** (opens strip and enters submenu on row 0) or **Right** on **Render: TIMELINE** (strip only; **Down** enters submenu), including when `timeline.enabled` is false. **Left** on that header or **t** when the strip is open closes it and returns focus to **Render: TIMELINE**. Strip open does not hide the main overlay or route keys until `submenu_focused` (**t** from the main tree sets this immediately; **Down** from the header when the strip is already open). When the strip is open, **Up**/**Down** use one focus ring ([cleave/viz/focus_nav.py](cleave/viz/focus_nav.py)): main navigable rows (ending at **Render: TIMELINE**), then timeline rows 0..N-1, with uniform modulo wrap. **Down** from the last timeline row wraps to **Settings**; **Up** from **Settings** wraps to the last timeline row. **Up** at timeline row 0 returns focus to **Render: TIMELINE** without closing the strip. **Up**/**Down** always route through the main tuning controls; other timeline keys route to the timeline strip when `submenu_focused`. **Esc** or **t** while in the submenu closes the strip and returns focus to **Render: TIMELINE**. Rows follow `layer_z_order`; labels use stem abbreviations (D/B/V/O). Each track owns a lane (`TimelineLane`: explicit `baseline` plus ordered `SlotCue(t, level, blend?, role?, cut?)` transitions in [cleave/timeline.py](cleave/timeline.py)); `level` is required; optional `blend` is held state (inherits the layer static blend when `None`); optional `role` (`bed` / `pulse` / `lead` / `accent`) is an on-transition cast into `preset_root/roles//`. Edits (`punch_lane`, snap, presets, cue authoring) are lane-local. Each row shows a monitor eye beside the label, the cue bar, and a committed-timeline eye at the far right: **left** = monitor/output (`effective_layer_enabled`; gold `OVERRIDE_BG` when stem is in `TimelineRuntime.override_slots` or when recording and the row is armed). Armed rows during record use `record_baseline` + per-slot `record_buffer` toggles only, not committed lane cues. Record start baseline is not drawn on the bar; only transition cues in that slot's `record_buffer` show ticks. **right** = committed lane level at playhead (`timeline_committed_level`; eye alpha follows the level; updates on seek while paused preview leaves left eye on `TimelineRuntime.monitor`). **a** arms a row (`ARMED_BG` red fill, distinct from override eye). **Space** pause snapshots current output into `monitor` and sets `preview_active` (resume clears both); while recording and playing, **Space** stops the take and pauses instead. Num keys **1**-**8** (main row and numpad) toggle layer visibility while paused (preview via `monitor` or override via `override_visible`; adds stem to override when needed), write record buffer when recording (armed only), toggle `override_visible` while playing for stems in `override_slots` only. **Shift+Enter** toggles override on the focused row while playing or paused (manual override via `override_slots` / `override_visible`; clears preview on enter; distinct from main-panel `session.solo_slot`; ignored when recording). Timeline strip uses **Shift+Left** / **Shift+Right** for 2s transport seek (not solo). **`,`** / **`.`** select the previous / next cue on the focused lane (all cues, including offs). First press (or when selection is missing) picks the navigable cue nearest the playhead. **Shift+,** / **Shift+.** nudge the selected on cue's timeline opacity by -/+ 1% (`LEVEL_STEP_SMALL`); **Ctrl+,** / **Ctrl+.** by -/+ 10% (`LEVEL_STEP_LARGE`); floored at 10% (`LEVEL_EDIT_MIN`) so nudging cannot erase the cue; Ctrl wins when both modifiers are held. Timeline opacity multiplies into the layer opacity fader (`fbo.opacity`); the YAML field remains `level`. Generative Apply may still quantise to `LEVEL_QUANTUM`. **b** cycles the selected on cue's blend through inherit (`None`) then `BLEND_MODES` (no-op on offs); **o** cycles its role through `None` then `CUE_ROLES` (no-op on offs; used by populate-from-cue-roles); **c** cycles cut type through `none` / `hard` / `soft` on any cue. Blend and role are authored for the next on / visible period (like preset switching at on-transition); `canonicalize` strips `blend` and `role` from off cues but keeps `cut`. Cue selection, opacity nudges, and blend/role edits are refused while the timeline is locked or recording. Selection is session-only on `TimelineRuntime.selected_cue_t` (keyed by slot; per-track memory). The selected tick, flash, and badge readout draw only for the focused timeline row; other slots keep their remembered `selected_cue_t` and restore the settled yellow highlight when focus returns (flash restarts only when `,` / `.` changes the cue on that track). **r** start captures WYSIWYG baseline for armed stems, preserves override on unarmed stems, clears preview, unpauses if needed; stop punch-overwrites armed lanes in range (playback keeps running). **Ctrl+Space** starts record the same way; while recording it stops the take and pauses (no preview). `preview_active` / `monitor` / `override_slots` / `override_visible` / `selected_cue_t` are session-only on `TimelineRuntime`. Per-lane state persists under root `timeline.lanes` in YAML (written last in snapshots). The strip draws each lane as a variable-height level bar from `lane_level_breakpoints` (bottom-filled polygons between breakpoints; remainder dark so a level-0 lane still reads as a full-height bed; colour lerps by mean segment level). Hard/soft cut fade groups shape the breakpoint slopes; cue ticks and the bar grid are unchanged. The selected cue's tick draws full row height in `HIGHLIGHT` at `SELECTED_CUE_TICK_WIDTH` (thinner than the flash width, thicker than normal ticks) and live-patches a 3s thick HIGHLIGHT/`ARMED_BG` (yellow/red) blink at `SELECTED_CUE_FLASH_TICK_WIDTH` via `selected_cue_flash_start_ms` (same cadence as arm flash); after the flash expires it settles on the thinner yellow tick; on cues with a role draw a bold single-letter glyph (`B` / `P` / `L` / `A`) at the bottom of the level bar, offset left of off edges and right of on edges (inside the enabled segment) via per-pixel RGB XOR (pygame 2.6 has no `BLEND_XOR`), painted last after bars, ticks, markers, and the playhead (never on off cues); the badge strip shows a selected-cue readout (`[mm:ss] opacity: 100% cut: soft blend: add role: lead` for on cues; `[mm:ss] cut: hard` for offs; LABEL prefixes and VALUE for time/values; `-` for unset blend/role; unset cut displays as `none`). ## Header rows section @@ -96,7 +96,7 @@ Colors live in [cleave/viz/theme.py](cleave/viz/theme.py). Label truncation uses | Constant | Use | | --- | --- | -| `HIGHLIGHT` | Focused row text and background tint | +| `HIGHLIGHT` | Focused row text and background tint; active preset list entry text | | `MOVE_MODE` | Z-order move mode text and background tint | | `LOCK_ICON` | Red padlock glyph on locked track and render section headers | | `SOLO_BG` | Solid red background behind the visibility eye when that stem is soloed | @@ -111,7 +111,7 @@ These rows do not split label and value; the implementation is deliberate: - **Settings** header (`SETTINGS_HEADER`): `Settings` prefix in `ACTION`; expand arrow in row value color. - **Active config path** (`CONFIG_HEADER`): path in `ACTION` (or `HIGHLIGHT` when focused); dirty asterisk stays `CONFIG_DIRTY` (row uses `DISABLED` while solo is active). - **Layer management** (`LAYER_MANAGEMENT_ADD`, `LAYER_MANAGEMENT_DELETE`) and timeline actions (`TIMELINE_SNAP_TO_SONG_MARKERS`, `TIMELINE_SNAP_TO_BEATS`, `TIMELINE_SNAP_TO_BARS`, `TIMELINE_APPLY_SOFT_CUTS`, `TIMELINE_APPLY_HARD_CUTS`, presets, reset): entire row in `ACTION` when unfocused, `HIGHLIGHT` when focused (delete uses `DISABLED` when only one layer remains). Focused activatable `RowAffordance.ACTION` rows append a bold return-corner Material Icon after the label. -- **Preset dir / preset filename**: single-color row text plus Material Icons (icon colors above). +- **Preset dir / preset filename**: single-color row text plus Material Icons (icon colors above). The currently playing `TRACK_PRESET_LIST_ITEM` uses `HIGHLIGHT` yellow (via `TrackBlock.active_preset_list_index`, baked at structure build); focus / move / locked / disabled still win. - **Transport**: single-color header row in row value color. - **Panel notifications** (`RowKind.PANEL_NOTIFICATION`): pinned under Transport; timed toast in `HIGHLIGHT`, persistent error in `ERROR_NOTIFICATION`; non-blocking. Attention swipe: accent fill grows across the row with black text, holds ~1s, then slides off leaving accent text on black for 5s (`NOTIFICATION_DURATION_SEC` after the swipe in [cleave/viz/panel_notification.py](cleave/viz/panel_notification.py)). - **Centered modals** ([cleave/viz/modal_overlay.py](cleave/viz/modal_overlay.py)): title/question in `LABEL`; optional `labeled_lines` (`ModalLabeledLine`) draw `label: ` in `LABEL` and the value in `VALUE`, with a blank line between title and settings and between settings and options; options use `HIGHLIGHT` when focused else `VALUE`. Focus background tint stays `HIGHLIGHT`. diff --git a/.cursor/rules/preset-scan-rotation-set.mdc b/.cursor/rules/preset-scan-rotation-set.mdc index 7c22f67..a745589 100644 --- a/.cursor/rules/preset-scan-rotation-set.mdc +++ b/.cursor/rules/preset-scan-rotation-set.mdc @@ -1,14 +1,15 @@ --- -description: Keep cleave scan preset derivation aligned with preset_switching_rotation_set -globs: cleave/config_schema.py,cleave/viz/preset_switching.py,cleave/preset_scan*.py,cleave/cli.py +description: Keep cleave scan targets aligned with live preset switching lists +globs: + - cleave/preset_scan*.py + - cleave/viz/preset_switching.py + - cleave/config_schema.py alwaysApply: false --- -# Preset scan and rotation set +# Preset scan and switching list -When changing `PresetSwitchingRotationSet`, `PRESET_SWITCHING_ROTATION_SETS`, or how [cleave/viz/preset_switching.py](cleave/viz/preset_switching.py) builds the projectM playlist (`add_path` recurse flag, directory vs user-defined list), also update **scan set derivation** for `cleave scan`. - -Plan: [docs/legacy-plans/presets-scan-plan.md](docs/legacy-plans/presets-scan-plan.md). +When changing how [cleave/viz/preset_switching.py](cleave/viz/preset_switching.py) builds the live switching list, also update **scan set derivation** for `cleave scan`. ## Experimental / low trust @@ -16,18 +17,16 @@ Plan: [docs/legacy-plans/presets-scan-plan.md](docs/legacy-plans/presets-scan-pl ## Current contract -- Mode (`preset_switching`): `none` | `projectm` | `timeline`. -- Rotation set (`preset_switching_rotation_set`): `directory` | `user_defined` (used when mode is `projectm` or `timeline`). -- **directory** live rotation: immediate `*.milk` siblings in `playlist.current_dir`, **non-recursive** (`recurse=False`), then `sort()`. -- **user_defined** live rotation: `preset_switching_presets` via `add_presets(..., allow_duplicates=True)` (no sort). -- Project scan includes the anchor directory for **every** layer (even `preset_switching: none` or `enabled: false`), plus `preset_switching_presets` when `preset_switching_rotation_set` is `user_defined`, plus non-recursive `*.milk` in each `preset_root/roles//` pool (`bed`, `pulse`, `lead`, `accent`). Role pools are part of the live rotation surface: timeline cues with a `role` cast from those directories via `role_rotations` in [cleave/viz/preset_switching.py](cleave/viz/preset_switching.py), so scan must enumerate the same files. +- Mode (`preset_switching`): `off` | `on`. +- Trigger (`preset_switching_trigger`): `timer` | `projectm` | `timeline`. +- Live switching always uses the explicit ordered `preset_switching_list` (session `preset_list`). +- Project scan includes the anchor directory for **every** layer, plus every path in `preset_switching_list`, plus non-recursive `*.milk` in each `preset_root/roles//` pool (`bed`, `pulse`, `lead`, `accent`). Role pools feed populate-from-role actions; scan should still enumerate them. - Bulk mode uses `--presets-dir` with optional `--recursive`; project mode does not recurse into subfolders. -## When rotation set expands +## When the live list source changes 1. Mirror the new live rule in the scan-target builder (same module family as [cleave/preset_scan.py](cleave/preset_scan.py)). 2. Extend JSON report `layers` / metadata if attribution changes. 3. Add or adjust unit tests for target enumeration (no GL required). -4. Update [docs/legacy-plans/presets-scan-plan.md](docs/legacy-plans/presets-scan-plan.md) scan set section. -Do not let play and scan disagree on which presets are in rotation. +Do not let play and scan disagree on which presets are in the switching surface. diff --git a/cleave/config.py b/cleave/config.py index 0eb4ef4..793a59c 100644 --- a/cleave/config.py +++ b/cleave/config.py @@ -16,7 +16,6 @@ _YAML_DUMP_WIDTH = 2**31 - 1 from cleave.blend_modes import BlendMode -from cleave.cue_roles import CueRole from cleave.effects.constants import clamp_effect_pct from cleave.extract import StemSource from cleave.config_schema import ( @@ -25,12 +24,8 @@ BEAT_SENSITIVITY_MIN, DEFAULT_BEAT_SENSITIVITY, DEFAULT_LAYER_Z_ORDER, - DEFAULT_CAST_ROLES_DEFAULT_ROLE, - DEFAULT_CAST_ROLES_TIMELINE_BEHAVIOUR, DEFAULT_PRESET_SWITCHING, - DEFAULT_PRESET_SWITCHING_ROTATION_SET, - DEFAULT_PRESET_SWITCHING_SHUFFLE, - DEFAULT_PRESET_SWITCHING_SHUFFLE_SALT, + DEFAULT_PRESET_SWITCHING_TRIGGER, DEFAULT_PRESET_DURATION, DEFAULT_SOFT_CUT_DURATION, DEFAULT_HARD_CUT_DURATION, @@ -39,9 +34,8 @@ DEFAULT_EASTER_EGG, DEFAULT_PRESET_START_CLEAN, DEFAULT_PRESET_ROOT, - CastRolesTimelineBehaviour, PresetSwitchingMode, - PresetSwitchingRotationSet, + PresetSwitchingTrigger, DEFAULT_RENDER_OVERLAY_BACKGROUND_COLOUR, DEFAULT_RENDER_OVERLAY_BACKGROUND_MARGIN, DEFAULT_RENDER_OVERLAY_BACKGROUND_OPACITY, @@ -72,6 +66,7 @@ DEFAULT_TIMELINE_PLACEMENT_SNAP, DEFAULT_VISUAL_LIMITER_ENABLED, DEFAULT_VISUAL_LIMITER_THRESHOLD, + DEFAULT_VISUAL_LIMITER_RATIO, DEFAULT_VISUAL_LIMITER_RELEASE, TimelinePlacementSnap, DEFAULT_HDR_COMPOSITING, @@ -128,7 +123,10 @@ DEFAULT_TIMELINE_PRESET_DENSITY, TimelinePresetDensity, ) -from cleave.timeline_presets.reshuffle import DEFAULT_TIMELINE_PRESET_RESHUFFLE +from cleave.timeline_presets.repopulate import ( + DEFAULT_TIMELINE_PRESET_REPOPULATE, + TimelinePresetRepopulate, +) from cleave.timeline_presets.song_marker_snap import ( DEFAULT_TIMELINE_PRESET_SONG_MARKER_SNAP, TimelinePresetSongMarkerSnap, @@ -158,13 +156,7 @@ class LayerConfig: blend_mode: BlendMode = "black-key" locked: bool = False preset_switching: PresetSwitchingMode = DEFAULT_PRESET_SWITCHING - preset_switching_rotation_set: PresetSwitchingRotationSet = DEFAULT_PRESET_SWITCHING_ROTATION_SET - cast_roles_timeline_behaviour: CastRolesTimelineBehaviour = ( - DEFAULT_CAST_ROLES_TIMELINE_BEHAVIOUR - ) - cast_roles_default_role: CueRole = DEFAULT_CAST_ROLES_DEFAULT_ROLE - preset_switching_shuffle: bool = DEFAULT_PRESET_SWITCHING_SHUFFLE - preset_switching_shuffle_salt: int = DEFAULT_PRESET_SWITCHING_SHUFFLE_SALT + preset_switching_trigger: PresetSwitchingTrigger = DEFAULT_PRESET_SWITCHING_TRIGGER preset_duration: float = DEFAULT_PRESET_DURATION soft_cut_duration: float = DEFAULT_SOFT_CUT_DURATION hard_cut_duration: float = DEFAULT_HARD_CUT_DURATION @@ -172,7 +164,7 @@ class LayerConfig: hard_cut_enabled: bool = DEFAULT_HARD_CUT_ENABLED easter_egg: float = DEFAULT_EASTER_EGG preset_start_clean: bool = DEFAULT_PRESET_START_CLEAN - preset_switching_presets: list[Path] = field(default_factory=list) + preset_switching_list: list[Path] = field(default_factory=list) @dataclass(frozen=True) @@ -318,7 +310,7 @@ class TimelinePresetConfig: DEFAULT_TIMELINE_PRESET_SONG_MARKER_SNAP ) timeline_cuts: TimelinePresetTimelineCuts = DEFAULT_TIMELINE_PRESET_TIMELINE_CUTS - reshuffle: bool = DEFAULT_TIMELINE_PRESET_RESHUFFLE + repopulate: TimelinePresetRepopulate = DEFAULT_TIMELINE_PRESET_REPOPULATE conductor: bool = DEFAULT_TIMELINE_PRESET_CONDUCTOR @@ -328,6 +320,7 @@ class TimelineLimiterConfig: enabled: bool = DEFAULT_VISUAL_LIMITER_ENABLED threshold: float = DEFAULT_VISUAL_LIMITER_THRESHOLD + ratio: float = DEFAULT_VISUAL_LIMITER_RATIO release: float = DEFAULT_VISUAL_LIMITER_RELEASE diff --git a/cleave/config_schema.py b/cleave/config_schema.py index a2ebfae..945a3f7 100644 --- a/cleave/config_schema.py +++ b/cleave/config_schema.py @@ -10,7 +10,7 @@ from typing import Any, Callable, Literal, TypeVar from cleave.blend_modes import BLEND_MODES, BlendMode -from cleave.cue_roles import CUE_ROLE_HELP_BLURB, CUE_ROLES, CueRole +from cleave.cue_roles import CUE_ROLES, CueRole from cleave.cut_types import CUT_TYPES, CutType from cleave.effects.constants import clamp_effect_pct from cleave.effects.registry import validate_effect_entry @@ -21,7 +21,6 @@ TIMELINE_PRESET_KIND_OPTIONS, ) from cleave.timeline_presets.conductor import DEFAULT_TIMELINE_PRESET_CONDUCTOR -from cleave.timeline_presets.reshuffle import DEFAULT_TIMELINE_PRESET_RESHUFFLE from cleave.timeline_presets.crescendo import CrescendoTarget from cleave.timeline_presets.cue_snap import ( DEFAULT_TIMELINE_PRESET_CUE_SNAP, @@ -33,6 +32,11 @@ TIMELINE_PRESET_DENSITY_OPTIONS, TimelinePresetDensity, ) +from cleave.timeline_presets.repopulate import ( + DEFAULT_TIMELINE_PRESET_REPOPULATE, + TIMELINE_PRESET_REPOPULATE_OPTIONS, + TimelinePresetRepopulate, +) from cleave.timeline_presets.song_marker_snap import ( DEFAULT_TIMELINE_PRESET_SONG_MARKER_SNAP, TIMELINE_PRESET_SONG_MARKER_SNAP_OPTIONS, @@ -58,66 +62,39 @@ BEAT_SENSITIVITY_MIN = 0.0 BEAT_SENSITIVITY_MAX = 5.0 -PresetSwitchingMode = Literal["none", "projectm", "timeline"] -PresetSwitchingRotationSet = Literal["directory", "user_defined", "cast_roles"] -CastRolesTimelineBehaviour = Literal["hold_current", "on_transition"] -PRESET_SWITCHING_MODES: tuple[PresetSwitchingMode, ...] = ( - "none", - "projectm", - "timeline", -) +PresetSwitchingMode = Literal["off", "on"] +PresetSwitchingTrigger = Literal["timer", "projectm", "timeline"] +PRESET_SWITCHING_MODES: tuple[PresetSwitchingMode, ...] = ("off", "on") PRESET_SWITCHING_MODE_HELP_ENTRIES: tuple[tuple[PresetSwitchingMode, str], ...] = ( - ("none", "keeps the current preset indefinitely."), - ("projectm", "libprojectM switches automatically using beat detection."), + ("off", "keeps the current browse preset; no automatic switching."), ( - "timeline", - "advances the preset on each committed timeline on-transition for this layer.", + "on", + "advances through the layer preset list (timer, projectM, or timeline).", ), ) -PRESET_SWITCHING_ROTATION_SETS: tuple[PresetSwitchingRotationSet, ...] = ( - "directory", - "user_defined", - "cast_roles", +PRESET_SWITCHING_TRIGGERS: tuple[PresetSwitchingTrigger, ...] = ( + "timer", + "projectm", + "timeline", ) -PRESET_SWITCHING_ROTATION_SET_HELP_ENTRIES: tuple[ - tuple[PresetSwitchingRotationSet, str], ... +PRESET_SWITCHING_TRIGGER_HELP_ENTRIES: tuple[ + tuple[PresetSwitchingTrigger, str], ... ] = ( - ("directory", "rotate through Milkdrop presets in the current browse directory."), ( - "user_defined", - "rotate through a fixed list of presets defined in config.", + "timer", + "advance by playhead / duration.", ), ( - "cast_roles", - "rotate through Milkdrop presets in roles// pools keyed by cue cast.", - ), -) -CAST_ROLES_TIMELINE_BEHAVIOURS: tuple[CastRolesTimelineBehaviour, ...] = ( - "hold_current", - "on_transition", -) -CAST_ROLES_TIMELINE_BEHAVIOUR_HELP_ENTRIES: tuple[ - tuple[CastRolesTimelineBehaviour, str], ... -] = ( - ( - "hold_current", - "only change preset when a new cue cast marker is encountered.", + "projectm", + "feed the list to libprojectM for its own timing and hard cuts.", ), ( - "on_transition", - "change to a new preset in the last specified role.", + "timeline", + "advance on each timeline on-transition (requires Render: TIMELINE).", ), ) -CAST_ROLES_DEFAULT_ROLE_HELP_ENTRIES: tuple[tuple[CueRole, str], ...] = tuple( - (role, f"{role} ({CUE_ROLE_HELP_BLURB[role]}).") - for role in CUE_ROLES -) -DEFAULT_PRESET_SWITCHING: PresetSwitchingMode = "none" -DEFAULT_PRESET_SWITCHING_ROTATION_SET: PresetSwitchingRotationSet = "directory" -DEFAULT_CAST_ROLES_TIMELINE_BEHAVIOUR: CastRolesTimelineBehaviour = "on_transition" -DEFAULT_CAST_ROLES_DEFAULT_ROLE: CueRole = "bed" -DEFAULT_PRESET_SWITCHING_SHUFFLE = False -DEFAULT_PRESET_SWITCHING_SHUFFLE_SALT = 0 +DEFAULT_PRESET_SWITCHING: PresetSwitchingMode = "off" +DEFAULT_PRESET_SWITCHING_TRIGGER: PresetSwitchingTrigger = "timer" DEFAULT_PRESET_DURATION = 30.0 DEFAULT_SOFT_CUT_DURATION = 0.0 DEFAULT_HARD_CUT_DURATION = 20.0 @@ -127,7 +104,7 @@ EASTER_EGG_MIN = 0.1 EASTER_EGG_MAX = 5.0 DEFAULT_PRESET_START_CLEAN = False -DEFAULT_PRESET_SWITCHING_PRESETS: list[str] = [] +DEFAULT_PRESET_SWITCHING_LIST: list[str] = [] EditorPreviewQuality = Literal[ "full-quality", "balanced", "performance", "ultra-performance" @@ -203,10 +180,8 @@ def new_layer_config(slot: str, preset: Path, preset_root: Path) -> Any: blend_mode=DEFAULT_BLEND_MODE[DEFAULT_NEW_LAYER_STEM], locked=False, preset_switching=DEFAULT_PRESET_SWITCHING, - preset_switching_rotation_set=DEFAULT_PRESET_SWITCHING_ROTATION_SET, - cast_roles_timeline_behaviour=DEFAULT_CAST_ROLES_TIMELINE_BEHAVIOUR, - cast_roles_default_role=DEFAULT_CAST_ROLES_DEFAULT_ROLE, - preset_switching_presets=[], + preset_switching_trigger=DEFAULT_PRESET_SWITCHING_TRIGGER, + preset_switching_list=[], ) DEFAULT_BLEND_MODE: dict[StemSource, BlendMode] = { @@ -405,16 +380,17 @@ def new_layer_config(slot: str, preset: Path, preset_root: Path) -> Any: # Visual limiter (timeline.limiter); module defaults match these. DEFAULT_VISUAL_LIMITER_ENABLED = True DEFAULT_VISUAL_LIMITER_THRESHOLD = 0.65 +DEFAULT_VISUAL_LIMITER_RATIO = 3.0 DEFAULT_VISUAL_LIMITER_RELEASE = 0.45 -VISUAL_LIMITER_THRESHOLD_MIN = 0.40 -VISUAL_LIMITER_THRESHOLD_MAX = 0.90 +VISUAL_LIMITER_THRESHOLD_MIN = 0.30 +VISUAL_LIMITER_THRESHOLD_MAX = 0.95 VISUAL_LIMITER_THRESHOLD_STEP = 0.01 -VISUAL_LIMITER_THRESHOLD_GAP = 0.17 -VISUAL_LIMITER_RELEASE_MIN = 0.15 -VISUAL_LIMITER_RELEASE_MAX = 1.5 +VISUAL_LIMITER_RATIO_MIN = 1.5 +VISUAL_LIMITER_RATIO_MAX = 8.0 +VISUAL_LIMITER_RATIO_STEP = 0.5 +VISUAL_LIMITER_RELEASE_MIN = 0.2 +VISUAL_LIMITER_RELEASE_MAX = 3.0 VISUAL_LIMITER_RELEASE_STEP = 0.1 -# RELEASE_SEC / RELEASE_RAMP_SEC at the tuned defaults (0.75 / 0.45). -VISUAL_LIMITER_RELEASE_HOLD_RATIO = 0.75 / 0.45 def clamp_visual_limiter_threshold(value: float) -> float: @@ -424,20 +400,19 @@ def clamp_visual_limiter_threshold(value: float) -> float: ) +def clamp_visual_limiter_ratio(value: float) -> float: + return max( + VISUAL_LIMITER_RATIO_MIN, + min(VISUAL_LIMITER_RATIO_MAX, float(value)), + ) + + def clamp_visual_limiter_release(value: float) -> float: return max( VISUAL_LIMITER_RELEASE_MIN, min(VISUAL_LIMITER_RELEASE_MAX, float(value)), ) - -def visual_limiter_threshold_off(threshold_on: float) -> float: - return max(0.0, float(threshold_on) - VISUAL_LIMITER_THRESHOLD_GAP) - - -def visual_limiter_release_hold_sec(release_ramp_sec: float) -> float: - return float(release_ramp_sec) * VISUAL_LIMITER_RELEASE_HOLD_RATIO - TimelinePlacementSnap = Literal["off", "beat", "bar"] TIMELINE_PLACEMENT_SNAP_OPTIONS: tuple[TimelinePlacementSnap, ...] = ( "off", @@ -541,10 +516,14 @@ def parse_timeline_preset_timeline_cuts( return value # type: ignore[return-value] -def parse_timeline_preset_reshuffle(raw: Any, label: str) -> bool: - if not isinstance(raw, bool): - raise ValueError(f"{label} must be true or false") - return raw +def parse_timeline_preset_repopulate( + raw: Any, label: str +) -> TimelinePresetRepopulate: + value = str(raw) + if value not in TIMELINE_PRESET_REPOPULATE_OPTIONS: + allowed = ", ".join(TIMELINE_PRESET_REPOPULATE_OPTIONS) + raise ValueError(f"{label} must be one of: {allowed}") + return value # type: ignore[return-value] def parse_timeline_preset_conductor(raw: Any, label: str) -> bool: @@ -570,25 +549,17 @@ def _parse_preset_switching(raw: Any, label: str) -> PresetSwitchingMode: if mode not in PRESET_SWITCHING_MODES: allowed = ", ".join(PRESET_SWITCHING_MODES) raise ValueError(f"{label} must be one of: {allowed}") - return mode + return mode # type: ignore[return-value] -def _parse_preset_switching_rotation_set(raw: Any, label: str) -> PresetSwitchingRotationSet: - rotation_set = str(raw) - if rotation_set not in PRESET_SWITCHING_ROTATION_SETS: - allowed = ", ".join(PRESET_SWITCHING_ROTATION_SETS) - raise ValueError(f"{label} must be one of: {allowed}") - return rotation_set - - -def _parse_cast_roles_timeline_behaviour( +def _parse_preset_switching_trigger( raw: Any, label: str -) -> CastRolesTimelineBehaviour: - behaviour = str(raw) - if behaviour not in CAST_ROLES_TIMELINE_BEHAVIOURS: - allowed = ", ".join(CAST_ROLES_TIMELINE_BEHAVIOURS) +) -> PresetSwitchingTrigger: + trigger = str(raw) + if trigger not in PRESET_SWITCHING_TRIGGERS: + allowed = ", ".join(PRESET_SWITCHING_TRIGGERS) raise ValueError(f"{label} must be one of: {allowed}") - return behaviour # type: ignore[return-value] + return trigger # type: ignore[return-value] def hard_cut_enabled_display(enabled: bool) -> str: @@ -609,10 +580,6 @@ def cycle_timeline_crossfade(value: bool, *, forward: bool) -> bool: return options[(index + delta) % len(options)] -def preset_switching_shuffle_display(enabled: bool) -> str: - return "on" if enabled else "off" - - def clamp_highlight_rolloff_threshold_pct(value: int) -> int: return max( HIGHLIGHT_ROLLOFF_THRESHOLD_PCT_MIN, @@ -713,33 +680,15 @@ def clamp_easter_egg(value: float) -> float: def preset_switching_display(mode: PresetSwitchingMode) -> str: - if mode == "projectm": - return "projectM" - if mode == "timeline": - return "timeline" - return "none" - + return "on" if mode == "on" else "off" -def preset_switching_rotation_set_display( - rotation_set: PresetSwitchingRotationSet, -) -> str: - if rotation_set == "user_defined": - return "user-defined" - if rotation_set == "cast_roles": - return "cast roles" - return "directory" - -def cast_roles_timeline_behaviour_display( - behaviour: CastRolesTimelineBehaviour, -) -> str: - if behaviour == "hold_current": - return "hold current" - return "on transition" - - -def cast_roles_default_role_display(role: CueRole) -> str: - return role +def preset_switching_trigger_display(trigger: PresetSwitchingTrigger) -> str: + if trigger == "projectm": + return "projectM" + if trigger == "timeline": + return "timeline" + return "timer" @dataclass(frozen=True) @@ -858,22 +807,26 @@ def resolve_user_preset(preset: str | Path, cfg_dir: Path) -> Path: def _to_cfg_relative(path: Path, cfg_dir: Path) -> str: - return path.resolve().relative_to(cfg_dir.resolve()).as_posix() + resolved = path.resolve() + try: + return resolved.relative_to(cfg_dir.resolve()).as_posix() + except ValueError: + return resolved.as_posix() -def _parse_preset_switching_presets( +def _parse_preset_switching_list( slot: str, layer_raw: dict[str, Any], ctx: ParseCtx, ) -> list[Path]: - raw = layer_raw.get("preset_switching_presets") + raw = layer_raw.get("preset_switching_list") if raw is None: return [] if not isinstance(raw, list): - raise ValueError(f"layers.{slot}.preset_switching_presets must be a list") + raise ValueError(f"layers.{slot}.preset_switching_list must be a list") if ctx.cfg_dir is None: warnings.warn( - f"layers.{slot}.preset_switching_presets skipped: cfg_dir not set", + f"layers.{slot}.preset_switching_list skipped: cfg_dir not set", stacklevel=2, ) return [] @@ -881,7 +834,7 @@ def _parse_preset_switching_presets( for index, entry in enumerate(raw): if not isinstance(entry, str): raise ValueError( - f"layers.{slot}.preset_switching_presets[{index}] must be a string" + f"layers.{slot}.preset_switching_list[{index}] must be a string" ) presets.append(resolve_user_preset(entry, ctx.cfg_dir)) return presets @@ -2014,33 +1967,11 @@ def parse_layers_section(data: dict[str, Any], ctx: ParseCtx) -> dict[str, Any]: layer_raw.get("preset_switching", DEFAULT_PRESET_SWITCHING), f"layers.{slot}.preset_switching", ) - preset_switching_rotation_set = _parse_preset_switching_rotation_set( - layer_raw.get("preset_switching_rotation_set", DEFAULT_PRESET_SWITCHING_ROTATION_SET), - f"layers.{slot}.preset_switching_rotation_set", - ) - cast_roles_timeline_behaviour = _parse_cast_roles_timeline_behaviour( - layer_raw.get( - "cast_roles_timeline_behaviour", - DEFAULT_CAST_ROLES_TIMELINE_BEHAVIOUR, - ), - f"layers.{slot}.cast_roles_timeline_behaviour", - ) - cast_roles_default_role = validate_cue_role( + preset_switching_trigger = _parse_preset_switching_trigger( layer_raw.get( - "cast_roles_default_role", DEFAULT_CAST_ROLES_DEFAULT_ROLE + "preset_switching_trigger", DEFAULT_PRESET_SWITCHING_TRIGGER ), - path=f"layers.{slot}.cast_roles_default_role", - ) - preset_switching_shuffle = bool( - layer_raw.get( - "preset_switching_shuffle", DEFAULT_PRESET_SWITCHING_SHUFFLE - ) - ) - preset_switching_shuffle_salt = int( - layer_raw.get( - "preset_switching_shuffle_salt", - DEFAULT_PRESET_SWITCHING_SHUFFLE_SALT, - ) + f"layers.{slot}.preset_switching_trigger", ) preset_duration = float( layer_raw.get("preset_duration", DEFAULT_PRESET_DURATION) @@ -2063,9 +1994,7 @@ def parse_layers_section(data: dict[str, Any], ctx: ParseCtx) -> dict[str, Any]: preset_start_clean = bool( layer_raw.get("preset_start_clean", DEFAULT_PRESET_START_CLEAN) ) - preset_switching_presets = _parse_preset_switching_presets( - slot, layer_raw, ctx - ) + preset_switching_list = _parse_preset_switching_list(slot, layer_raw, ctx) layers[slot] = LayerConfig( preset=_resolve_preset(preset_raw, preset_root), stem=stem, @@ -2078,11 +2007,7 @@ def parse_layers_section(data: dict[str, Any], ctx: ParseCtx) -> dict[str, Any]: blend_mode=parse_blend_mode(slot, stem, layer_raw), locked=bool(layer_raw.get("locked", False)), preset_switching=preset_switching, - preset_switching_rotation_set=preset_switching_rotation_set, - cast_roles_timeline_behaviour=cast_roles_timeline_behaviour, - cast_roles_default_role=cast_roles_default_role, - preset_switching_shuffle=preset_switching_shuffle, - preset_switching_shuffle_salt=preset_switching_shuffle_salt, + preset_switching_trigger=preset_switching_trigger, preset_duration=preset_duration, soft_cut_duration=soft_cut_duration, hard_cut_duration=hard_cut_duration, @@ -2090,7 +2015,7 @@ def parse_layers_section(data: dict[str, Any], ctx: ParseCtx) -> dict[str, Any]: hard_cut_enabled=hard_cut_enabled, easter_egg=easter_egg, preset_start_clean=preset_start_clean, - preset_switching_presets=preset_switching_presets, + preset_switching_list=preset_switching_list, ) return layers @@ -2115,11 +2040,7 @@ def persist_layers(ctx: PersistCtx) -> dict[str, dict[str, Any]]: effects = runtime.effects locked = runtime.locked preset_switching = runtime.preset_switching - preset_switching_rotation_set = runtime.preset_switching_rotation_set - cast_roles_timeline_behaviour = runtime.cast_roles_timeline_behaviour - cast_roles_default_role = runtime.cast_roles_default_role - preset_switching_shuffle = runtime.preset_switching_shuffle - preset_switching_shuffle_salt = runtime.preset_switching_shuffle_salt + preset_switching_trigger = runtime.preset_switching_trigger preset_duration = runtime.preset_duration soft_cut_duration = runtime.soft_cut_duration hard_cut_duration = runtime.hard_cut_duration @@ -2127,9 +2048,7 @@ def persist_layers(ctx: PersistCtx) -> dict[str, dict[str, Any]]: hard_cut_enabled = runtime.hard_cut_enabled easter_egg = runtime.easter_egg preset_start_clean = runtime.preset_start_clean - preset_switching_presets = [ - Path(path) for path in runtime.user_presets - ] + preset_switching_list = [Path(path) for path in runtime.preset_list] stem = getattr(runtime, "stem", stem) else: preset = to_config_relative(layer_cfg.preset, preset_root) @@ -2138,11 +2057,7 @@ def persist_layers(ctx: PersistCtx) -> dict[str, dict[str, Any]]: blend_mode = layer_cfg.blend_mode locked = layer_cfg.locked preset_switching = layer_cfg.preset_switching - preset_switching_rotation_set = layer_cfg.preset_switching_rotation_set - cast_roles_timeline_behaviour = layer_cfg.cast_roles_timeline_behaviour - cast_roles_default_role = layer_cfg.cast_roles_default_role - preset_switching_shuffle = layer_cfg.preset_switching_shuffle - preset_switching_shuffle_salt = layer_cfg.preset_switching_shuffle_salt + preset_switching_trigger = layer_cfg.preset_switching_trigger preset_duration = layer_cfg.preset_duration soft_cut_duration = layer_cfg.soft_cut_duration hard_cut_duration = layer_cfg.hard_cut_duration @@ -2150,7 +2065,7 @@ def persist_layers(ctx: PersistCtx) -> dict[str, dict[str, Any]]: hard_cut_enabled = layer_cfg.hard_cut_enabled easter_egg = layer_cfg.easter_egg preset_start_clean = layer_cfg.preset_start_clean - preset_switching_presets = list(layer_cfg.preset_switching_presets) + preset_switching_list = list(layer_cfg.preset_switching_list) effects = layer_cfg.effects beat = ( layer_cfg.beat_sensitivity @@ -2174,16 +2089,8 @@ def persist_layers(ctx: PersistCtx) -> dict[str, dict[str, Any]]: layer_out["effects"] = sparse if preset_switching != DEFAULT_PRESET_SWITCHING: layer_out["preset_switching"] = preset_switching - if preset_switching_rotation_set != DEFAULT_PRESET_SWITCHING_ROTATION_SET: - layer_out["preset_switching_rotation_set"] = preset_switching_rotation_set - if cast_roles_timeline_behaviour != DEFAULT_CAST_ROLES_TIMELINE_BEHAVIOUR: - layer_out["cast_roles_timeline_behaviour"] = cast_roles_timeline_behaviour - if cast_roles_default_role != DEFAULT_CAST_ROLES_DEFAULT_ROLE: - layer_out["cast_roles_default_role"] = cast_roles_default_role - if preset_switching_shuffle != DEFAULT_PRESET_SWITCHING_SHUFFLE: - layer_out["preset_switching_shuffle"] = preset_switching_shuffle - if preset_switching_shuffle_salt != DEFAULT_PRESET_SWITCHING_SHUFFLE_SALT: - layer_out["preset_switching_shuffle_salt"] = preset_switching_shuffle_salt + if preset_switching_trigger != DEFAULT_PRESET_SWITCHING_TRIGGER: + layer_out["preset_switching_trigger"] = preset_switching_trigger if preset_duration != DEFAULT_PRESET_DURATION: layer_out["preset_duration"] = preset_duration if soft_cut_duration != DEFAULT_SOFT_CUT_DURATION: @@ -2198,15 +2105,15 @@ def persist_layers(ctx: PersistCtx) -> dict[str, dict[str, Any]]: layer_out["easter_egg"] = easter_egg if preset_start_clean != DEFAULT_PRESET_START_CLEAN: layer_out["preset_start_clean"] = preset_start_clean - if preset_switching_presets: + if preset_switching_list: if ctx.cfg_dir is None: - layer_out["preset_switching_presets"] = [ - path.as_posix() for path in preset_switching_presets + layer_out["preset_switching_list"] = [ + path.as_posix() for path in preset_switching_list ] else: - layer_out["preset_switching_presets"] = [ + layer_out["preset_switching_list"] = [ _to_cfg_relative(path, ctx.cfg_dir) - for path in preset_switching_presets + for path in preset_switching_list ] layers_out[slot] = layer_out @@ -2450,9 +2357,9 @@ def _parse_timeline_preset(raw: Any) -> Any: ), "timeline.preset.timeline_cuts", ), - reshuffle=parse_timeline_preset_reshuffle( - preset_map.get("reshuffle", DEFAULT_TIMELINE_PRESET_RESHUFFLE), - "timeline.preset.reshuffle", + repopulate=parse_timeline_preset_repopulate( + preset_map.get("repopulate", DEFAULT_TIMELINE_PRESET_REPOPULATE), + "timeline.preset.repopulate", ), conductor=parse_timeline_preset_conductor( preset_map.get("conductor", DEFAULT_TIMELINE_PRESET_CONDUCTOR), @@ -2477,6 +2384,12 @@ def _parse_timeline_limiter(raw: Any) -> Any: "timeline.limiter.threshold", ) ), + ratio=clamp_visual_limiter_ratio( + require_non_negative_number( + limiter_map.get("ratio", DEFAULT_VISUAL_LIMITER_RATIO), + "timeline.limiter.ratio", + ) + ), release=clamp_visual_limiter_release( require_non_negative_number( limiter_map.get("release", DEFAULT_VISUAL_LIMITER_RELEASE), @@ -2620,6 +2533,7 @@ def _persist_timeline_limiter(limiter: Any) -> dict[str, Any]: return { "enabled": limiter.enabled, "threshold": limiter.threshold, + "ratio": limiter.ratio, "release": limiter.release, } @@ -2641,7 +2555,7 @@ def persist_timeline(ctx: PersistCtx) -> dict[str, Any]: "cue_snap": runtime.timeline_preset_cue_snap, "song_marker_snap": runtime.timeline_preset_song_marker_snap, "timeline_cuts": runtime.timeline_preset_timeline_cuts, - "reshuffle": runtime.timeline_preset_reshuffle, + "repopulate": runtime.timeline_preset_repopulate, "conductor": runtime.timeline_preset_conductor, }, "limiter": _persist_timeline_limiter(runtime.limiter), diff --git a/cleave/preset_curation.py b/cleave/preset_curation.py index 0824691..2f55a14 100644 --- a/cleave/preset_curation.py +++ b/cleave/preset_curation.py @@ -96,6 +96,23 @@ def build(cls, preset_root: Path) -> PresetCurationIndex: roles=roles, ) + @property + def curation_stamp(self) -> str: + """Cheap identity for label caches; changes when curation sets mutate.""" + role_pairs = tuple( + sorted( + (name, role) + for name, role_set in self.roles.items() + for role in role_set + ) + ) + payload = ( + tuple(sorted(self.favourites)), + tuple(sorted(self.blacklist)), + role_pairs, + ) + return repr(payload) + def marker(self, name: str, *, user: bool = False) -> str: letters = "" if name in self.favourites: @@ -309,18 +326,18 @@ def _unique_relocate_dest(dest_dir: Path, filename: str) -> Path: def scrub_user_preset_paths(layers: Mapping[str, object], removed: Path) -> list[str]: - """Remove ``removed`` from layer user preset lists; return affected slot names.""" + """Remove ``removed`` from layer preset lists; return affected slot names.""" removed_resolved = removed.resolve() affected: list[str] = [] for slot, layer in layers.items(): - user_presets: list[str] = layer.user_presets # type: ignore[attr-defined] + preset_list: list[str] = layer.preset_list # type: ignore[attr-defined] filtered = [ path - for path in user_presets + for path in preset_list if Path(path).resolve() != removed_resolved ] - if len(filtered) != len(user_presets): - user_presets[:] = filtered + if len(filtered) != len(preset_list): + preset_list[:] = filtered affected.append(slot) return affected @@ -330,16 +347,16 @@ def rewrite_user_preset_paths( old: Path, new: Path, ) -> list[str]: - """Rewrite ``old`` to ``new`` in layer user preset lists; return affected slots.""" + """Rewrite ``old`` to ``new`` in layer preset lists; return affected slots.""" old_resolved = old.resolve() new_str = str(new) affected: list[str] = [] for slot, layer in layers.items(): - user_presets: list[str] = layer.user_presets # type: ignore[attr-defined] + preset_list: list[str] = layer.preset_list # type: ignore[attr-defined] changed = False - for index, path in enumerate(user_presets): + for index, path in enumerate(preset_list): if Path(path).resolve() == old_resolved: - user_presets[index] = new_str + preset_list[index] = new_str changed = True if changed: affected.append(slot) diff --git a/cleave/preset_playlist.py b/cleave/preset_playlist.py index 2aedbb6..6fc6c99 100644 --- a/cleave/preset_playlist.py +++ b/cleave/preset_playlist.py @@ -57,6 +57,11 @@ def _path_at_or_below(path: Path, root: Path) -> bool: return False +def path_under_preset_root(path: Path, preset_root: Path) -> bool: + """True when ``path`` is ``preset_root`` or a descendant.""" + return _path_at_or_below(path, preset_root) + + def preset_browse_floor(anchor: Path, preset_root: Path) -> Path: """Lowest directory this layer may ascend to when browsing presets.""" resolved_root = preset_root.resolve() @@ -179,7 +184,12 @@ def _compute_dir_display_label( *, browse_floor: Path | None = None, ) -> str: - rel = to_config_relative(self.current_dir, preset_root).rstrip("/") + "/" + try: + rel = to_config_relative(self.current_dir, preset_root).rstrip("/") + "/" + except ValueError: + # Project user-presets (and other off-root copies) are not under + # preset_root; show an absolute path instead of crashing the panel. + rel = self.current_dir.resolve().as_posix().rstrip("/") + "/" siblings = list_browse_siblings(self.current_dir, preset_root) marker = directory_tree_marker( self.current_dir, preset_root, browse_floor=browse_floor diff --git a/cleave/preset_rotation.py b/cleave/preset_rotation.py index 8f7ad68..c3aa519 100644 --- a/cleave/preset_rotation.py +++ b/cleave/preset_rotation.py @@ -1,27 +1,20 @@ -"""Deterministic, seek-stable preset rotation for timeline switching.""" +"""Deterministic, seek-stable preset rotation for list-based switching.""" from __future__ import annotations -import hashlib -import random -from collections.abc import Sequence from dataclasses import dataclass from pathlib import Path @dataclass(frozen=True) class PresetRotation: - """Maps a non-negative transition count to a preset path. + """Maps a non-negative index to a preset path. - Non-shuffle wraps ``paths`` with an ``anchor`` offset. - Shuffle concatenates seeded permutations of ``paths`` (one full bag of - length ``n`` per block) so each path appears once before any repeats. - ``path_for(count)`` indexes ``anchor + count`` into that sequence. + Wraps ``paths`` with an ``anchor`` offset so ``path_for(count)`` returns + ``paths[(anchor + count) % n]``. """ paths: tuple[Path, ...] - shuffle: bool - seed: int anchor: int = 0 def __post_init__(self) -> None: @@ -33,59 +26,4 @@ def path_for(self, count: int) -> Path | None: n = len(self.paths) if n == 0: return None - index = self.anchor + int(count) - if not self.shuffle: - return self.paths[index % n] - return self._shuffled_at(index) - - def _shuffled_at(self, index: int) -> Path: - n = len(self.paths) - block, offset = divmod(index, n) - order = list(range(n)) - # Mix seed with block index; keep in 64-bit range for Random.seed. - block_seed = (self.seed + block * 0x9E3779B97F4A7C15) & 0xFFFFFFFFFFFFFFFF - rng = random.Random(block_seed) - rng.shuffle(order) - return self.paths[order[offset]] - - -def rotation_seed(paths: Sequence[Path], *, salt: str = "") -> int: - """Stable seed so live preview and offline render agree.""" - digest = hashlib.sha256() - digest.update(salt.encode("utf-8")) - for path in paths: - digest.update(b"\0") - digest.update(str(path).encode("utf-8")) - return int.from_bytes(digest.digest()[:8], "big") - - -def layer_rotation_seed( - paths: Sequence[Path], - *, - slot: str, - shuffle_salt: int = 0, -) -> int: - """Seed for a layer's shuffled rotation (slot + persisted salt).""" - return rotation_seed(paths, salt=f"{slot}:{int(shuffle_salt)}") - - -def first_shuffle_bag_order( - paths: Sequence[Path], - *, - seed: int, -) -> list[Path]: - """Return the first shuffle-bag order for ``paths`` under ``seed``.""" - if not paths: - return [] - rotation = PresetRotation( - paths=tuple(paths), - shuffle=True, - seed=seed, - anchor=0, - ) - ordered: list[Path] = [] - for i in range(len(paths)): - path = rotation.path_for(i) - if path is not None: - ordered.append(path) - return ordered + return self.paths[(self.anchor + int(count)) % n] diff --git a/cleave/preset_scan_targets.py b/cleave/preset_scan_targets.py index e036c42..13b79ca 100644 --- a/cleave/preset_scan_targets.py +++ b/cleave/preset_scan_targets.py @@ -47,14 +47,13 @@ def build_project_targets(cfg: CleaveConfig) -> ScanTargets: for preset_path in milk_files_in_dir(anchor_dir): _register_preset(by_path, preset_path, slot) - if layer.preset_switching_rotation_set == "user_defined": - for preset_path in layer.preset_switching_presets: - sources.append(preset_path) - _register_preset(by_path, preset_path, slot) + for preset_path in layer.preset_switching_list: + sources.append(preset_path) + _register_preset(by_path, preset_path, slot) layer_sources[slot] = tuple(sources) - # Role pools under preset_root/roles// are part of timeline casting. + # Role pools under preset_root/roles// feed populate-from-role actions. role_slots = tuple(layer_sources) for role in CUE_ROLES: pool = role_pool_paths(preset_root, role) diff --git a/cleave/projectm_health.py b/cleave/projectm_health.py index 49e6173..0d8a58c 100644 --- a/cleave/projectm_health.py +++ b/cleave/projectm_health.py @@ -10,7 +10,7 @@ from cleave.projectm import drain_log_messages from cleave.viz.layer import StemLayer -from cleave.viz.preset_switching import EMPTY_ROTATION_NOTIFICATION +from cleave.viz.preset_switching import EMPTY_PRESET_LIST_NOTIFICATION PRESET_SKIP_NOTIFICATION_INTERVAL_SEC = 10.0 PROJECTM_LOG_NOTIFICATION_PREFIX = "projectM: " @@ -66,7 +66,7 @@ def drain_stem_layers_preset_failures( for failure in failures: if failure.exhausted: if on_notification is not None: - on_notification(EMPTY_ROTATION_NOTIFICATION) + on_notification(EMPTY_PRESET_LIST_NOTIFICATION) continue if on_notification is None: continue diff --git a/cleave/timeline_presets/repopulate.py b/cleave/timeline_presets/repopulate.py new file mode 100644 index 0000000..4afc422 --- /dev/null +++ b/cleave/timeline_presets/repopulate.py @@ -0,0 +1,41 @@ +"""Staged preset-list repopulate mode for timeline preset Apply.""" + +from __future__ import annotations + +from typing import Literal + +TimelinePresetRepopulate = Literal[ + "no", + "cue roles", + "directory random", + "directory sequential", +] + +DEFAULT_TIMELINE_PRESET_REPOPULATE: TimelinePresetRepopulate = "no" + +TIMELINE_PRESET_REPOPULATE_OPTIONS: tuple[TimelinePresetRepopulate, ...] = ( + "no", + "cue roles", + "directory random", + "directory sequential", +) + + +def timeline_preset_repopulate_display( + mode: TimelinePresetRepopulate, +) -> str: + if mode in TIMELINE_PRESET_REPOPULATE_OPTIONS: + return mode + return DEFAULT_TIMELINE_PRESET_REPOPULATE + + +def cycle_timeline_preset_repopulate( + value: TimelinePresetRepopulate, *, forward: bool +) -> TimelinePresetRepopulate: + options = TIMELINE_PRESET_REPOPULATE_OPTIONS + try: + index = options.index(value) + except ValueError: + index = options.index(DEFAULT_TIMELINE_PRESET_REPOPULATE) + delta = 1 if forward else -1 + return options[(index + delta) % len(options)] diff --git a/cleave/timeline_presets/reshuffle.py b/cleave/timeline_presets/reshuffle.py deleted file mode 100644 index 601a2be..0000000 --- a/cleave/timeline_presets/reshuffle.py +++ /dev/null @@ -1,19 +0,0 @@ -"""Staged reshuffle toggle for timeline preset Apply.""" - -from __future__ import annotations - -DEFAULT_TIMELINE_PRESET_RESHUFFLE = False - - -def timeline_preset_reshuffle_display(reshuffle: bool) -> str: - return "on" if reshuffle else "off" - - -def cycle_timeline_preset_reshuffle(value: bool, *, forward: bool) -> bool: - options = (False, True) - try: - index = options.index(bool(value)) - except ValueError: - index = options.index(DEFAULT_TIMELINE_PRESET_RESHUFFLE) - delta = 1 if forward else -1 - return options[(index + delta) % len(options)] diff --git a/cleave/viz/controls.py b/cleave/viz/controls.py index 2a8e816..fbed49b 100644 --- a/cleave/viz/controls.py +++ b/cleave/viz/controls.py @@ -12,24 +12,22 @@ from cleave.config import CleaveConfig, clamp_beat_sensitivity, clamp_effect_pct from cleave.config_schema import clamp_easter_egg from cleave.config_schema import ( - CAST_ROLES_TIMELINE_BEHAVIOURS, PRESET_SWITCHING_MODES, - PRESET_SWITCHING_ROTATION_SETS, + PRESET_SWITCHING_TRIGGERS, ) from cleave.blend_modes import BLEND_MODES, BlendMode -from cleave.cue_roles import CUE_ROLES, role_pool_paths from cleave.extract import STEM_SOURCES from cleave.signals import Signals from cleave.song_markers import format_marker_time, place_marker from cleave.preset_curation import PresetCurationIndex -from cleave.preset_playlist import is_top_level_browse_dir +from cleave.preset_playlist import is_top_level_browse_dir, milk_files_in_dir from cleave.timeline import snap_placement_time from cleave.viz.config_save import ConfigSaveController from cleave.viz.editor_mode_controls import EditorModeController, is_preset_curation_mode from cleave.viz.post_fx import sync_live_compositor_format from cleave.viz.preset_curation_controls import PresetCurationController from cleave.viz.key_repeat import KeyRepeatController, add_current_preset_key_pressed, delete_key_pressed, mod_ctrl, mod_shift -from cleave.viz.modal import ModalHost +from cleave.viz.modal import ModalHost, ModalOption from cleave.viz.panel_notification import PanelNotificationHost from cleave.viz.playback import PlaybackState, current_sec, seek, seek_to, toggle_pause from cleave.viz.live_layer_bindings import LiveLayerBindings @@ -39,13 +37,19 @@ from cleave.viz.settings_controls import SettingsControls from cleave.viz.tap_sync_controls import TapSyncControls, TapSyncUiSnapshot from cleave.viz.timeline_phase_controls import TimelinePhaseController -from cleave.viz.preset_seed_controls import PresetSeedController from cleave.viz.timeline_preset_controls import TimelinePresetController +from cleave.viz.preset_list_populate import ( + needed_preset_count, + on_segment_populate_count, + populate_from_cue_marker_roles, + populate_from_directory, + repopulate_preset_lists, +) from cleave.viz.timeline_cut_controls import TimelineCutController from cleave.viz.timeline_snap_controls import TimelineSnapController from cleave.viz.user_presets import ( resolve_user_preset_dest, - user_preset_item_display_name, + preset_list_item_display_name, user_preset_referenced_on_disk, ) from cleave.viz.focus_nav import ( @@ -81,8 +85,8 @@ NOTIFICATION_TIMELINE_ENABLED_TEXT = "Timeline controls layer visibility" NOTIFICATION_TIMELINE_DISABLED_TEXT = "Layer panel controls visibility" -NOTIFICATION_TIMELINE_PRESET_SWITCHING_TEXT = ( - "Enable timeline for timeline preset switching" +NOTIFICATION_TIMELINE_TRIGGER_DISABLED_TEXT = ( + "Timeline is disabled; enable Render: TIMELINE to switch presets" ) NOTIFICATION_RESIDUAL_LATENCY_UNCHANGED_TEXT = ( "Existing marker and cue times unchanged" @@ -138,6 +142,8 @@ def __init__( ) self.move_mode_slot: str | None = None self._move_mode_original_z_order: list[str] | None = None + self.move_mode_preset: tuple[str, int] | None = None + self._move_mode_original_preset_list: list[str] | None = None self._notification_host = PanelNotificationHost() self._key_repeat = KeyRepeatController() self._hide_overlay_requested = False @@ -168,18 +174,6 @@ def __init__( layers_by_slot = ( layer_manager.layers_by_slot if layer_manager is not None else {} ) - on_switching = ( - None - if layer_bindings is None - else layer_bindings.on_preset_switching_change - ) - self._preset_seed = PresetSeedController( - session, - self._modal_host, - layers_by_slot, - preset_root=preset_root, - on_preset_switching_change=on_switching, - ) self._timeline_presets = TimelinePresetController( session, self._modal_host, @@ -187,7 +181,7 @@ def __init__( bar_times, signals=signals, on_notification=self.show_notification, - on_reshuffle=self._preset_seed.reroll_all, + on_repopulate=self._repopulate_preset_lists, ) self._timeline_phase = TimelinePhaseController( session, @@ -214,6 +208,7 @@ def __init__( curation_index, get_focus_cursor=lambda: self.focus_cursor, get_move_mode_slot=lambda: self.move_mode_slot, + get_move_mode_preset=lambda: self.move_mode_preset, config_save=self._config_save, get_notification=self._notification_host.active, layers_by_slot=layers_by_slot, @@ -371,7 +366,7 @@ def handle_keydown(self, event: pygame.event.Event) -> bool: return True if event.key == pygame.K_t: - if self.move_mode_slot is not None: + if self.move_mode_slot is not None or self.move_mode_preset is not None: return True tl = self.session.timeline if tl.panel_open: @@ -380,15 +375,21 @@ def handle_keydown(self, event: pygame.event.Event) -> bool: self._open_timeline_panel(enter_submenu=True) return True - if self.move_mode_slot is not None: + if self.move_mode_slot is not None or self.move_mode_preset is not None: if event.key in (pygame.K_ESCAPE, pygame.K_BACKSPACE): self._cancel_move_mode() return True if event.key == pygame.K_UP: - self._swap_stem_in_z_order(self.move_mode_slot, -1) + if self.move_mode_preset is not None: + self._swap_preset_list_item(-1) + else: + self._swap_stem_in_z_order(self.move_mode_slot, -1) return True if event.key == pygame.K_DOWN: - self._swap_stem_in_z_order(self.move_mode_slot, 1) + if self.move_mode_preset is not None: + self._swap_preset_list_item(1) + else: + self._swap_stem_in_z_order(self.move_mode_slot, 1) return True if event.key == pygame.K_m: self._confirm_move_mode() @@ -459,7 +460,7 @@ def handle_keydown(self, event: pygame.event.Event) -> bool: return True self._delete_song_marker(desc.marker_index) return True - if kind == RowKind.TRACK_USER_PRESET_ITEM: + if kind == RowKind.TRACK_PRESET_LIST_ITEM: slot = self.focus_descriptor.slot desc = self.focus_descriptor if slot is not None and desc.preset_index is not None: @@ -467,7 +468,7 @@ def handle_keydown(self, event: pygame.event.Event) -> bool: self.session, self.focus_descriptor ): return True - self._delete_user_preset(slot, desc.preset_index) + self._delete_preset_list_item(slot, desc.preset_index) return True if row_triggers_layer_delete(kind): slot = self.focus_descriptor.slot @@ -490,6 +491,16 @@ def handle_keydown(self, event: pygame.event.Event) -> bool: ) self.move_mode_slot = slot return True + if kind == RowKind.TRACK_PRESET_LIST_ITEM: + slot = self.focus_descriptor.slot + index = self.focus_descriptor.preset_index + if slot is not None and index is not None: + if section_lock_blocks_mutation( + self.session, self.focus_descriptor + ): + return True + self._enter_preset_list_move_mode(slot, index) + return True if event.key == pygame.K_l: kind = self.focus_descriptor.kind @@ -513,9 +524,8 @@ def handle_keydown(self, event: pygame.event.Event) -> bool: slot = self.focus_descriptor.slot if ( slot is not None - and kind in {RowKind.TRACK_PRESET_DIR, RowKind.TRACK_PRESET} - and self.session.layers[slot].preset_switching_rotation_set - == "user_defined" + and self.session.layers[slot].preset_switching == "on" + and row_behavior(kind).parent_group == "track" ): if section_lock_blocks_mutation( self.session, self.focus_descriptor @@ -541,7 +551,7 @@ def handle_keydown(self, event: pygame.event.Event) -> bool: self._preset_curation.prompt_blacklist( slot, src, - from_user_preset=(kind == RowKind.TRACK_USER_PRESET_ITEM), + from_user_preset=(kind == RowKind.TRACK_PRESET_LIST_ITEM), user_preset_index=self.focus_descriptor.preset_index, ) elif event.key == pygame.K_c: @@ -569,7 +579,7 @@ def handle_keydown(self, event: pygame.event.Event) -> bool: if 0 <= desc.marker_index < len(times): self.seek_to(times[desc.marker_index]) return True - if kind == RowKind.TRACK_USER_PRESET_ADD: + if kind == RowKind.TRACK_PRESET_LIST_ADD: slot = self.focus_descriptor.slot if slot is not None: if section_lock_blocks_mutation( @@ -578,6 +588,15 @@ def handle_keydown(self, event: pygame.event.Event) -> bool: return True self._add_current_preset(slot) return True + if kind == RowKind.TRACK_PRESET_LIST_POPULATE: + slot = self.focus_descriptor.slot + if slot is not None: + if section_lock_blocks_mutation( + self.session, self.focus_descriptor + ): + return True + self._prompt_populate_presets(slot) + return True if kind == RowKind.LAYER_MANAGEMENT_ADD: self._add_layer() return True @@ -592,15 +611,6 @@ def handle_keydown(self, event: pygame.event.Event) -> bool: if kind == RowKind.TIMELINE_RESET: self._timeline_presets.prompt_reset() return True - if kind == RowKind.TRACK_PRESET_SWITCHING_SEED: - slot = self.focus_descriptor.slot - if slot is not None: - if section_lock_blocks_mutation( - self.session, self.focus_descriptor - ): - return True - self._preset_seed.prompt(slot) - return True if kind == RowKind.TIMELINE_SNAP_TO_BEATS: self._timeline_snap.prompt_beats() return True @@ -709,7 +719,7 @@ def _handle_curation_keydown(self, event: pygame.event.Event) -> bool: self._preset_curation.prompt_blacklist( slot, src, - from_user_preset=(kind == RowKind.TRACK_USER_PRESET_ITEM), + from_user_preset=(kind == RowKind.TRACK_PRESET_LIST_ITEM), user_preset_index=self.focus_descriptor.preset_index, ) elif event.key == pygame.K_c: @@ -736,7 +746,6 @@ def key_repeat_armed(self) -> bool: def tick(self, dt_sec: float) -> None: self._key_repeat.tick(dt_sec) self._notification_host.clear_expired() - self._sync_cast_roles_pool_notification() def build_view_state( self, @@ -745,24 +754,10 @@ def build_view_state( position_sec: float | None = None, fps: float | None = None, ) -> TuningViewState: - self._sync_cast_roles_pool_notification() return self._view_state.build( paused=paused, position_sec=position_sec, fps=fps ) - def _sync_cast_roles_pool_notification(self) -> None: - for slot in self.session.layer_z_order: - layer = self.session.layers[slot] - if layer.preset_switching_rotation_set != "cast_roles": - continue - role = layer.cast_roles_default_role - if not role_pool_paths(self.preset_root, role): - self._notification_host.set_persistent( - f"No presets in {role} roles folder" - ) - return - self._notification_host.set_persistent(None) - @property def focus_descriptor(self) -> RowDescriptor: return cursor_main_descriptor(self.focus_cursor) @@ -881,9 +876,15 @@ def _apply_preview_resolutions(self) -> None: def _confirm_move_mode(self) -> None: self.move_mode_slot = None self._move_mode_original_z_order = None + if self.move_mode_preset is not None: + slot, _index = self.move_mode_preset + self.move_mode_preset = None + self._move_mode_original_preset_list = None + if self._layer_bindings is not None: + self._layer_bindings.on_preset_switching_change(slot) def _rebuild_view(self) -> None: - if self.move_mode_slot is not None: + if self.move_mode_slot is not None or self.move_mode_preset is not None: self._confirm_move_mode() def _add_layer(self) -> None: @@ -948,10 +949,10 @@ def _user_presets_dir(self) -> Path | None: return None return self.project_dir / "user-presets" - def _user_preset_path_referenced(self, path: str) -> bool: + def _preset_list_path_referenced(self, path: str) -> bool: target = Path(path).resolve() for layer in self.session.layers.values(): - for other in layer.user_presets: + for other in layer.preset_list: if Path(other).resolve() == target: return True return False @@ -966,11 +967,11 @@ def _resolve_preset_file_path( layer = self.session.layers[slot] if kind == RowKind.TRACK_PRESET: return layer.playlist.current - if kind == RowKind.TRACK_USER_PRESET_ITEM: + if kind == RowKind.TRACK_PRESET_LIST_ITEM: index = desc.preset_index - if index is None or index < 0 or index >= len(layer.user_presets): + if index is None or index < 0 or index >= len(layer.preset_list): return None - return Path(layer.user_presets[index]) + return Path(layer.preset_list[index]) return None def _add_current_preset(self, slot: str) -> None: @@ -995,17 +996,17 @@ def _confirm_add_preset(self, slot: str, src_path: Path) -> None: dest_path, needs_copy = resolve_user_preset_dest(dest_dir, src_path) if needs_copy: shutil.copy2(src_path, dest_path) - self.session.layers[slot].user_presets.append(str(dest_path.resolve())) + self.session.layers[slot].preset_list.append(str(dest_path.resolve())) if self._layer_bindings is not None: self._layer_bindings.on_preset_switching_change(slot) finally: self._unlock_preset_after_modal(slot) - def _delete_user_preset(self, slot: str, index: int) -> None: + def _delete_preset_list_item(self, slot: str, index: int) -> None: layer = self.session.layers[slot] - if index < 0 or index >= len(layer.user_presets): + if index < 0 or index >= len(layer.preset_list): return - label = user_preset_item_display_name(layer.user_presets, index) + label = preset_list_item_display_name(layer.preset_list, index) self._modal_host.prompt_yes_no( f"Remove preset: {label}?", on_confirm=lambda: self._confirm_delete_preset(slot, index), @@ -1013,9 +1014,9 @@ def _delete_user_preset(self, slot: str, index: int) -> None: def _confirm_delete_preset(self, slot: str, index: int) -> None: layer = self.session.layers[slot] - if index < 0 or index >= len(layer.user_presets): + if index < 0 or index >= len(layer.preset_list): return - removed = layer.user_presets.pop(index) + removed = layer.preset_list.pop(index) removed_path = Path(removed).resolve() presets_dir = self._user_presets_dir() if presets_dir is not None: @@ -1024,7 +1025,7 @@ def _confirm_delete_preset(self, slot: str, index: int) -> None: except ValueError: pass else: - still_needed = self._user_preset_path_referenced(removed) + still_needed = self._preset_list_path_referenced(removed) if not still_needed and self.project_dir is not None: still_needed = user_preset_referenced_on_disk( self.project_dir, @@ -1040,8 +1041,160 @@ def _cancel_move_mode(self) -> None: if self._move_mode_original_z_order is not None: self.session.layer_z_order[:] = self._move_mode_original_z_order self._apply_preview_resolutions() + if ( + self.move_mode_preset is not None + and self._move_mode_original_preset_list is not None + ): + slot, _index = self.move_mode_preset + self.session.layers[slot].preset_list[:] = ( + self._move_mode_original_preset_list + ) self.move_mode_slot = None self._move_mode_original_z_order = None + self.move_mode_preset = None + self._move_mode_original_preset_list = None + + def _enter_preset_list_move_mode(self, slot: str, index: int) -> None: + layer = self.session.layers[slot] + if index < 0 or index >= len(layer.preset_list): + return + self._move_mode_original_preset_list = list(layer.preset_list) + self.move_mode_preset = (slot, index) + + def _swap_preset_list_item(self, direction: int) -> None: + if self.move_mode_preset is None: + return + slot, index = self.move_mode_preset + presets = self.session.layers[slot].preset_list + target = index + direction + if target < 0 or target >= len(presets): + return + presets[index], presets[target] = presets[target], presets[index] + self.move_mode_preset = (slot, target) + self._apply_focus_cursor( + MainFocus( + RowDescriptor( + RowKind.TRACK_PRESET_LIST_ITEM, + slot=slot, + preset_index=target, + ) + ) + ) + + def _repopulate_preset_lists(self) -> None: + if self.project_dir is None: + return + repopulate_preset_lists( + self.session, + mode=self.session.timeline.timeline_preset_repopulate, + project_dir=self.project_dir, + preset_root=self.preset_root, + ) + if self._layer_bindings is None: + return + for slot in self.session.layer_z_order: + if self.session.layers[slot].preset_switching == "on": + self._layer_bindings.on_preset_switching_change(slot) + + def _prompt_populate_presets(self, slot: str) -> None: + if self.project_dir is None: + return + if self._layer_bindings is not None: + self._layer_bindings.lock_preset_for_modal(slot) + options: list[ModalOption] = [] + layer = self.session.layers[slot] + timeline_trigger = layer.preset_switching_trigger == "timeline" + if timeline_trigger: + populate_count = on_segment_populate_count(self.session, slot) + options.append( + ModalOption( + "Using Cue Marker Roles (random)", + action=lambda: self._confirm_populate(slot, "cue_roles"), + ) + ) + else: + needed = needed_preset_count( + song_duration_sec=self.duration_sec, + preset_duration=layer.preset_duration, + trigger=layer.preset_switching_trigger, + ) + available = len(milk_files_in_dir(layer.playlist.current_dir)) + populate_count = min(needed, available) + options.extend( + [ + ModalOption( + "From Current Directory (random)", + action=lambda: self._confirm_populate( + slot, "directory_random" + ), + ), + ModalOption( + "From Current Directory (sequential)", + action=lambda: self._confirm_populate( + slot, "directory_sequential" + ), + ), + ] + ) + options.append( + ModalOption( + "Cancel", + action=lambda: self._unlock_preset_after_modal(slot), + ) + ) + self._modal_host.prompt_choice( + f"Populate the preset list with {populate_count} presets?", + options, + on_dismiss=lambda: self._unlock_preset_after_modal(slot), + ) + + def _confirm_populate(self, slot: str, mode: str) -> None: + try: + if self.project_dir is None: + return + layer = self.session.layers[slot] + timeline_trigger_disabled = ( + layer.preset_switching_trigger == "timeline" + and not self.session.timeline.enabled + ) + if mode in ("directory_random", "directory_sequential"): + max_count: int | None = None + if layer.preset_switching_trigger != "timeline": + max_count = needed_preset_count( + song_duration_sec=self.duration_sec, + preset_duration=layer.preset_duration, + trigger=layer.preset_switching_trigger, + ) + order = ( + "sequential" if mode == "directory_sequential" else "random" + ) + populate_from_directory( + self.session, + slot, + project_dir=self.project_dir, + max_count=max_count, + order=order, + ) + else: + populate_from_cue_marker_roles( + self.session, + slot, + project_dir=self.project_dir, + preset_root=self.preset_root, + ) + layer.preset_list_expanded = True + if self._layer_bindings is not None: + self._layer_bindings.on_preset_switching_change(slot) + count = len(layer.preset_list) + if timeline_trigger_disabled: + self.show_notification( + f"Populated {count} presets; " + "enable Render: TIMELINE to switch" + ) + else: + self.show_notification(f"Populated {count} presets") + finally: + self._unlock_preset_after_modal(slot) def _apply_horizontal(self, key: int, mod: int, kind: RowKind) -> None: ctrl = mod_ctrl(mod) @@ -1108,85 +1261,47 @@ def _step_preset(self, slot: str, *, forward: bool, ctrl: bool) -> None: if self._layer_bindings is not None: self._layer_bindings.on_preset_change(slot, playlist) - def _cycle_preset_switching(self, slot: str, *, forward: bool) -> None: + def _warn_if_timeline_trigger_disabled(self, slot: str) -> bool: layer = self.session.layers[slot] - modes = PRESET_SWITCHING_MODES - try: - index = modes.index(layer.preset_switching) - except ValueError: - index = 0 - if forward: - layer.preset_switching = modes[(index + 1) % len(modes)] - else: - layer.preset_switching = modes[(index - 1) % len(modes)] if ( - layer.preset_switching != "timeline" - and layer.preset_switching_rotation_set == "cast_roles" - ): - layer.preset_switching_rotation_set = "directory" - if ( - layer.preset_switching == "timeline" + layer.preset_switching == "on" + and layer.preset_switching_trigger == "timeline" and not self.session.timeline.enabled ): - self.show_notification(NOTIFICATION_TIMELINE_PRESET_SWITCHING_TEXT) - if self._layer_bindings is not None: - self._layer_bindings.on_preset_switching_change(slot) - - def _cycle_preset_switching_rotation_set(self, slot: str, *, forward: bool) -> None: - layer = self.session.layers[slot] - rotation_sets = tuple( - value - for value in PRESET_SWITCHING_ROTATION_SETS - if value != "cast_roles" or layer.preset_switching == "timeline" - ) - try: - index = rotation_sets.index(layer.preset_switching_rotation_set) - except ValueError: - index = 0 - if forward: - layer.preset_switching_rotation_set = rotation_sets[ - (index + 1) % len(rotation_sets) - ] - else: - layer.preset_switching_rotation_set = rotation_sets[ - (index - 1) % len(rotation_sets) - ] - if layer.preset_switching_rotation_set == "user_defined": - layer.user_presets_expanded = True - if self._layer_bindings is not None: - self._layer_bindings.on_preset_switching_change(slot) + self.show_notification(NOTIFICATION_TIMELINE_TRIGGER_DISABLED_TEXT) + return True + return False - def _cycle_cast_roles_timeline_behaviour( - self, slot: str, *, forward: bool - ) -> None: + def _cycle_preset_switching(self, slot: str, *, forward: bool) -> None: layer = self.session.layers[slot] - options = CAST_ROLES_TIMELINE_BEHAVIOURS + modes = PRESET_SWITCHING_MODES try: - index = options.index(layer.cast_roles_timeline_behaviour) + index = modes.index(layer.preset_switching) except ValueError: index = 0 if forward: - layer.cast_roles_timeline_behaviour = options[ - (index + 1) % len(options) - ] + layer.preset_switching = modes[(index + 1) % len(modes)] else: - layer.cast_roles_timeline_behaviour = options[ - (index - 1) % len(options) - ] + layer.preset_switching = modes[(index - 1) % len(modes)] + if layer.preset_switching == "on": + layer.preset_list_expanded = True + self._warn_if_timeline_trigger_disabled(slot) if self._layer_bindings is not None: self._layer_bindings.on_preset_switching_change(slot) - def _cycle_cast_roles_default_role(self, slot: str, *, forward: bool) -> None: + def _cycle_preset_switching_trigger(self, slot: str, *, forward: bool) -> None: layer = self.session.layers[slot] - options = CUE_ROLES + options = PRESET_SWITCHING_TRIGGERS try: - index = options.index(layer.cast_roles_default_role) + index = options.index(layer.preset_switching_trigger) except ValueError: index = 0 if forward: - layer.cast_roles_default_role = options[(index + 1) % len(options)] + layer.preset_switching_trigger = options[(index + 1) % len(options)] else: - layer.cast_roles_default_role = options[(index - 1) % len(options)] + layer.preset_switching_trigger = options[(index - 1) % len(options)] + if not self._warn_if_timeline_trigger_disabled(slot) and layer.preset_list: + self.show_notification("Preset list may need adjusting") if self._layer_bindings is not None: self._layer_bindings.on_preset_switching_change(slot) @@ -1197,6 +1312,17 @@ def _step_preset_duration( step = 10.0 if ctrl else 1.0 delta = step if forward else -step layer.preset_duration = max(5.0, min(300.0, layer.preset_duration + delta)) + if ( + layer.preset_switching_trigger != "timeline" + and layer.preset_list + and len(layer.preset_list) + < needed_preset_count( + song_duration_sec=self.duration_sec, + preset_duration=layer.preset_duration, + trigger=layer.preset_switching_trigger, + ) + ): + self.show_notification("Preset list may need more presets") if self._layer_bindings is not None: self._layer_bindings.on_preset_switching_change(slot) @@ -1232,12 +1358,6 @@ def _cycle_hard_cut_enabled(self, slot: str, *, forward: bool) -> None: if self._layer_bindings is not None: self._layer_bindings.on_preset_switching_change(slot) - def _cycle_preset_switching_shuffle(self, slot: str, *, forward: bool) -> None: - del forward - layer = self.session.layers[slot] - layer.preset_switching_shuffle = not layer.preset_switching_shuffle - if self._layer_bindings is not None: - self._layer_bindings.on_preset_switching_change(slot) def _step_hard_cut_duration( self, slot: str, *, forward: bool, ctrl: bool = False @@ -1375,11 +1495,11 @@ def _set_effects_expanded(self, slot: str, expanded: bool) -> None: return layer.effects_expanded = expanded - def _set_user_presets_expanded(self, slot: str, expanded: bool) -> None: + def _set_preset_list_expanded(self, slot: str, expanded: bool) -> None: layer = self.session.layers[slot] - if layer.user_presets_expanded == expanded: + if layer.preset_list_expanded == expanded: return - layer.user_presets_expanded = expanded + layer.preset_list_expanded = expanded def _set_song_markers_expanded(self, expanded: bool) -> None: markers = self.session.song_markers diff --git a/cleave/viz/help_content.py b/cleave/viz/help_content.py index 2a733a6..be4bcbe 100644 --- a/cleave/viz/help_content.py +++ b/cleave/viz/help_content.py @@ -132,32 +132,33 @@ def layer_section( ), ) -_USER_PRESET_ADD_SHORTCUT = ("U", "add current preset to user-defined list") +_USER_PRESET_ADD_SHORTCUT = ("P", "add current preset to switching list") -_USER_PRESET_ITEM_SECTION = HelpSection( +_PRESET_LIST_ITEM_SECTION = HelpSection( "Edit", ( + ("M", "reorder in list"), ("Delete", "remove preset"), *_PRESET_CURATION_SHORTCUTS, ), ) -_USER_PRESET_ADD_SECTION = HelpSection( +_PRESET_LIST_ADD_SECTION = HelpSection( "Add Current Preset", (("Enter", "add current preset"),), ) -def _preset_dir_section(*, user_defined: bool = False) -> HelpSection: +def _preset_dir_section(*, switching_on: bool = False) -> HelpSection: entries = list(_PRESET_DIR_SECTION.entries) - if user_defined: + if switching_on: entries.append(_USER_PRESET_ADD_SHORTCUT) return HelpSection(_PRESET_DIR_SECTION.title, tuple(entries)) -def _preset_section(*, user_defined: bool = False) -> HelpSection: +def _preset_section(*, switching_on: bool = False) -> HelpSection: entries = list(_PRESET_SECTION.entries) - if user_defined: + if switching_on: entries.append(_USER_PRESET_ADD_SHORTCUT) return HelpSection(_PRESET_SECTION.title, tuple(entries)) @@ -299,7 +300,7 @@ def sections_for( paused: bool = False, timeline_recording: bool = False, timeline_override_active: bool = False, - preset_switching_rotation_set: str | None = None, + preset_switching: str | None = None, preset_curation: bool = False, ) -> tuple[HelpContent, ...]: nav = navigation_section(preset_curation=preset_curation) @@ -346,17 +347,17 @@ def sections_for( primary = _value_step_section(row_kind) elif behavior.affordance == RowAffordance.ACTION_PARAMETER: primary = _value_step_section(row_kind) - elif row_kind == RowKind.TRACK_USER_PRESET_ITEM: - primary = _USER_PRESET_ITEM_SECTION - elif row_kind == RowKind.TRACK_USER_PRESET_ADD: - primary = _USER_PRESET_ADD_SECTION + elif row_kind == RowKind.TRACK_PRESET_LIST_ITEM: + primary = _PRESET_LIST_ITEM_SECTION + elif row_kind == RowKind.TRACK_PRESET_LIST_ADD: + primary = _PRESET_LIST_ADD_SECTION elif behavior.affordance == RowAffordance.PATH_DIR: primary = _preset_dir_section( - user_defined=preset_switching_rotation_set == "user_defined" + switching_on=preset_switching == "on" ) elif behavior.affordance == RowAffordance.PATH_PRESET: primary = _preset_section( - user_defined=preset_switching_rotation_set == "user_defined" + switching_on=preset_switching == "on" ) elif behavior.affordance == RowAffordance.SEEK: primary = _TRANSPORT_SECTION diff --git a/cleave/viz/help_overlay.py b/cleave/viz/help_overlay.py index 346d788..29abd05 100644 --- a/cleave/viz/help_overlay.py +++ b/cleave/viz/help_overlay.py @@ -302,7 +302,7 @@ def compose_panel( paused: bool = False, timeline_recording: bool = False, timeline_override_active: bool = False, - preset_switching_rotation_set: str | None = None, + preset_switching: str | None = None, preset_curation: bool = False, ) -> ComposedHelpPanel | None: self._panel_rect = None @@ -314,7 +314,7 @@ def compose_panel( paused=paused, timeline_recording=timeline_recording, timeline_override_active=timeline_override_active, - preset_switching_rotation_set=preset_switching_rotation_set, + preset_switching=preset_switching, preset_curation=preset_curation, ) sections = sections_for( @@ -325,7 +325,7 @@ def compose_panel( paused=paused, timeline_recording=timeline_recording, timeline_override_active=timeline_override_active, - preset_switching_rotation_set=preset_switching_rotation_set, + preset_switching=preset_switching, preset_curation=preset_curation, ) panel_w, panel_h = compute_help_panel_size( @@ -415,7 +415,7 @@ def draw( paused: bool = False, timeline_recording: bool = False, timeline_override_active: bool = False, - preset_switching_rotation_set: str | None = None, + preset_switching: str | None = None, preset_curation: bool = False, ) -> None: composed = self.compose_panel( @@ -427,7 +427,7 @@ def draw( paused=paused, timeline_recording=timeline_recording, timeline_override_active=timeline_override_active, - preset_switching_rotation_set=preset_switching_rotation_set, + preset_switching=preset_switching, preset_curation=preset_curation, ) if composed is None: diff --git a/cleave/viz/help_panel_cache.py b/cleave/viz/help_panel_cache.py index 825fd0c..045792a 100644 --- a/cleave/viz/help_panel_cache.py +++ b/cleave/viz/help_panel_cache.py @@ -29,7 +29,7 @@ class HelpContentSignature: paused: bool timeline_recording: bool timeline_override_active: bool - preset_switching_rotation_set: str | None + preset_switching: str | None preset_curation: bool = False @@ -49,7 +49,7 @@ def help_content_signature( paused: bool, timeline_recording: bool, timeline_override_active: bool, - preset_switching_rotation_set: str | None, + preset_switching: str | None, preset_curation: bool = False, ) -> HelpContentSignature: return HelpContentSignature( @@ -60,7 +60,7 @@ def help_content_signature( paused=paused, timeline_recording=timeline_recording, timeline_override_active=timeline_override_active, - preset_switching_rotation_set=preset_switching_rotation_set, + preset_switching=preset_switching, preset_curation=preset_curation, ) @@ -245,11 +245,11 @@ def help_panel_max_dimensions( ) for effect_id in effect_ids: for flags in _help_flag_variants(): - for preset_switching_rotation_set in (None, "user_defined"): + for preset_switching in (None, "on"): sections = sections_for( row_kind, effect_id=effect_id, - preset_switching_rotation_set=preset_switching_rotation_set, + preset_switching=preset_switching, **flags, ) panel_w, panel_h = compute_help_panel_size( diff --git a/cleave/viz/key_repeat.py b/cleave/viz/key_repeat.py index 0cf341b..814bf87 100644 --- a/cleave/viz/key_repeat.py +++ b/cleave/viz/key_repeat.py @@ -42,9 +42,9 @@ def delete_key_pressed(event: pygame.event.Event) -> bool: def add_current_preset_key_pressed(key: int, mod: int) -> bool: - """True for the U key that adds the current preset in user-defined mode.""" + """True for the P key that adds the current preset in user-defined mode.""" del mod - return key == pygame.K_u + return key == pygame.K_p @dataclass diff --git a/cleave/viz/layer.py b/cleave/viz/layer.py index 9ba2e9e..916e9f5 100644 --- a/cleave/viz/layer.py +++ b/cleave/viz/layer.py @@ -2,10 +2,9 @@ from __future__ import annotations -from dataclasses import dataclass, field +from dataclasses import dataclass from pathlib import Path -from cleave.cue_roles import CueRole from cleave.gl_compositor import LayerFbo from cleave.preset_playlist import PresetPlaylist from cleave.preset_rotation import PresetRotation @@ -24,7 +23,5 @@ class StemLayer: timeline_level: float = 1.0 limiter_gain: float = 1.0 preset_rotation: PresetRotation | None = None - role_rotations: dict[CueRole, PresetRotation] = field(default_factory=dict) - last_cast_role: CueRole | None = None - timeline_switch_count: int = 0 + list_switch_index: int = 0 rotation_anchor: int = 0 diff --git a/cleave/viz/layer_pipeline.py b/cleave/viz/layer_pipeline.py index b3ddf39..b28e779 100644 --- a/cleave/viz/layer_pipeline.py +++ b/cleave/viz/layer_pipeline.py @@ -226,9 +226,9 @@ def build_single( apply_preset_switching( layer, mode=layer_cfg.preset_switching, - rotation_set=layer_cfg.preset_switching_rotation_set, - user_presets=[ - str(path) for path in layer_cfg.preset_switching_presets + trigger=layer_cfg.preset_switching_trigger, + preset_list=[ + str(path) for path in layer_cfg.preset_switching_list ], preset_duration=layer_cfg.preset_duration, soft_cut_duration=layer_cfg.soft_cut_duration, @@ -237,11 +237,6 @@ def build_single( hard_cut_enabled=layer_cfg.hard_cut_enabled, hard_cut_duration=layer_cfg.hard_cut_duration, hard_cut_sensitivity=layer_cfg.hard_cut_sensitivity, - shuffle=layer_cfg.preset_switching_shuffle, - shuffle_salt=layer_cfg.preset_switching_shuffle_salt, - cast_roles_default_role=layer_cfg.cast_roles_default_role, - cast_roles_timeline_behaviour=layer_cfg.cast_roles_timeline_behaviour, - preset_root=preset_root, ) return layer diff --git a/cleave/viz/layer_visibility.py b/cleave/viz/layer_visibility.py index 9fb983e..3b5cab7 100644 --- a/cleave/viz/layer_visibility.py +++ b/cleave/viz/layer_visibility.py @@ -357,8 +357,4 @@ def build_timeline_view_state( soft_cut_fades=_as_fade_group(tl.soft_cut_fades), selected_cue_t=dict(tl.selected_cue_t), selected_cue_flash_start_ms=tl.selected_cue_flash_start_ms, - slot_rotation_sets={ - slot: session.layers[slot].preset_switching_rotation_set - for slot in session.layer_z_order - }, ) diff --git a/cleave/viz/overlay_draw.py b/cleave/viz/overlay_draw.py index a78ce5b..6dadc28 100644 --- a/cleave/viz/overlay_draw.py +++ b/cleave/viz/overlay_draw.py @@ -95,11 +95,11 @@ def _present_overlay( def _help_compose_kwargs(view_state: TuningViewState) -> dict[str, object]: focus = view_state.focus_descriptor - preset_switching_rotation_set = None + preset_switching = None if focus.slot is not None: track = view_state.tracks.get(focus.slot) if track is not None: - preset_switching_rotation_set = track.preset_switching_rotation_set + preset_switching = track.preset_switching return { "focus": focus, "timeline_enabled": view_state.render_timeline.enabled, @@ -107,7 +107,7 @@ def _help_compose_kwargs(view_state: TuningViewState) -> dict[str, object]: "paused": view_state.paused, "timeline_recording": view_state.timeline_recording, "timeline_override_active": view_state.timeline_override_active, - "preset_switching_rotation_set": preset_switching_rotation_set, + "preset_switching": preset_switching, "preset_curation": view_state.settings.editor_mode == "preset_curation", } diff --git a/cleave/viz/preset_curation_controls.py b/cleave/viz/preset_curation_controls.py index c7c16b3..90bfee4 100644 --- a/cleave/viz/preset_curation_controls.py +++ b/cleave/viz/preset_curation_controls.py @@ -414,7 +414,7 @@ def _rebuild_timeline_role_rotations(self) -> None: if self._layer_bindings is None: return for layer_slot, layer in self.session.layers.items(): - if layer.preset_switching == "timeline": + if layer.preset_switching == "on": self._layer_bindings.on_preset_switching_change(layer_slot) def _prompt_restore_blacklist(self, slot: str, src: Path) -> None: diff --git a/cleave/viz/preset_list_populate.py b/cleave/viz/preset_list_populate.py new file mode 100644 index 0000000..b579321 --- /dev/null +++ b/cleave/viz/preset_list_populate.py @@ -0,0 +1,210 @@ +"""Populate a layer's ordered preset_list from directory or timeline role pools.""" + +from __future__ import annotations + +import math +import random +from collections.abc import Sequence +from pathlib import Path +from typing import Literal + +from cleave.cue_roles import CueRole, role_pool_paths +from cleave.preset_playlist import milk_files_in_dir +from cleave.timeline import ( + TimelineFadeGroup, + empty_lane, + lane_on_transition_cues, +) +from cleave.viz.session import TuningSession +from cleave.viz.user_presets import USER_PRESETS_DIRNAME, copy_with_dedup + +DEFAULT_POPULATE_ROLE: CueRole = "bed" + +DirectoryOrder = Literal["random", "sequential"] + + +def needed_preset_count( + *, + song_duration_sec: float, + preset_duration: float, + trigger: str, +) -> int: + """How many presets to populate for timer/projectm over the song.""" + duration = max(1e-6, float(preset_duration)) + base = math.ceil(float(song_duration_sec) / duration) + if trigger == "projectm": + return max(1, base + 2) + return max(1, base) + + +def _fade_groups(session: TuningSession) -> tuple[TimelineFadeGroup, TimelineFadeGroup]: + tl = session.timeline + hard = TimelineFadeGroup( + enabled=tl.hard_cut_fades.enabled, + fade_in=tl.hard_cut_fades.fade_in, + fade_out=tl.hard_cut_fades.fade_out, + crossfade=tl.hard_cut_fades.crossfade, + ) + soft = TimelineFadeGroup( + enabled=tl.soft_cut_fades.enabled, + fade_in=tl.soft_cut_fades.fade_in, + fade_out=tl.soft_cut_fades.fade_out, + crossfade=tl.soft_cut_fades.crossfade, + ) + return hard, soft + + +def _on_segment_count(session: TuningSession, slot: str) -> int: + hard, soft = _fade_groups(session) + lane = session.timeline.lanes.get(slot) or empty_lane() + return len(lane_on_transition_cues(lane, hard_cut_fades=hard, soft_cut_fades=soft)) + + +def on_segment_populate_count(session: TuningSession, slot: str) -> int: + """How many presets timeline populate modes add for ``slot``.""" + return max(1, _on_segment_count(session, slot)) + + +def _on_segment_roles(session: TuningSession, slot: str) -> list[CueRole | None]: + hard, soft = _fade_groups(session) + lane = session.timeline.lanes.get(slot) or empty_lane() + cues = lane_on_transition_cues(lane, hard_cut_fades=hard, soft_cut_fades=soft) + return [cue.role for _, cue in cues] + + +def _copy_into_user_presets( + project_dir: Path, + sources: Sequence[Path], +) -> list[str]: + dest_dir = project_dir / USER_PRESETS_DIRNAME + out: list[str] = [] + for src in sources: + if not src.is_file(): + continue + dest = copy_with_dedup(dest_dir, src) + out.append(str(dest.resolve())) + return out + + +def _pick_random(pool: Sequence[Path], rng: random.Random) -> Path | None: + if not pool: + return None + return rng.choice(list(pool)) + + +def populate_from_directory( + session: TuningSession, + slot: str, + *, + project_dir: Path, + max_count: int | None = None, + order: DirectoryOrder = "random", + rng: random.Random | None = None, +) -> list[str]: + """Replace ``preset_list`` with copies from the layer browse directory. + + Timeline trigger: one pick per on-segment (from lane cues if any). + Other triggers: ``*.milk`` in the browse dir (``max_count`` limits the + selection; ``None`` uses the full pool). + + ``order="random"`` shuffles or samples; ``order="sequential"`` walks the + sorted directory order (cycling when more picks than files). + """ + layer = session.layers[slot] + browse_dir = layer.playlist.current_dir + pool = list(milk_files_in_dir(browse_dir)) + if not pool: + layer.preset_list = [] + return [] + rng = rng or random.Random() + if layer.preset_switching_trigger == "timeline": + count = on_segment_populate_count(session, slot) + if order == "sequential": + sources = [pool[i % len(pool)] for i in range(count)] + else: + picks = [_pick_random(pool, rng) for _ in range(count)] + sources = [path for path in picks if path is not None] + elif order == "sequential": + sources = list(pool) + if max_count is not None: + sources = sources[:max_count] + elif max_count is None: + sources = list(pool) + rng.shuffle(sources) + else: + sources = rng.sample(pool, min(max_count, len(pool))) + layer.preset_list = _copy_into_user_presets(project_dir, sources) + return list(layer.preset_list) + + +def populate_from_cue_marker_roles( + session: TuningSession, + slot: str, + *, + project_dir: Path, + preset_root: Path, + default_role: CueRole = DEFAULT_POPULATE_ROLE, + rng: random.Random | None = None, +) -> list[str]: + """Replace ``preset_list`` with one pick per on-segment from each cue's role pool.""" + layer = session.layers[slot] + roles = _on_segment_roles(session, slot) + if not roles: + roles = [default_role] + rng = rng or random.Random() + sources: list[Path] = [] + for role in roles: + effective = role if role is not None else default_role + pool = list(role_pool_paths(preset_root, effective)) + pick = _pick_random(pool, rng) + if pick is not None: + sources.append(pick) + layer.preset_list = _copy_into_user_presets(project_dir, sources) + return list(layer.preset_list) + + +def repopulate_preset_lists( + session: TuningSession, + *, + mode: str, + project_dir: Path, + preset_root: Path, + rng: random.Random | None = None, +) -> None: + """Repopulate layers with timeline-trigger switching after a preset apply. + + ``mode`` is a staged ``TimelinePresetRepopulate`` value. ``no`` is a no-op. + """ + if mode == "no": + return + rng = rng or random.Random() + for slot in session.layer_z_order: + runtime = session.layers.get(slot) + if runtime is None or runtime.preset_switching != "on": + continue + if runtime.preset_switching_trigger != "timeline": + continue + if mode == "cue roles": + populate_from_cue_marker_roles( + session, + slot, + project_dir=project_dir, + preset_root=preset_root, + rng=rng, + ) + elif mode == "directory sequential": + populate_from_directory( + session, + slot, + project_dir=project_dir, + order="sequential", + rng=rng, + ) + else: + populate_from_directory( + session, + slot, + project_dir=project_dir, + order="random", + rng=rng, + ) diff --git a/cleave/viz/preset_seed_controls.py b/cleave/viz/preset_seed_controls.py deleted file mode 100644 index 6eb4a29..0000000 --- a/cleave/viz/preset_seed_controls.py +++ /dev/null @@ -1,99 +0,0 @@ -"""Per-layer shuffle seed confirm modal and salt re-roll orchestration.""" - -from __future__ import annotations - -import random -from collections.abc import Callable, Mapping -from pathlib import Path - -from cleave.viz.layer import StemLayer -from cleave.viz.modal import ModalHost -from cleave.viz.preset_switching import rebuild_timeline_preset_rotation_preserving_count -from cleave.viz.session import LayerRuntime, TuningSession - -_PROMPT = "Generate a new seed?" - - -def new_shuffle_salt(current: int) -> int: - while True: - candidate = random.getrandbits(31) - if candidate != current: - return candidate - - -class PresetSeedController: - """Prompt for and apply a new shuffle salt on one layer.""" - - def __init__( - self, - session: TuningSession, - modal_host: ModalHost, - layers_by_slot: Mapping[str, StemLayer], - *, - preset_root: Path, - on_preset_switching_change: Callable[[str], None] | None = None, - ) -> None: - self.session = session - self._modal = modal_host - self._layers_by_slot = layers_by_slot - self._preset_root = preset_root - self._on_preset_switching_change = on_preset_switching_change - - def prompt(self, slot: str) -> None: - runtime = self.session.layers.get(slot) - if runtime is None or not runtime.preset_switching_shuffle: - return - if runtime.preset_switching not in ("projectm", "timeline"): - return - self._modal.prompt_yes_no( - _PROMPT, - on_confirm=lambda: self._confirm(slot), - cancel_label="Cancel", - ) - - def reroll_all(self) -> None: - """Roll a new shuffle salt for every layer and rebuild active rotations.""" - for slot in self.session.layer_z_order: - runtime = self.session.layers.get(slot) - if runtime is None: - continue - runtime.preset_switching_shuffle_salt = new_shuffle_salt( - runtime.preset_switching_shuffle_salt - ) - if not runtime.preset_switching_shuffle: - continue - if runtime.preset_switching not in ("projectm", "timeline"): - continue - self._rebuild(slot, runtime) - - def _confirm(self, slot: str) -> None: - runtime = self.session.layers.get(slot) - if runtime is None or not runtime.preset_switching_shuffle: - return - if runtime.preset_switching not in ("projectm", "timeline"): - return - runtime.preset_switching_shuffle_salt = new_shuffle_salt( - runtime.preset_switching_shuffle_salt - ) - self._rebuild(slot, runtime) - - def _rebuild(self, slot: str, runtime: LayerRuntime) -> None: - if runtime.preset_switching == "timeline": - layer = self._layers_by_slot.get(slot) - if layer is None: - return - rebuild_timeline_preset_rotation_preserving_count( - layer, - rotation_set=runtime.preset_switching_rotation_set, - user_presets=runtime.user_presets, - shuffle=runtime.preset_switching_shuffle, - shuffle_salt=runtime.preset_switching_shuffle_salt, - preset_start_clean=runtime.preset_start_clean, - cast_roles_default_role=runtime.cast_roles_default_role, - cast_roles_timeline_behaviour=runtime.cast_roles_timeline_behaviour, - preset_root=self._preset_root, - session=self.session, - ) - return - if self._on_preset_switching_change is not None: - self._on_preset_switching_change(slot) diff --git a/cleave/viz/preset_switching.py b/cleave/viz/preset_switching.py index 3cc2825..8696bfe 100644 --- a/cleave/viz/preset_switching.py +++ b/cleave/viz/preset_switching.py @@ -1,45 +1,34 @@ -"""Apply per-layer preset switching mode to live ProjectM instances.""" +"""Apply per-layer preset switching from an explicit ordered preset list.""" from __future__ import annotations +import math from collections.abc import Callable, Sequence from pathlib import Path from cleave.config_schema import ( - DEFAULT_CAST_ROLES_DEFAULT_ROLE, - DEFAULT_CAST_ROLES_TIMELINE_BEHAVIOUR, DEFAULT_EASTER_EGG, DEFAULT_HARD_CUT_DURATION, DEFAULT_HARD_CUT_ENABLED, DEFAULT_HARD_CUT_SENSITIVITY, DEFAULT_PRESET_DURATION, DEFAULT_PRESET_START_CLEAN, - DEFAULT_PRESET_SWITCHING_SHUFFLE, - DEFAULT_PRESET_SWITCHING_SHUFFLE_SALT, + DEFAULT_PRESET_SWITCHING_TRIGGER, DEFAULT_SOFT_CUT_DURATION, - CastRolesTimelineBehaviour, PresetSwitchingMode, - PresetSwitchingRotationSet, -) -from cleave.cue_roles import CUE_ROLES, CueRole, role_pool_paths -from cleave.preset_playlist import milk_files_in_dir -from cleave.preset_rotation import ( - PresetRotation, - first_shuffle_bag_order, - layer_rotation_seed, + PresetSwitchingTrigger, ) +from cleave.preset_rotation import PresetRotation from cleave.projectm import ProjectM from cleave.projectm_playlist import ProjectMPlaylist from cleave.timeline import ( TimelineFadeGroup, empty_lane, lane_on_transition_count, - lane_on_transition_cues, ) from cleave.viz.layer import StemLayer -EMPTY_ROTATION_NOTIFICATION = "No presets in directory for auto switching" -EMPTY_USER_PRESETS_NOTIFICATION = "No presets in user-defined rotation set" +EMPTY_PRESET_LIST_NOTIFICATION = "No presets in switching list" def _apply_projectm_timing( @@ -62,101 +51,24 @@ def _apply_projectm_timing( pm.set_hard_cut_sensitivity(hard_cut_sensitivity) -def _clear_timeline_rotation(layer: StemLayer) -> None: +def _clear_list_rotation(layer: StemLayer) -> None: layer.preset_rotation = None - layer.role_rotations = {} - layer.last_cast_role = None - layer.timeline_switch_count = 0 + layer.list_switch_index = 0 layer.rotation_anchor = 0 -def _build_role_rotations( - layer: StemLayer, - preset_root: Path, - *, - shuffle: bool, - shuffle_salt: int, -) -> dict[CueRole, PresetRotation]: - role_rotations: dict[CueRole, PresetRotation] = {} - for role in CUE_ROLES: - pool = role_pool_paths(preset_root, role) - if not pool: - continue - role_rotations[role] = PresetRotation( - paths=pool, - shuffle=shuffle, - seed=layer_rotation_seed( - pool, slot=layer.slot, shuffle_salt=shuffle_salt - ), - anchor=0, - ) - return role_rotations - - -def _timeline_path_for_count( - layer: StemLayer, - *, - count: int, -) -> Path | None: - """Seek-stable path for transition ``count`` from the main rotation only.""" - rotation = layer.preset_rotation - if rotation is None: - return None - return rotation.path_for(count) +def _timeline_list_index(on_transition_count: int) -> int: + """0-based list index for timeline switching. - -def _cast_roles_path_for_count( - layer: StemLayer, - *, - count: int, - lane, - hard_cut_fades: TimelineFadeGroup, - soft_cut_fades: TimelineFadeGroup, - behaviour: CastRolesTimelineBehaviour, - default_role: CueRole, -) -> Path | None: - """Seek-stable path after ``count`` on-transitions for cast-roles rotation. - - ``on_transition``: every rise advances; null cue role uses ``last_cast_role`` - (initialised to ``default_role``). - ``hold_current``: only role-marked cues advance; null-role cues keep the - prior path (caller skips reload when unchanged). - Count 0 uses default-role pool entry 0. + ``lane_on_transition_count`` is how many on-transitions have fired + (1 after the first section starts). Populate builds one preset per + on-segment, so the first section maps to index 0. """ - default_rotation = layer.role_rotations.get(default_role) or layer.preset_rotation - if default_rotation is None: - return None - if count < 1: - layer.last_cast_role = default_role - return default_rotation.path_for(0) - - cues = lane_on_transition_cues( - lane, - hard_cut_fades=hard_cut_fades, - soft_cut_fades=soft_cut_fades, - ) - last_role: CueRole = default_role - role_use_count: dict[CueRole, int] = {role: 0 for role in CUE_ROLES} - active_path: Path | None = default_rotation.path_for(0) - limit = min(count, len(cues)) - - for index in range(limit): - cue = cues[index][1] - if behaviour == "hold_current": - if cue.role is None: - continue - role = cue.role - else: - role = cue.role if cue.role is not None else last_role - last_role = role - role_rotation = layer.role_rotations.get(role) - if role_rotation is None: - continue - active_path = role_rotation.path_for(role_use_count[role]) - role_use_count[role] += 1 + return max(0, int(on_transition_count) - 1) + - layer.last_cast_role = last_role - return active_path +def _preset_paths(preset_list: list[str] | None) -> list[Path]: + return [Path(path) for path in (preset_list or [])] def reapply_projectm_preset_switching( @@ -167,22 +79,23 @@ def reapply_projectm_preset_switching( delta_sec: float = 0.0, ) -> None: """Re-attach projectM playlist switching after seek without reloading browse preset.""" + del preset_root from cleave.viz.editor_mode_controls import preset_switching_active if not preset_switching_active(session): return for slot, layer in layers_by_slot.items(): runtime = session.layers[slot] - if runtime.preset_switching != "projectm": + if runtime.preset_switching != "on": + continue + if runtime.preset_switching_trigger != "projectm": continue if layer.projectm_playlist is None: apply_preset_switching( layer, mode=runtime.preset_switching, - rotation_set=runtime.preset_switching_rotation_set, - user_presets=runtime.user_presets, - shuffle=runtime.preset_switching_shuffle, - shuffle_salt=runtime.preset_switching_shuffle_salt, + trigger=runtime.preset_switching_trigger, + preset_list=runtime.preset_list, preset_duration=runtime.preset_duration, soft_cut_duration=runtime.soft_cut_duration, easter_egg=runtime.easter_egg, @@ -190,9 +103,7 @@ def reapply_projectm_preset_switching( hard_cut_enabled=runtime.hard_cut_enabled, hard_cut_duration=runtime.hard_cut_duration, hard_cut_sensitivity=runtime.hard_cut_sensitivity, - cast_roles_default_role=runtime.cast_roles_default_role, - cast_roles_timeline_behaviour=runtime.cast_roles_timeline_behaviour, - preset_root=preset_root, + session=session, ) continue _reapply_on_seek( @@ -212,11 +123,8 @@ def apply_preset_switching( layer: StemLayer, *, mode: PresetSwitchingMode, - rotation_set: PresetSwitchingRotationSet, - preset_root: Path, - user_presets: list[str] | None = None, - shuffle: bool = DEFAULT_PRESET_SWITCHING_SHUFFLE, - shuffle_salt: int = DEFAULT_PRESET_SWITCHING_SHUFFLE_SALT, + trigger: PresetSwitchingTrigger = DEFAULT_PRESET_SWITCHING_TRIGGER, + preset_list: list[str] | None = None, preset_duration: float = DEFAULT_PRESET_DURATION, soft_cut_duration: float = DEFAULT_SOFT_CUT_DURATION, easter_egg: float = DEFAULT_EASTER_EGG, @@ -224,10 +132,6 @@ def apply_preset_switching( hard_cut_enabled: bool = DEFAULT_HARD_CUT_ENABLED, hard_cut_duration: float = DEFAULT_HARD_CUT_DURATION, hard_cut_sensitivity: float = DEFAULT_HARD_CUT_SENSITIVITY, - cast_roles_default_role: CueRole = DEFAULT_CAST_ROLES_DEFAULT_ROLE, - cast_roles_timeline_behaviour: CastRolesTimelineBehaviour = ( - DEFAULT_CAST_ROLES_TIMELINE_BEHAVIOUR - ), on_empty: Callable[[], None] | None = None, session=None, ) -> None: @@ -237,45 +141,43 @@ def apply_preset_switching( layer.projectm_playlist.destroy() layer.projectm_playlist = None - if mode != "timeline": - _clear_timeline_rotation(layer) + if mode != "on": + _clear_list_rotation(layer) + layer.auto_preset_path = None + pm.lock_preset(True) + pm.set_hard_cut_enabled(False) + return + + del session - if mode == "none": + paths = _preset_paths(preset_list) + if not paths: + _clear_list_rotation(layer) layer.auto_preset_path = None pm.lock_preset(True) pm.set_hard_cut_enabled(False) + if on_empty is not None: + on_empty() return - if mode == "timeline": - # Already rotating: rebuild in place (shuffle/salt/paths) without - # resetting anchor or switch count. First enable still re-anchors. - if layer.preset_rotation is not None: - rebuild_timeline_preset_rotation_preserving_count( + if trigger in ("timer", "timeline"): + # Timer and timeline index the list (timer by duration; timeline by + # on-transitions when Render: TIMELINE is enabled). + if trigger == "timeline" and layer.preset_rotation is not None: + rebuild_list_rotation_preserving_index( layer, - rotation_set=rotation_set, - user_presets=user_presets, - shuffle=shuffle, - shuffle_salt=shuffle_salt, + preset_list=preset_list, preset_start_clean=preset_start_clean, - cast_roles_default_role=cast_roles_default_role, - cast_roles_timeline_behaviour=cast_roles_timeline_behaviour, - preset_root=preset_root, - session=session, ) return - _apply_timeline_preset_switching( + _apply_indexed_list_switching( layer, - rotation_set=rotation_set, - user_presets=user_presets, - shuffle=shuffle, - shuffle_salt=shuffle_salt, + paths=paths, preset_start_clean=preset_start_clean, - cast_roles_default_role=cast_roles_default_role, - preset_root=preset_root, - on_empty=on_empty, ) return + # projectM trigger: feed the list into ProjectMPlaylist. pm.lock_preset(False) _apply_projectm_timing( pm, @@ -287,227 +189,60 @@ def apply_preset_switching( hard_cut_duration=hard_cut_duration, hard_cut_sensitivity=hard_cut_sensitivity, ) - - # cast_roles is timeline-only; projectM mode uses the browse directory. - effective_rotation: PresetSwitchingRotationSet = ( - "directory" if rotation_set == "cast_roles" else rotation_set - ) - paths = _rotation_paths( - layer, - rotation_set=effective_rotation, - user_presets=user_presets, - preset_root=preset_root, - cast_roles_default_role=cast_roles_default_role, - ) - if not paths: - layer.auto_preset_path = None - pm.lock_preset(True) - if on_empty is not None: - on_empty() - return - + _clear_list_rotation(layer) playlist = ProjectMPlaylist.create() playlist.connect(pm, on_preset_loaded=_auto_preset_loaded_callback(layer)) - if shuffle: - seed = layer_rotation_seed( - paths, slot=layer.slot, shuffle_salt=shuffle_salt - ) - ordered = first_shuffle_bag_order(paths, seed=seed) - playlist.add_presets(ordered, allow_duplicates=True) - elif effective_rotation == "user_defined": - playlist.add_presets(paths, allow_duplicates=True) - else: - playlist.add_path( - layer.playlist.current_dir, recurse=False, allow_duplicates=False - ) - # Match Cleave browse order (sorted filenames); add_path uses readdir order. - playlist.sort() - # Deterministic Cleave order when shuffle is on; never use libprojectM shuffle. + playlist.add_presets(paths, allow_duplicates=True) playlist.set_shuffle(False) layer.projectm_playlist = playlist _sync_projectm_playlist_position(layer) restart_projectm_preset_timer(layer) -def _rotation_paths( +def _apply_indexed_list_switching( layer: StemLayer, *, - rotation_set: PresetSwitchingRotationSet, - user_presets: list[str] | None, - preset_root: Path | None = None, - cast_roles_default_role: CueRole = DEFAULT_CAST_ROLES_DEFAULT_ROLE, -) -> list[Path]: - if rotation_set == "user_defined": - return [Path(path) for path in (user_presets or [])] - if rotation_set == "cast_roles": - if preset_root is None: - return [] - return list(role_pool_paths(preset_root, cast_roles_default_role)) - return list(milk_files_in_dir(layer.playlist.current_dir)) - - -def _anchor_index(layer: StemLayer, paths: Sequence[Path]) -> int: - current = layer.playlist.current - if current is None or not paths: - return 0 - resolved = current.resolve() - for index, candidate in enumerate(paths): - if candidate.resolve() == resolved: - return index - return 0 - - -def _apply_timeline_preset_switching( - layer: StemLayer, - *, - rotation_set: PresetSwitchingRotationSet, - user_presets: list[str] | None, - shuffle: bool, - shuffle_salt: int, + paths: Sequence[Path], preset_start_clean: bool, - cast_roles_default_role: CueRole, - preset_root: Path, - on_empty: Callable[[], None] | None, ) -> None: pm = layer.pm pm.lock_preset(True) pm.set_hard_cut_enabled(False) pm.set_preset_start_clean(preset_start_clean) - paths = _rotation_paths( - layer, - rotation_set=rotation_set, - user_presets=user_presets, - preset_root=preset_root, - cast_roles_default_role=cast_roles_default_role, - ) - if not paths: - _clear_timeline_rotation(layer) - layer.auto_preset_path = None - if on_empty is not None: - on_empty() - return - anchor = _anchor_index(layer, paths) layer.rotation_anchor = anchor - layer.timeline_switch_count = 0 - layer.last_cast_role = ( - cast_roles_default_role if rotation_set == "cast_roles" else None - ) - layer.preset_rotation = PresetRotation( - paths=tuple(paths), - shuffle=shuffle, - seed=layer_rotation_seed( - paths, slot=layer.slot, shuffle_salt=shuffle_salt - ), - anchor=anchor, - ) - if rotation_set == "cast_roles": - layer.role_rotations = _build_role_rotations( - layer, - preset_root, - shuffle=shuffle, - shuffle_salt=shuffle_salt, - ) - default_rotation = layer.role_rotations.get(cast_roles_default_role) - if default_rotation is not None: - layer.preset_rotation = PresetRotation( - paths=default_rotation.paths, - shuffle=shuffle, - seed=default_rotation.seed, - anchor=0, - ) - layer.rotation_anchor = 0 - else: - layer.role_rotations = {} + layer.list_switch_index = 0 + layer.preset_rotation = PresetRotation(paths=tuple(paths), anchor=anchor) path = layer.preset_rotation.path_for(0) if path is None: return - _load_timeline_preset(layer, path, preset_start_clean=preset_start_clean) + _load_list_preset(layer, path, preset_start_clean=preset_start_clean) -def rebuild_timeline_preset_rotation_preserving_count( +def rebuild_list_rotation_preserving_index( layer: StemLayer, *, - rotation_set: PresetSwitchingRotationSet, - preset_root: Path, - user_presets: list[str] | None = None, - shuffle: bool = DEFAULT_PRESET_SWITCHING_SHUFFLE, - shuffle_salt: int = DEFAULT_PRESET_SWITCHING_SHUFFLE_SALT, + preset_list: list[str] | None = None, preset_start_clean: bool = DEFAULT_PRESET_START_CLEAN, - cast_roles_default_role: CueRole = DEFAULT_CAST_ROLES_DEFAULT_ROLE, - cast_roles_timeline_behaviour: CastRolesTimelineBehaviour = ( - DEFAULT_CAST_ROLES_TIMELINE_BEHAVIOUR - ), - session=None, ) -> None: - """Rebuild timeline rotation; keep anchor and switch count. - - Used after shuffle/salt changes (and other in-place timeline rebuilds) so the - active ``path_for(count)`` stays aligned with the playhead-derived count. - """ - paths = _rotation_paths( - layer, - rotation_set=rotation_set, - user_presets=user_presets, - preset_root=preset_root, - cast_roles_default_role=cast_roles_default_role, - ) + """Rebuild list rotation; keep anchor and switch index.""" + paths = _preset_paths(preset_list) if not paths: - _clear_timeline_rotation(layer) + _clear_list_rotation(layer) layer.auto_preset_path = None return anchor = layer.rotation_anchor - count = layer.timeline_switch_count - layer.preset_rotation = PresetRotation( - paths=tuple(paths), - shuffle=shuffle, - seed=layer_rotation_seed( - paths, slot=layer.slot, shuffle_salt=shuffle_salt - ), - anchor=anchor, - ) - if rotation_set == "cast_roles": - layer.role_rotations = _build_role_rotations( - layer, - preset_root, - shuffle=shuffle, - shuffle_salt=shuffle_salt, - ) - default_rotation = layer.role_rotations.get(cast_roles_default_role) - if default_rotation is not None: - layer.preset_rotation = PresetRotation( - paths=default_rotation.paths, - shuffle=shuffle, - seed=default_rotation.seed, - anchor=0, - ) - else: - layer.role_rotations = {} - layer.last_cast_role = None - - path: Path | None - if rotation_set == "cast_roles" and session is not None: - hard_cut_fades, soft_cut_fades = _timeline_fade_groups(session) - lane = session.timeline.lanes.get(layer.slot) or empty_lane() - path = _cast_roles_path_for_count( - layer, - count=count, - lane=lane, - hard_cut_fades=hard_cut_fades, - soft_cut_fades=soft_cut_fades, - behaviour=cast_roles_timeline_behaviour, - default_role=cast_roles_default_role, - ) - else: - path = layer.preset_rotation.path_for(count) + index = layer.list_switch_index + layer.preset_rotation = PresetRotation(paths=tuple(paths), anchor=anchor) + path = layer.preset_rotation.path_for(index) if path is None: return - _load_timeline_preset(layer, path, preset_start_clean=preset_start_clean) + _load_list_preset(layer, path, preset_start_clean=preset_start_clean) -def _load_timeline_preset( +def _load_list_preset( layer: StemLayer, path: Path, *, @@ -536,29 +271,54 @@ def _timeline_fade_groups(session) -> tuple[TimelineFadeGroup, TimelineFadeGroup return hard_cut_fades, soft_cut_fades -def advance_timeline_preset_switching( +def advance_preset_switching( session, layers_by_slot: dict[str, StemLayer], t_sec: float, ) -> None: - """Load the seek-stable rotation preset when a layer's on-transition count changes. - - Uses committed lane cues only. No-op when timeline is disabled, in preset - curation, or when the count is unchanged (preset holds). - """ + """Advance list-indexed switching (timeline on-transitions or timer).""" from cleave.viz.editor_mode_controls import preset_switching_active if not preset_switching_active(session): return + + _advance_timeline_indexed(session, layers_by_slot, t_sec) + _advance_timer_indexed(session, layers_by_slot, t_sec) + + +def advance_timeline_preset_switching( + session, + layers_by_slot: dict[str, StemLayer], + t_sec: float, +) -> None: + """Compatibility alias for frame ticks; advances any list-indexed switching.""" + advance_preset_switching(session, layers_by_slot, t_sec) + + +def resync_timeline_preset_switching( + session, + layers_by_slot: dict[str, StemLayer], + t_sec: float, +) -> None: + """Resync list-indexed layers after seek.""" + advance_preset_switching(session, layers_by_slot, t_sec) + + +def _advance_timeline_indexed( + session, + layers_by_slot: dict[str, StemLayer], + t_sec: float, +) -> None: if not session.timeline.enabled: return - hard_cut_fades, soft_cut_fades = _timeline_fade_groups(session) tl = session.timeline for slot, layer in layers_by_slot.items(): runtime = session.layers[slot] - if runtime.preset_switching != "timeline": + if runtime.preset_switching != "on": + continue + if runtime.preset_switching_trigger != "timeline": continue rotation = layer.preset_rotation if rotation is None: @@ -570,21 +330,11 @@ def advance_timeline_preset_switching( hard_cut_fades=hard_cut_fades, soft_cut_fades=soft_cut_fades, ) - if count == layer.timeline_switch_count: + index = _timeline_list_index(count) + if index == layer.list_switch_index: continue - if runtime.preset_switching_rotation_set == "cast_roles": - path = _cast_roles_path_for_count( - layer, - count=count, - lane=lane, - hard_cut_fades=hard_cut_fades, - soft_cut_fades=soft_cut_fades, - behaviour=runtime.cast_roles_timeline_behaviour, - default_role=runtime.cast_roles_default_role, - ) - else: - path = _timeline_path_for_count(layer, count=count) - layer.timeline_switch_count = count + path = rotation.path_for(index) + layer.list_switch_index = index if path is None: continue if ( @@ -592,73 +342,92 @@ def advance_timeline_preset_switching( and path.resolve() == layer.auto_preset_path ): continue - _load_timeline_preset( + _load_list_preset( layer, path, preset_start_clean=runtime.preset_start_clean, ) -def resync_timeline_preset_switching( +def _advance_timer_indexed( session, layers_by_slot: dict[str, StemLayer], t_sec: float, ) -> None: - """Resync timeline-mode layers after seek (load preset for transition count).""" - advance_timeline_preset_switching(session, layers_by_slot, t_sec) + for slot, layer in layers_by_slot.items(): + runtime = session.layers[slot] + if runtime.preset_switching != "on": + continue + if runtime.preset_switching_trigger != "timer": + continue + rotation = layer.preset_rotation + if rotation is None or not rotation.paths: + continue + duration = max(0.001, float(runtime.preset_duration)) + count = int(math.floor(max(0.0, t_sec) / duration)) + if count == layer.list_switch_index: + continue + path = rotation.path_for(count) + layer.list_switch_index = count + if path is None: + continue + if ( + layer.auto_preset_path is not None + and path.resolve() == layer.auto_preset_path + ): + continue + _load_list_preset( + layer, + path, + preset_start_clean=runtime.preset_start_clean, + ) -def reanchor_timeline_preset_after_browse( +def reanchor_list_preset_after_browse( layer: StemLayer, session, t_sec: float, *, - rotation_set: PresetSwitchingRotationSet, - user_presets: list[str] | None = None, - shuffle: bool = DEFAULT_PRESET_SWITCHING_SHUFFLE, - shuffle_salt: int = DEFAULT_PRESET_SWITCHING_SHUFFLE_SALT, - cast_roles_default_role: CueRole = DEFAULT_CAST_ROLES_DEFAULT_ROLE, - preset_root: Path | None = None, + preset_list: list[str] | None = None, ) -> None: - """Keep the browsed preset; next on-transition advances from the following entry.""" + """Keep the browsed preset; next index advance starts from the following entry.""" pm = layer.pm pm.lock_preset(True) pm.set_hard_cut_enabled(False) - paths = _rotation_paths( - layer, - rotation_set=rotation_set, - user_presets=user_presets, - preset_root=preset_root, - cast_roles_default_role=cast_roles_default_role, - ) + paths = _preset_paths(preset_list) if not paths: - _clear_timeline_rotation(layer) + _clear_list_rotation(layer) return - count = 0 - if session.timeline.enabled: + index = 0 + runtime = session.layers.get(layer.slot) + if ( + runtime is not None + and runtime.preset_switching_trigger == "timeline" + ): hard_cut_fades, soft_cut_fades = _timeline_fade_groups(session) lane = session.timeline.lanes.get(layer.slot) or empty_lane() - count = lane_on_transition_count( - lane, - t_sec, - hard_cut_fades=hard_cut_fades, - soft_cut_fades=soft_cut_fades, + index = _timeline_list_index( + lane_on_transition_count( + lane, + t_sec, + hard_cut_fades=hard_cut_fades, + soft_cut_fades=soft_cut_fades, + ) ) + elif ( + runtime is not None + and runtime.preset_switching_trigger == "timer" + ): + duration = max(0.001, float(runtime.preset_duration)) + index = int(math.floor(max(0.0, t_sec) / duration)) browse_index = _anchor_index(layer, paths) - anchor = (browse_index - count) % len(paths) + anchor = (browse_index - index) % len(paths) layer.rotation_anchor = anchor - layer.timeline_switch_count = count - layer.preset_rotation = PresetRotation( - paths=tuple(paths), - shuffle=shuffle, - seed=layer_rotation_seed( - paths, slot=layer.slot, shuffle_salt=shuffle_salt - ), - anchor=anchor, - ) + layer.list_switch_index = index + layer.preset_rotation = PresetRotation(paths=tuple(paths), anchor=anchor) def load_manual_preset_clean( @@ -666,14 +435,7 @@ def load_manual_preset_clean( *, preset_start_clean: bool = DEFAULT_PRESET_START_CLEAN, ) -> None: - """Load the current browse preset with a guaranteed clean (black) boot. - - ``load_preset(smooth=False)`` inherits the previous preset's final frame as - projectM feedback state, so a preset that only develops when seeded looks - fine after a switch even when it cannot start from black. Manual browsing - forces ``preset_start_clean`` for this load so each preset boots from black, - then restores the layer's configured value for later auto-switch transitions. - """ + """Load the current browse preset with a guaranteed clean (black) boot.""" pm = layer.pm current = layer.playlist.current if current is None: @@ -684,8 +446,8 @@ def load_manual_preset_clean( layer.auto_preset_path = current.resolve() -def sync_manual_browse_with_user_defined_rotation(layer: StemLayer) -> None: - """Align user-defined rotation state after manual preset browse.""" +def sync_manual_browse_with_list(layer: StemLayer) -> None: + """Align list-switching state after manual preset browse (projectM trigger).""" current = layer.playlist.current if current is None: return @@ -789,11 +551,7 @@ def on_preset_loaded(path: Path) -> None: def _record_auto_preset(layer: StemLayer, path: Path) -> None: - """Track the playing auto-switch preset without moving browse selection. - - Browse ``playlist.index`` is the saved/manual preset; mutating it here - would dirty config during ordinary playback. - """ + """Track the playing auto-switch preset without moving browse selection.""" layer.auto_preset_path = path.resolve() @@ -808,3 +566,14 @@ def _sync_projectm_playlist_position(layer: StemLayer) -> None: if item is not None and item.resolve() == target: playlist.set_position(index, hard_cut=True) return + + +def _anchor_index(layer: StemLayer, paths: Sequence[Path]) -> int: + current = layer.playlist.current + if current is None or not paths: + return 0 + resolved = current.resolve() + for index, candidate in enumerate(paths): + if candidate.resolve() == resolved: + return index + return 0 diff --git a/cleave/viz/row_fields.py b/cleave/viz/row_fields.py index 9e34959..5e0b095 100644 --- a/cleave/viz/row_fields.py +++ b/cleave/viz/row_fields.py @@ -15,11 +15,11 @@ from cleave.config_schema import ( TIMELINE_FADE_DURATION_STEP, + VISUAL_LIMITER_RATIO_STEP, VISUAL_LIMITER_RELEASE_STEP, VISUAL_LIMITER_THRESHOLD_STEP, - cast_roles_default_role_display, - cast_roles_timeline_behaviour_display, clamp_timeline_fade_duration, + clamp_visual_limiter_ratio, clamp_visual_limiter_release, clamp_visual_limiter_threshold, cycle_timeline_placement_snap, @@ -27,8 +27,7 @@ hard_cut_enabled_display, preset_start_clean_display, preset_switching_display, - preset_switching_rotation_set_display, - preset_switching_shuffle_display, + preset_switching_trigger_display, timeline_crossfade_display, ui_fade_display, ) @@ -42,9 +41,9 @@ cycle_timeline_preset_conductor, timeline_preset_conductor_display, ) -from cleave.timeline_presets.reshuffle import ( - cycle_timeline_preset_reshuffle, - timeline_preset_reshuffle_display, +from cleave.timeline_presets.repopulate import ( + cycle_timeline_preset_repopulate, + timeline_preset_repopulate_display, ) from cleave.timeline_presets.crescendo import ( cycle_timeline_preset_crescendo, @@ -103,11 +102,18 @@ class RowFieldDef: header_suffix: str | None = None +def tree_branch_leading_spaces(depth: int) -> str: + """Leading spaces before a branch glyph for nested tree depth.""" + if depth <= 1: + return "" + return " " * (2 * (depth - 1)) + + def tree_branch_prefix(depth: int) -> str: """Branch glyph for tree depth; pixel indent comes from row_tree_indent_depth.""" if depth <= 0: return "" - return " " * (2 * (depth - 1)) + "└─ " + return tree_branch_leading_spaces(depth) + "└─ " def _format_settings_preview_quality( @@ -341,15 +347,15 @@ def _apply_timeline_preset_timeline_cuts( ) -def _format_timeline_preset_reshuffle( +def _format_timeline_preset_repopulate( state: TuningViewState, _desc: RowDescriptor ) -> str: - return timeline_preset_reshuffle_display( - state.render_timeline.timeline_preset_reshuffle + return timeline_preset_repopulate_display( + state.render_timeline.timeline_preset_repopulate ) -def _apply_timeline_preset_reshuffle( +def _apply_timeline_preset_repopulate( controls: TuningControls, _desc: RowDescriptor, forward: bool, @@ -357,8 +363,8 @@ def _apply_timeline_preset_reshuffle( _shift: bool, ) -> None: tl = controls.session.timeline - tl.timeline_preset_reshuffle = cycle_timeline_preset_reshuffle( - tl.timeline_preset_reshuffle, + tl.timeline_preset_repopulate = cycle_timeline_preset_repopulate( + tl.timeline_preset_repopulate, forward=forward, ) @@ -427,6 +433,25 @@ def _apply_visual_limiter_threshold( ) +def _format_visual_limiter_ratio( + state: TuningViewState, _desc: RowDescriptor +) -> str: + return f"{state.render_timeline.limiter.ratio:.1f}:1" + + +def _apply_visual_limiter_ratio( + controls: TuningControls, + _desc: RowDescriptor, + forward: bool, + _ctrl: bool, + _shift: bool, +) -> None: + del _ctrl, _shift + lim = controls.session.timeline.limiter + delta = VISUAL_LIMITER_RATIO_STEP if forward else -VISUAL_LIMITER_RATIO_STEP + lim.ratio = clamp_visual_limiter_ratio(round(lim.ratio + delta, 1)) + + def _format_visual_limiter_release( state: TuningViewState, _desc: RowDescriptor ) -> str: @@ -666,27 +691,11 @@ def _format_track_preset_switching_mode( return preset_switching_display(_track_block(state, desc).preset_switching) -def _format_track_preset_switching_rotation_set( +def _format_track_preset_switching_trigger( state: TuningViewState, desc: RowDescriptor ) -> str: - return preset_switching_rotation_set_display( - _track_block(state, desc).preset_switching_rotation_set - ) - - -def _format_track_cast_roles_timeline_behaviour( - state: TuningViewState, desc: RowDescriptor -) -> str: - return cast_roles_timeline_behaviour_display( - _track_block(state, desc).cast_roles_timeline_behaviour - ) - - -def _format_track_cast_roles_default_role( - state: TuningViewState, desc: RowDescriptor -) -> str: - return cast_roles_default_role_display( - _track_block(state, desc).cast_roles_default_role + return preset_switching_trigger_display( + _track_block(state, desc).preset_switching_trigger ) @@ -710,20 +719,6 @@ def _format_track_preset_start_clean( return preset_start_clean_display(_track_block(state, desc).preset_start_clean) -def _format_track_preset_switching_shuffle( - state: TuningViewState, desc: RowDescriptor -) -> str: - return preset_switching_shuffle_display( - _track_block(state, desc).preset_switching_shuffle - ) - - -def _format_track_preset_switching_seed( - state: TuningViewState, desc: RowDescriptor -) -> str: - return str(_track_block(state, desc).preset_switching_shuffle_salt) - - def _format_track_hard_cut_enabled( state: TuningViewState, desc: RowDescriptor ) -> str: @@ -966,19 +961,7 @@ def _apply_track_preset_switching_mode( controls._cycle_preset_switching(desc.slot, forward=forward) -def _apply_track_preset_switching_rotation_set( - controls: TuningControls, - desc: RowDescriptor, - forward: bool, - _ctrl: bool, - _shift: bool, -) -> None: - if desc.slot is None: - return - controls._cycle_preset_switching_rotation_set(desc.slot, forward=forward) - - -def _apply_track_cast_roles_timeline_behaviour( +def _apply_track_preset_switching_trigger( controls: TuningControls, desc: RowDescriptor, forward: bool, @@ -987,19 +970,7 @@ def _apply_track_cast_roles_timeline_behaviour( ) -> None: if desc.slot is None: return - controls._cycle_cast_roles_timeline_behaviour(desc.slot, forward=forward) - - -def _apply_track_cast_roles_default_role( - controls: TuningControls, - desc: RowDescriptor, - forward: bool, - _ctrl: bool, - _shift: bool, -) -> None: - if desc.slot is None: - return - controls._cycle_cast_roles_default_role(desc.slot, forward=forward) + controls._cycle_preset_switching_trigger(desc.slot, forward=forward) def _apply_track_preset_duration( @@ -1038,15 +1009,6 @@ def _apply_track_preset_start_clean( controls._cycle_preset_start_clean(desc.slot, forward=forward) -def _apply_track_preset_switching_shuffle( - controls: TuningControls, desc: RowDescriptor, forward: bool, _ctrl: bool, - _shift: bool, -) -> None: - if desc.slot is None: - return - controls._cycle_preset_switching_shuffle(desc.slot, forward=forward) - - def _apply_track_hard_cut_enabled( controls: TuningControls, desc: RowDescriptor, forward: bool, _ctrl: bool, _shift: bool, @@ -1543,12 +1505,18 @@ def _format_track_preset(state: TuningViewState, desc: RowDescriptor) -> str: return _track_block(state, desc).preset_label -def _format_track_user_preset_item( +def _format_track_preset_list_count( + state: TuningViewState, desc: RowDescriptor +) -> str: + return str(len(_track_block(state, desc).preset_list)) + + +def _format_track_preset_list_item( state: TuningViewState, desc: RowDescriptor ) -> str: assert desc.preset_index is not None block = _track_block(state, desc) - return block.user_preset_labels[desc.preset_index] + return block.preset_list_labels[desc.preset_index] def _format_song_markers_count(state: TuningViewState, _desc: RowDescriptor) -> str: @@ -1760,53 +1728,36 @@ def _apply_transport( format_value=_format_track_beat, apply_horizontal=_apply_track_beat, ), - RowKind.TRACK_USER_PRESETS: RowFieldDef( - panel_label="user presets", + RowKind.TRACK_PRESET_SWITCHING_TRIGGER: RowFieldDef( + panel_label="trigger", + present_style=RowPresentStyle.LABELED_VALUE, + format_value=_format_track_preset_switching_trigger, + apply_horizontal=_apply_track_preset_switching_trigger, + ), + RowKind.TRACK_PRESET_LIST: RowFieldDef( + panel_label="preset list", present_style=RowPresentStyle.EXPAND_SUBHEADER, + format_value=_format_track_preset_list_count, apply_horizontal=_apply_expand_subheader, ), - RowKind.TRACK_USER_PRESET_ITEM: RowFieldDef( + RowKind.TRACK_PRESET_LIST_ITEM: RowFieldDef( panel_label="preset", present_style=RowPresentStyle.PATH_ICON, - format_value=_format_track_user_preset_item, + format_value=_format_track_preset_list_item, apply_horizontal=_noop_horizontal, ), - RowKind.TRACK_USER_PRESET_ADD: RowFieldDef( - panel_label="Add Current Preset", + RowKind.TRACK_PRESET_LIST_ADD: RowFieldDef( + panel_label="add current preset", present_style=RowPresentStyle.FULL_LINE, apply_horizontal=_noop_horizontal, ), - RowKind.TRACK_PRESET_SWITCHING_ROTATION_SET: RowFieldDef( - panel_label="rotation set", - present_style=RowPresentStyle.LABELED_VALUE, - format_value=_format_track_preset_switching_rotation_set, - apply_horizontal=_apply_track_preset_switching_rotation_set, - ), - RowKind.TRACK_CAST_ROLES_TIMELINE_BEHAVIOUR: RowFieldDef( - panel_label="timeline behaviour", - present_style=RowPresentStyle.LABELED_VALUE, - format_value=_format_track_cast_roles_timeline_behaviour, - apply_horizontal=_apply_track_cast_roles_timeline_behaviour, - ), - RowKind.TRACK_CAST_ROLES_DEFAULT_ROLE: RowFieldDef( - panel_label="default role", - present_style=RowPresentStyle.LABELED_VALUE, - format_value=_format_track_cast_roles_default_role, - apply_horizontal=_apply_track_cast_roles_default_role, - ), - RowKind.TRACK_PRESET_SWITCHING_SHUFFLE: RowFieldDef( - panel_label="shuffle", - present_style=RowPresentStyle.LABELED_VALUE, - format_value=_format_track_preset_switching_shuffle, - apply_horizontal=_apply_track_preset_switching_shuffle, - ), - RowKind.TRACK_PRESET_SWITCHING_SEED: RowFieldDef( - panel_label="seed", - present_style=RowPresentStyle.ACTION_PARAMETER, - format_value=_format_track_preset_switching_seed, + RowKind.TRACK_PRESET_LIST_POPULATE: RowFieldDef( + panel_label="populate presets", + present_style=RowPresentStyle.FULL_LINE, + apply_horizontal=_noop_horizontal, ), RowKind.TRACK_PRESET_DURATION: RowFieldDef( - panel_label="preset duration", + panel_label="duration", present_style=RowPresentStyle.LABELED_VALUE, format_value=_format_track_preset_duration, apply_horizontal=_apply_track_preset_duration, @@ -2206,11 +2157,11 @@ def _apply_transport( format_value=_format_timeline_preset_timeline_cuts, apply_horizontal=_apply_timeline_preset_timeline_cuts, ), - RowKind.TIMELINE_PRESET_RESHUFFLE: RowFieldDef( - panel_label="reshuffle", + RowKind.TIMELINE_PRESET_REPOPULATE: RowFieldDef( + panel_label="re-populate preset lists", present_style=RowPresentStyle.LABELED_VALUE, - format_value=_format_timeline_preset_reshuffle, - apply_horizontal=_apply_timeline_preset_reshuffle, + format_value=_format_timeline_preset_repopulate, + apply_horizontal=_apply_timeline_preset_repopulate, ), RowKind.TIMELINE_PRESET_CONDUCTOR: RowFieldDef( panel_label="conductor", @@ -2234,6 +2185,12 @@ def _apply_transport( format_value=_format_visual_limiter_threshold, apply_horizontal=_apply_visual_limiter_threshold, ), + RowKind.TIMELINE_VISUAL_LIMITER_RATIO: RowFieldDef( + panel_label="ratio", + present_style=RowPresentStyle.LABELED_VALUE, + format_value=_format_visual_limiter_ratio, + apply_horizontal=_apply_visual_limiter_ratio, + ), RowKind.TIMELINE_VISUAL_LIMITER_RELEASE: RowFieldDef( panel_label="release", present_style=RowPresentStyle.LABELED_VALUE, diff --git a/cleave/viz/row_sections.py b/cleave/viz/row_sections.py index 3f283e6..daed00d 100644 --- a/cleave/viz/row_sections.py +++ b/cleave/viz/row_sections.py @@ -125,10 +125,10 @@ def _toggle_effects_header(controls: TuningControls, slot: str | None, forward: controls._set_effects_expanded(slot, forward) -def _toggle_user_presets(controls: TuningControls, slot: str | None, forward: bool) -> None: +def _toggle_preset_list(controls: TuningControls, slot: str | None, forward: bool) -> None: if slot is None: return - controls._set_user_presets_expanded(slot, forward) + controls._set_preset_list_expanded(slot, forward) def _toggle_song_markers(controls: TuningControls, _slot: str | None, forward: bool) -> None: @@ -359,7 +359,7 @@ def _track_header_expanded(state: TuningViewState, slot: str | None) -> bool: def _track_preset_switching_expanded(state: TuningViewState, slot: str | None) -> bool: if slot is None: return True - return state.tracks[slot].preset_switching in ("projectm", "timeline") + return state.tracks[slot].preset_switching == "on" def _track_effects_expanded(state: TuningViewState, slot: str | None) -> bool: @@ -368,10 +368,10 @@ def _track_effects_expanded(state: TuningViewState, slot: str | None) -> bool: return state.tracks[slot].effects_expanded -def _user_presets_expanded(state: TuningViewState, slot: str | None) -> bool: +def _preset_list_expanded(state: TuningViewState, slot: str | None) -> bool: if slot is None: return True - return state.tracks[slot].user_presets_expanded + return state.tracks[slot].preset_list_expanded def _song_markers_expanded(state: TuningViewState, _slot: str | None) -> bool: @@ -397,7 +397,7 @@ def _append_track_effect_rows( ) -def _append_user_preset_rows( +def _append_preset_list_rows( row_list: list[RowDescriptor], state: TuningViewState, slot: str | None, @@ -405,15 +405,16 @@ def _append_user_preset_rows( if slot is None: return block = state.tracks[slot] - for index, _path in enumerate(block.user_presets): + for index, _path in enumerate(block.preset_list): row_list.append( RowDescriptor( - RowKind.TRACK_USER_PRESET_ITEM, + RowKind.TRACK_PRESET_LIST_ITEM, slot=slot, preset_index=index, ) ) - row_list.append(RowDescriptor(RowKind.TRACK_USER_PRESET_ADD, slot=slot)) + row_list.append(RowDescriptor(RowKind.TRACK_PRESET_LIST_ADD, slot=slot)) + row_list.append(RowDescriptor(RowKind.TRACK_PRESET_LIST_POPULATE, slot=slot)) def _append_song_marker_rows( @@ -687,22 +688,20 @@ def _build_render_overlay_card_section( ), ) -def _preset_switching_user_defined(state: TuningViewState, desc: RowDescriptor) -> bool: - if desc.slot is None: - return False - return state.tracks[desc.slot].preset_switching_rotation_set == "user_defined" - - -def _preset_switching_cast_roles(state: TuningViewState, desc: RowDescriptor) -> bool: +def _preset_switching_projectm_trigger( + state: TuningViewState, desc: RowDescriptor +) -> bool: if desc.slot is None: return False - return state.tracks[desc.slot].preset_switching_rotation_set == "cast_roles" + return state.tracks[desc.slot].preset_switching_trigger == "projectm" -def _preset_switching_projectm(state: TuningViewState, desc: RowDescriptor) -> bool: +def _preset_switching_uses_duration( + state: TuningViewState, desc: RowDescriptor +) -> bool: if desc.slot is None: return False - return state.tracks[desc.slot].preset_switching == "projectm" + return state.tracks[desc.slot].preset_switching_trigger != "timeline" def _hard_cut_enabled(state: TuningViewState, desc: RowDescriptor) -> bool: @@ -711,12 +710,6 @@ def _hard_cut_enabled(state: TuningViewState, desc: RowDescriptor) -> bool: return state.tracks[desc.slot].hard_cut_enabled -def _preset_switching_shuffle_on(state: TuningViewState, desc: RowDescriptor) -> bool: - if desc.slot is None: - return False - return state.tracks[desc.slot].preset_switching_shuffle - - HARD_CUT_ENABLED = ConditionalRowsDef( name="hard_cut_enabled", predicate=_hard_cut_enabled, @@ -726,42 +719,27 @@ def _preset_switching_shuffle_on(state: TuningViewState, desc: RowDescriptor) -> ), ) -PRESET_SWITCHING_SHUFFLE_ON = ConditionalRowsDef( - name="preset_switching_shuffle_on", - predicate=_preset_switching_shuffle_on, - children=(SectionNode(leaf_kind=RowKind.TRACK_PRESET_SWITCHING_SEED),), - child_indent_offset=1, -) - -USER_PRESETS_SECTION = ExpandSectionDef( - header_kind=RowKind.TRACK_USER_PRESETS, +PRESET_LIST_SECTION = ExpandSectionDef( + header_kind=RowKind.TRACK_PRESET_LIST, context="per_slot", - read_expanded=_user_presets_expanded, - toggle=_toggle_user_presets, + read_expanded=_preset_list_expanded, + toggle=_toggle_preset_list, children=(), - append_dynamic_children=_append_user_preset_rows, + append_dynamic_children=_append_preset_list_rows, ) -PRESET_SWITCHING_USER_DEFINED = ConditionalRowsDef( - name="preset_switching_user_defined", - predicate=_preset_switching_user_defined, - children=(SectionNode(expand=USER_PRESETS_SECTION),), -) - -PRESET_SWITCHING_CAST_ROLES = ConditionalRowsDef( - name="preset_switching_cast_roles", - predicate=_preset_switching_cast_roles, +PRESET_SWITCHING_USES_DURATION = ConditionalRowsDef( + name="preset_switching_uses_duration", + predicate=_preset_switching_uses_duration, children=( - SectionNode(leaf_kind=RowKind.TRACK_CAST_ROLES_TIMELINE_BEHAVIOUR), - SectionNode(leaf_kind=RowKind.TRACK_CAST_ROLES_DEFAULT_ROLE), + SectionNode(leaf_kind=RowKind.TRACK_PRESET_DURATION), ), ) -PRESET_SWITCHING_PROJECTM = ConditionalRowsDef( - name="preset_switching_projectm", - predicate=_preset_switching_projectm, +PRESET_SWITCHING_PROJECTM_TRIGGER = ConditionalRowsDef( + name="preset_switching_projectm_trigger", + predicate=_preset_switching_projectm_trigger, children=( - SectionNode(leaf_kind=RowKind.TRACK_PRESET_DURATION), SectionNode(leaf_kind=RowKind.TRACK_EASTER_EGG), SectionNode(leaf_kind=RowKind.TRACK_SOFT_CUT_DURATION), SectionNode(leaf_kind=RowKind.TRACK_HARD_CUT_ENABLED), @@ -775,13 +753,11 @@ def _preset_switching_shuffle_on(state: TuningViewState, desc: RowDescriptor) -> read_expanded=_track_preset_switching_expanded, toggle=_toggle_preset_switching, children=( - SectionNode(leaf_kind=RowKind.TRACK_PRESET_SWITCHING_ROTATION_SET), - SectionNode(conditional=PRESET_SWITCHING_USER_DEFINED), - SectionNode(conditional=PRESET_SWITCHING_CAST_ROLES), - SectionNode(leaf_kind=RowKind.TRACK_PRESET_SWITCHING_SHUFFLE), - SectionNode(conditional=PRESET_SWITCHING_SHUFFLE_ON), + SectionNode(leaf_kind=RowKind.TRACK_PRESET_SWITCHING_TRIGGER), + SectionNode(conditional=PRESET_SWITCHING_USES_DURATION), + SectionNode(conditional=PRESET_SWITCHING_PROJECTM_TRIGGER), SectionNode(leaf_kind=RowKind.TRACK_PRESET_START_CLEAN), - SectionNode(conditional=PRESET_SWITCHING_PROJECTM), + SectionNode(expand=PRESET_LIST_SECTION), ), ) @@ -913,7 +889,7 @@ def _timeline_soft_cut_fades_enabled( SectionNode(leaf_kind=RowKind.TIMELINE_PRESET_CUE_SNAP), SectionNode(leaf_kind=RowKind.TIMELINE_PRESET_SONG_MARKER_SNAP), SectionNode(leaf_kind=RowKind.TIMELINE_PRESET_TIMELINE_CUTS), - SectionNode(leaf_kind=RowKind.TIMELINE_PRESET_RESHUFFLE), + SectionNode(leaf_kind=RowKind.TIMELINE_PRESET_REPOPULATE), SectionNode(leaf_kind=RowKind.TIMELINE_PRESET_CONDUCTOR), SectionNode(leaf_kind=RowKind.TIMELINE_PRESETS), ), @@ -931,6 +907,7 @@ def _visual_limiter_enabled( predicate=_visual_limiter_enabled, children=( SectionNode(leaf_kind=RowKind.TIMELINE_VISUAL_LIMITER_THRESHOLD), + SectionNode(leaf_kind=RowKind.TIMELINE_VISUAL_LIMITER_RATIO), SectionNode(leaf_kind=RowKind.TIMELINE_VISUAL_LIMITER_RELEASE), ), ) @@ -1076,8 +1053,9 @@ def kinds_in_expand_section(section: ExpandSectionDef) -> frozenset[RowKind]: kinds_in_expand_section(TRACK_PRESET_SWITCHING_SECTION) - {RowKind.TRACK_PRESET_SWITCHING} | { - RowKind.TRACK_USER_PRESET_ITEM, - RowKind.TRACK_USER_PRESET_ADD, + RowKind.TRACK_PRESET_LIST_ITEM, + RowKind.TRACK_PRESET_LIST_ADD, + RowKind.TRACK_PRESET_LIST_POPULATE, } ) RENDER_TIMELINE_SECTION_KINDS = frozenset( @@ -1092,11 +1070,12 @@ def kinds_in_expand_section(section: ExpandSectionDef) -> frozenset[RowKind]: RowKind.TIMELINE_PRESET_CUE_SNAP, RowKind.TIMELINE_PRESET_SONG_MARKER_SNAP, RowKind.TIMELINE_PRESET_TIMELINE_CUTS, - RowKind.TIMELINE_PRESET_RESHUFFLE, + RowKind.TIMELINE_PRESET_REPOPULATE, RowKind.TIMELINE_PRESET_CONDUCTOR, RowKind.TIMELINE_PRESETS, RowKind.TIMELINE_VISUAL_LIMITER_HEADER, RowKind.TIMELINE_VISUAL_LIMITER_THRESHOLD, + RowKind.TIMELINE_VISUAL_LIMITER_RATIO, RowKind.TIMELINE_VISUAL_LIMITER_RELEASE, RowKind.TIMELINE_RESET, RowKind.TIMELINE_BEAT_BAR_GRID_HEADER, @@ -1157,8 +1136,12 @@ def _build_row_tree_indent_depth() -> dict[RowKind, int]: if node.expand is not None: _assign_expand_indent_depth(depths, node.expand, 0) depths[RowKind.TRACK_EFFECT] = 2 - depths[RowKind.TRACK_USER_PRESET_ITEM] = 7 - depths[RowKind.TRACK_USER_PRESET_ADD] = 3 + # Dynamic preset list rows nest under the preset list header. + preset_list_depth = depths[RowKind.TRACK_PRESET_LIST] + preset_list_item_depth = preset_list_depth + 1 + depths[RowKind.TRACK_PRESET_LIST_ITEM] = preset_list_item_depth + depths[RowKind.TRACK_PRESET_LIST_ADD] = preset_list_item_depth + depths[RowKind.TRACK_PRESET_LIST_POPULATE] = preset_list_item_depth depths[RowKind.SONG_MARKERS_HEADER] = 1 depths[RowKind.SONG_MARKER_ITEM] = 2 depths[RowKind.TIMELINE_RESET] = 1 @@ -1174,6 +1157,7 @@ def _build_row_tree_indent_depth() -> dict[RowKind, int]: depths[RowKind.TIMELINE_SOFT_CUT_FADE_OUT] = 3 depths[RowKind.TIMELINE_SOFT_CUT_CROSSFADE] = 3 depths[RowKind.TIMELINE_VISUAL_LIMITER_THRESHOLD] = 2 + depths[RowKind.TIMELINE_VISUAL_LIMITER_RATIO] = 2 depths[RowKind.TIMELINE_VISUAL_LIMITER_RELEASE] = 2 return depths diff --git a/cleave/viz/row_semantics.py b/cleave/viz/row_semantics.py index eb4982b..47fe53b 100644 --- a/cleave/viz/row_semantics.py +++ b/cleave/viz/row_semantics.py @@ -7,14 +7,12 @@ from cleave.blend_modes import BLEND_MODE_HELP_ENTRIES from cleave.config_schema import ( - CAST_ROLES_DEFAULT_ROLE_HELP_ENTRIES, - CAST_ROLES_TIMELINE_BEHAVIOUR_HELP_ENTRIES, CHROMA_BOOST_APPLY_MODE_HELP_ENTRIES, CHROMA_BOOST_VARIANT_HELP_ENTRIES, HIGHLIGHT_ROLLOFF_APPLY_MODE_HELP_ENTRIES, HIGHLIGHT_ROLLOFF_CURVE_HELP_ENTRIES, PRESET_SWITCHING_MODE_HELP_ENTRIES, - PRESET_SWITCHING_ROTATION_SET_HELP_ENTRIES, + PRESET_SWITCHING_TRIGGER_HELP_ENTRIES, EDITOR_PREVIEW_QUALITY_HELP_ENTRIES, RENDER_OVERLAY_ANIMATION_TYPE_HELP_ENTRIES, RENDER_OVERLAY_SLIDE_DIRECTION_HELP_ENTRIES, @@ -31,14 +29,11 @@ class RowKind(Enum): TRACK_PRESET_DIR = auto() TRACK_PRESET = auto() TRACK_PRESET_SWITCHING = auto() - TRACK_USER_PRESETS = auto() - TRACK_USER_PRESET_ITEM = auto() - TRACK_USER_PRESET_ADD = auto() - TRACK_PRESET_SWITCHING_ROTATION_SET = auto() - TRACK_CAST_ROLES_TIMELINE_BEHAVIOUR = auto() - TRACK_CAST_ROLES_DEFAULT_ROLE = auto() - TRACK_PRESET_SWITCHING_SHUFFLE = auto() - TRACK_PRESET_SWITCHING_SEED = auto() + TRACK_PRESET_SWITCHING_TRIGGER = auto() + TRACK_PRESET_LIST = auto() + TRACK_PRESET_LIST_ITEM = auto() + TRACK_PRESET_LIST_ADD = auto() + TRACK_PRESET_LIST_POPULATE = auto() TRACK_PRESET_DURATION = auto() TRACK_SOFT_CUT_DURATION = auto() TRACK_EASTER_EGG = auto() @@ -112,11 +107,12 @@ class RowKind(Enum): TIMELINE_PRESET_CUE_SNAP = auto() TIMELINE_PRESET_SONG_MARKER_SNAP = auto() TIMELINE_PRESET_TIMELINE_CUTS = auto() - TIMELINE_PRESET_RESHUFFLE = auto() + TIMELINE_PRESET_REPOPULATE = auto() TIMELINE_PRESET_CONDUCTOR = auto() TIMELINE_PRESETS = auto() TIMELINE_VISUAL_LIMITER_HEADER = auto() TIMELINE_VISUAL_LIMITER_THRESHOLD = auto() + TIMELINE_VISUAL_LIMITER_RATIO = auto() TIMELINE_VISUAL_LIMITER_RELEASE = auto() TIMELINE_RESET = auto() TIMELINE_BEAT_BAR_GRID_HEADER = auto() @@ -255,91 +251,67 @@ class RowBehavior: repeatable=True, parent_group="track", help_title="Preset switching", - help_entries=(("Left/Right", "cycle mode"),), + help_entries=(("Left/Right", "off / on"),), help_description=( - "Controls how and when presets change during playback.", + "When on, advances through this layer's ordered preset list.", + "The trigger chooses timer, projectM, or timeline on-transitions.", ), help_mode_entries=PRESET_SWITCHING_MODE_HELP_ENTRIES, ), - RowKind.TRACK_USER_PRESETS: RowBehavior( + RowKind.TRACK_PRESET_SWITCHING_TRIGGER: RowBehavior( + RowAffordance.VALUE_STEP, + repeatable=True, + parent_group="track", + help_title="Trigger", + help_entries=(("Left/Right", "cycle trigger"),), + help_description=( + "How the ordered preset list advances while switching is on.", + "Timeline trigger needs Render: TIMELINE enabled.", + ), + help_mode_entries=PRESET_SWITCHING_TRIGGER_HELP_ENTRIES, + ), + RowKind.TRACK_PRESET_LIST: RowBehavior( RowAffordance.EXPAND, is_sub_header=True, parent_group="track", - help_title="user presets", + help_title="preset list", help_description=( - "Presets in the rotation set for user-defined switching.", - "Expand to list entries and add from the current browse position.", + "Ordered presets used for automatic switching on this layer.", + "Expand to reorder, delete, or add the current browse preset.", ), ), - RowKind.TRACK_USER_PRESET_ITEM: RowBehavior( + RowKind.TRACK_PRESET_LIST_ITEM: RowBehavior( RowAffordance.PATH_PRESET, parent_group="track", - help_title="user preset entry", + can_enter_move_mode=True, + help_title="preset list entry", help_description=( - "Preset in the user-defined rotation set for this layer.", + "Preset in this layer's switching list.", "[F/B] indicates favourited/blacklisted.", "[R:X] indicates the chosen role.", ), help_mode_entries=CUE_ROLE_MARKER_HELP_ENTRIES, ), - RowKind.TRACK_USER_PRESET_ADD: RowBehavior( + RowKind.TRACK_PRESET_LIST_ADD: RowBehavior( RowAffordance.ACTION, parent_group="track", blocked_by_section_lock=True, help_title="Add Current Preset", help_description=( - "Add the layer's current preset to the user-defined rotation set.", - "Copies the preset file into the project presets folder.", - "U on the preset dir or preset file row is the same action.", - ), - ), - RowKind.TRACK_PRESET_SWITCHING_ROTATION_SET: RowBehavior( - RowAffordance.VALUE_STEP, - parent_group="track", - help_title="Rotation set", - help_entries=(("Left/Right", "cycle rotation set"),), - help_description=(), - help_mode_entries=PRESET_SWITCHING_ROTATION_SET_HELP_ENTRIES, - ), - RowKind.TRACK_CAST_ROLES_TIMELINE_BEHAVIOUR: RowBehavior( - RowAffordance.VALUE_STEP, - repeatable=True, - parent_group="track", - help_title="Timeline behaviour", - help_entries=(("Left/Right", "cycle behaviour"),), - help_description=(), - help_mode_entries=CAST_ROLES_TIMELINE_BEHAVIOUR_HELP_ENTRIES, - ), - RowKind.TRACK_CAST_ROLES_DEFAULT_ROLE: RowBehavior( - RowAffordance.VALUE_STEP, - repeatable=True, - parent_group="track", - help_title="Default role", - help_entries=(("Left/Right", "cycle role"),), - help_description=(), - help_mode_entries=CAST_ROLES_DEFAULT_ROLE_HELP_ENTRIES, - ), - RowKind.TRACK_PRESET_SWITCHING_SHUFFLE: RowBehavior( - RowAffordance.VALUE_STEP, - repeatable=True, - parent_group="track", - help_title="Shuffle", - help_entries=(("Left/Right", "off / on"),), - help_description=( - "When on, auto switching uses a fixed shuffled order", - "(same in live preview and offline render).", - "Use the seed row to roll a new order.", + "Add the layer's current browse preset to the end of the list.", + "Copies the preset file into the project user-presets folder.", + "U on any row in the layer is the same action.", ), ), - RowKind.TRACK_PRESET_SWITCHING_SEED: RowBehavior( + RowKind.TRACK_PRESET_LIST_POPULATE: RowBehavior( RowAffordance.ACTION, parent_group="track", blocked_by_section_lock=True, - help_title="Seed", - help_entries=(("Enter", "generate a new seed"),), + help_title="Populate Presets", help_description=( - "Current shuffle seed for this layer.", - "Enter rolls a new seed and rebuilds the shuffled preset order.", + "Replace the entire preset list from the current directory " + "(random or sequential) or random cue marker role pools.", + "Enter opens a choice modal.", ), ), RowKind.TRACK_PRESET_DURATION: RowBehavior( @@ -349,7 +321,7 @@ class RowBehavior: help_title="Preset duration", help_entries=(("Left/Right", "step value"),), help_description=( - "How long a preset plays before projectM transitions to the next.", + "Seconds between timer advances, and projectM preset duration.", ), ), RowKind.TRACK_SOFT_CUT_DURATION: RowBehavior( @@ -952,7 +924,7 @@ class RowBehavior: is_sub_header=True, help_title="Timeline preset", help_description=( - "Stage character, crescendo, density, reshuffle, and conductor, then", + "Stage character, crescendo, density, re-populate, and conductor, then", "apply a randomly generated timeline preset. Overwrites the current timeline.", ), ), @@ -1023,15 +995,16 @@ class RowBehavior: "By marker sets soft everywhere then hard on song markers.", ), ), - RowKind.TIMELINE_PRESET_RESHUFFLE: RowBehavior( + RowKind.TIMELINE_PRESET_REPOPULATE: RowBehavior( RowAffordance.VALUE_STEP, navigable=True, blocked_by_section_lock=True, - help_title="Reshuffle", - help_entries=(("Left/Right", "toggle reshuffle on/off"),), + help_title="Re-populate Preset Lists", + help_entries=(("Left/Right", "cycle re-populate mode"),), help_description=( - "When on, Apply rolls a new shuffle seed for every layer.", - "When off, each layer keeps its existing seed (presets unchanged).", + "When Apply runs, optionally rebuild preset lists on layers with", + "timeline-trigger switching: cue roles, directory random, or", + "directory sequential. Choose no to leave lists unchanged.", ), ), RowKind.TIMELINE_PRESET_CONDUCTOR: RowBehavior( @@ -1053,7 +1026,7 @@ class RowBehavior: help_entries=(("Enter", "apply timeline preset"),), help_description=( "Apply the staged character, crescendo, density, snaps, cuts,", - "reshuffle, and conductor. This overwrites the current timeline.", + "re-populate, and conductor. This overwrites the current timeline.", ), ), RowKind.TIMELINE_VISUAL_LIMITER_HEADER: RowBehavior( @@ -1064,7 +1037,7 @@ class RowBehavior: help_entries=(("Left/Right", "disable / enable"),), help_description=( "Duck busy stacked layers using post-composite busyness.", - "When enabled, threshold and release rows appear below.", + "When enabled, threshold, ratio, and release rows appear below.", ), ), RowKind.TIMELINE_VISUAL_LIMITER_THRESHOLD: RowBehavior( @@ -1078,8 +1051,22 @@ class RowBehavior: ("Right", "increase threshold"), ), help_description=( - "Busyness level that starts ducking hot layers.", - "Off-threshold stays a fixed gap below this value.", + "Busyness level above which compression engages.", + ), + ), + RowKind.TIMELINE_VISUAL_LIMITER_RATIO: RowBehavior( + RowAffordance.VALUE_STEP, + navigable=True, + repeatable=True, + blocked_by_section_lock=True, + help_title="Visual limiter ratio", + help_entries=( + ("Left", "decrease ratio"), + ("Right", "increase ratio"), + ), + help_description=( + "Compression aggressiveness above the threshold.", + "Higher ratios duck hot layers more.", ), ), RowKind.TIMELINE_VISUAL_LIMITER_RELEASE: RowBehavior( @@ -1089,12 +1076,12 @@ class RowBehavior: blocked_by_section_lock=True, help_title="Visual limiter release", help_entries=( - ("Left", "decrease release ramp"), - ("Right", "increase release ramp"), + ("Left", "decrease release"), + ("Right", "increase release"), ), help_description=( - "Playhead seconds to ramp ducked layers back to full opacity.", - "Release hold before the ramp scales with this value.", + "Envelope release time constant in seconds.", + "Controls how quickly ducking eases off.", ), ), RowKind.TIMELINE_RESET: RowBehavior( @@ -1500,11 +1487,15 @@ class RowBehavior: ) TRACK_LOCK_KINDS = TRACK_SUB_ROW_KINDS | frozenset({RowKind.TRACK_HEADER}) TRACK_EFFECT_SUB_ROW_KINDS = frozenset({RowKind.TRACK_EFFECT}) -TRACK_USER_PRESET_SUB_ROW_KINDS = frozenset( - {RowKind.TRACK_USER_PRESET_ITEM, RowKind.TRACK_USER_PRESET_ADD} +TRACK_PRESET_LIST_SUB_ROW_KINDS = frozenset( + { + RowKind.TRACK_PRESET_LIST_ITEM, + RowKind.TRACK_PRESET_LIST_ADD, + RowKind.TRACK_PRESET_LIST_POPULATE, + } ) SONG_MARKER_SUB_ROW_KINDS = frozenset({RowKind.SONG_MARKER_ITEM}) -PRESET_FILE_ROW_KINDS = frozenset({RowKind.TRACK_PRESET, RowKind.TRACK_USER_PRESET_ITEM}) +PRESET_FILE_ROW_KINDS = frozenset({RowKind.TRACK_PRESET, RowKind.TRACK_PRESET_LIST_ITEM}) _SECTION_LOCK_BLOCKING_AFFORDANCES = frozenset( { @@ -1652,8 +1643,8 @@ def section_header_descriptor(desc: RowDescriptor) -> RowDescriptor: kind = desc.kind if kind in TRACK_EFFECT_SUB_ROW_KINDS: return RowDescriptor(RowKind.TRACK_EFFECTS_HEADER, slot=desc.slot) - if kind in TRACK_USER_PRESET_SUB_ROW_KINDS: - return RowDescriptor(RowKind.TRACK_USER_PRESETS, slot=desc.slot) + if kind in TRACK_PRESET_LIST_SUB_ROW_KINDS: + return RowDescriptor(RowKind.TRACK_PRESET_LIST, slot=desc.slot) if kind in SONG_MARKER_SUB_ROW_KINDS: return RowDescriptor(RowKind.SONG_MARKERS_HEADER) return desc diff --git a/cleave/viz/session.py b/cleave/viz/session.py index 830b728..d001937 100644 --- a/cleave/viz/session.py +++ b/cleave/viz/session.py @@ -17,12 +17,8 @@ ) from cleave.config_schema import ( DEFAULT_BEAT_SENSITIVITY, - DEFAULT_CAST_ROLES_DEFAULT_ROLE, - DEFAULT_CAST_ROLES_TIMELINE_BEHAVIOUR, DEFAULT_PRESET_SWITCHING, - DEFAULT_PRESET_SWITCHING_ROTATION_SET, - DEFAULT_PRESET_SWITCHING_SHUFFLE, - DEFAULT_PRESET_SWITCHING_SHUFFLE_SALT, + DEFAULT_PRESET_SWITCHING_TRIGGER, DEFAULT_PRESET_DURATION, DEFAULT_SOFT_CUT_DURATION, DEFAULT_HARD_CUT_DURATION, @@ -37,12 +33,12 @@ DEFAULT_TIMELINE_PLACEMENT_SNAP, DEFAULT_VISUAL_LIMITER_ENABLED, DEFAULT_VISUAL_LIMITER_THRESHOLD, + DEFAULT_VISUAL_LIMITER_RATIO, DEFAULT_VISUAL_LIMITER_RELEASE, - CastRolesTimelineBehaviour, HighlightRolloffApplyMode, HighlightRolloffCurve, PresetSwitchingMode, - PresetSwitchingRotationSet, + PresetSwitchingTrigger, TimelinePlacementSnap, default_render_overlay_animation_runtime_values, default_render_overlay_closing_animation_runtime_values, @@ -52,7 +48,6 @@ default_chroma_boost_runtime_values, default_render_post_fx_runtime_values, ) -from cleave.cue_roles import CueRole from cleave.extract import StemSource from cleave.preset_playlist import PresetPlaylist, preset_browse_floor from cleave.projectm_health import PresetSkipNotifyTracker, ProjectMLogNotifyTracker @@ -60,7 +55,6 @@ from cleave.blend_modes import BlendMode from cleave.timeline_presets.characters import DEFAULT_TIMELINE_PRESET_KIND from cleave.timeline_presets.conductor import DEFAULT_TIMELINE_PRESET_CONDUCTOR -from cleave.timeline_presets.reshuffle import DEFAULT_TIMELINE_PRESET_RESHUFFLE from cleave.timeline_presets.crescendo import CrescendoTarget from cleave.timeline_presets.cue_snap import ( DEFAULT_TIMELINE_PRESET_CUE_SNAP, @@ -70,6 +64,10 @@ DEFAULT_TIMELINE_PRESET_DENSITY, TimelinePresetDensity, ) +from cleave.timeline_presets.repopulate import ( + DEFAULT_TIMELINE_PRESET_REPOPULATE, + TimelinePresetRepopulate, +) from cleave.timeline_presets.song_marker_snap import ( DEFAULT_TIMELINE_PRESET_SONG_MARKER_SNAP, TimelinePresetSongMarkerSnap, @@ -248,6 +246,7 @@ def default_timeline_fade_group_runtime() -> TimelineFadeGroupRuntime: class VisualLimiterRuntime: enabled: bool = DEFAULT_VISUAL_LIMITER_ENABLED threshold: float = DEFAULT_VISUAL_LIMITER_THRESHOLD + ratio: float = DEFAULT_VISUAL_LIMITER_RATIO release: float = DEFAULT_VISUAL_LIMITER_RELEASE @@ -293,7 +292,9 @@ class TimelineRuntime: timeline_preset_timeline_cuts: TimelinePresetTimelineCuts = ( DEFAULT_TIMELINE_PRESET_TIMELINE_CUTS ) - timeline_preset_reshuffle: bool = DEFAULT_TIMELINE_PRESET_RESHUFFLE + timeline_preset_repopulate: TimelinePresetRepopulate = ( + DEFAULT_TIMELINE_PRESET_REPOPULATE + ) timeline_preset_conductor: bool = DEFAULT_TIMELINE_PRESET_CONDUCTOR hard_cut_fades: TimelineFadeGroupRuntime = field( default_factory=default_timeline_fade_group_runtime @@ -354,13 +355,7 @@ class LayerRuntime: expanded: bool = False locked: bool = False preset_switching: PresetSwitchingMode = DEFAULT_PRESET_SWITCHING - preset_switching_rotation_set: PresetSwitchingRotationSet = DEFAULT_PRESET_SWITCHING_ROTATION_SET - cast_roles_timeline_behaviour: CastRolesTimelineBehaviour = ( - DEFAULT_CAST_ROLES_TIMELINE_BEHAVIOUR - ) - cast_roles_default_role: CueRole = DEFAULT_CAST_ROLES_DEFAULT_ROLE - preset_switching_shuffle: bool = DEFAULT_PRESET_SWITCHING_SHUFFLE - preset_switching_shuffle_salt: int = DEFAULT_PRESET_SWITCHING_SHUFFLE_SALT + preset_switching_trigger: PresetSwitchingTrigger = DEFAULT_PRESET_SWITCHING_TRIGGER preset_duration: float = DEFAULT_PRESET_DURATION soft_cut_duration: float = DEFAULT_SOFT_CUT_DURATION hard_cut_duration: float = DEFAULT_HARD_CUT_DURATION @@ -368,8 +363,8 @@ class LayerRuntime: hard_cut_enabled: bool = DEFAULT_HARD_CUT_ENABLED easter_egg: float = DEFAULT_EASTER_EGG preset_start_clean: bool = DEFAULT_PRESET_START_CLEAN - user_presets: list[str] = field(default_factory=list) # absolute paths - user_presets_expanded: bool = False + preset_list: list[str] = field(default_factory=list) # absolute paths + preset_list_expanded: bool = False # Playing auto-switch preset (panel display); not persisted. Mirrored from # StemLayer.auto_preset_path while the live layer map is available. auto_preset_path: Path | None = None @@ -503,6 +498,7 @@ def _limiter_runtime_from_cfg( return VisualLimiterRuntime( enabled=limiter.enabled, threshold=limiter.threshold, + ratio=limiter.ratio, release=limiter.release, ) @@ -540,8 +536,10 @@ def timeline_runtime_from_cfg(cfg: CleaveConfig) -> TimelineRuntime: if preset is None else preset.timeline_cuts ) - preset_reshuffle = ( - DEFAULT_TIMELINE_PRESET_RESHUFFLE if preset is None else preset.reshuffle + preset_repopulate = ( + DEFAULT_TIMELINE_PRESET_REPOPULATE + if preset is None + else preset.repopulate ) preset_conductor = ( DEFAULT_TIMELINE_PRESET_CONDUCTOR if preset is None else preset.conductor @@ -565,7 +563,7 @@ def timeline_runtime_from_cfg(cfg: CleaveConfig) -> TimelineRuntime: timeline_preset_cue_snap=preset_cue_snap, timeline_preset_song_marker_snap=preset_song_marker_snap, timeline_preset_timeline_cuts=preset_timeline_cuts, - timeline_preset_reshuffle=preset_reshuffle, + timeline_preset_repopulate=preset_repopulate, timeline_preset_conductor=preset_conductor, limiter=limiter, ) @@ -580,7 +578,7 @@ def timeline_runtime_from_cfg(cfg: CleaveConfig) -> TimelineRuntime: timeline_preset_cue_snap=preset_cue_snap, timeline_preset_song_marker_snap=preset_song_marker_snap, timeline_preset_timeline_cuts=preset_timeline_cuts, - timeline_preset_reshuffle=preset_reshuffle, + timeline_preset_repopulate=preset_repopulate, timeline_preset_conductor=preset_conductor, hard_cut_fades=_fade_group_runtime_from_cfg(cuts.hard), soft_cut_fades=_fade_group_runtime_from_cfg(cuts.soft), @@ -622,11 +620,7 @@ def session_from_cfg( enabled=layer_cfg.enabled, locked=layer_cfg.locked, preset_switching=layer_cfg.preset_switching, - preset_switching_rotation_set=layer_cfg.preset_switching_rotation_set, - cast_roles_timeline_behaviour=layer_cfg.cast_roles_timeline_behaviour, - cast_roles_default_role=layer_cfg.cast_roles_default_role, - preset_switching_shuffle=layer_cfg.preset_switching_shuffle, - preset_switching_shuffle_salt=layer_cfg.preset_switching_shuffle_salt, + preset_switching_trigger=layer_cfg.preset_switching_trigger, preset_duration=layer_cfg.preset_duration, soft_cut_duration=layer_cfg.soft_cut_duration, hard_cut_duration=layer_cfg.hard_cut_duration, @@ -634,9 +628,8 @@ def session_from_cfg( hard_cut_enabled=layer_cfg.hard_cut_enabled, easter_egg=layer_cfg.easter_egg, preset_start_clean=layer_cfg.preset_start_clean, - user_presets=[ - path.as_posix() - for path in layer_cfg.preset_switching_presets + preset_list=[ + path.as_posix() for path in layer_cfg.preset_switching_list ], ) for slot, layer_cfg in cfg.layers.items() diff --git a/cleave/viz/timeline_controls.py b/cleave/viz/timeline_controls.py index 7badb5c..c9f4d35 100644 --- a/cleave/viz/timeline_controls.py +++ b/cleave/viz/timeline_controls.py @@ -364,15 +364,6 @@ def _cycle_selected_cue_role(self) -> None: if selected is None: return slot, cue = selected - if ( - self.session.layers[slot].preset_switching_rotation_set - != "cast_roles" - ): - if self._on_notification is not None: - self._on_notification( - "Set rotation set to cast roles to assign cast" - ) - return if not cue_editable_for_blend_role(cue): return options: tuple[CueRole | None, ...] = (None, *CUE_ROLES) diff --git a/cleave/viz/timeline_overlay.py b/cleave/viz/timeline_overlay.py index 6fac542..184098c 100644 --- a/cleave/viz/timeline_overlay.py +++ b/cleave/viz/timeline_overlay.py @@ -142,7 +142,6 @@ class TimelineViewState: soft_cut_fades: TimelineFadeGroup = field(default_factory=TimelineFadeGroup) selected_cue_t: dict[str, float] = field(default_factory=dict) selected_cue_flash_start_ms: int | None = None - slot_rotation_sets: dict[str, str] = field(default_factory=dict) def cue_times_for_stem( @@ -1154,8 +1153,6 @@ def _draw_role_glyphs( layout.bar_width, max(1, row_h - BAR_VERTICAL_INSET * 2), ) - if state.slot_rotation_sets.get(slot) != "cast_roles": - continue for cue in bar_cues_for_row(state, slot): if cue.role is None or cue.level <= LEVEL_EPS: continue @@ -1530,10 +1527,7 @@ def _patch_live_overlay( if 0 <= state.focus_row < len(state.layer_z_order) else None ) - show_role = ( - focused_slot is not None - and state.slot_rotation_sets.get(focused_slot) == "cast_roles" - ) + show_role = focused_slot is not None cue_readout = ( render_selected_cue_readout(font, selected_cue, show_role=show_role) if selected_cue is not None diff --git a/cleave/viz/timeline_preset_controls.py b/cleave/viz/timeline_preset_controls.py index 0ebec8b..dd69268 100644 --- a/cleave/viz/timeline_preset_controls.py +++ b/cleave/viz/timeline_preset_controls.py @@ -34,7 +34,7 @@ density_bias_for, timeline_preset_density_display, ) -from cleave.timeline_presets.reshuffle import timeline_preset_reshuffle_display +from cleave.timeline_presets.repopulate import timeline_preset_repopulate_display from cleave.timeline_presets.song_marker_snap import ( timeline_preset_song_marker_snap_display, ) @@ -70,7 +70,7 @@ def __init__( *, signals: Signals | None = None, on_notification: Callable[[str], None] | None = None, - on_reshuffle: Callable[[], None] | None = None, + on_repopulate: Callable[[], None] | None = None, ) -> None: self.session = session self._modal = modal_host @@ -78,7 +78,7 @@ def __init__( self._bar_times = tuple(bar_times) self._signals = signals self._on_notification = on_notification - self._on_reshuffle = on_reshuffle + self._on_repopulate = on_repopulate def prompt(self, duration_sec: float) -> None: if self.session.timeline.locked: @@ -120,8 +120,8 @@ def _apply_prompt_labeled_lines(self) -> tuple[ModalLabeledLine, ...]: timeline_preset_timeline_cuts_display(tl.timeline_preset_timeline_cuts), ), ModalLabeledLine( - "reshuffle", - timeline_preset_reshuffle_display(tl.timeline_preset_reshuffle), + "re-populate preset lists", + timeline_preset_repopulate_display(tl.timeline_preset_repopulate), ), ModalLabeledLine( "conductor", @@ -178,8 +178,6 @@ def _apply( self._clear_timeline_state() tl = self.session.timeline tl.enabled = True - if tl.timeline_preset_reshuffle and self._on_reshuffle is not None: - self._on_reshuffle() slots = list(self.session.layer_z_order) markers = list(self.session.song_markers.times) rng = random.Random() @@ -222,6 +220,11 @@ def _apply( self._apply_timeline_cuts(built, markers, tl.timeline_preset_timeline_cuts) for slot in slots: tl.lanes[slot] = built[slot] + if ( + tl.timeline_preset_repopulate != "no" + and self._on_repopulate is not None + ): + self._on_repopulate() if conductor_skipped: self._notify("No signals; conductor skipped") else: diff --git a/cleave/viz/tuning_panel_cache.py b/cleave/viz/tuning_panel_cache.py index 639fcdc..82004bf 100644 --- a/cleave/viz/tuning_panel_cache.py +++ b/cleave/viz/tuning_panel_cache.py @@ -99,6 +99,15 @@ def clear_rows(self) -> None: self.text_fit.clear() self.row_cache_structure = None + def retain_row_surfaces(self, keep: set[RowRenderKey]) -> None: + """Drop row surfaces not in keep (incremental eviction on structure change).""" + if not keep: + self.row_surfaces.clear() + return + stale = [key for key in self.row_surfaces if key not in keep] + for key in stale: + del self.row_surfaces[key] + def clear_panel(self) -> None: self.panel = None self.panel_signature = None @@ -190,12 +199,17 @@ def static_row_keys( *, font: pygame.font.Font, cache: TuningPanelCache, - visible_indices: tuple[int, ...], + indices: tuple[int, ...], max_content_width_for_index: Callable[[int], int], line_h: int, ) -> tuple[tuple, ...]: + """Build the static panel signature fragment for the given row indices. + + Callers should pass viewport-visible (raster) indices only; off-screen + rows do not affect the composed panel pixels. + """ keys: list[tuple] = [] - for index in visible_indices: + for index in indices: if state.layout.kind(index) == RowKind.TRANSPORT: continue max_w = max_content_width_for_index(index) @@ -278,6 +292,7 @@ def ensure_row_surface( max_content_width: int, line_h: int, counters: OverlayDrawCounters | None = None, + used_keys: set[RowRenderKey] | None = None, ) -> RowRenderEntry: kind = state.layout.kind(index) if kind == RowKind.TRANSPORT: @@ -302,6 +317,8 @@ def ensure_row_surface( max_content_width=max_content_width, line_h=line_h, ) + if used_keys is not None: + used_keys.add(key) entry = cache.row_surfaces.get(key) if entry is not None: if counters is not None: diff --git a/cleave/viz/tuning_panel_draw.py b/cleave/viz/tuning_panel_draw.py index 5a3a46c..83002cb 100644 --- a/cleave/viz/tuning_panel_draw.py +++ b/cleave/viz/tuning_panel_draw.py @@ -30,10 +30,10 @@ row_expand_subheader_display_text, row_full_line_display_text, row_labeled_display_text, + tree_branch_leading_spaces, ) from cleave.extract import stem_overlay_header from cleave.viz.row_sections import ( - PRESET_SWITCHING_CHILD_KINDS, RENDER_OVERLAY_SECTION_KINDS, RENDER_POST_FX_SECTION_KINDS, RENDER_TIMELINE_SECTION_KINDS, @@ -122,6 +122,7 @@ from cleave.viz.tuning_panel_cache import ( PanelSignature, RowRenderEntry, + RowRenderKey, TuningPanelCache, ensure_row_surface, live_upload_signature, @@ -456,12 +457,16 @@ def track_header_prefix_width(font: pygame.font.Font) -> int: return icon_w + ROW_ICON_SUFFIX_GAP -def tree_branch_prefix_width(font: pygame.font.Font) -> int: - return font.size(TREE_BRANCH)[0] +def tree_branch_prefix_width(font: pygame.font.Font, *, depth: int = 1) -> int: + return font.size(tree_branch_leading_spaces(depth) + TREE_BRANCH)[0] -def preset_row_prefix_width(font: pygame.font.Font, line_height: int) -> int: - return tree_branch_prefix_width(font) + row_icon_prefix_width(line_height) +def preset_row_prefix_width( + font: pygame.font.Font, line_height: int, *, depth: int = 1 +) -> int: + return tree_branch_prefix_width(font, depth=depth) + row_icon_prefix_width( + line_height + ) def _render_preset_row_prefix( @@ -470,9 +475,11 @@ def _render_preset_row_prefix( glyph: str, icon_color: tuple[int, int, int], line_height: int, + depth: int = 1, counters: OverlayDrawCounters | None = None, ) -> pygame.Surface: - tree_surf = _render_text(font, TREE_BRANCH, True, LABEL, counters=counters) + branch = tree_branch_leading_spaces(depth) + TREE_BRANCH + tree_surf = _render_text(font, branch, True, LABEL, counters=counters) icon_surf = render_glyph(glyph, color=icon_color, line_height=line_height) total_w = tree_surf.get_width() + icon_surf.get_width() surf = _compose_surface((total_w, line_height), counters=counters) @@ -586,7 +593,9 @@ def _fit( text, budget - icon_w - suffix_w - enter_w, ) - prefix_w = preset_row_prefix_width(font, line_h) + prefix_w = preset_row_prefix_width( + font, line_h, depth=row_tree_indent_depth(kind) + ) return _fit("counter", fit_counter_label_to_width, text, budget - prefix_w) if kind == RowKind.TRACK_HEADER: stem = state.layout.slot(index) @@ -617,7 +626,8 @@ def _fit( if kind in { RowKind.LAYER_MANAGEMENT_ADD, RowKind.LAYER_MANAGEMENT_DELETE, - RowKind.TRACK_USER_PRESET_ADD, + RowKind.TRACK_PRESET_LIST_ADD, + RowKind.TRACK_PRESET_LIST_POPULATE, }: return _row_text(state, index) if field is not None and field.present_style == RowPresentStyle.COMPOSITE_HEADER: @@ -703,20 +713,12 @@ def _row_value_color(state: TuningViewState, index: int) -> tuple[int, int, int] if not state.render_timeline.enabled: return DISABLED - if kind in PRESET_SWITCHING_CHILD_KINDS: - stem_for_switching = state.layout.slot(index) - if ( - stem_for_switching is not None - and state.tracks[stem_for_switching].preset_switching == "timeline" - and not state.render_timeline.enabled - ): - return DISABLED - if kind in { RowKind.CONFIG_HEADER, RowKind.LAYER_MANAGEMENT_ADD, RowKind.LAYER_MANAGEMENT_DELETE, - RowKind.TRACK_USER_PRESET_ADD, + RowKind.TRACK_PRESET_LIST_ADD, + RowKind.TRACK_PRESET_LIST_POPULATE, RowKind.TIMELINE_PRESETS, RowKind.TIMELINE_RESET, RowKind.TIMELINE_SNAP_TO_BEATS, @@ -756,7 +758,7 @@ def _row_value_color(state: TuningViewState, index: int) -> tuple[int, int, int] ): return DISABLED - if stem is not None and state.move_mode_slot == stem: + if _row_in_move_mode(state, index): return MOVE_MODE if _row_has_tree_focus(state, index): @@ -768,12 +770,36 @@ def _row_value_color(state: TuningViewState, index: int) -> tuple[int, int, int] if stem is not None and _track_disabled(state, stem): return DISABLED + if ( + kind == RowKind.TRACK_PRESET_LIST_ITEM + and stem is not None + and desc.preset_index is not None + and state.tracks[stem].active_preset_list_index == desc.preset_index + ): + # HIGHLIGHT yellow; color_state is part of RowRenderKey so only the + # previous/next active rows miss the row-surface cache on switch. + return HIGHLIGHT + return VALUE -def _row_bg_color(state: TuningViewState, index: int) -> tuple[int, int, int] | None: +def _row_in_move_mode(state: TuningViewState, index: int) -> bool: stem = state.layout.slot(index) if stem is not None and state.move_mode_slot == stem: + return True + move = state.move_mode_preset + if move is None: + return False + desc = state.layout.descriptor(index) + return ( + desc.kind == RowKind.TRACK_PRESET_LIST_ITEM + and desc.slot == move[0] + and desc.preset_index == move[1] + ) + + +def _row_bg_color(state: TuningViewState, index: int) -> tuple[int, int, int] | None: + if _row_in_move_mode(state, index): return MOVE_MODE if _row_has_tree_focus(state, index): return HIGHLIGHT @@ -1060,170 +1086,6 @@ def _glyph_icon_width(glyph: str, line_h: int) -> int: return material_font(icon_h).size(glyph)[0] -def _estimate_row_content_width( - *, - padding: int, - font: pygame.font.Font, - state: TuningViewState, - index: int, - max_content_width: int, - line_h: int, -) -> int: - width = _estimate_row_content_width_base( - padding=padding, - font=font, - state=state, - index=index, - max_content_width=max_content_width, - line_h=line_h, - ) - if _row_shows_enter_icon(state, index): - width += action_enter_icon_suffix_width(line_h) - return width - - -def _estimate_row_content_width_base( - *, - padding: int, - font: pygame.font.Font, - state: TuningViewState, - index: int, - max_content_width: int, - line_h: int, -) -> int: - indent = padding + _row_indent(state, index) - kind = state.layout.kind(index) - - if kind == RowKind.TRANSPORT: - time_text = f" [{format_mmss(state.position_sec)}]" - return indent + _transport_icons_width(line_h) + font.size(time_text)[0] - - if kind == RowKind.TRACK_HEADER: - stem = state.layout.slot(index) - desc = RowDescriptor(RowKind.TRACK_HEADER, slot=stem) - locked = section_locked(state, desc) - prefix_w = visibility_icon_slot_width(line_h) - layer_prefix = composite_header_prefix_part(state, desc) - stem_text = _fit_track_header_stem( - font, state, index, max_content_width=max_content_width - ) - expand_arrow = ( - format_composite_header_expand_value(state, desc) - if stem is not None - else expand_arrow_glyph(False) - ) - label_w = ( - font.size(layer_prefix)[0] - + font.size(stem_text)[0] - + font.size(f" {expand_arrow}")[0] - ) - if locked: - label_w += track_header_lock_suffix_width(line_h) - return indent + prefix_w + label_w - - if kind == RowKind.SETTINGS_HEADER: - desc = state.layout.descriptor(index) - icon_w = _glyph_icon_width(SETTINGS_GLYPH, line_h) - prefix = composite_header_prefix_part(state, desc) - value = format_composite_header_expand_value(state, desc) - return indent + icon_w + font.size(prefix)[0] + font.size(value)[0] - - if kind in { - RowKind.RENDER_OVERLAYS_HEADER, - RowKind.RENDER_POST_FX_HEADER, - RowKind.RENDER_TIMELINE_HEADER, - }: - desc = state.layout.descriptor(index) - prefix_w = visibility_icon_slot_width(line_h) - layer_prefix = composite_header_prefix_part(state, desc) - stem_text = composite_header_suffix_part(state, desc) - expand_arrow = format_composite_header_expand_value(state, desc) - label_w = ( - font.size(layer_prefix)[0] - + font.size(stem_text)[0] - + font.size(f" {expand_arrow}")[0] - ) - if section_locked(state, desc): - label_w += track_header_lock_suffix_width(line_h) - return indent + prefix_w + label_w - - if kind == RowKind.RENDER_SECTION_GAP: - return indent + 1 - - if ( - ROW_FIELDS.get(kind) is not None - and ROW_FIELDS[kind].present_style == RowPresentStyle.PATH_ICON - ): - if kind == RowKind.TRACK_PRESET_DIR: - prefix_w = preset_row_prefix_width(font, line_h) - elif kind in {RowKind.TRACK_PRESET, RowKind.TRACK_USER_PRESET_ITEM}: - prefix_w = preset_row_prefix_width(font, line_h) - else: - prefix_w = tree_branch_prefix_width(font) + _glyph_icon_width( - FILE_GLYPH, line_h - ) - if kind == RowKind.CONFIG_HEADER: - path = fit_row_text( - font, state, index, max_content_width=max_content_width - ) - suffix_w = font.size("*")[0] if state.config_dirty else 0 - return indent + prefix_w + font.size(path)[0] + suffix_w - label = fit_row_text( - font, state, index, max_content_width=max_content_width - ) - return indent + prefix_w + font.size(label)[0] - - if ( - ROW_FIELDS.get(kind) is not None - and ROW_FIELDS[kind].present_style == RowPresentStyle.EXPAND_SUBHEADER - ): - desc = state.layout.descriptor(index) - prefix = expand_subheader_prefix(kind) - value = format_expand_subheader_value(state, desc) - return indent + font.size(prefix)[0] + font.size(value)[0] - - if _is_action_parameter_style(kind): - prefix = _action_parameter_row_prefix(kind) - value = _fit_action_parameter_row_value( - font, - state, - index, - max_content_width=max_content_width, - ) - return indent + font.size(prefix)[0] + font.size(value)[0] - - if kind in LABELED_SUB_ROW_KINDS: - prefix = _labeled_sub_row_prefix(state, index) - value = _fit_labeled_sub_row_value( - font, state, index, max_content_width=max_content_width - ) - return indent + font.size(prefix)[0] + font.size(value)[0] - - if ( - ROW_FIELDS.get(kind) is not None - and ROW_FIELDS[kind].present_style == RowPresentStyle.FULL_LINE - and kind - in { - RowKind.LAYER_MANAGEMENT_ADD, - RowKind.LAYER_MANAGEMENT_DELETE, - RowKind.TRACK_USER_PRESET_ADD, - RowKind.TIMELINE_PRESETS, - RowKind.TIMELINE_RESET, - RowKind.TIMELINE_SNAP_TO_BEATS, - RowKind.TIMELINE_SNAP_TO_BARS, - RowKind.TIMELINE_SNAP_TO_SONG_MARKERS, - RowKind.TIMELINE_APPLY_SOFT_CUTS, - RowKind.TIMELINE_APPLY_HARD_CUTS, - RowKind.SETTINGS_MEASURE_LATENCY, - } - ): - label = _row_text(state, index) - return indent + font.size(label)[0] - - text = fit_row_text(font, state, index, max_content_width=max_content_width) - return indent + font.size(text)[0] - - def _scrollable_row_in_viewport( *, row_index: int, @@ -1690,6 +1552,7 @@ def _finish( ROW_FIELDS.get(kind) is not None and ROW_FIELDS[kind].present_style == RowPresentStyle.PATH_ICON ): + depth = row_tree_indent_depth(kind) if kind == RowKind.TRACK_PRESET_DIR: glyph = FOLDER_GLYPH icon_color = PRESET_ICON @@ -1698,9 +1561,10 @@ def _finish( glyph=glyph, icon_color=icon_color, line_height=line_h, + depth=depth, counters=counters, ) - elif kind in {RowKind.TRACK_PRESET, RowKind.TRACK_USER_PRESET_ITEM}: + elif kind in {RowKind.TRACK_PRESET, RowKind.TRACK_PRESET_LIST_ITEM}: glyph = FILE_GLYPH icon_color = PRESET_FILE_ICON icon_surf = _render_preset_row_prefix( @@ -1708,6 +1572,7 @@ def _finish( glyph=glyph, icon_color=icon_color, line_height=line_h, + depth=depth, counters=counters, ) else: @@ -1817,7 +1682,8 @@ def _finish( in { RowKind.LAYER_MANAGEMENT_ADD, RowKind.LAYER_MANAGEMENT_DELETE, - RowKind.TRACK_USER_PRESET_ADD, + RowKind.TRACK_PRESET_LIST_ADD, + RowKind.TRACK_PRESET_LIST_POPULATE, RowKind.TIMELINE_PRESETS, RowKind.TIMELINE_RESET, RowKind.TIMELINE_SNAP_TO_BEATS, @@ -2206,9 +2072,13 @@ def compose_panel( scrollable_indices = frozenset(metrics.scrollable_indices) panel_max_width = panel_content_max_width_px(state.settings.ui_width) vis_tuple = tuple(visible_indices) + # Stable order matching layout; viewport rows only for the static signature. + raster_tuple = tuple(i for i in vis_tuple if i in raster_indices) cache = self._panel_cache - if cache.row_cache_structure != vis_tuple: - cache.clear_rows() + structure_changed = cache.row_cache_structure != vis_tuple + if structure_changed: + # Content-keyed surfaces stay valid across expand/collapse; prune + # unused entries after the full compose below instead of clear_rows(). cache.row_cache_structure = vis_tuple def max_content_width_for(index: int) -> int: @@ -2223,7 +2093,7 @@ def max_content_width_for(index: int) -> int: state, font=font, cache=cache, - visible_indices=vis_tuple, + indices=raster_tuple, max_content_width_for_index=max_content_width_for, line_h=line_h, ) @@ -2314,32 +2184,23 @@ def max_content_width_for(index: int) -> int: built_rows: dict[int, RowRenderEntry] = {} row_widths: list[int] = [] - for index in visible_indices: + used_row_keys: set[RowRenderKey] = set() + # Width from viewport rows only; off-screen estimates are skipped. + for index in raster_tuple: max_content_width = max_content_width_for(index) - if index in raster_indices: - entry = ensure_row_surface( - cache, - state, - index, - font, - self._build_row_at_index, - max_content_width=max_content_width, - line_h=line_h, - counters=counters, - ) - built_rows[index] = entry - row_widths.append(entry.content_width) - else: - row_widths.append( - _estimate_row_content_width( - padding=self._padding, - font=font, - state=state, - index=index, - max_content_width=max_content_width, - line_h=line_h, - ) - ) + entry = ensure_row_surface( + cache, + state, + index, + font, + self._build_row_at_index, + max_content_width=max_content_width, + line_h=line_h, + counters=counters, + used_keys=used_row_keys, + ) + built_rows[index] = entry + row_widths.append(entry.content_width) content_w = max(row_widths) if row_widths else 0 if state.settings.ui_width_mode == "fixed": @@ -2383,8 +2244,12 @@ def max_content_width_for(index: int) -> int: max_content_width=bottom_max, line_h=line_h, counters=counters, + used_keys=used_row_keys, ) + if structure_changed: + cache.retain_row_surfaces(used_row_keys) + alpha = int(BACKGROUND_ALPHA * self._visibility) if alpha < 2: return None diff --git a/cleave/viz/tuning_view_state.py b/cleave/viz/tuning_view_state.py index ef68192..3234adc 100644 --- a/cleave/viz/tuning_view_state.py +++ b/cleave/viz/tuning_view_state.py @@ -14,9 +14,7 @@ RenderOverlaySlideDirection, ) from cleave.config_schema import ( - DEFAULT_CAST_ROLES_DEFAULT_ROLE, - DEFAULT_CAST_ROLES_TIMELINE_BEHAVIOUR, - DEFAULT_CHROMA_BOOST_APPLY_MODE, + DEFAULT_CHROMA_BOOST_APPLY_MODE, DEFAULT_CHROMA_BOOST_VARIANT, DEFAULT_HIGHLIGHT_ROLLOFF_APPLY_MODE, DEFAULT_HIGHLIGHT_ROLLOFF_CURVE, @@ -26,9 +24,7 @@ DEFAULT_EASTER_EGG, DEFAULT_PRESET_START_CLEAN, DEFAULT_PRESET_DURATION, - DEFAULT_PRESET_SWITCHING_SHUFFLE, - DEFAULT_PRESET_SWITCHING_SHUFFLE_SALT, - DEFAULT_RENDER_OVERLAY_ANIMATION_TYPE, + DEFAULT_RENDER_OVERLAY_ANIMATION_TYPE, DEFAULT_RENDER_OVERLAY_APPEAR_AT, DEFAULT_RENDER_OVERLAY_DISAPPEAR_AT, DEFAULT_RENDER_OVERLAY_DISPLAY_TIME, @@ -39,13 +35,12 @@ DEFAULT_UI_WIDTH_MODE, DEFAULT_VISUAL_LIMITER_ENABLED, DEFAULT_VISUAL_LIMITER_THRESHOLD, + DEFAULT_VISUAL_LIMITER_RATIO, DEFAULT_VISUAL_LIMITER_RELEASE, - CastRolesTimelineBehaviour, default_render_overlay_card_runtime_values, default_render_overlays_runtime_values, default_render_post_fx_runtime_values, ) -from cleave.cue_roles import CueRole from cleave.extract import StemSource from cleave.preset_curation import PresetCurationIndex from cleave.preset_playlist import ( @@ -54,7 +49,6 @@ scan_preset_playlist, ) from cleave.timeline_presets.conductor import DEFAULT_TIMELINE_PRESET_CONDUCTOR -from cleave.timeline_presets.reshuffle import DEFAULT_TIMELINE_PRESET_RESHUFFLE from cleave.viz.panel_notification import PanelNotificationActive from cleave.timeline_presets.crescendo import CrescendoTarget from cleave.timeline_presets.cue_snap import ( @@ -65,6 +59,10 @@ DEFAULT_TIMELINE_PRESET_DENSITY, TimelinePresetDensity, ) +from cleave.timeline_presets.repopulate import ( + DEFAULT_TIMELINE_PRESET_REPOPULATE, + TimelinePresetRepopulate, +) from cleave.timeline_presets.song_marker_snap import ( DEFAULT_TIMELINE_PRESET_SONG_MARKER_SNAP, TimelinePresetSongMarkerSnap, @@ -77,7 +75,10 @@ from cleave.viz.playback import PlaybackState, current_sec from cleave.viz.row_semantics import RowDescriptor, RowKind from cleave.viz.session import LayerRuntime, TuningSession, config_path_display -from cleave.viz.user_presets import user_preset_item_display_name +from cleave.viz.user_presets import ( + path_list_identity, + preset_list_display_names, +) if TYPE_CHECKING: from cleave.viz.focus_nav import FocusCursor @@ -104,14 +105,8 @@ class TrackBlock: expanded: bool = False locked: bool = False preset_empty: bool = False - preset_switching: str = "none" - preset_switching_rotation_set: str = "directory" - cast_roles_timeline_behaviour: CastRolesTimelineBehaviour = ( - DEFAULT_CAST_ROLES_TIMELINE_BEHAVIOUR - ) - cast_roles_default_role: CueRole = DEFAULT_CAST_ROLES_DEFAULT_ROLE - preset_switching_shuffle: bool = DEFAULT_PRESET_SWITCHING_SHUFFLE - preset_switching_shuffle_salt: int = DEFAULT_PRESET_SWITCHING_SHUFFLE_SALT + preset_switching: str = "off" + preset_switching_trigger: str = "timer" preset_duration: float = DEFAULT_PRESET_DURATION soft_cut_duration: float = DEFAULT_SOFT_CUT_DURATION hard_cut_duration: float = DEFAULT_HARD_CUT_DURATION @@ -119,9 +114,12 @@ class TrackBlock: hard_cut_enabled: bool = DEFAULT_HARD_CUT_ENABLED easter_egg: float = DEFAULT_EASTER_EGG preset_start_clean: bool = DEFAULT_PRESET_START_CLEAN - user_presets: list[str] = field(default_factory=list) - user_preset_labels: list[str] = field(default_factory=list) - user_presets_expanded: bool = False + preset_list: list[str] = field(default_factory=list) + preset_list_labels: list[str] = field(default_factory=list) + preset_list_expanded: bool = False + # Index into preset_list of the currently playing preset, when present. + # Baked at structure build (signature includes auto_preset_path / playlist). + active_preset_list_index: int | None = None @dataclass @@ -226,6 +224,7 @@ class TimelineFadeGroupBlock: class VisualLimiterBlock: enabled: bool = DEFAULT_VISUAL_LIMITER_ENABLED threshold: float = DEFAULT_VISUAL_LIMITER_THRESHOLD + ratio: float = DEFAULT_VISUAL_LIMITER_RATIO release: float = DEFAULT_VISUAL_LIMITER_RELEASE @@ -250,7 +249,9 @@ class RenderTimelineBlock: timeline_preset_timeline_cuts: TimelinePresetTimelineCuts = ( DEFAULT_TIMELINE_PRESET_TIMELINE_CUTS ) - timeline_preset_reshuffle: bool = DEFAULT_TIMELINE_PRESET_RESHUFFLE + timeline_preset_repopulate: TimelinePresetRepopulate = ( + DEFAULT_TIMELINE_PRESET_REPOPULATE + ) timeline_preset_conductor: bool = DEFAULT_TIMELINE_PRESET_CONDUCTOR hard_cut_fades: TimelineFadeGroupBlock = field( default_factory=TimelineFadeGroupBlock @@ -286,6 +287,7 @@ class TuningViewState: position_sec: float focus_cursor: FocusCursor move_mode_slot: str | None + move_mode_preset: tuple[str, int] | None = None persistent_notification_message: str | None = None persistent_notification_elapsed_sec: float = 0.0 notification_message: str | None = None @@ -392,12 +394,9 @@ def view_state_structure_signature( layers[slot] = { "expanded": layer.expanded, "effects_expanded": layer.effects_expanded, - "user_presets_expanded": layer.user_presets_expanded, + "preset_list_expanded": layer.preset_list_expanded, "preset_switching": layer.preset_switching, - "preset_switching_rotation_set": layer.preset_switching_rotation_set, - "cast_roles_timeline_behaviour": layer.cast_roles_timeline_behaviour, - "cast_roles_default_role": layer.cast_roles_default_role, - "preset_switching_shuffle": layer.preset_switching_shuffle, + "preset_switching_trigger": layer.preset_switching_trigger, "preset_duration": layer.preset_duration, "soft_cut_duration": layer.soft_cut_duration, "hard_cut_duration": layer.hard_cut_duration, @@ -406,10 +405,12 @@ def view_state_structure_signature( "easter_egg": layer.easter_egg, "preset_start_clean": layer.preset_start_clean, "effects": sorted(layer.effects.keys()), - "user_presets": list(layer.user_presets), + "preset_list": path_list_identity(layer.preset_list), "playlist": { "current_dir": str(playlist.current_dir), - "paths": [str(path) for path in playlist.paths], + "paths": path_list_identity( + [str(path) for path in playlist.paths] + ), "index": playlist.index, }, "auto_preset_path": ( @@ -525,6 +526,7 @@ def __init__( *, get_focus_cursor: Callable[[], FocusCursor], get_move_mode_slot: Callable[[], str | None], + get_move_mode_preset: Callable[[], tuple[str, int] | None], config_save: ConfigSaveController, get_notification: Callable[[], PanelNotificationActive], layers_by_slot: dict[str, StemLayer] | None = None, @@ -536,10 +538,16 @@ def __init__( self._curation_index = curation_index self._get_focus_cursor = get_focus_cursor self._get_move_mode_slot = get_move_mode_slot + self._get_move_mode_preset = get_move_mode_preset self._config_save = config_save self._get_notification = get_notification self._layers_by_slot = layers_by_slot self._auto_display_cache: dict[Path, PresetPlaylist] = {} + self._base_preset_list_label_cache: dict[tuple[str, ...], list[str]] = {} + self._annotated_preset_list_label_cache: dict[ + tuple[tuple[str, ...], str], list[str] + ] = {} + self._user_basenames_cache: dict[tuple[tuple[str, ...], ...], set[str]] = {} self._structure: _ViewStateStructure | None = None def _sync_auto_preset_paths(self) -> None: @@ -551,11 +559,22 @@ def _sync_auto_preset_paths(self) -> None: if runtime is not None: runtime.auto_preset_path = stem.auto_preset_path + def _preset_list_paths_key(self) -> tuple[tuple[str, ...], ...]: + return tuple( + tuple(self.session.layers[slot].preset_list) + for slot in self.session.layer_z_order + ) + def _user_preset_basenames(self) -> set[str]: + key = self._preset_list_paths_key() + cached = self._user_basenames_cache.get(key) + if cached is not None: + return cached names: set[str] = set() - for layer in self.session.layers.values(): - for path in layer.user_presets: + for slot in self.session.layer_z_order: + for path in self.session.layers[slot].preset_list: names.add(Path(path).name) + self._user_basenames_cache[key] = names return names def _display_playlist(self, layer: LayerRuntime) -> PresetPlaylist: @@ -582,12 +601,53 @@ def _preset_label( label += self._curation_index.marker(name, user=name in user_names) return label - def _user_preset_labels(self, paths: list[str]) -> list[str]: - return [ - user_preset_item_display_name(paths, i) - + self._curation_index.marker(Path(paths[i]).name) + def _preset_list_labels(self, paths: list[str]) -> list[str]: + paths_key = tuple(paths) + annotated_key = (paths_key, self._curation_index.curation_stamp) + cached = self._annotated_preset_list_label_cache.get(annotated_key) + if cached is not None: + return cached + base = self._base_preset_list_label_cache.get(paths_key) + if base is None: + base = preset_list_display_names(paths) + self._base_preset_list_label_cache[paths_key] = base + labels = [ + base[i] + self._curation_index.marker(Path(paths[i]).name) for i in range(len(paths)) ] + self._annotated_preset_list_label_cache[annotated_key] = labels + return labels + + @staticmethod + def _active_preset_list_index(layer: LayerRuntime) -> int | None: + """Return the preset_list index matching the playing preset, if any. + + Computed only when the view structure rebuilds (``auto_preset_path`` and + playlist index are part of ``view_state_structure_signature``), so the + draw path can read a precomputed index without resolving paths per frame. + """ + paths = layer.preset_list + if not paths: + return None + active = layer.auto_preset_path + if active is None: + current = layer.playlist.current + if current is None: + return None + active = current.resolve() + target = active.resolve() + target_str = str(target) + target_posix = target.as_posix() + for index, raw in enumerate(paths): + if raw == target_str or raw == target_posix: + return index + for index, raw in enumerate(paths): + try: + if Path(raw).resolve() == target: + return index + except OSError: + continue + return None def _build_structure( self, @@ -604,9 +664,12 @@ def _build_structure( for slot in layer_z_order: layer = self.session.layers[slot] display = self._display_playlist(layer) + # Directory row always reflects browse navigation (Ctrl+Left/Right). + # The file row alone follows auto_preset_path when switching plays + # a cast/list preset outside the browse directory. tracks[slot] = TrackBlock( stem=layer.stem, - preset_dir_label=display.directory_display_label( + preset_dir_label=layer.playlist.directory_display_label( self.preset_root, browse_floor=layer.browse_floor, ), @@ -624,11 +687,7 @@ def _build_structure( locked=layer.locked, preset_empty=not display.paths, preset_switching=layer.preset_switching, - preset_switching_rotation_set=layer.preset_switching_rotation_set, - cast_roles_timeline_behaviour=layer.cast_roles_timeline_behaviour, - cast_roles_default_role=layer.cast_roles_default_role, - preset_switching_shuffle=layer.preset_switching_shuffle, - preset_switching_shuffle_salt=layer.preset_switching_shuffle_salt, + preset_switching_trigger=layer.preset_switching_trigger, preset_duration=layer.preset_duration, soft_cut_duration=layer.soft_cut_duration, hard_cut_duration=layer.hard_cut_duration, @@ -636,9 +695,10 @@ def _build_structure( hard_cut_enabled=layer.hard_cut_enabled, easter_egg=layer.easter_egg, preset_start_clean=layer.preset_start_clean, - user_presets=list(layer.user_presets), - user_preset_labels=self._user_preset_labels(list(layer.user_presets)), - user_presets_expanded=layer.user_presets_expanded, + preset_list=list(layer.preset_list), + preset_list_labels=self._preset_list_labels(list(layer.preset_list)), + preset_list_expanded=layer.preset_list_expanded, + active_preset_list_index=self._active_preset_list_index(layer), ) ro = self.session.render_overlays @@ -693,7 +753,7 @@ def _build_structure( timeline_preset_cue_snap=tl.timeline_preset_cue_snap, timeline_preset_song_marker_snap=tl.timeline_preset_song_marker_snap, timeline_preset_timeline_cuts=tl.timeline_preset_timeline_cuts, - timeline_preset_reshuffle=tl.timeline_preset_reshuffle, + timeline_preset_repopulate=tl.timeline_preset_repopulate, timeline_preset_conductor=tl.timeline_preset_conductor, hard_cut_fades=TimelineFadeGroupBlock( enabled=tl.hard_cut_fades.enabled, @@ -710,6 +770,7 @@ def _build_structure( limiter=VisualLimiterBlock( enabled=tl.limiter.enabled, threshold=tl.limiter.threshold, + ratio=tl.limiter.ratio, release=tl.limiter.release, ), song_markers_expanded=self.session.song_markers.expanded, @@ -769,11 +830,10 @@ def _patch_tracks( blend_mode=layer.blend_mode, opacity_pct=layer.opacity_pct, beat_sensitivity=layer.beat_sensitivity, - preset_switching_shuffle_salt=layer.preset_switching_shuffle_salt, preset_label=self._preset_label( display, user_names=user_names ), - user_preset_labels=self._user_preset_labels(list(layer.user_presets)), + preset_list_labels=self._preset_list_labels(list(layer.preset_list)), effects=dict(layer.effects), ) return tracks @@ -832,6 +892,7 @@ def build( position_sec=position_sec, focus_cursor=self._get_focus_cursor(), move_mode_slot=self._get_move_mode_slot(), + move_mode_preset=self._get_move_mode_preset(), persistent_notification_message=persistent_notification_message, persistent_notification_elapsed_sec=persistent_notification_elapsed_sec, notification_message=notification_message, @@ -939,7 +1000,7 @@ def build( timeline_preset_cue_snap=tl.timeline_preset_cue_snap, timeline_preset_song_marker_snap=tl.timeline_preset_song_marker_snap, timeline_preset_timeline_cuts=tl.timeline_preset_timeline_cuts, - timeline_preset_reshuffle=tl.timeline_preset_reshuffle, + timeline_preset_repopulate=tl.timeline_preset_repopulate, timeline_preset_conductor=tl.timeline_preset_conductor, hard_cut_fades=TimelineFadeGroupBlock( enabled=tl.hard_cut_fades.enabled, @@ -956,6 +1017,7 @@ def build( limiter=VisualLimiterBlock( enabled=tl.limiter.enabled, threshold=tl.limiter.threshold, + ratio=tl.limiter.ratio, release=tl.limiter.release, ), locked=tl.locked, diff --git a/cleave/viz/user_presets.py b/cleave/viz/user_presets.py index a41bfd9..573d02c 100644 --- a/cleave/viz/user_presets.py +++ b/cleave/viz/user_presets.py @@ -1,10 +1,12 @@ -"""Helpers for per-layer user-defined preset lists.""" +"""Helpers for per-layer preset switching lists (project user-presets copies).""" from __future__ import annotations import filecmp +import hashlib import os import shutil +from collections.abc import Sequence from pathlib import Path import yaml @@ -15,20 +17,40 @@ USER_PRESETS_DIRNAME = "user-presets" -def user_preset_item_display_name(paths: list[str], index: int) -> str: - """Format a user preset row label, numbering duplicate paths in the list.""" - path = paths[index] - resolved = Path(path).resolve() - name = Path(path).name - matching = [ - position - for position, candidate in enumerate(paths) - if Path(candidate).resolve() == resolved - ] - if len(matching) <= 1: - return name - instance = matching.index(index) + 1 - return f"{name} ({instance})" +def path_list_digest(paths: Sequence[str]) -> str: + """Stable digest for ordered path strings (empty list -> empty digest).""" + if not paths: + return "" + return hashlib.sha1("\0".join(paths).encode()).hexdigest() + + +def path_list_identity(paths: Sequence[str]) -> dict[str, int | str]: + """Compact structure-signature identity for an ordered path list.""" + return {"len": len(paths), "digest": path_list_digest(paths)} + + +def preset_list_display_names(paths: list[str]) -> list[str]: + """Format preset-list row labels, numbering duplicate paths in the list.""" + if not paths: + return [] + resolved = [Path(path).resolve() for path in paths] + by_resolved: dict[Path, list[int]] = {} + for index, path in enumerate(resolved): + by_resolved.setdefault(path, []).append(index) + labels = [""] * len(paths) + for indices in by_resolved.values(): + if len(indices) == 1: + index = indices[0] + labels[index] = Path(paths[index]).name + continue + for instance, index in enumerate(indices, start=1): + labels[index] = f"{Path(paths[index]).name} ({instance})" + return labels + + +def preset_list_item_display_name(paths: list[str], index: int) -> str: + """Format a preset-list row label, numbering duplicate paths in the list.""" + return preset_list_display_names(paths)[index] def copy_with_dedup(dest_dir: Path, src_path: Path) -> Path: @@ -147,7 +169,7 @@ def _preset_refs_from_viz_yaml(path: Path) -> set[Path]: for layer_raw in layers.values(): if not isinstance(layer_raw, dict): continue - raw = layer_raw.get("preset_switching_presets") + raw = layer_raw.get("preset_switching_list") if not isinstance(raw, list): continue for entry in raw: diff --git a/cleave/viz/visual_limiter.py b/cleave/viz/visual_limiter.py index 1ce7c83..cb51fd5 100644 --- a/cleave/viz/visual_limiter.py +++ b/cleave/viz/visual_limiter.py @@ -1,24 +1,24 @@ -"""Closed-loop visual limiter: duck lowest-priority hot layers when busy. +"""Feed-forward visual limiter: proportional compressor on stacked layer opacity. When enabled and timeline levels apply, measures post-composite busyness -(mean luma + frame delta) after the HDR display shoulder, decides ducks for -the next frame, and multiplies ``StemLayer.limiter_gain`` into opacity. -Persisted knobs live under ``timeline.limiter``; attack / duck / delta stay -fixed module constants. +(mean luma + frame delta) after the HDR display shoulder, runs a gain- +compensated envelope follower with ratio-based gain reduction, and multiplies +``StemLayer.limiter_gain`` into opacity. Persisted knobs live under +``timeline.limiter``; attack and delta weight stay fixed module constants. """ from __future__ import annotations +import math from dataclasses import dataclass, field from typing import TYPE_CHECKING import numpy as np from cleave.config_schema import ( + DEFAULT_VISUAL_LIMITER_RATIO, DEFAULT_VISUAL_LIMITER_RELEASE, DEFAULT_VISUAL_LIMITER_THRESHOLD, - visual_limiter_release_hold_sec, - visual_limiter_threshold_off, ) from cleave.cue_roles import CueRole from cleave.timeline import LEVEL_EPS, empty_lane, lane_role_at @@ -33,19 +33,17 @@ from cleave.viz.session import TuningSession, VisualLimiterRuntime # Busyness = mean_luma + DELTA_WEIGHT * mean_abs_delta (both in [0, 1]). -# ON / OFF / release defaults match schema; runtime values come from session. -THRESHOLD_ON = DEFAULT_VISUAL_LIMITER_THRESHOLD -THRESHOLD_OFF = visual_limiter_threshold_off(THRESHOLD_ON) +DEFAULT_THRESHOLD = DEFAULT_VISUAL_LIMITER_THRESHOLD +DEFAULT_RATIO = DEFAULT_VISUAL_LIMITER_RATIO +DEFAULT_RELEASE_TC = DEFAULT_VISUAL_LIMITER_RELEASE DELTA_WEIGHT = 0.85 -DUCK_GAIN = 0.50 -RELEASE_RAMP_SEC = DEFAULT_VISUAL_LIMITER_RELEASE -RELEASE_SEC = visual_limiter_release_hold_sec(RELEASE_RAMP_SEC) -ATTACK_SEC = 0.15 +ATTACK_TC = 0.08 +GAIN_FLOOR = 0.10 SEEK_JUMP_SEC = 0.25 GRID_WIDTH = 32 GRID_HEIGHT = 18 -# Lower rank is ducked first: bed < accent < pulse < lead. +# Lower rank absorbs more reduction: bed < accent < pulse < lead. _ROLE_RANK: dict[CueRole, int] = { "bed": 0, "accent": 1, @@ -53,23 +51,21 @@ "lead": 3, } _DEFAULT_ROLE_RANK = _ROLE_RANK["pulse"] +_PRIORITY_WEIGHT_BY_RANK: tuple[float, ...] = (1.0, 0.7, 0.4, 0.15) @dataclass(frozen=True) class VisualLimiterParams: - threshold_on: float = THRESHOLD_ON - threshold_off: float = THRESHOLD_OFF - release_ramp_sec: float = RELEASE_RAMP_SEC - release_sec: float = RELEASE_SEC + threshold: float = DEFAULT_THRESHOLD + ratio: float = DEFAULT_RATIO + release_tc: float = DEFAULT_RELEASE_TC @classmethod def from_runtime(cls, limiter: VisualLimiterRuntime) -> VisualLimiterParams: - ramp = float(limiter.release) return cls( - threshold_on=float(limiter.threshold), - threshold_off=visual_limiter_threshold_off(limiter.threshold), - release_ramp_sec=ramp, - release_sec=visual_limiter_release_hold_sec(ramp), + threshold=float(limiter.threshold), + ratio=float(limiter.ratio), + release_tc=float(limiter.release), ) @@ -85,14 +81,14 @@ class HotLayerRef: class VisualLimiterState: prev_luma: np.ndarray | None = None gains: dict[str, float] = field(default_factory=dict) - under_off_since: float | None = None + envelope: float = 0.0 last_t_sec: float | None = None controller_t_sec: float | None = None def reset(self) -> None: self.prev_luma = None self.gains.clear() - self.under_off_since = None + self.envelope = 0.0 self.last_t_sec = None self.controller_t_sec = None @@ -130,27 +126,60 @@ def role_rank(role: CueRole | None) -> int: return _ROLE_RANK.get(role, _DEFAULT_ROLE_RANK) +def priority_weight(role_rank_value: int) -> float: + if 0 <= role_rank_value < len(_PRIORITY_WEIGHT_BY_RANK): + return _PRIORITY_WEIGHT_BY_RANK[role_rank_value] + return _PRIORITY_WEIGHT_BY_RANK[_DEFAULT_ROLE_RANK] + + def busyness(mean_luma: float, mean_abs_delta: float) -> float: return float(mean_luma) + DELTA_WEIGHT * float(mean_abs_delta) -def pick_victim( - hot: list[HotLayerRef], +def compensated_busyness( + raw_busyness: float, gains: dict[str, float], -) -> str | None: - """Lowest-priority hot layer still above the duck floor.""" - candidates = [ - layer - for layer in hot - if gains.get(layer.slot, 1.0) > DUCK_GAIN + LEVEL_EPS - ] - if not candidates: - return None - chosen = min( - candidates, - key=lambda layer: (layer.role_rank, layer.timeline_level, layer.z_index), - ) - return chosen.slot +) -> float: + if not gains: + return raw_busyness + avg_gain = sum(gains.values()) / len(gains) + return raw_busyness / max(avg_gain, GAIN_FLOOR) + + +def target_bus_gain(envelope: float, *, threshold: float, ratio: float) -> float: + if envelope <= threshold + LEVEL_EPS: + return 1.0 + over_db = 20.0 * math.log10(envelope / threshold) + reduce_db = over_db * (1.0 - 1.0 / ratio) + gain = 10.0 ** (-reduce_db / 20.0) + return max(gain, GAIN_FLOOR) + + +def layer_gain_from_target(target_gain: float, weight: float) -> float: + return 1.0 - weight * (1.0 - target_gain) + + +def distribute_gain( + state: VisualLimiterState, + hot: list[HotLayerRef], + target_gain: float, +) -> None: + hot_slots = {layer.slot for layer in hot} + for slot in list(state.gains): + if slot not in hot_slots: + del state.gains[slot] + + if target_gain >= 1.0 - LEVEL_EPS: + state.gains.clear() + return + + for layer in hot: + weight = priority_weight(layer.role_rank) + gain = layer_gain_from_target(target_gain, weight) + if gain >= 1.0 - LEVEL_EPS: + state.gains.pop(layer.slot, None) + else: + state.gains[layer.slot] = gain def collect_hot_layers( @@ -198,33 +227,10 @@ def _playhead_dt(state: VisualLimiterState, t_sec: float) -> float: return max(0.0, t_sec - prev) -def _ramp_gain_toward(current: float, target: float, dt: float, duration_sec: float) -> float: - """Move ``current`` toward ``target`` over ``duration_sec`` of playhead time.""" - if duration_sec <= LEVEL_EPS or dt <= LEVEL_EPS: - return current - span = abs(1.0 - DUCK_GAIN) - if span <= LEVEL_EPS: - return target - step = span * (dt / duration_sec) - if current > target: - return max(target, current - step) - return min(target, current + step) - - -def _ramp_release( - state: VisualLimiterState, dt: float, *, release_ramp_sec: float -) -> None: - finished: list[str] = [] - for slot, gain in state.gains.items(): - new_gain = _ramp_gain_toward(gain, 1.0, dt, release_ramp_sec) - if new_gain >= 1.0 - LEVEL_EPS: - finished.append(slot) - else: - state.gains[slot] = new_gain - for slot in finished: - del state.gains[slot] - if not state.gains: - state.under_off_since = None +def _envelope_coeff(dt: float, time_constant: float) -> float: + if dt <= LEVEL_EPS or time_constant <= LEVEL_EPS: + return 0.0 + return 1.0 - math.exp(-dt / time_constant) def update_limiter_state( @@ -239,25 +245,21 @@ def update_limiter_state( """Pure controller step from measured busyness and hot-layer refs.""" knobs = params if params is not None else VisualLimiterParams() dt = _playhead_dt(state, t_sec) - score = busyness(mean_luma, mean_abs_delta) - if score > knobs.threshold_on: - state.under_off_since = None - victim = pick_victim(hot, state.gains) - if victim is not None: - current = state.gains.get(victim, 1.0) - new_gain = _ramp_gain_toward(current, DUCK_GAIN, dt, ATTACK_SEC) - if new_gain < 1.0 - LEVEL_EPS: - state.gains[victim] = new_gain - return + raw = busyness(mean_luma, mean_abs_delta) + compensated = compensated_busyness(raw, state.gains) - if score < knobs.threshold_off: - if state.under_off_since is None: - state.under_off_since = t_sec - elif t_sec - state.under_off_since >= knobs.release_sec: - _ramp_release(state, dt, release_ramp_sec=knobs.release_ramp_sec) - return + if compensated > state.envelope: + coeff = _envelope_coeff(dt, ATTACK_TC) + else: + coeff = _envelope_coeff(dt, knobs.release_tc) + state.envelope += coeff * (compensated - state.envelope) - state.under_off_since = None + target_gain = target_bus_gain( + state.envelope, + threshold=knobs.threshold, + ratio=knobs.ratio, + ) + distribute_gain(state, hot, target_gain) def read_luma_grid( diff --git a/cleave/viz/wiring.py b/cleave/viz/wiring.py index aae313a..5cee6f9 100644 --- a/cleave/viz/wiring.py +++ b/cleave/viz/wiring.py @@ -39,14 +39,13 @@ from cleave.viz.layer_visibility import apply_layer_visibility, effective_layer_enabled from cleave.viz.mix_player import MixPlayer from cleave.viz.preset_switching import ( - EMPTY_ROTATION_NOTIFICATION, - EMPTY_USER_PRESETS_NOTIFICATION, + EMPTY_PRESET_LIST_NOTIFICATION, apply_preset_switching, load_manual_preset_clean, - reanchor_timeline_preset_after_browse, + reanchor_list_preset_after_browse, reapply_projectm_preset_switching, resync_timeline_preset_switching, - sync_manual_browse_with_user_defined_rotation, + sync_manual_browse_with_list, ) from cleave.stem_pcm import StemPcmBank from cleave.viz.playback import current_sec, seek @@ -185,19 +184,16 @@ def make_tuning_controls( ) -> TuningControls: def _effective_preset_switching(slot: str) -> str: if not preset_switching_active(session): - return "none" + return "off" return session.layers[slot].preset_switching - def _empty_rotation_notify(slot: str) -> Callable[[], None]: + def _empty_list_notify() -> Callable[[], None]: def on_empty() -> None: if not preset_switching_active(session): return notify = notification_sink.get("fn") if notify is not None: - if session.layers[slot].preset_switching_rotation_set == "user_defined": - notify(EMPTY_USER_PRESETS_NOTIFICATION) - else: - notify(EMPTY_ROTATION_NOTIFICATION) + notify(EMPTY_PRESET_LIST_NOTIFICATION) return on_empty @@ -206,16 +202,19 @@ def on_preset_change(slot: str, playlist: PresetPlaylist) -> None: layer.playlist = playlist runtime = session.layers[slot] mode = _effective_preset_switching(slot) - rotation_set = runtime.preset_switching_rotation_set - if mode == "projectm" and rotation_set == "directory": + projectm_trigger = ( + mode == "on" + and runtime.preset_switching_trigger == "projectm" + ) + if projectm_trigger: current = playlist.current if current is not None: layer.auto_preset_path = current.resolve() apply_preset_switching( layer, mode=mode, - rotation_set=runtime.preset_switching_rotation_set, - user_presets=runtime.user_presets, + trigger=runtime.preset_switching_trigger, + preset_list=runtime.preset_list, preset_duration=runtime.preset_duration, soft_cut_duration=runtime.soft_cut_duration, easter_egg=runtime.easter_egg, @@ -223,12 +222,7 @@ def on_preset_change(slot: str, playlist: PresetPlaylist) -> None: hard_cut_enabled=runtime.hard_cut_enabled, hard_cut_duration=runtime.hard_cut_duration, hard_cut_sensitivity=runtime.hard_cut_sensitivity, - shuffle=runtime.preset_switching_shuffle, - shuffle_salt=runtime.preset_switching_shuffle_salt, - cast_roles_default_role=runtime.cast_roles_default_role, - cast_roles_timeline_behaviour=runtime.cast_roles_timeline_behaviour, - preset_root=preset_root, - on_empty=_empty_rotation_notify(slot), + on_empty=_empty_list_notify(), session=session, ) return @@ -237,24 +231,19 @@ def on_preset_change(slot: str, playlist: PresetPlaylist) -> None: load_manual_preset_clean( layer, preset_start_clean=runtime.preset_start_clean ) - if mode == "none": + if mode != "on": layer.pm.lock_preset(True) return - if mode == "timeline": - reanchor_timeline_preset_after_browse( + if runtime.preset_switching_trigger in ("timer", "timeline"): + reanchor_list_preset_after_browse( layer, session, current_sec(playback, duration_sec), - rotation_set=runtime.preset_switching_rotation_set, - user_presets=runtime.user_presets, - shuffle=runtime.preset_switching_shuffle, - shuffle_salt=runtime.preset_switching_shuffle_salt, - cast_roles_default_role=runtime.cast_roles_default_role, - preset_root=preset_root, + preset_list=runtime.preset_list, ) return layer.pm.lock_preset(False) - sync_manual_browse_with_user_defined_rotation(layer) + sync_manual_browse_with_list(layer) def on_preset_switching_change(slot: str) -> None: layer = layers_by_slot[slot] @@ -262,8 +251,8 @@ def on_preset_switching_change(slot: str) -> None: apply_preset_switching( layer, mode=_effective_preset_switching(slot), - rotation_set=runtime.preset_switching_rotation_set, - user_presets=runtime.user_presets, + trigger=runtime.preset_switching_trigger, + preset_list=runtime.preset_list, preset_duration=runtime.preset_duration, soft_cut_duration=runtime.soft_cut_duration, easter_egg=runtime.easter_egg, @@ -271,12 +260,7 @@ def on_preset_switching_change(slot: str) -> None: hard_cut_enabled=runtime.hard_cut_enabled, hard_cut_duration=runtime.hard_cut_duration, hard_cut_sensitivity=runtime.hard_cut_sensitivity, - shuffle=runtime.preset_switching_shuffle, - shuffle_salt=runtime.preset_switching_shuffle_salt, - cast_roles_default_role=runtime.cast_roles_default_role, - cast_roles_timeline_behaviour=runtime.cast_roles_timeline_behaviour, - preset_root=preset_root, - on_empty=_empty_rotation_notify(slot), + on_empty=_empty_list_notify(), session=session, ) @@ -285,10 +269,15 @@ def lock_preset_for_modal(slot: str) -> None: def unlock_preset_after_modal(slot: str) -> None: mode = _effective_preset_switching(slot) - if mode in ("none", "timeline"): - layers_by_slot[slot].pm.lock_preset(True) - else: + runtime = session.layers[slot] + projectm_trigger = ( + mode == "on" + and runtime.preset_switching_trigger == "projectm" + ) + if projectm_trigger: layers_by_slot[slot].pm.lock_preset(False) + else: + layers_by_slot[slot].pm.lock_preset(True) notification_sink: dict[str, Callable[[str], None] | None] = {"fn": None} diff --git a/docs/projectm-api-coverage.md b/docs/projectm-api-coverage.md index 430534d..9000234 100644 --- a/docs/projectm-api-coverage.md +++ b/docs/projectm-api-coverage.md @@ -75,8 +75,8 @@ Related: [legacy-plans/presets-scan-plan.md](legacy-plans/presets-scan-plan.md) | `projectm_playlist_connect` | bound, used | Attach to `ProjectM` | | `projectm_playlist_add_path` | bound, used | Directory rotation (`recurse=False`) | | `projectm_playlist_add_preset` | bound, used | Fallback when `add_presets` missing | -| `projectm_playlist_add_presets` | bound, used | User-defined rotation | -| `projectm_playlist_set_shuffle` | bound, used | Per-layer `preset_switching_shuffle` session field | +| `projectm_playlist_add_presets` | bound, used | Ordered `preset_switching_list` when trigger is projectM | +| `projectm_playlist_set_shuffle` | bound, used | Forced off for list order (no session shuffle field) | | `projectm_playlist_size` | bound, used | Position sync | | `projectm_playlist_get_position` | bound, ignored | Position set only | | `projectm_playlist_set_position` | bound, used | Browse sync | diff --git a/tests/cleave/test_config.py b/tests/cleave/test_config.py index 4075b6a..8e7b39d 100644 --- a/tests/cleave/test_config.py +++ b/tests/cleave/test_config.py @@ -1238,6 +1238,7 @@ def test_persist_timeline_limiter_round_trip() -> None: limiter=VisualLimiterRuntime( enabled=False, threshold=0.72, + ratio=4.0, release=0.6, ), ), @@ -1254,6 +1255,7 @@ def test_persist_timeline_limiter_round_trip() -> None: assert payload["limiter"] == { "enabled": False, "threshold": 0.72, + "ratio": 4.0, "release": 0.6, } round_trip = parse_timeline_section( @@ -1264,6 +1266,7 @@ def test_persist_timeline_limiter_round_trip() -> None: assert round_trip.limiter == TimelineLimiterConfig( enabled=False, threshold=0.72, + ratio=4.0, release=0.6, ) @@ -1281,7 +1284,7 @@ def test_persist_timeline_preset_round_trip() -> None: timeline_preset_cue_snap="bars", timeline_preset_song_marker_snap=2.0, timeline_preset_timeline_cuts="all hard", - timeline_preset_reshuffle=True, + timeline_preset_repopulate="cue roles", timeline_preset_conductor=True, ), ) @@ -1301,7 +1304,7 @@ def test_persist_timeline_preset_round_trip() -> None: "cue_snap": "bars", "song_marker_snap": 2.0, "timeline_cuts": "all hard", - "reshuffle": True, + "repopulate": "cue roles", "conductor": True, } round_trip = parse_timeline_section( @@ -1316,7 +1319,7 @@ def test_persist_timeline_preset_round_trip() -> None: cue_snap="bars", song_marker_snap=2.0, timeline_cuts="all hard", - reshuffle=True, + repopulate="cue roles", conductor=True, ) @@ -1332,7 +1335,7 @@ def test_parse_timeline_reads_preset() -> None: "cue_snap": "beats", "song_marker_snap": 1.0, "timeline_cuts": "none", - "reshuffle": True, + "repopulate": "directory sequential", "conductor": True, } } @@ -1347,7 +1350,7 @@ def test_parse_timeline_reads_preset() -> None: cue_snap="beats", song_marker_snap=1.0, timeline_cuts="none", - reshuffle=True, + repopulate="directory sequential", conductor=True, ) @@ -1364,7 +1367,7 @@ def test_parse_timeline_preset_null_crescendo() -> None: assert timeline.preset.cue_snap == "none" assert timeline.preset.song_marker_snap is None assert timeline.preset.timeline_cuts == "by marker" - assert timeline.preset.reshuffle is False + assert timeline.preset.repopulate == "no" assert timeline.preset.conductor is False @@ -1416,10 +1419,10 @@ def test_parse_timeline_rejects_invalid_preset_timeline_cuts() -> None: ) -def test_parse_timeline_rejects_invalid_preset_reshuffle() -> None: - with pytest.raises(ValueError, match="reshuffle"): +def test_parse_timeline_rejects_invalid_preset_repopulate() -> None: + with pytest.raises(ValueError, match="repopulate"): parse_timeline_section( - {"timeline": {"preset": {"reshuffle": "on"}}}, + {"timeline": {"preset": {"repopulate": "on"}}}, _timeline_parse_ctx(), ) diff --git a/tests/cleave/test_config_snapshot.py b/tests/cleave/test_config_snapshot.py index e98c7a3..f371471 100644 --- a/tests/cleave/test_config_snapshot.py +++ b/tests/cleave/test_config_snapshot.py @@ -1226,12 +1226,13 @@ def test_write_session_snapshot_persists_timeline_disabled_without_cues( "cue_snap": "none", "song_marker_snap": None, "timeline_cuts": "by marker", - "reshuffle": False, + "repopulate": "no", "conductor": False, }, "limiter": { "enabled": True, "threshold": 0.65, + "ratio": 3.0, "release": 0.45, }, } @@ -1245,7 +1246,7 @@ def test_write_session_snapshot_round_trips_timeline_preset(tmp_path: Path) -> N session.timeline.timeline_preset_cue_snap = "beats" session.timeline.timeline_preset_song_marker_snap = 5.0 session.timeline.timeline_preset_timeline_cuts = "all soft" - session.timeline.timeline_preset_reshuffle = True + session.timeline.timeline_preset_repopulate = "directory random" session.timeline.timeline_preset_conductor = True write_session_snapshot(out_path, cfg=cfg, session=session) @@ -1257,7 +1258,7 @@ def test_write_session_snapshot_round_trips_timeline_preset(tmp_path: Path) -> N "cue_snap": "beats", "song_marker_snap": 5.0, "timeline_cuts": "all soft", - "reshuffle": True, + "repopulate": "directory random", "conductor": True, } @@ -1283,7 +1284,7 @@ def test_write_session_snapshot_round_trips_timeline_preset(tmp_path: Path) -> N assert session2.timeline.timeline_preset_cue_snap == "beats" assert session2.timeline.timeline_preset_song_marker_snap == 5.0 assert session2.timeline.timeline_preset_timeline_cuts == "all soft" - assert session2.timeline.timeline_preset_reshuffle is True + assert session2.timeline.timeline_preset_repopulate == "directory random" assert session2.timeline.timeline_preset_conductor is True diff --git a/tests/cleave/test_preset_curation.py b/tests/cleave/test_preset_curation.py index f7f6c9a..d01deae 100644 --- a/tests/cleave/test_preset_curation.py +++ b/tests/cleave/test_preset_curation.py @@ -135,6 +135,13 @@ def test_preset_curation_index_mark_updates_sets() -> None: assert index.marker("c.milk") == " [R:L]" index.unmark_role("c.milk", "lead") assert index.roles == {} + + +def test_curation_stamp_changes_on_mark() -> None: + index = PresetCurationIndex(favourites=set(), blacklist=set()) + stamp_before = index.curation_stamp + index.mark_favourite("foo.milk") + assert index.curation_stamp != stamp_before assert index.marker("c.milk") == "" @@ -435,7 +442,7 @@ def test_delete_role_milk_missing_returns_none() -> None: @dataclass class _LayerStub: - user_presets: list[str] = field(default_factory=list) + preset_list: list[str] = field(default_factory=list) def test_relocate_curated_milk_moves_between_sibling_dirs() -> None: @@ -521,15 +528,15 @@ def test_rewrite_user_preset_paths_updates_matching_entries() -> None: _write(kept, "kept") layers = { - "layer_1": _LayerStub(user_presets=[str(old), str(kept)]), - "layer_2": _LayerStub(user_presets=[str(kept)]), + "layer_1": _LayerStub(preset_list=[str(old), str(kept)]), + "layer_2": _LayerStub(preset_list=[str(kept)]), } affected = rewrite_user_preset_paths(layers, old, new) assert affected == ["layer_1"] - assert layers["layer_1"].user_presets == [str(new), str(kept)] - assert layers["layer_2"].user_presets == [str(kept)] + assert layers["layer_1"].preset_list == [str(new), str(kept)] + assert layers["layer_2"].preset_list == [str(kept)] def test_scrub_user_preset_paths_removes_matching_entries() -> None: @@ -543,21 +550,21 @@ def test_scrub_user_preset_paths_removes_matching_entries() -> None: _write(other, "other") layers = { - "layer_1": _LayerStub(user_presets=[str(removed), str(kept)]), - "layer_2": _LayerStub(user_presets=[str(other)]), - "layer_3": _LayerStub(user_presets=[str(removed.resolve())]), + "layer_1": _LayerStub(preset_list=[str(removed), str(kept)]), + "layer_2": _LayerStub(preset_list=[str(other)]), + "layer_3": _LayerStub(preset_list=[str(removed.resolve())]), } affected = scrub_user_preset_paths(layers, removed) assert affected == ["layer_1", "layer_3"] - assert layers["layer_1"].user_presets == [str(kept)] - assert layers["layer_2"].user_presets == [str(other)] - assert layers["layer_3"].user_presets == [] + assert layers["layer_1"].preset_list == [str(kept)] + assert layers["layer_2"].preset_list == [str(other)] + assert layers["layer_3"].preset_list == [] def test_scrub_user_preset_paths_no_match() -> None: - layers = {"layer_1": _LayerStub(user_presets=["/tmp/a.milk"])} + layers = {"layer_1": _LayerStub(preset_list=["/tmp/a.milk"])} assert scrub_user_preset_paths(layers, Path("/tmp/missing.milk")) == [] diff --git a/tests/cleave/test_preset_playlist.py b/tests/cleave/test_preset_playlist.py index ae5053e..b5cff67 100644 --- a/tests/cleave/test_preset_playlist.py +++ b/tests/cleave/test_preset_playlist.py @@ -11,6 +11,7 @@ from cleave.preset_playlist import ( directory_display, list_navigable_dirs, + path_under_preset_root, playlist_at_dir, preset_browse_floor, preset_filename_display, @@ -263,6 +264,19 @@ def test_directory_display_clamps_sibling_parent_at_preset_root() -> None: assert label == "[▼]./ (1/2)" +def test_directory_display_label_outside_preset_root() -> None: + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) / "presets" + outside = Path(tmp) / "user-presets" + root.mkdir() + outside.mkdir() + _write_milk(outside / "a.milk") + playlist = playlist_at_dir(outside) + label = playlist.directory_display_label(root) + assert outside.resolve().as_posix() in label + assert not path_under_preset_root(outside, root) + + def test_directory_display_label_caches_on_repeated_calls() -> None: with tempfile.TemporaryDirectory() as tmp: root = Path(tmp) diff --git a/tests/cleave/test_preset_rotation.py b/tests/cleave/test_preset_rotation.py index 055ea3e..b57b506 100644 --- a/tests/cleave/test_preset_rotation.py +++ b/tests/cleave/test_preset_rotation.py @@ -1,104 +1,28 @@ -"""Tests for deterministic PresetRotation.""" +"""Tests for seek-stable list indexing via PresetRotation.""" from __future__ import annotations from pathlib import Path -from cleave.preset_rotation import ( - PresetRotation, - first_shuffle_bag_order, - layer_rotation_seed, - rotation_seed, -) +from cleave.preset_rotation import PresetRotation -def _paths(*names: str) -> tuple[Path, ...]: - return tuple(Path(name) for name in names) - - -def test_path_for_empty_returns_none() -> None: - rotation = PresetRotation(paths=(), shuffle=False, seed=1, anchor=0) - assert rotation.path_for(0) is None - assert rotation.path_for(3) is None - - -def test_non_shuffle_wraps_with_anchor() -> None: - paths = _paths("a.milk", "b.milk", "c.milk") - rotation = PresetRotation(paths=paths, shuffle=False, seed=0, anchor=1) +def test_path_for_wraps_with_anchor() -> None: + paths = (Path("a.milk"), Path("b.milk"), Path("c.milk")) + rotation = PresetRotation(paths=paths, anchor=1) assert rotation.path_for(0) == Path("b.milk") assert rotation.path_for(1) == Path("c.milk") assert rotation.path_for(2) == Path("a.milk") assert rotation.path_for(3) == Path("b.milk") -def test_non_shuffle_anchor_normalizes_modulo() -> None: - paths = _paths("a.milk", "b.milk") - rotation = PresetRotation(paths=paths, shuffle=False, seed=0, anchor=5) - assert rotation.anchor == 1 - assert rotation.path_for(0) == Path("b.milk") - - -def test_shuffle_no_repeat_within_block() -> None: - paths = _paths("a.milk", "b.milk", "c.milk", "d.milk") - rotation = PresetRotation(paths=paths, shuffle=True, seed=42, anchor=0) - first_block = [rotation.path_for(i) for i in range(4)] - assert sorted(first_block) == sorted(paths) - assert len(set(first_block)) == 4 - second_block = [rotation.path_for(i) for i in range(4, 8)] - assert sorted(second_block) == sorted(paths) - assert len(set(second_block)) == 4 - - -def test_shuffle_deterministic_by_seed() -> None: - paths = _paths("a.milk", "b.milk", "c.milk") - a = PresetRotation(paths=paths, shuffle=True, seed=7, anchor=0) - b = PresetRotation(paths=paths, shuffle=True, seed=7, anchor=0) - c = PresetRotation(paths=paths, shuffle=True, seed=8, anchor=0) - seq_a = [a.path_for(i) for i in range(9)] - seq_b = [b.path_for(i) for i in range(9)] - seq_c = [c.path_for(i) for i in range(9)] - assert seq_a == seq_b - assert seq_a != seq_c - - -def test_shuffle_respects_anchor_offset() -> None: - paths = _paths("a.milk", "b.milk", "c.milk") - base = PresetRotation(paths=paths, shuffle=True, seed=11, anchor=0) - offset = PresetRotation(paths=paths, shuffle=True, seed=11, anchor=2) - assert offset.path_for(0) == base.path_for(2) - assert offset.path_for(1) == base.path_for(3) - - -def test_rotation_seed_stable_across_calls() -> None: - paths = _paths("a.milk", "b.milk") - assert rotation_seed(paths, salt="layer_1") == rotation_seed( - paths, salt="layer_1" - ) - assert rotation_seed(paths, salt="layer_1") != rotation_seed( - paths, salt="layer_2" - ) - - -def test_layer_rotation_seed_mixes_slot_and_salt() -> None: - paths = _paths("a.milk", "b.milk", "c.milk") - same_a = layer_rotation_seed(paths, slot="layer_1", shuffle_salt=7) - same_b = layer_rotation_seed(paths, slot="layer_1", shuffle_salt=7) - other_salt = layer_rotation_seed(paths, slot="layer_1", shuffle_salt=8) - other_slot = layer_rotation_seed(paths, slot="layer_2", shuffle_salt=7) - assert same_a == same_b - assert same_a != other_salt - assert same_a != other_slot - assert same_a == rotation_seed(paths, salt="layer_1:7") +def test_path_for_empty_returns_none() -> None: + rotation = PresetRotation(paths=()) + assert rotation.path_for(0) is None -def test_first_shuffle_bag_order_deterministic_by_salt() -> None: - paths = list(_paths("a.milk", "b.milk", "c.milk", "d.milk")) - seed_a = layer_rotation_seed(paths, slot="layer_1", shuffle_salt=3) - seed_b = layer_rotation_seed(paths, slot="layer_1", shuffle_salt=4) - order_a = first_shuffle_bag_order(paths, seed=seed_a) - order_a_again = first_shuffle_bag_order(paths, seed=seed_a) - order_b = first_shuffle_bag_order(paths, seed=seed_b) - assert order_a == order_a_again - assert order_a != order_b - assert sorted(order_a) == sorted(paths) - assert len(set(order_a)) == len(paths) +def test_anchor_normalizes_out_of_range() -> None: + paths = (Path("a.milk"), Path("b.milk")) + rotation = PresetRotation(paths=paths, anchor=5) + assert rotation.anchor == 1 + assert rotation.path_for(0) == Path("b.milk") diff --git a/tests/cleave/test_preset_scan_targets.py b/tests/cleave/test_preset_scan_targets.py index 37e7929..3ac4a96 100644 --- a/tests/cleave/test_preset_scan_targets.py +++ b/tests/cleave/test_preset_scan_targets.py @@ -29,17 +29,15 @@ def _layer_config( *, stem: str = "drums", enabled: bool = True, - preset_switching: str = "projectm", - preset_switching_rotation_set: str = "directory", - preset_switching_presets: list[Path] | None = None, + preset_switching: str = "on", + preset_switching_list: list[Path] | None = None, ) -> LayerConfig: return LayerConfig( preset=preset, stem=stem, # type: ignore[arg-type] enabled=enabled, preset_switching=preset_switching, # type: ignore[arg-type] - preset_switching_rotation_set=preset_switching_rotation_set, # type: ignore[arg-type] - preset_switching_presets=preset_switching_presets or [], + preset_switching_list=preset_switching_list or [], ) @@ -80,7 +78,7 @@ def test_project_targets_include_sibling_presets_non_recursive() -> None: cfg = _project_cfg( root, layers={ - "layer_1": _layer_config(anchor, preset_switching="projectm"), + "layer_1": _layer_config(anchor, preset_switching="on"), }, ) targets = build_project_targets(cfg) @@ -105,7 +103,7 @@ def test_project_targets_include_anchor_dir_when_preset_switching_none() -> None cfg = _project_cfg( root, layers={ - "layer_1": _layer_config(locked, preset_switching="none"), + "layer_1": _layer_config(locked, preset_switching="off"), }, ) targets = build_project_targets(cfg) @@ -154,9 +152,8 @@ def test_project_targets_user_defined_adds_rotation_presets() -> None: layers={ "layer_1": _layer_config( anchor, - preset_switching="projectm", - preset_switching_rotation_set="user_defined", - preset_switching_presets=[extra], + preset_switching="on", + preset_switching_list=[extra], ), }, ) @@ -190,12 +187,12 @@ def test_project_targets_dedup_presets_across_layers() -> None: "layer_1": _layer_config( shared, stem="drums", - preset_switching="none", + preset_switching="off", ), "layer_2": _layer_config( other, stem="bass", - preset_switching="none", + preset_switching="off", ), }, layer_z_order=["layer_1", "layer_2"], @@ -219,7 +216,7 @@ def test_project_targets_respect_layer_z_order_slots() -> None: layers[slot] = _layer_config( preset, stem=STEM_NAMES[index], - preset_switching="none", + preset_switching="off", ) cfg = _project_cfg( @@ -294,7 +291,7 @@ def test_project_targets_include_role_pool_presets() -> None: cfg = _project_cfg( root, layers={ - "layer_1": _layer_config(anchor, preset_switching="timeline"), + "layer_1": _layer_config(anchor, preset_switching="on"), }, ) targets = build_project_targets(cfg) diff --git a/tests/cleave/test_preset_switching_config.py b/tests/cleave/test_preset_switching_config.py index 9ddff83..ea42fa9 100644 --- a/tests/cleave/test_preset_switching_config.py +++ b/tests/cleave/test_preset_switching_config.py @@ -1,15 +1,14 @@ -"""Config parse/persist for preset switching fields.""" +"""Config parse/persist for unified preset switching fields.""" from __future__ import annotations from pathlib import Path +import pytest import yaml -from cleave.config import CleaveConfig, LayerConfig, PathsConfig, EditorConfig, load_config +from cleave.config import CleaveConfig, LayerConfig, PathsConfig, EditorConfig from cleave.config_schema import ( - DEFAULT_CAST_ROLES_DEFAULT_ROLE, - DEFAULT_CAST_ROLES_TIMELINE_BEHAVIOUR, DEFAULT_EASTER_EGG, DEFAULT_HARD_CUT_DURATION, DEFAULT_HARD_CUT_ENABLED, @@ -17,9 +16,7 @@ DEFAULT_PRESET_START_CLEAN, DEFAULT_PRESET_DURATION, DEFAULT_PRESET_SWITCHING, - DEFAULT_PRESET_SWITCHING_ROTATION_SET, - DEFAULT_PRESET_SWITCHING_SHUFFLE, - DEFAULT_PRESET_SWITCHING_SHUFFLE_SALT, + DEFAULT_PRESET_SWITCHING_TRIGGER, DEFAULT_SOFT_CUT_DURATION, ParseCtx, parse_layers_section, @@ -49,11 +46,7 @@ def test_parse_layers_preset_switching_defaults_omitted() -> None: layers = parse_layers_section(data, ParseCtx(preset_root=preset_root)) layer = layers["layer_1"] assert layer.preset_switching == DEFAULT_PRESET_SWITCHING - assert layer.preset_switching_rotation_set == DEFAULT_PRESET_SWITCHING_ROTATION_SET - assert layer.cast_roles_timeline_behaviour == DEFAULT_CAST_ROLES_TIMELINE_BEHAVIOUR - assert layer.cast_roles_default_role == DEFAULT_CAST_ROLES_DEFAULT_ROLE - assert layer.preset_switching_shuffle == DEFAULT_PRESET_SWITCHING_SHUFFLE - assert layer.preset_switching_shuffle_salt == DEFAULT_PRESET_SWITCHING_SHUFFLE_SALT + assert layer.preset_switching_trigger == DEFAULT_PRESET_SWITCHING_TRIGGER assert layer.preset_duration == DEFAULT_PRESET_DURATION assert layer.soft_cut_duration == DEFAULT_SOFT_CUT_DURATION assert layer.hard_cut_duration == DEFAULT_HARD_CUT_DURATION @@ -61,100 +54,75 @@ def test_parse_layers_preset_switching_defaults_omitted() -> None: assert layer.hard_cut_enabled == DEFAULT_HARD_CUT_ENABLED assert layer.easter_egg == DEFAULT_EASTER_EGG assert layer.preset_start_clean == DEFAULT_PRESET_START_CLEAN + assert layer.preset_switching_list == [] -def test_parse_layers_preset_switching_projectm() -> None: +def test_parse_layers_preset_switching_on() -> None: preset_root = Path("/tmp/presets") data = {"layers": _layer_yaml()} - data["layers"]["layer_1"]["preset_switching"] = "projectm" + data["layers"]["layer_1"]["preset_switching"] = "on" + data["layers"]["layer_1"]["preset_switching_trigger"] = "projectm" layers = parse_layers_section(data, ParseCtx(preset_root=preset_root)) - assert layers["layer_1"].preset_switching == "projectm" + assert layers["layer_1"].preset_switching == "on" + assert layers["layer_1"].preset_switching_trigger == "projectm" -def test_parse_layers_preset_switching_timeline() -> None: +def test_parse_layers_rejects_legacy_modes() -> None: preset_root = Path("/tmp/presets") - data = {"layers": _layer_yaml()} - data["layers"]["layer_1"]["preset_switching"] = "timeline" - layers = parse_layers_section(data, ParseCtx(preset_root=preset_root)) - assert layers["layer_1"].preset_switching == "timeline" + for mode in ("none", "projectm", "timeline", "user_defined"): + data = {"layers": _layer_yaml()} + data["layers"]["layer_1"]["preset_switching"] = mode + with pytest.raises(ValueError, match="preset_switching"): + parse_layers_section(data, ParseCtx(preset_root=preset_root)) -def test_parse_layers_rejects_user_defined_mode() -> None: - import pytest - +def test_parse_layers_rejects_bad_trigger() -> None: preset_root = Path("/tmp/presets") data = {"layers": _layer_yaml()} - data["layers"]["layer_1"]["preset_switching"] = "user_defined" - with pytest.raises(ValueError, match="preset_switching"): + data["layers"]["layer_1"]["preset_switching_trigger"] = "beat" + with pytest.raises(ValueError, match="preset_switching_trigger"): parse_layers_section(data, ParseCtx(preset_root=preset_root)) -def test_parse_layers_preset_switching_rotation_set_user_defined() -> None: - preset_root = Path("/tmp/presets") - data = {"layers": _layer_yaml()} - data["layers"]["layer_1"]["preset_switching"] = "projectm" - data["layers"]["layer_1"]["preset_switching_rotation_set"] = "user_defined" - layers = parse_layers_section(data, ParseCtx(preset_root=preset_root)) - assert layers["layer_1"].preset_switching == "projectm" - assert layers["layer_1"].preset_switching_rotation_set == "user_defined" - - -def test_parse_layers_preset_switching_rotation_set_cast_roles() -> None: +def test_parse_layers_accepts_timeline_trigger() -> None: preset_root = Path("/tmp/presets") data = {"layers": _layer_yaml()} - data["layers"]["layer_1"]["preset_switching"] = "timeline" - data["layers"]["layer_1"]["preset_switching_rotation_set"] = "cast_roles" - data["layers"]["layer_1"]["cast_roles_timeline_behaviour"] = "hold_current" - data["layers"]["layer_1"]["cast_roles_default_role"] = "pulse" + data["layers"]["layer_1"]["preset_switching"] = "on" + data["layers"]["layer_1"]["preset_switching_trigger"] = "timeline" layers = parse_layers_section(data, ParseCtx(preset_root=preset_root)) - layer = layers["layer_1"] - assert layer.preset_switching_rotation_set == "cast_roles" - assert layer.cast_roles_timeline_behaviour == "hold_current" - assert layer.cast_roles_default_role == "pulse" - - -def test_parse_layers_rejects_invalid_cast_roles_timeline_behaviour() -> None: - import pytest + assert layers["layer_1"].preset_switching_trigger == "timeline" - preset_root = Path("/tmp/presets") - data = {"layers": _layer_yaml()} - data["layers"]["layer_1"]["cast_roles_timeline_behaviour"] = "always" - with pytest.raises(ValueError, match="cast_roles_timeline_behaviour"): - parse_layers_section(data, ParseCtx(preset_root=preset_root)) - -def test_parse_layers_preset_switching_timing() -> None: - preset_root = Path("/tmp/presets") +def test_parse_preset_switching_list_relative_to_cfg_dir(tmp_path: Path) -> None: + milk = tmp_path / "user-presets" / "a.milk" + milk.parent.mkdir() + milk.write_text("MILK") + preset_root = tmp_path / "presets" + preset_root.mkdir() data = {"layers": _layer_yaml()} - data["layers"]["layer_1"].update( - { - "preset_duration": 45.0, - "soft_cut_duration": 1.5, - "hard_cut_duration": 30.0, - "hard_cut_sensitivity": 3.5, - } + data["layers"]["layer_1"]["preset_switching"] = "on" + data["layers"]["layer_1"]["preset_switching_list"] = ["user-presets/a.milk"] + layers = parse_layers_section( + data, ParseCtx(preset_root=preset_root, cfg_dir=tmp_path) ) - layers = parse_layers_section(data, ParseCtx(preset_root=preset_root)) - layer = layers["layer_1"] - assert layer.preset_duration == 45.0 - assert layer.soft_cut_duration == 1.5 - assert layer.hard_cut_duration == 30.0 - assert layer.hard_cut_sensitivity == 3.5 + assert layers["layer_1"].preset_switching_list == [milk.resolve()] -def _cfg_and_session(*, preset_switching: str = "none") -> tuple[CleaveConfig, TuningSession]: - preset_root = Path("/tmp/presets") +def test_persist_omits_defaults() -> None: cfg = CleaveConfig( - paths=PathsConfig(preset_root=preset_root, texture_paths=(preset_root / "tex",)), + paths=PathsConfig( + preset_root=Path("/tmp/presets"), + texture_paths=(Path("/tmp/textures"),), + ), layers={ "layer_1": LayerConfig( - preset=preset_root / "drums" / "drums.milk", + preset=Path("/tmp/presets/drums/a.milk"), stem="drums", ) }, - layer_z_order=["layer_1"], editor=EditorConfig(), - config_path=Path("/tmp/test/cleave.config.yaml"), + layer_z_order=["layer_1"], + config_path=Path("/tmp/cleave.config.yaml"), user_config_path=Path("/tmp/user-config.yaml"), ) session = TuningSession( @@ -162,153 +130,82 @@ def _cfg_and_session(*, preset_switching: str = "none") -> tuple[CleaveConfig, T layers={ "layer_1": LayerRuntime( playlist=PresetPlaylist( - current_dir=preset_root / "drums", - paths=(preset_root / "drums" / "drums.milk",), + current_dir=Path("/tmp/presets/drums"), + paths=(Path("/tmp/presets/drums/a.milk"),), index=0, ), - browse_floor=preset_root / "drums", + browse_floor=Path("/tmp/presets/drums"), stem="drums", - preset_switching=preset_switching, ) }, ) - return cfg, session - + out = persist_layers(PersistCtx(cfg=cfg, session=session, cfg_dir=Path("/tmp"))) + layer_out = out["layer_1"] + assert "preset_switching" not in layer_out + assert "preset_switching_trigger" not in layer_out + assert "preset_switching_list" not in layer_out -def test_persist_layers_omits_default_preset_switching() -> None: - cfg, session = _cfg_and_session() - out = persist_layers(PersistCtx(cfg=cfg, session=session)) - assert "preset_switching" not in out["layer_1"] - assert "preset_switching_rotation_set" not in out["layer_1"] - assert "cast_roles_timeline_behaviour" not in out["layer_1"] - assert "cast_roles_default_role" not in out["layer_1"] - assert "preset_switching_shuffle" not in out["layer_1"] - assert "preset_switching_shuffle_salt" not in out["layer_1"] - assert "preset_duration" not in out["layer_1"] - assert "soft_cut_duration" not in out["layer_1"] - assert "hard_cut_duration" not in out["layer_1"] - assert "hard_cut_sensitivity" not in out["layer_1"] - assert "hard_cut_enabled" not in out["layer_1"] - assert "easter_egg" not in out["layer_1"] - assert "preset_start_clean" not in out["layer_1"] - -def test_persist_layers_writes_timing_overrides() -> None: - cfg, session = _cfg_and_session(preset_switching="projectm") - runtime = session.layers["layer_1"] - runtime.preset_duration = 45.0 - runtime.soft_cut_duration = 1.5 - runtime.hard_cut_duration = 30.0 - runtime.hard_cut_sensitivity = 3.5 - out = persist_layers(PersistCtx(cfg=cfg, session=session)) - assert out["layer_1"]["preset_duration"] == 45.0 - assert out["layer_1"]["soft_cut_duration"] == 1.5 - assert out["layer_1"]["hard_cut_duration"] == 30.0 - assert out["layer_1"]["hard_cut_sensitivity"] == 3.5 - - -def test_parse_layers_preset_switching_shuffle() -> None: - preset_root = Path("/tmp/presets") - data = {"layers": _layer_yaml()} - data["layers"]["layer_1"]["preset_switching_shuffle"] = True - layers = parse_layers_section(data, ParseCtx(preset_root=preset_root)) - assert layers["layer_1"].preset_switching_shuffle is True - - -def test_persist_layers_writes_shuffle_when_enabled() -> None: - cfg, session = _cfg_and_session(preset_switching="projectm") - session.layers["layer_1"].preset_switching_shuffle = True - out = persist_layers(PersistCtx(cfg=cfg, session=session)) - assert out["layer_1"]["preset_switching_shuffle"] is True - - -def test_parse_layers_preset_switching_shuffle_salt() -> None: - preset_root = Path("/tmp/presets") - data = {"layers": _layer_yaml()} - data["layers"]["layer_1"]["preset_switching_shuffle_salt"] = 42 - layers = parse_layers_section(data, ParseCtx(preset_root=preset_root)) - assert layers["layer_1"].preset_switching_shuffle_salt == 42 - - -def test_persist_layers_writes_shuffle_salt_when_nonzero() -> None: - cfg, session = _cfg_and_session(preset_switching="projectm") - session.layers["layer_1"].preset_switching_shuffle_salt = 99 - out = persist_layers(PersistCtx(cfg=cfg, session=session)) - assert out["layer_1"]["preset_switching_shuffle_salt"] == 99 - - -def test_persist_layers_omits_zero_shuffle_salt() -> None: - cfg, session = _cfg_and_session(preset_switching="projectm") - session.layers["layer_1"].preset_switching_shuffle_salt = 0 - out = persist_layers(PersistCtx(cfg=cfg, session=session)) - assert "preset_switching_shuffle_salt" not in out["layer_1"] - - -def test_parse_layers_preset_switching_easter_egg_and_start_clean() -> None: - preset_root = Path("/tmp/presets") - data = {"layers": _layer_yaml()} - data["layers"]["layer_1"].update( - { - "easter_egg": 2.5, - "preset_start_clean": True, - } +def test_persist_writes_non_defaults(tmp_path: Path) -> None: + milk = tmp_path / "user-presets" / "a.milk" + milk.parent.mkdir() + milk.write_text("MILK") + cfg = CleaveConfig( + paths=PathsConfig( + preset_root=tmp_path / "presets", + texture_paths=(tmp_path / "textures",), + ), + layers={ + "layer_1": LayerConfig( + preset=tmp_path / "presets" / "drums" / "a.milk", + stem="drums", + ) + }, + editor=EditorConfig(), + layer_z_order=["layer_1"], + config_path=tmp_path / "cleave.config.yaml", + user_config_path=tmp_path / "user-config.yaml", ) - layers = parse_layers_section(data, ParseCtx(preset_root=preset_root)) - layer = layers["layer_1"] - assert layer.easter_egg == 2.5 - assert layer.preset_start_clean is True - - -def test_parse_layers_easter_egg_clamps() -> None: - preset_root = Path("/tmp/presets") - data = {"layers": _layer_yaml()} - data["layers"]["layer_1"]["easter_egg"] = 99.0 - layers = parse_layers_section(data, ParseCtx(preset_root=preset_root)) - assert layers["layer_1"].easter_egg == 5.0 - - -def test_persist_layers_writes_hard_cut_disabled() -> None: - cfg, session = _cfg_and_session(preset_switching="projectm") - session.layers["layer_1"].hard_cut_enabled = False - out = persist_layers(PersistCtx(cfg=cfg, session=session)) - assert out["layer_1"]["hard_cut_enabled"] is False - - -def test_persist_layers_writes_user_defined_rotation_set() -> None: - cfg, session = _cfg_and_session(preset_switching="projectm") - session.layers["layer_1"].preset_switching_rotation_set = "user_defined" - out = persist_layers(PersistCtx(cfg=cfg, session=session)) - assert out["layer_1"]["preset_switching"] == "projectm" - assert out["layer_1"]["preset_switching_rotation_set"] == "user_defined" - - -def test_persist_layers_writes_cast_roles_fields() -> None: - cfg, session = _cfg_and_session(preset_switching="timeline") - runtime = session.layers["layer_1"] - runtime.preset_switching_rotation_set = "cast_roles" - runtime.cast_roles_timeline_behaviour = "hold_current" - runtime.cast_roles_default_role = "accent" - out = persist_layers(PersistCtx(cfg=cfg, session=session)) - assert out["layer_1"]["preset_switching_rotation_set"] == "cast_roles" - assert out["layer_1"]["cast_roles_timeline_behaviour"] == "hold_current" - assert out["layer_1"]["cast_roles_default_role"] == "accent" + session = TuningSession( + layer_z_order=["layer_1"], + layers={ + "layer_1": LayerRuntime( + playlist=PresetPlaylist( + current_dir=tmp_path / "presets" / "drums", + paths=(tmp_path / "presets" / "drums" / "a.milk",), + index=0, + ), + browse_floor=tmp_path / "presets" / "drums", + stem="drums", + preset_switching="on", + preset_switching_trigger="projectm", + preset_list=[str(milk.resolve())], + ) + }, + ) + out = persist_layers(PersistCtx(cfg=cfg, session=session, cfg_dir=tmp_path)) + layer_out = out["layer_1"] + assert layer_out["preset_switching"] == "on" + assert layer_out["preset_switching_trigger"] == "projectm" + assert layer_out["preset_switching_list"] == ["user-presets/a.milk"] -def test_persist_layers_writes_projectm_mode(tmp_path: Path) -> None: - preset_root = Path("/tmp/presets") - config_path = tmp_path / "cleave.config.yaml" - config_path.write_text("layers: {}\n", encoding="utf-8") +def test_persist_list_outside_cfg_dir_keeps_absolute() -> None: + outside = Path("/tmp/presets/layer_1/a.milk") cfg = CleaveConfig( - paths=PathsConfig(preset_root=preset_root, texture_paths=(preset_root / "tex",)), + paths=PathsConfig( + preset_root=Path("/tmp/presets"), + texture_paths=(), + ), layers={ "layer_1": LayerConfig( - preset=preset_root / "drums" / "drums.milk", + preset=outside, stem="drums", ) }, - layer_z_order=["layer_1"], editor=EditorConfig(), - config_path=config_path, + layer_z_order=["layer_1"], + config_path=Path("/tmp/test/cleave.config.yaml"), user_config_path=Path("/tmp/user-config.yaml"), ) session = TuningSession( @@ -316,96 +213,71 @@ def test_persist_layers_writes_projectm_mode(tmp_path: Path) -> None: layers={ "layer_1": LayerRuntime( playlist=PresetPlaylist( - current_dir=preset_root / "drums", - paths=(preset_root / "drums" / "drums.milk",), + current_dir=Path("/tmp/presets/layer_1"), + paths=(outside,), index=0, ), - browse_floor=preset_root / "drums", + browse_floor=Path("/tmp/presets/layer_1"), stem="drums", - preset_switching="projectm", + preset_switching="on", + preset_list=[str(outside)], ) }, ) - out = persist_layers(PersistCtx(cfg=cfg, session=session)) - assert out["layer_1"]["preset_switching"] == "projectm" - assert "preset_switching_rotation_set" not in out["layer_1"] - - path = tmp_path / "snap.yaml" - write_session_snapshot(path, cfg=cfg, session=session) - data = yaml.safe_load(path.read_text()) - assert data["layers"]["layer_1"]["preset_switching"] == "projectm" - - -def test_load_config_round_trip_preset_switching(tmp_path: Path) -> None: - preset_root = tmp_path / "presets" - preset_root.mkdir() - milk = preset_root / "drums" / "drums.milk" - milk.parent.mkdir(parents=True) - milk.write_text("; test\n", encoding="utf-8") - config_path = tmp_path / "cleave.config.yaml" - config_path.write_text( - yaml.safe_dump( - { - "paths": { - "preset_root": str(preset_root), - "texture_paths": [str(preset_root / "textures")], - }, - "layers": { - "layer_1": { - "stem": "drums", - "preset": "drums/drums.milk", - "preset_switching": "projectm", - } - }, - } - ), - encoding="utf-8", + out = persist_layers( + PersistCtx(cfg=cfg, session=session, cfg_dir=Path("/tmp/test")) ) - cfg = load_config(config_path) - assert cfg.layers["layer_1"].preset_switching == "projectm" - - -def test_load_config_round_trip_preset_switching_timeline(tmp_path: Path) -> None: - preset_root = tmp_path / "presets" - preset_root.mkdir() - milk = preset_root / "drums" / "drums.milk" - milk.parent.mkdir(parents=True) - milk.write_text("; test\n", encoding="utf-8") - config_path = tmp_path / "cleave.config.yaml" - config_path.write_text( - yaml.safe_dump( - { - "paths": { - "preset_root": str(preset_root), - "texture_paths": [str(preset_root / "textures")], - }, - "layers": { - "layer_1": { - "stem": "drums", - "preset": "drums/drums.milk", - "preset_switching": "timeline", - } - }, - } + assert out["layer_1"]["preset_switching_list"] == [outside.resolve().as_posix()] + + +def test_round_trip_snapshot(tmp_path: Path) -> None: + presets = tmp_path / "presets" / "drums" + presets.mkdir(parents=True) + milk = presets / "a.milk" + milk.write_text("MILK") + user = tmp_path / "user-presets" + user.mkdir() + copied = user / "b.milk" + copied.write_text("MILK2") + cfg_path = tmp_path / "unnamed-1.yaml" + cfg_path.write_text("layers:\n layer_1: {}\n", encoding="utf-8") + cfg = CleaveConfig( + paths=PathsConfig( + preset_root=tmp_path / "presets", + texture_paths=(tmp_path / "textures",), ), - encoding="utf-8", + layers={ + "layer_1": LayerConfig( + preset=milk, + stem="drums", + preset_switching="on", + preset_switching_trigger="timer", + preset_switching_list=[copied], + ) + }, + editor=EditorConfig(), + config_path=cfg_path, + user_config_path=tmp_path / "user-config.yaml", + layer_z_order=["layer_1"], ) - cfg = load_config(config_path) - assert cfg.layers["layer_1"].preset_switching == "timeline" session = TuningSession( layer_z_order=["layer_1"], layers={ "layer_1": LayerRuntime( playlist=PresetPlaylist( - current_dir=milk.parent, - paths=(milk,), - index=0, + current_dir=presets, paths=(milk,), index=0 ), - browse_floor=milk.parent, + browse_floor=presets, stem="drums", - preset_switching="timeline", + preset_switching="on", + preset_switching_trigger="timer", + preset_list=[str(copied.resolve())], ) }, ) - out = persist_layers(PersistCtx(cfg=cfg, session=session)) - assert out["layer_1"]["preset_switching"] == "timeline" + write_session_snapshot(cfg_path, cfg=cfg, session=session) + loaded = yaml.safe_load(cfg_path.read_text()) + assert loaded["layers"]["layer_1"]["preset_switching"] == "on" + assert loaded["layers"]["layer_1"]["preset_switching_list"] == [ + "user-presets/b.milk" + ] diff --git a/tests/cleave/test_projectm_health.py b/tests/cleave/test_projectm_health.py index e3d7c76..d262cc9 100644 --- a/tests/cleave/test_projectm_health.py +++ b/tests/cleave/test_projectm_health.py @@ -12,7 +12,7 @@ drain_stem_layers_preset_failures, ) from cleave.viz.layer import StemLayer -from cleave.viz.preset_switching import EMPTY_ROTATION_NOTIFICATION +from cleave.viz.preset_switching import EMPTY_PRESET_LIST_NOTIFICATION def _layer_with_failures(*failures: PresetLoadFailure) -> StemLayer: @@ -63,4 +63,4 @@ def test_drain_notifies_exhausted_rotation() -> None: ) notify = MagicMock() drain_stem_layers_preset_failures([layer], on_notification=notify) - notify.assert_called_once_with(EMPTY_ROTATION_NOTIFICATION) + notify.assert_called_once_with(EMPTY_PRESET_LIST_NOTIFICATION) diff --git a/tests/cleave/test_timeline_repopulate.py b/tests/cleave/test_timeline_repopulate.py new file mode 100644 index 0000000..a268362 --- /dev/null +++ b/tests/cleave/test_timeline_repopulate.py @@ -0,0 +1,29 @@ +"""Unit tests for timeline preset repopulate staging helpers.""" + +from cleave.timeline_presets.repopulate import ( + DEFAULT_TIMELINE_PRESET_REPOPULATE, + cycle_timeline_preset_repopulate, + timeline_preset_repopulate_display, +) + + +def test_repopulate_defaults_and_cycle() -> None: + assert DEFAULT_TIMELINE_PRESET_REPOPULATE == "no" + assert timeline_preset_repopulate_display("no") == "no" + assert timeline_preset_repopulate_display("cue roles") == "cue roles" + assert cycle_timeline_preset_repopulate("no", forward=True) == "cue roles" + assert ( + cycle_timeline_preset_repopulate("cue roles", forward=True) + == "directory random" + ) + assert ( + cycle_timeline_preset_repopulate("directory random", forward=True) + == "directory sequential" + ) + assert ( + cycle_timeline_preset_repopulate("directory sequential", forward=True) + == "no" + ) + assert ( + cycle_timeline_preset_repopulate("cue roles", forward=False) == "no" + ) diff --git a/tests/cleave/test_timeline_reshuffle.py b/tests/cleave/test_timeline_reshuffle.py deleted file mode 100644 index fbae1a4..0000000 --- a/tests/cleave/test_timeline_reshuffle.py +++ /dev/null @@ -1,19 +0,0 @@ -"""Unit tests for timeline preset reshuffle staging helpers.""" - -from __future__ import annotations - -from cleave.timeline_presets.reshuffle import ( - DEFAULT_TIMELINE_PRESET_RESHUFFLE, - cycle_timeline_preset_reshuffle, - timeline_preset_reshuffle_display, -) - - -def test_reshuffle_defaults_and_cycle() -> None: - assert DEFAULT_TIMELINE_PRESET_RESHUFFLE is False - assert timeline_preset_reshuffle_display(False) == "off" - assert timeline_preset_reshuffle_display(True) == "on" - assert cycle_timeline_preset_reshuffle(False, forward=True) is True - assert cycle_timeline_preset_reshuffle(True, forward=True) is False - assert cycle_timeline_preset_reshuffle(False, forward=False) is True - assert cycle_timeline_preset_reshuffle(True, forward=False) is False diff --git a/tests/cleave/viz/test_controls.py b/tests/cleave/viz/test_controls.py index ed3a606..b89117a 100644 --- a/tests/cleave/viz/test_controls.py +++ b/tests/cleave/viz/test_controls.py @@ -1651,7 +1651,7 @@ def test_timeline_presets_enter_opens_yes_cancel_modal() -> None: ModalLabeledLine("cue snap", "none"), ModalLabeledLine("song marker snap", "none"), ModalLabeledLine("timeline cuts", "by marker"), - ModalLabeledLine("reshuffle", "off"), + ModalLabeledLine("re-populate preset lists", "no"), ModalLabeledLine("conductor", "on"), ) @@ -1710,20 +1710,25 @@ def test_timeline_preset_timeline_cuts_row_cycles() -> None: assert "all soft" in _row_text(view, row) -def test_timeline_preset_reshuffle_row_cycles() -> None: +def test_timeline_preset_repopulate_row_cycles() -> None: controls = _make_controls(timeline_enabled=True) controls.session.timeline.panel_open = True controls.session.timeline.timeline_presets_expanded = True view = controls.build_view_state(paused=False) - row = view.layout.find_by_kind(RowKind.TIMELINE_PRESET_RESHUFFLE) + row = view.layout.find_by_kind(RowKind.TIMELINE_PRESET_REPOPULATE) controls.focus_descriptor = _desc(view, row) - assert controls.session.timeline.timeline_preset_reshuffle is False + assert controls.session.timeline.timeline_preset_repopulate == "no" assert controls.handle_keydown(_keydown(pygame.K_RIGHT)) is True - assert controls.session.timeline.timeline_preset_reshuffle is True + assert controls.session.timeline.timeline_preset_repopulate == "cue roles" + assert controls.handle_keydown(_keydown(pygame.K_RIGHT)) is True + assert ( + controls.session.timeline.timeline_preset_repopulate + == "directory random" + ) assert controls.handle_keydown(_keydown(pygame.K_LEFT)) is True - assert controls.session.timeline.timeline_preset_reshuffle is False + assert controls.session.timeline.timeline_preset_repopulate == "cue roles" view = controls.build_view_state(paused=False) - assert "off" in _row_text(view, row) + assert "cue roles" in _row_text(view, row) def test_timeline_presets_cuts_none_keeps_cut_none() -> None: @@ -2016,58 +2021,6 @@ def _fake_builder(slots, duration_sec, rng, **kwargs): assert view.notification_message == "Applied Breathing timeline preset" -def test_timeline_presets_reshuffle_on_rerolls_layer_salts() -> None: - beats = tuple(float(i) for i in range(241)) - bars = tuple(float(i) for i in range(0, 241, 4)) - controls = _make_controls( - ("layer_1", "layer_2"), - beat_times=beats, - bar_times=bars, - ) - controls.session.layers["layer_1"].preset_switching = "projectm" - controls.session.layers["layer_1"].preset_switching_shuffle = True - controls.session.layers["layer_1"].preset_switching_shuffle_salt = 11 - controls.session.layers["layer_2"].preset_switching_shuffle_salt = 22 - controls.session.timeline.timeline_preset_kind = "breathing" - controls.session.timeline.timeline_preset_reshuffle = True - - def _fake_builder(slots, duration_sec, rng, **kwargs): - return {slot: TimelineLane(baseline=1.0, cues=[]) for slot in slots} - - with patch.dict( - "cleave.viz.timeline_preset_controls._KIND_BUILDERS", - {"breathing": (_fake_builder, "Applied Breathing timeline preset")}, - ): - _focus_timeline_presets(controls) - _confirm_timeline_preset(controls) - assert controls.session.layers["layer_1"].preset_switching_shuffle_salt != 11 - assert controls.session.layers["layer_2"].preset_switching_shuffle_salt != 22 - - -def test_timeline_presets_reshuffle_off_keeps_layer_salts() -> None: - beats = tuple(float(i) for i in range(241)) - bars = tuple(float(i) for i in range(0, 241, 4)) - controls = _make_controls( - ("layer_1", "layer_2"), - beat_times=beats, - bar_times=bars, - ) - controls.session.layers["layer_1"].preset_switching_shuffle_salt = 11 - controls.session.layers["layer_2"].preset_switching_shuffle_salt = 22 - controls.session.timeline.timeline_preset_kind = "breathing" - controls.session.timeline.timeline_preset_reshuffle = False - - def _fake_builder(slots, duration_sec, rng, **kwargs): - return {slot: TimelineLane(baseline=1.0, cues=[]) for slot in slots} - - with patch.dict( - "cleave.viz.timeline_preset_controls._KIND_BUILDERS", - {"breathing": (_fake_builder, "Applied Breathing timeline preset")}, - ): - _focus_timeline_presets(controls) - _confirm_timeline_preset(controls) - assert controls.session.layers["layer_1"].preset_switching_shuffle_salt == 11 - assert controls.session.layers["layer_2"].preset_switching_shuffle_salt == 22 def test_timeline_presets_conductor_skips_without_signals() -> None: @@ -2735,6 +2688,9 @@ def test_render_timeline_down_enters_submenu() -> None: limiter_threshold_row = view.layout.find_by_kind( RowKind.TIMELINE_VISUAL_LIMITER_THRESHOLD ) + limiter_ratio_row = view.layout.find_by_kind( + RowKind.TIMELINE_VISUAL_LIMITER_RATIO + ) limiter_release_row = view.layout.find_by_kind( RowKind.TIMELINE_VISUAL_LIMITER_RELEASE ) @@ -2794,6 +2750,10 @@ def test_render_timeline_down_enters_submenu() -> None: assert controls.focus_descriptor == _desc(view, limiter_threshold_row) assert not isinstance(controls.focus_cursor, TimelineFocus) + controls.handle_keydown(_keydown(pygame.K_DOWN)) + assert controls.focus_descriptor == _desc(view, limiter_ratio_row) + assert not isinstance(controls.focus_cursor, TimelineFocus) + controls.handle_keydown(_keydown(pygame.K_DOWN)) assert controls.focus_descriptor == _desc(view, limiter_release_row) assert not isinstance(controls.focus_cursor, TimelineFocus) @@ -2842,7 +2802,7 @@ def test_render_timeline_down_enters_submenu_and_routes_keys() -> None: controls.focus_descriptor = _desc(view, header_row) controls.session.timeline.focus_row = 2 - for _ in range(13): + for _ in range(14): controls.handle_keydown(_keydown(pygame.K_DOWN)) assert isinstance(controls.focus_cursor, TimelineFocus) assert controls.session.timeline.focus_row == 0 @@ -3111,7 +3071,7 @@ def test_render_timeline_sub_rows_dim_when_disabled() -> None: RowKind.TIMELINE_PRESET_CUE_SNAP, RowKind.TIMELINE_PRESET_SONG_MARKER_SNAP, RowKind.TIMELINE_PRESET_TIMELINE_CUTS, - RowKind.TIMELINE_PRESET_RESHUFFLE, + RowKind.TIMELINE_PRESET_REPOPULATE, RowKind.TIMELINE_PRESET_CONDUCTOR, RowKind.TIMELINE_PRESETS, RowKind.TIMELINE_RESET, @@ -3672,6 +3632,38 @@ def test_directory_ctrl_arrows_descend_and_ascend() -> None: assert playlist.current_dir.resolve() == siblings[0].resolve() +def test_directory_ctrl_arrows_follow_browse_while_auto_preset_elsewhere() -> None: + """Dir row must track browse nav even when switching plays a cast/list path.""" + root, siblings = _make_sibling_dir_tree(2) + roles_bed = root / "roles" / "bed" + roles_bed.mkdir(parents=True) + cast = roles_bed / "cast.milk" + _write_milk(cast) + controls = _controls_with_playlist(root, siblings[0]) + layer = controls.session.layers["layer_1"] + layer.preset_switching = "on" + layer.auto_preset_path = cast.resolve() + controls.focus_descriptor = _desc( + controls.build_view_state(paused=False), _preset_dir_row(controls) + ) + child = siblings[0] / "child" + + before = controls.build_view_state(paused=False).tracks["layer_1"].preset_dir_label + assert "roles/" not in before + + controls.handle_keydown(_keydown(pygame.K_RIGHT, mod=pygame.KMOD_CTRL)) + assert layer.playlist.current_dir.resolve() == child.resolve() + after_down = controls.build_view_state(paused=False).tracks["layer_1"] + assert "roles/" not in after_down.preset_dir_label + assert "[▲]" in after_down.preset_dir_label + assert after_down.preset_label.startswith("cast.milk") + + controls.handle_keydown(_keydown(pygame.K_LEFT, mod=pygame.KMOD_CTRL)) + assert layer.playlist.current_dir.resolve() == siblings[0].resolve() + after_up = controls.build_view_state(paused=False).tracks["layer_1"].preset_dir_label + assert after_up == before + + def test_ctrl_left_at_browse_floor_is_noop() -> None: root, siblings = _make_sibling_dir_tree(2) controls = _controls_with_playlist(root, siblings[0]) @@ -3826,6 +3818,77 @@ def test_move_mode_colors_focused_track_header() -> None: assert _row_bg_color(view, child_row) == MOVE_MODE +def test_move_mode_colors_preset_list_item() -> None: + controls = _make_controls(("layer_1",)) + layer = controls.session.layers["layer_1"] + layer.preset_switching = "on" + layer.preset_list = ["/tmp/presets/a.milk", "/tmp/presets/b.milk"] + layer.preset_list_expanded = True + + view = controls.build_view_state(paused=False) + item_row = view.layout.find_descriptor( + RowDescriptor( + RowKind.TRACK_PRESET_LIST_ITEM, + slot="layer_1", + preset_index=0, + ) + ) + sibling_row = view.layout.find_descriptor( + RowDescriptor( + RowKind.TRACK_PRESET_LIST_ITEM, + slot="layer_1", + preset_index=1, + ) + ) + controls.focus_descriptor = _desc(view, item_row) + assert controls.handle_keydown(_keydown(_MOVE_MODE_KEY)) is True + assert controls.move_mode_preset == ("layer_1", 0) + + view = controls.build_view_state(paused=False) + assert _row_value_color(view, item_row) == MOVE_MODE + assert _row_bg_color(view, item_row) == MOVE_MODE + assert _row_value_color(view, sibling_row) != MOVE_MODE + assert _row_bg_color(view, sibling_row) != MOVE_MODE + + +def test_active_preset_list_item_uses_highlight_color() -> None: + controls = _make_controls(("layer_1",)) + layer = controls.session.layers["layer_1"] + layer.preset_switching = "on" + a = Path("/tmp/presets/list-a.milk").resolve() + b = Path("/tmp/presets/list-b.milk").resolve() + layer.preset_list = [str(a), str(b)] + layer.preset_list_expanded = True + layer.auto_preset_path = b + + controls.focus_descriptor = RowDescriptor(RowKind.TRANSPORT) + view = controls.build_view_state(paused=False) + assert view.tracks["layer_1"].active_preset_list_index == 1 + active_row = view.layout.find_descriptor( + RowDescriptor( + RowKind.TRACK_PRESET_LIST_ITEM, + slot="layer_1", + preset_index=1, + ) + ) + idle_row = view.layout.find_descriptor( + RowDescriptor( + RowKind.TRACK_PRESET_LIST_ITEM, + slot="layer_1", + preset_index=0, + ) + ) + assert _row_value_color(view, active_row) == HIGHLIGHT + assert _row_value_color(view, idle_row) == VALUE + assert _row_bg_color(view, active_row) is None + + layer.auto_preset_path = a + view = controls.build_view_state(paused=False) + assert view.tracks["layer_1"].active_preset_list_index == 0 + assert _row_value_color(view, active_row) == VALUE + assert _row_value_color(view, idle_row) == HIGHLIGHT + + def test_row_value_color_dim_for_focused_empty_preset() -> None: state = TuningViewState( layer_z_order=("layer_1",), @@ -4809,37 +4872,10 @@ def test_default_focus_stays_on_transport() -> None: assert controls.focus_descriptor == RowDescriptor(RowKind.TRANSPORT) -def test_preset_switching_row_cycles_none_projectm_and_timeline() -> None: - controls = _make_controls(("layer_1",)) - switched: list[str] = [] - notifications: list[str] = [] - controls._layer_bindings = noop_layer_bindings( - on_preset_switching_change=lambda slot: switched.append(slot) - ) - controls.show_notification = notifications.append - controls.session.timeline.enabled = False - controls.session.layers["layer_1"].expanded = True - view = controls.build_view_state(paused=False) - row = _row(view, "layer_1", RowKind.TRACK_PRESET_SWITCHING) - controls.focus_descriptor = view.layout.descriptor(row) - assert controls.session.layers["layer_1"].preset_switching == "none" - - controls.handle_keydown(_keydown(pygame.K_RIGHT)) - assert controls.session.layers["layer_1"].preset_switching == "projectm" - assert switched == ["layer_1"] - assert notifications == [] - - controls.handle_keydown(_keydown(pygame.K_RIGHT)) - assert controls.session.layers["layer_1"].preset_switching == "timeline" - assert "Enable timeline" in notifications[-1] - - controls.handle_keydown(_keydown(pygame.K_RIGHT)) - assert controls.session.layers["layer_1"].preset_switching == "none" - def test_projectm_mode_allows_preset_browse() -> None: controls = _make_controls(("layer_1",)) - controls.session.layers["layer_1"].preset_switching = "projectm" + controls.session.layers["layer_1"].preset_switching = "on" controls.session.layers["layer_1"].expanded = True changed: list[str] = [] controls._layer_bindings = noop_layer_bindings( @@ -4854,7 +4890,7 @@ def test_projectm_mode_allows_preset_browse() -> None: root, siblings = _make_sibling_dir_tree(3) dir_controls = _controls_with_playlist(root, siblings[0]) - dir_controls.session.layers["layer_1"].preset_switching = "projectm" + dir_controls.session.layers["layer_1"].preset_switching = "on" dir_controls.session.layers["layer_1"].expanded = True dir_controls.focus_descriptor = _desc( dir_controls.build_view_state(paused=False), _preset_dir_row(dir_controls) @@ -4864,178 +4900,43 @@ def test_projectm_mode_allows_preset_browse() -> None: assert playlist.current_dir.resolve() == siblings[1].resolve() -def test_user_defined_rotation_set_allows_preset_browse() -> None: - controls = _make_controls(("layer_1",)) - controls.session.layers["layer_1"].preset_switching = "projectm" - controls.session.layers["layer_1"].preset_switching_rotation_set = "user_defined" - controls.session.layers["layer_1"].expanded = True - changed: list[str] = [] - controls._layer_bindings = noop_layer_bindings( - on_preset_change=lambda slot, _pl: changed.append(slot) - ) - view = controls.build_view_state(paused=False) - controls.focus_descriptor = view.layout.descriptor( - _row(view, "layer_1", RowKind.TRACK_PRESET) - ) - controls.handle_keydown(_keydown(pygame.K_RIGHT)) - assert changed == ["layer_1"] - - root, siblings = _make_sibling_dir_tree(3) - dir_controls = _controls_with_playlist(root, siblings[0]) - dir_controls.session.layers["layer_1"].preset_switching = "projectm" - dir_controls.session.layers["layer_1"].preset_switching_rotation_set = "user_defined" - dir_controls.session.layers["layer_1"].expanded = True - dir_controls.focus_descriptor = _desc( - dir_controls.build_view_state(paused=False), _preset_dir_row(dir_controls) - ) - playlist = dir_controls.session.layers["layer_1"].playlist - dir_controls.handle_keydown(_keydown(pygame.K_RIGHT)) - assert playlist.current_dir.resolve() == siblings[1].resolve() - -def test_scope_row_hidden_when_mode_none() -> None: +def test_trigger_row_hidden_when_switching_off() -> None: controls = _make_controls(("layer_1",)) view = controls.build_view_state(paused=False) - with pytest.raises(ValueError, match="TRACK_PRESET_SWITCHING_ROTATION_SET"): - _row(view, "layer_1", RowKind.TRACK_PRESET_SWITCHING_ROTATION_SET) + with pytest.raises(ValueError, match="TRACK_PRESET_SWITCHING_TRIGGER"): + _row(view, "layer_1", RowKind.TRACK_PRESET_SWITCHING_TRIGGER) -def test_scope_row_visible_when_mode_projectm() -> None: +def test_trigger_row_visible_when_switching_on() -> None: controls = _make_controls(("layer_1",)) - controls.session.layers["layer_1"].preset_switching = "projectm" + controls.session.layers["layer_1"].preset_switching = "on" controls.session.layers["layer_1"].expanded = True view = controls.build_view_state(paused=False) - _row(view, "layer_1", RowKind.TRACK_PRESET_SWITCHING_ROTATION_SET) + _row(view, "layer_1", RowKind.TRACK_PRESET_SWITCHING_TRIGGER) -def test_preset_switching_rotation_set_cycles_directory_and_user_defined() -> None: - controls = _make_controls(("layer_1",)) - switched: list[str] = [] - controls._layer_bindings = noop_layer_bindings( - on_preset_switching_change=lambda slot: switched.append(slot) - ) - controls.session.layers["layer_1"].preset_switching = "projectm" - controls.session.layers["layer_1"].expanded = True - view = controls.build_view_state(paused=False) - row = _row(view, "layer_1", RowKind.TRACK_PRESET_SWITCHING_ROTATION_SET) - controls.focus_descriptor = view.layout.descriptor(row) - assert controls.session.layers["layer_1"].preset_switching_rotation_set == "directory" - - controls.handle_keydown(_keydown(pygame.K_RIGHT)) - assert controls.session.layers["layer_1"].preset_switching_rotation_set == "user_defined" - assert controls.session.layers["layer_1"].user_presets_expanded is True - assert switched == ["layer_1"] - - controls.handle_keydown(_keydown(pygame.K_LEFT)) - assert controls.session.layers["layer_1"].preset_switching_rotation_set == "directory" - assert controls.session.layers["layer_1"].user_presets_expanded is True - - -def test_preset_switching_rotation_set_skips_cast_roles_in_projectm_mode() -> None: - controls = _make_controls(("layer_1",)) - layer = controls.session.layers["layer_1"] - layer.preset_switching = "projectm" - layer.expanded = True - layer.preset_switching_rotation_set = "directory" - view = controls.build_view_state(paused=False) - row = _row(view, "layer_1", RowKind.TRACK_PRESET_SWITCHING_ROTATION_SET) - controls.focus_descriptor = view.layout.descriptor(row) - - controls.handle_keydown(_keydown(pygame.K_RIGHT)) - assert layer.preset_switching_rotation_set == "user_defined" - controls.handle_keydown(_keydown(pygame.K_RIGHT)) - assert layer.preset_switching_rotation_set == "directory" - assert layer.preset_switching_rotation_set != "cast_roles" - - -def test_preset_switching_rotation_set_includes_cast_roles_in_timeline_mode() -> None: - controls = _make_controls(("layer_1",)) - layer = controls.session.layers["layer_1"] - layer.preset_switching = "timeline" - layer.expanded = True - layer.preset_switching_rotation_set = "directory" - view = controls.build_view_state(paused=False) - row = _row(view, "layer_1", RowKind.TRACK_PRESET_SWITCHING_ROTATION_SET) - controls.focus_descriptor = view.layout.descriptor(row) - - controls.handle_keydown(_keydown(pygame.K_RIGHT)) - assert layer.preset_switching_rotation_set == "user_defined" - controls.handle_keydown(_keydown(pygame.K_RIGHT)) - assert layer.preset_switching_rotation_set == "cast_roles" -def test_cast_roles_empty_pool_sets_persistent_notification() -> None: - from cleave.viz.panel_notification import NOTIFICATION_ATTENTION_DURATION_SEC - - controls = _make_controls(("layer_1",)) - layer = controls.session.layers["layer_1"] - layer.preset_switching = "timeline" - layer.preset_switching_rotation_set = "cast_roles" - layer.cast_roles_default_role = "bed" - - with patch.object(time, "monotonic", return_value=1000.0): - with patch( - "cleave.viz.controls.role_pool_paths", - return_value=(), - ): - controls.build_view_state(paused=False) - with patch.object( - time, - "monotonic", - return_value=1000.0 + NOTIFICATION_ATTENTION_DURATION_SEC + 0.1, - ): - with patch( - "cleave.viz.controls.role_pool_paths", - return_value=(), - ): - view = controls.build_view_state(paused=False) - - assert view.persistent_notification_message == "No presets in bed roles folder" - notification_rows = [ - row - for row in view.layout.rows - if row.kind == RowKind.PANEL_NOTIFICATION - ] - assert len(notification_rows) == 1 - assert notification_rows[0].marker_index == 0 - idx = view.layout.find_descriptor(notification_rows[0]) - assert _row_value_color(view, idx) == ERROR_NOTIFICATION - - layer.preset_switching_rotation_set = "directory" - with patch( - "cleave.viz.controls.role_pool_paths", - return_value=(), - ): - cleared = controls.build_view_state(paused=False) - assert cleared.persistent_notification_message is None def test_persistent_and_timed_notifications_stack() -> None: from cleave.viz.panel_notification import NOTIFICATION_ATTENTION_DURATION_SEC + from cleave.viz.preset_switching import EMPTY_PRESET_LIST_NOTIFICATION controls = _make_controls(("layer_1",)) - controls.session.layers["layer_1"].preset_switching = "timeline" - controls.session.layers["layer_1"].preset_switching_rotation_set = "cast_roles" - with patch.object(time, "monotonic", return_value=1000.0): - with patch( - "cleave.viz.controls.role_pool_paths", - return_value=(), - ): - controls.show_notification("Saved") - controls.build_view_state(paused=False) + controls._notification_host.set_persistent(EMPTY_PRESET_LIST_NOTIFICATION) + controls.show_notification("Saved") + controls.build_view_state(paused=False) with patch.object( time, "monotonic", return_value=1000.0 + NOTIFICATION_ATTENTION_DURATION_SEC + 0.1, ): - with patch( - "cleave.viz.controls.role_pool_paths", - return_value=(), - ): - view = controls.build_view_state(paused=False) + view = controls.build_view_state(paused=False) - assert view.persistent_notification_message == "No presets in bed roles folder" + assert view.persistent_notification_message == EMPTY_PRESET_LIST_NOTIFICATION assert view.notification_message == "Saved" notification_rows = [ row @@ -5047,28 +4948,14 @@ def test_persistent_and_timed_notifications_stack() -> None: timed_idx = view.layout.find_descriptor(notification_rows[1]) assert _row_value_color(view, persistent_idx) == ERROR_NOTIFICATION assert _row_value_color(view, timed_idx) == HIGHLIGHT - assert _row_text(view, persistent_idx) == "No presets in bed roles folder" + assert _row_text(view, persistent_idx) == EMPTY_PRESET_LIST_NOTIFICATION assert _row_text(view, timed_idx) == "Saved" -def test_preset_switching_rotation_set_user_defined_auto_expands_user_presets() -> None: - controls = _make_controls(("layer_1",)) - layer = controls.session.layers["layer_1"] - layer.preset_switching = "projectm" - layer.expanded = True - layer.user_presets_expanded = False - view = controls.build_view_state(paused=False) - row = _row(view, "layer_1", RowKind.TRACK_PRESET_SWITCHING_ROTATION_SET) - controls.focus_descriptor = view.layout.descriptor(row) - - controls.handle_keydown(_keydown(pygame.K_RIGHT)) - assert layer.preset_switching_rotation_set == "user_defined" - assert layer.user_presets_expanded is True - def test_preset_duration_ctrl_step_is_ten_seconds() -> None: controls = _make_controls(("layer_1",)) - controls.session.layers["layer_1"].preset_switching = "projectm" + controls.session.layers["layer_1"].preset_switching = "on" controls.session.layers["layer_1"].expanded = True view = controls.build_view_state(paused=False) row = _row(view, "layer_1", RowKind.TRACK_PRESET_DURATION) @@ -5084,7 +4971,8 @@ def test_preset_duration_ctrl_step_is_ten_seconds() -> None: def test_hard_cut_enabled_cycles_and_hides_child_rows() -> None: controls = _make_controls(("layer_1",)) - controls.session.layers["layer_1"].preset_switching = "projectm" + controls.session.layers["layer_1"].preset_switching = "on" + controls.session.layers["layer_1"].preset_switching_trigger = "projectm" controls.session.layers["layer_1"].expanded = True switched: list[str] = [] controls._layer_bindings = noop_layer_bindings( @@ -5114,7 +5002,8 @@ def test_hard_cut_enabled_cycles_and_hides_child_rows() -> None: def test_easter_egg_steps_with_standard_and_large_increments() -> None: controls = _make_controls(("layer_1",)) - controls.session.layers["layer_1"].preset_switching = "projectm" + controls.session.layers["layer_1"].preset_switching = "on" + controls.session.layers["layer_1"].preset_switching_trigger = "projectm" controls.session.layers["layer_1"].expanded = True switched: list[str] = [] controls._layer_bindings = noop_layer_bindings( @@ -5135,7 +5024,7 @@ def test_easter_egg_steps_with_standard_and_large_increments() -> None: def test_preset_start_clean_cycles_yes_no() -> None: controls = _make_controls(("layer_1",)) - controls.session.layers["layer_1"].preset_switching = "projectm" + controls.session.layers["layer_1"].preset_switching = "on" controls.session.layers["layer_1"].expanded = True view = controls.build_view_state(paused=False) row = _row(view, "layer_1", RowKind.TRACK_PRESET_START_CLEAN) @@ -5149,30 +5038,11 @@ def test_preset_start_clean_cycles_yes_no() -> None: assert controls.session.layers["layer_1"].preset_start_clean is False -def test_preset_switching_shuffle_cycles_off_on() -> None: - controls = _make_controls(("layer_1",)) - controls.session.layers["layer_1"].preset_switching = "projectm" - controls.session.layers["layer_1"].expanded = True - switched: list[str] = [] - controls._layer_bindings = noop_layer_bindings( - on_preset_switching_change=lambda slot: switched.append(slot) - ) - view = controls.build_view_state(paused=False) - row = _row(view, "layer_1", RowKind.TRACK_PRESET_SWITCHING_SHUFFLE) - controls.focus_descriptor = view.layout.descriptor(row) - assert controls.session.layers["layer_1"].preset_switching_shuffle is False - - controls.handle_keydown(_keydown(pygame.K_RIGHT)) - assert controls.session.layers["layer_1"].preset_switching_shuffle is True - assert switched == ["layer_1"] - - controls.handle_keydown(_keydown(pygame.K_LEFT)) - assert controls.session.layers["layer_1"].preset_switching_shuffle is False - def test_hard_cut_sensitivity_steps_like_beat_sensitivity() -> None: controls = _make_controls(("layer_1",)) - controls.session.layers["layer_1"].preset_switching = "projectm" + controls.session.layers["layer_1"].preset_switching = "on" + controls.session.layers["layer_1"].preset_switching_trigger = "projectm" controls.session.layers["layer_1"].expanded = True controls.session.layers["layer_1"].hard_cut_enabled = True view = controls.build_view_state(paused=False) @@ -5210,16 +5080,15 @@ def _focus_user_preset_item_row( preset_path: Path | None = None, ) -> tuple[RowDescriptor, Path]: layer = controls.session.layers["layer_1"] - layer.preset_switching = "projectm" - layer.preset_switching_rotation_set = "user_defined" - layer.user_presets_expanded = True + layer.preset_switching = "on" + layer.preset_list_expanded = True layer.expanded = True path = preset_path or Path("/tmp/projects/my-track/user-preset-0.milk") path.parent.mkdir(parents=True, exist_ok=True) path.write_text("milk", encoding="utf-8") - layer.user_presets = [str(path)] + layer.preset_list = [str(path)] desc = RowDescriptor( - RowKind.TRACK_USER_PRESET_ITEM, + RowKind.TRACK_PRESET_LIST_ITEM, slot="layer_1", preset_index=0, ) @@ -5323,7 +5192,7 @@ def test_f_b_blocked_when_layer_locked() -> None: def test_f_b_allowed_in_projectm_mode() -> None: controls = _make_controls(("layer_1",)) - controls.session.layers["layer_1"].preset_switching = "projectm" + controls.session.layers["layer_1"].preset_switching = "on" current = _focus_preset_file_row(controls) mock_curation = MagicMock() controls._preset_curation = mock_curation @@ -5704,4 +5573,159 @@ def test_placement_snap_row_cycles() -> None: assert controls.handle_keydown(_keydown(pygame.K_LEFT)) is True assert controls.session.timeline.placement_snap == "bar" view = controls.build_view_state(paused=False) - assert "bar" in _row_text(view, row) \ No newline at end of file + assert "bar" in _row_text(view, row) + +def test_preset_switching_row_cycles_off_on() -> None: + from tests.support.viz import make_controls, keydown, noop_layer_bindings + import pygame + from cleave.viz.row_semantics import RowDescriptor, RowKind + from cleave.viz.focus_nav import MainFocus + + switched: list[str] = [] + controls = make_controls(("layer_1",)) + controls._layer_bindings = noop_layer_bindings( + on_preset_switching_change=lambda slot: switched.append(slot) + ) + controls.focus_cursor = MainFocus( + RowDescriptor(RowKind.TRACK_PRESET_SWITCHING, slot="layer_1") + ) + assert controls.session.layers["layer_1"].preset_switching == "off" + controls.handle_keydown(keydown(pygame.K_RIGHT)) + assert controls.session.layers["layer_1"].preset_switching == "on" + assert switched == ["layer_1"] + controls.handle_keydown(keydown(pygame.K_RIGHT)) + assert controls.session.layers["layer_1"].preset_switching == "off" + + +def test_preset_switching_trigger_cycles() -> None: + from tests.support.viz import make_controls, keydown + import pygame + from cleave.viz.row_semantics import RowDescriptor, RowKind + from cleave.viz.focus_nav import MainFocus + + controls = make_controls(("layer_1",)) + controls.session.layers["layer_1"].preset_switching = "on" + controls.session.timeline.enabled = True + controls.focus_cursor = MainFocus( + RowDescriptor(RowKind.TRACK_PRESET_SWITCHING_TRIGGER, slot="layer_1") + ) + assert controls.session.layers["layer_1"].preset_switching_trigger == "timer" + controls.handle_keydown(keydown(pygame.K_RIGHT)) + assert controls.session.layers["layer_1"].preset_switching_trigger == "projectm" + controls.handle_keydown(keydown(pygame.K_RIGHT)) + assert controls.session.layers["layer_1"].preset_switching_trigger == "timeline" + controls.handle_keydown(keydown(pygame.K_RIGHT)) + assert controls.session.layers["layer_1"].preset_switching_trigger == "timer" + + +def test_timeline_trigger_hides_duration_row() -> None: + controls = _make_controls(("layer_1",)) + controls.session.layers["layer_1"].preset_switching = "on" + controls.session.layers["layer_1"].preset_switching_trigger = "timeline" + controls.session.layers["layer_1"].expanded = True + view = controls.build_view_state(paused=False) + with pytest.raises(ValueError, match="TRACK_PRESET_DURATION"): + _row(view, "layer_1", RowKind.TRACK_PRESET_DURATION) + + +def test_timeline_trigger_while_disabled_shows_warning() -> None: + from cleave.viz.controls import NOTIFICATION_TIMELINE_TRIGGER_DISABLED_TEXT + from tests.support.viz import make_controls, keydown, noop_layer_bindings + import pygame + from cleave.viz.row_semantics import RowDescriptor, RowKind + from cleave.viz.focus_nav import MainFocus + + controls = make_controls(("layer_1",)) + controls._layer_bindings = noop_layer_bindings() + controls.session.layers["layer_1"].preset_switching = "on" + controls.session.timeline.enabled = False + controls.focus_cursor = MainFocus( + RowDescriptor(RowKind.TRACK_PRESET_SWITCHING_TRIGGER, slot="layer_1") + ) + controls.session.layers["layer_1"].preset_switching_trigger = "projectm" + controls.handle_keydown(keydown(pygame.K_RIGHT)) + assert controls.session.layers["layer_1"].preset_switching_trigger == "timeline" + view = controls.build_view_state(paused=False) + assert view.notification_message == NOTIFICATION_TIMELINE_TRIGGER_DISABLED_TEXT + + +def test_populate_modal_options_keyed_by_timeline_trigger() -> None: + controls = _make_controls(("layer_1",)) + controls.session.layers["layer_1"].preset_switching = "on" + controls.session.layers["layer_1"].preset_switching_trigger = "timeline" + controls.session.timeline.enabled = False + prompted: list[tuple[str, list[str]]] = [] + + def capture_prompt(title, options, on_dismiss=None): + del on_dismiss + prompted.append((title, [opt.label for opt in options])) + + controls._modal_host.prompt_choice = capture_prompt # type: ignore[method-assign] + controls.project_dir = Path("/tmp/project") + controls._prompt_populate_presets("layer_1") + assert prompted + assert prompted[0][0] == "Populate the preset list with 1 presets?" + labels = prompted[0][1] + assert labels == [ + "Using Cue Marker Roles (random)", + "From Current Directory (random)", + "From Current Directory (sequential)", + "Cancel", + ] + + prompted.clear() + controls.session.layers["layer_1"].preset_switching_trigger = "timer" + controls.session.timeline.enabled = True + controls._prompt_populate_presets("layer_1") + # 120s song / 30s duration = 4 needed; fixture browse dir has 1 milk + assert prompted[0][0] == "Populate the preset list with 1 presets?" + labels = prompted[0][1] + assert labels == [ + "From Current Directory (random)", + "From Current Directory (sequential)", + "Cancel", + ] + assert "Using Cue Marker Roles (random)" not in labels + + +def test_step_preset_duration_toasts_when_list_too_short() -> None: + controls = _make_controls(("layer_1",), duration_sec=120.0) + layer = controls.session.layers["layer_1"] + layer.preset_switching = "on" + layer.preset_switching_trigger = "timer" + layer.preset_duration = 30.0 + layer.preset_list = ["a.milk", "b.milk", "c.milk", "d.milk"] + noted: list[str] = [] + controls.show_notification = noted.append # type: ignore[method-assign] + controls._step_preset_duration("layer_1", forward=False) + assert layer.preset_duration == 29.0 + assert noted == ["Preset list may need more presets"] + + +def test_cycle_preset_switching_trigger_toasts_when_list_nonempty() -> None: + controls = _make_controls(("layer_1",)) + layer = controls.session.layers["layer_1"] + layer.preset_switching = "on" + layer.preset_switching_trigger = "timer" + layer.preset_list = ["a.milk"] + noted: list[str] = [] + controls.show_notification = noted.append # type: ignore[method-assign] + controls._cycle_preset_switching_trigger("layer_1", forward=True) + assert layer.preset_switching_trigger != "timer" + assert noted == ["Preset list may need adjusting"] + + +def test_cycle_preset_switching_trigger_skips_list_toast_for_timeline_disabled() -> None: + from cleave.viz.controls import NOTIFICATION_TIMELINE_TRIGGER_DISABLED_TEXT + + controls = _make_controls(("layer_1",)) + layer = controls.session.layers["layer_1"] + layer.preset_switching = "on" + layer.preset_switching_trigger = "projectm" + layer.preset_list = ["a.milk"] + controls.session.timeline.enabled = False + noted: list[str] = [] + controls.show_notification = noted.append # type: ignore[method-assign] + controls._cycle_preset_switching_trigger("layer_1", forward=True) + assert layer.preset_switching_trigger == "timeline" + assert noted == [NOTIFICATION_TIMELINE_TRIGGER_DISABLED_TEXT] diff --git a/tests/cleave/viz/test_editor_mode.py b/tests/cleave/viz/test_editor_mode.py index ba8d169..966f054 100644 --- a/tests/cleave/viz/test_editor_mode.py +++ b/tests/cleave/viz/test_editor_mode.py @@ -192,7 +192,7 @@ def test_enter_curation_defaults_full_mix_and_disables_rotation() -> None: controls.session.settings.expanded = True controls.session.solo_slot = "layer_2" controls.session.layers["layer_1"].stem = "drums" - controls.session.layers["layer_1"].preset_switching = "projectm" + controls.session.layers["layer_1"].preset_switching = "on" controls._config_save.clear_config_dirty() controls.focus_cursor = MainFocus(RowDescriptor(RowKind.SETTINGS_EDITOR_MODE)) mock_bindings = MagicMock() @@ -207,7 +207,7 @@ def test_enter_curation_defaults_full_mix_and_disables_rotation() -> None: assert controls.session.solo_slot is None assert controls.session.layers["layer_1"].stem == "full_mix" # Session YAML mode is left intact; live apply is forced to none via bindings. - assert controls.session.layers["layer_1"].preset_switching == "projectm" + assert controls.session.layers["layer_1"].preset_switching == "on" assert mock_bindings.on_preset_switching_change.call_args_list == [ (("layer_1",), {}), (("layer_2",), {}), diff --git a/tests/cleave/viz/test_help_overlay.py b/tests/cleave/viz/test_help_overlay.py index a441e1e..684339d 100644 --- a/tests/cleave/viz/test_help_overlay.py +++ b/tests/cleave/viz/test_help_overlay.py @@ -5,14 +5,12 @@ from cleave.blend_modes import BLEND_MODE_HELP_ENTRIES, BLEND_MODES from cleave.cue_roles import CUE_ROLE_MARKER_HELP_ENTRIES from cleave.config_schema import ( - CAST_ROLES_DEFAULT_ROLE_HELP_ENTRIES, - CAST_ROLES_TIMELINE_BEHAVIOUR_HELP_ENTRIES, HIGHLIGHT_ROLLOFF_APPLY_MODE_HELP_ENTRIES, HIGHLIGHT_ROLLOFF_APPLY_MODES, HIGHLIGHT_ROLLOFF_CURVE_HELP_ENTRIES, HIGHLIGHT_ROLLOFF_CURVES, PRESET_SWITCHING_MODE_HELP_ENTRIES, - PRESET_SWITCHING_ROTATION_SET_HELP_ENTRIES, + PRESET_SWITCHING_TRIGGER_HELP_ENTRIES, EDITOR_PREVIEW_QUALITY_HELP_ENTRIES, ) from cleave.timeline_presets import TIMELINE_PRESET_HELP_ENTRIES @@ -169,13 +167,13 @@ def test_preset_dir_help_titles() -> None: assert "Shift + +" not in entries -def test_preset_dir_help_includes_add_shortcut_in_user_defined_rotation_set() -> None: +def test_preset_dir_help_includes_add_shortcut_when_switching_on() -> None: sections = sections_for( RowKind.TRACK_PRESET_DIR, - preset_switching_rotation_set="user_defined", + preset_switching="on", ) entries = dict(_keyboard_section(sections).entries) - assert entries["U"] == "add current preset to user-defined list" + assert entries["P"] == "add current preset to switching list" def test_preset_file_help_titles() -> None: @@ -206,13 +204,13 @@ def test_preset_file_help_titles() -> None: assert "Shift + +" not in entries -def test_preset_file_help_includes_add_shortcut_in_user_defined_rotation_set() -> None: +def test_preset_file_help_includes_add_shortcut_when_switching_on() -> None: sections = sections_for( RowKind.TRACK_PRESET, - preset_switching_rotation_set="user_defined", + preset_switching="on", ) entries = dict(_keyboard_section(sections).entries) - assert entries["U"] == "add current preset to user-defined list" + assert entries["P"] == "add current preset to switching list" def test_blend_mode_help_lists_modes() -> None: @@ -233,48 +231,14 @@ def test_switching_mode_help_lists_modes() -> None: assert description is not None assert description.title == "Preset switching" assert description.lines == ( - "Controls how and when presets change during playback.", + "When on, advances through this layer's ordered preset list.", + "The trigger chooses timer, projectM, or timeline on-transitions.", ) assert description.entries == PRESET_SWITCHING_MODE_HELP_ENTRIES modes = [mode for mode, _ in description.entries] - assert modes == ["none", "projectm", "timeline"] - timeline_help = dict(description.entries)["timeline"] - assert "timeline on-transition" in timeline_help + assert modes == ["off", "on"] -def test_rotation_set_help_lists_rotation_sets() -> None: - description = _description_section( - sections_for(RowKind.TRACK_PRESET_SWITCHING_ROTATION_SET) - ) - assert description is not None - assert description.title == "Rotation set" - assert description.lines == () - assert description.entries == PRESET_SWITCHING_ROTATION_SET_HELP_ENTRIES - modes = [mode for mode, _ in description.entries] - assert modes == ["directory", "user_defined", "cast_roles"] - - -def test_cast_roles_timeline_behaviour_help_lists_options() -> None: - description = _description_section( - sections_for(RowKind.TRACK_CAST_ROLES_TIMELINE_BEHAVIOUR) - ) - assert description is not None - assert description.title == "Timeline behaviour" - assert description.entries == CAST_ROLES_TIMELINE_BEHAVIOUR_HELP_ENTRIES - assert [mode for mode, _ in description.entries] == [ - "hold_current", - "on_transition", - ] - - -def test_cast_roles_default_role_help_lists_roles() -> None: - description = _description_section( - sections_for(RowKind.TRACK_CAST_ROLES_DEFAULT_ROLE) - ) - assert description is not None - assert description.title == "Default role" - assert description.entries == CAST_ROLES_DEFAULT_ROLE_HELP_ENTRIES - def test_timeline_presets_help_lists_characters() -> None: sections = sections_for(RowKind.TIMELINE_PRESET_CHARACTER) @@ -310,36 +274,60 @@ def test_timeline_reset_help_lists_choices() -> None: assert keyboard.entries == (("Enter", "reset timeline"),) -def test_shuffle_help_describes_fixed_order() -> None: - sections = sections_for(RowKind.TRACK_PRESET_SWITCHING_SHUFFLE) - description = _description_section(sections) + +def test_preview_quality_help_lists_modes() -> None: + description = _description_section(sections_for(RowKind.SETTINGS_PREVIEW_QUALITY)) assert description is not None - assert description.title == "Shuffle" - joined = " ".join(description.lines) - assert "fixed shuffled order" in joined - assert "seed row" in joined - keyboard = _keyboard_section(sections) - assert keyboard.entries == (("Left/Right", "off / on"),) + assert description.title == "Preview quality" + assert "live view only" in " ".join(description.lines) + assert description.entries == EDITOR_PREVIEW_QUALITY_HELP_ENTRIES + + + +def test_preset_switching_trigger_help_lists_options() -> None: + sections = sections_for(RowKind.TRACK_PRESET_SWITCHING_TRIGGER) + description = next(s for s in sections if isinstance(s, DescriptionSection)) + assert description.entries == PRESET_SWITCHING_TRIGGER_HELP_ENTRIES -def test_seed_help_describes_generate_action() -> None: - sections = sections_for(RowKind.TRACK_PRESET_SWITCHING_SEED) +def test_preset_list_help() -> None: + sections = sections_for(RowKind.TRACK_PRESET_LIST) description = _description_section(sections) - assert description is not None - assert description.title == "Seed" - joined = " ".join(description.lines) - assert "shuffle seed" in joined - assert "new seed" in joined keyboard = _keyboard_section(sections) - assert keyboard.entries == (("Enter", "generate a new seed"),) + assert description is not None + assert description.title == "preset list" + assert description.lines == ( + "Ordered presets used for automatic switching on this layer.", + "Expand to reorder, delete, or add the current browse preset.", + ) + assert not description.entries + assert keyboard.title == KEYBOARD_CONTROLS_SECTION_TITLE + assert dict(keyboard.entries) == {"Left/Right": "expand/collapse"} -def test_preview_quality_help_lists_modes() -> None: - description = _description_section(sections_for(RowKind.SETTINGS_PREVIEW_QUALITY)) +def test_preset_list_entry_help() -> None: + sections = sections_for(RowKind.TRACK_PRESET_LIST_ITEM) + description = _description_section(sections) + keyboard = _keyboard_section(sections) assert description is not None - assert description.title == "Preview quality" - assert "live view only" in " ".join(description.lines) - assert description.entries == EDITOR_PREVIEW_QUALITY_HELP_ENTRIES + assert description.title == "preset list entry" + assert description.lines == ( + "Preset in this layer's switching list.", + "[F/B] indicates favourited/blacklisted.", + "[R:X] indicates the chosen role.", + ) + assert description.entries == CUE_ROLE_MARKER_HELP_ENTRIES + assert "Delete" not in " ".join(description.lines) + assert keyboard.title == KEYBOARD_CONTROLS_SECTION_TITLE + entries = dict(keyboard.entries) + assert entries == { + "M": "reorder in list", + "Delete": "remove preset", + "F": "favourite preset", + "B": "blacklist preset", + "C": "cast preset (bed/pulse/lead/accent)", + "R": "remove favourite / restore blacklist / remove cast", + } def test_highlight_rolloff_mode_help_lists_modes() -> None: @@ -459,46 +447,6 @@ def test_layer_management_delete_help() -> None: assert "At least one layer must remain." in description.lines -def test_user_presets_help() -> None: - sections = sections_for(RowKind.TRACK_USER_PRESETS) - description = _description_section(sections) - keyboard = _keyboard_section(sections) - assert description is not None - assert description.title == "user presets" - assert "user-defined switching" in " ".join(description.lines) - assert dict(keyboard.entries)["Left/Right"] == "expand/collapse" - - -def test_user_preset_item_help() -> None: - sections = sections_for(RowKind.TRACK_USER_PRESET_ITEM) - description = _description_section(sections) - keyboard = _keyboard_section(sections) - assert description is not None - assert description.title == "user preset entry" - assert description.lines == ( - "Preset in the user-defined rotation set for this layer.", - "[F/B] indicates favourited/blacklisted.", - "[R:X] indicates the chosen role.", - ) - assert description.entries == CUE_ROLE_MARKER_HELP_ENTRIES - entries = dict(keyboard.entries) - assert entries["Delete"] == "remove preset" - assert entries["F"] == "favourite preset" - assert entries["B"] == "blacklist preset" - assert entries["C"] == "cast preset (bed/pulse/lead/accent)" - assert entries["R"] == "remove favourite / restore blacklist / remove cast" - - -def test_user_preset_add_help() -> None: - sections = sections_for(RowKind.TRACK_USER_PRESET_ADD) - description = _description_section(sections) - keyboard = _keyboard_section(sections) - assert description is not None - assert description.title == "Add Current Preset" - assert dict(keyboard.entries)["Enter"] == "add current preset" - assert any("U" in line and "preset dir" in line for line in description.lines) - - def test_navigable_row_kinds_have_help_sections() -> None: for row_kind, behavior in ROW_BEHAVIORS.items(): if not behavior.navigable: diff --git a/tests/cleave/viz/test_help_panel_cache.py b/tests/cleave/viz/test_help_panel_cache.py index 3c985da..39abff4 100644 --- a/tests/cleave/viz/test_help_panel_cache.py +++ b/tests/cleave/viz/test_help_panel_cache.py @@ -25,7 +25,7 @@ def test_help_content_signature_stable_for_same_inputs() -> None: paused=False, timeline_recording=False, timeline_override_active=False, - preset_switching_rotation_set=None, + preset_switching=None, ) sig_b = help_content_signature( focus, @@ -34,7 +34,7 @@ def test_help_content_signature_stable_for_same_inputs() -> None: paused=False, timeline_recording=False, timeline_override_active=False, - preset_switching_rotation_set=None, + preset_switching=None, ) assert sig_a == sig_b @@ -47,7 +47,7 @@ def test_help_content_signature_changes_on_focus_change() -> None: paused=False, timeline_recording=False, timeline_override_active=False, - preset_switching_rotation_set=None, + preset_switching=None, ) track = help_content_signature( RowDescriptor(RowKind.TRACK_HEADER, slot="layer_1"), @@ -56,7 +56,7 @@ def test_help_content_signature_changes_on_focus_change() -> None: paused=False, timeline_recording=False, timeline_override_active=False, - preset_switching_rotation_set=None, + preset_switching=None, ) assert transport != track @@ -73,7 +73,6 @@ def test_compose_panel_cpu_cache_hit_reuses_panel_surface() -> None: paused=False, timeline_recording=False, timeline_override_active=False, - preset_switching_rotation_set=None, ) with patch.object(overlay, "_build_panel_surface", wraps=overlay._build_panel_surface) as build: @@ -146,7 +145,7 @@ def test_help_upload_signature_uses_screen_rect_active_size() -> None: paused=False, timeline_recording=False, timeline_override_active=False, - preset_switching_rotation_set=None, + preset_switching=None, ) screen_rect = (1000, 10, 260, 180) upload_sig = help_upload_signature(content_sig, screen_rect, (260, 180)) diff --git a/tests/cleave/viz/test_key_repeat.py b/tests/cleave/viz/test_key_repeat.py index de205d5..47788a9 100644 --- a/tests/cleave/viz/test_key_repeat.py +++ b/tests/cleave/viz/test_key_repeat.py @@ -155,7 +155,8 @@ def test_delete_key_pressed_matches_keysym_and_scancode() -> None: def test_add_current_preset_key_pressed() -> None: - assert add_current_preset_key_pressed(pygame.K_u, 0) + assert add_current_preset_key_pressed(pygame.K_p, 0) + assert not add_current_preset_key_pressed(pygame.K_u, 0) assert not add_current_preset_key_pressed(pygame.K_PLUS, 0) assert not add_current_preset_key_pressed(pygame.K_KP_PLUS, 0) assert not add_current_preset_key_pressed( diff --git a/tests/cleave/viz/test_overlay.py b/tests/cleave/viz/test_overlay.py index e3f8362..7b264c4 100644 --- a/tests/cleave/viz/test_overlay.py +++ b/tests/cleave/viz/test_overlay.py @@ -566,6 +566,7 @@ def test_bottom_row_highlight_stops_before_help_hint() -> None: expanded=True, ) }, + render_timeline=RenderTimelineBlock(enabled=True), ) visible = state.layout.visible_indices(state) state.focus_descriptor = state.layout.descriptor(visible[-1]) @@ -603,11 +604,11 @@ def test_bottom_row_highlight_stops_before_help_hint() -> None: ) mid_y = hint_layout.y + line_h // 2 tinted = panel.get_at((overlay._padding + highlight_w // 2, mid_y))[:3] - gap = panel.get_at((hint_layout.x - max(1, font.size("M")[0]) // 2, mid_y))[:3] under_help = panel.get_at((hint_layout.x + 2, mid_y))[:3] assert tinted == expected_tint - assert gap == BACKGROUND + # Help CTA must not sit on the focus tint (glyph AA may occupy the 1ch gap). assert under_help != expected_tint + assert highlight_w < hint_layout.x - overlay._padding def test_bottom_row_rebuilt_with_help_hint_content_width() -> None: @@ -1291,7 +1292,7 @@ def test_action_parameter_row_value_color() -> None: assert _row_value_color(state, mode_row) == HIGHLIGHT -def test_preset_switching_seed_uses_action_parameter_colors() -> None: +def test_preset_list_populate_uses_full_line_action_colors() -> None: state = _minimal_view_state( tracks={ "layer_1": TrackBlock( @@ -1303,21 +1304,18 @@ def test_preset_switching_seed_uses_action_parameter_colors() -> None: beat_sensitivity=1.0, effects={}, expanded=True, - preset_switching="projectm", - preset_switching_shuffle=True, - preset_switching_shuffle_salt=7, + preset_switching="on", + preset_list_expanded=True, ) }, layer_z_order=["layer_1"], ) - seed_row = state.layout.find_by_kind(RowKind.TRACK_PRESET_SWITCHING_SEED) - assert seed_row is not None - assert _action_parameter_label_color(state, seed_row) == ACTION - assert _row_value_color(state, seed_row) == VALUE - state.focus_descriptor = state.layout.descriptor(seed_row) - assert _action_parameter_label_color(state, seed_row) == HIGHLIGHT - assert _row_value_color(state, seed_row) == HIGHLIGHT - assert _row_shows_action_enter_hint(state, seed_row) is True + populate_row = state.layout.find_by_kind(RowKind.TRACK_PRESET_LIST_POPULATE) + assert populate_row is not None + assert _row_value_color(state, populate_row) == ACTION + state.focus_descriptor = state.layout.descriptor(populate_row) + assert _row_value_color(state, populate_row) == HIGHLIGHT + assert _row_shows_action_enter_hint(state, populate_row) is True def test_focused_action_row_shows_enter_hint() -> None: diff --git a/tests/cleave/viz/test_preset_curation_controls.py b/tests/cleave/viz/test_preset_curation_controls.py index f6ee915..9ba755c 100644 --- a/tests/cleave/viz/test_preset_curation_controls.py +++ b/tests/cleave/viz/test_preset_curation_controls.py @@ -40,7 +40,7 @@ def _make_controller( browse_floor=preset_root, stem="drums", opacity_pct=50, - user_presets=[str(preset_root / "pack" / "demo.milk")], + preset_list=[str(preset_root / "pack" / "demo.milk")], ), }, ) @@ -98,7 +98,7 @@ def test_confirm_favourite_copies_without_session_mutation() -> None: unlock_preset_after_modal=lambda slot: unlocked.append(slot), ), ) - user_presets_before = list(session.layers["layer_1"].user_presets) + user_presets_before = list(session.layers["layer_1"].preset_list) controller.prompt_favourite("layer_1", src) modal.handle_keydown(keydown(pygame.K_RETURN)) @@ -106,7 +106,7 @@ def test_confirm_favourite_copies_without_session_mutation() -> None: assert locked == ["layer_1"] assert unlocked == ["layer_1"] assert (root / FAVOURITES_DIR / "demo.milk").exists() - assert session.layers["layer_1"].user_presets == user_presets_before + assert session.layers["layer_1"].preset_list == user_presets_before assert "demo.milk" in controller._index.favourites assert controller._index.marker("demo.milk") == " [F]" @@ -150,7 +150,7 @@ def test_confirm_blacklist_moves_and_updates_playlist_and_user_presets() -> None assert session.layers["layer_1"].playlist.paths == () assert preset_changes == [("layer_1", 0)] assert switching_changes == ["layer_1"] - assert session.layers["layer_1"].user_presets == [] + assert session.layers["layer_1"].preset_list == [] assert "demo.milk" in controller._index.blacklist assert controller._index.marker("demo.milk") == " [B]" @@ -288,7 +288,7 @@ def test_blacklist_from_user_preset_skips_playlist_when_not_current() -> None: paths=(current, user_only), index=0, ) - session.layers["layer_1"].user_presets = [str(user_only)] + session.layers["layer_1"].preset_list = [str(user_only)] controller.prompt_blacklist( "layer_1", @@ -301,7 +301,7 @@ def test_blacklist_from_user_preset_skips_playlist_when_not_current() -> None: assert preset_changes == [] assert session.layers["layer_1"].playlist.paths == (current, user_only) assert session.layers["layer_1"].playlist.current == current - assert session.layers["layer_1"].user_presets == [] + assert session.layers["layer_1"].preset_list == [] assert not user_only.exists() @@ -342,7 +342,7 @@ def test_confirm_restore_removes_favourite() -> None: unlock_preset_after_modal=lambda slot: unlocked.append(slot), ), ) - user_presets_before = list(session.layers["layer_1"].user_presets) + user_presets_before = list(session.layers["layer_1"].preset_list) controller.prompt_restore("layer_1", src) modal.handle_keydown(keydown(pygame.K_RETURN)) @@ -351,7 +351,7 @@ def test_confirm_restore_removes_favourite() -> None: assert src.exists() assert "demo.milk" not in controller._index.favourites assert unlocked == ["layer_1"] - assert session.layers["layer_1"].user_presets == user_presets_before + assert session.layers["layer_1"].preset_list == user_presets_before def test_confirm_remove_favourite_scrubs_playlist_and_user_presets() -> None: @@ -381,7 +381,7 @@ def test_confirm_remove_favourite_scrubs_playlist_and_user_presets() -> None: paths=(keep, remove), index=1, ) - session.layers["layer_1"].user_presets = [str(remove)] + session.layers["layer_1"].preset_list = [str(remove)] controller.prompt_restore("layer_1", remove) modal.handle_keydown(keydown(pygame.K_RETURN)) @@ -391,7 +391,7 @@ def test_confirm_remove_favourite_scrubs_playlist_and_user_presets() -> None: assert "remove.milk" not in controller._index.favourites assert session.layers["layer_1"].playlist.paths == (keep,) assert session.layers["layer_1"].playlist.current == keep - assert session.layers["layer_1"].user_presets == [] + assert session.layers["layer_1"].preset_list == [] assert preset_changes == [("layer_1", 0)] assert switching_changes == ["layer_1"] assert unlocked == ["layer_1"] @@ -429,7 +429,7 @@ def test_confirm_restore_blacklist_scrubs_playlist_and_user_presets() -> None: paths=(keep, restore), index=1, ) - session.layers["layer_1"].user_presets = [str(restore)] + session.layers["layer_1"].preset_list = [str(restore)] controller.prompt_restore("layer_1", restore) modal.handle_keydown(keydown(pygame.K_RETURN)) @@ -441,7 +441,7 @@ def test_confirm_restore_blacklist_scrubs_playlist_and_user_presets() -> None: assert "restore.milk" not in controller._index.blacklist assert session.layers["layer_1"].playlist.paths == (keep,) assert session.layers["layer_1"].playlist.current == keep - assert session.layers["layer_1"].user_presets == [] + assert session.layers["layer_1"].preset_list == [] assert preset_changes == [("layer_1", 0)] assert switching_changes == ["layer_1"] assert unlocked == ["layer_1"] @@ -483,7 +483,7 @@ def test_confirm_restore_blacklist_moves_to_origin() -> None: ), ) playlist_before = session.layers["layer_1"].playlist.paths - user_presets_before = list(session.layers["layer_1"].user_presets) + user_presets_before = list(session.layers["layer_1"].preset_list) controller.prompt_restore("layer_1", src) modal.handle_keydown(keydown(pygame.K_RETURN)) @@ -495,7 +495,7 @@ def test_confirm_restore_blacklist_moves_to_origin() -> None: assert "demo.milk" not in controller._index.blacklist assert unlocked == ["layer_1"] assert session.layers["layer_1"].playlist.paths == playlist_before - assert session.layers["layer_1"].user_presets == user_presets_before + assert session.layers["layer_1"].preset_list == user_presets_before def test_confirm_restore_blacklist_without_origin_uses_choice_modal() -> None: @@ -601,7 +601,7 @@ def test_confirm_cast_copies_marks_and_rebuilds_timeline() -> None: unlock_preset_after_modal=lambda slot: unlocked.append(slot), ), ) - session.layers["layer_1"].preset_switching = "timeline" + session.layers["layer_1"].preset_switching = "on" controller.prompt_cast("layer_1", src) modal.handle_keydown(keydown(pygame.K_RETURN)) # bed @@ -646,7 +646,7 @@ def test_confirm_cast_move_deletes_old_role_and_copies_new() -> None: unlock_preset_after_modal=lambda slot: unlocked.append(slot), ), ) - session.layers["layer_1"].preset_switching = "timeline" + session.layers["layer_1"].preset_switching = "on" controller.prompt_cast("layer_1", src) modal.handle_keydown(keydown(pygame.K_RIGHT)) # pulse @@ -698,7 +698,7 @@ def test_confirm_restore_removes_role_cast() -> None: unlock_preset_after_modal=lambda slot: unlocked.append(slot), ), ) - session.layers["layer_1"].preset_switching = "timeline" + session.layers["layer_1"].preset_switching = "on" controller.prompt_restore("layer_1", src) modal.handle_keydown(keydown(pygame.K_RETURN)) @@ -750,7 +750,7 @@ def test_confirm_restore_choice_removes_only_selected_set() -> None: unlock_preset_after_modal=lambda slot: unlocked.append(slot), ), ) - session.layers["layer_1"].preset_switching = "timeline" + session.layers["layer_1"].preset_switching = "on" controller.prompt_restore("layer_1", src) modal.handle_keydown(keydown(pygame.K_RIGHT)) # cast (bed) @@ -764,7 +764,7 @@ def test_confirm_restore_choice_removes_only_selected_set() -> None: assert unlocked == ["layer_1"] -def test_cast_rebuild_skips_non_timeline_slots() -> None: +def test_cast_rebuild_skips_switching_off_slots() -> None: with tempfile.TemporaryDirectory() as tmp: root = Path(tmp) src = root / "pack" / "demo.milk" @@ -776,7 +776,7 @@ def test_cast_rebuild_skips_non_timeline_slots() -> None: on_preset_switching_change=lambda slot: switching_changes.append(slot), ), ) - session.layers["layer_1"].preset_switching = "projectm" + session.layers["layer_1"].preset_switching = "off" controller.prompt_cast("layer_1", src) modal.handle_keydown(keydown(pygame.K_RETURN)) # bed diff --git a/tests/cleave/viz/test_preset_list_populate.py b/tests/cleave/viz/test_preset_list_populate.py new file mode 100644 index 0000000..fea1318 --- /dev/null +++ b/tests/cleave/viz/test_preset_list_populate.py @@ -0,0 +1,295 @@ +"""Tests for preset list populate helpers.""" + +from __future__ import annotations + +import random +from pathlib import Path +from unittest.mock import patch + +from cleave.preset_playlist import PresetPlaylist +from cleave.timeline import SlotCue, TimelineLane +from cleave.viz.preset_list_populate import ( + needed_preset_count, + populate_from_cue_marker_roles, + populate_from_directory, + repopulate_preset_lists, +) +from cleave.viz.session import LayerRuntime, TuningSession + + +def _session( + tmp: Path, + *, + timeline_enabled: bool = False, + trigger: str = "timer", + cues: tuple[SlotCue, ...] = (), +) -> TuningSession: + browse = tmp / "presets" / "pack" + browse.mkdir(parents=True) + runtime = LayerRuntime( + playlist=PresetPlaylist(current_dir=browse, paths=(), index=0), + browse_floor=browse, + stem="drums", + preset_switching="on", + preset_switching_trigger=trigger, # type: ignore[arg-type] + ) + session = TuningSession(layer_z_order=["layer_1"], layers={"layer_1": runtime}) + session.timeline.enabled = timeline_enabled + if cues: + session.timeline.lanes["layer_1"] = TimelineLane(baseline=0.0, cues=cues) + return session + + +def test_needed_preset_count_timer_and_projectm() -> None: + assert needed_preset_count( + song_duration_sec=120.0, preset_duration=30.0, trigger="timer" + ) == 4 + assert needed_preset_count( + song_duration_sec=120.0, preset_duration=30.0, trigger="projectm" + ) == 6 + assert needed_preset_count( + song_duration_sec=10.0, preset_duration=30.0, trigger="timer" + ) == 1 + + +def test_populate_from_directory_timer_shuffles_all(tmp_path: Path) -> None: + session = _session(tmp_path, trigger="timer") + browse = session.layers["layer_1"].playlist.current_dir + for name in ("a.milk", "b.milk", "c.milk"): + (browse / name).write_text("MILK") + with patch( + "cleave.viz.preset_list_populate.copy_with_dedup", + side_effect=lambda dest, src: dest / src.name, + ): + out = populate_from_directory( + session, + "layer_1", + project_dir=tmp_path, + order="random", + rng=random.Random(0), + ) + assert sorted(Path(path).name for path in out) == ["a.milk", "b.milk", "c.milk"] + assert session.layers["layer_1"].preset_list == out + # Seeded shuffle should not match sorted directory order. + assert [Path(path).name for path in out] != ["a.milk", "b.milk", "c.milk"] + + +def test_populate_from_directory_sequential_keeps_sorted_order( + tmp_path: Path, +) -> None: + session = _session(tmp_path, trigger="timer") + browse = session.layers["layer_1"].playlist.current_dir + for name in ("c.milk", "a.milk", "b.milk"): + (browse / name).write_text("MILK") + with patch( + "cleave.viz.preset_list_populate.copy_with_dedup", + side_effect=lambda dest, src: dest / src.name, + ): + out = populate_from_directory( + session, + "layer_1", + project_dir=tmp_path, + order="sequential", + rng=random.Random(0), + ) + assert [Path(path).name for path in out] == ["a.milk", "b.milk", "c.milk"] + + +def test_populate_from_directory_sequential_respects_max_count( + tmp_path: Path, +) -> None: + session = _session(tmp_path, trigger="timer") + browse = session.layers["layer_1"].playlist.current_dir + for name in ("a.milk", "b.milk", "c.milk", "d.milk", "e.milk"): + (browse / name).write_text("MILK") + with patch( + "cleave.viz.preset_list_populate.copy_with_dedup", + side_effect=lambda dest, src: dest / src.name, + ): + out = populate_from_directory( + session, + "layer_1", + project_dir=tmp_path, + max_count=2, + order="sequential", + rng=random.Random(0), + ) + assert [Path(path).name for path in out] == ["a.milk", "b.milk"] + + +def test_populate_from_directory_respects_max_count(tmp_path: Path) -> None: + session = _session(tmp_path, trigger="timer") + browse = session.layers["layer_1"].playlist.current_dir + for name in ("a.milk", "b.milk", "c.milk", "d.milk", "e.milk"): + (browse / name).write_text("MILK") + with patch( + "cleave.viz.preset_list_populate.copy_with_dedup", + side_effect=lambda dest, src: dest / src.name, + ): + out = populate_from_directory( + session, + "layer_1", + project_dir=tmp_path, + max_count=2, + order="random", + rng=random.Random(0), + ) + assert len(out) == 2 + assert len(set(out)) == 2 + + +def test_populate_from_directory_timeline_trigger_one_per_segment( + tmp_path: Path, +) -> None: + session = _session( + tmp_path, + trigger="timeline", + timeline_enabled=False, + cues=( + SlotCue(t=1.0, level=1.0), + SlotCue(t=2.0, level=0.0), + SlotCue(t=3.0, level=1.0), + ), + ) + browse = session.layers["layer_1"].playlist.current_dir + (browse / "a.milk").write_text("MILK") + with patch( + "cleave.viz.preset_list_populate.copy_with_dedup", + side_effect=lambda dest, src: dest / src.name, + ): + out = populate_from_directory( + session, + "layer_1", + project_dir=tmp_path, + order="random", + rng=random.Random(0), + ) + assert len(out) == 2 + + +def test_populate_from_directory_timeline_sequential_cycles_sorted( + tmp_path: Path, +) -> None: + session = _session( + tmp_path, + trigger="timeline", + timeline_enabled=True, + cues=( + SlotCue(t=1.0, level=1.0), + SlotCue(t=2.0, level=0.0), + SlotCue(t=3.0, level=1.0), + SlotCue(t=4.0, level=0.0), + SlotCue(t=5.0, level=1.0), + ), + ) + browse = session.layers["layer_1"].playlist.current_dir + for name in ("b.milk", "a.milk"): + (browse / name).write_text("MILK") + with patch( + "cleave.viz.preset_list_populate.copy_with_dedup", + side_effect=lambda dest, src: dest / src.name, + ): + out = populate_from_directory( + session, + "layer_1", + project_dir=tmp_path, + order="sequential", + rng=random.Random(0), + ) + assert [Path(path).name for path in out] == ["a.milk", "b.milk", "a.milk"] + + +def test_populate_from_cue_marker_roles(tmp_path: Path) -> None: + session = _session( + tmp_path, + trigger="timeline", + timeline_enabled=True, + cues=( + SlotCue(t=1.0, level=1.0, role="pulse"), + SlotCue(t=2.0, level=0.0), + SlotCue(t=3.0, level=1.0, role="lead"), + ), + ) + for role in ("pulse", "lead"): + role_dir = tmp_path / "presets" / "roles" / role + role_dir.mkdir(parents=True) + (role_dir / f"{role}.milk").write_text("MILK") + with patch( + "cleave.viz.preset_list_populate.copy_with_dedup", + side_effect=lambda dest, src: dest / src.name, + ): + out = populate_from_cue_marker_roles( + session, + "layer_1", + project_dir=tmp_path, + preset_root=tmp_path / "presets", + rng=random.Random(0), + ) + assert [Path(path).name for path in out] == ["pulse.milk", "lead.milk"] + + +def test_repopulate_preset_lists_cue_roles(tmp_path: Path) -> None: + session = _session( + tmp_path, + trigger="timeline", + timeline_enabled=True, + cues=(SlotCue(t=1.0, level=1.0, role="accent"),), + ) + role_dir = tmp_path / "presets" / "roles" / "accent" + role_dir.mkdir(parents=True) + (role_dir / "accent.milk").write_text("MILK") + with patch( + "cleave.viz.preset_list_populate.copy_with_dedup", + side_effect=lambda dest, src: dest / src.name, + ): + repopulate_preset_lists( + session, + mode="cue roles", + project_dir=tmp_path, + preset_root=tmp_path / "presets", + rng=random.Random(0), + ) + assert Path(session.layers["layer_1"].preset_list[0]).name == "accent.milk" + + +def test_repopulate_preset_lists_no_is_noop(tmp_path: Path) -> None: + session = _session(tmp_path, trigger="timeline", timeline_enabled=True) + browse = session.layers["layer_1"].playlist.current_dir + (browse / "a.milk").write_text("MILK") + repopulate_preset_lists( + session, + mode="no", + project_dir=tmp_path, + preset_root=tmp_path / "presets", + rng=random.Random(0), + ) + assert session.layers["layer_1"].preset_list == [] + + +def test_repopulate_skips_layers_with_switching_off(tmp_path: Path) -> None: + session = _session(tmp_path, trigger="timeline", timeline_enabled=True) + session.layers["layer_1"].preset_switching = "off" + browse = session.layers["layer_1"].playlist.current_dir + (browse / "a.milk").write_text("MILK") + repopulate_preset_lists( + session, + mode="directory random", + project_dir=tmp_path, + preset_root=tmp_path / "presets", + rng=random.Random(0), + ) + assert session.layers["layer_1"].preset_list == [] + + +def test_repopulate_skips_non_timeline_trigger(tmp_path: Path) -> None: + session = _session(tmp_path, trigger="timer", timeline_enabled=True) + browse = session.layers["layer_1"].playlist.current_dir + (browse / "a.milk").write_text("MILK") + repopulate_preset_lists( + session, + mode="directory random", + project_dir=tmp_path, + preset_root=tmp_path / "presets", + rng=random.Random(0), + ) + assert session.layers["layer_1"].preset_list == [] diff --git a/tests/cleave/viz/test_preset_seed_controls.py b/tests/cleave/viz/test_preset_seed_controls.py deleted file mode 100644 index c02cd78..0000000 --- a/tests/cleave/viz/test_preset_seed_controls.py +++ /dev/null @@ -1,139 +0,0 @@ -"""Tests for per-layer shuffle seed modal controller.""" - -from __future__ import annotations - -from pathlib import Path -from unittest.mock import MagicMock, patch - -from cleave.preset_playlist import PresetPlaylist -from cleave.preset_rotation import PresetRotation -from cleave.viz.layer import StemLayer -from cleave.viz.modal import ModalHost -from cleave.viz.preset_seed_controls import PresetSeedController -from cleave.viz.session import LayerRuntime, TuningSession - - -def _runtime( - *, - mode: str = "none", - shuffle: bool = False, - salt: int = 0, -) -> LayerRuntime: - playlist = PresetPlaylist( - current_dir=Path("/tmp/presets/drums"), - paths=(Path("/tmp/presets/drums/a.milk"),), - index=0, - ) - return LayerRuntime( - playlist=playlist, - browse_floor=playlist.current_dir, - stem="drums", - preset_switching=mode, # type: ignore[arg-type] - preset_switching_shuffle=shuffle, - preset_switching_shuffle_salt=salt, - ) - - -def _session(*runtimes: tuple[str, LayerRuntime]) -> TuningSession: - order = [slot for slot, _ in runtimes] - return TuningSession( - layer_z_order=order, - layers={slot: runtime for slot, runtime in runtimes}, - ) - - -def test_prompt_opens_yes_cancel_when_shuffle_on() -> None: - session = _session(("layer_1", _runtime(mode="projectm", shuffle=True, salt=1))) - controller = PresetSeedController(session, ModalHost(), {}, preset_root=Path("/tmp/presets")) - controller.prompt("layer_1") - assert controller._modal.active is True - view = controller._modal.view_state() - assert view is not None - assert view.message == "Generate a new seed?" - assert view.options == ("Yes", "Cancel") - - -def test_prompt_noop_when_shuffle_off() -> None: - session = _session(("layer_1", _runtime(mode="projectm", shuffle=False))) - controller = PresetSeedController(session, ModalHost(), {}, preset_root=Path("/tmp/presets")) - controller.prompt("layer_1") - assert controller._modal.active is False - - -def test_prompt_noop_when_switching_none() -> None: - session = _session(("layer_1", _runtime(mode="none", shuffle=True))) - controller = PresetSeedController(session, ModalHost(), {}, preset_root=Path("/tmp/presets")) - controller.prompt("layer_1") - assert controller._modal.active is False - - -def test_confirm_changes_salt_and_rebuilds_projectm() -> None: - session = _session( - ("layer_1", _runtime(mode="projectm", shuffle=True, salt=1)), - ) - switched: list[str] = [] - controller = PresetSeedController( - session, - ModalHost(), - {}, - preset_root=Path("/tmp/presets"), - on_preset_switching_change=switched.append, - ) - controller._confirm("layer_1") - assert session.layers["layer_1"].preset_switching_shuffle_salt != 1 - assert switched == ["layer_1"] - - -def test_reroll_all_updates_every_layer_salt() -> None: - session = _session( - ("layer_1", _runtime(mode="projectm", shuffle=True, salt=1)), - ("layer_2", _runtime(mode="none", shuffle=False, salt=2)), - ("layer_3", _runtime(mode="timeline", shuffle=True, salt=3)), - ) - switched: list[str] = [] - controller = PresetSeedController( - session, - ModalHost(), - {}, - preset_root=Path("/tmp/presets"), - on_preset_switching_change=switched.append, - ) - controller.reroll_all() - assert session.layers["layer_1"].preset_switching_shuffle_salt != 1 - assert session.layers["layer_2"].preset_switching_shuffle_salt != 2 - assert session.layers["layer_3"].preset_switching_shuffle_salt != 3 - assert switched == ["layer_1"] - - -@patch("cleave.viz.preset_seed_controls.rebuild_timeline_preset_rotation_preserving_count") -def test_confirm_timeline_preserves_switch_count( - mock_rebuild: MagicMock, -) -> None: - runtime = _runtime(mode="timeline", shuffle=True, salt=5) - session = _session(("layer_1", runtime)) - layer = StemLayer( - slot="layer_1", - pm=MagicMock(), - fbo=MagicMock(), - playlist=runtime.playlist, - ) - layer.timeline_switch_count = 3 - layer.rotation_anchor = 1 - layer.preset_rotation = PresetRotation( - paths=(Path("a.milk"), Path("b.milk")), - shuffle=True, - seed=1, - anchor=1, - ) - controller = PresetSeedController( - session, - ModalHost(), - {"layer_1": layer}, - preset_root=Path("/tmp/presets"), - ) - controller._confirm("layer_1") - assert runtime.preset_switching_shuffle_salt != 5 - mock_rebuild.assert_called_once() - kwargs = mock_rebuild.call_args.kwargs - assert kwargs["shuffle_salt"] == runtime.preset_switching_shuffle_salt - assert layer.timeline_switch_count == 3 diff --git a/tests/cleave/viz/test_preset_switching.py b/tests/cleave/viz/test_preset_switching.py index b4e4e4f..b8d14df 100644 --- a/tests/cleave/viz/test_preset_switching.py +++ b/tests/cleave/viz/test_preset_switching.py @@ -1,35 +1,22 @@ -"""Tests for cleave.viz.preset_switching runtime helper.""" +"""Tests for unified list-based preset switching.""" from __future__ import annotations -import tempfile from pathlib import Path from unittest.mock import MagicMock, call, patch -from cleave.config_schema import ( - DEFAULT_EASTER_EGG, - DEFAULT_HARD_CUT_DURATION, - DEFAULT_HARD_CUT_ENABLED, - DEFAULT_HARD_CUT_SENSITIVITY, - DEFAULT_PRESET_DURATION, - DEFAULT_PRESET_START_CLEAN, - DEFAULT_SOFT_CUT_DURATION, -) from cleave.preset_playlist import PresetPlaylist from cleave.projectm import ProjectM -from cleave.viz.layer import StemLayer from cleave.timeline import SlotCue, TimelineLane +from cleave.viz.layer import StemLayer from cleave.viz.preset_switching import ( - EMPTY_ROTATION_NOTIFICATION, - EMPTY_USER_PRESETS_NOTIFICATION, + EMPTY_PRESET_LIST_NOTIFICATION, active_auto_preset_path, - advance_timeline_preset_switching, + advance_preset_switching, apply_preset_switching, load_manual_preset_clean, + reanchor_list_preset_after_browse, reapply_projectm_preset_switching, - rebuild_timeline_preset_rotation_preserving_count, - reset_projectm_preset_timer, - restart_projectm_preset_timer, ) from cleave.viz.session import LayerRuntime, TuningSession @@ -38,13 +25,13 @@ Path("/tmp/presets/drums/b.milk"), Path("/tmp/presets/drums/c.milk"), ) +_LIST = [str(path) for path in _MILK] -_PRESET_ROOT = Path("/tmp/presets") - -def _stem_layer(*, paths: tuple[Path, ...], index: int = 0) -> StemLayer: - current_dir = Path("/tmp/presets/drums") - playlist = PresetPlaylist(current_dir=current_dir, paths=paths, index=index) +def _stem_layer(*, paths: tuple[Path, ...] = _MILK, index: int = 0) -> StemLayer: + playlist = PresetPlaylist( + current_dir=Path("/tmp/presets/drums"), paths=paths, index=index + ) pm = ProjectM.__new__(ProjectM) pm.lock_preset = MagicMock() pm.set_hard_cut_enabled = MagicMock() @@ -58,12 +45,36 @@ def _stem_layer(*, paths: tuple[Path, ...], index: int = 0) -> StemLayer: return StemLayer(slot="layer_1", pm=pm, fbo=MagicMock(), playlist=playlist) +def _session( + *, + switching: str = "on", + trigger: str = "timer", + preset_list: list[str] | None = None, + timeline_enabled: bool = False, + duration: float = 30.0, +) -> TuningSession: + runtime = LayerRuntime( + playlist=PresetPlaylist( + current_dir=Path("/tmp/presets/drums"), + paths=_MILK, + index=0, + ), + browse_floor=Path("/tmp/presets/drums"), + stem="drums", + preset_switching=switching, # type: ignore[arg-type] + preset_switching_trigger=trigger, # type: ignore[arg-type] + preset_list=list(preset_list if preset_list is not None else _LIST), + preset_duration=duration, + ) + session = TuningSession(layer_z_order=["layer_1"], layers={"layer_1": runtime}) + session.timeline.enabled = timeline_enabled + return session + + def test_load_manual_preset_clean_forces_black_boot_then_restores() -> None: - layer = _stem_layer(paths=_MILK) + layer = _stem_layer() layer.playlist.load_into = MagicMock() - load_manual_preset_clean(layer, preset_start_clean=False) - assert layer.pm.set_preset_start_clean.call_args_list == [ call(True), call(False), @@ -72,870 +83,298 @@ def test_load_manual_preset_clean_forces_black_boot_then_restores() -> None: assert layer.auto_preset_path == _MILK[0].resolve() -def test_load_manual_preset_clean_restores_configured_start_clean() -> None: - layer = _stem_layer(paths=_MILK) - layer.playlist.load_into = MagicMock() - - load_manual_preset_clean(layer, preset_start_clean=True) - - assert layer.pm.set_preset_start_clean.call_args_list == [ - call(True), - call(True), - ] - - -def test_load_manual_preset_clean_noop_without_preset() -> None: - layer = _stem_layer(paths=()) - layer.playlist.load_into = MagicMock() - - load_manual_preset_clean(layer) - - layer.pm.set_preset_start_clean.assert_not_called() - layer.playlist.load_into.assert_not_called() - - -def test_apply_none_locks_and_disables_hard_cuts() -> None: - layer = _stem_layer(paths=_MILK) +def test_apply_off_locks_and_clears_playlist() -> None: + layer = _stem_layer() mock_pl = MagicMock() layer.projectm_playlist = mock_pl layer.auto_preset_path = _MILK[2] - - apply_preset_switching(layer, mode="none", rotation_set="directory", preset_root=_PRESET_ROOT) - + apply_preset_switching(layer, mode="off", preset_list=_LIST) mock_pl.destroy.assert_called_once() assert layer.projectm_playlist is None assert layer.auto_preset_path is None + assert layer.preset_rotation is None layer.pm.lock_preset.assert_called_with(True) - layer.pm.set_hard_cut_enabled.assert_called_with(False) -@patch("cleave.viz.preset_switching.milk_files_in_dir", return_value=_MILK) -@patch("cleave.viz.preset_switching.ProjectMPlaylist") -def test_apply_projectm_connects_playlist( - mock_playlist_cls: MagicMock, - _mock_milk: MagicMock, -) -> None: - layer = _stem_layer(paths=_MILK) - playlist = MagicMock() - playlist.size.return_value = 3 - playlist.item.side_effect = lambda i: _MILK[i] - mock_playlist_cls.create.return_value = playlist +def test_apply_on_empty_list_notifies_and_holds() -> None: + layer = _stem_layer() on_empty = MagicMock() - apply_preset_switching( - layer, mode="projectm", rotation_set="directory", on_empty=on_empty, - preset_root=_PRESET_ROOT, - ) - - layer.pm.lock_preset.assert_called_with(False) - layer.pm.set_hard_cut_enabled.assert_called_with(DEFAULT_HARD_CUT_ENABLED) - layer.pm.set_preset_duration.assert_called_once_with(DEFAULT_PRESET_DURATION) - layer.pm.set_soft_cut_duration.assert_called_once_with(DEFAULT_SOFT_CUT_DURATION) - layer.pm.set_hard_cut_duration.assert_called_once_with(DEFAULT_HARD_CUT_DURATION) - layer.pm.set_hard_cut_sensitivity.assert_called_once_with( - DEFAULT_HARD_CUT_SENSITIVITY + layer, mode="on", trigger="timer", preset_list=[], on_empty=on_empty ) - layer.pm.set_easter_egg.assert_called_once_with(DEFAULT_EASTER_EGG) - layer.pm.set_preset_start_clean.assert_called_once_with(False) - playlist.connect.assert_called_once() - assert playlist.connect.call_args.kwargs["on_preset_loaded"] is not None - playlist.add_path.assert_called_once_with( - layer.playlist.current_dir, recurse=False, allow_duplicates=False - ) - playlist.sort.assert_called_once_with() - playlist.set_shuffle.assert_called_once_with(False) - assert layer.projectm_playlist is playlist - layer.pm.load_preset.assert_called_once_with(_MILK[0], smooth=False) - assert layer.auto_preset_path == _MILK[0].resolve() - playlist.set_position.assert_called_once_with(0, hard_cut=True) - on_empty.assert_not_called() + on_empty.assert_called_once() + assert layer.preset_rotation is None + layer.pm.lock_preset.assert_called_with(True) -@patch("cleave.viz.preset_switching.milk_files_in_dir", return_value=_MILK) @patch("cleave.viz.preset_switching.ProjectMPlaylist") -def test_apply_projectm_shuffle_enabled( - mock_playlist_cls: MagicMock, - _mock_milk: MagicMock, -) -> None: - from cleave.preset_rotation import first_shuffle_bag_order, layer_rotation_seed - - layer = _stem_layer(paths=_MILK) +def test_apply_projectm_trigger_feeds_list(mock_playlist_cls: MagicMock) -> None: + layer = _stem_layer() playlist = MagicMock() playlist.size.return_value = 3 - playlist.item.side_effect = lambda i: _MILK[i] + playlist.item.side_effect = list(_MILK) mock_playlist_cls.create.return_value = playlist - salt = 11 - seed = layer_rotation_seed(_MILK, slot="layer_1", shuffle_salt=salt) - ordered = first_shuffle_bag_order(_MILK, seed=seed) - apply_preset_switching( - layer, - mode="projectm", - rotation_set="directory", - shuffle=True, - shuffle_salt=salt, - preset_root=_PRESET_ROOT, + layer, mode="on", trigger="projectm", preset_list=_LIST ) - - playlist.add_path.assert_not_called() - playlist.sort.assert_not_called() - playlist.add_presets.assert_called_once_with(ordered, allow_duplicates=True) + playlist.add_presets.assert_called_once_with(list(_MILK), allow_duplicates=True) playlist.set_shuffle.assert_called_once_with(False) + assert layer.projectm_playlist is playlist + layer.pm.lock_preset.assert_called_with(False) -@patch("cleave.viz.preset_switching.milk_files_in_dir", return_value=_MILK) -@patch("cleave.viz.preset_switching.ProjectMPlaylist") -def test_apply_projectm_respects_hard_cut_disabled( - mock_playlist_cls: MagicMock, - _mock_milk: MagicMock, -) -> None: - layer = _stem_layer(paths=_MILK) - playlist = MagicMock() - playlist.size.return_value = 3 - playlist.item.side_effect = lambda i: _MILK[i] - mock_playlist_cls.create.return_value = playlist - +def test_apply_timer_trigger_builds_rotation() -> None: + layer = _stem_layer() apply_preset_switching( - layer, - mode="projectm", - rotation_set="directory", - hard_cut_enabled=False, - preset_root=_PRESET_ROOT, + layer, mode="on", trigger="timer", preset_list=_LIST ) + assert layer.projectm_playlist is None + assert layer.preset_rotation is not None + assert layer.preset_rotation.paths == _MILK + layer.pm.lock_preset.assert_called_with(True) + layer.pm.load_preset.assert_called() - layer.pm.set_hard_cut_enabled.assert_called_with(False) - - -def test_restart_projectm_preset_timer_skips_when_no_current() -> None: - layer = _stem_layer(paths=()) - restart_projectm_preset_timer(layer) - layer.pm.load_preset.assert_not_called() - - -def test_restart_projectm_preset_timer_uses_tracked_auto_preset() -> None: - layer = _stem_layer(paths=_MILK, index=0) - layer.auto_preset_path = _MILK[2].resolve() - restart_projectm_preset_timer(layer) - layer.pm.load_preset.assert_called_once_with(_MILK[2].resolve(), smooth=False) - - -def test_restart_projectm_preset_timer_falls_back_to_browse_current() -> None: - layer = _stem_layer(paths=_MILK, index=1) - restart_projectm_preset_timer(layer) - layer.pm.load_preset.assert_called_once_with(_MILK[1], smooth=False) - assert layer.auto_preset_path == _MILK[1].resolve() - - -def test_reset_projectm_preset_timer_locks_and_unlocks() -> None: - layer = _stem_layer(paths=_MILK) - reset_projectm_preset_timer(layer) - layer.pm.lock_preset.assert_has_calls([call(True), call(False)]) - layer.pm.load_preset.assert_not_called() - - -def test_active_auto_preset_path_prefers_tracked() -> None: - layer = _stem_layer(paths=_MILK, index=0) - layer.auto_preset_path = _MILK[2].resolve() - assert active_auto_preset_path(layer) == _MILK[2].resolve() - - -@patch("cleave.viz.preset_switching.ProjectMPlaylist") -def test_apply_projectm_empty_dir_falls_back(mock_playlist_cls: MagicMock) -> None: - layer = _stem_layer(paths=()) - on_empty = MagicMock() +def test_apply_timeline_trigger_indexes_list() -> None: + layer = _stem_layer() + session = _session(trigger="timeline", timeline_enabled=True) apply_preset_switching( - layer, mode="projectm", rotation_set="directory", on_empty=on_empty, - preset_root=_PRESET_ROOT, + layer, + mode="on", + trigger="timeline", + preset_list=_LIST, + session=session, ) - - mock_playlist_cls.create.assert_not_called() + assert layer.projectm_playlist is None + assert layer.preset_rotation is not None layer.pm.lock_preset.assert_called_with(True) - on_empty.assert_called_once() - - -def test_empty_rotation_notification_text() -> None: - assert "No presets" in EMPTY_ROTATION_NOTIFICATION - - -def test_empty_user_presets_notification_text() -> None: - assert "user-defined" in EMPTY_USER_PRESETS_NOTIFICATION @patch("cleave.viz.preset_switching.ProjectMPlaylist") -def test_apply_user_defined_rotation_set_adds_presets( +def test_apply_projectm_trigger_works_with_timeline_enabled( mock_playlist_cls: MagicMock, ) -> None: - layer = _stem_layer(paths=_MILK) + layer = _stem_layer() playlist = MagicMock() - playlist.size.return_value = 2 - playlist.item.side_effect = lambda i: _MILK[i] + playlist.size.return_value = 3 + playlist.item.side_effect = list(_MILK) mock_playlist_cls.create.return_value = playlist - on_empty = MagicMock() - + session = _session(trigger="projectm", timeline_enabled=True) apply_preset_switching( layer, - mode="projectm", - rotation_set="user_defined", - user_presets=[str(_MILK[0]), str(_MILK[1])], - on_empty=on_empty, - preset_root=_PRESET_ROOT, + mode="on", + trigger="projectm", + preset_list=_LIST, + session=session, ) - + assert layer.projectm_playlist is playlist layer.pm.lock_preset.assert_called_with(False) - playlist.add_presets.assert_called_once_with( - [Path(str(_MILK[0])), Path(str(_MILK[1]))], - allow_duplicates=True, - ) - playlist.sort.assert_not_called() - playlist.add_path.assert_not_called() - on_empty.assert_not_called() - -@patch("cleave.viz.preset_switching.ProjectMPlaylist") -def test_apply_user_defined_rotation_set_empty_falls_back( - mock_playlist_cls: MagicMock, -) -> None: - layer = _stem_layer(paths=_MILK) - on_empty = MagicMock() +def test_apply_timer_trigger_works_with_timeline_enabled() -> None: + layer = _stem_layer() + session = _session(trigger="timer", timeline_enabled=True) apply_preset_switching( layer, - mode="projectm", - rotation_set="user_defined", - user_presets=[], - on_empty=on_empty, - preset_root=_PRESET_ROOT, - ) - - mock_playlist_cls.create.assert_not_called() - layer.pm.lock_preset.assert_called_with(True) - on_empty.assert_called_once() - - -def test_reapply_projectm_preset_switching_only_projectm_layers() -> None: - layer_projectm = _stem_layer(paths=_MILK) - layer_projectm.projectm_playlist = MagicMock() - layer_none = _stem_layer(paths=_MILK) - session = TuningSession( - layer_z_order=["layer_1", "layer_2"], - layers={ - "layer_1": LayerRuntime( - playlist=layer_projectm.playlist, - browse_floor=layer_projectm.playlist.current_dir, - stem="drums", - preset_switching="projectm", - ), - "layer_2": LayerRuntime( - playlist=layer_none.playlist, - browse_floor=layer_none.playlist.current_dir, - stem="bass", - preset_switching="none", - ), - }, - ) - - with ( - patch("cleave.viz.preset_switching.milk_files_in_dir", return_value=_MILK), - patch("cleave.viz.preset_switching.apply_preset_switching") as mock_apply, - patch("cleave.viz.preset_switching._reapply_on_seek") as mock_reapply, - ): - reapply_projectm_preset_switching( - session, - {"layer_1": layer_projectm, "layer_2": layer_none}, - preset_root=_PRESET_ROOT, - ) - - mock_apply.assert_not_called() - mock_reapply.assert_called_once_with( - layer_projectm, - 0.0, - preset_duration=DEFAULT_PRESET_DURATION, - soft_cut_duration=DEFAULT_SOFT_CUT_DURATION, - easter_egg=DEFAULT_EASTER_EGG, - preset_start_clean=DEFAULT_PRESET_START_CLEAN, - hard_cut_duration=DEFAULT_HARD_CUT_DURATION, - hard_cut_sensitivity=DEFAULT_HARD_CUT_SENSITIVITY, - hard_cut_enabled=DEFAULT_HARD_CUT_ENABLED, + mode="on", + trigger="timer", + preset_list=_LIST, + session=session, ) + assert layer.projectm_playlist is None + assert layer.preset_rotation is not None -def test_reapply_without_playlist_falls_back_to_apply() -> None: - layer = _stem_layer(paths=_MILK) - session = TuningSession( - layer_z_order=["layer_1"], - layers={ - "layer_1": LayerRuntime( - playlist=layer.playlist, - browse_floor=layer.playlist.current_dir, - stem="drums", - preset_switching="projectm", - ), - }, - ) - - with patch("cleave.viz.preset_switching.apply_preset_switching") as mock_apply: - reapply_projectm_preset_switching(session, {"layer_1": layer}, preset_root=_PRESET_ROOT) - - mock_apply.assert_called_once() - assert mock_apply.call_args.kwargs.get("on_empty") is None - - -@patch("cleave.viz.preset_switching.ProjectMPlaylist") -def test_reapply_without_playlist_does_not_notify_empty( - mock_playlist_cls: MagicMock, -) -> None: - layer = _stem_layer(paths=()) - on_empty = MagicMock() - session = TuningSession( - layer_z_order=["layer_1"], - layers={ - "layer_1": LayerRuntime( - playlist=layer.playlist, - browse_floor=layer.playlist.current_dir, - stem="drums", - preset_switching="projectm", - ), - }, - ) - +def test_advance_timer_uses_floor_playhead() -> None: + layer = _stem_layer() + session = _session(trigger="timer", duration=10.0) apply_preset_switching( - layer, mode="projectm", rotation_set="directory", on_empty=on_empty, - preset_root=_PRESET_ROOT, - ) - on_empty.assert_called_once() - on_empty.reset_mock() - - reapply_projectm_preset_switching(session, {"layer_1": layer}, preset_root=_PRESET_ROOT) - - on_empty.assert_not_called() - mock_playlist_cls.create.assert_not_called() - - -def test_reapply_on_forward_seek_reconnects_without_reload() -> None: - layer = _stem_layer(paths=_MILK, index=0) - layer.auto_preset_path = _MILK[2].resolve() - playlist = MagicMock() - layer.projectm_playlist = playlist - session = TuningSession( - layer_z_order=["layer_1"], - layers={ - "layer_1": LayerRuntime( - playlist=layer.playlist, - browse_floor=layer.playlist.current_dir, - stem="drums", - preset_switching="projectm", - ), - }, + layer, + mode="on", + trigger="timer", + preset_list=_LIST, + preset_duration=10.0, + session=session, ) - - reapply_projectm_preset_switching(session, {"layer_1": layer}, delta_sec=5.0, preset_root=_PRESET_ROOT) - - playlist.connect.assert_called_once() - layer.pm.lock_preset.assert_has_calls([call(False), call(True), call(False)]) + layer.pm.load_preset.reset_mock() + advance_preset_switching(session, {"layer_1": layer}, 0.0) layer.pm.load_preset.assert_not_called() - playlist.set_position.assert_not_called() - - -def test_reapply_on_backward_seek_restarts_tracked_preset() -> None: - layer = _stem_layer(paths=_MILK, index=0) - layer.auto_preset_path = _MILK[2].resolve() - playlist = MagicMock() - layer.projectm_playlist = playlist - session = TuningSession( - layer_z_order=["layer_1"], - layers={ - "layer_1": LayerRuntime( - playlist=layer.playlist, - browse_floor=layer.playlist.current_dir, - stem="drums", - preset_switching="projectm", - ), - }, - ) + advance_preset_switching(session, {"layer_1": layer}, 10.0) + layer.pm.load_preset.assert_called_once() + assert layer.list_switch_index == 1 - reapply_projectm_preset_switching(session, {"layer_1": layer}, delta_sec=-5.0, preset_root=_PRESET_ROOT) - playlist.connect.assert_called_once() - layer.pm.load_preset.assert_called_once_with(_MILK[2].resolve(), smooth=False) - layer.pm.lock_preset.assert_called_with(False) - - -def _timeline_session(layer: StemLayer, *, enabled: bool = True) -> TuningSession: - session = TuningSession( - layer_z_order=["layer_1"], - layers={ - "layer_1": LayerRuntime( - playlist=layer.playlist, - browse_floor=layer.playlist.current_dir, - stem="drums", - preset_switching="timeline", - ), - }, - ) - session.timeline.enabled = enabled +def test_advance_timeline_uses_on_transition_count() -> None: + layer = _stem_layer() + session = _session(trigger="timeline", timeline_enabled=True) session.timeline.lanes["layer_1"] = TimelineLane( baseline=0.0, - cues=[ + cues=( + SlotCue(t=1.0, level=1.0), + SlotCue(t=2.0, level=0.0), + SlotCue(t=3.0, level=1.0), + SlotCue(t=4.0, level=0.0), SlotCue(t=5.0, level=1.0), - SlotCue(t=10.0, level=0.0), - SlotCue(t=15.0, level=1.0), - ], + ), + ) + apply_preset_switching( + layer, + mode="on", + trigger="timeline", + preset_list=_LIST, + session=session, ) - return session - - -@patch("cleave.viz.preset_switching.milk_files_in_dir", return_value=_MILK) -def test_apply_timeline_locks_and_loads_anchor(_mock_milk: MagicMock) -> None: - layer = _stem_layer(paths=_MILK, index=1) - apply_preset_switching(layer, mode="timeline", rotation_set="directory", preset_root=_PRESET_ROOT) - layer.pm.lock_preset.assert_called_with(True) - layer.pm.set_hard_cut_enabled.assert_called_with(False) - assert layer.preset_rotation is not None - assert layer.rotation_anchor == 1 - assert layer.timeline_switch_count == 0 - layer.pm.load_preset.assert_called_once_with(_MILK[1], smooth=False) - - -@patch("cleave.viz.preset_switching.milk_files_in_dir", return_value=_MILK) -def test_advance_timeline_loads_next_on_rising_edge(_mock_milk: MagicMock) -> None: - layer = _stem_layer(paths=_MILK, index=0) - session = _timeline_session(layer) - apply_preset_switching(layer, mode="timeline", rotation_set="directory", preset_root=_PRESET_ROOT) layer.pm.load_preset.reset_mock() - - advance_timeline_preset_switching(session, {"layer_1": layer}, 4.9) + # Before first on-section: stay on list[0]. + advance_preset_switching(session, {"layer_1": layer}, 0.5) layer.pm.load_preset.assert_not_called() - assert layer.timeline_switch_count == 0 - - advance_timeline_preset_switching(session, {"layer_1": layer}, 5.0) - layer.pm.load_preset.assert_called_once_with(_MILK[1], smooth=False) - assert layer.timeline_switch_count == 1 + assert layer.list_switch_index == 0 + assert layer.auto_preset_path == _MILK[0].resolve() + # First on-section: still list[0] (on-transition count 1 -> index 0). + advance_preset_switching(session, {"layer_1": layer}, 1.5) + layer.pm.load_preset.assert_not_called() + assert layer.list_switch_index == 0 + assert layer.auto_preset_path == _MILK[0].resolve() + # Second on-section: list[1]. + advance_preset_switching(session, {"layer_1": layer}, 3.5) + layer.pm.load_preset.assert_called_once() + assert layer.list_switch_index == 1 assert layer.auto_preset_path == _MILK[1].resolve() - assert layer.playlist.index == 0 - + # Third (last) on-section: list[2], not wrapped to list[0]. layer.pm.load_preset.reset_mock() - advance_timeline_preset_switching(session, {"layer_1": layer}, 15.0) - layer.pm.load_preset.assert_called_once_with(_MILK[2], smooth=False) - assert layer.timeline_switch_count == 2 - assert layer.auto_preset_path == _MILK[2].resolve() - assert layer.playlist.index == 0 - - -@patch("cleave.viz.preset_switching.milk_files_in_dir", return_value=_MILK) -def test_advance_timeline_does_not_mark_config_dirty(_mock_milk: MagicMock) -> None: - from cleave.viz.config_save import ConfigSaveController - from cleave.viz.modal import ModalHost - from tests.support.viz import make_test_cfg - - layer = _stem_layer(paths=_MILK, index=0) - session = _timeline_session(layer) - cfg = make_test_cfg(("layer_1",), preset_root=Path("/tmp/presets")) - save = ConfigSaveController(session, cfg, ModalHost()) - assert not save.config_dirty - - apply_preset_switching(layer, mode="timeline", rotation_set="directory", preset_root=_PRESET_ROOT) - save.clear_config_dirty() - - advance_timeline_preset_switching(session, {"layer_1": layer}, 5.0) - advance_timeline_preset_switching(session, {"layer_1": layer}, 15.0) - + advance_preset_switching(session, {"layer_1": layer}, 5.5) + layer.pm.load_preset.assert_called_once() + assert layer.list_switch_index == 2 assert layer.auto_preset_path == _MILK[2].resolve() - assert layer.playlist.index == 0 - assert not save.config_dirty - - -@patch("cleave.viz.preset_switching.milk_files_in_dir", return_value=_MILK) -def test_advance_timeline_noop_when_timeline_disabled(_mock_milk: MagicMock) -> None: - layer = _stem_layer(paths=_MILK, index=0) - session = _timeline_session(layer, enabled=False) - apply_preset_switching(layer, mode="timeline", rotation_set="directory", preset_root=_PRESET_ROOT) - layer.pm.load_preset.reset_mock() - - advance_timeline_preset_switching(session, {"layer_1": layer}, 15.0) - layer.pm.load_preset.assert_not_called() - assert layer.timeline_switch_count == 0 -@patch("cleave.viz.preset_switching.milk_files_in_dir", return_value=_MILK) -def test_rebuild_timeline_preserves_switch_count(_mock_milk: MagicMock) -> None: - layer = _stem_layer(paths=_MILK, index=0) +def test_advance_timeline_skips_when_timeline_disabled() -> None: + layer = _stem_layer() + session = _session(trigger="timeline", timeline_enabled=False) + session.timeline.lanes["layer_1"] = TimelineLane( + baseline=0.0, + cues=( + SlotCue(t=1.0, level=1.0), + SlotCue(t=2.0, level=0.0), + SlotCue(t=3.0, level=1.0), + ), + ) apply_preset_switching( layer, - mode="timeline", - rotation_set="directory", - shuffle=True, - shuffle_salt=1, - preset_root=_PRESET_ROOT, + mode="on", + trigger="timeline", + preset_list=_LIST, + session=session, ) - layer.timeline_switch_count = 2 - layer.rotation_anchor = 1 - old_seed = layer.preset_rotation.seed layer.pm.load_preset.reset_mock() - - rebuild_timeline_preset_rotation_preserving_count( - layer, - rotation_set="directory", - shuffle=True, - shuffle_salt=2, - preset_root=_PRESET_ROOT, - ) - - assert layer.timeline_switch_count == 2 - assert layer.rotation_anchor == 1 - assert layer.preset_rotation is not None - assert layer.preset_rotation.seed != old_seed - expected = layer.preset_rotation.path_for(2) - layer.pm.load_preset.assert_called_once_with(expected, smooth=False) + advance_preset_switching(session, {"layer_1": layer}, 1.5) + layer.pm.load_preset.assert_not_called() + assert layer.list_switch_index == 0 -@patch("cleave.viz.preset_switching.milk_files_in_dir", return_value=_MILK) -def test_apply_timeline_shuffle_toggle_preserves_switch_count( - _mock_milk: MagicMock, -) -> None: - """Re-apply while already in timeline (e.g. shuffle toggle) must not reset count.""" - layer = _stem_layer(paths=_MILK, index=0) +def test_advance_timer_works_with_timeline_enabled() -> None: + layer = _stem_layer() + session = _session(trigger="timer", timeline_enabled=True, duration=10.0) apply_preset_switching( layer, - mode="timeline", - rotation_set="directory", - shuffle=False, - shuffle_salt=7, - preset_root=_PRESET_ROOT, + mode="on", + trigger="timer", + preset_list=_LIST, + preset_duration=10.0, + session=session, ) - layer.timeline_switch_count = 2 - layer.rotation_anchor = 0 - seed_before = layer.preset_rotation.seed layer.pm.load_preset.reset_mock() + advance_preset_switching(session, {"layer_1": layer}, 10.0) + layer.pm.load_preset.assert_called_once() + assert layer.list_switch_index == 1 - apply_preset_switching( - layer, - mode="timeline", - rotation_set="directory", - shuffle=True, - shuffle_salt=7, - preset_root=_PRESET_ROOT, - ) - assert layer.timeline_switch_count == 2 - assert layer.rotation_anchor == 0 - assert layer.preset_rotation is not None - assert layer.preset_rotation.shuffle is True - assert layer.preset_rotation.seed == seed_before - expected = layer.preset_rotation.path_for(2) - layer.pm.load_preset.assert_called_once_with(expected, smooth=False) - assert expected != layer.preset_rotation.path_for(0) +def test_advance_skips_when_switching_off() -> None: + layer = _stem_layer() + session = _session(switching="off") + advance_preset_switching(session, {"layer_1": layer}, 100.0) + layer.pm.load_preset.assert_not_called() -@patch("cleave.viz.preset_switching.milk_files_in_dir", return_value=_MILK) -def test_apply_timeline_first_enable_still_resets_count( - _mock_milk: MagicMock, -) -> None: - layer = _stem_layer(paths=_MILK, index=1) - layer.timeline_switch_count = 5 - layer.rotation_anchor = 2 - assert layer.preset_rotation is None - +def test_reanchor_after_browse_preserves_current() -> None: + layer = _stem_layer(index=1) + session = _session(trigger="timer", duration=10.0) apply_preset_switching( layer, - mode="timeline", - rotation_set="directory", - shuffle=True, - shuffle_salt=3, - preset_root=_PRESET_ROOT, + mode="on", + trigger="timer", + preset_list=_LIST, + preset_duration=10.0, + session=session, ) - - assert layer.timeline_switch_count == 0 - assert layer.rotation_anchor == 1 - layer.pm.load_preset.assert_called_once_with( - layer.preset_rotation.path_for(0), smooth=False + reanchor_list_preset_after_browse( + layer, session, 10.0, preset_list=_LIST ) + assert layer.list_switch_index == 1 + assert layer.preset_rotation is not None + # browsed b.milk at index 1 while count==1 => anchor 0 keeps b at count 1 + assert layer.preset_rotation.path_for(1) == _MILK[1] -def test_tick_frame_core_advances_timeline_when_playing() -> None: - from cleave.viz.app import tick_frame_core - - runtime = MagicMock() - runtime.seed.session = MagicMock() - runtime.layers_by_slot = {} - runtime.layers = [] - with ( - patch("cleave.viz.app.apply_layer_visibility"), - patch("cleave.viz.app.advance_timeline_preset_switching") as mock_advance, - patch("cleave.viz.app.LayerFramePipeline.render_frame"), - patch("cleave.viz.app.LayerFramePipeline.composite"), - ): - tick_frame_core( - runtime, - 1.0, - paused=False, - was_paused=False, - n_pcm=0, - pm_time_sec=1.0, - blank_visualizers=False, - ) - mock_advance.assert_called_once_with(runtime.seed.session, {}, 1.0) - - -def test_tick_frame_core_skips_advance_when_paused() -> None: - from cleave.viz.app import tick_frame_core - - runtime = MagicMock() - runtime.seed.session = MagicMock() - runtime.layers_by_slot = {} - runtime.layers = [] - with ( - patch("cleave.viz.app.apply_layer_visibility"), - patch("cleave.viz.app.advance_timeline_preset_switching") as mock_advance, - patch("cleave.viz.app.LayerFramePipeline.render_frame"), - patch("cleave.viz.app.LayerFramePipeline.composite"), - ): - tick_frame_core( - runtime, - 1.0, - paused=True, - was_paused=True, - n_pcm=0, - pm_time_sec=1.0, - blank_visualizers=False, - ) - mock_advance.assert_not_called() - - -_PULSE_POOL = ( - Path("/tmp/presets/roles/pulse/p0.milk"), - Path("/tmp/presets/roles/pulse/p1.milk"), -) - - -def _role_timeline_session( - layer: StemLayer, - *, - rotation_set: str = "cast_roles", -) -> TuningSession: - session = TuningSession( - layer_z_order=["layer_1"], - layers={ - "layer_1": LayerRuntime( - playlist=layer.playlist, - browse_floor=layer.playlist.current_dir, - stem="drums", - preset_switching="timeline", - preset_switching_rotation_set=rotation_set, # type: ignore[arg-type] - ), - }, - ) - session.timeline.enabled = True +def test_reanchor_timeline_uses_zero_based_section_index() -> None: + layer = _stem_layer(index=0) + session = _session(trigger="timeline", timeline_enabled=True) session.timeline.lanes["layer_1"] = TimelineLane( baseline=0.0, - cues=[ - SlotCue(t=5.0, level=1.0, role="pulse"), - SlotCue(t=10.0, level=0.0), - SlotCue(t=15.0, level=1.0, role="pulse"), - SlotCue(t=20.0, level=0.0), - SlotCue(t=25.0, level=1.0), - ], + cues=( + SlotCue(t=1.0, level=1.0), + SlotCue(t=2.0, level=0.0), + SlotCue(t=3.0, level=1.0), + ), ) - return session - - -_BED_POOL = ( - Path("/tmp/presets/roles/bed/b0.milk"), - Path("/tmp/presets/roles/bed/b1.milk"), -) - - -def _cast_role_pools(preset_root: Path, role: str): - del preset_root - if role == "pulse": - return _PULSE_POOL - if role == "bed": - return _BED_POOL - return () - - -@patch("cleave.viz.preset_switching.role_pool_paths") -@patch("cleave.viz.preset_switching.milk_files_in_dir", return_value=_MILK) -def test_advance_timeline_role_cue_loads_from_role_pool( - _mock_milk: MagicMock, - mock_role_pool: MagicMock, -) -> None: - mock_role_pool.side_effect = _cast_role_pools - layer = _stem_layer(paths=_MILK, index=0) - session = _role_timeline_session(layer) apply_preset_switching( - layer, mode="timeline", rotation_set="cast_roles", preset_root=_PRESET_ROOT + layer, + mode="on", + trigger="timeline", + preset_list=_LIST, + session=session, ) - assert "pulse" in layer.role_rotations - layer.pm.load_preset.reset_mock() - - advance_timeline_preset_switching(session, {"layer_1": layer}, 5.0) - layer.pm.load_preset.assert_called_once_with(_PULSE_POOL[0], smooth=False) - assert layer.timeline_switch_count == 1 - - -@patch("cleave.viz.preset_switching.role_pool_paths") -@patch("cleave.viz.preset_switching.milk_files_in_dir", return_value=_MILK) -def test_advance_timeline_role_index_is_seek_stable( - _mock_milk: MagicMock, - mock_role_pool: MagicMock, -) -> None: - mock_role_pool.side_effect = _cast_role_pools - layer = _stem_layer(paths=_MILK, index=0) - session = _role_timeline_session(layer) - apply_preset_switching( - layer, mode="timeline", rotation_set="cast_roles", preset_root=_PRESET_ROOT + # Browse to c.milk during the first on-section (transition count 1 -> index 0). + layer.playlist = PresetPlaylist( + current_dir=Path("/tmp/presets/drums"), paths=_MILK, index=2 ) - layer.pm.load_preset.reset_mock() - - advance_timeline_preset_switching(session, {"layer_1": layer}, 15.0) - assert layer.timeline_switch_count == 2 - assert layer.pm.load_preset.call_args_list[-1] == call(_PULSE_POOL[1], smooth=False) - - layer.timeline_switch_count = 0 - layer.auto_preset_path = None - layer.pm.load_preset.reset_mock() - advance_timeline_preset_switching(session, {"layer_1": layer}, 15.0) - layer.pm.load_preset.assert_called_once_with(_PULSE_POOL[1], smooth=False) - assert layer.timeline_switch_count == 2 - - -@patch("cleave.viz.preset_switching.role_pool_paths") -@patch("cleave.viz.preset_switching.milk_files_in_dir", return_value=_MILK) -def test_advance_timeline_directory_ignores_cue_roles( - _mock_milk: MagicMock, - mock_role_pool: MagicMock, -) -> None: - mock_role_pool.side_effect = _cast_role_pools - layer = _stem_layer(paths=_MILK, index=0) - session = _role_timeline_session(layer, rotation_set="directory") - apply_preset_switching( - layer, mode="timeline", rotation_set="directory", preset_root=_PRESET_ROOT + reanchor_list_preset_after_browse( + layer, session, 1.5, preset_list=_LIST ) - assert layer.role_rotations == {} - layer.pm.load_preset.reset_mock() - - advance_timeline_preset_switching(session, {"layer_1": layer}, 5.0) - expected = layer.preset_rotation.path_for(1) - layer.pm.load_preset.assert_called_once_with(expected, smooth=False) + assert layer.list_switch_index == 0 + assert layer.preset_rotation is not None + assert layer.preset_rotation.path_for(0) == _MILK[2] -@patch("cleave.viz.preset_switching.role_pool_paths") -@patch("cleave.viz.preset_switching.milk_files_in_dir", return_value=_MILK) -def test_advance_timeline_cast_roles_null_role_uses_last_role( - _mock_milk: MagicMock, - mock_role_pool: MagicMock, +@patch("cleave.viz.preset_switching.ProjectMPlaylist") +def test_reapply_projectm_only_when_trigger_projectm( + mock_playlist_cls: MagicMock, ) -> None: - mock_role_pool.side_effect = _cast_role_pools - layer = _stem_layer(paths=_MILK, index=0) - session = _role_timeline_session(layer) + layer = _stem_layer() + playlist = MagicMock() + playlist.size.return_value = 3 + playlist.item.side_effect = list(_MILK) + mock_playlist_cls.create.return_value = playlist + session = _session(trigger="projectm") apply_preset_switching( - layer, mode="timeline", rotation_set="cast_roles", preset_root=_PRESET_ROOT + layer, + mode="on", + trigger="projectm", + preset_list=_LIST, + session=session, ) - layer.pm.load_preset.reset_mock() - - # First two on-transitions are pulse; third (t=25) has no role -> last pulse. - advance_timeline_preset_switching(session, {"layer_1": layer}, 25.0) - assert layer.pm.load_preset.call_args_list[-1] == call(_PULSE_POOL[0], smooth=False) - assert layer.timeline_switch_count == 3 - assert layer.last_cast_role == "pulse" - - -@patch("cleave.viz.preset_switching.role_pool_paths") -@patch("cleave.viz.preset_switching.milk_files_in_dir", return_value=_MILK) -def test_advance_timeline_cast_roles_hold_current_skips_null_role( - _mock_milk: MagicMock, - mock_role_pool: MagicMock, -) -> None: - mock_role_pool.side_effect = _cast_role_pools - layer = _stem_layer(paths=_MILK, index=0) - session = _role_timeline_session(layer) - session.layers["layer_1"].cast_roles_timeline_behaviour = "hold_current" - apply_preset_switching( - layer, mode="timeline", rotation_set="cast_roles", preset_root=_PRESET_ROOT + layer.projectm_playlist = playlist + reapply_projectm_preset_switching( + session, {"layer_1": layer}, preset_root=Path("/tmp/presets"), delta_sec=1.0 ) - advance_timeline_preset_switching(session, {"layer_1": layer}, 15.0) - assert layer.timeline_switch_count == 2 - layer.pm.load_preset.reset_mock() - - advance_timeline_preset_switching(session, {"layer_1": layer}, 25.0) - layer.pm.load_preset.assert_not_called() - assert layer.timeline_switch_count == 3 - assert layer.last_cast_role == "pulse" + layer.pm.lock_preset.assert_any_call(False) -@patch("cleave.viz.preset_switching.role_pool_paths") -@patch("cleave.viz.preset_switching.milk_files_in_dir", return_value=_MILK) -def test_apply_cast_roles_uses_default_role_pool_as_main_rotation( - _mock_milk: MagicMock, - mock_role_pool: MagicMock, -) -> None: - mock_role_pool.side_effect = _cast_role_pools - layer = _stem_layer(paths=_MILK, index=0) - apply_preset_switching( - layer, - mode="timeline", - rotation_set="cast_roles", - cast_roles_default_role="pulse", - preset_root=_PRESET_ROOT, - ) - assert layer.preset_rotation is not None - assert layer.preset_rotation.paths == _PULSE_POOL - layer.pm.load_preset.assert_called_with(_PULSE_POOL[0], smooth=False) +def test_active_auto_preset_path_prefers_auto() -> None: + layer = _stem_layer() + layer.auto_preset_path = _MILK[2].resolve() + assert active_auto_preset_path(layer) == _MILK[2].resolve() -@patch("cleave.viz.preset_switching.role_pool_paths") -@patch("cleave.viz.preset_switching.milk_files_in_dir", return_value=_MILK) -def test_apply_cast_roles_empty_default_role_pool_clears_rotation( - _mock_milk: MagicMock, - mock_role_pool: MagicMock, -) -> None: - empty: list[str] = [] - - def empty_default(preset_root: Path, role: str): - del preset_root - if role == "bed": - return () - if role == "pulse": - return _PULSE_POOL - return () - - mock_role_pool.side_effect = empty_default - layer = _stem_layer(paths=_MILK, index=0) - apply_preset_switching( - layer, - mode="timeline", - rotation_set="cast_roles", - preset_root=_PRESET_ROOT, - on_empty=lambda: empty.append("empty"), - ) - assert layer.preset_rotation is None - assert layer.role_rotations == {} - assert empty == ["empty"] - - -def test_apply_preset_switching_rebuilds_role_rotations_from_disk() -> None: - """Cast add/remove updates on-disk pools; switching change rebuilds role_rotations.""" - with tempfile.TemporaryDirectory() as tmp: - root = Path(tmp) - pack = root / "pack" - pack.mkdir() - main = pack / "main.milk" - main.write_text("main", encoding="utf-8") - bed = root / "roles" / "bed" / "cast.milk" - bed.parent.mkdir(parents=True) - bed.write_text("cast", encoding="utf-8") - layer = _stem_layer(paths=(main,), index=0) - layer.playlist = PresetPlaylist(current_dir=pack, paths=(main,), index=0) - - apply_preset_switching( - layer, mode="timeline", rotation_set="cast_roles", preset_root=root - ) - assert "bed" in layer.role_rotations - assert layer.role_rotations["bed"].paths == (bed,) - - bed.unlink() - apply_preset_switching( - layer, mode="timeline", rotation_set="cast_roles", preset_root=root - ) - assert layer.role_rotations == {} +def test_empty_notification_constant() -> None: + assert "list" in EMPTY_PRESET_LIST_NOTIFICATION.lower() diff --git a/tests/cleave/viz/test_row_fields.py b/tests/cleave/viz/test_row_fields.py index 89e9cf6..0b4c1ab 100644 --- a/tests/cleave/viz/test_row_fields.py +++ b/tests/cleave/viz/test_row_fields.py @@ -51,6 +51,16 @@ def test_tree_branch_prefix() -> None: assert tree_branch_prefix(0) == "" assert tree_branch_prefix(1) == "└─ " assert tree_branch_prefix(2) == " └─ " + assert tree_branch_prefix(3) == " └─ " + + +def test_tree_branch_leading_spaces() -> None: + from cleave.viz.row_fields import tree_branch_leading_spaces + + assert tree_branch_leading_spaces(0) == "" + assert tree_branch_leading_spaces(1) == "" + assert tree_branch_leading_spaces(2) == " " + assert tree_branch_leading_spaces(3) == " " def test_row_panel_label_settings_header() -> None: @@ -66,7 +76,7 @@ def test_labeled_row_prefix_settings_children() -> None: def test_labeled_row_prefix_track_depths() -> None: assert labeled_row_prefix(RowKind.TRACK_STEM) == "└─ driving stem: " - assert labeled_row_prefix(RowKind.TRACK_PRESET_SWITCHING_ROTATION_SET) == " └─ rotation set: " + assert labeled_row_prefix(RowKind.TRACK_PRESET_SWITCHING_TRIGGER) == " └─ trigger: " def test_format_row_value_settings() -> None: @@ -104,7 +114,7 @@ def test_format_row_value_track_and_render() -> None: opacity_pct=75, beat_sensitivity=1.25, effects={}, - preset_switching="projectm", + preset_switching="on", preset_duration=45.0, ) }, @@ -118,7 +128,7 @@ def test_format_row_value_track_and_render() -> None: slot_desc = RowDescriptor(RowKind.TRACK_BLEND, slot="layer_1") assert format_row_value(state, slot_desc) == "add" mode_desc = RowDescriptor(RowKind.TRACK_PRESET_SWITCHING, slot="layer_1") - assert format_row_value(state, mode_desc) == "projectM" + assert format_row_value(state, mode_desc) == "on" duration_desc = RowDescriptor(RowKind.TRACK_PRESET_DURATION, slot="layer_1") assert format_row_value(state, duration_desc) == "45s" assert format_row_value(state, RowDescriptor(RowKind.RENDER_OVERLAY_OPENING_POSITION)) == ( @@ -243,7 +253,7 @@ def test_row_expand_subheader_display_text() -> None: desc = RowDescriptor(RowKind.TRACK_PRESET_SWITCHING, slot="layer_1") assert ( row_expand_subheader_display_text(state, desc) - == "└─ preset switching: none ▶" + == "└─ preset switching: off ▶" ) @@ -274,29 +284,31 @@ def test_composite_header_render_overlay_metadata() -> None: assert row_composite_header_display_text(state, desc) == "Render: OVERLAYS ▶" -def test_preset_switching_seed_is_action_parameter_with_value() -> None: - field = row_field_def(RowKind.TRACK_PRESET_SWITCHING_SEED) - assert field.present_style == RowPresentStyle.ACTION_PARAMETER - state = _minimal_view_state( - tracks={ - "layer_1": TrackBlock( - stem="drums", - preset_dir_label="dir", - preset_label="preset.milk", - blend_mode="black-key", - opacity_pct=50, - beat_sensitivity=1.0, - effects={}, - expanded=True, - preset_switching="projectm", - preset_switching_shuffle=True, - preset_switching_shuffle_salt=42, - ) - } +def test_preset_list_populate_is_full_line_action() -> None: + field = row_field_def(RowKind.TRACK_PRESET_LIST_POPULATE) + assert field.present_style == RowPresentStyle.FULL_LINE + assert field.panel_label == "populate presets" + add_field = row_field_def(RowKind.TRACK_PRESET_LIST_ADD) + assert add_field.present_style == RowPresentStyle.FULL_LINE + assert add_field.panel_label == "add current preset" + + +def test_preset_list_add_populate_share_list_item_tree_chrome() -> None: + """FULL_LINE actions nest under the preset list header like list items.""" + from cleave.viz.row_sections import row_tree_indent_depth + + item_depth = row_tree_indent_depth(RowKind.TRACK_PRESET_LIST_ITEM) + branch = tree_branch_prefix(item_depth) + assert branch == " └─ " + assert full_line_prefix(RowKind.TRACK_PRESET_LIST_ADD) == ( + branch + "add current preset" + ) + populate_depth = row_tree_indent_depth(RowKind.TRACK_PRESET_LIST_POPULATE) + populate_branch = tree_branch_prefix(populate_depth) + assert full_line_prefix(RowKind.TRACK_PRESET_LIST_POPULATE) == ( + populate_branch + "populate presets" ) - desc = RowDescriptor(RowKind.TRACK_PRESET_SWITCHING_SEED, slot="layer_1") - assert format_row_value(state, desc) == "42" - assert row_action_parameter_display_text(state, desc) == " └─ seed: 42" + assert labeled_row_prefix(RowKind.TRACK_PRESET_LIST_ITEM).startswith(branch) def test_apply_field_horizontal_expand_subheader_when_layer_locked() -> None: @@ -326,7 +338,7 @@ def test_apply_field_horizontal_track_header_solo_and_expand() -> None: def test_row_fields_count() -> None: - assert len(ROW_FIELDS) == 124 + assert len(ROW_FIELDS) == 122 def test_row_kinds_requiring_fields_registry_complete() -> None: diff --git a/tests/cleave/viz/test_row_layout_resolution.py b/tests/cleave/viz/test_row_layout_resolution.py index bf0ff65..1dff392 100644 --- a/tests/cleave/viz/test_row_layout_resolution.py +++ b/tests/cleave/viz/test_row_layout_resolution.py @@ -275,7 +275,7 @@ def test_section_header_descriptor_mappings() -> None: ) ) == RowDescriptor(RowKind.TRACK_EFFECTS_HEADER, slot="layer_1") assert section_header_descriptor( - RowDescriptor(RowKind.TRACK_PRESET_SWITCHING_ROTATION_SET, slot="layer_1") + RowDescriptor(RowKind.TRACK_PRESET_SWITCHING_TRIGGER, slot="layer_1") ) == RowDescriptor(RowKind.TRACK_PRESET_SWITCHING, slot="layer_1") assert section_header_descriptor( RowDescriptor(RowKind.TRACK_PRESET_DURATION, slot="layer_1") diff --git a/tests/cleave/viz/test_row_sections.py b/tests/cleave/viz/test_row_sections.py index 89d865b..0c15c8c 100644 --- a/tests/cleave/viz/test_row_sections.py +++ b/tests/cleave/viz/test_row_sections.py @@ -36,7 +36,7 @@ def _track_block(**overrides: object) -> TrackBlock: beat_sensitivity=1.0, effects={}, expanded=True, - preset_switching="projectm", + preset_switching="on", hard_cut_enabled=True, ) base.update(overrides) @@ -55,9 +55,9 @@ def test_track_layout_collapsed_layer() -> None: def test_track_layout_collapsed_preset_switching() -> None: - kinds = _track_row_kinds(preset_switching="none") + kinds = _track_row_kinds(preset_switching="off") assert RowKind.TRACK_PRESET_SWITCHING in kinds - assert RowKind.TRACK_PRESET_SWITCHING_ROTATION_SET not in kinds + assert RowKind.TRACK_PRESET_SWITCHING_TRIGGER not in kinds assert RowKind.TRACK_PRESET_DURATION not in kinds @@ -68,68 +68,87 @@ def test_track_layout_collapsed_effects() -> None: def test_track_layout_conditional_rows_when_predicates_pass() -> None: - kinds = _track_row_kinds( - preset_switching="projectm", - hard_cut_enabled=True, + timer_kinds = _track_row_kinds( + preset_switching="on", + preset_switching_trigger="timer", effects_expanded=False, + preset_list_expanded=True, ) - assert RowKind.TRACK_PRESET_DURATION in kinds - assert RowKind.TRACK_PRESET_SWITCHING_SHUFFLE in kinds - assert RowKind.TRACK_PRESET_SWITCHING_SEED not in kinds - assert RowKind.TRACK_HARD_CUT_DURATION in kinds - - shuffle_on_kinds = _track_row_kinds( - preset_switching="projectm", - preset_switching_shuffle=True, + assert RowKind.TRACK_PRESET_DURATION in timer_kinds + assert RowKind.TRACK_PRESET_SWITCHING_TRIGGER in timer_kinds + assert RowKind.TRACK_PRESET_START_CLEAN in timer_kinds + assert RowKind.TRACK_PRESET_LIST in timer_kinds + assert RowKind.TRACK_PRESET_LIST_ADD in timer_kinds + assert RowKind.TRACK_PRESET_LIST_POPULATE in timer_kinds + assert RowKind.TRACK_SOFT_CUT_DURATION not in timer_kinds + assert RowKind.TRACK_HARD_CUT_ENABLED not in timer_kinds + list_depth = row_tree_indent_depth(RowKind.TRACK_PRESET_LIST) + item_depth = list_depth + 1 + assert list_depth == row_tree_indent_depth(RowKind.TRACK_PRESET_SWITCHING_TRIGGER) + assert row_tree_indent_depth(RowKind.TRACK_PRESET_LIST_ADD) == item_depth + assert row_tree_indent_depth(RowKind.TRACK_PRESET_LIST_ITEM) == item_depth + assert row_tree_indent_depth(RowKind.TRACK_PRESET_LIST_POPULATE) == item_depth + + projectm_kinds = _track_row_kinds( + preset_switching="on", + preset_switching_trigger="projectm", hard_cut_enabled=True, effects_expanded=False, ) - assert RowKind.TRACK_PRESET_SWITCHING_SEED in shuffle_on_kinds - assert row_tree_indent_depth(RowKind.TRACK_PRESET_SWITCHING_SEED) == ( - row_tree_indent_depth(RowKind.TRACK_PRESET_SWITCHING_SHUFFLE) + 1 - ) - - user_defined_kinds = _track_row_kinds( - preset_switching="projectm", - preset_switching_rotation_set="user_defined", - hard_cut_enabled=True, + assert RowKind.TRACK_PRESET_DURATION in projectm_kinds + assert RowKind.TRACK_SOFT_CUT_DURATION in projectm_kinds + assert RowKind.TRACK_EASTER_EGG in projectm_kinds + assert RowKind.TRACK_HARD_CUT_ENABLED in projectm_kinds + assert RowKind.TRACK_HARD_CUT_DURATION in projectm_kinds + assert RowKind.TRACK_HARD_CUT_SENSITIVITY in projectm_kinds + assert RowKind.TRACK_PRESET_LIST in projectm_kinds + + timeline_kinds = _track_row_kinds( + preset_switching="on", + preset_switching_trigger="timeline", effects_expanded=False, ) - assert RowKind.TRACK_SOFT_CUT_DURATION in user_defined_kinds - assert RowKind.TRACK_PRESET_SWITCHING_SHUFFLE in user_defined_kinds - assert RowKind.TRACK_HARD_CUT_ENABLED in user_defined_kinds - assert RowKind.TRACK_HARD_CUT_DURATION in user_defined_kinds - assert RowKind.TRACK_PRESET_SWITCHING_ROTATION_SET in user_defined_kinds - assert RowKind.TRACK_USER_PRESETS in user_defined_kinds - assert RowKind.TRACK_CAST_ROLES_TIMELINE_BEHAVIOUR not in user_defined_kinds - - cast_roles_kinds = _track_row_kinds( - preset_switching="timeline", - preset_switching_rotation_set="cast_roles", + assert RowKind.TRACK_PRESET_SWITCHING_TRIGGER in timeline_kinds + assert RowKind.TRACK_PRESET_DURATION not in timeline_kinds + assert RowKind.TRACK_SOFT_CUT_DURATION not in timeline_kinds + assert RowKind.TRACK_EASTER_EGG not in timeline_kinds + assert RowKind.TRACK_HARD_CUT_ENABLED not in timeline_kinds + assert RowKind.TRACK_PRESET_START_CLEAN in timeline_kinds + assert RowKind.TRACK_PRESET_LIST in timeline_kinds + assert RowKind.TRACK_PRESET_LIST_POPULATE not in timeline_kinds + assert RowKind.TRACK_PRESET_LIST_ADD not in timeline_kinds + + timeline_expanded_kinds = _track_row_kinds( + preset_switching="on", + preset_switching_trigger="timeline", + preset_list_expanded=True, effects_expanded=False, ) - assert RowKind.TRACK_CAST_ROLES_TIMELINE_BEHAVIOUR in cast_roles_kinds - assert RowKind.TRACK_CAST_ROLES_DEFAULT_ROLE in cast_roles_kinds - assert RowKind.TRACK_USER_PRESETS not in cast_roles_kinds + assert RowKind.TRACK_PRESET_LIST_POPULATE in timeline_expanded_kinds + assert RowKind.TRACK_PRESET_LIST_ADD in timeline_expanded_kinds def test_track_layout_omits_conditional_rows_when_predicates_fail() -> None: - none_kinds = _track_row_kinds(preset_switching="none") + none_kinds = _track_row_kinds(preset_switching="off") assert RowKind.TRACK_PRESET_DURATION not in none_kinds - assert RowKind.TRACK_PRESET_SWITCHING_SHUFFLE not in none_kinds - assert RowKind.TRACK_PRESET_SWITCHING_SEED not in none_kinds + assert RowKind.TRACK_PRESET_SWITCHING_TRIGGER not in none_kinds + assert RowKind.TRACK_PRESET_LIST_POPULATE not in none_kinds - hard_cut_off = _track_row_kinds(hard_cut_enabled=False) + collapsed_list = _track_row_kinds( + preset_switching="on", + preset_list_expanded=False, + ) + assert RowKind.TRACK_PRESET_LIST in collapsed_list + assert RowKind.TRACK_PRESET_LIST_ADD not in collapsed_list + assert RowKind.TRACK_PRESET_LIST_POPULATE not in collapsed_list + + hard_cut_off = _track_row_kinds( + preset_switching="on", + preset_switching_trigger="projectm", + hard_cut_enabled=False, + ) assert RowKind.TRACK_HARD_CUT_DURATION not in hard_cut_off - - timeline_kinds = _track_row_kinds(preset_switching="timeline") - assert RowKind.TRACK_PRESET_SWITCHING_ROTATION_SET in timeline_kinds - assert RowKind.TRACK_PRESET_SWITCHING_SHUFFLE in timeline_kinds - assert RowKind.TRACK_PRESET_SWITCHING_SEED not in timeline_kinds - assert RowKind.TRACK_PRESET_START_CLEAN in timeline_kinds - assert RowKind.TRACK_PRESET_DURATION not in timeline_kinds - assert RowKind.TRACK_SOFT_CUT_DURATION not in timeline_kinds - assert RowKind.TRACK_HARD_CUT_ENABLED not in timeline_kinds + assert RowKind.TRACK_HARD_CUT_ENABLED in hard_cut_off def test_track_layout_effect_roster_when_expanded() -> None: @@ -139,7 +158,13 @@ def test_track_layout_effect_roster_when_expanded() -> None: def test_track_layout_row_order_when_fully_expanded() -> None: - kinds = _track_row_kinds(effects_expanded=True) + kinds = _track_row_kinds( + effects_expanded=True, + preset_switching_trigger="projectm", + hard_cut_enabled=True, + preset_list_expanded=True, + preset_list=["a.milk", "b.milk"], + ) effects_header = kinds.index(RowKind.TRACK_EFFECTS_HEADER) delete_idx = kinds.index(RowKind.LAYER_MANAGEMENT_DELETE) assert effects_header < delete_idx @@ -150,15 +175,19 @@ def test_track_layout_row_order_when_fully_expanded() -> None: RowKind.TRACK_PRESET_DIR, RowKind.TRACK_PRESET, RowKind.TRACK_PRESET_SWITCHING, - RowKind.TRACK_PRESET_SWITCHING_ROTATION_SET, - RowKind.TRACK_PRESET_SWITCHING_SHUFFLE, - RowKind.TRACK_PRESET_START_CLEAN, + RowKind.TRACK_PRESET_SWITCHING_TRIGGER, RowKind.TRACK_PRESET_DURATION, RowKind.TRACK_EASTER_EGG, RowKind.TRACK_SOFT_CUT_DURATION, RowKind.TRACK_HARD_CUT_ENABLED, RowKind.TRACK_HARD_CUT_DURATION, RowKind.TRACK_HARD_CUT_SENSITIVITY, + RowKind.TRACK_PRESET_START_CLEAN, + RowKind.TRACK_PRESET_LIST, + RowKind.TRACK_PRESET_LIST_ITEM, + RowKind.TRACK_PRESET_LIST_ITEM, + RowKind.TRACK_PRESET_LIST_ADD, + RowKind.TRACK_PRESET_LIST_POPULATE, RowKind.TRACK_BLEND, RowKind.TRACK_OPACITY, RowKind.TRACK_EFFECTS_HEADER, @@ -166,7 +195,6 @@ def test_track_layout_row_order_when_fully_expanded() -> None: assert kinds[delete_idx] == RowKind.LAYER_MANAGEMENT_DELETE assert all(kind == RowKind.TRACK_EFFECT for kind in kinds[effects_header + 1 : delete_idx]) - def test_expand_section_respects_expanded_when_block_disabled() -> None: disabled_overlay = _minimal_view_state( render_overlays=RenderOverlaysBlock( @@ -228,7 +256,7 @@ def test_sub_row_expand_visible_nested_sections() -> None: def test_layout_omits_conditional_rows_when_predicate_fails() -> None: none_mode = _minimal_view_state( - tracks={"layer_1": _track_block(preset_switching="none")}, + tracks={"layer_1": _track_block(preset_switching="off")}, ) duration = RowDescriptor(RowKind.TRACK_PRESET_DURATION, slot="layer_1") assert duration not in none_mode.layout.rows @@ -241,7 +269,15 @@ def test_layout_omits_conditional_rows_when_predicate_fails() -> None: def test_layout_includes_conditional_rows_when_predicates_pass() -> None: - projectm = _minimal_view_state(tracks={"layer_1": _track_block()}) + projectm = _minimal_view_state( + tracks={ + "layer_1": _track_block( + preset_switching="on", + preset_switching_trigger="projectm", + hard_cut_enabled=True, + ) + } + ) duration = RowDescriptor(RowKind.TRACK_PRESET_DURATION, slot="layer_1") hard_cut_min = RowDescriptor(RowKind.TRACK_HARD_CUT_DURATION, slot="layer_1") assert duration in projectm.layout.rows @@ -251,10 +287,10 @@ def test_layout_includes_conditional_rows_when_predicates_pass() -> None: def test_section_header_from_tree_preset_switching_submenu() -> None: - rotation_set = RowDescriptor( - RowKind.TRACK_PRESET_SWITCHING_ROTATION_SET, slot="layer_1" + trigger = RowDescriptor( + RowKind.TRACK_PRESET_SWITCHING_TRIGGER, slot="layer_1" ) - assert section_header_from_section_tree(rotation_set) == RowDescriptor( + assert section_header_from_section_tree(trigger) == RowDescriptor( RowKind.TRACK_PRESET_SWITCHING, slot="layer_1" ) hard_cut = RowDescriptor(RowKind.TRACK_HARD_CUT_SENSITIVITY, slot="layer_1") diff --git a/tests/cleave/viz/test_row_semantics.py b/tests/cleave/viz/test_row_semantics.py index 009d503..bb8793a 100644 --- a/tests/cleave/viz/test_row_semantics.py +++ b/tests/cleave/viz/test_row_semantics.py @@ -34,14 +34,12 @@ def _track_lock_state(locked: bool) -> SimpleNamespace: RowKind.TRACK_PRESET_DIR, RowKind.TRACK_PRESET, RowKind.TRACK_PRESET_SWITCHING, - RowKind.TRACK_CAST_ROLES_TIMELINE_BEHAVIOUR, - RowKind.TRACK_CAST_ROLES_DEFAULT_ROLE, - RowKind.TRACK_PRESET_DURATION, + RowKind.TRACK_PRESET_SWITCHING_TRIGGER, + RowKind.TRACK_PRESET_DURATION, RowKind.TRACK_SOFT_CUT_DURATION, RowKind.TRACK_EASTER_EGG, RowKind.TRACK_PRESET_START_CLEAN, - RowKind.TRACK_PRESET_SWITCHING_SHUFFLE, - RowKind.TRACK_HARD_CUT_ENABLED, + RowKind.TRACK_HARD_CUT_ENABLED, RowKind.TRACK_HARD_CUT_DURATION, RowKind.TRACK_HARD_CUT_SENSITIVITY, RowKind.TRACK_STEM, @@ -101,6 +99,7 @@ def _track_lock_state(locked: bool) -> SimpleNamespace: RowKind.TIMELINE_SOFT_CUT_FADE_OUT, RowKind.TIMELINE_SOFT_CUT_CROSSFADE, RowKind.TIMELINE_VISUAL_LIMITER_THRESHOLD, + RowKind.TIMELINE_VISUAL_LIMITER_RATIO, RowKind.TIMELINE_VISUAL_LIMITER_RELEASE, } ) @@ -191,14 +190,11 @@ def test_track_sub_row_kinds() -> None: RowKind.TRACK_PRESET_DIR, RowKind.TRACK_PRESET, RowKind.TRACK_PRESET_SWITCHING, - RowKind.TRACK_USER_PRESETS, - RowKind.TRACK_USER_PRESET_ITEM, - RowKind.TRACK_USER_PRESET_ADD, - RowKind.TRACK_PRESET_SWITCHING_ROTATION_SET, - RowKind.TRACK_CAST_ROLES_TIMELINE_BEHAVIOUR, - RowKind.TRACK_CAST_ROLES_DEFAULT_ROLE, - RowKind.TRACK_PRESET_SWITCHING_SHUFFLE, - RowKind.TRACK_PRESET_SWITCHING_SEED, + RowKind.TRACK_PRESET_SWITCHING_TRIGGER, + RowKind.TRACK_PRESET_LIST, + RowKind.TRACK_PRESET_LIST_ITEM, + RowKind.TRACK_PRESET_LIST_ADD, + RowKind.TRACK_PRESET_LIST_POPULATE, RowKind.TRACK_PRESET_DURATION, RowKind.TRACK_SOFT_CUT_DURATION, RowKind.TRACK_EASTER_EGG, @@ -227,7 +223,7 @@ def test_locked_navigable_sub_row_kinds() -> None: ) assert navigable == frozenset( { - RowKind.TRACK_USER_PRESETS, + RowKind.TRACK_PRESET_LIST, RowKind.TRACK_EFFECTS_HEADER, RowKind.LAYER_MANAGEMENT_DELETE, } @@ -243,11 +239,7 @@ def test_track_value_rows_blocked_by_section_lock() -> None: RowKind.TRACK_PRESET_DIR, RowKind.TRACK_PRESET, RowKind.TRACK_PRESET_SWITCHING, - RowKind.TRACK_PRESET_SWITCHING_ROTATION_SET, - RowKind.TRACK_CAST_ROLES_TIMELINE_BEHAVIOUR, - RowKind.TRACK_CAST_ROLES_DEFAULT_ROLE, - RowKind.TRACK_PRESET_SWITCHING_SHUFFLE, - RowKind.TRACK_PRESET_SWITCHING_SEED, + RowKind.TRACK_PRESET_SWITCHING_TRIGGER, RowKind.TRACK_PRESET_DURATION, RowKind.TRACK_SOFT_CUT_DURATION, RowKind.TRACK_EASTER_EGG, @@ -260,8 +252,9 @@ def test_track_value_rows_blocked_by_section_lock() -> None: RowKind.TRACK_OPACITY, RowKind.TRACK_BEAT, RowKind.TRACK_EFFECT, - RowKind.TRACK_USER_PRESET_ITEM, - RowKind.TRACK_USER_PRESET_ADD, + RowKind.TRACK_PRESET_LIST_ITEM, + RowKind.TRACK_PRESET_LIST_ADD, + RowKind.TRACK_PRESET_LIST_POPULATE, } ) for kind in blocked: @@ -272,7 +265,7 @@ def test_track_value_rows_blocked_by_section_lock() -> None: def test_only_effects_header_navigable_when_section_locked() -> None: navigable_when_locked = { - RowKind.TRACK_USER_PRESETS, + RowKind.TRACK_PRESET_LIST, RowKind.TRACK_EFFECTS_HEADER, RowKind.LAYER_MANAGEMENT_DELETE, } @@ -292,12 +285,14 @@ def test_labeled_sub_row_kinds_exclude_headers() -> None: assert not behavior.is_header -def test_preset_switching_seed_is_action_row() -> None: - assert RowKind.TRACK_PRESET_SWITCHING_SEED in ACTION_ROW_KINDS - assert RowKind.TRACK_PRESET_SWITCHING_SEED not in LABELED_SUB_ROW_KINDS - assert row_behavior(RowKind.TRACK_PRESET_SWITCHING_SEED).affordance == ( - RowAffordance.ACTION - ) +def test_preset_list_actions_are_action_rows() -> None: + for kind in ( + RowKind.TRACK_PRESET_LIST_POPULATE, + RowKind.TRACK_PRESET_LIST_ADD, + ): + assert kind in ACTION_ROW_KINDS + assert kind not in LABELED_SUB_ROW_KINDS + assert row_behavior(kind).affordance == RowAffordance.ACTION def _render_lock_state( @@ -322,9 +317,9 @@ def test_render_value_children_blocked_by_section_lock() -> None: assert row_blocked_by_section_lock(RowKind.TIMELINE_PRESET_CUE_SNAP) is True assert row_blocked_by_section_lock(RowKind.TIMELINE_PRESET_SONG_MARKER_SNAP) is True assert row_blocked_by_section_lock(RowKind.TIMELINE_PRESET_TIMELINE_CUTS) is True - assert row_blocked_by_section_lock(RowKind.TIMELINE_PRESET_RESHUFFLE) is True + assert row_blocked_by_section_lock(RowKind.TIMELINE_PRESET_REPOPULATE) is True assert row_blocked_by_section_lock(RowKind.TIMELINE_PRESET_CONDUCTOR) is True - assert row_blocked_by_section_lock(RowKind.TRACK_PRESET_SWITCHING_SEED) is True + assert row_blocked_by_section_lock(RowKind.TRACK_PRESET_LIST_POPULATE) is True assert row_blocked_by_section_lock(RowKind.TIMELINE_BAR_PHASE) is True assert row_blocked_by_section_lock(RowKind.TIMELINE_SNAP_TO_BEATS) is True assert row_blocked_by_section_lock(RowKind.TIMELINE_SNAP_TO_BARS) is True diff --git a/tests/cleave/viz/test_timeline_controls.py b/tests/cleave/viz/test_timeline_controls.py index 867a8cd..ed21abc 100644 --- a/tests/cleave/viz/test_timeline_controls.py +++ b/tests/cleave/viz/test_timeline_controls.py @@ -1186,7 +1186,6 @@ def test_o_casts_opening_baseline_materializing_cue_at_zero() -> None: lanes=lanes, position_sec=0.0, ) - session.layers["layer_1"].preset_switching_rotation_set = "cast_roles" controls.handle_keydown(keydown(pygame.K_PERIOD)) assert session.timeline.selected_cue_t["layer_1"] == 0.0 controls.handle_keydown(keydown(pygame.K_o)) @@ -1342,7 +1341,6 @@ def test_o_cycles_selected_cue_role_including_none() -> None: lanes=lanes, position_sec=5.0, ) - session.layers["layer_1"].preset_switching_rotation_set = "cast_roles" controls.handle_keydown(keydown(pygame.K_PERIOD)) assert session.timeline.lanes["layer_1"].cues[0].role is None @@ -1354,29 +1352,6 @@ def test_o_cycles_selected_cue_role_including_none() -> None: assert session.timeline.lanes["layer_1"].cues[0].role is None -def test_o_noop_when_rotation_set_is_not_cast_roles() -> None: - lanes = { - "layer_1": TimelineLane( - baseline=0.0, - cues=canonicalize( - 0.0, - [SlotCue(t=5.0, level=1.0)], - ), - ), - } - controls, session, _, _, _, notifications = _make_timeline_controls( - lanes=lanes, - position_sec=5.0, - ) - assert session.layers["layer_1"].preset_switching_rotation_set == "directory" - controls.handle_keydown(keydown(pygame.K_PERIOD)) - controls.handle_keydown(keydown(pygame.K_o)) - assert session.timeline.lanes["layer_1"].cues[0].role is None - assert notifications == [ - "Set rotation set to cast roles to assign cast" - ] - - def test_b_and_o_noop_on_off_cue_c_cycles_cut() -> None: from cleave.blend_modes import BLEND_MODES from cleave.cue_roles import CUE_ROLES @@ -1389,7 +1364,6 @@ def test_b_and_o_noop_on_off_cue_c_cycles_cut() -> None: lanes=lanes, position_sec=10.0, ) - session.layers["layer_1"].preset_switching_rotation_set = "cast_roles" session.timeline.selected_cue_t["layer_1"] = 10.0 off_before = session.timeline.lanes["layer_1"].cues[1] assert off_before.level == 0.0 diff --git a/tests/cleave/viz/test_timeline_overlay.py b/tests/cleave/viz/test_timeline_overlay.py index a05d784..8c7076d 100644 --- a/tests/cleave/viz/test_timeline_overlay.py +++ b/tests/cleave/viz/test_timeline_overlay.py @@ -131,7 +131,6 @@ def _view_state( soft_cut_crossfade: bool = False, selected_cue_t: dict[str, float] | None = None, selected_cue_flash_start_ms: int | None = None, - slot_rotation_sets: dict[str, str] | None = None, ) -> TimelineViewState: order = list(layer_z_order or list(DEFAULT_LAYER_SLOTS)) lane_map = dict(lanes or {}) @@ -201,11 +200,6 @@ def _view_state( ), selected_cue_t=dict(selected_cue_t or ()), selected_cue_flash_start_ms=selected_cue_flash_start_ms, - slot_rotation_sets=dict( - slot_rotation_sets - if slot_rotation_sets is not None - else {slot: "directory" for slot in order} - ), ) @@ -982,7 +976,6 @@ def test_draw_with_selected_cue_and_role_does_not_crash() -> None: focus_row=0, submenu_focused=True, selected_cue_t={"layer_1": 10.0}, - slot_rotation_sets={"layer_1": "cast_roles"}, ) surface = pygame.Surface((1280, 720), pygame.SRCALPHA) _draw(overlay, surface, state) @@ -1006,7 +999,6 @@ def test_role_glyph_not_drawn_on_off_cue() -> None: duration_sec=100.0, focus_row=0, position_sec=0.0, - slot_rotation_sets={"layer_1": "cast_roles"}, ) composed = overlay.compose_panel( state, @@ -1324,7 +1316,6 @@ def test_role_glyph_xor_drawn_late_on_upload_not_static() -> None: duration_sec=100.0, focus_row=0, position_sec=0.0, - slot_rotation_sets={"layer_1": "cast_roles"}, ) composed = overlay.compose_panel( state, diff --git a/tests/cleave/viz/test_tuning_panel_cache.py b/tests/cleave/viz/test_tuning_panel_cache.py index d4cac34..f1d1268 100644 --- a/tests/cleave/viz/test_tuning_panel_cache.py +++ b/tests/cleave/viz/test_tuning_panel_cache.py @@ -52,7 +52,7 @@ def max_w(index: int) -> int: state, font=font, cache=cache, - visible_indices=visible, + indices=visible, max_content_width_for_index=max_w, line_h=font.get_linesize(), ) @@ -86,6 +86,39 @@ def test_row_render_key_stable_across_fps_and_position() -> None: assert key_a == key_b +def test_static_row_keys_only_includes_requested_indices() -> None: + pygame.init() + overlay = TuningOverlay() + font = overlay._font_get() + cache = TuningPanelCache() + state = _minimal_view_state() + visible = tuple(state.layout.visible_indices(state)) + assert len(visible) >= 2 + subset = visible[:2] + + def max_w(_index: int) -> int: + return 400 + + keys_all = static_row_keys( + state, + font=font, + cache=cache, + indices=visible, + max_content_width_for_index=max_w, + line_h=font.get_linesize(), + ) + keys_subset = static_row_keys( + state, + font=font, + cache=cache, + indices=subset, + max_content_width_for_index=max_w, + line_h=font.get_linesize(), + ) + assert len(keys_subset) < len(keys_all) + assert keys_subset == keys_all[: len(keys_subset)] + + def test_focus_change_changes_static_row_keys_for_affected_rows() -> None: pygame.init() overlay = TuningOverlay() @@ -115,6 +148,99 @@ def test_focus_change_changes_static_row_keys_for_affected_rows() -> None: assert track_key_when_track_focused != track_key_when_transport_focused +def test_active_preset_list_index_changes_row_render_color_state() -> None: + """Active-list highlight is carried by color_state so only affected rows miss.""" + pygame.init() + overlay = TuningOverlay() + font = overlay._font_get() + cache = TuningPanelCache() + tracks = { + "layer_1": TrackBlock( + stem="drums", + preset_dir_label="dir", + preset_label="preset.milk", + blend_mode="black-key", + opacity_pct=50, + beat_sensitivity=1.0, + effects={}, + expanded=True, + preset_switching="on", + preset_list=["/tmp/a.milk", "/tmp/b.milk"], + preset_list_labels=["a.milk", "b.milk"], + preset_list_expanded=True, + active_preset_list_index=0, + ) + } + state = _minimal_view_state( + layer_z_order=("layer_1",), + tracks=tracks, + focus_cursor=MainFocus(RowDescriptor(RowKind.TRANSPORT)), + ) + active_index = state.layout.find_descriptor( + RowDescriptor( + RowKind.TRACK_PRESET_LIST_ITEM, + slot="layer_1", + preset_index=0, + ) + ) + idle_index = state.layout.find_descriptor( + RowDescriptor( + RowKind.TRACK_PRESET_LIST_ITEM, + slot="layer_1", + preset_index=1, + ) + ) + line_h = font.get_linesize() + max_w = 400 + key_active_before = row_render_key( + state, + active_index, + font, + cache=cache, + max_content_width=max_w, + line_h=line_h, + ) + key_idle_before = row_render_key( + state, + idle_index, + font, + cache=cache, + max_content_width=max_w, + line_h=line_h, + ) + switched = replace( + state, + tracks={ + "layer_1": replace( + state.tracks["layer_1"], + active_preset_list_index=1, + ) + }, + ) + key_active_after = row_render_key( + switched, + active_index, + font, + cache=cache, + max_content_width=max_w, + line_h=line_h, + ) + key_idle_after = row_render_key( + switched, + idle_index, + font, + cache=cache, + max_content_width=max_w, + line_h=line_h, + ) + assert key_active_before != key_active_after + assert key_idle_before != key_idle_after + assert key_active_before.color_state != key_active_after.color_state + assert key_idle_before.color_state != key_idle_after.color_state + assert key_active_before.display_text == key_active_after.display_text + assert key_idle_before.display_text == key_idle_after.display_text + + def test_solo_change_invalidates_track_header_row_key() -> None: pygame.init() overlay = TuningOverlay() @@ -458,11 +584,39 @@ def test_row_cache_hits_on_full_recompose_with_warm_cache() -> None: assert counters.row_cache_misses > 0 -def test_expand_collapse_invalidates_row_cache() -> None: +def test_retain_row_surfaces_keeps_only_requested_keys() -> None: pygame.init() overlay = TuningOverlay() - overlay.notify_input() font = overlay._font_get() + cache = TuningPanelCache() + state = _minimal_view_state() + line_h = font.get_linesize() + indices = [ + i + for i in state.layout.visible_indices(state) + if state.layout.kind(i) != RowKind.TRANSPORT + ][:2] + assert len(indices) == 2 + for index in indices: + ensure_row_surface( + cache, + state, + index, + font, + overlay._build_row_at_index, + max_content_width=300, + line_h=line_h, + ) + assert len(cache.row_surfaces) == 2 + keep = {next(iter(cache.row_surfaces))} + cache.retain_row_surfaces(keep) + assert set(cache.row_surfaces.keys()) == keep + + +def test_expand_collapse_incrementally_evicts_row_cache() -> None: + pygame.init() + overlay = TuningOverlay() + overlay.notify_input() cache = overlay._panel_cache collapsed = _minimal_view_state( @@ -499,15 +653,19 @@ def test_expand_collapse_invalidates_row_cache() -> None: viewport_width=1280, viewport_height=720, ) - assert len(cache.row_surfaces) > 0 + keys_collapsed = set(cache.row_surfaces.keys()) + assert keys_collapsed overlay.compose_panel( expanded, viewport_width=1280, viewport_height=720, ) + keys_expanded = set(cache.row_surfaces.keys()) assert cache.row_cache_structure == tuple(expanded.layout.visible_indices(expanded)) - assert len(cache.row_surfaces) > 0 + assert keys_expanded + # Shared viewport rows keep content-keyed surfaces across structure change. + assert keys_collapsed & keys_expanded def test_ensure_row_surface_cache_hit_miss_counters() -> None: diff --git a/tests/cleave/viz/test_user_presets.py b/tests/cleave/viz/test_user_presets.py index 00c80b3..c4c7c61 100644 --- a/tests/cleave/viz/test_user_presets.py +++ b/tests/cleave/viz/test_user_presets.py @@ -8,8 +8,11 @@ from cleave.viz.user_presets import ( cleanup_unreferenced_user_presets, iter_project_viz_config_paths, + path_list_digest, + path_list_identity, + preset_list_display_names, + preset_list_item_display_name, resolve_user_preset_dest, - user_preset_item_display_name, user_preset_referenced_on_disk, ) @@ -22,26 +25,78 @@ def _write_viz_yaml(path: Path, preset_relpaths: list[str]) -> None: path.write_text( "layers:\n" " layer_1:\n" - " preset_switching_presets:\n" + " preset_switching_list:\n" f"{presets}\n", encoding="utf-8", ) -def test_user_preset_item_display_name_single() -> None: +def test_preset_list_item_display_name_single() -> None: paths = ["/tmp/user-presets/foo.milk"] - assert user_preset_item_display_name(paths, 0) == "foo.milk" + assert preset_list_item_display_name(paths, 0) == "foo.milk" -def test_user_preset_item_display_name_duplicates() -> None: +def test_preset_list_item_display_name_duplicates() -> None: paths = [ "/tmp/user-presets/foo.milk", "/tmp/user-presets/foo.milk", "/tmp/user-presets/bar.milk", ] - assert user_preset_item_display_name(paths, 0) == "foo.milk (1)" - assert user_preset_item_display_name(paths, 1) == "foo.milk (2)" - assert user_preset_item_display_name(paths, 2) == "bar.milk" + assert preset_list_item_display_name(paths, 0) == "foo.milk (1)" + assert preset_list_item_display_name(paths, 1) == "foo.milk (2)" + assert preset_list_item_display_name(paths, 2) == "bar.milk" + + +def test_preset_list_display_names_single() -> None: + paths = ["/tmp/user-presets/foo.milk"] + assert preset_list_display_names(paths) == ["foo.milk"] + + +def test_preset_list_display_names_duplicates() -> None: + paths = [ + "/tmp/user-presets/foo.milk", + "/tmp/user-presets/foo.milk", + "/tmp/user-presets/bar.milk", + ] + assert preset_list_display_names(paths) == [ + "foo.milk (1)", + "foo.milk (2)", + "bar.milk", + ] + + +def test_preset_list_display_names_empty() -> None: + assert preset_list_display_names([]) == [] + + +def test_path_list_digest_empty() -> None: + assert path_list_digest([]) == "" + + +def test_path_list_digest_changes_on_content() -> None: + a = path_list_digest(["/a/foo.milk"]) + b = path_list_digest(["/a/bar.milk"]) + assert a != b + + +def test_path_list_digest_changes_on_order() -> None: + first = path_list_digest(["/a/foo.milk", "/a/bar.milk"]) + second = path_list_digest(["/a/bar.milk", "/a/foo.milk"]) + assert first != second + + +def test_path_list_digest_stable_for_same_paths() -> None: + paths = ["/a/foo.milk", "/a/bar.milk"] + assert path_list_digest(paths) == path_list_digest(paths) + + +def test_path_list_identity() -> None: + paths = ["/a/foo.milk", "/a/bar.milk"] + identity = path_list_identity(paths) + assert identity == { + "len": 2, + "digest": path_list_digest(paths), + } def test_resolve_user_preset_dest_creates_canonical_path(tmp_path: Path) -> None: @@ -140,7 +195,7 @@ def test_cleanup_ignores_project_yaml_and_non_viz_yaml(tmp_path: Path) -> None: "ingest:\n original: /tmp/a.wav\n separated_at: t\n demucs_model: htdemucs\n" "layers:\n" " layer_1:\n" - " preset_switching_presets:\n" + " preset_switching_list:\n" " - user-presets/orphan.milk\n", encoding="utf-8", ) diff --git a/tests/cleave/viz/test_view_state_structure.py b/tests/cleave/viz/test_view_state_structure.py index cf278c6..1d6340e 100644 --- a/tests/cleave/viz/test_view_state_structure.py +++ b/tests/cleave/viz/test_view_state_structure.py @@ -152,7 +152,7 @@ def test_builder_rebuilds_layout_when_timeline_panel_open_changes() -> None: preset_cue_snap = RowDescriptor(RowKind.TIMELINE_PRESET_CUE_SNAP) preset_song_marker_snap = RowDescriptor(RowKind.TIMELINE_PRESET_SONG_MARKER_SNAP) preset_timeline_cuts = RowDescriptor(RowKind.TIMELINE_PRESET_TIMELINE_CUTS) - preset_reshuffle = RowDescriptor(RowKind.TIMELINE_PRESET_RESHUFFLE) + preset_repopulate = RowDescriptor(RowKind.TIMELINE_PRESET_REPOPULATE) preset_conductor = RowDescriptor(RowKind.TIMELINE_PRESET_CONDUCTOR) presets_apply = RowDescriptor(RowKind.TIMELINE_PRESETS) reset = RowDescriptor(RowKind.TIMELINE_RESET) @@ -177,6 +177,7 @@ def test_builder_rebuilds_layout_when_timeline_panel_open_changes() -> None: apply_hard_cuts = RowDescriptor(RowKind.TIMELINE_APPLY_HARD_CUTS) limiter_header = RowDescriptor(RowKind.TIMELINE_VISUAL_LIMITER_HEADER) limiter_threshold = RowDescriptor(RowKind.TIMELINE_VISUAL_LIMITER_THRESHOLD) + limiter_ratio = RowDescriptor(RowKind.TIMELINE_VISUAL_LIMITER_RATIO) limiter_release = RowDescriptor(RowKind.TIMELINE_VISUAL_LIMITER_RELEASE) markers_header = RowDescriptor(RowKind.SONG_MARKERS_HEADER) assert presets_header not in view_closed.layout.rows @@ -204,7 +205,7 @@ def test_builder_rebuilds_layout_when_timeline_panel_open_changes() -> None: assert preset_cue_snap not in view_open.layout.rows assert preset_song_marker_snap not in view_open.layout.rows assert preset_timeline_cuts not in view_open.layout.rows - assert preset_reshuffle not in view_open.layout.rows + assert preset_repopulate not in view_open.layout.rows assert preset_conductor not in view_open.layout.rows assert presets_apply not in view_open.layout.rows assert reset in view_open.layout.rows @@ -220,6 +221,7 @@ def test_builder_rebuilds_layout_when_timeline_panel_open_changes() -> None: assert hard_cut_fades not in view_open.layout.rows assert limiter_header in view_open.layout.rows assert limiter_threshold in view_open.layout.rows + assert limiter_ratio in view_open.layout.rows assert limiter_release in view_open.layout.rows assert markers_header in view_open.layout.rows markers_idx = view_open.layout.rows.index(markers_header) @@ -235,8 +237,9 @@ def test_builder_rebuilds_layout_when_timeline_panel_open_changes() -> None: assert presets_header_idx == cuts_idx + 1 assert limiter_header_idx == presets_header_idx + 1 assert view_open.layout.rows.index(limiter_threshold) == limiter_header_idx + 1 - assert view_open.layout.rows.index(limiter_release) == limiter_header_idx + 2 - assert reset_idx == limiter_header_idx + 3 + assert view_open.layout.rows.index(limiter_ratio) == limiter_header_idx + 2 + assert view_open.layout.rows.index(limiter_release) == limiter_header_idx + 3 + assert reset_idx == limiter_header_idx + 4 session.song_markers.expanded = True view_markers_expanded = builder.build(paused=False) @@ -267,8 +270,9 @@ def test_builder_rebuilds_layout_when_timeline_panel_open_changes() -> None: assert view_beat_expanded.layout.rows.index(presets_header) == cuts_idx + 1 assert view_beat_expanded.layout.rows.index(limiter_header) == cuts_idx + 2 assert view_beat_expanded.layout.rows.index(limiter_threshold) == cuts_idx + 3 - assert view_beat_expanded.layout.rows.index(limiter_release) == cuts_idx + 4 - assert view_beat_expanded.layout.rows.index(reset) == cuts_idx + 5 + assert view_beat_expanded.layout.rows.index(limiter_ratio) == cuts_idx + 4 + assert view_beat_expanded.layout.rows.index(limiter_release) == cuts_idx + 5 + assert view_beat_expanded.layout.rows.index(reset) == cuts_idx + 6 session.timeline.snap_cues_expanded = True view_snap_expanded = builder.build(paused=False) @@ -334,7 +338,7 @@ def test_builder_rebuilds_layout_when_timeline_panel_open_changes() -> None: assert view_presets_expanded.layout.rows.index(preset_timeline_cuts) == ( presets_header_idx + 6 ) - assert view_presets_expanded.layout.rows.index(preset_reshuffle) == ( + assert view_presets_expanded.layout.rows.index(preset_repopulate) == ( presets_header_idx + 7 ) assert view_presets_expanded.layout.rows.index(preset_conductor) == ( @@ -349,10 +353,13 @@ def test_builder_rebuilds_layout_when_timeline_panel_open_changes() -> None: assert view_presets_expanded.layout.rows.index(limiter_threshold) == ( presets_header_idx + 11 ) - assert view_presets_expanded.layout.rows.index(limiter_release) == ( + assert view_presets_expanded.layout.rows.index(limiter_ratio) == ( presets_header_idx + 12 ) - assert view_presets_expanded.layout.rows.index(reset) == presets_header_idx + 13 + assert view_presets_expanded.layout.rows.index(limiter_release) == ( + presets_header_idx + 13 + ) + assert view_presets_expanded.layout.rows.index(reset) == presets_header_idx + 14 session.timeline.timeline_presets_expanded = False view_presets_collapsed = builder.build(paused=False) @@ -363,7 +370,7 @@ def test_builder_rebuilds_layout_when_timeline_panel_open_changes() -> None: assert preset_cue_snap not in view_presets_collapsed.layout.rows assert preset_song_marker_snap not in view_presets_collapsed.layout.rows assert preset_timeline_cuts not in view_presets_collapsed.layout.rows - assert preset_reshuffle not in view_presets_collapsed.layout.rows + assert preset_repopulate not in view_presets_collapsed.layout.rows assert preset_conductor not in view_presets_collapsed.layout.rows assert presets_apply not in view_presets_collapsed.layout.rows assert presets_header in view_presets_collapsed.layout.rows @@ -495,39 +502,31 @@ def test_builder_rebuilds_layout_when_highlight_rolloff_mode_changes() -> None: assert threshold not in view_off.layout.rows -def test_structure_signature_invalidates_on_preset_switching_shuffle() -> None: + +def test_structure_signature_invalidates_on_preset_switching_trigger() -> None: controls = _make_controls(("layer_1",)) session = controls.session config_save = controls._config_save - session.layers["layer_1"].preset_switching = "projectm" - sig_before = view_state_structure_signature( - session, config_save, notification_active=False - ) - session.layers["layer_1"].preset_switching_shuffle = True - sig_after = view_state_structure_signature( + session.layers["layer_1"].preset_switching = "on" + session.layers["layer_1"].preset_switching_trigger = "timer" + sig_timer = view_state_structure_signature( session, config_save, notification_active=False ) - assert sig_before != sig_after - - -def test_structure_signature_invalidates_on_preset_switching_timeline_mode() -> None: - controls = _make_controls(("layer_1",)) - session = controls.session - config_save = controls._config_save - session.layers["layer_1"].preset_switching = "projectm" + session.layers["layer_1"].preset_switching_trigger = "projectm" sig_projectm = view_state_structure_signature( session, config_save, notification_active=False ) - session.layers["layer_1"].preset_switching = "timeline" + assert sig_timer != sig_projectm + session.layers["layer_1"].preset_switching_trigger = "timeline" sig_timeline = view_state_structure_signature( session, config_save, notification_active=False ) assert sig_projectm != sig_timeline - session.layers["layer_1"].preset_switching = "none" - sig_none = view_state_structure_signature( + session.layers["layer_1"].preset_switching = "off" + sig_off = view_state_structure_signature( session, config_save, notification_active=False ) - assert sig_timeline != sig_none + assert sig_timeline != sig_off def test_structure_signature_invalidates_on_auto_preset_path() -> None: @@ -545,12 +544,11 @@ def test_structure_signature_invalidates_on_auto_preset_path() -> None: assert sig_before != sig_after -def test_builder_shows_auto_preset_in_dir_and_file_rows() -> None: +def test_builder_shows_auto_preset_in_file_row_only() -> None: controls = _make_controls(("layer_1",)) session = controls.session layer = session.layers["layer_1"] - layer.preset_switching = "timeline" - layer.preset_switching_rotation_set = "cast_roles" + layer.preset_switching = "on" root = controls._view_state.preset_root bed = root / "roles" / "bed" pulse = root / "roles" / "pulse" @@ -563,68 +561,43 @@ def test_builder_shows_auto_preset_in_dir_and_file_rows() -> None: path.write_text("milk", encoding="utf-8") view_browse = controls.build_view_state(paused=False) - assert "roles/" not in view_browse.tracks["layer_1"].preset_dir_label + browse_dir_label = view_browse.tracks["layer_1"].preset_dir_label + assert "roles/" not in browse_dir_label layer.auto_preset_path = bed_b.resolve() view_bed = controls.build_view_state(paused=False) - assert "roles/bed/" in view_bed.tracks["layer_1"].preset_dir_label + assert view_bed.tracks["layer_1"].preset_dir_label == browse_dir_label assert view_bed.tracks["layer_1"].preset_label.startswith("bed-b.milk (2/2)") layer.auto_preset_path = pulse_a.resolve() view_pulse = controls.build_view_state(paused=False) - assert "roles/pulse/" in view_pulse.tracks["layer_1"].preset_dir_label + assert view_pulse.tracks["layer_1"].preset_dir_label == browse_dir_label assert view_pulse.tracks["layer_1"].preset_label.startswith("pulse-a.milk (1/1)") # Browse playlist unchanged (config stays clean). assert layer.playlist.index == 0 -def test_structure_signature_invalidates_on_cast_roles_rotation_set() -> None: +def test_builder_shows_off_root_auto_preset_without_crashing( + tmp_path: Path, +) -> None: + """Playing a project user-presets copy must not break directory display.""" controls = _make_controls(("layer_1",)) session = controls.session - config_save = controls._config_save - session.layers["layer_1"].preset_switching = "timeline" - session.layers["layer_1"].preset_switching_rotation_set = "directory" - sig_before = view_state_structure_signature( - session, config_save, notification_active=False - ) - session.layers["layer_1"].preset_switching_rotation_set = "cast_roles" - sig_after = view_state_structure_signature( - session, config_save, notification_active=False - ) - assert sig_before != sig_after - - builder = controls._view_state - view_cast = builder.build(paused=False) - behaviour = RowDescriptor( - RowKind.TRACK_CAST_ROLES_TIMELINE_BEHAVIOUR, slot="layer_1" - ) - assert behaviour in view_cast.layout.rows - - session.layers["layer_1"].preset_switching_rotation_set = "directory" - view_dir = builder.build(paused=False) - assert view_dir.layout is not view_cast.layout - assert behaviour not in view_dir.layout.rows + layer = session.layers["layer_1"] + layer.preset_switching = "on" + browse_dir_label = controls.build_view_state(paused=False).tracks[ + "layer_1" + ].preset_dir_label + user_dir = tmp_path / "user-presets" + user_dir.mkdir() + playing = user_dir / "copied.milk" + playing.write_text("milk", encoding="utf-8") + layer.auto_preset_path = playing.resolve() -def test_structure_signature_invalidates_on_cast_roles_fields() -> None: - controls = _make_controls(("layer_1",)) - session = controls.session - config_save = controls._config_save - session.layers["layer_1"].preset_switching = "timeline" - session.layers["layer_1"].preset_switching_rotation_set = "cast_roles" - sig_before = view_state_structure_signature( - session, config_save, notification_active=False - ) - session.layers["layer_1"].cast_roles_timeline_behaviour = "hold_current" - sig_behaviour = view_state_structure_signature( - session, config_save, notification_active=False - ) - assert sig_before != sig_behaviour - session.layers["layer_1"].cast_roles_default_role = "pulse" - sig_role = view_state_structure_signature( - session, config_save, notification_active=False - ) - assert sig_behaviour != sig_role + view = controls.build_view_state(paused=False) + assert view.tracks["layer_1"].preset_dir_label == browse_dir_label + assert view.tracks["layer_1"].preset_label.startswith("copied.milk") def test_structure_signature_invalidates_on_persistent_notification() -> None: @@ -650,37 +623,13 @@ def test_structure_signature_invalidates_on_persistent_notification() -> None: assert sig_persistent != sig_both -def test_timeline_preset_switching_hides_projectm_only_rows() -> None: - controls = _make_controls(("layer_1",)) - session = controls.session - session.layers["layer_1"].expanded = True - session.layers["layer_1"].preset_switching = "timeline" - builder = controls._view_state - view = builder.build(paused=False) - slot = "layer_1" - assert RowDescriptor(RowKind.TRACK_PRESET_SWITCHING_ROTATION_SET, slot=slot) in ( - view.layout.rows - ) - assert RowDescriptor(RowKind.TRACK_PRESET_SWITCHING_SHUFFLE, slot=slot) in ( - view.layout.rows - ) - assert RowDescriptor(RowKind.TRACK_PRESET_START_CLEAN, slot=slot) in view.layout.rows - assert RowDescriptor(RowKind.TRACK_PRESET_DURATION, slot=slot) not in view.layout.rows - assert RowDescriptor(RowKind.TRACK_EASTER_EGG, slot=slot) not in view.layout.rows - assert RowDescriptor(RowKind.TRACK_SOFT_CUT_DURATION, slot=slot) not in ( - view.layout.rows - ) - assert RowDescriptor(RowKind.TRACK_HARD_CUT_ENABLED, slot=slot) not in ( - view.layout.rows - ) - def test_timeline_mode_row_set_unchanged_when_timeline_enabled_toggles() -> None: """Dimming when timeline is disabled does not add or remove preset-switching rows.""" controls = _make_controls(("layer_1",)) session = controls.session session.layers["layer_1"].expanded = True - session.layers["layer_1"].preset_switching = "timeline" + session.layers["layer_1"].preset_switching = "on" session.timeline.enabled = True builder = controls._view_state kinds_on = [desc.kind for desc in builder.build(paused=False).layout.rows] @@ -689,29 +638,6 @@ def test_timeline_mode_row_set_unchanged_when_timeline_enabled_toggles() -> None assert kinds_on == kinds_off -def test_builder_updates_shuffle_display_when_shuffle_changes() -> None: - controls = _make_controls(("layer_1",)) - session = controls.session - session.layers["layer_1"].preset_switching = "projectm" - session.layers["layer_1"].expanded = True - builder = controls._view_state - - view_off = builder.build(paused=False) - shuffle_row = RowDescriptor(RowKind.TRACK_PRESET_SWITCHING_SHUFFLE, slot="layer_1") - seed_row = RowDescriptor(RowKind.TRACK_PRESET_SWITCHING_SEED, slot="layer_1") - assert shuffle_row in view_off.layout.rows - assert seed_row not in view_off.layout.rows - assert view_off.tracks["layer_1"].preset_switching_shuffle is False - - session.layers["layer_1"].preset_switching_shuffle = True - view_on = builder.build(paused=False) - assert shuffle_row in view_on.layout.rows - assert seed_row in view_on.layout.rows - assert view_on.tracks["layer_1"].preset_switching_shuffle is True - shuffle_idx = view_on.layout.rows.index(shuffle_row) - seed_idx = view_on.layout.rows.index(seed_row) - assert seed_idx == shuffle_idx + 1 - def test_minimal_view_state_still_builds_layout() -> None: state = _minimal_view_state() @@ -910,7 +836,7 @@ def test_builder_appends_curation_markers_without_structure_change() -> None: user_path = Path("/tmp/projects/my-track/user.milk") user_path.parent.mkdir(parents=True, exist_ok=True) user_path.write_text("milk", encoding="utf-8") - layer.user_presets = [str(user_path)] + layer.preset_list = [str(user_path)] sig_before = view_state_structure_signature( session, config_save, notification_active=False @@ -918,7 +844,7 @@ def test_builder_appends_curation_markers_without_structure_change() -> None: view_before = controls.build_view_state(paused=False) block_before = view_before.tracks["layer_1"] assert block_before.preset_label == "preset-0.milk (1/3)" - assert block_before.user_preset_labels == ["user.milk"] + assert block_before.preset_list_labels == ["user.milk"] current_name = layer.playlist.current.name assert current_name is not None @@ -935,7 +861,7 @@ def test_builder_appends_curation_markers_without_structure_change() -> None: block_after = view_after.tracks["layer_1"] assert block_after.preset_label == "preset-0.milk (1/3) [F]" # User-preset rows never append U; F/B only in single-bracket form. - assert block_after.user_preset_labels == ["user.milk [FB]"] + assert block_after.preset_list_labels == ["user.milk [FB]"] def test_builder_appends_user_defined_marker_on_track_preset() -> None: @@ -954,14 +880,14 @@ def test_builder_appends_user_defined_marker_on_track_preset() -> None: other_path = Path("/tmp/projects/my-track") / current_name other_path.parent.mkdir(parents=True, exist_ok=True) other_path.write_text("milk", encoding="utf-8") - layer_2.user_presets = [str(other_path)] + layer_2.preset_list = [str(other_path)] sig_before = view_state_structure_signature( session, config_save, notification_active=False ) view_u = controls.build_view_state(paused=False) assert view_u.tracks["layer_1"].preset_label == f"{current_name} (1/3) [U]" - assert view_u.tracks["layer_2"].user_preset_labels == [current_name] + assert view_u.tracks["layer_2"].preset_list_labels == [current_name] index.mark_favourite(current_name) sig_after = view_state_structure_signature( @@ -971,4 +897,124 @@ def test_builder_appends_user_defined_marker_on_track_preset() -> None: view_fu = controls.build_view_state(paused=False) assert view_fu.tracks["layer_1"].preset_label == f"{current_name} (1/3) [FU]" - assert view_fu.tracks["layer_2"].user_preset_labels == [f"{current_name} [F]"] + assert view_fu.tracks["layer_2"].preset_list_labels == [f"{current_name} [F]"] + + +def test_builder_caches_preset_list_base_labels(monkeypatch) -> None: + controls = _make_controls(("layer_1",)) + session = controls.session + layer = session.layers["layer_1"] + user_path = Path("/tmp/projects/my-track/user.milk") + user_path.parent.mkdir(parents=True, exist_ok=True) + user_path.write_text("milk", encoding="utf-8") + layer.preset_list = [str(user_path)] + + calls: list[int] = [] + original = __import__( + "cleave.viz.user_presets", fromlist=["preset_list_display_names"] + ).preset_list_display_names + + def counting_display_names(paths: list[str]) -> list[str]: + calls.append(1) + return original(paths) + + monkeypatch.setattr( + "cleave.viz.tuning_view_state.preset_list_display_names", + counting_display_names, + ) + + view_first = controls.build_view_state(paused=False) + view_second = controls.build_view_state(paused=False) + assert view_first.tracks["layer_1"].preset_list_labels == ["user.milk"] + assert view_second.tracks["layer_1"].preset_list_labels == ["user.milk"] + assert calls == [1] + + index = controls._view_state._curation_index + index.mark_favourite(user_path.name) + view_marked = controls.build_view_state(paused=False) + assert view_marked.tracks["layer_1"].preset_list_labels == ["user.milk [F]"] + assert calls == [1] + + +def test_structure_signature_invalidates_on_preset_list_change() -> None: + controls = _make_controls(("layer_1",)) + session = controls.session + config_save = controls._config_save + layer = session.layers["layer_1"] + sig_before = view_state_structure_signature( + session, config_save, notification_active=False + ) + layer.preset_list = ["/tmp/a.milk"] + sig_after = view_state_structure_signature( + session, config_save, notification_active=False + ) + assert sig_before != sig_after + + +def test_structure_signature_uses_digest_not_full_paths() -> None: + controls = _make_controls(("layer_1",)) + session = controls.session + layer = session.layers["layer_1"] + long_path = "/tmp/projects/my-track/very-long-directory-name/preset.milk" + layer.preset_list = [long_path] + sig = view_state_structure_signature( + session, controls._config_save, notification_active=False + ) + assert long_path not in sig + assert '"digest"' in sig + assert '"len":1' in sig.replace(" ", "") + + +def test_structure_signature_stable_for_long_preset_list() -> None: + controls = _make_controls(("layer_1",)) + session = controls.session + config_save = controls._config_save + layer = session.layers["layer_1"] + layer.preset_list = [f"/tmp/preset-{i}.milk" for i in range(40)] + sig_a = view_state_structure_signature( + session, config_save, notification_active=False + ) + sig_b = view_state_structure_signature( + session, config_save, notification_active=False + ) + assert sig_a == sig_b + + +def test_builder_caches_labels_across_idle_builds(monkeypatch) -> None: + controls = _make_controls(("layer_1",)) + session = controls.session + layer = session.layers["layer_1"] + layer.preset_list = [f"/tmp/preset-{i}.milk" for i in range(40)] + layer.preset_list_expanded = False + + calls: list[int] = [] + original = __import__( + "cleave.viz.user_presets", fromlist=["preset_list_display_names"] + ).preset_list_display_names + + def counting_display_names(paths: list[str]) -> list[str]: + calls.append(1) + return original(paths) + + monkeypatch.setattr( + "cleave.viz.tuning_view_state.preset_list_display_names", + counting_display_names, + ) + + controls.build_view_state(paused=False) + controls.build_view_state(paused=False) + assert calls == [1] + + +def test_collapsed_preset_list_omits_item_rows() -> None: + controls = _make_controls(("layer_1",)) + session = controls.session + layer = session.layers["layer_1"] + layer.preset_switching = "on" + layer.preset_list = [f"/tmp/preset-{i}.milk" for i in range(5)] + layer.preset_list_expanded = False + view = controls.build_view_state(paused=False) + kinds = {desc.kind for desc in view.layout.rows} + assert RowKind.TRACK_PRESET_LIST in kinds + assert RowKind.TRACK_PRESET_LIST_ITEM not in kinds + assert RowKind.TRACK_PRESET_LIST_ADD not in kinds diff --git a/tests/cleave/viz/test_visual_limiter.py b/tests/cleave/viz/test_visual_limiter.py index a85aa15..9b3af3c 100644 --- a/tests/cleave/viz/test_visual_limiter.py +++ b/tests/cleave/viz/test_visual_limiter.py @@ -1,4 +1,4 @@ -"""Pure logic tests for the closed-loop visual limiter (no GL).""" +"""Pure logic tests for the feed-forward visual limiter (no GL).""" from __future__ import annotations @@ -16,32 +16,35 @@ from cleave.viz.layer_pipeline import apply_effect_modifiers from cleave.viz.session import LayerRuntime, TimelineRuntime, TuningSession from cleave.viz.visual_limiter import ( - ATTACK_SEC, - DUCK_GAIN, + ATTACK_TC, + DEFAULT_RATIO, + DEFAULT_RELEASE_TC, + DEFAULT_THRESHOLD, GRID_HEIGHT, GRID_WIDTH, - RELEASE_RAMP_SEC, - RELEASE_SEC, - THRESHOLD_OFF, - THRESHOLD_ON, HotLayerRef, VisualLimiterParams, VisualLimiterState, apply_visual_limiter_gains, busyness, collect_hot_layers, + compensated_busyness, + distribute_gain, + layer_gain_from_target, metrics_from_grid, - pick_victim, + priority_weight, read_luma_grid, role_rank, reset_if_seek, + target_bus_gain, update_limiter_state, visual_limiter_active, ) from tests.support.config import TEST_LAYER_STEMS -_OVER = THRESHOLD_ON + 0.01 -_UNDER = THRESHOLD_OFF - 0.01 +_OVER = DEFAULT_THRESHOLD + 0.10 +_UNDER = DEFAULT_THRESHOLD - 0.10 +_SAMPLE_GAIN = 0.6 def _playlist(slot: str) -> PresetPlaylist: @@ -100,6 +103,7 @@ def _drive( t_sec: float, hot: list[HotLayerRef], mean_abs_delta: float = 0.0, + params: VisualLimiterParams | None = None, ) -> None: update_limiter_state( state, @@ -107,159 +111,158 @@ def _drive( mean_abs_delta=mean_abs_delta, t_sec=t_sec, hot=hot, + params=params, ) -def _fully_duck( - state: VisualLimiterState, - hot: list[HotLayerRef], - *, - t0: float = 0.0, -) -> float: - """Advance playhead through a full attack; return final t_sec.""" - _drive(state, mean_luma=_OVER, t_sec=t0, hot=hot) - t1 = t0 + ATTACK_SEC + 0.01 - _drive(state, mean_luma=_OVER, t_sec=t1, hot=hot) - return t1 - - -def test_pick_victim_prefers_bed_over_lead() -> None: - hot = [ - _hot("layer_1", role="lead", z_index=0), - _hot("layer_2", role="bed", z_index=1), - ] - assert pick_victim(hot, {}) == "layer_2" - - def test_unset_role_ranks_as_pulse() -> None: assert role_rank(None) == role_rank("pulse") - hot = [ - _hot("layer_1", role="lead", z_index=0), - _hot("layer_2", role=None, z_index=1), - _hot("layer_3", role="bed", z_index=2), - ] - assert pick_victim(hot, {}) == "layer_3" - assert pick_victim(hot, {"layer_3": DUCK_GAIN}) == "layer_2" -def test_tie_break_level_then_z_order() -> None: - hot = [ - _hot("layer_1", role="pulse", level=1.0, z_index=0), - _hot("layer_2", role="pulse", level=0.5, z_index=1), - ] - assert pick_victim(hot, {}) == "layer_2" +def test_priority_weight_favors_bed_over_lead() -> None: + assert priority_weight(role_rank("bed")) > priority_weight(role_rank("lead")) - hot_same_level = [ - _hot("layer_1", role="pulse", level=1.0, z_index=0), - _hot("layer_2", role="pulse", level=1.0, z_index=1), - ] - assert pick_victim(hot_same_level, {}) == "layer_1" +def test_layer_gain_from_target_spreads_by_weight() -> None: + target = 0.6 + assert layer_gain_from_target(target, 1.0) == pytest.approx(0.6) + assert layer_gain_from_target(target, 0.15) == pytest.approx(0.94) -def test_attack_ramps_over_playhead_time() -> None: - state = VisualLimiterState() - hot = [_hot("layer_1", role="bed")] - - _drive(state, mean_luma=_OVER, t_sec=0.0, hot=hot) - assert state.gain_for("layer_1") == pytest.approx(1.0) - _drive(state, mean_luma=_OVER, t_sec=ATTACK_SEC * 0.5, hot=hot) - mid = state.gain_for("layer_1") - assert DUCK_GAIN < mid < 1.0 +def test_target_bus_gain_below_threshold_is_unity() -> None: + assert target_bus_gain(0.5, threshold=0.65, ratio=3.0) == pytest.approx(1.0) - _drive(state, mean_luma=_OVER, t_sec=ATTACK_SEC + 0.01, hot=hot) - assert state.gain_for("layer_1") == pytest.approx(DUCK_GAIN) +def test_target_bus_gain_higher_ratio_reduces_more() -> None: + envelope = 0.85 + gentle = target_bus_gain(envelope, threshold=0.65, ratio=2.0) + aggressive = target_bus_gain(envelope, threshold=0.65, ratio=8.0) + assert gentle < 1.0 + assert aggressive < gentle -def test_hysteresis_holds_until_release_ramp() -> None: - state = VisualLimiterState() - hot = [_hot("layer_1", role="bed"), _hot("layer_2", role="lead")] - t = _fully_duck(state, hot) - assert state.gain_for("layer_1") == pytest.approx(DUCK_GAIN) - - under_start = t + 0.1 - _drive(state, mean_luma=_UNDER, t_sec=under_start, hot=hot) - assert state.gain_for("layer_1") == pytest.approx(DUCK_GAIN) - _drive( - state, - mean_luma=_UNDER, - t_sec=under_start + RELEASE_SEC * 0.5, - hot=hot, - ) - assert state.gain_for("layer_1") == pytest.approx(DUCK_GAIN) +def test_compensated_busyness_undoes_applied_gains() -> None: + raw = 0.5 + gains = {"layer_1": 0.5, "layer_2": 0.5} + assert compensated_busyness(raw, gains) == pytest.approx(1.0) - # Cross the hold with a small dt so the first release step is partial. - hold_end = under_start + RELEASE_SEC - _drive(state, mean_luma=_UNDER, t_sec=hold_end - 0.01, hot=hot) - _drive(state, mean_luma=_UNDER, t_sec=hold_end + 0.02, hot=hot) - mid_gain = state.gain_for("layer_1") - assert DUCK_GAIN < mid_gain < 1.0 - _drive( - state, - mean_luma=_UNDER, - t_sec=hold_end + RELEASE_RAMP_SEC + 0.01, - hot=hot, - ) - assert state.gain_for("layer_1") == pytest.approx(1.0) - - -def test_between_thresholds_cancels_release() -> None: +def test_envelope_attacks_on_sustained_high_busyness() -> None: state = VisualLimiterState() hot = [_hot("layer_1", role="bed")] - mid = (THRESHOLD_ON + THRESHOLD_OFF) / 2.0 - t = _fully_duck(state, hot) - - under_start = t + 0.1 - _drive(state, mean_luma=_UNDER, t_sec=under_start, hot=hot) - _drive(state, mean_luma=mid, t_sec=under_start + 0.1, hot=hot) - _drive( - state, - mean_luma=_UNDER, - t_sec=under_start + 0.1 + RELEASE_SEC + RELEASE_RAMP_SEC + 0.01, - hot=hot, - ) - # Release timer restarted after the mid-band cancel; one under tick is not - # enough hold time, so the duck remains. - assert state.gain_for("layer_1") == pytest.approx(DUCK_GAIN) + params = VisualLimiterParams(release_tc=1.0) + unducked = _OVER + + _drive(state, mean_luma=unducked, t_sec=0.0, hot=hot, params=params) + assert state.envelope == pytest.approx(0.0) + + _drive(state, mean_luma=unducked, t_sec=ATTACK_TC * 0.5, hot=hot, params=params) + assert 0.0 < state.envelope < unducked + + for step in range(20): + t = ATTACK_TC + step * 0.05 + avg_gain = ( + sum(state.gains.values()) / len(state.gains) if state.gains else 1.0 + ) + _drive( + state, + mean_luma=unducked * avg_gain, + t_sec=t, + hot=hot, + params=params, + ) + assert state.envelope > DEFAULT_THRESHOLD + assert state.gain_for("layer_1") < 1.0 + + +def test_envelope_releases_when_busyness_drops() -> None: + state = VisualLimiterState() + hot = [_hot("layer_1", role="bed")] + params = VisualLimiterParams(release_tc=0.3) + + for step in range(30): + _drive( + state, + mean_luma=_OVER, + t_sec=step * 0.05, + hot=hot, + params=params, + ) + peak_envelope = state.envelope + assert peak_envelope > DEFAULT_THRESHOLD + assert state.gain_for("layer_1") < 1.0 + + for step in range(40): + _drive( + state, + mean_luma=_UNDER, + t_sec=1.5 + step * 0.05, + hot=hot, + params=params, + ) + assert state.envelope < peak_envelope + assert state.gain_for("layer_1") == pytest.approx(1.0, abs=0.05) + + +def test_gain_compensation_stabilizes_under_feedback() -> None: + state = VisualLimiterState() + hot = [_hot("layer_1", role="bed")] + params = VisualLimiterParams(release_tc=DEFAULT_RELEASE_TC) + unducked = _OVER + + for step in range(80): + t = step * 0.05 + avg_gain = ( + sum(state.gains.values()) / len(state.gains) if state.gains else 1.0 + ) + measured_luma = unducked * avg_gain + _drive(state, mean_luma=measured_luma, t_sec=t, hot=hot, params=params) + + assert state.envelope > DEFAULT_THRESHOLD + final_gain = state.gain_for("layer_1") + for step in range(10): + t = 4.0 + step * 0.05 + avg_gain = ( + sum(state.gains.values()) / len(state.gains) if state.gains else 1.0 + ) + measured_luma = unducked * avg_gain + _drive(state, mean_luma=measured_luma, t_sec=t, hot=hot, params=params) + assert abs(state.gain_for("layer_1") - final_gain) < 0.08 + final_gain = state.gain_for("layer_1") + + +def test_distribute_gain_bed_ducks_more_than_lead() -> None: + state = VisualLimiterState() + hot = [ + _hot("layer_1", role="bed", z_index=0), + _hot("layer_2", role="lead", z_index=1), + ] + distribute_gain(state, hot, 0.6) + assert state.gain_for("layer_1") == pytest.approx(0.6) + assert state.gain_for("layer_2") == pytest.approx(0.94) -def test_seek_clears_ducks() -> None: +def test_seek_clears_state() -> None: state = VisualLimiterState() hot = [_hot("layer_1", role="bed")] state.last_t_sec = 10.0 - _fully_duck(state, hot, t0=10.0) - assert state.gain_for("layer_1") == pytest.approx(DUCK_GAIN) + for step in range(20): + _drive(state, mean_luma=_OVER, t_sec=10.0 + step * 0.05, hot=hot) + assert state.gain_for("layer_1") < 1.0 state.prev_luma = np.zeros((2, 2), dtype=np.float32) assert reset_if_seek(state, 1.0) is True assert state.gains == {} assert state.prev_luma is None + assert state.envelope == pytest.approx(0.0) assert state.last_t_sec == pytest.approx(1.0) assert state.controller_t_sec is None - # Quiet frame after seek: no release hold inherited from the prior duck. _drive(state, mean_luma=_UNDER, t_sec=1.0, hot=hot) assert state.gains == {} -def test_cascading_ducks_next_victim() -> None: - state = VisualLimiterState() - hot = [ - _hot("layer_1", role="bed", z_index=0), - _hot("layer_2", role="accent", z_index=1), - ] - t = _fully_duck(state, hot) - assert state.gain_for("layer_1") == pytest.approx(DUCK_GAIN) - assert state.gain_for("layer_2") == pytest.approx(1.0) - - t = _fully_duck(state, hot, t0=t) - assert state.gain_for("layer_1") == pytest.approx(DUCK_GAIN) - assert state.gain_for("layer_2") == pytest.approx(DUCK_GAIN) - - def test_busyness_includes_delta_weight() -> None: assert busyness(0.2, 0.1) > busyness(0.2, 0.0) @@ -286,13 +289,12 @@ def test_collect_hot_layers_uses_role_and_level() -> None: } hot = collect_hot_layers(session, layers, 1.0) assert [h.slot for h in hot] == ["layer_1", "layer_2"] - assert pick_victim(hot, {}) == "layer_2" def test_apply_effect_modifiers_includes_limiter_gain() -> None: session = _session(timeline_enabled=False) layer = _stem("layer_1", timeline_level=0.5) - layer.limiter_gain = DUCK_GAIN + layer.limiter_gain = _SAMPLE_GAIN effect_runtime = MagicMock() mod = MagicMock( opacity=0.8, @@ -314,13 +316,7 @@ def test_apply_effect_modifiers_includes_limiter_gain() -> None: 0.0, update=False, ) - assert layer.fbo.opacity == pytest.approx(0.8 * 0.5 * DUCK_GAIN) - - -def test_thresholds_leave_hysteresis_gap() -> None: - assert THRESHOLD_OFF < THRESHOLD_ON - assert THRESHOLD_ON - THRESHOLD_OFF >= 0.15 - assert DUCK_GAIN > 0.25 + assert layer.fbo.opacity == pytest.approx(0.8 * 0.5 * _SAMPLE_GAIN) def test_visual_limiter_inactive_when_disabled() -> None: @@ -334,9 +330,9 @@ def test_apply_visual_limiter_gains_resets_when_disabled() -> None: session = _session(timeline_enabled=True) session.timeline.limiter.enabled = False state = VisualLimiterState() - state.gains["layer_1"] = DUCK_GAIN + state.gains["layer_1"] = _SAMPLE_GAIN layer = _stem("layer_1", timeline_level=1.0) - layer.limiter_gain = DUCK_GAIN + layer.limiter_gain = _SAMPLE_GAIN runtime = MagicMock() runtime.visual_limiter = state runtime.seed.session = session @@ -349,30 +345,20 @@ def test_apply_visual_limiter_gains_resets_when_disabled() -> None: def test_update_limiter_state_uses_session_params() -> None: state = VisualLimiterState() hot = [_hot("layer_1", role="bed")] - params = VisualLimiterParams( - threshold_on=0.80, - threshold_off=0.63, - release_ramp_sec=0.45, - release_sec=0.75, - ) - update_limiter_state( - state, - mean_luma=0.70, - mean_abs_delta=0.0, - t_sec=0.0, - hot=hot, - params=params, - ) + params = VisualLimiterParams(threshold=0.80, ratio=3.0, release_tc=0.45) + + _drive(state, mean_luma=0.70, t_sec=0.0, hot=hot, params=params) assert state.gains == {} - update_limiter_state( - state, - mean_luma=0.81, - mean_abs_delta=0.0, - t_sec=ATTACK_SEC + 0.01, - hot=hot, - params=params, - ) - assert state.gain_for("layer_1") == pytest.approx(DUCK_GAIN) + + for step in range(30): + _drive( + state, + mean_luma=0.90, + t_sec=0.05 + step * 0.05, + hot=hot, + params=params, + ) + assert state.gain_for("layer_1") < 1.0 def test_read_luma_grid_delegates_to_post_process() -> None: diff --git a/tests/cleave/viz/test_wiring.py b/tests/cleave/viz/test_wiring.py index e126630..168d77d 100644 --- a/tests/cleave/viz/test_wiring.py +++ b/tests/cleave/viz/test_wiring.py @@ -8,18 +8,26 @@ from cleave.preset_playlist import PresetPlaylist from cleave.projectm import ProjectM from cleave.viz.layer import StemLayer -from cleave.viz.session import LayerRuntime, TuningSession +from cleave.viz.session import LayerRuntime, TimelineRuntime, TuningSession from cleave.viz.wiring import make_tuning_controls from tests.support.viz import make_test_cfg, stub_playback_state +_MILK = ( + Path("/tmp/presets/layer_1/a.milk"), + Path("/tmp/presets/layer_1/b.milk"), +) -def _session_with_mode( - mode: str, *, rotation_set: str = "directory" + +def _session( + *, + mode: str = "on", + trigger: str = "timer", + preset_list: list[str] | None = None, + timeline_enabled: bool = False, ) -> TuningSession: - preset_root = Path("/tmp/presets") playlist = PresetPlaylist( - current_dir=preset_root / "layer_1", - paths=(preset_root / "layer_1" / "a.milk",), + current_dir=Path("/tmp/presets/layer_1"), + paths=_MILK, index=0, ) return TuningSession( @@ -27,339 +35,170 @@ def _session_with_mode( layers={ "layer_1": LayerRuntime( playlist=playlist, - browse_floor=preset_root / "layer_1", + browse_floor=Path("/tmp/presets/layer_1"), stem="drums", preset_switching=mode, # type: ignore[arg-type] - preset_switching_rotation_set=rotation_set, # type: ignore[arg-type] + preset_switching_trigger=trigger, # type: ignore[arg-type] + preset_list=list(preset_list or [str(p) for p in _MILK]), ) }, + timeline=TimelineRuntime(enabled=timeline_enabled), ) -def test_on_preset_change_forces_none_in_curation_mode() -> None: - session = _session_with_mode("projectm") - session.settings.editor_mode = "preset_curation" - cfg = make_test_cfg(("layer_1",)) +def _layer(session: TuningSession) -> StemLayer: pm = ProjectM.__new__(ProjectM) pm.lock_preset = MagicMock() pm.load_preset = MagicMock() pm.set_preset_start_clean = MagicMock() pm.set_hard_cut_enabled = MagicMock() - layer = StemLayer( + pm.flush_pcm = MagicMock() + pm._pcm_channels = 2 + return StemLayer( slot="layer_1", pm=pm, fbo=MagicMock(), playlist=session.layers["layer_1"].playlist, ) - layers_by_slot = {"layer_1": layer} - playlist = session.layers["layer_1"].playlist - notify = MagicMock() + +def _make_controls(session: TuningSession, layer: StemLayer): + cfg = make_test_cfg(("layer_1",)) + return make_tuning_controls( + session=session, + cfg=cfg, + preset_root=cfg.paths.preset_root, + project_dir=Path("/tmp/project"), + layers_by_slot={"layer_1": layer}, + layers=[layer], + playback=stub_playback_state(), + duration_sec=120.0, + signals=None, + effect_runtime=MagicMock(), + ) + + +def test_on_preset_change_forces_off_in_curation_mode() -> None: + session = _session(mode="on", trigger="projectm") + session.settings.editor_mode = "preset_curation" + layer = _layer(session) + playlist = session.layers["layer_1"].playlist with patch("cleave.viz.wiring.apply_preset_switching") as mock_apply: - controls = make_tuning_controls( - session=session, - cfg=cfg, - preset_root=cfg.paths.preset_root, - project_dir=Path("/tmp/project"), - layers_by_slot=layers_by_slot, - layers=[layer], - playback=stub_playback_state(), - duration_sec=120.0, - signals=None, - effect_runtime=MagicMock(), - ) - controls.show_notification = notify + controls = _make_controls(session, layer) bindings = controls._layer_bindings assert bindings is not None playlist.load_into = MagicMock() bindings.on_preset_change("layer_1", playlist) - mock_apply.assert_not_called() - playlist.load_into.assert_called_once_with(pm, smooth=False) - pm.lock_preset.assert_called_with(True) - notify.assert_not_called() + playlist.load_into.assert_called_once_with(layer.pm, smooth=False) + layer.pm.lock_preset.assert_called_with(True) -def test_on_preset_switching_change_forces_none_in_curation_mode() -> None: - session = _session_with_mode("projectm") +def test_on_preset_switching_change_forces_off_in_curation_mode() -> None: + session = _session(mode="on", trigger="projectm") session.settings.editor_mode = "preset_curation" - cfg = make_test_cfg(("layer_1",)) - pm = ProjectM.__new__(ProjectM) - layer = StemLayer( - slot="layer_1", - pm=pm, - fbo=MagicMock(), - playlist=session.layers["layer_1"].playlist, - ) - + layer = _layer(session) with patch("cleave.viz.wiring.apply_preset_switching") as mock_apply: - controls = make_tuning_controls( - session=session, - cfg=cfg, - preset_root=cfg.paths.preset_root, - project_dir=Path("/tmp/project"), - layers_by_slot={"layer_1": layer}, - layers=[layer], - playback=stub_playback_state(), - duration_sec=120.0, - signals=None, - effect_runtime=MagicMock(), - ) + controls = _make_controls(session, layer) bindings = controls._layer_bindings assert bindings is not None bindings.on_preset_switching_change("layer_1") - - mock_apply.assert_called_once() - assert mock_apply.call_args.kwargs["mode"] == "none" + assert mock_apply.call_args.kwargs["mode"] == "off" def test_reapply_projectm_preset_switching_noop_in_curation_mode() -> None: - from cleave.viz.preset_switching import reapply_projectm_preset_switching - - session = _session_with_mode("projectm") + session = _session(mode="on", trigger="projectm") session.settings.editor_mode = "preset_curation" - pm = ProjectM.__new__(ProjectM) - layer = StemLayer( - slot="layer_1", - pm=pm, - fbo=MagicMock(), - playlist=session.layers["layer_1"].playlist, - ) - with patch( - "cleave.viz.preset_switching.apply_preset_switching" - ) as mock_apply: - reapply_projectm_preset_switching(session, {"layer_1": layer}, preset_root=Path("/tmp/presets"), delta_sec=5.0) + layer = _layer(session) + with patch("cleave.viz.wiring.apply_preset_switching") as mock_apply: + controls = _make_controls(session, layer) + bindings = controls._layer_bindings + assert bindings is not None + bindings.on_seek(1.0) mock_apply.assert_not_called() -def test_on_preset_change_rebuilds_rotation_in_projectm_mode() -> None: - session = _session_with_mode("projectm") - cfg = make_test_cfg(("layer_1",)) - pm = ProjectM.__new__(ProjectM) - pm.lock_preset = MagicMock() - pm.load_preset = MagicMock() - layer = StemLayer( - slot="layer_1", - pm=pm, - fbo=MagicMock(), - playlist=session.layers["layer_1"].playlist, - ) - layers_by_slot = {"layer_1": layer} +def test_on_preset_change_rebuilds_projectm_playlist() -> None: + session = _session(mode="on", trigger="projectm", timeline_enabled=False) + layer = _layer(session) playlist = session.layers["layer_1"].playlist - with patch("cleave.viz.wiring.apply_preset_switching") as mock_apply: - controls = make_tuning_controls( - session=session, - cfg=cfg, - preset_root=cfg.paths.preset_root, - project_dir=Path("/tmp/project"), - layers_by_slot=layers_by_slot, - layers=[layer], - playback=stub_playback_state(), - duration_sec=120.0, - signals=None, - effect_runtime=MagicMock(), - ) - + controls = _make_controls(session, layer) bindings = controls._layer_bindings assert bindings is not None - playlist.load_into = MagicMock() bindings.on_preset_change("layer_1", playlist) - - playlist.load_into.assert_not_called() mock_apply.assert_called_once() - assert mock_apply.call_args.args[0] is layer - assert mock_apply.call_args.kwargs["mode"] == "projectm" - assert layer.auto_preset_path == playlist.current.resolve() + assert mock_apply.call_args.kwargs["mode"] == "on" + assert mock_apply.call_args.kwargs["trigger"] == "projectm" -def test_on_preset_change_loads_preset_in_user_defined_rotation_set() -> None: - session = _session_with_mode("projectm", rotation_set="user_defined") - cfg = make_test_cfg(("layer_1",)) - pm = ProjectM.__new__(ProjectM) - pm.lock_preset = MagicMock() - pm.load_preset = MagicMock() - pm.set_preset_start_clean = MagicMock() - layer = StemLayer( - slot="layer_1", - pm=pm, - fbo=MagicMock(), - playlist=session.layers["layer_1"].playlist, - ) - layers_by_slot = {"layer_1": layer} +def test_on_preset_change_forces_clean_boot_for_timer() -> None: + session = _session(mode="on", trigger="timer", timeline_enabled=False) + layer = _layer(session) playlist = session.layers["layer_1"].playlist - - with patch("cleave.viz.wiring.apply_preset_switching"): - controls = make_tuning_controls( - session=session, - cfg=cfg, - preset_root=cfg.paths.preset_root, - project_dir=Path("/tmp/project"), - layers_by_slot=layers_by_slot, - layers=[layer], - playback=stub_playback_state(), - duration_sec=120.0, - signals=None, - effect_runtime=MagicMock(), - ) - - bindings = controls._layer_bindings - assert bindings is not None - playlist.load_into = MagicMock() - bindings.on_preset_change("layer_1", playlist) - - playlist.load_into.assert_called_once_with(pm, smooth=False) - pm.lock_preset.assert_called_once_with(False) - - -def test_on_preset_change_forces_clean_boot_then_restores() -> None: - session = _session_with_mode("none") - session.layers["layer_1"].preset_start_clean = False - cfg = make_test_cfg(("layer_1",)) - pm = ProjectM.__new__(ProjectM) - pm.lock_preset = MagicMock() - pm.load_preset = MagicMock() - pm.set_preset_start_clean = MagicMock() - layer = StemLayer( - slot="layer_1", - pm=pm, - fbo=MagicMock(), - playlist=session.layers["layer_1"].playlist, - ) - layers_by_slot = {"layer_1": layer} - playlist = session.layers["layer_1"].playlist - - with patch("cleave.viz.wiring.apply_preset_switching"): - controls = make_tuning_controls( - session=session, - cfg=cfg, - preset_root=cfg.paths.preset_root, - project_dir=Path("/tmp/project"), - layers_by_slot=layers_by_slot, - layers=[layer], - playback=stub_playback_state(), - duration_sec=120.0, - signals=None, - effect_runtime=MagicMock(), - ) - - bindings = controls._layer_bindings - assert bindings is not None - bindings.on_preset_change("layer_1", playlist) - - assert pm.set_preset_start_clean.call_args_list == [ - (((True,)), {}), - (((False,)), {}), - ] + with patch("cleave.viz.wiring.apply_preset_switching") as mock_apply: + with patch("cleave.viz.wiring.reanchor_list_preset_after_browse") as mock_reanchor: + controls = _make_controls(session, layer) + bindings = controls._layer_bindings + assert bindings is not None + playlist.load_into = MagicMock() + bindings.on_preset_change("layer_1", playlist) + mock_apply.assert_not_called() + playlist.load_into.assert_called_once_with(layer.pm, smooth=False) + mock_reanchor.assert_called_once() def test_on_seek_reapplies_projectm_preset_switching() -> None: - session = _session_with_mode("projectm") - cfg = make_test_cfg(("layer_1",)) - pm = ProjectM.__new__(ProjectM) - layer = StemLayer( - slot="layer_1", - pm=pm, - fbo=MagicMock(), - playlist=session.layers["layer_1"].playlist, - ) - - with ( - patch("cleave.viz.wiring.reapply_projectm_preset_switching") as mock_reapply, - patch("cleave.viz.wiring.resync_timeline_preset_switching") as mock_resync, - patch("cleave.viz.wiring.LayerFramePipeline.flush_pcm"), - patch("cleave.viz.wiring.seek"), - ): - controls = make_tuning_controls( - session=session, - cfg=cfg, - preset_root=cfg.paths.preset_root, - project_dir=Path("/tmp/project"), - layers_by_slot={"layer_1": layer}, - layers=[layer], - playback=stub_playback_state(), - duration_sec=120.0, - signals=None, - effect_runtime=MagicMock(), - ) - bindings = controls._layer_bindings - assert bindings is not None - bindings.on_seek(5.0) - + session = _session(mode="on", trigger="projectm", timeline_enabled=False) + layer = _layer(session) + layer.projectm_playlist = MagicMock() + with patch("cleave.viz.wiring.reapply_projectm_preset_switching") as mock_reapply: + with patch("cleave.viz.wiring.resync_timeline_preset_switching"): + controls = _make_controls(session, layer) + bindings = controls._layer_bindings + assert bindings is not None + bindings.on_seek(1.0) mock_reapply.assert_called_once() - assert mock_reapply.call_args.kwargs["delta_sec"] == 5.0 - mock_resync.assert_called_once() -def test_on_preset_change_timeline_reanchors_and_stays_locked() -> None: - session = _session_with_mode("timeline") - cfg = make_test_cfg(("layer_1",)) - pm = ProjectM.__new__(ProjectM) - pm.lock_preset = MagicMock() - pm.load_preset = MagicMock() - pm.set_preset_start_clean = MagicMock() - pm.set_hard_cut_enabled = MagicMock() - layer = StemLayer( - slot="layer_1", - pm=pm, - fbo=MagicMock(), - playlist=session.layers["layer_1"].playlist, - ) +def test_on_preset_change_timeline_trigger_reanchors_and_stays_locked() -> None: + session = _session(mode="on", trigger="timeline", timeline_enabled=True) + layer = _layer(session) playlist = session.layers["layer_1"].playlist - - with ( - patch("cleave.viz.wiring.apply_preset_switching"), - patch("cleave.viz.wiring.reanchor_timeline_preset_after_browse") as mock_reanchor, - ): - controls = make_tuning_controls( - session=session, - cfg=cfg, - preset_root=cfg.paths.preset_root, - project_dir=Path("/tmp/project"), - layers_by_slot={"layer_1": layer}, - layers=[layer], - playback=stub_playback_state(), - duration_sec=120.0, - signals=None, - effect_runtime=MagicMock(), - ) + with patch( + "cleave.viz.wiring.reanchor_list_preset_after_browse", + side_effect=lambda *args, **kwargs: layer.pm.lock_preset(True), + ) as mock_reanchor: + controls = _make_controls(session, layer) bindings = controls._layer_bindings assert bindings is not None playlist.load_into = MagicMock() bindings.on_preset_change("layer_1", playlist) - - playlist.load_into.assert_called_once_with(pm, smooth=False) mock_reanchor.assert_called_once() - assert mock_reanchor.call_args.args[0] is layer + layer.pm.lock_preset.assert_called_with(True) -def test_unlock_preset_after_modal_keeps_timeline_locked() -> None: - session = _session_with_mode("timeline") - cfg = make_test_cfg(("layer_1",)) - pm = ProjectM.__new__(ProjectM) - pm.lock_preset = MagicMock() - layer = StemLayer( - slot="layer_1", - pm=pm, - fbo=MagicMock(), - playlist=session.layers["layer_1"].playlist, - ) +def test_on_preset_change_timer_reanchors_with_timeline_enabled() -> None: + session = _session(mode="on", trigger="timer", timeline_enabled=True) + layer = _layer(session) + playlist = session.layers["layer_1"].playlist + with patch("cleave.viz.wiring.apply_preset_switching") as mock_apply: + with patch("cleave.viz.wiring.reanchor_list_preset_after_browse") as mock_reanchor: + controls = _make_controls(session, layer) + bindings = controls._layer_bindings + assert bindings is not None + playlist.load_into = MagicMock() + bindings.on_preset_change("layer_1", playlist) + mock_apply.assert_not_called() + mock_reanchor.assert_called_once() - with patch("cleave.viz.wiring.apply_preset_switching"): - controls = make_tuning_controls( - session=session, - cfg=cfg, - preset_root=cfg.paths.preset_root, - project_dir=Path("/tmp/project"), - layers_by_slot={"layer_1": layer}, - layers=[layer], - playback=stub_playback_state(), - duration_sec=120.0, - signals=None, - effect_runtime=MagicMock(), - ) +def test_unlock_preset_after_modal_keeps_indexed_locked() -> None: + session = _session(mode="on", trigger="timeline", timeline_enabled=True) + layer = _layer(session) + controls = _make_controls(session, layer) bindings = controls._layer_bindings assert bindings is not None + layer.pm.lock_preset.reset_mock() bindings.unlock_preset_after_modal("layer_1") - pm.lock_preset.assert_called_with(True) + layer.pm.lock_preset.assert_called_with(True)