detached 6283fa3

build(demo): stream gif mask and border with ffmpeg

The imagemagick -coalesce pipeline needed the whole ~22GiB uncompressed clip at once and either crawled for an hour on its disk cache or got OOM-killed. ffmpeg applies the same border overlay and rounded-window transparency mask frame by frame in under a minute with flat memory. Temp files are now removed from inside a container because rootless docker leaves them owned by a subuid.
commit
6283fa30010f0cfb54290b44af7eb59ef4d5acf6
author
brice · · 2026-07-29 13:50 UTC
parent
50c5c14
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
1 file · +20 -12
M Makefile +20 -12
@@ -30,24 +30,32 @@ demo:
3030 ./scripts/demo/run.sh demo.tape
3131 # vhs cannot emit a transparent margin, so cut the window out after the
3232 # fact: everything outside the rounded window rect becomes GIF
33- # transparency. The geometry mirrors demo.tape (1200x750 canvas, margin
34- # 20, border radius 10); keep them in sync.
33+ # transparency, plus a 2px edge stroked along the window. The geometry
34+ # mirrors demo.tape (1200x750 canvas, margin 20, border radius 10); keep
35+ # them in sync. ffmpeg streams the clip frame by frame, so peak memory
36+ # stays flat; the previous imagemagick -coalesce pipeline needed the whole
37+ # ~22GiB uncompressed clip at once and either crawled for an hour on its
38+ # disk cache or got OOM-killed — do not go back to it.
39+ docker run --rm --entrypoint magick -v "$(CURDIR)/scripts/demo/out:/out" vev-demo \
40+ -size 1200x750 xc:none -fill none -stroke "#30363D" -strokewidth 2 \
41+ -draw "roundrectangle 21,21 1178,728 9,9" /out/border.png
3542 docker run --rm --entrypoint magick -v "$(CURDIR)/scripts/demo/out:/out" vev-demo \
3643 -size 1200x750 xc:black -fill white \
3744 -draw "roundrectangle 20,20 1179,729 10,10" /out/mask.png
38- # The 2px edge is stroked before the mask is applied so the mask clips the
39- # stroke's antialiased overflow; drawing it after would leave partly
40- # transparent pixels, which a GIF's 1-bit alpha cannot represent.
41- docker run --rm --entrypoint magick -v "$(CURDIR)/scripts/demo/out:/out" vev-demo \
42- -limit memory 8GiB -limit map 12GiB /out/demo.gif -coalesce \
43- -fill none -stroke "#30363D" -strokewidth 2 \
44- -draw "roundrectangle 21,21 1178,728 9,9" \
45- null: \( /out/mask.png -alpha off \) -compose CopyOpacity \
46- -layers composite /out/demo-masked.gif
45+ # alphamerge applies the mask after the border overlay so the mask clips
46+ # the stroke's antialiased overflow, which a GIF's 1-bit alpha cannot
47+ # represent; paletteuse's alpha_threshold folds the rest into that 1-bit
48+ # alpha (palettegen reserves the transparent slot).
49+ docker run --rm --entrypoint ffmpeg -v "$(CURDIR)/scripts/demo/out:/out" vev-demo \
50+ -y -v error -i /out/demo.gif -i /out/border.png -i /out/mask.png \
51+ -filter_complex "[0:v][1:v]overlay=0:0[bordered];[2:v]format=gray[m];[bordered][m]alphamerge,split[a][b];[a]palettegen=reserve_transparent=1[p];[b][p]paletteuse=alpha_threshold=128" \
52+ /out/demo-masked.gif
4753 # --lossy above 30 smears the near-identical darks of the window chrome
4854 # (window bar vs terminal background) into visible banding, and the
4955 # conserve-memory fallback for huge inputs roughly doubles the output.
5056 docker run --rm --entrypoint gifsicle -v "$(CURDIR)/scripts/demo/out:/out" vev-demo --no-conserve-memory -O3 --lossy=30 -o /out/demo.gif /out/demo-masked.gif
51- rm scripts/demo/out/demo-masked.gif scripts/demo/out/mask.png
57+ # Temp files are owned by the container's subuid, so remove them from
58+ # inside a container rather than the host.
59+ docker run --rm --entrypoint sh -v "$(CURDIR)/scripts/demo/out:/out" vev-demo -c 'rm /out/demo-masked.gif /out/mask.png /out/border.png'
5260 mkdir -p docs/assets
5361 cp scripts/demo/out/demo.gif docs/assets/demo.gif
@@ -30,24 +30,32 @@ demo:
30 ./scripts/demo/run.sh demo.tape30 ./scripts/demo/run.sh demo.tape
31 # vhs cannot emit a transparent margin, so cut the window out after the31 # vhs cannot emit a transparent margin, so cut the window out after the
32 # fact: everything outside the rounded window rect becomes GIF32 # fact: everything outside the rounded window rect becomes GIF
33- # transparency. The geometry mirrors demo.tape (1200x750 canvas, margin33+ # transparency, plus a 2px edge stroked along the window. The geometry
34- # 20, border radius 10); keep them in sync.34+ # mirrors demo.tape (1200x750 canvas, margin 20, border radius 10); keep
35+ # them in sync. ffmpeg streams the clip frame by frame, so peak memory
36+ # stays flat; the previous imagemagick -coalesce pipeline needed the whole
37+ # ~22GiB uncompressed clip at once and either crawled for an hour on its
38+ # disk cache or got OOM-killed — do not go back to it.
39+ docker run --rm --entrypoint magick -v "$(CURDIR)/scripts/demo/out:/out" vev-demo \
40+ -size 1200x750 xc:none -fill none -stroke "#30363D" -strokewidth 2 \
41+ -draw "roundrectangle 21,21 1178,728 9,9" /out/border.png
35 docker run --rm --entrypoint magick -v "$(CURDIR)/scripts/demo/out:/out" vev-demo \42 docker run --rm --entrypoint magick -v "$(CURDIR)/scripts/demo/out:/out" vev-demo \
36 -size 1200x750 xc:black -fill white \43 -size 1200x750 xc:black -fill white \
37 -draw "roundrectangle 20,20 1179,729 10,10" /out/mask.png44 -draw "roundrectangle 20,20 1179,729 10,10" /out/mask.png
38- # The 2px edge is stroked before the mask is applied so the mask clips the45+ # alphamerge applies the mask after the border overlay so the mask clips
39- # stroke's antialiased overflow; drawing it after would leave partly46+ # the stroke's antialiased overflow, which a GIF's 1-bit alpha cannot
40- # transparent pixels, which a GIF's 1-bit alpha cannot represent.47+ # represent; paletteuse's alpha_threshold folds the rest into that 1-bit
41- docker run --rm --entrypoint magick -v "$(CURDIR)/scripts/demo/out:/out" vev-demo \48+ # alpha (palettegen reserves the transparent slot).
42- -limit memory 8GiB -limit map 12GiB /out/demo.gif -coalesce \49+ docker run --rm --entrypoint ffmpeg -v "$(CURDIR)/scripts/demo/out:/out" vev-demo \
43- -fill none -stroke "#30363D" -strokewidth 2 \50+ -y -v error -i /out/demo.gif -i /out/border.png -i /out/mask.png \
44- -draw "roundrectangle 21,21 1178,728 9,9" \51+ -filter_complex "[0:v][1:v]overlay=0:0[bordered];[2:v]format=gray[m];[bordered][m]alphamerge,split[a][b];[a]palettegen=reserve_transparent=1[p];[b][p]paletteuse=alpha_threshold=128" \
45- null: \( /out/mask.png -alpha off \) -compose CopyOpacity \52+ /out/demo-masked.gif
46- -layers composite /out/demo-masked.gif
47 # --lossy above 30 smears the near-identical darks of the window chrome53 # --lossy above 30 smears the near-identical darks of the window chrome
48 # (window bar vs terminal background) into visible banding, and the54 # (window bar vs terminal background) into visible banding, and the
49 # conserve-memory fallback for huge inputs roughly doubles the output.55 # conserve-memory fallback for huge inputs roughly doubles the output.
50 docker run --rm --entrypoint gifsicle -v "$(CURDIR)/scripts/demo/out:/out" vev-demo --no-conserve-memory -O3 --lossy=30 -o /out/demo.gif /out/demo-masked.gif56 docker run --rm --entrypoint gifsicle -v "$(CURDIR)/scripts/demo/out:/out" vev-demo --no-conserve-memory -O3 --lossy=30 -o /out/demo.gif /out/demo-masked.gif
51- rm scripts/demo/out/demo-masked.gif scripts/demo/out/mask.png57+ # Temp files are owned by the container's subuid, so remove them from
58+ # inside a container rather than the host.
59+ docker run --rm --entrypoint sh -v "$(CURDIR)/scripts/demo/out:/out" vev-demo -c 'rm /out/demo-masked.gif /out/mask.png /out/border.png'
52 mkdir -p docs/assets60 mkdir -p docs/assets
53 cp scripts/demo/out/demo.gif docs/assets/demo.gif61 cp scripts/demo/out/demo.gif docs/assets/demo.gif

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 12

Tags 11

↑↓ moveenter openesc close