mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-01 19:42:03 +00:00
feat: cache shader transition preview frames (#634)
* feat: cache shader transition preview frames * fix: move shader transition loading to player
This commit is contained in:
@@ -0,0 +1,19 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { shouldWatchProjectFile } from "./fileWatcher.js";
|
||||
|
||||
describe("shouldWatchProjectFile", () => {
|
||||
it("watches files that can affect the project signature", () => {
|
||||
expect(shouldWatchProjectFile("index.html")).toBe(true);
|
||||
expect(shouldWatchProjectFile("src/scene.tsx")).toBe(true);
|
||||
expect(shouldWatchProjectFile("assets/hero.png")).toBe(true);
|
||||
expect(shouldWatchProjectFile("Dockerfile")).toBe(true);
|
||||
});
|
||||
|
||||
it("skips generated and dependency directories excluded from signatures", () => {
|
||||
expect(shouldWatchProjectFile("node_modules/pkg/index.js")).toBe(false);
|
||||
expect(shouldWatchProjectFile("renders/output.mp4")).toBe(false);
|
||||
expect(shouldWatchProjectFile("dist/index.html")).toBe(false);
|
||||
expect(shouldWatchProjectFile(".hyperframes/cache.json")).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -8,9 +8,27 @@ export interface ProjectWatcher {
|
||||
close(): void;
|
||||
}
|
||||
|
||||
const WATCHED_EXTENSIONS = new Set([".html", ".css", ".js", ".json"]);
|
||||
const WATCHER_EXCLUDED_DIRS = new Set([
|
||||
".cache",
|
||||
".git",
|
||||
".hyperframes",
|
||||
".next",
|
||||
".vite",
|
||||
"build",
|
||||
"coverage",
|
||||
"dist",
|
||||
"node_modules",
|
||||
"outputs",
|
||||
"renders",
|
||||
]);
|
||||
const DEBOUNCE_MS = 300;
|
||||
|
||||
export function shouldWatchProjectFile(filename: string): boolean {
|
||||
if (!filename) return false;
|
||||
const parts = filename.split(/[\\/]+/);
|
||||
return !parts.some((part) => WATCHER_EXCLUDED_DIRS.has(part));
|
||||
}
|
||||
|
||||
export function createProjectWatcher(projectDir: string): ProjectWatcher {
|
||||
const listeners = new Set<FileChangeListener>();
|
||||
let debounceTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
@@ -19,13 +37,13 @@ export function createProjectWatcher(projectDir: string): ProjectWatcher {
|
||||
try {
|
||||
watcher = watch(projectDir, { recursive: true }, (_event, filename) => {
|
||||
if (!filename) return;
|
||||
const ext = "." + filename.split(".").pop()?.toLowerCase();
|
||||
if (!WATCHED_EXTENSIONS.has(ext)) return;
|
||||
const relativePath = filename.toString();
|
||||
if (!shouldWatchProjectFile(relativePath)) return;
|
||||
|
||||
if (debounceTimer) clearTimeout(debounceTimer);
|
||||
debounceTimer = setTimeout(() => {
|
||||
for (const fn of listeners) {
|
||||
fn(filename);
|
||||
fn(relativePath);
|
||||
}
|
||||
}, DEBOUNCE_MS);
|
||||
});
|
||||
|
||||
@@ -14,6 +14,7 @@ import { loadRuntimeSource } from "./runtimeSource.js";
|
||||
import { VERSION as version } from "../version.js";
|
||||
import {
|
||||
createStudioApi,
|
||||
createProjectSignature,
|
||||
getMimeType,
|
||||
type StudioApiAdapter,
|
||||
type ResolvedProject,
|
||||
@@ -144,6 +145,10 @@ export function createStudioServer(options: StudioServerOptions): StudioServer {
|
||||
// ── CLI adapter for the shared studio API ──────────────────────────────
|
||||
|
||||
const project: ResolvedProject = { id: projectId, dir: projectDir, title: projectId };
|
||||
let cachedProjectSignature: string | null = null;
|
||||
watcher.addListener(() => {
|
||||
cachedProjectSignature = null;
|
||||
});
|
||||
|
||||
const adapter: StudioApiAdapter = {
|
||||
listProjects: () => [project],
|
||||
@@ -169,6 +174,12 @@ export function createStudioServer(options: StudioServerOptions): StudioServer {
|
||||
}
|
||||
},
|
||||
|
||||
getProjectSignature(dir: string): string {
|
||||
if (resolve(dir) !== resolve(projectDir)) return createProjectSignature(dir);
|
||||
cachedProjectSignature ??= createProjectSignature(projectDir);
|
||||
return cachedProjectSignature;
|
||||
},
|
||||
|
||||
async lint(html: string, opts?: { filePath?: string }) {
|
||||
const { lintHyperframeHtml } = await import("@hyperframes/core/lint");
|
||||
return lintHyperframeHtml(html, opts);
|
||||
|
||||
@@ -95,6 +95,37 @@ describe("createPickerModule", () => {
|
||||
expect(api.getCandidatesAtPoint(Infinity, 0)).toEqual([]);
|
||||
});
|
||||
|
||||
it("does not pick through blocking loading overlays", () => {
|
||||
const picker = createPickerModule({ postMessage: createMockPostMessage() });
|
||||
picker.installPickerApi();
|
||||
const scene = document.createElement("div");
|
||||
scene.id = "scene-title";
|
||||
scene.textContent = "Scene title";
|
||||
const overlay = document.createElement("div");
|
||||
overlay.setAttribute("data-hyper-shader-loading", "");
|
||||
const overlayLabel = document.createElement("span");
|
||||
overlayLabel.textContent = "Preparing scene transitions";
|
||||
overlay.appendChild(overlayLabel);
|
||||
document.body.appendChild(scene);
|
||||
document.body.appendChild(overlay);
|
||||
|
||||
const originalElementsFromPoint = document.elementsFromPoint;
|
||||
Object.defineProperty(document, "elementsFromPoint", {
|
||||
configurable: true,
|
||||
value: vi.fn(() => [overlayLabel, overlay, scene]),
|
||||
});
|
||||
|
||||
const api = (window as any).__HF_PICKER_API;
|
||||
try {
|
||||
expect(api.getCandidatesAtPoint(10, 10)).toEqual([]);
|
||||
} finally {
|
||||
Object.defineProperty(document, "elementsFromPoint", {
|
||||
configurable: true,
|
||||
value: originalElementsFromPoint,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
it("pickAtPoint returns null for invalid coords", () => {
|
||||
const picker = createPickerModule({ postMessage: createMockPostMessage() });
|
||||
picker.installPickerApi();
|
||||
|
||||
@@ -4,6 +4,19 @@ type PickerModuleDeps = {
|
||||
postMessage: (payload: RuntimeOutboundMessage) => void;
|
||||
};
|
||||
|
||||
const PICKER_IGNORE_SELECTOR = [
|
||||
"[data-hyperframes-ignore]",
|
||||
"[data-hyperframes-picker-ignore]",
|
||||
"[data-hf-ignore]",
|
||||
"[data-no-inspect]",
|
||||
"[data-no-pick]",
|
||||
"[data-hyper-shader-loading]",
|
||||
].join(",");
|
||||
const PICKER_BLOCK_SELECTOR = [
|
||||
"[data-hyperframes-picker-block]",
|
||||
"[data-hyper-shader-loading]",
|
||||
].join(",");
|
||||
|
||||
export type PickerModule = {
|
||||
enablePickMode: () => void;
|
||||
disablePickMode: () => void;
|
||||
@@ -48,9 +61,14 @@ export function createPickerModule(deps: PickerModuleDeps): PickerModule {
|
||||
const tag = el.tagName.toLowerCase();
|
||||
if (tag === "script" || tag === "style" || tag === "link" || tag === "meta") return false;
|
||||
if (el.classList.contains("__hf-pick-highlight")) return false;
|
||||
if (el.closest(PICKER_IGNORE_SELECTOR)) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
function blocksPickerAtPoint(el: Element | null): boolean {
|
||||
return Boolean(el?.closest(PICKER_BLOCK_SELECTOR));
|
||||
}
|
||||
|
||||
function buildElementSelector(el: Element): string {
|
||||
const htmlEl = el as HTMLElement;
|
||||
if (htmlEl.id) return `#${htmlEl.id}`;
|
||||
@@ -97,6 +115,7 @@ export function createPickerModule(deps: PickerModuleDeps): PickerModule {
|
||||
const single = document.elementFromPoint(clientX, clientY);
|
||||
raw = single ? [single] : [];
|
||||
}
|
||||
if (blocksPickerAtPoint(raw[0] ?? null)) return [];
|
||||
const dedupe: Record<string, true> = {};
|
||||
const candidates: Element[] = [];
|
||||
for (let i = 0; i < raw.length; i += 1) {
|
||||
|
||||
@@ -0,0 +1,142 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import { lstatSync, readFileSync, readdirSync } from "node:fs";
|
||||
import { extname, isAbsolute, relative, resolve } from "node:path";
|
||||
|
||||
const SIGNATURE_TEXT_EXTENSIONS = new Set([
|
||||
".cjs",
|
||||
".css",
|
||||
".html",
|
||||
".js",
|
||||
".json",
|
||||
".jsx",
|
||||
".mjs",
|
||||
".svg",
|
||||
".ts",
|
||||
".tsx",
|
||||
]);
|
||||
const SIGNATURE_EXCLUDED_DIRS = new Set([
|
||||
".cache",
|
||||
".git",
|
||||
".hyperframes",
|
||||
".next",
|
||||
".vite",
|
||||
"build",
|
||||
"coverage",
|
||||
"dist",
|
||||
"node_modules",
|
||||
"outputs",
|
||||
"renders",
|
||||
]);
|
||||
const MAX_SIGNATURE_TEXT_BYTES = 2_000_000;
|
||||
|
||||
interface ProjectSignatureFile {
|
||||
file: string;
|
||||
mtimeMs: number;
|
||||
size: number;
|
||||
textContentEligible: boolean;
|
||||
}
|
||||
|
||||
interface ProjectSignatureCacheEntry {
|
||||
fingerprint: string;
|
||||
signature: string;
|
||||
}
|
||||
|
||||
const projectSignatureCache = new Map<string, ProjectSignatureCacheEntry>();
|
||||
|
||||
function isPathWithin(parentDir: string, childPath: string): boolean {
|
||||
const childRelativePath = relative(parentDir, childPath);
|
||||
return (
|
||||
childRelativePath === "" ||
|
||||
(!childRelativePath.startsWith("..") && !isAbsolute(childRelativePath))
|
||||
);
|
||||
}
|
||||
|
||||
function isTextContentEligible(file: string, size: number): boolean {
|
||||
return (
|
||||
SIGNATURE_TEXT_EXTENSIONS.has(extname(file).toLowerCase()) && size <= MAX_SIGNATURE_TEXT_BYTES
|
||||
);
|
||||
}
|
||||
|
||||
function collectProjectSignatureFiles(
|
||||
projectDir: string,
|
||||
dir: string,
|
||||
files: ProjectSignatureFile[],
|
||||
): void {
|
||||
let entries: string[];
|
||||
try {
|
||||
entries = readdirSync(dir).sort();
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
|
||||
for (const entry of entries) {
|
||||
if (SIGNATURE_EXCLUDED_DIRS.has(entry)) continue;
|
||||
const file = resolve(dir, entry);
|
||||
if (!isPathWithin(projectDir, file)) continue;
|
||||
let stat: ReturnType<typeof lstatSync>;
|
||||
try {
|
||||
stat = lstatSync(file);
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
if (stat.isSymbolicLink()) continue;
|
||||
if (stat.isDirectory()) {
|
||||
collectProjectSignatureFiles(projectDir, file, files);
|
||||
} else if (stat.isFile()) {
|
||||
files.push({
|
||||
file,
|
||||
mtimeMs: stat.mtimeMs,
|
||||
size: stat.size,
|
||||
textContentEligible: isTextContentEligible(file, stat.size),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function createProjectFingerprint(projectDir: string, files: ProjectSignatureFile[]): string {
|
||||
const hash = createHash("sha256");
|
||||
for (const entry of files) {
|
||||
hash.update(relative(projectDir, entry.file));
|
||||
hash.update("\0");
|
||||
hash.update(String(entry.size));
|
||||
hash.update("\0");
|
||||
hash.update(String(entry.mtimeMs));
|
||||
hash.update("\0");
|
||||
hash.update(entry.textContentEligible ? "text" : "binary");
|
||||
hash.update("\0");
|
||||
}
|
||||
return hash.digest("hex").slice(0, 24);
|
||||
}
|
||||
|
||||
export function createProjectSignature(projectDir: string): string {
|
||||
const normalizedProjectDir = resolve(projectDir);
|
||||
const files: ProjectSignatureFile[] = [];
|
||||
collectProjectSignatureFiles(normalizedProjectDir, normalizedProjectDir, files);
|
||||
files.sort((a, b) => a.file.localeCompare(b.file));
|
||||
|
||||
const fingerprint = createProjectFingerprint(normalizedProjectDir, files);
|
||||
const cached = projectSignatureCache.get(normalizedProjectDir);
|
||||
if (cached?.fingerprint === fingerprint) return cached.signature;
|
||||
|
||||
const hash = createHash("sha256");
|
||||
for (const entry of files) {
|
||||
const relativePath = relative(normalizedProjectDir, entry.file);
|
||||
hash.update(relativePath);
|
||||
hash.update("\0");
|
||||
hash.update(String(entry.size));
|
||||
hash.update("\0");
|
||||
if (entry.textContentEligible) {
|
||||
try {
|
||||
hash.update(readFileSync(entry.file));
|
||||
} catch {
|
||||
hash.update(String(entry.mtimeMs));
|
||||
}
|
||||
} else {
|
||||
hash.update(String(entry.mtimeMs));
|
||||
}
|
||||
hash.update("\0");
|
||||
}
|
||||
const signature = hash.digest("hex").slice(0, 24);
|
||||
projectSignatureCache.set(normalizedProjectDir, { fingerprint, signature });
|
||||
return signature;
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
export { createStudioApi } from "./createStudioApi.js";
|
||||
export { createProjectSignature } from "./helpers/projectSignature.js";
|
||||
export type { StudioApiAdapter, ResolvedProject, RenderJobState, LintResult } from "./types.js";
|
||||
export { isSafePath, walkDir } from "./helpers/safePath.js";
|
||||
export { getMimeType, MIME_TYPES } from "./helpers/mime.js";
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { Hono } from "hono";
|
||||
import { mkdtempSync, rmSync, symlinkSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { registerPreviewRoutes } from "./preview";
|
||||
import type { StudioApiAdapter } from "../types";
|
||||
|
||||
const tempDirs: string[] = [];
|
||||
|
||||
afterEach(() => {
|
||||
for (const dir of tempDirs.splice(0)) {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
function createProjectDir(): string {
|
||||
const projectDir = mkdtempSync(join(tmpdir(), "hf-preview-test-"));
|
||||
tempDirs.push(projectDir);
|
||||
writeFileSync(join(projectDir, "index.html"), "<html><head></head><body>Preview</body></html>");
|
||||
return projectDir;
|
||||
}
|
||||
|
||||
function createAdapter(
|
||||
projectDir: string,
|
||||
overrides: Partial<StudioApiAdapter> = {},
|
||||
): StudioApiAdapter {
|
||||
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",
|
||||
}),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function tryCreateSymlink(target: string, path: string, type: "dir" | "file"): boolean {
|
||||
try {
|
||||
symlinkSync(target, path, type);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function getPreviewSignature(projectDir: string): Promise<string> {
|
||||
const app = new Hono();
|
||||
registerPreviewRoutes(app, createAdapter(projectDir));
|
||||
|
||||
const response = await app.request("http://localhost/projects/demo/preview");
|
||||
expect(response.status).toBe(200);
|
||||
const html = await response.text();
|
||||
const match = /<meta name="hyperframes-project-signature" content="([^"]+)">/.exec(html);
|
||||
expect(match?.[1]).toBeTruthy();
|
||||
return match![1]!;
|
||||
}
|
||||
|
||||
describe("registerPreviewRoutes", () => {
|
||||
it("uses the adapter project signature when available", async () => {
|
||||
const projectDir = createProjectDir();
|
||||
const getProjectSignature = vi.fn(() => "cached-signature");
|
||||
const app = new Hono();
|
||||
registerPreviewRoutes(app, createAdapter(projectDir, { getProjectSignature }));
|
||||
|
||||
const response = await app.request("http://localhost/projects/demo/preview");
|
||||
const html = await response.text();
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(getProjectSignature).toHaveBeenCalledWith(projectDir);
|
||||
expect(html).toContain(
|
||||
'<meta name="hyperframes-project-signature" content="cached-signature">',
|
||||
);
|
||||
});
|
||||
|
||||
it("updates the preview signature after project text edits", async () => {
|
||||
const projectDir = createProjectDir();
|
||||
const file = join(projectDir, "scene.js");
|
||||
writeFileSync(file, "export const label = 'first';");
|
||||
|
||||
const firstSignature = await getPreviewSignature(projectDir);
|
||||
expect(await getPreviewSignature(projectDir)).toBe(firstSignature);
|
||||
|
||||
writeFileSync(file, "export const label = 'second with changed size';");
|
||||
|
||||
await expect(getPreviewSignature(projectDir)).resolves.not.toBe(firstSignature);
|
||||
});
|
||||
|
||||
it("skips symlinked files when creating the preview signature", async () => {
|
||||
const projectDir = createProjectDir();
|
||||
const firstSignature = await getPreviewSignature(projectDir);
|
||||
|
||||
const externalDir = mkdtempSync(join(tmpdir(), "hf-preview-external-"));
|
||||
tempDirs.push(externalDir);
|
||||
const externalFile = join(externalDir, "external.js");
|
||||
writeFileSync(externalFile, "export const external = true;");
|
||||
|
||||
if (!tryCreateSymlink(externalFile, join(projectDir, "external.js"), "file")) return;
|
||||
|
||||
await expect(getPreviewSignature(projectDir)).resolves.toBe(firstSignature);
|
||||
});
|
||||
|
||||
it("skips symlinked directories when creating the preview signature", async () => {
|
||||
const projectDir = createProjectDir();
|
||||
if (!tryCreateSymlink(projectDir, join(projectDir, "loop"), "dir")) return;
|
||||
|
||||
const signature = await getPreviewSignature(projectDir);
|
||||
|
||||
expect(signature).toMatch(/^[a-f0-9]{24}$/);
|
||||
});
|
||||
});
|
||||
@@ -5,6 +5,25 @@ import type { StudioApiAdapter } from "../types.js";
|
||||
import { isSafePath } from "../helpers/safePath.js";
|
||||
import { getMimeType } from "../helpers/mime.js";
|
||||
import { buildSubCompositionHtml } from "../helpers/subComposition.js";
|
||||
import { createProjectSignature } from "../helpers/projectSignature.js";
|
||||
|
||||
const PROJECT_SIGNATURE_META = "hyperframes-project-signature";
|
||||
|
||||
function resolveProjectSignature(adapter: StudioApiAdapter, projectDir: string): string {
|
||||
return adapter.getProjectSignature?.(projectDir) ?? createProjectSignature(projectDir);
|
||||
}
|
||||
|
||||
function injectProjectSignature(html: string, signature: string): string {
|
||||
const tag = `<meta name="${PROJECT_SIGNATURE_META}" content="${signature}">`;
|
||||
if (html.includes(`name="${PROJECT_SIGNATURE_META}"`)) {
|
||||
return html.replace(
|
||||
new RegExp(`<meta\\s+name=["']${PROJECT_SIGNATURE_META}["'][^>]*>`, "i"),
|
||||
tag,
|
||||
);
|
||||
}
|
||||
if (html.includes("</head>")) return html.replace("</head>", `${tag}\n</head>`);
|
||||
return `${tag}\n${html}`;
|
||||
}
|
||||
|
||||
export function registerPreviewRoutes(api: Hono, adapter: StudioApiAdapter): void {
|
||||
// Bundled composition preview
|
||||
@@ -37,10 +56,18 @@ export function registerPreviewRoutes(api: Hono, adapter: StudioApiAdapter): voi
|
||||
bundled = bundled.replace(/<head>/i, `<head><base href="${baseHref}">`);
|
||||
}
|
||||
|
||||
bundled = injectProjectSignature(bundled, resolveProjectSignature(adapter, project.dir));
|
||||
return c.html(bundled);
|
||||
} catch {
|
||||
const file = resolve(project.dir, "index.html");
|
||||
if (existsSync(file)) return c.html(readFileSync(file, "utf-8"));
|
||||
if (existsSync(file)) {
|
||||
return c.html(
|
||||
injectProjectSignature(
|
||||
readFileSync(file, "utf-8"),
|
||||
resolveProjectSignature(adapter, project.dir),
|
||||
),
|
||||
);
|
||||
}
|
||||
return c.text("not found", 404);
|
||||
}
|
||||
});
|
||||
@@ -63,7 +90,7 @@ export function registerPreviewRoutes(api: Hono, adapter: StudioApiAdapter): voi
|
||||
const baseHref = `/api/projects/${project.id}/preview/`;
|
||||
const html = buildSubCompositionHtml(project.dir, compPath, adapter.runtimeUrl, baseHref);
|
||||
if (!html) return c.text("not found", 404);
|
||||
return c.html(html);
|
||||
return c.html(injectProjectSignature(html, resolveProjectSignature(adapter, project.dir)));
|
||||
});
|
||||
|
||||
// Static asset serving (with range request support for audio/video seeking)
|
||||
|
||||
@@ -41,6 +41,9 @@ export interface StudioApiAdapter {
|
||||
/** Bundle a project directory into a single HTML string. Returns null if unavailable. */
|
||||
bundle(projectDir: string): Promise<string | null>;
|
||||
|
||||
/** Optional: cached signature for project files that should invalidate preview frame caches. */
|
||||
getProjectSignature?: (projectDir: string) => string;
|
||||
|
||||
/** Lint a single HTML string. */
|
||||
lint(html: string, opts?: { filePath?: string }): Promise<LintResult> | LintResult;
|
||||
|
||||
|
||||
+40
-20
@@ -52,18 +52,35 @@ Show a static image before playback starts:
|
||||
|
||||
## Attributes
|
||||
|
||||
| Attribute | Type | Default | Description |
|
||||
| --------------- | ------- | ------- | -------------------------------------------- |
|
||||
| `src` | string | — | URL to the composition HTML file |
|
||||
| `audio-src` | string | — | Audio URL for parent-frame playback (mobile) |
|
||||
| `width` | number | 1920 | Composition width in pixels (aspect ratio) |
|
||||
| `height` | number | 1080 | Composition height in pixels (aspect ratio) |
|
||||
| `controls` | boolean | false | Show play/pause, scrubber, and time display |
|
||||
| `muted` | boolean | false | Mute audio playback |
|
||||
| `poster` | string | — | Image URL shown before playback starts |
|
||||
| `playback-rate` | number | 1 | Speed multiplier (0.5 = half, 2 = double) |
|
||||
| `autoplay` | boolean | false | Start playing when ready |
|
||||
| `loop` | boolean | false | Restart when the composition ends |
|
||||
| Attribute | Type | Default | Description |
|
||||
| ---------------------- | ------------------------------- | ------------- | --------------------------------------------------------------------------- |
|
||||
| `src` | string | — | URL to the composition HTML file |
|
||||
| `audio-src` | string | — | Audio URL for parent-frame playback (mobile) |
|
||||
| `width` | number | 1920 | Composition width in pixels (aspect ratio) |
|
||||
| `height` | number | 1080 | Composition height in pixels (aspect ratio) |
|
||||
| `controls` | boolean | false | Show play/pause, scrubber, and time display |
|
||||
| `muted` | boolean | false | Mute audio playback |
|
||||
| `poster` | string | — | Image URL shown before playback starts |
|
||||
| `playback-rate` | number | 1 | Speed multiplier (0.5 = half, 2 = double) |
|
||||
| `autoplay` | boolean | false | Start playing when ready |
|
||||
| `loop` | boolean | false | Restart when the composition ends |
|
||||
| `shader-capture-scale` | number | — | Shader transition snapshot scale forwarded to browser previews (`0.25`-`1`) |
|
||||
| `shader-loading` | `composition \| player \| none` | `composition` | Controls shader transition prep loading UI ownership |
|
||||
|
||||
### Shader transition previews
|
||||
|
||||
When a composition uses `@hyperframes/shader-transitions`, the player can own preview-only shader capture settings:
|
||||
|
||||
```html
|
||||
<hyperframes-player
|
||||
src="./composition/index.html"
|
||||
shader-capture-scale="1"
|
||||
shader-loading="player"
|
||||
controls
|
||||
></hyperframes-player>
|
||||
```
|
||||
|
||||
`shader-loading="player"` shows the player-owned transition-prep overlay from shader progress messages. `composition` leaves direct composition fallback behavior alone, and `none` suppresses the loader.
|
||||
|
||||
### Mobile audio
|
||||
|
||||
@@ -98,6 +115,8 @@ player.ready; // boolean (read-only)
|
||||
player.playbackRate; // number (read/write)
|
||||
player.muted; // boolean (read/write)
|
||||
player.loop; // boolean (read/write)
|
||||
player.shaderCaptureScale; // number (read/write)
|
||||
player.shaderLoading; // "composition" | "player" | "none" (read/write)
|
||||
|
||||
// Inner iframe access (for advanced consumers — see "Advanced: iframe access" below)
|
||||
player.iframeElement; // HTMLIFrameElement (read-only)
|
||||
@@ -157,14 +176,15 @@ function StudioPreview({ src }: { src: string }) {
|
||||
|
||||
## Events
|
||||
|
||||
| Event | Detail | Fired when |
|
||||
| ------------ | ----------------- | ------------------------------------------ |
|
||||
| `ready` | `{ duration }` | Composition loaded and duration determined |
|
||||
| `play` | — | Playback started |
|
||||
| `pause` | — | Playback paused |
|
||||
| `timeupdate` | `{ currentTime }` | Playback position changed (~10 fps) |
|
||||
| `ended` | — | Reached the end (when not looping) |
|
||||
| `error` | `{ message }` | Composition failed to load |
|
||||
| Event | Detail | Fired when |
|
||||
| ----------------------- | -------------------------- | ------------------------------------------ |
|
||||
| `ready` | `{ duration }` | Composition loaded and duration determined |
|
||||
| `play` | — | Playback started |
|
||||
| `pause` | — | Playback paused |
|
||||
| `timeupdate` | `{ currentTime }` | Playback position changed (~10 fps) |
|
||||
| `ended` | — | Reached the end (when not looping) |
|
||||
| `error` | `{ message }` | Composition failed to load |
|
||||
| `shadertransitionstate` | `{ compositionId, state }` | Shader transition cache/capture progress |
|
||||
|
||||
```js
|
||||
player.addEventListener("ready", (e) => {
|
||||
|
||||
@@ -323,6 +323,137 @@ describe("HyperframesPlayer parent-frame media", () => {
|
||||
});
|
||||
});
|
||||
|
||||
// ── Shader transition preview controls ──
|
||||
//
|
||||
// Shader transition capture scale and loading UI ownership are player-level
|
||||
// preview concerns. The player forwards those options into the iframe before
|
||||
// the composition runs, then renders transition-prep progress from runtime
|
||||
// messages when `shader-loading="player"` is enabled.
|
||||
|
||||
describe("HyperframesPlayer shader transition options", () => {
|
||||
type PlayerWithIframe = HTMLElement & {
|
||||
iframeElement: HTMLIFrameElement;
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
await import("./hyperframes-player.js");
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
document.body.innerHTML = "";
|
||||
});
|
||||
|
||||
it("observes shader capture scale and loading attributes", () => {
|
||||
const player = document.createElement("hyperframes-player");
|
||||
const Ctor = player.constructor as typeof HTMLElement & {
|
||||
observedAttributes: string[];
|
||||
};
|
||||
|
||||
expect(Ctor.observedAttributes).toContain("shader-capture-scale");
|
||||
expect(Ctor.observedAttributes).toContain("shader-loading");
|
||||
});
|
||||
|
||||
it("passes shader options through src query parameters", () => {
|
||||
const player = document.createElement("hyperframes-player") as PlayerWithIframe;
|
||||
player.setAttribute("shader-capture-scale", "0.5");
|
||||
player.setAttribute("shader-loading", "player");
|
||||
player.setAttribute("src", "/api/projects/demo/preview?x=1#stage");
|
||||
|
||||
const url = new URL(player.iframeElement.src);
|
||||
expect(url.pathname).toBe("/api/projects/demo/preview");
|
||||
expect(url.searchParams.get("x")).toBe("1");
|
||||
expect(url.searchParams.get("__hf_shader_capture_scale")).toBe("0.5");
|
||||
expect(url.searchParams.get("__hf_shader_loading")).toBe("player");
|
||||
expect(url.hash).toBe("#stage");
|
||||
});
|
||||
|
||||
it("injects shader options into srcdoc before composition scripts run", () => {
|
||||
const player = document.createElement("hyperframes-player") as PlayerWithIframe;
|
||||
player.setAttribute("shader-capture-scale", "0.5");
|
||||
player.setAttribute("shader-loading", "player");
|
||||
player.setAttribute(
|
||||
"srcdoc",
|
||||
'<!doctype html><html><head><script src="composition.js"></script></head><body></body></html>',
|
||||
);
|
||||
|
||||
const srcdoc = player.iframeElement.srcdoc;
|
||||
expect(srcdoc).toContain('window.__HF_SHADER_CAPTURE_SCALE="0.5";');
|
||||
expect(srcdoc).toContain('window.__HF_SHADER_LOADING="player";');
|
||||
expect(srcdoc.indexOf("data-hyperframes-player-shader-options")).toBeLessThan(
|
||||
srcdoc.indexOf("composition.js"),
|
||||
);
|
||||
});
|
||||
|
||||
it("shows and hides the player-owned shader loader from transition state messages", () => {
|
||||
vi.useFakeTimers();
|
||||
const player = document.createElement("hyperframes-player") as PlayerWithIframe;
|
||||
player.setAttribute("shader-loading", "player");
|
||||
document.body.appendChild(player);
|
||||
|
||||
const iframeWindow = player.iframeElement.contentWindow;
|
||||
expect(iframeWindow).toBeTruthy();
|
||||
window.dispatchEvent(
|
||||
new MessageEvent("message", {
|
||||
source: iframeWindow,
|
||||
data: {
|
||||
source: "hf-preview",
|
||||
type: "shader-transition-state",
|
||||
compositionId: "main",
|
||||
state: {
|
||||
loading: true,
|
||||
progress: 3,
|
||||
total: 10,
|
||||
currentTransition: 1,
|
||||
transitionTotal: 2,
|
||||
transitionFrame: 3,
|
||||
transitionFrames: 5,
|
||||
phase: "capturing",
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
const loader = player.shadowRoot?.querySelector(".hfp-shader-loader");
|
||||
expect(loader?.classList.contains("hfp-visible")).toBe(true);
|
||||
expect(loader?.textContent).toContain("1/2");
|
||||
expect(loader?.textContent).toContain("3/5");
|
||||
|
||||
const playEvents: Event[] = [];
|
||||
player.addEventListener("play", (event) => playEvents.push(event));
|
||||
loader?.dispatchEvent(new MouseEvent("click", { bubbles: true, composed: true }));
|
||||
expect(playEvents).toHaveLength(0);
|
||||
|
||||
window.dispatchEvent(
|
||||
new MessageEvent("message", {
|
||||
source: iframeWindow,
|
||||
data: {
|
||||
source: "hf-preview",
|
||||
type: "shader-transition-state",
|
||||
compositionId: "main",
|
||||
state: { loading: false, ready: true },
|
||||
},
|
||||
}),
|
||||
);
|
||||
window.dispatchEvent(
|
||||
new MessageEvent("message", {
|
||||
source: iframeWindow,
|
||||
data: {
|
||||
source: "hf-preview",
|
||||
type: "shader-transition-state",
|
||||
compositionId: "main",
|
||||
state: { loading: false, ready: true },
|
||||
},
|
||||
}),
|
||||
);
|
||||
expect(loader?.classList.contains("hfp-visible")).toBe(false);
|
||||
expect(loader?.classList.contains("hfp-hiding")).toBe(true);
|
||||
vi.advanceTimersByTime(420);
|
||||
expect(loader?.classList.contains("hfp-hiding")).toBe(false);
|
||||
vi.useRealTimers();
|
||||
});
|
||||
});
|
||||
|
||||
// ── Shared stylesheet (adoptedStyleSheets) ──
|
||||
//
|
||||
// Every player constructed in the same document should adopt the *same*
|
||||
|
||||
@@ -20,6 +20,114 @@ function getSharedSheet(): CSSStyleSheet | null {
|
||||
const DEFAULT_FPS = 30;
|
||||
const RUNTIME_CDN_URL =
|
||||
"https://cdn.jsdelivr.net/npm/@hyperframes/core/dist/hyperframe.runtime.iife.js";
|
||||
const SHADER_CAPTURE_SCALE_ATTR = "shader-capture-scale";
|
||||
const SHADER_LOADING_ATTR = "shader-loading";
|
||||
const SHADER_CAPTURE_SCALE_PARAM = "__hf_shader_capture_scale";
|
||||
const SHADER_LOADING_PARAM = "__hf_shader_loading";
|
||||
|
||||
export type ShaderLoadingMode = "composition" | "player" | "none";
|
||||
|
||||
interface ShaderTransitionState {
|
||||
ready?: boolean;
|
||||
progress?: number;
|
||||
total?: number;
|
||||
currentTransition?: number;
|
||||
transitionTotal?: number;
|
||||
transitionFrame?: number;
|
||||
transitionFrames?: number;
|
||||
phase?: "cached" | "capturing" | "finalizing";
|
||||
loading?: boolean;
|
||||
}
|
||||
|
||||
interface ShaderLoaderElements {
|
||||
root: HTMLDivElement;
|
||||
fill: HTMLDivElement;
|
||||
title: HTMLSpanElement;
|
||||
detail: HTMLDivElement;
|
||||
transitionValue: HTMLSpanElement;
|
||||
frameLabel: HTMLSpanElement;
|
||||
frameValue: HTMLSpanElement;
|
||||
frameRow: HTMLDivElement;
|
||||
}
|
||||
|
||||
const SHADER_LOADING_PHRASES = [
|
||||
"Preparing scene transitions",
|
||||
"Sampling outgoing scene motion",
|
||||
"Sampling incoming scene motion",
|
||||
"Caching transition frames",
|
||||
"Finalizing transition preview",
|
||||
];
|
||||
|
||||
function normalizeShaderCaptureScale(value: string | null): string | null {
|
||||
if (value === null) return null;
|
||||
const parsed = Number(value);
|
||||
if (!Number.isFinite(parsed) || parsed <= 0) return null;
|
||||
return String(Math.min(1, Math.max(0.25, parsed)));
|
||||
}
|
||||
|
||||
function normalizeShaderLoadingMode(value: string | null): ShaderLoadingMode {
|
||||
if (value === null || value.trim() === "") return "composition";
|
||||
const normalized = value.trim().toLowerCase();
|
||||
if (
|
||||
normalized === "none" ||
|
||||
normalized === "false" ||
|
||||
normalized === "0" ||
|
||||
normalized === "off"
|
||||
) {
|
||||
return "none";
|
||||
}
|
||||
if (
|
||||
normalized === "player" ||
|
||||
normalized === "true" ||
|
||||
normalized === "1" ||
|
||||
normalized === "on"
|
||||
) {
|
||||
return "player";
|
||||
}
|
||||
return "composition";
|
||||
}
|
||||
|
||||
function setQueryParam(params: URLSearchParams, key: string, value: string | null): void {
|
||||
if (value === null) params.delete(key);
|
||||
else params.set(key, value);
|
||||
}
|
||||
|
||||
function withShaderQueryParams(
|
||||
src: string,
|
||||
scale: string | null,
|
||||
loadingMode: ShaderLoadingMode,
|
||||
): string {
|
||||
const hashIndex = src.indexOf("#");
|
||||
const beforeHash = hashIndex >= 0 ? src.slice(0, hashIndex) : src;
|
||||
const hash = hashIndex >= 0 ? src.slice(hashIndex) : "";
|
||||
const queryIndex = beforeHash.indexOf("?");
|
||||
const path = queryIndex >= 0 ? beforeHash.slice(0, queryIndex) : beforeHash;
|
||||
const query = queryIndex >= 0 ? beforeHash.slice(queryIndex + 1) : "";
|
||||
const params = new URLSearchParams(query);
|
||||
setQueryParam(params, SHADER_CAPTURE_SCALE_PARAM, scale);
|
||||
setQueryParam(params, SHADER_LOADING_PARAM, loadingMode === "composition" ? null : loadingMode);
|
||||
const nextQuery = params.toString();
|
||||
return `${path}${nextQuery ? `?${nextQuery}` : ""}${hash}`;
|
||||
}
|
||||
|
||||
function injectShaderOptionsIntoSrcdoc(
|
||||
html: string,
|
||||
scale: string | null,
|
||||
loadingMode: ShaderLoadingMode,
|
||||
): string {
|
||||
if (scale === null && loadingMode === "composition") return html;
|
||||
const lines: string[] = [];
|
||||
if (scale !== null) lines.push(`window.__HF_SHADER_CAPTURE_SCALE=${JSON.stringify(scale)};`);
|
||||
if (loadingMode !== "composition") {
|
||||
lines.push(`window.__HF_SHADER_LOADING=${JSON.stringify(loadingMode)};`);
|
||||
}
|
||||
const script = `<script data-hyperframes-player-shader-options>${lines.join("")}</script>`;
|
||||
if (/<head\b[^>]*>/i.test(html))
|
||||
return html.replace(/<head\b[^>]*>/i, (match) => `${match}${script}`);
|
||||
if (/<html\b[^>]*>/i.test(html))
|
||||
return html.replace(/<html\b[^>]*>/i, (match) => `${match}${script}`);
|
||||
return `${script}${html}`;
|
||||
}
|
||||
|
||||
class HyperframesPlayer extends HTMLElement {
|
||||
static get observedAttributes() {
|
||||
@@ -33,6 +141,8 @@ class HyperframesPlayer extends HTMLElement {
|
||||
"poster",
|
||||
"playback-rate",
|
||||
"audio-src",
|
||||
SHADER_CAPTURE_SCALE_ATTR,
|
||||
SHADER_LOADING_ATTR,
|
||||
];
|
||||
}
|
||||
|
||||
@@ -42,6 +152,15 @@ class HyperframesPlayer extends HTMLElement {
|
||||
private posterEl: HTMLImageElement | null = null;
|
||||
private controlsApi: ReturnType<typeof createControls> | null = null;
|
||||
private resizeObserver: ResizeObserver;
|
||||
private shaderLoaderEl: HTMLDivElement;
|
||||
private shaderLoaderFillEl: HTMLDivElement;
|
||||
private shaderLoaderTitleEl: HTMLSpanElement;
|
||||
private shaderLoaderDetailEl: HTMLDivElement;
|
||||
private shaderLoaderTransitionValueEl: HTMLSpanElement;
|
||||
private shaderLoaderFrameLabelEl: HTMLSpanElement;
|
||||
private shaderLoaderFrameValueEl: HTMLSpanElement;
|
||||
private shaderLoaderFrameRowEl: HTMLDivElement;
|
||||
private shaderLoaderHideTimeout: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
private _ready = false;
|
||||
private _duration = 0;
|
||||
@@ -141,6 +260,16 @@ class HyperframesPlayer extends HTMLElement {
|
||||
|
||||
this.container.appendChild(this.iframe);
|
||||
this.shadow.appendChild(this.container);
|
||||
const shaderLoader = this._createShaderLoader();
|
||||
this.shaderLoaderEl = shaderLoader.root;
|
||||
this.shaderLoaderFillEl = shaderLoader.fill;
|
||||
this.shaderLoaderTitleEl = shaderLoader.title;
|
||||
this.shaderLoaderDetailEl = shaderLoader.detail;
|
||||
this.shaderLoaderTransitionValueEl = shaderLoader.transitionValue;
|
||||
this.shaderLoaderFrameLabelEl = shaderLoader.frameLabel;
|
||||
this.shaderLoaderFrameValueEl = shaderLoader.frameValue;
|
||||
this.shaderLoaderFrameRowEl = shaderLoader.frameRow;
|
||||
this.shadow.appendChild(this.shaderLoaderEl);
|
||||
|
||||
// Clicking the bare player surface toggles play/pause.
|
||||
// Ignore shadow-DOM control interactions so overlay clicks don't double-handle.
|
||||
@@ -167,8 +296,9 @@ class HyperframesPlayer extends HTMLElement {
|
||||
this._setupParentAudioFromUrl(this.getAttribute("audio-src")!);
|
||||
// srcdoc wins over src per HTML spec when both are set; mirror both attributes
|
||||
// so the browser applies the standard precedence rules.
|
||||
if (this.hasAttribute("srcdoc")) this.iframe.srcdoc = this.getAttribute("srcdoc")!;
|
||||
if (this.hasAttribute("src")) this.iframe.src = this.getAttribute("src")!;
|
||||
if (this.hasAttribute("srcdoc"))
|
||||
this.iframe.srcdoc = this._prepareSrcdoc(this.getAttribute("srcdoc")!);
|
||||
if (this.hasAttribute("src")) this.iframe.src = this._prepareSrc(this.getAttribute("src")!);
|
||||
}
|
||||
|
||||
disconnectedCallback() {
|
||||
@@ -176,6 +306,8 @@ class HyperframesPlayer extends HTMLElement {
|
||||
window.removeEventListener("message", this._onMessage);
|
||||
this.iframe.removeEventListener("load", this._onIframeLoad);
|
||||
if (this._probeInterval) clearInterval(this._probeInterval);
|
||||
if (this.shaderLoaderHideTimeout) clearTimeout(this.shaderLoaderHideTimeout);
|
||||
this.shaderLoaderHideTimeout = null;
|
||||
this._teardownMediaObserver();
|
||||
this.controlsApi?.destroy();
|
||||
for (const m of this._parentMedia) {
|
||||
@@ -190,7 +322,7 @@ class HyperframesPlayer extends HTMLElement {
|
||||
case "src":
|
||||
if (val) {
|
||||
this._ready = false;
|
||||
this.iframe.src = val;
|
||||
this.iframe.src = this._prepareSrc(val);
|
||||
}
|
||||
break;
|
||||
case "srcdoc":
|
||||
@@ -198,7 +330,7 @@ class HyperframesPlayer extends HTMLElement {
|
||||
// srcdoc and let src take over. Always reset readiness; the iframe will
|
||||
// load a new document either way.
|
||||
this._ready = false;
|
||||
if (val !== null) this.iframe.srcdoc = val;
|
||||
if (val !== null) this.iframe.srcdoc = this._prepareSrcdoc(val);
|
||||
else this.iframe.removeAttribute("srcdoc");
|
||||
break;
|
||||
case "width":
|
||||
@@ -234,6 +366,10 @@ class HyperframesPlayer extends HTMLElement {
|
||||
case "audio-src":
|
||||
if (val) this._setupParentAudioFromUrl(val);
|
||||
break;
|
||||
case SHADER_CAPTURE_SCALE_ATTR:
|
||||
case SHADER_LOADING_ATTR:
|
||||
this._reloadShaderOptions();
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -355,6 +491,21 @@ class HyperframesPlayer extends HTMLElement {
|
||||
this.setAttribute("playback-rate", String(r));
|
||||
}
|
||||
|
||||
get shaderCaptureScale() {
|
||||
return Number(normalizeShaderCaptureScale(this.getAttribute(SHADER_CAPTURE_SCALE_ATTR)) ?? "1");
|
||||
}
|
||||
set shaderCaptureScale(scale: number) {
|
||||
this.setAttribute(SHADER_CAPTURE_SCALE_ATTR, String(scale));
|
||||
}
|
||||
|
||||
get shaderLoading() {
|
||||
return normalizeShaderLoadingMode(this.getAttribute(SHADER_LOADING_ATTR));
|
||||
}
|
||||
set shaderLoading(mode: ShaderLoadingMode) {
|
||||
if (mode === "composition") this.removeAttribute(SHADER_LOADING_ATTR);
|
||||
else this.setAttribute(SHADER_LOADING_ATTR, mode);
|
||||
}
|
||||
|
||||
get muted() {
|
||||
return this.hasAttribute("muted");
|
||||
}
|
||||
@@ -384,6 +535,236 @@ class HyperframesPlayer extends HTMLElement {
|
||||
}
|
||||
}
|
||||
|
||||
private _shaderCaptureScaleParam(): string | null {
|
||||
return normalizeShaderCaptureScale(this.getAttribute(SHADER_CAPTURE_SCALE_ATTR));
|
||||
}
|
||||
|
||||
private _shaderLoadingMode(): ShaderLoadingMode {
|
||||
return normalizeShaderLoadingMode(this.getAttribute(SHADER_LOADING_ATTR));
|
||||
}
|
||||
|
||||
private _prepareSrc(src: string): string {
|
||||
return withShaderQueryParams(src, this._shaderCaptureScaleParam(), this._shaderLoadingMode());
|
||||
}
|
||||
|
||||
private _prepareSrcdoc(srcdoc: string): string {
|
||||
return injectShaderOptionsIntoSrcdoc(
|
||||
srcdoc,
|
||||
this._shaderCaptureScaleParam(),
|
||||
this._shaderLoadingMode(),
|
||||
);
|
||||
}
|
||||
|
||||
private _reloadShaderOptions(): void {
|
||||
if (this._shaderLoadingMode() !== "player") {
|
||||
this._resetShaderLoader();
|
||||
}
|
||||
if (this.hasAttribute("srcdoc")) {
|
||||
this.iframe.srcdoc = this._prepareSrcdoc(this.getAttribute("srcdoc") || "");
|
||||
return;
|
||||
}
|
||||
if (this.hasAttribute("src")) {
|
||||
this.iframe.src = this._prepareSrc(this.getAttribute("src") || "");
|
||||
}
|
||||
}
|
||||
|
||||
private _createShaderLoader(): ShaderLoaderElements {
|
||||
const root = document.createElement("div");
|
||||
root.className = "hfp-shader-loader";
|
||||
root.setAttribute("role", "status");
|
||||
root.setAttribute("aria-live", "polite");
|
||||
root.setAttribute("aria-label", "Preparing scene transitions");
|
||||
root.setAttribute("data-hyperframes-ignore", "");
|
||||
root.draggable = false;
|
||||
|
||||
const blockOverlayInteraction = (event: Event) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
};
|
||||
for (const eventName of [
|
||||
"selectstart",
|
||||
"dragstart",
|
||||
"pointerdown",
|
||||
"mousedown",
|
||||
"click",
|
||||
"dblclick",
|
||||
"contextmenu",
|
||||
"touchstart",
|
||||
]) {
|
||||
root.addEventListener(eventName, blockOverlayInteraction, { capture: true });
|
||||
}
|
||||
|
||||
const panel = document.createElement("div");
|
||||
panel.className = "hfp-shader-loader-panel";
|
||||
panel.draggable = false;
|
||||
|
||||
const markFrame = document.createElement("div");
|
||||
markFrame.className = "hfp-shader-loader-mark";
|
||||
markFrame.draggable = false;
|
||||
markFrame.innerHTML = [
|
||||
'<svg width="78" height="78" viewBox="0 0 100 100" fill="none" aria-hidden="true" draggable="false">',
|
||||
'<path d="M10.1851 57.8021L33.1145 73.8313C36.2202 75.9978 41.5173 73.5433 42.4816 69.4984L51.7611 30.4271C52.7253 26.3822 48.5802 23.9277 44.4602 26.0942L13.917 42.1235C6.96677 45.7676 4.97564 54.1579 10.1851 57.8021Z" fill="url(#hfp-shader-loader-grad-left)"/>',
|
||||
'<path d="M87.5129 57.5141L56.9696 73.5433C52.8371 75.7098 48.7046 73.2553 49.6688 69.2104L58.9483 30.1391C59.9125 26.0942 65.2097 23.6397 68.3154 25.8062L91.2447 41.8354C96.4668 45.4796 94.4631 53.8699 87.5129 57.5141Z" fill="url(#hfp-shader-loader-grad-right)"/>',
|
||||
"<defs>",
|
||||
'<linearGradient id="hfp-shader-loader-grad-left" x1="48.5676" y1="25" x2="44.7804" y2="71.9384" gradientUnits="userSpaceOnUse">',
|
||||
'<stop stop-color="#06E3FA"/>',
|
||||
'<stop offset="1" stop-color="#4FDB5E"/>',
|
||||
"</linearGradient>",
|
||||
'<linearGradient id="hfp-shader-loader-grad-right" x1="54.8282" y1="73.8392" x2="72.0989" y2="32.8932" gradientUnits="userSpaceOnUse">',
|
||||
'<stop stop-color="#06E3FA"/>',
|
||||
'<stop offset="1" stop-color="#4FDB5E"/>',
|
||||
"</linearGradient>",
|
||||
"</defs>",
|
||||
"</svg>",
|
||||
].join("");
|
||||
|
||||
const title = document.createElement("div");
|
||||
title.className = "hfp-shader-loader-title";
|
||||
const titleText = document.createElement("span");
|
||||
titleText.className = "hfp-shader-loader-title-text";
|
||||
titleText.textContent = SHADER_LOADING_PHRASES[0] || "Preparing scene transitions";
|
||||
title.appendChild(titleText);
|
||||
|
||||
const detail = document.createElement("div");
|
||||
detail.className = "hfp-shader-loader-detail";
|
||||
detail.textContent = "Rendering animated scene samples for shader transitions.";
|
||||
|
||||
const track = document.createElement("div");
|
||||
track.className = "hfp-shader-loader-track";
|
||||
track.setAttribute("aria-hidden", "true");
|
||||
const fill = document.createElement("div");
|
||||
fill.className = "hfp-shader-loader-fill";
|
||||
track.appendChild(fill);
|
||||
|
||||
const progress = document.createElement("div");
|
||||
progress.className = "hfp-shader-loader-progress";
|
||||
const createProgressRow = (labelText: string) => {
|
||||
const row = document.createElement("div");
|
||||
row.className = "hfp-shader-loader-row";
|
||||
const label = document.createElement("span");
|
||||
label.className = "hfp-shader-loader-label";
|
||||
label.textContent = labelText;
|
||||
const value = document.createElement("span");
|
||||
value.className = "hfp-shader-loader-value";
|
||||
row.appendChild(label);
|
||||
row.appendChild(value);
|
||||
progress.appendChild(row);
|
||||
return { row, label, value };
|
||||
};
|
||||
const transitionStatus = createProgressRow("transition");
|
||||
const frameStatus = createProgressRow("transition frame");
|
||||
|
||||
panel.appendChild(markFrame);
|
||||
panel.appendChild(title);
|
||||
panel.appendChild(detail);
|
||||
panel.appendChild(track);
|
||||
panel.appendChild(progress);
|
||||
root.appendChild(panel);
|
||||
|
||||
return {
|
||||
root,
|
||||
fill,
|
||||
title: titleText,
|
||||
detail,
|
||||
transitionValue: transitionStatus.value,
|
||||
frameLabel: frameStatus.label,
|
||||
frameValue: frameStatus.value,
|
||||
frameRow: frameStatus.row,
|
||||
};
|
||||
}
|
||||
|
||||
private _showShaderLoader(): void {
|
||||
if (this.shaderLoaderHideTimeout) {
|
||||
clearTimeout(this.shaderLoaderHideTimeout);
|
||||
this.shaderLoaderHideTimeout = null;
|
||||
}
|
||||
this.shaderLoaderEl.classList.remove("hfp-hiding");
|
||||
this.shaderLoaderEl.classList.add("hfp-visible");
|
||||
}
|
||||
|
||||
private _hideShaderLoader(): void {
|
||||
if (this.shaderLoaderEl.classList.contains("hfp-hiding")) {
|
||||
if (!this.shaderLoaderHideTimeout) this._scheduleShaderLoaderHideCleanup();
|
||||
return;
|
||||
}
|
||||
if (!this.shaderLoaderEl.classList.contains("hfp-visible")) return;
|
||||
this.shaderLoaderEl.classList.add("hfp-hiding");
|
||||
this.shaderLoaderEl.classList.remove("hfp-visible");
|
||||
this._scheduleShaderLoaderHideCleanup();
|
||||
}
|
||||
|
||||
private _scheduleShaderLoaderHideCleanup(): void {
|
||||
if (this.shaderLoaderHideTimeout) clearTimeout(this.shaderLoaderHideTimeout);
|
||||
this.shaderLoaderHideTimeout = setTimeout(() => {
|
||||
this.shaderLoaderEl.classList.remove("hfp-hiding");
|
||||
this.shaderLoaderHideTimeout = null;
|
||||
}, 420);
|
||||
}
|
||||
|
||||
private _resetShaderLoader(): void {
|
||||
if (this.shaderLoaderHideTimeout) {
|
||||
clearTimeout(this.shaderLoaderHideTimeout);
|
||||
this.shaderLoaderHideTimeout = null;
|
||||
}
|
||||
this.shaderLoaderEl.classList.remove("hfp-visible", "hfp-hiding");
|
||||
this.shaderLoaderFillEl.style.transform = "scaleX(0)";
|
||||
this.shaderLoaderTransitionValueEl.textContent = "";
|
||||
this.shaderLoaderFrameValueEl.textContent = "";
|
||||
this.shaderLoaderFrameRowEl.style.visibility = "hidden";
|
||||
}
|
||||
|
||||
private _updateShaderLoader(status: ShaderTransitionState): void {
|
||||
if (this._shaderLoadingMode() !== "player") {
|
||||
this._resetShaderLoader();
|
||||
return;
|
||||
}
|
||||
if (status.ready || !status.loading) {
|
||||
this._hideShaderLoader();
|
||||
return;
|
||||
}
|
||||
|
||||
const progress =
|
||||
typeof status.progress === "number" && Number.isFinite(status.progress) ? status.progress : 0;
|
||||
const total =
|
||||
typeof status.total === "number" && Number.isFinite(status.total) ? status.total : 0;
|
||||
const ratio = total > 0 ? Math.min(1, Math.max(0, progress / total)) : 0;
|
||||
const phraseIndex = Math.min(
|
||||
SHADER_LOADING_PHRASES.length - 1,
|
||||
Math.floor(ratio * SHADER_LOADING_PHRASES.length),
|
||||
);
|
||||
this.shaderLoaderTitleEl.textContent =
|
||||
SHADER_LOADING_PHRASES[phraseIndex] || "Preparing scene transitions";
|
||||
this.shaderLoaderDetailEl.textContent =
|
||||
status.phase === "cached"
|
||||
? "Loading cached transition frames before playback."
|
||||
: status.phase === "finalizing"
|
||||
? "Uploading transition textures for smooth playback."
|
||||
: "Rendering animated scene samples for shader transitions.";
|
||||
this.shaderLoaderFillEl.style.transform = `scaleX(${ratio})`;
|
||||
|
||||
this.shaderLoaderTransitionValueEl.textContent =
|
||||
status.currentTransition !== undefined && status.transitionTotal !== undefined
|
||||
? `${status.currentTransition}/${status.transitionTotal}`
|
||||
: total > 0
|
||||
? `${progress}/${total}`
|
||||
: "";
|
||||
|
||||
const frameValue =
|
||||
status.transitionFrame !== undefined && status.transitionFrames !== undefined
|
||||
? `${status.transitionFrame}/${status.transitionFrames}`
|
||||
: "";
|
||||
this.shaderLoaderFrameLabelEl.textContent =
|
||||
status.phase === "cached"
|
||||
? "cached transition frames"
|
||||
: status.phase === "finalizing"
|
||||
? "finalizing transition frames"
|
||||
: "rendering transition frames";
|
||||
this.shaderLoaderFrameValueEl.textContent = frameValue;
|
||||
this.shaderLoaderFrameRowEl.style.visibility = frameValue ? "visible" : "hidden";
|
||||
this.shaderLoaderEl.setAttribute("aria-valuenow", String(Math.round(ratio * 100)));
|
||||
this._showShaderLoader();
|
||||
}
|
||||
|
||||
/**
|
||||
* Reach into the runtime's `window.__player.seek` directly, skipping the
|
||||
* postMessage hop. Same-origin only — cross-origin embeds throw a
|
||||
@@ -426,6 +807,18 @@ class HyperframesPlayer extends HTMLElement {
|
||||
const data = e.data;
|
||||
if (!data || data.source !== "hf-preview") return;
|
||||
|
||||
if (data.type === "shader-transition-state") {
|
||||
const state: ShaderTransitionState =
|
||||
data.state && typeof data.state === "object" ? data.state : {};
|
||||
this._updateShaderLoader(state);
|
||||
this.dispatchEvent(
|
||||
new CustomEvent("shadertransitionstate", {
|
||||
detail: { compositionId: data.compositionId, state },
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (data.type === "state") {
|
||||
this._currentTime = (data.frame ?? 0) / DEFAULT_FPS;
|
||||
const wasPlaying = !this._paused;
|
||||
@@ -501,6 +894,7 @@ class HyperframesPlayer extends HTMLElement {
|
||||
private _onIframeLoad() {
|
||||
let attempts = 0;
|
||||
this._runtimeInjected = false;
|
||||
this._resetShaderLoader();
|
||||
// A fresh iframe means a fresh runtime — `mediaOutputMuted` and the
|
||||
// autoplay-blocked latch are both reset inside it. The web component's
|
||||
// `_audioOwner` must reset to match, otherwise a composition switch on
|
||||
|
||||
@@ -31,6 +31,161 @@ export const PLAYER_STYLES = /* css */ `
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.hfp-shader-loader {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 20;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
visibility: hidden;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
background: #030504;
|
||||
color: #f4f7fb;
|
||||
cursor: default;
|
||||
user-select: none;
|
||||
-webkit-user-select: none;
|
||||
transition: opacity 420ms ease-out, visibility 420ms ease-out;
|
||||
}
|
||||
|
||||
.hfp-shader-loader.hfp-visible,
|
||||
.hfp-shader-loader.hfp-hiding {
|
||||
visibility: visible;
|
||||
}
|
||||
|
||||
.hfp-shader-loader.hfp-visible {
|
||||
opacity: 1;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.hfp-shader-loader.hfp-hiding {
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.hfp-shader-loader-panel {
|
||||
display: grid;
|
||||
grid-template-rows: 86px 40px 26px 12px 44px;
|
||||
justify-items: center;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
width: min(620px, 82%);
|
||||
text-align: center;
|
||||
font-family: Inter, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
}
|
||||
|
||||
.hfp-shader-loader-mark {
|
||||
width: 86px;
|
||||
height: 86px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.hfp-shader-loader-mark svg {
|
||||
display: block;
|
||||
overflow: visible;
|
||||
filter: drop-shadow(0 0 5px rgba(79, 219, 94, 0.16));
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.hfp-shader-loader-title {
|
||||
width: 100%;
|
||||
height: 40px;
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
font-size: 26px;
|
||||
line-height: 40px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0;
|
||||
}
|
||||
|
||||
.hfp-shader-loader-title-text {
|
||||
color: transparent;
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
rgba(244, 247, 251, 0.84) 0%,
|
||||
#ffffff 42%,
|
||||
#80efe4 52%,
|
||||
#ffffff 62%,
|
||||
rgba(244, 247, 251, 0.84) 100%
|
||||
);
|
||||
background-size: 220% 100%;
|
||||
-webkit-background-clip: text;
|
||||
background-clip: text;
|
||||
animation: hfp-shader-loader-sheen 1.9s linear infinite;
|
||||
}
|
||||
|
||||
.hfp-shader-loader-detail {
|
||||
width: 100%;
|
||||
height: 26px;
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
color: rgba(244, 247, 251, 0.62);
|
||||
font-size: 15px;
|
||||
line-height: 26px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.hfp-shader-loader-track {
|
||||
width: min(360px, 100%);
|
||||
height: 8px;
|
||||
overflow: hidden;
|
||||
border-radius: 999px;
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.hfp-shader-loader-fill {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border-radius: inherit;
|
||||
background: linear-gradient(90deg, #06e3fa, #4fdb5e);
|
||||
transform: scaleX(0);
|
||||
transform-origin: left center;
|
||||
transition: transform 160ms ease;
|
||||
}
|
||||
|
||||
.hfp-shader-loader-progress {
|
||||
width: min(420px, 100%);
|
||||
height: 44px;
|
||||
display: grid;
|
||||
grid-template-rows: repeat(2, 22px);
|
||||
color: rgba(244, 247, 251, 0.48);
|
||||
font: 600 13px/22px "IBM Plex Mono", "SF Mono", "Fira Code", "Courier New", monospace;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.hfp-shader-loader-row {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) 74px;
|
||||
align-items: center;
|
||||
column-gap: 20px;
|
||||
width: 100%;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.hfp-shader-loader-label {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-align: left;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.hfp-shader-loader-value {
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
@keyframes hfp-shader-loader-sheen {
|
||||
from {
|
||||
background-position: 140% 0;
|
||||
}
|
||||
to {
|
||||
background-position: -140% 0;
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Theming via CSS custom properties ──
|
||||
*
|
||||
* Override from outside the shadow DOM:
|
||||
|
||||
@@ -30,7 +30,7 @@ const tl = init({
|
||||
});
|
||||
```
|
||||
|
||||
The `init()` function captures each scene to a WebGL texture at transition time, crossfades between them using the selected shader, and returns a GSAP timeline. If WebGL is unavailable, it falls back to hard cuts.
|
||||
The `init()` function pre-captures animated scene samples for every transition, composites cached samples with the selected shader during playback, and returns a GSAP timeline. Scene animations keep advancing through shader transitions without running DOM captures in the playback loop. If WebGL is unavailable, it falls back to normal timeline playback without shader compositing.
|
||||
|
||||
### With an existing timeline
|
||||
|
||||
@@ -74,14 +74,19 @@ init({
|
||||
|
||||
### `init(config): GsapTimeline`
|
||||
|
||||
| Option | Type | Required | Description |
|
||||
| --------------- | -------------------- | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `bgColor` | `string` | yes | Fallback background color (hex) for scene capture. Use the composition's body/canvas background — individual scenes set their own `background-color` via CSS. |
|
||||
| `accentColor` | `string` | no | Accent color (hex) for shader glow effects |
|
||||
| `scenes` | `string[]` | yes | Element IDs of each scene, in order |
|
||||
| `transitions` | `TransitionConfig[]` | yes | Transition definitions (see below) |
|
||||
| `timeline` | `GsapTimeline` | no | Existing timeline to attach transitions to |
|
||||
| `compositionId` | `string` | no | Override the `data-composition-id` for timeline registration |
|
||||
| Option | Type | Required | Description |
|
||||
| ------------------- | -------------------- | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `bgColor` | `string` | yes | Fallback background color (hex) for scene capture. Use the composition's body/canvas background — individual scenes set their own `background-color` via CSS. |
|
||||
| `accentColor` | `string` | no | Accent color (hex) for shader glow effects |
|
||||
| `scenes` | `string[]` | yes | Element IDs of each scene, in order |
|
||||
| `transitions` | `TransitionConfig[]` | yes | Transition definitions (see below) |
|
||||
| `timeline` | `GsapTimeline` | no | Existing timeline to attach transitions to |
|
||||
| `compositionId` | `string` | no | Override the `data-composition-id` for timeline registration |
|
||||
| `previewCaptureFps` | `number` | no | Browser preview pre-capture samples per transition second. Defaults to `30`; rendering uses deterministic per-frame compositing instead. |
|
||||
|
||||
Browser preview capture scale and transition-prep loading UI ownership are controlled by `<hyperframes-player>` (`shader-capture-scale`, `shader-loading`) instead of composition code. Direct non-player previews keep the built-in full-fidelity loading fallback.
|
||||
|
||||
Browser previews store captured transition snapshots in IndexedDB using a key derived from composition ID, scene DOM/style signatures, transition timing, capture FPS, scale, and dimensions. On refresh, matching snapshots are reloaded into WebGL textures instead of being captured again. Runtime scene or stylesheet edits mark only adjacent transition caches dirty; recapture is deferred until playback so editing stays responsive.
|
||||
|
||||
### `TransitionConfig`
|
||||
|
||||
|
||||
@@ -2,6 +2,22 @@ import html2canvas from "html2canvas";
|
||||
import { DEFAULT_WIDTH, DEFAULT_HEIGHT } from "./webgl.js";
|
||||
|
||||
let patched = false;
|
||||
const VOID_ELEMENT_TAGS = new Set([
|
||||
"AREA",
|
||||
"BASE",
|
||||
"BR",
|
||||
"COL",
|
||||
"EMBED",
|
||||
"HR",
|
||||
"IMG",
|
||||
"INPUT",
|
||||
"LINK",
|
||||
"META",
|
||||
"PARAM",
|
||||
"SOURCE",
|
||||
"TRACK",
|
||||
"WBR",
|
||||
]);
|
||||
|
||||
function patchCreatePattern(): void {
|
||||
if (patched) return;
|
||||
@@ -27,94 +43,113 @@ export function initCapture(): void {
|
||||
patchCreatePattern();
|
||||
}
|
||||
|
||||
export interface CaptureSceneOptions {
|
||||
forceVisible?: boolean;
|
||||
preferBrowserPaint?: boolean;
|
||||
scale?: number;
|
||||
}
|
||||
|
||||
function forceSceneVisibleInClone(source: HTMLElement, cloneDoc: Document): void {
|
||||
if (!source.id) return;
|
||||
const clone = cloneDoc.getElementById(source.id);
|
||||
if (!(clone instanceof HTMLElement)) return;
|
||||
|
||||
clone.style.opacity = "1";
|
||||
clone.style.visibility = "visible";
|
||||
clone.querySelectorAll<HTMLElement>("[data-start]").forEach((el) => {
|
||||
el.style.visibility = "visible";
|
||||
});
|
||||
}
|
||||
|
||||
function stabilizeTransformedBoxShadows(root: HTMLElement): void {
|
||||
const view = root.ownerDocument.defaultView;
|
||||
if (!view) return;
|
||||
|
||||
[root, ...Array.from(root.querySelectorAll<HTMLElement>("*"))].forEach((el) => {
|
||||
if (VOID_ELEMENT_TAGS.has(el.tagName)) return;
|
||||
const styles = view.getComputedStyle(el);
|
||||
if (styles.boxShadow === "none" || styles.transform === "none") return;
|
||||
|
||||
const shadow = root.ownerDocument.createElement("div");
|
||||
shadow.setAttribute("data-hyper-shader-shadow-shim", "");
|
||||
shadow.style.cssText = [
|
||||
"position:absolute",
|
||||
"inset:0",
|
||||
"border-radius:inherit",
|
||||
`box-shadow:${styles.boxShadow}`,
|
||||
"background:transparent",
|
||||
"pointer-events:none",
|
||||
"z-index:0",
|
||||
].join(";");
|
||||
|
||||
if (styles.position === "static") {
|
||||
el.style.position = "relative";
|
||||
}
|
||||
el.style.boxShadow = "none";
|
||||
el.insertBefore(shadow, el.firstChild);
|
||||
});
|
||||
}
|
||||
|
||||
export function captureScene(
|
||||
sceneEl: HTMLElement,
|
||||
bgColor: string,
|
||||
width: number = DEFAULT_WIDTH,
|
||||
height: number = DEFAULT_HEIGHT,
|
||||
options: CaptureSceneOptions = {},
|
||||
): Promise<HTMLCanvasElement> {
|
||||
return html2canvas(sceneEl, {
|
||||
width,
|
||||
height,
|
||||
scale: 1,
|
||||
backgroundColor: bgColor,
|
||||
logging: false,
|
||||
// Safari applies stricter canvas-taint rules than Chrome. SVG data URLs
|
||||
// with <filter> elements (e.g. feTurbulence grain backgrounds), certain
|
||||
// cross-origin images, and mask/clip-path url() refs can taint the
|
||||
// output canvas on WebKit. Without these flags, html2canvas throws
|
||||
// `SecurityError: The operation is insecure` during its own read-back
|
||||
// path and every shader transition falls through to the catch handler
|
||||
// — observed in Safari + Claude Design's cross-origin iframe sandbox.
|
||||
//
|
||||
// useCORS: send CORS headers on image fetches so cross-origin images
|
||||
// with proper `Access-Control-Allow-Origin` don't taint the
|
||||
// canvas in the first place. Strict improvement.
|
||||
// allowTaint: let html2canvas complete and return a canvas even when it
|
||||
// becomes tainted (instead of throwing). Important caveat:
|
||||
// a tainted canvas CANNOT be uploaded to WebGL via
|
||||
// `gl.texImage2D` — WebGL spec requires SecurityError on
|
||||
// non-origin-clean sources, with no opt-out. So this flag
|
||||
// only moves the failure point from html2canvas to the
|
||||
// texImage2D call in webgl.ts. In both cases `hyper-shader.ts`
|
||||
// catches the rejected promise and runs the CSS crossfade
|
||||
// fallback. Net effect: the end-user UX is the same (smooth
|
||||
// CSS fade in either case), but we get a cleaner, more
|
||||
// predictable error site and the flag is defensively
|
||||
// correct for the non-taint branches where it genuinely
|
||||
// helps (e.g., `crossOrigin="anonymous"` image fetches
|
||||
// that already had CORS headers).
|
||||
useCORS: true,
|
||||
allowTaint: true,
|
||||
ignoreElements: (el: Element) => el.tagName === "CANVAS" || el.hasAttribute("data-no-capture"),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Capture the incoming scene with .scene-content hidden (background + decoratives only).
|
||||
* Shows the scene behind the outgoing scene via z-index, waits 2 rAFs for font rendering,
|
||||
* captures, then restores.
|
||||
*
|
||||
* IMPORTANT: We force `visibility: visible` during capture because the HyperFrames runtime's
|
||||
* time-based visibility gate (in `packages/core/src/runtime/init.ts`) sets `style.visibility
|
||||
* = "hidden"` on every `[data-start]` element that's outside its current playback window —
|
||||
* every frame. When a shader transition fires *before* the incoming scene's `data-start`
|
||||
* boundary (the recommended "transition.time = boundary - duration/2" centered placement),
|
||||
* the runtime has `visibility: hidden` on the incoming scene. Without the visibility override
|
||||
* here, `html2canvas` captures the element as blank → shader transitions from the real
|
||||
* outgoing scene to a blank incoming texture → users see content fade/morph into the
|
||||
* background color mid-transition (a visible "blink"). Forcing `visibility: visible` only
|
||||
* for the duration of the capture fixes this without affecting what the user sees during
|
||||
* normal playback.
|
||||
*/
|
||||
export function captureIncomingScene(
|
||||
toScene: HTMLElement,
|
||||
bgColor: string,
|
||||
width: number = DEFAULT_WIDTH,
|
||||
height: number = DEFAULT_HEIGHT,
|
||||
): Promise<HTMLCanvasElement> {
|
||||
return new Promise<HTMLCanvasElement>((resolve, reject) => {
|
||||
const origZ = toScene.style.zIndex;
|
||||
const origOpacity = toScene.style.opacity;
|
||||
const origVisibility = toScene.style.visibility;
|
||||
toScene.style.zIndex = "-1";
|
||||
toScene.style.opacity = "1";
|
||||
toScene.style.visibility = "visible";
|
||||
|
||||
const contentEl = toScene.querySelector<HTMLElement>(".scene-content");
|
||||
if (contentEl) contentEl.style.visibility = "hidden";
|
||||
|
||||
const restore = () => {
|
||||
if (contentEl) contentEl.style.visibility = "";
|
||||
toScene.style.visibility = origVisibility;
|
||||
toScene.style.opacity = origOpacity;
|
||||
toScene.style.zIndex = origZ;
|
||||
};
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
requestAnimationFrame(() => {
|
||||
captureScene(toScene, bgColor, width, height).then(resolve, reject).finally(restore);
|
||||
});
|
||||
const captureWithRenderer = (foreignObjectRendering: boolean): Promise<HTMLCanvasElement> => {
|
||||
return html2canvas(sceneEl, {
|
||||
width,
|
||||
height,
|
||||
scale: options.scale ?? 1,
|
||||
backgroundColor: bgColor,
|
||||
logging: false,
|
||||
foreignObjectRendering,
|
||||
// Safari applies stricter canvas-taint rules than Chrome. SVG data URLs
|
||||
// with <filter> elements (e.g. feTurbulence grain backgrounds), certain
|
||||
// cross-origin images, and mask/clip-path url() refs can taint the
|
||||
// output canvas on WebKit. Without these flags, html2canvas throws
|
||||
// `SecurityError: The operation is insecure` during its own read-back
|
||||
// path and every shader transition falls through to the catch handler
|
||||
// — observed in Safari + Claude Design's cross-origin iframe sandbox.
|
||||
//
|
||||
// useCORS: send CORS headers on image fetches so cross-origin images
|
||||
// with proper `Access-Control-Allow-Origin` don't taint the
|
||||
// canvas in the first place. Strict improvement.
|
||||
// allowTaint: let html2canvas complete and return a canvas even when it
|
||||
// becomes tainted (instead of throwing). Important caveat:
|
||||
// a tainted canvas CANNOT be uploaded to WebGL via
|
||||
// `gl.texImage2D` — WebGL spec requires SecurityError on
|
||||
// non-origin-clean sources, with no opt-out. So this flag
|
||||
// only moves the failure point from html2canvas to the
|
||||
// texImage2D call in webgl.ts. The caller catches the
|
||||
// rejected promise and keeps the DOM fallback visible. Net
|
||||
// effect: the end-user UX avoids blank frames either way,
|
||||
// but we get a cleaner, more predictable error site and the
|
||||
// flag is defensively correct for the non-taint branches
|
||||
// where it genuinely helps (e.g.,
|
||||
// `crossOrigin="anonymous"` image fetches that already had
|
||||
// CORS headers).
|
||||
useCORS: true,
|
||||
allowTaint: true,
|
||||
onclone: (cloneDoc) => {
|
||||
if (!sceneEl.id) return;
|
||||
const clone = cloneDoc.getElementById(sceneEl.id);
|
||||
if (clone instanceof HTMLElement) {
|
||||
stabilizeTransformedBoxShadows(clone);
|
||||
}
|
||||
if (options.forceVisible) {
|
||||
forceSceneVisibleInClone(sceneEl, cloneDoc);
|
||||
}
|
||||
},
|
||||
ignoreElements: (el: Element) =>
|
||||
el.tagName === "CANVAS" || el.hasAttribute("data-no-capture"),
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
if (options.preferBrowserPaint === true) {
|
||||
return captureWithRenderer(true).catch(() => captureWithRenderer(false));
|
||||
}
|
||||
|
||||
return captureWithRenderer(false);
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -36,13 +36,14 @@ function compileShader(gl: WebGLRenderingContext, src: string, type: number): We
|
||||
return s;
|
||||
}
|
||||
|
||||
export function createProgram(gl: WebGLRenderingContext, fragSrc: string): WebGLProgram {
|
||||
if (!cachedVertexShader) {
|
||||
cachedVertexShader = compileShader(gl, vertSrc, gl.VERTEX_SHADER);
|
||||
}
|
||||
function linkProgram(
|
||||
gl: WebGLRenderingContext,
|
||||
vertexShader: WebGLShader,
|
||||
fragSrc: string,
|
||||
): WebGLProgram {
|
||||
const p = gl.createProgram();
|
||||
if (!p) throw new Error("[HyperShader] Failed to create program");
|
||||
gl.attachShader(p, cachedVertexShader);
|
||||
gl.attachShader(p, vertexShader);
|
||||
gl.attachShader(p, compileShader(gl, fragSrc, gl.FRAGMENT_SHADER));
|
||||
gl.linkProgram(p);
|
||||
if (!gl.getProgramParameter(p, gl.LINK_STATUS)) {
|
||||
@@ -51,6 +52,21 @@ export function createProgram(gl: WebGLRenderingContext, fragSrc: string): WebGL
|
||||
return p;
|
||||
}
|
||||
|
||||
export function createProgram(gl: WebGLRenderingContext, fragSrc: string): WebGLProgram {
|
||||
if (!cachedVertexShader) {
|
||||
cachedVertexShader = compileShader(gl, vertSrc, gl.VERTEX_SHADER);
|
||||
}
|
||||
return linkProgram(gl, cachedVertexShader, fragSrc);
|
||||
}
|
||||
|
||||
export function createProgramWithVertex(
|
||||
gl: WebGLRenderingContext,
|
||||
vertexSrc: string,
|
||||
fragSrc: string,
|
||||
): WebGLProgram {
|
||||
return linkProgram(gl, compileShader(gl, vertexSrc, gl.VERTEX_SHADER), fragSrc);
|
||||
}
|
||||
|
||||
export interface AccentColors {
|
||||
accent: [number, number, number];
|
||||
dark: [number, number, number];
|
||||
@@ -136,8 +152,16 @@ export function uploadTexture(
|
||||
tex: WebGLTexture,
|
||||
canvas: HTMLCanvasElement,
|
||||
): void {
|
||||
gl.bindTexture(gl.TEXTURE_2D, tex);
|
||||
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, canvas);
|
||||
uploadTextureSource(gl, tex, canvas);
|
||||
canvas.width = 0;
|
||||
canvas.height = 0;
|
||||
}
|
||||
|
||||
export function uploadTextureSource(
|
||||
gl: WebGLRenderingContext,
|
||||
tex: WebGLTexture,
|
||||
source: TexImageSource,
|
||||
): void {
|
||||
gl.bindTexture(gl.TEXTURE_2D, tex);
|
||||
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, source);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
export interface HyperframesLoaderProps {
|
||||
/** Status text shown below the mark. */
|
||||
title: string;
|
||||
/** Optional secondary detail line. */
|
||||
detail?: string;
|
||||
/** Optional monospace third line for IDs, counts, or percentages. */
|
||||
mono?: string;
|
||||
/** Pixel size of the mark itself; status text scales independently. */
|
||||
size?: number;
|
||||
/** Optional normalized progress value from 0 to 1. */
|
||||
progress?: number;
|
||||
}
|
||||
|
||||
export function HyperframesLoader({
|
||||
title,
|
||||
detail,
|
||||
mono,
|
||||
size = 64,
|
||||
progress,
|
||||
}: HyperframesLoaderProps) {
|
||||
const boundedProgress =
|
||||
typeof progress === "number" && Number.isFinite(progress)
|
||||
? Math.min(1, Math.max(0, progress))
|
||||
: undefined;
|
||||
const markFrameSize = Math.round(size * 1.16);
|
||||
|
||||
return (
|
||||
<div className="hf-loader" draggable={false}>
|
||||
<div
|
||||
className="hf-loader-mark-frame"
|
||||
style={{ width: markFrameSize, height: markFrameSize }}
|
||||
draggable={false}
|
||||
>
|
||||
<svg
|
||||
className="hf-loader-mark"
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox="0 0 100 100"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<g className="hf-loader-mark__mark" transform="translate(50 50)">
|
||||
<g className="hf-loader-mark__core" transform="scale(1)" opacity=".92">
|
||||
<g transform="translate(-50 -50)">
|
||||
<path
|
||||
d="M10.1851 57.8021L33.1145 73.8313C36.2202 75.9978 41.5173 73.5433 42.4816 69.4984L51.7611 30.4271C52.7253 26.3822 48.5802 23.9277 44.4602 26.0942L13.917 42.1235C6.96677 45.7676 4.97564 54.1579 10.1851 57.8021Z"
|
||||
fill="url(#hf-loader-grad-left)"
|
||||
/>
|
||||
<path
|
||||
d="M87.5129 57.5141L56.9696 73.5433C52.8371 75.7098 48.7046 73.2553 49.6688 69.2104L58.9483 30.1391C59.9125 26.0942 65.2097 23.6397 68.3154 25.8062L91.2447 41.8354C96.4668 45.4796 94.4631 53.8699 87.5129 57.5141Z"
|
||||
fill="url(#hf-loader-grad-right)"
|
||||
/>
|
||||
</g>
|
||||
</g>
|
||||
</g>
|
||||
<defs>
|
||||
<linearGradient
|
||||
id="hf-loader-grad-left"
|
||||
x1="48.5676"
|
||||
y1="25"
|
||||
x2="44.7804"
|
||||
y2="71.9384"
|
||||
gradientUnits="userSpaceOnUse"
|
||||
>
|
||||
<stop stopColor="#06E3FA" />
|
||||
<stop offset="1" stopColor="#4FDB5E" />
|
||||
</linearGradient>
|
||||
<linearGradient
|
||||
id="hf-loader-grad-right"
|
||||
x1="54.8282"
|
||||
y1="73.8392"
|
||||
x2="72.0989"
|
||||
y2="32.8932"
|
||||
gradientUnits="userSpaceOnUse"
|
||||
>
|
||||
<stop stopColor="#06E3FA" />
|
||||
<stop offset="1" stopColor="#4FDB5E" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
</svg>
|
||||
</div>
|
||||
<div className="hf-loader-title">{title}</div>
|
||||
{detail && <div className="hf-loader-detail">{detail}</div>}
|
||||
{boundedProgress !== undefined && (
|
||||
<div className="hf-loader-progress" aria-hidden="true">
|
||||
<div
|
||||
className="hf-loader-progress__fill"
|
||||
style={{ transform: `scaleX(${boundedProgress})` }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{mono && <div className="hf-loader-mono">{mono}</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function StatusFrame(props: HyperframesLoaderProps) {
|
||||
return (
|
||||
<div className="hf-frame">
|
||||
<HyperframesLoader {...props} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,2 +1,4 @@
|
||||
// Minimal UI primitives for studio canvas components
|
||||
export { Button, IconButton } from "./Button";
|
||||
export { HyperframesLoader, StatusFrame } from "./HyperframesLoader";
|
||||
export type { HyperframesLoaderProps } from "./HyperframesLoader";
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { forwardRef, useRef, useState } from "react";
|
||||
import { forwardRef, useEffect, useRef, useState } from "react";
|
||||
import { isLottieAnimationLoaded } from "@hyperframes/core/runtime/lottie-readiness";
|
||||
import { useMountEffect } from "../../hooks/useMountEffect";
|
||||
import { HyperframesLoader } from "../../components/ui";
|
||||
// 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.
|
||||
@@ -16,6 +17,19 @@ interface HyperframesPlayerElement extends HTMLElement {
|
||||
iframeElement: HTMLIFrameElement;
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null;
|
||||
}
|
||||
|
||||
function getShaderTransitionLoading(event: Event): boolean | null {
|
||||
if (!(event instanceof CustomEvent)) return null;
|
||||
const detail: unknown = event.detail;
|
||||
if (!isRecord(detail)) return null;
|
||||
const state = detail.state;
|
||||
if (!isRecord(state)) return null;
|
||||
return state.loading === true && state.ready !== true;
|
||||
}
|
||||
|
||||
// Assets are considered ready when every `<video>`/`<audio>` has enough data
|
||||
// to play through without buffering, and every registered Lottie animation has
|
||||
// finished loading.
|
||||
@@ -62,7 +76,11 @@ export const Player = forwardRef<HTMLIFrameElement, PlayerProps>(
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const loadCountRef = useRef(0);
|
||||
const assetPollRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
const assetFadeRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const [assetsLoading, setAssetsLoading] = useState(false);
|
||||
const [assetOverlayVisible, setAssetOverlayVisible] = useState(false);
|
||||
const [assetOverlayFading, setAssetOverlayFading] = useState(false);
|
||||
const [shaderTransitionLoading, setShaderTransitionLoading] = useState(false);
|
||||
|
||||
useMountEffect(() => {
|
||||
const container = containerRef.current;
|
||||
@@ -78,6 +96,8 @@ export const Player = forwardRef<HTMLIFrameElement, PlayerProps>(
|
||||
// Create the web component imperatively to avoid JSX custom-element typing.
|
||||
const player = document.createElement("hyperframes-player") as HyperframesPlayerElement;
|
||||
const src = directUrl || `/api/projects/${projectId}/preview`;
|
||||
player.setAttribute("shader-capture-scale", "1");
|
||||
player.setAttribute("shader-loading", "player");
|
||||
player.setAttribute("src", src);
|
||||
player.setAttribute("width", String(portrait ? 1080 : 1920));
|
||||
player.setAttribute("height", String(portrait ? 1920 : 1080));
|
||||
@@ -99,9 +119,16 @@ export const Player = forwardRef<HTMLIFrameElement, PlayerProps>(
|
||||
const preventToggle = (e: Event) => e.stopImmediatePropagation();
|
||||
player.addEventListener("click", preventToggle, { capture: true });
|
||||
|
||||
const handleShaderTransitionState = (event: Event) => {
|
||||
const loading = getShaderTransitionLoading(event);
|
||||
if (loading !== null) setShaderTransitionLoading(loading);
|
||||
};
|
||||
player.addEventListener("shadertransitionstate", handleShaderTransitionState);
|
||||
|
||||
// Forward the iframe's native load event to the studio's onIframeLoad.
|
||||
const handleLoad = () => {
|
||||
loadCountRef.current++;
|
||||
setShaderTransitionLoading(false);
|
||||
// Reveal animation on reload (hot-reload, composition switch)
|
||||
if (loadCountRef.current > 1) {
|
||||
container.classList.remove("preview-revealing");
|
||||
@@ -151,6 +178,7 @@ export const Player = forwardRef<HTMLIFrameElement, PlayerProps>(
|
||||
cleanup = () => {
|
||||
iframe.removeEventListener("load", handleLoad);
|
||||
player.removeEventListener("click", preventToggle, { capture: true });
|
||||
player.removeEventListener("shadertransitionstate", handleShaderTransitionState);
|
||||
if (assetPollRef.current) clearInterval(assetPollRef.current);
|
||||
assetPollRef.current = null;
|
||||
container.removeChild(player);
|
||||
@@ -169,13 +197,57 @@ export const Player = forwardRef<HTMLIFrameElement, PlayerProps>(
|
||||
};
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (assetFadeRef.current) {
|
||||
clearTimeout(assetFadeRef.current);
|
||||
assetFadeRef.current = null;
|
||||
}
|
||||
|
||||
if (assetsLoading) {
|
||||
setAssetOverlayVisible(true);
|
||||
setAssetOverlayFading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
setAssetOverlayFading(true);
|
||||
assetFadeRef.current = setTimeout(() => {
|
||||
setAssetOverlayVisible(false);
|
||||
setAssetOverlayFading(false);
|
||||
assetFadeRef.current = null;
|
||||
}, 240);
|
||||
|
||||
return () => {
|
||||
if (assetFadeRef.current) {
|
||||
clearTimeout(assetFadeRef.current);
|
||||
assetFadeRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [assetsLoading]);
|
||||
|
||||
const showAssetOverlay = assetOverlayVisible && !shaderTransitionLoading;
|
||||
|
||||
return (
|
||||
<div className="relative w-full h-full max-w-full max-h-full overflow-hidden bg-black flex items-center justify-center">
|
||||
<div ref={containerRef} className="w-full h-full" />
|
||||
{assetsLoading && (
|
||||
<div className="absolute inset-0 bg-black/80 flex flex-col items-center justify-center z-20 pointer-events-none">
|
||||
<div className="w-8 h-8 border-2 border-white/20 border-t-white rounded-full animate-spin" />
|
||||
<span className="text-white/60 text-xs mt-3">Loading assets…</span>
|
||||
{showAssetOverlay && (
|
||||
<div
|
||||
className="absolute inset-0 bg-black flex items-center justify-center z-20 select-none"
|
||||
data-hyperframes-ignore=""
|
||||
draggable={false}
|
||||
style={{
|
||||
opacity: assetOverlayFading ? 0 : 1,
|
||||
pointerEvents: assetOverlayFading ? "none" : "auto",
|
||||
transition: "opacity 240ms ease-out",
|
||||
}}
|
||||
onDragStart={(event) => event.preventDefault()}
|
||||
onMouseDown={(event) => event.preventDefault()}
|
||||
onPointerDown={(event) => event.preventDefault()}
|
||||
>
|
||||
<HyperframesLoader
|
||||
title="Preparing preview assets"
|
||||
detail="Waiting for media and motion assets before playback starts."
|
||||
size={56}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -49,3 +49,115 @@ body {
|
||||
.cm-editor.cm-focused {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
/*
|
||||
* HyperFrames brand loader. Shared by preview overlays that need a calm,
|
||||
* branded loading state instead of a generic spinner.
|
||||
*/
|
||||
.hf-loader {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.75rem;
|
||||
width: min(34rem, 100%);
|
||||
padding: 1.5rem;
|
||||
box-sizing: border-box;
|
||||
text-align: center;
|
||||
cursor: default;
|
||||
user-select: none;
|
||||
-webkit-user-select: none;
|
||||
-webkit-user-drag: none;
|
||||
}
|
||||
|
||||
.hf-frame {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
min-height: 12rem;
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
background: rgba(0, 0, 0, 0.52);
|
||||
}
|
||||
|
||||
.hf-loader-mark-frame {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
overflow: visible;
|
||||
transform-origin: 50% 50%;
|
||||
user-select: none;
|
||||
-webkit-user-select: none;
|
||||
-webkit-user-drag: none;
|
||||
}
|
||||
|
||||
.hf-loader-mark {
|
||||
display: block;
|
||||
overflow: visible;
|
||||
filter: drop-shadow(0 0 7px rgba(79, 219, 94, 0.2));
|
||||
user-select: none;
|
||||
-webkit-user-select: none;
|
||||
-webkit-user-drag: none;
|
||||
}
|
||||
|
||||
.hf-loader-title {
|
||||
font-family:
|
||||
Inter,
|
||||
-apple-system,
|
||||
BlinkMacSystemFont,
|
||||
"Segoe UI",
|
||||
sans-serif;
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0;
|
||||
color: var(--hf-heading, #f4f4f5);
|
||||
max-width: 100%;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.hf-loader-detail {
|
||||
max-width: 32rem;
|
||||
min-height: 2.5rem;
|
||||
overflow: hidden;
|
||||
color: var(--hf-text-secondary, rgba(244, 244, 245, 0.68));
|
||||
font-family:
|
||||
Inter,
|
||||
-apple-system,
|
||||
BlinkMacSystemFont,
|
||||
"Segoe UI",
|
||||
sans-serif;
|
||||
font-size: 0.82rem;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.hf-loader-mono {
|
||||
width: min(36rem, 100%);
|
||||
min-height: 1.5rem;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
color: var(--hf-text-tertiary, rgba(244, 244, 245, 0.46));
|
||||
font-family: "IBM Plex Mono", "SF Mono", "Fira Code", monospace;
|
||||
font-size: 0.75rem;
|
||||
letter-spacing: 0;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.hf-loader-progress {
|
||||
width: min(18rem, 72vw);
|
||||
height: 0.375rem;
|
||||
overflow: hidden;
|
||||
border-radius: 999px;
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.hf-loader-progress__fill {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
transform: scaleX(0);
|
||||
transform-origin: left center;
|
||||
border-radius: inherit;
|
||||
background: linear-gradient(90deg, #06e3fa, #4fdb5e);
|
||||
transition: transform 160ms ease;
|
||||
}
|
||||
|
||||
@@ -8,12 +8,13 @@ import {
|
||||
lstatSync,
|
||||
realpathSync,
|
||||
} from "node:fs";
|
||||
import { join, resolve } from "node:path";
|
||||
import { isAbsolute, join, relative, resolve } from "node:path";
|
||||
import type {
|
||||
StudioApiAdapter,
|
||||
ResolvedProject,
|
||||
RenderJobState,
|
||||
} from "@hyperframes/core/studio-api";
|
||||
import { createProjectSignature } from "../core/src/studio-api/helpers/projectSignature";
|
||||
import { createRetryingModuleLoader, ensureProducerDist } from "./vite.producer";
|
||||
import { readNodeRequestBody } from "./vite.request-body.js";
|
||||
import { seekThumbnailPreview } from "./vite.thumbnail";
|
||||
@@ -56,6 +57,14 @@ interface ScreenshotClip {
|
||||
height: number;
|
||||
}
|
||||
|
||||
function isPathWithin(parentDir: string, childPath: string): boolean {
|
||||
const childRelativePath = relative(resolve(parentDir), resolve(childPath));
|
||||
return (
|
||||
childRelativePath === "" ||
|
||||
(!childRelativePath.startsWith("..") && !isAbsolute(childRelativePath))
|
||||
);
|
||||
}
|
||||
|
||||
// ── Vite adapter for the shared studio API ───────────────────────────────────
|
||||
|
||||
function createViteAdapter(dataDir: string, server: ViteDevServer): StudioApiAdapter {
|
||||
@@ -76,6 +85,12 @@ function createViteAdapter(dataDir: string, server: ViteDevServer): StudioApiAda
|
||||
onProgress?: (job: { progress: number; currentStage?: string }) => void,
|
||||
) => Promise<void>;
|
||||
}> | null = null;
|
||||
const projectSignatureCache = new Map<string, string>();
|
||||
server.watcher.on("all", (_event, file) => {
|
||||
for (const projectDir of projectSignatureCache.keys()) {
|
||||
if (isPathWithin(projectDir, file)) projectSignatureCache.delete(projectDir);
|
||||
}
|
||||
});
|
||||
const getBundler = async () => {
|
||||
if (!_bundler) {
|
||||
try {
|
||||
@@ -184,6 +199,16 @@ function createViteAdapter(dataDir: string, server: ViteDevServer): StudioApiAda
|
||||
return html;
|
||||
},
|
||||
|
||||
getProjectSignature(projectDir: string): string {
|
||||
const cacheKey = resolve(projectDir);
|
||||
const cached = projectSignatureCache.get(cacheKey);
|
||||
if (cached) return cached;
|
||||
|
||||
const signature = createProjectSignature(cacheKey);
|
||||
projectSignatureCache.set(cacheKey, signature);
|
||||
return signature;
|
||||
},
|
||||
|
||||
async lint(html: string, opts?: { filePath?: string }) {
|
||||
const mod = await server.ssrLoadModule("@hyperframes/core/lint");
|
||||
return mod.lintHyperframeHtml(html, opts);
|
||||
|
||||
Reference in New Issue
Block a user