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/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/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/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
+
+
+ Cancel Learn
+
+
+
+
+ )}
{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..181c86fc1 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,19 @@ export function SlotClipTab({ composition, slotIndex }: SlotClipTabProps) {
};
}, [draggingEdge, updateTrimFromPointer]);
- const handlePlay = useCallback(() => {
+ const handleLaunch = useCallback(() => {
+ const launchTime = settings.trimIn;
+
if (!isLayerActive) {
activateOnLayer(composition.id, layerIndex);
- return;
}
- layerPlaybackManager.playLayer(layerIndex);
- }, [activateOnLayer, composition.id, isLayerActive, layerIndex]);
+ 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) {
@@ -188,23 +284,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 (
-
- {isLayerActive ? 'Active' : 'Loaded'}
+
+ {isLayerActive ? 'Active' : 'Loaded'}
+
{playback.playbackState}
{formatTime(playback.currentTime)} / {formatTime(duration)}
@@ -213,8 +317,8 @@ export function SlotClipTab({ composition, slotIndex }: SlotClipTabProps) {
Transport
-
- {playback.playbackState === 'playing' ? 'Playing' : 'Play'}
+
+ {isLayerActive ? 'Restart' : 'Launch'}
-
Crop
-
-
-
+
+
Range
+ {formatTime(settings.trimOut - settings.trimIn)}
+
+
+
+ 0.0s
+ {formatTime(duration / 2)}
+ {formatTime(duration)}
-
-
-
-
-
+
+ {rows.length > 0 ? (
+ rows.map((row, rowIndex) => (
+
+
+ {row.type === 'audio' ? 'A' : 'V'}{rowIndex + 1}
+
+
+ {row.clips.map((clip) => {
+ const left = getPercent(clip.startTime, duration);
+ const width = getClipWidthPercent(clip, duration);
+ const clipType = getClipType(clip);
+ return (
+
+ {getClipLabel(clip)}
+
+ );
+ })}
+
+
+ ))
+ ) : (
+
No timeline clips
+ )}
+
+
+
+
+
+
{
event.preventDefault();
setDraggingEdge('in');
@@ -262,7 +409,7 @@ export function SlotClipTab({ composition, slotIndex }: SlotClipTabProps) {
{
event.preventDefault();
setDraggingEdge('out');
@@ -273,6 +420,10 @@ export function SlotClipTab({ composition, slotIndex }: SlotClipTabProps) {
+
+ Current
+ {formatTime(playback.currentTime)}
+
In
{formatTime(settings.trimIn)}
diff --git a/src/components/timeline/SlotGrid.tsx b/src/components/timeline/SlotGrid.tsx
index 07fc6b3ab..09c0892d6 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 });
}
}
@@ -233,12 +241,23 @@ 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) => {
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 +269,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 +364,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 +390,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 +561,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 +686,9 @@ export function SlotGrid({ opacity }: SlotGridProps) {
onMouseDown={(e) => e.stopPropagation()}
>
Open in Editor
+
+ Map MIDI to Slot {getSlotGridLabel(contextMenu.slotIndex)}
+
Remove from Slot
)}
@@ -702,8 +758,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/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/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/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/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/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/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/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/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';
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
new file mode 100644
index 000000000..0ac76f29d
--- /dev/null
+++ b/tests/unit/SlotClipTab.test.tsx
@@ -0,0 +1,173 @@
+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',
+ 10,
+ { 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).toHaveBeenCalledWith(
+ 0,
+ 'comp-1',
+ 10,
+ { slotIndex: 0 }
+ );
+ });
+});
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/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);
+ });
});
diff --git a/tests/unit/slotGridLiveTrigger.test.tsx b/tests/unit/slotGridLiveTrigger.test.tsx
index 81552f9e9..81db829ba 100644
--- a/tests/unit/slotGridLiveTrigger.test.tsx
+++ b/tests/unit/slotGridLiveTrigger.test.tsx
@@ -1,9 +1,13 @@
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 type { SlotDeckState } from '../../src/stores/mediaStore/types';
+import { useDockStore } from '../../src/stores/dockStore';
+import { useMIDIStore } from '../../src/stores/midiStore';
+import { layerPlaybackManager } from '../../src/services/layerPlaybackManager';
+import type { SlotClipSettings, SlotDeckState } from '../../src/stores/mediaStore/types';
vi.mock('../../src/services/layerBuilder', () => ({
playheadState: {
@@ -17,6 +21,7 @@ vi.mock('../../src/services/layerPlaybackManager', () => ({
activateLayer: vi.fn(),
deactivateLayer: vi.fn(),
buildLayersForLayer: vi.fn(),
+ getLayerPlaybackInfo: vi.fn(() => null),
},
}));
@@ -100,6 +105,7 @@ type MockComposition = {
type MockMediaState = {
activeCompositionId: string | null;
slotAssignments: Record;
+ slotClipSettings: Record;
activeLayerSlots: Record;
slotDeckStates: Record;
openCompositionTab: ReturnType;
@@ -116,10 +122,16 @@ type MockMediaState = {
compositions: MockComposition[];
files: Array<{ id: string; thumbnailUrl?: string }>;
activateOnLayer: ReturnType;
+ ensureSlotClipSettings: ReturnType;
setActiveComposition: ReturnType;
};
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 +165,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);
@@ -161,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(),
@@ -177,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(),
};
@@ -198,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;
@@ -227,6 +291,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;
@@ -239,7 +336,31 @@ describe('SlotGrid live trigger flag', () => {
expect(mediaState.openCompositionTab).toHaveBeenCalledWith('comp-1', {
skipAnimation: true,
playFromStart: true,
+ playFromTime: 0,
+ });
+ });
+
+ 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', () => {