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
@@ -51,6 +51,27 @@ describe("registerThumbnailRoutes", () => {
compPath: "index.html",
seekTime: 1.2,
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 vpHeight = parseInt(url.searchParams.get("h") || "0") || 0;
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
let compW = vpWidth || 1920;
@@ -48,11 +50,11 @@ export function registerThumbnailRoutes(api: Hono, adapter: StudioApiAdapter): v
const selectorKey = selector
? `_${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);
if (existsSync(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,
previewUrl,
selector,
format,
});
if (!buffer) {
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 });
writeFileSync(cachePath, 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) {
const msg = err instanceof Error ? err.message : String(err);
+1
View File
@@ -72,6 +72,7 @@ export interface StudioApiAdapter {
height: number;
previewUrl: string;
selector?: string;
format?: "jpeg" | "png";
}) => Promise<Buffer | null>;
/** Optional: resolve session ID to project (multi-project mode). */