Skip to content

Long renders with injected video frames: renderer memory grows by every frame and capture eventually stalls #5492

Closed

Description

@MarketDataApp

Summary

During a render, each capture worker's renderer process grows by roughly the size of every video frame injected into the page, and never gives the memory back. On a long render with large source videos the page eventually stops making progress. The workers stay alive and the job keeps reporting "stage still running", but no new frames are written.

Environment

  • @hyperframes/producer 0.8.140
  • Chrome for Testing headless shell 154.0.8037.92 (mac-arm64)
  • macOS 27, Apple silicon
  • Node 26.0.0, ffmpeg 9.0.2

Reproduction (attached: make-media.sh, project/index.html, render.mjs, watch-rss.sh)

These scripts are a condensed standalone version of the setup we measured with; the numbers below come from the same composition shape.

  1. sh make-media.sh: two synthetic 1920x1080 30 fps clips with film-like grain (no real media). Each extracted PNG frame is several megabytes, as camera footage is.
  2. project/index.html: a 1920x588 composition holding the two clips side by side, each shown at 960x540, for 106.7 s (3,200 frames).
  3. node render.mjs: createRenderJob({ fps: 30, format: "png-sequence", workers: 2, videoFrameFormat: "png" }) and executeRenderJob. PNG extraction keeps the source frames lossless. The default extracts non-alpha videos as JPG, which makes each frame smaller, but each injected frame is still kept.
  4. sh watch-rss.sh in a second terminal: the resident size of each Chrome process every 30 s.

Expected: each capture worker's memory stays roughly flat. A frame injected for one capture is released once the next one replaces it, and the render finishes.

Actual: each worker's renderer process grows by roughly the bytes of every frame injected, and never shrinks. The render slows down as memory grows. On a long render with camera-like footage the page runs out of memory before the end: the workers stay alive, checkpoints keep arriving, and no new frames are written.

Measurements

The same layout with two smaller synthetic sources (960x540 each, so a quarter of the bytes per frame), 6,180 frames, two workers (about 3,100 captures per page), completes. But each renderer process grows linearly over the run:

time captures per page (approx.) renderer RSS per worker
120 s 650 646 MB
300 s 1,550 1,231 MB
510 s 3,000 1,539 MB

That is about 0.5 MB per capture for two 960x540 frames per capture. The browser and GPU processes stay flat (about 115-125 MB). One injected 960x540 video at the same frame count peaks near 250 MB, and a page with no video stays near 120 MB.

With two 1920x1080 smooth synthetic sources (small PNG frames), 3,200 frames and two workers complete. The renderer reaches about 1,840 MB per worker at about 1,500 captures per page, about 1.1 MB per capture.

With two 1920x1080 sources carrying film-like grain, each extracted PNG frame is several megabytes, as with real footage. The same 3,200-frame render does not finish within 12 minutes. Each renderer process grows to about 10.9 GB by only about 580 captures per page: about 17 MB retained per capture, roughly the two base64 data URIs injected per capture. Throughput falls to about 2.4 frames per second.

time captures per page (approx.) renderer RSS per worker
300 s 150 4.4 GB
450 s 300 7.4 GB
570 s 440 9.2 GB
690 s 580 10.9 GB

So the memory retained scales with the injected PNG bytes, and a long render with real footage runs the page out of memory long before it ends.

What we read in the code (not verified by experiment)

In the producer bundle, createVideoFrameInjector reads each frame file and turns it into a data:image/png;base64,... string. createFrameSourceCache is used without a frameSrcResolver: the render path passes only the cache limits. injectVideoFramesBatch then sets that string as the src of the sibling <img class="__render_frame__"> on every frame and awaits img.decode(). Each frame is a new, unique data URL. It looks as if the renderer keeps the decoded images (or the URL strings) for the page's lifetime.

Possible directions

  • Serve frames by URL from the file server the render already runs (the cache already has a frameSrcResolver hook), so the page fetches small URLs and its image cache can evict.
  • Or release the previous frame's image explicitly before setting the next.
  • Or recycle a capture page after a number of captures.

Any of these would let a long render with large injected videos finish in one pass. Our workaround meanwhile: render shorter frame ranges as separate renders and join their frames.

We would be glad to send a pull request for whichever direction you prefer (serving frames by URL looks the smallest to us). What we are asking for is a fix, or a documented limit (frames or bytes per capture page). We are not asking for a rewrite.

Activity

  1. MarketDataApp commented on Oct 12, 2026

    @MarketDataApp
    Author
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

No labels
No labels

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions