HEL-840: add isolated end-to-end evidence and reproducible review kit

Co-authored-by: multica-agent <github@multica.ai>
This commit is contained in:
2026-10-24 23:10:07 +08:00
co-authored by multica-agent
parent 5bb859f809
commit 4f4a75b3da
10 changed files with 1474 additions and 0 deletions
+3
View File
@@ -46,3 +46,6 @@ prototype/
# --- Reference clones (vendored for porting only) ---
reference/
# HEL-840 reproducible QA outputs are delivered as attachments.
/evidence/hel840/
+44
View File
@@ -0,0 +1,44 @@
HEL-840:第二版布局与提示词工具
本卡从 `leefer/hel-731-studio-project@415fb782362b2ac5b725e312ecbdbc9a782e9aa3` 分支施工。批准包 SHA256:`dfe02579065200e8f0edaa3114709fc44286f232fdaa76c34acd3f3814523508`。原件不改;截图中的项目、媒体、档案均为独立夹具,不编译进产品。
实现边界
- 素材库:筛选 / 网格 / 常驻详情;浏览器真实媒体元数据和 onerror;搜索、排序、时间分组、密度、完整/铺满、多选、键盘及复用全屏预览。批量存入复用既有 attach 接口,图片分别成为素材,视频明确选择不同场景;成功后刷新排除。删除仍由后端最终检查引用,分批遵守原 200 项限制。
- 工作室:继续创作、四个紧凑入口、项目主区、待确认/最近成果/队列右栏。复用 AProjectCard,保留改名/删除;只读 cover_path 和真实统计。队列汇总明确最近 20 条范围。
- 模型连接:统一列表、常驻详情、单一状态胶囊,中文错误和原始响应折叠,本浏览器测试记录。必填满足即可保存,测试不会保存配置。五智能体计数不混入提示词角色。ComfyUI 显示现有版本/设备字段;队列/显存未知。
- 设置:提示词模型只读;唯一编辑处为模型分工,单键增量保存 translate/polish,深链高亮对应行。没有第二个模型库或第二份配置。
- 新端点仅 `POST /api/prompt-tools/transform`。复用 Settings 角色解析与 LLMClient;请求开始捕获档案,纠正重试也使用同一档案,避免并发改配置使返回的模型身份失真。翻译双向/显式换向,润色同语言;预览不写原文,不创建任务。
- 共享提示词控件接创作台正向/反向/视频及工作台风格/素材/镜头编辑框;普通聊天不接。seq、文本递增版本和上下文递增版本共同保护,包含实际项目/来源字段身份及工作流选择 epoch。A→B→A、取消、切换、在途编辑的迟到结果均失效;结果后编辑需二次确认;5 秒撤销核对替换版本和上下文。
- 技术片段按完整表达式校验,含 LoRA、嵌套权重及参数值,纠正一次仍不合格返回错误而无结果。为保证权重归属,识别到的完整权重括号(包含其内词语)逐字保留;其余正文翻译/润色。普通专名和裸数不作逐字承诺。
- 独立断连监测不依赖 token 到达;取消协同取消上游任务并调用实际 `close()`,覆盖慢流、无首块和阻塞回退。45 秒间隔、120 秒总时长。修复既有 LLMClient 在流式 401/403 上读取未消费响应体、遮蔽原始状态的问题。关闭连接不等于供应商停止生成/计费。
视觉与动效
采用原有颜色/字体/圆角/动效 token,新增批准的几何 token;共享页头样式抽到唯一来源。创作台外壳/舞台不改,结果条沿现有 grid 重排。小窗角标仅防换行和给工作流名省略,不改变外壳。双环源码未变。
| 宽度 | 工作室右栏 | 素材筛选 / 详情 | 模型详情 |
| --- | --- | --- | --- |
| 1440 | 320 | 220 / 360 | 400 |
| 1366 | 296 | 200 / 300 | 360 |
| 1280 | 280 | 184 / 288 | 360 |
19 个批准的颜色/字体/几何 token × 12 组合,原 HTML 与产品 CSS 求值一致(228 项);实际产品 DOM 单独保留。60 张产品矩阵均无横溢;三个窗口生成按钮下沿分别 875/743/695,首屏可见。九组窗口/动效测试均验证结果条关闭恢复作品尺寸、作品和底座留距 ≥47px。
原稿与应用的媒体、文案、地址、计数不同,不能声称逐像素相同。对照查看器保留原件哈希、数据映射及独立产品截图;字体族一致,Linux Chromium 与原 Edge 的字体光栅化由总工在部署环境重拍核对。
自测与复现
- `npm run lint`、`check`、`test`、`build`;check 零错零警。未新增产品依赖。
- 相关后端套件 66 项通过,LLM 既有流/回退套件 19 项通过;提示词套件最终 34 项复测通过(含实际 HTTP 慢流/无首块/阻塞回退取消、档案并发切换、配置重新加载、删除回退)。日志分别交付,不重复累计测试数量。
- 隔离的真实前端→FastAPI→LLMClient→HTTP 假供应商,浏览器不拦截响应。60 张四主题三窗口×五页矩阵;45 张交互状态截图,含默认地址未启动、全部提示词状态、三个工作台面板、真实媒体失败/空目录/多选/存入移出、项目空态。原 39 图中的 X01/X02 是原型规范导航,不给产品新增该入口。
- 浏览器最后一轮 48 项断言通过,脚本错误 0。另交 1440×900 录屏和九组作品开关几何、三档双环定帧/样式记录。
- 独立服务进程实际停止后重启:translate/polish 指派保留,重启后真实 transform 请求分别路由到此前指定的不同假模型。
- `scripts/hel840/README.md`、capture.py、serve_fixture.py、exercise.py、motion.py、restart_config.py、compare_reference.py 供总工独立复拍。脚本仅操作显式指定的隔离目录。超时场景把测试进程总时钟缩到 1.5 秒,产品常量仍 120 秒。
- 不调用真实付费模型/GPU;夹具队列暂停,模型及 ComfyUI 测试均为本地假服务。未合并、未部署、未改现网数据/密钥/配置。
兼容与回退
无数据库迁移、无新配置顶级键,仅扩展角色白名单。现有档案、生成队列、attach/引用删除安全接口保持。总工部署前应同时快照 SQLite、calliope_config.json 和旧部署包,并记录实际构建入口/提交;这是独立部署步骤。本卡保留基线代码和隔离验收快照。回退代码时保留兼容的新数据库/配置写入,不能默认拿历史 DB/配置覆盖当前数据。
未测:真实收费供应商的译文质量和断连后计费、真实 GPU 生成、测试服务器最终部署画面。以上由独立审核/获准后的部署验收承接。
+18
View File
@@ -0,0 +1,18 @@
HEL-840 isolated verification
- Product build: `cd calliope-web && npm ci && npm run check && npm run lint && npm test && npm run build`.
- Backend: Python >=3.11, install existing project `[dev]` dependencies in `calliope-backend/.venv`; run `calliope-backend/.venv/bin/python -m pytest calliope-backend/tests/test_prompt_tools.py -q`.
- Capture: run `python3 scripts/hel840/capture.py --output ./evidence/hel840/final`. The orchestrating Python needs the existing Playwright test runtime; the server uses `calliope-backend/.venv/bin/python`.
- Chromium runtime libraries may be supplied via `HEL840_BROWSER_LIBS`; these are local test prerequisites, not product dependencies.
- `serve_fixture.py` creates only the explicitly named fixture directory. Never point it at existing user data. It serves the **built product**, real FastAPI router and real LLMClient, with an HTTP fake provider. Browser responses are not mocked.
- Fake images and project rows are deterministic QA data and never compiled into product. Approved original samples are separate and untouched.
- `capture.py` owns and cleans its exact child process after comparing the workspace daemon PID. It must finish in foreground; no service handoff is provided.
Additional verification:
- `python3 scripts/hel840/capture.py --output ./evidence/hel840/states --states-only` runs the full interaction assertions without the 60-image matrix.
- `python3 scripts/hel840/capture.py --output ./evidence/hel840/motion --motion-only` records the nine viewport/motion combinations and both rings at 1440×900 video resolution.
- `python3 scripts/hel840/restart_config.py --output ./evidence/hel840/restart` stops and restarts two exact owned server processes and checks persisted assignments plus actual transform routing.
- `python3 scripts/hel840/compare_reference.py --reference /path/to/approved-v2.html --output ./evidence/hel840/tokens.json` compares token values; it does not claim screenshot pixel equality.
The approved original HTML and PNGs must be supplied unchanged from the approved attachment. Keep originals, mapping and actual application captures separate. For cancellation/timeout screenshots the isolated fixture accelerates the total timeout to 1.5 seconds; the product uses 120 seconds. Complete recognized weighted expressions are preserved verbatim so their weights cannot move to another operand. Closing a request does not promise that a supplier stops charging.
+195
View File
@@ -0,0 +1,195 @@
"""Reproducible screenshots of built product against isolated real backend/fake HTTP LLM."""
from __future__ import annotations
import argparse
import json
import os
import subprocess
import time
import urllib.request
from pathlib import Path
from playwright.sync_api import sync_playwright
ROOT = Path(__file__).resolve().parents[2]
p = argparse.ArgumentParser()
p.add_argument("--output", type=Path, required=True)
p.add_argument("--quick", action="store_true")
p.add_argument("--states-only", action="store_true")
p.add_argument("--motion-only", action="store_true")
args = p.parse_args()
out = args.output.resolve()
out.mkdir(parents=True, exist_ok=True)
data = out / "fixture"
data.mkdir(exist_ok=True)
log = (out / "server.log").open("w")
proc = subprocess.Popen(
[
str(ROOT / "calliope-backend/.venv/bin/python"),
str(ROOT / "scripts/hel840/serve_fixture.py"),
"--data",
str(data),
"--port",
"18740",
],
stdout=log,
stderr=subprocess.STDOUT,
cwd=ROOT,
)
(out / "owned-process.json").write_text(
json.dumps(
{
"pid": proc.pid,
"purpose": "isolated fixture backend",
"cleanup": "foreground capture.py finally terminates exact child",
}
)
)
try:
for _ in range(100):
if proc.poll() is not None:
raise RuntimeError((out / "server.log").read_text()[-4000:])
try:
with urllib.request.urlopen(
"http://127.0.0.1:18740/api/health", timeout=1
) as response:
if response.status == 200:
break
except OSError:
time.sleep(0.1)
else:
raise RuntimeError("backend not ready")
with sync_playwright() as pw:
browser = pw.chromium.launch(
headless=True,
args=["--no-sandbox"],
env={
**os.environ,
"LD_LIBRARY_PATH": os.environ.get(
"HEL840_BROWSER_LIBS",
str(
ROOT
/ "evidence/hel840/browser-runtime/libs/usr/lib/x86_64-linux-gnu"
),
),
},
)
context = browser.new_context(
viewport={"width": 1440, "height": 900},
record_video_dir=str(out / "recordings"),
record_video_size={"width": 1440, "height": 900},
)
page = context.new_page()
errors = []
page.on("pageerror", lambda e: errors.append(str(e)))
themes = (
[]
if args.states_only or args.motion_only
else ["night"]
if args.quick
else ["night", "day", "grade", "bluehour"]
)
sizes = [(1440, 900)] if args.quick else [(1440, 900), (1366, 768), (1280, 720)]
metrics = []
for theme in themes:
for width, height in sizes:
page.set_viewport_size({"width": width, "height": height})
page.goto("http://127.0.0.1:18740/settings")
page.evaluate(
'(theme)=>localStorage.setItem("aigc-theme",theme)', theme
)
for label, route in [
("01-工作室", "/"),
("02-素材库", "/library"),
("03-模型连接", "/connections?tab=llm"),
("04-创作台翻译结果", "/image"),
("05-设置", "/settings"),
]:
page.goto("http://127.0.0.1:18740" + route)
page.wait_for_timeout(700)
if label.startswith("03"):
for n in range(2):
page.locator(".mrow").nth(n).get_by_role(
"button", name="测试", exact=True
).click()
page.locator(".mrow").nth(n).locator(".spill.ok").wait_for()
page.get_by_role("button", name="编辑", exact=True).nth(
1
).click()
page.wait_for_timeout(150)
if label.startswith("04"):
page.locator("#desk-prompt").wait_for()
page.locator("#desk-prompt").fill(
"一只猫,柔和侧光,<lora:cat:0.7>,(neon:1.2)"
)
page.locator(".pt-buttons").first.get_by_role(
"button"
).first.click()
page.locator(".pt-res").wait_for()
folder = out / "matrix" / theme
folder.mkdir(parents=True, exist_ok=True)
page.screenshot(path=str(folder / f"{label}-{width}x{height}.png"))
metrics.append(
{
"theme": theme,
"width": width,
"height": height,
"page": label,
"dom": page.evaluate(
"""()=>{const rect=s=>{const n=document.querySelector(s);if(!n)return null;const r=n.getBoundingClientRect(),c=getComputedStyle(n);return {x:r.x,y:r.y,width:r.width,height:r.height,bottom:r.bottom,font:c.font,fontFamily:c.fontFamily,color:c.color,background:c.backgroundColor}};return {overflow:document.documentElement.scrollWidth>innerWidth,theme:document.documentElement.dataset.theme,main:rect('main'),resume:rect('.resume'),library:rect('.lib2'),filter:rect('.mg-side'),detail:rect('.lib-detail'),models:rect('.mdl'),modelDetail:rect('.mdl-detail'),band:rect('.ptband'),dock:rect('.studio-form'),media:rect('.stage-media .print'),generate:rect('.gen')}}"""
),
}
)
(out / "geometry.json").write_text(
json.dumps(metrics, ensure_ascii=False, indent=2)
)
if args.motion_only:
from motion import record_motion
record_motion(page, data, out)
elif not args.quick:
from exercise import exercise
exercise(page, data, out)
(out / "browser-errors.json").write_text(
json.dumps(errors, ensure_ascii=False, indent=2)
)
print(
json.dumps(
{
"screenshots": len(metrics),
"browser_errors": errors,
"output": str(out),
},
ensure_ascii=False,
),
flush=True,
)
context.close()
browser.close()
finally:
# Compare against the workspace daemon before terminating only our own child.
raw = subprocess.check_output(
["multica", "daemon", "status", "--output", "json"], text=True
)
(out / "daemon-status.json").write_text(raw)
daemon = json.loads(raw)
def pids(obj):
if isinstance(obj, dict):
return [v for k, v in obj.items() if k in ("pid", "process_id")] + [
n for v in obj.values() for n in pids(v)
]
if isinstance(obj, list):
return [n for v in obj for n in pids(v)]
return []
if proc.pid not in pids(daemon):
proc.terminate()
try:
proc.wait(timeout=10)
except subprocess.TimeoutExpired:
proc.kill()
proc.wait(timeout=5)
log.close()
+101
View File
@@ -0,0 +1,101 @@
"""Compare approved original tokens with product CSS; screenshots remain separate artifacts."""
import argparse
import json
import os
from pathlib import Path
from playwright.sync_api import sync_playwright
p = argparse.ArgumentParser()
p.add_argument("--reference", type=Path, required=True)
p.add_argument("--output", type=Path, required=True)
args = p.parse_args()
root = Path(__file__).resolve().parents[2]
tokens = [
"--bg-0",
"--bg-1",
"--bg-2",
"--tx-1",
"--tx-2",
"--tx-3",
"--gold",
"--sc-image",
"--sc-video",
"--sc-manage",
"--font",
"--fs-body",
"--studio-side",
"--lib-filter",
"--lib-detail",
"--lib-tile-min",
"--mdl-detail",
"--mdl-list-row",
"--ptband-max-h",
]
css = (root / "calliope-web/static/themes.css").read_text() + (
root / "calliope-web/src/routes/app.css"
).read_text()
results = []
with sync_playwright() as pw:
browser = pw.chromium.launch(
headless=True,
args=["--no-sandbox"],
env={
**os.environ,
"LD_LIBRARY_PATH": os.environ.get(
"HEL840_BROWSER_LIBS",
str(
root
/ "evidence/hel840/browser-runtime/libs/usr/lib/x86_64-linux-gnu"
),
),
},
)
reference = browser.new_page()
product = browser.new_page()
reference.goto(args.reference.resolve().as_uri())
product.set_content(
"<!doctype html><style>" + css + "</style><body>CSS token probe</body>"
)
for theme in ["night", "day", "grade", "bluehour"]:
for width, height in [(1440, 900), (1366, 768), (1280, 720)]:
entry = {"theme": theme, "width": width, "height": height}
for name, page in [("original", reference), ("product_css", product)]:
page.set_viewport_size({"width": width, "height": height})
page.evaluate(
"(theme)=>document.documentElement.dataset.theme=theme", theme
)
entry[name] = page.evaluate(
"(tokens)=>Object.fromEntries(tokens.map(t=>[t,getComputedStyle(document.documentElement).getPropertyValue(t).trim()]))",
tokens,
)
entry["differences"] = {
key: {
"original": entry["original"][key],
"product_css": entry["product_css"][key],
}
for key in tokens
if "".join(entry["original"][key].split())
!= "".join(entry["product_css"][key].split())
}
results.append(entry)
browser.close()
args.output.write_text(
json.dumps(
{
"method": "Approved original browser CSS vs product source CSS evaluation; actual product DOM measurements are in geometry.json. No pixel equality is claimed for different media/text.",
"entries": results,
},
ensure_ascii=False,
indent=2,
)
)
print(
json.dumps(
{
"entries": len(results),
"differences": sum(len(r["differences"]) for r in results),
}
)
)
+352
View File
@@ -0,0 +1,352 @@
"""Interaction assertions use the product UI and live isolated backend, without response interception."""
import json
from pathlib import Path
from playwright.sync_api import expect
def exercise(page, data: Path, out: Path):
state_dir = out / "states"
state_dir.mkdir(exist_ok=True)
checks = []
control = data / "control.json"
base = "http://127.0.0.1:18740"
def mode(**kw):
control.write_text(json.dumps(kw))
def save(name):
page.screenshot(path=str(state_dir / (name + ".png")))
checks.append(name)
print("verified " + name, flush=True)
def desk():
mode()
page.goto(base + "/image")
page.locator("#desk-prompt").wait_for()
def input(text):
page.locator("#desk-prompt").fill(text)
def translate():
page.locator(".pt-buttons").first.get_by_role("button").first.click()
def result():
page.locator(".pt-res").wait_for()
def close():
page.get_by_role("button", name="关闭提示词结果").click()
def button(label):
return page.locator(".ptband").get_by_role("button", name=label, exact=True)
page.set_viewport_size({"width": 1440, "height": 900})
desk()
save("S00-创作台基准")
original = "一只猫,<lora:cat:0.7>,(neon:1.2)"
input(original)
translate()
result()
expect(page.locator("#desk-prompt")).to_have_value(original)
save("S01-翻译预览-原文未改")
button("换向重译").click()
result()
expect(page.locator(".pt-dir")).to_contain_text("英→中")
save("S02-显式换向")
close()
page.locator(".pt-buttons").first.get_by_role("button").nth(1).click()
result()
expect(page.locator(".pt-dir")).to_have_text("同语言润色")
save("S03-润色结果")
close()
mode(delay=0.8)
translate()
expect(page.locator(".pt-wait")).to_be_visible()
save("S04-处理中可取消")
button("取消").click()
page.wait_for_timeout(950)
expect(page.locator(".ptband")).to_have_count(0)
expect(page.locator("#desk-prompt")).to_have_value(original)
save("S05-取消迟到丢弃")
translate()
input("改动后的文本")
input(original)
page.wait_for_timeout(950)
expect(page.locator(".pt-res")).to_have_count(0)
expect(page.locator(".pt-confirm")).to_contain_text("结果已丢弃")
save("S06-在途ABA编辑丢弃")
mode()
button("按当前文本重新处理").click()
result()
input("结果出现后新输入")
save("S07-结果后编辑")
button("替换").click()
expect(button("仍然替换")).to_be_visible()
expect(page.locator("#desk-prompt")).to_have_value("结果出现后新输入")
save("S08-替换二次确认")
button("仍然替换").click()
expect(page.locator(".ptband")).to_have_count(0)
save("S09-替换撤销")
page.get_by_role("button", name="撤销", exact=True).click()
expect(page.locator("#desk-prompt")).to_have_value("结果出现后新输入")
input(original)
translate()
result()
button("替换").click()
input("新的文本")
input(
"A cat in soft side lighting, cinematic composition, <lora:cat:0.7>, (neon:1.2)"
)
page.get_by_role("button", name="撤销", exact=True).click()
expect(
page.get_by_text("内容已变化或已切换,未自动恢复", exact=True)
).to_be_visible()
save("S10-编辑后撤销被拒")
input(original)
mode(delay=0.8)
translate()
page.locator(".workflow-pick").first.click()
page.locator(".workflow-pick").last.click()
page.wait_for_timeout(950)
expect(page.locator(".ptband")).to_have_count(0)
save("S11-工作流ABA切换失效")
mode(status=401)
translate()
page.locator(".pt-error").wait_for()
expect(page.locator(".pt-error")).to_contain_text("密钥")
save("S12-认证失败真实服务响应")
close()
mode(delay=3)
translate()
page.locator(".pt-error").wait_for()
expect(page.locator(".pt-error")).to_contain_text("超时")
save("S13-总超时")
close()
mode(broken=True)
translate()
page.locator(".pt-error").wait_for()
expect(page.locator(".pt-error")).to_contain_text("技术片段")
expect(page.locator(".pt-res")).to_have_count(0)
save("S14-片段破坏纠正后仍失败")
close()
mode()
page.locator("#desk-negative").fill("模糊,低画质")
page.locator(".pt-buttons").nth(1).get_by_role("button").first.click()
result()
expect(page.locator(".pt-h")).to_contain_text("反向")
save("S15-反向提示词")
page.goto(base + "/video")
page.locator("#desk-prompt").fill("镜头缓慢推进")
translate()
result()
save("S16-视频提示词")
page.set_viewport_size({"width": 1280, "height": 720})
close()
page.locator(".pt-buttons").first.get_by_role("button").nth(1).click()
result()
save("S17-小窗润色")
close()
mode(delay=0.8)
translate()
save("S18-小窗处理中")
button("取消").click()
mode(status=401)
translate()
page.locator(".pt-error").wait_for()
save("S19-小窗失败")
close()
save("S20-小窗基准")
mode()
page.set_viewport_size({"width": 1440, "height": 900})
# Empty/long and the three editable workbench panels use the same real control.
desk()
input("")
expect(
page.locator(".pt-buttons").first.get_by_role("button").first
).to_be_disabled()
input("猫" * 20001)
translate()
page.locator(".pt-error").wait_for()
expect(page.locator(".pt-error")).to_contain_text("20000")
checks.append("empty-and-too-long")
settings = page.request.get(base + "/api/settings").json()
page.goto(base + "/settings")
page.get_by_role("button", name="去模型分工修改", exact=True).click()
expect(page.locator("#role-translate")).to_be_visible()
save("M01-设置跳转高亮")
select = page.locator("#role-translate select")
select.select_option("fixture-b")
page.wait_for_timeout(300)
page.locator("#role-polish select").select_option("fixture-a")
page.wait_for_timeout(300)
page.reload()
page.locator("#role-polish select").wait_for()
expect(page.locator("#role-translate select")).to_have_value("fixture-b")
expect(page.locator("#role-polish select")).to_have_value("fixture-a")
checks.append("independent-assignments-after-reload")
page.request.post(
base + "/api/settings",
data={"agent_llm_assignments": settings["agent_llm_assignments"]},
)
profile_list = [
{k: p[k] for k in ["id", "name", "model", "base_url"]}
for p in settings["llm_profiles"]
]
unavailable = {**profile_list[0], "base_url": "http://127.0.0.1:1/v1"}
page.request.post(base + "/api/settings", data={"llm_profiles": [unavailable]})
page.reload()
page.locator(".mrow").wait_for()
expect(page.locator(".mrow")).to_have_count(1)
page.locator(".mrow").first.get_by_role("button", name="测试", exact=True).click()
expect(page.locator(".mrow .spill")).to_contain_text("连接失败")
save("M02-仅默认档案")
page.goto(base + "/image")
page.locator("#desk-prompt").fill("一只猫")
translate()
page.locator(".pt-error").wait_for()
expect(page.locator(".pt-error")).to_contain_text("连接失败")
save("S12b-默认地址未启动")
page.request.post(
base + "/api/settings",
data={
"llm_profiles": profile_list,
"agent_llm_assignments": settings["agent_llm_assignments"],
},
)
page.goto(base + "/connections?tab=llm")
page.locator(".mrow").first.wait_for()
mode(status=401)
page.locator(".mrow").first.get_by_role("button", name="测试", exact=True).click()
expect(page.locator(".mdl-alert")).to_be_visible()
save("M03-模型测试失败")
page.get_by_role("button", name="添加模型", exact=True).click()
page.locator(".md-b input").nth(0).fill("本地不先测试")
page.locator(".md-b input").nth(1).fill("http://127.0.0.1:1/v1")
page.locator(".md-b input").nth(2).fill("local")
expect(page.get_by_role("button", name="保存", exact=True)).to_be_enabled()
save("M04-未测试可以保存")
page.goto(base + "/connections?tab=comfy")
page.locator(".mrow").first.wait_for()
page.locator(".mrow").first.get_by_role("button", name="测试", exact=True).click()
expect(page.locator(".mrow .spill")).to_contain_text("已连接")
page.locator(".mrow").first.get_by_role("button", name="编辑", exact=True).click()
save("M05-Comfy-未知如实显示")
page.goto(base + "/settings")
page.get_by_role("button", name="去模型分工修改").wait_for()
save("P01-设置只读工具卡")
mode()
for stage in ["style", "assets", "shot"]:
page.goto(base + f"/workbench/1?stage={stage}")
page.get_by_role("button", name="编辑", exact=True).click()
page.locator(".prompt-editor textarea").first.wait_for()
before = page.locator(".prompt-editor textarea").first.input_value()
page.locator(".prompt-editor .pt-buttons").first.get_by_role(
"button"
).first.click()
result()
expect(page.locator(".prompt-editor textarea").first).to_have_value(before)
save("workbench-" + stage + "-shared-control")
# Library: native loaded metadata, selection, full screen, safe delete, attach refresh.
mode()
page.goto(base + "/library")
page.locator(".lt").first.wait_for()
page.locator(".lt .ck").nth(0).click(force=True)
page.locator(".lt .ck").nth(1).click(force=True)
expect(page.locator(".selbar")).to_be_visible()
save("L01-多选选择条")
page.locator(".selbar").get_by_role("button", name="删除", exact=True).click()
page.get_by_role("dialog").wait_for()
save("L02-删除确认")
page.get_by_role("dialog").get_by_role("button", name="取消", exact=True).click()
page.locator(".selbar").get_by_role("button", name="取消", exact=True).click()
video = page.locator(".lt").filter(has_text="视频样本.webm")
video.locator(".mat").click()
expect(page.locator(".kv")).to_contain_text("640 × 360")
save("L03-视频元数据")
page.locator(".lt").filter(has_text="真实读取失败.png").locator(".mat").click()
expect(page.locator(".kv")).to_contain_text("未知")
save("L04-真实读取失败")
page.locator(".lt").filter(has_text="样本素材-01.png").locator(".mat").dblclick()
page.get_by_role("dialog").wait_for()
save("L05-全屏预览")
page.keyboard.press("Escape")
page.get_by_role("textbox", name="文件名查找").fill("no-match")
save("L06a-筛选空态")
uploads = data / "assets" / "uploads"
hidden = data / "assets" / "uploads-empty-state-backup"
uploads.rename(hidden)
try:
page.reload()
expect(page.get_by_text("还没有素材", exact=True)).to_be_visible()
save("L06-真正空目录")
finally:
hidden.rename(uploads)
page.reload()
page.locator(".lt").first.wait_for()
page.locator(".lt").filter(has_text="样本素材-01.png").locator(".mat").click()
page.locator(".ld-f").get_by_role("button", name="存入项目").click()
page.get_by_role("dialog").get_by_role(
"button", name="添加到项目", exact=True
).click()
page.get_by_role("dialog").locator("select").first.select_option("1")
save("L07-存入项目")
page.get_by_role("dialog").get_by_role("button", name="添加", exact=True).click()
expect(page.locator(".lt").filter(has_text="样本素材-01.png")).to_have_count(0)
save("L08-存入后刷新移出")
for n in range(7):
page.request.post(
base + "/api/projects",
data={
"title": "一段很长很长的项目标题,用来核验多行省略和边界 · "
+ str(n + 1)
},
)
page.goto(base + "/")
page.locator(".pg2").wait_for()
save("W01-多项目长标题")
import sqlite3
source = sqlite3.connect(data / "fixture.db")
backup = sqlite3.connect(data / "before-empty.db")
source.backup(backup)
backup.close()
source.close()
for project in page.request.get(base + "/api/projects").json():
response = page.request.delete(base + "/api/projects/" + str(project["id"]))
assert response.ok
page.reload()
expect(page.get_by_text("开始你的第一个片子", exact=True)).to_be_visible()
save("W02-真正无项目空态")
# Theme/motion state preservation and double ring unchanged: actual UI transitions.
import re
desk()
input("保留我的输入")
page.locator("#desk-negative").fill("保留反向")
for theme in ["夜片场", "日光棚", "调色间", "蓝调时刻"]:
page.get_by_role("button", name="主题", exact=True).click()
page.get_by_role("dialog").get_by_role("button", name=re.compile(theme)).click()
for motion in ["完整", "克制", "关闭"]:
page.get_by_role("dialog").get_by_role(
"button", name=motion, exact=True
).click()
expect(page.locator("#desk-prompt")).to_have_value("保留我的输入")
expect(page.locator("#desk-negative")).to_have_value("保留反向")
page.keyboard.press("Escape")
checks.append("four-themes-three-motion-preserve-both-prompts")
desk()
input("保留我的输入")
page.goto(base + "/agents")
page.wait_for_timeout(400)
for motion in ["full", "calm", "off"]:
page.evaluate('(motion)=>localStorage.setItem("aigc-motion",motion)', motion)
page.reload()
page.wait_for_timeout(500)
save("motion-" + motion + "-agent-rings")
control.write_text("{}")
(out / "interaction-checks.json").write_text(
json.dumps(checks, ensure_ascii=False, indent=2)
)
+69
View File
@@ -0,0 +1,69 @@
"""Record all approved viewport/motion combinations against actual product controls."""
import json
from playwright.sync_api import expect
def record_motion(page, data, out):
base = "http://127.0.0.1:18740"
checks = []
screenshots = out / "motion"
screenshots.mkdir(exist_ok=True)
(data / "control.json").write_text("{}")
def geometry():
return page.evaluate(
"""()=>{const media=document.querySelector('.stage-media .print').getBoundingClientRect(),dock=document.querySelector('.studio-form').getBoundingClientRect(),button=document.querySelector('.gen').getBoundingClientRect();return {mediaHeight:media.height,mediaBottom:media.bottom,dockTop:dock.top,gap:dock.top-media.bottom,generateBottom:button.bottom,viewport:innerHeight}}"""
)
for width, height in [(1440, 900), (1366, 768), (1280, 720)]:
page.set_viewport_size({"width": width, "height": height})
for mode, label in [("full", "完整"), ("calm", "克制"), ("off", "关闭")]:
page.goto(base + "/image")
page.locator(".stage-media .print img").wait_for()
page.locator("#desk-prompt").fill("保留我的输入,柔和侧光")
page.get_by_role("button", name="主题", exact=True).click()
page.get_by_role("dialog").get_by_role(
"button", name=label, exact=True
).click()
page.keyboard.press("Escape")
before = geometry()
page.locator(".pt-buttons").first.get_by_role("button").first.click()
page.locator(".pt-res").wait_for()
opened = geometry()
expect(page.locator("#desk-prompt")).to_have_value("保留我的输入,柔和侧光")
page.screenshot(path=str(screenshots / f"{width}-{mode}-result.png"))
page.get_by_role("button", name="关闭提示词结果").click()
page.wait_for_timeout(100)
after = geometry()
assert opened["gap"] >= 47 and opened["generateBottom"] <= height
assert abs(before["mediaHeight"] - after["mediaHeight"]) <= 1
checks.append(
{
"viewport": [width, height],
"mode": mode,
"before": before,
"opened": opened,
"after": after,
}
)
print(f"verified motion {width} {mode}", flush=True)
page.set_viewport_size({"width": 1440, "height": 900})
for mode, label in [("full", "完整"), ("calm", "克制"), ("off", "关闭")]:
page.goto(base + "/agents")
page.get_by_role("button", name="主题", exact=True).click()
page.get_by_role("dialog").get_by_role("button", name=label, exact=True).click()
page.keyboard.press("Escape")
# Full mode restarts only on a new page/task trigger by the approved existing policy.
page.reload()
page.wait_for_timeout(1000)
assert page.locator(".orb .ring").count() == 2
rings = page.locator(".orb .ring").evaluate_all(
"(nodes)=>nodes.map(n=>({animation:getComputedStyle(n).animationName,duration:getComputedStyle(n).animationDuration,display:getComputedStyle(n).display}))"
)
page.screenshot(path=str(screenshots / f"agent-rings-{mode}.png"))
checks.append({"agent": mode, "rings": rings})
(out / "motion-checks.json").write_text(
json.dumps(checks, ensure_ascii=False, indent=2)
)
+156
View File
@@ -0,0 +1,156 @@
"""Assemble separate approved originals, product evidence and reproducible metadata."""
import argparse
import hashlib
import json
import shutil
import subprocess
import zipfile
from pathlib import Path
parser = argparse.ArgumentParser()
parser.add_argument("--approved", type=Path, required=True)
parser.add_argument("--evidence", type=Path, required=True)
parser.add_argument("--output", type=Path, required=True)
args = parser.parse_args()
root = Path(__file__).resolve().parents[2]
evidence = args.evidence.resolve()
output = args.output.resolve()
output.mkdir(parents=True, exist_ok=True)
kit = output / "review"
kit.mkdir(exist_ok=True)
def copy(source, target):
target = kit / target
target.parent.mkdir(parents=True, exist_ok=True)
shutil.copy2(source, target)
for p in (args.approved / "四主题三窗口").rglob("*.png"):
copy(p, Path("originals") / p.relative_to(args.approved / "四主题三窗口"))
for name in [
"HEL-837-视觉规范-v2.md",
"HEL-838-技术方案-v2.txt",
"整合说明.txt",
"HEL-837-交互样本-v2.html",
]:
copy(args.approved / name, Path("originals") / name)
for p in (evidence / "delivery/matrix").rglob("*.png"):
copy(p, Path("product") / p.relative_to(evidence / "delivery/matrix"))
for p in (evidence / "default-verification/states").glob("*.png"):
copy(p, Path("states") / p.name)
for p in (evidence / "motion-hd/motion").glob("*.png"):
copy(p, Path("motion") / p.name)
for run, name in [("default-verification", "interactions"), ("motion-hd", "motion-hd")]:
for i, p in enumerate((evidence / run / "recordings").glob("*.webm")):
copy(p, Path("recordings") / f"{name}-{i}.webm")
for run, name in [
("delivery", "geometry.json"),
("delivery", "token-comparison.json"),
("default-verification", "interaction-checks.json"),
("motion-hd", "motion-checks.json"),
("restart", "restart-check.json"),
]:
copy(evidence / run / name, Path("metadata") / name)
for name in [
"lint-840.log",
"check-840.log",
"webtest-840.log",
"build-840.log",
"regression-840.log",
"llm-regression-840.log",
"test-840-py311.log",
"ruff-840.log",
"capture-840.log",
"default-ui-840.log",
"motion-840.log",
]:
copy(root / name, Path("logs") / name)
for run in ["delivery", "default-verification", "motion-hd"]:
copy(evidence / run / "server.log", Path("logs") / f"{run}-server.log")
copy(
evidence / run / "fixture/provider.jsonl",
Path("logs") / f"{run}-provider.jsonl",
)
copy(evidence / "restart/restart-server.log", Path("logs/restart-server.log"))
for p in (root / "scripts/hel840").glob("*"):
if p.is_file():
copy(p, Path("reproduction/scripts/hel840") / p.name)
copy(root / "docs/changelogs/2026-10-24-hel840-v2.md", Path("实现与自测说明.md"))
# Fake-only snapshots; never include daemon roster/state or installed configuration.
for p in (evidence / "default-verification/fixture/assets/uploads").glob("*"):
if p.is_file():
copy(p, Path("reproduction/fixture/assets/uploads") / p.name)
for name in ["before-empty.db", "calliope_config.json"]:
copy(
evidence / "default-verification/fixture" / name,
Path("reproduction/fixture") / name,
)
head = subprocess.check_output(
["git", "rev-parse", "HEAD"], cwd=root, text=True
).strip()
base = "415fb782362b2ac5b725e312ecbdbc9a782e9aa3"
patch = subprocess.check_output(["git", "diff", "--binary", base, head], cwd=root)
(kit / "reproduction/changes.patch").write_bytes(patch)
manifest = {
"head": head,
"base": base,
"branch": subprocess.check_output(
["git", "branch", "--show-current"], cwd=root, text=True
).strip(),
"approved_archive_sha256": "dfe02579065200e8f0edaa3114709fc44286f232fdaa76c34acd3f3814523508",
"counts": {
"original_matrix": 60,
"product_matrix": 60,
"interaction_states": len(list((kit / "states").glob("*.png"))),
"motion_stills": len(list((kit / "motion").glob("*.png"))),
},
"data_mapping": {
"projects": "5 fixture projects matching loaded/covered/placeholder/completed states; W01 adds 7 long titles; W02 deletes only fixture projects via real API.",
"models": "3 fixture profiles; first 2 probed by real settings test API; third untested. Translation follows active, polish assigned separately.",
"media": "Generated QA files decoded in browser; one valid 69-byte PNG, corrupt PNG, and real WebM. No product hardcoded samples.",
"queue": "20 real fixture DB rows, paused worker; 12 done plus running/pending/failed/cancelled. No GPU calls.",
"originals": "Approved design originals remain unchanged; media/text/address/latency data differs, so pixel equality is not claimed.",
},
"files": {},
}
for p in kit.rglob("*"):
if p.is_file():
manifest["files"][str(p.relative_to(kit))] = hashlib.sha256(
p.read_bytes()
).hexdigest()
(kit / "metadata/manifest.json").write_text(
json.dumps(manifest, ensure_ascii=False, indent=2)
)
(kit / "index.html").write_text(
"""<!doctype html><html lang="zh-CN"><meta charset="utf-8"><title>HEL-840 原件 / 实际产品对照</title><style>body{margin:20px;background:#10131a;color:#eee;font:14px system-ui}select{margin:8px;padding:8px}main{display:grid;grid-template-columns:1fr 1fr;gap:12px}img{width:100%;border:1px solid #454b58}a{color:#a8d7ff}p{max-width:1100px;line-height:1.7}h2{font-size:15px}</style><h1>HEL-840 · 原件与实际产品</h1><p>左:批准原稿;右:本地构建实际产品,经真实前后端与 HTTP 假服务取数。项目、图片、地址和计数为分别保留的数据,不声称逐像素相同。对比布局、样式和操作状态;实际 DOM、字体/颜色、token 核对、交互和进程重启记录见 metadata,脚本见 reproduction。最终部署由总工独立重拍。</p><select id="theme"><option>night</option><option>day</option><option>grade</option><option>bluehour</option></select><select id="size"><option>1440x900</option><option>1366x768</option><option>1280x720</option></select><select id="page"><option>01-工作室</option><option>02-素材库</option><option>03-模型连接</option><option>04-创作台翻译结果</option><option>05-设置</option></select><main><section><h2>批准原件</h2><img id="original"></section><section><h2>实际产品</h2><img id="product"></section></main><p><a href="metadata/manifest.json">版本 / 数据映射 / 哈希清单</a> · <a href="metadata/geometry.json">实际产品 DOM</a> · <a href="metadata/token-comparison.json">原件 token 核对</a> · <a href="metadata/interaction-checks.json">48 项交互断言</a> · <a href="metadata/motion-checks.json">九组动效 / 双环记录</a> · <a href="metadata/restart-check.json">真实进程重启</a></p><script>function show(){const path=theme.value+'/'+page.value+'-'+size.value+'.png';original.src='originals/'+path;product.src='product/'+path}document.querySelectorAll('select').forEach(s=>s.onchange=show);show()</script></html>""",
encoding="utf-8",
)
for path, source in [
(output / "HEL-840-原样本与实际产品证据.zip", kit),
(output / "HEL-840-前端构建.zip", root / "calliope-web/build"),
]:
with zipfile.ZipFile(
path, "w", compression=zipfile.ZIP_DEFLATED, compresslevel=6
) as archive:
for p in source.rglob("*"):
if p.is_file():
archive.write(p, p.relative_to(source))
print(
json.dumps(
{
"head": head,
"counts": manifest["counts"],
"artifacts": [
{
"name": p.name,
"bytes": p.stat().st_size,
"sha256": hashlib.sha256(p.read_bytes()).hexdigest(),
}
for p in output.glob("*.zip")
],
},
ensure_ascii=False,
)
)
+120
View File
@@ -0,0 +1,120 @@
"""Actual server stop/restart and role-routing persistence, isolated from user data."""
import argparse
import json
import subprocess
import time
import urllib.request
from pathlib import Path
parser = argparse.ArgumentParser()
parser.add_argument("--output", type=Path, required=True)
args = parser.parse_args()
root = Path(__file__).resolve().parents[2]
out = args.output.resolve()
out.mkdir(parents=True, exist_ok=True)
base = "http://127.0.0.1:18740"
log = (out / "restart-server.log").open("w")
owned = []
def request(method, path, body=None):
data = json.dumps(body).encode() if body is not None else None
req = urllib.request.Request(
base + path,
data=data,
headers={"Content-Type": "application/json"},
method=method,
)
with urllib.request.urlopen(req, timeout=5) as response:
return json.load(response)
def start(resume=False):
proc = subprocess.Popen(
[
str(root / "calliope-backend/.venv/bin/python"),
str(root / "scripts/hel840/serve_fixture.py"),
"--data",
str(out / "fixture"),
*(["--resume"] if resume else []),
],
cwd=root,
stdout=log,
stderr=subprocess.STDOUT,
)
owned.append(proc)
for _ in range(100):
if proc.poll() is not None:
raise RuntimeError("fixture backend exited")
try:
request("GET", "/api/health")
return proc
except OSError:
time.sleep(0.1)
raise RuntimeError("fixture readiness timeout")
def stop(proc):
if proc.poll() is not None:
return
status = json.loads(
subprocess.check_output(
["multica", "daemon", "status", "--output", "json"], text=True
)
)
def pids(obj):
if isinstance(obj, dict):
return [v for k, v in obj.items() if k in ("pid", "process_id")] + [
n for v in obj.values() for n in pids(v)
]
if isinstance(obj, list):
return [n for v in obj for n in pids(v)]
return []
assert proc.pid not in pids(status)
proc.terminate()
proc.wait(timeout=10)
try:
first = start()
request(
"POST", "/api/settings", {"agent_llm_assignments": {"translate": "fixture-b"}}
)
request("POST", "/api/settings", {"agent_llm_assignments": {"polish": "fixture-a"}})
saved = request("GET", "/api/settings")["agent_llm_assignments"]
assert saved["translate"] == "fixture-b" and saved["polish"] == "fixture-a"
stop(first)
second = start(True)
loaded = request("GET", "/api/settings")["agent_llm_assignments"]
assert loaded == saved
translation = request(
"POST", "/api/prompt-tools/transform", {"action": "translate", "text": "一只猫"}
)
polish = request(
"POST", "/api/prompt-tools/transform", {"action": "polish", "text": "一只猫"}
)
assert (
translation["model"]["id"] == "fixture-b"
and polish["model"]["id"] == "fixture-a"
)
result = {
"before_pid": first.pid,
"after_pid": second.pid,
"assignments": loaded,
"translation_model": translation["model"],
"polish_model": polish["model"],
"passed": True,
}
(out / "restart-check.json").write_text(
json.dumps(result, ensure_ascii=False, indent=2)
)
print(
json.dumps({"passed": True, "before_pid": first.pid, "after_pid": second.pid})
)
finally:
for proc in owned:
stop(proc)
log.close()
+416
View File
@@ -0,0 +1,416 @@
"""Local isolated product instance + HTTP fake LLM. Never touches deployment data."""
from __future__ import annotations
import argparse
import asyncio
import io
import json
import subprocess
import threading
import time
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path
import uvicorn
from calliope import config
from calliope.db import get_db, migrate_db
from calliope.main import create_app
from PIL import Image, ImageDraw
parser = argparse.ArgumentParser()
parser.add_argument("--data", required=True, type=Path)
parser.add_argument("--port", type=int, default=18740)
parser.add_argument("--resume", action="store_true")
args = parser.parse_args()
root = args.data.resolve()
root.mkdir(parents=True, exist_ok=True)
config.CONFIG_FILE = root / "calliope_config.json"
s = config.settings
prior_assignments = None
if args.resume:
s.load_config_file()
prior_assignments = dict(s.agent_llm_assignments)
s.data_dir = root
s.assets_dir = root / "assets"
s.assets_dir.mkdir(exist_ok=True)
s.db_name = "fixture.db"
s.dry_run = True
s.agent_shell_enabled = False
s.comfyui_endpoints = [
{
"id": "fixture-comfy",
"name": "本地隔离假服务",
"base_url": "http://127.0.0.1:1",
"auth_type": "none",
"auth_token": "",
}
]
s.comfyui_active_id = "fixture-comfy"
# Fake provider is intentionally a real HTTP boundary; product code is unchanged.
class Fake(BaseHTTPRequestHandler):
def log_message(self, *_args):
pass
def do_GET(self):
payload = json.dumps(
{
"system": {"comfyui_version": "fixture-0.3"},
"devices": [{"name": "Fixture GPU"}],
}
).encode()
self.send_response(200)
self.send_header("Content-Type", "application/json")
self.send_header("Content-Length", str(len(payload)))
self.end_headers()
self.wfile.write(payload)
def do_POST(self):
payload = json.loads(self.rfile.read(int(self.headers["Content-Length"])))
control_path = root / "control.json"
control = json.loads(control_path.read_text()) if control_path.exists() else {}
with (root / "provider.jsonl").open("a") as f:
# No prompt or secret logging: actual model, direction instruction, stream and timestamps only.
f.write(
json.dumps(
{
"at": time.time(),
"model": payload.get("model"),
"stream": payload.get("stream"),
"phase": "start",
}
)
+ "\n"
)
if control.get("blocking") and payload.get("stream"):
self.send_response(405)
self.end_headers()
return
self.send_response(control.get("status", 200))
self.send_header(
"Content-Type",
"text/event-stream" if payload.get("stream") else "application/json",
)
self.end_headers()
if control.get("status", 200) != 200:
return
text = next(
(m["content"] for m in payload["messages"] if m["role"] == "user"), "ping"
)
from calliope.routers.prompt_tools import fragments
protected = fragments(text)
instruction = payload["messages"][0]["content"]
result = (
"一只猫,柔和侧光,电影质感"
if "from English to Chinese" in instruction
else "A cat in soft side lighting, cinematic composition"
) + (" · 润色" if "Polish" in instruction else "")
if protected:
result += ", " + ", ".join(protected)
if control.get("broken"):
result = "Broken output"
delay = control.get("delay", 0.15)
try:
time.sleep(delay)
if payload.get("stream"):
for piece in [result[: len(result) // 2], result[len(result) // 2 :]]:
self.wfile.write(
(
"data: "
+ json.dumps({"choices": [{"delta": {"content": piece}}]})
+ "\n\n"
).encode()
)
self.wfile.flush()
time.sleep(control.get("chunk_delay", 0.05))
self.wfile.write(b"data: [DONE]\n\n")
else:
self.wfile.write(
json.dumps({"choices": [{"message": {"content": result}}]}).encode()
)
except (BrokenPipeError, ConnectionResetError):
with (root / "provider.jsonl").open("a") as f:
f.write(json.dumps({"at": time.time(), "phase": "disconnected"}) + "\n")
provider = ThreadingHTTPServer(("127.0.0.1", 0), Fake)
thread = threading.Thread(target=provider.serve_forever, daemon=True)
thread.start()
s.llm_profiles = [
{
"id": "fixture-a",
"name": "隔离翻译模型",
"model": "fixture-translate",
"base_url": f"http://127.0.0.1:{provider.server_port}/v1",
"api_key": "",
},
{
"id": "fixture-b",
"name": "隔离润色模型",
"model": "fixture-polish",
"base_url": f"http://127.0.0.1:{provider.server_port}/v1",
"api_key": "",
},
]
s.comfyui_endpoints[0]["base_url"] = f"http://127.0.0.1:{provider.server_port}"
s.llm_profiles.append(
{
"id": "fixture-c",
"name": "本地候选模型",
"model": "fixture-local",
"base_url": "http://127.0.0.1:1/v1",
"api_key": "",
}
)
s.llm_active_id = "fixture-a"
s.agent_llm_assignments = {
"translate": None,
"polish": "fixture-b",
"style": "fixture-b",
"video": "fixture-b",
}
s.apply_active_llm()
if prior_assignments is not None:
s.agent_llm_assignments = prior_assignments
s.save_config_file()
asyncio.run(migrate_db(s.db_path))
from calliope.routers import prompt_tools
prompt_tools.TOTAL_TIMEOUT = 1.5 # Accelerated total clock only in isolated QA process.
# A fixture-only deterministic media set, separate from product source and approved originals.
uploads = s.assets_dir / "uploads"
uploads.mkdir(exist_ok=True)
for index in range(14):
path = uploads / f"样本素材-{index + 1:02}.png"
im = Image.new(
"RGB",
(960 if index % 2 else 640, 640 if index % 2 else 960),
(28 + index * 8, 45 + index * 6, 60 + index * 5),
)
draw = ImageDraw.Draw(im)
draw.ellipse((80, 80, 400, 400), fill=(190, 145, 80))
draw.rectangle((100, 430, 530, 530), fill=(40, 110, 125))
im.save(path)
buffer = io.BytesIO()
Image.new("RGB", (640, 360), (34, 75, 95)).save(buffer, format="JPEG")
ffmpeg = Path.home() / ".cache/ms-playwright/ffmpeg-1011/ffmpeg-linux"
if ffmpeg.exists():
subprocess.run(
[
str(ffmpeg),
"-y",
"-f",
"image2pipe",
"-r",
"12",
"-c:v",
"mjpeg",
"-i",
"pipe:0",
"-c:v",
"libvpx",
"-f",
"webm",
str(uploads / "视频样本.webm"),
],
input=buffer.getvalue() * 24,
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
check=True,
)
(uploads / "真实读取失败.png").write_bytes(b"bad media")
Image.new("RGB", (1, 1), (255, 0, 0)).save(uploads / "小于1KB可解码.png")
conn = get_db(s.db_path)
if not conn.execute("SELECT 1 FROM projects LIMIT 1").fetchone():
for index in range(5):
title = [
"雾港来信",
"夏日站台",
"城市漫游者",
"旧照片里的猫",
"HEL690 冒烟项目",
][index]
cover = (
str(uploads / f"样本素材-{index + 1:02}.png")
if index not in [2, 4]
else None
)
pid = conn.execute(
"INSERT INTO projects(title,idea,cover_path) VALUES(?,?,?)",
(title, "隔离验收夹具", cover),
).lastrowid
for n in range(3 if index == 0 else 1):
conn.execute(
"INSERT INTO characters(project_id,name) VALUES(?,?)",
(pid, "角色 " + str(n + 1)),
)
for n in range(12 if index == 0 else 3):
conn.execute(
"INSERT INTO scenes(project_id,order_index,heading) VALUES(?,?,?)",
(pid, n + 1, "场景 " + str(n + 1)),
)
for key in ["script", "style", "storyboard", "assets", "shot"]:
conn.execute(
"INSERT INTO project_stages(project_id,stage_key,status,content_json) VALUES(?,?,?,?)",
(
pid,
key,
"confirmed"
if (index == 3 or (index == 0 and key in ["script", "style"]))
else "awaiting_confirm"
if (
index == 0
and key == "storyboard"
or index == 1
and key == "style"
)
else "todo",
"{}",
),
)
for index, kind in enumerate(["image", "image", "video"]):
workflow = {
"1": {
"class_type": "CLIPTextEncode",
"inputs": {"text": "一只猫,柔和侧光"},
"_meta": {"title": "Prompt (Input:prompt)"},
},
"2": {
"class_type": "CLIPTextEncode",
"inputs": {"text": "模糊,低画质"},
"_meta": {"title": "Negative (Input:negative)"},
},
"9": {
"class_type": "SaveImage" if kind == "image" else "SaveVideo",
"inputs": {"images": ["1", 0]},
"_meta": {"title": "Output (Output:" + kind + ")"},
},
}
from calliope.models.schemas import WorkflowCreate
from calliope.routers.workflows import create_workflow
conn.commit()
asyncio.run(
create_workflow(
WorkflowCreate(
name="隔离工作流 " + str(index + 1),
kind=kind,
workflow_json=workflow,
usage="text2image" if kind == "image" else "text2video",
)
)
)
content_by_stage = {
"style": {
"directions": [
{
"id": "s1",
"name": "柔光",
"description": "隔离夹具",
"prompt": "一只猫,柔和侧光",
"negative_prompt": "模糊",
},
{
"id": "s2",
"name": "暮色",
"description": "隔离夹具",
"prompt": "夜景中的猫",
"negative_prompt": "",
},
]
},
"assets": {
"assets": [
{
"id": "a1",
"kind": "character",
"name": "猫",
"description": "隔离夹具",
"prompt": "一只猫,柔和侧光",
"negative_prompt": "模糊",
}
]
},
"shot": {
"shots": [
{
"id": "v1",
"shot_no": "S1",
"description": "隔离夹具",
"prompt": "镜头缓慢推进",
"duration_sec": 5,
}
]
},
}
for key, content in content_by_stage.items():
conn.execute(
"UPDATE project_stages SET content_json=?,status='awaiting_confirm' WHERE project_id=1 AND stage_key=?",
(json.dumps(content), key),
)
scratch = conn.execute(
"INSERT INTO projects(title,status) VALUES('隔离独立创作','system')"
).lastrowid
for index in range(20):
status = (
"done"
if index < 12
else [
"running",
"pending",
"failed",
"cancelled",
"cancelled",
"cancelled",
"cancelled",
"cancelled",
][index - 12]
)
kind = "video" if index % 2 else "image"
output = (
str(uploads / "视频样本.webm")
if kind == "video"
else str(uploads / f"样本素材-{index % 14 + 1:02}.png")
)
conn.execute(
"INSERT INTO jobs(project_id,kind,status,payload_json,output_paths_json,workflow_id) VALUES(?,?,?,?,?,?)",
(
scratch,
kind,
status,
json.dumps(
{
"source": "studio-" + kind,
"input_values": {"1": "一只猫,柔和侧光", "2": "模糊"},
}
),
json.dumps([output] if status == "done" else []),
2 if kind == "image" else 3,
),
)
conn.commit()
conn.close()
(root / "runtime.json").write_text(
json.dumps(
{"port": args.port, "provider_port": provider.server_port, "data": str(root)}
)
)
from calliope.queue.manager import queue_manager
queue_manager.paused = (
True # Keep seeded QA queue states stable; never execute GPU jobs.
)
try:
uvicorn.run(
create_app(Path(__file__).resolve().parents[2] / "calliope-web" / "build"),
host="127.0.0.1",
port=args.port,
log_level="warning",
)
finally:
provider.shutdown()
thread.join()
provider.server_close()