mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-03 04:38:33 +00:00
fix: stabilize studio preview and runtime sync (#389)
## 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`
This commit is contained in:
@@ -183,6 +183,22 @@ describe("collectRuntimeTimelinePayload", () => {
|
||||
expect(result.durationInFrames).toBe(300); // 10s * 30fps
|
||||
});
|
||||
|
||||
it("preserves the authored root duration when clips end earlier", () => {
|
||||
const root = document.createElement("div");
|
||||
root.setAttribute("data-composition-id", "main");
|
||||
root.setAttribute("data-duration", "7");
|
||||
document.body.appendChild(root);
|
||||
|
||||
const clip = document.createElement("div");
|
||||
clip.id = "trimmed";
|
||||
clip.setAttribute("data-start", "0");
|
||||
clip.setAttribute("data-duration", "5");
|
||||
root.appendChild(clip);
|
||||
|
||||
const result = collectRuntimeTimelinePayload(defaultParams);
|
||||
expect(result.durationInFrames).toBe(210); // 7s * 30fps
|
||||
});
|
||||
|
||||
it("clamps duration to maxTimelineDurationSeconds", () => {
|
||||
const root = document.createElement("div");
|
||||
root.setAttribute("data-composition-id", "main");
|
||||
@@ -403,7 +419,39 @@ describe("collectRuntimeTimelinePayload", () => {
|
||||
expect(clip?.duration).toBeCloseTo(3.5);
|
||||
});
|
||||
|
||||
it("includes persistent overlays as full-duration clips", () => {
|
||||
it("includes persistent overlays as full-duration clips only when opted in", () => {
|
||||
const root = document.createElement("div");
|
||||
root.setAttribute("data-composition-id", "main");
|
||||
root.setAttribute("data-duration", "12");
|
||||
document.body.appendChild(root);
|
||||
|
||||
const overlay = document.createElement("div");
|
||||
overlay.id = "grid-overlay";
|
||||
overlay.setAttribute("data-timeline-role", "overlay");
|
||||
root.appendChild(overlay);
|
||||
|
||||
(window as any).__timelines = {
|
||||
main: {
|
||||
duration: () => 12,
|
||||
time: () => 0,
|
||||
play: () => {},
|
||||
pause: () => {},
|
||||
seek: () => {},
|
||||
add: () => {},
|
||||
paused: () => {},
|
||||
set: () => {},
|
||||
getChildren: () => [],
|
||||
},
|
||||
};
|
||||
|
||||
const result = collectRuntimeTimelinePayload(defaultParams);
|
||||
const clip = result.clips.find((c) => c.id === "grid-overlay");
|
||||
expect(clip).toBeDefined();
|
||||
expect(clip?.start).toBe(0);
|
||||
expect(clip?.duration).toBe(12);
|
||||
});
|
||||
|
||||
it("does not include persistent overlays by default", () => {
|
||||
const root = document.createElement("div");
|
||||
root.setAttribute("data-composition-id", "main");
|
||||
root.setAttribute("data-duration", "12");
|
||||
@@ -428,10 +476,7 @@ describe("collectRuntimeTimelinePayload", () => {
|
||||
};
|
||||
|
||||
const result = collectRuntimeTimelinePayload(defaultParams);
|
||||
const clip = result.clips.find((c) => c.id === "grid-overlay");
|
||||
expect(clip).toBeDefined();
|
||||
expect(clip?.start).toBe(0);
|
||||
expect(clip?.duration).toBe(12);
|
||||
expect(result.clips.find((c) => c.id === "grid-overlay")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("does not include script/style elements as persistent overlays", () => {
|
||||
|
||||
@@ -461,15 +461,18 @@ export function collectRuntimeTimelinePayload(params: {
|
||||
}
|
||||
|
||||
// ── Persistent overlays ─────────────────────────────────────────────────
|
||||
// Direct children of root with an ID that weren't picked up by either the
|
||||
// DOM query or GSAP introspection are persistent overlays (e.g. grid, border
|
||||
// decorations). Show them as full-duration clips on their own track.
|
||||
// Direct children of root that are pure structural overlays should only
|
||||
// surface in the timeline when authors explicitly opt them in. Otherwise
|
||||
// background layers like "backdrop" make the whole composition read as a
|
||||
// long clip, which is misleading in Studio.
|
||||
if (root && rootCompositionDuration != null && rootCompositionDuration > 0) {
|
||||
const overlayTrack = clips.length > 0 ? Math.max(...clips.map((c) => c.track)) + 1 : 0;
|
||||
for (const child of root.children) {
|
||||
const el = child as HTMLElement;
|
||||
if (!el.id) continue;
|
||||
if (gsapClipIds.has(el.id)) continue;
|
||||
const timelineRole = el.getAttribute("data-timeline-role");
|
||||
if (timelineRole !== "overlay" && timelineRole !== "persistent-overlay") continue;
|
||||
const tag = el.tagName.toLowerCase();
|
||||
if (tag === "script" || tag === "style" || tag === "link" || tag === "meta") continue;
|
||||
// Skip elements that are invisible (display:none in their CSS class)
|
||||
@@ -500,7 +503,7 @@ export function collectRuntimeTimelinePayload(params: {
|
||||
nodePath: null,
|
||||
compositionSrc: null,
|
||||
assetUrl: null,
|
||||
timelineRole: el.getAttribute("data-timeline-role"),
|
||||
timelineRole,
|
||||
timelineLabel: el.getAttribute("data-timeline-label"),
|
||||
timelineGroup: el.getAttribute("data-timeline-group"),
|
||||
timelinePriority: parseNum(el.getAttribute("data-timeline-priority")),
|
||||
@@ -536,7 +539,18 @@ export function collectRuntimeTimelinePayload(params: {
|
||||
avatarName: null,
|
||||
});
|
||||
}
|
||||
const safeDuration = Math.max(1, Math.min(maxEnd || 1, params.maxTimelineDurationSeconds));
|
||||
// Timeline payload duration should reflect the playable composition window,
|
||||
// not just the furthest currently-surfaced clip. Studio can intentionally
|
||||
// hide structural/background tracks from the timeline UI; if we collapse the
|
||||
// payload duration down to the last visible clip end, the controls jump even
|
||||
// though playback still runs for the full authored root duration.
|
||||
const safeDuration = Math.max(
|
||||
1,
|
||||
Math.min(
|
||||
Math.max(maxEnd || 1, rootCompositionDuration ?? 0),
|
||||
params.maxTimelineDurationSeconds,
|
||||
),
|
||||
);
|
||||
const shouldEmitNonDeterministicInf = timelineLooksLoopInflated && attrDurationCandidate == null;
|
||||
const durationInFrames = shouldEmitNonDeterministicInf
|
||||
? Number.POSITIVE_INFINITY
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { Hono } from "hono";
|
||||
import { mkdtempSync, rmSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { registerThumbnailRoutes } from "./thumbnail";
|
||||
import type { StudioApiAdapter } from "../types";
|
||||
|
||||
const tempProjectDirs: string[] = [];
|
||||
|
||||
afterEach(() => {
|
||||
for (const dir of tempProjectDirs.splice(0)) {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
function createAdapter(): StudioApiAdapter {
|
||||
const projectDir = mkdtempSync(join(tmpdir(), "hf-thumbnail-test-"));
|
||||
tempProjectDirs.push(projectDir);
|
||||
|
||||
return {
|
||||
listProjects: () => [],
|
||||
resolveProject: async (id: string) => ({ id, dir: projectDir }),
|
||||
bundle: async () => null,
|
||||
lint: async () => ({ findings: [] }),
|
||||
runtimeUrl: "/api/runtime.js",
|
||||
rendersDir: () => "/tmp/renders",
|
||||
startRender: () => ({
|
||||
id: "job-1",
|
||||
status: "rendering",
|
||||
progress: 0,
|
||||
outputPath: "/tmp/out.mp4",
|
||||
}),
|
||||
generateThumbnail: vi.fn(async () => Buffer.from("thumb")),
|
||||
};
|
||||
}
|
||||
|
||||
describe("registerThumbnailRoutes", () => {
|
||||
it("forwards selector queries to thumbnail generation", async () => {
|
||||
const adapter = createAdapter();
|
||||
const app = new Hono();
|
||||
registerThumbnailRoutes(app, adapter);
|
||||
|
||||
const response = await app.request(
|
||||
"http://localhost/projects/demo/thumbnail/index.html?t=1.2&selector=%23title-card",
|
||||
);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(adapter.generateThumbnail).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
compPath: "index.html",
|
||||
seekTime: 1.2,
|
||||
selector: "#title-card",
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -3,6 +3,8 @@ 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) {
|
||||
@@ -20,6 +22,7 @@ export function registerThumbnailRoutes(api: Hono, adapter: StudioApiAdapter): v
|
||||
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;
|
||||
@@ -42,7 +45,10 @@ export function registerThumbnailRoutes(api: Hono, adapter: StudioApiAdapter): v
|
||||
|
||||
// Cache
|
||||
const cacheDir = join(project.dir, ".thumbnails");
|
||||
const cacheKey = `${compPath.replace(/\//g, "_")}_${seekTime.toFixed(2)}.jpg`;
|
||||
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)), {
|
||||
@@ -58,6 +64,7 @@ export function registerThumbnailRoutes(api: Hono, adapter: StudioApiAdapter): v
|
||||
width: compW,
|
||||
height: compH,
|
||||
previewUrl,
|
||||
selector,
|
||||
});
|
||||
if (!buffer) {
|
||||
return c.json({ error: "Thumbnail generation returned null" }, 500);
|
||||
|
||||
@@ -71,6 +71,7 @@ export interface StudioApiAdapter {
|
||||
width: number;
|
||||
height: number;
|
||||
previewUrl: string;
|
||||
selector?: string;
|
||||
}) => Promise<Buffer | null>;
|
||||
|
||||
/** Optional: resolve session ID to project (multi-project mode). */
|
||||
|
||||
Reference in New Issue
Block a user