一、问题(本轮实测)
`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` 挪回原处即可。
414 lines
14 KiB
Python
414 lines
14 KiB
Python
#!/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())
|