videopilot 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.
- lib/__init__.py +1 -0
- lib/compose.py +450 -0
- lib/cut.py +121 -0
- lib/doctor.py +98 -0
- lib/export.py +404 -0
- lib/ffmpeg_wrap.py +164 -0
- lib/init_cmd.py +163 -0
- lib/silence.py +96 -0
- lib/transcribe.py +116 -0
- lib/tts.py +172 -0
- lib/voices.py +78 -0
- videopilot-0.1.0.dist-info/METADATA +285 -0
- videopilot-0.1.0.dist-info/RECORD +20 -0
- videopilot-0.1.0.dist-info/WHEEL +5 -0
- videopilot-0.1.0.dist-info/entry_points.txt +3 -0
- videopilot-0.1.0.dist-info/licenses/LICENSE +21 -0
- videopilot-0.1.0.dist-info/top_level.txt +4 -0
- videopilot.py +166 -0
- videopilot_cli.py +20 -0
- videopilot_mcp.py +478 -0
lib/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""video-creator library modules."""
|
lib/compose.py
ADDED
|
@@ -0,0 +1,450 @@
|
|
|
1
|
+
"""`compose` — render final video per compose-plan.json.
|
|
2
|
+
|
|
3
|
+
Pipeline:
|
|
4
|
+
1. Render each timeline item as `tmp/seg_NNN.mp4` at canonical params.
|
|
5
|
+
2. Concatenate intermediates with the ffmpeg concat demuxer.
|
|
6
|
+
3. If background_music is configured, mix it under the concat result.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import json
|
|
12
|
+
import sys
|
|
13
|
+
from dataclasses import dataclass
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
from typing import Any
|
|
16
|
+
|
|
17
|
+
from . import ffmpeg_wrap
|
|
18
|
+
|
|
19
|
+
# Canonical fallback render parameters when compose-plan.json omits them.
|
|
20
|
+
_DEFAULT_RES = (1920, 1080)
|
|
21
|
+
_DEFAULT_FPS = 30
|
|
22
|
+
_DEFAULT_VBITRATE = "8M"
|
|
23
|
+
_DEFAULT_ABITRATE = "192k"
|
|
24
|
+
_DEFAULT_VCODEC = "libx264"
|
|
25
|
+
_DEFAULT_ACODEC = "aac"
|
|
26
|
+
_DEFAULT_SR = 48000
|
|
27
|
+
_DEFAULT_AC = 2
|
|
28
|
+
|
|
29
|
+
_FONT_CANDIDATES = [
|
|
30
|
+
"C:/Windows/Fonts/segoeui.ttf",
|
|
31
|
+
"C:/Windows/Fonts/arial.ttf",
|
|
32
|
+
"C:/Windows/Fonts/calibri.ttf",
|
|
33
|
+
]
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@dataclass
|
|
37
|
+
class RenderParams:
|
|
38
|
+
width: int
|
|
39
|
+
height: int
|
|
40
|
+
fps: int
|
|
41
|
+
vbitrate: str
|
|
42
|
+
abitrate: str
|
|
43
|
+
vcodec: str
|
|
44
|
+
acodec: str
|
|
45
|
+
sr: int
|
|
46
|
+
ac: int
|
|
47
|
+
|
|
48
|
+
@classmethod
|
|
49
|
+
def from_output(cls, out: dict[str, Any]) -> "RenderParams":
|
|
50
|
+
res = out.get("resolution", f"{_DEFAULT_RES[0]}x{_DEFAULT_RES[1]}")
|
|
51
|
+
try:
|
|
52
|
+
w, h = (int(x) for x in res.lower().split("x"))
|
|
53
|
+
except Exception:
|
|
54
|
+
raise SystemExit(f"output.resolution invalid: {res!r}; want e.g. 1920x1080")
|
|
55
|
+
return cls(
|
|
56
|
+
width=w,
|
|
57
|
+
height=h,
|
|
58
|
+
fps=int(out.get("fps", _DEFAULT_FPS)),
|
|
59
|
+
vbitrate=str(out.get("video_bitrate", _DEFAULT_VBITRATE)),
|
|
60
|
+
abitrate=str(out.get("audio_bitrate", _DEFAULT_ABITRATE)),
|
|
61
|
+
vcodec=str(out.get("video_codec", _DEFAULT_VCODEC)),
|
|
62
|
+
acodec=str(out.get("audio_codec", _DEFAULT_ACODEC)),
|
|
63
|
+
sr=int(out.get("sample_rate", _DEFAULT_SR)),
|
|
64
|
+
ac=int(out.get("audio_channels", _DEFAULT_AC)),
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
def video_encode_args(self) -> list[str]:
|
|
68
|
+
return [
|
|
69
|
+
"-c:v", self.vcodec,
|
|
70
|
+
"-preset", "medium",
|
|
71
|
+
"-crf", "20",
|
|
72
|
+
"-b:v", self.vbitrate,
|
|
73
|
+
"-pix_fmt", "yuv420p",
|
|
74
|
+
"-r", str(self.fps),
|
|
75
|
+
]
|
|
76
|
+
|
|
77
|
+
def audio_encode_args(self) -> list[str]:
|
|
78
|
+
return [
|
|
79
|
+
"-c:a", self.acodec,
|
|
80
|
+
"-b:a", self.abitrate,
|
|
81
|
+
"-ar", str(self.sr),
|
|
82
|
+
"-ac", str(self.ac),
|
|
83
|
+
]
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _load_json(path: Path) -> dict:
|
|
87
|
+
return json.loads(path.read_text(encoding="utf-8"))
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def _write_json(path: Path, data: dict) -> None:
|
|
91
|
+
path.write_text(json.dumps(data, indent=2) + "\n", encoding="utf-8")
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def _font_path() -> str:
|
|
95
|
+
for f in _FONT_CANDIDATES:
|
|
96
|
+
if Path(f).exists():
|
|
97
|
+
# Double-escape the drive-letter colon so it survives both
|
|
98
|
+
# filtergraph-level and filter-level parsing.
|
|
99
|
+
return f.replace(":", r"\\:")
|
|
100
|
+
print(
|
|
101
|
+
"WARNING: No usable system font found; slide title/subtitle text will be skipped.",
|
|
102
|
+
file=sys.stderr,
|
|
103
|
+
)
|
|
104
|
+
return ""
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def _color_to_ffmpeg(color: str | None) -> str:
|
|
108
|
+
if not color:
|
|
109
|
+
return "0x000000"
|
|
110
|
+
c = color.strip()
|
|
111
|
+
if c.startswith("#"):
|
|
112
|
+
return "0x" + c[1:]
|
|
113
|
+
return c
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def _escape_drawtext_value(s: str) -> str:
|
|
117
|
+
# Inside drawtext, escape backslash, colon, and any chars that delimit filter args.
|
|
118
|
+
return (
|
|
119
|
+
s.replace("\\", "\\\\")
|
|
120
|
+
.replace(":", "\\:")
|
|
121
|
+
.replace("'", "\\'")
|
|
122
|
+
.replace(",", "\\,")
|
|
123
|
+
.replace("%", "\\%")
|
|
124
|
+
)
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def _write_textfile(tmp_dir: Path, name: str, text: str) -> str:
|
|
128
|
+
p = tmp_dir / name
|
|
129
|
+
p.write_text(text, encoding="utf-8")
|
|
130
|
+
# Forward slashes + double-escaped colon for the drive letter.
|
|
131
|
+
return str(p).replace("\\", "/").replace(":", r"\\:")
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def _scale_pad(rp: RenderParams) -> str:
|
|
135
|
+
return (
|
|
136
|
+
f"scale={rp.width}:{rp.height}:force_original_aspect_ratio=decrease,"
|
|
137
|
+
f"pad={rp.width}:{rp.height}:(ow-iw)/2:(oh-ih)/2:color=black,"
|
|
138
|
+
f"setsar=1,fps={rp.fps},format=yuv420p"
|
|
139
|
+
)
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def _build_drawtext_filters(
|
|
143
|
+
item: dict, rp: RenderParams, tmp_dir: Path, idx: int, font: str
|
|
144
|
+
) -> list[str]:
|
|
145
|
+
if not font:
|
|
146
|
+
return []
|
|
147
|
+
filters: list[str] = []
|
|
148
|
+
title = item.get("title")
|
|
149
|
+
subtitle = item.get("subtitle")
|
|
150
|
+
if title:
|
|
151
|
+
tf = _write_textfile(tmp_dir, f"seg_{idx:03d}_title.txt", title)
|
|
152
|
+
filters.append(
|
|
153
|
+
f"drawtext=fontfile={font}:textfile={tf}:fontsize=80:fontcolor=white"
|
|
154
|
+
f":box=0:x=(w-text_w)/2:y=(h/2)-100"
|
|
155
|
+
)
|
|
156
|
+
if subtitle:
|
|
157
|
+
sf = _write_textfile(tmp_dir, f"seg_{idx:03d}_subtitle.txt", subtitle)
|
|
158
|
+
filters.append(
|
|
159
|
+
f"drawtext=fontfile={font}:textfile={sf}:fontsize=42:fontcolor=white"
|
|
160
|
+
f":x=(w-text_w)/2:y=(h/2)+20"
|
|
161
|
+
)
|
|
162
|
+
return filters
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def run(root: Path, slug: str) -> int:
|
|
166
|
+
proj = root / slug
|
|
167
|
+
plan_path = proj / "compose-plan.json"
|
|
168
|
+
if not plan_path.exists():
|
|
169
|
+
raise SystemExit(f"compose-plan.json missing in {proj}")
|
|
170
|
+
plan = _load_json(plan_path)
|
|
171
|
+
timeline = plan.get("timeline", []) or []
|
|
172
|
+
if not timeline:
|
|
173
|
+
raise SystemExit("compose-plan.json: timeline is empty.")
|
|
174
|
+
|
|
175
|
+
rp = RenderParams.from_output(plan.get("output", {}) or {})
|
|
176
|
+
out_cfg = plan.get("output", {}) or {}
|
|
177
|
+
out_name = out_cfg.get("filename", "final.mp4")
|
|
178
|
+
|
|
179
|
+
clips_manifest_path = proj / "clips" / "manifest.json"
|
|
180
|
+
voice_manifest_path = proj / "voice" / "manifest.json"
|
|
181
|
+
clips_by_id: dict[str, dict] = {}
|
|
182
|
+
voice_by_id: dict[str, dict] = {}
|
|
183
|
+
if clips_manifest_path.exists():
|
|
184
|
+
clips_by_id = {c["id"]: c for c in _load_json(clips_manifest_path).get("clips", [])}
|
|
185
|
+
if voice_manifest_path.exists():
|
|
186
|
+
voice_by_id = {v["id"]: v for v in _load_json(voice_manifest_path).get("segments", [])}
|
|
187
|
+
|
|
188
|
+
tmp_dir = proj / "tmp"
|
|
189
|
+
tmp_dir.mkdir(parents=True, exist_ok=True)
|
|
190
|
+
for old in tmp_dir.glob("seg_*.mp4"):
|
|
191
|
+
old.unlink()
|
|
192
|
+
for old in tmp_dir.glob("seg_*.txt"):
|
|
193
|
+
old.unlink()
|
|
194
|
+
|
|
195
|
+
out_dir = proj / "out"
|
|
196
|
+
out_dir.mkdir(parents=True, exist_ok=True)
|
|
197
|
+
final_path = out_dir / out_name
|
|
198
|
+
|
|
199
|
+
font = _font_path()
|
|
200
|
+
|
|
201
|
+
print(f"Rendering {len(timeline)} timeline item(s) at {rp.width}x{rp.height}@{rp.fps}fps")
|
|
202
|
+
intermediates: list[Path] = []
|
|
203
|
+
for idx, item in enumerate(timeline, start=1):
|
|
204
|
+
seg_out = tmp_dir / f"seg_{idx:03d}.mp4"
|
|
205
|
+
kind = item.get("type", "clip")
|
|
206
|
+
if kind == "clip":
|
|
207
|
+
_render_clip(proj, item, clips_by_id, voice_by_id, rp, seg_out)
|
|
208
|
+
elif kind == "slide":
|
|
209
|
+
_render_slide(proj, item, voice_by_id, rp, tmp_dir, idx, font, seg_out)
|
|
210
|
+
elif kind == "gap":
|
|
211
|
+
_render_gap(item, rp, seg_out)
|
|
212
|
+
else:
|
|
213
|
+
raise SystemExit(f"timeline item {idx}: unknown type {kind!r}")
|
|
214
|
+
intermediates.append(seg_out)
|
|
215
|
+
print(f" [{idx:03d}] {kind:5} -> {seg_out.name}")
|
|
216
|
+
|
|
217
|
+
concat_list = tmp_dir / "concat.txt"
|
|
218
|
+
concat_list.write_text(
|
|
219
|
+
"".join(f"file '{p.as_posix()}'\n" for p in intermediates),
|
|
220
|
+
encoding="utf-8",
|
|
221
|
+
)
|
|
222
|
+
|
|
223
|
+
bg = plan.get("background_music")
|
|
224
|
+
if bg:
|
|
225
|
+
intermediate_concat = tmp_dir / "concat.mp4"
|
|
226
|
+
ffmpeg_wrap.run_ffmpeg(
|
|
227
|
+
[
|
|
228
|
+
"-f", "concat", "-safe", "0",
|
|
229
|
+
"-i", str(concat_list),
|
|
230
|
+
"-c", "copy",
|
|
231
|
+
str(intermediate_concat),
|
|
232
|
+
]
|
|
233
|
+
)
|
|
234
|
+
_mix_background_music(intermediate_concat, proj, bg, rp, final_path)
|
|
235
|
+
else:
|
|
236
|
+
ffmpeg_wrap.run_ffmpeg(
|
|
237
|
+
[
|
|
238
|
+
"-f", "concat", "-safe", "0",
|
|
239
|
+
"-i", str(concat_list),
|
|
240
|
+
"-c", "copy",
|
|
241
|
+
str(final_path),
|
|
242
|
+
]
|
|
243
|
+
)
|
|
244
|
+
|
|
245
|
+
info = ffmpeg_wrap.probe(final_path)
|
|
246
|
+
print(f"\nRendered: {final_path}")
|
|
247
|
+
print(f" duration: {info.duration_sec:.2f}s, {info.width}x{info.height}@{info.fps:.0f}fps")
|
|
248
|
+
return 0
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
def _render_clip(
|
|
252
|
+
proj: Path,
|
|
253
|
+
item: dict,
|
|
254
|
+
clips_by_id: dict[str, dict],
|
|
255
|
+
voice_by_id: dict[str, dict],
|
|
256
|
+
rp: RenderParams,
|
|
257
|
+
out_path: Path,
|
|
258
|
+
) -> None:
|
|
259
|
+
cid = item.get("clip")
|
|
260
|
+
if not cid or cid not in clips_by_id:
|
|
261
|
+
raise SystemExit(
|
|
262
|
+
f"timeline clip refers to unknown id {cid!r}. Run `cut` first or check cut-plan.json."
|
|
263
|
+
)
|
|
264
|
+
clip = clips_by_id[cid]
|
|
265
|
+
clip_path = proj / clip["path"]
|
|
266
|
+
clip_dur = float(clip["duration_sec"])
|
|
267
|
+
|
|
268
|
+
vo_id = item.get("voiceover")
|
|
269
|
+
pad_to_vo = bool(item.get("pad_to_voiceover", True))
|
|
270
|
+
mute_src = bool(item.get("mute_source", False))
|
|
271
|
+
duck_db = item.get("duck_source_db", -15 if vo_id else 0)
|
|
272
|
+
|
|
273
|
+
if vo_id and vo_id not in voice_by_id:
|
|
274
|
+
raise SystemExit(
|
|
275
|
+
f"timeline clip '{cid}' references voiceover '{vo_id}' which is not in "
|
|
276
|
+
f"voice/manifest.json. Run `tts` first."
|
|
277
|
+
)
|
|
278
|
+
|
|
279
|
+
args: list[str] = []
|
|
280
|
+
filter_complex_parts: list[str] = []
|
|
281
|
+
|
|
282
|
+
args += ["-i", str(clip_path)]
|
|
283
|
+
if vo_id:
|
|
284
|
+
vo = voice_by_id[vo_id]
|
|
285
|
+
args += ["-i", str(proj / vo["path"])]
|
|
286
|
+
vo_dur = float(vo["duration_sec"])
|
|
287
|
+
target_dur = max(clip_dur, vo_dur) if pad_to_vo else clip_dur
|
|
288
|
+
extra_pad = max(0.0, target_dur - clip_dur)
|
|
289
|
+
|
|
290
|
+
if extra_pad > 0.01:
|
|
291
|
+
filter_complex_parts.append(
|
|
292
|
+
f"[0:v]tpad=stop_mode=clone:stop_duration={extra_pad:.3f},{_scale_pad(rp)}[vout]"
|
|
293
|
+
)
|
|
294
|
+
else:
|
|
295
|
+
filter_complex_parts.append(f"[0:v]{_scale_pad(rp)}[vout]")
|
|
296
|
+
|
|
297
|
+
# Duck (or mute) the source audio; if source had no audio, anullsrc fallback.
|
|
298
|
+
src_vol_expr = "volume=0" if mute_src else f"volume={duck_db}dB"
|
|
299
|
+
filter_complex_parts.append(
|
|
300
|
+
f"[0:a]aresample={rp.sr},aformat=channel_layouts=stereo,"
|
|
301
|
+
f"{src_vol_expr},apad=whole_dur={target_dur:.3f}[a0]"
|
|
302
|
+
)
|
|
303
|
+
filter_complex_parts.append(
|
|
304
|
+
f"[1:a]aresample={rp.sr},aformat=channel_layouts=stereo,"
|
|
305
|
+
f"apad=whole_dur={target_dur:.3f}[a1]"
|
|
306
|
+
)
|
|
307
|
+
filter_complex_parts.append(
|
|
308
|
+
f"[a0][a1]amix=inputs=2:duration=longest:normalize=0,"
|
|
309
|
+
f"atrim=duration={target_dur:.3f}[aout]"
|
|
310
|
+
)
|
|
311
|
+
args += ["-filter_complex", ";".join(filter_complex_parts)]
|
|
312
|
+
args += ["-map", "[vout]", "-map", "[aout]"]
|
|
313
|
+
args += ["-t", f"{target_dur:.3f}"]
|
|
314
|
+
else:
|
|
315
|
+
filter_complex_parts.append(f"[0:v]{_scale_pad(rp)}[vout]")
|
|
316
|
+
src_vol_expr = "volume=0" if mute_src else None
|
|
317
|
+
if src_vol_expr:
|
|
318
|
+
filter_complex_parts.append(
|
|
319
|
+
f"[0:a]aresample={rp.sr},aformat=channel_layouts=stereo,{src_vol_expr}[aout]"
|
|
320
|
+
)
|
|
321
|
+
else:
|
|
322
|
+
filter_complex_parts.append(
|
|
323
|
+
f"[0:a]aresample={rp.sr},aformat=channel_layouts=stereo[aout]"
|
|
324
|
+
)
|
|
325
|
+
args += ["-filter_complex", ";".join(filter_complex_parts)]
|
|
326
|
+
args += ["-map", "[vout]", "-map", "[aout]"]
|
|
327
|
+
|
|
328
|
+
args += rp.video_encode_args() + rp.audio_encode_args()
|
|
329
|
+
args += [str(out_path)]
|
|
330
|
+
ffmpeg_wrap.run_ffmpeg(args)
|
|
331
|
+
|
|
332
|
+
|
|
333
|
+
def _render_slide(
|
|
334
|
+
proj: Path,
|
|
335
|
+
item: dict,
|
|
336
|
+
voice_by_id: dict[str, dict],
|
|
337
|
+
rp: RenderParams,
|
|
338
|
+
tmp_dir: Path,
|
|
339
|
+
idx: int,
|
|
340
|
+
font: str,
|
|
341
|
+
out_path: Path,
|
|
342
|
+
) -> None:
|
|
343
|
+
vo_id = item.get("voiceover")
|
|
344
|
+
duration = item.get("duration_sec")
|
|
345
|
+
bg_image = item.get("background_image")
|
|
346
|
+
bg_color = item.get("background_color", "#000000")
|
|
347
|
+
|
|
348
|
+
if vo_id:
|
|
349
|
+
if vo_id not in voice_by_id:
|
|
350
|
+
raise SystemExit(
|
|
351
|
+
f"slide references voiceover '{vo_id}' not in voice/manifest.json. Run `tts` first."
|
|
352
|
+
)
|
|
353
|
+
vo_dur = float(voice_by_id[vo_id]["duration_sec"])
|
|
354
|
+
pad_after = float(item.get("pad_after_sec", 0.3))
|
|
355
|
+
total = vo_dur + pad_after
|
|
356
|
+
elif duration is not None:
|
|
357
|
+
total = float(duration)
|
|
358
|
+
else:
|
|
359
|
+
raise SystemExit(
|
|
360
|
+
f"slide must have either `voiceover` or `duration_sec`. Item: {item}"
|
|
361
|
+
)
|
|
362
|
+
|
|
363
|
+
args: list[str] = []
|
|
364
|
+
vf_chain: list[str] = []
|
|
365
|
+
|
|
366
|
+
if bg_image:
|
|
367
|
+
bg_path = (proj / bg_image).resolve()
|
|
368
|
+
if not bg_path.exists():
|
|
369
|
+
raise SystemExit(f"slide background_image not found: {bg_path}")
|
|
370
|
+
args += ["-loop", "1", "-i", str(bg_path)]
|
|
371
|
+
vf_chain.append(_scale_pad(rp))
|
|
372
|
+
else:
|
|
373
|
+
color_arg = _color_to_ffmpeg(bg_color)
|
|
374
|
+
args += [
|
|
375
|
+
"-f", "lavfi",
|
|
376
|
+
"-i", f"color=c={color_arg}:s={rp.width}x{rp.height}:r={rp.fps}",
|
|
377
|
+
]
|
|
378
|
+
# color source is already correct size/fps; still ensure pixel format.
|
|
379
|
+
vf_chain.append(f"format=yuv420p,setsar=1,fps={rp.fps}")
|
|
380
|
+
|
|
381
|
+
text_filters = _build_drawtext_filters(item, rp, tmp_dir, idx, font)
|
|
382
|
+
if text_filters:
|
|
383
|
+
vf_chain.extend(text_filters)
|
|
384
|
+
|
|
385
|
+
if vo_id:
|
|
386
|
+
args += ["-i", str(proj / voice_by_id[vo_id]["path"])]
|
|
387
|
+
else:
|
|
388
|
+
args += ["-f", "lavfi", "-i", f"anullsrc=r={rp.sr}:cl=stereo"]
|
|
389
|
+
|
|
390
|
+
filter_complex = (
|
|
391
|
+
f"[0:v]{','.join(vf_chain)},trim=duration={total:.3f},setpts=PTS-STARTPTS[vout];"
|
|
392
|
+
f"[1:a]aresample={rp.sr},aformat=channel_layouts=stereo,"
|
|
393
|
+
f"apad=whole_dur={total:.3f},atrim=duration={total:.3f},asetpts=PTS-STARTPTS[aout]"
|
|
394
|
+
)
|
|
395
|
+
args += ["-filter_complex", filter_complex]
|
|
396
|
+
args += ["-map", "[vout]", "-map", "[aout]"]
|
|
397
|
+
args += ["-t", f"{total:.3f}"]
|
|
398
|
+
args += rp.video_encode_args() + rp.audio_encode_args()
|
|
399
|
+
args += [str(out_path)]
|
|
400
|
+
ffmpeg_wrap.run_ffmpeg(args)
|
|
401
|
+
|
|
402
|
+
|
|
403
|
+
def _render_gap(item: dict, rp: RenderParams, out_path: Path) -> None:
|
|
404
|
+
duration = float(item.get("duration_sec", 0))
|
|
405
|
+
if duration <= 0:
|
|
406
|
+
raise SystemExit("gap requires positive duration_sec")
|
|
407
|
+
args = [
|
|
408
|
+
"-f", "lavfi", "-i", f"color=c=0x000000:s={rp.width}x{rp.height}:r={rp.fps}",
|
|
409
|
+
"-f", "lavfi", "-i", f"anullsrc=r={rp.sr}:cl=stereo",
|
|
410
|
+
"-t", f"{duration:.3f}",
|
|
411
|
+
"-pix_fmt", "yuv420p",
|
|
412
|
+
]
|
|
413
|
+
args += rp.video_encode_args() + rp.audio_encode_args() + [str(out_path)]
|
|
414
|
+
ffmpeg_wrap.run_ffmpeg(args)
|
|
415
|
+
|
|
416
|
+
|
|
417
|
+
def _mix_background_music(
|
|
418
|
+
concat_video: Path, proj: Path, bg: dict, rp: RenderParams, final_path: Path
|
|
419
|
+
) -> None:
|
|
420
|
+
music_rel = bg.get("path")
|
|
421
|
+
if not music_rel:
|
|
422
|
+
raise SystemExit("background_music.path is required")
|
|
423
|
+
music_path = (proj / music_rel).resolve()
|
|
424
|
+
if not music_path.exists():
|
|
425
|
+
raise SystemExit(f"background_music.path not found: {music_path}")
|
|
426
|
+
|
|
427
|
+
volume_db = float(bg.get("volume_db", -22))
|
|
428
|
+
fade_in = float(bg.get("fade_in_sec", 1.0))
|
|
429
|
+
fade_out = float(bg.get("fade_out_sec", 2.0))
|
|
430
|
+
|
|
431
|
+
info = ffmpeg_wrap.probe(concat_video)
|
|
432
|
+
total = info.duration_sec
|
|
433
|
+
fade_out_start = max(0.0, total - fade_out)
|
|
434
|
+
|
|
435
|
+
filter_complex = (
|
|
436
|
+
f"[1:a]aresample={rp.sr},aformat=channel_layouts=stereo,"
|
|
437
|
+
f"volume={volume_db}dB,"
|
|
438
|
+
f"afade=t=in:st=0:d={fade_in:.3f},"
|
|
439
|
+
f"afade=t=out:st={fade_out_start:.3f}:d={fade_out:.3f}[bg];"
|
|
440
|
+
f"[0:a][bg]amix=inputs=2:duration=first:normalize=0[aout]"
|
|
441
|
+
)
|
|
442
|
+
args = [
|
|
443
|
+
"-i", str(concat_video),
|
|
444
|
+
"-stream_loop", "-1", "-i", str(music_path),
|
|
445
|
+
"-filter_complex", filter_complex,
|
|
446
|
+
"-map", "0:v", "-map", "[aout]",
|
|
447
|
+
"-c:v", "copy",
|
|
448
|
+
]
|
|
449
|
+
args += rp.audio_encode_args() + [str(final_path)]
|
|
450
|
+
ffmpeg_wrap.run_ffmpeg(args)
|
lib/cut.py
ADDED
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
"""`cut` — apply cut-plan.json: emit clips/<id>.mp4 + clips/manifest.json."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
from . import ffmpeg_wrap
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def _load_json(path: Path) -> dict:
|
|
12
|
+
return json.loads(path.read_text(encoding="utf-8"))
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _write_json(path: Path, data: dict) -> None:
|
|
16
|
+
path.write_text(json.dumps(data, indent=2) + "\n", encoding="utf-8")
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def run(
|
|
20
|
+
root: Path,
|
|
21
|
+
slug: str,
|
|
22
|
+
*,
|
|
23
|
+
only: list[str] | None = None,
|
|
24
|
+
force: bool = False,
|
|
25
|
+
stream_copy: bool = False,
|
|
26
|
+
) -> int:
|
|
27
|
+
proj = root / slug
|
|
28
|
+
plan_path = proj / "cut-plan.json"
|
|
29
|
+
if not plan_path.exists():
|
|
30
|
+
raise SystemExit(f"cut-plan.json missing in {proj}")
|
|
31
|
+
plan = _load_json(plan_path)
|
|
32
|
+
clips = plan.get("clips", []) or []
|
|
33
|
+
if not clips:
|
|
34
|
+
print("cut-plan.json has no clips; nothing to do.")
|
|
35
|
+
return 0
|
|
36
|
+
|
|
37
|
+
project = _load_json(proj / "project.json")
|
|
38
|
+
sources_by_id = {s["id"]: s for s in project.get("sources", [])}
|
|
39
|
+
|
|
40
|
+
out_dir = proj / "clips"
|
|
41
|
+
out_dir.mkdir(parents=True, exist_ok=True)
|
|
42
|
+
manifest_path = out_dir / "manifest.json"
|
|
43
|
+
manifest = _load_json(manifest_path) if manifest_path.exists() else {"clips": []}
|
|
44
|
+
existing = {c["id"]: c for c in manifest.get("clips", [])}
|
|
45
|
+
|
|
46
|
+
only_set = set(only or [])
|
|
47
|
+
todo = [c for c in clips if (not only_set or c["id"] in only_set)]
|
|
48
|
+
print(f"Cutting {len(todo)} clip(s) (mode: {'stream-copy' if stream_copy else 're-encode'})")
|
|
49
|
+
|
|
50
|
+
new_entries: dict[str, dict] = {}
|
|
51
|
+
for clip in todo:
|
|
52
|
+
cid = clip["id"]
|
|
53
|
+
src_id = clip["source"]
|
|
54
|
+
if src_id not in sources_by_id:
|
|
55
|
+
raise SystemExit(f"clip '{cid}': unknown source '{src_id}'")
|
|
56
|
+
src = sources_by_id[src_id]
|
|
57
|
+
src_path = proj / src["path"]
|
|
58
|
+
if not src_path.exists():
|
|
59
|
+
raise SystemExit(f"clip '{cid}': source file missing: {src_path}")
|
|
60
|
+
|
|
61
|
+
start = float(clip["start"])
|
|
62
|
+
end = float(clip["end"])
|
|
63
|
+
if end <= start:
|
|
64
|
+
raise SystemExit(f"clip '{cid}': end ({end}) must be > start ({start})")
|
|
65
|
+
duration = end - start
|
|
66
|
+
out_path = out_dir / f"{cid}.mp4"
|
|
67
|
+
|
|
68
|
+
if out_path.exists() and not force:
|
|
69
|
+
print(f" [skip] {cid} (exists; pass --force to regenerate)")
|
|
70
|
+
continue
|
|
71
|
+
|
|
72
|
+
print(f" [{cid}] {src_id} {start:.2f}-{end:.2f}s -> {out_path.name}")
|
|
73
|
+
|
|
74
|
+
if stream_copy:
|
|
75
|
+
args = [
|
|
76
|
+
"-ss", f"{start:.3f}",
|
|
77
|
+
"-i", str(src_path),
|
|
78
|
+
"-t", f"{duration:.3f}",
|
|
79
|
+
"-c", "copy",
|
|
80
|
+
"-avoid_negative_ts", "make_zero",
|
|
81
|
+
str(out_path),
|
|
82
|
+
]
|
|
83
|
+
else:
|
|
84
|
+
args = [
|
|
85
|
+
"-ss", f"{start:.3f}",
|
|
86
|
+
"-i", str(src_path),
|
|
87
|
+
"-t", f"{duration:.3f}",
|
|
88
|
+
"-c:v", "libx264",
|
|
89
|
+
"-preset", "medium",
|
|
90
|
+
"-crf", "20",
|
|
91
|
+
"-pix_fmt", "yuv420p",
|
|
92
|
+
"-c:a", "aac",
|
|
93
|
+
"-b:a", "192k",
|
|
94
|
+
"-ar", "48000",
|
|
95
|
+
"-ac", "2",
|
|
96
|
+
"-movflags", "+faststart",
|
|
97
|
+
str(out_path),
|
|
98
|
+
]
|
|
99
|
+
ffmpeg_wrap.run_ffmpeg(args)
|
|
100
|
+
|
|
101
|
+
info = ffmpeg_wrap.probe(out_path)
|
|
102
|
+
new_entries[cid] = {
|
|
103
|
+
"id": cid,
|
|
104
|
+
"source": src_id,
|
|
105
|
+
"path": f"clips/{out_path.name}",
|
|
106
|
+
"source_start": round(start, 3),
|
|
107
|
+
"source_end": round(end, 3),
|
|
108
|
+
"duration_sec": round(info.duration_sec, 3),
|
|
109
|
+
"label": clip.get("label", ""),
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
merged: list[dict] = []
|
|
113
|
+
for clip in clips:
|
|
114
|
+
cid = clip["id"]
|
|
115
|
+
if cid in new_entries:
|
|
116
|
+
merged.append(new_entries[cid])
|
|
117
|
+
elif cid in existing:
|
|
118
|
+
merged.append(existing[cid])
|
|
119
|
+
_write_json(manifest_path, {"clips": merged})
|
|
120
|
+
print(f"Wrote {manifest_path}")
|
|
121
|
+
return 0
|
lib/doctor.py
ADDED
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
"""`videopilot doctor` — check prerequisites and print a status report."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import importlib
|
|
6
|
+
import importlib.util
|
|
7
|
+
import os
|
|
8
|
+
import shutil
|
|
9
|
+
|
|
10
|
+
from . import ffmpeg_wrap
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def _ok(msg: str) -> None:
|
|
14
|
+
print(f" [OK] {msg}")
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _warn(msg: str) -> None:
|
|
18
|
+
print(f" [WARN] {msg}")
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _bad(msg: str) -> None:
|
|
22
|
+
print(f" [FAIL] {msg}")
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _have_pkg(name: str) -> bool:
|
|
26
|
+
try:
|
|
27
|
+
return importlib.util.find_spec(name) is not None
|
|
28
|
+
except (ImportError, ModuleNotFoundError, ValueError):
|
|
29
|
+
return False
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def run() -> int:
|
|
33
|
+
print("video-creator prerequisite check")
|
|
34
|
+
print("=" * 50)
|
|
35
|
+
|
|
36
|
+
failures = 0
|
|
37
|
+
|
|
38
|
+
# Trigger PATH augmentation (WinGet Gyan.FFmpeg auto-detect) before lookup.
|
|
39
|
+
ffmpeg_wrap.have_ffmpeg()
|
|
40
|
+
|
|
41
|
+
print("\nBinaries on PATH:")
|
|
42
|
+
if shutil.which("ffmpeg"):
|
|
43
|
+
_ok(f"ffmpeg: {shutil.which('ffmpeg')}")
|
|
44
|
+
else:
|
|
45
|
+
_bad("ffmpeg not found. Install: `winget install --id Gyan.FFmpeg -e`")
|
|
46
|
+
failures += 1
|
|
47
|
+
if shutil.which("ffprobe"):
|
|
48
|
+
_ok(f"ffprobe: {shutil.which('ffprobe')}")
|
|
49
|
+
else:
|
|
50
|
+
_bad("ffprobe not found (ships with ffmpeg).")
|
|
51
|
+
failures += 1
|
|
52
|
+
|
|
53
|
+
print("\nPython packages:")
|
|
54
|
+
for pkg, friendly in [
|
|
55
|
+
("edge_tts", "edge-tts"),
|
|
56
|
+
("faster_whisper", "faster-whisper"),
|
|
57
|
+
]:
|
|
58
|
+
if _have_pkg(pkg):
|
|
59
|
+
try:
|
|
60
|
+
mod = importlib.import_module(pkg)
|
|
61
|
+
ver = getattr(mod, "__version__", "?")
|
|
62
|
+
_ok(f"{friendly} ({ver})")
|
|
63
|
+
except Exception as exc:
|
|
64
|
+
_warn(f"{friendly} importable but errored: {exc}")
|
|
65
|
+
else:
|
|
66
|
+
_bad(f"{friendly} not installed. `pip install -r requirements.txt`")
|
|
67
|
+
failures += 1
|
|
68
|
+
|
|
69
|
+
print("\nOptional packages:")
|
|
70
|
+
if _have_pkg("azure.cognitiveservices.speech"):
|
|
71
|
+
_ok("azure-cognitiveservices-speech installed (engine: azure available)")
|
|
72
|
+
else:
|
|
73
|
+
_warn("azure-cognitiveservices-speech not installed (only matters if you set engine: azure)")
|
|
74
|
+
|
|
75
|
+
print("\nAzure Speech credentials (optional):")
|
|
76
|
+
if os.environ.get("AZURE_SPEECH_KEY") and os.environ.get("AZURE_SPEECH_REGION"):
|
|
77
|
+
_ok(f"AZURE_SPEECH_KEY set, region={os.environ['AZURE_SPEECH_REGION']}")
|
|
78
|
+
else:
|
|
79
|
+
_warn("AZURE_SPEECH_KEY / AZURE_SPEECH_REGION not set (edge-tts still works)")
|
|
80
|
+
|
|
81
|
+
print("\nffmpeg quick probe:")
|
|
82
|
+
if shutil.which("ffmpeg"):
|
|
83
|
+
try:
|
|
84
|
+
import subprocess
|
|
85
|
+
out = subprocess.run(
|
|
86
|
+
["ffmpeg", "-version"], capture_output=True, text=True
|
|
87
|
+
)
|
|
88
|
+
first = (out.stdout or "").splitlines()[0] if out.stdout else "?"
|
|
89
|
+
_ok(first)
|
|
90
|
+
except Exception as exc:
|
|
91
|
+
_warn(f"ffmpeg present but `-version` failed: {exc}")
|
|
92
|
+
|
|
93
|
+
print()
|
|
94
|
+
if failures:
|
|
95
|
+
print(f"{failures} check(s) failed. Fix the [FAIL] items above and re-run.")
|
|
96
|
+
return 1
|
|
97
|
+
print("All required checks passed. You're good to go.")
|
|
98
|
+
return 0
|