Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions docs/docs.json
Original file line number Diff line number Diff line change
Expand Up @@ -914,6 +914,7 @@
"concepts/data-attributes",
"guides/gsap-animation",
"guides/frame-sources",
"guides/imported-film-html",
"concepts/frame-adapters",
"concepts/determinism",
"guides/html-in-canvas",
Expand Down
32 changes: 32 additions & 0 deletions docs/guides/imported-film-html.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
---
title: "Imported film HTML"
description: "Connect preserved film-runner HTML to HyperFrames capture."
---

The runtime supports the `appifact-film:` message protocol used by current Claude Motion HTML exports. Keep the original `film-runner` HTML and scene modules intact. The MCP importer packages them with a HyperFrames wrapper; HyperFrames controls time and captures the resulting pixels using its existing renderer.

Create and mount an iframe with `sandbox="allow-scripts"`, explicit picture dimensions, and no player controls. Give its timed host, and a root without a GSAP timeline, `data-no-timeline` as described in Frame sources. Then connect it before assigning any runner HTML yourself:

```javascript
const bridge = window.__hyperframes.createFilmBridge({
iframe: document.getElementById("film-stage"),
runnerHtml: preservedRunnerHtml,
load: { script, modules, assets, look, faces },
});
const unregister = window.__hyperframes.registerFrameSource({
element: document.getElementById("film-clip"),
ready: bridge.ready,
render: bridge.render,
dispose: bridge.dispose,
});
```

`preservedRunnerHtml` is the decoded JSON value of the original `film-runner` script, not the outer HTML player. `load` carries the original entry script, modules, look, fonts and asset records. Supply the existing protocol's asset buffers/URLs; the bridge structured-clones them without transferring ownership, allowing separate runner instances to reuse them. Asset packaging and HTML extraction belong to the importer.

The bridge installs its listener before setting `srcdoc`. It accepts messages only from that iframe's window with opaque origin `"null"`. It sends `load` after `hello`, waits for `ready`, and sends explicit `frame` requests with increasing sequence IDs. Capture waits for the matching frame acknowledgement; stale replies do not release it. The wrapper never calls the original player's autoplay path.

Startup has a 20-second deadline and a frame has a 15-second deadline. Startup, runner and timeout failures reject capture. An individual `frame-error` can recover on the next seek. Unregistering rejects outstanding bridge work and removes its listener. Use a separate bridge and runner iframe for each independently timed or overlapping scene.

Use screenshot capture for iframe pixels. This bridge does not read the sandbox DOM, expose internal scene nodes as Studio layers, or provide audio mixing and video extraction. Font/asset readiness depends on the preserved runner's `ready` and `frame` guarantees and must be checked for each supported export version.

The compatibility target is the current protocol, not every arbitrary animated web page. Visual comparison against the partner preview remains an integration acceptance step after MCP packaging.
1 change: 1 addition & 0 deletions packages/core/src/runtime/entry.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,7 @@ describe("runtime entry", () => {
window.__timelines = {};
Object.defineProperty(document, "readyState", { configurable: true, get: () => "loading" });
await evaluateRuntime();
expect(window.__hyperframes!.createFilmBridge).toEqual(expect.any(Function));
let finish!: () => void;
const first = new Promise<void>((resolve) => {
finish = resolve;
Expand Down
3 changes: 3 additions & 0 deletions packages/core/src/runtime/entry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import { clearRuntimeData, registerRuntimeDataHandler, setRuntimeData } from "./
import { runScriptsAfterFonts } from "./afterFonts";
import { AFTER_FONTS_SCRIPTS } from "../compiler/scriptRuns";
import { hasFrameSources, registerFrameSource } from "./frameSources";
import { createFilmBridge } from "./filmBridge";

type HyperframeWindow = Window & {
__hyperframeRuntimeBootstrapped?: boolean;
Expand All @@ -27,6 +28,7 @@ type HyperframeWindow = Window & {
setRuntimeData: typeof setRuntimeData;
clearRuntimeData: typeof clearRuntimeData;
registerFrameSource: typeof registerFrameSource;
createFilmBridge: typeof createFilmBridge;
};
};

Expand Down Expand Up @@ -57,6 +59,7 @@ deferMediaUntilDue();
setRuntimeData,
clearRuntimeData,
registerFrameSource,
createFilmBridge,
};

function bootstrapHyperframeRuntime(): void {
Expand Down
202 changes: 202 additions & 0 deletions packages/core/src/runtime/filmBridge.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,202 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { createFilmBridge, type FilmBridge } from "./filmBridge";

const bridges: FilmBridge[] = [];
afterEach(() => {
for (const bridge of bridges.splice(0)) bridge.dispose();
document.body.replaceChildren();
vi.useRealTimers();
});
function setup() {
const iframe = document.createElement("iframe");
iframe.setAttribute("sandbox", "allow-scripts");
document.body.appendChild(iframe);
const send = vi.spyOn(iframe.contentWindow!, "postMessage").mockImplementation(() => {});
const load = { script: "window.CUT = kit.scenes()", modules: [], assets: [] };
const bridge = createFilmBridge({ iframe, runnerHtml: "<div>fixture runner</div>", load });
bridges.push(bridge);
const receive = (
type: string,
data: Record<string, unknown> = {},
origin = "null",
source = iframe.contentWindow,
) => {
window.dispatchEvent(
new MessageEvent("message", {
source,
origin,
data: { ...data, type: "appifact-film:" + type },
}),
);
};
const initialize = () => {
receive("hello");
receive("ready");
};
return { iframe, send, load, bridge, receive, initialize };
}

describe("film runner bridge", () => {
it("loads the original protocol payload once and waits for the matching frame", async () => {
const { iframe, bridge, receive, send, load, initialize } = setup();
expect(iframe.srcdoc).toContain("fixture runner");
initialize();
await bridge.ready;
const frame = bridge.render(3.25);
await Promise.resolve();
expect(send).toHaveBeenNthCalledWith(1, { ...load, type: "appifact-film:load" }, "*");
expect(send).toHaveBeenNthCalledWith(2, { type: "appifact-film:frame", t: 3.25, seq: 1 }, "*");
let complete = false;
void frame.then(() => {
complete = true;
});
receive("frame", { seq: 2 });
await Promise.resolve();
expect(complete).toBe(false);
receive("frame", { seq: 1 });
await frame;
expect(complete).toBe(true);
});

it("rejects a runner reload during a frame and ignores old acknowledgements", async () => {
vi.useFakeTimers();
const { bridge, initialize, receive, send } = setup();
initialize();
const frame = bridge.render(1);
const rejected = expect(frame).rejects.toThrow("reloaded");
await Promise.resolve();
receive("hello");
receive("ready");
receive("frame", { seq: 1 });
await rejected;
await expect(bridge.render(2)).rejects.toThrow("reloaded");
expect(send).toHaveBeenCalledTimes(2);
expect(vi.getTimerCount()).toBe(0);
});

it("rejects a reload before readiness instead of leaving startup pending", async () => {
vi.useFakeTimers();
const { bridge, receive } = setup();
receive("hello");
receive("hello");
await expect(bridge.ready).rejects.toThrow("reloaded");
expect(vi.getTimerCount()).toBe(0);
});

it("settles startup immediately when the load payload cannot be cloned", async () => {
vi.useFakeTimers();
const { bridge, receive, send } = setup();
send.mockImplementation(() => {
throw new DOMException("uncloneable load", "DataCloneError");
});
receive("hello");
await expect(bridge.ready).rejects.toThrow("uncloneable load");
await expect(bridge.render(0)).rejects.toThrow("uncloneable load");
expect(vi.getTimerCount()).toBe(0);
});

it("settles frame sends immediately when postMessage throws", async () => {
vi.useFakeTimers();
const { bridge, initialize, send } = setup();
initialize();
send.mockImplementation(() => {
throw new Error("frame send failed");
});
await expect(bridge.render(0)).rejects.toThrow("frame send failed");
await expect(bridge.render(1)).rejects.toThrow("frame send failed");
expect(vi.getTimerCount()).toBe(0);
});

it("rejects immediately when the runner has no content window", async () => {
vi.useFakeTimers();
const { iframe, bridge, initialize } = setup();
initialize();
Object.defineProperty(iframe, "contentWindow", { value: null });
await expect(bridge.render(0)).rejects.toThrow("window is unavailable");
expect(vi.getTimerCount()).toBe(0);
});

it("rejects sandbox configurations that expose a same-origin document", () => {
const iframe = document.createElement("iframe");
for (const sandbox of ["", "allow-scripts allow-same-origin"]) {
iframe.setAttribute("sandbox", sandbox);
expect(() => createFilmBridge({ iframe, runnerHtml: "", load: {} })).toThrow("sandbox");
}
});

it("ignores foreign sources, origins, and ready messages before hello", async () => {
const { bridge, receive, send } = setup();
let ready = false;
void bridge.ready.then(() => {
ready = true;
});
receive("hello", {}, "https://example.com");
receive("hello", {}, "null", window);
receive("ready");
await Promise.resolve();
expect(ready).toBe(false);
expect(send).not.toHaveBeenCalled();
receive("hello");
receive("ready");
await bridge.ready;
});

it("can seek again after an individual frame error", async () => {
const { bridge, initialize, receive } = setup();
initialize();
const failed = bridge.render(2);
const rejection = expect(failed).rejects.toThrow("bad frame");
await Promise.resolve();
receive("frame-error", { seq: 1, message: "bad frame" });
await rejection;
const next = bridge.render(0);
await Promise.resolve();
receive("frame", { seq: 1 });
receive("frame", { seq: 2 });
await next;
});

it("propagates startup failures and keeps fatal errors for later seeks", async () => {
const { bridge, receive } = setup();
receive("error", { message: "module missing" });
await expect(bridge.ready).rejects.toThrow("module missing");
await expect(bridge.render(0)).rejects.toThrow("module missing");
const second = setup();
second.initialize();
await second.bridge.ready;
second.receive("error", { message: "runner crashed" });
await expect(second.bridge.render(0)).rejects.toThrow("runner crashed");
});

it("rejects stalled initialization and frames with bounded timeouts", async () => {
vi.useFakeTimers();
const first = setup();
const rejected = expect(first.bridge.ready).rejects.toThrow("20 s");
await vi.advanceTimersByTimeAsync(20_000);
await rejected;
const second = setup();
second.initialize();
const frame = second.bridge.render(0);
const rejection = expect(frame).rejects.toThrow("15 s");
await vi.advanceTimersByTimeAsync(15_000);
await rejection;
await expect(second.bridge.render(1)).rejects.toThrow("15 s");
});

it("disposes before startup or during a frame and ignores late messages", async () => {
const first = setup();
first.bridge.dispose();
first.bridge.dispose();
first.initialize();
await expect(first.bridge.ready).rejects.toThrow("disposed");
expect(first.send).not.toHaveBeenCalled();
const second = setup();
second.initialize();
const frame = second.bridge.render(0);
await Promise.resolve();
second.bridge.dispose();
second.receive("frame", { seq: 1 });
await expect(frame).rejects.toThrow("disposed");
await expect(second.bridge.render(1)).rejects.toThrow("disposed");
});
});
123 changes: 123 additions & 0 deletions packages/core/src/runtime/filmBridge.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
const PREFIX = "appifact-film:";

export interface FilmBridge {
ready: Promise<void>;
render: (time: number) => Promise<void>;
dispose: () => void;
}

/** Install the listener before loading the preserved runner into its opaque sandbox. */
export function createFilmBridge(options: {
iframe: HTMLIFrameElement;
runnerHtml: string;
load: Record<string, unknown>;
}): FilmBridge {
const { iframe, runnerHtml, load } = options;
const permissions = (iframe.getAttribute("sandbox") ?? "").split(/\s+/);
if (!permissions.includes("allow-scripts") || permissions.includes("allow-same-origin")) {
throw new Error('Film runners require sandbox="allow-scripts" without allow-same-origin');
}
let sequence = 0;
let loaded = false;
let disposed = false;
let failure: Error | null = null;
let resolveReady!: () => void;
let rejectReady!: (reason: Error) => void;
const ready = new Promise<void>((resolve, reject) => {
resolveReady = resolve;
rejectReady = reject;
});
void ready.catch(() => {});
let pending: {
sequence: number;
resolve: () => void;
reject: (reason: Error) => void;
timer: ReturnType<typeof setTimeout>;
} | null = null;
const fail = (error: Error) => {
failure = error;
clearTimeout(startupTimer);
rejectReady(error);
if (pending) {
clearTimeout(pending.timer);
pending.reject(error);
pending = null;
}
};
const startupTimer = setTimeout(
() => fail(new Error("Film runner did not become ready within 20 s")),
20_000,
);
const completeFrame = (data: { type: unknown }) => {
if (
(data.type !== PREFIX + "frame" && data.type !== PREFIX + "frame-error") ||
!("seq" in data) ||
pending === null ||
pending.sequence !== data.seq
)
return;
const request = pending;
pending = null;
clearTimeout(request.timer);
if (data.type === PREFIX + "frame-error") {
request.reject(new Error("message" in data ? String(data.message) : "Film frame failed"));
} else request.resolve();
};
const send = (message: Record<string, unknown>) => {
try {
const target = iframe.contentWindow;
if (!target) throw new Error("Film runner window is unavailable");
target.postMessage(message, "*");
} catch (error) {
fail(error instanceof Error ? error : new Error(String(error)));
}
};
const receiveStartup = (data: { type: unknown }) => {
if (data.type === PREFIX + "hello") {
if (loaded) {
fail(new Error("Film runner reloaded; recreate the bridge before seeking"));
return;
}
loaded = true;
send({ ...load, type: PREFIX + "load" });
} else if (data.type === PREFIX + "ready" && loaded) {
clearTimeout(startupTimer);
resolveReady();
} else if (data.type === PREFIX + "error") {
fail(new Error("message" in data ? String(data.message) : "Film runner failed"));
}
};
const receive = (event: MessageEvent<unknown>) => {
if (disposed || failure || event.source !== iframe.contentWindow || event.origin !== "null") {
return;
}
const data = event.data;
if (!data || typeof data !== "object" || !("type" in data)) return;
receiveStartup(data);
completeFrame(data);
};
window.addEventListener("message", receive);
iframe.srcdoc = runnerHtml;
return {
ready,
async render(time) {
await ready;
if (failure) throw failure;
if (pending) throw new Error("Film seeks must be serialized; use registerFrameSource");
const seq = ++sequence;
return new Promise<void>((resolve, reject) => {
const timer = setTimeout(() => {
fail(new Error("Film frame did not arrive within 15 s"));
}, 15_000);
pending = { sequence: seq, resolve, reject, timer };
send({ type: PREFIX + "frame", t: time, seq });
});
},
dispose() {
if (disposed) return;
disposed = true;
window.removeEventListener("message", receive);
fail(new Error("Film bridge was disposed"));
},
};
}
Loading
Loading