#!/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. # # 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. # # 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)) # 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 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/" 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 # 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 # 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" /dev/null | head -n1 || true) ph=$(ffprobe -v error -select_streams v:0 -show_entries stream=height \ -of default=nw=1:nk=1 "$1" /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" /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 } # 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 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" /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" /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 rendition_ok "$_cand" av1; 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 rendition_ok "$_cand" png; then png_name="$_cand" elif encode_rendition "$_file" "$_cand" png -c:v png -f image2; then png_name="$_cand" fi fi } # The codec ffprobe reports for a file, or empty when it cannot say. codec_of() { [ "$HAVE_FFPROBE" = 1 ] || return 0 ffprobe -v error -select_streams v:0 -show_entries stream=codec_name \ -of default=nw=1:nk=1 "$1" /dev/null | head -n1 } # rendition_ok NAME EXPECTED_CODEC — true when the file is already on the mount # AND really is that codec. # # The second half is what makes the mount self-healing. Renditions are adopted # by name and never re-derived, so anything wrong that once landed there would # be trusted forever — which is exactly what a run of MJPEG files under .png # names would have been. A file that fails re-encodes over the top instead. rendition_ok() { [ -f "$MEDIA_DIR/$1" ] || return 1 _have=$(codec_of "$MEDIA_DIR/$1") # No ffprobe to ask: trust what is there rather than re-encoding every # image on every build. [ -n "$_have" ] || return 0 [ "$_have" = "$2" ] && return 0 echo "fetch-media: $1 on the mount is '$_have', not '$2' — re-encoding it" >&2 return 1 } # 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. # # -nostdin AND /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" /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 } MAP="$(mktemp)" POSTERMAP="$(mktemp)" FALLBACKMAP="$(mktemp)" AVIFMAP="$(mktemp)" POSTERONLY="$(mktemp)" trap 'rm -f "$MAP" "$POSTERMAP" "$FALLBACKMAP" "$AVIFMAP" "$POSTERONLY"' EXIT printf '[]' > "$MAP" printf '[]' > "$POSTERMAP" printf '[]' > "$FALLBACKMAP" 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