diff --git a/.fallowrc.jsonc b/.fallowrc.jsonc index 22e9af259f..49ab642315 100644 --- a/.fallowrc.jsonc +++ b/.fallowrc.jsonc @@ -267,6 +267,23 @@ "packages/parsers/src/gsapWriterParity.acorn.test.ts", "packages/parsers/src/htmlParser.roundtrip.test.ts", "packages/parsers/src/htmlParser.test.ts", + // @hyperframes/studio-server test files: parallel arrange/act/assert test cases + // (pre-existing structure from when studio-api lived in packages/core/src/studio-api/). + "packages/studio-server/src/routes/files.test.ts", + "packages/studio-server/src/routes/render.test.ts", + "packages/studio-server/src/routes/lint.test.ts", + "packages/studio-server/src/routes/preview.test.ts", + "packages/studio-server/src/routes/projects.test.ts", + "packages/studio-server/src/helpers/backupJournal.test.ts", + "packages/studio-server/src/helpers/finiteMutation.test.ts", + "packages/studio-server/src/helpers/hfIdPersist.test.ts", + "packages/studio-server/src/helpers/manualEditsRenderScript.test.ts", + "packages/studio-server/src/helpers/mediaValidation.test.ts", + "packages/studio-server/src/helpers/previewAdapter.test.ts", + "packages/studio-server/src/helpers/safePath.test.ts", + "packages/studio-server/src/helpers/sourceMutation.test.ts", + "packages/studio-server/src/helpers/studioMotionRenderScript.test.ts", + "packages/studio-server/src/helpers/subComposition.test.ts", // @hyperframes/lint rule test files: parallel arrange/act/assert test cases // (pre-existing structure from when lint lived in packages/core/src/lint/). "packages/lint/src/rules/adapters.test.ts", @@ -321,8 +338,15 @@ "packages/parsers/src/gsapParser.ts", // htmlParser.ts has pre-existing complexity (moved from packages/core). "packages/parsers/src/htmlParser.ts", - // executeGsapMutation (CRITICAL) pre-dates this PR; studio-api still lives in core. - "packages/core/src/studio-api/routes/files.ts", + // studio-server files: pre-existing complexity (moved from packages/core/src/studio-api/). + // files.ts: executeGsapMutationRecast/Acorn are CRITICAL; excluded as files.ts + // was already in health.ignore at the old path (packages/core/src/studio-api/routes/files.ts). + "packages/studio-server/src/routes/files.ts", + "packages/studio-server/src/routes/render.ts", + "packages/studio-server/src/routes/thumbnail.ts", + "packages/studio-server/src/helpers/manualEditsRenderScript.ts", + "packages/studio-server/src/helpers/studioMotionRenderScript.ts", + "packages/studio-server/src/helpers/subComposition.ts", // lint rule implementations and project linter: pre-existing complexity // (moved from packages/core/src/lint/). File-level exemption avoids the // line-shift fingerprint problem for inherited findings. @@ -351,12 +375,15 @@ // from @hyperframes/core/* subpaths to the new packages). Their complexity // is pre-existing; the line-shift fingerprint problem makes fallow treat // the violations as new even though no logic changed. + "packages/cli/src/server/studioServer.ts", "packages/core/src/core.types.ts", "packages/core/src/generators/hyperframes.ts", + "packages/producer/src/services/htmlCompiler.ts", "packages/studio/src/hooks/gsapRuntimeBridge.ts", "packages/studio/src/hooks/gsapShared.ts", "packages/studio/src/hooks/gsapDragPositionCommit.ts", "packages/studio/src/hooks/gsapKeyframeCacheHelpers.ts", + "packages/studio/vite.config.ts", "packages/cli/src/commands/lint.ts", "packages/cli/src/commands/preview.ts", "packages/cli/src/commands/publish.ts", diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 77e6fa1c0e..2ba539c1d9 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -228,6 +228,7 @@ jobs: - run: bun install --frozen-lockfile - run: bun run test:scripts - run: bun run --filter '@hyperframes/parsers' build + - run: bun run --filter '@hyperframes/studio-server' build - run: bun run --cwd packages/core build - run: bun run --cwd packages/core build:hyperframes-runtime - run: bun run --filter '!@hyperframes/producer' test @@ -358,8 +359,10 @@ jobs: - uses: ./.github/actions/prepare-ffmpeg-bin - run: bun install --frozen-lockfile # Build workspace deps so the studio vite.config.ts (loaded by Node) can - # resolve @hyperframes/core via the "node" export condition (dist). + # resolve @hyperframes/core and @hyperframes/studio-server via the "node" + # export condition (dist). - run: bun run --filter '@hyperframes/parsers' build + - run: bun run --filter '@hyperframes/studio-server' build - run: bun run --cwd packages/core build - run: bun run --cwd packages/core build:hyperframes-runtime - name: Start studio and check for runtime errors diff --git a/.github/workflows/preview-regression.yml b/.github/workflows/preview-regression.yml index 02e39db660..b9aaf38663 100644 --- a/.github/workflows/preview-regression.yml +++ b/.github/workflows/preview-regression.yml @@ -37,6 +37,7 @@ jobs: preview: - "packages/core/**" - "packages/parsers/**" + - "packages/studio-server/**" - "packages/lint/**" - "packages/player/**" - "packages/studio/**" @@ -77,13 +78,13 @@ jobs: - name: Build workspace packages (required for vite config loading) run: | - bun run --filter '@hyperframes/{parsers,lint}' build + bun run --filter '@hyperframes/{parsers,lint,studio-server}' build bun run --cwd packages/core build - name: Run Studio preview routing regression run: | bun run --cwd packages/studio test -- vite.thumbnail.test.ts src/utils/projectRouting.test.ts src/utils/frameCapture.test.ts - bun run --cwd packages/core test -- src/studio-api/routes/thumbnail.test.ts + bun run --cwd packages/studio-server test -- src/routes/thumbnail.test.ts - name: Build preview runtime run: bun run --cwd packages/core build:hyperframes-runtime diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index e12a14e47c..d1671c8ed6 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -126,6 +126,7 @@ jobs: publish_pkg "@hyperframes/parsers" "@hyperframes/parsers" publish_pkg "@hyperframes/lint" "@hyperframes/lint" + publish_pkg "@hyperframes/studio-server" "@hyperframes/studio-server" publish_pkg "@hyperframes/core" "@hyperframes/core" publish_pkg "@hyperframes/sdk" "@hyperframes/sdk" publish_pkg "@hyperframes/engine" "@hyperframes/engine" diff --git a/Dockerfile.test b/Dockerfile.test index 2320ac902d..faf73ba45b 100644 --- a/Dockerfile.test +++ b/Dockerfile.test @@ -76,6 +76,7 @@ ENV PATH="/root/.bun/bin:$PATH" COPY package.json bun.lock ./ COPY packages/parsers/package.json packages/parsers/package.json COPY packages/lint/package.json packages/lint/package.json +COPY packages/studio-server/package.json packages/studio-server/package.json COPY packages/core/package.json packages/core/package.json COPY packages/engine/package.json packages/engine/package.json COPY packages/player/package.json packages/player/package.json @@ -92,12 +93,13 @@ RUN bun install --frozen-lockfile # Copy source COPY packages/parsers/ packages/parsers/ COPY packages/lint/ packages/lint/ +COPY packages/studio-server/ packages/studio-server/ COPY packages/core/ packages/core/ COPY packages/engine/ packages/engine/ COPY packages/producer/ packages/producer/ # Build workspace packages so "node" export conditions resolve to built dist -RUN bun run --filter '@hyperframes/{parsers,lint}' build \ +RUN bun run --filter '@hyperframes/{parsers,lint,studio-server}' build \ && bun run --cwd packages/core build # Build core runtime artifacts (needed by renderer) diff --git a/bun.lock b/bun.lock index 1dfc2b8cab..41f53da468 100644 --- a/bun.lock +++ b/bun.lock @@ -22,7 +22,7 @@ }, "packages/aws-lambda": { "name": "@hyperframes/aws-lambda", - "version": "0.7.13", + "version": "0.7.14", "dependencies": { "@aws-sdk/client-s3": "^3.700.0", "@aws-sdk/client-sfn": "^3.700.0", @@ -54,7 +54,7 @@ }, "packages/cli": { "name": "@hyperframes/cli", - "version": "0.7.13", + "version": "0.7.14", "bin": { "hyperframes": "./dist/cli.js", }, @@ -85,6 +85,7 @@ "@hyperframes/lint": "workspace:*", "@hyperframes/producer": "workspace:*", "@hyperframes/studio": "workspace:*", + "@hyperframes/studio-server": "workspace:*", "@types/adm-zip": "^0.5.7", "@types/fontkit": "^2.0.9", "@types/mime-types": "^3.0.1", @@ -102,15 +103,15 @@ }, "packages/core": { "name": "@hyperframes/core", - "version": "0.7.13", + "version": "0.7.14", "dependencies": { "@chenglou/pretext": "^0.0.5", "@hyperframes/lint": "workspace:*", "@hyperframes/parsers": "workspace:*", + "@hyperframes/studio-server": "workspace:*", "bpm-detective": "^2.0.5", "linkedom": "^0.18.12", "postcss": "^8.5.8", - "postcss-selector-parser": "^7.1.2", }, "devDependencies": { "@types/jsdom": "^28.0.0", @@ -124,16 +125,10 @@ "optionalDependencies": { "esbuild": "^0.25.12", }, - "peerDependencies": { - "hono": "^4.0.0", - }, - "optionalPeers": [ - "hono", - ], }, "packages/engine": { "name": "@hyperframes/engine", - "version": "0.7.13", + "version": "0.7.14", "dependencies": { "@hono/node-server": "^1.13.0", "@hyperframes/core": "workspace:^", @@ -151,7 +146,7 @@ }, "packages/gcp-cloud-run": { "name": "@hyperframes/gcp-cloud-run", - "version": "0.7.13", + "version": "0.7.14", "dependencies": { "@google-cloud/storage": "^7.14.0", "@google-cloud/workflows": "^4.2.0", @@ -207,7 +202,7 @@ }, "packages/player": { "name": "@hyperframes/player", - "version": "0.7.13", + "version": "0.7.14", "dependencies": { "@hyperframes/core": "workspace:*", }, @@ -222,7 +217,7 @@ }, "packages/producer": { "name": "@hyperframes/producer", - "version": "0.7.13", + "version": "0.7.14", "dependencies": { "@fontsource/archivo-black": "^5.2.8", "@fontsource/eb-garamond": "^5.2.7", @@ -239,6 +234,7 @@ "@hyperframes/core": "workspace:^", "@hyperframes/engine": "workspace:^", "@hyperframes/lint": "workspace:^", + "@hyperframes/studio-server": "workspace:^", "hono": "^4.6.0", "linkedom": "^0.18.12", "postcss": "^8.4.0", @@ -264,7 +260,7 @@ }, "packages/sdk": { "name": "@hyperframes/sdk", - "version": "0.7.13", + "version": "0.7.14", "dependencies": { "@hyperframes/core": "workspace:*", "@hyperframes/parsers": "workspace:*", @@ -290,7 +286,7 @@ }, "packages/shader-transitions": { "name": "@hyperframes/shader-transitions", - "version": "0.7.13", + "version": "0.7.14", "dependencies": { "html2canvas": "^1.4.1", }, @@ -302,7 +298,7 @@ }, "packages/studio": { "name": "@hyperframes/studio", - "version": "0.7.13", + "version": "0.7.14", "dependencies": { "@codemirror/autocomplete": "^6.20.1", "@codemirror/commands": "^6.10.3", @@ -319,6 +315,7 @@ "@hyperframes/parsers": "workspace:*", "@hyperframes/player": "workspace:*", "@hyperframes/sdk": "workspace:*", + "@hyperframes/studio-server": "workspace:*", "@phosphor-icons/react": "^2.1.10", "bpm-detective": "^2.0.5", "dompurify": "^3.2.4", @@ -346,6 +343,25 @@ "zustand": "^4.0.0 || ^5.0.0", }, }, + "packages/studio-server": { + "name": "@hyperframes/studio-server", + "version": "0.7.11", + "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", + }, + }, }, "overrides": { "@types/react": "^19.0.0", @@ -720,6 +736,8 @@ "@hyperframes/studio": ["@hyperframes/studio@workspace:packages/studio"], + "@hyperframes/studio-server": ["@hyperframes/studio-server@workspace:packages/studio-server"], + "@img/colour": ["@img/colour@1.1.0", "", {}, "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ=="], "@img/sharp-darwin-arm64": ["@img/sharp-darwin-arm64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-arm64": "1.2.4" }, "os": "darwin", "cpu": "arm64" }, "sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w=="], diff --git a/package.json b/package.json index 6751dd3a85..420e7831f6 100644 --- a/package.json +++ b/package.json @@ -11,7 +11,7 @@ "type": "module", "scripts": { "dev": "bun run studio", - "build": "bun run --filter '@hyperframes/{parsers,lint}' build && bun run --filter @hyperframes/core build && bun run --filter '@hyperframes/{core,engine,producer,player,studio,shader-transitions,aws-lambda,gcp-cloud-run,sdk}' build && bun run --filter @hyperframes/cli build", + "build": "bun run --filter '@hyperframes/{parsers,lint,studio-server}' build && bun run --filter @hyperframes/core build && bun run --filter '@hyperframes/{core,engine,producer,player,studio,shader-transitions,aws-lambda,gcp-cloud-run,sdk}' build && bun run --filter @hyperframes/cli build", "build:producer": "bun run --filter @hyperframes/producer build", "studio": "bun run --filter @hyperframes/studio dev", "build:hyperframes-runtime": "bun run --filter @hyperframes/core build:hyperframes-runtime", diff --git a/packages/cli/package.json b/packages/cli/package.json index 5c8e474508..209edd4539 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -53,6 +53,7 @@ "@hyperframes/lint": "workspace:*", "@hyperframes/producer": "workspace:*", "@hyperframes/studio": "workspace:*", + "@hyperframes/studio-server": "workspace:*", "@types/adm-zip": "^0.5.7", "@types/fontkit": "^2.0.9", "@types/mime-types": "^3.0.1", diff --git a/packages/cli/src/server/studioServer.ts b/packages/cli/src/server/studioServer.ts index 3b284cee72..8fe1d4dd14 100644 --- a/packages/cli/src/server/studioServer.ts +++ b/packages/cli/src/server/studioServer.ts @@ -25,9 +25,9 @@ import { type StudioApiAdapter, type ResolvedProject, type RenderJobState, -} from "@hyperframes/core/studio-api"; -import { getElementScreenshotClip } from "@hyperframes/core/studio-api/screenshot-clip"; -import type { ScreenshotClip } from "@hyperframes/core/studio-api/screenshot-clip"; +} from "@hyperframes/studio-server"; +import { getElementScreenshotClip } from "@hyperframes/studio-server/screenshot-clip"; +import type { ScreenshotClip } from "@hyperframes/studio-server/screenshot-clip"; import type { RenderJob } from "@hyperframes/producer"; const STUDIO_MANUAL_EDITS_PATH = ".hyperframes/studio-manual-edits.json"; diff --git a/packages/cli/tsup.config.ts b/packages/cli/tsup.config.ts index b34f45d030..f3aba1848e 100644 --- a/packages/cli/tsup.config.ts +++ b/packages/cli/tsup.config.ts @@ -62,6 +62,7 @@ var __dirname = __hf_dirname(__filename);`, noExternal: [ "@hyperframes/core", "@hyperframes/parsers", + "@hyperframes/studio-server", "@hyperframes/lint", "@hyperframes/producer", "@hyperframes/engine", diff --git a/packages/core/package.json b/packages/core/package.json index 14a4cbf749..0d51a784fc 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -355,10 +355,10 @@ "@chenglou/pretext": "^0.0.5", "@hyperframes/lint": "workspace:*", "@hyperframes/parsers": "workspace:*", + "@hyperframes/studio-server": "workspace:*", "bpm-detective": "^2.0.5", "linkedom": "^0.18.12", - "postcss": "^8.5.8", - "postcss-selector-parser": "^7.1.2" + "postcss": "^8.5.8" }, "devDependencies": { "@types/jsdom": "^28.0.0", @@ -369,14 +369,6 @@ "typescript": "^5.0.0", "vitest": "^3.2.4" }, - "peerDependencies": { - "hono": "^4.0.0" - }, - "peerDependenciesMeta": { - "hono": { - "optional": true - } - }, "optionalDependencies": { "esbuild": "^0.25.12" } diff --git a/packages/core/src/studio-api/helpers/draftMarkers.ts b/packages/core/src/studio-api/helpers/draftMarkers.ts index 38bc61861f..4484439394 100644 --- a/packages/core/src/studio-api/helpers/draftMarkers.ts +++ b/packages/core/src/studio-api/helpers/draftMarkers.ts @@ -1,10 +1,2 @@ -/** - * 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"; +/** @deprecated Import from @hyperframes/studio-server/draft-markers */ +export * from "@hyperframes/studio-server/draft-markers"; diff --git a/packages/core/src/studio-api/helpers/finiteMutation.ts b/packages/core/src/studio-api/helpers/finiteMutation.ts index 1e145e4fae..82611bf430 100644 --- a/packages/core/src/studio-api/helpers/finiteMutation.ts +++ b/packages/core/src/studio-api/helpers/finiteMutation.ts @@ -1,38 +1,2 @@ -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), - }); -} +/** @deprecated Import from @hyperframes/studio-server/finite-mutation */ +export * from "@hyperframes/studio-server/finite-mutation"; diff --git a/packages/core/src/studio-api/helpers/manualEditsRenderScript.ts b/packages/core/src/studio-api/helpers/manualEditsRenderScript.ts index 2d6dd39aa5..d086ba8bb1 100644 --- a/packages/core/src/studio-api/helpers/manualEditsRenderScript.ts +++ b/packages/core/src/studio-api/helpers/manualEditsRenderScript.ts @@ -1,735 +1,2 @@ -// 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): 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) => Record; - set?: (el: HTMLElement, vars: Record) => void; - registerPlugin?: (plugin: unknown) => void; - }; - CustomEase?: { create?: (id: string, data: string) => void }; - __timelines?: Record>; - }; - 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, - to: Record, - 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; - 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; - 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; - __player?: Record; - }; - - const isWrapped = (fn: (time: number) => unknown): boolean => - Boolean((fn as unknown as Record)[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)[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 | 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 | 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 | null => - value && typeof value === "object" ? (value as Record) : 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): 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): 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): 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): 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)[WRAPPED_SEEK_PROP] = true; - } catch { - // Ignore non-extensible functions. - } - } - }; - - const isWrapped = (fn: (time: number) => unknown): boolean => - Boolean((fn as unknown as Record)[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); -} +/** @deprecated Import from @hyperframes/studio-server/manual-edits-render-script */ +export * from "@hyperframes/studio-server/manual-edits-render-script"; diff --git a/packages/core/src/studio-api/helpers/screenshotClip.ts b/packages/core/src/studio-api/helpers/screenshotClip.ts index a1db59033e..5c47610345 100644 --- a/packages/core/src/studio-api/helpers/screenshotClip.ts +++ b/packages/core/src/studio-api/helpers/screenshotClip.ts @@ -1,31 +1,2 @@ -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)), - }; -} +/** @deprecated Import from @hyperframes/studio-server/screenshot-clip */ +export * from "@hyperframes/studio-server/screenshot-clip"; diff --git a/packages/core/src/studio-api/helpers/studioMotionRenderScript.ts b/packages/core/src/studio-api/helpers/studioMotionRenderScript.ts index 87d2b815ef..81b0803f74 100644 --- a/packages/core/src/studio-api/helpers/studioMotionRenderScript.ts +++ b/packages/core/src/studio-api/helpers/studioMotionRenderScript.ts @@ -1,260 +1,2 @@ -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(); -} +/** @deprecated Import from @hyperframes/studio-server/studio-motion-render-script */ +export * from "@hyperframes/studio-server/studio-motion-render-script"; diff --git a/packages/core/src/studio-api/index.ts b/packages/core/src/studio-api/index.ts index bbc77f69d6..9e3e76b6a7 100644 --- a/packages/core/src/studio-api/index.ts +++ b/packages/core/src/studio-api/index.ts @@ -1,18 +1,2 @@ -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, - createStudioPositionSeekReapplyScript, - type StudioManualEditsRenderScriptOptions, -} from "./helpers/manualEditsRenderScript.js"; -export { - STUDIO_MOTION_PATH, - createStudioMotionRenderBodyScript, - type StudioMotionRenderScriptOptions, -} from "./helpers/studioMotionRenderScript.js"; +/** @deprecated Import from @hyperframes/studio-server */ +export * from "@hyperframes/studio-server"; diff --git a/packages/producer/package.json b/packages/producer/package.json index 972ff59d7b..5f7f13d74f 100644 --- a/packages/producer/package.json +++ b/packages/producer/package.json @@ -71,6 +71,7 @@ "@hyperframes/core": "workspace:^", "@hyperframes/engine": "workspace:^", "@hyperframes/lint": "workspace:^", + "@hyperframes/studio-server": "workspace:^", "hono": "^4.6.0", "linkedom": "^0.18.12", "postcss": "^8.4.0", diff --git a/packages/producer/src/services/htmlCompiler.ts b/packages/producer/src/services/htmlCompiler.ts index 6048bc8a6f..a141f08f7f 100644 --- a/packages/producer/src/services/htmlCompiler.ts +++ b/packages/producer/src/services/htmlCompiler.ts @@ -40,7 +40,7 @@ import { assertPublicHttpsUrl, downloadToTemp, isHttpUrl } from "../utils/urlDow import type { Page } from "puppeteer-core"; import { injectDeterministicFontFaces } from "./deterministicFonts.js"; import { prepareAnimatedGifInputs } from "./animatedGifPrep.js"; -import { createStudioPositionSeekReapplyScript } from "@hyperframes/core/studio-api/manual-edits-render-script"; +import { createStudioPositionSeekReapplyScript } from "@hyperframes/studio-server/manual-edits-render-script"; import { defaultLogger, type ProducerLogger } from "../logger.js"; export interface CompiledComposition { diff --git a/packages/studio-server/package.json b/packages/studio-server/package.json new file mode 100644 index 0000000000..25edf894b2 --- /dev/null +++ b/packages/studio-server/package.json @@ -0,0 +1,109 @@ +{ + "name": "@hyperframes/studio-server", + "version": "0.7.11", + "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" + } + }, + "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" + } + }, + "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" + } +} diff --git a/packages/core/src/studio-api/createStudioApi.ts b/packages/studio-server/src/createStudioApi.ts similarity index 100% rename from packages/core/src/studio-api/createStudioApi.ts rename to packages/studio-server/src/createStudioApi.ts diff --git a/packages/core/src/studio-api/helpers/backupJournal.test.ts b/packages/studio-server/src/helpers/backupJournal.test.ts similarity index 100% rename from packages/core/src/studio-api/helpers/backupJournal.test.ts rename to packages/studio-server/src/helpers/backupJournal.test.ts diff --git a/packages/core/src/studio-api/helpers/backupJournal.ts b/packages/studio-server/src/helpers/backupJournal.ts similarity index 100% rename from packages/core/src/studio-api/helpers/backupJournal.ts rename to packages/studio-server/src/helpers/backupJournal.ts diff --git a/packages/studio-server/src/helpers/draftMarkers.ts b/packages/studio-server/src/helpers/draftMarkers.ts new file mode 100644 index 0000000000..38bc61861f --- /dev/null +++ b/packages/studio-server/src/helpers/draftMarkers.ts @@ -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"; diff --git a/packages/studio-server/src/helpers/finiteMutation.test.ts b/packages/studio-server/src/helpers/finiteMutation.test.ts new file mode 100644 index 0000000000..df2df70afc --- /dev/null +++ b/packages/studio-server/src/helpers/finiteMutation.test.ts @@ -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" }]); + }); +}); diff --git a/packages/studio-server/src/helpers/finiteMutation.ts b/packages/studio-server/src/helpers/finiteMutation.ts new file mode 100644 index 0000000000..1e145e4fae --- /dev/null +++ b/packages/studio-server/src/helpers/finiteMutation.ts @@ -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), + }); +} diff --git a/packages/core/src/studio-api/helpers/hfIdPersist.test.ts b/packages/studio-server/src/helpers/hfIdPersist.test.ts similarity index 100% rename from packages/core/src/studio-api/helpers/hfIdPersist.test.ts rename to packages/studio-server/src/helpers/hfIdPersist.test.ts diff --git a/packages/core/src/studio-api/helpers/hfIdPersist.ts b/packages/studio-server/src/helpers/hfIdPersist.ts similarity index 100% rename from packages/core/src/studio-api/helpers/hfIdPersist.ts rename to packages/studio-server/src/helpers/hfIdPersist.ts diff --git a/packages/studio-server/src/helpers/manualEditsRenderScript.test.ts b/packages/studio-server/src/helpers/manualEditsRenderScript.test.ts new file mode 100644 index 0000000000..f052a6d2c2 --- /dev/null +++ b/packages/studio-server/src/helpers/manualEditsRenderScript.test.ts @@ -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 = '
'; + 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 = ` +
+
+
+
+
+
+ `; + 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 = ` +
+
+
+
+
+
+ `; + 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 = `
`; + 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 = `
`; + 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 = ` +
+ `; + 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 = `
`; + 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 = ` +
+
+ `; + 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 }).__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 = ` +
+
+ `; + 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 }).__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 = ` +
+
+ `; + 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 }).__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 = ` +
+
+ `; + 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 }).__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 = ` +
+
+ `; + const card = window.document.getElementById("card") as unknown as HTMLElement; + + (window as unknown as { __hf: Record }).__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 = ` +
+
+ `; + 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 }).__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); + }); +}); diff --git a/packages/studio-server/src/helpers/manualEditsRenderScript.ts b/packages/studio-server/src/helpers/manualEditsRenderScript.ts new file mode 100644 index 0000000000..2d6dd39aa5 --- /dev/null +++ b/packages/studio-server/src/helpers/manualEditsRenderScript.ts @@ -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): 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) => Record; + set?: (el: HTMLElement, vars: Record) => void; + registerPlugin?: (plugin: unknown) => void; + }; + CustomEase?: { create?: (id: string, data: string) => void }; + __timelines?: Record>; + }; + 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, + to: Record, + 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; + 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; + 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; + __player?: Record; + }; + + const isWrapped = (fn: (time: number) => unknown): boolean => + Boolean((fn as unknown as Record)[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)[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 | 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 | 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 | null => + value && typeof value === "object" ? (value as Record) : 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): 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): 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): 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): 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)[WRAPPED_SEEK_PROP] = true; + } catch { + // Ignore non-extensible functions. + } + } + }; + + const isWrapped = (fn: (time: number) => unknown): boolean => + Boolean((fn as unknown as Record)[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); +} diff --git a/packages/core/src/studio-api/helpers/mediaValidation.test.ts b/packages/studio-server/src/helpers/mediaValidation.test.ts similarity index 100% rename from packages/core/src/studio-api/helpers/mediaValidation.test.ts rename to packages/studio-server/src/helpers/mediaValidation.test.ts diff --git a/packages/core/src/studio-api/helpers/mediaValidation.ts b/packages/studio-server/src/helpers/mediaValidation.ts similarity index 100% rename from packages/core/src/studio-api/helpers/mediaValidation.ts rename to packages/studio-server/src/helpers/mediaValidation.ts diff --git a/packages/core/src/studio-api/helpers/mime.ts b/packages/studio-server/src/helpers/mime.ts similarity index 100% rename from packages/core/src/studio-api/helpers/mime.ts rename to packages/studio-server/src/helpers/mime.ts diff --git a/packages/core/src/studio-api/helpers/previewAdapter.test.ts b/packages/studio-server/src/helpers/previewAdapter.test.ts similarity index 100% rename from packages/core/src/studio-api/helpers/previewAdapter.test.ts rename to packages/studio-server/src/helpers/previewAdapter.test.ts diff --git a/packages/core/src/studio-api/helpers/previewAdapter.ts b/packages/studio-server/src/helpers/previewAdapter.ts similarity index 100% rename from packages/core/src/studio-api/helpers/previewAdapter.ts rename to packages/studio-server/src/helpers/previewAdapter.ts diff --git a/packages/core/src/studio-api/helpers/projectSignature.ts b/packages/studio-server/src/helpers/projectSignature.ts similarity index 100% rename from packages/core/src/studio-api/helpers/projectSignature.ts rename to packages/studio-server/src/helpers/projectSignature.ts diff --git a/packages/core/src/studio-api/helpers/safePath.test.ts b/packages/studio-server/src/helpers/safePath.test.ts similarity index 100% rename from packages/core/src/studio-api/helpers/safePath.test.ts rename to packages/studio-server/src/helpers/safePath.test.ts diff --git a/packages/core/src/studio-api/helpers/safePath.ts b/packages/studio-server/src/helpers/safePath.ts similarity index 95% rename from packages/core/src/studio-api/helpers/safePath.ts rename to packages/studio-server/src/helpers/safePath.ts index de1d909844..688eb2386e 100644 --- a/packages/core/src/studio-api/helpers/safePath.ts +++ b/packages/studio-server/src/helpers/safePath.ts @@ -4,7 +4,7 @@ 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 "../../safePath.js"; +export { isSafePath, resolveWithinProject } from "@hyperframes/core"; const IGNORE_DIRS = new Set([".thumbnails", "node_modules", ".git"]); diff --git a/packages/studio-server/src/helpers/screenshotClip.ts b/packages/studio-server/src/helpers/screenshotClip.ts new file mode 100644 index 0000000000..a1db59033e --- /dev/null +++ b/packages/studio-server/src/helpers/screenshotClip.ts @@ -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)), + }; +} diff --git a/packages/core/src/studio-api/helpers/sourceMutation.test.ts b/packages/studio-server/src/helpers/sourceMutation.test.ts similarity index 100% rename from packages/core/src/studio-api/helpers/sourceMutation.test.ts rename to packages/studio-server/src/helpers/sourceMutation.test.ts diff --git a/packages/core/src/studio-api/helpers/sourceMutation.ts b/packages/studio-server/src/helpers/sourceMutation.ts similarity index 99% rename from packages/core/src/studio-api/helpers/sourceMutation.ts rename to packages/studio-server/src/helpers/sourceMutation.ts index 727cd326a2..14951aaf33 100644 --- a/packages/core/src/studio-api/helpers/sourceMutation.ts +++ b/packages/studio-server/src/helpers/sourceMutation.ts @@ -1,7 +1,7 @@ import { parseHTML } from "linkedom"; import postcss from "postcss"; import selectorParser from "postcss-selector-parser"; -import { isAllowedHtmlAttribute, isSafeAttributeValue } from "../../utils/htmlAttrSafety"; +import { isAllowedHtmlAttribute, isSafeAttributeValue } from "@hyperframes/core/html-attr-safety"; export interface SourceMutationTarget { id?: string | null; diff --git a/packages/studio-server/src/helpers/studioMotionRenderScript.test.ts b/packages/studio-server/src/helpers/studioMotionRenderScript.test.ts new file mode 100644 index 0000000000..852ff56b54 --- /dev/null +++ b/packages/studio-server/src/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/studio-server/src/helpers/studioMotionRenderScript.ts b/packages/studio-server/src/helpers/studioMotionRenderScript.ts new file mode 100644 index 0000000000..87d2b815ef --- /dev/null +++ b/packages/studio-server/src/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/helpers/subComposition.test.ts b/packages/studio-server/src/helpers/subComposition.test.ts similarity index 100% rename from packages/core/src/studio-api/helpers/subComposition.test.ts rename to packages/studio-server/src/helpers/subComposition.test.ts diff --git a/packages/core/src/studio-api/helpers/subComposition.ts b/packages/studio-server/src/helpers/subComposition.ts similarity index 99% rename from packages/core/src/studio-api/helpers/subComposition.ts rename to packages/studio-server/src/helpers/subComposition.ts index 5695de7f3c..eb5ca69031 100644 --- a/packages/core/src/studio-api/helpers/subComposition.ts +++ b/packages/studio-server/src/helpers/subComposition.ts @@ -5,8 +5,8 @@ import { rewriteAssetPaths, rewriteCssAssetUrls, rewriteInlineStyleAssetUrls, -} from "../../compiler/rewriteSubCompPaths.js"; -import { stripEmbeddedRuntimeScripts } from "../../compiler/htmlDocument.js"; +} from "@hyperframes/core"; +import { stripEmbeddedRuntimeScripts } from "@hyperframes/core/compiler"; /** * Detect whether `html` is a full document (has ``, ``, or diff --git a/packages/core/src/studio-api/helpers/waveform.ts b/packages/studio-server/src/helpers/waveform.ts similarity index 100% rename from packages/core/src/studio-api/helpers/waveform.ts rename to packages/studio-server/src/helpers/waveform.ts diff --git a/packages/studio-server/src/index.ts b/packages/studio-server/src/index.ts new file mode 100644 index 0000000000..bbc77f69d6 --- /dev/null +++ b/packages/studio-server/src/index.ts @@ -0,0 +1,18 @@ +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, + createStudioPositionSeekReapplyScript, + 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/files.test.ts b/packages/studio-server/src/routes/files.test.ts similarity index 100% rename from packages/core/src/studio-api/routes/files.test.ts rename to packages/studio-server/src/routes/files.test.ts diff --git a/packages/core/src/studio-api/routes/files.ts b/packages/studio-server/src/routes/files.ts similarity index 99% rename from packages/core/src/studio-api/routes/files.ts rename to packages/studio-server/src/routes/files.ts index 18f0ff6484..c628d49c6e 100644 --- a/packages/core/src/studio-api/routes/files.ts +++ b/packages/studio-server/src/routes/files.ts @@ -1708,7 +1708,7 @@ export function registerFileRoutes(api: Hono, adapter: StudioApiAdapter): void { let block = extractGsapScriptBlock(html); if (!block && (body.type === "add" || body.type === "add-with-keyframes")) { const compId = html.match(/data-composition-id="([^"]+)"/)?.[1] ?? "main"; - const { GSAP_CDN } = await import("../../templates/constants.js"); + const { GSAP_CDN } = await import("@hyperframes/core"); const gsapCdn = ``; const bootstrap = [ gsapCdn, diff --git a/packages/core/src/studio-api/routes/fonts.ts b/packages/studio-server/src/routes/fonts.ts similarity index 99% rename from packages/core/src/studio-api/routes/fonts.ts rename to packages/studio-server/src/routes/fonts.ts index ac92aa8ddb..9698ac324a 100644 --- a/packages/core/src/studio-api/routes/fonts.ts +++ b/packages/studio-server/src/routes/fonts.ts @@ -6,7 +6,7 @@ import { getSystemProfilerFamilies, locateSystemFont, SYSTEM_FONT_SIZE_LIMIT, -} from "../../fonts/systemFontLocator"; +} from "@hyperframes/core/fonts/system-locator"; const MAX_FONT_RESULTS = 2000; const GOOGLE_FONTS_METADATA_URL = "https://fonts.google.com/metadata/fonts"; diff --git a/packages/core/src/studio-api/routes/lint.test.ts b/packages/studio-server/src/routes/lint.test.ts similarity index 100% rename from packages/core/src/studio-api/routes/lint.test.ts rename to packages/studio-server/src/routes/lint.test.ts diff --git a/packages/core/src/studio-api/routes/lint.ts b/packages/studio-server/src/routes/lint.ts similarity index 100% rename from packages/core/src/studio-api/routes/lint.ts rename to packages/studio-server/src/routes/lint.ts diff --git a/packages/core/src/studio-api/routes/preview.test.ts b/packages/studio-server/src/routes/preview.test.ts similarity index 100% rename from packages/core/src/studio-api/routes/preview.test.ts rename to packages/studio-server/src/routes/preview.test.ts diff --git a/packages/core/src/studio-api/routes/preview.ts b/packages/studio-server/src/routes/preview.ts similarity index 99% rename from packages/core/src/studio-api/routes/preview.ts rename to packages/studio-server/src/routes/preview.ts index fb6e11dc97..ab53883d43 100644 --- a/packages/core/src/studio-api/routes/preview.ts +++ b/packages/studio-server/src/routes/preview.ts @@ -1,7 +1,7 @@ import type { Hono } from "hono"; import { existsSync, readFileSync, statSync } from "node:fs"; import { join } from "node:path"; -import { injectScriptsIntoHtml, stripEmbeddedRuntimeScripts } from "../../compiler/htmlDocument.js"; +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"; diff --git a/packages/core/src/studio-api/routes/projects.test.ts b/packages/studio-server/src/routes/projects.test.ts similarity index 100% rename from packages/core/src/studio-api/routes/projects.test.ts rename to packages/studio-server/src/routes/projects.test.ts diff --git a/packages/core/src/studio-api/routes/projects.ts b/packages/studio-server/src/routes/projects.ts similarity index 100% rename from packages/core/src/studio-api/routes/projects.ts rename to packages/studio-server/src/routes/projects.ts diff --git a/packages/core/src/studio-api/routes/registry.ts b/packages/studio-server/src/routes/registry.ts similarity index 100% rename from packages/core/src/studio-api/routes/registry.ts rename to packages/studio-server/src/routes/registry.ts diff --git a/packages/core/src/studio-api/routes/render.test.ts b/packages/studio-server/src/routes/render.test.ts similarity index 99% rename from packages/core/src/studio-api/routes/render.test.ts rename to packages/studio-server/src/routes/render.test.ts index fca63003b4..461419b2d3 100644 --- a/packages/core/src/studio-api/routes/render.test.ts +++ b/packages/studio-server/src/routes/render.test.ts @@ -3,7 +3,7 @@ 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 "../../core.types"; +import { VALID_CANVAS_RESOLUTIONS } from "@hyperframes/parsers"; import { registerRenderRoutes } from "./render"; import type { StudioApiAdapter } from "../types"; diff --git a/packages/core/src/studio-api/routes/render.ts b/packages/studio-server/src/routes/render.ts similarity index 98% rename from packages/core/src/studio-api/routes/render.ts rename to packages/studio-server/src/routes/render.ts index 1435148c0d..3cd4bb68e7 100644 --- a/packages/core/src/studio-api/routes/render.ts +++ b/packages/studio-server/src/routes/render.ts @@ -3,7 +3,8 @@ 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, parseFps, type CanvasResolution } from "../../core.types.js"; +import { VALID_CANVAS_RESOLUTIONS, type CanvasResolution } from "@hyperframes/parsers"; +import { parseFps } from "@hyperframes/core"; import { resolveWithinProject } from "../helpers/safePath.js"; const VALID_RESOLUTIONS = new Set(VALID_CANVAS_RESOLUTIONS); diff --git a/packages/core/src/studio-api/routes/storyboard.test.ts b/packages/studio-server/src/routes/storyboard.test.ts similarity index 100% rename from packages/core/src/studio-api/routes/storyboard.test.ts rename to packages/studio-server/src/routes/storyboard.test.ts diff --git a/packages/core/src/studio-api/routes/storyboard.ts b/packages/studio-server/src/routes/storyboard.ts similarity index 98% rename from packages/core/src/studio-api/routes/storyboard.ts rename to packages/studio-server/src/routes/storyboard.ts index 1f60c7ac47..874204208b 100644 --- a/packages/core/src/studio-api/routes/storyboard.ts +++ b/packages/studio-server/src/routes/storyboard.ts @@ -7,7 +7,7 @@ import { SCRIPT_FILENAME, STORYBOARD_FILENAME, type StoryboardFrame, -} from "../../storyboard/index.js"; +} from "@hyperframes/core/storyboard"; /** A frame enriched with disk-resolution info the Studio needs to render tiles. */ interface ResolvedStoryboardFrame extends StoryboardFrame { diff --git a/packages/core/src/studio-api/routes/thumbnail.test.ts b/packages/studio-server/src/routes/thumbnail.test.ts similarity index 100% rename from packages/core/src/studio-api/routes/thumbnail.test.ts rename to packages/studio-server/src/routes/thumbnail.test.ts diff --git a/packages/core/src/studio-api/routes/thumbnail.ts b/packages/studio-server/src/routes/thumbnail.ts similarity index 100% rename from packages/core/src/studio-api/routes/thumbnail.ts rename to packages/studio-server/src/routes/thumbnail.ts diff --git a/packages/core/src/studio-api/routes/waveform.ts b/packages/studio-server/src/routes/waveform.ts similarity index 100% rename from packages/core/src/studio-api/routes/waveform.ts rename to packages/studio-server/src/routes/waveform.ts diff --git a/packages/core/src/studio-api/types.ts b/packages/studio-server/src/types.ts similarity index 96% rename from packages/core/src/studio-api/types.ts rename to packages/studio-server/src/types.ts index eb553d2e59..27a6bc5ddb 100644 --- a/packages/core/src/studio-api/types.ts +++ b/packages/studio-server/src/types.ts @@ -1,5 +1,5 @@ -import type { CanvasResolution } from "../core.types.js"; -import type { RegistryItem } from "../registry/types.js"; +import type { CanvasResolution } from "@hyperframes/parsers"; +import type { RegistryItem } from "@hyperframes/core"; /** Resolved info about a single project. */ export interface ResolvedProject { @@ -81,7 +81,7 @@ export interface StudioApiAdapter { * route normalizes both into `Fps` before invoking the adapter, so * adapter implementations only ever see the rational form. */ - fps: import("../core.types.js").Fps; + fps: import("@hyperframes/core").Fps; quality: string; jobId: string; /** diff --git a/packages/studio-server/tsconfig.json b/packages/studio-server/tsconfig.json new file mode 100644 index 0000000000..96f38bce00 --- /dev/null +++ b/packages/studio-server/tsconfig.json @@ -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"] +} diff --git a/packages/studio-server/tsup.config.ts b/packages/studio-server/tsup.config.ts new file mode 100644 index 0000000000..53aeca203f --- /dev/null +++ b/packages/studio-server/tsup.config.ts @@ -0,0 +1,21 @@ +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", + }, + format: ["esm"], + outDir: "dist", + target: "node22", + platform: "node", + bundle: true, + splitting: false, + sourcemap: true, + clean: true, + dts: true, +}); diff --git a/packages/studio-server/vitest.config.ts b/packages/studio-server/vitest.config.ts new file mode 100644 index 0000000000..269f285ce4 --- /dev/null +++ b/packages/studio-server/vitest.config.ts @@ -0,0 +1,8 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + include: ["src/**/*.test.ts"], + environment: "happy-dom", + }, +}); diff --git a/packages/studio/package.json b/packages/studio/package.json index db7881ce4d..335d2a82b5 100644 --- a/packages/studio/package.json +++ b/packages/studio/package.json @@ -58,6 +58,7 @@ "@hyperframes/parsers": "workspace:*", "@hyperframes/player": "workspace:*", "@hyperframes/sdk": "workspace:*", + "@hyperframes/studio-server": "workspace:*", "@phosphor-icons/react": "^2.1.10", "bpm-detective": "^2.0.5", "dompurify": "^3.2.4", diff --git a/packages/studio/src/components/editor/manualEditsTypes.ts b/packages/studio/src/components/editor/manualEditsTypes.ts index f540711824..40c5b673b3 100644 --- a/packages/studio/src/components/editor/manualEditsTypes.ts +++ b/packages/studio/src/components/editor/manualEditsTypes.ts @@ -5,7 +5,7 @@ export { STUDIO_WIDTH_PROP, STUDIO_HEIGHT_PROP, STUDIO_MANUAL_EDIT_GESTURE_ATTR, -} from "@hyperframes/core/studio-api/draft-markers"; +} from "@hyperframes/studio-server/draft-markers"; export const STUDIO_ROTATION_PROP = "--hf-studio-rotation"; /* ── Internal DOM attribute names ─────────────────────────────────── */ diff --git a/packages/studio/src/utils/sdkCutoverParity.test.ts b/packages/studio/src/utils/sdkCutoverParity.test.ts index 0245b3eae4..302ae417d1 100644 --- a/packages/studio/src/utils/sdkCutoverParity.test.ts +++ b/packages/studio/src/utils/sdkCutoverParity.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "vitest"; import { openComposition } from "@hyperframes/sdk"; -import { patchElementInHtml } from "../../../core/src/studio-api/helpers/sourceMutation.js"; +import { patchElementInHtml } from "../../../studio-server/src/helpers/sourceMutation.js"; import type { PatchOperation } from "./sourcePatcher"; import { patchOpsToSdkEditOps } from "./sdkOpMapping"; diff --git a/packages/studio/vite.adapter.ts b/packages/studio/vite.adapter.ts index 277d092e21..3a5c68647e 100644 --- a/packages/studio/vite.adapter.ts +++ b/packages/studio/vite.adapter.ts @@ -15,9 +15,9 @@ import { type ResolvedProject, type RenderJobState, type StudioApiAdapter, -} from "@hyperframes/core/studio-api"; + createProjectSignature, +} from "@hyperframes/studio-server"; import type { RegistryItem } from "@hyperframes/core/registry"; -import { createProjectSignature } from "../core/src/studio-api/helpers/projectSignature"; import { createRetryingModuleLoader, ensureProducerDist } from "./vite.producer"; import { createStudioDevRenderBodyScripts } from "./vite.studioMotion"; import { generateThumbnail, findSystemChrome } from "./vite.browser"; diff --git a/packages/studio/vite.config.ts b/packages/studio/vite.config.ts index 0bf939a7cb..4e31c81836 100644 --- a/packages/studio/vite.config.ts +++ b/packages/studio/vite.config.ts @@ -65,7 +65,7 @@ function devProjectApi(): Plugin { let _api: { fetch: (req: Request) => Promise } | null = null; const getApi = async () => { if (!_api) { - const mod = await server.ssrLoadModule("@hyperframes/core/studio-api"); + const mod = await server.ssrLoadModule("@hyperframes/studio-server"); const adapter = createViteAdapter(dataDir, server); _api = mod.createStudioApi(adapter); } @@ -188,7 +188,7 @@ export default defineConfig({ }, ssr: { // recast / @babel/parser are CommonJS and call `require("fs")`. They are - // reachable only server-side via the Node-only `@hyperframes/core/gsap-parser` + // reachable only server-side via the Node-only `@hyperframes/parsers/gsap-parser` // subpath (studio-api GSAP mutations + the linter), which the dev server loads // through Vite SSR. Externalizing them makes SSR load the native Node modules // instead of esbuild-transforming the `require` into a shim that throws diff --git a/scripts/set-version.ts b/scripts/set-version.ts index b2c021f0c3..99175f7a0f 100644 --- a/scripts/set-version.ts +++ b/scripts/set-version.ts @@ -23,6 +23,7 @@ import { CLI_SEMVER_PATTERN } from "./cli-options.ts"; const PACKAGES = [ "packages/parsers", "packages/lint", + "packages/studio-server", "packages/core", "packages/engine", "packages/player",