修: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` 挪回原处即可。
This commit is contained in:
1 parent
e03465c398
commit
237a09a5b0
161 files changed
+19429
-2
No files matched your search
@@ -0,0 +1,177 @@
|
||||
"""绿幕视频编译与 WebGL 运行时共享的 dominance-v2 色键规范。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import asdict, dataclass
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import numpy as np
|
||||
from PIL import Image
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ChromaKeyParameters:
|
||||
algorithm: str
|
||||
mode: str
|
||||
key_color: tuple[int, int, int]
|
||||
similarity: float = 0.12
|
||||
smoothness: float = 0.06
|
||||
dominance_start: float = 0.0
|
||||
dominance_end: float = 0.12
|
||||
spill_start: float = -0.005
|
||||
spill_end: float = 0.06
|
||||
spill: float = 1.0
|
||||
|
||||
def manifest(self) -> dict[str, Any]:
|
||||
data = asdict(self)
|
||||
return {
|
||||
"algorithm": data["algorithm"],
|
||||
"mode": data["mode"],
|
||||
"keyColor": list(data["key_color"]),
|
||||
"similarity": data["similarity"],
|
||||
"smoothness": data["smoothness"],
|
||||
"dominanceStart": data["dominance_start"],
|
||||
"dominanceEnd": data["dominance_end"],
|
||||
"spillStart": data["spill_start"],
|
||||
"spillEnd": data["spill_end"],
|
||||
"spill": data["spill"],
|
||||
}
|
||||
|
||||
|
||||
def key_mode(key: tuple[int, int, int]) -> str:
|
||||
red, green, blue = key
|
||||
if green >= red + 40 and green >= blue + 40:
|
||||
return "green"
|
||||
if red >= green + 40 and blue >= green + 40:
|
||||
return "magenta"
|
||||
raise ValueError("色键不是可识别的绿色或洋红色")
|
||||
|
||||
|
||||
def default_parameters(key: tuple[int, int, int]) -> ChromaKeyParameters:
|
||||
return ChromaKeyParameters(
|
||||
algorithm="dominance-v2",
|
||||
mode=key_mode(key),
|
||||
key_color=key,
|
||||
)
|
||||
|
||||
|
||||
def _smoothstep(edge0: float, edge1: float, value: np.ndarray) -> np.ndarray:
|
||||
width = max(0.0001, edge1 - edge0)
|
||||
normalized = np.clip((value - edge0) / width, 0.0, 1.0)
|
||||
return normalized * normalized * (3.0 - 2.0 * normalized)
|
||||
|
||||
|
||||
def _chroma(colors: np.ndarray) -> np.ndarray:
|
||||
luminance = (
|
||||
colors[..., 0] * 0.299
|
||||
+ colors[..., 1] * 0.587
|
||||
+ colors[..., 2] * 0.114
|
||||
)
|
||||
return np.stack(
|
||||
(colors[..., 2] - luminance, colors[..., 0] - luminance),
|
||||
axis=-1,
|
||||
)
|
||||
|
||||
|
||||
def _dominance(colors: np.ndarray, mode: str) -> np.ndarray:
|
||||
if mode == "green":
|
||||
return colors[..., 1] - np.maximum(colors[..., 0], colors[..., 2])
|
||||
return np.minimum(colors[..., 0], colors[..., 2]) - colors[..., 1]
|
||||
|
||||
|
||||
def apply_key(
|
||||
rgb: np.ndarray,
|
||||
parameters: ChromaKeyParameters,
|
||||
) -> tuple[np.ndarray, np.ndarray, np.ndarray]:
|
||||
"""返回去溢色 RGB、Alpha 和原始色键通道优势,数值范围均为 0..1。"""
|
||||
colors = np.asarray(rgb, dtype=np.float32)
|
||||
if colors.size and float(colors.max()) > 1.0:
|
||||
colors = colors / 255.0
|
||||
key = np.asarray(parameters.key_color, dtype=np.float32) / 255.0
|
||||
distance = np.linalg.norm(_chroma(colors) - _chroma(key), axis=-1)
|
||||
distance_alpha = _smoothstep(
|
||||
parameters.similarity,
|
||||
parameters.similarity + parameters.smoothness,
|
||||
distance,
|
||||
)
|
||||
dominance = _dominance(colors, parameters.mode)
|
||||
dominance_mask = _smoothstep(
|
||||
parameters.dominance_start,
|
||||
parameters.dominance_end,
|
||||
dominance,
|
||||
)
|
||||
alpha = np.minimum(distance_alpha, 1.0 - dominance_mask)
|
||||
dominance_spill = _smoothstep(
|
||||
parameters.spill_start,
|
||||
parameters.spill_end,
|
||||
dominance,
|
||||
)
|
||||
spill_mask = np.clip(
|
||||
np.maximum(dominance_spill, (1.0 - alpha) * parameters.spill),
|
||||
0.0,
|
||||
1.0,
|
||||
)
|
||||
output = colors.copy()
|
||||
if parameters.mode == "green":
|
||||
neutral = np.maximum(colors[..., 0], colors[..., 2])
|
||||
output[..., 1] = colors[..., 1] * (1.0 - spill_mask) + neutral * spill_mask
|
||||
else:
|
||||
neutral = colors[..., 1]
|
||||
output[..., 0] = colors[..., 0] * (1.0 - spill_mask) + neutral * spill_mask
|
||||
output[..., 2] = colors[..., 2] * (1.0 - spill_mask) + neutral * spill_mask
|
||||
return np.clip(output, 0.0, 1.0), np.clip(alpha, 0.0, 1.0), dominance
|
||||
|
||||
|
||||
def key_image(
|
||||
source: Path,
|
||||
output: Path,
|
||||
parameters: ChromaKeyParameters,
|
||||
) -> dict[str, int]:
|
||||
rgb = np.asarray(Image.open(source).convert("RGB"), dtype=np.float32) / 255.0
|
||||
color, alpha, _ = apply_key(rgb, parameters)
|
||||
rgba = np.dstack(
|
||||
(
|
||||
np.rint(color * 255).astype(np.uint8),
|
||||
np.rint(alpha * 255).astype(np.uint8),
|
||||
)
|
||||
)
|
||||
output.parent.mkdir(parents=True, exist_ok=True)
|
||||
Image.fromarray(rgba, "RGBA").save(output)
|
||||
return {
|
||||
"transparentPixels": int(np.count_nonzero(alpha <= 0.01)),
|
||||
"partialPixels": int(np.count_nonzero((alpha > 0.01) & (alpha < 0.99))),
|
||||
}
|
||||
|
||||
|
||||
def analyze_frame(
|
||||
source: Path,
|
||||
parameters: ChromaKeyParameters,
|
||||
) -> dict[str, float | int]:
|
||||
rgb = np.asarray(Image.open(source).convert("RGB"), dtype=np.float32) / 255.0
|
||||
output, alpha, dominance = apply_key(rgb, parameters)
|
||||
key_like = dominance > 0.02
|
||||
visible_key = key_like & (alpha > 0.01)
|
||||
opaque_key = key_like & (alpha > 0.5)
|
||||
border = np.zeros(alpha.shape, dtype=bool)
|
||||
band = max(2, min(alpha.shape) // 80)
|
||||
border[:band] = True
|
||||
border[-band:] = True
|
||||
border[:, :band] = True
|
||||
border[:, -band:] = True
|
||||
edge = (alpha > 0.02) & (alpha < 0.98)
|
||||
output_dominance = _dominance(output, parameters.mode)
|
||||
|
||||
def percentile(values: np.ndarray, quantile: float) -> float:
|
||||
return float(np.quantile(values, quantile)) if values.size else 0.0
|
||||
|
||||
key_count = max(1, int(np.count_nonzero(key_like)))
|
||||
return {
|
||||
"keyLikePixels": int(np.count_nonzero(key_like)),
|
||||
"keyLikeAlphaP99": percentile(alpha[key_like], 0.99),
|
||||
"visibleKeyPixelRatio": float(np.count_nonzero(visible_key) / key_count),
|
||||
"opaqueKeyPixelRatio": float(np.count_nonzero(opaque_key) / key_count),
|
||||
"borderAlphaP99": percentile(alpha[border], 0.99),
|
||||
"edgeKeyDominanceP95": max(0.0, percentile(output_dominance[edge], 0.95)),
|
||||
"meanAlpha": float(alpha.mean()),
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,126 @@
|
||||
#!/usr/bin/env python3
|
||||
"""把同一透明角色层精确放到轨道顶部和底部,生成一致的首尾帧。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
from pathlib import Path
|
||||
|
||||
from PIL import Image, ImageDraw
|
||||
|
||||
|
||||
def parse_size(raw: str) -> tuple[int, int]:
|
||||
try:
|
||||
width, height = raw.lower().split("x", 1)
|
||||
return int(width), int(height)
|
||||
except (ValueError, AttributeError) as error:
|
||||
raise ValueError("--size 必须是 WIDTHxHEIGHT") from error
|
||||
|
||||
|
||||
def build(args: argparse.Namespace) -> int:
|
||||
source = Path(args.source).expanduser().resolve()
|
||||
first_output = Path(args.first_output).expanduser().resolve()
|
||||
last_output = Path(args.last_output).expanduser().resolve()
|
||||
width, height = parse_size(args.size)
|
||||
|
||||
image = Image.open(source).convert("RGBA")
|
||||
bbox = image.getbbox()
|
||||
if not bbox:
|
||||
raise ValueError("输入没有可见像素")
|
||||
subject = image.crop(bbox)
|
||||
|
||||
target_height = round(height * args.subject_height)
|
||||
scale = target_height / subject.height
|
||||
target_width = round(subject.width * scale)
|
||||
if target_width > width * args.max_subject_width:
|
||||
scale = (width * args.max_subject_width) / subject.width
|
||||
target_width = round(subject.width * scale)
|
||||
target_height = round(subject.height * scale)
|
||||
subject = subject.resize((target_width, target_height), Image.Resampling.LANCZOS)
|
||||
|
||||
pole_x = round(width * args.pole_x)
|
||||
anchor_x = round(subject.width * args.subject_anchor_x)
|
||||
subject_x = pole_x - anchor_x
|
||||
start_y = round(height * args.top_margin)
|
||||
end_y = height - round(height * args.bottom_margin) - subject.height
|
||||
if end_y <= start_y:
|
||||
raise ValueError("角色过高,没有足够的上下移动空间")
|
||||
|
||||
def compose(y: int) -> Image.Image:
|
||||
canvas = Image.new("RGB", (width, height), args.key_color)
|
||||
draw = ImageDraw.Draw(canvas)
|
||||
outline = max(2, round(width * args.pole_outline_ratio))
|
||||
pole_width = max(outline + 2, round(width * args.pole_width_ratio))
|
||||
draw.rectangle(
|
||||
(
|
||||
pole_x - pole_width // 2 - outline,
|
||||
0,
|
||||
pole_x + pole_width // 2 + outline,
|
||||
height,
|
||||
),
|
||||
fill=args.pole_outline,
|
||||
)
|
||||
draw.rectangle(
|
||||
(
|
||||
pole_x - pole_width // 2,
|
||||
0,
|
||||
pole_x + pole_width // 2,
|
||||
height,
|
||||
),
|
||||
fill=args.pole_fill,
|
||||
)
|
||||
canvas.paste(subject, (subject_x, y), subject)
|
||||
return canvas
|
||||
|
||||
first_output.parent.mkdir(parents=True, exist_ok=True)
|
||||
last_output.parent.mkdir(parents=True, exist_ok=True)
|
||||
compose(start_y).save(first_output)
|
||||
compose(end_y).save(last_output)
|
||||
print(
|
||||
{
|
||||
"source": str(source),
|
||||
"subjectSize": [subject.width, subject.height],
|
||||
"canvas": [width, height],
|
||||
"poleX": pole_x,
|
||||
"subjectX": subject_x,
|
||||
"startY": start_y,
|
||||
"endY": end_y,
|
||||
"first": str(first_output),
|
||||
"last": str(last_output),
|
||||
}
|
||||
)
|
||||
return 0
|
||||
|
||||
|
||||
def parser() -> argparse.ArgumentParser:
|
||||
result = argparse.ArgumentParser(
|
||||
description="用同一透明主体生成沿竖直轨道移动的首帧和尾帧"
|
||||
)
|
||||
result.add_argument("source", help="已抠图的 RGBA PNG")
|
||||
result.add_argument("--first-output", required=True)
|
||||
result.add_argument("--last-output", required=True)
|
||||
result.add_argument("--size", default="864x1536")
|
||||
result.add_argument("--key-color", default="#00FF00")
|
||||
result.add_argument("--pole-x", type=float, default=0.5)
|
||||
result.add_argument("--pole-fill", default="#B8B8B2")
|
||||
result.add_argument("--pole-outline", default="#181818")
|
||||
result.add_argument("--pole-width-ratio", type=float, default=0.012)
|
||||
result.add_argument("--pole-outline-ratio", type=float, default=0.004)
|
||||
result.add_argument("--subject-height", type=float, default=0.36)
|
||||
result.add_argument("--max-subject-width", type=float, default=0.84)
|
||||
result.add_argument(
|
||||
"--subject-anchor-x",
|
||||
type=float,
|
||||
default=0.535,
|
||||
help="透明主体中应与轨道重合的横向比例",
|
||||
)
|
||||
result.add_argument("--top-margin", type=float, default=0.045)
|
||||
result.add_argument("--bottom-margin", type=float, default=0.045)
|
||||
return result
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
try:
|
||||
raise SystemExit(build(parser().parse_args()))
|
||||
except (FileNotFoundError, ValueError) as error:
|
||||
raise SystemExit(f"错误:{error}") from error
|
||||
@@ -0,0 +1,47 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Composite a native-transparent keyframe onto a uniform video chroma backdrop."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
from PIL import Image
|
||||
|
||||
|
||||
def parse_color(value: str) -> tuple[int, int, int, int]:
|
||||
if not re.fullmatch(r"#[0-9a-fA-F]{6}", value):
|
||||
raise SystemExit(f"invalid key color: {value}; expected #RRGGBB")
|
||||
return tuple(int(value[index : index + 2], 16) for index in (1, 3, 5)) + (255,)
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("source", help="Accepted native-transparent keyframe PNG")
|
||||
parser.add_argument("output", help="RGB PNG for the video model")
|
||||
parser.add_argument("--key-color", default="#00FF00")
|
||||
args = parser.parse_args()
|
||||
|
||||
source = Path(args.source).expanduser().resolve()
|
||||
output = Path(args.output).expanduser().resolve()
|
||||
if source == output:
|
||||
raise SystemExit("source and output must differ; preserve the native-transparent keyframe")
|
||||
|
||||
with Image.open(source) as opened:
|
||||
if "A" not in opened.getbands():
|
||||
raise SystemExit("source has no alpha channel; regenerate it with native transparency")
|
||||
foreground = opened.convert("RGBA")
|
||||
alpha_min, alpha_max = foreground.getchannel("A").getextrema()
|
||||
if alpha_min > 16 or alpha_max <= 16:
|
||||
raise SystemExit("source must contain both transparent background and visible subject pixels")
|
||||
|
||||
background = Image.new("RGBA", foreground.size, parse_color(args.key_color))
|
||||
background.alpha_composite(foreground)
|
||||
output.parent.mkdir(parents=True, exist_ok=True)
|
||||
background.convert("RGB").save(output)
|
||||
print(output)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,203 @@
|
||||
#!/usr/bin/env python3
|
||||
"""从通用模板生成独立的动画原理展示页。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import math
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
|
||||
SKILL_ROOT = Path(__file__).resolve().parent.parent
|
||||
TEMPLATE_PATH = SKILL_ROOT / "assets" / "motion-explainer-template.html"
|
||||
CONFIG_PLACEHOLDER = "__MOTION_EXPLAINER_CONFIG__"
|
||||
DRIVERS = ("pointer-angle", "pointer-x", "drag", "scroll", "autoplay")
|
||||
PLAY_MODES = ("loop", "pingpong", "once")
|
||||
|
||||
|
||||
def positive_int(value: str) -> int:
|
||||
number = int(value)
|
||||
if number <= 0:
|
||||
raise argparse.ArgumentTypeError("必须是正整数")
|
||||
return number
|
||||
|
||||
|
||||
def positive_float(value: str) -> float:
|
||||
number = float(value)
|
||||
if number <= 0:
|
||||
raise argparse.ArgumentTypeError("必须大于 0")
|
||||
return number
|
||||
|
||||
|
||||
def load_manifest(path: Path | None) -> dict[str, Any]:
|
||||
if path is None:
|
||||
return {}
|
||||
try:
|
||||
data = json.loads(path.read_text(encoding="utf-8"))
|
||||
except (OSError, json.JSONDecodeError) as error:
|
||||
raise SystemExit(f"无法读取 manifest:{error}") from error
|
||||
if not isinstance(data, dict):
|
||||
raise SystemExit("manifest 顶层必须是 JSON 对象")
|
||||
return data
|
||||
|
||||
|
||||
def first_value(
|
||||
explicit: Any,
|
||||
manifest: dict[str, Any],
|
||||
*keys: str,
|
||||
default: Any = None,
|
||||
) -> Any:
|
||||
if explicit is not None:
|
||||
return explicit
|
||||
for key in keys:
|
||||
if key in manifest and manifest[key] is not None:
|
||||
return manifest[key]
|
||||
return default
|
||||
|
||||
|
||||
def parse_args() -> argparse.Namespace:
|
||||
parser = argparse.ArgumentParser(
|
||||
description="生成带雪碧图、母版视频和实时帧高亮的独立 HTML 展示页。"
|
||||
)
|
||||
parser.add_argument("--title", default="一张图,很多种运动。")
|
||||
parser.add_argument(
|
||||
"--atlas-url",
|
||||
required=True,
|
||||
help="HTML 中使用的图集 URL;相对地址以输出 HTML 所在目录为基准。",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--video-url",
|
||||
help="可选母版视频 URL;不传时隐藏视频 Tab。",
|
||||
)
|
||||
parser.add_argument("--manifest", type=Path, help="可选本地图集 manifest。")
|
||||
parser.add_argument("--frames", type=positive_int)
|
||||
parser.add_argument("--columns", type=positive_int)
|
||||
parser.add_argument("--rows", type=positive_int)
|
||||
parser.add_argument("--cell-width", type=positive_int)
|
||||
parser.add_argument("--cell-height", type=positive_int)
|
||||
parser.add_argument("--rest-frame", type=int)
|
||||
parser.add_argument("--driver", choices=DRIVERS, default="pointer-angle")
|
||||
parser.add_argument("--angle-start-deg", type=float)
|
||||
parser.add_argument("--smooth-time", type=positive_float, default=0.11)
|
||||
parser.add_argument("--max-frame-speed", type=positive_float)
|
||||
parser.add_argument("--autoplay-fps", type=positive_float, default=24)
|
||||
parser.add_argument(
|
||||
"--autoplay-mode",
|
||||
choices=PLAY_MODES,
|
||||
default="loop",
|
||||
)
|
||||
parser.add_argument("--scroll-pages", type=positive_float, default=3)
|
||||
parser.add_argument("--output", type=Path, required=True)
|
||||
parser.add_argument("--force", action="store_true")
|
||||
return parser.parse_args()
|
||||
|
||||
|
||||
def build_config(args: argparse.Namespace) -> dict[str, Any]:
|
||||
manifest = load_manifest(args.manifest)
|
||||
frame_count = int(
|
||||
first_value(args.frames, manifest, "frameCount", "frames", default=0)
|
||||
)
|
||||
columns = int(first_value(args.columns, manifest, "columns", default=0))
|
||||
rows = int(first_value(args.rows, manifest, "rows", default=0))
|
||||
cell_width = int(
|
||||
first_value(args.cell_width, manifest, "cellWidth", default=1)
|
||||
)
|
||||
cell_height = int(
|
||||
first_value(args.cell_height, manifest, "cellHeight", default=1)
|
||||
)
|
||||
|
||||
if frame_count <= 0 or columns <= 0 or rows <= 0:
|
||||
raise SystemExit(
|
||||
"缺少图集尺寸:请提供 --frames、--columns、--rows,或传入包含这些字段的 manifest。"
|
||||
)
|
||||
if columns * rows < frame_count:
|
||||
raise SystemExit(
|
||||
f"图集网格只有 {columns * rows} 个格子,无法容纳 {frame_count} 帧。"
|
||||
)
|
||||
if cell_width <= 0 or cell_height <= 0:
|
||||
raise SystemExit("cellWidth 和 cellHeight 必须大于 0。")
|
||||
|
||||
rest_frame = int(
|
||||
first_value(
|
||||
args.rest_frame,
|
||||
manifest,
|
||||
"initialFrame",
|
||||
"restFrame",
|
||||
default=0,
|
||||
)
|
||||
)
|
||||
if not 0 <= rest_frame < frame_count:
|
||||
raise SystemExit(
|
||||
f"rest frame 必须位于 0 到 {frame_count - 1} 之间。"
|
||||
)
|
||||
|
||||
manifest_angle = manifest.get("startAngleRadians")
|
||||
if args.angle_start_deg is not None:
|
||||
angle_start_deg = args.angle_start_deg
|
||||
elif manifest_angle is not None:
|
||||
angle_start_deg = math.degrees(float(manifest_angle))
|
||||
else:
|
||||
angle_start_deg = -135
|
||||
|
||||
max_frame_speed = (
|
||||
args.max_frame_speed
|
||||
if args.max_frame_speed is not None
|
||||
else max(60.0, frame_count * 2.0)
|
||||
)
|
||||
|
||||
return {
|
||||
"title": args.title,
|
||||
"atlasUrl": args.atlas_url,
|
||||
"videoUrl": args.video_url or "",
|
||||
"frameCount": frame_count,
|
||||
"columns": columns,
|
||||
"rows": rows,
|
||||
"cellWidth": cell_width,
|
||||
"cellHeight": cell_height,
|
||||
"restFrame": rest_frame,
|
||||
"driver": args.driver,
|
||||
"angleStartDeg": angle_start_deg,
|
||||
"smoothTime": args.smooth_time,
|
||||
"maxFrameSpeed": max_frame_speed,
|
||||
"autoplayFps": args.autoplay_fps,
|
||||
"autoplayMode": args.autoplay_mode,
|
||||
"scrollPages": args.scroll_pages,
|
||||
}
|
||||
|
||||
|
||||
def main() -> None:
|
||||
args = parse_args()
|
||||
if args.output.exists() and not args.force:
|
||||
raise SystemExit(
|
||||
f"输出已存在:{args.output}。确认后使用 --force 覆盖。"
|
||||
)
|
||||
if not TEMPLATE_PATH.exists():
|
||||
raise SystemExit(f"找不到模板:{TEMPLATE_PATH}")
|
||||
|
||||
template = TEMPLATE_PATH.read_text(encoding="utf-8")
|
||||
if template.count(CONFIG_PLACEHOLDER) != 1:
|
||||
raise SystemExit("模板配置占位符缺失或不唯一。")
|
||||
|
||||
config = build_config(args)
|
||||
embedded = json.dumps(
|
||||
config,
|
||||
ensure_ascii=False,
|
||||
separators=(",", ":"),
|
||||
).replace("</", "<\\/")
|
||||
output = template.replace(CONFIG_PLACEHOLDER, embedded)
|
||||
|
||||
args.output.parent.mkdir(parents=True, exist_ok=True)
|
||||
args.output.write_text(output, encoding="utf-8")
|
||||
print(f"已生成:{args.output.resolve()}")
|
||||
print(
|
||||
"配置:"
|
||||
f"{config['frameCount']} 帧,"
|
||||
f"{config['columns']}×{config['rows']},"
|
||||
f"driver={config['driver']}"
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,162 @@
|
||||
# 可复用凭据输入页
|
||||
|
||||
供本机桌面 Skill 配置 API Key、访问令牌等单行凭据。一个 Key 使用紧凑单输入框,多个 Key 在同页纵向排列,共用一个保存按钮。页面默认黑白灰,密钥只存系统凭据库。
|
||||
|
||||
## 适用范围
|
||||
|
||||
适合没有现成安全配置入口、需要用户首次填写或更换 API Key、访问令牌、Client Secret 的本机桌面 Skill。已有宿主凭据能力优先复用。
|
||||
|
||||
| 其他场景 | 使用方式 |
|
||||
| --- | --- |
|
||||
| OAuth、验证码或账号登录 | 服务官方授权流程 |
|
||||
| 模型名、地址、目录等普通设置 | 普通配置文件或设置页 |
|
||||
| 私钥文件、证书、多行密钥 | 专用凭据或文件授权机制 |
|
||||
| CI、容器、远程服务器 | 已有 Secret 管理与可信运行时注入,不暴露本机输入页到网络 |
|
||||
|
||||
本组件减少密钥进入 Agent 对话和工具输出的机会,不是对同一用户下任意代码执行或浏览器控制的强隔离。
|
||||
|
||||
## 安装与单项配置
|
||||
|
||||
要求 Node.js 22.18+,以及下文对应系统凭据服务。首次下载依赖后,本地输入页可离线运行:
|
||||
|
||||
```bash
|
||||
npm ci --ignore-scripts
|
||||
npm run configure -- --label "服务 API Key" --title "输入密钥" --placeholder "粘贴你的密钥"
|
||||
npm start
|
||||
```
|
||||
|
||||
用户打开返回的本机链接,亲自填写并保存。默认字段文件为 manifests/default.json;configure 只修改非敏感声明,不接收密钥。支持 --dry-run 和 --save-label。
|
||||
|
||||
字段声明示例:
|
||||
|
||||
```json
|
||||
{
|
||||
"version": 1,
|
||||
"id": "sample-skill",
|
||||
"label": "服务 API Key",
|
||||
"credential": "sample-skill/service/default",
|
||||
"ui": { "title": "输入密钥", "placeholder": "粘贴你的密钥", "saveLabel": "保存" }
|
||||
}
|
||||
```
|
||||
|
||||
id、label、credential 必填,ui 可省略。每项只接受非空单行密钥,最长 2500 个字符;系统后端的容量限制仍可能更低,失败时不会改存明文文件。密钥值不属于声明字段。
|
||||
|
||||
## 同页填写多个 Key
|
||||
|
||||
先创建另一项独立声明:
|
||||
|
||||
```bash
|
||||
npm run configure -- --manifest manifests/image.json --id sample-skill --label "图片服务 API Key" --credential sample-skill/image/default --placeholder "粘贴图片服务密钥"
|
||||
```
|
||||
|
||||
临时组合:
|
||||
|
||||
```bash
|
||||
npm start -- --manifest manifests/default.json --manifest manifests/image.json
|
||||
```
|
||||
|
||||
可复用页面配置:
|
||||
|
||||
```bash
|
||||
npm run configure-page -- --page manifests/setup.page.json --manifest manifests/default.json --manifest manifests/image.json --title "连接服务" --label "两项服务" --save-label "保存"
|
||||
npm start -- --page manifests/setup.page.json
|
||||
```
|
||||
|
||||
页面 JSON 只保存引用与文案:
|
||||
|
||||
```json
|
||||
{
|
||||
"version": 1,
|
||||
"manifests": ["default.json", "image.json"],
|
||||
"ui": { "title": "连接服务", "label": "两项服务", "saveLabel": "保存" }
|
||||
}
|
||||
```
|
||||
|
||||
每页 1 至 16 项,顺序由 manifests 决定。文件内的路径相对于页面配置文件,CLI 参数路径相对于运行目录。配置命令自动转换为相对路径;支持三端搬移。
|
||||
|
||||
只改页面文案:
|
||||
|
||||
```bash
|
||||
npm run configure-page -- --page manifests/setup.page.json --title "配置服务"
|
||||
```
|
||||
|
||||
不传 --manifest 保留列表,传入时替换整组。支持 --dry-run。字段标签和占位文字继续由 configure 修改;同页标题与按钮来自页面 ui。修改配置后重新启动。--page 和启动时的 --manifest 互斥。
|
||||
|
||||
已有字段声明的 id 与 credential 不可直接改写;新账号另建声明,不同账号使用不同引用。移除页面字段只改变表单,不删除系统凭据。
|
||||
|
||||
## 保存与恢复
|
||||
|
||||
尚未配置项必填;已有项显示“已配置”,留空保留,输入新值才替换。原值不会回填页面。替换按钮固定显示“替换并保存”,自定义文案不能覆盖这一提示。
|
||||
|
||||
整组预检通过后顺序保存。系统凭据库没有跨项事务:中途失败保留成功项、停止后续写入,并逐项标明结果,不自动回滚。用户先核对状态,再补填未完成项;后端报错可能存在结果不确定的情况,不能据此断言未写入。
|
||||
|
||||
提交结束或离开页面会清空输入。会话 30 分钟有效,全部成功后保留结果 90 秒。取消和过期不撤销此前保存。网络超时后先查状态,不重复提交。
|
||||
|
||||
Agent 只处理入口与脱敏状态,不自动操作含真实密钥的页面。/agent/status 使用启动令牌认证;只有 saved 表示全部成功,partial 表示部分失败。不要分享本机会话链接或让用户将密钥贴进聊天。
|
||||
|
||||
## 存储与平台
|
||||
|
||||
| 平台 | 后端 | 前提与管理入口 |
|
||||
| --- | --- | --- |
|
||||
| macOS | Keychain 钥匙串 | 当前用户钥匙串可访问;“钥匙串访问”中管理,系统授权由用户确认 |
|
||||
| Windows | Credential Manager 凭据管理器 | 当前用户会话可用;“凭据管理器 → Windows 凭据”中的对应通用凭据 |
|
||||
| Linux | secret-tool / Secret Service | libsecret 工具、用户 D-Bus 会话及已解锁的 Secret Service 实现,例如 GNOME Keyring;管理入口依桌面环境而定 |
|
||||
|
||||
macOS / Windows 通过 @napi-rs/keyring 原生绑定。Linux 显式调用 secret-tool,保存值经标准输入传递,不使用可能降级到临时 keyutils 的默认绑定。
|
||||
|
||||
服务标识为 org.oiloil.skill-credentials,账号属性为声明的 credential。不同后端界面显示名称可能不同。存储属于当前系统用户,本组件不负责跨设备同步、备份或迁移旧文件。
|
||||
|
||||
凭据库不可用或被锁定时停止,明确提示检查服务;不自动安装、解锁或降级到 JSON、浏览器存储等明文介质。服务器、无桌面 Linux 或其他凭据实现必须另行确认实际能力。
|
||||
|
||||
当前 macOS 已验证;Windows / Linux 已实现适配但尚未实机验证。
|
||||
|
||||
## 业务使用
|
||||
|
||||
把真实可信程序与环境变量名代入:
|
||||
|
||||
```bash
|
||||
node src/run.ts --manifest manifests/default.json --env SERVICE_API_KEY -- your-program your-arguments
|
||||
```
|
||||
|
||||
一次任务读取多个 Key:
|
||||
|
||||
```bash
|
||||
node src/run.ts --manifest manifests/default.json --env FIRST_API_KEY --manifest manifests/image.json --env SECOND_API_KEY -- your-program your-arguments
|
||||
```
|
||||
|
||||
全部读取成功后才启动程序,重复变量或缺少凭据时停止。页面文件只定义填写方式,业务端仍逐项绑定,避免注入不需要的密钥。密钥不进入命令参数或父进程环境。
|
||||
|
||||
包装器不是沙箱:可信子进程取得原值后仍可能打印或外传。需要强隔离时使用独立可信执行服务或宿主权限边界,不依赖密码框或系统凭据库作绝对保证。
|
||||
|
||||
检查单项状态:
|
||||
|
||||
```bash
|
||||
npm run status -- --manifest manifests/image.json
|
||||
```
|
||||
|
||||
仅返回配置状态与后端;未配置退出码 2,后端失败退出码 1。可信 Node.js 程序也可内部使用 nativeBackend 的 get / set / delete;保存与删除须用户授权,不向 Agent 暴露任意读取或删除接口。
|
||||
|
||||
## 开发验收
|
||||
|
||||
```bash
|
||||
npm run check
|
||||
npm run build
|
||||
npm test
|
||||
npm run test:native
|
||||
```
|
||||
|
||||
常规测试使用假后端与受控 DOM,不操作真实浏览器。原生测试经真实同页 HTTP 入口写入随机假凭据,检查多变量业务读取与隔离,最后清理测试项。修改前端后必须提交一致的 public/app.js,最终用户无需编译。
|
||||
|
||||
维护时同时验证单 Key、同页多 Key、留空保留、部分失败重试、未知字段拒绝、会话隔离、状态脱敏及系统后端。不能用另写的静态演示替代正式页面测试。
|
||||
|
||||
## 固定业务入口
|
||||
|
||||
`manifests/profiles.json` 只保存业务名、声明文件和环境变量,不保存密钥。目标 Skill 接入时提供实际配置名;单项或多项复用相同页面与运行器:
|
||||
|
||||
```bash
|
||||
node src/profile.ts status default
|
||||
node src/profile.ts setup default
|
||||
node src/profile.ts run default -- your-program your-arguments
|
||||
```
|
||||
|
||||
status 退出码 0 表示所需凭据可读取,2 表示缺失,1 表示后端或配置错误。setup 由用户亲自填写;run 优先复用运行时环境变量,缺失时仅读取对应声明,不把其他服务凭据注入任务。指定其他服务时同时修改业务参数,不能只换凭据。
|
||||
@@ -0,0 +1,29 @@
|
||||
{
|
||||
"version": 1,
|
||||
"files": {
|
||||
"README.md": "c66161233e7b45ed3027d43a1dacbb3f5eca68c8db52cac20d3781f4a868f786",
|
||||
"tsconfig.web.json": "7ced73bff9909988639b0369bbd4bfbb756d8570f75ac711a02b29a6e42d88e7",
|
||||
"package-lock.json": "0b457e658ad62b3d699d4aa3b0a504ae03e9f879a9d0a8d8d4e15d0db1a3845c",
|
||||
"package.json": "82c1b1b85ee7f5ce2be69d2f389167de6a7f3bfb47ef210a83c4dd4ec51d720d",
|
||||
"tsconfig.json": "005d701b301f56cf02563ba2936134899a60685d1b41536c13726457eab351a5",
|
||||
"web/app.ts": "c279800cdbdd67b145802ae0716588e605f05c43ad7468b43c1b26392bde461d",
|
||||
"tests/profile.test.ts": "48dcbbb90f0b39c4adaec1615c51edf33a76fff57549907da5e197ffde425da5",
|
||||
"tests/configure.test.ts": "3f360ed0102c944b7a502d783eea3c63016fdcaa3c230a1a6ec0865eb513cbd6",
|
||||
"tests/run.test.ts": "157f946c795d5f5b0a4c049201818bf0f646305050324d14f0913e751df1ca45",
|
||||
"tests/page.test.ts": "85772cc4b949443ff80d090bc1fc2fbc77f30ec4d8c26e40aa6530dd4270417a",
|
||||
"tests/native-smoke.ts": "d0a7ed2dd1ce7135a2a63bb8cfb779c24ccba19290cbabefaa280598866e0990",
|
||||
"tests/ui.test.ts": "613a90efc3607315a058a65a161e8d701453e4cba66a4cef89651a7ecd6c490a",
|
||||
"tests/credentials.test.ts": "2e33bbdfac5ffe04919526e755680e259e0a4aeef4cf411c5b39b1ccbeac0b13",
|
||||
"tests/multi-key.test.ts": "1879363e3718e8c538f01dcb180f78cbaa9b4a672e759068ec10b1188c1be654",
|
||||
"public/index.html": "859d06fa573f9f5409bc7d575e752a06dd08ce77fc67f464eea248f3b8f274be",
|
||||
"public/style.css": "7a993fc7fbd5edd29dfbff919c3370da3dc44fd6abc022709d55b892e3c0a893",
|
||||
"public/app.js": "7e08218bf04a2614bf801bc5c1c33f239bcdd2975804aa5869e60fdb6c4aaa3d",
|
||||
"public/favicon.svg": "574d5efbf305158c498636fbc81ecd27756b7cb7ff77ad73b4051fcf07d8fe42",
|
||||
"src/cli.ts": "a29c42750d81061f4721ce44ac3684d17d66fec791db155452234116c8988f34",
|
||||
"src/page.ts": "fbad1e2a9b1b2157444a715f55257f9992fea9b8dd8a9d3af15ef9545775f7f8",
|
||||
"src/profile.ts": "215ba42fa05cda235d1c9d09d42afbfc8c43a1d53fa4d7b7b4253948d6a3cdaf",
|
||||
"src/config.ts": "6d84f58c7859b49d291429819a4cd8b73051720ec420b34fcfb86538ed1cb687",
|
||||
"src/server.ts": "b84d14af6d805e1872875bd1aae94823b80d429139af9dc61ed937281795e2bf",
|
||||
"src/run.ts": "fe6b0ed145b6737ed472c929bb0e7f6c77721a0e4dfa9b6d785c3f09a2c5c028"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"version": 1,
|
||||
"id": "oil-motion",
|
||||
"label": "动画素材服务 API Key",
|
||||
"credential": "oil-motion/zenmux/default",
|
||||
"ui": {
|
||||
"title": "配置服务",
|
||||
"placeholder": "粘贴该服务的 API Key",
|
||||
"saveLabel": "保存"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"version": 1,
|
||||
"profiles": {
|
||||
"default": [
|
||||
{
|
||||
"manifest": "default.json",
|
||||
"env": "ZENMUX_API_KEY"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
+272
@@ -0,0 +1,272 @@
|
||||
{
|
||||
"name": "skill-credentials",
|
||||
"version": "0.1.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "skill-credentials",
|
||||
"version": "0.1.0",
|
||||
"dependencies": {
|
||||
"@napi-rs/keyring": "2.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "22.20.1",
|
||||
"typescript": "~5.9.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=22.18.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@napi-rs/keyring": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/keyring/-/keyring-2.0.0.tgz",
|
||||
"integrity": "sha512-TnrIt0nO9U2Ue9E9vJQjso1hqhIYiGrn2Ew1HBsMSqQe4+ceOqPJJwRVsV0/Wy7pqp04/doOLASyIFM3gGwZJw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/Brooooooklyn"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@napi-rs/keyring-darwin-arm64": "2.0.0",
|
||||
"@napi-rs/keyring-darwin-x64": "2.0.0",
|
||||
"@napi-rs/keyring-freebsd-x64": "2.0.0",
|
||||
"@napi-rs/keyring-linux-arm-gnueabihf": "2.0.0",
|
||||
"@napi-rs/keyring-linux-arm64-gnu": "2.0.0",
|
||||
"@napi-rs/keyring-linux-arm64-musl": "2.0.0",
|
||||
"@napi-rs/keyring-linux-riscv64-gnu": "2.0.0",
|
||||
"@napi-rs/keyring-linux-x64-gnu": "2.0.0",
|
||||
"@napi-rs/keyring-linux-x64-musl": "2.0.0",
|
||||
"@napi-rs/keyring-win32-arm64-msvc": "2.0.0",
|
||||
"@napi-rs/keyring-win32-ia32-msvc": "2.0.0",
|
||||
"@napi-rs/keyring-win32-x64-msvc": "2.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@napi-rs/keyring-darwin-arm64": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/keyring-darwin-arm64/-/keyring-darwin-arm64-2.0.0.tgz",
|
||||
"integrity": "sha512-yvIfviiXpsDSsPdyzWWd7STZt7v774biPfMBpWkiK7rwauwWbOmVjUzgiJ11rbhJbWLqXE3CuQMyLgvcbm3jIA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/@napi-rs/keyring-darwin-x64": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/keyring-darwin-x64/-/keyring-darwin-x64-2.0.0.tgz",
|
||||
"integrity": "sha512-XJUONH0c5cg7M9/1Vj3WeIi8TtWYdZlo8Jqho09ga8OWm9cFNxUv7+4QZx5UK/3JSp2qiyhTlresyKdcpwpnRw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/@napi-rs/keyring-freebsd-x64": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/keyring-freebsd-x64/-/keyring-freebsd-x64-2.0.0.tgz",
|
||||
"integrity": "sha512-u/M114J9Lp3RqtIZihIqhvreNQ5f8wgLFo9tJFy+bIIO/xEHkMR0LENrfUF8hSY6FTNyS2kSbbkN6yOItBFY5g==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"freebsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/@napi-rs/keyring-linux-arm-gnueabihf": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/keyring-linux-arm-gnueabihf/-/keyring-linux-arm-gnueabihf-2.0.0.tgz",
|
||||
"integrity": "sha512-CHMv/KTuELo/MsrGUha52KrFGPBuBkeAonnMFUd3nNcxBTSmeJbCAZTuVSXzpessdpb4tT9xIMQsv2ZdWrl8iw==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/@napi-rs/keyring-linux-arm64-gnu": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/keyring-linux-arm64-gnu/-/keyring-linux-arm64-gnu-2.0.0.tgz",
|
||||
"integrity": "sha512-BeUUPGSnW026yDGT4pKuNXDnwxw0xslwiSK6cuOIsDNLi3UO93rfF/7moqKznrBldgsZr8pl9LkMdRk8bnbjEA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/@napi-rs/keyring-linux-arm64-musl": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/keyring-linux-arm64-musl/-/keyring-linux-arm64-musl-2.0.0.tgz",
|
||||
"integrity": "sha512-zj7wZ23Vs7SL4odnGDbWnhZhiyrnEgBe4+8dzKHDi1mTBX1d00FYlGOubkjvx3AeK7mm6G2FyFDDFqIxyR82gA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/@napi-rs/keyring-linux-riscv64-gnu": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/keyring-linux-riscv64-gnu/-/keyring-linux-riscv64-gnu-2.0.0.tgz",
|
||||
"integrity": "sha512-xk/1SOhuk2yQvXiN+pBhR4njSfquLm1SUKUIJcPIeYV1bHFXbUsYrXdfy4NtynSW0lhs41zWjLXSdE8TLeTt2g==",
|
||||
"cpu": [
|
||||
"riscv64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/@napi-rs/keyring-linux-x64-gnu": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/keyring-linux-x64-gnu/-/keyring-linux-x64-gnu-2.0.0.tgz",
|
||||
"integrity": "sha512-12Dq6t2TOrQTibcJcfV5bnHbTvMwEz6zSDqQLHMO2x388gGFQBAeEvw7Hmt+R1QavxspTa2ptgI7axomu9TH+w==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/@napi-rs/keyring-linux-x64-musl": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/keyring-linux-x64-musl/-/keyring-linux-x64-musl-2.0.0.tgz",
|
||||
"integrity": "sha512-7NJZvFUiL1FPCrSIQ1L4IUzN6l/2zoxT3IN6j3rHTuDdIEunJaycrUovDMvPlBNP1Vh3fSYz6Pp7tFjdt/1/oQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/@napi-rs/keyring-win32-arm64-msvc": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/keyring-win32-arm64-msvc/-/keyring-win32-arm64-msvc-2.0.0.tgz",
|
||||
"integrity": "sha512-JTktZGXKow0HF/rhaZiQYB8DUS/iSX7S7FvJDoGxPT8mGavKE3w/vadtfKSBNDL4uGef3wu2Ll0XxSfDKBsm2Q==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/@napi-rs/keyring-win32-ia32-msvc": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/keyring-win32-ia32-msvc/-/keyring-win32-ia32-msvc-2.0.0.tgz",
|
||||
"integrity": "sha512-AzvIFTqn1hJzCPu0foeYWn+kXruAIrVq3Z2IfJ24WPbIXUdD7+rcj49fqco7b14Z6C1aUfsSTyvrAgDfUWhKvA==",
|
||||
"cpu": [
|
||||
"ia32"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/@napi-rs/keyring-win32-x64-msvc": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/keyring-win32-x64-msvc/-/keyring-win32-x64-msvc-2.0.0.tgz",
|
||||
"integrity": "sha512-POpEUTV6U+pb69cpuOgtLV4xhcyEWDWq+/9zdiZmNxTL1AenE0MllWrbepS+Hng4M7EarQ5TB3kX69ASqGwfIw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/node": {
|
||||
"version": "22.20.1",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.20.1.tgz",
|
||||
"integrity": "sha512-EANqOCF9QFyra+4pfxUcX9STKJpCLjMbObVzljIJomAWSnuSIEAvyzEU53GaajbXJEgdh0iEcPL+DGvpUd4k1Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"undici-types": "~6.21.0"
|
||||
}
|
||||
},
|
||||
"node_modules/typescript": {
|
||||
"version": "5.9.3",
|
||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
|
||||
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
"tsc": "bin/tsc",
|
||||
"tsserver": "bin/tsserver"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14.17"
|
||||
}
|
||||
},
|
||||
"node_modules/undici-types": {
|
||||
"version": "6.21.0",
|
||||
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
|
||||
"integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"name": "skill-credentials",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"engines": {
|
||||
"node": ">=22.18.0"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "tsc -p tsconfig.web.json",
|
||||
"check": "tsc --noEmit -p tsconfig.json",
|
||||
"start": "node src/server.ts",
|
||||
"configure": "node src/cli.ts configure",
|
||||
"configure-page": "node src/cli.ts configure-page",
|
||||
"status": "node src/cli.ts status",
|
||||
"test": "node --test tests/*.test.ts",
|
||||
"test:native": "node tests/native-smoke.ts"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "22.20.1",
|
||||
"typescript": "~5.9.3"
|
||||
},
|
||||
"dependencies": {
|
||||
"@napi-rs/keyring": "2.0.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
"use strict";
|
||||
const get = (id) => document.getElementById(id);
|
||||
const form = get('credential-form');
|
||||
const button = get('save');
|
||||
let inputs = [];
|
||||
let meta;
|
||||
let busy = false;
|
||||
const saveLabel = () => inputs.some((input, index) => input.value.trim() && meta.fields[index].configured)
|
||||
? '替换并保存' : (meta.page.saveLabel ?? (meta.fields.length === 1 ? meta.fields[0].ui?.saveLabel : undefined) ?? '保存');
|
||||
async function request(url, options = {}) {
|
||||
let response;
|
||||
try {
|
||||
response = await fetch(url, { ...options, credentials: 'same-origin', cache: 'no-store',
|
||||
headers: { 'Content-Type': 'application/json', 'X-Local-Request': '1', ...options.headers }, signal: AbortSignal.timeout(25_000) });
|
||||
}
|
||||
catch {
|
||||
throw new Error('未收到服务回复,请刷新确认保存结果,不要重复提交。');
|
||||
}
|
||||
const result = await response.json();
|
||||
if (!response.ok)
|
||||
throw new Error(result.error ?? '操作未完成,请重试。');
|
||||
return result;
|
||||
}
|
||||
function message(text) { const node = get('message'); node.textContent = text; node.hidden = false; }
|
||||
function clearInputs() { inputs.forEach(input => { input.value = ''; }); }
|
||||
function done() {
|
||||
clearInputs();
|
||||
form.hidden = true;
|
||||
get('heading').textContent = '已保存';
|
||||
get('hint').hidden = true;
|
||||
message('可以关闭此页,回到对话继续。');
|
||||
}
|
||||
function update() {
|
||||
button.disabled = busy || !meta || !['waiting', 'partial'].includes(meta.outcome)
|
||||
|| !inputs.some(input => input.value.trim())
|
||||
|| inputs.some((input, index) => !meta.fields[index].configured && !input.value.trim());
|
||||
if (meta && !busy)
|
||||
button.textContent = saveLabel();
|
||||
}
|
||||
function render() {
|
||||
clearInputs();
|
||||
inputs = [];
|
||||
const multi = meta.fields.length > 1;
|
||||
form.classList.toggle('multi', multi);
|
||||
const container = get('fields');
|
||||
container.replaceChildren();
|
||||
meta.fields.forEach((field, index) => {
|
||||
const wrapper = document.createElement('div');
|
||||
wrapper.className = 'field';
|
||||
const label = document.createElement('label');
|
||||
label.htmlFor = 'secret-' + index;
|
||||
label.textContent = field.label;
|
||||
if (!multi)
|
||||
label.className = 'visually-hidden';
|
||||
if (field.configured) {
|
||||
const state = document.createElement('span');
|
||||
state.className = 'field-state';
|
||||
state.textContent = '已配置';
|
||||
label.append(state);
|
||||
}
|
||||
const input = document.createElement('input');
|
||||
input.id = label.htmlFor;
|
||||
input.type = 'password';
|
||||
input.autocomplete = 'new-password';
|
||||
input.autocapitalize = 'off';
|
||||
input.spellcheck = false;
|
||||
input.maxLength = 2500;
|
||||
input.required = !field.configured;
|
||||
input.setAttribute('aria-describedby', 'hint message');
|
||||
input.placeholder = field.configured ? '留空保留,输入则替换' : (field.ui?.placeholder ?? '粘贴 API Key');
|
||||
input.disabled = !['waiting', 'partial'].includes(meta.outcome);
|
||||
wrapper.append(label, input);
|
||||
container.append(wrapper);
|
||||
inputs.push(input);
|
||||
});
|
||||
get('context').textContent = meta.page.label ?? (multi ? meta.fields.length + ' 项凭据' : meta.fields[0].label);
|
||||
const title = meta.page.title ?? (multi ? '输入密钥' : meta.fields[0].ui?.title) ?? '输入密钥';
|
||||
get('heading').textContent = title;
|
||||
document.title = title;
|
||||
const storage = [...new Set(meta.fields.map(field => field.storage))].join('、');
|
||||
get('hint').textContent = '仅保存到' + storage + (meta.fields.some(field => field.configured) ? ' · 已配置项留空保留' : '');
|
||||
update();
|
||||
if (meta.outcome === 'saved')
|
||||
done();
|
||||
else if (meta.outcome === 'partial')
|
||||
message('上次仅部分保存。请核对已配置项,补填未完成项后重试。');
|
||||
else if (meta.outcome !== 'waiting')
|
||||
message('本次配置已结束,请重新打开入口。');
|
||||
}
|
||||
form.addEventListener('input', () => { update(); get('message').hidden = true; });
|
||||
form.addEventListener('submit', async (event) => {
|
||||
event.preventDefault();
|
||||
if (busy || button.disabled)
|
||||
return;
|
||||
busy = true;
|
||||
update();
|
||||
inputs.forEach(input => { input.disabled = true; });
|
||||
button.textContent = '正在保存…';
|
||||
try {
|
||||
const entries = inputs.flatMap((input, index) => input.value.trim() ? [{ credential: meta.fields[index].credential,
|
||||
value: input.value, revision: meta.fields[index].revision, replaceExisting: meta.fields[index].configured }] : []);
|
||||
const result = await request('/api/save', { method: 'POST', body: JSON.stringify({ entries }) });
|
||||
if (result.status === 'saved') {
|
||||
meta.outcome = 'saved';
|
||||
done();
|
||||
}
|
||||
else {
|
||||
clearInputs();
|
||||
meta = await request('/api/meta');
|
||||
render();
|
||||
const statuses = { saved: '已保存', failed: '未确认成功', not_attempted: '未尝试' };
|
||||
message((result.results ?? []).map(item => (meta.fields.find(field => field.credential === item.credential)?.label ?? '凭据') + ':' + (statuses[item.status] ?? '请核对')).join(';')
|
||||
+ '。请核对状态后重新填写未完成项。');
|
||||
}
|
||||
}
|
||||
catch (error) {
|
||||
clearInputs();
|
||||
try {
|
||||
meta = await request('/api/meta');
|
||||
render();
|
||||
}
|
||||
catch {
|
||||
meta.outcome = 'unknown';
|
||||
}
|
||||
if (meta.outcome !== 'saved')
|
||||
message(error instanceof Error ? error.message : '保存未完成,请刷新确认结果。');
|
||||
}
|
||||
finally {
|
||||
clearInputs();
|
||||
busy = false;
|
||||
inputs.forEach(input => { input.disabled = !['waiting', 'partial'].includes(meta.outcome); });
|
||||
update();
|
||||
}
|
||||
});
|
||||
window.addEventListener('pagehide', clearInputs);
|
||||
async function initialize() {
|
||||
const token = location.hash.slice(1);
|
||||
if (token) {
|
||||
history.replaceState(null, '', location.pathname);
|
||||
await request('/api/session', { method: 'POST', headers: { Authorization: 'Bearer ' + token }, body: '{}' });
|
||||
}
|
||||
meta = await request('/api/meta');
|
||||
render();
|
||||
}
|
||||
initialize().catch(error => { get('context').textContent = '暂时无法连接'; message(error instanceof Error ? error.message : '请重新打开配置入口。'); });
|
||||
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 40 40"><rect width="40" height="40" rx="12" fill="#292929"/><path d="M12 19v-4a8 8 0 0 1 16 0v4" fill="none" stroke="#eeeeee" stroke-width="3"/><rect x="9" y="18" width="22" height="16" rx="5" fill="#eeeeee"/><circle cx="20" cy="25" r="2" fill="#292929"/></svg>
|
||||
|
After Width: | Height: | Size: 315 B |
@@ -0,0 +1,28 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<meta name="color-scheme" content="light" />
|
||||
<meta name="referrer" content="no-referrer" />
|
||||
<title>输入密钥</title>
|
||||
<link rel="icon" href="/favicon.svg" type="image/svg+xml" />
|
||||
<link rel="stylesheet" href="/style.css" />
|
||||
<script type="module" src="/app.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<main>
|
||||
<h1 id="heading">输入密钥</h1>
|
||||
<p id="context" class="context">正在连接…</p>
|
||||
<form id="credential-form" autocomplete="off">
|
||||
<div id="fields">
|
||||
<label for="secret" class="visually-hidden">密钥</label>
|
||||
<input id="secret" name="secret" type="password" placeholder="粘贴 API Key" autocomplete="new-password" autocapitalize="off" autocorrect="off" spellcheck="false" maxlength="2500" required disabled aria-describedby="hint message" />
|
||||
</div>
|
||||
<button id="save" type="submit" disabled>保存</button>
|
||||
</form>
|
||||
<p id="hint" class="hint">仅保存到本机系统凭据库</p>
|
||||
<p id="message" class="message" role="status" aria-live="polite" hidden></p>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,143 @@
|
||||
@charset "UTF-8";
|
||||
|
||||
:root {
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", "Microsoft YaHei", sans-serif;
|
||||
color: #282828;
|
||||
background: #fafafa;
|
||||
font-synthesis: none;
|
||||
color-scheme: light;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
body {
|
||||
margin: 0;
|
||||
min-height: 100svh;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
padding: 24px;
|
||||
}
|
||||
main {
|
||||
width: 100%;
|
||||
max-width: 320px;
|
||||
margin-top: -5vh;
|
||||
}
|
||||
h1 {
|
||||
margin: 0 0 6px;
|
||||
font-size: 20px;
|
||||
font-weight: 580;
|
||||
letter-spacing: -.45px;
|
||||
line-height: 1.45;
|
||||
}
|
||||
.context {
|
||||
margin: 0 0 19px;
|
||||
font-size: 11.5px;
|
||||
color: #777777;
|
||||
line-height: 1.6;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
form {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
align-items: start;
|
||||
gap: 8px;
|
||||
}
|
||||
#fields, .field { display: contents; }
|
||||
form.multi { grid-template-columns: 1fr; gap: 18px; }
|
||||
.multi #fields { display: grid; gap: 17px; }
|
||||
.multi .field { display: grid; gap: 7px; }
|
||||
.multi label { font-size: 12px; line-height: 1.5; overflow-wrap: anywhere; }
|
||||
.field-state { color: #818181; font-size: 10.5px; margin-left: 7px; }
|
||||
.multi button { justify-self: end; height: 36px; }
|
||||
body:has(form.multi) { align-items: safe center; }
|
||||
body:has(form.multi) main { margin-top: 0; }
|
||||
input, button {
|
||||
font: inherit;
|
||||
border-radius: 8px;
|
||||
height: 42px;
|
||||
}
|
||||
input {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
background: #fff;
|
||||
border: 1px solid #dddddd;
|
||||
padding: 0 12px;
|
||||
font-size: 14px;
|
||||
color: #303030;
|
||||
outline: none;
|
||||
box-shadow: 0 1px 2px #00000003;
|
||||
transition: border-color .15s, box-shadow .15s;
|
||||
}
|
||||
input::placeholder {
|
||||
color: #999999;
|
||||
font-size: 12px;
|
||||
}
|
||||
input:hover:not(:disabled) { border-color: #c5c5c5; }
|
||||
input:focus {
|
||||
border-color: #929292;
|
||||
box-shadow: 0 0 0 3px #eeeeee, 0 1px 2px #00000003;
|
||||
}
|
||||
input:disabled { background: #f4f4f4; }
|
||||
button {
|
||||
min-width: 62px;
|
||||
padding: 0 15px;
|
||||
background: #303030;
|
||||
border: 1px solid #292929;
|
||||
color: #fafafa;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
white-space: nowrap;
|
||||
cursor: pointer;
|
||||
box-shadow: inset 0 1px 0 #ffffff0d, 0 1px 2px #0000000c;
|
||||
transition: background .15s, border-color .15s, transform .12s;
|
||||
}
|
||||
button:hover:not(:disabled) {
|
||||
background: #454545;
|
||||
border-color: #3c3c3c;
|
||||
}
|
||||
button:active:not(:disabled) { transform: translateY(1px); }
|
||||
button:disabled {
|
||||
background: #ececec;
|
||||
border-color: #e3e3e3;
|
||||
color: #949494;
|
||||
box-shadow: none;
|
||||
cursor: default;
|
||||
}
|
||||
button:focus-visible {
|
||||
outline: 3px solid #c9c9c9;
|
||||
outline-offset: 3px;
|
||||
}
|
||||
.hint {
|
||||
margin: 12px 0 0;
|
||||
font-size: 10.5px;
|
||||
color: #818181;
|
||||
line-height: 1.7;
|
||||
}
|
||||
.message {
|
||||
margin: 12px 0 0;
|
||||
font-size: 11px;
|
||||
color: #5c5c5c;
|
||||
line-height: 1.8;
|
||||
}
|
||||
.message.success { color: #5c5c5c; }
|
||||
.visually-hidden {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
padding: 0;
|
||||
margin: -1px;
|
||||
overflow: hidden;
|
||||
clip: rect(0, 0, 0, 0);
|
||||
white-space: nowrap;
|
||||
border: 0;
|
||||
}
|
||||
[hidden] { display: none !important; }
|
||||
|
||||
@media (max-width: 480px) {
|
||||
main { margin-top: -8vh; }
|
||||
input { font-size: 16px; }
|
||||
input, button { height: 44px; }
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
* { transition: none !important; }
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
import { randomBytes } from 'node:crypto';
|
||||
import { lstat, mkdir, open, readFile, rename, unlink } from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath, pathToFileURL } from 'node:url';
|
||||
import { loadManifest, nativeBackend, PublicError, validateManifest, type Manifest } from './config.ts';
|
||||
import { loadPage, validateFields, validatePageUI } from './page.ts';
|
||||
|
||||
async function writeConfiguration(file: string, configuration: object) {
|
||||
await mkdir(path.dirname(file), { recursive: true });
|
||||
const temporary = file + '.' + randomBytes(8).toString('hex') + '.tmp';
|
||||
try {
|
||||
const handle = await open(temporary, 'wx', 0o600);
|
||||
try { await handle.writeFile(JSON.stringify(configuration, null, 2) + '\n', 'utf8'); await handle.sync(); }
|
||||
finally { await handle.close(); }
|
||||
await rename(temporary, file);
|
||||
} finally { await unlink(temporary).catch(() => {}); }
|
||||
}
|
||||
export async function configurePage(file: string, manifests: string[], options: Record<string, string>, dryRun = false) {
|
||||
if (Object.keys(options).some(key => !['title', 'label', 'save-label'].includes(key))) throw new PublicError('存在不支持的页面配置项。');
|
||||
let existing: { manifests: string[]; ui?: Record<string, string> } | undefined;
|
||||
try {
|
||||
const stat = await lstat(file);
|
||||
if (!stat.isFile() || stat.isSymbolicLink() || stat.nlink > 1) throw new PublicError('页面配置必须是独立的普通文件。');
|
||||
await loadPage(file);
|
||||
existing = JSON.parse(await readFile(file, 'utf8'));
|
||||
} catch (error) { if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error; }
|
||||
// 命令参数按当前目录解析;写入页面文件后统一保存为相对该文件的路径。
|
||||
const paths = manifests.length ? manifests.map(item => path.relative(path.dirname(file), path.resolve(item)).split(path.sep).join('/')) : existing?.manifests ?? [];
|
||||
validateFields(await Promise.all(paths.map(item => loadManifest(path.resolve(path.dirname(file), item)))));
|
||||
const ui = { ...existing?.ui };
|
||||
for (const [flag, key] of [['title', 'title'], ['label', 'label'], ['save-label', 'saveLabel']])
|
||||
if (options[flag] !== undefined) ui[key] = options[flag];
|
||||
const configuration = { version: 1, manifests: paths, ui: validatePageUI(ui) };
|
||||
if (!dryRun) await writeConfiguration(file, configuration);
|
||||
return { status: dryRun ? 'preview' : 'configured', page: file, configuration };
|
||||
}
|
||||
|
||||
export async function configureManifest(file: string, options: Record<string, string>, dryRun = false) {
|
||||
const allowed = ['id', 'label', 'credential', 'title', 'placeholder', 'save-label'];
|
||||
if (Object.keys(options).some(k => !allowed.includes(k))) throw new PublicError('存在不支持的配置项。');
|
||||
let existing: Manifest | undefined;
|
||||
try {
|
||||
const stat = await lstat(file);
|
||||
if (!stat.isFile() || stat.isSymbolicLink() || stat.nlink > 1) throw new PublicError('声明必须是独立的普通文件。');
|
||||
existing = await loadManifest(file);
|
||||
} catch (error) { if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error; }
|
||||
if (existing && ((options.id && options.id !== existing.id) || (options.credential && options.credential !== existing.credential)))
|
||||
throw new PublicError('已有声明的身份和凭据引用不可改写。请为新凭据指定新的 --manifest 文件。');
|
||||
const draft: Record<string, unknown> = { version: 1, ...existing };
|
||||
for (const key of ['id', 'label', 'credential']) if (options[key] !== undefined) draft[key] = options[key];
|
||||
const ui = { ...existing?.ui };
|
||||
for (const [flag, key] of [['title', 'title'], ['placeholder', 'placeholder'], ['save-label', 'saveLabel']] as const)
|
||||
if (options[flag] !== undefined) ui[key] = options[flag];
|
||||
if (Object.keys(ui).length) draft.ui = ui;
|
||||
const manifest = validateManifest(draft);
|
||||
if (!dryRun) await writeConfiguration(file, manifest);
|
||||
return { status: dryRun ? 'preview' : 'configured', manifest: file, configuration: manifest };
|
||||
}
|
||||
async function main() {
|
||||
const [command, ...args] = process.argv.slice(2);
|
||||
if (!command || command === '--help') {
|
||||
process.stdout.write('configure [--manifest 文件] --id 标识 --label 用途 --credential 引用 [--title 标题] [--placeholder 占位文字] [--save-label 按钮文字] [--dry-run]\nconfigure-page --page 文件 [--manifest 声明(可重复,替换整组)] [--title 标题] [--label 用途] [--save-label 按钮文字] [--dry-run]\nstatus [--manifest 文件]\n');
|
||||
return;
|
||||
}
|
||||
const flags: Record<string, string> = {};
|
||||
const manifests: string[] = [];
|
||||
let dryRun = false;
|
||||
for (let i = 0; i < args.length; i++) {
|
||||
if (args[i] === '--dry-run' && ['configure', 'configure-page'].includes(command)) { dryRun = true; continue; }
|
||||
if (command === 'configure-page' && args[i] === '--manifest' && args[i + 1] && !args[i + 1].startsWith('--')) { manifests.push(args[++i]); continue; }
|
||||
const name = args[i].replace(/^--/, '');
|
||||
if (!args[i].startsWith('--') || !args[i + 1] || args[i + 1].startsWith('--') || Object.hasOwn(flags, name))
|
||||
throw new PublicError('命令参数不正确。');
|
||||
flags[name] = args[++i];
|
||||
}
|
||||
if (command === 'configure-page') {
|
||||
if (!flags.page) throw new PublicError('请指定 --page 配置文件。');
|
||||
const file = path.resolve(flags.page); delete flags.page;
|
||||
process.stdout.write(JSON.stringify(await configurePage(file, manifests, flags, dryRun)) + '\n'); return;
|
||||
}
|
||||
const file = path.resolve(flags.manifest ?? fileURLToPath(new URL('../manifests/default.json', import.meta.url)));
|
||||
delete flags.manifest;
|
||||
if (command === 'configure') process.stdout.write(JSON.stringify(await configureManifest(file, flags, dryRun)) + '\n');
|
||||
else if (command === 'status' && !Object.keys(flags).length) {
|
||||
const m = await loadManifest(file);
|
||||
const backend = await nativeBackend(m.credential);
|
||||
const configured = (await backend.get()) !== undefined;
|
||||
process.stdout.write(JSON.stringify({ id: m.id, credential: m.credential, configured, storage: backend.name }) + '\n');
|
||||
if (!configured) process.exitCode = 2;
|
||||
} else throw new PublicError('命令不支持,请使用 --help。');
|
||||
}
|
||||
if (process.argv[1] && pathToFileURL(path.resolve(process.argv[1])).href === import.meta.url)
|
||||
main().catch(error => {
|
||||
process.stderr.write((error instanceof PublicError ? error.message : '配置操作未完成,请检查文件位置与权限。') + '\n');
|
||||
process.exitCode = 1;
|
||||
});
|
||||
@@ -0,0 +1,103 @@
|
||||
import { createHmac, randomBytes } from 'node:crypto';
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import { execFile, spawn } from 'node:child_process';
|
||||
|
||||
export class PublicError extends Error {
|
||||
status: number;
|
||||
constructor(message: string, status = 400) { super(message); this.status = status; }
|
||||
}
|
||||
export type Manifest = {
|
||||
version: 1; id: string; label: string; credential: string;
|
||||
ui?: { title?: string; placeholder?: string; saveLabel?: string };
|
||||
};
|
||||
export const object = (v: unknown): v is Record<string, unknown> => !!v && typeof v === 'object' && !Array.isArray(v);
|
||||
export async function loadManifest(file: string): Promise<Manifest> {
|
||||
let m: unknown;
|
||||
try { m = JSON.parse(await readFile(file, 'utf8')); } catch { throw new PublicError('无法读取配置声明。'); }
|
||||
return validateManifest(m);
|
||||
}
|
||||
export function validateManifest(m: unknown): Manifest {
|
||||
if (!object(m) || m.version !== 1 || typeof m.id !== 'string' || !/^[a-z0-9-]{1,80}$/.test(m.id)
|
||||
|| typeof m.label !== 'string' || !m.label.trim() || m.label.length > 120
|
||||
|| typeof m.credential !== 'string' || !/^[a-z0-9][a-z0-9/_.-]{0,150}$/.test(m.credential)
|
||||
|| Object.keys(m).some(k => !['version', 'id', 'label', 'credential', 'ui'].includes(k))) throw new PublicError('配置声明不合法。');
|
||||
if (m.ui !== undefined && (!object(m.ui) || Object.entries(m.ui).some(([k, v]) =>
|
||||
!['title', 'placeholder', 'saveLabel'].includes(k) || typeof v !== 'string' || !v.trim() || v.length > 80)))
|
||||
throw new PublicError('页面配置不合法。');
|
||||
return m as Manifest;
|
||||
}
|
||||
export interface CredentialBackend {
|
||||
name: string;
|
||||
get(): Promise<string | undefined>;
|
||||
set(value: string): Promise<void>;
|
||||
delete(): Promise<void>;
|
||||
}
|
||||
export const service = 'org.oiloil.skill-credentials';
|
||||
const backendError = () => new PublicError('无法访问系统凭据库,请解锁或检查系统凭据服务后重试。', 503);
|
||||
|
||||
// Linux 显式使用 Secret Service,不走可能回退到临时 keyutils 的默认绑定。
|
||||
function linuxBackend(credential: string): CredentialBackend {
|
||||
const attributes = ['service', service, 'account', credential];
|
||||
const run = (args: string[]): Promise<string | undefined> => new Promise((resolve, reject) => {
|
||||
execFile('secret-tool', args, { timeout: 15_000, maxBuffer: 32_768, encoding: 'utf8' }, (error, stdout, stderr) => {
|
||||
if (error) {
|
||||
if (args[0] === 'lookup' && error.code === 1 && !stderr.trim()) resolve(undefined);
|
||||
else reject(backendError());
|
||||
} else resolve(stdout.replace(/\r?\n$/, ''));
|
||||
});
|
||||
});
|
||||
return { name: 'Linux Secret Service', get: () => run(['lookup', ...attributes]),
|
||||
set: value => new Promise((resolve, reject) => {
|
||||
const child = spawn('secret-tool', ['store', '--label=Skill 凭据', ...attributes], { stdio: ['pipe', 'ignore', 'ignore'], timeout: 15_000, shell: false });
|
||||
child.once('error', () => reject(backendError()));
|
||||
child.stdin.on('error', () => reject(backendError()));
|
||||
child.once('close', code => code === 0 ? resolve() : reject(backendError()));
|
||||
child.stdin.end(value + '\n');
|
||||
}),
|
||||
delete: async () => { await run(['clear', ...attributes]); }
|
||||
};
|
||||
}
|
||||
export async function nativeBackend(credential: string): Promise<CredentialBackend> {
|
||||
if (process.platform === 'linux') return linuxBackend(credential);
|
||||
if (!['darwin', 'win32'].includes(process.platform)) throw new PublicError('当前系统暂不支持凭据保存。', 503);
|
||||
try {
|
||||
const { AsyncEntry } = await import('@napi-rs/keyring');
|
||||
const entry = new AsyncEntry(service, credential);
|
||||
return { name: process.platform === 'darwin' ? 'macOS 钥匙串' : 'Windows 凭据管理器',
|
||||
// 原生绑定的空结果在实际运行中可能为 null,统一为接口约定的 undefined。
|
||||
get: async () => { try { return (await entry.getPassword()) ?? undefined; } catch { throw backendError(); } },
|
||||
set: async value => { try { await entry.setPassword(value); } catch { throw backendError(); } },
|
||||
delete: async () => { try { await entry.deleteCredential(); } catch { throw backendError(); } }
|
||||
};
|
||||
} catch { throw backendError(); }
|
||||
}
|
||||
export function createStore(manifest: Manifest, backend: CredentialBackend) {
|
||||
const salt = randomBytes(32);
|
||||
let busy = false;
|
||||
async function status() {
|
||||
let value: string | undefined;
|
||||
try { value = await backend.get(); } catch { throw backendError(); }
|
||||
const revision = createHmac('sha256', salt).update(value === undefined ? 'missing:' : 'exists:').update(value ?? '').digest('hex');
|
||||
return { revision, configured: value !== undefined, storage: backend.name };
|
||||
}
|
||||
async function validate(input: unknown) {
|
||||
if (!object(input) || typeof input.value !== 'string' || !input.value.trim() || input.value.length > 2500
|
||||
|| /[\r\n\0]/.test(input.value) || typeof input.revision !== 'string'
|
||||
|| Object.keys(input).some(k => !['value', 'revision', 'replaceExisting'].includes(k))) throw new PublicError('请填写有效的单行密钥。');
|
||||
const current = await status();
|
||||
if (current.revision !== input.revision) throw new PublicError('凭据已发生变化,请刷新后重试。', 409);
|
||||
if (current.configured && input.replaceExisting !== true) throw new PublicError('已有凭据,请确认替换后保存。', 409);
|
||||
return { value: input.value.trim() };
|
||||
}
|
||||
async function save(input: unknown) {
|
||||
if (busy) throw new PublicError('正在保存,请稍后。', 409);
|
||||
busy = true;
|
||||
try {
|
||||
const checked = await validate(input);
|
||||
await backend.set(checked.value);
|
||||
return { status: 'saved', skill: manifest.id, credential: manifest.credential, configured: true };
|
||||
} catch (error) { if (error instanceof PublicError) throw error; throw backendError(); }
|
||||
finally { busy = false; }
|
||||
}
|
||||
return { status, save, validate };
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import { loadManifest, object, PublicError, validateManifest, type Manifest } from './config.ts';
|
||||
|
||||
export type PageUI = { title?: string; label?: string; saveLabel?: string };
|
||||
export function validatePageUI(ui: unknown): PageUI {
|
||||
if (!object(ui) || Object.entries(ui).some(([key, value]) =>
|
||||
!['title', 'label', 'saveLabel'].includes(key) || typeof value !== 'string' || !value.trim() || value.length > 120))
|
||||
throw new PublicError('页面文案配置不合法。');
|
||||
return ui as PageUI;
|
||||
}
|
||||
export function validateFields(fields: Manifest[]): Manifest[] {
|
||||
if (!Array.isArray(fields) || fields.length < 1 || fields.length > 16) throw new PublicError('每页需要 1 至 16 项凭据声明。');
|
||||
fields.forEach(validateManifest);
|
||||
if (new Set(fields.map(field => field.credential)).size !== fields.length) throw new PublicError('同一页不能重复引用相同凭据。');
|
||||
return fields;
|
||||
}
|
||||
export async function loadPage(file: string) {
|
||||
let value: unknown;
|
||||
try { value = JSON.parse(await readFile(file, 'utf8')); } catch { throw new PublicError('无法读取页面配置。'); }
|
||||
if (!object(value) || value.version !== 1 || Object.keys(value).some(key => !['version', 'manifests', 'ui'].includes(key))
|
||||
|| !Array.isArray(value.manifests) || !value.manifests.length || value.manifests.length > 16
|
||||
|| value.manifests.some(item => typeof item !== 'string' || !item.trim())) throw new PublicError('页面配置不合法。');
|
||||
const ui = validatePageUI(value.ui ?? {});
|
||||
const manifests = validateFields(await Promise.all(value.manifests.map(item => loadManifest(path.resolve(path.dirname(file), item)))));
|
||||
return { manifests, ui };
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import { spawn } from 'node:child_process';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath, pathToFileURL } from 'node:url';
|
||||
import { loadManifest, nativeBackend, object, PublicError } from './config.ts';
|
||||
import { validateFields } from './page.ts';
|
||||
import { parseBindings, prepareCommand } from './run.ts';
|
||||
import { startServer } from './server.ts';
|
||||
|
||||
const root = fileURLToPath(new URL('../', import.meta.url));
|
||||
type Binding = { manifest: string; env: string };
|
||||
export async function loadProfile(name: string, directory = root): Promise<Binding[]> {
|
||||
let value: unknown;
|
||||
try { value = JSON.parse(await readFile(path.join(directory, 'manifests/profiles.json'), 'utf8')); }
|
||||
catch { throw new PublicError('未找到业务凭据配置。'); }
|
||||
if (!/^[a-z0-9-]+$/.test(name) || !object(value) || value.version !== 1 || !object(value.profiles)
|
||||
|| !Object.hasOwn(value.profiles, name)) throw new PublicError('请选择已声明的业务配置。');
|
||||
const items = value.profiles[name];
|
||||
if (!Array.isArray(items) || !items.length || items.length > 16) throw new PublicError('业务凭据配置不合法。');
|
||||
const bindings: Binding[] = items.map(item => {
|
||||
if (!object(item) || Object.keys(item).some(k => !['manifest', 'env'].includes(k))
|
||||
|| typeof item.manifest !== 'string' || !/^[a-z0-9-]+\.json$/.test(item.manifest)
|
||||
|| typeof item.env !== 'string') throw new PublicError('业务凭据绑定不合法。');
|
||||
return { manifest: path.join(directory, 'manifests', item.manifest), env: item.env };
|
||||
});
|
||||
parseBindings([...bindingArgs(bindings), '--', 'check']);
|
||||
validateFields(await Promise.all(bindings.map(b => loadManifest(b.manifest))));
|
||||
return bindings;
|
||||
}
|
||||
function bindingArgs(bindings: Binding[]) {
|
||||
return bindings.flatMap(b => ['--manifest', b.manifest, '--env', b.env]);
|
||||
}
|
||||
export async function profileStatus(bindings: Binding[], environment = process.env,
|
||||
read = async (ref: string) => (await nativeBackend(ref)).get()) {
|
||||
const fields = [];
|
||||
for (const b of bindings) {
|
||||
const m = await loadManifest(b.manifest);
|
||||
const fromEnv = Boolean(environment[b.env]?.trim());
|
||||
const configured = fromEnv || Boolean(await read(m.credential));
|
||||
fields.push({ credential: m.credential, configured, source: fromEnv ? 'environment' : 'system-store' });
|
||||
}
|
||||
return { configured: fields.every(f => f.configured), fields };
|
||||
}
|
||||
export async function prepareProfile(bindings: Binding[], command: string[], environment = process.env,
|
||||
read = async (ref: string) => (await nativeBackend(ref)).get()) {
|
||||
if (!command.length) throw new PublicError('请指定真实业务程序。');
|
||||
// 环境注入优先;本次只读取所选业务需要的凭据。
|
||||
const missing = bindings.filter(b => !environment[b.env]?.trim());
|
||||
if (!missing.length) return { command: command[0], args: command.slice(1), env: { ...environment } };
|
||||
return prepareCommand([...bindingArgs(missing), '--', ...command], read, environment);
|
||||
}
|
||||
async function main() {
|
||||
const [action, name, ...args] = process.argv.slice(2);
|
||||
if (!['status', 'setup', 'run'].includes(action) || !name || (action !== 'run' && args.length)
|
||||
|| (action === 'run' && (args[0] !== '--' || args.length < 2)))
|
||||
throw new PublicError('用法:node src/profile.ts status|setup 配置名;node src/profile.ts run 配置名 -- 程序 参数');
|
||||
const bindings = await loadProfile(name);
|
||||
if (action === 'status') {
|
||||
const status = await profileStatus(bindings);
|
||||
process.stdout.write(JSON.stringify(status) + '\n');
|
||||
if (!status.configured) process.exitCode = 2;
|
||||
} else if (action === 'setup') {
|
||||
const manifests = await Promise.all(bindings.map(b => loadManifest(b.manifest)));
|
||||
const app = await startServer({ manifests, onComplete: result => process.stdout.write(JSON.stringify(result) + '\n') });
|
||||
process.stdout.write(`本机配置页面(由用户亲自填写,30 分钟内有效):\n${app.url}\n`);
|
||||
process.once('SIGINT', app.close); process.once('SIGTERM', app.close);
|
||||
} else {
|
||||
const plan = await prepareProfile(bindings, args.slice(1));
|
||||
const child = spawn(plan.command, plan.args, { env: plan.env, stdio: 'inherit', shell: false });
|
||||
for (const b of bindings) delete plan.env[b.env];
|
||||
child.once('error', () => { process.stderr.write('业务程序启动失败。\n'); process.exitCode = 1; });
|
||||
child.once('exit', code => { process.exitCode = code ?? 1; });
|
||||
}
|
||||
}
|
||||
if (process.argv[1] && pathToFileURL(path.resolve(process.argv[1])).href === import.meta.url)
|
||||
main().catch(error => {
|
||||
process.stderr.write((error instanceof PublicError ? error.message : '配置未完成,请检查依赖与系统凭据服务。') + '\n');
|
||||
process.exitCode = 1;
|
||||
});
|
||||
@@ -0,0 +1,61 @@
|
||||
import { spawn } from 'node:child_process';
|
||||
import { fileURLToPath, pathToFileURL } from 'node:url';
|
||||
import path from 'node:path';
|
||||
import { loadManifest, nativeBackend, PublicError } from './config.ts';
|
||||
|
||||
type Binding = { file: string; variable: string };
|
||||
const defaultManifest = fileURLToPath(new URL('../manifests/default.json', import.meta.url));
|
||||
const reserved = new Set(['PATH', 'HOME', 'SHELL', 'NODE_OPTIONS', 'LD_PRELOAD', 'DYLD_INSERT_LIBRARIES', 'PYTHONPATH', 'PYTHONSTARTUP', 'COMSPEC', 'SYSTEMROOT']);
|
||||
export function parseBindings(args: string[]) {
|
||||
const split = args.indexOf('--');
|
||||
if (split < 0 || !args[split + 1]) throw new PublicError('请指定要启动的程序。');
|
||||
const bindings: Binding[] = [];
|
||||
const variables = new Set<string>();
|
||||
let pending: string | undefined;
|
||||
for (let i = 0; i < split; i += 2) {
|
||||
const value = args[i + 1];
|
||||
if (i + 1 >= split || !value || value.startsWith('--')) throw new PublicError('凭据绑定参数不完整。');
|
||||
if (args[i] === '--manifest' && pending === undefined) pending = value;
|
||||
else if (args[i] === '--env') {
|
||||
if (!/^[A-Z][A-Z0-9_]*$/.test(value) || reserved.has(value) || variables.has(value))
|
||||
throw new PublicError('环境变量名重复或不适合注入凭据。');
|
||||
bindings.push({ file: pending ?? defaultManifest, variable: value });
|
||||
variables.add(value); pending = undefined;
|
||||
} else throw new PublicError('凭据绑定参数不正确。');
|
||||
}
|
||||
if (pending !== undefined || !bindings.length || bindings.length > 16) throw new PublicError('请提供一至十六组完整的凭据绑定。');
|
||||
return { bindings, command: args[split + 1], args: args.slice(split + 2) };
|
||||
}
|
||||
export async function prepareCommand(
|
||||
args: string[],
|
||||
readCredential: (ref: string) => Promise<string | undefined> = async ref => (await nativeBackend(ref)).get(),
|
||||
baseEnv: NodeJS.ProcessEnv = process.env,
|
||||
) {
|
||||
const plan = parseBindings(args);
|
||||
const manifests = await Promise.all(plan.bindings.map(binding => loadManifest(binding.file)));
|
||||
const env = { ...baseEnv };
|
||||
try {
|
||||
for (let i = 0; i < plan.bindings.length; i++) {
|
||||
const value = await readCredential(manifests[i].credential);
|
||||
if (!value) throw new PublicError('有凭据尚未配置,任务未启动。');
|
||||
env[plan.bindings[i].variable] = value;
|
||||
}
|
||||
return { ...plan, env };
|
||||
} catch (error) {
|
||||
for (const binding of plan.bindings) delete env[binding.variable];
|
||||
if (error instanceof PublicError) throw error;
|
||||
throw new PublicError('凭据读取未完成,任务未启动。');
|
||||
}
|
||||
}
|
||||
async function main() {
|
||||
const prepared = await prepareCommand(process.argv.slice(2));
|
||||
const child = spawn(prepared.command, prepared.args, { env: prepared.env, stdio: 'inherit', shell: false });
|
||||
for (const binding of prepared.bindings) delete prepared.env[binding.variable];
|
||||
child.once('error', () => { process.stderr.write('无法启动目标程序。\n'); process.exitCode = 1; });
|
||||
child.once('exit', code => { process.exitCode = code ?? 1; });
|
||||
}
|
||||
if (process.argv[1] && pathToFileURL(path.resolve(process.argv[1])).href === import.meta.url)
|
||||
main().catch(error => {
|
||||
process.stderr.write((error instanceof PublicError ? error.message : '无法读取凭据或启动参数不正确。') + '\n');
|
||||
process.exitCode = 1;
|
||||
});
|
||||
@@ -0,0 +1,179 @@
|
||||
import { createServer, type IncomingMessage, type ServerResponse } from 'node:http';
|
||||
import { randomBytes, timingSafeEqual } from 'node:crypto';
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import { fileURLToPath, pathToFileURL } from 'node:url';
|
||||
import path from 'node:path';
|
||||
import { createStore, loadManifest, nativeBackend, object, PublicError, type Manifest, type CredentialBackend } from './config.ts';
|
||||
import { loadPage, validateFields, validatePageUI, type PageUI } from './page.ts';
|
||||
|
||||
const root = fileURLToPath(new URL('../', import.meta.url));
|
||||
function equal(a: string, b: string) { const x = Buffer.from(a); const y = Buffer.from(b); return x.length === y.length && timingSafeEqual(x, y); }
|
||||
export async function startServer(options: { manifest?: Manifest; manifests?: Manifest[]; ui?: PageUI; backend?: CredentialBackend; backends?: CredentialBackend[]; port?: number; ttlMs?: number; onComplete?: (result: object) => void }) {
|
||||
if (options.manifest && options.manifests) throw new PublicError('不能同时指定单项与多项声明。');
|
||||
const fields = validateFields(options.manifests ?? (options.manifest ? [options.manifest] : []));
|
||||
const ui = validatePageUI(options.ui ?? {});
|
||||
if ((options.backends && options.backends.length !== fields.length) || (options.backend && (fields.length !== 1 || options.backends)))
|
||||
throw new PublicError('凭据后端与字段数量不匹配。');
|
||||
const stores = await Promise.all(fields.map(async (field, index) => createStore(field,
|
||||
options.backends?.[index] ?? options.backend ?? await nativeBackend(field.credential))));
|
||||
const store = stores[0];
|
||||
const identity = fields.length === 1 ? { skill: fields[0].id, credential: fields[0].credential }
|
||||
: { credentials: fields.map(field => ({ skill: field.id, credential: field.credential })) };
|
||||
let lastResult: object | undefined;
|
||||
const bootstrap = randomBytes(32).toString('hex');
|
||||
const session = randomBytes(32).toString('hex');
|
||||
// 同一主机上的不同端口共享 cookie 命名空间,因此每个会话使用独立名称。
|
||||
const cookieName = 'credentials_session_' + randomBytes(12).toString('hex');
|
||||
let origin = '';
|
||||
let outcome = 'waiting';
|
||||
let saving = false;
|
||||
let completedTimer: ReturnType<typeof setTimeout> | undefined;
|
||||
const assets: Record<string, [string, string]> = {
|
||||
'/': ['index.html', 'text/html; charset=utf-8'], '/app.js': ['app.js', 'text/javascript; charset=utf-8'],
|
||||
'/style.css': ['style.css', 'text/css; charset=utf-8'], '/favicon.svg': ['favicon.svg', 'image/svg+xml']
|
||||
};
|
||||
function json(res: ServerResponse, code: number, body: object) { res.writeHead(code, { 'Content-Type': 'application/json; charset=utf-8' }); res.end(JSON.stringify(body)); }
|
||||
async function body(req: IncomingMessage) {
|
||||
if (req.headers['content-type'] !== 'application/json') throw new PublicError('请求格式不支持。', 415);
|
||||
const parts: Buffer[] = []; let size = 0;
|
||||
for await (const chunk of req) {
|
||||
size += chunk.length;
|
||||
if (size > 256 * 1024) throw new PublicError('请求内容过大。', 413);
|
||||
parts.push(chunk);
|
||||
}
|
||||
try { return JSON.parse(Buffer.concat(parts).toString('utf8')) as unknown; }
|
||||
catch { throw new PublicError('请求格式不正确。'); }
|
||||
}
|
||||
const server = createServer(async (req, res) => {
|
||||
res.setHeader('Cache-Control', 'no-store');
|
||||
res.setHeader('X-Content-Type-Options', 'nosniff');
|
||||
res.setHeader('Referrer-Policy', 'no-referrer');
|
||||
res.setHeader('X-Frame-Options', 'DENY');
|
||||
res.setHeader('Content-Security-Policy', "default-src 'none'; script-src 'self'; style-src 'self'; img-src 'self'; connect-src 'self'; base-uri 'none'; form-action 'none'; frame-ancestors 'none'");
|
||||
try {
|
||||
if (req.headers.host !== new URL(origin).host) throw new PublicError('请求来源不正确。', 403);
|
||||
const url = new URL(req.url ?? '/', origin);
|
||||
if (url.search) throw new PublicError('不接受 URL 查询参数。');
|
||||
if (req.headers.origin && req.headers.origin !== origin) throw new PublicError('不接受跨站请求。', 403);
|
||||
if (req.method === 'GET' && Object.hasOwn(assets, url.pathname)) {
|
||||
const [file, type] = assets[url.pathname];
|
||||
const content = await readFile(path.join(root, 'public', file));
|
||||
res.writeHead(200, { 'Content-Type': type }); res.end(content); return;
|
||||
}
|
||||
if (url.pathname === '/agent/status' && req.method === 'GET') {
|
||||
if (!equal(req.headers.authorization ?? '', `Bearer ${bootstrap}`)) throw new PublicError('无权访问。', 401);
|
||||
json(res, 200, { ...identity, status: outcome, result: lastResult }); return;
|
||||
}
|
||||
if (req.method === 'POST' && (req.headers.origin !== origin || req.headers['x-local-request'] !== '1'))
|
||||
throw new PublicError('请从本地配置页面提交。', 403);
|
||||
if (url.pathname === '/api/session' && req.method === 'POST') {
|
||||
if (!equal(req.headers.authorization ?? '', `Bearer ${bootstrap}`)) throw new PublicError('入口已失效,请重新打开工具提供的链接。', 401);
|
||||
res.setHeader('Set-Cookie', `${cookieName}=${session}; HttpOnly; SameSite=Strict; Path=/; Max-Age=${Math.ceil((options.ttlMs ?? 30 * 60_000) / 1000)}`);
|
||||
json(res, 200, { status: 'ready' }); return;
|
||||
}
|
||||
const cookies = (req.headers.cookie ?? '').split(';').map(c => c.trim());
|
||||
if (!cookies.some(c => equal(c, `${cookieName}=${session}`))) throw new PublicError('请重新打开工具提供的配置链接。', 401);
|
||||
if (url.pathname === '/api/meta' && req.method === 'GET') {
|
||||
const metadata = await Promise.all(fields.map(async (field, index) => ({ ...field, ...await stores[index].status() })));
|
||||
json(res, 200, { ...metadata[0], fields: metadata, page: ui, outcome }); return;
|
||||
}
|
||||
if (url.pathname === '/api/save' && req.method === 'POST') {
|
||||
if (!['waiting', 'partial'].includes(outcome) || saving) throw new PublicError('配置正在保存或已结束,请稍后确认状态。', 409);
|
||||
saving = true;
|
||||
try {
|
||||
const input = await body(req);
|
||||
let result;
|
||||
if (fields.length === 1 && object(input) && !Object.hasOwn(input, 'entries')) result = await store.save(input);
|
||||
else {
|
||||
if (!object(input) || Object.keys(input).some(key => key !== 'entries') || !Array.isArray(input.entries)
|
||||
|| !input.entries.length || input.entries.length > fields.length) throw new PublicError('提交字段不合法。');
|
||||
const seen = new Set<string>();
|
||||
const entries = input.entries.map(entry => {
|
||||
if (!object(entry) || typeof entry.credential !== 'string' || seen.has(entry.credential)) throw new PublicError('提交凭据重复或不合法。');
|
||||
seen.add(entry.credential);
|
||||
const index = fields.findIndex(field => field.credential === entry.credential);
|
||||
if (index === -1) throw new PublicError('提交了页面之外的凭据。');
|
||||
const { credential, ...payload } = entry;
|
||||
return { index, payload };
|
||||
});
|
||||
// 所有格式、替换授权和版本先验证;系统存储不提供跨项事务。
|
||||
await Promise.all([
|
||||
...entries.map(entry => stores[entry.index].validate(entry.payload)),
|
||||
...fields.map(async (field, index) => {
|
||||
if (!seen.has(field.credential) && !(await stores[index].status()).configured)
|
||||
throw new PublicError('请填写所有尚未配置的密钥。');
|
||||
}),
|
||||
]);
|
||||
const results: { credential: string; status: string }[] = [];
|
||||
let failed = false;
|
||||
for (const entry of entries) {
|
||||
let status = 'not_attempted';
|
||||
if (!failed) {
|
||||
try { await stores[entry.index].save(entry.payload); status = 'saved'; }
|
||||
catch { status = 'failed'; failed = true; }
|
||||
}
|
||||
results.push({ credential: fields[entry.index].credential, status });
|
||||
}
|
||||
result = { ...identity, status: failed ? 'partial' : 'saved', results };
|
||||
}
|
||||
outcome = result.status;
|
||||
lastResult = result;
|
||||
json(res, 200, result);
|
||||
options.onComplete?.(result);
|
||||
if (outcome === 'saved') { completedTimer = setTimeout(close, 90_000); completedTimer.unref(); }
|
||||
return;
|
||||
} finally { saving = false; }
|
||||
}
|
||||
if (url.pathname === '/api/cancel' && req.method === 'POST') {
|
||||
if (!['waiting', 'partial'].includes(outcome) || saving) throw new PublicError('配置正在保存或已结束。', 409);
|
||||
outcome = 'cancelled'; json(res, 200, { status: outcome });
|
||||
options.onComplete?.({ ...identity, status: outcome });
|
||||
completedTimer = setTimeout(close, 500); completedTimer.unref(); return;
|
||||
}
|
||||
throw new PublicError('接口不存在。', 404);
|
||||
} catch (error) {
|
||||
if (!res.headersSent) json(res, error instanceof PublicError ? error.status : 500,
|
||||
{ error: error instanceof PublicError ? error.message : '操作未完成,请检查系统凭据服务。' });
|
||||
else res.end();
|
||||
}
|
||||
});
|
||||
server.requestTimeout = 15_000;
|
||||
server.headersTimeout = 10_000;
|
||||
server.maxHeadersCount = 30;
|
||||
await new Promise<void>((resolve, reject) => { server.once('error', reject); server.listen(options.port ?? 0, '127.0.0.1', () => resolve()); });
|
||||
const addr = server.address();
|
||||
if (!addr || typeof addr === 'string') throw new Error('无法启动本机服务。');
|
||||
origin = `http://127.0.0.1:${addr.port}`;
|
||||
const expiry = setTimeout(() => {
|
||||
if (['waiting', 'partial'].includes(outcome)) options.onComplete?.({ ...identity, status: 'expired', result: lastResult });
|
||||
close();
|
||||
}, options.ttlMs ?? 30 * 60_000);
|
||||
expiry.unref();
|
||||
function close() { clearTimeout(expiry); clearTimeout(completedTimer); server.close(); server.closeAllConnections(); }
|
||||
return { origin, url: `${origin}/#${bootstrap}`, bootstrap, close };
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const args = process.argv.slice(2);
|
||||
const manifestFiles: string[] = [];
|
||||
let pageFile: string | undefined;
|
||||
let port = 0;
|
||||
for (let i = 0; i < args.length; i++) {
|
||||
if (args[i] === '--manifest' && args[i + 1]) manifestFiles.push(path.resolve(args[++i]));
|
||||
else if (args[i] === '--page' && args[i + 1] && !pageFile) pageFile = path.resolve(args[++i]);
|
||||
else if (args[i] === '--port' && /^\d+$/.test(args[i + 1] ?? '')) port = Number(args[++i]);
|
||||
else throw new PublicError('用法:npm start -- [--manifest 声明路径(可重复) | --page 页面配置] [--port 端口]');
|
||||
}
|
||||
if (port > 65535) throw new PublicError('端口不合法。');
|
||||
if (pageFile && manifestFiles.length) throw new PublicError('--page 与 --manifest 不能同时使用。');
|
||||
const page = pageFile ? await loadPage(pageFile) : {
|
||||
manifests: await Promise.all((manifestFiles.length ? manifestFiles : [path.join(root, 'manifests', 'default.json')]).map(loadManifest))
|
||||
};
|
||||
const app = await startServer({ ...page, port,
|
||||
onComplete: result => process.stdout.write(JSON.stringify(result) + '\n') });
|
||||
process.stdout.write(`本机配置页面(30 分钟内有效):\n${app.url}\n`);
|
||||
process.once('SIGINT', app.close); process.once('SIGTERM', app.close);
|
||||
}
|
||||
if (process.argv[1] && pathToFileURL(path.resolve(process.argv[1])).href === import.meta.url) {
|
||||
main().catch(() => { process.stderr.write('启动失败,请检查 Node.js 版本、声明文件和端口。\n'); process.exitCode = 1; });
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { test } from 'node:test';
|
||||
import { mkdtemp, readFile, rm } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { execFile } from 'node:child_process';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const script = fileURLToPath(new URL('../src/cli.ts', import.meta.url));
|
||||
const run = (args: string[]) => new Promise<{ code: number; output: string }>(resolve => {
|
||||
execFile(process.execPath, [script, 'configure', ...args], { encoding: 'utf8' },
|
||||
(error, stdout, stderr) => resolve({ code: error ? 1 : 0, output: stdout + stderr }));
|
||||
});
|
||||
test('真实 CLI:创建、局部修改、幂等与只读预览', async t => {
|
||||
const dir = await mkdtemp(path.join(tmpdir(), 'credential-configure-')); t.after(() => rm(dir, { recursive: true, force: true }));
|
||||
const file = path.join(dir, 'service.credential.json');
|
||||
const create = ['--manifest', file, '--id', 'sample-skill', '--label', '服务凭据', '--credential', 'sample/service/default'];
|
||||
assert.equal((await run(create)).code, 0);
|
||||
const first = await readFile(file, 'utf8');
|
||||
assert.equal((await run(create)).code, 0); assert.equal(await readFile(file, 'utf8'), first);
|
||||
assert.equal((await run(['--manifest', file, '--title', '连接服务', '--placeholder', '输入访问凭据'])).code, 0);
|
||||
const updated = JSON.parse(await readFile(file, 'utf8'));
|
||||
assert.equal(updated.credential, 'sample/service/default'); assert.equal(updated.ui.title, '连接服务');
|
||||
const before = await readFile(file, 'utf8');
|
||||
assert.equal((await run(['--manifest', file, '--label', '预览', '--dry-run'])).code, 0);
|
||||
assert.equal(await readFile(file, 'utf8'), before);
|
||||
});
|
||||
test('真实 CLI:拒绝更换已有身份、未知字段和不完整声明', async t => {
|
||||
const dir = await mkdtemp(path.join(tmpdir(), 'credential-configure-')); t.after(() => rm(dir, { recursive: true, force: true }));
|
||||
const file = path.join(dir, 'service.credential.json');
|
||||
await run(['--manifest', file, '--id', 'sample-skill', '--label', '服务凭据', '--credential', 'sample/service/default']);
|
||||
const before = await readFile(file, 'utf8');
|
||||
assert.equal((await run(['--manifest', file, '--credential', 'other/account'])).code, 1);
|
||||
assert.equal((await run(['--manifest', file, '--api-key', 'FAKE_VALUE_FOR_TEST_ONLY'])).code, 1);
|
||||
assert.equal(await readFile(file, 'utf8'), before);
|
||||
assert.equal((await run(['--manifest', path.join(dir, 'incomplete.json'), '--label', '缺字段'])).code, 1);
|
||||
});
|
||||
@@ -0,0 +1,104 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { test } from 'node:test';
|
||||
import { request as httpRequest } from 'node:http';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import { createStore, loadManifest, type CredentialBackend } from '../src/config.ts';
|
||||
import { startServer } from '../src/server.ts';
|
||||
|
||||
const manifest = await loadManifest(fileURLToPath(new URL('../manifests/default.json', import.meta.url)));
|
||||
const fake = 'TEST_ONLY_NOT_A_REAL_SECRET_12345';
|
||||
test('通用页面默认使用中性色,不引入未经配置的品牌色', async () => {
|
||||
const css = await readFile(new URL('../public/style.css', import.meta.url), 'utf8');
|
||||
for (const match of css.matchAll(/#([0-9a-f]{6})(?:[0-9a-f]{2})?\b/gi)) {
|
||||
const color = match[1];
|
||||
assert.equal(color.slice(0, 2), color.slice(2, 4));
|
||||
assert.equal(color.slice(2, 4), color.slice(4, 6));
|
||||
}
|
||||
});
|
||||
function memory(initial?: string) {
|
||||
let value = initial;
|
||||
let writes = 0;
|
||||
const backend: CredentialBackend = { name: '测试凭据库', get: async () => value,
|
||||
set: async v => { value = v; writes++; }, delete: async () => { value = undefined; } };
|
||||
return { backend, value: () => value, writes: () => writes };
|
||||
}
|
||||
test('首次保存只写入凭据后端;状态与结果不包含密钥', async () => {
|
||||
const m = memory();
|
||||
const store = createStore(manifest, m.backend);
|
||||
const before = await store.status();
|
||||
assert.equal(before.configured, false);
|
||||
const saved = await store.save({ revision: before.revision, value: fake });
|
||||
assert.equal(m.value(), fake);
|
||||
const after = await store.status();
|
||||
assert.equal(after.configured, true);
|
||||
assert.equal(JSON.stringify({ saved, after }).includes(fake), false);
|
||||
assert.equal('target' in after, false);
|
||||
});
|
||||
test('已有值需要确认替换,旧版本请求不能覆盖新值', async () => {
|
||||
const m = memory('TEST_OLD');
|
||||
const store = createStore(manifest, m.backend);
|
||||
const before = await store.status();
|
||||
await assert.rejects(store.save({ revision: before.revision, value: fake }), /确认替换/);
|
||||
assert.equal(m.writes(), 0);
|
||||
await store.save({ revision: before.revision, value: fake, replaceExisting: true });
|
||||
await assert.rejects(store.save({ revision: before.revision, value: 'TEST_STALE', replaceExisting: true }), /发生变化/);
|
||||
assert.equal(m.value(), fake);
|
||||
});
|
||||
test('凭据服务不可用时明确失败,不回退文件,不泄漏底层错误', async () => {
|
||||
const bad: CredentialBackend = { name: '不可用', get: async () => { throw Error(fake); },
|
||||
set: async () => { throw Error(fake); }, delete: async () => {} };
|
||||
await assert.rejects(createStore(manifest, bad).status(), e => e instanceof Error && !e.message.includes(fake) && /系统凭据/.test(e.message));
|
||||
const m = memory(); m.backend.set = async () => { throw Error(fake); };
|
||||
const store = createStore(manifest, m.backend);
|
||||
await assert.rejects(store.save({ revision: (await store.status()).revision, value: fake }), e => e instanceof Error && !e.message.includes(fake));
|
||||
});
|
||||
test('拒绝路径、旧 JSON 请求、空值、多行及超长密钥', async () => {
|
||||
const m = memory(); const store = createStore(manifest, m.backend);
|
||||
const revision = (await store.status()).revision;
|
||||
for (const input of [{ revision, value: fake, target: '/tmp/unwanted.json' },
|
||||
{ revision, values: { api_key: fake } }, { revision, value: '' },
|
||||
{ revision, value: 'one\ntwo' }, { revision, value: 'x'.repeat(2501) }])
|
||||
await assert.rejects(store.save(input));
|
||||
assert.equal(m.writes(), 0);
|
||||
});
|
||||
test('HTTP 认证、Host 和跨站保护、脱敏及默认单字段占位框', async t => {
|
||||
const m = memory(); const emitted: object[] = [];
|
||||
const app = await startServer({ manifest, backend: m.backend, onComplete: v => emitted.push(v) });
|
||||
t.after(app.close);
|
||||
assert.equal((await fetch(app.origin + '/api/meta')).status, 401);
|
||||
const wrongHost = await new Promise<number | undefined>((resolve, reject) => {
|
||||
const req = httpRequest(app.origin, { headers: { Host: 'attacker.example' } }, res => { res.resume(); resolve(res.statusCode); });
|
||||
req.on('error', reject); req.end();
|
||||
});
|
||||
assert.equal(wrongHost, 403);
|
||||
const html = await fetch(app.origin);
|
||||
assert.match(html.headers.get('Content-Security-Policy')!, /frame-ancestors 'none'/);
|
||||
assert.equal((await html.text()).match(/<input\b/g)?.length, 1);
|
||||
const headers: Record<string, string> = { Origin: app.origin, 'X-Local-Request': '1', 'Content-Type': 'application/json', Authorization: 'Bearer ' + app.bootstrap };
|
||||
const auth = await fetch(app.origin + '/api/session', { method: 'POST', headers, body: '{}' });
|
||||
assert.equal(auth.status, 200); headers.Cookie = auth.headers.get('set-cookie')!.split(';')[0];
|
||||
const meta = await (await fetch(app.origin + '/api/meta', { headers })).json();
|
||||
assert.equal(meta.label, manifest.label);
|
||||
assert.deepEqual(meta.ui, manifest.ui);
|
||||
const payload = { revision: meta.revision, value: fake };
|
||||
const post = (extras: Record<string, string>) => fetch(app.origin + '/api/save', { method: 'POST', headers: { ...headers, ...extras }, body: JSON.stringify(payload) });
|
||||
assert.equal((await post({ Origin: 'https://attacker.example' })).status, 403);
|
||||
assert.equal((await post({ 'X-Local-Request': '' })).status, 403);
|
||||
const result = await post({}); assert.equal(result.status, 200);
|
||||
const visible = [await result.text(), JSON.stringify(emitted)];
|
||||
visible.push(await (await fetch(app.origin + '/api/meta', { headers })).text());
|
||||
const agent = await (await fetch(app.origin + '/agent/status', { headers })).json();
|
||||
assert.equal(agent.status, 'saved'); visible.push(JSON.stringify(agent));
|
||||
assert.equal(visible.some(v => v.includes(fake)), false);
|
||||
assert.equal((await post({})).status, 409); assert.equal(m.writes(), 1);
|
||||
});
|
||||
test('取消不写凭据,取消后不能再保存', async t => {
|
||||
const m = memory(); const app = await startServer({ manifest, backend: m.backend }); t.after(app.close);
|
||||
const headers: Record<string, string> = { Origin: app.origin, 'X-Local-Request': '1', 'Content-Type': 'application/json', Authorization: 'Bearer ' + app.bootstrap };
|
||||
const auth = await fetch(app.origin + '/api/session', { method: 'POST', headers, body: '{}' });
|
||||
headers.Cookie = auth.headers.get('set-cookie')!.split(';')[0];
|
||||
assert.equal((await fetch(app.origin + '/api/cancel', { method: 'POST', headers, body: '{}' })).status, 200);
|
||||
assert.equal((await fetch(app.origin + '/api/save', { method: 'POST', headers, body: '{}' })).status, 409);
|
||||
assert.equal(m.writes(), 0);
|
||||
});
|
||||
@@ -0,0 +1,136 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { test } from 'node:test';
|
||||
import { createStore, type CredentialBackend, type Manifest } from '../src/config.ts';
|
||||
import { startServer } from '../src/server.ts';
|
||||
|
||||
const manifest = (name: string): Manifest => ({ version: 1, id: 'sample-skill', label: name, credential: 'sample-skill/' + name });
|
||||
function memory() {
|
||||
const values = new Map<string, string>();
|
||||
const backend = (ref: string): CredentialBackend => ({
|
||||
name: '测试凭据库', get: async () => values.get(ref),
|
||||
set: async value => { values.set(ref, value); }, delete: async () => { values.delete(ref); }
|
||||
});
|
||||
return { values, backend };
|
||||
}
|
||||
test('同一个 Skill 的多个 key 独立保存、替换和删除', async () => {
|
||||
const m = memory(), a = manifest('first'), b = manifest('second');
|
||||
const sa = createStore(a, m.backend(a.credential)), sb = createStore(b, m.backend(b.credential));
|
||||
await sa.save({ value: 'FAKE_FIRST_VALUE', revision: (await sa.status()).revision });
|
||||
await sb.save({ value: 'FAKE_SECOND_VALUE', revision: (await sb.status()).revision });
|
||||
await sa.save({ value: 'FAKE_REPLACED_VALUE', revision: (await sa.status()).revision, replaceExisting: true });
|
||||
assert.equal(m.values.get(b.credential), 'FAKE_SECOND_VALUE');
|
||||
await m.backend(a.credential).delete();
|
||||
assert.equal((await sa.status()).configured, false); assert.equal((await sb.status()).configured, true);
|
||||
});
|
||||
test('多个端口共享浏览器 cookie 容器时,会话仍互不覆盖', async t => {
|
||||
const m = memory(), a = manifest('first'), b = manifest('second');
|
||||
const one = await startServer({ manifest: a, backend: m.backend(a.credential) });
|
||||
const two = await startServer({ manifest: b, backend: m.backend(b.credential) });
|
||||
t.after(one.close); t.after(two.close);
|
||||
const jar = new Map<string, string>();
|
||||
for (const app of [one, two]) {
|
||||
const auth = await fetch(app.origin + '/api/session', { method: 'POST',
|
||||
headers: { Origin: app.origin, Authorization: 'Bearer ' + app.bootstrap, 'X-Local-Request': '1' } });
|
||||
assert.equal(auth.status, 200);
|
||||
const cookie = auth.headers.get('set-cookie')!.split(';')[0], split = cookie.indexOf('=');
|
||||
jar.set(cookie.slice(0, split), cookie.slice(split + 1));
|
||||
}
|
||||
assert.equal(jar.size, 2);
|
||||
const cookie = [...jar].map(([k, v]) => k + '=' + v).join('; ');
|
||||
for (const [app, ref, value] of [[one, a.credential, 'FAKE_FIRST_VALUE'], [two, b.credential, 'FAKE_SECOND_VALUE']] as const) {
|
||||
const headers = { Cookie: cookie, Origin: app.origin, 'X-Local-Request': '1', 'Content-Type': 'application/json' };
|
||||
const response = await fetch(app.origin + '/api/meta', { headers }); assert.equal(response.status, 200);
|
||||
const meta = await response.json();
|
||||
const saved = await fetch(app.origin + '/api/save', { method: 'POST', headers, body: JSON.stringify({ revision: meta.revision, value }) });
|
||||
assert.equal(saved.status, 200);
|
||||
const status = await (await fetch(app.origin + '/agent/status', { headers: { Authorization: 'Bearer ' + app.bootstrap } })).json();
|
||||
assert.equal(status.credential, ref); assert.equal(status.status, 'saved');
|
||||
}
|
||||
assert.equal(m.values.size, 2);
|
||||
assert.equal((await fetch(one.origin + '/agent/status', { headers: { Authorization: 'Bearer ' + two.bootstrap } })).status, 401);
|
||||
});
|
||||
test('同一凭据的旧会话在其他会话保存后不能覆盖', async () => {
|
||||
const m = memory(), a = manifest('shared');
|
||||
const first = createStore(a, m.backend(a.credential)), second = createStore(a, m.backend(a.credential));
|
||||
const initial = await second.status();
|
||||
await first.save({ revision: (await first.status()).revision, value: 'FAKE_LATEST_VALUE' });
|
||||
await assert.rejects(second.save({ revision: initial.revision, value: 'FAKE_STALE_VALUE', replaceExisting: true }), /发生变化/);
|
||||
assert.equal(m.values.get(a.credential), 'FAKE_LATEST_VALUE');
|
||||
});
|
||||
|
||||
async function session(app: Awaited<ReturnType<typeof startServer>>) {
|
||||
const headers: Record<string, string> = { Origin: app.origin, Authorization: 'Bearer ' + app.bootstrap,
|
||||
'X-Local-Request': '1', 'Content-Type': 'application/json' };
|
||||
const auth = await fetch(app.origin + '/api/session', { method: 'POST', headers });
|
||||
headers.Cookie = auth.headers.get('set-cookie')!.split(';')[0];
|
||||
return {
|
||||
meta: async () => (await fetch(app.origin + '/api/meta', { headers })).json(),
|
||||
save: (entries: object[]) => fetch(app.origin + '/api/save', { method: 'POST', headers, body: JSON.stringify({ entries }) }),
|
||||
agent: async () => (await fetch(app.origin + '/agent/status', { headers })).json(),
|
||||
};
|
||||
}
|
||||
test('同页多 key 真实 HTTP 保存、文案与状态脱敏', async t => {
|
||||
const m = memory(), fields = [manifest('one'), manifest('two')], emitted: object[] = [];
|
||||
const app = await startServer({ manifests: fields, backends: fields.map(field => m.backend(field.credential)),
|
||||
ui: { title: '连接服务', label: '双服务配置', saveLabel: '确认保存' }, onComplete: value => emitted.push(value) });
|
||||
t.after(app.close); const client = await session(app); const meta = await client.meta();
|
||||
assert.equal(meta.fields.length, 2); assert.equal(meta.page.title, '连接服务');
|
||||
const response = await client.save(meta.fields.map((field: any, index: number) => ({ credential: field.credential, revision: field.revision, value: 'FAKE_BATCH_' + index })));
|
||||
assert.equal(response.status, 200); const result = await response.json(); assert.equal(result.status, 'saved');
|
||||
assert.equal(m.values.get(fields[0].credential), 'FAKE_BATCH_0'); assert.equal(m.values.get(fields[1].credential), 'FAKE_BATCH_1');
|
||||
assert.equal(JSON.stringify([result, await client.meta(), await client.agent(), emitted]).includes('FAKE_BATCH_'), false);
|
||||
});
|
||||
test('整组预检拒绝无效项、重复项、越界引用、未确认替换和缺失项,零写入', async t => {
|
||||
const m = memory(), fields = [manifest('one'), manifest('two')];
|
||||
m.values.set(fields[0].credential, 'FAKE_OLD');
|
||||
const app = await startServer({ manifests: fields, backends: fields.map(field => m.backend(field.credential)) });
|
||||
t.after(app.close); const client = await session(app); const meta = await client.meta();
|
||||
const entries = meta.fields.map((field: any) => ({ credential: field.credential, revision: field.revision, value: 'FAKE_NEW', replaceExisting: true }));
|
||||
for (const invalid of [
|
||||
[entries[0]], [entries[0], { ...entries[1], value: 'multi\nline' }],
|
||||
[entries[0], entries[0]], [entries[0], { ...entries[1], credential: 'not/in/page' }],
|
||||
[{ ...entries[0], replaceExisting: false }, entries[1]],
|
||||
[entries[0], { ...entries[1], revision: 'stale' }],
|
||||
[entries[0], { ...entries[1], target: '/tmp/plaintext.json' }],
|
||||
]) { assert.ok((await client.save(invalid)).status >= 400); assert.deepEqual([...m.values], [[fields[0].credential, 'FAKE_OLD']]); }
|
||||
});
|
||||
test('已有项留空不覆盖,中途失败保留已保存项并可仅重试失败项', async t => {
|
||||
const m = memory(), fields = [manifest('one'), manifest('two'), manifest('three'), manifest('four')];
|
||||
m.values.set(fields[0].credential, 'FAKE_KEEP');
|
||||
const backends = fields.map(field => m.backend(field.credential));
|
||||
let fail = true; const original = backends[2].set;
|
||||
backends[2].set = async value => { if (fail) throw Error(value); await original(value); };
|
||||
const app = await startServer({ manifests: fields, backends }); t.after(app.close);
|
||||
const client = await session(app); let meta = await client.meta();
|
||||
const response = await client.save(meta.fields.slice(1).map((field: any) => ({ credential: field.credential, revision: field.revision, value: 'FAKE_BATCH' })));
|
||||
const result = await response.json(); assert.equal(result.status, 'partial');
|
||||
assert.deepEqual(result.results.map((item: any) => item.status), ['saved', 'failed', 'not_attempted']);
|
||||
assert.equal(m.values.has(fields[3].credential), false);
|
||||
assert.equal((await client.agent()).status, 'partial'); assert.equal(JSON.stringify(result).includes('FAKE_BATCH'), false);
|
||||
assert.equal(m.values.get(fields[0].credential), 'FAKE_KEEP'); assert.equal(m.values.get(fields[1].credential), 'FAKE_BATCH');
|
||||
fail = false; meta = await client.meta();
|
||||
const retry = await client.save(meta.fields.slice(2).map((field: any) => ({ credential: field.credential, revision: field.revision, value: 'FAKE_RETRY' })));
|
||||
assert.equal((await retry.json()).status, 'saved'); assert.equal(m.values.get(fields[1].credential), 'FAKE_BATCH');
|
||||
});
|
||||
test('16 项预检并行读取,写入仍顺序执行且每项写前复验', async t => {
|
||||
const fields = Array.from({ length: 16 }, (_, index) => manifest('key-' + index));
|
||||
let active = 0, peak = 0, reads = 0, writes = 0;
|
||||
const backends = fields.map((): CredentialBackend => ({ name: '测试凭据库',
|
||||
get: async () => {
|
||||
reads++; active++; peak = Math.max(peak, active);
|
||||
await new Promise(resolve => setTimeout(resolve, 2)); active--; return undefined;
|
||||
},
|
||||
set: async () => { assert.equal(active, 0); writes++; }, delete: async () => {},
|
||||
}));
|
||||
const app = await startServer({ manifests: fields, backends }); t.after(app.close);
|
||||
const client = await session(app); const meta = await client.meta();
|
||||
peak = 0; reads = 0;
|
||||
const response = await client.save(meta.fields.map((field: any) => ({ credential: field.credential, revision: field.revision, value: 'FAKE_ONLY' })));
|
||||
assert.equal((await response.json()).status, 'saved');
|
||||
assert.equal(peak, 16); assert.equal(reads, 32); assert.equal(writes, 16);
|
||||
});
|
||||
test('页面拒绝重复引用、超限字段和空配置', async () => {
|
||||
const m = memory(), field = manifest('one');
|
||||
for (const fields of [[], [field, field], Array.from({ length: 17 }, (_, index) => manifest('key-' + index))])
|
||||
await assert.rejects(startServer({ manifests: fields, backends: fields.map(item => m.backend(item.credential)) }));
|
||||
});
|
||||
@@ -0,0 +1,68 @@
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { mkdtemp, writeFile, rm } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { execFile } from 'node:child_process';
|
||||
import { nativeBackend, type CredentialBackend } from '../src/config.ts';
|
||||
import { startServer } from '../src/server.ts';
|
||||
|
||||
const namespace = 'test/' + randomUUID();
|
||||
const refs = [namespace + '/first', namespace + '/second'];
|
||||
const entries: { backend: CredentialBackend; owned: boolean }[] = [];
|
||||
const directory = await mkdtemp(path.join(tmpdir(), 'credential-native-'));
|
||||
let phase = '初始化';
|
||||
let cleanupFailed = false;
|
||||
let app: Awaited<ReturnType<typeof startServer>> | undefined;
|
||||
try {
|
||||
for (const ref of refs) {
|
||||
const backend = await nativeBackend(ref);
|
||||
if (await backend.get() !== undefined) throw Error();
|
||||
entries.push({ backend, owned: false });
|
||||
}
|
||||
const values = ['TEST_ONLY_A_' + randomUUID(), 'TEST_ONLY_B_' + randomUUID()];
|
||||
phase = '同页 HTTP 多 key 保存与回读';
|
||||
app = await startServer({ manifests: refs.map((credential, index) => ({ version: 1, id: 'sample-skill', label: '测试服务' + index, credential })) });
|
||||
const headers: Record<string, string> = { Origin: app.origin, Authorization: 'Bearer ' + app.bootstrap, 'X-Local-Request': '1', 'Content-Type': 'application/json' };
|
||||
const session = await fetch(app.origin + '/api/session', { method: 'POST', headers });
|
||||
headers.Cookie = session.headers.get('set-cookie')!.split(';')[0];
|
||||
const metadata = await (await fetch(app.origin + '/api/meta', { headers })).json();
|
||||
entries.forEach(entry => { entry.owned = true; });
|
||||
const saved = await fetch(app.origin + '/api/save', { method: 'POST', headers, body: JSON.stringify({
|
||||
entries: metadata.fields.map((field: { credential: string; revision: string }, index: number) => ({ credential: field.credential, revision: field.revision, value: values[index] }))
|
||||
}) });
|
||||
if (!saved.ok || (await saved.json()).status !== 'saved') throw Error();
|
||||
for (let i = 0; i < entries.length; i++) if (await entries[i].backend.get() !== values[i]) throw Error();
|
||||
phase = '替换隔离';
|
||||
values[0] = 'TEST_ONLY_A_REPLACED_' + randomUUID();
|
||||
await entries[0].backend.set(values[0]);
|
||||
if (await entries[1].backend.get() !== values[1]) throw Error();
|
||||
phase = '多 key 业务读取';
|
||||
const files = [path.join(directory, 'first.json'), path.join(directory, 'second.json')];
|
||||
for (let i = 0; i < files.length; i++)
|
||||
await writeFile(files[i], JSON.stringify({ version: 1, id: 'sample-skill', label: '测试服务', credential: refs[i] }));
|
||||
const script = fileURLToPath(new URL('../src/run.ts', import.meta.url));
|
||||
await new Promise<void>((resolve, reject) => execFile(process.execPath, [script,
|
||||
'--manifest', files[0], '--env', 'FIRST_API_KEY', '--manifest', files[1], '--env', 'SECOND_API_KEY', '--',
|
||||
process.execPath, '-e', 'process.exit(process.env.FIRST_API_KEY?.startsWith("TEST_ONLY_A_REPLACED_") && process.env.SECOND_API_KEY?.startsWith("TEST_ONLY_B_") ? 0 : 1)'],
|
||||
{ timeout: 20_000 }, error => error ? reject(Error()) : resolve()));
|
||||
phase = '删除隔离';
|
||||
await entries[0].backend.delete();
|
||||
if (await entries[0].backend.get() !== undefined || await entries[1].backend.get() !== values[1]) throw Error();
|
||||
process.stdout.write('系统凭据库多 key 保存、替换隔离、业务读取和删除隔离验证通过(假凭据)。\n');
|
||||
} catch {
|
||||
process.stderr.write('系统凭据库验证未通过,阶段:' + phase + '。\n'); process.exitCode = 1;
|
||||
} finally {
|
||||
app?.close();
|
||||
for (const entry of entries) {
|
||||
if (!entry.owned) continue;
|
||||
try {
|
||||
if (await entry.backend.get() !== undefined) await entry.backend.delete();
|
||||
if (await entry.backend.get() !== undefined) cleanupFailed = true;
|
||||
} catch { cleanupFailed = true; }
|
||||
}
|
||||
await rm(directory, { recursive: true, force: true });
|
||||
if (cleanupFailed) {
|
||||
process.stderr.write('测试凭据清理失败,测试引用前缀:' + namespace + '\n'); process.exitCode = 1;
|
||||
} else process.stdout.write('测试凭据和临时声明已清理。\n');
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { test } from 'node:test';
|
||||
import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { execFile } from 'node:child_process';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { loadPage } from '../src/page.ts';
|
||||
|
||||
test('页面配置真实 CLI:相对路径、独立字段文案、更新与只读预览', async t => {
|
||||
const dir = await mkdtemp(path.join(tmpdir(), 'credential-page-')); t.after(() => rm(dir, { recursive: true, force: true }));
|
||||
const files = ['one', 'two'].map(name => path.join(dir, name + '.json'));
|
||||
for (const [index, file] of files.entries()) await writeFile(file, JSON.stringify({ version: 1, id: 'sample-skill', label: '服务' + index,
|
||||
credential: 'sample/' + index, ui: { placeholder: '请输入测试凭据' + index } }));
|
||||
const page = path.join(dir, 'page.json');
|
||||
const script = fileURLToPath(new URL('../src/cli.ts', import.meta.url));
|
||||
const run = (args: string[]) => new Promise<number>(resolve => execFile(process.execPath,
|
||||
[script, 'configure-page', '--page', page, ...args], error => resolve(error ? 1 : 0)));
|
||||
const args = ['--manifest', files[0], '--manifest', files[1], '--title', '两项凭据'];
|
||||
assert.equal(await run(args), 0);
|
||||
assert.deepEqual(JSON.parse(await readFile(page, 'utf8')).manifests, ['one.json', 'two.json']);
|
||||
const loaded = await loadPage(page); assert.equal(loaded.manifests.length, 2); assert.equal(loaded.ui.title, '两项凭据');
|
||||
assert.equal(loaded.manifests[1].ui?.placeholder, '请输入测试凭据1');
|
||||
assert.equal(await run(['--label', '业务配置']), 0);
|
||||
const before = await readFile(page, 'utf8');
|
||||
assert.equal(await run(['--title', '只读预览', '--dry-run']), 0); assert.equal(await readFile(page, 'utf8'), before);
|
||||
assert.notEqual(await run(['--manifest', files[0], '--manifest', files[0]]), 0);
|
||||
assert.notEqual(await run(['--api-key', 'FAKE_ONLY']), 0);
|
||||
assert.equal(await readFile(page, 'utf8'), before);
|
||||
});
|
||||
@@ -0,0 +1,60 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { test } from 'node:test';
|
||||
import { mkdtemp, mkdir, writeFile, rm, readFile } from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { loadProfile, prepareProfile, profileStatus } from '../src/profile.ts';
|
||||
import { loadManifest, type CredentialBackend } from '../src/config.ts';
|
||||
import { startServer } from '../src/server.ts';
|
||||
|
||||
test('真实分发配置:正式页面保存后可被对应业务环境读取,状态与参数不含值', async () => {
|
||||
const root = fileURLToPath(new URL('../', import.meta.url));
|
||||
const config = JSON.parse(await readFile(path.join(root, 'manifests/profiles.json'), 'utf8'));
|
||||
for (const name of Object.keys(config.profiles)) {
|
||||
const bindings = await loadProfile(name);
|
||||
const manifests = await Promise.all(bindings.map(b => loadManifest(b.manifest)));
|
||||
const values = new Map<string, string>();
|
||||
const backendFactory = async (ref: string): Promise<CredentialBackend> => ({
|
||||
name: 'fake', get: async () => values.get(ref),
|
||||
set: async value => { values.set(ref, value); }, delete: async () => { values.delete(ref); },
|
||||
});
|
||||
const app = await startServer({ manifests, backends: await Promise.all(manifests.map(m => backendFactory(m.credential))) });
|
||||
try {
|
||||
const headers: Record<string, string> = { Origin: app.origin, Authorization: 'Bearer ' + app.bootstrap, 'X-Local-Request': '1', 'Content-Type': 'application/json' };
|
||||
const session = await fetch(app.origin + '/api/session', { method: 'POST', headers });
|
||||
headers.Cookie = session.headers.get('set-cookie')!.split(';')[0];
|
||||
assert.equal((await fetch(app.origin)).status, 200);
|
||||
const meta = await (await fetch(app.origin + '/api/meta', { headers })).json();
|
||||
const saved = await fetch(app.origin + '/api/save', { method: 'POST', headers, body: JSON.stringify({ entries: meta.fields.map((f: { credential: string; revision: string }, i: number) => ({ credential: f.credential, revision: f.revision, value: 'TEST_ONLY_PROFILE_' + i })) }) });
|
||||
assert.equal((await saved.json()).status, 'saved');
|
||||
const status = await profileStatus(bindings, {}, async ref => values.get(ref));
|
||||
assert.equal(status.configured, true);
|
||||
assert.equal(JSON.stringify(status).includes('TEST_ONLY'), false);
|
||||
const plan = await prepareProfile(bindings, ['business-program', '--input', 'a b'], {}, async ref => values.get(ref));
|
||||
for (const b of bindings) assert.match(plan.env[b.env]!, /^TEST_ONLY_PROFILE_/);
|
||||
assert.equal(JSON.stringify(plan.args).includes('TEST_ONLY'), false);
|
||||
assert.deepEqual(plan.args, ['--input', 'a b']);
|
||||
} finally { app.close(); }
|
||||
}
|
||||
});
|
||||
|
||||
test('已有环境凭据无需读取系统库;缺失或后端失败时不启动业务', async () => {
|
||||
const bindings = await loadProfile('default');
|
||||
const env = Object.fromEntries(bindings.map(b => [b.env, 'TEST_ONLY_ENV']));
|
||||
const noRead = async () => { throw Error('TEST_ONLY_FAILURE'); };
|
||||
assert.equal((await profileStatus(bindings, env, noRead)).configured, true);
|
||||
assert.equal((await prepareProfile(bindings, ['business'], env, noRead)).env[bindings[0].env], 'TEST_ONLY_ENV');
|
||||
await assert.rejects(prepareProfile(bindings, ['business'], {}, async () => undefined), /未配置/);
|
||||
await assert.rejects(prepareProfile(bindings, ['business'], {}, noRead), error => error instanceof Error && !error.message.includes('TEST_ONLY'));
|
||||
});
|
||||
|
||||
test('配置拒绝跨目录声明、危险变量与未知业务', async t => {
|
||||
const dir = await mkdtemp(path.join(tmpdir(), 'credential-profile-')); t.after(() => rm(dir, { recursive: true, force: true }));
|
||||
await mkdir(path.join(dir, 'manifests'));
|
||||
for (const binding of [{ manifest: '../outside.json', env: 'API_KEY' }, { manifest: 'default.json', env: 'NODE_OPTIONS' }]) {
|
||||
await writeFile(path.join(dir, 'manifests/profiles.json'), JSON.stringify({ version: 1, profiles: { default: [binding] } }));
|
||||
await assert.rejects(loadProfile('default', dir));
|
||||
}
|
||||
await assert.rejects(loadProfile('unknown', dir));
|
||||
});
|
||||
@@ -0,0 +1,38 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { test } from 'node:test';
|
||||
import { mkdtemp, writeFile, rm } from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { execFile } from 'node:child_process';
|
||||
import { parseBindings, prepareCommand } from '../src/run.ts';
|
||||
|
||||
async function fixture() {
|
||||
const dir = await mkdtemp(path.join(tmpdir(), 'credential-run-'));
|
||||
const files = [path.join(dir, 'first.json'), path.join(dir, 'second.json')];
|
||||
await Promise.all(files.map((file, i) => writeFile(file, JSON.stringify({ version: 1, id: 'sample-skill', label: '服务', credential: 'sample/key-' + i }))));
|
||||
return { dir, files, cleanup: () => rm(dir, { recursive: true, force: true }) };
|
||||
}
|
||||
test('多个 key 同时注入一个真实子进程,命令参数不包含值', async t => {
|
||||
const f = await fixture(); t.after(f.cleanup);
|
||||
const plan = await prepareCommand(['--manifest', f.files[0], '--env', 'FIRST_API_KEY', '--manifest', f.files[1], '--env', 'SECOND_API_KEY', '--',
|
||||
process.execPath, '-e', 'process.exit(process.env.FIRST_API_KEY && process.env.SECOND_API_KEY && process.env.FIRST_API_KEY !== process.env.SECOND_API_KEY ? 0 : 1)'],
|
||||
async ref => ref.endsWith('0') ? 'FAKE_FIRST_VALUE' : 'FAKE_SECOND_VALUE');
|
||||
assert.equal(plan.env.FIRST_API_KEY, 'FAKE_FIRST_VALUE'); assert.equal(plan.env.SECOND_API_KEY, 'FAKE_SECOND_VALUE');
|
||||
assert.equal(JSON.stringify(plan.args).includes('FAKE_'), false);
|
||||
await new Promise<void>((resolve, reject) => execFile(plan.command, plan.args, { env: plan.env }, error => error ? reject(error) : resolve()));
|
||||
});
|
||||
test('任一 key 缺失或后端失败,不返回可启动的命令,也不修改父环境', async t => {
|
||||
const f = await fixture(); t.after(f.cleanup);
|
||||
const args = ['--manifest', f.files[0], '--env', 'FIRST_API_KEY', '--manifest', f.files[1], '--env', 'SECOND_API_KEY', '--', 'unused'];
|
||||
const base = { KEEP: 'unchanged' };
|
||||
await assert.rejects(prepareCommand(args, async ref => ref.endsWith('0') ? 'FAKE_FIRST_VALUE' : undefined, base), /未配置/);
|
||||
await assert.rejects(prepareCommand(args, async () => { throw Error('FAKE_VALUE_MUST_NOT_LEAK'); }, base),
|
||||
error => error instanceof Error && !error.message.includes('FAKE_'));
|
||||
assert.deepEqual(base, { KEEP: 'unchanged' });
|
||||
});
|
||||
test('拒绝重复变量和不完整绑定,兼容单 key 的原入口', () => {
|
||||
assert.throws(() => parseBindings(['--env', 'DUP_KEY', '--env', 'DUP_KEY', '--', 'unused']), /重复/);
|
||||
assert.throws(() => parseBindings(['--manifest', 'a.json', '--manifest', 'b.json', '--env', 'A_KEY', '--', 'unused']));
|
||||
assert.throws(() => parseBindings(['--env', 'HOME', '--', 'unused']));
|
||||
assert.equal(parseBindings(['--env', 'SERVICE_API_KEY', '--', 'program', 'arg']).bindings.length, 1);
|
||||
});
|
||||
@@ -0,0 +1,70 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { test } from 'node:test';
|
||||
import { runInNewContext } from 'node:vm';
|
||||
import { readFile } from 'node:fs/promises';
|
||||
|
||||
// 运行实际构建产物的 DOM 契约测试,不启动浏览器,不接触真实凭据。
|
||||
class Element {
|
||||
children: Element[] = [];
|
||||
value = ''; textContent = ''; hidden = false; disabled = false; type = ''; required = false; placeholder = '';
|
||||
className = ''; id = ''; htmlFor = '';
|
||||
listeners: Record<string, (event: object) => unknown> = {};
|
||||
classes = new Set<string>();
|
||||
classList = { toggle: (key: string, enabled: boolean) => enabled ? this.classes.add(key) : this.classes.delete(key) };
|
||||
append(...children: Element[]) { this.children.push(...children); }
|
||||
replaceChildren() { this.children = []; }
|
||||
setAttribute() {}
|
||||
addEventListener(event: string, callback: (event: object) => unknown) { this.listeners[event] = callback; }
|
||||
}
|
||||
async function harness(configured = false, fail = false) {
|
||||
const nodes = Object.fromEntries(['credential-form', 'fields', 'save', 'heading', 'context', 'hint', 'message'].map(key => [key, new Element()]));
|
||||
const fields = [0, 1].map(index => ({ id: 'sample', label: index ? '<img src=x>' : '语音服务', credential: 'sample/' + index,
|
||||
configured: index === 0 && configured, revision: 'revision-' + index, storage: '测试凭据库', ui: { placeholder: '测试输入' } }));
|
||||
const metadata = { fields, page: { title: '连接服务', label: '两个服务', saveLabel: '确认保存' }, outcome: 'waiting' };
|
||||
const submitted: any[] = [];
|
||||
const lifecycle: Record<string, () => void> = {};
|
||||
const context = { document: { getElementById: (id: string) => nodes[id], createElement: () => new Element(), title: '' },
|
||||
location: { hash: '', pathname: '/' }, history: { replaceState() {} }, AbortSignal,
|
||||
window: { addEventListener: (key: string, callback: () => void) => { lifecycle[key] = callback; } },
|
||||
fetch: async (url: string, options: any) => {
|
||||
if (url === '/api/meta') return { ok: true, json: async () => structuredClone(metadata) };
|
||||
assert.equal(url, '/api/save'); submitted.push(JSON.parse(options.body));
|
||||
if (fail) {
|
||||
metadata.fields[0].configured = true; metadata.outcome = 'partial';
|
||||
return { ok: true, json: async () => ({ status: 'partial', results: [{ credential: 'sample/0', status: 'saved' }, { credential: 'sample/1', status: 'failed' }] }) };
|
||||
}
|
||||
metadata.outcome = 'saved'; return { ok: true, json: async () => ({ status: 'saved' }) };
|
||||
} };
|
||||
runInNewContext(await readFile(new URL('../public/app.js', import.meta.url), 'utf8'), context);
|
||||
await new Promise(resolve => setImmediate(resolve));
|
||||
const inputs = () => nodes.fields.children.map(field => field.children[1]);
|
||||
const input = () => nodes['credential-form'].listeners.input({});
|
||||
const submit = () => nodes['credential-form'].listeners.submit({ preventDefault() {} });
|
||||
return { nodes, inputs, input, submit, submitted, lifecycle };
|
||||
}
|
||||
test('真实前端产物:配置生成两个密码框、纯文本标签、必填与成功清空', async () => {
|
||||
const h = await harness();
|
||||
assert.equal(h.inputs().length, 2); assert.ok(h.inputs().every(input => input.type === 'password' && input.required));
|
||||
assert.equal(h.nodes.fields.children[1].children[0].textContent, '<img src=x>');
|
||||
assert.equal(h.nodes.heading.textContent, '连接服务');
|
||||
h.inputs()[0].value = 'FAKE_ONE'; h.input(); assert.equal(h.nodes.save.disabled, true);
|
||||
h.inputs()[1].value = 'FAKE_TWO'; h.input(); assert.equal(h.nodes.save.disabled, false);
|
||||
await h.submit(); assert.equal(h.submitted[0].entries.length, 2);
|
||||
assert.ok(h.inputs().every(input => !input.value)); assert.equal(h.nodes['credential-form'].hidden, true);
|
||||
});
|
||||
test('真实前端产物:已有项留空保留,替换按钮明确,离开清空', async () => {
|
||||
const h = await harness(true);
|
||||
assert.equal(h.inputs()[0].required, false); assert.match(h.inputs()[0].placeholder, /留空保留/);
|
||||
h.inputs()[0].value = 'FAKE_REPLACE'; h.input(); assert.equal(h.nodes.save.textContent, '替换并保存');
|
||||
h.inputs()[0].value = ''; h.inputs()[1].value = 'FAKE_SECOND'; h.input();
|
||||
await h.submit(); assert.equal(h.submitted[0].entries.length, 1); assert.equal(h.submitted[0].entries[0].credential, 'sample/1');
|
||||
h.inputs()[0].value = 'FAKE_LEAVE'; h.lifecycle.pagehide(); assert.equal(h.inputs()[0].value, '');
|
||||
});
|
||||
test('真实前端产物:部分失败不显示全部保存,刷新状态并允许补填', async () => {
|
||||
const h = await harness(false, true);
|
||||
h.inputs().forEach(input => { input.value = 'FAKE_ONLY'; }); h.input(); await h.submit();
|
||||
assert.equal(h.nodes['credential-form'].hidden, false); assert.match(h.nodes.message.textContent, /未确认成功/);
|
||||
assert.ok(h.inputs().every(input => !input.value)); assert.equal(h.inputs()[0].required, false);
|
||||
assert.equal(h.inputs()[1].required, true); assert.equal(h.nodes.save.disabled, true);
|
||||
h.inputs()[1].value = 'FAKE_RETRY'; h.input(); assert.equal(h.nodes.save.disabled, false);
|
||||
});
|
||||
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2023",
|
||||
"module": "NodeNext",
|
||||
"moduleResolution": "NodeNext",
|
||||
"strict": true,
|
||||
"allowImportingTsExtensions": true,
|
||||
"noEmit": true,
|
||||
"types": ["node"],
|
||||
"lib": ["ES2023", "DOM", "DOM.Iterable"],
|
||||
"skipLibCheck": true
|
||||
},
|
||||
"include": ["src/**/*.ts", "web/**/*.ts", "tests/**/*.ts"]
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "ES2022",
|
||||
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||
"strict": true,
|
||||
"rootDir": "web",
|
||||
"outDir": "public",
|
||||
"types": []
|
||||
},
|
||||
"include": ["web/**/*.ts"]
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
type Field = { id: string; label: string; credential: string; revision: string; configured: boolean; storage: string; ui?: { title?: string; placeholder?: string; saveLabel?: string } };
|
||||
type Metadata = { fields: Field[]; page: { title?: string; label?: string; saveLabel?: string }; outcome: string };
|
||||
type SaveResult = { status: string; results?: { credential: string; status: string }[] };
|
||||
const get = <T extends HTMLElement = HTMLElement>(id: string) => document.getElementById(id) as T;
|
||||
const form = get<HTMLFormElement>('credential-form');
|
||||
const button = get<HTMLButtonElement>('save');
|
||||
let inputs: HTMLInputElement[] = [];
|
||||
let meta: Metadata;
|
||||
let busy = false;
|
||||
const saveLabel = () => inputs.some((input, index) => input.value.trim() && meta.fields[index].configured)
|
||||
? '替换并保存' : (meta.page.saveLabel ?? (meta.fields.length === 1 ? meta.fields[0].ui?.saveLabel : undefined) ?? '保存');
|
||||
async function request<T>(url: string, options: RequestInit = {}): Promise<T> {
|
||||
let response: Response;
|
||||
try {
|
||||
response = await fetch(url, { ...options, credentials: 'same-origin', cache: 'no-store',
|
||||
headers: { 'Content-Type': 'application/json', 'X-Local-Request': '1', ...options.headers }, signal: AbortSignal.timeout(25_000) });
|
||||
} catch { throw new Error('未收到服务回复,请刷新确认保存结果,不要重复提交。'); }
|
||||
const result = await response.json();
|
||||
if (!response.ok) throw new Error(result.error ?? '操作未完成,请重试。');
|
||||
return result as T;
|
||||
}
|
||||
function message(text: string) { const node = get('message'); node.textContent = text; node.hidden = false; }
|
||||
function clearInputs() { inputs.forEach(input => { input.value = ''; }); }
|
||||
function done() {
|
||||
clearInputs(); form.hidden = true; get('heading').textContent = '已保存';
|
||||
get('hint').hidden = true; message('可以关闭此页,回到对话继续。');
|
||||
}
|
||||
function update() {
|
||||
button.disabled = busy || !meta || !['waiting', 'partial'].includes(meta.outcome)
|
||||
|| !inputs.some(input => input.value.trim())
|
||||
|| inputs.some((input, index) => !meta.fields[index].configured && !input.value.trim());
|
||||
if (meta && !busy) button.textContent = saveLabel();
|
||||
}
|
||||
function render() {
|
||||
clearInputs(); inputs = [];
|
||||
const multi = meta.fields.length > 1;
|
||||
form.classList.toggle('multi', multi);
|
||||
const container = get('fields'); container.replaceChildren();
|
||||
meta.fields.forEach((field, index) => {
|
||||
const wrapper = document.createElement('div'); wrapper.className = 'field';
|
||||
const label = document.createElement('label'); label.htmlFor = 'secret-' + index; label.textContent = field.label;
|
||||
if (!multi) label.className = 'visually-hidden';
|
||||
if (field.configured) {
|
||||
const state = document.createElement('span'); state.className = 'field-state'; state.textContent = '已配置'; label.append(state);
|
||||
}
|
||||
const input = document.createElement('input'); input.id = label.htmlFor; input.type = 'password';
|
||||
input.autocomplete = 'new-password'; input.autocapitalize = 'off'; input.spellcheck = false; input.maxLength = 2500;
|
||||
input.required = !field.configured; input.setAttribute('aria-describedby', 'hint message');
|
||||
input.placeholder = field.configured ? '留空保留,输入则替换' : (field.ui?.placeholder ?? '粘贴 API Key');
|
||||
input.disabled = !['waiting', 'partial'].includes(meta.outcome);
|
||||
wrapper.append(label, input); container.append(wrapper); inputs.push(input);
|
||||
});
|
||||
get('context').textContent = meta.page.label ?? (multi ? meta.fields.length + ' 项凭据' : meta.fields[0].label);
|
||||
const title = meta.page.title ?? (multi ? '输入密钥' : meta.fields[0].ui?.title) ?? '输入密钥';
|
||||
get('heading').textContent = title; document.title = title;
|
||||
const storage = [...new Set(meta.fields.map(field => field.storage))].join('、');
|
||||
get('hint').textContent = '仅保存到' + storage + (meta.fields.some(field => field.configured) ? ' · 已配置项留空保留' : '');
|
||||
update();
|
||||
if (meta.outcome === 'saved') done();
|
||||
else if (meta.outcome === 'partial') message('上次仅部分保存。请核对已配置项,补填未完成项后重试。');
|
||||
else if (meta.outcome !== 'waiting') message('本次配置已结束,请重新打开入口。');
|
||||
}
|
||||
form.addEventListener('input', () => { update(); get('message').hidden = true; });
|
||||
form.addEventListener('submit', async event => {
|
||||
event.preventDefault(); if (busy || button.disabled) return;
|
||||
busy = true; update(); inputs.forEach(input => { input.disabled = true; }); button.textContent = '正在保存…';
|
||||
try {
|
||||
const entries = inputs.flatMap((input, index) => input.value.trim() ? [{ credential: meta.fields[index].credential,
|
||||
value: input.value, revision: meta.fields[index].revision, replaceExisting: meta.fields[index].configured }] : []);
|
||||
const result = await request<SaveResult>('/api/save', { method: 'POST', body: JSON.stringify({ entries }) });
|
||||
if (result.status === 'saved') { meta.outcome = 'saved'; done(); }
|
||||
else {
|
||||
clearInputs(); meta = await request<Metadata>('/api/meta'); render();
|
||||
const statuses: Record<string, string> = { saved: '已保存', failed: '未确认成功', not_attempted: '未尝试' };
|
||||
message((result.results ?? []).map(item => (meta.fields.find(field => field.credential === item.credential)?.label ?? '凭据') + ':' + (statuses[item.status] ?? '请核对')).join(';')
|
||||
+ '。请核对状态后重新填写未完成项。');
|
||||
}
|
||||
} catch (error) {
|
||||
clearInputs();
|
||||
try { meta = await request<Metadata>('/api/meta'); render(); }
|
||||
catch { meta.outcome = 'unknown'; }
|
||||
if (meta.outcome !== 'saved') message(error instanceof Error ? error.message : '保存未完成,请刷新确认结果。');
|
||||
} finally {
|
||||
clearInputs(); busy = false; inputs.forEach(input => { input.disabled = !['waiting', 'partial'].includes(meta.outcome); }); update();
|
||||
}
|
||||
});
|
||||
window.addEventListener('pagehide', clearInputs);
|
||||
async function initialize() {
|
||||
const token = location.hash.slice(1);
|
||||
if (token) {
|
||||
history.replaceState(null, '', location.pathname);
|
||||
await request('/api/session', { method: 'POST', headers: { Authorization: 'Bearer ' + token }, body: '{}' });
|
||||
}
|
||||
meta = await request<Metadata>('/api/meta'); render();
|
||||
}
|
||||
initialize().catch(error => { get('context').textContent = '暂时无法连接'; message(error instanceof Error ? error.message : '请重新打开配置入口。'); });
|
||||
@@ -0,0 +1,283 @@
|
||||
#!/usr/bin/env python3
|
||||
"""通过 ZenMux 图片模型生成关键帧 PNG。
|
||||
|
||||
- `--background` 必填:`page` 路线传 transparent,`video` 路线传 opaque,不静默猜测。
|
||||
- 传 `--image` 时走改图接口,用参考图锁定产品结构、角色身份或上一张关键帧。
|
||||
- transparent 结果会检查真实 Alpha;四角不透明或没有可见主体时拒收,不交给后续色键合成。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import base64
|
||||
import binascii
|
||||
import io
|
||||
import json
|
||||
import mimetypes
|
||||
import re
|
||||
import sys
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from PIL import Image
|
||||
|
||||
from oil_motion_config import require_api_key
|
||||
|
||||
API_ROOT = "https://zenmux.ai/api/v1"
|
||||
DEFAULT_IMAGE_MODEL = "openai/gpt-image-2.5-flare"
|
||||
DEFAULT_SIZE = "1024x1024"
|
||||
BACKGROUNDS = ("transparent", "opaque")
|
||||
QUALITIES = ("auto", "low", "medium", "high")
|
||||
MAX_REFERENCES = 16
|
||||
MAX_REFERENCE_BYTES = 50 * 1024 * 1024
|
||||
ALPHA_TRANSPARENT = 16
|
||||
KEY_PATTERN = re.compile(r"sk-[A-Za-z0-9_-]{12,}")
|
||||
|
||||
|
||||
def redact(text: str, api_key: str = "") -> str:
|
||||
if api_key:
|
||||
text = text.replace(api_key, "[redacted]")
|
||||
return KEY_PATTERN.sub("[redacted]", text)
|
||||
|
||||
|
||||
def parse_size(size: str, model: str) -> tuple[int, int]:
|
||||
match = re.fullmatch(r"(\d{2,4})x(\d{2,4})", size)
|
||||
if not match:
|
||||
raise ValueError(f"--size 写成 宽x高(如 2048x1152),当前是 {size}")
|
||||
width, height = int(match.group(1)), int(match.group(2))
|
||||
if model.startswith("openai/gpt-image-2"):
|
||||
problems = []
|
||||
if width % 16 or height % 16:
|
||||
problems.append("宽和高都要是 16 的倍数")
|
||||
if max(width, height) > 3840:
|
||||
problems.append("最长边不超过 3840")
|
||||
if max(width, height) / min(width, height) > 3:
|
||||
problems.append("长短边之比不超过 3:1")
|
||||
if not 655_360 <= width * height <= 8_294_400:
|
||||
problems.append("总像素在 655,360 到 8,294,400 之间")
|
||||
if problems:
|
||||
raise ValueError(f"--size {size} 不可用:{';'.join(problems)}")
|
||||
return width, height
|
||||
|
||||
|
||||
def check_references(paths: list[str]) -> list[Path]:
|
||||
if len(paths) > MAX_REFERENCES:
|
||||
raise ValueError(f"参考图最多 {MAX_REFERENCES} 张,当前 {len(paths)} 张")
|
||||
result = []
|
||||
for value in paths:
|
||||
path = Path(value).expanduser().resolve()
|
||||
if not path.is_file():
|
||||
raise FileNotFoundError(f"找不到参考图:{path}")
|
||||
if path.stat().st_size > MAX_REFERENCE_BYTES:
|
||||
raise ValueError(f"参考图超过 50MB:{path}")
|
||||
result.append(path)
|
||||
return result
|
||||
|
||||
|
||||
def multipart(fields: dict[str, str], files: list[tuple[str, Path]]) -> tuple[bytes, str]:
|
||||
boundary = f"----oil-motion-{uuid.uuid4().hex}"
|
||||
body = bytearray()
|
||||
for name, value in fields.items():
|
||||
body += f'--{boundary}\r\nContent-Disposition: form-data; name="{name}"\r\n\r\n{value}\r\n'.encode("utf-8")
|
||||
for name, path in files:
|
||||
mime = mimetypes.guess_type(path.name)[0] or "application/octet-stream"
|
||||
body += (
|
||||
f'--{boundary}\r\nContent-Disposition: form-data; name="{name}"; filename="{path.name}"\r\n'
|
||||
f"Content-Type: {mime}\r\n\r\n"
|
||||
).encode("utf-8")
|
||||
body += path.read_bytes() + b"\r\n"
|
||||
body += f"--{boundary}--\r\n".encode("utf-8")
|
||||
return bytes(body), f"multipart/form-data; boundary={boundary}"
|
||||
|
||||
|
||||
def build_request(
|
||||
prompt: str,
|
||||
background: str,
|
||||
size: str,
|
||||
model: str,
|
||||
quality: str,
|
||||
references: list[Path],
|
||||
) -> tuple[str, dict[str, str]]:
|
||||
"""返回接口路径与请求字段;不含密钥。"""
|
||||
fields = {
|
||||
"model": model,
|
||||
"prompt": prompt,
|
||||
"size": size,
|
||||
"quality": quality,
|
||||
"background": background,
|
||||
"output_format": "png",
|
||||
"n": "1",
|
||||
}
|
||||
return ("/images/edits" if references else "/images/generations"), fields
|
||||
|
||||
|
||||
def send(path: str, fields: dict[str, str], references: list[Path], api_key: str, timeout: float) -> dict[str, Any]:
|
||||
headers = {
|
||||
"Authorization": f"Bearer {api_key}",
|
||||
"Accept": "application/json",
|
||||
"User-Agent": "oil-motion/1.0",
|
||||
}
|
||||
if references:
|
||||
data, headers["Content-Type"] = multipart(fields, [("image[]", item) for item in references])
|
||||
else:
|
||||
payload = {**fields, "n": 1}
|
||||
data = json.dumps(payload, ensure_ascii=False).encode("utf-8")
|
||||
headers["Content-Type"] = "application/json"
|
||||
request = urllib.request.Request(f"{API_ROOT}{path}", data=data, method="POST", headers=headers)
|
||||
try:
|
||||
with urllib.request.urlopen(request, timeout=timeout) as response:
|
||||
body = response.read().decode("utf-8")
|
||||
except urllib.error.HTTPError as exc:
|
||||
details = redact(exc.read().decode("utf-8", errors="replace")[:2000], api_key)
|
||||
raise RuntimeError(f"ZenMux Image API {exc.code}: {details}") from exc
|
||||
try:
|
||||
result = json.loads(body)
|
||||
except json.JSONDecodeError as exc:
|
||||
raise RuntimeError(f"图片接口返回了无效 JSON:{redact(body[:500], api_key)}") from exc
|
||||
if not isinstance(result, dict):
|
||||
raise RuntimeError("图片接口返回的顶层数据不是对象")
|
||||
return result
|
||||
|
||||
|
||||
def image_bytes(result: dict[str, Any], timeout: float) -> bytes:
|
||||
items = result.get("data")
|
||||
if not isinstance(items, list) or not items or not isinstance(items[0], dict):
|
||||
raise RuntimeError("图片接口没有返回图片")
|
||||
item = items[0]
|
||||
if isinstance(item.get("b64_json"), str):
|
||||
try:
|
||||
return base64.b64decode(item["b64_json"], validate=True)
|
||||
except (binascii.Error, ValueError) as exc:
|
||||
raise RuntimeError("返回的图片数据不是有效的 Base64") from exc
|
||||
if isinstance(item.get("url"), str):
|
||||
request = urllib.request.Request(item["url"], headers={"User-Agent": "oil-motion/1.0"})
|
||||
with urllib.request.urlopen(request, timeout=timeout) as response:
|
||||
return response.read()
|
||||
raise RuntimeError("返回的图片项既没有 b64_json 也没有 url")
|
||||
|
||||
|
||||
def alpha_problem(image: Image.Image) -> str | None:
|
||||
"""page 路线关键帧的透明验收:真实 Alpha、四角透明、存在可见主体。"""
|
||||
if "A" not in image.getbands():
|
||||
return "没有 Alpha 通道"
|
||||
alpha = image.getchannel("A")
|
||||
width, height = image.size
|
||||
corners = [alpha.getpixel(point) for point in ((0, 0), (width - 1, 0), (0, height - 1), (width - 1, height - 1))]
|
||||
if max(corners) > ALPHA_TRANSPARENT:
|
||||
return f"四角不透明(Alpha {corners}),背景没有真正透明"
|
||||
if alpha.getextrema()[1] <= ALPHA_TRANSPARENT:
|
||||
return "整张图透明,没有可见主体"
|
||||
return None
|
||||
|
||||
|
||||
def generate_image(
|
||||
prompt: str,
|
||||
output_path: Path,
|
||||
background: str,
|
||||
size: str = DEFAULT_SIZE,
|
||||
model: str = DEFAULT_IMAGE_MODEL,
|
||||
quality: str = "auto",
|
||||
references: list[Path] | None = None,
|
||||
force: bool = False,
|
||||
timeout: float = 600,
|
||||
) -> Path:
|
||||
references = references or []
|
||||
if output_path.suffix.lower() != ".png":
|
||||
raise ValueError(f"关键帧输出使用 .png:{output_path.name}")
|
||||
rejected = output_path.with_name(f"{output_path.stem}.rejected.png")
|
||||
for target in (output_path, rejected):
|
||||
if target.exists() and not force:
|
||||
raise FileExistsError(f"输出文件已存在:{target};确认后使用 --force")
|
||||
|
||||
api_key = require_api_key()
|
||||
path, fields = build_request(prompt, background, size, model, quality, references)
|
||||
mode = f"改图({len(references)} 张参考图)" if references else "文生图"
|
||||
print(f"正在提交关键帧:{model} {size} {background},{mode}…", flush=True)
|
||||
data = image_bytes(send(path, fields, references, api_key, timeout), timeout)
|
||||
|
||||
try:
|
||||
with Image.open(io.BytesIO(data)) as opened:
|
||||
opened.load()
|
||||
image = opened.copy()
|
||||
except OSError as exc:
|
||||
raise RuntimeError("返回的数据不是可读取的图片") from exc
|
||||
|
||||
problem = alpha_problem(image) if background == "transparent" else None
|
||||
target = rejected if problem else output_path
|
||||
target.parent.mkdir(parents=True, exist_ok=True)
|
||||
image.save(target, format="PNG")
|
||||
width, height = image.size
|
||||
print(f"实际尺寸:{width}x{height}(宽高比 {width / height:.3f})", flush=True)
|
||||
if problem:
|
||||
raise RuntimeError(f"透明验收失败:{problem}。结果已另存为 {target},不要用作 page 路线关键帧")
|
||||
print(f"关键帧已保存:{target}", flush=True)
|
||||
return target
|
||||
|
||||
|
||||
def parser() -> argparse.ArgumentParser:
|
||||
result = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
|
||||
prompt_group = result.add_mutually_exclusive_group(required=True)
|
||||
prompt_group.add_argument("--prompt", help="提示词文本")
|
||||
prompt_group.add_argument("--prompt-file", help="提示词文件")
|
||||
result.add_argument("--output", required=True, help="输出 PNG 路径")
|
||||
result.add_argument(
|
||||
"--background",
|
||||
required=True,
|
||||
choices=BACKGROUNDS,
|
||||
help="background_owner=page 传 transparent;background_owner=video 传 opaque",
|
||||
)
|
||||
result.add_argument(
|
||||
"--image",
|
||||
action="append",
|
||||
default=[],
|
||||
help="参考图,可重复;顺序即提示词中的“图 1、图 2”",
|
||||
)
|
||||
result.add_argument("--size", default=DEFAULT_SIZE, help="宽x高,按合同 aspect_ratio 选择")
|
||||
result.add_argument("--model", default=DEFAULT_IMAGE_MODEL, help="图片模型 ID")
|
||||
result.add_argument("--quality", default="auto", choices=QUALITIES)
|
||||
result.add_argument("--timeout", type=float, default=600, help="请求超时秒数")
|
||||
result.add_argument("--force", action="store_true", help="覆盖已存在的输出文件")
|
||||
result.add_argument("--dry-run", action="store_true", help="只校验参数并显示请求摘要,不调用接口、不需要密钥")
|
||||
return result
|
||||
|
||||
|
||||
def main() -> int:
|
||||
args = parser().parse_args()
|
||||
prompt = Path(args.prompt_file).read_text(encoding="utf-8") if args.prompt_file else args.prompt
|
||||
prompt = (prompt or "").strip()
|
||||
if not prompt:
|
||||
raise ValueError("提示词是空的")
|
||||
parse_size(args.size, args.model)
|
||||
references = check_references(args.image)
|
||||
output = Path(args.output).expanduser().resolve()
|
||||
if args.dry_run:
|
||||
path, fields = build_request(prompt, args.background, args.size, args.model, args.quality, references)
|
||||
print(json.dumps(
|
||||
{"endpoint": f"{API_ROOT}{path}", **fields, "references": [str(item) for item in references], "output": str(output)},
|
||||
ensure_ascii=False,
|
||||
indent=2,
|
||||
))
|
||||
return 0
|
||||
generate_image(
|
||||
prompt=prompt,
|
||||
output_path=output,
|
||||
background=args.background,
|
||||
size=args.size,
|
||||
model=args.model,
|
||||
quality=args.quality,
|
||||
references=references,
|
||||
force=args.force,
|
||||
timeout=args.timeout,
|
||||
)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
try:
|
||||
raise SystemExit(main())
|
||||
except (FileNotFoundError, FileExistsError, RuntimeError, ValueError) as error:
|
||||
print(f"错误:{error}", file=sys.stderr)
|
||||
raise SystemExit(1) from None
|
||||
@@ -0,0 +1,154 @@
|
||||
#!/usr/bin/env python3
|
||||
"""清理 AI 闭环视频切帧中的重复帧和尾部停帧。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import shutil
|
||||
from pathlib import Path
|
||||
|
||||
from PIL import Image, ImageChops, ImageStat
|
||||
|
||||
|
||||
IMAGE_SUFFIXES = {".png", ".webp", ".jpg", ".jpeg"}
|
||||
|
||||
|
||||
def difference(left: Path, right: Path, sample_size: int = 128) -> float:
|
||||
with Image.open(left) as first, Image.open(right) as second:
|
||||
def visible_rgb(image: Image.Image) -> Image.Image:
|
||||
if "A" not in image.getbands():
|
||||
return image.convert("RGB")
|
||||
rgba = image.convert("RGBA")
|
||||
background = Image.new("RGBA", rgba.size, (255, 255, 255, 255))
|
||||
return Image.alpha_composite(background, rgba).convert("RGB")
|
||||
|
||||
first_rgb = visible_rgb(first)
|
||||
second_rgb = visible_rgb(second)
|
||||
first_rgb.thumbnail((sample_size, sample_size), Image.Resampling.LANCZOS)
|
||||
second_rgb.thumbnail((sample_size, sample_size), Image.Resampling.LANCZOS)
|
||||
if first_rgb.size != second_rgb.size:
|
||||
second_rgb = second_rgb.resize(first_rgb.size, Image.Resampling.LANCZOS)
|
||||
means = ImageStat.Stat(ImageChops.difference(first_rgb, second_rgb)).mean
|
||||
return sum(means) / (3 * 255)
|
||||
|
||||
|
||||
def frames_in(directory: Path) -> list[Path]:
|
||||
return sorted(
|
||||
path
|
||||
for path in directory.iterdir()
|
||||
if path.is_file() and path.suffix.lower() in IMAGE_SUFFIXES
|
||||
)
|
||||
|
||||
|
||||
def clean(args: argparse.Namespace) -> int:
|
||||
source = Path(args.source).expanduser().resolve()
|
||||
output = Path(args.output).expanduser().resolve()
|
||||
report_path = (
|
||||
Path(args.report).expanduser().resolve()
|
||||
if args.report
|
||||
else output.parent / f"{output.name}-cleanup.json"
|
||||
)
|
||||
frames = frames_in(source)
|
||||
if len(frames) < 3:
|
||||
raise ValueError("至少需要 3 帧")
|
||||
if output.exists() and any(output.iterdir()) and not args.force:
|
||||
raise FileExistsError(f"输出目录非空:{output};确认后使用 --force")
|
||||
output.mkdir(parents=True, exist_ok=True)
|
||||
if args.force:
|
||||
for item in output.iterdir():
|
||||
if item.is_file() or item.is_symlink():
|
||||
item.unlink()
|
||||
elif item.is_dir():
|
||||
shutil.rmtree(item)
|
||||
|
||||
seam_window_start = max(1, len(frames) - args.seam_window)
|
||||
end_reference = (
|
||||
Path(args.end_reference).expanduser().resolve()
|
||||
if args.end_reference
|
||||
else frames[0]
|
||||
)
|
||||
seam_candidates = [
|
||||
(index, difference(end_reference, frames[index]))
|
||||
for index in range(seam_window_start, len(frames))
|
||||
]
|
||||
seam_index, seam_difference = min(seam_candidates, key=lambda item: item[1])
|
||||
|
||||
kept = [0]
|
||||
dropped: list[dict[str, float | int | str]] = []
|
||||
for index in range(1, seam_index):
|
||||
delta = difference(frames[kept[-1]], frames[index])
|
||||
if delta < args.duplicate_threshold:
|
||||
dropped.append(
|
||||
{
|
||||
"index": index,
|
||||
"file": frames[index].name,
|
||||
"difference": round(delta, 8),
|
||||
}
|
||||
)
|
||||
continue
|
||||
kept.append(index)
|
||||
if kept[-1] != seam_index:
|
||||
kept.append(seam_index)
|
||||
|
||||
digits = max(5, len(str(len(kept))))
|
||||
for output_index, source_index in enumerate(kept, start=1):
|
||||
source_path = frames[source_index]
|
||||
target = output / f"frame_{output_index:0{digits}d}{source_path.suffix.lower()}"
|
||||
shutil.copy2(source_path, target)
|
||||
|
||||
report = {
|
||||
"source": str(source),
|
||||
"output": str(output),
|
||||
"inputFrameCount": len(frames),
|
||||
"outputFrameCount": len(kept),
|
||||
"seamWindow": args.seam_window,
|
||||
"endReference": str(end_reference),
|
||||
"seamSourceIndex": seam_index,
|
||||
"seamDifference": round(seam_difference, 8),
|
||||
"duplicateThreshold": args.duplicate_threshold,
|
||||
"keptSourceIndices": kept,
|
||||
"droppedNearDuplicates": dropped,
|
||||
"trimmedTailSourceIndices": list(range(seam_index + 1, len(frames))),
|
||||
}
|
||||
report_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
report_path.write_text(
|
||||
json.dumps(report, ensure_ascii=False, indent=2),
|
||||
encoding="utf-8",
|
||||
)
|
||||
print(json.dumps(report, ensure_ascii=False, indent=2))
|
||||
return 0
|
||||
|
||||
|
||||
def parser() -> argparse.ArgumentParser:
|
||||
result = argparse.ArgumentParser(
|
||||
description="选择最接近首帧的尾部接缝,并删除视觉近重复帧"
|
||||
)
|
||||
result.add_argument("source")
|
||||
result.add_argument("output")
|
||||
result.add_argument(
|
||||
"--seam-window",
|
||||
type=int,
|
||||
default=24,
|
||||
help="只在最后 N 帧中寻找最接近首帧的接缝",
|
||||
)
|
||||
result.add_argument(
|
||||
"--end-reference",
|
||||
help="单向转场的目标尾帧;省略时使用首帧,适合闭环",
|
||||
)
|
||||
result.add_argument(
|
||||
"--duplicate-threshold",
|
||||
type=float,
|
||||
default=0.003,
|
||||
help="与上一保留帧的归一化 RGB 差异低于此值时删除",
|
||||
)
|
||||
result.add_argument("--report")
|
||||
result.add_argument("--force", action="store_true")
|
||||
return result
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
try:
|
||||
raise SystemExit(clean(parser().parse_args()))
|
||||
except (FileNotFoundError, FileExistsError, ValueError) as error:
|
||||
raise SystemExit(f"错误:{error}") from error
|
||||
@@ -0,0 +1,36 @@
|
||||
#!/usr/bin/env python3
|
||||
"""确定性提取视频首帧与实际尾帧。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def extract_edge_frame(video: str | Path, output: str | Path, edge: str) -> None:
|
||||
source = Path(video).expanduser().resolve()
|
||||
target = Path(output).expanduser().resolve()
|
||||
if not source.is_file():
|
||||
raise FileNotFoundError(f"视频不存在:{source}")
|
||||
if edge not in {"head", "tail"}:
|
||||
raise ValueError("edge 必须是 head 或 tail")
|
||||
target.parent.mkdir(parents=True, exist_ok=True)
|
||||
command = ["ffmpeg", "-hide_banner", "-loglevel", "error", "-y"]
|
||||
if edge == "tail":
|
||||
command.extend(["-sseof", "-3", "-i", str(source), "-update", "1"])
|
||||
else:
|
||||
command.extend(["-i", str(source), "-frames:v", "1"])
|
||||
command.append(str(target))
|
||||
completed = subprocess.run(command, capture_output=True, text=True)
|
||||
if completed.returncode != 0 or not target.is_file() or target.stat().st_size == 0:
|
||||
raise RuntimeError(
|
||||
f"无法提取视频{edge}帧:{completed.stderr.strip() or source}"
|
||||
)
|
||||
|
||||
|
||||
def extract_first_frame(video: str | Path, output: str | Path) -> None:
|
||||
extract_edge_frame(video, output, "head")
|
||||
|
||||
|
||||
def extract_last_frame(video: str | Path, output: str | Path) -> None:
|
||||
extract_edge_frame(video, output, "tail")
|
||||
@@ -0,0 +1,413 @@
|
||||
#!/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())
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,166 @@
|
||||
#!/usr/bin/env python3
|
||||
"""保存并读取 Oil Motion 的本地配置。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import getpass
|
||||
import json
|
||||
import os
|
||||
import stat
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
import secure_credentials
|
||||
|
||||
|
||||
CONFIG_FILE_ENV = "OIL_MOTION_CONFIG_FILE"
|
||||
API_KEY_ENV = "ZENMUX_API_KEY"
|
||||
CREDENTIAL_REF = 'oil-motion/zenmux/default'
|
||||
|
||||
|
||||
def config_path() -> Path:
|
||||
override = os.environ.get(CONFIG_FILE_ENV, "").strip()
|
||||
if override:
|
||||
return Path(override).expanduser().resolve()
|
||||
config_home = os.environ.get("XDG_CONFIG_HOME", "").strip()
|
||||
root = Path(config_home).expanduser() if config_home else Path.home() / ".config"
|
||||
return root / "oil-motion" / "config.json"
|
||||
|
||||
|
||||
def read_config(path: Path | None = None) -> dict[str, Any]:
|
||||
target = path or config_path()
|
||||
if not target.exists():
|
||||
return {}
|
||||
try:
|
||||
value = json.loads(target.read_text(encoding="utf-8"))
|
||||
except json.JSONDecodeError as error:
|
||||
raise RuntimeError(f"配置文件不是有效的 JSON:{target}") from error
|
||||
if not isinstance(value, dict):
|
||||
raise RuntimeError(f"配置文件的根节点必须是对象:{target}")
|
||||
return value
|
||||
|
||||
|
||||
def configured_api_key(path: Path | None = None) -> tuple[str, str]:
|
||||
environment_key = os.environ.get(API_KEY_ENV, "").strip()
|
||||
if environment_key:
|
||||
return environment_key, API_KEY_ENV
|
||||
config = read_config(path)
|
||||
zenmux = config.get("zenmux")
|
||||
if isinstance(zenmux, dict):
|
||||
if zenmux.get('credential_ref'):
|
||||
key = secure_credentials.read(zenmux['credential_ref'])
|
||||
return (key, '系统凭据库') if key else ('', '')
|
||||
stored_key = zenmux.get("api_key")
|
||||
if isinstance(stored_key, str) and stored_key.strip():
|
||||
return stored_key.strip(), str(path or config_path())
|
||||
return "", ""
|
||||
|
||||
|
||||
def require_api_key(path: Path | None = None) -> str:
|
||||
api_key, _ = configured_api_key(path)
|
||||
if api_key:
|
||||
return api_key
|
||||
raise RuntimeError(
|
||||
"读取不到 ZenMux API Key。先运行 `node scripts/credential-ui/src/profile.ts status default`;"
|
||||
"未配置时用 `setup default` 打开本机页面由用户填写,"
|
||||
"再通过 `profile.ts run default -- python3 <生成脚本>` 运行生成命令。"
|
||||
)
|
||||
|
||||
|
||||
def write_config(config: dict[str, Any], path: Path | None = None) -> Path:
|
||||
target = path or config_path()
|
||||
target.parent.mkdir(parents=True, exist_ok=True, mode=0o700)
|
||||
try:
|
||||
target.parent.chmod(0o700)
|
||||
except OSError:
|
||||
pass
|
||||
temporary = target.with_suffix(f"{target.suffix}.tmp")
|
||||
temporary.write_text(
|
||||
json.dumps(config, ensure_ascii=False, indent=2) + "\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
try:
|
||||
temporary.chmod(stat.S_IRUSR | stat.S_IWUSR)
|
||||
except OSError:
|
||||
pass
|
||||
temporary.replace(target)
|
||||
return target
|
||||
|
||||
|
||||
def set_api_key(path: Path | None = None) -> int:
|
||||
key = getpass.getpass("ZenMux API Key:").strip()
|
||||
if not key:
|
||||
raise RuntimeError("API Key 不能为空")
|
||||
config = read_config(path)
|
||||
zenmux = config.get("zenmux")
|
||||
if not isinstance(zenmux, dict):
|
||||
zenmux = {}
|
||||
secure_credentials.save(CREDENTIAL_REF, key)
|
||||
zenmux.pop('api_key', None)
|
||||
zenmux['credential_ref'] = CREDENTIAL_REF
|
||||
config["zenmux"] = zenmux
|
||||
target = write_config(config, path)
|
||||
print(f"已保存:{target}")
|
||||
return 0
|
||||
|
||||
|
||||
def clear_api_key(path: Path | None = None) -> int:
|
||||
target = path or config_path()
|
||||
config = read_config(target)
|
||||
zenmux = config.get("zenmux")
|
||||
if isinstance(zenmux, dict):
|
||||
reference = zenmux.get('credential_ref')
|
||||
if reference:
|
||||
secure_credentials.delete(reference)
|
||||
zenmux.pop('credential_ref', None)
|
||||
zenmux.pop("api_key", None)
|
||||
if zenmux:
|
||||
config["zenmux"] = zenmux
|
||||
else:
|
||||
config.pop("zenmux", None)
|
||||
if config:
|
||||
write_config(config, target)
|
||||
elif target.exists():
|
||||
target.unlink()
|
||||
print("已清除 ZenMux API Key")
|
||||
return 0
|
||||
|
||||
|
||||
def show_status(path: Path | None = None) -> int:
|
||||
_, source = configured_api_key(path)
|
||||
if source:
|
||||
print(f"ZenMux API Key 已配置(来源:{source})")
|
||||
return 0
|
||||
print("ZenMux API Key 尚未配置")
|
||||
return 1
|
||||
|
||||
|
||||
def parser() -> argparse.ArgumentParser:
|
||||
result = argparse.ArgumentParser(description="管理 Oil Motion 的本地配置")
|
||||
subparsers = result.add_subparsers(dest="command", required=True)
|
||||
subparsers.add_parser("set", help="在隐藏输入框中保存 ZenMux API Key")
|
||||
subparsers.add_parser("status", help="检查 API Key 是否已经配置")
|
||||
subparsers.add_parser("clear", help="清除已经保存的 API Key")
|
||||
subparsers.add_parser("path", help="显示配置文件路径")
|
||||
return result
|
||||
|
||||
|
||||
def main() -> int:
|
||||
args = parser().parse_args()
|
||||
if args.command == "set":
|
||||
return set_api_key()
|
||||
if args.command == "status":
|
||||
return show_status()
|
||||
if args.command == "clear":
|
||||
return clear_api_key()
|
||||
print(config_path())
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
try:
|
||||
raise SystemExit(main())
|
||||
except RuntimeError as error:
|
||||
print(f"错误:{error}", file=sys.stderr)
|
||||
raise SystemExit(1) from error
|
||||
@@ -0,0 +1,858 @@
|
||||
#!/usr/bin/env python3
|
||||
"""补帧验收和网页动画资产目标体积压缩工具。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import math
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import tempfile
|
||||
from collections import Counter
|
||||
from fractions import Fraction
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import motion_pipeline
|
||||
import motion_budget
|
||||
from PIL import Image
|
||||
|
||||
|
||||
MIB = 1024 * 1024
|
||||
|
||||
|
||||
def positive_float(value: str) -> float:
|
||||
number = float(value)
|
||||
if number <= 0:
|
||||
raise argparse.ArgumentTypeError("必须大于 0")
|
||||
return number
|
||||
|
||||
|
||||
def unit_float(value: str) -> float:
|
||||
number = float(value)
|
||||
if not 0 < number <= 1:
|
||||
raise argparse.ArgumentTypeError("必须位于 0 到 1 之间")
|
||||
return number
|
||||
|
||||
|
||||
def run(command: list[str]) -> subprocess.CompletedProcess[str]:
|
||||
return subprocess.run(
|
||||
command,
|
||||
check=True,
|
||||
text=True,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
)
|
||||
|
||||
|
||||
def ensure_output(path: Path, force: bool, directory: bool = False) -> None:
|
||||
if path.exists():
|
||||
occupied = path.is_file() or (path.is_dir() and any(path.iterdir()))
|
||||
if occupied and not force:
|
||||
raise SystemExit(f"输出已存在:{path}。确认后使用 --force。")
|
||||
if force:
|
||||
if path.is_dir():
|
||||
shutil.rmtree(path)
|
||||
else:
|
||||
path.unlink()
|
||||
if directory:
|
||||
path.mkdir(parents=True, exist_ok=True)
|
||||
else:
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
|
||||
def write_json(path: Path, value: Any) -> None:
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_text(
|
||||
json.dumps(value, ensure_ascii=False, indent=2) + "\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
|
||||
def size_mb(path: Path) -> float:
|
||||
return path.stat().st_size / MIB
|
||||
|
||||
|
||||
def parse_rate(value: str | None) -> float:
|
||||
if not value or value == "0/0":
|
||||
return 0
|
||||
try:
|
||||
return float(Fraction(value))
|
||||
except (ValueError, ZeroDivisionError):
|
||||
return 0
|
||||
|
||||
|
||||
def video_stream(probe: dict[str, Any]) -> dict[str, Any]:
|
||||
for stream in probe.get("streams", []):
|
||||
if stream.get("codec_type") == "video":
|
||||
return stream
|
||||
raise SystemExit("输入中没有视频流。")
|
||||
|
||||
|
||||
def audio_stream(probe: dict[str, Any]) -> dict[str, Any] | None:
|
||||
for stream in probe.get("streams", []):
|
||||
if stream.get("codec_type") == "audio":
|
||||
return stream
|
||||
return None
|
||||
|
||||
|
||||
def warning_counts(report: dict[str, Any]) -> dict[str, int]:
|
||||
return dict(Counter(item["type"] for item in report["warnings"]))
|
||||
|
||||
|
||||
def artifact_warning_count(report: dict[str, Any]) -> int:
|
||||
artifact_types = {
|
||||
"blank",
|
||||
"canvas-size",
|
||||
"brightness-jump",
|
||||
"scale-jump",
|
||||
"center-jump",
|
||||
}
|
||||
return sum(
|
||||
1 for item in report["warnings"] if item["type"] in artifact_types
|
||||
)
|
||||
|
||||
|
||||
def analysis_args() -> dict[str, float | int]:
|
||||
return {
|
||||
"alpha_threshold": 16,
|
||||
"duplicate_threshold": 0.004,
|
||||
"brightness_jump": 0.12,
|
||||
"scale_jump": 0.08,
|
||||
"center_jump": 0.08,
|
||||
}
|
||||
|
||||
|
||||
def make_extract_args(
|
||||
source: Path,
|
||||
output: Path,
|
||||
fps: float,
|
||||
args: argparse.Namespace,
|
||||
interpolate: bool,
|
||||
) -> argparse.Namespace:
|
||||
return argparse.Namespace(
|
||||
input=source,
|
||||
output=output,
|
||||
fps=fps,
|
||||
start=args.start,
|
||||
duration=args.duration,
|
||||
width=args.width,
|
||||
height=args.height,
|
||||
interpolate=interpolate,
|
||||
key=args.key,
|
||||
transparent_threshold=args.transparent_threshold,
|
||||
opaque_threshold=args.opaque_threshold,
|
||||
force=False,
|
||||
)
|
||||
|
||||
|
||||
def command_interpolate(args: argparse.Namespace) -> None:
|
||||
source = args.input.expanduser().resolve()
|
||||
output = args.output.expanduser().resolve()
|
||||
if not source.is_file():
|
||||
raise SystemExit(f"输入视频不存在:{source}")
|
||||
ensure_output(output, args.force, directory=True)
|
||||
|
||||
probe = motion_pipeline.probe_video(source)
|
||||
stream = video_stream(probe)
|
||||
source_fps = (
|
||||
parse_rate(stream.get("avg_frame_rate"))
|
||||
or parse_rate(stream.get("r_frame_rate"))
|
||||
or 24
|
||||
)
|
||||
if args.fps <= source_fps:
|
||||
raise SystemExit(
|
||||
f"目标帧率 {args.fps:g} 必须高于源帧率 {source_fps:g}。"
|
||||
)
|
||||
|
||||
frames = output / "frames"
|
||||
qa = output / "qa"
|
||||
qa.mkdir(parents=True, exist_ok=True)
|
||||
interpolated_extract = motion_pipeline.extract_frames(
|
||||
make_extract_args(source, frames, args.fps, args, True)
|
||||
)
|
||||
thresholds = analysis_args()
|
||||
interpolated_report = motion_pipeline.analyze_directory(
|
||||
frames, **thresholds
|
||||
)
|
||||
motion_pipeline.write_json(
|
||||
qa / "analysis-interpolated.json", interpolated_report
|
||||
)
|
||||
motion_pipeline.create_contact_sheet(
|
||||
frames,
|
||||
qa / "contact-sheet-interpolated.jpg",
|
||||
args.contact_columns,
|
||||
args.thumb_width,
|
||||
)
|
||||
|
||||
with tempfile.TemporaryDirectory(prefix="oil-motion-original-") as temp:
|
||||
original_frames = Path(temp) / "frames"
|
||||
original_extract = motion_pipeline.extract_frames(
|
||||
make_extract_args(
|
||||
source,
|
||||
original_frames,
|
||||
source_fps,
|
||||
args,
|
||||
False,
|
||||
)
|
||||
)
|
||||
original_report = motion_pipeline.analyze_directory(
|
||||
original_frames, **thresholds
|
||||
)
|
||||
motion_pipeline.create_contact_sheet(
|
||||
original_frames,
|
||||
qa / "contact-sheet-original.jpg",
|
||||
args.contact_columns,
|
||||
args.thumb_width,
|
||||
)
|
||||
|
||||
original_artifacts = artifact_warning_count(original_report)
|
||||
interpolated_artifacts = artifact_warning_count(interpolated_report)
|
||||
original_rate = original_artifacts / max(1, original_report["frameCount"])
|
||||
interpolated_rate = interpolated_artifacts / max(
|
||||
1, interpolated_report["frameCount"]
|
||||
)
|
||||
passed = interpolated_rate <= original_rate + args.warning_rate_tolerance
|
||||
|
||||
report = {
|
||||
"type": "motion-interpolation-report",
|
||||
"source": str(source),
|
||||
"sourceFps": source_fps,
|
||||
"targetFps": args.fps,
|
||||
"original": {
|
||||
"extraction": original_extract,
|
||||
"summary": original_report["summary"],
|
||||
"warningsByType": warning_counts(original_report),
|
||||
"artifactWarningRate": original_rate,
|
||||
},
|
||||
"interpolated": {
|
||||
"extraction": interpolated_extract,
|
||||
"summary": interpolated_report["summary"],
|
||||
"warningsByType": warning_counts(interpolated_report),
|
||||
"artifactWarningRate": interpolated_rate,
|
||||
},
|
||||
"verdict": {
|
||||
"passedAutomaticChecks": passed,
|
||||
"warningRateTolerance": args.warning_rate_tolerance,
|
||||
"manualReviewRequired": True,
|
||||
"note": (
|
||||
"自动检查只覆盖空帧、亮度、大小和中心跳变;"
|
||||
"仍需查看两张接触表确认重影、肢体扭曲和语义错误。"
|
||||
),
|
||||
},
|
||||
}
|
||||
write_json(output / "interpolation-report.json", report)
|
||||
print(json.dumps(report["verdict"], ensure_ascii=False, indent=2))
|
||||
print(f"补帧序列:{frames}")
|
||||
print(f"对比报告:{output / 'interpolation-report.json'}")
|
||||
|
||||
|
||||
def render_atlas_candidate(
|
||||
source: Path,
|
||||
directory: Path,
|
||||
columns: int | None,
|
||||
width: int,
|
||||
height: int,
|
||||
quality: int,
|
||||
max_texture: int,
|
||||
) -> tuple[Path, dict[str, Any]]:
|
||||
output = directory / f"atlas-{width}x{height}-q{quality}.webp"
|
||||
manifest_path = output.with_suffix(".json")
|
||||
manifest = motion_pipeline.create_atlas(
|
||||
source,
|
||||
output,
|
||||
manifest_path,
|
||||
columns,
|
||||
width,
|
||||
height,
|
||||
quality,
|
||||
False,
|
||||
max_texture,
|
||||
)
|
||||
return output, manifest
|
||||
|
||||
|
||||
def command_atlas(args: argparse.Namespace) -> None:
|
||||
source = args.input.expanduser().resolve()
|
||||
output = args.output.expanduser().resolve()
|
||||
if not source.is_dir() or not motion_pipeline.frame_files(source):
|
||||
raise SystemExit(f"找不到帧序列:{source}")
|
||||
if output.suffix.lower() != ".webp":
|
||||
raise SystemExit("图集输出必须使用 .webp。")
|
||||
ensure_output(output, args.force)
|
||||
report_path = (
|
||||
args.report.expanduser().resolve()
|
||||
if args.report
|
||||
else output.with_suffix(".optimize.json")
|
||||
)
|
||||
if report_path.exists() and not args.force:
|
||||
raise SystemExit(f"报告已存在:{report_path}。确认后使用 --force。")
|
||||
|
||||
display_width, display_height = args.display
|
||||
required_width = math.ceil(display_width * args.dpr)
|
||||
required_height = math.ceil(display_height * args.dpr)
|
||||
if (
|
||||
args.cell_width < required_width
|
||||
or args.cell_height < required_height
|
||||
):
|
||||
raise SystemExit(
|
||||
"清晰度阻断:初始单帧 "
|
||||
f"{args.cell_width}×{args.cell_height} px 低于展示所需 "
|
||||
f"{required_width}×{required_height} px。"
|
||||
)
|
||||
|
||||
frames = motion_pipeline.frame_files(source)
|
||||
with Image.open(frames[0]) as first_frame:
|
||||
source_width, source_height = first_frame.size
|
||||
if source_width < required_width or source_height < required_height:
|
||||
raise SystemExit(
|
||||
"清晰度阻断:源帧 "
|
||||
f"{source_width}×{source_height} px 低于展示所需 "
|
||||
f"{required_width}×{required_height} px;禁止放大后交付。"
|
||||
)
|
||||
|
||||
target_bytes = int(args.target_mb * MIB)
|
||||
trials: list[dict[str, Any]] = []
|
||||
chosen: dict[str, Any] | None = None
|
||||
best_fallback: dict[str, Any] | None = None
|
||||
frame_count = len(frames)
|
||||
clarity_scale = max(
|
||||
required_width / args.cell_width,
|
||||
required_height / args.cell_height,
|
||||
)
|
||||
minimum_scale = max(args.min_cell_scale, clarity_scale)
|
||||
|
||||
required_columns = args.columns
|
||||
if required_columns is None:
|
||||
required_columns = max(
|
||||
1,
|
||||
math.ceil(
|
||||
math.sqrt(
|
||||
frame_count
|
||||
* (required_height / max(1, required_width))
|
||||
)
|
||||
),
|
||||
)
|
||||
required_rows = math.ceil(frame_count / required_columns)
|
||||
if (
|
||||
required_columns * required_width > args.max_texture
|
||||
or required_rows * required_height > args.max_texture
|
||||
):
|
||||
raise SystemExit(
|
||||
"清晰度阻断:满足实际展示尺寸时,单张图集至少为 "
|
||||
f"{required_columns * required_width}×"
|
||||
f"{required_rows * required_height} px,超过纹理上限 "
|
||||
f"{args.max_texture}。请重新运行 motion_budget.py,并执行其自动选择结果。"
|
||||
)
|
||||
|
||||
with tempfile.TemporaryDirectory(prefix="oil-motion-atlas-") as temp:
|
||||
temp_path = Path(temp)
|
||||
scale = 1.0
|
||||
while scale + 1e-9 >= minimum_scale:
|
||||
width = max(required_width, round(args.cell_width * scale))
|
||||
height = max(required_height, round(args.cell_height * scale))
|
||||
columns = args.columns
|
||||
if columns is None:
|
||||
columns = max(
|
||||
1,
|
||||
math.ceil(
|
||||
math.sqrt(frame_count * (height / max(1, width)))
|
||||
),
|
||||
)
|
||||
rows = math.ceil(frame_count / columns)
|
||||
if (
|
||||
columns * width > args.max_texture
|
||||
or rows * height > args.max_texture
|
||||
):
|
||||
trials.append(
|
||||
{
|
||||
"cellWidth": width,
|
||||
"cellHeight": height,
|
||||
"scale": scale,
|
||||
"targetMet": False,
|
||||
"skipped": "texture-limit",
|
||||
"atlasWidth": columns * width,
|
||||
"atlasHeight": rows * height,
|
||||
}
|
||||
)
|
||||
scale = round(scale - args.scale_step, 6)
|
||||
continue
|
||||
low = args.min_quality
|
||||
high = args.max_quality
|
||||
scale_best: dict[str, Any] | None = None
|
||||
|
||||
while low <= high:
|
||||
quality = (low + high) // 2
|
||||
candidate, manifest = render_atlas_candidate(
|
||||
source,
|
||||
temp_path,
|
||||
columns,
|
||||
width,
|
||||
height,
|
||||
quality,
|
||||
args.max_texture,
|
||||
)
|
||||
candidate_bytes = candidate.stat().st_size
|
||||
trial = {
|
||||
"cellWidth": width,
|
||||
"cellHeight": height,
|
||||
"scale": scale,
|
||||
"quality": quality,
|
||||
"bytes": candidate_bytes,
|
||||
"sizeMB": candidate_bytes / MIB,
|
||||
"targetMet": candidate_bytes <= target_bytes,
|
||||
"path": str(candidate),
|
||||
"manifest": manifest,
|
||||
}
|
||||
trials.append(trial)
|
||||
if (
|
||||
best_fallback is None
|
||||
or trial["bytes"] < best_fallback["bytes"]
|
||||
):
|
||||
best_fallback = trial
|
||||
if candidate_bytes <= target_bytes:
|
||||
scale_best = trial
|
||||
low = quality + 1
|
||||
else:
|
||||
high = quality - 1
|
||||
|
||||
if scale_best is not None:
|
||||
chosen = scale_best
|
||||
break
|
||||
scale = round(scale - args.scale_step, 6)
|
||||
|
||||
if chosen is None:
|
||||
chosen = best_fallback
|
||||
if chosen is None:
|
||||
raise SystemExit("没有生成任何图集候选。")
|
||||
|
||||
shutil.copy2(chosen["path"], output)
|
||||
final_manifest = dict(chosen["manifest"])
|
||||
final_manifest["asset"] = output.name
|
||||
final_manifest["targetMB"] = args.target_mb
|
||||
final_manifest["targetMet"] = chosen["targetMet"]
|
||||
final_manifest["display"] = {
|
||||
"width": display_width,
|
||||
"height": display_height,
|
||||
"dpr": args.dpr,
|
||||
}
|
||||
final_manifest["requiredCell"] = {
|
||||
"width": required_width,
|
||||
"height": required_height,
|
||||
}
|
||||
final_manifest["clarityMet"] = (
|
||||
chosen["cellWidth"] >= required_width
|
||||
and chosen["cellHeight"] >= required_height
|
||||
)
|
||||
manifest_path = (
|
||||
args.manifest.expanduser().resolve()
|
||||
if args.manifest
|
||||
else output.with_suffix(".json")
|
||||
)
|
||||
write_json(manifest_path, final_manifest)
|
||||
|
||||
report = {
|
||||
"type": "atlas-optimization-report",
|
||||
"source": str(source),
|
||||
"output": str(output),
|
||||
"targetMB": args.target_mb,
|
||||
"resultMB": size_mb(output),
|
||||
"targetMet": chosen["targetMet"],
|
||||
"clarityMet": True,
|
||||
"display": {
|
||||
"width": display_width,
|
||||
"height": display_height,
|
||||
"dpr": args.dpr,
|
||||
},
|
||||
"requiredCell": {
|
||||
"width": required_width,
|
||||
"height": required_height,
|
||||
},
|
||||
"sourceFrame": {
|
||||
"width": source_width,
|
||||
"height": source_height,
|
||||
},
|
||||
"selected": {
|
||||
key: chosen[key]
|
||||
for key in (
|
||||
"cellWidth",
|
||||
"cellHeight",
|
||||
"scale",
|
||||
"quality",
|
||||
"bytes",
|
||||
)
|
||||
},
|
||||
"trials": [
|
||||
{key: value for key, value in trial.items() if key not in {"path", "manifest"}}
|
||||
for trial in trials
|
||||
],
|
||||
}
|
||||
write_json(report_path, report)
|
||||
print(json.dumps(report, ensure_ascii=False, indent=2))
|
||||
|
||||
|
||||
def video_filters(args: argparse.Namespace) -> str | None:
|
||||
filters: list[str] = []
|
||||
if args.max_width and args.max_height:
|
||||
filters.append(
|
||||
"scale="
|
||||
f"'min(iw,{args.max_width})':'min(ih,{args.max_height})':"
|
||||
"force_original_aspect_ratio=decrease:"
|
||||
"force_divisible_by=2"
|
||||
)
|
||||
elif args.max_width:
|
||||
filters.append(f"scale='min(iw,{args.max_width})':-2")
|
||||
elif args.max_height:
|
||||
filters.append(f"scale=-2:'min(ih,{args.max_height})'")
|
||||
if args.fps:
|
||||
filters.append(f"fps={args.fps}")
|
||||
return ",".join(filters) or None
|
||||
|
||||
|
||||
def encode_two_pass(
|
||||
source: Path,
|
||||
output: Path,
|
||||
bitrate_kbps: int,
|
||||
args: argparse.Namespace,
|
||||
passlog: Path,
|
||||
) -> None:
|
||||
ffmpeg = motion_pipeline.require_tool("ffmpeg")
|
||||
extension = output.suffix.lower()
|
||||
if extension == ".mp4":
|
||||
codec = "libx264"
|
||||
preset_args = ["-preset", args.preset, "-pix_fmt", "yuv420p"]
|
||||
mux = "mp4"
|
||||
audio_args = (
|
||||
["-c:a", "aac", "-b:a", f"{args.audio_kbps}k"]
|
||||
if args.keep_audio
|
||||
else ["-an"]
|
||||
)
|
||||
final_args = ["-movflags", "+faststart"]
|
||||
elif extension == ".webm":
|
||||
codec = "libvpx-vp9"
|
||||
preset_args = ["-deadline", "good", "-cpu-used", "2"]
|
||||
mux = "webm"
|
||||
audio_args = (
|
||||
["-c:a", "libopus", "-b:a", f"{args.audio_kbps}k"]
|
||||
if args.keep_audio
|
||||
else ["-an"]
|
||||
)
|
||||
final_args = []
|
||||
else:
|
||||
raise SystemExit("视频输出只支持 .mp4 或 .webm。")
|
||||
|
||||
common = [
|
||||
ffmpeg,
|
||||
"-hide_banner",
|
||||
"-loglevel",
|
||||
"error",
|
||||
"-y",
|
||||
"-i",
|
||||
str(source),
|
||||
]
|
||||
filters = video_filters(args)
|
||||
if filters:
|
||||
common.extend(["-vf", filters])
|
||||
video_args = [
|
||||
"-c:v",
|
||||
codec,
|
||||
"-b:v",
|
||||
f"{bitrate_kbps}k",
|
||||
*preset_args,
|
||||
"-passlogfile",
|
||||
str(passlog),
|
||||
]
|
||||
run(
|
||||
[
|
||||
*common,
|
||||
*video_args,
|
||||
"-pass",
|
||||
"1",
|
||||
"-an",
|
||||
"-f",
|
||||
mux,
|
||||
os.devnull,
|
||||
]
|
||||
)
|
||||
run(
|
||||
[
|
||||
*common,
|
||||
*video_args,
|
||||
"-pass",
|
||||
"2",
|
||||
*audio_args,
|
||||
*final_args,
|
||||
str(output),
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
def command_video(args: argparse.Namespace) -> None:
|
||||
source = args.input.expanduser().resolve()
|
||||
output = args.output.expanduser().resolve()
|
||||
if not source.is_file():
|
||||
raise SystemExit(f"输入视频不存在:{source}")
|
||||
ensure_output(output, args.force)
|
||||
report_path = (
|
||||
args.report.expanduser().resolve()
|
||||
if args.report
|
||||
else output.with_suffix(".optimize.json")
|
||||
)
|
||||
if report_path.exists() and not args.force:
|
||||
raise SystemExit(f"报告已存在:{report_path}。确认后使用 --force。")
|
||||
|
||||
probe = motion_pipeline.probe_video(source)
|
||||
stream = video_stream(probe)
|
||||
source_width = int(stream.get("width") or 0)
|
||||
source_height = int(stream.get("height") or 0)
|
||||
source_fps = (
|
||||
parse_rate(stream.get("avg_frame_rate"))
|
||||
or parse_rate(stream.get("r_frame_rate"))
|
||||
or 24
|
||||
)
|
||||
display_width, display_height = args.display
|
||||
required_width = math.ceil(display_width * args.dpr)
|
||||
required_height = math.ceil(display_height * args.dpr)
|
||||
if source_width < required_width or source_height < required_height:
|
||||
raise SystemExit(
|
||||
"清晰度阻断:源视频 "
|
||||
f"{source_width}×{source_height} px 低于展示所需 "
|
||||
f"{required_width}×{required_height} px;禁止放大后交付。"
|
||||
)
|
||||
args.max_width = args.max_width or required_width
|
||||
args.max_height = args.max_height or required_height
|
||||
if args.max_width < required_width or args.max_height < required_height:
|
||||
raise SystemExit(
|
||||
"清晰度阻断:视频输出上限不能低于展示所需 "
|
||||
f"{required_width}×{required_height} px。"
|
||||
)
|
||||
scale = min(
|
||||
1.0,
|
||||
args.max_width / source_width,
|
||||
args.max_height / source_height,
|
||||
)
|
||||
encoded_width = max(2, math.floor(source_width * scale / 2) * 2)
|
||||
encoded_height = max(2, math.floor(source_height * scale / 2) * 2)
|
||||
if encoded_width < required_width or encoded_height < required_height:
|
||||
raise SystemExit(
|
||||
"清晰度阻断:源视频比例放入当前输出框后只能得到 "
|
||||
f"{encoded_width}×{encoded_height} px,低于展示所需 "
|
||||
f"{required_width}×{required_height} px。请按实际内容比例填写 "
|
||||
"--display,或提供更高分辨率源视频。"
|
||||
)
|
||||
output_fps = args.fps or source_fps
|
||||
clarity_kbps = math.ceil(
|
||||
encoded_width
|
||||
* encoded_height
|
||||
* output_fps
|
||||
* args.min_bpp
|
||||
/ 1000
|
||||
)
|
||||
quality_floor_kbps = max(args.min_video_kbps, clarity_kbps)
|
||||
duration = float(probe.get("format", {}).get("duration") or 0)
|
||||
if duration <= 0:
|
||||
raise SystemExit("无法读取视频时长。")
|
||||
has_audio = audio_stream(probe) is not None
|
||||
audio_kbps = args.audio_kbps if args.keep_audio and has_audio else 0
|
||||
target_bits = args.target_mb * MIB * 8 * 0.97
|
||||
bitrate_kbps = max(
|
||||
quality_floor_kbps,
|
||||
round(target_bits / duration / 1000 - audio_kbps),
|
||||
)
|
||||
attempts: list[dict[str, Any]] = []
|
||||
|
||||
with tempfile.TemporaryDirectory(prefix="oil-motion-video-") as temp:
|
||||
passlog = Path(temp) / "pass"
|
||||
for attempt in range(1, 4):
|
||||
encode_two_pass(source, output, bitrate_kbps, args, passlog)
|
||||
actual_mb = size_mb(output)
|
||||
attempts.append(
|
||||
{
|
||||
"attempt": attempt,
|
||||
"videoKbps": bitrate_kbps,
|
||||
"sizeMB": actual_mb,
|
||||
}
|
||||
)
|
||||
if actual_mb <= args.target_mb * 1.01:
|
||||
break
|
||||
ratio = args.target_mb / actual_mb
|
||||
bitrate_kbps = max(
|
||||
quality_floor_kbps,
|
||||
math.floor(bitrate_kbps * ratio * 0.98),
|
||||
)
|
||||
|
||||
output_probe = motion_pipeline.probe_video(output)
|
||||
output_stream = video_stream(output_probe)
|
||||
output_width = int(output_stream.get("width") or 0)
|
||||
output_height = int(output_stream.get("height") or 0)
|
||||
result_mb = size_mb(output)
|
||||
clarity_met = (
|
||||
output_width >= required_width
|
||||
and output_height >= required_height
|
||||
)
|
||||
report = {
|
||||
"type": "video-optimization-report",
|
||||
"source": str(source),
|
||||
"output": str(output),
|
||||
"targetMB": args.target_mb,
|
||||
"resultMB": result_mb,
|
||||
"targetMet": result_mb <= args.target_mb * 1.01,
|
||||
"clarityMet": clarity_met,
|
||||
"display": {
|
||||
"width": display_width,
|
||||
"height": display_height,
|
||||
"dpr": args.dpr,
|
||||
},
|
||||
"requiredVideo": {
|
||||
"width": required_width,
|
||||
"height": required_height,
|
||||
},
|
||||
"encodedVideo": {
|
||||
"width": output_width,
|
||||
"height": output_height,
|
||||
"fps": output_fps,
|
||||
},
|
||||
"minBitsPerPixelPerFrame": args.min_bpp,
|
||||
"qualityFloorKbps": quality_floor_kbps,
|
||||
"sourceMB": size_mb(source),
|
||||
"compressionRatio": result_mb / max(size_mb(source), 1e-9),
|
||||
"audioKept": bool(args.keep_audio and has_audio),
|
||||
"attempts": attempts,
|
||||
"sourceProbe": probe,
|
||||
"outputProbe": output_probe,
|
||||
}
|
||||
write_json(report_path, report)
|
||||
print(
|
||||
json.dumps(
|
||||
{
|
||||
key: report[key]
|
||||
for key in (
|
||||
"targetMB",
|
||||
"resultMB",
|
||||
"targetMet",
|
||||
"clarityMet",
|
||||
"sourceMB",
|
||||
"compressionRatio",
|
||||
)
|
||||
},
|
||||
ensure_ascii=False,
|
||||
indent=2,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def add_extract_options(parser: argparse.ArgumentParser) -> None:
|
||||
parser.add_argument("--start", type=float)
|
||||
parser.add_argument("--duration", type=positive_float)
|
||||
parser.add_argument("--width", type=int)
|
||||
parser.add_argument("--height", type=int)
|
||||
parser.add_argument(
|
||||
"--key",
|
||||
default="none",
|
||||
help="'auto'、'none' 或十六进制色键。",
|
||||
)
|
||||
parser.add_argument("--transparent-threshold", type=float, default=12)
|
||||
parser.add_argument("--opaque-threshold", type=float, default=220)
|
||||
|
||||
|
||||
def parse_args() -> argparse.Namespace:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
subparsers = parser.add_subparsers(dest="command", required=True)
|
||||
|
||||
interpolate = subparsers.add_parser(
|
||||
"interpolate",
|
||||
help="光流补帧,并生成原始/补帧异常对比。",
|
||||
)
|
||||
interpolate.add_argument("input", type=Path)
|
||||
interpolate.add_argument("output", type=Path)
|
||||
interpolate.add_argument("--fps", type=positive_float, required=True)
|
||||
add_extract_options(interpolate)
|
||||
interpolate.add_argument("--warning-rate-tolerance", type=float, default=0.02)
|
||||
interpolate.add_argument("--contact-columns", type=int, default=8)
|
||||
interpolate.add_argument("--thumb-width", type=int, default=160)
|
||||
interpolate.add_argument("--force", action="store_true")
|
||||
interpolate.set_defaults(function=command_interpolate)
|
||||
|
||||
atlas = subparsers.add_parser(
|
||||
"atlas",
|
||||
help="按目标体积自动选择图集尺寸和 WebP 质量。",
|
||||
)
|
||||
atlas.add_argument("input", type=Path)
|
||||
atlas.add_argument("--output", type=Path, required=True)
|
||||
atlas.add_argument("--target-mb", type=positive_float, required=True)
|
||||
atlas.add_argument(
|
||||
"--display",
|
||||
type=motion_budget.parse_size,
|
||||
required=True,
|
||||
help="最大实际 CSS 展示尺寸,如 360x360。",
|
||||
)
|
||||
atlas.add_argument("--dpr", type=positive_float, default=2.0)
|
||||
atlas.add_argument("--cell-width", type=int, required=True)
|
||||
atlas.add_argument("--cell-height", type=int, required=True)
|
||||
atlas.add_argument("--columns", type=int)
|
||||
atlas.add_argument("--min-quality", type=int, default=45)
|
||||
atlas.add_argument("--max-quality", type=int, default=92)
|
||||
atlas.add_argument("--min-cell-scale", type=unit_float, default=0.6)
|
||||
atlas.add_argument("--scale-step", type=positive_float, default=0.1)
|
||||
atlas.add_argument("--max-texture", type=int, default=4096)
|
||||
atlas.add_argument("--manifest", type=Path)
|
||||
atlas.add_argument("--report", type=Path)
|
||||
atlas.add_argument("--force", action="store_true")
|
||||
atlas.set_defaults(function=command_atlas)
|
||||
|
||||
video = subparsers.add_parser(
|
||||
"video",
|
||||
help="按目标体积两遍编码 MP4/WebM。",
|
||||
)
|
||||
video.add_argument("input", type=Path)
|
||||
video.add_argument("--output", type=Path, required=True)
|
||||
video.add_argument("--target-mb", type=positive_float, required=True)
|
||||
video.add_argument(
|
||||
"--display",
|
||||
type=motion_budget.parse_size,
|
||||
required=True,
|
||||
help="最大实际 CSS 展示尺寸,如 360x360。",
|
||||
)
|
||||
video.add_argument("--dpr", type=positive_float, default=2.0)
|
||||
video.add_argument("--max-width", type=int)
|
||||
video.add_argument("--max-height", type=int)
|
||||
video.add_argument("--fps", type=positive_float)
|
||||
video.add_argument("--keep-audio", action="store_true")
|
||||
video.add_argument("--audio-kbps", type=int, default=96)
|
||||
video.add_argument("--min-video-kbps", type=int, default=80)
|
||||
video.add_argument(
|
||||
"--min-bpp",
|
||||
type=positive_float,
|
||||
default=0.08,
|
||||
help="每像素每帧最低码率;目标体积不能突破此清晰度底线。",
|
||||
)
|
||||
video.add_argument(
|
||||
"--preset",
|
||||
choices=("medium", "slow", "slower"),
|
||||
default="slow",
|
||||
)
|
||||
video.add_argument("--report", type=Path)
|
||||
video.add_argument("--force", action="store_true")
|
||||
video.set_defaults(function=command_video)
|
||||
return parser.parse_args()
|
||||
|
||||
|
||||
def main() -> None:
|
||||
args = parse_args()
|
||||
if hasattr(args, "width") and args.width is not None and args.width <= 0:
|
||||
raise SystemExit("--width 必须大于 0。")
|
||||
if hasattr(args, "height") and args.height is not None and args.height <= 0:
|
||||
raise SystemExit("--height 必须大于 0。")
|
||||
if hasattr(args, "min_quality") and not (
|
||||
0 <= args.min_quality <= args.max_quality <= 100
|
||||
):
|
||||
raise SystemExit("质量范围必须满足 0 <= min <= max <= 100。")
|
||||
if hasattr(args, "scale_step") and args.scale_step > 1:
|
||||
raise SystemExit("--scale-step 不能大于 1。")
|
||||
args.function(args)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,422 @@
|
||||
#!/usr/bin/env python3
|
||||
"""为 Pilot 批准与多段视频首尾帧链提供可审计的硬门。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import hashlib
|
||||
import json
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import numpy as np
|
||||
from PIL import Image
|
||||
import yaml
|
||||
|
||||
from media_edges import extract_first_frame, extract_last_frame
|
||||
|
||||
PILOT_ARTIFACTS = (
|
||||
"conceptContract",
|
||||
"firstFrame",
|
||||
"lastFrame",
|
||||
"pilotVideo",
|
||||
"pageEvidence",
|
||||
)
|
||||
|
||||
class UniqueKeyLoader(yaml.SafeLoader):
|
||||
"""拒绝 YAML 重复键,避免后值静默覆盖已经批准的合同语义。"""
|
||||
|
||||
|
||||
def construct_unique_mapping(
|
||||
loader: UniqueKeyLoader,
|
||||
node: yaml.nodes.MappingNode,
|
||||
deep: bool = False,
|
||||
) -> dict[Any, Any]:
|
||||
loader.flatten_mapping(node)
|
||||
mapping: dict[Any, Any] = {}
|
||||
for key_node, value_node in node.value:
|
||||
key = loader.construct_object(key_node, deep=deep)
|
||||
if key in mapping:
|
||||
raise ValueError(f"Concept Contract 包含重复字段:{key}")
|
||||
mapping[key] = loader.construct_object(value_node, deep=deep)
|
||||
return mapping
|
||||
|
||||
|
||||
UniqueKeyLoader.add_constructor(
|
||||
yaml.resolver.BaseResolver.DEFAULT_MAPPING_TAG,
|
||||
construct_unique_mapping,
|
||||
)
|
||||
|
||||
|
||||
def sha256_file(path: Path) -> str:
|
||||
digest = hashlib.sha256()
|
||||
with path.open("rb") as source:
|
||||
for chunk in iter(lambda: source.read(1024 * 1024), b""):
|
||||
digest.update(chunk)
|
||||
return digest.hexdigest()
|
||||
|
||||
|
||||
def artifact_record(raw_path: str | Path) -> dict[str, Any]:
|
||||
path = Path(raw_path).expanduser().resolve()
|
||||
if not path.is_file():
|
||||
raise FileNotFoundError(f"验收工件不存在:{path}")
|
||||
return {
|
||||
"path": str(path),
|
||||
"bytes": path.stat().st_size,
|
||||
"sha256": sha256_file(path),
|
||||
}
|
||||
|
||||
|
||||
def validate_artifact(
|
||||
record: dict[str, Any],
|
||||
label: str,
|
||||
require_exact_hash: bool = True,
|
||||
force: bool = False,
|
||||
) -> None:
|
||||
path = Path(str(record.get("path", ""))).expanduser().resolve()
|
||||
if not path.is_file():
|
||||
raise FileNotFoundError(f"{label} 工件不存在:{path}")
|
||||
if path.stat().st_size == 0:
|
||||
raise ValueError(f"{label} 工件为空文件:{path}")
|
||||
if require_exact_hash:
|
||||
actual_size = path.stat().st_size
|
||||
actual_hash = sha256_file(path)
|
||||
if actual_size != record.get("bytes") or actual_hash != record.get("sha256"):
|
||||
if force:
|
||||
print(
|
||||
f"警告:{label} 工件哈希已变化,已通过 --force 忽略差异:{path}",
|
||||
file=sys.stderr,
|
||||
)
|
||||
else:
|
||||
raise ValueError(f"{label} 工件已变化,Pilot 批准失效:{path}")
|
||||
|
||||
|
||||
def contract_continuity_mode(raw_path: str | Path) -> str:
|
||||
path = Path(raw_path).expanduser().resolve()
|
||||
if not path.is_file():
|
||||
raise FileNotFoundError(f"Concept Contract 不存在:{path}")
|
||||
try:
|
||||
contract = yaml.load(
|
||||
path.read_text(encoding="utf-8"),
|
||||
Loader=UniqueKeyLoader,
|
||||
)
|
||||
except yaml.YAMLError as error:
|
||||
raise ValueError(f"Concept Contract YAML 无效:{error}") from error
|
||||
if not isinstance(contract, dict):
|
||||
raise ValueError("Concept Contract 顶层必须是 YAML 对象")
|
||||
mode = contract.get("clip_continuity")
|
||||
if mode not in {"chain", "independent"}:
|
||||
raise ValueError(
|
||||
"Concept Contract 顶层必须显式包含 "
|
||||
"clip_continuity: chain | independent"
|
||||
)
|
||||
return mode
|
||||
|
||||
|
||||
def create_pilot_approval(args: argparse.Namespace) -> dict[str, Any]:
|
||||
if args.decision != "pass":
|
||||
raise ValueError("只有明确传入 --decision pass 才能生成 Pilot 批准文件")
|
||||
continuity_mode = contract_continuity_mode(args.contract)
|
||||
artifacts = {
|
||||
"conceptContract": artifact_record(args.contract),
|
||||
"firstFrame": artifact_record(args.first_frame),
|
||||
"lastFrame": artifact_record(args.last_frame),
|
||||
"pilotVideo": artifact_record(args.video),
|
||||
"pageEvidence": artifact_record(args.page_evidence),
|
||||
}
|
||||
if args.identity_bible:
|
||||
artifacts["identityBible"] = artifact_record(args.identity_bible)
|
||||
report = {
|
||||
"schemaVersion": 1,
|
||||
"passed": True,
|
||||
"approvedAt": datetime.now(timezone.utc).isoformat(),
|
||||
"reviewer": args.reviewer,
|
||||
"continuityMode": continuity_mode,
|
||||
"notes": args.notes or "",
|
||||
"artifacts": artifacts,
|
||||
}
|
||||
output = Path(args.output).expanduser().resolve()
|
||||
if output.exists() and not args.force:
|
||||
raise FileExistsError(f"Pilot 批准文件已存在:{output};确认后使用 --force")
|
||||
output.parent.mkdir(parents=True, exist_ok=True)
|
||||
output.write_text(
|
||||
json.dumps(report, ensure_ascii=False, indent=2),
|
||||
encoding="utf-8",
|
||||
)
|
||||
return report
|
||||
|
||||
|
||||
def validate_pilot_approval(
|
||||
raw_path: str | Path,
|
||||
force: bool = False,
|
||||
) -> dict[str, Any]:
|
||||
path = Path(raw_path).expanduser().resolve()
|
||||
if not path.is_file():
|
||||
raise FileNotFoundError(f"缺少 Pilot 批准文件:{path}")
|
||||
report = json.loads(path.read_text(encoding="utf-8"))
|
||||
if report.get("schemaVersion") != 1 or report.get("passed") is not True:
|
||||
raise ValueError(f"Pilot 尚未通过:{path}")
|
||||
if not str(report.get("reviewer", "")).strip():
|
||||
raise ValueError(f"Pilot 批准缺少 reviewer:{path}")
|
||||
artifacts = report.get("artifacts")
|
||||
if not isinstance(artifacts, dict):
|
||||
raise ValueError(f"Pilot 批准缺少 artifacts:{path}")
|
||||
for label in PILOT_ARTIFACTS:
|
||||
record = artifacts.get(label)
|
||||
if not isinstance(record, dict):
|
||||
raise ValueError(f"Pilot 批准缺少 {label}:{path}")
|
||||
is_strict = label != "pageEvidence"
|
||||
validate_artifact(record, label, require_exact_hash=is_strict, force=force)
|
||||
approved_mode = report.get("continuityMode")
|
||||
if approved_mode not in {"chain", "independent"}:
|
||||
raise ValueError(f"Pilot 批准缺少有效 continuityMode:{path}")
|
||||
contract_path = artifacts["conceptContract"]["path"]
|
||||
actual_mode = contract_continuity_mode(contract_path)
|
||||
if actual_mode != approved_mode:
|
||||
if force:
|
||||
print(
|
||||
f"警告:Concept Contract 连续模式已由 {approved_mode} 变为 {actual_mode},已通过 --force 允许执行",
|
||||
file=sys.stderr,
|
||||
)
|
||||
else:
|
||||
raise ValueError(f"Concept Contract 连续模式已变化,Pilot 批准失效:{path}")
|
||||
identity = artifacts.get("identityBible")
|
||||
if identity is not None:
|
||||
if not isinstance(identity, dict):
|
||||
raise ValueError(f"Pilot identityBible 记录无效:{path}")
|
||||
validate_artifact(identity, "identityBible", force=force)
|
||||
return report
|
||||
|
||||
|
||||
def verify_frame_chain(
|
||||
previous_tail: str | Path,
|
||||
next_first: str | Path,
|
||||
segment_index: int,
|
||||
manifest_path: str | Path | None = None,
|
||||
force: bool = False,
|
||||
) -> dict[str, Any]:
|
||||
if segment_index < 2:
|
||||
raise ValueError("帧链只用于第 2 段及之后的片段")
|
||||
previous = artifact_record(previous_tail)
|
||||
current = artifact_record(next_first)
|
||||
exact_match = (
|
||||
previous["bytes"] == current["bytes"]
|
||||
and previous["sha256"] == current["sha256"]
|
||||
)
|
||||
if not exact_match:
|
||||
if force:
|
||||
print(
|
||||
f"警告:第 {segment_index} 段首帧与上一段尾帧哈希不一致,已通过 --force 忽略差异继续执行",
|
||||
file=sys.stderr,
|
||||
)
|
||||
else:
|
||||
raise ValueError(
|
||||
"连续帧链断裂:下一段 --first-frame 不是上一段验收尾帧的原文件"
|
||||
)
|
||||
link = {
|
||||
"segmentIndex": segment_index,
|
||||
"verifiedAt": datetime.now(timezone.utc).isoformat(),
|
||||
"exactSha256Match": exact_match,
|
||||
"previousTail": previous,
|
||||
"nextFirst": current,
|
||||
}
|
||||
if manifest_path is not None:
|
||||
manifest = Path(manifest_path).expanduser().resolve()
|
||||
if manifest.is_file():
|
||||
payload = json.loads(manifest.read_text(encoding="utf-8"))
|
||||
else:
|
||||
payload = {"schemaVersion": 1, "links": []}
|
||||
links = payload.setdefault("links", [])
|
||||
if not isinstance(links, list):
|
||||
raise ValueError(f"帧链清单格式错误:{manifest}")
|
||||
links = [
|
||||
item
|
||||
for item in links
|
||||
if item.get("segmentIndex") != segment_index
|
||||
]
|
||||
links.append(link)
|
||||
payload["links"] = sorted(links, key=lambda item: item["segmentIndex"])
|
||||
manifest.parent.mkdir(parents=True, exist_ok=True)
|
||||
manifest.write_text(
|
||||
json.dumps(payload, ensure_ascii=False, indent=2),
|
||||
encoding="utf-8",
|
||||
)
|
||||
return link
|
||||
|
||||
|
||||
def frame_similarity(previous: Path, current: Path) -> dict[str, float]:
|
||||
with Image.open(previous) as opened:
|
||||
first = np.asarray(opened.convert("RGB"), dtype=np.float64) / 255.0
|
||||
with Image.open(current) as opened:
|
||||
second = np.asarray(opened.convert("RGB"), dtype=np.float64) / 255.0
|
||||
if first.shape != second.shape:
|
||||
raise ValueError(
|
||||
f"相邻成片尺寸不同:{first.shape[1]}x{first.shape[0]} 与 "
|
||||
f"{second.shape[1]}x{second.shape[0]}"
|
||||
)
|
||||
mae = float(np.mean(np.abs(first - second)))
|
||||
first_mean = np.mean(first, axis=(0, 1))
|
||||
second_mean = np.mean(second, axis=(0, 1))
|
||||
first_variance = np.var(first, axis=(0, 1))
|
||||
second_variance = np.var(second, axis=(0, 1))
|
||||
covariance = np.mean(
|
||||
(first - first_mean) * (second - second_mean), axis=(0, 1)
|
||||
)
|
||||
c1 = 0.01**2
|
||||
c2 = 0.03**2
|
||||
ssim = np.mean(
|
||||
((2 * first_mean * second_mean + c1) * (2 * covariance + c2))
|
||||
/ (
|
||||
(first_mean**2 + second_mean**2 + c1)
|
||||
* (first_variance + second_variance + c2)
|
||||
)
|
||||
)
|
||||
return {"ssim": round(float(ssim), 6), "normalizedMae": round(mae, 6)}
|
||||
|
||||
|
||||
def verify_output_chain(
|
||||
previous_video: str | Path,
|
||||
next_video: str | Path,
|
||||
segment_index: int,
|
||||
manifest_path: str | Path,
|
||||
evidence_directory: str | Path,
|
||||
minimum_ssim: float = 0.97,
|
||||
maximum_mae: float = 0.04,
|
||||
) -> dict[str, Any]:
|
||||
if segment_index < 2:
|
||||
raise ValueError("成片接缝只用于第 2 段及之后的片段")
|
||||
if not 0 <= minimum_ssim <= 1 or not 0 <= maximum_mae <= 1:
|
||||
raise ValueError("SSIM 与 MAE 阈值必须在 0..1")
|
||||
evidence = Path(evidence_directory).expanduser().resolve()
|
||||
previous_tail = evidence / f"segment-{segment_index - 1:02d}-tail.png"
|
||||
next_head = evidence / f"segment-{segment_index:02d}-head.png"
|
||||
extract_last_frame(previous_video, previous_tail)
|
||||
extract_first_frame(next_video, next_head)
|
||||
metrics = frame_similarity(previous_tail, next_head)
|
||||
passed = (
|
||||
metrics["ssim"] >= minimum_ssim
|
||||
and metrics["normalizedMae"] <= maximum_mae
|
||||
)
|
||||
link = {
|
||||
"segmentIndex": segment_index,
|
||||
"verifiedAt": datetime.now(timezone.utc).isoformat(),
|
||||
"passed": passed,
|
||||
"thresholds": {
|
||||
"minimumSsim": minimum_ssim,
|
||||
"maximumNormalizedMae": maximum_mae,
|
||||
},
|
||||
"metrics": metrics,
|
||||
"previousTail": artifact_record(previous_tail),
|
||||
"nextHead": artifact_record(next_head),
|
||||
"previousVideo": artifact_record(previous_video),
|
||||
"nextVideo": artifact_record(next_video),
|
||||
}
|
||||
manifest = Path(manifest_path).expanduser().resolve()
|
||||
if manifest.is_file():
|
||||
payload = json.loads(manifest.read_text(encoding="utf-8"))
|
||||
else:
|
||||
payload = {"schemaVersion": 1, "links": [], "outputLinks": []}
|
||||
output_links = payload.setdefault("outputLinks", [])
|
||||
if not isinstance(output_links, list):
|
||||
raise ValueError(f"帧链清单 outputLinks 格式错误:{manifest}")
|
||||
output_links = [
|
||||
item for item in output_links if item.get("segmentIndex") != segment_index
|
||||
]
|
||||
output_links.append(link)
|
||||
payload["outputLinks"] = sorted(
|
||||
output_links, key=lambda item: item["segmentIndex"]
|
||||
)
|
||||
manifest.parent.mkdir(parents=True, exist_ok=True)
|
||||
manifest.write_text(
|
||||
json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8"
|
||||
)
|
||||
if not passed:
|
||||
raise ValueError(
|
||||
"相邻成片接缝不连续:"
|
||||
f"SSIM {metrics['ssim']:.4f},MAE {metrics['normalizedMae']:.4f}"
|
||||
)
|
||||
return link
|
||||
|
||||
|
||||
def parser() -> argparse.ArgumentParser:
|
||||
result = argparse.ArgumentParser(description="校验 oil-motion 生产硬门")
|
||||
commands = result.add_subparsers(dest="command", required=True)
|
||||
|
||||
approve = commands.add_parser("approve-pilot", help="生成可校验的 Pilot 批准文件")
|
||||
approve.add_argument("--contract", required=True)
|
||||
approve.add_argument("--identity-bible")
|
||||
approve.add_argument("--first-frame", required=True)
|
||||
approve.add_argument("--last-frame", required=True)
|
||||
approve.add_argument("--video", required=True)
|
||||
approve.add_argument("--page-evidence", required=True)
|
||||
approve.add_argument("--reviewer", required=True)
|
||||
approve.add_argument("--notes")
|
||||
approve.add_argument("--decision", choices=("pass", "reject"), required=True)
|
||||
approve.add_argument("--output", required=True)
|
||||
approve.add_argument("--force", action="store_true")
|
||||
|
||||
check = commands.add_parser("check-pilot", help="复核 Pilot 工件未变化")
|
||||
check.add_argument("approval")
|
||||
|
||||
chain = commands.add_parser("verify-chain", help="验证并记录相邻片段帧链")
|
||||
chain.add_argument("--previous-tail", required=True)
|
||||
chain.add_argument("--next-first", required=True)
|
||||
chain.add_argument("--segment-index", type=int, required=True)
|
||||
chain.add_argument("--manifest", required=True)
|
||||
|
||||
output_chain = commands.add_parser(
|
||||
"verify-output-chain", help="验证相邻成片解码后的实际接缝"
|
||||
)
|
||||
output_chain.add_argument("--previous-video", required=True)
|
||||
output_chain.add_argument("--next-video", required=True)
|
||||
output_chain.add_argument("--segment-index", type=int, required=True)
|
||||
output_chain.add_argument("--manifest", required=True)
|
||||
output_chain.add_argument("--evidence-dir", required=True)
|
||||
output_chain.add_argument("--min-ssim", type=float, default=0.97)
|
||||
output_chain.add_argument("--max-mae", type=float, default=0.04)
|
||||
return result
|
||||
|
||||
|
||||
def main() -> int:
|
||||
args = parser().parse_args()
|
||||
if args.command == "approve-pilot":
|
||||
create_pilot_approval(args)
|
||||
print(f"Pilot 已批准:{Path(args.output).expanduser().resolve()}")
|
||||
elif args.command == "check-pilot":
|
||||
validate_pilot_approval(args.approval)
|
||||
print(f"Pilot 工件有效:{Path(args.approval).expanduser().resolve()}")
|
||||
elif args.command == "verify-chain":
|
||||
verify_frame_chain(
|
||||
args.previous_tail,
|
||||
args.next_first,
|
||||
args.segment_index,
|
||||
args.manifest,
|
||||
)
|
||||
print(f"帧链已验证:segment {args.segment_index}")
|
||||
else:
|
||||
verify_output_chain(
|
||||
args.previous_video,
|
||||
args.next_video,
|
||||
args.segment_index,
|
||||
args.manifest,
|
||||
args.evidence_dir,
|
||||
args.min_ssim,
|
||||
args.max_mae,
|
||||
)
|
||||
print(f"成片接缝已验证:segment {args.segment_index}")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
try:
|
||||
raise SystemExit(main())
|
||||
except (
|
||||
FileNotFoundError,
|
||||
FileExistsError,
|
||||
RuntimeError,
|
||||
ValueError,
|
||||
json.JSONDecodeError,
|
||||
) as error:
|
||||
print(f"错误:{error}")
|
||||
raise SystemExit(1) from error
|
||||
@@ -0,0 +1,2 @@
|
||||
# 系统凭据存储;不启用第三方明文后端。
|
||||
keyring>=25,<26
|
||||
@@ -0,0 +1,5 @@
|
||||
Pillow>=10.0.0
|
||||
numpy>=1.24.0
|
||||
PyYAML>=6.0.0
|
||||
|
||||
-r requirements-credentials.txt
|
||||
@@ -0,0 +1,45 @@
|
||||
"""可信程序内部使用的系统凭据适配器,不提供明文输出 CLI。"""
|
||||
from __future__ import annotations
|
||||
import importlib
|
||||
import sys
|
||||
|
||||
SERVICE = 'org.oiloil.skill-credentials'
|
||||
|
||||
def backend():
|
||||
"""显式选择系统后端,拒绝自动退回明文或临时存储。"""
|
||||
target = {
|
||||
'darwin': ('keyring.backends.macOS', 'Keyring'),
|
||||
'win32': ('keyring.backends.Windows', 'WinVaultKeyring'),
|
||||
'linux': ('keyring.backends.SecretService', 'Keyring'),
|
||||
}.get(sys.platform)
|
||||
if target is None:
|
||||
raise RuntimeError('当前系统未实现安全凭据后端;请使用运行时环境变量')
|
||||
try:
|
||||
result = getattr(importlib.import_module(target[0]), target[1])()
|
||||
if result.priority <= 0:
|
||||
raise RuntimeError('后端不可用')
|
||||
return result
|
||||
except Exception:
|
||||
raise RuntimeError('系统凭据库不可用;请检查 keyring 依赖与系统解锁状态,或使用运行时环境变量') from None
|
||||
|
||||
def read(reference: str) -> str:
|
||||
try:
|
||||
return backend().get_password(SERVICE, reference) or ''
|
||||
except Exception:
|
||||
raise RuntimeError('无法读取系统凭据;不会降级保存明文') from None
|
||||
|
||||
def save(reference: str, secret: str) -> None:
|
||||
if not secret or '\n' in secret or '\r' in secret:
|
||||
raise ValueError('凭据必须是非空单行文本')
|
||||
try:
|
||||
backend().set_password(SERVICE, reference, secret)
|
||||
except Exception:
|
||||
raise RuntimeError('系统凭据保存失败;原配置未被改为明文') from None
|
||||
|
||||
def delete(reference: str) -> None:
|
||||
try:
|
||||
store = backend()
|
||||
if store.get_password(SERVICE, reference) is not None:
|
||||
store.delete_password(SERVICE, reference)
|
||||
except Exception:
|
||||
raise RuntimeError('系统凭据删除失败;请检查后端状态') from None
|
||||
@@ -0,0 +1,478 @@
|
||||
#!/usr/bin/env python3
|
||||
"""提交、轮询并下载 MiniMax H3 视频任务。
|
||||
|
||||
密钥优先从 ZENMUX_API_KEY 读取,否则读取 Oil Motion 的本地配置。
|
||||
MiniMax H3 有两种互斥的图片约束模式:
|
||||
|
||||
- 闭环:同一张图同时作为 first_frame 与 last_frame。
|
||||
- 转场:不同图片分别作为 first_frame 与 last_frame。
|
||||
- 参考模式:只传 reference_image,不得与首尾帧混用。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import base64
|
||||
import json
|
||||
import mimetypes
|
||||
import sys
|
||||
import time
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from media_edges import extract_last_frame
|
||||
from production_gate import validate_pilot_approval, verify_frame_chain
|
||||
|
||||
from PIL import Image
|
||||
|
||||
from oil_motion_config import require_api_key
|
||||
|
||||
|
||||
API_ROOT = "https://zenmux.ai/api/v1"
|
||||
DEFAULT_MODEL = "minimax/minimax-h3-max"
|
||||
H3_MAX_RESOLUTIONS = ("480P", "768P")
|
||||
TERMINAL_STATES = {"succeeded", "failed", "cancelled", "canceled"}
|
||||
COMMON_RATIOS = {
|
||||
"21:9": 21 / 9,
|
||||
"16:9": 16 / 9,
|
||||
"4:3": 4 / 3,
|
||||
"1:1": 1.0,
|
||||
"3:4": 3 / 4,
|
||||
"9:16": 9 / 16,
|
||||
}
|
||||
|
||||
|
||||
def local_image_data_uri(path: Path) -> str:
|
||||
if not path.is_file():
|
||||
raise FileNotFoundError(f"找不到图片:{path}")
|
||||
mime = mimetypes.guess_type(path.name)[0] or "image/png"
|
||||
encoded = base64.b64encode(path.read_bytes()).decode("ascii")
|
||||
return f"data:{mime};base64,{encoded}"
|
||||
|
||||
|
||||
def image_content(path: Path, role: str) -> dict[str, Any]:
|
||||
return {
|
||||
"type": "image_url",
|
||||
"role": role,
|
||||
"image_url": {
|
||||
"url": local_image_data_uri(path),
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def infer_ratio(path: Path | None) -> str:
|
||||
if path is None:
|
||||
return "1:1"
|
||||
with Image.open(path) as image:
|
||||
actual = image.width / image.height
|
||||
return min(COMMON_RATIOS, key=lambda name: abs(COMMON_RATIOS[name] - actual))
|
||||
|
||||
|
||||
def request_json(
|
||||
method: str,
|
||||
url: str,
|
||||
api_key: str,
|
||||
payload: dict[str, Any] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
body = None if payload is None else json.dumps(payload).encode("utf-8")
|
||||
request = urllib.request.Request(
|
||||
url,
|
||||
data=body,
|
||||
method=method,
|
||||
headers={
|
||||
"Authorization": f"Bearer {api_key}",
|
||||
"Content-Type": "application/json",
|
||||
"Accept": "application/json",
|
||||
"User-Agent": "oil-motion/1.0",
|
||||
},
|
||||
)
|
||||
try:
|
||||
with urllib.request.urlopen(request, timeout=120) as response:
|
||||
return json.loads(response.read().decode("utf-8"))
|
||||
except urllib.error.HTTPError as exc:
|
||||
details = exc.read().decode("utf-8", errors="replace").replace(api_key, "[redacted]")
|
||||
raise RuntimeError(f"ZenMux API {exc.code}: {details}") from exc
|
||||
|
||||
|
||||
def download(url: str, output: Path) -> None:
|
||||
output.parent.mkdir(parents=True, exist_ok=True)
|
||||
request = urllib.request.Request(url, headers={"User-Agent": "oil-motion/1.0"})
|
||||
with urllib.request.urlopen(request, timeout=300) as response:
|
||||
output.write_bytes(response.read())
|
||||
|
||||
|
||||
def find_job_id(response: dict[str, Any]) -> str:
|
||||
for key in ("id", "job_id", "jobId", "task_id", "taskId"):
|
||||
if response.get(key):
|
||||
return str(response[key])
|
||||
data = response.get("data")
|
||||
if isinstance(data, dict):
|
||||
return find_job_id(data)
|
||||
raise RuntimeError(f"提交成功但没有找到任务 ID:{response}")
|
||||
|
||||
|
||||
def find_status(response: dict[str, Any]) -> str:
|
||||
for source in (response, response.get("data"), response.get("result")):
|
||||
if isinstance(source, dict):
|
||||
for key in ("status", "state"):
|
||||
if source.get(key):
|
||||
return str(source[key]).lower()
|
||||
return "unknown"
|
||||
|
||||
|
||||
def walk_for_url(value: Any, preferred_keys: tuple[str, ...]) -> str | None:
|
||||
if isinstance(value, dict):
|
||||
for key in preferred_keys:
|
||||
candidate = value.get(key)
|
||||
if isinstance(candidate, str) and candidate.startswith(("http://", "https://")):
|
||||
return candidate
|
||||
for child in value.values():
|
||||
found = walk_for_url(child, preferred_keys)
|
||||
if found:
|
||||
return found
|
||||
elif isinstance(value, list):
|
||||
for child in value:
|
||||
found = walk_for_url(child, preferred_keys)
|
||||
if found:
|
||||
return found
|
||||
return None
|
||||
|
||||
|
||||
def redacted_metadata(
|
||||
payload: dict[str, Any],
|
||||
submit_response: dict[str, Any],
|
||||
final_response: dict[str, Any],
|
||||
production_gate: dict[str, Any] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
def redact_remote_urls(value: Any) -> Any:
|
||||
if isinstance(value, dict):
|
||||
return {
|
||||
key: (
|
||||
"<remote-url-redacted>"
|
||||
if isinstance(child, str)
|
||||
and child.startswith(("http://", "https://"))
|
||||
else redact_remote_urls(child)
|
||||
)
|
||||
for key, child in value.items()
|
||||
}
|
||||
if isinstance(value, list):
|
||||
return [redact_remote_urls(child) for child in value]
|
||||
return value
|
||||
|
||||
safe_payload = dict(payload)
|
||||
safe_content = []
|
||||
for item in payload.get("content", []):
|
||||
if item.get("type") == "image_url":
|
||||
safe_content.append(
|
||||
{
|
||||
"type": "image_url",
|
||||
"role": item.get("role"),
|
||||
"image_url": {
|
||||
"url": "<local-image-data-uri>",
|
||||
},
|
||||
}
|
||||
)
|
||||
else:
|
||||
safe_content.append(item)
|
||||
safe_payload["content"] = safe_content
|
||||
result = {
|
||||
"payload": safe_payload,
|
||||
"submit": redact_remote_urls(submit_response),
|
||||
"final": redact_remote_urls(final_response),
|
||||
}
|
||||
if production_gate is not None:
|
||||
result["productionGate"] = production_gate
|
||||
return result
|
||||
|
||||
|
||||
def validate_production_gate(args: argparse.Namespace) -> dict[str, Any]:
|
||||
if args.segment_index < 1:
|
||||
raise ValueError("--segment-index 必须大于等于 1")
|
||||
stage = getattr(args, "stage", None) or "pilot"
|
||||
if stage == "pilot":
|
||||
return {"stage": "pilot", "segmentIndex": args.segment_index}
|
||||
|
||||
if args.segment_index < 2:
|
||||
raise ValueError("production 阶段从第 2 段开始,--segment-index 必须大于等于 2")
|
||||
if not args.pilot_approval:
|
||||
raise ValueError("production 阶段必须提供 --pilot-approval")
|
||||
approval = validate_pilot_approval(
|
||||
args.pilot_approval,
|
||||
force=getattr(args, "force", False),
|
||||
)
|
||||
gate: dict[str, Any] = {
|
||||
"stage": "production",
|
||||
"segmentIndex": args.segment_index,
|
||||
"pilotApproval": str(
|
||||
Path(args.pilot_approval).expanduser().resolve()
|
||||
),
|
||||
"continuityMode": args.continuity_mode,
|
||||
}
|
||||
if args.continuity_mode is None:
|
||||
raise ValueError(
|
||||
"production 阶段必须显式提供 --continuity-mode chain|independent"
|
||||
)
|
||||
approved_mode = approval["continuityMode"]
|
||||
if args.continuity_mode != approved_mode:
|
||||
raise ValueError(
|
||||
"production 连续模式与 Pilot 批准的 Concept Contract 不一致:"
|
||||
f"合同要求 {approved_mode},收到 {args.continuity_mode}"
|
||||
)
|
||||
if args.continuity_mode == "chain":
|
||||
if not args.previous_tail or not args.first_frame or not args.frame_chain_manifest:
|
||||
raise ValueError(
|
||||
"chain 模式必须同时提供 --previous-tail、--first-frame "
|
||||
"和 --frame-chain-manifest"
|
||||
)
|
||||
link = verify_frame_chain(
|
||||
args.previous_tail,
|
||||
args.first_frame,
|
||||
args.segment_index,
|
||||
args.frame_chain_manifest,
|
||||
force=getattr(args, "force", False),
|
||||
)
|
||||
gate["frameChain"] = link
|
||||
gate["frameChainManifest"] = str(
|
||||
Path(args.frame_chain_manifest).expanduser().resolve()
|
||||
)
|
||||
return gate
|
||||
|
||||
|
||||
def build_payload(args: argparse.Namespace) -> dict[str, Any]:
|
||||
resolution = args.resolution
|
||||
if args.model == DEFAULT_MODEL:
|
||||
resolution = resolution.upper()
|
||||
if resolution not in H3_MAX_RESOLUTIONS:
|
||||
raise ValueError(
|
||||
f"{args.model} 不支持分辨率 {args.resolution!r};"
|
||||
f"仅支持 {', '.join(H3_MAX_RESOLUTIONS)}。"
|
||||
"请改用 --resolution 768P(母版)或 --resolution 480P(动作草案);"
|
||||
"母版像素不足时,下调 motion_budget.py 的 --display 或 --dpr,"
|
||||
"并用 --source 传入实际尺寸重新预算,不要放大母版。"
|
||||
)
|
||||
|
||||
prompt = args.prompt
|
||||
if args.prompt_file:
|
||||
prompt = Path(args.prompt_file).read_text(encoding="utf-8").strip()
|
||||
if not prompt:
|
||||
raise ValueError("必须提供 --prompt 或 --prompt-file")
|
||||
|
||||
content: list[dict[str, Any]] = [{"type": "text", "text": prompt}]
|
||||
first = Path(args.first_frame).expanduser().resolve() if args.first_frame else None
|
||||
last = Path(args.last_frame).expanduser().resolve() if args.last_frame else None
|
||||
if args.loop_frame and args.last_frame:
|
||||
raise ValueError("--loop-frame 与 --last-frame 不能同时使用")
|
||||
if args.loop_frame:
|
||||
if not first:
|
||||
raise ValueError("--loop-frame 需要同时提供 --first-frame")
|
||||
last = first
|
||||
if last and not first:
|
||||
raise ValueError("--last-frame 需要同时提供 --first-frame")
|
||||
|
||||
frame_mode = first is not None or last is not None
|
||||
reference_mode = bool(args.reference_image)
|
||||
if frame_mode and reference_mode:
|
||||
raise ValueError(
|
||||
"MiniMax H3 的 reference_image 与 first_frame/last_frame 互斥"
|
||||
"(接口错误 2013)。需要锁定身份时,请先把身份信息生成进首尾关键帧。"
|
||||
)
|
||||
if args.frames is not None and args.duration is not None:
|
||||
raise ValueError("--frames 与 --duration 不能同时传入")
|
||||
if args.frames is not None and args.frames < 2:
|
||||
raise ValueError("--frames 必须至少为 2")
|
||||
if args.duration is not None and args.duration <= 0:
|
||||
raise ValueError("--duration 必须大于 0")
|
||||
|
||||
if first:
|
||||
content.append(image_content(first, "first_frame"))
|
||||
if last:
|
||||
content.append(image_content(last, "last_frame"))
|
||||
reference_paths = [
|
||||
Path(raw_path).expanduser().resolve() for raw_path in args.reference_image
|
||||
]
|
||||
for reference_path in reference_paths:
|
||||
content.append(image_content(reference_path, "reference_image"))
|
||||
|
||||
payload: dict[str, Any] = {
|
||||
"model": args.model,
|
||||
"content": content,
|
||||
"resolution": resolution,
|
||||
"generate_audio": False,
|
||||
"watermark": False,
|
||||
"return_last_frame": True,
|
||||
}
|
||||
if args.frames is None:
|
||||
payload["duration"] = args.duration if args.duration is not None else 5
|
||||
payload["ratio"] = args.ratio or infer_ratio(
|
||||
first or (reference_paths[0] if reference_paths else None)
|
||||
)
|
||||
if args.seed is not None:
|
||||
payload["seed"] = args.seed
|
||||
if args.frames is not None:
|
||||
payload["frames"] = args.frames
|
||||
return payload
|
||||
|
||||
|
||||
def generate(args: argparse.Namespace) -> int:
|
||||
# 先做纯本地参数校验,避免因为缺少密钥而掩盖组合错误。
|
||||
payload = build_payload(args)
|
||||
gate_report = validate_production_gate(args)
|
||||
api_key = require_api_key()
|
||||
|
||||
output = Path(args.output).expanduser().resolve()
|
||||
metadata = (
|
||||
Path(args.metadata).expanduser().resolve()
|
||||
if args.metadata
|
||||
else output.with_suffix(".job.json")
|
||||
)
|
||||
if output.exists() and not args.force:
|
||||
raise FileExistsError(f"输出已存在:{output};确认后使用 --force")
|
||||
|
||||
submit_response = request_json("POST", f"{API_ROOT}/videos", api_key, payload)
|
||||
job_id = find_job_id(submit_response)
|
||||
print(f"任务已提交:{job_id}", flush=True)
|
||||
|
||||
deadline = time.monotonic() + args.timeout
|
||||
final_response = submit_response
|
||||
last_status = ""
|
||||
while time.monotonic() < deadline:
|
||||
final_response = request_json(
|
||||
"GET", f"{API_ROOT}/videos/{job_id}", api_key
|
||||
)
|
||||
status = find_status(final_response)
|
||||
if status != last_status:
|
||||
print(f"状态:{status}", flush=True)
|
||||
last_status = status
|
||||
if status in TERMINAL_STATES:
|
||||
break
|
||||
time.sleep(args.poll_interval)
|
||||
else:
|
||||
raise TimeoutError(f"等待视频超时:{args.timeout} 秒,任务 {job_id}")
|
||||
|
||||
metadata.parent.mkdir(parents=True, exist_ok=True)
|
||||
metadata.write_text(
|
||||
json.dumps(
|
||||
redacted_metadata(
|
||||
payload,
|
||||
submit_response,
|
||||
final_response,
|
||||
gate_report,
|
||||
),
|
||||
ensure_ascii=False,
|
||||
indent=2,
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
status = find_status(final_response)
|
||||
if status != "succeeded":
|
||||
raise RuntimeError(f"视频生成未成功,状态:{status};详情见 {metadata}")
|
||||
|
||||
video_url = walk_for_url(
|
||||
final_response, ("video_url", "videoUrl", "url", "download_url")
|
||||
)
|
||||
if not video_url:
|
||||
raise RuntimeError(f"任务成功但没有找到视频地址;详情见 {metadata}")
|
||||
download(video_url, output)
|
||||
print(f"视频:{output}", flush=True)
|
||||
|
||||
last_frame_output = (
|
||||
Path(args.last_frame_output).expanduser().resolve()
|
||||
if args.last_frame_output
|
||||
else output.with_name(f"{output.stem}-last-frame.jpg")
|
||||
)
|
||||
last_frame_url = walk_for_url(
|
||||
final_response, ("last_frame_url", "lastFrameUrl", "last_frame")
|
||||
)
|
||||
if last_frame_url:
|
||||
download(last_frame_url, last_frame_output)
|
||||
last_frame_source = "api"
|
||||
else:
|
||||
extract_last_frame(output, last_frame_output)
|
||||
last_frame_source = "video-fallback"
|
||||
print(f"尾帧:{last_frame_output}(来源:{last_frame_source})", flush=True)
|
||||
|
||||
metadata_payload = json.loads(metadata.read_text(encoding="utf-8"))
|
||||
metadata_payload["lastFrame"] = {
|
||||
"path": str(last_frame_output),
|
||||
"source": last_frame_source,
|
||||
}
|
||||
metadata.write_text(
|
||||
json.dumps(metadata_payload, ensure_ascii=False, indent=2),
|
||||
encoding="utf-8",
|
||||
)
|
||||
print(f"元数据:{metadata}", flush=True)
|
||||
return 0
|
||||
|
||||
|
||||
def parser() -> argparse.ArgumentParser:
|
||||
result = argparse.ArgumentParser(
|
||||
description="使用 ZenMux 的 MiniMax H3 生成并下载视频动作母版"
|
||||
)
|
||||
result.add_argument("--prompt")
|
||||
result.add_argument("--prompt-file")
|
||||
result.add_argument("--first-frame")
|
||||
result.add_argument("--last-frame")
|
||||
result.add_argument(
|
||||
"--loop-frame",
|
||||
action="store_true",
|
||||
help="把首帧同时作为尾帧,约束闭环",
|
||||
)
|
||||
result.add_argument(
|
||||
"--reference-image",
|
||||
action="append",
|
||||
default=[],
|
||||
help="参考模式,可重复传入;不得与首帧、尾帧或闭环模式混用",
|
||||
)
|
||||
result.add_argument("--model", default=DEFAULT_MODEL)
|
||||
result.add_argument(
|
||||
"--resolution",
|
||||
default="768P",
|
||||
help=(
|
||||
"minimax/minimax-h3-max 仅支持 480P 或 768P(默认,大小写均可);"
|
||||
"其他模型按其原生参数传入"
|
||||
),
|
||||
)
|
||||
result.add_argument(
|
||||
"--ratio",
|
||||
help="输出画幅;有首帧时默认推断最接近的常用画幅,否则默认 1:1",
|
||||
)
|
||||
result.add_argument(
|
||||
"--duration",
|
||||
type=int,
|
||||
help="视频秒数;未传 --frames 时默认 5,不能与 --frames 同时使用",
|
||||
)
|
||||
result.add_argument("--seed", type=int)
|
||||
result.add_argument("--frames", type=int)
|
||||
result.add_argument(
|
||||
"--stage",
|
||||
choices=("pilot", "production"),
|
||||
default="pilot",
|
||||
help="阶段类型:默认 pilot(直接生成与验证);production 阶段校验 Pilot 批准与连续性",
|
||||
)
|
||||
result.add_argument("--segment-index", type=int, default=1)
|
||||
result.add_argument("--pilot-approval")
|
||||
result.add_argument(
|
||||
"--continuity-mode",
|
||||
choices=("chain", "independent"),
|
||||
help="production 阶段必填;chain 会校验上一段尾帧与本段首帧 SHA-256",
|
||||
)
|
||||
result.add_argument("--previous-tail")
|
||||
result.add_argument("--frame-chain-manifest")
|
||||
result.add_argument("--output", required=True)
|
||||
result.add_argument("--last-frame-output")
|
||||
result.add_argument("--metadata")
|
||||
result.add_argument("--poll-interval", type=float, default=12.0)
|
||||
result.add_argument("--timeout", type=float, default=1200.0)
|
||||
result.add_argument("--force", action="store_true")
|
||||
return result
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
try:
|
||||
raise SystemExit(generate(parser().parse_args()))
|
||||
except (FileNotFoundError, FileExistsError, RuntimeError, TimeoutError, ValueError) as error:
|
||||
print(f"错误:{error}", file=sys.stderr)
|
||||
raise SystemExit(1) from error
|
||||
Reference in new issue
Block a user