Files
workbuddy_skills/oil-motion/scripts/compile_scroll_video.py
T
admin 237a09a5b0 修:draw-ui / oil-motion 原被当子模块指针收录 ⇒ 改为正常文件入库(两份内容原先对别人是空的)
一、问题(本轮实测)
`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` 挪回原处即可。
2026-10-08 22:29:52 +08:00

1202 lines
40 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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