一、问题(本轮实测)
`draw-ui` 与 `oil-motion` 目录里**各自带一个内嵌 `.git`** ⇒ 上一次提交把它们记成了 **gitlink(子模块指针)**
⇒ 仓库里只存了一个不属于任何远端的 commit id,**别人克隆下来这两份是空的** ✗(`git status` 显示 ` m draw-ui` / ` m oil-motion` = 子模块内容有改动)。
二、处置(可回退)
· 把两处的 `.git` **挪走**(⛔ 不是删除)⇒ `归档/内嵌git-20261008/{draw-ui,oil-motion}.git`;
· `git rm --cached` 掉那两个 gitlink,再 `git add` 两个目录 ⇒ **按正常文件入库**(内容才真的进仓库)。
三、副作用(如实记)
挪走 `.git` 后,这两个技能**不能再原地 `git pull` 取上游更新**(要更新得重新拉一份覆盖);
如需恢复其本地仓库,把 `归档/内嵌git-20261008/` 里的 `.git` 挪回原处即可。
1202 lines
40 KiB
Python
1202 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
|