Skip to content

fix(producer): seed random values in every render so re-renders give the same film - #7410

Merged
miguel-heygen merged 2 commits into
mainfrom
fix/seed-page-random
Nov 8, 2026
Merged

miguel-heygen merged 2 commits into
mainfrom
fix/seed-page-random

Conversation

@miguel-heygen

@miguel-heygen miguel-heygen commented Nov 8, 2026 •

Copy link
Copy Markdown
Collaborator

What

Every render now seeds Math.random and crypto.getRandomValues, so re-rendering a composition with the same settings gives the same film. A GSAP stagger: { from: "random" } grid used to come out in a different order on each render.

Draws at page load, like that stagger, and draws made inside a frame also match across worker counts. One case does not: a value resolved when a tween first renders, such as x: "random(-400, 400)". A worker resolves it under the seed of the first frame it reaches past the tween's start, so the film can differ between worker counts and jump where one worker hands over to the next. I rendered this: it repeats exactly at 1 worker and fails against that golden at 2 and at 4. It is no worse than main, where such values were random anyway. Fixing it means changing when tweens initialize, which is not in this PR.

This reverses a deliberate choice, at Miguel's call. The seeding option's doc said single-machine renders stay random to preserve "non-deterministic behavior for compositions that rely on it". That option is gone: a composition that wanted a different result on every render now gets the same result each time.

Preview does not change. It has no virtual-time shim and still uses native randomness, so a random draw in preview can differ from the render.

Why

Pages render in several places: each worker loads the page itself, and chunked renders already seeded while single-machine renders did not. So the same composition could give different frames on two renders, and could differ between workers within one render, since each worker's page drew its own order. The same page also rendered differently chunked and unchunked.

Related work

Refs #7390 (page timers on the film's schedule, in the same shim).

How

Main already had the seeded generator: buildVirtualTimeShim({ seedRandomFromFrame: true }) (Mulberry32, seeded from virtual time at load and on each seek), used only by chunked renders. Every render path already injects a virtual-time shim before page scripts, through the file server's preHeadScripts, so that is the one place to fix.

  • The seedRandomFromFrame option and buildVirtualTimeShim are deleted. VIRTUAL_TIME_SHIM is now the only shim, always seeded.
  • renderChunk.ts and planFrameCost.ts use that constant. No new generator was added.
  • The three tests pinning "the default leaves Math.random native" are removed with the option, along with one that string-matched the gated block.

Every composition starts from the same seed; there is no per-composition seed.

Test plan

  • Unit tests added/updated: draws the same Math.random sequence at page load in two fresh pages. fails without the fix: on main the two pages draw different sequences. Passes with it.
  • Regression fixture random-stagger-workers: a 100-cell grid with stagger: { from: "random" }, rendered with 2 workers against a golden made with the fix. It has shapes only, no text. fails without the fix: with main's fileServer.ts it fails at the visual comparison. Passes twice in a row with the fix.
  • fileServer-seededRandom 7/7, fileServer 58/58, fileServer-virtualTimers 9/9, renderChunk 38/38, renderChunkFallback 17/17, renderChunkFrameRange 12/12, renderChunkVideoMetadata 1/1 under bun; renderChunk.stall 1/1 and renderChunk.verifyInWorker 5/5 under vitest. tsc --noEmit, oxfmt and lint pass.
  • random-stagger-workers is in shard-schedule.json, and plan-regression-shards.mjs schedules it.
  • Comments follow CONTRIBUTING.md "Comments".
  • Documentation updated: not needed. The authoring rule "no unseeded Math.random()" stays right, because preview is still unseeded.

Not exercised locally: the rest of the regression suite (CI runs it), the fixture in distributed mode, and Windows. The golden was made on Linux outside the test Docker image.

@heygengenesis heygengenesis Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Verdict: REQUEST CHANGES · 1 blocker · 1 important · 0 nits · head b5e9204a

  • blocker (packages/producer/tests/shard-schedule.json, not in the diff): the regression workflow fails at Detect changes with Fixtures are neither scheduled nor excluded: random-stagger-workers, so no regression shard ran on this head. That means the new fixture (the PR's second fails-without-the-fix proof) has not run in CI. Neither has the rest of the suite, which the body says CI covers. That matters here because every in-process render now replaces Math.random/crypto.getRandomValues, so any existing golden that draws randomness during a render can shift. Add the fixture to timings, then read the full regression result. The golden was also made outside the test Docker image, so this first run is its real check.
Scope

Read the full diff. Every producer site that sets preHeadScripts now gets the seeded shim: renderOrchestrator, probeStage, planReferenceBackground, renderChunk and planFrameCost. No other seekToTime shim exists. The PR has a fails-without-the-fix line, for the page-load unit test and the fixture. I did not render locally. CI was still running when I posted; regression had already failed.

if (safeTimeMs > 0) seekToTime(0);
}
${seekToTimeReseedCall}fireDueTimers(safeTimeMs);
reseedRngFromTime(safeTimeMs);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

important: "the same random values at any worker count" holds for draws at page load, not for draws GSAP makes when a tween first renders. Take a .to(el, { x: "random(-200, 200)" }) (or a function-based value) that starts at 0.5 s in a paused timeline. GSAP resolves it in _initTween on the first render that crosses its start (gsap-core 3.12.2 _parseFuncOrString → _replaceRandom).

With one worker, that render is the 0.5 s frame, seeded reseedRngFromTime(500). With two workers, a worker whose chunk starts at 1 s jumps from 0 to 1 s in its first render, so it initializes the tween under seed 1000 and gets a different x from the frame the boundary falls on. The film then differs by worker count, and the chunk boundary jumps.

This is better than main (native random), not a regression. Either narrow the title, body and fixture description to load-time draws (plus per-frame draws, which reseeding fixes), or add a fixture with a lazily resolved random() value that spans a chunk boundary and make it pass.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Partly fixed in 921fa5a: the shim comment is now accurate. The PR title and the What section still say "at any worker count" without qualification, and the title becomes the squash commit message. Worth narrowing before merge; not blocking.

@github-actions

github-actions Bot commented Nov 8, 2026 •

Copy link
Copy Markdown
Contributor

Edit accuracy: accurate 267 (base branch 267), smooth 125 of those

The gate passes.
Smoothness is reported in the artifact, not gated. A case fails only if it fails 2 of 3 runs.

Quarantined, measured but not gated (8)

@miguel-heygen miguel-heygen changed the title fix(producer): every render draws the same random values, at any worker count fix(producer): seed random values in every render so re-renders give the same film Nov 8, 2026

@heygengenesis heygengenesis Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Verdict: APPROVE · 1 resolved, 1 partly open (important, non-blocking), 0 new · b5e9204a → 921fa5aa

The regression run on this head passed in full, and random-stagger-workers passed with 0 failed frames.

@miguel-heygen
miguel-heygen added this pull request to the merge queue Nov 8, 2026
Merged via the queue into main with commit eb16510 Nov 8, 2026
138 checks passed
@miguel-heygen
miguel-heygen deleted the fix/seed-page-random branch November 8, 2026 16:32
This was referenced Nov 8, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant