#!/usr/bin/env python3 """根据交互方式、显示尺寸和资源预算自动选择网页动画交付方案。""" from __future__ import annotations import argparse import json import math import sys from dataclasses import asdict, dataclass from pathlib import Path def parse_size(value: str) -> tuple[int, int]: normalized = value.lower().replace("×", "x").strip() try: width_text, height_text = normalized.split("x", 1) width, height = int(width_text), int(height_text) except (ValueError, AttributeError) as exc: raise argparse.ArgumentTypeError("尺寸必须写成 WIDTHxHEIGHT") from exc if width <= 0 or height <= 0: raise argparse.ArgumentTypeError("宽高必须大于 0") return width, height @dataclass(frozen=True) class SizeCheck: width: int height: int required_width: int required_height: int width_ratio: float height_ratio: float passes: bool def check_size(size: tuple[int, int], required: tuple[int, int]) -> SizeCheck: width, height = size required_width, required_height = required return SizeCheck( width=width, height=height, required_width=required_width, required_height=required_height, width_ratio=round(width / required_width, 3), height_ratio=round(height / required_height, 3), passes=width >= required_width and height >= required_height, ) def resolve_access(args: argparse.Namespace) -> str: if args.access != "auto": return args.access if args.time_control in {"segment-play", "autonomous"}: return "sequential" if args.parameter_space == "linear" and args.driver in { "scroll", "audio", }: return "sequential" return "random" def select_delivery( args: argparse.Namespace, capacity: int, sheets: int | None, decoded_mib: float, access: str, ) -> dict[str, object]: atlas_within_budget = ( capacity > 0 and sheets is not None and sheets == 1 and decoded_mib <= args.atlas_max_memory_mib ) reason_codes: list[str] = [] if args.background_owner == "video": selected = "baked-video" reason_codes.append("background-baked-into-video") if args.parameter_space in {"2d", "discrete"}: reason_codes.append("baked-video-needs-independent-clips") if ( access == "sequential" and args.parameter_space == "linear" and args.frames >= args.linear_video_min_frames ): reason_codes.append("long-linear-sequence") elif args.parameter_space == "2d": selected = "alpha-atlas" reason_codes.append("two-dimensional-parameter-needs-discrete-frames") if not atlas_within_budget: reason_codes.append("atlas-needs-budget-optimization") elif args.parameter_space == "discrete": selected = "alpha-atlas" reason_codes.append("discrete-states-need-independent-assets") if not atlas_within_budget: reason_codes.append("states-need-separate-budgets") elif access == "random" and atlas_within_budget: selected = "alpha-atlas" reason_codes.append("random-access-fits-atlas-budget") elif ( access == "sequential" and args.parameter_space == "linear" and args.frames >= args.linear_video_min_frames ): selected = "chroma-video" reason_codes.append("long-linear-sequence") elif not atlas_within_budget: selected = "chroma-video" reason_codes.append("atlas-budget-exceeded") else: selected = "alpha-atlas" reason_codes.append("small-asset-fits-atlas-budget") return { "selected": selected, "reasonCodes": reason_codes, "atlasWithinBudget": atlas_within_budget, "thresholds": { "atlasMaxSheets": 1, "atlasMaxDecodedMemoryMiB": args.atlas_max_memory_mib, "linearVideoMinFrames": args.linear_video_min_frames, }, } def build_report(args: argparse.Namespace) -> dict[str, object]: display_width, display_height = args.display required = ( math.ceil(display_width * args.dpr), math.ceil(display_height * args.dpr), ) required_width, required_height = required max_columns = args.max_texture // required_width max_rows = args.max_texture // required_height capacity = max_columns * max_rows sheets = math.ceil(args.frames / capacity) if capacity else None decoded_mib = ( required_width * required_height * args.frames * 4 / 1024 / 1024 ) access = resolve_access(args) delivery = select_delivery(args, capacity, sheets, decoded_mib, access) runtime = { "renderer": { "alpha-atlas": "css-alpha-atlas", "chroma-video": "webgl-chroma-video", "baked-video": "baked-video", }[str(delivery["selected"])], "controller": { "scrub": "frame-scrub", "segment-play": "segment-playback", "autonomous": "autonomous-playback", }[args.time_control], } failures: list[str] = [] source_check = check_size(args.source, required) if args.source else None cell_check = check_size(args.cell, required) if args.cell else None if source_check and not source_check.passes: failures.append("源素材低于最终显示所需像素") if ( cell_check and delivery["selected"] == "alpha-atlas" and not cell_check.passes ): failures.append("图集单帧低于最终显示所需像素") if delivery["selected"] == "alpha-atlas" and capacity == 0: failures.append("单帧已超过纹理尺寸上限") if ( delivery["selected"] == "baked-video" and args.parameter_space in {"2d", "discrete"} ): failures.append( "烘焙视频是一条线性时间轴,二维或离散参数必须拆成多条独立片段分别预算" ) if ( delivery["selected"] == "alpha-atlas" and not delivery["atlasWithinBudget"] ): if args.parameter_space == "discrete": failures.append("离散状态合并后超预算,需要拆成独立状态或转场并分别预算") else: failures.append( "二维 Alpha 图集超出单图集或内存预算,需要降低参数采样密度、" "拆分参数轴,或在用户确认的显示范围内重新预算" ) temporal_check = None if args.scroll_pages is not None: required_frames = math.ceil(args.scroll_pages * args.frames_per_page) frames_per_page = args.frames / args.scroll_pages temporal_check = { "scrollPages": args.scroll_pages, "framesPerPage": round(frames_per_page, 2), "targetFramesPerPage": args.frames_per_page, "requiredFrames": required_frames, "passes": args.frames >= required_frames, } if not temporal_check["passes"]: failures.append( f"滚动采样密度不足:需要至少 {required_frames} 帧" ) return { "display": { "width": display_width, "height": display_height, "dpr": args.dpr, }, "requiredCell": { "width": required_width, "height": required_height, }, "frames": args.frames, "texture": { "maxSize": args.max_texture, "columnsPerSheet": max_columns, "rowsPerSheet": max_rows, "framesPerSheet": capacity, "sheetCount": sheets, }, "decodedFrameMemoryMiB": round(decoded_mib, 1), "sourceCheck": asdict(source_check) if source_check else None, "cellCheck": asdict(cell_check) if cell_check else None, "temporalCheck": temporal_check, "driver": args.driver, "parameterSpace": args.parameter_space, "timeControl": args.time_control, "access": access, "backgroundOwner": args.background_owner, "delivery": delivery, "runtime": runtime, "failures": failures, "passes": not failures, } def print_human(report: dict[str, object]) -> None: display = report["display"] required = report["requiredCell"] texture = report["texture"] print( f"显示尺寸:{display['width']}×{display['height']} CSS px," f"DPR {display['dpr']}" ) print(f"最低单帧:{required['width']}×{required['height']} px") print( f"纹理预算:每片最多 {texture['columnsPerSheet']}×" f"{texture['rowsPerSheet']} 帧,共需 {texture['sheetCount']} 片" ) print(f"全部帧解码内存理论值:{report['decodedFrameMemoryMiB']} MiB") delivery = report["delivery"] runtime = report["runtime"] print( f"背景归属:{report['backgroundOwner']};" f"媒体:{delivery['selected']};" f"控制器:{runtime['controller']};渲染器:{runtime['renderer']}" ) print("选择依据:" + "、".join(delivery["reasonCodes"])) for label, key in (("源素材", "sourceCheck"), ("当前单帧", "cellCheck")): check = report[key] if check: status = "通过" if check["passes"] else "不通过" print( f"{label}:{check['width']}×{check['height']} px,{status}" f"(宽 {check['width_ratio']}×,高 {check['height_ratio']}×)" ) temporal = report["temporalCheck"] if temporal: status = "通过" if temporal["passes"] else "不通过" print( f"滚动采样:{temporal['framesPerPage']} 帧/屏,{status}" f"(目标 {temporal['targetFramesPerPage']} 帧/屏," f"至少 {temporal['requiredFrames']} 帧)" ) failures = report["failures"] if failures: print("阻断项:") for failure in failures: print(f"- {failure}") else: print("结果:通过") def main() -> int: parser = argparse.ArgumentParser( description=( "根据 Concept Contract 的背景归属、交互参数、CSS 尺寸、DPR、帧数和纹理预算" "自动选择烘焙场景视频、Alpha 图集或绿幕视频。" ) ) parser.add_argument("--frames", type=int, required=True, help="总帧数") parser.add_argument( "--display", type=parse_size, required=True, help="最终 CSS 尺寸,如 268x468" ) parser.add_argument("--dpr", type=float, default=2.0, help="目标设备 DPR") parser.add_argument( "--max-texture", type=int, default=4096, help="保守纹理边长上限" ) parser.add_argument( "--driver", choices=( "pointer", "scroll", "drag", "touch", "orientation", "audio", "data", "state", "time", ), default="scroll", help="Motion Brief 中的交互驱动,默认 scroll", ) parser.add_argument( "--parameter-space", choices=("linear", "circular", "2d", "discrete"), default="linear", help="Motion Brief 中的参数空间,默认 linear", ) parser.add_argument( "--time-control", choices=("scrub", "segment-play", "autonomous"), required=True, help="Concept Contract 中的时间控制方式,必须显式传入", ) parser.add_argument( "--access", choices=("auto", "random", "sequential"), default="auto", help="运行时主要访问方式;默认根据 driver 与 parameter-space 自动推断", ) parser.add_argument( "--background-owner", choices=("page", "video"), required=True, help=( "背景归属,来自 Concept Contract:video 表示背景与主体在同一视频中" "烘焙生成(baked-video,默认场景路线);page 表示主体需要透明复用," "由预算在 alpha-atlas 与 chroma-video 之间选择。必须显式传入," "禁止因遗漏参数而静默回退到绿幕路线" ), ) parser.add_argument( "--atlas-max-memory-mib", type=float, default=192.0, help="自动优先选择 Alpha 图集时允许的理论解码内存,默认 192 MiB", ) parser.add_argument( "--linear-video-min-frames", type=int, default=180, help="一维顺序动画自动优先视频的帧数门槛,默认 180", ) parser.add_argument("--source", type=parse_size, help="源视频或母版帧尺寸") parser.add_argument("--cell", type=parse_size, help="当前图集单帧尺寸") parser.add_argument( "--scroll-pages", type=float, help="滚动驱动覆盖的视口屏数;传入后同时检查时间采样密度", ) parser.add_argument( "--frames-per-page", type=float, default=24.0, help="每屏目标帧数,默认 24", ) parser.add_argument("--json", action="store_true", help="输出 JSON") parser.add_argument("--report", type=Path, help="同时把完整 JSON 报告写入指定路径") parser.add_argument( "--strict", action="store_true", help="存在阻断项时返回非零退出码" ) args = parser.parse_args() if args.frames <= 0: parser.error("--frames 必须大于 0") if args.dpr <= 0: parser.error("--dpr 必须大于 0") if args.max_texture <= 0: parser.error("--max-texture 必须大于 0") if args.atlas_max_memory_mib <= 0: parser.error("--atlas-max-memory-mib 必须大于 0") if args.linear_video_min_frames <= 0: parser.error("--linear-video-min-frames 必须大于 0") if args.scroll_pages is not None and args.scroll_pages <= 0: parser.error("--scroll-pages 必须大于 0") if args.frames_per_page <= 0: parser.error("--frames-per-page 必须大于 0") report = build_report(args) if args.report: report_path = args.report.expanduser().resolve() report_path.parent.mkdir(parents=True, exist_ok=True) report_path.write_text( json.dumps(report, ensure_ascii=False, indent=2), encoding="utf-8", ) if args.json: print(json.dumps(report, ensure_ascii=False, indent=2)) else: print_human(report) return 2 if args.strict and not report["passes"] else 0 if __name__ == "__main__": sys.exit(main())