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)
This commit is contained in:
@@ -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() == [] # 连导入也被撤销(快照覆盖首个批次)
|
||||
@@ -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
|
||||
@@ -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))
|
||||
@@ -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
|
||||
@@ -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 "<html" in r.text
|
||||
|
||||
|
||||
def test_status_endpoint():
|
||||
app = create_app(adapter=MockHostAdapter(), provider=MockProvider(script=[]))
|
||||
client = TestClient(app)
|
||||
data = client.get("/api/status").json()
|
||||
assert data["ready"] is True
|
||||
|
||||
|
||||
# ---- confirm approve / reject(必须的验收路径) ----
|
||||
|
||||
def test_confirm_approve_flow():
|
||||
adapter = MockHostAdapter()
|
||||
provider = MockProvider(
|
||||
script=[
|
||||
AssistantTurn(
|
||||
text="",
|
||||
tool_calls=(ToolCall(id="c1", name="import_footage", arguments={"paths": ["/media/a.mp4"]}),),
|
||||
),
|
||||
AssistantTurn(text="导入完成", tool_calls=()),
|
||||
]
|
||||
)
|
||||
app = create_app(adapter=adapter, provider=provider, confirm_timeout=10)
|
||||
client = TestClient(app)
|
||||
assert client.post("/api/chat", json={"text": "导入素材 a"}).json()["ok"] is True
|
||||
|
||||
batch = _wait_pending(client)
|
||||
assert batch is not None
|
||||
assert batch["actions"][0]["tool"] == "import_footage"
|
||||
|
||||
r = client.post(f"/api/confirm/{batch['batch_id']}", json={"approved": True})
|
||||
assert r.status_code == 200 and r.json()["ok"] is True
|
||||
assert app.state.session.wait_done(10)
|
||||
assert len(adapter.list_footage()) == 1 # 确认后变更已执行
|
||||
|
||||
|
||||
def test_confirm_reject_flow():
|
||||
adapter = MockHostAdapter()
|
||||
provider = MockProvider(
|
||||
script=[
|
||||
AssistantTurn(
|
||||
text="",
|
||||
tool_calls=(ToolCall(id="c1", name="import_footage", arguments={"paths": ["/media/a.mp4"]}),),
|
||||
),
|
||||
AssistantTurn(text="已取消", tool_calls=()),
|
||||
]
|
||||
)
|
||||
app = create_app(adapter=adapter, provider=provider, confirm_timeout=10)
|
||||
client = TestClient(app)
|
||||
client.post("/api/chat", json={"text": "导入素材"})
|
||||
batch = _wait_pending(client)
|
||||
assert batch is not None
|
||||
|
||||
r = client.post(f"/api/confirm/{batch['batch_id']}", json={"approved": False})
|
||||
assert r.json()["ok"] is True
|
||||
assert app.state.session.wait_done(10)
|
||||
assert adapter.list_footage() == [] # 拒绝后未执行
|
||||
|
||||
|
||||
def test_confirm_unknown_batch_rejected():
|
||||
app = create_app(adapter=MockHostAdapter(), provider=MockProvider(script=[]))
|
||||
client = TestClient(app)
|
||||
r = client.post("/api/confirm/no_such_batch", json={"approved": True})
|
||||
assert r.json()["ok"] is False
|
||||
|
||||
|
||||
def test_chat_busy_guard():
|
||||
"""上一轮对话(待确认阻塞中)未结束前,拒绝新的发消息请求。"""
|
||||
provider = MockProvider(
|
||||
script=[AssistantTurn(text="", tool_calls=(ToolCall(id="c1", name="import_footage", arguments={"paths": ["/media/a.mp4"]}),))]
|
||||
)
|
||||
app = create_app(adapter=MockHostAdapter(), provider=provider, confirm_timeout=30)
|
||||
client = TestClient(app)
|
||||
assert client.post("/api/chat", json={"text": "第一条"}).json()["ok"] is True
|
||||
assert _wait_pending(client) is not None # 会话被待确认阻塞
|
||||
r = client.post("/api/chat", json={"text": "第二条"})
|
||||
assert r.json()["ok"] is False
|
||||
assert "进行" in r.json()["reason"]
|
||||
|
||||
|
||||
# ---- undo_session / 快照管理端点 ----
|
||||
|
||||
def test_undo_session_endpoint():
|
||||
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"]}))
|
||||
r = client.post("/api/undo_session")
|
||||
data = r.json()
|
||||
assert data["ok"] is True and data["restored"] >= 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()
|
||||
@@ -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",
|
||||
]
|
||||
@@ -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
|
||||
@@ -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))
|
||||
@@ -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}(只读工具应直通,不走批次)")
|
||||
@@ -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)
|
||||
Reference in New Issue
Block a user