diff --git a/core/pyproject.toml b/core/pyproject.toml index cc3b49c..a74a758 100644 --- a/core/pyproject.toml +++ b/core/pyproject.toml @@ -13,9 +13,16 @@ dependencies = [ ] [project.optional-dependencies] +webui = [ + "fastapi>=0.115,<1.0", + "uvicorn>=0.30,<1.0", +] dev = [ "pytest>=8.0", ] +[project.scripts] +quercus-webui = "quercus_core.webui.__main__:main" + [tool.hatch.build.targets.wheel] packages = ["src/quercus_core"] diff --git a/core/src/quercus_core/host/__init__.py b/core/src/quercus_core/host/__init__.py index 0660338..faa2027 100644 --- a/core/src/quercus_core/host/__init__.py +++ b/core/src/quercus_core/host/__init__.py @@ -1,4 +1,36 @@ -"""HostAdapter 抽象接口(§3.1)。P1 只定义 ABC,不提供实现。""" +"""HostAdapter 抽象接口(§3.1)。P1 只定义 ABC,P2 增加错误/快照/事件类型。""" from quercus_core.host.adapter import HostAdapter +from quercus_core.host.errors import EntityNotFound, HostError, RateLimited +from quercus_core.host.types import ( + BatchResult, + FootageId, + HostEvent, + JobHandle, + Levels, + MediaInfo, + PlaybackState, + ProjectOverview, + SeqId, + SnapshotInfo, + Target, + Timeline, +) -__all__ = ["HostAdapter"] +__all__ = [ + "HostAdapter", + "HostError", + "EntityNotFound", + "RateLimited", + "BatchResult", + "FootageId", + "HostEvent", + "JobHandle", + "Levels", + "MediaInfo", + "PlaybackState", + "ProjectOverview", + "SeqId", + "SnapshotInfo", + "Target", + "Timeline", +] diff --git a/core/src/quercus_core/host/errors.py b/core/src/quercus_core/host/errors.py new file mode 100644 index 0000000..e8ef7ee --- /dev/null +++ b/core/src/quercus_core/host/errors.py @@ -0,0 +1,24 @@ +"""HostAdapter 层通用错误(对齐 OPP/1 §6 的失败语义,P2 新增)。 + +- ``EntityNotFound``:不透明 id 失效 / 指向已删除对象(OPP/1 的 + ``ENTITY_NOT_FOUND`` 风格错误)。core 把它当作可回喂 LLM 的工具错误。 +- ``RateLimited``:宿主限流(取帧等)。``retry_after_ms`` 建议调用方等待的 + 毫秒数;core 侧取帧流水线据此退避(计划文档 §6 约束 4)。 +""" +from __future__ import annotations + + +class HostError(Exception): + """宿主侧错误基类。""" + + +class EntityNotFound(HostError): + """实体 id 不存在或已失效(删除后 id 作废,再访问即抛此错)。""" + + +class RateLimited(HostError): + """宿主限流:调用被拒绝,建议 ``retry_after_ms`` 毫秒后重试。""" + + def __init__(self, retry_after_ms: int, message: str = "操作被限流"): + super().__init__(message) + self.retry_after_ms = retry_after_ms diff --git a/core/src/quercus_core/host/types.py b/core/src/quercus_core/host/types.py index 9c25a8d..bc38644 100644 --- a/core/src/quercus_core/host/types.py +++ b/core/src/quercus_core/host/types.py @@ -100,6 +100,29 @@ class BatchResult: ok: bool message: str = "" results: list[ToolResult] = field(default_factory=list) + # P2:本次批次执行前宿主为它创建的快照 id(空串表示未建快照)。 + # 工具适配层据此记录"本会话批次 → 快照"映射,供 undo_session 反向恢复。 + snapshot_id: str = "" + + +@dataclass(frozen=True) +class SnapshotInfo: + """快照元数据(供 Web 面板快照管理与 undo_session 定位)。""" + + id: str + session_id: str + created_at: str + label: str = "" + action_count: int = 0 + + +@dataclass(frozen=True) +class HostEvent: + """宿主事件(结构变化 / 播放头 / 导出进度),订阅者回调收到它。""" + + kind: str # structure_changed | playhead_moved | export_progress | ... + seq_id: str = "" + payload: dict = field(default_factory=dict) @dataclass diff --git a/core/src/quercus_core/tools/executor.py b/core/src/quercus_core/tools/executor.py new file mode 100644 index 0000000..be2de65 --- /dev/null +++ b/core/src/quercus_core/tools/executor.py @@ -0,0 +1,497 @@ +"""工具适配层:P1 的 26 个工具 schema → HostAdapter 方法组合(ToolExecutor 协议)。 + +设计要点(计划文档 §3.2–3.3 / §6): +- 每个工具一个 handler;只读工具直通 adapter 方法;变更类工具包成 + ``ActionBatch`` 经 ``adapter.execute``(快照 + 补偿语义)执行。 +- ``get_params`` 从 adapter 读出效果参数的 min/max/choices(供未来回填 + tool schema)。 +- 取帧流水线:``scan_timeline`` 批量取帧并发执行(ThreadPoolExecutor, + 禁止串行等帧);前置令牌桶按 ``Limits`` 限速;遇 ``RateLimited`` 按 + ``retry_after_ms`` 退避(有限重试);帧结果用完即弃(不缓存进会话)。 +- 会话撤销 ``undo_session()``:反向恢复本会话各批次快照(mock 宿主上 + 即"恢复各批次快照"),供 Web 面板"撤销整段会话"按钮使用。 + +AgentLoop(P1 协议)逐 action 派发;本类同时提供 ``execute_batch`` 供 +webui/测试按批执行(一个批次一个快照)。 +""" +from __future__ import annotations + +import time +import uuid +from concurrent.futures import ThreadPoolExecutor, as_completed +from dataclasses import dataclass +from typing import Any, Callable + +from quercus_core.host.adapter import HostAdapter +from quercus_core.host.errors import EntityNotFound, RateLimited +from quercus_core.host.types import BatchResult, SnapshotInfo, Target +from quercus_core.tools.ratelimit import TokenBucket +from quercus_core.tools.schemas import TOOLS +from quercus_core.types import ( + Action, + ActionBatch, + PngBytes, + Rational, + Size, + TimeRange, + ToolResult, +) + +# 变更类工具里不走"快照批次"的两类:导出(进度语义)与撤销(自身即回退)。 +_DIRECT_MUTATION_TOOLS = frozenset({"export_render", "undo_last_action"}) + +# 批量取帧的默认缩略尺寸(contact sheet 用小图,避免会话囤积大图)。 +_SCAN_FRAME_SIZE = Size(480, 270) + + +@dataclass +class ProgressEvent: + """进度事件(scan / export),经 progress_callback 上抛给 UI。""" + + kind: str # scan | export + seq_id: str = "" + done: int = 0 + total: int = 0 + message: str = "" + + +def _sample_times(rng: TimeRange, count: int) -> list[Rational]: + if count <= 1: + return [rng.start] + step = rng.duration / Rational(count - 1, 1) + return [rng.start + step * Rational(i, 1) for i in range(count)] + + +class HostToolExecutor: + """实现 ``quercus_core.agent.loop.ToolExecutor`` 协议的工具适配层。""" + + def __init__( + self, + adapter: HostAdapter, + *, + session_id: str | None = None, + frame_concurrency: int = 4, + max_frame_retries: int = 4, + rate_limiter: TokenBucket | None = None, + progress_callback: Callable[[ProgressEvent], None] | None = None, + ) -> None: + self._adapter = adapter + self._session_id = session_id or uuid.uuid4().hex + self._concurrency = max(1, frame_concurrency) + self._max_frame_retries = max(0, max_frame_retries) + limits = adapter.limits() + self._bucket = rate_limiter or TokenBucket( + capacity=limits.frame_burst, refill_rate=limits.max_frame_rate + ) + self._progress = progress_callback + # 会话撤销日志:(snapshot_id, call_id, tool) + self._undo_log: list[tuple[str, str, str]] = [] + + # ---- 会话属性 ---- + + @property + def session_id(self) -> str: + return self._session_id + + @property + def adapter(self) -> HostAdapter: + return self._adapter + + # ---- ToolExecutor 协议:逐 action 派发(AgentLoop 入口) ---- + + def execute(self, action: Action) -> ToolResult: + if action.tool in _MUTATION_TOOLS: + return self._run_mutation(action) + if action.tool in _DIRECT_MUTATION_TOOLS: + return self._run_direct(action) + handler = _READONLY_HANDLERS.get(action.tool) + if handler is None: + return ToolResult(tool=action.tool, ok=False, summary=f"未知工具 {action.tool!r}") + return handler(self, action) + + # ---- 批级执行(webui/测试用:一个批次一个快照) ---- + + def execute_batch(self, batch: ActionBatch) -> list[ToolResult]: + """整批执行:只读直通、变更动作合并为一次 adapter.execute(一个快照)。""" + results: list[ToolResult] = [] + mutations: list[Action] = [] + for action in batch.actions: + tool_schema = TOOLS.get(action.tool) + if action.tool in _DIRECT_MUTATION_TOOLS: + results.append(self.execute(action)) + elif tool_schema is None: + results.append( + ToolResult(tool=action.tool, ok=False, summary=f"未知工具 {action.tool!r}") + ) + elif tool_schema.read_only: + results.append(self.execute(action)) + else: + mutations.append(action) + if mutations: + sub = ActionBatch( + label=batch.label or f"AI 动作:{len(mutations)}", + actions=mutations, + id=batch.id or uuid.uuid4().hex, + session_id=batch.session_id or self._session_id, + ) + br = self._adapter.execute(sub) + if br.snapshot_id: + self._undo_log.append((br.snapshot_id, sub.id, sub.label)) + mresults = list(br.results) + while len(mresults) < len(mutations): + mresults.append( + ToolResult(tool=mutations[len(mresults)].tool, ok=False, summary="缺失结果") + ) + results.extend(mresults) + return results + + # ---- 撤销 / 快照 / 参数(Web 面板与 schema 回填用) ---- + + def undo_session(self) -> int: + """整段会话撤销:反向恢复本会话各批次快照(mock 宿主上即"恢复各批次快照")。 + + 已消费(undo_last / drop)的快照会抛 EntityNotFound,跳过即可。 + """ + restored = 0 + for snapshot_id, _call_id, _tool in reversed(self._undo_log): + try: + self._adapter.restore_snapshot(snapshot_id) + restored += 1 + except (EntityNotFound, NotImplementedError): + continue + self._undo_log.clear() + return restored + + def list_snapshots(self) -> list[SnapshotInfo]: + method = getattr(self._adapter, "list_snapshots", None) + return list(method()) if method else [] + + def drop_snapshot(self, snapshot_id: str) -> None: + method = getattr(self._adapter, "drop_snapshot", None) + if method is None: + raise EntityNotFound("宿主不支持快照管理") + method(snapshot_id) + + def get_params(self, effect_type: str) -> list[dict]: + """从 adapter 读出效果参数 min/max/choices(供未来回填 tool schema)。 + + 未知效果类型返回空列表(优雅降级,不打断 schema 回填)。 + """ + method = getattr(self._adapter, "get_effect_params", None) + if method is None: + return [] + try: + return method(effect_type) + except EntityNotFound: + return [] + + # ---- 变更类工具(经 adapter.execute 快照批次) ---- + + def _run_mutation(self, action: Action) -> ToolResult: + batch = ActionBatch( + label=f"AI 动作:{action.tool}", + actions=[Action(tool=action.tool, params=dict(action.params), call_id=action.call_id)], + id=action.call_id or uuid.uuid4().hex, + session_id=self._session_id, + ) + br = self._adapter.execute(batch) + if br.snapshot_id: + self._undo_log.append((br.snapshot_id, action.call_id, action.tool)) + if br.results: + return br.results[0] + return ToolResult( + tool=action.tool, + ok=br.ok, + summary=br.message or ("ok" if br.ok else "执行失败"), + error=None if br.ok else br.message, + ) + + def _run_direct(self, action: Action) -> ToolResult: + if action.tool == "undo_last_action": + try: + self._adapter.undo_last() + return ToolResult(tool="undo_last_action", ok=True, summary="已撤销上一个 AI 动作(恢复其快照)") + except (EntityNotFound, NotImplementedError) as exc: + return ToolResult(tool="undo_last_action", ok=False, summary=f"撤销失败:{exc}", error=str(exc)) + if action.tool == "export_render": + return _h_export_render(self, action) + return ToolResult(tool=action.tool, ok=False, summary=f"未知直接工具 {action.tool!r}") + + # ---- 取帧流水线 ---- + + def _fetch_frame(self, target: Target, time_point: Rational, size: Size) -> PngBytes: + """取一帧:前置令牌桶 + 宿主 RateLimited 有限重试退避(不轰炸)。""" + for attempt in range(self._max_frame_retries + 1): + self._bucket.acquire() # 前置限速(按 Limits 令牌桶) + try: + return self._adapter.get_frame(target, time_point, size) + except RateLimited as exc: + if attempt >= self._max_frame_retries: + raise + time.sleep(exc.retry_after_ms / 1000.0) + raise RuntimeError("不可达") # pragma: no cover + + def _current_seq_id(self) -> str: + try: + ov = self._adapter.get_project_overview() + except Exception: + return "" + return ov.timeline_ids[0] if ov.timeline_ids else "" + + def _tool_target(self, params: dict) -> Target: + """get_frame / scan_timeline 的目标:显式 target 或当前时间线。""" + target_id = params.get("target") + if target_id: + return Target(kind="timeline", id=target_id) + return Target(kind="timeline", id=self._current_seq_id()) + + def _emit_progress(self, kind: str, done: int, total: int, message: str = "") -> None: + if self._progress: + self._progress( + ProgressEvent(kind=kind, seq_id=self._current_seq_id(), done=done, total=total, message=message) + ) + + +# ---- 只读工具 handler(直通 adapter) ---- + +def _h_get_project_overview(self: HostToolExecutor, action: Action) -> ToolResult: + try: + ov = self._adapter.get_project_overview() + except Exception as exc: + return ToolResult(tool="get_project_overview", ok=False, summary=f"获取概览失败:{exc}", error=str(exc)) + lines = [f"工程:{ov.name}(fps {ov.fps:g})"] + if ov.duration is not None: + lines.append(f"时长:{ov.duration.to_float():.3f}s") + for sid in ov.timeline_ids: + try: + tl = self._adapter.get_timeline_structure(sid) + track_desc = "、".join( + f"{t.kind}{t.index}({len(t.clips)}块)" for t in tl.tracks + ) or "空" + lines.append(f"时间线 {sid}「{tl.name}」:{track_desc},时长 {tl.duration.to_float():.3f}s") + except Exception as exc: + lines.append(f"时间线 {sid}:读取失败({exc})") + return ToolResult(tool="get_project_overview", ok=True, summary="\n".join(lines)) + + +def _h_probe_media(self: HostToolExecutor, action: Action) -> ToolResult: + try: + info = self._adapter.probe_media(action.params["path"]) + except Exception as exc: + return ToolResult(tool="probe_media", ok=False, summary=f"探测失败:{exc}", error=str(exc)) + parts = [f"路径:{info.path}"] + if info.duration is not None: + parts.append(f"时长:{info.duration.to_float():.3f}s") + if info.width and info.height: + parts.append(f"分辨率:{info.width}x{info.height}") + if info.fps: + parts.append(f"帧率:{info.fps:g}") + return ToolResult(tool="probe_media", ok=True, summary=";".join(parts)) + + +def _h_list_footage(self: HostToolExecutor, action: Action) -> ToolResult: + method = getattr(self._adapter, "list_footage", None) + if method is None: + return ToolResult(tool="list_footage", ok=False, summary="宿主不支持 list_footage") + try: + items = method() + except Exception as exc: + return ToolResult(tool="list_footage", ok=False, summary=f"列出素材失败:{exc}", error=str(exc)) + if not items: + return ToolResult(tool="list_footage", ok=True, summary="媒体池为空") + return ToolResult( + tool="list_footage", + ok=True, + summary="媒体池({}):{}".format( + len(items), "、".join(f"{it['name']}({it['id']})" for it in items) + ), + ) + + +def _h_list_effects(self: HostToolExecutor, action: Action) -> ToolResult: + method = getattr(self._adapter, "list_effects", None) + if method is None: + return ToolResult(tool="list_effects", ok=False, summary="宿主不支持 list_effects") + clip_id = action.params.get("clip_id") + category = action.params.get("category") + try: + items = method(clip_id, category) + except Exception as exc: + return ToolResult(tool="list_effects", ok=False, summary=f"列出效果失败:{exc}", error=str(exc)) + if clip_id: + if not items: + return ToolResult(tool="list_effects", ok=True, summary=f"片段 {clip_id} 无效果") + desc = ", ".join("{}({})".format(it["type"], it["node_id"]) for it in items) + return ToolResult( + tool="list_effects", + ok=True, + summary=f"片段 {clip_id} 效果:{desc}", + ) + if not items: + return ToolResult(tool="list_effects", ok=True, summary="无可用的效果类型") + return ToolResult( + tool="list_effects", + ok=True, + summary=f"可用效果类型:{', '.join(it['type'] for it in items)}", + ) + + +def _h_get_frame(self: HostToolExecutor, action: Action) -> ToolResult: + params = action.params + try: + t = Rational.from_json(params["time"]) + size = Size(int(params["max_size"]["width"]), int(params["max_size"]["height"])) + target = self._tool_target(params) + frame = self._fetch_frame(target, t, size) + except RateLimited as exc: + return ToolResult( + tool="get_frame", + ok=False, + summary=f"取帧被限流(退避 {exc.retry_after_ms}ms 后仍失败)", + error=str(exc), + ) + except (EntityNotFound, ValueError) as exc: + return ToolResult(tool="get_frame", ok=False, summary=f"取帧失败:{exc}", error=str(exc)) + return ToolResult( + tool="get_frame", + ok=True, + summary=f"帧 @ {t.to_float():.3f}s({len(frame)} 字节 PNG)", + images=(frame,), + ) + + +def _h_scan_timeline(self: HostToolExecutor, action: Action) -> ToolResult: + """批量取帧:并发执行 + 令牌桶 + RateLimited 退避;帧用完即弃。""" + params = action.params + try: + rng = TimeRange.from_json(params["range"]) + except (ValueError, KeyError) as exc: + return ToolResult(tool="scan_timeline", ok=False, summary=f"参数非法:{exc}", error=str(exc)) + count = max(1, min(int(params.get("count", 8)), self._adapter.limits().max_scan_frames)) + target = self._tool_target(params) + times = _sample_times(rng, count) + + frames: dict[int, PngBytes] = {} + errors: list[str] = [] + workers = min(self._concurrency, count) + done = 0 + with ThreadPoolExecutor(max_workers=workers) as pool: + future_to_index = { + pool.submit(self._fetch_frame, target, t, _SCAN_FRAME_SIZE): i + for i, t in enumerate(times) + } + for future in as_completed(future_to_index): + i = future_to_index[future] + try: + frames[i] = future.result() + except RateLimited as exc: + errors.append(f"第 {i + 1} 帧限流:{exc}") + except (EntityNotFound, ValueError) as exc: + errors.append(f"第 {i + 1} 帧失败:{exc}") + done += 1 + self._emit_progress("scan", done, count, f"扫描 {done}/{count}") + + ordered = [frames.get(i) for i in range(count)] + if errors: + return ToolResult( + tool="scan_timeline", + ok=False, + summary=f"扫描 {count} 帧,成功 {len(ordered) - sum(1 for f in ordered if f is None)},失败 {len(errors)}:{errors[0]}", + images=tuple(f for f in ordered if f is not None), + error="; ".join(errors), + ) + return ToolResult( + tool="scan_timeline", + ok=True, + summary=f"扫描 {count} 帧({rng.start.to_float():.2f}s ~ {rng.end.to_float():.2f}s)", + images=tuple(ordered), # type: ignore[arg-type] + ) + + +def _h_get_audio_levels(self: HostToolExecutor, action: Action) -> ToolResult: + params = action.params + try: + rng = TimeRange.from_json(params["range"]) + resolution = int(params.get("resolution", 64)) + levels = self._adapter.get_audio_levels(self._current_seq_id(), rng, resolution) + except (EntityNotFound, ValueError, KeyError) as exc: + return ToolResult(tool="get_audio_levels", ok=False, summary=f"读取电平失败:{exc}", error=str(exc)) + if not levels.values: + return ToolResult(tool="get_audio_levels", ok=True, summary="无电平数据") + return ToolResult( + tool="get_audio_levels", + ok=True, + summary=f"电平 {len(levels.values)} 点(min {min(levels.values):.1f}dB,max {max(levels.values):.1f}dB)", + ) + + +def _h_play(self: HostToolExecutor, action: Action) -> ToolResult: + try: + self._adapter.play(float(action.params.get("speed", 1.0))) + except Exception as exc: + return ToolResult(tool="play", ok=False, summary=f"播放失败:{exc}", error=str(exc)) + return ToolResult(tool="play", ok=True, summary="开始回放") + + +def _h_pause(self: HostToolExecutor, action: Action) -> ToolResult: + try: + self._adapter.pause() + except Exception as exc: + return ToolResult(tool="pause", ok=False, summary=f"暂停失败:{exc}", error=str(exc)) + return ToolResult(tool="pause", ok=True, summary="已暂停") + + +def _h_seek(self: HostToolExecutor, action: Action) -> ToolResult: + try: + t = Rational.from_json(action.params["time"]) + self._adapter.seek(t) + except (ValueError, KeyError) as exc: + return ToolResult(tool="seek", ok=False, summary=f"seek 失败:{exc}", error=str(exc)) + return ToolResult(tool="seek", ok=True, summary=f"播放头移至 {t.to_float():.3f}s") + + +def _h_export_render(self: HostToolExecutor, action: Action) -> ToolResult: + """导出:adapter.export 启动 + (若宿主支持)有界轮询 export_status 报进度。""" + params = action.params + output = params["output"] + preset = params.get("preset", "") + seq = self._current_seq_id() + try: + job = self._adapter.export(seq, output, preset) + except (EntityNotFound, ValueError) as exc: + return ToolResult(tool="export_render", ok=False, summary=f"导出失败:{exc}", error=str(exc)) + status_fn = getattr(self._adapter, "export_status", None) + if status_fn is None: + return ToolResult(tool="export_render", ok=True, summary=f"已启动导出任务 {job.job_id} → {output}") + poll_interval = min(getattr(self._adapter.limits(), "poll_interval_seconds", 0.5), 0.05) + for _ in range(100): # 有界轮询,不无限等 + st = status_fn(job.job_id) + self._emit_progress("export", int(float(st.get("progress", 0.0)) * 100), 100, st.get("message", "")) + if st.get("status") == "done": + return ToolResult(tool="export_render", ok=True, summary=f"导出完成 → {output}") + if st.get("status") == "failed": + return ToolResult(tool="export_render", ok=False, summary=f"导出失败:{st.get('message')}", error=st.get("message")) + time.sleep(poll_interval) + return ToolResult(tool="export_render", ok=True, summary=f"导出仍在进行:{job.job_id} → {output}") + + +# ---- 派发表 ---- + +_READONLY_HANDLERS: dict[str, Callable[[HostToolExecutor, Action], ToolResult]] = { + "get_project_overview": _h_get_project_overview, + "probe_media": _h_probe_media, + "list_footage": _h_list_footage, + "list_effects": _h_list_effects, + "get_frame": _h_get_frame, + "scan_timeline": _h_scan_timeline, + "get_audio_levels": _h_get_audio_levels, + "play": _h_play, + "pause": _h_pause, + "seek": _h_seek, +} + +_MUTATION_TOOLS: frozenset[str] = frozenset( + name + for name, schema in TOOLS.items() + if not schema.read_only and name not in _DIRECT_MUTATION_TOOLS +) diff --git a/core/src/quercus_core/tools/ratelimit.py b/core/src/quercus_core/tools/ratelimit.py new file mode 100644 index 0000000..2c05f94 --- /dev/null +++ b/core/src/quercus_core/tools/ratelimit.py @@ -0,0 +1,67 @@ +"""令牌桶限流器:取帧流水线的通用限流客户端(计划文档 §6 约束 4)。 + +core 侧(工具适配层取帧流水线)与 mock 宿主共用同一实现: +- 执行器用它做**前置令牌桶**(按 ``Limits`` 均匀限速,避免轰炸宿主); +- mock 宿主用它做**强制限流**(令牌耗尽即抛 ``RateLimited``,测试客户端退避)。 +""" +from __future__ import annotations + +import threading +import time + + +class TokenBucket: + """简单令牌桶:容量 ``capacity``,按 ``refill_rate``(令牌/秒)补充。 + + 线程安全。``acquire()`` 阻塞直到取到一枚令牌;``try_acquire()`` 非阻塞, + 取不到返回 False 并可用 ``seconds_until_token()`` 查询需等待时长。 + """ + + def __init__( + self, + capacity: int, + refill_rate: float, + monotonic=time.monotonic, + ) -> None: + if capacity < 1: + raise ValueError("令牌桶容量必须 >= 1") + if refill_rate <= 0: + raise ValueError("补充速率必须为正") + self._capacity = float(capacity) + self._rate = float(refill_rate) + self._monotonic = monotonic + self._tokens = float(capacity) + self._last = monotonic() + self._lock = threading.Lock() + + def _refill(self, now: float) -> None: + self._tokens = min(self._capacity, self._tokens + (now - self._last) * self._rate) + self._last = now + + def try_acquire(self) -> bool: + """非阻塞取令牌:取到返回 True,否则 False(不扣减)。""" + with self._lock: + self._refill(self._monotonic()) + if self._tokens >= 1.0: + self._tokens -= 1.0 + return True + return False + + def seconds_until_token(self) -> float: + """按当前余量估算取到下一枚令牌所需秒数(0 表示立即可取)。""" + with self._lock: + self._refill(self._monotonic()) + if self._tokens >= 1.0: + return 0.0 + return (1.0 - self._tokens) / self._rate + + def acquire(self) -> None: + """阻塞直到取到一枚令牌(等待期间持锁,令牌发放全局串行)。""" + with self._lock: + while True: + self._refill(self._monotonic()) + if self._tokens >= 1.0: + self._tokens -= 1.0 + return + wait = (1.0 - self._tokens) / self._rate + time.sleep(wait) diff --git a/core/src/quercus_core/types.py b/core/src/quercus_core/types.py index 9efc40d..3d9ac72 100644 --- a/core/src/quercus_core/types.py +++ b/core/src/quercus_core/types.py @@ -171,6 +171,7 @@ class Limits: max_frame_width: int = 1920 max_frame_height: int = 1080 max_frame_rate: float = 8.0 # 每秒取帧上限 + frame_burst: int = 4 # 取帧令牌桶容量(突发余量,P2 新增) max_scan_frames: int = 64 # 单次 scan_timeline 的采样数上限 max_snapshot_count: int = 16 # 快照时间线数量上限 poll_interval_seconds: float = 0.5 # 宿主无事件源时适配层轮询间隔 @@ -180,6 +181,7 @@ class Limits: "max_frame_width": self.max_frame_width, "max_frame_height": self.max_frame_height, "max_frame_rate": self.max_frame_rate, + "frame_burst": self.frame_burst, "max_scan_frames": self.max_scan_frames, "max_snapshot_count": self.max_snapshot_count, "poll_interval_seconds": self.poll_interval_seconds, @@ -210,6 +212,7 @@ class ActionBatch: actions: list[Action] id: str = "" # 由调用方(loop)分配 created_at: str = "" # ISO 时间戳 + session_id: str = "" # 所属会话 id(P2:快照带会话标识,供整段撤销定位) def to_json(self) -> dict[str, Any]: return { @@ -217,6 +220,7 @@ class ActionBatch: "actions": [a.to_json() for a in self.actions], "id": self.id, "created_at": self.created_at, + "session_id": self.session_id, } diff --git a/core/src/quercus_core/webui/__init__.py b/core/src/quercus_core/webui/__init__.py new file mode 100644 index 0000000..72c7053 --- /dev/null +++ b/core/src/quercus_core/webui/__init__.py @@ -0,0 +1,10 @@ +"""本地 Web 面板(可选 extra ``core[webui]``):聊天 + 待确认清单 + 进度。 + +交互:SSE 推送(服务端 → 浏览器)+ POST(浏览器 → 服务端)。 +启动入口:``python -m quercus_core.webui`` 或 ``quercus-webui``。 +""" +from quercus_core.webui.app import create_app +from quercus_core.webui.broker import EventBroker +from quercus_core.webui.session import WebSession + +__all__ = ["create_app", "EventBroker", "WebSession"] diff --git a/core/src/quercus_core/webui/__main__.py b/core/src/quercus_core/webui/__main__.py new file mode 100644 index 0000000..d3d558c --- /dev/null +++ b/core/src/quercus_core/webui/__main__.py @@ -0,0 +1,98 @@ +"""本地 Web 面板启动入口:``python -m quercus_core.webui`` 或 ``quercus-webui``。 + +绑定 127.0.0.1 + 随机端口(``--port 0``);P2 阶段宿主适配层只有 mock, +因此 ``--adapter mock`` 会用 tests/mock_host 的内存实现演示。 +需安装可选 extra ``core[webui]``(fastapi + uvicorn)。 +""" +from __future__ import annotations + +import argparse +import socket +import sys +from pathlib import Path + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser(description="Quercus AI 剪辑助手 — 本地 Web 面板") + parser.add_argument("--host", default="127.0.0.1", help="监听地址(默认仅本机)") + parser.add_argument("--port", type=int, default=0, help="监听端口(0 = 随机端口)") + parser.add_argument( + "--adapter", + default="mock", + choices=["mock"], + help="宿主适配层(P2 阶段仅 mock)", + ) + args = parser.parse_args(argv) + + try: + import uvicorn # noqa: F401 + except ImportError: + print( + "缺少依赖 uvicorn,请先安装 core[webui]:uv pip install -e 'core[webui]'", + file=sys.stderr, + ) + return 2 + + if args.port == 0: + with socket.socket() as s: + s.bind(("127.0.0.1", 0)) + args.port = s.getsockname()[1] + + adapter = _make_adapter(args.adapter) + provider = _make_provider() + from quercus_core.webui.app import create_app + + app = create_app(adapter=adapter, provider=provider, confirm_timeout=300.0) + print(f"Quercus Web 面板:http://{args.host}:{args.port} (Ctrl-C 退出)", flush=True) + uvicorn.run(app, host=args.host, port=args.port, log_level="warning") + return 0 + + +def _make_adapter(name: str): + if name != "mock": + raise ValueError(f"未知 adapter: {name}") + # P2 无真实宿主,用 tests/mock_host 的内存实现演示。 + try: + from mock_host import MockHostAdapter + except ImportError: # 直接运行时 tests/ 不在 sys.path,手动补上 + repo_root = Path(__file__).resolve().parents[3].parent # .../quercus + tests_dir = repo_root / "tests" + if str(tests_dir) not in sys.path: + sys.path.insert(0, str(tests_dir)) + from mock_host import MockHostAdapter + return MockHostAdapter() + + +def _make_provider(): + """有 key 用真实 provider;无 key 用降级 provider(不做对话编排)。""" + from quercus_core.config import load_config + from quercus_core.providers import CLAUDE, OPENAI_COMPAT, create_provider + + cfg = load_config() + for name in (CLAUDE, OPENAI_COMPAT): + try: + return create_provider(name, cfg) + except Exception: + continue + return _DegradedProvider() + + +class _DegradedProvider: + """无 key 降级:告知用户如何配置,不参与对话编排。""" + + name = "degraded" + + def generate(self, messages, tools=None): + from quercus_core.providers.base import AssistantTurn + + return AssistantTurn( + text=( + "未配置 LLM API key,无法进行 AI 对话编排。\n" + "请设置 QUERCUS_ANTHROPIC_API_KEY / QUERCUS_OPENAI_API_KEY " + "(或写入 ~/.quercus/config.toml,权限 0600)后重启面板。" + ) + ) + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/core/src/quercus_core/webui/app.py b/core/src/quercus_core/webui/app.py new file mode 100644 index 0000000..94d206b --- /dev/null +++ b/core/src/quercus_core/webui/app.py @@ -0,0 +1,135 @@ +"""FastAPI 应用工厂:聊天 + 待确认清单 + 进度 + 撤销会话 + 快照管理。 + +本模块属可选 extra ``core[webui]``(fastapi + uvicorn),不污染 base 依赖。 +与 P1 风格一致:**不引入 pydantic 模型**,请求体经 ``await request.json()`` +手工解析;dataclass + 同步代码。 +""" +from __future__ import annotations + +import json +import queue +from pathlib import Path + +from fastapi import FastAPI, HTTPException, Request +from fastapi.responses import HTMLResponse, StreamingResponse + +from quercus_core.webui.broker import EventBroker +from quercus_core.webui.session import WebSession + +_STATIC_DIR = Path(__file__).resolve().parent / "static" + + +def _project_summary(adapter) -> dict: + try: + ov = adapter.get_project_overview() + return {"name": ov.name, "fps": ov.fps, "timelines": len(ov.timeline_ids)} + except Exception: + return {} + + +def create_app( + adapter, + provider, + *, + executor=None, + confirm_timeout: float = 300.0, + session_log=None, + default_confirm: bool | None = None, +) -> FastAPI: + """创建 Web 面板应用。adapter 为 HostAdapter 实例,provider 为 LLMProvider。""" + broker = EventBroker() + session = WebSession( + adapter, + provider, + executor=executor, + confirm_timeout=confirm_timeout, + event_sink=lambda kind, payload: broker.publish({"kind": kind, **payload}), + session_log=session_log, + default_confirm=default_confirm, + ) + app = FastAPI(title="Quercus AI 剪辑助手", docs_url=None, redoc_url=None) + app.state.session = session + app.state.broker = broker + + @app.get("/", response_class=HTMLResponse) + def index() -> str: + return (_STATIC_DIR / "index.html").read_text(encoding="utf-8") + + @app.get("/api/status") + def status() -> dict: + return { + "ready": True, + "busy": session.is_busy, + "session_id": session.executor.session_id, + "project": _project_summary(adapter), + "pending": len(session.pending_batches()), + } + + @app.post("/api/chat") + async def chat(request: Request) -> dict: + body = await request.json() + text = (body.get("text") or "").strip() + if not text: + raise HTTPException(400, "text 不能为空") + if session.is_busy: + return {"ok": False, "reason": "上一轮对话仍在进行,请稍候"} + session.send_message(text) + return {"ok": True} + + @app.get("/api/pending") + def pending() -> dict: + return {"pending": session.pending_batches()} + + @app.post("/api/confirm/{batch_id}") + async def confirm(batch_id: str, request: Request) -> dict: + body = await request.json() + approved = bool(body.get("approved", False)) + ok = session.confirm_batch(batch_id, approved) + if not ok: + return {"ok": False, "reason": "批次不存在或已超时"} + return {"ok": True, "approved": approved} + + @app.post("/api/undo_session") + def undo_session() -> dict: + restored = session.undo_session() + return {"ok": True, "restored": restored} + + @app.get("/api/snapshots") + def snapshots() -> dict: + return {"snapshots": session.list_snapshots()} + + @app.post("/api/snapshots/{snapshot_id}/drop") + def drop_snapshot(snapshot_id: str) -> dict: + try: + session.drop_snapshot(snapshot_id) + except Exception as exc: + raise HTTPException(404, str(exc)) from exc + return {"ok": True} + + @app.get("/api/events") + def events() -> StreamingResponse: + """SSE 事件流:user/assistant/system 消息、pending、progress、tool_result。 + + 首条事件为 ``connected``(保证生成器已订阅、前端可感知连接就绪), + 之后的真实事件经 broker 队列投递。 + """ + q = broker.subscribe() + + def gen(): + try: + yield f"data: {json.dumps({'kind': 'connected'}, ensure_ascii=False)}\n\n" + while True: + try: + item = q.get(timeout=broker.keepalive_seconds) + except queue.Empty: + yield ": ping\n\n" + continue + if item is None: + break + yield f"data: {json.dumps(item, ensure_ascii=False)}\n\n" + finally: + broker.unsubscribe(q) + + return StreamingResponse(gen(), media_type="text/event-stream") + + return app diff --git a/core/src/quercus_core/webui/broker.py b/core/src/quercus_core/webui/broker.py new file mode 100644 index 0000000..349780d --- /dev/null +++ b/core/src/quercus_core/webui/broker.py @@ -0,0 +1,52 @@ +"""极简 SSE 事件广播器(发布/订阅,每订阅者一个队列)。 + +Web 面板与 AgentLoop 的交互用 **SSE + POST**(计划文档 §4 / 任务 3): +- 服务端 → 浏览器:SSE 事件流(chat / pending / progress / system); +- 浏览器 → 服务端:普通 POST(发消息、裁决、撤销、快照管理)。 + +选 SSE 而非 WebSocket:单向推送语义正好匹配,EventSource 自动重连, +实现简单可靠。 +""" +from __future__ import annotations + +import queue +import threading + + +class EventBroker: + """线程安全的发布/订阅;``publish`` 把事件投递给所有订阅者队列。""" + + def __init__(self, keepalive_seconds: float = 15.0) -> None: + self._queues: list[queue.Queue] = [] + self._lock = threading.Lock() + self._keepalive = keepalive_seconds + + @property + def keepalive_seconds(self) -> float: + return self._keepalive + + @property + def subscriber_count(self) -> int: + with self._lock: + return len(self._queues) + + def subscribe(self) -> queue.Queue: + """注册订阅,返回接收队列。""" + q: queue.Queue = queue.Queue(maxsize=128) + with self._lock: + self._queues.append(q) + return q + + def unsubscribe(self, q: queue.Queue) -> None: + with self._lock: + if q in self._queues: + self._queues.remove(q) + + def publish(self, event: dict) -> None: + with self._lock: + queues = list(self._queues) + for q in queues: + try: + q.put_nowait(event) + except queue.Full: + pass # 客户端太慢则丢事件,不阻塞宿主线程 diff --git a/core/src/quercus_core/webui/session.py b/core/src/quercus_core/webui/session.py new file mode 100644 index 0000000..bd8dc83 --- /dev/null +++ b/core/src/quercus_core/webui/session.py @@ -0,0 +1,202 @@ +"""Web 会话:AgentLoop 的确认门接到 HTTP 端点(待确认清单)。 + +一次 Web 会话 = adapter + executor + provider + AgentLoop 的接线: +- ``send_message`` 在后台线程跑 AgentLoop(confirm 会阻塞等用户裁决); +- ``_confirm`` 回调把批次登记进 ``self._pending`` 并阻塞等待,HTTP 端点 + (approve/reject)裁决,**超时默认拒绝**; +- 事件经 ``event_sink(kind, payload)`` 上抛(Web 层接到 SSE 流)。 +""" +from __future__ import annotations + +import base64 +import threading +from dataclasses import dataclass, field +from typing import Any, Callable + +from quercus_core.agent.loop import AgentLoop +from quercus_core.types import Action, ActionBatch, ToolResult + + +@dataclass +class _Pending: + """一份待裁决的批次:event 被 HTTP 端点 set,approved 记录裁决。""" + + batch: ActionBatch + event: threading.Event = field(default_factory=threading.Event) + approved: bool = False + + +class _EventingExecutor: + """把 ToolResult(含 PNG 帧图)转发给事件流,供 Web 面板显示。""" + + def __init__( + self, + inner, + event_sink: Callable[[str, dict], None], + ) -> None: + self._inner = inner + self._event_sink = event_sink + + def execute(self, action: Action) -> ToolResult: + result = self._inner.execute(action) + self._event_sink( + "tool_result", + { + "tool": result.tool, + "ok": result.ok, + "summary": result.summary, + "error": result.error, + "images": [base64.b64encode(img).decode("ascii") for img in result.images], + }, + ) + return result + + +class WebSession: + """一个 Web 会话。``default_confirm`` 仅在测试注入固定裁决时使用。""" + + def __init__( + self, + adapter, + provider, + *, + executor=None, + confirm_timeout: float = 300.0, + event_sink: Callable[[str, dict], None] | None = None, + session_log=None, + default_confirm: bool | None = None, + ) -> None: + self._adapter = adapter + self._provider = provider + self._executor = executor + if self._executor is None: + from quercus_core.tools.executor import HostToolExecutor + + self._executor = HostToolExecutor(adapter) + self._confirm_timeout = confirm_timeout + self._event_sink = event_sink or (lambda kind, payload: None) + self._session_log = session_log + self._default_confirm = default_confirm + self._pending: dict[str, _Pending] = {} + self._lock = threading.Lock() + self._threads: list[threading.Thread] = [] + self._finished = threading.Event() + self._finished.set() + + # ---- 属性 ---- + + @property + def adapter(self): + return self._adapter + + @property + def executor(self): + return self._executor + + @property + def is_busy(self) -> bool: + return not self._finished.is_set() + + def wait_done(self, timeout: float = 5.0) -> bool: + """等待当前对话回合结束(测试用)。返回是否在超时前完成。""" + return self._finished.wait(timeout) + + def pending_batches(self) -> list[dict]: + """当前待确认清单(HTTP GET /api/pending 与测试共用)。""" + with self._lock: + return [self._pending_to_dict(p) for p in self._pending.values()] + + @staticmethod + def _pending_to_dict(p: _Pending) -> dict: + b = p.batch + return { + "batch_id": b.id, + "label": b.label, + "created_at": b.created_at, + "actions": [{"tool": a.tool, "params": a.params} for a in b.actions], + } + + # ---- 对话 ---- + + def send_message(self, text: str) -> None: + thread = threading.Thread( + target=self._run_agent, args=(text,), daemon=True, name="webui-agent" + ) + self._threads.append(thread) + self._finished.clear() + thread.start() + + def _run_agent(self, text: str) -> None: + self._event_sink("user_message", {"text": text}) + executor = _EventingExecutor(self._executor, self._event_sink) + loop = AgentLoop( + provider=self._provider, + executor=executor, + confirm=self._confirm, + session=self._session_log, + ) + try: + result = loop.run(text) + self._event_sink( + "assistant_message", + {"text": result.final_text or "(完成,无文本)", "turns": result.turns}, + ) + except Exception as exc: # 后台线程不能带异常死亡,转成系统消息 + self._event_sink("system", {"text": f"对话异常:{exc}"}) + finally: + self._finished.set() + + def _confirm(self, batch: ActionBatch) -> bool: + """AgentLoop 的确认门:登记待确认清单并阻塞等待用户裁决(超时默认拒绝)。""" + if self._default_confirm is not None: + return self._default_confirm + pending = _Pending(batch=batch) + with self._lock: + self._pending[batch.id] = pending + self._event_sink("pending_batch", self._pending_to_dict(pending)) + decided = pending.event.wait(timeout=self._confirm_timeout) + with self._lock: + self._pending.pop(batch.id, None) + if not decided: + self._event_sink("system", {"text": f"待确认批次「{batch.label}」等待超时,默认拒绝"}) + return False + return pending.approved + + def confirm_batch(self, batch_id: str, approved: bool) -> bool: + """HTTP 端点裁决:找到待裁决批次则 set event 返回 True,否则 False。""" + with self._lock: + pending = self._pending.get(batch_id) + if pending is None: + return False + pending.approved = approved + pending.event.set() + self._event_sink( + "system", {"text": f"批次「{pending.batch.label}」已{'执行' if approved else '拒绝'}"} + ) + return True + + # ---- 撤销 / 快照管理 ---- + + def undo_session(self) -> int: + n = self._executor.undo_session() + self._event_sink( + "system", + {"text": f"已撤销本会话 {n} 个批次(时间线已恢复原状)" if n else "本会话无可撤销批次"}, + ) + return n + + def list_snapshots(self) -> list[dict]: + return [ + { + "id": s.id, + "session_id": s.session_id, + "created_at": s.created_at, + "label": s.label, + "action_count": s.action_count, + } + for s in self._executor.list_snapshots() + ] + + def drop_snapshot(self, snapshot_id: str) -> None: + self._executor.drop_snapshot(snapshot_id) + self._event_sink("system", {"text": f"已清理快照 {snapshot_id}"}) diff --git a/core/src/quercus_core/webui/static/index.html b/core/src/quercus_core/webui/static/index.html new file mode 100644 index 0000000..6f0a1f9 --- /dev/null +++ b/core/src/quercus_core/webui/static/index.html @@ -0,0 +1,221 @@ + + + + +Quercus AI 剪辑助手 + + + +
+

Quercus AI 剪辑助手

+ 连接中… + + + +
+
+
+ +
+ + +
+
+ + + + diff --git a/pyproject.toml b/pyproject.toml index c3392be..364845c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -10,12 +10,17 @@ dependencies = [ [dependency-groups] dev = [ "pytest>=8.0", + "quercus-core[webui]", ] [tool.uv] # 根目录不是可安装包,仅作 workspace 锚点 package = false +[[tool.uv.index]] +url = "https://pypi.org/simple" +default = true + [tool.uv.sources] quercus-core = { workspace = true } diff --git a/tests/core/test_acceptance_p2.py b/tests/core/test_acceptance_p2.py new file mode 100644 index 0000000..acbf49f --- /dev/null +++ b/tests/core/test_acceptance_p2.py @@ -0,0 +1,85 @@ +"""P2 验收链路:mock 宿主上"导入 → 铺轨 → 切/重建 → 删除 → 加效果 → 取帧验证 → +整段会话撤销"全链路自动化通过(计划文档 §4 P2 验收)。 + +关键断言:帧非全黑、编辑前后切口帧像素不同、undo_session 后时间线恢复原状。 +""" +import hashlib + +from mock_host import MockHostAdapter, decode_png, frame_is_black +from quercus_core.tools.executor import HostToolExecutor +from quercus_core.types import Action, Rational + +FRAME_SIZE = {"width": 64, "height": 36} + + +def r(x: int) -> dict: + return {"num": x, "den": 1} + + +def _frame_at(executor, t_json, size=FRAME_SIZE): + result = executor.execute(Action(tool="get_frame", params={"time": t_json, "max_size": size})) + assert result.ok, f"get_frame 失败: {result.summary}" + assert len(result.images) == 1 + return result.images[0] + + +def _sha(png: bytes) -> str: + return hashlib.sha256(png).hexdigest() + + +def _first_clips(adapter): + seq_id = adapter.get_project_overview().timeline_ids[0] + return adapter.get_timeline_structure(seq_id).tracks[0].clips + + +def test_p2_acceptance_chain(): + adapter = MockHostAdapter() + executor = HostToolExecutor(adapter) + + def act(tool, **params): + result = executor.execute(Action(tool=tool, params=params)) + assert result.ok, f"{tool} 失败: {result.summary}" + return result + + # 1) 导入两个素材 + act("import_footage", paths=["/media/alpha.mp4", "/media/beta.mp4"]) + footage = {it["name"]: it["id"] for it in adapter.list_footage()} + f_alpha = footage["alpha.mp4"] + f_beta = footage["beta.mp4"] + + # 2) 铺轨:alpha[0,4) + beta[4,8) + act("place_clip", clip_id=f_alpha, track_index=1, time=r(0), in_point=r(0), out_point=r(4)) + act("place_clip", clip_id=f_beta, track_index=1, time=r(4), in_point=r(0), out_point=r(4)) + clip_a = _first_clips(adapter)[0] + + # 3) 切口帧(编辑前):t=3 处是 alpha 的画面 + frame_before = _frame_at(executor, r(3)) + assert not frame_is_black(frame_before, 64, 36), "编辑前切口帧不得全黑" + decode_png(frame_before) # 必须是合法 PNG + + # 4) 在 t=2 处切开 alpha + act("split_clip", clip_id=clip_a.id, time=r(2)) + right_half = next(c for c in _first_clips(adapter) if c.start == Rational(2)) + + # 5) 波纹删除右半段 → beta 前移到 [2,6) + act("ripple_delete", clip_ids=[right_half.id]) + frame_after_cut = _frame_at(executor, r(3)) + assert not frame_is_black(frame_after_cut, 64, 36) + # 编辑前后切口帧像素不同(beta 代替了 alpha 出现在 t=3) + assert _sha(frame_after_cut) != _sha(frame_before), "切口帧像素应随编辑变化" + + # 6) 给 beta 加效果并设参数 → 画面再变 + beta_clip = next(c for c in _first_clips(adapter) if c.source == f_beta) + act("add_effect", clip_id=beta_clip.id, effect_type="brightness") + node_id = adapter._must_clip(beta_clip.id).effects[0].node_id # 白盒取节点 + act("set_param", effect_id=node_id, param="level", value=0.5) + frame_after_effect = _frame_at(executor, r(3)) + assert not frame_is_black(frame_after_effect, 64, 36) + assert _sha(frame_after_effect) != _sha(frame_after_cut), "加效果后画面应改变" + + # 7) 整段会话撤销 → 时间线恢复原状(回到首个变更批次之前:空) + restored = executor.undo_session() + assert restored >= 6, f"应撤销 ≥6 个批次,实际 {restored}" + overview = adapter.get_project_overview() + assert overview.timeline_ids == [] # 无时间线 = 铺轨前的原状 + assert adapter.list_footage() == [] # 连导入也被撤销(快照覆盖首个批次) diff --git a/tests/core/test_executor.py b/tests/core/test_executor.py new file mode 100644 index 0000000..dc6b43c --- /dev/null +++ b/tests/core/test_executor.py @@ -0,0 +1,363 @@ +"""工具适配层测试:26 个工具 handler 在 mock 宿主上逐个过一遍 + get_params 回填。 + +每个工具至少一个用例;变更类工具经 executor 走 adapter.execute 快照批次。 +""" +import re + +import pytest + +from mock_host import MockHostAdapter, decode_png +from quercus_core.tools.executor import HostToolExecutor +from quercus_core.tools.schemas import TOOL_LIST, TOOLS +from quercus_core.types import Action, ActionBatch, Rational + + +def r(x: int) -> dict: + return {"num": x, "den": 1} + + +@pytest.fixture +def fresh(): + """预铺好的 mock 宿主 + 执行器(alpha[0,4) + beta[4,8) 在轨道 1)。""" + adapter = MockHostAdapter() + executor = HostToolExecutor(adapter) + f_alpha, f_beta = adapter.import_footage(["/media/alpha.mp4", "/media/beta.mp4"]) + adapter.execute( + ActionBatch( + label="setup", + actions=[ + Action( + tool="place_clip", + params={"clip_id": f_alpha, "track_index": 1, "time": r(0), "in_point": r(0), "out_point": r(4)}, + ), + Action( + tool="place_clip", + params={"clip_id": f_beta, "track_index": 1, "time": r(4), "in_point": r(0), "out_point": r(4)}, + ), + ], + ) + ) + return adapter, executor + + +def _seq(adapter): + return adapter.get_project_overview().timeline_ids[0] + + +def _clips(adapter): + return adapter.get_timeline_structure(_seq(adapter)).tracks[0].clips + + +def _exec(executor, tool, **params): + return executor.execute(Action(tool=tool, params=params)) + + +def test_all_26_tools_registered(): + assert len(TOOLS) == 26 + assert len(TOOL_LIST) == 26 + + +# ---- 工程 ---- + +def test_open_project(fresh): + adapter, executor = fresh + result = _exec(executor, "open_project", path="/tmp/demo.qproj") + assert result.ok + assert "已打开" in result.summary + assert adapter.get_project_overview().name == "demo" + + +def test_save_project(fresh): + adapter, executor = fresh + result = _exec(executor, "save_project", path="/tmp/demo.qproj") + assert result.ok + assert adapter._state.project.path == "/tmp/demo.qproj" + assert adapter._state.project.modified is False + + +def test_get_project_overview(fresh): + adapter, executor = fresh + result = _exec(executor, "get_project_overview") + assert result.ok + assert "工程:" in result.summary + assert "时间线" in result.summary + + +# ---- 媒体 ---- + +def test_probe_media(fresh): + adapter, executor = fresh + result = _exec(executor, "probe_media", path="/media/alpha.mp4") + assert result.ok + assert "分辨率" in result.summary + + +def test_import_footage(fresh): + adapter, executor = fresh + result = _exec(executor, "import_footage", paths=["/media/gamma.mp4"]) + assert result.ok + assert any("gamma" in it["path"] for it in adapter.list_footage()) + + +def test_list_footage(fresh): + adapter, executor = fresh + result = _exec(executor, "list_footage") + assert result.ok + assert "2" in result.summary + + +# ---- 时间线 ---- + +def test_add_track(fresh): + adapter, executor = fresh + result = _exec(executor, "add_track", type="audio") + assert result.ok + tl = adapter.get_timeline_structure(_seq(adapter)) + assert any(t.kind == "audio" for t in tl.tracks) + + +def test_place_clip(fresh): + adapter, executor = fresh + fid = adapter.list_footage()[0]["id"] + result = _exec(executor, "place_clip", clip_id=fid, track_index=2, time=r(0), in_point=r(0), out_point=r(2)) + assert result.ok + assert "已放置" in result.summary + tl = adapter.get_timeline_structure(_seq(adapter)) + placed = [c for t in tl.tracks if t.index == 2 for c in t.clips] + assert len(placed) == 1 and placed[0].duration == Rational(2) + + +def test_split_clip(fresh): + adapter, executor = fresh + clip_id = _clips(adapter)[0].id + result = _exec(executor, "split_clip", clip_id=clip_id, time=r(2)) + assert result.ok + assert len(_clips(adapter)) == 3 # 左右两段 + beta + + +def test_trim_clip(fresh): + adapter, executor = fresh + clip_id = _clips(adapter)[0].id + result = _exec(executor, "trim_clip", clip_id=clip_id, in_point=r(1), duration=r(2)) + assert result.ok + clip = next(c for c in _clips(adapter) if c.id == clip_id) + assert clip.duration == Rational(2) + assert clip.in_point == Rational(1) + + +def test_move_clip(fresh): + adapter, executor = fresh + clip_id = _clips(adapter)[0].id + result = _exec(executor, "move_clip", clip_id=clip_id, track_index=2, time=r(1)) + assert result.ok + tl = adapter.get_timeline_structure(_seq(adapter)) + moved = [c for t in tl.tracks if t.index == 2 for c in t.clips] + assert len(moved) == 1 and moved[0].start == Rational(1) + + +def test_ripple_delete(fresh): + adapter, executor = fresh + clip_a = _clips(adapter)[0].id + result = _exec(executor, "ripple_delete", clip_ids=[clip_a]) + assert result.ok + clips = _clips(adapter) + assert len(clips) == 1 + assert clips[0].start == Rational(0) # beta 前移到开头(空隙闭合) + + +def test_add_transition(fresh): + adapter, executor = fresh + clip_id = _clips(adapter)[0].id + result = _exec(executor, "add_transition", clip_id=clip_id, transition_type="cross_dissolve", duration=r(1)) + assert result.ok + assert "cross_dissolve" in result.summary + + +def test_add_marker(fresh): + adapter, executor = fresh + result = _exec(executor, "add_marker", time=r(1), label="打点", color="red", custom_data={"ai": True}) + assert result.ok + seq = adapter._must_seq(_seq(adapter)) + assert len(seq.markers) == 1 and seq.markers[0].label == "打点" + + +# ---- 效果 / 关键帧 ---- + +def test_add_effect(fresh): + adapter, executor = fresh + clip_id = _clips(adapter)[0].id + result = _exec(executor, "add_effect", clip_id=clip_id, effect_type="brightness") + assert result.ok + assert "brightness" in result.summary + assert len(adapter._must_clip(clip_id).effects) == 1 + + +def test_set_param(fresh): + adapter, executor = fresh + clip_id = _clips(adapter)[0].id + _exec(executor, "add_effect", clip_id=clip_id, effect_type="brightness") + node = adapter._must_clip(clip_id).effects[0].node_id + result = _exec(executor, "set_param", effect_id=node, param="level", value=0.5) + assert result.ok + assert adapter._must_effect(node).params["level"] == 0.5 + + +def test_set_keyframe(fresh): + adapter, executor = fresh + clip_id = _clips(adapter)[0].id + _exec(executor, "add_effect", clip_id=clip_id, effect_type="brightness") + node = adapter._must_clip(clip_id).effects[0].node_id + result = _exec(executor, "set_keyframe", effect_id=node, param="level", time=r(1), value=0.8) + assert result.ok + assert adapter._must_effect(node).keyframes["level"][0].value == 0.8 + + +def test_list_effects_available_and_on_clip(fresh): + adapter, executor = fresh + result = _exec(executor, "list_effects") + assert result.ok + assert "brightness" in result.summary + clip_id = _clips(adapter)[0].id + _exec(executor, "add_effect", clip_id=clip_id, effect_type="crop") + result2 = _exec(executor, "list_effects", clip_id=clip_id) + assert result2.ok + assert "crop" in result2.summary + + +# ---- 取帧 ---- + +def test_get_frame(fresh): + adapter, executor = fresh + result = _exec(executor, "get_frame", time=r(1), max_size={"width": 64, "height": 36}) + assert result.ok + assert len(result.images) == 1 + assert result.images[0].startswith(b"\x89PNG\r\n\x1a\n") + decode_png(result.images[0]) # 合法 PNG + + +def test_scan_timeline(fresh): + adapter, executor = fresh + result = _exec(executor, "scan_timeline", range={"start": r(0), "end": r(4)}, count=4) + assert result.ok + assert len(result.images) == 4 + for img in result.images: + decode_png(img) + + +def test_get_audio_levels(fresh): + adapter, executor = fresh + result = _exec(executor, "get_audio_levels", range={"start": r(0), "end": r(4)}, resolution=16) + assert result.ok + assert "电平" in result.summary + + +# ---- 回放 ---- + +def test_play_pause_seek(fresh): + adapter, executor = fresh + assert _exec(executor, "play").ok + assert _exec(executor, "seek", time=r(2)).ok + state = adapter.get_state() + assert state.playing is True and state.position == Rational(2) + assert _exec(executor, "pause").ok + assert adapter.get_state().playing is False + + +# ---- 导出 / 撤销 ---- + +def test_export_render(fresh): + adapter, executor = fresh + result = _exec(executor, "export_render", output="/tmp/out.mp4", preset="h264") + assert result.ok + assert "导出" in result.summary + + +def test_undo_last_action(fresh): + adapter, executor = fresh + before = len(_clips(adapter)) + _exec(executor, "add_marker", time=r(1)) + result = _exec(executor, "undo_last_action") + assert result.ok + assert len(_clips(adapter)) == before # 快照恢复到 add_marker 前 + assert "撤销" in result.summary + + +# ---- get_params 回填(min/max/choices 供未来 schema 回填) ---- + +def test_get_params_backfill(fresh): + adapter, executor = fresh + brightness = executor.get_params("brightness") + level = next(p for p in brightness if p["name"] == "level") + assert level["min"] == -1.0 and level["max"] == 1.0 + blend = next(p for p in executor.get_params("overlay") if p["name"] == "blend_mode") + assert blend["choices"] == ["normal", "screen", "multiply"] + assert executor.get_params("no_such") == [] # 未知效果类型优雅降级 + + +def test_set_param_rejects_invalid_choice(fresh): + adapter, executor = fresh + clip_id = _clips(adapter)[0].id + _exec(executor, "add_effect", clip_id=clip_id, effect_type="overlay") + node = adapter._must_clip(clip_id).effects[0].node_id + result = _exec(executor, "set_param", effect_id=node, param="blend_mode", value="blur") + assert result.ok is False + assert "非法" in result.summary or "失败" in result.summary + + +# ---- 批级执行(一个批次一个快照) ---- + +def test_execute_batch_single_snapshot(fresh): + adapter, executor = fresh + before = len(executor.list_snapshots()) + batch = ActionBatch( + label="two-markers", + session_id=executor.session_id, + actions=[ + Action(tool="add_marker", params={"time": r(1), "label": "m1"}), + Action(tool="add_marker", params={"time": r(2), "label": "m2"}), + ], + ) + results = executor.execute_batch(batch) + assert all(res.ok for res in results) + assert len(executor.list_snapshots()) == before + 1 # 一批次一个快照 + + +# ---- AgentLoop 集成:mock LLM 派发到 executor ---- + +def test_agent_loop_with_host_executor(fresh): + from mock_llm import MockProvider + from quercus_core.agent.loop import AgentLoop + from quercus_core.providers.base import AssistantTurn, ToolCall + + adapter, executor = fresh + fid = adapter.list_footage()[0]["id"] + mock = MockProvider( + script=[ + AssistantTurn( + text="", + tool_calls=( + ToolCall( + id="c1", + name="add_marker", + arguments={"time": {"num": 3, "den": 1}, "label": "AI 打点"}, + ), + ToolCall( + id="c2", + name="get_frame", + arguments={"time": {"num": 1, "den": 1}, "max_size": {"width": 64, "height": 36}}, + ), + ), + ), + AssistantTurn(text="完成", tool_calls=()), + ] + ) + loop = AgentLoop(provider=mock, executor=executor, confirm=lambda batch: True) + result = loop.run("在 3 秒处打点并看一眼画面") + assert result.turns == 2 + seq = adapter._must_seq(_seq(adapter)) + assert any(m.label == "AI 打点" for m in seq.markers) # 变更已生效 + # get_frame 的 PNG 回喂给 provider + last = mock.observed[-1] + tool_msgs = [m for m in last if m.role == "tool"] + frame_msg = next(m for m in tool_msgs if m.name == "get_frame") + assert len(frame_msg.images) == 1 diff --git a/tests/core/test_frame_pipeline.py b/tests/core/test_frame_pipeline.py new file mode 100644 index 0000000..16486f5 --- /dev/null +++ b/tests/core/test_frame_pipeline.py @@ -0,0 +1,112 @@ +"""取帧流水线测试:mock 宿主限流下,批量扫描经退避最终成功且不超限流。 + +- 前置令牌桶按 ``Limits`` 限速 → 扫描耗时受速率约束(不超发); +- 宿主 ``RateLimited`` → 执行器按 retry_after_ms 有限退避重试(不轰炸); +- 批量取帧并发执行(串行等帧被禁止)。 +""" +import time + +import pytest + +from mock_host import MockHostAdapter, frame_is_black +from quercus_core.host.errors import RateLimited +from quercus_core.host.types import Target +from quercus_core.tools.executor import HostToolExecutor +from quercus_core.tools.ratelimit import TokenBucket +from quercus_core.types import Action, Limits, Rational, Size + + +def r(x: int) -> dict: + return {"num": x, "den": 1} + + +def setup_clip(adapter: MockHostAdapter) -> None: + """铺一条 alpha[0,4) 的基础时间线(供扫描取帧)。""" + fid = adapter.import_footage(["/media/alpha.mp4"])[0] + adapter._ensure_sequence() + from quercus_core.types import ActionBatch + + adapter.execute( + ActionBatch( + label="setup", + actions=[ + Action( + tool="place_clip", + params={"clip_id": fid, "track_index": 1, "time": r(0), "in_point": r(0), "out_point": r(4)}, + ) + ], + ) + ) + + +def test_scan_concurrent_under_rate_limit(): + """默认 8 帧/s burst 4:8 帧扫描应在 ~0.5s 内完成且全部返回。""" + adapter = MockHostAdapter() # 默认 limits:8 帧/s,burst 4 + executor = HostToolExecutor(adapter, frame_concurrency=4) + setup_clip(adapter) + + t0 = time.monotonic() + result = executor.execute( + Action(tool="scan_timeline", params={"range": {"start": r(0), "end": r(4)}, "count": 8}) + ) + elapsed = time.monotonic() - t0 + + assert result.ok, result.summary + assert len(result.images) == 8 + # 前置令牌桶按 8/s 限速:burst 4 之外每帧至少 0.125s → 总耗时 ≥ ~0.5s + assert elapsed >= 0.4, f"批量扫描不应超发令牌(耗时 {elapsed:.2f}s)" + assert elapsed <= 5.0 + for img in result.images: + assert not frame_is_black(img, 480, 270) + + +def test_scan_backoff_on_rate_limited(): + """宿主限流严格(4 帧/s burst 2)、执行器前置桶宽松 → 必须走 RateLimited 退避。""" + adapter = MockHostAdapter(limits=Limits(max_frame_rate=4.0, frame_burst=2)) + executor = HostToolExecutor( + adapter, + frame_concurrency=4, + rate_limiter=TokenBucket(capacity=64, refill_rate=1e6), # 前置桶放得很宽 + ) + setup_clip(adapter) + + t0 = time.monotonic() + result = executor.execute( + Action(tool="scan_timeline", params={"range": {"start": r(0), "end": r(4)}, "count": 6}) + ) + elapsed = time.monotonic() - t0 + + assert result.ok, result.summary + assert len(result.images) == 6 + assert adapter._rate_limited_count > 0, "应真实触发宿主限流" + # (6-2)/4 = 1.0s 的令牌补充时间,退避后成功 + assert elapsed >= 0.8, f"退避耗时不足({elapsed:.2f}s)" + assert elapsed <= 6.0 + + +def test_fetch_retries_exhausted_reports_error(): + """令牌彻底耗尽且超过重试上限时,错误回喂而非无限轰炸。""" + adapter = MockHostAdapter(limits=Limits(max_frame_rate=1.0, frame_burst=1)) + executor = HostToolExecutor( + adapter, + frame_concurrency=1, + max_frame_retries=0, # 不允许重试 + rate_limiter=TokenBucket(capacity=64, refill_rate=1e6), + ) + setup_clip(adapter) + result = executor.execute( + Action(tool="scan_timeline", params={"range": {"start": r(0), "end": r(4)}, "count": 3}) + ) + assert result.ok is False + assert result.images # 至少第一帧成功 + assert "限流" in (result.error or "") or "失败" in (result.summary or "") + + +def test_rate_limited_raised_directly(): + """get_frame 遇限流直接抛 RateLimited(退避逻辑由流水线负责)。""" + adapter = MockHostAdapter(limits=Limits(max_frame_rate=1.0, frame_burst=1)) + setup_clip(adapter) + seq = adapter.get_project_overview().timeline_ids[0] + adapter.get_frame(Target(kind="timeline", id=seq), Rational(1), Size(32, 18)) + with pytest.raises(RateLimited): + adapter.get_frame(Target(kind="timeline", id=seq), Rational(2), Size(32, 18)) diff --git a/tests/core/test_mock_host.py b/tests/core/test_mock_host.py new file mode 100644 index 0000000..129d4c7 --- /dev/null +++ b/tests/core/test_mock_host.py @@ -0,0 +1,267 @@ +"""MockHostAdapter 行为测试:id 失效、限流、PNG 合法非全黑随时间变化、快照/恢复。 + +mock 宿主是未来 Oak/Resolve/Premiere 适配层的验收替身(计划文档 §3.3 / §7), +这些断言即未来真实适配层要达标的行为契约。 +""" +import hashlib + +import pytest + +from mock_host import MockHostAdapter, decode_png, frame_is_black +from quercus_core.host.errors import EntityNotFound, RateLimited +from quercus_core.host.types import Target +from quercus_core.types import ( + Action, + ActionBatch, + Capabilities, + Limits, + Rational, + Size, + TimeRange, +) + + +def r(x: int) -> dict: + """有理秒参数 {num, den}。""" + return {"num": x, "den": 1} + + +def setup_base(adapter: MockHostAdapter) -> None: + """铺一条基础时间线:alpha[0,4) + beta[4,8) 在轨道 1。""" + f_alpha, f_beta = adapter.import_footage(["/media/alpha.mp4", "/media/beta.mp4"]) + adapter.execute( + ActionBatch( + label="setup", + actions=[ + Action( + tool="place_clip", + params={ + "clip_id": f_alpha, + "track_index": 1, + "time": r(0), + "in_point": r(0), + "out_point": r(4), + }, + ), + Action( + tool="place_clip", + params={ + "clip_id": f_beta, + "track_index": 1, + "time": r(4), + "in_point": r(0), + "out_point": r(4), + }, + ), + ], + ) + ) + + +def _first_seq(adapter: MockHostAdapter): + return adapter.get_project_overview().timeline_ids[0] + + +def _timeline_clips(adapter: MockHostAdapter): + return adapter.get_timeline_structure(_first_seq(adapter)).tracks[0].clips + + +def _sha(png: bytes) -> str: + return hashlib.sha256(png).hexdigest() + + +# ---- 能力与限制 ---- + +def test_capabilities_and_limits(): + a = MockHostAdapter() + assert Capabilities.TIMELINE_EDIT in a.capabilities() + assert a.limits().max_frame_rate == 8.0 + assert a.limits().frame_burst == 4 + + +def test_entity_ids_are_opaque_strings(): + a = MockHostAdapter() + ids = a.import_footage(["/media/x.mp4"]) + assert isinstance(ids[0], str) + assert ids[0].startswith("ft_") + + +# ---- id 失效(删除后 EntityNotFound) ---- + +def test_clip_id_invalid_after_delete(): + a = MockHostAdapter() + setup_base(a) + clip_id = _timeline_clips(a)[0].id + a.execute(ActionBatch(label="del", actions=[Action(tool="ripple_delete", params={"clip_ids": [clip_id]})])) + with pytest.raises(EntityNotFound): + a.get_frame(Target(kind="clip", id=clip_id), Rational(1), Size(32, 18)) + + +def test_unknown_footage_raises_entity_not_found(): + a = MockHostAdapter() + with pytest.raises(EntityNotFound): + a.get_timeline_structure("no_such_seq") + + +def test_unknown_clip_place_fails(): + a = MockHostAdapter() + br = a.execute( + ActionBatch( + label="t", + actions=[Action(tool="place_clip", params={"clip_id": "nope", "track_index": 1, "time": r(0)})], + ) + ) + assert br.ok is False + assert "不存在" in br.results[0].summary + + +# ---- 限流模拟 ---- + +def test_rate_limited_raised_with_retry_after(): + a = MockHostAdapter(limits=Limits(max_frame_rate=1.0, frame_burst=1)) + setup_base(a) + seq = _first_seq(a) + a.get_frame(Target(kind="timeline", id=seq), Rational(1), Size(32, 18)) # 耗掉唯一令牌 + with pytest.raises(RateLimited) as ei: + a.get_frame(Target(kind="timeline", id=seq), Rational(2), Size(32, 18)) + assert ei.value.retry_after_ms > 0 + assert a._rate_limited_count >= 1 + + +# ---- 帧:合法 PNG、非全黑、随时间变化 ---- + +def test_frame_png_valid_nonblack(): + a = MockHostAdapter() + setup_base(a) + seq = _first_seq(a) + png = a.get_frame(Target(kind="timeline", id=seq), Rational(1), Size(64, 36)) + assert png.startswith(b"\x89PNG\r\n\x1a\n") + rgb = decode_png(png) # 解不开即抛 ValueError(非法 PNG) + assert len(rgb) == 64 * 36 * 3 + assert not frame_is_black(png, 64, 36) + + +def test_frame_pixels_vary_with_time(): + a = MockHostAdapter() + setup_base(a) + seq = _first_seq(a) + p1 = a.get_frame(Target(kind="timeline", id=seq), Rational(1), Size(64, 36)) + p2 = a.get_frame(Target(kind="timeline", id=seq), Rational(2), Size(64, 36)) + assert _sha(p1) != _sha(p2) + assert decode_png(p1) != decode_png(p2) + + +def test_thumbnails_valid_and_distinct(): + a = MockHostAdapter(limits=Limits(max_frame_rate=1000.0, frame_burst=100)) + setup_base(a) + seq = _first_seq(a) + thumbs = a.get_thumbnails(Target(kind="timeline", id=seq), TimeRange(Rational(0), Rational(4)), 5) + assert len(thumbs) == 5 + hashes = {_sha(t) for t in thumbs} + assert len(hashes) == 5 # 各采样点画面不同 + for t in thumbs: + assert not frame_is_black(t, 480, 270) + + +# ---- 快照 / 恢复 / 清理 ---- + +def test_snapshot_restore_and_drop(): + a = MockHostAdapter() + setup_base(a) # 直接 import_footage + execute(setup 批次) → 快照 "setup" + assert len(_timeline_clips(a)) == 2 + a.execute( + ActionBatch(label="mark", session_id="s1", actions=[Action(tool="add_marker", params={"time": r(2)})]) + ) + snaps = a.list_snapshots() + assert {s.label for s in snaps} == {"setup", "mark"} + mark_snap = next(s for s in snaps if s.label == "mark") + assert mark_snap.session_id == "s1" # 快照带会话 id + assert mark_snap.created_at # 快照带时间戳 + # 恢复 setup 快照 → 回到铺轨前:无序列无片段,但直接导入的素材仍在 + setup_snap = next(s for s in a.list_snapshots() if s.label == "setup") + a.restore_snapshot(setup_snap.id) + assert a.get_project_overview().timeline_ids == [] + assert len(a.list_footage()) == 2 + # 清理快照后不可再恢复 + a.drop_snapshot(mark_snap.id) + with pytest.raises(EntityNotFound): + a.restore_snapshot(mark_snap.id) + + +def test_snapshot_cap_eviction(): + a = MockHostAdapter(limits=Limits(max_snapshot_count=2)) + for i in range(3): + a.execute( + ActionBatch( + label=f"b{i}", + actions=[Action(tool="add_marker", params={"time": r(i + 1)})], + ) + ) + snaps = a.list_snapshots() + assert len(snaps) == 2 # 最旧的被淘汰 + assert {s.label for s in snaps} == {"b1", "b2"} + + +def test_undo_last_restores_newest_snapshot(): + a = MockHostAdapter() + setup_base(a) + before = len(_timeline_clips(a)) + a.execute(ActionBatch(label="m1", actions=[Action(tool="add_marker", params={"time": r(1)})])) + a.execute(ActionBatch(label="m2", actions=[Action(tool="add_marker", params={"time": r(2)})])) + a.undo_last() # 撤销 m2 + a.undo_last() # 撤销 m1 + # 时间线片段数回到 setup 后的状态 + assert len(_timeline_clips(a)) == before + + +# ---- 事件订阅 ---- + +def test_event_subscription(): + a = MockHostAdapter() + seen: list[str] = [] + unsub = a.subscribe(lambda ev: seen.append(ev.kind)) + setup_base(a) + assert "structure_changed" in seen + unsub() + seen.clear() + setup_base(a) # 不再通知 + assert seen == [] + + +# ---- 音频电平 / 效果参数 ---- + +def test_audio_levels_shape(): + a = MockHostAdapter() + setup_base(a) + levels = a.get_audio_levels(_first_seq(a), TimeRange(Rational(0), Rational(4)), 16) + assert len(levels.values) == 16 + assert min(levels.values) >= levels.min_db + + +def test_effect_params_min_max_choices(): + a = MockHostAdapter() + params = a.get_effect_params("brightness") + level = next(p for p in params if p["name"] == "level") + assert level["min"] == -1.0 and level["max"] == 1.0 + blend = next(p for p in a.get_effect_params("overlay") if p["name"] == "blend_mode") + assert blend["choices"] == ["normal", "screen", "multiply"] + with pytest.raises(EntityNotFound): + a.get_effect_params("no_such_effect") + + +def test_set_param_rejects_out_of_range(): + a = MockHostAdapter() + setup_base(a) + clip_id = _timeline_clips(a)[0].id + a.execute( + ActionBatch( + label="fx", + actions=[Action(tool="add_effect", params={"clip_id": clip_id, "effect_type": "brightness"})], + ) + ) + node_id = a._must_clip(clip_id).effects[0].node_id # 白盒取节点 id + bad = a.execute( + ActionBatch(label="bad", actions=[Action(tool="set_param", params={"effect_id": node_id, "param": "level", "value": 5.0})]) + ) + assert bad.ok is False # 5.0 超出 level 的 [-1,1] + assert "非法" in bad.results[0].summary or "失败" in bad.results[0].summary diff --git a/tests/core/test_webui.py b/tests/core/test_webui.py new file mode 100644 index 0000000..157387c --- /dev/null +++ b/tests/core/test_webui.py @@ -0,0 +1,217 @@ +"""Web 面板测试:HTTP/SSE 端点的集成路径(confirm approve/reject 经端点走通)。 + +- confirm approve / reject 流程经 HTTP 端点裁决; +- undo_session / 快照管理端点; +- SSE 事件流(用真实 uvicorn 起服务走 TCP,TestClient 会缓冲无限流); +- EventBroker 单元测试。 +本模块依赖可选 extra ``core[webui]``,未安装时整模块 skip。 +""" +import http.client +import json +import socket +import threading +import time + +import pytest + +fastapi = pytest.importorskip("fastapi") +import uvicorn # noqa: E402 +from fastapi.testclient import TestClient # noqa: E402 + +from mock_host import MockHostAdapter # noqa: E402 +from mock_llm import MockProvider # noqa: E402 +from quercus_core.providers.base import AssistantTurn, ToolCall # noqa: E402 +from quercus_core.types import Action # noqa: E402 +from quercus_core.webui.app import create_app # noqa: E402 +from quercus_core.webui.broker import EventBroker # noqa: E402 + + +def _wait_pending(client, timeout=5.0): + deadline = time.time() + timeout + while time.time() < deadline: + data = client.get("/api/pending").json() + if data["pending"]: + return data["pending"][0] + time.sleep(0.02) + return None + + +# ---- 基础端点 ---- + +def test_index_served(): + app = create_app(adapter=MockHostAdapter(), provider=MockProvider(script=[])) + client = TestClient(app) + r = client.get("/") + assert r.status_code == 200 + assert "= 1 + assert adapter.list_footage() == [] # 时间线/媒体恢复原状 + + +def test_snapshot_endpoints(): + adapter = MockHostAdapter() + app = create_app(adapter=adapter, provider=MockProvider(script=[])) + client = TestClient(app) + app.state.session.executor.execute(Action(tool="import_footage", params={"paths": ["/media/a.mp4"]})) + snaps = client.get("/api/snapshots").json()["snapshots"] + assert len(snaps) >= 1 + sid = snaps[0]["id"] + assert client.post(f"/api/snapshots/{sid}/drop").json()["ok"] is True + assert client.get("/api/snapshots").json()["snapshots"] == [] + + +# ---- EventBroker 单元 ---- + +def test_event_broker(): + broker = EventBroker() + q1 = broker.subscribe() + q2 = broker.subscribe() + broker.publish({"kind": "system", "text": "hi"}) + assert q1.get(timeout=1)["text"] == "hi" + assert q2.get(timeout=1)["text"] == "hi" + broker.unsubscribe(q1) + assert broker.subscriber_count == 1 + + +# ---- SSE 事件流(真实 uvicorn 走 TCP) ---- + +@pytest.fixture +def live_server(): + adapter = MockHostAdapter() + app = create_app(adapter=adapter, provider=MockProvider(script=[])) + with socket.socket() as s: + s.bind(("127.0.0.1", 0)) + port = s.getsockname()[1] + config = uvicorn.Config(app, host="127.0.0.1", port=port, log_level="error") + server = uvicorn.Server(config) + thread = threading.Thread(target=server.run, daemon=True) + thread.start() + deadline = time.time() + 5 + while time.time() < deadline: + try: + with socket.create_connection(("127.0.0.1", port), timeout=0.2): + break + except OSError: + time.sleep(0.05) + else: + pytest.fail("uvicorn 未在超时内就绪") + yield {"app": app, "port": port} + server.should_exit = True + thread.join(timeout=5) + + +def test_sse_stream_delivers_events(live_server): + port = live_server["port"] + conn = http.client.HTTPConnection("127.0.0.1", port, timeout=10) + try: + conn.request("GET", "/api/events") + resp = conn.getresponse() + assert resp.status == 200 + assert resp.getheader("content-type", "").startswith("text/event-stream") + first = resp.readline() + assert b"connected" in first # 首条事件确认流已就绪 + + # 发布一条真实事件(undo_session 会发 system 消息) + live_server["app"].state.session.undo_session() + got = None + deadline = time.time() + 5 + while time.time() < deadline: + line = resp.readline() + if not line: + break + if line.startswith(b"data: "): + got = json.loads(line[6:]) + break + assert got is not None + assert got["kind"] == "system" + finally: + conn.close() diff --git a/tests/mock_host/__init__.py b/tests/mock_host/__init__.py new file mode 100644 index 0000000..172f73d --- /dev/null +++ b/tests/mock_host/__init__.py @@ -0,0 +1,41 @@ +"""Mock 宿主(tests/mock_host,可 import 包)。 + +MockHostAdapter 是未来 Oak / Resolve / Premiere 适配层的验收替身 +(计划文档 §3.3 / §7):内存时间线模型 + 快照补偿 + 限流模拟 + 真实 PNG 取帧。 +""" +from quercus_core.host.errors import EntityNotFound, HostError, RateLimited + +from mock_host.effects import MOCK_EFFECT_TYPES +from mock_host.frames import decode_png, frame_is_black, render_footage_frame +from mock_host.host import MockHostAdapter +from mock_host.model import ( + MockClip, + MockEffect, + MockFootage, + MockMarker, + MockProject, + MockSequence, + MockSnapshot, + MockState, + MockTrack, +) + +__all__ = [ + "MockHostAdapter", + "MOCK_EFFECT_TYPES", + "MockClip", + "MockEffect", + "MockFootage", + "MockMarker", + "MockProject", + "MockSequence", + "MockSnapshot", + "MockState", + "MockTrack", + "render_footage_frame", + "frame_is_black", + "decode_png", + "EntityNotFound", + "HostError", + "RateLimited", +] diff --git a/tests/mock_host/effects.py b/tests/mock_host/effects.py new file mode 100644 index 0000000..0598e4d --- /dev/null +++ b/tests/mock_host/effects.py @@ -0,0 +1,124 @@ +"""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 diff --git a/tests/mock_host/frames.py b/tests/mock_host/frames.py new file mode 100644 index 0000000..86e991a --- /dev/null +++ b/tests/mock_host/frames.py @@ -0,0 +1,221 @@ +"""Mock 宿主取帧:stdlib zlib 手写最小 PNG 编码器 + 确定性画面渲染。 + +- 不引入 Pillow(保持依赖小);编码器只写 RGB8 非隔行 PNG(合法 PNG)。 +- 帧内容由 (footage_id, source_time, 尺寸) 纯函数决定:随时间点变化、 + 不同素材不同、绝不整幅全黑——对齐计划文档 §7 的"帧非全黑"验收。 +- 时间线上的取帧语义:找到该时间点最上层启用的视频片段,取其素材 + ``media_in + (time - clip.start)`` 处的源画面(编辑改变片段覆盖关系, + 切口帧像素随之变化)。 +""" +from __future__ import annotations + +import hashlib +import struct +import zlib + +from quercus_core.types import PngBytes, Rational + +from mock_host.effects import effect_spec + +# 帧像素亮度阈值:低于它视为"全黑"(渲染永远高于此值)。 +_MIN_BRIGHTNESS = 24 + + +def _seed(footage_id: str) -> int: + return int(hashlib.md5(footage_id.encode("utf-8")).hexdigest()[:6], 16) % 256 + + +def encode_png(width: int, height: int, rgb: bytes) -> bytes: + """把 RGB8 平面数据编码为合法 PNG 字节(filter 0,逐行前置 0)。""" + if len(rgb) != width * height * 3: + raise ValueError(f"RGB 数据长度不符: {len(rgb)} != {width}x{height}x3") + stride = width * 3 + raw = bytearray((width * 3 + 1) * height) + for y in range(height): + raw[y * (stride + 1)] = 0 # filter type 0(None) + raw[y * (stride + 1) + 1 : (y + 1) * (stride + 1)] = rgb[y * stride : (y + 1) * stride] + compressed = zlib.compress(bytes(raw), 9) + + def _chunk(tag: bytes, data: bytes) -> bytes: + out = struct.pack(">I", len(data)) + tag + data + out += struct.pack(">I", zlib.crc32(tag + data) & 0xFFFFFFFF) + return out + + ihdr = struct.pack(">IIBBBBB", width, height, 8, 2, 0, 0, 0) # 8bit RGB + return ( + b"\x89PNG\r\n\x1a\n" + + _chunk(b"IHDR", ihdr) + + _chunk(b"IDAT", compressed) + + _chunk(b"IEND", b"") + ) + + +def render_footage_frame( + footage_id: str, + source_time: Rational, + width: int, + height: int, + effects: list | None = None, +) -> PngBytes: + """按素材 id 与源时间点渲染一帧(可叠加片段效果)。""" + seed = _seed(footage_id) + phase = int(round(source_time.to_float() * 30)) % 256 + data = bytearray(width * height * 3) + idx = 0 + for y in range(height): + for x in range(width): + r = (x * 255 // width + phase + seed) % 256 + g = (y * 255 // height + (phase // 2) + (seed * 3) % 256) % 256 + b = ((x * 255 // width + y * 255 // height) + phase * 2 + (seed * 5) % 256) % 256 + data[idx] = r + data[idx + 1] = g + data[idx + 2] = b + idx += 3 + if effects: + _apply_effects(data, width, height, effects) + return PngBytes(encode_png(width, height, bytes(data))) + + +def frame_is_black(png: bytes, width: int, height: int) -> bool: + """解码 PNG(仅取像素)判断是否整幅全黑。非法数据抛 ValueError。""" + rgb = decode_png(png) + if len(rgb) != width * height * 3: + raise ValueError("解码尺寸与预期不符") + return max(rgb) <= _MIN_BRIGHTNESS + + +def decode_png(png: bytes) -> bytes: + """测试用最小 PNG 解码器:校验签名/IHDR/CRC 并返回 RGB 平面数据。""" + if not png.startswith(b"\x89PNG\r\n\x1a\n"): + raise ValueError("非法 PNG 签名") + pos = 8 + width = height = None + idat = b"" + while pos < len(png): + (length,) = struct.unpack(">I", png[pos : pos + 4]) + tag = png[pos + 4 : pos + 8] + data = png[pos + 8 : pos + 8 + length] + (crc,) = struct.unpack(">I", png[pos + 8 + length : pos + 12 + length]) + if crc != (zlib.crc32(tag + data) & 0xFFFFFFFF): + raise ValueError(f"PNG CRC 校验失败(块 {tag!r})") + if tag == b"IHDR": + width, height, bit_depth, color_type, _, _, _ = struct.unpack(">IIBBBBB", data) + if bit_depth != 8 or color_type != 2: + raise ValueError(f"仅支持 8bit RGB PNG,得到 bit={bit_depth} color={color_type}") + elif tag == b"IDAT": + idat += data + pos += 12 + length + if width is None or height is None: + raise ValueError("PNG 缺少 IHDR") + raw = zlib.decompress(idat) + stride = 1 + width * 3 + if len(raw) != height * stride: + raise ValueError("PNG 原始数据长度不符") + out = bytearray(width * height * 3) + for y in range(height): + if raw[y * stride] != 0: + raise ValueError("仅支持 filter 0 的 PNG") + out[y * width * 3 : (y + 1) * width * 3] = raw[y * stride + 1 : (y + 1) * stride] + return bytes(out) + + +def _apply_effects(data: bytearray, width: int, height: int, effects: list) -> None: + """对 RGB 平面数据叠加片段效果(就地修改)。""" + for eff in effects: + params = eff.params + etype = eff.effect_type + spec = effect_spec(etype) + if etype == "brightness": + if params.get("enabled", True): + factor = 1.0 + float(params.get("level", 0.0)) + _mul_channels(data, factor) + elif etype == "color_shift": + if params.get("enabled", True): + shift = int(float(params.get("hue", 0.0)) / 360.0 * 255) + _rotate_channels(data, shift) + elif etype == "crop": + left = float(params.get("left", 0.0)) + top = float(params.get("top", 0.0)) + right = float(params.get("right", 0.0)) + bottom = float(params.get("bottom", 0.0)) + _crop_edges(data, width, height, left, top, right, bottom) + elif etype == "blur": + if params.get("enabled", True) and float(params.get("radius", 0.0)) > 0: + _box_blur(data, width, height, int(float(params.get("radius", 0.0)))) + elif etype == "overlay": + mode = params.get("blend_mode", "normal") + opacity = float(params.get("opacity", 0.5)) + _blend(data, mode, opacity) + + +def _clamp(v: int) -> int: + return 0 if v < 0 else (255 if v > 255 else v) + + +def _mul_channels(data: bytearray, factor: float) -> None: + for i in range(0, len(data), 3): + data[i] = _clamp(round(data[i] * factor)) + data[i + 1] = _clamp(round(data[i + 1] * factor)) + data[i + 2] = _clamp(round(data[i + 2] * factor)) + + +def _rotate_channels(data: bytearray, shift: int) -> None: + for i in range(0, len(data), 3): + r, g, b = data[i], data[i + 1], data[i + 2] + data[i] = (r + shift) % 256 + data[i + 1] = (g + shift // 2) % 256 + data[i + 2] = (b + shift) % 256 + + +def _crop_edges(data, width, height, left, top, right, bottom) -> None: + x0 = int(left * width) + y0 = int(top * height) + x1 = int((1.0 - right) * width) + y1 = int((1.0 - bottom) * height) + for y in range(height): + for x in range(width): + if x < x0 or x >= x1 or y < y0 or y >= y1: + i = (y * width + x) * 3 + data[i] = data[i + 1] = data[i + 2] = 0 + + +def _box_blur(data: bytearray, width: int, height: int, radius: int) -> None: + src = bytes(data) + r = max(1, radius) + for y in range(height): + for x in range(width): + total = [0, 0, 0] + count = 0 + for dy in range(-r, r + 1): + for dx in range(-r, r + 1): + ny, nx = y + dy, x + dx + if 0 <= ny < height and 0 <= nx < width: + i = (ny * width + nx) * 3 + total[0] += src[i] + total[1] += src[i + 1] + total[2] += src[i + 2] + count += 1 + if count: + i = (y * width + x) * 3 + data[i] = total[0] // count + data[i + 1] = total[1] // count + data[i + 2] = total[2] // count + + +def _blend(data: bytearray, mode: str, opacity: float) -> None: + if mode == "normal" or opacity <= 0: + return + # 叠加色取固定中灰,screen/multiply 各取一个方向 + for i in range(0, len(data), 3): + base = (data[i], data[i + 1], data[i + 2]) + if mode == "screen": + out = (255 - (255 - base[0]) * (255 - 128) // 255, + 255 - (255 - base[1]) * (255 - 128) // 255, + 255 - (255 - base[2]) * (255 - 128) // 255) + elif mode == "multiply": + out = (base[0] * 128 // 255, base[1] * 128 // 255, base[2] * 128 // 255) + else: + out = base + data[i] = _clamp(round(base[0] + (out[0] - base[0]) * opacity)) + data[i + 1] = _clamp(round(base[1] + (out[1] - base[1]) * opacity)) + data[i + 2] = _clamp(round(base[2] + (out[2] - base[2]) * opacity)) diff --git a/tests/mock_host/host.py b/tests/mock_host/host.py new file mode 100644 index 0000000..5b18dc1 --- /dev/null +++ b/tests/mock_host/host.py @@ -0,0 +1,784 @@ +"""MockHostAdapter:HostAdapter 的内存实现(tests/mock_host 可 import 包)。 + +它是未来 Oak / Resolve / Premiere 适配层的**验收替身**(计划文档 §3.3 / §7): +- 行为对齐 OPP/1 语义:EntityId 不透明、删除后 id 失效(再访问抛 + ``EntityNotFound``)、取帧按 ``Limits`` 令牌桶限流(令牌耗尽抛 + ``RateLimited(retry_after_ms)``)。 +- 取帧返回**真实合法 PNG** 且像素随时间点/素材/效果变化,绝不整幅全黑 + (frames.py 的确定性渲染)。 +- ActionBatch 走"快照 + 补偿"档:每个变更批次执行前深拷贝时间线快照 + (带时间戳与会话 id,上限 ``Limits.max_snapshot_count``);子操作失败时 + 已成功的保持、错误进 ``BatchResult``;提供 ``list_snapshots`` / + ``restore_snapshot`` / ``drop_snapshot`` / ``undo_last``。 +- 结构变化等事件经 ``subscribe`` 回调通知(core 可注册 listener)。 +""" +from __future__ import annotations + +import builtins as _builtins # get_audio_levels 形参名为 range,需经 builtins.range 绕开 +import copy +import hashlib +import math +import time +import uuid +from datetime import datetime, timezone +from pathlib import Path +from typing import Any, Callable + +from quercus_core.host.adapter import HostAdapter +from quercus_core.host.errors import EntityNotFound, RateLimited +from quercus_core.host.types import ( + BatchResult, + Clip, + FootageId, + HostEvent, + JobHandle, + Levels, + MediaInfo, + PlaybackState, + ProjectOverview, + SeqId, + SnapshotInfo, + Target, + Timeline, + Track, +) +from quercus_core.tools.ratelimit import TokenBucket +from quercus_core.types import ( + ActionBatch, + Capabilities, + Capability, + Limits, + PngBytes, + Rational, + Size, + TimeRange, + ToolResult, +) + +from mock_host.effects import MOCK_EFFECT_TYPES, validate_param +from mock_host.frames import render_footage_frame +from mock_host.model import ( + MockClip, + MockEffect, + MockFootage, + MockMarker, + MockProject, + MockSequence, + MockSnapshot, + MockState, + MockTrack, + MockTransition, +) + +# 默认帧率(mock 素材/序列的统一 fps,有理数)。 +_DEFAULT_FPS = Rational(25, 1) +# 批量取帧(get_thumbnails)的默认缩略尺寸。 +_THUMB_SIZE = Size(480, 270) + + +def _now_iso() -> str: + return datetime.now(timezone.utc).isoformat() + + +def _sample_times(rng: TimeRange, count: int) -> list[Rational]: + """在 [start, end] 上等间隔采样 count 个时间点(含两端)。""" + if count <= 1: + return [rng.start] + step = rng.duration / Rational(count - 1, 1) + return [rng.start + step * Rational(i, 1) for i in range(count)] + + +class MockHostAdapter(HostAdapter): + """内存宿主实现。构造参数均可注入(limits / burst / monotonic)便于测试。""" + + def __init__( + self, + *, + limits: Limits | None = None, + burst: int | None = None, + monotonic=time.monotonic, + ) -> None: + self._limits = limits or Limits() + self._burst = burst if burst is not None else self._limits.frame_burst + self._monotonic = monotonic + self._bucket = TokenBucket(self._burst, self._limits.max_frame_rate, monotonic=monotonic) + self._state = MockState(project=MockProject()) + self._snapshots: dict[str, MockSnapshot] = {} + self._jobs: dict[str, dict] = {} + self._listeners: list[Callable[[HostEvent], None]] = [] + self._rate_limited_count = 0 # 供测试断言"确实触发了宿主限流" + self._seq_counter = 0 + + # ---- 内部工具 ---- + + def _new_id(self, prefix: str): + from quercus_core.types import EntityId as _EntityId + + return _EntityId(f"{prefix}_{uuid.uuid4().hex[:10]}") + + def _fire(self, kind: str, payload: dict | None = None) -> None: + ev = HostEvent( + kind=kind, + seq_id=self._state.current_seq_id or "", + payload=dict(payload or {}), + ) + for listener in list(self._listeners): + try: + listener(ev) + except Exception: # 监听器异常不影响宿主(best effort) + pass + + # ---- 实体查找(id 失效即抛 EntityNotFound) ---- + + def _must_footage(self, footage_id: str) -> MockFootage: + ft = self._state.footage.get(footage_id) + if ft is None: + raise EntityNotFound(f"素材 {footage_id} 不存在或已删除") + return ft + + def _must_seq(self, seq_id: str) -> MockSequence: + seq = self._state.sequences.get(seq_id) + if seq is None: + raise EntityNotFound(f"序列 {seq_id} 不存在或已删除") + return seq + + def _must_clip(self, clip_id: str) -> MockClip: + for seq in self._state.sequences.values(): + for track in seq.tracks: + for clip in track.clips: + if clip.id == clip_id: + return clip + raise EntityNotFound(f"片段 {clip_id} 不存在或已删除") + + def _must_effect(self, node_id: str) -> MockEffect: + for seq in self._state.sequences.values(): + for track in seq.tracks: + for clip in track.clips: + for eff in clip.effects: + if eff.node_id == node_id: + return eff + raise EntityNotFound(f"效果节点 {node_id} 不存在或已删除") + + def _must_job(self, job_id: str) -> dict: + job = self._jobs.get(job_id) + if job is None: + raise EntityNotFound(f"导出任务 {job_id} 不存在") + return job + + def _track_of(self, clip: MockClip) -> MockTrack: + for seq in self._state.sequences.values(): + for track in seq.tracks: + if any(c.id == clip.id for c in track.clips): + return track + raise EntityNotFound(f"片段 {clip.id} 不在任何轨道") + + # ---- 时间线惰性构建(首个编辑自动建默认序列/轨道) ---- + + def _ensure_sequence(self) -> MockSequence: + if self._state.current_seq_id and self._state.current_seq_id in self._state.sequences: + return self._state.sequences[self._state.current_seq_id] + self._seq_counter += 1 + seq = MockSequence( + id=self._new_id("seq"), + name=f"序列 {self._seq_counter}", + fps=_DEFAULT_FPS, + ) + self._state.sequences[seq.id] = seq + self._state.current_seq_id = seq.id + return seq + + def _ensure_track(self, seq: MockSequence, index: int | None, kind: str) -> MockTrack: + if index is None: + index = max([t.index for t in seq.tracks], default=-1) + 1 + for track in seq.tracks: + if track.index == index: + if track.kind != kind: + raise ValueError(f"轨道 {index} 已存在且类型为 {track.kind}") + return track + track = MockTrack(index=index, kind=kind) + seq.tracks.append(track) + seq.tracks.sort(key=lambda t: t.index) + return track + + # ---- HostAdapter ABC:会话与能力 ---- + + def capabilities(self) -> set[Capability]: + return { + Capabilities.PROJECT_READ, + Capabilities.MEDIA_READ, + Capabilities.MEDIA_IMPORT, + Capabilities.TIMELINE_READ, + Capabilities.TIMELINE_EDIT, + Capabilities.NODE_READ, + Capabilities.NODE_EDIT, + Capabilities.RENDER_FRAME, + Capabilities.PLAYBACK, + Capabilities.EXPORT, + } + + def limits(self) -> Limits: + return self._limits + + # ---- 工程 / 媒体 / 时间线 ---- + + def open_project(self, path: str) -> None: + self._state = MockState( + project=MockProject(name=Path(path).stem or "未命名工程", path=path) + ) + self._fire("project_opened", {"path": path}) + + def save_project(self, path: str | None = None) -> None: + if path: + self._state.project.path = path + self._state.project.modified = False + + def get_project_overview(self) -> ProjectOverview: + seqs = sorted(self._state.sequences.values(), key=lambda s: s.id) + dur = None + fps = _DEFAULT_FPS.to_float() + if seqs: + dur = max( + (c.start + c.duration for s in seqs for t in s.tracks for c in t.clips), + default=Rational(0), + ) + fps = seqs[0].fps.to_float() + return ProjectOverview( + project_id=self._state.project.path or self._state.project.name, + name=self._state.project.name, + fps=fps, + timeline_ids=[s.id for s in seqs], + duration=dur, + ) + + def probe_media(self, path: str) -> MediaInfo: + for ft in self._state.footage.values(): + if ft.path == path: + return MediaInfo( + path=path, + duration=ft.duration, + width=ft.width, + height=ft.height, + fps=ft.fps.to_float(), + ) + # 未导入的路径:确定性合成元数据 + return MediaInfo(path=path, duration=Rational(10), width=1920, height=1080, fps=25.0) + + def import_footage(self, paths: list[str]) -> list[FootageId]: + ids = [self._import_one(p) for p in paths] + self._fire("media_changed", {"paths": list(paths)}) + return ids + + def _import_one(self, path: str) -> FootageId: + for ft in self._state.footage.values(): + if ft.path == path: + return ft.id + name = Path(path).name or path + h = int(hashlib.md5(path.encode("utf-8")).hexdigest()[:6], 16) + ft = MockFootage( + id=self._new_id("ft"), + name=name, + path=path, + duration=Rational(4 + h % 20, 1), + width=1920, + height=1080, + fps=_DEFAULT_FPS, + ) + self._state.footage[ft.id] = ft + return ft.id + + def get_timeline_structure(self, seq: SeqId) -> Timeline: + s = self._must_seq(seq) + tracks = [] + for t in sorted(s.tracks, key=lambda x: x.index): + clips = [ + Clip( + id=c.id, + name=c.name, + source=c.footage_id, + start=c.start, + duration=c.duration, + in_point=c.media_in, + out_point=c.media_out, + ) + for c in sorted(t.clips, key=lambda x: x.start) + ] + tracks.append(Track(index=t.index, kind=t.kind, clips=clips)) + dur = max( + (c.start + c.duration for t in s.tracks for c in t.clips), default=Rational(0) + ) + return Timeline(id=s.id, name=s.name, fps=s.fps.to_float(), duration=dur, tracks=tracks) + + # ---- 取帧(视觉闭环,令牌桶强制限流) ---- + + def _clamp_size(self, size: Size) -> tuple[int, int]: + w = max(1, min(size.width, self._limits.max_frame_width)) + h = max(1, min(size.height, self._limits.max_frame_height)) + return w, h + + def _acquire_frame_token(self) -> None: + if not self._bucket.try_acquire(): + self._rate_limited_count += 1 + retry_ms = max(1, int(math.ceil(self._bucket.seconds_until_token() * 1000))) + raise RateLimited(retry_ms) + + def _resolve_frame(self, target: Target, time: Rational): + """找到时间点对应的可见片段,返回 (clip, 源时间点, effects)。""" + if target.kind == "clip": + clip = self._must_clip(target.id) + return clip, clip.media_in + time, clip.effects + seq = self._must_seq(target.id) + max_end: Rational | None = None + for track in sorted(seq.tracks, key=lambda t: t.index, reverse=True): + if track.kind != "video": + continue + for clip in sorted(track.clips, key=lambda c: c.start): + if not clip.enabled: + continue + end = clip.start + clip.duration + if max_end is None or end > max_end: + max_end = end + if clip.start <= time < end: + src = clip.media_in + (time - clip.start) + return clip, src, clip.effects + # 扫描等间隔采样含右端点:time 恰为最末片段结束点时取末帧(避免落空报错) + if max_end is not None and time == max_end: + for track in sorted(seq.tracks, key=lambda t: t.index, reverse=True): + if track.kind != "video": + continue + for clip in sorted(track.clips, key=lambda c: c.start): + if clip.enabled and clip.start + clip.duration == max_end: + return clip, clip.media_out, clip.effects + raise ValueError(f"时间点 {time} 无可见视频片段(时间线 {target.id})") + + def get_frame(self, target: Target, time: Rational, max_size: Size) -> PngBytes: + self._acquire_frame_token() + w, h = self._clamp_size(max_size) + clip, src, effects = self._resolve_frame(target, time) + return render_footage_frame(clip.footage_id, src, w, h, effects) + + def get_thumbnails(self, target: Target, range: TimeRange, count: int) -> list[PngBytes]: + count = max(1, min(count, self._limits.max_scan_frames)) + out: list[PngBytes] = [] + for t in _sample_times(range, count): + self._acquire_frame_token() + clip, src, effects = self._resolve_frame(target, t) + w, h = self._clamp_size(_THUMB_SIZE) + out.append(render_footage_frame(clip.footage_id, src, w, h, effects)) + return out + + def get_audio_levels(self, seq: SeqId, range: TimeRange, resolution: int) -> Levels: + self._must_seq(seq) + res = max(1, min(int(resolution), 1024)) + values: list[float] = [] + span = range.duration.to_float() + start = range.start.to_float() + for i in _builtins.range(res): + t = start + span * i / max(1, res - 1) if res > 1 else start + db = -30.0 + 12.0 * math.sin(t * 2.0 * math.pi / 4.0) + values.append(round(max(-60.0, db), 2)) + return Levels(values=values, min_db=-60.0, max_db=0.0) + + # ---- 回放 / 导出 ---- + + def play(self, speed: float = 1.0) -> None: + seq = self._ensure_sequence() + seq.playing = True + self._fire("playback_state", {"playing": True, "speed": speed}) + + def pause(self) -> None: + seq = self._ensure_sequence() + seq.playing = False + self._fire("playback_state", {"playing": False}) + + def seek(self, time: Rational) -> None: + seq = self._ensure_sequence() + seq.playhead = time + self._fire("playhead_moved", {"time": time.to_json()}) + + def get_state(self) -> PlaybackState: + seq = self._ensure_sequence() + return PlaybackState(playing=seq.playing, position=seq.playhead) + + def export(self, seq: SeqId, output: str, preset: str) -> JobHandle: + self._must_seq(seq) + job_id = "job_" + uuid.uuid4().hex[:10] + self._jobs[job_id] = { + "seq": seq, + "output": output, + "preset": preset, + "progress": 0.0, + "status": "running", + "message": "排队中", + } + self._fire("export_progress", {"job_id": job_id, "progress": 0.0, "message": "排队中"}) + return JobHandle(job_id) + + def export_status(self, job_id: str) -> dict: + """非 ABC 扩展:轮询导出进度(每次调用模拟推进 25%,供 executor 有界轮询)。""" + job = self._must_job(job_id) + if job["status"] == "running": + job["progress"] = min(1.0, job["progress"] + 0.25) + if job["progress"] >= 1.0: + job["status"] = "done" + job["message"] = "导出完成" + self._fire("export_done", {"job_id": job_id, "output": job["output"]}) + else: + job["message"] = f"导出中 {int(job['progress'] * 100)}%" + self._fire( + "export_progress", + {"job_id": job_id, "progress": job["progress"], "message": job["message"]}, + ) + return { + "job_id": job_id, + "status": job["status"], + "progress": job["progress"], + "message": job["message"], + } + + # ---- 编辑(ActionBatch 快照 + 补偿档,计划文档 §3.3) ---- + + def execute(self, batch: ActionBatch) -> BatchResult: + # 变更批次执行前深拷贝时间线快照(带时间戳与会话 id,有上限)。 + snapshot = self._take_snapshot(batch) + results: list[ToolResult] = [] + ok_all = True + for action in batch.actions: + try: + summary = self._apply(action.tool, action.params) + except (EntityNotFound, ValueError) as exc: + ok_all = False + results.append( + ToolResult( + tool=action.tool, + ok=False, + summary=f"{action.tool} 失败:{exc}", + error=str(exc), + ) + ) + break # 任一子操作失败:已成功的保持,错误进 BatchResult,中止剩余 + except Exception as exc: # pragma: no cover - 防御性兜底 + ok_all = False + results.append( + ToolResult( + tool=action.tool, + ok=False, + summary=f"{action.tool} 失败:{exc}", + error=str(exc), + ) + ) + break + results.append(ToolResult(tool=action.tool, ok=True, summary=summary)) + self._fire( + self._event_kind_for(action.tool), + {"seq_id": self._state.current_seq_id or "", "tool": action.tool}, + ) + # 中止后剩余动作标记失败(与 OPP/1 §6 规则 3 对齐:不静默吞掉) + for action in batch.actions[len(results):]: + results.append( + ToolResult( + tool=action.tool, + ok=False, + summary=f"{action.tool} 未执行:批次前序子操作失败", + error="batch aborted", + ) + ) + succeeded = sum(1 for r in results if r.ok) + failed = len(results) - succeeded + message = f"{succeeded} 个动作成功,{failed} 个失败" + (f"(快照 {snapshot.id})" if snapshot else "") + return BatchResult( + ok=ok_all and failed == 0, + message=message, + results=results, + snapshot_id=snapshot.id if snapshot else "", + ) + + @staticmethod + def _event_kind_for(tool: str) -> str: + if tool == "import_footage": + return "media_changed" + if tool == "open_project": + return "project_opened" + if tool == "export_render": + return "export" + return "structure_changed" + + def _take_snapshot(self, batch: ActionBatch) -> MockSnapshot | None: + if not batch.actions: + return None + if len(self._snapshots) >= self._limits.max_snapshot_count: + oldest = min(self._snapshots.values(), key=lambda s: s.created_at) + del self._snapshots[oldest.id] + snap = MockSnapshot( + id=self._new_id("snap"), + session_id=batch.session_id or "", + created_at=_now_iso(), + label=batch.label or "", + action_count=len(batch.actions), + state=copy.deepcopy(self._state), + ) + self._snapshots[snap.id] = snap + return snap + + # ---- 快照管理(Web 面板与 undo_session 使用) ---- + + def list_snapshots(self) -> list[SnapshotInfo]: + return [ + SnapshotInfo( + id=s.id, + session_id=s.session_id, + created_at=s.created_at, + label=s.label, + action_count=s.action_count, + ) + for s in sorted(self._snapshots.values(), key=lambda x: x.created_at) + ] + + def restore_snapshot(self, snapshot_id: str) -> None: + snap = self._snapshots.get(snapshot_id) + if snap is None: + raise EntityNotFound(f"快照 {snapshot_id} 不存在或已被清理") + self._state = copy.deepcopy(snap.state) + self._fire( + "structure_changed", + {"reason": "snapshot_restore", "snapshot_id": snapshot_id}, + ) + + def drop_snapshot(self, snapshot_id: str) -> None: + if snapshot_id not in self._snapshots: + raise EntityNotFound(f"快照 {snapshot_id} 不存在") + del self._snapshots[snapshot_id] + + def undo_last(self) -> None: + """撤销上一个 AI 动作 = 恢复并消费最新一份快照。""" + if not self._snapshots: + raise EntityNotFound("无可用快照,无法撤销") + newest = max(self._snapshots.values(), key=lambda s: s.created_at) + self.restore_snapshot(newest.id) + del self._snapshots[newest.id] + + # ---- 只读扩展(工具适配层需要的 duck-typed 方法) ---- + + def list_footage(self) -> list[dict]: + return [ + { + "id": ft.id, + "name": ft.name, + "path": ft.path, + "duration": ft.duration.to_json(), + "width": ft.width, + "height": ft.height, + } + for ft in self._state.footage.values() + ] + + def list_effects(self, clip_id: str | None = None, category: str | None = None) -> list[dict]: + if clip_id: + clip = self._must_clip(clip_id) + return [ + {"node_id": e.node_id, "type": e.effect_type, "preset": e.preset} + for e in clip.effects + ] + out = [] + for etype, spec in MOCK_EFFECT_TYPES.items(): + if category and category not in spec.get("categories", []): + continue + out.append({"type": etype, "description": spec["description"]}) + return out + + def get_effect_params(self, effect_type: str) -> list[dict]: + spec = MOCK_EFFECT_TYPES.get(effect_type) + if spec is None: + raise EntityNotFound(f"未知效果类型 {effect_type!r}(可选: {sorted(MOCK_EFFECT_TYPES)})") + return [p.to_json() for p in spec["params"]] + + # ---- 事件订阅(core 可注册 listener) ---- + + def subscribe(self, listener: Callable[[HostEvent], None]) -> Callable[[], None]: + """注册事件监听,返回取消订阅函数。""" + self._listeners.append(listener) + + def _unsubscribe() -> None: + if listener in self._listeners: + self._listeners.remove(listener) + + return _unsubscribe + + # ---- 单动作应用(execute 的子操作,各 mutation 工具的实现) ---- + + def _apply(self, tool: str, params: dict) -> str: + if tool == "open_project": + path = params["path"] + self._state = MockState( + project=MockProject(name=Path(path).stem or "未命名工程", path=path) + ) + return f"已打开工程 {path}" + if tool == "save_project": + path = params.get("path") + if path: + self._state.project.path = path + self._state.project.modified = False + return f"已保存工程 {self._state.project.path or self._state.project.name}" + if tool == "import_footage": + ids = [self._import_one(p) for p in params["paths"]] + return f"已导入 {len(ids)} 个素材:{', '.join(ids)}" + if tool == "add_track": + seq = self._ensure_sequence() + kind = params["type"] + index = params.get("index") + track = self._ensure_track(seq, index, kind) + return f"已添加 {kind} 轨道 {track.index}" + if tool == "place_clip": + seq = self._ensure_sequence() + footage = self._must_footage(params["clip_id"]) + track_index = int(params["track_index"]) + time_p = Rational.from_json(params["time"]) + in_p = Rational.from_json(params["in_point"]) if params.get("in_point") else Rational(0) + out_p = ( + Rational.from_json(params["out_point"]) + if params.get("out_point") + else footage.duration + ) + if out_p <= in_p or out_p > footage.duration: + raise ValueError(f"片段入出点越界: {in_p}..{out_p}(素材 {footage.duration})") + track = self._ensure_track(seq, track_index, "video") + clip = MockClip( + id=self._new_id("clip"), + name=footage.name, + footage_id=footage.id, + track_index=track_index, + media_in=in_p, + media_out=out_p, + start=time_p, + ) + track.clips.append(clip) + track.clips.sort(key=lambda c: c.start) + return f"已放置片段 {clip.id}(素材 {footage.id},轨道 {track_index},@{time_p})" + if tool == "split_clip": + clip = self._must_clip(params["clip_id"]) + t = Rational.from_json(params["time"]) + if t <= clip.start or t >= clip.start + clip.duration: + raise ValueError(f"切点 {t} 不在片段 {clip.id} 区间 [{clip.start}, {clip.start + clip.duration})") + src_t = clip.media_in + (t - clip.start) + right = MockClip( + id=self._new_id("clip"), + name=clip.name, + footage_id=clip.footage_id, + track_index=clip.track_index, + media_in=src_t, + media_out=clip.media_out, + start=t, + enabled=clip.enabled, + effects=copy.deepcopy(clip.effects), + transition=clip.transition, + ) + clip.media_out = src_t + track = self._track_of(clip) + track.clips.append(right) + track.clips.sort(key=lambda c: c.start) + return f"已把片段 {clip.id} 在 {t} 处切开,右段 {right.id}" + if tool == "trim_clip": + clip = self._must_clip(params["clip_id"]) + footage = self._must_footage(clip.footage_id) + in_p = Rational.from_json(params["in_point"]) if params.get("in_point") is not None else clip.media_in + if params.get("out_point") is not None: + out_p = Rational.from_json(params["out_point"]) + elif params.get("duration") is not None: + out_p = in_p + Rational.from_json(params["duration"]) + else: + out_p = clip.media_out + if out_p <= in_p: + raise ValueError(f"修剪后片段非正时长: {in_p}..{out_p}") + if out_p > footage.duration: + raise ValueError(f"修剪越界(素材 {footage.id} 时长 {footage.duration})") + clip.media_in = in_p + clip.media_out = out_p + return f"已修剪片段 {clip.id} 至 {in_p}..{out_p}" + if tool == "move_clip": + clip = self._must_clip(params["clip_id"]) + seq = self._ensure_sequence() + new_track_index = int(params["track_index"]) + new_time = Rational.from_json(params["time"]) + old_track = self._track_of(clip) + if old_track.index != new_track_index: + old_track.clips.remove(clip) + new_track = self._ensure_track(seq, new_track_index, "video") + clip.track_index = new_track_index + clip.start = new_time + new_track.clips.append(clip) + else: + clip.start = new_time + return f"已移动片段 {clip.id} 至轨道 {new_track_index} @{new_time}" + if tool == "ripple_delete": + ids = set(params["clip_ids"]) + removed: list[str] = [] + for seq in self._state.sequences.values(): + for track in seq.tracks: + gone = [c for c in track.clips if c.id in ids] + for c in gone: + track.clips.remove(c) + removed.append(c.id) + # 波纹闭合:同轨道剩余片段按 start 排序后紧密排列(闭合删除产生的空隙) + for seq in self._state.sequences.values(): + for track in seq.tracks: + track.clips.sort(key=lambda c: c.start) + cursor = Rational(0) + for c in track.clips: + c.start = cursor + cursor = c.start + c.duration + return f"已波纹删除 {len(removed)} 个片段" + if tool == "add_transition": + clip = self._must_clip(params["clip_id"]) + dur = Rational.from_json(params["duration"]) if params.get("duration") else Rational(0) + clip.transition = MockTransition( + transition_type=params["transition_type"], duration=dur + ) + return f"已为片段 {clip.id} 添加转场 {params['transition_type']}({dur})" + if tool == "add_marker": + seq = self._ensure_sequence() + marker = MockMarker( + id=self._new_id("marker"), + time=Rational.from_json(params["time"]), + label=params.get("label", ""), + color=params.get("color", "yellow"), + custom_data=params.get("custom_data") or {}, + ) + seq.markers.append(marker) + return f"已添加标记 {marker.id} @{marker.time}({marker.label or '未命名'})" + if tool == "add_effect": + clip = self._must_clip(params["clip_id"]) + etype = params["effect_type"] + if etype not in MOCK_EFFECT_TYPES: + raise ValueError(f"未知效果类型 {etype!r}(可选: {sorted(MOCK_EFFECT_TYPES)})") + spec = MOCK_EFFECT_TYPES[etype] + defaults = {p.name: p.default for p in spec["params"]} + eff = MockEffect( + node_id=self._new_id("fx"), + effect_type=etype, + preset=params.get("preset", ""), + params=defaults, + ) + clip.effects.append(eff) + return f"已添加效果 {etype}(节点 {eff.node_id})到片段 {clip.id}" + if tool == "set_param": + eff = self._must_effect(params["effect_id"]) + validate_param(eff.effect_type, params["param"], params["value"]) + eff.params[params["param"]] = params["value"] + return f"已设置效果 {eff.node_id} 参数 {params['param']}={params['value']!r}" + if tool == "set_keyframe": + eff = self._must_effect(params["effect_id"]) + name = params["param"] + value = params["value"] + if not isinstance(value, (int, float)) or isinstance(value, bool): + raise ValueError("关键帧值必须为数值") + # 先校验参数合法(含 choices/min/max) + validate_param(eff.effect_type, name, value) + t = Rational.from_json(params["time"]) + from mock_host.model import MockKeyframe + + eff.keyframes.setdefault(name, []).append(MockKeyframe(time=t, value=value)) + return f"已为效果 {eff.node_id} 参数 {name} @{t} 打关键帧 {value}" + if tool == "export_render": + seq = self._ensure_sequence() + job = self.export(seq, params["output"], params.get("preset", "")) + return f"已启动导出任务 {job.job_id} → {params['output']}" + raise ValueError(f"execute 收到未知工具 {tool!r}(只读工具应直通,不走批次)") diff --git a/tests/mock_host/model.py b/tests/mock_host/model.py new file mode 100644 index 0000000..be9efb1 --- /dev/null +++ b/tests/mock_host/model.py @@ -0,0 +1,139 @@ +"""Mock 宿主内存剪辑模型(tests/mock_host 可 import 包)。 + +形状对齐计划文档 §3.1 / §3.3: +- 不透明 id(``EntityId``),删除后失效(再访问抛 ``EntityNotFound``); +- clip 携带 footage 引用、in/out(素材内入出点)、media_in、enabled; +- clip 上挂 effects(node id + 参数),参数带 min/max/choices; +- sequence 含 fps 有理数、video/audio 轨道、markers、播放状态; +- snapshot = 对 ``MockState`` 的深拷贝(带时间戳与会话 id)。 +""" +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import Any + +from quercus_core.types import EntityId, Rational + + +@dataclass +class MockKeyframe: + """效果参数关键帧(时间 + 值)。""" + + time: Rational + value: float + + +@dataclass +class MockEffect: + """片段上的效果节点。params 值需满足 effects.py 注册表的 min/max/choices。""" + + node_id: EntityId + effect_type: str + preset: str = "" + params: dict[str, Any] = field(default_factory=dict) + keyframes: dict[str, list[MockKeyframe]] = field(default_factory=dict) + + +@dataclass +class MockTransition: + """片段转场(mock 仅记录,不参与像素渲染)。""" + + transition_type: str + duration: Rational = Rational(0) + + +@dataclass +class MockClip: + """时间线片段。media_in/media_out 为素材内入出点(源区间)。""" + + id: EntityId + name: str + footage_id: str + track_index: int + media_in: Rational + media_out: Rational + start: Rational # 时间线上位置 + enabled: bool = True + effects: list[MockEffect] = field(default_factory=list) + transition: MockTransition | None = None + + @property + def duration(self) -> Rational: + return self.media_out - self.media_in + + +@dataclass +class MockTrack: + """时间线轨道。""" + + index: int + kind: str = "video" # video | audio | subtitle + clips: list[MockClip] = field(default_factory=list) + + +@dataclass +class MockMarker: + """时间线标记(可携带 AI 元数据 custom_data)。""" + + id: EntityId + time: Rational + label: str = "" + color: str = "yellow" + custom_data: dict = field(default_factory=dict) + + +@dataclass +class MockSequence: + """时间线序列。fps 为有理数(对齐决策 D5)。""" + + id: EntityId + name: str + fps: Rational + tracks: list[MockTrack] = field(default_factory=list) + markers: list[MockMarker] = field(default_factory=list) + playhead: Rational = Rational(0) + playing: bool = False + + +@dataclass +class MockFootage: + """媒体池素材(id 不透明,内容由 frames.py 按 id 确定性渲染)。""" + + id: EntityId + name: str + path: str + duration: Rational + width: int = 1920 + height: int = 1080 + fps: Rational = Rational(25, 1) + + +@dataclass +class MockProject: + """工程:name / modified(计划文档 §3.3 的工程状态)。""" + + name: str = "未命名工程" + path: str = "" + modified: bool = False + + +@dataclass +class MockState: + """宿主全部状态;快照 = 对它的深拷贝。""" + + project: MockProject = field(default_factory=MockProject) + footage: dict[str, MockFootage] = field(default_factory=dict) + sequences: dict[str, MockSequence] = field(default_factory=dict) + current_seq_id: EntityId | None = None + + +@dataclass +class MockSnapshot: + """一份时间线快照(带时间戳与会话 id,有上限见 Limits.max_snapshot_count)。""" + + id: str + session_id: str + created_at: str + label: str + action_count: int + state: Any # deepcopy(MockState) diff --git a/uv.lock b/uv.lock index 4aa2045..cbcf609 100644 --- a/uv.lock +++ b/uv.lock @@ -12,6 +12,15 @@ members = [ "quercus-core", ] +[[package]] +name = "annotated-doc" +version = "0.0.5" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5a/8e/38aa427ed5402449e226975b649c5dc73ccadfefeb95e6aecb8f8ea4b6b6/annotated_doc-0.0.5.tar.gz", hash = "sha256:c7e58ce09192557605d8bbd92836d7e1d520ac9580096042c0bfd197efacf1bb", size = 10758, upload-time = "2026-07-28T13:50:58.129Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3e/30/e900b21425a860e195f32e37657aa1f7c7f2b1bfb26f03ca209b90933c06/annotated_doc-0.0.5-py3-none-any.whl", hash = "sha256:117bac03a25ede5df5440e855b32d556049ca169ead221505badf432fed4b101", size = 5302, upload-time = "2026-07-28T13:50:57.239Z" }, +] + [[package]] name = "annotated-types" version = "0.8.0" @@ -62,6 +71,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/0b/a7/71ac2cff56fec219ed242bb11b8efb69fcc4bec75db06fb7bfe35de520e6/certifi-2026.7.22-py3-none-any.whl", hash = "sha256:62f22742b58a1a33014a2b6b706588a8d7e2a88ae7bd1a6ebe8c992928483775", size = 136983, upload-time = "2026-07-22T03:35:11.276Z" }, ] +[[package]] +name = "click" +version = "8.4.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/76/d4/81420972a676e8ffea40450d8c8c92943e7218a78fe9b64359836cc9876b/click-8.4.2.tar.gz", hash = "sha256:9a6cea6e60b17ebe0a44c5cc636d94f09bd66142c1cd7d8b4cd731c4917a15f6", size = 338000, upload-time = "2026-06-24T17:45:15.148Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fb/e2/79c688af8b210d232694e31e59da9f6ec747bae31c3f5946e4e9b98860d5/click-8.4.2-py3-none-any.whl", hash = "sha256:e6f9f66136c816745b9d65817da91d61d957fb16e02e4dcd0552553c5a197b76", size = 119243, upload-time = "2026-06-24T17:45:13.73Z" }, +] + [[package]] name = "colorama" version = "0.4.6" @@ -89,6 +110,22 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/a7/5f/ed01f9a3cdffbd5a008556fc7b2a08ddb1cc6ace7effa7340604b1d16699/docstring_parser-0.18.0-py3-none-any.whl", hash = "sha256:b3fcbed555c47d8479be0796ef7e19c2670d428d72e96da63f3a40122860374b", size = 22484, upload-time = "2026-04-14T04:09:18.638Z" }, ] +[[package]] +name = "fastapi" +version = "0.141.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-doc" }, + { name = "pydantic" }, + { name = "starlette" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/8a/02/91e3416a8fdd715abb903a952a6bec7cdd8d14eed55d415fc8595524c319/fastapi-0.141.1.tar.gz", hash = "sha256:e8822fc40db1e1858054d7a949a888695bc9bdce70139178e33bd2871a453ca1", size = 425799, upload-time = "2026-07-29T17:18:05.568Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/03/10388a42375ee7e4ac9b94eb2c5c569c8b5795e377e701c9ac3ad63de890/fastapi-0.141.1-py3-none-any.whl", hash = "sha256:bfb91aa2d334c61cb35ba9a116fc123b3d3df31640b801cf57a7a78ec3f603b3", size = 131954, upload-time = "2026-07-29T17:18:04.364Z" }, +] + [[package]] name = "h11" version = "0.16.0" @@ -401,13 +438,17 @@ dependencies = [ [package.dev-dependencies] dev = [ { name = "pytest" }, + { name = "quercus-core", extra = ["webui"] }, ] [package.metadata] requires-dist = [{ name = "quercus-core", editable = "core" }] [package.metadata.requires-dev] -dev = [{ name = "pytest", specifier = ">=8.0" }] +dev = [ + { name = "pytest", specifier = ">=8.0" }, + { name = "quercus-core", extras = ["webui"], editable = "core" }, +] [[package]] name = "quercus-core" @@ -422,14 +463,20 @@ dependencies = [ dev = [ { name = "pytest" }, ] +webui = [ + { name = "fastapi" }, + { name = "uvicorn" }, +] [package.metadata] requires-dist = [ { name = "anthropic", specifier = ">=0.40,<1.0" }, + { name = "fastapi", marker = "extra == 'webui'", specifier = ">=0.115,<1.0" }, { name = "httpx", specifier = ">=0.27,<1.0" }, { name = "pytest", marker = "extra == 'dev'", specifier = ">=8.0" }, + { name = "uvicorn", marker = "extra == 'webui'", specifier = ">=0.30,<1.0" }, ] -provides-extras = ["dev"] +provides-extras = ["webui", "dev"] [[package]] name = "sniffio" @@ -440,6 +487,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/e9/44/75a9c9421471a6c4805dbf2356f7c181a29c1879239abab1ea2cc8f38b40/sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2", size = 10235, upload-time = "2024-02-25T23:20:01.196Z" }, ] +[[package]] +name = "starlette" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b5/b4/205b0d5241d934e8add0c38aa924c4f9fb7330834ff11e5444db964ec3f9/starlette-1.6.0.tar.gz", hash = "sha256:d4e3ac5e546444960c710297a3c9fc3f7ebae1b7e963f3d36173b49da535be9b", size = 2716969, upload-time = "2026-08-08T18:27:57.512Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c8/cb/6a6a47d5b464bd08695d254f3da6e7986cc70c9fa5d778eda57538edfe56/starlette-1.6.0-py3-none-any.whl", hash = "sha256:a86dd39d14bb45f85a3d18525215a9ef0cfd1f192ac793220e72598c90335f0c", size = 75969, upload-time = "2026-08-08T18:27:56.196Z" }, +] + [[package]] name = "typing-extensions" version = "4.16.0" @@ -460,3 +520,16 @@ sdist = { url = "https://files.pythonhosted.org/packages/a3/26/b09b8010994eccc3c wheels = [ { url = "https://files.pythonhosted.org/packages/67/81/4add07e5172b7ac40d8ed5ff580409a7801a4fe26d529bdd915401dabfbe/typing_inspection-0.4.4-py3-none-any.whl", hash = "sha256:65b8397ba37ccbce054456aaccddfc91e6e3083c92824df348d96ca832f3f147", size = 14750, upload-time = "2026-08-12T12:37:24.648Z" }, ] + +[[package]] +name = "uvicorn" +version = "0.52.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "h11" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f2/0f/3f86e61397dd33bf2ccf28188c40db6a740658aeebbbf6e7dbc101a1f487/uvicorn-0.52.4.tar.gz", hash = "sha256:73acfee47a0b133c5de13d219492d62d8a31e935f4fe6e41a232451a15379f86", size = 100627, upload-time = "2026-08-19T06:27:41.821Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f1/79/4a20b54ab0491485ccd8c077db2d39187c7f12b3e15485d38a7be37c81b4/uvicorn-0.52.4-py3-none-any.whl", hash = "sha256:f86e41a149d7d05a9969337e3946a9c171c06a5d42680896daaba624aeac8da1", size = 79871, upload-time = "2026-08-19T06:27:40.36Z" }, +]