修: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:
admin committed 2026-10-08 22:29:52 +08:00
1 parent e03465c398
commit 237a09a5b0
161 files changed
+19429 -2

No files matched your search

+177
View File
@@ -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
+126
View File
@@ -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()
+203
View File
@@ -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()
+162
View File
@@ -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
View File
@@ -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 : '请重新打开配置入口。'); });
+283
View File
@@ -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
+154
View File
@@ -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
+36
View File
@@ -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")
+413
View File
@@ -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
+166
View File
@@ -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
+858
View File
@@ -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()
+422
View File
@@ -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
+5
View File
@@ -0,0 +1,5 @@
Pillow>=10.0.0
numpy>=1.24.0
PyYAML>=6.0.0
-r requirements-credentials.txt
+45
View File
@@ -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
+478
View File
@@ -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