diff --git a/.gitignore b/.gitignore index 211ff22fb..21053dc04 100644 --- a/.gitignore +++ b/.gitignore @@ -72,7 +72,9 @@ tmp/ .tmp/ # Generated files -packages/core/src/generated/ +packages/core/src/generated/* +!packages/core/src/generated/position-edits-render-inline.ts +!packages/core/src/generated/position-edits-render-inline.test.ts packages/producer/src/services/fontData.generated.ts # Plan documents (working artifacts, not committed) diff --git a/packages/core/package.json b/packages/core/package.json index e4f9ea6a7..70d9edc8e 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -119,6 +119,12 @@ "import": "./src/runtime/positionEdits.ts", "types": "./src/runtime/positionEdits.ts" }, + "./runtime/position-edits-render": { + "bun": "./src/generated/position-edits-render-inline.ts", + "node": "./dist/generated/position-edits-render-inline.js", + "import": "./src/generated/position-edits-render-inline.ts", + "types": "./src/generated/position-edits-render-inline.ts" + }, "./runtime/lottie-readiness": { "bun": "./src/lottieReadiness.ts", "node": "./dist/lottieReadiness.js", @@ -309,6 +315,10 @@ "import": "./dist/runtime/positionEdits.js", "types": "./dist/runtime/positionEdits.d.ts" }, + "./runtime/position-edits-render": { + "import": "./dist/generated/position-edits-render-inline.js", + "types": "./dist/generated/position-edits-render-inline.d.ts" + }, "./runtime/lottie-readiness": { "import": "./dist/lottieReadiness.js", "types": "./dist/lottieReadiness.d.ts" @@ -388,13 +398,14 @@ "types": "./dist/index.d.ts" }, "scripts": { - "build": "bun run build:hyperframes-runtime && tsc && tsx scripts/rewrite-esm-extensions.ts", + "build": "bun run build:hyperframes-runtime && bun run build:position-edits-render && tsc && tsx scripts/rewrite-esm-extensions.ts", "test": "vitest run", "test:watch": "vitest", "test:coverage": "vitest run --coverage", "typecheck": "tsc --noEmit", "lint:runtime-preview-guards": "tsx scripts/lint-runtime-preview-guards.ts", "build:hyperframes-runtime": "tsx scripts/build-hyperframes-runtime-artifact.ts", + "build:position-edits-render": "tsx scripts/build-position-edits-render.ts", "build:hyperframes-runtime:modular": "SANDBOX_RUNTIME_VARIANT=modular tsx scripts/build-hyperframes-runtime-artifact.ts", "build:hyperframe-runtime": "tsx scripts/build-hyperframes-runtime-artifact.ts", "test:hyperframe-runtime-contract": "tsx scripts/test-hyperframe-runtime-contract.ts", diff --git a/packages/core/scripts/build-position-edits-render.ts b/packages/core/scripts/build-position-edits-render.ts new file mode 100644 index 000000000..8e4d25be7 --- /dev/null +++ b/packages/core/scripts/build-position-edits-render.ts @@ -0,0 +1,52 @@ +/** Build the injectable position-edits render artifact from the canonical runtime. */ + +import { mkdirSync, writeFileSync } from "node:fs"; +import { dirname, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import { buildSync } from "esbuild"; +import { execSync } from "node:child_process"; + +const thisDir = dirname(fileURLToPath(import.meta.url)); +const repoRoot = resolve(thisDir, ".."); +const entry = resolve(repoRoot, "stubs/position-edits-render-entry.ts"); +const generatedDir = resolve(repoRoot, "src/generated"); +const outPath = resolve(generatedDir, "position-edits-render-inline.ts"); + +const result = buildSync({ + entryPoints: [entry], + bundle: true, + write: false, + platform: "browser", + format: "iife", + target: ["es2020"], + minify: true, + legalComments: "none", +}); +const iife = result.outputFiles[0]?.text ?? ""; +if (!iife) throw new Error("esbuild produced no output for position-edits-render-entry.ts"); + +mkdirSync(generatedDir, { recursive: true }); +writeFileSync( + outPath, + [ + "// AUTO-GENERATED by scripts/build-position-edits-render.ts - do not edit", + `const POSITION_EDITS_RENDER_IIFE: string = ${JSON.stringify(iife)};`, + "", + "/** Returns the pre-built position-edits render IIFE as a string constant. */", + "export function getPositionEditsRenderScript(): string {", + " return POSITION_EDITS_RENDER_IIFE;", + "}", + "", + ].join("\n"), + "utf8", +); + +try { + execSync(`bunx oxfmt ${outPath}`, { stdio: "ignore" }); +} catch { + // Formatting is best effort when the generator runs in a minimal environment. +} + +console.log( + JSON.stringify({ event: "position_edits_render_generated", outPath, bytes: iife.length }), +); diff --git a/packages/core/src/generated/position-edits-render-inline.test.ts b/packages/core/src/generated/position-edits-render-inline.test.ts new file mode 100644 index 000000000..bd09b90f5 --- /dev/null +++ b/packages/core/src/generated/position-edits-render-inline.test.ts @@ -0,0 +1,23 @@ +import { describe, expect, it } from "vitest"; +import { getPositionEditsRenderScript } from "./position-edits-render-inline"; + +describe("getPositionEditsRenderScript", () => { + it("returns a non-empty IIFE string built from the real algorithm", () => { + const script = getPositionEditsRenderScript(); + expect(script.length).toBeGreaterThan(0); + expect(script).toContain("data-hf-edit-base-x"); + }); + + it("is a safe no-op without position-edit markers", () => { + document.body.innerHTML = "

no edits

"; + expect(() => new Function(getPositionEditsRenderScript())()).not.toThrow(); + expect(document.querySelector("h1")?.style.getPropertyValue("translate")).toBe(""); + }); + + it("applies the translate delta when markers are present", () => { + document.body.innerHTML = + '

hi

'; + new Function(getPositionEditsRenderScript())(); + expect(document.querySelector("h1")?.style.getPropertyValue("translate")).toBe("10px 0px"); + }); +}); diff --git a/packages/core/src/generated/position-edits-render-inline.ts b/packages/core/src/generated/position-edits-render-inline.ts new file mode 100644 index 000000000..967ce4a9c --- /dev/null +++ b/packages/core/src/generated/position-edits-render-inline.ts @@ -0,0 +1,8 @@ +// AUTO-GENERATED by scripts/build-position-edits-render.ts - do not edit +const POSITION_EDITS_RENDER_IIFE: string = + '"use strict";(()=>{function g(){return globalThis}function w(t,e){if(typeof window>"u")return;let n=g(),o=n.__hf?.onSwallowed;if(o)try{o({label:t,error:e})}catch(r){}(n.__hfDebug||n.__HYPERFRAMES_DEBUG)&&console.debug(`[hyperframes] ${t} swallowed:`,e)}var E=null;function h(t,e){if(E)try{E({source:"hf-preview",type:"analytics",event:t,properties:e??{}})}catch(n){w("runtime.analytics.site1",n)}}var l="data-hf-edit-base-x",p="data-hf-edit-base-y",f="data-hf-edit-original-translate",u=t=>{let e=parseFloat(t??"");return Number.isFinite(e)?e:0},$=t=>{let e=[],n=0,o="";for(let r of t.trim())r==="("&&(n+=1),r===")"&&(n=Math.max(0,n-1)),/\\s/.test(r)&&n===0?(o&&e.push(o),o=""):o+=r;return o&&e.push(o),e},k=/^-?(?:\\d+(?:\\.\\d+)?|\\.\\d+)px$/,_=(t,e)=>k.test(t)&&k.test(e)?`${parseFloat(t)+parseFloat(e)}px`:`calc(${t} + ${e})`,D=(t,e,n)=>{if(!t||t==="none")return`${e} ${n}`;let[o,r,i]=$(t);if(o===void 0)return`${e} ${n}`;if(r===void 0)return`${_(o,e)} ${n}`;let s=i===void 0?"":` ${i}`;return`${_(o,e)} ${_(r,n)}${s}`},L=t=>{try{t.ownerDocument.defaultView?.gsap?.getProperty?.(t,"x")}catch{}},M=t=>{let e=t.style.getPropertyValue("translate").trim();if(e)return e==="none"?"":e;try{let n=t.ownerDocument.defaultView,o=n?n.getComputedStyle(t).getPropertyValue("translate").trim():"";return o==="none"?"":o}catch{return""}},A=new WeakMap;function H(t,e){let n=A.get(t);if(!e?.force&&n!==void 0&&t.style.getPropertyValue("translate")!==n){h("position_edit_fold_skipped",{hfId:t.getAttribute("data-hf-id")});return}let o=u(t.getAttribute("data-x"))-u(t.getAttribute(l)),r=u(t.getAttribute("data-y"))-u(t.getAttribute(p));t.hasAttribute(f)||t.setAttribute(f,M(t)),n===void 0&&L(t);let i=t.getAttribute(f)??"",s=D(i,`${o}px`,`${r}px`);t.style.setProperty("translate",s),A.set(t,t.style.getPropertyValue("translate"))}function y(t,e){let n=t.querySelectorAll(`[${l}], [${p}]`),o=t.defaultView?.HTMLElement,r=0;for(let i=0;i{try{y(e.document,{force:!0})}catch{}},o=a=>typeof a=="function"&&!!a[T],r=a=>{try{Object.defineProperty(a,T,{value:!0})}catch{}},i=(a,P)=>{let d=a();if(typeof d!="function"||o(d))return;let m=function(...R){let x=d.apply(this,R);return n(),x};r(m),P(m),n()},s=()=>{i(()=>e.__hf?.seek,a=>{e.__hf&&(e.__hf.seek=a)}),i(()=>e.__player?.renderSeek,a=>{e.__player&&(e.__player.renderSeek=a)})};s();let c=120,S=e.setInterval(()=>{s(),c-=1,c<=0&&e.clearInterval(S)},50)}function b(){document.querySelector(`[${l}], [${p}]`)&&(y(document),v(window))}document.readyState==="loading"?document.addEventListener("DOMContentLoaded",b,{once:!0}):b();})();\n'; + +/** Returns the pre-built position-edits render IIFE as a string constant. */ +export function getPositionEditsRenderScript(): string { + return POSITION_EDITS_RENDER_IIFE; +} diff --git a/packages/core/src/runtime/init.positionEdits.test.ts b/packages/core/src/runtime/init.positionEdits.test.ts new file mode 100644 index 000000000..631aa9a29 --- /dev/null +++ b/packages/core/src/runtime/init.positionEdits.test.ts @@ -0,0 +1,24 @@ +import { describe, expect, it } from "vitest"; +import { installPositionEditsSeekReapply } from "./positionEdits"; + +describe("init.ts per-seek position-edit parity", () => { + it("reapplies the position edit after renderSeek", () => { + document.body.innerHTML = + '

hi

'; + const h1 = document.querySelector("h1"); + if (!(h1 instanceof HTMLElement)) throw new Error("test element missing"); + + // @ts-expect-error test global + window.__player = { + renderSeek: () => h1.style.setProperty("translate", "none"), + }; + installPositionEditsSeekReapply(window as Window & typeof globalThis); + // @ts-expect-error test global + window.__player.renderSeek(1); + + expect(h1.style.getPropertyValue("translate")).toBe("20px 0px"); + // @ts-expect-error test global + delete window.__player; + h1.remove(); + }); +}); diff --git a/packages/core/src/runtime/init.ts b/packages/core/src/runtime/init.ts index 618d815e2..38f1dfc58 100644 --- a/packages/core/src/runtime/init.ts +++ b/packages/core/src/runtime/init.ts @@ -29,7 +29,7 @@ import { createRuntimeStartTimeResolver } from "./startResolver"; import { createClipTree } from "./clipTree"; import { loadExternalCompositions, loadInlineTemplateCompositions } from "./compositionLoader"; import { applyCaptionOverrides } from "./captionOverrides"; -import { applyPositionEdits } from "./positionEdits"; +import { applyPositionEdits, installPositionEditsSeekReapply } from "./positionEdits"; import { applyVariableBindings } from "./applyVariableBindings"; import { createColorGradingRuntime, type RuntimeColorGradingApi } from "./colorGrading"; import { TransportClock } from "./clock"; @@ -2987,6 +2987,8 @@ export function initSandboxRuntimeModular(): void { } } + installPositionEditsSeekReapply(window as Window & typeof globalThis); + // Start the rAF tick loop state.transportRafId = window.requestAnimationFrame(transportTick); postTimeline(); diff --git a/packages/core/src/runtime/positionEdits.test.ts b/packages/core/src/runtime/positionEdits.test.ts index 5354a3673..22cdfbde7 100644 --- a/packages/core/src/runtime/positionEdits.test.ts +++ b/packages/core/src/runtime/positionEdits.test.ts @@ -6,6 +6,7 @@ import { applyPositionEditToElement, applyPositionEdits, composeTranslate, + installPositionEditsSeekReapply, } from "./positionEdits"; function makeElement(attrs: Record, style = ""): HTMLElement { @@ -174,3 +175,45 @@ describe("applyPositionEdits", () => { el.remove(); }); }); + +describe("installPositionEditsSeekReapply", () => { + it("wraps __player.renderSeek so each call reapplies position edits", () => { + const el = makeElement({ "data-x": "10", "data-y": "0", "data-hf-edit-base-x": "0" }); + const calls: number[] = []; + // @ts-expect-error test global + window.__player = { renderSeek: (time: number) => calls.push(time) }; + + installPositionEditsSeekReapply(window as Window & typeof globalThis); + // @ts-expect-error test global + window.__player.renderSeek(1.5); + + expect(calls).toEqual([1.5]); + expect(el.style.getPropertyValue("translate")).toBe("10px 0px"); + // @ts-expect-error test global + delete window.__player; + el.remove(); + }); + + it("is idempotent when installed twice", () => { + const el = makeElement({ "data-x": "5", "data-y": "0", "data-hf-edit-base-x": "0" }); + const calls: number[] = []; + // @ts-expect-error test global + window.__player = { renderSeek: (time: number) => calls.push(time) }; + + installPositionEditsSeekReapply(window as Window & typeof globalThis); + installPositionEditsSeekReapply(window as Window & typeof globalThis); + // @ts-expect-error test global + window.__player.renderSeek(2); + + expect(calls).toEqual([2]); + // @ts-expect-error test global + delete window.__player; + el.remove(); + }); + + it("does not throw when neither seek global exists", () => { + expect(() => + installPositionEditsSeekReapply(window as Window & typeof globalThis), + ).not.toThrow(); + }); +}); diff --git a/packages/core/src/runtime/positionEdits.ts b/packages/core/src/runtime/positionEdits.ts index 512fb6d25..49e3a64ea 100644 --- a/packages/core/src/runtime/positionEdits.ts +++ b/packages/core/src/runtime/positionEdits.ts @@ -154,7 +154,7 @@ export function applyPositionEditToElement(el: HTMLElement, opts?: { force?: boo * Apply all pending position edits in the document. Returns the number of * elements updated. */ -export function applyPositionEdits(doc: Document): number { +export function applyPositionEdits(doc: Document, opts?: { force?: boolean }): number { const marked = doc.querySelectorAll(`[${EDIT_BASE_X_ATTR}], [${EDIT_BASE_Y_ATTR}]`); // Not `instanceof HTMLElement`: `doc` is frequently an iframe's document (the // SDK's edit preview, a host embedding a composition), and its elements are @@ -170,8 +170,80 @@ export function applyPositionEdits(doc: Document): number { ? el instanceof RealmHTMLElement : typeof (el as HTMLElement).style?.setProperty === "function"; if (!isStylable) continue; - applyPositionEditToElement(el as HTMLElement); + applyPositionEditToElement(el as HTMLElement, opts); applied += 1; } return applied; } + +const SEEK_REAPPLY_WRAPPED = "__hfPositionEditsSeekReapplyWrapped"; + +type SeekWindow = Window & + typeof globalThis & { + __hf?: { seek?: (...args: unknown[]) => unknown }; + __player?: { renderSeek?: (...args: unknown[]) => unknown }; + }; + +/** Reapply SDK position edits after every render seek, including late-bound seeks. */ +export function installPositionEditsSeekReapply(win: Window & typeof globalThis): void { + const target = win as SeekWindow; + const reapply = (): void => { + try { + applyPositionEdits(target.document, { force: true }); + } catch { + // A position edit must never break the render seek path. + } + }; + + const isWrapped = (fn: unknown): fn is (...args: unknown[]) => unknown => + typeof fn === "function" && + Boolean((fn as { [SEEK_REAPPLY_WRAPPED]?: boolean })[SEEK_REAPPLY_WRAPPED]); + + const markWrapped = (fn: (...args: unknown[]) => unknown): void => { + try { + Object.defineProperty(fn, SEEK_REAPPLY_WRAPPED, { value: true }); + } catch { + // Frozen functions cannot be marked; the wrapper itself is still valid. + } + }; + + const wrapOne = ( + get: () => unknown, + set: (fn: (...args: unknown[]) => unknown) => void, + ): void => { + const fn = get(); + if (typeof fn !== "function") return; + if (isWrapped(fn)) return; + const wrapped = function (this: unknown, ...args: unknown[]): unknown { + const result = fn.apply(this, args); + reapply(); + return result; + }; + markWrapped(wrapped); + set(wrapped); + reapply(); + }; + + const wrapAll = (): void => { + wrapOne( + () => target.__hf?.seek, + (fn) => { + if (target.__hf) target.__hf.seek = fn; + }, + ); + wrapOne( + () => target.__player?.renderSeek, + (fn) => { + if (target.__player) target.__player.renderSeek = fn; + }, + ); + }; + + wrapAll(); + let remaining = 120; + const interval = target.setInterval(() => { + wrapAll(); + remaining -= 1; + if (remaining <= 0) target.clearInterval(interval); + }, 50); +} diff --git a/packages/core/stubs/position-edits-render-entry.ts b/packages/core/stubs/position-edits-render-entry.ts new file mode 100644 index 000000000..4dadc161e --- /dev/null +++ b/packages/core/stubs/position-edits-render-entry.ts @@ -0,0 +1,20 @@ +import { + EDIT_BASE_X_ATTR, + EDIT_BASE_Y_ATTR, + applyPositionEdits, + installPositionEditsSeekReapply, +} from "../src/runtime/positionEdits"; + +function start(): void { + if (!document.querySelector(`[${EDIT_BASE_X_ATTR}], [${EDIT_BASE_Y_ATTR}]`)) { + return; + } + applyPositionEdits(document); + installPositionEditsSeekReapply(window); +} + +if (document.readyState === "loading") { + document.addEventListener("DOMContentLoaded", start, { once: true }); +} else { + start(); +} diff --git a/packages/producer/src/services/htmlCompiler.test.ts b/packages/producer/src/services/htmlCompiler.test.ts index 5b41203a3..2fa6573dd 100644 --- a/packages/producer/src/services/htmlCompiler.test.ts +++ b/packages/producer/src/services/htmlCompiler.test.ts @@ -7,6 +7,7 @@ import { parseHTML } from "linkedom"; import { collectExternalAssets, compileForRender, + injectSdkPositionEditsRenderScript, detectRenderModeHints, detectShaderTransitionUsage, detectThreeDTransformUsage, @@ -19,6 +20,28 @@ import { } from "./htmlCompiler.js"; import { validateNoSystemFonts } from "./render/planValidation.js"; +describe("injectSdkPositionEditsRenderScript", () => { + it("injects before when SDK position-edit markers are present", () => { + const html = + '

Hi

'; + const out = injectSdkPositionEditsRenderScript(html); + expect(out).toContain("`; + const bodyClose = html.search(/<\/body\s*>/i); + if (bodyClose < 0) return `${html}${script}`; + return `${html.slice(0, bodyClose)}${script}${html.slice(bodyClose)}`; +} + /** * Thrown by {@link assertSubCompositionsUsable} when one or more * `data-composition-src` references resolve to a missing, empty, or @@ -1730,6 +1741,7 @@ export async function compileForRender( ``, ) : assembledHtml; + const htmlWithSdkPositionScript = injectSdkPositionEditsRenderScript(htmlWithPositionScript); // Download remote