From e966e4aa665dc26425e906448159df520948e355 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Tue, 21 Apr 2026 15:49:25 -0400 Subject: [PATCH 1/6] fix: stabilize studio preview and runtime sync --- packages/cli/src/server/studioServer.ts | 66 ++++++- packages/core/src/runtime/timeline.test.ts | 47 ++++- packages/core/src/runtime/timeline.ts | 24 ++- .../src/studio-api/routes/thumbnail.test.ts | 43 ++++ .../core/src/studio-api/routes/thumbnail.ts | 9 +- packages/core/src/studio-api/types.ts | 1 + .../studio/src/components/nle/NLELayout.tsx | 16 +- .../src/components/nle/NLEPreview.test.ts | 32 +++ .../studio/src/components/nle/NLEPreview.tsx | 13 +- .../components/CompositionThumbnail.tsx | 111 +++++++++-- .../studio/src/player/components/Player.tsx | 7 +- .../src/player/hooks/useTimelinePlayer.ts | 185 ++++++++++++++---- .../studio/src/player/store/playerStore.ts | 9 +- packages/studio/vite.config.ts | 87 ++++++-- 14 files changed, 564 insertions(+), 86 deletions(-) create mode 100644 packages/core/src/studio-api/routes/thumbnail.test.ts create mode 100644 packages/studio/src/components/nle/NLEPreview.test.ts diff --git a/packages/cli/src/server/studioServer.ts b/packages/cli/src/server/studioServer.ts index 9ea8a4670..72416a779 100644 --- a/packages/cli/src/server/studioServer.ts +++ b/packages/cli/src/server/studioServer.ts @@ -9,6 +9,7 @@ import { Hono } from "hono"; import { streamSSE } from "hono/streaming"; import { existsSync, readFileSync, writeFileSync, statSync } from "node:fs"; import { resolve, join, basename } from "node:path"; +import { pathToFileURL } from "node:url"; import { createProjectWatcher, type ProjectWatcher } from "./fileWatcher.js"; import { VERSION as version } from "../version.js"; import { @@ -45,6 +46,29 @@ function resolveRuntimePath(): string { return builtPath; } +async function loadRuntimeSourceFallback(): Promise { + try { + const sourceModulePath = resolve( + __dirname, + "..", + "..", + "..", + "core", + "src", + "inline-scripts", + "hyperframe.ts", + ); + if (!existsSync(sourceModulePath)) return null; + const mod = await import(pathToFileURL(sourceModulePath).href); + if (typeof mod.loadHyperframeRuntimeSource === "function") { + return mod.loadHyperframeRuntimeSource(); + } + } catch (err) { + console.warn("[studio] Failed to load runtime source fallback:", err); + } + return null; +} + // ── Shared thumbnail browser (singleton per process) ──────────────────────── // One browser instance is reused across all composition thumbnail requests. // Spawning a new Puppeteer process per request adds 2-5s overhead and causes @@ -228,7 +252,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 +304,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) => { diff --git a/packages/core/src/runtime/timeline.test.ts b/packages/core/src/runtime/timeline.test.ts index 684aab74a..2381568a3 100644 --- a/packages/core/src/runtime/timeline.test.ts +++ b/packages/core/src/runtime/timeline.test.ts @@ -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,7 @@ 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"); @@ -411,6 +427,7 @@ describe("collectRuntimeTimelinePayload", () => { const overlay = document.createElement("div"); overlay.id = "grid-overlay"; + overlay.setAttribute("data-timeline-role", "overlay"); root.appendChild(overlay); (window as any).__timelines = { @@ -434,6 +451,34 @@ describe("collectRuntimeTimelinePayload", () => { 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"); + document.body.appendChild(root); + + const overlay = document.createElement("div"); + overlay.id = "grid-overlay"; + root.appendChild(overlay); + + (window as any).__timelines = { + main: { + duration: () => 12, + time: () => 0, + play: () => {}, + pause: () => {}, + seek: () => {}, + add: () => {}, + paused: () => {}, + set: () => {}, + getChildren: () => [], + }, + }; + + const result = collectRuntimeTimelinePayload(defaultParams); + expect(result.clips.find((c) => c.id === "grid-overlay")).toBeUndefined(); + }); + it("does not include script/style elements as persistent overlays", () => { const root = document.createElement("div"); root.setAttribute("data-composition-id", "main"); diff --git a/packages/core/src/runtime/timeline.ts b/packages/core/src/runtime/timeline.ts index b8860d08a..1262d58cc 100644 --- a/packages/core/src/runtime/timeline.ts +++ b/packages/core/src/runtime/timeline.ts @@ -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 diff --git a/packages/core/src/studio-api/routes/thumbnail.test.ts b/packages/core/src/studio-api/routes/thumbnail.test.ts new file mode 100644 index 000000000..5b06a1053 --- /dev/null +++ b/packages/core/src/studio-api/routes/thumbnail.test.ts @@ -0,0 +1,43 @@ +import { describe, expect, it, vi } from "vitest"; +import { Hono } from "hono"; +import { registerThumbnailRoutes } from "./thumbnail"; +import type { StudioApiAdapter } from "../types"; + +function createAdapter(): StudioApiAdapter { + return { + listProjects: () => [], + resolveProject: async (id: string) => ({ id, dir: "/tmp/project" }), + 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", + }), + ); + }); +}); diff --git a/packages/core/src/studio-api/routes/thumbnail.ts b/packages/core/src/studio-api/routes/thumbnail.ts index 6b757c0a9..c5107fde6 100644 --- a/packages/core/src/studio-api/routes/thumbnail.ts +++ b/packages/core/src/studio-api/routes/thumbnail.ts @@ -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); diff --git a/packages/core/src/studio-api/types.ts b/packages/core/src/studio-api/types.ts index d186358c0..73364e8c7 100644 --- a/packages/core/src/studio-api/types.ts +++ b/packages/core/src/studio-api/types.ts @@ -71,6 +71,7 @@ export interface StudioApiAdapter { width: number; height: number; previewUrl: string; + selector?: string; }) => Promise; /** Optional: resolve session ID to project (multi-project mode). */ diff --git a/packages/studio/src/components/nle/NLELayout.tsx b/packages/studio/src/components/nle/NLELayout.tsx index 9b46ea1a1..3e9bcf226 100644 --- a/packages/studio/src/components/nle/NLELayout.tsx +++ b/packages/studio/src/components/nle/NLELayout.tsx @@ -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 */}
+ > +
+
{/* Timeline section — fixed height, resizable */}
diff --git a/packages/studio/src/components/nle/NLEPreview.test.ts b/packages/studio/src/components/nle/NLEPreview.test.ts new file mode 100644 index 000000000..4451458ee --- /dev/null +++ b/packages/studio/src/components/nle/NLEPreview.test.ts @@ -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", + }), + ); + }); +}); diff --git a/packages/studio/src/components/nle/NLEPreview.tsx b/packages/studio/src/components/nle/NLEPreview.tsx index c0722909a..ce08b8834 100644 --- a/packages/studio/src/components/nle/NLEPreview.tsx +++ b/packages/studio/src/components/nle/NLEPreview.tsx @@ -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 (
diff --git a/packages/studio/src/player/components/CompositionThumbnail.tsx b/packages/studio/src/player/components/CompositionThumbnail.tsx index e558b1b90..6fdcecc30 100644 --- a/packages/studio/src/player/components/CompositionThumbnail.tsx +++ b/packages/studio/src/player/components/CompositionThumbnail.tsx @@ -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(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 ( -
+
{ - (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 ? ( +
+ {Array.from({ length: frameCount }).map((_, i) => ( +
+ +
+ ))} +
+ ) : ( +
+ )} + +
+ +
+ + {label} + +
+
{label} diff --git a/packages/studio/src/player/components/Player.tsx b/packages/studio/src/player/components/Player.tsx index 7229e67c5..cda592bdd 100644 --- a/packages/studio/src/player/components/Player.tsx +++ b/packages/studio/src/player/components/Player.tsx @@ -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( 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)); diff --git a/packages/studio/src/player/hooks/useTimelinePlayer.ts b/packages/studio/src/player/hooks/useTimelinePlayer.ts index 63a1474f5..d15dd6128 100644 --- a/packages/studio/src/player/hooks/useTimelinePlayer.ts +++ b/packages/studio/src/player/hooks/useTimelinePlayer.ts @@ -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"; @@ -224,6 +306,14 @@ export function useTimelinePlayer() { const { setIsPlaying, setCurrentTime, setDuration, setTimelineReady, setElements } = usePlayerStore.getState(); + const syncTimelineElements = useCallback( + (elements: TimelineElement[]) => { + setElements(elements); + setTimelineReady(true); + }, + [setElements, setTimelineReady], + ); + const getAdapter = useCallback((): PlaybackAdapter | null => { try { const iframe = iframeRef.current; @@ -364,6 +454,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 +462,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 +486,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,30 +505,36 @@ 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; + setElements(clampedEls); + if (newDuration > 0) { + setDuration(newDuration); } - 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 (clampedEls.length > 0) { + setTimelineReady(true); } - if (els.length > 0) setTimelineReady(true); }, [setElements, setTimelineReady, setDuration], ); @@ -504,6 +614,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 +631,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 +669,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 +730,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 +752,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 +762,6 @@ export function useTimelinePlayer() { compositionSrc, }, ]); - setTimelineReady(true); } } // The runtime will also postMessage the full timeline after all compositions load. @@ -672,6 +787,7 @@ export function useTimelinePlayer() { setIsPlaying, processTimelineMessage, enrichMissingCompositions, + syncTimelineElements, ]); /** Save the current playback time so the next onIframeLoad restores it. */ @@ -745,12 +861,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 +876,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) { diff --git a/packages/studio/src/player/store/playerStore.ts b/packages/studio/src/player/store/playerStore.ts index 8b5be919d..9b4d3566e 100644 --- a/packages/studio/src/player/store/playerStore.ts +++ b/packages/studio/src/player/store/playerStore.ts @@ -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>, + updates: Partial>, ) => void; setZoomMode: (mode: ZoomMode) => void; setPixelsPerSecond: (pps: number) => void; diff --git a/packages/studio/vite.config.ts b/packages/studio/vite.config.ts index 85eee3dd1..6da4dcf0f 100644 --- a/packages/studio/vite.config.ts +++ b/packages/studio/vite.config.ts @@ -44,6 +44,7 @@ async function getSharedBrowser(): Promise>(); +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 { + 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, From 4095d6d3dfb82c26792138d781e7a16d79cf1fd5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Tue, 21 Apr 2026 15:55:04 -0400 Subject: [PATCH 2/6] fix: declare studio player module for typecheck --- packages/studio/src/types/hyperframes-player.d.ts | 1 + 1 file changed, 1 insertion(+) create mode 100644 packages/studio/src/types/hyperframes-player.d.ts diff --git a/packages/studio/src/types/hyperframes-player.d.ts b/packages/studio/src/types/hyperframes-player.d.ts new file mode 100644 index 000000000..97cf726e5 --- /dev/null +++ b/packages/studio/src/types/hyperframes-player.d.ts @@ -0,0 +1 @@ +declare module "@hyperframes/player"; From 5113537743d145c296c02154f77aba3ea954d481 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Tue, 21 Apr 2026 17:23:56 -0400 Subject: [PATCH 3/6] fix: pass selector through timeline thumbnails --- packages/studio/src/App.tsx | 3 +++ 1 file changed, 3 insertions(+) diff --git a/packages/studio/src/App.tsx b/packages/studio/src/App.tsx index 18ad5367d..6693261b8 100644 --- a/packages/studio/src/App.tsx +++ b/packages/studio/src/App.tsx @@ -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} /> ); } From 8c2b0d90a10427500ef7431b618c4b8cb166d87a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Tue, 21 Apr 2026 18:50:18 -0400 Subject: [PATCH 4/6] fix: preserve timeline state across partial refreshes --- packages/cli/src/server/studioServer.test.ts | 9 ++++ packages/cli/src/server/studioServer.ts | 16 +------ .../player/hooks/useTimelinePlayer.test.ts | 32 +++++++++++++ .../src/player/hooks/useTimelinePlayer.ts | 47 +++++++++++++++---- 4 files changed, 81 insertions(+), 23 deletions(-) create mode 100644 packages/cli/src/server/studioServer.test.ts create mode 100644 packages/studio/src/player/hooks/useTimelinePlayer.test.ts diff --git a/packages/cli/src/server/studioServer.test.ts b/packages/cli/src/server/studioServer.test.ts new file mode 100644 index 000000000..18d10101e --- /dev/null +++ b/packages/cli/src/server/studioServer.test.ts @@ -0,0 +1,9 @@ +import { describe, expect, it } from "vitest"; +import { loadHyperframeRuntimeSource } from "@hyperframes/core"; +import { loadRuntimeSourceFallback } from "./studioServer.js"; + +describe("loadRuntimeSourceFallback", () => { + it("loads runtime source from the published core entrypoint", async () => { + await expect(loadRuntimeSourceFallback()).resolves.toBe(loadHyperframeRuntimeSource()); + }); +}); diff --git a/packages/cli/src/server/studioServer.ts b/packages/cli/src/server/studioServer.ts index 72416a779..5b5d78c6d 100644 --- a/packages/cli/src/server/studioServer.ts +++ b/packages/cli/src/server/studioServer.ts @@ -9,7 +9,6 @@ import { Hono } from "hono"; import { streamSSE } from "hono/streaming"; import { existsSync, readFileSync, writeFileSync, statSync } from "node:fs"; import { resolve, join, basename } from "node:path"; -import { pathToFileURL } from "node:url"; import { createProjectWatcher, type ProjectWatcher } from "./fileWatcher.js"; import { VERSION as version } from "../version.js"; import { @@ -46,20 +45,9 @@ function resolveRuntimePath(): string { return builtPath; } -async function loadRuntimeSourceFallback(): Promise { +export async function loadRuntimeSourceFallback(): Promise { try { - const sourceModulePath = resolve( - __dirname, - "..", - "..", - "..", - "core", - "src", - "inline-scripts", - "hyperframe.ts", - ); - if (!existsSync(sourceModulePath)) return null; - const mod = await import(pathToFileURL(sourceModulePath).href); + const mod = await import("@hyperframes/core"); if (typeof mod.loadHyperframeRuntimeSource === "function") { return mod.loadHyperframeRuntimeSource(); } diff --git a/packages/studio/src/player/hooks/useTimelinePlayer.test.ts b/packages/studio/src/player/hooks/useTimelinePlayer.test.ts new file mode 100644 index 000000000..c66886db7 --- /dev/null +++ b/packages/studio/src/player/hooks/useTimelinePlayer.test.ts @@ -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 }]); + }); +}); diff --git a/packages/studio/src/player/hooks/useTimelinePlayer.ts b/packages/studio/src/player/hooks/useTimelinePlayer.ts index d15dd6128..ced4914d4 100644 --- a/packages/studio/src/player/hooks/useTimelinePlayer.ts +++ b/packages/studio/src/player/hooks/useTimelinePlayer.ts @@ -294,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(null); const rafRef = useRef(0); @@ -307,11 +330,21 @@ export function useTimelinePlayer() { usePlayerStore.getState(); const syncTimelineElements = useCallback( - (elements: TimelineElement[]) => { - setElements(elements); + (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], + [setElements, setTimelineReady, setDuration], ); const getAdapter = useCallback((): PlaybackAdapter | null => { @@ -528,15 +561,11 @@ export function useTimelinePlayer() { })) .filter((element) => element.duration > 0) : els; - setElements(clampedEls); - if (newDuration > 0) { - setDuration(newDuration); - } if (clampedEls.length > 0) { - setTimelineReady(true); + syncTimelineElements(clampedEls, newDuration > 0 ? newDuration : undefined); } }, - [setElements, setTimelineReady, setDuration], + [syncTimelineElements], ); /** From 04ef8544095666de913d20504a9a5cd9ce0e0a3f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Tue, 21 Apr 2026 19:04:28 -0400 Subject: [PATCH 5/6] fix: isolate studio runtime source loading --- packages/cli/src/server/runtimeSource.ts | 11 +++++++++++ packages/cli/src/server/studioServer.test.ts | 2 +- packages/cli/src/server/studioServer.ts | 13 +------------ 3 files changed, 13 insertions(+), 13 deletions(-) create mode 100644 packages/cli/src/server/runtimeSource.ts diff --git a/packages/cli/src/server/runtimeSource.ts b/packages/cli/src/server/runtimeSource.ts new file mode 100644 index 000000000..a3ea51194 --- /dev/null +++ b/packages/cli/src/server/runtimeSource.ts @@ -0,0 +1,11 @@ +export async function loadRuntimeSourceFallback(): Promise { + 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; +} diff --git a/packages/cli/src/server/studioServer.test.ts b/packages/cli/src/server/studioServer.test.ts index 18d10101e..edad2f1e9 100644 --- a/packages/cli/src/server/studioServer.test.ts +++ b/packages/cli/src/server/studioServer.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "vitest"; import { loadHyperframeRuntimeSource } from "@hyperframes/core"; -import { loadRuntimeSourceFallback } from "./studioServer.js"; +import { loadRuntimeSourceFallback } from "./runtimeSource.js"; describe("loadRuntimeSourceFallback", () => { it("loads runtime source from the published core entrypoint", async () => { diff --git a/packages/cli/src/server/studioServer.ts b/packages/cli/src/server/studioServer.ts index 5b5d78c6d..bb9a05d0d 100644 --- a/packages/cli/src/server/studioServer.ts +++ b/packages/cli/src/server/studioServer.ts @@ -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, @@ -45,18 +46,6 @@ function resolveRuntimePath(): string { return builtPath; } -export async function loadRuntimeSourceFallback(): Promise { - 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; -} - // ── Shared thumbnail browser (singleton per process) ──────────────────────── // One browser instance is reused across all composition thumbnail requests. // Spawning a new Puppeteer process per request adds 2-5s overhead and causes From 47f6e6f0bffce6cd132fc1629b9f901208868ef5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Tue, 21 Apr 2026 19:07:27 -0400 Subject: [PATCH 6/6] test: isolate thumbnail route cache state --- .../src/studio-api/routes/thumbnail.test.ts | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) diff --git a/packages/core/src/studio-api/routes/thumbnail.test.ts b/packages/core/src/studio-api/routes/thumbnail.test.ts index 5b06a1053..24e13e4f2 100644 --- a/packages/core/src/studio-api/routes/thumbnail.test.ts +++ b/packages/core/src/studio-api/routes/thumbnail.test.ts @@ -1,12 +1,26 @@ -import { describe, expect, it, vi } from "vitest"; +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: "/tmp/project" }), + resolveProject: async (id: string) => ({ id, dir: projectDir }), bundle: async () => null, lint: async () => ({ findings: [] }), runtimeUrl: "/api/runtime.js",