- MockHostAdapter (tests/mock_host): in-memory editing model with opaque entity ids, id invalidation, token-bucket rate limiting, snapshot/restore with cap, event subscription, and a stdlib-zlib PNG encoder producing time-varying non-black frames - HostToolExecutor: all 26 tool handlers mapped to HostAdapter calls, snapshot orchestration for mutating batches, concurrent scan_timeline frame pipeline with token bucket and RateLimited backoff, undo_session() restoring per-batch snapshots in reverse, get_params min/max/choices reflection - Web UI (core[webui] extra): FastAPI + single-page vanilla JS, chat log, pending-confirmation list with approve/reject endpoints (timeout defaults to reject), progress, undo-session, snapshot management; SSE push + POST, bound to 127.0.0.1 - Host error types (HostError/EntityNotFound/RateLimited) and shared TokenBucket - P2 acceptance chain: import -> place -> split -> ripple delete -> effect + param -> frame verification (non-black, pixel changes at cut and after effect) -> undo_session restores original state Tests: 129 passed via uv run pytest (no network)
785 lines
32 KiB
Python
785 lines
32 KiB
Python
"""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}(只读工具应直通,不走批次)")
|