Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
96 changes: 96 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
name: CI

on:
push:
branches: [main]
pull_request:
branches: [main]

jobs:
smoke:
name: smoke (${{ matrix.os }})
runs-on: ${{ matrix.os }}
strategy:
fail-fast: false
matrix:
os: [macos-latest, ubuntu-latest]

steps:
- name: Checkout
uses: actions/checkout@v4

- name: Install deps (macOS)
if: matrix.os == 'macos-latest'
run: |
brew update
brew install yt-dlp ffmpeg jq

- name: Install deps (Ubuntu)
if: matrix.os == 'ubuntu-latest'
run: |
sudo apt-get update
sudo apt-get install -y yt-dlp ffmpeg jq curl python3

- name: Verify required binaries
run: |
for b in yt-dlp ffmpeg ffprobe jq curl python3 bash; do
command -v "$b" >/dev/null 2>&1 || { echo "missing $b"; exit 1; }
done

- name: Bash syntax check
run: |
bash -n bin/watch bin/dl-video bin/extract-frames bin/transcribe bin/audio-q bin/models

- name: Make bins executable
run: chmod +x bin/*

- name: Install symlinks
run: |
# Run install.sh in an isolated dir so it doesn't pull a fresh clone.
# We just want the symlink step to validate, but install.sh is geared
# to clone-and-link from GitHub. Skip it and replicate the symlink
# step inline against the checked-out tree.
mkdir -p "$HOME/.local/bin"
for bin in watch dl-video extract-frames transcribe audio-q models; do
ln -sf "$GITHUB_WORKSPACE/bin/$bin" "$HOME/.local/bin/$bin"
done
echo "$HOME/.local/bin" >> "$GITHUB_PATH"

- name: Smoke — watch --help
run: watch --help

- name: Smoke — transcribe --help
run: transcribe --help

- name: Smoke — extract-frames --help
run: extract-frames --help

- name: Smoke — dl-video --help
run: dl-video --help

- name: Smoke — audio-q --help
run: audio-q --help

- name: Smoke — models --help
run: models --help

- name: Negative — watch with no args must exit 64
run: |
set +e
watch
rc=$?
set -e
if [[ $rc -ne 64 ]]; then
echo "expected exit 64 from 'watch' with no args, got $rc" >&2
exit 1
fi
echo "OK: rc=$rc"

- name: Version — watch --version must exit 0 and print a version string
run: |
out="$(watch --version)"
echo "$out"
[[ "$out" =~ watch-cli ]] || { echo "expected 'watch-cli' in version output" >&2; exit 1; }

- name: Output schema test
run: bash tests/test-output-schema.sh
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
# watch-cli

![CI](https://github.com/sonpiaz/watch-cli/actions/workflows/ci.yml/badge.svg)

**Watch any social video → get an architecture diagram, working component, runnable notebook, or step-by-step cheat sheet — automatically.**

Eyes and ears for your AI agent. watch-cli composes `yt-dlp` + `ffmpeg` + a Whisper-class ASR into a single command that hands an agent the raw materials to "watch" any video: VIDEO + FRAMES + TRANSCRIPT, ready for an LLM to read frames as images and transcript as text.
Expand Down
162 changes: 116 additions & 46 deletions bin/audio-q
Original file line number Diff line number Diff line change
Expand Up @@ -8,48 +8,79 @@
# - Kyma mode (KYMA_API_KEY set): POST api.kymaapi.com/v1/audio/understand
# Get a Kyma key at https://kymaapi.com.
# - Direct mode (GOOGLE_AI_KEY set): POST Gemini API directly (BYO).
#
# Exit codes (see docs/exit-codes.md):
# 0 success · 1 general · 2 missing-dep · 4 transcribe fail · 64 usage error

set -euo pipefail
set -uo pipefail

SELF="${BASH_SOURCE[0]}"
SELF_DIR="$(cd "$(dirname "$SELF")" && pwd)"
ROOT_DIR="$(cd "$SELF_DIR/.." && pwd)"
# shellcheck source=../lib/env.sh
source "$ROOT_DIR/lib/env.sh"

INPUT="${1:-}"
QUESTION="${2:-}"
INPUT=""
QUESTION=""

while [[ $# -gt 0 ]]; do
case "$1" in
-h|--help)
sed -n '2,11p' "$0" | sed 's/^# \{0,1\}//'
exit 0
;;
-V|--version)
echo "watch-cli v0.2.0"
exit 0
;;
*)
if [[ -z "$INPUT" ]]; then
INPUT="$1"
elif [[ -z "$QUESTION" ]]; then
QUESTION="$1"
fi
shift
;;
esac
done

if [[ -z "$INPUT" || ! -f "$INPUT" || -z "$QUESTION" ]]; then
echo 'usage: audio-q <audio-or-video> "<question>"' >&2
if [[ -z "$INPUT" || -z "$QUESTION" ]]; then
echo 'usage: audio-q <audio-or-video> "<question>" tag=usage-error' >&2
exit 64
fi

watch_cli_audio_mode_check "understand" || exit 1

if ! command -v ffmpeg >/dev/null 2>&1; then
echo "[audio-q] ffmpeg required. brew install ffmpeg / apt install ffmpeg" >&2
exit 1
if [[ ! -f "$INPUT" ]]; then
echo "[audio-q] error: file not found: $INPUT tag=usage-error" >&2
exit 64
fi
if ! command -v jq >/dev/null 2>&1; then
echo "[audio-q] jq required. brew install jq / apt install jq" >&2
exit 1

if ! watch_cli_audio_mode_check "understand"; then
echo "[audio-q] error: no usable audio backend tag=transcribe-other" >&2
exit 4
fi

for dep in ffmpeg ffprobe jq curl base64; do
if ! command -v "$dep" >/dev/null 2>&1; then
echo "[audio-q] error: $dep not found on PATH tag=missing-dep:$dep" >&2
exit 2
fi
done

HASH="$(echo -n "$INPUT" | shasum | cut -c1-10)"
AUDIO="/tmp/audioq_${HASH}.mp3"
if [[ ! -s "$AUDIO" ]]; then
ffmpeg -hide_banner -loglevel error -y -i "$INPUT" \
-vn -ac 1 -ar 16000 -b:a 48k -f mp3 "$AUDIO" 2>&1 >&2 || {
echo "[audio-q] ffmpeg audio extraction failed" >&2
exit 1
}
if ! ffmpeg -hide_banner -loglevel error -y -i "$INPUT" \
-vn -ac 1 -ar 16000 -b:a 48k -f mp3 "$AUDIO" >&2 2>&1; then
echo "[audio-q] error: ffmpeg audio extraction failed tag=transcribe-other" >&2
exit 4
fi
fi

SIZE=$(stat -f%z "$AUDIO" 2>/dev/null || stat -c%s "$AUDIO")
# POSIX size check: wc -c works on both macOS and Linux without a flag.
SIZE="$(wc -c < "$AUDIO" | tr -d ' ')"
if (( SIZE > 19 * 1024 * 1024 )); then
echo "[audio-q] audio is $((SIZE / 1024 / 1024))MB — exceeds 20MB inline cap. Trim source first." >&2
exit 1
echo "[audio-q] error: audio is $((SIZE / 1024 / 1024))MB — exceeds 20MB inline cap. Trim source first. tag=transcribe-other" >&2
exit 4
fi

# Silence guard: multimodal LLMs hallucinate plausible-sounding scene
Expand All @@ -60,9 +91,8 @@ MAX_DB="$(ffmpeg -i "$AUDIO" -af volumedetect -f null /dev/null 2>&1 | \
if [[ -n "$MAX_DB" ]]; then
IS_SILENT="$(awk -v v="$MAX_DB" 'BEGIN { print (v < -60.0) ? 1 : 0 }')"
if [[ "$IS_SILENT" == "1" ]]; then
echo "[audio-q] audio is silent (max_volume ${MAX_DB} dB). The video has no audible content to analyze." >&2
echo "(silent audio: ${MAX_DB} dB)"
exit 0
echo "[audio-q] error: audio is silent (max_volume ${MAX_DB} dB) tag=transcribe-silent-audio" >&2
exit 4
fi
fi

Expand All @@ -74,27 +104,47 @@ case "$WATCH_AUDIO_MODE" in
kyma)
# Use the "audio-understand" alias rather than a concrete SKU.
# Kyma can swap the underlying model without breaking watch-cli.
RESP="$(curl -sS -X POST \
BODY="$(curl -sS -X POST \
-w "%{http_code}" \
-o /dev/stdout \
--max-time 300 \
-H "Authorization: Bearer $KYMA_API_KEY" \
-H "User-Agent: $WATCH_CLI_USER_AGENT" \
-F "file=@$AUDIO;type=audio/mpeg" \
-F "model=audio-understand" \
-F "question=$QUESTION" \
-F "duration_sec=$DUR_SEC" \
"$WATCH_KYMA_BASE/v1/audio/understand")"

TEXT="$(echo "$RESP" | jq -r '.answer // empty')"
if [[ -z "$TEXT" ]]; then
ERR="$(echo "$RESP" | jq -r '.error.message // "unknown error"')"
echo "[audio-q] Kyma call failed: $ERR" >&2
exit 1
fi
echo "$TEXT"
"$WATCH_KYMA_BASE/v1/audio/understand" 2>/dev/null)" || {
echo "[audio-q] error: Kyma request failed tag=transcribe-other" >&2
exit 4
}
HTTP_CODE="${BODY: -3}"
RESP="${BODY:0:${#BODY}-3}"
case "$HTTP_CODE" in
200)
TEXT="$(echo "$RESP" | jq -r '.answer // empty')"
if [[ -z "$TEXT" ]]; then
ERR="$(echo "$RESP" | jq -r '.error.message // "unknown error"')"
echo "[audio-q] error: Kyma call returned empty answer: $ERR tag=transcribe-other" >&2
exit 4
fi
echo "$TEXT"
;;
402|429)
echo "[audio-q] error: Kyma quota or rate limit (HTTP $HTTP_CODE) tag=transcribe-quota" >&2
exit 4 ;;
408|504)
echo "[audio-q] error: Kyma timeout (HTTP $HTTP_CODE) tag=transcribe-timeout" >&2
exit 4 ;;
*)
echo "[audio-q] error: Kyma returned HTTP $HTTP_CODE tag=transcribe-other" >&2
exit 4 ;;
esac
;;

direct)
B64="$(base64 < "$AUDIO" | tr -d '\n')"
BODY="$(jq -n \
REQ_BODY="$(jq -n \
--arg q "$QUESTION" \
--arg data "$B64" \
'{
Expand All @@ -107,19 +157,39 @@ case "$WATCH_AUDIO_MODE" in
generationConfig: { temperature: 0.3, maxOutputTokens: 2048 }
}')"

RESP="$(curl -sS -X POST \
BODY="$(curl -sS -X POST \
-w "%{http_code}" \
-o /dev/stdout \
--max-time 300 \
-H "Content-Type: application/json" \
-H "x-goog-api-key: $GOOGLE_AI_KEY" \
-H "User-Agent: $WATCH_CLI_USER_AGENT" \
-d "$BODY" \
"https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash:generateContent")"

TEXT="$(echo "$RESP" | jq -r '.candidates[0].content.parts[0].text // empty')"
if [[ -z "$TEXT" ]]; then
ERR="$(echo "$RESP" | jq -r '.error.message // "unknown error"')"
echo "[audio-q] Gemini call failed: $ERR" >&2
exit 1
fi
echo "$TEXT"
-d "$REQ_BODY" \
"https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash:generateContent" 2>/dev/null)" || {
echo "[audio-q] error: Gemini request failed tag=transcribe-other" >&2
exit 4
}
HTTP_CODE="${BODY: -3}"
RESP="${BODY:0:${#BODY}-3}"
case "$HTTP_CODE" in
200)
TEXT="$(echo "$RESP" | jq -r '.candidates[0].content.parts[0].text // empty')"
if [[ -z "$TEXT" ]]; then
ERR="$(echo "$RESP" | jq -r '.error.message // "unknown error"')"
echo "[audio-q] error: Gemini call returned empty answer: $ERR tag=transcribe-other" >&2
exit 4
fi
echo "$TEXT"
;;
402|429)
echo "[audio-q] error: Gemini quota or rate limit (HTTP $HTTP_CODE) tag=transcribe-quota" >&2
exit 4 ;;
408|504)
echo "[audio-q] error: Gemini timeout (HTTP $HTTP_CODE) tag=transcribe-timeout" >&2
exit 4 ;;
*)
echo "[audio-q] error: Gemini returned HTTP $HTTP_CODE tag=transcribe-other" >&2
exit 4 ;;
esac
;;
esac
Loading
Loading