- MockHostAdapter (tests/mock_host): in-memory editing model with opaque entity ids, id invalidation, token-bucket rate limiting, snapshot/restore with cap, event subscription, and a stdlib-zlib PNG encoder producing time-varying non-black frames - HostToolExecutor: all 26 tool handlers mapped to HostAdapter calls, snapshot orchestration for mutating batches, concurrent scan_timeline frame pipeline with token bucket and RateLimited backoff, undo_session() restoring per-batch snapshots in reverse, get_params min/max/choices reflection - Web UI (core[webui] extra): FastAPI + single-page vanilla JS, chat log, pending-confirmation list with approve/reject endpoints (timeout defaults to reject), progress, undo-session, snapshot management; SSE push + POST, bound to 127.0.0.1 - Host error types (HostError/EntityNotFound/RateLimited) and shared TokenBucket - P2 acceptance chain: import -> place -> split -> ripple delete -> effect + param -> frame verification (non-black, pixel changes at cut and after effect) -> undo_session restores original state Tests: 129 passed via uv run pytest (no network)
125 lines
4.3 KiB
Python
125 lines
4.3 KiB
Python
"""Mock 宿主的效果类型注册表与渲染语义(参数带 min/max/choices)。
|
||
|
||
``get_effect_params`` 从这张表读出参数元数据,供工具适配层回填 tool schema
|
||
(计划文档 §3.2:参数 min/max/choices 由 get_params 类信息回填)。
|
||
"""
|
||
from __future__ import annotations
|
||
|
||
from dataclasses import dataclass
|
||
from typing import Any
|
||
|
||
|
||
@dataclass(frozen=True)
|
||
class ParamSpec:
|
||
"""单个效果参数的定义。"""
|
||
|
||
name: str
|
||
type: str # number | string | boolean
|
||
min: float | None = None
|
||
max: float | None = None
|
||
choices: tuple[str, ...] | None = None
|
||
default: Any = None
|
||
description: str = ""
|
||
|
||
def to_json(self) -> dict:
|
||
out: dict = {"name": self.name, "type": self.type, "default": self.default}
|
||
if self.min is not None:
|
||
out["min"] = self.min
|
||
if self.max is not None:
|
||
out["max"] = self.max
|
||
if self.choices is not None:
|
||
out["choices"] = list(self.choices)
|
||
if self.description:
|
||
out["description"] = self.description
|
||
return out
|
||
|
||
|
||
MOCK_EFFECT_TYPES: dict[str, dict] = {
|
||
"brightness": {
|
||
"description": "亮度调整(level 为 -1..1 的亮度增量)",
|
||
"params": [
|
||
ParamSpec("level", "number", min=-1.0, max=1.0, default=0.0, description="亮度增量"),
|
||
ParamSpec("enabled", "boolean", default=True, description="是否启用"),
|
||
],
|
||
},
|
||
"color_shift": {
|
||
"description": "色相偏移(0..360 度)",
|
||
"params": [
|
||
ParamSpec("hue", "number", min=0.0, max=360.0, default=0.0, description="色相偏移角度"),
|
||
ParamSpec("enabled", "boolean", default=True),
|
||
],
|
||
},
|
||
"crop": {
|
||
"description": "裁切(各边 0..0.95 比例)",
|
||
"params": [
|
||
ParamSpec("left", "number", min=0.0, max=0.95, default=0.0),
|
||
ParamSpec("top", "number", min=0.0, max=0.95, default=0.0),
|
||
ParamSpec("right", "number", min=0.0, max=0.95, default=0.0),
|
||
ParamSpec("bottom", "number", min=0.0, max=0.95, default=0.0),
|
||
],
|
||
},
|
||
"blur": {
|
||
"description": "模糊(像素半径,近似盒式平均)",
|
||
"params": [
|
||
ParamSpec("radius", "number", min=0.0, max=50.0, default=0.0),
|
||
ParamSpec("enabled", "boolean", default=True),
|
||
],
|
||
},
|
||
"overlay": {
|
||
"description": "叠加混合(blend_mode 带 choices,供参数回填测试)",
|
||
"params": [
|
||
ParamSpec(
|
||
"blend_mode",
|
||
"string",
|
||
choices=("normal", "screen", "multiply"),
|
||
default="normal",
|
||
),
|
||
ParamSpec("opacity", "number", min=0.0, max=1.0, default=0.5),
|
||
],
|
||
},
|
||
}
|
||
|
||
|
||
def effect_spec(effect_type: str) -> dict:
|
||
spec = MOCK_EFFECT_TYPES.get(effect_type)
|
||
if spec is None:
|
||
raise KeyError(effect_type)
|
||
return spec
|
||
|
||
|
||
def validate_param(effect_type: str, name: str, value: Any) -> None:
|
||
"""校验参数值是否满足注册表的类型/范围/choices,非法抛 ValueError。"""
|
||
spec = effect_spec(effect_type)
|
||
pspec = next((p for p in spec["params"] if p.name == name), None)
|
||
if pspec is None:
|
||
raise ValueError(
|
||
f"效果 {effect_type} 无参数 {name!r}(可选: {[p.name for p in spec['params']]})"
|
||
)
|
||
ok = _valid_value(pspec, value)
|
||
if not ok:
|
||
raise ValueError(
|
||
f"参数 {name}={value!r} 非法:类型 {pspec.type}"
|
||
+ (f",范围 {pspec.min}..{pspec.max}" if pspec.min is not None or pspec.max is not None else "")
|
||
+ (f",可选 {list(pspec.choices)}" if pspec.choices is not None else "")
|
||
)
|
||
|
||
|
||
def _valid_value(pspec: ParamSpec, value: Any) -> bool:
|
||
if pspec.type == "boolean":
|
||
return isinstance(value, bool)
|
||
if pspec.type == "string":
|
||
if not isinstance(value, str):
|
||
return False
|
||
if pspec.choices is not None and value not in pspec.choices:
|
||
return False
|
||
return True
|
||
if pspec.type == "number":
|
||
if not isinstance(value, (int, float)) or isinstance(value, bool):
|
||
return False
|
||
if pspec.min is not None and value < pspec.min:
|
||
return False
|
||
if pspec.max is not None and value > pspec.max:
|
||
return False
|
||
return True
|
||
return False
|