Skip to content

Commit c3a90a9

Browse files
alexkromanclaude
andauthored
Add video support (--video) to clip/dub and a new caption command (#139)
URL sources (YouTube/media pages) were always downloaded audio-only, so clip cut audio clips from videos and dub had no URL path at all. Now: - youtube.download_audio becomes download_media with a video flag: video=True fetches best video+audio merged to mp4. A shared validate_video_flag rejects --video for local sources (their video is already operated on directly, and a requested flag is never dropped silently). - assembly clip --video downloads the full video for a URL source, so the clips are cut from the video. - assembly dub gains URL sources (mirroring clip's download-then-process flow, default output in the cwd) and --video, so the dub keeps the picture. - New assembly caption command: transcribe (or reuse a transcript with -t), fetch the SRT export, and burn open captions into the video with ffmpeg's subtitles filter (audio copied untouched; explicit -map 0:v makes audio-only input a clean error instead of a silent uncaptioned copy). --chars-per-caption and --font-size shape the captions; URL sources always download the full video. https://claude.ai/code/session_01Vie2WKBt5zjUWwLbxp9BpN Co-authored-by: Claude <noreply@anthropic.com>
1 parent e523721 commit c3a90a9

26 files changed

Lines changed: 1428 additions & 88 deletions

.importlinter

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ source_modules =
1010
aai_cli.agent_exec
1111
aai_cli.argscan
1212
aai_cli.auth
13+
aai_cli.caption_exec
1314
aai_cli.client
1415
aai_cli.clip_exec
1516
aai_cli.clip_select
@@ -66,6 +67,7 @@ modules =
6667
aai_cli.commands.account
6768
aai_cli.commands.agent
6869
aai_cli.commands.audit
70+
aai_cli.commands.caption
6971
aai_cli.commands.clip
7072
aai_cli.commands.deploy
7173
aai_cli.commands.dev

aai_cli/caption_exec.py

Lines changed: 238 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,238 @@
1+
"""Run logic for `assembly caption`: transcribe → SRT export → ffmpeg burn-in.
2+
3+
The command module (aai_cli/commands/caption.py) only parses argv — it builds a
4+
``CaptionOptions`` and hands it to ``run_caption`` via ``context.run_command``
5+
(the options/run split, see AGENTS.md), so tests drive the whole pipeline by
6+
constructing options directly.
7+
8+
The pipeline: the video is transcribed (or an existing transcript is reused via
9+
``--transcript-id``), the transcript's SRT captions are fetched from the export
10+
endpoint, and ffmpeg's ``subtitles`` filter burns them into the picture (open
11+
captions, always visible) while the audio stream is copied untouched. A
12+
YouTube/media-page URL is downloaded first — always the full video, since the
13+
captions are burned into it.
14+
"""
15+
16+
from __future__ import annotations
17+
18+
import shutil
19+
import subprocess
20+
import tempfile
21+
from dataclasses import dataclass
22+
from pathlib import Path
23+
24+
import assemblyai as aai
25+
from rich.markup import escape
26+
27+
from aai_cli import client, output, youtube
28+
from aai_cli.context import AppState
29+
from aai_cli.errors import CLIError, UsageError
30+
31+
32+
@dataclass(frozen=True)
33+
class CaptionOptions:
34+
"""Every `assembly caption` flag as plain data (``--json`` excluded:
35+
run_command resolves it into the ``json_mode`` argument)."""
36+
37+
# The raw source as typed: a local path, or a downloadable media-page URL
38+
# (a pathlib.Path would collapse the "//" in "https://").
39+
media: str
40+
transcript_id: str | None
41+
chars_per_caption: int | None
42+
font_size: int | None
43+
out: Path | None
44+
45+
46+
def default_out_path(media: Path) -> Path:
47+
"""The default output file: ``<stem>.captioned<ext>`` next to the input."""
48+
return media.parent / f"{media.stem}.captioned{media.suffix}"
49+
50+
51+
# ffmpeg's filtergraph syntax gives these characters meaning (option/filter/chain
52+
# separators, stream labels, quoting), so a path embedded in `-vf subtitles=…`
53+
# must escape them or a TMPDIR containing one would corrupt the filter spec.
54+
_FILTER_ESCAPES = str.maketrans({ch: f"\\{ch}" for ch in "\\':,;[]"})
55+
56+
57+
def subtitles_filter(srt: Path, font_size: int | None) -> str:
58+
"""The ``-vf`` filtergraph burning ``srt`` into the video."""
59+
spec = f"subtitles={str(srt).translate(_FILTER_ESCAPES)}"
60+
if font_size is not None:
61+
spec += f":force_style=FontSize={font_size}"
62+
return spec
63+
64+
65+
def _validate_media(media: Path) -> None:
66+
"""Reject a missing local source before credential resolution, so a typo'd
67+
path reads as "file not found", never as a login prompt or an ffmpeg error."""
68+
if not media.exists():
69+
raise CLIError(
70+
f"File not found: {media}",
71+
error_type="file_not_found",
72+
exit_code=2,
73+
suggestion="Check the path. assembly caption needs a local video file.",
74+
)
75+
if not media.is_file():
76+
raise CLIError(
77+
f"Not a file: {media}",
78+
error_type="not_a_file",
79+
exit_code=2,
80+
suggestion="Pass a video file, not a directory.",
81+
)
82+
83+
84+
def _validate_out(out: Path, media: Path) -> None:
85+
"""The captioned file must never overwrite its own input: ffmpeg would read
86+
and write the same file concurrently, corrupting it."""
87+
if out.resolve() == media.resolve():
88+
raise UsageError(
89+
"--out would overwrite the input file.",
90+
suggestion="Pick a different output path.",
91+
)
92+
93+
94+
def _require_ffmpeg() -> str:
95+
"""The ffmpeg executable; checked before any (billed) transcription work."""
96+
path = shutil.which("ffmpeg")
97+
if path is None:
98+
raise CLIError(
99+
"ffmpeg is required to burn captions into video, but it isn't on PATH.",
100+
error_type="missing_dependency",
101+
suggestion="Install it (brew install ffmpeg / apt install ffmpeg) and re-run.",
102+
)
103+
return path
104+
105+
106+
def _run_ffmpeg(args: list[str]) -> subprocess.CompletedProcess[str]:
107+
"""Boundary seam for tests: one ffmpeg invocation, output captured."""
108+
return subprocess.run(args, capture_output=True, text=True, check=False)
109+
110+
111+
def _burn(ffmpeg: str, media: Path, srt: Path, out: Path, font_size: int | None) -> None:
112+
"""Burn the ``srt`` captions into ``media``'s video stream, writing ``out``.
113+
114+
The video is necessarily re-encoded (the captions become pixels); ``-c:a
115+
copy`` carries the audio over untouched. The explicit ``-map 0:v`` makes
116+
audio-only input an ffmpeg error ("matches no streams") instead of a silent
117+
uncaptioned copy; ``-map 0:a?`` keeps a silent video legal. ``-y`` makes a
118+
re-run overwrite its own earlier output instead of stalling on ffmpeg's
119+
prompt.
120+
"""
121+
result = _run_ffmpeg(
122+
[
123+
ffmpeg,
124+
"-hide_banner",
125+
"-loglevel",
126+
"error",
127+
"-y",
128+
"-i",
129+
str(media),
130+
"-vf",
131+
subtitles_filter(srt, font_size),
132+
"-map",
133+
"0:v",
134+
"-map",
135+
"0:a?",
136+
"-c:a",
137+
"copy",
138+
str(out),
139+
]
140+
)
141+
if result.returncode != 0:
142+
detail = result.stderr.strip().splitlines()
143+
reason = detail[-1] if detail else f"ffmpeg exited with code {result.returncode}"
144+
raise CLIError(
145+
f"Could not write {out.name}: {reason}",
146+
error_type="caption_failed",
147+
suggestion="Check that the input is a readable video file — captions "
148+
"can't be burned into audio-only media.",
149+
)
150+
151+
152+
def _resolve_transcript(
153+
opts: CaptionOptions, media: Path, state: AppState, *, json_mode: bool
154+
) -> object:
155+
"""The transcript whose captions are burned in: fetched by id, or made fresh
156+
from the (already local) media file."""
157+
if opts.transcript_id is not None:
158+
return client.get_transcript(state.resolve_api_key(), opts.transcript_id)
159+
api_key = state.resolve_api_key()
160+
with output.status("Transcribing for captions…", json_mode=json_mode, quiet=state.quiet):
161+
return client.transcribe(api_key, str(media), config=aai.TranscriptionConfig())
162+
163+
164+
def _fetch_srt(transcript: object, opts: CaptionOptions, *, json_mode: bool, quiet: bool) -> str:
165+
"""The transcript's SRT captions from the export endpoint; empty is an error."""
166+
with output.status("Fetching captions…", json_mode=json_mode, quiet=quiet):
167+
srt = client.select_transcript_field(
168+
transcript, "srt", chars_per_caption=opts.chars_per_caption
169+
)
170+
if not srt.strip():
171+
transcript_id = str(getattr(transcript, "id", ""))
172+
raise CLIError(
173+
f"Transcript {transcript_id} has no captions to burn in.",
174+
error_type="no_captions",
175+
exit_code=2,
176+
suggestion="The media may contain no speech; check it with "
177+
"'assembly transcribe <file>'.",
178+
)
179+
return srt
180+
181+
182+
def run_caption(opts: CaptionOptions, state: AppState, *, json_mode: bool) -> None:
183+
"""Execute one `assembly caption` invocation from already-parsed flags."""
184+
ffmpeg = _require_ffmpeg()
185+
if youtube.is_downloadable_url(opts.media):
186+
# A media-page URL (YouTube, …) is downloaded once — always the full
187+
# video, since the captions are burned into it. The download dir is
188+
# temporary, so the default output lands in the current directory.
189+
with tempfile.TemporaryDirectory(prefix="aai-caption-src-") as td:
190+
with output.status("Downloading video…", json_mode=json_mode, quiet=state.quiet):
191+
local = youtube.download_media(opts.media, Path(td), video=True)
192+
out = opts.out if opts.out is not None else Path.cwd() / default_out_path(local).name
193+
_validate_out(out, local)
194+
_caption_and_emit(opts, local, out, ffmpeg, state, json_mode=json_mode)
195+
return
196+
if opts.media.startswith(("http://", "https://")):
197+
raise UsageError(
198+
"assembly caption can't fetch this URL; it captions a local file or a "
199+
"media-page URL yt-dlp can download (YouTube, …).",
200+
suggestion="Download the video first, then caption the local copy.",
201+
)
202+
media = Path(opts.media)
203+
_validate_media(media)
204+
out = opts.out if opts.out is not None else default_out_path(media)
205+
_validate_out(out, media)
206+
_caption_and_emit(opts, media, out, ffmpeg, state, json_mode=json_mode)
207+
208+
209+
def _caption_and_emit(
210+
opts: CaptionOptions,
211+
media: Path,
212+
out: Path,
213+
ffmpeg: str,
214+
state: AppState,
215+
*,
216+
json_mode: bool,
217+
) -> None:
218+
"""Caption an already-local video file into ``out`` and report the result."""
219+
transcript = _resolve_transcript(opts, media, state, json_mode=json_mode)
220+
transcript_id = str(getattr(transcript, "id", ""))
221+
srt = _fetch_srt(transcript, opts, json_mode=json_mode, quiet=state.quiet)
222+
captions = srt.count("-->") # one arrow per SRT cue timing line
223+
with tempfile.TemporaryDirectory(prefix="aai-caption-") as tmp:
224+
srt_path = Path(tmp) / "captions.srt"
225+
srt_path.write_text(srt, encoding="utf-8")
226+
with output.status("Burning captions…", json_mode=json_mode, quiet=state.quiet):
227+
_burn(ffmpeg, media, srt_path, out, opts.font_size)
228+
payload: dict[str, object] = {
229+
"source": opts.media,
230+
"out": str(out),
231+
"transcript_id": transcript_id,
232+
"captions": captions,
233+
}
234+
output.emit(
235+
payload,
236+
lambda _: output.success(f"{escape(str(out))} {captions} caption(s) burned in"),
237+
json_mode=json_mode,
238+
)

aai_cli/clip_exec.py

Lines changed: 10 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,7 @@ class ClipOptions:
5454
padding: float
5555
snap: bool
5656
out_dir: Path | None
57+
video: bool
5758

5859

5960
def _llm_segments(
@@ -347,15 +348,19 @@ def run_clip(opts: ClipOptions, state: AppState, *, json_mode: bool) -> None:
347348
"""Execute one `assembly clip` invocation from already-parsed flags."""
348349
_validate_out_dir(opts.out_dir)
349350
_validate_selection(opts)
351+
youtube.validate_video_flag(opts.media, video=opts.video)
350352
explicit = [clip_select.parse_range(value) for value in opts.ranges]
351353
ffmpeg = _require_ffmpeg()
352354
if youtube.is_downloadable_url(opts.media):
353-
# A media-page URL (YouTube, podcast page, …) is downloaded once and
354-
# clipped locally. The download dir is temporary, so the clips land in
355-
# --out-dir or the current directory — never next to the temp file.
355+
# A media-page URL (YouTube, podcast page, …) is downloaded once — the
356+
# audio track by default, the full video with --video so the clips carry
357+
# video too — and clipped locally. The download dir is temporary, so the
358+
# clips land in --out-dir or the current directory — never next to the
359+
# temp file.
360+
downloading = "Downloading video…" if opts.video else "Downloading audio…"
356361
with tempfile.TemporaryDirectory(prefix="aai-clip-") as td:
357-
with output.status("Downloading audio…", json_mode=json_mode, quiet=state.quiet):
358-
local = youtube.download_audio(opts.media, Path(td))
362+
with output.status(downloading, json_mode=json_mode, quiet=state.quiet):
363+
local = youtube.download_media(opts.media, Path(td), video=opts.video)
359364
out_dir = opts.out_dir if opts.out_dir is not None else Path.cwd()
360365
_cut_and_emit(opts, local, out_dir, explicit, ffmpeg, state, json_mode=json_mode)
361366
return

aai_cli/commands/caption.py

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
from __future__ import annotations
2+
3+
from pathlib import Path
4+
5+
import typer
6+
7+
from aai_cli import caption_exec, help_panels, options
8+
from aai_cli.context import run_command
9+
from aai_cli.help_text import examples_epilog
10+
11+
app = typer.Typer()
12+
13+
14+
@app.command(
15+
rich_help_panel=help_panels.TRANSCRIPTION,
16+
epilog=examples_epilog(
17+
[
18+
("Burn captions into a video", "assembly caption talk.mp4"),
19+
(
20+
"Caption a YouTube video (downloaded via yt-dlp)",
21+
'assembly caption "https://youtube.com/watch?v=ID"',
22+
),
23+
(
24+
"Reuse a finished transcript instead of re-transcribing",
25+
"assembly caption talk.mp4 -t TRANSCRIPT_ID",
26+
),
27+
(
28+
"Shorter caption lines in a bigger font",
29+
"assembly caption talk.mp4 --chars-per-caption 32 --font-size 28",
30+
),
31+
("Choose the output file", "assembly caption talk.mp4 --out talk-captioned.mp4"),
32+
]
33+
),
34+
)
35+
def caption(
36+
ctx: typer.Context,
37+
media: str = typer.Argument(
38+
...,
39+
help="Video to caption: a local file, or a YouTube/media-page URL "
40+
"(the full video is downloaded via yt-dlp).",
41+
),
42+
transcript_id: str | None = typer.Option(
43+
None,
44+
"--transcript-id",
45+
"-t",
46+
help="Reuse an existing transcript of this media instead of transcribing it again.",
47+
),
48+
chars_per_caption: int | None = typer.Option(
49+
None,
50+
"--chars-per-caption",
51+
min=1,
52+
help="Max characters per caption line.",
53+
),
54+
font_size: int | None = typer.Option(
55+
None,
56+
"--font-size",
57+
min=1,
58+
help="Font size of the burned-in captions (ffmpeg's default styling when omitted).",
59+
),
60+
out: Path | None = typer.Option(
61+
None, "--out", help="Output file (default: <name>.captioned<ext> next to the input)."
62+
),
63+
json_out: bool = options.json_option("Emit JSON describing the captioned file."),
64+
) -> None:
65+
"""Burn always-visible captions into a video.
66+
67+
The video is transcribed (or an existing transcript is reused with
68+
--transcript-id), the transcript's SRT captions are fetched, and ffmpeg
69+
(which must be installed) burns them into the picture as open captions —
70+
the audio stream is copied untouched. A YouTube/media-page URL is
71+
downloaded first (always the full video); its output lands in --out or
72+
the current directory.
73+
"""
74+
opts = caption_exec.CaptionOptions(
75+
media=media,
76+
transcript_id=transcript_id,
77+
chars_per_caption=chars_per_caption,
78+
font_size=font_size,
79+
out=out,
80+
)
81+
run_command(
82+
ctx,
83+
lambda state, json_mode: caption_exec.run_caption(opts, state, json_mode=json_mode),
84+
json=json_out,
85+
)

0 commit comments

Comments
 (0)