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
@@ -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>
);
});