From 74d8025dcb9d9a7d711c314014994303bc84817d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Fri, 8 May 2026 12:18:37 -0700 Subject: [PATCH] feat(core): add studio animation preview APIs --- packages/core/package.json | 8 + .../helpers/manualEditsRenderScript.ts | 2 + .../studio-api/helpers/projectSignature.ts | 175 ++++++++++++ .../helpers/studioMotionRenderScript.test.ts | 205 ++++++++++++++ .../helpers/studioMotionRenderScript.ts | 260 ++++++++++++++++++ packages/core/src/studio-api/index.ts | 7 + .../src/studio-api/routes/preview.test.ts | 214 ++++++++++++++ .../core/src/studio-api/routes/preview.ts | 136 ++++++++- .../src/studio-api/routes/thumbnail.test.ts | 24 ++ .../core/src/studio-api/routes/thumbnail.ts | 12 +- packages/core/src/studio-api/types.ts | 3 + 11 files changed, 1040 insertions(+), 6 deletions(-) create mode 100644 packages/core/src/studio-api/helpers/projectSignature.ts create mode 100644 packages/core/src/studio-api/helpers/studioMotionRenderScript.test.ts create mode 100644 packages/core/src/studio-api/helpers/studioMotionRenderScript.ts create mode 100644 packages/core/src/studio-api/routes/preview.test.ts diff --git a/packages/core/package.json b/packages/core/package.json index 28c482e6a..76dd71725 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -46,6 +46,10 @@ "import": "./src/studio-api/helpers/manualEditsRenderScript.ts", "types": "./src/studio-api/helpers/manualEditsRenderScript.ts" }, + "./studio-api/studio-motion-render-script": { + "import": "./src/studio-api/helpers/studioMotionRenderScript.ts", + "types": "./src/studio-api/helpers/studioMotionRenderScript.ts" + }, "./text": { "import": "./src/text/index.ts", "types": "./src/text/index.ts" @@ -89,6 +93,10 @@ "import": "./dist/studio-api/helpers/manualEditsRenderScript.js", "types": "./dist/studio-api/helpers/manualEditsRenderScript.d.ts" }, + "./studio-api/studio-motion-render-script": { + "import": "./dist/studio-api/helpers/studioMotionRenderScript.js", + "types": "./dist/studio-api/helpers/studioMotionRenderScript.d.ts" + }, "./text": { "import": "./dist/text/index.js", "types": "./dist/text/index.d.ts" diff --git a/packages/core/src/studio-api/helpers/manualEditsRenderScript.ts b/packages/core/src/studio-api/helpers/manualEditsRenderScript.ts index 714b86de4..444bb9f0b 100644 --- a/packages/core/src/studio-api/helpers/manualEditsRenderScript.ts +++ b/packages/core/src/studio-api/helpers/manualEditsRenderScript.ts @@ -2,6 +2,8 @@ export interface StudioManualEditsRenderScriptOptions { activeCompositionPath?: string | null; } +export const STUDIO_MANUAL_EDITS_PATH = ".hyperframes/studio-manual-edits.json"; + export function createStudioManualEditsRenderBodyScript( manifestContent: string, options: StudioManualEditsRenderScriptOptions = {}, diff --git a/packages/core/src/studio-api/helpers/projectSignature.ts b/packages/core/src/studio-api/helpers/projectSignature.ts new file mode 100644 index 000000000..4062a1f14 --- /dev/null +++ b/packages/core/src/studio-api/helpers/projectSignature.ts @@ -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(); + +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; + 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; + 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; +} diff --git a/packages/core/src/studio-api/helpers/studioMotionRenderScript.test.ts b/packages/core/src/studio-api/helpers/studioMotionRenderScript.test.ts new file mode 100644 index 000000000..852ff56b5 --- /dev/null +++ b/packages/core/src/studio-api/helpers/studioMotionRenderScript.test.ts @@ -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; + to: Record; + at: number; + }>; + timeCalls: number[]; + customEaseCalls: Array<{ id: string; data: string }>; + killCalls: number; +} { + const state = { + calls: [] as Array<{ + target: HTMLElement; + from: Record; + to: Record; + at: number; + }>, + timeCalls: [] as number[], + customEaseCalls: [] as Array<{ id: string; data: string }>, + killCalls: 0, + }; + const timeline = { + fromTo( + target: HTMLElement, + from: Record, + to: Record, + 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) => 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 = '
'; + 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 }).__timelines?.[ + "studio-motion" + ], + ).toBeTruthy(); + }); + + it("does not mutate when GSAP is unavailable", () => { + const window = new Window(); + window.document.body.innerHTML = '
'; + 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 }).__timelines?.[ + "studio-motion" + ], + ).toBeUndefined(); + }); + + it("registers CustomEase data before adding Studio motion tweens", () => { + const window = new Window(); + window.document.body.innerHTML = '
'; + 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"); + }); +}); diff --git a/packages/core/src/studio-api/helpers/studioMotionRenderScript.ts b/packages/core/src/studio-api/helpers/studioMotionRenderScript.ts new file mode 100644 index 000000000..87d2b815e --- /dev/null +++ b/packages/core/src/studio-api/helpers/studioMotionRenderScript.ts @@ -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 | null => + value && typeof value === "object" ? (value as Record) : null; + + const finiteNumber = (value: unknown): number | null => + typeof value === "number" && Number.isFinite(value) ? value : null; + + const runtimeWindow = window as Window & { + gsap?: { + timeline?: (vars?: Record) => { + fromTo?: ( + target: HTMLElement, + from: Record, + to: Record, + at: number, + ) => unknown; + totalTime?: (time: number, suppressEvents?: boolean) => unknown; + time?: (time: number) => unknown; + pause?: () => unknown; + kill?: () => unknown; + }; + set?: (target: HTMLElement, vars: Record) => 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 | null => { + const record = objectRecord(value); + if (!record) return null; + const parsed: Record = {}; + 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): 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 => { + 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(); +} diff --git a/packages/core/src/studio-api/index.ts b/packages/core/src/studio-api/index.ts index 011c8b086..9ac338b30 100644 --- a/packages/core/src/studio-api/index.ts +++ b/packages/core/src/studio-api/index.ts @@ -1,10 +1,17 @@ export { createStudioApi } from "./createStudioApi.js"; +export { createProjectSignature } from "./helpers/projectSignature.js"; export type { StudioApiAdapter, ResolvedProject, RenderJobState, LintResult } from "./types.js"; export { isSafePath, walkDir } from "./helpers/safePath.js"; export { getMimeType, MIME_TYPES } from "./helpers/mime.js"; export { buildSubCompositionHtml } from "./helpers/subComposition.js"; export { getElementScreenshotClip, type ScreenshotClip } from "./helpers/screenshotClip.js"; export { + STUDIO_MANUAL_EDITS_PATH, createStudioManualEditsRenderBodyScript, type StudioManualEditsRenderScriptOptions, } from "./helpers/manualEditsRenderScript.js"; +export { + STUDIO_MOTION_PATH, + createStudioMotionRenderBodyScript, + type StudioMotionRenderScriptOptions, +} from "./helpers/studioMotionRenderScript.js"; diff --git a/packages/core/src/studio-api/routes/preview.test.ts b/packages/core/src/studio-api/routes/preview.test.ts new file mode 100644 index 000000000..1c5b1c952 --- /dev/null +++ b/packages/core/src/studio-api/routes/preview.test.ts @@ -0,0 +1,214 @@ +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"), "Preview"); + return projectDir; +} + +function createAdapter( + projectDir: string, + overrides: Partial = {}, +): 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 { + 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 = //.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"), + "
", + ); + 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"), + "
", + ); + 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 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"), + "", + ); + writeFileSync( + join(projectDir, "compositions/scene.html"), + ``, + ); + 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("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( + '', + ); + }); + + 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}$/); + }); +}); diff --git a/packages/core/src/studio-api/routes/preview.ts b/packages/core/src/studio-api/routes/preview.ts index 1713ee990..e802a84d2 100644 --- a/packages/core/src/studio-api/routes/preview.ts +++ b/packages/core/src/studio-api/routes/preview.ts @@ -1,10 +1,128 @@ import type { Hono } from "hono"; import { existsSync, readFileSync, statSync } from "node:fs"; -import { resolve } from "node:path"; +import { join, resolve } from "node:path"; +import { injectScriptsIntoHtml } from "../../compiler/htmlDocument.js"; import type { StudioApiAdapter } from "../types.js"; import { isSafePath } from "../helpers/safePath.js"; import { getMimeType } from "../helpers/mime.js"; import { buildSubCompositionHtml } from "../helpers/subComposition.js"; +import { createProjectSignature } from "../helpers/projectSignature.js"; +import { + createStudioMotionRenderBodyScript, + STUDIO_MOTION_PATH, +} from "../helpers/studioMotionRenderScript.js"; + +const PROJECT_SIGNATURE_META = "hyperframes-project-signature"; +const GSAP_CDN_VERSION = "3.15.0"; +const GSAP_CDN_SCRIPT = ``; +const GSAP_CUSTOM_EASE_CDN_SCRIPT = ``; + +function resolveProjectSignature(adapter: StudioApiAdapter, projectDir: string): string { + return adapter.getProjectSignature?.(projectDir) ?? createProjectSignature(projectDir); +} + +function injectProjectSignature(html: string, signature: string): string { + const tag = ``; + if (html.includes(`name="${PROJECT_SIGNATURE_META}"`)) { + return html.replace( + new RegExp(`]*>`, "i"), + tag, + ); + } + if (html.includes("")) return html.replace("", `${tag}\n`); + 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("")) return html.replace("", `${scriptTag}\n`); + return `${scriptTag}\n${html}`; +} + +function htmlHasGsap(html: string): boolean { + // Keep this heuristic conservative: if user source already loads GSAP, Studio does not add another copy. + return ( + /]*src=["'][^"']*gsap/i.test(html) || + /\/\*\s*inlined:.*gsap/i.test(html) || + /\b(GreenSock|_gsScope)\b/.test(html) || + /\bgsap\.(config|defaults|registerPlugin|version)\b/.test(html) + ); +} + +function htmlHasCustomEase(html: string): boolean { + return ( + /]*src=["'][^"']*CustomEase/i.test(html) || + /\bwindow\.CustomEase\b/.test(html) || + /\bCustomEase\s*=\s*/.test(html) + ); +} + +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, + ); +} + +function injectStudioPreviewAugmentations( + html: string, + adapter: StudioApiAdapter, + projectDir: string, + activeCompositionPath: string, +): string { + return injectStudioMotionScript( + injectProjectSignature(html, resolveProjectSignature(adapter, projectDir)), + projectDir, + activeCompositionPath, + ); +} export function registerPreviewRoutes(api: Hono, adapter: StudioApiAdapter): void { // Bundled composition preview @@ -37,10 +155,20 @@ export function registerPreviewRoutes(api: Hono, adapter: StudioApiAdapter): voi bundled = bundled.replace(//i, ``); } + bundled = injectStudioPreviewAugmentations(bundled, adapter, project.dir, "index.html"); return c.html(bundled); } catch { const file = resolve(project.dir, "index.html"); - if (existsSync(file)) return c.html(readFileSync(file, "utf-8")); + if (existsSync(file)) { + return c.html( + injectStudioPreviewAugmentations( + readFileSync(file, "utf-8"), + adapter, + project.dir, + "index.html", + ), + ); + } return c.text("not found", 404); } }); @@ -61,9 +189,9 @@ export function registerPreviewRoutes(api: Hono, adapter: StudioApiAdapter): voi return c.text("not found", 404); } const baseHref = `/api/projects/${project.id}/preview/`; - const html = buildSubCompositionHtml(project.dir, compPath, adapter.runtimeUrl, baseHref); + let html = buildSubCompositionHtml(project.dir, compPath, adapter.runtimeUrl, baseHref); if (!html) return c.text("not found", 404); - return c.html(html); + return c.html(injectStudioPreviewAugmentations(html, adapter, project.dir, compPath)); }); // Static asset serving (with range request support for audio/video seeking) diff --git a/packages/core/src/studio-api/routes/thumbnail.test.ts b/packages/core/src/studio-api/routes/thumbnail.test.ts index 0c2697aa8..1516b3950 100644 --- a/packages/core/src/studio-api/routes/thumbnail.test.ts +++ b/packages/core/src/studio-api/routes/thumbnail.test.ts @@ -174,4 +174,28 @@ describe("registerThumbnailRoutes", () => { 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, `
`); + 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); + }); }); diff --git a/packages/core/src/studio-api/routes/thumbnail.ts b/packages/core/src/studio-api/routes/thumbnail.ts index dd8a0aeb0..87290fb95 100644 --- a/packages/core/src/studio-api/routes/thumbnail.ts +++ b/packages/core/src/studio-api/routes/thumbnail.ts @@ -3,9 +3,10 @@ import { existsSync, readFileSync, writeFileSync, mkdirSync, statSync } from "no 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"; -const STUDIO_MANUAL_EDITS_PATH = ".hyperframes/studio-manual-edits.json"; export function registerThumbnailRoutes(api: Hono, adapter: StudioApiAdapter): void { api.get("/projects/:id/thumbnail/*", async (c) => { @@ -56,6 +57,13 @@ export function registerThumbnailRoutes(api: Hono, adapter: StudioApiAdapter): v 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" @@ -70,7 +78,7 @@ export function registerThumbnailRoutes(api: Hono, adapter: StudioApiAdapter): v const urlVersionKey = urlVersion ? `_${urlVersion.replace(/[^a-zA-Z0-9_-]+/g, "_").slice(0, 32)}` : ""; - const cacheKey = `${THUMBNAIL_CACHE_VERSION}${urlVersionKey}${manualEditsKey}_${format}_${compPath.replace(/\//g, "_")}_${compW}x${compH}_${sourceMtime}_${seekTime.toFixed(2)}${selectorKey}.${format === "png" ? "png" : "jpg"}`; + const cacheKey = `${THUMBNAIL_CACHE_VERSION}${urlVersionKey}${manualEditsKey}${motionKey}_${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)), { diff --git a/packages/core/src/studio-api/types.ts b/packages/core/src/studio-api/types.ts index 62c00eb2c..11d16239b 100644 --- a/packages/core/src/studio-api/types.ts +++ b/packages/core/src/studio-api/types.ts @@ -41,6 +41,9 @@ export interface StudioApiAdapter { /** Bundle a project directory into a single HTML string. Returns null if unavailable. */ bundle(projectDir: string): Promise; + /** 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;