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
5 changes: 5 additions & 0 deletions .github/workflows/preview-regression.yml
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,11 @@ jobs:

- run: bun install --frozen-lockfile

- name: Run Studio preview routing regression
run: |
bun run --cwd packages/studio test -- vite.thumbnail.test.ts src/utils/projectRouting.test.ts src/utils/frameCapture.test.ts
bun run --cwd packages/core test -- src/studio-api/routes/thumbnail.test.ts
- name: Build preview runtime
run: bun run --cwd packages/core build:hyperframes-runtime

Expand Down
19 changes: 19 additions & 0 deletions packages/core/src/studio-api/routes/thumbnail.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -75,4 +75,23 @@ describe("registerThumbnailRoutes", () => {
}),
);
});

it("preserves an explicit zero seek time", async () => {
const adapter = createAdapter();
const app = new Hono();
registerThumbnailRoutes(app, adapter);

const response = await app.request(
"http://localhost/projects/demo/thumbnail/index.html?t=0&format=png",
);

expect(response.status).toBe(200);
expect(adapter.generateThumbnail).toHaveBeenCalledWith(
expect.objectContaining({
compPath: "index.html",
seekTime: 0,
format: "png",
}),
);
});
});
6 changes: 4 additions & 2 deletions packages/core/src/studio-api/routes/thumbnail.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { existsSync, readFileSync, writeFileSync, mkdirSync } from "node:fs";
import { join } from "node:path";
import type { StudioApiAdapter } from "../types.js";

const THUMBNAIL_CACHE_VERSION = "v2";
const THUMBNAIL_CACHE_VERSION = "v3";

export function registerThumbnailRoutes(api: Hono, adapter: StudioApiAdapter): void {
api.get("/projects/:id/thumbnail/*", async (c) => {
Expand All @@ -19,7 +19,9 @@ export function registerThumbnailRoutes(api: Hono, adapter: StudioApiAdapter): v
if (compPath && !compPath.includes(".")) compPath += ".html";

const url = new URL(c.req.url, `http://${c.req.header("host") || "localhost"}`);
const seekTime = parseFloat(url.searchParams.get("t") || "0.5") || 0.5;
const rawSeekTime = url.searchParams.get("t");
const parsedSeekTime = rawSeekTime == null ? Number.NaN : parseFloat(rawSeekTime);
const seekTime = Number.isFinite(parsedSeekTime) ? parsedSeekTime : 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;
Expand Down
9 changes: 5 additions & 4 deletions packages/studio/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ import {
shouldHandleTimelineToggleHotkey,
} from "./utils/timelineDiscovery";
import { buildFrameCaptureFilename, buildFrameCaptureUrl } from "./utils/frameCapture";
import { buildProjectHash, parseProjectIdFromHash } from "./utils/projectRouting";
import { Camera } from "./icons/SystemIcons";

interface EditingFile {
Expand Down Expand Up @@ -122,9 +123,9 @@ export function StudioApp() {
const [resolving, setResolving] = useState(true);

useMountEffect(() => {
const hashMatch = window.location.hash.match(/^#project\/([^/]+)/);
if (hashMatch) {
setProjectId(hashMatch[1]);
const hashProjectId = parseProjectIdFromHash(window.location.hash);
if (hashProjectId) {
setProjectId(hashProjectId);
setResolving(false);
return;
}
Expand All @@ -135,7 +136,7 @@ export function StudioApp() {
const first = (data.projects ?? [])[0];
if (first) {
setProjectId(first.id);
window.location.hash = `#project/${first.id}`;
window.location.hash = buildProjectHash(first.id);
}
})
.catch(() => {})
Expand Down
4 changes: 3 additions & 1 deletion packages/studio/src/utils/frameCapture.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import { buildProjectApiPath } from "./projectRouting";

export interface FrameCaptureRequest {
projectId: string;
compositionPath: string | null;
Expand All @@ -17,7 +19,7 @@ export function buildFrameCaptureUrl({
}: FrameCaptureRequest): string {
const compPath = normalizeCompositionPath(compositionPath);
const url = new URL(
`/api/projects/${encodeURIComponent(projectId)}/thumbnail/${encodeURIComponent(compPath)}`,
buildProjectApiPath(projectId, `/thumbnail/${encodeURIComponent(compPath)}`),
origin,
);
url.searchParams.set("t", Math.max(0, currentTime).toFixed(3));
Expand Down
87 changes: 87 additions & 0 deletions packages/studio/src/utils/projectRouting.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
import { describe, expect, it, vi } from "vitest";
import { buildFrameCaptureUrl } from "./frameCapture";
import {
buildProjectApiPath,
buildProjectHash,
encodeProjectId,
parseProjectIdFromHash,
} from "./projectRouting";

describe("project routing utilities", () => {
it("decodes project ids from hash routes before building capture URLs", () => {
vi.useFakeTimers();
vi.setSystemTime(new Date("2026-05-01T12:00:00Z"));

const projectId = parseProjectIdFromHash("#project/Notion%20Showcase");

expect(projectId).toBe("Notion Showcase");
expect(
buildFrameCaptureUrl({
projectId: projectId ?? "",
compositionPath: null,
currentTime: 1.809,
origin: "http://localhost:3002",
}),
).toBe(
"http://localhost:3002/api/projects/Notion%20Showcase/thumbnail/index.html?t=1.809&format=png&v=1777636800000",
);

vi.useRealTimers();
});

it("accepts legacy raw-space hash routes", () => {
expect(parseProjectIdFromHash("#project/Notion Showcase")).toBe("Notion Showcase");
});

it("decodes reserved characters when the hash route is encoded", () => {
expect(parseProjectIdFromHash("#project/Launch%20%231%3F%20v2")).toBe("Launch #1? v2");
});

it("does not throw on malformed percent escapes in hash routes", () => {
expect(parseProjectIdFromHash("#project/Broken%ZZName")).toBe("Broken%ZZName");
});

it("ignores non-project hash routes", () => {
expect(parseProjectIdFromHash("")).toBeNull();
expect(parseProjectIdFromHash("#settings")).toBeNull();
expect(parseProjectIdFromHash("#project/")).toBeNull();
expect(parseProjectIdFromHash("#project/foo/bar")).toBeNull();
});

it("encodes project ids when writing hash routes", () => {
expect(buildProjectHash("Notion Showcase")).toBe("#project/Notion%20Showcase");
expect(buildProjectHash("Notion%20Showcase")).toBe("#project/Notion%2520Showcase");
expect(buildProjectHash("Launch #1? v2")).toBe("#project/Launch%20%231%3F%20v2");
});

it("round-trips unicode project ids through hash routes", () => {
const hash = buildProjectHash("Mañana demo");

expect(hash).toBe("#project/Ma%C3%B1ana%20demo");
expect(parseProjectIdFromHash(hash)).toBe("Mañana demo");
});

it("encodes project ids as one API path segment", () => {
expect(encodeProjectId("Notion Showcase")).toBe("Notion%20Showcase");
expect(encodeProjectId("Notion%20Showcase")).toBe("Notion%2520Showcase");
expect(encodeProjectId("Launch #1? v2")).toBe("Launch%20%231%3F%20v2");
});

it("builds API paths without double encoding decoded project ids", () => {
expect(buildProjectApiPath("Notion Showcase", "/thumbnail/index.html")).toBe(
"/api/projects/Notion%20Showcase/thumbnail/index.html",
);
});

it("keeps literal percent signs safe in API paths", () => {
expect(buildProjectApiPath("Percent%20Name", "/preview")).toBe(
"/api/projects/Percent%2520Name/preview",
);
});

it("keeps unicode project ids safe in API paths", () => {
expect(buildProjectApiPath("Mañana demo", "/preview")).toBe(
"/api/projects/Ma%C3%B1ana%20demo/preview",
);
});
});
27 changes: 27 additions & 0 deletions packages/studio/src/utils/projectRouting.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
const PROJECT_HASH_PREFIX = "#project/";

export function encodeProjectId(projectId: string): string {
return encodeURIComponent(projectId);
}

export function buildProjectHash(projectId: string): string {
return `${PROJECT_HASH_PREFIX}${encodeProjectId(projectId)}`;
}

export function parseProjectIdFromHash(hash: string): string | null {
if (!hash.startsWith(PROJECT_HASH_PREFIX)) return null;

const encodedProjectId = hash.slice(PROJECT_HASH_PREFIX.length);
if (!encodedProjectId || encodedProjectId.includes("/")) return null;

try {
return decodeURIComponent(encodedProjectId);
} catch {
return encodedProjectId;
}
}

export function buildProjectApiPath(projectId: string, suffix = ""): string {
const normalizedSuffix = suffix && !suffix.startsWith("/") ? `/${suffix}` : suffix;
return `/api/projects/${encodeProjectId(projectId)}${normalizedSuffix}`;
}
24 changes: 3 additions & 21 deletions packages/studio/vite.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import type {
} from "@hyperframes/core/studio-api";
import { createRetryingModuleLoader, ensureProducerDist } from "./vite.producer";
import { readNodeRequestBody } from "./vite.request-body.js";
import { seekThumbnailPreview } from "./vite.thumbnail";

// ── Shared Puppeteer browser ─────────────────────────────────────────────────

Expand Down Expand Up @@ -46,7 +47,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";
const THUMBNAIL_CACHE_VERSION = "v3";

interface ScreenshotClip {
x: number;
Expand Down Expand Up @@ -272,26 +273,7 @@ function createViteAdapter(dataDir: string, server: ViteDevServer): StudioApiAda
{ timeout: 5000 },
)
.catch(() => {});
await page.evaluate((t: number) => {
const w = window as Window & {
__timelines?: Record<
string,
{ seek: (t: number) => void; pause: (t?: number) => void }
>;
gsap?: { ticker: { tick: () => void } };
};
if (w.__timelines) {
// Seek ALL timelines (compositions may register multiple)
for (const tl of Object.values(w.__timelines)) {
if (tl) {
// pause(t) both seeks AND forces GSAP to render the frame
tl.pause(t);
}
}
// Force GSAP to flush any pending renders
if (w.gsap?.ticker) w.gsap.ticker.tick();
}
}, opts.seekTime);
await seekThumbnailPreview(page, opts.seekTime);
await page.evaluate("document.fonts?.ready");
await new Promise((r) => setTimeout(r, 200));
let clip: ScreenshotClip | undefined;
Expand Down
56 changes: 56 additions & 0 deletions packages/studio/vite.thumbnail.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import { describe, expect, it, vi } from "vitest";
import { seekThumbnailPreview } from "./vite.thumbnail";

describe("seekThumbnailPreview", () => {
it("prefers the HyperFrames player seek path over raw timelines", async () => {
const evaluate = vi.fn(async (fn: (time: number) => string, time: number) => {
const playerSeek = vi.fn();
const timelinePause = vi.fn();
const previousWindow = globalThis.window;
vi.stubGlobal("window", {
__player: { seek: playerSeek },
__timelines: {
main: { pause: timelinePause },
nested: { pause: timelinePause },
},
});
try {
const result = fn(time);
expect(playerSeek).toHaveBeenCalledWith(10);
expect(timelinePause).not.toHaveBeenCalled();
return result;
} finally {
vi.stubGlobal("window", previousWindow);
}
});

await expect(seekThumbnailPreview({ evaluate }, 10)).resolves.toBe("player");
});

it("falls back to all registered timelines for standalone composition pages", async () => {
const evaluate = vi.fn(async (fn: (time: number) => string, time: number) => {
const firstPause = vi.fn();
const secondPause = vi.fn();
const tickerTick = vi.fn();
const previousWindow = globalThis.window;
vi.stubGlobal("window", {
__timelines: {
first: { pause: firstPause },
second: { pause: secondPause },
},
gsap: { ticker: { tick: tickerTick } },
});
try {
const result = fn(time);
expect(firstPause).toHaveBeenCalledWith(2.5);
expect(secondPause).toHaveBeenCalledWith(2.5);
expect(tickerTick).toHaveBeenCalled();
return result;
} finally {
vi.stubGlobal("window", previousWindow);
}
});

await expect(seekThumbnailPreview({ evaluate }, 2.5)).resolves.toBe("timelines");
});
});
36 changes: 36 additions & 0 deletions packages/studio/vite.thumbnail.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
interface ThumbnailPreviewPage {
evaluate<TArg, TResult>(
fn: (arg: TArg) => TResult | Promise<TResult>,
arg: TArg,
): Promise<TResult>;
}

type SeekResult = "player" | "timelines" | "none";

export async function seekThumbnailPreview(
page: ThumbnailPreviewPage,
seekTime: number,
): Promise<SeekResult> {
return page.evaluate((t: number): SeekResult => {
const w = window as Window & {
__player?: { seek?: (time: number) => void };
__timelines?: Record<string, { pause?: (time?: number) => void }>;
gsap?: { ticker?: { tick?: () => void } };
};

if (typeof w.__player?.seek === "function") {
w.__player.seek(t);
return "player";
}

if (w.__timelines) {
for (const tl of Object.values(w.__timelines)) {
tl?.pause?.(t);
}
w.gsap?.ticker?.tick?.();
return "timelines";
}

return "none";
}, seekTime);
}
Loading