2026-08-05 04:18:37 +02:00
#!/bin/sh
# Mirror the media referenced by content/posts.json, and rewrite the entries to
# point at our own copies.
#
# Run AFTER tools/fetch-posts.sh, which records the original URLs.
#
2026-08-10 01:37:26 +02:00
# TWO KINDS OF MEDIA, one pipeline:
#
# * a post's headline file — `.media`, the recording or screenshot the post is
# about;
# * everything embedded inside the body — the screenshots a post argues with,
# which are just as much content and, until the body was hosted here, were
# never fetched at all.
#
# Both are content-addressed into the same directory, so a file used as one
# post's headline and quoted inside another's body is stored once. Body files
# are rewritten IN THE MARKDOWN TEXT (the body is still Markdown at this point)
# and additionally recorded in `.body_media`, which is where the renderer reads
# the dimensions, poster and H.264 fallback that Markdown syntax has nowhere to
# carry.
#
2026-08-05 04:18:37 +02:00
# WHY MIRROR rather than embed from the source:
#
# * Privacy. The privacy notice states that everything the browser loads comes
# from catcrafts.net, and it should stay true. Embedding directly would send
# every visitor's IP address to whichever instance hosts the file — an odd
# thing to do on a site selling a privacy-focused phone.
# * Durability. These posts ARE their media: the screen recording of VoLTE
# working is the content. If the source instance deletes it or disappears,
# a direct embed becomes a broken box and the post loses its point.
# * Cost. One download per file, ever, instead of one per visitor. Kinder to
# small instances than hotlinking them.
#
# Files are content-addressed (sha256 of the bytes), so a file already present is
# never downloaded again and a changed file gets a new name — which makes the
# long cache lifetime Caddy sets honest.
#
# usage: tools/fetch-media.sh [media-dir] (default: media/)
#
# On any single download failure the entry keeps its original URL and the script
# carries on, so one dead file does not cost the whole page. Exits non-zero only
# if it cannot do its job at all.
set -eu
MEDIA_DIR = " ${ 1 :- media } "
POSTS = "content/posts.json"
MAX_BYTES = $(( 64 * 1024 * 1024 ))
2026-08-10 01:37:26 +02:00
# What counts as a media reference inside a post body: an absolute URL or a path
# we have already rewritten, ending in a media extension.
#
# Local paths are in the pattern deliberately. Leaving them out looked right —
# nothing needs downloading twice — but it is what made a second run destructive
# rather than idempotent: the already-rewritten body references were not
# enumerated, so they never re-entered the mirror map, so the body_media sidecar
# came back with only the handful of entries that happened to still be absolute.
# Matching them means they are adopted from the mount and everything is rebuilt
# exactly as it was.
#
# One definition, passed to every jq that needs it, because three copies of a
# regex is three chances for one of them to drift.
MEDIA_REF_RE = '(?:https?://|/media/)[^\s)\]"<>]+\.(?:mp4|webm|mov|webp|png|jpe?g|gif|avif)'
media: publish to our own origin instead of mirroring a file host
A 167 MB screen recording uploaded to catbox.moe hit fetch-media.sh's 64 MB
MAX_BYTES, so curl refused it, the entry kept its third-party URL, and e2e
failed "/posts media origin" on a file we had on disk the whole time. Raising
the cap would have papered over it: the mirror step still depends on someone
else's server being up, fast, and still holding the file.
Invert it. tools/publish-media.sh uploads a recording to the media mount under
its content hash BEFORE the post exists and prints the URL to post, and
fetch-media.sh adopts an own-origin URL by rewriting it to /media/<hash> with no
request at all — no size cap, no third party in the build.
Also here, because publishing exposed them:
* Rotation. Phones record 1920x1080 and attach a display matrix rather than
rotating pixels, so an untouched file reports landscape while playing
portrait and width/height reserve exactly the wrong box. publish-media.sh
bakes rotation into the frames; fetch-media.sh swaps the dimensions on a
quarter-turn matrix for anything mirrored straight from a phone.
* Posters. pict-rs will not thumbnail AV1, so a self-hosted video usually
arrives with no poster. publish-media.sh uploads <hash>.poster.webp beside
the video and fetch-media.sh falls back to it — a real thumbnail still wins.
* The workflow comment claiming a file on the mount is never downloaded again
was wrong: the name is the hash of the bytes, so third-party media is
re-fetched every build and only the write is skipped.
Transcoding to AV1 is what makes self-hosting cheap: that clip was 78 s of a
dark room at 17 Mbps, and denoise + AV1 gives the same picture in 15 MB. Note
<video> carries a single src with no fallback, so AV1 excludes Safari < 17;
--raw skips the transcode when that matters.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-08 01:16:17 +02:00
# Media we host ourselves, published by tools/publish-media.sh before the post
# that carries it exists. Such a URL is ALREADY the one the page should use, so
# there is nothing to fetch: the bytes are on the media mount, and downloading
# them back from our own web server would only mint a second copy under a second
# name. Skipping the download also skips MAX_BYTES, which is what a 167 MB
# recording on a third-party file host ran into — and it removes the last part of
# a build that could fail because someone else's server was slow, rate-limiting
# or gone.
OWN_ORIGIN = "https://catcrafts.net/media/"
2026-08-05 04:18:37 +02:00
command -v jq >/dev/null 2>& 1 || { echo "fetch-media: jq not found" >& 2; exit 1; }
[ -f " $POSTS " ] || { echo " fetch-media: $POSTS not found — run fetch-posts.sh first " >& 2; exit 1; }
mkdir -p " $MEDIA_DIR "
# ffprobe gives real pixel dimensions, which become width/height attributes.
# Without them the browser cannot reserve space and the text below jumps as each
# image arrives; with them the layout is stable on first paint. Optional — the
# markup degrades to no dimensions rather than failing.
HAVE_FFPROBE = 0
command -v ffprobe >/dev/null 2>& 1 && HAVE_FFPROBE = 1
2026-08-10 01:37:26 +02:00
# ffmpeg does the still-image transcodes below. Optional in exactly the same
# way ffprobe is: without it every image is served as the single file the mirror
# downloaded, which is what this site did before the format ladder existed.
HAVE_FFMPEG = 0
command -v ffmpeg >/dev/null 2>& 1 && HAVE_FFMPEG = 1
media: publish to our own origin instead of mirroring a file host
A 167 MB screen recording uploaded to catbox.moe hit fetch-media.sh's 64 MB
MAX_BYTES, so curl refused it, the entry kept its third-party URL, and e2e
failed "/posts media origin" on a file we had on disk the whole time. Raising
the cap would have papered over it: the mirror step still depends on someone
else's server being up, fast, and still holding the file.
Invert it. tools/publish-media.sh uploads a recording to the media mount under
its content hash BEFORE the post exists and prints the URL to post, and
fetch-media.sh adopts an own-origin URL by rewriting it to /media/<hash> with no
request at all — no size cap, no third party in the build.
Also here, because publishing exposed them:
* Rotation. Phones record 1920x1080 and attach a display matrix rather than
rotating pixels, so an untouched file reports landscape while playing
portrait and width/height reserve exactly the wrong box. publish-media.sh
bakes rotation into the frames; fetch-media.sh swaps the dimensions on a
quarter-turn matrix for anything mirrored straight from a phone.
* Posters. pict-rs will not thumbnail AV1, so a self-hosted video usually
arrives with no poster. publish-media.sh uploads <hash>.poster.webp beside
the video and fetch-media.sh falls back to it — a real thumbnail still wins.
* The workflow comment claiming a file on the mount is never downloaded again
was wrong: the name is the hash of the bytes, so third-party media is
re-fetched every build and only the write is skipped.
Transcoding to AV1 is what makes self-hosting cheap: that clip was 78 s of a
dark room at 17 Mbps, and denoise + AV1 gives the same picture in 15 MB. Note
<video> carries a single src with no fallback, so AV1 excludes Safari < 17;
--raw skips the transcode when that matters.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-08 01:16:17 +02:00
# Sets $w and $h for the file named in $1, or leaves both 0.
#
# One query per dimension. Asking for both at once and splitting the CSV looked
# simpler but was wrong: for some files ffprobe appends an empty field, so
# `width,height` came back as "854x480x" and splitting on `x` gave a height of
# "480x" — which the digit guard below then threw away, silently costing the
# dimensions of exactly the videos that had the extra field. `nk=1` prints the
# bare value, so there is nothing to split.
#
# ROTATION: a phone records 1920x1080 and attaches a display matrix rather than
# rotating the pixels, so the stream reads landscape while the video plays
# portrait. Believing the stream there reserves a landscape box for a portrait
# video — precisely the layout shift these attributes exist to prevent — so a
# quarter-turn swaps them. Files that went through publish-media.sh have the
# rotation baked into the pixels and report no matrix at all; this is for
# anything mirrored straight from a phone.
probe_dims( ) {
w = 0; h = 0
[ " $HAVE_FFPROBE " = 1 ] || return 0
pw = $( ffprobe -v error -select_streams v:0 -show_entries stream = width \
-of default = nw = 1:nk= 1 " $1 " 2>/dev/null | head -n1 || true )
ph = $( ffprobe -v error -select_streams v:0 -show_entries stream = height \
-of default = nw = 1:nk= 1 " $1 " 2>/dev/null | head -n1 || true )
rot = $( ffprobe -v error -select_streams v:0 \
-show_entries stream_side_data = rotation \
-of default = nw = 1:nk= 1 " $1 " 2>/dev/null | head -n1 || true )
case " $pw " in '' | *[ !0-9] *) pw = 0 ; ; esac
case " $ph " in '' | *[ !0-9] *) ph = 0 ; ; esac
# ffprobe reports this as a signed number that some builds print with a
# fractional part ("-90.000000"), so compare on the integer portion.
case " ${ rot %%.* } " in
90| -90| 270| -270) t = $pw ; pw = $ph ; ph = $t ; ;
esac
# Both or neither: a lone dimension is worse than none, because the browser
# derives the missing one from it and gets the aspect wrong.
if [ " $pw " -gt 0 ] && [ " $ph " -gt 0 ] ; then w = $pw ; h = $ph ; fi
if [ " $w " = 0 ] ; then
echo " fetch-media: no dimensions for $1 ; layout will shift on load " >& 2
fi
}
2026-08-10 01:37:26 +02:00
# Derive the two renditions a mirrored image is served between: AVIF above it
# and PNG below. Sets $avif_name / $png_name to the sibling file names, or
# leaves one empty when that rendition could not be produced — :Media then drops
# the tier rather than pointing at a file that is not on the mount.
#
# Siblings are named after the source file, which is itself the hash of its
# bytes, so a rendition already present is never re-encoded and a changed source
# gets new names. Only genuinely new images cost encoder time; a rebuild costs
# none, which is what keeps this off the critical path of every deploy.
#
# WHY BOTH TIERS. AVIF is smaller than the WebP the instances serve (~15% on
# these screenshots, far more on photographs) and is what almost every visitor
# actually receives. PNG is lossless and universally understood, which is what
# makes it a fallback worth having — but it is also several times the size of
# the WebP beside it, so the <picture> offers the mirrored original in between
# and the PNG is reached only by a browser that understands neither of the
# other two.
#
# The settings, measured against these files rather than guessed:
# crf 26, cpu-used 6 SSIM 0.997 against the source and still smaller than
# it, at roughly half a second per image.
# yuv444p these are screenshots of text. Re-subsampling chroma
# that pict-rs already subsampled once fringes coloured
# text visibly, and full chroma costs about 3% here.
transcode_image( ) {
avif_name = ""
png_name = ""
_file = " $1 "
_name = " $2 "
_base = " ${ _name %.* } "
# Already that format: serve the mirrored file as the tier rather than
# re-encoding it into a second copy of itself.
case " $_name " in *.avif) avif_name = " $_name " ; ; esac
case " $_name " in *.png) png_name = " $_name " ; ; esac
[ " $HAVE_FFMPEG " = 1 ] || return 0
# An animated source is not a still, and -frames:v 1 would silently freeze
# it. Leave it entirely alone: one moving GIF is worth more than three
# copies of its first frame. nb_frames is N/A for WebP, so the frames have
# to actually be counted — ~75 ms on a 3 MP image, once per new file.
_frames = $( ffprobe -v error -select_streams v:0 -count_frames \
-show_entries stream = nb_read_frames \
-of default = nw = 1:nk= 1 " $_file " 2>/dev/null | head -n1)
case " $_frames " in
'' | *[ !0-9] *| 1) ; ; # unknown or a single frame: a still
*) echo " fetch-media: $_name is animated, serving it as one file " >& 2
return 0 ; ;
esac
# Alpha has to survive the transcode: an image with a transparent corner
# encoded into a format with no alpha plane gains an opaque black one.
_pixfmt = $( ffprobe -v error -select_streams v:0 -show_entries stream = pix_fmt \
-of default = nw = 1:nk= 1 " $_file " 2>/dev/null | head -n1)
case " $_pixfmt " in
yuva*| rgba*| bgra*| argb*| abgr*| gbrap*| ya8| ya16*| pal8) _avif_pix = yuva444p ; ;
*) _avif_pix = yuv444p ; ;
esac
# Encoded to a .part, checked, and only then renamed — so an interrupted or
# wrong-format encode cannot leave a file the next run adopts as finished.
if [ -z " $avif_name " ] ; then
_cand = " $_base .avif "
if [ -f " $MEDIA_DIR / $_cand " ] ; then
avif_name = " $_cand "
elif encode_rendition " $_file " " $_cand " av1 \
-c:v libaom-av1 -still-picture 1 -crf 26 -cpu-used 6 \
-pix_fmt " $_avif_pix " -f avif; then
avif_name = " $_cand "
fi
fi
if [ -z " $png_name " ] ; then
_cand = " $_base .png "
if [ -f " $MEDIA_DIR / $_cand " ] ; then
png_name = " $_cand "
elif encode_rendition " $_file " " $_cand " png -c:v png -f image2; then
png_name = " $_cand "
fi
fi
}
# encode_rendition SRC OUTNAME EXPECTED_CODEC ffmpeg-args...
#
# Runs the encode into a .part, verifies the result really is the codec asked
# for, and only then publishes it. Returns non-zero (leaving nothing behind) if
# either step fails, which drops that tier rather than shipping a broken one.
#
# The verification is not paranoia. ffmpeg picks an encoder from the MUXER when
# one is not named, and the image2 muxer defaults to MJPEG — so `-f image2
# out.png` silently produced a run of lossy JPEGs sitting under .png names, which
# the page then advertised to browsers as image/png. The codec is pinned by the
# callers above; this is the check that the pin held.
encode_rendition( ) {
_src = " $1 " ; _out = " $2 " ; _want = " $3 "
shift 3
if ! ffmpeg -y -v error -i " $_src " -frames:v 1 " $@ " \
" $MEDIA_DIR / $_out .part " 2>/dev/null; then
rm -f " $MEDIA_DIR / $_out .part "
echo " fetch-media: could not encode $_out , serving without that tier " >& 2
return 1
fi
_got = $( ffprobe -v error -select_streams v:0 -show_entries stream = codec_name \
-of default = nw = 1:nk= 1 " $MEDIA_DIR / $_out .part " 2>/dev/null | head -n1)
if [ " $_got " != " $_want " ] ; then
rm -f " $MEDIA_DIR / $_out .part "
echo " fetch-media: $_out came out as ' $_got ', expected ' $_want ' — discarding it " >& 2
return 1
fi
mv " $MEDIA_DIR / $_out .part " " $MEDIA_DIR / $_out "
chmod 0644 " $MEDIA_DIR / $_out "
encoded = $(( encoded + 1 ))
return 0
}
2026-08-05 04:18:37 +02:00
MAP = " $( mktemp) "
media: publish to our own origin instead of mirroring a file host
A 167 MB screen recording uploaded to catbox.moe hit fetch-media.sh's 64 MB
MAX_BYTES, so curl refused it, the entry kept its third-party URL, and e2e
failed "/posts media origin" on a file we had on disk the whole time. Raising
the cap would have papered over it: the mirror step still depends on someone
else's server being up, fast, and still holding the file.
Invert it. tools/publish-media.sh uploads a recording to the media mount under
its content hash BEFORE the post exists and prints the URL to post, and
fetch-media.sh adopts an own-origin URL by rewriting it to /media/<hash> with no
request at all — no size cap, no third party in the build.
Also here, because publishing exposed them:
* Rotation. Phones record 1920x1080 and attach a display matrix rather than
rotating pixels, so an untouched file reports landscape while playing
portrait and width/height reserve exactly the wrong box. publish-media.sh
bakes rotation into the frames; fetch-media.sh swaps the dimensions on a
quarter-turn matrix for anything mirrored straight from a phone.
* Posters. pict-rs will not thumbnail AV1, so a self-hosted video usually
arrives with no poster. publish-media.sh uploads <hash>.poster.webp beside
the video and fetch-media.sh falls back to it — a real thumbnail still wins.
* The workflow comment claiming a file on the mount is never downloaded again
was wrong: the name is the hash of the bytes, so third-party media is
re-fetched every build and only the write is skipped.
Transcoding to AV1 is what makes self-hosting cheap: that clip was 78 s of a
dark room at 17 Mbps, and denoise + AV1 gives the same picture in 15 MB. Note
<video> carries a single src with no fallback, so AV1 excludes Safari < 17;
--raw skips the transcode when that matters.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-08 01:16:17 +02:00
POSTERMAP = " $( mktemp) "
media: H.264 fallback beside every AV1, and the post links the H.264
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>
2026-08-08 03:14:51 +02:00
FALLBACKMAP = " $( mktemp) "
2026-08-10 01:37:26 +02:00
AVIFMAP = " $( mktemp) "
POSTERONLY = " $( mktemp) "
trap 'rm -f "$MAP" "$POSTERMAP" "$FALLBACKMAP" "$AVIFMAP" "$POSTERONLY"' EXIT
2026-08-05 04:18:37 +02:00
printf '[]' > " $MAP "
media: publish to our own origin instead of mirroring a file host
A 167 MB screen recording uploaded to catbox.moe hit fetch-media.sh's 64 MB
MAX_BYTES, so curl refused it, the entry kept its third-party URL, and e2e
failed "/posts media origin" on a file we had on disk the whole time. Raising
the cap would have papered over it: the mirror step still depends on someone
else's server being up, fast, and still holding the file.
Invert it. tools/publish-media.sh uploads a recording to the media mount under
its content hash BEFORE the post exists and prints the URL to post, and
fetch-media.sh adopts an own-origin URL by rewriting it to /media/<hash> with no
request at all — no size cap, no third party in the build.
Also here, because publishing exposed them:
* Rotation. Phones record 1920x1080 and attach a display matrix rather than
rotating pixels, so an untouched file reports landscape while playing
portrait and width/height reserve exactly the wrong box. publish-media.sh
bakes rotation into the frames; fetch-media.sh swaps the dimensions on a
quarter-turn matrix for anything mirrored straight from a phone.
* Posters. pict-rs will not thumbnail AV1, so a self-hosted video usually
arrives with no poster. publish-media.sh uploads <hash>.poster.webp beside
the video and fetch-media.sh falls back to it — a real thumbnail still wins.
* The workflow comment claiming a file on the mount is never downloaded again
was wrong: the name is the hash of the bytes, so third-party media is
re-fetched every build and only the write is skipped.
Transcoding to AV1 is what makes self-hosting cheap: that clip was 78 s of a
dark room at 17 Mbps, and denoise + AV1 gives the same picture in 15 MB. Note
<video> carries a single src with no fallback, so AV1 excludes Safari < 17;
--raw skips the transcode when that matters.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-08 01:16:17 +02:00
printf '[]' > " $POSTERMAP "
media: H.264 fallback beside every AV1, and the post links the H.264
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>
2026-08-08 03:14:51 +02:00
printf '[]' > " $FALLBACKMAP "
2026-08-10 01:37:26 +02:00
printf '[]' > " $AVIFMAP "
# URLs that are ONLY ever a video's poster frame. They are skipped by the
# transcode above, because `poster` takes exactly one URL: a <video> cannot
# negotiate a format the way <picture> can, so the renditions would be files
# nothing is able to ask for. A file that is a poster somewhere and an ordinary
# image somewhere else is not in this list and is transcoded normally.
jq -r --arg re " $MEDIA_REF_RE " \
' ( [ .[ ] .media[ ] ? | .poster // empty] | map( select ( . != "" ) ) | unique) as $posters
| ( [ .[ ] .media[ ] ? | .src] + [ .[ ] | .body // "" | scan( $re ) ] | unique) as $srcs
| ( $posters - $srcs ) | .[ ] ' " $POSTS " > " $POSTERONLY " 2>/dev/null || true
2026-08-05 04:18:37 +02:00
downloaded = 0
reused = 0
media: publish to our own origin instead of mirroring a file host
A 167 MB screen recording uploaded to catbox.moe hit fetch-media.sh's 64 MB
MAX_BYTES, so curl refused it, the entry kept its third-party URL, and e2e
failed "/posts media origin" on a file we had on disk the whole time. Raising
the cap would have papered over it: the mirror step still depends on someone
else's server being up, fast, and still holding the file.
Invert it. tools/publish-media.sh uploads a recording to the media mount under
its content hash BEFORE the post exists and prints the URL to post, and
fetch-media.sh adopts an own-origin URL by rewriting it to /media/<hash> with no
request at all — no size cap, no third party in the build.
Also here, because publishing exposed them:
* Rotation. Phones record 1920x1080 and attach a display matrix rather than
rotating pixels, so an untouched file reports landscape while playing
portrait and width/height reserve exactly the wrong box. publish-media.sh
bakes rotation into the frames; fetch-media.sh swaps the dimensions on a
quarter-turn matrix for anything mirrored straight from a phone.
* Posters. pict-rs will not thumbnail AV1, so a self-hosted video usually
arrives with no poster. publish-media.sh uploads <hash>.poster.webp beside
the video and fetch-media.sh falls back to it — a real thumbnail still wins.
* The workflow comment claiming a file on the mount is never downloaded again
was wrong: the name is the hash of the bytes, so third-party media is
re-fetched every build and only the write is skipped.
Transcoding to AV1 is what makes self-hosting cheap: that clip was 78 s of a
dark room at 17 Mbps, and denoise + AV1 gives the same picture in 15 MB. Note
<video> carries a single src with no fallback, so AV1 excludes Safari < 17;
--raw skips the transcode when that matters.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-08 01:16:17 +02:00
adopted = 0
2026-08-05 04:18:37 +02:00
failed = 0
2026-08-10 01:37:26 +02:00
encoded = 0
2026-08-05 04:18:37 +02:00
# Every distinct media URL across all posts, so a file shared by two posts is
# fetched once. Video posters are in here too: a poster left pointing at the
# source instance would leak a visitor IP on page load exactly like an embedded
# image would, and it is the frame shown before anyone presses play.
#
2026-08-10 01:37:26 +02:00
# Body URLs are found by pattern rather than by parsing Markdown: anything that
# looks like an absolute URL ending in a media extension is mirrored, whether it
# was written as an embed, as a link, or bare. That is deliberately wider than
# "images the body displays" — the origin rule covers href as well as src, and a
# link whose target is a .webp on someone else's instance is still a third-party
# address on our page. Already-rewritten paths start with /media/ and so do not
# match, which is what makes re-running this a no-op.
#
2026-08-05 04:18:37 +02:00
# Fed by a here-document rather than a pipe so the counters below survive — in
# `jq | while`, the loop runs in a subshell and every increment is discarded.
while IFS = read -r src; do
[ -n " $src " ] || continue
media: publish to our own origin instead of mirroring a file host
A 167 MB screen recording uploaded to catbox.moe hit fetch-media.sh's 64 MB
MAX_BYTES, so curl refused it, the entry kept its third-party URL, and e2e
failed "/posts media origin" on a file we had on disk the whole time. Raising
the cap would have papered over it: the mirror step still depends on someone
else's server being up, fast, and still holding the file.
Invert it. tools/publish-media.sh uploads a recording to the media mount under
its content hash BEFORE the post exists and prints the URL to post, and
fetch-media.sh adopts an own-origin URL by rewriting it to /media/<hash> with no
request at all — no size cap, no third party in the build.
Also here, because publishing exposed them:
* Rotation. Phones record 1920x1080 and attach a display matrix rather than
rotating pixels, so an untouched file reports landscape while playing
portrait and width/height reserve exactly the wrong box. publish-media.sh
bakes rotation into the frames; fetch-media.sh swaps the dimensions on a
quarter-turn matrix for anything mirrored straight from a phone.
* Posters. pict-rs will not thumbnail AV1, so a self-hosted video usually
arrives with no poster. publish-media.sh uploads <hash>.poster.webp beside
the video and fetch-media.sh falls back to it — a real thumbnail still wins.
* The workflow comment claiming a file on the mount is never downloaded again
was wrong: the name is the hash of the bytes, so third-party media is
re-fetched every build and only the write is skipped.
Transcoding to AV1 is what makes self-hosting cheap: that clip was 78 s of a
dark room at 17 Mbps, and denoise + AV1 gives the same picture in 15 MB. Note
<video> carries a single src with no fallback, so AV1 excludes Safari < 17;
--raw skips the transcode when that matters.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-08 01:16:17 +02:00
# Ours already — adopt the file on the mount and do no network at all.
# A URL that is on our origin but names a file that is NOT on the mount is
# left alone rather than invented: that is a post published without its
# media, and keeping the original URL makes the e2e origin check fail
# loudly instead of shipping a 404 in a <video> tag.
case " $src " in
" $OWN_ORIGIN " *)
name = ${ src # " $OWN_ORIGIN " }
# Refuse anything that is not a bare filename. A path separator or a
# traversal segment arriving from a post URL must never reach a path we
# then read or publish.
case " $name " in
'' | */*| *..*)
echo " fetch-media: refusing suspicious own-origin URL: $src " >& 2
failed = $(( failed + 1 )) ; continue ; ;
esac
media: H.264 fallback beside every AV1, and the post links the H.264
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>
2026-08-08 03:14:51 +02:00
# 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
media: publish to our own origin instead of mirroring a file host
A 167 MB screen recording uploaded to catbox.moe hit fetch-media.sh's 64 MB
MAX_BYTES, so curl refused it, the entry kept its third-party URL, and e2e
failed "/posts media origin" on a file we had on disk the whole time. Raising
the cap would have papered over it: the mirror step still depends on someone
else's server being up, fast, and still holding the file.
Invert it. tools/publish-media.sh uploads a recording to the media mount under
its content hash BEFORE the post exists and prints the URL to post, and
fetch-media.sh adopts an own-origin URL by rewriting it to /media/<hash> with no
request at all — no size cap, no third party in the build.
Also here, because publishing exposed them:
* Rotation. Phones record 1920x1080 and attach a display matrix rather than
rotating pixels, so an untouched file reports landscape while playing
portrait and width/height reserve exactly the wrong box. publish-media.sh
bakes rotation into the frames; fetch-media.sh swaps the dimensions on a
quarter-turn matrix for anything mirrored straight from a phone.
* Posters. pict-rs will not thumbnail AV1, so a self-hosted video usually
arrives with no poster. publish-media.sh uploads <hash>.poster.webp beside
the video and fetch-media.sh falls back to it — a real thumbnail still wins.
* The workflow comment claiming a file on the mount is never downloaded again
was wrong: the name is the hash of the bytes, so third-party media is
re-fetched every build and only the write is skipped.
Transcoding to AV1 is what makes self-hosting cheap: that clip was 78 s of a
dark room at 17 Mbps, and denoise + AV1 gives the same picture in 15 MB. Note
<video> carries a single src with no fallback, so AV1 excludes Safari < 17;
--raw skips the transcode when that matters.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-08 01:16:17 +02:00
dest = " $MEDIA_DIR / $name "
if [ ! -f " $dest " ] ; then
echo " fetch-media: $name not on the media mount, keeping original URL: $src " >& 2
failed = $(( failed + 1 )) ; continue
fi
adopted = $(( adopted + 1 ))
; ;
2026-08-10 01:37:26 +02:00
/media/*)
# Already rewritten by an earlier run of this script. Adopt the file on
# the mount rather than trying to fetch our own path as though it were a
# URL — which is what makes running this twice a no-op instead of a way
# to lose every rewrite it made the first time. The script is meant to
# follow fetch-posts.sh, but "meant to" is not a guarantee, and the
# failure was silent: the body_media list simply came back empty.
name = ${ src #/media/ }
case " $name " in
'' | */*| *..*)
echo " fetch-media: refusing suspicious local path: $src " >& 2
failed = $(( failed + 1 )) ; continue ; ;
esac
dest = " $MEDIA_DIR / $name "
if [ ! -f " $dest " ] ; then
echo " fetch-media: $name not on the media mount, leaving it alone: $src " >& 2
failed = $(( failed + 1 )) ; continue
fi
adopted = $(( adopted + 1 ))
; ;
media: publish to our own origin instead of mirroring a file host
A 167 MB screen recording uploaded to catbox.moe hit fetch-media.sh's 64 MB
MAX_BYTES, so curl refused it, the entry kept its third-party URL, and e2e
failed "/posts media origin" on a file we had on disk the whole time. Raising
the cap would have papered over it: the mirror step still depends on someone
else's server being up, fast, and still holding the file.
Invert it. tools/publish-media.sh uploads a recording to the media mount under
its content hash BEFORE the post exists and prints the URL to post, and
fetch-media.sh adopts an own-origin URL by rewriting it to /media/<hash> with no
request at all — no size cap, no third party in the build.
Also here, because publishing exposed them:
* Rotation. Phones record 1920x1080 and attach a display matrix rather than
rotating pixels, so an untouched file reports landscape while playing
portrait and width/height reserve exactly the wrong box. publish-media.sh
bakes rotation into the frames; fetch-media.sh swaps the dimensions on a
quarter-turn matrix for anything mirrored straight from a phone.
* Posters. pict-rs will not thumbnail AV1, so a self-hosted video usually
arrives with no poster. publish-media.sh uploads <hash>.poster.webp beside
the video and fetch-media.sh falls back to it — a real thumbnail still wins.
* The workflow comment claiming a file on the mount is never downloaded again
was wrong: the name is the hash of the bytes, so third-party media is
re-fetched every build and only the write is skipped.
Transcoding to AV1 is what makes self-hosting cheap: that clip was 78 s of a
dark room at 17 Mbps, and denoise + AV1 gives the same picture in 15 MB. Note
<video> carries a single src with no fallback, so AV1 excludes Safari < 17;
--raw skips the transcode when that matters.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-08 01:16:17 +02:00
*)
ext = $( printf '%s' " $src " | sed -E 's/.*\.([A-Za-z0-9]+)$/\1/' | tr 'A-Z' 'a-z' )
case " $ext " in
mp4| webm| mov| webp| png| jpg| jpeg| gif| avif) ; ;
*) echo " fetch-media: skipping unexpected extension: $src " >& 2; continue ; ;
esac
tmp = " $( mktemp) "
# --max-filesize refuses an oversized body before writing it; the explicit
# size check afterwards covers servers that do not send Content-Length.
if ! curl -fsSL --max-time 120 --max-filesize " $MAX_BYTES " \
-A 'catcrafts.net-buildfetch/1.0 (+https://catcrafts.net)' \
" $src " -o " $tmp " 2>/dev/null; then
echo " fetch-media: download failed, keeping original URL: $src " >& 2
rm -f " $tmp "
failed = $(( failed + 1 ))
continue
fi
if [ " $( wc -c < " $tmp " ) " -gt " $MAX_BYTES " ] ; then
echo " fetch-media: oversized, keeping original URL: $src " >& 2
rm -f " $tmp "
failed = $(( failed + 1 ))
continue
fi
2026-08-05 04:18:37 +02:00
media: publish to our own origin instead of mirroring a file host
A 167 MB screen recording uploaded to catbox.moe hit fetch-media.sh's 64 MB
MAX_BYTES, so curl refused it, the entry kept its third-party URL, and e2e
failed "/posts media origin" on a file we had on disk the whole time. Raising
the cap would have papered over it: the mirror step still depends on someone
else's server being up, fast, and still holding the file.
Invert it. tools/publish-media.sh uploads a recording to the media mount under
its content hash BEFORE the post exists and prints the URL to post, and
fetch-media.sh adopts an own-origin URL by rewriting it to /media/<hash> with no
request at all — no size cap, no third party in the build.
Also here, because publishing exposed them:
* Rotation. Phones record 1920x1080 and attach a display matrix rather than
rotating pixels, so an untouched file reports landscape while playing
portrait and width/height reserve exactly the wrong box. publish-media.sh
bakes rotation into the frames; fetch-media.sh swaps the dimensions on a
quarter-turn matrix for anything mirrored straight from a phone.
* Posters. pict-rs will not thumbnail AV1, so a self-hosted video usually
arrives with no poster. publish-media.sh uploads <hash>.poster.webp beside
the video and fetch-media.sh falls back to it — a real thumbnail still wins.
* The workflow comment claiming a file on the mount is never downloaded again
was wrong: the name is the hash of the bytes, so third-party media is
re-fetched every build and only the write is skipped.
Transcoding to AV1 is what makes self-hosting cheap: that clip was 78 s of a
dark room at 17 Mbps, and denoise + AV1 gives the same picture in 15 MB. Note
<video> carries a single src with no fallback, so AV1 excludes Safari < 17;
--raw skips the transcode when that matters.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-08 01:16:17 +02:00
hash = $( sha256sum " $tmp " | cut -c1-16)
name = " $hash . $ext "
dest = " $MEDIA_DIR / $name "
if [ -f " $dest " ] ; then
rm -f " $tmp "
reused = $(( reused + 1 ))
else
mv " $tmp " " $dest "
chmod 0644 " $dest "
downloaded = $(( downloaded + 1 ))
2026-08-05 04:18:37 +02:00
fi
media: publish to our own origin instead of mirroring a file host
A 167 MB screen recording uploaded to catbox.moe hit fetch-media.sh's 64 MB
MAX_BYTES, so curl refused it, the entry kept its third-party URL, and e2e
failed "/posts media origin" on a file we had on disk the whole time. Raising
the cap would have papered over it: the mirror step still depends on someone
else's server being up, fast, and still holding the file.
Invert it. tools/publish-media.sh uploads a recording to the media mount under
its content hash BEFORE the post exists and prints the URL to post, and
fetch-media.sh adopts an own-origin URL by rewriting it to /media/<hash> with no
request at all — no size cap, no third party in the build.
Also here, because publishing exposed them:
* Rotation. Phones record 1920x1080 and attach a display matrix rather than
rotating pixels, so an untouched file reports landscape while playing
portrait and width/height reserve exactly the wrong box. publish-media.sh
bakes rotation into the frames; fetch-media.sh swaps the dimensions on a
quarter-turn matrix for anything mirrored straight from a phone.
* Posters. pict-rs will not thumbnail AV1, so a self-hosted video usually
arrives with no poster. publish-media.sh uploads <hash>.poster.webp beside
the video and fetch-media.sh falls back to it — a real thumbnail still wins.
* The workflow comment claiming a file on the mount is never downloaded again
was wrong: the name is the hash of the bytes, so third-party media is
re-fetched every build and only the write is skipped.
Transcoding to AV1 is what makes self-hosting cheap: that clip was 78 s of a
dark room at 17 Mbps, and denoise + AV1 gives the same picture in 15 MB. Note
<video> carries a single src with no fallback, so AV1 excludes Safari < 17;
--raw skips the transcode when that matters.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-08 01:16:17 +02:00
; ;
esac
probe_dims " $dest "
# A self-hosted video has no Lemmy thumbnail to mirror when the instance
# cannot decode it — AV1 is the common case, since pict-rs will not generate
# a still from one. publish-media.sh uploads a poster frame alongside the
# video under the video's own hash, so look for that sibling and offer it to
# the rewrite below. Without a poster a preload="metadata" video is a black
# box until someone presses play.
case " $name " in
*.mp4| *.webm| *.mov)
media: H.264 fallback beside every AV1, and the post links the H.264
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>
2026-08-08 03:14:51 +02:00
# 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 "
media: publish to our own origin instead of mirroring a file host
A 167 MB screen recording uploaded to catbox.moe hit fetch-media.sh's 64 MB
MAX_BYTES, so curl refused it, the entry kept its third-party URL, and e2e
failed "/posts media origin" on a file we had on disk the whole time. Raising
the cap would have papered over it: the mirror step still depends on someone
else's server being up, fast, and still holding the file.
Invert it. tools/publish-media.sh uploads a recording to the media mount under
its content hash BEFORE the post exists and prints the URL to post, and
fetch-media.sh adopts an own-origin URL by rewriting it to /media/<hash> with no
request at all — no size cap, no third party in the build.
Also here, because publishing exposed them:
* Rotation. Phones record 1920x1080 and attach a display matrix rather than
rotating pixels, so an untouched file reports landscape while playing
portrait and width/height reserve exactly the wrong box. publish-media.sh
bakes rotation into the frames; fetch-media.sh swaps the dimensions on a
quarter-turn matrix for anything mirrored straight from a phone.
* Posters. pict-rs will not thumbnail AV1, so a self-hosted video usually
arrives with no poster. publish-media.sh uploads <hash>.poster.webp beside
the video and fetch-media.sh falls back to it — a real thumbnail still wins.
* The workflow comment claiming a file on the mount is never downloaded again
was wrong: the name is the hash of the bytes, so third-party media is
re-fetched every build and only the write is skipped.
Transcoding to AV1 is what makes self-hosting cheap: that clip was 78 s of a
dark room at 17 Mbps, and denoise + AV1 gives the same picture in 15 MB. Note
<video> carries a single src with no fallback, so AV1 excludes Safari < 17;
--raw skips the transcode when that matters.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-08 01:16:17 +02:00
if [ -f " $MEDIA_DIR / $sibling " ] ; then
jq --arg k " /media/ $name " --arg v " /media/ $sibling " \
'. + [{key: $k, value: $v}]' " $POSTERMAP " > " $POSTERMAP .new " \
&& mv " $POSTERMAP .new " " $POSTERMAP "
fi
media: H.264 fallback beside every AV1, and the post links the H.264
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>
2026-08-08 03:14:51 +02:00
# 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
media: publish to our own origin instead of mirroring a file host
A 167 MB screen recording uploaded to catbox.moe hit fetch-media.sh's 64 MB
MAX_BYTES, so curl refused it, the entry kept its third-party URL, and e2e
failed "/posts media origin" on a file we had on disk the whole time. Raising
the cap would have papered over it: the mirror step still depends on someone
else's server being up, fast, and still holding the file.
Invert it. tools/publish-media.sh uploads a recording to the media mount under
its content hash BEFORE the post exists and prints the URL to post, and
fetch-media.sh adopts an own-origin URL by rewriting it to /media/<hash> with no
request at all — no size cap, no third party in the build.
Also here, because publishing exposed them:
* Rotation. Phones record 1920x1080 and attach a display matrix rather than
rotating pixels, so an untouched file reports landscape while playing
portrait and width/height reserve exactly the wrong box. publish-media.sh
bakes rotation into the frames; fetch-media.sh swaps the dimensions on a
quarter-turn matrix for anything mirrored straight from a phone.
* Posters. pict-rs will not thumbnail AV1, so a self-hosted video usually
arrives with no poster. publish-media.sh uploads <hash>.poster.webp beside
the video and fetch-media.sh falls back to it — a real thumbnail still wins.
* The workflow comment claiming a file on the mount is never downloaded again
was wrong: the name is the hash of the bytes, so third-party media is
re-fetched every build and only the write is skipped.
Transcoding to AV1 is what makes self-hosting cheap: that clip was 78 s of a
dark room at 17 Mbps, and denoise + AV1 gives the same picture in 15 MB. Note
<video> carries a single src with no fallback, so AV1 excludes Safari < 17;
--raw skips the transcode when that matters.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-08 01:16:17 +02:00
; ;
2026-08-10 01:37:26 +02:00
*.webp| *.png| *.jpg| *.jpeg| *.gif| *.avif)
# The AVIF and PNG tiers this image is served between. Both maps are
# keyed by the LOCAL path, like the video ones above, so the rewrite
# below can look them up from the src it has just written.
if grep -qxF " $src " " $POSTERONLY " 2>/dev/null; then
: # poster-only; see POSTERONLY above
else
transcode_image " $dest " " $name "
if [ -n " $avif_name " ] ; then
jq --arg k " /media/ $name " --arg v " /media/ $avif_name " \
'. + [{key: $k, value: $v}]' " $AVIFMAP " > " $AVIFMAP .new " \
&& mv " $AVIFMAP .new " " $AVIFMAP "
fi
if [ -n " $png_name " ] ; then
jq --arg k " /media/ $name " --arg v " /media/ $png_name " \
'. + [{key: $k, value: $v}]' " $FALLBACKMAP " > " $FALLBACKMAP .new " \
&& mv " $FALLBACKMAP .new " " $FALLBACKMAP "
fi
fi
; ;
media: publish to our own origin instead of mirroring a file host
A 167 MB screen recording uploaded to catbox.moe hit fetch-media.sh's 64 MB
MAX_BYTES, so curl refused it, the entry kept its third-party URL, and e2e
failed "/posts media origin" on a file we had on disk the whole time. Raising
the cap would have papered over it: the mirror step still depends on someone
else's server being up, fast, and still holding the file.
Invert it. tools/publish-media.sh uploads a recording to the media mount under
its content hash BEFORE the post exists and prints the URL to post, and
fetch-media.sh adopts an own-origin URL by rewriting it to /media/<hash> with no
request at all — no size cap, no third party in the build.
Also here, because publishing exposed them:
* Rotation. Phones record 1920x1080 and attach a display matrix rather than
rotating pixels, so an untouched file reports landscape while playing
portrait and width/height reserve exactly the wrong box. publish-media.sh
bakes rotation into the frames; fetch-media.sh swaps the dimensions on a
quarter-turn matrix for anything mirrored straight from a phone.
* Posters. pict-rs will not thumbnail AV1, so a self-hosted video usually
arrives with no poster. publish-media.sh uploads <hash>.poster.webp beside
the video and fetch-media.sh falls back to it — a real thumbnail still wins.
* The workflow comment claiming a file on the mount is never downloaded again
was wrong: the name is the hash of the bytes, so third-party media is
re-fetched every build and only the write is skipped.
Transcoding to AV1 is what makes self-hosting cheap: that clip was 78 s of a
dark room at 17 Mbps, and denoise + AV1 gives the same picture in 15 MB. Note
<video> carries a single src with no fallback, so AV1 excludes Safari < 17;
--raw skips the transcode when that matters.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-08 01:16:17 +02:00
esac
2026-08-05 04:18:37 +02:00
jq --arg src " $src " --arg path " /media/ $name " \
--argjson w " ${ w :- 0 } " --argjson h " ${ h :- 0 } " \
'. + [{src: $src, path: $path, w: $w, h: $h}]' " $MAP " > " $MAP .new " \
&& mv " $MAP .new " " $MAP "
done <<EOF
2026-08-10 01:37:26 +02:00
$( jq -r --arg re " $MEDIA_REF_RE " \
' [ ( .[ ] .media[ ] ? | .src, ( .poster // empty) ) ,
( .[ ] | .body // "" | scan( $re ) ) ]
| map( select ( . != "" ) ) | unique[ ] ' " $POSTS " )
2026-08-05 04:18:37 +02:00
EOF
media: publish to our own origin instead of mirroring a file host
A 167 MB screen recording uploaded to catbox.moe hit fetch-media.sh's 64 MB
MAX_BYTES, so curl refused it, the entry kept its third-party URL, and e2e
failed "/posts media origin" on a file we had on disk the whole time. Raising
the cap would have papered over it: the mirror step still depends on someone
else's server being up, fast, and still holding the file.
Invert it. tools/publish-media.sh uploads a recording to the media mount under
its content hash BEFORE the post exists and prints the URL to post, and
fetch-media.sh adopts an own-origin URL by rewriting it to /media/<hash> with no
request at all — no size cap, no third party in the build.
Also here, because publishing exposed them:
* Rotation. Phones record 1920x1080 and attach a display matrix rather than
rotating pixels, so an untouched file reports landscape while playing
portrait and width/height reserve exactly the wrong box. publish-media.sh
bakes rotation into the frames; fetch-media.sh swaps the dimensions on a
quarter-turn matrix for anything mirrored straight from a phone.
* Posters. pict-rs will not thumbnail AV1, so a self-hosted video usually
arrives with no poster. publish-media.sh uploads <hash>.poster.webp beside
the video and fetch-media.sh falls back to it — a real thumbnail still wins.
* The workflow comment claiming a file on the mount is never downloaded again
was wrong: the name is the hash of the bytes, so third-party media is
re-fetched every build and only the write is skipped.
Transcoding to AV1 is what makes self-hosting cheap: that clip was 78 s of a
dark room at 17 Mbps, and denoise + AV1 gives the same picture in 15 MB. Note
<video> carries a single src with no fallback, so AV1 excludes Safari < 17;
--raw skips the transcode when that matters.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-08 01:16:17 +02:00
echo " fetch-media: $downloaded new, $reused already present, $adopted self-hosted, $failed failed "
2026-08-10 01:37:26 +02:00
echo " fetch-media: $encoded image rendition(s) encoded this run "
2026-08-05 04:18:37 +02:00
# Rewrite each media entry to the local path. An entry with no mapping (download
# failed) keeps its original src, so the page still shows something rather than
# silently dropping the post's whole point.
TMP_POSTS = " $( mktemp) "
media: H.264 fallback beside every AV1, and the post links the H.264
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>
2026-08-08 03:14:51 +02:00
if jq --slurpfile map " $MAP " --slurpfile posters " $POSTERMAP " \
2026-08-10 01:37:26 +02:00
--slurpfile fallbacks " $FALLBACKMAP " --slurpfile avifs " $AVIFMAP " '
2026-08-05 04:18:37 +02:00
( $map [ 0] | map( { key: .src, value: .} ) | from_entries) as $m
media: publish to our own origin instead of mirroring a file host
A 167 MB screen recording uploaded to catbox.moe hit fetch-media.sh's 64 MB
MAX_BYTES, so curl refused it, the entry kept its third-party URL, and e2e
failed "/posts media origin" on a file we had on disk the whole time. Raising
the cap would have papered over it: the mirror step still depends on someone
else's server being up, fast, and still holding the file.
Invert it. tools/publish-media.sh uploads a recording to the media mount under
its content hash BEFORE the post exists and prints the URL to post, and
fetch-media.sh adopts an own-origin URL by rewriting it to /media/<hash> with no
request at all — no size cap, no third party in the build.
Also here, because publishing exposed them:
* Rotation. Phones record 1920x1080 and attach a display matrix rather than
rotating pixels, so an untouched file reports landscape while playing
portrait and width/height reserve exactly the wrong box. publish-media.sh
bakes rotation into the frames; fetch-media.sh swaps the dimensions on a
quarter-turn matrix for anything mirrored straight from a phone.
* Posters. pict-rs will not thumbnail AV1, so a self-hosted video usually
arrives with no poster. publish-media.sh uploads <hash>.poster.webp beside
the video and fetch-media.sh falls back to it — a real thumbnail still wins.
* The workflow comment claiming a file on the mount is never downloaded again
was wrong: the name is the hash of the bytes, so third-party media is
re-fetched every build and only the write is skipped.
Transcoding to AV1 is what makes self-hosting cheap: that clip was 78 s of a
dark room at 17 Mbps, and denoise + AV1 gives the same picture in 15 MB. Note
<video> carries a single src with no fallback, so AV1 excludes Safari < 17;
--raw skips the transcode when that matters.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-08 01:16:17 +02:00
| ( $posters [ 0] | from_entries) as $pm
media: H.264 fallback beside every AV1, and the post links the H.264
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>
2026-08-08 03:14:51 +02:00
| ( $fallbacks [ 0] | from_entries) as $fm
2026-08-10 01:37:26 +02:00
| ( $avifs [ 0] | from_entries) as $am
2026-08-05 04:18:37 +02:00
| map( .media = ( ( .media // [ ] ) | map(
. as $item
| ( $m [ $item .src] // null) as $hit
| ( if $hit = = null then $item
else $item + { src: $hit .path, w: $hit .w, h: $hit .h }
end)
# The poster gets its path rewritten but NOT its dimensions: w/h describe
# the video, and a poster is a differently-sized still of it. Feeding the
# poster'\''s size to the <video> element would set the wrong aspect ratio.
| if ( .poster // "" ) = = "" then .
else . + { poster: ( ( $m [ .poster] .path) // .poster) }
media: publish to our own origin instead of mirroring a file host
A 167 MB screen recording uploaded to catbox.moe hit fetch-media.sh's 64 MB
MAX_BYTES, so curl refused it, the entry kept its third-party URL, and e2e
failed "/posts media origin" on a file we had on disk the whole time. Raising
the cap would have papered over it: the mirror step still depends on someone
else's server being up, fast, and still holding the file.
Invert it. tools/publish-media.sh uploads a recording to the media mount under
its content hash BEFORE the post exists and prints the URL to post, and
fetch-media.sh adopts an own-origin URL by rewriting it to /media/<hash> with no
request at all — no size cap, no third party in the build.
Also here, because publishing exposed them:
* Rotation. Phones record 1920x1080 and attach a display matrix rather than
rotating pixels, so an untouched file reports landscape while playing
portrait and width/height reserve exactly the wrong box. publish-media.sh
bakes rotation into the frames; fetch-media.sh swaps the dimensions on a
quarter-turn matrix for anything mirrored straight from a phone.
* Posters. pict-rs will not thumbnail AV1, so a self-hosted video usually
arrives with no poster. publish-media.sh uploads <hash>.poster.webp beside
the video and fetch-media.sh falls back to it — a real thumbnail still wins.
* The workflow comment claiming a file on the mount is never downloaded again
was wrong: the name is the hash of the bytes, so third-party media is
re-fetched every build and only the write is skipped.
Transcoding to AV1 is what makes self-hosting cheap: that clip was 78 s of a
dark room at 17 Mbps, and denoise + AV1 gives the same picture in 15 MB. Note
<video> carries a single src with no fallback, so AV1 excludes Safari < 17;
--raw skips the transcode when that matters.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-08 01:16:17 +02:00
end
# Last resort, and only for media we host: the sibling poster frame
# publish-media.sh uploaded next to the video. Runs after the rewrite
# above so it sees the LOCAL src, and only fills a poster that is still
# empty — a thumbnail the instance did provide always wins.
| if ( ( .poster // "" ) = = "" ) and ( ( $pm [ .src] // "" ) != "" )
then . + { poster: $pm [ .src] }
media: H.264 fallback beside every AV1, and the post links the H.264
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>
2026-08-08 03:14:51 +02:00
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] }
2026-08-10 01:37:26 +02:00
else . end
# The AVIF tier, keyed by the LOCAL src like the two maps above. Only
# ever set for images, and only when the encode actually produced one.
| if ( ( $am [ .src] // "" ) != "" )
then . + { avif: $am [ .src] }
media: publish to our own origin instead of mirroring a file host
A 167 MB screen recording uploaded to catbox.moe hit fetch-media.sh's 64 MB
MAX_BYTES, so curl refused it, the entry kept its third-party URL, and e2e
failed "/posts media origin" on a file we had on disk the whole time. Raising
the cap would have papered over it: the mirror step still depends on someone
else's server being up, fast, and still holding the file.
Invert it. tools/publish-media.sh uploads a recording to the media mount under
its content hash BEFORE the post exists and prints the URL to post, and
fetch-media.sh adopts an own-origin URL by rewriting it to /media/<hash> with no
request at all — no size cap, no third party in the build.
Also here, because publishing exposed them:
* Rotation. Phones record 1920x1080 and attach a display matrix rather than
rotating pixels, so an untouched file reports landscape while playing
portrait and width/height reserve exactly the wrong box. publish-media.sh
bakes rotation into the frames; fetch-media.sh swaps the dimensions on a
quarter-turn matrix for anything mirrored straight from a phone.
* Posters. pict-rs will not thumbnail AV1, so a self-hosted video usually
arrives with no poster. publish-media.sh uploads <hash>.poster.webp beside
the video and fetch-media.sh falls back to it — a real thumbnail still wins.
* The workflow comment claiming a file on the mount is never downloaded again
was wrong: the name is the hash of the bytes, so third-party media is
re-fetched every build and only the write is skipped.
Transcoding to AV1 is what makes self-hosting cheap: that clip was 78 s of a
dark room at 17 Mbps, and denoise + AV1 gives the same picture in 15 MB. Note
<video> carries a single src with no fallback, so AV1 excludes Safari < 17;
--raw skips the transcode when that matters.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-08 01:16:17 +02:00
else . end) ) )
2026-08-10 01:37:26 +02:00
# ── the body ──────────────────────────────────────────────────────
#
# Substitution is literal (split/join, not gsub), because these URLs are
# full of regex metacharacters and a mirrored path must land in the text
# exactly as written. A URL with no mapping — its download failed — is left
# alone, so the post still shows the image rather than losing it; the
# accounting at the end of this script reports that as media still pointing
# at its source.
| ( $m | to_entries) as $subs
| map( .body = ( reduce $subs [ ] as $s ( ( .body // "" ) ;
split( $s .key) | join( $s .value.path) ) ) )
# Everything the rewritten body now points at, as records the renderer can
# read: Markdown has nowhere to put a width, a poster frame or a second
# source, so the sidecar list is how an inline video gets the same treatment
# as a headline one. Keyed by the LOCAL path, which is what the body says
# by this point.
| ( $m | map( { key: .path, value: . } ) | from_entries) as $byPath
| map( .body_media = ( [ ( .body // "" )
| scan( "/media/[A-Za-z0-9._-]+" )
| . as $path
| select ( $byPath [ $path ] != null)
| { src: $path ,
kind: ( if ( $path | test( " \\.(?:mp4|webm|mov) $" ) )
then "video" else "image" end) ,
poster: ( $pm [ $path ] // "" ) ,
fallback: ( $fm [ $path ] // "" ) ,
avif: ( $am [ $path ] // "" ) ,
w: ( $byPath [ $path ] .w // 0) ,
h: ( $byPath [ $path ] .h // 0) } ]
| unique_by( .src) ) )
2026-08-05 04:18:37 +02:00
' " $POSTS " > " $TMP_POSTS " 2>/dev/null; then
2026-08-05 06:17:00 +02:00
# Same reason as the chmod on each mirrored file: mktemp is 0600 and the
# mode survives to production, where other users must read this.
chmod 0644 " $TMP_POSTS "
2026-08-05 04:18:37 +02:00
mv " $TMP_POSTS " " $POSTS "
else
rm -f " $TMP_POSTS "
echo " fetch-media: could not rewrite $POSTS , leaving it unchanged " >& 2
exit 1
fi
2026-08-10 01:37:26 +02:00
# Counted over the bodies too, because a body URL that never mirrored is the
# same privacy leak as a card one and must not be reported as success.
total = $( jq --arg re " $MEDIA_REF_RE " \
' [ ( .[ ] .media[ ] ? | .src, ( .poster // empty) ) ,
( .[ ] | .body // "" | scan( $re ) ) ,
( .[ ] .body_media[ ] ? | .src, ( .poster // empty) ) ]
| map( select ( . != "" ) ) | unique | length' " $POSTS " )
local_count = $( jq ' [ ( .[ ] .media[ ] ? | .src, ( .poster // empty) ) ,
( .[ ] .body_media[ ] ? | .src, ( .poster // empty) ) ]
| map( select ( startswith( "/media/" ) ) ) | length' " $POSTS " )
inline = $( jq '[.[].body_media[]?] | length' " $POSTS " )
echo " fetch-media: $local_count of $total media entries served locally ( $inline of them embedded in post bodies; $( du -sh " $MEDIA_DIR " | cut -f1) in $MEDIA_DIR ) "
2026-08-05 04:18:37 +02:00
if [ " $local_count " -ne " $total " ] ; then
echo " fetch-media: $(( total - local_count)) still point at their source — see the failures above " >& 2
fi