mirror of
https://git.pumpin.online/funkpower/catena.git
synced 2026-10-22 18:57:14 +00:00
110 lines
4.7 KiB
Bash
110 lines
4.7 KiB
Bash
#!/bin/sh
|
||||
|
|
# A recording to chapter marks, citable passages, and a copy that plays.
|
|||
|
|
#
|
|||
|
|
# Takes the captions a video already carries, normalises them to one cue
|
|||
|
|
# stream, and turns that into chapter marks for a player and passages worth
|
|||
|
|
# embedding — each keeping the second it was said at. Then asks whether the
|
|||
|
|
# file itself would play on a phone, and prints the one ffmpeg call that
|
|||
|
|
# fixes it.
|
|||
|
|
#
|
|||
|
|
# examples/shell/02-video-to-chapters.sh [url] [work-dir]
|
|||
|
|
#
|
|||
|
|
# The default is a NASA upload: a work of the US government, so public
|
|||
|
|
# domain, and captioned by a person rather than by a model. Any URL yt-dlp
|
|||
|
|
# can reach works, and any local subtitle file works too — skip to the
|
|||
|
|
# cuenorm line and feed it your own.
|
|||
|
|
#
|
|||
|
|
# Needs jq, ffmpeg, and yt-dlp with impersonation support, which YouTube now
|
|||
|
|
# requires before it will hand over captions:
|
|||
|
|
#
|
|||
|
|
# pipx install "yt-dlp[default,curl-cffi]"
|
|||
|
|
#
|
|||
|
|
# and the catena commands on PATH.
|
|||
|
|
set -eu
|
|||
|
|
|
|||
|
|
url="${1:-https://www.youtube.com/watch?v=h0wLZJeYGxw}"
|
|||
|
|
work="${2:-./work}"
|
|||
|
|
mkdir -p "$work"
|
|||
|
|
|
|||
|
|
# Captions written by a person where there are any, and a model's otherwise.
|
|||
|
|
# Automatic captions roll — each line is re-sent as it scrolls up the screen —
|
|||
|
|
# so the same sentence arrives three times and every passage says it thrice.
|
|||
|
|
if [ -z "$(find "$work" -name '*.vtt' -print -quit 2>/dev/null)" ]; then
|
|||
|
|
echo "fetching captions"
|
|||
|
|
yt-dlp --skip-download --write-subs --sub-langs 'en.*' --sub-format vtt \
|
|||
|
|
--output "$work/%(id)s.%(ext)s" "$url" >/dev/null 2>&1 ||
|
|||
|
|
yt-dlp --skip-download --write-auto-subs --sub-langs 'en.*' --sub-format vtt \
|
|||
|
|
--output "$work/%(id)s.%(ext)s" "$url" >/dev/null
|
|||
|
|
fi
|
|||
|
|
|
|||
|
|
# Where several tracks came back, the smallest is the written one: a rolling
|
|||
|
|
# track repeats every line and is four or five times the size.
|
|||
|
|
subs="$(find "$work" -name '*.vtt' -exec ls -S {} + | tail -1)"
|
|||
|
|
[ -n "$subs" ] || { echo "no captions on that video; try another URL" >&2; exit 1; }
|
|||
|
|
echo " captions: $(basename "$subs")"
|
|||
|
|
|
|||
|
|
echo
|
|||
|
|
echo "== cues"
|
|||
|
|
cuenorm "$subs" > "$work/cues.ndjson"
|
|||
|
|
jq -s -r '" \(length) cues over \((map(.end) | max) / 60 | floor)m\((map(.end) | max) % 60 | floor)s"' "$work/cues.ndjson"
|
|||
|
|
jq -r '" [\(.start | floor)s] \(.text[0:62])"' "$work/cues.ndjson" | head -3
|
|||
|
|
|
|||
|
|
# Where the subject changes, found from the words alone — no model, no
|
|||
|
|
# network. The titles come out of each chapter's own first sentence, and are
|
|||
|
|
# meant to be replaced by something better later.
|
|||
|
|
echo
|
|||
|
|
echo "== chapters"
|
|||
|
|
cueseg < "$work/cues.ndjson" > "$work/chapters.ndjson"
|
|||
|
|
jq -r '" \(.start | floor)s–\(.end | floor)s \(.title)"' "$work/chapters.ndjson"
|
|||
|
|
|
|||
|
|
# Cues packed to a size worth embedding, each carrying the seconds it spans,
|
|||
|
|
# so a search hit can be played back at the moment it was said.
|
|||
|
|
echo
|
|||
|
|
echo "== passages"
|
|||
|
|
cuepack < "$work/cues.ndjson" > "$work/passages.ndjson"
|
|||
|
|
jq -s -r '" \(length) passages, \(map(.tokens) | add / length | floor) tokens on average"' "$work/passages.ndjson"
|
|||
|
|
jq -s -r '.[0] | " [\(.start_time | floor)s–\(.end_time | floor)s] \(.text[0:150])…"' "$work/passages.ndjson"
|
|||
|
|
|
|||
|
|
echo
|
|||
|
|
echo "== chapter marks, as a player would take them"
|
|||
|
|
jq -r '"\(.start | floor) \(.title)"' "$work/chapters.ndjson" |
|
|||
|
|
awk '{ t = $1; $1 = ""; printf " %02d:%02d:%02d%s\n", t/3600, (t%3600)/60, t%60, $0 }'
|
|||
|
|
|
|||
|
|
# And the file itself. A recording indexed but unplayable is half a job: a
|
|||
|
|
# phone will not open VP9 in a WebM container, whatever the transcript says.
|
|||
|
|
# Twenty seconds of it is enough to judge the streams, and spares the
|
|||
|
|
# download.
|
|||
|
|
echo
|
|||
|
|
echo "== would it play on a phone"
|
|||
|
|
video="$(find "$work" -type f \( -name '*.webm' -o -name '*.mkv' -o -name '*.mp4' \) -print -quit)"
|
|||
|
|
if [ -z "$video" ]; then
|
|||
|
|
echo " fetching twenty seconds of it"
|
|||
|
|
yt-dlp -f 'worstvideo[ext=webm]+worstaudio[ext=webm]/worst' \
|
|||
|
|
--download-sections '*0-20' --force-keyframes-at-cuts \
|
|||
|
|
--output "$work/%(id)s.%(ext)s" "$url" >/dev/null 2>&1 || true
|
|||
|
|
video="$(find "$work" -type f \( -name '*.webm' -o -name '*.mkv' -o -name '*.mp4' \) -print -quit)"
|
|||
|
|
fi
|
|||
|
|
[ -n "$video" ] || { echo " nothing to judge"; exit 0; }
|
|||
|
|
|
|||
|
|
avplan --json "$video" | jq -r '
|
|||
|
|
" \(.streams.container): \(.streams.video) video, \(.streams.audio // "no") audio",
|
|||
|
|
(if .playable then " plays as it stands" else (.reasons[] | " · \(.)") end)'
|
|||
|
|
|
|||
|
|
# Exit 0 means there is a plan to print; 1 means the file already plays. The
|
|||
|
|
# encoder is whatever this machine actually has — videotoolbox on a Mac,
|
|||
|
|
# NVENC or VAAPI on a Linux box, libx264 where there is no card. Nothing is
|
|||
|
|
# run: converting is expensive and irreversible enough to read first.
|
|||
|
|
echo
|
|||
|
|
if plan="$(avplan --output "$work/for-phone.mp4" "$video")"; then
|
|||
|
|
echo " the one call that fixes it:"
|
|||
|
|
echo
|
|||
|
|
printf ' %s\n' "$plan"
|
|||
|
|
echo
|
|||
|
|
echo " run it with: eval \"\$(avplan --output out.mp4 FILE)\""
|
|||
|
|
else
|
|||
|
|
echo " nothing to convert"
|
|||
|
|
fi
|
|||
|
|
|
|||
|
|
echo
|
|||
|
|
echo "written to $work/"
|