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
@@ -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",