media: H.264 fallback beside every AV1, and the post links the H.264
All checks were successful
Deploy / build-deploy (push) Successful in 7m30s

The AV1-only publish lasted four hours in the wild: the first viewer on
mobile Safari got "bad media error" from the raw file, because a post's
link is fetched raw — Lemmy apps and browsers play that exact URL, with
no <source> negotiation in front of it. The previous commit's note
("--raw ... if that audience matters") had it backwards: the audience
that cannot play AV1 is not a per-post judgement call, it is whoever
happens to open the thread on an iPhone.

So publish-media.sh now emits two encodings and points the post at the
compatible one:

  * Every video transcode also produces <hash>.h264.mp4 (x264 crf 23,
    same denoise, same frames), uploaded beside the AV1 under the AV1's
    hash — the poster's sibling-naming trick, reused, so fetch-media.sh
    finds it by name with nothing to look up.
  * The printed URL to paste into the post is the H.264 one. pict-rs
    can thumbnail it too, so instance thumbnails come back as a bonus.

fetch-media.sh adopting an own-origin .h264.mp4 URL swaps the AV1 back
in as the page's primary when it is on the mount, and records the H.264
as `fallback` in posts.json. The renderer turns a non-empty fallback
into a <source> pair: the AV1 first with an explicit codecs parameter —
both files are video/mp4, so the parameter is the only thing that lets
a non-AV1 browser skip to the file it can play — and the H.264 second.
Browsers with AV1 keep downloading the small file; Safari before 17
gets one that plays instead of an element that will not.

e2e gains the matching conditional gate: a page offering an av01
<source> must offer an .h264.mp4 one, so an AV1 video can never again
ship without its fallback.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Jorijn van der Graaf 2026-08-08 03:14:51 +02:00
commit a625176c7d
6 changed files with 142 additions and 25 deletions

View file

@ -132,8 +132,13 @@ pointing at its original URL rather than losing the post.
**The recommended flow is to put a recording on catcrafts.net before writing the **The recommended flow is to put a recording on catcrafts.net before writing the
post, and use that URL as the post's link.** Run `tools/publish-media.sh post, and use that URL as the post's link.** Run `tools/publish-media.sh
recording.mp4`; it transcodes, uploads to the media mount under its content hash, recording.mp4`; it transcodes to AV1 *and* an H.264 sibling, uploads both to the
and prints a `https://catcrafts.net/media/<hash>.mp4` URL to paste into the post. media mount under the AV1's content hash, and prints a
`https://catcrafts.net/media/<hash>.h264.mp4` URL to paste into the post. The
H.264 one on purpose: a post's link is fetched raw — Lemmy apps and browsers play
that exact file, with no negotiation in front of it — so it must be the encoding
everything can play. (An AV1 link posted before this existed drew "bad media
error" reports from iPhones within hours.)
`fetch-media.sh` recognises its own origin and **adopts** such a URL: it rewrites `fetch-media.sh` recognises its own origin and **adopts** such a URL: it rewrites
it to `/media/<hash>` and probes the local file for dimensions, downloading it to `/media/<hash>` and probes the local file for dimensions, downloading
@ -143,6 +148,12 @@ file host once hit `MAX_BYTES` (64 MB), so the entry kept its original URL, and
the deploy then failed the e2e "media origin" check on a file that was sitting on the deploy then failed the e2e "media origin" check on a file that was sitting on
our own disk the entire time. our own disk the entire time.
Adopting a `<hash>.h264.mp4` URL swaps the AV1 sibling back in as the page's
primary `<source>` when it is on the mount, keeping the H.264 as the fallback
`<source>`. So the post links the compatible file, while browsers that can take
AV1 download the small one — the `codecs` parameter on the first source is what
lets the rest skip it.
The transcode matters as much as the hosting. Phone recordings are wildly The transcode matters as much as the hosting. Phone recordings are wildly
oversized for what they show — that same 167 MB clip was 78 s of a dark room at oversized for what they show — that same 167 MB clip was 78 s of a dark room at
17 Mbps, and denoising into AV1 gives the same picture in 15 MB. It also **bakes 17 Mbps, and denoising into AV1 gives the same picture in 15 MB. It also **bakes
@ -154,15 +165,18 @@ correct too — but transcoding means nothing downstream has to know.)
Two things to know about publishing AV1: Two things to know about publishing AV1:
* Nothing emits a fallback encoding and `<video>` carries a single `src`, so * Browsers without AV1 (Safari before 17, Apple hardware older than A17/M3) get
browsers without AV1 (Safari before 17, Apple hardware older than A17/M3) get the `<hash>.h264.mp4` sibling: on the site via the second `<source>`, on the
an element that will not play. Use `publish-media.sh --raw`, or a H.264 fediverse because that sibling *is* the posted URL. `--raw` skips the
re-encode, when that matters for a particular post. transcode and the fallback both, so a raw AV1 upload recreates the
will-not-play problem — use it for files that are already universally
playable.
* Lemmy's `pict-rs` will not generate a thumbnail from an AV1 file, so a * Lemmy's `pict-rs` will not generate a thumbnail from an AV1 file, so a
self-hosted video usually arrives with no `poster`. `publish-media.sh` uploads self-hosted video usually arrives with no `poster`. `publish-media.sh` uploads
a poster frame beside the video, named `<video-hash>.poster.webp`, and a poster frame beside the video, named `<video-hash>.poster.webp`, and
`fetch-media.sh` falls back to that sibling when the instance supplied nothing. `fetch-media.sh` falls back to that sibling when the instance supplied nothing.
A thumbnail the instance *did* provide always wins. A thumbnail the instance *did* provide always wins. (Posting the H.264 URL
also means pict-rs can thumbnail it again, so instance thumbnails come back.)
An own-origin URL naming a file that is **not** on the mount is deliberately left An own-origin URL naming a file that is **not** on the mount is deliberately left
pointing at its original URL rather than rewritten. That is a post published pointing at its original URL rather than rewritten. That is a post published

View file

@ -50,6 +50,12 @@ export struct PostMedia {
// play — and these posts are their video, so the black box is the page. // play — and these posts are their video, so the black box is the page.
// Empty for images, and empty when the instance generated no thumbnail. // Empty for images, and empty when the instance generated no thumbnail.
std::string poster; std::string poster;
// H.264 rendition of an AV1 video, when tools/publish-media.sh uploaded one
// beside it. Non-empty means the renderer emits a <source> pair instead of
// a bare src, so a browser without AV1 (Safari before 17, Apple hardware
// without the decoder) gets a file it can play. Empty for images and for
// videos that need no fallback.
std::string fallback;
std::int64_t width = 0; std::int64_t width = 0;
std::int64_t height = 0; std::int64_t height = 0;
}; };
@ -311,6 +317,7 @@ export std::vector<Post> LoadPosts(std::string_view json) {
pm.src = std::string(mv.Str("src")); pm.src = std::string(mv.Str("src"));
pm.kind = std::string(mv.Str("kind", "image")); pm.kind = std::string(mv.Str("kind", "image"));
pm.poster = std::string(mv.Str("poster")); pm.poster = std::string(mv.Str("poster"));
pm.fallback = std::string(mv.Str("fallback"));
pm.width = mv.Int("w"); pm.width = mv.Int("w");
pm.height = mv.Int("h"); pm.height = mv.Int("h");
// Only the two kinds the renderer knows how to emit. Anything // Only the two kinds the renderer knows how to emit. Anything

View file

@ -353,10 +353,29 @@ SafeHtml RenderPostMedia(std::span<const PostMedia> media) {
: SafeHtml{}; : SafeHtml{};
if (m.kind == "video") { if (m.kind == "video") {
SafeHtml poster = m.poster.empty() ? SafeHtml{} : Url("poster", m.poster); SafeHtml poster = m.poster.empty() ? SafeHtml{} : Url("poster", m.poster);
if (m.fallback.empty() || m.fallback == m.src) {
items.push_back(Format( items.push_back(Format(
R"(<video class="post-media__item" controls preload="metadata" )" R"(<video class="post-media__item" controls preload="metadata" )"
R"(playsinline{}{}{}></video>)", R"(playsinline{}{}{}></video>)",
Url("src", m.src), poster, dims)); Url("src", m.src), poster, dims));
} else {
// An AV1 video with its H.264 rendition. Both are .mp4, so the
// container alone cannot tell them apart: the codecs parameter
// on the first <source> is what lets a browser without AV1
// (Safari before 17, Apple hardware without the decoder) skip
// it and take the H.264 instead of failing on a file it cannot
// decode. The string is advisory and used only for selection —
// once a source is picked the browser reads the actual stream —
// so the canonical profile-0 8-bit form is right for anything
// publish-media.sh emits (yuv420p is pinned there).
items.push_back(Format(
R"(<video class="post-media__item" controls preload="metadata" )"
R"(playsinline{}{}>)"
R"(<source{} type="video/mp4; codecs=av01.0.08M.08">)"
R"(<source{} type="video/mp4">)"
R"(</video>)",
poster, dims, Url("src", m.src), Url("src", m.fallback)));
}
} else { } else {
// alt is empty and aria-hidden is absent on purpose: these are // alt is empty and aria-hidden is absent on purpose: these are
// screenshots whose meaning is already in the post title and // screenshots whose meaning is already in the post title and

View file

@ -383,6 +383,18 @@ if curl -s "$BASE/posts" | grep -qE '<video class="post-media__item"[^>]*poster=
else else
bad "video poster" "no video has a poster; a black box shows until play" bad "video poster" "no video has a poster; a black box shows until play"
fi fi
# A video offering an AV1 <source> must offer an H.264 one after it: the codecs
# parameter is what lets a browser without AV1 (Safari before 17, Apple hardware
# without the decoder) skip to a file it can play, and an AV1 source alone is
# exactly the "element that will not play" the fallback pipeline exists to
# prevent. Conditional — a build whose posts carry no AV1 has nothing to check.
if curl -s "$BASE/posts" | grep -q 'codecs=av01'; then
if curl -s "$BASE/posts" | grep -qE '<source src="/media/[^"]*\.h264\.mp4" type="video/mp4">'; then
ok "AV1 videos carry an H.264 fallback source"
else
bad "video fallback" "an av01 <source> has no h264 sibling"
fi
fi
# preload="metadata", not auto: several 5 MB recordings must not all download on # preload="metadata", not auto: several 5 MB recordings must not all download on
# page load. # page load.
if curl -s "$BASE/posts" | grep -q 'preload="metadata"'; then if curl -s "$BASE/posts" | grep -q 'preload="metadata"'; then

View file

@ -97,9 +97,11 @@ probe_dims() {
MAP="$(mktemp)" MAP="$(mktemp)"
POSTERMAP="$(mktemp)" POSTERMAP="$(mktemp)"
trap 'rm -f "$MAP" "$POSTERMAP"' EXIT FALLBACKMAP="$(mktemp)"
trap 'rm -f "$MAP" "$POSTERMAP" "$FALLBACKMAP"' EXIT
printf '[]' > "$MAP" printf '[]' > "$MAP"
printf '[]' > "$POSTERMAP" printf '[]' > "$POSTERMAP"
printf '[]' > "$FALLBACKMAP"
downloaded=0 downloaded=0
reused=0 reused=0
@ -132,6 +134,17 @@ while IFS= read -r src; do
echo "fetch-media: refusing suspicious own-origin URL: $src" >&2 echo "fetch-media: refusing suspicious own-origin URL: $src" >&2
failed=$((failed + 1)); continue ;; failed=$((failed + 1)); continue ;;
esac esac
# A .h264.mp4 URL is the fediverse-facing form of an AV1 video: the
# post links the encoding everything can play, because that link is
# fetched raw by Lemmy apps. Our page can negotiate, so when the AV1
# sibling is on the mount it becomes the primary <source> and the
# H.264 drops to the fallback (picked up by name below).
case "$name" in
*.h264.mp4)
av1="${name%.h264.mp4}.mp4"
[ -f "$MEDIA_DIR/$av1" ] && name="$av1"
;;
esac
dest="$MEDIA_DIR/$name" dest="$MEDIA_DIR/$name"
if [ ! -f "$dest" ]; then if [ ! -f "$dest" ]; then
echo "fetch-media: $name not on the media mount, keeping original URL: $src" >&2 echo "fetch-media: $name not on the media mount, keeping original URL: $src" >&2
@ -189,12 +202,29 @@ while IFS= read -r src; do
# box until someone presses play. # box until someone presses play.
case "$name" in case "$name" in
*.mp4|*.webm|*.mov) *.mp4|*.webm|*.mov)
sibling="${name%.*}.poster.webp" # Siblings are named after the AV1's hash, so strip the .h264
# marker too — a video adopted as X.h264.mp4 (its AV1 never
# published) still finds X.poster.webp.
base="${name%.*}"
base="${base%.h264}"
sibling="$base.poster.webp"
if [ -f "$MEDIA_DIR/$sibling" ]; then if [ -f "$MEDIA_DIR/$sibling" ]; then
jq --arg k "/media/$name" --arg v "/media/$sibling" \ jq --arg k "/media/$name" --arg v "/media/$sibling" \
'. + [{key: $k, value: $v}]' "$POSTERMAP" > "$POSTERMAP.new" \ '. + [{key: $k, value: $v}]' "$POSTERMAP" > "$POSTERMAP.new" \
&& mv "$POSTERMAP.new" "$POSTERMAP" && mv "$POSTERMAP.new" "$POSTERMAP"
fi fi
# The H.264 sibling publish-media.sh uploaded next to the AV1.
# Rendered as a second <source> so browsers without AV1 (Safari
# before 17, Apple hardware without the decoder) get a file they
# can play instead of an element that will not. The guard against
# naming itself covers a video adopted as X.h264.mp4 whose AV1 is
# not on the mount.
fb="$base.h264.mp4"
if [ "$fb" != "$name" ] && [ -f "$MEDIA_DIR/$fb" ]; then
jq --arg k "/media/$name" --arg v "/media/$fb" \
'. + [{key: $k, value: $v}]' "$FALLBACKMAP" > "$FALLBACKMAP.new" \
&& mv "$FALLBACKMAP.new" "$FALLBACKMAP"
fi
;; ;;
esac esac
@ -212,9 +242,11 @@ echo "fetch-media: $downloaded new, $reused already present, $adopted self-hoste
# failed) keeps its original src, so the page still shows something rather than # failed) keeps its original src, so the page still shows something rather than
# silently dropping the post's whole point. # silently dropping the post's whole point.
TMP_POSTS="$(mktemp)" TMP_POSTS="$(mktemp)"
if jq --slurpfile map "$MAP" --slurpfile posters "$POSTERMAP" ' if jq --slurpfile map "$MAP" --slurpfile posters "$POSTERMAP" \
--slurpfile fallbacks "$FALLBACKMAP" '
($map[0] | map({key: .src, value: .}) | from_entries) as $m ($map[0] | map({key: .src, value: .}) | from_entries) as $m
| ($posters[0] | from_entries) as $pm | ($posters[0] | from_entries) as $pm
| ($fallbacks[0] | from_entries) as $fm
| map(.media = ((.media // []) | map( | map(.media = ((.media // []) | map(
. as $item . as $item
| ($m[$item.src] // null) as $hit | ($m[$item.src] // null) as $hit
@ -233,6 +265,12 @@ if jq --slurpfile map "$MAP" --slurpfile posters "$POSTERMAP" '
# empty — a thumbnail the instance did provide always wins. # empty — a thumbnail the instance did provide always wins.
| if ((.poster // "") == "") and (($pm[.src] // "") != "") | if ((.poster // "") == "") and (($pm[.src] // "") != "")
then . + { poster: $pm[.src] } then . + { poster: $pm[.src] }
else . end
# H.264 fallback, keyed by the LOCAL src like the poster map. Only ever
# set for media on our own mount — a mirrored third-party file has no
# sibling to find.
| if (($fm[.src] // "") != "")
then . + { fallback: $fm[.src] }
else . end))) else . end)))
' "$POSTS" > "$TMP_POSTS" 2>/dev/null; then ' "$POSTS" > "$TMP_POSTS" 2>/dev/null; then
# Same reason as the chmod on each mirrored file: mktemp is 0600 and the # Same reason as the chmod on each mirrored file: mktemp is 0600 and the

View file

@ -30,11 +30,16 @@
# downstream consumer measures is what the viewer actually sees — see the # downstream consumer measures is what the viewer actually sees — see the
# rotation note in fetch-media.sh for what goes wrong when it does not. # rotation note in fetch-media.sh for what goes wrong when it does not.
# #
# NOTE ON AV1: nothing here emits a fallback encoding, and the <video> tag the # FALLBACK: AV1 alone locks out Safari before 17 and Apple hardware older than
# site renders carries a single src. Browsers without AV1 (Safari before 17, and # A17/M3 — which surfaced the first time an AV1 link was posted and an iPhone
# Apple hardware older than A17/M3) get an element that will not play rather than # viewer got "bad media error" from the raw file. So every transcode also emits
# a degraded one. Pass --raw, or re-encode to H.264, if that audience matters for # an H.264 sibling, uploaded as <hash>.h264.mp4 next to the AV1 (same trick as
# a particular post. # the poster: named after the AV1's hash so fetch-media.sh finds it with nothing
# to look up). The URL this script prints for the post is the H.264 one, because
# a post's link is fetched raw — Lemmy apps and browsers play that exact file,
# with no <source> negotiation in front of it — and it must be the encoding
# everything can play. Our own pages recognise the .h264.mp4 name, serve the AV1
# to browsers that can take it, and keep the H.264 as the <source> fallback.
set -eu set -eu
@ -95,6 +100,7 @@ for src in $FILES; do
esac esac
poster="" poster=""
fallback=""
if [ "$RAW" = 1 ] || [ "$kind" = other ]; then if [ "$RAW" = 1 ] || [ "$kind" = other ]; then
payload="$src" payload="$src"
out_ext="$ext" out_ext="$ext"
@ -111,6 +117,18 @@ for src in $FILES; do
# AAC rather than Opus on purpose: Opus-in-MP4 is still uneven in exactly # AAC rather than Opus on purpose: Opus-in-MP4 is still uneven in exactly
# the players most likely to be shaky about AV1 anyway, and the audio is # the players most likely to be shaky about AV1 anyway, and the audio is
# a rounding error next to the video either way. # a rounding error next to the video either way.
fallback="$WORK/h264.mp4"
echo " transcoding H.264 fallback (crf 23)…"
# Same source and same denoise as the AV1, so the two encodings show the
# same frames. CRF is x264's own scale (not comparable to SVT-AV1's);
# 23 is its "looks the same" default, and this file is the compatibility
# rail, not the one most visitors download — bigger is acceptable here.
ffmpeg -nostdin -v error -i "$src" \
-vf "hqdn3d=4:3:6:4.5" \
-c:v libx264 -preset slow -crf 23 -pix_fmt yuv420p \
-movflags +faststart \
-c:a aac -b:a 96k -ac 1 \
"$fallback" -y
poster="$WORK/poster.webp" poster="$WORK/poster.webp"
# A frame a third of the way in, which beats frame 0 — recordings tend to # A frame a third of the way in, which beats frame 0 — recordings tend to
# open on a lock screen, a hand moving into place, or a fade. # open on a lock screen, a hand moving into place, or a fade.
@ -130,23 +148,32 @@ for src in $FILES; do
name="$hash.$out_ext" name="$hash.$out_ext"
publish_file "$payload" "$name" publish_file "$payload" "$name"
# The poster is named after the VIDEO's hash, not its own. That is the whole # The poster and the H.264 fallback are named after the AV1's hash, not
# point: fetch-media.sh finds it by name, with nothing to look up, when the # their own. That is the whole point: fetch-media.sh finds both by name,
# instance did not manage to make a thumbnail — pict-rs will not read AV1, so # with nothing to look up — the poster when the instance made no thumbnail
# for these uploads that is the normal case rather than the exception. # (pict-rs will not read AV1, so for these uploads that is the normal
# case), the fallback for every browser that cannot decode AV1.
if [ -n "$poster" ] && [ -f "$poster" ]; then if [ -n "$poster" ] && [ -f "$poster" ]; then
publish_file "$poster" "$hash.poster.webp" publish_file "$poster" "$hash.poster.webp"
fi fi
post_name="$name"
if [ -n "$fallback" ] && [ -f "$fallback" ]; then
post_name="$hash.h264.mp4"
publish_file "$fallback" "$post_name"
fi
url="$SITE_ORIGIN/media/$name" # The postable URL is the H.264 one when it exists — see FALLBACK above.
url="$SITE_ORIGIN/media/$post_name"
# Confirm it is actually being served before handing over a URL that is about # Confirm it is actually being served before handing over a URL that is about
# to be pasted into a post, where a 404 is public and permanent. # to be pasted into a post, where a 404 is public and permanent.
code=$(curl -s -o /dev/null -w '%{http_code}' --max-time 30 "$url" || echo 000) code=$(curl -s -o /dev/null -w '%{http_code}' --max-time 30 "$url" || echo 000)
if [ "$code" = 200 ]; then if [ "$code" = 200 ]; then
printf ' %s\n' "$url" printf ' post this: %s\n' "$url"
else else
echo " WARNING: $url answered HTTP $code — do not post this link yet" >&2 echo " WARNING: $url answered HTTP $code — do not post this link yet" >&2
fi fi
printf ' %s -> %s (%s)\n' \ printf ' %s -> %s (%s)\n' \
"$(du -h "$src" | cut -f1)" "$(du -h "$payload" | cut -f1)" "$name" "$(du -h "$src" | cut -f1)" "$(du -h "$payload" | cut -f1)" "$name"
[ -z "$fallback" ] || printf ' fallback: %s (%s)\n' \
"$(du -h "$fallback" | cut -f1)" "$post_name"
done done