Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions packages/cli/src/server/runtimeSource.ts
Original file line number Diff line number Diff line change
@@ -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;
}
9 changes: 9 additions & 0 deletions packages/cli/src/server/studioServer.test.ts
Original file line number Diff line number Diff line change
@@ -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());
});
});
43 changes: 37 additions & 6 deletions packages/cli/src/server/studioServer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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) => {
Expand Down
47 changes: 46 additions & 1 deletion packages/core/src/runtime/timeline.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down Expand Up @@ -403,14 +419,15 @@ 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 = {
Expand All @@ -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");
Expand Down
24 changes: 19 additions & 5 deletions packages/core/src/runtime/timeline.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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")),
Expand Down Expand Up @@ -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
Expand Down
57 changes: 57 additions & 0 deletions packages/core/src/studio-api/routes/thumbnail.test.ts
Original file line number Diff line number Diff line change
@@ -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",
}),
);
});
});
9 changes: 8 additions & 1 deletion packages/core/src/studio-api/routes/thumbnail.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand All @@ -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;
Expand All @@ -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)), {
Expand All @@ -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);
Expand Down
1 change: 1 addition & 0 deletions packages/core/src/studio-api/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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). */
Expand Down
3 changes: 3 additions & 0 deletions packages/studio/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -224,6 +224,7 @@ export function StudioApp() {
labelColor={style.label}
seekTime={0}
duration={el.duration}
selector={el.selector}
/>
);
}
Expand All @@ -238,6 +239,7 @@ export function StudioApp() {
labelColor={style.label}
seekTime={el.start}
duration={el.duration}
selector={el.selector}
/>
);
}
Expand Down Expand Up @@ -278,6 +280,7 @@ export function StudioApp() {
labelColor={style.label}
seekTime={el.start}
duration={el.duration}
selector={el.selector}
/>
);
}
Expand Down
16 changes: 10 additions & 6 deletions packages/studio/src/components/nle/NLELayout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@ export const NLELayout = memo(function NLELayout({
togglePlay,
seek,
onIframeLoad: baseOnIframeLoad,
refreshPlayer,
saveSeekPosition,
} = useTimelinePlayer();

Expand All @@ -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(() => {
Expand Down Expand Up @@ -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 }}>
Expand Down
Loading
Loading