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
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:
@@ -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, "&").replace(/"/g, """);
|
||||
}
|
||||
|
||||
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));
|
||||
}
|
||||
@@ -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
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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(/"/g, '"').replace(/&/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();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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 });
|
||||
});
|
||||
}
|
||||
@@ -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 }>;
|
||||
}
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
@@ -0,0 +1,8 @@
|
||||
import { defineConfig } from "vitest/config";
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
include: ["src/**/*.test.ts"],
|
||||
environment: "happy-dom",
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user