refactor(studio): code quality — 22 findings, dead code removal, App.tsx split (#144)

## Summary

Full code quality review of the studio package, fixing 22 of 25 findings. Removes dead code, extracts modules from App.tsx, fixes accessibility and performance issues.

## Critical fixes (3)

- **`aria-valuenow`** on seek bar now updates imperatively via `liveTime.subscribe` — screen readers previously always reported position 0
- **Speed menu** closes on outside click (was permanently stuck open)
- **RenderQueue auto-scroll** moved from render phase to `useEffect` (was violating React render purity via `queueMicrotask` during render)

## Dead code removed (-331 lines)

| File | Lines | Why dead |
|---|---|---|
| `PreviewPanel.tsx` | 180 | Replaced by NLELayout + NLEPreview |
| `useCodeEditor.ts` | 80 | Exported but never imported |
| `formatTick` alias | 2 | Deprecated, unused |
| `onClipChange` prop | 5 | Declared, never used |
| `trackH` prop | 5 | Declared, never used |
| `editRange*` + updaters in store | 60 | Never read or written |

## App.tsx extraction

| Extracted to | Lines | What |
|---|---|---|
| `components/LintModal.tsx` | 130 | Lint results modal + LintFinding type |
| `components/MediaPreview.tsx` | 75 | Image/video/audio/font file previewer |
| `utils/mediaTypes.ts` | 15 | Shared regex constants (App.tsx and AssetsTab.tsx had diverged copies) |

## Performance fixes

- `useMemo` for `compositions`/`assets` derivation from `fileTree`
- `useMemo` for `buildTree(files)` in FileTree
- Debounced `handleContentChange` PUT (600ms — was firing on every keystroke)
- CompositionsTab iframe hover debounced (300ms — was mounting immediately)
- `VideoFrameThumbnail` re-extracts frame when `src` prop changes

## Not addressed (3 — low priority)

- #6: SystemIcons consolidation (large refactor across many files)
- #16-17: Overlay dismiss pattern standardization
- #18: Inline SVG → Phosphor replacement (gradual, per-PR)

🤖 Generated with [Claude Code](https://claude.com/claude-code)
This commit is contained in:
Miguel Ángel
2026-03-31 04:21:12 +02:00
committed by GitHub
parent 1bc83c62a5
commit dac304ed9f
26 changed files with 475 additions and 753 deletions
@@ -1,18 +1,12 @@
/**
* CompositionThumbnail — Film-strip of server-rendered JPEG thumbnails.
* CompositionThumbnail — Single server-rendered JPEG stretched across the clip.
*
* Requests multiple thumbnails at different timestamps across the clip duration
* and tiles them horizontally — like VideoThumbnail does for video clips.
* Each frame is a separate <img> from /api/projects/:id/thumbnail/:path?t=X.
*
* Uses ResizeObserver to adapt frame count when the clip width changes (zoom).
* Takes one screenshot at the midpoint of the clip and covers the full width —
* same approach as After Effects for precomps. This avoids the 1-2s per-frame
* Puppeteer cost of rendering multiple filmstrip frames.
*/
import { memo, useRef, useState, useCallback } from "react";
import { useMountEffect } from "../../hooks/useMountEffect";
const CLIP_HEIGHT = 66;
const MAX_UNIQUE_FRAMES = 6;
import { memo } from "react";
interface CompositionThumbnailProps {
previewUrl: string;
@@ -30,95 +24,27 @@ export const CompositionThumbnail = memo(function CompositionThumbnail({
labelColor,
seekTime = 2,
duration = 5,
width = 1920,
height = 1080,
}: CompositionThumbnailProps) {
const [containerWidth, setContainerWidth] = useState(0);
const roRef = useRef<ResizeObserver | null>(null);
const setRef = useCallback((el: HTMLDivElement | null) => {
roRef.current?.disconnect();
if (!el) return;
// Walk up to data-clip parent for accurate width
let target: HTMLElement = el;
let parent = el.parentElement;
let depth = 0;
while (parent && !parent.hasAttribute("data-clip") && depth < 5) {
parent = parent.parentElement;
depth++;
}
if (parent?.hasAttribute("data-clip")) target = parent;
requestAnimationFrame(() => {
const w = target.clientWidth || target.getBoundingClientRect().width;
if (w > 0) setContainerWidth(w);
});
roRef.current = new ResizeObserver(([entry]) => setContainerWidth(entry.contentRect.width));
roRef.current.observe(target);
}, []);
useMountEffect(() => () => {
roRef.current?.disconnect();
});
// Convert preview URL to thumbnail base URL
// Single screenshot at the midpoint of the clip
const thumbnailBase = previewUrl
.replace("/preview/comp/", "/thumbnail/")
.replace(/\/preview$/, "/thumbnail/index.html");
// Calculate frame layout
const aspect = width / height;
const frameW = Math.round(CLIP_HEIGHT * aspect);
const frameCount = containerWidth > 0 ? Math.max(1, Math.ceil(containerWidth / frameW)) : 1;
const uniqueFrames = Math.min(frameCount, MAX_UNIQUE_FRAMES);
// Each frame tile represents a real position in the clip.
// Offset slightly (0.5s) into each segment to avoid landing on transition
// points where content is invisible due to fade-in/fade-out animations.
const timestamps: number[] = [];
const pad = Math.min(0.5, duration * 0.05);
for (let i = 0; i < uniqueFrames; i++) {
const frac = uniqueFrames === 1 ? 0.5 : i / (uniqueFrames - 1);
const raw = seekTime + frac * duration;
// Clamp to [pad, duration - pad] to stay inside visible content
timestamps.push(seekTime + Math.max(pad, Math.min(duration - pad, raw - seekTime)));
}
const midTime = seekTime + duration / 2;
const url = `${thumbnailBase}?t=${midTime.toFixed(2)}`;
return (
<div ref={setRef} className="absolute inset-0 overflow-hidden bg-neutral-950">
{/* Film strip — each tile maps to its real timeline position */}
<div className="absolute inset-0 flex">
{Array.from({ length: frameCount }).map((_, i) => {
// Map this tile's visual position to a timestamp
const tileFrac = frameCount === 1 ? 0.5 : i / (frameCount - 1);
const t = seekTime + tileFrac * duration;
// Use the nearest cached unique frame
const uniqueIdx = Math.min(Math.round(tileFrac * (uniqueFrames - 1)), uniqueFrames - 1);
const cachedT = timestamps[uniqueIdx];
const url = `${thumbnailBase}?t=${(cachedT ?? t).toFixed(2)}`;
return (
<div
key={i}
className="flex-shrink-0 h-full relative overflow-hidden bg-neutral-900"
style={{ width: frameW }}
>
<img
src={url}
alt=""
draggable={false}
loading="lazy"
onLoad={(e) => {
(e.target as HTMLImageElement).style.opacity = "1";
}}
className="absolute inset-0 w-full h-full object-contain"
style={{ opacity: 0, transition: "opacity 200ms ease-out" }}
/>
</div>
);
})}
</div>
<div className="absolute inset-0 overflow-hidden bg-neutral-950">
<img
src={url}
alt=""
draggable={false}
loading="lazy"
onLoad={(e) => {
(e.target as HTMLImageElement).style.opacity = "1";
}}
className="absolute inset-0 w-full h-full object-cover"
style={{ opacity: 0, transition: "opacity 200ms ease-out" }}
/>
{/* Label */}
<div
@@ -105,7 +105,7 @@ Preserve all other elements and timing outside this range.`;
{/* Header */}
<div className="flex items-center justify-between px-4 py-2.5 border-b border-neutral-800/60">
<div className="flex items-center gap-2">
<div className="w-1.5 h-1.5 rounded-full bg-blue-400" />
<div className="w-1.5 h-1.5 rounded-full bg-studio-accent" />
<span className="text-[11px] font-medium text-neutral-300">
{formatTime(start)} {formatTime(end)}
</span>
@@ -120,7 +120,7 @@ Preserve all other elements and timing outside this range.`;
<div className="px-4 py-2 border-b border-neutral-800/40 max-h-24 overflow-y-auto">
{elementsInRange.map((el) => (
<div key={el.id} className="flex items-center justify-between py-0.5">
<span className="text-[10px] font-mono text-blue-400/80">#{el.id}</span>
<span className="text-[10px] font-mono text-studio-accent/80">#{el.id}</span>
<span className="text-[10px] text-neutral-600">{el.tag}</span>
</div>
))}
@@ -141,7 +141,7 @@ Preserve all other elements and timing outside this range.`;
}}
placeholder="What should change?"
rows={2}
className="w-full px-3 py-2 text-xs bg-neutral-800/60 border border-neutral-700/40 rounded-lg text-neutral-200 placeholder:text-neutral-600 resize-none focus:outline-none focus:border-blue-500/40 transition-colors"
className="w-full px-3 py-2 text-xs bg-neutral-800/60 border border-neutral-700/40 rounded-lg text-neutral-200 placeholder:text-neutral-600 resize-none focus:outline-none focus:border-studio-accent/40 transition-colors"
/>
</div>
@@ -152,11 +152,11 @@ Preserve all other elements and timing outside this range.`;
className={`w-full py-1.5 text-[11px] font-medium rounded-lg transition-all ${
copied
? "bg-green-500/20 text-green-400 border border-green-500/30"
: "bg-blue-500/15 text-blue-400 border border-blue-500/25 hover:bg-blue-500/25"
: "bg-studio-accent/15 text-studio-accent border border-studio-accent/25 hover:bg-studio-accent/25"
}`}
>
{copied ? "Copied!" : "Copy to Agent"}
{!copied && <span className="text-[9px] text-blue-400/50 ml-1.5">Cmd+Enter</span>}
{!copied && <span className="text-[9px] text-studio-accent/50 ml-1.5">Cmd+Enter</span>}
</button>
</div>
</div>
@@ -1,4 +1,4 @@
import { useRef, useState, useCallback, memo } from "react";
import { useRef, useState, useCallback, useEffect, memo } from "react";
import { useMountEffect } from "../../hooks/useMountEffect";
import { formatTime } from "../lib/time";
import { usePlayerStore, liveTime } from "../store/playerStore";
@@ -30,6 +30,8 @@ export const PlayerControls = memo(function PlayerControls({
const progressThumbRef = useRef<HTMLDivElement>(null);
const timeDisplayRef = useRef<HTMLSpanElement>(null);
const seekBarRef = useRef<HTMLDivElement>(null);
const sliderRef = useRef<HTMLDivElement>(null);
const speedMenuContainerRef = useRef<HTMLDivElement>(null);
const isDraggingRef = useRef(false);
const currentTimeRef = useRef(0);
@@ -43,6 +45,7 @@ export const PlayerControls = memo(function PlayerControls({
if (progressFillRef.current) progressFillRef.current.style.width = `${pct}%`;
if (progressThumbRef.current) progressThumbRef.current.style.left = `${pct}%`;
if (timeDisplayRef.current) timeDisplayRef.current.textContent = formatTime(t);
if (sliderRef.current) sliderRef.current.setAttribute("aria-valuenow", String(Math.round(t)));
};
const unsub = liveTime.subscribe(updateProgress);
updateProgress(usePlayerStore.getState().currentTime);
@@ -64,6 +67,22 @@ export const PlayerControls = memo(function PlayerControls({
};
});
useEffect(() => {
if (!showSpeedMenu) return;
const handleMouseDown = (e: MouseEvent) => {
if (
speedMenuContainerRef.current &&
!speedMenuContainerRef.current.contains(e.target as Node)
) {
setShowSpeedMenu(false);
}
};
document.addEventListener("mousedown", handleMouseDown);
return () => {
document.removeEventListener("mousedown", handleMouseDown);
};
}, [showSpeedMenu]);
const seekFromClientX = useCallback(
(clientX: number) => {
const bar = seekBarRef.current;
@@ -153,7 +172,10 @@ export const PlayerControls = memo(function PlayerControls({
{/* Seek bar — teal progress fill */}
<div
ref={seekBarRef}
ref={(el) => {
(seekBarRef as React.MutableRefObject<HTMLDivElement | null>).current = el;
(sliderRef as React.MutableRefObject<HTMLDivElement | null>).current = el;
}}
role="slider"
tabIndex={0}
aria-label="Seek"
@@ -188,7 +210,7 @@ export const PlayerControls = memo(function PlayerControls({
</div>
{/* Speed control */}
<div className="relative flex-shrink-0">
<div ref={speedMenuContainerRef} className="relative flex-shrink-0">
<button
type="button"
onClick={() => setShowSpeedMenu((v) => !v)}
@@ -235,7 +257,7 @@ export const PlayerControls = memo(function PlayerControls({
onClick={onToggleTimeline}
className={`w-7 h-7 flex items-center justify-center rounded-md border transition-colors ${
timelineVisible
? "text-[#3CE6AC] bg-[#3CE6AC]/10 border-[#3CE6AC]/30"
? "text-studio-accent bg-studio-accent/10 border-studio-accent/30"
: "border-neutral-700 text-neutral-500 hover:text-neutral-300 hover:bg-neutral-800"
}`}
title={timelineVisible ? "Hide timeline" : "Show timeline"}
@@ -1,181 +0,0 @@
import type { ReactNode, Ref } from "react";
import { Player } from "./Player";
import { PlayerControls } from "./PlayerControls";
import { Timeline } from "./Timeline";
interface RenderStatus {
state: "idle" | "rendering" | "complete" | "error";
stage?: string;
progress?: number;
error?: string;
onRender?: () => void;
}
interface PreviewPanelProps {
projectId: string | null;
hasProject: boolean;
portrait: boolean;
iframeRef: Ref<HTMLIFrameElement>;
onIframeLoad: () => void;
onTogglePlay: () => void;
onSeek: (t: number) => void;
/** Optional render status — pass to show rendering progress/state */
renderStatus?: RenderStatus;
/** Optional slot for custom content below the timeline */
children?: ReactNode;
}
export function PreviewPanel({
projectId,
hasProject,
portrait,
iframeRef,
onIframeLoad,
onTogglePlay,
onSeek,
renderStatus,
children,
}: PreviewPanelProps) {
const renderState = renderStatus?.state ?? "idle";
return (
<div
className="min-w-0 overflow-hidden"
style={{
display: "grid",
gridTemplateRows: hasProject && projectId ? "1fr auto auto auto" : "1fr",
height: "100%",
minHeight: 0,
}}
>
{hasProject && projectId ? (
<>
{/* Player — takes all remaining space, constrained for portrait */}
<div
className="flex items-center justify-center p-2 overflow-hidden"
style={{ minHeight: 0, minWidth: 0 }}
>
<Player
ref={iframeRef}
projectId={projectId}
onLoad={onIframeLoad}
portrait={portrait}
/>
</div>
{/* Controls — fixed height */}
<div className="bg-neutral-950 border-t border-neutral-800 flex-shrink-0">
<PlayerControls onTogglePlay={onTogglePlay} onSeek={onSeek} />
</div>
{/* Timeline — capped height, internal scroll */}
<div
className="bg-neutral-950 flex-shrink-0 overflow-y-auto"
style={{ maxHeight: "100px" }}
>
<Timeline onSeek={onSeek} />
</div>
{/* Render status — only shown when actively rendering, complete, or error */}
{renderStatus &&
(renderState === "rendering" ||
renderState === "complete" ||
renderState === "error") && (
<div className="bg-neutral-950 border-t border-neutral-800 px-4 py-2 flex items-center justify-end gap-2 flex-shrink-0">
{renderState === "rendering" && (
<div className="flex-1">
<div className="flex items-center gap-2">
<div className="flex-1 h-1.5 bg-neutral-800 rounded-full overflow-hidden">
<div
className="h-full bg-blue-500 rounded-full transition-[width] duration-200"
style={{ width: `${renderStatus.progress ?? 0}%` }}
/>
</div>
<span className="text-xs text-neutral-400 flex-shrink-0">
{renderStatus.stage || "Rendering..."}
</span>
</div>
</div>
)}
{renderState === "complete" && (
<div className="flex items-center gap-1.5 text-xs text-green-400">
<svg
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
>
<path d="M22 11.08V12a10 10 0 1 1-5.93-9.14" />
<polyline points="22 4 12 14.01 9 11.01" />
</svg>
<span>Complete</span>
</div>
)}
{renderState === "error" && (
<div className="flex items-center gap-2 text-xs text-red-400">
<svg
width="12"
height="12"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
>
<circle cx="12" cy="12" r="10" />
<line x1="12" y1="8" x2="12" y2="12" />
<line x1="12" y1="16" x2="12.01" y2="16" />
</svg>
<span className="truncate">{renderStatus.error}</span>
{renderStatus.onRender && (
<button
type="button"
onClick={renderStatus.onRender}
className="flex-shrink-0 px-2 py-0.5 text-xs text-neutral-300 hover:text-white hover:bg-neutral-800 rounded transition-colors"
>
Retry
</button>
)}
</div>
)}
</div>
)}
{/* Optional custom slot */}
{children}
</>
) : (
<div className="flex items-center justify-center w-full min-w-0">
<div className="text-center w-full">
<div className="w-16 h-16 mx-auto mb-4 rounded-card bg-neutral-900 flex items-center justify-center">
<svg
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
className="text-neutral-600"
>
<polygon points="5 3 19 12 5 21 5 3" />
</svg>
</div>
<p className="text-sm text-neutral-600">Preview will appear here</p>
<p className="text-xs text-neutral-700 mt-1">
Send a message to generate a video composition
</p>
</div>
</div>
)}
</div>
);
}
@@ -152,9 +152,6 @@ export function generateTicks(duration: number): { major: number[]; minor: numbe
return { major, minor };
}
/** @deprecated Use formatTime from '../lib/time' instead */
export const formatTick = formatTime;
/* ── Component ──────────────────────────────────────────────────── */
interface TimelineProps {
/** Called when user seeks via ruler/track click or playhead drag */
@@ -170,11 +167,6 @@ interface TimelineProps {
renderClipOverlay?: (element: import("../store/playerStore").TimelineElement) => ReactNode;
/** Called when files are dropped onto the empty timeline */
onFileDrop?: (files: File[]) => void;
/** Called when a clip is moved, resized, or changes track via drag */
onClipChange?: (
elementId: string,
updates: { start?: number; duration?: number; track?: number },
) => void;
}
export const Timeline = memo(function Timeline({
@@ -346,12 +338,11 @@ export const Timeline = memo(function Timeline({
const handlePointerDown = useCallback(
(e: React.PointerEvent) => {
if ((e.target as HTMLElement).closest("[data-clip]")) return;
if (e.button !== 0) return;
(e.currentTarget as HTMLElement).setPointerCapture(e.pointerId);
// Shift+click starts range selection
// Shift+click starts range selection — even on clips
if (e.shiftKey) {
(e.currentTarget as HTMLElement).setPointerCapture(e.pointerId);
isRangeSelecting.current = true;
setShowPopover(false);
const rect = scrollRef.current?.getBoundingClientRect();
@@ -364,6 +355,10 @@ export const Timeline = memo(function Timeline({
return;
}
// Normal click on a clip — let the clip handle it
if ((e.target as HTMLElement).closest("[data-clip]")) return;
(e.currentTarget as HTMLElement).setPointerCapture(e.pointerId);
isDragging.current = true;
setRangeSelection(null);
setShowPopover(false);
@@ -434,7 +429,7 @@ export const Timeline = memo(function Timeline({
return (
<div
className={`h-full border-t bg-[#0a0a0b] flex flex-col select-none transition-colors duration-150 ${
isDragOver ? "border-blue-500/50 bg-blue-500/[0.03]" : "border-neutral-800/50"
isDragOver ? "border-studio-accent/50 bg-studio-accent/[0.03]" : "border-neutral-800/50"
}`}
onDragOver={(e) => {
e.preventDefault();
@@ -471,7 +466,9 @@ export const Timeline = memo(function Timeline({
<div className="flex-1 flex items-center justify-center">
<div
className={`flex items-center gap-3 px-6 py-3 border border-dashed rounded-lg transition-colors duration-150 ${
isDragOver ? "border-blue-400/60 bg-blue-500/[0.06]" : "border-neutral-700/50"
isDragOver
? "border-studio-accent/60 bg-studio-accent/[0.06]"
: "border-neutral-700/50"
}`}
>
{isDragOver ? (
@@ -485,13 +482,13 @@ export const Timeline = memo(function Timeline({
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
className="text-blue-400 flex-shrink-0"
className="text-studio-accent flex-shrink-0"
>
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
<polyline points="7 10 12 15 17 10" />
<line x1="12" y1="15" x2="12" y2="3" />
</svg>
<span className="text-[13px] text-blue-400">Drop media files to import</span>
<span className="text-[13px] text-studio-accent">Drop media files to import</span>
</>
) : (
<>
@@ -573,7 +570,7 @@ export const Timeline = memo(function Timeline({
{/* Shift hint */}
{shiftHeld && !rangeSelection && (
<div className="absolute inset-0 flex items-center justify-center pointer-events-none z-10">
<span className="text-[9px] text-blue-400/60 font-medium">
<span className="text-[9px] text-studio-accent/60 font-medium">
Drag to select range
</span>
</div>
@@ -642,7 +639,6 @@ export const Timeline = memo(function Timeline({
key={clipKey}
el={el}
pps={pps}
trackH={TRACK_H}
clipY={CLIP_Y}
isSelected={isSelected}
isHovered={isHovered}
@@ -6,7 +6,6 @@ import type { TimelineElement } from "../store/playerStore";
interface TimelineClipProps {
el: TimelineElement;
pps: number;
trackH: number;
clipY: number;
isSelected: boolean;
isHovered: boolean;
-1
View File
@@ -2,7 +2,6 @@
export { Player } from "./components/Player";
export { PlayerControls } from "./components/PlayerControls";
export { Timeline } from "./components/Timeline";
export { PreviewPanel } from "./components/PreviewPanel";
export { VideoThumbnail } from "./components/VideoThumbnail";
export { CompositionThumbnail } from "./components/CompositionThumbnail";
@@ -27,10 +27,6 @@ interface PlayerState {
zoomMode: ZoomMode;
/** Pixels per second when in manual zoom mode */
pixelsPerSecond: number;
/** Edit range selection */
editRangeStart: number | null;
editRangeEnd: number | null;
editMode: boolean;
setIsPlaying: (playing: boolean) => void;
setCurrentTime: (time: number) => void;
@@ -39,11 +35,6 @@ interface PlayerState {
setTimelineReady: (ready: boolean) => void;
setElements: (elements: TimelineElement[]) => void;
setSelectedElementId: (id: string | null) => void;
setEditRange: (start: number | null, end: number | null) => void;
setEditMode: (active: boolean) => void;
updateElementStart: (elementId: string, newStart: number) => void;
updateElementDuration: (elementId: string, newDuration: number) => void;
updateElementTrack: (elementId: string, newTrack: number) => void;
updateElement: (
elementId: string,
updates: Partial<Pick<TimelineElement, "start" | "duration" | "track">>,
@@ -76,9 +67,6 @@ export const usePlayerStore = create<PlayerState>((set) => ({
playbackRate: 1,
zoomMode: "fit",
pixelsPerSecond: 100,
editRangeStart: null,
editRangeEnd: null,
editMode: false,
setIsPlaying: (playing) => set({ isPlaying: playing }),
setPlaybackRate: (rate) => set({ playbackRate: rate }),
@@ -89,26 +77,13 @@ export const usePlayerStore = create<PlayerState>((set) => ({
setTimelineReady: (ready) => set({ timelineReady: ready }),
setElements: (elements) => set({ elements }),
setSelectedElementId: (id) => set({ selectedElementId: id }),
setEditRange: (start, end) => set({ editRangeStart: start, editRangeEnd: end }),
setEditMode: (active) => set({ editMode: active, editRangeStart: null, editRangeEnd: null }),
updateElementStart: (elementId, newStart) =>
set((state) => ({
elements: state.elements.map((el) => (el.id === elementId ? { ...el, start: newStart } : el)),
})),
updateElementDuration: (elementId, newDuration) =>
set((state) => ({
elements: state.elements.map((el) =>
el.id === elementId ? { ...el, duration: newDuration } : el,
),
})),
updateElementTrack: (elementId, newTrack) =>
set((state) => ({
elements: state.elements.map((el) => (el.id === elementId ? { ...el, track: newTrack } : el)),
})),
updateElement: (elementId, updates) =>
set((state) => ({
elements: state.elements.map((el) => (el.id === elementId ? { ...el, ...updates } : el)),
})),
// Resets project-specific state when switching compositions.
// playbackRate, zoomMode, and pixelsPerSecond are intentionally preserved
// because they are user preferences that should survive project switches.
reset: () =>
set({
isPlaying: false,