858 lines
28 KiB
Python
858 lines
28 KiB
Python
#!/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()
|