mirror of
https://git.pumpin.online/funkpower/catena.git
synced 2026-10-22 18:57:14 +00:00
Eight of them, each doing something a pipeline actually has to do, and each starting from something anyone can fetch — a public domain book from Project Gutenberg, a NASA broadcast, or a sample carried in the file. Shell, because the tools are filters first: a book to passages and what its notes cite; a recording to chapter marks, citable passages and the one ffmpeg call that makes it play on a phone; passages into pgvector and a search that asks twice and reconciles. Rust, for the case where the caller already holds the text and a subprocess would be serialising for nothing. Python, for the shape a pipeline stage takes once the reading is a library call. TypeScript, for the app side — chapter marks and deep links, and fusing two rankings in process. Three of them end with what re-ranking means, because it is two different things under one name: fusing ranked lists, which is what `rrf` does and needs no model, and a cross-encoder scoring query and passage together, which is accurate, slow, and runs over what retrieval already found.
110 lines
4.7 KiB
Bash
Executable File
110 lines
4.7 KiB
Bash
Executable File
#!/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/"
|