feat: add Studio current-frame capture (#565)

## Problem

Closes #555. Studio users could inspect the preview, but there was no first-class way to capture the current rendered frame as an image.

## What this fixes

- Adds a `Capture` action to the Studio header toolbar so it does not cover the video preview.
- Downloads the current composition frame as a PNG using the current player time.
- Extends the existing thumbnail route and Studio/CLI thumbnail generators with an explicit PNG format path while preserving JPEG thumbnails for existing previews.
- Adds URL/filename utility coverage plus thumbnail route coverage for PNG requests.

## Root cause

Studio already had frame thumbnail generation, but the API path was JPEG-oriented and the editor UI only used it for previews. There was no current-frame capture affordance wired to the player state.

## Verification

### Local

- `bun run --filter @hyperframes/core test src/studio-api/routes/thumbnail.test.ts`
- `bun run --filter @hyperframes/studio test src/utils/frameCapture.test.ts src/player/components/PlayerControls.test.ts`
- `bun run --filter @hyperframes/studio typecheck`
- `bun run --filter @hyperframes/core typecheck`
- `bun run --filter @hyperframes/cli typecheck`
- `bunx oxlint packages/cli/src/server/studioServer.ts packages/core/src/studio-api/routes/thumbnail.test.ts packages/core/src/studio-api/routes/thumbnail.ts packages/core/src/studio-api/types.ts packages/studio/src/App.tsx packages/studio/src/icons/SystemIcons.tsx packages/studio/vite.config.ts packages/studio/src/utils/frameCapture.ts packages/studio/src/utils/frameCapture.test.ts`
- `bunx oxfmt --check packages/cli/src/server/studioServer.ts packages/core/src/studio-api/routes/thumbnail.test.ts packages/core/src/studio-api/routes/thumbnail.ts packages/core/src/studio-api/types.ts packages/studio/src/App.tsx packages/studio/src/icons/SystemIcons.tsx packages/studio/vite.config.ts packages/studio/src/utils/frameCapture.ts packages/studio/src/utils/frameCapture.test.ts`
- `git diff --check`

### Browser
<img width="1027" height="910" alt="image" src="https://github.com/user-attachments/assets/71973af4-0279-4074-9
<img width="1026" height="902" alt="Screenshot 2026-04-29 at 16 17 22" src="https://github.com/user-attachments/assets/a32e1c19-b793-40b9-82f8-de8bbb11f123" />
060-130839a2d419" />
This commit is contained in:
Miguel Ángel
2026-04-29 22:48:14 +02:00
committed by GitHub
parent b9a9998ff0
commit ea3b708b12
13 changed files with 321 additions and 117 deletions
+12 -5
View File
@@ -278,11 +278,18 @@ export function createStudioServer(options: StudioServerOptions): StudioServer {
}; };
}, opts.selector); }, opts.selector);
} }
const screenshot = (await page.screenshot({ const screenshot = (await page.screenshot(
type: "jpeg", opts.format === "png"
quality: 80, ? {
...(clip ? { clip } : {}), type: "png",
})) as Buffer; ...(clip ? { clip } : {}),
}
: {
type: "jpeg",
quality: 80,
...(clip ? { clip } : {}),
},
)) as Buffer;
return screenshot; return screenshot;
} catch { } catch {
return null; return null;
@@ -51,6 +51,27 @@ describe("registerThumbnailRoutes", () => {
compPath: "index.html", compPath: "index.html",
seekTime: 1.2, seekTime: 1.2,
selector: "#title-card", selector: "#title-card",
format: "jpeg",
}),
);
});
it("forwards png capture requests and returns a png content type", async () => {
const adapter = createAdapter();
const app = new Hono();
registerThumbnailRoutes(app, adapter);
const response = await app.request(
"http://localhost/projects/demo/thumbnail/compositions%2Fintro.html?t=2&format=png",
);
expect(response.status).toBe(200);
expect(response.headers.get("Content-Type")).toBe("image/png");
expect(adapter.generateThumbnail).toHaveBeenCalledWith(
expect.objectContaining({
compPath: "compositions/intro.html",
seekTime: 2,
format: "png",
}), }),
); );
}); });
@@ -23,6 +23,8 @@ export function registerThumbnailRoutes(api: Hono, adapter: StudioApiAdapter): v
const vpWidth = parseInt(url.searchParams.get("w") || "0") || 0; const vpWidth = parseInt(url.searchParams.get("w") || "0") || 0;
const vpHeight = parseInt(url.searchParams.get("h") || "0") || 0; const vpHeight = parseInt(url.searchParams.get("h") || "0") || 0;
const selector = url.searchParams.get("selector") || undefined; const selector = url.searchParams.get("selector") || undefined;
const format = url.searchParams.get("format") === "png" ? "png" : "jpeg";
const contentType = format === "png" ? "image/png" : "image/jpeg";
// Determine composition dimensions from HTML // Determine composition dimensions from HTML
let compW = vpWidth || 1920; let compW = vpWidth || 1920;
@@ -48,11 +50,11 @@ export function registerThumbnailRoutes(api: Hono, adapter: StudioApiAdapter): v
const selectorKey = selector const selectorKey = selector
? `_${selector.replace(/[^a-zA-Z0-9_-]+/g, "_").slice(0, 80)}` ? `_${selector.replace(/[^a-zA-Z0-9_-]+/g, "_").slice(0, 80)}`
: ""; : "";
const cacheKey = `${THUMBNAIL_CACHE_VERSION}_${compPath.replace(/\//g, "_")}_${seekTime.toFixed(2)}${selectorKey}.jpg`; const cacheKey = `${THUMBNAIL_CACHE_VERSION}_${format}_${compPath.replace(/\//g, "_")}_${seekTime.toFixed(2)}${selectorKey}.${format === "png" ? "png" : "jpg"}`;
const cachePath = join(cacheDir, cacheKey); const cachePath = join(cacheDir, cacheKey);
if (existsSync(cachePath)) { if (existsSync(cachePath)) {
return new Response(new Uint8Array(readFileSync(cachePath)), { return new Response(new Uint8Array(readFileSync(cachePath)), {
headers: { "Content-Type": "image/jpeg", "Cache-Control": "public, max-age=60" }, headers: { "Content-Type": contentType, "Cache-Control": "public, max-age=60" },
}); });
} }
@@ -65,6 +67,7 @@ export function registerThumbnailRoutes(api: Hono, adapter: StudioApiAdapter): v
height: compH, height: compH,
previewUrl, previewUrl,
selector, selector,
format,
}); });
if (!buffer) { if (!buffer) {
return c.json({ error: "Thumbnail generation returned null" }, 500); return c.json({ error: "Thumbnail generation returned null" }, 500);
@@ -72,7 +75,7 @@ export function registerThumbnailRoutes(api: Hono, adapter: StudioApiAdapter): v
if (!existsSync(cacheDir)) mkdirSync(cacheDir, { recursive: true }); if (!existsSync(cacheDir)) mkdirSync(cacheDir, { recursive: true });
writeFileSync(cachePath, buffer); writeFileSync(cachePath, buffer);
return new Response(new Uint8Array(buffer), { return new Response(new Uint8Array(buffer), {
headers: { "Content-Type": "image/jpeg", "Cache-Control": "public, max-age=60" }, headers: { "Content-Type": contentType, "Cache-Control": "public, max-age=60" },
}); });
} catch (err) { } catch (err) {
const msg = err instanceof Error ? err.message : String(err); const msg = err instanceof Error ? err.message : String(err);
+1
View File
@@ -72,6 +72,7 @@ export interface StudioApiAdapter {
height: number; height: number;
previewUrl: string; previewUrl: string;
selector?: string; selector?: string;
format?: "jpeg" | "png";
}) => Promise<Buffer | null>; }) => Promise<Buffer | null>;
/** Optional: resolve session ID to project (multi-project mode). */ /** Optional: resolve session ID to project (multi-project mode). */
+130 -51
View File
@@ -1,11 +1,19 @@
import { useState, useCallback, useRef, useEffect, useMemo, type ReactNode } from "react"; import {
useState,
useCallback,
useRef,
useEffect,
useMemo,
type MouseEvent,
type ReactNode,
} from "react";
import { useMountEffect } from "./hooks/useMountEffect"; import { useMountEffect } from "./hooks/useMountEffect";
import { NLELayout } from "./components/nle/NLELayout"; import { NLELayout } from "./components/nle/NLELayout";
import { SourceEditor } from "./components/editor/SourceEditor"; import { SourceEditor } from "./components/editor/SourceEditor";
import { LeftSidebar } from "./components/sidebar/LeftSidebar"; import { LeftSidebar } from "./components/sidebar/LeftSidebar";
import { RenderQueue } from "./components/renders/RenderQueue"; import { RenderQueue } from "./components/renders/RenderQueue";
import { useRenderQueue } from "./components/renders/useRenderQueue"; import { useRenderQueue } from "./components/renders/useRenderQueue";
import { CompositionThumbnail, VideoThumbnail, usePlayerStore } from "./player"; import { CompositionThumbnail, VideoThumbnail, liveTime, usePlayerStore } from "./player";
import { AudioWaveform } from "./player/components/AudioWaveform"; import { AudioWaveform } from "./player/components/AudioWaveform";
import type { TimelineElement } from "./player"; import type { TimelineElement } from "./player";
import { LintModal } from "./components/LintModal"; import { LintModal } from "./components/LintModal";
@@ -40,6 +48,8 @@ import {
getTimelineToggleTitle, getTimelineToggleTitle,
shouldHandleTimelineToggleHotkey, shouldHandleTimelineToggleHotkey,
} from "./utils/timelineDiscovery"; } from "./utils/timelineDiscovery";
import { buildFrameCaptureFilename, buildFrameCaptureUrl } from "./utils/frameCapture";
import { Camera } from "./icons/SystemIcons";
interface EditingFile { interface EditingFile {
path: string; path: string;
@@ -264,6 +274,7 @@ export function StudioApp() {
const [globalDragOver, setGlobalDragOver] = useState(false); const [globalDragOver, setGlobalDragOver] = useState(false);
const [appToast, setAppToast] = useState<AppToast | null>(null); const [appToast, setAppToast] = useState<AppToast | null>(null);
const [timelineVisible, setTimelineVisible] = useState(true); const [timelineVisible, setTimelineVisible] = useState(true);
const [captureFrameTime, setCaptureFrameTime] = useState(0);
const dragCounterRef = useRef(0); const dragCounterRef = useRef(0);
const toastTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null); const toastTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const lastBlockedTimelineToastAtRef = useRef(0); const lastBlockedTimelineToastAtRef = useRef(0);
@@ -298,6 +309,26 @@ export function StudioApp() {
const toggleTimelineVisibility = useCallback(() => { const toggleTimelineVisibility = useCallback(() => {
setTimelineVisible((visible) => !visible); setTimelineVisible((visible) => !visible);
}, []); }, []);
const toggleLeftSidebar = useCallback(() => {
setLeftCollapsed((collapsed) => !collapsed);
}, []);
const refreshCaptureFrameTime = useCallback(() => {
setCaptureFrameTime(usePlayerStore.getState().currentTime);
}, []);
useMountEffect(() => {
setCaptureFrameTime(usePlayerStore.getState().currentTime);
return liveTime.subscribe(setCaptureFrameTime);
});
const captureFrameHref = projectId
? buildFrameCaptureUrl({
projectId,
compositionPath: activeCompPath,
currentTime: captureFrameTime,
})
: "#";
const captureFrameFilename = buildFrameCaptureFilename(activeCompPath, captureFrameTime);
useMountEffect(() => () => { useMountEffect(() => () => {
if (toastTimerRef.current) clearTimeout(toastTimerRef.current); if (toastTimerRef.current) clearTimeout(toastTimerRef.current);
}); });
@@ -496,6 +527,28 @@ export function StudioApp() {
> >
+ +
</button> </button>
<button
type="button"
onClick={toggleTimelineVisibility}
className="ml-1 flex h-7 w-7 items-center justify-center rounded-md text-neutral-500 transition-colors hover:bg-neutral-900 hover:text-neutral-200"
title={getTimelineToggleTitle(true)}
aria-label="Hide timeline editor"
>
<svg
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.8"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
>
<path d="M5 7h14" />
<path d="m8 11 4 4 4-4" />
</svg>
</button>
</div> </div>
</div> </div>
</div> </div>
@@ -787,6 +840,42 @@ export function StudioApp() {
toastTimerRef.current = setTimeout(() => setAppToast(null), 4000); toastTimerRef.current = setTimeout(() => setAppToast(null), 4000);
}, []); }, []);
const handleCaptureFrameClick = useCallback(
async (event: MouseEvent<HTMLAnchorElement>) => {
if (!projectId) return;
event.preventDefault();
const currentTime = usePlayerStore.getState().currentTime;
setCaptureFrameTime(currentTime);
const href = buildFrameCaptureUrl({
projectId,
compositionPath: activeCompPath,
currentTime,
});
const filename = buildFrameCaptureFilename(activeCompPath, currentTime);
try {
const response = await fetch(href, { cache: "no-store" });
if (!response.ok) {
throw new Error(`Capture failed (${response.status})`);
}
const blob = await response.blob();
const blobUrl = URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = blobUrl;
link.download = filename;
document.body.appendChild(link);
link.click();
link.remove();
setTimeout(() => URL.revokeObjectURL(blobUrl), 0);
} catch (err) {
const message = err instanceof Error ? err.message : "Capture failed";
showToast(message);
}
},
[activeCompPath, projectId, showToast],
);
const handleTimelineElementDelete = useCallback( const handleTimelineElementDelete = useCallback(
async (element: TimelineElement) => { async (element: TimelineElement) => {
const pid = projectIdRef.current; const pid = projectIdRef.current;
@@ -1345,55 +1434,19 @@ export function StudioApp() {
</div> </div>
{/* Right: toolbar buttons */} {/* Right: toolbar buttons */}
<div className="flex items-center gap-1.5"> <div className="flex items-center gap-1.5">
<button <a
onClick={() => setLeftCollapsed((v) => !v)} href={captureFrameHref}
className={`h-7 w-7 flex items-center justify-center rounded-md border transition-colors ${ download={captureFrameFilename}
!leftCollapsed onClick={handleCaptureFrameClick}
? "text-studio-accent bg-studio-accent/10 border-studio-accent/30" onFocus={refreshCaptureFrameTime}
: "bg-transparent border-transparent text-neutral-500 hover:text-neutral-300 hover:bg-neutral-800" onPointerDown={refreshCaptureFrameTime}
}`} className="h-7 flex items-center gap-1.5 px-2.5 rounded-md text-[11px] font-medium border border-neutral-700 text-neutral-300 transition-colors hover:border-neutral-500 hover:bg-neutral-800"
title={leftCollapsed ? "Show sidebar" : "Hide sidebar"} title="Capture current frame"
aria-label="Capture current frame"
> >
<svg <Camera size={14} />
width="14" <span>Capture</span>
height="14" </a>
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
>
<rect x="3" y="3" width="18" height="18" rx="2" />
<path d="M9 3v18" />
</svg>
</button>
<button
type="button"
onClick={toggleTimelineVisibility}
className={`h-7 flex items-center gap-1.5 px-2.5 rounded-md text-[11px] font-medium border transition-colors ${
timelineVisible
? "text-studio-accent bg-studio-accent/10 border-studio-accent/30"
: "text-neutral-300 border-neutral-700 hover:border-neutral-500 hover:bg-neutral-800"
}`}
title={getTimelineToggleTitle(timelineVisible)}
aria-label={timelineVisible ? "Hide timeline editor" : "Show timeline editor"}
>
<svg
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
>
<rect x="3" y="13" width="18" height="8" rx="1" />
<line x1="3" y1="9" x2="21" y2="9" />
<line x1="3" y1="5" x2="21" y2="5" />
</svg>
<span>Timeline</span>
</button>
<button <button
onClick={() => setRightCollapsed((v) => !v)} onClick={() => setRightCollapsed((v) => !v)}
className={`h-7 flex items-center gap-1.5 px-2.5 rounded-md text-[11px] font-medium border transition-colors ${ className={`h-7 flex items-center gap-1.5 px-2.5 rounded-md text-[11px] font-medium border transition-colors ${
@@ -1422,7 +1475,32 @@ export function StudioApp() {
{/* Main content: sidebar + preview + right panel */} {/* Main content: sidebar + preview + right panel */}
<div className="flex flex-1 min-h-0"> <div className="flex flex-1 min-h-0">
{/* Left sidebar: Compositions + Assets (resizable, collapsible) */} {/* Left sidebar: Compositions + Assets (resizable, collapsible) */}
{!leftCollapsed && ( {leftCollapsed ? (
<div className="flex w-10 flex-shrink-0 flex-col items-center border-r border-neutral-800/50 bg-neutral-950 pt-1">
<button
type="button"
onClick={toggleLeftSidebar}
className="flex h-8 w-8 items-center justify-center rounded-md border border-transparent text-neutral-500 transition-colors hover:border-neutral-800 hover:bg-neutral-900 hover:text-neutral-300"
title="Show sidebar"
aria-label="Show sidebar"
>
<svg
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
>
<path d="M5 4v16" />
<path d="m10 7 5 5-5 5" />
</svg>
</button>
</div>
) : (
<LeftSidebar <LeftSidebar
width={leftWidth} width={leftWidth}
projectId={projectId} projectId={projectId}
@@ -1469,6 +1547,7 @@ export function StudioApp() {
} }
onLint={handleLint} onLint={handleLint}
linting={linting} linting={linting}
onToggleCollapse={toggleLeftSidebar}
/> />
)} )}
@@ -5,6 +5,10 @@ import type { TimelineElement } from "../../player";
import type { BlockedTimelineEditIntent } from "../../player/components/timelineEditing"; import type { BlockedTimelineEditIntent } from "../../player/components/timelineEditing";
import { NLEPreview } from "./NLEPreview"; import { NLEPreview } from "./NLEPreview";
import { CompositionBreadcrumb, type CompositionLevel } from "./CompositionBreadcrumb"; import { CompositionBreadcrumb, type CompositionLevel } from "./CompositionBreadcrumb";
import {
TIMELINE_TOGGLE_SHORTCUT_LABEL,
getTimelineToggleTitle,
} from "../../utils/timelineDiscovery";
interface NLELayoutProps { interface NLELayoutProps {
projectId: string; projectId: string;
@@ -197,6 +201,7 @@ export const NLELayout = memo(function NLELayout({
// Resizable timeline height // Resizable timeline height
const [timelineH, setTimelineH] = useState(DEFAULT_TIMELINE_H); const [timelineH, setTimelineH] = useState(DEFAULT_TIMELINE_H);
const isTimelineVisible = timelineVisible ?? true;
const isDragging = useRef(false); const isDragging = useRef(false);
const containerRef = useRef<HTMLDivElement>(null); const containerRef = useRef<HTMLDivElement>(null);
@@ -366,16 +371,11 @@ export const NLELayout = memo(function NLELayout({
onNavigate={handleNavigateComposition} onNavigate={handleNavigateComposition}
/> />
)} )}
<PlayerControls <PlayerControls onTogglePlay={togglePlay} onSeek={seek} />
onTogglePlay={togglePlay}
onSeek={seek}
timelineVisible={timelineVisible ?? true}
onToggleTimeline={onToggleTimeline}
/>
</div> </div>
</div> </div>
{(timelineVisible ?? true) && ( {isTimelineVisible ? (
<> <>
{/* Resize divider */} {/* Resize divider */}
<div <div
@@ -417,7 +417,42 @@ export const NLELayout = memo(function NLELayout({
{timelineFooter && <div className="flex-shrink-0">{timelineFooter}</div>} {timelineFooter && <div className="flex-shrink-0">{timelineFooter}</div>}
</div> </div>
</> </>
)} ) : onToggleTimeline ? (
<div className="flex-shrink-0 border-t border-neutral-800/50 bg-neutral-950/96">
<div className="flex h-10 items-center justify-between px-3">
<div className="text-[10px] font-medium uppercase tracking-[0.16em] text-neutral-500">
Timeline
</div>
<button
type="button"
onClick={onToggleTimeline}
className="flex h-7 items-center gap-1.5 rounded-md border border-neutral-800 px-2.5 text-[11px] font-medium text-neutral-300 transition-colors hover:border-neutral-700 hover:bg-neutral-900 hover:text-neutral-100"
title={getTimelineToggleTitle(false)}
aria-label="Show timeline editor"
>
<svg
width="13"
height="13"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.7"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
>
<rect x="3" y="13" width="18" height="8" rx="1" />
<path d="M7 9h10" />
<path d="M8 5h8" />
</svg>
<span>Show</span>
<span className="hidden rounded bg-white/5 px-1 py-0.5 font-mono text-[9px] text-neutral-500 sm:inline">
{TIMELINE_TOGGLE_SHORTCUT_LABEL}
</span>
</button>
</div>
</div>
) : null}
</div> </div>
); );
}); });
@@ -35,6 +35,7 @@ interface LeftSidebarProps {
codeChildren?: ReactNode; codeChildren?: ReactNode;
onLint?: () => void; onLint?: () => void;
linting?: boolean; linting?: boolean;
onToggleCollapse?: () => void;
} }
export const LeftSidebar = memo(function LeftSidebar({ export const LeftSidebar = memo(function LeftSidebar({
@@ -57,6 +58,7 @@ export const LeftSidebar = memo(function LeftSidebar({
codeChildren, codeChildren,
onLint, onLint,
linting, linting,
onToggleCollapse,
}: LeftSidebarProps) { }: LeftSidebarProps) {
const [tab, setTab] = useState<SidebarTab>(getPersistedTab); const [tab, setTab] = useState<SidebarTab>(getPersistedTab);
@@ -122,6 +124,30 @@ export const LeftSidebar = memo(function LeftSidebar({
> >
Assets Assets
</button> </button>
{onToggleCollapse && (
<button
type="button"
onClick={onToggleCollapse}
className="mx-1 my-1 flex h-7 w-7 flex-shrink-0 items-center justify-center rounded-md border border-transparent text-neutral-500 transition-colors hover:border-neutral-800 hover:bg-neutral-900 hover:text-neutral-300"
title="Hide sidebar"
aria-label="Hide sidebar"
>
<svg
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
>
<path d="m14 7-5 5 5 5" />
<path d="M19 4v16" />
</svg>
</button>
)}
</div> </div>
{/* Tab content */} {/* Tab content */}
@@ -53,6 +53,7 @@ import {
CaretRight, CaretRight,
ClipboardText, ClipboardText,
ArrowCounterClockwise, ArrowCounterClockwise,
Camera as PhCamera,
Gear, Gear,
} from "@phosphor-icons/react"; } from "@phosphor-icons/react";
import type { Icon as PhosphorIcon, IconProps as PhosphorIconProps } from "@phosphor-icons/react"; import type { Icon as PhosphorIcon, IconProps as PhosphorIconProps } from "@phosphor-icons/react";
@@ -127,4 +128,5 @@ export const ChevronDown = makeIcon(CaretDown);
export const ChevronRight = makeIcon(CaretRight); export const ChevronRight = makeIcon(CaretRight);
export const ClipboardList = makeIcon(ClipboardText); export const ClipboardList = makeIcon(ClipboardText);
export const RotateCcw = makeIcon(ArrowCounterClockwise); export const RotateCcw = makeIcon(ArrowCounterClockwise);
export const Camera = makeIcon(PhCamera);
export const Settings = makeIcon(Gear); export const Settings = makeIcon(Gear);
@@ -1,9 +1,5 @@
import { useRef, useState, useCallback, useEffect, memo } from "react"; import { useRef, useState, useCallback, useEffect, memo } from "react";
import { useMountEffect } from "../../hooks/useMountEffect"; import { useMountEffect } from "../../hooks/useMountEffect";
import {
TIMELINE_TOGGLE_SHORTCUT_LABEL,
getTimelineToggleTitle,
} from "../../utils/timelineDiscovery";
import { formatFrameTime, frameToSeconds, formatTime } from "../lib/time"; import { formatFrameTime, frameToSeconds, formatTime } from "../lib/time";
import { usePlayerStore, liveTime } from "../store/playerStore"; import { usePlayerStore, liveTime } from "../store/playerStore";
@@ -30,15 +26,11 @@ export function resolveSeekPercent(clientX: number, rectLeft: number, rectWidth:
interface PlayerControlsProps { interface PlayerControlsProps {
onTogglePlay: () => void; onTogglePlay: () => void;
onSeek: (time: number) => void; onSeek: (time: number) => void;
timelineVisible?: boolean;
onToggleTimeline?: () => void;
} }
export const PlayerControls = memo(function PlayerControls({ export const PlayerControls = memo(function PlayerControls({
onTogglePlay, onTogglePlay,
onSeek, onSeek,
timelineVisible,
onToggleTimeline,
}: PlayerControlsProps) { }: PlayerControlsProps) {
// Subscribe to only the fields we render — each selector prevents cascading re-renders // Subscribe to only the fields we render — each selector prevents cascading re-renders
const isPlaying = usePlayerStore((s) => s.isPlaying); const isPlaying = usePlayerStore((s) => s.isPlaying);
@@ -437,39 +429,6 @@ export const PlayerControls = memo(function PlayerControls({
</span> </span>
))} ))}
</div> </div>
{/* Timeline toggle */}
{onToggleTimeline !== undefined && (
<button
type="button"
onClick={onToggleTimeline}
className={`h-7 flex items-center gap-1.5 rounded-md border px-2.5 text-[11px] font-medium transition-colors ${
timelineVisible
? "text-studio-accent bg-studio-accent/10 border-studio-accent/30"
: "border-neutral-700 text-neutral-300 hover:border-neutral-500 hover:bg-neutral-800"
}`}
title={getTimelineToggleTitle(Boolean(timelineVisible))}
aria-label={timelineVisible ? "Hide timeline editor" : "Show timeline editor"}
>
<svg
width="13"
height="13"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
>
<rect x="3" y="13" width="18" height="8" rx="1" />
<line x1="3" y1="9" x2="21" y2="9" />
<line x1="3" y1="5" x2="21" y2="5" />
</svg>
<span>Timeline</span>
<span className="hidden md:inline rounded bg-black/20 px-1 py-0.5 text-[9px] font-mono opacity-70">
{TIMELINE_TOGGLE_SHORTCUT_LABEL}
</span>
</button>
)}
</div> </div>
); );
}); });
@@ -63,12 +63,12 @@ const TRACK_STYLES: Record<string, TimelineTrackStyle> = {
const DEFAULT_TRACK_STYLE: TimelineTrackStyle = createTrackStyle(); const DEFAULT_TRACK_STYLE: TimelineTrackStyle = createTrackStyle();
export const defaultTimelineTheme: TimelineTheme = { export const defaultTimelineTheme: TimelineTheme = {
shellBackground: "#0A0E15", shellBackground: "#0A0A0B",
shellBorder: "rgba(255,255,255,0.05)", shellBorder: "rgba(255,255,255,0.05)",
rulerBorder: "rgba(255,255,255,0.045)", rulerBorder: "rgba(255,255,255,0.045)",
rowBackground: "#0A0E15", rowBackground: "#0A0A0B",
rowBorder: "rgba(255,255,255,0.05)", rowBorder: "rgba(255,255,255,0.05)",
gutterBackground: "#0D121B", gutterBackground: "#0A0A0B",
gutterBorder: "rgba(255,255,255,0.05)", gutterBorder: "rgba(255,255,255,0.05)",
textPrimary: "#E8EDF5", textPrimary: "#E8EDF5",
textSecondary: "#8391A8", textSecondary: "#8391A8",
@@ -0,0 +1,26 @@
import { describe, expect, it, vi } from "vitest";
import { buildFrameCaptureFilename, buildFrameCaptureUrl } from "./frameCapture";
describe("frame capture utilities", () => {
it("builds a PNG capture URL for the master composition", () => {
vi.useFakeTimers();
vi.setSystemTime(new Date("2026-04-29T12:00:00Z"));
expect(
buildFrameCaptureUrl({
projectId: "demo project",
compositionPath: null,
currentTime: 1.23456,
origin: "http://localhost:5194",
}),
).toBe(
"http://localhost:5194/api/projects/demo%20project/thumbnail/index.html?t=1.235&format=png&v=1777464000000",
);
vi.useRealTimers();
});
it("builds a safe filename from a nested composition path", () => {
expect(buildFrameCaptureFilename("compositions/intro.html", 2.5)).toBe("intro-2-500s.png");
});
});
+38
View File
@@ -0,0 +1,38 @@
export interface FrameCaptureRequest {
projectId: string;
compositionPath: string | null;
currentTime: number;
origin?: string;
}
function normalizeCompositionPath(compositionPath: string | null): string {
return compositionPath && compositionPath !== "master" ? compositionPath : "index.html";
}
export function buildFrameCaptureUrl({
projectId,
compositionPath,
currentTime,
origin = window.location.origin,
}: FrameCaptureRequest): string {
const compPath = normalizeCompositionPath(compositionPath);
const url = new URL(
`/api/projects/${encodeURIComponent(projectId)}/thumbnail/${encodeURIComponent(compPath)}`,
origin,
);
url.searchParams.set("t", Math.max(0, currentTime).toFixed(3));
url.searchParams.set("format", "png");
url.searchParams.set("v", String(Date.now()));
return url.toString();
}
export function buildFrameCaptureFilename(compositionPath: string | null, currentTime: number) {
const compPath = normalizeCompositionPath(compositionPath);
const base =
compPath
.split("/")
.pop()
?.replace(/\.html$/i, "") || "frame";
const frameTime = Math.max(0, currentTime).toFixed(3).replace(".", "-");
return `${base}-${frameTime}s.png`;
}
+13 -6
View File
@@ -250,7 +250,7 @@ function createViteAdapter(dataDir: string, server: ViteDevServer): StudioApiAda
await page.setViewport({ await page.setViewport({
width: opts.width, width: opts.width,
height: opts.height, height: opts.height,
deviceScaleFactor: 0.5, deviceScaleFactor: opts.format === "png" ? 1 : 0.5,
}); });
await page.goto(opts.previewUrl, { waitUntil: "domcontentloaded", timeout: 10000 }); await page.goto(opts.previewUrl, { waitUntil: "domcontentloaded", timeout: 10000 });
await page.evaluate(() => { await page.evaluate(() => {
@@ -307,11 +307,18 @@ function createViteAdapter(dataDir: string, server: ViteDevServer): StudioApiAda
}; };
}, opts.selector); }, opts.selector);
} }
const buf = await page.screenshot({ const buf = await page.screenshot(
type: "jpeg", opts.format === "png"
quality: 75, ? {
...(clip ? { clip } : {}), type: "png",
}); ...(clip ? { clip } : {}),
}
: {
type: "jpeg",
quality: 75,
...(clip ? { clip } : {}),
},
);
await page.close(); await page.close();
return buf as Buffer; return buf as Buffer;
})(); })();