mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-03 04:38:33 +00:00
## Summary Stabilize the Studio preview/runtime path so timeline data, preview rendering, and thumbnails stay in sync. This PR includes: - preview hot-refresh without remounting the iframe - runtime duration/timeline fixes so Studio stops drifting from playback state - thumbnail and selector-based preview fixes - local Studio runtime serving and player-resolution fixes so dev/CI do not depend on prebuilt player artifacts - tests around preview identity and thumbnail/runtime behavior ## Why This PR Exists This is the foundation layer for timeline editing. Without it, the editor was prone to: - iframe remount flashes after saves - duration mismatches between preview and timeline - stale or incorrect thumbnails - CI/test failures when `@hyperframes/player` artifacts were not prebuilt ## Verification - `bun run --filter @hyperframes/studio test` - `bun run --filter @hyperframes/studio typecheck` - `bun run --filter @hyperframes/core typecheck` - `bunx oxlint packages/cli/src/server/studioServer.ts packages/core/src/runtime/timeline.ts packages/core/src/runtime/timeline.test.ts packages/core/src/studio-api/routes/thumbnail.ts packages/core/src/studio-api/routes/thumbnail.test.ts packages/core/src/studio-api/types.ts packages/studio/src/components/nle/NLELayout.tsx packages/studio/src/components/nle/NLEPreview.tsx packages/studio/src/components/nle/NLEPreview.test.ts packages/studio/src/player/components/CompositionThumbnail.tsx packages/studio/src/player/components/Player.tsx packages/studio/src/player/hooks/useTimelinePlayer.ts packages/studio/src/player/store/playerStore.ts packages/studio/vite.config.ts` - `bunx oxfmt --check packages/cli/src/server/studioServer.ts packages/core/src/runtime/timeline.ts packages/core/src/runtime/timeline.test.ts packages/core/src/studio-api/routes/thumbnail.ts packages/core/src/studio-api/routes/thumbnail.test.ts packages/core/src/studio-api/types.ts packages/studio/src/components/nle/NLELayout.tsx packages/studio/src/components/nle/NLEPreview.tsx packages/studio/src/components/nle/NLEPreview.test.ts packages/studio/src/player/components/CompositionThumbnail.tsx packages/studio/src/player/components/Player.tsx packages/studio/src/player/hooks/useTimelinePlayer.ts packages/studio/src/player/store/playerStore.ts packages/studio/vite.config.ts` ## Stack - base of stack - followed by `feat: add studio timeline editing` - followed by `fix: smooth scrubber end seeking`
83 lines
3.2 KiB
TypeScript
83 lines
3.2 KiB
TypeScript
import type { Hono } from "hono";
|
|
import { existsSync, readFileSync, writeFileSync, mkdirSync } from "node:fs";
|
|
import { join } from "node:path";
|
|
import type { StudioApiAdapter } from "../types.js";
|
|
|
|
const THUMBNAIL_CACHE_VERSION = "v2";
|
|
|
|
export function registerThumbnailRoutes(api: Hono, adapter: StudioApiAdapter): void {
|
|
api.get("/projects/:id/thumbnail/*", async (c) => {
|
|
if (!adapter.generateThumbnail) {
|
|
return c.json({ error: "Thumbnails not available" }, 501);
|
|
}
|
|
const project = await adapter.resolveProject(c.req.param("id"));
|
|
if (!project) return c.json({ error: "not found" }, 404);
|
|
|
|
let compPath = decodeURIComponent(
|
|
c.req.path.replace(`/projects/${project.id}/thumbnail/`, "").split("?")[0] ?? "",
|
|
);
|
|
if (compPath && !compPath.includes(".")) compPath += ".html";
|
|
|
|
const url = new URL(c.req.url, `http://${c.req.header("host") || "localhost"}`);
|
|
const seekTime = parseFloat(url.searchParams.get("t") || "0.5") || 0.5;
|
|
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;
|
|
|
|
// Determine composition dimensions from HTML
|
|
let compW = vpWidth || 1920;
|
|
let compH = vpHeight || 1080;
|
|
if (!vpWidth) {
|
|
const htmlFile = join(project.dir, compPath);
|
|
if (existsSync(htmlFile)) {
|
|
const html = readFileSync(htmlFile, "utf-8");
|
|
const wMatch = html.match(/data-width=["'](\d+)["']/);
|
|
const hMatch = html.match(/data-height=["'](\d+)["']/);
|
|
if (wMatch?.[1]) compW = parseInt(wMatch[1]);
|
|
if (hMatch?.[1]) compH = parseInt(hMatch[1]);
|
|
}
|
|
}
|
|
|
|
const previewUrl =
|
|
compPath === "index.html"
|
|
? `http://${c.req.header("host")}/api/projects/${project.id}/preview`
|
|
: `http://${c.req.header("host")}/api/projects/${project.id}/preview/comp/${compPath}`;
|
|
|
|
// Cache
|
|
const cacheDir = join(project.dir, ".thumbnails");
|
|
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 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" },
|
|
});
|
|
}
|
|
|
|
try {
|
|
const buffer = await adapter.generateThumbnail({
|
|
project,
|
|
compPath,
|
|
seekTime,
|
|
width: compW,
|
|
height: compH,
|
|
previewUrl,
|
|
selector,
|
|
});
|
|
if (!buffer) {
|
|
return c.json({ error: "Thumbnail generation returned null" }, 500);
|
|
}
|
|
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" },
|
|
});
|
|
} catch (err) {
|
|
const msg = err instanceof Error ? err.message : String(err);
|
|
return c.json({ error: `Thumbnail generation failed: ${msg}` }, 500);
|
|
}
|
|
});
|
|
}
|