Files
workbuddy_skills/oil-motion/scripts/optimize_motion.py
T

858 lines
28 KiB
Python
Raw Normal View History

#!/usr/bin/env python3
"""补帧验收和网页动画资产目标体积压缩工具。"""
from __future__ import annotations
import argparse
import json
import math
import os
import shutil
import subprocess
import tempfile
from collections import Counter
from fractions import Fraction
from pathlib import Path
from typing import Any
import motion_pipeline
import motion_budget
from PIL import Image
MIB = 1024 * 1024
def positive_float(value: str) -> float:
number = float(value)
if number <= 0:
raise argparse.ArgumentTypeError("必须大于 0")
return number
def unit_float(value: str) -> float:
number = float(value)
if not 0 < number <= 1:
raise argparse.ArgumentTypeError("必须位于 0 到 1 之间")
return number
def run(command: list[str]) -> subprocess.CompletedProcess[str]:
return subprocess.run(
command,
check=True,
text=True,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
)
def ensure_output(path: Path, force: bool, directory: bool = False) -> None:
if path.exists():
occupied = path.is_file() or (path.is_dir() and any(path.iterdir()))
if occupied and not force:
raise SystemExit(f"输出已存在:{path}。确认后使用 --force。")
if force:
if path.is_dir():
shutil.rmtree(path)
else:
path.unlink()
if directory:
path.mkdir(parents=True, exist_ok=True)
else:
path.parent.mkdir(parents=True, exist_ok=True)
def write_json(path: Path, value: Any) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(
json.dumps(value, ensure_ascii=False, indent=2) + "\n",
encoding="utf-8",
)
def size_mb(path: Path) -> float:
return path.stat().st_size / MIB
def parse_rate(value: str | None) -> float:
if not value or value == "0/0":
return 0
try:
return float(Fraction(value))
except (ValueError, ZeroDivisionError):
return 0
def video_stream(probe: dict[str, Any]) -> dict[str, Any]:
for stream in probe.get("streams", []):
if stream.get("codec_type") == "video":
return stream
raise SystemExit("输入中没有视频流。")
def audio_stream(probe: dict[str, Any]) -> dict[str, Any] | None:
for stream in probe.get("streams", []):
if stream.get("codec_type") == "audio":
return stream
return None
def warning_counts(report: dict[str, Any]) -> dict[str, int]:
return dict(Counter(item["type"] for item in report["warnings"]))
def artifact_warning_count(report: dict[str, Any]) -> int:
artifact_types = {
"blank",
"canvas-size",
"brightness-jump",
"scale-jump",
"center-jump",
}
return sum(
1 for item in report["warnings"] if item["type"] in artifact_types
)
def analysis_args() -> dict[str, float | int]:
return {
"alpha_threshold": 16,
"duplicate_threshold": 0.004,
"brightness_jump": 0.12,
"scale_jump": 0.08,
"center_jump": 0.08,
}
def make_extract_args(
source: Path,
output: Path,
fps: float,
args: argparse.Namespace,
interpolate: bool,
) -> argparse.Namespace:
return argparse.Namespace(
input=source,
output=output,
fps=fps,
start=args.start,
duration=args.duration,
width=args.width,
height=args.height,
interpolate=interpolate,
key=args.key,
transparent_threshold=args.transparent_threshold,
opaque_threshold=args.opaque_threshold,
force=False,
)
def command_interpolate(args: argparse.Namespace) -> None:
source = args.input.expanduser().resolve()
output = args.output.expanduser().resolve()
if not source.is_file():
raise SystemExit(f"输入视频不存在:{source}")
ensure_output(output, args.force, directory=True)
probe = motion_pipeline.probe_video(source)
stream = video_stream(probe)
source_fps = (
parse_rate(stream.get("avg_frame_rate"))
or parse_rate(stream.get("r_frame_rate"))
or 24
)
if args.fps <= source_fps:
raise SystemExit(
f"目标帧率 {args.fps:g} 必须高于源帧率 {source_fps:g}。"
)
frames = output / "frames"
qa = output / "qa"
qa.mkdir(parents=True, exist_ok=True)
interpolated_extract = motion_pipeline.extract_frames(
make_extract_args(source, frames, args.fps, args, True)
)
thresholds = analysis_args()
interpolated_report = motion_pipeline.analyze_directory(
frames, **thresholds
)
motion_pipeline.write_json(
qa / "analysis-interpolated.json", interpolated_report
)
motion_pipeline.create_contact_sheet(
frames,
qa / "contact-sheet-interpolated.jpg",
args.contact_columns,
args.thumb_width,
)
with tempfile.TemporaryDirectory(prefix="oil-motion-original-") as temp:
original_frames = Path(temp) / "frames"
original_extract = motion_pipeline.extract_frames(
make_extract_args(
source,
original_frames,
source_fps,
args,
False,
)
)
original_report = motion_pipeline.analyze_directory(
original_frames, **thresholds
)
motion_pipeline.create_contact_sheet(
original_frames,
qa / "contact-sheet-original.jpg",
args.contact_columns,
args.thumb_width,
)
original_artifacts = artifact_warning_count(original_report)
interpolated_artifacts = artifact_warning_count(interpolated_report)
original_rate = original_artifacts / max(1, original_report["frameCount"])
interpolated_rate = interpolated_artifacts / max(
1, interpolated_report["frameCount"]
)
passed = interpolated_rate <= original_rate + args.warning_rate_tolerance
report = {
"type": "motion-interpolation-report",
"source": str(source),
"sourceFps": source_fps,
"targetFps": args.fps,
"original": {
"extraction": original_extract,
"summary": original_report["summary"],
"warningsByType": warning_counts(original_report),
"artifactWarningRate": original_rate,
},
"interpolated": {
"extraction": interpolated_extract,
"summary": interpolated_report["summary"],
"warningsByType": warning_counts(interpolated_report),
"artifactWarningRate": interpolated_rate,
},
"verdict": {
"passedAutomaticChecks": passed,
"warningRateTolerance": args.warning_rate_tolerance,
"manualReviewRequired": True,
"note": (
"自动检查只覆盖空帧、亮度、大小和中心跳变;"
"仍需查看两张接触表确认重影、肢体扭曲和语义错误。"
),
},
}
write_json(output / "interpolation-report.json", report)
print(json.dumps(report["verdict"], ensure_ascii=False, indent=2))
print(f"补帧序列:{frames}")
print(f"对比报告:{output / 'interpolation-report.json'}")
def render_atlas_candidate(
source: Path,
directory: Path,
columns: int | None,
width: int,
height: int,
quality: int,
max_texture: int,
) -> tuple[Path, dict[str, Any]]:
output = directory / f"atlas-{width}x{height}-q{quality}.webp"
manifest_path = output.with_suffix(".json")
manifest = motion_pipeline.create_atlas(
source,
output,
manifest_path,
columns,
width,
height,
quality,
False,
max_texture,
)
return output, manifest
def command_atlas(args: argparse.Namespace) -> None:
source = args.input.expanduser().resolve()
output = args.output.expanduser().resolve()
if not source.is_dir() or not motion_pipeline.frame_files(source):
raise SystemExit(f"找不到帧序列:{source}")
if output.suffix.lower() != ".webp":
raise SystemExit("图集输出必须使用 .webp。")
ensure_output(output, args.force)
report_path = (
args.report.expanduser().resolve()
if args.report
else output.with_suffix(".optimize.json")
)
if report_path.exists() and not args.force:
raise SystemExit(f"报告已存在:{report_path}。确认后使用 --force。")
display_width, display_height = args.display
required_width = math.ceil(display_width * args.dpr)
required_height = math.ceil(display_height * args.dpr)
if (
args.cell_width < required_width
or args.cell_height < required_height
):
raise SystemExit(
"清晰度阻断:初始单帧 "
f"{args.cell_width}×{args.cell_height} px 低于展示所需 "
f"{required_width}×{required_height} px。"
)
frames = motion_pipeline.frame_files(source)
with Image.open(frames[0]) as first_frame:
source_width, source_height = first_frame.size
if source_width < required_width or source_height < required_height:
raise SystemExit(
"清晰度阻断:源帧 "
f"{source_width}×{source_height} px 低于展示所需 "
f"{required_width}×{required_height} px;禁止放大后交付。"
)
target_bytes = int(args.target_mb * MIB)
trials: list[dict[str, Any]] = []
chosen: dict[str, Any] | None = None
best_fallback: dict[str, Any] | None = None
frame_count = len(frames)
clarity_scale = max(
required_width / args.cell_width,
required_height / args.cell_height,
)
minimum_scale = max(args.min_cell_scale, clarity_scale)
required_columns = args.columns
if required_columns is None:
required_columns = max(
1,
math.ceil(
math.sqrt(
frame_count
* (required_height / max(1, required_width))
)
),
)
required_rows = math.ceil(frame_count / required_columns)
if (
required_columns * required_width > args.max_texture
or required_rows * required_height > args.max_texture
):
raise SystemExit(
"清晰度阻断:满足实际展示尺寸时,单张图集至少为 "
f"{required_columns * required_width}×"
f"{required_rows * required_height} px,超过纹理上限 "
f"{args.max_texture}。请重新运行 motion_budget.py,并执行其自动选择结果。"
)
with tempfile.TemporaryDirectory(prefix="oil-motion-atlas-") as temp:
temp_path = Path(temp)
scale = 1.0
while scale + 1e-9 >= minimum_scale:
width = max(required_width, round(args.cell_width * scale))
height = max(required_height, round(args.cell_height * scale))
columns = args.columns
if columns is None:
columns = max(
1,
math.ceil(
math.sqrt(frame_count * (height / max(1, width)))
),
)
rows = math.ceil(frame_count / columns)
if (
columns * width > args.max_texture
or rows * height > args.max_texture
):
trials.append(
{
"cellWidth": width,
"cellHeight": height,
"scale": scale,
"targetMet": False,
"skipped": "texture-limit",
"atlasWidth": columns * width,
"atlasHeight": rows * height,
}
)
scale = round(scale - args.scale_step, 6)
continue
low = args.min_quality
high = args.max_quality
scale_best: dict[str, Any] | None = None
while low <= high:
quality = (low + high) // 2
candidate, manifest = render_atlas_candidate(
source,
temp_path,
columns,
width,
height,
quality,
args.max_texture,
)
candidate_bytes = candidate.stat().st_size
trial = {
"cellWidth": width,
"cellHeight": height,
"scale": scale,
"quality": quality,
"bytes": candidate_bytes,
"sizeMB": candidate_bytes / MIB,
"targetMet": candidate_bytes <= target_bytes,
"path": str(candidate),
"manifest": manifest,
}
trials.append(trial)
if (
best_fallback is None
or trial["bytes"] < best_fallback["bytes"]
):
best_fallback = trial
if candidate_bytes <= target_bytes:
scale_best = trial
low = quality + 1
else:
high = quality - 1
if scale_best is not None:
chosen = scale_best
break
scale = round(scale - args.scale_step, 6)
if chosen is None:
chosen = best_fallback
if chosen is None:
raise SystemExit("没有生成任何图集候选。")
shutil.copy2(chosen["path"], output)
final_manifest = dict(chosen["manifest"])
final_manifest["asset"] = output.name
final_manifest["targetMB"] = args.target_mb
final_manifest["targetMet"] = chosen["targetMet"]
final_manifest["display"] = {
"width": display_width,
"height": display_height,
"dpr": args.dpr,
}
final_manifest["requiredCell"] = {
"width": required_width,
"height": required_height,
}
final_manifest["clarityMet"] = (
chosen["cellWidth"] >= required_width
and chosen["cellHeight"] >= required_height
)
manifest_path = (
args.manifest.expanduser().resolve()
if args.manifest
else output.with_suffix(".json")
)
write_json(manifest_path, final_manifest)
report = {
"type": "atlas-optimization-report",
"source": str(source),
"output": str(output),
"targetMB": args.target_mb,
"resultMB": size_mb(output),
"targetMet": chosen["targetMet"],
"clarityMet": True,
"display": {
"width": display_width,
"height": display_height,
"dpr": args.dpr,
},
"requiredCell": {
"width": required_width,
"height": required_height,
},
"sourceFrame": {
"width": source_width,
"height": source_height,
},
"selected": {
key: chosen[key]
for key in (
"cellWidth",
"cellHeight",
"scale",
"quality",
"bytes",
)
},
"trials": [
{key: value for key, value in trial.items() if key not in {"path", "manifest"}}
for trial in trials
],
}
write_json(report_path, report)
print(json.dumps(report, ensure_ascii=False, indent=2))
def video_filters(args: argparse.Namespace) -> str | None:
filters: list[str] = []
if args.max_width and args.max_height:
filters.append(
"scale="
f"'min(iw,{args.max_width})':'min(ih,{args.max_height})':"
"force_original_aspect_ratio=decrease:"
"force_divisible_by=2"
)
elif args.max_width:
filters.append(f"scale='min(iw,{args.max_width})':-2")
elif args.max_height:
filters.append(f"scale=-2:'min(ih,{args.max_height})'")
if args.fps:
filters.append(f"fps={args.fps}")
return ",".join(filters) or None
def encode_two_pass(
source: Path,
output: Path,
bitrate_kbps: int,
args: argparse.Namespace,
passlog: Path,
) -> None:
ffmpeg = motion_pipeline.require_tool("ffmpeg")
extension = output.suffix.lower()
if extension == ".mp4":
codec = "libx264"
preset_args = ["-preset", args.preset, "-pix_fmt", "yuv420p"]
mux = "mp4"
audio_args = (
["-c:a", "aac", "-b:a", f"{args.audio_kbps}k"]
if args.keep_audio
else ["-an"]
)
final_args = ["-movflags", "+faststart"]
elif extension == ".webm":
codec = "libvpx-vp9"
preset_args = ["-deadline", "good", "-cpu-used", "2"]
mux = "webm"
audio_args = (
["-c:a", "libopus", "-b:a", f"{args.audio_kbps}k"]
if args.keep_audio
else ["-an"]
)
final_args = []
else:
raise SystemExit("视频输出只支持 .mp4 或 .webm。")
common = [
ffmpeg,
"-hide_banner",
"-loglevel",
"error",
"-y",
"-i",
str(source),
]
filters = video_filters(args)
if filters:
common.extend(["-vf", filters])
video_args = [
"-c:v",
codec,
"-b:v",
f"{bitrate_kbps}k",
*preset_args,
"-passlogfile",
str(passlog),
]
run(
[
*common,
*video_args,
"-pass",
"1",
"-an",
"-f",
mux,
os.devnull,
]
)
run(
[
*common,
*video_args,
"-pass",
"2",
*audio_args,
*final_args,
str(output),
]
)
def command_video(args: argparse.Namespace) -> None:
source = args.input.expanduser().resolve()
output = args.output.expanduser().resolve()
if not source.is_file():
raise SystemExit(f"输入视频不存在:{source}")
ensure_output(output, args.force)
report_path = (
args.report.expanduser().resolve()
if args.report
else output.with_suffix(".optimize.json")
)
if report_path.exists() and not args.force:
raise SystemExit(f"报告已存在:{report_path}。确认后使用 --force。")
probe = motion_pipeline.probe_video(source)
stream = video_stream(probe)
source_width = int(stream.get("width") or 0)
source_height = int(stream.get("height") or 0)
source_fps = (
parse_rate(stream.get("avg_frame_rate"))
or parse_rate(stream.get("r_frame_rate"))
or 24
)
display_width, display_height = args.display
required_width = math.ceil(display_width * args.dpr)
required_height = math.ceil(display_height * args.dpr)
if source_width < required_width or source_height < required_height:
raise SystemExit(
"清晰度阻断:源视频 "
f"{source_width}×{source_height} px 低于展示所需 "
f"{required_width}×{required_height} px;禁止放大后交付。"
)
args.max_width = args.max_width or required_width
args.max_height = args.max_height or required_height
if args.max_width < required_width or args.max_height < required_height:
raise SystemExit(
"清晰度阻断:视频输出上限不能低于展示所需 "
f"{required_width}×{required_height} px。"
)
scale = min(
1.0,
args.max_width / source_width,
args.max_height / source_height,
)
encoded_width = max(2, math.floor(source_width * scale / 2) * 2)
encoded_height = max(2, math.floor(source_height * scale / 2) * 2)
if encoded_width < required_width or encoded_height < required_height:
raise SystemExit(
"清晰度阻断:源视频比例放入当前输出框后只能得到 "
f"{encoded_width}×{encoded_height} px,低于展示所需 "
f"{required_width}×{required_height} px。请按实际内容比例填写 "
"--display,或提供更高分辨率源视频。"
)
output_fps = args.fps or source_fps
clarity_kbps = math.ceil(
encoded_width
* encoded_height
* output_fps
* args.min_bpp
/ 1000
)
quality_floor_kbps = max(args.min_video_kbps, clarity_kbps)
duration = float(probe.get("format", {}).get("duration") or 0)
if duration <= 0:
raise SystemExit("无法读取视频时长。")
has_audio = audio_stream(probe) is not None
audio_kbps = args.audio_kbps if args.keep_audio and has_audio else 0
target_bits = args.target_mb * MIB * 8 * 0.97
bitrate_kbps = max(
quality_floor_kbps,
round(target_bits / duration / 1000 - audio_kbps),
)
attempts: list[dict[str, Any]] = []
with tempfile.TemporaryDirectory(prefix="oil-motion-video-") as temp:
passlog = Path(temp) / "pass"
for attempt in range(1, 4):
encode_two_pass(source, output, bitrate_kbps, args, passlog)
actual_mb = size_mb(output)
attempts.append(
{
"attempt": attempt,
"videoKbps": bitrate_kbps,
"sizeMB": actual_mb,
}
)
if actual_mb <= args.target_mb * 1.01:
break
ratio = args.target_mb / actual_mb
bitrate_kbps = max(
quality_floor_kbps,
math.floor(bitrate_kbps * ratio * 0.98),
)
output_probe = motion_pipeline.probe_video(output)
output_stream = video_stream(output_probe)
output_width = int(output_stream.get("width") or 0)
output_height = int(output_stream.get("height") or 0)
result_mb = size_mb(output)
clarity_met = (
output_width >= required_width
and output_height >= required_height
)
report = {
"type": "video-optimization-report",
"source": str(source),
"output": str(output),
"targetMB": args.target_mb,
"resultMB": result_mb,
"targetMet": result_mb <= args.target_mb * 1.01,
"clarityMet": clarity_met,
"display": {
"width": display_width,
"height": display_height,
"dpr": args.dpr,
},
"requiredVideo": {
"width": required_width,
"height": required_height,
},
"encodedVideo": {
"width": output_width,
"height": output_height,
"fps": output_fps,
},
"minBitsPerPixelPerFrame": args.min_bpp,
"qualityFloorKbps": quality_floor_kbps,
"sourceMB": size_mb(source),
"compressionRatio": result_mb / max(size_mb(source), 1e-9),
"audioKept": bool(args.keep_audio and has_audio),
"attempts": attempts,
"sourceProbe": probe,
"outputProbe": output_probe,
}
write_json(report_path, report)
print(
json.dumps(
{
key: report[key]
for key in (
"targetMB",
"resultMB",
"targetMet",
"clarityMet",
"sourceMB",
"compressionRatio",
)
},
ensure_ascii=False,
indent=2,
)
)
def add_extract_options(parser: argparse.ArgumentParser) -> None:
parser.add_argument("--start", type=float)
parser.add_argument("--duration", type=positive_float)
parser.add_argument("--width", type=int)
parser.add_argument("--height", type=int)
parser.add_argument(
"--key",
default="none",
help="'auto'、'none' 或十六进制色键。",
)
parser.add_argument("--transparent-threshold", type=float, default=12)
parser.add_argument("--opaque-threshold", type=float, default=220)
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(description=__doc__)
subparsers = parser.add_subparsers(dest="command", required=True)
interpolate = subparsers.add_parser(
"interpolate",
help="光流补帧,并生成原始/补帧异常对比。",
)
interpolate.add_argument("input", type=Path)
interpolate.add_argument("output", type=Path)
interpolate.add_argument("--fps", type=positive_float, required=True)
add_extract_options(interpolate)
interpolate.add_argument("--warning-rate-tolerance", type=float, default=0.02)
interpolate.add_argument("--contact-columns", type=int, default=8)
interpolate.add_argument("--thumb-width", type=int, default=160)
interpolate.add_argument("--force", action="store_true")
interpolate.set_defaults(function=command_interpolate)
atlas = subparsers.add_parser(
"atlas",
help="按目标体积自动选择图集尺寸和 WebP 质量。",
)
atlas.add_argument("input", type=Path)
atlas.add_argument("--output", type=Path, required=True)
atlas.add_argument("--target-mb", type=positive_float, required=True)
atlas.add_argument(
"--display",
type=motion_budget.parse_size,
required=True,
help="最大实际 CSS 展示尺寸,如 360x360。",
)
atlas.add_argument("--dpr", type=positive_float, default=2.0)
atlas.add_argument("--cell-width", type=int, required=True)
atlas.add_argument("--cell-height", type=int, required=True)
atlas.add_argument("--columns", type=int)
atlas.add_argument("--min-quality", type=int, default=45)
atlas.add_argument("--max-quality", type=int, default=92)
atlas.add_argument("--min-cell-scale", type=unit_float, default=0.6)
atlas.add_argument("--scale-step", type=positive_float, default=0.1)
atlas.add_argument("--max-texture", type=int, default=4096)
atlas.add_argument("--manifest", type=Path)
atlas.add_argument("--report", type=Path)
atlas.add_argument("--force", action="store_true")
atlas.set_defaults(function=command_atlas)
video = subparsers.add_parser(
"video",
help="按目标体积两遍编码 MP4/WebM。",
)
video.add_argument("input", type=Path)
video.add_argument("--output", type=Path, required=True)
video.add_argument("--target-mb", type=positive_float, required=True)
video.add_argument(
"--display",
type=motion_budget.parse_size,
required=True,
help="最大实际 CSS 展示尺寸,如 360x360。",
)
video.add_argument("--dpr", type=positive_float, default=2.0)
video.add_argument("--max-width", type=int)
video.add_argument("--max-height", type=int)
video.add_argument("--fps", type=positive_float)
video.add_argument("--keep-audio", action="store_true")
video.add_argument("--audio-kbps", type=int, default=96)
video.add_argument("--min-video-kbps", type=int, default=80)
video.add_argument(
"--min-bpp",
type=positive_float,
default=0.08,
help="每像素每帧最低码率;目标体积不能突破此清晰度底线。",
)
video.add_argument(
"--preset",
choices=("medium", "slow", "slower"),
default="slow",
)
video.add_argument("--report", type=Path)
video.add_argument("--force", action="store_true")
video.set_defaults(function=command_video)
return parser.parse_args()
def main() -> None:
args = parse_args()
if hasattr(args, "width") and args.width is not None and args.width <= 0:
raise SystemExit("--width 必须大于 0。")
if hasattr(args, "height") and args.height is not None and args.height <= 0:
raise SystemExit("--height 必须大于 0。")
if hasattr(args, "min_quality") and not (
0 <= args.min_quality <= args.max_quality <= 100
):
raise SystemExit("质量范围必须满足 0 <= min <= max <= 100。")
if hasattr(args, "scale_step") and args.scale_step > 1:
raise SystemExit("--scale-step 不能大于 1。")
args.function(args)
if __name__ == "__main__":
main()