mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-03 04:38:33 +00:00
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:
@@ -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);
|
||||
|
||||
@@ -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). */
|
||||
|
||||
Reference in New Issue
Block a user