From 2304ab1538a4fd5f301efbf58f1a28b987b78809 Mon Sep 17 00:00:00 2001 From: leefer Date: Wed, 21 Oct 2026 21:07:19 +0800 Subject: [PATCH 1/6] feat(HEL-729): unify the Nocturne creative desk and isolate history Co-authored-by: multica-agent --- .../src/lib/components/desk/Artboard.svelte | 79 ++- .../components/desk/DeskWorkflowRail.svelte | 136 ++-- .../lib/components/desk/HistoryRail.svelte | 159 +++++ .../src/lib/components/desk/StudioDesk.svelte | 163 ++++- .../src/lib/components/desk/draftCache.ts | 17 + .../lib/components/generate/Composer.svelte | 109 ++-- .../src/lib/components/shell/AppShell.svelte | 6 +- .../workflows/WorkflowLibrary.svelte | 13 + calliope-web/src/routes/create/+page.svelte | 9 + calliope-web/src/routes/create/+page.ts | 8 - calliope-web/src/routes/image/+page.svelte | 10 +- .../src/routes/playground/+page.svelte | 41 +- calliope-web/src/routes/video/+page.svelte | 10 +- calliope-web/static/themes.css | 10 + .../2026-10-21-hel729-creative-desk.md | 26 + e2e/hel729.cjs | 592 ++++++++++++++++++ e2e/run-hel729.py | 44 ++ 17 files changed, 1271 insertions(+), 161 deletions(-) create mode 100644 calliope-web/src/lib/components/desk/HistoryRail.svelte create mode 100644 calliope-web/src/lib/components/desk/draftCache.ts create mode 100644 calliope-web/src/routes/create/+page.svelte create mode 100644 docs/changelogs/2026-10-21-hel729-creative-desk.md create mode 100644 e2e/hel729.cjs create mode 100644 e2e/run-hel729.py diff --git a/calliope-web/src/lib/components/desk/Artboard.svelte b/calliope-web/src/lib/components/desk/Artboard.svelte index c38efd8a..fcdf7725 100644 --- a/calliope-web/src/lib/components/desk/Artboard.svelte +++ b/calliope-web/src/lib/components/desk/Artboard.svelte @@ -132,16 +132,27 @@ return live?.progress ?? 3; }); let dims = $state(''); + let naturalWidth = $state(1), + naturalHeight = $state(1); + let stageWidth = $state(0), + stageHeight = $state(0); + const fitted = $derived( + Math.min(stageWidth / naturalWidth, stageHeight / naturalHeight), + ); $effect.pre(() => { void output; dims = ''; }); function noteImg(e: Event) { const img = e.currentTarget as HTMLImageElement; + naturalWidth = img.naturalWidth; + naturalHeight = img.naturalHeight; dims = `${img.naturalWidth}×${img.naturalHeight}`; } function noteVideo(e: Event) { const v = e.currentTarget as HTMLVideoElement; + naturalWidth = v.videoWidth; + naturalHeight = v.videoHeight; dims = `${v.videoWidth}×${v.videoHeight}`; } @@ -205,10 +216,18 @@
{#if job && st}{#key mediaKey} -
+
{#if output && video} -
+
0 ? `${naturalWidth * fitted}px` : '100%'} + style:height={fitted > 0 ? `${naturalHeight * fitted}px` : '100%'} + > {#if st.tone === 'ok' && !fileGone}{t('jobst.done')}{/if} @@ -217,6 +236,12 @@ src={assetUrl(output)} controls aria-label="生成的视频,使用播放按钮开始播放" + onkeydown={(e) => { + if (e.code === 'Space' && e.target === e.currentTarget) { + e.preventDefault(); + togglePlayback(); + } + }} onplay={() => (paused = false)} onpause={() => (paused = true)} onended={() => (paused = true)} @@ -228,9 +253,21 @@ }} onerror={() => output && markOutputMissing(output)} > + {#if paused}{/if}
{:else if output} -
+
0 ? `${naturalWidth * fitted}px` : '100%'} + style:height={fitted > 0 ? `${naturalHeight * fitted}px` : '100%'} + > {#if st.tone === 'ok' && !fileGone}{t('jobst.done')}{/if} @@ -287,6 +324,10 @@
{/if}
+ {#if job && output}{/if} {#if video} +
@@ -128,14 +149,23 @@ >
+
- {filtered.length} 个可用工作流
- {#each filtered.slice(0, limit) as m (m.id)}
+ {pins.includes(m.id) ? '置顶工作流' : m.model_family || '本地工作流'} +
{/if} +
onPick(m.id)} + onclick={() => choose(m)} aria-pressed={selectedId === m.id} title={m.name} - >{kind === 'image' ? '◈' : '▷'}{m.model_family?.slice(0, 3) || (kind === 'image' ? '◈' : '▷')}{m.name}{m.name}{#if !runnable(m)}待配置 · 映射 / 测试 →{/if}{m.description || (m.usage ? usageLabel(m.usage) : '本地 ComfyUI')} .workflow-rail { - width: 232px; + width: var(--list); flex-shrink: 0; display: flex; flex-direction: column; min-height: 0; - background: #121216; + background: var(--bg-1); border-right: 1px solid var(--line); } .workflow-rail > header { @@ -218,34 +251,33 @@ } .kind-tabs { display: flex; - gap: 3px; - background: #09090c; - border: 1px solid var(--line); - border-radius: 9px; - padding: 3px; + gap: 4px; } .kind-tabs button { - flex: 1; - border: 0; - padding: 7px 3px; - background: none; - color: var(--text-3); - font-size: 12px; - border-radius: 6px; + padding: 5px 12px; + border: 1px solid var(--line-2); + border-radius: var(--r-pill); + background: var(--bg-2); + color: var(--tx-2); cursor: pointer; } .kind-tabs [aria-selected='true'] { - color: #062f35; - background: var(--accent); - font-weight: 600; + border-color: var(--scene); + color: var(--scene); + } + .kind-tabs button:disabled { + cursor: wait; + } + .kind-tabs button { + flex: 1; } .rail-tools input { width: 100%; margin-top: 10px; font-size: 11px; padding: 9px 10px; - background: #1d1b16; - border: 1px solid #f5b04228; + background: var(--well); + border: 1px solid var(--line-2); border-radius: 8px; color: var(--text); } @@ -264,6 +296,17 @@ font-size: 10px; color: var(--text-3); } + .runnable-filter { + display: flex; + align-items: center; + gap: 6px; + padding: 4px 14px; + font-size: 11px; + color: var(--tx-2); + } + .pending { + color: var(--warn); + } .list-meta { display: flex; justify-content: space-between; @@ -281,6 +324,11 @@ .list-meta button[aria-pressed='true'] { color: var(--warn); } + .family-label { + font-size: 11px; + color: var(--tx-3); + padding: 8px; + } .rail-list { flex: 1; min-height: 0; @@ -297,8 +345,12 @@ margin: 2px 0; } .workflow-row.selected { - border-color: #00cae039; - background: linear-gradient(120deg, #00cae016, #00cae003); + border-color: color-mix(in srgb, var(--scene) 35%, transparent); + background: linear-gradient( + 90deg, + color-mix(in srgb, var(--scene) 12%, var(--bg-3)), + var(--bg-3) + ); } .workflow-row.selected::before { content: ''; @@ -306,7 +358,7 @@ height: 22px; position: absolute; left: 0; - background: var(--accent); + background: var(--scene); border-radius: 3px; } .workflow-pick { @@ -330,9 +382,9 @@ height: 32px; flex-shrink: 0; border-radius: 9px; - border: 1px solid #00cae01f; - color: var(--accent); - background: #00cae008; + border: 1px solid var(--line-2); + color: var(--scene); + background: var(--bg-3); font-size: 16px; font-style: normal; } @@ -383,7 +435,7 @@ opacity: 1; } .workflow-row:hover { - background-color: #ffffff05; + background-color: var(--bg-2); } .empty { color: var(--text-3); @@ -434,7 +486,7 @@ } @media (max-width: 1100px) { .workflow-rail { - width: 210px; + width: var(--list); } } diff --git a/calliope-web/src/lib/components/desk/HistoryRail.svelte b/calliope-web/src/lib/components/desk/HistoryRail.svelte new file mode 100644 index 00000000..78b04c7c --- /dev/null +++ b/calliope-web/src/lib/components/desk/HistoryRail.svelte @@ -0,0 +1,159 @@ + + + + + diff --git a/calliope-web/src/lib/components/desk/StudioDesk.svelte b/calliope-web/src/lib/components/desk/StudioDesk.svelte index 8ae3cbe5..8a829325 100644 --- a/calliope-web/src/lib/components/desk/StudioDesk.svelte +++ b/calliope-web/src/lib/components/desk/StudioDesk.svelte @@ -1,4 +1,6 @@ -
+
{@render header?.()} {#if current} -
+

2 · 准备这次创作

{#if hasSlots && !dock}
{/if} - {#if dock}
+ {#if dock}
{#if hasSlots} - {#each ds.slots.slice(0, 4) as slot}+ 参考材料 {ds.lists.reduce( + (n, g) => + n + + (Array.isArray(values[g.nodeId]) + ? (values[g.nodeId] as string[]).length + : 0), + 0, + )}{/if} {:else}描述你的画面{/if} - Ctrl + Enter 生成 +
{/if}
{#if ds.prompt}
@@ -482,7 +499,7 @@ background: var(--surface); display: grid; gap: 10px; - box-shadow: 0 -8px 20px #292a2d05; + box-shadow: var(--sh-1); } .gen { width: 100%; @@ -520,24 +537,24 @@ } .dock { - height: 100%; + height: auto; min-height: 0; } .dock .composer { - height: 100%; + height: auto; min-height: 0; - border: 1px solid #f5b04246; - border-radius: 16px; - background: linear-gradient(125deg, #1b1a1d, #17161a); + border: 1px solid var(--line-2); + border-radius: var(--r-lg); + background: var(--composer-bg); overflow: hidden; - box-shadow: inset 0 0 0 1px #f5b04206; + box-shadow: var(--sh-2); } .dock .composer:focus-within { - border-color: #f5b04280; + border-color: var(--accent-line); } .dock .composer-scroll { padding: 9px 14px 4px; - overflow: hidden; + overflow: auto; display: flex; flex-direction: column; } @@ -557,11 +574,28 @@ display: flex; align-items: center; gap: 6px; - height: 28px; + min-height: 24px; margin-bottom: 6px; flex-shrink: 0; overflow: auto; } + + .dock .input-section.has-slots { + display: grid; + grid-template-columns: minmax(160px, 32%) minmax(0, 1fr); + gap: 12px; + } + .has-slots .media-summary { + align-self: start; + flex-wrap: wrap; + margin: 0; + padding-right: 10px; + border-right: 1px solid var(--line); + } + .has-slots .expand-prompt { + width: 100%; + text-align: right; + } .media-summary > small { color: var(--text-3); font-size: 10px; @@ -571,24 +605,30 @@ display: flex; align-items: center; gap: 5px; - background: #ffffff04; - border: 1px dashed #ffffff25; + background: var(--bg-2); + border: 1px dashed var(--line-3); border-radius: 6px; padding: 3px 7px; font-size: 10px; color: var(--text-2); cursor: pointer; white-space: nowrap; - height: 26px; + height: var(--slot-size); } .media-token img { - height: 20px; - width: 20px; + height: 36px; + width: 36px; object-fit: cover; border-radius: 3px; } - .input-shortcut { + .expand-prompt { margin-left: auto; + flex-shrink: 0; + border: 0; + background: none; + color: var(--tx-2); + font-size: 11px; + cursor: pointer; } .dock .prompt-pair { display: grid; @@ -618,7 +658,7 @@ gap: 6px; } .dock .prompt-field:first-child label { - color: #d0b37d; + color: var(--tx-2); } .dock .prompt { background: none; @@ -630,10 +670,13 @@ resize: none; min-height: 0; max-height: none; - height: 100%; - flex: 1; + height: var(--prompt-height); + flex: none; overflow: auto; } + .dock.expanded .prompt { + height: var(--prompt-expanded); + } .dock .prompt:focus { outline: 0; } @@ -668,12 +711,12 @@ overflow: auto; } .parameter { - background: #25242a; - border: 1px solid #ffffff07; + background: var(--bg-3); + border: 1px solid var(--line); border-radius: 7px; padding: 5px 8px; font-size: 10px; - color: #c4c2cb; + color: var(--tx-2); cursor: pointer; white-space: nowrap; max-width: 180px; @@ -682,7 +725,7 @@ flex-shrink: 0; } .parameter:hover { - border-color: #f5b04255; + border-color: var(--line-3); } .dock .gen { width: auto; @@ -692,10 +735,6 @@ border-radius: 20px; margin-left: auto; flex-shrink: 0; - background: linear-gradient(140deg, #ffc65e, #f5b042); - color: #30210c; - border-color: #edb458; - box-shadow: 0 3px 12px #f5b04217; } .dock .why { position: absolute; @@ -704,14 +743,14 @@ max-width: 65%; padding: 3px 8px; border-radius: 5px; - background: #241e17; + background: var(--bg-2); font-size: 10px; color: var(--warn); z-index: 1; } .dock .why.err { color: var(--err); - background: #241717; + background: var(--bg-2); } .dock .ready { display: none; diff --git a/calliope-web/src/lib/components/shell/AppShell.svelte b/calliope-web/src/lib/components/shell/AppShell.svelte index 6ee60a51..2fa4e359 100644 --- a/calliope-web/src/lib/components/shell/AppShell.svelte +++ b/calliope-web/src/lib/components/shell/AppShell.svelte @@ -156,7 +156,11 @@ ), ); const scene = $derived( - page.url.pathname === "/video" ? "video" : (currentNav?.scene ?? "manage"), + page.url.pathname === "/video" || + (page.url.pathname === "/create" && + page.url.searchParams.get("kind") === "video") + ? "video" + : (currentNav?.scene ?? "manage"), ); let themeOpen = $state(false); let focus = $state(false); diff --git a/calliope-web/src/lib/components/workflows/WorkflowLibrary.svelte b/calliope-web/src/lib/components/workflows/WorkflowLibrary.svelte index 9c321fad..cde8a9c0 100644 --- a/calliope-web/src/lib/components/workflows/WorkflowLibrary.svelte +++ b/calliope-web/src/lib/components/workflows/WorkflowLibrary.svelte @@ -1,4 +1,5 @@ + +{#key kind + page.url.search}{/key} diff --git a/calliope-web/src/routes/create/+page.ts b/calliope-web/src/routes/create/+page.ts index 8dd58fbb..a3d15781 100644 --- a/calliope-web/src/routes/create/+page.ts +++ b/calliope-web/src/routes/create/+page.ts @@ -1,9 +1 @@ -import { redirect } from "@sveltejs/kit"; export const ssr = false; -export const prerender = false; -// B0 compatibility gateway: existing working editors remain the destination -// until the unified editor is delivered in B1. Preserve all source context. -export function load({ url }: { url: URL }) { - const kind = url.searchParams.get("kind") === "video" ? "video" : "image"; - redirect(307, `/${kind}${url.search}${new URL(url.href).hash}`); -} diff --git a/calliope-web/src/routes/image/+page.svelte b/calliope-web/src/routes/image/+page.svelte index 2aa33335..183a2dfb 100644 --- a/calliope-web/src/routes/image/+page.svelte +++ b/calliope-web/src/routes/image/+page.svelte @@ -1,6 +1,12 @@ -{#key page.url.pathname + page.url.search}{/key} +

正在打开创作台…

diff --git a/calliope-web/src/routes/playground/+page.svelte b/calliope-web/src/routes/playground/+page.svelte index f8474c70..d594e9a2 100644 --- a/calliope-web/src/routes/playground/+page.svelte +++ b/calliope-web/src/routes/playground/+page.svelte @@ -1,41 +1,12 @@ -{#if failed} - -{:else} -
{t('playground.opening')}
-{/if} - - +

正在打开创作台…

diff --git a/calliope-web/src/routes/video/+page.svelte b/calliope-web/src/routes/video/+page.svelte index 98e6c3e1..c9ecb660 100644 --- a/calliope-web/src/routes/video/+page.svelte +++ b/calliope-web/src/routes/video/+page.svelte @@ -1,6 +1,12 @@ -{#key page.url.pathname + page.url.search}{/key} +

正在打开创作台…

diff --git a/calliope-web/static/themes.css b/calliope-web/static/themes.css index e955055b..983f0276 100644 --- a/calliope-web/static/themes.css +++ b/calliope-web/static/themes.css @@ -44,6 +44,9 @@ --fs-body: 13px; --fs-small: 12px; --fs-micro: 11px; + --slot-size: 56px; + --prompt-height: 64px; + --prompt-expanded: 28vh; /* space (4 base) */ --s1: 4px; --s2: 8px; @@ -545,3 +548,10 @@ html { background: var(--bg-0); color: var(--tx-1); } + +@media (max-height: 800px) { + :root { + --slot-size: 50px; + --prompt-height: 48px; + } +} diff --git a/docs/changelogs/2026-10-21-hel729-creative-desk.md b/docs/changelogs/2026-10-21-hel729-creative-desk.md new file mode 100644 index 00000000..4d0b54eb --- /dev/null +++ b/docs/changelogs/2026-10-21-hel729-creative-desk.md @@ -0,0 +1,26 @@ +# HEL-729 · B1 夜片场创作台 + +从 B0 已审核、已部署的 `8e84e4c21f22febe7bd4f20777d06fa1d4914e8e` 继续,目标分支为 `feat/hel-728-night-foundation`。本批只改创作台与其兼容入口,不改生成 API、数据库、连接配置与项目确认语义,不部署。 + +## 实现 + +- `/create` 成为真实创作台,内部切换图片/视频。旧 `/image`、`/video`、`/playground` 保留所有查询参数与片段转入创作台。 +- 复用 StudioDesk、Composer、SlotTray、ParamsPanel、ParameterPopover、TakeFeed 和真实生成/回流 API。工作流列采用 B0 的 `--list`,搜索、用途/模型筛选、收藏与置顶保留;仅可运行默认开启,关闭后待配置项进入对应的真实映射/测试管理行。 +- 草稿按媒介和来源分别保存,内部工作流独立记忆输入及连接。首次切换工作流延续正反向提示词;显式深链优先于不同请求的缓存,同一请求刷新恢复用户的选择,异步详情与列表加载顺序不改变归属。存储受限时保留同标签页内存回退。 +- 素材与提示词并排放在底座,长提示词可展开并内部滚动。参数弹层沿用草稿取消/保存。槽尺寸与提示词高度统一归入 `static/themes.css`,四套主题继续可用。 +- 历史是独立右栏,可收起。回看只改变观看对象,显示历史状态条;返回当前创作保留原输入。主动复用才回填,跨媒介复用切到对应编辑器,并恢复生成时使用的可用连接。 +- 作品按容器实测尺寸与媒体宽高计算 contain 大小。视频无自动播放,中央大播放键清晰,空格仅在视频自身获得焦点时控制;切换结果暂停。下载、取消、重试、详情及已有来源项目回流均沿用原链路。 + +## 自测与证据 + +`e2e/run-hel729.py` 在前台启动并收集隔离 FastAPI、假 ComfyUI、假 LLM 与真实前端,结束清理子进程。测试断言数据目录为本次隔离目录,未读取用户数据或调用收费服务。测试输入、遮罩、首尾帧、多参考素材通过真实上传、队列与输出保存链路;来源结果通过原 attach-workbench 接口显式回流。 + +浏览器脚本 `e2e/hel729.cjs` 检查三档桌面(1440×900、1366×768、1280×720)、横竖图完整显示、首屏主操作、手动播放、图视频与工作流输入归属、刷新、主题切换、参数取消/保存、历史与复用、失败重试、取消和存储受限。PNG 和 results.json 通过任务卡附件交付;样图均为隔离测试内容。 + +相关后端回归:`test_image_input_workflows.py`、`test_playground_uploads.py`、`test_playground_workbench_flowback.py`、`test_playground.py` 共 47 项通过。前端 `npm run check`、`npm run test`、`npm run build` 通过;类型检查保留基线 ShotStagePanel 视频字幕警告一条。 + +全仓 `npm run lint` 仍被既有格式问题阻断,直接 ESLint 也因仓库缺少 eslint.config 配置退出;这两项为 B0 既有工具链问题,不在本批扩面格式化或新增依赖。本批主要组件另做 Prettier 定向检查和 `git diff --check`。 + +## 尚未覆盖 + +未连接真实 ComfyUI/GPU 或云模型做付费生成,未部署测试站。通用“选择项目/阶段存入”和智能体任务成果能力仍按分批安排留给 B4。本批交总工独立对照样本审核,不能以自动化数量替代视觉验收。 diff --git a/e2e/hel729.cjs b/e2e/hel729.cjs new file mode 100644 index 00000000..7ed1a16d --- /dev/null +++ b/e2e/hel729.cjs @@ -0,0 +1,592 @@ +// B1 integration: disposable FastAPI -> fake ComfyUI, real UI and HTTP only. +const { chromium } = require( + process.env.PLAYWRIGHT_MODULE_PATH || "playwright", +); +const assert = require("node:assert/strict"), + fs = require("node:fs"), + path = require("node:path"); +const API = "http://127.0.0.1:8247", + WEB = "http://127.0.0.1:5173", + out = "e2e/shots/hel729"; +fs.mkdirSync(out, { recursive: true }); +const f = JSON.parse(fs.readFileSync("e2e/shots/reference/fixtures.json")); +const checks = []; +function ok(n, v) { + assert(v, n); + checks.push(n); + console.log("PASS", n); +} +async function api(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, + }); + if (!r.ok) throw Error(p + " " + (await r.text())); + return r.json(); +} +async function wait(fn) { + for (let i = 0; i < 120; i++) { + const v = await fn(); + if (v) return v; + await new Promise((r) => setTimeout(r, 150)); + } + throw Error("condition timeout"); +} +function fixtureDimensions(file) { + const b = fs.readFileSync(file); + return b.readUInt32BE(16) === 480 && b.readUInt32BE(20) === 720; +} +function mode(video = false, extra = {}) { + fs.writeFileSync( + "e2e/fake_mode.json", + JSON.stringify({ + mode: "ok", + delay_sec: 0, + unique_filename: true, + videos: video, + image_file: path.resolve("calliope-web/static/refract/coast.jpg"), + ...extra, + }), + ); +} +async function submit(p, wf, video = false) { + mode(video); + const before = Math.max( + 0, + ...(await api("/api/playground/jobs")).map((j) => j.id), + ); + await p.locator(".gen").click(); + const j = await wait(async () => + (await api("/api/playground/jobs")).find( + (j) => j.id > before && j.workflow_id === wf && j.status === "done", + ), + ); + await p + .locator(".result-title") + .filter({ hasText: "第 " + j.id + " 次生成" }) + .waitFor(); + return j; +} +async function shot(p, name) { + await p.screenshot({ path: out + "/" + name + ".png" }); +} +(async () => { + const browser = await chromium.launch({ + executablePath: process.env.CHROMIUM_EXECUTABLE_PATH, + args: ["--no-sandbox", "--disable-dev-shm-usage"], + }); + const errors = []; + let p; + const saved = fs.existsSync("e2e/fake_mode.json") + ? fs.readFileSync("e2e/fake_mode.json") + : null; + try { + const c = await browser.newContext({ + viewport: { width: 1440, height: 900 }, + }); + p = await c.newPage(); + p.setDefaultTimeout(15000); + p.on("pageerror", (e) => errors.push(e.message)); + await p.goto(WEB + "/image?wf=" + f.dual + "#source"); + await p.locator("#desk-negative").waitFor(); + ok( + "旧生图入口进入统一创作台并保留工作流和片段", + new URL(p.url()).pathname === "/create" && + new URL(p.url()).hash === "#source" && + new URL(p.url()).searchParams.get("wf") === String(f.dual), + ); + await p.locator("#desk-prompt").fill("一位女孩站在海岸,清晨柔光。"); + await p.locator("#desk-negative").fill("模糊、畸形"); + await p.locator(".rail-tools input[type=search]").fill("Qwen"); + ok("检索工作流", (await p.locator(".workflow-pick").count()) === 1); + await p.locator(".row-actions button").first().click(); + ok( + "置顶保留输入", + (await p.locator("#desk-prompt").inputValue()) === + "一位女孩站在海岸,清晨柔光。", + ); + await p + .getByRole("button", { name: "创作设置", exact: true }) + .first() + .click(); + await p.locator(".modal-panel input[type=number]").first().fill("99"); + await p.getByRole("button", { name: "取消", exact: true }).click(); + await p + .getByRole("button", { name: "创作设置", exact: true }) + .first() + .click(); + ok( + "参数取消不保存", + (await p + .locator(".modal-panel input[type=number]") + .first() + .inputValue()) === "42", + ); + await p.locator(".modal-panel input[type=number]").first().fill("101"); + await p.getByRole("button", { name: "保存设置", exact: true }).click(); + const j = await submit(p, f.dual); + ok( + "真实图片生成写入正反向和参数", + JSON.stringify(j.payload).includes("101") && + JSON.stringify(j.payload).includes("模糊、畸形"), + ); + await p.locator("#desk-prompt").fill("这是当前输入,不随历史变化"); + await p + .locator(".history-take") + .filter({ hasText: "#" + j.id }) + .click(); + ok( + "历史状态明确且不修改输入", + (await p.locator(".history-banner").isVisible()) && + (await p.locator("#desk-prompt").inputValue()) === + "这是当前输入,不随历史变化", + ); + await shot(p, "1440-history"); + await p.getByRole("button", { name: "返回当前创作", exact: true }).click(); + await p.getByRole("button", { name: "展开提示词", exact: true }).click(); + await p.locator("#desk-prompt").fill("长提示词描述。".repeat(600)); + ok( + "长提示词可展开且生成键可见", + (await p.locator("#desk-prompt").evaluate((e) => e.clientHeight > 180)) && + (await p.locator(".gen").isVisible()), + ); + await shot(p, "1440-long-prompt"); + await p.getByRole("button", { name: "收起提示词", exact: true }).click(); + await p.locator("#desk-prompt").fill("这是当前输入,不随历史变化"); + // Previously unseen workflow inherits semantic prompts, returning restores its own parameters. + await p.locator(".rail-tools input[type=search]").fill("文生图"); + await p + .locator(".workflow-pick") + .filter({ hasText: "文生图·回归" }) + .click(); + await p.waitForFunction(() => !document.querySelector("#desk-negative")); + ok( + "切换新工作流保留提示词", + (await p.locator("#desk-prompt").inputValue()) === + "这是当前输入,不随历史变化", + ); + await p.reload(); + await p.locator("#desk-prompt").waitFor(); + ok( + "刷新恢复切换后的工作流及草稿", + (await p + .locator(".model-selector") + .innerText() + .then((s) => s.includes("文生图"))) && + (await p.locator("#desk-prompt").inputValue()) === + "这是当前输入,不随历史变化", + ); + await p.locator(".rail-tools input[type=search]").fill("Qwen"); + await p.locator(".workflow-pick").first().click(); + await p + .getByRole("button", { name: "创作设置", exact: true }) + .first() + .click(); + ok( + "切回工作流恢复独立参数", + (await p + .locator(".modal-panel input[type=number]") + .first() + .inputValue()) === "101", + ); + await p.keyboard.press("Escape"); + await p.getByRole("tab", { name: "视频", exact: true }).click(); + await p.locator("#desk-prompt").waitFor(); + await p.locator(".rail-tools input[type=search]").fill("H3"); + await p.locator(".workflow-pick").first().click(); + await p.locator("#desk-prompt").fill("视频独立草稿"); + await p.getByRole("button", { name: "管理素材", exact: true }).click(); + const first = p + .locator(".a-slot") + .filter({ has: p.getByText("首帧", { exact: true }) }), + last = p + .locator(".a-slot") + .filter({ has: p.getByText("尾帧", { exact: true }) }); + await first + .locator("input[type=file]") + .setInputFiles("calliope-web/static/refract/coast.jpg"); + await first.locator("img").waitFor(); + await last + .locator("input[type=file]") + .setInputFiles("calliope-web/static/refract/arch.jpg"); + await last.locator("img").waitFor(); + await p.getByRole("button", { name: "完成", exact: true }).click(); + await p.getByRole("tab", { name: "图片", exact: true }).click(); + await p.locator("#desk-negative").waitFor(); + ok( + "图视频切换恢复图片草稿", + (await p.locator("#desk-prompt").inputValue()) === + "这是当前输入,不随历史变化", + ); + await p.getByRole("tab", { name: "视频", exact: true }).click(); + await p.locator("#desk-prompt").waitFor(); + ok( + "图视频切换恢复视频工作流和上传素材", + (await p.locator("#desk-prompt").inputValue()) === "视频独立草稿" && + (await p.locator(".media-token img").count()) === 2, + ); + const v = await submit(p, f.h3, true); + const player = p.locator(".print video"); + await player.waitFor(); + await wait(() => player.evaluate((e) => e.readyState >= 1)); + ok( + "视频结果默认暂停", + await player.evaluate((e) => e.paused && !e.autoplay), + ); + await p.locator("#desk-prompt").focus(); + await p.keyboard.press("Space"); + ok("提示词中的空格不播放视频", await player.evaluate((e) => e.paused)); + await p.getByRole("button", { name: "播放此视频", exact: true }).click(); + await wait(() => player.evaluate((e) => !e.paused)); + await player.focus(); + await p.keyboard.press("Space"); + await wait(() => player.evaluate((e) => e.paused)); + ok("中央大播放键与聚焦空格可控", true); + for (const [width, height] of [ + [1440, 900], + [1366, 768], + [1280, 720], + ]) { + await p.setViewportSize({ width, height }); + await p.waitForTimeout(150); + ok( + width + " 视频完整且主按钮首屏", + (await p.locator(".gen").evaluate((e) => { + const r = e.getBoundingClientRect(); + return r.top >= 0 && r.bottom <= innerHeight && r.right <= innerWidth; + })) && + (await player.evaluate( + (e) => getComputedStyle(e).objectFit === "contain", + )), + ); + await shot(p, width + "-video"); + } + // Cross-medium reuse from the complete history switches the editor kind explicitly. + await p.getByRole("button", { name: "◷ 生成历史", exact: true }).click(); + await p + .locator(".history-full .a-seg") + .getByRole("button", { name: "全部", exact: true }) + .click(); + await p + .locator('.history-full [data-job="' + j.id + '"]') + .getByRole("button", { name: "复用", exact: true }) + .click(); + await p + .getByRole("tab", { name: "图片", exact: true, selected: true }) + .waitFor(); + await p.locator("#desk-negative").waitFor(); + ok( + "跨媒介历史复用切到所属类型并回填", + (await p.locator("#desk-prompt").inputValue()) === + "一位女孩站在海岸,清晨柔光。", + ); + await p.getByRole("tab", { name: "视频", exact: true }).click(); + await p.locator(".print video").waitFor(); + // Theme switches preserve the same editor node and state. + await p.locator("#desk-prompt").evaluate((e) => (window.__editor = e)); + for (const [id, name] of [ + ["night", "夜片场"], + ["day", "日光棚"], + ["grade", "调色间"], + ["bluehour", "蓝调时刻"], + ]) { + await p.getByRole("button", { name: "主题", exact: true }).click(); + await p.locator(".choice").filter({ hasText: name }).click(); + await p.keyboard.press("Escape"); + ok( + id + " 切换保持编辑和素材", + (await p + .locator("#desk-prompt") + .evaluate((e) => e === window.__editor)) && + (await p.locator(".media-token img").count()) === 2, + ); + await shot(p, "1280-theme-" + id); + } + + // Import minimal API fixtures with the same parser roles used by user workflows. + const base = await api("/api/workflows/" + f.dual); + const maskBody = { + ...base.workflow_json, + 5: { + class_type: "LoadImage", + inputs: { image: "" }, + _meta: { title: "(Input:image) 输入图" }, + }, + 6: { + class_type: "LoadImageMask", + inputs: { image: "", channel: "red" }, + _meta: { title: "(Input:mask) 遮罩" }, + }, + }; + const maskWf = await api("/api/workflows", { + name: "遮罩图生图测试", + kind: "image", + workflow_json: maskBody, + }); + await p.goto(WEB + "/create?wf=" + maskWf.id); + await p.locator("#desk-prompt").waitFor(); + await p.getByRole("button", { name: "管理素材", exact: true }).click(); + const imageSlots = p.locator(".a-slot"); + ok("图生图与遮罩独立槽位", (await imageSlots.count()) === 2); + for (let i = 0; i < 2; i++) { + await imageSlots + .nth(i) + .locator("input[type=file]") + .setInputFiles("calliope-web/static/refract/coast.jpg"); + await imageSlots.nth(i).locator("img").waitFor(); + } + await p.getByRole("button", { name: "完成", exact: true }).click(); + const masked = await submit(p, maskWf.id); + ok( + "上传和遮罩经真实队列生成", + !!masked.payload.input_values["5"] && !!masked.payload.input_values["6"], + ); + const refBody = { + 1: { + class_type: "CLIPTextEncode", + inputs: { text: "多参考视频" }, + _meta: { title: "提示词" }, + }, + 5: { class_type: "LoadImage", inputs: { image: "" } }, + 7: { + class_type: "MiniMaxH3ReferenceToVideo", + inputs: { "ref_images.ref_image_0": ["5", 0] }, + }, + 9: { + class_type: "VHS_VideoCombine", + inputs: { images: "x" }, + _meta: { title: "(Output:video) 视频输出" }, + }, + }; + const refWf = await api("/api/workflows", { + name: "H3多参考测试", + kind: "video", + workflow_json: refBody, + }); + await p.goto(WEB + "/create?kind=video&wf=" + refWf.id); + await p.locator("#desk-prompt").waitFor(); + await p.getByRole("button", { name: "管理素材", exact: true }).click(); + await p + .locator(".media-editor input[type=file]") + .first() + .setInputFiles([ + "calliope-web/static/refract/coast.jpg", + "calliope-web/static/refract/arch.jpg", + ]); + await wait( + async () => (await p.locator(".media-editor img").count()) === 2, + ); + await p.getByRole("button", { name: "完成", exact: true }).click(); + const refs = await submit(p, refWf.id, true); + ok( + "多参考素材真实上传并生成", + Array.isArray(refs.payload.input_values["7@ref_images"]) && + refs.payload.input_values["7@ref_images"].length === 2, + ); + await shot(p, "1280-multi-reference"); + // Preserve failure, retry and cancellation paths. + await p.goto(WEB + "/create?wf=" + f.dual); + await p.locator("#desk-prompt").waitFor(); + mode(false, { mode: "fail" }); + await p.locator(".gen").click(); + const failed = await wait(async () => + (await api("/api/playground/jobs")).find( + (j) => j.workflow_id === f.dual && j.status === "failed", + ), + ); + await p.getByRole("button", { name: "重试", exact: true }).waitFor(); + mode(); + await p.getByRole("button", { name: "重试", exact: true }).click(); + await wait(async () => + (await api("/api/playground/jobs")).some( + (j) => j.id === failed.id && j.status === "done", + ), + ); + ok("失败后重试取得真实结果", true); + mode(false, { delay_sec: 10 }); + await p.locator(".gen").click(); + await p.getByRole("button", { name: "取消生成", exact: true }).waitFor(); + await p.getByRole("button", { name: "取消生成", exact: true }).click(); + await wait(async () => + (await api("/api/playground/jobs")).some( + (j) => + j.workflow_id === f.dual && + j.status === "failed" && + j.error === "cancelled", + ), + ); + ok("进行中生成可以取消", true); + mode(); + // Pending rows are hidden by default and point to the actual mapping/testing row. + const w = await api("/api/workflows/" + f.dual); + const pending = await api("/api/workflows", { + name: "待配置测试工具", + kind: "image", + workflow_json: w.workflow_json, + }); + await api("/api/workflows/" + pending.id, { is_enabled: false }, "PATCH"); + await p.goto(WEB + "/create"); + await p.locator("#desk-prompt").waitFor(); + await p.locator(".rail-tools input[type=search]").fill("待配置测试"); + ok("默认仅显示可运行", (await p.locator(".workflow-pick").count()) === 0); + await p.getByRole("checkbox", { name: "仅可运行" }).uncheck(); + await p + .locator(".workflow-pick") + .filter({ hasText: "待配置测试工具" }) + .click(); + await p.waitForURL("**/workflows?manage=1&wf=" + pending.id); + ok( + "待配置导向真实管理和测试入口", + await p.locator(".wf-test-btn").first().isVisible(), + ); + // Source project round-trip uses original API and explicit attachment. + await api( + "/api/workbench/projects/" + f.project + "/stages/assets", + { + content: { + assets: [ + { + id: "b1-source", + kind: "character", + name: "海边女孩", + prompt: "源项目提示词", + description: "", + }, + ], + }, + }, + "PATCH", + ); + const q = new URLSearchParams({ + kind: "image", + wf: String(f.dual), + from: "workbench", + project: String(f.project), + stage: "assets", + source_kind: "asset", + source_id: "b1-source", + label: "海边女孩", + prompt: "源项目提示词", + }); + await p.goto(WEB + "/create?" + q); + await p.locator("#desk-prompt").waitFor(); + const flow = await submit(p, f.dual); + ok( + "来源随真实生成请求发送", + flow.payload?.workbench_context?.source_id === "b1-source" || + JSON.stringify(flow.payload).includes("b1-source"), + ); + await p + .getByRole("button", { name: "保存到来源项目 ↗", exact: true }) + .click(); + await wait(async () => + JSON.stringify( + await api("/api/workbench/projects/" + f.project), + ).includes(flow.output_paths[0]), + ); + ok("结果显式回流来源项目", true); + // Create a deterministic portrait fixture locally (no external image request). + const fixture = await c.newPage(); + await fixture.setViewportSize({ width: 480, height: 720 }); + await fixture.setContent( + '
', + ); + await fixture.screenshot({ path: out + "/portrait.png" }); + await fixture.close(); + ok("竖图测试素材为480×720", await fixtureDimensions(out + "/portrait.png")); + // Portrait & landscape outputs in the real application. + for (const file of ["coast.jpg", "portrait.png"]) { + const img = path.resolve( + file === "portrait.png" + ? out + "/portrait.png" + : "calliope-web/static/refract/" + file, + ); + mode(false, { image_file: img }); + const before = Math.max( + ...(await api("/api/playground/jobs")).map((j) => j.id), + ); + await p.locator(".gen").click(); + const image = await wait(async () => + (await api("/api/playground/jobs")).find( + (j) => j.id > before && j.status === "done", + ), + ); + await p + .locator(".result-title") + .filter({ hasText: "第 " + image.id + " 次生成" }) + .waitFor(); + await p.locator(".print img").waitFor(); + for (const [width, height] of [ + [1440, 900], + [1366, 768], + [1280, 720], + ]) { + await p.setViewportSize({ width, height }); + await p.waitForTimeout(120); + ok( + width + " " + file + " 媒体比例正确", + await p.locator(".print img").evaluate((e) => { + const r = e.getBoundingClientRect(); + return ( + Math.abs(r.width / r.height - e.naturalWidth / e.naturalHeight) < + 0.02 && + r.bottom <= innerHeight && + r.top >= 0 + ); + }), + ); + await shot(p, width + "-" + file); + } + } + await p.goto( + WEB + "/playground?kind=video&wf=" + f.h3 + "&from=test#legacy", + ); + await p.locator("#desk-prompt").waitFor(); + ok( + "试验台兼容入口保留参数及片段", + new URL(p.url()).pathname === "/create" && + new URL(p.url()).searchParams.get("from") === "test" && + new URL(p.url()).hash === "#legacy", + ); + const blocked = await browser.newContext({ + viewport: { width: 1280, height: 720 }, + }); + await blocked.addInitScript(() => { + Storage.prototype.getItem = function () { + throw Error("denied"); + }; + Storage.prototype.setItem = function () { + throw Error("denied"); + }; + }); + const bp = await blocked.newPage(); + await bp.goto(WEB + "/create?wf=" + f.dual); + await bp.locator("#desk-prompt").fill("存储禁止也保留草稿"); + await bp.getByRole("tab", { name: "视频", exact: true }).click(); + await bp.locator("#desk-prompt").fill("视频草稿"); + await bp.getByRole("tab", { name: "图片", exact: true }).click(); + await bp.locator("#desk-negative").waitFor(); + ok( + "浏览器存储被禁止时仍可切换并恢复草稿", + (await bp.locator("#desk-prompt").inputValue()) === "存储禁止也保留草稿", + ); + await blocked.close(); + ok("没有浏览器运行异常", errors.length === 0); + fs.writeFileSync( + out + "/results.json", + JSON.stringify({ checks, errors }, null, 2), + ); + } catch (e) { + if (p) await shot(p, "failure"); + throw e; + } finally { + if (saved) fs.writeFileSync("e2e/fake_mode.json", saved); + else if (fs.existsSync("e2e/fake_mode.json")) + fs.unlinkSync("e2e/fake_mode.json"); + await browser.close(); + } +})().catch((e) => { + console.error(e); + process.exitCode = 1; +}); diff --git a/e2e/run-hel729.py b/e2e/run-hel729.py new file mode 100644 index 00000000..c75b25df --- /dev/null +++ b/e2e/run-hel729.py @@ -0,0 +1,44 @@ +"""Run the isolated regression stack in the foreground and always stop children.""" +import os, signal, socket, subprocess, time, urllib.request +from pathlib import Path +root=Path(__file__).resolve().parents[1] +os.chdir(root) +py=str(root/'calliope-backend/.venv/bin/python') +logs=root/'e2e/shots/hel729'; logs.mkdir(parents=True,exist_ok=True) +env=os.environ.copy() +env['PYTHONPATH']=str(root/'calliope-backend/src') +for port in [8247,5173,18188,18189]: + with socket.socket() as probe: + assert probe.connect_ex(('127.0.0.1',port)) != 0, f'Test port {port} is already in use' +# Every run gets fresh disposable state; retain prior evidence instead of deleting it. +reference=root/'e2e/shots/reference' +if reference.exists():reference.rename(reference.with_name('reference.previous-'+str(time.time_ns()))) +processes=[]; handles=[] +try: + for name,cmd in [('api',[py,'e2e/reference/server.py']),('comfy',[py,'e2e/fake_comfy.py']),('llm',[py,'e2e/reference/fake_llm.py']),('web',['npm','--prefix',os.environ.get('HEL729_WEB_DIR','calliope-web'),'run','dev'])]: + h=open(logs/(name+'.log'),'w');handles.append(h);processes.append(subprocess.Popen(cmd,stdout=h,stderr=subprocess.STDOUT,env=env,start_new_session=True)) + for url in ['http://127.0.0.1:8247/api/health','http://127.0.0.1:5173']: + for i in range(100): + try: + urllib.request.urlopen(url,timeout=2); break + except Exception: + if any(p.poll() is not None for p in processes): raise RuntimeError('A test service failed') + if i==99:raise + time.sleep(.2) + for cmd in [[py,'e2e/seed.py'],[py,'e2e/reference/seed.py']]:subprocess.run(cmd,env=env,check=True) + failures=[] + scripts=os.environ.get('HEL729_TESTS','e2e/hel729.cjs').split(',') + for script in scripts: + print('RUN',script,flush=True) + result=subprocess.run(['node',script],env=env) + if result.returncode:failures.append(script) + print('FAILED SCRIPTS',failures,flush=True) + if failures:raise SystemExit(1) + +finally: + for p in processes: + if p.poll() is None:os.killpg(p.pid,signal.SIGTERM) + for p in processes: + try:p.wait(timeout=10) + except subprocess.TimeoutExpired:os.killpg(p.pid,signal.SIGKILL);p.wait() + for h in handles:h.close() -- 2.54.0 From 5cb75d5718052a6678c8e32c66fba45144ea50e3 Mon Sep 17 00:00:00 2001 From: leefer Date: Wed, 21 Oct 2026 21:54:09 +0800 Subject: [PATCH 2/6] HEL-740: restore approved creative desk dock and anchored controls Co-authored-by: multica-agent --- .../src/lib/components/desk/Artboard.svelte | 186 +++++--- .../components/desk/DeskWorkflowRail.svelte | 52 +- .../lib/components/desk/HistoryRail.svelte | 18 +- .../src/lib/components/desk/StudioDesk.svelte | 91 +++- .../lib/components/generate/Composer.svelte | 96 ++-- .../src/lib/components/ui/Modal.svelte | 72 ++- calliope-web/src/lib/components/ui/README.md | 34 +- calliope-web/static/themes.css | 44 +- .../2026-10-21-hel740-creative-desk-rework.md | 27 ++ e2e/hel729.cjs | 8 +- e2e/hel740.cjs | 443 ++++++++++++++++++ 11 files changed, 890 insertions(+), 181 deletions(-) create mode 100644 docs/changelogs/2026-10-21-hel740-creative-desk-rework.md create mode 100644 e2e/hel740.cjs diff --git a/calliope-web/src/lib/components/desk/Artboard.svelte b/calliope-web/src/lib/components/desk/Artboard.svelte index fcdf7725..ab525544 100644 --- a/calliope-web/src/lib/components/desk/Artboard.svelte +++ b/calliope-web/src/lib/components/desk/Artboard.svelte @@ -59,6 +59,9 @@ onCancel: (job: Job) => void; onSendToSlot: (path: string) => void; onDelete: (job: Job) => void; + canAttach?: boolean; + attachBusy?: boolean; + onAttach?: () => void; } let { @@ -80,6 +83,9 @@ onCancel, onSendToSlot, onDelete, + canAttach = false, + attachBusy = false, + onAttach, }: Props = $props(); let detailsOpen = $state(false); @@ -190,29 +196,6 @@
第 {job?.id} 次生成{wf?.name ?? '历史作品'}
-
- {#if output && !video} - {#if compareSource && compareSource !== output}{/if} - {/if} - {#if output}↓ 下载{/if} - -
{#if job && st}{#key mediaKey} @@ -324,23 +307,64 @@
{/if}
- {#if job && output}{/if} - {#if video}{/if} - {dims || st?.label} · {job ? fmtDur(elapsedSec(job, nowMs)) : ''} + video + ? videoEl?.requestFullscreen().catch(() => {}) + : (lightbox = true)} + aria-label="放大查看">放大 + 下载 + + + + + 素材库 + + {#if job?.status === 'pending' || job?.status === 'running'}{/if} + {#if st?.tone === 'fail'}{/if} {#if compare && output && !video}{/if} - {#if job?.status === 'pending' || job?.status === 'running'}{/if} - {#if st?.tone === 'fail'}{/if}
{#if job && st}
+ {#if video}{/if} + {dims || st?.label} · {job ? fmtDur(elapsedSec(job, nowMs)) : ''} + {#if compare && output && !video}{/if} + {#if output && !video && compareSource && compareSource !== output}{/if}