
Video Transcripts
- 64 installs
- 1.2k repo stars
- Updated August 4, 2026
- udecode/dotai
Helps with ai & agent building tasks.
About
video-transcripts is a Claude Code skill for ai & agent building. It helps solo builders move faster with AI-assisted development.
- video-transcripts
- AI & Agent Building
- AI-coding skill
Video Transcripts by the numbers
- 64 all-time installs (skills.sh)
- Ranked #6,110 of 16,546 AI & Agent Building skills by installs in the Skillselion catalog
- Data as of Aug 5, 2026 (Skillselion catalog sync)
npx skills add https://github.com/udecode/dotai --skill video-transcriptsAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 64 |
|---|---|
| repo stars | ★ 1.2k |
| Last updated | August 4, 2026 |
| Repository | udecode/dotai ↗ |
What it does
Helps with ai & agent building tasks.
Files
Video Transcripts
Quick Start
Run the helper once per relevant video:
bash .agents/skills/video-transcripts/scripts/generate_video_transcript.sh \
"https://uploads.linear.app/.../screen-recording.mov" \
--title "PDF preview hyperlinks trigger leave-page modal"Or for a GitHub attachment:
bash .agents/skills/video-transcripts/scripts/generate_video_transcript.sh \
"https://github.com/user-attachments/assets/..." \
--title "Slash menu loses selection after confirm"Or for a Screencastify watch page:
bash .agents/skills/video-transcripts/scripts/generate_video_transcript.sh \
"https://app.screencastify.com/watch/..." \
--title "Bulk send expands filtered recipients"Or for a local file:
bash .agents/skills/video-transcripts/scripts/generate_video_transcript.sh \
"/absolute/path/to/video.mov" \
--title "Preview hyperlink exits workflow"For auth-gated Linear uploads, the helper automatically retries with cookies from the local Linear desktop app on macOS. For private GitHub asset URLs, it retries with GITHUB_TOKEN, GH_TOKEN, or gh auth token when available.
Use This When
- A GitHub or Linear issue, PR, or comment includes a screen recording.
- An attachment URL points to
uploads.linear.app. - An attachment URL points to a GitHub attachment or private GitHub asset host.
- A tracker links to a public Screencastify watch page.
- You need timeline-style transcript lines, not a vague summary.
- You want the result in this exact XML shape:
<video-transcripts>
<video-transcript title="...">
[00:00] (...)
</video-transcript>
</video-transcripts>Workflow
1. Run the helper once for each relevant video. 2. Give each run a short, bug-focused --title. 3. For tracked work, the canonical shared cache should live in the tracker next to the evidence it describes. 4. If the video is in the issue or PR body, use a top-level tracker comment. 5. If the video is in a Linear comment, post the transcript cache as a reply to that specific comment. 6. If the video is in a GitHub issue or PR comment, use one dedicated top-level cache comment for that source comment's video set. GitHub has no replies, so keep cache comments separated by source container instead of merging unrelated comment videos together. 7. Cache comment or reply body should start with a transcript source link, then the timestamp lines:
```md [[Transcript](link to video or comment if not available)] [00:00] (...) ```
8. When caching helper XML, strip the <video-transcripts> and <video-transcript ...> wrapper lines and paste only timestamp lines after the source link. 9. If there are multiple videos in the same source container, repeat the [[Transcript](...)] source link plus timestamp lines for each video. 10. Do not hand-write or paraphrase video behavior when the helper can run. Use the actual transcript output. 11. Link [[Transcript](...)] to the video URL when available. If the video URL is not available or is unstable, link to the source comment that contains the video. 12. For signed tracker-hosted URLs like uploads.linear.app, strip the query string so a new signature does not invalidate an otherwise valid cache entry. For Screencastify, cache against the stable https://app.screencastify.com/watch/<id> URL, not the signed dash-assets HLS URLs. 13. Do not add decorative metadata like title to cached transcript entries unless a later workflow truly needs it. 14. Before re-transcribing for tracked work, match cache entries by source container first:
- one cache comment for issue or PR body videos
- one Linear reply per comment containing video(s)
- one dedicated GitHub cache comment per issue or PR comment containing video(s)
15. If the matching cache already covers the current normalized video keys for that source container, reuse it. Only transcribe missing or new video evidence. 16. Do not use docs/ for raw tracker transcript cache. That is durable repo knowledge space, not raw issue evidence. 17. If you invoke this through codex exec, prefer -o <file> so the final XML is captured without CLI progress chatter.
Output Contract
- Return XML, not Markdown.
- Use one
[MM:SS] (...)line per observed action or system response. - Quote visible UI text when legible.
- Describe only visible actions, screen changes, and audible speech if present.
- Do not invent hidden state, motives, or implementation details.
- Prefer concise, high-signal lines over per-keystroke sludge.
Model Strategy
The helper defaults to gemini-3.1-flash-lite-preview for cost efficiency.
If that output is malformed, too thin, or obviously noisy, it retries with gemini-3-flash-preview.
For Gemini 3 models, the helper forces minimal thinking so output budget goes to the transcript instead of hidden reasoning.
Override with:
VIDEO_TRANSCRIPTS_MODEL=gemini-3-flash-preview bash .agents/skills/video-transcripts/scripts/generate_video_transcript.sh ...Or:
bash .agents/skills/video-transcripts/scripts/generate_video_transcript.sh ... \
--model gemini-2.5-flashNotes
- The helper accepts a local file path or remote URL.
- For
uploads.linear.appURLs, it first triesLINEAR_COOKIE_HEADER, thenLINEAR_COOKIES_DB, then falls back to the local Linear desktop cookie store at~/Library/Application Support/Linear/Cookies. - For GitHub asset URLs, it first tries
GITHUB_TOKEN, thenGH_TOKEN, thengh auth token, then falls back to an unauthenticated download. - For Screencastify watch URLs, it calls
https://umbrella.svc.screencastify.com/api/umbrellaService/watch/<id>, resolves the signed HLS manifest, rewrites child playlists so segments keep the signature, and remuxes the recording withffmpeg. - Screencastify URL support requires
ffmpeg. - It looks for
GEMINI_API_KEY, thenGOOGLE_API_KEY. - If neither is set, it tries
~/.bash_profilebefore failing. - Use
--debug-dir <dir>when you want request and response artifacts saved. - Quality gate rejects obvious low-signal transcripts such as repeated partial typing runs and falls back automatically.
#!/usr/bin/env bash
set -euo pipefail
usage() {
cat <<'EOF'
Usage:
generate_video_transcript.sh <video-path-or-url> [--title TITLE] [--model MODEL] [--fallback-model MODEL] [--debug-dir DIR]
Examples:
generate_video_transcript.sh "/tmp/bug.mov" --title "Preview link leaves workflow"
generate_video_transcript.sh "https://uploads.linear.app/.../recording.mov" --title "PDF preview hyperlink exits workflow"
generate_video_transcript.sh "https://github.com/user-attachments/assets/..." --title "Slash menu loses selection"
generate_video_transcript.sh "https://app.screencastify.com/watch/..." --title "Bulk send expands filtered recipients"
EOF
}
if [[ "${1:-}" == "" || "${1:-}" == "--help" || "${1:-}" == "-h" ]]; then
usage
exit 0
fi
INPUT="$1"
shift
TITLE=""
MODEL="${VIDEO_TRANSCRIPTS_MODEL:-gemini-3.1-flash-lite-preview}"
FALLBACK_MODEL="${VIDEO_TRANSCRIPTS_FALLBACK_MODEL:-gemini-3-flash-preview}"
DEBUG_DIR=""
while [[ $# -gt 0 ]]; do
case "$1" in
--title)
TITLE="${2:-}"
shift 2
;;
--model)
MODEL="${2:-}"
shift 2
;;
--fallback-model)
FALLBACK_MODEL="${2:-}"
shift 2
;;
--debug-dir)
DEBUG_DIR="${2:-}"
shift 2
;;
*)
echo "Unknown argument: $1" >&2
usage >&2
exit 1
;;
esac
done
if [[ -z "${GEMINI_API_KEY:-}" && -z "${GOOGLE_API_KEY:-}" && -f "$HOME/.bash_profile" ]]; then
# shellcheck disable=SC1090
source "$HOME/.bash_profile"
fi
API_KEY="${GEMINI_API_KEY:-${GOOGLE_API_KEY:-}}"
if [[ -z "$API_KEY" ]]; then
echo "Missing GEMINI_API_KEY or GOOGLE_API_KEY." >&2
exit 1
fi
WORK_DIR="$(mktemp -d "${TMPDIR:-/tmp}/video-transcripts.XXXXXX")"
cleanup() {
rm -rf "$WORK_DIR"
}
trap cleanup EXIT
if [[ -n "$DEBUG_DIR" ]]; then
mkdir -p "$DEBUG_DIR"
fi
linear_cookie_header() {
if [[ -n "${LINEAR_COOKIE_HEADER:-}" ]]; then
printf '%s\n' "$LINEAR_COOKIE_HEADER"
return 0
fi
local cookie_db="${LINEAR_COOKIES_DB:-$HOME/Library/Application Support/Linear/Cookies}"
if [[ ! -f "$cookie_db" ]]; then
return 0
fi
python3 - "$cookie_db" <<'PY'
import sqlite3
import sys
cookie_db = sys.argv[1]
try:
conn = sqlite3.connect(f"file:{cookie_db}?mode=ro", uri=True)
except sqlite3.Error:
sys.exit(0)
try:
rows = conn.execute(
"""
select name, value
from cookies
where host_key = '.linear.app'
and length(value) > 0
order by name
"""
).fetchall()
except sqlite3.Error:
sys.exit(0)
if rows:
print("; ".join(f"{name}={value}" for name, value in rows))
PY
}
github_auth_header() {
if [[ -n "${GITHUB_TOKEN:-}" ]]; then
printf 'Authorization: Bearer %s\n' "$GITHUB_TOKEN"
return 0
fi
if [[ -n "${GH_TOKEN:-}" ]]; then
printf 'Authorization: Bearer %s\n' "$GH_TOKEN"
return 0
fi
if command -v gh >/dev/null 2>&1; then
local token
token="$(gh auth token 2>/dev/null || true)"
if [[ -n "$token" ]]; then
printf 'Authorization: Bearer %s\n' "$token"
return 0
fi
fi
}
download_to_path() {
local source="$1"
local dest="$2"
shift 2
curl -L --fail --silent --show-error "$@" "$source" -o "$dest"
}
append_query() {
local url="$1"
local query="$2"
if [[ -z "$query" ]]; then
printf '%s\n' "$url"
elif [[ "$url" == *"?"* ]]; then
printf '%s&%s\n' "$url" "$query"
else
printf '%s?%s\n' "$url" "$query"
fi
}
screencastify_recording_id() {
local source="$1"
if [[ "$source" =~ screencastify\.com/watch/([^/?#]+) ]]; then
printf '%s\n' "${BASH_REMATCH[1]}"
return 0
fi
if [[ "$source" =~ screencastify\.com/v/([^/?#]+) ]]; then
printf '%s\n' "${BASH_REMATCH[1]}"
return 0
fi
return 1
}
rewrite_screencastify_child_playlist() {
local input_file="$1"
local output_file="$2"
local prefix="$3"
local query="$4"
python3 - "$input_file" "$output_file" "$prefix" "$query" <<'PY'
import re
import sys
input_file, output_file, prefix, query = sys.argv[1:]
prefix = prefix.rstrip("/") + "/"
def signed(uri: str) -> str:
if uri.startswith(("data:", "file:")):
return uri
if uri.startswith(("http://", "https://")):
base = uri
else:
base = prefix + uri.lstrip("/")
if not query:
return base
separator = "&" if "?" in base else "?"
return f"{base}{separator}{query}"
with open(input_file, encoding="utf-8") as source:
lines = source.read().splitlines()
rewritten = []
for line in lines:
stripped = line.strip()
if stripped.startswith("#EXT-X-MAP:"):
rewritten.append(
re.sub(r'URI="([^"]+)"', lambda match: f'URI="{signed(match.group(1))}"', line)
)
elif stripped.startswith("#") or not stripped:
rewritten.append(line)
else:
rewritten.append(signed(stripped))
with open(output_file, "w", encoding="utf-8") as dest:
dest.write("\n".join(rewritten) + "\n")
PY
}
rewrite_screencastify_master_playlist() {
local input_file="$1"
local output_file="$2"
local child_list_file="$3"
python3 - "$input_file" "$output_file" "$child_list_file" <<'PY'
import os
import re
import sys
input_file, output_file, child_list_file = sys.argv[1:]
uri_pattern = re.compile(r'URI="([^"]+)"')
with open(input_file, encoding="utf-8") as source:
lines = source.read().splitlines()
children = []
rewritten = []
def local_child(uri: str) -> str:
name = os.path.basename(uri.split("?", 1)[0])
if name and name not in children:
children.append(uri)
return name
for line in lines:
stripped = line.strip()
if stripped.startswith("#EXT-X-MEDIA:"):
rewritten.append(
uri_pattern.sub(lambda match: f'URI="{local_child(match.group(1))}"', line)
)
elif stripped.startswith("#") or not stripped:
rewritten.append(line)
elif stripped.endswith(".m3u8") or ".m3u8?" in stripped:
rewritten.append(local_child(stripped))
else:
rewritten.append(line)
with open(output_file, "w", encoding="utf-8") as dest:
dest.write("\n".join(rewritten) + "\n")
with open(child_list_file, "w", encoding="utf-8") as dest:
for child in children:
dest.write(child + "\n")
PY
}
download_screencastify_watch() {
local source="$1"
local dest="$2"
local recording_id
recording_id="$(screencastify_recording_id "$source")"
if ! command -v jq >/dev/null 2>&1; then
echo "Screencastify downloads require jq." >&2
return 1
fi
if ! command -v ffmpeg >/dev/null 2>&1; then
echo "Screencastify downloads require ffmpeg to remux the signed HLS stream." >&2
return 1
fi
local metadata_file="$WORK_DIR/screencastify.json"
download_to_path "https://umbrella.svc.screencastify.com/api/umbrellaService/watch/$recording_id" "$metadata_file"
local hls_url auth_query prefix
hls_url="$(jq -r '.manifest.hlsUrl // empty' "$metadata_file")"
auth_query="$(jq -r '.manifest.auth.query // empty' "$metadata_file")"
prefix="$(jq -r '.manifest.auth.prefix // empty' "$metadata_file")"
if [[ -z "$hls_url" ]]; then
echo "Screencastify watch metadata did not include an HLS manifest URL." >&2
return 1
fi
if [[ -z "$prefix" ]]; then
prefix="${hls_url%/*}/"
fi
local hls_dir="$WORK_DIR/screencastify-hls"
mkdir -p "$hls_dir"
local remote_master="$hls_dir/master.remote.m3u8"
local local_master="$hls_dir/master.m3u8"
local child_list="$hls_dir/children.txt"
download_to_path "$(append_query "$hls_url" "$auth_query")" "$remote_master"
rewrite_screencastify_master_playlist "$remote_master" "$local_master" "$child_list"
while IFS= read -r child_uri; do
[[ -z "$child_uri" ]] && continue
local child_name child_remote child_local child_url
child_name="$(basename "${child_uri%%\?*}")"
child_remote="$hls_dir/$child_name.remote"
child_local="$hls_dir/$child_name"
if [[ "$child_uri" =~ ^https?:// ]]; then
child_url="$(append_query "$child_uri" "$auth_query")"
else
child_url="$(append_query "$prefix$child_uri" "$auth_query")"
fi
download_to_path "$child_url" "$child_remote"
rewrite_screencastify_child_playlist "$child_remote" "$child_local" "$prefix" "$auth_query"
done < "$child_list"
ffmpeg -hide_banner -loglevel error -y \
-protocol_whitelist file,http,https,tcp,tls,crypto \
-allowed_extensions ALL \
-i "$local_master" \
-map 0:v:0 -map 0:a:0? \
-c copy "$dest"
if [[ -n "$DEBUG_DIR" ]]; then
cp "$metadata_file" "$DEBUG_DIR/screencastify.json"
cp -R "$hls_dir" "$DEBUG_DIR/screencastify-hls"
fi
}
download_input_if_needed() {
local source="$1"
if [[ "$source" =~ ^https?:// ]]; then
local ext
ext="$(basename "${source%%\?*}")"
ext="${ext##*.}"
[[ "$ext" == "$source" ]] && ext="bin"
local dest="$WORK_DIR/input.$ext"
if screencastify_recording_id "$source" >/dev/null; then
dest="$WORK_DIR/input.mp4"
download_screencastify_watch "$source" "$dest"
elif [[ "$source" == *"uploads.linear.app"* ]]; then
local cookie_header
cookie_header="$(linear_cookie_header)"
if [[ -n "$cookie_header" ]]; then
download_to_path "$source" "$dest" -H "Cookie: $cookie_header"
else
download_to_path "$source" "$dest" || {
echo "Failed to download Linear upload. Set LINEAR_COOKIE_HEADER or LINEAR_COOKIES_DB, or open the Linear desktop app so the helper can reuse its session cookies." >&2
return 1
}
fi
elif [[ "$source" == *"github.com/user-attachments/"* || "$source" == *"private-user-images.githubusercontent.com"* || "$source" == *"private-attachments.githubusercontent.com"* || "$source" == *"media.githubusercontent.com/media/attachments/"* ]]; then
local auth_header
auth_header="$(github_auth_header || true)"
if [[ -n "$auth_header" ]]; then
download_to_path "$source" "$dest" -H "$auth_header" || download_to_path "$source" "$dest"
else
download_to_path "$source" "$dest" || {
echo "Failed to download GitHub attachment. Set GITHUB_TOKEN or GH_TOKEN, or run gh auth login so the helper can reuse GitHub auth." >&2
return 1
}
fi
else
download_to_path "$source" "$dest"
fi
printf '%s\n' "$dest"
else
printf '%s\n' "$source"
fi
}
escape_xml_attr() {
local value="$1"
value="${value//&/&}"
value="${value//\"/"}"
value="${value//</<}"
value="${value//>/>}"
printf '%s' "$value"
}
normalize_output() {
local raw="$1"
raw="$(printf '%s\n' "$raw" | sed -e '1{/^```[a-zA-Z0-9_-]*$/d;}' -e '${/^```$/d;}')"
raw="$(
printf '%s\n' "$raw" | awk '
/^\[[0-9][0-9]:[0-9][0-9]\] / {
prefix = substr($0, 1, 7)
rest = substr($0, 9)
if (rest !~ /^\(.*\)$/) {
$0 = prefix " (" rest ")"
}
}
{ print }
'
)"
if grep -q '<video-transcripts>' <<<"$raw"; then
printf '%s\n' "$raw"
return 0
fi
if grep -q '<video-transcript ' <<<"$raw"; then
printf '<video-transcripts>\n%s\n</video-transcripts>\n' "$raw"
return 0
fi
return 1
}
count_timestamps() {
local text="$1"
grep -c '^\[[0-9][0-9]:[0-9][0-9]\]' <<<"$text" || true
}
generation_config_json() {
local model_name="$1"
if [[ "$model_name" == gemini-3* ]]; then
cat <<'EOF'
{
"temperature": 0,
"maxOutputTokens": 2400,
"thinkingConfig": {
"thinkingLevel": "minimal"
}
}
EOF
return 0
fi
if [[ "$model_name" == gemini-2.5-flash* ]]; then
cat <<'EOF'
{
"temperature": 0,
"maxOutputTokens": 2400,
"thinkingConfig": {
"thinkingBudget": 0
}
}
EOF
return 0
fi
cat <<'EOF'
{
"temperature": 0,
"maxOutputTokens": 2400
}
EOF
}
has_noisy_runs() {
local text="$1"
awk '
function classify(line) {
lower = tolower(line)
if (lower ~ /broadcast title field|broadcast title input field/) {
return "broadcast_title"
}
if (lower ~ /message text area|message field| into the message| to the message/) {
return "message"
}
if (lower ~ /scrolls|scrolling/) {
return "scroll"
}
return ""
}
function noisy(line) {
lower = tolower(line)
return lower ~ /types|typing|continues typing|finishes typing|adds|enters|scrolls|scrolling/
}
/^\[[0-9][0-9]:[0-9][0-9]\]/ {
target = classify($0)
is_noisy = noisy($0)
if (target != "" && is_noisy && target == prev_target && prev_noisy == 1) {
run_len++
} else if (target != "" && is_noisy) {
run_len = 1
} else {
run_len = 0
}
limit = 0
if (target == "broadcast_title") {
limit = 2
} else if (target == "message") {
limit = 3
} else if (target == "scroll") {
limit = 2
}
if (limit > 0 && run_len > limit) {
noisy_found = 1
}
prev_target = target
prev_noisy = is_noisy
}
END {
exit noisy_found ? 0 : 1
}
' <<<"$text"
}
call_model() {
local model_name="$1"
local video_path="$2"
local title_value="$3"
local prefix="$4"
local mime_type
mime_type="$(file -b --mime-type "$video_path")"
local num_bytes
num_bytes="$(wc -c < "$video_path" | tr -d ' ')"
local display_name
display_name="$(basename "$video_path")"
local safe_title
safe_title="$(escape_xml_attr "$title_value")"
local header_file="$WORK_DIR/${prefix}.upload-header.tmp"
local file_info="$WORK_DIR/${prefix}.file_info.json"
local get_file="$WORK_DIR/${prefix}.file_get.json"
local request_file="$WORK_DIR/${prefix}.request.json"
local response_file="$WORK_DIR/${prefix}.response.json"
local generation_config
generation_config="$(generation_config_json "$model_name")"
curl -s "https://generativelanguage.googleapis.com/upload/v1beta/files?key=$API_KEY" \
-D "$header_file" \
-H "X-Goog-Upload-Protocol: resumable" \
-H "X-Goog-Upload-Command: start" \
-H "X-Goog-Upload-Header-Content-Length: $num_bytes" \
-H "X-Goog-Upload-Header-Content-Type: $mime_type" \
-H "Content-Type: application/json" \
-d "{\"file\":{\"display_name\":\"$display_name\"}}" >/dev/null
local upload_url
upload_url="$(awk -F': ' 'tolower($1)=="x-goog-upload-url" {gsub("\r", "", $2); print $2}' "$header_file")"
if [[ -z "$upload_url" ]]; then
echo "Failed to get resumable upload URL for $model_name." >&2
return 1
fi
curl -s "$upload_url" \
-H "Content-Length: $num_bytes" \
-H "X-Goog-Upload-Offset: 0" \
-H "X-Goog-Upload-Command: upload, finalize" \
--data-binary @"$video_path" > "$file_info"
local file_name
file_name="$(jq -r '.file.name // empty' "$file_info")"
local file_uri
file_uri="$(jq -r '.file.uri // empty' "$file_info")"
if [[ -z "$file_name" || -z "$file_uri" ]]; then
echo "Upload failed for $model_name." >&2
return 1
fi
local state=""
for _ in $(seq 1 60); do
curl -s "https://generativelanguage.googleapis.com/v1beta/$file_name?key=$API_KEY" > "$get_file"
state="$(jq -r '.state // empty' "$get_file")"
if [[ "$state" == "ACTIVE" ]]; then
break
fi
if [[ "$state" == "FAILED" ]]; then
echo "Video processing failed for $model_name." >&2
return 1
fi
sleep 2
done
if [[ "$state" != "ACTIVE" ]]; then
echo "Timed out waiting for video processing for $model_name." >&2
return 1
fi
cat > "$request_file" <<EOF
{
"generationConfig": $generation_config,
"contents": [{
"parts": [
{
"file_data": {
"mime_type": "$mime_type",
"file_uri": "$file_uri"
}
},
{
"text": "Analyze this video and return exactly one XML transcript block for the supplied title. Output only XML with no Markdown code fences. Use this exact shape:\n<video-transcripts>\n<video-transcript title=\"$safe_title\">\n[00:00] (Sentence.)\n</video-transcript>\n</video-transcripts>\n\nRules:\n- Use [MM:SS] timestamps.\n- One line per visible action, UI transition, or audible spoken event if present.\n- Quote visible UI text when legible.\n- Describe only visible behavior and clearly observable system responses.\n- Do not invent hidden state, motives, or implementation details.\n- Prefer concise, high-signal transcript lines over per-keystroke typing spam.\n- Collapse active typing into the final stable text for that field unless intermediate edits are themselves the bug.\n- Do not output multiple partial strings for the same text field while the user is still typing.\n- Avoid repetitive scrolling lines unless a scroll directly reveals the bug.\n- For a 2 to 5 minute product video, target roughly 15 to 35 transcript lines.\n- Keep the transcript useful for bug triage and QA reproduction."
}
]
}]
}
EOF
curl -s "https://generativelanguage.googleapis.com/v1beta/models/$model_name:generateContent?key=$API_KEY" \
-H "Content-Type: application/json" \
-X POST \
--data @"$request_file" > "$response_file"
if jq -e '.error' "$response_file" >/dev/null; then
jq -r '.error.message' "$response_file" >&2
return 1
fi
LAST_FINISH_REASON="$(jq -r '.candidates[0].finishReason // empty' "$response_file")"
LAST_THOUGHTS_TOKEN_COUNT="$(jq -r '.usageMetadata.thoughtsTokenCount // 0' "$response_file")"
LAST_CANDIDATES_TOKEN_COUNT="$(jq -r '.usageMetadata.candidatesTokenCount // 0' "$response_file")"
if [[ -n "$DEBUG_DIR" ]]; then
cp "$file_info" "$DEBUG_DIR/${prefix}.file_info.json"
cp "$get_file" "$DEBUG_DIR/${prefix}.file_get.json"
cp "$request_file" "$DEBUG_DIR/${prefix}.request.json"
cp "$response_file" "$DEBUG_DIR/${prefix}.response.json"
fi
jq -r '.candidates[0].content.parts[]?.text // empty' "$response_file"
}
VIDEO_PATH="$(download_input_if_needed "$INPUT")"
if [[ ! -f "$VIDEO_PATH" ]]; then
echo "Video not found: $VIDEO_PATH" >&2
exit 1
fi
if [[ -z "$TITLE" ]]; then
TITLE="$(basename "$VIDEO_PATH")"
TITLE="${TITLE%.*}"
fi
run_and_validate() {
local model_name="$1"
local prefix="$2"
local raw normalized timestamp_count
raw="$(call_model "$model_name" "$VIDEO_PATH" "$TITLE" "$prefix")"
if [[ "${LAST_FINISH_REASON:-}" != "STOP" && "${LAST_FINISH_REASON:-}" != "" ]]; then
echo "Transcript from $model_name ended with finish reason ${LAST_FINISH_REASON}." >&2
return 1
fi
normalized="$(normalize_output "$raw")" || return 1
timestamp_count="$(count_timestamps "$normalized")"
if (( timestamp_count < 5 )); then
echo "Transcript from $model_name looked too thin ($timestamp_count timestamp lines)." >&2
return 1
fi
if has_noisy_runs "$normalized"; then
echo "Transcript from $model_name looked noisy and repetitive." >&2
return 1
fi
printf '%s\n' "$normalized"
}
if transcript="$(run_and_validate "$MODEL" "primary")"; then
printf '%s\n' "$transcript"
exit 0
fi
if [[ "$FALLBACK_MODEL" != "$MODEL" ]]; then
echo "Retrying with fallback model: $FALLBACK_MODEL" >&2
transcript="$(run_and_validate "$FALLBACK_MODEL" "fallback")"
printf '%s\n' "$transcript"
exit 0
fi
echo "Failed to generate a valid transcript." >&2
exit 1