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
+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 { NLELayout } from "./components/nle/NLELayout";
import { SourceEditor } from "./components/editor/SourceEditor";
import { LeftSidebar } from "./components/sidebar/LeftSidebar";
import { RenderQueue } from "./components/renders/RenderQueue";
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 type { TimelineElement } from "./player";
import { LintModal } from "./components/LintModal";
@@ -40,6 +48,8 @@ import {
getTimelineToggleTitle,
shouldHandleTimelineToggleHotkey,
} from "./utils/timelineDiscovery";
import { buildFrameCaptureFilename, buildFrameCaptureUrl } from "./utils/frameCapture";
import { Camera } from "./icons/SystemIcons";
interface EditingFile {
path: string;
@@ -264,6 +274,7 @@ export function StudioApp() {
const [globalDragOver, setGlobalDragOver] = useState(false);
const [appToast, setAppToast] = useState<AppToast | null>(null);
const [timelineVisible, setTimelineVisible] = useState(true);
const [captureFrameTime, setCaptureFrameTime] = useState(0);
const dragCounterRef = useRef(0);
const toastTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const lastBlockedTimelineToastAtRef = useRef(0);
@@ -298,6 +309,26 @@ export function StudioApp() {
const toggleTimelineVisibility = useCallback(() => {
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(() => () => {
if (toastTimerRef.current) clearTimeout(toastTimerRef.current);
});
@@ -496,6 +527,28 @@ export function StudioApp() {
>
+
</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>
@@ -787,6 +840,42 @@ export function StudioApp() {
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(
async (element: TimelineElement) => {
const pid = projectIdRef.current;
@@ -1345,55 +1434,19 @@ export function StudioApp() {
</div>
{/* Right: toolbar buttons */}
<div className="flex items-center gap-1.5">
<button
onClick={() => setLeftCollapsed((v) => !v)}
className={`h-7 w-7 flex items-center justify-center rounded-md border transition-colors ${
!leftCollapsed
? "text-studio-accent bg-studio-accent/10 border-studio-accent/30"
: "bg-transparent border-transparent text-neutral-500 hover:text-neutral-300 hover:bg-neutral-800"
}`}
title={leftCollapsed ? "Show sidebar" : "Hide sidebar"}
<a
href={captureFrameHref}
download={captureFrameFilename}
onClick={handleCaptureFrameClick}
onFocus={refreshCaptureFrameTime}
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="Capture current frame"
aria-label="Capture current frame"
>
<svg
width="14"
height="14"
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>
<Camera size={14} />
<span>Capture</span>
</a>
<button
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 ${
@@ -1422,7 +1475,32 @@ export function StudioApp() {
{/* Main content: sidebar + preview + right panel */}
<div className="flex flex-1 min-h-0">
{/* 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
width={leftWidth}
projectId={projectId}
@@ -1469,6 +1547,7 @@ export function StudioApp() {
}
onLint={handleLint}
linting={linting}
onToggleCollapse={toggleLeftSidebar}
/>
)}
@@ -5,6 +5,10 @@ import type { TimelineElement } from "../../player";
import type { BlockedTimelineEditIntent } from "../../player/components/timelineEditing";
import { NLEPreview } from "./NLEPreview";
import { CompositionBreadcrumb, type CompositionLevel } from "./CompositionBreadcrumb";
import {
TIMELINE_TOGGLE_SHORTCUT_LABEL,
getTimelineToggleTitle,
} from "../../utils/timelineDiscovery";
interface NLELayoutProps {
projectId: string;
@@ -197,6 +201,7 @@ export const NLELayout = memo(function NLELayout({
// Resizable timeline height
const [timelineH, setTimelineH] = useState(DEFAULT_TIMELINE_H);
const isTimelineVisible = timelineVisible ?? true;
const isDragging = useRef(false);
const containerRef = useRef<HTMLDivElement>(null);
@@ -366,16 +371,11 @@ export const NLELayout = memo(function NLELayout({
onNavigate={handleNavigateComposition}
/>
)}
<PlayerControls
onTogglePlay={togglePlay}
onSeek={seek}
timelineVisible={timelineVisible ?? true}
onToggleTimeline={onToggleTimeline}
/>
<PlayerControls onTogglePlay={togglePlay} onSeek={seek} />
</div>
</div>
{(timelineVisible ?? true) && (
{isTimelineVisible ? (
<>
{/* Resize divider */}
<div
@@ -417,7 +417,42 @@ export const NLELayout = memo(function NLELayout({
{timelineFooter && <div className="flex-shrink-0">{timelineFooter}</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>
);
});
@@ -35,6 +35,7 @@ interface LeftSidebarProps {
codeChildren?: ReactNode;
onLint?: () => void;
linting?: boolean;
onToggleCollapse?: () => void;
}
export const LeftSidebar = memo(function LeftSidebar({
@@ -57,6 +58,7 @@ export const LeftSidebar = memo(function LeftSidebar({
codeChildren,
onLint,
linting,
onToggleCollapse,
}: LeftSidebarProps) {
const [tab, setTab] = useState<SidebarTab>(getPersistedTab);
@@ -122,6 +124,30 @@ export const LeftSidebar = memo(function LeftSidebar({
>
Assets
</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>
{/* Tab content */}
@@ -53,6 +53,7 @@ import {
CaretRight,
ClipboardText,
ArrowCounterClockwise,
Camera as PhCamera,
Gear,
} 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 ClipboardList = makeIcon(ClipboardText);
export const RotateCcw = makeIcon(ArrowCounterClockwise);
export const Camera = makeIcon(PhCamera);
export const Settings = makeIcon(Gear);
@@ -1,9 +1,5 @@
import { useRef, useState, useCallback, useEffect, memo } from "react";
import { useMountEffect } from "../../hooks/useMountEffect";
import {
TIMELINE_TOGGLE_SHORTCUT_LABEL,
getTimelineToggleTitle,
} from "../../utils/timelineDiscovery";
import { formatFrameTime, frameToSeconds, formatTime } from "../lib/time";
import { usePlayerStore, liveTime } from "../store/playerStore";
@@ -30,15 +26,11 @@ export function resolveSeekPercent(clientX: number, rectLeft: number, rectWidth:
interface PlayerControlsProps {
onTogglePlay: () => void;
onSeek: (time: number) => void;
timelineVisible?: boolean;
onToggleTimeline?: () => void;
}
export const PlayerControls = memo(function PlayerControls({
onTogglePlay,
onSeek,
timelineVisible,
onToggleTimeline,
}: PlayerControlsProps) {
// Subscribe to only the fields we render — each selector prevents cascading re-renders
const isPlaying = usePlayerStore((s) => s.isPlaying);
@@ -437,39 +429,6 @@ export const PlayerControls = memo(function PlayerControls({
</span>
))}
</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>
);
});
@@ -63,12 +63,12 @@ const TRACK_STYLES: Record<string, TimelineTrackStyle> = {
const DEFAULT_TRACK_STYLE: TimelineTrackStyle = createTrackStyle();
export const defaultTimelineTheme: TimelineTheme = {
shellBackground: "#0A0E15",
shellBackground: "#0A0A0B",
shellBorder: "rgba(255,255,255,0.05)",
rulerBorder: "rgba(255,255,255,0.045)",
rowBackground: "#0A0E15",
rowBackground: "#0A0A0B",
rowBorder: "rgba(255,255,255,0.05)",
gutterBackground: "#0D121B",
gutterBackground: "#0A0A0B",
gutterBorder: "rgba(255,255,255,0.05)",
textPrimary: "#E8EDF5",
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({
width: opts.width,
height: opts.height,
deviceScaleFactor: 0.5,
deviceScaleFactor: opts.format === "png" ? 1 : 0.5,
});
await page.goto(opts.previewUrl, { waitUntil: "domcontentloaded", timeout: 10000 });
await page.evaluate(() => {
@@ -307,11 +307,18 @@ function createViteAdapter(dataDir: string, server: ViteDevServer): StudioApiAda
};
}, opts.selector);
}
const buf = await page.screenshot({
type: "jpeg",
quality: 75,
...(clip ? { clip } : {}),
});
const buf = await page.screenshot(
opts.format === "png"
? {
type: "png",
...(clip ? { clip } : {}),
}
: {
type: "jpeg",
quality: 75,
...(clip ? { clip } : {}),
},
);
await page.close();
return buf as Buffer;
})();