chore: import upstream snapshot with attribution
regression / regression-shards (style-16-prod style-9-prod style-17-prod iframe-render-compat variables-prod mp4-h265-sdr, shard-4) (push) Has been cancelled
regression / regression-shards (style-4-prod style-11-prod style-2-prod animejs-adapter typegpu-adapter parallel-capture-regression, shard-5) (push) Has been cancelled
regression / regression-shards (style-7-prod style-8-prod style-10-prod css-spinner-render-compat webm-transparency mp4-h264-sdr webm-vp9, shard-3) (push) Has been cancelled
regression / regression-shards (sub-composition-video style-18-prod raf-ball-render-compat font-variant-numeric sub-comp-t0 sub-comp-id-selector, shard-7) (push) Has been cancelled
Windows render verification / Detect changes (push) Has been cancelled
Windows render verification / Preflight (lint + format) (push) Has been cancelled
Windows render verification / Render on windows-latest (push) Has been cancelled
Windows render verification / Tests on windows-latest (push) Has been cancelled
CI / Detect changes (push) Has been cancelled
CI / Build (push) Has been cancelled
CI / Lint (push) Has been cancelled
CI / Fallow audit (push) Has been cancelled
CI / Format (push) Has been cancelled
CI / Typecheck (push) Has been cancelled
CI / Test (push) Has been cancelled
CI / Producer: integration tests (push) Has been cancelled
CI / Producer: unit tests (push) Has been cancelled
CI / File size check (push) Has been cancelled
CI / Test: skills (push) Has been cancelled
CI / Skills: manifest in sync (push) Has been cancelled
CI / CLI: npx shim (macos-latest) (push) Has been cancelled
CI / CLI: npx shim (ubuntu-latest) (push) Has been cancelled
CI / CLI: npx shim (windows-latest) (push) Has been cancelled
CI / SDK: unit + contract + smoke (push) Has been cancelled
CI / Test: runtime contract (push) Has been cancelled
CI / Studio: load smoke (push) Has been cancelled
CI / Smoke: global install (push) Has been cancelled
CI / CLI smoke (required) (push) Has been cancelled
CI / Semantic PR title (push) Has been cancelled
Player perf / Detect changes (push) Has been cancelled
Player perf / Preflight (lint + format) (push) Has been cancelled
Player perf / player-perf (push) Has been cancelled
Player perf / Perf: drift (push) Has been cancelled
Player perf / Perf: fps (push) Has been cancelled
Player perf / Perf: parity (push) Has been cancelled
Player perf / Perf: scrub (push) Has been cancelled
Player perf / Perf: load (push) Has been cancelled
preview-regression / Detect changes (push) Has been cancelled
preview-regression / Preflight (lint + format) (push) Has been cancelled
preview-regression / Preview parity (push) Has been cancelled
preview-regression / preview-regression (push) Has been cancelled
regression / regression (push) Has been cancelled
regression / Detect changes (push) Has been cancelled
regression / Preflight (lint + format) (push) Has been cancelled
regression / regression-shards (hdr-regression style-5-prod style-3-prod mov-prores, shard-1) (push) Has been cancelled
regression / regression-shards (overlay-montage-prod style-12-prod chat missing-host-comp-id png-sequence portrait-edge-bleed, shard-6) (push) Has been cancelled
regression / regression-shards (style-13-prod style-6-prod vignelli-stacking gsap-letters-render-compat audio-mux-parity, shard-8) (push) Has been cancelled
regression / regression-shards (style-15-prod hdr-hlg-regression style-1-prod many-cuts vfr-screen-recording render-symlinked-assets, shard-2) (push) Has been cancelled
CodeQL / Analyze (actions) (push) Has been cancelled
CodeQL / Analyze (javascript-typescript) (push) Has been cancelled
CodeQL / Analyze (python) (push) Has been cancelled
Docs / Validate docs (push) Has been cancelled
Sync skills to ClawHub / Publish changed skills (push) Has been cancelled

This commit is contained in:
wehub-resource-sync
2026-07-13 12:58:35 +08:00
commit 85453da49f
4031 changed files with 710987 additions and 0 deletions
+119
View File
@@ -0,0 +1,119 @@
{
"name": "@hyperframes/studio-server",
"version": "0.7.55",
"repository": {
"type": "git",
"url": "https://github.com/heygen-com/hyperframes",
"directory": "packages/studio-server"
},
"files": [
"dist",
"README.md"
],
"type": "module",
"main": "./src/index.ts",
"types": "./src/index.ts",
"exports": {
".": {
"bun": "./src/index.ts",
"node": "./dist/index.js",
"import": "./src/index.ts",
"types": "./src/index.ts"
},
"./package.json": "./package.json",
"./screenshot-clip": {
"bun": "./src/helpers/screenshotClip.ts",
"node": "./dist/helpers/screenshotClip.js",
"import": "./src/helpers/screenshotClip.ts",
"types": "./src/helpers/screenshotClip.ts"
},
"./manual-edits-render-script": {
"bun": "./src/helpers/manualEditsRenderScript.ts",
"node": "./dist/helpers/manualEditsRenderScript.js",
"import": "./src/helpers/manualEditsRenderScript.ts",
"types": "./src/helpers/manualEditsRenderScript.ts"
},
"./studio-motion-render-script": {
"bun": "./src/helpers/studioMotionRenderScript.ts",
"node": "./dist/helpers/studioMotionRenderScript.js",
"import": "./src/helpers/studioMotionRenderScript.ts",
"types": "./src/helpers/studioMotionRenderScript.ts"
},
"./draft-markers": {
"bun": "./src/helpers/draftMarkers.ts",
"node": "./dist/helpers/draftMarkers.js",
"import": "./src/helpers/draftMarkers.ts",
"types": "./src/helpers/draftMarkers.ts"
},
"./finite-mutation": {
"bun": "./src/helpers/finiteMutation.ts",
"node": "./dist/helpers/finiteMutation.js",
"import": "./src/helpers/finiteMutation.ts",
"types": "./src/helpers/finiteMutation.ts"
},
"./source-mutation": {
"bun": "./src/helpers/sourceMutation.ts",
"node": "./dist/helpers/sourceMutation.js",
"import": "./src/helpers/sourceMutation.ts",
"types": "./src/helpers/sourceMutation.ts"
}
},
"publishConfig": {
"access": "public",
"exports": {
".": {
"import": "./dist/index.js",
"types": "./dist/index.d.ts"
},
"./package.json": "./package.json",
"./screenshot-clip": {
"import": "./dist/helpers/screenshotClip.js",
"types": "./dist/helpers/screenshotClip.d.ts"
},
"./manual-edits-render-script": {
"import": "./dist/helpers/manualEditsRenderScript.js",
"types": "./dist/helpers/manualEditsRenderScript.d.ts"
},
"./studio-motion-render-script": {
"import": "./dist/helpers/studioMotionRenderScript.js",
"types": "./dist/helpers/studioMotionRenderScript.d.ts"
},
"./draft-markers": {
"import": "./dist/helpers/draftMarkers.js",
"types": "./dist/helpers/draftMarkers.d.ts"
},
"./finite-mutation": {
"import": "./dist/helpers/finiteMutation.js",
"types": "./dist/helpers/finiteMutation.d.ts"
},
"./source-mutation": {
"import": "./dist/helpers/sourceMutation.js",
"types": "./dist/helpers/sourceMutation.d.ts"
}
},
"main": "./dist/index.js",
"types": "./dist/index.d.ts"
},
"scripts": {
"build": "tsup",
"test": "vitest run",
"test:watch": "vitest",
"typecheck": "tsc --noEmit",
"prepublishOnly": "echo skip"
},
"dependencies": {
"@hyperframes/core": "workspace:*",
"@hyperframes/parsers": "workspace:*",
"hono": "^4.0.0",
"linkedom": "^0.18.12",
"postcss": "^8.5.8",
"postcss-selector-parser": "^7.1.2"
},
"devDependencies": {
"@types/node": "^25.0.10",
"tsup": "^8.0.0",
"tsx": "^4.21.0",
"typescript": "^5.0.0",
"vitest": "^3.2.4"
}
}
@@ -0,0 +1,41 @@
import { Hono } from "hono";
import type { StudioApiAdapter } from "./types.js";
import { registerProjectRoutes } from "./routes/projects.js";
import { registerStoryboardRoutes } from "./routes/storyboard.js";
import { registerFileRoutes } from "./routes/files.js";
import { registerPreviewRoutes } from "./routes/preview.js";
import { registerLintRoutes } from "./routes/lint.js";
import { registerRenderRoutes } from "./routes/render.js";
import { registerThumbnailRoutes } from "./routes/thumbnail.js";
import { registerWaveformRoutes } from "./routes/waveform.js";
import { registerFontRoutes } from "./routes/fonts.js";
import { registerRegistryRoutes } from "./routes/registry.js";
import { registerSelectionRoutes } from "./routes/selection.js";
import { registerMediaRoutes } from "./routes/media.js";
import { registerGlobalAssetRoutes } from "./routes/globalAssets.js";
/**
* Create a Hono sub-app with all studio API routes.
*
* Both the vite dev server and CLI embedded server mount this app
* under /api, each providing their own adapter for host-specific behavior.
*/
export function createStudioApi(adapter: StudioApiAdapter): Hono {
const api = new Hono();
registerProjectRoutes(api, adapter);
registerStoryboardRoutes(api, adapter);
registerFileRoutes(api, adapter);
registerPreviewRoutes(api, adapter);
registerLintRoutes(api, adapter);
registerRenderRoutes(api, adapter);
registerThumbnailRoutes(api, adapter);
registerSelectionRoutes(api, adapter);
registerMediaRoutes(api, adapter);
registerWaveformRoutes(api, adapter);
registerFontRoutes(api);
registerRegistryRoutes(api, adapter);
registerGlobalAssetRoutes(api);
return api;
}
@@ -0,0 +1,90 @@
import type { MediaProcessingJobState, StudioApiAdapter } from "../types.js";
export type BackgroundRemovalJobOptions = Parameters<
NonNullable<StudioApiAdapter["startBackgroundRemoval"]>
>[0];
export type BackgroundRemovalProgressEvent =
| { kind: "info"; message: string }
| { kind: "metadata"; width: number; height: number; fps: number; frameCount: number }
| { kind: "frame"; index: number; total: number; avgMsPerFrame: number };
export type BackgroundRemovalRender = (options: {
inputPath: string;
outputPath: string;
backgroundOutputPath?: string;
device?: BackgroundRemovalJobOptions["device"];
quality?: BackgroundRemovalJobOptions["quality"];
onProgress?: (event: BackgroundRemovalProgressEvent) => void;
}) => Promise<{
provider: string;
framesProcessed: number;
durationSeconds: number;
avgMsPerFrame: number;
}>;
export function createBackgroundRemovalJob(
opts: BackgroundRemovalJobOptions,
render: BackgroundRemovalRender,
): MediaProcessingJobState {
const state: MediaProcessingJobState = {
id: opts.jobId,
status: "processing",
progress: 0,
stage: "Preparing background removal",
inputAssetPath: opts.inputAssetPath,
outputAssetPath: opts.outputAssetPath,
outputPath: opts.outputPath,
...(opts.backgroundOutputPath ? { backgroundOutputPath: opts.backgroundOutputPath } : {}),
...(opts.backgroundOutputAssetPath
? { backgroundOutputAssetPath: opts.backgroundOutputAssetPath }
: {}),
};
void (async () => {
try {
const result = await render({
inputPath: opts.inputPath,
outputPath: opts.outputPath,
backgroundOutputPath: opts.backgroundOutputPath,
device: opts.device,
quality: opts.quality,
onProgress: (event) => updateBackgroundRemovalProgress(state, event),
});
state.status = "complete";
state.progress = 100;
state.stage = "Complete";
state.provider = result.provider;
state.framesProcessed = result.framesProcessed;
state.durationSeconds = result.durationSeconds;
state.avgMsPerFrame = result.avgMsPerFrame;
} catch (err) {
state.status = "failed";
state.error = err instanceof Error ? err.message : String(err);
state.stage = "Failed";
}
})();
return state;
}
function updateBackgroundRemovalProgress(
state: MediaProcessingJobState,
event: BackgroundRemovalProgressEvent,
): void {
if (event.kind === "info") {
state.stage = event.message;
return;
}
if (event.kind === "metadata") {
state.stage = `Source ${event.width}×${event.height}`;
state.progress = 2;
return;
}
state.progress = event.total ? Math.min(99, Math.floor((event.index / event.total) * 100)) : 0;
state.stage = event.total
? `Removing background ${event.index}/${event.total}`
: `Removing background frame ${event.index}`;
state.framesProcessed = event.index;
state.avgMsPerFrame = event.avgMsPerFrame;
}
@@ -0,0 +1,88 @@
import {
existsSync,
mkdirSync,
mkdtempSync,
readdirSync,
readFileSync,
rmSync,
writeFileSync,
} from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import { backupPathForResponse, snapshotBeforeWrite } from "./backupJournal";
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-backup-journal-"));
tempDirs.push(projectDir);
return projectDir;
}
describe("snapshotBeforeWrite", () => {
it("copies the current file bytes before overwrite", () => {
const projectDir = createProjectDir();
mkdirSync(join(projectDir, "compositions"), { recursive: true });
const file = join(projectDir, "compositions", "scene.html");
writeFileSync(file, "before");
const result = snapshotBeforeWrite(projectDir, file);
writeFileSync(file, "after");
expect(result.backupPath && existsSync(result.backupPath)).toBe(true);
expect(readFileSync(result.backupPath!, "utf-8")).toBe("before");
expect(backupPathForResponse(projectDir, result.backupPath)).toMatch(
/^\.hyperframes\/backup\//,
);
});
it("creates backups for zero-byte files", () => {
const projectDir = createProjectDir();
const file = join(projectDir, "empty.html");
writeFileSync(file, "");
const result = snapshotBeforeWrite(projectDir, file);
expect(result.backupPath && existsSync(result.backupPath)).toBe(true);
expect(readFileSync(result.backupPath!, "utf-8")).toBe("");
});
it("prunes older backups for the same file", () => {
const projectDir = createProjectDir();
const file = join(projectDir, "index.html");
writeFileSync(file, "0");
for (let i = 1; i <= 5; i += 1) {
writeFileSync(file, String(i));
snapshotBeforeWrite(projectDir, file, { keepPerFile: 3 });
}
expect(readdirSync(join(projectDir, ".hyperframes", "backup"))).toHaveLength(3);
});
it("does not prune backups for paths with colliding sanitized names", () => {
const projectDir = createProjectDir();
const first = join(projectDir, "My File.html");
const second = join(projectDir, "My_File.html");
writeFileSync(first, "space");
writeFileSync(second, "underscore");
snapshotBeforeWrite(projectDir, first, { keepPerFile: 1 });
snapshotBeforeWrite(projectDir, second, { keepPerFile: 1 });
const backups = readdirSync(join(projectDir, ".hyperframes", "backup"));
expect(backups).toHaveLength(2);
expect(
backups
.map((name) => readFileSync(join(projectDir, ".hyperframes", "backup", name), "utf-8"))
.sort(),
).toEqual(["space", "underscore"]);
});
});
@@ -0,0 +1,99 @@
import { mkdirSync, readdirSync, readFileSync, unlinkSync, writeFileSync } from "node:fs";
import { Buffer } from "node:buffer";
import { join, relative } from "node:path";
import { isSafePath } from "./safePath.js";
const DEFAULT_KEEP_PER_FILE = 10;
export interface BackupJournalResult {
backupPath: string | null;
error?: string;
}
function backupKeyForPath(path: string): string {
return Buffer.from(path, "utf-8").toString("base64url");
}
function timestampPrefix(): string {
return new Date().toISOString().replace(/[:.]/g, "-");
}
export function backupPathForResponse(
projectDir: string,
backupPath: string | null,
): string | null {
if (!backupPath) return null;
const rel = relative(projectDir, backupPath);
if (!rel || rel.startsWith("..")) return null;
return rel.split("\\").join("/");
}
export function snapshotBeforeWrite(
projectDir: string,
absPath: string,
options: { keepPerFile?: number } = {},
): BackupJournalResult {
if (!isSafePath(projectDir, absPath)) return { backupPath: null };
try {
const content = readFileSync(absPath);
const relativePath = relative(projectDir, absPath);
const backupDir = join(projectDir, ".hyperframes", "backup");
mkdirSync(backupDir, { recursive: true });
const backupKey = backupKeyForPath(relativePath);
const backupPath = nextBackupPath(backupDir, backupKey);
writeFileSync(backupPath, content);
pruneBackups(backupDir, backupKey, options.keepPerFile ?? DEFAULT_KEEP_PER_FILE);
return { backupPath };
} catch (error) {
if (
error &&
typeof error === "object" &&
"code" in error &&
(error.code === "ENOENT" || error.code === "EISDIR")
) {
return { backupPath: null };
}
return { backupPath: null, error: error instanceof Error ? error.message : String(error) };
}
}
function nextBackupPath(backupDir: string, backupKey: string): string {
const base = `${timestampPrefix()}-${backupKey}`;
let candidate = join(backupDir, base);
let counter = 2;
while (true) {
try {
readFileSync(candidate);
} catch (error) {
if (error && typeof error === "object" && "code" in error && error.code === "ENOENT") {
return candidate;
}
throw error;
}
candidate = join(backupDir, `${base}-${counter}`);
counter += 1;
}
}
function pruneBackups(backupDir: string, backupKey: string, keepPerFile: number): void {
const keep = Math.max(1, Math.floor(keepPerFile));
const suffix = `-${backupKey}`;
const numberedSuffix = new RegExp(`-${backupKey}-\\d+$`);
const matches = readdirSync(backupDir)
.filter((name) => name.endsWith(suffix) || numberedSuffix.test(name))
.map((name) => join(backupDir, name))
.sort((a, b) => {
return b.localeCompare(a);
});
for (const file of matches.slice(keep)) {
try {
unlinkSync(file);
} catch {
// Backup pruning is best-effort and must not block the user's write.
}
}
}
@@ -0,0 +1,10 @@
/**
* Draft-marker constants shared between core's PreviewAdapter and Studio's
* manual-edits code. CSS custom properties written during a drag gesture, plus
* the gesture marker attribute. Exported from @hyperframes/core/studio-api/draft-markers.
*/
export const STUDIO_OFFSET_X_PROP = "--hf-studio-offset-x";
export const STUDIO_OFFSET_Y_PROP = "--hf-studio-offset-y";
export const STUDIO_WIDTH_PROP = "--hf-studio-width";
export const STUDIO_HEIGHT_PROP = "--hf-studio-height";
export const STUDIO_MANUAL_EDIT_GESTURE_ATTR = "data-hf-studio-manual-edit-gesture";
@@ -0,0 +1,41 @@
import { describe, expect, it } from "vitest";
import { findUnsafeDomPatchValues, findUnsafeMutationValues } from "./finiteMutation";
describe("finiteMutation", () => {
it("reports non-finite numbers before mutation serialization", () => {
expect(
findUnsafeMutationValues({
type: "set-arc-path",
segments: [{ curviness: Number.NaN, cp1: { x: Infinity, y: 0 } }],
}).map((field) => field.path),
).toEqual(["body.segments[0].curviness", "body.segments[0].cp1.x"]);
});
it("treats null as unsafe because JSON serializes NaN and Infinity to null", () => {
expect(
findUnsafeMutationValues({
type: "update-property",
property: "x",
value: null,
}),
).toEqual([{ path: "body.value", reason: "null" }]);
});
it("allows explicit DOM patch value removals while rejecting unsafe patch metadata", () => {
expect(
findUnsafeDomPatchValues({
target: { id: "title", selectorIndex: null },
operations: [{ type: "inline-style", property: "opacity", value: null }],
}),
).toEqual([{ path: "body.target.selectorIndex", reason: "null" }]);
});
it("rejects non-finite DOM patch values before JSON serialization can turn them into null", () => {
expect(
findUnsafeDomPatchValues({
target: { id: "title" },
operations: [{ type: "inline-style", property: "left", value: Number.NaN }],
}),
).toEqual([{ path: "body.operations[0].value", reason: "non-finite-number" }]);
});
});
@@ -0,0 +1,38 @@
export interface UnsafeMutationValue {
path: string;
reason: "non-finite-number" | "null";
}
interface FindUnsafeMutationValuesOptions {
allowNullPath?: (path: string) => boolean;
}
export function findUnsafeMutationValues(
value: unknown,
path = "body",
options: FindUnsafeMutationValuesOptions = {},
): UnsafeMutationValue[] {
if (value === null) {
return options.allowNullPath?.(path) ? [] : [{ path, reason: "null" }];
}
if (typeof value === "number") {
return Number.isFinite(value) ? [] : [{ path, reason: "non-finite-number" }];
}
if (!value || typeof value !== "object") return [];
if (Array.isArray(value)) {
return value.flatMap((item, index) =>
findUnsafeMutationValues(item, `${path}[${index}]`, options),
);
}
return Object.entries(value).flatMap(([key, item]) =>
findUnsafeMutationValues(item, `${path}.${key}`, options),
);
}
const DOM_PATCH_NULL_VALUE_PATH = /^body\.operations\[\d+\]\.value$/;
export function findUnsafeDomPatchValues(value: unknown): UnsafeMutationValue[] {
return findUnsafeMutationValues(value, "body", {
allowNullPath: (path) => DOM_PATCH_NULL_VALUE_PATH.test(path),
});
}
@@ -0,0 +1,115 @@
import { describe, it, expect, afterEach } from "vitest";
import { mkdtempSync, writeFileSync, readFileSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { persistHfIdsIfNeeded, stampFileHfIds } from "./hfIdPersist.js";
describe("persistHfIdsIfNeeded", () => {
const tmpDirs: string[] = [];
afterEach(() => {
for (const d of tmpDirs) rmSync(d, { recursive: true, force: true });
tmpDirs.length = 0;
});
function tmpFile(content: string): string {
const dir = mkdtempSync(join(tmpdir(), "hfid-test-"));
tmpDirs.push(dir);
const file = join(dir, "index.html");
writeFileSync(file, content, "utf-8");
return file;
}
it("writes data-hf-id to disk when source is untagged", () => {
const raw = `<!doctype html><html><body><div>hello</div></body></html>`;
const file = tmpFile(raw);
const returned = persistHfIdsIfNeeded(file, raw);
expect(returned).toContain('data-hf-id="hf-');
const onDisk = readFileSync(file, "utf-8");
expect(onDisk).toContain('data-hf-id="hf-');
expect(onDisk).toBe(returned);
});
it("does not rewrite disk when source is already tagged", () => {
const raw = `<!doctype html><html><body><div>hello</div></body></html>`;
const file = tmpFile(raw);
const tagged = persistHfIdsIfNeeded(file, raw);
const diskAfterFirst = readFileSync(file, "utf-8");
const returned2 = persistHfIdsIfNeeded(file, tagged);
expect(returned2).toBe(tagged);
expect(readFileSync(file, "utf-8")).toBe(diskAfterFirst);
});
it("does not rewrite when source is already tagged with non-standard HTML formatting", () => {
// Single-quoted attrs would cause a false-positive write under string-equality
// change detection; count-based detection handles this correctly.
const alreadyTagged = `<!doctype html><html><body><div data-hf-id='hf-ab12'>hello</div></body></html>`;
const file = tmpFile(alreadyTagged);
persistHfIdsIfNeeded(file, alreadyTagged);
expect(readFileSync(file, "utf-8")).toBe(alreadyTagged);
});
it("returned id matches id written to disk (serve-time == persist-time invariant)", () => {
const raw = `<!doctype html><html><body><span>text</span></body></html>`;
const file = tmpFile(raw);
const result = persistHfIdsIfNeeded(file, raw);
const onDisk = readFileSync(file, "utf-8");
expect(result).toBe(onDisk);
});
it("skips write if file was modified concurrently (TOCTOU guard)", () => {
const old = `<!doctype html><html><body><div>original</div></body></html>`;
const newer = `<!doctype html><html><body><div>modified by user</div></body></html>`;
// Disk has newer content — simulates a concurrent save after the server read old.
const file = tmpFile(newer);
const returned = persistHfIdsIfNeeded(file, old);
// Serve-time HTML gets ids based on what we read.
expect(returned).toContain('data-hf-id="hf-');
// Disk must not be overwritten — user's concurrent save is preserved.
expect(readFileSync(file, "utf-8")).toBe(newer);
});
});
describe("stampFileHfIds", () => {
const tmpDirs: string[] = [];
afterEach(() => {
for (const d of tmpDirs) rmSync(d, { recursive: true, force: true });
tmpDirs.length = 0;
});
function tmpFile(content: string): string {
const dir = mkdtempSync(join(tmpdir(), "hfid-stamp-test-"));
tmpDirs.push(dir);
const file = join(dir, "scene.html");
writeFileSync(file, content, "utf-8");
return file;
}
it("stamps ids and writes back through the same fd", () => {
const file = tmpFile(`<div class="clip" data-start="0" data-end="3">Hi</div>`);
const returned = stampFileHfIds(file);
expect(returned).toContain('data-hf-id="hf-');
expect(readFileSync(file, "utf-8")).toBe(returned);
});
it("does not rewrite an already-stamped file", () => {
const file = tmpFile(`<div data-hf-id="hf-keep">Hi</div>`);
const before = readFileSync(file, "utf-8");
const returned = stampFileHfIds(file);
expect(returned).toContain('data-hf-id="hf-keep"');
expect(readFileSync(file, "utf-8")).toBe(before); // byte-identical, no write
});
it("returns null for a missing file", () => {
const dir = mkdtempSync(join(tmpdir(), "hfid-stamp-test-"));
tmpDirs.push(dir);
expect(stampFileHfIds(join(dir, "nope.html"))).toBeNull();
});
it("returns null for a directory", () => {
const dir = mkdtempSync(join(tmpdir(), "hfid-stamp-test-"));
tmpDirs.push(dir);
expect(stampFileHfIds(dir)).toBeNull();
});
});
@@ -0,0 +1,104 @@
import { ensureHfIds } from "@hyperframes/parsers/hf-ids";
import {
closeSync,
constants,
fstatSync,
ftruncateSync,
openSync,
readFileSync,
writeFileSync,
writeSync,
} from "node:fs";
/**
* Ensure `html` has `data-hf-id` attributes minted, and write the result back
* to `filePath` if new ids were added.
*
* **Invariant:** `html` must be the raw file content read from `filePath` just
* before this call. If `html` is constructed or transformed HTML the TOCTOU
* guard (`current === html`) will never match and writes will silently be
* skipped — no ids will reach disk.
*/
export function persistHfIdsIfNeeded(filePath: string, html: string): string {
const normalized = ensureHfIds(html);
// Use attribute count instead of string equality: linkedom serialization may
// normalize quote style and whitespace even when no ids were actually minted,
// which would cause spurious writes on every request.
const idsBefore = (html.match(/\bdata-hf-id=/g) ?? []).length;
const idsAfter = (normalized.match(/\bdata-hf-id=/g) ?? []).length;
if (idsAfter > idsBefore) {
try {
// Re-read before writing to guard against concurrent user saves. If the
// file changed since we read it, skip the write — serving with ids is
// still correct; the next request will re-persist. Best-effort only: a
// user save landing between readFileSync and writeFileSync below can
// still be overwritten (microsecond window).
const current = readFileSync(filePath, "utf-8");
if (current === html) {
writeFileSync(filePath, normalized, "utf-8");
}
} catch (err) {
// Non-fatal — serve with ids even if the disk write fails (e.g. read-only
// filesystem, sandboxed environment). Log so the failure is diagnosable.
console.warn("[hyperframes] persistHfIdsIfNeeded: failed to write ids to disk:", err);
}
}
return normalized;
}
function openNoFollow(filePath: string, flags: number): number | null {
// O_NOFOLLOW is undefined on Windows; opening without it is the platform norm there.
const noFollow = constants.O_NOFOLLOW ?? 0;
try {
return openSync(filePath, flags | noFollow);
} catch {
return null;
}
}
/**
* Read `filePath`, mint any missing `data-hf-id`s, write the stamped content
* back if new ids were added, and return the stamped content — all through ONE
* file descriptor. Unlike the check-path / read-path / write-path sequence a
* route handler would otherwise do, the validation (fstat), read, and write
* all target the same open inode, so the path cannot be swapped (e.g. for a
* symlink) between validation and write (CodeQL js/file-system-race).
*
* Falls back to read-only stamping when the file isn't writable (read-only
* fs, sandbox) — serving stamped content without persisting is still correct;
* ids are content-keyed so the SDK mints the same ones from the same bytes.
*
* Returns null when the file is missing, unreadable, or not a regular file.
*
* Best-effort on concurrent saves: a user save landing between the read and
* the write below can still be overwritten (same microsecond window
* persistHfIdsIfNeeded documents) — the next save simply re-persists.
*/
export function stampFileHfIds(filePath: string): string | null {
let fd = openNoFollow(filePath, constants.O_RDWR);
let writable = true;
if (fd === null) {
fd = openNoFollow(filePath, constants.O_RDONLY);
writable = false;
}
if (fd === null) return null;
try {
if (!fstatSync(fd).isFile()) return null;
const html = readFileSync(fd, "utf-8");
const normalized = ensureHfIds(html);
// Attribute count, not string equality — linkedom serialization normalizes
// quote style/whitespace even when no ids were minted (see persistHfIdsIfNeeded).
const idsBefore = (html.match(/\bdata-hf-id=/g) ?? []).length;
const idsAfter = (normalized.match(/\bdata-hf-id=/g) ?? []).length;
if (writable && idsAfter > idsBefore) {
ftruncateSync(fd, 0);
writeSync(fd, normalized, 0, "utf-8");
}
return normalized;
} catch (err) {
console.warn("[hyperframes] stampFileHfIds: failed to stamp ids:", err);
return null;
} finally {
closeSync(fd);
}
}
@@ -0,0 +1,564 @@
import { describe, expect, it } from "vitest";
import { Window } from "happy-dom";
import {
createStudioManualEditsRenderBodyScript,
createStudioPositionSeekReapplyScript,
} from "./manualEditsRenderScript";
function runScript(
window: Window,
script: string,
getComputedStyle: typeof window.getComputedStyle = window.getComputedStyle.bind(window),
timers: {
setInterval?: typeof globalThis.setInterval;
clearInterval?: typeof globalThis.clearInterval;
} = {},
): void {
const execute = new Function(
"window",
"document",
"HTMLElement",
"getComputedStyle",
"setInterval",
"clearInterval",
script,
);
execute(
window,
window.document,
window.HTMLElement,
getComputedStyle,
timers.setInterval ??
(((callback: TimerHandler) => {
void callback;
return 0 as never;
}) as typeof globalThis.setInterval),
timers.clearInterval ?? globalThis.clearInterval,
);
}
describe("createStudioManualEditsRenderBodyScript", () => {
it("returns null for an empty manifest", () => {
expect(createStudioManualEditsRenderBodyScript("")).toBeNull();
});
it("applies manual edits and reapplies them after render seeks", () => {
const window = new Window();
window.document.body.innerHTML = '<div id="card" style="width: 20px; height: 20px"></div>';
const card = window.document.getElementById("card");
if (!(card instanceof window.HTMLElement)) {
throw new Error("card fixture missing");
}
let seekCalls = 0;
(
window as unknown as {
__hf: { seek: (time: number) => void };
}
).__hf = {
seek: () => {
seekCalls += 1;
card.style.removeProperty("translate");
},
};
const script = createStudioManualEditsRenderBodyScript(
JSON.stringify({
version: 1,
edits: [
{
kind: "path-offset",
target: { sourceFile: "index.html", id: "card" },
x: 12,
y: 24,
},
{
kind: "box-size",
target: { sourceFile: "index.html", id: "card" },
width: 120,
height: 64,
},
{
kind: "rotation",
target: { sourceFile: "index.html", id: "card" },
angle: 15,
},
],
}),
);
if (!script) throw new Error("script fixture missing");
const computedStyle = (element: Element) =>
({
display: element === card ? "block" : "block",
flexDirection: "row",
}) as CSSStyleDeclaration;
const intervalCallbacks: Array<() => void> = [];
runScript(window, script, computedStyle, {
setInterval: ((callback: TimerHandler) => {
if (typeof callback === "function") intervalCallbacks.push(callback as () => void);
return 0 as never;
}) as typeof globalThis.setInterval,
});
expect(card.style.getPropertyValue("translate")).toContain("--hf-studio-offset-x");
expect(card.style.getPropertyValue("width")).toBe("120px");
expect(card.style.getPropertyValue("height")).toBe("64px");
expect(card.style.getPropertyValue("rotate")).toContain("--hf-studio-rotation");
expect(card.style.getPropertyValue("transform-origin")).toBe("center center");
(
window as unknown as {
__hf: { seek: (time: number) => void };
}
).__hf.seek(1);
expect(seekCalls).toBe(1);
expect(card.style.getPropertyValue("translate")).toContain("--hf-studio-offset-x");
(
window as unknown as {
__hf: { seek: (time: number) => void };
}
).__hf.seek = () => {
card.style.removeProperty("rotate");
};
intervalCallbacks.forEach((callback) => callback());
(
window as unknown as {
__hf: { seek: (time: number) => void };
}
).__hf.seek(2);
expect(card.style.getPropertyValue("rotate")).toContain("--hf-studio-rotation");
(
window as unknown as {
__player: { renderSeek: (time: number) => void };
}
).__player = {
renderSeek: () => {
card.style.removeProperty("rotate");
},
};
intervalCallbacks.forEach((callback) => callback());
(
window as unknown as {
__player: { renderSeek: (time: number) => void };
}
).__player.renderSeek(3);
expect(card.style.getPropertyValue("rotate")).toContain("--hf-studio-rotation");
});
it("applies render edits to the matching source file target", () => {
const window = new Window();
window.document.body.innerHTML = `
<div data-composition-id="root">
<div id="card"></div>
<div data-composition-id="nested" data-composition-file="scenes/nested.html">
<div id="card"></div>
</div>
</div>
`;
const cards = Array.from(window.document.getElementsByTagName("*")).filter(
(element): element is HTMLElement =>
element instanceof window.HTMLElement && element.id === "card",
);
const rootCard = cards[0];
const nestedCard = cards[1];
if (!rootCard || !nestedCard) {
throw new Error("source-scoped render fixture missing");
}
const script = createStudioManualEditsRenderBodyScript(
JSON.stringify({
version: 1,
edits: [
{
kind: "rotation",
target: { sourceFile: "scenes/nested.html", id: "card" },
angle: 21,
},
],
}),
);
if (!script) throw new Error("script fixture missing");
runScript(window, script);
expect(rootCard.style.getPropertyValue("rotate")).toBe("");
expect(nestedCard.style.getPropertyValue("rotate")).toContain("--hf-studio-rotation");
});
it("applies render edits inside composition-file hosts without composition ids", () => {
const window = new Window();
window.document.body.innerHTML = `
<div data-composition-id="root">
<div id="card"></div>
<div data-composition-file="scenes/anonymous.html">
<div id="card"></div>
</div>
</div>
`;
const cards = Array.from(window.document.getElementsByTagName("*")).filter(
(element): element is HTMLElement =>
element instanceof window.HTMLElement && element.id === "card",
);
const rootCard = cards[0];
const nestedCard = cards[1];
if (!rootCard || !nestedCard) {
throw new Error("anonymous composition render fixture missing");
}
const script = createStudioManualEditsRenderBodyScript(
JSON.stringify({
version: 1,
edits: [
{
kind: "path-offset",
target: { sourceFile: "scenes/anonymous.html", id: "card" },
x: 12,
y: 24,
},
],
}),
);
if (!script) throw new Error("script fixture missing");
runScript(window, script);
expect(rootCard.style.getPropertyValue("translate")).toBe("");
expect(nestedCard.style.getPropertyValue("translate")).toContain("--hf-studio-offset-x");
});
it("uses the active composition path as the unscoped document fallback", () => {
const window = new Window();
window.document.body.innerHTML = `<div id="card"></div>`;
const card = window.document.getElementById("card");
if (!(card instanceof window.HTMLElement)) {
throw new Error("card fixture missing");
}
const script = createStudioManualEditsRenderBodyScript(
JSON.stringify({
version: 1,
edits: [
{
kind: "path-offset",
target: { sourceFile: "compositions/scene-2.html", id: "card" },
x: 12,
y: 24,
},
],
}),
{ activeCompositionPath: "compositions/scene-2.html" },
);
if (!script) throw new Error("script fixture missing");
runScript(window, script);
expect(card.style.getPropertyValue("translate")).toContain("--hf-studio-offset-x");
});
it("preserves computed transform longhands as render edit bases", () => {
const window = new Window();
window.document.body.innerHTML = `<div id="card"></div>`;
const card = window.document.getElementById("card");
if (!(card instanceof window.HTMLElement)) {
throw new Error("card fixture missing");
}
const script = createStudioManualEditsRenderBodyScript(
JSON.stringify({
version: 1,
edits: [
{
kind: "path-offset",
target: { sourceFile: "index.html", id: "card" },
x: 12,
y: 24,
},
{
kind: "rotation",
target: { sourceFile: "index.html", id: "card" },
angle: 15,
},
],
}),
);
if (!script) throw new Error("script fixture missing");
const computedStyle = (element: Element) =>
({
getPropertyValue: (property: string) => {
if (element !== card) return "";
if (property === "translate") return "10px 20px";
if (property === "rotate") return "8deg";
return "";
},
}) as CSSStyleDeclaration;
runScript(window, script, computedStyle);
expect(card.style.getPropertyValue("translate")).toContain("calc(10px +");
expect(card.style.getPropertyValue("translate")).toContain("calc(20px +");
expect(card.style.getPropertyValue("rotate")).toContain("8deg");
expect(card.style.getPropertyValue("rotate")).toContain("--hf-studio-rotation");
expect(card.style.getPropertyValue("transform-origin")).toBe("center center");
});
it("does not compound stale studio variables during render reapply", () => {
const window = new Window();
window.document.body.innerHTML = `
<div id="card" style="
translate: var(--hf-studio-offset-x, 0px) var(--hf-studio-offset-y, 0px);
rotate: var(--hf-studio-rotation, 0deg);
"></div>
`;
const card = window.document.getElementById("card");
if (!(card instanceof window.HTMLElement)) {
throw new Error("card fixture missing");
}
const script = createStudioManualEditsRenderBodyScript(
JSON.stringify({
version: 1,
edits: [
{
kind: "path-offset",
target: { sourceFile: "index.html", id: "card" },
x: 12,
y: 24,
},
{
kind: "rotation",
target: { sourceFile: "index.html", id: "card" },
angle: 15,
},
],
}),
);
if (!script) throw new Error("script fixture missing");
runScript(window, script);
expect(card.style.getPropertyValue("translate")).toBe(
"var(--hf-studio-offset-x, 0px) var(--hf-studio-offset-y, 0px)",
);
expect(card.style.getPropertyValue("rotate")).toBe("var(--hf-studio-rotation, 0deg)");
});
it("exposes a render reapply hook for thumbnails after layout settles", () => {
const window = new Window();
window.document.body.innerHTML = `<div id="card"></div>`;
const card = window.document.getElementById("card");
if (!(card instanceof window.HTMLElement)) {
throw new Error("card fixture missing");
}
const script = createStudioManualEditsRenderBodyScript(
JSON.stringify({
version: 1,
edits: [
{
kind: "path-offset",
target: { sourceFile: "index.html", id: "card" },
x: 12,
y: 24,
},
],
}),
);
if (!script) throw new Error("script fixture missing");
runScript(window, script);
card.style.removeProperty("translate");
(
window as unknown as {
__hfStudioManualEditsApply?: () => number;
}
).__hfStudioManualEditsApply?.();
expect(card.style.getPropertyValue("translate")).toContain("--hf-studio-offset-x");
});
});
describe("createStudioPositionSeekReapplyScript", () => {
function runPositionScript(
window: Window,
timers: {
setInterval?: typeof globalThis.setInterval;
clearInterval?: typeof globalThis.clearInterval;
} = {},
): void {
Object.assign(window, { SyntaxError });
const script = createStudioPositionSeekReapplyScript();
const execute = new Function(
"window",
"document",
"HTMLElement",
"DOMMatrix",
"setInterval",
"clearInterval",
script,
);
execute(
window,
window.document,
window.HTMLElement,
globalThis.DOMMatrix,
timers.setInterval ??
(((callback: TimerHandler) => {
void callback;
return 0 as never;
}) as typeof globalThis.setInterval),
timers.clearInterval ?? globalThis.clearInterval,
);
}
it("reapplies box-size after seek", () => {
const window = new Window();
window.document.body.innerHTML = `
<div id="card"
data-hf-studio-box-size="true"
style="--hf-studio-width: 200px; --hf-studio-height: 100px; width: 200px; height: 100px">
</div>
`;
const card = window.document.getElementById("card") as unknown as HTMLElement;
const originalSeek = () => {
card.style.removeProperty("width");
card.style.removeProperty("height");
};
(window as unknown as { __hf: Record<string, unknown> }).__hf = { seek: originalSeek };
runPositionScript(window);
const wrappedSeek = (window as unknown as { __hf: { seek: (t: number) => void } }).__hf.seek;
wrappedSeek(1);
expect(card.style.getPropertyValue("width")).toBe("200px");
expect(card.style.getPropertyValue("height")).toBe("100px");
});
it("strips GSAP translate from transform after reapplying path offset", () => {
const window = new Window();
window.document.body.innerHTML = `
<div id="card"
data-hf-studio-path-offset="true"
data-hf-studio-original-translate=""
style="--hf-studio-offset-x: 50px; --hf-studio-offset-y: 30px; translate: var(--hf-studio-offset-x, 0px) var(--hf-studio-offset-y, 0px)">
</div>
`;
const card = window.document.getElementById("card") as unknown as HTMLElement;
const originalSeek = () => {
card.style.setProperty("transform", "matrix(1, 0, 0, 1, 120, 60)");
};
(window as unknown as { __hf: Record<string, unknown> }).__hf = { seek: originalSeek };
runPositionScript(window);
const wrappedSeek = (window as unknown as { __hf: { seek: (t: number) => void } }).__hf.seek;
wrappedSeek(1);
expect(card.style.getPropertyValue("translate")).toContain("--hf-studio-offset-x");
const transform = card.style.getPropertyValue("transform");
if (transform && transform !== "none") {
const m = new DOMMatrix(transform);
expect(m.m41).toBe(0);
expect(m.m42).toBe(0);
}
});
it("preserves non-translate components when stripping GSAP transform", () => {
const window = new Window();
window.document.body.innerHTML = `
<div id="card"
data-hf-studio-path-offset="true"
data-hf-studio-original-translate=""
style="--hf-studio-offset-x: 10px; --hf-studio-offset-y: 20px; translate: var(--hf-studio-offset-x, 0px) var(--hf-studio-offset-y, 0px)">
</div>
`;
const card = window.document.getElementById("card") as unknown as HTMLElement;
const originalSeek = () => {
card.style.setProperty("transform", "matrix(0.5, 0, 0, 0.5, 80, 40)");
};
(window as unknown as { __hf: Record<string, unknown> }).__hf = { seek: originalSeek };
runPositionScript(window);
const wrappedSeek = (window as unknown as { __hf: { seek: (t: number) => void } }).__hf.seek;
wrappedSeek(1);
const transform = card.style.getPropertyValue("transform");
expect(transform).toBeTruthy();
expect(transform).not.toContain("80");
expect(transform).not.toContain("40");
});
it("removes transform entirely when it becomes identity after stripping translate", () => {
const window = new Window();
window.document.body.innerHTML = `
<div id="card"
data-hf-studio-path-offset="true"
data-hf-studio-original-translate=""
style="--hf-studio-offset-x: 10px; --hf-studio-offset-y: 20px; translate: var(--hf-studio-offset-x, 0px) var(--hf-studio-offset-y, 0px)">
</div>
`;
const card = window.document.getElementById("card") as unknown as HTMLElement;
const originalSeek = () => {
card.style.setProperty("transform", "matrix(1, 0, 0, 1, 50, 25)");
};
(window as unknown as { __hf: Record<string, unknown> }).__hf = { seek: originalSeek };
runPositionScript(window);
const wrappedSeek = (window as unknown as { __hf: { seek: (t: number) => void } }).__hf.seek;
wrappedSeek(1);
const transform = card.style.getPropertyValue("transform");
expect(!transform || transform === "none" || transform === "").toBe(true);
});
it("no-ops when transform is 'none'", () => {
const window = new Window();
window.document.body.innerHTML = `
<div id="card"
data-hf-studio-path-offset="true"
data-hf-studio-original-translate=""
style="--hf-studio-offset-x: 10px; --hf-studio-offset-y: 20px; translate: var(--hf-studio-offset-x, 0px) var(--hf-studio-offset-y, 0px); transform: none">
</div>
`;
const card = window.document.getElementById("card") as unknown as HTMLElement;
(window as unknown as { __hf: Record<string, unknown> }).__hf = { seek: () => {} };
runPositionScript(window);
expect(card.style.getPropertyValue("transform")).toBe("none");
});
it("strips GSAP translate for rotation-only elements", () => {
const window = new Window();
window.document.body.innerHTML = `
<div id="card"
data-hf-studio-rotation="true"
data-hf-studio-original-rotate=""
style="--hf-studio-rotation: 45deg; rotate: var(--hf-studio-rotation, 0deg)">
</div>
`;
const card = window.document.getElementById("card") as unknown as HTMLElement;
const originalSeek = () => {
card.style.setProperty("transform", "matrix(1, 0, 0, 1, 100, 50)");
};
(window as unknown as { __hf: Record<string, unknown> }).__hf = { seek: originalSeek };
runPositionScript(window);
const wrappedSeek = (window as unknown as { __hf: { seek: (t: number) => void } }).__hf.seek;
wrappedSeek(1);
expect(card.style.getPropertyValue("rotate")).toContain("--hf-studio-rotation");
const transform = card.style.getPropertyValue("transform");
expect(!transform || transform === "none" || transform === "").toBe(true);
});
});
@@ -0,0 +1,735 @@
// fallow-ignore-file code-duplication
export interface StudioManualEditsRenderScriptOptions {
activeCompositionPath?: string | null;
}
export const STUDIO_MANUAL_EDITS_PATH = ".hyperframes/studio-manual-edits.json";
export function createStudioManualEditsRenderBodyScript(
manifestContent: string,
options: StudioManualEditsRenderScriptOptions = {},
): string | null {
if (!manifestContent.trim()) return null;
return `(${studioManualEditsRenderRuntime.toString()})(${JSON.stringify(manifestContent)}, ${JSON.stringify(options.activeCompositionPath ?? null)});`;
}
/**
* Returns a self-contained IIFE string that re-applies studio position edits
* (translate, rotate) after every GSAP seek by querying data attributes baked
* into the HTML. Works without a JSON manifest — positions are already inlined
* as CSS custom properties on the elements.
*/
export function createStudioPositionSeekReapplyScript(): string {
return `(${studioPositionSeekReapplyRuntime.toString()})();`;
}
function studioPositionSeekReapplyRuntime(): void {
const OFFSET_X_PROP = "--hf-studio-offset-x";
const OFFSET_Y_PROP = "--hf-studio-offset-y";
const WIDTH_PROP = "--hf-studio-width";
const HEIGHT_PROP = "--hf-studio-height";
const ROTATION_PROP = "--hf-studio-rotation";
const PATH_OFFSET_ATTR = "data-hf-studio-path-offset";
const BOX_SIZE_ATTR = "data-hf-studio-box-size";
const ROTATION_ATTR = "data-hf-studio-rotation";
const ORIGINAL_TRANSLATE_ATTR = "data-hf-studio-original-translate";
const ORIGINAL_ROTATE_ATTR = "data-hf-studio-original-rotate";
const MOTION_ATTR = "data-hf-studio-motion";
const MOTION_TL_KEY = "studio-motion";
const WRAPPED_PROP = "__hfStudioPositionSeekReapplyWrapped";
if (
!document.querySelector("[" + PATH_OFFSET_ATTR + '="true"]') &&
!document.querySelector("[" + BOX_SIZE_ATTR + '="true"]') &&
!document.querySelector("[" + ROTATION_ATTR + '="true"]') &&
!document.querySelector("[" + MOTION_ATTR + "]")
)
return;
const splitTopLevelWhitespace = (value: string): string[] => {
const parts: string[] = [];
let depth = 0;
let current = "";
for (const char of value.trim()) {
if (char === "(") depth += 1;
if (char === ")") depth = Math.max(0, depth - 1);
if (/\s/.test(char) && depth === 0) {
if (current) parts.push(current);
current = "";
} else {
current += char;
}
}
if (current) parts.push(current);
return parts;
};
const composeTranslate = (element: HTMLElement, x: string, y: string): string => {
const original = element.getAttribute(ORIGINAL_TRANSLATE_ATTR)?.trim();
if (!original || original === "none") return x + " " + y;
const parts = splitTopLevelWhitespace(original);
if (parts.length === 1) return "calc(" + parts[0] + " + " + x + ") " + y;
if (parts.length >= 2) {
const z = parts.length >= 3 ? " " + parts[2] : "";
return "calc(" + parts[0] + " + " + x + ") calc(" + parts[1] + " + " + y + ")" + z;
}
return x + " " + y;
};
const isSimpleRotateAngle = (value: string): boolean =>
/^-?(?:\d+(?:\.\d+)?|\.\d+)(?:deg|rad|turn|grad)$/.test(value.trim());
const composeRotation = (element: HTMLElement, rotationValue: string): string => {
const original = element.getAttribute(ORIGINAL_ROTATE_ATTR)?.trim();
if (!original || original === "none" || !isSimpleRotateAngle(original)) return rotationValue;
return "calc(" + original + " + " + rotationValue + ")";
};
let lastSeekTime = 0;
let cachedMotionKey = "";
const finiteNum = (v: unknown): number | null =>
typeof v === "number" && Number.isFinite(v) ? v : null;
const computeMotionKey = (motionEls: NodeListOf<Element>): string => {
let key = "";
for (let i = 0; i < motionEls.length; i++) {
const json = (motionEls[i] as HTMLElement).getAttribute?.(MOTION_ATTR);
if (json) key += (key ? "\n" : "") + json;
}
return key;
};
const reapplyMotionTimeline = (): void => {
const motionEls = document.querySelectorAll("[" + MOTION_ATTR + "]");
if (motionEls.length === 0) {
cachedMotionKey = "";
return;
}
const win = window as Window & {
gsap?: {
timeline?: (opts: Record<string, unknown>) => Record<string, unknown>;
set?: (el: HTMLElement, vars: Record<string, unknown>) => void;
registerPlugin?: (plugin: unknown) => void;
};
CustomEase?: { create?: (id: string, data: string) => void };
__timelines?: Record<string, Record<string, unknown>>;
};
const gsap = win.gsap;
if (!gsap || typeof gsap.timeline !== "function") return;
win.__timelines = win.__timelines || {};
// Cache the timeline keyed by the concatenated motion JSON strings.
// On each seek, if the key hasn't changed, just seek the existing timeline
// instead of rebuilding it (avoids kill+recreate on every frame).
const motionKey = computeMotionKey(motionEls);
const existing = win.__timelines[MOTION_TL_KEY];
if (
motionKey &&
motionKey === cachedMotionKey &&
existing &&
typeof existing.totalTime === "function"
) {
(existing.totalTime as (t: number, s: boolean) => void)(lastSeekTime, false);
return;
}
if (existing && typeof existing.kill === "function") (existing.kill as () => void)();
const tl = gsap.timeline({ paused: true, defaults: { overwrite: "auto" } });
const fromTo = tl.fromTo as (
el: HTMLElement,
from: Record<string, unknown>,
to: Record<string, unknown>,
pos: number,
) => void;
if (typeof fromTo !== "function") return;
let applied = 0;
for (let i = 0; i < motionEls.length; i++) {
const el = motionEls[i] as HTMLElement;
if (!(el instanceof HTMLElement)) continue;
const json = el.getAttribute(MOTION_ATTR);
if (!json) continue;
try {
const m = JSON.parse(json) as Record<string, unknown>;
const start = finiteNum(m.start);
const duration = finiteNum(m.duration);
if (start == null || duration == null || duration <= 0) continue;
const ease = typeof m.ease === "string" ? m.ease : "none";
const from = (m.from && typeof m.from === "object" ? m.from : {}) as Record<
string,
unknown
>;
const to = (m.to && typeof m.to === "object" ? m.to : {}) as Record<string, unknown>;
const customEase = m.customEase as { id?: string; data?: string } | null | undefined;
let resolvedEase = ease;
if (customEase?.id && customEase?.data && win.CustomEase?.create) {
try {
gsap.registerPlugin?.(win.CustomEase);
win.CustomEase.create(customEase.id, customEase.data);
resolvedEase = customEase.id;
} catch {
/* use default ease */
}
}
fromTo.call(
tl,
el,
{ ...from },
{ ...to, duration, ease: resolvedEase, overwrite: "auto", immediateRender: false },
start,
);
applied += 1;
} catch {
/* malformed JSON — skip */
}
}
if (applied === 0) {
cachedMotionKey = "";
if (typeof (tl as { kill?: () => void }).kill === "function")
(tl as { kill: () => void }).kill();
return;
}
cachedMotionKey = motionKey;
win.__timelines[MOTION_TL_KEY] = tl;
if (typeof tl.pause === "function") (tl.pause as () => void)();
if (typeof tl.totalTime === "function")
(tl.totalTime as (t: number, s: boolean) => void)(lastSeekTime, false);
};
const stripGsapTranslateFromTransform = (el: HTMLElement): void => {
const transform = el.style.getPropertyValue("transform");
if (!transform || transform === "none") return;
const win = el.ownerDocument.defaultView as (Window & typeof globalThis) | null;
const MatrixCtor = (win as unknown as { DOMMatrix?: typeof DOMMatrix })?.DOMMatrix;
if (!MatrixCtor) return;
try {
const m = new MatrixCtor(transform);
if (m.m41 === 0 && m.m42 === 0) return;
m.m41 = 0;
m.m42 = 0;
if (m.is2D && m.a === 1 && m.b === 0 && m.c === 0 && m.d === 1) {
el.style.removeProperty("transform");
} else {
el.style.setProperty("transform", m.toString());
}
} catch {
/* non-parseable transform — leave as-is */
}
};
const reapplyAll = (): void => {
const offsetEls = document.querySelectorAll("[" + PATH_OFFSET_ATTR + '="true"]');
for (let i = 0; i < offsetEls.length; i++) {
const el = offsetEls[i] as HTMLElement;
if (!(el instanceof HTMLElement)) continue;
const x = el.style.getPropertyValue(OFFSET_X_PROP);
const y = el.style.getPropertyValue(OFFSET_Y_PROP);
if (x || y) {
el.style.setProperty(
"translate",
composeTranslate(
el,
"var(" + OFFSET_X_PROP + ", 0px)",
"var(" + OFFSET_Y_PROP + ", 0px)",
),
);
stripGsapTranslateFromTransform(el);
}
}
const boxSizeEls = document.querySelectorAll("[" + BOX_SIZE_ATTR + '="true"]');
for (let i = 0; i < boxSizeEls.length; i++) {
const el = boxSizeEls[i] as HTMLElement;
if (!(el instanceof HTMLElement)) continue;
const w = el.style.getPropertyValue(WIDTH_PROP);
const h = el.style.getPropertyValue(HEIGHT_PROP);
if (w) el.style.setProperty("width", w);
if (h) el.style.setProperty("height", h);
}
const rotEls = document.querySelectorAll("[" + ROTATION_ATTR + '="true"]');
for (let i = 0; i < rotEls.length; i++) {
const el = rotEls[i] as HTMLElement;
if (!(el instanceof HTMLElement)) continue;
const rot = el.style.getPropertyValue(ROTATION_PROP);
if (rot) {
el.style.setProperty("rotate", composeRotation(el, "var(" + ROTATION_PROP + ", 0deg)"));
stripGsapTranslateFromTransform(el);
}
}
reapplyMotionTimeline();
};
const runtimeWindow = window as Window & {
__hf?: Record<string, unknown>;
__player?: Record<string, unknown>;
};
const isWrapped = (fn: (time: number) => unknown): boolean =>
Boolean((fn as unknown as Record<string, unknown>)[WRAPPED_PROP]);
const markWrapped = (fn: (time: number) => unknown): void => {
try {
Object.defineProperty(fn, WRAPPED_PROP, {
configurable: false,
enumerable: false,
value: true,
});
} catch {
try {
(fn as unknown as Record<string, unknown>)[WRAPPED_PROP] = true;
} catch {
/* ignore */
}
}
};
const wrapFn = (get: () => unknown, set: (fn: (time: number) => unknown) => void): boolean => {
const fn = get();
if (typeof fn !== "function") return false;
const seek = fn as (time: number) => unknown;
if (isWrapped(seek)) {
reapplyAll();
return true;
}
const wrapped = function (this: unknown, time: number): unknown {
lastSeekTime = typeof time === "number" && Number.isFinite(time) ? Math.max(0, time) : 0;
const result = seek.call(this, time);
reapplyAll();
return result;
};
markWrapped(wrapped);
set(wrapped);
reapplyAll();
return true;
};
const wrapSeekFunctions = (): boolean => {
const a = wrapFn(
() => runtimeWindow.__hf?.["seek"],
(fn) => {
if (runtimeWindow.__hf) runtimeWindow.__hf["seek"] = fn;
},
);
const b = wrapFn(
() => runtimeWindow.__player?.["renderSeek"],
(fn) => {
if (runtimeWindow.__player) runtimeWindow.__player["renderSeek"] = fn;
},
);
return a || b;
};
const installSeekTrap = (
obj: Record<string, unknown> | undefined,
key: string,
getter: () => unknown,
setter: (fn: (time: number) => unknown) => void,
): void => {
if (!obj) return;
try {
let current = obj[key];
Object.defineProperty(obj, key, {
configurable: true,
enumerable: true,
get() {
return current;
},
set(value: unknown) {
current = value;
if (typeof value === "function" && !isWrapped(value as (time: number) => unknown)) {
wrapFn(getter, setter);
}
},
});
} catch {
/* non-configurable — fall back to polling */
}
};
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", () => reapplyAll(), { once: true });
} else {
reapplyAll();
}
wrapSeekFunctions();
installSeekTrap(
runtimeWindow.__hf,
"seek",
() => runtimeWindow.__hf?.["seek"],
(fn) => {
if (runtimeWindow.__hf) runtimeWindow.__hf["seek"] = fn;
},
);
installSeekTrap(
runtimeWindow.__player as Record<string, unknown> | undefined,
"renderSeek",
() => runtimeWindow.__player?.["renderSeek"],
(fn) => {
if (runtimeWindow.__player) runtimeWindow.__player["renderSeek"] = fn;
},
);
let remaining = 120;
const interval = setInterval(() => {
wrapSeekFunctions();
remaining -= 1;
if (remaining <= 0) clearInterval(interval);
}, 50);
}
function studioManualEditsRenderRuntime(
manifestContent: string,
activeCompositionPath: string | null,
): void {
const OFFSET_X_PROP = "--hf-studio-offset-x";
const OFFSET_Y_PROP = "--hf-studio-offset-y";
const WIDTH_PROP = "--hf-studio-width";
const HEIGHT_PROP = "--hf-studio-height";
const ROTATION_PROP = "--hf-studio-rotation";
const PATH_OFFSET_ATTR = "data-hf-studio-path-offset";
const BOX_SIZE_ATTR = "data-hf-studio-box-size";
const ROTATION_ATTR = "data-hf-studio-rotation";
const ORIGINAL_TRANSLATE_ATTR = "data-hf-studio-original-translate";
const ORIGINAL_ROTATE_ATTR = "data-hf-studio-original-rotate";
const WRAPPED_SEEK_PROP = "__hfStudioManualEditsWrapped";
const ROTATION_TRANSFORM_ORIGIN = "center center";
const finiteNumber = (value: unknown): number | null =>
typeof value === "number" && Number.isFinite(value) ? value : null;
const objectRecord = (value: unknown): Record<string, unknown> | null =>
value && typeof value === "object" ? (value as Record<string, unknown>) : null;
const runtimeWindow = window as Window & {
__hf?: { seek?: (time: number) => unknown };
__hfStudioManualEditsApply?: () => number;
__player?: { renderSeek?: (time: number) => unknown };
};
const parsedManifest = (() => {
try {
return objectRecord(JSON.parse(manifestContent));
} catch {
return null;
}
})();
const manifestEdits = Array.isArray(parsedManifest?.edits) ? parsedManifest.edits : [];
if (manifestEdits.length === 0) return;
const sourceFileForElement = (element: HTMLElement): string => {
let current: HTMLElement | null = element;
while (current) {
const sourceFile =
current.getAttribute("data-composition-file") ??
current.getAttribute("data-composition-src");
if (sourceFile) return sourceFile;
current = current.parentElement;
}
return activeCompositionPath ?? "index.html";
};
const elementMatchesSourceFile = (element: HTMLElement, sourceFile: string): boolean =>
sourceFileForElement(element) === sourceFile;
const styleUsesStudioOffset = (value: string): boolean =>
value.includes(OFFSET_X_PROP) || value.includes(OFFSET_Y_PROP);
const styleUsesStudioRotation = (value: string): boolean => value.includes(ROTATION_PROP);
const splitTopLevelWhitespace = (value: string): string[] => {
const parts: string[] = [];
let depth = 0;
let current = "";
for (const char of value.trim()) {
if (char === "(") depth += 1;
if (char === ")") depth = Math.max(0, depth - 1);
if (/\s/.test(char) && depth === 0) {
if (current) parts.push(current);
current = "";
} else {
current += char;
}
}
if (current) parts.push(current);
return parts;
};
const composeTranslate = (element: HTMLElement, x: string, y: string): string => {
const original = element.getAttribute(ORIGINAL_TRANSLATE_ATTR)?.trim();
if (!original || original === "none") return `${x} ${y}`;
const parts = splitTopLevelWhitespace(original);
if (parts.length === 1) return `calc(${parts[0]} + ${x}) ${y}`;
if (parts.length === 2) return `calc(${parts[0]} + ${x}) calc(${parts[1]} + ${y})`;
if (parts.length === 3) {
return `calc(${parts[0]} + ${x}) calc(${parts[1]} + ${y}) ${parts[2]}`;
}
return `${x} ${y}`;
};
const readStyleOrComputed = (element: HTMLElement, property: string): string => {
try {
return (
element.style.getPropertyValue(property) ||
getComputedStyle(element).getPropertyValue(property)
);
} catch {
return element.style.getPropertyValue(property);
}
};
const readTransformLonghandBase = (
element: HTMLElement,
property: "translate" | "rotate",
): string => {
const value = readStyleOrComputed(element, property).trim();
return value === "none" ? "" : value;
};
const preparePathOffsetBase = (element: HTMLElement): void => {
const currentTranslate = readTransformLonghandBase(element, "translate");
const hasMarker = element.hasAttribute(PATH_OFFSET_ATTR);
const wasResetByAnimation = !styleUsesStudioOffset(currentTranslate);
if (!hasMarker) {
element.setAttribute(ORIGINAL_TRANSLATE_ATTR, wasResetByAnimation ? currentTranslate : "");
} else if (wasResetByAnimation) {
element.setAttribute(ORIGINAL_TRANSLATE_ATTR, currentTranslate);
}
};
const prepareRotationBase = (element: HTMLElement): void => {
const currentRotate = readTransformLonghandBase(element, "rotate");
const hasMarker = element.hasAttribute(ROTATION_ATTR);
const wasResetByAnimation = !styleUsesStudioRotation(currentRotate);
if (!hasMarker) {
element.setAttribute(ORIGINAL_ROTATE_ATTR, wasResetByAnimation ? currentRotate : "");
} else if (wasResetByAnimation) {
element.setAttribute(ORIGINAL_ROTATE_ATTR, currentRotate);
}
};
const querySelectorCandidates = (selector: string): HTMLElement[] => {
const isCandidate = (element: Element): element is HTMLElement =>
element instanceof HTMLElement;
const className = selector.match(/^\.([A-Za-z0-9_-]+)$/)?.[1];
if (className) {
return Array.from(document.getElementsByTagName("*")).filter(
(element): element is HTMLElement =>
isCandidate(element) && element.classList.contains(className),
);
}
if (/^[A-Za-z][A-Za-z0-9-]*$/.test(selector)) {
return Array.from(document.getElementsByTagName(selector)).filter(isCandidate);
}
return Array.from(document.querySelectorAll(selector)).filter(isCandidate);
};
const resolveTarget = (edit: Record<string, unknown>): HTMLElement | null => {
const targetRecord = objectRecord(edit.target);
if (!targetRecord) return null;
const sourceFile = typeof targetRecord.sourceFile === "string" ? targetRecord.sourceFile : "";
if (!sourceFile) return null;
const id = typeof targetRecord.id === "string" ? targetRecord.id : "";
if (id) {
const byId = document.getElementById(id);
if (byId instanceof HTMLElement && elementMatchesSourceFile(byId, sourceFile)) return byId;
const matchesById = [
document.documentElement,
...Array.from(document.getElementsByTagName("*")),
].filter(
(element): element is HTMLElement =>
element instanceof HTMLElement &&
element.id === id &&
elementMatchesSourceFile(element, sourceFile),
);
if (matchesById[0]) return matchesById[0];
}
const selector = typeof targetRecord.selector === "string" ? targetRecord.selector : "";
if (!selector) return null;
try {
const matches = querySelectorCandidates(selector).filter((element) =>
elementMatchesSourceFile(element, sourceFile),
);
const selectorIndex = finiteNumber(targetRecord.selectorIndex) ?? 0;
return matches[Math.max(0, Math.floor(selectorIndex))] ?? null;
} catch {
return null;
}
};
const roundRotationAngle = (angle: number): number => Math.round(angle * 10) / 10;
const isSimpleRotateAngle = (value: string): boolean =>
/^-?(?:\d+(?:\.\d+)?|\.\d+)(?:deg|rad|turn|grad)$/.test(value.trim());
const composeRotation = (element: HTMLElement, rotationValue: string): string => {
const original = element.getAttribute(ORIGINAL_ROTATE_ATTR)?.trim();
if (!original || original === "none" || !isSimpleRotateAngle(original)) {
return rotationValue;
}
return `calc(${original} + ${rotationValue})`;
};
const applyPathOffset = (element: HTMLElement, edit: Record<string, unknown>): void => {
const x = finiteNumber(edit.x);
const y = finiteNumber(edit.y);
if (x == null || y == null) return;
preparePathOffsetBase(element);
element.setAttribute(PATH_OFFSET_ATTR, "true");
element.style.setProperty(OFFSET_X_PROP, `${Math.round(x)}px`);
element.style.setProperty(OFFSET_Y_PROP, `${Math.round(y)}px`);
element.style.setProperty(
"translate",
composeTranslate(element, `var(${OFFSET_X_PROP}, 0px)`, `var(${OFFSET_Y_PROP}, 0px)`),
);
};
const readParentFlexBasisPixels = (
element: HTMLElement,
size: { width: number; height: number },
): number | null => {
const parent = element.parentElement;
if (!parent) return null;
const styles = getComputedStyle(parent);
if (styles.display !== "flex" && styles.display !== "inline-flex") return null;
return Math.round(
Math.max(1, styles.flexDirection.startsWith("column") ? size.height : size.width),
);
};
const applyBoxSize = (element: HTMLElement, edit: Record<string, unknown>): void => {
const width = finiteNumber(edit.width);
const height = finiteNumber(edit.height);
if (width == null || height == null || width <= 0 || height <= 0) return;
const rounded = {
width: Math.round(Math.max(1, width)),
height: Math.round(Math.max(1, height)),
};
element.setAttribute(BOX_SIZE_ATTR, "true");
element.style.setProperty(WIDTH_PROP, `${rounded.width}px`);
element.style.setProperty(HEIGHT_PROP, `${rounded.height}px`);
element.style.setProperty("box-sizing", "border-box");
element.style.setProperty("width", `${rounded.width}px`);
element.style.setProperty("height", `${rounded.height}px`);
element.style.setProperty("min-width", "0px");
element.style.setProperty("min-height", "0px");
element.style.setProperty("max-width", "none");
element.style.setProperty("max-height", "none");
const flexBasis = readParentFlexBasisPixels(element, rounded);
if (flexBasis != null) {
element.style.setProperty("flex-basis", `${flexBasis}px`);
element.style.setProperty("flex-grow", "0");
element.style.setProperty("flex-shrink", "0");
}
if (getComputedStyle(element).display === "inline") {
element.style.setProperty("display", "inline-block");
}
};
const applyRotation = (element: HTMLElement, edit: Record<string, unknown>): void => {
const angle = finiteNumber(edit.angle);
if (angle == null) return;
prepareRotationBase(element);
element.setAttribute(ROTATION_ATTR, "true");
element.style.setProperty(ROTATION_PROP, `${roundRotationAngle(angle)}deg`);
element.style.setProperty("transform-origin", ROTATION_TRANSFORM_ORIGIN);
element.style.setProperty("rotate", composeRotation(element, `var(${ROTATION_PROP}, 0deg)`));
};
const applyManifest = (): number => {
let applied = 0;
for (const edit of manifestEdits) {
const editRecord = objectRecord(edit);
if (!editRecord) continue;
const element = resolveTarget(editRecord);
if (!element) continue;
if (editRecord.kind === "path-offset") applyPathOffset(element, editRecord);
if (editRecord.kind === "box-size") applyBoxSize(element, editRecord);
if (editRecord.kind === "rotation") applyRotation(element, editRecord);
applied += 1;
}
return applied;
};
runtimeWindow.__hfStudioManualEditsApply = applyManifest;
const markWrapped = (fn: (time: number) => unknown): void => {
try {
Object.defineProperty(fn, WRAPPED_SEEK_PROP, {
configurable: false,
enumerable: false,
value: true,
});
} catch {
try {
(fn as unknown as Record<string, unknown>)[WRAPPED_SEEK_PROP] = true;
} catch {
// Ignore non-extensible functions.
}
}
};
const isWrapped = (fn: (time: number) => unknown): boolean =>
Boolean((fn as unknown as Record<string, unknown>)[WRAPPED_SEEK_PROP]);
const wrapFunction = (
get: () => ((time: number) => unknown) | undefined,
set: (fn: (time: number) => unknown) => void,
): boolean => {
const fn = get();
if (!fn) return false;
const seek = fn as (time: number) => unknown;
if (isWrapped(seek)) {
applyManifest();
return true;
}
const wrappedSeek = function (this: unknown, time: number): unknown {
const result = seek.call(this, time);
applyManifest();
return result;
};
markWrapped(wrappedSeek);
set(wrappedSeek);
applyManifest();
return true;
};
const wrapSeekFunctions = (): boolean => {
const wrappedHfSeek = wrapFunction(
() => runtimeWindow.__hf?.seek,
(fn) => {
if (runtimeWindow.__hf) runtimeWindow.__hf.seek = fn;
},
);
const wrappedPlayerRenderSeek = wrapFunction(
() => runtimeWindow.__player?.renderSeek,
(fn) => {
if (runtimeWindow.__player) runtimeWindow.__player.renderSeek = fn;
},
);
return wrappedHfSeek || wrappedPlayerRenderSeek;
};
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", () => applyManifest(), { once: true });
} else {
applyManifest();
}
wrapSeekFunctions();
let remainingSeekWrapAttempts = 120;
const seekWrapInterval = setInterval(() => {
wrapSeekFunctions();
remainingSeekWrapAttempts -= 1;
if (remainingSeekWrapAttempts <= 0) clearInterval(seekWrapInterval);
}, 50);
}
@@ -0,0 +1,97 @@
import { describe, expect, it } from "vitest";
import { classifyMediaColor, probeMediaMetadata } from "./mediaMetadata.js";
describe("classifyMediaColor", () => {
it("detects HDR PQ from BT.2020 + smpte2084 metadata", () => {
expect(
classifyMediaColor({
codec_type: "video",
codec_name: "hevc",
profile: "Main 10",
pix_fmt: "yuv420p10le",
color_space: "bt2020nc",
color_transfer: "smpte2084",
color_primaries: "bt2020",
}),
).toMatchObject({
dynamicRange: "hdr",
hdrTransfer: "pq",
label: "HDR PQ",
isHdr: true,
});
});
it("detects HDR HLG from arib-std-b67 metadata", () => {
expect(
classifyMediaColor({
codec_type: "video",
color_space: "bt2020nc",
color_transfer: "arib-std-b67",
color_primaries: "bt2020",
}),
).toMatchObject({
dynamicRange: "hdr",
hdrTransfer: "hlg",
label: "HDR HLG",
isHdr: true,
});
});
it("labels BT.709 media as SDR Rec.709", () => {
expect(
classifyMediaColor({
codec_type: "video",
color_space: "bt709",
color_transfer: "bt709",
color_primaries: "bt709",
}),
).toMatchObject({
dynamicRange: "sdr",
hdrTransfer: null,
label: "SDR Rec.709",
isHdr: false,
});
});
});
describe("probeMediaMetadata", () => {
it("reads the first video stream from ffprobe JSON", () => {
const metadata = probeMediaMetadata("/tmp/clip.mp4", () => ({
status: 0,
stdout: JSON.stringify({
streams: [
{ codec_type: "audio", codec_name: "aac" },
{
codec_type: "video",
codec_name: "hevc",
pix_fmt: "yuv420p10le",
color_space: "bt2020nc",
color_transfer: "smpte2084",
color_primaries: "bt2020",
},
],
}),
stderr: "",
}));
expect(metadata).toMatchObject({
kind: "video",
color: { isHdr: true, label: "HDR PQ" },
});
});
it("returns unknown metadata when ffprobe is unavailable", () => {
expect(
probeMediaMetadata("/tmp/clip.mp4", () => ({
status: null,
stdout: "",
stderr: "",
error: { code: "ENOENT" } as NodeJS.ErrnoException,
})),
).toMatchObject({
kind: "video",
color: { dynamicRange: "unknown", isHdr: false },
probeError: "ffprobe unavailable",
});
});
});
@@ -0,0 +1,160 @@
import { spawnSync, type SpawnSyncOptions } from "node:child_process";
import { extname } from "node:path";
type FfprobeRunner = (
command: string,
args: string[],
options?: SpawnSyncOptions,
) => {
status: number | null;
stdout: string | Buffer;
stderr: string | Buffer;
error?: NodeJS.ErrnoException;
};
export type MediaDynamicRange = "hdr" | "sdr" | "unknown";
export type MediaHdrTransfer = "pq" | "hlg" | "unknown";
export interface MediaColorMetadata {
dynamicRange: MediaDynamicRange;
hdrTransfer: MediaHdrTransfer | null;
label: string;
isHdr: boolean;
codecName?: string;
profile?: string;
pixelFormat?: string;
colorSpace?: string;
colorTransfer?: string;
colorPrimaries?: string;
bitsPerRawSample?: string;
}
export interface MediaMetadata {
kind: "video" | "image" | "audio" | "unknown";
color: MediaColorMetadata;
probeError?: string;
}
interface FfprobeStream {
codec_type?: string;
codec_name?: string;
profile?: string;
pix_fmt?: string;
color_space?: string;
color_transfer?: string;
color_primaries?: string;
bits_per_raw_sample?: string;
}
const VIDEO_EXT = new Set([".mp4", ".mov", ".webm", ".mkv", ".avi", ".m4v"]);
const IMAGE_EXT = new Set([".jpg", ".jpeg", ".png", ".webp", ".avif"]);
const AUDIO_EXT = new Set([".mp3", ".wav", ".ogg", ".m4a", ".aac"]);
function lower(value: string | undefined): string {
return value?.toLowerCase() ?? "";
}
function inferKindFromPath(path: string): MediaMetadata["kind"] {
const ext = extname(path).toLowerCase();
if (VIDEO_EXT.has(ext)) return "video";
if (IMAGE_EXT.has(ext)) return "image";
if (AUDIO_EXT.has(ext)) return "audio";
return "unknown";
}
function colorLabel(input: {
isHdr: boolean;
hdrTransfer: MediaHdrTransfer | null;
colorPrimaries: string;
colorSpace: string;
colorTransfer: string;
}): string {
if (input.isHdr) {
if (input.hdrTransfer === "pq") return "HDR PQ";
if (input.hdrTransfer === "hlg") return "HDR HLG";
return "HDR";
}
if (
input.colorPrimaries.includes("bt709") ||
input.colorSpace.includes("bt709") ||
input.colorTransfer.includes("bt709")
) {
return "SDR Rec.709";
}
return "SDR/unknown";
}
export function classifyMediaColor(stream: FfprobeStream | null | undefined): MediaColorMetadata {
const colorPrimaries = lower(stream?.color_primaries);
const colorSpace = lower(stream?.color_space);
const colorTransfer = lower(stream?.color_transfer);
const isHdr =
colorPrimaries.includes("bt2020") ||
colorSpace.includes("bt2020") ||
colorTransfer === "smpte2084" ||
colorTransfer === "arib-std-b67";
const hdrTransfer: MediaHdrTransfer | null = isHdr
? colorTransfer === "smpte2084"
? "pq"
: colorTransfer === "arib-std-b67"
? "hlg"
: "unknown"
: null;
return {
dynamicRange: stream ? (isHdr ? "hdr" : "sdr") : "unknown",
hdrTransfer,
label: stream
? colorLabel({ isHdr, hdrTransfer, colorPrimaries, colorSpace, colorTransfer })
: "Unknown",
isHdr,
codecName: stream?.codec_name,
profile: stream?.profile,
pixelFormat: stream?.pix_fmt,
colorSpace: stream?.color_space,
colorTransfer: stream?.color_transfer,
colorPrimaries: stream?.color_primaries,
bitsPerRawSample: stream?.bits_per_raw_sample,
};
}
export function probeMediaMetadata(
filePath: string,
runner: FfprobeRunner = spawnSync as unknown as FfprobeRunner,
): MediaMetadata {
const kind = inferKindFromPath(filePath);
if (kind === "audio" || kind === "unknown") {
return { kind, color: classifyMediaColor(null) };
}
const result = runner(
"ffprobe",
[
"-v",
"error",
"-show_entries",
"stream=codec_type,codec_name,profile,pix_fmt,color_space,color_transfer,color_primaries,bits_per_raw_sample",
"-of",
"json",
filePath,
],
{ timeout: 15_000, maxBuffer: 1024 * 1024 },
);
if (result.error?.code === "ENOENT") {
return { kind, color: classifyMediaColor(null), probeError: "ffprobe unavailable" };
}
if (result.status !== 0) {
return { kind, color: classifyMediaColor(null), probeError: "ffprobe failed" };
}
try {
const parsed = JSON.parse(String(result.stdout || "{}")) as { streams?: FfprobeStream[] };
const stream = parsed.streams?.find((item) =>
kind === "image" ? item.codec_type === "video" : item.codec_type === kind,
);
return { kind, color: classifyMediaColor(stream) };
} catch {
return { kind, color: classifyMediaColor(null), probeError: "ffprobe returned invalid json" };
}
}
@@ -0,0 +1,71 @@
import { describe, expect, it } from "vitest";
import { validateUploadedMedia, validateUploadedMediaBuffer } from "./mediaValidation.js";
describe("validateUploadedMedia", () => {
it("passes through non-media files", () => {
expect(
validateUploadedMedia("/tmp/test.svg", () => ({ status: 0, stdout: "", stderr: "" })),
).toEqual({
ok: true,
});
});
it("accepts video files with a video stream", () => {
expect(
validateUploadedMedia("/tmp/test.mp4", () => ({
status: 0,
stdout: JSON.stringify({ streams: [{ codec_type: "video" }] }),
stderr: "",
})),
).toEqual({ ok: true });
});
it("rejects video files with no supported video stream", () => {
expect(
validateUploadedMedia("/tmp/test.mp4", () => ({
status: 0,
stdout: JSON.stringify({ streams: [] }),
stderr: "",
})),
).toEqual({ ok: false, reason: "no supported video stream found" });
});
it("accepts audio files with an audio stream", () => {
expect(
validateUploadedMedia("/tmp/test.wav", () => ({
status: 0,
stdout: JSON.stringify({ streams: [{ codec_type: "audio" }] }),
stderr: "",
})),
).toEqual({ ok: true });
});
it("does not block upload when ffprobe is unavailable", () => {
expect(
validateUploadedMedia("/tmp/test.mp4", () => ({
status: null,
stdout: "",
stderr: "",
error: { code: "ENOENT" } as NodeJS.ErrnoException,
})),
).toEqual({ ok: true });
});
});
describe("validateUploadedMediaBuffer", () => {
it("validates media from a temp file that preserves the extension", () => {
let inspectedPath = "";
expect(
validateUploadedMediaBuffer("raycast.mp4", new Uint8Array([0, 1, 2]), (_command, args) => {
inspectedPath = args.at(-1) ?? "";
return {
status: 0,
stdout: JSON.stringify({ streams: [{ codec_type: "video" }] }),
stderr: "",
};
}),
).toEqual({ ok: true });
expect(inspectedPath).toMatch(/raycast\.mp4$/);
});
});
@@ -0,0 +1,80 @@
import { spawnSync } from "node:child_process";
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { basename, join } from "node:path";
const VIDEO_EXT = /\.(mp4|webm|mov)$/i;
const AUDIO_EXT = /\.(mp3|wav|ogg|m4a|aac)$/i;
type FfprobeRunner = (
command: string,
args: string[],
) => {
status: number | null;
stdout: string | Buffer;
stderr: string | Buffer;
error?: NodeJS.ErrnoException;
};
export function validateUploadedMedia(
filePath: string,
runner: FfprobeRunner = spawnSync as unknown as FfprobeRunner,
): { ok: true } | { ok: false; reason: string } {
const isVideo = VIDEO_EXT.test(filePath);
const isAudio = AUDIO_EXT.test(filePath);
if (!isVideo && !isAudio) {
return { ok: true };
}
const result = runner("ffprobe", [
"-v",
"error",
"-show_entries",
"stream=codec_type",
"-of",
"json",
filePath,
]);
if (result.error?.code === "ENOENT") {
return { ok: true };
}
if (result.status !== 0) {
return { ok: false, reason: "ffprobe failed to read the media file" };
}
try {
const parsed = JSON.parse(String(result.stdout || "{}")) as {
streams?: Array<{ codec_type?: string }>;
};
const streams = parsed.streams ?? [];
const hasVideo = streams.some((stream) => stream.codec_type === "video");
const hasAudio = streams.some((stream) => stream.codec_type === "audio");
if (isVideo && !hasVideo) {
return { ok: false, reason: "no supported video stream found" };
}
if (isAudio && !hasAudio) {
return { ok: false, reason: "no supported audio stream found" };
}
return { ok: true };
} catch {
return { ok: false, reason: "ffprobe returned unreadable media metadata" };
}
}
export function validateUploadedMediaBuffer(
fileName: string,
buffer: Uint8Array,
runner: FfprobeRunner = spawnSync as unknown as FfprobeRunner,
): { ok: true } | { ok: false; reason: string } {
const tempDir = mkdtempSync(join(tmpdir(), "hyperframes-upload-"));
const tempPath = join(tempDir, basename(fileName));
try {
writeFileSync(tempPath, buffer);
return validateUploadedMedia(tempPath, runner);
} finally {
rmSync(tempDir, { recursive: true, force: true });
}
}
@@ -0,0 +1,40 @@
export const MIME_TYPES: Record<string, string> = {
".html": "text/html",
".css": "text/css",
".js": "text/javascript",
".mjs": "text/javascript",
".json": "application/json",
".svg": "image/svg+xml",
".png": "image/png",
".jpg": "image/jpeg",
".jpeg": "image/jpeg",
".gif": "image/gif",
".webp": "image/webp",
".ico": "image/x-icon",
".mp4": "video/mp4",
".mov": "video/quicktime",
".webm": "video/webm",
".mp3": "audio/mpeg",
".wav": "audio/wav",
".ogg": "audio/ogg",
".m4a": "audio/mp4",
".aac": "audio/aac",
".flac": "audio/flac",
".opus": "audio/ogg",
".woff": "font/woff",
".woff2": "font/woff2",
".ttf": "font/ttf",
".otf": "font/otf",
".txt": "text/plain",
".md": "text/markdown",
".cube": "text/plain; charset=utf-8",
};
export function getMimeType(path: string): string {
const ext = path.slice(path.lastIndexOf(".")).toLowerCase();
return MIME_TYPES[ext] || "application/octet-stream";
}
export function isAudioFile(name: string): boolean {
return (getMimeType(name) ?? "").startsWith("audio/");
}
@@ -0,0 +1,324 @@
// fallow-ignore-file code-duplication
/**
* T10 — PreviewAdapter contract (spec for R7).
*
* Converted from it.todo stubs. These tests FAIL until Task 3 implements
* createPreviewAdapter in ./previewAdapter.ts.
*
* Position resolution: elementFromPoint is always null in jsdom. All
* elementAtPoint tests inject a resolvePoint stub so the contract tested
* is filtering logic (root exclusion, data-hf-id ancestor walk,
* opacity-at-playhead), not geometry.
*
* CSS custom property names used below mirror the Studio constants from
* manualEditsTypes.ts — they will be shared with the PreviewAdapter
* implementation once the draft-marker module moves to core (Task 4).
*/
import { describe, it, expect, beforeEach } from "vitest";
import { createPreviewAdapter } from "./previewAdapter.js";
// ── DOM helpers ────────────────────────────────────────────────────────────
beforeEach(() => {
document.body.innerHTML = "";
});
/** Create + append an element to body; optionally set attrs and inline styles. */
function make(
tag: string,
attrs: Record<string, string> = {},
styles: Record<string, string> = {},
): HTMLElement {
const elem = document.createElement(tag);
for (const [k, v] of Object.entries(attrs)) elem.setAttribute(k, v);
for (const [k, v] of Object.entries(styles)) elem.style.setProperty(k, v);
document.body.appendChild(elem);
return elem;
}
function adapterWith(resolvePoint: (x: number, y: number) => Element | null) {
return createPreviewAdapter(document, { resolvePoint });
}
// ── elementAtPoint ─────────────────────────────────────────────────────────
describe("T10 — PreviewAdapter contract (spec for R7)", () => {
describe("elementAtPoint", () => {
it("returns null for the stage root (data-hf-root)", () => {
const root = make("div", { "data-hf-root": "true" });
const adapter = adapterWith(() => root);
expect(adapter.elementAtPoint(0, 0)).toBeNull();
});
it("returns the nearest ancestor with data-hf-id", () => {
const parent = make("div", { "data-hf-id": "hf-abcd" });
const child = document.createElement("span");
parent.appendChild(child);
const adapter = adapterWith(() => child);
expect(adapter.elementAtPoint(0, 0)).toBe(parent);
});
it("returns null when the hit element has no data-hf-id ancestor", () => {
const orphan = make("div");
const adapter = adapterWith(() => orphan);
expect(adapter.elementAtPoint(0, 0)).toBeNull();
});
it("skips elements whose currently-computed opacity is 0 (atTime is a caller-seek hint, not evaluated by the adapter)", () => {
const elem = make("div", { "data-hf-id": "hf-zzzz" }, { opacity: "0" });
const adapter = adapterWith(() => elem);
expect(adapter.elementAtPoint(0, 0, { atTime: 1.0 })).toBeNull();
});
it("returns null for nested data-hf-root without data-hf-id (treated same as outer stage root)", () => {
const outerRoot = make("div", { "data-hf-root": "true" });
const innerRoot = document.createElement("div");
innerRoot.setAttribute("data-hf-root", "true");
// no data-hf-id — no explicit id means no draggable target
outerRoot.appendChild(innerRoot);
const adapter = adapterWith(() => innerRoot);
expect(adapter.elementAtPoint(0, 0)).toBeNull();
});
});
// ── applyDraft / revertDraft ───────────────────────────────────────────
describe("applyDraft / revertDraft", () => {
it("applyDraft writes --hf-studio-* CSS props and sets the gesture marker", () => {
const target = make("div", { "data-hf-id": "hf-aaaa" });
const adapter = adapterWith(() => null);
adapter.applyDraft({ type: "move", hfId: "hf-aaaa", dx: 10, dy: 20 });
expect(target.style.getPropertyValue("--hf-studio-offset-x")).not.toBe("");
expect(target.hasAttribute("data-hf-studio-manual-edit-gesture")).toBe(true);
});
it("applyDraft accepts a move payload (dx/dy) and writes the translate draft", () => {
const target = make("div", { "data-hf-id": "hf-aaaa" });
const adapter = adapterWith(() => null);
adapter.applyDraft({ type: "move", hfId: "hf-aaaa", dx: 30, dy: 15 });
expect(target.style.getPropertyValue("--hf-studio-offset-x")).toBe("30px");
expect(target.style.getPropertyValue("--hf-studio-offset-y")).toBe("15px");
});
it("applyDraft accepts a resize payload (w/h) and writes the size draft", () => {
const target = make("div", { "data-hf-id": "hf-aaaa" });
const adapter = adapterWith(() => null);
adapter.applyDraft({ type: "resize", hfId: "hf-aaaa", w: 200, h: 100 });
expect(target.style.getPropertyValue("--hf-studio-width")).toBe("200px");
expect(target.style.getPropertyValue("--hf-studio-height")).toBe("100px");
});
it("revertDraft removes draft props and clears the gesture marker", () => {
const target = make("div", { "data-hf-id": "hf-aaaa" });
const adapter = adapterWith(() => null);
adapter.applyDraft({ type: "move", hfId: "hf-aaaa", dx: 10, dy: 20 });
adapter.revertDraft();
expect(target.style.getPropertyValue("--hf-studio-offset-x")).toBe("");
expect(target.style.getPropertyValue("--hf-studio-offset-y")).toBe("");
expect(target.hasAttribute("data-hf-studio-manual-edit-gesture")).toBe(false);
});
it("revertDraft restores original translate when an original was recorded", () => {
const target = make("div", { "data-hf-id": "hf-aaaa" });
target.style.setProperty("translate", "50px 0px");
const adapter = adapterWith(() => null);
adapter.applyDraft({ type: "move", hfId: "hf-aaaa", dx: 10, dy: 0 });
adapter.revertDraft();
expect(target.style.getPropertyValue("translate")).toBe("50px 0px");
});
});
// ── edge cases ─────────────────────────────────────────────────────────
describe("applyDraft edge cases (R7 implementation contract)", () => {
it("second applyDraft before revert/commit overwrites first draft — does not accumulate (dx/dy)", () => {
const target = make("div", { "data-hf-id": "hf-aaaa" });
const adapter = adapterWith(() => null);
adapter.applyDraft({ type: "move", hfId: "hf-aaaa", dx: 10, dy: 20 });
adapter.applyDraft({ type: "move", hfId: "hf-aaaa", dx: 5, dy: 15 });
expect(target.style.getPropertyValue("--hf-studio-offset-x")).toBe("5px");
expect(target.style.getPropertyValue("--hf-studio-offset-y")).toBe("15px");
});
it("resize → move switch clears width/height props — no cross-type prop leak", () => {
const target = make("div", { "data-hf-id": "hf-aaaa" });
const adapter = adapterWith(() => null);
adapter.applyDraft({ type: "resize", hfId: "hf-aaaa", w: 200, h: 100 });
adapter.applyDraft({ type: "move", hfId: "hf-aaaa", dx: 10, dy: 5 });
// move props set
expect(target.style.getPropertyValue("--hf-studio-offset-x")).toBe("10px");
expect(target.style.getPropertyValue("--hf-studio-offset-y")).toBe("5px");
// resize props cleared by the auto-revert before re-apply
expect(target.style.getPropertyValue("--hf-studio-width")).toBe("");
expect(target.style.getPropertyValue("--hf-studio-height")).toBe("");
});
it("revertDraft after commitPreview is a no-op — does not restore stale translate", () => {
const target = make("div", { "data-hf-id": "hf-aaaa" });
target.style.setProperty("translate", "50px 0px");
const adapter = adapterWith(() => null);
adapter.applyDraft({ type: "move", hfId: "hf-aaaa", dx: 10, dy: 0 });
adapter.commitPreview();
// simulate caller applying translate after commit
target.style.setProperty("translate", "10px 0px");
adapter.revertDraft(); // no gesture in flight — should be no-op
expect(target.style.getPropertyValue("translate")).toBe("10px 0px");
});
it("revertDraft is safe to call when no gesture is in progress (idempotent / no-op on empty marker)", () => {
const adapter = adapterWith(() => null);
expect(() => adapter.revertDraft()).not.toThrow();
expect(() => adapter.revertDraft()).not.toThrow();
});
it("elementAtPoint filtering is stable when inline opacity changes mid-drag — computed style re-evaluated per call", () => {
const elem = make("div", { "data-hf-id": "hf-zzzz" });
const adapter = adapterWith(() => elem);
expect(adapter.elementAtPoint(0, 0)).toBe(elem);
// simulates GSAP seeking to a time where the element is hidden
elem.style.setProperty("opacity", "0");
expect(adapter.elementAtPoint(0, 0)).toBeNull();
});
it("stage-root exclusion applies only to the outermost data-hf-root; nested sub-composition roots count as targets", () => {
const outerRoot = make("div", { "data-hf-root": "true" });
const innerRoot = document.createElement("div");
innerRoot.setAttribute("data-hf-root", "true");
innerRoot.setAttribute("data-hf-id", "hf-sub1");
outerRoot.appendChild(innerRoot);
const adapterOuter = adapterWith(() => outerRoot);
expect(adapterOuter.elementAtPoint(0, 0)).toBeNull();
const adapterInner = adapterWith(() => innerRoot);
expect(adapterInner.elementAtPoint(0, 0)).toBe(innerRoot);
});
});
// ── commitPreview ──────────────────────────────────────────────────────
describe("commitPreview", () => {
it("returns null when no gesture marker is present", () => {
const adapter = adapterWith(() => null);
expect(adapter.commitPreview()).toBeNull();
});
it("derives a moveElement patch from draft markers on commit", () => {
make("div", { "data-hf-id": "hf-aaaa" });
const adapter = adapterWith(() => null);
adapter.applyDraft({ type: "move", hfId: "hf-aaaa", dx: 30, dy: 15 });
const patch = adapter.commitPreview();
expect(patch).toEqual({ type: "moveElement", hfId: "hf-aaaa", dx: 30, dy: 15 });
});
it("derives a resize patch from draft markers on commit", () => {
make("div", { "data-hf-id": "hf-aaaa" });
const adapter = adapterWith(() => null);
adapter.applyDraft({ type: "resize", hfId: "hf-aaaa", w: 200, h: 100 });
const patch = adapter.commitPreview();
expect(patch).toEqual({ type: "resize", hfId: "hf-aaaa", width: 200, height: 100 });
});
it("clears the gesture marker after commit", () => {
const target = make("div", { "data-hf-id": "hf-aaaa" });
const adapter = adapterWith(() => null);
adapter.applyDraft({ type: "move", hfId: "hf-aaaa", dx: 10, dy: 0 });
adapter.commitPreview();
expect(target.hasAttribute("data-hf-studio-manual-edit-gesture")).toBe(false);
});
});
// ── getElementTimings ──────────────────────────────────────────────────
describe("getElementTimings", () => {
it("reads authored absolute times from data-start / data-end", () => {
make("div", { "data-hf-id": "hf-t1", "data-start": "0.5", "data-end": "2.0" });
const adapter = adapterWith(() => null);
const timings = adapter.getElementTimings();
expect(timings["hf-t1"]).toEqual({ start: 0.5, end: 2.0 });
});
it("ignores elements without data-hf-id", () => {
make("div", { "data-start": "0.5", "data-end": "2.0" }); // no data-hf-id
const adapter = adapterWith(() => null);
const timings = adapter.getElementTimings();
expect(Object.keys(timings)).toHaveLength(0);
});
it("returns a defined timing entry when data-hf-id is present but data-start / data-end are missing", () => {
make("div", { "data-hf-id": "hf-notimed" });
const adapter = adapterWith(() => null);
const timings = adapter.getElementTimings();
expect(timings["hf-notimed"]).toBeDefined();
expect(timings["hf-notimed"].start).toBeUndefined();
expect(timings["hf-notimed"].end).toBeUndefined();
});
it("resolves data-duration into end when there is no data-end (never worked before)", () => {
make("div", { "data-hf-id": "hf-t1", "data-start": "1", "data-duration": "3" });
const adapter = adapterWith(() => null);
const timings = adapter.getElementTimings();
expect(timings["hf-t1"]).toEqual({ start: 1, end: 4 });
});
it("resolves a relative data-start reference ('ref + offset') instead of returning undefined", () => {
make("div", { "data-hf-id": "hf-intro", "data-start": "1", "data-duration": "3" });
make("div", { "data-hf-id": "hf-outro", "data-start": "hf-intro + 2", "data-duration": "1" });
const adapter = adapterWith(() => null);
const timings = adapter.getElementTimings();
// hf-intro ends at 4 (1 + 3); hf-outro starts 2s after that = 6.
expect(timings["hf-outro"]).toEqual({ start: 6, end: 7 });
});
it("resolves a bare reference (no offset) to the referenced element's end", () => {
make("div", { "data-hf-id": "hf-intro", "data-start": "1", "data-end": "4" });
make("div", {
"data-hf-id": "hf-right-after",
"data-start": "hf-intro",
"data-duration": "1",
});
const adapter = adapterWith(() => null);
const timings = adapter.getElementTimings();
expect(timings["hf-right-after"]).toEqual({ start: 4, end: 5 });
});
it("returns undefined start (not NaN) when the reference target doesn't exist", () => {
make("div", {
"data-hf-id": "hf-orphan",
"data-start": "hf-nonexistent + 5",
"data-duration": "2",
});
const adapter = adapterWith(() => null);
const timings = adapter.getElementTimings();
expect(timings["hf-orphan"].start).toBeUndefined();
});
it("returns undefined start when the reference target exists but its own timing is unresolvable", () => {
make("div", { "data-hf-id": "hf-untimed" }); // no data-end, no data-duration
make("div", {
"data-hf-id": "hf-outro",
"data-start": "hf-untimed + 2",
"data-duration": "1",
});
const adapter = adapterWith(() => null);
const timings = adapter.getElementTimings();
expect(timings["hf-outro"].start).toBeUndefined();
});
it("terminates (not an infinite loop) on a mutual A <-> B reference cycle", () => {
make("div", { "data-hf-id": "hf-a", "data-start": "hf-b", "data-duration": "2" });
make("div", { "data-hf-id": "hf-b", "data-start": "hf-a", "data-duration": "3" });
const adapter = adapterWith(() => null);
// Unlike the SDK's static resolver (which fails safe to 0 and lets real
// durations propagate outward into arbitrary-but-finite numbers), this
// simpler resolver has no 0-fallback — an unresolvable `sv` poisons
// `resolveEnd` (it requires a finite start to add duration), so the whole
// cycle correctly reports "no defined timing" rather than a fabricated
// number. The guard's job is just termination; this call must return
// synchronously instead of hanging.
const timings = adapter.getElementTimings();
expect(timings["hf-a"].start).toBeUndefined();
expect(timings["hf-b"].start).toBeUndefined();
});
});
});
@@ -0,0 +1,217 @@
import {
STUDIO_OFFSET_X_PROP,
STUDIO_OFFSET_Y_PROP,
STUDIO_WIDTH_PROP,
STUDIO_HEIGHT_PROP,
STUDIO_MANUAL_EDIT_GESTURE_ATTR,
} from "./draftMarkers.js";
import { parseStartExpression } from "@hyperframes/core/runtime/start-expression";
export type DraftPayload =
| { type: "move"; hfId: string; dx: number; dy: number }
| { type: "resize"; hfId: string; w: number; h: number };
export type CommitPatch =
| { type: "moveElement"; hfId: string; dx: number; dy: number }
| { type: "resize"; hfId: string; width: number; height: number };
export interface PreviewAdapter {
/**
* @param atTime - Caller hint only. The adapter reads current computed styles;
* the caller must seek the GSAP timeline to `atTime` before invoking so that
* GSAP-driven inline styles reflect the desired playhead position.
*/
elementAtPoint(x: number, y: number, opts?: { atTime?: number }): Element | null;
applyDraft(payload: DraftPayload): void;
revertDraft(): void;
commitPreview(): CommitPatch | null;
getElementTimings(): Record<string, { start?: number; end?: number }>;
}
interface GestureState {
payload: DraftPayload;
originalTranslate: string | undefined;
}
export function createPreviewAdapter(
doc: Document,
opts?: { resolvePoint?: (x: number, y: number) => Element | null },
): PreviewAdapter {
let gesture: GestureState | null = null;
function findById(hfId: string): HTMLElement | null {
// CSS.escape is available in browsers; hf-ids are always hf-[a-z0-9]+ so
// no escaping is strictly needed, but be safe in non-browser environments.
const escaped =
typeof CSS !== "undefined" && typeof CSS.escape === "function"
? CSS.escape(hfId)
: hfId.replace(/([^\w-])/g, "\\$1");
return doc.querySelector(`[data-hf-id="${escaped}"]`) as HTMLElement | null;
}
function isVisible(el: Element): boolean {
const view = doc.defaultView;
if (!view) return true;
const style = view.getComputedStyle(el);
if (style.display === "none" || style.visibility === "hidden") return false;
const op = parseFloat(style.opacity);
// NaN (empty string from environments with no CSS cascade) → treat as visible.
// 0.01 threshold: sub-1% opacity is not user-targetable in drag gestures.
return Number.isNaN(op) || op >= 0.01;
}
function clearDraftProps(target: HTMLElement): void {
target.style.removeProperty(STUDIO_OFFSET_X_PROP);
target.style.removeProperty(STUDIO_OFFSET_Y_PROP);
target.style.removeProperty(STUDIO_WIDTH_PROP);
target.style.removeProperty(STUDIO_HEIGHT_PROP);
target.removeAttribute(STUDIO_MANUAL_EDIT_GESTURE_ATTR);
}
function revertGesture(target: HTMLElement, state: GestureState): void {
clearDraftProps(target);
if (state.originalTranslate !== undefined) {
target.style.setProperty("translate", state.originalTranslate);
}
}
return {
elementAtPoint(x, y, _perCallOpts) {
const hit = opts?.resolvePoint?.(x, y) ?? null;
if (!hit) return null;
let el: Element | null = hit;
while (el && el !== doc.body) {
if (el.hasAttribute("data-hf-id")) {
return isVisible(el) ? (el as HTMLElement) : null;
}
// data-hf-root without data-hf-id = outermost stage root — stop
if (el.hasAttribute("data-hf-root")) return null;
el = el.parentElement;
}
return null;
},
applyDraft(payload) {
// Auto-revert any in-flight gesture before starting a new one so no
// element is left with orphaned draft CSS props or the gesture marker.
if (gesture) {
const prev = findById(gesture.payload.hfId);
if (prev) revertGesture(prev, gesture);
gesture = null;
}
const target = findById(payload.hfId);
if (!target) return;
const originalTranslate = target.style.getPropertyValue("translate") || undefined;
gesture = { payload, originalTranslate };
target.setAttribute(STUDIO_MANUAL_EDIT_GESTURE_ATTR, "true");
if (payload.type === "move") {
target.style.setProperty(STUDIO_OFFSET_X_PROP, `${payload.dx}px`);
target.style.setProperty(STUDIO_OFFSET_Y_PROP, `${payload.dy}px`);
} else {
target.style.setProperty(STUDIO_WIDTH_PROP, `${payload.w}px`);
target.style.setProperty(STUDIO_HEIGHT_PROP, `${payload.h}px`);
}
},
revertDraft() {
if (!gesture) return;
const target = findById(gesture.payload.hfId);
if (target) revertGesture(target, gesture);
gesture = null;
},
commitPreview() {
if (!gesture) return null;
const { payload } = gesture;
const target = findById(payload.hfId);
if (target) clearDraftProps(target);
gesture = null;
if (payload.type === "move") {
return { type: "moveElement", hfId: payload.hfId, dx: payload.dx, dy: payload.dy };
}
return { type: "resize", hfId: payload.hfId, width: payload.w, height: payload.h };
},
getElementTimings() {
// data-start can be a relative-reference expression ("intro", "intro + 2" —
// parseStartExpression's grammar), not just an absolute number. A raw
// parseFloat on it (the old behavior) silently resolves any reference to
// undefined. Resolve references recursively against the target's own
// resolved end (data-end, or data-start + data-duration when the target
// has no data-end) — this function never read data-duration before either,
// so a reference to a duration-authored (not end-authored) clip used to be
// unresolvable regardless of the parseFloat bug.
//
// This is the third copy of "resolve relative data-start" (runtime
// startResolver.ts; the SDK's own getElementTimings in session.ts; this
// one). The runtime version is substantially more complex (host offsets,
// media, live timelines) so a shared extraction isn't a straightforward
// win — this one and the SDK's stay hand-kept in sync instead.
const startCache = new Map<Element, number | undefined>();
const visiting = new Set<Element>();
const resolveEnd = (el: Element): number | undefined => {
const endAttr = el.getAttribute("data-end");
if (endAttr !== null) {
const ev = parseFloat(endAttr);
if (Number.isFinite(ev)) return ev;
}
const durationAttr = el.getAttribute("data-duration");
const dv = durationAttr !== null ? parseFloat(durationAttr) : NaN;
const sv = resolveStart(el);
if (Number.isFinite(dv) && sv !== undefined) return sv + dv;
return undefined;
};
// Split out of resolveStart so its own branching stays low — mirrors the
// SDK's getElementTimings resolver split (resolveReferenceStart).
const resolveReferenceStart = (refId: string, offset: number): number | undefined => {
const target = findById(refId);
const targetEnd = target ? resolveEnd(target) : undefined;
return targetEnd !== undefined ? Math.max(0, targetEnd + offset) : undefined;
};
const resolveStart = (el: Element): number | undefined => {
if (startCache.has(el)) return startCache.get(el);
if (visiting.has(el)) return undefined; // reference cycle — fail safe, don't loop
visiting.add(el);
let resolved: number | undefined;
try {
const startStr = el.getAttribute("data-start");
const expr = parseStartExpression(startStr);
if (expr?.kind === "reference") {
resolved = resolveReferenceStart(expr.refId, expr.offset);
} else if (expr?.kind === "absolute") {
resolved = expr.value;
} else {
// parseStartExpression returns null for empty/absent data-start, and
// also for a malformed grammar string (e.g. "3 abc" — a leading
// number followed by content the reference regex rejects). The
// parseFloat below only ever succeeds on that second, malformed
// case (a clean number or a clean reference already matched above).
const sv = startStr !== null ? parseFloat(startStr) : NaN;
resolved = Number.isFinite(sv) ? sv : undefined;
}
} finally {
visiting.delete(el);
}
startCache.set(el, resolved);
return resolved;
};
const result: Record<string, { start?: number; end?: number }> = {};
for (const el of doc.querySelectorAll("[data-hf-id]")) {
const hfId = el.getAttribute("data-hf-id");
if (!hfId) continue;
result[hfId] = { start: resolveStart(el), end: resolveEnd(el) };
}
return result;
},
};
}
@@ -0,0 +1,175 @@
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;
const STUDIO_SIGNATURE_MANIFEST_PATHS = [
".hyperframes/studio-manual-edits.json",
".hyperframes/studio-motion.json",
] as const;
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 collectProjectSignatureManifestFiles(
projectDir: string,
files: ProjectSignatureFile[],
): void {
const seen = new Set(files.map((entry) => entry.file));
for (const manifestPath of STUDIO_SIGNATURE_MANIFEST_PATHS) {
const file = resolve(projectDir, manifestPath);
if (seen.has(file) || !isPathWithin(projectDir, file)) continue;
let stat: ReturnType<typeof lstatSync>;
try {
stat = lstatSync(file);
} catch {
continue;
}
if (stat.isSymbolicLink() || !stat.isFile()) continue;
files.push({
file,
mtimeMs: stat.mtimeMs,
size: stat.size,
textContentEligible: isTextContentEligible(file, stat.size),
});
seen.add(file);
}
}
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);
}
/**
* Creates a stable preview cache-busting signature for project source plus Studio manifests.
*/
export function createProjectSignature(projectDir: string): string {
const normalizedProjectDir = resolve(projectDir);
const files: ProjectSignatureFile[] = [];
collectProjectSignatureFiles(normalizedProjectDir, normalizedProjectDir, files);
collectProjectSignatureManifestFiles(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;
}
@@ -0,0 +1,39 @@
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import { walkDir } from "./safePath";
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-safe-path-"));
tempDirs.push(projectDir);
return projectDir;
}
describe("walkDir", () => {
it("hides internal HyperFrames backup files from project listings", () => {
const projectDir = createProjectDir();
mkdirSync(join(projectDir, ".hyperframes", "backup"), { recursive: true });
mkdirSync(join(projectDir, ".hyperframes", "examples"), { recursive: true });
mkdirSync(join(projectDir, ".cache", "examples"), { recursive: true });
mkdirSync(join(projectDir, "compositions"), { recursive: true });
writeFileSync(join(projectDir, ".hyperframes", "backup", "snapshot.html"), "backup");
writeFileSync(join(projectDir, ".hyperframes", "examples", "preset.html"), "preset");
writeFileSync(join(projectDir, ".cache", "examples", "preset.html"), "preset");
writeFileSync(join(projectDir, "compositions", "scene.html"), "scene");
const files = walkDir(projectDir);
expect(files).toContain(".cache/examples/preset.html");
expect(files).toContain(".hyperframes/examples/preset.html");
expect(files).toContain("compositions/scene.html");
expect(files).not.toContain(".hyperframes/backup/snapshot.html");
});
});
@@ -0,0 +1,40 @@
import { join } from "node:path";
import { readdirSync } from "node:fs";
// `isSafePath` lives at the package root so non-studio-api layers (compiler,
// CLI, engine) can share it without a backwards dependency on studio-api.
// Re-exported here for back-compat with existing `../helpers/safePath.js` imports.
export { isSafePath, resolveWithinProject } from "@hyperframes/core";
const IGNORE_DIRS = new Set([".thumbnails", "node_modules", ".git"]);
function shouldIgnoreDir(rel: string): boolean {
return rel === ".hyperframes/backup";
}
/**
* True when any directory segment of a relative path is a dot-directory or
* node_modules. Projects that vendor tooling assets under dot-directories
* (.hyperframes/, .cache/, …) ship example/preset HTML that must not surface
* as project compositions or studio lint targets (#1384). The file tree is
* deliberately not filtered — this only gates discovery.
*/
export function isInHiddenOrVendorDir(relPath: string): boolean {
const segments = relPath.split("/");
return segments.slice(0, -1).some((seg) => seg.startsWith(".") || seg === "node_modules");
}
/** Recursively walk a directory and return relative file paths. */
export function walkDir(dir: string, prefix = ""): string[] {
const files: string[] = [];
for (const entry of readdirSync(dir, { withFileTypes: true })) {
const rel = prefix ? `${prefix}/${entry.name}` : entry.name;
if (IGNORE_DIRS.has(entry.name) || shouldIgnoreDir(rel)) continue;
if (entry.isDirectory()) {
files.push(...walkDir(join(dir, entry.name), rel));
} else {
files.push(rel);
}
}
return files;
}
@@ -0,0 +1,31 @@
export interface ScreenshotClip {
x: number;
y: number;
width: number;
height: number;
}
export function getElementScreenshotClip(
selector: string,
selectorIndex?: number,
): ScreenshotClip | undefined {
const matches = Array.from(document.querySelectorAll(selector)).filter(
(el): el is HTMLElement => el instanceof HTMLElement,
);
const safeIndex = Math.max(0, Math.min(matches.length - 1, Math.floor(selectorIndex ?? 0)));
const el = matches[safeIndex] ?? null;
if (!(el instanceof HTMLElement)) return undefined;
const rect = el.getBoundingClientRect();
if (rect.width < 4 || rect.height < 4) return undefined;
const pad = 8;
const x = Math.max(0, rect.left - pad);
const y = Math.max(0, rect.top - pad);
const maxWidth = window.innerWidth - x;
const maxHeight = window.innerHeight - y;
return {
x,
y,
width: Math.max(1, Math.min(rect.width + pad * 2, maxWidth)),
height: Math.max(1, Math.min(rect.height + pad * 2, maxHeight)),
};
}
@@ -0,0 +1,544 @@
// fallow-ignore-file code-duplication
import { parseHTML } from "linkedom";
import { describe, expect, it } from "vitest";
import {
removeElementFromHtml,
patchElementInHtml,
probeElementInSource,
} from "./sourceMutation.js";
describe("removeElementFromHtml", () => {
it("removes a self-closing element by id", () => {
const html = `<!doctype html><html><body><div data-composition-id="main"><img id="photo" src="asset.png" /><div id="rest"></div></div></body></html>`;
const updated = removeElementFromHtml(html, { id: "photo" });
expect(updated).not.toContain(`id="photo"`);
expect(updated).toContain(`id="rest"`);
});
it("removes a matched composition host by selector", () => {
const html = `<!doctype html><html><body><div data-composition-id="main"><div data-composition-id="scene-a"><span>Scene A</span></div><div data-composition-id="scene-b"></div></div></body></html>`;
const updated = removeElementFromHtml(html, {
selector: '[data-composition-id="scene-a"]',
});
expect(updated).not.toContain(`data-composition-id="scene-a"`);
expect(updated).toContain(`data-composition-id="scene-b"`);
});
it("supports fragment html by returning updated body markup", () => {
const html = `<div id="photo"></div><div id="rest"></div>`;
expect(removeElementFromHtml(html, { id: "photo" })).toBe(`<div id="rest"></div>`);
});
});
describe("patchElementInHtml", () => {
const FIXTURE = `<!doctype html><html><head></head><body>
<div id="root" data-composition-id="main">
<div class="layer" data-composition-id="overlay" data-composition-src="compositions/overlay.html">
<div class="chrome">
<span class="brand">HyperFrames</span>
</div>
</div>
<div id="hero" class="hero-heading" style="font-size: 48px">Hello World</div>
</div>
</body></html>`;
it("patches inline style by id", () => {
const { html: result, matched } = patchElementInHtml(FIXTURE, { id: "hero" }, [
{ type: "inline-style", property: "color", value: "red" },
]);
expect(matched).toBe(true);
expect(result).toMatch(/color:\s*red/);
expect(result).toContain('id="hero"');
});
it("patches a 4-side clip-path inset inline style", () => {
const { html: result, matched } = patchElementInHtml(FIXTURE, { id: "hero" }, [
{ type: "inline-style", property: "clip-path", value: "inset(10px 20px 30px 40px)" },
]);
expect(matched).toBe(true);
expect(result).toMatch(/clip-path:\s*inset\(10px 20px 30px 40px\)/);
expect(result).toContain('id="hero"');
});
it("patches inline style by class selector", () => {
const { html: result } = patchElementInHtml(FIXTURE, { selector: ".hero-heading" }, [
{ type: "inline-style", property: "font-size", value: "72px" },
]);
expect(result).toMatch(/font-size:\s*72px/);
});
it("patches data attribute", () => {
const { html: result } = patchElementInHtml(FIXTURE, { id: "hero" }, [
{ type: "attribute", property: "hf-studio-path-offset", value: "true" },
]);
expect(result).toContain('data-hf-studio-path-offset="true"');
});
it("does not double data- prefix when property already has it", () => {
const { html: result } = patchElementInHtml(FIXTURE, { id: "hero" }, [
{ type: "attribute", property: "data-hf-studio-path-offset", value: "true" },
]);
expect(result).toContain('data-hf-studio-path-offset="true"');
expect(result).not.toContain("data-data-hf-studio-path-offset");
});
it("does not double data- prefix for any studio attribute", () => {
const attrs = [
"data-hf-studio-path-offset",
"data-hf-studio-original-translate",
"data-hf-studio-original-inline-translate",
"data-hf-studio-box-size",
"data-hf-studio-rotation",
];
for (const attr of attrs) {
const { html: result } = patchElementInHtml(FIXTURE, { id: "hero" }, [
{ type: "attribute", property: attr, value: "true" },
]);
expect(result).toContain(`${attr}="true"`);
expect(result).not.toContain(`data-${attr}`);
}
});
it("removes attribute with data- prefix already present", () => {
const { html: withAttr } = patchElementInHtml(FIXTURE, { id: "hero" }, [
{ type: "attribute", property: "data-hf-studio-path-offset", value: "true" },
]);
expect(withAttr).toContain('data-hf-studio-path-offset="true"');
const { html: removed } = patchElementInHtml(withAttr, { id: "hero" }, [
{ type: "attribute", property: "data-hf-studio-path-offset", value: null },
]);
expect(removed).not.toContain("hf-studio-path-offset");
});
it("patches html attribute", () => {
const { html: result } = patchElementInHtml(FIXTURE, { id: "hero" }, [
{ type: "html-attribute", property: "title", value: "greeting" },
]);
expect(result).toContain('title="greeting"');
});
it("patches text content", () => {
const { html: result } = patchElementInHtml(FIXTURE, { id: "hero" }, [
{ type: "text-content", property: "", value: "New Title" },
]);
expect(result).toContain("New Title");
expect(result).not.toContain("Hello World");
});
it("applies child-scoped inline style without changing the parent style", () => {
const source = `<div data-hf-id="parent" style="color: red"><span class="line">A</span><span class="line">B</span></div>`;
const { html: result, matched } = patchElementInHtml(source, { hfId: "parent" }, [
{
type: "inline-style",
property: "color",
value: "blue",
childSelector: ":scope > span",
childIndex: 1,
},
]);
expect(matched).toBe(true);
const { document } = parseHTML(result);
const parent = document.querySelector('[data-hf-id="parent"]');
const children = Array.from(document.querySelectorAll(".line"));
expect(parent?.getAttribute("style")).toContain("color: red");
expect(children[0]?.getAttribute("style")).toBeNull();
expect(children[1]?.getAttribute("style")).toContain("color: blue");
});
it("applies child-scoped text content to the child only", () => {
const source = `<div data-hf-id="parent"><span class="line">A</span><span class="line">B</span></div>`;
const { html: result, matched } = patchElementInHtml(source, { hfId: "parent" }, [
{
type: "text-content",
property: "text",
value: "B < C & D",
childSelector: ":scope > span",
childIndex: 1,
},
]);
expect(matched).toBe(true);
const { document } = parseHTML(result);
const children = Array.from(document.querySelectorAll(".line"));
expect(children[0]?.textContent).toBe("A");
expect(children[1]?.textContent).toBe("B < C & D");
});
it("rejects the whole batch when a child-scoped operation cannot resolve", () => {
const source = `<div data-hf-id="parent"><span class="line">A</span><span class="line">B</span></div>`;
const result = patchElementInHtml(source, { hfId: "parent" }, [
{
type: "inline-style",
property: "color",
value: "blue",
childSelector: ":scope > span",
childIndex: 0,
},
{
type: "text-content",
property: "text",
value: "missing",
childSelector: ":scope > strong",
childIndex: 0,
},
]);
expect(result.matched).toBe(false);
expect(result.html).toBe(source);
});
it("applies multiple operations in one call", () => {
const { html: result } = patchElementInHtml(FIXTURE, { id: "hero" }, [
{ type: "inline-style", property: "color", value: "blue" },
{ type: "inline-style", property: "font-size", value: "96px" },
{ type: "attribute", property: "hf-studio-path-offset", value: "true" },
]);
expect(result).toMatch(/color:\s*blue/);
expect(result).toMatch(/font-size:\s*96px/);
expect(result).toContain('data-hf-studio-path-offset="true"');
});
it("finds element by composition-id selector", () => {
const { html: result } = patchElementInHtml(
FIXTURE,
{ selector: '[data-composition-id="overlay"]' },
[{ type: "inline-style", property: "opacity", value: "0.5" }],
);
expect(result).toMatch(/opacity:\s*0\.5/);
});
it("finds element by class with selectorIndex", () => {
const html = `<div class="item">A</div><div class="item">B</div>`;
const { html: result } = patchElementInHtml(html, { selector: ".item", selectorIndex: 1 }, [
{ type: "text-content", property: "", value: "Changed" },
]);
expect(result).toContain("A");
expect(result).toContain("Changed");
expect(result).not.toContain(">B<");
});
it("returns unchanged html and matched:false when target not found", () => {
const { html: result, matched } = patchElementInHtml(FIXTURE, { id: "nonexistent" }, [
{ type: "inline-style", property: "color", value: "red" },
]);
expect(matched).toBe(false);
expect(result).toBe(FIXTURE);
});
it("removes inline style when value is null", () => {
const { html: result } = patchElementInHtml(FIXTURE, { id: "hero" }, [
{ type: "inline-style", property: "font-size", value: null },
]);
expect(result).not.toContain("font-size");
});
it("removes attribute when value is null", () => {
const { html: result } = patchElementInHtml(
FIXTURE,
{ selector: '[data-composition-id="overlay"]' },
[{ type: "html-attribute", property: "data-composition-src", value: null }],
);
expect(result).not.toContain("data-composition-src");
});
it("patches fragment html without doctype", () => {
const fragment = `<div id="card" style="padding: 8px"><span>Title</span></div>`;
const { html: result } = patchElementInHtml(fragment, { id: "card" }, [
{ type: "inline-style", property: "padding", value: "16px" },
]);
expect(result).toMatch(/padding:\s*16px/);
});
it("rejects event handler attributes", () => {
const { html: result } = patchElementInHtml(FIXTURE, { id: "hero" }, [
{ type: "html-attribute", property: "onload", value: "fetch('/evil')" },
]);
expect(result).not.toContain("onload");
expect(result).not.toContain("fetch");
});
it("rejects javascript: URLs in src", () => {
const { html: result } = patchElementInHtml(FIXTURE, { id: "hero" }, [
{ type: "html-attribute", property: "src", value: "javascript:alert(1)" },
]);
expect(result).not.toContain("javascript:");
});
it("allows aria-* and data-* attributes", () => {
const { html: result } = patchElementInHtml(FIXTURE, { id: "hero" }, [
{ type: "html-attribute", property: "aria-label", value: "greeting" },
{ type: "html-attribute", property: "data-custom", value: "test" },
]);
expect(result).toContain('aria-label="greeting"');
expect(result).toContain('data-custom="test"');
});
it("rejects srcdoc and formaction attributes", () => {
const { html: result } = patchElementInHtml(FIXTURE, { id: "hero" }, [
{ type: "html-attribute", property: "srcdoc", value: "<script>alert(1)</script>" },
{ type: "html-attribute", property: "formaction", value: "javascript:void(0)" },
]);
expect(result).not.toContain("srcdoc");
expect(result).not.toContain("formaction");
});
it("rejects on* event handlers regardless of casing", () => {
const { html: result } = patchElementInHtml(FIXTURE, { id: "hero" }, [
{ type: "html-attribute", property: "onClick", value: "alert(1)" },
{ type: "html-attribute", property: "ONERROR", value: "alert(2)" },
{ type: "html-attribute", property: "onmouseover", value: "alert(3)" },
]);
expect(result).not.toContain("alert");
});
it("rejects data:text/html URIs in src", () => {
const { html: result } = patchElementInHtml(FIXTURE, { id: "hero" }, [
{
type: "html-attribute",
property: "src",
value: "data:text/html,<script>alert(1)</script>",
},
]);
expect(result).not.toContain("data:text/html");
});
it("allows safe href values", () => {
const { html: result } = patchElementInHtml(FIXTURE, { id: "hero" }, [
{ type: "html-attribute", property: "href", value: "https://example.com" },
]);
expect(result).toContain('href="https://example.com"');
});
it("rejects javascript: in href", () => {
const { html: result } = patchElementInHtml(FIXTURE, { id: "hero" }, [
{ type: "html-attribute", property: "href", value: "javascript:alert(1)" },
]);
expect(result).not.toContain("javascript:");
});
it("allows legitimate form and media attributes", () => {
const { html: result } = patchElementInHtml(FIXTURE, { id: "hero" }, [
{ type: "html-attribute", property: "placeholder", value: "Enter text" },
{ type: "html-attribute", property: "target", value: "_blank" },
{ type: "html-attribute", property: "rel", value: "noopener" },
{ type: "html-attribute", property: "srcset", value: "img-2x.png 2x" },
]);
expect(result).toContain('placeholder="Enter text"');
expect(result).toContain('target="_blank"');
expect(result).toContain('rel="noopener"');
expect(result).toContain("srcset");
});
it("rejects unknown/dangerous attributes", () => {
const { html: result } = patchElementInHtml(FIXTURE, { id: "hero" }, [
{ type: "html-attribute", property: "xmlns", value: "http://evil.com" },
{ type: "html-attribute", property: "background", value: "http://evil.com/bg.js" },
{ type: "html-attribute", property: "dynsrc", value: "http://evil.com/vid.avi" },
]);
expect(result).not.toContain("xmlns");
expect(result).not.toContain("background=");
expect(result).not.toContain("dynsrc");
});
});
describe("probeElementInSource", () => {
const FIXTURE = `<!doctype html><html><head></head><body>
<div id="root" data-composition-id="main">
<div class="layer" data-composition-id="overlay" data-composition-src="compositions/overlay.html">
<div class="chrome">
<span class="brand">HyperFrames</span>
</div>
</div>
<div id="hero" class="hero-heading" style="font-size: 48px">Hello World</div>
</div>
</body></html>`;
it("returns true for an element found by id", () => {
expect(probeElementInSource(FIXTURE, { id: "hero" })).toBe(true);
});
it("returns true for an element found by class selector", () => {
expect(probeElementInSource(FIXTURE, { selector: ".hero-heading" })).toBe(true);
});
it("returns true for an element found by data-composition-id selector", () => {
expect(probeElementInSource(FIXTURE, { selector: '[data-composition-id="overlay"]' })).toBe(
true,
);
});
it("returns false for an id that does not exist in source", () => {
expect(probeElementInSource(FIXTURE, { id: "arrows-svg" })).toBe(false);
});
it("returns false for a class selector that does not exist", () => {
expect(probeElementInSource(FIXTURE, { selector: ".phone-frame" })).toBe(false);
});
it("returns false when target has neither id nor selector", () => {
expect(probeElementInSource(FIXTURE, {})).toBe(false);
});
it("returns true for class selector with valid selectorIndex", () => {
const html = `<div class="item">A</div><div class="item">B</div>`;
expect(probeElementInSource(html, { selector: ".item", selectorIndex: 1 })).toBe(true);
});
it("returns false for class selector with out-of-bounds selectorIndex", () => {
const html = `<div class="item">A</div><div class="item">B</div>`;
expect(probeElementInSource(html, { selector: ".item", selectorIndex: 5 })).toBe(false);
});
it("returns false for an element that would only exist after JS execution", () => {
const sourceHtml = `<!doctype html><html><head></head><body>
<div id="root" data-composition-id="main">
<div id="canvas"></div>
<script>
const svg = document.createElement("div");
svg.id = "arrows-svg";
document.getElementById("canvas").appendChild(svg);
</script>
</div>
</body></html>`;
expect(probeElementInSource(sourceHtml, { id: "arrows-svg" })).toBe(false);
expect(probeElementInSource(sourceHtml, { id: "canvas" })).toBe(true);
});
});
// T7 — data-hf-id targeting (spec for R1).
// R1 adds `hfId?: string` to SourceMutationTarget and a `[data-hf-id="…"]` branch
// in findTargetElement (sourceMutation.ts:34). Convert from it.todo in the R1 PR.
// Covers the same surface as T3 (Studio sourcePatcher) — Core sourceMutation supports
// all patch types (inline-style, attribute, text-content) via patchElementInHtml.
describe("T7 — data-hf-id targeting (spec for R1)", () => {
it("updates inline style by data-hf-id when no HTML id attribute is present", () => {
const source = `<h1 data-hf-id="hf-x7k2" style="color: red">Hello</h1>`;
const { html, matched } = patchElementInHtml(source, { hfId: "hf-x7k2" }, [
{ type: "inline-style", property: "color", value: "blue" },
]);
expect(matched).toBe(true);
expect(html).toMatch(/color:\s*blue/);
expect(html).toContain('data-hf-id="hf-x7k2"');
});
it("updates text content by data-hf-id", () => {
const source = `<p data-hf-id="hf-a1b2">Old text</p>`;
const { html, matched } = patchElementInHtml(source, { hfId: "hf-a1b2" }, [
{ type: "text-content", property: "", value: "New text" },
]);
expect(matched).toBe(true);
expect(html).toContain("New text");
});
it("updates attribute by data-hf-id", () => {
const source = `<div data-hf-id="hf-c3d4" data-start="0"></div>`;
const { html, matched } = patchElementInHtml(source, { hfId: "hf-c3d4" }, [
{ type: "attribute", property: "start", value: "2.5" },
]);
expect(matched).toBe(true);
expect(html).toContain('data-start="2.5"');
});
it("data-hf-id attribute survives the patch (can be targeted again)", () => {
const source = `<h1 data-hf-id="hf-x7k2" style="color: red">Hello</h1>`;
const { html } = patchElementInHtml(source, { hfId: "hf-x7k2" }, [
{ type: "inline-style", property: "color", value: "blue" },
]);
expect(html).toContain('data-hf-id="hf-x7k2"');
});
it("resolves a data-hf-id inside a NESTED template (matches SDK deep resolution)", () => {
// ensureHfIds and the SDK descend nested composition templates, so ids
// exist at any template depth; the server-side patch path must resolve
// them too or those ops silently no-op while the SDK reports parity.
const source = `<template data-composition-id="a"><div>x</div><template data-composition-id="b"><p data-hf-id="hf-deep" data-start="0">deep</p></template></template>`;
const { html, matched } = patchElementInHtml(source, { hfId: "hf-deep" }, [
{ type: "attribute", property: "start", value: "2.5" },
]);
expect(matched).toBe(true);
expect(html).toContain('data-start="2.5"');
});
it("hfId lookup falls through to selector when hfId is not found in the document", () => {
const source = `<h1 class="headline" style="color: red">Hello</h1>`;
const { html, matched } = patchElementInHtml(
source,
{ hfId: "hf-missing", selector: ".headline" },
[{ type: "inline-style", property: "color", value: "blue" }],
);
expect(matched).toBe(true);
expect(html).toMatch(/color:\s*blue/);
});
it("does not break out of the selector on a crafted hfId (CSS injection guard)", () => {
// A value with a quote/bracket must be escaped, not injected — it should
// simply match nothing and leave the source untouched, never throw.
const source = `<h1 class="safe">A</h1><h1 class="victim">B</h1>`;
const evil = `x"] , [class="victim`;
const run = () =>
patchElementInHtml(source, { hfId: evil }, [
{ type: "text-content", property: "textContent", value: "HACKED" },
]);
expect(run).not.toThrow();
const { html, matched } = run();
expect(matched).toBe(false);
expect(html).toBe(source);
expect(html).not.toContain("HACKED");
});
// The Studio edit path targets by id/selector (it never sends hfId). Once a
// persisted data-hf-id exists in source, those edits must NOT strip it — else
// the stable handle is destroyed by the next edit. This is the preservation
// guarantee the write-back design depends on.
it("preserves an existing data-hf-id when the element is patched by id", () => {
const source = `<h1 id="hero" data-hf-id="hf-x7k2" style="color: red">Hello</h1>`;
const { html, matched } = patchElementInHtml(source, { id: "hero" }, [
{ type: "inline-style", property: "color", value: "blue" },
]);
expect(matched).toBe(true);
expect(html).toMatch(/color:\s*blue/);
expect(html).toContain('data-hf-id="hf-x7k2"');
});
it("preserves an existing data-hf-id when the element is patched by selector", () => {
const source = `<p class="body" data-hf-id="hf-a1b2">Old</p>`;
const { html, matched } = patchElementInHtml(source, { selector: ".body" }, [
{ type: "text-content", property: "textContent", value: "New" },
]);
expect(matched).toBe(true);
expect(html).toContain("New");
expect(html).toContain('data-hf-id="hf-a1b2"');
});
});
@@ -0,0 +1,568 @@
import { parseHTML } from "linkedom";
import postcss from "postcss";
import selectorParser from "postcss-selector-parser";
import { isAllowedHtmlAttribute, isSafeAttributeValue } from "@hyperframes/core/html-attr-safety";
import { parseStyleDecls, patchStyleAttrString } from "./sourceStyleMutation.js";
export interface SourceMutationTarget {
id?: string | null;
hfId?: string;
selector?: string;
selectorIndex?: number;
}
function parseSourceDocument(source: string): { document: Document; wrappedFragment: boolean } {
const hasDocumentShell = /<!doctype|<html[\s>]/i.test(source);
if (hasDocumentShell) {
return { document: parseHTML(source).document, wrappedFragment: false };
}
return {
document: parseHTML(`<!DOCTYPE html><html><head></head><body>${source}</body></html>`).document,
wrappedFragment: true,
};
}
function duplicateCssRulesForId(document: Document, originalId: string, newId: string): void {
const idToken = `#${originalId}`;
const transform = selectorParser((selectors) => {
selectors.walkIds((node) => {
if (node.value === originalId) node.value = newId;
});
});
for (const styleEl of document.querySelectorAll("style")) {
const css = styleEl.textContent ?? "";
let root: postcss.Root;
try {
root = postcss.parse(css);
} catch {
continue;
}
const clones: postcss.Rule[] = [];
root.walkRules((rule) => {
if (!rule.selector.includes(idToken)) return;
const newSelector = transform.processSync(rule.selector);
if (newSelector === rule.selector) return;
const clone = rule.clone({ selector: newSelector });
clones.push(clone);
});
if (clones.length > 0) {
for (const c of clones) root.append(c);
styleEl.textContent = root.toString();
}
}
}
function querySelectorAllWithTemplates(root: Document | Element, selector: string): Element[] {
const matches = Array.from(root.querySelectorAll(selector));
if (matches.length > 0) return matches;
// querySelectorAll doesn't traverse <template> content in linkedom.
// Search directly on each template element (NOT .content — removing from
// .content's DocumentFragment doesn't update the serialized output).
// Recurse so NESTED templates resolve too — ensureHfIds and the SDK's
// querySelectorAllDeep descend nested composition templates, so ids exist at
// any template depth; a one-level search here would silently no-op
// server-side ops on those ids while the SDK resolves them.
const templates = Array.from(root.querySelectorAll("template"));
for (const tmpl of templates) {
const inner = querySelectorAllWithTemplates(tmpl, selector);
if (inner.length > 0) return inner;
}
return [];
}
// Prevent CSS attribute-selector injection via a crafted hfId: escape
// backslashes first, then double-quotes. Keeps a malformed/hostile value from
// breaking out of the `[data-hf-id="…"]` selector once callers beyond the
// internal mint contract (R2+ user flows) pass values here.
function escapeCssAttrValue(value: string): string {
return value.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
}
function findByHfId(document: Document, hfId: string): Element | null {
try {
const matches = querySelectorAllWithTemplates(
document,
`[data-hf-id="${escapeCssAttrValue(hfId)}"]`,
);
if (matches.length > 1) {
// The mint contract guarantees uniqueness; a duplicate means upstream
// id drift. Don't silently patch an arbitrary one — surface it.
// eslint-disable-next-line no-console
console.warn(
`sourceMutation: data-hf-id "${hfId}" matched ${matches.length} elements; using the first. ids must be unique per document.`,
);
}
return matches[0] ?? null;
} catch {
// Malformed selector despite escaping — let the caller fall back.
return null;
}
}
function findTargetElement(document: Document, target: SourceMutationTarget): Element | null {
if (target.hfId) {
const el = findByHfId(document, target.hfId);
if (el) return el;
}
if (target.id) {
const byId = document.getElementById(target.id);
if (byId) return byId;
}
if (!target.selector) return null;
try {
const matches = querySelectorAllWithTemplates(document, target.selector);
return matches[target.selectorIndex ?? 0] ?? null;
} catch {
return null;
}
}
export function removeElementFromHtml(source: string, target: SourceMutationTarget): string {
const { document, wrappedFragment } = parseSourceDocument(source);
const element = findTargetElement(document, target);
if (!element) return source;
element.remove();
return wrappedFragment ? document.body.innerHTML || "" : document.toString();
}
export function isHTMLElement(el: Node): el is HTMLElement {
const HTMLEl = el.ownerDocument?.defaultView?.HTMLElement;
return HTMLEl ? el instanceof HTMLEl : el.nodeType === 1 && "style" in el;
}
export interface PatchOperation {
type: "inline-style" | "attribute" | "html-attribute" | "text-content";
property: string;
value: string | null;
childSelector?: string;
childIndex?: number;
}
interface ResolvedPatchOperation {
op: PatchOperation;
target: HTMLElement;
}
function resolveOperationTarget(parent: HTMLElement, op: PatchOperation): HTMLElement | null {
if (op.childSelector === undefined) return parent;
try {
const child = parent.querySelectorAll(op.childSelector)[op.childIndex ?? 0] ?? null;
return child && isHTMLElement(child) ? child : null;
} catch {
return null;
}
}
// fallow-ignore-next-line complexity
export function patchElementInHtml(
source: string,
target: SourceMutationTarget,
operations: PatchOperation[],
): { html: string; matched: boolean } {
const { document, wrappedFragment } = parseSourceDocument(source);
const el = findTargetElement(document, target);
if (!el || !isHTMLElement(el)) return { html: source, matched: false };
const htmlEl = el;
const resolved: ResolvedPatchOperation[] = [];
for (const op of operations) {
const opTarget = resolveOperationTarget(htmlEl, op);
if (!opTarget) return { html: source, matched: false };
resolved.push({ op, target: opTarget });
}
for (const { op, target: opTarget } of resolved) {
switch (op.type) {
case "inline-style":
// linkedom's CSSStyleDeclaration does not support CSS custom properties
// (--foo) or newer individual transform properties (translate, rotate,
// scale) via style.setProperty(). Manipulate the style attribute string
// directly so all property names survive the round-trip.
{
const raw = opTarget.getAttribute("style") ?? "";
const patched = patchStyleAttrString(raw, op.property, op.value);
opTarget.setAttribute("style", patched);
}
break;
case "attribute":
{
const fullAttr = op.property.startsWith("data-") ? op.property : `data-${op.property}`;
if (op.value != null) {
opTarget.setAttribute(fullAttr, op.value);
} else {
opTarget.removeAttribute(fullAttr);
}
}
break;
case "html-attribute":
if (!isAllowedHtmlAttribute(op.property)) break;
if (op.value != null) {
if (!isSafeAttributeValue(op.property, op.value)) break;
opTarget.setAttribute(op.property, op.value);
} else {
opTarget.removeAttribute(op.property);
}
break;
case "text-content":
if (op.value != null) {
const inner = opTarget.children.length === 1 ? opTarget.firstElementChild : null;
const textTarget = inner && isHTMLElement(inner) ? inner : opTarget;
textTarget.textContent = op.value;
}
break;
}
}
return {
html: wrappedFragment ? document.body.innerHTML || "" : document.toString(),
matched: true,
};
}
export function probeElementInSource(source: string, target: SourceMutationTarget): boolean {
if (!target.id && !target.hfId && !target.selector) return false;
const { document } = parseSourceDocument(source);
const el = findTargetElement(document, target);
return el != null && isHTMLElement(el);
}
export interface SplitElementResult {
html: string;
matched: boolean;
newId: string | null;
}
function resolveElementTiming(el: Element): {
start: number;
duration: number;
usesDataEnd: boolean;
} {
const start = parseFloat(el.getAttribute("data-start") ?? "0") || 0;
const usesDataEnd = el.hasAttribute("data-end");
const duration = usesDataEnd
? parseFloat(el.getAttribute("data-end") ?? "") - start || 0
: parseFloat(el.getAttribute("data-duration") ?? "0") || 0;
return { start, duration, usesDataEnd };
}
function setElementDuration(
el: Element,
start: number,
duration: number,
usesDataEnd: boolean,
): void {
if (usesDataEnd) {
const endTime = String(Math.round((start + duration) * 1000) / 1000);
el.setAttribute("data-end", endTime);
el.removeAttribute("data-duration");
} else {
el.setAttribute("data-duration", String(Math.round(duration * 1000) / 1000));
el.removeAttribute("data-end");
}
}
// fallow-ignore-next-line complexity
export function splitElementInHtml(
source: string,
target: SourceMutationTarget,
splitTime: number,
newId: string,
fallbackTiming?: { start: number; duration: number },
): SplitElementResult {
const { document, wrappedFragment } = parseSourceDocument(source);
const el = findTargetElement(document, target);
if (!el || !isHTMLElement(el)) return { html: source, matched: false, newId: null };
const timing = resolveElementTiming(el);
const { usesDataEnd } = timing;
let { start, duration } = timing;
// GSAP-animated elements carry their timing in the script, not in data-* attrs,
// so the source has no authored duration. Fall back to the store's (GSAP-derived)
// range — the runtime windows visibility off data-start/data-duration regardless
// of class, so stamping both halves below makes each half show only in its window.
if (duration <= 0 && fallbackTiming && fallbackTiming.duration > 0) {
start = fallbackTiming.start;
duration = fallbackTiming.duration;
}
if (duration <= 0 || splitTime <= start || splitTime >= start + duration) {
return { html: source, matched: false, newId: null };
}
if (document.getElementById(newId)) {
let suffix = 2;
const base = newId;
while (document.getElementById(newId)) {
newId = `${base}-${suffix++}`;
}
}
const firstDuration = splitTime - start;
const secondDuration = duration - firstDuration;
const clone = el.cloneNode(true);
if (!isHTMLElement(clone)) return { html: source, matched: false, newId: null };
clone.setAttribute("id", newId);
clone.removeAttribute("data-hf-id");
// Descendants carry their own data-hf-id; leaving them duplicates the id of
// every nested node (e.g. an inner <span>), so strip them on the clone too.
for (const node of clone.querySelectorAll("[data-hf-id]")) node.removeAttribute("data-hf-id");
clone.setAttribute("data-start", String(Math.round(splitTime * 1000) / 1000));
setElementDuration(clone, splitTime, secondDuration, usesDataEnd);
// Keep the "clip" class — the runtime uses it to control visibility
// based on data-start/data-duration timing.
// Adjust media trim offset for the second half
const playbackStartAttr = el.hasAttribute("data-playback-start")
? "data-playback-start"
: el.hasAttribute("data-media-start")
? "data-media-start"
: null;
if (playbackStartAttr) {
const currentTrim = parseFloat(el.getAttribute(playbackStartAttr) ?? "0") || 0;
const rateRaw = parseFloat(el.getAttribute("data-playback-rate") ?? "");
const rate = Number.isFinite(rateRaw) ? rateRaw : 1;
clone.setAttribute(
playbackStartAttr,
String(Math.round((currentTrim + firstDuration * rate) * 1000) / 1000),
);
}
// Duplicate CSS rules targeting the original ID so the clone inherits the same styles.
const originalId = el.getAttribute("id");
if (originalId) {
duplicateCssRulesForId(document, originalId, newId);
}
// Trim the original element's duration. A GSAP element had no data-start; stamp
// it so the runtime windows the first half (visibility selects on [data-start]).
el.setAttribute("data-start", String(Math.round(start * 1000) / 1000));
setElementDuration(el, start, firstDuration, usesDataEnd);
// Insert clone after original
if (el.nextSibling) {
el.parentElement!.insertBefore(clone, el.nextSibling);
} else {
el.parentElement!.appendChild(clone);
}
return {
html: wrappedFragment ? document.body.innerHTML || "" : document.toString(),
matched: true,
newId,
};
}
// --- Element grouping -------------------------------------------------------
// A group is a real `<div data-hf-group="…">` wrapping its members in the DOM.
// Wrapping rebases each member's left/top so its absolute position is unchanged:
// the wrapper sits at the selection bbox top-left, and each child's new left/top
// is its old left/top minus the wrapper origin (computed client-side, where live
// layout is available, and passed in via `rebases`). GSAP x/y, CSS translate and
// --hf-studio-offset vars are deltas relative to flow position and stay untouched.
export interface WrapElementsResult {
html: string;
matched: boolean;
groupId: string | null;
error?: string;
}
export interface UnwrapElementsResult {
html: string;
unwrapped: boolean;
/** The unwrapped wrapper's id, so callers can strip GSAP that targeted it
* (the wrapper is gone; a leftover `gsap.set("#id")` would throw at runtime). */
unwrappedGroupId?: string;
/** Members (id'd children) with their absolute layout centres (post un-rebase),
* so the caller can BAKE the group's GSAP transform into each member before
* stripping it — otherwise the group's moves are lost on ungroup. */
members?: Array<{ id: string; cx: number; cy: number }>;
/** The wrapper's layout centre — the pivot for baking the group's rotation/scale. */
groupCenter?: { cx: number; cy: number };
}
export interface ElementRebase {
target: SourceMutationTarget;
left: number;
top: number;
}
function getInlineStylePx(el: Element, property: string): number {
const style = (isHTMLElement(el) ? el.getAttribute("style") : null) ?? "";
const { props } = parseStyleDecls(style);
const raw = props.get(property);
if (!raw) return 0;
const n = parseFloat(raw);
return Number.isFinite(n) ? n : 0;
}
function setInlineLeftTop(el: HTMLElement, left: number, top: number): void {
let style = el.getAttribute("style") ?? "";
style = patchStyleAttrString(style, "left", `${left}px`);
style = patchStyleAttrString(style, "top", `${top}px`);
el.setAttribute("style", style);
}
// Slug the group name ("Group 1" → "group-1") into a unique, valid element id.
function uniqueGroupDomId(document: Document, groupId: string): string {
const base =
groupId
.trim()
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-+|-+$/g, "") || "group";
let id = base;
let n = 2;
while (document.getElementById(id)) {
id = `${base}-${n}`;
n += 1;
}
return id;
}
// fallow-ignore-next-line complexity
export function wrapElementsInHtml(
source: string,
targets: SourceMutationTarget[],
groupId: string,
bbox: { left: number; top: number; width: number; height: number },
rebases: ElementRebase[],
): WrapElementsResult {
const { document, wrappedFragment } = parseSourceDocument(source);
if (targets.length === 0) {
return { html: source, matched: false, groupId: null, error: "no targets" };
}
// Resolve + dedupe by element ref (two targets may point at the same node).
const els: HTMLElement[] = [];
const seen = new Set<Element>();
for (const target of targets) {
const el = findTargetElement(document, target);
if (!el || !isHTMLElement(el) || seen.has(el)) continue;
seen.add(el);
els.push(el);
}
if (els.length === 0) {
return { html: source, matched: false, groupId: null, error: "no targets matched" };
}
// P1: require a single common parent (LCA multi-parent wrapping is P2).
const parent = els[0]?.parentElement;
if (!parent || els.some((el) => el.parentElement !== parent)) {
return {
html: source,
matched: false,
groupId: null,
error: "grouped elements must share a single parent",
};
}
// Order members by their position in the parent (= z-order / stacking order).
const memberSet = new Set<Element>(els);
const ordered = Array.from(parent.children).filter((c): c is HTMLElement => memberSet.has(c));
// Map each member to its rebased left/top (resolved against the same document).
const rebaseByEl = new Map<Element, { left: number; top: number }>();
for (const rebase of rebases) {
const el = findTargetElement(document, rebase.target);
if (el) rebaseByEl.set(el, { left: rebase.left, top: rebase.top });
}
const wrapper = document.createElement("div");
wrapper.setAttribute("data-hf-group", groupId);
// A real `id` (slug of the group name) makes the wrapper a first-class node in the
// clip manifest / timeline parent-map (both keyed by id) and a clean GSAP target —
// without it the wrapper is invisible to the timeline and breaks child enumeration.
wrapper.setAttribute("id", uniqueGroupDomId(document, groupId));
// Adopt the topmost member's stacking level. A group is one stacking unit, so a
// non-member interleaved between two selected members can't stay "between" them
// once they unify. Matching Figma/Sketch, the group lifts to the topmost selected
// layer: the wrapper goes at the LAST member's slot and carries the max member
// z-index — so an interleaved non-member falls below the group instead of hoisting
// above it, and explicit member z-indexes are honored.
const memberZIndexes = ordered
.map((el) =>
Number.parseInt(
parseStyleDecls(el.getAttribute("style") ?? "").props.get("z-index") ?? "",
10,
),
)
.filter((z) => Number.isFinite(z));
const maxZ = memberZIndexes.length > 0 ? Math.max(...memberZIndexes) : null;
wrapper.setAttribute(
"style",
`position: absolute; left: ${bbox.left}px; top: ${bbox.top}px; width: ${bbox.width}px; height: ${bbox.height}px` +
(maxZ !== null ? `; z-index: ${maxZ}` : ""),
);
// Insert the wrapper at the topmost member's slot, then move members into it.
parent.insertBefore(wrapper, ordered[ordered.length - 1] ?? null);
for (const el of ordered) {
const rebase = rebaseByEl.get(el);
if (rebase) setInlineLeftTop(el, rebase.left, rebase.top);
wrapper.appendChild(el); // appendChild moves the node, preserving order
}
return {
html: wrappedFragment ? document.body.innerHTML || "" : document.toString(),
matched: true,
groupId,
};
}
export function unwrapElementsFromHtml(
source: string,
groupTarget: SourceMutationTarget,
): UnwrapElementsResult {
const { document, wrappedFragment } = parseSourceDocument(source);
const group = findTargetElement(document, groupTarget);
if (!group || !isHTMLElement(group)) return { html: source, unwrapped: false };
// Shape guard mirroring the wrap-side contract: only ever dissolve an actual
// group wrapper. A stale/desynced selection that resolves to a plain <div>
// would otherwise be unwrapped — rebasing its children by the parent's origin
// (silent corruption). Wrap enforces invariants; unwrap must too.
if (!group.hasAttribute("data-hf-group")) return { html: source, unwrapped: false };
const parent = group.parentElement;
if (!parent) return { html: source, unwrapped: false };
// Undo the rebase: child absolute position = child (rebased) + wrapper origin.
const wLeft = getInlineStylePx(group, "left");
const wTop = getInlineStylePx(group, "top");
const groupCenter = {
cx: wLeft + getInlineStylePx(group, "width") / 2,
cy: wTop + getInlineStylePx(group, "height") / 2,
};
// Move children back to the wrapper's slot, preserving order.
const members: Array<{ id: string; cx: number; cy: number }> = [];
for (const child of Array.from(group.children)) {
if (isHTMLElement(child)) {
const newLeft = getInlineStylePx(child, "left") + wLeft;
const newTop = getInlineStylePx(child, "top") + wTop;
setInlineLeftTop(child, newLeft, newTop);
if (child.id) {
members.push({
id: child.id,
cx: newLeft + getInlineStylePx(child, "width") / 2,
cy: newTop + getInlineStylePx(child, "height") / 2,
});
}
}
parent.insertBefore(child, group);
}
const groupId = group.id || undefined;
group.remove();
return {
html: wrappedFragment ? document.body.innerHTML || "" : document.toString(),
unwrapped: true,
unwrappedGroupId: groupId,
members,
groupCenter,
};
}
@@ -0,0 +1,218 @@
import { parseHTML } from "linkedom";
import { describe, expect, it } from "vitest";
import {
splitElementInHtml,
unwrapElementsFromHtml,
wrapElementsInHtml,
} from "./sourceMutation.js";
describe("splitElementInHtml — hfId clone isolation", () => {
it("does not copy data-hf-id to the cloned second half", () => {
const source = `<html><body><div data-composition-id="root"><div id="clip1" class="clip" data-start="0" data-duration="10" data-hf-id="hf-abc123"></div></div></body></html>`;
const { html, matched } = splitElementInHtml(source, { id: "clip1" }, 5, "clip2");
expect(matched).toBe(true);
const occurrences = (html.match(/data-hf-id="hf-abc123"/g) ?? []).length;
expect(occurrences).toBe(1);
});
});
describe("splitElementInHtml", () => {
const source = `<!DOCTYPE html><html><head><style>#box { position: absolute; top: 100px; background: red; }</style></head><body><div data-composition-id="root"><div id="box" class="clip" data-start="1" data-duration="6">Hello</div></div></body></html>`;
it("splits element at the given time", () => {
const result = splitElementInHtml(source, { id: "box" }, 3, "box-split");
expect(result.matched).toBe(true);
expect(result.html).toContain('data-duration="2"');
expect(result.html).toContain('id="box-split"');
expect(result.html).toContain('data-start="3"');
expect(result.html).toContain('data-duration="4"');
});
it("duplicates CSS rules for the new element ID", () => {
const result = splitElementInHtml(source, { id: "box" }, 3, "box-split");
expect(result.html).toContain("#box-split");
expect(result.html).toContain("background: red");
const cssMatches = result.html.match(/#box-split\s*\{/g);
expect(cssMatches?.length).toBeGreaterThanOrEqual(1);
});
it("deduplicates IDs when the requested newId already exists", () => {
const withExisting = source.replace(
"</div></div>",
'</div><div id="box-split" data-start="5" data-duration="1">Existing</div></div>',
);
const result = splitElementInHtml(withExisting, { id: "box" }, 3, "box-split");
expect(result.matched).toBe(true);
expect(result.html).toContain('id="box-split-2"');
});
it("keeps clip class on the cloned element", () => {
const result = splitElementInHtml(source, { id: "box" }, 3, "box-split");
expect(result.html).toMatch(/id="box-split"[^>]*class="clip"/);
});
it("returns matched false for out-of-range split time", () => {
expect(splitElementInHtml(source, { id: "box" }, 0.5, "box-split").matched).toBe(false);
expect(splitElementInHtml(source, { id: "box" }, 7.5, "box-split").matched).toBe(false);
});
it("splits a GSAP element with no authored timing using fallback timing", () => {
const gsapSource = `<html><body><div data-composition-id="root"><h1 id="title" class="title">Hi</h1></div></body></html>`;
const result = splitElementInHtml(gsapSource, { id: "title" }, 2, "title-split", {
start: 0,
duration: 6,
});
expect(result.matched).toBe(true);
const original = result.html.match(/<h1[^>]*\bid="title"[^>]*>/);
const clone = result.html.match(/<h1[^>]*\bid="title-split"[^>]*>/);
expect(original?.[0]).toContain('data-start="0"');
expect(original?.[0]).toContain('data-duration="2"');
expect(clone?.[0]).toContain('data-start="2"');
expect(clone?.[0]).toContain('data-duration="4"');
});
it("still rejects a no-timing element when no fallback timing is given", () => {
const gsapSource = `<html><body><div data-composition-id="root"><h1 id="title">Hi</h1></div></body></html>`;
expect(splitElementInHtml(gsapSource, { id: "title" }, 2, "title-split").matched).toBe(false);
});
it("adjusts media playback-start for the second half", () => {
const mediaSource = source.replace(
'id="box" class="clip" data-start="1" data-duration="6"',
'id="box" class="clip" data-start="1" data-duration="6" data-playback-start="0"',
);
const result = splitElementInHtml(mediaSource, { id: "box" }, 3, "box-split");
expect(result.html).toMatch(/id="box-split"[^>]*data-playback-start="2"/);
});
});
describe("wrapElementsInHtml / unwrapElementsFromHtml", () => {
const FIXTURE = `<!doctype html><html><body><div data-composition-id="main">
<div id="title" class="clip" style="position: absolute; left: 260px; top: 100px">Title</div>
<div id="logo" class="clip" style="position: absolute; left: 300px; top: 200px; transform: translate(10px, 5px)">Logo</div>
<div id="badge" class="clip" style="position: absolute; left: 400px; top: 50px; --hf-studio-offset: 12px">Badge</div>
<div id="outside" class="clip" style="position: absolute; left: 10px; top: 10px">Outside</div>
</div></body></html>`;
const BBOX = { left: 260, top: 50, width: 300, height: 300 };
const REBASES = [
{ target: { id: "title" }, left: 0, top: 50 },
{ target: { id: "logo" }, left: 40, top: 150 },
{ target: { id: "badge" }, left: 140, top: 0 },
];
const TARGETS = [{ id: "title" }, { id: "logo" }, { id: "badge" }];
function leftTop(el: Element): { left: number; top: number } {
const style = el.getAttribute("style") ?? "";
const left = parseFloat(/(?:^|;)\s*left\s*:\s*([\d.]+)px/.exec(style)?.[1] ?? "NaN");
const top = parseFloat(/(?:^|;)\s*top\s*:\s*([\d.]+)px/.exec(style)?.[1] ?? "NaN");
return { left, top };
}
function requireElement(document: Document, selector: string): Element {
const element = document.querySelector(selector);
if (!element) throw new Error(`Expected ${selector} to match`);
return element;
}
it("wraps members in a data-hf-group div, preserving order and rebasing left/top", () => {
const { html, matched, groupId } = wrapElementsInHtml(
FIXTURE,
TARGETS,
"Group 1",
BBOX,
REBASES,
);
expect(matched).toBe(true);
expect(groupId).toBe("Group 1");
const { document } = parseHTML(html);
const group = requireElement(document, '[data-hf-group="Group 1"]');
expect(leftTop(group)).toEqual({ left: 260, top: 50 });
expect(Array.from(group.children).map((c) => c.id)).toEqual(["title", "logo", "badge"]);
expect(requireElement(document, "#outside").parentElement).toBe(
requireElement(document, '[data-composition-id="main"]'),
);
expect(leftTop(requireElement(document, "#title"))).toEqual({ left: 0, top: 50 });
expect(leftTop(requireElement(document, "#logo"))).toEqual({ left: 40, top: 150 });
expect(requireElement(document, "#logo").getAttribute("style")).toContain(
"transform: translate(10px, 5px)",
);
expect(leftTop(requireElement(document, "#badge"))).toEqual({ left: 140, top: 0 });
expect(requireElement(document, "#badge").getAttribute("style")).toContain(
"--hf-studio-offset: 12px",
);
});
it("round-trips: unwrap restores original structure and coordinates", () => {
const wrapped = wrapElementsInHtml(FIXTURE, TARGETS, "Group 1", BBOX, REBASES).html;
const { html, unwrapped } = unwrapElementsFromHtml(wrapped, {
selector: '[data-hf-group="Group 1"]',
});
expect(unwrapped).toBe(true);
const { document } = parseHTML(html);
expect(document.querySelector("[data-hf-group]")).toBeNull();
const main = requireElement(document, '[data-composition-id="main"]');
expect(Array.from(main.children).map((c) => c.id)).toEqual([
"title",
"logo",
"badge",
"outside",
]);
expect(leftTop(requireElement(document, "#title"))).toEqual({ left: 260, top: 100 });
expect(leftTop(requireElement(document, "#logo"))).toEqual({ left: 300, top: 200 });
expect(requireElement(document, "#logo").getAttribute("style")).toContain(
"transform: translate(10px, 5px)",
);
expect(leftTop(requireElement(document, "#badge"))).toEqual({ left: 400, top: 50 });
expect(requireElement(document, "#badge").getAttribute("style")).toContain(
"--hf-studio-offset: 12px",
);
});
it("rejects members that do not share a single parent", () => {
const split = `<!doctype html><html><body><div data-composition-id="main"><div id="a" style="position:absolute;left:0;top:0"></div><section><div id="b" style="position:absolute;left:0;top:0"></div></section></div></body></html>`;
const result = wrapElementsInHtml(split, [{ id: "a" }, { id: "b" }], "Group 1", BBOX, [
{ target: { id: "a" }, left: 0, top: 0 },
{ target: { id: "b" }, left: 0, top: 0 },
]);
expect(result.matched).toBe(false);
expect(result.error).toMatch(/single parent/);
expect(result.html).toBe(split);
});
it("lifts the group to the topmost member's slot so an interleaved non-member falls below it", () => {
const fixture = `<!doctype html><html><body><div data-composition-id="main"><div id="low" style="position:absolute;left:0;top:0;z-index:2"></div><div id="middle" style="position:absolute;left:0;top:0;z-index:3"></div><div id="high" style="position:absolute;left:0;top:0;z-index:4"></div></div></body></html>`;
const { html, matched } = wrapElementsInHtml(
fixture,
[{ id: "low" }, { id: "high" }],
"Group 1",
{ left: 0, top: 0, width: 10, height: 10 },
[
{ target: { id: "low" }, left: 0, top: 0 },
{ target: { id: "high" }, left: 0, top: 0 },
],
);
expect(matched).toBe(true);
const { document } = parseHTML(html);
const parent = requireElement(document, '[data-composition-id="main"]');
const group = requireElement(document, '[data-hf-group="Group 1"]');
expect(Array.from(group.children).map((c) => c.id)).toEqual(["low", "high"]);
const topChildren = Array.from(parent.children).map(
(c) => c.getAttribute("data-hf-group") ?? c.id,
);
expect(topChildren).toEqual(["middle", "Group 1"]);
expect(group.getAttribute("style")).toMatch(/z-index:\s*4/);
});
it("refuses to unwrap an element without data-hf-group (no silent corruption)", () => {
const html = `<!doctype html><html><body><div data-composition-id="main"><div id="plain" style="position:absolute;left:0;top:0"><span id="kid"></span></div></div></body></html>`;
const result = unwrapElementsFromHtml(html, { id: "plain" });
expect(result.unwrapped).toBe(false);
expect(result.html).toBe(html);
});
});
@@ -0,0 +1,58 @@
// fallow-ignore-next-line complexity
export function parseStyleDecls(style: string): { props: Map<string, string>; order: string[] } {
const props = new Map<string, string>();
const order: string[] = [];
let i = 0;
while (i < style.length) {
let depth = 0;
let inSingle = false;
let inDouble = false;
const start = i;
while (i < style.length) {
const ch = style[i];
if (ch === "'" && !inDouble) inSingle = !inSingle;
else if (ch === '"' && !inSingle) inDouble = !inDouble;
else if (!inSingle && !inDouble) {
if (ch === "(") depth++;
else if (ch === ")") depth = Math.max(0, depth - 1);
else if (ch === ";" && depth === 0) break;
}
i++;
}
const decl = style.slice(start, i).trim();
i++;
if (!decl) continue;
const colon = decl.indexOf(":");
if (colon < 0) continue;
const key = decl.slice(0, colon).trim();
const val = decl.slice(colon + 1).trim();
if (!key) continue;
if (!props.has(key)) order.push(key);
props.set(key, val);
}
return { props, order };
}
function serializeStyleDecls(props: Map<string, string>, order: string[]): string {
return order
.map((k) => `${k}: ${props.get(k) ?? ""}`)
.filter((d) => d.trim())
.join("; ");
}
export function patchStyleAttrString(
style: string,
property: string,
value: string | null,
): string {
const { props, order } = parseStyleDecls(style);
if (value === null) {
props.delete(property);
const idx = order.indexOf(property);
if (idx >= 0) order.splice(idx, 1);
} else {
if (!props.has(property)) order.push(property);
props.set(property, value);
}
return serializeStyleDecls(props, order);
}
@@ -0,0 +1,205 @@
import { describe, expect, it } from "vitest";
import { Window } from "happy-dom";
import { createStudioMotionRenderBodyScript } from "./studioMotionRenderScript";
function runScript(window: Window, script: string): void {
const execute = new Function("window", "document", "HTMLElement", script);
execute(window, window.document, window.HTMLElement);
}
function installFakeGsap(window: Window): {
calls: Array<{
target: HTMLElement;
from: Record<string, unknown>;
to: Record<string, unknown>;
at: number;
}>;
timeCalls: number[];
customEaseCalls: Array<{ id: string; data: string }>;
killCalls: number;
} {
const state = {
calls: [] as Array<{
target: HTMLElement;
from: Record<string, unknown>;
to: Record<string, unknown>;
at: number;
}>,
timeCalls: [] as number[],
customEaseCalls: [] as Array<{ id: string; data: string }>,
killCalls: 0,
};
const timeline = {
fromTo(
target: HTMLElement,
from: Record<string, unknown>,
to: Record<string, unknown>,
at: number,
) {
state.calls.push({ target, from, to, at });
return timeline;
},
time(value: number) {
state.timeCalls.push(value);
return timeline;
},
pause() {
return timeline;
},
kill() {
state.killCalls += 1;
},
duration() {
return 2;
},
};
(
window as unknown as {
gsap: {
timeline: () => typeof timeline;
set: (target: HTMLElement, vars: Record<string, unknown>) => void;
};
CustomEase: { create: (id: string, data: string) => void };
__player?: { getTime: () => number };
}
).gsap = {
timeline: () => timeline,
set(target, vars) {
if (vars.clearProps === "transform,opacity,visibility") {
target.style.removeProperty("transform");
target.style.removeProperty("opacity");
target.style.removeProperty("visibility");
}
},
};
(
window as unknown as {
CustomEase: { create: (id: string, data: string) => void };
}
).CustomEase = {
create(id, data) {
state.customEaseCalls.push({ id, data });
},
};
return state;
}
describe("createStudioMotionRenderBodyScript", () => {
it("returns null for an empty manifest", () => {
expect(createStudioMotionRenderBodyScript("")).toBeNull();
});
it("returns null for a valid manifest without motions", () => {
expect(createStudioMotionRenderBodyScript(`{"version":1,"motions":[]}`)).toBeNull();
});
it("registers Studio-authored GSAP motion into window.__timelines", () => {
const window = new Window();
window.document.body.innerHTML = '<div id="card" style="opacity: 0.6"></div>';
const card = window.document.getElementById("card");
if (!(card instanceof window.HTMLElement)) throw new Error("card fixture missing");
const gsapState = installFakeGsap(window);
(
window as unknown as {
__player: { getTime: () => number };
}
).__player = { getTime: () => 0.5 };
const script = createStudioMotionRenderBodyScript(
JSON.stringify({
version: 1,
motions: [
{
kind: "gsap-motion",
target: { sourceFile: "index.html", id: "card" },
start: 0.2,
duration: 0.7,
ease: "power2.out",
from: { y: 32, autoAlpha: 0 },
to: { y: 0, autoAlpha: 1 },
},
],
}),
);
if (!script) throw new Error("script fixture missing");
runScript(window, script);
expect(gsapState.calls[0]).toMatchObject({
target: card,
from: { y: 32, autoAlpha: 0 },
to: { y: 0, autoAlpha: 1, duration: 0.7, ease: "power2.out" },
at: 0.2,
});
expect(gsapState.timeCalls).toEqual([0.5]);
expect(
(window as unknown as { __timelines?: Record<string, unknown> }).__timelines?.[
"studio-motion"
],
).toBeTruthy();
});
it("does not mutate when GSAP is unavailable", () => {
const window = new Window();
window.document.body.innerHTML = '<div id="card" style="opacity: 0.6"></div>';
const script = createStudioMotionRenderBodyScript(
JSON.stringify({
version: 1,
motions: [
{
kind: "gsap-motion",
target: { sourceFile: "index.html", id: "card" },
start: 0,
duration: 1,
ease: "none",
from: { x: 0 },
to: { x: 10 },
},
],
}),
);
if (!script) throw new Error("script fixture missing");
runScript(window, script);
expect(
(window as unknown as { __timelines?: Record<string, unknown> }).__timelines?.[
"studio-motion"
],
).toBeUndefined();
});
it("registers CustomEase data before adding Studio motion tweens", () => {
const window = new Window();
window.document.body.innerHTML = '<div id="card"></div>';
const gsapState = installFakeGsap(window);
const script = createStudioMotionRenderBodyScript(
JSON.stringify({
version: 1,
motions: [
{
kind: "gsap-motion",
target: { sourceFile: "index.html", id: "card" },
start: 0,
duration: 1,
ease: "studio-card-bounce",
customEase: {
id: "studio-card-bounce",
data: "M0,0 C0.18,0.9 0.32,1 1,1",
},
from: { y: 32 },
to: { y: 0 },
},
],
}),
);
if (!script) throw new Error("script fixture missing");
runScript(window, script);
expect(gsapState.customEaseCalls).toEqual([
{ id: "studio-card-bounce", data: "M0,0 C0.18,0.9 0.32,1 1,1" },
]);
expect(gsapState.calls[0]?.to.ease).toBe("studio-card-bounce");
});
});
@@ -0,0 +1,260 @@
export interface StudioMotionRenderScriptOptions {
activeCompositionPath?: string | null;
}
export const STUDIO_MOTION_PATH = ".hyperframes/studio-motion.json";
function hasStudioMotionEntries(manifestContent: string): boolean {
try {
const parsed = JSON.parse(manifestContent) as { motions?: unknown };
return Array.isArray(parsed.motions) && parsed.motions.length > 0;
} catch {
return false;
}
}
/**
* Builds the render-time Studio motion runtime script, or null when no owned motion exists.
*/
export function createStudioMotionRenderBodyScript(
manifestContent: string,
options: StudioMotionRenderScriptOptions = {},
): string | null {
if (!manifestContent.trim() || !hasStudioMotionEntries(manifestContent)) return null;
return `(${studioMotionRenderRuntime.toString()})(${JSON.stringify(manifestContent)}, ${JSON.stringify(options.activeCompositionPath ?? null)});`;
}
function studioMotionRenderRuntime(
manifestContent: string,
activeCompositionPath: string | null,
): void {
const STUDIO_MOTION_TIMELINE_ID = "studio-motion";
const STUDIO_MOTION_ATTR = "data-hf-studio-motion";
const ORIGINAL_TRANSFORM_ATTR = "data-hf-studio-motion-original-transform";
const ORIGINAL_OPACITY_ATTR = "data-hf-studio-motion-original-opacity";
const ORIGINAL_VISIBILITY_ATTR = "data-hf-studio-motion-original-visibility";
const objectRecord = (value: unknown): Record<string, unknown> | null =>
value && typeof value === "object" ? (value as Record<string, unknown>) : null;
const finiteNumber = (value: unknown): number | null =>
typeof value === "number" && Number.isFinite(value) ? value : null;
const runtimeWindow = window as Window & {
gsap?: {
timeline?: (vars?: Record<string, unknown>) => {
fromTo?: (
target: HTMLElement,
from: Record<string, unknown>,
to: Record<string, unknown>,
at: number,
) => unknown;
totalTime?: (time: number, suppressEvents?: boolean) => unknown;
time?: (time: number) => unknown;
pause?: () => unknown;
kill?: () => unknown;
};
set?: (target: HTMLElement, vars: Record<string, unknown>) => unknown;
registerPlugin?: (...plugins: unknown[]) => unknown;
};
CustomEase?: { create?: (id: string, data: string) => unknown };
__player?: { getTime?: () => number };
__timeline?: { time?: () => number };
__timelines?: Record<
string,
| {
kill?: () => unknown;
}
| undefined
>;
__hfStudioMotionApply?: () => number;
};
const parseMotionValues = (value: unknown): Record<string, number> | null => {
const record = objectRecord(value);
if (!record) return null;
const parsed: Record<string, number> = {};
for (const key of ["x", "y", "scale", "rotation", "opacity", "autoAlpha"]) {
const next = finiteNumber(record[key]);
if (next != null) parsed[key] = next;
}
return Object.keys(parsed).length > 0 ? parsed : null;
};
const parseCustomEase = (value: unknown): { id: string; data: string } | null => {
const record = objectRecord(value);
if (!record) return null;
const id = typeof record.id === "string" ? record.id.trim() : "";
const data = typeof record.data === "string" ? record.data.trim() : "";
if (!id || !data) return null;
return { id, data };
};
const parsedManifest = (() => {
try {
return objectRecord(JSON.parse(manifestContent));
} catch {
return null;
}
})();
const manifestMotions = Array.isArray(parsedManifest?.motions) ? parsedManifest.motions : [];
const sourceFileForElement = (element: HTMLElement): string => {
let current: HTMLElement | null = element;
while (current) {
const sourceFile =
current.getAttribute("data-composition-file") ??
current.getAttribute("data-composition-src");
if (sourceFile) return sourceFile;
current = current.parentElement;
}
return activeCompositionPath ?? "index.html";
};
const elementMatchesSourceFile = (element: HTMLElement, sourceFile: string): boolean =>
sourceFileForElement(element) === sourceFile;
const isHTMLElement = (element: Element | null): element is HTMLElement =>
element instanceof HTMLElement;
const querySelectorCandidates = (selector: string): HTMLElement[] => {
const className = selector.match(/^\.([A-Za-z0-9_-]+)$/)?.[1];
if (className) {
return Array.from(document.getElementsByTagName("*")).filter(
(element): element is HTMLElement =>
isHTMLElement(element) && element.classList.contains(className),
);
}
if (/^[A-Za-z][A-Za-z0-9-]*$/.test(selector)) {
return Array.from(document.getElementsByTagName(selector)).filter(isHTMLElement);
}
return Array.from(document.querySelectorAll(selector)).filter(isHTMLElement);
};
const resolveTarget = (targetRecord: Record<string, unknown>): HTMLElement | null => {
const sourceFile = typeof targetRecord.sourceFile === "string" ? targetRecord.sourceFile : "";
if (!sourceFile) return null;
const id = typeof targetRecord.id === "string" ? targetRecord.id : "";
if (id) {
const byId = document.getElementById(id);
if (isHTMLElement(byId) && elementMatchesSourceFile(byId, sourceFile)) return byId;
}
const selector = typeof targetRecord.selector === "string" ? targetRecord.selector : "";
if (!selector) return null;
try {
const selectorIndex = Math.max(0, Math.floor(finiteNumber(targetRecord.selectorIndex) ?? 0));
return (
querySelectorCandidates(selector).filter((element) =>
elementMatchesSourceFile(element, sourceFile),
)[selectorIndex] ?? null
);
} catch {
return null;
}
};
const restoreElement = (element: HTMLElement): void => {
runtimeWindow.gsap?.set?.(element, { clearProps: "transform,opacity,visibility" });
element.style.transform = element.getAttribute(ORIGINAL_TRANSFORM_ATTR) ?? "";
element.style.opacity = element.getAttribute(ORIGINAL_OPACITY_ATTR) ?? "";
element.style.visibility = element.getAttribute(ORIGINAL_VISIBILITY_ATTR) ?? "";
element.removeAttribute(STUDIO_MOTION_ATTR);
element.removeAttribute(ORIGINAL_TRANSFORM_ATTR);
element.removeAttribute(ORIGINAL_OPACITY_ATTR);
element.removeAttribute(ORIGINAL_VISIBILITY_ATTR);
};
const restoreStudioMotionElements = (): void => {
for (const element of Array.from(document.querySelectorAll(`[${STUDIO_MOTION_ATTR}]`))) {
if (isHTMLElement(element)) restoreElement(element);
}
};
const readCurrentTime = (): number => {
try {
const playerTime = runtimeWindow.__player?.getTime?.();
if (typeof playerTime === "number" && Number.isFinite(playerTime)) {
return Math.max(0, playerTime);
}
} catch {
// fall through
}
try {
const timelineTime = runtimeWindow.__timeline?.time?.();
if (typeof timelineTime === "number" && Number.isFinite(timelineTime)) {
return Math.max(0, timelineTime);
}
} catch {
// fall through
}
return 0;
};
const resolveEase = (motion: Record<string, unknown>): string => {
const fallback =
typeof motion.ease === "string" && motion.ease.trim() ? motion.ease.trim() : "none";
const customEase = parseCustomEase(motion.customEase);
const customEasePlugin = runtimeWindow.CustomEase;
if (!customEase || typeof customEasePlugin?.create !== "function") return fallback;
try {
runtimeWindow.gsap?.registerPlugin?.(customEasePlugin);
customEasePlugin.create(customEase.id, customEase.data);
return customEase.id;
} catch {
return fallback;
}
};
const applyManifest = (): number => {
runtimeWindow.__timelines = runtimeWindow.__timelines ?? {};
runtimeWindow.__timelines[STUDIO_MOTION_TIMELINE_ID]?.kill?.();
delete runtimeWindow.__timelines[STUDIO_MOTION_TIMELINE_ID];
restoreStudioMotionElements();
const gsap = runtimeWindow.gsap;
if (!gsap?.timeline || manifestMotions.length === 0) return 0;
const timeline = gsap.timeline({ paused: true, defaults: { overwrite: "auto" } });
let applied = 0;
for (const motionValue of manifestMotions) {
const motion = objectRecord(motionValue);
if (!motion || motion.kind !== "gsap-motion") continue;
const targetRecord = objectRecord(motion.target);
if (!targetRecord) continue;
const target = resolveTarget(targetRecord);
if (!target || typeof timeline.fromTo !== "function") continue;
const start = finiteNumber(motion.start);
const duration = finiteNumber(motion.duration);
if (start == null || duration == null || start < 0 || duration <= 0) continue;
const from = parseMotionValues(motion.from);
const to = parseMotionValues(motion.to);
if (!from || !to) continue;
if (!target.hasAttribute(STUDIO_MOTION_ATTR)) {
target.setAttribute(ORIGINAL_TRANSFORM_ATTR, target.style.transform);
target.setAttribute(ORIGINAL_OPACITY_ATTR, target.style.opacity);
target.setAttribute(ORIGINAL_VISIBILITY_ATTR, target.style.visibility);
}
target.setAttribute(STUDIO_MOTION_ATTR, "true");
timeline.fromTo(
target,
from,
{ ...to, duration, ease: resolveEase(motion), overwrite: "auto", immediateRender: false },
start,
);
applied += 1;
}
if (applied === 0) {
timeline.kill?.();
return 0;
}
runtimeWindow.__timelines[STUDIO_MOTION_TIMELINE_ID] = timeline;
timeline.pause?.();
const currentTime = readCurrentTime();
if (typeof timeline.totalTime === "function") timeline.totalTime(currentTime, false);
else timeline.time?.(currentTime);
return applied;
};
runtimeWindow.__hfStudioMotionApply = applyManifest;
applyManifest();
}
@@ -0,0 +1,362 @@
// @vitest-environment node
import { mkdtempSync, mkdirSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { describe, expect, it } from "vitest";
import { buildSubCompositionHtml } from "./subComposition";
function makeTempProject(files: Record<string, string>): string {
const dir = mkdtempSync(join(tmpdir(), "hf-subcomp-preview-"));
for (const [rel, content] of Object.entries(files)) {
const full = join(dir, rel);
mkdirSync(join(full, ".."), { recursive: true });
writeFileSync(full, content, "utf-8");
}
return dir;
}
describe("buildSubCompositionHtml", () => {
it("handles full HTML document compositions without nesting <html> in <body>", () => {
const dir = makeTempProject({
"index.html": `<!doctype html>
<html><head><title>Host</title></head><body></body></html>`,
"compositions/map-block.html": `<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=1920, height=1080" />
<link rel="stylesheet" href="../styles/theme.css" />
<script src="https://cdn.jsdelivr.net/npm/gsap@3.14.2/dist/gsap.min.js"></script>
<style>
.map { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: cover; }
#root { position: relative; width: 1920px; height: 1080px; overflow: hidden; }
</style>
</head>
<body>
<div id="root" data-composition-id="map-block" data-width="1920" data-height="1080">
<img class="map" src="assets/map.png" alt="" />
</div>
<script>
window.__timelines = window.__timelines || {};
window.__timelines["map-block"] = gsap.timeline({ paused: true });
</script>
</body>
</html>`,
});
const html = buildSubCompositionHtml(
dir,
"compositions/map-block.html",
"/api/runtime.js",
"/api/projects/demo/preview/",
);
expect(html).not.toBeNull();
// Must not nest a full HTML document inside <body>
const bodyStart = html!.indexOf("<body>");
const afterBody = html!.slice(bodyStart);
expect(afterBody).not.toContain("<html");
expect(afterBody).not.toContain("<head>");
// Composition styles must be in <head>, not lost
expect(html).toContain(".map {");
expect(html).toContain("#root {");
// Image src preserved (no ../ rewrite needed for bare relative paths)
expect(html).toContain('src="assets/map.png"');
// Base tag for asset resolution
expect(html).toContain('<base href="/api/projects/demo/preview/">');
// GSAP from the composition's own <head> must be preserved
expect(html).toContain("gsap@3.14.2");
// Body script content preserved
expect(html).toContain('__timelines["map-block"]');
// <link> and <meta> from composition head must not be dropped
expect(html).toContain('rel="stylesheet"');
expect(html).toContain('href="styles/theme.css"');
expect(html).toContain('name="viewport"');
// <html lang="en"> attribute forwarded to the output
expect(html).toContain('lang="en"');
});
it("handles raw fragment compositions (no template, no full document)", () => {
const dir = makeTempProject({
"index.html": `<!doctype html>
<html><head><title>Host</title></head><body></body></html>`,
"compositions/card.html": `<div data-composition-id="card" data-width="400" data-height="300">
<img src="../icon.svg" alt="" />
<p>Hello</p>
</div>`,
});
const html = buildSubCompositionHtml(
dir,
"compositions/card.html",
"/api/runtime.js",
"/api/projects/demo/preview/",
);
expect(html).not.toBeNull();
expect(html).toContain('<base href="/api/projects/demo/preview/">');
// ../icon.svg from compositions/ rewrites to icon.svg at project root
expect(html).toContain('src="icon.svg"');
expect(html).not.toContain('src="../icon.svg"');
expect(html).toContain("<p>Hello</p>");
});
it("rewrites sub-composition asset paths against the project root preview base", () => {
const dir = makeTempProject({
"index.html": `<!doctype html>
<html><head><title>Test</title></head><body></body></html>`,
"compositions/hero.html": `<template id="hero-template">
<div data-composition-id="hero" data-width="1920" data-height="1080">
<img src="../logo.png" alt="Logo" />
<div style="background-image: url('../poster.png')"></div>
<style>
@font-face {
font-family: "Brand Sans";
src: url("../fonts/brand.woff2") format("woff2");
}
</style>
</div>
</template>`,
});
const html = buildSubCompositionHtml(
dir,
"compositions/hero.html",
"/api/runtime.js",
"/api/projects/demo/preview/",
);
expect(html).toContain('<base href="/api/projects/demo/preview/">');
expect(html).toContain('src="logo.png"');
expect(html).toContain("background-image: url('poster.png')");
expect(html).toContain('url("fonts/brand.woff2")');
expect(html).not.toContain('src="../logo.png"');
expect(html).not.toContain("url('../poster.png')");
expect(html).not.toContain('url("../fonts/brand.woff2")');
});
it("promotes the <template>'s data-composition-id onto the root element when the content lacks one", () => {
const dir = makeTempProject({
"index.html": `<!doctype html><html><head></head><body></body></html>`,
"compositions/frames/02-music.html": `<template data-composition-id="02-music">
<style>#music-scene { background: #121212; }</style>
<div id="music-scene" class="clip" data-start="0" data-duration="3.213" data-track-index="0">
<div id="music-headline">ready when you are.</div>
</div>
<script>
window.__timelines = window.__timelines || {};
window.__timelines["02-music"] = gsap.timeline({ paused: true });
</script>
</template>`,
});
const html = buildSubCompositionHtml(
dir,
"compositions/frames/02-music.html",
"/api/runtime.js",
);
// Root rendered element gains the id so the runtime can bind the timeline
// (attribute order is serializer-dependent, so match the tag as a whole).
expect(html).toMatch(
/<div\b(?=[^>]*\sid="music-scene")(?=[^>]*\sdata-composition-id="02-music")[^>]*>/,
);
// The <style>/<script> siblings must NOT receive it.
expect(html).not.toMatch(/<style[^>]*data-composition-id/i);
expect(html).not.toMatch(/<script[^>]*data-composition-id/i);
});
it("extracts the real <template> even when a head comment mentions the literal text <template>", () => {
// Regression: a greedy /<template>([\s\S]*)<\/template>/ regex latches onto
// the "<template>" inside the head comment, mis-slicing the capture so the
// real content stays wrapped in an inert <template> in the output — leaving
// the preview with no [data-composition-id] element and rendering blank.
const dir = makeTempProject({
"index.html": `<!doctype html><html><head></head><body></body></html>`,
"compositions/frames/03-force-pair.html": `<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<!-- head is metadata only; the HF runtime clones ONLY <template> contents -->
</head>
<body>
<template>
<style>#fp-root { width: 1920px; height: 1080px; }</style>
<div id="fp-root" data-composition-id="03-force-pair" data-width="1920" data-height="1080">
<div class="headline">forces come in pairs</div>
</div>
<script>
window.__timelines = window.__timelines || {};
window.__timelines["03-force-pair"] = gsap.timeline({ paused: true });
</script>
</template>
</body>
</html>`,
});
const html = buildSubCompositionHtml(
dir,
"compositions/frames/03-force-pair.html",
"/api/runtime.js",
"/api/projects/demo/preview/",
);
expect(html).not.toBeNull();
// The real composition content is rendered into <body> directly...
expect(html).toContain('data-composition-id="03-force-pair"');
expect(html).toContain("forces come in pairs");
expect(html).toContain('__timelines["03-force-pair"]');
// ...and is NOT re-wrapped in an inert <template> (which the browser would
// never render). The comment fragment must not leak through either.
const bodyStart = html!.indexOf("<body>");
const body = html!.slice(bodyStart);
expect(body).not.toContain("<template");
expect(body).not.toContain("clones ONLY");
});
it("escapes #id selectors whose id starts with a digit so the rule is not dropped", () => {
// A CSS ident can't start with a digit, so `#01-wall { ... }` is an invalid
// selector and the browser drops the whole rule — the root loses its size and
// background and a standalone preview renders blank. Escape it to a valid form.
const dir = makeTempProject({
"index.html": `<!doctype html><html><head></head><body></body></html>`,
"compositions/frames/01-wall.html": `<template>
<div id="01-wall" data-composition-id="01-wall" data-start="0" data-duration="5.5" data-track-index="0">
<style>
#01-wall { position: absolute; inset: 0; width: 1920px; height: 1080px; background-color: #F0EBDE; }
.arrow { background: #1F2BE0; color: #1A2B3C; }
</style>
<div class="arrow">action</div>
</div>
</template>`,
});
const html = buildSubCompositionHtml(
dir,
"compositions/frames/01-wall.html",
"/api/runtime.js",
);
expect(html).not.toBeNull();
// The invalid `#01-wall` selector is rewritten to its escaped, valid form.
expect(html).toContain("#\\30 1-wall {");
expect(html).not.toMatch(/#01-wall\s*\{/);
// Digit-leading hex color VALUES (not element ids) must be left untouched.
expect(html).toContain("background: #1F2BE0;");
expect(html).toContain("color: #1A2B3C;");
// The element's id attribute itself is unchanged (only the selector is escaped).
expect(html).toContain('id="01-wall"');
});
it("does not add a duplicate data-composition-id when the root element already has one", () => {
const dir = makeTempProject({
"index.html": `<!doctype html><html><head></head><body></body></html>`,
"compositions/frames/04-allinone.html": `<template data-composition-id="04-allinone">
<div id="c04" data-composition-id="04-allinone" data-start="0" data-duration="5.695" data-track-index="0">
<div class="headline">all in one app.</div>
</div>
</template>`,
});
const html = buildSubCompositionHtml(
dir,
"compositions/frames/04-allinone.html",
"/api/runtime.js",
);
const occurrences = html?.match(/data-composition-id="04-allinone"/g) ?? [];
expect(occurrences).toHaveLength(1);
});
it("escapes every digit-leading #id selector when several appear in one composition", () => {
// The fix iterates over all digit-leading ids present on elements; pin that
// a second id in the same document is escaped too, not just the first.
const dir = makeTempProject({
"index.html": `<!doctype html><html><head></head><body></body></html>`,
"compositions/frames/01-wall.html": `<template>
<div id="01-wall" data-composition-id="01-wall" data-start="0" data-duration="4" data-track-index="0">
<style>
#01-wall { width: 1920px; height: 1080px; }
#02-music { color: #1F2BE0; }
</style>
<div id="02-music">two</div>
</div>
</template>`,
});
const html = buildSubCompositionHtml(
dir,
"compositions/frames/01-wall.html",
"/api/runtime.js",
);
expect(html).not.toBeNull();
expect(html).toContain("#\\30 1-wall {");
expect(html).toContain("#\\30 2-music {");
expect(html).not.toMatch(/#01-wall\s*\{/);
expect(html).not.toMatch(/#02-music\s*\{/);
// The hex color value is still left untouched.
expect(html).toContain("color: #1F2BE0;");
});
it("escapes a digit-leading #id inside compound and combinator selectors", () => {
// Only the leading-digit id token is escaped; the descendant/combinator and
// pseudo-class tail must be preserved verbatim.
const dir = makeTempProject({
"index.html": `<!doctype html><html><head></head><body></body></html>`,
"compositions/frames/01-wall.html": `<template>
<div id="01-wall" data-composition-id="01-wall" data-start="0" data-duration="4" data-track-index="0">
<style>
#01-wall .child > span { color: red; }
#01-wall:hover { opacity: 1; }
</style>
<div class="child"><span>x</span></div>
</div>
</template>`,
});
const html = buildSubCompositionHtml(
dir,
"compositions/frames/01-wall.html",
"/api/runtime.js",
);
expect(html).not.toBeNull();
expect(html).toContain("#\\30 1-wall .child > span {");
expect(html).toContain("#\\30 1-wall:hover {");
// No unescaped form survives.
expect(html).not.toContain("#01-wall .child");
expect(html).not.toContain("#01-wall:hover");
});
it("leaves the DOM untouched when the <template> has no data-composition-id to promote", () => {
// No data-composition-id on the <template> tag → promoteTemplateCompositionId
// returns early. The content's own id must be the only one, with nothing
// injected onto the root or its siblings.
const dir = makeTempProject({
"index.html": `<!doctype html><html><head></head><body></body></html>`,
"compositions/frames/05-plain.html": `<template>
<style>#c05 { width: 1920px; height: 1080px; }</style>
<div id="c05" data-composition-id="05-plain" data-start="0" data-duration="3" data-track-index="0">
<div class="headline">plain</div>
</div>
</template>`,
});
const html = buildSubCompositionHtml(
dir,
"compositions/frames/05-plain.html",
"/api/runtime.js",
);
expect(html).not.toBeNull();
// The authored id is preserved and is the ONLY data-composition-id — promote
// didn't fabricate one (no <template> id to copy) or duplicate it.
const occ = html?.match(/data-composition-id="05-plain"/g) ?? [];
expect(occ).toHaveLength(1);
expect(html).toMatch(
/<div\b(?=[^>]*\sid="c05")(?=[^>]*\sdata-composition-id="05-plain")[^>]*>/,
);
// The <style> sibling was not tagged.
expect(html).not.toMatch(/<style[^>]*data-composition-id/i);
});
});
@@ -0,0 +1,366 @@
import { existsSync, readFileSync } from "node:fs";
import { join } from "node:path";
import { parseHTML } from "linkedom";
import {
rewriteAssetPaths,
rewriteCssAssetUrls,
rewriteInlineStyleAssetUrls,
} from "@hyperframes/core";
import { stripEmbeddedRuntimeScripts } from "@hyperframes/core/compiler";
/**
* Detect whether `html` is a full document (has `<html>`, `<head>`, or
* `<!doctype`), as opposed to a `<template>`-wrapped fragment.
* Anchored to start-of-string (ignoring leading whitespace) so stray
* occurrences inside script/template content don't false-positive.
*/
function isFullHtmlDocument(html: string): boolean {
return /^\s*(?:<!doctype\s|<html[\s>])/i.test(html);
}
/**
* Rewrite relative asset paths in a parsed DOM tree. Shared across all
* three dispatch branches (template, full-doc, fragment) to avoid drift.
*/
function rewriteRelativePaths(root: ParentNode, compPath: string): void {
rewriteAssetPaths(
root.querySelectorAll("[src], [href]"),
compPath,
(el: Element, attr: string) => el.getAttribute(attr),
(el: Element, attr: string, value: string) => el.setAttribute(attr, value),
);
rewriteInlineStyleAssetUrls(
root.querySelectorAll("[style]"),
compPath,
(el: Element) => el.getAttribute("style"),
(el: Element, value: string) => el.setAttribute("style", value),
);
for (const styleEl of root.querySelectorAll("style")) {
styleEl.textContent = rewriteCssAssetUrls(styleEl.textContent || "", compPath);
}
}
/**
* Escape a CSS identifier whose first character is a digit so it is a valid
* selector. A CSS ident cannot start with a digit, so it must be written as an
* escaped code point: `01-foo` → `\30 1-foo` (leading `0` → `\30 `, rest kept).
*
* Only the leading digit needs escaping (per CSS Syntax Level 3 §4.3.11): once
* the parser consumes the `\<hex> ` escape, the rest of the ident continues
* normally, so `123-scene` → `\31 23-scene` is valid (the `23-scene` tail is
* consumed as identifier continuation). The trailing space terminates the hex
* escape so a following hex digit isn't folded into the code point.
*/
function escapeLeadingDigitIdent(id: string): string {
return `\\${id.charCodeAt(0).toString(16)} ${id.slice(1)}`;
}
const REGEXP_SPECIALS = /[.*+?^${}()|[\]\\]/g;
/**
* Fix `#<digit-leading-id>` selectors in the tree's `<style>` blocks.
*
* CSS identifiers cannot start with a digit, so an authored rule like
* `#01-wall-pushes-back { width: 1920px; height: 1080px; background: #F0EBDE }`
* is an invalid selector and the browser silently drops the WHOLE rule — taking
* the root's size and background with it. In a full composition the frame is
* stretched/painted by its `data-composition-src` host so the collapse is
* masked, but a standalone preview has no host: the root falls back to
* `height: 0` + transparent and the frame renders blank (black).
*
* Rewrite each such selector to its escaped, valid form (`#\30 1-wall-pushes-back`,
* which still matches `id="01-wall-pushes-back"`) so the rule applies and the
* whole declaration block — size, background, position, container-type — comes
* back. Scoped to ids that are actually present on elements in the content and
* matched only as `#id` not followed by another ident char, so hex colors
* (`#1F2BE0`) and other values are never touched (they are not element ids).
*/
function fixDigitLeadingIdSelectors(root: ParentNode): void {
const digitIds = new Set<string>();
for (const el of root.querySelectorAll("[id]")) {
const id = el.getAttribute("id");
if (id && /^\d/.test(id)) digitIds.add(id);
}
if (digitIds.size === 0) return;
for (const styleEl of root.querySelectorAll("style")) {
let css = styleEl.textContent || "";
for (const id of digitIds) {
const pattern = new RegExp(`#${id.replace(REGEXP_SPECIALS, "\\$&")}(?![\\w-])`, "g");
css = css.replace(pattern, `#${escapeLeadingDigitIdent(id)}`);
}
styleEl.textContent = css;
}
}
/**
* Parse a full HTML document and extract its head elements and body
* content separately, so they can be reassembled into a clean standalone
* page without nesting `<html>` inside `<body>`.
*
* Extracts the full innerHTML of `<head>` — this preserves `<style>`,
* `<script>`, `<link>`, `<meta>`, and any other head-level tags the
* composition declares. Dropping `<link rel="stylesheet">` or `<meta>`
* would cause silent rendering failures for compositions that ship with
* external CSS or viewport-dependent meta.
*
* `<html>` and `<body>` attributes (lang, class, data-*) are extracted
* so callers can forward them to the assembled page.
*/
function extractFullDocumentParts(
rawHtml: string,
compPath: string,
): {
headContent: string;
bodyContent: string;
htmlAttrs: string;
bodyAttrs: string;
} {
const { document: doc } = parseHTML(rawHtml);
const rewriteTargets = [doc.head, doc.body].filter(Boolean);
for (const target of rewriteTargets) {
rewriteRelativePaths(target, compPath);
}
// Run on the whole document: ids live in <body> but their rules may live in
// a <head> <style>, so the scope must span both.
fixDigitLeadingIdSelectors(doc);
const headContent = doc.head?.innerHTML ?? "";
const bodyContent = doc.body?.innerHTML ?? "";
const htmlEl = doc.documentElement;
const htmlAttrs = extractElementAttrs(htmlEl);
const bodyAttrs = doc.body ? extractElementAttrs(doc.body) : "";
return { headContent, bodyContent, htmlAttrs, bodyAttrs };
}
/**
* Extract the inner HTML of the composition's wrapping `<template>` element, or
* `null` if the source has no `<template>`.
*
* Located via the DOM rather than a regex. A greedy
* `/<template[^>]*>([\s\S]*)<\/template>/` can latch onto a literal
* `"<template>"` that appears inside an HTML comment — e.g. a head note such as
* "the HF runtime clones ONLY <template> contents" — and mis-slice the capture,
* leaving the real composition content re-wrapped in an inert `<template>` in
* the output. That template is never rendered by the browser, so the standalone
* preview has no `[data-composition-id]` element and no registered timeline, and
* renders blank. `querySelector("template")` only ever matches a real element
* node, so comment text can't fool it.
*/
function extractTemplateInnerHtml(rawComp: string): string | null {
const { document: doc } = parseHTML(rawComp);
const template = doc.querySelector("template");
return template ? template.innerHTML : null;
}
/** Attribute values read from the DOM are decoded — re-escape on rebuild or
* quote-bearing values (data-composition-variables is a JSON array) shred
* the wrapper's markup into bogus attributes. */
function escapeAttrValue(value: string): string {
return value.replace(/&/g, "&amp;").replace(/"/g, "&quot;");
}
function extractElementAttrs(el: Element): string {
const parts: string[] = [];
for (let i = 0; i < el.attributes.length; i++) {
const attr = el.attributes[i]!;
if (attr.value === "") {
parts.push(attr.name);
} else {
parts.push(`${attr.name}="${escapeAttrValue(attr.value)}"`);
}
}
return parts.join(" ");
}
const NON_RENDERED_TAGS = new Set(["SCRIPT", "STYLE", "LINK", "META", "TEMPLATE", "NOSCRIPT"]);
/**
* Carry the `<template>`'s `data-composition-id` onto the content's root
* rendered element when the author declared it only on the `<template>` tag.
*
* In a full composition, each sub-composition is mounted under a wrapper
* element (the `data-composition-src` host) that carries the composition id,
* which is how the runtime binds `window.__timelines[id]` into the player's
* master timeline. A standalone preview has no such wrapper, so it relies on
* the frame's own root element carrying `data-composition-id`. If the id lives
* only on the inert `<template>` tag (a common authoring pattern), the rendered
* body has no `[data-composition-id]` element — the runtime then never selects
* a root composition, the registered GSAP timeline stays unbound, and seeking
* does nothing. The frame renders at its pre-animation state (GSAP `fromTo`
* pins `opacity:0`), producing a blank preview/thumbnail.
*
* This is a no-op when the content already exposes a `[data-composition-id]`
* element (e.g. the id is authored on the root div), so compositions that
* already render correctly are untouched.
*/
function promoteTemplateCompositionId(rawComp: string, body: Element): void {
// Two-step match instead of one `[^>]*\s…` regex: the single-pattern form
// backtracks polynomially on crafted input (CodeQL js/polynomial-redos).
// Step 1 grabs each <template …> open tag (linear); step 2 finds the attr
// within that short tag text.
let templateCompositionId: string | undefined;
for (const tag of rawComp.matchAll(/<template\b[^>]*/gi)) {
const id = /\bdata-composition-id\s*=\s*["']([^"']+)["']/i.exec(tag[0] ?? "")?.[1];
if (id) {
templateCompositionId = id;
break;
}
}
if (!templateCompositionId) return;
if (body.querySelector("[data-composition-id]")) return;
const root = Array.from(body.children).find((el) => !NON_RENDERED_TAGS.has(el.tagName));
root?.setAttribute("data-composition-id", templateCompositionId);
}
/**
* Add `data-composition-file="<compPath>"` to the comp's root composition
* element (the first `[data-composition-id]` that lacks the attribute), so the
* studio resolves its top-level elements to the right source file. Idempotent;
* a no-op when no composition element is present.
*/
function tagRootCompositionFile(bodyHtml: string, compPath: string): string {
const match = bodyHtml.match(/<[a-zA-Z][^>]*\bdata-composition-id=/);
if (match?.index == null) return bodyHtml;
const tagEnd = bodyHtml.indexOf(">", match.index);
if (tagEnd === -1) return bodyHtml;
if (bodyHtml.slice(match.index, tagEnd).includes("data-composition-file")) return bodyHtml;
return (
bodyHtml.slice(0, tagEnd) + ` data-composition-file="${compPath}"` + bodyHtml.slice(tagEnd)
);
}
/**
* Build a standalone HTML page for a sub-composition.
*
* Uses the project's own index.html `<head>` so all dependencies (GSAP, fonts,
* Lottie, reset styles, runtime) are preserved — instead of building a minimal
* page from scratch that would miss important scripts/styles.
*
* Three dispatch modes, tried in order:
* 1. `<template>` wrapper → extract template content (existing compositions)
* 2. Full HTML document → parse and extract head/body separately (registry blocks)
* 3. Raw fragment → wrap in a minimal document
*
* For full-doc mode, the composition's own `<head>` content (styles, scripts,
* links, meta) is appended AFTER the project's index.html head. When both
* declare the same dependency (e.g. GSAP CDN), the composition's copy wins
* by last-write-wins script execution order — this is intentional so the
* composition can pin a specific version.
*/
export function buildSubCompositionHtml(
projectDir: string,
compPath: string,
runtimeUrl: string,
baseHref?: string,
rawOverride?: string,
): string | null {
const compFile = join(projectDir, compPath);
if (!existsSync(compFile)) return null;
// rawOverride lets the preview route thread the hf-id-stamped content in
// directly, so the build uses pinned ids even when the persist-to-disk write
// was skipped (read-only fs, concurrent-save TOCTOU guard).
const rawComp = rawOverride ?? readFileSync(compFile, "utf-8");
let compHeadContent = "";
let rewrittenContent: string;
let htmlAttrs = "";
let bodyAttrs = "";
const templateInner = extractTemplateInnerHtml(rawComp);
if (templateInner != null) {
const { document: contentDoc } = parseHTML(
`<!DOCTYPE html><html><head></head><body>${templateInner}</body></html>`,
);
rewriteRelativePaths(contentDoc, compPath);
fixDigitLeadingIdSelectors(contentDoc);
promoteTemplateCompositionId(rawComp, contentDoc.body);
rewrittenContent = contentDoc.body.innerHTML || templateInner;
} else if (isFullHtmlDocument(rawComp)) {
const parts = extractFullDocumentParts(rawComp, compPath);
compHeadContent = parts.headContent;
rewrittenContent = parts.bodyContent;
htmlAttrs = parts.htmlAttrs;
bodyAttrs = parts.bodyAttrs;
} else {
const { document: contentDoc } = parseHTML(
`<!DOCTYPE html><html><head></head><body>${rawComp}</body></html>`,
);
rewriteRelativePaths(contentDoc, compPath);
fixDigitLeadingIdSelectors(contentDoc);
rewrittenContent = contentDoc.body.innerHTML || rawComp;
}
// A composition file may ship a baked inline runtime (from a prior export:
// data-hyperframes-runtime / __hyperframeRuntime…). The studio injects its own
// preview runtime below, so strip the baked one from the body — otherwise it's
// double-loaded AND the baked inline copy can fail to parse inline (the
// "Unexpected token '<'" SyntaxError seen on comps with a baked runtime).
rewrittenContent = stripEmbeddedRuntimeScripts(rewrittenContent);
// The comp's root carries data-composition-id but (unlike inlined sub-comps,
// which inlineSubCompositions tags) no data-composition-file. Without it the
// studio can't resolve which file this comp's top-level elements live in and
// falls back to "index.html" — so the GSAP panel parses the project root (which
// may be a multi-timeline master) and wrongly reports "multiple timelines",
// disabling editing for a single-timeline comp. Tag the root with its own path.
rewrittenContent = tagRootCompositionFile(rewrittenContent, compPath);
// Use the project's index.html <head> to preserve all dependencies
const indexPath = join(projectDir, "index.html");
let headContent = "";
if (existsSync(indexPath)) {
const indexHtml = readFileSync(indexPath, "utf-8");
const headMatch = indexHtml.match(/<head[^>]*>([\s\S]*?)<\/head>/i);
headContent = headMatch?.[1] ?? "";
}
// Inject <base> for relative asset resolution (before other tags)
if (baseHref && !headContent.includes("<base")) {
headContent = `<base href="${baseHref}">\n${headContent}`;
}
// Append the sub-composition's own <head> content so its CSS, scripts,
// links, and meta tags are preserved. Placed after the project head so
// the composition's deps take precedence (last-write-wins for scripts).
if (compHeadContent) headContent += `\n${compHeadContent}`;
// Strip any baked runtime the borrowed index/comp <head> carried, for the same
// reason as the body above — done before injecting the preview runtime so the
// injected tag (added next) is never removed.
headContent = stripEmbeddedRuntimeScripts(headContent);
// Ensure runtime is present (might differ from the one in index.html)
if (
!headContent.includes("hyperframe.runtime") &&
!headContent.includes("hyperframes-preview-runtime")
) {
headContent += `\n<script data-hyperframes-preview-runtime="1" src="${runtimeUrl}"></script>`;
}
// Fallback: if no index.html head was found, add minimal deps
if (!headContent.includes("gsap")) {
headContent += `\n<script src="https://cdn.jsdelivr.net/npm/gsap@3/dist/gsap.min.js"></script>`;
}
const htmlOpen = htmlAttrs ? `<html ${htmlAttrs}>` : "<html>";
const bodyOpen = bodyAttrs ? `<body ${bodyAttrs}>` : "<body>";
return `<!DOCTYPE html>
${htmlOpen}
<head>
${headContent}
</head>
${bodyOpen}
<script>window.__timelines=window.__timelines||{};</script>
${rewrittenContent}
</body>
</html>`;
}
@@ -0,0 +1,11 @@
/**
* Shared shape check for composition-variable payloads (`?variables=` on the
* preview routes, `body.variables` on the render route) — one contract, one
* error string, so the routes can't drift.
*/
export const VARIABLES_PAYLOAD_ERROR = "variables must be a JSON object of {variableId: value}";
export function isVariablesPayload(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
@@ -0,0 +1,89 @@
import { spawn } from "node:child_process";
import { existsSync, writeFileSync, mkdirSync } from "node:fs";
import { join, resolve } from "node:path";
const SAMPLE_RATE = 4000;
const PEAK_COUNT = 4000;
const WAVEFORM_CACHE_VERSION = "v2";
export function buildWaveformCacheKey(assetPath: string): string {
return `${WAVEFORM_CACHE_VERSION}_${assetPath.replace(/[/\\]/g, "_")}.json`;
}
function computePeaks(floats: Float32Array, count: number): number[] {
const step = floats.length / count;
const peaks: number[] = [];
for (let i = 0; i < count; i++) {
const start = Math.floor(i * step);
const end = Math.min(Math.floor((i + 1) * step), floats.length);
let max = 0;
for (let j = start; j < end; j++) {
// fallow-ignore-next-line code-duplication
const abs = Math.abs(floats[j] ?? 0);
if (abs > max) max = abs;
}
peaks.push(max);
}
const maxPeak = Math.max(...peaks, 0.001);
return peaks.map((p) => p / maxPeak);
}
function ffmpegBinary(): string {
const configured = process.env.HYPERFRAMES_FFMPEG_PATH?.trim();
if (configured) return resolve(configured);
return "ffmpeg";
}
export function decodeAudioPeaks(audioPath: string): Promise<number[]> {
return new Promise((resolvePromise, reject) => {
const proc = spawn(
ffmpegBinary(),
[
"-i",
audioPath,
"-af",
"atrim=start_sample=1152",
"-f",
"f32le",
"-ac",
"1",
"-ar",
String(SAMPLE_RATE),
"-vn",
"pipe:1",
],
{ stdio: ["ignore", "pipe", "ignore"] },
);
const chunks: Buffer[] = [];
proc.stdout?.on("data", (chunk: Buffer) => chunks.push(chunk));
proc.on("close", (code) => {
if (code !== 0 && chunks.length === 0) {
reject(new Error(`ffmpeg exited with code ${code}`));
return;
}
const buf = Buffer.concat(chunks);
const numSamples = Math.floor(buf.length / 4);
if (numSamples === 0) {
reject(new Error("ffmpeg produced no audio samples"));
return;
}
const ab = buf.buffer.slice(buf.byteOffset, buf.byteOffset + numSamples * 4);
resolvePromise(computePeaks(new Float32Array(ab), PEAK_COUNT));
});
proc.on("error", reject);
});
}
export async function generateWaveformCache(projectDir: string, assetPath: string): Promise<void> {
const audioPath = join(projectDir, assetPath);
if (!existsSync(audioPath)) return;
const cacheDir = join(projectDir, ".waveform-cache");
const cachePath = join(cacheDir, buildWaveformCacheKey(assetPath));
if (existsSync(cachePath)) return;
const peaks = await decodeAudioPeaks(audioPath);
mkdirSync(cacheDir, { recursive: true });
writeFileSync(cachePath, JSON.stringify(peaks));
}
+31
View File
@@ -0,0 +1,31 @@
export { createStudioApi } from "./createStudioApi.js";
export { createProjectSignature } from "./helpers/projectSignature.js";
export type {
StudioApiAdapter,
ResolvedProject,
RenderJobState,
MediaProcessingJobState,
LintResult,
StudioSelectionResponse,
StudioSelectionSnapshot,
StudioSelectionTextField,
} from "./types.js";
export { isSafePath, walkDir } from "./helpers/safePath.js";
export { getMimeType, MIME_TYPES } from "./helpers/mime.js";
export { buildSubCompositionHtml } from "./helpers/subComposition.js";
export { getElementScreenshotClip, type ScreenshotClip } from "./helpers/screenshotClip.js";
export {
createBackgroundRemovalJob,
type BackgroundRemovalRender,
} from "./helpers/backgroundRemovalJob.js";
export {
STUDIO_MANUAL_EDITS_PATH,
createStudioManualEditsRenderBodyScript,
createStudioPositionSeekReapplyScript,
type StudioManualEditsRenderScriptOptions,
} from "./helpers/manualEditsRenderScript.js";
export {
STUDIO_MOTION_PATH,
createStudioMotionRenderBodyScript,
type StudioMotionRenderScriptOptions,
} from "./helpers/studioMotionRenderScript.js";
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+172
View File
@@ -0,0 +1,172 @@
import { closeSync, constants, fstatSync, openSync, readSync } from "node:fs";
import type { Hono } from "hono";
import {
collectFontFileEntries,
fontDirectories,
getSystemProfilerFamilies,
locateSystemFont,
SYSTEM_FONT_SIZE_LIMIT,
} from "@hyperframes/core/fonts/system-locator";
const MAX_FONT_RESULTS = 2000;
const GOOGLE_FONTS_METADATA_URL = "https://fonts.google.com/metadata/fonts";
const GOOGLE_FONTS_FETCH_TIMEOUT_MS = 3000;
let cachedFonts: string[] | null = null;
let cachedGoogleFonts: string[] | null = null;
const GOOGLE_FONT_FALLBACKS = [
"Inter",
"Roboto",
"Open Sans",
"Montserrat",
"Poppins",
"Lato",
"Oswald",
"Raleway",
"Nunito",
"Playfair Display",
"Merriweather",
"Source Sans 3",
"Source Serif 4",
"Source Code Pro",
"DM Sans",
"Space Grotesk",
"Space Mono",
"Bebas Neue",
"Outfit",
"JetBrains Mono",
];
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null;
}
function collectFontsFromDir(dir: string): string[] {
return collectFontFileEntries(dir).map((e) => e.family);
}
function listInstalledFontFamilies(): string[] {
if (cachedFonts) return cachedFonts;
const families = new Set<string>();
for (const family of getSystemProfilerFamilies()) {
families.add(family);
if (families.size >= MAX_FONT_RESULTS) break;
}
for (const dir of fontDirectories()) {
for (const family of collectFontsFromDir(dir)) {
families.add(family);
if (families.size >= MAX_FONT_RESULTS) break;
}
if (families.size >= MAX_FONT_RESULTS) break;
}
cachedFonts = Array.from(families).sort((a, b) => a.localeCompare(b));
return cachedFonts;
}
function parseGoogleFontMetadata(value: unknown): string[] {
if (!isRecord(value) || !Array.isArray(value.familyMetadataList)) return [];
const families: string[] = [];
for (const entry of value.familyMetadataList) {
if (!isRecord(entry) || typeof entry.family !== "string") continue;
families.push(entry.family);
}
return families;
}
function stripGoogleJsonGuard(raw: string): string {
const prefix = ")]}'";
if (!raw.startsWith(prefix)) return raw;
let index = prefix.length;
while (
index < raw.length &&
(raw[index] === " " ||
raw[index] === "\n" ||
raw[index] === "\r" ||
raw[index] === "\t" ||
raw[index] === "\f")
) {
index += 1;
}
return raw.slice(index);
}
async function listGoogleFontFamilies(): Promise<string[]> {
if (cachedGoogleFonts) return cachedGoogleFonts;
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), GOOGLE_FONTS_FETCH_TIMEOUT_MS);
try {
const response = await fetch(GOOGLE_FONTS_METADATA_URL, { signal: controller.signal });
if (!response.ok) {
cachedGoogleFonts = GOOGLE_FONT_FALLBACKS;
return cachedGoogleFonts;
}
const raw = await response.text();
const jsonText = stripGoogleJsonGuard(raw);
const families = parseGoogleFontMetadata(JSON.parse(jsonText));
cachedGoogleFonts = families.length > 0 ? families : GOOGLE_FONT_FALLBACKS;
} catch {
cachedGoogleFonts = GOOGLE_FONT_FALLBACKS;
} finally {
clearTimeout(timer);
}
return cachedGoogleFonts;
}
export function registerFontRoutes(api: Hono): void {
api.get("/fonts", (c) => c.json({ fonts: listInstalledFontFamilies() }));
api.get("/fonts/google", async (c) => c.json({ fonts: await listGoogleFontFamilies() }));
// fallow-ignore-next-line complexity
api.get("/fonts/file", (c) => {
const family = c.req.query("family");
if (!family) return c.json({ error: "family parameter required" }, 400);
const located = locateSystemFont(family);
if (!located) return c.json({ error: "font not found" }, 404);
let fd: number;
try {
fd = openSync(located.path, constants.O_RDONLY | constants.O_NOFOLLOW);
} catch {
return c.json({ error: "font file not accessible" }, 404);
}
try {
const stat = fstatSync(fd);
if (stat.size > SYSTEM_FONT_SIZE_LIMIT) {
return c.json({ error: "font file too large" }, 413);
}
const buffer = Buffer.alloc(stat.size);
readSync(fd, buffer, 0, stat.size, 0);
const mimeType =
located.format === "otf"
? "font/otf"
: located.format === "woff2"
? "font/woff2"
: located.format === "woff"
? "font/woff"
: located.format === "ttc"
? "font/collection"
: "font/ttf";
const fileName = `${family.replace(/[^a-zA-Z0-9 -]/g, "")}.${located.format}`;
return new Response(buffer, {
headers: {
"Content-Type": mimeType,
"Content-Disposition": `attachment; filename="${fileName}"`,
},
});
} catch {
return c.json({ error: "failed to read font file" }, 500);
} finally {
closeSync(fd);
}
});
}
@@ -0,0 +1,62 @@
import { describe, it, expect, beforeEach, afterEach } from "vitest";
import { mkdtempSync, rmSync, mkdirSync, writeFileSync } from "node:fs";
import { join } from "node:path";
import { tmpdir } from "node:os";
import { readGlobalAssets, toPublicAsset } from "./globalAssets";
describe("readGlobalAssets", () => {
let home: string;
beforeEach(() => {
home = mkdtempSync(join(tmpdir(), "mu-global-"));
});
afterEach(() => rmSync(home, { recursive: true, force: true }));
function writeManifest(lines: string[]) {
mkdirSync(join(home, ".media"), { recursive: true });
writeFileSync(join(home, ".media", "manifest.jsonl"), lines.join("\n"));
}
it("returns [] when there is no global manifest", () => {
expect(readGlobalAssets(home)).toEqual([]);
});
it("returns only reusable records", () => {
writeManifest([
JSON.stringify({ id: "bgm_001", type: "bgm", reusable: true, sha: "a" }),
JSON.stringify({ id: "tmp_001", type: "sfx", reusable: false, sha: "b" }),
]);
const assets = readGlobalAssets(home);
expect(assets.map((a) => a.id)).toEqual(["bgm_001"]);
});
it("skips malformed lines instead of throwing (torn write)", () => {
writeManifest([
JSON.stringify({ id: "bgm_001", reusable: true }),
"{ not valid json",
"",
JSON.stringify({ id: "img_001", reusable: true }),
]);
expect(readGlobalAssets(home).map((a) => a.id)).toEqual(["bgm_001", "img_001"]);
});
});
describe("toPublicAsset", () => {
it("drops the absolute cached_path before it reaches the browser (m13)", () => {
const pub = toPublicAsset({
id: "bgm_001",
type: "bgm",
description: "calm",
sha: "abc",
entity: "Acme",
cached_path: "/Users/someone/.media/bgm/bgm_001.mp3",
});
expect(pub).toEqual({
id: "bgm_001",
type: "bgm",
description: "calm",
sha: "abc",
entity: "Acme",
});
expect("cached_path" in pub).toBe(false);
});
});
@@ -0,0 +1,44 @@
import { existsSync, readFileSync } from "node:fs";
import { homedir } from "node:os";
import { join } from "node:path";
import type { Hono } from "hono";
// Non-project-scoped route: the media-use global asset cache (~/.media). Lets the
// Studio Asset tab show assets resolved in OTHER projects (cross-project reuse).
// Reads ONLY the global manifest — no path params, no arbitrary fs access.
export interface GlobalAssetRecord {
id?: string;
type?: string;
description?: string;
sha?: string;
cached_path?: string;
entity?: string;
}
/** Parse the global manifest (~/.media/manifest.jsonl) into reusable records. */
export function readGlobalAssets(home = homedir()): GlobalAssetRecord[] {
const manifestPath = join(home, ".media", "manifest.jsonl");
if (!existsSync(manifestPath)) return [];
const out: GlobalAssetRecord[] = [];
for (const line of readFileSync(manifestPath, "utf8").split("\n")) {
if (!line.trim()) continue;
try {
const rec = JSON.parse(line);
if (rec && rec.reusable) out.push(rec);
} catch {
// skip malformed lines — a torn write shouldn't 500 the panel
}
}
return out;
}
// Fields the Studio panel actually renders. Deliberately omits cached_path —
// an absolute ~/.media filesystem path has no business reaching the browser (m13).
export function toPublicAsset(r: GlobalAssetRecord): GlobalAssetRecord {
return { id: r.id, type: r.type, description: r.description, sha: r.sha, entity: r.entity };
}
export function registerGlobalAssetRoutes(api: Hono): void {
api.get("/assets/global", (c) => c.json({ assets: readGlobalAssets().map(toPublicAsset) }));
}
@@ -0,0 +1,61 @@
import { afterEach, describe, expect, it } from "vitest";
import { Hono } from "hono";
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { registerLintRoutes } from "./lint";
import type { StudioApiAdapter } from "../types";
const tempDirs: string[] = [];
afterEach(() => {
for (const dir of tempDirs.splice(0)) {
rmSync(dir, { recursive: true, force: true });
}
});
// Project layout for #1384: one real composition plus vendored example HTML
// inside a dot-directory that must not inflate the lint findings.
function createProjectDir(): string {
const projectDir = mkdtempSync(join(tmpdir(), "hf-lint-test-"));
tempDirs.push(projectDir);
writeFileSync(join(projectDir, "index.html"), "<html><body>real</body></html>");
mkdirSync(join(projectDir, ".hyperframes"));
writeFileSync(join(projectDir, ".hyperframes", "preset.html"), "<html><body>junk</body></html>");
return projectDir;
}
// Every linted file reports one finding, so the response reveals exactly
// which files were linted.
function createAdapter(projectDir: string): StudioApiAdapter {
return {
listProjects: () => [],
resolveProject: async (id: string) => ({ id, dir: projectDir }),
bundle: async () => null,
lint: async () => ({ findings: [{ severity: "warning", message: "finding" }] }),
runtimeUrl: "/api/runtime.js",
rendersDir: () => "/tmp/renders",
startRender: () => ({
id: "job-1",
status: "rendering",
progress: 0,
outputPath: "/tmp/out.mp4",
}),
};
}
describe("registerLintRoutes — dot-directory exclusion (#1384)", () => {
it("does not lint HTML inside dot-directories", async () => {
const projectDir = createProjectDir();
const app = new Hono();
registerLintRoutes(app, createAdapter(projectDir));
const response = await app.request("http://localhost/projects/demo/lint");
const payload = (await response.json()) as { findings?: Array<{ file?: string }> };
expect(response.status).toBe(200);
const lintedFiles = (payload.findings ?? []).map((f) => f.file);
expect(lintedFiles).toContain("index.html");
expect(lintedFiles).not.toContain(".hyperframes/preset.html");
});
});
+36
View File
@@ -0,0 +1,36 @@
import type { Hono } from "hono";
import { readFileSync } from "node:fs";
import { join } from "node:path";
import type { StudioApiAdapter } from "../types.js";
import { isInHiddenOrVendorDir, walkDir } from "../helpers/safePath.js";
export function registerLintRoutes(api: Hono, adapter: StudioApiAdapter): void {
api.get("/projects/:id/lint", async (c) => {
const project = await adapter.resolveProject(c.req.param("id"));
if (!project) return c.json({ error: "not found" }, 404);
try {
const htmlFiles = walkDir(project.dir).filter(
(f) => f.endsWith(".html") && !isInHiddenOrVendorDir(f),
);
const allFindings: Array<{
severity: string;
message: string;
file?: string;
fixHint?: string;
}> = [];
for (const file of htmlFiles) {
const content = readFileSync(join(project.dir, file), "utf-8");
const result = await adapter.lint(content, { filePath: file });
if (result?.findings) {
for (const f of result.findings) {
allFindings.push({ ...f, file });
}
}
}
return c.json({ findings: allFindings });
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
return c.json({ error: `Lint failed: ${msg}` }, 500);
}
});
}
@@ -0,0 +1,231 @@
// fallow-ignore-file code-duplication
import { afterEach, describe, expect, it, vi } from "vitest";
import { Hono } from "hono";
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { registerMediaRoutes } from "./media";
import type { MediaProcessingJobState, StudioApiAdapter } from "../types";
const tempProjectDirs: string[] = [];
afterEach(() => {
for (const dir of tempProjectDirs.splice(0)) {
rmSync(dir, { recursive: true, force: true });
}
});
function createAdapter(
startBackgroundRemoval?: StudioApiAdapter["startBackgroundRemoval"],
probeMediaMetadata?: NonNullable<Parameters<typeof registerMediaRoutes>[2]>["probeMediaMetadata"],
): {
app: Hono;
projectDir: string;
startBackgroundRemoval: ReturnType<typeof vi.fn>;
} {
const projectDir = mkdtempSync(join(tmpdir(), "hf-media-route-test-"));
tempProjectDirs.push(projectDir);
mkdirSync(join(projectDir, "assets"), { recursive: true });
writeFileSync(join(projectDir, "assets", "clip.mp4"), "video");
writeFileSync(join(projectDir, "assets", "photo.jpg"), "image");
const spy = vi.fn(startBackgroundRemoval);
const adapter: StudioApiAdapter = {
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",
}),
...(startBackgroundRemoval ? { startBackgroundRemoval: spy } : {}),
};
const app = new Hono();
registerMediaRoutes(app, adapter, probeMediaMetadata ? { probeMediaMetadata } : undefined);
return { app, projectDir, startBackgroundRemoval: spy };
}
function completeJob(opts: Parameters<NonNullable<StudioApiAdapter["startBackgroundRemoval"]>>[0]) {
return {
id: opts.jobId,
status: "complete",
progress: 100,
inputAssetPath: opts.inputAssetPath,
outputAssetPath: opts.outputAssetPath,
outputPath: opts.outputPath,
} satisfies MediaProcessingJobState;
}
describe("registerMediaRoutes", () => {
it("returns metadata for a project-local media asset", async () => {
const probe = vi.fn(() => ({
kind: "video" as const,
color: {
dynamicRange: "hdr" as const,
hdrTransfer: "hlg" as const,
label: "HDR HLG",
isHdr: true,
},
}));
const { app, projectDir } = createAdapter(undefined, probe);
const response = await app.request(
"http://localhost/projects/demo/media/metadata?path=assets%2Fclip.mp4",
);
const data = (await response.json()) as { metadata: { color: { label: string } } };
expect(response.status).toBe(200);
expect(data.metadata.color.label).toBe("HDR HLG");
expect(probe).toHaveBeenCalledWith(join(projectDir, "assets", "clip.mp4"));
});
it("rejects media metadata paths outside the project", async () => {
const { app } = createAdapter();
const response = await app.request(
"http://localhost/projects/demo/media/metadata?path=..%2Fsecret.mp4",
);
expect(response.status).toBe(403);
});
it("rejects null bytes in media metadata paths", async () => {
const { app } = createAdapter();
const response = await app.request(
"http://localhost/projects/demo/media/metadata?path=assets%00clip.mp4",
);
expect(response.status).toBe(403);
});
it("returns 501 when background removal is not available", async () => {
const { app } = createAdapter();
const response = await app.request("http://localhost/projects/demo/media/remove-background", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ inputPath: "assets/clip.mp4" }),
});
expect(response.status).toBe(501);
});
it("rejects remote input paths", async () => {
const { app, startBackgroundRemoval } = createAdapter(completeJob);
const response = await app.request("http://localhost/projects/demo/media/remove-background", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ inputPath: "https://example.com/clip.mp4" }),
});
expect(response.status).toBe(400);
expect(startBackgroundRemoval).not.toHaveBeenCalled();
});
it("rejects null bytes in background-removal paths", async () => {
const { app, startBackgroundRemoval } = createAdapter(completeJob);
const inputResponse = await app.request(
"http://localhost/projects/demo/media/remove-background",
{
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ inputPath: "assets/clip\0.mp4" }),
},
);
const outputResponse = await app.request(
"http://localhost/projects/demo/media/remove-background",
{
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ inputPath: "assets/clip.mp4", outputPath: "assets/out\0.webm" }),
},
);
expect(inputResponse.status).toBe(403);
expect(outputResponse.status).toBe(403);
expect(startBackgroundRemoval).not.toHaveBeenCalled();
});
it("starts a video cutout job with safe default output paths", async () => {
const { app, projectDir, startBackgroundRemoval } = createAdapter(completeJob);
const response = await app.request("http://localhost/projects/demo/media/remove-background", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ inputPath: "assets/clip.mp4", createBackgroundPlate: true }),
});
const data = (await response.json()) as {
jobId: string;
outputPath: string;
backgroundOutputPath: string;
};
expect(response.status).toBe(200);
expect(data.outputPath).toBe("assets/cutouts/clip-cutout.webm");
expect(data.backgroundOutputPath).toBe("assets/cutouts/clip-plate.webm");
expect(startBackgroundRemoval).toHaveBeenCalledWith(
expect.objectContaining({
project: { id: "demo", dir: projectDir },
inputAssetPath: "assets/clip.mp4",
outputAssetPath: "assets/cutouts/clip-cutout.webm",
backgroundOutputAssetPath: "assets/cutouts/clip-plate.webm",
quality: "balanced",
device: "auto",
jobId: data.jobId,
}),
);
});
it("normalizes query strings from local media paths", async () => {
const { app, startBackgroundRemoval } = createAdapter(completeJob);
const response = await app.request("http://localhost/projects/demo/media/remove-background", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ inputPath: "./assets/clip.mp4?v=123#frame" }),
});
expect(response.status).toBe(200);
expect(startBackgroundRemoval).toHaveBeenCalledWith(
expect.objectContaining({
inputAssetPath: "assets/clip.mp4",
outputAssetPath: "assets/cutouts/clip-cutout.webm",
}),
);
});
it("requires png output for image cutouts", async () => {
const { app, startBackgroundRemoval } = createAdapter(completeJob);
const response = await app.request("http://localhost/projects/demo/media/remove-background", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ inputPath: "assets/photo.jpg", outputPath: "assets/photo.webm" }),
});
expect(response.status).toBe(400);
expect(startBackgroundRemoval).not.toHaveBeenCalled();
});
it("keeps output paths inside the project", async () => {
const { app, startBackgroundRemoval } = createAdapter(completeJob);
const response = await app.request("http://localhost/projects/demo/media/remove-background", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ inputPath: "assets/clip.mp4", outputPath: "../escape.webm" }),
});
expect(response.status).toBe(403);
expect(startBackgroundRemoval).not.toHaveBeenCalled();
});
});
+255
View File
@@ -0,0 +1,255 @@
import type { Hono } from "hono";
import { streamSSE } from "hono/streaming";
import { existsSync, mkdirSync } from "node:fs";
import { basename, dirname, extname, join } from "node:path";
import type { MediaProcessingJobState, StudioApiAdapter } from "../types.js";
import { resolveWithinProject } from "../helpers/safePath.js";
import { probeMediaMetadata } from "../helpers/mediaMetadata.js";
const VIDEO_EXTENSIONS = new Set([".mp4", ".mov", ".webm", ".mkv", ".avi"]);
const IMAGE_EXTENSIONS = new Set([".jpg", ".jpeg", ".png", ".webp"]);
const VIDEO_OUTPUT_EXTENSIONS = new Set([".webm", ".mov"]);
const QUALITIES = new Set(["fast", "balanced", "best"]);
const DEVICES = new Set(["auto", "cpu", "coreml", "cuda"]);
type BackgroundRemovalQuality = "fast" | "balanced" | "best";
type BackgroundRemovalDevice = "auto" | "cpu" | "coreml" | "cuda";
interface BackgroundRemovalBody {
inputPath?: string;
outputPath?: string;
createBackgroundPlate?: boolean;
quality?: string;
device?: string;
}
type JobWithCreatedAt = MediaProcessingJobState & { createdAt: number };
type ProbeMediaMetadata = typeof probeMediaMetadata;
function isVideoPath(path: string): boolean {
return VIDEO_EXTENSIONS.has(extname(path).toLowerCase());
}
function isImagePath(path: string): boolean {
return IMAGE_EXTENSIONS.has(extname(path).toLowerCase());
}
function normalizeProjectAssetPath(path: string): string {
return path
.trim()
.replace(/^[.]\//, "")
.replace(/[?#].*$/, "");
}
function containsNullByte(path: string): boolean {
return path.includes("\0");
}
function slugFileBase(path: string): string {
const name = basename(path, extname(path))
.replace(/[^a-zA-Z0-9._-]+/g, "-")
.replace(/^-+|-+$/g, "");
return name || "media";
}
function uniqueAssetPath(projectDir: string, assetPath: string): string {
const ext = extname(assetPath);
const withoutExt = assetPath.slice(0, -ext.length);
let candidate = assetPath;
for (let index = 2; existsSync(join(projectDir, candidate)); index++) {
candidate = `${withoutExt}-${index}${ext}`;
}
return candidate;
}
function defaultOutputPath(projectDir: string, inputPath: string): string {
const ext = isImagePath(inputPath) ? ".png" : ".webm";
return uniqueAssetPath(projectDir, `assets/cutouts/${slugFileBase(inputPath)}-cutout${ext}`);
}
function defaultPlatePath(projectDir: string, inputPath: string): string {
return uniqueAssetPath(projectDir, `assets/cutouts/${slugFileBase(inputPath)}-plate.webm`);
}
function makeJobId(projectId: string, mediaJobs: Map<string, JobWithCreatedAt>): string {
const stamp = new Date()
.toISOString()
.replace(/[-:.TZ]/g, "")
.slice(0, 14);
const safeProject = projectId.replace(/[^a-zA-Z0-9_-]+/g, "-").replace(/^-+|-+$/g, "");
const base = `${safeProject || "project"}_remove-bg_${stamp}`;
if (!mediaJobs.has(base)) return base;
for (let index = 2; ; index++) {
const candidate = `${base}-${index}`;
if (!mediaJobs.has(candidate)) return candidate;
}
}
function normalizeQuality(value: string | undefined): BackgroundRemovalQuality {
return QUALITIES.has(value ?? "") ? (value as BackgroundRemovalQuality) : "balanced";
}
function normalizeDevice(value: string | undefined): BackgroundRemovalDevice {
return DEVICES.has(value ?? "") ? (value as BackgroundRemovalDevice) : "auto";
}
export function registerMediaRoutes(
api: Hono,
adapter: StudioApiAdapter,
options: { probeMediaMetadata?: ProbeMediaMetadata } = {},
): void {
const mediaJobs = new Map<string, JobWithCreatedAt>();
const TTL_MS = 300_000;
const readMediaMetadata = options.probeMediaMetadata ?? probeMediaMetadata;
function cleanupFinishedJobs(): void {
const now = Date.now();
for (const [id, job] of mediaJobs) {
if ((job.status === "complete" || job.status === "failed") && now - job.createdAt > TTL_MS) {
mediaJobs.delete(id);
}
}
}
api.get("/projects/:id/media/metadata", async (c) => {
const project = await adapter.resolveProject(c.req.param("id"));
if (!project) return c.json({ error: "not found" }, 404);
const assetPath = normalizeProjectAssetPath(c.req.query("path") ?? "");
if (!assetPath) return c.json({ error: "path required" }, 400);
if (containsNullByte(assetPath)) return c.json({ error: "forbidden" }, 403);
if (/^(?:https?:|data:|blob:)/i.test(assetPath)) {
return c.json({ error: "media metadata requires a project-local asset" }, 400);
}
const filePath = resolveWithinProject(project.dir, assetPath);
if (!filePath) return c.json({ error: "forbidden" }, 403);
if (!existsSync(filePath)) return c.json({ error: "media not found" }, 404);
return c.json({ path: assetPath, metadata: readMediaMetadata(filePath) });
});
api.post(
"/projects/:id/media/remove-background",
// fallow-ignore-next-line complexity
async (c) => {
cleanupFinishedJobs();
if (!adapter.startBackgroundRemoval) {
return c.json({ error: "background removal is not available in this Studio server" }, 501);
}
// fallow-ignore-next-line code-duplication
const project = await adapter.resolveProject(c.req.param("id"));
if (!project) return c.json({ error: "not found" }, 404);
const body = (await c.req.json().catch(() => ({}))) as BackgroundRemovalBody;
const inputAssetPath = body.inputPath ? normalizeProjectAssetPath(body.inputPath) : "";
if (!inputAssetPath) return c.json({ error: "inputPath required" }, 400);
if (containsNullByte(inputAssetPath)) return c.json({ error: "forbidden" }, 403);
if (/^(?:https?:|data:|blob:)/i.test(inputAssetPath)) {
return c.json({ error: "background removal requires a project-local media asset" }, 400);
}
const inputPath = resolveWithinProject(project.dir, inputAssetPath);
if (!inputPath) return c.json({ error: "forbidden" }, 403);
if (!existsSync(inputPath)) return c.json({ error: "input media not found" }, 404);
const inputIsVideo = isVideoPath(inputAssetPath);
const inputIsImage = isImagePath(inputAssetPath);
if (!inputIsVideo && !inputIsImage) {
return c.json({ error: "background removal supports video or image assets only" }, 400);
}
const requestedOutput = body.outputPath ? normalizeProjectAssetPath(body.outputPath) : "";
if (requestedOutput && containsNullByte(requestedOutput)) {
return c.json({ error: "forbidden" }, 403);
}
if (requestedOutput && !resolveWithinProject(project.dir, requestedOutput)) {
return c.json({ error: "forbidden" }, 403);
}
const outputAssetPath = requestedOutput
? uniqueAssetPath(project.dir, requestedOutput)
: defaultOutputPath(project.dir, inputAssetPath);
const outputPath = resolveWithinProject(project.dir, outputAssetPath);
if (!outputPath) return c.json({ error: "forbidden" }, 403);
if (inputIsVideo && !VIDEO_OUTPUT_EXTENSIONS.has(extname(outputAssetPath).toLowerCase())) {
return c.json({ error: "video background removal output must be .webm or .mov" }, 400);
}
if (inputIsImage && extname(outputAssetPath).toLowerCase() !== ".png") {
return c.json({ error: "image background removal output must be .png" }, 400);
}
let backgroundOutputAssetPath: string | undefined;
let backgroundOutputPath: string | undefined;
if (body.createBackgroundPlate) {
if (!inputIsVideo) {
return c.json({ error: "background plates are only supported for video inputs" }, 400);
}
backgroundOutputAssetPath = defaultPlatePath(project.dir, inputAssetPath);
backgroundOutputPath =
resolveWithinProject(project.dir, backgroundOutputAssetPath) ?? undefined;
if (!backgroundOutputPath) {
return c.json({ error: "forbidden" }, 403);
}
}
mkdirSync(dirname(outputPath), { recursive: true });
if (backgroundOutputPath) mkdirSync(dirname(backgroundOutputPath), { recursive: true });
const jobId = makeJobId(project.id, mediaJobs);
const state = adapter.startBackgroundRemoval({
project,
inputPath,
inputAssetPath,
outputPath,
outputAssetPath,
backgroundOutputPath,
backgroundOutputAssetPath,
quality: normalizeQuality(body.quality),
device: normalizeDevice(body.device),
jobId,
}) as JobWithCreatedAt;
state.createdAt = Date.now();
mediaJobs.set(jobId, state);
return c.json({
jobId,
status: state.status,
outputPath: outputAssetPath,
backgroundOutputPath: backgroundOutputAssetPath,
});
},
);
api.get("/media-jobs/:jobId/progress", (c) => {
cleanupFinishedJobs();
const { jobId } = c.req.param();
const job = mediaJobs.get(jobId);
if (!job) return c.json({ error: "not found" }, 404);
return streamSSE(c, async (stream) => {
while (true) {
const current = mediaJobs.get(jobId);
if (!current) break;
await stream.writeSSE({
event: "progress",
data: JSON.stringify({
id: current.id,
status: current.status,
progress: current.progress,
stage: current.stage,
outputPath: current.outputAssetPath,
backgroundOutputPath: current.backgroundOutputAssetPath,
error: current.error,
provider: current.provider,
framesProcessed: current.framesProcessed,
durationSeconds: current.durationSeconds,
avgMsPerFrame: current.avgMsPerFrame,
}),
});
if (current.status === "complete" || current.status === "failed") break;
await stream.sleep(500);
}
});
});
}
@@ -0,0 +1,639 @@
// fallow-ignore-file code-duplication
import { afterEach, describe, expect, it, vi } from "vitest";
import { Hono } from "hono";
import { mkdirSync, 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("injects Studio GSAP motion manifest runtime into project preview", async () => {
const projectDir = createProjectDir();
writeFileSync(
join(projectDir, "index.html"),
"<!doctype html><html><head></head><body><div id='card'></div></body></html>",
);
const manifestDir = join(projectDir, ".hyperframes");
mkdirSync(manifestDir, { recursive: true });
writeFileSync(
join(manifestDir, "studio-motion.json"),
`{"version":1,"motions":[{"kind":"gsap-motion","target":{"sourceFile":"index.html","id":"card"},"start":0,"duration":1,"ease":"power2.out","from":{"y":32},"to":{"y":0}}]}`,
);
const app = new Hono();
registerPreviewRoutes(app, createAdapter(projectDir));
const response = await app.request("http://localhost/projects/demo/preview");
const html = await response.text();
expect(response.status).toBe(200);
expect(html).toContain("__hfStudioMotionApply");
expect(html).toContain("studio-motion");
expect(html).toContain("gsap@3.15.0/dist/gsap.min.js");
});
it("injects the GSAP CustomEase plugin when Studio motion uses a custom ease", async () => {
const projectDir = createProjectDir();
writeFileSync(
join(projectDir, "index.html"),
"<!doctype html><html><head></head><body><div id='card'></div></body></html>",
);
const manifestDir = join(projectDir, ".hyperframes");
mkdirSync(manifestDir, { recursive: true });
writeFileSync(
join(manifestDir, "studio-motion.json"),
`{"version":1,"motions":[{"kind":"gsap-motion","target":{"sourceFile":"index.html","id":"card"},"start":0,"duration":1,"ease":"studio-card-ease","customEase":{"id":"studio-card-ease","data":"M0,0 C0.18,0.9 0.32,1 1,1"},"from":{"y":32},"to":{"y":0}}]}`,
);
const app = new Hono();
registerPreviewRoutes(app, createAdapter(projectDir));
const response = await app.request("http://localhost/projects/demo/preview");
const html = await response.text();
expect(response.status).toBe(200);
expect(html).toContain("gsap@3.15.0/dist/gsap.min.js");
expect(html).toContain("gsap@3.15.0/dist/CustomEase.min.js");
expect(html.indexOf("gsap.min.js")).toBeLessThan(html.indexOf("CustomEase.min.js"));
expect(html.indexOf("CustomEase.min.js")).toBeLessThan(html.indexOf("__hfStudioMotionApply"));
});
it("injects the GSAP MotionPathPlugin when the composition uses a motionPath", async () => {
const projectDir = createProjectDir();
writeFileSync(
join(projectDir, "index.html"),
`<!doctype html><html><head>
<script src="https://cdn.jsdelivr.net/npm/gsap@3/dist/gsap.min.js"></script>
</head><body><div id="card" class="clip"></div>
<script>
const tl = gsap.timeline({ paused: true });
tl.to("#card", { motionPath: { path: [{ x: 0, y: 0 }, { x: 100, y: 50 }] }, duration: 1 }, 0);
window.__timelines = { index: tl };
</script>
</body></html>`,
);
const app = new Hono();
registerPreviewRoutes(app, createAdapter(projectDir));
const response = await app.request("http://localhost/projects/demo/preview");
const html = await response.text();
expect(response.status).toBe(200);
// Plugin version is derived from the composition's own gsap (gsap@3 here).
expect(html).toContain("gsap@3/dist/MotionPathPlugin.min.js");
// Plugin must load AFTER the core gsap script so it can register onto it.
expect(html.indexOf("gsap.min.js")).toBeLessThan(html.indexOf("MotionPathPlugin.min.js"));
});
it("does NOT inject MotionPathPlugin when the composition has no motionPath", async () => {
const projectDir = createProjectDir();
writeFileSync(
join(projectDir, "index.html"),
`<!doctype html><html><head>
<script src="https://cdn.jsdelivr.net/npm/gsap@3/dist/gsap.min.js"></script>
</head><body><div id="card" class="clip"></div>
<script>
const tl = gsap.timeline({ paused: true });
tl.to("#card", { x: 100, duration: 1 }, 0);
window.__timelines = { index: tl };
</script>
</body></html>`,
);
const app = new Hono();
registerPreviewRoutes(app, createAdapter(projectDir));
const response = await app.request("http://localhost/projects/demo/preview");
const html = await response.text();
expect(response.status).toBe(200);
expect(html).not.toContain("MotionPathPlugin.min.js");
});
it("injects Studio GSAP motion runtime into sub-composition previews with the active source path", async () => {
const projectDir = createProjectDir();
mkdirSync(join(projectDir, "compositions"), { recursive: true });
writeFileSync(
join(projectDir, "index.html"),
"<!doctype html><html><head></head><body></body></html>",
);
writeFileSync(
join(projectDir, "compositions/scene.html"),
`<template><section id="card" data-composition-id="scene" data-width="1280" data-height="720"></section></template>`,
);
const manifestDir = join(projectDir, ".hyperframes");
mkdirSync(manifestDir, { recursive: true });
writeFileSync(
join(manifestDir, "studio-motion.json"),
`{"version":1,"motions":[{"kind":"gsap-motion","target":{"sourceFile":"compositions/scene.html","id":"card"},"start":0,"duration":1,"ease":"power2.out","from":{"y":32},"to":{"y":0}}]}`,
);
const app = new Hono();
registerPreviewRoutes(app, createAdapter(projectDir));
const response = await app.request(
"http://localhost/projects/demo/preview/comp/compositions/scene.html",
);
const html = await response.text();
expect(response.status).toBe(200);
expect(html).toContain("__hfStudioMotionApply");
expect(html).toContain("compositions/scene.html");
});
it("applies adapter preview transforms to bundled root previews", async () => {
const projectDir = createProjectDir();
const app = new Hono();
registerPreviewRoutes(
app,
createAdapter(projectDir, {
bundle: async () => "<!doctype html><html><head></head><body>Preview</body></html>",
transformPreviewHtml: async ({ html, activeCompositionPath }) =>
html.replace(
"</head>",
`<meta name="preview-path" content="${activeCompositionPath}"></head>`,
),
}),
);
const response = await app.request("http://localhost/projects/demo/preview");
const html = await response.text();
expect(response.status).toBe(200);
expect(html).toContain('<meta name="preview-path" content="index.html">');
});
it("applies adapter preview transforms to sub-composition previews", async () => {
const projectDir = createProjectDir();
mkdirSync(join(projectDir, "compositions"), { recursive: true });
writeFileSync(
join(projectDir, "compositions/scene.html"),
`<template><section data-composition-id="scene" data-width="1280" data-height="720"></section></template>`,
);
const app = new Hono();
registerPreviewRoutes(
app,
createAdapter(projectDir, {
transformPreviewHtml: async ({ html, activeCompositionPath }) =>
html.replace(
"</head>",
`<meta name="preview-path" content="${activeCompositionPath}"></head>`,
),
}),
);
const response = await app.request(
"http://localhost/projects/demo/preview/comp/compositions/scene.html",
);
const html = await response.text();
expect(response.status).toBe(200);
expect(html).toContain('<meta name="preview-path" content="compositions/scene.html">');
});
it("applies adapter preview transforms when bundle() returns null (reads from disk)", async () => {
const projectDir = createProjectDir();
const app = new Hono();
registerPreviewRoutes(
app,
createAdapter(projectDir, {
// bundle: async () => null <-- default; falls back to reading index.html from disk
transformPreviewHtml: async ({ html, activeCompositionPath }) =>
html.replace(
"</head>",
`<meta name="preview-path" content="${activeCompositionPath}"></head>`,
),
}),
);
const response = await app.request("http://localhost/projects/demo/preview");
const html = await response.text();
expect(response.status).toBe(200);
expect(html).toContain('<meta name="preview-path" content="index.html">');
});
it("applies adapter preview transforms in the bundle error fallback path", async () => {
const projectDir = createProjectDir();
const app = new Hono();
registerPreviewRoutes(
app,
createAdapter(projectDir, {
bundle: async () => {
throw new Error("bundler unavailable");
},
transformPreviewHtml: async ({ html, activeCompositionPath }) =>
html.replace(
"</head>",
`<meta name="preview-path" content="${activeCompositionPath}"></head>`,
),
}),
);
const response = await app.request("http://localhost/projects/demo/preview");
const html = await response.text();
expect(response.status).toBe(200);
expect(html).toContain('<meta name="preview-path" content="index.html">');
});
it("falls back to original HTML when transformPreviewHtml throws", async () => {
const projectDir = createProjectDir();
const app = new Hono();
registerPreviewRoutes(
app,
createAdapter(projectDir, {
bundle: async () => "<!doctype html><html><head></head><body>Preview</body></html>",
transformPreviewHtml: async () => {
throw new Error("transform failed");
},
}),
);
const response = await app.request("http://localhost/projects/demo/preview");
const html = await response.text();
expect(response.status).toBe(200);
expect(html).toContain("Preview");
});
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("updates the preview signature after Studio manifest edits", async () => {
const projectDir = createProjectDir();
const manifestDir = join(projectDir, ".hyperframes");
mkdirSync(manifestDir, { recursive: true });
const motionFile = join(manifestDir, "studio-motion.json");
writeFileSync(motionFile, `{"version":1,"motions":[]}`);
const firstSignature = await getPreviewSignature(projectDir);
writeFileSync(
motionFile,
`{"version":1,"motions":[{"kind":"gsap-motion","target":{"sourceFile":"index.html","id":"card"},"start":0,"duration":1,"from":{"y":32},"to":{"y":0}}]}`,
);
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}$/);
});
});
describe("hf-id surfacing in preview route", () => {
it("serves HTML with data-hf-id on body elements (R7 write-back)", async () => {
const projectDir = createProjectDir();
writeFileSync(
join(projectDir, "index.html"),
`<!doctype html><html><head></head><body><div class="card"><p>text</p></div></body></html>`,
);
const app = new Hono();
registerPreviewRoutes(app, createAdapter(projectDir));
const res = await app.request("http://localhost/projects/demo/preview");
expect(res.status).toBe(200);
const html = await res.text();
const ids = html.match(/data-hf-id="hf-[a-z0-9]{4}"/g);
// div and p both tagged
expect(ids?.length).toBeGreaterThanOrEqual(2);
});
it("writes data-hf-id back to disk on first serve", async () => {
const { readFileSync } = await import("node:fs");
const projectDir = createProjectDir();
const indexPath = join(projectDir, "index.html");
writeFileSync(
indexPath,
`<!doctype html><html><head></head><body><div>hello</div></body></html>`,
);
const app = new Hono();
registerPreviewRoutes(app, createAdapter(projectDir));
await app.request("http://localhost/projects/demo/preview");
const onDisk = readFileSync(indexPath, "utf-8");
expect(onDisk).toContain('data-hf-id="hf-');
});
it("bundle returning untagged HTML gets same ids as disk — content-hash is stable across mint contexts", async () => {
// Regression guard for bundle-vs-disk id divergence: if the bundler reads from
// a pre-write cache snapshot (no ids), ensureHfIds mints ids on the bundle output.
// Because ids are content-keyed (FNV1a of element content), the minted ids must
// equal the ids persisted to disk for the same source HTML — otherwise a
// drag-to-edit patch keyed by a wire-time id would fail to apply on disk.
const { readFileSync } = await import("node:fs");
const projectDir = createProjectDir();
const indexPath = join(projectDir, "index.html");
const sourceHtml = `<!doctype html><html><head></head><body><div class="card"><p>hello</p></div></body></html>`;
writeFileSync(indexPath, sourceHtml);
const app = new Hono();
// Bundler returns the same untagged source HTML (simulates stale cache read)
registerPreviewRoutes(app, createAdapter(projectDir, { bundle: async () => sourceHtml }));
const res = await app.request("http://localhost/projects/demo/preview");
expect(res.status).toBe(200);
const servedHtml = await res.text();
const diskHtml = readFileSync(indexPath, "utf-8");
// Extract ids from served HTML and disk HTML
const servedIds = [...servedHtml.matchAll(/data-hf-id="(hf-[a-z0-9]+)"/g)].map((m) => m[1]);
const diskIds = [...diskHtml.matchAll(/data-hf-id="(hf-[a-z0-9]+)"/g)].map((m) => m[1]);
expect(servedIds.length).toBeGreaterThanOrEqual(2);
expect(servedIds).toEqual(diskIds);
});
it("sub-comp route writes data-hf-id back to disk on first serve", async () => {
const { readFileSync } = await import("node:fs");
const projectDir = createProjectDir();
const compPath = join(projectDir, "scene.html");
writeFileSync(compPath, `<div class="clip" data-start="0" data-end="3">Hi</div>`);
const app = new Hono();
registerPreviewRoutes(app, createAdapter(projectDir));
const res = await app.request("http://localhost/projects/demo/preview/comp/scene.html");
expect(res.status).toBe(200);
expect(readFileSync(compPath, "utf-8")).toContain('data-hf-id="hf-');
});
it("sub-comp served ids equal disk ids even when relative asset paths are rewritten", async () => {
// Regression guard for the setTiming element_not_found divergence class:
// the sub-comp route rewrites relative src/href BEFORE minting, so an
// element with a relative asset path got a preview-only id that existed
// nowhere in the raw file. Persisting ids from the RAW file first pins
// them; the rewrite then carries the pinned ids through unchanged.
const { readFileSync } = await import("node:fs");
const projectDir = createProjectDir();
const compPath = join(projectDir, "scene.html");
writeFileSync(
compPath,
`<div class="clip" data-start="0" data-end="3"><img src="assets/logo.png"></div>`,
);
const app = new Hono();
registerPreviewRoutes(app, createAdapter(projectDir));
const res = await app.request("http://localhost/projects/demo/preview/comp/scene.html");
expect(res.status).toBe(200);
const servedIds = [...(await res.text()).matchAll(/data-hf-id="(hf-[a-z0-9]+)"/g)]
.map((m) => m[1])
.sort();
const diskIds = [...readFileSync(compPath, "utf-8").matchAll(/data-hf-id="(hf-[a-z0-9]+)"/g)]
.map((m) => m[1])
.sort();
expect(servedIds.length).toBeGreaterThanOrEqual(2); // div + img
expect(servedIds).toEqual(diskIds);
});
it("template-based sub-comp: inner ids persist to disk and match the served (unwrapped) ids", async () => {
const { readFileSync } = await import("node:fs");
const projectDir = createProjectDir();
const compPath = join(projectDir, "test-minimal.html");
writeFileSync(
compPath,
`<template data-composition-id="test-minimal"><div class="clip" data-start="0" data-end="3">Hello</div><div class="clip" data-start="3" data-end="6">World</div></template>`,
);
const app = new Hono();
registerPreviewRoutes(app, createAdapter(projectDir));
const res = await app.request("http://localhost/projects/demo/preview/comp/test-minimal.html");
expect(res.status).toBe(200);
const servedIds = [...(await res.text()).matchAll(/data-hf-id="(hf-[a-z0-9]+)"/g)].map(
(m) => m[1],
);
const diskIds = [
...readFileSync(compPath, "utf-8").matchAll(/data-hf-id="(hf-[a-z0-9]+)"/g),
].map((m) => m[1]);
expect(diskIds.length).toBe(2);
for (const id of diskIds) expect(servedIds).toContain(id);
});
it("sub-comp route does NOT rewrite a non-HTML file on disk (GET must not corrupt assets)", async () => {
const { readFileSync } = await import("node:fs");
const projectDir = createProjectDir();
const svgPath = join(projectDir, "logo.svg");
const svgBytes = `<?xml version="1.0"?><svg xmlns="http://www.w3.org/2000/svg"><path d="M0 0"/></svg>`;
writeFileSync(svgPath, svgBytes);
const app = new Hono();
registerPreviewRoutes(app, createAdapter(projectDir));
await app.request("http://localhost/projects/demo/preview/comp/logo.svg");
// Whatever the route serves, a GET must leave the file byte-identical.
expect(readFileSync(svgPath, "utf-8")).toBe(svgBytes);
});
it("sub-comp route does NOT persist ids inside a plain <template> (runtime clone-source)", async () => {
const { readFileSync } = await import("node:fs");
const projectDir = createProjectDir();
const compPath = join(projectDir, "clones.html");
writeFileSync(
compPath,
`<div class="clip" data-start="0" data-end="3">stage</div><template><li class="row">item</li></template>`,
);
const app = new Hono();
registerPreviewRoutes(app, createAdapter(projectDir));
const res = await app.request("http://localhost/projects/demo/preview/comp/clones.html");
expect(res.status).toBe(200);
const disk = readFileSync(compPath, "utf-8");
expect(disk).toMatch(/<div[^>]*data-hf-id/); // stage div stamped
expect(disk).not.toMatch(/<li[^>]*data-hf-id/); // clone-source untouched
});
});
describe("preview ?variables= injection", () => {
it("injects window.__hfVariables before composition scripts in the main preview", async () => {
const projectDir = createProjectDir();
const app = new Hono();
registerPreviewRoutes(app, createAdapter(projectDir));
const values = { title: "Custom", count: 5 };
const res = await app.request(
`http://localhost/projects/demo/preview?variables=${encodeURIComponent(JSON.stringify(values))}`,
);
expect(res.status).toBe(200);
const html = await res.text();
expect(html).toContain("data-hf-preview-variables");
expect(html).toContain('window.__hfVariables={"title":"Custom","count":5}');
// Injected in <head> — before the runtime script and all body scripts.
expect(html.indexOf("data-hf-preview-variables")).toBeLessThan(html.indexOf("</head>"));
});
it("escapes </script> breakout attempts in string values", async () => {
const projectDir = createProjectDir();
const app = new Hono();
registerPreviewRoutes(app, createAdapter(projectDir));
const values = { title: "</script><script>alert(1)</script>" };
const res = await app.request(
`http://localhost/projects/demo/preview?variables=${encodeURIComponent(JSON.stringify(values))}`,
);
const html = await res.text();
const injected = /<script data-hf-preview-variables>([\s\S]*?)<\/script>/.exec(html);
expect(injected?.[1]).toContain("\\u003c/script>");
expect(injected?.[1]).not.toContain("</script>");
});
it("returns 400 for invalid JSON and non-object payloads", async () => {
const projectDir = createProjectDir();
const app = new Hono();
registerPreviewRoutes(app, createAdapter(projectDir));
const bad = await app.request("http://localhost/projects/demo/preview?variables=%7Bnope");
expect(bad.status).toBe(400);
const arr = await app.request(
`http://localhost/projects/demo/preview?variables=${encodeURIComponent("[1,2]")}`,
);
expect(arr.status).toBe(400);
});
it("salts the ETag so cached previews revalidate when values change", async () => {
const projectDir = createProjectDir();
const app = new Hono();
registerPreviewRoutes(app, createAdapter(projectDir));
const plain = await app.request("http://localhost/projects/demo/preview");
const withVars = await app.request(
`http://localhost/projects/demo/preview?variables=${encodeURIComponent('{"a":1}')}`,
);
const otherVars = await app.request(
`http://localhost/projects/demo/preview?variables=${encodeURIComponent('{"a":2}')}`,
);
const etags = [plain, withVars, otherVars].map((r) => r.headers.get("ETag"));
expect(new Set(etags).size).toBe(3);
});
it("injects variables into sub-composition previews", async () => {
const projectDir = createProjectDir();
writeFileSync(
join(projectDir, "scene.html"),
"<!doctype html><html><head></head><body><div class='clip' data-start='0' data-duration='2'>Scene</div></body></html>",
);
const app = new Hono();
registerPreviewRoutes(app, createAdapter(projectDir));
const res = await app.request(
`http://localhost/projects/demo/preview/comp/scene.html?variables=${encodeURIComponent('{"accent":"#f00"}')}`,
);
expect(res.status).toBe(200);
const html = await res.text();
expect(html).toContain('window.__hfVariables={"accent":"#f00"}');
});
});
describe("sub-composition preview attribute integrity", () => {
it("preserves quote-bearing html attributes (data-composition-variables JSON)", async () => {
const projectDir = createProjectDir();
const decls = JSON.stringify([
{ id: "title", type: "string", label: "Title", default: "Hello" },
]);
writeFileSync(
join(projectDir, "card.html"),
`<!doctype html><html data-composition-variables='${decls}'><head></head><body><div class="clip" data-start="0" data-duration="2">x</div></body></html>`,
);
const app = new Hono();
registerPreviewRoutes(app, createAdapter(projectDir));
const res = await app.request("http://localhost/projects/demo/preview/comp/card.html");
expect(res.status).toBe(200);
const html = await res.text();
const attr = /data-composition-variables="([^"]*)"/.exec(html)?.[1] ?? "";
// Entities decode back to the exact declared JSON — a lost/shredded
// attribute here silently breaks getVariables() on the comp route.
const decoded = attr.replace(/&quot;/g, '"').replace(/&amp;/g, "&");
expect(JSON.parse(decoded)).toEqual(JSON.parse(decls));
});
});
@@ -0,0 +1,536 @@
import type { Hono } from "hono";
import { existsSync, readFileSync, statSync } from "node:fs";
import { join } from "node:path";
import { createHash } from "node:crypto";
import { injectScriptsIntoHtml, stripEmbeddedRuntimeScripts } from "@hyperframes/core/compiler";
import type { StudioApiAdapter } from "../types.js";
import { resolveWithinProject } from "../helpers/safePath.js";
import { getMimeType } from "../helpers/mime.js";
import { buildSubCompositionHtml } from "../helpers/subComposition.js";
import { createProjectSignature } from "../helpers/projectSignature.js";
import {
createStudioMotionRenderBodyScript,
STUDIO_MOTION_PATH,
} from "../helpers/studioMotionRenderScript.js";
import { ensureHfIds } from "@hyperframes/parsers/hf-ids";
import { persistHfIdsIfNeeded, stampFileHfIds } from "../helpers/hfIdPersist.js";
import { isVariablesPayload, VARIABLES_PAYLOAD_ERROR } from "../helpers/variablesPayload.js";
const PROJECT_SIGNATURE_META = "hyperframes-project-signature";
const GSAP_CDN_VERSION = "3.15.0";
const GSAP_CDN_SCRIPT = `<script src="https://cdn.jsdelivr.net/npm/gsap@${GSAP_CDN_VERSION}/dist/gsap.min.js"></script>`;
const GSAP_CUSTOM_EASE_CDN_SCRIPT = `<script src="https://cdn.jsdelivr.net/npm/gsap@${GSAP_CDN_VERSION}/dist/CustomEase.min.js"></script>`;
const GSAP_MOTION_PATH_CDN_SCRIPT = `<script src="https://cdn.jsdelivr.net/npm/gsap@${GSAP_CDN_VERSION}/dist/MotionPathPlugin.min.js"></script>`;
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}`;
}
function readStudioMotionManifestContent(projectDir: string): string {
const manifestPath = join(projectDir, STUDIO_MOTION_PATH);
if (!existsSync(manifestPath)) return "";
try {
return readFileSync(manifestPath, "utf-8");
} catch {
return "";
}
}
function parseStudioMotionManifestContent(content: string): {
hasMotion: boolean;
hasCustomEase: boolean;
} {
try {
const parsed = JSON.parse(content) as { motions?: Array<{ customEase?: unknown }> };
const motions = Array.isArray(parsed.motions) ? parsed.motions : [];
return {
hasMotion: motions.length > 0,
hasCustomEase: motions.some((motion) => Boolean(motion?.customEase)),
};
} catch {
return { hasMotion: false, hasCustomEase: false };
}
}
function injectScriptTagIntoHead(html: string, scriptTag: string): string {
if (html.includes("</head>")) return html.replace("</head>", `${scriptTag}\n</head>`);
return `${scriptTag}\n${html}`;
}
function htmlHasGsap(html: string): boolean {
// Only match GSAP references outside <template> elements — scripts inside
// templates are inert when cloned and don't make GSAP globally available.
const outsideTemplates = html.replace(/<template\b[^>]*>[\s\S]*?<\/template>/gi, "");
return (
/<script\b[^>]*src=["'][^"']*gsap/i.test(outsideTemplates) ||
/\/\*\s*inlined:.*gsap/i.test(outsideTemplates) ||
/\b(GreenSock|_gsScope)\b/.test(outsideTemplates) ||
/\bgsap\.(config|defaults|registerPlugin|version)\b/.test(outsideTemplates)
);
}
function htmlHasCustomEase(html: string): boolean {
return (
/<script\b[^>]*src=["'][^"']*CustomEase/i.test(html) ||
/\bwindow\.CustomEase\b/.test(html) ||
/\bCustomEase\s*=\s*/.test(html)
);
}
// A composition that drives motion via GSAP's `motionPath` (e.g. a studio-created
// motion path written into the single-source timeline) needs MotionPathPlugin
// registered before the timeline first renders — otherwise the initial seek
// throws "Invalid property motionPath ... Missing plugin?". Detect it anywhere in
// the bundle (the plugin registers globally, so sub-composition usage counts too).
function htmlUsesMotionPath(html: string): boolean {
return /motionPath\s*[:{]/.test(html);
}
function htmlHasMotionPathPlugin(html: string): boolean {
return (
/<script\b[^>]*src=["'][^"']*MotionPathPlugin/i.test(html) ||
/\bwindow\.MotionPathPlugin\b/.test(html) ||
/\bMotionPathPlugin\s*=\s*/.test(html)
);
}
function injectMotionPathPluginIfNeeded(html: string): string {
if (!htmlUsesMotionPath(html) || htmlHasMotionPathPlugin(html)) return html;
// The plugin registers onto an already-loaded gsap, so it must come AFTER the
// core gsap script — which often lives at body-end, not <head>. Insert it
// directly after the gsap script tag; only fall back to <head> if none is found
// (e.g. gsap is inlined).
const gsapScript = /<script\b[^>]*\bsrc=["'][^"']*\/gsap(\.min)?\.js["'][^>]*>\s*<\/script>/i;
const match = html.match(gsapScript);
if (match) {
// Match the plugin version to the composition's own gsap so the plugin
// registers cleanly (a minor-version skew triggers a GSAP compatibility warning).
const version = match[0].match(/gsap@([\d.]+)/)?.[1] ?? GSAP_CDN_VERSION;
const pluginTag = `<script src="https://cdn.jsdelivr.net/npm/gsap@${version}/dist/MotionPathPlugin.min.js"></script>`;
const end = html.indexOf(match[0]) + match[0].length;
return html.slice(0, end) + "\n" + pluginTag + html.slice(end);
}
return injectScriptTagIntoHead(html, GSAP_MOTION_PATH_CDN_SCRIPT);
}
function injectStudioMotionDependencies(html: string, manifestContent: string): string {
const manifest = parseStudioMotionManifestContent(manifestContent);
if (!manifest.hasMotion) return html;
let next = html;
if (!htmlHasGsap(next)) next = injectScriptTagIntoHead(next, GSAP_CDN_SCRIPT);
if (manifest.hasCustomEase && !htmlHasCustomEase(next)) {
next = injectScriptTagIntoHead(next, GSAP_CUSTOM_EASE_CDN_SCRIPT);
}
return next;
}
function injectStudioMotionScript(
html: string,
projectDir: string,
activeCompositionPath: string,
): string {
const manifestContent = readStudioMotionManifestContent(projectDir);
const script = createStudioMotionRenderBodyScript(manifestContent, {
activeCompositionPath,
});
if (!script) return html;
return injectScriptsIntoHtml(
injectStudioMotionDependencies(html, manifestContent),
[],
[script],
false,
);
}
const GSAP_CDN_FALLBACK_SCRIPT = `<script data-hf-gsap-fallback>
(function(){
var cdnBase="https://cdn.jsdelivr.net/npm/gsap@${GSAP_CDN_VERSION}/dist/";
var loaded={};
function loadFallback(file){
if(loaded[file])return loaded[file];
return loaded[file]=new Promise(function(ok,fail){
var s=document.createElement("script");
s.src=cdnBase+file;s.onload=ok;s.onerror=fail;
document.head.appendChild(s);
});
}
document.addEventListener("error",function(e){
var t=e.target;
if(!t||t.tagName!=="SCRIPT"||!t.src)return;
var m=t.src.match(/gsap[^/]*\\/dist\\/(.+\\.js)/);
if(m)loadFallback(m[1]);
},true);
})();
</script>`;
function injectGsapCdnFallback(html: string): string {
if (html.includes("data-hf-gsap-fallback")) return html;
if (html.includes("<head>")) return html.replace("<head>", "<head>" + GSAP_CDN_FALLBACK_SCRIPT);
return GSAP_CDN_FALLBACK_SCRIPT + html;
}
/**
* Inject preview variable overrides: `?variables=<json>` becomes
* `window.__hfVariables` set before any composition script runs — the exact
* global the engine sets via evaluateOnNewDocument at render time
* (engine/src/services/frameCapture.ts), so preview-with-values cannot
* diverge from render behavior. The runtime's getVariables() merges these
* overrides over the declared defaults.
*/
function injectPreviewVariables(html: string, values: Record<string, unknown>): string {
// <-escape prevents a string value containing "</script>" from
// breaking out of the injected tag.
const json = JSON.stringify(values).replace(/</g, "\\u003c");
const tag = `<script data-hf-preview-variables>window.__hfVariables=${json};</script>`;
// Insert as early as possible without ever landing before the doctype —
// content before <!doctype> flips the document into quirks mode, so the
// fallback chain is <head…> → <html…> → after the doctype → prepend.
for (const pattern of [/<head[^>]*>/i, /<html[^>]*>/i, /^\s*<!doctype[^>]*>/i]) {
const match = pattern.exec(html);
if (match) {
const at = match.index + match[0].length;
return html.slice(0, at) + tag + html.slice(at);
}
}
return tag + html;
}
/**
* Parse the `?variables=` query param. Absent/empty → null (no injection).
* Invalid JSON or a non-object payload is a caller error — surfaced as a 400
* by the routes rather than silently previewing with defaults.
*/
function parsePreviewVariablesParam(
raw: string | undefined,
): { ok: true; values: Record<string, unknown> | null } | { ok: false; error: string } {
if (raw === undefined || raw === "") return { ok: true, values: null };
let parsed: unknown;
try {
parsed = JSON.parse(raw);
} catch {
return { ok: false, error: "variables must be valid JSON" };
}
if (!isVariablesPayload(parsed)) {
return { ok: false, error: VARIABLES_PAYLOAD_ERROR };
}
return { ok: true, values: parsed };
}
/** ETag salt so cached previews revalidate when the variable values change. */
function variablesEtagSalt(raw: string | undefined): string {
if (!raw) return "";
return `:vars:${createHash("sha1").update(raw).digest("hex").slice(0, 12)}`;
}
/**
* Read + parse `?variables=` for a preview route. `error` present → the
* route should 400; otherwise `values` is the override object (or null when
* the param is absent) and `raw` feeds the ETag salt.
*/
function previewVariablesFromRequest(
rawVariables: string | undefined,
):
| { error: string }
| { error?: undefined; raw: string | undefined; values: Record<string, unknown> | null } {
const parse = parsePreviewVariablesParam(rawVariables);
if (!parse.ok) return { error: parse.error };
return { raw: rawVariables, values: parse.values };
}
function injectStudioPreviewAugmentations(
html: string,
adapter: StudioApiAdapter,
projectDir: string,
activeCompositionPath: string,
): string {
return injectStudioMotionScript(
injectMotionPathPluginIfNeeded(
injectGsapCdnFallback(
injectProjectSignature(html, resolveProjectSignature(adapter, projectDir)),
),
),
projectDir,
activeCompositionPath,
);
}
async function transformPreviewHtml(
html: string,
adapter: StudioApiAdapter,
project: { id: string; dir: string; title?: string; sessionId?: string },
activeCompositionPath: string,
): Promise<string> {
if (!adapter.transformPreviewHtml) return html;
try {
return await adapter.transformPreviewHtml({
html,
project,
activeCompositionPath,
});
} catch (err) {
console.warn("[Studio] preview transform failed, using original HTML:", err);
return html;
}
}
function resolveProjectMainHtml(
projectDir: string,
projectId: string,
): { html: string; compositionPath: string } | null {
const indexPath = join(projectDir, "index.html");
if (existsSync(indexPath)) {
return { html: readFileSync(indexPath, "utf-8"), compositionPath: "index.html" };
}
const blockHtmlPath = join(projectDir, `${projectId}.html`);
if (existsSync(blockHtmlPath)) {
return { html: readFileSync(blockHtmlPath, "utf-8"), compositionPath: `${projectId}.html` };
}
return null;
}
export function registerPreviewRoutes(api: Hono, adapter: StudioApiAdapter): void {
const previewCacheHeaders = (etag: string) => ({
"Cache-Control": "private, no-cache",
ETag: etag,
});
// Bundled composition preview
// fallow-ignore-next-line complexity
api.get("/projects/:id/preview", async (c) => {
const project = await adapter.resolveProject(c.req.param("id"));
if (!project) return c.json({ error: "not found" }, 404);
// fallow-ignore-next-line code-duplication
const vars = previewVariablesFromRequest(c.req.query("variables"));
if (vars.error !== undefined) return c.json({ error: vars.error }, 400);
const previewVariables = vars.values;
const signature = resolveProjectSignature(adapter, project.dir);
const etag = `"preview:${signature}${variablesEtagSalt(vars.raw)}"`;
const ifNoneMatch = c.req.header("If-None-Match");
if (ifNoneMatch === etag) {
return new Response(null, { status: 304, headers: previewCacheHeaders(etag) });
}
// Normalize + persist data-hf-id to disk before bundle reads it. Idempotent.
const diskMain = resolveProjectMainHtml(project.dir, project.id);
const normalizedDisk = diskMain
? persistHfIdsIfNeeded(join(project.dir, diskMain.compositionPath), diskMain.html)
: null;
try {
let bundled = await adapter.bundle(project.dir);
let mainCompositionPath = "index.html";
if (!bundled) {
if (!diskMain) return c.text("not found", 404);
// Disk HTML may carry a baked inline runtime from a prior export; strip
// it so the preview runtime injected below isn't double-loaded (the
// bundled path already strips via htmlBundler). Idempotent if absent.
bundled = stripEmbeddedRuntimeScripts(normalizedDisk ?? diskMain.html);
mainCompositionPath = diskMain.compositionPath;
}
// Inject runtime if not already present (check URL pattern and bundler attribute)
if (
!bundled.includes("hyperframe.runtime") &&
!bundled.includes("hyperframes-preview-runtime")
) {
const runtimeTag = `<script src="${adapter.runtimeUrl}"></script>`;
bundled = bundled.includes("</body>")
? bundled.replace("</body>", `${runtimeTag}\n</body>`)
: bundled + `\n${runtimeTag}`;
}
// Inject <base> for relative asset resolution
const baseHref = `/api/projects/${project.id}/preview/`;
if (!bundled.includes("<base")) {
bundled = bundled.replace(/<head>/i, `<head><base href="${baseHref}">`);
}
// ensureHfIds runs after transformPreviewHtml in case the adapter injected
// new elements. On the no-bundle path bundled=normalizedDisk (already tagged)
// so this is idempotent. On the bundled path the bundler may return untagged
// HTML (stale cache); because ids are content-keyed the minted ids will match
// the ids already written to disk by persistHfIdsIfNeeded above.
bundled = injectStudioPreviewAugmentations(
ensureHfIds(await transformPreviewHtml(bundled, adapter, project, mainCompositionPath)),
adapter,
project.dir,
mainCompositionPath,
);
if (previewVariables) bundled = injectPreviewVariables(bundled, previewVariables);
return c.html(bundled, 200, previewCacheHeaders(etag));
} catch {
// Re-read disk on bundle failure so we serve the latest file content,
// not the pre-request snapshot that may have been saved over.
const fallback = resolveProjectMainHtml(project.dir, project.id);
if (fallback) {
const fallbackHtml = persistHfIdsIfNeeded(
join(project.dir, fallback.compositionPath),
fallback.html,
);
let fallbackAugmented = injectStudioPreviewAugmentations(
await transformPreviewHtml(fallbackHtml, adapter, project, fallback.compositionPath),
adapter,
project.dir,
fallback.compositionPath,
);
if (previewVariables) {
fallbackAugmented = injectPreviewVariables(fallbackAugmented, previewVariables);
}
return c.html(fallbackAugmented, 200, previewCacheHeaders(etag));
}
return c.text("not found", 404);
}
});
/**
* Pin hf-ids to the RAW sub-comp file before the build pipeline mutates
* attributes (rewriteRelativePaths etc.) — minting is content-keyed over
* attrs, so stamping only AFTER the rewrite mints preview-only ids that
* exist nowhere in the source. Pinned ids ride through the rewrite
* unchanged, keeping the served DOM, the disk file, and the studio SDK
* session in one id space. Mirrors the main-preview route's
* persistHfIdsIfNeeded call.
*
* Gated to composition files: the wildcard route serves any project path,
* and stamping a non-HTML file (SVG, etc.) would corrupt it on disk.
*
* Returns the stamped content to thread into the build (so served ids match
* the mint even when the disk write is skipped — read-only fs), undefined
* for non-HTML paths, or null when the file vanished after the caller's
* stat. stampFileHfIds does its validation, read, and write through one
* file descriptor, so there is no check/read/write path gap to race.
*/
function pinSubCompHfIds(compFile: string, compPath: string): string | undefined | null {
if (!/\.html?$/i.test(compPath)) return undefined;
return stampFileHfIds(compFile);
}
// Sub-composition preview
// fallow-ignore-next-line complexity
api.get("/projects/:id/preview/comp/*", async (c) => {
const project = await adapter.resolveProject(c.req.param("id"));
if (!project) return c.json({ error: "not found" }, 404);
// fallow-ignore-next-line code-duplication
const vars = previewVariablesFromRequest(c.req.query("variables"));
if (vars.error !== undefined) return c.json({ error: vars.error }, 400);
const previewVariables = vars.values;
const signature = resolveProjectSignature(adapter, project.dir);
const compPath = decodeURIComponent(
c.req.path.replace(`/projects/${project.id}/preview/comp/`, "").split("?")[0] ?? "",
);
const compFile = resolveWithinProject(project.dir, compPath);
if (!compFile || !existsSync(compFile) || !statSync(compFile).isFile()) {
return c.text("not found", 404);
}
// "v2" salts the etag for the hf-id-pinning change below: a client holding
// a pre-pin cached response (preview-only ids, unstamped disk file) must
// not revalidate to a 304 that skips the pin.
const etag = `"comp:v2:${compPath}:${signature}${variablesEtagSalt(vars.raw)}"`;
const ifNoneMatch = c.req.header("If-None-Match");
if (ifNoneMatch === etag) {
return new Response(null, { status: 304, headers: previewCacheHeaders(etag) });
}
const stamped = pinSubCompHfIds(compFile, compPath);
if (stamped === null) return c.text("not found", 404); // file removed between stat and read
const baseHref = `/api/projects/${project.id}/preview/`;
let html = buildSubCompositionHtml(
project.dir,
compPath,
adapter.runtimeUrl,
baseHref,
stamped,
);
if (!html) return c.text("not found", 404);
html = ensureHfIds(await transformPreviewHtml(html, adapter, project, compPath));
html = injectStudioPreviewAugmentations(html, adapter, project.dir, compPath);
if (previewVariables) html = injectPreviewVariables(html, previewVariables);
return c.html(html, 200, previewCacheHeaders(etag));
});
// Static asset serving (with range request support for audio/video seeking)
// fallow-ignore-next-line complexity
api.get("/projects/:id/preview/*", async (c) => {
const project = await adapter.resolveProject(c.req.param("id"));
if (!project) return c.json({ error: "not found" }, 404);
const subPath = decodeURIComponent(
c.req.path.replace(`/projects/${project.id}/preview/`, "").split("?")[0] ?? "",
);
const file = resolveWithinProject(project.dir, subPath);
if (!file) {
return c.text("not found", 404);
}
const stat = existsSync(file) ? statSync(file) : null;
if (!stat?.isFile()) {
return c.text("not found", 404);
}
const contentType = getMimeType(subPath);
const isText = /\.(html|css|js|json|svg|txt|md|cube)$/i.test(subPath);
const etag = `"${stat.mtimeMs.toString(36)}-${stat.size.toString(36)}"`;
const cacheHeaders: Record<string, string> = isText
? { "Cache-Control": "no-store" }
: { "Cache-Control": "private, max-age=3600, must-revalidate", ETag: etag };
if (!isText) {
const ifNoneMatch = c.req.header("If-None-Match");
if (ifNoneMatch === etag) {
return new Response(null, { status: 304, headers: cacheHeaders });
}
}
const buffer: Buffer = isText
? Buffer.from(readFileSync(file, "utf-8"), "utf-8")
: readFileSync(file);
const totalSize = buffer.length;
// Support byte-range requests so browsers can seek audio/video elements.
const rangeHeader = c.req.header("Range");
if (rangeHeader) {
const match = /bytes=(\d+)-(\d*)/.exec(rangeHeader);
if (match) {
const start = parseInt(match[1]!, 10);
const end = match[2] ? parseInt(match[2], 10) : totalSize - 1;
const safeEnd = Math.min(end, totalSize - 1);
const chunkSize = safeEnd - start + 1;
return new Response(new Uint8Array(buffer.slice(start, safeEnd + 1)), {
status: 206,
headers: {
...cacheHeaders,
"Content-Type": contentType,
"Content-Range": `bytes ${start}-${safeEnd}/${totalSize}`,
"Accept-Ranges": "bytes",
"Content-Length": String(chunkSize),
},
});
}
}
return new Response(new Uint8Array(buffer), {
headers: {
...cacheHeaders,
"Content-Type": contentType,
"Accept-Ranges": "bytes",
"Content-Length": String(totalSize),
},
});
});
}
@@ -0,0 +1,91 @@
import { afterEach, describe, expect, it } from "vitest";
import { Hono } from "hono";
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { registerProjectRoutes } from "./projects";
import type { StudioApiAdapter } from "../types";
const tempDirs: string[] = [];
afterEach(() => {
for (const dir of tempDirs.splice(0)) {
rmSync(dir, { recursive: true, force: true });
}
});
const COMPOSITION_HTML = '<html><body><div data-composition-id="main"></div></body></html>';
// Project layout for #1384: real compositions at the root and under
// compositions/, plus dot-directory content that exercises discovery gating
// and the file tree's backup-only hiding (#1366):
// - .cache/examples/ a vendored dot-dir. walkDir does NOT special-case
// it, so it stays listed in the file tree, but it is
// gated out of composition discovery (isInHiddenOrVendorDir).
// - .hyperframes/examples/ vendored under Studio's dir — also listed in the
// file tree, also gated out of discovery.
// - .hyperframes/backup/ Studio's internal snapshots — the only thing hidden
// from the file tree (walkDir's shouldIgnoreDir).
function createProjectDir(): string {
const projectDir = mkdtempSync(join(tmpdir(), "hf-projects-test-"));
tempDirs.push(projectDir);
writeFileSync(join(projectDir, "index.html"), COMPOSITION_HTML);
mkdirSync(join(projectDir, "compositions"));
writeFileSync(join(projectDir, "compositions", "scene.html"), COMPOSITION_HTML);
mkdirSync(join(projectDir, ".cache", "examples"), { recursive: true });
writeFileSync(join(projectDir, ".cache", "examples", "preset.html"), COMPOSITION_HTML);
mkdirSync(join(projectDir, ".hyperframes", "examples"), { recursive: true });
writeFileSync(join(projectDir, ".hyperframes", "examples", "preset.html"), COMPOSITION_HTML);
mkdirSync(join(projectDir, ".hyperframes", "backup"), { recursive: true });
writeFileSync(join(projectDir, ".hyperframes", "backup", "snapshot.html"), COMPOSITION_HTML);
return projectDir;
}
function createAdapter(projectDir: string): 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",
}),
};
}
describe("registerProjectRoutes — composition discovery (#1384)", () => {
it("excludes HTML inside dot-directories from compositions", async () => {
const projectDir = createProjectDir();
const app = new Hono();
registerProjectRoutes(app, createAdapter(projectDir));
const response = await app.request("http://localhost/projects/demo");
const payload = (await response.json()) as { compositions?: string[] };
expect(response.status).toBe(200);
expect(payload.compositions).toContain("index.html");
expect(payload.compositions).toContain("compositions/scene.html");
expect(payload.compositions).not.toContain(".cache/examples/preset.html");
expect(payload.compositions).not.toContain(".hyperframes/examples/preset.html");
});
it("lists vendored dot-directory files in the file tree but hides Studio backups", async () => {
const projectDir = createProjectDir();
const app = new Hono();
registerProjectRoutes(app, createAdapter(projectDir));
const response = await app.request("http://localhost/projects/demo");
const payload = (await response.json()) as { files?: string[] };
// Vendored dot-dirs stay browsable — discovery is gated, the file tree is not.
expect(payload.files).toContain(".cache/examples/preset.html");
expect(payload.files).toContain(".hyperframes/examples/preset.html");
// Only Studio's own backup snapshots are hidden from the tree (#1366).
expect(payload.files).not.toContain(".hyperframes/backup/snapshot.html");
});
});
@@ -0,0 +1,50 @@
import { readFile } from "node:fs/promises";
import { join } from "node:path";
import type { Hono } from "hono";
import type { StudioApiAdapter } from "../types.js";
import { isInHiddenOrVendorDir, walkDir } from "../helpers/safePath.js";
const COMPOSITION_ID_RE = /data-composition-id\s*=/;
async function filterCompositionFiles(projectDir: string, files: string[]): Promise<string[]> {
const htmlFiles = files.filter((f) => f.endsWith(".html") && !isInHiddenOrVendorDir(f));
const checks = await Promise.all(
htmlFiles.map(async (f) => {
try {
const content = await readFile(join(projectDir, f), "utf-8");
return COMPOSITION_ID_RE.test(content);
} catch {
return false;
}
}),
);
return htmlFiles.filter((_, i) => checks[i]);
}
export function registerProjectRoutes(api: Hono, adapter: StudioApiAdapter): void {
// List all projects
api.get("/projects", async (c) => {
const projects = await adapter.listProjects();
return c.json({ projects });
});
// Resolve session to project (multi-project mode)
api.get("/resolve-session/:sessionId", async (c) => {
if (!adapter.resolveSession) {
return c.json({ error: "not available" }, 404);
}
const { sessionId } = c.req.param();
const result = await adapter.resolveSession(sessionId);
if (!result) return c.json({ error: "Session not found" }, 404);
return c.json(result);
});
// Project file tree
api.get("/projects/:id", async (c) => {
const project = await adapter.resolveProject(c.req.param("id"));
if (!project) return c.json({ error: "not found" }, 404);
const files = walkDir(project.dir);
const compositions = await filterCompositionFiles(project.dir, files);
return c.json({ id: project.id, dir: project.dir, title: project.title, files, compositions });
});
}
@@ -0,0 +1,34 @@
import type { Hono } from "hono";
import type { StudioApiAdapter } from "../types.js";
export function registerRegistryRoutes(api: Hono, adapter: StudioApiAdapter): void {
api.get("/registry/blocks", async (c) => {
if (!adapter.listRegistryCatalog) {
return c.json({ error: "Registry not available" }, 501);
}
const items = await adapter.listRegistryCatalog();
return c.json(items);
});
// fallow-ignore-next-line complexity
api.post("/projects/:id/registry/install", async (c) => {
if (!adapter.installRegistryBlock) {
return c.json({ error: "Registry install not available" }, 501);
}
const project = await adapter.resolveProject(c.req.param("id"));
if (!project) return c.json({ error: "Project not found" }, 404);
const body = await c.req.json<{ blockName?: string }>().catch(() => null);
if (!body?.blockName) {
return c.json({ error: "blockName is required" }, 400);
}
try {
const result = await adapter.installRegistryBlock({ project, blockName: body.blockName });
return c.json(result);
} catch (err) {
const message = err instanceof Error ? err.message : "Install failed";
return c.json({ error: message }, 500);
}
});
}
@@ -0,0 +1,621 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { Hono } from "hono";
import { mkdtempSync, mkdirSync, writeFileSync, symlinkSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { VALID_CANVAS_RESOLUTIONS } from "@hyperframes/parsers";
import { registerRenderRoutes } from "./render";
import type { StudioApiAdapter } from "../types";
function createAdapter(
startRenderSpy: ReturnType<typeof vi.fn>,
rendersDir = mkdtempSync(join(tmpdir(), "hf-render-test-")),
): { adapter: StudioApiAdapter; rendersDir: string } {
const adapter: StudioApiAdapter = {
listProjects: () => [],
// Use a real, existing dir: isSafePath() canonicalizes the project dir with
// realpath and fails closed if it doesn't exist (real projects always do).
resolveProject: async (id: string) => ({ id, dir: tmpdir() }),
bundle: async () => null,
lint: async () => ({ findings: [] }),
runtimeUrl: "/api/runtime.js",
rendersDir: () => rendersDir,
startRender: (opts) => {
startRenderSpy(opts);
return {
id: opts.jobId,
status: "rendering",
progress: 0,
outputPath: opts.outputPath,
};
},
};
return { adapter, rendersDir };
}
function buildApp(spy: ReturnType<typeof vi.fn>): { app: Hono; cleanup: () => void } {
const { adapter, rendersDir } = createAdapter(spy);
const app = new Hono();
registerRenderRoutes(app, adapter);
return { app, cleanup: () => rmSync(rendersDir, { recursive: true, force: true }) };
}
describe("POST /projects/:id/render — outputResolution forwarding", () => {
it("forwards a valid resolution preset to the adapter", async () => {
const spy = vi.fn();
const { app, cleanup } = buildApp(spy);
try {
const res = await app.request("http://localhost/projects/demo/render", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({
fps: 30,
quality: "high",
format: "mp4",
resolution: "landscape-4k",
}),
});
expect(res.status).toBe(200);
expect(spy).toHaveBeenCalledOnce();
const opts = spy.mock.calls[0][0];
expect(opts.outputResolution).toBe("landscape-4k");
} finally {
cleanup();
}
});
it("omits outputResolution when the request does not specify one", async () => {
const spy = vi.fn();
const { app, cleanup } = buildApp(spy);
try {
const res = await app.request("http://localhost/projects/demo/render", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ fps: 30, quality: "standard", format: "mp4" }),
});
expect(res.status).toBe(200);
const opts = spy.mock.calls[0][0];
expect(opts.outputResolution).toBeUndefined();
} finally {
cleanup();
}
});
it("drops an invalid resolution string (defense-in-depth, not a 400)", async () => {
// The route is intentionally lenient on unknown enum values — the producer
// is the source of truth for validation and emits a clear error message.
// We just want to make sure garbage doesn't propagate as if it were valid.
const spy = vi.fn();
const { app, cleanup } = buildApp(spy);
try {
const res = await app.request("http://localhost/projects/demo/render", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ fps: 30, quality: "standard", format: "mp4", resolution: "8k" }),
});
expect(res.status).toBe(200);
const opts = spy.mock.calls[0][0];
expect(opts.outputResolution).toBeUndefined();
} finally {
cleanup();
}
});
it("accepts each canonical preset value", async () => {
for (const preset of VALID_CANVAS_RESOLUTIONS) {
const spy = vi.fn();
const { app, cleanup } = buildApp(spy);
try {
await app.request("http://localhost/projects/demo/render", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ fps: 30, quality: "standard", format: "mp4", resolution: preset }),
});
expect(spy.mock.calls[0][0].outputResolution).toBe(preset);
} finally {
cleanup();
}
}
});
});
describe("POST /projects/:id/render — composition forwarding", () => {
it("forwards a valid composition path to the adapter", async () => {
const spy = vi.fn();
const { app, cleanup } = buildApp(spy);
try {
const res = await app.request("http://localhost/projects/demo/render", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({
fps: 30,
quality: "standard",
format: "mp4",
composition: "compositions/intro.html",
}),
});
expect(res.status).toBe(200);
expect(spy).toHaveBeenCalledOnce();
expect(spy.mock.calls[0][0].composition).toBe("compositions/intro.html");
} finally {
cleanup();
}
});
it("omits composition when not specified", async () => {
const spy = vi.fn();
const { app, cleanup } = buildApp(spy);
try {
const res = await app.request("http://localhost/projects/demo/render", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ fps: 30, quality: "standard", format: "mp4" }),
});
expect(res.status).toBe(200);
expect(spy.mock.calls[0][0].composition).toBeUndefined();
} finally {
cleanup();
}
});
it("omits composition when empty string", async () => {
const spy = vi.fn();
const { app, cleanup } = buildApp(spy);
try {
const res = await app.request("http://localhost/projects/demo/render", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ fps: 30, quality: "standard", format: "mp4", composition: "" }),
});
expect(res.status).toBe(200);
expect(spy.mock.calls[0][0].composition).toBeUndefined();
} finally {
cleanup();
}
});
it("rejects path-traversal attempts with 400", async () => {
const spy = vi.fn();
const { app, cleanup } = buildApp(spy);
try {
const res = await app.request("http://localhost/projects/demo/render", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({
fps: 30,
quality: "standard",
format: "mp4",
composition: "../../../etc/passwd",
}),
});
expect(res.status).toBe(400);
expect(spy).not.toHaveBeenCalled();
} finally {
cleanup();
}
});
});
describe("POST /projects/:id/render — fps wire format", () => {
// The fps fraction-syntax feature accepts JSON `number` (integer fps) and
// JSON `string` (ffmpeg-style rational) on the wire, normalizing both to
// the structured Fps form before invoking the adapter.
it("forwards integer fps as { num, den: 1 }", async () => {
const spy = vi.fn();
const { app, cleanup } = buildApp(spy);
try {
await app.request("http://localhost/projects/demo/render", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ fps: 60, quality: "standard", format: "mp4" }),
});
expect(spy.mock.calls[0][0].fps).toEqual({ num: 60, den: 1 });
} finally {
cleanup();
}
});
it("parses '30000/1001' string body as exact NTSC", async () => {
const spy = vi.fn();
const { app, cleanup } = buildApp(spy);
try {
await app.request("http://localhost/projects/demo/render", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ fps: "30000/1001", quality: "standard", format: "mp4" }),
});
expect(spy.mock.calls[0][0].fps).toEqual({ num: 30000, den: 1001 });
} finally {
cleanup();
}
});
it("falls back to 30/1 for malformed fps values", async () => {
// Matches the lenient handling of `quality` and `resolution` in the same
// route — the producer surfaces a clearer downstream error if the value
// is genuinely unusable.
const spy = vi.fn();
const { app, cleanup } = buildApp(spy);
try {
await app.request("http://localhost/projects/demo/render", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ fps: "abc", quality: "standard", format: "mp4" }),
});
expect(spy.mock.calls[0][0].fps).toEqual({ num: 30, den: 1 });
} finally {
cleanup();
}
});
it("falls back to 30/1 when fps is omitted", async () => {
const spy = vi.fn();
const { app, cleanup } = buildApp(spy);
try {
await app.request("http://localhost/projects/demo/render", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ quality: "standard", format: "mp4" }),
});
expect(spy.mock.calls[0][0].fps).toEqual({ num: 30, den: 1 });
} finally {
cleanup();
}
});
});
describe("POST /projects/:id/render — composition path safety", () => {
const tmpDirs: string[] = [];
function buildAppWithProjectDir(spy: ReturnType<typeof vi.fn>): {
app: Hono;
projectDir: string;
} {
const projectDir = mkdtempSync(join(tmpdir(), "hf-render-proj-"));
const rendersDir = mkdtempSync(join(tmpdir(), "hf-render-out-"));
tmpDirs.push(projectDir, rendersDir);
const adapter: StudioApiAdapter = {
listProjects: () => [],
resolveProject: async (id: string) => ({ id, dir: projectDir }),
bundle: async () => null,
lint: async () => ({ findings: [] }),
runtimeUrl: "/api/runtime.js",
rendersDir: () => rendersDir,
startRender: (opts) => {
spy(opts);
return { id: opts.jobId, status: "rendering", progress: 0, outputPath: opts.outputPath };
},
};
const app = new Hono();
registerRenderRoutes(app, adapter);
return { app, projectDir };
}
afterEach(() => {
for (const d of tmpDirs) rmSync(d, { recursive: true, force: true });
tmpDirs.length = 0;
});
async function postComposition(app: Hono, composition: string): Promise<Response> {
return app.request("http://localhost/projects/demo/render", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ fps: 30, quality: "standard", format: "mp4", composition }),
});
}
// Mirror the repo convention (preview.test.ts): skip symlink cases on
// non-symlink-privileged Windows runners rather than crash the suite.
function tryCreateSymlink(target: string, path: string, type: "dir" | "file"): boolean {
try {
symlinkSync(target, path, type);
return true;
} catch {
return false;
}
}
it("accepts a composition path inside the project directory", async () => {
const spy = vi.fn();
const { app } = buildAppWithProjectDir(spy);
const res = await postComposition(app, "scenes/intro.html");
expect(res.status).toBe(200);
expect(spy).toHaveBeenCalledOnce();
});
it("rejects a `..` traversal in the composition path", async () => {
const spy = vi.fn();
const { app } = buildAppWithProjectDir(spy);
const res = await postComposition(app, "../../etc/passwd");
expect(res.status).toBe(400);
expect(spy).not.toHaveBeenCalled();
});
it("rejects a composition reached through an in-project symlink pointing outside the project", async () => {
const spy = vi.fn();
const { app, projectDir } = buildAppWithProjectDir(spy);
const external = mkdtempSync(join(tmpdir(), "hf-render-external-"));
tmpDirs.push(external);
writeFileSync(join(external, "secret.html"), "<html></html>");
if (!tryCreateSymlink(external, join(projectDir, "link"), "dir")) return;
const res = await postComposition(app, "link/secret.html");
expect(res.status).toBe(400);
expect(spy).not.toHaveBeenCalled();
});
it("allows a composition reached through an in-project symlink that stays inside the project", async () => {
const spy = vi.fn();
const { app, projectDir } = buildAppWithProjectDir(spy);
mkdirSync(join(projectDir, "real"));
writeFileSync(join(projectDir, "real", "scene.html"), "<html></html>");
if (!tryCreateSymlink(join(projectDir, "real"), join(projectDir, "alias"), "dir")) return;
const res = await postComposition(app, "alias/scene.html");
expect(res.status).toBe(200);
expect(spy).toHaveBeenCalledOnce();
});
});
describe("GET /projects/:id/renders/file/* — path safety", () => {
const tmpDirs: string[] = [];
function buildApp(): { app: Hono; rendersDir: string } {
const rendersDir = mkdtempSync(join(tmpdir(), "hf-renders-out-"));
tmpDirs.push(rendersDir);
const adapter: StudioApiAdapter = {
listProjects: () => [],
resolveProject: async (id: string) => ({ id, dir: tmpdir() }),
bundle: async () => null,
lint: async () => ({ findings: [] }),
runtimeUrl: "/api/runtime.js",
rendersDir: () => rendersDir,
startRender: (opts) => ({
id: opts.jobId,
status: "rendering",
progress: 0,
outputPath: opts.outputPath,
}),
};
const app = new Hono();
registerRenderRoutes(app, adapter);
return { app, rendersDir };
}
// Mirror the repo convention (preview.test.ts / composition tests above):
// skip symlink cases on non-symlink-privileged Windows runners.
function tryCreateSymlink(target: string, path: string, type: "dir" | "file"): boolean {
try {
symlinkSync(target, path, type);
return true;
} catch {
return false;
}
}
afterEach(() => {
for (const d of tmpDirs) rmSync(d, { recursive: true, force: true });
tmpDirs.length = 0;
});
it("serves a render file that lives inside rendersDir", async () => {
const { app, rendersDir } = buildApp();
writeFileSync(join(rendersDir, "demo.mp4"), "render-bytes");
const res = await app.request("http://localhost/projects/demo/renders/file/demo.mp4");
expect(res.status).toBe(200);
expect(await res.text()).toBe("render-bytes");
});
it("rejects a file reached through a symlink inside rendersDir pointing outside it", async () => {
const { app, rendersDir } = buildApp();
// A bare join()+readFileSync followed the symlink and leaked the target;
// the resolveWithinProject chokepoint canonicalizes with realpath first.
const external = mkdtempSync(join(tmpdir(), "hf-renders-external-"));
tmpDirs.push(external);
writeFileSync(join(external, "secret.txt"), "TOP-SECRET");
if (!tryCreateSymlink(join(external, "secret.txt"), join(rendersDir, "leak.txt"), "file"))
return;
const res = await app.request("http://localhost/projects/demo/renders/file/leak.txt");
expect(res.status).toBe(403);
expect(await res.text()).not.toContain("TOP-SECRET");
});
it("serves a render file reached through a symlink that stays inside rendersDir", async () => {
const { app, rendersDir } = buildApp();
mkdirSync(join(rendersDir, "nested"));
writeFileSync(join(rendersDir, "nested", "clip.mp4"), "nested-bytes");
if (!tryCreateSymlink(join(rendersDir, "nested"), join(rendersDir, "alias"), "dir")) return;
const res = await app.request("http://localhost/projects/demo/renders/file/alias/clip.mp4");
expect(res.status).toBe(200);
expect(await res.text()).toBe("nested-bytes");
});
});
describe("POST /render/:jobId/cancel", () => {
async function startJob(app: Hono): Promise<string> {
const res = await app.request("http://localhost/projects/demo/render", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ fps: 30, quality: "standard", format: "mp4" }),
});
expect(res.status).toBe(200);
return ((await res.json()) as { jobId: string }).jobId;
}
it("marks a rendering job cancelled and invokes the adapter abort hook", async () => {
const spy = vi.fn();
let aborted = false;
const { adapter, rendersDir } = createAdapter(spy);
const baseStartRender = adapter.startRender.bind(adapter);
adapter.startRender = (opts) => {
const state = baseStartRender(opts);
state.cancel = () => {
aborted = true;
};
return state;
};
const app = new Hono();
registerRenderRoutes(app, adapter);
try {
const jobId = await startJob(app);
const res = await app.request(`http://localhost/render/${jobId}/cancel`, { method: "POST" });
expect(res.status).toBe(200);
expect(((await res.json()) as { status: string }).status).toBe("cancelled");
expect(aborted).toBe(true);
// SSE progress for a cancelled job must terminate (status is terminal).
const progress = await app.request(`http://localhost/render/${jobId}/progress`);
expect(progress.status).toBe(200);
} finally {
rmSync(rendersDir, { recursive: true, force: true });
}
});
it("does not cancel a job that already completed", async () => {
const spy = vi.fn();
const states: Array<{ status: string }> = [];
const { adapter, rendersDir } = createAdapter(spy);
const baseStartRender = adapter.startRender.bind(adapter);
adapter.startRender = (opts) => {
const state = baseStartRender(opts);
states.push(state);
return state;
};
const app = new Hono();
registerRenderRoutes(app, adapter);
try {
const jobId = await startJob(app);
const [state] = states;
if (state) state.status = "complete";
const res = await app.request(`http://localhost/render/${jobId}/cancel`, { method: "POST" });
expect(res.status).toBe(200);
expect(((await res.json()) as { status: string }).status).toBe("complete");
} finally {
rmSync(rendersDir, { recursive: true, force: true });
}
});
it("404s for unknown jobs", async () => {
const spy = vi.fn();
const { app, cleanup } = buildApp(spy);
try {
const res = await app.request("http://localhost/render/nope/cancel", { method: "POST" });
expect(res.status).toBe(404);
} finally {
cleanup();
}
});
});
describe("POST /projects/:id/render — telemetryDistinctId forwarding", () => {
it("forwards the browser telemetryDistinctId to the adapter as distinctId", async () => {
const spy = vi.fn();
const { app, cleanup } = buildApp(spy);
try {
const res = await app.request("http://localhost/projects/demo/render", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({
fps: 30,
quality: "standard",
format: "mp4",
telemetryDistinctId: "browser-user-123",
}),
});
expect(res.status).toBe(200);
expect(spy.mock.calls[0][0].distinctId).toBe("browser-user-123");
} finally {
cleanup();
}
});
it("passes undefined when no telemetryDistinctId is sent (older clients)", async () => {
const spy = vi.fn();
const { app, cleanup } = buildApp(spy);
try {
const res = await app.request("http://localhost/projects/demo/render", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ fps: 30, quality: "standard", format: "mp4" }),
});
expect(res.status).toBe(200);
expect(spy.mock.calls[0][0].distinctId).toBeUndefined();
} finally {
cleanup();
}
});
it("ignores a non-string telemetryDistinctId", async () => {
const spy = vi.fn();
const { app, cleanup } = buildApp(spy);
try {
const res = await app.request("http://localhost/projects/demo/render", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({
fps: 30,
quality: "standard",
format: "mp4",
telemetryDistinctId: 42,
}),
});
expect(res.status).toBe(200);
expect(spy.mock.calls[0][0].distinctId).toBeUndefined();
} finally {
cleanup();
}
});
});
describe("POST /projects/:id/render — variables forwarding", () => {
it("forwards a variables object to the adapter", async () => {
const spy = vi.fn();
const { app, cleanup } = buildApp(spy);
try {
const res = await app.request("http://localhost/projects/demo/render", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({
format: "mp4",
variables: { title: "Custom", count: 5, dark: true },
}),
});
expect(res.status).toBe(200);
expect(spy).toHaveBeenCalledOnce();
expect(spy.mock.calls[0][0].variables).toEqual({ title: "Custom", count: 5, dark: true });
} finally {
cleanup();
}
});
it("omits variables from adapter opts when not provided", async () => {
const spy = vi.fn();
const { app, cleanup } = buildApp(spy);
try {
const res = await app.request("http://localhost/projects/demo/render", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ format: "mp4" }),
});
expect(res.status).toBe(200);
expect(spy.mock.calls[0][0].variables).toBeUndefined();
} finally {
cleanup();
}
});
it("rejects non-object variables payloads with 400 (no silent drop)", async () => {
const spy = vi.fn();
const { app, cleanup } = buildApp(spy);
try {
for (const variables of [["a"], "str", 42, null]) {
const res = await app.request("http://localhost/projects/demo/render", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ format: "mp4", variables }),
});
expect(res.status).toBe(400);
}
expect(spy).not.toHaveBeenCalled();
} finally {
cleanup();
}
});
});
+324
View File
@@ -0,0 +1,324 @@
import type { Hono } from "hono";
import { streamSSE } from "hono/streaming";
import { existsSync, readFileSync, mkdirSync, unlinkSync, readdirSync, statSync } from "node:fs";
import { join } from "node:path";
import type { StudioApiAdapter, RenderJobState } from "../types.js";
import { VALID_CANVAS_RESOLUTIONS, type CanvasResolution } from "@hyperframes/parsers";
import { parseFps } from "@hyperframes/core";
import { resolveWithinProject } from "../helpers/safePath.js";
import { isVariablesPayload, VARIABLES_PAYLOAD_ERROR } from "../helpers/variablesPayload.js";
const VALID_RESOLUTIONS = new Set<string>(VALID_CANVAS_RESOLUTIONS);
export function registerRenderRoutes(api: Hono, adapter: StudioApiAdapter): void {
// Scoped job store — not shared across createStudioApi() calls
const renderJobs = new Map<string, RenderJobState & { createdAt: number }>();
// TTL cleanup for completed jobs (5 minutes)
const TTL_MS = 300_000;
const CLEANUP_INTERVAL_MS = 60_000;
let cleanupTimer: ReturnType<typeof setInterval> | null = null;
const cleanupEnabled = () =>
typeof process !== "undefined" &&
process.env.NODE_ENV !== "production" &&
!process.argv.includes("build");
const cleanupFinishedJobs = () => {
const now = Date.now();
for (const [key, job] of renderJobs) {
if (job.status !== "rendering" && now - job.createdAt > TTL_MS) {
renderJobs.delete(key);
}
}
if (renderJobs.size === 0 && cleanupTimer) {
clearInterval(cleanupTimer);
cleanupTimer = null;
}
};
const ensureCleanupTimer = () => {
if (cleanupTimer || !cleanupEnabled()) return;
cleanupTimer = setInterval(cleanupFinishedJobs, CLEANUP_INTERVAL_MS);
if (typeof cleanupTimer === "object" && "unref" in cleanupTimer) {
cleanupTimer.unref();
}
};
ensureCleanupTimer();
// Start a render
api.post("/projects/:id/render", async (c) => {
const project = await adapter.resolveProject(c.req.param("id"));
if (!project) return c.json({ error: "not found" }, 404);
const body = (await c.req.json().catch(() => ({}))) as {
// Polymorphic per design note in core.types.Fps:
// number → integer fps (e.g. 30)
// string → rational fps (e.g. "30000/1001" for NTSC 29.97)
// Decimals are rejected on purpose so the exact denominator stays
// unambiguous (29.97 ≠ 30000/1001 when ffmpeg consumes them).
fps?: number | string;
quality?: string;
format?: string;
resolution?: string;
composition?: string;
// Browser telemetry id, so the server-emitted render outcome is
// attributed to the user who triggered the render (joinable funnel).
telemetryDistinctId?: string;
// Composition-variable overrides ({variableId: value}), injected as
// window.__hfVariables — same channel as `hyperframes render --variables`.
variables?: Record<string, unknown>;
};
const VALID_FORMATS = new Set(["mp4", "webm", "mov"]);
const FORMAT_EXT: Record<string, string> = { mp4: ".mp4", webm: ".webm", mov: ".mov" };
const format = VALID_FORMATS.has(body.format ?? "") ? (body.format as string) : "mp4";
// Default to 30 fps when unset or unparseable. The route stays lenient on
// invalid fps values (matching the lenient handling of `resolution` and
// `quality` already in this file) — the producer surfaces a clearer error
// message if the caller really did mean to fail loudly.
const fpsParse = body.fps === undefined ? null : parseFps(body.fps);
const fps = fpsParse && fpsParse.ok ? fpsParse.value : { num: 30, den: 1 };
const quality = ["draft", "standard", "high"].includes(body.quality ?? "")
? (body.quality as string)
: "standard";
const outputResolution = VALID_RESOLUTIONS.has(body.resolution ?? "")
? (body.resolution as CanvasResolution)
: undefined;
let composition: string | undefined;
if (typeof body.composition === "string" && body.composition.length > 0) {
// `body.composition` is attacker-controlled (from c.req.json()).
// resolveWithinProject dereferences symlinks, so an in-project symlink
// pointing outside the root can't smuggle the render target out.
if (!resolveWithinProject(project.dir, body.composition)) {
return c.json({ error: "composition path must be within the project directory" }, 400);
}
composition = body.composition;
}
// Unlike fps/quality (lenient with safe fallbacks), a malformed variables
// payload means the user's values would be silently dropped — fail loudly.
let variables: Record<string, unknown> | undefined;
if (body.variables !== undefined) {
if (!isVariablesPayload(body.variables)) {
return c.json({ error: VARIABLES_PAYLOAD_ERROR }, 400);
}
variables = body.variables;
}
// fallow-ignore-next-line code-duplication
const now = new Date();
const datePart = now.toISOString().slice(0, 10);
const timePart = now.toTimeString().slice(0, 8).replace(/:/g, "-");
const jobId = `${project.id}_${datePart}_${timePart}`;
const rendersDir = adapter.rendersDir(project);
if (!existsSync(rendersDir)) mkdirSync(rendersDir, { recursive: true });
const ext = FORMAT_EXT[format] ?? ".mp4";
const outputPath = join(rendersDir, `${jobId}${ext}`);
const jobState = adapter.startRender({
project,
outputPath,
format: format as "mp4" | "webm" | "mov",
fps,
quality,
jobId,
outputResolution,
composition,
variables,
distinctId:
typeof body.telemetryDistinctId === "string" ? body.telemetryDistinctId : undefined,
});
(jobState as RenderJobState & { createdAt: number }).createdAt = Date.now();
renderJobs.set(jobId, jobState as RenderJobState & { createdAt: number });
ensureCleanupTimer();
return c.json({ jobId, status: "rendering" });
});
// SSE progress stream
api.get("/render/:jobId/progress", (c) => {
const { jobId } = c.req.param();
const job = renderJobs.get(jobId);
if (!job) return c.json({ error: "not found" }, 404);
return streamSSE(c, async (stream) => {
while (true) {
const current = renderJobs.get(jobId);
if (!current) break;
await stream.writeSSE({
event: "progress",
data: JSON.stringify({
progress: current.progress,
status: current.status,
stage: current.stage,
error: current.error,
}),
});
if (current.status !== "rendering") break;
await stream.sleep(500);
}
});
});
// Cancel an in-flight render. Marks the job cancelled immediately (so the
// SSE stream terminates) and invokes the adapter's abort hook when present.
api.post("/render/:jobId/cancel", (c) => {
const { jobId } = c.req.param();
const job = renderJobs.get(jobId);
if (!job) return c.json({ error: "not found" }, 404);
if (job.status === "rendering") {
job.status = "cancelled";
job.cancel?.();
}
return c.json({ status: job.status });
});
const RENDER_MIME: Record<string, string> = {
".mp4": "video/mp4",
".webm": "video/webm",
".mov": "video/quicktime",
};
const RENDER_EXTENSIONS = Object.keys(RENDER_MIME);
function renderContentType(filePath: string): string {
const ext = RENDER_EXTENSIONS.find((e) => filePath.endsWith(e));
return (ext && RENDER_MIME[ext]) ?? "video/mp4";
}
// Serve render inline (for in-browser playback — opens in a new tab)
// fallow-ignore-next-line code-duplication
api.get("/render/:jobId/view", (c) => {
const { jobId } = c.req.param();
const job = renderJobs.get(jobId);
if (!job?.outputPath || !existsSync(job.outputPath)) {
return c.json({ error: "not found" }, 404);
}
const contentType = renderContentType(job.outputPath);
const filename = job.outputPath.split("/").pop() ?? `render.mp4`;
const content = readFileSync(job.outputPath);
return new Response(content, {
headers: {
"Content-Type": contentType,
"Content-Disposition": `inline; filename="${filename}"`,
"Accept-Ranges": "bytes",
"Content-Length": String(content.length),
},
});
});
// Download render
// fallow-ignore-next-line code-duplication
api.get("/render/:jobId/download", (c) => {
const { jobId } = c.req.param();
const job = renderJobs.get(jobId);
if (!job?.outputPath || !existsSync(job.outputPath)) {
return c.json({ error: "not found" }, 404);
}
const contentType = renderContentType(job.outputPath);
const filename = job.outputPath.split("/").pop() ?? `render.mp4`;
const content = readFileSync(job.outputPath);
return new Response(content, {
headers: {
"Content-Type": contentType,
"Content-Disposition": `attachment; filename="${filename}"`,
},
});
});
// Delete render
api.delete("/render/:jobId", (c) => {
const { jobId } = c.req.param();
for (const [, state] of renderJobs) {
if (state.id === jobId && state.outputPath) {
const dir = state.outputPath.replace(/\/[^/]+$/, "");
for (const ext of [".mp4", ".webm", ".mov", ".meta.json"]) {
const fp = join(dir, `${jobId}${ext}`);
if (existsSync(fp)) unlinkSync(fp);
}
break;
}
}
renderJobs.delete(jobId);
return c.json({ deleted: true });
});
// Serve render file directly from disk (no in-memory map dependency)
api.get("/projects/:id/renders/file/*", async (c) => {
const project = await adapter.resolveProject(c.req.param("id"));
if (!project) return c.json({ error: "not found" }, 404);
const filename = c.req.path.split("/renders/file/")[1];
if (!filename) return c.json({ error: "missing filename" }, 400);
const rendersDir = adapter.rendersDir(project);
// Containment guard: the filename is attacker-controlled wildcard input, so
// route it through the same chokepoint every other project-scoped path uses.
// Literal `..` is collapsed upstream by the URL parser, but a bare join() +
// readFileSync still followed an in-rendersDir symlink pointing outside the
// dir; resolveWithinProject canonicalizes with realpath before serving.
const fp = resolveWithinProject(rendersDir, filename);
if (!fp) return c.json({ error: "forbidden" }, 403);
if (!existsSync(fp)) return c.json({ error: "not found" }, 404);
const contentType = renderContentType(fp);
const content = readFileSync(fp);
return new Response(content, {
headers: {
"Content-Type": contentType,
"Content-Disposition": `inline; filename="${filename}"`,
"Accept-Ranges": "bytes",
"Content-Length": String(content.length),
},
});
});
// List renders
api.get("/projects/:id/renders", async (c) => {
const project = await adapter.resolveProject(c.req.param("id"));
if (!project) return c.json({ error: "not found" }, 404);
const rendersDir = adapter.rendersDir(project);
if (!existsSync(rendersDir)) return c.json({ renders: [] });
const files = readdirSync(rendersDir)
.filter((f) => f.endsWith(".mp4") || f.endsWith(".webm") || f.endsWith(".mov"))
.map((f) => {
const fp = join(rendersDir, f);
const stat = statSync(fp);
const rid = f.replace(/\.(mp4|webm|mov)$/, "");
const metaPath = join(rendersDir, `${rid}.meta.json`);
let status: "complete" | "failed" = "complete";
let durationMs: number | undefined;
if (existsSync(metaPath)) {
try {
const meta = JSON.parse(readFileSync(metaPath, "utf-8"));
if (meta.status === "failed") status = "failed";
if (meta.durationMs) durationMs = meta.durationMs;
} catch {
/* ignore */
}
}
return {
id: rid,
filename: f,
size: stat.size,
createdAt: stat.mtimeMs,
status,
durationMs,
};
})
.sort((a, b) => b.createdAt - a.createdAt);
// Register on-disk renders that aren't in the current session's job map
// so they remain downloadable after a server restart.
for (const file of files) {
if (!renderJobs.has(file.id)) {
renderJobs.set(file.id, {
id: file.id,
status: file.status,
progress: 100,
outputPath: join(rendersDir, file.filename),
createdAt: file.createdAt,
} as RenderJobState & { createdAt: number });
}
}
return c.json({ renders: files });
});
}
@@ -0,0 +1,119 @@
import { describe, expect, it } from "vitest";
import { Hono } from "hono";
import { registerSelectionRoutes } from "./selection";
import type { StudioApiAdapter, StudioSelectionSnapshot } from "../types";
function createAdapter(): StudioApiAdapter {
return {
listProjects: () => [],
resolveProject: async (id: string) =>
id === "demo" ? { id, dir: "/tmp/demo", title: "Demo" } : null,
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",
}),
};
}
const selection = {
schemaVersion: 1,
projectId: "demo",
compositionPath: "index.html",
sourceFile: "index.html",
currentTime: 1.25,
target: { hfId: "hero-title", selector: ".title", selectorIndex: 0 },
label: "Hero title",
tagName: "h1",
boundingBox: { x: 10, y: 20, width: 300, height: 64 },
textContent: "Launch faster",
dataAttributes: { "data-hf-id": "hero-title" },
inlineStyles: { color: "white" },
computedStyles: { "font-size": "48px" },
textFields: [
{
key: "self",
label: "Text",
value: "Launch faster",
tagName: "h1",
source: "self",
},
],
capabilities: { canSelect: true, canEditStyles: true },
thumbnailUrl:
"/api/projects/demo/thumbnail/index.html?t=1.25&format=png&selector=.title&selectorIndex=0",
} satisfies StudioSelectionSnapshot;
describe("registerSelectionRoutes", () => {
it("stores and returns the latest Studio selection snapshot", async () => {
const app = new Hono();
registerSelectionRoutes(app, createAdapter());
const put = await app.request("http://localhost/projects/demo/selection", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ selection }),
});
expect(put.status).toBe(200);
const response = await app.request("http://localhost/projects/demo/selection");
const payload = (await response.json()) as {
selection?: StudioSelectionSnapshot | null;
updatedAt?: string | null;
};
expect(response.status).toBe(200);
expect(payload.selection).toMatchObject({
projectId: "demo",
sourceFile: "index.html",
target: { hfId: "hero-title" },
thumbnailUrl: expect.stringContaining("/thumbnail/index.html"),
});
expect(payload.updatedAt).toMatch(/^\d{4}-\d{2}-\d{2}T/);
});
it("clears a stored selection when Studio posts null", async () => {
const app = new Hono();
registerSelectionRoutes(app, createAdapter());
await app.request("http://localhost/projects/demo/selection", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ selection }),
});
const clear = await app.request("http://localhost/projects/demo/selection", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ selection: null }),
});
expect(clear.status).toBe(200);
const response = await app.request("http://localhost/projects/demo/selection");
const payload = (await response.json()) as {
selection?: StudioSelectionSnapshot | null;
updatedAt?: string | null;
};
expect(payload.selection).toBeNull();
expect(payload.updatedAt).toBeNull();
});
it("rejects malformed selection payloads", async () => {
const app = new Hono();
registerSelectionRoutes(app, createAdapter());
const response = await app.request("http://localhost/projects/demo/selection", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ selection: { projectId: "demo" } }),
});
expect(response.status).toBe(400);
});
});
@@ -0,0 +1,141 @@
import type { Hono } from "hono";
import type {
StudioApiAdapter,
StudioSelectionResponse,
StudioSelectionSnapshot,
} from "../types.js";
interface StoredSelection {
selection: StudioSelectionSnapshot;
updatedAt: string;
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
function isFiniteNumber(value: unknown): value is number {
return typeof value === "number" && Number.isFinite(value);
}
function isStringRecord(value: unknown): value is Record<string, string> {
return isRecord(value) && Object.values(value).every((v) => typeof v === "string");
}
function hasString(value: Record<string, unknown>, key: string): boolean {
return typeof value[key] === "string";
}
function hasRequiredStrings(value: Record<string, unknown>, keys: string[]): boolean {
return keys.every((key) => hasString(value, key));
}
function hasOptionalString(value: Record<string, unknown>, key: string): boolean {
return value[key] === undefined || typeof value[key] === "string";
}
function hasOptionalNullableString(value: Record<string, unknown>, key: string): boolean {
return value[key] == null || typeof value[key] === "string";
}
function hasOptionalNumber(value: Record<string, unknown>, key: string): boolean {
return value[key] === undefined || isFiniteNumber(value[key]);
}
function isBoundingBox(value: unknown): value is StudioSelectionSnapshot["boundingBox"] {
return (
isRecord(value) && ["x", "y", "width", "height"].every((key) => isFiniteNumber(value[key]))
);
}
function isTarget(value: unknown): value is StudioSelectionSnapshot["target"] {
if (!isRecord(value)) return false;
return (
hasOptionalNullableString(value, "id") &&
hasOptionalString(value, "hfId") &&
hasOptionalString(value, "selector") &&
hasOptionalNumber(value, "selectorIndex")
);
}
function isTextField(value: unknown): value is StudioSelectionSnapshot["textFields"][number] {
return (
isRecord(value) &&
hasRequiredStrings(value, ["key", "label", "value", "tagName"]) &&
["self", "child", "text-node"].includes(value.source as string)
);
}
function isTextFields(value: unknown): value is StudioSelectionSnapshot["textFields"] {
return Array.isArray(value) && value.every(isTextField);
}
function isSelectionSnapshot(value: unknown): value is StudioSelectionSnapshot {
if (!isRecord(value)) return false;
const checks = [
value.schemaVersion === 1 &&
hasRequiredStrings(value, [
"projectId",
"compositionPath",
"sourceFile",
"label",
"tagName",
"thumbnailUrl",
]),
isFiniteNumber(value.currentTime),
isTarget(value.target),
isBoundingBox(value.boundingBox),
value.textContent === null || typeof value.textContent === "string",
isStringRecord(value.dataAttributes),
isStringRecord(value.inlineStyles),
isStringRecord(value.computedStyles),
isTextFields(value.textFields),
isRecord(value.capabilities),
];
return checks.every(Boolean);
}
export function registerSelectionRoutes(api: Hono, adapter: StudioApiAdapter): void {
const selections = new Map<string, StoredSelection>();
api.get("/projects/:id/selection", async (c) => {
const project = await adapter.resolveProject(c.req.param("id"));
if (!project) return c.json({ error: "not found" }, 404);
const stored = selections.get(project.id);
return c.json({
selection: stored?.selection ?? null,
updatedAt: stored?.updatedAt ?? null,
} satisfies StudioSelectionResponse);
});
api.put("/projects/:id/selection", async (c) => {
const project = await adapter.resolveProject(c.req.param("id"));
if (!project) return c.json({ error: "not found" }, 404);
let body: unknown;
try {
body = await c.req.json();
} catch {
return c.json({ error: "invalid json" }, 400);
}
if (!isRecord(body) || !("selection" in body)) {
return c.json({ error: "missing selection" }, 400);
}
if (body.selection === null) {
selections.delete(project.id);
return c.json({ ok: true, selection: null, updatedAt: null });
}
if (!isSelectionSnapshot(body.selection)) {
return c.json({ error: "invalid selection" }, 400);
}
const selection = { ...body.selection, projectId: project.id };
const updatedAt = new Date().toISOString();
selections.set(project.id, { selection, updatedAt });
return c.json({ ok: true, selection, updatedAt });
});
}
@@ -0,0 +1,109 @@
import { afterEach, describe, expect, it } from "vitest";
import { Hono } from "hono";
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { registerStoryboardRoutes } from "./storyboard.js";
import type { StudioApiAdapter } from "../types.js";
const tempDirs: string[] = [];
afterEach(() => {
for (const dir of tempDirs.splice(0)) {
rmSync(dir, { recursive: true, force: true });
}
});
function makeProject(): string {
const dir = mkdtempSync(join(tmpdir(), "storyboard-route-"));
tempDirs.push(dir);
return dir;
}
function makeApp(projectDir: string): Hono {
const adapter = {
resolveProject: (id: string) => (id === "p" ? { id: "p", dir: projectDir } : null),
} as unknown as StudioApiAdapter;
const app = new Hono();
registerStoryboardRoutes(app, adapter);
return app;
}
/** Request the storyboard for project "p" and return status + parsed JSON body. */
async function getStoryboard(projectDir: string) {
const res = await makeApp(projectDir).request("/projects/p/storyboard");
return { status: res.status, body: await res.json() };
}
describe("GET /projects/:id/storyboard", () => {
it("returns exists:false with empty frames when STORYBOARD.md is absent", async () => {
const { status, body } = await getStoryboard(makeProject());
expect(status).toBe(200);
expect(body.exists).toBe(false);
expect(body.frames).toEqual([]);
});
it("404s for an unknown project", async () => {
const res = await makeApp(makeProject()).request("/projects/nope/storyboard");
expect(res.status).toBe(404);
});
it("parses the manifest and resolves frame src existence on disk", async () => {
const dir = makeProject();
mkdirSync(join(dir, "compositions", "frames"), { recursive: true });
writeFileSync(join(dir, "compositions", "frames", "01-hook.html"), "<div></div>");
writeFileSync(
join(dir, "STORYBOARD.md"),
`---
message: Hello world
---
## Frame 1 — Hook
- status: built
- src: compositions/frames/01-hook.html
Opening line.
## Frame 2 — Missing
- status: outline
- src: compositions/frames/02-missing.html
Not built yet.
`,
);
const { status, body } = await getStoryboard(dir);
expect(status).toBe(200);
expect(body.exists).toBe(true);
expect(body.globals.message).toBe("Hello world");
expect(body.frames).toHaveLength(2);
expect(body.frames[0]).toMatchObject({ title: "Hook", status: "built", srcExists: true });
expect(body.frames[1]).toMatchObject({ title: "Missing", status: "outline", srcExists: false });
});
it("surfaces the companion SCRIPT.md when present", async () => {
const dir = makeProject();
writeFileSync(join(dir, "STORYBOARD.md"), "## Frame 1\n\nHi.\n");
writeFileSync(join(dir, "SCRIPT.md"), "# Script\n\nLine 1.\n");
const { body } = await getStoryboard(dir);
expect(body.script).toMatchObject({ exists: true, path: "SCRIPT.md" });
expect(body.script.content).toContain("Line 1.");
});
it("reports script.exists=false when there is no SCRIPT.md", async () => {
const dir = makeProject();
writeFileSync(join(dir, "STORYBOARD.md"), "## Frame 1\n\nHi.\n");
const { body } = await getStoryboard(dir);
expect(body.script.exists).toBe(false);
});
it("does not resolve src paths that escape the project", async () => {
const dir = makeProject();
writeFileSync(
join(dir, "STORYBOARD.md"),
"## Frame 1\n- src: ../../etc/passwd\n\nEscape attempt.\n",
);
const { body } = await getStoryboard(dir);
expect(body.frames[0].srcExists).toBe(false);
});
});
@@ -0,0 +1,80 @@
import { existsSync, readFileSync } from "node:fs";
import type { Hono } from "hono";
import type { StudioApiAdapter } from "../types.js";
import { resolveWithinProject } from "../helpers/safePath.js";
import {
parseStoryboard,
SCRIPT_FILENAME,
STORYBOARD_FILENAME,
type StoryboardFrame,
} from "@hyperframes/core/storyboard";
/** A frame enriched with disk-resolution info the Studio needs to render tiles. */
interface ResolvedStoryboardFrame extends StoryboardFrame {
/** Whether `src` resolves to an existing file inside the project. */
srcExists: boolean;
}
function resolveFrames(projectDir: string, frames: StoryboardFrame[]): ResolvedStoryboardFrame[] {
return frames.map((frame) => {
let srcExists = false;
if (frame.src) {
const abs = resolveWithinProject(projectDir, frame.src);
srcExists = abs ? existsSync(abs) : false;
}
return { ...frame, srcExists };
});
}
/** Read the companion SCRIPT.md narration doc if it exists alongside the storyboard. */
function readScript(projectDir: string): { exists: boolean; path: string; content: string } {
const abs = resolveWithinProject(projectDir, SCRIPT_FILENAME);
if (abs && existsSync(abs)) {
try {
return { exists: true, path: SCRIPT_FILENAME, content: readFileSync(abs, "utf-8") };
} catch {
/* fall through to absent */
}
}
return { exists: false, path: SCRIPT_FILENAME, content: "" };
}
export function registerStoryboardRoutes(api: Hono, adapter: StudioApiAdapter): void {
// Parsed storyboard manifest for a project. Markdown (STORYBOARD.md) stays
// canonical on disk; this returns the derived, normalized structure. When the
// file is absent we return `exists: false` with empty frames rather than 404,
// so the Studio can render an opt-in empty state.
api.get("/projects/:id/storyboard", async (c) => {
const project = await adapter.resolveProject(c.req.param("id"));
if (!project) return c.json({ error: "not found" }, 404);
const abs = resolveWithinProject(project.dir, STORYBOARD_FILENAME);
if (!abs || !existsSync(abs)) {
return c.json({
exists: false,
path: STORYBOARD_FILENAME,
globals: { extra: {} },
frames: [],
warnings: [],
script: readScript(project.dir),
});
}
let source: string;
try {
source = readFileSync(abs, "utf-8");
} catch {
return c.json({ error: "failed to read storyboard" }, 500);
}
const manifest = parseStoryboard(source);
return c.json({
exists: true,
path: STORYBOARD_FILENAME,
globals: manifest.globals,
frames: resolveFrames(project.dir, manifest.frames),
warnings: manifest.warnings,
script: readScript(project.dir),
});
});
}
@@ -0,0 +1,223 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { Hono } from "hono";
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { registerThumbnailRoutes } from "./thumbnail";
import type { StudioApiAdapter } from "../types";
const tempProjectDirs: string[] = [];
afterEach(() => {
for (const dir of tempProjectDirs.splice(0)) {
rmSync(dir, { recursive: true, force: true });
}
});
function createAdapter(): StudioApiAdapter {
const projectDir = mkdtempSync(join(tmpdir(), "hf-thumbnail-test-"));
tempProjectDirs.push(projectDir);
return {
listProjects: () => [],
resolveProject: async (id: string) => ({ id, dir: projectDir }),
bundle: async () => null,
lint: async () => ({ findings: [] }),
runtimeUrl: "/api/runtime.js",
rendersDir: () => "/tmp/renders",
startRender: () => ({
id: "job-1",
status: "rendering",
progress: 0,
outputPath: "/tmp/out.mp4",
}),
generateThumbnail: vi.fn(async () => Buffer.from("thumb")),
};
}
describe("registerThumbnailRoutes", () => {
it("forwards selector queries to thumbnail generation", async () => {
const adapter = createAdapter();
const app = new Hono();
registerThumbnailRoutes(app, adapter);
const response = await app.request(
"http://localhost/projects/demo/thumbnail/index.html?t=1.2&selector=%23title-card",
);
expect(response.status).toBe(200);
expect(adapter.generateThumbnail).toHaveBeenCalledWith(
expect.objectContaining({
compPath: "index.html",
seekTime: 1.2,
selector: "#title-card",
format: "jpeg",
}),
);
});
it("forwards png capture requests and returns a png content type", async () => {
const adapter = createAdapter();
const app = new Hono();
registerThumbnailRoutes(app, adapter);
const response = await app.request(
"http://localhost/projects/demo/thumbnail/compositions%2Fintro.html?t=2&format=png",
);
expect(response.status).toBe(200);
expect(response.headers.get("Content-Type")).toBe("image/png");
expect(adapter.generateThumbnail).toHaveBeenCalledWith(
expect.objectContaining({
compPath: "compositions/intro.html",
seekTime: 2,
format: "png",
}),
);
});
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",
}),
);
});
it("forwards selector occurrence indexes to thumbnail generation", async () => {
const adapter = createAdapter();
const app = new Hono();
registerThumbnailRoutes(app, adapter);
const response = await app.request(
"http://localhost/projects/demo/thumbnail/index.html?t=1.2&selector=.card&selectorIndex=2",
);
expect(response.status).toBe(200);
expect(adapter.generateThumbnail).toHaveBeenCalledWith(
expect.objectContaining({
selector: ".card",
selectorIndex: 2,
}),
);
});
it("keeps url thumbnail versions separated in the disk cache", async () => {
const adapter = createAdapter();
const app = new Hono();
registerThumbnailRoutes(app, adapter);
await app.request("http://localhost/projects/demo/thumbnail/index.html?t=2&v=old");
await app.request("http://localhost/projects/demo/thumbnail/index.html?t=2&v=old");
await app.request("http://localhost/projects/demo/thumbnail/index.html?t=2&v=new");
expect(adapter.generateThumbnail).toHaveBeenCalledTimes(2);
});
it("keeps changed composition dimensions separated in the disk cache", async () => {
const adapter = createAdapter();
const project = await adapter.resolveProject("demo");
if (!project) throw new Error("missing project");
const app = new Hono();
registerThumbnailRoutes(app, adapter);
const indexPath = join(project.dir, "index.html");
writeFileSync(indexPath, `<div data-composition-id="main" data-width="640" data-height="360">`);
await app.request("http://localhost/projects/demo/thumbnail/index.html?t=2&v=test");
writeFileSync(
indexPath,
`<div data-composition-id="main" data-width="1280" data-height="720">`,
);
await app.request("http://localhost/projects/demo/thumbnail/index.html?t=2&v=test");
expect(adapter.generateThumbnail).toHaveBeenCalledTimes(2);
expect(adapter.generateThumbnail).toHaveBeenLastCalledWith(
expect.objectContaining({
width: 1280,
height: 720,
}),
);
});
it("keeps changed studio manual edits separated in the disk cache", async () => {
const adapter = createAdapter();
const project = await adapter.resolveProject("demo");
if (!project) throw new Error("missing project");
const app = new Hono();
registerThumbnailRoutes(app, adapter);
const indexPath = join(project.dir, "index.html");
writeFileSync(indexPath, `<div data-composition-id="main" data-width="640" data-height="360">`);
const manualEditsDir = join(project.dir, ".hyperframes");
mkdirSync(manualEditsDir, { recursive: true });
const manualEditsPath = join(manualEditsDir, "studio-manual-edits.json");
writeFileSync(manualEditsPath, `{"version":1,"edits":[]}`);
await app.request("http://localhost/projects/demo/thumbnail/index.html?t=2&v=test");
writeFileSync(
manualEditsPath,
`{"version":1,"edits":[{"kind":"rotation","target":{"sourceFile":"index.html","id":"card"},"angle":30}]}`,
);
await app.request("http://localhost/projects/demo/thumbnail/index.html?t=2&v=test");
expect(adapter.generateThumbnail).toHaveBeenCalledTimes(2);
});
it("regenerates when the composition HTML changes even with explicit w/h", async () => {
// Repro: the Studio requests thumbnails WITH explicit dimensions. The old
// code only read (and keyed on) the composition file when no w/h was given,
// so editing the HTML left the disk-cache key unchanged and served a stale
// thumbnail — even after a hard reload. The content hash must always be keyed.
const adapter = createAdapter();
const project = await adapter.resolveProject("demo");
if (!project) throw new Error("missing project");
const app = new Hono();
registerThumbnailRoutes(app, adapter);
const indexPath = join(project.dir, "index.html");
const url = "http://localhost/projects/demo/thumbnail/index.html?t=2&w=640&h=360&v=test";
writeFileSync(indexPath, `<div id="box">before</div>`);
await app.request(url);
writeFileSync(indexPath, `<div id="box">after</div>`);
await app.request(url);
expect(adapter.generateThumbnail).toHaveBeenCalledTimes(2);
});
it("keeps changed studio motion separated in the disk cache", async () => {
const adapter = createAdapter();
const project = await adapter.resolveProject("demo");
if (!project) throw new Error("missing project");
const app = new Hono();
registerThumbnailRoutes(app, adapter);
const indexPath = join(project.dir, "index.html");
writeFileSync(indexPath, `<div data-composition-id="main" data-width="640" data-height="360">`);
const motionDir = join(project.dir, ".hyperframes");
mkdirSync(motionDir, { recursive: true });
const motionPath = join(motionDir, "studio-motion.json");
writeFileSync(motionPath, `{"version":1,"motions":[]}`);
await app.request("http://localhost/projects/demo/thumbnail/index.html?t=2&v=test");
writeFileSync(
motionPath,
`{"version":1,"motions":[{"kind":"gsap-motion","target":{"sourceFile":"index.html","id":"card"},"start":0,"duration":1,"ease":"power2.out","from":{"y":32},"to":{"y":0}}]}`,
);
await app.request("http://localhost/projects/demo/thumbnail/index.html?t=2&v=test");
expect(adapter.generateThumbnail).toHaveBeenCalledTimes(2);
});
});
@@ -0,0 +1,125 @@
import type { Hono } from "hono";
import { existsSync, readFileSync, writeFileSync, mkdirSync, statSync } from "node:fs";
import { join } from "node:path";
import { createHash } from "node:crypto";
import type { StudioApiAdapter } from "../types.js";
import { STUDIO_MANUAL_EDITS_PATH } from "../helpers/manualEditsRenderScript.js";
import { STUDIO_MOTION_PATH } from "../helpers/studioMotionRenderScript.js";
const THUMBNAIL_CACHE_VERSION = "v4";
export function registerThumbnailRoutes(api: Hono, adapter: StudioApiAdapter): void {
api.get("/projects/:id/thumbnail/*", async (c) => {
if (!adapter.generateThumbnail) {
return c.json({ error: "Thumbnails not available" }, 501);
}
const project = await adapter.resolveProject(c.req.param("id"));
if (!project) return c.json({ error: "not found" }, 404);
let compPath = decodeURIComponent(
c.req.path.replace(`/projects/${project.id}/thumbnail/`, "").split("?")[0] ?? "",
);
if (compPath && !compPath.includes(".")) compPath += ".html";
const url = new URL(c.req.url, `http://${c.req.header("host") || "localhost"}`);
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;
const format = url.searchParams.get("format") === "png" ? "png" : "jpeg";
const contentType = format === "png" ? "image/png" : "image/jpeg";
const rawSelectorIndex = Number.parseInt(url.searchParams.get("selectorIndex") || "0", 10);
const selectorIndex =
Number.isFinite(rawSelectorIndex) && rawSelectorIndex > 0 ? rawSelectorIndex : undefined;
const urlVersion = url.searchParams.get("v") || "";
// Determine composition dimensions from HTML
let compW = vpWidth || 1920;
let compH = vpHeight || 1080;
let sourceMtime = 0;
// Content-hash the composition HTML into the cache key — ALWAYS, even when
// explicit w/h are supplied. The old code only read the file when `!vpWidth`,
// so Studio thumbnail requests (which pass dimensions) kept the source out of
// the key entirely (sourceMtime=0) and served a stale thumbnail after every
// edit, even on a hard reload. Keyed on content (like manualEdits/motion), not
// just mtime, so a restore/copy with a preserved mtime can't serve stale.
let sourceKey = "";
const htmlFile = join(project.dir, compPath);
if (existsSync(htmlFile)) {
const html = readFileSync(htmlFile, "utf-8");
sourceKey = `_${createHash("sha1").update(html).digest("hex").slice(0, 16)}`;
sourceMtime = Math.round(statSync(htmlFile).mtimeMs);
if (!vpWidth) {
const wMatch = html.match(/data-width=["'](\d+)["']/);
const hMatch = html.match(/data-height=["'](\d+)["']/);
if (wMatch?.[1]) compW = parseInt(wMatch[1]);
if (hMatch?.[1]) compH = parseInt(hMatch[1]);
}
}
const manualEditsFile = join(project.dir, STUDIO_MANUAL_EDITS_PATH);
let manualEditsKey = "";
if (existsSync(manualEditsFile)) {
const manualEditsContent = readFileSync(manualEditsFile, "utf-8");
manualEditsKey = `_${createHash("sha1").update(manualEditsContent).digest("hex").slice(0, 16)}`;
sourceMtime = Math.max(sourceMtime, Math.round(statSync(manualEditsFile).mtimeMs));
}
const motionFile = join(project.dir, STUDIO_MOTION_PATH);
let motionKey = "";
if (existsSync(motionFile)) {
const motionContent = readFileSync(motionFile, "utf-8");
motionKey = `_${createHash("sha1").update(motionContent).digest("hex").slice(0, 16)}`;
sourceMtime = Math.max(sourceMtime, Math.round(statSync(motionFile).mtimeMs));
}
const previewUrl =
compPath === "index.html"
? `http://${c.req.header("host")}/api/projects/${project.id}/preview`
: `http://${c.req.header("host")}/api/projects/${project.id}/preview/comp/${compPath}`;
// Cache
const cacheDir = join(project.dir, ".thumbnails");
const selectorKey = selector
? `_${selector.replace(/[^a-zA-Z0-9_-]+/g, "_").slice(0, 80)}_${selectorIndex ?? 0}`
: "";
const urlVersionKey = urlVersion
? `_${urlVersion.replace(/[^a-zA-Z0-9_-]+/g, "_").slice(0, 32)}`
: "";
const cacheKey = `${THUMBNAIL_CACHE_VERSION}${urlVersionKey}${manualEditsKey}${motionKey}${sourceKey}_${format}_${compPath.replace(/\//g, "_")}_${compW}x${compH}_${sourceMtime}_${seekTime.toFixed(2)}${selectorKey}.${format === "png" ? "png" : "jpg"}`;
const cachePath = join(cacheDir, cacheKey);
if (existsSync(cachePath)) {
return new Response(new Uint8Array(readFileSync(cachePath)), {
headers: { "Content-Type": contentType, "Cache-Control": "no-cache" },
});
}
try {
const buffer = await adapter.generateThumbnail({
project,
compPath,
seekTime,
width: compW,
height: compH,
previewUrl,
selector,
format,
selectorIndex,
});
if (!buffer) {
return c.json(
{ error: "Thumbnail generation failed — Chrome browser may not be available" },
500,
);
}
if (!existsSync(cacheDir)) mkdirSync(cacheDir, { recursive: true });
writeFileSync(cachePath, buffer);
return new Response(new Uint8Array(buffer), {
headers: { "Content-Type": contentType, "Cache-Control": "no-cache" },
});
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
return c.json({ error: `Thumbnail generation failed: ${msg}` }, 500);
}
});
}
@@ -0,0 +1,46 @@
import { existsSync, readFileSync, writeFileSync, mkdirSync } from "node:fs";
import { join } from "node:path";
import type { Hono } from "hono";
import type { StudioApiAdapter } from "../types.js";
import { decodeAudioPeaks, buildWaveformCacheKey } from "../helpers/waveform.js";
export function registerWaveformRoutes(api: Hono, adapter: StudioApiAdapter): void {
api.get("/projects/:id/waveform/*", async (c) => {
const project = await adapter.resolveProject(c.req.param("id"));
if (!project) return c.json({ error: "not found" }, 404);
const assetPath = decodeURIComponent(
c.req.path.replace(`/projects/${project.id}/waveform/`, "").split("?")[0] ?? "",
);
const audioPath = join(project.dir, assetPath);
if (!existsSync(audioPath)) return c.json({ error: "file not found" }, 404);
const cacheDir = join(project.dir, ".waveform-cache");
const cachePath = join(cacheDir, buildWaveformCacheKey(assetPath));
if (existsSync(cachePath)) {
try {
const peaks = JSON.parse(readFileSync(cachePath, "utf-8")) as number[];
return c.json({ peaks });
} catch {
// corrupt cache — regenerate
}
}
let peaks: number[];
try {
peaks = await decodeAudioPeaks(audioPath);
} catch {
return c.json({ error: "failed to decode audio" }, 500);
}
try {
mkdirSync(cacheDir, { recursive: true });
writeFileSync(cachePath, JSON.stringify(peaks));
} catch {
// cache write failure is non-fatal
}
return c.json({ peaks });
});
}
+206
View File
@@ -0,0 +1,206 @@
import type { CanvasResolution } from "@hyperframes/parsers";
import type { RegistryItem } from "@hyperframes/core";
/** Resolved info about a single project. */
export interface ResolvedProject {
id: string;
dir: string;
title?: string;
sessionId?: string;
}
/** Observable render job state, polled by the SSE progress handler. */
export interface RenderJobState {
id: string;
status: "rendering" | "complete" | "failed" | "cancelled";
progress: number;
stage?: string;
outputPath: string;
error?: string;
/**
* Optional abort hook set by the adapter. The cancel route calls this to
* stop an in-flight render; adapters that can't abort may omit it (the
* route still marks the job cancelled so the SSE stream terminates).
*/
cancel?: () => void;
}
export interface MediaProcessingJobState {
id: string;
status: "processing" | "complete" | "failed";
progress: number;
stage?: string;
inputAssetPath: string;
outputAssetPath: string;
outputPath: string;
backgroundOutputAssetPath?: string;
backgroundOutputPath?: string;
error?: string;
provider?: string;
framesProcessed?: number;
durationSeconds?: number;
avgMsPerFrame?: number;
}
/** Lint result from the core linter. */
export interface LintResult {
findings: Array<{
severity: string;
message: string;
file?: string;
fixHint?: string;
}>;
}
export interface StudioSelectionTextField {
key: string;
label: string;
value: string;
tagName: string;
source: "self" | "child" | "text-node";
}
export interface StudioSelectionSnapshot {
schemaVersion: 1;
projectId: string;
compositionPath: string;
sourceFile: string;
currentTime: number;
target: {
id?: string | null;
hfId?: string;
selector?: string;
selectorIndex?: number;
};
label: string;
tagName: string;
boundingBox: { x: number; y: number; width: number; height: number };
textContent: string | null;
dataAttributes: Record<string, string>;
inlineStyles: Record<string, string>;
computedStyles: Record<string, string>;
textFields: StudioSelectionTextField[];
capabilities: Record<string, boolean | string | undefined>;
thumbnailUrl: string;
}
export interface StudioSelectionResponse {
selection: StudioSelectionSnapshot | null;
updatedAt: string | null;
}
/**
* Adapter interface — injected by each consumer to handle host-specific behavior.
* The shared API module calls these methods; each host (vite dev, CLI embedded)
* provides its own implementation.
*/
export interface StudioApiAdapter {
/** List all available projects. */
listProjects(): Promise<ResolvedProject[]> | ResolvedProject[];
/** Resolve a project ID (or session ID) to its directory. Returns null if not found. */
resolveProject(id: string): Promise<ResolvedProject | null> | ResolvedProject | null;
/** 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;
/** URL to the hyperframe runtime JS (injected into preview HTML). */
runtimeUrl: string;
/**
* Optional: post-process preview HTML before Studio augments it.
* Useful when preview must mirror render-time compilation steps.
*/
transformPreviewHtml?: (opts: {
html: string;
project: ResolvedProject;
activeCompositionPath: string;
}) => Promise<string> | string;
/** Directory where render output files are stored. */
rendersDir(project: ResolvedProject): string;
/**
* Start a render job. The adapter owns the async execution and must
* update the returned RenderJobState object reactively.
*/
startRender(opts: {
project: ResolvedProject;
outputPath: string;
format: "mp4" | "webm" | "mov";
/**
* Frame rate as an exact rational. The HTTP layer (POST
* `/projects/:id/render`) accepts either a JSON number (integer fps,
* `30`) or a JSON string (ffmpeg-style rational, `"30000/1001"`); the
* route normalizes both into `Fps` before invoking the adapter, so
* adapter implementations only ever see the rational form.
*/
fps: import("@hyperframes/core").Fps;
quality: string;
jobId: string;
/**
* Optional output resolution preset. See `resolveDeviceScaleFactor` in
* the producer for the integer-scale + aspect + HDR constraints.
*/
outputResolution?: CanvasResolution;
/** Entry file relative to projectDir (e.g. "compositions/intro.html"). Defaults to index.html. */
composition?: string;
/**
* Composition-variable overrides ({variableId: value}), forwarded to the
* producer's RenderConfig.variables and injected as window.__hfVariables —
* the same channel `hyperframes render --variables` uses.
*/
variables?: Record<string, unknown>;
/**
* Telemetry id of the browser user who triggered the render. Lets the
* adapter attribute the server-emitted render_complete/render_error to
* that user so the studio render funnel is joinable. Undefined for older
* clients → falls back to the install's anonymous id.
*/
distinctId?: string;
}): RenderJobState;
startBackgroundRemoval?: (opts: {
project: ResolvedProject;
inputPath: string;
inputAssetPath: string;
outputPath: string;
outputAssetPath: string;
backgroundOutputPath?: string;
backgroundOutputAssetPath?: string;
quality: "fast" | "balanced" | "best";
device?: "auto" | "cpu" | "coreml" | "cuda";
jobId: string;
}) => MediaProcessingJobState;
/** Optional: generate a JPEG thumbnail via Puppeteer or similar. */
generateThumbnail?: (opts: {
project: ResolvedProject;
compPath: string;
seekTime: number;
width: number;
height: number;
previewUrl: string;
selector?: string;
format?: "jpeg" | "png";
selectorIndex?: number;
}) => Promise<Buffer | null>;
/** Optional: resolve session ID to project (multi-project mode). */
resolveSession?: (sessionId: string) => Promise<{ projectId: string; title: string } | null>;
/** Optional: list all registry items (blocks + components) for the catalog. */
listRegistryCatalog?(): Promise<RegistryItem[]>;
/** Optional: install a registry item into a project directory. */
installRegistryBlock?(opts: {
project: ResolvedProject;
blockName: string;
}): Promise<{ written: string[]; block: RegistryItem }>;
}
+18
View File
@@ -0,0 +1,18 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "bundler",
"strict": true,
"noUncheckedIndexedAccess": true,
"esModuleInterop": true,
"skipLibCheck": true,
"declaration": true,
"declarationMap": true,
"sourceMap": true,
"outDir": "./dist",
"rootDir": "./src"
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist", "**/*.test.ts"]
}
+22
View File
@@ -0,0 +1,22 @@
import { defineConfig } from "tsup";
export default defineConfig({
entry: {
index: "src/index.ts",
"helpers/screenshotClip": "src/helpers/screenshotClip.ts",
"helpers/manualEditsRenderScript": "src/helpers/manualEditsRenderScript.ts",
"helpers/studioMotionRenderScript": "src/helpers/studioMotionRenderScript.ts",
"helpers/draftMarkers": "src/helpers/draftMarkers.ts",
"helpers/finiteMutation": "src/helpers/finiteMutation.ts",
"helpers/sourceMutation": "src/helpers/sourceMutation.ts",
},
format: ["esm"],
outDir: "dist",
target: "node22",
platform: "node",
bundle: true,
splitting: false,
sourcemap: true,
clean: true,
dts: true,
});
+8
View File
@@ -0,0 +1,8 @@
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
include: ["src/**/*.test.ts"],
environment: "happy-dom",
},
});