From 88ecd186b7b3014502d914c4ef3c476a7109129f Mon Sep 17 00:00:00 2001 From: SpoddyCoder Date: Sun, 21 Jun 2026 11:47:12 +0100 Subject: [PATCH 1/3] Add dynamic layers implementation plan --- docs/dynamic-layers-plan.md | 609 ++++++++++++++++++++++++++++++++++++ 1 file changed, 609 insertions(+) create mode 100644 docs/dynamic-layers-plan.md diff --git a/docs/dynamic-layers-plan.md b/docs/dynamic-layers-plan.md new file mode 100644 index 0000000..3dc0185 --- /dev/null +++ b/docs/dynamic-layers-plan.md @@ -0,0 +1,609 @@ +# Dynamic layers plan + +## Overview + +Remove the hardcoded four-layer ceiling. Layers become first-class runtime objects that can +be added and removed through the live tuning UI. The default starting config remains four +layers but the schema accepts 1-8. All existing layer semantics (expand/collapse, show/hide, +solo, lock, effects, timeline, preset browsing, stem, blend, opacity, beat sensitivity, +z-order reorder, save/snapshot) carry over unchanged to dynamically added layers. + +The implementation is split into eight phases that can be developed and reviewed incrementally. + +--- + +## Decisions + +| Question | Answer | +|---|---| +| Default stem for new layer | `full_mix` | +| Default blend mode | `black-key` | +| Default dimensions | 1280x720 | +| Default preset | Random from preset root | +| Max layers | 8 | +| Min layers | 1 | +| Timeline cues for deleted layer | Discarded silently | +| GL rebuild on add/remove | Acceptable (brief freeze) | +| Timeline num keys | 1-8, including numpad 1-8 | +| New layer z-order position | Appended (bottom of compositor stack; user reorders in move mode) | + +--- + +## Phase 1 — Config schema + +**Files:** `cleave/config_schema.py`, `cleave/config.py` + +### 1.1 Replace the `LAYER_SLOTS` constant + +Remove: +```python +LAYER_SLOTS: tuple[str, ...] = ("layer_1", "layer_2", "layer_3", "layer_4") +DEFAULT_LAYER_Z_ORDER = LAYER_SLOTS +DEFAULT_STEM_FOR_SLOT: dict[str, StemSource] = { ... } +``` + +Add: +```python +MAX_LAYER_COUNT = 8 +MIN_LAYER_COUNT = 1 +DEFAULT_LAYER_SLOTS = ("layer_1", "layer_2", "layer_3", "layer_4") +DEFAULT_LAYER_Z_ORDER: list[str] = list(DEFAULT_LAYER_SLOTS) +DEFAULT_NEW_LAYER_STEM: StemSource = "full_mix" + +def next_layer_slot(existing_slots: list[str]) -> str: + """Return the lowest unused layer_N slot name.""" + used = set(existing_slots) + for i in range(1, MAX_LAYER_COUNT + 1): + candidate = f"layer_{i}" + if candidate not in used: + return candidate + raise ValueError(f"Maximum {MAX_LAYER_COUNT} layers already present") + +def new_layer_config(slot: str, preset: Path, preset_root: Path) -> LayerConfig: + """Factory for a fresh LayerConfig with new-layer defaults.""" + from cleave.config import LayerConfig + w, h = LAYER_DEFAULT_SIZE[DEFAULT_NEW_LAYER_STEM] + return LayerConfig( + preset=preset, + stem=DEFAULT_NEW_LAYER_STEM, + enabled=True, + opacity=1.0, + width=w, + height=h, + blend_mode=DEFAULT_BLEND_MODE[DEFAULT_NEW_LAYER_STEM], + locked=False, + ) +``` + +`DEFAULT_STEM_FOR_SLOT` had no live runtime use after variable-stems; remove it. Any +remaining reference (only `wiring.py` stub) is updated in Phase 3. + +### 1.2 Relax `parse_layers_section` + +- Accept any number of `layer_N` keys where 1 ≤ N ≤ 8, so count in range [1, 8]. +- Reject keys not matching `layer_\d+`, N out of [1, 8], or duplicate N. +- Reject an empty `layers` block. +- Retain all per-layer field validation unchanged. + +Replace the fixed missing/unknown check with: + +```python +import re + +_SLOT_RE = re.compile(r"^layer_(\d+)$") + +def _valid_slot(key: str) -> int | None: + m = _SLOT_RE.match(key) + if m: + n = int(m.group(1)) + if 1 <= n <= MAX_LAYER_COUNT: + return n + return None + +# inside parse_layers_section: +for key in layers_raw: + if _valid_slot(key) is None: + raise ValueError(f"invalid layer key '{key}': must be layer_1 .. layer_{MAX_LAYER_COUNT}") +if not layers_raw: + raise ValueError("layers section must contain at least one layer") +``` + +### 1.3 Relax `parse_layer_z_order_section` + +Currently validates permutation of fixed `LAYER_SLOTS`. Change to validate: +- Is a list. +- All entries appear in `layers` keys (the parsed layer set, passed in). +- No duplicates. +- Length matches the parsed layer set. + +The parsed layer set is available as `ctx.layer_slots` — add this field to `ParseCtx`. + +### 1.4 Update `persist_layers` + +Replace the `for slot in LAYER_SLOTS` loop with `for slot in ctx.session.layer_z_order`. +For newly added layers that have no entry in `ctx.cfg.layers` (see Phase 3), read all values +from `ctx.session.layers[slot]` and from `slot_layer_config(slot)` (a new helper that returns +the `LayerConfig` from cfg or synthesises defaults from session runtime). + +Actually, by the time persist runs, `cfg.layers` includes the new slot (Phase 3 keeps cfg and +session in sync). No special case needed. + +### 1.5 Update `parse_timeline_section` + +Cue `layers` sub-keys are validated against the actual layer set in the config (not +`LAYER_SLOTS`). Pass the parsed layer slots through `ParseCtx` and use them here. + +### 1.6 `CleaveConfig` — un-freeze and use `list` + +`CleaveConfig` is `frozen=True` with `layer_z_order: tuple[str, ...]`. Dynamic add/remove +requires mutating both fields. Changes: + +- Remove `frozen=True` from `CleaveConfig`. +- Change `layer_z_order: tuple[str, ...]` → `layer_z_order: list[str]`. +- Audit all call sites that construct `CleaveConfig` or read `layer_z_order` expecting a + tuple; update to list. +- `layers_in_z_order` is unaffected (returns a list already). +- `LayerConfig` stays `frozen=True` (individual layer configs are immutable values). + +--- + +## Phase 2 — GL lifecycle helpers + +**Files:** `cleave/gl_compositor.py`, `cleave/viz/layer_pipeline.py`, +`cleave/preset_playlist.py` + +### 2.1 `GlCompositor.remove_layer_fbo(name: str)` + +The compositor's `_layers` list already has no fixed capacity. Add: + +```python +def remove_layer_fbo(self, name: str) -> None: + """Destroy the named FBO and remove it from the compositor stack.""" + self._layers = [fbo for fbo in self._layers if fbo.name != name] + # Release GL resources for the removed FBO. +``` + +### 2.2 `LayerFramePipeline.build_single` + +Build exactly one layer (ProjectM + FBO) and return a `StemLayer`: + +```python +@staticmethod +def build_single( + slot: str, + layer_cfg: LayerConfig, + compositor: GlCompositor, + playlist: PresetPlaylist, + fps: int, + texture_paths: list[Path], +) -> StemLayer: +``` + +Reuses the same logic as the inner loop body in `build`. No warmup is applied; the new +layer starts from frame zero (same behaviour as any fresh projectM instance). + +### 2.3 `LayerFramePipeline.destroy_single` + +```python +@staticmethod +def destroy_single( + slot: str, + layers: list[StemLayer], + layers_by_slot: dict[str, StemLayer], + compositor: GlCompositor, +) -> None: + """Destroy the GL resources for one slot and remove it from both collections.""" + layer = layers_by_slot.pop(slot) + layers.remove(layer) + layer.pm.destroy() + compositor.remove_layer_fbo(slot) +``` + +### 2.4 `scan_single_layer` + +New function in `preset_playlist.py`: + +```python +def scan_single_layer( + slot: str, + preset_root: Path, + project_dir: Path, +) -> PresetPlaylist: + """Scan and return a playlist for a single slot, seeding a random preset.""" +``` + +Same logic as `scan_all_layers` for one slot. Picks a random preset as initial current. + +--- + +## Phase 3 — Session + wiring + +**Files:** `cleave/viz/session.py`, `cleave/viz/wiring.py` + +### 3.1 `session.py` — add/remove helpers + +```python +def add_layer_to_session( + session: TuningSession, + slot: str, + runtime: LayerRuntime, +) -> None: + session.layers[slot] = runtime + session.layer_z_order.append(slot) + +def remove_layer_from_session(session: TuningSession, slot: str) -> None: + session.layer_z_order.remove(slot) + del session.layers[slot] + if session.solo_slot == slot: + session.solo_slot = None +``` + +`session_from_cfg` already iterates `cfg.layers.items()` — no change needed there. + +### 3.2 `wiring.py` — `LayerManager` + +Add a `LayerManager` class that holds the mutable GL collections and exposes the add/remove +operations. `TuningControls` receives a `LayerManager` and calls it on modal confirm. + +```python +class LayerManager: + def __init__( + self, + cfg: CleaveConfig, + session: TuningSession, + compositor: GlCompositor, + layers: list[StemLayer], + layers_by_slot: dict[str, StemLayer], + playlists: dict[str, PresetPlaylist], + pcm_bank: StemPcmBank, + preset_root: Path, + fps: int, + texture_paths: list[Path], + ) -> None: ... + + def can_add(self) -> bool: + return len(self.session.layer_z_order) < MAX_LAYER_COUNT + + def can_remove(self) -> bool: + return len(self.session.layer_z_order) > MIN_LAYER_COUNT + + def add_layer(self) -> str: + """Create a new layer, update cfg/session/GL, return the new slot name.""" + slot = next_layer_slot(self.session.layer_z_order) + playlist = scan_single_layer(slot, self.preset_root, project_dir=...) + layer_cfg = new_layer_config(slot, playlist.current_path, self.preset_root) + self.cfg.layers[slot] = layer_cfg + stem_layer = LayerFramePipeline.build_single( + slot, layer_cfg, self.compositor, playlist, self.fps, self.texture_paths + ) + self.layers.append(stem_layer) + self.layers_by_slot[slot] = stem_layer + self.playlists[slot] = playlist + runtime = LayerRuntime( + playlist=playlist, + browse_floor=self.preset_root, + stem=DEFAULT_NEW_LAYER_STEM, + ) + add_layer_to_session(self.session, slot, runtime) + self.cfg.layer_z_order.append(slot) + return slot + + def remove_layer(self, slot: str) -> None: + """Destroy a layer and remove it from cfg/session/GL.""" + # Clear timeline records for this slot (cues are in session, not cfg) + _discard_timeline_slot(self.session, slot) + LayerFramePipeline.destroy_single( + slot, self.layers, self.layers_by_slot, self.compositor + ) + del self.cfg.layers[slot] + self.cfg.layer_z_order.remove(slot) + del self.playlists[slot] + remove_layer_from_session(self.session, slot) +``` + +`_discard_timeline_slot` clears `session.timeline` records (armed slot, override stems, +record buffer, monitor) for the deleted slot — cues committed to the timeline are simply +orphaned (ignored by `layer_visible_at` since the slot is gone). + +Remove `_stub_cfg_for_session`. Tests that needed it should build a minimal `CleaveConfig` +directly with an explicit slot list. + +### 3.3 `make_tuning_controls` / `make_timeline_controls` + +Pass `LayerManager` into `make_tuning_controls`; store it on `TuningControls` for use in +`_add_layer` / `_delete_layer` handlers (Phase 5). + +--- + +## Phase 4 — UI rows + +**Files:** `cleave/viz/row_semantics.py`, `cleave/viz/overlay.py` + +### 4.1 New `RowKind` values + +```python +class RowKind(Enum): + ... + LAYER_MANAGEMENT_ADD = auto() # "ADD NEW LAYER" — one row, below all track blocks + LAYER_MANAGEMENT_DELETE = auto() # "Delete Layer" — one per track block, last sub-row +``` + +### 4.2 `RowBehavior` entries + +```python +RowKind.LAYER_MANAGEMENT_ADD: RowBehavior( + RowAffordance.ACTION, + help_title="Add new layer", + navigable=True, +), +RowKind.LAYER_MANAGEMENT_DELETE: RowBehavior( + RowAffordance.ACTION, + help_title="Delete layer", + navigable=True, + blocked_by_layer_lock=False, # always accessible +), +``` + +`LAYER_MANAGEMENT_DELETE` carries a `slot` in its `RowDescriptor` (same pattern as +`TRACK_EFFECT`). `LAYER_MANAGEMENT_ADD` has no slot. + +Add both kinds to `TRACK_SUB_ROW_KINDS` (or a new `LAYER_MANAGEMENT_ROW_KINDS`) as +appropriate for navigation and group membership. + +### 4.3 `build_row_layout` in `overlay.py` + +**Delete Layer** row: appended as the last sub-row of each track block, after the cleave +effects header (and any visible effect sub-rows). It is only included when the track is +expanded (follows the same expand gate as other sub-rows). Descriptor: +`RowDescriptor(RowKind.LAYER_MANAGEMENT_DELETE, slot=slot)`. + +**ADD NEW LAYER** row: inserted as the first row after the last track block, before +`RENDER_SECTION_GAP`. Descriptor: `RowDescriptor(RowKind.LAYER_MANAGEMENT_ADD)`. Always +visible (not gated on any expand state). + +### 4.4 Drawing in `overlay.py` + +**ADD NEW LAYER:** Draw with `_render_label_value_row` using label `ADD NEW LAYER` in +`LABEL` color. No eye, no expand arrow. + +**Delete Layer:** Draw with `_render_label_value_row` using label `Delete Layer` in `LABEL` +color. No eye, no expand arrow. If `len(session.layer_z_order) == 1`, draw in `DISABLED` +color to signal the action is blocked (even though it is still navigable so the user can +receive the "must have at least 1 layer" notification). + +--- + +## Phase 5 — Controls + +**Files:** `cleave/viz/controls.py` + +### 5.1 Constructor + +Accept `layer_manager: LayerManager` (may be `None` for headless tests). + +### 5.2 `_add_layer` + +Called when Enter is pressed on `LAYER_MANAGEMENT_ADD`: + +```python +def _add_layer(self) -> None: + if self._layer_manager is None: + return + if not self._layer_manager.can_add(): + self.show_toast(f"Maximum {MAX_LAYER_COUNT} layers") + return + self._modal_host.prompt_yes_no( + "Add new Milkdrop visualisation layer?", + on_confirm=self._confirm_add_layer, + ) + +def _confirm_add_layer(self) -> None: + if self._layer_manager is None: + return + self._layer_manager.add_layer() + self._rebuild_view() # refresh the bindings / navigable rows +``` + +### 5.3 `_delete_layer` + +Called when Enter is pressed on `LAYER_MANAGEMENT_DELETE` (slot from `row_slot()`): + +```python +def _delete_layer(self, slot: str) -> None: + if self._layer_manager is None: + return + if not self._layer_manager.can_remove(): + self.show_toast("Must have at least 1 layer") + return + self._modal_host.prompt_yes_no( + "Delete this Milkdrop visualisation layer?", + on_confirm=lambda: self._confirm_delete_layer(slot), + ) + +def _confirm_delete_layer(self, slot: str) -> None: + if self._layer_manager is None: + return + current_focus = self._focus_row_index + self._layer_manager.remove_layer(slot) + self._rebuild_view() + # Clamp focus to new row count + self._focus_row_index = min(current_focus, len(navigable_row_indices(self._state)) - 1) +``` + +### 5.4 `_rebuild_view` + +After add/remove, the number of navigable rows changes. Call: +```python +self._state = TuningViewStateBuilder(self._session, self._cfg).build() +``` +(or however the view state is currently rebuilt on other mutations that change row count). +Any z-order move mode is exited before the rebuild. + +### 5.5 Key dispatch + +In the existing Enter handler, add cases for the two new row kinds before falling through to +existing cases: + +```python +if kind == RowKind.LAYER_MANAGEMENT_ADD: + self._add_layer() + return +if kind == RowKind.LAYER_MANAGEMENT_DELETE: + self._delete_layer(row_slot(self._state, self._focus_row_index)) + return +``` + +--- + +## Phase 6 — Timeline + +**Files:** `cleave/timeline.py`, `cleave/viz/timeline_controls.py`, +`cleave/viz/timeline_overlay.py`, `cleave/viz/layer_visibility.py` + +### 6.1 `timeline.py` — remove `LAYER_SLOTS` dependency + +`visible_state_at` currently iterates `LAYER_SLOTS`. Change signature to accept +`slots: list[str]` and iterate that instead. All callers pass `session.layer_z_order`. + +### 6.2 `timeline_controls.py` — extend num keys to 1-8 + +```python +_LAYER_KEY_INDEX: dict[int, int] = { + pygame.K_1: 0, pygame.K_2: 1, pygame.K_3: 2, pygame.K_4: 3, + pygame.K_5: 4, pygame.K_6: 5, pygame.K_7: 6, pygame.K_8: 7, + pygame.K_KP1: 0, pygame.K_KP2: 1, pygame.K_KP3: 2, pygame.K_KP4: 3, + pygame.K_KP5: 4, pygame.K_KP6: 5, pygame.K_KP7: 6, pygame.K_KP8: 7, +} +``` + +Guard the index against `len(session.layer_z_order)` before using it (already implicit +in `_slot_for_layer_index` — keep that guard). + +### 6.3 `timeline_overlay.py` — dynamic width probe + +Replace `layer_num_prefix(4)` with `layer_num_prefix(max(len(layer_z_order), 1))`. Since +max is 8, the column never needs more than one digit plus a space — the probe just needs to +match the widest label that will actually appear. + +### 6.4 `layer_visibility.py` + +Any hardcoded slot references are replaced with `session.layer_z_order` iteration. + +--- + +## Phase 7 — Snapshot and dirty tracking + +**File:** `cleave/config_snapshot.py` + +### 7.1 `persist_layers` + +The current loop is `for slot in LAYER_SLOTS`. Change to `for slot in ctx.session.layer_z_order`. + +Since Phase 3 keeps `cfg.layers` and `session.layer_z_order` in sync (add/remove updates +both), `ctx.cfg.layers[slot]` is always present for every session slot. No special fallback +needed. + +### 7.2 `persist_layer_z_order` + +Already reads `session.layer_z_order` — no change. + +### 7.3 Dirty tracking + +`persisted_session_signature` computes the hash of `persisted_session_payload`. Because that +payload is built from the actual session + cfg (both updated on add/remove), dirtiness is +detected correctly with no extra logic. + +Adding a layer marks the config dirty (the new layer is in the payload but was not in the +last-saved signature). Removing a layer similarly. The user is prompted to save on quit as +usual. + +--- + +## Phase 8 — Tests and docs + +### 8.1 Unit tests + +Update any test that hard-codes `LAYER_SLOTS`, constructs a `CleaveConfig` with exactly +four layers, or uses `DEFAULT_STEM_FOR_SLOT`: + +- Replace `LAYER_SLOTS` references with the explicit list or the test's own slot set. +- Remove `_stub_cfg_for_session` and inline equivalent construction. +- Add tests: + - `next_layer_slot` returns correct names and raises at capacity. + - `parse_layers_section` accepts 1-layer and 8-layer configs; rejects 0 and 9. + - `persist_layers` round-trips a 3-layer and a 6-layer session. + - Timeline `visible_state_at` with 6 slots. + +### 8.2 `cleave-viz.yaml` template + +No change to the default content (still four layers). The template is not a constraint; the +schema now accepts any count. Remove any comment that says "must have exactly four." + +### 8.3 `.cursor/rules/project-context.mdc` + +Remove "four libprojectM layers". Update to "up to eight Milkdrop layers" or equivalent. + +### 8.4 `.cursor/rules/live-tuning-ui.mdc` + +- Remove `layer_1`..`layer_4` enumeration where it implies a fixed count. +- Document the two new row kinds: `LAYER_MANAGEMENT_ADD` and `LAYER_MANAGEMENT_DELETE`. +- Note num keys 1-8. +- Update timeline strip focus-ring `0..3` comment to `0..N-1`. + +### 8.5 `docs/roadmap.md` / `docs/todos.md` + +Remove or mark done any item about dynamic layers or the four-layer cap. Remove references +to four layers as the permanent stack size. + +--- + +## File change summary + +| File | Change | +|---|---| +| `cleave/config_schema.py` | Remove `LAYER_SLOTS`; add `MAX_LAYER_COUNT`, `MIN_LAYER_COUNT`, `DEFAULT_LAYER_SLOTS`, `next_layer_slot`, `new_layer_config`, `DEFAULT_NEW_LAYER_STEM`; relax parse; iterate session in persist | +| `cleave/config.py` | Un-freeze `CleaveConfig`; `layer_z_order: list[str]` | +| `cleave/gl_compositor.py` | Add `remove_layer_fbo` | +| `cleave/viz/layer_pipeline.py` | Add `build_single`, `destroy_single` | +| `cleave/preset_playlist.py` | Add `scan_single_layer` | +| `cleave/viz/session.py` | Add `add_layer_to_session`, `remove_layer_from_session` | +| `cleave/viz/wiring.py` | Add `LayerManager`; remove `_stub_cfg_for_session` | +| `cleave/viz/row_semantics.py` | Add `LAYER_MANAGEMENT_ADD`, `LAYER_MANAGEMENT_DELETE` kinds and behaviors | +| `cleave/viz/overlay.py` | Insert new rows in `build_row_layout`; add drawing for both | +| `cleave/viz/controls.py` | Accept `LayerManager`; add `_add_layer`, `_delete_layer`, `_rebuild_view` | +| `cleave/timeline.py` | Replace `LAYER_SLOTS` with caller-supplied slot list | +| `cleave/viz/timeline_controls.py` | Extend `_LAYER_KEY_INDEX` to 1-8 + numpad 1-8 | +| `cleave/viz/timeline_overlay.py` | Dynamic width probe | +| `cleave/viz/layer_visibility.py` | Remove any `LAYER_SLOTS` references | +| `cleave/config_snapshot.py` | `persist_layers` iterates `session.layer_z_order` | +| `cleave/viz/app.py` | Construct and pass `LayerManager` in `init_gl_resources_heavy` | +| `.cursor/rules/project-context.mdc` | Remove four-layer cap language | +| `.cursor/rules/live-tuning-ui.mdc` | Update row docs, num keys, slot refs | +| `docs/roadmap.md` / `docs/todos.md` | Remove fixed-layer entries | + +--- + +## Architectural notes + +**Why un-freeze `CleaveConfig`?** The frozen dataclass was a style choice, not a correctness +requirement. The `layers` dict field was already mutable even under `frozen=True`. Un-freezing +lets `layer_z_order` (previously a tuple) become a list, allowing in-place append/remove +without constructing a new `CleaveConfig` instance on every layer operation. Since `cfg` is +never used as a dict key or set member, the hashability guarantee of `frozen=True` is unused. + +**Why mutate `cfg` on add/remove?** `persist_layers` cross-references `cfg.layers` for +width/height (values not on `LayerRuntime`). Keeping `cfg.layers` in sync with the live +layer set means persist, dirty tracking, and the GL pipeline all use a single coherent view. +The alternative (a parallel "live layer config" dict) duplicates state and divergence risk. + +**Why incremental GL build?** Full pipeline teardown and rebuild on each add/remove would +disrupt all existing projectM waveforms and FBO state. `build_single`/`destroy_single` +leave untouched layers running continuously; only the added/removed FBO and projectM instance +are created/destroyed. The brief freeze is limited to one projectM init (roughly the same +as a preset load). + +**Why no warm-up for new layers?** The projectM warm-up in `LayerFramePipeline.warmup` is a +startup optimisation to avoid the white frame-zero flash before the first real frame. A +layer added mid-session will fade in from black naturally (its FBO starts opaque black) and +reaches a stable visual within a few frames. A per-add warm-up would require pausing +playback, which outweighs the cosmetic benefit. From a100492fba3806ae82e12e0a9f55e7ba86e736b6 Mon Sep 17 00:00:00 2001 From: SpoddyCoder Date: Sun, 21 Jun 2026 15:08:51 +0100 Subject: [PATCH 2/3] Initial implementation of full dynamic layers --- .cursor/rules/live-tuning-ui.mdc | 7 +- .cursor/rules/project-context.mdc | 2 +- README.md | 2 +- cleave/config.py | 18 +- cleave/config_schema.py | 110 ++++++---- cleave/gl_compositor.py | 9 + cleave/preset_playlist.py | 17 +- cleave/timeline.py | 9 +- cleave/viz/app.py | 15 +- cleave/viz/controls.py | 75 +++++++ cleave/viz/help_overlay.py | 13 ++ cleave/viz/layer_pipeline.py | 87 +++++--- cleave/viz/overlay.py | 43 +++- cleave/viz/row_semantics.py | 15 ++ cleave/viz/session.py | 16 ++ cleave/viz/timeline_controls.py | 8 + cleave/viz/timeline_overlay.py | 6 +- cleave/viz/wiring.py | 163 +++++++++++---- docs/dynamic-layers-plan.md | 77 +++++++ docs/roadmap.md | 4 +- tests/cleave/effects/test_flare.py | 9 +- tests/cleave/effects/test_flash.py | 17 +- tests/cleave/effects/test_grit.py | 13 +- tests/cleave/effects/test_hue.py | 9 +- tests/cleave/effects/test_pulse.py | 13 +- tests/cleave/test_config.py | 191 ++++++++++++++---- tests/cleave/test_config_snapshot.py | 223 ++++++++++++++------- tests/cleave/test_gl_compositor.py | 24 ++- tests/cleave/test_preset_playlist.py | 33 ++- tests/cleave/test_timeline.py | 21 +- tests/cleave/viz/test_app.py | 11 +- tests/cleave/viz/test_controls.py | 180 ++++++++++++++++- tests/cleave/viz/test_help_overlay.py | 14 ++ tests/cleave/viz/test_input_dispatch.py | 11 +- tests/cleave/viz/test_layer.py | 17 +- tests/cleave/viz/test_layer_manager.py | 164 +++++++++++++++ tests/cleave/viz/test_layer_pipeline.py | 38 ++++ tests/cleave/viz/test_overlay.py | 168 +++++++++++++++- tests/cleave/viz/test_render.py | 4 +- tests/cleave/viz/test_row_semantics.py | 14 +- tests/cleave/viz/test_session.py | 59 ++++++ tests/cleave/viz/test_timeline_controls.py | 41 +++- tests/cleave/viz/test_timeline_overlay.py | 34 +++- tests/cleave/viz/test_wiring.py | 6 +- tests/support/config.py | 30 +-- tests/support/viz.py | 13 +- 46 files changed, 1702 insertions(+), 351 deletions(-) create mode 100644 tests/cleave/viz/test_layer_manager.py create mode 100644 tests/cleave/viz/test_layer_pipeline.py create mode 100644 tests/cleave/viz/test_session.py diff --git a/.cursor/rules/live-tuning-ui.mdc b/.cursor/rules/live-tuning-ui.mdc index cd93591..9e080ed 100644 --- a/.cursor/rules/live-tuning-ui.mdc +++ b/.cursor/rules/live-tuning-ui.mdc @@ -18,17 +18,18 @@ Confirm, save-choice, and unsaved-quit prompts are drawn by [cleave/viz/modal_ov The scrollable block below the pinned header: all rows where `row_kind` is not in `HEADER_ROW_KINDS` from [cleave/viz/row_semantics.py](cleave/viz/row_semantics.py) (`track_row_count` counts these rows from `build_row_layout`). Row interaction groups (sub-row frozensets, repeat keys, help affordances) are defined in the same module. -- One **track block** per stem in `layer_z_order` (layer slot keys `layer_1`..`layer_4`; use `row_slot()` in [cleave/viz/overlay.py](cleave/viz/overlay.py) to read the slot from a row index). +- One **track block** per slot in `layer_z_order` (dynamic slots `layer_1`..`layer_8`; default four; use `row_slot()` in [cleave/viz/overlay.py](cleave/viz/overlay.py) to read the slot from a row index). - **Base rows** (always present per stem): header, preset dir, preset, stem, blend, opacity, beat sensitivity, then **cleave effects** header (`RowKind.TRACK_EFFECTS_HEADER`). - **Stem row** (`RowKind.TRACK_STEM`): Left/Right cycles `STEM_SOURCES`; follows standard locked sub-row rules (blocked when layer locked; only **cleave effects** header remains navigable among locked sub-rows). - **Effect sub-rows** (`RowKind.TRACK_EFFECT`): one per entry in [cleave/effects/registry.py](cleave/effects/registry.py) for that stem; visible only when both the track block and `TrackBlock.effects_expanded` are expanded. +- **Layer management rows** (after the last track block's effect sub-rows, before the render section gap): `RowKind.LAYER_MANAGEMENT_DELETE` (one per track block; Enter deletes that layer when more than one remains) and `RowKind.LAYER_MANAGEMENT_ADD` (single row at the bottom of the track section when fewer than eight layers; Enter adds a layer). Both are action rows (`RowAffordance.ACTION`); delete stays navigable when the layer is locked. - Per-stem state: `TrackBlock` in `TuningViewState.tracks` (`effects`, `effects_expanded`, plus preset/blend/opacity/beat fields). - On-screen header label: `Layer N: STEM` (user-facing "layer"; code often says "track"). - **cleave effects** header: lowercase label `└─ cleave effects` plus expand arrow; collapsed by default; sits after beat sensitivity; Left/Right toggles `effects_expanded`. Say **track rows section** for the whole upper block; **track row** for a single line. -After the four stem blocks, a blank **render section gap** row (`RowKind.RENDER_SECTION_GAP`) separates stems from render blocks. Then **Render: OVERLAY** (`RowKind.RENDER_OVERLAY_HEADER`) is always present. It is not reorderable (Enter does not enter move mode). Header label: `Render:` in `LABEL`, `OVERLAY` and expand arrow in value color. Sub-rows when expanded: position, opacity, border width, start delay, display time, title (expandable; font, font size, and margin-bottom sub-rows when expanded), body (expandable; font and font size sub-rows when expanded). State: `RenderOverlayBlock` / `RenderOverlayRuntime` on session ([cleave/viz/session.py](cleave/viz/session.py)); solo via `render_overlay_solo` (no audio impact). Disable clears solo and collapses. +After the last track block (and its layer management rows), a blank **render section gap** row (`RowKind.RENDER_SECTION_GAP`) separates stems from render blocks. Then **Render: OVERLAY** (`RowKind.RENDER_OVERLAY_HEADER`) is always present. It is not reorderable (Enter does not enter move mode). Header label: `Render:` in `LABEL`, `OVERLAY` and expand arrow in value color. Sub-rows when expanded: position, opacity, border width, start delay, display time, title (expandable; font, font size, and margin-bottom sub-rows when expanded), body (expandable; font and font size sub-rows when expanded). State: `RenderOverlayBlock` / `RenderOverlayRuntime` on session ([cleave/viz/session.py](cleave/viz/session.py)); solo via `render_overlay_solo` (no audio impact). Disable clears solo and collapses. Below overlay, **Render: POST FX** (`RowKind.RENDER_POST_FX_HEADER`) is always present. Same eye / expand / solo semantics as overlay (`render_post_fx_solo`; solo is not persisted). Sub-rows when expanded: fade in, fade out (seconds). State: `RenderPostFxBlock` / `RenderPostFxRuntime` on session ([cleave/viz/session.py](cleave/viz/session.py)). Disable clears solo and collapses. @@ -36,7 +37,7 @@ Below post-FX, **Render: TIMELINE** (`RowKind.RENDER_TIMELINE_HEADER`) is always ## 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** when `timeline.enabled` (strip only; **Down** enters submenu). **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 and enabled, **Up**/**Down** treat timeline rows as part of the main focus ring: order is main navigable rows (ending at **Render: TIMELINE**), then timeline rows 0..N-1, wrapping **Down** from the last timeline row to **TRANSPORT** and **Up** from **TRANSPORT** 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 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_stems` or when recording and the row is armed). Armed rows during record use `record_baseline` + `record_buffer` toggles only, not committed cues. Record start baseline is not drawn on the bar; only transition cues in `record_buffer` show ticks., **right** = committed cues at playhead only (`timeline_committed_visible`; updates on seek while paused preview leaves left eye on `TimelineRuntime.monitor`). **Enter** 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**-**4** 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_stems` only. **Shift+Enter** toggles override on the focused row while playing or paused (manual override via `override_stems` / `override_visible`; clears preview on enter; distinct from main-panel `session.solo_slot`; ignored when recording). Timeline strip does not use **Shift+Left** / **Shift+Right** for solo (transport seek). **r** start captures WYSIWYG baseline for armed stems, preserves override on unarmed stems, clears preview, unpauses if needed; stop punch-overwrites armed 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_stems` / `override_visible` are session-only on `TimelineRuntime`. Cue list persists under root `timeline:` in YAML (written last in snapshots). See [docs/timeline-idea.md](docs/timeline-idea.md). +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** when `timeline.enabled` (strip only; **Down** enters submenu). **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 and enabled, **Up**/**Down** treat timeline rows as part of the main focus ring: order is main navigable rows (ending at **Render: TIMELINE**), then timeline rows 0..N-1, wrapping **Down** from the last timeline row to **TRANSPORT** and **Up** from **TRANSPORT** 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 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_stems` or when recording and the row is armed). Armed rows during record use `record_baseline` + `record_buffer` toggles only, not committed cues. Record start baseline is not drawn on the bar; only transition cues in `record_buffer` show ticks., **right** = committed cues at playhead only (`timeline_committed_visible`; updates on seek while paused preview leaves left eye on `TimelineRuntime.monitor`). **Enter** 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_stems` only. **Shift+Enter** toggles override on the focused row while playing or paused (manual override via `override_stems` / `override_visible`; clears preview on enter; distinct from main-panel `session.solo_slot`; ignored when recording). Timeline strip does not use **Shift+Left** / **Shift+Right** for solo (transport seek). **r** start captures WYSIWYG baseline for armed stems, preserves override on unarmed stems, clears preview, unpauses if needed; stop punch-overwrites armed 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_stems` / `override_visible` are session-only on `TimelineRuntime`. Cue list persists under root `timeline:` in YAML (written last in snapshots). See [docs/timeline-idea.md](docs/timeline-idea.md). ## Header rows section diff --git a/.cursor/rules/project-context.mdc b/.cursor/rules/project-context.mdc index 6a542f8..da03aa6 100644 --- a/.cursor/rules/project-context.mdc +++ b/.cursor/rules/project-context.mdc @@ -5,7 +5,7 @@ alwaysApply: true # Cleave project context -- BAU iterative development. Visualizer: `python -m cleave play` via [cleave/cli.py](cleave/cli.py) calling `cleave.viz.launch()`; [cleave.py](cleave.py) is an alias; implementation in [cleave/viz/](cleave/viz/) (four libprojectM layers, 1280x720 / 30 fps default, stem PCM). Black-key stack in [cleave/gl_compositor.py](cleave/gl_compositor.py). Live tuning: session in [cleave/viz/session.py](cleave/viz/session.py), input in [cleave/viz/controls.py](cleave/viz/controls.py), view state in [cleave/viz/tuning_view_state.py](cleave/viz/tuning_view_state.py), overlay draw in [cleave/viz/overlay.py](cleave/viz/overlay.py); shared live/offline frame finish in [cleave/viz/frame_finish.py](cleave/viz/frame_finish.py). Render credits overlay in [cleave/viz/render_overlay.py](cleave/viz/render_overlay.py) (`render.overlay` in YAML): live preview in play, burned in by [cleave/viz/render.py](cleave/viz/render.py) offline. Cleave effects: [cleave/effects/](cleave/effects/) (dispatch via [cleave/effects/handlers.py](cleave/effects/handlers.py)). Paths: [cleave/paths.py](cleave/paths.py) (repo-root data dir by default, `projects//`; `CLEAVE_DATA` override). Config: parse and defaults in [cleave/config_schema.py](cleave/config_schema.py); repo-root [cleave-viz.yaml](cleave-viz.yaml) copied into projects; `unnamed-N.yaml` snapshots via [cleave/config_snapshot.py](cleave/config_snapshot.py). Shared easing: [cleave/easing.py](cleave/easing.py). Architecture conventions: [.cursor/rules/architecture-principles.mdc](.cursor/rules/architecture-principles.mdc). +- BAU iterative development. Visualizer: `python -m cleave play` via [cleave/cli.py](cleave/cli.py) calling `cleave.viz.launch()`; [cleave.py](cleave.py) is an alias; implementation in [cleave/viz/](cleave/viz/) (up to eight Milkdrop/libprojectM layers, default four; add/remove in live tuning, 1280x720 / 30 fps default, stem PCM). Black-key stack in [cleave/gl_compositor.py](cleave/gl_compositor.py). Live tuning: session in [cleave/viz/session.py](cleave/viz/session.py), input in [cleave/viz/controls.py](cleave/viz/controls.py), view state in [cleave/viz/tuning_view_state.py](cleave/viz/tuning_view_state.py), overlay draw in [cleave/viz/overlay.py](cleave/viz/overlay.py); shared live/offline frame finish in [cleave/viz/frame_finish.py](cleave/viz/frame_finish.py). Render credits overlay in [cleave/viz/render_overlay.py](cleave/viz/render_overlay.py) (`render.overlay` in YAML): live preview in play, burned in by [cleave/viz/render.py](cleave/viz/render.py) offline. Cleave effects: [cleave/effects/](cleave/effects/) (dispatch via [cleave/effects/handlers.py](cleave/effects/handlers.py)). Paths: [cleave/paths.py](cleave/paths.py) (repo-root data dir by default, `projects//`; `CLEAVE_DATA` override). Config: parse and defaults in [cleave/config_schema.py](cleave/config_schema.py); repo-root [cleave-viz.yaml](cleave-viz.yaml) copied into projects; `unnamed-N.yaml` snapshots via [cleave/config_snapshot.py](cleave/config_snapshot.py). Shared easing: [cleave/easing.py](cleave/easing.py). Architecture conventions: [.cursor/rules/architecture-principles.mdc](.cursor/rules/architecture-principles.mdc). - Must-do list: [docs/todos.md](docs/todos.md). Aspirational: [docs/roadmap.md](docs/roadmap.md). - See [README.md](README.md) for usage. - Project layout: `projects//` under repo root (or `CLEAVE_DATA` override) with copied mix audio, `project.yaml`, `signals.json`, `stems/` (four stem wavs), and optional configs. `separate` copies the source file, writes the manifest, runs Demucs, and writes `signals.json`; the visualizer reads the mix from `project.yaml`. CLI: `python -m cleave separate|play` (or `python cleave.py`, same entry point). diff --git a/README.md b/README.md index ea6a0e1..f13afee 100644 --- a/README.md +++ b/README.md @@ -127,7 +127,7 @@ Controls... #### Compositing -* The visualizer is four libprojectM layers at tiered resolutions, composited to **1280x720 @ 30 fps** by default (editable `cleave-viz.yaml`) +* The visualizer supports up to eight libprojectM layers (default four; add/remove in live tuning) at tiered resolutions, composited to **1280x720 @ 30 fps** by default (editable `cleave-viz.yaml`) * Each layer's libprojectM instance receives PCM from its assigned stem; stereo stems are fed as stereo (mono sources stay mono). * Milkdrop draws on black, so cleave treats black as transparent and uses pixel brightness as blend weight (`black-key` default). diff --git a/cleave/config.py b/cleave/config.py index 1cb1962..69ff182 100644 --- a/cleave/config.py +++ b/cleave/config.py @@ -162,13 +162,13 @@ class TimelineConfig: cues: tuple[TimelineCue, ...] -@dataclass(frozen=True) +@dataclass class CleaveConfig: paths: PathsConfig layers: dict[str, LayerConfig] visualizer: VisualizerConfig config_path: Path - layer_z_order: tuple[str, ...] = DEFAULT_LAYER_Z_ORDER + layer_z_order: list[str] = field(default_factory=lambda: list(DEFAULT_LAYER_Z_ORDER)) render: RenderConfig | None = None timeline: TimelineConfig | None = None @@ -282,10 +282,14 @@ def _validate_presets(layers: dict[str, LayerConfig]) -> None: ) -def _parse_layers(data: dict[str, Any], preset_root: Path) -> dict[str, LayerConfig]: +def _parse_layers( + data: dict[str, Any], preset_root: Path +) -> tuple[dict[str, LayerConfig], "ParseCtx"]: from cleave.config_schema import ParseCtx - return parse_layers_section(data, ParseCtx(preset_root=preset_root)) + ctx = ParseCtx(preset_root=preset_root) + layers = parse_layers_section(data, ctx) + return layers, ctx def load_config( @@ -311,9 +315,9 @@ def load_config( paths = _parse_paths(data) visualizer = parse_visualizer_section(data) render = parse_render_section(data) - timeline = parse_timeline_section(data) - layer_z_order = parse_layer_z_order_section(data) - layers = _parse_layers(data, paths.preset_root) + layers, parse_ctx = _parse_layers(data, paths.preset_root) + layer_z_order = parse_layer_z_order_section(data, parse_ctx) + timeline = parse_timeline_section(data, parse_ctx) _validate_presets(layers) return CleaveConfig( diff --git a/cleave/config_schema.py b/cleave/config_schema.py index 8688bfe..ff0c03b 100644 --- a/cleave/config_schema.py +++ b/cleave/config_schema.py @@ -3,6 +3,7 @@ from __future__ import annotations import os +import re from dataclasses import dataclass from pathlib import Path from typing import Any, Callable, Literal, TypeVar @@ -27,16 +28,47 @@ # --- Layer defaults --- -LAYER_SLOTS: tuple[str, ...] = ("layer_1", "layer_2", "layer_3", "layer_4") +MAX_LAYER_COUNT = 8 +MIN_LAYER_COUNT = 1 +DEFAULT_LAYER_SLOTS = ("layer_1", "layer_2", "layer_3", "layer_4") +DEFAULT_LAYER_Z_ORDER: list[str] = list(DEFAULT_LAYER_SLOTS) +DEFAULT_NEW_LAYER_STEM: StemSource = "full_mix" -DEFAULT_LAYER_Z_ORDER = LAYER_SLOTS +_SLOT_RE = re.compile(r"^layer_(\d+)$") -DEFAULT_STEM_FOR_SLOT: dict[str, StemSource] = { - "layer_1": "drums", - "layer_2": "bass", - "layer_3": "vocals", - "layer_4": "other", -} + +def _valid_slot(key: str) -> int | None: + m = _SLOT_RE.match(key) + if m: + n = int(m.group(1)) + if 1 <= n <= MAX_LAYER_COUNT: + return n + return None + + +def next_layer_slot(existing_slots: list[str]) -> str: + used = set(existing_slots) + for i in range(1, MAX_LAYER_COUNT + 1): + candidate = f"layer_{i}" + if candidate not in used: + return candidate + raise ValueError(f"Maximum {MAX_LAYER_COUNT} layers already present") + + +def new_layer_config(slot: str, preset: Path, preset_root: Path) -> Any: + from cleave.config import LayerConfig + + w, h = LAYER_DEFAULT_SIZE[DEFAULT_NEW_LAYER_STEM] + return LayerConfig( + preset=preset, + stem=DEFAULT_NEW_LAYER_STEM, + enabled=True, + opacity=1.0, + width=w, + height=h, + blend_mode=DEFAULT_BLEND_MODE[DEFAULT_NEW_LAYER_STEM], + locked=False, + ) DEFAULT_BLEND_MODE: dict[StemSource, BlendMode] = { "drums": "add", @@ -157,6 +189,7 @@ def key(self) -> str: @dataclass class ParseCtx: preset_root: Path | None = None + layer_slots: tuple[str, ...] | None = None @dataclass @@ -767,21 +800,24 @@ def persist_visualizer(ctx: PersistCtx) -> dict[str, Any]: return _dump_fields(VISUALIZER_FIELDS, values, ctx) -def parse_layer_z_order_section(data: dict[str, Any]) -> tuple[str, ...]: +def parse_layer_z_order_section(data: dict[str, Any], ctx: ParseCtx) -> list[str]: + if ctx.layer_slots is None: + raise ValueError("layer_slots required to parse layer_z_order") + layer_slots = ctx.layer_slots raw = data.get("layer_z_order") if raw is None: - return DEFAULT_LAYER_Z_ORDER + return list(layer_slots) if not isinstance(raw, list): raise ValueError("layer_z_order must be a list") - if len(raw) != len(LAYER_SLOTS): + if len(raw) != len(layer_slots): raise ValueError( - f"layer_z_order must contain exactly {len(LAYER_SLOTS)} entries" + f"layer_z_order must contain exactly {len(layer_slots)} entries" ) - if set(raw) != set(LAYER_SLOTS): + if set(raw) != set(layer_slots): raise ValueError( - f"layer_z_order must contain each of {', '.join(LAYER_SLOTS)} exactly once" + f"layer_z_order must contain each of {', '.join(layer_slots)} exactly once" ) - return tuple(raw) + return list(raw) def persist_layer_z_order(ctx: PersistCtx) -> list[str]: @@ -789,8 +825,6 @@ def persist_layer_z_order(ctx: PersistCtx) -> list[str]: cfg_order = list(ctx.cfg.layer_z_order) if len(order) == len(cfg_order) and set(order) == set(cfg_order): return list(order) - if len(order) == len(LAYER_SLOTS) and set(order) == set(LAYER_SLOTS): - return list(order) return cfg_order @@ -807,7 +841,7 @@ def parse_blend_mode(slot: str, stem: StemSource, layer_raw: dict[str, Any]) -> def _parse_stem(slot: str, layer_raw: dict[str, Any]) -> StemSource: raw = layer_raw.get("stem") if raw is None: - return DEFAULT_STEM_FOR_SLOT[slot] + return DEFAULT_NEW_LAYER_STEM if raw not in STEM_SOURCES: allowed = ", ".join(STEM_SOURCES) raise ValueError(f"layers.{slot}.stem must be one of: {allowed}") @@ -867,19 +901,23 @@ def parse_layers_section(data: dict[str, Any], ctx: ParseCtx) -> dict[str, Any]: preset_root = ctx.preset_root layers_raw = as_mapping(data.get("layers"), "layers") - unknown = sorted(set(layers_raw) - set(LAYER_SLOTS)) - if unknown: - raise ValueError( - f"unknown layer keys in config (expected {', '.join(LAYER_SLOTS)}): " - + ", ".join(unknown) - ) + if not layers_raw: + raise ValueError("layers section must contain at least one layer") + for key in layers_raw: + if _valid_slot(key) is None: + raise ValueError( + f"invalid layer key '{key}': must be layer_1 .. layer_{MAX_LAYER_COUNT}" + ) + if len(layers_raw) < MIN_LAYER_COUNT: + raise ValueError("layers section must contain at least one layer") + if len(layers_raw) > MAX_LAYER_COUNT: + raise ValueError(f"layers section must contain at most {MAX_LAYER_COUNT} layers") - missing = [slot for slot in LAYER_SLOTS if slot not in layers_raw] - if missing: - raise ValueError(f"missing layer config for: {', '.join(missing)}") + layer_keys = sorted(layers_raw, key=lambda k: _valid_slot(k) or 0) + ctx.layer_slots = tuple(layer_keys) layers: dict[str, LayerConfig] = {} - for slot in LAYER_SLOTS: + for slot in layer_keys: layer_raw = as_mapping(layers_raw[slot], f"layers.{slot}") preset_raw = layer_raw.get("preset") if not preset_raw: @@ -912,7 +950,7 @@ def persist_layers(ctx: PersistCtx) -> dict[str, dict[str, Any]]: layers_out: dict[str, dict[str, Any]] = {} global_beat = ctx.cfg.visualizer.beat_sensitivity - for slot in LAYER_SLOTS: + for slot in ctx.session.layer_z_order: layer_cfg = ctx.cfg.layers[slot] stem = layer_cfg.stem if slot in ctx.session.layers: @@ -1073,7 +1111,7 @@ def persist_render(ctx: PersistCtx) -> dict[str, Any]: return {"overlay": overlay, "post_fx": post_fx} -def parse_timeline_section(data: dict[str, Any]) -> Any | None: +def parse_timeline_section(data: dict[str, Any], ctx: ParseCtx) -> Any | None: from cleave.config import TimelineConfig timeline = data.get("timeline") @@ -1099,11 +1137,14 @@ def parse_timeline_section(data: dict[str, Any]) -> Any | None: cue_map.get("layers"), f"timeline.cues[{index}].layers", ) - unknown = sorted(set(layers_raw) - set(LAYER_SLOTS)) + if ctx.layer_slots is None: + raise ValueError("layer_slots required to parse timeline") + allowed_slots = ctx.layer_slots + unknown = sorted(set(layers_raw) - set(allowed_slots)) if unknown: raise ValueError( f"unknown layer keys in timeline.cues[{index}].layers " - f"(expected {', '.join(LAYER_SLOTS)}): " + f"(expected {', '.join(allowed_slots)}): " + ", ".join(unknown) ) layers = {stem: bool(layers_raw[stem]) for stem in layers_raw} @@ -1173,8 +1214,9 @@ def template_visualizer_section(*, name: str = "cleave-viz-example") -> dict[str return out -def template_layer_entry(slot: str) -> dict[str, Any]: - stem = DEFAULT_STEM_FOR_SLOT[slot] +def template_layer_entry( + slot: str, stem: StemSource = DEFAULT_NEW_LAYER_STEM +) -> dict[str, Any]: width, height = LAYER_DEFAULT_SIZE[stem] return { "stem": stem, diff --git a/cleave/gl_compositor.py b/cleave/gl_compositor.py index 565098f..7dcba0f 100644 --- a/cleave/gl_compositor.py +++ b/cleave/gl_compositor.py @@ -360,6 +360,15 @@ def create_layer_fbo( self._layers.append(layer) return layer + def remove_layer_fbo(self, name: str) -> None: + """Destroy the named FBO and remove it from the compositor stack.""" + for i, fbo in enumerate(self._layers): + if fbo.name == name: + fbo.destroy() + del self._layers[i] + return + raise ValueError(f"no layer FBO named {name!r}") + def _bind_content_fbo(self) -> None: glBindFramebuffer(GL_FRAMEBUFFER, self._content_fbo_id) glUseProgram(0) diff --git a/cleave/preset_playlist.py b/cleave/preset_playlist.py index 71797c8..7fe5e0b 100644 --- a/cleave/preset_playlist.py +++ b/cleave/preset_playlist.py @@ -2,12 +2,12 @@ from __future__ import annotations +import random from dataclasses import dataclass from pathlib import Path from typing import TYPE_CHECKING from cleave.config import CleaveConfig -from cleave.config_schema import LAYER_SLOTS if TYPE_CHECKING: from cleave.projectm import ProjectM @@ -159,6 +159,19 @@ def config_preset_path(self, preset_root: Path) -> str: return rel if rel.endswith("/") else f"{rel}/" +def scan_single_layer( + slot: str, + preset_root: Path, + project_dir: Path, +) -> PresetPlaylist: + resolved_root = preset_root.resolve() + paths = list(resolved_root.rglob("*.milk")) + if paths: + anchor = random.choice(paths) + return scan_preset_playlist(anchor) + return scan_preset_playlist(resolved_root) + + def scan_preset_playlist(anchor: Path) -> PresetPlaylist: """Build a playlist from a .milk file or a directory of presets.""" resolved = anchor.resolve() @@ -226,6 +239,6 @@ def scan_all_layers(cfg: CleaveConfig) -> dict[str, PresetPlaylist]: """Scan one preset playlist per configured layer.""" return { slot: scan_preset_playlist(cfg.layers[slot].preset) - for slot in LAYER_SLOTS + for slot in cfg.layer_z_order if slot in cfg.layers } diff --git a/cleave/timeline.py b/cleave/timeline.py index 4863488..c39297f 100644 --- a/cleave/timeline.py +++ b/cleave/timeline.py @@ -37,10 +37,8 @@ def layer_visible_at( slot: str, t_sec: float, ) -> bool: - from cleave.config_schema import LAYER_SLOTS - if slot not in defaults: - allowed = ", ".join(LAYER_SLOTS) + allowed = ", ".join(sorted(defaults)) raise ValueError(f"unknown slot: {slot!r} (expected one of: {allowed})") visible = defaults[slot] for cue in sorted(cues, key=lambda c: c.t): @@ -54,12 +52,11 @@ def layer_visible_at( def visible_state_at( cues: list[TimelineCue], defaults: dict[str, bool], + slots: list[str], t_sec: float, ) -> dict[str, bool]: - from cleave.config_schema import LAYER_SLOTS - return { - slot: layer_visible_at(cues, defaults, slot, t_sec) for slot in LAYER_SLOTS + slot: layer_visible_at(cues, defaults, slot, t_sec) for slot in slots } diff --git a/cleave/viz/app.py b/cleave/viz/app.py index c4af01b..4b0d937 100644 --- a/cleave/viz/app.py +++ b/cleave/viz/app.py @@ -39,7 +39,7 @@ dispatch_should_notify_overlay, key_handler_for_runtime, ) -from cleave.viz.wiring import make_timeline_controls, make_tuning_controls +from cleave.viz.wiring import LayerManager, make_timeline_controls, make_tuning_controls @dataclass @@ -179,6 +179,18 @@ def report(message: str) -> None: playback = init_playback(mix_player) modal_host = ModalHost() + layer_manager = LayerManager( + cfg=seed.cfg, + session=seed.session, + compositor=compositor, + layers=layers, + layers_by_slot=layers_by_slot, + playlists=seed.playlists, + preset_root=seed.preset_root, + project_dir=seed.project_dir, + fps=seed.cfg.visualizer.fps, + texture_paths=list(seed.cfg.paths.texture_paths), + ) controls = make_tuning_controls( session=seed.session, cfg=seed.cfg, @@ -193,6 +205,7 @@ def report(message: str) -> None: pcm_bank=seed.pcm_bank, mix_player=mix_player, modal_host=modal_host, + layer_manager=layer_manager, ) timeline_controls = make_timeline_controls( session=seed.session, diff --git a/cleave/viz/controls.py b/cleave/viz/controls.py index 12e353b..f3ca196 100644 --- a/cleave/viz/controls.py +++ b/cleave/viz/controls.py @@ -5,10 +5,12 @@ import time from collections.abc import Callable from pathlib import Path +from typing import TYPE_CHECKING import pygame from cleave.config import CleaveConfig, clamp_beat_sensitivity, clamp_effect_pct +from cleave.config_schema import MAX_LAYER_COUNT from cleave.effects.registry import effect_row_count from cleave.blend_modes import BLEND_MODES, BlendMode from cleave.extract import STEM_SOURCES @@ -41,6 +43,9 @@ row_slot, ) from cleave.viz.session import TuningSession + +if TYPE_CHECKING: + from cleave.viz.wiring import LayerManager from cleave.viz.tuning_view_state import TuningViewStateBuilder TOAST_DURATION_SEC = 5.0 @@ -65,6 +70,7 @@ def __init__( launch_config_path: Path | None = None, repo_root_example: Path | None = None, modal_host: ModalHost | None = None, + layer_manager: LayerManager | None = None, ) -> None: self.session = session self.cfg = cfg @@ -72,6 +78,7 @@ def __init__( self.playback = playback self.duration_sec = duration_sec self._layer_bindings = layer_bindings + self._layer_manager = layer_manager self._modal_host = modal_host if modal_host is not None else ModalHost() self.focus_index = 0 @@ -264,6 +271,14 @@ def handle_keydown(self, event: pygame.event.Event) -> bool: if event.key == pygame.K_RETURN: view = self.build_view_state(paused=self.playback.paused) kind = row_kind(view, self.focus_index) + if kind == RowKind.LAYER_MANAGEMENT_ADD: + self._add_layer() + return True + if kind == RowKind.LAYER_MANAGEMENT_DELETE: + slot = row_slot(view, self.focus_index) + if slot is not None: + self._delete_layer(slot) + return True if kind == RowKind.TRACK_PRESET_DIR: slot = row_slot(view, self.focus_index) if slot is not None: @@ -411,6 +426,66 @@ def _confirm_move_mode(self) -> None: self.move_mode_slot = None self._move_mode_original_z_order = None + def _rebuild_view(self, *, nav_pos: int | None = None) -> None: + if self.move_mode_slot is not None: + self._confirm_move_mode() + view = self.build_view_state(paused=self.playback.paused) + navigable = navigable_row_indices(view) + if not navigable: + return + if nav_pos is None: + try: + nav_pos = navigable.index(self.focus_index) + except ValueError: + nav_pos = 0 + self.focus_index = navigable[min(nav_pos, len(navigable) - 1)] + + def _add_layer(self) -> None: + if self._layer_manager is None: + return + if not self._layer_manager.can_add(): + self.show_toast(f"Maximum {MAX_LAYER_COUNT} layers") + return + self._modal_host.prompt_yes_no( + "Add new Milkdrop visualisation layer?", + on_confirm=self._confirm_add_layer, + ) + + def _confirm_add_layer(self) -> None: + if self._layer_manager is None: + return + self._layer_manager.add_layer() + self._rebuild_view() + + def _delete_layer(self, slot: str) -> None: + if self._layer_manager is None: + return + if not self._layer_manager.can_remove(): + self.show_toast("Must have at least 1 layer") + return + self._modal_host.prompt_yes_no( + "Delete this Milkdrop visualisation layer?", + on_confirm=lambda: self._confirm_delete_layer(slot), + ) + + def _confirm_delete_layer(self, slot: str) -> None: + if self._layer_manager is None: + return + view = self.build_view_state(paused=self.playback.paused) + navigable = navigable_row_indices(view) + try: + nav_pos = navigable.index(self.focus_index) + except ValueError: + nav_pos = 0 + self._layer_manager.remove_layer(slot) + self._rebuild_view(nav_pos=nav_pos) + tl = self.session.timeline + row_count = len(self.session.layer_z_order) + if row_count == 0: + tl.submenu_focused = False + elif tl.focus_row >= row_count: + tl.focus_row = row_count - 1 + 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 diff --git a/cleave/viz/help_overlay.py b/cleave/viz/help_overlay.py index defb274..ac17f08 100644 --- a/cleave/viz/help_overlay.py +++ b/cleave/viz/help_overlay.py @@ -218,6 +218,19 @@ def _sections_for( primary = _PRESET_SECTION elif behavior.affordance == RowAffordance.SEEK: primary = _TRANSPORT_SECTION + elif row_kind == RowKind.LAYER_MANAGEMENT_ADD: + primary = HelpSection( + behavior.help_title or "Add new layer", + (("Enter", "confirm add"),), + ) + elif row_kind == RowKind.LAYER_MANAGEMENT_DELETE: + primary = HelpSection( + behavior.help_title or "Delete layer", + ( + ("Enter", "confirm delete"), + ("", "at least 1 layer required"), + ), + ) elif behavior.affordance == RowAffordance.ACTION: primary = _SAVE_SECTION diff --git a/cleave/viz/layer_pipeline.py b/cleave/viz/layer_pipeline.py index 8198223..a8df06f 100644 --- a/cleave/viz/layer_pipeline.py +++ b/cleave/viz/layer_pipeline.py @@ -2,7 +2,9 @@ from __future__ import annotations -from cleave.config import CleaveConfig +from pathlib import Path + +from cleave.config import CleaveConfig, LayerConfig from cleave.effects.runtime import EffectRuntime from cleave.gl_compositor import GlCompositor from cleave.gl_post_process import GlPostProcess @@ -90,6 +92,55 @@ def _apply_layer_grit(layer: StemLayer, post_process: GlPostProcess | None) -> N class LayerFramePipeline: """Per-frame GL path for stem layers.""" + @staticmethod + def build_single( + slot: str, + layer_cfg: LayerConfig, + compositor: GlCompositor, + playlist: PresetPlaylist, + fps: int, + texture_paths: list[Path], + beat_sensitivity: float, + ) -> StemLayer: + w, h = layer_cfg.width, layer_cfg.height + + pm = ProjectM() + pm.set_window_size(w, h) + if texture_paths: + pm.set_texture_paths(texture_paths) + playlist.load_into(pm) + pm.lock_preset(True) + pm.set_hard_cut_enabled(False) + pm.set_fps(fps) + pm.set_beat_sensitivity(beat_sensitivity) + + fbo = compositor.create_layer_fbo( + slot, + w, + h, + opacity=layer_cfg.opacity, + blend_mode=layer_cfg.blend_mode, + ) + fbo.enabled = layer_cfg.enabled + return StemLayer( + slot=slot, + pm=pm, + fbo=fbo, + playlist=playlist, + ) + + @staticmethod + def destroy_single( + slot: str, + layers: list[StemLayer], + layers_by_slot: dict[str, StemLayer], + compositor: GlCompositor, + ) -> None: + layer = layers_by_slot.pop(slot) + layers.remove(layer) + layer.pm.destroy() + compositor.remove_layer_fbo(slot) + @staticmethod def build( cfg: CleaveConfig, @@ -101,33 +152,15 @@ def build( runtimes: list[StemLayer] = [] for slot, layer_cfg in cfg.layers_in_z_order(): - w, h = layer_cfg.width, layer_cfg.height - playlist = playlists[slot] - - pm = ProjectM() - pm.set_window_size(w, h) - if texture_paths: - pm.set_texture_paths(texture_paths) - playlist.load_into(pm) - pm.lock_preset(True) - pm.set_hard_cut_enabled(False) - pm.set_fps(fps) - pm.set_beat_sensitivity(_beat_sensitivity(cfg, slot)) - - fbo = compositor.create_layer_fbo( - slot, - w, - h, - opacity=layer_cfg.opacity, - blend_mode=layer_cfg.blend_mode, - ) - fbo.enabled = layer_cfg.enabled runtimes.append( - StemLayer( - slot=slot, - pm=pm, - fbo=fbo, - playlist=playlist, + LayerFramePipeline.build_single( + slot, + layer_cfg, + compositor, + playlists[slot], + fps, + texture_paths, + _beat_sensitivity(cfg, slot), ) ) diff --git a/cleave/viz/overlay.py b/cleave/viz/overlay.py index cb45ce0..19be24f 100644 --- a/cleave/viz/overlay.py +++ b/cleave/viz/overlay.py @@ -206,6 +206,11 @@ def build_row_layout(state: TuningViewState) -> list[RowDescriptor]: driver_slug=effect_def.driver_slug, ) ) + if block.expanded: + rows.append( + RowDescriptor(RowKind.LAYER_MANAGEMENT_DELETE, slot=slot) + ) + rows.append(RowDescriptor(RowKind.LAYER_MANAGEMENT_ADD)) if state.render_timeline.enabled: rows.append(RowDescriptor(RowKind.TIMELINE_LAYER_HINT)) rows.append(RowDescriptor(RowKind.RENDER_SECTION_GAP)) @@ -400,6 +405,11 @@ def _row_text(state: TuningViewState, index: int) -> str: if kind == RowKind.TIMELINE_LAYER_HINT: return TIMELINE_LAYER_HINT_TEXT + if kind == RowKind.LAYER_MANAGEMENT_ADD: + return "ADD NEW LAYER" + if kind == RowKind.LAYER_MANAGEMENT_DELETE: + return "Delete Layer" + if kind == RowKind.RENDER_OVERLAY_HEADER: arrow = "▼" if state.render_overlay.expanded else "▶" return f"Render: OVERLAY {arrow}" @@ -589,10 +599,11 @@ def _render_label_value_row( value: str, value_color: tuple[int, int, int], line_height: int, + prefix_color: tuple[int, int, int] | None = None, suffix_surf: pygame.Surface | None = None, suffix_gap: int = 0, ) -> pygame.Surface: - prefix_surf = font.render(prefix, True, LABEL) + prefix_surf = font.render(prefix, True, prefix_color if prefix_color is not None else LABEL) value_surf = font.render(value, True, value_color) label_w = prefix_surf.get_width() + value_surf.get_width() if suffix_surf is not None: @@ -773,6 +784,8 @@ def fit_row_text( return "" if kind == RowKind.TIMELINE_LAYER_HINT: return TIMELINE_LAYER_HINT_TEXT + if kind in {RowKind.LAYER_MANAGEMENT_ADD, RowKind.LAYER_MANAGEMENT_DELETE}: + return _row_text(state, index) if kind == RowKind.RENDER_OVERLAY_HEADER: expanded = state.render_overlay.expanded return ( @@ -814,6 +827,10 @@ def _row_indent(state: TuningViewState, index: int) -> int: return 0 if kind == RowKind.TIMELINE_LAYER_HINT: return 0 + if kind == RowKind.LAYER_MANAGEMENT_ADD: + return 0 + if kind == RowKind.LAYER_MANAGEMENT_DELETE: + return TREE_INDENT if kind == RowKind.TRACK_EFFECT: return TREE_INDENT * 2 if kind in RENDER_OVERLAY_TITLE_NESTED_KINDS | RENDER_OVERLAY_BODY_NESTED_KINDS: @@ -854,6 +871,13 @@ def _row_value_color(state: TuningViewState, index: int) -> tuple[int, int, int] if kind == RowKind.TIMELINE_LAYER_HINT: return DISABLED + if kind == RowKind.LAYER_MANAGEMENT_ADD: + return LABEL + if kind == RowKind.LAYER_MANAGEMENT_DELETE: + if len(state.layer_z_order) == 1: + return DISABLED + return LABEL + stem = row_slot(state, index) if kind == RowKind.CONFIG_HEADER: if state.solo_active: @@ -1492,6 +1516,23 @@ def draw( row_surfaces.append(surf) row_time_surfaces.append(None) row_widths.append(indent + surf.get_width()) + elif kind in { + RowKind.LAYER_MANAGEMENT_ADD, + RowKind.LAYER_MANAGEMENT_DELETE, + }: + label = _row_text(state, index) + label_color = _row_value_color(state, index) + surf = _render_label_value_row( + font, + prefix=label, + value="", + value_color=label_color, + prefix_color=label_color, + line_height=line_h, + ) + row_surfaces.append(surf) + row_time_surfaces.append(None) + row_widths.append(indent + surf.get_width()) else: text = fit_row_text( font, state, index, max_content_width=max_content_width diff --git a/cleave/viz/row_semantics.py b/cleave/viz/row_semantics.py index 260a6df..b89c5ac 100644 --- a/cleave/viz/row_semantics.py +++ b/cleave/viz/row_semantics.py @@ -16,6 +16,8 @@ class RowKind(Enum): TRACK_BEAT = auto() TRACK_EFFECTS_HEADER = auto() TRACK_EFFECT = auto() + LAYER_MANAGEMENT_ADD = auto() + LAYER_MANAGEMENT_DELETE = auto() TIMELINE_LAYER_HINT = auto() RENDER_SECTION_GAP = auto() RENDER_OVERLAY_HEADER = auto() @@ -133,6 +135,19 @@ class RowBehavior: help_title="Cleave Effects", parent_group="track", ), + RowKind.LAYER_MANAGEMENT_ADD: RowBehavior( + RowAffordance.ACTION, + help_title="Add new layer", + navigable=True, + ), + RowKind.LAYER_MANAGEMENT_DELETE: RowBehavior( + RowAffordance.ACTION, + help_title="Delete layer", + navigable=True, + parent_group="track", + blocked_by_layer_lock=False, + navigable_when_layer_locked=True, + ), RowKind.TIMELINE_LAYER_HINT: RowBehavior( RowAffordance.DISPLAY, navigable=False, diff --git a/cleave/viz/session.py b/cleave/viz/session.py index eb8af92..5e2d356 100644 --- a/cleave/viz/session.py +++ b/cleave/viz/session.py @@ -205,3 +205,19 @@ def session_from_cfg( for slot, layer_cfg in cfg.layers.items() }, ) + + +def add_layer_to_session( + session: TuningSession, + slot: str, + runtime: LayerRuntime, +) -> None: + session.layers[slot] = runtime + session.layer_z_order.append(slot) + + +def remove_layer_from_session(session: TuningSession, slot: str) -> None: + session.layer_z_order.remove(slot) + del session.layers[slot] + if session.solo_slot == slot: + session.solo_slot = None diff --git a/cleave/viz/timeline_controls.py b/cleave/viz/timeline_controls.py index 69544aa..a31cd00 100644 --- a/cleave/viz/timeline_controls.py +++ b/cleave/viz/timeline_controls.py @@ -27,10 +27,18 @@ pygame.K_2: 1, pygame.K_3: 2, pygame.K_4: 3, + pygame.K_5: 4, + pygame.K_6: 5, + pygame.K_7: 6, + pygame.K_8: 7, pygame.K_KP1: 0, pygame.K_KP2: 1, pygame.K_KP3: 2, pygame.K_KP4: 3, + pygame.K_KP5: 4, + pygame.K_KP6: 5, + pygame.K_KP7: 6, + pygame.K_KP8: 7, } diff --git a/cleave/viz/timeline_overlay.py b/cleave/viz/timeline_overlay.py index aeb4b86..0aa27a2 100644 --- a/cleave/viz/timeline_overlay.py +++ b/cleave/viz/timeline_overlay.py @@ -61,7 +61,7 @@ class TimelineViewState: defaults: dict[str, bool] position_sec: float duration_sec: float - focus_row: int # 0..3, index into layer_z_order (0 = bottom stem) + focus_row: int # 0..N-1, index into layer_z_order (0 = bottom stem) monitor_visible: dict[str, bool] timeline_visible: dict[str, bool] slot_stems: dict[str, StemSource] = field(default_factory=dict) @@ -377,7 +377,9 @@ def draw( panel_y = display_height - panel_h - self._margin font = self._font_get() - num_sample = font.render(layer_num_prefix(4), True, LABEL) + num_sample = font.render( + layer_num_prefix(max(len(state.layer_z_order), 1)), True, LABEL + ) abbrev_sample = font.render(stem_abbrev_label("drums"), True, LABEL) self._layer_num_width = num_sample.get_width() self._stem_abbrev_width = abbrev_sample.get_width() diff --git a/cleave/viz/wiring.py b/cleave/viz/wiring.py index 7b5f625..9d1c1d3 100644 --- a/cleave/viz/wiring.py +++ b/cleave/viz/wiring.py @@ -5,18 +5,30 @@ from collections.abc import Callable from pathlib import Path -from cleave.config import VIZ_CONFIG_FILENAME, CleaveConfig, LayerConfig, PathsConfig, VisualizerConfig +from cleave.config import CleaveConfig +from cleave.config_schema import ( + MAX_LAYER_COUNT, + MIN_LAYER_COUNT, + DEFAULT_NEW_LAYER_STEM, + new_layer_config, + next_layer_slot, +) from cleave.config_snapshot import next_unnamed_path, write_session_snapshot from cleave.effects.runtime import EffectRuntime -from cleave.config_schema import DEFAULT_STEM_FOR_SLOT, LAYER_SLOTS from cleave.extract import STEM_NAMES, STEM_SOURCES +from cleave.gl_compositor import GlCompositor from cleave.paths import repo_root -from cleave.preset_playlist import PresetPlaylist +from cleave.preset_playlist import PresetPlaylist, preset_browse_floor, scan_single_layer from cleave.signals import Signals from cleave.viz.controls import TuningControls from cleave.viz.live_layer_bindings import LiveLayerBindings from cleave.viz.modal import ModalHost -from cleave.viz.session import TuningSession +from cleave.viz.session import ( + LayerRuntime, + TuningSession, + add_layer_to_session, + remove_layer_from_session, +) from cleave.viz.timeline_controls import TimelineControls from cleave.viz.layer import StemLayer from cleave.viz.layer_pipeline import LayerFramePipeline, apply_effect_modifiers @@ -24,6 +36,90 @@ from cleave.viz.mix_player import MixPlayer from cleave.stem_pcm import StemPcmBank from cleave.viz.playback import current_sec, seek +from cleave.config import VIZ_CONFIG_FILENAME + + +def _discard_timeline_slot(session: TuningSession, slot: str) -> None: + timeline = session.timeline + timeline.armed_slots.discard(slot) + timeline.override_slots.discard(slot) + timeline.record_baseline.pop(slot, None) + timeline.monitor.pop(slot, None) + timeline.override_visible.pop(slot, None) + timeline.arm_flash_start_ms.pop(slot, None) + for cue in timeline.record_buffer: + cue.layers.pop(slot, None) + timeline.record_buffer = [cue for cue in timeline.record_buffer if cue.layers] + + +class LayerManager: + def __init__( + self, + cfg: CleaveConfig, + session: TuningSession, + compositor: GlCompositor, + layers: list[StemLayer], + layers_by_slot: dict[str, StemLayer], + playlists: dict[str, PresetPlaylist], + preset_root: Path, + project_dir: Path, + fps: int, + texture_paths: list[Path], + ) -> None: + self.cfg = cfg + self.session = session + self.compositor = compositor + self.layers = layers + self.layers_by_slot = layers_by_slot + self.playlists = playlists + self.preset_root = preset_root + self.project_dir = project_dir + self.fps = fps + self.texture_paths = texture_paths + + def can_add(self) -> bool: + return len(self.session.layer_z_order) < MAX_LAYER_COUNT + + def can_remove(self) -> bool: + return len(self.session.layer_z_order) > MIN_LAYER_COUNT + + def add_layer(self) -> str: + slot = next_layer_slot(self.session.layer_z_order) + playlist = scan_single_layer(slot, self.preset_root, self.project_dir) + preset = playlist.current if playlist.current is not None else self.preset_root + layer_cfg = new_layer_config(slot, preset, self.preset_root) + self.cfg.layers[slot] = layer_cfg + stem_layer = LayerFramePipeline.build_single( + slot, + layer_cfg, + self.compositor, + playlist, + self.fps, + self.texture_paths, + beat_sensitivity=self.cfg.visualizer.beat_sensitivity, + ) + self.layers.append(stem_layer) + self.layers_by_slot[slot] = stem_layer + self.playlists[slot] = playlist + runtime = LayerRuntime( + playlist=playlist, + browse_floor=preset_browse_floor(layer_cfg.preset, self.preset_root), + stem=DEFAULT_NEW_LAYER_STEM, + beat_sensitivity=self.cfg.visualizer.beat_sensitivity, + ) + add_layer_to_session(self.session, slot, runtime) + self.cfg.layer_z_order.append(slot) + return slot + + def remove_layer(self, slot: str) -> None: + _discard_timeline_slot(self.session, slot) + LayerFramePipeline.destroy_single( + slot, self.layers, self.layers_by_slot, self.compositor + ) + del self.cfg.layers[slot] + self.cfg.layer_z_order.remove(slot) + del self.playlists[slot] + remove_layer_from_session(self.session, slot) def _solo_audio_source(session: TuningSession) -> str | None: @@ -36,30 +132,10 @@ def _sync_mix_player_solo(session: TuningSession, mix_player: MixPlayer) -> None mix_player.set_solo_source(_solo_audio_source(session)) -def _stub_cfg_for_session( - session: TuningSession, - preset_root: Path, - project_dir: Path, -) -> CleaveConfig: - return CleaveConfig( - paths=PathsConfig(preset_root=preset_root, texture_paths=()), - layers={ - slot: LayerConfig( - preset=preset_root / slot / "stub.milk", - stem=DEFAULT_STEM_FOR_SLOT[slot], - ) - for slot in LAYER_SLOTS - }, - visualizer=VisualizerConfig(), - config_path=project_dir / VIZ_CONFIG_FILENAME, - layer_z_order=tuple(session.layer_z_order), - ) - - def make_tuning_controls( *, session: TuningSession, - cfg: CleaveConfig | None, + cfg: CleaveConfig, preset_root: Path, project_dir: Path, layers_by_slot: dict[str, StemLayer], @@ -71,6 +147,7 @@ def make_tuning_controls( pcm_bank: StemPcmBank | None = None, mix_player: MixPlayer | None = None, modal_host: ModalHost | None = None, + layer_manager: LayerManager | None = None, ) -> TuningControls: def on_preset_change(slot: str, playlist: PresetPlaylist) -> None: layer = layers_by_slot[slot] @@ -168,33 +245,31 @@ def on_seek(delta_sec: float) -> None: kwargs: dict = { "session": session, - "cfg": cfg if cfg is not None else _stub_cfg_for_session( - session, preset_root, project_dir - ), + "cfg": cfg, "preset_root": preset_root, "playback": playback, "duration_sec": duration_sec, "layer_bindings": layer_bindings, + "layer_manager": layer_manager, } if modal_host is not None: kwargs["modal_host"] = modal_host - if cfg is not None: - def on_save_new_config() -> Path: - out_path = next_unnamed_path(project_dir) - write_session_snapshot(out_path, cfg=cfg, session=session) - return out_path - - def on_overwrite_config(path: Path) -> str: - write_session_snapshot(path, cfg=cfg, session=session) - return path.name - - kwargs.update( - on_save_new_config=on_save_new_config, - on_overwrite_config=on_overwrite_config, - launch_config_path=cfg.config_path, - repo_root_example=repo_root() / VIZ_CONFIG_FILENAME, - ) + def on_save_new_config() -> Path: + out_path = next_unnamed_path(project_dir) + write_session_snapshot(out_path, cfg=cfg, session=session) + return out_path + + def on_overwrite_config(path: Path) -> str: + write_session_snapshot(path, cfg=cfg, session=session) + return path.name + + kwargs.update( + on_save_new_config=on_save_new_config, + on_overwrite_config=on_overwrite_config, + launch_config_path=cfg.config_path, + repo_root_example=repo_root() / VIZ_CONFIG_FILENAME, + ) controls = TuningControls(**kwargs) if pcm_bank is not None and mix_player is not None: diff --git a/docs/dynamic-layers-plan.md b/docs/dynamic-layers-plan.md index 3dc0185..5a6a7e5 100644 --- a/docs/dynamic-layers-plan.md +++ b/docs/dynamic-layers-plan.md @@ -1,5 +1,7 @@ # Dynamic layers plan +**Status:** All phases complete as of June 2026. + ## Overview Remove the hardcoded four-layer ceiling. Layers become first-class runtime objects that can @@ -35,6 +37,8 @@ The implementation is split into eight phases that can be developed and reviewed ### 1.1 Replace the `LAYER_SLOTS` constant +- [x] + Remove: ```python LAYER_SLOTS: tuple[str, ...] = ("layer_1", "layer_2", "layer_3", "layer_4") @@ -80,6 +84,8 @@ remaining reference (only `wiring.py` stub) is updated in Phase 3. ### 1.2 Relax `parse_layers_section` +- [x] + - Accept any number of `layer_N` keys where 1 ≤ N ≤ 8, so count in range [1, 8]. - Reject keys not matching `layer_\d+`, N out of [1, 8], or duplicate N. - Reject an empty `layers` block. @@ -110,6 +116,8 @@ if not layers_raw: ### 1.3 Relax `parse_layer_z_order_section` +- [x] + Currently validates permutation of fixed `LAYER_SLOTS`. Change to validate: - Is a list. - All entries appear in `layers` keys (the parsed layer set, passed in). @@ -120,6 +128,8 @@ The parsed layer set is available as `ctx.layer_slots` — add this field to `Pa ### 1.4 Update `persist_layers` +- [x] + Replace the `for slot in LAYER_SLOTS` loop with `for slot in ctx.session.layer_z_order`. For newly added layers that have no entry in `ctx.cfg.layers` (see Phase 3), read all values from `ctx.session.layers[slot]` and from `slot_layer_config(slot)` (a new helper that returns @@ -130,11 +140,15 @@ session in sync). No special case needed. ### 1.5 Update `parse_timeline_section` +- [x] + Cue `layers` sub-keys are validated against the actual layer set in the config (not `LAYER_SLOTS`). Pass the parsed layer slots through `ParseCtx` and use them here. ### 1.6 `CleaveConfig` — un-freeze and use `list` +- [x] + `CleaveConfig` is `frozen=True` with `layer_z_order: tuple[str, ...]`. Dynamic add/remove requires mutating both fields. Changes: @@ -154,6 +168,8 @@ requires mutating both fields. Changes: ### 2.1 `GlCompositor.remove_layer_fbo(name: str)` +- [x] + The compositor's `_layers` list already has no fixed capacity. Add: ```python @@ -165,6 +181,8 @@ def remove_layer_fbo(self, name: str) -> None: ### 2.2 `LayerFramePipeline.build_single` +- [x] + Build exactly one layer (ProjectM + FBO) and return a `StemLayer`: ```python @@ -184,6 +202,8 @@ layer starts from frame zero (same behaviour as any fresh projectM instance). ### 2.3 `LayerFramePipeline.destroy_single` +- [x] + ```python @staticmethod def destroy_single( @@ -201,6 +221,8 @@ def destroy_single( ### 2.4 `scan_single_layer` +- [x] + New function in `preset_playlist.py`: ```python @@ -222,6 +244,8 @@ Same logic as `scan_all_layers` for one slot. Picks a random preset as initial c ### 3.1 `session.py` — add/remove helpers +- [x] + ```python def add_layer_to_session( session: TuningSession, @@ -242,6 +266,8 @@ def remove_layer_from_session(session: TuningSession, slot: str) -> None: ### 3.2 `wiring.py` — `LayerManager` +- [x] + Add a `LayerManager` class that holds the mutable GL collections and exposes the add/remove operations. `TuningControls` receives a `LayerManager` and calls it on modal confirm. @@ -310,6 +336,8 @@ directly with an explicit slot list. ### 3.3 `make_tuning_controls` / `make_timeline_controls` +- [x] + Pass `LayerManager` into `make_tuning_controls`; store it on `TuningControls` for use in `_add_layer` / `_delete_layer` handlers (Phase 5). @@ -321,6 +349,8 @@ Pass `LayerManager` into `make_tuning_controls`; store it on `TuningControls` fo ### 4.1 New `RowKind` values +- [x] + ```python class RowKind(Enum): ... @@ -330,6 +360,8 @@ class RowKind(Enum): ### 4.2 `RowBehavior` entries +- [x] + ```python RowKind.LAYER_MANAGEMENT_ADD: RowBehavior( RowAffordance.ACTION, @@ -352,6 +384,8 @@ appropriate for navigation and group membership. ### 4.3 `build_row_layout` in `overlay.py` +- [x] + **Delete Layer** row: appended as the last sub-row of each track block, after the cleave effects header (and any visible effect sub-rows). It is only included when the track is expanded (follows the same expand gate as other sub-rows). Descriptor: @@ -363,6 +397,8 @@ visible (not gated on any expand state). ### 4.4 Drawing in `overlay.py` +- [x] + **ADD NEW LAYER:** Draw with `_render_label_value_row` using label `ADD NEW LAYER` in `LABEL` color. No eye, no expand arrow. @@ -379,10 +415,14 @@ receive the "must have at least 1 layer" notification). ### 5.1 Constructor +- [x] + Accept `layer_manager: LayerManager` (may be `None` for headless tests). ### 5.2 `_add_layer` +- [x] + Called when Enter is pressed on `LAYER_MANAGEMENT_ADD`: ```python @@ -406,6 +446,8 @@ def _confirm_add_layer(self) -> None: ### 5.3 `_delete_layer` +- [x] + Called when Enter is pressed on `LAYER_MANAGEMENT_DELETE` (slot from `row_slot()`): ```python @@ -432,6 +474,8 @@ def _confirm_delete_layer(self, slot: str) -> None: ### 5.4 `_rebuild_view` +- [x] + After add/remove, the number of navigable rows changes. Call: ```python self._state = TuningViewStateBuilder(self._session, self._cfg).build() @@ -441,6 +485,8 @@ Any z-order move mode is exited before the rebuild. ### 5.5 Key dispatch +- [x] + In the existing Enter handler, add cases for the two new row kinds before falling through to existing cases: @@ -462,11 +508,15 @@ if kind == RowKind.LAYER_MANAGEMENT_DELETE: ### 6.1 `timeline.py` — remove `LAYER_SLOTS` dependency +- [x] + `visible_state_at` currently iterates `LAYER_SLOTS`. Change signature to accept `slots: list[str]` and iterate that instead. All callers pass `session.layer_z_order`. ### 6.2 `timeline_controls.py` — extend num keys to 1-8 +- [x] + ```python _LAYER_KEY_INDEX: dict[int, int] = { pygame.K_1: 0, pygame.K_2: 1, pygame.K_3: 2, pygame.K_4: 3, @@ -481,14 +531,25 @@ in `_slot_for_layer_index` — keep that guard). ### 6.3 `timeline_overlay.py` — dynamic width probe +- [x] + Replace `layer_num_prefix(4)` with `layer_num_prefix(max(len(layer_z_order), 1))`. Since max is 8, the column never needs more than one digit plus a space — the probe just needs to match the widest label that will actually appear. ### 6.4 `layer_visibility.py` +- [x] + Any hardcoded slot references are replaced with `session.layer_z_order` iteration. +### 6.5 `controls.py` — clamp timeline focus on delete + +- [x] + +After `remove_layer`, clamp `timeline.focus_row` to `len(layer_z_order) - 1` (or clear +`submenu_focused` when no layers remain). + --- ## Phase 7 — Snapshot and dirty tracking @@ -497,6 +558,8 @@ Any hardcoded slot references are replaced with `session.layer_z_order` iteratio ### 7.1 `persist_layers` +- [x] + The current loop is `for slot in LAYER_SLOTS`. Change to `for slot in ctx.session.layer_z_order`. Since Phase 3 keeps `cfg.layers` and `session.layer_z_order` in sync (add/remove updates @@ -505,10 +568,14 @@ needed. ### 7.2 `persist_layer_z_order` +- [x] + Already reads `session.layer_z_order` — no change. ### 7.3 Dirty tracking +- [x] + `persisted_session_signature` computes the hash of `persisted_session_payload`. Because that payload is built from the actual session + cfg (both updated on add/remove), dirtiness is detected correctly with no extra logic. @@ -523,6 +590,8 @@ usual. ### 8.1 Unit tests +- [x] + Update any test that hard-codes `LAYER_SLOTS`, constructs a `CleaveConfig` with exactly four layers, or uses `DEFAULT_STEM_FOR_SLOT`: @@ -536,15 +605,21 @@ four layers, or uses `DEFAULT_STEM_FOR_SLOT`: ### 8.2 `cleave-viz.yaml` template +- [x] + No change to the default content (still four layers). The template is not a constraint; the schema now accepts any count. Remove any comment that says "must have exactly four." ### 8.3 `.cursor/rules/project-context.mdc` +- [x] + Remove "four libprojectM layers". Update to "up to eight Milkdrop layers" or equivalent. ### 8.4 `.cursor/rules/live-tuning-ui.mdc` +- [x] + - Remove `layer_1`..`layer_4` enumeration where it implies a fixed count. - Document the two new row kinds: `LAYER_MANAGEMENT_ADD` and `LAYER_MANAGEMENT_DELETE`. - Note num keys 1-8. @@ -552,6 +627,8 @@ Remove "four libprojectM layers". Update to "up to eight Milkdrop layers" or equ ### 8.5 `docs/roadmap.md` / `docs/todos.md` +- [x] + Remove or mark done any item about dynamic layers or the four-layer cap. Remove references to four layers as the permanent stack size. diff --git a/docs/roadmap.md b/docs/roadmap.md index 0ee7da5..501f187 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -38,7 +38,7 @@ Cleave today uses the standard four-stem split: drums, bass, vocals, other. Demu | --- | --- | --- | | Four-stem split | `htdemucs` (fast) or `htdemucs_ft` (higher quality) | Current default; one Milkdrop layer per stem | | Six-stem split | `htdemucs_6s` model adds **guitar** and **piano** | Two extra layers or replace `other` with more targeted stems | -| Two-stem mode | `--two-stems=vocals` (or drums, etc.) | Quick vocal isolation pass; less useful for the four-layer stack | +| Two-stem mode | `--two-stems=vocals` (or drums, etc.) | Quick vocal isolation pass; less useful when running a full multi-layer stack | | Re-run on a stem | Separate `drums.wav` again with a different model | Experimental; quality varies; not a built-in kick/snare/hihat mode | **Kick / snare / hihat:** HTDemucs does **not** ship a first-class drum-kit split. Getting individual drum pieces usually means either (a) running a specialised percussion model on the drum stem, (b) classical onset/spectral heuristics on `drums.wav`, or (c) a custom fine-tuned separator. All are feasible side projects but not drop-in Demucs flags. @@ -50,4 +50,4 @@ Cleave today uses the standard four-stem split: drums, bass, vocals, other. Demu - **Shorter clips**: Demucs on full albums is slow; chunking or stem caching (already partially there via skip-if-exists) scales better for catalogue work. - **Live-ish separation**: sliding-window Demucs on a ring buffer (high latency, heavy CPU/GPU) could feed stems to Cleave in near real time; see also MIDI out for lower-latency drum triggers without full re-separation. -None of the above is required for the current four-layer visualizer. Pick one when a concrete creative need shows up (e.g. guitar gets its own preset stack, or drum layers need independent bloom). +None of the above is required for the current visualizer (default four layers, up to eight). Pick one when a concrete creative need shows up (e.g. guitar gets its own preset stack, or drum layers need independent bloom). diff --git a/tests/cleave/effects/test_flare.py b/tests/cleave/effects/test_flare.py index 8c8830c..bf06d97 100644 --- a/tests/cleave/effects/test_flare.py +++ b/tests/cleave/effects/test_flare.py @@ -22,9 +22,10 @@ from cleave.preset_playlist import playlist_at_dir from pathlib import Path -from cleave.config_schema import DEFAULT_STEM_FOR_SLOT, LAYER_SLOTS +from cleave.config_schema import DEFAULT_LAYER_SLOTS +from tests.support.config import TEST_LAYER_STEMS -SLOT_FOR_STEM = {v: k for k, v in DEFAULT_STEM_FOR_SLOT.items()} +SLOT_FOR_STEM = {v: k for k, v in TEST_LAYER_STEMS.items()} def _signals_with_stem_key(stem: str, key: str, values: list[float]) -> Signals: @@ -39,7 +40,7 @@ def _signals_with_stem_key(stem: str, key: str, values: list[float]) -> Signals: def _layer_runtime(stem: str, *, opacity_pct: int = 50, effects: dict | None = None) -> LayerRuntime: slot = SLOT_FOR_STEM.get(stem, stem) - audio = DEFAULT_STEM_FOR_SLOT.get(slot, stem) + audio = TEST_LAYER_STEMS.get(slot, stem) return LayerRuntime( playlist=playlist_at_dir(Path(f"/tmp/presets/{slot}"), index=0), browse_floor=Path(f"/tmp/presets/{slot}"), @@ -161,4 +162,4 @@ def test_effect_runtime_flare_only_on_drums() -> None: def test_flare_threshold_constant() -> None: assert FLARE_THRESHOLD == 0.55 assert flare_triggered(FLARE_THRESHOLD + 0.01, 0.0, 0.0) is True - assert flare_triggered(FLARE_THRESHOLD - 0.01, 0.0, 0.0) is False + assert flare_triggered(FLARE_THRESHOLD - 0.01, 0.0, 0.0) is False \ No newline at end of file diff --git a/tests/cleave/effects/test_flash.py b/tests/cleave/effects/test_flash.py index cb57759..776d917 100644 --- a/tests/cleave/effects/test_flash.py +++ b/tests/cleave/effects/test_flash.py @@ -22,9 +22,10 @@ from cleave.viz.session import LayerRuntime, TuningSession from pathlib import Path -from cleave.config_schema import DEFAULT_STEM_FOR_SLOT, LAYER_SLOTS +from cleave.config_schema import DEFAULT_LAYER_SLOTS +from tests.support.config import TEST_LAYER_STEMS -SLOT_FOR_STEM = {v: k for k, v in DEFAULT_STEM_FOR_SLOT.items()} +SLOT_FOR_STEM = {v: k for k, v in TEST_LAYER_STEMS.items()} def _signals_with_stem_key(stem: str, key: str, values: list[float]) -> Signals: @@ -39,7 +40,7 @@ def _signals_with_stem_key(stem: str, key: str, values: list[float]) -> Signals: def _layer_runtime(stem: str, *, opacity_pct: int = 50, effects: dict | None = None) -> LayerRuntime: slot = SLOT_FOR_STEM.get(stem, stem) - audio = DEFAULT_STEM_FOR_SLOT.get(slot, stem) + audio = TEST_LAYER_STEMS.get(slot, stem) return LayerRuntime( playlist=playlist_at_dir(Path(f"/tmp/presets/{slot}"), index=0), browse_floor=Path(f"/tmp/presets/{slot}"), @@ -184,13 +185,13 @@ def test_effect_runtime_all_stems_expose_flash_modifier() -> None: "other": {"flash": {"centroid": 100}}, } session = TuningSession( - layer_z_order=list(LAYER_SLOTS), + layer_z_order=list(DEFAULT_LAYER_SLOTS), layers={ - slot: _layer_runtime(DEFAULT_STEM_FOR_SLOT[slot], effects=flash_effects[DEFAULT_STEM_FOR_SLOT[slot]]) - for slot in LAYER_SLOTS + slot: _layer_runtime(TEST_LAYER_STEMS[slot], effects=flash_effects[TEST_LAYER_STEMS[slot]]) + for slot in DEFAULT_LAYER_SLOTS }, ) runtime = EffectRuntime() mods = runtime.tick(session, signals, 0.01) - for slot in LAYER_SLOTS: - assert mods[slot].flash_alpha > 0.0 + for slot in DEFAULT_LAYER_SLOTS: + assert mods[slot].flash_alpha > 0.0 \ No newline at end of file diff --git a/tests/cleave/effects/test_grit.py b/tests/cleave/effects/test_grit.py index 636740e..1ca21af 100644 --- a/tests/cleave/effects/test_grit.py +++ b/tests/cleave/effects/test_grit.py @@ -4,9 +4,10 @@ from pathlib import Path -from cleave.config_schema import DEFAULT_STEM_FOR_SLOT, LAYER_SLOTS +from cleave.config_schema import DEFAULT_LAYER_SLOTS +from tests.support.config import TEST_LAYER_STEMS -SLOT_FOR_STEM = {v: k for k, v in DEFAULT_STEM_FOR_SLOT.items()} +SLOT_FOR_STEM = {v: k for k, v in TEST_LAYER_STEMS.items()} import numpy as np import pytest @@ -40,7 +41,7 @@ def _signals_with_stem_key(stem: str, key: str, values: list[float]) -> Signals: def _layer_runtime(stem: str, *, opacity_pct: int = 50, effects: dict | None = None) -> LayerRuntime: slot = SLOT_FOR_STEM.get(stem, stem) - audio = DEFAULT_STEM_FOR_SLOT.get(slot, stem) + audio = TEST_LAYER_STEMS.get(slot, stem) return LayerRuntime( playlist=playlist_at_dir(Path(f"/tmp/presets/{slot}"), index=0), browse_floor=Path(f"/tmp/presets/{slot}"), @@ -163,7 +164,7 @@ def test_effect_runtime_grit_all_stems() -> None: }, ) session = TuningSession( - layer_z_order=list(LAYER_SLOTS), + layer_z_order=list(DEFAULT_LAYER_SLOTS), layers={ "layer_1": _layer_runtime("drums", effects={"grit": {"onset": 100}}), "layer_2": _layer_runtime("bass", effects={"grit": {"sub_bass": 100}}), @@ -173,6 +174,6 @@ def test_effect_runtime_grit_all_stems() -> None: ) runtime = EffectRuntime() mods = runtime.tick(session, signals, 0.01) - for slot in LAYER_SLOTS: + for slot in DEFAULT_LAYER_SLOTS: assert mods[slot].grit_strength > 0.0 - assert mods[slot].aberration_px > 0.0 + assert mods[slot].aberration_px > 0.0 \ No newline at end of file diff --git a/tests/cleave/effects/test_hue.py b/tests/cleave/effects/test_hue.py index 11e8694..2fb7ea1 100644 --- a/tests/cleave/effects/test_hue.py +++ b/tests/cleave/effects/test_hue.py @@ -29,9 +29,10 @@ from cleave.viz.session import LayerRuntime, TuningSession from pathlib import Path -from cleave.config_schema import DEFAULT_STEM_FOR_SLOT, LAYER_SLOTS +from cleave.config_schema import DEFAULT_LAYER_SLOTS +from tests.support.config import TEST_LAYER_STEMS -SLOT_FOR_STEM = {v: k for k, v in DEFAULT_STEM_FOR_SLOT.items()} +SLOT_FOR_STEM = {v: k for k, v in TEST_LAYER_STEMS.items()} def _signals_with_pitch(values: list[float]) -> Signals: @@ -46,7 +47,7 @@ def _signals_with_pitch(values: list[float]) -> Signals: def _layer_runtime(stem: str, *, opacity_pct: int = 50, effects: dict | None = None) -> LayerRuntime: slot = SLOT_FOR_STEM.get(stem, stem) - audio = DEFAULT_STEM_FOR_SLOT.get(slot, stem) + audio = TEST_LAYER_STEMS.get(slot, stem) return LayerRuntime( playlist=playlist_at_dir(Path(f"/tmp/presets/{slot}"), index=0), browse_floor=Path(f"/tmp/presets/{slot}"), @@ -164,4 +165,4 @@ def test_effect_runtime_hue_zero_depth_is_noop() -> None: runtime = EffectRuntime() mods = runtime.tick(session, signals, 0.0) assert mods["layer_3"].hue_mix == 0.0 - assert mods["layer_3"].hue_rgb == (1.0, 1.0, 1.0) + assert mods["layer_3"].hue_rgb == (1.0, 1.0, 1.0) \ No newline at end of file diff --git a/tests/cleave/effects/test_pulse.py b/tests/cleave/effects/test_pulse.py index ae48127..dc513a6 100644 --- a/tests/cleave/effects/test_pulse.py +++ b/tests/cleave/effects/test_pulse.py @@ -4,9 +4,10 @@ from pathlib import Path -from cleave.config_schema import DEFAULT_STEM_FOR_SLOT, LAYER_SLOTS +from cleave.config_schema import DEFAULT_LAYER_SLOTS +from tests.support.config import TEST_LAYER_STEMS -SLOT_FOR_STEM = {v: k for k, v in DEFAULT_STEM_FOR_SLOT.items()} +SLOT_FOR_STEM = {v: k for k, v in TEST_LAYER_STEMS.items()} import numpy as np import pytest @@ -29,7 +30,7 @@ def _layer_runtime(stem: str, *, opacity_pct: int = 50, effects: dict | None = None) -> LayerRuntime: slot = SLOT_FOR_STEM.get(stem, stem) - audio = DEFAULT_STEM_FOR_SLOT.get(slot, stem) + audio = TEST_LAYER_STEMS.get(slot, stem) return LayerRuntime( playlist=playlist_at_dir(Path(f"/tmp/presets/{slot}"), index=0), browse_floor=Path(f"/tmp/presets/{slot}"), @@ -109,7 +110,7 @@ def test_effect_runtime_all_stems_pulse_modulate() -> None: }, ) session = TuningSession( - layer_z_order=list(LAYER_SLOTS), + layer_z_order=list(DEFAULT_LAYER_SLOTS), layers={ "layer_1": _layer_runtime( "drums", effects={"pulse": {"onset": 100}} @@ -127,7 +128,7 @@ def test_effect_runtime_all_stems_pulse_modulate() -> None: ) runtime = EffectRuntime() mods = runtime.tick(session, signals, 0.02) - for slot in LAYER_SLOTS: + for slot in DEFAULT_LAYER_SLOTS: assert mods[slot].opacity != 0.5 @@ -199,4 +200,4 @@ def test_effect_runtime_pulse_driver_modulates_opacity( runtime = EffectRuntime() baseline = runtime.tick(session, signals, 0.0) modulated = runtime.tick(session, signals, 0.01) - assert modulated[slot].opacity != baseline[slot].opacity + assert modulated[slot].opacity != baseline[slot].opacity \ No newline at end of file diff --git a/tests/cleave/test_config.py b/tests/cleave/test_config.py index 61ad6b0..fa33f3a 100644 --- a/tests/cleave/test_config.py +++ b/tests/cleave/test_config.py @@ -32,8 +32,10 @@ _parse_layers, ) from cleave.config_schema import ( - DEFAULT_STEM_FOR_SLOT, - LAYER_SLOTS, + DEFAULT_LAYER_SLOTS, + MAX_LAYER_COUNT, + ParseCtx, + next_layer_slot, parse_hex_colour, parse_render_section, parse_timeline_section, @@ -43,7 +45,7 @@ from cleave.paths import repo_root from cleave.extract import STEM_NAMES from cleave.timeline import TimelineCue -from tests.support.config import slot_for_stem, write_minimal_config +from tests.support.config import TEST_LAYER_STEMS, slot_for_stem, write_minimal_config _LONG_PRESET = ( "presets-cream-of-the-crop/Drawing/Dunes/" @@ -65,12 +67,44 @@ def _preset_lines(dumped: str) -> list[str]: return lines +def _timeline_parse_ctx( + slots: tuple[str, ...] = DEFAULT_LAYER_SLOTS, +) -> ParseCtx: + return ParseCtx(layer_slots=slots) + + +def _layer_slots_raw(slots: list[str]) -> dict: + stem_cycle = ["drums", "bass", "vocals", "other"] + layers: dict = {} + for slot in slots: + stem = TEST_LAYER_STEMS.get( + slot, stem_cycle[(int(slot.split("_")[1]) - 1) % len(stem_cycle)] + ) + layers[slot] = { + **template_layer_entry(slot, stem=stem), + "preset": f"{stem}/{stem}.milk", + } + return layers + + +def _write_config_with_slots( + project_dir: Path, preset_root: Path, slots: list[str], **overrides +) -> Path: + data_overrides = { + "layers": _layer_slots_raw(slots), + "layer_z_order": list(slots), + } + data_overrides.update(overrides) + return write_minimal_config(project_dir, preset_root, **data_overrides) + + def _minimal_layers_raw(*, locked_slot: str | None = None) -> dict: layers: dict = {} - for slot in LAYER_SLOTS: + for slot in DEFAULT_LAYER_SLOTS: + stem = TEST_LAYER_STEMS[slot] entry: dict = { - **template_layer_entry(slot), - "preset": f"{DEFAULT_STEM_FOR_SLOT[slot]}/anchor.milk", + **template_layer_entry(slot, stem=stem), + "preset": f"{stem}/anchor.milk", } if slot == locked_slot: entry["locked"] = True @@ -91,12 +125,12 @@ def test_dump_yaml_keeps_long_preset_on_one_line() -> None: def test_parse_layers_reads_locked_true() -> None: preset_root = Path("/tmp/presets") - layers = _parse_layers( + layers, _ = _parse_layers( {"layers": _minimal_layers_raw(locked_slot="layer_1")}, preset_root, ) assert layers["layer_1"].locked is True - for slot in LAYER_SLOTS: + for slot in DEFAULT_LAYER_SLOTS: if slot != "layer_1": assert layers[slot].locked is False @@ -186,7 +220,7 @@ def test_load_config_reads_visualizer_name(minimal_project: Path) -> None: def test_layers_in_z_order_matches_reversed_layer_z_order() -> None: - layer_z_order = ("layer_4", "layer_2", "layer_3", "layer_1") + layer_z_order = ["layer_4", "layer_2", "layer_3", "layer_1"] cfg = CleaveConfig( paths=PathsConfig( preset_root=Path("/tmp/presets"), @@ -195,11 +229,11 @@ def test_layers_in_z_order_matches_reversed_layer_z_order() -> None: layers={ slot: LayerConfig( preset=Path( - f"/tmp/presets/{DEFAULT_STEM_FOR_SLOT[slot]}/anchor.milk" + f"/tmp/presets/{TEST_LAYER_STEMS[slot]}/anchor.milk" ), - stem=DEFAULT_STEM_FOR_SLOT[slot], + stem=TEST_LAYER_STEMS[slot], ) - for slot in LAYER_SLOTS + for slot in DEFAULT_LAYER_SLOTS }, visualizer=VisualizerConfig(), config_path=Path("/tmp/cleave.config.yaml"), @@ -229,7 +263,7 @@ def test_parse_layers_reads_effects() -> None: preset_root = Path("/tmp/presets") layers_raw = _minimal_layers_raw() layers_raw["layer_1"]["effects"] = {"pulse": {"onset": 75}} - layers = _parse_layers({"layers": layers_raw}, preset_root) + layers, _ = _parse_layers({"layers": layers_raw}, preset_root) assert layers["layer_1"].effects == {"pulse": {"onset": 75}} assert layers["layer_2"].effects == {} @@ -305,10 +339,10 @@ def test_find_config_path_global_fallback( def test_load_config_round_trip(minimal_project: Path) -> None: cfg = load_config(project_root=minimal_project) assert cfg.config_path == (minimal_project / VIZ_CONFIG_FILENAME).resolve() - assert set(cfg.layers) == set(LAYER_SLOTS) + assert set(cfg.layers) == set(DEFAULT_LAYER_SLOTS) assert cfg.visualizer.width > 0 assert cfg.paths.preset_root.is_dir() - for slot in LAYER_SLOTS: + for slot in DEFAULT_LAYER_SLOTS: assert cfg.layers[slot].preset.is_file() @@ -328,47 +362,57 @@ def _write_invalid_config(project_dir: Path, preset_root: Path, **overrides) -> "layers": { **{ slot: { - **template_layer_entry(slot), + **template_layer_entry(slot, stem=TEST_LAYER_STEMS[slot]), "preset": ( - f"{DEFAULT_STEM_FOR_SLOT[slot]}/" - f"{DEFAULT_STEM_FOR_SLOT[slot]}.milk" + f"{TEST_LAYER_STEMS[slot]}/" + f"{TEST_LAYER_STEMS[slot]}.milk" ), } - for slot in LAYER_SLOTS + for slot in DEFAULT_LAYER_SLOTS }, "guitars": {"stem": "drums", "preset": "guitars/guitars.milk"}, } }, - "unknown layer keys", + "invalid layer key", + ), + ( + {"layers": {}}, + "layers section must contain at least one layer", ), ( { "layers": { - slot: { - **template_layer_entry(slot), - "preset": ( - f"{DEFAULT_STEM_FOR_SLOT[slot]}/" - f"{DEFAULT_STEM_FOR_SLOT[slot]}.milk" - ), + "layer_0": { + **template_layer_entry("layer_1", stem="drums"), + "preset": "drums/drums.milk", + } + } + }, + "invalid layer key 'layer_0'", + ), + ( + { + "layers": { + "layer_9": { + **template_layer_entry("layer_1", stem="drums"), + "preset": "drums/drums.milk", } - for slot in LAYER_SLOTS - if slot != "layer_4" } }, - "missing layer config", + "invalid layer key 'layer_9'", ), ( { "layers": { slot: { - **template_layer_entry(slot), + **template_layer_entry(slot, stem=TEST_LAYER_STEMS[slot]), "preset": ( - f"{DEFAULT_STEM_FOR_SLOT[slot]}/" - f"{DEFAULT_STEM_FOR_SLOT[slot]}.milk" + f"{TEST_LAYER_STEMS[slot]}/" + f"{TEST_LAYER_STEMS[slot]}.milk" ), "blend_mode": "overlay" if slot == "layer_1" else "black-key", } - for slot in LAYER_SLOTS + for slot in DEFAULT_LAYER_SLOTS } }, "blend_mode must be one of", @@ -536,7 +580,10 @@ def test_load_config_missing_preset_file(tmp_path: Path) -> None: def test_parse_timeline_defaults_enabled_true() -> None: - timeline = parse_timeline_section({"timeline": {}}) + timeline = parse_timeline_section( + {"timeline": {}}, + _timeline_parse_ctx(), + ) assert timeline == TimelineConfig(enabled=True, cues=()) @@ -550,7 +597,8 @@ def test_parse_timeline_reads_cues_sorted_by_t() -> None: {"t": 2.5, "layers": {"layer_2": True}}, ], } - } + }, + _timeline_parse_ctx(), ) assert timeline is not None assert timeline.enabled is True @@ -567,7 +615,8 @@ def test_parse_timeline_rejects_unknown_stem() -> None: "timeline": { "cues": [{"t": 1.0, "layers": {"synth": True}}], } - } + }, + _timeline_parse_ctx(), ) @@ -578,7 +627,8 @@ def test_parse_timeline_clamps_negative_t() -> None: "timeline": { "cues": [{"t": -1.0, "layers": {"layer_1": False}}], } - } + }, + _timeline_parse_ctx(), ) @@ -596,3 +646,70 @@ def test_load_config_reads_timeline(minimal_project: Path) -> None: assert cfg.timeline is not None assert cfg.timeline.enabled is True assert cfg.timeline.cues == (TimelineCue(t=3.0, layers={"layer_3": False}),) + + +def test_next_layer_slot_skips_used_slots() -> None: + assert next_layer_slot(["layer_1", "layer_2"]) == "layer_3" + + +def test_next_layer_slot_raises_at_capacity() -> None: + slots = [f"layer_{i}" for i in range(1, MAX_LAYER_COUNT + 1)] + with pytest.raises(ValueError, match=f"Maximum {MAX_LAYER_COUNT} layers"): + next_layer_slot(slots) + + +@pytest.mark.parametrize("count", [1, 8]) +def test_load_config_accepts_variable_layer_count( + tmp_path: Path, count: int +) -> None: + preset_root = tmp_path / "presets" + project_dir = tmp_path / "project" + slots = [f"layer_{i}" for i in range(1, count + 1)] + _write_config_with_slots(project_dir, preset_root, slots) + cfg = load_config(project_root=project_dir) + assert list(cfg.layers) == slots + assert cfg.layer_z_order == slots + + +def test_load_config_rejects_z_order_for_missing_layer(tmp_path: Path) -> None: + preset_root = tmp_path / "presets" + project_dir = tmp_path / "project" + _write_config_with_slots( + project_dir, + preset_root, + ["layer_1", "layer_2", "layer_3"], + layer_z_order=["layer_1", "layer_2", "layer_4"], + ) + with pytest.raises(ValueError, match="layer_z_order must contain each of"): + load_config(project_root=project_dir) + + +def test_parse_timeline_rejects_layer_not_in_config(tmp_path: Path) -> None: + preset_root = tmp_path / "presets" + project_dir = tmp_path / "project" + _write_config_with_slots(project_dir, preset_root, ["layer_1", "layer_2"]) + cfg_path = project_dir / VIZ_CONFIG_FILENAME + data = yaml.safe_load(cfg_path.read_text(encoding="utf-8")) + data["timeline"] = { + "cues": [{"t": 1.0, "layers": {"layer_3": True}}], + } + with cfg_path.open("w", encoding="utf-8") as handle: + dump_yaml(data, handle) + with pytest.raises(ValueError, match="unknown layer keys in timeline.cues"): + load_config(project_root=project_dir) + + +def test_cleave_config_layer_z_order_defaults_to_list() -> None: + cfg = CleaveConfig( + paths=PathsConfig( + preset_root=Path("/tmp/presets"), + texture_paths=(Path("/tmp/textures"),), + ), + layers={}, + visualizer=VisualizerConfig(), + config_path=Path("/tmp/cleave.config.yaml"), + ) + assert isinstance(cfg.layer_z_order, list) + cfg.layer_z_order.append("layer_5") + assert "layer_5" in cfg.layer_z_order + diff --git a/tests/cleave/test_config_snapshot.py b/tests/cleave/test_config_snapshot.py index 75a8a5f..291bf74 100644 --- a/tests/cleave/test_config_snapshot.py +++ b/tests/cleave/test_config_snapshot.py @@ -5,6 +5,7 @@ import tempfile from pathlib import Path +import pytest import yaml from cleave.config import ( @@ -22,12 +23,13 @@ load_config, ) from cleave.config_schema import ( - DEFAULT_STEM_FOR_SLOT, - LAYER_SLOTS, + DEFAULT_LAYER_SLOTS, + ParseCtx, parse_render_section, parse_timeline_section, template_layer_entry, ) +from tests.support.config import TEST_LAYER_STEMS from cleave.config_snapshot import ( next_unnamed_path, persisted_session_payload, @@ -73,27 +75,27 @@ def test_write_session_snapshot_includes_locked() -> None: paths=PathsConfig(preset_root=preset_root, texture_paths=(root / "tex",)), layers={ slot: LayerConfig( - preset=preset_root / DEFAULT_STEM_FOR_SLOT[slot] / "anchor.milk", - stem=DEFAULT_STEM_FOR_SLOT[slot], + preset=preset_root / TEST_LAYER_STEMS[slot] / "anchor.milk", + stem=TEST_LAYER_STEMS[slot], ) - for slot in LAYER_SLOTS + for slot in DEFAULT_LAYER_SLOTS }, visualizer=VisualizerConfig(), config_path=config_path, ) session = TuningSession( - layer_z_order=list(LAYER_SLOTS), + layer_z_order=list(DEFAULT_LAYER_SLOTS), layers={ slot: LayerRuntime( - stem=DEFAULT_STEM_FOR_SLOT[slot], + stem=TEST_LAYER_STEMS[slot], playlist=playlist_at_dir( - preset_root / DEFAULT_STEM_FOR_SLOT[slot], index=0 + preset_root / TEST_LAYER_STEMS[slot], index=0 ), - browse_floor=preset_root / DEFAULT_STEM_FOR_SLOT[slot], + browse_floor=preset_root / TEST_LAYER_STEMS[slot], locked=(slot == "layer_2"), ) - for slot in LAYER_SLOTS + for slot in DEFAULT_LAYER_SLOTS }, ) @@ -102,7 +104,7 @@ def test_write_session_snapshot_includes_locked() -> None: data = yaml.safe_load(out_path.read_text(encoding="utf-8")) assert data["layers"]["layer_2"]["locked"] is True - for slot in LAYER_SLOTS: + for slot in DEFAULT_LAYER_SLOTS: if slot != "layer_2": assert data["layers"][slot]["locked"] is False @@ -120,27 +122,27 @@ def test_write_session_snapshot_sparse_effects() -> None: paths=PathsConfig(preset_root=preset_root, texture_paths=(root / "tex",)), layers={ slot: LayerConfig( - preset=preset_root / DEFAULT_STEM_FOR_SLOT[slot] / "anchor.milk", - stem=DEFAULT_STEM_FOR_SLOT[slot], + preset=preset_root / TEST_LAYER_STEMS[slot] / "anchor.milk", + stem=TEST_LAYER_STEMS[slot], ) - for slot in LAYER_SLOTS + for slot in DEFAULT_LAYER_SLOTS }, visualizer=VisualizerConfig(), config_path=config_path, ) session = TuningSession( - layer_z_order=list(LAYER_SLOTS), + layer_z_order=list(DEFAULT_LAYER_SLOTS), layers={ slot: LayerRuntime( - stem=DEFAULT_STEM_FOR_SLOT[slot], + stem=TEST_LAYER_STEMS[slot], playlist=playlist_at_dir( - preset_root / DEFAULT_STEM_FOR_SLOT[slot], index=0 + preset_root / TEST_LAYER_STEMS[slot], index=0 ), - browse_floor=preset_root / DEFAULT_STEM_FOR_SLOT[slot], + browse_floor=preset_root / TEST_LAYER_STEMS[slot], effects={"pulse": {"onset": 60}} if slot == "layer_1" else {}, ) - for slot in LAYER_SLOTS + for slot in DEFAULT_LAYER_SLOTS }, ) @@ -190,27 +192,27 @@ def test_write_session_snapshot_sparse_all_effect_types() -> None: paths=PathsConfig(preset_root=preset_root, texture_paths=(root / "tex",)), layers={ slot: LayerConfig( - preset=preset_root / DEFAULT_STEM_FOR_SLOT[slot] / "anchor.milk", - stem=DEFAULT_STEM_FOR_SLOT[slot], + preset=preset_root / TEST_LAYER_STEMS[slot] / "anchor.milk", + stem=TEST_LAYER_STEMS[slot], ) - for slot in LAYER_SLOTS + for slot in DEFAULT_LAYER_SLOTS }, visualizer=VisualizerConfig(), config_path=config_path, ) session = TuningSession( - layer_z_order=list(LAYER_SLOTS), + layer_z_order=list(DEFAULT_LAYER_SLOTS), layers={ slot: LayerRuntime( - stem=DEFAULT_STEM_FOR_SLOT[slot], + stem=TEST_LAYER_STEMS[slot], playlist=playlist_at_dir( - preset_root / DEFAULT_STEM_FOR_SLOT[slot], index=0 + preset_root / TEST_LAYER_STEMS[slot], index=0 ), - browse_floor=preset_root / DEFAULT_STEM_FOR_SLOT[slot], + browse_floor=preset_root / TEST_LAYER_STEMS[slot], effects=session_effects[slot], ) - for slot in LAYER_SLOTS + for slot in DEFAULT_LAYER_SLOTS }, ) @@ -234,12 +236,74 @@ def test_write_session_snapshot_sparse_all_effect_types() -> None: "grit": {"centroid": 5}, } - round_trip = _parse_layers({"layers": data["layers"]}, preset_root) + round_trip, _ = _parse_layers({"layers": data["layers"]}, preset_root) assert round_trip["layer_1"].effects == session_effects["layer_1"] assert round_trip["layer_2"].effects["pulse"] == {"sub_bass": 40} assert round_trip["layer_3"].effects["hue"] == {"pitch": 25} +def _stem_for_snapshot_slot(slot: str) -> str: + return TEST_LAYER_STEMS.get(slot, "full_mix") + + +def _snapshot_round_trip_layer_count(layer_count: int) -> None: + slots = [f"layer_{i}" for i in range(1, layer_count + 1)] + session_order = list(reversed(slots)) + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + preset_root = root / "presets" + make_preset_dirs(preset_root) + + config_path = root / "cleave.config.yaml" + config_path.write_text("layers: {}\n") + + cfg = CleaveConfig( + paths=PathsConfig(preset_root=preset_root, texture_paths=(root / "tex",)), + layers={ + slot: LayerConfig( + preset=preset_root + / _stem_for_snapshot_slot(slot) + / "anchor.milk", + stem=_stem_for_snapshot_slot(slot), + ) + for slot in slots + }, + visualizer=VisualizerConfig(), + config_path=config_path, + layer_z_order=list(slots), + ) + + session = TuningSession( + layer_z_order=session_order, + layers={ + slot: LayerRuntime( + stem=_stem_for_snapshot_slot(slot), + playlist=playlist_at_dir( + preset_root / _stem_for_snapshot_slot(slot), index=0 + ), + browse_floor=preset_root / _stem_for_snapshot_slot(slot), + ) + for slot in slots + }, + ) + + out_path = root / "snapshot.yaml" + write_session_snapshot(out_path, cfg=cfg, session=session) + + data = yaml.safe_load(out_path.read_text(encoding="utf-8")) + assert set(data["layers"]) == set(slots) + assert data["layer_z_order"] == session_order + + round_trip, ctx = _parse_layers({"layers": data["layers"]}, preset_root) + assert set(round_trip) == set(slots) + assert ctx.layer_slots == tuple(sorted(slots, key=lambda s: int(s.split("_")[1]))) + + +@pytest.mark.parametrize("layer_count", [3, 6]) +def test_write_session_snapshot_persist_layers_round_trip(layer_count: int) -> None: + _snapshot_round_trip_layer_count(layer_count) + + def test_write_session_snapshot_uses_session_z_order_when_valid() -> None: session_order = ["layer_4", "layer_1", "layer_2", "layer_3"] with tempfile.TemporaryDirectory() as tmp: @@ -254,27 +318,27 @@ def test_write_session_snapshot_uses_session_z_order_when_valid() -> None: paths=PathsConfig(preset_root=preset_root, texture_paths=(root / "tex",)), layers={ slot: LayerConfig( - preset=preset_root / DEFAULT_STEM_FOR_SLOT[slot] / "anchor.milk", - stem=DEFAULT_STEM_FOR_SLOT[slot], + preset=preset_root / TEST_LAYER_STEMS[slot] / "anchor.milk", + stem=TEST_LAYER_STEMS[slot], ) - for slot in LAYER_SLOTS + for slot in DEFAULT_LAYER_SLOTS }, visualizer=VisualizerConfig(), config_path=config_path, - layer_z_order=LAYER_SLOTS, + layer_z_order=list(DEFAULT_LAYER_SLOTS), ) session = TuningSession( layer_z_order=session_order, layers={ slot: LayerRuntime( - stem=DEFAULT_STEM_FOR_SLOT[slot], + stem=TEST_LAYER_STEMS[slot], playlist=playlist_at_dir( - preset_root / DEFAULT_STEM_FOR_SLOT[slot], index=0 + preset_root / TEST_LAYER_STEMS[slot], index=0 ), - browse_floor=preset_root / DEFAULT_STEM_FOR_SLOT[slot], + browse_floor=preset_root / TEST_LAYER_STEMS[slot], ) - for slot in LAYER_SLOTS + for slot in DEFAULT_LAYER_SLOTS }, ) @@ -286,7 +350,7 @@ def test_write_session_snapshot_uses_session_z_order_when_valid() -> None: def test_write_session_snapshot_falls_back_to_cfg_z_order_when_invalid() -> None: - cfg_order = ("layer_1", "layer_3", "layer_2", "layer_4") + cfg_order = ["layer_1", "layer_3", "layer_2", "layer_4"] with tempfile.TemporaryDirectory() as tmp: root = Path(tmp) preset_root = root / "presets" @@ -299,10 +363,10 @@ def test_write_session_snapshot_falls_back_to_cfg_z_order_when_invalid() -> None paths=PathsConfig(preset_root=preset_root, texture_paths=(root / "tex",)), layers={ slot: LayerConfig( - preset=preset_root / DEFAULT_STEM_FOR_SLOT[slot] / "anchor.milk", - stem=DEFAULT_STEM_FOR_SLOT[slot], + preset=preset_root / TEST_LAYER_STEMS[slot] / "anchor.milk", + stem=TEST_LAYER_STEMS[slot], ) - for slot in LAYER_SLOTS + for slot in DEFAULT_LAYER_SLOTS }, visualizer=VisualizerConfig(), config_path=config_path, @@ -313,13 +377,13 @@ def test_write_session_snapshot_falls_back_to_cfg_z_order_when_invalid() -> None layer_z_order=["layer_1", "layer_2"], layers={ slot: LayerRuntime( - stem=DEFAULT_STEM_FOR_SLOT[slot], + stem=TEST_LAYER_STEMS[slot], playlist=playlist_at_dir( - preset_root / DEFAULT_STEM_FOR_SLOT[slot], index=0 + preset_root / TEST_LAYER_STEMS[slot], index=0 ), - browse_floor=preset_root / DEFAULT_STEM_FOR_SLOT[slot], + browse_floor=preset_root / TEST_LAYER_STEMS[slot], ) - for slot in LAYER_SLOTS + for slot in DEFAULT_LAYER_SLOTS }, ) @@ -343,26 +407,26 @@ def test_write_session_snapshot_includes_upscale() -> None: paths=PathsConfig(preset_root=preset_root, texture_paths=(root / "tex",)), layers={ slot: LayerConfig( - preset=preset_root / DEFAULT_STEM_FOR_SLOT[slot] / "anchor.milk", - stem=DEFAULT_STEM_FOR_SLOT[slot], + preset=preset_root / TEST_LAYER_STEMS[slot] / "anchor.milk", + stem=TEST_LAYER_STEMS[slot], ) - for slot in LAYER_SLOTS + for slot in DEFAULT_LAYER_SLOTS }, visualizer=VisualizerConfig(width=1280, height=720, upscale=2.0), config_path=config_path, ) session = TuningSession( - layer_z_order=list(LAYER_SLOTS), + layer_z_order=list(DEFAULT_LAYER_SLOTS), layers={ slot: LayerRuntime( - stem=DEFAULT_STEM_FOR_SLOT[slot], + stem=TEST_LAYER_STEMS[slot], playlist=playlist_at_dir( - preset_root / DEFAULT_STEM_FOR_SLOT[slot], index=0 + preset_root / TEST_LAYER_STEMS[slot], index=0 ), - browse_floor=preset_root / DEFAULT_STEM_FOR_SLOT[slot], + browse_floor=preset_root / TEST_LAYER_STEMS[slot], ) - for slot in LAYER_SLOTS + for slot in DEFAULT_LAYER_SLOTS }, ) @@ -386,27 +450,27 @@ def test_write_session_snapshot_sparse_beat_sensitivity() -> None: paths=PathsConfig(preset_root=preset_root, texture_paths=(root / "tex",)), layers={ slot: LayerConfig( - preset=preset_root / DEFAULT_STEM_FOR_SLOT[slot] / "anchor.milk", - stem=DEFAULT_STEM_FOR_SLOT[slot], + preset=preset_root / TEST_LAYER_STEMS[slot] / "anchor.milk", + stem=TEST_LAYER_STEMS[slot], ) - for slot in LAYER_SLOTS + for slot in DEFAULT_LAYER_SLOTS }, visualizer=VisualizerConfig(beat_sensitivity=2.0), config_path=config_path, ) session = TuningSession( - layer_z_order=list(LAYER_SLOTS), + layer_z_order=list(DEFAULT_LAYER_SLOTS), layers={ slot: LayerRuntime( - stem=DEFAULT_STEM_FOR_SLOT[slot], + stem=TEST_LAYER_STEMS[slot], playlist=playlist_at_dir( - preset_root / DEFAULT_STEM_FOR_SLOT[slot], index=0 + preset_root / TEST_LAYER_STEMS[slot], index=0 ), - browse_floor=preset_root / DEFAULT_STEM_FOR_SLOT[slot], + browse_floor=preset_root / TEST_LAYER_STEMS[slot], beat_sensitivity=1.5 if slot == "layer_2" else 2.0, ) - for slot in LAYER_SLOTS + for slot in DEFAULT_LAYER_SLOTS }, ) @@ -432,31 +496,31 @@ def test_write_session_snapshot_omits_all_zero_effects() -> None: paths=PathsConfig(preset_root=preset_root, texture_paths=(root / "tex",)), layers={ slot: LayerConfig( - preset=preset_root / DEFAULT_STEM_FOR_SLOT[slot] / "anchor.milk", - stem=DEFAULT_STEM_FOR_SLOT[slot], + preset=preset_root / TEST_LAYER_STEMS[slot] / "anchor.milk", + stem=TEST_LAYER_STEMS[slot], ) - for slot in LAYER_SLOTS + for slot in DEFAULT_LAYER_SLOTS }, visualizer=VisualizerConfig(), config_path=config_path, ) session = TuningSession( - layer_z_order=list(LAYER_SLOTS), + layer_z_order=list(DEFAULT_LAYER_SLOTS), layers={ slot: LayerRuntime( - stem=DEFAULT_STEM_FOR_SLOT[slot], + stem=TEST_LAYER_STEMS[slot], playlist=playlist_at_dir( - preset_root / DEFAULT_STEM_FOR_SLOT[slot], index=0 + preset_root / TEST_LAYER_STEMS[slot], index=0 ), - browse_floor=preset_root / DEFAULT_STEM_FOR_SLOT[slot], + browse_floor=preset_root / TEST_LAYER_STEMS[slot], effects=( {"pulse": {"onset": 0}, "flare": {"onset": 0}} if slot == "layer_3" else {} ), ) - for slot in LAYER_SLOTS + for slot in DEFAULT_LAYER_SLOTS }, ) @@ -507,7 +571,7 @@ def _snapshot_fixture(tmp_path: Path) -> tuple[CleaveConfig, TuningSession, Path config_path.write_text( yaml.safe_dump( { - "layers": {slot: {**template_layer_entry(slot), "preset": f"presets/{DEFAULT_STEM_FOR_SLOT[slot]}/anchor.milk"} for slot in LAYER_SLOTS}, + "layers": {slot: {**template_layer_entry(slot), "preset": f"presets/{TEST_LAYER_STEMS[slot]}/anchor.milk"} for slot in DEFAULT_LAYER_SLOTS}, "render": { "post_fx": { "enabled": True, @@ -550,10 +614,10 @@ def _snapshot_fixture(tmp_path: Path) -> tuple[CleaveConfig, TuningSession, Path paths=PathsConfig(preset_root=preset_root, texture_paths=(root / "tex",)), layers={ slot: LayerConfig( - preset=preset_root / DEFAULT_STEM_FOR_SLOT[slot] / "anchor.milk", - stem=DEFAULT_STEM_FOR_SLOT[slot], + preset=preset_root / TEST_LAYER_STEMS[slot] / "anchor.milk", + stem=TEST_LAYER_STEMS[slot], ) - for slot in LAYER_SLOTS + for slot in DEFAULT_LAYER_SLOTS }, visualizer=VisualizerConfig(), config_path=config_path, @@ -565,7 +629,7 @@ def _snapshot_fixture(tmp_path: Path) -> tuple[CleaveConfig, TuningSession, Path ), ) session = TuningSession( - layer_z_order=list(LAYER_SLOTS), + layer_z_order=list(DEFAULT_LAYER_SLOTS), render_post_fx=RenderPostFxRuntime( enabled=True, expanded=False, @@ -592,9 +656,9 @@ def _snapshot_fixture(tmp_path: Path) -> tuple[CleaveConfig, TuningSession, Path slot: LayerRuntime( playlist=playlist_at_dir(preset_root / slot, index=0), browse_floor=preset_root / slot, - stem=DEFAULT_STEM_FOR_SLOT[slot], + stem=TEST_LAYER_STEMS[slot], ) - for slot in LAYER_SLOTS + for slot in DEFAULT_LAYER_SLOTS }, ) return cfg, session, root / "snapshot.yaml" @@ -732,7 +796,10 @@ def test_write_session_snapshot_persists_timeline_at_bottom(tmp_path: Path) -> N {"t": 10.0, "layers": {"layer_3": False}}, ] - timeline = parse_timeline_section(data) + timeline = parse_timeline_section( + data, + ParseCtx(layer_slots=tuple(cfg.layer_z_order)), + ) assert timeline is not None playlists = _round_trip_playlists(cfg.paths.preset_root) cfg_with_timeline = CleaveConfig( @@ -768,8 +835,8 @@ def _round_trip_preset_dirs(root: Path) -> Path: def _round_trip_playlists(preset_root: Path) -> dict[str, object]: return { - slot: playlist_at_dir(preset_root / DEFAULT_STEM_FOR_SLOT[slot], index=0) - for slot in LAYER_SLOTS + slot: playlist_at_dir(preset_root / TEST_LAYER_STEMS[slot], index=0) + for slot in DEFAULT_LAYER_SLOTS } diff --git a/tests/cleave/test_gl_compositor.py b/tests/cleave/test_gl_compositor.py index a7217b5..f82622b 100644 --- a/tests/cleave/test_gl_compositor.py +++ b/tests/cleave/test_gl_compositor.py @@ -2,10 +2,12 @@ from __future__ import annotations +from unittest.mock import MagicMock + import pytest from cleave.blend_modes import BLEND_MODES -from cleave.gl_compositor import GlCompositor +from cleave.gl_compositor import GlCompositor, LayerFbo # Modes whose GL blend func uses GL_SRC_ALPHA (opacity stays in glColor alpha). _OPACITY_VIA_ALPHA = frozenset({"add"}) @@ -88,3 +90,23 @@ def test_pulse_zero_opacity_can_still_leave_flash_visible() -> None: assert effective_opacity(1.0, 100, 0.0) == 0.0 assert flash_alpha(100, 0.15) >= 0.01 + + +def test_remove_layer_fbo_removes_and_destroys() -> None: + compositor = GlCompositor.__new__(GlCompositor) + fbo = MagicMock(spec=LayerFbo) + fbo.name = "layer_5" + compositor._layers = [fbo] + + compositor.remove_layer_fbo("layer_5") + + fbo.destroy.assert_called_once() + assert compositor._layers == [] + + +def test_remove_layer_fbo_unknown_name_raises() -> None: + compositor = GlCompositor.__new__(GlCompositor) + compositor._layers = [] + + with pytest.raises(ValueError, match="no layer FBO named 'missing'"): + compositor.remove_layer_fbo("missing") diff --git a/tests/cleave/test_preset_playlist.py b/tests/cleave/test_preset_playlist.py index d12581b..16db216 100644 --- a/tests/cleave/test_preset_playlist.py +++ b/tests/cleave/test_preset_playlist.py @@ -6,7 +6,7 @@ from pathlib import Path from cleave.config import load_config -from cleave.config_schema import LAYER_SLOTS +from cleave.config_schema import DEFAULT_LAYER_SLOTS from cleave.preset_playlist import ( directory_display, list_navigable_dirs, @@ -15,6 +15,7 @@ preset_filename_display, scan_all_layers, scan_preset_playlist, + scan_single_layer, ) @@ -232,7 +233,33 @@ def test_container_directory_anchor_no_direct_presets() -> None: def test_scan_all_layers_uses_slot_keys(minimal_project: Path) -> None: cfg = load_config(project_root=minimal_project) playlists = scan_all_layers(cfg) - assert tuple(playlists.keys()) == LAYER_SLOTS - for slot in LAYER_SLOTS: + assert tuple(playlists.keys()) == DEFAULT_LAYER_SLOTS + for slot in DEFAULT_LAYER_SLOTS: assert playlists[slot].current is not None assert len(playlists[slot].paths) >= 1 + + +def test_scan_single_layer_picks_from_available_presets() -> None: + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + preset_dir = root / "pack" + preset_dir.mkdir() + milk_paths = [] + for name in ("alpha.milk", "beta.milk", "gamma.milk"): + path = preset_dir / name + _write_milk(path) + milk_paths.append(path.resolve()) + + playlist = scan_single_layer("layer_5", preset_dir, root) + assert playlist.current in milk_paths + + +def test_scan_single_layer_empty_root_returns_empty_playlist() -> None: + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + empty = root / "empty" + empty.mkdir() + + playlist = scan_single_layer("layer_5", empty, root) + assert playlist.current is None + assert playlist.paths == () diff --git a/tests/cleave/test_timeline.py b/tests/cleave/test_timeline.py index bb4b0fa..1ca025a 100644 --- a/tests/cleave/test_timeline.py +++ b/tests/cleave/test_timeline.py @@ -4,7 +4,7 @@ import pytest -from cleave.config_schema import LAYER_SLOTS +from cleave.config_schema import DEFAULT_LAYER_SLOTS from cleave.timeline import ( RECORD_DEBOUNCE_SEC, TimelineCue, @@ -17,7 +17,7 @@ def _defaults(**overrides: bool) -> dict[str, bool]: - base = {slot: True for slot in LAYER_SLOTS} + base = {slot: True for slot in DEFAULT_LAYER_SLOTS} base.update(overrides) return base @@ -71,14 +71,27 @@ def test_layer_visible_at_last_write_wins_per_slot() -> None: def test_visible_state_at_returns_all_slots() -> None: defaults = _defaults(layer_1=False) cues = [TimelineCue(t=1.0, layers={"layer_2": False})] - state = visible_state_at(cues, defaults, 2.0) - assert set(state) == set(LAYER_SLOTS) + state = visible_state_at(cues, defaults, list(DEFAULT_LAYER_SLOTS), 2.0) + assert set(state) == set(DEFAULT_LAYER_SLOTS) assert state["layer_1"] is False assert state["layer_2"] is False assert state["layer_3"] is True assert state["layer_4"] is True +def test_visible_state_at_with_six_slots() -> None: + slots = [f"layer_{i}" for i in range(1, 7)] + defaults = {slot: True for slot in slots} + defaults["layer_3"] = False + cues = [TimelineCue(t=1.0, layers={"layer_4": False})] + state = visible_state_at(cues, defaults, slots, 2.0) + assert set(state) == set(slots) + assert state["layer_3"] is False + assert state["layer_4"] is False + assert state["layer_1"] is True + assert state["layer_6"] is True + + def test_punch_replace_removes_armed_cues_in_range() -> None: cues = [ TimelineCue(t=1.0, layers={"layer_1": False}), diff --git a/tests/cleave/viz/test_app.py b/tests/cleave/viz/test_app.py index c014f30..026efbe 100644 --- a/tests/cleave/viz/test_app.py +++ b/tests/cleave/viz/test_app.py @@ -6,7 +6,8 @@ import pygame -from cleave.config_schema import DEFAULT_STEM_FOR_SLOT, LAYER_SLOTS +from cleave.config_schema import DEFAULT_LAYER_SLOTS +from tests.support.config import TEST_LAYER_STEMS from cleave.extract import STEM_NAMES from cleave.viz.app import ( LiveVisualizerRuntime, @@ -105,14 +106,14 @@ def _run_seed(*, upscale: float = 2.0) -> VisualizerSeed: def _timeline_open_runtime(compositor: MagicMock) -> LiveVisualizerRuntime: runtime = _minimal_runtime(compositor) runtime.overlay = TuningOverlay() - runtime.seed.session.layer_z_order = list(LAYER_SLOTS) + runtime.seed.session.layer_z_order = list(DEFAULT_LAYER_SLOTS) runtime.seed.session.layers = { slot: LayerRuntime( playlist=MagicMock(), browse_floor=MagicMock(), - stem=DEFAULT_STEM_FOR_SLOT[slot], + stem=TEST_LAYER_STEMS[slot], ) - for slot in LAYER_SLOTS + for slot in DEFAULT_LAYER_SLOTS } runtime.seed.session.timeline.enabled = True runtime.seed.session.timeline.panel_open = True @@ -758,4 +759,4 @@ def test_tick_frame_restores_timeline_after_overlay_shown_again( overlay.notify_input() app.tick_frame(1.0, paused=True, draw_overlay=True) mock_draw_timeline.assert_called_once() - assert runtime.seed.session.timeline.panel_open is True + assert runtime.seed.session.timeline.panel_open is True \ No newline at end of file diff --git a/tests/cleave/viz/test_controls.py b/tests/cleave/viz/test_controls.py index eb53a54..0ace1a4 100644 --- a/tests/cleave/viz/test_controls.py +++ b/tests/cleave/viz/test_controls.py @@ -12,7 +12,8 @@ import pygame import pytest -from cleave.config_schema import DEFAULT_STEM_FOR_SLOT, LAYER_SLOTS +from cleave.config_schema import DEFAULT_LAYER_SLOTS, MAX_LAYER_COUNT +from tests.support.config import TEST_LAYER_STEMS from cleave.preset_playlist import ( PresetPlaylist, directory_display, @@ -123,7 +124,7 @@ def _make_controls( slot: LayerRuntime( playlist=_make_playlist(slot), browse_floor=preset_root / slot, - stem=DEFAULT_STEM_FOR_SLOT.get(slot, "drums"), + stem=TEST_LAYER_STEMS.get(slot, "drums"), opacity_pct=50, ) for slot in slots @@ -141,6 +142,49 @@ def _make_controls( ) +def _make_controls_with_manager( + slots: tuple[str, ...] = ("layer_1",), + *, + can_add: bool = True, + can_remove: bool = True, + launch_config_path: Path | None = _DEFAULT_ACTIVE_CONFIG, + repo_root_example: Path = _REPO_ROOT_EXAMPLE, +) -> tuple[TuningControls, MagicMock]: + preset_root = Path("/tmp/presets") + cfg = make_test_cfg(slots, preset_root=preset_root, config_path=launch_config_path or _DEFAULT_ACTIVE_CONFIG) + session = TuningSession( + layer_z_order=list(slots), + layers={ + slot: LayerRuntime( + playlist=_make_playlist(slot), + browse_floor=preset_root / slot, + stem=TEST_LAYER_STEMS.get(slot, "drums"), + opacity_pct=50, + ) + for slot in slots + }, + ) + layer_manager = MagicMock() + layer_manager.can_add.return_value = can_add + layer_manager.can_remove.return_value = can_remove + controls = TuningControls( + session, + cfg, + preset_root=preset_root, + playback=stub_playback_state(), + duration_sec=120.0, + launch_config_path=launch_config_path, + repo_root_example=repo_root_example, + layer_manager=layer_manager, + ) + return controls, layer_manager + + +def _confirm_modal_yes(controls: TuningControls) -> None: + controls.handle_keydown(_keydown(pygame.K_RETURN)) + controls.handle_keydown(_keydown(pygame.K_RETURN)) + + def _config_header_row(view: TuningViewState) -> int: return next( i for i in range(row_count(view)) if row_kind(view, i) == RowKind.CONFIG_HEADER @@ -162,6 +206,125 @@ def _choose_overwrite(controls: TuningControls) -> None: controls.handle_keydown(_keydown(pygame.K_RETURN)) +def test_add_layer_at_max_shows_toast() -> None: + controls, manager = _make_controls_with_manager(("layer_1",), can_add=False) + view = controls.build_view_state(paused=False) + controls.focus_index = find_row_by_kind(view, RowKind.LAYER_MANAGEMENT_ADD) + + controls.handle_keydown(_keydown(pygame.K_RETURN)) + + manager.add_layer.assert_not_called() + assert controls.modal_host.view_state() is None + assert ( + controls.build_view_state(paused=False).toast_message + == f"Maximum {MAX_LAYER_COUNT} layers" + ) + + +def test_delete_layer_at_min_shows_toast() -> None: + controls, manager = _make_controls_with_manager(("layer_1",), can_remove=False) + controls.session.layers["layer_1"].expanded = True + view = controls.build_view_state(paused=False) + controls.focus_index = find_row( + view, "layer_1", RowKind.LAYER_MANAGEMENT_DELETE + ) + + controls.handle_keydown(_keydown(pygame.K_RETURN)) + + manager.remove_layer.assert_not_called() + assert controls.modal_host.view_state() is None + assert ( + controls.build_view_state(paused=False).toast_message + == "Must have at least 1 layer" + ) + + +def test_add_layer_confirm_calls_manager() -> None: + controls, manager = _make_controls_with_manager(("layer_1",)) + + def add_layer() -> None: + controls.session.layer_z_order.append("layer_2") + controls.session.layers["layer_2"] = LayerRuntime( + playlist=_make_playlist("layer_2"), + browse_floor=Path("/tmp/presets/layer_2"), + stem="bass", + opacity_pct=50, + ) + + manager.add_layer.side_effect = add_layer + view = controls.build_view_state(paused=False) + before_count = row_count(view) + controls.focus_index = find_row_by_kind(view, RowKind.LAYER_MANAGEMENT_ADD) + + _confirm_modal_yes(controls) + + manager.add_layer.assert_called_once() + view = controls.build_view_state(paused=False) + assert row_count(view) > before_count + assert "layer_2" in controls.session.layer_z_order + + +def test_delete_layer_confirm_calls_manager() -> None: + controls, manager = _make_controls_with_manager(("layer_1", "layer_2")) + controls.session.layers["layer_2"].expanded = True + + def remove_layer(slot: str) -> None: + controls.session.layer_z_order.remove(slot) + del controls.session.layers[slot] + + manager.remove_layer.side_effect = remove_layer + view = controls.build_view_state(paused=False) + controls.focus_index = find_row( + view, "layer_2", RowKind.LAYER_MANAGEMENT_DELETE + ) + + _confirm_modal_yes(controls) + + manager.remove_layer.assert_called_once_with("layer_2") + assert "layer_2" not in controls.session.layer_z_order + + +def test_delete_layer_clamps_timeline_focus_row() -> None: + slots = ("layer_1", "layer_2", "layer_3", "layer_4") + controls, manager = _make_controls_with_manager(slots) + controls.session.timeline.enabled = True + controls.session.timeline.panel_open = True + controls.session.timeline.submenu_focused = True + controls.session.timeline.focus_row = 3 + + def remove_layer(slot: str) -> None: + controls.session.layer_z_order.remove(slot) + del controls.session.layers[slot] + + manager.remove_layer.side_effect = remove_layer + controls._confirm_delete_layer("layer_4") + + manager.remove_layer.assert_called_once_with("layer_4") + assert len(controls.session.layer_z_order) == 3 + assert controls.session.timeline.focus_row == 2 + + +def test_delete_layer_exits_move_mode() -> None: + controls, manager = _make_controls_with_manager(("layer_1", "layer_2")) + controls.session.layers["layer_2"].expanded = True + + def remove_layer(slot: str) -> None: + controls.session.layer_z_order.remove(slot) + del controls.session.layers[slot] + + manager.remove_layer.side_effect = remove_layer + view = controls.build_view_state(paused=False) + controls.focus_index = _row(view, "layer_1", RowKind.TRACK_HEADER) + controls.handle_keydown(_keydown(pygame.K_RETURN)) + assert controls.move_mode_slot == "layer_1" + + controls._delete_layer("layer_2") + controls.handle_keydown(_keydown(pygame.K_RETURN)) + + assert controls.move_mode_slot is None + manager.remove_layer.assert_called_once_with("layer_2") + + def _row( view: TuningViewState, stem: str, @@ -278,6 +441,7 @@ def test_re_enable_without_expanding() -> None: controls.session.layers["layer_1"].expanded = False view = controls.build_view_state(paused=False) header_row = _row(view, "layer_1", RowKind.TRACK_HEADER) + add_layer_row = find_row_by_kind(view, RowKind.LAYER_MANAGEMENT_ADD) render_overlay_row = find_row_by_kind(view, RowKind.RENDER_OVERLAY_HEADER) transport_row = next( i for i in range(row_count(view)) if row_kind(view, i) == RowKind.TRANSPORT @@ -291,6 +455,9 @@ def test_re_enable_without_expanding() -> None: view, RowKind.RENDER_TIMELINE_HEADER ) + controls.handle_keydown(_keydown(pygame.K_DOWN)) + assert controls.focus_index == add_layer_row + controls.handle_keydown(_keydown(pygame.K_DOWN)) assert controls.focus_index == render_overlay_row @@ -305,6 +472,9 @@ def test_re_enable_without_expanding() -> None: assert controls.session.layers["layer_1"].enabled is True assert controls.session.layers["layer_1"].expanded is False + controls.handle_keydown(_keydown(pygame.K_DOWN)) + assert controls.focus_index == add_layer_row + controls.handle_keydown(_keydown(pygame.K_DOWN)) assert controls.focus_index == render_overlay_row @@ -753,7 +923,7 @@ def test_navigable_rows_without_overwrite() -> None: ) view = controls.build_view_state(paused=False) assert view.allow_overwrite is False - assert row_count(view) == 14 + assert row_count(view) == 15 kinds = {row_kind(view, i) for i in range(row_count(view))} assert RowKind.CONFIG_HEADER in kinds @@ -774,7 +944,7 @@ def test_navigable_rows_with_overwrite() -> None: controls = _make_controls(("layer_1",)) view = controls.build_view_state(paused=False) assert view.allow_overwrite is True - assert row_count(view) == 14 + assert row_count(view) == 15 config_row = _config_header_row(view) assert config_row in navigable_row_indices(view) @@ -2671,4 +2841,4 @@ def test_try_quit_overwrite_confirm_esc_clears_quit_after_save() -> None: assert controls._config_save._quit_after_save is False assert controls._config_save._pending_exit is False assert controls.config_dirty - assert not controls.modal_host.active + assert not controls.modal_host.active \ No newline at end of file diff --git a/tests/cleave/viz/test_help_overlay.py b/tests/cleave/viz/test_help_overlay.py index daa7d4b..8c3c02a 100644 --- a/tests/cleave/viz/test_help_overlay.py +++ b/tests/cleave/viz/test_help_overlay.py @@ -120,6 +120,20 @@ def test_render_overlay_sub_header_help_expand_collapse() -> None: assert "adjust value" not in entries.values() +def test_layer_management_add_help() -> None: + section = _sections_for(RowKind.LAYER_MANAGEMENT_ADD)[0] + assert section.title == "Add new layer" + assert dict(section.entries)["Enter"] == "confirm add" + + +def test_layer_management_delete_help() -> None: + section = _sections_for(RowKind.LAYER_MANAGEMENT_DELETE)[0] + assert section.title == "Delete layer" + entries = dict(section.entries) + assert entries["Enter"] == "confirm delete" + assert entries[""] == "at least 1 layer required" + + 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_input_dispatch.py b/tests/cleave/viz/test_input_dispatch.py index f05c156..4815325 100644 --- a/tests/cleave/viz/test_input_dispatch.py +++ b/tests/cleave/viz/test_input_dispatch.py @@ -7,7 +7,8 @@ import pygame -from cleave.config_schema import DEFAULT_STEM_FOR_SLOT, LAYER_SLOTS +from cleave.config_schema import DEFAULT_LAYER_SLOTS +from tests.support.config import TEST_LAYER_STEMS from cleave.extract import STEM_NAMES from cleave.viz.app import LiveVisualizerRuntime, VisualizerSeed from cleave.viz.controls import TuningControls @@ -33,14 +34,14 @@ def _make_runtime( ) -> LiveVisualizerRuntime: preset_root = Path("/tmp/presets") session = TuningSession( - layer_z_order=list(LAYER_SLOTS), + layer_z_order=list(DEFAULT_LAYER_SLOTS), layers={ slot: LayerRuntime( playlist=make_playlist(slot), browse_floor=preset_root / slot, - stem=DEFAULT_STEM_FOR_SLOT[slot], + stem=TEST_LAYER_STEMS[slot], ) - for slot in LAYER_SLOTS + for slot in DEFAULT_LAYER_SLOTS }, ) session.help_visible = help_visible @@ -214,4 +215,4 @@ def test_notify_overlay_skipped_in_submenu() -> None: def test_notify_overlay_when_main_context() -> None: runtime = _make_runtime(submenu_focused=False) assert dispatch_should_notify_overlay(keydown(pygame.K_LEFT), runtime) is True - assert dispatch_should_notify_overlay(keydown(pygame.K_t), runtime) is False + assert dispatch_should_notify_overlay(keydown(pygame.K_t), runtime) is False \ No newline at end of file diff --git a/tests/cleave/viz/test_layer.py b/tests/cleave/viz/test_layer.py index b20c07b..b3e9e1c 100644 --- a/tests/cleave/viz/test_layer.py +++ b/tests/cleave/viz/test_layer.py @@ -9,7 +9,8 @@ import pygame import pytest -from cleave.config_schema import DEFAULT_STEM_FOR_SLOT, LAYER_SLOTS +from cleave.config_schema import DEFAULT_LAYER_SLOTS +from tests.support.config import TEST_LAYER_STEMS from cleave.extract import STEM_NAMES from cleave.preset_playlist import PresetPlaylist from cleave.stem_pcm import StemPcmBank @@ -44,7 +45,7 @@ def _playlist(name: str) -> PresetPlaylist: return PresetPlaylist(current_dir=current_dir, paths=paths, index=0) -LAYER_SLOTS_LIST = list(LAYER_SLOTS) +DEFAULT_LAYER_SLOTS_LIST = list(DEFAULT_LAYER_SLOTS) def _session( @@ -54,9 +55,9 @@ def _session( cues: list[TimelineCue] | None = None, solo_slot: str | None = None, ) -> TuningSession: - enabled = layer_enabled or {slot: True for slot in LAYER_SLOTS} + enabled = layer_enabled or {slot: True for slot in DEFAULT_LAYER_SLOTS} return TuningSession( - layer_z_order=list(LAYER_SLOTS), + layer_z_order=list(DEFAULT_LAYER_SLOTS), solo_slot=solo_slot, timeline=TimelineRuntime( enabled=timeline_enabled, @@ -66,10 +67,10 @@ def _session( slot: LayerRuntime( playlist=_playlist(slot), browse_floor=Path(f"/tmp/presets/{slot}"), - stem=DEFAULT_STEM_FOR_SLOT[slot], + stem=TEST_LAYER_STEMS[slot], enabled=enabled[slot], ) - for slot in LAYER_SLOTS + for slot in DEFAULT_LAYER_SLOTS }, ) @@ -238,7 +239,7 @@ def test_apply_layer_visibility_sets_fbo_enabled_from_timeline() -> None: timeline_enabled=True, cues=[TimelineCue(t=3.0, layers={"layer_3": False})], ) - layers_by_slot = {slot: _stem_layer(slot) for slot in LAYER_SLOTS} + layers_by_slot = {slot: _stem_layer(slot) for slot in DEFAULT_LAYER_SLOTS} apply_layer_visibility(session, layers_by_slot, 2.0) assert layers_by_slot["layer_3"].fbo.enabled is True @@ -756,4 +757,4 @@ def test_timeline_bar_preserves_pre_first_cue_after_layer_toggle() -> None: (5.0, 10.0, True), (10.0, 15.0, False), (15.0, 60.0, True), - ] + ] \ No newline at end of file diff --git a/tests/cleave/viz/test_layer_manager.py b/tests/cleave/viz/test_layer_manager.py new file mode 100644 index 0000000..06dec77 --- /dev/null +++ b/tests/cleave/viz/test_layer_manager.py @@ -0,0 +1,164 @@ +"""Unit tests for LayerManager.""" + +from __future__ import annotations + +from pathlib import Path +from unittest.mock import MagicMock, patch + +from cleave.config_schema import MAX_LAYER_COUNT, MIN_LAYER_COUNT +from cleave.preset_playlist import PresetPlaylist +from cleave.timeline import TimelineCue +from cleave.viz.layer import StemLayer +from cleave.viz.session import LayerRuntime, TuningSession +from cleave.viz.wiring import LayerManager, _discard_timeline_slot +from tests.support.viz import make_test_cfg + + +def _manager( + slots: tuple[str, ...] = ("layer_1",), +) -> tuple[LayerManager, MagicMock]: + preset_root = Path("/tmp/presets") + cfg = make_test_cfg(slots, preset_root=preset_root) + session = TuningSession( + layer_z_order=list(slots), + layers={ + slot: LayerRuntime( + playlist=PresetPlaylist( + current_dir=preset_root / slot, + paths=(preset_root / slot / "preset-0.milk",), + index=0, + ), + browse_floor=preset_root / slot, + stem="drums", + ) + for slot in slots + }, + ) + compositor = MagicMock() + layers: list[StemLayer] = [] + layers_by_slot: dict[str, StemLayer] = {} + playlists = { + slot: PresetPlaylist( + current_dir=preset_root / slot, + paths=(preset_root / slot / "preset-0.milk",), + index=0, + ) + for slot in slots + } + for slot in slots: + stem_layer = StemLayer( + slot=slot, + pm=MagicMock(), + fbo=MagicMock(), + playlist=playlists[slot], + ) + layers.append(stem_layer) + layers_by_slot[slot] = stem_layer + + manager = LayerManager( + cfg=cfg, + session=session, + compositor=compositor, + layers=layers, + layers_by_slot=layers_by_slot, + playlists=playlists, + preset_root=preset_root, + project_dir=Path("/tmp/projects/test"), + fps=30, + texture_paths=[], + ) + return manager, compositor + + +def test_can_add_and_can_remove_respect_limits() -> None: + manager, _ = _manager(("layer_1",)) + assert manager.can_add() is True + assert manager.can_remove() is False + + manager.session.layer_z_order = [f"layer_{i}" for i in range(1, MAX_LAYER_COUNT + 1)] + assert manager.can_add() is False + assert manager.can_remove() is True + + manager.session.layer_z_order = [f"layer_{i}" for i in range(1, MIN_LAYER_COUNT + 1)] + assert manager.can_remove() is False + + +@patch("cleave.viz.wiring.LayerFramePipeline.build_single") +@patch("cleave.viz.wiring.scan_single_layer") +def test_add_layer_updates_cfg_session_and_collections( + scan_single_layer: MagicMock, + build_single: MagicMock, +) -> None: + manager, compositor = _manager(("layer_1",)) + playlist = PresetPlaylist( + current_dir=Path("/tmp/presets/new"), + paths=(Path("/tmp/presets/new/preset.milk"),), + index=0, + ) + scan_single_layer.return_value = playlist + stem_layer = StemLayer( + slot="layer_2", + pm=MagicMock(), + fbo=MagicMock(), + playlist=playlist, + ) + build_single.return_value = stem_layer + + slot = manager.add_layer() + + assert slot == "layer_2" + assert "layer_2" in manager.cfg.layers + assert manager.cfg.layer_z_order == ["layer_1", "layer_2"] + assert manager.session.layer_z_order == ["layer_1", "layer_2"] + assert manager.session.layers["layer_2"].stem == "full_mix" + assert manager.layers_by_slot["layer_2"] is stem_layer + assert manager.playlists["layer_2"] is playlist + assert stem_layer in manager.layers + build_single.assert_called_once() + assert build_single.call_args.kwargs["beat_sensitivity"] == manager.cfg.visualizer.beat_sensitivity + + +@patch("cleave.viz.wiring.LayerFramePipeline.destroy_single") +def test_remove_layer_updates_cfg_session_and_collections( + destroy_single: MagicMock, +) -> None: + manager, compositor = _manager(("layer_1", "layer_2")) + manager.session.solo_slot = "layer_2" + + manager.remove_layer("layer_2") + + destroy_single.assert_called_once_with( + "layer_2", manager.layers, manager.layers_by_slot, compositor + ) + assert "layer_2" not in manager.cfg.layers + assert manager.cfg.layer_z_order == ["layer_1"] + assert manager.session.layer_z_order == ["layer_1"] + assert "layer_2" not in manager.session.layers + assert manager.session.solo_slot is None + assert "layer_2" not in manager.playlists + + +def test_discard_timeline_slot_strips_slot_from_timeline_state() -> None: + session = TuningSession(layer_z_order=["layer_1", "layer_2"]) + session.timeline.armed_slots.add("layer_2") + session.timeline.override_slots.add("layer_2") + session.timeline.record_baseline["layer_2"] = True + session.timeline.monitor["layer_2"] = False + session.timeline.override_visible["layer_2"] = True + session.timeline.arm_flash_start_ms["layer_2"] = 100 + session.timeline.record_buffer = [ + TimelineCue(t=1.0, layers={"layer_1": True, "layer_2": False}), + TimelineCue(t=2.0, layers={"layer_2": True}), + ] + + _discard_timeline_slot(session, "layer_2") + + assert "layer_2" not in session.timeline.armed_slots + assert "layer_2" not in session.timeline.override_slots + assert "layer_2" not in session.timeline.record_baseline + assert "layer_2" not in session.timeline.monitor + assert "layer_2" not in session.timeline.override_visible + assert "layer_2" not in session.timeline.arm_flash_start_ms + assert session.timeline.record_buffer == [ + TimelineCue(t=1.0, layers={"layer_1": True}), + ] diff --git a/tests/cleave/viz/test_layer_pipeline.py b/tests/cleave/viz/test_layer_pipeline.py new file mode 100644 index 0000000..c4cb36f --- /dev/null +++ b/tests/cleave/viz/test_layer_pipeline.py @@ -0,0 +1,38 @@ +"""Unit tests for LayerFramePipeline add/remove helpers.""" + +from __future__ import annotations + +from pathlib import Path +from unittest.mock import MagicMock + +from cleave.preset_playlist import PresetPlaylist +from cleave.viz.layer import StemLayer +from cleave.viz.layer_pipeline import LayerFramePipeline + + +def _stem_layer(slot: str) -> StemLayer: + current_dir = Path(f"/tmp/presets/{slot}") + return StemLayer( + slot=slot, + pm=MagicMock(), + fbo=MagicMock(), + playlist=PresetPlaylist( + current_dir=current_dir, + paths=(current_dir / "preset.milk",), + index=0, + ), + ) + + +def test_destroy_single_tears_down_gl_and_updates_collections() -> None: + layer = _stem_layer("layer_5") + layers = [layer] + layers_by_slot = {"layer_5": layer} + compositor = MagicMock() + + LayerFramePipeline.destroy_single("layer_5", layers, layers_by_slot, compositor) + + assert layers == [] + assert layers_by_slot == {} + layer.pm.destroy.assert_called_once() + compositor.remove_layer_fbo.assert_called_once_with("layer_5") diff --git a/tests/cleave/viz/test_overlay.py b/tests/cleave/viz/test_overlay.py index 2f56e23..405eba9 100644 --- a/tests/cleave/viz/test_overlay.py +++ b/tests/cleave/viz/test_overlay.py @@ -4,7 +4,8 @@ import pygame -from cleave.config_schema import DEFAULT_STEM_FOR_SLOT, LAYER_SLOTS +from cleave.config_schema import DEFAULT_LAYER_SLOTS +from tests.support.config import TEST_LAYER_STEMS from cleave.extract import STEM_NAMES from cleave.viz.material_icons import row_icon_prefix_width from cleave.viz.row_semantics import RowKind @@ -54,7 +55,7 @@ def _effects_expanded_view_state() -> TuningViewState: tracks = { slot: TrackBlock( - stem=DEFAULT_STEM_FOR_SLOT[slot], + stem=TEST_LAYER_STEMS[slot], preset_dir_label=f"{slot}/dir", preset_label=f"{slot}/preset.milk", blend_mode="add", @@ -64,10 +65,10 @@ def _effects_expanded_view_state() -> TuningViewState: effects_expanded=True, expanded=True, ) - for slot in LAYER_SLOTS + for slot in DEFAULT_LAYER_SLOTS } return TuningViewState( - layer_z_order=LAYER_SLOTS, + layer_z_order=DEFAULT_LAYER_SLOTS, tracks=tracks, paused=False, position_sec=0.0, @@ -489,6 +490,163 @@ def test_draw_timeline_layer_hint_without_error() -> None: assert hint_idx in visible_row_indices(state) +def test_build_row_layout_includes_add_before_render_gap() -> None: + state = _minimal_view_state() + add_idx = find_row_by_kind(state, RowKind.LAYER_MANAGEMENT_ADD) + gap_idx = find_row_by_kind(state, RowKind.RENDER_SECTION_GAP) + overlay_idx = find_row_by_kind(state, RowKind.RENDER_OVERLAY_HEADER) + assert add_idx < gap_idx < overlay_idx + + +def test_delete_row_after_effects_when_expanded() -> None: + 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, + effects_expanded=True, + ) + }, + ) + layout = build_row_layout(state) + delete_idx = find_row(state, "layer_1", RowKind.LAYER_MANAGEMENT_DELETE) + effects_header = find_row(state, "layer_1", RowKind.TRACK_EFFECTS_HEADER) + effect_rows = [ + index + for index, row in enumerate(layout) + if row.kind == RowKind.TRACK_EFFECT and row.slot == "layer_1" + ] + assert effect_rows + assert delete_idx > effects_header + assert delete_idx > max(effect_rows) + + +def test_delete_row_omitted_when_track_collapsed() -> None: + 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=False, + ) + }, + ) + kinds = [row.kind for row in build_row_layout(state)] + assert RowKind.LAYER_MANAGEMENT_DELETE not in kinds + + +def test_delete_row_navigable_when_locked() -> None: + 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, + locked=True, + ) + }, + ) + delete_row = find_row(state, "layer_1", RowKind.LAYER_MANAGEMENT_DELETE) + assert delete_row in navigable_row_indices(state) + + +def test_add_row_always_navigable() -> None: + collapsed = _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=False, + ) + }, + ) + expanded = _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, + ) + }, + ) + for state in (collapsed, expanded): + add_row = find_row_by_kind(state, RowKind.LAYER_MANAGEMENT_ADD) + assert add_row in navigable_row_indices(state) + + +def test_delete_row_disabled_color_single_layer() -> None: + 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, + ) + }, + ) + delete_row = find_row(state, "layer_1", RowKind.LAYER_MANAGEMENT_DELETE) + assert _row_value_color(state, delete_row) == DISABLED + + +def test_draw_layer_management_rows_without_error() -> None: + pygame.init() + overlay = TuningOverlay() + 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, + effects_expanded=True, + ) + }, + ) + surface = pygame.Surface((1280, 720), pygame.SRCALPHA) + overlay.notify_input() + overlay.draw(surface, state) + assert overlay.panel_rect is not None + add_row = find_row_by_kind(state, RowKind.LAYER_MANAGEMENT_ADD) + delete_row = find_row(state, "layer_1", RowKind.LAYER_MANAGEMENT_DELETE) + assert add_row in visible_row_indices(state) + assert delete_row in visible_row_indices(state) + + def test_render_overlay_row_layout_includes_header_and_sub_rows_when_expanded() -> None: state = _minimal_view_state( render_overlay=RenderOverlayBlock(expanded=True), @@ -767,4 +925,4 @@ def test_toast_stays_at_panel_bottom() -> None: toast_active=True, ) assert toast_layout.toast_y is not None - assert toast_layout.toast_y == panel_h - overlay._padding - line_h + assert toast_layout.toast_y == panel_h - overlay._padding - line_h \ No newline at end of file diff --git a/tests/cleave/viz/test_render.py b/tests/cleave/viz/test_render.py index 47c6d2e..fe70052 100644 --- a/tests/cleave/viz/test_render.py +++ b/tests/cleave/viz/test_render.py @@ -18,7 +18,7 @@ session_from_cfg, ) from cleave.paths import repo_root -from cleave.config_schema import LAYER_SLOTS +from cleave.config_schema import DEFAULT_LAYER_SLOTS from cleave.extract import STEM_NAMES, stems_dir from cleave.project import write_manifest from cleave.separate import project_stems_complete @@ -83,7 +83,7 @@ def _mock_render_runtime( seed.cfg = MagicMock() seed.cfg.render = None seed.session = TuningSession( - layer_z_order=list(LAYER_SLOTS), + layer_z_order=list(DEFAULT_LAYER_SLOTS), render_overlay=replace(default_render_overlay_runtime(), enabled=False), render_post_fx=RenderPostFxRuntime( enabled=False, diff --git a/tests/cleave/viz/test_row_semantics.py b/tests/cleave/viz/test_row_semantics.py index 8837efa..fce8bb3 100644 --- a/tests/cleave/viz/test_row_semantics.py +++ b/tests/cleave/viz/test_row_semantics.py @@ -113,6 +113,7 @@ def test_track_sub_row_kinds() -> None: RowKind.TRACK_BEAT, RowKind.TRACK_EFFECTS_HEADER, RowKind.TRACK_EFFECT, + RowKind.LAYER_MANAGEMENT_DELETE, } ) @@ -172,7 +173,9 @@ def test_locked_navigable_sub_row_kinds() -> None: navigable = frozenset( k for k in TRACK_SUB_ROW_KINDS if row_navigable_when_layer_locked(k) ) - assert navigable == frozenset({RowKind.TRACK_EFFECTS_HEADER}) + assert navigable == frozenset( + {RowKind.TRACK_EFFECTS_HEADER, RowKind.LAYER_MANAGEMENT_DELETE} + ) def test_track_value_rows_blocked_by_layer_lock() -> None: @@ -196,11 +199,12 @@ def test_track_value_rows_blocked_by_layer_lock() -> None: def test_only_effects_header_navigable_when_layer_locked() -> None: + navigable_when_locked = { + RowKind.TRACK_EFFECTS_HEADER, + RowKind.LAYER_MANAGEMENT_DELETE, + } for kind in TRACK_SUB_ROW_KINDS: - if kind == RowKind.TRACK_EFFECTS_HEADER: - assert row_navigable_when_layer_locked(kind) is True - else: - assert row_navigable_when_layer_locked(kind) is False + assert row_navigable_when_layer_locked(kind) == (kind in navigable_when_locked) def test_labeled_sub_row_kinds_exclude_headers() -> None: diff --git a/tests/cleave/viz/test_session.py b/tests/cleave/viz/test_session.py new file mode 100644 index 0000000..50febb2 --- /dev/null +++ b/tests/cleave/viz/test_session.py @@ -0,0 +1,59 @@ +"""Unit tests for session add/remove helpers.""" + +from __future__ import annotations + +from pathlib import Path + +from cleave.preset_playlist import PresetPlaylist +from cleave.viz.session import ( + LayerRuntime, + TuningSession, + add_layer_to_session, + remove_layer_from_session, +) + + +def _runtime(slot: str) -> LayerRuntime: + current_dir = Path(f"/tmp/presets/{slot}") + return LayerRuntime( + playlist=PresetPlaylist(current_dir=current_dir, paths=(), index=0), + browse_floor=current_dir, + stem="full_mix", + ) + + +def test_add_layer_to_session_appends_slot_and_runtime() -> None: + session = TuningSession(layer_z_order=["layer_1"], layers={"layer_1": _runtime("layer_1")}) + runtime = _runtime("layer_2") + + add_layer_to_session(session, "layer_2", runtime) + + assert session.layer_z_order == ["layer_1", "layer_2"] + assert session.layers["layer_2"] is runtime + + +def test_remove_layer_from_session_drops_slot() -> None: + session = TuningSession( + layer_z_order=["layer_1", "layer_2"], + layers={ + "layer_1": _runtime("layer_1"), + "layer_2": _runtime("layer_2"), + }, + ) + + remove_layer_from_session(session, "layer_2") + + assert session.layer_z_order == ["layer_1"] + assert "layer_2" not in session.layers + + +def test_remove_layer_from_session_clears_solo() -> None: + session = TuningSession( + layer_z_order=["layer_1"], + layers={"layer_1": _runtime("layer_1")}, + solo_slot="layer_1", + ) + + remove_layer_from_session(session, "layer_1") + + assert session.solo_slot is None diff --git a/tests/cleave/viz/test_timeline_controls.py b/tests/cleave/viz/test_timeline_controls.py index d6d046c..447ace7 100644 --- a/tests/cleave/viz/test_timeline_controls.py +++ b/tests/cleave/viz/test_timeline_controls.py @@ -6,7 +6,8 @@ import pygame -from cleave.config_schema import DEFAULT_STEM_FOR_SLOT, LAYER_SLOTS +from cleave.config_schema import DEFAULT_LAYER_SLOTS +from tests.support.config import TEST_LAYER_STEMS from cleave.extract import STEM_NAMES from cleave.timeline import TimelineCue from cleave.viz.controls import SEEK_LONG, SEEK_SHORT, TuningControls @@ -17,7 +18,7 @@ def _make_timeline_controls( *, - slots: tuple[str, ...] = tuple(LAYER_SLOTS), + slots: tuple[str, ...] = tuple(DEFAULT_LAYER_SLOTS), cues: list[TimelineCue] | None = None, focus_row: int = 0, armed_slots: set[str] | None = None, @@ -41,7 +42,7 @@ def _make_timeline_controls( slot: LayerRuntime( playlist=make_playlist(slot), browse_floor=preset_root / slot, - stem=DEFAULT_STEM_FOR_SLOT[slot], + stem=TEST_LAYER_STEMS.get(slot, "drums"), ) for slot in slots }, @@ -215,6 +216,38 @@ def test_num_keys_toggle_monitor_when_paused() -> None: assert visibility_calls == [True, True] +def test_num_key_5_toggles_fifth_layer_when_paused() -> None: + slots = tuple(f"layer_{i}" for i in range(1, 6)) + controls, session, visibility_calls, _, _, _ = _make_timeline_controls( + slots=slots, + position_sec=3.0, + ) + controls.playback.paused = True + session.timeline.preview_active = True + session.timeline.monitor = {slot: True for slot in slots} + + controls.handle_keydown(keydown(pygame.K_5)) + assert session.timeline.monitor["layer_5"] is False + assert session.timeline.monitor["layer_4"] is True + assert visibility_calls == [True] + + +def test_num_keys_beyond_layer_count_are_noop() -> None: + controls, session, visibility_calls, _, _, _ = _make_timeline_controls() + controls.playback.paused = True + session.timeline.preview_active = True + session.timeline.monitor = {slot: True for slot in DEFAULT_LAYER_SLOTS} + + controls.handle_keydown(keydown(pygame.K_5)) + assert session.timeline.monitor == { + "layer_1": True, + "layer_2": True, + "layer_3": True, + "layer_4": True, + } + assert visibility_calls == [] + + def test_num_keys_ignored_when_playing_not_in_override() -> None: controls, session, visibility_calls, _, _, _ = _make_timeline_controls() session.layers["layer_1"].enabled = True @@ -841,4 +874,4 @@ def segments() -> list[tuple[float, float, bool]]: session.layers["layer_1"].enabled = True session.timeline.enabled = True assert segments() == expected - assert expected[0] == (0.0, 3.0, False) + assert expected[0] == (0.0, 3.0, False) \ No newline at end of file diff --git a/tests/cleave/viz/test_timeline_overlay.py b/tests/cleave/viz/test_timeline_overlay.py index d98c134..7bd0331 100644 --- a/tests/cleave/viz/test_timeline_overlay.py +++ b/tests/cleave/viz/test_timeline_overlay.py @@ -4,7 +4,8 @@ import pygame -from cleave.config_schema import DEFAULT_STEM_FOR_SLOT, LAYER_SLOTS +from cleave.config_schema import DEFAULT_LAYER_SLOTS +from tests.support.config import TEST_LAYER_STEMS from cleave.extract import STEM_NAMES from cleave.timeline import TimelineCue, layer_visible_at, stem_abbreviation from cleave.viz.material_icons import visibility_icon_slot_width @@ -62,9 +63,11 @@ def _view_state( override_slots: set[str] | None = None, arm_flash_start_ms: dict[str, int] | None = None, ) -> TimelineViewState: - order = list(layer_z_order or list(LAYER_SLOTS)) + order = list(layer_z_order or list(DEFAULT_LAYER_SLOTS)) cue_list = list(cues or []) - default_map = dict(defaults or {slot: True for slot in LAYER_SLOTS}) + default_map = dict( + defaults or {slot: True for slot in (layer_z_order or list(DEFAULT_LAYER_SLOTS))} + ) if monitor_visible is None: monitor_visible = { stem: layer_visible_at(cue_list, default_map, stem, position_sec) @@ -75,7 +78,7 @@ def _view_state( return TimelineViewState( layer_z_order=order, slot_stems={ - slot: DEFAULT_STEM_FOR_SLOT.get(slot, slot) # type: ignore[arg-type] + slot: TEST_LAYER_STEMS.get(slot, "drums") for slot in order }, cues=cue_list, @@ -121,6 +124,19 @@ def test_row_prefix_width_includes_monitor_eye_slot() -> None: assert row_prefix_width(layer_num_w, abbrev_w, row_h) == layer_num_w + abbrev_w + eye_slot_w +def test_layer_num_width_probe_scales_with_eight_layers() -> None: + pygame.init() + overlay = TimelineOverlay() + surface = pygame.Surface((1280, 720), pygame.SRCALPHA) + order = [f"layer_{i}" for i in range(1, 9)] + defaults = {slot: True for slot in order} + _draw(overlay, surface, _view_state(layer_z_order=order, defaults=defaults)) + + font = pygame.font.SysFont("monospace", timeline_ui_metrics().font_size) + expected = font.render(layer_num_prefix(8), True, (255, 255, 255)).get_width() + assert overlay._layer_num_width == expected + + def test_dual_eye_positions_monitor_left_committed_right() -> None: pygame.init() margin = 10 @@ -480,7 +496,7 @@ def test_visibility_segments_default_only() -> None: def test_visibility_segments_from_cues() -> None: - defaults = {slot: True for slot in LAYER_SLOTS} + defaults = {slot: True for slot in DEFAULT_LAYER_SLOTS} cues = [ TimelineCue(t=10.0, layers={"layer_1": False}), TimelineCue(t=30.0, layers={"layer_1": True}), @@ -494,7 +510,7 @@ def test_visibility_segments_from_cues() -> None: def test_visibility_segments_other_stem_unchanged_across_unrelated_cue() -> None: - defaults = {slot: True for slot in LAYER_SLOTS} + defaults = {slot: True for slot in DEFAULT_LAYER_SLOTS} cues = [TimelineCue(t=5.0, layers={"layer_1": False})] segments = visibility_segments(cues, defaults, "layer_2", 20.0) assert segments == [(0.0, 5.0, True), (5.0, 20.0, True)] @@ -590,7 +606,7 @@ def test_draw_skipped_when_disabled() -> None: def test_armed_row_layout_recorded() -> None: pygame.init() overlay = TimelineOverlay() - state = _view_state(armed_slots={"layer_1"}, layer_z_order=list(LAYER_SLOTS)) + state = _view_state(armed_slots={"layer_1"}, layer_z_order=list(DEFAULT_LAYER_SLOTS)) surface = pygame.Surface((800, 400), pygame.SRCALPHA) _draw(overlay, surface, state) @@ -605,7 +621,7 @@ def test_armed_row_layout_recorded() -> None: def test_focus_row_index_matches_stem() -> None: pygame.init() overlay = TimelineOverlay() - state = _view_state(focus_row=2, layer_z_order=list(LAYER_SLOTS)) + state = _view_state(focus_row=2, layer_z_order=list(DEFAULT_LAYER_SLOTS)) surface = pygame.Surface((800, 400), pygame.SRCALPHA) _draw(overlay, surface, state) @@ -676,4 +692,4 @@ def test_upscale_expands_bar_width_not_row_height() -> None: assert upscaled_row_h == baseline_row_h assert upscaled_panel[3] == baseline_panel[3] assert upscaled_bar_width > baseline_bar_width - assert upscaled_panel[2] > baseline_panel[2] + assert upscaled_panel[2] > baseline_panel[2] \ No newline at end of file diff --git a/tests/cleave/viz/test_wiring.py b/tests/cleave/viz/test_wiring.py index 4439173..05d87e4 100644 --- a/tests/cleave/viz/test_wiring.py +++ b/tests/cleave/viz/test_wiring.py @@ -13,8 +13,8 @@ from cleave.viz.mix_player import MixPlayer from cleave.viz.session import LayerRuntime, TuningSession from cleave.viz.layer import StemLayer -from tests.support.viz import stub_playback_state from cleave.viz.wiring import make_tuning_controls +from tests.support.viz import make_test_cfg, stub_playback_state def _make_wired_controls() -> tuple: @@ -44,7 +44,7 @@ def _make_wired_controls() -> tuple: ) controls = make_tuning_controls( session=session, - cfg=None, + cfg=make_test_cfg(("layer_1",)), preset_root=Path("/tmp/presets"), project_dir=Path("/tmp/projects/test"), layers_by_slot=layers_by_slot, @@ -115,7 +115,7 @@ def test_on_stem_change_updates_mix_player_solo_source() -> None: ) controls = make_tuning_controls( session=session, - cfg=None, + cfg=make_test_cfg(("layer_1",)), preset_root=Path("/tmp/presets"), project_dir=Path("/tmp/projects/test"), layers_by_slot=layers_by_slot, diff --git a/tests/support/config.py b/tests/support/config.py index 54c0ffb..cbe2f83 100644 --- a/tests/support/config.py +++ b/tests/support/config.py @@ -6,24 +6,30 @@ from cleave.config import LayerConfig, VIZ_CONFIG_FILENAME, dump_yaml from cleave.config_schema import ( + DEFAULT_LAYER_SLOTS, DEFAULT_LAYER_Z_ORDER, - DEFAULT_STEM_FOR_SLOT, - LAYER_SLOTS, template_layer_entry, template_visualizer_section, ) -from cleave.extract import STEM_NAMES +from cleave.extract import STEM_NAMES, StemSource from cleave.paths import repo_root from cleave.preset_playlist import playlist_at_dir from cleave.viz.session import LayerRuntime +TEST_LAYER_STEMS: dict[str, StemSource] = { + "layer_1": "drums", + "layer_2": "bass", + "layer_3": "vocals", + "layer_4": "other", +} + def repo_root_template_path() -> Path: return repo_root() / VIZ_CONFIG_FILENAME def slot_for_stem(stem: str) -> str: - for slot, assigned in DEFAULT_STEM_FOR_SLOT.items(): + for slot, assigned in TEST_LAYER_STEMS.items(): if assigned == stem: return slot raise KeyError(stem) @@ -39,10 +45,10 @@ def make_preset_dirs(preset_root: Path) -> None: def layer_configs(preset_root: Path) -> dict[str, LayerConfig]: return { slot: LayerConfig( - preset=preset_root / DEFAULT_STEM_FOR_SLOT[slot] / "anchor.milk", - stem=DEFAULT_STEM_FOR_SLOT[slot], + preset=preset_root / TEST_LAYER_STEMS[slot] / "anchor.milk", + stem=TEST_LAYER_STEMS[slot], ) - for slot in LAYER_SLOTS + for slot in DEFAULT_LAYER_SLOTS } @@ -51,8 +57,8 @@ def layer_runtimes( **per_slot: dict, ) -> dict[str, LayerRuntime]: runtimes: dict[str, LayerRuntime] = {} - for slot in LAYER_SLOTS: - stem = DEFAULT_STEM_FOR_SLOT[slot] + for slot in DEFAULT_LAYER_SLOTS: + stem = TEST_LAYER_STEMS[slot] stem_dir = preset_root / stem kwargs = per_slot.get(slot, {}) runtimes[slot] = LayerRuntime( @@ -84,12 +90,12 @@ def write_minimal_config(project_dir: Path, preset_root: Path, **overrides) -> P "layer_z_order": list(DEFAULT_LAYER_Z_ORDER), "layers": { slot: { - **template_layer_entry(slot), + **template_layer_entry(slot, stem=TEST_LAYER_STEMS[slot]), "preset": ( - f"{DEFAULT_STEM_FOR_SLOT[slot]}/{DEFAULT_STEM_FOR_SLOT[slot]}.milk" + f"{TEST_LAYER_STEMS[slot]}/{TEST_LAYER_STEMS[slot]}.milk" ), } - for slot in LAYER_SLOTS + for slot in DEFAULT_LAYER_SLOTS }, } data.update(overrides) diff --git a/tests/support/viz.py b/tests/support/viz.py index dddc3cf..2e1311a 100644 --- a/tests/support/viz.py +++ b/tests/support/viz.py @@ -9,7 +9,8 @@ import pygame from cleave.config import CleaveConfig, LayerConfig, PathsConfig, VisualizerConfig -from cleave.config_schema import DEFAULT_STEM_FOR_SLOT, LAYER_SLOTS +from cleave.config_schema import DEFAULT_LAYER_SLOTS +from tests.support.config import TEST_LAYER_STEMS from cleave.preset_playlist import PresetPlaylist from cleave.viz.controls import TuningControls from cleave.viz.live_layer_bindings import LiveLayerBindings @@ -100,11 +101,11 @@ def make_test_cfg( layers={ slot: LayerConfig( preset=root / slot / "preset-0.milk", - stem=DEFAULT_STEM_FOR_SLOT.get(slot, "drums"), + stem=TEST_LAYER_STEMS.get(slot, "drums"), ) - for slot in LAYER_SLOTS + for slot in DEFAULT_LAYER_SLOTS }, - layer_z_order=slots, + layer_z_order=list(slots), visualizer=VisualizerConfig(), config_path=config_path or Path("/tmp/test/cleave.config.yaml"), ) @@ -124,7 +125,7 @@ def make_controls( slot: LayerRuntime( playlist=make_playlist(slot), browse_floor=preset_root / slot, - stem=DEFAULT_STEM_FOR_SLOT.get(slot, "drums"), + stem=TEST_LAYER_STEMS.get(slot, "drums"), opacity_pct=50, ) for slot in slots @@ -138,4 +139,4 @@ def make_controls( duration_sec=120.0, launch_config_path=launch_config_path, repo_root_example=repo_root_example, - ) + ) \ No newline at end of file From 21134f46fdd894ebb94437057cf357d8581288cb Mon Sep 17 00:00:00 2001 From: SpoddyCoder Date: Sun, 21 Jun 2026 15:32:40 +0100 Subject: [PATCH 3/3] Fix timeline UI scaling issues when tracks != 4 --- cleave/viz/app.py | 1 - cleave/viz/overlay.py | 2 +- cleave/viz/overlay_draw.py | 3 +- cleave/viz/theme.py | 21 ++++++++------ cleave/viz/timeline_overlay.py | 22 +++++++------- tests/cleave/viz/test_overlay.py | 4 +-- tests/cleave/viz/test_overlay_targets.py | 3 +- tests/cleave/viz/test_timeline_overlay.py | 35 ++++++++++++++++------- tests/cleave/viz/test_ui_scale.py | 11 +++++-- 9 files changed, 60 insertions(+), 42 deletions(-) diff --git a/cleave/viz/app.py b/cleave/viz/app.py index 4b0d937..90ac49f 100644 --- a/cleave/viz/app.py +++ b/cleave/viz/app.py @@ -343,7 +343,6 @@ def _tick_frame_live_overlay( runtime.timeline_overlay, runtime.overlay_surface, timeline_state, - runtime.seed.height, visibility=_timeline_strip_fade(tl, overlay_visibility=overlay_visibility), ) diff --git a/cleave/viz/overlay.py b/cleave/viz/overlay.py index 19be24f..3535338 100644 --- a/cleave/viz/overlay.py +++ b/cleave/viz/overlay.py @@ -1298,7 +1298,7 @@ def draw( if timeline_panel_open: from cleave.viz.timeline_overlay import timeline_viewport_reserve_px - max_panel_h -= timeline_viewport_reserve_px(surface.get_height()) + max_panel_h -= timeline_viewport_reserve_px(len(state.layer_z_order)) metrics = scroll_metrics( visible_indices=visible_indices, first_scrollable_visible=first_scrollable_visible, diff --git a/cleave/viz/overlay_draw.py b/cleave/viz/overlay_draw.py index 9de5c2b..f6bfe06 100644 --- a/cleave/viz/overlay_draw.py +++ b/cleave/viz/overlay_draw.py @@ -92,12 +92,11 @@ def draw_timeline( overlay: TimelineOverlay, overlay_surface: pygame.Surface, view_state: TimelineViewState, - content_height: int, *, visibility: float = 1.0, ) -> None: overlay_surface.fill((0, 0, 0, 0)) - overlay.draw(overlay_surface, view_state, content_height=content_height) + overlay.draw(overlay_surface, view_state) panel = overlay.panel_rect if panel is not None and visibility > 0.01: upload_rect = panel diff --git a/cleave/viz/theme.py b/cleave/viz/theme.py index c4aa7f6..94abff1 100644 --- a/cleave/viz/theme.py +++ b/cleave/viz/theme.py @@ -11,8 +11,11 @@ TIMELINE_BAR_ON, PLAYHEAD Layout scales: - UI_SCALE (1.5) — main tuning panel, help, modals, and Material Icons spacing - TIMELINE_UI_SCALE (1.0) — bottom timeline strip height, typography, and spacing + UI_SCALE (1.2) — main tuning panel, help, modals, and Material Icons spacing + TIMELINE_UI_SCALE (1.2) — bottom timeline strip typography and spacing + +Timeline panel height is derived from a fixed per-row height (BASE_TIMELINE_ROW_HEIGHT) +times row count plus padding and gaps via timeline_panel_height_px(). Use tuning_ui_metrics(), timeline_ui_metrics(), and timeline_panel_height_px() for scaled layout; BORDER_WIDTH is not scaled. @@ -28,7 +31,7 @@ BASE_UI_FONT_SIZE: int = 14 UI_SCALE: float = 1.2 TIMELINE_UI_SCALE: float = 1.2 -BASE_TIMELINE_PANEL_HEIGHT_FRACTION: float = 0.13 +BASE_TIMELINE_ROW_HEIGHT: int = 25 def scale_px(value: float, *, scale: float) -> int: @@ -58,6 +61,7 @@ class TuningUiMetrics: class TimelineUiMetrics: font_size: int padding: int + row_height: int row_gap: int margin: int panel_gap: int @@ -93,6 +97,7 @@ def timeline_ui_metrics(*, scale: float = TIMELINE_UI_SCALE) -> TimelineUiMetric return TimelineUiMetrics( font_size=scale_px(14, scale=scale), padding=scale_px(8, scale=scale), + row_height=scale_px(BASE_TIMELINE_ROW_HEIGHT, scale=scale), row_gap=scale_px(2, scale=scale), margin=scale_px(10, scale=scale), panel_gap=scale_px(16, scale=scale), @@ -106,15 +111,15 @@ def timeline_ui_metrics(*, scale: float = TIMELINE_UI_SCALE) -> TimelineUiMetric def timeline_panel_height_px( - content_height: int, + row_count: int, *, scale: float = TIMELINE_UI_SCALE, ) -> int: """Scaled bottom timeline strip height in pixels.""" - return max( - 1, - round(content_height * BASE_TIMELINE_PANEL_HEIGHT_FRACTION * scale), - ) + if row_count <= 0: + return 0 + m = timeline_ui_metrics(scale=scale) + return m.padding * 2 + row_count * m.row_height + max(0, row_count - 1) * m.row_gap _tuning_ui = tuning_ui_metrics() diff --git a/cleave/viz/timeline_overlay.py b/cleave/viz/timeline_overlay.py index 0aa27a2..b5fda4d 100644 --- a/cleave/viz/timeline_overlay.py +++ b/cleave/viz/timeline_overlay.py @@ -33,11 +33,11 @@ OFF_SEGMENT_COLOR: tuple[int, int, int] = (40, 40, 40) -def timeline_viewport_reserve_px(content_height: int, *, margin: int | None = None) -> int: +def timeline_viewport_reserve_px(row_count: int, *, margin: int | None = None) -> int: metrics = timeline_ui_metrics() if margin is None: margin = metrics.margin - panel_h = timeline_panel_height_px(content_height) + panel_h = timeline_panel_height_px(row_count) return panel_h + margin + metrics.panel_gap @@ -360,8 +360,6 @@ def draw( self, surface: pygame.Surface, state: TimelineViewState, - *, - content_height: int, ) -> None: self._panel_rect = None self._header_badge_rect = None @@ -372,23 +370,23 @@ def draw( display_width, display_height = surface.get_size() panel_w = display_width - self._margin * 2 - panel_h = timeline_panel_height_px(content_height) + row_count = len(state.layer_z_order) + if row_count == 0: + return + + metrics = timeline_ui_metrics() + row_h = metrics.row_height + panel_h = timeline_panel_height_px(row_count) panel_x = self._margin panel_y = display_height - panel_h - self._margin font = self._font_get() num_sample = font.render( - layer_num_prefix(max(len(state.layer_z_order), 1)), True, LABEL + layer_num_prefix(max(row_count, 1)), True, LABEL ) abbrev_sample = font.render(stem_abbrev_label("drums"), True, LABEL) self._layer_num_width = num_sample.get_width() self._stem_abbrev_width = abbrev_sample.get_width() - row_count = len(state.layer_z_order) - if row_count == 0: - return - - inner_h = panel_h - self._padding * 2 - row_h = max(1, (inner_h - self._row_gap * (row_count - 1)) // row_count) eye_slot_w = visibility_icon_slot_width(row_h) prefix_width = row_prefix_width( self._layer_num_width, self._stem_abbrev_width, row_h diff --git a/tests/cleave/viz/test_overlay.py b/tests/cleave/viz/test_overlay.py index 405eba9..e15c0db 100644 --- a/tests/cleave/viz/test_overlay.py +++ b/tests/cleave/viz/test_overlay.py @@ -126,7 +126,7 @@ def _panel_scroll_metrics( _, margin_y = overlay._margin max_panel_h = surface_height - margin_y * 2 if timeline_panel_open: - max_panel_h -= timeline_viewport_reserve_px(surface_height) + max_panel_h -= timeline_viewport_reserve_px(len(state.layer_z_order)) toast_active = bool(state.toast_message and state.toast_remaining_sec > 0) @@ -889,7 +889,7 @@ def test_panel_reserves_timeline_viewport_when_open() -> None: assert panel is not None _, py, _, ph = panel _, margin_y = overlay._margin - reserve = timeline_viewport_reserve_px(surface_height) + reserve = timeline_viewport_reserve_px(len(state.layer_z_order)) assert py + ph + reserve <= surface_height - margin_y open_metrics = _panel_scroll_metrics( diff --git a/tests/cleave/viz/test_overlay_targets.py b/tests/cleave/viz/test_overlay_targets.py index 17d0726..d082820 100644 --- a/tests/cleave/viz/test_overlay_targets.py +++ b/tests/cleave/viz/test_overlay_targets.py @@ -85,7 +85,7 @@ def test_draw_timeline_overlay_uses_display_target() -> None: ) OverlayDrawer.draw_timeline( - compositor, overlay, overlay_surface, view_state, content_height=720 + compositor, overlay, overlay_surface, view_state ) compositor.draw_overlay.assert_called_once_with(22, 0, 600, 1280, 120, 1.0) @@ -124,7 +124,6 @@ def test_draw_timeline_overlay_applies_visibility_alpha() -> None: overlay, overlay_surface, view_state, - content_height=720, visibility=0.4, ) diff --git a/tests/cleave/viz/test_timeline_overlay.py b/tests/cleave/viz/test_timeline_overlay.py index 7bd0331..b8b85cb 100644 --- a/tests/cleave/viz/test_timeline_overlay.py +++ b/tests/cleave/viz/test_timeline_overlay.py @@ -104,14 +104,8 @@ def _draw( overlay: TimelineOverlay, surface: pygame.Surface, state: TimelineViewState, - *, - content_height: int | None = None, ) -> None: - overlay.draw( - surface, - state, - content_height=content_height if content_height is not None else surface.get_height(), - ) + overlay.draw(surface, state) def test_row_prefix_width_includes_monitor_eye_slot() -> None: @@ -671,10 +665,9 @@ def test_upscale_expands_bar_width_not_row_height() -> None: pygame.init() overlay = TimelineOverlay() state = _view_state() - content_height = 720 baseline_surface = pygame.Surface((1280, 720), pygame.SRCALPHA) - _draw(overlay, baseline_surface, state, content_height=content_height) + _draw(overlay, baseline_surface, state) baseline_panel = overlay.panel_rect baseline_row_h = overlay.row_layout[0][4] _, baseline_bar_width, _ = overlay.bar_layout @@ -682,7 +675,7 @@ def test_upscale_expands_bar_width_not_row_height() -> None: assert overlay.bar_layout is not None upscaled_surface = pygame.Surface((2560, 1440), pygame.SRCALPHA) - _draw(overlay, upscaled_surface, state, content_height=content_height) + _draw(overlay, upscaled_surface, state) upscaled_panel = overlay.panel_rect upscaled_row_h = overlay.row_layout[0][4] _, upscaled_bar_width, _ = overlay.bar_layout @@ -692,4 +685,24 @@ def test_upscale_expands_bar_width_not_row_height() -> None: assert upscaled_row_h == baseline_row_h assert upscaled_panel[3] == baseline_panel[3] assert upscaled_bar_width > baseline_bar_width - assert upscaled_panel[2] > baseline_panel[2] \ No newline at end of file + assert upscaled_panel[2] > baseline_panel[2] + + +def test_row_height_constant_across_layer_counts() -> None: + pygame.init() + overlay = TimelineOverlay() + surface = pygame.Surface((1280, 720), pygame.SRCALPHA) + expected_row_h = timeline_ui_metrics().row_height + + for row_count in (1, 2, 4, 8): + order = [f"layer_{i}" for i in range(1, row_count + 1)] + state = _view_state(layer_z_order=order) + _draw(overlay, surface, state) + assert overlay.row_layout + assert overlay.row_layout[0][4] == expected_row_h + assert overlay.panel_rect is not None + assert overlay.panel_rect[3] == ( + timeline_ui_metrics().padding * 2 + + row_count * expected_row_h + + max(0, row_count - 1) * timeline_ui_metrics().row_gap + ) \ No newline at end of file diff --git a/tests/cleave/viz/test_ui_scale.py b/tests/cleave/viz/test_ui_scale.py index 342b48a..9de9a80 100644 --- a/tests/cleave/viz/test_ui_scale.py +++ b/tests/cleave/viz/test_ui_scale.py @@ -23,14 +23,19 @@ def test_timeline_ui_metrics_default_scale() -> None: metrics = timeline_ui_metrics(scale=1.0) assert metrics.font_size == 14 assert metrics.padding == 8 + assert metrics.row_height == 25 assert metrics.row_gap == 2 assert metrics.panel_gap == 16 def test_timeline_panel_height_px_scales_with_ui_scale() -> None: - assert timeline_panel_height_px(720, scale=1.0) == 94 - assert timeline_panel_height_px(720, scale=1.2) == 112 - assert timeline_panel_height_px(720, scale=1.5) == 140 + assert timeline_panel_height_px(4, scale=1.0) == 122 + assert timeline_panel_height_px(4, scale=1.2) == 146 + + +def test_timeline_ui_metrics_row_height_scales() -> None: + assert timeline_ui_metrics(scale=1.0).row_height == 25 + assert timeline_ui_metrics(scale=1.2).row_height == 30 def test_scale_px_rounds_and_clamps() -> None: