新增 Live Photo 正式能力:单视频、二宫格、三宫格、四宫格、三连拼图、 长视频低成本诊断。增加 references/live-photo-production.md 生产规范、 scripts/ 工具链(contact sheet / .pvt 打包 / Apple metadata)、 validator R8 visual bounds + R9 title gap、文档约束测试。 由 Codex 完成主体实现。
84 lines
2.9 KiB
Python
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()
|