144 lines
5.9 KiB
Bash
Executable file
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}"
|