#!/bin/sh # Publish a recording or screenshot to catcrafts.net and print the URL to put in # the fediverse post. # # usage: tools/publish-media.sh [-r] [-c CRF] ... # # -r, --raw upload the file unchanged (no transcode, no poster) # -c, --crf N AV1 quality, lower is better (default 32) # # WHY THIS EXISTS: the media used to be uploaded to a third-party file host and # then mirrored back at build time by tools/fetch-media.sh. That worked until a # 167 MB recording hit the mirror's size cap, and the deploy failed on a file we # had sitting on disk the whole time. Publishing to our own origin FIRST inverts # it — the URL in the post is already the URL the page wants, so there is nothing # to download, no size cap to clear, and no third party who can be slow, rate- # limit us, or delete the file that IS the post. # # The name is the content hash, matching what fetch-media.sh produces, which is # what makes Caddy's one-year immutable cache on /media honest: different bytes # can never appear under a name someone already has cached. # # TRANSCODE: phone recordings are enormous for what they show — the one that # started this was 78 s of a dark room at 17 Mbps, 167 MB, where denoising and # AV1 give the same picture in 15 MB. Dark handheld footage is mostly sensor # noise, which is expensive to encode and worth nothing, so hqdn3d earns its # place before the encoder rather than after it. # # ROTATION: phones record landscape and attach a display matrix instead of # rotating pixels. Transcoding bakes the rotation into the frames, so what every # 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. # # FALLBACK: AV1 alone locks out Safari before 17 and Apple hardware older than # A17/M3 — which surfaced the first time an AV1 link was posted and an iPhone # viewer got "bad media error" from the raw file. So every transcode also emits # an H.264 sibling, uploaded as .h264.mp4 next to the AV1 (same trick as # 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 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 fallback. set -eu MEDIA_HOST="${MEDIA_HOST:-root@catcrafts.net}" MEDIA_PATH="${MEDIA_PATH:-/srv/catcrafts-app/media}" SITE_ORIGIN="${SITE_ORIGIN:-https://catcrafts.net}" CRF="${CRF:-32}" RAW=0 for c in ffmpeg ffprobe sha256sum ssh scp; do command -v "$c" >/dev/null 2>&1 || { echo "publish-media: $c not found" >&2; exit 1; } done FILES="" while [ $# -gt 0 ]; do case "$1" in -r|--raw) RAW=1; shift ;; -c|--crf) CRF="$2"; shift 2 ;; -h|--help) sed -n '2,10p' "$0"; exit 0 ;; -*) echo "publish-media: unknown option: $1" >&2; exit 1 ;; *) FILES="$FILES $1"; shift ;; esac done [ -n "$FILES" ] || { echo "publish-media: no input file. See --help." >&2; exit 1; } WORK="$(mktemp -d)" trap 'rm -rf "$WORK"' EXIT # Upload only what is not already there. The name is the content hash, so a name # that exists on the mount holds these exact bytes and re-sending them would # change nothing — which makes re-running this script on the same file free, and # makes a half-finished batch safe to just run again. publish_file() { local_file=$1 remote_name=$2 if ssh -o BatchMode=yes "$MEDIA_HOST" "test -f '$MEDIA_PATH/$remote_name'"; then echo " already published: $remote_name" return 0 fi # Land it under a temporary name and move it into place, so a reader can # never see a partial file under the name its hash promises. scp -q -o BatchMode=yes "$local_file" "$MEDIA_HOST:$MEDIA_PATH/.$remote_name.part" ssh -o BatchMode=yes "$MEDIA_HOST" \ "chmod 0644 '$MEDIA_PATH/.$remote_name.part' && \ mv -f '$MEDIA_PATH/.$remote_name.part' '$MEDIA_PATH/$remote_name'" echo " uploaded: $remote_name" } for src in $FILES; do [ -f "$src" ] || { echo "publish-media: no such file: $src" >&2; exit 1; } echo "$src" ext=$(printf '%s' "$src" | sed -E 's/.*\.([A-Za-z0-9]+)$/\1/' | tr 'A-Z' 'a-z') kind=other case "$ext" in mp4|webm|mov|mkv|avi) kind=video ;; png|jpg|jpeg|webp|gif|avif) kind=image ;; esac poster="" fallback="" if [ "$RAW" = 1 ] || [ "$kind" = other ]; then payload="$src" out_ext="$ext" elif [ "$kind" = video ]; then payload="$WORK/out.mp4" out_ext=mp4 echo " transcoding to AV1 (crf $CRF)…" ffmpeg -nostdin -v error -i "$src" \ -vf "hqdn3d=4:3:6:4.5" \ -c:v libsvtav1 -preset 4 -crf "$CRF" -pix_fmt yuv420p -g 240 \ -movflags +faststart \ -c:a aac -b:a 96k -ac 1 \ "$payload" -y # 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 # 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" # 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. dur=$(ffprobe -v error -show_entries format=duration \ -of default=nw=1:nk=1 "$payload" 2>/dev/null || echo 0) at=$(awk -v d="$dur" 'BEGIN { printf "%.2f", (d > 3 ? d / 3 : 0) }') ffmpeg -nostdin -v error -ss "$at" -i "$payload" -frames:v 1 \ -vf "scale=720:-2" -c:v libwebp -quality 80 "$poster" -y else payload="$WORK/out.webp" out_ext=webp echo " converting to webp…" ffmpeg -nostdin -v error -i "$src" -c:v libwebp -quality 82 "$payload" -y fi hash=$(sha256sum "$payload" | cut -c1-16) name="$hash.$out_ext" publish_file "$payload" "$name" # The poster and the H.264 fallback are named after the AV1's hash, not # their own. That is the whole point: fetch-media.sh finds both by name, # with nothing to look up — the poster when the instance made no thumbnail # (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 publish_file "$poster" "$hash.poster.webp" fi post_name="$name" if [ -n "$fallback" ] && [ -f "$fallback" ]; then post_name="$hash.h264.mp4" publish_file "$fallback" "$post_name" fi # 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 # 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) if [ "$code" = 200 ]; then printf ' post this: %s\n' "$url" else echo " WARNING: $url answered HTTP $code — do not post this link yet" >&2 fi printf ' %s -> %s (%s)\n' \ "$(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