the effing blog

Effing 0.41.0: dressed to the nines

The bundled FFmpeg jumps from 6.1 to 9.0.1, making renders faster and frame-exact, and the canvas renderer gains clip paths and backdrop filters.

Effing 0.41.0 is out, with two headlines. The FFmpeg that @effing/ffs renders with jumps three major versions, from 6.1.6 to 9.0.1, which makes renders faster and fixes a long-standing off-by-one-frame quirk. And @effing/canvas picks up clipPath and backdropFilter, so you can cut elements into shapes and put frosted glass over anything.

FFmpeg 9

@effing/ffmpeg has been pinning FFmpeg 6.1 for a while now, and the filter graph in @effing/ffs had come to depend on it in a couple of places. FFmpeg 7.0 removed the fifo filter that the global background chain used, so every render failed on anything newer, and that’s where things stayed. 0.41.0 reworks the filter graph to target FFmpeg 8.0 or newer and bundles 9.0.1.

The most visible payoff is speed. FFmpeg 7.0 made the CLI pipeline fully parallel, with demuxing, decoding, filtering, encoding and muxing each on their own thread, where 6.1 serialized on the many PNG and JPEG inputs a typical composition has. We rendered about a hundred real-world compositions with both binaries from identical effie JSON:

Workloadmedian wall-clock, new/old
All compositions0.86
Photo slideshows and carousels0.81
Annie-heavy (x264-bound)0.93

Image-only compositions render in roughly three quarters of the time, and the best cases went from 34 seconds to 11. Nothing got slower. CPU time is unchanged, since it’s the same x264 core with the same settings, so this is a latency win rather than a throughput one: a render on a busy server won’t free up more capacity, but the person waiting for it gets it sooner.

The second payoff is frame accuracy. FFmpeg 6.1 dropped the last frame of every segment that had layers on it. With plain concat joins that accumulated, up to ten frames on a long slideshow in our test set, and content after each join started a frame early relative to the audio; crossfades skipped a blend step too. Output is now exactly fps × duration frames long, with transitions landing at their nominal offsets. Rendering the same hundred compositions on both versions confirmed that every remaining pixel difference traces back to that dropped frame.

A few smaller fixes came along with the rework:

  • Delayed layers are padded with tpad instead of a concatenated transparent color source, so a delayed layer smaller than the frame no longer fails the render, and a delay whose delay × fps isn’t an integer (say 0.25 s at 30 fps) no longer adds a frame per segment that drifted video from audio.
  • Output is pinned to limited color range. FFmpeg 7.1 and up negotiate color range separately, so a single JPEG layer used to turn the whole output full-range. It no longer does.
  • ffs render routes HTTP(S) inputs through the local proxy, like the server already did. FFmpeg 9 verifies TLS certificates by default and the static build has no system CA store, so https sources opened by FFmpeg directly would fail. The proxy also cancels the upstream fetch when FFmpeg drops a response, which it does on every byte-range re-request, instead of leaving the connection open.

On the installer side, @effing/ffmpeg now replaces an already-downloaded binary whose SHA-256 doesn’t match the pinned digest, so this version bump (and any future one) doesn’t leave a stale binary behind on existing installs. The old binary stays in place until its replacement has been downloaded and verified.

If you bring your own FFmpeg rather than the bundled one, it needs to be 8.0 or newer. 6.1 still renders, with the dropped-frame caveat above, and logs a warning once per process. The 7.x line is not supported: its xfade rejects the segment chains as variable frame rate.

Clip paths and frosted glass

renderReactElement() in @effing/canvas gains two CSS properties, both prompted by recent satori releases.

clipPath clips an element’s entire rendering, background, borders, box-shadow and children included, to a shape in the element’s own coordinate space, so it follows transform:

<div
  style={{
    width: 200,
    height: 200,
    background: "#3B82F6",
    clipPath: "shape(from 50% 0, line to 100% 100%, line to 0 100%, close)",
  }}
/>

All the basic shapes are supported: inset() with round, circle() and ellipse() with at positions, polygon(), path(), rect() and xywh(). So is the newer shape() function with its move, line, hline, vline, curve, smooth, arc and close commands, and the geometry-box keywords (border-box, padding-box, content-box, margin-box) either on their own or as a shape’s reference box. url(#id) references and calc() are not supported; an unrecognized value leaves the element unclipped, the same way a browser drops an invalid declaration. The common basic shapes are pixel-compared against satori in the test suite and come out identical.

backdropFilter takes whatever has already been painted behind the element’s border box, runs it through a filter, and paints the result back before the element’s own background. Put a translucent background on top and you get the frosted-glass look:

<div
  style={{
    padding: 24,
    borderRadius: 16,
    backdropFilter: "blur(12px) saturate(1.4)",
    backgroundColor: "rgba(255, 255, 255, 0.25)",
  }}
>
  Frosted glass
</div>

It follows borderRadius, accepts every filter function filter does, and snapshots the backdrop in device space so it works under any transform, with blur() lengths scaled along so a blur(10px) stays 10 CSS pixels wide however the element is scaled. The backdrop is everything drawn before the element, including anything you drew on the context before calling renderReactElement(); elements painted later are unaffected.

Both properties resolve em, rem, viewport and absolute units like the other string-valued properties, and the WebkitClipPath and WebkitBackdropFilter aliases map to the unprefixed names.

Also in this release

  • Font registration order no longer matters. Earlier versions of @napi-rs/canvas cached the typeface Skia picked for a given ctx.font for the life of the process and didn’t invalidate it when a font was registered. Measure text for a family before its face was registered and that lookup stayed pinned to the fallback, and nothing, including the registrations renderReactElement() does for options.fonts, could fix it. @effing/canvas now requires @napi-rs/canvas 1.0.9 or later, which invalidates the cache on registration, and a regression test covers the scenario.
  • Explicit return types on the effie schema factories. The createEffie*Schema() factories now declare named return types, so z.infer on a factory result yields the corresponding Effie* type alias, the same shape the default effie*Schema constants already expose. This also stops the generated API docs from reshuffling whenever an unrelated file in the monorepo changes.

Upgrading

Bump your @effing/* packages to 0.41.0. Four things to be aware of:

  • Videos rendered by @effing/ffs are now exactly fps × duration frames long, one frame longer than before.
  • If you bring your own FFmpeg, it needs to be 8.0 or newer.
  • @effing/canvas requires @napi-rs/canvas 1.0.9 or later.
  • The createEffie*Schema() factories return a ZodType rather than a ZodObject, so .shape, .extend and similar object-specific helpers are no longer available on their results.

The full per-package changelogs are in the release PR.