返回 VideoClaw
utils.py
1 import asyncio
2 import base64
3 import json
4 import logging
5 import math
6 import os
7 import re
8 import shutil
9 import subprocess
10 import sys
11 import tempfile
12 from html import escape
13 from pathlib import Path
14 from typing import Any, Iterable, Optional
15
16 from PIL import Image, ImageDraw, ImageFont
17
18 from config import BASE_DIR
19
20 logger = logging.getLogger(__name__)
21
22 TEMPLATE_FIELD_DEFAULTS = {
23 "title": "山河入梦",
24 "text": "心之所向,素履而往",
25 "author": "HITsz-TMG",
26 "describe": "开源视频生成智能体",
27 "brand": "Video-Claw",
28 "signature": "HITsz-TMG",
29 "subtitle": "这是一个副标题",
30 }
31 CUSTOM_TEMPLATE_FIELDS = ("author", "describe", "brand", "signature", "subtitle")
32
33
34 def write_text(path: str, content: str) -> str:
35 os.makedirs(os.path.dirname(path), exist_ok=True)
36 with open(path, "w", encoding="utf-8") as f:
37 f.write(content)
38 return path
39
40
41 def write_json(path: str, data: Any) -> str:
42 os.makedirs(os.path.dirname(path), exist_ok=True)
43 with open(path, "w", encoding="utf-8") as f:
44 json.dump(data, f, ensure_ascii=False, indent=2)
45 return path
46
47
48 def artifact(path: str, kind: str, name: Optional[str] = None) -> dict[str, Any]:
49 return {
50 "kind": kind,
51 "name": name or os.path.basename(path),
52 "path": path,
53 "exists": os.path.exists(path),
54 }
55
56
57 def extract_json_array(text: str) -> list[Any]:
58 text = text.strip()
59 try:
60 data = json.loads(text)
61 if isinstance(data, list):
62 return data
63 except Exception:
64 pass
65
66 match = re.search(r"```(?:json)?\s*(\[.*?\])\s*```", text, re.S)
67 if not match:
68 match = re.search(r"(\[.*\])", text, re.S)
69 if match:
70 data = json.loads(match.group(1))
71 if isinstance(data, list):
72 return data
73 raise ValueError("Model response did not contain a JSON array.")
74
75
76 def split_script(text: str, split_mode: str = "paragraph") -> list[str]:
77 if split_mode == "line":
78 parts = [line.strip() for line in text.splitlines()]
79 elif split_mode == "sentence":
80 parts = [part.strip() for part in re.split(r"(?<=[。!?.!?])\s*", text)]
81 else:
82 parts = [part.strip() for part in re.split(r"\n\s*\n", text)]
83 return [part for part in parts if part]
84
85
86 def copy_input_file(path: str, output_dir: str, prefix: str) -> str:
87 if path.startswith(("http://", "https://", "file://", "data:")):
88 return path
89 src = Path(path)
90 if not src.exists():
91 raise FileNotFoundError(f"Input file not found: {path}")
92 dst = Path(output_dir) / f"{prefix}{src.suffix.lower()}"
93 shutil.copy2(src, dst)
94 return str(dst)
95
96
97 async def run_blocking(func, *args, **kwargs):
98 return await asyncio.to_thread(func, *args, **kwargs)
99
100
101 def concat_videos(video_paths: Iterable[str], output_path: str) -> Optional[str]:
102 paths = [path for path in video_paths if path and os.path.exists(path)]
103 if not paths:
104 return None
105
106 ffmpeg = shutil.which("ffmpeg")
107 if not ffmpeg:
108 logger.warning("ffmpeg not found; cannot concatenate videos")
109 return None
110
111 list_path = os.path.join(os.path.dirname(output_path), "concat.txt")
112 with open(list_path, "w", encoding="utf-8") as f:
113 for path in paths:
114 f.write(f"file '{os.path.abspath(path)}'\n")
115
116 cmd = [ffmpeg, "-y", "-f", "concat", "-safe", "0", "-i", list_path, "-c", "copy", output_path]
117 logger.info("Concatenating %d videos -> %s", len(paths), output_path)
118 subprocess.run(cmd, check=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
119 logger.info("Video concatenation complete: %s", output_path)
120 return output_path
121
122
123 def create_static_image_clip(
124 image_path: str,
125 audio_path: str,
126 output_path: str,
127 *,
128 video_ratio: str = "9:16",
129 duration: Optional[float] = None,
130 ) -> str:
131 ffmpeg = shutil.which("ffmpeg")
132 if not ffmpeg:
133 raise RuntimeError("ffmpeg is required to create static short-video clips.")
134 if not os.path.exists(image_path):
135 raise FileNotFoundError(f"Image not found: {image_path}")
136 if not os.path.exists(audio_path):
137 raise FileNotFoundError(f"Audio not found: {audio_path}")
138
139 width, height = _resolution_from_ratio(video_ratio)
140 clip_duration = duration or media_duration_seconds(audio_path) or 3.0
141 vf = (
142 f"scale={width}:{height}:force_original_aspect_ratio=decrease,"
143 f"pad={width}:{height}:(ow-iw)/2:(oh-ih)/2:color=black,"
144 "format=yuv420p"
145 )
146 os.makedirs(os.path.dirname(output_path), exist_ok=True)
147 logger.info(
148 "Creating static image clip: image=%s audio=%s duration=%.2fs -> %s",
149 image_path,
150 audio_path,
151 clip_duration,
152 output_path,
153 )
154 cmd = [
155 ffmpeg,
156 "-y",
157 "-loop",
158 "1",
159 "-framerate",
160 "30",
161 "-i",
162 image_path,
163 "-i",
164 audio_path,
165 "-t",
166 f"{clip_duration:.3f}",
167 "-vf",
168 vf,
169 "-c:v",
170 "libx264",
171 "-preset",
172 "veryfast",
173 "-crf",
174 "18",
175 "-c:a",
176 "aac",
177 "-shortest",
178 "-movflags",
179 "+faststart",
180 output_path,
181 ]
182 subprocess.run(cmd, check=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
183 return output_path
184
185
186 def _load_font(size: int) -> ImageFont.ImageFont:
187 candidates = [
188 "/System/Library/Fonts/PingFang.ttc",
189 "/System/Library/Fonts/STHeiti Light.ttc",
190 "/System/Library/Fonts/Supplemental/Arial Unicode.ttf",
191 "/usr/share/fonts/opentype/noto/NotoSansCJK-Regular.ttc",
192 "/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf",
193 ]
194 for path in candidates:
195 if os.path.exists(path):
196 try:
197 return ImageFont.truetype(path, size)
198 except Exception:
199 continue
200 return ImageFont.load_default()
201
202
203 def _text_width(draw: ImageDraw.ImageDraw, text: str, font: ImageFont.ImageFont) -> int:
204 bbox = draw.textbbox((0, 0), text, font=font)
205 return bbox[2] - bbox[0]
206
207
208 def _wrap_text(draw: ImageDraw.ImageDraw, text: str, font: ImageFont.ImageFont, max_width: int) -> list[str]:
209 lines: list[str] = []
210 for paragraph in str(text or "").splitlines() or [""]:
211 current = ""
212 for char in paragraph.strip():
213 candidate = current + char
214 if current and _text_width(draw, candidate, font) > max_width:
215 lines.append(current)
216 current = char
217 else:
218 current = candidate
219 if current:
220 lines.append(current)
221 return lines or [""]
222
223
224 def _draw_centered_lines(
225 draw: ImageDraw.ImageDraw,
226 lines: list[str],
227 *,
228 center_x: int,
229 y: int,
230 font: ImageFont.ImageFont,
231 fill: tuple[int, int, int, int],
232 stroke_width: int,
233 stroke_fill: tuple[int, int, int, int],
234 line_gap: int,
235 ) -> int:
236 cursor = y
237 for line in lines:
238 bbox = draw.textbbox((0, 0), line, font=font, stroke_width=stroke_width)
239 width = bbox[2] - bbox[0]
240 height = bbox[3] - bbox[1]
241 draw.text(
242 (center_x - width / 2, cursor),
243 line,
244 font=font,
245 fill=fill,
246 stroke_width=stroke_width,
247 stroke_fill=stroke_fill,
248 )
249 cursor += height + line_gap
250 return cursor
251
252
253 def render_static_text_image(
254 image_path: str,
255 output_path: str,
256 *,
257 subtitle: str,
258 title: Optional[str] = None,
259 video_ratio: str = "9:16",
260 ) -> str:
261 if not os.path.exists(image_path):
262 raise FileNotFoundError(f"Image not found: {image_path}")
263
264 width, height = _resolution_from_ratio(video_ratio)
265 with Image.open(image_path) as source:
266 source = source.convert("RGB")
267 source.thumbnail((width, height), Image.Resampling.LANCZOS)
268 canvas = Image.new("RGB", (width, height), (0, 0, 0))
269 canvas.paste(source, ((width - source.width) // 2, (height - source.height) // 2))
270
271 image = canvas.convert("RGBA")
272 overlay = Image.new("RGBA", image.size, (0, 0, 0, 0))
273 draw = ImageDraw.Draw(overlay)
274
275 title_font = _load_font(max(48, int(height * 0.06)))
276 subtitle_font = _load_font(max(32, int(height * 0.035)))
277 margin_x = max(48, int(width * 0.07))
278 max_text_width = width - margin_x * 2
279
280 if title:
281 title_lines = _wrap_text(draw, title, title_font, max_text_width)
282 title_line_height = max(1, draw.textbbox((0, 0), "国", font=title_font)[3])
283 title_height = len(title_lines) * title_line_height + max(0, len(title_lines) - 1) * 10
284 title_y = max(48, int(height * 0.055))
285 _draw_centered_lines(
286 draw,
287 title_lines,
288 center_x=width // 2,
289 y=title_y,
290 font=title_font,
291 fill=(255, 255, 255, 255),
292 stroke_width=3,
293 stroke_fill=(0, 0, 0, 210),
294 line_gap=10,
295 )
296
297 subtitle_lines = _wrap_text(draw, subtitle, subtitle_font, max_text_width)
298 subtitle_line_height = max(1, draw.textbbox((0, 0), "国", font=subtitle_font)[3])
299 subtitle_height = len(subtitle_lines) * subtitle_line_height + max(0, len(subtitle_lines) - 1) * 10
300 subtitle_y = height - max(96, int(height * 0.08)) - subtitle_height
301 _draw_centered_lines(
302 draw,
303 subtitle_lines,
304 center_x=width // 2,
305 y=subtitle_y,
306 font=subtitle_font,
307 fill=(255, 255, 255, 255),
308 stroke_width=3,
309 stroke_fill=(0, 0, 0, 230),
310 line_gap=10,
311 )
312
313 os.makedirs(os.path.dirname(output_path), exist_ok=True)
314 Image.alpha_composite(image, overlay).convert("RGB").save(output_path, quality=95)
315 logger.info("Rendered static title/subtitle image: %s -> %s", image_path, output_path)
316 return output_path
317
318
319 def ratio_from_size(width: int, height: int) -> str:
320 if width <= 0 or height <= 0:
321 return "1:1"
322 divisor = max(1, math.gcd(width, height))
323 return f"{width // divisor}:{height // divisor}"
324
325
326 def _template_size_from_id(template_id: str) -> tuple[str, int, int]:
327 size = str(template_id or "").split("/", 1)[0]
328 if size == "1920x1080":
329 return size, 1920, 1080
330 if size == "1080x1080":
331 return size, 1080, 1080
332 if size == "1080x1920":
333 return size, 1080, 1920
334 raise ValueError(f"Unsupported subtitle template size: {template_id}")
335
336
337 def _resolve_template_path(template_id: str, video_ratio: str) -> tuple[str, int, int]:
338 if "/" in str(template_id):
339 size, filename = str(template_id).split("/", 1)
340 _, width, height = _template_size_from_id(template_id)
341 else:
342 width, height = _resolution_from_ratio(video_ratio)
343 size = f"{width}x{height}"
344 filename = str(template_id)
345 if "/" in filename or "\\" in filename or not filename.endswith(".html"):
346 raise ValueError(f"Invalid subtitle template: {template_id}")
347 path = (BASE_DIR / "templates" / size / filename).resolve()
348 root = (BASE_DIR / "templates" / size).resolve()
349 if not str(path).startswith(str(root) + os.sep) or not path.exists():
350 raise FileNotFoundError(f"Subtitle template not found: {template_id}")
351 return str(path), width, height
352
353
354 def template_media_spec(template_id: str, video_ratio: str = "9:16") -> dict[str, Any]:
355 template_path, _, _ = _resolve_template_path(template_id, video_ratio)
356 with open(template_path, "r", encoding="utf-8") as f:
357 raw = f.read()
358 width_match = re.search(
359 r'<meta\s+name=["\']template:media-width["\']\s+content=["\'](\d+)["\']',
360 raw,
361 re.I,
362 )
363 height_match = re.search(
364 r'<meta\s+name=["\']template:media-height["\']\s+content=["\'](\d+)["\']',
365 raw,
366 re.I,
367 )
368 if not width_match or not height_match:
369 raise ValueError(f"Subtitle template missing media size metadata: {template_id}")
370 width = int(width_match.group(1))
371 height = int(height_match.group(1))
372 return {
373 "media_width": width,
374 "media_height": height,
375 "media_ratio": ratio_from_size(width, height),
376 "media_resolution": f"{width}*{height}",
377 "supports_video": template_supports_video_media(raw),
378 }
379
380
381 def template_supports_video_media(raw: str) -> bool:
382 visible_raw = re.sub(r"<!--.*?-->", "", raw, flags=re.S)
383 if re.search(r"\{\{\s*media(?:[:=][^{}]*)?\s*\}\}", visible_raw):
384 return True
385 return bool(
386 re.search(
387 r"<img\b[^>]*\bsrc=[\"']\{\{\s*image\s*\}\}[\"'][^>]*>",
388 visible_raw,
389 re.I,
390 )
391 )
392
393
394 def parse_template_placeholders(raw: str) -> list[dict[str, str]]:
395 placeholders = []
396 for match in re.finditer(r"\{\{\s*([^{}]+?)\s*\}\}", raw):
397 token = match.group(1).strip()
398 key = token.split(":", 1)[0].split("=", 1)[0].strip()
399 field_type = token.split(":", 1)[1].split("=", 1)[0].strip() if ":" in token else "text"
400 default = token.split("=", 1)[1].strip() if "=" in token else ""
401 placeholders.append({
402 "key": key,
403 "type": field_type,
404 "default": TEMPLATE_FIELD_DEFAULTS.get(key, default),
405 })
406 return placeholders
407
408
409 def template_custom_fields(template_id: str, video_ratio: str = "9:16") -> list[dict[str, str]]:
410 template_path, _, _ = _resolve_template_path(template_id, video_ratio)
411 with open(template_path, "r", encoding="utf-8") as f:
412 placeholders = parse_template_placeholders(f.read())
413
414 fields = []
415 seen = set()
416 for item in placeholders:
417 key = item["key"]
418 if key not in CUSTOM_TEMPLATE_FIELDS or key in seen:
419 continue
420 seen.add(key)
421 fields.append(item)
422 return fields
423
424
425 def _image_data_uri(path: str) -> str:
426 if path.startswith(("http://", "https://", "data:", "file:")):
427 return path
428 if not os.path.exists(path):
429 raise FileNotFoundError(f"Image not found: {path}")
430 suffix = Path(path).suffix.lower()
431 mime = {
432 ".jpg": "image/jpeg",
433 ".jpeg": "image/jpeg",
434 ".png": "image/png",
435 ".webp": "image/webp",
436 ".gif": "image/gif",
437 }.get(suffix, "image/png")
438 with open(path, "rb") as f:
439 encoded = base64.b64encode(f.read()).decode("ascii")
440 return f"data:{mime};base64,{encoded}"
441
442
443 def _render_template_html(raw: str, values: dict[str, Any], raw_keys: Optional[set[str]] = None) -> str:
444 raw_keys = raw_keys or set()
445
446 def repl(match: re.Match) -> str:
447 token = match.group(1).strip()
448 key = token.split(":", 1)[0].split("=", 1)[0].strip()
449 if key in values:
450 if key in raw_keys:
451 return str(values[key] or "")
452 return escape(str(values[key] or ""), quote=True)
453 if "=" in token:
454 return escape(token.split("=", 1)[1].strip(), quote=True)
455 return ""
456
457 return re.sub(r"\{\{\s*([^{}]+?)\s*\}\}", repl, raw)
458
459
460 def _media_file_uri(path: str) -> str:
461 if path.startswith(("http://", "https://", "data:", "file:")):
462 return path
463 if not os.path.exists(path):
464 raise FileNotFoundError(f"Media not found: {path}")
465 return Path(path).resolve().as_uri()
466
467
468 def _template_media_element(src: str, media_kind: str) -> str:
469 attrs = 'class="template-media" style="width:100%;height:100%;object-fit:cover;display:block;"'
470 if media_kind == "video":
471 return f'<video {attrs} src="{escape(src, quote=True)}" muted playsinline preload="auto"></video>'
472 return f'<img {attrs} src="{escape(src, quote=True)}" alt="">'
473
474
475 def _inject_template_media_css(raw: str) -> str:
476 css = (
477 "<style>"
478 ".template-media{width:100%;height:100%;object-fit:cover;display:block;}"
479 "video.template-media{background:#000;}"
480 "</style>"
481 )
482 if "</head>" in raw:
483 return raw.replace("</head>", f"{css}</head>", 1)
484 return css + raw
485
486
487 def _prepare_template_media_html(raw: str, *, media_kind: str) -> str:
488 prepared = _inject_template_media_css(raw)
489 if media_kind != "video":
490 return prepared
491 if re.search(r"\{\{\s*media(?:[:=][^{}]*)?\s*\}\}", prepared):
492 return prepared
493 return re.sub(
494 r"<img\b[^>]*\bsrc=[\"']\{\{\s*image\s*\}\}[\"'][^>]*>",
495 "{{media}}",
496 prepared,
497 flags=re.I,
498 )
499
500
501 def _template_values(
502 *,
503 image_path: str,
504 subtitle: str,
505 title: Optional[str],
506 template_values: Optional[dict[str, Any]],
507 index: int,
508 media_kind: str = "image",
509 media_path: Optional[str] = None,
510 ) -> dict[str, Any]:
511 image_uri = _image_data_uri(image_path)
512 media_src = _media_file_uri(media_path) if media_kind == "video" and media_path else image_uri
513 return {
514 **TEMPLATE_FIELD_DEFAULTS,
515 **(template_values or {}),
516 "title": title or TEMPLATE_FIELD_DEFAULTS["title"],
517 "text": subtitle or "",
518 "image": image_uri,
519 "media": _template_media_element(media_src, media_kind),
520 "index": index,
521 }
522
523
524 def _install_playwright_chromium() -> None:
525 cmd = [sys.executable, "-m", "playwright", "install", "chromium"]
526 logger.info("Installing Playwright Chromium for HTML subtitle templates")
527 try:
528 subprocess.run(cmd, check=True)
529 except subprocess.CalledProcessError as exc:
530 raise RuntimeError(
531 "Playwright is installed, but Chromium is missing and automatic installation failed. "
532 "Run `python -m playwright install chromium` manually and retry."
533 ) from exc
534
535
536 def _launch_playwright_chromium(playwright):
537 try:
538 return playwright.chromium.launch(headless=True)
539 except Exception as exc:
540 message = str(exc)
541 if "Executable doesn't exist" not in message and "playwright install" not in message:
542 raise
543 _install_playwright_chromium()
544 return playwright.chromium.launch(headless=True)
545
546
547 def render_template_text_image(
548 image_path: str,
549 output_path: str,
550 *,
551 subtitle: str,
552 title: Optional[str] = None,
553 video_ratio: str = "9:16",
554 template_id: str,
555 template_values: Optional[dict[str, Any]] = None,
556 index: int = 1,
557 ) -> str:
558 template_path, width, height = _resolve_template_path(template_id, video_ratio)
559 with open(template_path, "r", encoding="utf-8") as f:
560 raw = _prepare_template_media_html(f.read(), media_kind="image")
561 html = _render_template_html(
562 raw,
563 _template_values(
564 image_path=image_path,
565 subtitle=subtitle,
566 title=title,
567 template_values=template_values,
568 index=index,
569 ),
570 raw_keys={"media"},
571 )
572
573 try:
574 from playwright.sync_api import sync_playwright
575 except ImportError as exc:
576 raise RuntimeError(
577 "HTML subtitle templates require Playwright. "
578 "Install backend dependencies again or run `pip install playwright`."
579 ) from exc
580
581 os.makedirs(os.path.dirname(output_path), exist_ok=True)
582 with sync_playwright() as playwright:
583 browser = _launch_playwright_chromium(playwright)
584 try:
585 page = browser.new_page(viewport={"width": width, "height": height}, device_scale_factor=1)
586 page.set_content(html, wait_until="networkidle", timeout=30000)
587 page.screenshot(path=output_path, full_page=False, type="jpeg", quality=95)
588 finally:
589 browser.close()
590 logger.info("Rendered HTML template subtitle image: template=%s image=%s -> %s", template_id, image_path, output_path)
591 return output_path
592
593
594 def render_template_media_video(
595 media_video_path: str,
596 output_path: str,
597 *,
598 poster_image_path: str,
599 subtitle: str,
600 title: Optional[str] = None,
601 video_ratio: str = "9:16",
602 template_id: str,
603 template_values: Optional[dict[str, Any]] = None,
604 index: int = 1,
605 duration: Optional[float] = None,
606 fps: int = 24,
607 ) -> str:
608 ffmpeg = shutil.which("ffmpeg")
609 if not ffmpeg:
610 raise RuntimeError("ffmpeg is required to render HTML template videos.")
611 if not os.path.exists(media_video_path):
612 raise FileNotFoundError(f"Template media video not found: {media_video_path}")
613
614 template_path, width, height = _resolve_template_path(template_id, video_ratio)
615 with open(template_path, "r", encoding="utf-8") as f:
616 raw = f.read()
617 if not template_supports_video_media(raw):
618 raise ValueError(f"Template does not support video media: {template_id}")
619
620 raw = _prepare_template_media_html(raw, media_kind="video")
621 html = _render_template_html(
622 raw,
623 _template_values(
624 image_path=poster_image_path,
625 media_kind="image",
626 subtitle=subtitle,
627 title=title,
628 template_values=template_values,
629 index=index,
630 ),
631 raw_keys={"media"},
632 )
633
634 try:
635 from playwright.sync_api import sync_playwright
636 except ImportError as exc:
637 raise RuntimeError(
638 "HTML subtitle templates require Playwright. "
639 "Install backend dependencies again or run `pip install playwright`."
640 ) from exc
641
642 clip_duration = float(duration or media_duration_seconds(media_video_path) or 3.0)
643 frame_count = max(1, int(math.ceil(clip_duration * fps)))
644 os.makedirs(os.path.dirname(output_path), exist_ok=True)
645 frame_dir = tempfile.mkdtemp(prefix="template_frames_", dir=os.path.dirname(output_path))
646 media_frame_dir = tempfile.mkdtemp(prefix="template_media_frames_", dir=os.path.dirname(output_path))
647 frame_pattern = os.path.join(frame_dir, "frame_%05d.jpg")
648 media_frame_pattern = os.path.join(media_frame_dir, "media_%05d.jpg")
649
650 logger.info(
651 "Rendering HTML template video: template=%s media=%s duration=%.2fs fps=%d -> %s",
652 template_id,
653 media_video_path,
654 clip_duration,
655 fps,
656 output_path,
657 )
658 try:
659 extract_cmd = [
660 ffmpeg,
661 "-y",
662 "-i",
663 media_video_path,
664 "-vf",
665 f"fps={fps}",
666 "-q:v",
667 "2",
668 media_frame_pattern,
669 ]
670 subprocess.run(extract_cmd, check=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
671 media_frames = sorted(str(path) for path in Path(media_frame_dir).glob("media_*.jpg"))
672 if not media_frames:
673 media_frames = [poster_image_path]
674
675 with sync_playwright() as playwright:
676 browser = _launch_playwright_chromium(playwright)
677 try:
678 page = browser.new_page(viewport={"width": width, "height": height}, device_scale_factor=1)
679 page.set_content(html, wait_until="networkidle", timeout=30000)
680 for frame_index in range(frame_count):
681 media_frame_uri = _image_data_uri(media_frames[frame_index % len(media_frames)])
682 page.evaluate(
683 """async (src) => {
684 const images = Array.from(document.querySelectorAll('img.template-media'));
685 await Promise.all(images.map(async (image) => {
686 image.src = src;
687 if (image.decode) {
688 await image.decode().catch(() => {});
689 }
690 }));
691 await new Promise(resolve => requestAnimationFrame(resolve));
692 }""",
693 media_frame_uri,
694 )
695 page.screenshot(
696 path=os.path.join(frame_dir, f"frame_{frame_index + 1:05d}.jpg"),
697 full_page=False,
698 type="jpeg",
699 quality=92,
700 )
701 finally:
702 browser.close()
703
704 cmd = [
705 ffmpeg,
706 "-y",
707 "-framerate",
708 str(fps),
709 "-i",
710 frame_pattern,
711 "-t",
712 f"{clip_duration:.3f}",
713 "-vf",
714 "format=yuv420p",
715 "-c:v",
716 "libx264",
717 "-preset",
718 "veryfast",
719 "-crf",
720 "18",
721 "-movflags",
722 "+faststart",
723 output_path,
724 ]
725 subprocess.run(cmd, check=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
726 finally:
727 shutil.rmtree(frame_dir, ignore_errors=True)
728 shutil.rmtree(media_frame_dir, ignore_errors=True)
729 return output_path
730
731
732 def concat_audios(audio_paths: Iterable[str], output_path: str) -> Optional[str]:
733 paths = [path for path in audio_paths if path and os.path.exists(path)]
734 if not paths:
735 return None
736 if len(paths) == 1:
737 return paths[0]
738
739 ffmpeg = shutil.which("ffmpeg")
740 if not ffmpeg:
741 logger.warning("ffmpeg not found; cannot concatenate audios")
742 return None
743
744 list_path = os.path.join(os.path.dirname(output_path), "concat_audio.txt")
745 with open(list_path, "w", encoding="utf-8") as f:
746 for path in paths:
747 f.write(f"file '{os.path.abspath(path)}'\n")
748
749 cmd = [ffmpeg, "-y", "-f", "concat", "-safe", "0", "-i", list_path, "-c", "copy", output_path]
750 logger.info("Concatenating %d audios -> %s", len(paths), output_path)
751 subprocess.run(cmd, check=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
752 return output_path
753
754
755 def replace_video_audio(video_path: str, audio_path: str, output_path: str) -> str:
756 ffmpeg = shutil.which("ffmpeg")
757 if not ffmpeg:
758 raise RuntimeError("ffmpeg is required to replace digital-human video audio.")
759 if not os.path.exists(video_path):
760 raise FileNotFoundError(f"Video not found: {video_path}")
761 if not os.path.exists(audio_path):
762 raise FileNotFoundError(f"Audio not found: {audio_path}")
763
764 os.makedirs(os.path.dirname(output_path), exist_ok=True)
765 logger.info("Replacing video audio: video=%s audio=%s -> %s", video_path, audio_path, output_path)
766 cmd = [
767 ffmpeg,
768 "-y",
769 "-i",
770 video_path,
771 "-i",
772 audio_path,
773 "-map",
774 "0:v:0",
775 "-map",
776 "1:a:0",
777 "-c:v",
778 "copy",
779 "-c:a",
780 "aac",
781 "-shortest",
782 output_path,
783 ]
784 subprocess.run(cmd, check=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
785 return output_path
786
787
788 def media_duration_seconds(path: str) -> Optional[float]:
789 ffprobe = shutil.which("ffprobe")
790 if not ffprobe or not os.path.exists(path):
791 return None
792
793 cmd = [
794 ffprobe,
795 "-v",
796 "error",
797 "-show_entries",
798 "format=duration",
799 "-of",
800 "default=noprint_wrappers=1:nokey=1",
801 path,
802 ]
803 try:
804 result = subprocess.run(cmd, check=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True)
805 duration = float(result.stdout.strip())
806 logger.debug("Media duration: %s = %.3fs", path, duration)
807 return duration
808 except Exception as exc:
809 logger.warning("Failed to probe media duration: %s (%s)", path, exc)
810 return None
811
812
813 def _resolution_from_ratio(video_ratio: str) -> tuple[int, int]:
814 ratio = (video_ratio or "9:16").strip()
815 if ratio in {"16:9", "landscape"}:
816 return 1920, 1080
817 if ratio in {"1:1", "square"}:
818 return 1080, 1080
819 return 1080, 1920
820
821
822 def _ass_time(seconds: float) -> str:
823 total_centiseconds = max(0, int(round(seconds * 100)))
824 centiseconds = total_centiseconds % 100
825 total_seconds = total_centiseconds // 100
826 hours = total_seconds // 3600
827 minutes = (total_seconds % 3600) // 60
828 secs = total_seconds % 60
829 return f"{hours}:{minutes:02d}:{secs:02d}.{centiseconds:02d}"
830
831
832 def _ass_text(text: str) -> str:
833 cleaned = str(text or "").strip()
834 cleaned = cleaned.replace("{", "{").replace("}", "}")
835 return cleaned.replace("\r\n", "\\N").replace("\n", "\\N")
836
837
838 def write_ass_subtitles(
839 path: str,
840 *,
841 subtitles: Iterable[tuple[str, float]],
842 title: Optional[str] = None,
843 video_ratio: str = "9:16",
844 ) -> str:
845 width, height = _resolution_from_ratio(video_ratio)
846 title_size = max(36, int(height * 0.038))
847 subtitle_size = max(34, int(height * 0.032))
848 title_margin_v = max(70, int(height * 0.06))
849 subtitle_margin_v = max(80, int(height * 0.085))
850 subtitle_margin_h = max(70, int(width * 0.07))
851
852 events: list[str] = []
853 cursor = 0.0
854 normalized: list[tuple[str, float, float]] = []
855 for text, duration in subtitles:
856 duration_seconds = max(0.1, float(duration or 0))
857 start = cursor
858 end = cursor + duration_seconds
859 normalized.append((text, start, end))
860 cursor = end
861
862 total_duration = cursor or 0.1
863 if title:
864 events.append(
865 f"Dialogue: 0,{_ass_time(0)},{_ass_time(total_duration)},Title,,0,0,0,,{_ass_text(title)}"
866 )
867 for text, start, end in normalized:
868 events.append(
869 f"Dialogue: 0,{_ass_time(start)},{_ass_time(end)},Subtitle,,0,0,0,,{_ass_text(text)}"
870 )
871
872 content = "\n".join(
873 [
874 "[Script Info]",
875 "ScriptType: v4.00+",
876 "Collisions: Normal",
877 f"PlayResX: {width}",
878 f"PlayResY: {height}",
879 "WrapStyle: 0",
880 "ScaledBorderAndShadow: yes",
881 "",
882 "[V4+ Styles]",
883 "Format: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding",
884 f"Style: Title,Arial,{title_size},&H00FFFFFF,&H000000FF,&H99000000,&H66000000,-1,0,0,0,100,100,0,0,1,4,0,8,{subtitle_margin_h},{subtitle_margin_h},{title_margin_v},1",
885 f"Style: Subtitle,Arial,{subtitle_size},&H00FFFFFF,&H000000FF,&HAA000000,&H66000000,0,0,0,0,100,100,0,0,1,4,0,2,{subtitle_margin_h},{subtitle_margin_h},{subtitle_margin_v},1",
886 "",
887 "[Events]",
888 "Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text",
889 *events,
890 "",
891 ]
892 )
893 logger.info("Writing ASS subtitles: %s lines=%d title=%s", path, len(normalized), bool(title))
894 return write_text(path, content)
895
896
897 def _ffmpeg_filter_path(path: str) -> str:
898 escaped = os.path.abspath(path).replace("\\", "/")
899 return escaped.replace(":", "\\:").replace("'", "\\'")
900
901
902 def _ffmpeg_has_filter(ffmpeg: str, filter_name: str) -> bool:
903 try:
904 result = subprocess.run(
905 [ffmpeg, "-hide_banner", "-filters"],
906 check=True,
907 stdout=subprocess.PIPE,
908 stderr=subprocess.PIPE,
909 text=True,
910 )
911 except Exception as exc:
912 logger.warning("Failed to inspect ffmpeg filters: %s", exc)
913 return False
914 return any(line.split()[1:2] == [filter_name] for line in result.stdout.splitlines() if line.strip())
915
916
917 def burn_ass_subtitles(video_path: str, subtitle_path: str, output_path: str) -> str:
918 ffmpeg = shutil.which("ffmpeg")
919 if not ffmpeg:
920 raise RuntimeError("ffmpeg is required to burn subtitles into video.")
921 if not _ffmpeg_has_filter(ffmpeg, "ass"):
922 raise RuntimeError(
923 "ffmpeg was found, but its libass/ass filter is unavailable. "
924 "Install an ffmpeg build with libass support before burning ASS subtitles."
925 )
926 if not os.path.exists(video_path):
927 raise FileNotFoundError(f"Video not found: {video_path}")
928 if not os.path.exists(subtitle_path):
929 raise FileNotFoundError(f"Subtitle not found: {subtitle_path}")
930
931 os.makedirs(os.path.dirname(output_path), exist_ok=True)
932 logger.info("Burning ASS subtitles: video=%s subtitles=%s -> %s", video_path, subtitle_path, output_path)
933 cmd = [
934 ffmpeg,
935 "-y",
936 "-i",
937 video_path,
938 "-vf",
939 f"ass=filename='{_ffmpeg_filter_path(subtitle_path)}'",
940 "-c:v",
941 "libx264",
942 "-crf",
943 "18",
944 "-preset",
945 "veryfast",
946 "-c:a",
947 "copy",
948 output_path,
949 ]
950 try:
951 subprocess.run(cmd, check=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True)
952 except subprocess.CalledProcessError as exc:
953 logger.error("Failed to burn ASS subtitles: %s", exc.stderr)
954 raise
955 return output_path
956
957
958 def speed_audio_to_duration(audio_path: str, output_path: str, target_seconds: int) -> str:
959 ffmpeg = shutil.which("ffmpeg")
960 if not ffmpeg:
961 raise RuntimeError("ffmpeg is required to speed up long digital-human narration audio.")
962
963 duration = media_duration_seconds(audio_path)
964 if not duration:
965 return audio_path
966 if duration <= target_seconds:
967 return audio_path
968
969 speed = duration / float(target_seconds)
970 filters = []
971 remaining = speed
972 while remaining > 2.0:
973 filters.append("atempo=2.0")
974 remaining /= 2.0
975 filters.append(f"atempo={remaining:.6f}")
976
977 os.makedirs(os.path.dirname(output_path), exist_ok=True)
978 logger.info(
979 "Speeding audio to fit video duration: %s duration=%.2fs target=%ss speed=%.3fx -> %s",
980 audio_path,
981 duration,
982 target_seconds,
983 speed,
984 output_path,
985 )
986 cmd = [
987 ffmpeg,
988 "-y",
989 "-i",
990 audio_path,
991 "-filter:a",
992 ",".join(filters),
993 "-vn",
994 "-acodec",
995 "libmp3lame",
996 output_path,
997 ]
998 subprocess.run(cmd, check=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
999 return output_path
1000
1001
1002 def extract_last_frame(video_path: str, output_path: str) -> str:
1003 ffmpeg = shutil.which("ffmpeg")
1004 if not ffmpeg:
1005 raise RuntimeError("ffmpeg is required to extract the previous video tail frame.")
1006
1007 os.makedirs(os.path.dirname(output_path), exist_ok=True)
1008 logger.info("Extracting tail frame: %s -> %s", video_path, output_path)
1009 cmd = [
1010 ffmpeg,
1011 "-y",
1012 "-sseof",
1013 "-0.1",
1014 "-i",
1015 video_path,
1016 "-frames:v",
1017 "1",
1018 "-q:v",
1019 "2",
1020 output_path,
1021 ]
1022 subprocess.run(cmd, check=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
1023 return output_path
1024
1024 lines PYTHON