From 1f6c2d2a6b8da29ef29a874709d90da138123216 Mon Sep 17 00:00:00 2001 From: leefer <2298888111@qq.com> Date: Fri, 23 Oct 2026 12:43:05 +0800 Subject: [PATCH 1/9] HEL-733: align approved motion and audit remaining sample gaps Co-authored-by: multica-agent --- .../src/lib/actions/finishAnimations.ts | 18 ++ .../components/agent/AgentWorkspace.svelte | 16 +- .../agent/LegacyAgentWorkspace.svelte | 21 ++- .../src/lib/components/desk/Artboard.svelte | 86 ++++++++- .../lib/components/desk/HistoryRail.svelte | 64 ++++--- .../src/lib/components/desk/StudioDesk.svelte | 65 ++----- .../src/lib/components/shell/AppShell.svelte | 44 +++-- .../lib/components/shell/ThemeChoices.svelte | 36 +++- .../src/lib/components/ui/Modal.svelte | 54 ++++-- calliope-web/src/lib/components/ui/icons.ts | 1 + calliope-web/src/lib/theme.ts | 11 +- calliope-web/src/routes/app.css | 172 +++++++++++++++--- .../src/routes/build-scene/+page.svelte | 17 +- calliope-web/src/routes/canvas/+page.svelte | 3 +- .../src/routes/canvas/[id]/+page.svelte | 12 +- calliope-web/static/themes.css | 83 ++++++++- .../2026-10-23-hel733-motion-audit.md | 47 +++++ e2e/hel732-visual.cjs | 2 +- e2e/hel733-audit.mjs | 114 ++++++++++++ e2e/hel733-live.cjs | 86 +++++++++ e2e/run-hel733.py | 57 ++++++ 21 files changed, 836 insertions(+), 173 deletions(-) create mode 100644 calliope-web/src/lib/actions/finishAnimations.ts create mode 100644 docs/changelogs/2026-10-23-hel733-motion-audit.md create mode 100644 e2e/hel733-audit.mjs create mode 100644 e2e/hel733-live.cjs create mode 100644 e2e/run-hel733.py diff --git a/calliope-web/src/lib/actions/finishAnimations.ts b/calliope-web/src/lib/actions/finishAnimations.ts new file mode 100644 index 00000000..e3baa42d --- /dev/null +++ b/calliope-web/src/lib/actions/finishAnimations.ts @@ -0,0 +1,18 @@ +import type { Action } from "svelte/action"; + +/** HEL-716: interacting with an arriving result skips its development effect. */ +export const finishAnimations: Action = (node) => { + const finish = () => { + for (const animation of node.getAnimations({ subtree: true })) { + if (animation.playState === "running") animation.finish(); + } + }; + node.addEventListener("pointerdown", finish); + node.addEventListener("keydown", finish); + return { + destroy() { + node.removeEventListener("pointerdown", finish); + node.removeEventListener("keydown", finish); + }, + }; +}; diff --git a/calliope-web/src/lib/components/agent/AgentWorkspace.svelte b/calliope-web/src/lib/components/agent/AgentWorkspace.svelte index 1e91e199..34ddc6ea 100644 --- a/calliope-web/src/lib/components/agent/AgentWorkspace.svelte +++ b/calliope-web/src/lib/components/agent/AgentWorkspace.svelte @@ -1,5 +1,6 @@ {#if items.length} @@ -27,6 +20,7 @@ {#each items as toast (toast.id)}
pauseToast(toast.id)} onmouseleave={() => resumeToast(toast.id)} @@ -35,15 +29,18 @@ > - {toast.message} - {#if toast.action} - - {/if} - + {/if} +
{/if}
-
-
+
+
{#if selectedJob} void; const DEFAULT_DURATION = 3200; +const EXIT_DURATION = 300; let seq = 0; let toasts: Toast[] = []; @@ -27,6 +29,7 @@ type TimerState = { remaining: number; startedAt: number; handle: ReturnType; + paused?: boolean; }; const timers = new Map(); @@ -56,7 +59,18 @@ function schedule(id: number, delay: number) { timers.set(id, { remaining: delay, startedAt: Date.now(), - handle: setTimeout(() => dismissToast(id), delay), + handle: setTimeout(() => expireToast(id), delay), + }); +} + +function expireToast(id: number) { + timers.delete(id); + toasts = toasts.map((t) => (t.id === id ? { ...t, leaving: true } : t)); + emit(); + timers.set(id, { + remaining: 0, + startedAt: Date.now(), + handle: setTimeout(() => dismissToast(id), EXIT_DURATION), }); } @@ -76,24 +90,27 @@ export function showToast( /** Pause auto-dismiss (e.g. while the pointer or focus is on the toast). */ export function pauseToast(id: number) { const timer = timers.get(id); - if (!timer) return; + if (!timer || timer.paused || toasts.find((t) => t.id === id)?.leaving) + return; clearTimeout(timer.handle); timer.remaining = Math.max( 0, timer.remaining - (Date.now() - timer.startedAt), ); timer.startedAt = Date.now(); + timer.paused = true; } /** Resume auto-dismiss after a pause. */ export function resumeToast(id: number) { const timer = timers.get(id); - if (!timer) return; + if (!timer?.paused) return; clearTimeout(timer.handle); const delay = Math.max(0, timer.remaining); timer.remaining = delay; timer.startedAt = Date.now(); - timer.handle = setTimeout(() => dismissToast(id), delay); + timer.paused = false; + timer.handle = setTimeout(() => expireToast(id), delay); } export const toast = { diff --git a/calliope-web/static/themes.css b/calliope-web/static/themes.css index 9fb6872b..23e5c8af 100644 --- a/calliope-web/static/themes.css +++ b/calliope-web/static/themes.css @@ -245,6 +245,9 @@ --desk-player-offset: -50%; --desk-media-bg: #000; --desk-media-radius: 8px; + --desk-stage-padding: 12px 16px 8px; + --desk-layout-gap: 0px; + --desk-action-height: 40px; --desk-history-padding: 8px; --desk-history-gap: 8px; --desk-history-label-bg: rgba(0, 0, 0, 0.6); @@ -511,6 +514,7 @@ } [data-theme="grade"] { + --desk-media-radius: 4px; --result-develop: a-develop-grade; --project-card-radius: 4px; --project-hero-radius: 4px; @@ -620,6 +624,7 @@ } [data-theme="bluehour"] { + --desk-media-radius: 14px; --rail-background: linear-gradient(180deg, #101a40, #1a1440); --project-shot-radius: 14px; --project-button-radius: 10px; @@ -744,6 +749,11 @@ --hist: 84px; } } +@media (max-height: 800px) { + :root { + --desk-stage-padding: 8px 16px 8px; + } +} @media (max-width: 1280px) { :root { --rail: 60px; @@ -762,7 +772,23 @@ --composer-bg: var(--desk-dock-bg); --composer-focus: var(--bg-2); --float-bg: var(--bg-2); - --toast-bottom: 24px; + --toast-right: 18px; + --toast-bottom: 18px; + --toast-min-width: 260px; + --toast-max-width: 380px; + --toast-padding: 10px 14px; + --toast-radius: 10px; + --toast-bg: color-mix(in srgb, var(--bg-3) 97%, transparent); + --toast-border: var(--line-2); + --toast-shadow: var(--sh-2); + --toast-font: 12.5px; + --toast-line-height: 1.55; + --toast-enter: 320ms; + --toast-ease: cubic-bezier(0.34, 1.56, 0.64, 1); + --toast-enter-y: 10px; + --toast-enter-scale: 0.96; + --toast-exit: 300ms; + --toast-dot-top: 6px; --on-media: #fff; --on-media-line: #ffffff66; --sel-shadow: 0 0 0 1px var(--gold); @@ -920,7 +946,7 @@ html.theme-anim *::after { } } -/* Keep transient feedback clear of the project’s fixed primary actions. */ +/* Preserve the existing clearance for the fixed project actions. */ :root:has(.project-workspace) { --toast-bottom: calc(var(--project-footer-height) + 24px); } diff --git a/docs/changelogs/2026-10-23-hel806-same-state-audit.md b/docs/changelogs/2026-10-23-hel806-same-state-audit.md new file mode 100644 index 00000000..ae208739 --- /dev/null +++ b/docs/changelogs/2026-10-23-hel806-same-state-audit.md @@ -0,0 +1,17 @@ +HEL-806 continues draft PR #12 on `agent/agent/8ec6e532b3ac`, from `1f6c2d2a6b8da29ef29a874709d90da138123216`; target remains `leefer/hel-731-studio-project@2952af42b6232483640f1f27803980367cd62dd0`. All B4 and HEL-733 changes are retained. No merge or deployment. + +The approved HTML remains read-only, SHA256 `0473d80d22b90194375f3566c1b8ca30dbd7433581b18f60956eabca252242a4`. The architectural boundaries follow HEL-727 and the existing B1–B4 components. No dependency, backend, schema, API, cloud generation integration or production configuration is changed. + +- `themes.css`, `ToastHost.svelte`, `toast.ts`: use the sample's 18px placement, surface, border, 10px radius, 12.5px text, dot, 320ms spring entry and 3200ms display followed by 300ms fade. Repeated pause no longer consumes remaining time twice. Existing undo/manual dismissal/sticky feedback and the project's fixed-action clearance remain. Those real controls and clearance are recorded as specific sample conflicts, not silently removed or presented as approved. +- `StudioDesk.svelte`, `Artboard.svelte`: replace the extra 12px gap with the original stage padding (12px 16px 8px, top 8px when height <=800), keep the 40px action row for empty as well as populated states, and clip media to its theme radius (8px night/day, 4px grade, 14px bluehour). Values have one source in tokens; no overlay stylesheet or duplicate component. +- `e2e/hel806.cjs`: real disposable HTTP data, fake ComfyUI/model only, four themes × three viewports. Original reference, runtime content map and actual screenshots are distinct. Reference changes use existing templates/content/state; no reference CSS/layout changes. Missing empty states, non-storyboard stage content, real edit/compare/validation dialogs and organization-only pages are explicitly marked. The sample's static parameter list is not substituted for actual workflow declarations. +- `e2e/hel806-toast.mjs`, `hel806-toast-browser.cjs`: deterministic expiry/pause/sticky/cleanup regression plus full/reduced motion, project footer clearance and real explicit confirmation. +- `scripts/report-hel806.py`: measured values, self-contained themed galleries and parallel images. Screenshot delivery uses lossless WebP; decoded RGB equality is checked against source PNGs. Manifests retain both hashes. Screenshot counts and DOM measurements are evidence, never automatic visual approval. + +Reproduce from a frozen build: install the repository's existing dependencies, run frontend lint/check/test/build, then set existing `PLAYWRIGHT_MODULE_PATH`, browser library path and `HEL732_PYTHON` for this machine. `HEL733_TESTS=e2e/hel732.cjs,e2e/hel729.cjs,e2e/hel742.cjs,e2e/hel733-live.cjs,e2e/hel732-visual.cjs,e2e/hel806.cjs,e2e/hel806-toast-browser.cjs HEL733_SKIP_SHOTS=1 python3 e2e/run-hel733.py`. The original HTML is downloaded through the CLI to `../materials` and the B4 reference copy to `../evidence/reference`. All required results are collected before exit; the runner closes its exact child processes and removes the disposable database. Run `node e2e/hel806-toast.mjs` separately. The report script reads `../evidence/hel806-final/results.json`. + +The attached coverage report owns the per-page findings and exact run results. The six approved business differences, small-window hero exception, text-to-image without image slots, honest unknown connection metrics and manual video playback remain. Restrained motion, final navigation slot purpose and canvas/scene visual samples still await the design decision already routed by the coordinator. No claim that HEL-733 or the whole site is complete; final independent review and deployment belong to the responsible reviewer. + +Rollback only these commits, keeping `1f6c2d2` and B4. No database rollback is needed. + +Validation completed: full frontend lint/check (0 errors / 0 warnings), guard scripts and production build pass. Relevant backend regressions: 24 pass, one existing Starlette deprecation warning. B1 browser checks: 42; no-image guards: 7; B4 functional: 31; shared motion/storage checks: 43, all pass with no page errors. B4 same-state 24 groups / 156 measured selector pairs have no differences, plus four interactions. The audit delivers 34 states × 12 viewports/themes = 408 groups and 12 dedicated Toast style/clearance checks. Image frame geometry/radius and empty-ring geometry each match the reference in their 12 measured groups; these local findings do not imply whole-page approval. diff --git a/e2e/hel806-toast-browser.cjs b/e2e/hel806-toast-browser.cjs new file mode 100644 index 00000000..79dacdc6 --- /dev/null +++ b/e2e/hel806-toast-browser.cjs @@ -0,0 +1,17 @@ +// Shared feedback on the real fixed project footer, using only disposable state. +const {chromium}=require(process.env.PLAYWRIGHT_MODULE_PATH||'playwright'); +const fs=require('node:fs'),http=require('node:http'),assert=require('node:assert/strict'); +(async()=>{const out='../evidence/hel806-toast';fs.mkdirSync(out,{recursive:true});const api=async(p,b,method)=>{const r=await fetch('http://127.0.0.1:8247'+p,{method:method||(b?'POST':'GET'),headers:{'Content-Type':'application/json'},body:b?JSON.stringify(b):undefined});assert(r.ok,await r.clone().text());return r.json()}; +const settings=await api('/api/settings');assert(settings.data_dir.includes('e2e/shots/reference')); +const project=await api('/api/projects',{title:'HEL806 Toast clearance'});await api(`/api/workbench/projects/${project.id}/stages/storyboard`,{content:{shots:[{id:'s1',scene:'1',shot_size:'远景',description:'隔离的海岸镜头,纸船驶向灯塔。',duration_sec:5}]}},'PATCH'); +const server=http.createServer((req,res)=>{res.setHeader('Content-Type','text/html;charset=utf-8');res.end(fs.readFileSync('../materials/夜片场-多主题交互样本.html'))});await new Promise(r=>server.listen(0,'127.0.0.1',r));const browser=await chromium.launch({args:['--no-sandbox']});const rows=[],errors=[]; +try{const c=await browser.newContext();await c.addInitScript(()=>{localStorage.setItem('aigc-lang','zh');localStorage.setItem('aigc-reduce-motion','0')});const p=await c.newPage(),s=await c.newPage();p.on('pageerror',e=>errors.push(e.message));await p.goto('http://127.0.0.1:5173/workbench/'+project.id+'?stage=storyboard');await p.getByRole('button',{name:'编辑',exact:true}).click();await p.locator('.shot-edit textarea').first().fill('HEL806 Toast 验证:在隔离海岸镜头加入新的纸船描述');await p.locator('[data-testid=stage-save-btn]').click();await p.locator('.toast').last().hover();await p.mouse.move(0,0);await p.locator('.toast .dismiss').last().focus(); +const message=await p.locator('.toast .msg').last().innerText(); +const style=loc=>loc.evaluate(e=>{const r=e.getBoundingClientRect(),s=getComputedStyle(e);return {x:r.x,y:r.y,width:r.width,height:r.height,...Object.fromEntries(['padding','borderRadius','backgroundColor','borderTopColor','boxShadow','fontSize','lineHeight','animationDuration','animationTimingFunction','transitionDuration'].map(k=>[k,s[k]]))}}); +for(const [width,height]of [[1440,900],[1366,768],[1280,720]])for(const theme of ['night','day','grade','bluehour']){ + await p.setViewportSize({width,height});await s.setViewportSize({width,height});await p.evaluate(t=>document.documentElement.dataset.theme=t,theme);await s.goto('http://127.0.0.1:'+server.address().port);await s.evaluate(({theme,message})=>{go('project');document.documentElement.dataset.theme=theme;toast(message)}, {theme,message});await p.waitForTimeout(350); + const actual=await style(p.locator('.toast').last()),reference=await style(s.locator('.toast').last()),footer=await p.locator('.project-footer').boundingBox();assert(actual.y+actual.height<=footer.y,'toast clears fixed actions');assert.equal(actual.animationDuration,reference.animationDuration);assert.equal(actual.animationTimingFunction,reference.animationTimingFunction); + await p.screenshot({path:`${out}/project-${theme}-${width}.png`});await p.locator('.toast').last().screenshot({path:`${out}/toast-${theme}-${width}-actual.png`});await s.locator('.toast').last().screenshot({path:`${out}/toast-${theme}-${width}-reference.png`}); + await p.evaluate(()=>document.documentElement.dataset.reduce='1');assert.equal(await p.locator('.toast').last().evaluate(e=>getComputedStyle(e).animationName),'none');await p.evaluate(()=>document.documentElement.dataset.reduce='0');rows.push({theme,width,height,message,actual,reference,footer,gap:footer.y-actual.y-actual.height});} +await p.locator('.toast .dismiss').last().evaluate(e=>e.blur());await p.mouse.move(0,0);await p.locator('.toast').last().waitFor({state:'detached',timeout:5000});await p.locator('[data-testid=stage-confirm-btn]').click();assert.equal((await api(`/api/workbench/projects/${project.id}`)).stages.find(x=>x.stage_key==='storyboard').status,'confirmed');assert.equal(errors.length,0);console.log('PASS 12 toast style/timing pairs, reduce, footer clearance and real confirmation'); +}finally{fs.writeFileSync(out+'/results.json',JSON.stringify({rows,errors},null,2));await browser.close();await new Promise(r=>server.close(r))}})().catch(e=>{console.error(e);process.exitCode=1}); diff --git a/e2e/hel806-toast.mjs b/e2e/hel806-toast.mjs new file mode 100644 index 00000000..3733524e --- /dev/null +++ b/e2e/hel806-toast.mjs @@ -0,0 +1,16 @@ +// Deterministic timing regression: expiry, fade, repeated pause, resume, sticky and dismissal. +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import {createRequire} from 'node:module'; +const ts=createRequire(import.meta.url)('../calliope-web/node_modules/typescript'); +let time=0,seq=0;const pending=new Map(); +const saved={now:Date.now,setTimeout:globalThis.setTimeout,clearTimeout:globalThis.clearTimeout}; +Date.now=()=>time;globalThis.setTimeout=(fn,delay)=>{pending.set(++seq,{at:time+delay,fn});return seq};globalThis.clearTimeout=id=>pending.delete(id); +const tick=ms=>{const end=time+ms;for(;;){const next=[...pending].sort((a,b)=>a[1].at-b[1].at)[0];if(!next||next[1].at>end)break;time=next[1].at;pending.delete(next[0]);next[1].fn()}time=end}; +try{ + const {showToast,subscribeToasts,pauseToast,resumeToast,dismissToast}=await import('data:text/javascript;base64,'+Buffer.from(ts.transpileModule(fs.readFileSync(new URL('../calliope-web/src/lib/toast.ts',import.meta.url),'utf8'),{compilerOptions:{module:ts.ModuleKind.ESNext,target:ts.ScriptTarget.ES2022}}).outputText).toString('base64'));let items=[];const unsub=subscribeToasts(x=>items=x); + const first=showToast('one');tick(3199);assert.equal(items[0].leaving,undefined);tick(1);assert.equal(items[0].leaving,true);tick(299);assert.equal(items.length,1);tick(1);assert.equal(items.length,0); + const paused=showToast('paused');tick(1000);pauseToast(paused);tick(1000);pauseToast(paused);tick(5000);assert.equal(items[0].leaving,undefined);resumeToast(paused);tick(2199);assert.equal(items[0].leaving,undefined);tick(1);assert.equal(items[0].leaving,true);pauseToast(paused);resumeToast(paused);tick(300);assert.equal(items.length,0); + const sticky=showToast('sticky','info',0);tick(9000);assert.equal(items.length,1);dismissToast(sticky);assert.equal(items.length,0); + const closed=showToast('manual');dismissToast(closed);tick(4000);assert.equal(items.length,0);assert.equal(pending.size,0);unsub();console.log('PASS toast: 3200ms visible + 300ms fade, idempotent pause, remaining time, sticky/manual cleanup'); +}finally{Date.now=saved.now;globalThis.setTimeout=saved.setTimeout;globalThis.clearTimeout=saved.clearTimeout} diff --git a/e2e/hel806.cjs b/e2e/hel806.cjs new file mode 100644 index 00000000..9b6504fe --- /dev/null +++ b/e2e/hel806.cjs @@ -0,0 +1,135 @@ +// Foreground real isolated API audit. Reference DOM changes only content, never CSS. +const {chromium}=require(process.env.PLAYWRIGHT_MODULE_PATH||'playwright'); +const fs=require('node:fs'),http=require('node:http'),path=require('node:path'),assert=require('node:assert/strict'); +const OUT=process.env.HEL806_OUT||'../evidence/hel806-final',WEB='http://127.0.0.1:5173',API='http://127.0.0.1:8247'; +const result={rows:[],checks:[],errors:[],blocked:[],requests:[]}; +const api=async(p,body,method)=>{const r=await fetch(API+p,{method:method||(body?'POST':'GET'),headers:{'Content-Type':'application/json'},body:body?JSON.stringify(body):undefined});if(!r.ok)throw Error(p+' '+await r.text());return r.json()}; +const ok=(name,value)=>{assert(value,name);result.checks.push(name);console.log('PASS',name)}; +const wait=ms=>new Promise(r=>setTimeout(r,ms)); +const metrics=(p,sels)=>p.evaluate(sels=>Object.fromEntries(Object.entries(sels).map(([key,sel])=>{const e=document.querySelector(sel);if(!e)return[key,null];const r=e.getBoundingClientRect(),s=getComputedStyle(e);return[key,{x:r.x,y:r.y,width:r.width,height:r.height,...Object.fromEntries(['fontSize','fontWeight','lineHeight','borderRadius','borderTopWidth','borderTopColor','backgroundColor','backgroundImage','color','padding','boxShadow','animationDuration','animationTimingFunction'].map(k=>[k,s[k]]))}]})),sels); +(async()=>{ + fs.mkdirSync(OUT+'/shots',{recursive:true}); + const f=JSON.parse(fs.readFileSync('e2e/shots/reference/fixtures.json')); + const settings=await api('/api/settings');ok('database is disposable',settings.data_dir.includes('e2e/shots/reference')); + const base=(await api('/api/workflows/'+f.dual)).workflow_json; + const img={'class_type':'LoadImage',inputs:{image:''},_meta:{title:'(Input:image) 输入图'}}; + const wf=async(name,kind,extra)=>api('/api/workflows',{name,kind,workflow_json:{...base,...extra}}); + const edit=await wf('HEL806 · 图生图真实输入','image',{'5':img}); + const mask=await wf('HEL806 · 遮罩真实输入','image',{'5':img,'6':{class_type:'LoadImageMask',inputs:{image:'',channel:'red'},_meta:{title:'(Input:mask) 遮罩'}}}); + const ref=await wf('HEL806 · 视频参考真实输入','video',{'5':img,'9':{class_type:'VHS_VideoCombine',inputs:{},_meta:{title:'(Output:video) 视频输出'}}}); + const long=await wf('HEL806 · 真实长文件名 · Qwen_Image_2_1_局部重绘_批量蒙版_参数映射_最终审核版本_20261023.json','image',{}); + await api('/api/workflows/'+long.id,{is_enabled:false},'PATCH');await api('/api/workflows/'+ref.id,{usage:'ref2video',usage_source:'user',is_enabled:true},'PATCH'); + await new Promise((resolve,reject)=>{const socket=require('node:net').connect(18187,'127.0.0.1');socket.on('connect',()=>{socket.destroy();reject(Error('Failure fixture port must be unused'))});socket.on('error',e=>e.code==='ECONNREFUSED'?resolve():reject(e));socket.setTimeout(1000,()=>{socket.destroy();reject(Error('Failure fixture probe timeout'))})}); + await api('/api/comfyui/endpoints',{name:'HEL806 · 故意不可达的隔离连接',base_url:'http://127.0.0.1:18187',auth_type:'none'}); + const sourceFile='4-视频生成/Minimax-h3/MiniMax_H3_音频MV_多参长视频分段_Selflift双采潜放大_Qwen3.8官方Skills自动提示词.json'; + const sourceBytes=fs.readFileSync(path.join('example_ComfyUI_workflows',sourceFile)); + const imported=await api('/api/workflows/import-batch',{files:[{source_path:sourceFile,workflow_json:JSON.parse(sourceBytes.toString('utf8').replace(/^\uFEFF/,''))}]}); + ok('real long repository filename imported without generation',imported.imported===1); + await api('/api/workflows/'+imported.imported_ids[0],{name:'HEL806 · 真文件 · '+path.basename(sourceFile)},'PATCH'); + fs.writeFileSync(OUT+'/import-report.json',JSON.stringify({sourceFile,source_sha256:require('node:crypto').createHash('sha256').update(sourceBytes).digest('hex'),imported,workflow:await api('/api/workflows/'+imported.imported_ids[0])},null,2)); + const project=await api('/api/projects',{title:'HEL806 · 海岸来信',idea:'纸船带回一片树叶'}); + const stages={script:{text:'第一场:清晨的海岸,女孩把一只纸船放入海水。她等候着父亲的归来。第二场:纸船带回一片树叶,女孩望向海平面,知道旅程才刚刚开始。'},style:{directions:[{id:'s1',name:'晨光',description:'柔和暖光',prompt:'warm'},{id:'s2',name:'蓝调',description:'冷光',prompt:'blue'}]},storyboard:{shots:Array.from({length:12},(_,i)=>({id:'b'+i,scene:'1',shot_size:'远景',camera:'固定',description:'女孩沿着海岸走向灯塔,纸船随海风起伏。',duration_sec:5,dialog:''}))},assets:{assets:[{id:'a1',kind:'character',name:'海边女孩',description:'隔离角色',prompt:'girl'}]},shot:{shots:[{id:'s1',shot_no:'S1',description:'女孩走向灯塔',prompt:'girl on coast',duration_sec:5}]}}; + const stagePath=s=>`/api/workbench/projects/${project.id}/stages/${s}`; + for(const [s,content]of Object.entries(stages))await api(stagePath(s),{content},'PATCH'); + await api(stagePath('storyboard')+'/confirm',{}); + await api(stagePath('storyboard'),{content:{shots:stages.storyboard.shots.map((x,i)=>i?x:{...x,description:'等待确认的新草稿'})}},'PATCH'); + fs.writeFileSync(OUT+'/inputs.json',JSON.stringify({f,edit:await api('/api/workflows/'+edit.id),mask:await api('/api/workflows/'+mask.id),ref:await api('/api/workflows/'+ref.id),long:await api('/api/workflows/'+long.id),project,stages},null,2)); + if(process.env.HEL806_SEED_TASKS==='1'){ + const seeded=[];for(const mode of ['ok','fail']){fs.writeFileSync('e2e/fake_mode.json',JSON.stringify({mode,delay_sec:0,unique_filename:true,image_file:path.resolve('calliope-web/static/refract/coast.jpg')}));const created=await api('/api/playground/generate',{workflow_id:f.dual,source:'studio-image',input_values:{'1':'HEL806 isolated '+mode,'2':'模糊','3':42}});let job;for(let i=0;i<200;i++){job=await api('/api/jobs/'+created.job.id);if(!['running','pending'].includes(job.status))break;await wait(150)}assert.equal(job.status,mode==='ok'?'done':'failed');seeded.push(job)}fs.writeFileSync(OUT+'/seed-jobs.json',JSON.stringify(seeded,null,2)); + } + const sample=http.createServer((req,res)=>{res.setHeader('Content-Type','text/html;charset=utf-8');res.end(fs.readFileSync('../materials/夜片场-多主题交互样本.html'))});await new Promise(r=>sample.listen(0,'127.0.0.1',r)); + const browser=await chromium.launch({args:['--no-sandbox','--disable-dev-shm-usage']}); + try{ + const c=await browser.newContext({viewport:{width:1440,height:900},recordVideo:{dir:OUT+'/recordings'}}); + await c.addInitScript(()=>{localStorage.setItem('aigc-lang','zh');localStorage.setItem('aigc-reduce-motion','1')}); + await c.route('**/*',route=>{const u=new URL(route.request().url());if(['data:','blob:'].includes(u.protocol)||['127.0.0.1','localhost'].includes(u.hostname)){if(u.pathname.startsWith('/api/'))result.requests.push([route.request().method(),u.pathname]);return route.continue()}result.blocked.push(u.origin);return route.abort()}); + const p=await c.newPage(),s=await c.newPage();p.setDefaultTimeout(12000);p.on('pageerror',e=>result.errors.push(e.message));p.on('dialog',d=>d.accept()); + const goto=async route=>{await p.goto(WEB+route);await p.locator('aside.sidebar').first().waitFor();await wait(220)}; + const upload=async()=>{await p.getByRole('button',{name:'管理素材',exact:true}).click();const inputs=p.locator('.a-slot input[type=file]');for(let i=0;i{await goto('/create?kind='+kind+'&wf='+id);await p.locator('#desk-prompt').waitFor();const w=await api('/api/workflows/'+id);await p.locator('.rail-search input').fill(w.name);await wait(150)}; + const emptyRoute=async(route,pattern,json)=>{await p.route(pattern,r=>r.fulfill({json}));await goto(route)}; + const generate=async(id,kind)=>{fs.writeFileSync('e2e/fake_mode.json',JSON.stringify({mode:'ok',delay_sec:0,unique_filename:true,videos:kind==='video',image_file:path.resolve('calliope-web/static/refract/coast.jpg')}));await creative(id,kind);await p.locator('#desk-prompt').fill('HEL806 successful isolated output');const before=Math.max(0,...(await api('/api/playground/jobs')).map(j=>j.id));await p.locator('.gen').click();let job;for(let i=0;i<200;i++){job=(await api('/api/playground/jobs')).find(j=>j.id>before&&j.workflow_id===id&&j.status==='done');if(job)break;await wait(150)}assert(job,'new successful isolated job');await p.locator('.result-title').filter({hasText:'第 '+job.id+' 次生成'}).waitFor();await p.locator(kind==='video'?'.print video':'.print img').waitFor({timeout:45000});await wait(800)}; + const videoWf=(await api('/api/workflows')).find(x=>x.name==='文生视频·回归'); + const cases=[ + {name:'image-output',ref:'create',run:()=>generate(f.dual,'image'),creative:true}, + {name:'video-output',ref:'create',run:()=>generate(videoWf.id,'video'),creative:true}, + {name:'image-failed',ref:'create',run:async()=>{await creative(f.dual);await p.locator('#desk-prompt').fill('HEL806 fake failure');fs.writeFileSync('e2e/fake_mode.json',JSON.stringify({mode:'fail',delay_sec:0}));await p.locator('.gen').click();await p.locator('.stage-empty').filter({hasText:'失败'}).waitFor({timeout:45000})},creative:true}, + {name:'studio-real',ref:'studio',run:()=>goto('/'),basis:'approved-cover-exception'}, + {name:'studio-empty',ref:'studio',run:()=>emptyRoute('/','**/api/projects',[]),missing:'原件没有无项目空态'}, + {name:'image-text-empty',ref:'create',run:async()=>{await p.route('**/api/playground/jobs',r=>r.fulfill({json:[]}));await creative(f.dual)},creative:true}, + {name:'image-long-prompt',ref:'create',run:async()=>{await creative(f.dual);await p.locator('#desk-prompt').fill('海边女孩、晨光、纸船与灯塔,保持人物和空间连续。'.repeat(70));const btn=p.getByRole('button',{name:'展开提示词',exact:true});if(await btn.count())await btn.click()},creative:true}, + {name:'image-edit-upload',ref:'create',run:async()=>{await creative(edit.id);await upload()},creative:true}, + {name:'image-mask-upload',ref:'create',run:async()=>{await creative(mask.id);await upload()},creative:true}, + {name:'video-first-last',ref:'create',run:async()=>{await creative(f.h3,'video');await upload()},creative:true}, + {name:'video-reference',ref:'create',run:async()=>{await creative(ref.id,'video');await upload()},creative:true}, + {name:'video-parameters',ref:'create',run:async()=>{await creative(f.h3,'video');await p.getByRole('button',{name:'创作设置⌄',exact:true}).first().click()},missing:'真实声明参数与原件固定参数内容不同;弹层公共框可比较'}, + ...Object.keys(stages).map(stage=>({name:'project-'+stage,ref:'project',run:async()=>{await goto('/workbench/'+project.id+'?stage='+stage);await p.locator('[data-testid=workbench-page]').waitFor()},projectStage:stage,...(stage==='storyboard'?{}:{missing:'原件renderProject切阶段后仍渲染同一分镜网格,无此阶段内容样本'})})), + {name:'project-compare',ref:'project',run:async()=>{await goto('/workbench/'+project.id+'?stage=storyboard');await p.getByRole('button',{name:'对比已确认版',exact:true}).click()},missing:'原件比较按钮没有比较弹窗实现'}, + {name:'project-confirmed',ref:'project',run:async()=>{await api(stagePath('storyboard')+'/confirm',{});await goto('/workbench/'+project.id+'?stage=storyboard')},projectStage:'storyboard'}, + {name:'workflows-long-disabled',ref:'workflows',run:async()=>{await goto('/workflows');await p.getByRole('textbox',{name:'搜索名称、文件名、模型…',exact:true}).fill('HEL806');await wait(100)},basis:'content-mapped-table'}, + {name:'connections-probed',ref:'models',run:async()=>{await goto('/connections');await p.getByText('连接测试通过',{exact:false}).first().waitFor()},basis:'content-mapped-cards'}, + {name:'connections-empty',ref:'models',run:()=>emptyRoute('/connections','**/api/settings',{...settings,comfyui_endpoints:[],comfyui_active_id:null}),missing:'原件没有无本地连接状态'}, + {name:'connections-edit',ref:'models',run:async()=>{await goto('/connections');await p.getByRole('button',{name:'编辑',exact:true}).first().click()},missing:'原件编辑按钮没有编辑表单实现'}, + {name:'models-llm',ref:'models',run:()=>goto('/connections?tab=llm'),missing:'原件大语言模型页签没有内容实现'}, + {name:'models-cloud-image',ref:'models',run:()=>goto('/connections?tab=image'),basis:'cloud-card-subset'}, + {name:'models-cloud-video',ref:'models',run:()=>goto('/connections?tab=video'),basis:'cloud-card-subset'}, + {name:'tasks-real',ref:'tasks',run:()=>goto('/tasks'),basis:'content-mapped-table'}, + {name:'tasks-empty',ref:'tasks',run:()=>emptyRoute('/tasks','**/api/jobs',[]),missing:'原件没有零任务空态'}, + {name:'library-real',ref:'library',run:()=>goto('/library'),basis:'content-mapped-media'}, + {name:'library-empty',ref:'library',run:()=>emptyRoute('/library','**/api/library/media',[]),missing:'原件没有零素材空态'}, + {name:'settings',ref:'settings',run:()=>goto('/settings'),basis:'approved-real-settings'}, + {name:'settings-invalid',ref:'settings',run:async()=>{await goto('/settings');await p.locator('input[type=number]').first().fill('0')},missing:'原件无数值校验提示的实现,保留真实字段校验界面'}, + {name:'canvas-current',ref:'canvas',run:()=>goto('/canvas'),missing:'原件仅组织说明页,待总管另补样本'}, + {name:'scene-current',ref:'canvas',run:async()=>{await goto('/build-scene');await p.locator('canvas').first().waitFor();await wait(1500)},missing:'原件仅组织说明页,待总管另补样本'} + ]; + for(const item of cases.filter(x=>!process.env.HEL806_CASES||process.env.HEL806_CASES==='*'||process.env.HEL806_CASES.split(',').includes(x.name))){ + await p.unrouteAll({behavior:'wait'});await item.run(); + for(const [width,height]of [[1440,900],[1366,768],[1280,720]])for(const theme of ['night','day','grade','bluehour']){ + await p.setViewportSize({width,height});await s.setViewportSize({width,height}); + await p.evaluate(t=>{document.documentElement.dataset.theme=t;window.dispatchEvent(new StorageEvent('storage',{key:'aigc-theme',newValue:t}))},theme); + await s.goto('http://127.0.0.1:'+sample.address().port);await s.evaluate(({ref,theme})=>{go(ref);document.documentElement.dataset.theme=theme;document.documentElement.classList.add('reduce');document.querySelector('#toasts').replaceChildren()}, {ref:item.ref,theme}); + const prefix=OUT+'/shots/'+item.name+'-'+theme+'-'+width; + await s.screenshot({path:prefix+'-original.png',animations:'disabled'}); + let mapping={}; + if(item.name==='studio-real'){ + const data=await p.evaluate(()=>({eye:document.querySelector('.studio-eyebrow').textContent,title:document.querySelector('.studio-display').innerText,em:document.querySelector('.studio-display em').textContent,description:document.querySelector('.hero-description').textContent,stages:[...document.querySelectorAll('.studio-stages>div')].map(e=>({text:e.textContent,done:e.classList.contains('done'),cur:e.classList.contains('cur')})),cards:[...document.querySelectorAll('.a-card')].map(e=>({title:e.querySelector('.title')?.textContent,status:e.querySelector('.status-tag')?.textContent,meta:e.querySelector('.meta')?.innerText,cover:e.querySelector('img')?.src})),confirm:[...document.querySelectorAll('.studio-side .confirm-item')].map(e=>e.innerText)}));mapping=data; + await s.evaluate(data=>{const hero=document.querySelector('#p-studio .hero');hero.querySelector('.eyebrow').textContent=data.eye;const d=hero.querySelector('.display'),em=document.createElement('em');em.textContent=data.em;d.replaceChildren(data.title.split('\n')[0],document.createElement('br'),em,' '+data.title.split('\n').slice(1).join(' ').replace(data.em,''));hero.querySelector('.copy p').textContent=data.description;hero.querySelector('.art').style.backgroundImage='none';hero.querySelector('.copy .row .btn:last-child').textContent='查看阶段草稿';hero.querySelectorAll('.stagebar>div').forEach((e,i)=>{e.classList.toggle('done',data.stages[i].done);e.classList.toggle('cur',data.stages[i].cur)});document.querySelectorAll('#p-studio .quick p')[1].textContent='粘贴剧本文本';const list=document.querySelector('#projGrid'),template=list.firstElementChild.cloneNode(true),add=list.lastElementChild.cloneNode(true);list.replaceChildren();for(const card of data.cards){const el=template.cloneNode(true);el.querySelector('.cv').style.backgroundImage=card.cover?`url("${card.cover}")`:'none';el.querySelector('.pb b').textContent=card.title;el.querySelector('.tag').textContent=card.status;el.querySelector('.pb>.row').textContent=card.meta;list.append(el)}list.append(add);document.querySelector('#p-studio .sec-h .tag').textContent=data.cards.length;},data); + } + if(item.creative){ + const id=+new URL(p.url()).searchParams.get('wf'),w=await api('/api/workflows/'+id); + const data=await p.evaluate(()=>({prompt:document.querySelector('#desk-prompt')?.value||'',negative:document.querySelector('#desk-negative')?.value||'',uploads:[...document.querySelectorAll('.media-token img')].map(e=>e.src),file:document.querySelector('.workflow-file')?.textContent,connection:document.querySelector('.studio-heading .conn')?.textContent,media:document.querySelector('.print img,.print video')?.src,ratio:(()=>{const e=document.querySelector('.print img,.print video');return e?((e.naturalWidth||e.videoWidth)/(e.naturalHeight||e.videoHeight)):null})(),failed:document.querySelector('.stage-empty')?.innerText?.includes('失败')})); + mapping={workflow:w.name,...data}; + await s.evaluate(({w,data,kind})=>{st.kind=kind;const slots=w.input_schema.filter(x=>['image','mask'].includes(x.kind)).map(x=>[x.role==='mask'?'mask':x.role==='first_frame'?'first':x.role==='last_frame'?'last':'input',1,1,true]);const mapped={...WF.find(x=>x.k===kind),id:w.id,k:kind,name:w.name,file:data.file||w.name,slots,neg:!!data.negative||w.input_schema.some(x=>x.role==='negative'),ready:true,pending:false};WF.splice(0,WF.length,mapped);st.sel[kind]=w.id;st.q=w.name;document.querySelector('#wfSearch').value=w.name;st.hist[kind]=data.media?[{n:1,k:kind,wf:w.id,ratio:data.ratio||1,seed:42,img:data.media}]:data.failed?[{n:1,k:kind,wf:w.id,fail:true}]:[];st.fills[w.id]={};slots.forEach((x,i)=>{if(data.uploads[i])st.fills[w.id][x[0]+i]=data.uploads[i]});renderCreate();document.querySelector('#posTa').value=data.prompt;document.querySelector('#negTa').value=data.negative;count();if(data.prompt.length>1000)document.querySelector('#dock').classList.add('tall')},{w,data,kind:new URL(p.url()).searchParams.get('kind')||'image'}); + } + if(item.projectStage==='storyboard'){ + const texts=await p.locator('.storyboard-card').allTextContents();mapping={project:project.title,shots:stages.storyboard.shots,texts}; + await s.evaluate(({title,shots,texts})=>{document.querySelector('#p-project .crumb b').textContent=title;document.querySelectorAll('#projShots .scard').forEach((e,i)=>{e.querySelector('.im').style.backgroundImage='none';e.querySelector('.bd p').textContent=i?'女孩沿着海岸走向灯塔,纸船随海风起伏。':(texts[0]?.includes('等待确认')?'等待确认的新草稿':'女孩沿着海岸走向灯塔,纸船随海风起伏。');e.querySelector('.bd b').textContent='远景';e.querySelector('.tc').textContent=String(i+1).padStart(2,'0')+' · 5s';e.querySelector('.st .tag').textContent='待画'});document.querySelector('#projTasks').replaceChildren();document.querySelector('#draftbar').classList.toggle('hide',!texts[0]?.includes('等待确认'))},{title:project.title,shots:stages.storyboard.shots,texts}); + } + if(item.name==='tasks-real'){ + const cells=await p.locator('.task-table tbody tr').evaluateAll(es=>es.map(e=>[...e.cells].map(c=>c.innerText))); + const thumbnails=await p.locator('.task-table tbody tr').evaluateAll(es=>es.map(e=>e.cells[0].querySelector('img')?.src||''));mapping={cells,thumbnails}; + await s.evaluate(({cells,thumbnails})=>{const tbody=document.querySelector('#taskTable'),template=tbody.firstElementChild.cloneNode(true),button=template.querySelector('button').cloneNode(true);tbody.replaceChildren();for(const [idx,cellsRow] of cells.entries()){const row=template.cloneNode(true);[...row.cells].forEach((e,i)=>{if(i===0)e.firstElementChild.style.backgroundImage=thumbnails[idx]?`url("${thumbnails[idx]}")`:'none';else if(i===1)e.querySelector('b').textContent=cellsRow[i];else if(i===5){const tag=e.querySelector('.tag');tag.textContent=cellsRow[i];tag.className='tag'+(cellsRow[i].includes('失败')?' err':cellsRow[i].includes('完成')?' ok':'');e.querySelector('.bar')?.remove()}else if(i===7){const b=button.cloneNode(true);b.textContent=cellsRow[i];b.className='btn sm'+(cellsRow[i].includes('重试')?'':' ghost');e.querySelector('.row').replaceChildren(b)}else e.textContent=cellsRow[i]||''});tbody.append(row)}},{cells,thumbnails}); + } + if(item.name==='workflows-long-disabled'){ + const cells=await p.locator('.mg-table tbody tr').evaluateAll(es=>es.map(e=>[...e.cells].map(c=>c.innerText)));mapping={cells}; + await s.evaluate(cells=>{const body=document.querySelector('#wfTable'),template=body.firstElementChild.cloneNode(true);body.replaceChildren();for(const vals of cells){const row=template.cloneNode(true);[...row.cells].forEach((e,i)=>{if(i===0){e.querySelector('b').textContent=vals[i].split('\n')[0];e.querySelector('small').textContent=vals[i].split('\n').slice(1).join(' ')}else if(i<5){const child=e.querySelector('.tag,.dim');if(child)child.textContent=vals[i];else e.textContent=vals[i]}});body.append(row)}},cells); + } + if(item.name==='library-real'){ + const media=await p.locator('.asset-preview img,.asset-preview video').evaluateAll(es=>es.map(e=>({src:e.src,kind:e.tagName})));for(const [i,m]of media.entries())if(m.kind==='VIDEO'){const video=p.locator('.asset-preview video').nth(media.slice(0,i).filter(x=>x.kind==='VIDEO').length);m.src='data:image/png;base64,'+(await video.screenshot()).toString('base64')};mapping={media}; + await s.evaluate(media=>{const grid=document.querySelector('#libGrid'),template=grid.firstElementChild.cloneNode(true);grid.replaceChildren();for(const m of media){const el=template.cloneNode(true);el.querySelector('img').src=m.src;el.querySelector('.tag')?.remove();grid.append(el)}},media); + } + if(item.name.startsWith('models-cloud'))await s.evaluate(kind=>{const cards=document.querySelector('#modelCards');[...cards.children].forEach((e,i)=>{if(kind==='image'?![2].includes(i):![3,4].includes(i))e.remove()});document.querySelectorAll('#p-models .seg button').forEach((e,i)=>e.classList.toggle('on',i===(kind==='image'?2:3)))},item.name.endsWith('image')?'image':'video'); + if(item.name==='connections-probed'){ + const cards=await p.locator('.mg-card').evaluateAll(es=>es.map(e=>e.innerText));mapping={cards};await s.evaluate(cards=>{const c=document.querySelector('#modelCards');const template=c.firstElementChild.cloneNode(true);const add=c.lastElementChild.cloneNode(true);const clouds=[...c.children].slice(2,5);c.replaceChildren();for(const text of cards.filter(x=>x.includes('http://'))){const e=template.cloneNode(true),lines=text.split('\n').filter(Boolean);e.querySelector('b').textContent=lines[0];e.querySelector('.mono').textContent=lines.find(x=>x.includes('http://'));e.querySelector('.muted').textContent=lines.find(x=>x.includes('队列未知'));e.querySelector('.lat').textContent=lines.find(x=>/^\d+ms$/.test(x))||'—';c.append(e)}c.append(...clouds,add)},cards); + } + if(item.creative)await s.evaluate(()=>renderViewport()); + await wait(80);await p.screenshot({path:prefix+'-actual.png',animations:'disabled'});await s.screenshot({path:prefix+'-mapped.png',animations:'disabled'}); + const actual=await metrics(p,{rail:'aside.sidebar',header:'.management-header,.studio-header,.studio-heading,.project-header',content:'.mg-main,.studio-hero,.reference-desk,.project-content,.mg-media',primary:'.dock .gen,[data-testid=stage-confirm-btn]',footer:'.project-footer',card:'.mg-card,.storyboard-card,.asset-card',input:'#desk-prompt',modal:'.modal-panel',frame:'.print',ring:'.desk-emblem'}); + const sampleValues=await metrics(s,{rail:'.rail',header:'#p-'+item.ref+' .ph',content:'#p-'+item.ref+' .mg-main,#p-'+item.ref+' .hero,#p-'+item.ref+' .create,#p-'+item.ref+' .proj-main,#libGrid',primary:'#genBtn,#confirmStage',footer:'#p-project .proj-foot',card:'#p-'+item.ref+' .mcard,#p-'+item.ref+' .scard,#libGrid>div',input:'#posTa',modal:'.pop.on',frame:'.frame,.player',ring:'.empty-stage .ring'}); + result.rows.push({name:item.name,theme,width,height,basis:item.missing?'missing-reference':item.basis||'content-map-partial',missing:item.missing,mapping,actual,sample:sampleValues}); + } + console.log('CAPTURED',item.name,12);fs.writeFileSync(OUT+'/results.json',JSON.stringify(result,null,2)); + } + await c.close();ok('no pageerror',result.errors.length===0); + }finally{fs.writeFileSync(OUT+'/results.json',JSON.stringify(result,null,2));await browser.close();await new Promise(r=>sample.close(r))} +})().catch(e=>{console.error(e);process.exitCode=1}); diff --git a/scripts/report-hel806.py b/scripts/report-hel806.py new file mode 100644 index 00000000..aaf71631 --- /dev/null +++ b/scripts/report-hel806.py @@ -0,0 +1,61 @@ +"""Package lossless original/content-mapped/actual captures; never infer visual approval.""" +from pathlib import Path +import concurrent.futures, hashlib, html, json, zipfile, csv +from PIL import Image, ImageDraw, ImageFont +ROOT=Path(__file__).resolve().parents[1] +OUT=ROOT.parent/'evidence/hel806-final' +REPORT=ROOT.parent/'evidence/hel806/report' +REPORT.mkdir(parents=True,exist_ok=True) +data=json.loads((OUT/'results.json').read_text()) +rows=[r for r in data['rows'] if r['name']!='toast'] +assert len(rows)==408, f'Expected 34 states x 12, got {len(rows)}' +assert len(rows)==len({(r['name'],r['theme'],r['width']) for r in rows}) +font=ImageFont.truetype('/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf',14) +manifest=[] +def convert(p): + im=Image.open(p).convert('RGB');q=p.with_suffix('.webp') + if not q.exists() or Image.open(q).convert('RGB').tobytes()!=im.tobytes():im.save(q,lossless=True,method=3) + assert Image.open(q).convert('RGB').tobytes()==im.tobytes() + return {'source':p.name,'sha256':hashlib.sha256(p.read_bytes()).hexdigest(),'delivered':q.name,'sha256_webp':hashlib.sha256(q.read_bytes()).hexdigest(),'pixels_identical':True} +with concurrent.futures.ThreadPoolExecutor(max_workers=4) as pool: + manifest=list(pool.map(convert, sorted((OUT/'shots').glob('*.png')))) +for r in rows: + stem=f"{r['name']}-{r['theme']}-{r['width']}" + pair_path=OUT/'shots'/(stem+'-pair.webp') + if pair_path.exists() and pair_path.stat().st_mtime>=max((OUT/'shots'/(stem+'-actual.webp')).stat().st_mtime,(OUT/'shots'/(stem+'-mapped.webp')).stat().st_mtime):continue + a=Image.open(OUT/'shots'/(stem+'-actual.webp')).convert('RGB');s=Image.open(OUT/'shots'/(stem+'-mapped.webp')).convert('RGB') + pair=Image.new('RGB',(a.width*2,a.height+32),'#181818');d=ImageDraw.Draw(pair) + d.text((10,8),'REFERENCE CONTENT MAPPED / '+r['basis'],font=font,fill='white');d.text((a.width+10,8),'ACTUAL ISOLATED API / '+stem,font=font,fill='white');pair.paste(s,(0,32));pair.paste(a,(a.width,32));pair.save(OUT/'shots'/(stem+'-pair.webp'),lossless=True,method=3) +# Twelve actual viewports per state, for layout inspection; native pairs remain available. +contacts=REPORT/'contacts';contacts.mkdir(exist_ok=True) +for name in sorted({r['name'] for r in rows}): + sheet=Image.new('RGB',(1920,3*302),'#202020');d=ImageDraw.Draw(sheet) + for r in [r for r in rows if r['name']==name]: + col=['night','day','grade','bluehour'].index(r['theme']);line=[1440,1366,1280].index(r['width']) + im=Image.open(OUT/'shots'/f"{name}-{r['theme']}-{r['width']}-actual.webp").convert('RGB');im.thumbnail((480,270));sheet.paste(im,(col*480,line*302+28));d.text((col*480+6,line*302+6),f"{name} / {r['theme']} / {r['width']}",font=font,fill='white') + sheet.save(contacts/(name+'.webp'),lossless=True) +# Source values, with no tolerance-based pass flag. A hidden region is not comparable. +with (REPORT/'key-values.csv').open('w') as f: + w=csv.writer(f);w.writerow(['state','theme','viewport','region','actual_xywh','reference_xywh','delta_xywh','basis']) + for r in rows: + for key,a in r['actual'].items(): + b=r['sample'].get(key) + if not a or not b or a['width']<=0 or b['width']<=0:continue + values=lambda o:[round(o[k],4) for k in ['x','y','width','height']] + av,bv=values(a),values(b);w.writerow([r['name'],r['theme'],f"{r['width']}x{r['height']}",key,av,bv,[round(x-y,4)for x,y in zip(av,bv)],r['basis']]) +(REPORT/'capture-manifest.json').write_text(json.dumps({'source_png_to_lossless_webp':manifest,'states':len({r['name']for r in rows}),'groups':len(rows),'errors':data['errors'],'blocked_external_origins':sorted(set(data['blocked']))},indent=2)) +# A theme archive is self-contained and opens without a running server. +for theme in ['night','day','grade','bluehour']: + theme_rows=[r for r in rows if r['theme']==theme] + intro='HEL806 '+theme+'

HEL806 '+theme+'

原图只读。mapped只映射内容;missing-reference 表示原件缺此状态。并排图左为映射参考,右为隔离实图,不代表全页通过。原始数值在results.json。

' + chunks=[intro] + for r in theme_rows: + stem=f"{r['name']}-{theme}-{r['width']}" + chunks.append('
'+html.escape(stem+' · '+r['basis']+' · '+str(r.get('missing','')))+''+f'未改原图 · 内容映射参考 · 实图
') + with zipfile.ZipFile(REPORT/f'HEL806-{theme}.zip','w',zipfile.ZIP_DEFLATED,compresslevel=3) as z: + z.writestr('index.html','\n'.join(chunks));z.writestr('results.json',json.dumps(theme_rows,ensure_ascii=False,indent=2)) + for r in theme_rows: + stem=f"{r['name']}-{theme}-{r['width']}" + for suffix in ['original','mapped','actual','pair']: + p=OUT/'shots'/f'{stem}-{suffix}.webp';z.write(p,'shots/'+p.name) +print(json.dumps({'groups':len(rows),'states':len({r['name']for r in rows}),'archives':{p.name:p.stat().st_size for p in REPORT.glob('*.zip')}},indent=2)) -- 2.54.0 From cb410fbdeaa87528aaaab1f0dbffeb0240a9a32e Mon Sep 17 00:00:00 2001 From: leefer <2298888111@qq.com> Date: Fri, 23 Oct 2026 13:36:32 +0800 Subject: [PATCH 3/9] test: package HEL-806 evidence and verify genuine empty state Co-authored-by: multica-agent --- .../2026-10-23-hel806-same-state-audit.md | 2 +- e2e/hel806.cjs | 5 +- scripts/report-hel806.py | 48 ++++++++++++++----- 3 files changed, 41 insertions(+), 14 deletions(-) diff --git a/docs/changelogs/2026-10-23-hel806-same-state-audit.md b/docs/changelogs/2026-10-23-hel806-same-state-audit.md index ae208739..c0f75a96 100644 --- a/docs/changelogs/2026-10-23-hel806-same-state-audit.md +++ b/docs/changelogs/2026-10-23-hel806-same-state-audit.md @@ -6,7 +6,7 @@ The approved HTML remains read-only, SHA256 `0473d80d22b90194375f3566c1b8ca30dbd - `StudioDesk.svelte`, `Artboard.svelte`: replace the extra 12px gap with the original stage padding (12px 16px 8px, top 8px when height <=800), keep the 40px action row for empty as well as populated states, and clip media to its theme radius (8px night/day, 4px grade, 14px bluehour). Values have one source in tokens; no overlay stylesheet or duplicate component. - `e2e/hel806.cjs`: real disposable HTTP data, fake ComfyUI/model only, four themes × three viewports. Original reference, runtime content map and actual screenshots are distinct. Reference changes use existing templates/content/state; no reference CSS/layout changes. Missing empty states, non-storyboard stage content, real edit/compare/validation dialogs and organization-only pages are explicitly marked. The sample's static parameter list is not substituted for actual workflow declarations. - `e2e/hel806-toast.mjs`, `hel806-toast-browser.cjs`: deterministic expiry/pause/sticky/cleanup regression plus full/reduced motion, project footer clearance and real explicit confirmation. -- `scripts/report-hel806.py`: measured values, self-contained themed galleries and parallel images. Screenshot delivery uses lossless WebP; decoded RGB equality is checked against source PNGs. Manifests retain both hashes. Screenshot counts and DOM measurements are evidence, never automatic visual approval. +- `scripts/report-hel806.py`: measured values, self-contained themed galleries and parallel images. Screenshot delivery uses lossless WebP; decoded RGB equality is checked against source PNGs. Manifests retain both hashes. Identical bitmap bytes are deduplicated inside self-contained upload parts; media-files.json preserves the logical-name mapping, and every state stays with its three viewport sizes. Screenshot counts and DOM measurements are evidence, never automatic visual approval. Reproduce from a frozen build: install the repository's existing dependencies, run frontend lint/check/test/build, then set existing `PLAYWRIGHT_MODULE_PATH`, browser library path and `HEL732_PYTHON` for this machine. `HEL733_TESTS=e2e/hel732.cjs,e2e/hel729.cjs,e2e/hel742.cjs,e2e/hel733-live.cjs,e2e/hel732-visual.cjs,e2e/hel806.cjs,e2e/hel806-toast-browser.cjs HEL733_SKIP_SHOTS=1 python3 e2e/run-hel733.py`. The original HTML is downloaded through the CLI to `../materials` and the B4 reference copy to `../evidence/reference`. All required results are collected before exit; the runner closes its exact child processes and removes the disposable database. Run `node e2e/hel806-toast.mjs` separately. The report script reads `../evidence/hel806-final/results.json`. diff --git a/e2e/hel806.cjs b/e2e/hel806.cjs index 9b6504fe..1bef2947 100644 --- a/e2e/hel806.cjs +++ b/e2e/hel806.cjs @@ -37,6 +37,9 @@ const metrics=(p,sels)=>p.evaluate(sels=>Object.fromEntries(Object.entries(sels) if(process.env.HEL806_SEED_TASKS==='1'){ const seeded=[];for(const mode of ['ok','fail']){fs.writeFileSync('e2e/fake_mode.json',JSON.stringify({mode,delay_sec:0,unique_filename:true,image_file:path.resolve('calliope-web/static/refract/coast.jpg')}));const created=await api('/api/playground/generate',{workflow_id:f.dual,source:'studio-image',input_values:{'1':'HEL806 isolated '+mode,'2':'模糊','3':42}});let job;for(let i=0;i<200;i++){job=await api('/api/jobs/'+created.job.id);if(!['running','pending'].includes(job.status))break;await wait(150)}assert.equal(job.status,mode==='ok'?'done':'failed');seeded.push(job)}fs.writeFileSync(OUT+'/seed-jobs.json',JSON.stringify(seeded,null,2)); } + if(process.env.HEL806_REAL_EMPTY==='1'){ + assert.equal((await api('/api/jobs')).length,0,'empty-state run must start without jobs');const owned=await api('/api/projects');for(const project of owned){const r=await fetch(API+'/api/projects/'+project.id,{method:'DELETE'});assert(r.ok,'delete only disposable fixture project')};const after={projects:await api('/api/projects'),jobs:await api('/api/jobs')};assert.equal(after.projects.length,0);assert.equal(after.jobs.length,0);fs.writeFileSync(OUT+'/real-empty.json',JSON.stringify({removedFixtureIds:owned.map(p=>p.id),after},null,2)); + } const sample=http.createServer((req,res)=>{res.setHeader('Content-Type','text/html;charset=utf-8');res.end(fs.readFileSync('../materials/夜片场-多主题交互样本.html'))});await new Promise(r=>sample.listen(0,'127.0.0.1',r)); const browser=await chromium.launch({args:['--no-sandbox','--disable-dev-shm-usage']}); try{ @@ -55,7 +58,7 @@ const metrics=(p,sels)=>p.evaluate(sels=>Object.fromEntries(Object.entries(sels) {name:'video-output',ref:'create',run:()=>generate(videoWf.id,'video'),creative:true}, {name:'image-failed',ref:'create',run:async()=>{await creative(f.dual);await p.locator('#desk-prompt').fill('HEL806 fake failure');fs.writeFileSync('e2e/fake_mode.json',JSON.stringify({mode:'fail',delay_sec:0}));await p.locator('.gen').click();await p.locator('.stage-empty').filter({hasText:'失败'}).waitFor({timeout:45000})},creative:true}, {name:'studio-real',ref:'studio',run:()=>goto('/'),basis:'approved-cover-exception'}, - {name:'studio-empty',ref:'studio',run:()=>emptyRoute('/','**/api/projects',[]),missing:'原件没有无项目空态'}, + {name:'studio-empty',ref:'studio',run:async()=>{if(process.env.HEL806_REAL_EMPTY==='1')await goto('/');else{await p.route('**/api/jobs',r=>r.fulfill({json:[]}));await emptyRoute('/','**/api/projects',[])}},missing:'原件没有无项目空态'}, {name:'image-text-empty',ref:'create',run:async()=>{await p.route('**/api/playground/jobs',r=>r.fulfill({json:[]}));await creative(f.dual)},creative:true}, {name:'image-long-prompt',ref:'create',run:async()=>{await creative(f.dual);await p.locator('#desk-prompt').fill('海边女孩、晨光、纸船与灯塔,保持人物和空间连续。'.repeat(70));const btn=p.getByRole('button',{name:'展开提示词',exact:true});if(await btn.count())await btn.click()},creative:true}, {name:'image-edit-upload',ref:'create',run:async()=>{await creative(edit.id);await upload()},creative:true}, diff --git a/scripts/report-hel806.py b/scripts/report-hel806.py index aaf71631..dfdb6035 100644 --- a/scripts/report-hel806.py +++ b/scripts/report-hel806.py @@ -44,18 +44,42 @@ with (REPORT/'key-values.csv').open('w') as f: values=lambda o:[round(o[k],4) for k in ['x','y','width','height']] av,bv=values(a),values(b);w.writerow([r['name'],r['theme'],f"{r['width']}x{r['height']}",key,av,bv,[round(x-y,4)for x,y in zip(av,bv)],r['basis']]) (REPORT/'capture-manifest.json').write_text(json.dumps({'source_png_to_lossless_webp':manifest,'states':len({r['name']for r in rows}),'groups':len(rows),'errors':data['errors'],'blocked_external_origins':sorted(set(data['blocked']))},indent=2)) -# A theme archive is self-contained and opens without a running server. +# Self-contained parts keep every state/three sizes together and each upload under 40 MB. +archives=[] for theme in ['night','day','grade','bluehour']: theme_rows=[r for r in rows if r['theme']==theme] - intro='HEL806 '+theme+'

HEL806 '+theme+'

原图只读。mapped只映射内容;missing-reference 表示原件缺此状态。并排图左为映射参考,右为隔离实图,不代表全页通过。原始数值在results.json。

' - chunks=[intro] - for r in theme_rows: - stem=f"{r['name']}-{theme}-{r['width']}" - chunks.append('
'+html.escape(stem+' · '+r['basis']+' · '+str(r.get('missing','')))+''+f'未改原图 · 内容映射参考 · 实图
') - with zipfile.ZipFile(REPORT/f'HEL806-{theme}.zip','w',zipfile.ZIP_DEFLATED,compresslevel=3) as z: - z.writestr('index.html','\n'.join(chunks));z.writestr('results.json',json.dumps(theme_rows,ensure_ascii=False,indent=2)) - for r in theme_rows: + b4files=[p for p in (ROOT/'e2e/shots/hel732').glob(f'*-{theme}-*.png') if p.name.startswith(('home-','task-'))] + b4size=sum(p.stat().st_size for p in b4files) + chunks=[];current=[];objects={};weight=b4size + def files_for(group): + return [OUT/'shots'/f"{r['name']}-{theme}-{r['width']}-{suffix}.webp" for r in group for suffix in ['original','mapped','actual','pair']] + for name in dict.fromkeys(r['name'] for r in theme_rows): + group=[r for r in theme_rows if r['name']==name] + candidates={hashlib.sha256(p.read_bytes()).hexdigest():p for p in files_for(group)} + extra=sum(p.stat().st_size for h,p in candidates.items() if h not in objects) + if current and weight+extra>40_000_000: + chunks.append(current);current=[];objects={};weight=0 + extra=sum(p.stat().st_size for p in candidates.values()) + current+=group;objects.update(candidates);weight+=extra + if current:chunks.append(current) + for part,part_rows in enumerate(chunks,1): + media={};objects={} + for p in files_for(part_rows): + h=hashlib.sha256(p.read_bytes()).hexdigest();media[p.name]='assets/'+h+'.webp';objects[h]=p + doc=['HEL806 '+theme+'

HEL806 '+theme+' / '+str(part)+'

原图只读;mapped只映射内容。missing-reference 是缺依据;并排左参考、右隔离实图,不代表全页通过。此分包可独立解压阅读。B4 gallery 位于本主题 part1 的 B4-index.html

'] + for r in part_rows: stem=f"{r['name']}-{theme}-{r['width']}" - for suffix in ['original','mapped','actual','pair']: - p=OUT/'shots'/f'{stem}-{suffix}.webp';z.write(p,'shots/'+p.name) -print(json.dumps({'groups':len(rows),'states':len({r['name']for r in rows}),'archives':{p.name:p.stat().st_size for p in REPORT.glob('*.zip')}},indent=2)) + link=lambda suffix:media[stem+'-'+suffix+'.webp'] + doc.append('
'+html.escape(stem+' · '+r['basis']+' · '+str(r.get('missing','')))+''+f'未改原图 · 内容映射参考 · 实图
') + target=REPORT/f'HEL806-{theme}-part{part}.zip' + with zipfile.ZipFile(target,'w',zipfile.ZIP_DEFLATED,compresslevel=3) as z: + z.writestr('index.html','\n'.join(doc));z.writestr('results.json',json.dumps(part_rows,ensure_ascii=False,indent=2));z.writestr('media-files.json',json.dumps(media,indent=2)) + for h,p in objects.items():z.write(p,'assets/'+h+'.webp') + if part==1: + b4doc=['

B4 '+theme+'

沿用已批准内容/hero映射。原PNG保持不变;不把获批例外扩大解释。

'] + for p in sorted(b4files):z.write(p,'b4/'+p.name);b4doc.append('

'+html.escape(p.name)+'

') + z.writestr('B4-index.html','\n'.join(b4doc)) + assert target.stat().st_size<45_000_000 + archives.append({'name':target.name,'bytes':target.stat().st_size,'groups':len(part_rows)}) +(REPORT/'archives.json').write_text(json.dumps(archives,indent=2)) +print(json.dumps({'groups':len(rows),'states':len({r['name']for r in rows}),'archives':archives},indent=2)) -- 2.54.0 From b6ded3942b7f18a522343dff39a1e48bf924e177 Mon Sep 17 00:00:00 2001 From: leefer <2298888111@qq.com> Date: Fri, 23 Oct 2026 15:08:19 +0800 Subject: [PATCH 4/9] fix(workflows): derive honest media metadata for single imports (HEL-809) Co-authored-by: multica-agent --- .../src/calliope/comfyui/metadata.py | 30 +-- .../src/calliope/routers/workflows.py | 87 ++++++-- .../tests/test_workflow_import_batch.py | 4 +- .../tests/test_workflow_media_io.py | 205 ++++++++++++++++++ .../2026-10-23-hel809-workflow-media.md | 10 + e2e/hel809.cjs | 69 ++++++ e2e/run-hel809.py | 69 ++++++ 7 files changed, 441 insertions(+), 33 deletions(-) create mode 100644 calliope-backend/tests/test_workflow_media_io.py create mode 100644 docs/changelogs/2026-10-23-hel809-workflow-media.md create mode 100644 e2e/hel809.cjs create mode 100644 e2e/run-hel809.py diff --git a/calliope-backend/src/calliope/comfyui/metadata.py b/calliope-backend/src/calliope/comfyui/metadata.py index 9c170ec9..cf658156 100644 --- a/calliope-backend/src/calliope/comfyui/metadata.py +++ b/calliope-backend/src/calliope/comfyui/metadata.py @@ -124,6 +124,19 @@ def note_description(ui_workflow: dict[str, Any], limit: int = 160) -> str | Non return None +def media_io_from_schema( + inputs: list[dict[str, Any]], outputs: list[dict[str, Any]] +) -> dict[str, list[str]]: + """Media declared by the final input schema and real graph outputs.""" + media_kinds = ("image", "image_list", "video", "video_list", "audio", "audio_list") + return { + "in": sorted( + {e["kind"].replace("_list", "") for e in inputs if e.get("kind") in media_kinds} + ), + "out": sorted({e["kind"] for e in outputs if e.get("kind") in ("image", "video", "audio")}), + } + + def suggested_metadata( converted: dict[str, Any], source_path: str | None = None, @@ -131,21 +144,10 @@ def suggested_metadata( ) -> dict[str, Any]: """The import suggestion bundle for one converted workflow.""" judgment = classify_usage(converted) - media_kinds = ("image", "image_list", "video", "video_list", "audio", "audio_list") - media_in = sorted( - { - entry["kind"].replace("_list", "") - for entry in parse_dynamic_inputs(converted) - if entry.get("kind") in media_kinds - } - ) - media_out = sorted( - { - str(entry.get("kind")) - for entry in parse_dynamic_outputs(converted) - if entry.get("kind") in ("image", "video", "audio") - } + media_io = media_io_from_schema( + parse_dynamic_inputs(converted), parse_dynamic_outputs(converted) ) + media_in, media_out = media_io["in"], media_io["out"] category, category_label = category_from_source_path(source_path) return { "kind": "video" if "video" in media_out and "image" not in media_out else "image", diff --git a/calliope-backend/src/calliope/routers/workflows.py b/calliope-backend/src/calliope/routers/workflows.py index d038991f..e195568f 100644 --- a/calliope-backend/src/calliope/routers/workflows.py +++ b/calliope-backend/src/calliope/routers/workflows.py @@ -7,7 +7,7 @@ from fastapi import APIRouter, HTTPException from calliope.comfyui.endpoints import client_for, endpoint_snapshot from calliope.comfyui.lint import blocking_problem, workflow_warnings -from calliope.comfyui.metadata import suggested_metadata +from calliope.comfyui.metadata import media_io_from_schema, suggested_metadata from calliope.comfyui.output_map import ( auto_output_map, load_output_map, @@ -76,7 +76,9 @@ def _serialize_workflow(row: Any) -> dict[str, Any]: mapping_problem(data["workflow_json"], raw_map, data.get("output_map_source")) is not None ) # HEL-681: structured library metadata (raw JSON columns → values). - data["media_io"] = _load_json_col(data.get("media_io"), {"in": [], "out": []}) + data["media_io"] = _resolved_media_io( + data.get("media_io"), data["input_schema"], data["output_schema"] + ) data["tags"] = _load_json_col(data.get("tags"), []) data["favorite"] = bool(data.get("favorite")) return data @@ -90,6 +92,20 @@ def _load_json_col(raw: Any, default: Any) -> Any: return value if value is not None else default +def _resolved_media_io( + raw: Any, inputs: list[dict[str, Any]], outputs: list[dict[str, Any]] +) -> dict[str, list[str]]: + """Fill missing declarations only; an explicit empty list is meaningful.""" + stored = _load_json_col(raw, {}) + derived = media_io_from_schema(inputs, outputs) + if not isinstance(stored, dict): + return derived + return { + key: stored[key] if isinstance(stored.get(key), list) else derived[key] + for key in ("in", "out") + } + + async def _connection_check( endpoint_id: str | None, workflow_json: dict[str, Any], refresh: bool = False ) -> dict[str, Any]: @@ -197,13 +213,15 @@ async def reanalyze_workflow(workflow_id: int) -> dict[str, Any]: output_map, output_map_source = None, None else: output_map, output_map_source = json.dumps(auto_output_map(workflow)), "auto" + media_io = _resolved_media_io(data["media_io"], schema, outputs) + media_io["in"] = media_io_from_schema(schema, outputs)["in"] is_enabled = data["is_enabled"] if output_map_source is None or usage is None: is_enabled = 0 conn.execute( "UPDATE workflows SET input_schema = ?, output_schema = ?," " usage = ?, usage_source = ?, output_map = ?, output_map_source = ?," - " is_enabled = ? WHERE id = ?", + " is_enabled = ?, media_io = ? WHERE id = ?", ( json.dumps(schema), json.dumps(outputs), @@ -212,6 +230,7 @@ async def reanalyze_workflow(workflow_id: int) -> dict[str, Any]: output_map, output_map_source, is_enabled, + json.dumps(media_io), workflow_id, ), ) @@ -247,7 +266,8 @@ async def list_workflows(view: str | None = None) -> list[dict[str, Any]]: try: if view == "meta": # HEL-681: metadata-only list for 150+ scale browsing — no - # workflow_json, no per-row re-parse. `needs_mapping` here is the + # workflow_json in the response. Only legacy/missing media + # declarations need graph parsing. `needs_mapping` here is the # cheap pending check (usage/output_map not yet settled); rows # that never generated keep showing as pending exactly like the # full view reports them. @@ -260,11 +280,27 @@ async def list_workflows(view: str | None = None) -> list[dict[str, Any]]: data = row_to_dict(row) data["is_enabled"] = bool(data.get("is_enabled")) data["favorite"] = bool(data.get("favorite")) - data["media_io"] = _load_json_col(data.get("media_io"), {"in": [], "out": []}) + media = _load_json_col(data.get("media_io"), {}) + if isinstance(media, dict) and all( + isinstance(media.get(key), list) for key in ("in", "out") + ): + data["media_io"] = {key: media[key] for key in ("in", "out")} + else: + legacy = conn.execute( + "SELECT workflow_json, input_schema FROM workflows WHERE id = ?", + (data["id"],), + ).fetchone() + graph = json.loads(legacy["workflow_json"]) + schema = effective_input_schema( + _load_json_col(legacy["input_schema"], []), graph + ) + data["media_io"] = _resolved_media_io( + data.get("media_io"), schema, parse_dynamic_outputs(graph) + ) data["tags"] = _load_json_col(data.get("tags"), []) - data["needs_mapping"] = data.get("usage") is None or data.get( - "output_map_source" - ) is None + data["needs_mapping"] = ( + data.get("usage") is None or data.get("output_map_source") is None + ) out.append(data) return out rows = conn.execute("SELECT * FROM workflows ORDER BY id DESC").fetchall() @@ -369,6 +405,9 @@ async def create_workflow(payload: WorkflowCreate) -> dict[str, Any]: output_map = json.dumps(auto_map) if auto_map else None output_map_source = "auto" if auto_map else None mapping_ok = not needs_user_output_map(payload.workflow_json) or bool(payload.output_map) + schema = _stored_schema(payload.input_schema, payload.workflow_json) + outputs = parse_dynamic_outputs(payload.workflow_json) + media_io = media_io_from_schema(json.loads(schema), outputs) conn = get_db(settings.db_path) try: # Byte-identical re-imports are the main way the library fills up with @@ -395,15 +434,15 @@ async def create_workflow(payload: WorkflowCreate) -> dict[str, Any]: INSERT INTO workflows (name, kind, workflow_json, input_schema, output_schema, description, prompt_profile, endpoint_id, is_enabled, usage, usage_source, - output_map, output_map_source) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + output_map, output_map_source, media_io) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) """, ( payload.name, kind, json.dumps(payload.workflow_json), - _stored_schema(payload.input_schema, payload.workflow_json), - json.dumps(parse_dynamic_outputs(payload.workflow_json)), + schema, + json.dumps(outputs), payload.description, profile, payload.endpoint_id, @@ -412,6 +451,7 @@ async def create_workflow(payload: WorkflowCreate) -> dict[str, Any]: usage_source, output_map, output_map_source, + json.dumps(media_io), ), ) conn.commit() @@ -474,7 +514,20 @@ async def update_workflow(workflow_id: int, payload: WorkflowUpdate) -> dict[str if "input_schema" in data and data["input_schema"] is not None: if not all(isinstance(e, dict) for e in data["input_schema"]): raise HTTPException(status_code=422, detail="input_schema 必须是参数对象数组") + media = _resolved_media_io( + existing["media_io"], [], parse_dynamic_outputs(workflow_json) + ) + schema = effective_input_schema(data["input_schema"], workflow_json) + media["in"] = media_io_from_schema(schema, [])["in"] + data["media_io"] = json.dumps(media) data["input_schema"] = json.dumps(data["input_schema"]) + elif existing["media_io"] is None: + schema = effective_input_schema( + _load_json_col(existing["input_schema"], []), workflow_json + ) + data["media_io"] = json.dumps( + media_io_from_schema(schema, parse_dynamic_outputs(workflow_json)) + ) # endpoint_id: explicit null clears the binding; other Nones are skips # (an output_map reset keeps its explicit NULLs). explicit_null = {"endpoint_id", "output_map", "output_map_source"} & { @@ -792,6 +845,8 @@ def _insert_converted( "existing_id": existing["id"], } is_enabled = 1 if (usage and mapping_ok) else 0 + schema = _stored_schema(item.input_schema, converted) + outputs = parse_dynamic_outputs(converted) cur = conn.execute( """ INSERT INTO workflows (name, kind, workflow_json, input_schema, output_schema, @@ -805,10 +860,8 @@ def _insert_converted( report["name"], kind, workflow_text, - json.dumps( - item.input_schema if item.input_schema else parse_dynamic_inputs(converted) - ), - json.dumps(parse_dynamic_outputs(converted)), + schema, + json.dumps(outputs), item.description if item.description is not None else suggested["description"], profile, is_enabled, @@ -818,7 +871,7 @@ def _insert_converted( output_map_source, suggested["category"], suggested["model_family"], - json.dumps({"in": suggested["media_in"], "out": suggested["media_out"]}), + json.dumps(media_io_from_schema(json.loads(schema), outputs)), json.dumps(item.tags or []), item.source_path, ), diff --git a/calliope-backend/tests/test_workflow_import_batch.py b/calliope-backend/tests/test_workflow_import_batch.py index 61671bab..da917410 100644 --- a/calliope-backend/tests/test_workflow_import_batch.py +++ b/calliope-backend/tests/test_workflow_import_batch.py @@ -339,7 +339,7 @@ def test_meta_view_has_metadata_but_no_full_json(client): def test_legacy_list_shape_and_old_records_unchanged(client): """Old imports (API format via POST /api/workflows) keep working; the - legacy list still carries workflow_json and the new columns default.""" + legacy list still carries workflow_json and truthful media declarations.""" created = client.post( "/api/workflows", json={"name": "旧导入", "workflow_json": _api_workflow()}, @@ -352,7 +352,7 @@ def test_legacy_list_shape_and_old_records_unchanged(client): assert "usage" in row and "output_map" in row assert row["favorite"] is False assert row["tags"] == [] - assert row["media_io"] == {"in": [], "out": []} + assert row["media_io"] == {"in": ["image"], "out": ["video"]} meta = client.get("/api/workflows", params={"view": "meta"}).json() assert meta[0]["needs_mapping"] is False or meta[0]["needs_mapping"] is True assert meta[0]["source"] is None diff --git a/calliope-backend/tests/test_workflow_media_io.py b/calliope-backend/tests/test_workflow_media_io.py new file mode 100644 index 00000000..ae95efd3 --- /dev/null +++ b/calliope-backend/tests/test_workflow_media_io.py @@ -0,0 +1,205 @@ +"""HEL-809: import declarations, persistence and both library views agree.""" + +from __future__ import annotations + +import json + +import pytest + +from calliope.config import settings +from calliope.db import get_db +from tests.test_workflow_import_batch import _ui_graph + + +def graph(*classes): + nodes = {str(i): {"class_type": cls, "inputs": {}} for i, cls in enumerate(classes, 1)} + nodes["90"] = { + "class_type": "PrimitiveStringMultiline", + "inputs": {"value": "HEL809 isolated prompt"}, + "_meta": {"title": "Prompt (Input:prompt)"}, + } + for i in range(1, len(classes) + 1): + role = "mask" if classes[i - 1] == "LoadImageMask" else "image" + nodes[str(i)]["_meta"] = {"title": f"Reference (Input:{role})"} + return nodes + + +def create(client, workflow, schema=None): + analyzed = client.post("/api/workflows/analyze", json={"workflow_json": workflow}) + assert analyzed.status_code == 200 + response = client.post( + "/api/workflows", + json={ + "name": "HEL809 input contract", + "workflow_json": workflow, + "input_schema": analyzed.json()["inputs"] if schema is None else schema, + }, + ) + assert response.status_code == 200, response.text + return response.json() + + +def assert_views(client, wid, expected): + detail = client.get(f"/api/workflows/{wid}").json() + assert detail["media_io"] == expected + for query in ("", "?view=meta"): + rows = client.get("/api/workflows" + query).json() + row = next(r for r in rows if r["id"] == wid) + assert row["media_io"] == expected + # This is the library's exact media predicate, fed by the meta API. + for media in ("image", "video", "audio"): + hits = [ + r["id"] + for r in rows + if media in r["media_io"]["in"] or media in r["media_io"]["out"] + ] + assert (wid in hits) == (media in expected["in"] or media in expected["out"]) + if query: + assert not {"workflow_json", "input_schema", "output_schema"} & row.keys() + + +def stored_media(wid): + with get_db(settings.db_path) as conn: + return json.loads( + conn.execute("SELECT media_io FROM workflows WHERE id = ?", (wid,)).fetchone()[ + "media_io" + ] + ) + + +@pytest.mark.parametrize( + "classes", [("LoadImage",), ("LoadImageMask",), ("LoadImage", "LoadImageMask"), ()] +) +def test_analyze_create_persists_media_and_agrees_with_library(client, classes): + row = create(client, graph(*classes)) + expected = {"in": ["image"] if classes else [], "out": []} + if "LoadImageMask" in classes: + assert any(e["role"] == "mask" and e["kind"] == "image" for e in row["input_schema"]) + assert stored_media(row["id"]) == expected + assert row["model_family"] is None # never infer a model or output from kind + assert_views(client, row["id"], expected) + + +def test_create_uses_confirmed_schema_not_original_graph(client): + row = create(client, graph(), [{"nodeId": "90", "kind": "audio_list", "manual": True}]) + assert stored_media(row["id"]) == {"in": ["audio"], "out": []} + assert_views(client, row["id"], {"in": ["audio"], "out": []}) + + +def test_update_changes_inputs_preserving_explicit_outputs_and_other_metadata(client): + row = create(client, graph("LoadImage")) + with get_db(settings.db_path) as conn: + conn.execute( + "UPDATE workflows SET media_io = ?, model_family = ?, tags = ? WHERE id = ?", + (json.dumps({"in": ["image"], "out": []}), "existing", '["tag"]', row["id"]), + ) + for kind, expected in (("audio_list", ["audio"]), ("text", []), ("video_list", ["video"])): + response = client.patch( + f"/api/workflows/{row['id']}", + json={ + "input_schema": [{"nodeId": "90", "kind": kind, "manual": True}], + }, + ) + assert response.status_code == 200, response.text + assert response.json()["model_family"] == "existing" + assert response.json()["tags"] == ["tag"] + assert stored_media(row["id"]) == {"in": expected, "out": []} + assert_views(client, row["id"], {"in": expected, "out": []}) + + +@pytest.mark.parametrize( + "raw, expected", + [ + (None, {"in": ["image"], "out": ["image"]}), + ('{"in": [], "out": []}', {"in": [], "out": []}), + ('{"out": []}', {"in": ["image"], "out": []}), + ('{"in": []}', {"in": [], "out": ["image"]}), + ], +) +def test_legacy_null_and_missing_keys_are_distinct_from_empty_lists(client, raw, expected): + workflow = graph("LoadImageMask") + workflow["99"] = {"class_type": "SaveImage", "inputs": {"images": ["1", 0]}} + row = create(client, workflow) + with get_db(settings.db_path) as conn: + conn.execute("UPDATE workflows SET media_io = ? WHERE id = ?", (raw, row["id"])) + assert_views(client, row["id"], expected) + with get_db(settings.db_path) as conn: + assert ( + conn.execute("SELECT media_io FROM workflows WHERE id = ?", (row["id"],)).fetchone()[ + "media_io" + ] + == raw + ) # read only fallback + if raw is None: + response = client.patch(f"/api/workflows/{row['id']}", json={"favorite": True}) + assert response.status_code == 200 + assert stored_media(row["id"]) == expected + + +def test_legacy_prompt_only_has_no_invented_media(client): + row = create(client, graph()) + with get_db(settings.db_path) as conn: + conn.execute("UPDATE workflows SET media_io = NULL WHERE id = ?", (row["id"],)) + assert_views(client, row["id"], {"in": [], "out": []}) + + +def test_batch_import_metadata_survives_unrelated_update(client): + response = client.post( + "/api/workflows/import-batch", + json={ + "files": [ + { + "source_path": "3-图像生成/Qwen-Image-2.1/demo.json", + "workflow_json": _ui_graph(), + "tags": ["existing batch"], + } + ] + }, + ) + assert response.status_code == 200, response.text + wid = response.json()["imported_ids"][0] + before = client.get(f"/api/workflows/{wid}").json() + response = client.patch(f"/api/workflows/{wid}", json={"favorite": True}) + after = response.json() + for key in ("media_io", "source", "source_path", "category", "model_family", "tags"): + assert before[key] == after[key] + assert_views(client, wid, {"in": [], "out": ["image"]}) + + +def test_reanalyze_refreshes_inputs_and_preserves_empty_output_declaration(client): + row = create(client, graph("LoadImageMask")) + client.patch( + f"/api/workflows/{row['id']}", + json={ + "input_schema": [{"nodeId": "90", "kind": "text"}], + }, + ) + # Legacy auto schema: read/reanalyze must rederive it before media classification. + with get_db(settings.db_path) as conn: + conn.execute( + "UPDATE workflows SET input_schema = ? WHERE id = ?", + ('[{"nodeId": "90", "kind": "text", "auto": true}]', row["id"]), + ) + response = client.post(f"/api/workflows/{row['id']}/reanalyze") + assert response.status_code == 200 + expected = {"in": ["image"], "out": []} + assert stored_media(row["id"]) == expected + assert_views(client, row["id"], expected) + + +def test_batch_import_uses_final_confirmed_input_schema(client): + response = client.post( + "/api/workflows/import-batch", + json={ + "files": [ + { + "workflow_json": _ui_graph(), + "input_schema": [{"nodeId": "2", "kind": "image_list", "manual": True}], + } + ] + }, + ) + assert response.status_code == 200, response.text + wid = response.json()["imported_ids"][0] + assert stored_media(wid) == {"in": ["image"], "out": ["image"]} + assert_views(client, wid, {"in": ["image"], "out": ["image"]}) diff --git a/docs/changelogs/2026-10-23-hel809-workflow-media.md b/docs/changelogs/2026-10-23-hel809-workflow-media.md new file mode 100644 index 00000000..fe6af1bc --- /dev/null +++ b/docs/changelogs/2026-10-23-hel809-workflow-media.md @@ -0,0 +1,10 @@ +HEL-809 fixes single-workflow imports being shown as prompt-only and excluded from media filters. It continues draft PR #12 from `cb410fbdeaa87528aaaab1f0dbffeb0240a9a32e`, retaining all existing fixes; target is `leefer/hel-731-studio-project@2952af42b6232483640f1f27803980367cd62dd0`. + +- `comfyui/metadata.py`: extracts the existing input-kind normalization and real output-kind rules into `media_io_from_schema`, shared by import suggestions and persistence. Mask inputs already have kind=image; lists normalize to their media kind. Model family inference is unchanged. +- `routers/workflows.py`: single create and schema updates persist media declarations from the final schema. Reanalyze also refreshes inputs. Batch import uses the same helper with the confirmed schema while retaining category, model family, tags and source. Existing output declarations, including empty arrays, survive schema edits; missing outputs derive only from actual parsed graph declarations, never workflow kind/usage. +- Full and metadata-only serialization derive absent media declarations for legacy NULL rows without database writes. Present arrays, including valid empty arrays, are preserved independently per key. The lightweight list fetches/parses graph data only for records missing declarations; its response still excludes heavy schemas/graphs. The existing frontend row and filter both consume this result; no frontend product file or visual rule changes. +- `tests/test_workflow_media_io.py`: 14 targeted regressions cover real analyze→create image/mask/prompt paths, confirmed schema overrides, update classification, NULL and partial legacy metadata versus empty arrays, no invented outputs/families, batch metadata retention, reanalyze and agreement among detail/full/meta library views. The previous batch test expecting missing declarations now expects the graph's actual image→video declaration. + +Validation: Python 3.12 relevant backend suites: 93 passed, one existing Starlette deprecation warning. Ruff on changed product/new regression files and git diff whitespace check pass. Unchanged frontend production build passes. `e2e/hel809.cjs` and `e2e/run-hel809.py` use the same frontend build against frozen pre-fix and current backend, capture actual drawer analyze/create requests and responses plus meta/detail reads, and assert old prompt-only/filter miss versus corrected image/filter hit. Pure prompt row retains its label and is excluded from the image filter. Screenshots/responses/logs and exact final head/base are delivered on HEL-809. + +Reproduce: use an existing backend Python environment with repository dependencies and build `calliope-web`; set `PLAYWRIGHT_MODULE_PATH` and browser library path for the machine, then run `python e2e/run-hel809.py`. The runner uses loopback-only ports 18249/18250, checks availability first, pauses generation, points ComfyUI/LLM to a closed loopback port, starts no real model service, terminates its exact backend children and removes its temporary databases in finally. Evidence remains in the caller's workdir. No new dependency, migration, production data/config, deployment or merge. Rollback this task's commit only; existing NULL records remain compatible with the preceding version. diff --git a/e2e/hel809.cjs b/e2e/hel809.cjs new file mode 100644 index 00000000..4a3bf8db --- /dev/null +++ b/e2e/hel809.cjs @@ -0,0 +1,69 @@ +// Targeted real drawer -> HTTP -> database -> library regression; no generation. +const { chromium } = require(process.env.PLAYWRIGHT_MODULE_PATH || 'playwright'); +const fs = require('node:fs'), path = require('node:path'), http = require('node:http'); +const assert = require('node:assert/strict'); +const phase = process.env.HEL809_PHASE, out = process.env.HEL809_EVIDENCE; +const graph = { + '1': {class_type:'PrimitiveStringMultiline',inputs:{value:'isolated test prompt'},_meta:{title:'Prompt (Input:prompt)'}}, + '2': {class_type:'LoadImage',inputs:{image:'isolated.png'},_meta:{title:'Image (Input:image)'}}, + '3': {class_type:'LoadImageMask',inputs:{image:'isolated.png',channel:'red'},_meta:{title:'Mask (Input:mask)'}}, + '4': {class_type:'SaveImage',inputs:{images:['2',0]},_meta:{title:'Result (Output:image)'}} +}; +const build = path.resolve('calliope-web/build'); +const mime = {'.js':'text/javascript','.css':'text/css','.html':'text/html;charset=utf-8','.json':'application/json','.svg':'image/svg+xml','.woff2':'font/woff2'}; +const sockets = new Set(); +const server = http.createServer((req,res)=>{ + if(req.url.startsWith('/api/')) { + const proxy=http.request({hostname:'127.0.0.1',port:18249,path:req.url,method:req.method,headers:req.headers},r=>{res.writeHead(r.statusCode,r.headers);r.pipe(res)}); + proxy.on('error',()=>{res.writeHead(502);res.end()});req.pipe(proxy);return; + } + let file=path.join(build,new URL(req.url,'http://localhost').pathname); + if(!fs.existsSync(file)||fs.statSync(file).isDirectory())file=path.join(build,'index.html'); + res.setHeader('Content-Type',mime[path.extname(file)]||'application/octet-stream');fs.createReadStream(file).pipe(res); +}); +server.on('connection',s=>{sockets.add(s);s.on('close',()=>sockets.delete(s))}); +(async()=>{ + await new Promise((resolve,reject)=>{server.once('error',reject);server.listen(18250,'127.0.0.1',resolve)}); + let browser; const errors=[],responses=[],checks=[]; + try { + browser=await chromium.launch({args:['--no-sandbox']}); + const context=await browser.newContext({viewport:{width:1440,height:900}}); + await context.addInitScript(()=>{localStorage.setItem('aigc-lang','zh');localStorage.setItem('aigc-theme','night')}); + const page=await context.newPage();page.on('pageerror',e=>errors.push(e.message)); + page.on('response',r=>{if(r.url().includes('/api/workflows') && ['POST','PATCH'].includes(r.request().method()))responses.push((async()=>({method:r.request().method(),url:new URL(r.url()).pathname,request:r.request().postDataJSON(),status:r.status(),response:await r.json()}))())}); + await page.goto('http://127.0.0.1:18250/workflows'); + await page.getByRole('button',{name:'导入工作流',exact:true}).click(); + await page.getByPlaceholder('Qwen Image 2.1 文生图').fill('HEL809 图像与遮罩'); + await page.locator('textarea.json').fill(JSON.stringify(graph,null,2)); + await page.getByRole('button',{name:'分析',exact:true}).click(); + await page.getByRole('button',{name:'确认保存',exact:true}).click(); + const row=page.locator('tbody tr').filter({hasText:'HEL809 图像与遮罩'}); + await row.waitFor(); + await page.screenshot({path:path.join(out,phase+'-library.png'),fullPage:true}); + const rowText=await row.innerText(); + assert(rowText.includes(phase==='before'?'仅提示词':'图像'),rowText);checks.push('real drawer row label'); + await page.getByRole('combobox',{name:'素材类型',exact:true}).selectOption('image'); + // The output is image too: media filter uses both input and output declarations. + if(phase==='before')await row.waitFor({state:'hidden'});else await row.waitFor(); + await page.screenshot({path:path.join(out,phase+'-image-filter.png'),fullPage:true}); + checks.push('image filter '+(phase==='before'?'miss reproduced':'hit')); + const api=async(p,b,method)=>{const r=await fetch('http://127.0.0.1:18249/api'+p,{method:method||(b?'POST':'GET'),headers:{'Content-Type':'application/json'},body:b?JSON.stringify(b):undefined});assert(r.ok,await r.clone().text());return r.json()}; + const meta=await api('/workflows?view=meta'),detail=await api('/workflows/'+meta[0].id); + assert(detail.input_schema.some(x=>x.role==='mask'&&x.kind==='image'));checks.push('mask is image'); + if(phase==='after'){ + assert.deepEqual(meta[0].media_io,{in:['image'],out:['image']}); + const promptGraph={'1':graph['1']}; + const analyzed=await api('/workflows/analyze',{workflow_json:promptGraph}); + const prompt=await api('/workflows',{name:'HEL809 纯提示词',workflow_json:promptGraph,input_schema:analyzed.inputs,usage:'text2image'}); + assert.deepEqual(prompt.media_io,{in:[],out:[]}); + await page.reload();await page.getByRole('combobox',{name:'素材类型',exact:true}).selectOption('all'); + const promptRow=page.locator('tbody tr').filter({hasText:'HEL809 纯提示词'});await promptRow.waitFor();assert((await promptRow.innerText()).includes('仅提示词')); + await page.getByRole('combobox',{name:'素材类型',exact:true}).selectOption('image');await promptRow.waitFor({state:'hidden'});await row.waitFor(); + checks.push('pure prompt label and exclusion from image filter'); + await page.screenshot({path:path.join(out,phase+'-prompt-excluded.png'),fullPage:true}); + } + const network=await Promise.all(responses); + fs.writeFileSync(path.join(out,phase+'-responses.json'),JSON.stringify({network,meta,detail,rowText,checks,errors},null,2)); + assert.deepEqual(errors,[]);console.log(phase,checks); + } finally {if(browser)await browser.close();for(const s of sockets)s.destroy();await new Promise(r=>server.close(r));} +})().catch(e=>{console.error(e);process.exitCode=1}); diff --git a/e2e/run-hel809.py b/e2e/run-hel809.py new file mode 100644 index 00000000..5868da44 --- /dev/null +++ b/e2e/run-hel809.py @@ -0,0 +1,69 @@ +"""Foreground isolated before/after API + browser proof; always remove own state.""" +import io +import json +import os +from pathlib import Path +import shutil +import signal +import socket +import subprocess +import sys +import tarfile +import tempfile +import time +import urllib.request + +root = Path(__file__).resolve().parents[1] +os.chdir(root) +out = root.parent / 'evidence/hel809' +out.mkdir(parents=True, exist_ok=True) +for port in (18249, 18250): + with socket.socket() as s: + assert s.connect_ex(('127.0.0.1', port)) != 0, f'Port in use: {port}' +bootstrap = '''from pathlib import Path +import os +from calliope import config +root = Path(os.environ['HEL809_DATA']) +config.CONFIG_FILE = root / 'settings.json' +config.settings = config.Settings(data_dir=root/'data', assets_dir=root/'data/assets', + comfyui_base_url='http://127.0.0.1:1', llm_base_url='http://127.0.0.1:1/v1', 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=18249,log_level='warning') +''' +cleanup = [] +with tempfile.TemporaryDirectory(prefix='hel809-', dir=out) as tmp: + work = Path(tmp) + old = work/'before'; old.mkdir() + archive = subprocess.check_output(['git','archive','cb410fbdeaa87528aaaab1f0dbffeb0240a9a32e','calliope-backend/src']) + with tarfile.open(fileobj=io.BytesIO(archive)) as bundle: + bundle.extractall(old, filter='data') + server = work/'server.py'; server.write_text(bootstrap) + for phase, src in [('before',old/'calliope-backend/src'),('after',root/'calliope-backend/src')]: + data = work/phase/'runtime'; data.mkdir(parents=True) + env = {**os.environ, 'PYTHONPATH':str(src), 'HEL809_DATA':str(data), + 'HEL809_PHASE':phase,'HEL809_EVIDENCE':str(out)} + with (out/(phase+'-backend.log')).open('w') as log: + child = subprocess.Popen([sys.executable,str(server)],env=env,stdout=log,stderr=subprocess.STDOUT) + try: + for attempt in range(100): + try: + urllib.request.urlopen('http://127.0.0.1:18249/api/health',timeout=1);break + except Exception: + if child.poll() is not None: raise RuntimeError('Backend failed: '+phase) + if attempt==99:raise + time.sleep(.1) + subprocess.run(['node','e2e/hel809.cjs'],env=env,check=True,timeout=100) + finally: + daemon = json.loads(subprocess.check_output(['multica','daemon','status','--output','json'])) + assert child.pid != daemon['pid'] + if child.poll() is None:child.send_signal(signal.SIGTERM) + try:child.wait(timeout=10) + except subprocess.TimeoutExpired:child.kill();child.wait() + cleanup.append({'phase':phase,'pid':child.pid,'exit':child.returncode}) + # TemporaryDirectory removes only this run's database and before-source copy. +(out/'cleanup.json').write_text(json.dumps({'children':cleanup,'temporary_state_removed':not work.exists()},indent=2)) +print('CLEANUP',cleanup, 'state removed',not work.exists()) -- 2.54.0 From e2f139806fd5d49640b5e75f6d9215b62d2b8e40 Mon Sep 17 00:00:00 2001 From: leefer <2298888111@qq.com> Date: Fri, 23 Oct 2026 15:23:58 +0800 Subject: [PATCH 5/9] fix(i18n): translate Build Scene suggestion labels (HEL-810) Co-authored-by: multica-agent --- calliope-web/src/lib/i18n/de.ts | 4 ++++ calliope-web/src/lib/i18n/en.ts | 4 ++++ calliope-web/src/lib/i18n/es.ts | 4 ++++ calliope-web/src/lib/i18n/fr.ts | 4 ++++ calliope-web/src/lib/i18n/ja.ts | 4 ++++ calliope-web/src/lib/i18n/ko.ts | 4 ++++ calliope-web/src/lib/i18n/zh.ts | 4 ++++ .../2026-10-23-hel810-scene-suggestions.md | 20 +++++++++++++++++++ 8 files changed, 48 insertions(+) create mode 100644 docs/changelogs/2026-10-23-hel810-scene-suggestions.md diff --git a/calliope-web/src/lib/i18n/de.ts b/calliope-web/src/lib/i18n/de.ts index e343ce78..d73400de 100644 --- a/calliope-web/src/lib/i18n/de.ts +++ b/calliope-web/src/lib/i18n/de.ts @@ -599,6 +599,10 @@ export const de: Dict = { // --- Build scene --- "buildScene.title": "Szene bauen", "buildScene.builderBadge": "3D-Builder", + "buildScene.suggestionTwoPerson": "Komposition mit zwei Personen", + "buildScene.suggestionHeroPose": "Komposition mit Heldenpose", + "buildScene.suggestionBlockout": "Einfachen Raum anlegen", + "buildScene.suggestionCapture": "Referenzbild aufnehmen", "buildScene.preparing": "Szene wird vorbereitet…", "buildScene.stillLoading": "Szene lädt noch…", "buildScene.expandChat": "Chat ausklappen", diff --git a/calliope-web/src/lib/i18n/en.ts b/calliope-web/src/lib/i18n/en.ts index 2d507c33..d9dc4619 100644 --- a/calliope-web/src/lib/i18n/en.ts +++ b/calliope-web/src/lib/i18n/en.ts @@ -579,6 +579,10 @@ export const en = { // --- Build scene --- "buildScene.title": "Build Scene", "buildScene.builderBadge": "3D Builder", + "buildScene.suggestionTwoPerson": "Two-person composition", + "buildScene.suggestionHeroPose": "Hero pose composition", + "buildScene.suggestionBlockout": "Block out a simple room", + "buildScene.suggestionCapture": "Capture a reference image", "buildScene.preparing": "Preparing scene…", "buildScene.stillLoading": "Scene still loading…", "buildScene.expandChat": "Expand chat", diff --git a/calliope-web/src/lib/i18n/es.ts b/calliope-web/src/lib/i18n/es.ts index 6f444673..211f28a3 100644 --- a/calliope-web/src/lib/i18n/es.ts +++ b/calliope-web/src/lib/i18n/es.ts @@ -599,6 +599,10 @@ export const es: Dict = { // --- Build scene --- "buildScene.title": "Componer escena", "buildScene.builderBadge": "Editor 3D", + "buildScene.suggestionTwoPerson": "Composición de dos personas", + "buildScene.suggestionHeroPose": "Composición con pose heroica", + "buildScene.suggestionBlockout": "Crear una habitación sencilla", + "buildScene.suggestionCapture": "Capturar una imagen de referencia", "buildScene.preparing": "Preparando la escena…", "buildScene.stillLoading": "La escena aún se está cargando…", "buildScene.expandChat": "Expandir chat", diff --git a/calliope-web/src/lib/i18n/fr.ts b/calliope-web/src/lib/i18n/fr.ts index 052a899e..d8d69eb7 100644 --- a/calliope-web/src/lib/i18n/fr.ts +++ b/calliope-web/src/lib/i18n/fr.ts @@ -603,6 +603,10 @@ export const fr: Dict = { // --- Build scene --- "buildScene.title": "Composer la scène", "buildScene.builderBadge": "Éditeur 3D", + "buildScene.suggestionTwoPerson": "Composition à deux personnages", + "buildScene.suggestionHeroPose": "Composition en pose héroïque", + "buildScene.suggestionBlockout": "Créer une pièce simple", + "buildScene.suggestionCapture": "Capturer une image de référence", "buildScene.preparing": "Préparation de la scène…", "buildScene.stillLoading": "Scène en cours de chargement…", "buildScene.expandChat": "Développer la discussion", diff --git a/calliope-web/src/lib/i18n/ja.ts b/calliope-web/src/lib/i18n/ja.ts index 3f8d491a..9d8ec46e 100644 --- a/calliope-web/src/lib/i18n/ja.ts +++ b/calliope-web/src/lib/i18n/ja.ts @@ -592,6 +592,10 @@ export const ja: Dict = { // --- Build scene --- "buildScene.title": "シーン構築", "buildScene.builderBadge": "3D ビルダー", + "buildScene.suggestionTwoPerson": "二人の会話の構図", + "buildScene.suggestionHeroPose": "ヒーローポーズの構図", + "buildScene.suggestionBlockout": "シンプルな部屋を作成", + "buildScene.suggestionCapture": "参考画像をキャプチャ", "buildScene.preparing": "シーンを準備中…", "buildScene.stillLoading": "シーンをまだ読み込み中…", "buildScene.expandChat": "チャットを展開", diff --git a/calliope-web/src/lib/i18n/ko.ts b/calliope-web/src/lib/i18n/ko.ts index f9157ee7..c8c0ede4 100644 --- a/calliope-web/src/lib/i18n/ko.ts +++ b/calliope-web/src/lib/i18n/ko.ts @@ -582,6 +582,10 @@ export const ko: Dict = { // --- Build scene --- "buildScene.title": "장면 구성", "buildScene.builderBadge": "3D 빌더", + "buildScene.suggestionTwoPerson": "두 사람 대화 구도", + "buildScene.suggestionHeroPose": "영웅 자세 구도", + "buildScene.suggestionBlockout": "간단한 방 만들기", + "buildScene.suggestionCapture": "참조 이미지 캡처", "buildScene.preparing": "장면 준비 중…", "buildScene.stillLoading": "장면을 아직 불러오는 중…", "buildScene.expandChat": "채팅 펼치기", diff --git a/calliope-web/src/lib/i18n/zh.ts b/calliope-web/src/lib/i18n/zh.ts index c6667006..929ca276 100644 --- a/calliope-web/src/lib/i18n/zh.ts +++ b/calliope-web/src/lib/i18n/zh.ts @@ -566,6 +566,10 @@ export const zh: Dict = { // --- Build scene --- "buildScene.title": "搭建场景", "buildScene.builderBadge": "3D 搭建器", + "buildScene.suggestionTwoPerson": "双人对话构图", + "buildScene.suggestionHeroPose": "英雄姿态构图", + "buildScene.suggestionBlockout": "搭建简单房间", + "buildScene.suggestionCapture": "截取参考画面", "buildScene.preparing": "正在准备场景……", "buildScene.stillLoading": "场景仍在加载……", "buildScene.expandChat": "展开对话", diff --git a/docs/changelogs/2026-10-23-hel810-scene-suggestions.md b/docs/changelogs/2026-10-23-hel810-scene-suggestions.md new file mode 100644 index 00000000..2ab04954 --- /dev/null +++ b/docs/changelogs/2026-10-23-hel810-scene-suggestions.md @@ -0,0 +1,20 @@ +## HEL-810 — translate scene suggestion buttons + +The four Build Scene welcome suggestions exposed internal translation keys. +Added the approved Chinese labels and matching English labels in the existing +dictionaries. The other five dictionaries also receive these four entries to +preserve their existing exhaustive `Dict` contract. No prompts, interactions, +layout, theme, dependencies, or scene data changed. + +Validation: frontend lint, Svelte/TypeScript check, existing npm guards, and build. +An isolated browser run at 1440×900 captured full-page before/after Chinese night +views and the English view; all four buttons still fill the original prompts, +and switching the existing language selector to English updates all four labels. +API responses were in-memory fixtures; mutations and external requests were +blocked. No backend, database, GPU, paid generation, or production service was +used. Browser and local Vite server closed in finally; evidence is attached to +HEL-810. No new test duplicates the dictionary implementation. + +Continues PR #12 from HEL-809 head b6ded3942b7f18a522343dff39a1e48bf924e177, +targeting leefer/hel-731-studio-project at +2952af42b6232483640f1f27803980367cd62dd0. No merge or deployment. -- 2.54.0 From fe4759b788ea82747e1085f94cfe749756b08fb3 Mon Sep 17 00:00:00 2001 From: leefer <2298888111@qq.com> Date: Fri, 23 Oct 2026 18:00:46 +0800 Subject: [PATCH 6/9] feat: implement approved restrained motion and state audit (HEL-813) Co-authored-by: multica-agent --- calliope-web/src/app.html | 19 +- calliope-web/src/lib/actions/spotlight.ts | 1 + .../lib/components/WorkflowTestDialog.svelte | 4 +- .../components/agent/AgentArtifactCard.svelte | 4 +- .../src/lib/components/agent/AgentChat.svelte | 9 +- .../components/agent/AgentPlanPanel.svelte | 8 +- .../agent/AgentSessionSidebar.svelte | 8 +- .../lib/components/agent/AgentToolCard.svelte | 4 +- .../components/agent/AgentWorkspace.svelte | 16 +- .../components/agent/ReasoningPanel.svelte | 8 +- .../src/lib/components/agent/workspace.css | 12 +- .../src/lib/components/desk/Artboard.svelte | 15 +- .../generate/ParameterPopover.svelte | 2 +- .../lib/components/generate/TakeCard.svelte | 4 +- .../src/lib/components/manage/management.css | 4 +- .../src/lib/components/shell/AppShell.svelte | 35 +-- .../lib/components/shell/MotionChoices.svelte | 14 ++ .../src/lib/components/ui/Drawer.svelte | 8 +- .../src/lib/components/ui/ProgressBar.svelte | 4 +- .../src/lib/components/ui/Skeleton.svelte | 4 +- .../src/lib/components/ui/Spinner.svelte | 4 +- .../src/lib/components/ui/StatusChip.svelte | 4 +- .../lib/components/workbench/JobChip.svelte | 4 +- .../src/lib/components/workbench/project.css | 6 +- calliope-web/src/lib/reduceMotion.svelte.ts | 107 +++++++-- .../shot/components/ShotSessionRail.svelte | 8 +- .../lib/shot/components/ShotViewport.svelte | 12 +- calliope-web/src/lib/storage.ts | 11 +- calliope-web/src/lib/theme.ts | 14 +- calliope-web/src/routes/app.css | 29 ++- calliope-web/src/routes/settings/+page.svelte | 22 +- calliope-web/static/assets/logo/logo.png | Bin 0 -> 600 bytes calliope-web/static/themes.css | 47 ++++ design/nocturne/design-spec-v3.md | 216 ++++++++++++++++++ .../2026-10-23-hel813-calm-motion.md | 28 +++ e2e/hel733-live.cjs | 2 +- e2e/hel813-fonts.cjs | 40 ++++ e2e/hel813-supplement.cjs | 25 ++ e2e/hel813.cjs | 106 +++++++++ 39 files changed, 736 insertions(+), 132 deletions(-) create mode 100644 calliope-web/src/lib/components/shell/MotionChoices.svelte create mode 100644 calliope-web/static/assets/logo/logo.png create mode 100644 design/nocturne/design-spec-v3.md create mode 100644 docs/changelogs/2026-10-23-hel813-calm-motion.md create mode 100644 e2e/hel813-fonts.cjs create mode 100644 e2e/hel813-supplement.cjs create mode 100644 e2e/hel813.cjs diff --git a/calliope-web/src/app.html b/calliope-web/src/app.html index 02629c38..0b484ee1 100644 --- a/calliope-web/src/app.html +++ b/calliope-web/src/app.html @@ -4,12 +4,29 @@ diff --git a/calliope-web/src/lib/actions/spotlight.ts b/calliope-web/src/lib/actions/spotlight.ts index 9bc377c2..6afb3035 100644 --- a/calliope-web/src/lib/actions/spotlight.ts +++ b/calliope-web/src/lib/actions/spotlight.ts @@ -8,6 +8,7 @@ export const spotlight: Action = (node) => { event.pointerType === "touch" ) return; + if (document.documentElement.dataset.motion === "calm") return; const rect = node.getBoundingClientRect(); node.style.setProperty("--pointer-x", `${event.clientX - rect.left}px`); node.style.setProperty("--pointer-y", `${event.clientY - rect.top}px`); diff --git a/calliope-web/src/lib/components/WorkflowTestDialog.svelte b/calliope-web/src/lib/components/WorkflowTestDialog.svelte index be9702c4..67382711 100644 --- a/calliope-web/src/lib/components/WorkflowTestDialog.svelte +++ b/calliope-web/src/lib/components/WorkflowTestDialog.svelte @@ -643,9 +643,9 @@ height: 7px; border-radius: 50%; background: var(--run); - animation: wft-breathe 1.6s ease-in-out infinite; + animation: var(--decorative-loop-name, motion-workflowtestdialog-wft-breathe) 1.6s ease-in-out infinite; } - @keyframes wft-breathe { + @keyframes -global-motion-workflowtestdialog-wft-breathe { 0%, 100% { opacity: 1; diff --git a/calliope-web/src/lib/components/agent/AgentArtifactCard.svelte b/calliope-web/src/lib/components/agent/AgentArtifactCard.svelte index 1139113b..248616c6 100644 --- a/calliope-web/src/lib/components/agent/AgentArtifactCard.svelte +++ b/calliope-web/src/lib/components/agent/AgentArtifactCard.svelte @@ -192,9 +192,9 @@ flex-shrink: 0; border-radius: 9999px; background: var(--warning); - animation: pulse 1.2s ease-in-out infinite; + animation: var(--decorative-loop-name, motion-agentartifactcard-pulse) 1.2s ease-in-out infinite; } - @keyframes pulse { + @keyframes -global-motion-agentartifactcard-pulse { 0%, 100% { opacity: 0.4; diff --git a/calliope-web/src/lib/components/agent/AgentChat.svelte b/calliope-web/src/lib/components/agent/AgentChat.svelte index 120e2c7f..c5285515 100644 --- a/calliope-web/src/lib/components/agent/AgentChat.svelte +++ b/calliope-web/src/lib/components/agent/AgentChat.svelte @@ -664,10 +664,10 @@ height: 14px; margin-left: 2px; background: var(--run); - animation: blink 1s step-end infinite; + animation: var(--decorative-loop-name, motion-agentchat-blink) 1s step-end infinite; vertical-align: text-bottom; } - @keyframes blink { + @keyframes -global-motion-agentchat-blink { 50% { opacity: 0; } @@ -681,13 +681,14 @@ } /* 进行中指示 = 信号青(§1.3),不占金色。 */ .pulse { + opacity: var(--thinking-opacity, 1); width: 8px; height: 8px; border-radius: 999px; background: var(--run); - animation: pulse 1.1s ease-in-out infinite; + animation: var(--decorative-loop-name, motion-agentchat-pulse) 1.1s ease-in-out infinite; } - @keyframes pulse { + @keyframes -global-motion-agentchat-pulse { 0%, 100% { opacity: 0.35; diff --git a/calliope-web/src/lib/components/agent/AgentPlanPanel.svelte b/calliope-web/src/lib/components/agent/AgentPlanPanel.svelte index 17845ea0..ca991dcb 100644 --- a/calliope-web/src/lib/components/agent/AgentPlanPanel.svelte +++ b/calliope-web/src/lib/components/agent/AgentPlanPanel.svelte @@ -84,9 +84,9 @@ color: var(--text-muted); } .icon-wrap.spinning { - animation: spin 1.5s linear infinite; + animation: var(--decorative-loop-name, motion-agentplanpanel-spin) 1.5s linear infinite; } - @keyframes spin { + @keyframes -global-motion-agentplanpanel-spin { to { transform: rotate(360deg); } @@ -155,9 +155,9 @@ width: 10px; height: 10px; border-radius: 9999px; - animation: pulse 1.1s ease-in-out infinite; + animation: var(--decorative-loop-name, motion-agentplanpanel-pulse) 1.1s ease-in-out infinite; } - @keyframes pulse { + @keyframes -global-motion-agentplanpanel-pulse { 0%, 100% { opacity: 0.35; diff --git a/calliope-web/src/lib/components/agent/AgentSessionSidebar.svelte b/calliope-web/src/lib/components/agent/AgentSessionSidebar.svelte index 5bdd4235..6b3ba519 100644 --- a/calliope-web/src/lib/components/agent/AgentSessionSidebar.svelte +++ b/calliope-web/src/lib/components/agent/AgentSessionSidebar.svelte @@ -283,9 +283,9 @@ border-color: var(--accent); } .rail-dot.run { - animation: rail-pulse 1.4s ease-in-out infinite; + animation: var(--decorative-loop-name, motion-agentsessionsidebar-rail-pulse) 1.4s ease-in-out infinite; } - @keyframes rail-pulse { + @keyframes -global-motion-agentsessionsidebar-rail-pulse { 0%, 100% { box-shadow: 0 0 0 0 var(--accent-glow); @@ -399,9 +399,9 @@ .dot.run { background: var(--warning); opacity: 1; - animation: pulse 1.1s ease-in-out infinite; + animation: var(--decorative-loop-name, motion-agentsessionsidebar-pulse) 1.1s ease-in-out infinite; } - @keyframes pulse { + @keyframes -global-motion-agentsessionsidebar-pulse { 0%, 100% { opacity: 0.35; diff --git a/calliope-web/src/lib/components/agent/AgentToolCard.svelte b/calliope-web/src/lib/components/agent/AgentToolCard.svelte index 6e605ea3..4c914d6a 100644 --- a/calliope-web/src/lib/components/agent/AgentToolCard.svelte +++ b/calliope-web/src/lib/components/agent/AgentToolCard.svelte @@ -106,9 +106,9 @@ } .dot.running { background: var(--warning); - animation: pulse 1.1s ease-in-out infinite; + animation: var(--decorative-loop-name, motion-agenttoolcard-pulse) 1.1s ease-in-out infinite; } - @keyframes pulse { + @keyframes -global-motion-agenttoolcard-pulse { 0%, 100% { opacity: 0.35; diff --git a/calliope-web/src/lib/components/agent/AgentWorkspace.svelte b/calliope-web/src/lib/components/agent/AgentWorkspace.svelte index 34ddc6ea..d29c7a4b 100644 --- a/calliope-web/src/lib/components/agent/AgentWorkspace.svelte +++ b/calliope-web/src/lib/components/agent/AgentWorkspace.svelte @@ -312,8 +312,20 @@ }); }); $effect(() => { - if (!particles || activeId || reduceMotion.manual || reduceMotion.system) + // Entering another task/home is the next legitimate loop trigger. + const canvas = particles; + void activeId; + if (!canvas) return; + delete canvas.dataset.motionRevision; + canvas.parentElement?.querySelectorAll('[data-motion-held]').forEach((node) => node.removeAttribute('data-motion-held')); + }); + $effect(() => { + const revision = reduceMotion.loopRevision; + if (!particles || activeId || reduceMotion.mode !== "full") return; + // Switching back to full does not start particles in the current scene. + if (particles.dataset.motionRevision && Number(particles.dataset.motionRevision) !== revision) return; + particles.dataset.motionRevision = String(revision); const canvas = particles, ctx = canvas.getContext("2d"); if (!ctx) return; @@ -336,7 +348,7 @@ ctx.clearRect(0, 0, canvas.width, canvas.height); if ( !activeId && - document.documentElement.dataset.reduce !== "1" && + document.documentElement.dataset.motion === "full" && !matchMedia("(prefers-reduced-motion: reduce)").matches ) { const color = diff --git a/calliope-web/src/lib/components/agent/ReasoningPanel.svelte b/calliope-web/src/lib/components/agent/ReasoningPanel.svelte index 4f48b780..81d094c3 100644 --- a/calliope-web/src/lib/components/agent/ReasoningPanel.svelte +++ b/calliope-web/src/lib/components/agent/ReasoningPanel.svelte @@ -76,9 +76,9 @@ color: var(--text-muted); } .icon-wrap.spinning { - animation: spin 1.5s linear infinite; + animation: var(--decorative-loop-name, motion-reasoningpanel-spin) 1.5s linear infinite; } - @keyframes spin { + @keyframes -global-motion-reasoningpanel-spin { to { transform: rotate(360deg); } @@ -118,10 +118,10 @@ height: 12px; margin-left: 1px; background: var(--accent); - animation: blink 1s step-end infinite; + animation: var(--decorative-loop-name, motion-reasoningpanel-blink) 1s step-end infinite; vertical-align: text-bottom; } - @keyframes blink { + @keyframes -global-motion-reasoningpanel-blink { 50% { opacity: 0; } diff --git a/calliope-web/src/lib/components/agent/workspace.css b/calliope-web/src/lib/components/agent/workspace.css index f6dcbd10..c514c06c 100644 --- a/calliope-web/src/lib/components/agent/workspace.css +++ b/calliope-web/src/lib/components/agent/workspace.css @@ -339,6 +339,7 @@ filter: blur(70px); opacity: var(--aur-op); animation: drift 18s var(--ease-io) infinite alternate; + animation-play-state: var(--loop-play-state); } .aurora i:nth-child(1) { width: 520px; @@ -353,7 +354,7 @@ right: 4%; top: 40px; background: var(--aur-2); - animation-delay: -6s; + animation-delay: var(--loop-delay, -6s); opacity: calc(var(--aur-op) * 0.55); } .aurora i:nth-child(3) { @@ -362,7 +363,7 @@ left: 38%; top: 260px; background: var(--aur-3); - animation-delay: -12s; + animation-delay: var(--loop-delay, -12s); opacity: calc(var(--aur-op) * 0.36); } @@ -534,6 +535,7 @@ background: var(--orb-core); box-shadow: var(--agent-orb-shadow); animation: breath 4.5s var(--ease-io) infinite; + animation-play-state: var(--loop-play-state); } .orb .ring { position: absolute; @@ -542,6 +544,7 @@ border-radius: 50%; border: 1px solid rgb(var(--ink) / 0.12); animation: spin 24s linear infinite; + animation-play-state: var(--loop-play-state); } .orb .ring::before { content: ""; @@ -823,7 +826,7 @@ border-color: var(--sc-agent); color: var(--sc-agent); box-shadow: 0 0 0 3px color-mix(in srgb, var(--sc-agent) 20%, transparent); - animation: breath 2s infinite; + animation: var(--stage-loop, breath) 2s infinite; } .pstep.done b { color: var(--tx-2); @@ -873,7 +876,8 @@ height: 6px; border-radius: 50%; background: var(--sc-agent); - animation: blink 1.2s infinite; + opacity: var(--thinking-opacity, 1); + animation: var(--thinking-loop, blink) 1.2s infinite; } .thinking i:nth-child(2) { animation-delay: 0.2s; diff --git a/calliope-web/src/lib/components/desk/Artboard.svelte b/calliope-web/src/lib/components/desk/Artboard.svelte index f4a1dfba..8fc5dff1 100644 --- a/calliope-web/src/lib/components/desk/Artboard.svelte +++ b/calliope-web/src/lib/components/desk/Artboard.svelte @@ -109,7 +109,10 @@ let arriving = $state(false); let previous: { id: number; status: string } | null = null; $effect(() => { - if (reduceMotion.manual || reduceMotion.system) arriving = false; + // A live preference/system change finishes an in-flight reveal. + void reduceMotion.mode; + void reduceMotion.loopRevision; + arriving = false; }); $effect(() => { const id = job?.id, @@ -117,11 +120,19 @@ if (!id || previous?.id !== id || status !== 'done') { arriving = false; } else if (previous.status === 'running' || previous.status === 'pending') { - arriving = !untrack(() => reduceMotion.manual || reduceMotion.system); + arriving = !untrack(() => reduceMotion.mode === 'off'); } // Repeated done snapshots must not erase the effect before the first paint. previous = id && status ? { id, status } : null; }); + $effect(() => { + if (!arriving) return; + const skip = () => (arriving = false); + // v3: any explicit pointer action may skip an arrival, including actions + // outside the media surface. The listener exists only during this reveal. + window.addEventListener('pointerdown', skip, true); + return () => window.removeEventListener('pointerdown', skip, true); + }); const st = $derived(job ? jobStatusInfo(job) : null); // §3.4 红/琥珀界线: amber only when the task SUCCEEDED but its files // cannot be fetched — the failed case stays red. diff --git a/calliope-web/src/lib/components/generate/ParameterPopover.svelte b/calliope-web/src/lib/components/generate/ParameterPopover.svelte index 80b1a99f..a51e84a3 100644 --- a/calliope-web/src/lib/components/generate/ParameterPopover.svelte +++ b/calliope-web/src/lib/components/generate/ParameterPopover.svelte @@ -145,7 +145,7 @@ @keyframes appear { from { opacity: 0; - transform: translateY(4px) scale(0.98); + transform: translateY(var(--parameter-pop-enter-y)) scale(var(--pop-enter-scale)); } to { opacity: 1; diff --git a/calliope-web/src/lib/components/generate/TakeCard.svelte b/calliope-web/src/lib/components/generate/TakeCard.svelte index 633c183a..a9dce2b2 100644 --- a/calliope-web/src/lib/components/generate/TakeCard.svelte +++ b/calliope-web/src/lib/components/generate/TakeCard.svelte @@ -606,9 +606,9 @@ position: absolute; inset: 0; background: linear-gradient(100deg, transparent 30%, var(--run-soft) 50%, transparent 70%); - animation: take-sheen 1.4s linear infinite; + animation: var(--decorative-loop-name, motion-takecard-take-sheen) 1.4s linear infinite; } - @keyframes take-sheen { + @keyframes -global-motion-takecard-take-sheen { from { transform: translateX(-100%); } diff --git a/calliope-web/src/lib/components/manage/management.css b/calliope-web/src/lib/components/manage/management.css index a12f1c9c..1be61dc8 100644 --- a/calliope-web/src/lib/components/manage/management.css +++ b/calliope-web/src/lib/components/manage/management.css @@ -40,7 +40,7 @@ --button-font-size: var(--manage-control-size); --button-radius: var(--manage-control-radius); --button-secondary-bg: var(--bg-3); - animation: management-enter var(--t-slow) var(--ease-out); + animation: management-enter var(--page-enter-duration) var(--ease-out); --button-sm-height: var(--manage-button-height); --button-sm-padding: var(--manage-button-padding); --button-sm-radius: var(--manage-button-radius); @@ -717,7 +717,7 @@ textarea.f-input { @keyframes management-enter { from { opacity: 0; - transform: translateY(6px); + transform: translateY(var(--page-enter-y)); } to { opacity: 1; diff --git a/calliope-web/src/lib/components/shell/AppShell.svelte b/calliope-web/src/lib/components/shell/AppShell.svelte index 6869aca5..52de9a3b 100644 --- a/calliope-web/src/lib/components/shell/AppShell.svelte +++ b/calliope-web/src/lib/components/shell/AppShell.svelte @@ -6,10 +6,10 @@ import { page } from "$app/state"; import LanguageSwitcher from "$lib/components/LanguageSwitcher.svelte"; import Icon from "$lib/components/ui/Icon.svelte"; - import Switch from "$lib/components/ui/Switch.svelte"; + import MotionChoices from "./MotionChoices.svelte"; import { settings, jobsApi, comfy } from "$lib/api"; import { t } from "$lib/i18n.svelte"; - import { reduceMotion, setReduceMotion } from "$lib/reduceMotion.svelte"; + import { reduceMotion } from "$lib/reduceMotion.svelte"; import CommandPalette from "./CommandPalette.svelte"; import ProjectScope from "./ProjectScope.svelte"; import QueueTray from "./QueueTray.svelte"; @@ -230,7 +230,7 @@ aria-label="查看生成任务" > - {#if genJobs.some((j) => j.status === "running") && !reduceMotion.manual && !reduceMotion.system} j.status === "running") && reduceMotion.mode !== "off"}{/if} @@ -324,14 +324,7 @@ >搜索Ctrl K - + {/if} @@ -343,11 +336,7 @@ {#snippet headerActions()}同一布局与功能,只换灯光{/snippet} {#snippet footer()} -
{$themeStorageUnavailable ? "浏览器禁止本地存储:本次有效,其他操作不受影响" : "已记住,下次打开沿用 · 正在编辑的内容不受影响"}
+
{$themeStorageUnavailable ? "浏览器禁止本地存储:本次有效,其他操作不受影响" : "已记住,下次打开沿用 · 正在编辑的内容不受影响"}
{/snippet} @@ -367,6 +356,7 @@ .theme-subtitle { font-size: var(--theme-pop-note-size); color: var(--tx-3); } .theme-foot-note { display: flex; align-items: center; gap: var(--theme-pop-footer-gap); } .theme-motion { + flex-wrap: wrap; --button-sm-height: var(--theme-pop-button-height); --button-sm-padding: var(--theme-pop-button-padding); --button-sm-font-size: var(--theme-pop-button-size); @@ -540,9 +530,9 @@ border-radius: var(--rail-queue-ring-radius); border: var(--rail-queue-ring-border) solid transparent; border-top-color: var(--sc-video); - animation: rail-spin var(--rail-queue-ring-duration) linear infinite; + animation: var(--queue-loop, rail-spin) var(--rail-queue-ring-duration) linear infinite; } - @keyframes rail-spin { + @keyframes -global-rail-spin { to { transform: rotate(360deg); } @@ -571,7 +561,7 @@ text-decoration: none; } .app-body { - animation: a-page-enter var(--t-slow) var(--ease-out); + animation: a-page-enter var(--page-enter-duration) var(--ease-out); display: flex; flex-direction: column; min-height: 0; @@ -622,13 +612,6 @@ color: var(--tx-3); margin-left: 8px; } - .motion { - display: flex; - align-items: center; - gap: 6px; - font-size: 10px; - color: var(--text-3); - } .body { flex: 1; min-height: 0; diff --git a/calliope-web/src/lib/components/shell/MotionChoices.svelte b/calliope-web/src/lib/components/shell/MotionChoices.svelte new file mode 100644 index 00000000..5f0e523a --- /dev/null +++ b/calliope-web/src/lib/components/shell/MotionChoices.svelte @@ -0,0 +1,14 @@ + + +
+ {#each MOTION_MODES as mode (mode.id)} + + {/each} + {#if reduceMotion.system}关闭 · 跟随系统{/if} + {#if reduceMotion.storageUnavailable}浏览器禁止本地存储:本次有效,其他操作不受影响{/if} +
diff --git a/calliope-web/src/lib/components/ui/Drawer.svelte b/calliope-web/src/lib/components/ui/Drawer.svelte index 1c694baf..8e61ac93 100644 --- a/calliope-web/src/lib/components/ui/Drawer.svelte +++ b/calliope-web/src/lib/components/ui/Drawer.svelte @@ -162,7 +162,7 @@ border-radius: var(--r-lg) 0 0 var(--r-lg); box-shadow: var(--shadow-float); outline: none; - animation: drawer-slide var(--d-drawer) var(--ease-drawer) both; + animation: var(--drawer-animation, drawer-slide) var(--d-drawer) var(--ease-drawer) both; } .drawer-header { display: flex; @@ -229,7 +229,11 @@ opacity: 1; } } - @keyframes drawer-slide { + @keyframes -global-drawer-calm { + from { opacity: 0; transform: translateY(var(--pop-enter-y)) scale(var(--pop-enter-scale)); } + to { opacity: 1; transform: none; } + } + @keyframes -global-drawer-slide { from { transform: translateX(100%); } diff --git a/calliope-web/src/lib/components/ui/ProgressBar.svelte b/calliope-web/src/lib/components/ui/ProgressBar.svelte index 48f64a51..aa03cae7 100644 --- a/calliope-web/src/lib/components/ui/ProgressBar.svelte +++ b/calliope-web/src/lib/components/ui/ProgressBar.svelte @@ -113,9 +113,9 @@ ); box-shadow: none; transition: none; - animation: progress-slide 1.2s ease-in-out infinite; + animation: var(--decorative-loop-name, motion-progressbar-progress-slide) 1.2s ease-in-out infinite; } - @keyframes progress-slide { + @keyframes -global-motion-progressbar-progress-slide { 0% { left: -40%; } diff --git a/calliope-web/src/lib/components/ui/Skeleton.svelte b/calliope-web/src/lib/components/ui/Skeleton.svelte index ce9c676b..81dd3307 100644 --- a/calliope-web/src/lib/components/ui/Skeleton.svelte +++ b/calliope-web/src/lib/components/ui/Skeleton.svelte @@ -23,12 +23,12 @@ .skeleton { background: var(--bg-3); border-radius: var(--r-sm); - animation: skeleton-breath 1.4s ease-in-out infinite; + animation: var(--decorative-loop-name, motion-skeleton-skeleton-breath) 1.4s ease-in-out infinite; } .skeleton.circle { border-radius: 50%; } - @keyframes skeleton-breath { + @keyframes -global-motion-skeleton-skeleton-breath { 50% { opacity: 0.5; } diff --git a/calliope-web/src/lib/components/ui/Spinner.svelte b/calliope-web/src/lib/components/ui/Spinner.svelte index 2d8a29bc..98c993a1 100644 --- a/calliope-web/src/lib/components/ui/Spinner.svelte +++ b/calliope-web/src/lib/components/ui/Spinner.svelte @@ -20,7 +20,7 @@ border-style: solid; border-color: var(--spinner-track, var(--bg-elevated)); border-top-color: var(--spinner-arc, var(--accent)); - animation: spin 0.7s linear infinite; + animation: var(--decorative-loop-name, motion-spinner-spin) 0.7s linear infinite; } .spinner-sm { width: 14px; @@ -48,7 +48,7 @@ white-space: nowrap; border: 0; } - @keyframes spin { + @keyframes -global-motion-spinner-spin { to { transform: rotate(360deg); } diff --git a/calliope-web/src/lib/components/ui/StatusChip.svelte b/calliope-web/src/lib/components/ui/StatusChip.svelte index 2eb45d2c..f1a901f4 100644 --- a/calliope-web/src/lib/components/ui/StatusChip.svelte +++ b/calliope-web/src/lib/components/ui/StatusChip.svelte @@ -76,9 +76,9 @@ background: var(--bg-4); } .dot.pulsing { - animation: chip-pulse 1.4s ease-in-out infinite; + animation: var(--decorative-loop-name, motion-statuschip-chip-pulse) 1.4s ease-in-out infinite; } - @keyframes chip-pulse { + @keyframes -global-motion-statuschip-chip-pulse { 0%, 100% { opacity: 1; diff --git a/calliope-web/src/lib/components/workbench/JobChip.svelte b/calliope-web/src/lib/components/workbench/JobChip.svelte index 50836e80..62b5d230 100644 --- a/calliope-web/src/lib/components/workbench/JobChip.svelte +++ b/calliope-web/src/lib/components/workbench/JobChip.svelte @@ -84,9 +84,9 @@ height: 6px; border-radius: 50%; background: var(--run); - animation: wb-chip-breath 2s cubic-bezier(0.16, 1, 0.3, 1) infinite; + animation: var(--decorative-loop-name, motion-jobchip-wb-chip-breath) 2s cubic-bezier(0.16, 1, 0.3, 1) infinite; } - @keyframes wb-chip-breath { + @keyframes -global-motion-jobchip-wb-chip-breath { 0%, 100% { opacity: 0.35; diff --git a/calliope-web/src/lib/components/workbench/project.css b/calliope-web/src/lib/components/workbench/project.css index 34aa2c38..361f7c31 100644 --- a/calliope-web/src/lib/components/workbench/project.css +++ b/calliope-web/src/lib/components/workbench/project.css @@ -408,11 +408,15 @@ border-color: color-mix(in srgb, var(--gold) 50%, transparent); color: var(--gold); } +.project-workspace [data-state="running"] { + --project-stage-shadow: var(--running-stage-shadow); +} +.project-workspace [data-state="running"] .avatar, .project-workspace [data-state="draft"] .avatar, .project-workspace [data-state="edited"] .avatar { border-color: var(--sc-agent); color: var(--sc-agent); - box-shadow: var(--project-draft-shadow); + box-shadow: var(--project-stage-shadow, var(--project-draft-shadow)); } .project-workspace .step-btn.active::after { content: ""; diff --git a/calliope-web/src/lib/reduceMotion.svelte.ts b/calliope-web/src/lib/reduceMotion.svelte.ts index cbab48d8..65181c46 100644 --- a/calliope-web/src/lib/reduceMotion.svelte.ts +++ b/calliope-web/src/lib/reduceMotion.svelte.ts @@ -1,33 +1,90 @@ -import { storageGet, storageSet } from "./storage"; +import { storageGet, storageTrySet } from "./storage"; -/** - * 减少动态效果 (HEL-649 §五). The OS setting always wins (app.css also reacts - * to `prefers-reduced-motion` directly, so it cannot be switched off here); - * otherwise the top-bar switch decides. The effective value is mirrored to - * ``, which is what every reduced-motion rule keys on. - */ -const STORAGE_KEY = "aigc-reduce-motion"; +export type MotionMode = "full" | "calm" | "off"; +export const MOTION_MODES = [ + { id: "full", label: "完整" }, + { id: "calm", label: "克制" }, + { id: "off", label: "关闭" }, +] as const; +const STORAGE_KEY = "aigc-motion"; const QUERY = "(prefers-reduced-motion: reduce)"; - -export const reduceMotion = $state({ system: false, manual: false }); - +export const reduceMotion = $state({ + system: false, + manual: false, + preference: "full" as MotionMode, + mode: "full" as MotionMode, + loopRevision: 0, + storageUnavailable: false, +}); let stop: (() => void) | null = null; +const isMode = (value: string | null): value is MotionMode => + value === "full" || value === "calm" || value === "off"; -function apply() { - document.documentElement.dataset.reduce = - reduceMotion.system || reduceMotion.manual ? "1" : "0"; +function apply(initial = false) { + const root = document.documentElement; + const mode = reduceMotion.system ? "off" : reduceMotion.preference; + if (!initial && mode !== reduceMotion.mode) { + // Returning to full must wait for the next page/task trigger. New nodes + // have no marker; mounted loops stay at their static initial appearance. + root + .querySelectorAll( + ".aurora i, .orb .core, .orb .ring, .thinking i, .pstep.run .s, .queue-ring, .a-scan-beam, .a-tile-ring > i", + ) + .forEach((node) => node.setAttribute("data-motion-held", "")); + for (const animation of document.getAnimations()) { + if (animation.effect?.getTiming().iterations === Infinity) { + const target = (animation.effect as KeyframeEffect).target; + if (target instanceof Element) + target.setAttribute("data-motion-held", ""); + } else { + try { + animation.finish(); + } catch { + animation.cancel(); + } + } + } + reduceMotion.loopRevision++; + } + reduceMotion.mode = mode; + reduceMotion.manual = reduceMotion.preference === "off"; + root.dataset.motion = mode; + root.dataset.reduce = mode === "off" ? "1" : "0"; + root.classList.toggle("calm", mode === "calm"); + root.classList.toggle("reduce", mode === "off"); + if (!initial && mode === "full") { + // Loops suppressed by calm/off have no Animation until the tokens change. + // Hold those too, in the same task and before the next paint. + for (const animation of document.getAnimations()) { + if (animation.effect?.getTiming().iterations !== Infinity) continue; + const target = (animation.effect as KeyframeEffect).target; + if (target instanceof Element) + target.setAttribute("data-motion-held", ""); + animation.cancel(); + } + } + if (!initial) window.dispatchEvent(new Event("aigc-motion-change")); } -/** Call once on the client (root layout). Returns the teardown. */ +/** Root-layout lifecycle; OS override never overwrites the saved preference. */ export function initReduceMotion(): () => void { if (typeof window === "undefined") return () => {}; stop?.(); const mq = window.matchMedia(QUERY); + const saved = storageGet(STORAGE_KEY); + const legacy = storageGet("aigc-reduce-motion"); + reduceMotion.preference = + saved === null + ? legacy === "1" || legacy === "true" + ? "off" + : "full" + : isMode(saved) + ? saved + : "full"; reduceMotion.system = mq.matches; - reduceMotion.manual = storageGet(STORAGE_KEY) === "1"; - apply(); - const onChange = (e: MediaQueryListEvent) => { - reduceMotion.system = e.matches; + apply(true); + const onChange = (event: MediaQueryListEvent) => { + reduceMotion.system = event.matches; apply(); }; mq.addEventListener("change", onChange); @@ -38,8 +95,14 @@ export function initReduceMotion(): () => void { }; } -export function setReduceMotion(on: boolean) { - reduceMotion.manual = on; - storageSet(STORAGE_KEY, on ? "1" : "0"); +export function setMotionMode(mode: MotionMode) { + if (reduceMotion.system || !isMode(mode)) return; + reduceMotion.preference = mode; + reduceMotion.storageUnavailable = !storageTrySet(STORAGE_KEY, mode); apply(); } + +/** Compatibility for existing boolean controls. */ +export function setReduceMotion(on: boolean) { + setMotionMode(on ? "off" : "full"); +} diff --git a/calliope-web/src/lib/shot/components/ShotSessionRail.svelte b/calliope-web/src/lib/shot/components/ShotSessionRail.svelte index 6f635fe9..b223e409 100644 --- a/calliope-web/src/lib/shot/components/ShotSessionRail.svelte +++ b/calliope-web/src/lib/shot/components/ShotSessionRail.svelte @@ -200,9 +200,9 @@ border-color: var(--accent); } .rail-dot.run { - animation: rail-pulse 1.4s ease-in-out infinite; + animation: var(--decorative-loop-name, motion-shotsessionrail-rail-pulse) 1.4s ease-in-out infinite; } - @keyframes rail-pulse { + @keyframes -global-motion-shotsessionrail-rail-pulse { 0%, 100% { box-shadow: 0 0 0 0 var(--accent-glow); @@ -266,9 +266,9 @@ } .dot.run { background: var(--run); - animation: dot-pulse 1.4s ease-in-out infinite; + animation: var(--decorative-loop-name, motion-shotsessionrail-dot-pulse) 1.4s ease-in-out infinite; } - @keyframes dot-pulse { + @keyframes -global-motion-shotsessionrail-dot-pulse { 0%, 100% { opacity: 1; diff --git a/calliope-web/src/lib/shot/components/ShotViewport.svelte b/calliope-web/src/lib/shot/components/ShotViewport.svelte index 778a19af..bef181ee 100644 --- a/calliope-web/src/lib/shot/components/ShotViewport.svelte +++ b/calliope-web/src/lib/shot/components/ShotViewport.svelte @@ -42,6 +42,7 @@ let orbit: OrbitControls; let gizmo: TransformControls; let gizmoHelper: THREE.Object3D; let raf = 0; +let cleanupVisibility: (() => void) | null = null; let resizeObserver: ResizeObserver; let cleanupPointerUp: (() => void) | null = null; @@ -473,6 +474,7 @@ let exporting = false; // ── loop ──────────────────────────────────────────────────────── function tick() { + if (document.hidden) { raf = 0; return; } raf = requestAnimationFrame(tick); if (exporting) return; // export drives the scene on its own fixed clock const now = performance.now() / 1000; @@ -635,10 +637,18 @@ let exporting = false; }); resizeObserver.observe(container); - raf = requestAnimationFrame(tick); + const visibility = () => { + cancelAnimationFrame(raf); + raf = 0; + if (!document.hidden) tick(); + }; + document.addEventListener('visibilitychange', visibility); + cleanupVisibility = () => document.removeEventListener('visibilitychange', visibility); + visibility(); }); onDestroy(() => { + cleanupVisibility?.(); cancelAnimationFrame(raf); resizeObserver?.disconnect(); cleanupPointerUp?.(); diff --git a/calliope-web/src/lib/storage.ts b/calliope-web/src/lib/storage.ts index 31689ea0..e0279c08 100644 --- a/calliope-web/src/lib/storage.ts +++ b/calliope-web/src/lib/storage.ts @@ -29,10 +29,19 @@ export function storageGet(key: string): string | null { } export function storageSet(key: string, value: string): void { + storageTrySet(key, value); +} + +/** Also reports failure when a preference UI needs to explain persistence. */ +export function storageTrySet(key: string, value: string): boolean { try { - webStorage()?.setItem(key, value); + const storage = webStorage(); + if (!storage) return false; + storage.setItem(key, value); + return true; } catch { // Private mode / quota exceeded — persistence is best-effort. + return false; } } diff --git a/calliope-web/src/lib/theme.ts b/calliope-web/src/lib/theme.ts index de094c4e..f13aeecc 100644 --- a/calliope-web/src/lib/theme.ts +++ b/calliope-web/src/lib/theme.ts @@ -76,7 +76,7 @@ export function setTheme(id: ThemeId, event?: MouseEvent) { apply(); return; } - if (document.startViewTransition) { + if (document.startViewTransition && root.dataset.motion !== "calm") { if (event && event.detail !== 0) { root.style.setProperty("--theme-x", event.clientX + "px"); root.style.setProperty("--theme-y", event.clientY + "px"); @@ -108,6 +108,16 @@ export function initTheme() { document.documentElement.dataset.theme = id; theme.set(id); }; + const finishMotion = () => { + transition?.skipTransition(); + clearTimeout(fallbackTimer); + document.documentElement.classList.remove("theme-anim"); + }; + window.addEventListener("aigc-motion-change", finishMotion); window.addEventListener("storage", sync); - return () => window.removeEventListener("storage", sync); + return () => { + finishMotion(); + window.removeEventListener("aigc-motion-change", finishMotion); + window.removeEventListener("storage", sync); + }; } diff --git a/calliope-web/src/routes/app.css b/calliope-web/src/routes/app.css index 985bdecb..dcd4355b 100644 --- a/calliope-web/src/routes/app.css +++ b/calliope-web/src/routes/app.css @@ -583,7 +583,7 @@ button.a-tile:disabled { background: conic-gradient(var(--accent) 0.3turn, var(--line-2) 0); -webkit-mask: radial-gradient(circle, transparent 9.5px, #000 10.5px); mask: radial-gradient(circle, transparent 9.5px, #000 10.5px); - animation: a-tile-spin 900ms linear infinite; + animation: var(--tile-loop, a-tile-spin) 900ms linear infinite; } @keyframes a-tile-spin { to { @@ -1020,15 +1020,16 @@ select:focus-visible { z-index: -1; background-image: radial-gradient( - var(--lattice-light-radius) circle at var(--pointer-x, 50%) - var(--pointer-y, 50%), + var(--lattice-light-radius) circle at + var(--lattice-x, var(--pointer-x, 50%)) + var(--lattice-y, var(--pointer-y, 50%)), color-mix(in srgb, var(--scene) var(--lattice-scene), transparent), transparent 70% ), var(--lattice); mask-image: radial-gradient( - var(--lattice-mask-radius) circle at var(--pointer-x, 50%) - var(--pointer-y, 50%), + var(--lattice-mask-radius) circle at var(--lattice-x, var(--pointer-x, 50%)) + var(--lattice-y, var(--pointer-y, 50%)), #000 0, transparent var(--lattice-mask-end) ); @@ -1075,6 +1076,7 @@ select:focus-visible { } } .a-btn.primary::after { + display: var(--effect-display); content: ""; position: absolute; top: 0; @@ -1094,6 +1096,20 @@ select:focus-visible { .a-result-develop > video { animation: var(--result-develop) var(--t-reveal) var(--ease-out); } +@keyframes a-develop-calm { + from { + filter: var(--result-calm-filter); + opacity: var(--result-calm-opacity); + } + to { + filter: none; + opacity: 1; + } +} +/* Finishing a one-shot or changing mode must not restart a mounted loop. */ +[data-motion-held] { + animation: none !important; +} @keyframes a-develop { 0% { filter: blur(18px) brightness(1.8) saturate(0); @@ -1135,6 +1151,7 @@ select:focus-visible { } } .a-scan-beam { + display: var(--effect-display); position: absolute; top: 0; bottom: 0; @@ -1169,7 +1186,7 @@ select:focus-visible { opacity: 0; transition: opacity var(--t-base), - inset var(--t-slow) var(--ease-out); + inset var(--viewfinder-position-duration, var(--t-slow)) var(--ease-out); background: linear-gradient(var(--viewfinder-color), var(--viewfinder-color)) top left/var(--viewfinder-length) var(--viewfinder-stroke) no-repeat, diff --git a/calliope-web/src/routes/settings/+page.svelte b/calliope-web/src/routes/settings/+page.svelte index 80cb7670..a639a706 100644 --- a/calliope-web/src/routes/settings/+page.svelte +++ b/calliope-web/src/routes/settings/+page.svelte @@ -8,7 +8,7 @@ } from "@tanstack/svelte-query"; import ManagementPage from "$lib/components/manage/ManagementPage.svelte"; import LanguageSwitcher from "$lib/components/LanguageSwitcher.svelte"; - import { reduceMotion, setReduceMotion } from "$lib/reduceMotion.svelte"; + import MotionChoices from "$lib/components/shell/MotionChoices.svelte"; import ThemeChoices from "$lib/components/shell/ThemeChoices.svelte"; import MemoryPanel from "$lib/components/settings/MemoryPanel.svelte"; import Button from "$lib/components/ui/Button.svelte"; @@ -431,25 +431,7 @@

动效强度

-
- -
+

系统开启“减少动态效果”时自动关闭;主题切换保留当前编辑内容。

diff --git a/calliope-web/static/assets/logo/logo.png b/calliope-web/static/assets/logo/logo.png new file mode 100644 index 0000000000000000000000000000000000000000..3f9bef055a27b5f1ebf961d907be517d3849d6e5 GIT binary patch literal 600 zcmV-e0;m0nP)>;_l>OH)u0P%(fgK~V`R1`H@k-2h2MkqkyeMS{AAo}P$29)Gl|TZF=y zp1%J;pRW3z`<(OqUy*>{#*e4iOGNYg`TYs@qx_41dyFUuSiTQYl3V$;UPMuL1aapYDK}WEO9OnWPtQftjQ~-UKsAFSLW18Q=z(N&3TeFq72#Ybd1Rq5}o} zzZswnHf-tu#n3P&91)9PbKr^2i;4(CYGk{(`NzOMxQz!!{=9A>yC1?qwyf=bUMl--gXbPvi zUOq|AHbPS<11RQ`#7qM;g)x9;J~Kc)G=(#OUOq`o*Fjre<(0EZV(J2H2vpueE;GP+ z*pgd$JDDUgSqq!8DsM55#3#<7kTwe!^8aRl8ZeXicr}LF^?#d;9`*a-843)~cBR0000 授权记录:HEL-801 确认线程 `01a167c6-b1dd-7a15-b304-c6d1dbd50dca`,2026-10-23 用户“1-8项按建议”。下文待确认字样保留原始材料,施工按该次批准解释:四主题350ms滤镜推荐方案、关闭档静态阶段微调、首版不自动降级;200ms纯淡入和硬件降级备选未选。 + +# 夜片场 · 动效三档规范 v3(原始美术师材料存档) + +> 美术师出具,补 HEL-733 报告的规范缺口。v1 §6 与 v2 §4 继续有效;本文只定义「克制」档行为和三档优先规则。**凡本文新增、原样本与 v1/v2 中没有依据的规则,一律标【待确认】,经白栖知看样确认后才授权施工。** + +## 0. 依据核对(哪些已有依据,哪些是本次新建议) + +已有依据,可直接施工,不需再请批: + +- 原 HTML 1137 行只有「完整 / 克制 / 关闭(减少动效)」三个选项和一句“系统开启减少动态效果时自动切到关闭”;1532 行 `motionBtn` 与 1605 行系统检测都只切换 `html.reduce`。**原样本没有克制档的任何行为定义。** +- v1 §6(样本「规范 → 06 动效」表)定义了完整档各效果的触发/时长/曲线/可打断,以及“减少动效时”的行为;v2 §4 沿用 v1 并补充主题切换 520/280ms。**v1/v2 同样没有克制档行为表。** +- 关闭档行为已有依据:`html.reduce` 全局 `animation-duration:1ms; animation-iteration-count:1; transition-duration:1ms`,`.fx-only`(队列环、扫描光、极光、粒子、光球、思考点)`display:none`,主题切换立即替换,显影淡入 1ms,蜂窝仅背景变亮,取景框保留角标无位移。 + +本次新建议(全部【待确认】):克制档每一项行为、三档优先与偏好恢复规则、动态切换规则、低性能降级策略。下列表格中「克制」列即新建议,施工员不得自行乘系数或类推。 + +## 1. 三档定义与优先级 + +| 档 | 类名 | 含义 | +|---|---|---| +| 完整 | (默认,无类) | 现有全部动效,v1 §6 + v2 §4 已批准 | +| 克制 | `html.calm` | 【待确认】保留状态反馈与方向感:一次性过渡缩短减幅,装饰性循环改静态标识,真实数值反馈(进度条)不动 | +| 关闭 | `html.reduce` | 既有行为:去掉光束、粒子与位移,只保留颜色和状态变化 | + +优先级(【待确认】规则 1–5): + +1. **系统优先**:`prefers-reduced-motion: reduce` 生效时强制关闭档,覆盖用户任何选择;界面上三档开关显示「关闭」并标注“跟随系统”。 +2. **偏好记忆与恢复**:用户选择存 `localStorage['aigc-motion']`(`full`/`calm`/`off`,非法值回退 `full`)。系统减少动效只覆盖表现、**不改写已存偏好**;用户关闭系统设置后,恢复其先前选择的档位。 +3. **动态切换即时停止**:运行中切到克制/关闭,所有循环立即停止(粒子取消 rAF 并清空画布、CSS 循环类移除),进行中的入场/显影/弹层动画跳到终态;切回完整时,循环从下一个触发事件起恢复,不向进行中的画面中途注入循环。 +4. **后台与卸载**:标签页隐藏(`visibilitychange`)时粒子 rAF 停止(既有行为,扩展到一切未来 rAF 循环);CSS 循环依赖浏览器自动节流,不另做计时器。`pagehide`/卸载不做除既有偏好持久化外的任何操作。 +5. **低性能降级**:首版不做自动降级——三档 + 系统档已覆盖,自动跳档会造成状态不一致。若总工评估后认为需要,备选方案是仅在首次访问、用户从未选择过时,按 `hardwareConcurrency ≤ 4` 或 `deviceMemory ≤ 4` 默认建议克制档并一次性提示,绝不覆盖已有选择。**此备选技术可行性待总工确认后再定。** + +## 2. 克制档循环保留总决策【待确认】 + +| 循环 | 克制档处置 | 理由 | +|---|---|---| +| 扫描光束(生成中) | 停止扫动,保留生成底色 + 进度条 | 进度条是真实数值反馈;光束是装饰 | +| 队列环(侧栏队列点) | 停止旋转,保留静态弧形描边 | 静态弧仍有“进行中”含义 | +| 智能体极光 | 暂停漂移,保留静态氛围色块 | 极光是页面气质底色,静态不丢氛围 | +| 46 粒子(canvas) | 停止 rAF 并清空 | 静态粒子无意义且 canvas 常驻成本高 | +| 光球呼吸 + 双环旋转 | 停止呼吸与旋转,保留静态光球与环 | 光球本体是视觉锚点 | +| 思考点(三点闪烁) | 停止闪烁,保留静态三点 + 文字 | 文字本身已传达状态 | +| 运行阶段呼吸(项目步骤) | 停止呼吸,保留静态高亮描边与底色 | 当前步骤必须可辨识 | +| 视频播放 pan | 不变 | 只在用户手动播放时运行,非装饰循环 | + +## 3. 逐类行为表 + +「完整」列是已批准的既有行为(原样本实测值);「克制」列全部为【待确认】新建议;「关闭」列为既有 `html.reduce` 行为。 + +### 3.1 扫描光束(生成中) + +| 项 | 完整(既有) | 克制【待确认】 | 关闭(既有) | +|---|---|---|---| +| 触发 | 任务进入 running | 同左 | 同左 | +| 循环与停止 | 1.6s `ease-io` 无限循环;任务停止/取消即卸载 | 不循环:光束层不渲染,仅生成底色 `--gen-bg` + 颗粒保留 | 光束隐藏(`.fx-only`) | +| 动画属性及幅度 | `left: -45% → 100%`,宽 45% | 无位移动画 | 无 | +| 时长曲线 | 1.6s · `--ease-io` | — | — | +| 可否打断 | 取消即停 | 无循环可打断 | — | +| 终态 | 任务结束卸载生成层 | 同左 | 同左 | +| 配套保留 | — | 进度条 `width .25s linear` 照常(真实数值,三档均不动) | 同左 | + +### 3.2 队列环(侧栏队列点) + +| 项 | 完整(既有) | 克制【待确认】 | 关闭(既有) | +|---|---|---|---| +| 触发 | 队列有进行中任务 | 同左 | 同左 | +| 循环与停止 | `spin` 1.6s linear 无限 | 不旋转,静态显示 1.5px 弧形描边(border-top-color 场景色) | 隐藏(`.fx-only`),仅保留计数数字 | +| 动画属性及幅度 | `rotate 0→360deg` | 无 | 无 | +| 可否打断 | 队列清空即移除 | 同左 | 同左 | +| 终态 | 无进行中任务时移除 | 同左 | 同左 | + +### 3.3 智能体极光 + +| 项 | 完整(既有) | 克制【待确认】 | 关闭(既有) | +|---|---|---|---| +| 触发 | 进入智能体首页/任务页 | 同左 | 进入页面 | +| 循环与停止 | `drift` 18s `ease-io` 无限交替;离开页面即停 | 暂停漂移(`animation-play-state:paused` 于初始帧),保留三色静态色块与 `blur(70px)`、浓度 `--aur-op` 不变 | 隐藏(`.fx-only`) | +| 动画属性及幅度 | `translate(60px,40px) scale(1.12)` | 无位移 | 无 | +| 可否打断 | 离开页面即停 | 同左 | — | +| 终态 | 离页卸载 | 同左 | 同左 | + +### 3.4 粒子(46 粒 canvas) + +| 项 | 完整(既有) | 克制【待确认】 | 关闭(既有) | +|---|---|---|---| +| 触发 | 进入智能体页 `startParticles()` | 不启动 | 不启动 | +| 循环与停止 | rAF 循环;离页/隐藏标签页/减少动效时停止并清空 | 不启动 rAF,canvas 保持空 | 不启动(既有判断) | +| 动画属性及幅度 | 上升 `.15–.5 px/f`、正弦横漂 `.2`、半径 `.4–2`、透明度 `.15–.65` | 无 | 无 | +| 可否打断 | 离页即停 | — | — | +| 终态 | 清空画布 | 始终为空 | 同左 | + +### 3.5 光球呼吸与双环 + +| 项 | 完整(既有) | 克制【待确认】 | 关闭(既有) | +|---|---|---|---| +| 触发 | 进入智能体首页 hero | 同左 | 隐藏 | +| 循环与停止 | 核心 `breath` 4.5s `ease-io` 无限;环 `spin` 24s、外环 40s 反向虚线 | 全部暂停:核心保留 `--orb-core` 渐变与基础光晕(`0 0 80px`),双环静态 | 整体隐藏(`.fx-only`) | +| 动画属性及幅度 | `scale 1→1.05`,光晕 80→120px | 无 | 无 | +| 可否打断 | 离页即停 | — | — | + +### 3.6 思考点 + +| 项 | 完整(既有) | 克制【待确认】 | 关闭(既有) | +|---|---|---|---| +| 触发 | 智能体任务进行中 | 同左 | 隐藏 | +| 循环与停止 | `blink` 1.2s 无限,三点依次延迟 0/.2/.4s | 静态三点(透明度 .65 固定),说明文字照常 | 隐藏(`.fx-only`),文字保留 | +| 动画属性及幅度 | `opacity→.2`、`translateY(-2px)` | 无 | 无 | + +### 3.7 运行阶段呼吸(项目步骤 `.pstep.run`) + +| 项 | 完整(既有) | 克制【待确认】 | 关闭(既有) | +|---|---|---|---| +| 触发 | 项目阶段进入 running | 同左 | 同左 | +| 循环与停止 | `breath` 2s 无限(光晕脉冲) | 不脉冲;保留场景色描边 + 静态 `0 0 0 3px 20%` 光晕 + 场景色图标 | 全局 1ms 规则使脉冲仅一闪;建议克制同款静态样式应用于关闭档以保持可辨识【待确认,属对关闭档的微调建议】 | +| 可否打断 | 阶段结束即换样式 | 同左 | 同左 | + +### 3.8 页面进入 + +| 项 | 完整(既有) | 克制【待确认】 | 关闭(既有) | +|---|---|---|---| +| 触发 | 切换一级页 | 同左 | 同左 | +| 动画属性及幅度 | `opacity 0→1` + `translateY(6px)→0` | `opacity 0→1` + `translateY(2px)→0`(幅度减到 2px,不乘时长系数之外的属性) | 直接出现(1ms) | +| 时长曲线 | 320ms · `--ease-out` | 200ms · `--ease-out` | 1ms | +| 可否打断 | 是(再次切换接管) | 是 | — | +| 终态 | 移除动画、稳定布局 | 同左 | 同左 | + +### 3.9 弹层(参数弹层 / 主题弹层 / 灯箱 / 底部 sheet) + +| 项 | 完整(既有) | 克制【待确认】 | 关闭(既有) | +|---|---|---|---| +| 触发 | 打开弹层 | 同左 | 同左 | +| 动画属性及幅度 | `opacity` + `translateY(6px)` + `scale(.98)` | `opacity` + `translateY(2px)` + `scale(.995)` | 直接出现 | +| 时长曲线 | 200ms · `--ease-out` | 150ms · `--ease-out` | 1ms | +| 可否打断 | Esc / 点外部立即关 | 同左 | 同左 | +| 终态 | 稳定显示,焦点圈定不变 | 同左 | 同左 | + +### 3.10 主题切换「开灯」 + +| 项 | 完整(既有) | 克制【待确认】 | 关闭(既有) | +|---|---|---|---| +| 触发 | 选择新主题 | 同左 | 同左 | +| 形式 | View Transitions 圆形展开;不支持时 280ms 颜色淡入淡出 | 不用圆形展开:统一走 280ms 颜色淡入淡出(`html.theme-anim`,340ms 后移除类) | 立即替换 | +| 时长曲线 | 520ms · `--ease-out`(回退 280ms · `ease-io`) | 280ms · `--ease-io` | 0ms | +| 可否打断 | 是,再次选择接管 | 是 | — | +| 终态 | `data-theme` 已换、内容不动 | 同左 | 同左 | + +### 3.11 显影(结果到达)——四主题分别规定,不乘统一系数 + +触发均为:仅当前同一任务从 pending/running 到 done;历史与主题切换不重播;点击任意处跳过;完成后移除类。三档通用。 + +| 主题 | 完整(既有,700ms · `--ease-out`) | 克制【待确认】 | 关闭(既有) | +|---|---|---|---| +| 夜片场 night | `develop`:blur(18px) brightness(1.8) saturate(0) opacity .2 scale(1.015) → 60% blur(2px) → 清晰 | 保留模糊显影个性但去掉过曝与缩放:blur(10px) opacity .4 → 清晰,350ms · `--ease-out` | 淡入 1ms | +| 蓝调时刻 bluehour | 同 night | 同 night 的克制规则(两主题完整档本就共用 `develop`) | 淡入 1ms | +| 日光棚 day | `developDay`:blur(16px) brightness(1.35) sepia(.6) saturate(.2) → 清晰(相纸显影) | 保留棕褐相纸感、减幅度:blur(8px) sepia(.25) opacity .4 → 清晰,350ms | 淡入 1ms | +| 调色间 grade | `developGrade`:blur(10px) saturate(0) opacity .25 → 清晰 | 保留去饱和个性:blur(5px) saturate(.2) opacity .5 → 清晰,350ms | 淡入 1ms | + +备选方案(供用户二选一,默认推荐上行方案):克制档四主题统一改为 200ms 纯淡入,不做滤镜。推荐保留各主题滤镜个性的方案,否则克制档下四主题显影无差别,违背“同一个片场换一种灯光”的主题原则。 + +### 3.12 主按钮扫光 + +| 项 | 完整(既有) | 克制【待确认】 | 关闭(既有) | +|---|---|---|---| +| 触发 | 悬停主操作 | 悬停主操作 | — | +| 动画属性及幅度 | 40% 宽高光 `left -60%→130%`、`skewX(-20deg)` | 无扫光;保留既有背景/阴影 hover 变化(`--t-fast/--t-base` 颜色过渡不动) | 无扫光(1ms 等同无) | +| 时长曲线 | 700ms · `--ease-out`,一次 | — | — | +| 可否打断 | 是(移出即复位) | — | — | +| 说明 | disabled/aria-disabled 不扫光,三档通用 | 同左 | 同左 | + +### 3.13 蜂窝 / 光栅悬停(`.lg`) + +| 项 | 完整(既有) | 克制【待确认】 | 关闭(既有) | +|---|---|---|---| +| 触发 | 指针进入列表/卡片 | 同左 | 同左 | +| 行为 | 160px 径向场景色光 + 140px mask 蜂窝纹,跟随指针 `--mx/--my` | 不跟随指针:进入时蜂窝光斑静止于卡片中心渐显,离开渐隐 | 仅背景变亮,无蜂窝纹 | +| 时长曲线 | 320ms 渐显 · `--ease-out` | 320ms 渐显渐隐 · `--ease-out`(不变) | 1ms | +| 可否打断 | 是 | 是 | — | + +### 3.14 取景框(`.vf` 角标) + +| 项 | 完整(既有) | 克制【待确认】 | 关闭(既有) | +|---|---|---|---| +| 触发 | 选中工作流/作品 | 同左 | 同左 | +| 动画属性及幅度 | 角标透明度 0→1 + inset −1→−3(外扩 2px) | 角标透明度 0→1,无 inset 位移,直接出现在 −3 | 保留角标,无位移(既有表已定) | +| 时长曲线 | 透明度 200ms;位置 320ms · `--ease-out` | 透明度 200ms 不变 | 1ms | +| 可否打断 | 是 | 是 | — | + +### 3.15 提示 Toast + +| 项 | 完整(既有) | 克制【待确认】 | 关闭(既有) | +|---|---|---|---| +| 触发 | 操作完成 | 同左 | 同左 | +| 动画属性及幅度 | `opacity` + `translateY(10px)` + `scale(.96)`,`--ease-spring` | `opacity` + `translateY(4px)`,去弹性用 `--ease-out` | 淡入(1ms) | +| 时长曲线 | 320ms · spring;3.2s 后消失 | 200ms · `--ease-out`;3.2s 消失不变 | 1ms | +| 可否打断 | 是 | 是 | — | + +### 3.16 微交互(三档均保留,非装饰) + +悬停/聚焦的颜色、背景、边框过渡(`--t-fast 120ms` / `--t-base 200ms`)、开关弹簧位移、卡片 hover 位移、进度条 width 过渡、选中态颜色变化:**三档全部保留**,属于可用性反馈而非动效装饰;关闭档下由全局 1ms 规则即时完成,不删除属性本身。 + +## 4. 施工提示(确认后执行) + +1. 克制档实现为 `html.calm` 一个类:CSS 侧用 `html.calm` 覆盖各动画属性(停用 animation 或替换关键帧引用),JS 侧三处接入点——`startParticles` 增加 calm 判断、主题切换 `setTheme` 在 calm 时走 `theme-anim` 回退路径、显影按主题选择 `develop*Calm` 关键帧。 +2. 全部视觉值继续引用语义 token;克制档不新增颜色变量,只新增(【待确认】命名):`--t-calm:150–350ms` 各值写入上表、`--ease-out` 复用。 +3. 存储键 `aigc-motion` 复用既有安全存储封装(存储不可用时当次有效 + 提示,与主题一致);旧版只有 reduce 布尔时的迁移:`reduce=true → off`,无记录 → `full`。【待确认】 +4. 三档开关 UI:主题弹层底部「动效开」按钮改为三态分段(完整/克制/关闭),系统减少动效时显示“关闭 · 跟随系统”且禁用切换。【待确认】 +5. 不得借通用“减少动效”建议改回已定完整/关闭档行为;emil-skills 等通用参考与已批准样本冲突时,以样本为准。 + +## 5. 待确认清单(汇总,请白栖知逐条过目) + +1. §1 优先级规则 1–5(系统优先、偏好恢复、动态切换、后台卸载、低性能不自动降级 + 备选)。 +2. §2 八类循环的克制处置(静态化 or 停止)。 +3. §3 各一次性效果的克制幅度/时长(逐项数值,非系数)。 +4. §3.11 四主题克制显影:推荐方案(保留个性滤镜 350ms)vs 备选(统一 200ms 淡入)。 +5. §3.7 附带建议:关闭档的运行阶段样式与克制档静态样式对齐(属对既有关闭档的唯一微调建议)。 +6. §4 存储键命名、迁移规则与三态开关 UI 形态。 diff --git a/docs/changelogs/2026-10-23-hel813-calm-motion.md b/docs/changelogs/2026-10-23-hel813-calm-motion.md new file mode 100644 index 00000000..4feba239 --- /dev/null +++ b/docs/changelogs/2026-10-23-hel813-calm-motion.md @@ -0,0 +1,28 @@ +# HEL-813 · 已批准克制档与取舍落实 + +继续原草稿 PR #12 / `agent/agent/8ec6e532b3ac`,审核起点 `e2f139806fd5d49640b5e75f6d9215b62d2b8e40`,base `leefer/hel-731-studio-project@2952af42b6232483640f1f27803980367cd62dd0`。保留 HEL-733/806/809/810 及 B4。依据是 HEL-801 确认线程 `01a167c6-b1dd-7a15-b304-c6d1dbd50dca` 中“1-8项按建议”、看图单第8组和已批准的美术师 v3 推荐方案;画布整页仍另等样图确认。 + +## 实现与边界 + +- `reduceMotion.svelte.ts` 保存 `aigc-motion=full|calm|off`,系统减少动态强制表现为 off,保留用户偏好,恢复系统后恢复选择。兼容旧 `aigc-reduce-motion=1/true`;非法新值回 full。`app.html` 首绘执行相同优先规则。 +- 共用 `MotionChoices` 复用既有 `mg-options/mg-option`,主题弹层、设置及原顶栏入口消费同一组件。系统优先时禁用三档并显示“关闭 · 跟随系统”;安全存储失败时本次仍有效并给说明。`storageSet` 原 void 契约保持,新增可报告成功的 `storageTrySet`。 +- `themes.css` 是各项视觉值唯一来源:页面 200ms/2px,弹层150ms/2px/.995,Toast200ms/4px/非弹簧,蜂窝固定中心,取景框无位移。完整档数值保留。管理页原本有独立的320ms/6px规则,现消费同一页面入场token。原参数弹层完整档4px保持,克制档2px。 +- 克制档隐藏扫描光及按钮扫光、保留生成底色/颗粒/真实进度;队列弧保留静态描边;极光及光球保留静态初始外观;粒子取消rAF并清空。真实任务/计划/侧栏/占位等现有循环也消费同一停用token,保留静态状态及文字,未造第二套状态控件。项目运行阶段复用既有草稿高亮边框/光晕。未创建演示助手或模型身份。 +- 显影只响应同一当前任务 pending/running→done:克制档夜片场/蓝调 blur(10px)、日光 blur(8px) sepia(.25)、调色 blur(5px) saturate(.2),均350ms。保持历史/主题切换不重播、重复done不抹掉首次显影、点击可跳过。切档使当前一次性动画到终态,既有循环保持静态,下一次进入页面/任务才恢复完整档循环。 +- 克制主题切换走280ms颜色过渡,340ms清理;完整档仍圆形520ms/280ms回退,关闭档立即替换。只改root token和store,编辑器、上传、项目和会话不重挂。切档与卸载清理主题过渡。 +- 智能体粒子隐藏标签/离页清理;搭景预览rAF在隐藏标签停调度、显示恢复、卸载清理。手动视频播放/时间线功能保留。 +- 搭景缺图标来自既有依赖 `mannequin-js/src/scene.js` 导入副作用,向head追加 `../assets/logo/logo.png`。新增该兼容地址,字节复用 `static/favicon.png`,无新设计/依赖/第三方源码修改。 + +Toast关闭/撤销、18px普通页及项目底栏上方24px避让、真实项目来源、导航末槽用途、搭景现有结构、未知模型资料,以及看图单第8组19种真实状态沿用已批准现状。本轮不删真实功能、不改参数声明、不加进度硬偏移、不更换字体源、不改原HTML、不动数据库/业务API或线上配置。具体逐状态无需改/已改/待确认清单在本卡证据报告中。 + +## 复现 + +先安装锁文件中的既有依赖,运行前端 lint / check / test / build,再运行冻结preview测试;不要在preview回归进行中重建文件。 + +`e2e/hel813.cjs`:三档×四主题×三窗口截图、前后同态空态几何、批准样本、350ms逐帧、系统优先/恢复、旧偏好、存储禁用、粒子清理、切档终态、显影跳过与主题编辑器保留。 + +`e2e/hel813-fonts.cjs`:明确标注的隔离假服务快照与归属进度事件,按HEL-807六行夹具;原样本只在运行DOM映射真实文案并加载与应用同一字体CSS。联网/离线分开,每组记录实际FontFace加载状态、computed font、文字宽度及外框/填充几何,不改产品字体或偏移。 + +环境沿用运行机已有 Python3.12 虚拟环境与 Playwright,无项目依赖增加。用 `PLAYWRIGHT_MODULE_PATH`、`LD_LIBRARY_PATH`、`HEL732_PYTHON` 指向现有运行工具。测试进程由 `e2e/run-hel733.py` 前台管理,退出先比对multica daemon PID,再停止自己的进程并删除隔离库。只有一次性本机API、假ComfyUI/LLM,无真实GPU/收费调用。 + +所有最终结果、精确版本、数表、录屏和清理记录交本卡附件。后端零修改;HEL-812独立确认的五项全量隔离失败保持原结论,不新增跳过或忽略。最终独立审核/部署由总管另派,总站与画布未在此签验收。 diff --git a/e2e/hel733-live.cjs b/e2e/hel733-live.cjs index 66b38044..69f75773 100644 --- a/e2e/hel733-live.cjs +++ b/e2e/hel733-live.cjs @@ -61,7 +61,7 @@ const wait=ms=>new Promise(r=>setTimeout(r,ms)); ok('theme keeps session and unsaved result',(await p.locator('.shot-copy textarea').first().inputValue())==='HEL733 unsaved session draft'&&p.url()===sessionUrl); await p.goto(WEB+'/agents');await p.locator('canvas.particles').waitFor();await wait(150); const full=await p.evaluate(()=>window.particleFrames);await wait(150);ok('particles advance in full mode',(await p.evaluate(()=>window.particleFrames))>full); - await p.locator('.sidebar-bottom button[title="主题"]').click();await p.locator('.theme-motion button').click();await wait(100);const reduced=await p.evaluate(()=>window.particleFrames);await wait(200); + await p.locator('.sidebar-bottom button[title="主题"]').click();await p.locator('.theme-motion').getByRole('button',{name:'关闭',exact:true}).click();await wait(100);const reduced=await p.evaluate(()=>window.particleFrames);await wait(200); ok('manual reduce stops particle rAF',(await p.evaluate(()=>window.particleFrames))===reduced);await p.keyboard.press('Escape'); await p.emulateMedia({reducedMotion:'reduce'});ok('system reduce is mirrored on root',await p.locator('html').getAttribute('data-reduce')==='1'); await p.goto(WEB+'/settings');await p.locator('.management-header').waitFor();ok('system reduce disables motion override',await p.locator('html').getAttribute('data-reduce')==='1'); diff --git a/e2e/hel813-fonts.cjs b/e2e/hel813-fonts.cjs new file mode 100644 index 00000000..d71b9a79 --- /dev/null +++ b/e2e/hel813-fonts.cjs @@ -0,0 +1,40 @@ +// Explicit fake-service fixture; same as HEL-807, isolated API files/workflows only. +const {chromium}=require(process.env.PLAYWRIGHT_MODULE_PATH||'playwright'); +const fs=require('node:fs'),http=require('node:http'),assert=require('node:assert/strict'); +const OUT='../evidence/hel813/fonts';fs.mkdirSync(OUT,{recursive:true}); +const api=async(p,b)=>{const r=await fetch('http://127.0.0.1:8247'+p,{method:b?'POST':'GET',headers:{'Content-Type':'application/json'},body:b?JSON.stringify(b):undefined});assert(r.ok,await r.clone().text());return r.json()}; +(async()=>{ + const result={rows:[],failures:[],conditions:'Same origin-bound Google font CSS on both documents; no production font changes. Offline is a separate recorded condition.'}; + const names=['H3 首尾帧生视频','Krea2 · 4 宫格故事板','Qwen 2.1 · 局部重绘','H3 参考生视频','Krea2 · 文生图']; + const tmpl=(await api('/api/workflows'))[0],wf=[]; + for(const name of names)wf.push(await api('/api/workflows',{name,kind:name.startsWith('H3')?'video':'image',workflow_json:tmpl.workflow_json})); + const project=await api('/api/projects',{title:'雾港来信',idea:'HEL813 font fixture only'}); + const real=await api('/api/jobs');const png=real.find(j=>j.kind==='image'&&j.output_paths?.length)?.output_paths||[];const mp4=real.find(j=>j.kind==='video'&&j.output_paths?.length)?.output_paths||[]; + const tasks=[['镜头 07 · 首尾帧','workbench','video',0,'running'],['分镜草图 8/12','agent','image',1,'running'],['Qwen 局部重绘 #62','studio-image','image',2,'pending'],['参考生视频 #21','studio-video','video',3,'failed'],['Krea2 文生图 #55','studio-image','image',4,'done'],['镜头 06 · 首尾帧','workbench','video',0,'done']].map(([title,source,kind,w,status],i)=>({id:900001+i,project_id:project.id,workflow_id:wf[w].id,scene_id:null,clip_id:null,kind,status,payload:{title,source,endpoint:{id:'hel813-fake',name:'5090D'}},output_paths:status==='done'?(kind==='image'?png:mp4):[],error:status==='failed'?'OOM':null,retry_count:0,phase:status==='running'?'comfy_running':null,created_at:`2026-10-${21-i}T13:0${i}:00Z`,updated_at:'2026-10-23T00:00:00Z'})); + fs.writeFileSync(OUT+'/fixture.json',JSON.stringify({kind:'explicit fake-service snapshots; progress SSE 62/35; no product state changed',tasks},null,2)); + const server=http.createServer((req,res)=>{res.setHeader('Content-Type','text/html;charset=utf-8');res.end(fs.readFileSync('../materials/夜片场-多主题交互样本.html'))});await new Promise(r=>server.listen(0,'127.0.0.1',r)); + const b=await chromium.launch({args:['--no-sandbox']}); + const fontCSS='https://fonts.googleapis.com/css2?family=Outfit:wght@400;500;600;700&family=IBM+Plex+Sans:wght@400;500;600&family=JetBrains+Mono:wght@400;500&family=Noto+Sans+SC:wght@400;500;600&display=swap'; + try{for(const condition of ['online','offline']){ + const c=await b.newContext({timezoneId:'Asia/Shanghai',ignoreHTTPSErrors:false}); + if(condition==='offline')await c.route(/^https:\/\/(fonts.googleapis.com|fonts.gstatic.com)\//,r=>r.abort()); + await c.addInitScript(()=>{localStorage.setItem('aigc-motion','off');localStorage.setItem('aigc-lang','zh');window.sources=[];window.EventSource=class {constructor(){window.sources.push(this);setTimeout(()=>this.onopen?.(),0)}addEventListener(t,f){(this.handlers??={})[t]=f}close(){}}}); + const a=await c.newPage(),s=await c.newPage();a.on('pageerror',e=>result.failures.push(e.message)); + await a.route('**/api/jobs',r=>r.fulfill({json:tasks}));await a.goto('http://127.0.0.1:5173/tasks');await a.locator('.task-table tbody tr').first().waitFor(); + await a.evaluate(()=>{for(const source of window.sources)for(const [job_id,progress]of [[900001,62],[900002,35]])source.handlers?.['job.progress']?.({data:JSON.stringify({type:'job.progress',data:{job_id,progress},ts:new Date().toISOString()})})});await a.locator('.task-progress').first().waitFor(); + await s.goto('http://127.0.0.1:'+server.address().port);await s.evaluate(css=>{go('tasks');renderTasks();document.documentElement.classList.add('reduce');const link=document.createElement('link');link.rel='stylesheet';link.href=css;document.head.append(link)},fontCSS); + for(const p of [a,s])await p.evaluate(async()=>{await Promise.race([document.fonts.load('400 11px "JetBrains Mono"'),new Promise(r=>setTimeout(r,15000))]);await document.fonts.ready}); + // Map only the legitimate source-label difference; preserve the raw original. + const cells=await a.locator('.task-table tbody tr').evaluateAll(es=>es.map(e=>[...e.cells].map(c=>c.innerText.trim()))); + await s.evaluate(cells=>{const rows=document.querySelectorAll('#taskTable tr');for(const [i,row]of rows.entries())for(let k=1;k<8;k++){const e=row.cells[k],text=cells[i][k];if(k===1)e.querySelector('b').textContent=text;else if(k<5||k===6)e.textContent=text;else if(k===5)e.querySelector('.tag').textContent=text.split('\n')[0];else {const buttons=e.querySelectorAll('button');const desired=text.split('\n');buttons.forEach((b,j)=>b.textContent=desired[j]||'')}}},cells); + for(const [width,height]of [[1440,900],[1366,768],[1280,720]])for(const theme of ['night','day','grade','bluehour']){ + for(const p of [a,s]){await p.setViewportSize({width,height});await p.evaluate(t=>{document.documentElement.dataset.theme=t;window.dispatchEvent(new StorageEvent('storage',{key:'aigc-theme',newValue:t}))},theme)} + for(const page of [a,s])await page.evaluate(()=>new Promise(r=>requestAnimationFrame(()=>requestAnimationFrame(r))));await new Promise(r=>setTimeout(r,150)); + const measure=async(p,kind)=>p.evaluate(kind=>{const body=document.querySelector(kind==='app'?'.task-table tbody':'#taskTable'),bar=body.querySelector(kind==='app'?'.task-progress':'.bar'),time=body.rows[0].cells[6],r=e=>{const x=e.getBoundingClientRect();return {x:x.x,y:x.y,width:x.width,height:x.height}};const cv=document.createElement('canvas').getContext('2d');cv.font=getComputedStyle(time).font;return {bar:r(bar),fill:r(bar.querySelector('i')),time:r(time),computedFont:getComputedStyle(time).font,computedFamily:getComputedStyle(time).fontFamily,text:time.textContent,canvasTextWidth:cv.measureText(time.textContent).width,fontCheck:document.fonts.check('400 11px "JetBrains Mono"'),loadedFonts:[...document.fonts].filter(f=>f.status==='loaded').map(f=>({family:f.family,weight:f.weight,status:f.status})),allFonts:[...document.fonts].map(f=>({family:f.family,weight:f.weight,status:f.status})),fontRequests:performance.getEntriesByType('resource').filter(r=>r.name.includes('fonts.')).map(r=>({url:r.name,duration:r.duration,transfer:r.transferSize}))}},kind); + const app=await measure(a,'app'),sample=await measure(s,'sample');result.rows.push({condition,theme,width,height,app,sample,delta:{x:app.bar.x-sample.bar.x,y:app.bar.y-sample.bar.y,width:app.bar.width-sample.bar.width,height:app.bar.height-sample.bar.height}}); + console.log('FONT',condition,theme,width,'dx',app.bar.x-sample.bar.x,'loaded',app.loadedFonts.length,sample.loadedFonts.length); + await a.screenshot({path:`${OUT}/${condition}-${theme}-${width}-app.png`});await s.screenshot({path:`${OUT}/${condition}-${theme}-${width}-sample.png`}); + } + await c.close(); + }}finally{fs.writeFileSync(OUT+'/results.json',JSON.stringify(result,null,2));await b.close();server.closeAllConnections();await new Promise(r=>server.close(r))} +})().catch(e=>{console.error(e);process.exitCode=1}); diff --git a/e2e/hel813-supplement.cjs b/e2e/hel813-supplement.cjs new file mode 100644 index 00000000..9eda4000 --- /dev/null +++ b/e2e/hel813-supplement.cjs @@ -0,0 +1,25 @@ +const {chromium}=require(process.env.PLAYWRIGHT_MODULE_PATH||'playwright'); +const fs=require('node:fs'),assert=require('node:assert/strict'),path=require('node:path'); +const OUT='../evidence/hel813/supplement';fs.mkdirSync(OUT,{recursive:true}); +const checks=[],errors=[],rows=[];const ok=(n,v)=>{checks.push({name:n,pass:!!v});assert(v,n);console.log('PASS',n)}; +const api=async(p,b)=>{const r=await fetch('http://127.0.0.1:8247'+p,{method:b?'POST':'GET',headers:{'Content-Type':'application/json'},body:b?JSON.stringify(b):undefined});assert(r.ok);return r.json()}; +(async()=>{const b=await chromium.launch({args:['--no-sandbox']});try{ + const c=await b.newContext({viewport:{width:1440,height:900}});await c.addInitScript(()=>{localStorage.setItem('aigc-motion','calm');localStorage.setItem('aigc-lang','zh');window.viewportFrames=0;for(const Type of [window.WebGLRenderingContext,window.WebGL2RenderingContext]){if(!Type)continue;const clear=Type.prototype.clear;Type.prototype.clear=function(...args){window.viewportFrames++;return clear.apply(this,args)}}});const p=await c.newPage();p.on('pageerror',e=>errors.push(e.stack));p.setDefaultTimeout(15000); + const f=JSON.parse(fs.readFileSync('e2e/shots/reference/fixtures.json'));await p.goto('http://127.0.0.1:5173/create?wf='+f.dual);await p.locator('#desk-prompt').fill('HEL813 outside reveal skip'); + const row=p.locator('.workflow-row.reference-option').first();await row.hover({position:{x:8,y:8}});const a=await row.evaluate(e=>getComputedStyle(e,'::after').backgroundImage);await row.hover({position:{x:80,y:25}});const z=await row.evaluate(e=>getComputedStyle(e,'::after').backgroundImage);rows.push({honeycombBefore:a,honeycombAfter:z});ok('calm honeycomb stays centered when pointer moves',a===z&&await row.evaluate(e=>!e.style.getPropertyValue('--pointer-x')&&getComputedStyle(document.documentElement).getPropertyValue('--lattice-x').trim()==='50%')); + const sweep=await p.locator('.gen').evaluate(e=>getComputedStyle(e,'::after').display);ok('calm primary sweep hidden',sweep==='none'); + fs.writeFileSync('e2e/fake_mode.json',JSON.stringify({mode:'ok',delay_sec:1,unique_filename:true,image_file:path.resolve('calliope-web/static/refract/coast.jpg')}));await p.locator('.gen').click();await p.waitForFunction(()=>document.querySelector('.a-result-develop'));await p.locator('#desk-prompt').dispatchEvent('pointerdown');await p.waitForFunction(()=>!document.querySelector('.a-result-develop'));ok('pointer outside media skips current reveal',await p.locator('.a-result-develop').count()===0); + const frame=await p.locator('.print').evaluate(e=>{const s=getComputedStyle(e,'::after');return {inset:s.inset,transition:s.transitionDuration}});ok('calm viewfinder has no position transition',frame.transition.split(',').some(x=>parseFloat(x)===0));rows.push({viewfinder:frame}); + // A real isolated project, only its read response marks an ongoing stage. + const project=await api('/api/projects',{title:'HEL813 running-state fixture'});const snapshot=await api('/api/workbench/projects/'+project.id);snapshot.stages[0].status='running'; + await p.route('**/api/workbench/projects/'+project.id,r=>r.fulfill({json:snapshot})); + for(const [width,height]of [[1440,900],[1366,768],[1280,720]])for(const theme of ['night','day','grade','bluehour']){ + await p.setViewportSize({width,height});await p.goto('http://127.0.0.1:5173/workbench/'+project.id+'?stage=script');await p.locator('[data-state="running"] .avatar').waitFor();await p.evaluate(t=>{document.documentElement.dataset.theme=t;window.dispatchEvent(new StorageEvent('storage',{key:'aigc-theme',newValue:t}))},theme); + const style=await p.locator('[data-state="running"] .avatar').evaluate(e=>{const s=getComputedStyle(e);return {border:s.borderColor,color:s.color,shadow:s.boxShadow,animation:s.animationName}});rows.push({theme,width,height,style});ok(`running stage keeps static halo ${theme} ${width}`,style.shadow!=='none'&&style.animation==='none');await p.screenshot({path:`${OUT}/running-${theme}-${width}.png`,animations:'disabled'}); + } + await p.goto('http://127.0.0.1:5173/build-scene');await p.waitForTimeout(1000);const frames=await p.evaluate(()=>window.viewportFrames);await p.waitForTimeout(150);ok('manual scene viewport renders in calm',await p.evaluate(()=>window.viewportFrames)>frames); + await p.evaluate(()=>{Object.defineProperty(document,'hidden',{configurable:true,get:()=>true});document.dispatchEvent(new Event('visibilitychange'))});const hidden=await p.evaluate(()=>window.viewportFrames);await p.waitForTimeout(180);ok('background scene RAF stops',await p.evaluate(()=>window.viewportFrames)===hidden); + await p.evaluate(()=>{delete document.hidden;document.dispatchEvent(new Event('visibilitychange'))});await p.waitForTimeout(120);ok('visible scene RAF resumes',await p.evaluate(()=>window.viewportFrames)>hidden); + await p.goto('http://127.0.0.1:5173/settings');const left=await p.evaluate(()=>window.viewportFrames);await p.waitForTimeout(120);ok('scene unload cleans RAF',await p.evaluate(()=>window.viewportFrames)===left); + const icon=await p.request.get('http://127.0.0.1:5173/assets/logo/logo.png');ok('dependency favicon uses existing image bytes',icon.status()===200&&(await icon.body()).equals(fs.readFileSync('calliope-web/static/favicon.png')));ok('no supplemental pageerrors',errors.length===0);await c.close(); +}finally{fs.writeFileSync(OUT+'/results.json',JSON.stringify({checks,errors,rows},null,2));await b.close()}})().catch(e=>{console.error(e);process.exitCode=1}); diff --git a/e2e/hel813.cjs b/e2e/hel813.cjs new file mode 100644 index 00000000..33f2f087 --- /dev/null +++ b/e2e/hel813.cjs @@ -0,0 +1,106 @@ +// Approved v3 behavior on the frozen production build + disposable real API. +const {chromium}=require(process.env.PLAYWRIGHT_MODULE_PATH||'playwright'); +const fs=require('node:fs'),path=require('node:path'),http=require('node:http'),assert=require('node:assert/strict'); +const OUT='../evidence/hel813',WEB='http://127.0.0.1:5173',API='http://127.0.0.1:8247'; +fs.mkdirSync(OUT+'/shots',{recursive:true}); +const report={checks:[],errors:[],motion:[],geometry:[],reveal:[],fonts:[]}; +const ok=(name,value)=>{report.checks.push({name,pass:!!value});assert(value,name);console.log('PASS',name)}; +const wait=ms=>new Promise(r=>setTimeout(r,ms)); +const api=async(p,b,method)=>{const r=await fetch(API+p,{method:method||(b?'POST':'GET'),headers:{'Content-Type':'application/json'},body:b?JSON.stringify(b):undefined});assert(r.ok,await r.clone().text());return r.json()}; +const sampleServer=http.createServer((req,res)=>{ + const u=new URL(req.url,'http://localhost'); + if(u.pathname.startsWith('/api/')){const q=http.request(API+req.url,{method:req.method,headers:req.headers},r=>{res.writeHead(r.statusCode,r.headers);r.pipe(res)});q.on('error',()=>res.end());res.on('close',()=>q.destroy());req.pipe(q);return} + if(u.pathname==='/sample'||u.pathname==='/calm-sample'){res.setHeader('Content-Type','text/html;charset=utf-8');res.end(fs.readFileSync('../materials/'+(u.pathname==='/sample'?'夜片场-多主题交互样本.html':'motion-calm-sample.html')));return} + const build=path.resolve('../evidence/hel813-before/calliope-web/build');let file=path.join(build,decodeURIComponent(u.pathname));if(!fs.existsSync(file)||fs.statSync(file).isDirectory())file=path.join(build,'index.html'); + res.setHeader('Content-Type',({'.js':'text/javascript','.css':'text/css','.html':'text/html','.png':'image/png','.woff2':'font/woff2','.svg':'image/svg+xml'})[path.extname(file)]||'application/octet-stream');fs.createReadStream(file).pipe(res); +}); +const sockets=new Set();sampleServer.on('connection',s=>{sockets.add(s);s.on('close',()=>sockets.delete(s))}); +const geom=loc=>loc.evaluate(e=>{const r=e.getBoundingClientRect(),s=getComputedStyle(e);return {x:r.x,y:r.y,w:r.width,h:r.height,font:s.font,fontFamily:s.fontFamily,bg:s.backgroundColor,radius:s.borderRadius,animation:s.animation,transform:s.transform}}); +const mode=async(p,label)=>{await p.locator('.sidebar-bottom button[title="主题"]').click();await p.locator('.theme-motion').getByRole('button',{name:label,exact:true}).click();await p.keyboard.press('Escape');await wait(40)}; +const theme=async(p,id)=>p.evaluate(id=>{document.documentElement.dataset.theme=id;window.dispatchEvent(new StorageEvent('storage',{key:'aigc-theme',newValue:id}))},id); +(async()=>{ + await new Promise(r=>sampleServer.listen(0,'127.0.0.1',r));const OLD='http://127.0.0.1:'+sampleServer.address().port; + const b=await chromium.launch({args:['--no-sandbox','--disable-dev-shm-usage']}); + try{ + const f=JSON.parse(fs.readFileSync('e2e/shots/reference/fixtures.json')); + const ctx=await b.newContext({viewport:{width:1440,height:900},recordVideo:{dir:OUT+'/recordings'}}); + await ctx.addInitScript(()=>{ + localStorage.setItem('aigc-lang','zh'); + window.arrivals=[];window.particleFrames=0;window.vtCalls=0; + document.addEventListener('animationstart',e=>{if(e.animationName.startsWith('a-develop'))window.arrivals.push({name:e.animationName,time:performance.now()})},true); + const clear=CanvasRenderingContext2D.prototype.clearRect;CanvasRenderingContext2D.prototype.clearRect=function(...args){if(this.canvas.classList.contains('particles'))window.particleFrames++;return clear.apply(this,args)}; + const vt=document.startViewTransition?.bind(document);if(vt)document.startViewTransition=(...args)=>{window.vtCalls++;return vt(...args)}; + }); + const p=await ctx.newPage();p.on('pageerror',e=>report.errors.push(e.stack));p.setDefaultTimeout(12000); + await p.goto(WEB+'/agents');await p.locator('.orb').waitFor();await wait(250); + const frames=await p.evaluate(()=>window.particleFrames);await wait(150);ok('full particles advance',await p.evaluate(()=>window.particleFrames)>frames); + await mode(p,'克制');ok('calm is effective and persisted',await p.evaluate(()=>document.documentElement.dataset.motion==='calm'&&localStorage.getItem('aigc-motion')==='calm')); + const stopped=await p.evaluate(()=>window.particleFrames);await wait(180);ok('calm cancels and clears particle rAF',await p.evaluate(()=>window.particleFrames)===stopped); + ok('calm keeps static aurora and orb',await p.locator('.orb').evaluate(e=>getComputedStyle(e).display!=='none')); + ok('switching mode stops existing loops',await p.evaluate(()=>document.getAnimations().filter(a=>a.effect?.getTiming().iterations===Infinity&&a.playState==='running').length===0)); + await p.emulateMedia({reducedMotion:'reduce'});await wait(50);ok('OS override displays off without changing calm preference',await p.evaluate(()=>document.documentElement.dataset.motion==='off'&&localStorage.getItem('aigc-motion')==='calm')); + await p.locator('.sidebar-bottom button[title="主题"]').click();ok('OS locks all three controls',await p.locator('.theme-motion .mg-option:disabled').count()===3);ok('OS explanation visible',await p.getByText('关闭 · 跟随系统',{exact:true}).isVisible());await p.keyboard.press('Escape'); + await p.emulateMedia({reducedMotion:'no-preference'});await wait(50);ok('OS restoration returns to calm',await p.locator('html').getAttribute('data-motion')==='calm'); + await p.reload();ok('calm survives refresh',await p.locator('html').getAttribute('data-motion')==='calm'); + await mode(p,'完整');const held=await p.evaluate(()=>window.particleFrames);await wait(160);ok('returning full does not inject particles mid-scene',await p.evaluate(()=>window.particleFrames)===held); + ok('returning full leaves current CSS loops static',await p.evaluate(()=>document.getAnimations().filter(a=>a.effect?.getTiming().iterations===Infinity&&a.playState==='running').length===0)); + await p.goto(WEB+'/settings');await p.goto(WEB+'/agents');await wait(150);const resumed=await p.evaluate(()=>window.particleFrames);await wait(150);ok('next page entry restarts full particles',await p.evaluate(()=>window.particleFrames)>resumed); + // Real visibility handlers, not timers: hidden clears the canvas and cancels RAF. + await p.evaluate(()=>{Object.defineProperty(document,'hidden',{configurable:true,get:()=>true});document.dispatchEvent(new Event('visibilitychange'))});const hidden=await p.evaluate(()=>window.particleFrames);await wait(150);ok('hidden tab stops particle RAF',await p.evaluate(()=>window.particleFrames)===hidden); + await p.evaluate(()=>{delete document.hidden;document.dispatchEvent(new Event('visibilitychange'))});await wait(100);ok('visible tab resumes full particle RAF',await p.evaluate(()=>window.particleFrames)>hidden); + await p.goto(WEB+'/settings');const left=await p.evaluate(()=>window.particleFrames);await wait(100);ok('leaving agent page cleans particle RAF',await p.evaluate(()=>window.particleFrames)===left); + // Old preferences: absence, both historical true encodings, invalid new key. + for(const [saved,legacy,expected] of [[null,null,'full'],[null,'1','off'],[null,'true','off'],['invalid','1','full']]){ + const c=await b.newContext();await c.addInitScript(({saved,legacy})=>{if(saved!==null)localStorage.setItem('aigc-motion',saved);if(legacy!==null)localStorage.setItem('aigc-reduce-motion',legacy)},{saved,legacy});const q=await c.newPage();await q.goto(WEB+'/settings');await q.locator('.management-header').waitFor();ok('preference migration '+JSON.stringify([saved,legacy]),await q.locator('html').getAttribute('data-motion')===expected);await c.close(); + } + const blocked=await b.newContext();await blocked.addInitScript(()=>{Storage.prototype.getItem=()=>{throw new DOMException('blocked','SecurityError')};Storage.prototype.setItem=()=>{throw new DOMException('blocked','SecurityError')}});const q=await blocked.newPage();q.on('pageerror',e=>report.errors.push(e.message));await q.goto(WEB+'/settings');await q.getByRole('group',{name:'动效强度'}).last().getByRole('button',{name:'克制',exact:true}).click();ok('blocked storage still allows calm',await q.locator('html').getAttribute('data-motion')==='calm');ok('blocked storage explains in-session preference',await q.getByText('浏览器禁止本地存储:本次有效,其他操作不受影响',{exact:true}).count()>0);await blocked.close(); + const oldCtx=await b.newContext();await oldCtx.addInitScript(()=>{localStorage.setItem('aigc-lang','zh');localStorage.setItem('aigc-reduce-motion','1')});const before=await oldCtx.newPage(); + const candidate=await ctx.newPage();await candidate.goto(OLD+'/calm-sample');await candidate.locator('#motionSeg [data-m="calm"]').click(); + await p.route('**/api/playground/jobs*',r=>r.fulfill({json:[]}));await before.route('**/api/playground/jobs*',r=>r.fulfill({json:[]})); + // Geometry and static/motion values for all four themes and three windows. + for(const [width,height] of [[1440,900],[1366,768],[1280,720]])for(const id of ['night','day','grade','bluehour']){ + await p.setViewportSize({width,height});await before.setViewportSize({width,height});await candidate.setViewportSize({width,height}); + await before.goto(OLD+'/settings');await theme(before,id);await before.screenshot({path:`${OUT}/shots/settings-${id}-${width}-before.png`,animations:'disabled'}); + await p.goto(WEB+'/settings');await mode(p,'克制');await theme(p,id);await wait(200);await p.screenshot({path:`${OUT}/shots/settings-${id}-${width}-after.png`,animations:'disabled'}); + await candidate.evaluate(id=>setTheme(id),id);await candidate.screenshot({path:`${OUT}/shots/approved-${id}-${width}.png`,animations:'disabled'}); + await p.locator('.sidebar-bottom button[title="主题"]').click();await p.screenshot({path:`${OUT}/shots/theme-${id}-${width}-calm.png`,animations:'disabled'}); + const panel=await geom(p.locator('.modal-panel'));ok(`theme controls fit ${id} ${width}`,panel.x>=0&&panel.x+panel.w<=width&&panel.y>=0&&panel.y+panel.h<=height); + const values=await p.evaluate(()=>{const s=getComputedStyle(document.documentElement);return Object.fromEntries(['--page-enter-duration','--page-enter-y','--d-pop','--pop-enter-y','--pop-enter-scale','--t-reveal','--result-calm-filter','--toast-enter','--toast-enter-y','--toast-enter-scale','--lattice-x','--lattice-y','--viewfinder-inset'].map(k=>[k,s.getPropertyValue(k).trim()]))}); + ok(`actual management entry duration ${id} ${width}`,await p.locator('.management-page').evaluate(e=>getComputedStyle(e).animationDuration==='0.2s')); + report.motion.push({id,width,height,panel,values});ok(`per-effect calm tokens ${id} ${width}`,values['--d-pop']==='150ms'&&values['--t-reveal']==='350ms'&&values['--toast-enter']==='200ms');await p.keyboard.press('Escape'); + for(const [value,label] of [['full','完整'],['calm','克制'],['off','关闭']]) { + await mode(p,label);await p.goto(WEB+'/agents');await theme(p,id);await wait(120); + const loops=await p.evaluate(()=>document.getAnimations().filter(a=>a.effect?.getTiming().iterations===Infinity&&a.playState==='running').length); + ok(`agent ${value} loops ${id} ${width}`,value==='full'?loops>0:loops===0); + await p.screenshot({path:`${OUT}/shots/agent-${id}-${width}-${value}.png`}); + } + await mode(p,'克制'); + // Same actual workflow + prompt before/after; no demonstration brand data. + const route='/create?wf='+f.dual;await before.goto(OLD+route);await theme(before,id);await before.locator('#desk-prompt').fill('HEL813 同状态输入');await before.screenshot({path:`${OUT}/shots/create-${id}-${width}-before.png`,animations:'disabled'}); + await p.goto(WEB+route);await theme(p,id);await p.locator('#desk-prompt').fill('HEL813 同状态输入');await p.screenshot({path:`${OUT}/shots/create-${id}-${width}-after.png`,animations:'disabled'}); + const a=await geom(p.locator('.desk-emblem')),z=await geom(before.locator('.desk-emblem'));report.geometry.push({id,width,height,before:z,after:a});ok(`unchanged empty-ring geometry ${id} ${width}`,['x','y','w','h'].every(k=>a[k]===z[k])); + } + await p.unroute('**/api/playground/jobs*'); + await before.close();await oldCtx.close();await candidate.close(); + // Live reveals through pending/running→done from fake ComfyUI, per theme. + await p.setViewportSize({width:1440,height:900}); + for(const id of ['night','day','grade','bluehour']){ + await p.goto(WEB+'/create?wf='+f.dual);await theme(p,id);await p.locator('#desk-prompt').fill('HEL813 '+id+' live reveal'); + fs.writeFileSync('e2e/fake_mode.json',JSON.stringify({mode:'ok',delay_sec:1,unique_filename:true,image_file:path.resolve('calliope-web/static/refract/coast.jpg')})); + await p.locator('.gen').click();await p.locator('.a-scan-beam').waitFor({state:'attached'});ok(id+' calm scan hidden',await p.locator('.a-scan-beam').evaluate(e=>getComputedStyle(e).display==='none')); + await p.waitForFunction(()=>window.arrivals.length>0); + const reveal=await p.evaluate(()=>{const e=document.querySelector('.a-result-develop > img');if(!e)return null;const a=e.getAnimations()[0];a.pause();a.currentTime=0;return {duration:a.effect.getTiming().duration,frames:a.effect.getKeyframes()}});ok(id+' reveal exists at 350ms',reveal?.duration===350);report.reveal.push({id,...reveal}); + await p.screenshot({path:`${OUT}/shots/reveal-${id}-000.png`});await p.evaluate(()=>{const a=document.querySelector('.a-result-develop > img').getAnimations()[0];a.currentTime=175});await p.screenshot({path:`${OUT}/shots/reveal-${id}-175.png`}); + // Repeated snapshots must keep this first arrival; mode switch finishes it. + await p.evaluate(()=>window.dispatchEvent(new Event('focus')));ok(id+' repeated done does not erase paused reveal',await p.locator('.a-result-develop').count()===1); + await mode(p,'关闭');ok(id+' running reveal jumps to final when changing mode',await p.locator('.a-result-develop').count()===0);await mode(p,'克制'); + await p.reload();ok(id+' history does not replay reveal',await p.locator('.a-result-develop').count()===0); + } + // Theme changes use fallback, preserve the actual editor and input values. + await p.goto(WEB+'/create?wf='+f.dual);await p.locator('#desk-prompt').fill('HEL813 未保存输入');await p.locator('#desk-prompt').evaluate(e=>window.savedEditor=e); + await p.locator('.sidebar-bottom button[title="主题"]').click();await p.evaluate(()=>{window.themeDiagnostics={before:{theme:document.documentElement.dataset.theme,mode:document.documentElement.dataset.motion,classes:document.documentElement.className},mutations:[]};new MutationObserver(()=>window.themeDiagnostics.mutations.push({time:performance.now(),theme:document.documentElement.dataset.theme,mode:document.documentElement.dataset.motion,classes:document.documentElement.className})).observe(document.documentElement,{attributes:true})});const vt=await p.evaluate(()=>window.vtCalls);const nextTheme=(await p.locator('html').getAttribute('data-theme'))==='day'?'夜片场':'日光棚';await p.locator('.choices.compact .choice').filter({hasText:nextTheme}).click();await wait(30);fs.writeFileSync(OUT+'/theme-diagnostics.json',JSON.stringify(await p.evaluate(()=>window.themeDiagnostics),null,2));ok('calm theme uses color fallback',await p.evaluate(()=>window.themeDiagnostics.mutations.some(x=>x.classes.includes('theme-anim'))));ok('calm skips circular View Transition',await p.evaluate(()=>window.vtCalls)===vt);await wait(350);ok('fallback timer clears class',!(await p.locator('html').evaluate(e=>e.classList.contains('theme-anim'))));await p.keyboard.press('Escape');ok('theme preserves editor identity and prompt',await p.locator('#desk-prompt').evaluate(e=>e===window.savedEditor&&e.value==='HEL813 未保存输入')); + // Genuine skip: generate again, pause at arrival, click the media surface. + fs.writeFileSync('e2e/fake_mode.json',JSON.stringify({mode:'ok',delay_sec:1,unique_filename:true}));await p.locator('.gen').click();await p.waitForFunction(()=>document.querySelector('.a-result-develop'));await p.locator('.a-result-develop').first().dispatchEvent('pointerdown');await p.waitForFunction(()=>!document.querySelector('.a-result-develop'));ok('arrival can be skipped explicitly',await p.locator('.a-result-develop').count()===0); + await ctx.close();ok('no browser page errors',report.errors.length===0); + }finally{fs.writeFileSync(OUT+'/results.json',JSON.stringify(report,null,2));await b.close();for(const s of sockets)s.destroy();await new Promise(r=>sampleServer.close(r))} +})().catch(e=>{console.error(e);process.exitCode=1}); -- 2.54.0 From f30092754671dba6e59b8c7cc5165cacd97da99b Mon Sep 17 00:00:00 2001 From: leefer <2298888111@qq.com> Date: Fri, 23 Oct 2026 18:46:06 +0800 Subject: [PATCH 7/9] feat: apply approved four-theme canvas design (HEL-818) Co-authored-by: multica-agent --- .../src/lib/canvas/ArtifactNode.svelte | 189 ++---- calliope-web/src/lib/canvas/EntityNode.svelte | 130 ++--- calliope-web/src/lib/canvas/canvas.css | 227 ++++++++ .../src/lib/components/agent/AgentChat.svelte | 28 +- .../lib/components/agent/AgentComposer.svelte | 6 +- .../agent/AgentSessionSidebar.svelte | 49 +- .../src/routes/canvas/[id]/+page.svelte | 537 +++++++++++------- calliope-web/static/themes.css | 8 + docs/changelogs/2026-10-23-hel818-canvas.md | 21 + e2e/hel818-fixture.py | 31 + e2e/hel818-report.py | 28 + e2e/hel818-web.cjs | 8 + e2e/hel818.cjs | 121 ++++ e2e/run-hel818.py | 54 ++ 14 files changed, 967 insertions(+), 470 deletions(-) create mode 100644 calliope-web/src/lib/canvas/canvas.css create mode 100644 docs/changelogs/2026-10-23-hel818-canvas.md create mode 100644 e2e/hel818-fixture.py create mode 100644 e2e/hel818-report.py create mode 100644 e2e/hel818-web.cjs create mode 100644 e2e/hel818.cjs create mode 100644 e2e/run-hel818.py diff --git a/calliope-web/src/lib/canvas/ArtifactNode.svelte b/calliope-web/src/lib/canvas/ArtifactNode.svelte index 05f2115c..b0c6f879 100644 --- a/calliope-web/src/lib/canvas/ArtifactNode.svelte +++ b/calliope-web/src/lib/canvas/ArtifactNode.svelte @@ -1,11 +1,15 @@ -
+
{data.kind} {data.title} - {#if running} - {t('canvas.running')} - {:else if data.status === 'failed'} - {t('canvas.failed')} - {:else if videoSrc} - - {/if}
-
+
(arriving = false)} + > {#if videoSrc}
- {data.kind} +
+ {#if running}{t('canvas.running')}{:else if data.status === 'failed'}{t('canvas.failed')}{/if}#{data.canvasNodeId} +
- - diff --git a/calliope-web/src/lib/canvas/EntityNode.svelte b/calliope-web/src/lib/canvas/EntityNode.svelte index 942712f0..2f131fb8 100644 --- a/calliope-web/src/lib/canvas/EntityNode.svelte +++ b/calliope-web/src/lib/canvas/EntityNode.svelte @@ -32,13 +32,15 @@ } } - let { data }: NodeProps = $props(); + let { data, selected }: NodeProps = $props(); const posterSrc = $derived(assetUrl(data.imagePath)); // '#t=0.1' media fragment: with preload="metadata" browsers paint nothing // until playback, so the thumbnail sat black. The fragment seeks+paints // the first frame without playing (same trick as ClipMonitor). - const videoSrc = $derived(data.videoPath ? assetUrl(data.videoPath) + '#t=0.1' : null); + const videoSrc = $derived( + data.videoPath ? assetUrl(data.videoPath) + '#t=0.1' : null, + ); function activate() { // One rule for every card (artifact and entity alike): click opens the @@ -52,7 +54,9 @@
- + {data.title} {#if videoSrc}
- {#if videoSrc} - video - {:else if posterSrc} - image - {/if} +
+ {data.entityType}#{data.canvasNodeId} +
- - diff --git a/calliope-web/src/lib/canvas/canvas.css b/calliope-web/src/lib/canvas/canvas.css new file mode 100644 index 00000000..a4847197 --- /dev/null +++ b/calliope-web/src/lib/canvas/canvas.css @@ -0,0 +1,227 @@ +/* Scope border-box to the canvas; shared legacy chat uses content-box elsewhere. */ +.canvas-root, +.canvas-root *, +.canvas-root *::before, +.canvas-root *::after { + box-sizing: border-box; +} +/* Canvas surfaces consume the approved shared theme and motion tokens. */ +.canvas-root .svelte-flow { + --xy-background-color-default: var(--board-bg); + --xy-background-pattern-color-default: var(--board-dot); + --xy-controls-button-background-color-default: transparent; + --xy-controls-button-background-color-hover-default: rgb(var(--ink) / 0.06); + --xy-controls-button-color-default: var(--tx-2); + --xy-controls-button-color-hover-default: var(--tx-1); + --xy-controls-button-border-color-default: transparent; +} +.canvas-root .svelte-flow__panel { + margin: 14px; +} +.canvas-root .svelte-flow__controls, +.canvas-root .svelte-flow__minimap { + padding: 4px; + border: 1px solid var(--line-2); + border-radius: var(--r-md); + background: var(--glass); + backdrop-filter: blur(14px); + box-shadow: var(--sh-1); + overflow: hidden; +} +.canvas-root .svelte-flow__controls { + gap: 2px; + height: 160px; +} +.canvas-root .svelte-flow__controls-button { + width: 30px; + height: 30px; + padding: 7px; + border-radius: 7px; +} +.canvas-root .svelte-flow__minimap { + padding: 10px; +} +.canvas-root .svelte-flow__minimap svg { + display: block; + background: rgb(var(--ink) / 0.04); + border-radius: 5px; +} +.canvas-root .svelte-flow__minimap-mask { + fill: rgb(var(--ink) / 0.04); + stroke: color-mix(in srgb, var(--gold) 80%, var(--hi)); +} +.canvas-card { + position: relative; + width: 240px; + border-radius: var(--r-lg); + background: var(--bg-2); + border: 1px solid var(--line-2); + box-shadow: var(--sh-1); + cursor: grab; + transition: + box-shadow var(--t-base), + border-color var(--t-base); + --scene: var(--sc-image); +} +.canvas-card.artifact-node { + width: 220px; + border-radius: var(--r-md); +} +.canvas-card:hover { + box-shadow: var(--sh-2); + border-color: var(--line-3); +} +.canvas-card.a-in-frame, +.canvas-root .svelte-flow__node:focus-visible .canvas-card { + border-color: color-mix(in srgb, var(--scene) 45%, transparent); +} +.canvas-card:focus-visible { + outline: none; +} +.canvas-card:focus-visible::after, +.canvas-root .svelte-flow__node:focus-visible .canvas-card::after { + opacity: 1; + inset: var(--viewfinder-selected-inset); +} +.canvas-card header { + display: flex; + align-items: center; + gap: 7px; + padding: 9px 10px 8px; +} +.canvas-card .title { + flex: 1; + min-width: 0; + font-size: 12.5px; + font-weight: 600; + color: var(--tx-1); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.canvas-card .kind-icon { + width: 22px; + height: 22px; + display: grid; + place-items: center; + flex-shrink: 0; + border-radius: 6px; + color: var(--kind-color); + background: color-mix(in srgb, var(--kind-color) 12%, transparent); +} +.canvas-card[data-kind="character"] { + --kind-color: var(--sc-agent); +} +.canvas-card[data-kind="location"], +.canvas-card[data-kind="scene"], +.canvas-card[data-kind="video"] { + --kind-color: var(--sc-video); +} +.canvas-card[data-kind="item"] { + --kind-color: var(--sc-project); +} +.canvas-card[data-kind="image"] { + --kind-color: var(--sc-image); +} +.canvas-card .play-badge { + display: grid; + place-items: center; + flex-shrink: 0; + width: 22px; + height: 22px; + border-radius: 50%; + background: rgb(var(--ink) / 0.06); + color: var(--tx-2); +} +.canvas-card .media { + position: relative; + height: 132px; + margin: 0 8px 8px; + border-radius: calc(var(--r-md) - 2px); + background: var(--desk-media-bg); + box-shadow: var(--media-shadow); + display: flex; + align-items: center; + justify-content: center; + overflow: hidden; +} +.canvas-card.artifact-node .media { + height: 124px; +} +.canvas-card .media-el { + width: 100%; + height: 100%; + object-fit: cover; + display: block; +} +.canvas-card .media-btn { + appearance: none; + border: 0; + background: transparent; + padding: 0; + width: 100%; + height: 100%; + cursor: pointer; + display: block; +} +.canvas-card .placeholder { + font-size: 10px; + letter-spacing: 0.08em; + color: var(--tx-3); +} +.canvas-card .out-kind { + position: absolute; + right: 6px; + bottom: 6px; + height: 18px; + display: inline-flex; + align-items: center; + padding: 0 6px; + border-radius: 4px; + font-size: 10.5px; + color: var(--on-media); + background: var(--media-scrim); + border: 1px solid var(--line-2); + pointer-events: none; +} +.canvas-card footer { + display: flex; + align-items: center; + gap: 6px; + padding: 0 10px 9px; + font-size: 11px; + color: var(--tx-3); +} +.canvas-card footer .node-id { + margin-left: auto; + font-family: var(--mono); +} +.canvas-card .type-chip { + font-size: 10.5px; + padding: 2px 6px; + border-radius: 4px; + color: var(--kind-color); + background: color-mix(in srgb, var(--kind-color) 12%, transparent); + height: 18px; + display: inline-flex; + align-items: center; +} +.canvas-card .status { + display: inline-flex; + align-items: center; + gap: 4px; + font-size: 10.5px; +} +.canvas-card .running-badge { + color: var(--info); +} +.canvas-card .running-badge::before { + content: ""; + width: 6px; + height: 6px; + border-radius: 50%; + background: var(--info); +} +.canvas-card .failed-badge { + color: var(--err); +} diff --git a/calliope-web/src/lib/components/agent/AgentChat.svelte b/calliope-web/src/lib/components/agent/AgentChat.svelte index c5285515..400764e1 100644 --- a/calliope-web/src/lib/components/agent/AgentChat.svelte +++ b/calliope-web/src/lib/components/agent/AgentChat.svelte @@ -488,38 +488,38 @@ display: inline-flex; align-items: center; gap: 6px; - font-size: var(--fs-mini); - font-weight: 700; - letter-spacing: 0.06em; - text-transform: uppercase; + font-size: var(--chat-tag-size, var(--fs-mini)); + font-weight: var(--chat-tag-weight, 700); + letter-spacing: var(--chat-tag-spacing, 0.06em); + text-transform: var(--chat-tag-transform, uppercase); color: var(--agent, var(--text-2)); margin-bottom: 4px; padding-left: 2px; } .agent-dot { - width: 8px; - height: 8px; + width: var(--chat-dot-size, 8px); + height: var(--chat-dot-size, 8px); border-radius: 999px; flex-shrink: 0; - box-shadow: 0 0 0 2px color-mix(in srgb, var(--agent, var(--text-2)) 25%, transparent); + box-shadow: var(--chat-identity-shadow, 0 0 0 2px color-mix(in srgb, var(--agent, var(--text-2)) 25%, transparent)); } .reasoning-wrap { margin-bottom: 4px; max-width: 86%; } .bubble { - max-width: 86%; - border-radius: var(--r-lg); - padding: 10px 14px; - font-size: 13.5px; + max-width: var(--chat-bubble-width, 86%); + border-radius: var(--chat-bubble-radius, var(--r-lg)); + padding: var(--chat-bubble-padding, 10px 14px); + font-size: var(--chat-bubble-font, 13.5px); line-height: 1.6; white-space: pre-wrap; word-break: break-word; } /* 对话气泡按规范 §3.2/§3.6:用户 --bg-3,Agent --bg-2 + --line 描边。 */ .user-bubble { - background: var(--bg-3); - border: 1px solid var(--line); + background: var(--chat-user-bg, var(--bg-3)); + border: 1px solid var(--chat-user-border, var(--line)); color: var(--text); } .user-bubble.steering { @@ -574,7 +574,7 @@ .assistant-bubble { background: var(--bg-2); border: 1px solid var(--line); - border-left: 3px solid var(--agent, var(--text-2)); + border-left: var(--chat-agent-border-width, 3px) solid var(--chat-agent-border, var(--agent, var(--text-2))); color: var(--text); } .question-card { diff --git a/calliope-web/src/lib/components/agent/AgentComposer.svelte b/calliope-web/src/lib/components/agent/AgentComposer.svelte index 7ae70752..7f1d45c1 100644 --- a/calliope-web/src/lib/components/agent/AgentComposer.svelte +++ b/calliope-web/src/lib/components/agent/AgentComposer.svelte @@ -21,6 +21,7 @@ interface Props { studio?: boolean; + primaryAction?: boolean; running: boolean; onSend: (payload: AgentComposerPayload) => void; onCancel: () => void; @@ -36,6 +37,7 @@ interface Props { let { studio = false, + primaryAction = false, running, onSend, onCancel, @@ -578,7 +580,7 @@ let { {/if} {/if} {#if savedFlash} saved {/if} + {#if canvas.project} - - + + {canvas.project.title} {:else} - + Sandbox {/if} @@ -1119,7 +1214,7 @@ bind:nodes bind:viewport {nodeTypes} - colorMode="light" + colorMode="system" minZoom={0.05} maxZoom={4} {initialViewport} @@ -1135,9 +1230,24 @@ onmoveend={(_event, vp) => scheduleViewportSave(vp)} oninit={assertViewportOnInit} > - - - + + {Math.round(viewport.zoom * 100)}% + node.type === 'video' ? 'color-mix(in srgb, var(--sc-video) 60%, var(--tx-3))' : 'color-mix(in srgb, var(--sc-image) 55%, var(--tx-3))'} + maskColor="rgb(var(--ink) / 0.04)" + maskStrokeColor="var(--gold)" + maskStrokeWidth={1.5} + />