HEL-883: audit deployed visuals and mount canvas lightbox regressions
Co-authored-by: multica-agent <github@multica.ai>
This commit is contained in:
@@ -0,0 +1,36 @@
|
||||
# HEL-883:视觉变化取证(未定位到本轮新增视觉回归)
|
||||
|
||||
基线 `344fede19833e936582b0fd7d8a85aec28edb59a`,集成头/本卡起点 `2ad5d33c7227136e09c7babbba64254c1ec63236`,目标 `leefer/hel-731-studio-project`。本卡只增加取证工具和记录,产品源码零修改;没有以新设计或整包回退代替定位,也不宣称用户反馈已被解决。
|
||||
|
||||
## 定位表
|
||||
|
||||
| 核查位置 | 实测与依据 | 结论/处置 |
|
||||
| --- | --- | --- |
|
||||
| 线上真实入口/静态包 | `/create?kind=image`、`/library` 实际截图与资源 URL;`start.lI2IEPce.js`;部署包全部 127 文件与服务端实际静态目录逐字节相同 | 未发现这两个入口加载错误部署包。浏览器旧标签页/旧缓存状态未知 |
|
||||
| 服务器源码与浏览器产物 | 服务端保留的 `calliope-web/src` 多处旧于仓库;28 个当前构建 CSS 与实际服务文件完全相同;本机构建 JS 文件名与部署包不同 | 源码目录不能充当浏览器版本证据。额外下载改前、当前真实部署包独立重放,不以版本标记推定显示一致 |
|
||||
| `344fede→2ad5d33` | 实际 14 个前端产品文件(含 7 个语言文件),不是卡内概述的 13 个;涉及灯箱、SafeMedia、槽位、Drawer、素材库 | 对新增调用与共享行为做实际挂载回归;不凭无 CSS diff 否认反馈 |
|
||||
| 创作台静态/输入 | 本机构建与真实部署包分别比较,固定隔离工作流、同空槽、无作品/历史、相同主题/窗口;原定版 HEL-726 SHA 核实;已有槽、管理弹层和灯箱另测 | 所测静态状态未发现新增布局/配色差异。已有→预览为用户明确批准;空→添加、替换、移除、多图、首尾帧保留 |
|
||||
| 素材库 | 同两份隔离图片/视频,四主题三窗口;旧/新真实部署包比较;线上独立只读截图 | 所测布局未发现新增偏差;删除保留原因文案属于另一已授权修复,不回退 |
|
||||
| 画布 4 `/canvas/4?session=17` | 从只读 SQLite 导出用户最近项目画布(项目 7,11 个实体,0 产物,0 边);17 仅是隔离测试会话;同节点/位置/保存视口镜像重放 | 与画布 5 不是同一内容范围;没有加入演示节点或生成假过程 |
|
||||
| 画布 5 `/canvas/5?session=16` | 用户最近沙盒画布,0 卡片,6 条现有消息;只读事件类型汇总保留 | 页面空态有真实数据依据。不能据此认定用户实际进入的就是这个画布 |
|
||||
| `/canvas` 导航 | 源码按本地活动会话/最新会话选择项目或沙盒,再 ensure 画布;该入口可以 POST,画布 GET 也会 seed | 线上不直接开画布、不请求 seed GET。入口逻辑与上轮基线相同;用户当时 URL、查询、存储键未知 |
|
||||
| 已批画布样本 | HEL-814 主卡明确“同意”;HTML 与《总管核对补充》原件不改;2 张隔离媒体卡只用于灯箱验证,运行时映射到原样本,12 组图/数表 | 产物宽 220、媒体高 124、缩放控件 40×160、小地图 172×110、助手 320/300 与批准样本吻合;壳层 1px 高度差已有 HEL-818 记录。沙盒胶囊/实际按钮图标和示例项目胶囊属于既有真实状态差异,不做新设计 |
|
||||
| 共享 ImageLightbox/Drawer | 槽位、嵌套素材管理、试运行抽屉、素材库、画布图片/视频实际打开;焦点圈定/返回、滚动锁/恢复、手动播放、拖动和坐标持久化 | 未发现本轮新增的布局或交互必修项;旧 `opened=false` 未计入任何通过项 |
|
||||
| 视口冷启动边界 | 首次无本地镜像时,基线与当前均可 fit 到 38%,即使服务端已有另一视口;同一来源的保存镜像存在时正常恢复 | 发现的既有边界,不能证明是用户本次变化原因,未扩大本卡修改画布。独立保留首次重放日志与镜像条件说明 |
|
||||
|
||||
## 对照方法与验证结果
|
||||
|
||||
- 1440×900、1366×768、1280×720;night/day/grade/bluehour;离线同字体回退;静态比较均为关闭动效,鼠标移出控件。未把本次截图冒充完整/克制动效实测。
|
||||
- 4 个产物版本(本地基线、本地当前、改前真实部署包、当前真实部署包)×5 个页面状态×12 条件,240 张原图。两组各 60 对比较:本地 50 对逐像素相同、其余至多 88 像素不同;真实部署包 45 对相同、其余至多 74 像素不同(约 0.006%)。差异边界在文字/图标边缘,测得的公共区域几何/计算样式相同;不据此声称全站一致。
|
||||
- 另附 36 张批准原件截图。创作台只映射空历史/空素材状态,批准样本工作流名称/目录仍是演示数据,不能称整页同数据像素一致。素材库原件也保留演示素材,重点对照已批准布局,不伪造产品能力。
|
||||
- 画布样本追加 12 组同媒体、同坐标/比例映射,比较画布区域;助手演示对话不纳入裁图。独立保存映射 JSON,原始 HTML/CSS 不改。非逐像素零差承诺。
|
||||
- `scripts/hel871/capture.py` 当前真实输入链 102/102,通过实际上传、多图/首尾帧、切工作流清预览、替换/移除、图片缩放、视频/音频手动播放、坏媒体、试运行 Drawer 和素材库灯箱;基线脚本复现已有槽点击打开文件选择器。
|
||||
- 本卡主重放 35/35、JS 错误 0;补充批准样本/视口保存重放 19/19,JS 错误 0。实际断言 `.backdrop` 可见,画布图片与视频均打开;检查 Esc 返回原入口和 body overflow/scroll 恢复、视频手动播放、拖动入库及重载。
|
||||
- 前端 `check` 0 错 0 警;`lint`、`test`、`build` 通过。构建保留既有三方未使用导入与大包提示;首次干净构建另有生成前 tsconfig 提示,原始日志完整保留,不称构建全零提示。后端无改动,没有重跑后台全量。
|
||||
- 初次工具错误(素材库选择器、参考映射 CSS 优先级、测试初始化覆盖视口镜像)保留原始尝试日志与失败报告;修正验证工具后再执行,失败不冒充产品通过。
|
||||
|
||||
## 限制与交接
|
||||
|
||||
用户所述变化的确切位置/当时状态仍未知;已测状态没有足以支持视觉回退的证据,因此产品最小修正为零。不能以本次调查代替用户最终验收,也不保证未经测试的状态无问题。没有合并、部署、改服务器配置/数据/媒体或调用真实生成;未接入占用定位 API,未改智能体双入口或将画布改成流程编辑器。`emil-skills` 在本运行提供的技能与本机已查位置中不可用,本次按已批准样本及 v3 动效约束审查源码,没有用泛化技能重解释定版;完整/克制动态重播未做。
|
||||
|
||||
线上浏览器拦截所有非 GET/HEAD 及全部 canvas API;实际拦截两个自动 ComfyUI endpoint-test POST。远端 SQLite 使用 `mode=ro` + `query_only`,只导出选定画布所需行及关联消息/事件汇总,未下载配置或密钥。测试后先核 Multica daemon PID,再停各自子进程;临时库/媒体清除,原始证据保留在任务附件。代码仓库仅提交复现脚本与本文。
|
||||
@@ -0,0 +1,13 @@
|
||||
HEL-883 视觉取证;产品代码零修改。
|
||||
|
||||
先读 `docs/changelogs/2026-10-26-hel883-visual-investigation.md`。需要环境已有 Python/Playwright/Pillow、后端依赖、Chromium 系统库、Node;不新增产品依赖。
|
||||
|
||||
1. 使用 Multica CLI 下载批准 HEL-726、HEL-814 HTML 和 HEL-837 第二版附件;按取证报告核 SHA,保留原件不改。
|
||||
2. `read_canvas.py` 仅适用于本卡已授权测试站,默认画布 4,`--board 5` 选择另一画布。通过 `ssh root@192.168.200.15 python3 - --board 4 < scripts/hel883/read_canvas.py` 只读导出到证据目录 `production-board4.json`;另一份存 `production-board5.json`。不读取配置/密钥,也不使用可能 seed 的线上画布 GET。
|
||||
3. 构建冻结 `344fede` 与当前头。下载卡内已核验的改前/当前部署包到本地解压,分别指向真实静态根目录。不要在服务器部署或重建。
|
||||
4. `python3 scripts/hel883/capture.py --output <证据目录> --baseline <基线构建> --deployed <当前部署包根> --previous <改前部署包根> --references <原件目录>`。原件目录需包含 `canvas-sample.html`;`--only-reference` 只执行部署包同状态样本比较和画布灯箱/持久化回归。
|
||||
5. `readonly.py --output <证据目录> --references <原件目录>` 采线上 `/create?kind=image`、`/library` 两个只读入口与批准原件矩阵;原件目录另含 `夜片场-多主题交互样本.html`、`extracted/HEL-837-交互样本-v2.html`。线上所有非只读及 canvas API 都被拦截。
|
||||
|
||||
`capture.py` 占用仅本地 18383/15383,开跑先验证端口空闲,暂停隔离队列、LLM/ComfyUI 指向不可用回环地址;唯一媒体夹具明确标注非用户画布,不用于“丰富”产品内容。进程在前台 finally 回收,终止前比对 daemon;隔离目录由 TemporaryDirectory 清理,原始报告/截图/日志保留。参考映射 JSON 含两张测试媒体的截图数据,不含凭据。
|
||||
|
||||
输入槽完整回归继续复用 `scripts/hel871/capture.py`,需要单独执行;它的构建、真实上传、焦点/嵌套 Drawer 验证见原 README。源码与部署产物不必共享构建哈希;本卡额外以部署包 127/127 文件与服务端实际静态目录一致证明取证对象。
|
||||
@@ -0,0 +1,216 @@
|
||||
"""Foreground, loopback-only before/current audit and selected production replay.
|
||||
|
||||
Needs existing Playwright/Pillow/backend dependencies. Production export is read
|
||||
only; write/drag assertions run exclusively against this disposable database.
|
||||
"""
|
||||
import argparse
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import shutil
|
||||
import signal
|
||||
import socket
|
||||
import sqlite3
|
||||
import subprocess
|
||||
import tempfile
|
||||
import time
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
|
||||
from PIL import Image, ImageChops
|
||||
from playwright.sync_api import sync_playwright, expect
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
p = argparse.ArgumentParser()
|
||||
p.add_argument('--output', type=Path, required=True)
|
||||
p.add_argument('--baseline', type=Path, required=True)
|
||||
p.add_argument('--references', type=Path, required=True)
|
||||
p.add_argument('--deployed', type=Path, required=True)
|
||||
p.add_argument('--previous', type=Path, required=True)
|
||||
p.add_argument('--only-reference', action='store_true')
|
||||
a = p.parse_args()
|
||||
out = a.output.resolve(); out.mkdir(parents=True, exist_ok=True)
|
||||
report = {'checks': [], 'errors': [], 'geometry': [], 'comparisons': [], 'writes': []}
|
||||
API = 'http://127.0.0.1:18383'
|
||||
WEB = 'http://127.0.0.1:15383'
|
||||
|
||||
|
||||
def check(name, value):
|
||||
report['checks'].append({'name': name, 'pass': bool(value)})
|
||||
print('PASS' if value else 'FAIL', name, flush=True)
|
||||
assert value, name
|
||||
|
||||
|
||||
def api(path, body=None, method=None):
|
||||
r = urllib.request.Request(API + '/api' + path,
|
||||
data=None if body is None else json.dumps(body).encode(),
|
||||
headers={'Content-Type': 'application/json'},
|
||||
method=method or ('GET' if body is None else 'POST'))
|
||||
with urllib.request.urlopen(r, timeout=10) as response:
|
||||
return json.load(response)
|
||||
|
||||
|
||||
|
||||
def geometry(page):
|
||||
return page.evaluate('''()=>Object.fromEntries(['.desk','.stage','.composer','.media-summary','.library-layout','.flow-panel','.chat-panel','.svelte-flow__controls','.svelte-flow__minimap'].map(s=>{let e=document.querySelector(s);if(!e)return [s,null];let r=e.getBoundingClientRect(),c=getComputedStyle(e);return [s,{x:r.x,y:r.y,w:r.width,h:r.height,background:c.backgroundColor,color:c.color,font:c.font,padding:c.padding}]}))''')
|
||||
|
||||
|
||||
def seed(runtime):
|
||||
db = sqlite3.connect(runtime/'data/calliope.db')
|
||||
# Import only the two explicitly selected boards; no credentials/config or
|
||||
# unrelated user/project records are copied from the server.
|
||||
for board in [4, 5]:
|
||||
snap = json.loads((out/f'production-board{board}.json').read_text())
|
||||
for table in ['projects','agent_sessions','characters','locations','items','canvas','canvas_node','canvas_edge','agent_messages']:
|
||||
for row in snap.get(table, []):
|
||||
columns = ','.join('"'+k+'"' for k in row)
|
||||
db.execute('INSERT INTO '+table+' ('+columns+') VALUES ('+','.join('?' for _ in row)+')', list(row.values()))
|
||||
# An empty, explicitly isolated session for the selected project's UI.
|
||||
db.execute("INSERT INTO agent_sessions(id,title,project_id) VALUES(17,'隔离项目会话',7)")
|
||||
assets = runtime/'data/assets/uploads'; assets.mkdir(parents=True,exist_ok=True)
|
||||
for src, dst in [(ROOT/'calliope-web/static/refract/coast.jpg',assets/'HEL883-fixture.jpg'),(ROOT/'e2e/tiny.mp4',assets/'HEL883-fixture.mp4')]:
|
||||
shutil.copyfile(src,dst)
|
||||
db.execute("INSERT INTO agent_sessions(id,title) VALUES(18,'HEL883 共享灯箱隔离回归')")
|
||||
db.execute("INSERT INTO canvas(id,title,agent_session_id,viewport_json) VALUES(6,'HEL883 测试夹具(非用户画布)',18,?)", (json.dumps({'x':0,'y':0,'zoom':1}),))
|
||||
for kind,x in [('image',40),('video',320)]:
|
||||
db.execute("INSERT INTO canvas_node(canvas_id,type,title,x,y,status,artifact_path) VALUES(6,?,'HEL883 灯箱夹具',?,140,'done',?)",(kind,x,str(assets/('HEL883-fixture.jpg' if kind=='image' else 'HEL883-fixture.mp4'))))
|
||||
db.commit();db.close()
|
||||
graph = {'1': {'class_type':'PrimitiveStringMultiline','inputs':{'value':'HEL883 同状态视觉核对'},'_meta':{'title':'Prompt (Input:prompt)'}}, '2': {'class_type':'LoadImage','inputs':{'image':''},'_meta':{'title':'Image (Input:image)'}},'3':{'class_type':'SaveImage','inputs':{'images':['2',0]}}}
|
||||
return api('/workflows', {'name':'HEL883 隔离图生图','kind':'image','workflow_json':graph})['id']
|
||||
|
||||
|
||||
def matrix(browser,wf):
|
||||
phases=[('base',a.baseline.resolve()),('current',ROOT/'calliope-web/build'),('previous',a.previous.resolve()),('deployed',a.deployed.resolve())]
|
||||
if a.only_reference: phases=phases[-1:]
|
||||
for phase, build in phases:
|
||||
(out/'active-build.txt').write_text(str(build))
|
||||
context = browser.new_context(viewport={'width':1440,'height':900})
|
||||
context.add_init_script("localStorage.setItem('aigc-lang','zh');localStorage.setItem('aigc-motion','off');localStorage.setItem('aigc-theme','night')")
|
||||
mirrors={str(board):json.loads((out/f'production-board{board}.json').read_text())['canvas'][0]['viewport_json'] for board in [4,5]}
|
||||
mirrors['6']=json.dumps({'x':0,'y':0,'zoom':1})
|
||||
context.add_init_script('Object.entries('+json.dumps(mirrors)+').forEach(([id,v])=>{if(!localStorage.getItem("calliope.canvas.viewport."+id))localStorage.setItem("calliope.canvas.viewport."+id,v)})')
|
||||
context.route('https://**/*',lambda r:r.abort())
|
||||
page = context.new_page();page.set_default_timeout(12000)
|
||||
page.on('pageerror',lambda e:report['errors'].append(str(e)))
|
||||
page.on('request',lambda r:report['writes'].append({'phase':phase,'method':r.method,'path':r.url.split('15383')[-1]}) if r.method not in ['GET','HEAD'] else None)
|
||||
for label,path,ready in [('create',f'/create?kind=image&wf={wf}','#desk-prompt'),('library','/library','.lt'),('board4','/canvas/4?session=17','.canvas-card'),('board5','/canvas/5?session=16','.flow-panel'),('fixture','/canvas/6?session=18','.canvas-card')]:
|
||||
if a.only_reference and label!='fixture': continue
|
||||
page.goto(WEB+path);page.locator(ready).first.wait_for()
|
||||
page.wait_for_timeout(900)
|
||||
for width,height in [(1440,900),(1366,768),(1280,720)]:
|
||||
page.set_viewport_size({'width':width,'height':height})
|
||||
for theme in ['night','day','grade','bluehour']:
|
||||
page.evaluate('(t)=>document.documentElement.dataset.theme=t',theme)
|
||||
page.mouse.move(1,1);page.wait_for_timeout(200)
|
||||
name=f'{label}-{theme}-{width}'
|
||||
page.screenshot(path=str(out/f'{phase}-{name}.png'),animations='disabled')
|
||||
if phase=='deployed' and label=='fixture':
|
||||
from reference import capture
|
||||
capture(page,context,a.references/'canvas-sample.html',out,theme,width)
|
||||
report['geometry'].append({'phase':phase,'name':name,'url':page.url,'theme':theme,'motion':page.locator('html').get_attribute('data-motion'),'geometry':geometry(page)})
|
||||
check(f'{phase} {label} mounted',True)
|
||||
if phase=='deployed':
|
||||
for kind in ['image','video']:
|
||||
page.goto(WEB+'/canvas/6?session=18')
|
||||
hit=page.locator(f'.artifact-node[data-kind="{kind}"] .media-btn')
|
||||
hit.wait_for();hit.focus();scroll=page.evaluate('({x:scrollX,y:scrollY,overflow:document.body.style.overflow})')
|
||||
page.keyboard.press('Enter')
|
||||
box=page.locator('.backdrop');expect(box).to_be_visible()
|
||||
check(f'canvas {kind} lightbox actually opened',box.count()==1)
|
||||
check(f'canvas {kind} body locked',page.evaluate('document.body.style.overflow')=='hidden')
|
||||
if kind=='video':
|
||||
video=box.locator('video');expect(video).to_be_visible()
|
||||
video.evaluate('(v)=>v.play()');page.wait_for_timeout(150)
|
||||
check('canvas video manual playback',video.evaluate('(v)=>!v.paused && v.currentTime>0'))
|
||||
video.evaluate('(v)=>v.pause()')
|
||||
else:
|
||||
expect(box.locator('img')).to_be_visible()
|
||||
box.get_by_role('button',name='100%',exact=True).click()
|
||||
expect(box.locator('.frame.zoomed')).to_be_visible()
|
||||
box.locator('button,a').first.focus();page.keyboard.press('Shift+Tab')
|
||||
check(f'{kind} tab stays inside preview',box.evaluate('(e)=>e.contains(document.activeElement)'))
|
||||
page.screenshot(path=str(out/f'canvas-{kind}-opened.png'))
|
||||
page.keyboard.press('Escape');expect(box).to_have_count(0)
|
||||
check(f'canvas {kind} focus restored',hit.evaluate('(e)=>e===document.activeElement'))
|
||||
check(f'canvas {kind} scroll restored',scroll==page.evaluate('({x:scrollX,y:scrollY,overflow:document.body.style.overflow})'))
|
||||
header=page.locator('.artifact-node[data-kind="image"] header')
|
||||
before=next(n for n in api('/canvas/6')['nodes'] if n['type']=='image')
|
||||
b=header.bounding_box();page.mouse.move(b['x']+80,b['y']+12);page.mouse.down();page.mouse.move(b['x']+160,b['y']+72,steps=10);page.mouse.up();page.wait_for_timeout(800)
|
||||
after=next(n for n in api('/canvas/6')['nodes'] if n['id']==before['id'])
|
||||
check('canvas drag persists coordinates in isolated DB',abs(after['x']-before['x'])>30 and abs(after['y']-before['y'])>30)
|
||||
page.reload();header.wait_for();page.wait_for_timeout(500)
|
||||
check('canvas drag retained after reload',next(n for n in api('/canvas/6')['nodes'] if n['id']==before['id'])['x']==after['x'])
|
||||
zoom_before=json.loads(api('/canvas/6')['canvas']['viewport_json'])
|
||||
page.locator('.svelte-flow__controls-button').first.click()
|
||||
page.wait_for_timeout(800)
|
||||
saved=json.loads(api('/canvas/6')['canvas']['viewport_json'])
|
||||
check('canvas zoom saves to isolated DB',saved['zoom']!=zoom_before['zoom'])
|
||||
check('canvas viewport mirror matches backend',json.loads(page.evaluate("localStorage.getItem('calliope.canvas.viewport.6')"))==saved)
|
||||
page.reload();page.locator('.zoom-value').wait_for();page.wait_for_timeout(500)
|
||||
check('canvas viewport restored after reload',page.locator('.zoom-value').inner_text()==str(round(saved['zoom']*100))+'%')
|
||||
context.close()
|
||||
if a.only_reference: return
|
||||
for current in sorted(list(out.glob('current-*.png'))+list(out.glob('deployed-*.png'))):
|
||||
base=out/(current.name.replace('current-','base-',1) if current.name.startswith('current-') else current.name.replace('deployed-','previous-',1))
|
||||
b,c=Image.open(base).convert('RGB'),Image.open(current).convert('RGB')
|
||||
diff=ImageChops.difference(b,c)
|
||||
pixels=sum(1 for x in diff.get_flattened_data() if x!=(0,0,0))
|
||||
report['comparisons'].append({'name':current.stem,'different_pixels':pixels,'total_pixels':b.width*b.height,'bbox':diff.getbbox()})
|
||||
pair=Image.new('RGB',(b.width*2,b.height));pair.paste(b,(0,0));pair.paste(c,(b.width,0));pair.save(out/('compare-'+current.name))
|
||||
|
||||
|
||||
for port in [18383,15383]:
|
||||
with socket.socket() as s:assert s.connect_ex(('127.0.0.1',port))!=0
|
||||
proc=None;web_proc=None
|
||||
with tempfile.TemporaryDirectory(prefix='hel883-',dir=out) as temp:
|
||||
runtime=Path(temp)
|
||||
code='''from pathlib import Path
|
||||
from calliope import config
|
||||
root=Path(TEMP)
|
||||
config.CONFIG_FILE=root/'settings.json'
|
||||
config.settings=config.Settings(data_dir=root/'data',assets_dir=root/'data/assets',llm_base_url='http://127.0.0.1:1/v1',comfyui_base_url='http://127.0.0.1:1',dry_run=False)
|
||||
config.settings.ensure_storage_dirs()
|
||||
from calliope.queue.manager import queue_manager
|
||||
queue_manager.paused=True
|
||||
from calliope.main import create_app
|
||||
import uvicorn
|
||||
uvicorn.run(create_app(),host='127.0.0.1',port=18383)
|
||||
'''.replace('TEMP',repr(temp))
|
||||
with (out/'replay-api.log').open('w') as log:
|
||||
try:
|
||||
proc=subprocess.Popen(['python3','-c',code],env={**os.environ,'PYTHONPATH':str(ROOT/'calliope-backend/src')},stdout=log,stderr=subprocess.STDOUT)
|
||||
for attempt in range(100):
|
||||
try: api('/health');break
|
||||
except OSError:
|
||||
if proc.poll() is not None: raise RuntimeError('API startup failure')
|
||||
time.sleep(.1)
|
||||
wf=seed(runtime)
|
||||
(out/'active-build.txt').write_text(str(a.baseline.resolve()))
|
||||
web=(ROOT/'e2e/hel818-web.cjs').read_text().replace('18247','18383').replace('15173','15383').replace("const build=path.resolve('calliope-web/build');", "const marker="+json.dumps(str(out/'active-build.txt'))+";").replace(" let file=path.join(build", " const build=fs.readFileSync(marker,'utf8'); let file=path.join(build")
|
||||
web_proc=subprocess.Popen(['node','-e',web],stdout=log,stderr=subprocess.STDOUT)
|
||||
for attempt in range(100):
|
||||
try: urllib.request.urlopen(WEB,timeout=1);break
|
||||
except OSError:time.sleep(.1)
|
||||
with sync_playwright() as pw:
|
||||
browser=pw.chromium.launch(args=['--no-sandbox'],env={**os.environ,'LD_LIBRARY_PATH':'/home/xiaomo/pwlibs/root/usr/lib/x86_64-linux-gnu'})
|
||||
try: matrix(browser,wf)
|
||||
finally: browser.close()
|
||||
check('no JS runtime errors',not report['errors'])
|
||||
check('no generation or model call',not any(any(t in r['path'] for t in ['/generate','/prompt-tools','/agent/stream']) for r in report['writes']))
|
||||
finally:
|
||||
if web_proc:
|
||||
daemon=json.loads(subprocess.check_output(['multica','daemon','status','--output','json']))['pid']
|
||||
assert web_proc.pid!=daemon
|
||||
web_proc.terminate();web_proc.wait(timeout=10)
|
||||
report['web_cleanup']={'pid':web_proc.pid,'daemon_pid':daemon,'exit':web_proc.returncode}
|
||||
if proc:
|
||||
daemon=json.loads(subprocess.check_output(['multica','daemon','status','--output','json']))['pid']
|
||||
assert proc.pid!=daemon
|
||||
if proc.poll() is None:proc.send_signal(signal.SIGTERM)
|
||||
try:proc.wait(timeout=10)
|
||||
except subprocess.TimeoutExpired:proc.kill();proc.wait()
|
||||
report['cleanup']={'pid':proc.pid,'daemon_pid':daemon,'exit':proc.returncode}
|
||||
(out/'replay-report.json').write_text(json.dumps(report,ensure_ascii=False,indent=2))
|
||||
report['runtime_removed']=not runtime.exists()
|
||||
(out/'replay-report.json').write_text(json.dumps(report,ensure_ascii=False,indent=2))
|
||||
@@ -0,0 +1,29 @@
|
||||
"""Read only the selected board and its renderable rows; never call seeding GET."""
|
||||
import argparse, json, sqlite3
|
||||
p=argparse.ArgumentParser()
|
||||
p.add_argument("--board",type=int,choices=[4,5],default=4)
|
||||
a=p.parse_args()
|
||||
c = sqlite3.connect('file:/opt/aigc-home/data/calliope.db?mode=ro', uri=True)
|
||||
c.execute('PRAGMA query_only=ON')
|
||||
c.row_factory = sqlite3.Row
|
||||
one = lambda sql, args=(): [dict(r) for r in c.execute(sql, args)]
|
||||
board = one('SELECT * FROM canvas WHERE id=?',(a.board,))[0]
|
||||
nodes = one('SELECT * FROM canvas_node WHERE canvas_id=?',(a.board,))
|
||||
out = {'canvas': [board], 'canvas_node': nodes,
|
||||
'canvas_edge': one('SELECT * FROM canvas_edge WHERE canvas_id=?',(a.board,))}
|
||||
if board['project_id']:
|
||||
out['projects'] = one('SELECT id,title,status,idea FROM projects WHERE id=?', (board['project_id'],))
|
||||
for table, kind in [('characters', 'character'), ('locations', 'location'), ('items', 'item')]:
|
||||
ids = [n['entity_id'] for n in nodes if n['entity_type'] == kind]
|
||||
if ids:
|
||||
cols = ['id','project_id','name'] + (['portrait_path','sheet_path'] if kind=='character' else ['reference_image_path'])
|
||||
out[table] = one('SELECT '+','.join(cols)+' FROM '+table+' WHERE id IN ('+','.join('?' for _ in ids)+')', ids)
|
||||
sessions = one('SELECT id,title,project_id FROM agent_sessions WHERE project_id=? ORDER BY updated_at DESC LIMIT 1', (board['project_id'],))
|
||||
if not board['project_id']:
|
||||
sessions = one('SELECT id,title,project_id FROM agent_sessions WHERE id=?', (board['agent_session_id'],))
|
||||
out['agent_sessions'] = sessions
|
||||
if sessions:
|
||||
sid = sessions[0]['id']
|
||||
out['agent_messages'] = one('SELECT session_id,role,content,agent_name FROM agent_messages WHERE session_id=? ORDER BY id', (sid,))
|
||||
out['event_summary'] = one('SELECT type,count(*) as count FROM agent_events WHERE session_id=? GROUP BY type', (sid,))
|
||||
print(json.dumps(out,ensure_ascii=False))
|
||||
@@ -0,0 +1,49 @@
|
||||
"""Capture read-only online entry evidence and approved original reference states."""
|
||||
import argparse
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
from playwright.sync_api import sync_playwright
|
||||
|
||||
p=argparse.ArgumentParser()
|
||||
p.add_argument('--output',type=Path,required=True)
|
||||
p.add_argument('--references',type=Path,required=True)
|
||||
a=p.parse_args();out=a.output.resolve();refs=a.references.resolve()
|
||||
report={'blocked':[],'online':[],'reference':[],'errors':[]}
|
||||
with sync_playwright() as pw:
|
||||
b=pw.chromium.launch(args=['--no-sandbox'],env={**os.environ,'LD_LIBRARY_PATH':'/home/xiaomo/pwlibs/root/usr/lib/x86_64-linux-gnu'})
|
||||
try:
|
||||
ctx=b.new_context(viewport={'width':1440,'height':900})
|
||||
ctx.add_init_script("localStorage.setItem('aigc-theme','night');localStorage.setItem('aigc-motion','off');localStorage.setItem('aigc-lang','zh')")
|
||||
def guard(route):
|
||||
r=route.request
|
||||
if r.method not in ['GET','HEAD'] or '/api/canvas' in r.url or not r.url.startswith('http://192.168.200.15:8080/'):
|
||||
report['blocked'].append({'method':r.method,'url':r.url});route.abort()
|
||||
else:route.continue_()
|
||||
ctx.route('**/*',guard)
|
||||
page=ctx.new_page();page.on('pageerror',lambda e:report['errors'].append(str(e)))
|
||||
for name,path,ready in [('create','/create?kind=image','#desk-prompt'),('library','/library','.lib-body')]:
|
||||
page.goto('http://192.168.200.15:8080'+path);page.locator(ready).wait_for();page.wait_for_timeout(1000)
|
||||
page.screenshot(path=str(out/f'online-{name}-night-1440.png'))
|
||||
report['online'].append({'name':name,'url':page.url,'theme':page.locator('html').get_attribute('data-theme'),'motion':page.locator('html').get_attribute('data-motion'),'resources':page.evaluate('performance.getEntriesByType("resource").filter(r=>r.initiatorType==="script"||r.initiatorType==="link").map(r=>r.name)')})
|
||||
ctx.close()
|
||||
ctx=b.new_context();ctx.route('https://**/*',lambda r:r.abort());s=ctx.new_page()
|
||||
originals=[('desk',refs/'夜片场-多主题交互样本.html','?still=1#create/image'),('library',refs/'extracted/HEL-837-交互样本-v2.html','?still=1#library'),('canvas',refs/'canvas-sample.html','?chrome=0&reduce=1')]
|
||||
for label,file,query in originals:
|
||||
for width,height in [(1440,900),(1366,768),(1280,720)]:
|
||||
s.set_viewport_size({'width':width,'height':height})
|
||||
for theme in ['night','day','grade','bluehour']:
|
||||
s.goto(file.as_uri()+query)
|
||||
s.evaluate('(t)=>document.documentElement.dataset.theme=t',theme)
|
||||
if label=='desk':
|
||||
# Map only the empty-result state, never alter reference CSS.
|
||||
s.evaluate("st.hist.image=[];st.fills={};st.viewing=null;renderCreate()")
|
||||
s.wait_for_timeout(300)
|
||||
s.screenshot(path=str(out/f'approved-{label}-{theme}-{width}.png'),animations='disabled')
|
||||
tokens=s.evaluate("()=>Object.fromEntries(['--bg-0','--bg-1','--bg-2','--tx-1','--tx-2','--board-bg','--board-dot','--rail','--head'].map(t=>[t,getComputedStyle(document.documentElement).getPropertyValue(t).trim()]))")
|
||||
report['reference'].append({'label':label,'theme':theme,'width':width,'file_sha256':hashlib.sha256(file.read_bytes()).hexdigest(),'tokens':tokens,'state':'empty result/history' if label=='desk' else 'original demonstration data'})
|
||||
ctx.close()
|
||||
finally:b.close()
|
||||
(out/'readonly-report.json').write_text(json.dumps(report,ensure_ascii=False,indent=2))
|
||||
print('online entries',len(report['online']),'approved screenshots',len(report['reference']),'JS errors',report['errors'])
|
||||
@@ -0,0 +1,43 @@
|
||||
"""Map fixture content into an untouched approved canvas HTML at runtime.
|
||||
|
||||
Called by capture.py while the real isolated page is mounted. Changes only
|
||||
fixture text, media, positions and selection; original CSS/file stay intact.
|
||||
"""
|
||||
import base64
|
||||
import json
|
||||
|
||||
|
||||
def capture(page, context, reference, out, theme, width):
|
||||
sample=context.new_page()
|
||||
sample.set_viewport_size(page.viewport_size)
|
||||
sample.goto(reference.resolve().as_uri()+'?theme='+theme+'&chrome=0&reduce=1')
|
||||
data=page.evaluate('''()=>{let b=document.querySelector('.flow-panel').getBoundingClientRect();return {title:document.querySelector('.title-btn').textContent.trim(),zoom:document.querySelector('.zoom-value').textContent,cards:[...document.querySelectorAll('.canvas-card')].map(e=>{let r=e.getBoundingClientRect();return {title:e.querySelector('.title').textContent,kind:e.dataset.kind,x:r.x-b.x,y:r.y-b.y,scale:r.width/parseFloat(getComputedStyle(e).width),foot:e.querySelector('footer').innerText,image:e.querySelector('img')?.src,selected:e.classList.contains('a-in-frame')}})}}''')
|
||||
data['video']='data:image/png;base64,'+base64.b64encode(page.locator('.canvas-card video').screenshot()).decode()
|
||||
data['minimap']='data:image/png;base64,'+base64.b64encode(page.locator('.svelte-flow__minimap svg').screenshot()).decode()
|
||||
sample.evaluate('''m=>{
|
||||
let templates=[...document.querySelectorAll('.card.artifact')].map(e=>e.cloneNode(true));
|
||||
document.querySelector('#cards').replaceChildren();
|
||||
for(let [i,d] of m.cards.entries()){
|
||||
let e=templates[d.kind==='video'?1:0];e.classList.remove('running','sel','on');
|
||||
e.style.setProperty('left',d.x+'px','important');e.style.setProperty('top',d.y+'px','important');e.style.transform='scale('+d.scale+')';e.style.transformOrigin='top left';
|
||||
e.querySelector('.t').textContent=d.title;e.querySelector('.pill').textContent=d.kind;
|
||||
let image=document.createElement('img');image.src=d.image||m.video;image.style.cssText='display:block;width:100%;height:100%;object-fit:cover';
|
||||
let media=e.querySelector('.media');media.replaceChildren(image);let badge=document.createElement('span');badge.className='kind';badge.textContent=d.kind;media.append(badge);
|
||||
let foot=e.querySelector('.cfoot');foot.replaceChildren();let grow=document.createElement('span');grow.className='grow';let num=document.createElement('span');num.className='mono';num.textContent=d.foot;foot.append(grow,num);document.querySelector('#cards').append(e);
|
||||
}
|
||||
document.querySelector('.tname').firstChild.nodeValue=m.title;
|
||||
document.querySelector('.titlecard .saved').remove();
|
||||
document.querySelector('.proj-chip').textContent='Sandbox';document.querySelector('.unlink').remove();
|
||||
document.querySelector('.zoomv').textContent=m.zoom;
|
||||
let mini=document.createElement('img');mini.src=m.minimap;mini.style.cssText='width:100%;height:100%;display:block';document.querySelector('.minimap .mm').replaceChildren(mini);document.querySelector('.minimap .mmtag')?.remove();
|
||||
document.querySelector('.demo-badge')?.remove();
|
||||
}''',data)
|
||||
sample.wait_for_timeout(150)
|
||||
name=f'{theme}-{width}'
|
||||
page.locator('.flow-panel').screenshot(path=str(out/f'product-board-{name}.png'))
|
||||
sample.locator('.board').screenshot(path=str(out/f'approved-mapped-board-{name}.png'))
|
||||
geometry={}
|
||||
for label,p,selectors in [('product',page,['.artifact-node','.artifact-node .media','.svelte-flow__controls','.svelte-flow__minimap','.chat-panel']),('approved',sample,['.card.artifact','.card.artifact .media','.ctl','.minimap','#chat'])]:
|
||||
geometry[label]=p.evaluate('''ss=>ss.map(s=>{let e=document.querySelector(s);if(!e)return {selector:s};let r=e.getBoundingClientRect(),c=getComputedStyle(e);return {selector:s,w:r.width,h:r.height,color:c.color,background:c.backgroundColor,font:c.font,border:c.border,borderRadius:c.borderRadius}})''',selectors)
|
||||
(out/f'approved-mapping-{name}.json').write_text(json.dumps({'data':data,'geometry':geometry,'scope':'Same isolated image/video fixture mapped into approved board; chat excluded from board crops. No original file or stylesheet modified.'},ensure_ascii=False,indent=2))
|
||||
sample.close()
|
||||
Reference in New Issue
Block a user