Why a deterministic scene contract matters more than asking a model for “a video.”
When people say a language model “made a video,” the model often did something more specific: it wrote code for a scene, then a renderer turned that code into frames. That distinction matters. A script can produce attractive motion in a live browser and still fail when you try to export it reliably.
The interface I would ask an AI coding model to implement is small: given a time t, render exactly the frame that belongs at t. Call it seek(t), renderAt(t), or something else. The name is incidental; the ability to jump to any timestamp is the contract. I use Claude Opus 5.5 as an example of a model that can write scene code, but the design applies to any code-generating model.
A browser preview can lie
A live animation normally moves forward. requestAnimationFrame calls a draw function, a timer changes state, and the next frame inherits whatever happened before. This is fine for a browser preview. An export pipeline may capture frame 240 before frame 120, retry a failed frame, or distribute scenes across workers. If frame 240 depends on having played frames 0 through 239, the result becomes fragile.
Consider a particle scene that calls Math.random() each time it draws. Re-render frame 42 after a crash and the particles move. Consider a counter driven by Date.now(). A slow screenshot changes the apparent timing. A CSS transition triggered by page load may be halfway through when Playwright captures the screen. Each issue is easy to miss in a casual preview.
Instead, define the scene as a function of explicit inputs:
frame = render(time, data, fixed_assets, seed)
The same inputs should produce the same pixels. Rendering frame 42 twice becomes a useful test, not a hope.
Give the model a contract it can check
I would ask the model for a self-contained HTML scene with a declared duration and an asynchronous seek function. The function should clear the canvas, calculate the state for t, draw it, and finish only after fonts and assets needed for that frame are ready. The renderer owns the frame rate: it asks for t = frameIndex / fps, awaits completion, then takes a screenshot.
The prompt should forbid hidden clocks: no wall-clock time, autonomous animation loops, CSS transitions, or unseeded randomness. It should also specify canvas size, a small visual vocabulary, the factual brief, and the expected first and last frames. Those constraints make the scene testable. “Make it cinematic” does not.
For motion, use functions of time rather than accumulated mutations. A title can have opacity clamp((t - 0.5) / 0.3, 0, 1). A dot can have horizontal position start + distance * ease(t / duration). A chart line can reveal the first floor(progress * points.length) points. If it needs noise, derive pseudo-random values from a fixed seed and a stable object ID.
A minimal implementation: seek first, capture second
Here is a small, hand-written example of the contract. It is a two-second animation of a green circle moving across a Canvas, not model-generated footage. Save it as scene.html:
<!doctype html>
<html lang="en">
<meta charset="utf-8">
<title>Deterministic seek example</title>
<style>
html, body { margin: 0; background: #101827; }
canvas { display: block; }
</style>
<canvas id="scene" width="640" height="360"></canvas>
<script>
const canvas = document.querySelector("#scene");
const ctx = canvas.getContext("2d");
window.DURATION = 2; // seconds
window.seek = async function seek(t) {
if (!Number.isFinite(t)) throw new Error("Time must be finite");
await document.fonts.ready;
const progress = Math.max(0, Math.min(1, t / window.DURATION));
const eased = progress * progress * (3 - 2 * progress);
// Rebuild the whole frame from t; do not inherit previous state.
ctx.fillStyle = "#101827";
ctx.fillRect(0, 0, canvas.width, canvas.height);
ctx.fillStyle = "#5be2a1";
ctx.beginPath();
ctx.arc(60 + 520 * eased, 180, 24, 0, Math.PI * 2);
ctx.fill();
};
</script>
</html>
The position is calculated directly from t. Seeking to 1.5 seconds does not require playing the first 1.5 seconds. Seeking backward simply redraws the background and the circle at the requested position. Time is clamped to the scene boundary, so seek(2) produces the end state rather than moving the circle off screen.
This scene waits for document.fonts.ready to show where asynchronous preparation belongs, although it contains no text or external assets. For a scene with images, create and await a shared asset-loading Promise before drawing, and let failures reject seek(t). The asynchronous contract means “this frame is ready,” not “wait this many seconds.” It does not start playback or use a timer.
Now save the renderer as render.mjs beside the scene. In that folder, install Playwright and its Chromium browser:
npm install playwright
npx playwright install chromium
node render.mjs
import { chromium } from "playwright";
import { mkdir } from "node:fs/promises";
import { fileURLToPath } from "node:url";
import assert from "node:assert/strict";
const browser = await chromium.launch({
...(process.env.CHROME_PATH ? { executablePath: process.env.CHROME_PATH } : {}),
});
try {
const page = await browser.newPage({
viewport: { width: 640, height: 360 },
deviceScaleFactor: 1,
});
await page.goto(new URL("./scene.html", import.meta.url).href);
await page.waitForFunction(() => typeof window.seek === "function");
async function captureAt(t, path) {
// Return the Promise so evaluate waits for seek to finish.
await page.evaluate((time) => window.seek(time), t);
return await page.locator("#scene").screenshot({ path });
}
const first = await captureAt(0.75);
await captureAt(1.5);
const repeated = await captureAt(0.75);
assert.ok(first.equals(repeated), "Returning to t must reproduce the frame");
const fps = 30;
const duration = await page.evaluate(() => window.DURATION);
const frames = new URL("./frames/", import.meta.url);
await mkdir(frames, { recursive: true });
for (let i = 0; i < Math.ceil(duration * fps); i++) {
const file = new URL(`${String(i).padStart(4, "0")}.png`, frames);
await captureAt(i / fps, fileURLToPath(file));
}
console.log("Determinism check passed; PNG frames saved.");
} finally {
await browser.close();
}
The critical line is await page.evaluate((time) => window.seek(time), t). The browser callback returns the Promise from seek, so Playwright waits for it to resolve before the screenshot. Calling seek without returning its Promise would lose that ordering. No arbitrary sleep is needed for this Canvas scene. Playwright documents this Promise behavior.
Before exporting, the renderer captures 0.75 seconds, seeks to 1.5 seconds, then returns to 0.75 seconds and compares the PNG buffers. This checks repeatability within the same browser session, not identical pixels across different operating systems, browser versions, fonts, or GPU drivers. Keep those fixed when comparing larger production scenes.
At 30 fps, the loop captures 60 frames with timestamps from 0 through 59/30 seconds. The exact two-second boundary is available for a keyframe check; it is not an extra frame in the export. To make a silent MP4, install ffmpeg separately and run:
ffmpeg -y -framerate 30 -start_number 0 -i frames/%04d.png \
-frames:v 60 -c:v libx264 -pix_fmt yuv420p out.mp4
The output is a two-second, 640×360 clip. A slow browser may take longer than two seconds to render it; the film's timing comes from the timestamps and encoding frame rate, not from the time spent taking screenshots.
Review keyframes before encoding everything
The expensive mistake is to render hundreds of frames before noticing that the title clips at 16:9 or a number is wrong. I would first capture a contact sheet: the first frame, the end of each scene, both sides of every transition, and the final frame. Add intermediate frames where motion changes direction. This catches layout and factual errors early.
Next, check the contract mechanically. Seek to a timestamp, take a screenshot, seek elsewhere, then return to the first timestamp. Compare the two first-frame images. They should match pixel for pixel, or within a known tolerance if the browser environment itself introduces variation. Test a timestamp at zero, near the duration boundary, and just after each scene cut. Test with fonts loaded and with missing assets so failure is explicit rather than silent.
Only then sample every frame and encode. A headless browser can capture PNG frames; ffmpeg can turn them into an H.264 MP4. Audio is a separate timeline. Keep narration and music timestamps in data rather than hoping a browser timer will line up with the final export.
A good contract changes the editing loop
Determinism is useful beyond reproducibility. If the editor asks to change the caption between seconds 6 and 9, you can render that segment instead of the whole film. You can render scenes in parallel. You can compare the old and new version at the same timestamps. A failing frame can be retried. A model can inspect a contact sheet, patch the scene, and verify exactly the frames it changed.
It also changes how I would use an AI coding model. I would ask for a beat sheet and four keyframes first, review the factual brief, then ask for code. After the first render, I would give the model specific visual feedback: “At 00:03.2 the subtitle overlaps the chart; move it 80 pixels lower and preserve the final frame.” That is a tractable coding task. “Make it better” usually produces drift.
What this pipeline cannot do
A deterministic HTML scene is good for motion graphics, product diagrams, charts, and typographic explainers. It does not create filmed footage or natural human performance on its own. It also cannot guarantee design quality or factual correctness. The model can write a perfectly deterministic false claim. A human still needs to verify source facts, licensed assets, accessibility, and the final export.
For me, the test of an AI-generated video workflow is not whether the first preview looks impressive. It is whether I can reproduce frame 42, change one scene without breaking another, and explain where every on-screen fact came from. A small seek(t) contract makes those checks possible.
Disclosure: I build developer tools for AI APIs. This article describes a rendering design, not a benchmark of Claude Opus 5.5 or a recommendation of a particular API provider. This article was drafted with AI assistance.
Further reading: Playwright screenshots; ffmpeg documentation.
