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:
| Workload | median wall-clock, new/old |
|---|---|
| All compositions | 0.86 |
| Photo slideshows and carousels | 0.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
tpadinstead of a concatenated transparent color source, so a delayed layer smaller than the frame no longer fails the render, and a delay whosedelay × fpsisn’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 renderroutes 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/canvascached the typeface Skia picked for a givenctx.fontfor 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 registrationsrenderReactElement()does foroptions.fonts, could fix it.@effing/canvasnow requires@napi-rs/canvas1.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, soz.inferon a factory result yields the correspondingEffie*type alias, the same shape the defaulteffie*Schemaconstants 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/ffsare now exactlyfps × durationframes long, one frame longer than before. - If you bring your own FFmpeg, it needs to be 8.0 or newer.
@effing/canvasrequires@napi-rs/canvas1.0.9 or later.- The
createEffie*Schema()factories return aZodTyperather than aZodObject, so.shape,.extendand similar object-specific helpers are no longer available on their results.
The full per-package changelogs are in the release PR.