From c842fce9ce015692c5e8b3c662a23107c71ff820 Mon Sep 17 00:00:00 2001 From: Sportinger Date: Fri, 24 Apr 2026 14:48:26 +0200 Subject: [PATCH 1/4] Improve slot grid MIDI mapping and slot clip playback --- docs/Features/Slot-Grid.md | 7 +- docs/Features/UI-Panels.md | 9 +- src/App.css | 227 ++++++++++++--- src/components/panels/MIDIMappingPanel.css | 10 + src/components/panels/MIDIMappingPanel.tsx | 115 +++++++- .../panels/properties/SlotClipTab.tsx | 264 ++++++++++++++---- src/components/timeline/SlotGrid.tsx | 63 ++++- src/hooks/useMIDI.ts | 18 ++ src/hooks/useMIDIRuntime.ts | 14 +- src/services/midi/midiBindingMutations.ts | 28 ++ src/services/midi/midiCommands.ts | 20 ++ src/services/midi/midiMappingSummary.ts | 52 +++- src/stores/midiStore.ts | 15 + src/types/midi.ts | 19 ++ tests/unit/SlotClipTab.test.tsx | 168 +++++++++++ tests/unit/midiBindingMutations.test.ts | 24 ++ tests/unit/midiCommands.test.ts | 25 ++ tests/unit/midiMappingSummary.test.ts | 34 ++- tests/unit/slotGridLiveTrigger.test.tsx | 84 ++++++ 19 files changed, 1080 insertions(+), 116 deletions(-) create mode 100644 tests/unit/SlotClipTab.test.tsx diff --git a/docs/Features/Slot-Grid.md b/docs/Features/Slot-Grid.md index 88de79703..7654bf166 100644 --- a/docs/Features/Slot-Grid.md +++ b/docs/Features/Slot-Grid.md @@ -48,10 +48,11 @@ When `window.__ENGINE_FLAGS__.useLiveSlotTrigger` is enabled, the primary click ### Other Actions -- re-clicking an active slot restarts playback from the beginning +- re-clicking an active slot restarts playback from the slot `trimIn` point - clicking an empty slot clears that layer - dragging moves or swaps slot assignments -- right-clicking a filled slot opens `Open in Editor` and `Remove from Slot` +- right-clicking a filled slot opens `Open in Editor`, `Map MIDI to Slot`, and `Remove from Slot` +- `Map MIDI to Slot` opens the MIDI Mapping panel, creates a pending slot trigger mapping, and arms MIDI learn mode for that slot --- @@ -71,6 +72,8 @@ The current defaults are: These settings are stored in `mediaStore.slotClipSettings` and edited through the `Slot Clip` properties tab. +Slot launches use the configured slot window, not the composition editor playhead. The `Slot Clip` tab renders the composition tracks, the active trim window, and the current layer playhead in the same timeline surface so the tab reflects the playback state used by `layerPlaybackManager`. + --- ## Multi-Layer Playback diff --git a/docs/Features/UI-Panels.md b/docs/Features/UI-Panels.md index 2597e1b47..a73da24a4 100644 --- a/docs/Features/UI-Panels.md +++ b/docs/Features/UI-Panels.md @@ -179,13 +179,13 @@ MasterSelects currently exposes 17 dockable panel types, plus the Slot Grid over - Open from `View -> Panels -> MIDI Mapping` - Shows all currently assigned MIDI notes in one list -- Includes both global transport bindings and per-marker bindings +- Includes global transport bindings, per-marker bindings, and Slot Grid trigger bindings - Each row shows the note, target, and resulting command behavior - Click any mapping card to trigger the assigned action and preview what the MIDI note does - `Edit` opens inline controls for manual channel/note changes and marker reassignment - `Learn` and `Clear` remain available directly from the panel - Marker bindings support `Jump To Marker`, `Play From Marker`, and `Jump To Marker And Stop` -- Transport and marker learning still happen in Settings and the marker context menu +- Slot bindings can be created from the Slot Grid filled-slot context menu, which opens this panel with a pending `Listening...` mapping ### AI Chat Panel @@ -289,11 +289,11 @@ Resolume-style slot grid for simultaneous multi-layer composition playback. The | Action | Behavior | |--------|----------| | Click a filled slot | Select slot clip settings, open the Slot Clip tab, and either open the comp in the editor or trigger it live depending on `useLiveSlotTrigger` | -| Re-click an active slot | Restart playback from the beginning | +| Re-click an active slot | Restart playback from the slot trim-in point | | Click an empty slot | Deactivate that layer | | Click a column header | Activate all compositions in that column | | Drag a slot | Reorder or swap a composition position | -| Right-click a filled slot | Open in Editor or Remove from Slot | +| Right-click a filled slot | Open in Editor, map MIDI to the slot, or Remove from Slot | ### Multi-Layer Playback @@ -356,6 +356,7 @@ The unified Properties panel adapts its tabs to the selected clip type and to sl - Tabs switch automatically based on clip type - Badge counts appear for effects, masks, transcripts, and analysis readiness - Slot grid mode switches the panel to the Slot Clip tab +- The Slot Clip tab shows the slotted composition tracks, the configured trim window, and the current live layer playhead on one range timeline --- diff --git a/src/App.css b/src/App.css index f81a2f425..4bc8958cf 100644 --- a/src/App.css +++ b/src/App.css @@ -3768,6 +3768,10 @@ input, textarea { gap: var(--sp-2); } +.slot-clip-summary-title { + min-width: 0; +} + .slot-clip-title { margin-top: 2px; font-size: var(--font-md); @@ -3794,13 +3798,19 @@ input, textarea { .slot-clip-summary-meta span { padding: 3px 7px; - border-radius: 999px; + border-radius: var(--radius-sm); background: var(--bg-primary); border: 1px solid var(--border-color); color: var(--text-secondary); font-size: var(--font-xs); } +.slot-clip-summary-meta .slot-clip-status-active { + border-color: rgba(58, 206, 128, 0.38); + color: #56d98f; + background: rgba(58, 206, 128, 0.11); +} + .slot-clip-transport { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); @@ -3823,6 +3833,11 @@ input, textarea { font-weight: var(--font-medium); } +.slot-clip-transport-btn.primary { + border-color: rgba(74, 158, 255, 0.5); + background: rgba(74, 158, 255, 0.16); +} + .slot-clip-transport-btn:hover:not(:disabled), .slot-clip-end-btn:hover { background: var(--bg-hover); @@ -3833,89 +3848,237 @@ input, textarea { cursor: default; } +.slot-clip-section-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--sp-2); +} + +.slot-clip-section-header span { + font-family: 'SF Mono', 'Cascadia Code', 'Consolas', monospace; + color: var(--text-secondary); + font-size: var(--font-xs); +} + .slot-clip-timeline-shell { display: flex; flex-direction: column; - gap: var(--sp-1); + border-radius: var(--radius-md); + border: 1px solid var(--border-color); + background: var(--bg-primary); + overflow: hidden; } -.slot-clip-timeline-preview { +.slot-clip-ruler { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + align-items: center; + min-height: 22px; + padding: 0 8px; + color: var(--text-tertiary); + font-family: 'SF Mono', 'Cascadia Code', 'Consolas', monospace; + font-size: 10px; + background: rgba(255, 255, 255, 0.03); + border-bottom: 1px solid var(--border-color); +} + +.slot-clip-ruler span:nth-child(2) { + text-align: center; +} + +.slot-clip-ruler span:nth-child(3) { + text-align: right; +} + +.slot-clip-track-stack { position: relative; - min-height: 84px; - border-radius: var(--radius-md); - border: 1px solid var(--border-color); + min-height: 116px; background: - linear-gradient(180deg, rgba(255, 255, 255, 0.05), rgba(0, 0, 0, 0.18)), - linear-gradient(135deg, rgba(74, 158, 255, 0.08), rgba(240, 196, 90, 0.04)); + repeating-linear-gradient( + 90deg, + rgba(255, 255, 255, 0.035) 0, + rgba(255, 255, 255, 0.035) 1px, + transparent 1px, + transparent 10% + ), + linear-gradient(180deg, rgba(255, 255, 255, 0.025), rgba(0, 0, 0, 0.12)); overflow: hidden; } -.slot-clip-range-overlay { +.slot-clip-track-row { position: relative; - height: 26px; - border-radius: var(--radius-md); - background: var(--bg-primary); - border: 1px solid var(--border-color); + display: grid; + grid-template-columns: 30px minmax(0, 1fr); + min-height: 29px; + border-bottom: 1px solid rgba(255, 255, 255, 0.055); +} + +.slot-clip-track-row:last-of-type { + border-bottom: none; +} + +.slot-clip-track-label { + position: relative; + z-index: 4; + display: flex; + align-items: center; + justify-content: center; + border-right: 1px solid var(--border-color); + background: rgba(0, 0, 0, 0.32); + color: var(--text-secondary); + font-size: 10px; + font-weight: var(--font-semibold); +} + +.slot-clip-track-label-audio { + color: #57cf87; +} + +.slot-clip-track-label-video { + color: #63adff; +} + +.slot-clip-track-lane { + position: relative; + min-width: 0; + overflow: hidden; +} + +.slot-clip-clip { + position: absolute; + top: 5px; + bottom: 5px; + min-width: 3px; + border-radius: var(--radius-sm); + border: 1px solid rgba(255, 255, 255, 0.2); + background: rgba(98, 157, 255, 0.64); + box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.1); + overflow: hidden; +} + +.slot-clip-clip span { + display: block; + padding: 2px 5px; + color: rgba(255, 255, 255, 0.86); + font-size: 10px; + line-height: 1.2; + white-space: nowrap; overflow: hidden; + text-overflow: ellipsis; +} + +.slot-clip-clip-audio { + background: rgba(56, 190, 108, 0.68); } -.slot-clip-range-mask { +.slot-clip-clip-image, +.slot-clip-clip-lottie { + background: rgba(153, 122, 255, 0.66); +} + +.slot-clip-clip-text, +.slot-clip-clip-solid { + background: rgba(238, 156, 68, 0.66); +} + +.slot-clip-clip-model, +.slot-clip-clip-gaussian-avatar, +.slot-clip-clip-gaussian-splat, +.slot-clip-clip-splat-effector, +.slot-clip-clip-camera { + background: rgba(90, 202, 200, 0.64); +} + +.slot-clip-empty-timeline { + display: flex; + align-items: center; + justify-content: center; + min-height: 116px; + color: var(--text-tertiary); + font-size: var(--font-sm); +} + +.slot-clip-trim-mask { position: absolute; top: 0; bottom: 0; background: rgba(0, 0, 0, 0.45); pointer-events: none; + z-index: 2; } -.slot-clip-range-mask-left { +.slot-clip-trim-mask-left { left: 0; } -.slot-clip-range-mask-right { +.slot-clip-trim-mask-right { right: 0; } .slot-clip-range-window { position: absolute; - top: 2px; - bottom: 2px; - border-radius: var(--radius-sm); - background: rgba(74, 158, 255, 0.18); - border: 1px solid rgba(74, 158, 255, 0.45); + top: 0; + bottom: 0; + background: rgba(74, 158, 255, 0.08); + border-left: 1px solid rgba(240, 196, 90, 0.7); + border-right: 1px solid rgba(240, 196, 90, 0.7); pointer-events: none; + z-index: 3; +} + +.slot-clip-range-progress { + position: absolute; + top: 0; + bottom: 0; + background: rgba(74, 158, 255, 0.12); + pointer-events: none; + z-index: 3; } .slot-clip-playhead { position: absolute; - top: 2px; - bottom: 2px; + top: 0; + bottom: 0; width: 2px; margin-left: -1px; background: #ff6b57; box-shadow: 0 0 0 1px rgba(255, 107, 87, 0.18); pointer-events: none; + z-index: 5; } .slot-clip-handle { position: absolute; - top: 2px; - bottom: 2px; - width: 10px; - margin-left: -5px; - border-radius: 999px; - background: #f0c45a; + top: 0; + bottom: 0; + width: 14px; + margin-left: -7px; + background: transparent; border: none; cursor: ew-resize; - z-index: 2; + z-index: 6; +} + +.slot-clip-handle::before { + content: ''; + position: absolute; + top: 6px; + bottom: 6px; + left: 5px; + width: 4px; + border-radius: var(--radius-sm); + background: #f0c45a; + box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.22); } .slot-clip-handle.dragging { - box-shadow: 0 0 0 2px rgba(240, 196, 90, 0.2); + background: rgba(240, 196, 90, 0.08); } .slot-clip-timecodes { display: grid; - grid-template-columns: repeat(3, minmax(0, 1fr)); + grid-template-columns: repeat(auto-fit, minmax(86px, 1fr)); gap: var(--sp-1); } diff --git a/src/components/panels/MIDIMappingPanel.css b/src/components/panels/MIDIMappingPanel.css index ea1f6e73c..dd93a45a3 100644 --- a/src/components/panels/MIDIMappingPanel.css +++ b/src/components/panels/MIDIMappingPanel.css @@ -106,6 +106,11 @@ 0 0 20px color-mix(in srgb, var(--accent) 12%, transparent); } +.midi-mapping-card.is-learning { + border-color: color-mix(in srgb, var(--accent) 60%, var(--border-color)); + background: color-mix(in srgb, var(--accent-subtle) 45%, var(--bg-tertiary)); +} + .midi-mapping-card-top { display: flex; align-items: center; @@ -137,6 +142,11 @@ color: var(--warning-light); } +.midi-mapping-scope-slot { + background: color-mix(in srgb, var(--success-light) 18%, transparent); + color: var(--success-light); +} + .midi-mapping-card-main { display: flex; flex-direction: column; diff --git a/src/components/panels/MIDIMappingPanel.tsx b/src/components/panels/MIDIMappingPanel.tsx index 4e4df0d84..1e3a496e9 100644 --- a/src/components/panels/MIDIMappingPanel.tsx +++ b/src/components/panels/MIDIMappingPanel.tsx @@ -8,19 +8,24 @@ import { } from 'react'; import { useMIDI } from '../../hooks/useMIDI'; import { useTimelineStore } from '../../stores/timeline'; +import { useMediaStore } from '../../stores/mediaStore'; import { collectMIDIMappingSummary, + getSlotGridLabel, getMarkerTargetLabel, type MIDIMappingSummaryEntry, + type MIDISlotTarget, } from '../../services/midi/midiMappingSummary'; import { moveMarkerMIDIBinding, setMarkerMIDIBinding, + setSlotMIDIBinding, setTransportMIDIBinding, } from '../../services/midi/midiBindingMutations'; import { triggerMIDITransportAction, triggerMarkerMIDIAction, + triggerSlotMIDIAction, } from '../../services/midi/midiCommands'; import { describeMIDILearnTarget, @@ -35,7 +40,7 @@ function MIDIMappingEmptyState({ isEnabled }: { isEnabled: boolean }) {

No MIDI mappings assigned yet.

- Add transport bindings in Settings / MIDI and marker bindings from the timeline marker right-click menu. + Add transport bindings in Settings / MIDI, marker bindings from the timeline marker menu, or slot bindings from the Slot Grid menu.

{!isEnabled && (

@@ -71,18 +76,55 @@ export function MIDIMappingPanel() { learnTarget, startLearningTransportBinding, startLearningMarkerBinding, + startLearningSlotBinding, cancelLearning, + slotBindings, } = useMIDI(); const markers = useTimelineStore((state) => state.markers); + const compositions = useMediaStore((state) => state.compositions); + const slotAssignments = useMediaStore((state) => state.slotAssignments); const [draft, setDraft] = useState(null); const [previewingMappingId, setPreviewingMappingId] = useState(null); const previewResetTimeoutRef = useRef(null); + const slotTargets = useMemo(() => { + const targets = new Map(); + + for (const [slotKey] of Object.entries(slotBindings)) { + const slotIndex = Number(slotKey); + if (Number.isInteger(slotIndex) && slotIndex >= 0) { + targets.set(slotIndex, { + slotIndex, + label: getSlotGridLabel(slotIndex), + }); + } + } + + for (const [compositionId, slotIndex] of Object.entries(slotAssignments)) { + if (!Number.isInteger(slotIndex) || slotIndex < 0) { + continue; + } + + const composition = compositions.find((candidate) => candidate.id === compositionId); + targets.set(slotIndex, { + slotIndex, + label: getSlotGridLabel(slotIndex), + compositionName: composition?.name, + }); + } + + return Array.from(targets.values()); + }, [compositions, slotAssignments, slotBindings]); + const mappings = useMemo( - () => collectMIDIMappingSummary(transportBindings, markers), - [markers, transportBindings] + () => collectMIDIMappingSummary(transportBindings, markers, slotBindings, slotTargets), + [markers, slotBindings, slotTargets, transportBindings] ); const learnDescription = describeMIDILearnTarget(learnTarget); + const pendingSlotLearnTarget = learnTarget?.kind === 'slot' ? learnTarget : null; + const shouldShowPendingSlotCard = !!pendingSlotLearnTarget && !mappings.some((mapping) => ( + mapping.scope === 'slot' && mapping.slotIndex === pendingSlotLearnTarget.slotIndex + )); useEffect(() => () => { if (previewResetTimeoutRef.current !== null) { @@ -127,6 +169,11 @@ export function MIDIMappingPanel() { return; } + if (mapping.scope === 'slot' && mapping.slotIndex !== undefined) { + void triggerSlotMIDIAction(mapping.slotIndex); + return; + } + const previewMarkerTime = resolvePreviewMarkerTime(mapping); if (previewMarkerTime === undefined) { return; @@ -163,6 +210,8 @@ export function MIDIMappingPanel() { const clearMapping = (mapping: MIDIMappingSummaryEntry) => { if (mapping.scope === 'transport') { setTransportMIDIBinding(mapping.action as MIDITransportAction, null); + } else if (mapping.scope === 'slot' && mapping.slotIndex !== undefined) { + setSlotMIDIBinding(mapping.slotIndex, null); } else if (mapping.markerId) { setMarkerMIDIBinding(mapping.markerId, mapping.action as MarkerMIDIAction, null); } @@ -175,6 +224,11 @@ export function MIDIMappingPanel() { (learnTarget?.kind === 'transport' && mapping.scope === 'transport' && learnTarget.action === mapping.action) + || ( + learnTarget?.kind === 'slot' + && mapping.scope === 'slot' + && learnTarget.slotIndex === mapping.slotIndex + ) || ( learnTarget?.kind === 'marker' && mapping.scope === 'marker' @@ -207,6 +261,14 @@ export function MIDIMappingPanel() { return; } + if (mapping.scope === 'slot') { + if (mapping.slotIndex !== undefined) { + setSlotMIDIBinding(mapping.slotIndex, nextBinding); + } + closeEditor(); + return; + } + const nextMarkerId = draft.markerId ?? mapping.markerId; if (!nextMarkerId || !mapping.markerId) { return; @@ -232,6 +294,19 @@ export function MIDIMappingPanel() { return; } + if (mapping.scope === 'slot') { + if (mapping.slotIndex !== undefined) { + const target = slotTargets.find((candidate) => candidate.slotIndex === mapping.slotIndex); + startLearningSlotBinding( + mapping.slotIndex, + target?.label ?? getSlotGridLabel(mapping.slotIndex), + undefined, + target?.compositionName + ); + } + return; + } + const targetMarkerId = draft?.mappingId === mapping.id ? draft.markerId ?? mapping.markerId @@ -275,16 +350,39 @@ export function MIDIMappingPanel() {

)} - {mappings.length === 0 ? ( + {mappings.length === 0 && !shouldShowPendingSlotCard ? ( ) : (
+ {pendingSlotLearnTarget && shouldShowPendingSlotCard && ( +
+
+ Listening... + Slot +
+
+ Trigger Slot + + {pendingSlotLearnTarget.slotLabel} + {pendingSlotLearnTarget.compositionName ? ` - ${pendingSlotLearnTarget.compositionName}` : ''} + +
+
+ Trigger this slot on its layer +
+ +
+
+
+ )} {mappings.map((mapping) => { const isEditing = draft?.mappingId === mapping.id; const channelValue = isEditing ? draft.channel : String(mapping.binding.channel); const noteValue = isEditing ? draft.note : String(mapping.binding.note); const selectedMarkerId = isEditing ? draft.markerId ?? mapping.markerId : mapping.markerId; - const isPreviewable = mapping.scope === 'transport' || mapping.markerTime !== undefined; + const isPreviewable = mapping.scope === 'transport' || mapping.scope === 'slot' || mapping.markerTime !== undefined; const notePreview = getMIDINoteName( clampInteger(noteValue, mapping.binding.note, 0, 127) ); @@ -296,6 +394,11 @@ export function MIDIMappingPanel() { (learnTarget?.kind === 'transport' && mapping.scope === 'transport' && learnTarget.action === mapping.action) + || ( + learnTarget?.kind === 'slot' + && mapping.scope === 'slot' + && learnTarget.slotIndex === mapping.slotIndex + ) || ( learnTarget?.kind === 'marker' && mapping.scope === 'marker' @@ -316,7 +419,7 @@ export function MIDIMappingPanel() {
{mapping.bindingLabel} - {mapping.scope === 'transport' ? 'Transport' : 'Marker'} + {mapping.scope === 'transport' ? 'Transport' : mapping.scope === 'slot' ? 'Slot' : 'Marker'}
diff --git a/src/components/panels/properties/SlotClipTab.tsx b/src/components/panels/properties/SlotClipTab.tsx index d2983002f..59866b4d1 100644 --- a/src/components/panels/properties/SlotClipTab.tsx +++ b/src/components/panels/properties/SlotClipTab.tsx @@ -1,22 +1,47 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; -import { MiniTimeline } from '../../timeline/MiniTimeline'; import { layerPlaybackManager } from '../../../services/layerPlaybackManager'; import { useMediaStore } from '../../../stores/mediaStore'; -import type { Composition, SlotClipEndBehavior } from '../../../stores/mediaStore'; +import type { Composition, SlotClipEndBehavior, SlotClipSettings } from '../../../stores/mediaStore'; +import type { SerializableClip, TimelineSourceType, TimelineTrack } from '../../../types'; const GRID_COLS = 12; -const MIN_TIMELINE_WIDTH = 220; -const TIMELINE_HEIGHT = 84; +const MIN_SLOT_WINDOW_SECONDS = 0.05; interface SlotClipTabProps { composition: Composition; slotIndex: number; } +interface SlotTimelineRow { + id: string; + name: string; + type: TimelineTrack['type']; + clips: SerializableClip[]; +} + +const SOURCE_LABELS: Partial> = { + video: 'Video', + audio: 'Audio', + image: 'Image', + text: 'Text', + solid: 'Solid', + model: '3D', + camera: 'Camera', + 'gaussian-avatar': 'Avatar', + 'gaussian-splat': 'Splat', + 'splat-effector': 'Effector', + lottie: 'Lottie', +}; + +function clamp(value: number, min: number, max: number): number { + return Math.max(min, Math.min(max, value)); +} + function formatTime(seconds: number): string { - const mins = Math.floor(seconds / 60); - const secs = Math.floor(seconds % 60); - const tenths = Math.floor((seconds % 1) * 10); + const safeSeconds = Math.max(0, seconds); + const mins = Math.floor(safeSeconds / 60); + const secs = Math.floor(safeSeconds % 60); + const tenths = Math.floor((safeSeconds % 1) * 10); if (mins > 0) { return `${mins}:${secs.toString().padStart(2, '0')}.${tenths}`; } @@ -29,6 +54,86 @@ function getSlotLabel(slotIndex: number): string { return `${String.fromCharCode(65 + row)}${col + 1}`; } +function getDefaultSettings(duration: number): SlotClipSettings { + return { + trimIn: 0, + trimOut: Math.max(duration, MIN_SLOT_WINDOW_SECONDS), + endBehavior: 'loop', + }; +} + +function normalizeSettings(duration: number, settings?: SlotClipSettings): SlotClipSettings { + const defaults = getDefaultSettings(duration); + const safeDuration = defaults.trimOut; + const requestedTrimIn = settings?.trimIn ?? defaults.trimIn; + const requestedTrimOut = settings?.trimOut ?? defaults.trimOut; + const endBehavior = settings?.endBehavior ?? defaults.endBehavior; + + if (safeDuration <= MIN_SLOT_WINDOW_SECONDS) { + return { + trimIn: 0, + trimOut: safeDuration, + endBehavior, + }; + } + + const trimIn = clamp(requestedTrimIn, 0, safeDuration - MIN_SLOT_WINDOW_SECONDS); + const trimOut = clamp(requestedTrimOut, trimIn + MIN_SLOT_WINDOW_SECONDS, safeDuration); + return { trimIn, trimOut, endBehavior }; +} + +function getTrackLabel(track: TimelineTrack, index: number): string { + const letter = track.type === 'audio' ? 'A' : 'V'; + return `${letter}${index + 1}`; +} + +function getClipType(clip: SerializableClip): TimelineSourceType { + return clip.sourceType ?? 'video'; +} + +function getClipLabel(clip: SerializableClip): string { + return SOURCE_LABELS[getClipType(clip)] ?? getClipType(clip); +} + +function getPercent(time: number, duration: number): number { + return duration <= 0 ? 0 : clamp((time / duration) * 100, 0, 100); +} + +function getClipWidthPercent(clip: SerializableClip, duration: number): number { + const start = clamp(clip.startTime, 0, duration); + const end = clamp(clip.startTime + Math.max(clip.duration, 0), 0, duration); + return Math.max(0.6, getPercent(end - start, duration)); +} + +function buildRows(tracks: TimelineTrack[], clips: SerializableClip[]): SlotTimelineRow[] { + const trackIds = new Set(tracks.map(track => track.id)); + const rows = tracks + .filter(track => track.visible !== false) + .map((track, index) => ({ + id: track.id, + name: track.name || getTrackLabel(track, index), + type: track.type, + clips: clips + .filter(clip => clip.trackId === track.id) + .toSorted((a, b) => a.startTime - b.startTime), + })); + + const orphanClips = clips + .filter(clip => !trackIds.has(clip.trackId)) + .toSorted((a, b) => a.startTime - b.startTime); + + if (orphanClips.length > 0) { + rows.push({ + id: '__orphan__', + name: 'Clips', + type: 'video', + clips: orphanClips, + }); + } + + return rows; +} + export function SlotClipTab({ composition, slotIndex }: SlotClipTabProps) { const slotClipSettings = useMediaStore(state => state.slotClipSettings[composition.id]); const activeLayerSlots = useMediaStore(state => state.activeLayerSlots); @@ -40,17 +145,14 @@ export function SlotClipTab({ composition, slotIndex }: SlotClipTabProps) { const activateOnLayer = useMediaStore(state => state.activateOnLayer) as (compositionId: string, layerIndex: number) => void; const layerIndex = Math.floor(slotIndex / GRID_COLS); - const duration = Math.max(composition.duration || composition.timelineData?.duration || 0, 0.05); - const settings = slotClipSettings ?? { - trimIn: 0, - trimOut: duration, - endBehavior: 'loop' as const, - }; + const duration = Math.max(composition.duration || composition.timelineData?.duration || 0, MIN_SLOT_WINDOW_SECONDS); + const settings = useMemo( + () => normalizeSettings(duration, slotClipSettings), + [duration, slotClipSettings] + ); const isLayerActive = activeLayerSlots[layerIndex] === composition.id; const timelineRef = useRef(null); - const miniTimelineRef = useRef(null); - const [miniTimelineWidth, setMiniTimelineWidth] = useState(260); const [draggingEdge, setDraggingEdge] = useState<'in' | 'out' | null>(null); const [playback, setPlayback] = useState<{ currentTime: number; @@ -60,21 +162,10 @@ export function SlotClipTab({ composition, slotIndex }: SlotClipTabProps) { playbackState: 'stopped', }); - useEffect(() => { - const container = miniTimelineRef.current; - if (!container || typeof ResizeObserver === 'undefined') { - return; - } - - const updateWidth = () => { - setMiniTimelineWidth(Math.max(MIN_TIMELINE_WIDTH, Math.floor(container.clientWidth) - 2)); - }; - - updateWidth(); - const observer = new ResizeObserver(updateWidth); - observer.observe(container); - return () => observer.disconnect(); - }, []); + const rows = useMemo(() => { + const timelineData = composition.timelineData; + return buildRows(timelineData?.tracks ?? [], timelineData?.clips ?? []); + }, [composition.timelineData]); useEffect(() => { let rafId = 0; @@ -124,7 +215,7 @@ export function SlotClipTab({ composition, slotIndex }: SlotClipTabProps) { return 0; } - const ratio = Math.max(0, Math.min(1, (clientX - rect.left) / rect.width)); + const ratio = clamp((clientX - rect.left) / rect.width, 0, 1); return ratio * duration; }, [duration]); @@ -159,14 +250,14 @@ export function SlotClipTab({ composition, slotIndex }: SlotClipTabProps) { }; }, [draggingEdge, updateTrimFromPointer]); - const handlePlay = useCallback(() => { + const handleLaunch = useCallback(() => { if (!isLayerActive) { activateOnLayer(composition.id, layerIndex); return; } - layerPlaybackManager.playLayer(layerIndex); - }, [activateOnLayer, composition.id, isLayerActive, layerIndex]); + layerPlaybackManager.activateLayer(layerIndex, composition.id, undefined, { slotIndex }); + }, [activateOnLayer, composition.id, isLayerActive, layerIndex, slotIndex]); const handlePause = useCallback(() => { if (!isLayerActive) { @@ -188,23 +279,31 @@ export function SlotClipTab({ composition, slotIndex }: SlotClipTabProps) { layerPlaybackManager.stopLayer(layerIndex); }, [isLayerActive, layerIndex, settings.trimIn]); - const rangeLeft = `${(settings.trimIn / duration) * 100}%`; - const rangeWidth = `${((settings.trimOut - settings.trimIn) / duration) * 100}%`; - const playheadLeft = `${(playback.currentTime / duration) * 100}%`; + const trimInPercent = getPercent(settings.trimIn, duration); + const trimOutPercent = getPercent(settings.trimOut, duration); + const rangeWidthPercent = Math.max(0, trimOutPercent - trimInPercent); + const playheadPercent = getPercent(playback.currentTime, duration); const slotLabel = useMemo(() => getSlotLabel(slotIndex), [slotIndex]); + const currentWithinRange = clamp( + ((playback.currentTime - settings.trimIn) / Math.max(settings.trimOut - settings.trimIn, MIN_SLOT_WINDOW_SECONDS)) * 100, + 0, + 100 + ); return (
-
+

Slot

{composition.name}
{slotLabel}
- {isLayerActive ? 'Active' : 'Loaded'} + + {isLayerActive ? 'Active' : 'Loaded'} + {playback.playbackState} {formatTime(playback.currentTime)} / {formatTime(duration)}
@@ -213,8 +312,8 @@ export function SlotClipTab({ composition, slotIndex }: SlotClipTabProps) {

Transport

-
+
+ Current + {formatTime(playback.currentTime)} +
In {formatTime(settings.trimIn)} diff --git a/src/components/timeline/SlotGrid.tsx b/src/components/timeline/SlotGrid.tsx index 07fc6b3ab..f537ce125 100644 --- a/src/components/timeline/SlotGrid.tsx +++ b/src/components/timeline/SlotGrid.tsx @@ -7,9 +7,11 @@ import { Fragment, useCallback, useEffect, useMemo, useRef, useState, memo } fro import { useMediaStore } from '../../stores/mediaStore'; import { useTimelineStore } from '../../stores/timeline'; import { useDockStore } from '../../stores/dockStore'; +import { useMIDIStore } from '../../stores/midiStore'; import { playheadState } from '../../services/layerBuilder'; import { layerPlaybackManager } from '../../services/layerPlaybackManager'; import { slotDeckManager } from '../../services/slotDeckManager'; +import { getSlotGridLabel } from '../../services/midi/midiMappingSummary'; import { flags } from '../../engine/featureFlags'; import { animateSlotGrid } from './slotGridAnimation'; import { MiniTimeline } from './MiniTimeline'; @@ -141,7 +143,13 @@ export function SlotGrid({ opacity }: SlotGridProps) { const [isExternalDrag, setIsExternalDrag] = useState(false); // Context menu state - const [contextMenu, setContextMenu] = useState<{ x: number; y: number; compId: string } | null>(null); + const [contextMenu, setContextMenu] = useState<{ + x: number; + y: number; + compId: string; + compName: string; + slotIndex: number; + } | null>(null); // Track previous "desired background layers" to diff — only process layers that actually changed // A layer is "desired background" when it has a comp assigned AND that comp is NOT the editor comp @@ -182,9 +190,9 @@ export function SlotGrid({ opacity }: SlotGridProps) { // Activate new background layer if (newCompId) { const comp = compositions.find(c => c.id === newCompId); - const savedPosition = comp?.timelineData?.playheadPosition ?? 0; const slotIndex = slotAssignments[newCompId] ?? null; - layerPlaybackManager.activateLayer(layerIndex, newCompId, savedPosition, { slotIndex }); + const initialElapsed = slotIndex === null ? (comp?.timelineData?.playheadPosition ?? 0) : undefined; + layerPlaybackManager.activateLayer(layerIndex, newCompId, initialElapsed, { slotIndex }); } } @@ -239,6 +247,7 @@ export function SlotGrid({ opacity }: SlotGridProps) { // Click = select slot, open Slot Clip tab, and keep layer activation local to slot view. const handleSlotClick = useCallback((comp: Composition, slotIndex: number) => { const layerIndex = Math.floor(slotIndex / GRID_COLS); + const wasLayerActiveForComp = useMediaStore.getState().activeLayerSlots[layerIndex] === comp.id; ensureSlotClipSettings?.(comp.id, comp.duration); selectSlotComposition?.(comp.id); try { @@ -250,11 +259,17 @@ export function SlotGrid({ opacity }: SlotGridProps) { if (flags.useLiveSlotTrigger) { triggerLiveSlot(comp.id, layerIndex); + if (wasLayerActiveForComp) { + layerPlaybackManager.activateLayer(layerIndex, comp.id, undefined, { slotIndex }); + } return; } openSlotInEditor(comp.id); useMediaStore.getState().activateOnLayer(comp.id, layerIndex); + if (wasLayerActiveForComp) { + layerPlaybackManager.activateLayer(layerIndex, comp.id, undefined, { slotIndex }); + } }, [ensureSlotClipSettings, openSlotInEditor, selectSlotComposition, triggerLiveSlot]); const handleSlotDoubleClick = useCallback((comp: Composition) => { @@ -339,10 +354,16 @@ export function SlotGrid({ opacity }: SlotGridProps) { }, [activateColumn, getSlotMap, openSlotInEditor, triggerLiveColumn]); // Right-click context menu on filled slots - const handleContextMenu = useCallback((e: React.MouseEvent, comp: Composition) => { + const handleContextMenu = useCallback((e: React.MouseEvent, comp: Composition, slotIndex: number) => { e.preventDefault(); e.stopPropagation(); - setContextMenu({ x: e.clientX, y: e.clientY, compId: comp.id }); + setContextMenu({ + x: e.clientX, + y: e.clientY, + compId: comp.id, + compName: comp.name, + slotIndex, + }); }, []); const handleRemoveFromSlot = useCallback(() => { @@ -359,6 +380,28 @@ export function SlotGrid({ opacity }: SlotGridProps) { } }, [contextMenu, openSlotInEditor]); + const handleMapMIDISlot = useCallback(() => { + if (!contextMenu) { + return; + } + + const slotLabel = getSlotGridLabel(contextMenu.slotIndex); + try { + useDockStore.getState().activatePanelType('midi-mapping'); + } catch { + // Learning can still be armed even if the dock layout cannot be updated. + } + + useMIDIStore.getState().startLearning({ + kind: 'slot', + slotIndex: contextMenu.slotIndex, + slotLabel, + compositionId: contextMenu.compId, + compositionName: contextMenu.compName, + }); + setContextMenu(null); + }, [contextMenu]); + // Drag handlers — track comp ID, not slot index const handleDragStart = useCallback((e: React.DragEvent, comp: Composition) => { setDragCompId(comp.id); @@ -508,7 +551,7 @@ export function SlotGrid({ opacity }: SlotGridProps) { }} onClick={() => handleSlotClick(comp, slotIndex)} onDoubleClick={() => handleSlotDoubleClick(comp)} - onContextMenu={(e) => handleContextMenu(e, comp)} + onContextMenu={(e) => handleContextMenu(e, comp, slotIndex)} title={ `${flags.useLiveSlotTrigger ? `${comp.name} - Click to trigger live, double-click to open in editor` @@ -633,6 +676,9 @@ export function SlotGrid({ opacity }: SlotGridProps) { onMouseDown={(e) => e.stopPropagation()} > +
)} @@ -702,8 +748,11 @@ const SlotTimeOverlay = memo(function SlotTimeOverlay({ const update = () => { const isEditor = useMediaStore.getState().activeCompositionId === compId; + const layerPlayback = layerPlaybackManager.getLayerPlaybackInfo(layerIndex); let pos: number; - if (isEditor) { + if (layerPlayback?.compositionId === compId) { + pos = layerPlayback.currentTime; + } else if (isEditor) { // Editor comp: must reflect pause/scrub/seek — read from global playhead pos = playheadState.isUsingInternalPosition ? playheadState.position diff --git a/src/hooks/useMIDI.ts b/src/hooks/useMIDI.ts index c71381601..1ff343e88 100644 --- a/src/hooks/useMIDI.ts +++ b/src/hooks/useMIDI.ts @@ -43,6 +43,7 @@ export function useMIDI() { lastMessage, learnTarget, transportBindings, + slotBindings, setSupported, setEnabled, setConnectionStatus, @@ -138,6 +139,21 @@ export function useMIDI() { }); }, [startLearning]); + const startLearningSlotBinding = useCallback(( + slotIndex: number, + slotLabel: string, + compositionId?: string, + compositionName?: string + ) => { + startLearning({ + kind: 'slot', + slotIndex, + slotLabel, + compositionId, + compositionName, + }); + }, [startLearning]); + const clearTransportBinding = useCallback((action: MIDITransportAction) => { setTransportBinding(action, null); }, [setTransportBinding]); @@ -152,10 +168,12 @@ export function useMIDI() { lastMessage, learnTarget: learnTarget as MIDILearnTarget | null, transportBindings: transportBindings as Record, + slotBindings: slotBindings as Record, enableMIDI, disableMIDI, startLearningTransportBinding, startLearningMarkerBinding, + startLearningSlotBinding, clearTransportBinding, cancelLearning, }; diff --git a/src/hooks/useMIDIRuntime.ts b/src/hooks/useMIDIRuntime.ts index 8b77f0d9d..a4bab95df 100644 --- a/src/hooks/useMIDIRuntime.ts +++ b/src/hooks/useMIDIRuntime.ts @@ -10,10 +10,12 @@ import { import { triggerMarkerMIDIBinding, triggerMIDITransportAction, + triggerSlotMIDIAction, } from '../services/midi/midiCommands'; import { moveMarkerMIDIBinding, setMarkerMIDIBinding, + setSlotMIDIBinding, setTransportMIDIBinding, } from '../services/midi/midiBindingMutations'; @@ -75,7 +77,7 @@ export function useMIDIRuntime() { if (learnTarget) { if (learnTarget.kind === 'transport') { setTransportMIDIBinding(learnTarget.action, learnedBinding); - } else { + } else if (learnTarget.kind === 'marker') { if (learnTarget.sourceMarkerId && learnTarget.sourceMarkerId !== learnTarget.markerId) { moveMarkerMIDIBinding({ fromMarkerId: learnTarget.sourceMarkerId, @@ -90,6 +92,8 @@ export function useMIDIRuntime() { learnedBinding ); } + } else { + setSlotMIDIBinding(learnTarget.slotIndex, learnedBinding); } midiStore.cancelLearning(); return; @@ -111,6 +115,14 @@ export function useMIDIRuntime() { if (markerBinding) { void triggerMarkerMIDIBinding(markerBinding); + return; + } + + const slotBindingEntry = Object.entries(midiStore.slotBindings) + .find(([, binding]) => binding && midiBindingsMatch(binding, learnedBinding)); + + if (slotBindingEntry) { + void triggerSlotMIDIAction(Number(slotBindingEntry[0])); } return; } diff --git a/src/services/midi/midiBindingMutations.ts b/src/services/midi/midiBindingMutations.ts index 3ffeaa033..70955f3c3 100644 --- a/src/services/midi/midiBindingMutations.ts +++ b/src/services/midi/midiBindingMutations.ts @@ -12,6 +12,7 @@ interface ConflictOptions { transportAction?: MIDITransportAction; markerId?: string; markerAction?: MarkerMIDIAction; + slotIndex?: number; } function updateMarkerBindings(markerId: string, bindings: MarkerMIDIBinding[]): void { @@ -36,6 +37,14 @@ export function removeConflictingMIDIBinding( } }); + Object.entries(midiStore.slotBindings).forEach(([slotKey, existingBinding]) => { + const slotIndex = Number(slotKey); + const isSameTarget = options?.slotIndex === slotIndex; + if (existingBinding && !isSameTarget && midiBindingsMatch(existingBinding, binding)) { + midiStore.setSlotBinding(slotIndex, null); + } + }); + timelineStore.markers.forEach((marker) => { if (!marker.midiBindings || marker.midiBindings.length === 0) { return; @@ -105,6 +114,25 @@ export function setMarkerMIDIBinding( updateMarkerBindings(markerId, nextBindings); } +export function setSlotMIDIBinding( + slotIndex: number, + binding: MIDINoteBinding | null +): void { + const midiStore = useMIDIStore.getState(); + + if (!Number.isInteger(slotIndex) || slotIndex < 0) { + return; + } + + if (!binding) { + midiStore.setSlotBinding(slotIndex, null); + return; + } + + removeConflictingMIDIBinding(binding, { slotIndex }); + midiStore.setSlotBinding(slotIndex, binding); +} + export function moveMarkerMIDIBinding(params: { fromMarkerId: string; toMarkerId: string; diff --git a/src/services/midi/midiCommands.ts b/src/services/midi/midiCommands.ts index 95fb373cd..c0385bf3f 100644 --- a/src/services/midi/midiCommands.ts +++ b/src/services/midi/midiCommands.ts @@ -1,7 +1,9 @@ import { useTimelineStore } from '../../stores/timeline'; +import { useMediaStore } from '../../stores/mediaStore'; import type { MarkerMIDIBinding, MarkerMIDIAction, + SlotMIDIBinding, MIDITransportAction, } from '../../types/midi'; @@ -116,3 +118,21 @@ export async function triggerMarkerMIDIBinding(binding: MarkerMIDIBinding): Prom await triggerMarkerMIDIAction(binding.action, marker.time); } + +export async function triggerSlotMIDIAction(slotIndex: number): Promise { + const mediaStore = useMediaStore.getState(); + const slotEntry = Object.entries(mediaStore.slotAssignments ?? {}) + .find(([, assignedSlotIndex]) => assignedSlotIndex === slotIndex); + const compositionId = slotEntry?.[0]; + + if (!compositionId) { + return; + } + + const layerIndex = Math.floor(slotIndex / 12); + mediaStore.triggerLiveSlot(compositionId, layerIndex); +} + +export async function triggerSlotMIDIBinding(binding: SlotMIDIBinding): Promise { + await triggerSlotMIDIAction(binding.slotIndex); +} diff --git a/src/services/midi/midiMappingSummary.ts b/src/services/midi/midiMappingSummary.ts index 7f24228fb..b976c4c17 100644 --- a/src/services/midi/midiMappingSummary.ts +++ b/src/services/midi/midiMappingSummary.ts @@ -2,14 +2,15 @@ import type { TimelineMarker } from '../../stores/timeline/types'; import { formatMIDINoteBinding, type MarkerMIDIAction, + type MIDISlotAction, type MIDINoteBinding, type MIDITransportAction, } from '../../types/midi'; export interface MIDIMappingSummaryEntry { id: string; - scope: 'transport' | 'marker'; - action: MIDITransportAction | MarkerMIDIAction; + scope: 'transport' | 'marker' | 'slot'; + action: MIDITransportAction | MarkerMIDIAction | MIDISlotAction; actionLabel: string; targetLabel: string; behaviorLabel: string; @@ -17,9 +18,17 @@ export interface MIDIMappingSummaryEntry { bindingLabel: string; markerId?: string; markerTime?: number; + slotIndex?: number; } type MIDITransportBindings = Record; +type MIDISlotBindings = Record; + +export interface MIDISlotTarget { + slotIndex: number; + label: string; + compositionName?: string; +} export function formatMarkerTime(seconds: number): string { const safeSeconds = Math.max(0, seconds); @@ -68,6 +77,19 @@ function getMarkerBehaviorLabel(action: MarkerMIDIAction): string { return 'Move the playhead to the marker time and keep the current playback state'; } +export function getSlotGridLabel(slotIndex: number): string { + const safeSlotIndex = Math.max(0, Math.floor(slotIndex)); + const row = Math.floor(safeSlotIndex / 12); + const col = safeSlotIndex % 12; + return `${String.fromCharCode(65 + row)}${col + 1}`; +} + +function getSlotTargetLabel(slotIndex: number, slotTargets: MIDISlotTarget[]): string { + const target = slotTargets.find((candidate) => candidate.slotIndex === slotIndex); + const label = target?.label ?? getSlotGridLabel(slotIndex); + return target?.compositionName ? `${label} - ${target.compositionName}` : label; +} + export function getMarkerTargetLabel(marker: TimelineMarker): string { const label = marker.label.trim() || 'Marker'; return `${label} at ${formatMarkerTime(marker.time)}`; @@ -75,7 +97,9 @@ export function getMarkerTargetLabel(marker: TimelineMarker): string { export function collectMIDIMappingSummary( transportBindings: MIDITransportBindings, - markers: TimelineMarker[] + markers: TimelineMarker[], + slotBindings: MIDISlotBindings = {}, + slotTargets: MIDISlotTarget[] = [] ): MIDIMappingSummaryEntry[] { const transportEntries: MIDIMappingSummaryEntry[] = (Object.entries(transportBindings) as Array<[ MIDITransportAction, @@ -107,7 +131,27 @@ export function collectMIDIMappingSummary( })) )); - return [...transportEntries, ...markerEntries].sort((left, right) => { + const slotEntries: MIDIMappingSummaryEntry[] = Object.entries(slotBindings) + .flatMap(([slotKey, binding]) => { + if (!binding) { + return []; + } + + const slotIndex = Number(slotKey); + return [{ + id: `slot-${slotIndex}-${binding.channel}-${binding.note}`, + scope: 'slot' as const, + action: 'triggerSlot' as const, + actionLabel: 'Trigger Slot', + targetLabel: getSlotTargetLabel(slotIndex, slotTargets), + behaviorLabel: 'Trigger this slot on its layer', + binding, + bindingLabel: formatMIDINoteBinding(binding), + slotIndex, + }]; + }); + + return [...transportEntries, ...markerEntries, ...slotEntries].sort((left, right) => { if (left.binding.channel !== right.binding.channel) { return left.binding.channel - right.binding.channel; } diff --git a/src/stores/midiStore.ts b/src/stores/midiStore.ts index 2441bce0b..f106493e5 100644 --- a/src/stores/midiStore.ts +++ b/src/stores/midiStore.ts @@ -11,6 +11,7 @@ import type { type MIDIConnectionStatus = 'idle' | 'requesting' | 'connected' | 'error'; type MIDITransportBindings = Record; +type MIDISlotBindings = Record; interface MIDIStoreState { isSupported: boolean; @@ -21,12 +22,14 @@ interface MIDIStoreState { lastMessage: MIDILastMessage | null; learnTarget: MIDILearnTarget | null; transportBindings: MIDITransportBindings; + slotBindings: MIDISlotBindings; setSupported: (supported: boolean) => void; setEnabled: (enabled: boolean) => void; setConnectionStatus: (status: MIDIConnectionStatus, error?: string | null) => void; setDevices: (devices: MIDIDeviceInfo[]) => void; setLastMessage: (message: MIDILastMessage | null) => void; setTransportBinding: (action: MIDITransportAction, binding: MIDINoteBinding | null) => void; + setSlotBinding: (slotIndex: number, binding: MIDINoteBinding | null) => void; startLearning: (target: MIDILearnTarget) => void; cancelLearning: () => void; resetRuntimeState: () => void; @@ -49,6 +52,7 @@ export const useMIDIStore = create()( lastMessage: null, learnTarget: null, transportBindings: initialTransportBindings, + slotBindings: {}, setSupported: (isSupported) => set({ isSupported }), setEnabled: (isEnabled) => set({ isEnabled }), setConnectionStatus: (connectionStatus, connectionError = null) => @@ -62,6 +66,16 @@ export const useMIDIStore = create()( [action]: binding, }, })), + setSlotBinding: (slotIndex, binding) => + set((state) => { + const nextBindings = { ...state.slotBindings }; + if (binding) { + nextBindings[slotIndex] = binding; + } else { + delete nextBindings[slotIndex]; + } + return { slotBindings: nextBindings }; + }), startLearning: (learnTarget) => set({ learnTarget }), cancelLearning: () => set({ learnTarget: null }), resetRuntimeState: () => @@ -78,6 +92,7 @@ export const useMIDIStore = create()( partialize: (state) => ({ isEnabled: state.isEnabled, transportBindings: state.transportBindings, + slotBindings: state.slotBindings, }), } ) diff --git a/src/types/midi.ts b/src/types/midi.ts index 5d1c60457..c9da7ddd1 100644 --- a/src/types/midi.ts +++ b/src/types/midi.ts @@ -2,6 +2,8 @@ export type MIDITransportAction = 'playPause' | 'stop'; export type MarkerMIDIAction = 'playFromMarker' | 'jumpToMarker' | 'jumpToMarkerAndStop'; +export type MIDISlotAction = 'triggerSlot'; + export interface MIDIDeviceInfo { id: string; name: string; @@ -17,6 +19,11 @@ export interface MarkerMIDIBinding extends MIDINoteBinding { action: MarkerMIDIAction; } +export interface SlotMIDIBinding extends MIDINoteBinding { + action: MIDISlotAction; + slotIndex: number; +} + export interface MIDILastMessage { channel: number; type: 'note-on' | 'note-off' | 'control-change'; @@ -38,6 +45,13 @@ export type MIDILearnTarget = markerLabel: string; action: MarkerMIDIAction; sourceMarkerId?: string; + } + | { + kind: 'slot'; + slotIndex: number; + slotLabel: string; + compositionId?: string; + compositionName?: string; }; export type MIDIPermissionState = PermissionState | 'unknown' | 'unsupported'; @@ -74,6 +88,11 @@ export function describeMIDILearnTarget(target: MIDILearnTarget | null): string : 'Waiting for a note for Stop'; } + if (target.kind === 'slot') { + const compositionSuffix = target.compositionName ? ` (${target.compositionName})` : ''; + return `Waiting for a note for ${target.slotLabel}${compositionSuffix} -> Trigger Slot`; + } + const markerLabel = target.markerLabel || 'Marker'; if (target.action === 'playFromMarker') { return `Waiting for a note for "${markerLabel}" -> Play From Marker`; diff --git a/tests/unit/SlotClipTab.test.tsx b/tests/unit/SlotClipTab.test.tsx new file mode 100644 index 000000000..778b45eb4 --- /dev/null +++ b/tests/unit/SlotClipTab.test.tsx @@ -0,0 +1,168 @@ +import { fireEvent, render, screen } from '@testing-library/react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { SlotClipTab } from '../../src/components/panels/properties/SlotClipTab'; +import { layerPlaybackManager } from '../../src/services/layerPlaybackManager'; +import { useMediaStore } from '../../src/stores/mediaStore'; +import type { Composition, SlotClipSettings } from '../../src/stores/mediaStore'; + +vi.mock('../../src/services/layerPlaybackManager', () => ({ + layerPlaybackManager: { + activateLayer: vi.fn(), + pauseLayer: vi.fn(), + stopLayer: vi.fn(), + getLayerPlaybackInfo: vi.fn(() => null), + }, +})); + +vi.mock('../../src/stores/mediaStore', () => ({ + useMediaStore: Object.assign(vi.fn(), { + getState: vi.fn(), + }), +})); + +type MockFn = ReturnType; + +type MockMediaState = { + slotClipSettings: Record; + activeLayerSlots: Record; + updateSlotClipSettings: MockFn; + activateOnLayer: MockFn; +}; + +const mockedUseMediaStore = useMediaStore as unknown as MockFn & { + getState: MockFn; +}; +const mockedLayerPlaybackManager = layerPlaybackManager as unknown as { + activateLayer: MockFn; + pauseLayer: MockFn; + stopLayer: MockFn; + getLayerPlaybackInfo: MockFn; +}; + +function createComposition(): Composition { + return { + id: 'comp-1', + name: 'Slot Comp', + type: 'composition', + parentId: null, + createdAt: 1, + width: 1920, + height: 1080, + frameRate: 30, + duration: 60, + backgroundColor: '#000000', + timelineData: { + tracks: [ + { id: 'v1', name: 'Video 1', type: 'video', height: 80, muted: false, visible: true, solo: false }, + { id: 'a1', name: 'Audio 1', type: 'audio', height: 60, muted: false, visible: true, solo: false }, + ], + clips: [ + { + id: 'clip-v', + trackId: 'v1', + name: 'Intro Video', + mediaFileId: 'file-v', + startTime: 5, + duration: 20, + inPoint: 0, + outPoint: 20, + sourceType: 'video', + transform: {} as any, + effects: [], + }, + { + id: 'clip-a', + trackId: 'a1', + name: 'Intro Audio', + mediaFileId: 'file-a', + startTime: 5, + duration: 20, + inPoint: 0, + outPoint: 20, + sourceType: 'audio', + transform: {} as any, + effects: [], + }, + ], + playheadPosition: 17, + duration: 60, + zoom: 50, + scrollX: 0, + inPoint: null, + outPoint: null, + loopPlayback: false, + }, + }; +} + +describe('SlotClipTab', () => { + let mediaState: MockMediaState; + + beforeEach(() => { + vi.spyOn(window, 'requestAnimationFrame').mockImplementation(() => 1); + vi.spyOn(window, 'cancelAnimationFrame').mockImplementation(() => undefined); + mockedLayerPlaybackManager.activateLayer.mockClear(); + mockedLayerPlaybackManager.pauseLayer.mockClear(); + mockedLayerPlaybackManager.stopLayer.mockClear(); + mockedLayerPlaybackManager.getLayerPlaybackInfo.mockReturnValue(null); + + mediaState = { + slotClipSettings: { + 'comp-1': { + trimIn: 10, + trimOut: 30, + endBehavior: 'loop', + }, + }, + activeLayerSlots: {}, + updateSlotClipSettings: vi.fn(), + activateOnLayer: vi.fn(), + }; + + mockedUseMediaStore.mockImplementation((selector: (state: MockMediaState) => unknown) => selector(mediaState)); + mockedUseMediaStore.getState.mockImplementation(() => mediaState); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('renders a track-aware slot timeline with the configured range', () => { + const { container } = render(); + + expect(screen.getByText('Range')).toBeInTheDocument(); + expect(screen.getByText('Slot Comp')).toBeInTheDocument(); + expect(screen.getByText('A1')).toBeInTheDocument(); + expect(screen.getByText('Current')).toBeInTheDocument(); + expect(screen.getByText('In')).toBeInTheDocument(); + expect(screen.getByText('Out')).toBeInTheDocument(); + expect(screen.getByText('Length')).toBeInTheDocument(); + expect(container.querySelectorAll('.slot-clip-clip')).toHaveLength(2); + expect(container.querySelector('.slot-clip-range-window')).toHaveStyle({ + left: '16.666666666666664%', + width: '33.333333333333336%', + }); + }); + + it('restarts an active slot from its range start', () => { + mediaState.activeLayerSlots = { 0: 'comp-1' }; + + render(); + fireEvent.click(screen.getByText('Restart')); + + expect(mockedLayerPlaybackManager.activateLayer).toHaveBeenCalledWith( + 0, + 'comp-1', + undefined, + { slotIndex: 0 } + ); + }); + + it('activates the layer when launching an inactive slot', () => { + render(); + fireEvent.click(screen.getByText('Launch')); + + expect(mediaState.activateOnLayer).toHaveBeenCalledWith('comp-1', 0); + expect(mockedLayerPlaybackManager.activateLayer).not.toHaveBeenCalled(); + }); +}); diff --git a/tests/unit/midiBindingMutations.test.ts b/tests/unit/midiBindingMutations.test.ts index 3c4641fee..eefd22173 100644 --- a/tests/unit/midiBindingMutations.test.ts +++ b/tests/unit/midiBindingMutations.test.ts @@ -5,6 +5,7 @@ import { useTimelineStore } from '../../src/stores/timeline'; import { moveMarkerMIDIBinding, setMarkerMIDIBinding, + setSlotMIDIBinding, setTransportMIDIBinding, } from '../../src/services/midi/midiBindingMutations'; @@ -30,6 +31,7 @@ describe('midiBindingMutations', () => { playPause: null, stop: null, }, + slotBindings: {}, }); useTimelineStore.setState({ @@ -88,4 +90,26 @@ describe('midiBindingMutations', () => { { action: 'jumpToMarker', channel: 1, note: 60 }, ]); }); + + it('clears conflicting slot bindings when assigning a transport note', () => { + setSlotMIDIBinding(0, { channel: 1, note: 64 }); + + setTransportMIDIBinding('playPause', { channel: 1, note: 64 }); + + expect(useMIDIStore.getState().slotBindings[0]).toBeUndefined(); + expect(useMIDIStore.getState().transportBindings.playPause).toEqual({ channel: 1, note: 64 }); + }); + + it('clears conflicting transport and marker bindings when assigning a slot note', () => { + setTransportMIDIBinding('playPause', { channel: 1, note: 64 }); + setMarkerMIDIBinding('marker-b', 'playFromMarker', { channel: 1, note: 65 }); + + setSlotMIDIBinding(5, { channel: 1, note: 64 }); + setSlotMIDIBinding(6, { channel: 1, note: 65 }); + + expect(useMIDIStore.getState().transportBindings.playPause).toBeNull(); + expect(useTimelineStore.getState().markers[1]?.midiBindings).toBeUndefined(); + expect(useMIDIStore.getState().slotBindings[5]).toEqual({ channel: 1, note: 64 }); + expect(useMIDIStore.getState().slotBindings[6]).toEqual({ channel: 1, note: 65 }); + }); }); diff --git a/tests/unit/midiCommands.test.ts b/tests/unit/midiCommands.test.ts index 80f944c2f..4cd986688 100644 --- a/tests/unit/midiCommands.test.ts +++ b/tests/unit/midiCommands.test.ts @@ -1,9 +1,12 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { useTimelineStore } from '../../src/stores/timeline'; +import { useMediaStore } from '../../src/stores/mediaStore'; import { triggerMIDITransportAction, triggerMarkerMIDIAction, triggerMarkerMIDIBinding, + triggerSlotMIDIAction, + triggerSlotMIDIBinding, } from '../../src/services/midi/midiCommands'; describe('midiCommands', () => { @@ -18,6 +21,7 @@ describe('midiCommands', () => { afterEach(() => { globalThis.requestAnimationFrame = originalRequestAnimationFrame; + useMediaStore.setState({ slotAssignments: {} }); vi.restoreAllMocks(); }); @@ -109,4 +113,25 @@ describe('midiCommands', () => { expect(play).toHaveBeenCalledTimes(1); expect(setPlaybackSpeed).toHaveBeenCalledWith(1); }); + + it('triggers slot bindings on the assigned slot layer', async () => { + const triggerLiveSlot = vi.fn(); + vi.spyOn(useMediaStore, 'getState').mockReturnValue({ + slotAssignments: { + 'comp-slot': 13, + }, + triggerLiveSlot, + } as ReturnType); + + await triggerSlotMIDIAction(13); + await triggerSlotMIDIBinding({ + action: 'triggerSlot', + slotIndex: 13, + channel: 1, + note: 36, + }); + + expect(triggerLiveSlot).toHaveBeenCalledTimes(2); + expect(triggerLiveSlot).toHaveBeenCalledWith('comp-slot', 1); + }); }); diff --git a/tests/unit/midiMappingSummary.test.ts b/tests/unit/midiMappingSummary.test.ts index feeb0174f..17a7e3047 100644 --- a/tests/unit/midiMappingSummary.test.ts +++ b/tests/unit/midiMappingSummary.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest'; -import { collectMIDIMappingSummary } from '../../src/services/midi/midiMappingSummary'; +import { collectMIDIMappingSummary, getSlotGridLabel } from '../../src/services/midi/midiMappingSummary'; import type { TimelineMarker } from '../../src/stores/timeline/types'; describe('collectMIDIMappingSummary', () => { @@ -78,4 +78,36 @@ describe('collectMIDIMappingSummary', () => { expect(result[0]?.targetLabel).toBe('Long at 01:02:03'); }); + + it('collects slot mappings with grid labels and composition names', () => { + const result = collectMIDIMappingSummary( + { + playPause: null, + stop: null, + }, + [], + { + 0: { channel: 1, note: 36 }, + 13: { channel: 1, note: 37 }, + }, + [ + { slotIndex: 13, label: 'B2', compositionName: 'Loop B' }, + ] + ); + + expect(getSlotGridLabel(0)).toBe('A1'); + expect(getSlotGridLabel(13)).toBe('B2'); + expect(result).toHaveLength(2); + expect(result[0]).toMatchObject({ + scope: 'slot', + action: 'triggerSlot', + targetLabel: 'A1', + behaviorLabel: 'Trigger this slot on its layer', + }); + expect(result[1]).toMatchObject({ + scope: 'slot', + action: 'triggerSlot', + targetLabel: 'B2 - Loop B', + }); + }); }); diff --git a/tests/unit/slotGridLiveTrigger.test.tsx b/tests/unit/slotGridLiveTrigger.test.tsx index 81552f9e9..1e8a45fa2 100644 --- a/tests/unit/slotGridLiveTrigger.test.tsx +++ b/tests/unit/slotGridLiveTrigger.test.tsx @@ -1,8 +1,12 @@ import { describe, it, expect, beforeEach, vi } from 'vitest'; import { fireEvent, render, screen } from '@testing-library/react'; +import { createJSONStorage } from 'zustand/middleware'; import { SlotGrid } from '../../src/components/timeline/SlotGrid'; import { flags } from '../../src/engine/featureFlags'; import { useMediaStore } from '../../src/stores/mediaStore'; +import { useDockStore } from '../../src/stores/dockStore'; +import { useMIDIStore } from '../../src/stores/midiStore'; +import { layerPlaybackManager } from '../../src/services/layerPlaybackManager'; import type { SlotDeckState } from '../../src/stores/mediaStore/types'; vi.mock('../../src/services/layerBuilder', () => ({ @@ -17,6 +21,7 @@ vi.mock('../../src/services/layerPlaybackManager', () => ({ activateLayer: vi.fn(), deactivateLayer: vi.fn(), buildLayersForLayer: vi.fn(), + getLayerPlaybackInfo: vi.fn(() => null), }, })); @@ -120,6 +125,11 @@ type MockMediaState = { }; const mockedUseMediaStore = useMediaStore as unknown as MockMediaStore; +const mockedLayerPlaybackManager = layerPlaybackManager as unknown as { + activateLayer: MockFn; + deactivateLayer: MockFn; + getLayerPlaybackInfo: MockFn; +}; function createComposition(id = 'comp-1', name = 'Comp 1'): MockComposition { return { @@ -153,6 +163,24 @@ describe('SlotGrid live trigger flag', () => { beforeEach(() => { flags.useLiveSlotTrigger = false; flags.useWarmSlotDecks = false; + mockedLayerPlaybackManager.activateLayer.mockClear(); + mockedLayerPlaybackManager.deactivateLayer.mockClear(); + mockedLayerPlaybackManager.getLayerPlaybackInfo.mockReturnValue(null); + useMIDIStore.persist.setOptions({ + storage: createJSONStorage(() => ({ + getItem: () => null, + setItem: () => {}, + removeItem: () => {}, + })), + }); + useMIDIStore.setState({ + learnTarget: null, + slotBindings: {}, + transportBindings: { + playPause: null, + stop: null, + }, + }); const comp = createComposition(); const slotMap = new Array(48).fill(null); @@ -227,6 +255,39 @@ describe('SlotGrid live trigger flag', () => { expect(mediaState.activateColumn).not.toHaveBeenCalled(); }); + it('starts slotted layer playback from the slot range start instead of the saved editor playhead', () => { + mediaState.activeLayerSlots = { 0: 'comp-1' }; + mediaState.compositions[0].timelineData.playheadPosition = 17; + + render(); + + expect(mockedLayerPlaybackManager.activateLayer).toHaveBeenCalledWith( + 0, + 'comp-1', + undefined, + { slotIndex: 0 } + ); + }); + + it('re-clicking an active live slot restarts layer playback at the slot range start', () => { + flags.useLiveSlotTrigger = true; + mediaState.activeLayerSlots = { 0: 'comp-1' }; + + const { container } = render(); + mockedLayerPlaybackManager.activateLayer.mockClear(); + const slot = container.querySelector('[data-comp-id="comp-1"]') as HTMLElement; + + fireEvent.click(slot); + + expect(mediaState.triggerLiveSlot).toHaveBeenCalledWith('comp-1', 0); + expect(mockedLayerPlaybackManager.activateLayer).toHaveBeenCalledWith( + 0, + 'comp-1', + undefined, + { slotIndex: 0 } + ); + }); + it('keeps an explicit editor-open action available through the slot context menu', () => { flags.useLiveSlotTrigger = true; @@ -242,6 +303,29 @@ describe('SlotGrid live trigger flag', () => { }); }); + it('arms MIDI learn for a filled slot from the context menu and opens the mapping panel', () => { + const activatePanelType = vi + .spyOn(useDockStore.getState(), 'activatePanelType') + .mockImplementation(() => undefined); + + const { container } = render(); + const slot = container.querySelector('[data-comp-id="comp-1"]') as HTMLElement; + + fireEvent.contextMenu(slot, { clientX: 16, clientY: 24 }); + fireEvent.click(screen.getByText('Map MIDI to Slot A1')); + + expect(activatePanelType).toHaveBeenCalledWith('midi-mapping'); + expect(useMIDIStore.getState().learnTarget).toMatchObject({ + kind: 'slot', + slotIndex: 0, + slotLabel: 'A1', + compositionId: 'comp-1', + compositionName: 'Comp 1', + }); + + activatePanelType.mockRestore(); + }); + it('renders slot deck readiness badges from transient slotDeckStates', () => { flags.useWarmSlotDecks = true; mediaState.slotDeckStates = { From 142805de9a86736462497a8a6b9a6b7ec3dbc108 Mon Sep 17 00:00:00 2001 From: Sportinger Date: Fri, 24 Apr 2026 15:42:29 +0200 Subject: [PATCH 2/4] fix: interpolate camera keyframes as world poses --- docs/Features/3D-Layers.md | 2 +- src/engine/scene/SceneCameraUtils.ts | 33 +----- tests/unit/sceneCameraUtils.test.ts | 158 +++++++++++++++++++++++++-- 3 files changed, 156 insertions(+), 37 deletions(-) diff --git a/docs/Features/3D-Layers.md b/docs/Features/3D-Layers.md index 6456d4de2..885bae4a3 100644 --- a/docs/Features/3D-Layers.md +++ b/docs/Features/3D-Layers.md @@ -88,7 +88,7 @@ Camera clips expose their own Properties tab with: The Transform tab becomes scene-navigation controls for the active camera clip. In FPS mode, the preview accepts WASD/QE navigation plus uncapped mouse look. Free scene navigation now belongs to camera clips rather than gaussian-splat clips. -Camera rotation keyframes interpolate through the shortest angular path so timeline flights do not spin the long way around when yaw, pitch, or roll crosses a 360-degree wrap. FPS-look camera segments with keyed position/forward travel render through world-pose interpolation, keeping vertical-look roll moves from drifting away between keyframes. +Camera rotation keyframes interpolate through the shortest angular path so timeline flights do not spin the long way around when yaw, pitch, or roll crosses a 360-degree wrap. Camera transform keyframes for pan, distance, zoom, forward travel, and rotation render through world-pose interpolation: the camera eye and target are interpolated between keyed world poses, keeping FPS-look moves, zoom-outs, and vertical-look roll moves from drifting away between keyframes. ## Gaussian Splats diff --git a/src/engine/scene/SceneCameraUtils.ts b/src/engine/scene/SceneCameraUtils.ts index fe807d879..2d78833f8 100644 --- a/src/engine/scene/SceneCameraUtils.ts +++ b/src/engine/scene/SceneCameraUtils.ts @@ -30,17 +30,14 @@ type CameraVector3 = { x: number; y: number; z: number }; type CameraQuaternion = { x: number; y: number; z: number; w: number }; const CAMERA_ROTATION_PROPERTIES = new Set(['rotation.x', 'rotation.y', 'rotation.z']); -const CAMERA_POSE_TRIGGER_PROPERTIES = new Set([ +const CAMERA_POSE_PROPERTIES = new Set([ 'position.x', 'position.y', 'position.z', - 'scale.z', -]); -const CAMERA_POSE_PROPERTIES = new Set([ - ...CAMERA_ROTATION_PROPERTIES, - ...CAMERA_POSE_TRIGGER_PROPERTIES, 'scale.x', 'scale.y', + 'scale.z', + ...CAMERA_ROTATION_PROPERTIES, ]); function lerpNumber(a: number, b: number, t: number): number { @@ -55,18 +52,10 @@ function lerpVector(a: CameraVector3, b: CameraVector3, t: number): CameraVector }; } -function addVector(a: CameraVector3, b: CameraVector3): CameraVector3 { - return { x: a.x + b.x, y: a.y + b.y, z: a.z + b.z }; -} - function scaleVector(v: CameraVector3, scale: number): CameraVector3 { return { x: v.x * scale, y: v.y * scale, z: v.z * scale }; } -function distanceBetween(a: CameraVector3, b: CameraVector3): number { - return Math.hypot(a.x - b.x, a.y - b.y, a.z - b.z); -} - function normalizeQuaternion(q: CameraQuaternion): CameraQuaternion { const length = Math.hypot(q.x, q.y, q.z, q.w); if (length <= 1e-8) { @@ -182,9 +171,7 @@ function rotateVectorByQuaternion(v: CameraVector3, q: CameraQuaternion): Camera } function hasCameraPoseInterpolationKeyframes(keyframes: Keyframe[]): boolean { - const hasRotation = keyframes.some((keyframe) => CAMERA_ROTATION_PROPERTIES.has(keyframe.property)); - const hasPoseTrigger = keyframes.some((keyframe) => CAMERA_POSE_TRIGGER_PROPERTIES.has(keyframe.property)); - return hasRotation && hasPoseTrigger; + return getCameraPoseKeyframeTimes(keyframes).length >= 2; } function getCameraPoseKeyframeTimes(keyframes: Keyframe[]): number[] { @@ -375,20 +362,12 @@ function buildPoseInterpolatedCameraConfigFromClip( const endOrientation = quaternionFromCameraBasis(endFrame.right, endFrame.cameraUp, endFrame.forward); const orientation = slerpQuaternion(startOrientation, endOrientation, t); const eye = lerpVector(startFrame.eye, endFrame.eye, t); - const forward = rotateVectorByQuaternion({ x: 0, y: 0, z: -1 }, orientation); + const target = lerpVector(startFrame.target, endFrame.target, t); const up = rotateVectorByQuaternion({ x: 0, y: 1, z: 0 }, orientation); - const focusDistance = Math.max( - 0.001, - lerpNumber( - distanceBetween(startFrame.eye, startFrame.target), - distanceBetween(endFrame.eye, endFrame.target), - t, - ), - ); return { position: eye, - target: addVector(eye, scaleVector(forward, focusDistance)), + target, up, fov: cameraSettings.fov, near: cameraSettings.near, diff --git a/tests/unit/sceneCameraUtils.test.ts b/tests/unit/sceneCameraUtils.test.ts index c6ea9c1f0..fd195fccb 100644 --- a/tests/unit/sceneCameraUtils.test.ts +++ b/tests/unit/sceneCameraUtils.test.ts @@ -6,6 +6,7 @@ import { resolveSharedSceneCameraConfig, } from '../../src/engine/scene/SceneCameraUtils'; import { + resolveOrbitCameraFrame, resolveOrbitCameraPose, resolveOrbitCameraTranslationForFixedEye, } from '../../src/engine/gaussian/core/SplatCameraUtils'; @@ -262,15 +263,19 @@ describe('SceneCameraUtils', () => { viewport, ); - expect(config).toMatchObject({ - position: expected.eye, - target: expected.target, - up: expected.up, - fov: expected.fovDegrees, - near: expected.near, - far: expected.far, - applyDefaultDistance: false, - }); + expect(config.position.x).toBeCloseTo(expected.eye.x, 5); + expect(config.position.y).toBeCloseTo(expected.eye.y, 5); + expect(config.position.z).toBeCloseTo(expected.eye.z, 5); + expect(config.target.x).toBeCloseTo(expected.target.x, 5); + expect(config.target.y).toBeCloseTo(expected.target.y, 5); + expect(config.target.z).toBeCloseTo(expected.target.z, 5); + expect(config.up.x).toBeCloseTo(expected.up.x, 5); + expect(config.up.y).toBeCloseTo(expected.up.y, 5); + expect(config.up.z).toBeCloseTo(expected.up.z, 5); + expect(config.fov).toBe(expected.fovDegrees); + expect(config.near).toBe(expected.near); + expect(config.far).toBe(expected.far); + expect(config.applyDefaultDistance).toBe(false); }); it('interpolates FPS look camera keyframes as world poses near vertical pitch', () => { @@ -360,4 +365,139 @@ describe('SceneCameraUtils', () => { expect(config.position.y).toBeCloseTo(startPose.eye.y, 5); expect(config.position.z).toBeCloseTo(startPose.eye.z, 5); }); + + it('interpolates pure camera zoom keyframes in world pose space', () => { + const viewport = { width: 1920, height: 1080 }; + const settings = { + nearPlane: 0.1, + farPlane: 1000, + fov: 60, + minimumDistance: getSharedSceneDefaultCameraDistance(60), + }; + const cameraClip = { + id: 'zoom-camera', + trackId: 'camera-track', + startTime: 0, + duration: 2, + transform: { + position: { x: 0, y: 0, z: 0 }, + scale: { x: 1, y: 1, z: 0 }, + rotation: { x: 0, y: 0, z: 0 }, + opacity: 1, + blendMode: 'normal', + }, + source: { + type: 'camera', + cameraSettings: { + fov: 60, + near: 0.1, + far: 1000, + }, + }, + }; + const startFrame = resolveOrbitCameraFrame(cameraClip.transform, settings, viewport); + const endFrame = resolveOrbitCameraFrame( + { + ...cameraClip.transform, + scale: { x: 0.25, y: 0.25, z: 0 }, + }, + settings, + viewport, + ); + + const config = resolveSharedSceneCameraConfig(viewport, 1, { + sceneNavClipId: 'zoom-camera', + tracks: [{ + id: 'camera-track', + type: 'video', + visible: true, + }], + clips: [cameraClip as any], + clipKeyframes: new Map([[ + 'zoom-camera', + [ + { id: 'sx0', clipId: 'zoom-camera', property: 'scale.x', time: 0, value: 1, easing: 'linear' }, + { id: 'sx1', clipId: 'zoom-camera', property: 'scale.x', time: 2, value: 0.25, easing: 'linear' }, + { id: 'sy0', clipId: 'zoom-camera', property: 'scale.y', time: 0, value: 1, easing: 'linear' }, + { id: 'sy1', clipId: 'zoom-camera', property: 'scale.y', time: 2, value: 0.25, easing: 'linear' }, + ], + ]]), + }); + + expect(config.position.z).toBeCloseTo((startFrame.eye.z + endFrame.eye.z) / 2, 5); + expect(config.target).toEqual({ x: 0, y: 0, z: 0 }); + }); + + it('interpolates camera targets directly between keyed world poses', () => { + const viewport = { width: 1920, height: 1080 }; + const settings = { + nearPlane: 0.1, + farPlane: 1000, + fov: 60, + minimumDistance: getSharedSceneDefaultCameraDistance(60), + }; + const startTransform = { + position: { x: 0, y: 0, z: 0 }, + scale: { x: 1, y: 1, z: 0 }, + rotation: { x: 0, y: 0, z: 0 }, + }; + const endTransform = { + position: { x: 1, y: 0, z: 0 }, + scale: { x: 0.5, y: 0.5, z: 0 }, + rotation: { x: 0, y: 90, z: 0 }, + }; + const cameraClip = { + id: 'target-camera', + trackId: 'camera-track', + startTime: 0, + duration: 2, + transform: { + ...startTransform, + opacity: 1, + blendMode: 'normal', + }, + source: { + type: 'camera', + cameraSettings: { + fov: 60, + near: 0.1, + far: 1000, + }, + }, + }; + const startFrame = resolveOrbitCameraFrame(startTransform, settings, viewport); + const endFrame = resolveOrbitCameraFrame(endTransform, settings, viewport); + const expectedTarget = { + x: (startFrame.target.x + endFrame.target.x) / 2, + y: (startFrame.target.y + endFrame.target.y) / 2, + z: (startFrame.target.z + endFrame.target.z) / 2, + }; + + const config = resolveSharedSceneCameraConfig(viewport, 1, { + sceneNavClipId: 'target-camera', + tracks: [{ + id: 'camera-track', + type: 'video', + visible: true, + }], + clips: [cameraClip as any], + clipKeyframes: new Map([[ + 'target-camera', + [ + { id: 'px0', clipId: 'target-camera', property: 'position.x', time: 0, value: startTransform.position.x, easing: 'linear' }, + { id: 'px1', clipId: 'target-camera', property: 'position.x', time: 2, value: endTransform.position.x, easing: 'linear' }, + { id: 'sx0', clipId: 'target-camera', property: 'scale.x', time: 0, value: startTransform.scale.x, easing: 'linear' }, + { id: 'sx1', clipId: 'target-camera', property: 'scale.x', time: 2, value: endTransform.scale.x, easing: 'linear' }, + { id: 'sy0', clipId: 'target-camera', property: 'scale.y', time: 0, value: startTransform.scale.y, easing: 'linear' }, + { id: 'sy1', clipId: 'target-camera', property: 'scale.y', time: 2, value: endTransform.scale.y, easing: 'linear' }, + { id: 'ry0', clipId: 'target-camera', property: 'rotation.y', time: 0, value: startTransform.rotation.y, easing: 'linear' }, + { id: 'ry1', clipId: 'target-camera', property: 'rotation.y', time: 2, value: endTransform.rotation.y, easing: 'linear' }, + ], + ]]), + }); + + expect(config.target.x).toBeCloseTo(expectedTarget.x, 5); + expect(config.target.y).toBeCloseTo(expectedTarget.y, 5); + expect(config.target.z).toBeCloseTo(expectedTarget.z, 5); + }); }); From 66b92542b88decdef9660d2674f79bbc99d8c3ef Mon Sep 17 00:00:00 2001 From: Sportinger Date: Fri, 24 Apr 2026 15:46:05 +0200 Subject: [PATCH 3/4] Fix in-out playback for slot clips --- .../panels/properties/SlotClipTab.tsx | 11 +++-- src/components/timeline/SlotGrid.tsx | 14 +++++- .../timeline/hooks/usePlayheadDrag.ts | 7 ++- .../mediaStore/slices/compositionSlice.ts | 16 ++++-- src/stores/timeline/playbackRange.ts | 34 +++++++++++++ src/stores/timeline/playbackSlice.ts | 26 +++++++--- tests/helpers/storeFactory.ts | 13 ++++- .../mediaStore/compositionSlice.test.ts | 31 ++++++++++++ tests/stores/timeline/playbackSlice.test.ts | 49 +++++++++++++++++++ tests/unit/SlotClipTab.test.tsx | 9 +++- tests/unit/slotGridLiveTrigger.test.tsx | 39 ++++++++++++++- 11 files changed, 230 insertions(+), 19 deletions(-) create mode 100644 src/stores/timeline/playbackRange.ts diff --git a/src/components/panels/properties/SlotClipTab.tsx b/src/components/panels/properties/SlotClipTab.tsx index 59866b4d1..181c86fc1 100644 --- a/src/components/panels/properties/SlotClipTab.tsx +++ b/src/components/panels/properties/SlotClipTab.tsx @@ -251,13 +251,18 @@ export function SlotClipTab({ composition, slotIndex }: SlotClipTabProps) { }, [draggingEdge, updateTrimFromPointer]); const handleLaunch = useCallback(() => { + const launchTime = settings.trimIn; + if (!isLayerActive) { activateOnLayer(composition.id, layerIndex); - return; } - layerPlaybackManager.activateLayer(layerIndex, composition.id, undefined, { slotIndex }); - }, [activateOnLayer, composition.id, isLayerActive, layerIndex, slotIndex]); + layerPlaybackManager.activateLayer(layerIndex, composition.id, launchTime, { slotIndex }); + setPlayback({ + currentTime: launchTime, + playbackState: 'playing', + }); + }, [activateOnLayer, composition.id, isLayerActive, layerIndex, settings.trimIn, slotIndex]); const handlePause = useCallback(() => { if (!isLayerActive) { diff --git a/src/components/timeline/SlotGrid.tsx b/src/components/timeline/SlotGrid.tsx index f537ce125..09c0892d6 100644 --- a/src/components/timeline/SlotGrid.tsx +++ b/src/components/timeline/SlotGrid.tsx @@ -241,8 +241,18 @@ export function SlotGrid({ opacity }: SlotGridProps) { }, []); const openSlotInEditor = useCallback((compId: string) => { - openCompositionTab(compId, { skipAnimation: true, playFromStart: true }); - }, [openCompositionTab]); + const mediaState = useMediaStore.getState(); + const comp = mediaState.compositions.find(c => c.id === compId); + const duration = Math.max(comp?.duration || comp?.timelineData?.duration || 0, 0.05); + ensureSlotClipSettings?.(compId, duration); + + const playFromTime = useMediaStore.getState().slotClipSettings?.[compId]?.trimIn ?? 0; + openCompositionTab(compId, { + skipAnimation: true, + playFromStart: true, + playFromTime, + }); + }, [ensureSlotClipSettings, openCompositionTab]); // Click = select slot, open Slot Clip tab, and keep layer activation local to slot view. const handleSlotClick = useCallback((comp: Composition, slotIndex: number) => { diff --git a/src/components/timeline/hooks/usePlayheadDrag.ts b/src/components/timeline/hooks/usePlayheadDrag.ts index 591abff5e..3abff2152 100644 --- a/src/components/timeline/hooks/usePlayheadDrag.ts +++ b/src/components/timeline/hooks/usePlayheadDrag.ts @@ -121,6 +121,11 @@ export function usePlayheadDrag({ (e: React.MouseEvent, type: 'in' | 'out') => { e.stopPropagation(); e.preventDefault(); + + if (isPlaying) { + pause(); + } + const originalTime = type === 'in' ? inPoint : outPoint; if (originalTime === null) return; @@ -130,7 +135,7 @@ export function usePlayheadDrag({ originalTime, }); }, - [inPoint, outPoint] + [isPlaying, pause, inPoint, outPoint] ); // Handle marker dragging diff --git a/src/stores/mediaStore/slices/compositionSlice.ts b/src/stores/mediaStore/slices/compositionSlice.ts index e461c473e..b606ea8dc 100644 --- a/src/stores/mediaStore/slices/compositionSlice.ts +++ b/src/stores/mediaStore/slices/compositionSlice.ts @@ -13,6 +13,7 @@ import type { SlotDeckState } from '../types'; export interface CompositionSwitchOptions { skipAnimation?: boolean; playFromStart?: boolean; + playFromTime?: number; } export interface CompositionActions { @@ -61,6 +62,13 @@ const DURATION_SYNC_EPSILON = 0.0001; const AUTO_TIMELINE_MIN_DURATION = 60; const AUTO_TIMELINE_PADDING_SECONDS = 10; +function resolvePlayStartTime(options?: CompositionSwitchOptions): number { + const requested = options?.playFromTime; + return typeof requested === 'number' && Number.isFinite(requested) + ? Math.max(0, requested) + : 0; +} + type NestedCompReferenceClip = Pick & Partial> & Partial>; @@ -403,11 +411,12 @@ export const createCompositionSlice: MediaSliceCreator = (se // Same comp already active + playFromStart → just restart playback (no reload) if (id === activeCompositionId && options?.playFromStart) { const ts = useTimelineStore.getState(); + const playStartTime = resolvePlayStartTime(options); // Stop first to reset everything cleanly, then restart ts.pause(); - ts.setPlayheadPosition(0); + ts.setPlayheadPosition(playStartTime); // Reset the high-frequency playhead and audio master - playheadState.position = 0; + playheadState.position = playStartTime; playheadState.hasMasterAudio = false; playheadState.masterAudioElement = null; playheadState.playbackJustStarted = true; @@ -776,6 +785,7 @@ async function finishCompositionSwitch( const timelineStore = useTimelineStore.getState(); const skipAnimation = options?.skipAnimation ?? false; const playFromStart = options?.playFromStart ?? false; + const playStartTime = resolvePlayStartTime(options); // Update active composition set({ activeCompositionId: newId }); @@ -787,7 +797,7 @@ async function finishCompositionSwitch( await timelineStore.loadState(newComp?.timelineData); if (playFromStart) { - timelineStore.setPlayheadPosition(0); + timelineStore.setPlayheadPosition(playStartTime); timelineStore.play(); } else if (syncedPlayhead !== null && syncedPlayhead >= 0) { timelineStore.setPlayheadPosition(syncedPlayhead); diff --git a/src/stores/timeline/playbackRange.ts b/src/stores/timeline/playbackRange.ts new file mode 100644 index 000000000..567af6d5d --- /dev/null +++ b/src/stores/timeline/playbackRange.ts @@ -0,0 +1,34 @@ +function sanitizeTime(value: unknown, fallback = 0): number { + return typeof value === 'number' && Number.isFinite(value) ? value : fallback; +} + +export function resolvePlaybackStartPosition( + playheadPosition: number, + inPoint: number | null, + outPoint: number | null, + duration: number, + playbackSpeed: number, +): number { + const safeDuration = Math.max(0, sanitizeTime(duration, 0)); + const rangeStart = Math.max(0, Math.min(inPoint ?? 0, safeDuration)); + const rangeEnd = Math.max(rangeStart, Math.min(outPoint ?? safeDuration, safeDuration)); + const clampedPlayhead = Math.max(0, Math.min( + sanitizeTime(playheadPosition, rangeStart), + safeDuration, + )); + const hasRange = inPoint !== null || outPoint !== null; + + if (!hasRange) { + return clampedPlayhead; + } + + if (playbackSpeed < 0) { + return clampedPlayhead <= rangeStart || clampedPlayhead > rangeEnd + ? rangeEnd + : clampedPlayhead; + } + + return clampedPlayhead < rangeStart || clampedPlayhead >= rangeEnd + ? rangeStart + : clampedPlayhead; +} diff --git a/src/stores/timeline/playbackSlice.ts b/src/stores/timeline/playbackSlice.ts index 423ac29ca..7f2e28949 100644 --- a/src/stores/timeline/playbackSlice.ts +++ b/src/stores/timeline/playbackSlice.ts @@ -6,6 +6,7 @@ import { useMediaStore } from '../mediaStore'; import { engine } from '../../engine/WebGPUEngine'; import { getRuntimeFrameProvider } from '../../services/mediaRuntime/runtimePlayback'; import { playheadState, sanitizePlayheadPosition } from '../../services/layerBuilder/PlayheadState'; +import { resolvePlaybackStartPosition } from './playbackRange'; // Playback actions only (RAM preview and proxy cache in separate slices) export const createPlaybackSlice: SliceCreator = (set, get) => ({ @@ -33,11 +34,24 @@ export const createPlaybackSlice: SliceCreator = (set, get) => }, play: async () => { - const { clips } = get(); + const { clips, inPoint, outPoint, duration, playbackSpeed } = get(); const playheadPosition = sanitizePlayheadPosition( get().playheadPosition, sanitizePlayheadPosition(playheadState.position, 0) ); + const playbackStartPosition = resolvePlaybackStartPosition( + playheadPosition, + inPoint, + outPoint, + duration, + playbackSpeed, + ); + + if (playbackStartPosition !== playheadPosition) { + set({ playheadPosition: playbackStartPosition }); + playheadState.position = playbackStartPosition; + } + const needsHtmlPlaybackReadiness = ( source: (typeof clips)[number]['source'] | undefined ): source is NonNullable<(typeof clips)[number]['source']> & { @@ -60,8 +74,8 @@ export const createPlaybackSlice: SliceCreator = (set, get) => // Find all video clips at current playhead position that need to be ready const clipsAtPlayhead = clips.filter(clip => { - const isAtPlayhead = playheadPosition >= clip.startTime && - playheadPosition < clip.startTime + clip.duration; + const isAtPlayhead = playbackStartPosition >= clip.startTime && + playbackStartPosition < clip.startTime + clip.duration; const hasVideo = needsHtmlPlaybackReadiness(clip.source); return isAtPlayhead && hasVideo; }); @@ -70,10 +84,10 @@ export const createPlaybackSlice: SliceCreator = (set, get) => const nestedVideos: HTMLVideoElement[] = []; for (const clip of clips) { if (clip.isComposition && clip.nestedClips) { - const isAtPlayhead = playheadPosition >= clip.startTime && - playheadPosition < clip.startTime + clip.duration; + const isAtPlayhead = playbackStartPosition >= clip.startTime && + playbackStartPosition < clip.startTime + clip.duration; if (isAtPlayhead) { - const compTime = playheadPosition - clip.startTime + clip.inPoint; + const compTime = playbackStartPosition - clip.startTime + clip.inPoint; for (const nestedClip of clip.nestedClips) { if (needsHtmlPlaybackReadiness(nestedClip.source)) { const isNestedAtTime = compTime >= nestedClip.startTime && diff --git a/tests/helpers/storeFactory.ts b/tests/helpers/storeFactory.ts index 11cd712ce..419dd6ea9 100644 --- a/tests/helpers/storeFactory.ts +++ b/tests/helpers/storeFactory.ts @@ -23,6 +23,7 @@ import { createClipEffectSlice } from '../../src/stores/timeline/clipEffectSlice import { createLinkedGroupSlice } from '../../src/stores/timeline/linkedGroupSlice'; import { createDownloadClipSlice } from '../../src/stores/timeline/downloadClipSlice'; import { createPositioningUtils } from '../../src/stores/timeline/positioningUtils'; +import { resolvePlaybackStartPosition } from '../../src/stores/timeline/playbackRange'; // Minimal initial state sufficient for testing slices function getInitialState(): Partial { @@ -111,7 +112,17 @@ export function createTestTimelineStore(overrides?: Partial) { set({ playheadPosition: Math.max(0, Math.min(position, duration)) } as any); }, setDraggingPlayhead: (dragging: boolean) => set({ isDraggingPlayhead: dragging } as any), - play: async () => set({ isPlaying: true } as any), + play: async () => { + const { playheadPosition, inPoint, outPoint, duration, playbackSpeed } = get(); + const playbackStartPosition = resolvePlaybackStartPosition( + playheadPosition, + inPoint, + outPoint, + duration, + playbackSpeed, + ); + set({ isPlaying: true, playheadPosition: playbackStartPosition } as any); + }, pause: () => set({ isPlaying: false, playbackSpeed: 1 } as any), stop: () => set({ isPlaying: false, playheadPosition: 0 } as any), setZoom: (zoom: number) => set({ zoom: Math.max(0.1, Math.min(200, zoom)) } as any), diff --git a/tests/stores/mediaStore/compositionSlice.test.ts b/tests/stores/mediaStore/compositionSlice.test.ts index 9a74fe0b1..4d84f3af1 100644 --- a/tests/stores/mediaStore/compositionSlice.test.ts +++ b/tests/stores/mediaStore/compositionSlice.test.ts @@ -119,6 +119,8 @@ function createTestMediaStore(overrides?: Partial) { activeCompositionId: 'comp-1', openCompositionIds: ['comp-1'], slotAssignments: {}, + slotClipSettings: {}, + slotDeckStates: {}, previewCompositionId: null, activeLayerSlots: {}, layerOpacities: {}, @@ -819,6 +821,35 @@ describe('compositionSlice', () => { expect(store.getState().activeCompositionId).toBe(comp2.id); }); + it('openCompositionTab: restarts active playback from requested playFromTime', async () => { + useTimelineStore.setState({ playheadPosition: 25, isPlaying: true } as any); + + store.getState().openCompositionTab('comp-1', { + playFromStart: true, + playFromTime: 14, + }); + await Promise.resolve(); + + expect(useTimelineStore.getState().playheadPosition).toBe(14); + expect(useTimelineStore.getState().isPlaying).toBe(true); + }); + + it('openCompositionTab: starts newly opened playback from requested playFromTime', async () => { + const comp2 = store.getState().createComposition('SlotStart', { + timelineData: makeTimelineData([], { playheadPosition: 30 }), + }); + + store.getState().openCompositionTab(comp2.id, { + skipAnimation: true, + playFromStart: true, + playFromTime: 12, + }); + await new Promise(resolve => setTimeout(resolve, 0)); + + expect(useTimelineStore.getState().playheadPosition).toBe(12); + expect(useTimelineStore.getState().isPlaying).toBe(true); + }); + // ─── closeCompositionTab ──────────────────────────────────────── it('closeCompositionTab: removes comp from openCompositionIds', () => { diff --git a/tests/stores/timeline/playbackSlice.test.ts b/tests/stores/timeline/playbackSlice.test.ts index ac24c7c89..80f3bae16 100644 --- a/tests/stores/timeline/playbackSlice.test.ts +++ b/tests/stores/timeline/playbackSlice.test.ts @@ -80,6 +80,55 @@ describe('playbackSlice', () => { expect(store.getState().isPlaying).toBe(true); }); + it('play: does not move playhead when no range is active', async () => { + store.getState().setPlayheadPosition(15); + await store.getState().play(); + expect(store.getState().playheadPosition).toBe(15); + }); + + it('play: starts from in point when playhead is before active range', async () => { + store.getState().setInPoint(10); + store.getState().setOutPoint(30); + store.getState().setPlayheadPosition(0); + + await store.getState().play(); + + expect(store.getState().playheadPosition).toBe(10); + expect(store.getState().isPlaying).toBe(true); + }); + + it('play: restarts from in point when playhead is at out point', async () => { + store.getState().setInPoint(10); + store.getState().setOutPoint(30); + store.getState().setPlayheadPosition(30); + + await store.getState().play(); + + expect(store.getState().playheadPosition).toBe(10); + }); + + it('play: preserves playhead when already inside active range', async () => { + store.getState().setInPoint(10); + store.getState().setOutPoint(30); + store.getState().setPlayheadPosition(20); + + await store.getState().play(); + + expect(store.getState().playheadPosition).toBe(20); + }); + + it('play: starts reverse playback from out point when playhead is outside active range', async () => { + store.getState().setInPoint(10); + store.getState().setOutPoint(30); + store.getState().setPlayheadPosition(5); + store.getState().setPlaybackSpeed(-1); + + await store.getState().play(); + + expect(store.getState().playheadPosition).toBe(30); + expect(store.getState().playbackSpeed).toBe(-1); + }); + // ─── setDraggingPlayhead ────────────────────────────────────────────── it('setDraggingPlayhead: sets isDraggingPlayhead to true', () => { diff --git a/tests/unit/SlotClipTab.test.tsx b/tests/unit/SlotClipTab.test.tsx index 778b45eb4..0ac76f29d 100644 --- a/tests/unit/SlotClipTab.test.tsx +++ b/tests/unit/SlotClipTab.test.tsx @@ -153,7 +153,7 @@ describe('SlotClipTab', () => { expect(mockedLayerPlaybackManager.activateLayer).toHaveBeenCalledWith( 0, 'comp-1', - undefined, + 10, { slotIndex: 0 } ); }); @@ -163,6 +163,11 @@ describe('SlotClipTab', () => { fireEvent.click(screen.getByText('Launch')); expect(mediaState.activateOnLayer).toHaveBeenCalledWith('comp-1', 0); - expect(mockedLayerPlaybackManager.activateLayer).not.toHaveBeenCalled(); + expect(mockedLayerPlaybackManager.activateLayer).toHaveBeenCalledWith( + 0, + 'comp-1', + 10, + { slotIndex: 0 } + ); }); }); diff --git a/tests/unit/slotGridLiveTrigger.test.tsx b/tests/unit/slotGridLiveTrigger.test.tsx index 1e8a45fa2..81db829ba 100644 --- a/tests/unit/slotGridLiveTrigger.test.tsx +++ b/tests/unit/slotGridLiveTrigger.test.tsx @@ -7,7 +7,7 @@ import { useMediaStore } from '../../src/stores/mediaStore'; import { useDockStore } from '../../src/stores/dockStore'; import { useMIDIStore } from '../../src/stores/midiStore'; import { layerPlaybackManager } from '../../src/services/layerPlaybackManager'; -import type { SlotDeckState } from '../../src/stores/mediaStore/types'; +import type { SlotClipSettings, SlotDeckState } from '../../src/stores/mediaStore/types'; vi.mock('../../src/services/layerBuilder', () => ({ playheadState: { @@ -105,6 +105,7 @@ type MockComposition = { type MockMediaState = { activeCompositionId: string | null; slotAssignments: Record; + slotClipSettings: Record; activeLayerSlots: Record; slotDeckStates: Record; openCompositionTab: ReturnType; @@ -121,6 +122,7 @@ type MockMediaState = { compositions: MockComposition[]; files: Array<{ id: string; thumbnailUrl?: string }>; activateOnLayer: ReturnType; + ensureSlotClipSettings: ReturnType; setActiveComposition: ReturnType; }; @@ -189,6 +191,13 @@ describe('SlotGrid live trigger flag', () => { mediaState = { activeCompositionId: null, slotAssignments: { [comp.id]: 0 }, + slotClipSettings: { + [comp.id]: { + trimIn: 0, + trimOut: 60, + endBehavior: 'loop', + }, + }, activeLayerSlots: {}, slotDeckStates: {}, openCompositionTab: vi.fn(), @@ -205,6 +214,13 @@ describe('SlotGrid live trigger flag', () => { compositions: [comp], files: [], activateOnLayer: vi.fn(), + ensureSlotClipSettings: vi.fn((compositionId: string, duration: number) => { + mediaState.slotClipSettings[compositionId] ??= { + trimIn: 0, + trimOut: duration, + endBehavior: 'loop', + }; + }), setActiveComposition: vi.fn(), }; @@ -226,11 +242,31 @@ describe('SlotGrid live trigger flag', () => { expect(mediaState.openCompositionTab).toHaveBeenCalledWith('comp-1', { skipAnimation: true, playFromStart: true, + playFromTime: 0, }); expect(mediaState.activateOnLayer).toHaveBeenCalledWith('comp-1', 0); expect(mediaState.triggerLiveSlot).not.toHaveBeenCalled(); }); + it('opens the slot editor playback at the configured slot range start', () => { + mediaState.slotClipSettings['comp-1'] = { + trimIn: 12, + trimOut: 40, + endBehavior: 'loop', + }; + + const { container } = render(); + const slot = container.querySelector('[data-comp-id="comp-1"]') as HTMLElement; + + fireEvent.click(slot); + + expect(mediaState.openCompositionTab).toHaveBeenCalledWith('comp-1', { + skipAnimation: true, + playFromStart: true, + playFromTime: 12, + }); + }); + it('routes slot click through live triggering without opening the editor when the flag is on', () => { flags.useLiveSlotTrigger = true; @@ -300,6 +336,7 @@ describe('SlotGrid live trigger flag', () => { expect(mediaState.openCompositionTab).toHaveBeenCalledWith('comp-1', { skipAnimation: true, playFromStart: true, + playFromTime: 0, }); }); From eb97ddfad87d4d41afbea65de5194262a7909eae Mon Sep 17 00:00:00 2001 From: Sportinger Date: Fri, 24 Apr 2026 15:48:39 +0200 Subject: [PATCH 4/4] Release 1.5.11 --- src/changelog-data.json | 20 ++++++++++++++++++++ src/version.ts | 2 +- 2 files changed, 21 insertions(+), 1 deletion(-) diff --git a/src/changelog-data.json b/src/changelog-data.json index a687de356..9e8b46c83 100644 --- a/src/changelog-data.json +++ b/src/changelog-data.json @@ -1,4 +1,24 @@ [ + { + "date": "2026-04-24", + "type": "fix", + "title": "Slot Clip In/Out Ranges Now Drive Playback Start", + "description": "Slot Clip launch, restart, and editor-open playback now start from the configured slot In point, while normal timeline playback also respects active In/Out ranges before playback begins.", + "section": "Slot Grid / Playback", + "commits": [ + "66b92542" + ] + }, + { + "date": "2026-04-24", + "type": "improve", + "title": "Slot Grid MIDI Mapping and Slot Clip Controls Improved", + "description": "Slot MIDI mapping now has clearer summaries and conflict handling, and the Slot Clip properties view includes a transport-aware range editor for per-slot playback control.", + "section": "Slot Grid / MIDI", + "commits": [ + "c842fce9" + ] + }, { "date": "2026-04-24", "type": "fix", diff --git a/src/version.ts b/src/version.ts index 5991729a6..af2c769d3 100644 --- a/src/version.ts +++ b/src/version.ts @@ -1,6 +1,6 @@ // App version // Format: MAJOR.MINOR.PATCH -export const APP_VERSION = '1.5.10'; +export const APP_VERSION = '1.5.11'; export interface ChangelogNotice { type: 'info' | 'warning' | 'success' | 'danger';