Skip to content

Commit f4edb46

Browse files
authored
Improve timeline MIDI editing and instrument panels (#5683)
* feat(timeline): improve MIDI editing and instrument panels * feat(timeline): allow optional instrument UI parameters
1 parent 60f17d6 commit f4edb46

37 files changed

Lines changed: 3455 additions & 1304 deletions

packages/timeline/src/types.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -623,6 +623,8 @@ export interface WavetableOscillator {
623623
*/
624624
export interface WavetableMidiInstrument {
625625
type: "wavetable";
626+
/** UI parameters from the WT-1 rack that are not part of the render core. */
627+
fableParams?: Record<string, number>;
626628
oscA: WavetableOscillator;
627629
oscB: WavetableOscillator;
628630
/** Sine sub-oscillator level, 0..1. */
@@ -653,6 +655,8 @@ export interface WavetableMidiInstrument {
653655
*/
654656
export interface BassMidiInstrument {
655657
type: "bass";
658+
/** UI parameters from the BL-1 rack that are not part of the render core. */
659+
fableParams?: Record<string, number>;
656660
table: MidiWavetableName;
657661
/** Morph position across the table's frames, 0..1. */
658662
position: number;
@@ -740,6 +744,8 @@ export interface DrumPad {
740744
*/
741745
export interface DrumMidiInstrument {
742746
type: "drum";
747+
/** UI parameters from the DR-1 rack that are not part of the render core. */
748+
fableParams?: Record<string, number>;
743749
/** The MIDI note pad 0 answers to. Pads run `baseNote`..`baseNote + 15`. */
744750
baseNote: number;
745751
pads: DrumPad[];

web/src/components/panels/PanelBottom.tsx

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ import WorkerStatusIndicator from "../workers/WorkerStatusIndicator";
2929
import { VersionHistoryPanel } from "../version";
3030
import PanelHeadline from "../ui/PanelHeadline";
3131
import { useCombo } from "../../stores/KeyPressedStore";
32+
import { useWorkspaceTabsStore } from "../../stores/WorkspaceTabsStore";
3233
import { TOOLTIP_ENTER_DELAY } from "../../config/constants";
3334
import { useWorkflowManager } from "../../contexts/WorkflowManagerContext";
3435
import { ContextMenuProvider } from "../../providers/ContextMenuProvider";
@@ -469,7 +470,11 @@ const PanelBottom: React.FC = () => {
469470
const systemStats = useSystemStatsStore((state) => state.stats);
470471

471472
useCombo(["Control", "Shift", "T"], () => handlePanelToggle("trace"), false);
472-
useCombo(["l"], () => handlePanelToggle("logs"), false);
473+
const timelineEditing = useWorkspaceTabsStore((state) => {
474+
const tab = state.tabs.find((item) => item.id === state.activeTabId);
475+
return tab?.type === "timeline" && tab.mode === "edit";
476+
});
477+
useCombo(["l"], () => handlePanelToggle("logs"), false, !timelineEditing);
473478

474479
// Shown in the legacy editor (/editor) and the unified workspace (/workspace).
475480
if (!path.startsWith("/editor") && !path.startsWith("/workspace")) {

web/src/components/timeline/SourceViewerPanel.test.tsx

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,7 @@ jest.mock("../ui_primitives", () => ({
4343
Text: ({ children }: React.PropsWithChildren) => <span>{children}</span>,
4444
Tooltip: ({ children }: React.PropsWithChildren) => <>{children}</>,
4545
TruncatedText: ({ children }: React.PropsWithChildren) => <span>{children}</span>,
46-
VideoPlayer: ({ onDurationChange }: { onDurationChange?: (duration: number) => void }) => <button aria-label="Video player" onClick={() => onDurationChange?.(5)}>Video</button>,
46+
VideoPlayer: ({ onDurationChange, onTimeUpdate }: { onDurationChange?: (duration: number) => void; onTimeUpdate?: (time: number) => void }) => <button aria-label="Video player" onClick={() => { onDurationChange?.(5); onTimeUpdate?.(2); }}>Video</button>,
4747
SPACING: { md: 1, xs: 1, sm: 1 },
4848
getSpacingPx: () => "1px"
4949
}));
@@ -77,3 +77,25 @@ describe("SourceViewerPanel", () => {
7777
expect(mockPerformSourceEdit).toHaveBeenCalledWith("append", expect.objectContaining({ ui: expect.objectContaining({ sourceRange: { inMs: 1000, outMs: 2000 } }) }));
7878
});
7979
});
80+
81+
82+
it("marks the source playhead with I/O without forwarding to the timeline", () => {
83+
render(<SourceViewerPanel />);
84+
fireEvent.click(screen.getByRole("button", { name: "Video player" }));
85+
mockSetSourceRange.mockClear();
86+
const forwarded = jest.fn();
87+
window.addEventListener("keydown", forwarded);
88+
try {
89+
fireEvent.keyDown(screen.getByTestId("source-viewer"), { key: "i" });
90+
expect(mockSetSourceRange).toHaveBeenLastCalledWith({ inMs: 2000, outMs: 5000 });
91+
fireEvent.keyDown(screen.getByTestId("source-viewer"), { key: "o" });
92+
expect(mockSetSourceRange).toHaveBeenLastCalledWith({ inMs: 2000, outMs: 2000 });
93+
expect(forwarded).not.toHaveBeenCalled();
94+
mockSetSourceRange.mockClear();
95+
fireEvent.keyDown(screen.getByLabelText("Source in point"), { key: "i" });
96+
fireEvent.keyDown(screen.getByTestId("source-viewer"), { key: "i", ctrlKey: true });
97+
expect(mockSetSourceRange).not.toHaveBeenCalled();
98+
} finally {
99+
window.removeEventListener("keydown", forwarded);
100+
}
101+
});

web/src/components/timeline/SourceViewerPanel.tsx

Lines changed: 36 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -79,7 +79,6 @@ export const SourceViewerPanel: React.FC = memo(() => {
7979

8080
// The player's own time, so "mark in/out here" reads where it is parked.
8181
const playerTimeRef = useRef(0);
82-
const [playerTimeMs, setPlayerTimeMs] = useState(0);
8382
const [sourceDurationMs, setSourceDurationMs] = useState<number | null>(null);
8483
const onDurationChange = useCallback(
8584
(seconds: number) => {
@@ -93,15 +92,13 @@ export const SourceViewerPanel: React.FC = memo(() => {
9392
);
9493
const onTimeUpdate = useCallback((sec: number) => {
9594
playerTimeRef.current = Math.round(sec * 1000);
96-
setPlayerTimeMs(playerTimeRef.current);
9795
}, []);
9896

9997
// A new asset starts with no range.
10098
const assetId = asset?.id;
10199
useEffect(() => {
102100
setSourceRange(null);
103101
setSourceDurationMs(null);
104-
setPlayerTimeMs(0);
105102
playerTimeRef.current = 0;
106103
}, [assetId, setSourceRange]);
107104

@@ -135,8 +132,41 @@ export const SourceViewerPanel: React.FC = memo(() => {
135132
const keys = (action: "sourceAppend" | "sourceInsert" | "sourceOverwrite") =>
136133
bindingKeys(TIMELINE_KEYMAPS[preset][action][0]);
137134

135+
const markSource = (edge: "in" | "out") => {
136+
const currentRange = sourceRangeFor(asset, uiApi.getState().sourceRange);
137+
const timeMs = Math.max(
138+
0, Math.min(playerTimeRef.current, sourceDurationMs ?? Infinity)
139+
);
140+
setSourceRange(
141+
edge === "in"
142+
? { inMs: timeMs, outMs: Math.max(timeMs, currentRange.outMs) }
143+
: { inMs: Math.min(timeMs, currentRange.inMs), outMs: timeMs }
144+
);
145+
};
146+
147+
const handleKeyDown = (event: React.KeyboardEvent<HTMLDivElement>) => {
148+
if (
149+
event.ctrlKey || event.metaKey || event.altKey || event.shiftKey ||
150+
event.target instanceof HTMLInputElement ||
151+
event.target instanceof HTMLTextAreaElement ||
152+
(event.target instanceof HTMLElement && event.target.isContentEditable)
153+
) return;
154+
if (mediaType === "video" && (event.key === "i" || event.key === "o")) {
155+
event.preventDefault();
156+
event.stopPropagation();
157+
markSource(event.key === "i" ? "in" : "out");
158+
}
159+
};
160+
138161
return (
139-
<div css={panelStyles(theme)} data-testid="source-viewer">
162+
<div
163+
css={panelStyles(theme)}
164+
data-testid="source-viewer"
165+
role="region"
166+
aria-label="Source monitor"
167+
tabIndex={0}
168+
onKeyDown={handleKeyDown}
169+
>
140170
<FlexColumn gap={SPACING.md}>
141171
<TruncatedText variant="body2" sx={{ fontWeight: 500 }} showTooltip>
142172
{asset.name}
@@ -172,7 +202,7 @@ export const SourceViewerPanel: React.FC = memo(() => {
172202
}}
173203
/>
174204
{mediaType === "video" && (
175-
<Button size="small" variant="text" onClick={() => setSourceRange({ inMs: playerTimeMs, outMs: range.outMs })}>
205+
<Button size="small" variant="text" onClick={() => markSource("in")} aria-label="Mark source in (I)">
176206
Mark here
177207
</Button>
178208
)}
@@ -193,7 +223,7 @@ export const SourceViewerPanel: React.FC = memo(() => {
193223
}}
194224
/>
195225
{mediaType === "video" && (
196-
<Button size="small" variant="text" onClick={() => setSourceRange({ inMs: range.inMs, outMs: playerTimeMs })}>
226+
<Button size="small" variant="text" onClick={() => markSource("out")} aria-label="Mark source out (O)">
197227
Mark here
198228
</Button>
199229
)}

web/src/components/timeline/TimelineEditor.tsx

Lines changed: 39 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,8 @@ import { TimelineProvider } from "../../stores/timeline/TimelineInstance";
7676
import { VideoLandingStrip } from "../setup/video/VideoLandingStrip";
7777
import { useReattachSequenceJobs } from "../../hooks/timeline/useReattachSequenceJobs";
7878
import { PreviewArea } from "./preview/PreviewArea";
79+
import { SelectFieldDensityContext } from "../ui_primitives";
80+
import { TimelineInstrumentsPanel } from "./TimelineInstrumentsPanel";
7981
import { TimelineInspector } from "./Inspector/TimelineInspector";
8082
import { SourceViewerPanel } from "./SourceViewerPanel";
8183
import MovieFilterOutlinedIcon from "@mui/icons-material/MovieFilterOutlined";
@@ -125,7 +127,13 @@ const editorStyles = (theme: Theme) =>
125127
width: "100%",
126128
height: "100%",
127129
overflow: "hidden",
128-
backgroundColor: theme.vars.palette.background.default
130+
backgroundColor: theme.vars.palette.background.default,
131+
"&& .MuiOutlinedInput-root:not(.Mui-focused):not(.Mui-error)": {
132+
"& .MuiOutlinedInput-notchedOutline": { borderColor: "transparent" },
133+
"&:hover:not(.Mui-disabled) .MuiOutlinedInput-notchedOutline": {
134+
borderColor: theme.vars.palette.divider
135+
}
136+
}
129137
});
130138

131139
const middleAreaStyles = (theme: Theme) =>
@@ -157,7 +165,8 @@ const dragHandleStyles = (theme: Theme, tall: boolean) =>
157165
height: tall ? TOUCH_HANDLE_HEIGHT_PX : HANDLE_HEIGHT_PX,
158166
cursor: "ns-resize",
159167
flexShrink: 0,
160-
backgroundColor: theme.vars.palette.divider,
168+
backgroundColor: theme.vars.palette.background.default,
169+
boxShadow: `inset 0 1px ${theme.vars.palette.divider}`,
161170
transition: MOTION.background,
162171
outline: "none",
163172
// The handle is a drag target, not a scroll surface: without this a touch
@@ -299,10 +308,11 @@ const PreviewRegion: React.FC<{
299308
});
300309
PreviewRegion.displayName = "PreviewRegion";
301310

302-
type InspectorTab = "inspector" | "source" | "agent" | "history" | "script";
311+
type InspectorTab = "inspector" | "source" | "instrument" | "agent" | "history" | "script";
303312

304313
const INSPECTOR_TABS = [
305314
{ value: "inspector", label: "Inspector", icon: <TuneOutlinedIcon /> },
315+
{ value: "instrument", label: "Instruments", icon: <TuneOutlinedIcon /> },
306316
{ value: "source", label: "Source", icon: <MovieFilterOutlinedIcon /> },
307317
{ value: "agent", label: "Assistant", icon: <AutoAwesomeIcon /> },
308318
{ value: "history", label: "History", icon: <HistoryOutlinedIcon /> }
@@ -317,7 +327,8 @@ const SCRIPT_TAB = {
317327
const InspectorRegion: React.FC<{ sequenceId: string | undefined }> = memo(
318328
({ sequenceId }) => {
319329
const theme = useTheme();
320-
const [tab, setTab] = useState<InspectorTab>("inspector");
330+
const tab = useTimelineUIStore(s => s.panelTab);
331+
const setTab = useTimelineUIStore(s => s.setPanelTab);
321332

322333
const tabs = INSPECTOR_TABS;
323334

@@ -333,7 +344,6 @@ const InspectorRegion: React.FC<{ sequenceId: string | undefined }> = memo(
333344
value={tab}
334345
onChange={(value) => setTab(value as InspectorTab)}
335346
size="small"
336-
fullWidth
337347
sx={{
338348
flexShrink: 0,
339349
borderBottom: `1px solid ${theme.vars.palette.divider}`
@@ -342,6 +352,8 @@ const InspectorRegion: React.FC<{ sequenceId: string | undefined }> = memo(
342352
<FlexColumn fullWidth sx={{ flex: 1, minHeight: 0, overflow: "hidden" }}>
343353
{tab === "inspector" ? (
344354
<TimelineInspector />
355+
) : tab === "instrument" ? (
356+
<TimelineInstrumentsPanel />
345357
) : tab === "source" ? (
346358
<SourceViewerPanel />
347359
) : tab === "agent" ? (
@@ -406,7 +418,6 @@ const MobilePanelSheet: React.FC<{
406418
value={activeTab}
407419
onChange={(value) => onTabChange(value as InspectorTab)}
408420
size="small"
409-
fullWidth
410421
/>
411422
);
412423

@@ -425,6 +436,10 @@ const MobilePanelSheet: React.FC<{
425436
<FlexColumn fullWidth sx={{ height: "52vh", minHeight: 0 }}>
426437
{activeTab === "inspector" ? (
427438
<TimelineInspector />
439+
) : activeTab === "instrument" ? (
440+
<TimelineInstrumentsPanel />
441+
) : activeTab === "source" ? (
442+
<SourceViewerPanel />
428443
) : activeTab === "agent" ? (
429444
<TimelineAgentPanel />
430445
) : activeTab === "script" ? (
@@ -504,7 +519,9 @@ const TimelineEditorBody: React.FC<
504519

505520
// Phone panel sheet (Inspector / Assistant / History / Script).
506521
const [panelSheetOpen, setPanelSheetOpen] = useState(false);
507-
const [panelTab, setPanelTab] = useState<InspectorTab>("inspector");
522+
const panelTab = useTimelineUIStore(s => s.panelTab);
523+
const setPanelTab = useTimelineUIStore(s => s.setPanelTab);
524+
useEffect(() => { if (isMobile && panelTab === "instrument") setPanelSheetOpen(true); }, [isMobile, panelTab]);
508525
const openPanelSheet = useCallback(() => setPanelSheetOpen(true), []);
509526
const closePanelSheet = useCallback(() => setPanelSheetOpen(false), []);
510527
const hasSelection = useTimelineUIStore((s) => s.selectedClipIds.size > 0);
@@ -608,6 +625,13 @@ const TimelineEditorBody: React.FC<
608625

609626
// Tracks resize ─────────────────────────────────────────────────────────
610627
const [tracksHeight, setTracksHeight] = useState(DEFAULT_TRACKS_HEIGHT_PX);
628+
const expandedInstrumentTrackId = useTimelineUIStore((s) => s.expandedInstrumentTrackId);
629+
useEffect(() => {
630+
if (expandedInstrumentTrackId && !pianoRollOpen) setTracksHeight((height) => Math.max(height, 420));
631+
}, [expandedInstrumentTrackId, pianoRollOpen]);
632+
useEffect(() => {
633+
if (pianoRollOpen) setTracksHeight((height) => Math.min(height, DEFAULT_TRACKS_HEIGHT_PX));
634+
}, [pianoRollOpen]);
611635
const [isDragging, setIsDragging] = useState(false);
612636
const dragStartYRef = useRef(0);
613637
const dragStartHeightRef = useRef(DEFAULT_TRACKS_HEIGHT_PX);
@@ -801,6 +825,7 @@ const TimelineEditorBody: React.FC<
801825
);
802826

803827
return (
828+
<SelectFieldDensityContext.Provider value="compact">
804829
<FlexColumn fullWidth fullHeight css={editorStyles(theme)}>
805830
{/* ── Top bar ───────────────────────────────────────────────── */}
806831
<TopBar
@@ -827,7 +852,9 @@ const TimelineEditorBody: React.FC<
827852
onSelectFolder={(folderId) => void saveAsAsset(folderId, sequence?.name)}
828853
/>
829854

830-
{/* ── Middle: assets + preview + inspector ──────────────────── */}
855+
<FlexRow fullWidth sx={{ flex: 1, minHeight: 0, overflow: "hidden" }}>
856+
<FlexColumn sx={{ flex: 1, minWidth: 0, minHeight: 0 }}>
857+
{/* ── Middle: assets + preview ──────────────────────────────── */}
831858
{/* Basis 0 (not `auto`): the middle row absorbs all leftover height via
832859
* flex-grow, but its *content* never contributes to the column's size.
833860
* With `auto`, a tall inspector (clip selected) inflated this row's
@@ -850,7 +877,6 @@ const TimelineEditorBody: React.FC<
850877
createSequenceErrorMessage={createErrorMessage}
851878
fullWidth={isMobile}
852879
/>
853-
{!isMobile && <InspectorRegion sequenceId={sequenceId} />}
854880
</FlexRow>
855881

856882
{/* ── Horizontal drag handle (pointer + keyboard resizable) ─── */}
@@ -877,6 +903,9 @@ const TimelineEditorBody: React.FC<
877903

878904
{/* ── Clip editor (piano roll) ──────────────────────────────── */}
879905
<PianoRollPanel fullHeight={pianoRollFullScreen} />
906+
</FlexColumn>
907+
{!isMobile && <InspectorRegion sequenceId={sequenceId} />}
908+
</FlexRow>
880909

881910
{/* ── Bottom status bar ─────────────────────────────────────── */}
882911
<TimelineStatusBar
@@ -948,6 +977,7 @@ const TimelineEditorBody: React.FC<
948977
</Dialog>
949978

950979
</FlexColumn>
980+
</SelectFieldDensityContext.Provider>
951981
);
952982
});
953983

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
import React from "react";
2+
import { useTimelineStore } from "../../stores/timeline/TimelineStore";
3+
import { useTimelineUIStore } from "../../stores/timeline/TimelineUIStore";
4+
import { Caption, FlexColumn, SelectField, SPACING } from "../ui_primitives";
5+
import { TrackInstrumentPanel } from "./Tracks/TrackInstrumentPanel";
6+
7+
export function TimelineInstrumentsPanel() {
8+
const tracks = useTimelineStore(state => state.tracks);
9+
const clips = useTimelineStore(state => state.clips);
10+
const selected = useTimelineUIStore(state => state.selectedClipIds);
11+
const instrumentTrackId = useTimelineUIStore(state => state.expandedInstrumentTrackId);
12+
const openInstrument = useTimelineUIStore(state => state.toggleExpandedInstrument);
13+
const midiTracks = tracks.filter(track => track.type === "midi");
14+
const selectedTrack = clips.find(clip => selected.has(clip.id))?.trackId;
15+
const track = midiTracks.find(track => track.id === instrumentTrackId)
16+
?? midiTracks.find(track => track.id === selectedTrack) ?? midiTracks[0];
17+
if (!track) return <Caption sx={{ p: SPACING.md }}>Add a MIDI track to choose an instrument.</Caption>;
18+
return <FlexColumn fullHeight sx={{ minHeight: 0 }} data-testid="timeline-instruments-panel">
19+
<FlexColumn sx={{ p: SPACING.sm }}>
20+
<SelectField label="Instrument track" hideLabel size="small" value={track.id}
21+
options={midiTracks.map(track => ({value: track.id, label: track.name}))} onChange={openInstrument} />
22+
</FlexColumn>
23+
<TrackInstrumentPanel key={track.id} trackId={track.id} />
24+
</FlexColumn>;
25+
}

web/src/components/timeline/TimelineShortcutsDialog.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -102,6 +102,8 @@ const GROUPS: Group[] = [
102102
title: "Playback",
103103
rows: [
104104
{ keys: ["Space"], label: "Play / pause" },
105+
{ action: "stepFrameBack", label: "Step back one frame" },
106+
{ action: "stepFrameForward", label: "Step forward one frame" },
105107
{ action: "shuttleBack", label: "Shuttle backwards (again: faster)" },
106108
{ action: "shuttleStop", label: "Stop shuttle" },
107109
{ action: "shuttleForward", label: "Shuttle forwards (again: faster)" },

0 commit comments

Comments
 (0)