1201 lines
40 KiB
Python
1201 lines
40 KiB
Python
#!/usr/bin/env python3
|
||||
|
|
"""把动作母版编译为可精确控制的全关键帧 MP4。
|
|||
|
|
|
|||
|
|
适合一维连续时间轴的大尺寸动画,支持逐帧、分段和自主播放,以及两种背景归属:
|
|||
|
|
|
|||
|
|
- `--background-owner page`(chroma 路线):保留均匀色键背景,网页使用
|
|||
|
|
WebGL 实时抠色;编译前逐帧模拟同一套 shader 参数,检查主体内部绿块、边缘溢色
|
|||
|
|
和压缩脏边,并输出多种测试底色上的抠色合成图供验收。
|
|||
|
|
- `--background-owner video`(baked 路线):背景与主体在同一视频中烘焙生成,
|
|||
|
|
不做任何抠色;视频本身就是最终画面。
|
|||
|
|
"""
|
|||
|
|
|
|||
|
|
from __future__ import annotations
|
|||
|
|
|
|||
|
|
import argparse
|
|||
|
|
import bisect
|
|||
|
|
import json
|
|||
|
|
import shutil
|
|||
|
|
import subprocess
|
|||
|
|
import sys
|
|||
|
|
from pathlib import Path
|
|||
|
|
from statistics import median
|
|||
|
|
from typing import Any
|
|||
|
|
|
|||
|
|
from PIL import Image, ImageDraw
|
|||
|
|
|
|||
|
|
from chroma_key import (
|
|||
|
|
ChromaKeyParameters,
|
|||
|
|
analyze_frame,
|
|||
|
|
default_parameters,
|
|||
|
|
key_image,
|
|||
|
|
key_mode,
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
SCRIPT_DIR = Path(__file__).resolve().parent
|
|||
|
|
PIPELINE = SCRIPT_DIR / "motion_pipeline.py"
|
|||
|
|
CLEANUP = SCRIPT_DIR / "loop_cleanup.py"
|
|||
|
|
OPTIMIZE = SCRIPT_DIR / "optimize_motion.py"
|
|||
|
|
|
|||
|
|
POST_ENCODE_LIMITS = {
|
|||
|
|
"keyLikeAlphaP99": 0.01,
|
|||
|
|
"visibleKeyPixelRatio": 0.01,
|
|||
|
|
"opaqueKeyPixelRatio": 0.005,
|
|||
|
|
"edgeKeyDominanceP95": 0.02,
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
|
|||
|
|
def run(command: list[str]) -> None:
|
|||
|
|
print("+ " + " ".join(command), flush=True)
|
|||
|
|
subprocess.run(command, check=True)
|
|||
|
|
|
|||
|
|
|
|||
|
|
def require_command(name: str) -> None:
|
|||
|
|
if shutil.which(name) is None:
|
|||
|
|
raise RuntimeError(f"找不到 {name},请先安装 ffmpeg")
|
|||
|
|
|
|||
|
|
|
|||
|
|
def probe(path: Path) -> dict[str, Any]:
|
|||
|
|
completed = subprocess.run(
|
|||
|
|
[
|
|||
|
|
"ffprobe",
|
|||
|
|
"-v",
|
|||
|
|
"error",
|
|||
|
|
"-show_streams",
|
|||
|
|
"-show_format",
|
|||
|
|
"-of",
|
|||
|
|
"json",
|
|||
|
|
str(path),
|
|||
|
|
],
|
|||
|
|
check=True,
|
|||
|
|
capture_output=True,
|
|||
|
|
text=True,
|
|||
|
|
)
|
|||
|
|
return json.loads(completed.stdout)
|
|||
|
|
|
|||
|
|
|
|||
|
|
def video_stream(report: dict[str, Any]) -> dict[str, Any]:
|
|||
|
|
for stream in report.get("streams", []):
|
|||
|
|
if stream.get("codec_type") == "video":
|
|||
|
|
return stream
|
|||
|
|
raise ValueError("输入文件没有视频流")
|
|||
|
|
|
|||
|
|
|
|||
|
|
def parse_rate(value: str | None) -> float | None:
|
|||
|
|
if not value or value in {"0/0", "N/A"}:
|
|||
|
|
return None
|
|||
|
|
if "/" in value:
|
|||
|
|
numerator, denominator = value.split("/", 1)
|
|||
|
|
denominator_value = float(denominator)
|
|||
|
|
return float(numerator) / denominator_value if denominator_value else None
|
|||
|
|
return float(value)
|
|||
|
|
|
|||
|
|
|
|||
|
|
def parse_segment_specs(values: list[str]) -> list[dict[str, int | str]]:
|
|||
|
|
segments: list[dict[str, int | str]] = []
|
|||
|
|
identifiers: set[str] = set()
|
|||
|
|
for raw in values:
|
|||
|
|
try:
|
|||
|
|
identifier, frames = raw.split("=", 1)
|
|||
|
|
start_text, hold_text, end_text = frames.split(":", 2)
|
|||
|
|
start, hold, end_exclusive = (
|
|||
|
|
int(start_text),
|
|||
|
|
int(hold_text),
|
|||
|
|
int(end_text),
|
|||
|
|
)
|
|||
|
|
except ValueError as error:
|
|||
|
|
raise ValueError(
|
|||
|
|
"--segment 必须写成 DESTINATION_STATE_ID=START:HOLD:END_EXCLUSIVE"
|
|||
|
|
) from error
|
|||
|
|
identifier = identifier.strip()
|
|||
|
|
if not identifier or identifier in identifiers:
|
|||
|
|
raise ValueError(f"片段 ID 为空或重复:{identifier or raw}")
|
|||
|
|
if not 0 <= start <= hold < end_exclusive:
|
|||
|
|
raise ValueError(f"片段 {identifier} 必须满足 START <= HOLD < END_EXCLUSIVE")
|
|||
|
|
identifiers.add(identifier)
|
|||
|
|
segments.append(
|
|||
|
|
{
|
|||
|
|
"id": identifier,
|
|||
|
|
"start": start,
|
|||
|
|
"hold": hold,
|
|||
|
|
"endExclusive": end_exclusive,
|
|||
|
|
}
|
|||
|
|
)
|
|||
|
|
return segments
|
|||
|
|
|
|||
|
|
|
|||
|
|
def build_timeline(
|
|||
|
|
specs: list[dict[str, int | str]],
|
|||
|
|
kept_source_indices: list[int],
|
|||
|
|
raw_count: int,
|
|||
|
|
fps: float,
|
|||
|
|
curve: dict[str, float | str],
|
|||
|
|
initial_state_id: str = "state-0",
|
|||
|
|
) -> dict[str, Any]:
|
|||
|
|
final_count = len(kept_source_indices)
|
|||
|
|
if not specs:
|
|||
|
|
specs = [
|
|||
|
|
{
|
|||
|
|
"id": "state-1",
|
|||
|
|
"start": 0,
|
|||
|
|
"hold": raw_count - 1,
|
|||
|
|
"endExclusive": raw_count,
|
|||
|
|
}
|
|||
|
|
]
|
|||
|
|
initial_state_id = initial_state_id.strip()
|
|||
|
|
if not initial_state_id:
|
|||
|
|
raise ValueError("--initial-state-id 不能为空")
|
|||
|
|
destination_ids = {str(spec["id"]) for spec in specs}
|
|||
|
|
if initial_state_id in destination_ids:
|
|||
|
|
raise ValueError("初始状态 ID 不能与目标状态 ID 重复")
|
|||
|
|
segments: list[dict[str, Any]] = []
|
|||
|
|
states: list[dict[str, Any]] = []
|
|||
|
|
previous_end = 0
|
|||
|
|
previous_state_id = initial_state_id
|
|||
|
|
for spec in specs:
|
|||
|
|
source_start = int(spec["start"])
|
|||
|
|
source_hold = int(spec["hold"])
|
|||
|
|
source_end = int(spec["endExclusive"])
|
|||
|
|
if source_end > raw_count:
|
|||
|
|
raise ValueError(
|
|||
|
|
f"片段 {spec['id']} 的 END_EXCLUSIVE 超出源帧数 {raw_count}"
|
|||
|
|
)
|
|||
|
|
start = bisect.bisect_left(kept_source_indices, source_start)
|
|||
|
|
hold = bisect.bisect_right(kept_source_indices, source_hold) - 1
|
|||
|
|
end_exclusive = bisect.bisect_left(kept_source_indices, source_end)
|
|||
|
|
if not 0 <= start <= hold < end_exclusive <= final_count:
|
|||
|
|
raise ValueError(f"片段 {spec['id']} 在清理后没有有效连续帧")
|
|||
|
|
if start < previous_end:
|
|||
|
|
raise ValueError(f"片段 {spec['id']} 与上一片段重叠")
|
|||
|
|
destination_state_id = str(spec["id"])
|
|||
|
|
if not states:
|
|||
|
|
states.append(
|
|||
|
|
{
|
|||
|
|
"id": initial_state_id,
|
|||
|
|
"frame": start,
|
|||
|
|
"hold": start / fps,
|
|||
|
|
}
|
|||
|
|
)
|
|||
|
|
segments.append(
|
|||
|
|
{
|
|||
|
|
"id": f"{previous_state_id}->{destination_state_id}",
|
|||
|
|
"from": previous_state_id,
|
|||
|
|
"to": destination_state_id,
|
|||
|
|
"sourceFrames": {
|
|||
|
|
"start": source_start,
|
|||
|
|
"hold": source_hold,
|
|||
|
|
"endExclusive": source_end,
|
|||
|
|
},
|
|||
|
|
"frames": {
|
|||
|
|
"start": start,
|
|||
|
|
"hold": hold,
|
|||
|
|
"endExclusive": end_exclusive,
|
|||
|
|
},
|
|||
|
|
"start": start / fps,
|
|||
|
|
"hold": hold / fps,
|
|||
|
|
"endExclusive": end_exclusive / fps,
|
|||
|
|
"curve": curve,
|
|||
|
|
}
|
|||
|
|
)
|
|||
|
|
states.append(
|
|||
|
|
{
|
|||
|
|
"id": destination_state_id,
|
|||
|
|
"frame": hold,
|
|||
|
|
"hold": hold / fps,
|
|||
|
|
}
|
|||
|
|
)
|
|||
|
|
previous_end = end_exclusive
|
|||
|
|
previous_state_id = destination_state_id
|
|||
|
|
return {
|
|||
|
|
"schemaVersion": 1,
|
|||
|
|
"fps": fps,
|
|||
|
|
"frameDuration": 1 / fps,
|
|||
|
|
"frameCount": final_count,
|
|||
|
|
"initialState": initial_state_id,
|
|||
|
|
"states": states,
|
|||
|
|
"segments": segments,
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
|
|||
|
|
def dimensions_for_width(
|
|||
|
|
requested_width: int,
|
|||
|
|
source_width: int,
|
|||
|
|
source_height: int,
|
|||
|
|
allow_upscale: bool,
|
|||
|
|
) -> tuple[int, int]:
|
|||
|
|
width = requested_width
|
|||
|
|
if width > source_width and not allow_upscale:
|
|||
|
|
width = source_width
|
|||
|
|
print(
|
|||
|
|
f"提示:请求宽度 {requested_width}px 超过母版,自动限制为 {source_width}px",
|
|||
|
|
flush=True,
|
|||
|
|
)
|
|||
|
|
width = max(2, width - width % 2)
|
|||
|
|
height = round(width * source_height / source_width)
|
|||
|
|
height = max(2, height - height % 2)
|
|||
|
|
return width, height
|
|||
|
|
|
|||
|
|
|
|||
|
|
def image_frames(directory: Path) -> list[Path]:
|
|||
|
|
return sorted(directory.glob("frame_*.png"))
|
|||
|
|
|
|||
|
|
|
|||
|
|
def representative_frames(paths: list[Path], limit: int = 48) -> list[Path]:
|
|||
|
|
if limit < 1:
|
|||
|
|
raise ValueError("代表帧数量必须大于 0")
|
|||
|
|
if len(paths) <= limit:
|
|||
|
|
return paths
|
|||
|
|
if limit == 1:
|
|||
|
|
return [paths[0]]
|
|||
|
|
return [
|
|||
|
|
paths[round(index * (len(paths) - 1) / (limit - 1))]
|
|||
|
|
for index in range(limit)
|
|||
|
|
]
|
|||
|
|
|
|||
|
|
|
|||
|
|
def representative_indices(count: int, limit: int = 48) -> list[int]:
|
|||
|
|
if count < 1:
|
|||
|
|
raise ValueError("帧数必须大于 0")
|
|||
|
|
if limit < 1:
|
|||
|
|
raise ValueError("代表帧数量必须大于 0")
|
|||
|
|
if count <= limit:
|
|||
|
|
return list(range(count))
|
|||
|
|
if limit == 1:
|
|||
|
|
return [0]
|
|||
|
|
return [
|
|||
|
|
round(index * (count - 1) / (limit - 1))
|
|||
|
|
for index in range(limit)
|
|||
|
|
]
|
|||
|
|
|
|||
|
|
|
|||
|
|
def border_samples(path: Path) -> list[tuple[int, int, int]]:
|
|||
|
|
with Image.open(path) as opened:
|
|||
|
|
image = opened.convert("RGB")
|
|||
|
|
width, height = image.size
|
|||
|
|
band = max(1, min(width, height, 6))
|
|||
|
|
step = max(1, min(width, height) // 256)
|
|||
|
|
pixels = image.load()
|
|||
|
|
samples: list[tuple[int, int, int]] = []
|
|||
|
|
for x in range(0, width, step):
|
|||
|
|
for offset in range(band):
|
|||
|
|
samples.append(pixels[x, offset])
|
|||
|
|
samples.append(pixels[x, height - 1 - offset])
|
|||
|
|
for y in range(0, height, step):
|
|||
|
|
for offset in range(band):
|
|||
|
|
samples.append(pixels[offset, y])
|
|||
|
|
samples.append(pixels[width - 1 - offset, y])
|
|||
|
|
return samples
|
|||
|
|
|
|||
|
|
|
|||
|
|
def sample_key_color(path: Path) -> tuple[int, int, int]:
|
|||
|
|
samples = border_samples(path)
|
|||
|
|
return tuple(
|
|||
|
|
int(round(median(sample[channel] for sample in samples)))
|
|||
|
|
for channel in range(3)
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
|
|||
|
|
def sample_key_color_many(paths: list[Path]) -> tuple[int, int, int]:
|
|||
|
|
if not paths:
|
|||
|
|
raise ValueError("没有可用于采样色键的帧")
|
|||
|
|
per_frame = [sample_key_color(path) for path in paths]
|
|||
|
|
return tuple(
|
|||
|
|
int(round(median(color[channel] for color in per_frame)))
|
|||
|
|
for channel in range(3)
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
|
|||
|
|
def key_color_hex(key: tuple[int, int, int]) -> str:
|
|||
|
|
return f"#{key[0]:02X}{key[1]:02X}{key[2]:02X}"
|
|||
|
|
|
|||
|
|
|
|||
|
|
def validate_key_source(
|
|||
|
|
paths: list[Path],
|
|||
|
|
key: tuple[int, int, int],
|
|||
|
|
) -> dict[str, object]:
|
|||
|
|
try:
|
|||
|
|
kind = key_mode(key)
|
|||
|
|
except ValueError as error:
|
|||
|
|
raise ValueError("母版边缘不是可识别的绿色或洋红色键背景") from error
|
|||
|
|
checked = representative_frames(paths)
|
|||
|
|
worst_spread = 0
|
|||
|
|
for path in checked:
|
|||
|
|
spreads = sorted(
|
|||
|
|
max(abs(color[channel] - key[channel]) for channel in range(3))
|
|||
|
|
for color in border_samples(path)
|
|||
|
|
)
|
|||
|
|
spread_p95 = spreads[round((len(spreads) - 1) * 0.95)]
|
|||
|
|
worst_spread = max(worst_spread, spread_p95)
|
|||
|
|
if spread_p95 > 32:
|
|||
|
|
raise ValueError(
|
|||
|
|
f"母版色键边缘不均匀:{path.name} 的 95% 色差范围为 "
|
|||
|
|
f"{spread_p95},上限 32"
|
|||
|
|
)
|
|||
|
|
return {
|
|||
|
|
"kind": kind,
|
|||
|
|
"borderSpreadP95Max": worst_spread,
|
|||
|
|
"checkedFrames": len(checked),
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
|
|||
|
|
def decode_video_frames(
|
|||
|
|
video: Path,
|
|||
|
|
output: Path,
|
|||
|
|
indices: list[int],
|
|||
|
|
) -> dict[int, Path]:
|
|||
|
|
if not indices:
|
|||
|
|
raise ValueError("至少需要解码一帧")
|
|||
|
|
output.mkdir(parents=True, exist_ok=True)
|
|||
|
|
expression = "+".join(f"eq(n\\,{index})" for index in indices)
|
|||
|
|
run(
|
|||
|
|
[
|
|||
|
|
"ffmpeg",
|
|||
|
|
"-hide_banner",
|
|||
|
|
"-loglevel",
|
|||
|
|
"warning",
|
|||
|
|
"-y",
|
|||
|
|
"-i",
|
|||
|
|
str(video),
|
|||
|
|
"-vf",
|
|||
|
|
f"select={expression}",
|
|||
|
|
"-fps_mode",
|
|||
|
|
"vfr",
|
|||
|
|
str(output / "frame_%05d.png"),
|
|||
|
|
]
|
|||
|
|
)
|
|||
|
|
decoded = image_frames(output)
|
|||
|
|
if len(decoded) != len(indices):
|
|||
|
|
raise RuntimeError(
|
|||
|
|
f"编码后抽帧数量错误:期望 {len(indices)},实际 {len(decoded)}"
|
|||
|
|
)
|
|||
|
|
return dict(zip(indices, decoded, strict=True))
|
|||
|
|
|
|||
|
|
|
|||
|
|
def parse_anchors(values: list[str]) -> dict[str, int]:
|
|||
|
|
anchors: dict[str, int] = {}
|
|||
|
|
for value in values:
|
|||
|
|
if "=" not in value:
|
|||
|
|
raise ValueError("--anchor 格式必须是 NAME=SOURCE_FRAME")
|
|||
|
|
name, raw_frame = value.split("=", 1)
|
|||
|
|
name = name.strip()
|
|||
|
|
if not name or not name.replace("-", "_").isidentifier():
|
|||
|
|
raise ValueError(f"无效锚点名称:{name or value}")
|
|||
|
|
if name in anchors:
|
|||
|
|
raise ValueError(f"锚点名称重复:{name}")
|
|||
|
|
try:
|
|||
|
|
source_frame = int(raw_frame)
|
|||
|
|
except ValueError as error:
|
|||
|
|
raise ValueError(f"锚点帧必须是整数:{value}") from error
|
|||
|
|
if source_frame < 0:
|
|||
|
|
raise ValueError(f"锚点帧不能小于 0:{value}")
|
|||
|
|
anchors[name] = source_frame
|
|||
|
|
return anchors
|
|||
|
|
|
|||
|
|
|
|||
|
|
def map_source_frame(source_frame: int, kept_source_indices: list[int]) -> int:
|
|||
|
|
if not kept_source_indices:
|
|||
|
|
raise ValueError("清理报告没有保留帧")
|
|||
|
|
return min(
|
|||
|
|
range(len(kept_source_indices)),
|
|||
|
|
key=lambda index: (abs(kept_source_indices[index] - source_frame), index),
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
|
|||
|
|
def create_background_matrix(alpha_frames: list[Path], output: Path) -> None:
|
|||
|
|
selected = representative_frames(alpha_frames, limit=6)
|
|||
|
|
if not selected:
|
|||
|
|
raise ValueError("没有可生成背景验收矩阵的 Alpha 帧")
|
|||
|
|
thumb_width = 160
|
|||
|
|
with Image.open(selected[0]) as opened:
|
|||
|
|
thumb_height = max(1, round(thumb_width * opened.height / opened.width))
|
|||
|
|
backgrounds = [
|
|||
|
|
(255, 255, 255),
|
|||
|
|
(8, 8, 10),
|
|||
|
|
(245, 24, 88),
|
|||
|
|
(0, 112, 255),
|
|||
|
|
]
|
|||
|
|
gutter = 8
|
|||
|
|
label_height = 18
|
|||
|
|
width = gutter + len(selected) * (thumb_width + gutter)
|
|||
|
|
height = gutter + len(backgrounds) * (thumb_height + label_height + gutter)
|
|||
|
|
sheet = Image.new("RGB", (width, height), (32, 32, 34))
|
|||
|
|
draw = ImageDraw.Draw(sheet)
|
|||
|
|
for row, background_color in enumerate(backgrounds):
|
|||
|
|
y = gutter + row * (thumb_height + label_height + gutter)
|
|||
|
|
draw.text((gutter, y + thumb_height + 2), f"BG {row + 1}", fill=(230, 230, 230))
|
|||
|
|
for column, path in enumerate(selected):
|
|||
|
|
with Image.open(path) as opened:
|
|||
|
|
foreground = opened.convert("RGBA")
|
|||
|
|
foreground.thumbnail(
|
|||
|
|
(thumb_width, thumb_height),
|
|||
|
|
Image.Resampling.LANCZOS,
|
|||
|
|
)
|
|||
|
|
background = Image.new(
|
|||
|
|
"RGBA",
|
|||
|
|
(thumb_width, thumb_height),
|
|||
|
|
(*background_color, 255),
|
|||
|
|
)
|
|||
|
|
x = gutter + column * (thumb_width + gutter)
|
|||
|
|
paste_x = (thumb_width - foreground.width) // 2
|
|||
|
|
paste_y = (thumb_height - foreground.height) // 2
|
|||
|
|
background.alpha_composite(foreground, (paste_x, paste_y))
|
|||
|
|
sheet.paste(background.convert("RGB"), (x, y))
|
|||
|
|
output.parent.mkdir(parents=True, exist_ok=True)
|
|||
|
|
sheet.save(output, quality=90)
|
|||
|
|
|
|||
|
|
|
|||
|
|
def analyze_encoded_frames(
|
|||
|
|
name: str,
|
|||
|
|
decoded: dict[int, Path],
|
|||
|
|
alpha_output: Path,
|
|||
|
|
parameters: ChromaKeyParameters,
|
|||
|
|
qa: Path,
|
|||
|
|
contact_columns: int,
|
|||
|
|
) -> dict[str, Any]:
|
|||
|
|
alpha_output.mkdir(parents=True, exist_ok=True)
|
|||
|
|
records: list[dict[str, Any]] = []
|
|||
|
|
for sequence, (frame_index, source) in enumerate(decoded.items(), start=1):
|
|||
|
|
alpha_path = alpha_output / f"frame_{sequence:05d}.png"
|
|||
|
|
key_image(source, alpha_path, parameters)
|
|||
|
|
records.append(
|
|||
|
|
{
|
|||
|
|
"frame": frame_index,
|
|||
|
|
"file": source.name,
|
|||
|
|
**analyze_frame(source, parameters),
|
|||
|
|
}
|
|||
|
|
)
|
|||
|
|
maxima = {
|
|||
|
|
metric: max(float(record[metric]) for record in records)
|
|||
|
|
for metric in POST_ENCODE_LIMITS
|
|||
|
|
}
|
|||
|
|
violations = [
|
|||
|
|
{
|
|||
|
|
"metric": metric,
|
|||
|
|
"actual": maxima[metric],
|
|||
|
|
"limit": limit,
|
|||
|
|
}
|
|||
|
|
for metric, limit in POST_ENCODE_LIMITS.items()
|
|||
|
|
if maxima[metric] > limit
|
|||
|
|
]
|
|||
|
|
run(
|
|||
|
|
[
|
|||
|
|
sys.executable,
|
|||
|
|
str(PIPELINE),
|
|||
|
|
"contact",
|
|||
|
|
str(alpha_output),
|
|||
|
|
"--output",
|
|||
|
|
str(qa / f"{name}-alpha-contact.jpg"),
|
|||
|
|
"--columns",
|
|||
|
|
str(contact_columns),
|
|||
|
|
]
|
|||
|
|
)
|
|||
|
|
create_background_matrix(
|
|||
|
|
image_frames(alpha_output),
|
|||
|
|
qa / f"{name}-background-matrix.jpg",
|
|||
|
|
)
|
|||
|
|
return {
|
|||
|
|
"passed": not violations,
|
|||
|
|
"checkedFrames": len(records),
|
|||
|
|
"limits": POST_ENCODE_LIMITS,
|
|||
|
|
"maxima": maxima,
|
|||
|
|
"violations": violations,
|
|||
|
|
"frames": records,
|
|||
|
|
}
|
|||
|
|
def load_budget_report(
|
|||
|
|
path: Path,
|
|||
|
|
expected: str = "chroma-video",
|
|||
|
|
) -> dict[str, Any]:
|
|||
|
|
if not path.is_file():
|
|||
|
|
raise FileNotFoundError(f"找不到预算报告:{path}")
|
|||
|
|
report = json.loads(path.read_text(encoding="utf-8"))
|
|||
|
|
delivery = report.get("delivery", {})
|
|||
|
|
if delivery.get("selected") != expected:
|
|||
|
|
raise ValueError(
|
|||
|
|
f"预算报告没有选择 {expected},禁止执行视频编译路线"
|
|||
|
|
)
|
|||
|
|
if not report.get("passes"):
|
|||
|
|
raise ValueError("预算报告存在阻断项,禁止执行视频编译路线")
|
|||
|
|
return report
|
|||
|
|
|
|||
|
|
|
|||
|
|
def require_frame_preparation_pass(path: Path) -> dict[str, Any]:
|
|||
|
|
report = json.loads(path.read_text(encoding="utf-8"))
|
|||
|
|
verdict = report.get("verdict", {})
|
|||
|
|
if not verdict.get("passedAutomaticChecks"):
|
|||
|
|
raise RuntimeError("帧准备自动检查未通过,禁止继续编码视频")
|
|||
|
|
return report
|
|||
|
|
|
|||
|
|
|
|||
|
|
def encode_all_intra(
|
|||
|
|
frames: Path,
|
|||
|
|
output: Path,
|
|||
|
|
fps: float,
|
|||
|
|
width: int,
|
|||
|
|
height: int,
|
|||
|
|
crf: int,
|
|||
|
|
) -> None:
|
|||
|
|
output.parent.mkdir(parents=True, exist_ok=True)
|
|||
|
|
run(
|
|||
|
|
[
|
|||
|
|
"ffmpeg",
|
|||
|
|
"-hide_banner",
|
|||
|
|
"-loglevel",
|
|||
|
|
"warning",
|
|||
|
|
"-y",
|
|||
|
|
"-framerate",
|
|||
|
|
str(fps),
|
|||
|
|
"-start_number",
|
|||
|
|
"1",
|
|||
|
|
"-i",
|
|||
|
|
str(frames / "frame_%05d.png"),
|
|||
|
|
"-vf",
|
|||
|
|
f"scale={width}:{height}:flags=lanczos,setsar=1",
|
|||
|
|
"-c:v",
|
|||
|
|
"libx264",
|
|||
|
|
"-preset",
|
|||
|
|
"slow",
|
|||
|
|
"-crf",
|
|||
|
|
str(crf),
|
|||
|
|
"-g",
|
|||
|
|
"1",
|
|||
|
|
"-keyint_min",
|
|||
|
|
"1",
|
|||
|
|
"-sc_threshold",
|
|||
|
|
"0",
|
|||
|
|
"-pix_fmt",
|
|||
|
|
"yuv420p",
|
|||
|
|
"-an",
|
|||
|
|
"-movflags",
|
|||
|
|
"+faststart",
|
|||
|
|
str(output),
|
|||
|
|
]
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
|
|||
|
|
def all_frames_are_keyframes(path: Path) -> bool:
|
|||
|
|
completed = subprocess.run(
|
|||
|
|
[
|
|||
|
|
"ffprobe",
|
|||
|
|
"-v",
|
|||
|
|
"error",
|
|||
|
|
"-select_streams",
|
|||
|
|
"v:0",
|
|||
|
|
"-show_entries",
|
|||
|
|
"frame=key_frame",
|
|||
|
|
"-of",
|
|||
|
|
"csv=p=0",
|
|||
|
|
str(path),
|
|||
|
|
],
|
|||
|
|
check=True,
|
|||
|
|
capture_output=True,
|
|||
|
|
text=True,
|
|||
|
|
)
|
|||
|
|
values = [
|
|||
|
|
line.strip().split(",", 1)[0]
|
|||
|
|
for line in completed.stdout.splitlines()
|
|||
|
|
if line.strip()
|
|||
|
|
]
|
|||
|
|
return bool(values) and all(value == "1" for value in values)
|
|||
|
|
|
|||
|
|
|
|||
|
|
def safe_prepare_output(output: Path, force: bool) -> None:
|
|||
|
|
output = output.resolve()
|
|||
|
|
if output == Path(output.anchor) or output == Path.home().resolve():
|
|||
|
|
raise ValueError("输出目录不能是磁盘根目录或用户主目录")
|
|||
|
|
if output.exists() and any(output.iterdir()):
|
|||
|
|
if not force:
|
|||
|
|
raise FileExistsError(f"输出目录非空:{output};确认后使用 --force")
|
|||
|
|
for item in output.iterdir():
|
|||
|
|
if item.is_dir() and not item.is_symlink():
|
|||
|
|
shutil.rmtree(item)
|
|||
|
|
else:
|
|||
|
|
item.unlink()
|
|||
|
|
output.mkdir(parents=True, exist_ok=True)
|
|||
|
|
|
|||
|
|
|
|||
|
|
def compile_motion(args: argparse.Namespace) -> int:
|
|||
|
|
require_command("ffmpeg")
|
|||
|
|
require_command("ffprobe")
|
|||
|
|
|
|||
|
|
source = Path(args.source).expanduser().resolve()
|
|||
|
|
output = Path(args.output_dir).expanduser().resolve()
|
|||
|
|
budget_path = Path(args.budget_report).expanduser().resolve()
|
|||
|
|
if not source.is_file():
|
|||
|
|
raise FileNotFoundError(f"找不到视频:{source}")
|
|||
|
|
background_owner = args.background_owner
|
|||
|
|
expected_delivery = (
|
|||
|
|
"baked-video" if background_owner == "video" else "chroma-video"
|
|||
|
|
)
|
|||
|
|
budget_report = load_budget_report(budget_path, expected_delivery)
|
|||
|
|
if args.loop and args.end_reference:
|
|||
|
|
raise ValueError("--loop 与 --end-reference 不能同时使用")
|
|||
|
|
if args.fps is not None and args.fps <= 0:
|
|||
|
|
raise ValueError("--fps 必须大于 0")
|
|||
|
|
if args.desktop_width < 2 or args.mobile_width < 2:
|
|||
|
|
raise ValueError("输出宽度必须至少为 2")
|
|||
|
|
if not 0 <= args.desktop_crf <= 51 or not 0 <= args.mobile_crf <= 51:
|
|||
|
|
raise ValueError("CRF 必须在 0–51 之间")
|
|||
|
|
if args.seam_window < 1:
|
|||
|
|
raise ValueError("--seam-window 必须至少为 1")
|
|||
|
|
if args.duplicate_threshold < 0:
|
|||
|
|
raise ValueError("--duplicate-threshold 不能小于 0")
|
|||
|
|
if args.contact_columns < 1:
|
|||
|
|
raise ValueError("--contact-columns 必须至少为 1")
|
|||
|
|
for name, value in (
|
|||
|
|
("--playback-rate", args.playback_rate),
|
|||
|
|
("--edge-rate", args.edge_rate),
|
|||
|
|
("--mid-rate", args.mid_rate),
|
|||
|
|
):
|
|||
|
|
if value <= 0:
|
|||
|
|
raise ValueError(f"{name} 必须大于 0")
|
|||
|
|
if args.poster_source_frame < 0:
|
|||
|
|
raise ValueError("--poster-source-frame 不能小于 0")
|
|||
|
|
anchor_sources = parse_anchors(args.anchor)
|
|||
|
|
segment_specs = parse_segment_specs(args.segment)
|
|||
|
|
safe_prepare_output(output, args.force)
|
|||
|
|
|
|||
|
|
source_probe = probe(source)
|
|||
|
|
source_video = video_stream(source_probe)
|
|||
|
|
source_width = int(source_video["width"])
|
|||
|
|
source_height = int(source_video["height"])
|
|||
|
|
source_fps = (
|
|||
|
|
parse_rate(source_video.get("avg_frame_rate"))
|
|||
|
|
or parse_rate(source_video.get("r_frame_rate"))
|
|||
|
|
or 24.0
|
|||
|
|
)
|
|||
|
|
if args.frame_policy == "native":
|
|||
|
|
if args.fps is not None and abs(args.fps - source_fps) > 0.01:
|
|||
|
|
raise ValueError("frame-policy=native 时 --fps 必须省略或等于源帧率")
|
|||
|
|
args.fps = source_fps
|
|||
|
|
else:
|
|||
|
|
args.fps = args.fps or 48.0
|
|||
|
|
if args.fps <= source_fps:
|
|||
|
|
raise ValueError("frame-policy=interpolate 时 --fps 必须高于源帧率")
|
|||
|
|
|
|||
|
|
preparation = output / "frame-preparation"
|
|||
|
|
raw_frames = preparation / "frames"
|
|||
|
|
cleaned_frames = output / "frames" / "final"
|
|||
|
|
post_encode_frames = output / "post-encode-frames"
|
|||
|
|
qa = output / "qa"
|
|||
|
|
final = output / "final"
|
|||
|
|
qa.mkdir(parents=True, exist_ok=True)
|
|||
|
|
final.mkdir(parents=True, exist_ok=True)
|
|||
|
|
|
|||
|
|
if args.frame_policy == "interpolate":
|
|||
|
|
run(
|
|||
|
|
[
|
|||
|
|
sys.executable,
|
|||
|
|
str(OPTIMIZE),
|
|||
|
|
"interpolate",
|
|||
|
|
str(source),
|
|||
|
|
str(preparation),
|
|||
|
|
"--fps",
|
|||
|
|
str(args.fps),
|
|||
|
|
"--key",
|
|||
|
|
"none",
|
|||
|
|
]
|
|||
|
|
)
|
|||
|
|
frame_report_path = preparation / "interpolation-report.json"
|
|||
|
|
else:
|
|||
|
|
run(
|
|||
|
|
[
|
|||
|
|
sys.executable,
|
|||
|
|
str(PIPELINE),
|
|||
|
|
"extract",
|
|||
|
|
str(source),
|
|||
|
|
str(raw_frames),
|
|||
|
|
"--fps",
|
|||
|
|
str(args.fps),
|
|||
|
|
"--key",
|
|||
|
|
"none",
|
|||
|
|
]
|
|||
|
|
)
|
|||
|
|
native_qa = preparation / "qa"
|
|||
|
|
native_qa.mkdir(parents=True, exist_ok=True)
|
|||
|
|
analysis_path = native_qa / "analysis-native.json"
|
|||
|
|
run(
|
|||
|
|
[
|
|||
|
|
sys.executable,
|
|||
|
|
str(PIPELINE),
|
|||
|
|
"analyze",
|
|||
|
|
str(raw_frames),
|
|||
|
|
"--output",
|
|||
|
|
str(analysis_path),
|
|||
|
|
]
|
|||
|
|
)
|
|||
|
|
run(
|
|||
|
|
[
|
|||
|
|
sys.executable,
|
|||
|
|
str(PIPELINE),
|
|||
|
|
"contact",
|
|||
|
|
str(raw_frames),
|
|||
|
|
"--output",
|
|||
|
|
str(native_qa / "contact-sheet-native.jpg"),
|
|||
|
|
"--columns",
|
|||
|
|
str(args.contact_columns),
|
|||
|
|
]
|
|||
|
|
)
|
|||
|
|
frame_report_path = preparation / "frame-preparation-report.json"
|
|||
|
|
frame_report_path.write_text(
|
|||
|
|
json.dumps(
|
|||
|
|
{
|
|||
|
|
"type": "motion-frame-preparation-report",
|
|||
|
|
"policy": "native",
|
|||
|
|
"sourceFps": source_fps,
|
|||
|
|
"targetFps": args.fps,
|
|||
|
|
"analysis": str(analysis_path),
|
|||
|
|
"verdict": {
|
|||
|
|
"passedAutomaticChecks": True,
|
|||
|
|
"manualReviewRequired": True,
|
|||
|
|
},
|
|||
|
|
},
|
|||
|
|
ensure_ascii=False,
|
|||
|
|
indent=2,
|
|||
|
|
),
|
|||
|
|
encoding="utf-8",
|
|||
|
|
)
|
|||
|
|
frame_report = require_frame_preparation_pass(frame_report_path)
|
|||
|
|
raw_count = len(image_frames(raw_frames))
|
|||
|
|
if raw_count < 3:
|
|||
|
|
raise RuntimeError("母版切帧后少于 3 帧")
|
|||
|
|
requested_source_frames = {
|
|||
|
|
"poster": args.poster_source_frame,
|
|||
|
|
**anchor_sources,
|
|||
|
|
}
|
|||
|
|
for name, source_frame in requested_source_frames.items():
|
|||
|
|
if source_frame >= raw_count:
|
|||
|
|
raise ValueError(
|
|||
|
|
f"{name} 源帧 {source_frame} 超出插帧范围 0..{raw_count - 1}"
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
cleanup_report_path = qa / "cleanup.json"
|
|||
|
|
if args.loop or args.end_reference:
|
|||
|
|
cleanup_command = [
|
|||
|
|
sys.executable,
|
|||
|
|
str(CLEANUP),
|
|||
|
|
str(raw_frames),
|
|||
|
|
str(cleaned_frames),
|
|||
|
|
"--seam-window",
|
|||
|
|
str(args.seam_window),
|
|||
|
|
"--duplicate-threshold",
|
|||
|
|
str(args.duplicate_threshold),
|
|||
|
|
"--report",
|
|||
|
|
str(cleanup_report_path),
|
|||
|
|
]
|
|||
|
|
if args.end_reference:
|
|||
|
|
cleanup_command.extend(
|
|||
|
|
[
|
|||
|
|
"--end-reference",
|
|||
|
|
str(Path(args.end_reference).expanduser().resolve()),
|
|||
|
|
]
|
|||
|
|
)
|
|||
|
|
run(cleanup_command)
|
|||
|
|
final_frames = cleaned_frames
|
|||
|
|
cleanup_data = json.loads(cleanup_report_path.read_text(encoding="utf-8"))
|
|||
|
|
kept_source_indices = [
|
|||
|
|
int(index) for index in cleanup_data["keptSourceIndices"]
|
|||
|
|
]
|
|||
|
|
else:
|
|||
|
|
final_frames = raw_frames
|
|||
|
|
kept_source_indices = list(range(raw_count))
|
|||
|
|
print("提示:未传 --loop 或 --end-reference,使用全部准备帧", flush=True)
|
|||
|
|
|
|||
|
|
final_frame_paths = image_frames(final_frames)
|
|||
|
|
if not final_frame_paths:
|
|||
|
|
raise RuntimeError("清理后没有可编码帧")
|
|||
|
|
final_count = len(final_frame_paths)
|
|||
|
|
if final_count != len(kept_source_indices):
|
|||
|
|
raise RuntimeError("清理报告帧数与最终帧目录不一致")
|
|||
|
|
anchor_manifest = {
|
|||
|
|
name: {
|
|||
|
|
"sourceFrame": source_frame,
|
|||
|
|
"finalFrame": map_source_frame(source_frame, kept_source_indices),
|
|||
|
|
}
|
|||
|
|
for name, source_frame in anchor_sources.items()
|
|||
|
|
}
|
|||
|
|
poster_final_frame = map_source_frame(
|
|||
|
|
args.poster_source_frame,
|
|||
|
|
kept_source_indices,
|
|||
|
|
)
|
|||
|
|
curve = (
|
|||
|
|
{"type": "constant", "rate": args.playback_rate}
|
|||
|
|
if args.playback_curve == "constant"
|
|||
|
|
else {
|
|||
|
|
"type": "edge-mid-edge",
|
|||
|
|
"edgeRate": args.edge_rate,
|
|||
|
|
"midRate": args.mid_rate,
|
|||
|
|
}
|
|||
|
|
)
|
|||
|
|
timeline = build_timeline(
|
|||
|
|
segment_specs,
|
|||
|
|
kept_source_indices,
|
|||
|
|
raw_count,
|
|||
|
|
args.fps,
|
|||
|
|
curve,
|
|||
|
|
args.initial_state_id,
|
|||
|
|
)
|
|||
|
|
timeline_path = Path(args.timeline_output).expanduser().resolve()
|
|||
|
|
if timeline_path.exists() and not args.force:
|
|||
|
|
raise FileExistsError(f"时间轴已存在:{timeline_path};确认后使用 --force")
|
|||
|
|
source_key_color: str | None = None
|
|||
|
|
source_key_validation: dict[str, object] | None = None
|
|||
|
|
if background_owner == "page":
|
|||
|
|
source_key = sample_key_color_many(
|
|||
|
|
representative_frames(final_frame_paths)
|
|||
|
|
)
|
|||
|
|
source_key_color = key_color_hex(source_key)
|
|||
|
|
source_key_validation = validate_key_source(
|
|||
|
|
final_frame_paths,
|
|||
|
|
source_key,
|
|||
|
|
)
|
|||
|
|
else:
|
|||
|
|
shutil.copy2(final_frame_paths[poster_final_frame], final / "poster.png")
|
|||
|
|
run(
|
|||
|
|
[
|
|||
|
|
sys.executable,
|
|||
|
|
str(PIPELINE),
|
|||
|
|
"contact",
|
|||
|
|
str(final_frames),
|
|||
|
|
"--output",
|
|||
|
|
str(qa / "contact-sheet.jpg"),
|
|||
|
|
"--columns",
|
|||
|
|
str(args.contact_columns),
|
|||
|
|
]
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
desktop_size = dimensions_for_width(
|
|||
|
|
args.desktop_width,
|
|||
|
|
source_width,
|
|||
|
|
source_height,
|
|||
|
|
args.allow_upscale,
|
|||
|
|
)
|
|||
|
|
mobile_size = dimensions_for_width(
|
|||
|
|
args.mobile_width,
|
|||
|
|
source_width,
|
|||
|
|
source_height,
|
|||
|
|
args.allow_upscale,
|
|||
|
|
)
|
|||
|
|
asset_kind = "chroma" if background_owner == "page" else "baked"
|
|||
|
|
desktop_output = final / f"motion-{asset_kind}-desktop.mp4"
|
|||
|
|
mobile_output = final / f"motion-{asset_kind}-mobile.mp4"
|
|||
|
|
encode_all_intra(
|
|||
|
|
final_frames,
|
|||
|
|
desktop_output,
|
|||
|
|
args.fps,
|
|||
|
|
desktop_size[0],
|
|||
|
|
desktop_size[1],
|
|||
|
|
args.desktop_crf,
|
|||
|
|
)
|
|||
|
|
encode_all_intra(
|
|||
|
|
final_frames,
|
|||
|
|
mobile_output,
|
|||
|
|
args.fps,
|
|||
|
|
mobile_size[0],
|
|||
|
|
mobile_size[1],
|
|||
|
|
args.mobile_crf,
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
desktop_all_intra = all_frames_are_keyframes(desktop_output)
|
|||
|
|
mobile_all_intra = all_frames_are_keyframes(mobile_output)
|
|||
|
|
if not desktop_all_intra or not mobile_all_intra:
|
|||
|
|
raise RuntimeError("最终 MP4 不是全关键帧编码,禁止交付")
|
|||
|
|
|
|||
|
|
qa_indices: list[int] = []
|
|||
|
|
runtime_key_color: str | None = None
|
|||
|
|
keying_parameters: ChromaKeyParameters | None = None
|
|||
|
|
post_encode_qa_path: Path | None = None
|
|||
|
|
post_encode_qa: dict[str, Any] | None = None
|
|||
|
|
if background_owner == "page":
|
|||
|
|
qa_indices = sorted(
|
|||
|
|
set(representative_indices(final_count) + [poster_final_frame])
|
|||
|
|
)
|
|||
|
|
desktop_decoded = decode_video_frames(
|
|||
|
|
desktop_output,
|
|||
|
|
post_encode_frames / "desktop" / "source",
|
|||
|
|
qa_indices,
|
|||
|
|
)
|
|||
|
|
mobile_decoded = decode_video_frames(
|
|||
|
|
mobile_output,
|
|||
|
|
post_encode_frames / "mobile" / "source",
|
|||
|
|
qa_indices,
|
|||
|
|
)
|
|||
|
|
runtime_key = sample_key_color_many(
|
|||
|
|
list(desktop_decoded.values()) + list(mobile_decoded.values())
|
|||
|
|
)
|
|||
|
|
runtime_key_color = key_color_hex(runtime_key)
|
|||
|
|
keying_parameters = default_parameters(runtime_key)
|
|||
|
|
desktop_keying_qa = analyze_encoded_frames(
|
|||
|
|
"desktop",
|
|||
|
|
desktop_decoded,
|
|||
|
|
post_encode_frames / "desktop" / "alpha",
|
|||
|
|
keying_parameters,
|
|||
|
|
qa,
|
|||
|
|
args.contact_columns,
|
|||
|
|
)
|
|||
|
|
mobile_keying_qa = analyze_encoded_frames(
|
|||
|
|
"mobile",
|
|||
|
|
mobile_decoded,
|
|||
|
|
post_encode_frames / "mobile" / "alpha",
|
|||
|
|
keying_parameters,
|
|||
|
|
qa,
|
|||
|
|
args.contact_columns,
|
|||
|
|
)
|
|||
|
|
post_encode_qa = {
|
|||
|
|
"passed": (
|
|||
|
|
desktop_keying_qa["passed"] and mobile_keying_qa["passed"]
|
|||
|
|
),
|
|||
|
|
"algorithm": keying_parameters.algorithm,
|
|||
|
|
"runtimeKeyColor": runtime_key_color,
|
|||
|
|
"parameters": keying_parameters.manifest(),
|
|||
|
|
"frameIndices": qa_indices,
|
|||
|
|
"desktop": desktop_keying_qa,
|
|||
|
|
"mobile": mobile_keying_qa,
|
|||
|
|
}
|
|||
|
|
post_encode_qa_path = qa / "post-encode-keying.json"
|
|||
|
|
post_encode_qa_path.write_text(
|
|||
|
|
json.dumps(post_encode_qa, ensure_ascii=False, indent=2),
|
|||
|
|
encoding="utf-8",
|
|||
|
|
)
|
|||
|
|
if not post_encode_qa["passed"]:
|
|||
|
|
raise RuntimeError(
|
|||
|
|
f"编码后色键检查未通过,请查看 {post_encode_qa_path}"
|
|||
|
|
)
|
|||
|
|
key_image(
|
|||
|
|
desktop_decoded[poster_final_frame],
|
|||
|
|
final / "poster-alpha.png",
|
|||
|
|
keying_parameters,
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
desktop_probe = probe(desktop_output)
|
|||
|
|
mobile_probe = probe(mobile_output)
|
|||
|
|
manifest = {
|
|||
|
|
"source": {
|
|||
|
|
"path": str(source),
|
|||
|
|
"width": source_width,
|
|||
|
|
"height": source_height,
|
|||
|
|
"probe": source_probe,
|
|||
|
|
},
|
|||
|
|
"compile": {
|
|||
|
|
"backgroundOwner": background_owner,
|
|||
|
|
"framePolicy": args.frame_policy,
|
|||
|
|
"sourceKeyColor": source_key_color,
|
|||
|
|
"runtimeKeyColor": runtime_key_color,
|
|||
|
|
"sourceKeyValidation": source_key_validation,
|
|||
|
|
"fps": args.fps,
|
|||
|
|
"rawFrameCount": raw_count,
|
|||
|
|
"finalFrameCount": final_count,
|
|||
|
|
"alphaQaFrameCount": len(qa_indices),
|
|||
|
|
"duration": final_count / args.fps,
|
|||
|
|
"cleanup": (
|
|||
|
|
"loop"
|
|||
|
|
if args.loop
|
|||
|
|
else "end-reference"
|
|||
|
|
if args.end_reference
|
|||
|
|
else "none"
|
|||
|
|
),
|
|||
|
|
"duplicateThreshold": args.duplicate_threshold,
|
|||
|
|
"seamWindow": args.seam_window,
|
|||
|
|
"framePreparationReport": str(frame_report_path),
|
|||
|
|
"framePreparationVerdict": frame_report["verdict"],
|
|||
|
|
"budgetReport": str(budget_path),
|
|||
|
|
"selection": budget_report["delivery"],
|
|||
|
|
"timeline": str(timeline_path),
|
|||
|
|
"postEncodeKeyingReport": (
|
|||
|
|
str(post_encode_qa_path) if post_encode_qa_path else None
|
|||
|
|
),
|
|||
|
|
"postEncodeKeyingPassed": (
|
|||
|
|
post_encode_qa["passed"] if post_encode_qa else None
|
|||
|
|
),
|
|||
|
|
"intermediateFramesRetained": args.keep_frames,
|
|||
|
|
},
|
|||
|
|
"runtime": {
|
|||
|
|
"type": "chroma-video" if background_owner == "page" else "baked-video",
|
|||
|
|
"frameCount": final_count,
|
|||
|
|
"fps": args.fps,
|
|||
|
|
"keying": keying_parameters.manifest() if keying_parameters else None,
|
|||
|
|
"anchors": anchor_manifest,
|
|||
|
|
"posterFrame": poster_final_frame,
|
|||
|
|
"assets": {
|
|||
|
|
"poster": (
|
|||
|
|
"final/poster-alpha.png"
|
|||
|
|
if background_owner == "page"
|
|||
|
|
else "final/poster.png"
|
|||
|
|
),
|
|||
|
|
"desktop": f"final/motion-{asset_kind}-desktop.mp4",
|
|||
|
|
"mobile": f"final/motion-{asset_kind}-mobile.mp4",
|
|||
|
|
"timeline": str(timeline_path),
|
|||
|
|
},
|
|||
|
|
},
|
|||
|
|
"outputs": {
|
|||
|
|
"poster": {
|
|||
|
|
"path": str(
|
|||
|
|
final / "poster-alpha.png"
|
|||
|
|
if background_owner == "page"
|
|||
|
|
else final / "poster.png"
|
|||
|
|
),
|
|||
|
|
"alpha": background_owner == "page",
|
|||
|
|
"sourceFrame": args.poster_source_frame,
|
|||
|
|
"finalFrame": poster_final_frame,
|
|||
|
|
},
|
|||
|
|
"desktop": {
|
|||
|
|
"path": str(desktop_output),
|
|||
|
|
"width": desktop_size[0],
|
|||
|
|
"height": desktop_size[1],
|
|||
|
|
"bytes": desktop_output.stat().st_size,
|
|||
|
|
"allFramesAreKeyframes": desktop_all_intra,
|
|||
|
|
"probe": desktop_probe,
|
|||
|
|
},
|
|||
|
|
"mobile": {
|
|||
|
|
"path": str(mobile_output),
|
|||
|
|
"width": mobile_size[0],
|
|||
|
|
"height": mobile_size[1],
|
|||
|
|
"bytes": mobile_output.stat().st_size,
|
|||
|
|
"allFramesAreKeyframes": mobile_all_intra,
|
|||
|
|
"probe": mobile_probe,
|
|||
|
|
},
|
|||
|
|
},
|
|||
|
|
}
|
|||
|
|
timeline_path.parent.mkdir(parents=True, exist_ok=True)
|
|||
|
|
timeline_path.write_text(
|
|||
|
|
json.dumps(timeline, ensure_ascii=False, indent=2), encoding="utf-8"
|
|||
|
|
)
|
|||
|
|
manifest_path = output / "compile.json"
|
|||
|
|
manifest_path.write_text(
|
|||
|
|
json.dumps(manifest, ensure_ascii=False, indent=2),
|
|||
|
|
encoding="utf-8",
|
|||
|
|
)
|
|||
|
|
if not args.keep_frames:
|
|||
|
|
for directory in (raw_frames, preparation, cleaned_frames, post_encode_frames):
|
|||
|
|
if directory.is_dir():
|
|||
|
|
shutil.rmtree(directory)
|
|||
|
|
frames_root = output / "frames"
|
|||
|
|
if frames_root.is_dir() and not any(frames_root.iterdir()):
|
|||
|
|
frames_root.rmdir()
|
|||
|
|
print(f"编译完成:{manifest_path}", flush=True)
|
|||
|
|
return 0
|
|||
|
|
|
|||
|
|
|
|||
|
|
def parser() -> argparse.ArgumentParser:
|
|||
|
|
result = argparse.ArgumentParser(
|
|||
|
|
description=(
|
|||
|
|
"按帧策略准备动作母版,并编译为桌面与移动端全关键帧视频;"
|
|||
|
|
"chroma 路线供 WebGL 实时抠色,baked 路线直接呈现烘焙场景"
|
|||
|
|
)
|
|||
|
|
)
|
|||
|
|
result.add_argument(
|
|||
|
|
"source",
|
|||
|
|
help="MiniMax 生成的动作母版 MP4(chroma 路线必须为均匀色键背景)",
|
|||
|
|
)
|
|||
|
|
result.add_argument("output_dir", help="新的构建目录")
|
|||
|
|
result.add_argument(
|
|||
|
|
"--background-owner",
|
|||
|
|
choices=("page", "video"),
|
|||
|
|
required=True,
|
|||
|
|
help=(
|
|||
|
|
"page:色键母版,编译前逐帧模拟运行时抠色并检查残留,供 WebGL 实时抠色;"
|
|||
|
|
"video:背景已烘焙进视频,不做抠色。必须显式传入,禁止静默回退绿幕"
|
|||
|
|
),
|
|||
|
|
)
|
|||
|
|
result.add_argument(
|
|||
|
|
"--budget-report",
|
|||
|
|
required=True,
|
|||
|
|
help=(
|
|||
|
|
"motion_budget.py 生成的 JSON 报告;page 要求选择 chroma-video,"
|
|||
|
|
"video 要求选择 baked-video"
|
|||
|
|
),
|
|||
|
|
)
|
|||
|
|
mode = result.add_mutually_exclusive_group()
|
|||
|
|
mode.add_argument("--loop", action="store_true", help="按闭环首帧裁掉尾部停顿")
|
|||
|
|
mode.add_argument("--end-reference", help="单向转场目标尾帧,用于裁掉尾部停顿")
|
|||
|
|
result.add_argument(
|
|||
|
|
"--frame-policy",
|
|||
|
|
choices=("native", "interpolate"),
|
|||
|
|
required=True,
|
|||
|
|
help="native 保留源帧;interpolate 补到更高目标帧率",
|
|||
|
|
)
|
|||
|
|
result.add_argument(
|
|||
|
|
"--fps",
|
|||
|
|
type=float,
|
|||
|
|
help="目标帧率;native 默认源帧率,interpolate 默认 48",
|
|||
|
|
)
|
|||
|
|
result.add_argument(
|
|||
|
|
"--timeline-output",
|
|||
|
|
required=True,
|
|||
|
|
help="编译生成的时间轴 JSON 路径,例如 build/timeline.json",
|
|||
|
|
)
|
|||
|
|
result.add_argument(
|
|||
|
|
"--segment",
|
|||
|
|
action="append",
|
|||
|
|
default=[],
|
|||
|
|
metavar="DESTINATION_STATE_ID=START:HOLD:END_EXCLUSIVE",
|
|||
|
|
help="按目标状态 ID 和帧准备后的源帧索引定义片段;可重复传入",
|
|||
|
|
)
|
|||
|
|
result.add_argument(
|
|||
|
|
"--initial-state-id",
|
|||
|
|
default="state-0",
|
|||
|
|
help="时间轴初始状态的稳定 ID,默认 state-0",
|
|||
|
|
)
|
|||
|
|
result.add_argument(
|
|||
|
|
"--playback-curve",
|
|||
|
|
choices=("constant", "edge-mid-edge"),
|
|||
|
|
default="constant",
|
|||
|
|
)
|
|||
|
|
result.add_argument("--playback-rate", type=float, default=1.0)
|
|||
|
|
result.add_argument("--edge-rate", type=float, default=1.6)
|
|||
|
|
result.add_argument("--mid-rate", type=float, default=1.0)
|
|||
|
|
result.add_argument(
|
|||
|
|
"--desktop-width",
|
|||
|
|
type=int,
|
|||
|
|
default=1920,
|
|||
|
|
help="桌面资源像素宽度,通常为最大 CSS 宽度 × DPR;默认 1920",
|
|||
|
|
)
|
|||
|
|
result.add_argument(
|
|||
|
|
"--mobile-width",
|
|||
|
|
type=int,
|
|||
|
|
default=1280,
|
|||
|
|
help="移动端资源像素宽度,通常为最大 CSS 宽度 × DPR;默认 1280",
|
|||
|
|
)
|
|||
|
|
result.add_argument("--desktop-crf", type=int, default=12)
|
|||
|
|
result.add_argument("--mobile-crf", type=int, default=14)
|
|||
|
|
result.add_argument("--seam-window", type=int, default=40)
|
|||
|
|
result.add_argument("--duplicate-threshold", type=float, default=0.003)
|
|||
|
|
result.add_argument("--contact-columns", type=int, default=8)
|
|||
|
|
result.add_argument(
|
|||
|
|
"--anchor",
|
|||
|
|
action="append",
|
|||
|
|
default=[],
|
|||
|
|
metavar="NAME=SOURCE_FRAME",
|
|||
|
|
help="记录清理前插帧序列中的语义锚点,可重复传入",
|
|||
|
|
)
|
|||
|
|
result.add_argument(
|
|||
|
|
"--poster-source-frame",
|
|||
|
|
type=int,
|
|||
|
|
default=0,
|
|||
|
|
help="静态降级图对应的清理前插帧索引;默认 0",
|
|||
|
|
)
|
|||
|
|
result.add_argument(
|
|||
|
|
"--allow-upscale",
|
|||
|
|
action="store_true",
|
|||
|
|
help="允许输出宽度超过母版;默认自动限制为母版宽度",
|
|||
|
|
)
|
|||
|
|
result.add_argument(
|
|||
|
|
"--keep-frames",
|
|||
|
|
action="store_true",
|
|||
|
|
help="保留帧准备和清理帧用于调试;默认成功后删除中间 PNG",
|
|||
|
|
)
|
|||
|
|
result.add_argument("--force", action="store_true")
|
|||
|
|
return result
|
|||
|
|
|
|||
|
|
|
|||
|
|
if __name__ == "__main__":
|
|||
|
|
try:
|
|||
|
|
raise SystemExit(compile_motion(parser().parse_args()))
|
|||
|
|
except (
|
|||
|
|
FileNotFoundError,
|
|||
|
|
FileExistsError,
|
|||
|
|
RuntimeError,
|
|||
|
|
ValueError,
|
|||
|
|
subprocess.CalledProcessError,
|
|||
|
|
) as error:
|
|||
|
|
print(f"错误:{error}", file=sys.stderr)
|
|||
|
|
raise SystemExit(1) from error
|