Files
workbuddy_skills/oil-motion/scripts/motion_budget.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

414 lines
14 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
"""根据交互方式、显示尺寸和资源预算自动选择网页动画交付方案。"""
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())