ContentRoofLab

LabNote 01Rendering

We took the browser out of video export

Every ContentRoof video is a React program. We exported it the way everyone does: open it in a headless browser and take a screenshot thirty times a second. Most of the time went to the screenshot. So we stopped taking it.

Abstract

A ContentRoof video is a React composition, exported by having headless Chrome paint and screenshot every frame. Profiling showed about 80% of a frame's time went to that screenshot. We wrote a renderer that draws the same, unchanged components with Skia straight from Node. On a 4-core server a 55-second video now exports in 90.5 s instead of 218.5 s, and every frame stays within 29–44 dB PSNR of Chrome's. On a laptop GPU the same video takes 13.4 s.

2.4×faster on a 4-core server: 90.5 s instead of 218.5 s for a 55 s video
29–44 dBPSNR of every frame against Chrome's, average 33.4
13.4 sfor the same video at 1080p on an M4 laptop's GPU (Chrome: 67.5 s)
A frame of an explainer about why the sky is blue, drawn by our renderer
A frame of an explainer about a bank run
A frame of an explainer about dreams
Specimens. Frames drawn by the new renderer. The first is from the video measured in this note.

Four seconds of work for every second of video

A ContentRoof video is a React composition. The same code runs twice: in the browser, as the live preview you scrub through while you edit, and on our server, where it becomes an MP4. The standard export path opens the composition in headless Chrome, sets the frame, takes a screenshot, hands the picture to an encoder, and repeats, thirty times for every second.

On a 4-core cloud server that took about four seconds of work for every second of video. A one-minute video kept the machine busy for four minutes, and people waited in a queue behind it.

Figure 1Where the CPU went while Chrome exported

Chrome painting · 62%16%11%8%x264 encoder 16%GPU and compositor process 11%Browser process 8%Node (our code) 2%
Chrome painting · 62%16%11%8%x264 encoder 16%GPU and compositor process 11%Browser process 8%Node (our code) 2%
Share of CPU time by process during a 600-frame export through headless Chrome on a 4-core server. Painting the page is most of it; our own code (Node) is 2%.
Data
PartCountShare
Chrome painting6263%
x264 encoder1616%
GPU and compositor process1111%
Browser process88%
Node (our code)22%

Setting the React frame took about 5 ms. The screenshot was about 80% of each frame. Even an almost empty frame cost 85 CPU-milliseconds to capture, compress and encode. The cost was not in our components. It was in asking a browser to paint a page and hand back a picture.

Everything we tried first

We tried to make Chrome cheaper before replacing it.

Figure 2What each idea saved before we replaced the browser

Skip resizing the page per screenshot10%keptFaster x264 preset13–18%files 2.3–3.2× biggerNo moving background18%a different lookNo captions15%not an optionNo glows on the drawings12%the glows are the lookLower JPEG qualitynothingCompositor layersslower
Skip resizing the page per screenshot10%keptFaster x264 preset13–18%files 2.3–3.2× biggerNo moving background18%a different lookNo captions15%not an optionNo glows on the drawings12%the glows are the lookLower JPEG qualitynothingCompositor layersslower
Time saved on the same export, one change at a time. Only the first was kept: it changed no pixels. The rest cost too much in file size or in the look. Making Chrome put elements on their own compositor layers made it slower.
Data
Time saved
Skip resizing the page per screenshot10%
Faster x264 preset13–18%
No moving background18%
No captions15%
No glows on the drawings12%
Lower JPEG qualitynothing
Compositor layersslower

One change was free: a capture setting made Chrome resize the page around every screenshot and repaint everything. Turning it off gave byte-identical frames and 10% more speed. We kept it.

The rest were trades we didn't want. Faster encoder presets made files two to three times bigger. Removing effects one at a time showed that no single effect dominates: the moving background, the glows, the captions and the scene transitions each cost 10–20%. Giving elements their own compositor layers, the usual advice for smooth animation in a browser, made software rendering slower, because every layer is blended over the whole frame.

Drawing the frames ourselves

So we wrote a renderer that runs the unchanged video components without a browser.

Figure 3The renderer, layer by layer

1 Video componentsThe same React code the browser preview runs.Unchanged.2 ReactRenders the components, as it would in a browser.3 LayoutOur own layout: boxes, flexible rows, lines of textin both directions.4 PaintEvery pixel drawn with Skia, the 2D engine insideChrome.5 WorkersSeveral processes drawing different parts of thevideo at once.6 MP4The parts joined into one video, with its sound.
1 Video componentsThe same React code thebrowser preview runs.2 ReactRenders the components, as itwould in a browser.3 LayoutOur own layout: boxes,flexible rows, lines of text4 PaintEvery pixel drawn with Skia,the 2D engine inside Chrome.5 WorkersSeveral processes drawingdifferent parts of the video6 MP4The parts joined into onevideo, with its sound.
A frame passes down the stack. Only the top layer is shared with the browser preview; everything under it replaces Chrome. Paint (highlighted) is where the hours went.

The components don't know the difference. Underneath them, our own renderer lays the page out and paints it with Skia, the same 2D graphics engine Chrome uses, and several worker processes draw different stretches of the video at once. Anything the renderer can't draw yet (3D, stock footage) is detected before any work starts and goes to Chrome as before.

Surprises along the way

Most of the speed came from things that were slow for reasons nobody would guess, and that only showed up when we timed a single frame step by step. Drawing text from several threads was slower than from one. Some gradients and very small blurs cost ten times what their neighbours did. A single encoder, fed by every worker, quietly became the ceiling. None of them was visible in a profile of the whole export, and each had a simple fix once it was found.

Results

Figure 4Exporting the same 55-second video

4-core server · Chrome218.5 s4-core server · our renderer90.5 s2.4×M4 laptop · Chrome67.5 sM4 · our renderer, CPU28.0 s2.4×M4 · GPU drawing, x26416.0 s4.2×M4 · GPU drawing and encoding13.4 s5.0×M4 · the same at 720p7.6 s
4-core server · Chrome218.5 s4-core server · our renderer90.5 s2.4×M4 laptop · Chrome67.5 sM4 · our renderer, CPU28.0 s2.4×M4 · GPU drawing, x26416.0 s4.2×M4 · GPU drawing and encoding13.4 s5.0×M4 · the same at 720p7.6 s
Wall-clock time for the whole export of a 55 s video at 1080p, including the loudness pass. Top two: a 4-core cloud server. The rest: an M4 MacBook with 10 cores, where the GPU can both draw and encode.
Data
Seconds
4-core server · Chrome218.5 s
4-core server · our renderer90.5 s
M4 laptop · Chrome67.5 s
M4 · our renderer, CPU28.0 s
M4 · GPU drawing, x26416.0 s
M4 · GPU drawing and encoding13.4 s
M4 · the same at 720p7.6 s

On the 4-core server the 55-second video exports in 90.5 s instead of 218.5 s, 2.4× faster, and the file is 24% smaller at the same quality setting (Chrome's frames pass through JPEG and carry its noise). At 720p, which free accounts export, a frame takes 33 ms.

On a laptop with a GPU, drawing with Skia on Metal and encoding in hardware brings the same video to 13.4 s, five times faster than Chrome on the same machine. There the slowest step is no longer drawing but copying 8 MB of pixels back from the GPU for each frame, which is why 720p is twice as fast again.

Does it look the same?

Speed only counts if the video is the same video.

Figure 5The same frame, two renderers

The frame drawn by Chrome: a prism splitting white lightA Chrome
The same frame drawn by our rendererB Our renderer
The difference between the two frames, mostly white with faint outlinesC Difference ×8
Frame 269 of the measured video, one of the lower-scoring frames of a fresh check (29.5 dB). C is the absolute difference between A and B, multiplied by eight and inverted: white means identical. What differs is the anti-aliasing at the edges of letters and lines.

Figure 6How close the frames are to Chrome's

20 dB30 dB40 dB50 dBour pass mark: 26 dBEvery frame of themeasured export (1,660)294433.4Separate check acrossscene types and templates3045Same check with GPUdrawing3045
20 dB30 dB40 dB50 dBour pass mark: 26 dBEvery frame of the measured export (1,660)294433.4Separate check across scene types and templates3045Same check with GPU drawing3045
PSNR against Chrome's frame (higher is closer; above about 30 dB differences are hard to see). Each bar spans the lowest to the highest frame; the dot is the average where we have it. The separate check covered every scene type, all six 2D templates, Urdu, the wide format and 2.5D pictures.
Data
FromTo
Every frame of the measured export (1,660)29 dB44 dB
Separate check across scene types and templates30 dB45 dB
Same check with GPU drawing30 dB45 dB

Every frame of the measured export is within 29–44 dB of Chrome's, 33.4 on average.1 A separate check across every scene type and template gave 30–45 dB. A fresh check of 17 frames for this note showed where the remaining difference lives: frames in the middle of a transition, all soft blur and fades, scored 41–44 dB; still frames full of sharp text scored 28–34. The difference is anti-aliasing at the edges of letters and thin lines, which Skia and Chrome compute slightly differently.2

What's next

The production server still exports on its CPU. The rented machines that render our 3D simulations have GPUs that sit idle during ordinary exports, and the GPU path has not been tried on Linux yet. That is the next measurement.

Method

Video
One 55-second explainer (why the sky is blue): 1,660 frames at 1080p and 30 fps, with real voices, captions and music.
Machines
A 4-core cloud server for the main comparison; an M4 MacBook (10 cores) for the GPU numbers, 1,768 frames.
Timing
Wall-clock time of the whole export, including the loudness pass, for both renderers.
Fidelity
PSNR of each frame against the frame Chrome draws for the same moment. A fresh check on 17 frames today: 28.0–43.7 dB.
Profile
CPU time by process during a 600-frame Chrome export, from the operating system's process accounting.

Limits

  • One video on two machines. A video full of blur or text will move the numbers either way.
  • PSNR counts pixel differences, not what a viewer notices. We also looked at the frames side by side.
  • 3D videos, stock footage and captioned uploads still go to Chrome: the renderer can't draw WebGL or decode video frames yet. It checks a video before starting and hands those over.
  • The GPU path is measured on a Mac only. Our production server has no GPU, so production exports use the CPU path.

Notes

  1. PSNR (peak signal-to-noise ratio) compares two images pixel by pixel. 30 dB means the average pixel differs by about 3% of full brightness; 40 dB, by about 1%. ↩
  2. Skia is the 2D graphics engine inside Chrome, so the two renderers share their lowest layer. What we replaced is everything Chrome does above it: the page, its layout and the screenshot. ↩

Make a video and export it →

04 Translation5 Nov 20264 min A billion is not a billion in Spanish Spanish billón, German Billion and French billion mean a million million. How we check every number when a video is rebuilt in another language: 8 of 8 planted errors caught. 8 of 8planted number errors caught 03 Speech5 Nov 20263 min Sixty seconds is a different length in every language Asked for 60 s, our videos came out 44–105 s. Measuring each language's voice per letter now lands 43–62 s in eleven languages, and a cloned voice took Arabic errors from 10% to 0. 44–105 s → 43–62 sasked for 60 s, in eleven languages 02 Evaluation5 Nov 20264 min Balanced benchmarks hid a 95% error rate Our sentence classifier scored 79–95% on balanced tests. On real narration it gave 95% of plain statements a shape they don't have. What we measured and what fixed it. 5% → 88%plain statements left alone