flybrain/infra/bin/fly-recap
acamilo 660c3cf00d
Some checks failed
ci / node 22 (test + typecheck) (push) Has been cancelled
ci / rust stable (cargo test --workspace --release) (push) Has been cancelled
ci / infra/tests/lint.sh (push) Has been cancelled
ci / playwright apps/stage (allowed to fail) (push) Has been cancelled
flybrain v0.4.0: public tree (history retained privately)
2026-09-21 15:09:46 +00:00

144 lines
5.9 KiB
Bash
Executable file

#!/usr/bin/env bash
# infra/bin/fly-recap — fly-recap.service, run daily at 04:20 by
# fly-recap.timer. Cuts yesterday's highlight reel from the rolling
# MPEG-TS recording. docs/design/infra.md section 3 ("fly-recap reads
# .../events.jsonl for yesterday...").
#
# Inputs this script depends on that are not yet nailed down by a binding
# schema doc (flysim does not exist yet — see the deviation note in the
# final report): events.jsonl is assumed to hold one FeedEvent
# (docs/feed-protocol.md) per line plus a top-level "wallMs" epoch-ms
# field; segments.csv is assumed to be ffmpeg's own `segment_list_type=csv`
# output (filename,start_time,end_time, in seconds relative to
# flycast-start). If the real shapes differ once flysim ships, this script
# is the one place to update.
set -euo pipefail
: "${FLY_STATE_DIR:=/srv/fly/state}"
: "${FLY_MEDIA_DIR:=/srv/fly/media}"
: "${HIGHLIGHT_DIR:=${FLY_MEDIA_DIR}/highlights}"
: "${TZ_AUDIENCE:=America/New_York}"
: "${CLIP_SECONDS:=12}" # per-event clip length; 2s GOP means 2s cut granularity
: "${OUTRO_REENCODE_SECONDS:=20}"
log() { echo "fly-recap: $*" >&2; }
die() { echo "fly-recap: FATAL: $*" >&2; exit 1; }
need_cmd() { command -v "$1" >/dev/null 2>&1 || die "missing command: $1"; }
need_cmd jq
need_cmd ffmpeg
need_cmd date
events_file="${FLY_STATE_DIR}/events.jsonl"
segments_csv="${FLY_STATE_DIR}/segments.csv"
start_file="${FLY_STATE_DIR}/flycast-start"
[ -f "$events_file" ] || { log "no events.jsonl yet, nothing to do"; exit 0; }
[ -f "$segments_csv" ] || { log "no segments.csv yet, nothing to do"; exit 0; }
[ -f "$start_file" ] || { log "no flycast-start marker yet, nothing to do"; exit 0; }
flycast_start_epoch="$(cat "$start_file")"
# "Yesterday" is computed in the audience's timezone (America/New_York)
# per docs/design/infra.md section 3, even though every event/filename
# timestamp on disk is UTC — the documented off-by-one trap.
yesterday_start_epoch="$(TZ="$TZ_AUDIENCE" date -d 'yesterday 00:00:00' +%s)"
yesterday_end_epoch="$(TZ="$TZ_AUDIENCE" date -d 'today 00:00:00' +%s)"
yesterday_label="$(TZ="$TZ_AUDIENCE" date -d @"$yesterday_start_epoch" +%Y%m%d)"
out="${HIGHLIGHT_DIR}/${yesterday_label}.mp4"
if [ -f "$out" ]; then
log "already have ${out}, nothing to do (idempotent re-run)"
exit 0
fi
mkdir -p "$HIGHLIGHT_DIR"
workdir="$(mktemp -d "/tmp/fly-recap.XXXXXX")"
trap 'rm -rf "$workdir"' EXIT
# Milestone rank-ups, badges, first-visit areas, and sugar redemptions.
# "deaths" are folded in for game adapters that emit them (Pokemon does
# not; the platformer will) via a rewardKind of "death" if one is ever
# added — jq below tolerates its absence.
selected="$workdir/events.jsonl"
jq -c --argjson lo "$((yesterday_start_epoch * 1000))" --argjson hi "$((yesterday_end_epoch * 1000))" '
select(.wallMs >= $lo and .wallMs < $hi) |
select(
.kind == "milestone" or
.kind == "sugar" or
(.kind == "reward" and (.rewardKind == "badge" or .rewardKind == "area" or .rewardKind == "death"))
)
' "$events_file" > "$selected" || true
count="$(wc -l < "$selected" | tr -d ' ')"
if [ "${count:-0}" -eq 0 ]; then
log "no recap-worthy events for ${yesterday_label}, skipping"
exit 0
fi
log "${count} candidate events for ${yesterday_label}"
# segments.csv: filename,start_time,end_time (seconds relative to
# flycast-start). Sorted ascending by start_time.
clip_list="$workdir/concat.txt"
: > "$clip_list"
clip_i=0
while IFS= read -r event_wall_ms; do
event_epoch=$(( event_wall_ms / 1000 ))
rel_from_start=$(( event_epoch - flycast_start_epoch ))
[ "$rel_from_start" -lt 0 ] && continue
# Find the segment whose [start,end) window contains this offset.
seg_line="$(awk -F, -v t="$rel_from_start" '$2 <= t && t < $3 {print; exit}' "$segments_csv" || true)"
[ -z "$seg_line" ] && continue
seg_file="$(echo "$seg_line" | cut -d, -f1)"
seg_start="$(echo "$seg_line" | cut -d, -f2)"
[ -f "$seg_file" ] || continue
in_segment_offset=$(( rel_from_start - ${seg_start%.*} ))
[ "$in_segment_offset" -lt 0 ] && in_segment_offset=0
clip_i=$((clip_i + 1))
clip_out="$workdir/clip-$(printf '%03d' "$clip_i").ts"
if ffmpeg -nostdin -loglevel error -y \
-ss "$in_segment_offset" -i "$seg_file" -t "$CLIP_SECONDS" \
-c copy "$clip_out" 2>/dev/null; then
echo "file '$clip_out'" >> "$clip_list"
fi
done < <(jq -r '.wallMs' "$selected")
clip_count="$(wc -l < "$clip_list" | tr -d ' ')"
if [ "${clip_count:-0}" -eq 0 ]; then
log "no clips could be cut from segments on disk (likely already pruned by retention), skipping"
exit 0
fi
log "cut ${clip_count} clips"
concat_out="$workdir/concat.ts"
ffmpeg -nostdin -loglevel warning -y -f concat -safe 0 -i "$clip_list" -c copy "$concat_out"
# Re-encode only the final few seconds for a clean ending (a copy-concat of
# .ts clips can leave a rough final GOP at the point ffmpeg's live recorder
# was still writing when we cut). Cheap relative to a full re-encode.
duration="$(ffprobe -v error -show_entries format=duration -of csv=p=0 "$concat_out" 2>/dev/null || echo 0)"
duration_int="${duration%.*}"
tail_start=$(( duration_int > OUTRO_REENCODE_SECONDS ? duration_int - OUTRO_REENCODE_SECONDS : 0 ))
if [ "$tail_start" -gt 0 ]; then
head_out="$workdir/head.ts"
tail_out="$workdir/tail.mp4"
ffmpeg -nostdin -loglevel warning -y -i "$concat_out" -t "$tail_start" -c copy "$head_out"
ffmpeg -nostdin -loglevel warning -y -ss "$tail_start" -i "$concat_out" \
-c:v libx264 -preset veryfast -c:a aac -b:a 160k "$tail_out"
reencoded_list="$workdir/final.txt"
printf "file '%s'\nfile '%s'\n" "$head_out" "$tail_out" > "$reencoded_list"
ffmpeg -nostdin -loglevel warning -y -f concat -safe 0 -i "$reencoded_list" \
-c:v libx264 -preset veryfast -c:a aac -b:a 160k "$out.tmp.mp4"
else
ffmpeg -nostdin -loglevel warning -y -i "$concat_out" \
-c:v libx264 -preset veryfast -c:a aac -b:a 160k "$out.tmp.mp4"
fi
mv -f "$out.tmp.mp4" "$out"
log "wrote ${out}"