Something went wrong. Try again.
Monorepo for Aesthetic.Computer aesthetic.computer
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131#!/bin/sh# rig.sh — broadcast a web page as an RTMP live stream, 24/7.## The whole rig in one process tree: a virtual screen (Xvfb), a virtual# speaker (PulseAudio null sink), Chrome in kiosk mode watching the page,# and ffmpeg turning screen + speaker into RTMP. Built for jasellite;# any Ubuntu box with google-chrome/Xvfb/pulseaudio/xdotool/ffmpeg works.## rig.sh <env-file>## The env file carries the secrets and the station config:# RTMP_URL=rtmp://a.rtmp.youtube.com/live2 (from live.mjs ensure-stream)# STREAM_KEY=xxxx-xxxx-… (secret — chmod 600)# URL=https://tv.whistlegraph.org (what the station shows)# SIZE=1920x1080 FPS=30 VBITRATE=4500k (optional overrides)# UNMUTE_CLICK=1 (tv.whistlegraph.org: first# tap = sound on; exactly one)## Foreground process is ffmpeg; when it dies the whole rig is torn down,# so a systemd unit with Restart=always makes the station self-healing.
set -eu
ENV_FILE="${1:?usage: rig.sh <env-file>}". "$ENV_FILE"
: "${RTMP_URL:?env file must set RTMP_URL}": "${STREAM_KEY:?env file must set STREAM_KEY}"URL="${URL:-https://tv.whistlegraph.org}"SIZE="${SIZE:-1920x1080}"FPS="${FPS:-30}"VBITRATE="${VBITRATE:-4500k}"DISPLAY_NUM="${DISPLAY_NUM:-:99}"SINK="${SINK:-tvsink}"UNMUTE_CLICK="${UNMUTE_CLICK:-1}"PROFILE_DIR="${PROFILE_DIR:-$HOME/.tv-rig-chrome}"
W="${SIZE%x*}"H="${SIZE#*x}"
log() { echo "[rig $(date +%H:%M:%S)] $*"; }
cleanup() { log "tearing down" [ -n "${CHROME_PID:-}" ] && kill "$CHROME_PID" 2>/dev/null || true [ -n "${XVFB_PID:-}" ] && kill "$XVFB_PID" 2>/dev/null || true}trap cleanup EXIT# A bare signal trap would swallow TERM during startup and keep booting a# unit systemd is trying to stop — exit explicitly so the EXIT trap tears down.trap 'exit 143' INT TERM
# ── virtual speaker ──────────────────────────────────────────────────pulseaudio --check 2>/dev/null || pulseaudio --start --exit-idle-time=-1pactl list short sinks | awk '{print $2}' | grep -qx "$SINK" || pactl load-module module-null-sink "sink_name=$SINK" >/dev/nulllog "pulse sink '$SINK' ready"
# ── virtual screen ───────────────────────────────────────────────────Xvfb "$DISPLAY_NUM" -screen 0 "${SIZE}x24" -nolisten tcp &XVFB_PID=$!sleep 2log "Xvfb up on $DISPLAY_NUM (${SIZE})"
# No mouse on television — hide the cursor whenever it isn't moving.if command -v unclutter >/dev/null 2>&1; then DISPLAY="$DISPLAY_NUM" unclutter -idle 1 -root & log "unclutter hiding the cursor"fi
# ── the viewer ───────────────────────────────────────────────────────# Fresh profile every boot: the TV page remembers sound-on across visits,# and a remembered unmute would turn our one tap into a channel change.rm -rf "$PROFILE_DIR"# GPU-less VM: force software raster + decode. No HEVC here either way —# tv.html detects that and serves its H.264 (-avc) twins.PULSE_SINK="$SINK" DISPLAY="$DISPLAY_NUM" google-chrome \ --kiosk "--window-size=${W},${H}" --window-position=0,0 \ --no-first-run --disable-infobars --disable-session-crashed-bubble \ --disable-features=TranslateUI --hide-crash-restore-bubble \ --autoplay-policy=no-user-gesture-required \ --disable-gpu --disable-accelerated-video-decode --disable-gpu-compositing \ --user-data-dir="$PROFILE_DIR" \ "$URL" &CHROME_PID=$!sleep 15log "chrome watching $URL"
if [ "$UNMUTE_CLICK" = "1" ]; then # Default tap lands center; stations whose center is interactive (laklok # opens a message dialog) aim CLICK_X/CLICK_Y somewhere inert instead. DISPLAY="$DISPLAY_NUM" xdotool mousemove "${CLICK_X:-$((W / 2))}" "${CLICK_Y:-$((H / 2))}" click 1 sleep 1 # Park the cursor in the corner so it doesn't sit on the picture. DISPLAY="$DISPLAY_NUM" xdotool mousemove "$((W - 1))" "$((H - 1))" log "unmute tap sent"fi
# ── the transmitter ──────────────────────────────────────────────────# Broadcast legibility: capture at SIZE, transmit at OUT_SIZE. Rendering# the page small and upscaling makes every glyph and QR proportionally# bigger in the YouTube frame — Slidecop for the stream. SCALE_FLAGS# picks the resampler: neighbor keeps pixel UIs crisp, lanczos suits# video footage.OUT_SIZE="${OUT_SIZE:-$SIZE}"SCALE_FLAGS="${SCALE_FLAGS:-lanczos}"SCALE_ARGS=""if [ "$OUT_SIZE" != "$SIZE" ]; then SCALE_ARGS="-vf scale=${OUT_SIZE%x*}:${OUT_SIZE#*x}:flags=$SCALE_FLAGS" log "upscaling ${SIZE} → ${OUT_SIZE} (${SCALE_FLAGS})"fi
# Keyframe every 2s (YouTube wants ≤4s), constant-ish bitrate for ingest.# A/V sync: screen and speaker are two unsynced clocks — wallclock# timestamps on both inputs + async audio resampling + CFR output keep# them married instead of drifting apart over a broadcast day.log "ffmpeg → ${RTMP_URL}/(key)"exec ffmpeg -hide_banner -loglevel warning \ -thread_queue_size 1024 -use_wallclock_as_timestamps 1 \ -f x11grab -framerate "$FPS" -video_size "$SIZE" -i "$DISPLAY_NUM" \ -thread_queue_size 1024 -use_wallclock_as_timestamps 1 \ -itsoffset "${AV_OFFSET:-0}" -f pulse -fragment_size 1024 -i "${SINK}.monitor" \ $SCALE_ARGS \ -af "aresample=async=1" \ -fps_mode cfr \ -c:v libx264 -preset veryfast -tune zerolatency -pix_fmt yuv420p \ -b:v "$VBITRATE" -maxrate "$VBITRATE" -bufsize 9000k \ -x264-params "keyint=$((FPS * 2)):min-keyint=$((FPS * 2)):scenecut=0" \ -c:a aac -b:a 160k -ar 48000 -ac 2 \ -f flv "${RTMP_URL}/${STREAM_KEY}"