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 Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
1 file · +20 -12
M Makefile +20 -12
| @@ -30,24 +30,32 @@ demo: | |||
| 30 | 30 | ./scripts/demo/run.sh demo.tape | |
| 31 | 31 | # vhs cannot emit a transparent margin, so cut the window out after the | |
| 32 | 32 | # 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 | |
| 35 | 42 | docker run --rm --entrypoint magick -v "$(CURDIR)/scripts/demo/out:/out" vev-demo \ | |
| 36 | 43 | -size 1200x750 xc:black -fill white \ | |
| 37 | 44 | -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 | |
| 47 | 53 | # --lossy above 30 smears the near-identical darks of the window chrome | |
| 48 | 54 | # (window bar vs terminal background) into visible banding, and the | |
| 49 | 55 | # conserve-memory fallback for huge inputs roughly doubles the output. | |
| 50 | 56 | 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' | |
| 52 | 60 | mkdir -p docs/assets | |
| 53 | 61 | cp scripts/demo/out/demo.gif docs/assets/demo.gif | |
| @@ -30,24 +30,32 @@ demo: | |||||
| 30 | ./scripts/demo/run.sh demo.tape | 30 | ./scripts/demo/run.sh demo.tape | ||
| 31 | # vhs cannot emit a transparent margin, so cut the window out after the | 31 | # vhs cannot emit a transparent margin, so cut the window out after the | ||
| 32 | # fact: everything outside the rounded window rect becomes GIF | 32 | # fact: everything outside the rounded window rect becomes GIF | ||
| 33 | - | # transparency. The geometry mirrors demo.tape (1200x750 canvas, margin | 33 | + | # 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.png | 44 | -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 | 45 | + | # alphamerge applies the mask after the border overlay so the mask clips |
| 39 | - | # stroke's antialiased overflow; drawing it after would leave partly | 46 | + | # 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 chrome | 53 | # --lossy above 30 smears the near-identical darks of the window chrome | ||
| 48 | # (window bar vs terminal background) into visible banding, and the | 54 | # (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.gif | 56 | 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' | |||
| 52 | mkdir -p docs/assets | 60 | mkdir -p docs/assets | ||
| 53 | cp scripts/demo/out/demo.gif docs/assets/demo.gif | 61 | cp scripts/demo/out/demo.gif docs/assets/demo.gif | ||