vidfix 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
vidfix/__init__.py ADDED
@@ -0,0 +1,38 @@
1
+ """vidfix: test-media toolkit for QA engineers."""
2
+
3
+ from vidfix.core.caption import caption
4
+ from vidfix.core.convert import ConvertPlan, convert
5
+ from vidfix.core.formats import to_format
6
+ from vidfix.core.generate import generate
7
+ from vidfix.core.probe import AudioInfo, MediaInfo, probe
8
+ from vidfix.core.verify import PropertyCheck, VerifyResult, verify
9
+ from vidfix.exceptions import (
10
+ ConversionError,
11
+ FFmpegNotFoundError,
12
+ InvalidSpecError,
13
+ PresetError,
14
+ ProbeError,
15
+ VidfixError,
16
+ )
17
+
18
+ __version__ = "0.1.0"
19
+
20
+ __all__ = [
21
+ "AudioInfo",
22
+ "ConversionError",
23
+ "ConvertPlan",
24
+ "FFmpegNotFoundError",
25
+ "InvalidSpecError",
26
+ "MediaInfo",
27
+ "PresetError",
28
+ "ProbeError",
29
+ "PropertyCheck",
30
+ "VerifyResult",
31
+ "VidfixError",
32
+ "caption",
33
+ "convert",
34
+ "generate",
35
+ "probe",
36
+ "to_format",
37
+ "verify",
38
+ ]
vidfix/cli.py ADDED
@@ -0,0 +1,513 @@
1
+ """vidfix command-line interface."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+ from typing import Annotated
7
+
8
+ import typer
9
+ from rich.console import Console
10
+ from rich.table import Table
11
+
12
+ from vidfix.core import caption as caption_mod
13
+ from vidfix.core import convert as convert_mod
14
+ from vidfix.core import formats as formats_mod
15
+ from vidfix.core import generate as generate_mod
16
+ from vidfix.core import matrix as matrix_mod
17
+ from vidfix.core import presets as presets_mod
18
+ from vidfix.core import probe as probe_mod
19
+ from vidfix.core import verify as verify_mod
20
+ from vidfix.core.duration import format_seconds, parse_duration, parse_fps, parse_resolution
21
+ from vidfix.core.ffmpeg import ProgressCallback, ProgressEvent
22
+ from vidfix.exceptions import VidfixError
23
+
24
+ app = typer.Typer(
25
+ name="vidfix",
26
+ help=(
27
+ "Media toolkit: convert, generate, and verify videos and images to exact specs. "
28
+ "Run with no arguments for an interactive wizard."
29
+ ),
30
+ rich_markup_mode="rich",
31
+ )
32
+ console = Console()
33
+ err_console = Console(stderr=True)
34
+
35
+
36
+ @app.callback(invoke_without_command=True)
37
+ def main(ctx: typer.Context) -> None:
38
+ """vidfix — exact-spec media, no syntax required (just run `vidfix`)."""
39
+ if ctx.invoked_subcommand is None:
40
+ from vidfix.interactive import wizard
41
+
42
+ app(wizard())
43
+
44
+
45
+ def _fail(error: Exception) -> None:
46
+ err_console.print(f"[red]error:[/red] {error}")
47
+ raise typer.Exit(code=1)
48
+
49
+
50
+ class _ProgressBar:
51
+ """Rich progress bar driven by FFmpeg ``-progress`` events."""
52
+
53
+ def __init__(self, label: str, total_seconds: float) -> None:
54
+ from rich.progress import BarColumn, Progress, TextColumn, TimeRemainingColumn
55
+
56
+ self._progress = Progress(
57
+ TextColumn("[bold blue]{task.description}"),
58
+ BarColumn(),
59
+ TextColumn("{task.percentage:>3.0f}%"),
60
+ TimeRemainingColumn(),
61
+ console=console,
62
+ )
63
+ self._task = self._progress.add_task(label, total=total_seconds)
64
+
65
+ def __enter__(self) -> ProgressCallback:
66
+ self._progress.start()
67
+
68
+ def on_progress(event: ProgressEvent) -> None:
69
+ self._progress.update(self._task, completed=event.seconds)
70
+ if event.done:
71
+ self._progress.update(self._task, completed=self._progress.tasks[0].total or 0)
72
+
73
+ return on_progress
74
+
75
+ def __exit__(self, *exc: object) -> None:
76
+ self._progress.stop()
77
+
78
+
79
+ @app.command(
80
+ epilog=(
81
+ "Examples:\n\n"
82
+ " vidfix generate -o bars.mp4 --fps 60 --duration 30s --res 720p\n\n"
83
+ " vidfix generate -o df.mp4 --fps 29.97 --pattern testsrc --timecode\n\n"
84
+ " vidfix generate -o red.mp4 --pattern solid:red --audio none"
85
+ )
86
+ )
87
+ def generate(
88
+ output: Annotated[Path, typer.Option("-o", "--output", help="Output file path.")],
89
+ fps: Annotated[
90
+ str | None, typer.Option(help="Frame rate: 30, 59.94, or 30000/1001. Default 30.")
91
+ ] = None,
92
+ duration: Annotated[
93
+ str | None, typer.Option(help="Duration: '30s', '1:30', or '90'. Default 5s.")
94
+ ] = None,
95
+ res: Annotated[
96
+ str | None, typer.Option(help="Resolution: '1280x720', '720p', '4k'. Default 1280x720.")
97
+ ] = None,
98
+ pattern: Annotated[
99
+ str,
100
+ typer.Option(help="Test pattern: smpte, color-bars, testsrc, gradient, or solid:COLOR."),
101
+ ] = "smpte",
102
+ codec: Annotated[
103
+ str | None, typer.Option(help="Video codec: h264, h265, prores, vp9. Default h264.")
104
+ ] = None,
105
+ audio: Annotated[str, typer.Option(help="Audio track: tone (440Hz), silence, none.")] = "tone",
106
+ timecode: Annotated[
107
+ bool,
108
+ typer.Option(
109
+ "--timecode",
110
+ help="Burn in running timecode (HH:MM:SS:FF; drop-frame ';FF' for NTSC 29.97/59.94).",
111
+ ),
112
+ ] = False,
113
+ text: Annotated[
114
+ str | None, typer.Option("--text", help="Burn a caption into the video.")
115
+ ] = None,
116
+ preset: Annotated[
117
+ str | None, typer.Option(help="Preset name for defaults ('vidfix preset list').")
118
+ ] = None,
119
+ ) -> None:
120
+ """Create a synthetic test video with exact specs — no source file needed."""
121
+ try:
122
+ merged = presets_mod.apply_preset(preset, fps=fps, duration=duration, res=res, codec=codec)
123
+ total = parse_duration(merged.get("duration") or "5s")
124
+ drop_frame = {"df": True, "ndf": False}.get(merged.get("timecode") or "")
125
+ with _ProgressBar(f"generate {output.name}", total) as on_progress:
126
+ generate_mod.generate(
127
+ output,
128
+ fps=merged.get("fps") or "30",
129
+ duration=merged.get("duration") or "5s",
130
+ res=merged.get("res") or "1280x720",
131
+ pattern=pattern,
132
+ codec=merged.get("codec") or "h264",
133
+ audio=audio,
134
+ timecode=timecode,
135
+ drop_frame=drop_frame,
136
+ text=text,
137
+ on_progress=on_progress,
138
+ )
139
+ except VidfixError as exc:
140
+ _fail(exc)
141
+ return
142
+ console.print(f"[green]✓[/green] wrote {output}")
143
+
144
+
145
+ @app.command(
146
+ epilog=(
147
+ "Examples:\n\n"
148
+ ' vidfix caption in.mp4 --text "Take 42" -o out.mp4\n\n'
149
+ ' vidfix caption in.mp4 --text "INTRO" --position top --color yellow -o out.mp4\n\n'
150
+ ' vidfix caption in.mp4 --text "3..2..1" --start 0 --end 3 -o out.mp4'
151
+ )
152
+ )
153
+ def caption(
154
+ input: Annotated[Path, typer.Argument(help="Source video file.")],
155
+ output: Annotated[Path, typer.Option("-o", "--output", help="Output file path.")],
156
+ text: Annotated[str, typer.Option("--text", help="Caption text to burn in.")],
157
+ position: Annotated[
158
+ str, typer.Option(help="Caption placement: top, center, bottom.")
159
+ ] = "bottom",
160
+ size: Annotated[
161
+ str, typer.Option(help="Font size (pixels or expression like 'h/12').")
162
+ ] = "h/12",
163
+ color: Annotated[str, typer.Option(help="Font color, e.g. white, yellow, #ff0000.")] = "white",
164
+ start: Annotated[
165
+ float | None, typer.Option(help="Show caption from this second onward.")
166
+ ] = None,
167
+ end: Annotated[float | None, typer.Option(help="Hide caption after this second.")] = None,
168
+ ) -> None:
169
+ """Burn a text caption into a video (audio untouched)."""
170
+ try:
171
+ total = probe_mod.probe(input).duration
172
+ with _ProgressBar(f"caption {output.name}", total) as on_progress:
173
+ caption_mod.caption(
174
+ input,
175
+ output,
176
+ text,
177
+ position=position,
178
+ size=size,
179
+ color=color,
180
+ start=start,
181
+ end=end,
182
+ on_progress=on_progress,
183
+ )
184
+ except VidfixError as exc:
185
+ _fail(exc)
186
+ return
187
+ console.print(f"[green]✓[/green] wrote {output}")
188
+
189
+
190
+ @app.command(
191
+ epilog=(
192
+ "Examples:\n\n"
193
+ " vidfix convert in.mp4 --fps 60 --duration 30s --res 1280x720 -o out.mp4\n\n"
194
+ " vidfix convert in.mp4 --preset df60 -o out.mp4 (preset defaults)\n\n"
195
+ " vidfix convert in.mp4 --fps 60 --smooth -o out.mp4 (motion-interpolated 30→60)\n\n"
196
+ " vidfix convert in.mp4 --duration 10s -o out.mp4 (stream-copy trim, instant)\n\n"
197
+ " vidfix convert in.mp4 --duration 60s --extend-mode loop -o out.mp4"
198
+ )
199
+ )
200
+ def convert(
201
+ input: Annotated[Path, typer.Argument(help="Source video file.")],
202
+ output: Annotated[Path, typer.Option("-o", "--output", help="Output file path.")],
203
+ fps: Annotated[
204
+ str | None, typer.Option(help="Target frame rate: 30, 59.94, or 30000/1001.")
205
+ ] = None,
206
+ duration: Annotated[
207
+ str | None,
208
+ typer.Option(help="Target duration: '30s', '1:30', or '90'. Trims or extends."),
209
+ ] = None,
210
+ res: Annotated[
211
+ str | None, typer.Option(help="Target resolution: '1280x720', '720p', '4k'.")
212
+ ] = None,
213
+ codec: Annotated[
214
+ str | None, typer.Option(help="Video codec: h264, h265, prores, vp9. Default h264.")
215
+ ] = None,
216
+ preset: Annotated[
217
+ str | None, typer.Option(help="Preset name for defaults ('vidfix preset list').")
218
+ ] = None,
219
+ smooth: Annotated[
220
+ bool, typer.Option("--smooth", help="Motion-interpolate fps changes (minterpolate).")
221
+ ] = False,
222
+ extend_mode: Annotated[
223
+ str,
224
+ typer.Option(help="When target duration > source: 'freeze' last frame or 'loop'."),
225
+ ] = "freeze",
226
+ stretch: Annotated[
227
+ bool, typer.Option("--stretch", help="Stretch to target resolution (no pad bars).")
228
+ ] = False,
229
+ no_audio: Annotated[bool, typer.Option("--no-audio", help="Drop the audio track.")] = False,
230
+ audio_tone: Annotated[
231
+ bool, typer.Option("--audio-tone", help="Replace audio with a 440Hz test tone.")
232
+ ] = False,
233
+ precise: Annotated[
234
+ bool, typer.Option("--precise", help="Force re-encode for frame-accurate trims.")
235
+ ] = False,
236
+ ) -> None:
237
+ """Transform an existing video to exact specs (stream-copies when possible)."""
238
+ try:
239
+ merged = presets_mod.apply_preset(preset, fps=fps, duration=duration, res=res, codec=codec)
240
+ target_duration = merged.get("duration")
241
+ label = f"convert {output.name}"
242
+ total = (
243
+ parse_duration(target_duration) if target_duration else probe_mod.probe(input).duration
244
+ )
245
+ with _ProgressBar(label, total) as on_progress:
246
+ plan = convert_mod.convert(
247
+ input,
248
+ output,
249
+ fps=merged.get("fps"),
250
+ duration=target_duration,
251
+ res=merged.get("res"),
252
+ codec=merged.get("codec") or "h264",
253
+ smooth=smooth,
254
+ extend_mode=extend_mode,
255
+ stretch=stretch,
256
+ no_audio=no_audio,
257
+ audio_tone=audio_tone,
258
+ precise=precise,
259
+ on_progress=on_progress,
260
+ )
261
+ except VidfixError as exc:
262
+ _fail(exc)
263
+ return
264
+ for warning in plan.warnings:
265
+ err_console.print(f"[yellow]warning:[/yellow] {warning}")
266
+ mode = "stream copy" if plan.stream_copy else "re-encode"
267
+ console.print(f"[green]✓[/green] wrote {output} ({mode})")
268
+
269
+
270
+ @app.command(
271
+ name="format",
272
+ epilog=(
273
+ "Examples:\n\n"
274
+ " vidfix format clip.mov -o clip.mp4 (any container → any container)\n\n"
275
+ " vidfix format clip.mp4 -o clip.gif (palette-optimized gif)\n\n"
276
+ " vidfix format photo.png -o photo.webp (image → image)\n\n"
277
+ " vidfix format clip.mp4 -o thumb.jpg (first-frame grab)"
278
+ ),
279
+ )
280
+ def format_cmd(
281
+ input: Annotated[Path, typer.Argument(help="Source picture or video.")],
282
+ output: Annotated[
283
+ Path, typer.Option("-o", "--output", help="Output path; extension picks the format.")
284
+ ],
285
+ ) -> None:
286
+ """Convert any picture or video to any other format (by output extension)."""
287
+ try:
288
+ if (
289
+ formats_mod.media_kind(str(input)) == "video"
290
+ and formats_mod.media_kind(str(output)) == "video"
291
+ ):
292
+ total = probe_mod.probe(input).duration
293
+ with _ProgressBar(f"format {output.name}", total) as on_progress:
294
+ formats_mod.to_format(input, output, on_progress=on_progress)
295
+ else:
296
+ formats_mod.to_format(input, output)
297
+ except VidfixError as exc:
298
+ _fail(exc)
299
+ return
300
+ console.print(f"[green]✓[/green] wrote {output}")
301
+
302
+
303
+ @app.command(
304
+ epilog=(
305
+ "Exit code 0 when all checks pass, 1 otherwise — wire it straight into CI.\n\n"
306
+ "Examples:\n\n"
307
+ " vidfix verify out.mp4 --fps 60 --duration 30s --res 1280x720\n\n"
308
+ " vidfix verify out.mp4 --fps 29.97 --codec h264 --json"
309
+ )
310
+ )
311
+ def verify(
312
+ input: Annotated[Path, typer.Argument(help="Media file to verify.")],
313
+ fps: Annotated[
314
+ str | None, typer.Option(help="Expected frame rate: 30, 59.94, or 30000/1001.")
315
+ ] = None,
316
+ duration: Annotated[
317
+ str | None, typer.Option(help="Expected duration: '30s', '1:30', or '90'.")
318
+ ] = None,
319
+ res: Annotated[
320
+ str | None, typer.Option(help="Expected resolution: '1280x720', '720p', '4k'.")
321
+ ] = None,
322
+ codec: Annotated[
323
+ str | None, typer.Option(help="Expected video codec: h264, h265, prores, vp9.")
324
+ ] = None,
325
+ fps_tolerance: Annotated[
326
+ float, typer.Option(help="Allowed fps deviation.")
327
+ ] = verify_mod.DEFAULT_FPS_TOLERANCE,
328
+ duration_tolerance: Annotated[
329
+ float, typer.Option(help="Allowed duration deviation in seconds.")
330
+ ] = verify_mod.DEFAULT_DURATION_TOLERANCE,
331
+ json_out: Annotated[
332
+ bool, typer.Option("--json", help="Emit machine-readable JSON instead of a table.")
333
+ ] = False,
334
+ ) -> None:
335
+ """Assert a file matches specs; exit 0 on pass, 1 on fail (designed for CI)."""
336
+ if fps is None and duration is None and res is None and codec is None:
337
+ _fail(ValueError("Nothing to verify: pass at least one of --fps/--duration/--res/--codec."))
338
+ return
339
+ try:
340
+ result = verify_mod.verify(
341
+ input,
342
+ fps=parse_fps(fps) if fps else None,
343
+ duration=parse_duration(duration) if duration else None,
344
+ res=parse_resolution(res) if res else None,
345
+ codec=codec,
346
+ fps_tolerance=fps_tolerance,
347
+ duration_tolerance=duration_tolerance,
348
+ )
349
+ except VidfixError as exc:
350
+ _fail(exc)
351
+ return
352
+
353
+ if json_out:
354
+ console.print_json(result.model_dump_json())
355
+ else:
356
+ table = Table(title=str(input))
357
+ table.add_column("property", style="bold cyan")
358
+ table.add_column("expected")
359
+ table.add_column("actual")
360
+ table.add_column("result")
361
+ for check in result.checks:
362
+ mark = "[green]PASS[/green]" if check.passed else "[red]FAIL[/red]"
363
+ table.add_row(check.name, check.expected, check.actual, mark)
364
+ console.print(table)
365
+ raise typer.Exit(code=0 if result.passed else 1)
366
+
367
+
368
+ @app.command(
369
+ name="info",
370
+ epilog="Examples:\n\n vidfix info clip.mp4\n\n vidfix info clip.mp4 --json | jq .fps",
371
+ )
372
+ def probe(
373
+ input: Annotated[Path, typer.Argument(help="Media file to inspect.")],
374
+ json_out: Annotated[
375
+ bool, typer.Option("--json", help="Emit machine-readable JSON (jq-friendly).")
376
+ ] = False,
377
+ ) -> None:
378
+ """Pretty-print media metadata: container, duration, fps, resolution, codecs."""
379
+ try:
380
+ info = probe_mod.probe(input)
381
+ except VidfixError as exc:
382
+ _fail(exc)
383
+ return
384
+
385
+ if json_out:
386
+ console.print_json(info.model_dump_json())
387
+ return
388
+
389
+ table = Table(title=str(input), show_header=False)
390
+ table.add_column(style="bold cyan")
391
+ table.add_column()
392
+ table.add_row("container", info.container)
393
+ table.add_row("duration", f"{format_seconds(info.duration)} ({info.duration:.3f}s)")
394
+ table.add_row("resolution", info.resolution)
395
+ table.add_row("fps", info.fps_display)
396
+ table.add_row("video codec", info.video_codec)
397
+ table.add_row("pixel format", info.pix_fmt or "-")
398
+ table.add_row("bitrate", f"{info.bitrate // 1000} kb/s" if info.bitrate else "-")
399
+ if info.audio:
400
+ rate = f"{info.audio.sample_rate} Hz" if info.audio.sample_rate else "?"
401
+ channels = f"{info.audio.channels}ch" if info.audio.channels else "?"
402
+ table.add_row("audio", f"{info.audio.codec} {rate} {channels}")
403
+ else:
404
+ table.add_row("audio", "none")
405
+ console.print(table)
406
+
407
+
408
+ app.command(name="probe", hidden=True)(probe) # pre-rename alias
409
+
410
+
411
+ @app.command(
412
+ name="variants",
413
+ epilog=(
414
+ "Examples:\n\n"
415
+ " vidfix variants in.mp4 --fps 30,60 --res 720p,1080p -o variants/\n\n"
416
+ " vidfix variants in.mp4 --fps 29.97,59.94 --res 480p -o out/ --jobs 8"
417
+ ),
418
+ )
419
+ def matrix(
420
+ input: Annotated[Path, typer.Argument(help="Source video file.")],
421
+ outdir: Annotated[Path, typer.Option("-o", "--output", help="Output directory.")],
422
+ fps: Annotated[str, typer.Option(help="Comma-separated frame rates, e.g. '30,60'.")],
423
+ res: Annotated[str, typer.Option(help="Comma-separated resolutions, e.g. '720p,1080p'.")],
424
+ codec: Annotated[str, typer.Option(help="Video codec for all variants.")] = "h264",
425
+ jobs: Annotated[
426
+ int | None, typer.Option(help="Parallel FFmpeg processes (default: min(4, cpus)).")
427
+ ] = None,
428
+ ) -> None:
429
+ """Generate the cartesian product of fps x resolution variants."""
430
+ from rich.progress import BarColumn, Progress, TextColumn
431
+
432
+ try:
433
+ fps_list = matrix_mod.parse_list(fps, "fps")
434
+ res_list = matrix_mod.parse_list(res, "resolution")
435
+ total = len(fps_list) * len(res_list)
436
+ progress = Progress(
437
+ TextColumn("[bold blue]matrix"),
438
+ BarColumn(),
439
+ TextColumn("{task.completed}/{task.total}"),
440
+ console=console,
441
+ )
442
+ with progress:
443
+ task = progress.add_task("matrix", total=total)
444
+ results = matrix_mod.run_matrix(
445
+ input,
446
+ outdir,
447
+ fps_list,
448
+ res_list,
449
+ codec=codec,
450
+ jobs=jobs,
451
+ on_result=lambda _: progress.advance(task),
452
+ )
453
+ except VidfixError as exc:
454
+ _fail(exc)
455
+ return
456
+
457
+ table = Table(title=f"{input} → {outdir}")
458
+ table.add_column("fps", style="bold cyan")
459
+ table.add_column("res")
460
+ table.add_column("output")
461
+ table.add_column("result")
462
+ for r in results:
463
+ mark = "[green]OK[/green]" if r.ok else f"[red]FAIL[/red] {r.error}"
464
+ table.add_row(r.job.fps, r.job.res, r.job.output.name, mark)
465
+ console.print(table)
466
+ if not all(r.ok for r in results):
467
+ raise typer.Exit(code=1)
468
+
469
+
470
+ app.command(name="matrix", hidden=True)(matrix) # pre-rename alias
471
+
472
+
473
+ preset_app = typer.Typer(help="Inspect built-in and user presets.", no_args_is_help=True)
474
+ app.add_typer(preset_app, name="preset")
475
+
476
+
477
+ @preset_app.command("list")
478
+ def preset_list() -> None:
479
+ """List all presets (built-in + ~/.config/vidfix/presets.yaml)."""
480
+ try:
481
+ presets = presets_mod.load_presets()
482
+ except VidfixError as exc:
483
+ _fail(exc)
484
+ return
485
+ table = Table()
486
+ table.add_column("name", style="bold cyan")
487
+ table.add_column("description")
488
+ table.add_column("settings")
489
+ for name, settings in sorted(presets.items()):
490
+ description = settings.get("description", "")
491
+ rest = ", ".join(f"{k}={v}" for k, v in settings.items() if k != "description")
492
+ table.add_row(name, description, rest)
493
+ console.print(table)
494
+
495
+
496
+ @preset_app.command("show")
497
+ def preset_show(name: Annotated[str, typer.Argument(help="Preset name.")]) -> None:
498
+ """Show one preset's settings."""
499
+ try:
500
+ settings = presets_mod.get_preset(name)
501
+ except VidfixError as exc:
502
+ _fail(exc)
503
+ return
504
+ table = Table(title=name, show_header=False)
505
+ table.add_column(style="bold cyan")
506
+ table.add_column()
507
+ for key, value in settings.items():
508
+ table.add_row(key, value)
509
+ console.print(table)
510
+
511
+
512
+ if __name__ == "__main__":
513
+ app()
File without changes
vidfix/core/caption.py ADDED
@@ -0,0 +1,96 @@
1
+ """Burn text captions into videos (existing files or freshly generated ones).
2
+
3
+ Caption text goes through drawtext's ``textfile=`` option — writing the text to
4
+ a temp file sidesteps every filter-graph escaping rule for user content.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import tempfile
10
+ from pathlib import Path
11
+
12
+ from vidfix.core.ffmpeg import FFmpegRunner, ProgressCallback, video_codec_args
13
+ from vidfix.core.generate import drawtext_runner, escape_filter_path, find_font
14
+ from vidfix.exceptions import InvalidSpecError
15
+
16
+ #: Caption placement -> drawtext x/y expressions (centered horizontally).
17
+ POSITIONS: dict[str, str] = {
18
+ "top": "x=(w-text_w)/2:y=h/20",
19
+ "center": "x=(w-text_w)/2:y=(h-text_h)/2",
20
+ "bottom": "x=(w-text_w)/2:y=h-text_h-h/20",
21
+ }
22
+
23
+
24
+ def caption_filter(
25
+ textfile: str,
26
+ position: str = "bottom",
27
+ size: str = "h/12",
28
+ color: str = "white",
29
+ font: str | None = None,
30
+ start: float | None = None,
31
+ end: float | None = None,
32
+ ) -> str:
33
+ """Pure builder for a caption drawtext filter."""
34
+ if position not in POSITIONS:
35
+ raise InvalidSpecError(
36
+ f"Unknown position {position!r}; expected one of: {', '.join(POSITIONS)}."
37
+ )
38
+ parts = [f"textfile='{escape_filter_path(textfile)}'"]
39
+ if font:
40
+ parts.append(f"fontfile='{escape_filter_path(font)}'")
41
+ parts += [
42
+ f"fontsize={size}",
43
+ f"fontcolor={color}",
44
+ "box=1",
45
+ "boxcolor=black@0.5",
46
+ "boxborderw=8",
47
+ POSITIONS[position],
48
+ ]
49
+ if start is not None or end is not None:
50
+ lo = start if start is not None else 0
51
+ hi = end if end is not None else 10**9
52
+ parts.append(f"enable='between(t,{lo},{hi})'")
53
+ return "drawtext=" + ":".join(parts)
54
+
55
+
56
+ def write_caption_file(text: str) -> str:
57
+ """Write caption text to a temp file and return its path."""
58
+ if not text.strip():
59
+ raise InvalidSpecError("Caption text is empty.")
60
+ with tempfile.NamedTemporaryFile(
61
+ mode="w", suffix=".txt", prefix="vidfix-caption-", delete=False, encoding="utf-8"
62
+ ) as handle:
63
+ handle.write(text)
64
+ return handle.name
65
+
66
+
67
+ def caption(
68
+ input_path: str | Path,
69
+ output: str | Path,
70
+ text: str,
71
+ position: str = "bottom",
72
+ size: str = "h/12",
73
+ color: str = "white",
74
+ start: float | None = None,
75
+ end: float | None = None,
76
+ runner: FFmpegRunner | None = None,
77
+ on_progress: ProgressCallback | None = None,
78
+ ) -> Path:
79
+ """Burn a caption into an existing video (audio is stream-copied)."""
80
+ runner = drawtext_runner(runner or FFmpegRunner())
81
+ textfile = write_caption_file(text)
82
+ try:
83
+ vf = caption_filter(
84
+ textfile,
85
+ position=position,
86
+ size=size,
87
+ color=color,
88
+ font=find_font(),
89
+ start=start,
90
+ end=end,
91
+ )
92
+ args = ["-i", str(input_path), "-vf", vf, *video_codec_args("h264"), "-c:a", "copy"]
93
+ runner.run([*args, str(output)], on_progress=on_progress)
94
+ finally:
95
+ Path(textfile).unlink(missing_ok=True)
96
+ return Path(output)