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 +38 -0
- vidfix/cli.py +513 -0
- vidfix/core/__init__.py +0 -0
- vidfix/core/caption.py +96 -0
- vidfix/core/convert.py +170 -0
- vidfix/core/duration.py +190 -0
- vidfix/core/ffmpeg.py +238 -0
- vidfix/core/formats.py +71 -0
- vidfix/core/generate.py +224 -0
- vidfix/core/matrix.py +90 -0
- vidfix/core/presets.py +100 -0
- vidfix/core/probe.py +268 -0
- vidfix/core/verify.py +110 -0
- vidfix/data/presets.yaml +45 -0
- vidfix/exceptions.py +44 -0
- vidfix/interactive.py +169 -0
- vidfix/py.typed +0 -0
- vidfix-0.1.0.dist-info/METADATA +331 -0
- vidfix-0.1.0.dist-info/RECORD +22 -0
- vidfix-0.1.0.dist-info/WHEEL +4 -0
- vidfix-0.1.0.dist-info/entry_points.txt +2 -0
- vidfix-0.1.0.dist-info/licenses/LICENSE +57 -0
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()
|
vidfix/core/__init__.py
ADDED
|
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)
|