Files
quercus/tests/mock_host/host.py
T
Mike-Solar 1b66a70859 P2: tool adapter layer, mock host, frame pipeline, and local web UI
- 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)
2026-08-25 02:40:43 +08:00

785 lines
32 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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}(只读工具应直通,不走批次)")