diff --git a/apps/headless/page.html b/apps/headless/page.html
new file mode 100644
index 00000000..ecee783c
--- /dev/null
+++ b/apps/headless/page.html
@@ -0,0 +1,5 @@
+
+
+
headless render
+
+
diff --git a/apps/headless/src/page.ts b/apps/headless/src/page.ts
new file mode 100644
index 00000000..27ef08dd
--- /dev/null
+++ b/apps/headless/src/page.ts
@@ -0,0 +1,60 @@
+import {
+ Ai, ChildOf, Fonts, FramePromises, FrameRate, Library, Mode, Project,
+ RenderSurface, Root, Source, createRuntimeWorld, disposeDecoders, resetCamera,
+} from "@diffusionstudio/runtime";
+import { mount } from "@diffusionstudio/reconciler";
+import { createEncoder } from "@diffusionstudio/encoder";
+
+declare const window: any;
+
+async function post(path: string, body: any, type: string) {
+ await fetch(path, { method: "POST", headers: { "content-type": type }, body });
+}
+
+async function run() {
+ const job = await (await fetch("/api/job")).json();
+
+ const world = createRuntimeWorld(job.projectId ?? "headless");
+ world.set(Mode, { value: job.mode ?? "offline-video" });
+ world.set(FrameRate, { value: job.fps ?? 30 });
+ world.set(FramePromises, { list: [] });
+
+ const canvas = document.createElement("canvas");
+ canvas.width = 2;
+ canvas.height = 2;
+ canvas.style.cssText =
+ "position:fixed;left:0;top:0;z-index:-9999;opacity:0;will-change:opacity;pointer-events:none;";
+ document.body.appendChild(canvas);
+ world.set(RenderSurface, { canvas, ctx: canvas.getContext("2d"), resolution: 1 });
+
+ const mounted = mount(job.code, world);
+
+ const roots = [...world.query(ChildOf(world.get(Root)!))];
+ if (roots.length !== 1) throw new Error(`Expected one scene, found ${roots.length}`);
+
+ resetCamera(world);
+
+ const encoder = await createEncoder(world, {
+ format: job.format ?? "mp4",
+ video: job.video ?? { codec: "avc", resolution: 1080, fps: job.fps ?? 30, bitrate: 8e6 },
+ audio: job.audio ?? { enabled: false },
+ comment: "Rendered by Frank",
+ });
+
+ const result = await encoder.render();
+ if (result.type !== "success") throw new Error(`Render ${result.type}`);
+
+ const buf = await result.data.arrayBuffer();
+ await post("/api/result", buf, "application/octet-stream");
+
+ mounted.dispose();
+ disposeDecoders(world, world.get(Root)!);
+ canvas.remove();
+ world.destroy();
+
+ await post("/api/status", "ok", "text/plain");
+}
+
+run().catch(async (e) => {
+ await post("/api/status", String(e && e.stack ? e.stack : e), "text/plain");
+});