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:
@@ -0,0 +1,11 @@
|
||||
export async function loadRuntimeSourceFallback(): Promise<string | null> {
|
||||
try {
|
||||
const mod = await import("@hyperframes/core");
|
||||
if (typeof mod.loadHyperframeRuntimeSource === "function") {
|
||||
return mod.loadHyperframeRuntimeSource();
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn("[studio] Failed to load runtime source fallback:", err);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { loadHyperframeRuntimeSource } from "@hyperframes/core";
|
||||
import { loadRuntimeSourceFallback } from "./runtimeSource.js";
|
||||
|
||||
describe("loadRuntimeSourceFallback", () => {
|
||||
it("loads runtime source from the published core entrypoint", async () => {
|
||||
await expect(loadRuntimeSourceFallback()).resolves.toBe(loadHyperframeRuntimeSource());
|
||||
});
|
||||
});
|
||||
@@ -10,6 +10,7 @@ import { streamSSE } from "hono/streaming";
|
||||
import { existsSync, readFileSync, writeFileSync, statSync } from "node:fs";
|
||||
import { resolve, join, basename } from "node:path";
|
||||
import { createProjectWatcher, type ProjectWatcher } from "./fileWatcher.js";
|
||||
import { loadRuntimeSourceFallback } from "./runtimeSource.js";
|
||||
import { VERSION as version } from "../version.js";
|
||||
import {
|
||||
createStudioApi,
|
||||
@@ -228,7 +229,31 @@ export function createStudioServer(options: StudioServerOptions): StudioServer {
|
||||
}, opts.seekTime);
|
||||
// Let the seek render settle.
|
||||
await new Promise((r) => setTimeout(r, 200));
|
||||
const screenshot = (await page.screenshot({ type: "jpeg", quality: 80 })) as Buffer;
|
||||
let clip: { x: number; y: number; width: number; height: number } | undefined;
|
||||
if (opts.selector) {
|
||||
clip = await page.evaluate((selector: string) => {
|
||||
const el = document.querySelector(selector);
|
||||
if (!(el instanceof HTMLElement)) return undefined;
|
||||
const rect = el.getBoundingClientRect();
|
||||
if (rect.width < 4 || rect.height < 4) return undefined;
|
||||
const pad = 8;
|
||||
const x = Math.max(0, rect.left - pad);
|
||||
const y = Math.max(0, rect.top - pad);
|
||||
const maxWidth = window.innerWidth - x;
|
||||
const maxHeight = window.innerHeight - y;
|
||||
return {
|
||||
x,
|
||||
y,
|
||||
width: Math.max(1, Math.min(rect.width + pad * 2, maxWidth)),
|
||||
height: Math.max(1, Math.min(rect.height + pad * 2, maxHeight)),
|
||||
};
|
||||
}, opts.selector);
|
||||
}
|
||||
const screenshot = (await page.screenshot({
|
||||
type: "jpeg",
|
||||
quality: 80,
|
||||
...(clip ? { clip } : {}),
|
||||
})) as Buffer;
|
||||
return screenshot;
|
||||
} catch {
|
||||
return null;
|
||||
@@ -256,11 +281,17 @@ export function createStudioServer(options: StudioServerOptions): StudioServer {
|
||||
|
||||
// CLI-specific routes (before shared API)
|
||||
app.get("/api/runtime.js", (c) => {
|
||||
if (!existsSync(runtimePath)) return c.text("runtime not built", 404);
|
||||
return c.body(readFileSync(runtimePath, "utf-8"), 200, {
|
||||
"Content-Type": "text/javascript",
|
||||
"Cache-Control": "no-store",
|
||||
});
|
||||
const serve = async () => {
|
||||
const runtimeSource = existsSync(runtimePath)
|
||||
? readFileSync(runtimePath, "utf-8")
|
||||
: await loadRuntimeSourceFallback();
|
||||
if (!runtimeSource) return c.text("runtime not available", 404);
|
||||
return c.body(runtimeSource, 200, {
|
||||
"Content-Type": "text/javascript",
|
||||
"Cache-Control": "no-store",
|
||||
});
|
||||
};
|
||||
return serve();
|
||||
});
|
||||
|
||||
app.get("/api/events", (c) => {
|
||||
|
||||
@@ -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). */
|
||||
|
||||
@@ -224,6 +224,7 @@ export function StudioApp() {
|
||||
labelColor={style.label}
|
||||
seekTime={0}
|
||||
duration={el.duration}
|
||||
selector={el.selector}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -238,6 +239,7 @@ export function StudioApp() {
|
||||
labelColor={style.label}
|
||||
seekTime={el.start}
|
||||
duration={el.duration}
|
||||
selector={el.selector}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -278,6 +280,7 @@ export function StudioApp() {
|
||||
labelColor={style.label}
|
||||
seekTime={el.start}
|
||||
duration={el.duration}
|
||||
selector={el.selector}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -59,6 +59,7 @@ export const NLELayout = memo(function NLELayout({
|
||||
togglePlay,
|
||||
seek,
|
||||
onIframeLoad: baseOnIframeLoad,
|
||||
refreshPlayer,
|
||||
saveSeekPosition,
|
||||
} = useTimelinePlayer();
|
||||
|
||||
@@ -72,12 +73,13 @@ export const NLELayout = memo(function NLELayout({
|
||||
usePlayerStore.getState().reset();
|
||||
}
|
||||
|
||||
// Preserve seek position when refreshKey changes (iframe will remount via key prop).
|
||||
// Refresh the existing iframe in place when source files change.
|
||||
const prevRefreshKeyRef = useRef(refreshKey);
|
||||
if (refreshKey !== prevRefreshKeyRef.current) {
|
||||
useEffect(() => {
|
||||
if (refreshKey === prevRefreshKeyRef.current) return;
|
||||
prevRefreshKeyRef.current = refreshKey;
|
||||
saveSeekPosition();
|
||||
}
|
||||
refreshPlayer();
|
||||
}, [refreshKey, refreshPlayer]);
|
||||
|
||||
// Wrap onIframeLoad to also notify parent of iframe ref
|
||||
const onIframeLoad = useCallback(() => {
|
||||
@@ -351,12 +353,14 @@ export const NLELayout = memo(function NLELayout({
|
||||
<>
|
||||
{/* Resize divider */}
|
||||
<div
|
||||
className="h-1 flex-shrink-0 bg-neutral-800 hover:bg-studio-accent cursor-row-resize transition-colors active:bg-studio-accent/80 z-10"
|
||||
className="group h-2 flex-shrink-0 cursor-row-resize flex items-center justify-center z-10"
|
||||
style={{ touchAction: "none" }}
|
||||
onPointerDown={handleDividerPointerDown}
|
||||
onPointerMove={handleDividerPointerMove}
|
||||
onPointerUp={handleDividerPointerUp}
|
||||
/>
|
||||
>
|
||||
<div className="h-px w-full bg-white/10 transition-colors group-hover:bg-white/16 group-active:bg-white/22" />
|
||||
</div>
|
||||
|
||||
{/* Timeline section — fixed height, resizable */}
|
||||
<div className="flex flex-col flex-shrink-0" style={{ height: timelineH }}>
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { getPreviewPlayerKey } from "./NLEPreview";
|
||||
|
||||
describe("getPreviewPlayerKey", () => {
|
||||
it("keeps the same player identity when only refreshKey changes", () => {
|
||||
expect(
|
||||
getPreviewPlayerKey({
|
||||
projectId: "timeline-edit-playground",
|
||||
refreshKey: 1,
|
||||
}),
|
||||
).toBe(
|
||||
getPreviewPlayerKey({
|
||||
projectId: "timeline-edit-playground",
|
||||
refreshKey: 2,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("switches identity when drilling into a different directUrl", () => {
|
||||
expect(
|
||||
getPreviewPlayerKey({
|
||||
projectId: "timeline-edit-playground",
|
||||
directUrl: "/api/projects/timeline-edit-playground/preview",
|
||||
}),
|
||||
).not.toBe(
|
||||
getPreviewPlayerKey({
|
||||
projectId: "timeline-edit-playground",
|
||||
directUrl: "/api/projects/timeline-edit-playground/preview/comp/compositions/intro.html",
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -10,6 +10,17 @@ interface NLEPreviewProps {
|
||||
refreshKey?: number;
|
||||
}
|
||||
|
||||
export function getPreviewPlayerKey({
|
||||
projectId,
|
||||
directUrl,
|
||||
}: {
|
||||
projectId: string;
|
||||
directUrl?: string;
|
||||
refreshKey?: number;
|
||||
}): string {
|
||||
return directUrl ?? projectId;
|
||||
}
|
||||
|
||||
export const NLEPreview = memo(function NLEPreview({
|
||||
projectId,
|
||||
iframeRef,
|
||||
@@ -18,7 +29,7 @@ export const NLEPreview = memo(function NLEPreview({
|
||||
directUrl,
|
||||
refreshKey,
|
||||
}: NLEPreviewProps) {
|
||||
const playerKey = `${directUrl ?? projectId}_${refreshKey ?? 0}`;
|
||||
const playerKey = getPreviewPlayerKey({ projectId, directUrl, refreshKey });
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full min-h-0">
|
||||
|
||||
@@ -1,52 +1,129 @@
|
||||
/**
|
||||
* CompositionThumbnail — Single server-rendered JPEG stretched across the clip.
|
||||
*
|
||||
* Takes one screenshot at the midpoint of the clip and covers the full width —
|
||||
* same approach as After Effects for precomps. This avoids the 1-2s per-frame
|
||||
* Puppeteer cost of rendering multiple filmstrip frames.
|
||||
*/
|
||||
|
||||
import { memo } from "react";
|
||||
import { memo, useCallback, useState, useRef } from "react";
|
||||
import { useMountEffect } from "../../hooks/useMountEffect";
|
||||
|
||||
interface CompositionThumbnailProps {
|
||||
previewUrl: string;
|
||||
label: string;
|
||||
labelColor: string;
|
||||
accentColor?: string;
|
||||
selector?: string;
|
||||
seekTime?: number;
|
||||
duration?: number;
|
||||
width?: number;
|
||||
height?: number;
|
||||
}
|
||||
|
||||
const CLIP_HEIGHT = 66;
|
||||
const THUMBNAIL_URL_VERSION = "v2";
|
||||
|
||||
export const CompositionThumbnail = memo(function CompositionThumbnail({
|
||||
previewUrl,
|
||||
label,
|
||||
labelColor,
|
||||
accentColor = "#6B7280",
|
||||
selector,
|
||||
seekTime = 2,
|
||||
duration = 5,
|
||||
}: CompositionThumbnailProps) {
|
||||
// Single screenshot at the midpoint of the clip
|
||||
const [containerWidth, setContainerWidth] = useState(0);
|
||||
const [loaded, setLoaded] = useState(false);
|
||||
const [aspect, setAspect] = useState(16 / 9);
|
||||
const roRef = useRef<ResizeObserver | null>(null);
|
||||
|
||||
const setContainerRef = useCallback((el: HTMLDivElement | null) => {
|
||||
roRef.current?.disconnect();
|
||||
if (!el) return;
|
||||
|
||||
const measured = el.parentElement?.clientWidth || el.clientWidth;
|
||||
setContainerWidth(measured);
|
||||
|
||||
const target = el.parentElement || el;
|
||||
roRef.current = new ResizeObserver(([entry]) => {
|
||||
setContainerWidth(entry.contentRect.width);
|
||||
});
|
||||
roRef.current.observe(target);
|
||||
}, []);
|
||||
|
||||
useMountEffect(() => () => {
|
||||
roRef.current?.disconnect();
|
||||
});
|
||||
|
||||
const thumbnailBase = previewUrl
|
||||
.replace("/preview/comp/", "/thumbnail/")
|
||||
.replace(/\/preview$/, "/thumbnail/index.html");
|
||||
const midTime = seekTime + duration / 2;
|
||||
const url = `${thumbnailBase}?t=${midTime.toFixed(2)}`;
|
||||
const thumbnailUrl = new URL(thumbnailBase, window.location.origin);
|
||||
thumbnailUrl.searchParams.set("t", midTime.toFixed(2));
|
||||
thumbnailUrl.searchParams.set("v", THUMBNAIL_URL_VERSION);
|
||||
if (selector) thumbnailUrl.searchParams.set("selector", selector);
|
||||
const url = thumbnailUrl.toString();
|
||||
const frameW = Math.max(48, Math.round(CLIP_HEIGHT * aspect));
|
||||
const frameCount = containerWidth > 0 ? Math.max(1, Math.ceil(containerWidth / frameW)) : 1;
|
||||
|
||||
return (
|
||||
<div className="absolute inset-0 overflow-hidden bg-neutral-950">
|
||||
<div ref={setContainerRef} className="absolute inset-0 overflow-hidden">
|
||||
<img
|
||||
src={url}
|
||||
alt=""
|
||||
draggable={false}
|
||||
loading="lazy"
|
||||
onLoad={(e) => {
|
||||
(e.target as HTMLImageElement).style.opacity = "1";
|
||||
const img = e.currentTarget;
|
||||
if (img.naturalWidth > 0 && img.naturalHeight > 0) {
|
||||
setAspect(img.naturalWidth / img.naturalHeight);
|
||||
}
|
||||
setLoaded(true);
|
||||
}}
|
||||
className="absolute inset-0 w-full h-full object-cover"
|
||||
style={{ opacity: 0, transition: "opacity 200ms ease-out" }}
|
||||
className="hidden"
|
||||
/>
|
||||
|
||||
{/* Label */}
|
||||
{loaded ? (
|
||||
<div className="absolute inset-0 flex">
|
||||
{Array.from({ length: frameCount }).map((_, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="relative h-full flex-shrink-0 overflow-hidden"
|
||||
style={{ width: frameW }}
|
||||
>
|
||||
<img
|
||||
src={url}
|
||||
alt=""
|
||||
draggable={false}
|
||||
className="absolute inset-0 h-full w-full object-cover opacity-60"
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
className="absolute inset-0 animate-pulse"
|
||||
style={{
|
||||
background:
|
||||
"linear-gradient(90deg, rgba(255,255,255,0.02) 0%, rgba(255,255,255,0.05) 50%, rgba(255,255,255,0.02) 100%)",
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div
|
||||
className="absolute inset-0"
|
||||
style={{
|
||||
background: `linear-gradient(120deg, ${accentColor}2e, transparent 34%), linear-gradient(180deg, rgba(255,255,255,0.02), rgba(0,0,0,0.08))`,
|
||||
}}
|
||||
/>
|
||||
|
||||
<div className="absolute left-2 top-2 z-10">
|
||||
<span
|
||||
className="block max-w-full truncate rounded-md px-1.5 py-0.5 text-[9px] font-semibold uppercase leading-none"
|
||||
style={{
|
||||
color: labelColor,
|
||||
background: `${accentColor}2e`,
|
||||
boxShadow: `inset 0 0 0 1px ${accentColor}40`,
|
||||
}}
|
||||
>
|
||||
{label}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="absolute bottom-0 left-0 right-0 z-10 px-1.5 pb-0.5 pt-3"
|
||||
style={{
|
||||
@@ -55,7 +132,7 @@ export const CompositionThumbnail = memo(function CompositionThumbnail({
|
||||
}}
|
||||
>
|
||||
<span
|
||||
className="text-[9px] font-semibold truncate block leading-tight"
|
||||
className="block truncate text-[9px] font-semibold leading-tight"
|
||||
style={{ color: labelColor, textShadow: "0 1px 2px rgba(0,0,0,0.9)" }}
|
||||
>
|
||||
{label}
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { forwardRef, useRef, useState } from "react";
|
||||
import { useMountEffect } from "../../hooks/useMountEffect";
|
||||
import type { HyperframesPlayer } from "@hyperframes/player";
|
||||
// NOTE: importing "@hyperframes/player" registers a class extending HTMLElement
|
||||
// at module load, which throws under SSR. Defer the import to the mount effect
|
||||
// so it only runs in the browser.
|
||||
@@ -12,6 +11,10 @@ interface PlayerProps {
|
||||
portrait?: boolean;
|
||||
}
|
||||
|
||||
interface HyperframesPlayerElement extends HTMLElement {
|
||||
iframeElement: HTMLIFrameElement;
|
||||
}
|
||||
|
||||
/**
|
||||
* Readiness check for a Lottie animation instance. Duck-types both supported
|
||||
* player shapes:
|
||||
@@ -96,7 +99,7 @@ export const Player = forwardRef<HTMLIFrameElement, PlayerProps>(
|
||||
if (canceled) return;
|
||||
|
||||
// Create the web component imperatively to avoid JSX custom-element typing.
|
||||
const player = document.createElement("hyperframes-player") as HyperframesPlayer;
|
||||
const player = document.createElement("hyperframes-player") as HyperframesPlayerElement;
|
||||
const src = directUrl || `/api/projects/${projectId}/preview`;
|
||||
player.setAttribute("src", src);
|
||||
player.setAttribute("width", String(portrait ? 1080 : 1920));
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { mergeTimelineElementsPreservingDowngrades } from "./useTimelinePlayer";
|
||||
|
||||
describe("mergeTimelineElementsPreservingDowngrades", () => {
|
||||
it("preserves missing current elements when a shorter manifest arrives", () => {
|
||||
expect(
|
||||
mergeTimelineElementsPreservingDowngrades(
|
||||
[
|
||||
{ id: "hero", tag: "div", start: 0, duration: 4, track: 0 },
|
||||
{ id: "cta", tag: "div", start: 4, duration: 2, track: 1 },
|
||||
],
|
||||
[{ id: "hero", tag: "div", start: 0, duration: 4, track: 0 }],
|
||||
8,
|
||||
8,
|
||||
),
|
||||
).toEqual([
|
||||
{ id: "hero", tag: "div", start: 0, duration: 4, track: 0 },
|
||||
{ id: "cta", tag: "div", start: 4, duration: 2, track: 1 },
|
||||
]);
|
||||
});
|
||||
|
||||
it("accepts longer-duration or same-size updates as authoritative", () => {
|
||||
expect(
|
||||
mergeTimelineElementsPreservingDowngrades(
|
||||
[{ id: "hero", tag: "div", start: 0, duration: 4, track: 0 }],
|
||||
[{ id: "hero", tag: "div", start: 0, duration: 4, track: 0 }],
|
||||
4,
|
||||
6,
|
||||
),
|
||||
).toEqual([{ id: "hero", tag: "div", start: 0, duration: 4, track: 0 }]);
|
||||
});
|
||||
});
|
||||
@@ -61,6 +61,50 @@ function wrapTimeline(tl: TimelineLike): PlaybackAdapter {
|
||||
};
|
||||
}
|
||||
|
||||
function resolveMediaElement(el: Element): HTMLMediaElement | HTMLImageElement | null {
|
||||
if (el instanceof HTMLMediaElement || el instanceof HTMLImageElement) return el;
|
||||
const candidate = el.querySelector("video, audio, img");
|
||||
return candidate instanceof HTMLMediaElement || candidate instanceof HTMLImageElement
|
||||
? candidate
|
||||
: null;
|
||||
}
|
||||
|
||||
function applyMediaMetadataFromElement(entry: TimelineElement, el: Element): void {
|
||||
const mediaStartAttr = el.getAttribute("data-playback-start")
|
||||
? "playback-start"
|
||||
: el.getAttribute("data-media-start")
|
||||
? "media-start"
|
||||
: undefined;
|
||||
const mediaStartValue =
|
||||
el.getAttribute("data-playback-start") ?? el.getAttribute("data-media-start");
|
||||
if (mediaStartValue != null) {
|
||||
const playbackStart = parseFloat(mediaStartValue);
|
||||
if (Number.isFinite(playbackStart)) entry.playbackStart = playbackStart;
|
||||
}
|
||||
if (mediaStartAttr) entry.playbackStartAttr = mediaStartAttr;
|
||||
|
||||
const mediaEl = resolveMediaElement(el);
|
||||
if (!mediaEl) return;
|
||||
|
||||
entry.tag = mediaEl.tagName.toLowerCase();
|
||||
const src = mediaEl.getAttribute("src");
|
||||
if (src) entry.src = src;
|
||||
|
||||
if (!(mediaEl instanceof HTMLMediaElement)) return;
|
||||
|
||||
const sourceDurationAttr =
|
||||
el.getAttribute("data-source-duration") ?? mediaEl.getAttribute("data-source-duration");
|
||||
const sourceDuration = sourceDurationAttr ? parseFloat(sourceDurationAttr) : mediaEl.duration;
|
||||
if (Number.isFinite(sourceDuration) && sourceDuration > 0) {
|
||||
entry.sourceDuration = sourceDuration;
|
||||
}
|
||||
|
||||
const playbackRate = mediaEl.defaultPlaybackRate;
|
||||
if (Number.isFinite(playbackRate) && playbackRate > 0) {
|
||||
entry.playbackRate = playbackRate;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse [data-start] elements from a Document into TimelineElement[].
|
||||
* Shared helper — used by onIframeLoad fallback, handleMessage, and enrichMissingCompositions.
|
||||
@@ -78,37 +122,46 @@ function parseTimelineFromDOM(doc: Document, rootDuration: number): TimelineElem
|
||||
if (startStr == null) return;
|
||||
const start = parseFloat(startStr);
|
||||
if (isNaN(start)) return;
|
||||
if (Number.isFinite(rootDuration) && rootDuration > 0 && start >= rootDuration) return;
|
||||
|
||||
const tagLower = el.tagName.toLowerCase();
|
||||
let dur = 0;
|
||||
const durStr = el.getAttribute("data-duration");
|
||||
if (durStr != null) dur = parseFloat(durStr);
|
||||
if (isNaN(dur) || dur <= 0) dur = Math.max(0, rootDuration - start);
|
||||
if (Number.isFinite(rootDuration) && rootDuration > 0) {
|
||||
dur = Math.min(dur, Math.max(0, rootDuration - start));
|
||||
}
|
||||
if (!Number.isFinite(dur) || dur <= 0) return;
|
||||
|
||||
const trackStr = el.getAttribute("data-track-index");
|
||||
const track = trackStr != null ? parseInt(trackStr, 10) : trackCounter++;
|
||||
const compId = el.getAttribute("data-composition-id");
|
||||
const entry: TimelineElement = {
|
||||
id: el.id || el.className?.split(" ")[0] || tagLower,
|
||||
id: el.id || compId || el.className?.split(" ")[0] || tagLower,
|
||||
tag: tagLower,
|
||||
start,
|
||||
duration: dur,
|
||||
track: isNaN(track) ? 0 : track,
|
||||
selector: getTimelineElementSelector(el),
|
||||
sourceFile: getTimelineElementSourceFile(el),
|
||||
};
|
||||
|
||||
// Media elements
|
||||
if (tagLower === "video" || tagLower === "audio" || tagLower === "img") {
|
||||
const src = el.getAttribute("src");
|
||||
const mediaEl = resolveMediaElement(el);
|
||||
if (mediaEl) {
|
||||
if (mediaEl.tagName === "IMG") {
|
||||
entry.tag = "img";
|
||||
}
|
||||
const src = mediaEl.getAttribute("src");
|
||||
if (src) entry.src = src;
|
||||
const ms = el.getAttribute("data-media-start");
|
||||
if (ms) entry.playbackStart = parseFloat(ms);
|
||||
const vol = el.getAttribute("data-volume");
|
||||
const vol = el.getAttribute("data-volume") ?? mediaEl.getAttribute("data-volume");
|
||||
if (vol) entry.volume = parseFloat(vol);
|
||||
applyMediaMetadataFromElement(entry, el);
|
||||
}
|
||||
|
||||
// Sub-compositions
|
||||
const compSrc =
|
||||
el.getAttribute("data-composition-src") || el.getAttribute("data-composition-file");
|
||||
const compId = el.getAttribute("data-composition-id");
|
||||
if (compSrc) {
|
||||
entry.compositionSrc = compSrc;
|
||||
} else if (compId && compId !== rootComp?.getAttribute("data-composition-id")) {
|
||||
@@ -126,6 +179,35 @@ function parseTimelineFromDOM(doc: Document, rootDuration: number): TimelineElem
|
||||
return els;
|
||||
}
|
||||
|
||||
function getTimelineElementSelector(el: Element): string | undefined {
|
||||
if (el instanceof HTMLElement && el.id) return `#${el.id}`;
|
||||
const compId = el.getAttribute("data-composition-id");
|
||||
if (compId) return `[data-composition-id="${compId}"]`;
|
||||
if (el instanceof HTMLElement) {
|
||||
const firstClass = el.className.split(/\s+/).find(Boolean);
|
||||
if (firstClass) return `.${firstClass}`;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function getTimelineElementSourceFile(el: Element): string | undefined {
|
||||
const ownerRoot = el.parentElement?.closest("[data-composition-id]");
|
||||
return (
|
||||
ownerRoot?.getAttribute("data-composition-file") ??
|
||||
ownerRoot?.getAttribute("data-composition-src") ??
|
||||
undefined
|
||||
);
|
||||
}
|
||||
|
||||
function findTimelineDomNode(doc: Document, id: string): Element | null {
|
||||
return (
|
||||
doc.getElementById(id) ??
|
||||
doc.querySelector(`[data-composition-id="${id}"]`) ??
|
||||
doc.querySelector(`.${id}`) ??
|
||||
null
|
||||
);
|
||||
}
|
||||
|
||||
function normalizePreviewViewport(doc: Document, win: Window): void {
|
||||
if (doc.documentElement) {
|
||||
doc.documentElement.style.overflow = "hidden";
|
||||
@@ -212,6 +294,29 @@ export function resolveIframe(el: Element | null): HTMLIFrameElement | null {
|
||||
return el.shadowRoot?.querySelector("iframe") ?? el.querySelector("iframe") ?? null;
|
||||
}
|
||||
|
||||
export function mergeTimelineElementsPreservingDowngrades(
|
||||
currentElements: TimelineElement[],
|
||||
nextElements: TimelineElement[],
|
||||
currentDuration: number,
|
||||
nextDuration: number,
|
||||
): TimelineElement[] {
|
||||
const safeCurrentDuration = Number.isFinite(currentDuration) ? currentDuration : 0;
|
||||
const safeNextDuration = Number.isFinite(nextDuration) ? nextDuration : 0;
|
||||
|
||||
if (
|
||||
currentElements.length === 0 ||
|
||||
nextElements.length >= currentElements.length ||
|
||||
safeNextDuration > safeCurrentDuration
|
||||
) {
|
||||
return nextElements;
|
||||
}
|
||||
|
||||
const nextIds = new Set(nextElements.map((element) => element.id));
|
||||
const preserved = currentElements.filter((element) => !nextIds.has(element.id));
|
||||
if (preserved.length === 0) return nextElements;
|
||||
return [...nextElements, ...preserved];
|
||||
}
|
||||
|
||||
export function useTimelinePlayer() {
|
||||
const iframeRef = useRef<HTMLIFrameElement | null>(null);
|
||||
const rafRef = useRef<number>(0);
|
||||
@@ -224,6 +329,24 @@ export function useTimelinePlayer() {
|
||||
const { setIsPlaying, setCurrentTime, setDuration, setTimelineReady, setElements } =
|
||||
usePlayerStore.getState();
|
||||
|
||||
const syncTimelineElements = useCallback(
|
||||
(elements: TimelineElement[], nextDuration?: number) => {
|
||||
const state = usePlayerStore.getState();
|
||||
const mergedElements = mergeTimelineElementsPreservingDowngrades(
|
||||
state.elements,
|
||||
elements,
|
||||
state.duration,
|
||||
nextDuration ?? state.duration,
|
||||
);
|
||||
setElements(mergedElements);
|
||||
if (Number.isFinite(nextDuration) && (nextDuration ?? 0) > 0) {
|
||||
setDuration(nextDuration ?? 0);
|
||||
}
|
||||
setTimelineReady(true);
|
||||
},
|
||||
[setElements, setTimelineReady, setDuration],
|
||||
);
|
||||
|
||||
const getAdapter = useCallback((): PlaybackAdapter | null => {
|
||||
try {
|
||||
const iframe = iframeRef.current;
|
||||
@@ -364,6 +487,7 @@ export function useTimelinePlayer() {
|
||||
(clip) => !clip.parentCompositionId || !clipCompositionIds.has(clip.parentCompositionId),
|
||||
);
|
||||
const els: TimelineElement[] = filtered.map((clip) => {
|
||||
let hostEl: Element | null = null;
|
||||
const entry: TimelineElement = {
|
||||
id: clip.id || clip.label || clip.tagName || "element",
|
||||
tag: clip.tagName || clip.kind,
|
||||
@@ -371,6 +495,19 @@ export function useTimelinePlayer() {
|
||||
duration: clip.duration,
|
||||
track: clip.track,
|
||||
};
|
||||
try {
|
||||
const iframeDoc = iframeRef.current?.contentDocument;
|
||||
if (iframeDoc && entry.id) {
|
||||
hostEl = findTimelineDomNode(iframeDoc, entry.id);
|
||||
}
|
||||
} catch {
|
||||
/* cross-origin */
|
||||
}
|
||||
if (hostEl) {
|
||||
entry.selector = getTimelineElementSelector(hostEl);
|
||||
entry.sourceFile = getTimelineElementSourceFile(hostEl);
|
||||
applyMediaMetadataFromElement(entry, hostEl);
|
||||
}
|
||||
if (clip.assetUrl) entry.src = clip.assetUrl;
|
||||
if (clip.kind === "composition" && clip.compositionId) {
|
||||
// The bundler renames data-composition-src to data-composition-file
|
||||
@@ -382,7 +519,7 @@ export function useTimelinePlayer() {
|
||||
try {
|
||||
const iframeDoc = iframeRef.current?.contentDocument;
|
||||
hostEl =
|
||||
iframeDoc?.querySelector(`[data-composition-id="${clip.compositionId}"]`) ?? null;
|
||||
iframeDoc?.querySelector(`[data-composition-id="${clip.compositionId}"]`) ?? hostEl;
|
||||
resolvedSrc =
|
||||
hostEl?.getAttribute("data-composition-src") ??
|
||||
hostEl?.getAttribute("data-composition-file") ??
|
||||
@@ -401,32 +538,34 @@ export function useTimelinePlayer() {
|
||||
entry.tag = "video";
|
||||
}
|
||||
}
|
||||
if (hostEl) {
|
||||
entry.selector = getTimelineElementSelector(hostEl);
|
||||
entry.sourceFile = getTimelineElementSourceFile(hostEl);
|
||||
}
|
||||
}
|
||||
return entry;
|
||||
});
|
||||
// Don't downgrade: if we already have more elements with a longer duration,
|
||||
// skip updates that would show fewer clips (transient runtime state).
|
||||
const currentElements = usePlayerStore.getState().elements;
|
||||
const currentDuration = usePlayerStore.getState().duration;
|
||||
const rawDuration = data.durationInFrames / 30;
|
||||
// Clamp non-finite or absurdly large durations — the runtime can emit
|
||||
// Infinity when it detects a loop-inflated GSAP timeline without an
|
||||
// explicit data-duration on the root composition.
|
||||
const newDuration = Number.isFinite(rawDuration) && rawDuration < 7200 ? rawDuration : 0;
|
||||
if (currentElements.length > els.length && newDuration <= currentDuration) {
|
||||
return; // skip transient downgrade
|
||||
const effectiveDuration = newDuration > 0 ? newDuration : usePlayerStore.getState().duration;
|
||||
const clampedEls =
|
||||
effectiveDuration > 0
|
||||
? els
|
||||
.filter((element) => element.start < effectiveDuration)
|
||||
.map((element) => ({
|
||||
...element,
|
||||
duration: Math.min(element.duration, effectiveDuration - element.start),
|
||||
}))
|
||||
.filter((element) => element.duration > 0)
|
||||
: els;
|
||||
if (clampedEls.length > 0) {
|
||||
syncTimelineElements(clampedEls, newDuration > 0 ? newDuration : undefined);
|
||||
}
|
||||
setElements(els);
|
||||
// Ensure duration covers the furthest clip end so fit-zoom shows everything
|
||||
if (els.length > 0) {
|
||||
const maxEnd = Math.max(...els.map((e) => e.start + e.duration));
|
||||
const effectiveDur = Math.max(newDuration, maxEnd);
|
||||
if (Number.isFinite(effectiveDur) && effectiveDur > currentDuration)
|
||||
setDuration(effectiveDur);
|
||||
}
|
||||
if (els.length > 0) setTimelineReady(true);
|
||||
},
|
||||
[setElements, setTimelineReady, setDuration],
|
||||
[syncTimelineElements],
|
||||
);
|
||||
|
||||
/**
|
||||
@@ -504,6 +643,12 @@ export function useTimelinePlayer() {
|
||||
}
|
||||
if (!Number.isFinite(dur) || dur <= 0) return;
|
||||
if (!Number.isFinite(start)) start = 0;
|
||||
const rootDuration = usePlayerStore.getState().duration;
|
||||
if (Number.isFinite(rootDuration) && rootDuration > 0) {
|
||||
if (start >= rootDuration) return;
|
||||
dur = Math.min(dur, Math.max(0, rootDuration - start));
|
||||
if (dur <= 0) return;
|
||||
}
|
||||
|
||||
const trackStr = el.getAttribute("data-track-index");
|
||||
const track = trackStr != null ? parseInt(trackStr, 10) : 0;
|
||||
@@ -515,6 +660,8 @@ export function useTimelinePlayer() {
|
||||
start,
|
||||
duration: dur,
|
||||
track: isNaN(track) ? 0 : track,
|
||||
selector: getTimelineElementSelector(el),
|
||||
sourceFile: getTimelineElementSourceFile(el),
|
||||
};
|
||||
if (compSrc) {
|
||||
entry.compositionSrc = compSrc;
|
||||
@@ -551,13 +698,12 @@ export function useTimelinePlayer() {
|
||||
// Dedup: ensure no missing element duplicates an existing one
|
||||
const finalIds = new Set(updatedEls.map((e) => e.id));
|
||||
const dedupedMissing = missing.filter((m) => !finalIds.has(m.id));
|
||||
setElements([...updatedEls, ...dedupedMissing]);
|
||||
setTimelineReady(true);
|
||||
syncTimelineElements([...updatedEls, ...dedupedMissing]);
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn("[useTimelinePlayer] enrichMissingCompositions failed", err);
|
||||
}
|
||||
}, [setElements, setTimelineReady]);
|
||||
}, [syncTimelineElements]);
|
||||
|
||||
const onIframeLoad = useCallback(() => {
|
||||
unmutePreviewMedia(iframeRef.current);
|
||||
@@ -613,8 +759,7 @@ export function useTimelinePlayer() {
|
||||
// Fallback: parse data-start elements directly from DOM (raw HTML without runtime)
|
||||
const els = parseTimelineFromDOM(doc, adapter.getDuration());
|
||||
if (els.length > 0) {
|
||||
setElements(els);
|
||||
setTimelineReady(true);
|
||||
syncTimelineElements(els);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -636,7 +781,7 @@ export function useTimelinePlayer() {
|
||||
: undefined;
|
||||
// Always show the root composition as a single clip — guarantees
|
||||
// the timeline is never empty when a valid composition is loaded.
|
||||
setElements([
|
||||
syncTimelineElements([
|
||||
{
|
||||
id: rootId,
|
||||
tag: (rootComp as HTMLElement).tagName?.toLowerCase() || "div",
|
||||
@@ -646,7 +791,6 @@ export function useTimelinePlayer() {
|
||||
compositionSrc,
|
||||
},
|
||||
]);
|
||||
setTimelineReady(true);
|
||||
}
|
||||
}
|
||||
// The runtime will also postMessage the full timeline after all compositions load.
|
||||
@@ -672,6 +816,7 @@ export function useTimelinePlayer() {
|
||||
setIsPlaying,
|
||||
processTimelineMessage,
|
||||
enrichMissingCompositions,
|
||||
syncTimelineElements,
|
||||
]);
|
||||
|
||||
/** Save the current playback time so the next onIframeLoad restores it. */
|
||||
@@ -745,12 +890,12 @@ export function useTimelinePlayer() {
|
||||
processTimelineMessageRef.current(data);
|
||||
// Fill in composition hosts the manifest missed (element-reference starts)
|
||||
enrichMissingCompositionsRef.current();
|
||||
// Update duration only if the new value is longer (don't downgrade during generation)
|
||||
if (data.durationInFrames > 0 && Number.isFinite(data.durationInFrames)) {
|
||||
const fps = 30;
|
||||
const dur = data.durationInFrames / fps;
|
||||
const currentDur = usePlayerStore.getState().duration;
|
||||
if (dur > currentDur) usePlayerStore.getState().setDuration(dur);
|
||||
if (dur > 0 && dur < 7200) {
|
||||
usePlayerStore.getState().setDuration(dur);
|
||||
}
|
||||
}
|
||||
// If manifest produced 0 elements after filtering, try DOM fallback
|
||||
if (usePlayerStore.getState().elements.length === 0) {
|
||||
@@ -760,8 +905,7 @@ export function useTimelinePlayer() {
|
||||
if (doc && adapter) {
|
||||
const els = parseTimelineFromDOM(doc, adapter.getDuration());
|
||||
if (els.length > 0) {
|
||||
setElements(els);
|
||||
setTimelineReady(true);
|
||||
syncTimelineElements(els);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
|
||||
@@ -6,8 +6,15 @@ export interface TimelineElement {
|
||||
start: number;
|
||||
duration: number;
|
||||
track: number;
|
||||
/** Best-effort selector used when patching source HTML back from timeline edits */
|
||||
selector?: string;
|
||||
/** Source composition file that owns this element, when known */
|
||||
sourceFile?: string;
|
||||
src?: string;
|
||||
playbackStart?: number;
|
||||
playbackStartAttr?: "media-start" | "playback-start";
|
||||
playbackRate?: number;
|
||||
sourceDuration?: number;
|
||||
volume?: number;
|
||||
/** Path from data-composition-src — identifies sub-composition elements */
|
||||
compositionSrc?: string;
|
||||
@@ -37,7 +44,7 @@ interface PlayerState {
|
||||
setSelectedElementId: (id: string | null) => void;
|
||||
updateElement: (
|
||||
elementId: string,
|
||||
updates: Partial<Pick<TimelineElement, "start" | "duration" | "track">>,
|
||||
updates: Partial<Pick<TimelineElement, "start" | "duration" | "track" | "playbackStart">>,
|
||||
) => void;
|
||||
setZoomMode: (mode: ZoomMode) => void;
|
||||
setPixelsPerSecond: (pps: number) => void;
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
declare module "@hyperframes/player";
|
||||
@@ -44,6 +44,7 @@ async function getSharedBrowser(): Promise<import("puppeteer-core").Browser | nu
|
||||
|
||||
// In-flight thumbnail dedup
|
||||
const _thumbnailInflight = new Map<string, Promise<Buffer>>();
|
||||
const THUMBNAIL_CACHE_VERSION = "v2";
|
||||
|
||||
// ── Vite adapter for the shared studio API ───────────────────────────────────
|
||||
|
||||
@@ -205,7 +206,10 @@ function createViteAdapter(dataDir: string, server: ViteDevServer): StudioApiAda
|
||||
},
|
||||
|
||||
async generateThumbnail(opts) {
|
||||
const cacheKey = `${opts.compPath.replace(/\//g, "_")}_${opts.seekTime.toFixed(2)}.jpg`;
|
||||
const selectorKey = opts.selector
|
||||
? `_${opts.selector.replace(/[^a-zA-Z0-9_-]+/g, "_").slice(0, 80)}`
|
||||
: "";
|
||||
const cacheKey = `${THUMBNAIL_CACHE_VERSION}_${opts.compPath.replace(/\//g, "_")}_${opts.seekTime.toFixed(2)}${selectorKey}.jpg`;
|
||||
|
||||
let bufferPromise = _thumbnailInflight.get(cacheKey);
|
||||
if (!bufferPromise) {
|
||||
@@ -253,7 +257,31 @@ function createViteAdapter(dataDir: string, server: ViteDevServer): StudioApiAda
|
||||
}, opts.seekTime);
|
||||
await page.evaluate("document.fonts?.ready");
|
||||
await new Promise((r) => setTimeout(r, 200));
|
||||
const buf = await page.screenshot({ type: "jpeg", quality: 75 });
|
||||
let clip: { x: number; y: number; width: number; height: number } | undefined;
|
||||
if (opts.selector) {
|
||||
clip = await page.evaluate((selector: string) => {
|
||||
const el = document.querySelector(selector);
|
||||
if (!(el instanceof HTMLElement)) return undefined;
|
||||
const rect = el.getBoundingClientRect();
|
||||
if (rect.width < 4 || rect.height < 4) return undefined;
|
||||
const pad = 8;
|
||||
const x = Math.max(0, rect.left - pad);
|
||||
const y = Math.max(0, rect.top - pad);
|
||||
const maxWidth = window.innerWidth - x;
|
||||
const maxHeight = window.innerHeight - y;
|
||||
return {
|
||||
x,
|
||||
y,
|
||||
width: Math.max(1, Math.min(rect.width + pad * 2, maxWidth)),
|
||||
height: Math.max(1, Math.min(rect.height + pad * 2, maxHeight)),
|
||||
};
|
||||
}, opts.selector);
|
||||
}
|
||||
const buf = await page.screenshot({
|
||||
type: "jpeg",
|
||||
quality: 75,
|
||||
...(clip ? { clip } : {}),
|
||||
});
|
||||
await page.close();
|
||||
return buf as Buffer;
|
||||
})();
|
||||
@@ -278,6 +306,20 @@ function createViteAdapter(dataDir: string, server: ViteDevServer): StudioApiAda
|
||||
};
|
||||
}
|
||||
|
||||
async function loadRuntimeSourceForDev(server: ViteDevServer): Promise<string | null> {
|
||||
try {
|
||||
const mod = await server.ssrLoadModule(
|
||||
resolve(__dirname, "../core/src/inline-scripts/hyperframe.ts"),
|
||||
);
|
||||
if (typeof mod.loadHyperframeRuntimeSource === "function") {
|
||||
return mod.loadHyperframeRuntimeSource();
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn("[Studio] Failed to load runtime source from core:", err);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// ── Bridge Hono fetch → Node http response ───────────────────────────────────
|
||||
|
||||
async function bridgeHonoResponse(
|
||||
@@ -332,16 +374,34 @@ function devProjectApi(): Plugin {
|
||||
const runtimePath = resolve(__dirname, "../core/dist/hyperframe.runtime.iife.js");
|
||||
server.middlewares.use((req, res, next) => {
|
||||
if (req.url !== "/api/runtime.js") return next();
|
||||
if (!existsSync(runtimePath)) {
|
||||
res.writeHead(404);
|
||||
res.end("runtime not built — run pnpm build in packages/core");
|
||||
return;
|
||||
}
|
||||
res.writeHead(200, {
|
||||
"Content-Type": "text/javascript",
|
||||
"Cache-Control": "no-store",
|
||||
const serve = async () => {
|
||||
let runtimeSource: string | null = null;
|
||||
if (existsSync(runtimePath)) {
|
||||
runtimeSource = readFileSync(runtimePath, "utf-8");
|
||||
} else {
|
||||
runtimeSource = await loadRuntimeSourceForDev(server);
|
||||
}
|
||||
|
||||
if (!runtimeSource) {
|
||||
res.writeHead(404);
|
||||
res.end("runtime not available — build packages/core or load runtime source");
|
||||
return;
|
||||
}
|
||||
|
||||
res.writeHead(200, {
|
||||
"Content-Type": "text/javascript",
|
||||
"Cache-Control": "no-store",
|
||||
});
|
||||
res.end(runtimeSource);
|
||||
};
|
||||
|
||||
void serve().catch((err) => {
|
||||
console.error("[Studio runtime] Failed to serve runtime", err);
|
||||
if (!res.headersSent) {
|
||||
res.writeHead(500);
|
||||
res.end("failed to serve runtime");
|
||||
}
|
||||
});
|
||||
res.end(readFileSync(runtimePath, "utf-8"));
|
||||
});
|
||||
|
||||
server.middlewares.use(async (req, res, next) => {
|
||||
@@ -420,6 +480,11 @@ function devProjectApi(): Plugin {
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react(), devProjectApi()],
|
||||
resolve: {
|
||||
alias: {
|
||||
"@hyperframes/player": resolve(__dirname, "../player/src/hyperframes-player.ts"),
|
||||
},
|
||||
},
|
||||
build: {
|
||||
outDir: "dist",
|
||||
emptyOutDir: true,
|
||||
|
||||
Reference in New Issue
Block a user