feat(studio): default to adaptive thumbnails

This commit is contained in:
Miguel Angel Simon Sierra
2026-07-19 00:53:20 +02:00
parent 16b7c0be4d
commit 84c58a314f
7 changed files with 70 additions and 23 deletions
@@ -10,7 +10,7 @@ import { TimelineToolbar } from "./TimelineToolbar";
afterEach(() => {
document.body.innerHTML = "";
usePlayerStore.setState({ autoKeyframeEnabled: true });
usePlayerStore.setState({ autoKeyframeEnabled: true, thumbnailMode: "adaptive" });
});
function renderToolbar() {
@@ -54,3 +54,21 @@ describe("TimelineToolbar — auto-keyframe toggle (#1808)", () => {
act(() => root.unmount());
});
});
describe("TimelineToolbar — adaptive thumbnails", () => {
it("keeps a user-controlled hidden mode as the rollback path", () => {
const { host, root } = renderToolbar();
const button = host.querySelector<HTMLButtonElement>(
'button[aria-label="Hide thumbnails — labels only"]',
);
if (!button) throw new Error("thumbnail toggle not rendered");
act(() => button.click());
expect(usePlayerStore.getState().thumbnailMode).toBe("hidden");
expect(button.getAttribute("aria-label")).toBe(
"Show thumbnails — posters stay visible; richer previews appear on interaction",
);
act(() => root.unmount());
});
});
@@ -80,8 +80,9 @@ 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);
const thumbnailMode = usePlayerStore((s) => s.thumbnailMode);
const setThumbnailMode = usePlayerStore((s) => s.setThumbnailMode);
const thumbnailsVisible = thumbnailMode === "adaptive";
// 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);
@@ -359,14 +360,24 @@ export function TimelineToolbar({ domEditSession, onSplitElement }: TimelineTool
})()}
</div>
<div className="flex items-center gap-0.5">
<Tooltip label={thumbnailsEnabled ? "Hide clip thumbnails" : "Show clip thumbnails"}>
<Tooltip
label={
thumbnailsVisible
? "Hide thumbnails — labels only"
: "Show thumbnails — posters stay visible; richer previews appear on interaction"
}
>
<button
type="button"
aria-label={thumbnailsEnabled ? "Hide clip thumbnails" : "Show clip thumbnails"}
aria-pressed={thumbnailsEnabled}
onClick={() => setThumbnailsEnabled(!thumbnailsEnabled)}
aria-label={
thumbnailsVisible
? "Hide thumbnails — labels only"
: "Show thumbnails — posters stay visible; richer previews appear on interaction"
}
aria-pressed={thumbnailsVisible}
onClick={() => setThumbnailMode(thumbnailsVisible ? "hidden" : "adaptive")}
className={`h-7 px-2 rounded-md text-[11px] font-medium transition-colors ${
thumbnailsEnabled
thumbnailsVisible
? "bg-studio-accent/10 text-studio-accent"
: "text-neutral-400 hover:bg-white/[0.06] hover:text-neutral-200"
}`}
@@ -12,7 +12,7 @@ import { useRenderClipContent } from "./useRenderClipContent";
(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
afterEach(() => {
usePlayerStore.setState({ thumbnailsEnabled: false });
usePlayerStore.setState({ thumbnailMode: "hidden" });
document.body.innerHTML = "";
});
@@ -107,7 +107,7 @@ describe("useRenderClipContent", () => {
});
it("passes empty labels to thumbnail content so TimelineClip owns clip names", () => {
usePlayerStore.setState({ thumbnailsEnabled: true });
usePlayerStore.setState({ thumbnailMode: "adaptive" });
const cases: Array<{ content: ReactNode; type: unknown }> = [
{
@@ -6,6 +6,7 @@ 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";
import { effectiveThumbnailMode } from "../player/lib/thumbnailPolicy";
export function normalizeCompositionSrc(
compSrc: string,
@@ -90,7 +91,8 @@ export function useRenderClipContent({
}: 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);
const thumbnailMode = usePlayerStore((s) => s.thumbnailMode);
const effectiveMode = effectiveThumbnailMode(thumbnailMode);
return useCallback(
// Pre-existing clip-content dispatcher; reduced by extracting renderAudioClip.
// fallow-ignore-next-line complexity
@@ -100,7 +102,7 @@ export function useRenderClipContent({
// Thumbnail generation disabled (perf) -> plain clip bars. Audio still shows
// its waveform (cheap, not a frame thumbnail). Toggle: timeline toolbar.
if (!thumbnailsEnabled) {
if (effectiveMode === "hidden") {
return el.tag === "audio" ? renderAudioClip(el, pid, style.label) : null;
}
@@ -192,6 +194,6 @@ export function useRenderClipContent({
return null;
},
[projectIdRef, compIdToSrc, activePreviewUrl, effectiveTimelineDuration, thumbnailsEnabled],
[projectIdRef, compIdToSrc, activePreviewUrl, effectiveTimelineDuration, effectiveMode],
);
}
@@ -6,6 +6,7 @@ import { readStudioUiPreferences, writeStudioUiPreferences } from "../../utils/s
import { computePinnedZoomPercent } from "../components/timelineZoom";
import { createKeyframeSlice, type KeyframeSlice } from "./keyframeSlice";
import { createTimelineFocusRequest, type TimelineFocusRequest } from "./timelineFocusState";
import { defaultThumbnailMode, type ThumbnailMode } from "../lib/thumbnailPolicy";
export type { KeyframeCacheEntry } from "./keyframeSlice";
@@ -114,7 +115,7 @@ interface PlayerState extends KeyframeSlice {
loopEnabled: boolean;
/** Timeline zoom: 'fit' auto-scales to viewport, 'manual' uses manualZoomPercent */
zoomMode: ZoomMode;
thumbnailsEnabled: boolean;
thumbnailMode: ThumbnailMode;
/** Timeline zoom percent relative to the fit width when in manual mode */
manualZoomPercent: number;
/**
@@ -202,7 +203,7 @@ interface PlayerState extends KeyframeSlice {
>,
) => void;
setZoomMode: (mode: ZoomMode) => void;
setThumbnailsEnabled: (enabled: boolean) => void;
setThumbnailMode: (mode: ThumbnailMode) => void;
setManualZoomPercent: (percent: number) => void;
bumpZEditVersion: () => void;
setInPoint: (time: number | null) => void;
@@ -329,7 +330,7 @@ export const usePlayerStore = create<PlayerState>((set, get) => ({
audioMuted: readStudioUiPreferences().audioMuted ?? false,
loopEnabled: false,
zoomMode: "fit",
thumbnailsEnabled: readStudioUiPreferences().thumbnailsEnabled ?? false,
thumbnailMode: defaultThumbnailMode(readStudioUiPreferences().thumbnailMode),
manualZoomPercent: 100,
zEditVersion: 0,
timelinePps: 100,
@@ -456,9 +457,9 @@ export const usePlayerStore = create<PlayerState>((set, get) => ({
writeStudioUiPreferences({ audioMuted: muted });
set({ audioMuted: muted });
},
setThumbnailsEnabled: (enabled) => {
writeStudioUiPreferences({ thumbnailsEnabled: enabled });
set({ thumbnailsEnabled: enabled });
setThumbnailMode: (mode) => {
writeStudioUiPreferences({ thumbnailMode: mode });
set({ thumbnailMode: mode });
},
setLoopEnabled: (enabled) => set({ loopEnabled: enabled }),
setZoomMode: (mode) => set({ zoomMode: mode }),
@@ -587,7 +588,6 @@ export const usePlayerStore = create<PlayerState>((set, get) => ({
reset: () => set(createTimelineResetState()),
}));
// Dev-only bug-bash handle; guard import.meta.env for non-Vite bundlers.
function isDevBuild(): boolean {
try {
return import.meta.env.DEV === true;
@@ -65,6 +65,20 @@ describe("timelineSnapEnabled preference", () => {
});
});
describe("thumbnailMode preference", () => {
it("round-trips the adaptive mode", () => {
const storage = createStorage();
writeStudioUiPreferences({ thumbnailMode: "adaptive" }, storage);
expect(readStudioUiPreferences(storage).thumbnailMode).toBe("adaptive");
});
it("migrates the legacy boolean without retaining two owners", () => {
const storage = createStorage();
storage.setItem("hf-studio-ui-preferences", JSON.stringify({ thumbnailsEnabled: false }));
expect(readStudioUiPreferences(storage).thumbnailMode).toBe("hidden");
});
});
describe("timeline zoom pin persistence", () => {
it("round-trips a pinned manual zoom (survives the post-edit reload)", () => {
const storage = createStorage();
@@ -10,7 +10,7 @@ export interface StudioUiPreferences {
timelineHeight?: number;
playbackRate?: number;
audioMuted?: boolean;
thumbnailsEnabled?: boolean;
thumbnailMode?: "adaptive" | "hidden";
previewZoom?: StoredPreviewZoomState;
recentBlocks?: string[];
snapEnabled?: boolean;
@@ -71,8 +71,10 @@ 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 (parsed.thumbnailMode === "adaptive" || parsed.thumbnailMode === "hidden") {
preferences.thumbnailMode = parsed.thumbnailMode;
} else if (typeof parsed.thumbnailsEnabled === "boolean") {
preferences.thumbnailMode = parsed.thumbnailsEnabled ? "adaptive" : "hidden";
}
if (isRecord(parsed.previewZoom)) {
const { zoomPercent, panX, panY } = parsed.previewZoom;