Files
guizang-social-card-skill/scripts/make-video-contact-sheet.py
T
郭浩 f492caeb8b v0.15: Live Photo 生产规则 — 平台限制、拼图版式、长视频处理、发布提醒、可见文案规则
新增 Live Photo 正式能力:单视频、二宫格、三宫格、四宫格、三连拼图、
长视频低成本诊断。增加 references/live-photo-production.md 生产规范、
scripts/ 工具链(contact sheet / .pvt 打包 / Apple metadata)、
validator R8 visual bounds + R9 title gap、文档约束测试。

由 Codex 完成主体实现。
2026-07-01 16:11:34 +08:00

84 lines
2.9 KiB
Python

#!/usr/bin/env python3
"""Create a tiled contact sheet from a video for cheap Live Photo QA."""
from __future__ import annotations
import argparse
import json
import math
import subprocess
from pathlib import Path
def run(cmd: list[str]) -> subprocess.CompletedProcess[str]:
return subprocess.run(cmd, check=True, text=True, capture_output=True)
def probe_duration(video: Path) -> float:
result = run(
[
"ffprobe",
"-v",
"error",
"-show_entries",
"format=duration",
"-of",
"json",
str(video),
]
)
data = json.loads(result.stdout)
duration = float(data["format"]["duration"])
if duration <= 0:
raise SystemExit(f"invalid duration for {video}: {duration}")
return duration
def main() -> None:
parser = argparse.ArgumentParser(description="Make a tiled frame strip for video QA.")
parser.add_argument("video", type=Path, help="Input video path.")
parser.add_argument("output", type=Path, help="Output JPG/PNG contact sheet path.")
parser.add_argument("--frames", type=int, default=12, help="Number of frames to sample.")
parser.add_argument("--cols", type=int, default=4, help="Tile columns.")
parser.add_argument("--start", type=float, default=0.0, help="Start time in seconds.")
parser.add_argument("--duration", type=float, help="Inspection window duration in seconds.")
parser.add_argument("--thumb-width", type=int, default=320, help="Thumbnail width in pixels.")
parser.add_argument("--padding", type=int, default=12, help="Padding between tiles.")
parser.add_argument("--margin", type=int, default=12, help="Outer tile margin.")
parser.add_argument("--color", default="white", help="Tile background color.")
args = parser.parse_args()
video = args.video.expanduser().resolve()
output = args.output.expanduser().resolve()
if not video.exists():
raise SystemExit(f"missing video: {video}")
if args.frames < 1:
raise SystemExit("--frames must be >= 1")
if args.cols < 1:
raise SystemExit("--cols must be >= 1")
source_duration = probe_duration(video)
window = args.duration if args.duration is not None else max(0.1, source_duration - args.start)
if window <= 0:
raise SystemExit("--duration must be > 0")
output.parent.mkdir(parents=True, exist_ok=True)
rows = math.ceil(args.frames / args.cols)
fps_expr = f"{args.frames}/{window:.6f}"
vf = (
f"fps={fps_expr},"
f"scale={args.thumb_width}:-1:flags=lanczos,"
f"tile={args.cols}x{rows}:padding={args.padding}:margin={args.margin}:color={args.color}"
)
cmd = ["ffmpeg", "-y"]
if args.start > 0:
cmd += ["-ss", f"{args.start:.6f}"]
cmd += ["-t", f"{window:.6f}", "-i", str(video), "-vf", vf, "-frames:v", "1", str(output)]
run(cmd)
print(output)
if __name__ == "__main__":
main()