Skip to content

Commit 25e3215

Browse files
committed
Save apply timeline preset button config to yaml
1 parent 24d9013 commit 25e3215

7 files changed

Lines changed: 226 additions & 2 deletions

File tree

.cursor/rules/live-tuning-ui.mdc

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,7 @@ Below overlay, **Render: POST FX** (`RowKind.RENDER_POST_FX_HEADER`) is always p
5252

5353
**Section lock** (overlay, post-FX, timeline, and layer tracks share one mechanism in [cleave/viz/row_semantics.py](cleave/viz/row_semantics.py)): persisted `locked` on `RenderOverlayConfig` / `RenderPostFxConfig` / `TimelineConfig` and the matching runtimes. **l** on `RENDER_OVERLAY_HEADER`, `RENDER_POST_FX_HEADER`, `RENDER_TIMELINE_HEADER` (or `TRACK_HEADER`) toggles it and draws a red `LOCK_ICON` on the header. When locked: the header still expands/opens and expandable child headers stay navigable, but value and action children are drawn in `LOCKED` and skipped in navigation (`section_locked(state, desc)` with `row_navigable_when_section_locked` / `row_blocked_by_section_lock`); **Ctrl** enable/disable is refused while solo stays allowed. `section_locked` accepts either a `TuningViewState` (tracks / `render_timeline`) or a `TuningSession` (layers / `timeline`). In preset curation mode, `section_locked` always returns false (lock icon, `LOCKED` coloring, navigation skip, and mutation blocks are all ignored) so layer browse and **f** / **b** / **r** stay available.
5454

55-
Below post-FX, **Render: TIMELINE** (`RowKind.RENDER_TIMELINE_HEADER`) is always present. This row is a **panel anchor** (not an expandable section): the timeline strip is hosted separately ([cleave/viz/timeline_overlay.py](cleave/viz/timeline_overlay.py)), not as `RowLayout` children. Eye semantics match post-FX (no solo in v1). **Ctrl+Right** / **Ctrl+Left** sets `session.timeline.enabled`; **Right** opens the timeline strip without entering the submenu; **Left** closes it. **t** toggles the strip: when closed, opens and enters the submenu on row 0; when open, closes and returns focus to this header. Expand arrow reflects `session.timeline.panel_open` (▼ when open). Disable closes the strip; **Right** / **t** can still open it while disabled (same expand-while-disabled semantics as layer and other render headers). State: `RenderTimelineBlock` / `TimelineRuntime` on session ([cleave/viz/session.py](cleave/viz/session.py)). `enabled` and `locked` persist via config snapshot; `panel_open` is UI-only. When the strip is open, main-panel children under **Render: TIMELINE** are: song markers (expandable; marker items and **snap to song markers** when expanded), beat / bar grid (expandable; placement snap, bar grid, bar phase, snap to grid), timeline fades (expandable; song markers and standard cues each with enabled/disabled and fade in/out duration when enabled), timeline preset (expandable; character, crescendo, density, and **apply timeline preset** when expanded), and reset timeline. **snap to song markers** is a green action row inside the song markers section (last row after marker items; Enter opens proximity then layer-scope choice modals). **snap to grid** is a green action row under beat / bar grid (Enter opens BEATS / BARS / CANCEL). When the timeline is locked, those children are blocked and skipped in navigation (expandable headers stay navigable), and the strip stays openable and seekable (Seek Left/Right); Space still toggles transport play/pause (session-only preview), but arm, record, override, number-key visibility, and Ctrl+Space record stay blocked ([cleave/viz/timeline_controls.py](cleave/viz/timeline_controls.py)); preset/snap/phase controllers also refuse while locked. **l** on the timeline header is refused while `timeline.recording` is true.
55+
Below post-FX, **Render: TIMELINE** (`RowKind.RENDER_TIMELINE_HEADER`) is always present. This row is a **panel anchor** (not an expandable section): the timeline strip is hosted separately ([cleave/viz/timeline_overlay.py](cleave/viz/timeline_overlay.py)), not as `RowLayout` children. Eye semantics match post-FX (no solo in v1). **Ctrl+Right** / **Ctrl+Left** sets `session.timeline.enabled`; **Right** opens the timeline strip without entering the submenu; **Left** closes it. **t** toggles the strip: when closed, opens and enters the submenu on row 0; when open, closes and returns focus to this header. Expand arrow reflects `session.timeline.panel_open` (▼ when open). Disable closes the strip; **Right** / **t** can still open it while disabled (same expand-while-disabled semantics as layer and other render headers). State: `RenderTimelineBlock` / `TimelineRuntime` on session ([cleave/viz/session.py](cleave/viz/session.py)). `enabled` and `locked` persist via config snapshot; staged timeline preset character / crescendo / density persist under `timeline.preset`; `panel_open` is UI-only. When the strip is open, main-panel children under **Render: TIMELINE** are: song markers (expandable; marker items and **snap to song markers** when expanded), beat / bar grid (expandable; placement snap, bar grid, bar phase, snap to grid), timeline fades (expandable; song markers and standard cues each with enabled/disabled and fade in/out duration when enabled), timeline preset (expandable; character, crescendo, density, and **apply timeline preset** when expanded), and reset timeline. **snap to song markers** is a green action row inside the song markers section (last row after marker items; Enter opens proximity then layer-scope choice modals). **snap to grid** is a green action row under beat / bar grid (Enter opens BEATS / BARS / CANCEL). When the timeline is locked, those children are blocked and skipped in navigation (expandable headers stay navigable), and the strip stays openable and seekable (Seek Left/Right); Space still toggles transport play/pause (session-only preview), but arm, record, override, number-key visibility, and Ctrl+Space record stay blocked ([cleave/viz/timeline_controls.py](cleave/viz/timeline_controls.py)); preset/snap/phase controllers also refuse while locked. **l** on the timeline header is refused while `timeline.recording` is true.
5656

5757
## Timeline panel
5858

cleave/config.py

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -102,6 +102,12 @@
102102
require_non_negative_number,
103103
)
104104
from cleave.timeline import TimelineLane
105+
from cleave.timeline_presets.characters import DEFAULT_TIMELINE_PRESET_KIND
106+
from cleave.timeline_presets.crescendo import CrescendoTarget
107+
from cleave.timeline_presets.density import (
108+
DEFAULT_TIMELINE_PRESET_DENSITY,
109+
TimelinePresetDensity,
110+
)
105111

106112
VIZ_CONFIG_FILENAME = "cleave-viz.yaml"
107113

@@ -247,13 +253,23 @@ class TimelineFadesConfig:
247253
standard: TimelineFadeGroupConfig = field(default_factory=TimelineFadeGroupConfig)
248254

249255

256+
@dataclass(frozen=True)
257+
class TimelinePresetConfig:
258+
"""Staged character / crescendo / density for the timeline preset action."""
259+
260+
character: str = DEFAULT_TIMELINE_PRESET_KIND
261+
crescendo: CrescendoTarget | None = None
262+
density: TimelinePresetDensity = DEFAULT_TIMELINE_PRESET_DENSITY
263+
264+
250265
@dataclass(frozen=True)
251266
class TimelineConfig:
252267
enabled: bool
253268
lanes: dict[str, TimelineLane]
254269
locked: bool = False
255270
fades: TimelineFadesConfig = field(default_factory=TimelineFadesConfig)
256271
placement_snap: TimelinePlacementSnap = DEFAULT_TIMELINE_PLACEMENT_SNAP
272+
preset: TimelinePresetConfig = field(default_factory=TimelinePresetConfig)
257273

258274

259275
@dataclass

cleave/config_schema.py

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,16 @@
1414
from cleave.effects.registry import validate_effect_entry
1515
from cleave.extract import STEM_SOURCES, StemSource
1616
from cleave.timeline import SlotCue, TimelineLane, canonicalize
17+
from cleave.timeline_presets.characters import (
18+
DEFAULT_TIMELINE_PRESET_KIND,
19+
TIMELINE_PRESET_KIND_OPTIONS,
20+
)
21+
from cleave.timeline_presets.crescendo import CrescendoTarget
22+
from cleave.timeline_presets.density import (
23+
DEFAULT_TIMELINE_PRESET_DENSITY,
24+
TIMELINE_PRESET_DENSITY_OPTIONS,
25+
TimelinePresetDensity,
26+
)
1727

1828
# --- Editor defaults ---
1929

@@ -329,6 +339,32 @@ def parse_timeline_placement_snap(raw: Any, label: str) -> TimelinePlacementSnap
329339
raise ValueError(f"{label} must be one of: {allowed}")
330340
return value # type: ignore[return-value]
331341

342+
343+
def parse_timeline_preset_character(raw: Any, label: str) -> str:
344+
value = str(raw)
345+
if value not in TIMELINE_PRESET_KIND_OPTIONS:
346+
allowed = ", ".join(TIMELINE_PRESET_KIND_OPTIONS)
347+
raise ValueError(f"{label} must be one of: {allowed}")
348+
return value
349+
350+
351+
def parse_timeline_preset_crescendo(raw: Any, label: str) -> CrescendoTarget | None:
352+
if raw is None:
353+
return None
354+
value = str(raw)
355+
if value not in ("last", "penultimate"):
356+
raise ValueError(f"{label} must be one of: last, penultimate, or null")
357+
return value # type: ignore[return-value]
358+
359+
360+
def parse_timeline_preset_density(raw: Any, label: str) -> TimelinePresetDensity:
361+
value = str(raw)
362+
if value not in TIMELINE_PRESET_DENSITY_OPTIONS:
363+
allowed = ", ".join(TIMELINE_PRESET_DENSITY_OPTIONS)
364+
raise ValueError(f"{label} must be one of: {allowed}")
365+
return value # type: ignore[return-value]
366+
367+
332368
FieldSource = Literal["cfg", "session", "both"]
333369
T = TypeVar("T")
334370

@@ -1922,6 +1958,28 @@ def _parse_timeline_fade_group(raw: Any, label: str) -> Any:
19221958
)
19231959

19241960

1961+
def _parse_timeline_preset(raw: Any) -> Any:
1962+
from cleave.config import TimelinePresetConfig
1963+
1964+
if raw is None:
1965+
return TimelinePresetConfig()
1966+
preset_map = as_mapping(raw, "timeline.preset")
1967+
return TimelinePresetConfig(
1968+
character=parse_timeline_preset_character(
1969+
preset_map.get("character", DEFAULT_TIMELINE_PRESET_KIND),
1970+
"timeline.preset.character",
1971+
),
1972+
crescendo=parse_timeline_preset_crescendo(
1973+
preset_map.get("crescendo"),
1974+
"timeline.preset.crescendo",
1975+
),
1976+
density=parse_timeline_preset_density(
1977+
preset_map.get("density", DEFAULT_TIMELINE_PRESET_DENSITY),
1978+
"timeline.preset.density",
1979+
),
1980+
)
1981+
1982+
19251983
def parse_timeline_section(data: dict[str, Any], ctx: ParseCtx) -> Any | None:
19261984
from cleave.config import TimelineConfig, TimelineFadesConfig
19271985

@@ -1950,6 +2008,7 @@ def parse_timeline_section(data: dict[str, Any], ctx: ParseCtx) -> Any | None:
19502008
"timeline.fades.standard",
19512009
),
19522010
)
2011+
preset = _parse_timeline_preset(timeline_map.get("preset"))
19532012
# Legacy timeline.cues is ignored (clean break; no migration).
19542013
lanes_raw = timeline_map.get("lanes")
19552014
if lanes_raw is None:
@@ -1959,6 +2018,7 @@ def parse_timeline_section(data: dict[str, Any], ctx: ParseCtx) -> Any | None:
19592018
locked=locked,
19602019
fades=fades,
19612020
placement_snap=placement_snap,
2021+
preset=preset,
19622022
)
19632023
lanes_map = as_mapping(lanes_raw, "timeline.lanes")
19642024
if ctx.layer_slots is None:
@@ -2008,6 +2068,7 @@ def parse_timeline_section(data: dict[str, Any], ctx: ParseCtx) -> Any | None:
20082068
locked=locked,
20092069
fades=fades,
20102070
placement_snap=placement_snap,
2071+
preset=preset,
20112072
)
20122073

20132074

@@ -2029,6 +2090,11 @@ def persist_timeline(ctx: PersistCtx) -> dict[str, Any]:
20292090
"song_markers": _persist_timeline_fade_group(runtime.song_marker_fades),
20302091
"standard": _persist_timeline_fade_group(runtime.standard_cue_fades),
20312092
},
2093+
"preset": {
2094+
"character": runtime.timeline_preset_kind,
2095+
"crescendo": runtime.timeline_preset_crescendo,
2096+
"density": runtime.timeline_preset_density,
2097+
},
20322098
}
20332099
lanes_out: dict[str, Any] = {}
20342100
for slot in sorted(runtime.lanes):

cleave/timeline_presets/density.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
"""Session-only stack-density staging for timeline presets."""
1+
"""Stack-density staging for timeline presets (persisted under timeline.preset)."""
22

33
from __future__ import annotations
44

cleave/viz/session.py

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -352,6 +352,14 @@ def timeline_runtime_from_cfg(cfg: CleaveConfig) -> TimelineRuntime:
352352
if timeline is None
353353
else timeline.placement_snap
354354
)
355+
preset = None if timeline is None else timeline.preset
356+
preset_kind = (
357+
DEFAULT_TIMELINE_PRESET_KIND if preset is None else preset.character
358+
)
359+
preset_crescendo = None if preset is None else preset.crescendo
360+
preset_density = (
361+
DEFAULT_TIMELINE_PRESET_DENSITY if preset is None else preset.density
362+
)
355363
lanes: dict[str, TimelineLane] = {}
356364
for slot in cfg.layer_z_order:
357365
if slot in source_lanes:
@@ -364,12 +372,18 @@ def timeline_runtime_from_cfg(cfg: CleaveConfig) -> TimelineRuntime:
364372
locked=locked,
365373
lanes=lanes,
366374
placement_snap=placement_snap,
375+
timeline_preset_kind=preset_kind,
376+
timeline_preset_crescendo=preset_crescendo,
377+
timeline_preset_density=preset_density,
367378
)
368379
return TimelineRuntime(
369380
enabled=enabled,
370381
locked=locked,
371382
lanes=lanes,
372383
placement_snap=placement_snap,
384+
timeline_preset_kind=preset_kind,
385+
timeline_preset_crescendo=preset_crescendo,
386+
timeline_preset_density=preset_density,
373387
song_marker_fades=_fade_group_runtime_from_cfg(fades.song_markers),
374388
standard_cue_fades=_fade_group_runtime_from_cfg(fades.standard),
375389
)

tests/cleave/test_config.py

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@
2222
TimelineConfig,
2323
TimelineFadeGroupConfig,
2424
TimelineFadesConfig,
25+
TimelinePresetConfig,
2526
EditorConfig,
2627
clamp_beat_sensitivity,
2728
clamp_effect_pct,
@@ -1043,6 +1044,7 @@ def test_parse_timeline_defaults_enabled_true() -> None:
10431044
assert timeline.locked is False
10441045
assert timeline.fades == TimelineFadesConfig()
10451046
assert timeline.placement_snap == "beat"
1047+
assert timeline.preset == TimelinePresetConfig()
10461048

10471049

10481050
def test_parse_timeline_reads_fades() -> None:
@@ -1167,6 +1169,76 @@ def test_persist_timeline_placement_snap_round_trip() -> None:
11671169
assert round_trip.placement_snap == "bar"
11681170

11691171

1172+
def test_persist_timeline_preset_round_trip() -> None:
1173+
from cleave.viz.session import TimelineRuntime
1174+
1175+
session = TuningSession(
1176+
layer_z_order=list(DEFAULT_LAYER_SLOTS),
1177+
timeline=TimelineRuntime(
1178+
enabled=True,
1179+
timeline_preset_kind="arc",
1180+
timeline_preset_crescendo="penultimate",
1181+
timeline_preset_density="very dense",
1182+
),
1183+
)
1184+
cfg = CleaveConfig(
1185+
paths=PathsConfig(preset_root=Path("/tmp"), texture_paths=()),
1186+
layers={},
1187+
editor=EditorConfig(),
1188+
config_path=Path("/tmp/cleave-viz.yaml"),
1189+
user_config_path=Path("/tmp/user.yaml"),
1190+
layer_z_order=list(DEFAULT_LAYER_SLOTS),
1191+
)
1192+
payload = persist_timeline(PersistCtx(cfg=cfg, session=session, cfg_dir=None))
1193+
assert payload["preset"] == {
1194+
"character": "arc",
1195+
"crescendo": "penultimate",
1196+
"density": "very dense",
1197+
}
1198+
round_trip = parse_timeline_section(
1199+
{"timeline": payload},
1200+
_timeline_parse_ctx(),
1201+
)
1202+
assert round_trip is not None
1203+
assert round_trip.preset == TimelinePresetConfig(
1204+
character="arc",
1205+
crescendo="penultimate",
1206+
density="very dense",
1207+
)
1208+
1209+
1210+
def test_parse_timeline_reads_preset() -> None:
1211+
timeline = parse_timeline_section(
1212+
{
1213+
"timeline": {
1214+
"preset": {
1215+
"character": "pulse",
1216+
"crescendo": "last",
1217+
"density": "sparse",
1218+
}
1219+
}
1220+
},
1221+
_timeline_parse_ctx(),
1222+
)
1223+
assert timeline is not None
1224+
assert timeline.preset == TimelinePresetConfig(
1225+
character="pulse",
1226+
crescendo="last",
1227+
density="sparse",
1228+
)
1229+
1230+
1231+
def test_parse_timeline_preset_null_crescendo() -> None:
1232+
timeline = parse_timeline_section(
1233+
{"timeline": {"preset": {"character": "dialogue", "crescendo": None}}},
1234+
_timeline_parse_ctx(),
1235+
)
1236+
assert timeline is not None
1237+
assert timeline.preset.character == "dialogue"
1238+
assert timeline.preset.crescendo is None
1239+
assert timeline.preset.density == "normal"
1240+
1241+
11701242
def test_parse_timeline_rejects_invalid_placement_snap() -> None:
11711243
with pytest.raises(ValueError, match="placement_snap"):
11721244
parse_timeline_section(
@@ -1175,6 +1247,22 @@ def test_parse_timeline_rejects_invalid_placement_snap() -> None:
11751247
)
11761248

11771249

1250+
def test_parse_timeline_rejects_invalid_preset_character() -> None:
1251+
with pytest.raises(ValueError, match="character"):
1252+
parse_timeline_section(
1253+
{"timeline": {"preset": {"character": "waltz"}}},
1254+
_timeline_parse_ctx(),
1255+
)
1256+
1257+
1258+
def test_parse_timeline_rejects_invalid_preset_density() -> None:
1259+
with pytest.raises(ValueError, match="density"):
1260+
parse_timeline_section(
1261+
{"timeline": {"preset": {"density": "extreme"}}},
1262+
_timeline_parse_ctx(),
1263+
)
1264+
1265+
11781266
def test_parse_timeline_reads_locked() -> None:
11791267
timeline = parse_timeline_section(
11801268
{"timeline": {"enabled": True, "locked": True}},

tests/cleave/test_config_snapshot.py

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1043,9 +1043,49 @@ def test_write_session_snapshot_persists_timeline_disabled_without_cues(
10431043
"fade_out": 2.0,
10441044
},
10451045
},
1046+
"preset": {
1047+
"character": "breathing",
1048+
"crescendo": None,
1049+
"density": "normal",
1050+
},
10461051
}
10471052

10481053

1054+
def test_write_session_snapshot_round_trips_timeline_preset(tmp_path: Path) -> None:
1055+
cfg, session, out_path = _snapshot_fixture(tmp_path)
1056+
session.timeline.timeline_preset_kind = "arc"
1057+
session.timeline.timeline_preset_crescendo = "last"
1058+
session.timeline.timeline_preset_density = "dense"
1059+
write_session_snapshot(out_path, cfg=cfg, session=session)
1060+
1061+
data = yaml.safe_load(out_path.read_text(encoding="utf-8"))
1062+
assert data["timeline"]["preset"] == {
1063+
"character": "arc",
1064+
"crescendo": "last",
1065+
"density": "dense",
1066+
}
1067+
1068+
timeline = parse_timeline_section(
1069+
data,
1070+
ParseCtx(layer_slots=tuple(cfg.layer_z_order)),
1071+
)
1072+
assert timeline is not None
1073+
playlists = _round_trip_playlists(cfg.paths.preset_root)
1074+
cfg_with_timeline = CleaveConfig(
1075+
paths=cfg.paths,
1076+
layers=cfg.layers,
1077+
editor=cfg.editor,
1078+
config_path=out_path,
1079+
user_config_path=cfg.user_config_path,
1080+
render=cfg.render,
1081+
timeline=timeline,
1082+
)
1083+
session2 = session_from_cfg(cfg_with_timeline, playlists)
1084+
assert session2.timeline.timeline_preset_kind == "arc"
1085+
assert session2.timeline.timeline_preset_crescendo == "last"
1086+
assert session2.timeline.timeline_preset_density == "dense"
1087+
1088+
10491089
def _round_trip_preset_dirs(root: Path) -> Path:
10501090
preset_root = root / "presets"
10511091
make_preset_dirs(preset_root)

0 commit comments

Comments
 (0)