feat(studio): timeline clip thumbnails off by default with a toolbar toggle

Background thumbnail generation (per-clip composition/video frame capture)
lags timeline scrubbing on long videos (#2428). Adds a persisted
thumbnailsEnabled setting, default OFF, so the timeline is snappy out of
the box; a toggle next to Fit in the zoom toolbar turns thumbnails on.
When off, clips render as plain bars (audio still shows its waveform).
This commit is contained in:
Miguel Angel Simon Sierra
2026-07-16 11:24:52 -04:00
parent 7e41582a20
commit e8be020b5d
4 changed files with 40 additions and 2 deletions
@@ -1,5 +1,5 @@
import { useEffect, useRef } from "react";
import { Magnet, MagnifyingGlassMinus, MagnifyingGlassPlus } from "@phosphor-icons/react";
import { Image, Magnet, MagnifyingGlassMinus, MagnifyingGlassPlus } from "@phosphor-icons/react";
import {
useEnableKeyframes,
isPlayheadWithinTween,
@@ -80,6 +80,8 @@ export function TimelineToolbar({ domEditSession, onSplitElement }: TimelineTool
const setTimelineSnapEnabled = usePlayerStore((s) => s.setTimelineSnapEnabled);
const autoKeyframeEnabled = usePlayerStore((s) => s.autoKeyframeEnabled);
const setAutoKeyframeEnabled = usePlayerStore((s) => s.setAutoKeyframeEnabled);
const thumbnailsEnabled = usePlayerStore((s) => s.thumbnailsEnabled);
const setThumbnailsEnabled = usePlayerStore((s) => s.setThumbnailsEnabled);
// Subscribe so the add-beat button reacts to playhead movement and analysis load.
const currentTime = usePlayerStore((s) => s.currentTime);
const beatAnalysisReady = usePlayerStore((s) => s.beatAnalysis !== null);
@@ -357,6 +359,21 @@ export function TimelineToolbar({ domEditSession, onSplitElement }: TimelineTool
})()}
</div>
<div className="flex items-center gap-0.5">
<Tooltip label={thumbnailsEnabled ? "Hide clip thumbnails" : "Show clip thumbnails"}>
<button
type="button"
aria-label={thumbnailsEnabled ? "Hide clip thumbnails" : "Show clip thumbnails"}
aria-pressed={thumbnailsEnabled}
onClick={() => setThumbnailsEnabled(!thumbnailsEnabled)}
className={`h-7 px-2 rounded-md text-[11px] font-medium transition-colors ${
thumbnailsEnabled
? "bg-studio-accent/10 text-studio-accent"
: "text-neutral-400 hover:bg-white/[0.06] hover:text-neutral-200"
}`}
>
<Image size={16} aria-hidden="true" />
</button>
</Tooltip>
<Tooltip label="Fit timeline to width">
<button
type="button"
@@ -5,6 +5,7 @@ import type { TimelineElement } from "../player";
import { AudioWaveform } from "../player/components/AudioWaveform";
import { ImageThumbnail } from "../player/components/ImageThumbnail";
import { encodePreviewPath, resolveMediaPreviewUrl } from "../player/components/thumbnailUtils";
import { usePlayerStore } from "../player/store/playerStore";
export function normalizeCompositionSrc(
compSrc: string,
@@ -87,6 +88,9 @@ export function useRenderClipContent({
activePreviewUrl,
effectiveTimelineDuration,
}: UseRenderClipContentOptions) {
// Self-sourced (not threaded via App) so the toggle gates generation without
// App.tsx plumbing. Off by default -> plain clip bars, snappy timeline (#2428).
const thumbnailsEnabled = usePlayerStore((s) => s.thumbnailsEnabled);
return useCallback(
// Pre-existing clip-content dispatcher; reduced by extracting renderAudioClip.
// fallow-ignore-next-line complexity
@@ -94,6 +98,12 @@ export function useRenderClipContent({
const pid = projectIdRef.current;
if (!pid) return null;
// Thumbnail generation disabled (perf) -> plain clip bars. Audio still shows
// its waveform (cheap, not a frame thumbnail). Toggle: timeline toolbar.
if (!thumbnailsEnabled) {
return el.tag === "audio" ? renderAudioClip(el, pid, style.label) : null;
}
let compSrc = el.compositionSrc;
if (compSrc) {
compSrc = normalizeCompositionSrc(compSrc, pid, window.location.origin);
@@ -182,6 +192,6 @@ export function useRenderClipContent({
return null;
},
[projectIdRef, compIdToSrc, activePreviewUrl, effectiveTimelineDuration],
[projectIdRef, compIdToSrc, activePreviewUrl, effectiveTimelineDuration, thumbnailsEnabled],
);
}
@@ -109,6 +109,7 @@ interface PlayerState extends KeyframeSlice {
loopEnabled: boolean;
/** Timeline zoom: 'fit' auto-scales to viewport, 'manual' uses manualZoomPercent */
zoomMode: ZoomMode;
thumbnailsEnabled: boolean;
/** Timeline zoom percent relative to the fit width when in manual mode */
manualZoomPercent: number;
/**
@@ -196,6 +197,7 @@ interface PlayerState extends KeyframeSlice {
>,
) => void;
setZoomMode: (mode: ZoomMode) => void;
setThumbnailsEnabled: (enabled: boolean) => void;
setManualZoomPercent: (percent: number) => void;
bumpZEditVersion: () => void;
setInPoint: (time: number | null) => void;
@@ -294,6 +296,7 @@ export const usePlayerStore = create<PlayerState>((set, get) => ({
audioMuted: readStudioUiPreferences().audioMuted ?? false,
loopEnabled: false,
zoomMode: "fit",
thumbnailsEnabled: readStudioUiPreferences().thumbnailsEnabled ?? false,
manualZoomPercent: 100,
zEditVersion: 0,
timelinePps: 100,
@@ -409,6 +412,10 @@ export const usePlayerStore = create<PlayerState>((set, get) => ({
writeStudioUiPreferences({ audioMuted: muted });
set({ audioMuted: muted });
},
setThumbnailsEnabled: (enabled) => {
writeStudioUiPreferences({ thumbnailsEnabled: enabled });
set({ thumbnailsEnabled: enabled });
},
setLoopEnabled: (enabled) => set({ loopEnabled: enabled }),
setZoomMode: (mode) => set({ zoomMode: mode }),
clearSelectedElementIds: () => set({ selectedElementIds: new Set() }),
@@ -10,6 +10,7 @@ export interface StudioUiPreferences {
timelineHeight?: number;
playbackRate?: number;
audioMuted?: boolean;
thumbnailsEnabled?: boolean;
previewZoom?: StoredPreviewZoomState;
recentBlocks?: string[];
snapEnabled?: boolean;
@@ -70,6 +71,9 @@ function readStorage(storage: Storage | null): StudioUiPreferences {
if (typeof parsed.audioMuted === "boolean") {
preferences.audioMuted = parsed.audioMuted;
}
if (typeof parsed.thumbnailsEnabled === "boolean") {
preferences.thumbnailsEnabled = parsed.thumbnailsEnabled;
}
if (isRecord(parsed.previewZoom)) {
const { zoomPercent, panX, panY } = parsed.previewZoom;
if (