Compare commits

...
Author SHA1 Message Date
leeferandmultica-agent 30851d57f1 HEL-731: align studio and five-stage project with approved sample
Co-authored-by: multica-agent <github@multica.ai>
2026-10-22 08:34:59 +08:00
leeferandmultica-agent a1c01f1e90 docs(HEL-771): record raw pixel results and verification boundary
Co-authored-by: multica-agent <github@multica.ai>
2026-10-22 07:45:14 +08:00
leeferandmultica-agent fb6139d45c test(HEL-771): wait for sample border transition before capture
Co-authored-by: multica-agent <github@multica.ai>
2026-10-22 07:43:33 +08:00
leeferandmultica-agent af0417e874 fix(HEL-771): align theme tile hover tone and badge spacing
Co-authored-by: multica-agent <github@multica.ai>
2026-10-22 07:42:46 +08:00
leeferandmultica-agent b2bcbc10df fix(HEL-769): restore theme tile preview details from sample
Co-authored-by: multica-agent <github@multica.ai>
2026-10-22 07:07:44 +08:00
leeferandmultica-agent 2004f527cc fix(HEL-767): align management header, theme cards and shared rail with sample
Co-authored-by: multica-agent <github@multica.ai>
2026-10-22 06:38:32 +08:00
leeferandmultica-agent dca88b3765 fix(HEL-751): restore confirmed full-width task table
Co-authored-by: multica-agent <github@multica.ai>
2026-10-22 05:18:07 +08:00
45 changed files with 7705 additions and 4207 deletions
@@ -165,6 +165,7 @@
<h3>2 · 准备这次创作</h3>
{#if hasSlots && !dock}<div class="slots">
<SlotTray
allowScene={current?.kind === 'video'}
bind:this={tray}
bind:busy={slotBusy}
{ds}
@@ -374,6 +375,7 @@
>
<div class="media-editor">
<SlotTray
allowScene={current?.kind === 'video'}
bind:this={tray}
bind:busy={slotBusy}
{ds}
@@ -0,0 +1,110 @@
<script lang="ts">
import { resolve } from "$app/paths";
import { assetUrl } from "$lib/api";
import { shotApi, type Capture } from "$lib/shot/api";
import Modal from "$lib/components/ui/Modal.svelte";
import { toast } from "$lib/toast";
let {
open = $bindable(false),
onPick,
}: { open?: boolean; onPick: (path: string) => void } = $props();
let compositions = $state<Array<{ id: number; title: string }>>([]);
let compositionId = $state("");
let captures = $state<Capture[]>([]);
let loading = $state(false);
let request = 0;
$effect(() => {
if (!open) {
request++;
return;
}
const version = ++request;
loading = true;
shotApi
.listCompositions()
.then((rows) => {
if (version !== request) return;
compositions = rows;
compositionId = rows.length ? String(rows[0].id) : "";
})
.catch((error) => toast.error(String(error)))
.finally(() => {
if (version === request) loading = false;
});
});
$effect(() => {
if (!open || !compositionId) {
captures = [];
return;
}
const id = Number(compositionId);
let active = true;
captures = [];
shotApi.listCaptures(id).then((rows) => {
if (active)
captures = rows.filter((c) => c.kind === "image" && c.file_path);
});
return () => {
active = false;
};
});
</script>
<Modal bind:open title="从三维搭景截取">
<p>选择已保存的搭景截图,填入当前工作流声明的图片槽。</p>
<a
class="a-btn secondary sm"
href={resolve("/build-scene")}
target="_blank"
rel="noopener">打开三维搭景</a
>
{#if loading}<p>正在读取截图…</p>{:else if !compositions.length}<p>
暂无搭景,请先保存截图。
</p>{:else}
<label class="field"
><span class="field-label">场景</span><select
class="field-input"
bind:value={compositionId}
>{#each compositions as comp (comp.id)}<option value={String(comp.id)}
>{comp.title}</option
>{/each}</select
></label
>
<div class="capture-choices">
{#each captures as capture (capture.id)}<button
class="a-btn secondary"
onclick={() => {
if (capture.file_path) {
onPick(capture.file_path);
open = false;
}
}}
><img
src={assetUrl(capture.file_path) ?? ""}
alt={capture.label ?? "搭景截图"}
/><span>{capture.label ?? `截图 ${capture.id}`}</span></button
>{:else}<p>该场景暂无图片截图。</p>{/each}
</div>{/if}
</Modal>
<style>
.capture-choices {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: var(--sp-4);
margin-top: var(--sp-5);
}
.capture-choices button {
display: flex;
flex-direction: column;
padding: var(--sp-3);
height: auto;
white-space: normal;
}
.capture-choices img {
width: 100%;
aspect-ratio: 16/9;
object-fit: contain;
}
</style>
@@ -1,4 +1,5 @@
<script lang="ts">
import SceneCapturePicker from './SceneCapturePicker.svelte';
import MediaSlot from '$lib/components/studio/MediaSlot.svelte';
import RefImageGrid from '$lib/components/studio/RefImageGrid.svelte';
import { mediaFileProblem } from '$lib/comfy/imageFile';
@@ -16,6 +17,7 @@
*/
interface Props {
ds: DeskSchema;
allowScene?: boolean;
values: FormValues;
showErrors: boolean;
errors: Record<string, string>;
@@ -25,7 +27,34 @@
onFromResults: (nodeId: string) => void;
}
let { ds, values, showErrors, errors, busy = $bindable(false), onSet, onFromResults }: Props = $props();
let { ds, allowScene = false, values, showErrors, errors, busy = $bindable(false), onSet, onFromResults }: Props = $props();
let sceneOpen = $state(false);
let sceneNode = $state("");
$effect(() => {
void ds;
sceneOpen = false;
sceneNode = "";
});
function pickScene(path: string) {
const slot = ds.slots.find(
(s) => s.inp.nodeId === sceneNode && s.mediaKind === "image" && !s.mask,
);
const list = ds.lists.find(
(g) => g.nodeId === sceneNode && g.kind === "image_list",
);
if (!allowScene) return;
if (slot) onSet(sceneNode, path);
else if (list) {
const paths = listPaths(values, list);
if (paths.length >= (list.max ?? 9)) {
toast.error("参考图数量已达上限");
return;
}
onSet(sceneNode, [...paths, path]);
}
}
const uploads = createUploadManager();
type Reject = { name: string; message: string };
@@ -149,6 +178,7 @@
if (unplaced) toast.error(t('desk.dropNoSlot', { n: unplaced }));
}
</script>
<SceneCapturePicker bind:open={sceneOpen} onPick={pickScene}/>
{#if ds.slots.length || ds.lists.length}
<div class="tray">
@@ -169,6 +199,7 @@
anchorId={`slot-${s.inp.nodeId}`}
onFile={(f) => slotFile(s, f)}
onFromResults={() => onFromResults(s.inp.nodeId)}
onFromScene={allowScene && s.mediaKind === "image" && !s.mask ? () => { sceneNode = s.inp.nodeId; sceneOpen = true; } : undefined}
onClear={() => {
setRejects(s.inp.nodeId, []);
onSet(s.inp.nodeId, '');
@@ -193,6 +224,7 @@
onReplace={(i, file) => groupReplace(entry, i, file)}
onRemove={(i) => onSet(entry.nodeId, listPaths(values, entry).filter((_, j) => j !== i))}
onDismissReject={(i) => setRejects(entry.nodeId, (rejects[entry.nodeId] ?? []).filter((_, j) => j !== i))}
onFromScene={allowScene && entry.kind === "image_list" ? () => { sceneNode = entry.nodeId; sceneOpen = true; } : undefined}
onFromResults={entry.kind === 'image_list' ? () => onFromResults(entry.nodeId) : undefined}
/>
{/each}
File diff suppressed because it is too large Load Diff
@@ -28,6 +28,10 @@
>
<b>{title}</b>{@render tools?.()}<span class="mg-grow"
></span>{@render actions?.()}
<!-- The approved header reserves this inline width independently of demo copy. -->
<span class="mg-header-reserve" aria-hidden="true"
><span></span><span>&emsp;&emsp;&emsp;&emsp;</span></span
>
</header>
{/snippet}
<section
@@ -138,7 +138,7 @@
overflow: auto;
}
.mg-table table {
table-layout: fixed;
table-layout: var(--manage-table-layout, fixed);
width: 100%;
border-collapse: collapse;
font-size: 12.5px;
@@ -148,6 +148,7 @@
top: 0;
background: var(--bg-0);
z-index: 1;
box-sizing: var(--manage-cell-box, content-box);
text-align: left;
font-weight: 500;
color: var(--tx-3);
@@ -156,6 +157,7 @@
border-bottom: 1px solid var(--line);
}
.mg-table td {
box-sizing: var(--manage-cell-box, content-box);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
@@ -752,3 +754,101 @@ textarea.f-input {
.mg-pin[aria-pressed="true"] svg {
fill: currentColor;
}
/* Confirmed full-width task table. Shared table/controls remain the single source. */
.task-table {
--manage-table-layout: var(--task-table-layout);
--manage-cell-box: border-box;
}
.task-thumbnail-column {
width: var(--task-thumbnail-column);
}
.task-progress-column {
width: var(--task-progress-column);
}
.task-tabs {
width: var(--task-tabs-width);
margin-left: var(--task-tabs-offset);
}
.task-search {
width: var(--task-search-width);
}
.task-thumbnail {
display: grid;
place-items: center;
width: var(--task-thumbnail-width);
height: var(--task-thumbnail-height);
border-radius: var(--task-thumbnail-radius);
border: 0;
padding: 0;
overflow: hidden;
color: var(--tx-3);
background: var(--bg-2);
cursor: pointer;
}
.task-thumbnail img {
width: 100%;
height: 100%;
object-fit: cover;
}
.task-name {
font-weight: 500;
}
.task-progress {
height: var(--task-progress-height);
margin-top: var(--task-progress-gap);
border-radius: var(--task-progress-radius);
background: rgb(var(--ink) / 0.1);
overflow: hidden;
}
.task-progress i {
display: block;
height: 100%;
background: var(--task-progress-fill);
box-shadow: var(--task-progress-shadow);
transition: width var(--task-progress-duration) linear;
}
.task-progress-unknown {
color: var(--tx-3);
margin-left: var(--task-progress-gap);
}
.task-table .mg-tag.run,
.task-dialog .mg-tag.run {
color: var(--task-running-color);
border-color: var(--task-running-border);
background: var(--task-running-background);
}
.task-dialog {
height: auto;
overflow: visible;
gap: 12px;
}
.task-output {
display: flex;
flex-direction: column;
gap: 8px;
}
.task-output img,
.task-output video {
width: 100%;
max-height: var(--task-preview-height);
object-fit: contain;
background: var(--media-bg);
}
.mg-header-reserve {
display: inline-flex;
flex: none;
align-items: center;
gap: 6px;
padding: 0 var(--manage-header-reserve-padding);
border: 1px solid transparent;
font: var(--manage-header-reserve-font);
letter-spacing: inherit;
}
.mg-header-reserve > :first-child {
width: var(--manage-header-reserve-dot);
}
.mg-header-reserve > :last-child {
white-space: pre;
}
@@ -1,7 +1,6 @@
<script lang="ts">
import { resolve } from "$app/paths";
import { onMount } from "svelte";
import { spotlight } from "$lib/actions/spotlight";
import { createQuery } from "@tanstack/svelte-query";
import { toStore } from "svelte/store";
import { page } from "$app/state";
@@ -23,7 +22,7 @@
* real queue and connection status. Individual tools keep their routes. */
interface Props {
workspace?: boolean;
pageHeader?: import("svelte").Snippet;
pageHeader?: import("svelte").Snippet<[{ openSearch: () => void }]>;
active: NavId;
title: string;
subtitle?: string;
@@ -184,48 +183,61 @@
class="refract-shell"
class:focus
class:expanded
style:--scene={`var(--sc-${scene})`}
style:--scene={scene === "gold" ? "var(--gold)" : `var(--sc-${scene})`}
>
<aside class="sidebar" aria-label="全站导航">
<a class="brand" href={resolve('/')} aria-label="返回工作室"
>AIGC{#if expanded}<small>创作工作台</small>{/if}</a
<a class="brand" href={resolve("/")} aria-label="返回工作室"
><b>片</b>{#if expanded}<small>创作工作台</small>{/if}</a
>
<nav aria-label="主要导航">
{#each mainNav as item (item.id)}<a
class="nav-item reference-option"
use:spotlight
class="nav-item"
href={resolve(item.href)}
style:--nav-c={`var(--sc-${item.scene})`}
style:--nav-c={item.scene === "gold"
? "var(--gold)"
: `var(--sc-${item.scene})`}
aria-current={item.matches(page.url.pathname) ? "page" : undefined}
title={item.label}
><Icon name={item.icon} size={20} /><span>{item.label}</span></a
title={item.id === "connections"
? `${item.label} · ${connDetail}`
: item.label}
><Icon name={item.icon} size={18} /><span>{item.label}</span></a
>{/each}
</nav>
<hr />
<nav class="management" aria-label="管理导航">
{#each NAV_MANAGE as item (item.id)}<a
class="nav-item reference-option"
use:spotlight
class="nav-item"
href={resolve(item.href)}
style:--nav-c={`var(--sc-${item.scene})`}
style:--nav-c={item.scene === "gold"
? "var(--gold)"
: `var(--sc-${item.scene})`}
aria-current={item.matches(page.url.pathname) ? "page" : undefined}
title={item.label}
title={item.id === "connections"
? `${item.label} · ${connDetail}`
: item.label}
><Icon name={item.icon} size={18} /><span>{item.label}</span></a
>{/each}
</nav>
<div class="sidebar-bottom">
<button
class="nav-item reference-option"
use:spotlight
onclick={toggleNavigation}
aria-expanded={expanded}
title={expanded ? "收起导航" : "展开导航"}
><Icon name="chevron-right" size={18} /><span
>{expanded ? "收起导航" : "展开导航"}</span
></button
<a
class="queue-dot"
href={resolve("/tasks")}
title={`${genJobs.filter((j) => j.status === "running" || j.status === "pending").length} 个任务进行中`}
aria-label="查看生成任务"
>
<Icon name="nav-queue" />
{#if genJobs.some((j) => j.status === "running") && !reduceMotion.manual && !reduceMotion.system}<span
class="queue-ring"
aria-hidden="true"
></span>{/if}
{#if genJobs.some((j) => j.status === "running" || j.status === "pending")}<i
>{genJobs.filter(
(j) => j.status === "running" || j.status === "pending",
).length}</i
>{/if}
</a>
<button
class="nav-item reference-option"
use:spotlight
class="nav-item"
onclick={() => (themeOpen = true)}
aria-haspopup="dialog"
aria-expanded={themeOpen}
@@ -234,34 +246,43 @@
></button
>
<a
class="nav-item reference-option"
use:spotlight
href={resolve('/settings')}
class="nav-item"
href={resolve("/settings")}
aria-current={page.url.pathname === "/settings" ? "page" : undefined}
title="设置"><Icon name="settings" size={18} /><span>设置</span></a
title="设置"><Icon name="nav-gear" size={18} /><span>设置</span></a
>
<a
class="connection"
href={resolve('/connections')}
title={connDetail}
aria-label={connDetail}
><span class="status-chip-dot {connDot}"></span>{#if expanded}<span
>{probeState === "ok"
? "本地服务已连接"
: probeState === "testing"
? "正在检测服务"
: probeState === "fail"
? "本地服务离线"
: "配置本地服务"}</span
>{/if}</a
<button
class="nav-item"
onclick={toggleNavigation}
aria-expanded={expanded}
title={expanded ? "收起导航" : "展开导航"}
><Icon name="nav-palette" size={18} /><span
>{expanded ? "收起" : "导航"}</span
></button
>
{#if expanded}<a
class="connection"
href={resolve("/connections")}
title={connDetail}
aria-label={connDetail}
><span class="status-chip-dot {connDot}"></span>{#if expanded}<span
>{probeState === "ok"
? "本地服务已连接"
: probeState === "testing"
? "正在检测服务"
: probeState === "fail"
? "本地服务离线"
: "配置本地服务"}</span
>{/if}</a
>
{/if}
</div>
</aside>
<div
class="app-body"
style:background={pageHeader ? "var(--manage-page-background)" : undefined}
>
{#if pageHeader}{@render pageHeader()}{:else}
{#if pageHeader}{@render pageHeader({ openSearch: openPalette })}{:else}
<header class="topbar">
<div class="breadcrumb">
{currentNav?.label ?? title}<span>/</span><span class="page-name"
@@ -272,14 +293,14 @@
{#if currentNav?.id === "connections"}
<a
class="a-btn secondary sm"
href={resolve('/connections')}
href={resolve("/connections")}
aria-current={page.url.pathname === "/connections"
? "page"
: undefined}>ComfyUI 连接</a
>
<a
class="a-btn secondary sm"
href={resolve('/models')}
href={resolve("/models")}
aria-current={page.url.pathname === "/models" ? "page" : undefined}
>大语言模型</a
>
@@ -325,11 +346,6 @@
</Modal>
<style>
.management {
border-top: 1px solid var(--line-2);
margin-top: 8px;
padding-top: 8px;
}
.theme-dot {
width: 18px;
height: 18px;
@@ -353,7 +369,7 @@
color: var(--tx-2);
}
.nav-item[aria-current="page"] :global(svg) {
color: var(--nav-c, var(--scene));
color: var(--nav-c, var(--gold));
}
.refract-shell {
@@ -366,28 +382,45 @@
min-width: 0;
}
.refract-shell.expanded {
grid-template-columns: 174px minmax(0, 1fr);
grid-template-columns: var(--rail-expanded-width) minmax(0, 1fr);
}
.sidebar {
background: var(--bg-1);
border-right: 1px solid var(--line);
padding: 16px 8px 9px;
--icon-stroke: var(--manage-icon-stroke);
padding: var(--rail-padding);
align-items: center;
gap: 2px;
z-index: 5;
display: flex;
flex-direction: column;
min-height: 0;
overflow: auto;
scrollbar-width: thin;
overflow: visible;
}
.brand {
font-size: 19px;
font-weight: 800;
color: var(--gold);
text-align: center;
width: var(--rail-brand-size);
height: var(--rail-brand-size);
border-radius: var(--rail-brand-radius);
display: grid;
place-items: center;
margin-bottom: var(--rail-brand-margin);
position: relative;
background: var(--brand-grad);
box-shadow: var(--brand-glow);
text-decoration: none;
letter-spacing: -1px;
display: block;
margin: 2px 0 18px;
text-shadow: var(--brand-glow);
flex: none;
}
.brand b {
font: 800 15px/1 var(--font);
color: var(--brand-ink);
letter-spacing: -0.5px;
}
.sidebar hr {
width: 28px;
border: 0;
border-top: 1px solid var(--line-2);
margin: var(--rail-divider-margin);
flex: none;
}
.brand small {
display: block;
@@ -403,26 +436,24 @@
gap: 2px;
}
.nav-item {
min-height: 44px;
box-sizing: border-box;
position: relative;
width: var(--rail-nav-width);
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
gap: 5px;
font-size: 10px;
line-height: 1;
padding: 7px 3px;
gap: 3px;
font: inherit;
padding: var(--rail-nav-padding);
color: var(--tx-3);
text-decoration: none;
border: 0;
background: none;
border-radius: var(--r-md);
border-radius: var(--rail-nav-radius);
cursor: pointer;
font-family: inherit;
transition:
background-color 180ms ease,
color 180ms ease,
transform 160ms var(--ease-out);
color var(--t-base),
background var(--t-base);
flex-shrink: 0;
}
.nav-item[aria-current="page"] {
@@ -436,20 +467,30 @@
.nav-item[aria-current="page"]::before {
content: "";
position: absolute;
left: -2px;
left: -6px;
top: 12px;
bottom: 12px;
width: 3px;
border-radius: 3px;
background: var(--nav-c, var(--scene));
background: var(--nav-c, var(--gold));
box-shadow: var(
--rail-active-shadow,
0 0 var(--rail-active-shadow-blur) var(--nav-c, var(--gold))
);
}
.nav-item:active {
transform: scale(var(--press-scale));
}
.nav-item > span {
font-size: var(--rail-nav-size);
line-height: 1;
white-space: nowrap;
}
.expanded .sidebar {
overflow-y: auto;
}
.expanded .nav-item {
width: var(--rail-expanded-nav-width);
flex-direction: row;
justify-content: flex-start;
gap: 12px;
@@ -457,26 +498,56 @@
padding: 10px 14px;
min-height: 43px;
}
.expanded .nav-item > span {
font-size: var(--rail-expanded-label-size);
}
.expanded .nav-item::before {
display: none;
}
.sidebar-bottom {
display: flex;
flex-direction: column;
gap: 2px;
align-items: center;
gap: 6px;
margin-top: auto;
padding-top: 8px;
}
.sidebar-bottom .nav-item {
min-height: 45px;
font-size: 9px;
padding: 7px 3px;
gap: 4px;
.queue-dot {
box-sizing: border-box;
width: var(--rail-queue-size);
height: var(--rail-queue-size);
border-radius: var(--rail-brand-radius);
display: grid;
place-items: center;
position: relative;
color: var(--tx-2);
background: var(--bg-2);
border: 1px solid var(--line);
}
.expanded .sidebar-bottom .nav-item {
min-height: 39px;
padding: 9px 14px;
font-size: 12px;
.queue-ring {
position: absolute;
inset: -1px;
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;
}
@keyframes rail-spin {
to {
transform: rotate(360deg);
}
}
.queue-dot i {
position: absolute;
top: -4px;
right: -4px;
min-width: 16px;
height: 16px;
padding: 0 4px;
border-radius: 8px;
background: var(--sc-video);
color: var(--on-scene);
font: 700 10px/16px var(--mono);
font-style: normal;
}
.connection {
display: flex;
@@ -587,48 +658,4 @@
text-overflow: ellipsis;
}
}
@media (max-height: 780px) {
.sidebar {
padding-top: 11px;
}
.brand {
margin-bottom: 8px;
}
.nav-item {
min-height: 40px;
padding: 5px 0 3px;
gap: 3px;
}
.sidebar-bottom {
gap: 0;
padding-top: 12px;
}
.sidebar-bottom .nav-item {
min-height: 37px;
}
}
@media (max-height: 740px) {
.sidebar {
padding-top: 10px;
}
.brand {
margin-bottom: 12px;
}
.sidebar nav {
gap: 4px;
}
.nav-item {
min-height: 38px;
padding: 4px 2px;
gap: 3px;
}
.sidebar-bottom {
padding-top: 8px;
}
.sidebar-bottom .nav-item {
min-height: 31px;
padding: 4px 2px;
}
}
</style>
@@ -48,19 +48,23 @@
>
<span class="preview" data-theme={item.id} aria-hidden="true"
><span class="rail"></span><span class="list"
><i></i><i></i><i></i></span
><i></i><i></i><i></i>{#if tiles}<i></i>{/if}</span
><span class="stage"
><i></i><b
>{#if tiles}<em></em>{/if}</b
></span
></span
>{#if tiles}<span class="preview-text"></span>{/if}</span
>
<span class="copy"
><strong
>{item.name}{#if tiles && item.id === "night"}
<span class="mg-tag theme-default">默认</span>{/if}</strong
><small>{tiles ? item.description.split(" · ")[0] : item.english}</small
>{#if !tiles}<span>{item.description}</span>{/if}</span
>{tiles && item.id === "night"
? item.name + " "
: item.name}{#if tiles && item.id === "night"}<span
class="mg-tag theme-default">默认</span
>{/if}</strong
><small>{tiles ? item.tone : item.english}</small>{#if !tiles}<span
>{item.description}</span
>{/if}</span
>
<span class="check" class:tile-check={tiles} aria-hidden="true"
>{$theme === item.id ? "✓" : ""}</span
@@ -80,19 +84,31 @@
}
.choices.tiles {
--choice-check-width: 0px;
--choice-hover-background: var(--bg-2);
--choice-display: flex;
--choice-box-sizing: border-box;
--choice-gap: 6px;
--choice-copy-gap: 6px;
--choice-tone-align: flex-start;
--choice-tone-align: stretch;
--choice-preview-art: var(--theme-preview-art);
--choice-preview-radius: 8px;
--choice-art-border: 0;
--choice-dock-radius: 3px;
--choice-dock-width: 100%;
--choice-dock-background: var(--bg-3);
--choice-radius: 12px;
--choice-name-size: 12.5px;
--choice-tone-font: 11px var(--font-body);
--choice-name-size: var(--manage-theme-name-size);
--choice-name-line: var(--manage-theme-line);
--choice-tone-font: var(--manage-theme-tone-size)/var(--manage-theme-line)
var(--font);
--choice-selected: color-mix(in srgb, var(--gold) 60%, transparent);
--choice-shadow: 0 0 0 3px color-mix(in srgb, var(--gold) 14%, transparent);
}
.tiles .preview {
--choice-art-shadow: 0 0 0 1px var(--line-2);
--choice-dock-border: 1px solid var(--line-2);
--choice-dock-background: var(--bg-3);
color: var(--tx-1);
}
.tile-check {
display: none;
}
@@ -104,7 +120,8 @@
}
.choice {
box-sizing: var(--choice-box-sizing, content-box);
display: grid;
display: var(--choice-display, grid);
flex-direction: column;
grid-template-columns: minmax(0, 1fr) var(--choice-check-width, 18px);
gap: var(--choice-gap, 8px);
padding: 8px;
@@ -117,7 +134,7 @@
}
.choice:hover {
border-color: var(--line-3);
background: var(--bg-3);
background: var(--choice-hover-background, var(--bg-3));
}
.choice.selected {
border-color: var(--choice-selected, var(--gold));
@@ -135,7 +152,8 @@
box-sizing: var(--choice-box-sizing, content-box);
grid-column: 1 / -1;
height: 64px;
border-radius: var(--r-sm);
border-radius: var(--choice-preview-radius, var(--r-sm));
position: relative;
overflow: hidden;
display: grid;
grid-template-columns: 12px 22px 1fr;
@@ -175,6 +193,7 @@
background: var(--sc-image);
}
.stage {
position: relative;
padding: 5px 6px;
display: flex;
flex-direction: column;
@@ -184,13 +203,16 @@
flex: 1;
border-radius: 3px;
background: var(--choice-preview-art, var(--bg-3));
border: 1px solid var(--line-2);
border: var(--choice-art-border, 1px solid var(--line-2));
box-shadow: var(--choice-art-shadow, none);
}
.stage b {
box-sizing: var(--choice-box-sizing, content-box);
border: var(--choice-dock-border, 0);
height: 9px;
width: var(--choice-dock-width, 18px);
align-self: end;
border-radius: 2px;
border-radius: var(--choice-dock-radius, 2px);
background: var(--choice-dock-background, var(--grad-gold));
display: flex;
justify-content: flex-end;
@@ -201,6 +223,16 @@
border-radius: 2px;
background: var(--grad-gold);
}
.preview-text {
position: absolute;
left: 40px;
top: 6px;
width: 20px;
height: 3px;
border-radius: 2px;
background: var(--tx-1);
opacity: 0.85;
}
.copy {
display: flex;
flex-wrap: wrap;
@@ -209,6 +241,7 @@
}
.copy strong {
font-size: var(--choice-name-size, 13px);
line-height: var(--choice-name-line, inherit);
}
.copy small {
font: var(--choice-tone-font, 10px var(--mono));
+14 -14
View File
@@ -20,22 +20,22 @@ export interface NavItem {
label: string;
icon: IconName;
href: Pathname;
scene: "project" | "image" | "agent" | "manage" | "asset";
scene: "project" | "image" | "agent" | "manage" | "asset" | "gold";
matches: (path: string) => boolean;
}
export const NAV_MAIN: NavItem[] = [
{
id: "projects",
label: "工作室",
icon: "home",
icon: "nav-home",
href: "/",
scene: "project",
scene: "gold",
matches: (p) => p === "/",
},
{
id: "workbench",
label: "项目",
icon: "folder",
icon: "nav-project",
href: "/workbench",
scene: "project",
matches: (p) => /^\/(workbench|project)(\/|$)/.test(p),
@@ -43,7 +43,7 @@ export const NAV_MAIN: NavItem[] = [
{
id: "image",
label: "创作台",
icon: "image",
icon: "nav-create",
href: "/create",
scene: "image",
matches: (p) =>
@@ -54,7 +54,7 @@ export const NAV_MAIN: NavItem[] = [
{
id: "agents",
label: "智能体",
icon: "sparkle",
icon: "nav-agent",
href: "/agents",
scene: "agent",
matches: (p) => p === "/agents",
@@ -62,17 +62,17 @@ export const NAV_MAIN: NavItem[] = [
{
id: "canvas",
label: "画布",
icon: "story",
icon: "nav-canvas",
href: "/canvas",
scene: "manage",
scene: "image",
matches: (p) => p.startsWith("/canvas"),
},
{
id: "library",
label: "素材库",
icon: "assets",
icon: "nav-lib",
href: "/library",
scene: "asset",
scene: "gold",
matches: (p) => p === "/library",
},
];
@@ -80,7 +80,7 @@ export const NAV_MANAGE: NavItem[] = [
{
id: "tasks",
label: "任务",
icon: "clock",
icon: "nav-queue",
href: "/tasks",
scene: "manage",
matches: (p) => p === "/tasks",
@@ -88,7 +88,7 @@ export const NAV_MANAGE: NavItem[] = [
{
id: "workflows",
label: "工作流",
icon: "playground",
icon: "nav-flow",
href: "/workflows",
scene: "manage",
matches: (p) => p === "/workflows",
@@ -96,7 +96,7 @@ export const NAV_MANAGE: NavItem[] = [
{
id: "connections",
label: "模型连接",
icon: "link",
icon: "nav-plug",
href: "/model-connections",
scene: "manage",
matches: (p) =>
@@ -107,7 +107,7 @@ export const NAV_FOOT: NavItem[] = [
{
id: "settings",
label: "设置",
icon: "settings",
icon: "nav-gear",
href: "/settings",
scene: "manage",
matches: (p) => p === "/settings",
@@ -30,6 +30,7 @@
anchorId?: string;
onFile: (file: File) => void;
onFromResults?: () => void;
onFromScene?: () => void;
onClear: () => void;
}
@@ -48,6 +49,7 @@
anchorId,
onFile,
onFromResults,
onFromScene,
onClear,
}: Props = $props();
@@ -108,6 +110,7 @@
<span>{label}</span>
{#if required}<span class="req" aria-hidden="true">*</span>{/if}
{#if hint}<span>{hint}</span>{/if}
{#if onFromScene}<button type="button" class="a-btn ghost xs" onclick={onFromScene}>从三维搭景截取</button>{/if}
{#if allowFromResults && onFromResults}
<button type="button" class="a-btn ghost xs" onclick={onFromResults}>
{t('studio.slotFromResultsShort')}
@@ -44,6 +44,7 @@
onRemove: (index: number) => void;
onDismissReject: (index: number) => void;
onFromResults?: () => void;
onFromScene?: () => void;
}
let {
@@ -64,6 +65,7 @@
onRemove,
onDismissReject,
onFromResults,
onFromScene,
}: Props = $props();
let addInput = $state<HTMLInputElement | null>(null);
@@ -133,6 +135,7 @@
<span>{label}</span>
<span class="req" aria-hidden="true">*</span>
<span class="mono">{hintLabel}</span>
{#if onFromScene}<button type="button" class="a-btn ghost xs" onclick={onFromScene}>从三维搭景截取</button>{/if}
{#if allowFromResults && onFromResults && remaining > 0}
<button type="button" class="a-btn ghost xs" onclick={onFromResults}>
{t('studio.slotFromResultsShort')}
@@ -4,12 +4,59 @@
* Each entry is SVG inner content rendered by Icon.svelte via {@html}.
*/
export const icons = {
// Approved B3 sample geometry; retain existing tool icon contracts.
"project-check": '<path d="M5 12.5 10 17l9-10"/>',
"project-grid":
'<rect x="3" y="3" width="7" height="7"/><rect x="14" y="3" width="7" height="7"/><rect x="3" y="14" width="7" height="7"/><rect x="14" y="14" width="7" height="7"/>',
"project-book":
'<path d="M4 4h6a3 3 0 0 1 3 3v13a2 2 0 0 0-2-2H4zM20 4h-5a3 3 0 0 0-3 3"/><path d="M20 4v14h-6"/>',
"project-folder-in":
'<path d="M3 6a2 2 0 0 1 2-2h4l2 2h8a2 2 0 0 1 2 2v10a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/><path d="M12 10v6M9 13l3 3 3-3"/>',
"project-film":
'<rect x="2" y="4" width="20" height="16" rx="2"/><path d="M7 4v16M17 4v16M2 9h5M2 15h5M17 9h5M17 15h5"/>',
"project-image":
'<rect x="3" y="3" width="18" height="18" rx="2"/><circle cx="8.5" cy="8.5" r="1.5"/><path d="m21 15-5-5L5 21"/>',
"project-refresh": '<path d="M20 11a8 8 0 1 0-2.3 5.7M20 4v7h-7"/>',
"project-cube":
'<path d="M12 2 3 7v10l9 5 9-5V7z"/><path d="M3 7l9 5 9-5M12 12v10"/>',
"project-search": '<circle cx="11" cy="11" r="7"/><path d="m20 20-3.5-3.5"/>',
"project-arrow": '<path d="M5 12h14M13 6l6 6-6 6"/>',
"project-agent":
'<path d="M12 3l1.8 4.6L18.5 9l-4.7 1.4L12 15l-1.8-4.6L5.5 9l4.7-1.4z"/><path d="M18.5 15l.9 2.1 2.1.9-2.1.9-.9 2.1-.9-2.1-2.1-.9 2.1-.9z"/>',
"project-lib":
'<rect x="3" y="5" width="18" height="15" rx="2"/><circle cx="9" cy="10" r="2"/><path d="m21 16-5-5-9 9"/>',
"project-canvas":
'<rect x="3" y="3" width="7" height="7" rx="1.5"/><rect x="14" y="3" width="7" height="5" rx="1.5"/><rect x="14" y="12" width="7" height="9" rx="1.5"/><rect x="3" y="14" width="7" height="7" rx="1.5"/>',
"project-queue":
'<path d="M4 6h16M4 12h10M4 18h7"/><circle cx="18" cy="16" r="3"/>',
"search-manage": '<circle cx="11" cy="11" r="7"/><path d="m20 20-3.5-3.5"/>',
"upload-manage": '<path d="M12 16V4M6 9l6-6 6 6M4 20h16"/>',
"film-manage":
'<rect x="2" y="4" width="20" height="16" rx="2"/><path d="M7 4v16M17 4v16M2 9h5M2 15h5M17 9h5M17 15h5"/>',
"arrow-right": '<path d="M5 12h14M13 6l6 6-6 6"/>',
pin: '<path d="M12 17v5M5 17h14l-2-4V5a2 2 0 0 0-2-2H9a2 2 0 0 0-2 2v8z"/>',
// Approved HEL-726 rail geometry; existing tool icons retain their contracts.
"nav-home":
'<path d="M3 10.5 12 3l9 7.5V20a1 1 0 0 1-1 1h-5v-6H9v6H4a1 1 0 0 1-1-1z"/>',
"nav-project":
'<rect x="3" y="4" width="18" height="16" rx="2"/><path d="M3 9h18M8 4v5M13 4v5"/>',
"nav-create":
'<path d="M12 3v3M12 18v3M3 12h3M18 12h3M5.6 5.6l2.1 2.1M16.3 16.3l2.1 2.1M5.6 18.4l2.1-2.1M16.3 7.7l2.1-2.1"/><circle cx="12" cy="12" r="3"/>',
"nav-agent":
'<path d="M12 3l1.8 4.6L18.5 9l-4.7 1.4L12 15l-1.8-4.6L5.5 9l4.7-1.4z"/><path d="M18.5 15l.9 2.1 2.1.9-2.1.9-.9 2.1-.9-2.1-2.1-.9 2.1-.9z"/>',
"nav-canvas":
'<rect x="3" y="3" width="7" height="7" rx="1.5"/><rect x="14" y="3" width="7" height="5" rx="1.5"/><rect x="14" y="12" width="7" height="9" rx="1.5"/><rect x="3" y="14" width="7" height="7" rx="1.5"/>',
"nav-lib":
'<rect x="3" y="5" width="18" height="15" rx="2"/><circle cx="9" cy="10" r="2"/><path d="m21 16-5-5-9 9"/>',
"nav-queue":
'<path d="M4 6h16M4 12h10M4 18h7"/><circle cx="18" cy="16" r="3"/>',
"nav-flow":
'<rect x="3" y="3" width="6" height="6" rx="1.5"/><rect x="15" y="15" width="6" height="6" rx="1.5"/><path d="M9 6h4a2 2 0 0 1 2 2v7"/>',
"nav-plug": '<path d="M9 2v5M15 2v5M6 7h12v4a6 6 0 0 1-12 0zM12 17v5"/>',
"nav-gear":
'<circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.7 1.7 0 0 0 .3 1.8l.1.1a2 2 0 1 1-2.8 2.8l-.1-.1a1.7 1.7 0 0 0-1.8-.3 1.7 1.7 0 0 0-1 1.5V21a2 2 0 1 1-4 0v-.1a1.7 1.7 0 0 0-1.1-1.5 1.7 1.7 0 0 0-1.8.3l-.1.1a2 2 0 1 1-2.8-2.8l.1-.1a1.7 1.7 0 0 0 .3-1.8 1.7 1.7 0 0 0-1.5-1H3a2 2 0 1 1 0-4h.1a1.7 1.7 0 0 0 1.5-1.1 1.7 1.7 0 0 0-.3-1.8l-.1-.1a2 2 0 1 1 2.8-2.8l.1.1a1.7 1.7 0 0 0 1.8.3H9a1.7 1.7 0 0 0 1-1.5V3a2 2 0 1 1 4 0v.1a1.7 1.7 0 0 0 1 1.5 1.7 1.7 0 0 0 1.8-.3l.1-.1a2 2 0 1 1 2.8 2.8l-.1.1a1.7 1.7 0 0 0-.3 1.8V9a1.7 1.7 0 0 0 1.5 1H21a2 2 0 1 1 0 4h-.1a1.7 1.7 0 0 0-1.5 1z"/>',
"nav-palette":
'<path d="M12 3a9 9 0 1 0 0 18c1 0 1.5-.8 1.2-1.6-.4-1 .3-2.4 1.6-2.4H17a4 4 0 0 0 4-4c0-5.5-4-10-9-10z"/><circle cx="7.5" cy="11" r="1"/><circle cx="10" cy="7" r="1"/><circle cx="15" cy="7.5" r="1"/>',
// Navigation
home: '<path d="m3 10 9-7 9 7v10a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2Z"/><path d="M9 22V12h6v10"/>',
story:
@@ -74,6 +74,12 @@
function jobActive(job: WorkbenchAsset['job']): boolean {
return !!job && (job.status === 'pending' || job.status === 'running');
}
export function saveDraft() {
if (editing) save();
else if (stage.content) onSave?.(stage.content);
}
export function hasUnsavedDraft() { return editing; }
</script>
{#if editing}
@@ -0,0 +1,728 @@
<script lang="ts">
import "$lib/components/workbench/project.css";
import Modal from "$lib/components/ui/Modal.svelte";
import StudioHeader from "$lib/components/workbench/StudioHeader.svelte";
import { resolve } from "$app/paths";
import { goto } from "$app/navigation";
import { untrack } from "svelte";
import { page as pageStore } from "$app/state";
import {
createMutation,
createQuery,
useQueryClient,
} from "@tanstack/svelte-query";
import {
agentApi,
assetUrl,
jobsApi,
settings,
workbenchApi,
type WorkbenchStageKey,
} from "$lib/api";
import { connectEvents } from "$lib/events";
import { parseDbTs } from "$lib/dbtime";
import { handleJobEvent, progressFor } from "$lib/jobProgress";
import { storageSet } from "$lib/storage";
import { toast } from "$lib/toast";
import AppShell from "$lib/components/shell/AppShell.svelte";
import Icon from "$lib/components/ui/Icon.svelte";
import Skeleton from "$lib/components/ui/Skeleton.svelte";
import StageStepper from "$lib/components/workbench/StageStepper.svelte";
import ScriptStagePanel from "$lib/components/workbench/ScriptStagePanel.svelte";
import StyleStagePanel from "$lib/components/workbench/StyleStagePanel.svelte";
import StoryboardStagePanel from "$lib/components/workbench/StoryboardStagePanel.svelte";
import AssetsStagePanel from "$lib/components/workbench/AssetsStagePanel.svelte";
import ShotStagePanel from "$lib/components/workbench/ShotStagePanel.svelte";
import { t } from "$lib/i18n.svelte";
/**
* 五段创作工作台 (a2): stepper + stage artifact panel + agent/task side
* column. Only confirmed snapshots flow downstream; drafts are clearly
* flagged and never treated as final. In-flow generation rides the shared
* queue (HEL-612) so tasks and results come back to this project.
*/
const client = useQueryClient();
let { projectId: suppliedId }: { projectId: number } = $props();
const projectId = untrack(() => suppliedId);
let editor = $state<{saveDraft:()=>void;hasUnsavedDraft:()=>boolean}|null>(null);
let compareOpen = $state(false);
const STAGE_ORDER: WorkbenchStageKey[] = [
"script",
"style",
"storyboard",
"assets",
"shot",
];
const overviewQuery = createQuery({
queryKey: ["workbench", projectId],
queryFn: () => workbenchApi.overview(projectId),
refetchInterval: 2500,
});
const jobsQuery = createQuery({
queryKey: ["jobs", projectId],
queryFn: () => jobsApi.list(projectId),
refetchInterval: 5000,
});
const agentsQuery = createQuery({
queryKey: ["agents-registry"],
queryFn: agentApi.listNamedAgents,
staleTime: 60_000,
});
const settingsQuery = createQuery({
queryKey: ["settings"],
queryFn: settings.get,
staleTime: 60_000,
});
const overview = $derived($overviewQuery.data);
const stages = $derived(overview?.stages ?? []);
// ?stage= selects the open stage; default = first not-confirmed one.
const urlStage = $derived(
pageStore.url.searchParams.get("stage") as WorkbenchStageKey | null,
);
const fallbackStage = $derived.by(() => {
for (const key of STAGE_ORDER) {
const s = stages.find((x) => x.stage_key === key);
if (!s || s.status !== "confirmed") return key;
}
return "shot";
});
const stageKey = $derived(
urlStage && STAGE_ORDER.includes(urlStage) ? urlStage : fallbackStage,
);
const currentStage = $derived(stages.find((s) => s.stage_key === stageKey));
function selectStage(key: string) {
if (Number(pageStore.params.id) !== projectId) return;
noteOpen = false;
const url = new URL(pageStore.url);
url.searchParams.set("stage", key);
goto(resolve(`/workbench/${pageStore.params.id}${url.search}${url.hash}`), {
keepFocus: true,
noScroll: true,
});
}
function advanceAfterConfirm(key: WorkbenchStageKey) {
const idx = STAGE_ORDER.indexOf(key);
if (idx >= 0 && idx < STAGE_ORDER.length - 1) {
selectStage(STAGE_ORDER[idx + 1]);
}
}
// ── Live job feed (shared queue) ───────────────────────────────────
$effect(() => {
const off = connectEvents((ev) => {
handleJobEvent(ev);
if (ev.type.startsWith("job.")) {
client.invalidateQueries({ queryKey: ["jobs", projectId] });
if (ev.type === "job.completed" || ev.type === "job.failed") {
client.invalidateQueries({ queryKey: ["workbench", projectId] });
client.invalidateQueries({ queryKey: ["projects"] });
}
}
});
return off;
});
// ── Stage mutations ────────────────────────────────────────────────
const instructions = $state<Record<string,string>>({});
let runWatchKey = $state<WorkbenchStageKey | null>(null);
let chosenStyleId = $state<string | null>(null);
let noteOpen = $state(false),
noteText = $state(""),
noteBusy = $state(false);
$effect(() => {
void stageKey;
noteOpen = false;
noteText = '';
compareOpen = false;
});
async function saveNote() {
noteBusy = true;
try {
await workbenchApi.saveNote(projectId, stageKey, noteText);
await client.invalidateQueries({ queryKey: ["workbench", projectId] });
noteOpen = false;
toast.success("助手建议已保存");
} catch (e) {
toast.error(e instanceof Error ? e.message : String(e));
} finally {
noteBusy = false;
}
}
// Keep the chosen style selector in sync with the stage payload.
let lastStyleSync = $state("");
$effect(() => {
const styleStage = stages.find((s) => s.stage_key === "style");
if (!styleStage) return;
const directions = (
styleStage.content as unknown as { directions?: { id: string }[] } | null
)?.directions;
const stamp = JSON.stringify(directions ?? []) + styleStage.chosen_style_id;
if (stamp === lastStyleSync) return;
lastStyleSync = stamp;
const ids = (directions ?? []).map((d) => d.id);
chosenStyleId =
styleStage.chosen_style_id && ids.includes(styleStage.chosen_style_id)
? styleStage.chosen_style_id
: (ids[0] ?? null);
});
// Toast once a watched run finishes (success or Chinese failure reason).
$effect(() => {
if (!runWatchKey) return;
const stage = stages.find((s) => s.stage_key === runWatchKey);
if (!stage || stage.status === "running") return;
if (stage.status === "awaiting_confirm") {
toast.success(t("workbench.runDone", { agent: agentName(runWatchKey) }));
} else if (stage.last_run?.status === "failed" && stage.last_run.error) {
toast.error(stage.last_run.error);
}
runWatchKey = null;
});
function agentName(key: string): string {
const agent = ($agentsQuery.data ?? []).find((a) => a.key === key);
return agent ? agent.name : key;
}
function busyFor(key: WorkbenchStageKey): boolean {
const stage = stages.find((s) => s.stage_key === key);
return stage?.status === "running";
}
const runMutation = createMutation({
mutationFn: (args: {key: WorkbenchStageKey; instruction: string}) =>
workbenchApi.runStage(projectId, args.key, args.instruction || undefined),
onSuccess: (_data, args) => {
runWatchKey = args.key;
if ((instructions[args.key] ?? '').trim() === args.instruction) instructions[args.key] = '';
client.invalidateQueries({ queryKey: ["workbench", projectId] });
},
onError: (err) => {
toast.error(
err instanceof Error ? err.message : t("workbench.runFailed"),
);
},
});
const saveMutation = createMutation({
mutationFn: (args: {
key: WorkbenchStageKey;
content: Record<string, unknown>;
}) =>
workbenchApi.patchStage(projectId, args.key, { content: args.content }),
onSuccess: () => {
toast.success(t("workbench.draftSaved"));
client.invalidateQueries({ queryKey: ["workbench", projectId] });
client.invalidateQueries({ queryKey: ["projects"] });
},
onError: (err) => {
toast.error(
err instanceof Error ? err.message : t("workbench.saveFailed"),
);
},
});
const confirmMutation = createMutation({
mutationFn: (key: WorkbenchStageKey) =>
workbenchApi.confirmStage(
projectId,
key,
key === "style" ? chosenStyleId : undefined,
),
onSuccess: (_data, key) => {
toast.success(t("workbench.confirmed", { agent: agentName(key) }));
client.invalidateQueries({ queryKey: ["workbench", projectId] });
client.invalidateQueries({ queryKey: ["projects"] });
advanceAfterConfirm(key);
},
onError: (err) => {
toast.error(
err instanceof Error ? err.message : t("workbench.confirmFailed"),
);
},
});
const restoreMutation = createMutation({
mutationFn: (key: WorkbenchStageKey) =>
workbenchApi.restoreStage(projectId, key),
onSuccess: () => {
toast.success(t("workbench.restored"));
client.invalidateQueries({ queryKey: ["workbench", projectId] });
},
onError: (err) => {
toast.error(
err instanceof Error ? err.message : t("workbench.restoreFailed"),
);
},
});
const importMutation = createMutation({
mutationFn: (text: string) => workbenchApi.importScript(projectId, text),
onSuccess: () => {
toast.success(t("workbench.script.importDone"));
client.invalidateQueries({ queryKey: ["workbench", projectId] });
client.invalidateQueries({ queryKey: ["projects"] });
selectStage("style");
},
onError: (err) => {
toast.error(
err instanceof Error ? err.message : t("workbench.script.importFailed"),
);
},
});
const generateMutation = createMutation({
mutationFn: (payload: {
kind: "image" | "video";
stage_key: WorkbenchStageKey;
target_id?: string;
prompt: string;
negative_prompt?: string;
duration_sec?: number;
}) => workbenchApi.generate(projectId, payload),
onSuccess: (data) => {
toast.success(t("workbench.jobSubmitted"));
client.invalidateQueries({ queryKey: ["workbench", projectId] });
client.invalidateQueries({ queryKey: ["jobs", projectId] });
void data;
},
onError: (err) => {
toast.error(
err instanceof Error ? err.message : t("workbench.jobSubmitFailed"),
);
},
});
const retryMutation = createMutation({
mutationFn: (jobId: number) => jobsApi.retry(jobId),
onSuccess: () => {
toast.success(t("workbench.jobRequeued"));
client.invalidateQueries({ queryKey: ["workbench", projectId] });
client.invalidateQueries({ queryKey: ["jobs", projectId] });
},
});
const cancelMutation = createMutation({
mutationFn: (jobId: number) => jobsApi.cancel(jobId),
onSuccess: () => {
client.invalidateQueries({ queryKey: ["jobs", projectId] });
client.invalidateQueries({ queryKey: ["workbench", projectId] });
},
});
function onGenerate(payload: {
kind: "image" | "video";
targetId: string;
prompt: string;
negativePrompt?: string;
durationSec?: number;
}) {
$generateMutation.mutate({
kind: payload.kind,
stage_key: stageKey,
target_id: payload.targetId,
prompt: payload.prompt,
negative_prompt: payload.negativePrompt,
duration_sec: payload.durationSec,
});
}
// ── Sidebar: agent + tasks ─────────────────────────────────────────
const currentAgent = $derived(
($agentsQuery.data ?? []).find((a) => a.key === stageKey) ?? null,
);
const currentModelName = $derived.by(() => {
const agent = currentAgent;
const s = $settingsQuery.data;
if (!agent || !s) return null;
const pid = s.agent_llm_assignments?.[agent.llm_role];
const profile = (s.llm_profiles ?? []).find((p) => p.id === pid);
return profile?.name ?? null;
});
function openAgentChat() {
if (!currentAgent) return;
storageSet("calliope.agents.activeKey", currentAgent.key);
goto(resolve("/agents"));
}
const projectJobs = $derived(
($jobsQuery.data ?? []).filter(
(j) => j.project_id === projectId &&
(j.kind === "image" || j.kind === "video") &&
(j.payload?.stage_key ?? (j.payload?.workbench as {stage?:string}|undefined)?.stage) === stageKey,
),
);
function jobTime(raw: string | null | undefined): string {
const ts = parseDbTs(raw);
if (!Number.isFinite(ts)) return "";
return new Date(ts).toLocaleTimeString([], {
hour: "2-digit",
minute: "2-digit",
});
}
function jobProgressPct(id: number): number | null {
const entry = progressFor(id);
return entry?.progress ?? null;
}
// ── Header state helpers ───────────────────────────────────────────
const STAGE_LABEL: Record<string, string> = {
script: "故事",
style: "风格",
storyboard: "分镜",
assets: "素材",
shot: "镜头脚本",
};
const STATUS_LABEL: Record<string, string> = {
todo: "workbench.state.todo",
running: "workbench.state.running",
awaiting_confirm: "workbench.state.draft",
confirmed: "workbench.state.confirmed",
};
const STATUS_CLASS: Record<string, string> = {
todo: "",
running: "run",
awaiting_confirm: "warn",
confirmed: "ok",
};
const stageStatus = $derived(currentStage?.status ?? "todo");
const isLastStage = $derived(stageKey === "shot");
const panelProps = $derived.by(() => {
const stage = currentStage;
if (!stage) return null;
return { stage };
});
function handleRun(key: WorkbenchStageKey) {
$runMutation.mutate({key,instruction:(instructions[key] ?? '').trim()});
}
</script>
<AppShell
active="workbench"
title={overview?.project.title ?? "项目"}
workspace
>
{#snippet pageHeader({ openSearch })}<StudioHeader
title={overview?.project.title ?? "项目"}
project
onSearch={openSearch}
/>{/snippet}
{#if !$overviewQuery.data && $overviewQuery.isLoading}<div class="project-load-pad">
<Skeleton height="64px" /><Skeleton height="280px" />
</div>
{:else if !overview}<div class="project-load-pad">
<p class="load-error">
{t("workbench.loadFailed")}<button
class="a-btn ghost"
onclick={() => $overviewQuery.refetch()}
>{t("workbench.retryLoad")}</button
>
</p>
</div>
{:else}
<div class="project-workspace" data-testid="workbench-page">
<StageStepper {stages} activeKey={stageKey} onSelect={selectStage} />
<div class="project-body">
<section class="project-main">
{#if currentStage?.error || (currentStage?.last_run?.status === "failed" && currentStage?.last_run?.error)}<div
class="error-banner"
data-testid="stage-error"
>
{currentStage.last_run?.error ?? currentStage.error}
</div>{/if}
{#if stageStatus === "awaiting_confirm"}<div
class="project-draftbar"
data-testid="draft-banner"
>
<Icon name="project-agent" size={18} /><span
><b>{currentAgent?.name ?? STAGE_LABEL[stageKey] + "助手"}</b
>交回了本阶段草稿。确认后才会交给下一阶段使用。</span
><span class="b3-grow"></span><button
class="a-btn ghost sm"
onclick={openAgentChat}>在智能体中打开</button
><button
class="a-btn secondary sm"
disabled={!currentStage?.confirmed_content}
onclick={() => (compareOpen = true)}>对比已确认版</button
>
</div>{/if}
{#if stageStatus === "running"}<div
class="project-draftbar"
data-testid="running-banner"
>
<span class="breath"></span>{t("workbench.runningHint", {
agent: agentName(stageKey),
})}
</div>{/if}
<div class="project-content">
{#if panelProps}
<div class="stage-panel">
{#if currentStage?.assistant_note || noteOpen}<details
class="stage-note"
open
>
<summary>助手建议 · 待整理到阶段内容</summary>
{#if noteOpen}<textarea
class="a-input"
aria-label="编辑阶段助手建议"
bind:value={noteText}
></textarea>
<div class="note-actions">
<button
class="a-btn ghost sm"
disabled={noteBusy}
onclick={() => (noteOpen = false)}>取消</button
><button
class="a-btn secondary sm"
disabled={noteBusy}
onclick={saveNote}>保存建议</button
>
</div>
{:else}<p>{currentStage?.assistant_note}</p>
<button
class="a-btn ghost sm"
disabled={busyFor(stageKey)}
onclick={() => {
noteText = currentStage?.assistant_note ?? "";
noteOpen = true;
}}>编辑建议</button
>{/if}
</details>{/if}
{#if stageKey === "script"}
<ScriptStagePanel
bind:this={editor}
stage={panelProps.stage}
projectIdea={overview.project.idea}
busy={busyFor("script")}
onSave={(content) =>
$saveMutation.mutate({ key: "script", content })}
onImport={(text) => $importMutation.mutate(text)}
onRun={() => handleRun("script")}
/>
{:else if stageKey === "style"}
<StyleStagePanel
bind:this={editor}
stage={panelProps.stage}
bind:chosenStyleId
busy={busyFor("style")}
onSave={(content) =>
$saveMutation.mutate({ key: "style", content })}
onRun={() => handleRun("style")}
/>
{:else if stageKey === "storyboard"}
<StoryboardStagePanel
bind:this={editor}
{projectId}
stage={panelProps.stage}
busy={busyFor("storyboard")}
onSave={(content) =>
$saveMutation.mutate({ key: "storyboard", content })}
onRun={() => handleRun("storyboard")}
/>
{:else if stageKey === "assets"}
<AssetsStagePanel
bind:this={editor}
stage={panelProps.stage}
{projectId}
busy={busyFor("assets")}
onSave={(content) =>
$saveMutation.mutate({ key: "assets", content })}
onRun={() => handleRun("assets")}
{onGenerate}
onRetryJob={(jobId) => $retryMutation.mutate(jobId)}
/>
{:else}
<ShotStagePanel
bind:this={editor}
stage={panelProps.stage}
{projectId}
busy={busyFor("shot")}
onSave={(content) =>
$saveMutation.mutate({ key: "shot", content })}
onRun={() => handleRun("shot")}
{onGenerate}
onRetryJob={(jobId) => $retryMutation.mutate(jobId)}
/>
{/if}
</div>
{/if}
</div>
</section>
<aside class="project-side">
<section class="project-assistant">
<h3>本阶段助手</h3>
<div class="project-agent">
<span class="agent-icon"><Icon name="project-grid" size={20} /></span>
<div class="b3-grow">
<b>{currentAgent?.name ?? STAGE_LABEL[stageKey] + "助手"}</b
><small
>{currentModelName ?? "未配置模型"} · {currentAgent?.skill_count ??
0} 个技能</small
>
</div>
<button
class="a-btn ghost sm"
disabled={!currentAgent}
onclick={openAgentChat}>对话</button
>
</div>
<div class="stage-input-wrap"><textarea
class="stage-instruction"
bind:value={instructions[stageKey]}
placeholder="补充要求(可选):节奏更慢、第 3 镜改俯拍…"
disabled={busyFor(stageKey)}
data-testid="stage-instruction"
></textarea></div>
<div class="b3-row">
<button
class="a-btn secondary sm b3-grow"
disabled={busyFor(stageKey) || $runMutation.isPending}
onclick={() => handleRun(stageKey)}
data-testid="stage-run-btn"
><Icon name="project-refresh" size={14} />{stageStatus === "todo"
? "生成本阶段"
: "重新生成本阶段"}</button
><button
class="a-btn ghost sm"
disabled={!currentStage?.confirmed_content ||
busyFor(stageKey) ||
$restoreMutation.isPending}
onclick={() => $restoreMutation.mutate(stageKey)}
data-testid="stage-restore-btn">恢复已确认版</button
>
</div>
{#if stageKey === "shot"}<a
class="a-btn secondary sm"
href={resolve("/build-scene")}>三维搭景 · 截取参考图</a
>{/if}
</section>
<section class="project-task-heading">
<h3>阶段任务 <span class="a-tag">{projectJobs.length}</span></h3>
<span class="b3-grow"></span><a
class="a-btn ghost sm"
href={resolve("/tasks")}>全部</a
>
</section>
<div class="project-tasks">
{#if projectJobs.length === 0}
<p class="jobs-empty">{t("workbench.tasksEmpty")}</p>
{:else}
<ul class="jobs-list" data-testid="workbench-jobs">
{#each projectJobs.slice(0, 8) as job (job.id)}
{@const live = jobProgressPct(job.id)}
<li class="job-row">
<span class="job-kind kind-{job.kind}">
<Icon
name={job.kind === "image" ? "image" : "film"}
size={12}
/>
</span>
<span class="job-body">
<span class="job-prompt"
>{job.payload?.prompt ??
job.payload?.source ??
`#${job.id}`}</span
>
<span class="job-time mono"
>{jobTime(job.created_at)}</span
>
</span>
<span class="job-state st-{job.status}">
{#if job.status === "running"}
<span class="breath"></span>
{#if live != null}{Math.round(live)}%{:else}{t(
"workbench.jobRunning",
)}{/if}
{:else if job.status === "pending"}
{t("workbench.jobQueued")}
{:else if job.status === "done"}
{t("workbench.jobDone")}
{:else}
{t("workbench.jobFailed")}
{/if}
</span>
{#if job.status === "pending" || job.status === "running"}
<button
type="button"
class="icon-btn"
title={t("workbench.cancelJob")}
onclick={() => $cancelMutation.mutate(job.id)}
>
<Icon name="stop" size={12} />
</button>
{:else if job.status === "failed"}
<button
type="button"
class="icon-btn"
title={t("workbench.retryJob")}
onclick={() => $retryMutation.mutate(job.id)}
>
<Icon name="project-refresh" size={12} />
</button>
{:else if job.output_paths?.[0]}
<a
class="icon-btn"
href={assetUrl(job.output_paths[0]) ?? "#"}
target="_blank"
rel="noopener external"
title={t("workbench.openOutput")}
>
<Icon name="external-link" size={12} />
</a>
{/if}
</li>
{/each}
</ul>
{/if}
</div>
</aside>
</div>
<footer class="project-footer">
<span class="a-tag {STATUS_CLASS[stageStatus]}"
>{t(STATUS_LABEL[stageStatus])}</span
><span class="project-save-state"
>{currentStage?.draft_edited
? "已编辑 · 草稿待确认"
: "确认后才会交给下一阶段使用"}</span
><span class="b3-grow"></span><button class="a-btn secondary" disabled={busyFor(stageKey) || !currentStage?.content || $saveMutation.isPending} onclick={() => editor?.saveDraft()} data-testid="stage-save-btn">保存草稿</button><button
class="a-btn primary"
disabled={stageStatus !== "awaiting_confirm" ||
$confirmMutation.isPending || !!editor?.hasUnsavedDraft()}
onclick={() => $confirmMutation.mutate(stageKey)}
data-testid="stage-confirm-btn"
><Icon name="project-check" size={14} />{isLastStage
? "确认镜头脚本"
: `确认${STAGE_LABEL[stageKey]},进入${STAGE_LABEL[STAGE_ORDER[STAGE_ORDER.indexOf(stageKey) + 1]]}阶段`}</button
>
</footer>
</div>{/if}
</AppShell>
<Modal bind:open={compareOpen} title="对比已确认版"
><div class="project-compare">
<section>
<h3>已确认版</h3>
<pre>{JSON.stringify(currentStage?.confirmed_content, null, 2)}</pre>
</section>
<section>
<h3>当前草稿</h3>
<pre>{JSON.stringify(currentStage?.content, null, 2)}</pre>
</section>
</div></Modal
>
@@ -46,6 +46,12 @@
importOpen = false;
importText = '';
}
export function saveDraft() {
if (editing) save();
else if (stage.content) onSave?.(stage.content);
}
export function hasUnsavedDraft() { return editing; }
</script>
{#if stage.status === 'todo' && !text}
@@ -67,6 +67,12 @@
function jobActive(job: VideoShot['job']): boolean {
return !!job && (job.status === 'pending' || job.status === 'running');
}
export function saveDraft() {
if (editing) save();
else if (stage.content) onSave?.(stage.content);
}
export function hasUnsavedDraft() { return editing; }
</script>
{#if editing}
@@ -165,6 +171,7 @@
<PromptBlock label={t('workbench.vshot.promptLabel')} text={shot.prompt} />
<div class="vshot-foot">
{#if projectId}
<a class="a-btn ghost sm" href={resolve('/build-scene')}>三维搭景</a>
<a
class="a-btn ghost sm"
href={resolve(deskHref({
@@ -1,343 +1,97 @@
<script lang="ts">
import type { WorkbenchStage } from '$lib/api';
import { t } from '$lib/i18n.svelte';
import Icon from '$lib/components/ui/Icon.svelte';
import type { WorkbenchStage } from "$lib/api";
import { t } from "$lib/i18n.svelte";
/**
* Five-stage stepper (a2): one step per agent with the fusion status
* colors — gray=待开始, cyan=进行中 (+breath), amber=待确认,
* green=已确认, --info=用户改过草稿. A step unlocks when itself or any
* earlier stage has content; later steps stay clickable only after the
* previous one is confirmed.
*/
interface Props {
stages: WorkbenchStage[];
activeKey: string;
onSelect: (key: string) => void;
}
/**
* Five-stage stepper (a2): one step per agent with the fusion status
* colors — gray=待开始, cyan=进行中 (+breath), amber=待确认,
* green=已确认, --info=用户改过草稿. A step unlocks when itself or any
* earlier stage has content; later steps stay clickable only after the
* previous one is confirmed.
*/
interface Props {
stages: WorkbenchStage[];
activeKey: string;
onSelect: (key: string) => void;
}
let { stages, activeKey, onSelect }: Props = $props();
let { stages, activeKey, onSelect }: Props = $props();
const ORDER = ['script', 'style', 'storyboard', 'assets', 'shot'];
const ORDER = ["script", "style", "storyboard", "assets", "shot"];
function label(key: string): string {
return t(`workbench.stage.${key}`);
}
function label(key: string): string {
return (
{
script: "故事",
style: "风格",
storyboard: "分镜",
assets: "素材",
shot: "镜头脚本",
} as Record<string, string>
)[key];
}
function stateOf(
stage: WorkbenchStage,
): 'todo' | 'running' | 'draft' | 'edited' | 'confirmed' {
if (stage.status === 'running') return 'running';
if (stage.status === 'confirmed' && !stage.draft_edited) return 'confirmed';
if (stage.status === 'confirmed' && stage.draft_edited) return 'edited';
if (stage.status === 'awaiting_confirm')
return stage.draft_edited ? 'edited' : 'draft';
return 'todo';
}
function stateOf(
stage: WorkbenchStage,
): "todo" | "running" | "draft" | "edited" | "confirmed" {
if (stage.status === "running") return "running";
if (stage.status === "confirmed" && !stage.draft_edited) return "confirmed";
if (stage.status === "confirmed" && stage.draft_edited) return "edited";
if (stage.status === "awaiting_confirm")
return stage.draft_edited ? "edited" : "draft";
return "todo";
}
function stateLabel(stage: WorkbenchStage): string {
const s = stateOf(stage);
if (s === 'running') return t('workbench.state.running');
if (s === 'confirmed') return `✓ ${t('workbench.state.confirmed')}`;
if (s === 'edited') return t('workbench.state.edited');
if (s === 'draft') return t('workbench.state.draft');
return t('workbench.state.todo');
}
function stateLabel(stage: WorkbenchStage): string {
const s = stateOf(stage);
if (s === "running") return t("workbench.state.running");
if (s === "confirmed") return `✓ ${t("workbench.state.confirmed")}`;
if (s === "edited") return t("workbench.state.edited");
if (s === "draft") return t("workbench.state.draft");
return t("workbench.state.todo");
}
function unlocked(stages: WorkbenchStage[], key: string): boolean {
const idx = ORDER.indexOf(key);
if (idx <= 0) return true;
const prev = stages.find((s) => s.stage_key === ORDER[idx - 1]);
return prev?.status === 'confirmed';
}
function unlocked(stages: WorkbenchStage[], key: string): boolean {
const idx = ORDER.indexOf(key);
if (idx <= 0) return true;
const prev = stages.find((s) => s.stage_key === ORDER[idx - 1]);
return prev?.status === "confirmed";
}
</script>
<ol class="stepper" data-testid="workbench-stepper">
{#each ORDER as key, i (key)}
{@const stage = stages.find((s) => s.stage_key === key)}
{@const state = stage ? stateOf(stage) : 'todo'}
{@const open = stage ? unlocked(stages, key) : i === 0}
<li class="step">
{#if i > 0}<span class="bar" class:bar-done={unlocked(stages, key)}
></span>{/if}
<button
type="button"
class="step-btn"
class:active={activeKey === key}
class:locked={!open}
data-stage={key}
data-state={state}
onclick={() => onSelect(key)}
aria-current={activeKey === key ? 'step' : undefined}
title={open
? label(key)
: t('workbench.lockedStep', { stage: label(ORDER[i - 1]) })}
>
<span class="avatar"
>{state === 'confirmed' ? '✓' : String(i + 1).padStart(2, '0')}</span
>
<span class="step-copy">
<span class="step-name">{label(key)}</span>
<span class="step-state st-{state}">
{#if state === 'running'}<span class="breath" aria-hidden="true"
></span>{/if}
{stage ? stateLabel(stage) : t('workbench.state.todo')}
</span>
</span>
</button>
</li>
{/each}
{#each ORDER as key, i (key)}
{@const stage = stages.find((s) => s.stage_key === key)}
{@const state = stage ? stateOf(stage) : "todo"}
{@const open = stage ? unlocked(stages, key) : i === 0}
<li class="step">
{#if i > 0}<span class="bar" class:bar-done={unlocked(stages, key)}
></span>{/if}
<button
type="button"
class="step-btn"
class:active={activeKey === key}
class:locked={!open}
data-stage={key}
data-state={state}
onclick={() => onSelect(key)}
aria-current={activeKey === key ? "step" : undefined}
title={open
? label(key)
: t("workbench.lockedStep", { stage: label(ORDER[i - 1]) })}
>
<span class="avatar">{#if state === "confirmed"}<Icon name="project-check" size={18}/>{:else}{String(i + 1)}{/if}</span
>
<span class="step-copy">
<span class="step-name">{label(key)}</span>
<span class="step-state st-{state}">
{#if state === "running"}<span class="breath" aria-hidden="true"
></span>{/if}
{stage ? stateLabel(stage) : t("workbench.state.todo")}
</span>
</span>
</button>
</li>
{/each}
</ol>
<style>
.stepper {
display: flex;
align-items: stretch;
gap: 0;
list-style: none;
margin: 0;
padding: 12px 0 4px;
overflow-x: auto;
scrollbar-width: thin;
}
.step {
display: flex;
align-items: center;
flex-shrink: 0;
}
.bar {
width: 26px;
height: 2px;
background: var(--surface-3);
border-radius: 1px;
}
.bar-done {
background: var(--success);
}
.step-btn {
display: flex;
align-items: center;
gap: 8px;
padding: 6px 10px;
border: 1px solid transparent;
border-radius: 9px;
background: transparent;
cursor: pointer;
font-family: inherit;
text-align: left;
transition:
background-color 150ms cubic-bezier(0.16, 1, 0.3, 1),
border-color 150ms;
}
.step-btn:hover:not(.locked) {
background: var(--surface-3);
}
.step-btn.active {
background: var(--surface-2);
border-color: var(--border-strong);
/* §3.2 当前段:金色下划 2px 内阴影 + 段名金色。 */
box-shadow: inset 0 -2px 0 var(--accent);
}
.step-btn.active .step-name {
color: var(--accent);
}
.step-btn[data-state='confirmed']:not(.active) .step-name {
color: var(--success);
}
.step-btn.locked {
opacity: 0.55;
cursor: not-allowed;
}
.avatar {
width: 32px;
height: 32px;
border-radius: 8px;
display: inline-flex;
align-items: center;
justify-content: center;
font-size: 14px;
font-weight: 700;
color: var(--text);
flex-shrink: 0;
}
.step-copy {
display: flex;
flex-direction: column;
gap: 1px;
min-width: 58px;
}
.step-name {
font-size: 12.5px;
font-weight: 600;
color: var(--text);
white-space: nowrap;
}
.step-state {
display: inline-flex;
align-items: center;
gap: 5px;
font-size: 10.5px;
white-space: nowrap;
}
.st-todo {
color: var(--text-3);
}
.st-running {
color: var(--run);
}
.st-draft {
color: var(--warn);
}
.st-edited {
color: var(--warn);
}
.st-confirmed {
color: var(--success);
}
.step-btn.active[data-state='running'] .avatar,
.step-btn[data-state='running'] .avatar {
box-shadow: 0 0 10px var(--run-soft);
}
.breath {
width: 6px;
height: 6px;
border-radius: 50%;
background: var(--run);
animation: wb-breath 2s cubic-bezier(0.16, 1, 0.3, 1) infinite;
}
@keyframes wb-breath {
0%,
100% {
opacity: 0.35;
}
50% {
opacity: 1;
}
}
@media (prefers-reduced-motion: reduce) {
.breath {
animation: none;
opacity: 0.8;
}
}
.stepper {
display: flex;
flex-direction: column;
align-items: stretch;
gap: 7px;
padding: 0;
overflow: visible;
}
.step {
display: block;
}
.bar {
display: none;
}
.step-btn {
width: 100%;
padding: 12px 10px;
border: 0;
gap: 10px;
}
.avatar {
width: 27px;
height: 27px;
background: none !important;
border: 1px solid var(--line-2);
border-radius: 50%;
font: 9px var(--mono);
color: var(--text-3);
font-weight: 400;
}
.step-btn.active {
background: #e8eafe;
box-shadow: none;
}
.step-btn.active .avatar {
background: var(--accent) !important;
border-color: var(--accent);
color: white;
}
.step-name {
font-size: 11px;
font-weight: 550;
}
.step-state {
font-size: 9px;
margin-top: 5px;
}
.step-copy {
min-width: 0;
}
.step-btn.locked {
opacity: 0.7;
cursor: pointer;
}
.step-btn[data-state='confirmed'] .avatar {
border-color: var(--ok);
color: var(--ok);
}
.stepper {
display: flex;
flex-direction: row;
align-items: stretch;
gap: 8px;
padding: 0;
overflow-x: auto;
}
.step {
display: flex;
flex: 1;
min-width: 130px;
}
.bar {
display: none;
}
.step-btn {
width: 100%;
border: 1px solid #ffffff10;
border-radius: 10px;
background: #12171c;
padding: 12px;
gap: 10px;
}
.step-btn.active {
background: #00cae012;
border-color: #00cae080;
box-shadow: none;
}
.step-btn.active .avatar {
background: #00cae012 !important;
color: var(--accent);
border-color: var(--accent);
}
.step-btn.active .step-name {
color: #86e8f1;
}
.step-state {
margin-top: 3px;
}
.step-btn[data-state='confirmed'] .avatar {
color: var(--ok);
}
.step-btn.locked {
opacity: 0.65;
}
.step-btn:hover:not(.active) {
background: #ffffff07;
}
</style>
@@ -1,339 +1,349 @@
<script lang="ts">
import type { StoryboardShot, WorkbenchStage } from '$lib/api';
import Icon from '$lib/components/ui/Icon.svelte';
import { t } from '$lib/i18n.svelte';
import { resolve } from "$app/paths";
import { assetUrl } from "$lib/api";
import { deskHref } from "$lib/components/generate/context";
import type { StoryboardShot, WorkbenchStage } from "$lib/api";
import Icon from "$lib/components/ui/Icon.svelte";
import { t } from "$lib/i18n.svelte";
interface Props {
stage: WorkbenchStage;
onSave?: (content: Record<string, unknown>) => void;
onRun?: (instruction?: string) => void;
busy?: boolean;
}
interface Props {
stage: WorkbenchStage;
projectId?: number;
onSave?: (content: Record<string, unknown>) => void;
onRun?: (instruction?: string) => void;
busy?: boolean;
}
let { stage, onSave, onRun, busy = false }: Props = $props();
let { stage, projectId, onSave, onRun, busy = false }: Props = $props();
let editing = $state(false);
let draft = $state<StoryboardShot[]>([]);
let editing = $state(false);
let selectedShot = $state<string | null>(null);
let draft = $state<StoryboardShot[]>([]);
const shots = $derived(
Array.isArray((stage.content as { shots?: unknown } | null)?.shots)
? ((stage.content as unknown as { shots: StoryboardShot[] }).shots ?? [])
: [],
);
const totalDuration = $derived(shots.reduce((acc, s) => acc + (s.duration_sec || 0), 0));
const shots = $derived(
Array.isArray((stage.content as { shots?: unknown } | null)?.shots)
? ((stage.content as unknown as { shots: StoryboardShot[] }).shots ?? [])
: [],
);
const totalDuration = $derived(
shots.reduce((acc, s) => acc + (s.duration_sec || 0), 0),
);
function startEdit() {
draft = shots.map((s) => ({ ...s }));
editing = true;
}
function startEdit() {
draft = shots.map((s) => ({ ...s }));
editing = true;
}
function save() {
const clean = draft.filter((s) => s.description.trim());
if (!clean.length) return;
onSave?.({ shots: clean });
editing = false;
}
function save() {
const clean = draft.filter((s) => s.description.trim());
if (!clean.length) return;
onSave?.({ shots: clean });
editing = false;
}
function addShot() {
draft = [
...draft,
{
id: `shot-new-${draft.length + 1}-${Date.now() % 1000}`,
scene: `${draft.length + 1}`,
shot_size: '中景',
camera: '固定',
description: '',
dialog: '',
duration_sec: 3,
},
];
}
function addShot() {
draft = [
...draft,
{
id: `shot-new-${draft.length + 1}-${Date.now() % 1000}`,
scene: `${draft.length + 1}`,
shot_size: "中景",
camera: "固定",
description: "",
dialog: "",
duration_sec: 3,
},
];
}
function removeShot(id: string) {
draft = draft.filter((s) => s.id !== id);
}
function removeShot(id: string) {
draft = draft.filter((s) => s.id !== id);
}
export function saveDraft() {
if (editing) save();
else if (stage.content) onSave?.(stage.content);
}
export function hasUnsavedDraft() { return editing; }
</script>
{#if editing}
<div class="editor">
<div class="edit-list">
{#each draft as shot, i (shot.id)}
<div class="shot-edit">
<div class="shot-edit-head">
<span class="shot-index mono">{String(i + 1).padStart(2, '0')}</span>
<button
type="button"
class="icon-btn danger"
title={t('workbench.board.remove')}
onclick={() => removeShot(shot.id)}
>
<Icon name="trash" size={13} />
</button>
</div>
<div class="shot-fields">
<input class="a-input w-xs" bind:value={shot.scene} placeholder={t('workbench.board.scene')} />
<input class="a-input w-xs" bind:value={shot.shot_size} placeholder={t('workbench.board.size')} />
<input class="a-input w-xs" bind:value={shot.camera} placeholder={t('workbench.board.camera')} />
<input
class="a-input w-xs mono"
type="number"
min="0.5"
step="0.5"
bind:value={shot.duration_sec}
placeholder={t('workbench.board.duration')}
/>
</div>
<textarea
class="a-input"
rows="2"
bind:value={shot.description}
placeholder={t('workbench.board.descPh')}
></textarea>
<input class="a-input" bind:value={shot.dialog} placeholder={t('workbench.board.dialogPh')} />
</div>
{/each}
</div>
<div class="editor-foot">
<button type="button" class="a-btn ghost" onclick={addShot}>
<Icon name="plus" size={13} />
{t('workbench.board.add')}
</button>
<div class="foot-btns">
<button type="button" class="a-btn ghost" onclick={() => (editing = false)}>
{t('workbench.cancelEdit')}
</button>
<button
type="button"
class="a-btn primary"
disabled={!draft.some((s) => s.description.trim())}
onclick={save}
>
{t('workbench.saveDraft')}
</button>
</div>
</div>
</div>
<div class="editor">
<div class="edit-list">
{#each draft as shot, i (shot.id)}
<div class="shot-edit">
<div class="shot-edit-head">
<span class="shot-index mono">{String(i + 1).padStart(2, "0")}</span
>
<button
type="button"
class="icon-btn danger"
title={t("workbench.board.remove")}
onclick={() => removeShot(shot.id)}
>
<Icon name="trash" size={13} />
</button>
</div>
<div class="shot-fields">
<input
class="a-input w-xs"
bind:value={shot.scene}
placeholder={t("workbench.board.scene")}
/>
<input
class="a-input w-xs"
bind:value={shot.shot_size}
placeholder={t("workbench.board.size")}
/>
<input
class="a-input w-xs"
bind:value={shot.camera}
placeholder={t("workbench.board.camera")}
/>
<input
class="a-input w-xs mono"
type="number"
min="0.5"
step="0.5"
bind:value={shot.duration_sec}
placeholder={t("workbench.board.duration")}
/>
</div>
<textarea
class="a-input"
rows="2"
bind:value={shot.description}
placeholder={t("workbench.board.descPh")}
></textarea>
<input
class="a-input"
bind:value={shot.dialog}
placeholder={t("workbench.board.dialogPh")}
/>
</div>
{/each}
</div>
<div class="editor-foot">
<button type="button" class="a-btn ghost" onclick={addShot}>
<Icon name="plus" size={13} />
{t("workbench.board.add")}
</button>
<div class="foot-btns">
<button
type="button"
class="a-btn ghost"
onclick={() => (editing = false)}
>
{t("workbench.cancelEdit")}
</button>
<button
type="button"
class="a-btn primary"
disabled={!draft.some((s) => s.description.trim())}
onclick={save}
>
{t("workbench.saveDraft")}
</button>
</div>
</div>
</div>
{:else if shots.length === 0}
<div class="todo-card">
<p class="todo-title">{t('workbench.board.todoTitle')}</p>
<p class="todo-body">{t('workbench.board.todoBody')}</p>
<button type="button" class="a-btn primary" disabled={busy} onclick={() => onRun?.()}>
<Icon name="sparkle" size={14} />
{t('workbench.board.generate')}
</button>
</div>
<div class="todo-card">
<p class="todo-title">{t("workbench.board.todoTitle")}</p>
<p class="todo-body">{t("workbench.board.todoBody")}</p>
<button
type="button"
class="a-btn primary"
disabled={busy}
onclick={() => onRun?.()}
>
<Icon name="sparkle" size={14} />
{t("workbench.board.generate")}
</button>
</div>
{:else}
<div class="board-table" data-testid="storyboard-table">
<div class="bt-head">
<span class="col-no">#</span>
<span class="col-scene">{t('workbench.board.scene')}</span>
<span class="col-size">{t('workbench.board.size')}</span>
<span class="col-camera">{t('workbench.board.camera')}</span>
<span class="col-desc">{t('workbench.board.desc')}</span>
<span class="col-dialog">{t('workbench.board.dialog')}</span>
<span class="col-dur mono">{t('workbench.board.duration')}</span>
</div>
{#each shots as shot, i (shot.id)}
<div class="bt-row">
<span class="col-no mono">{String(i + 1).padStart(2, '0')}</span>
<span class="col-scene">{shot.scene}</span>
<span class="col-size"><span class="tag">{shot.shot_size}</span></span>
<span class="col-camera">{shot.camera}</span>
<span class="col-desc">{shot.description}</span>
<span class="col-dialog">{shot.dialog || '—'}</span>
<span class="col-dur mono">{shot.duration_sec}s</span>
</div>
{/each}
</div>
<div class="view-foot">
<span class="hint mono">{t('workbench.board.summary', { n: shots.length, sec: totalDuration })}</span>
<div class="foot-btns">
{#if stage.status !== 'running'}
<button type="button" class="a-btn ghost" disabled={busy} onclick={startEdit}>
<Icon name="edit" size={13} />
{t('workbench.editDraft')}
</button>
<button type="button" class="a-btn ghost" disabled={busy} onclick={() => onRun?.()}>
<Icon name="retry" size={13} />
{t('workbench.regenerate')}
</button>
{/if}
</div>
</div>
<div class="storyboard-grid" data-testid="storyboard-table">
{#each shots as shot, i (shot.id)}
{@const media = (shot as StoryboardShot & { job?: { output?: string } })
.job?.output}
<article class="storyboard-card" class:selected={selectedShot === shot.id}>
<button type="button" class="storyboard-image" aria-label={`选择镜头 ${i+1}`} aria-pressed={selectedShot === shot.id} onclick={() => selectedShot = shot.id}>
{#if media}<img
src={assetUrl(media) ?? ""}
alt={shot.description}
/>{:else}<span class="storyboard-missing">暂无草图</span>{/if}<span
class="shot-time"
>{String(i + 1).padStart(2, "0")} · {shot.duration_sec}s</span
><span class="shot-media-state a-tag">{media ? "草图" : "待画"}</span>
</button>
<div class="storyboard-copy">
<div class="b3-row">
<b>{shot.shot_size}</b><span class="shot-scene"
>场 {shot.scene}</span
>
</div>
<p>{shot.description}</p>
<div class="b3-row">
<span class="shot-dialog" title={shot.dialog}
>台词 {shot.dialog || "—"}</span
><span class="b3-grow"></span>{#if projectId}<a
class="a-btn ghost sm"
href={resolve(
deskHref({
kind: "video",
projectId,
stage: "storyboard",
label: `分镜 ${i + 1}`,
prompt: shot.description,
durationSec: shot.duration_sec,
}),
)}><Icon name="project-film" size={14} />去生视频</a
>{/if}
</div>
</div>
</article>
{/each}
</div>
<div class="view-foot">
<span class="hint mono"
>{t("workbench.board.summary", {
n: shots.length,
sec: totalDuration,
})}</span
>
<div class="foot-btns">
{#if stage.status !== "running"}
<button
type="button"
class="a-btn ghost"
disabled={busy}
onclick={startEdit}
>
<Icon name="edit" size={13} />
{t("workbench.editDraft")}
</button>
<button
type="button"
class="a-btn ghost"
disabled={busy}
onclick={() => onRun?.()}
>
<Icon name="retry" size={13} />
{t("workbench.regenerate")}
</button>
{/if}
</div>
</div>
{/if}
<style>
.todo-card {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 12px;
padding: 22px;
border: 1px dashed var(--border-strong);
border-radius: 10px;
background: var(--surface);
}
.todo-title {
margin: 0;
font-size: 14.5px;
font-weight: 600;
color: var(--text);
}
.todo-body {
margin: 0;
font-size: 12.5px;
line-height: 1.7;
color: var(--text-2);
}
.todo-card {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 12px;
padding: 22px;
border: 1px dashed var(--border-strong);
border-radius: 10px;
background: var(--surface);
}
.todo-title {
margin: 0;
font-size: 14.5px;
font-weight: 600;
color: var(--text);
}
.todo-body {
margin: 0;
font-size: 12.5px;
line-height: 1.7;
color: var(--text-2);
}
.board-table {
border: 1px solid var(--border);
border-radius: 10px;
background: var(--surface);
overflow-x: auto;
}
.bt-head,
.bt-row {
display: grid;
grid-template-columns: 34px 64px 76px 76px minmax(220px, 1fr) minmax(140px, 0.8fr) 56px;
gap: 10px;
align-items: start;
padding: 10px 14px;
min-width: 680px;
}
.bt-head {
font-size: 11px;
font-weight: 600;
color: var(--text-3);
border-bottom: 1px solid var(--border);
background: var(--surface-2);
position: sticky;
top: 0;
}
.bt-row {
border-bottom: 1px solid var(--border);
font-size: 12.5px;
line-height: 1.6;
color: var(--text);
}
.bt-row:last-child {
border-bottom: none;
}
.col-no {
color: var(--text-3);
}
.col-scene,
.col-camera {
color: var(--text-2);
white-space: nowrap;
}
.col-desc {
color: var(--text);
}
.col-dialog {
color: var(--text-2);
}
.col-dur {
text-align: right;
color: var(--text-2);
font-variant-numeric: tabular-nums;
white-space: nowrap;
}
.tag {
display: inline-block;
padding: 1px 8px;
border-radius: 999px;
background: var(--surface-3);
color: var(--text-2);
font-size: 10.5px;
white-space: nowrap;
}
.editor {
display: flex;
flex-direction: column;
gap: 12px;
}
.edit-list {
display: flex;
flex-direction: column;
gap: 10px;
}
.shot-edit {
display: flex;
flex-direction: column;
gap: 8px;
padding: 14px;
border: 1px solid var(--border);
border-radius: 10px;
background: var(--surface);
}
.shot-edit-head {
display: flex;
align-items: center;
justify-content: space-between;
}
.shot-index {
color: var(--text-3);
font-size: 11px;
}
.shot-fields {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.a-input.mono {
font-family: var(--font-a-mono);
font-size: 11.5px;
}
.a-input.w-xs {
width: 110px;
flex: 1;
min-width: 96px;
}
.editor {
display: flex;
flex-direction: column;
gap: 12px;
}
.edit-list {
display: flex;
flex-direction: column;
gap: 10px;
}
.shot-edit {
display: flex;
flex-direction: column;
gap: 8px;
padding: 14px;
border: 1px solid var(--border);
border-radius: 10px;
background: var(--surface);
}
.shot-edit-head {
display: flex;
align-items: center;
justify-content: space-between;
}
.shot-index {
color: var(--text-3);
font-size: 11px;
}
.shot-fields {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.a-input.mono {
font-family: var(--font-a-mono);
font-size: 11.5px;
}
.a-input.w-xs {
width: 110px;
flex: 1;
min-width: 96px;
}
.editor-foot {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
}
.editor-foot {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
}
.view-foot {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
}
.hint {
font-size: 11.5px;
color: var(--text-3);
font-variant-numeric: tabular-nums;
}
.foot-btns {
display: flex;
gap: 8px;
}
.icon-btn {
display: inline-flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
border-radius: 7px;
border: 1px solid transparent;
background: transparent;
color: var(--text-3);
cursor: pointer;
}
.icon-btn:hover {
background: var(--surface-3);
color: var(--text);
}
.icon-btn.danger:hover {
background: var(--error-dim);
color: var(--error);
}
.view-foot {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
}
.hint {
font-size: 11.5px;
color: var(--text-3);
font-variant-numeric: tabular-nums;
}
.foot-btns {
display: flex;
gap: 8px;
}
.icon-btn {
display: inline-flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
border-radius: 7px;
border: 1px solid transparent;
background: transparent;
color: var(--text-3);
cursor: pointer;
}
.icon-btn:hover {
background: var(--surface-3);
color: var(--text);
}
.icon-btn.danger:hover {
background: var(--error-dim);
color: var(--error);
}
</style>
@@ -0,0 +1,32 @@
<script lang="ts">
import { resolve } from "$app/paths";
import Icon from "$lib/components/ui/Icon.svelte";
let {
title,
project = false,
onSearch,
}: { title: string; project?: boolean; onSearch: () => void } = $props();
</script>
<header class="studio-header">
<div class="studio-crumb">
{#if project}<a href={resolve("/")}>工作室</a><span>/</span>{/if}<b
>{title}</b
>
</div>
{#if !project}<span class="studio-greeting">晚上好,今天从哪里继续?</span
>{/if}
<span class="b3-grow"></span>
{#if project}<a class="a-btn ghost sm" href={resolve("/canvas")}
><Icon name="project-canvas" size={14} />灵感画布</a
><a class="a-btn ghost sm" href={resolve("/library")}
><Icon name="project-lib" size={14} />项目素材</a
>{:else}<button
class="studio-search"
onclick={onSearch}
aria-keyshortcuts="Control+K Meta+K"
><Icon name="project-search" size={14} /><span>搜索项目、工作流、素材…</span><kbd
>Ctrl K</kbd
></button
>{/if}
</header>
@@ -59,6 +59,12 @@
},
];
}
export function saveDraft() {
if (editing) save();
else if (stage.content) onSave?.(stage.content);
}
export function hasUnsavedDraft() { return editing; }
</script>
{#if editing}
@@ -0,0 +1,831 @@
.home,
.home *,
.studio-header,
.studio-header *,
.project-workspace,
.project-workspace * {
box-sizing: border-box;
}
/* HEL-731: approved sample 01/08/16, shared by studio and project panels.
Theme values live in static/themes.css; no per-page theme overrides. */
.b3-row {
display: flex;
align-items: center;
gap: 8px;
}
.b3-grow {
flex: 1;
min-width: 0;
}
.b3-empty {
color: var(--tx-3);
font-size: 12px;
margin: 0;
}
.home {
display: grid;
grid-template-columns: minmax(0, 1fr) var(--project-side-width);
gap: 20px;
padding: 20px 24px 28px;
max-width: 1680px;
--scene: var(--gold);
}
.home .main-col {
min-width: 0;
}
.studio-hero {
position: relative;
border-radius: var(--project-hero-radius);
overflow: hidden;
min-height: 250px;
display: grid;
grid-template-columns: 1.1fr 1fr;
border: 1px solid var(--line-2);
background: var(--studio-hero-bg);
}
.hero-art {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
object-fit: cover;
opacity: 0.9;
}
.hero-shade {
position: absolute;
inset: 0;
background: linear-gradient(
90deg,
color-mix(in srgb, var(--bg-0) 96%, transparent) 0%,
color-mix(in srgb, var(--bg-0) 85%, transparent) 38%,
color-mix(in srgb, var(--bg-0) 15%, transparent) 75%,
color-mix(in srgb, var(--bg-0) 30%, transparent)
);
}
.hero-copy {
position: relative;
padding: 26px 28px;
display: flex;
flex-direction: column;
gap: 12px;
justify-content: center;
}
.studio-eyebrow {
display: inline-flex;
align-items: center;
gap: 6px;
height: 22px;
padding: 0 9px;
border-radius: var(--project-eyebrow-radius);
font-size: 11px;
letter-spacing: 0.5px;
width: max-content;
color: color-mix(in srgb, var(--scene) 75%, var(--hi));
border: 1px solid color-mix(in srgb, var(--scene) 45%, transparent);
background: color-mix(in srgb, var(--scene) 10%, transparent);
}
.studio-display {
font: 700 var(--fs-display)/1.25 var(--project-display-font);
letter-spacing: 0.5px;
margin: 0;
}
.studio-display em {
font-style: normal;
background: var(--display-grad);
background-clip: text;
color: transparent;
}
.hero-copy > .b3-row {
margin-top: 6px;
}
.hero-description {
color: var(--tx-2);
max-width: 420px;
margin: 0;
}
.studio-stages {
display: flex;
gap: 4px;
margin-top: 4px;
max-width: 440px;
}
.studio-stages div {
flex: 1;
display: flex;
flex-direction: column;
gap: 6px;
font-size: 11px;
color: var(--tx-3);
}
.studio-stages i {
height: 3px;
border-radius: 3px;
background: rgb(var(--ink) / 0.12);
}
.studio-stages .done i {
background: var(--gold);
}
.studio-stages .cur i {
background: linear-gradient(
90deg,
var(--gold) 50%,
rgb(var(--ink) / 0.12) 50%
);
}
.studio-stages .done,
.studio-stages .cur {
color: var(--tx-1);
}
.studio-quick {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 12px;
margin-top: 16px;
}
.studio-quick > * {
border-radius: var(--project-card-radius);
border: 1px solid var(--line);
background: var(--bg-2);
padding: 14px;
display: flex;
flex-direction: column;
gap: 8px;
cursor: pointer;
transition:
transform var(--t-base) var(--ease-out),
border-color var(--t-base);
color: var(--tx-1);
font: inherit;
text-decoration: none;
text-align: left;
}
.studio-quick > *:hover {
transform: translateY(-2px);
border-color: color-mix(in srgb, var(--scene) 45%, transparent);
}
.studio-quick .qi {
width: 34px;
height: 34px;
border-radius: 10px;
display: grid;
place-items: center;
background: color-mix(in srgb, var(--scene) 15%, transparent);
color: var(--scene);
}
.studio-quick b {
font-size: 14px;
}
.studio-quick p {
font-size: 12px;
color: var(--tx-3);
margin: 0;
}
.home .projects-head {
display: flex;
align-items: center;
gap: 10px;
margin: 24px 0 12px;
}
.home .projects-head h2 {
margin: 0;
font-size: 15px;
font-weight: 600;
}
.studio-filters {
display: flex;
width: 200px;
border: 1px solid var(--line-2);
border-radius: 9px;
padding: 3px;
}
.studio-filters button {
flex: 1;
background: none;
color: var(--tx-3);
border: 0;
font: inherit;
font-size: 12px;
border-radius: 7px;
padding: 4px;
cursor: pointer;
}
.studio-filters .on {
background: var(--bg-3);
color: var(--tx-1);
}
.home .cards {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(176px, 1fr));
gap: 12px;
}
.studio-side {
display: flex;
flex-direction: column;
gap: 14px;
}
.studio-side-card {
border-radius: var(--project-card-radius);
background: var(--bg-1);
border: 1px solid var(--line);
padding: 14px;
display: flex;
flex-direction: column;
gap: 10px;
}
.studio-side-card h3 {
margin: 0;
font-size: 13px;
font-weight: 600;
display: flex;
align-items: center;
gap: 8px;
}
.studio-side-card small {
font-size: 11px;
color: var(--tx-3);
}
.waiting-dot {
width: 6px;
height: 6px;
border-radius: 50%;
background: var(--sc-agent);
box-shadow: 0 0 8px var(--sc-agent);
}
.home .confirm-item {
border-radius: var(--project-confirm-radius);
border: 1px solid color-mix(in srgb, var(--sc-agent) 35%, transparent);
background: linear-gradient(
135deg,
color-mix(in srgb, var(--sc-agent) 10%, transparent),
transparent
);
padding: 10px 12px;
display: flex;
flex-direction: column;
gap: 6px;
font-size: 12.5px;
}
.studio-outputs {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 6px;
}
.studio-outputs a {
aspect-ratio: 1;
overflow: hidden;
border-radius: 6px;
background: var(--bg-2);
}
.studio-outputs img,
.studio-outputs video {
width: 100%;
height: 100%;
object-fit: cover;
}
.home .panel-empty {
padding: 24px;
color: var(--tx-3);
border: 1px dashed var(--line-2);
border-radius: var(--project-card-radius);
text-align: center;
}
.home .job-list {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 12px;
}
.home .job-row {
min-width: 0;
font-size: 12px;
}
.home .job-top,
.home .job-side {
display: flex;
align-items: center;
gap: 6px;
}
.home .job-name {
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.home .job-meta {
font-size: 11px;
color: var(--tx-3);
margin: 4px 0 0;
}
.home .job-status {
font-size: 11px;
}
.home .failed-text {
color: var(--err);
}
.home .running-text {
color: var(--sc-video);
}
.home .done-text {
color: var(--ok);
}
.home .retry-link {
background: none;
border: 0;
color: var(--tx-2);
cursor: pointer;
}
.project-workspace {
display: flex;
flex-direction: column;
flex: 1;
min-height: 0;
--scene: var(--sc-project);
}
.project-workspace .stepper {
display: flex;
align-items: stretch;
gap: 0;
margin: 0;
padding: 0 var(--s4);
list-style: none;
border-bottom: 1px solid var(--line);
background: var(--bg-1);
flex: none;
height: 58px;
}
.project-workspace .step {
flex: 1;
display: flex;
min-width: 0;
}
.project-workspace .bar {
display: none;
}
.project-workspace .step-btn {
flex: 1;
display: flex;
align-items: center;
gap: 10px;
padding: 0 14px;
cursor: pointer;
position: relative;
color: var(--tx-3);
transition: color var(--t-base);
background: transparent;
border: 0;
font: inherit;
text-align: left;
}
.project-workspace .step-btn:hover,
.project-workspace .step-btn.active {
color: var(--tx-1);
}
.project-workspace .avatar {
width: 26px;
height: 26px;
border-radius: 8px;
display: grid;
place-items: center;
font: 700 11px var(--mono);
border: 1px solid var(--line-3);
flex-shrink: 0;
}
.project-workspace .step-copy {
display: flex;
flex-direction: column;
line-height: 1.3;
}
.project-workspace .step-name {
font-size: 13px;
font-weight: 600;
}
.project-workspace .step-state {
font-size: 11px;
}
.project-workspace [data-state="confirmed"] .avatar {
background: color-mix(in srgb, var(--gold) 16%, transparent);
border-color: color-mix(in srgb, var(--gold) 50%, transparent);
color: var(--gold);
}
.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);
}
.project-workspace .step-btn.active::after {
content: "";
position: absolute;
left: 14px;
right: 14px;
bottom: -1px;
height: 2px;
background: var(--gold);
box-shadow: var(--project-active-shadow);
}
.project-workspace .step + .step::before {
content: "";
align-self: center;
width: 12px;
margin: 0 -6px;
height: 1px;
background: var(--line-3);
z-index: 1;
}
.project-body {
flex: 1;
display: grid;
grid-template-columns: minmax(0, 1fr) var(--project-side-width);
min-height: 0;
}
.project-main {
display: flex;
flex-direction: column;
min-height: 0;
min-width: 0;
}
.project-draftbar {
margin: 14px 18px 0;
border-radius: var(--project-draft-radius);
padding: 10px 12px 10px 14px;
display: flex;
align-items: center;
gap: 10px;
background: linear-gradient(
90deg,
color-mix(in srgb, var(--sc-agent) 14%, transparent),
color-mix(in srgb, var(--sc-agent) 4%, transparent)
);
border: 1px solid color-mix(in srgb, var(--sc-agent) 35%, transparent);
font-size: 12.5px;
}
.project-draftbar > svg {
color: var(--sc-agent);
flex-shrink: 0;
}
.project-content {
flex: 1;
min-height: 0;
overflow: auto;
padding: 14px 18px 18px;
}
.project-side {
border-left: 1px solid var(--line);
background: var(--project-side-bg);
display: flex;
flex-direction: column;
min-height: 0;
}
.project-assistant {
padding: 14px;
border-bottom: 1px solid var(--line);
display: flex;
flex-direction: column;
gap: 10px;
}
.project-assistant h3,
.project-task-heading h3 {
margin: 0;
font-size: 12.5px;
font-weight: 600;
display: flex;
align-items: center;
gap: 8px;
}
.project-agent {
display: flex;
align-items: center;
gap: 10px;
padding: 10px;
border-radius: var(--project-draft-radius);
background: linear-gradient(
135deg,
color-mix(in srgb, var(--sc-agent) 12%, transparent),
rgb(var(--ink) / 0.02)
);
border: 1px solid color-mix(in srgb, var(--sc-agent) 25%, transparent);
}
.project-agent b {
font-size: 13px;
}
.project-agent small {
display: block;
color: var(--tx-3);
font-size: 11px;
}
.agent-icon {
width: 34px;
height: 34px;
border-radius: 10px;
display: grid;
place-items: center;
background: color-mix(in srgb, var(--sc-agent) 20%, transparent);
color: var(--sc-agent);
}
.stage-input-wrap {
height: 70px;
color: var(--tx-3);
display: flex;
align-items: flex-start;
padding: 8px 10px;
border: 1px solid var(--line-2);
background: var(--well);
border-radius: var(--r-sm);
}
.stage-instruction {
flex: 1;
width: 100%;
height: 52px;
resize: none;
background: none;
border: 0;
outline: 0;
font: 400 12.5px/1.55 var(--font);
color: var(--tx-1);
padding: 0;
}
.stage-instruction::placeholder {
color: var(--tx-3);
}
.project-task-heading {
padding: 14px 14px 4px;
display: flex;
align-items: center;
gap: 8px;
}
.project-tasks {
flex: 1;
overflow: auto;
padding: 8px 10px;
}
.project-tasks .jobs-list {
list-style: none;
margin: 0;
padding: 0;
}
.project-tasks .job-row {
display: flex;
align-items: center;
gap: 8px;
padding: 10px 4px;
border-bottom: 1px solid var(--line);
}
.project-tasks .job-body {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
font-size: 12px;
}
.project-tasks .job-prompt {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.project-tasks .job-time,
.project-tasks .job-state {
color: var(--tx-3);
font-size: 11px;
}
.project-tasks .jobs-empty {
color: var(--tx-3);
font-size: 12px;
}
.project-footer {
flex: none;
min-height: 56px;
border-top: 1px solid var(--line);
display: flex;
align-items: center;
gap: 10px;
padding: 0 18px;
background: color-mix(in srgb, var(--bg-1) 85%, transparent);
}
.project-save-state {
font-size: 12px;
color: var(--tx-3);
}
.project-workspace .stage-note {
padding: 14px;
margin-bottom: 12px;
border: 1px solid var(--line);
border-radius: var(--project-card-radius);
font-size: 12.5px;
}
.project-workspace .note-actions {
display: flex;
gap: 8px;
justify-content: flex-end;
}
.project-workspace .stage-note textarea {
width: 100%;
min-height: 100px;
}
.project-workspace .stage-note p {
white-space: pre-wrap;
}
.project-compare {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 16px;
}
.project-compare pre {
white-space: pre-wrap;
overflow-wrap: anywhere;
font-size: 12px;
max-height: 60vh;
overflow: auto;
}
.project-workspace .error-banner {
color: var(--err);
padding: 14px 18px;
}
.project-load-pad {
padding: 24px;
}
.project-workspace .icon-btn {
border: 0;
background: none;
color: var(--tx-2);
cursor: pointer;
}
.storyboard-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(250px, 1fr));
gap: 12px;
align-content: start;
}
.storyboard-card {
border-radius: var(--project-shot-radius);
background: var(--bg-2);
border: 1px solid var(--line);
overflow: hidden;
transition:
border-color var(--t-base),
transform var(--t-base) var(--ease-out);
}
.storyboard-card:hover {
border-color: var(--line-3);
transform: translateY(-2px);
}
.storyboard-card.selected {
border-color: color-mix(in srgb, var(--gold) 50%, transparent);
}
.storyboard-image {
width: 100%;
border: 0;
padding: 0;
font: inherit;
color: inherit;
cursor: pointer;
aspect-ratio: 16/9;
position: relative;
display: grid;
place-items: center;
background: var(--gen-bg);
}
.storyboard-image img {
width: 100%;
height: 100%;
object-fit: cover;
}
.storyboard-missing {
font-size: 12px;
color: var(--tx-3);
}
.shot-time {
position: absolute;
left: 8px;
top: 8px;
font: 700 11px var(--mono);
background: var(--media-scrim);
color: var(--media-text);
padding: 2px 6px;
border-radius: 4px;
}
.shot-media-state {
--tag-background: var(--project-media-tag-bg);
--tag-color: var(--project-media-tag-color);
--tag-border: var(--project-media-tag-border);
position: absolute;
right: 8px;
top: 8px;
}
.storyboard-copy {
padding: 9px 11px 11px;
display: flex;
flex-direction: column;
gap: 5px;
font-size: 12px;
}
.storyboard-copy p {
color: var(--tx-2);
margin: 0;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.shot-scene,
.shot-dialog {
color: var(--tx-3);
font-size: 11px;
}
.shot-dialog {
max-width: 50%;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.studio-header {
height: var(--head);
flex: none;
display: flex;
align-items: center;
gap: var(--s3);
padding: 0 var(--s4);
border-bottom: 1px solid var(--line);
background: var(--project-header-bg);
backdrop-filter: blur(10px);
position: relative;
z-index: 3;
}
.studio-crumb {
display: flex;
align-items: center;
gap: 6px;
color: var(--tx-3);
font-size: 12px;
}
.studio-crumb b {
color: var(--tx-1);
font-size: 14px;
font-weight: 600;
}
.studio-crumb a {
color: inherit;
text-decoration: none;
}
.studio-greeting {
font-size: 12px;
color: var(--tx-3);
}
.studio-search {
width: 280px;
height: 34px;
display: flex;
align-items: center;
gap: 8px;
border-radius: var(--r-sm);
background: var(--well);
border: 1px solid var(--line-2);
padding: 0 10px;
color: var(--tx-3);
font: inherit;
font-size: 13px;
cursor: pointer;
}
.studio-search kbd {
margin-left: auto;
font: 500 10.5px/1 var(--mono);
color: var(--tx-3);
border: 1px solid var(--line-2);
border-bottom-width: 2px;
padding: 3px 5px;
border-radius: 4px;
background: var(--bg-2);
}
/* Configure the existing controls at the page boundary, keeping their source shared. */
.home,
.project-workspace,
.studio-header {
--icon-stroke: var(--project-icon-stroke);
--button-height: 32px;
--button-font-size: 12.5px;
--button-weight: 500;
--button-radius: var(--project-button-radius);
--tag-height: 18px;
--tag-padding: 6px;
--tag-radius: 4px;
--tag-font-size: 11px;
--tag-border-width: 1px;
--tag-border: var(--line);
--tag-background: rgb(var(--ink) / 0.05);
--tag-color: var(--tx-2);
--tag-warn-color: var(--sc-agent);
--tag-warn-background: rgb(var(--ink) / 0.05);
--tag-warn-border: color-mix(in srgb, var(--sc-agent) 40%, transparent);
--button-sm-font-size: 12px;
--button-sm-height: 26px;
--button-sm-padding: 9px;
--button-sm-radius: var(--project-button-sm-radius);
--button-primary-height: 32px;
--button-primary-padding: 12px;
--button-primary-radius: var(--project-button-radius);
--button-primary-weight: 700;
--button-primary-border-width: 0px;
--button-secondary-bg: rgb(var(--ink) / 0.03);
--button-secondary-radius: var(--project-button-radius);
}
+4
View File
@@ -7,24 +7,28 @@ export const THEMES = [
id: "night",
name: "夜片场",
english: "Nocturne",
tone: "深色 · 默认",
description: "墨黑与钨丝金 · 沉浸式创作",
},
{
id: "day",
name: "日光棚",
english: "Daylight",
tone: "浅色",
description: "暖白纸面 · 白天办公与长时间审稿",
},
{
id: "grade",
name: "调色间",
english: "Grading",
tone: "中性深灰",
description: "中性深灰 · 判断作品色彩",
},
{
id: "bluehour",
name: "蓝调时刻",
english: "Blue Hour",
tone: "深蓝",
description: "靛蓝与珊瑚 · 提案与灵感发散",
},
] as const;
File diff suppressed because it is too large Load Diff
+12 -10
View File
@@ -94,7 +94,7 @@ code,
.a-btn.sm {
min-height: var(--button-sm-height, 28px);
padding: 0 var(--button-sm-padding, var(--sp-4));
font-size: var(--fs-aux);
font-size: var(--button-sm-font-size, var(--fs-aux));
border-radius: var(--button-sm-radius, var(--r-sm));
}
.a-btn.xs {
@@ -107,7 +107,7 @@ code,
width: 100%;
}
.a-btn.primary {
border-color: transparent;
border: var(--button-primary-border-width, 1px) solid transparent;
border-bottom-width: 0;
min-height: var(--button-primary-height, 40px);
padding: 0 var(--button-primary-padding, 18px);
@@ -247,12 +247,13 @@ button.status-chip:active {
display: inline-flex;
align-items: center;
gap: var(--sp-1);
height: 20px;
padding: 0 var(--sp-2);
border-radius: var(--r-sm);
background: var(--bg-4);
color: var(--text-2);
font-size: var(--fs-aux);
height: var(--tag-height, 20px);
padding: 0 var(--tag-padding, var(--sp-2));
border: var(--tag-border-width, 0px) solid var(--tag-border, transparent);
border-radius: var(--tag-radius, var(--r-sm));
background: var(--tag-background, var(--bg-4));
color: var(--tag-color, var(--text-2));
font-size: var(--tag-font-size, var(--fs-aux));
white-space: nowrap;
box-sizing: border-box;
transition:
@@ -276,8 +277,9 @@ button.status-chip:active {
background: var(--err-soft);
}
.a-tag.warn {
color: var(--warn);
background: var(--warn-soft);
color: var(--tag-warn-color, var(--warn));
background: var(--tag-warn-background, var(--warn-soft));
border-color: var(--tag-warn-border, transparent);
}
/* Real-progress bar (§1.3):<div class="a-progress" style="--p:.42"><i></i></div>.
+250 -158
View File
@@ -1,4 +1,6 @@
<script lang="ts">
import { SvelteMap } from "svelte/reactivity";
import Modal from "$lib/components/ui/Modal.svelte";
import { resolve } from "$app/paths";
import { createQuery, useQueryClient } from "@tanstack/svelte-query";
import ManagementPage from "$lib/components/manage/ManagementPage.svelte";
@@ -14,7 +16,7 @@
} from "$lib/api";
import { connectEvents } from "$lib/events";
import { parseDbTs } from "$lib/dbtime";
import { handleJobEvent, progressFor } from "$lib/jobProgress";
import { handleJobEvent } from "$lib/jobProgress";
import { jobStatusInfo } from "$lib/jobStatus";
import { isFileMissing, markOutputMissing } from "$lib/missingOutputs.svelte";
import { toast } from "$lib/toast";
@@ -26,7 +28,7 @@
} from "$lib/jobLabels";
import { createTakeDeleteQueue } from "$lib/components/generate/takeDelete.svelte";
/** Three-column real tasks: source filters, queue list and details. Existing SSE, retry, cancellation and undo-delete behavior is preserved. */
/** Full-width confirmed task table; existing mutations stay in the shared APIs. */
const client = useQueryClient();
const jobsQuery = createQuery({
@@ -43,9 +45,27 @@
queryFn: projects.list,
});
// Only explicit, attributed numeric reports are percentages. The shared
// progress store also estimates ticks for other consumers; do not show those here.
const reportedProgress = new SvelteMap<number, number>();
$effect(() => {
const off = connectEvents((ev) => {
handleJobEvent(ev);
if (ev.type === "job.progress" && typeof ev.data.job_id === "number") {
const value = ev.data.progress ?? ev.data.percent ?? ev.data.value;
if (typeof value === "number" && Number.isFinite(value)) {
reportedProgress.set(
ev.data.job_id,
Math.max(0, Math.min(100, value <= 1 ? value * 100 : value)),
);
}
}
if (
["job.completed", "job.failed", "job.deleted"].includes(ev.type) &&
typeof ev.data.job_id === "number"
) {
reportedProgress.delete(ev.data.job_id);
}
if (ev.type.startsWith("job.") || ev.type === "events.resync") {
void client.invalidateQueries({ queryKey: ["jobs"] });
void client.invalidateQueries({ queryKey: ["playground-jobs"] });
@@ -83,16 +103,11 @@
)
return false;
if (!q) return true;
return `${wfName(j)} ${jobSourceLabel(j)} ${j.error ?? ""} ${j.id}`
return `${taskName(j)} ${wfName(j)} ${projectOf(j)} ${jobSourceLabel(j)} ${connectionOf(j)} ${j.error ?? ""} ${j.id}`
.toLowerCase()
.includes(q);
});
});
const activeCount = $derived(
genJobs.filter((j) => j.status === "pending" || j.status === "running")
.length,
);
function wfName(job: Job): string {
return (
($workflowsQuery.data ?? []).find((w) => w.id === job.workflow_id)
@@ -105,7 +120,17 @@
const title = ($projectsQuery.data ?? []).find(
(p) => p.id === job.project_id,
)?.title;
return title ? `《${title}》` : "";
return title ? `项目 · ${title}` : "";
}
function sourceOf(job: Job): string {
if (isWorkflowTestJob(job)) return "工作流试跑";
if (
job.payload?.source === "studio-image" ||
job.payload?.source === "studio-video"
)
return "创作台";
return projectOf(job) || jobSourceLabel(job);
}
function timeOf(job: Job): string {
@@ -113,7 +138,7 @@
if (!Number.isFinite(ts)) return "—";
const d = new Date(ts);
const p = (n: number) => String(n).padStart(2, "0");
return `${p(d.getMonth() + 1)}-${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}`;
return `${p(d.getHours())}:${p(d.getMinutes())}`;
}
function toneOf(job: Job, base: string): string {
@@ -124,9 +149,19 @@
}
function labelOf(job: Job, base: { tone: string; label: string }): string {
return base.tone === "ok" && isFileMissing(job)
? t("jobst.missing")
: base.label;
if (base.tone === "ok" && isFileMissing(job)) return t("jobst.missing");
if (base.tone === "ok") return "完成";
if (job.status === "pending") return "排队";
if (job.status === "running" && job.phase === "comfy_running")
return "生成中";
if (
base.tone === "fail" &&
job.error &&
job.error !== "cancelled" &&
job.error.length <= 16
)
return `失败 · ${job.error}`;
return base.label;
}
function requestDelete(job: Job) {
@@ -172,6 +207,7 @@
let sourceFilter = $state("all");
let activeId = $state<number | null>(null);
let detailOpen = $state(false);
function matchesSource(job: Job, value: string) {
if (value === "all") return true;
if (value === "test") return isWorkflowTestJob(job);
@@ -180,6 +216,26 @@
? isStandaloneJob(job)
: !isStandaloneJob(job);
}
function taskName(job: Job): string {
return typeof job.payload?.title === "string" && job.payload.title.trim()
? job.payload.title
: `${job.kind === "image" ? "图片任务" : "视频任务"} #${job.id}`;
}
function connectionOf(job: Job): string {
const snapshot = job.payload?.endpoint;
if (
snapshot &&
typeof snapshot === "object" &&
"name" in snapshot &&
typeof snapshot.name === "string"
)
return snapshot.name;
return job.status === "pending" ? "等待分配" : "未记录";
}
function openDetail(job: Job) {
activeId = job.id;
detailOpen = true;
}
const activeJob = $derived(
genJobs.find((j) => j.id === activeId && !pendingIds.has(j.id)) ?? null,
);
@@ -193,7 +249,11 @@
onclick={() => (filter = value as Filter)}>{label}</button
>{/each}
</div>{/snippet}
{#snippet actions()}<label class="mg-search task-search"
{#snippet actions()}{#if sourceFilter !== "all"}<Button
size="sm"
variant="ghost"
onclick={() => (sourceFilter = "all")}>全部来源</Button
>{/if}<label class="mg-search task-search"
><Icon name="search-manage" size={14} /><input
type="search"
bind:value={search}
@@ -201,151 +261,183 @@
aria-label="搜索任务…"
/></label
>{/snippet}
<div class="mg-layout">
<aside class="mg-side" aria-label="任务来源">
<div class="mg-label">来源</div>
{#each [["all", "全部任务"], ["standalone", "创作台"], ["project", "项目"]] as [value, label] (value)}<button
class="mg-filter"
class:on={sourceFilter === value}
onclick={() => (sourceFilter = value)}
>{label}<small
>{genJobs.filter((j) => matchesSource(j, value)).length}</small
></button
>{/each}
<div class="mg-label">队列</div>
<span class="mg-dim">进行中 {activeCount} 项</span>
</aside>
<div class="mg-main">
<div class="mg-table">
<table>
<colgroup
><col style="width:42%" /><col style="width:20%" /><col
style="width:20%"
/><col style="width:18%" /></colgroup
><thead
><tr
><th>任务 / 工作流</th><th>来源</th><th>进度</th><th>时间</th></tr
></thead
><tbody>
{#each shown as job (job.id)}{@const st = jobStatusInfo(job)}
<tr class:on={activeId === job.id}
><td
><button class="mg-click" onclick={() => (activeId = job.id)}
><Icon
name={job.kind === "image" ? "image" : "film-manage"}
size={14}
/><span class="mg-name"
><b>{wfName(job)}</b><small
>#{job.id}{isWorkflowTestJob(job)
? " · 工作流试跑"
: ""}</small
></span
></button
></td
><td title={projectOf(job) || jobSourceLabel(job)}
>{projectOf(job) || jobSourceLabel(job)}</td
><td
><span class="mg-tag {toneOf(job, st.tone)}" title={st.detail}
>{labelOf(job, st)}</span
>{#if job.status === "running" && progressFor(job.id)?.progress != null}<span
class="mg-dim">{progressFor(job.id)?.progress}%</span
>{/if}</td
><td class="mg-mono">{timeOf(job)}</td></tr
<div class="mg-table task-table">
<table aria-label="生成任务">
<thead
><tr
><th class="task-thumbnail-column" aria-label="缩略图"></th><th
>任务</th
><th>来源</th><th>工作流</th><th>连接</th><th
class="task-progress-column">进度</th
><th>时间</th><th aria-label="操作"></th></tr
></thead
>
<tbody>
{#each shown as job (job.id)}{@const st = jobStatusInfo(job)}
<tr>
<td
><button
class="task-thumbnail"
aria-label={`查看任务 #${job.id}`}
onclick={() => openDetail(job)}
>
{/each}
</tbody>
</table>
{#if $jobsQuery.isLoading}<p class="mg-empty">
正在读取任务…
</p>{:else if $jobsQuery.isError}<p class="mg-empty" role="alert">
任务加载失败<Button onclick={() => $jobsQuery.refetch()}
>重试</Button
>
</p>{:else if !shown.length}<div class="mg-empty">
<p>
{search || filter !== "all"
? t("tasks.emptyFiltered")
: t("tasks.empty")}
</p>
<a class="a-btn secondary sm" href={resolve('/image')}>前往创作台</a>
</div>{/if}
</div>
</div>
<aside class="mg-detail" aria-label="任务详情">
{#if activeJob}{@const job = activeJob}{@const st = jobStatusInfo(job)}
<h2>{wfName(job)}</h2>
<div class="mg-mono">#{job.id} · {timeOf(job)}</div>
<div class="mg-row">
<span class="mg-tag {toneOf(job, st.tone)}">{labelOf(job, st)}</span
><span class="mg-dim">{jobSourceLabel(job)}</span>
</div>
<div class="mg-field">
<h3>运行状态</h3>
<p class="mg-dim">{job.error ?? st.detail}</p>
{#if job.status === "running"}<p class="mg-dim">
{progressFor(job.id)?.progress != null
? progressFor(job.id)?.progress + "%"
: "等待服务报告进度"}
</p>{/if}
</div>
{#if job.output_paths?.length}<div class="mg-field">
<h3>产出 · {job.output_paths.length}</h3>
<!-- Output paths may repeat. Keep positional video DOM reuse so playback state stays with its original slot. -->
{#each job.output_paths as path, i (i)}<div class="task-output">
{#if job.kind === "image"}<img
src={assetUrl(path)}
alt={wfName(job)}
{#if job.kind === "image" && job.output_paths?.length && !isFileMissing(job)}
<img
src={assetUrl(job.output_paths[0])}
alt=""
loading="lazy"
onerror={() => markOutputMissing(path)}
/>{:else}<video
src={assetUrl(path)}
controls
preload="metadata"><track kind="captions" /></video
>{/if}<a
class="a-btn secondary sm"
href={assetUrl(path)}
download rel="external">下载</a
>
</div>{/each}
</div>{/if}
<div class="mg-row">
{#if (st.tone === "fail" && job.error !== "cancelled") || (st.tone === "ok" && isFileMissing(job))}<Button
size="sm"
disabled={busyId === job.id}
onclick={() => retry(job)}>重试</Button
>{/if}{#if job.status === "pending" || job.status === "running"}<Button
size="sm"
disabled={busyId === job.id}
onclick={() => cancel(job)}>取消任务</Button
>{/if}{#if isStandaloneJob(job) && job.status !== "pending" && job.status !== "running"}<Button
size="sm"
variant="danger"
onclick={() => requestDelete(job)}>删除</Button
>{/if}
</div>
{:else}<div class="mg-empty">选择任务查看状态与产出</div>{/if}
</aside>
onerror={() => markOutputMissing(job.output_paths[0])}
/>
{:else}<Icon
name={job.kind === "image" ? "image" : "film-manage"}
size={14}
/>{/if}
</button></td
>
<td
><button
class="mg-click task-name"
title={taskName(job)}
onclick={() => openDetail(job)}>{taskName(job)}</button
></td
>
<td class="mg-muted" title={sourceOf(job)}>{sourceOf(job)}</td>
<td title={wfName(job)}>{wfName(job)}</td>
<td class="mg-muted" title={connectionOf(job)}
>{connectionOf(job)}</td
>
<td
><span
class="mg-tag {toneOf(job, st.tone)}"
title={job.error ?? st.detail}>{labelOf(job, st)}</span
>
{#if job.status === "running"}
{#if reportedProgress.has(job.id)}<div
class="task-progress"
role="progressbar"
aria-label={`任务 #${job.id} 进度`}
aria-valuenow={reportedProgress.get(job.id)}
aria-valuemin="0"
aria-valuemax="100"
title={`${reportedProgress.get(job.id)}%`}
>
<i style:width={`${reportedProgress.get(job.id)}%`}></i>
</div>
{:else}<span
class="task-progress-unknown"
title="等待服务报告进度">—</span
>{/if}
{/if}
</td>
<td class="mg-mono" title={job.created_at}>{timeOf(job)}</td>
<td
><div class="mg-row">
{#if job.status === "pending" || job.status === "running"}<Button
size="sm"
variant="ghost"
disabled={busyId === job.id}
onclick={() => cancel(job)}>取消</Button
>
{:else if (st.tone === "fail" && job.error !== "cancelled") || (st.tone === "ok" && isFileMissing(job))}<Button
size="sm"
disabled={busyId === job.id}
onclick={() => retry(job)}>重试</Button
>
{:else}<Button
size="sm"
variant="ghost"
onclick={() => openDetail(job)}>查看</Button
>{/if}
</div></td
>
</tr>
{/each}
</tbody>
</table>
{#if $jobsQuery.isLoading}<p class="mg-empty">正在读取任务…</p>
{:else if $jobsQuery.isError}<div class="mg-empty" role="alert">
任务加载失败<Button onclick={() => $jobsQuery.refetch()}>重试</Button>
</div>
{:else if !shown.length}<div class="mg-empty">
<p>
{search || filter !== "all" || sourceFilter !== "all"
? t("tasks.emptyFiltered")
: t("tasks.empty")}
</p>
<a class="a-btn secondary sm" href={resolve("/image")}>前往创作台</a>
</div>{/if}
</div>
</ManagementPage>
<style>
.task-tabs {
width: 340px;
margin-left: 12px;
}
.task-search {
width: 240px;
}
.task-output img,
.task-output video {
width: 100%;
max-height: 300px;
object-fit: contain;
background: var(--media-bg);
}
.task-output {
display: flex;
flex-direction: column;
gap: 8px;
}
</style>
<Modal bind:open={detailOpen} title="任务详情" size="lg">
<div class="management-page task-dialog">
{#if activeJob}{@const job = activeJob}{@const st = jobStatusInfo(job)}
<h2>{wfName(job)}</h2>
<div class="mg-mono">#{job.id} · {job.created_at}</div>
<p class="mg-muted">{taskName(job)} · {connectionOf(job)}</p>
<div class="mg-row">
<span class="mg-tag {toneOf(job, st.tone)}">{labelOf(job, st)}</span
><span class="mg-dim">{jobSourceLabel(job)}</span>
</div>
<div class="mg-field">
<h3>运行状态</h3>
<p class="mg-dim">{job.error ?? st.detail}</p>
{#if job.status === "running"}<p class="mg-dim">
{reportedProgress.has(job.id)
? reportedProgress.get(job.id) + "%"
: "等待服务报告进度"}
</p>{/if}
</div>
{#if job.output_paths?.length}<div class="mg-field">
<h3>产出 · {job.output_paths.length}</h3>
<!-- Output paths may repeat. Keep positional video DOM reuse so playback state stays with its original slot. -->
{#each job.output_paths as path, i (i)}<div class="task-output">
{#if job.kind === "image"}<img
src={assetUrl(path)}
alt={wfName(job)}
loading="lazy"
onerror={() => markOutputMissing(path)}
/>{:else}<video src={assetUrl(path)} controls preload="metadata"
><track kind="captions" /></video
>{/if}<a
class="a-btn secondary sm"
href={assetUrl(path)}
download
rel="external">下载</a
>
</div>{/each}
</div>{/if}
<div class="mg-row">
{#if (st.tone === "fail" && job.error !== "cancelled") || (st.tone === "ok" && isFileMissing(job))}<Button
size="sm"
disabled={busyId === job.id}
onclick={() => retry(job)}>重试</Button
>{/if}{#if job.status === "pending" || job.status === "running"}<Button
size="sm"
disabled={busyId === job.id}
onclick={() => cancel(job)}>取消任务</Button
>{/if}{#if isStandaloneJob(job) && job.status !== "pending" && job.status !== "running"}<Button
size="sm"
variant="danger"
onclick={() => {
requestDelete(job);
detailOpen = false;
}}>删除</Button
>{/if}
</div>
{:else}<div class="mg-empty">选择任务查看状态与产出</div>{/if}
{#if activeJob}<div class="mg-field">
<h3>来源筛选</h3>
<div class="mg-options" aria-label="任务来源">
{#each [["all", "全部任务"], ["standalone", "创作台"], ["project", "项目"], ["test", "工作流试跑"]] as [value, label] (value)}<button
class="mg-option"
class:on={sourceFilter === value}
aria-pressed={sourceFilter === value}
onclick={() => {
sourceFilter = value;
detailOpen = false;
}}>{label}</button
>{/each}
</div>
</div>{/if}
</div>
</Modal>
File diff suppressed because it is too large Load Diff
+92
View File
@@ -44,6 +44,26 @@
--fs-body: 13px;
--fs-small: 12px;
--fs-micro: 11px;
--studio-hero-bg: #0b0c11;
--project-side-width: 340px;
--project-media-tag-bg: rgba(7, 8, 12, 0.62);
--project-media-tag-color: #ddd;
--project-media-tag-border: rgba(255, 255, 255, 0.16);
--project-icon-stroke: 1.7;
--project-hero-radius: var(--r-xl);
--project-button-radius: var(--r-sm);
--project-button-sm-radius: 5px;
--project-header-bg: var(--glass);
--project-eyebrow-radius: var(--r-pill);
--project-draft-shadow: 0 0 12px
color-mix(in srgb, var(--sc-agent) 35%, transparent);
--project-shot-radius: 12px;
--project-active-shadow: 0 0 12px var(--gold);
--project-side-bg: var(--bg-1);
--project-display-font: var(--font);
--project-card-radius: var(--r-lg);
--project-confirm-radius: 10px;
--project-draft-radius: 12px;
--slot-size: 56px;
--prompt-height: 60px;
--desk-dock-radius: 14px;
@@ -90,6 +110,58 @@
--manage-add-min-height: 130px;
--manage-card-min: 280px;
--manage-card-padding: 14px;
/* Shared sample rail and management typography, not per-theme offsets. */
--rail-padding: 12px 0 10px;
--rail-brand-size: 40px;
--rail-brand-margin: 10px;
--rail-brand-radius: 12px;
--rail-nav-width: 56px;
--rail-expanded-width: 174px;
--rail-expanded-nav-width: 158px;
--rail-expanded-label-size: 12px;
--rail-nav-padding: 7px 0 6px;
--rail-nav-size: 11px;
--rail-nav-radius: 12px;
--rail-divider-margin: 8px 0;
--rail-queue-size: 40px;
--rail-queue-ring-radius: 12px;
--rail-queue-ring-border: 1.5px;
--rail-queue-ring-duration: 1.6s;
--rail-active-shadow: initial;
--rail-active-shadow-blur: 12px;
--manage-header-reserve-font: 11px/1.55 var(--font);
--manage-header-reserve-padding: 10px;
--manage-header-reserve-dot: 6px;
--manage-theme-name-size: 12.5px;
--manage-theme-tone-size: 11px;
--manage-theme-line: 1.55;
--task-table-layout: auto;
--task-thumbnail-column: 64px;
--task-progress-column: 180px;
--task-thumbnail-width: 48px;
--task-thumbnail-height: 32px;
--task-thumbnail-radius: 5px;
--task-progress-fill: linear-gradient(
90deg,
color-mix(in srgb, var(--sc-manage) 70%, var(--hi)),
var(--sc-manage)
);
--task-progress-shadow: 0 0 12px var(--sc-manage);
--task-progress-duration: 250ms;
--task-running-color: color-mix(in srgb, var(--sc-video) 80%, var(--hi));
--task-running-border: color-mix(in srgb, var(--sc-video) 40%, transparent);
--task-running-background: color-mix(
in srgb,
var(--sc-video) 10%,
transparent
);
--task-progress-height: 4px;
--task-progress-radius: 4px;
--task-progress-gap: 5px;
--task-tabs-width: 340px;
--task-tabs-offset: 12px;
--task-search-width: 240px;
--task-preview-height: 300px;
--desk-head-height: var(--head);
--desk-head-inset: 16px;
--desk-head-bg: var(--glass);
@@ -225,6 +297,9 @@
}
[data-theme="day"] {
--project-active-shadow: none;
--project-draft-shadow: none;
--rail-active-shadow: none;
color-scheme: light;
--bg-0: #e9e4da;
--bg-1: #f4f1ea;
@@ -307,6 +382,17 @@
}
[data-theme="grade"] {
--project-card-radius: 4px;
--project-hero-radius: 4px;
--project-eyebrow-radius: 3px;
--project-confirm-radius: 10px;
--project-draft-radius: 4px;
--project-shot-radius: 4px;
--project-active-shadow: none;
--project-draft-shadow: none;
--rail-active-shadow: none;
--rail-nav-radius: 4px;
--rail-brand-radius: 4px;
--desk-filter-radius: 3px;
--desk-connection-radius: 3px;
color-scheme: dark;
@@ -404,6 +490,12 @@
}
[data-theme="bluehour"] {
--project-shot-radius: 14px;
--project-button-radius: 10px;
--project-button-sm-radius: 8px;
--project-header-bg: color-mix(in srgb, #0f1838 55%, transparent);
--project-side-bg: color-mix(in srgb, var(--bg-1) 78%, transparent);
--rail-nav-radius: 14px;
--desk-head-bg: color-mix(in srgb, #0f1838 55%, transparent);
color-scheme: dark;
--bg-0: #0a1029;
@@ -0,0 +1,17 @@
HEL-731(B3)将工作室与项目五阶段接到已确认的夜片场四主题样本。基底固定为 `fix/hel-771-theme-tile-details @ a1c01f1e90b0b1ce76300291eae51785604180d4`,不合并既有请求、不部署、不迁移数据。
- 工作室首页改为真实项目继续创作、四个入口、项目筛选、待确认、队列与媒体墙。移除旧 hero 和重复覆盖规则。没有数据时显示空态,不写死样本项目、计数或图片。
- `ProjectWorkbench.svelte` 复用五阶段 API 与已有编辑组件;顶部阶段条、右侧助手、镜头缩略图网格、固定保存和确认、确认快照对比。编辑中禁止确认旧内容。阶段要求分别保存;任务同时按项目和阶段过滤。路由按项目 ID 重建组件,异步完成不能把导航或输入带到另一项目。
- 镜头脚本保留完整项目/阶段/镜头来源的创作台入口及原有显式结果回流,新增搭景入口。分镜仅携带真实分镜上下文和提示词预填,不伪造可回流的镜头脚本 ID。
- 视频声明的非遮罩图片槽及参考图片列表可选择真实搭景截图,遵守列表数量上限;无图声明的文生图仍无图片/遮罩槽。复用 Modal、MediaSlot、RefImageGrid、shotApi,未新增依赖或生成 API。
- 所有布局样式集中在 `project.css`,主题差异集中在 `themes.css`。样本图标加入已有 Icon 注册表,旧图标保持原契约。AppShell 页头 snippet 新增搜索回调;全局搜索快捷键继续由原组件负责。共享按钮、标签仅新增可配置 token,默认值保持原样;AProjectCard 同时影响项目列表,保留改名/删除。
验证:干净 `npm ci`;lint、Svelte check(0 errors / 0 warnings)、build、前端脚本测试;工作台生命周期和来源回流 pytest 18 passed。B3 浏览器 64 项、创作台 42 项、无图声明 7 项,运行异常均为 0。旧 B1 脚本将 B2 已移除的 `.wf-test-btn` 选择器更新为真实“试运行”按钮,等待数据加载,保留原行为断言,无检查豁免。
四主题 × 三尺寸 × 工作室/项目共 24 组原始样本与实际并排图,附 DOM 数值 CSV/JSON、原始样本、操作截图和 Playwright trace。三尺寸的页头、阶段条、右栏、底栏、按钮及输入几何按样本对齐。1280 档原始样本的描述文字较长,hero 和镜头卡会多换一行;证据保留原图与原始数值,另附仅替换这两处描述为隔离夹具文字的复测,24 组高度差均为 0。真实项目数、阶段状态、版本、产物和空态不替换为演示数据。
已验证真实 Three.js 截图 → 工作流声明槽 → 假 ComfyUI 视频生成 → 原镜头显式回流;另一项目不收到产物;项目视频默认暂停且可手动播放。五阶段生成/确认/恢复/导入通过隔离 pytest;保存、确认、恢复、对比、阶段切换、长内容滚动、主题与搜索通过浏览器操作。
限制:只使用隔离库和假生成服务,未调用真实 GPU 或收费服务。主题卡追加 144 组回归,DOM 差异 0;136 组原始截图逐像素一致,另外 8 组仍有 1–13 个像素、最大色阶差 2,原图全部保留,不据此宣布视觉通过。未部署,需总工独立审核;B4/B5 与整套样本仍未完成。pytest 有既存 Pydantic 字段 alias 警告,build 有依赖内未使用导入提示;lint/check 均无警告。回滚仅需切回上述精确基底,无数据库回滚步骤。
复测:安装仓库既有依赖后,设置本机 Playwright/Chromium 库路径,运行 `python3 e2e/run-hel731.py`;用 `HEL731_TESTS=e2e/hel731.cjs,e2e/hel729.cjs,e2e/hel742.cjs` 同时运行相关回归。样本通过 CLI 下载至上级 `attachments`,SHA-256 为 `0473d80d22b90194375f3566c1b8ca30dbd7433581b18f60956eabca252242a4`。随后运行 `node e2e/hel731-normalize.mjs`、`python3 scripts/report-hel731.py` 生成对照与数值证据。
@@ -0,0 +1,44 @@
# HEL-751 · 恢复确认原件的整宽任务表格
本卡施工稿待总工独立对照审核,不代表整个 B2 样本验收通过。未合并、未部署、未迁移、未调用真实或付费生成服务。
## 基底与实现
独立核对远端及认证 API:施工基底为 `fix/hel-750-code-checks@54880e900bc2fc83cfce7d979379c5bfbbf1825e`,新分支 `feat/hel-751-task-table`,新 PR 目标仍为该检查分支。原 PR #4/#5 保持 open、未合并;未修改其正文。确认 HTML SHA256 为 `0473d80d22b90194375f3566c1b8ca30dbd7433581b18f60956eabca252242a4`。
- `calliope-web/src/routes/tasks/+page.svelte`:移除常驻来源栏和详情栏,恢复原件八列(缩略图、任务、来源、工作流、连接、进度、时间、操作);使用原件自动列宽。页头保留五个筛选及搜索,搜索同时匹配任务标题、工作流、项目、来源、连接、错误及编号。
- 连接只读取任务的历史 endpoint 快照;未记录显示“未记录”或“等待分配”,不使用当前连接冒充历史事实。任务无标题时使用媒介和真实编号;时间列使用原件 HH:mm,详情保留完整创建时间。创作台和工作流试跑来源明确区分。
- 取消、失败/输出缺失重试直接在行内调用原 API。缩略图、任务名及“查看”打开已有 Modal,容纳真实状态、完整产物、逐项下载、手动视频播放和原撤销删除队列;来源筛选也复用该弹层,筛选后可从页头“全部来源”清除。
- 保留现有 jobStatusInfo / SSE 事件和错误语义。任务页的百分比只消费带明确 job_id 的有限数值上报;仅阶段/文本事件显示未知,不使用共享进度模块估算的百分比。未修改共享进度 store。
- `static/themes.css` 是新增任务几何/颜色/进度渐变的统一来源。旧页面局部样式移入既有 `components/manage/management.css`,继续复用表格、按钮、标签、Modal,没有另一套表格组件。共享表格仅增加布局和 box-sizing 的可选变量,其他页面默认 fixed/content-box 保持;任务页使用原件 auto/border-box。新增选择器限定任务类,未改其他页面结构。
- 两个 `scripts/verify-hel751.*` 是隔离证据工具,不是产品路由或数据。无新增依赖或 lockfile 变化;32 条既有 ESLint 局部例外及字幕边界未扩展。
## 自测与共享影响
干净 `npm ci`、`npm run lint`、`npm run check`、`npm test`、`npm run build` 均 exit 0;check 为 0 errors / 0 warnings。构建仍有既有 @xyflow 未使用导入、超过 500kB chunk 提示。Prettier 的全仓脚本仍不覆盖 Svelte 模板,本次任务模板额外显式使用原有 prettier-plugin-svelte 格式化,无扩大豁免。
后端相关回归 19 项通过:studio_generation、playground、workflow_test、stop_kills_jobs;保留既有 Pydantic warning。前端四组守护通过。
浏览器在明确标记用途的临时 SQLite 夹具上运行,所有 API 在精确 origin 下拦截,外部 origin 中止,未知写操作直接失败。原件只读取不修改。生成 2 秒含 440Hz 音轨的视频验证文件,媒体假接口支持 Range;无真实服务连接。唯一对应原件的合成图片也仅存在于 API 夹具,产品不包含示例任务、数量、时间或媒体。
四主题 × 三尺寸独立截图,共 12 组完整样本/实际/并排图及数字表。页头、表格区域、表头、行、缩略图、分段筛选、搜索框、进度条的宽高逐项与原件比较;全部通过。浏览器 254 项断言通过、0 pageerror:全部/进行/失败/图片/视频、任务标题搜索、筛选空态/真实空态/API 错误与恢复、取消/重试、原生下载、产物预览、原生播放按钮、暂停/跳播/音量、焦点圈定与返回、关闭卸载播放器、删除撤销和超时提交、来源/试跑分离、无数值进度不造百分比。
共享范围实际回归:工作流库仍是三栏(左栏 220px),连接/素材/设置路由可用,任务样式不泄漏;创作台图片和视频两路的提示词可编辑,刷新保留各自草稿。未重跑这些页面全部历史业务,不以路由冒烟代替整个 B2 验收。
复现:先通过 Multica 下载原 HTML 到仓库父目录 `attachments/`,构建前端;已有运行环境安装 Playwright、Pillow、imageio_ffmpeg。设置 `PLAYWRIGHT_MODULE` 指向已有 Playwright 模块,必要时设置浏览器库路径,然后执行 `python3 scripts/verify-hel751.py`。工具使用动态本机端口,finally 关闭浏览器/HTTP 服务,退出删除临时 SQLite/媒体;证据在仓库父目录 `hel751-evidence/`,通过任务卡附件交付。
## 仍须独立判断的可见差异
- 继承 B1 的 AIGC 品牌、导航顺序/项目范围/展开入口和队列展示未在本卡改动;它们与原件的差异仍由总工逐项审核。
- 产品不显示原件“设计演示”徽标,搜索框因此右移 90px(grade 为 90.8125px);宽高一致。
- 原件第二项演示来源为“智能体 · 分镜助手”;当前隔离夹具是具有真实项目来源契约的任务,显示“项目 · 雾港来信”。不伪造当前 API 没有的智能体来源记录。原件自动列宽随文案分配,进度列 x 差约 −4.297 至 −4.938px,列宽/行高一致,详见数字表。
- 没有真实输出的任务显示同尺寸媒介图标;视频任务尚无后端缩略图字段时也显示媒介图标,不把演示风景当成真实产物。完成的图片使用实际 output 路径;视频完整产物在弹层手动预览。真实空态、未知连接/进度如实显示。
- “克制”动效档继续按既有实现禁用,归 HEL-733;未擅自新增动效。既有任务视频的字幕欠缺未在本卡扩修;声音保留且需手动播放。未测真实 GPU/云 API、硬件音频输出和全屏。
以上是明确交付边界,不表示这些差异已经被用户接受,也不宣称整页像素完全一致。若后续要求智能体专属来源或视频缩略图,应先由总管裁定其数据契约及范围,不能把演示字符串或媒体写入产品。
## 回滚与清理
仅本卡前端和证据脚本,无数据库/配置变更,无已部署版本需要回滚。需要撤销时对本卡精确 head 执行 `git revert <HEL-751 head>` 后正常推送,保留检查基底;禁止 reset/强推。未执行回滚。
临时 SQLite、生成媒体、浏览器及动态端口 HTTP 服务在证据工具退出时清理;无临时产品路由、无调试日志写入产品。最终提交及远端 head/base、工作树状态见本卡唯一交付评论。
@@ -0,0 +1,45 @@
# HEL-767 · 搜索位置、主题卡片和侧栏样本返工
施工基底经 `git ls-remote` 与认证仓库 API 双重核对,为完整 B2 `feat/hel-751-task-table@dca88b3765c507cba87a7c1da34f2309091e0588`。新分支 `fix/hel-767-sample-alignment`,PR 目标仍为该任务表格分支。PR #4/#5/#6 保持原目标、open、未合并。没有部署、改后端、修改用户配置或数据,没有增加依赖和检查例外。
原 HTML 通过 Multica 附件 `01a15ddd-040d-7e9a-a1a3-04932a3aa0c8` 下载,SHA256 `0473d80d22b90194375f3566c1b8ca30dbd7433581b18f60956eabca252242a4`。只读运行该原件,未编辑或抽换样本。
## 变更与原因
- `ManagementPage.svelte` 和既有 `management.css`:在全部管理页统一头部恢复无文案、无交互、对辅助技术隐藏的右侧预留区域。使用字体、四个 em 空格、原件圆点占位和 padding 自然布局,继承 grade 页头字距;没有按主题补偿搜索坐标,没有演示徽标或假功能。
- `ThemeChoices.svelte`:修复 tiles 标题与说明的字号/行高来源,标题 12.5px / 1.55,说明 11px / 1.55。改回样本纵向 flex 排版,文字区正常拉伸,不设固定卡片总高度。compact 弹层形态继续复用。`themes.css` 是对应排版 token 来源。
- `AppShell.svelte`、`nav.ts`、`icons.ts`:在原组件和图标表内恢复品牌块、原件 SVG 路径和 1.7 描边、入口顺序、18px 图标、56px 宽入口、行距、分隔线、选中底座和左侧光条、底部队列/主题/设置/末槽的尺寸位置。恢复正确场景色和 day/grade 无光晕规则。去除侧栏继承的额外蜂窝悬停层,使用样本同一悬停来源。队列计数读取真实任务,运行环仅在真实 running 且未减少动效时显示,时长沿用原件;没有扩做 HEL-733 全局动效。
- 保留原有导航展开、服务状态、所有产品路由和旧入口。原件最后的“规范”明确为非产品演示入口,该位置承载已有“导航”展开功能,复用原件图标和尺寸,不加入产品设计规范页。服务状态在模型连接提示和展开态保持可读。队列链接进入真实任务页;原顶栏 QueueTray 继续复用。
- 小高度规则以浏览器 computed 为准:原 HTML 的早期 max-height 规则被后面的基础规则覆盖,三档窗口下原件实际都是 40px 品牌/队列、45px 入口;没有照抄一条无效规则造成小窗口压缩。
## 证据与复现
`scripts/verify-hel767.py` / `.mjs` 复用既有任务页隔离验证方式,创建临时 SQLite 与媒体夹具、动态本机静态服务器;所有 API 在精确本机 origin 下拦截,外部 origin 中止。未列出的写操作记录为失败并阻止。画布 ensure 仅返回隔离空图,不发送智能体消息或生成请求。退出 finally 关闭浏览器与服务器,Python TemporaryDirectory 删除 SQLite/视频。
准备已有的 Playwright 与浏览器运行库,令 `PLAYWRIGHT_MODULE` 指向其 `index.mjs`,必要时设置 `LD_LIBRARY_PATH`。先构建前端,再从仓库根运行 `python3 scripts/verify-hel767.py`。不将浏览器工具或运行库加入项目依赖。
截图矩阵为五个管理页 × night/day/grade/bluehour × 1440×900、1366×768、1280×720。样本与实际使用相同窗口、当前主题和减少动效模式;每组均有原件 PNG、实际 PNG、并排 PNG。`manifest.json` 列出图片文件名、字节数和 SHA256;`results.json` 保留 DOM 数字、完整断言、API 读写记录,`dimensions.md` 列样本/实际/差值。附加四主题悬停原图和操作录屏。
固定比较范围:任务搜索 x/y/宽高;全部四张主题卡片的 x/y/宽高、预览、标题、说明字号/行高;品牌、九个主/管理入口、分隔线、底部队列/主题/设置的位置;全部导航 SVG 路径、尺寸、描边、颜色及选中伪元素。其余管理区域截图供独立复核,不把真实数据差异当成结构完全相同。
## 已完成检查
干净 `npm ci`、全仓 `npm run lint`、`npm run check`(0 errors / 0 warnings)、四组既有前端守护 `npm test` 和 `npm run build` 通过。构建保留既有 @xyflow 未使用导入和超过 500kB chunk 提示,未扩大豁免。额外检查了修改的 Svelte 模板格式、脚本语法和 `git diff --check`。
后端相关回归 `test_studio_generation.py`、`test_playground.py`、`test_workflow_test.py`、`test_stop_kills_jobs.py`:19 passed,保留一条既有 Pydantic warning。后端源码未改。
独立交互回归 73 项通过,0 pageerror;全动效主题切换实测包含 `theme-lamp` 520ms,运行环 1600ms;减少动效立即应用且移除运行环。完整矩阵 60 组、6,169 条断言全部通过,0 pageerror。搜索框 12 组 x/y/宽高差为 0;全部四张主题卡片 × 12 组合(48 项)x/y/宽高差为 0,总高 130.421875px;标题行高 19.375px,说明 computed 行高 17.05px。侧栏入口、底部几何、图标和选中状态逐组合通过。数字原始结果见附件 `results.json` / `summary.json`。
## 真实数据和覆盖边界
本机夹具明确标为隔离验证,不写入产品。任务第二行沿真实项目来源契约显示项目,未伪造 API 不存在的智能体来源;自动列宽随文案变化,进度列 x 单列报告。未有输出的任务和视频缩略图使用同尺寸媒介图标,完成图片只显示夹具真实输出路径。队列是两项 running 加一项 pending,真实计数为 3,不写原件演示计数 2。
工作流、连接、素材的 API 列表由隔离契约夹具提供,不伪造用户真实目录、连接探测或素材。空连接/空素材状态与原件示例有内容差异,逐页对照保留。设置的真实语言、保存栏、高级选项与禁用“克制”档继续保留,隔离存储路径明确标记,不显示原件演示盘符。扫描导入真实入口保留,工作流三栏和任务整宽八列不变。
交互验证包含五个管理路由、兼容跳转、其他共用侧栏路由冒烟;任务搜索/筛选/错误恢复、取消/重试/下载/预览、手动视频播放/暂停/跳播/音量、弹层焦点与归还、删除撤销;创作台图片/视频提示词编辑与刷新草稿、Ctrl+K、无图工作流无上传格;侧栏展开/收起,四主题悬停、主题键盘选择/记忆和减少动效。
未覆盖真实 ComfyUI/GPU、付费云生成、用户数据库、全站全部历史业务、硬件扬声器与全屏,也未执行 HEL-731~733。此次交付须总管安排总工定向独立复核,不代表整套样本已验收,不合并或部署。
## 回滚
对本卡最终提交执行 `git revert <HEL-767 head>` 后正常推送,保留 B2 基底,不 reset/强推。无服务器或用户数据变更需要回滚。
@@ -0,0 +1,34 @@
# HEL-769 · 主题卡内部预览还原
基于完整 `fix/hel-767-sample-alignment@2004f527ccb689c84219282e3b706a95490c8b07`,隔离分支 `fix/hel-769-theme-preview`,新 PR 目标为 `fix/hel-767-sample-alignment`。不合并、部署或修改既有 PR。
## 实现
产品代码只修改 `calliope-web/src/lib/components/shell/ThemeChoices.svelte`:复用原组件与现有主题 token,恢复 tiles 内第四条列表线、20×3px 短文字线、固定 8px 预览圆角、主画面外描边与底条独立边框。底条显式继承 tiles 的 border-box,避免 padding 增加高度。外框、名称和说明的排版来源保持原有值。
主画面描边和底条颜色相关自定义属性在带 `data-theme` 的预览容器内解析,避免在外层 choices 提前解析为当前页面主题的颜色。预览内部使用对应卡片自己的 `--line-2`、`--bg-3` 和 `--tx-1`。没有新 CSS 补偿覆盖、依赖、检查豁免、业务演示数据或后端修改。compact 继续使用原有默认参数,新增图形只用于 tiles。
## 原件核实
原 HTML 由 Multica 附件下载,SHA256 `0473d80d22b90194375f3566c1b8ca30dbd7433581b18f60956eabca252242a4`,原件未经编辑。实际浏览器测量:主画面高 39px、底条高 9px、角标 14×5px、文字线 20×3px。旧报告角标样本 14×9px 的数字不成立,不照搬。所有尺寸、配色和圆角以本次原件逐主题运行测量为准。
## 复现与证据
运行 `npm ci`、`npm run lint`、`npm run check`、`npm test`、`npm run build`。构建完成后从仓库根运行 `python3 scripts/verify-hel769.py`,设置 `PLAYWRIGHT_MODULE` 指向已有 Playwright `index.mjs`,必要时设置浏览器运行库 `LD_LIBRARY_PATH`。原 HTML 放在仓库同级 `materials/夜片场-多主题交互样本.html`。
验证脚本沿用 HEL-767 的 API 契约隔离方式,临时 SQLite 和媒体夹具在 finally 中删除,动态 localhost 静态服务器和浏览器在 finally 中关闭。所有非本机 origin 拦截,未列出的 API 写操作失败,不访问用户库、真实 GPU 或付费接口。
完整矩阵保留五管理页 × 四主题 × 三窗口的整页 sample/actual/pair;设置页额外保留内部元素 DOM 原始值、内部近景 sample/actual/3 倍并排图,以及每张卡悬停的预览原图。`dimensions.md` 列出样本/实际/差值。交互沿用既有守护,覆盖主题选择、悬停、切换、键盘、记忆、减少动效、搜索、侧栏和管理页业务。逐文件 SHA256/大小清单与包索引随附件交付。
真实任务来源文字与样本演示文案不同,自动列宽随内容变化;无输出任务使用媒介图标。空连接、空素材和设置真实字段继续保持现有业务,不把样本虚构记录写入产品。
## 回滚与边界
使用 `git revert <HEL-769 head>` 正常推送;不 reset 或强推。没有服务器、用户配置或数据变更。未验证真实 GPU/付费生成、用户库、全站其他历史业务或后续 HEL-731~733;本次只施工和自测,须总管安排独立复核。
## 本次结果
- `npm ci`、lint、check(0 errors / 0 warnings)、四组既有前端守护、build 全部通过。build 保留既有 @xyflow 未使用导入和超过 500kB chunk 提示。
- 完整矩阵 60 组,11,809 项断言通过;补充交互与布局守护 121 项通过,两个运行均无 pageerror 或意外 API 写请求。四卡 × 四主题 × 三窗口的内部坐标/尺寸最大偏差 0px,主画面、底条、角标、文字线及四列表线的颜色/边框/圆角逐项匹配。外框高度 130.421875px、标题 12.5px/19.375px、说明 11px/17.05px 保持原值。
- 原始 PNG 像素核对也保留在 `pixel-results.json`:48 个普通态预览裁切中 40 个逐像素一致,另 8 个存在最大单通道 1/255 的栅格差异;DOM 几何和 computed 绘制值一致。悬停裁切包含圆角边缘与父卡背景的抗锯齿,原始差异如实保留,不将它们改图或声称所有 PNG 为零差异。
- 额外检查四主题的设置卡选中、localStorage 保存及刷新恢复;12 组合下工作流三栏与任务整宽八列全部通过。完整/减少动效、520ms 主题显影和 1600ms 队列运行环沿用既有验证并通过。
@@ -0,0 +1,24 @@
# HEL-771 · 主题卡三处对齐
基于 `b2bcbc10df97f448cfa3f3f17bf3febad8c7a8ed`,分支 `fix/hel-771-theme-tile-details`,PR base `fix/hel-769-theme-preview`。不修改 PR #8、不合并、不部署。
产品仅修改 `src/lib/theme.ts` 和 `src/lib/components/shell/ThemeChoices.svelte`(均在 calliope-web 下):主题定义增加 tiles 专用 tone;复用 `.choice:hover` 的单一背景规则,以 tiles 变量保持 bg-2,compact 默认仍为 bg-3;夜卡默认徽标前在名称文本节点末尾补一个空格,避免格式化工具消除 HTML 空白,同时避免分离文本节点的逐段小数舍入。不改预览几何、字号、行高、卡高或 compact 文案和布局,不加依赖或检查豁免。
纠正 HEL-769 记录中的定性:悬停整卡差异来源是 `.choice:hover` 将背景由 bg-2 改为 bg-3 的计算样式差异,不是父卡背景抗锯齿。HEL-770 原始测量、定位 JSON 和图片保留在本卡附件的 inputs/review 中,并核对原清单全部 18 项大小及 SHA256。
复现:将未经编辑的原 HTML 放在仓库同级 `materials/夜片场-多主题交互样本.html`,哈希必须为 `0473d80d22b90194375f3566c1b8ca30dbd7433581b18f60956eabca252242a4`。
- `cd calliope-web`,运行 `npm ci`、`npm run lint`、`npm run check`、`npm test`、`npm run build`。
- 仓库根运行 `node scripts/verify-hel771.mjs` 和 `python3 scripts/compare-hel771.py`。Playwright 通过已有环境提供(`PLAYWRIGHT_MODULE`),浏览器库通过 `LD_LIBRARY_PATH` 提供,不改项目依赖。
- 对照 1440×900、1366×768、1280×720 × 四页面主题 × 四卡 × 普通/选中/悬停,共 144 对。选中态为在固定页面主题下仅切换卡片选中 class 的视觉夹具,真实点击、键盘和保存另由既有交互守护验证。
- 复用 HEL-770 DOM 测量函数,增加默认徽标几何;记录每张卡、预览内部、文字、背景/边框、徽标的原始 JSON 和未经修改的 PNG。比较脚本按文件计算整卡逐像素差异,生成并排图与三倍近景。
- `HEL769_SKIP_VISUAL=1 python3 scripts/verify-hel769.py` 验证现有 121 项交互和布局守护;完整运行同脚本验证原管理页矩阵及搜索框/侧栏/任务八列/工作流三栏,保留原始结果。
所有服务在 localhost 随机端口,API 为隔离假服务,外部来源被拦截;未触真实 GPU、付费生成、用户库/配置或 HEL-731~733。临时 SQLite/媒体由既有脚本临时目录清理,浏览器及服务在 finally 关闭。没有独立架构文档,施工前按 README 的前后端目录边界、既有主题/组件结构和 HEL-767/769 交付记录核对,未扩展模块范围。
## 最终自测结果
- npm ci(268 包)、lint、check(0 错误 0 警告)、四组既有前端守护与 build 通过;build 保留既有第三方未使用导入与大 chunk 提示。
- 144 对卡片状态的文字、徽标位置、卡高、字号/行高、背景/边框与预览内部几何/样式全部零差异。主画面39px、底条9px、角标14×5px、短文字线20×3px及四条列表线保持。
- 整卡原始像素:138/144 对完全一致;其余6对仅圆角边缘1~8像素有差异,最大单通道2/255,均为普通态。选中态48/48、悬停态48/48均整卡逐像素一致。原图和逐项统计完整保留,不以裁边、改图或容差过滤隐藏差异;像素统计供独立复核,未宣称所有截图零差异。
- 最终版本交互守护121项通过;管理页60组/11,809项断言通过,零pageerror。证据范围仅本卡三项返工与既有定向守护,未宣称整套样本完成。
+5 -4
View File
@@ -436,10 +436,11 @@ async function shot(p, name) {
.filter({ hasText: "待配置测试工具" })
.click();
await p.waitForURL("**/workflows?manage=1&wf=" + pending.id);
ok(
"待配置导向真实管理和测试入口",
await p.locator(".wf-test-btn").first().isVisible(),
);
// B2 replaced the old wf-test-btn class with the same real test action.
// Wait for its data-driven detail panel; keep the original behavior assertion.
const workflowTest = p.getByRole("button", { name: "试运行", exact: true });
await workflowTest.waitFor();
ok("待配置导向真实管理和测试入口", await workflowTest.isVisible());
// Source project round-trip uses original API and explicit attachment.
await api(
"/api/workbench/projects/" + f.project + "/stages/assets",
+26
View File
@@ -0,0 +1,26 @@
// Explain content-driven height differences without altering the frozen sample or product.
// Original sample screenshots remain in the evidence pack. This second pass changes only
// the two descriptive strings to the isolated fixture strings and measures their reflow.
import fs from 'node:fs/promises';
import path from 'node:path';
import {pathToFileURL} from 'node:url';
const {chromium}=await import(process.env.PLAYWRIGHT_MODULE || 'playwright');
const out='e2e/shots/hel731';
const measurements=JSON.parse(await fs.readFile(out+'/measurements.json','utf8'));
const browser=await chromium.launch({args:['--no-sandbox']});
const results=[];
try{
for(const [width,height] of [[1440,900],[1366,768],[1280,720]])for(const theme of ['night','day','grade','bluehour']){
const page=await browser.newPage({viewport:{width,height}});
await page.goto(pathToFileURL(path.resolve('../attachments/夜片场-多主题交互样本.html')).href);
await page.evaluate(t=>{document.documentElement.dataset.theme=t;document.documentElement.classList.add('reduce');document.querySelector('.hero .copy p').textContent='纸船带回一片树叶';document.querySelectorAll('#projShots .scard .bd p').forEach(e=>e.textContent='女孩沿着海岸走向灯塔,纸船随海风起伏。');},theme);
for(const [name,ref,actual] of [['studio','.hero','.studio-hero'],['project','.scard','.storyboard-card']]){
await page.evaluate(name=>document.querySelectorAll('.page').forEach(e=>e.classList.toggle('on',e.id==='p-'+name)),name);await page.waitForTimeout(350);
const box=await page.locator(ref).first().boundingBox();const a=measurements.find(x=>x.width===width&&x.theme===theme&&x.selector===actual).actual;
results.push({theme,width,page:name,sampleWithFixtureDescription:box,actual:a,deltaHeight:a.height-box.height});
await page.screenshot({path:`${out}/${name}-${theme}-${width}-sample-description-normalized.png`});
}await page.close();
}
await fs.writeFile(out+'/description-reflow.json',JSON.stringify(results,null,2));
console.log('normalized reflow comparisons',results.length,'maximum height delta',Math.max(...results.map(r=>Math.abs(r.deltaHeight))));
}finally{await browser.close()}
+107
View File
@@ -0,0 +1,107 @@
// B3: real local API, disposable database and fake ComfyUI 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 API='http://127.0.0.1:8247',WEB='http://127.0.0.1:5173',out='e2e/shots/hel731';
fs.mkdirSync(out,{recursive:true}); const checks=[],errors=[];
const ok=(name,value)=>{assert(value,name);checks.push(name);console.log('PASS',name)};
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 x=await fn();if(x)return x;await new Promise(r=>setTimeout(r,150))}throw Error('condition timeout')}
const base=id=>'/api/workbench/projects/'+id;
const fixtures={
script:{text:'第一场:清晨的海岸,女孩把一只纸船放入海水。她等候着父亲的归来。第二场:纸船带回一片树叶,女孩望向海平面,知道旅程才刚刚开始。'},
style:{directions:[{id:'s1',name:'晨光',description:'暖光',prompt:'warm light'},{id:'s2',name:'蓝调',description:'冷光',prompt:'blue hour'}]},
storyboard:{shots:Array.from({length:12},(_,i)=>({id:'board-'+i,scene:'1',shot_size:'远景',camera:'固定',description:'女孩沿着海岸走向灯塔,纸船随海风起伏。',dialog:i%2?'你好':'',duration_sec:5}))},
assets:{assets:[{id:'asset-a',kind:'character',name:'海边女孩',description:'女孩',prompt:'girl on coast'}]},
shot:{shots:[{id:'shot-a',shot_no:'S1',description:'女孩走向灯塔',prompt:'girl walks toward lighthouse',duration_sec:4}]}
};
(async()=>{
const b=await chromium.launch({executablePath:process.env.CHROMIUM_EXECUTABLE_PATH,args:['--no-sandbox','--disable-dev-shm-usage']});
const sample=http.createServer((req,res)=>{res.setHeader('Content-Type','text/html;charset=utf-8');res.end(fs.readFileSync('../attachments/夜片场-多主题交互样本.html'))});await new Promise(r=>sample.listen(0,'127.0.0.1',r));
const c=await b.newContext({viewport:{width:1440,height:900}});await c.tracing.start({screenshots:true,snapshots:true,sources:true});const p=await c.newPage();p.setDefaultTimeout(15000);p.on('pageerror',e=>errors.push(e.message));
try{
const f=JSON.parse(fs.readFileSync('e2e/shots/reference/fixtures.json'));
const proj=await api('/api/projects',{title:'隔离验证 · 海岸来信',idea:'纸船带回一片树叶'}),other=await api('/api/projects',{title:'隔离验证 · 独立项目'});
for(const [stage,content] of Object.entries(fixtures)){
await api(base(proj.id)+'/stages/'+stage,{content},'PATCH');
if(stage==='script'||stage==='style')await api(base(proj.id)+'/stages/'+stage+'/confirm',stage==='style'?{chosen_style_id:'s1'}:{});
}
// Verify real UI selection, compare modal and explicit confirmation.
await p.goto(WEB+'/workbench/'+proj.id+'?stage=storyboard');await p.locator('.storyboard-card').first().waitFor();
ok('分镜显示12张真实镜头卡',(await p.locator('.storyboard-card').count())===12);
ok('固定确认首屏可见',await p.locator('[data-testid=stage-confirm-btn]').isVisible());
await api(base(proj.id)+'/stages/storyboard/confirm',{});
await api(base(proj.id)+'/stages/storyboard',{content:{shots:fixtures.storyboard.shots.map((x,i)=>i?x:{...x,description:'已编辑的第一个镜头'})}},'PATCH');
await p.reload();await p.getByRole('button',{name:'对比已确认版',exact:true}).click();await p.locator('.project-compare').waitFor();await p.screenshot({path:out+'/compare-modal.png'});await p.keyboard.press('Escape');
await p.locator('[data-testid=stage-restore-btn]').click();await wait(async()=>JSON.stringify((await api(base(proj.id))).stages.find(s=>s.stage_key==='storyboard').content)===JSON.stringify(fixtures.storyboard));ok('恢复真实确认快照',true);
await api(base(proj.id)+'/stages/storyboard',{content:fixtures.storyboard},'PATCH');
await api(base(proj.id)+'/stages/storyboard',{content:{shots:fixtures.storyboard.shots.map((x,i)=>i?x:{...x,description:'等待确认的新草稿'})}},'PATCH');
await p.reload();await p.locator('[data-testid=stage-confirm-btn]').click();await wait(async()=>new URL(p.url()).searchParams.get('stage')==='assets');ok('仅显式确认后进入下一阶段',true);
// Fixed footer saves the panel's edited draft and prevents stale confirmation.
await p.goto(WEB+'/workbench/'+proj.id+'?stage=storyboard');await p.locator('.storyboard-card').first().waitFor();
await p.getByRole('button',{name:'编辑',exact:true}).click();await p.locator('.shot-edit textarea').first().fill('通过固定底栏保存的新镜头描述');
ok('编辑期间禁止确认旧内容',await p.locator('[data-testid=stage-confirm-btn]').isDisabled());
await p.locator('[data-testid=stage-save-btn]').click();await wait(async()=>JSON.stringify(await api(base(proj.id))).includes('通过固定底栏保存的新镜头描述'));ok('固定底栏保存实际编辑草稿',true);
for(const stage of ['script','style','storyboard','assets','shot']){
await p.locator(`[data-stage=${stage}]`).click();await p.screenshot({path:`${out}/stage-${stage}.png`});
ok('阶段切换 '+stage,new URL(p.url()).searchParams.get('stage')===stage);
}
await p.locator('[data-stage=storyboard]').click();await p.locator('[data-testid=stage-instruction]').fill('分镜专属要求');await p.locator('[data-stage=style]').click();ok('阶段要求不串阶段',await p.locator('[data-testid=stage-instruction]').inputValue()==='');await p.locator('[data-stage=storyboard]').click();ok('切回阶段保留要求',await p.locator('[data-testid=stage-instruction]').inputValue()==='分镜专属要求');await p.locator('[data-testid=stage-instruction]').fill('');
await p.locator('[data-stage=storyboard]').click();await p.locator('.project-content').evaluate(e=>e.scrollTop=e.scrollHeight);await p.screenshot({path:out+'/long-scroll.png'});ok('长内容滚动时固定确认可用',await p.locator('[data-testid=stage-confirm-btn]').isVisible());
// Project navigation stays scoped; edits from the old component cannot write into the new project.
await p.goto(WEB+'/workbench/'+other.id+'?stage=script');await p.locator('[data-testid=workbench-page]').waitFor();ok('跨项目不带旧镜头',await p.locator('.storyboard-card').count()===0);
// Script import uses the original import endpoint.
await api(base(other.id)+'/stages/script/import',{text:fixtures.script.text});ok('剧本导入保留正式确认语义',(await api(base(other.id))).stages.find(s=>s.stage_key==='script').status==='confirmed');
// Create a real 3D composition, render it in the existing viewport and save a screenshot.
await p.goto(WEB+'/build-scene');await p.waitForTimeout(2000);await p.screenshot({path:out+'/scene-entry.png'});
await p.locator('canvas').first().waitFor();
await p.locator('.toolbar .capture').click();
const scene = await wait(async () => (await api('/api/shots')).find(s => s.title));
const cap = await wait(async () => (await api('/api/shots/'+scene.id+'/captures'))[0]);
ok('真实三维视口截图写入隔离存储',!!cap.file_path);
// Five-stage source -> real declared image slot -> fake generation -> explicit source attachment.
const q=new URLSearchParams({kind:'video',wf:String(f.h3),from:'workbench',project:String(proj.id),stage:'shot',source_kind:'shot',source_id:'shot-a',label:'镜头 S1',prompt:fixtures.shot.shots[0].prompt});
await p.goto(WEB+'/create?'+q);await p.locator('#desk-prompt').waitFor();
await p.getByRole('button',{name:'管理素材',exact:false}).click();await p.getByRole('button',{name:'从三维搭景截取',exact:true}).first().click();await p.locator('.capture-choices button').first().click();
await p.getByRole('button',{name:'从三维搭景截取',exact:true}).nth(1).click();await p.locator('.capture-choices button').first().click();
await p.screenshot({path:out+'/capture-selected.png'});await p.getByRole('button',{name:'完成',exact:true}).click();
fs.writeFileSync('e2e/fake_mode.json',JSON.stringify({mode:'ok',delay_sec:0,videos:true,unique_filename:true}));
const before=Math.max(0,...(await api('/api/playground/jobs')).map(j=>j.id));await p.locator('.gen').click();
const job=await wait(async()=>(await api('/api/playground/jobs')).find(j=>j.id>before&&j.status==='done'));
ok('假服务生成保留来源镜头',JSON.stringify(job.payload).includes('shot-a'));
await p.getByRole('button',{name:'存入项目',exact:true}).click();await wait(async()=>JSON.stringify(await api(base(proj.id))).includes(job.output_paths[0]));ok('截图参考生成结果回流来源项目',true);
ok('回流不串项目',!JSON.stringify(await api(base(other.id))).includes(job.output_paths[0]));
await p.goto(WEB+'/workbench/'+proj.id+'?stage=shot');await p.locator('.vshot-video').waitFor();
ok('镜头阶段显示本阶段任务',await p.locator('[data-testid=workbench-jobs] .job-row').count()===1);
ok('项目回流视频默认暂停',await p.locator('.vshot-video').evaluate(v=>v.paused));
ok('项目视频手动播放',await p.locator('.vshot-video').evaluate(async v=>{await v.play();return !v.paused}));await p.screenshot({path:out+'/project-video-playing.png'});await p.locator('.vshot-video').evaluate(v=>v.pause());await p.screenshot({path:out+'/project-video-paused.png'});
await p.locator('[data-stage=storyboard]').click();ok('分镜阶段不混入镜头任务',await p.locator('[data-testid=workbench-jobs] .job-row').count()===0);
// Text-only workflows must retain no image slots.
await p.goto(WEB+'/create?kind=image&wf='+f.dual);await p.locator('#desk-prompt').waitFor();ok('文生图无伪造图片槽',await p.locator('.a-slot').count()===0);
// Stable data state for visual evidence; these records live only in this disposable DB.
await api(base(proj.id)+'/stages/storyboard',{content:fixtures.storyboard},'PATCH');
const sp=await c.newPage();const measures=[];
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 sp.setViewportSize({width,height});
await p.evaluate(t=>{localStorage.setItem('aigc-theme',t)},theme);
await sp.goto('http://127.0.0.1:'+sample.address().port);await sp.evaluate(t=>{document.documentElement.dataset.theme=t;document.documentElement.classList.add('reduce')},theme);
for(const page of ['studio','project']){
await p.goto(WEB+(page==='studio'?'/':'/workbench/'+proj.id+'?stage=storyboard'));
await p.locator(page==='studio'?'.studio-hero':'.storyboard-card').first().waitFor();
await sp.evaluate(v=>{document.querySelectorAll('.page').forEach(e=>e.classList.toggle('on',e.id==='p-'+v))},page);
await sp.waitForTimeout(350);await p.waitForTimeout(100);
if(page==='project')await p.getByRole('button',{name:'选择镜头 3',exact:true}).click();
await p.screenshot({path:`${out}/${page}-${theme}-${width}-actual.png`});await sp.screenshot({path:`${out}/${page}-${theme}-${width}-sample.png`});
const selectors=page==='studio'?[['.hero','.studio-hero'],['.copy','.hero-copy'],['.display','.studio-display'],['.studio-wrap','.home'],['.quick','.studio-quick']]:[['.stepper','.stepper'],['.proj-side','.project-side'],['.proj-foot','.project-footer'],['.scard','.storyboard-card'],['.ps-sec .input','.stage-input-wrap'],['#confirmStage','[data-testid=stage-confirm-btn]']];
ok(`主题 ${theme} ${page} ${width}`, await p.evaluate(t=>document.documentElement.dataset.theme===t,theme));
const measure=async(pg,sel)=>pg.locator(sel).first().evaluate(e=>{const b=e.getBoundingClientRect(),s=getComputedStyle(e);return {x:b.x,y:b.y,width:b.width,height:b.height,font:s.fontSize,weight:s.fontWeight,radius:s.borderRadius,color:s.color,background:s.backgroundColor}});
for(const [ref,actual] of selectors)measures.push({page,theme,width,selector:actual,sample:await measure(sp,ref),actual:await measure(p,actual)});
if(page==='project')ok(`固定确认 ${theme} ${width}`,await p.locator('[data-testid=stage-confirm-btn]').evaluate(e=>e.getBoundingClientRect().bottom<=innerHeight));
}
}
await p.goto(WEB+'/');await p.locator('.studio-search').click();await p.screenshot({path:out+'/search-open.png'});await p.keyboard.press('Escape');await p.keyboard.press('Control+k');ok('工作室搜索快捷键保留',await p.getByRole('dialog').isVisible());await p.keyboard.press('Escape');
await p.getByRole('button',{name:'主题',exact:true}).click();await p.locator('.choices .choice').filter({hasText:'日光棚'}).click();await p.waitForTimeout(600);await p.screenshot({path:out+'/theme-operation.png'});ok('主题操作生效',await p.evaluate(()=>document.documentElement.dataset.theme==='day'));await p.keyboard.press('Escape');
await p.route('**/api/projects',route=>route.fulfill({json:[]}));await p.reload();await p.locator('.studio-hero').waitFor();await p.screenshot({path:out+'/empty-studio.png'});ok('空态不展示样本项目',!(await p.locator('.studio-hero').innerText()).includes('雾港来信'));await p.unroute('**/api/projects');
fs.writeFileSync(out+'/measurements.json',JSON.stringify(measures,null,2));fs.writeFileSync(out+'/results.json',JSON.stringify({checks,errors},null,2));ok('无浏览器运行错误',errors.length===0);
}finally{await c.tracing.stop({path:out+'/interaction-trace.zip'});await b.close();await new Promise(r=>sample.close(r));}
})().catch(e=>{console.error(e);process.exitCode=1});
+49
View File
@@ -0,0 +1,49 @@
"""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=os.environ.get('HEL731_PYTHON', 'python3')
logs=root/'e2e/shots/hel731'; 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())))
mode_path=root/'e2e/fake_mode.json'
saved_mode=mode_path.read_bytes() if mode_path.exists() else None
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('HEL731_WEB_DIR',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('HEL731_TESTS',os.environ.get('HEL729_TESTS','e2e/hel731.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()
if saved_mode is not None:mode_path.write_bytes(saved_mode)
elif mode_path.exists():mode_path.unlink()
+46
View File
@@ -0,0 +1,46 @@
"""Compare the unmodified captures and DOM measurements from verify-hel771.mjs."""
import json
import os
from pathlib import Path
from PIL import Image, ImageChops, ImageDraw
OUT = Path(os.environ.get('HEL771_OUT', Path(__file__).resolve().parents[2] / 'evidence' / 'tiles'))
r = json.loads((OUT / 'measurements.json').read_text())
summary = {'groups': len(r['groups']), 'differences': [], 'pixels': []}
def compare(a,b,prefix):
if isinstance(a,dict):
for k in a:
if k in ('cardClass','ariaChecked'): continue
compare(a[k],b[k],prefix+'.'+k)
elif isinstance(a,list):
if len(a)!=len(b): summary['differences'].append([prefix,a,b])
else:
for i,(x,y) in enumerate(zip(a,b)): compare(x,y,f'{prefix}.{i}')
elif a!=b:
summary['differences'].append([prefix,a,b])
for g in r['groups']:
compare(g['app'],g['sample'],g['name'])
a=Image.open(OUT/(g['name']+'-app.png')).convert('RGB')
b=Image.open(OUT/(g['name']+'-sample.png')).convert('RGB')
assert a.size==b.size
d=ImageChops.difference(a,b)
pixels=sum(1 for p in d.get_flattened_data() if p!=(0,0,0))
summary['pixels'].append({'name':g['name'],'different_pixels':pixels,'total_pixels':a.width*a.height,'max_channel_delta':max(x[1] for x in d.getextrema())})
if g['width']==1440 and g['theme']=='night' and g['card'] in (0,1):
pair=Image.new('RGB',(a.width*2,a.height+24),'#151515')
ImageDraw.Draw(pair).text((5,5),'Original (left) / App (right)',fill='white')
pair.paste(b,(0,24));pair.paste(a,(a.width,24))
pair.resize((pair.width*3,pair.height*3),Image.Resampling.NEAREST).save(OUT/(g['name']+'-pair-3x.png'))
for p in OUT.glob('*-full-app.png'):
a=Image.open(p);b=Image.open(str(p).replace('-app.png','-sample.png'))
pair=Image.new('RGB',(a.width*2,a.height+24),'#151515')
ImageDraw.Draw(pair).text((8,5),'Original (left) / App (right)',fill='white')
pair.paste(b,(0,24));pair.paste(a,(a.width,24));pair.save(str(p).replace('-app.png','-pair.png'))
(OUT/'comparison.json').write_text(json.dumps(summary,ensure_ascii=False,indent=2))
print('groups:',len(r['groups']),'DOM differences:',len(summary['differences']),'pixel-identical:',sum(p['different_pixels']==0 for p in summary['pixels']))
print(json.dumps(summary['differences'][:8],ensure_ascii=False,indent=2))
assert not summary['differences'], 'DOM mismatch'
# Pixel statistics are evidence, not a hidden tolerance gate: retain every raw pair.
print('Non-identical raw pairs:', json.dumps([p for p in summary['pixels'] if p['different_pixels']], ensure_ascii=False))
+37
View File
@@ -0,0 +1,37 @@
"""Package B3 evidence without changing or retouching application screenshots."""
import csv
import hashlib
import json
import shutil
from pathlib import Path
from PIL import Image
root = Path(__file__).resolve().parents[1]
source = root / 'e2e/shots/hel731'
out = root.parent / 'evidence/hel731'
out.mkdir(parents=True, exist_ok=True)
for path in source.iterdir():
if path.is_file() and path.name != 'interaction-trace.zip':
shutil.copy2(path, out / path.name)
for actual in out.glob('*-actual.png'):
reference = actual.with_name(actual.name.replace('-actual', '-sample'))
left, right = Image.open(reference), Image.open(actual)
pair = Image.new('RGB', (left.width + right.width, max(left.height, right.height)))
pair.paste(left, (0, 0)); pair.paste(right, (left.width, 0))
pair.save(actual.with_name(actual.name.replace('-actual', '-pair')))
measurements = json.loads((source / 'measurements.json').read_text())
with (out / 'measurements.csv').open('w', newline='') as handle:
writer = csv.writer(handle)
writer.writerow(['page', 'theme', 'viewport', 'selector', 'property', 'sample', 'actual', 'delta'])
for row in measurements:
for key, sample in row['sample'].items():
actual = row['actual'][key]
delta = round(actual - sample, 5) if isinstance(sample, (int, float)) else ''
writer.writerow([row['page'], row['theme'], row['width'], row['selector'], key, sample, actual, delta])
logs = out / 'checks'; logs.mkdir(exist_ok=True)
for name in ['lint-b3.log', 'check-b3.log', 'build-b3.log', 'pytest-b3.log', 'frontend-test-b3.log', 'browser-b3.log', 'final-browser-b3.log', 'related-browser-b3.log', 'normalize-b3.log', 'theme-regression-b3.log']:
shutil.copy2(root / name, logs / name)
for suite in ['hel729', 'hel742']:
shutil.copy2(root / f'e2e/shots/{suite}/results.json', logs / f'{suite}-results.json')
shutil.copy2(root.parent / 'attachments/夜片场-多主题交互样本.html', out / 'approved-sample.html')
print('Evidence copied to', out.name)
+565
View File
@@ -0,0 +1,565 @@
/** Run via verify-hel751.py; explicit API fake, local-only origins, no user data. */
import fs from "node:fs/promises";
import http from "node:http";
import path from "node:path";
import { pathToFileURL } from "node:url";
const { chromium } = await import(
process.env.PLAYWRIGHT_MODULE || "playwright"
);
const out = process.env.HEL751_OUT,
data = process.env.HEL751_DATA;
const fixture = JSON.parse(
await fs.readFile(path.join(data, "fixtures.json"), "utf8"),
);
let jobs = structuredClone(fixture.jobs),
apiError = false,
imageFixture;
const result = {
checks: [],
measurements: [],
errors: [],
mutations: [],
externalBlocked: [],
apiReads: [],
};
const check = (v, n) => {
result.checks.push({ name: n, pass: !!v });
if (!v) throw Error(n);
};
const server = http.createServer(async (req, res) => {
try {
const p = new URL(req.url, "http://local").pathname;
let f = path.join("calliope-web/build", decodeURIComponent(p));
if (!path.extname(f)) f = path.join("calliope-web/build", "index.html");
const bytes = await fs.readFile(f);
res.setHeader(
"Content-Type",
f.endsWith(".js")
? "text/javascript"
: f.endsWith(".css")
? "text/css"
: f.endsWith(".html")
? "text/html"
: "application/octet-stream",
);
res.end(bytes);
} catch {
res.writeHead(404);
res.end();
}
});
await new Promise((r) => server.listen(0, "127.0.0.1", r));
const base = `http://127.0.0.1:${server.address().port}`;
const browser = await chromium.launch({
headless: true,
args: ["--no-sandbox"],
});
try {
const context = await browser.newContext({
viewport: { width: 1440, height: 900 },
timezoneId: "Asia/Shanghai",
recordVideo: {
dir: path.join(out, "recordings"),
size: { width: 1440, height: 900 },
},
});
await context.addInitScript(() => {
localStorage.setItem("aigc-lang", "zh");
localStorage.setItem("aigc-reduce-motion", "1");
window.__sse = [];
window.EventSource = class {
constructor() {
window.__sse.push(this);
setTimeout(() => this.onopen?.(), 0);
}
addEventListener(t, f) {
(this.handlers ??= {})[t] = f;
}
close() {}
};
});
await context.route("**/*", async (route) => {
const req = route.request(),
u = new URL(req.url());
if (["data:", "blob:", "file:"].includes(u.protocol))
return route.continue();
if (u.origin !== base) {
result.externalBlocked.push(u.origin);
return route.abort();
}
if (!u.pathname.startsWith("/api/")) return route.continue();
const json = (body) => route.fulfill({ json: body });
if (u.pathname === "/api/file") {
if (u.searchParams.get("path")?.endsWith(".mp4")) {
const bytes = await fs.readFile(path.join(data, "video.mp4"));
const range = req.headers()["range"]?.match(/bytes=(\d+)-(\d*)/);
if (range) {
const start = +range[1],
end = range[2]
? Math.min(+range[2], bytes.length - 1)
: bytes.length - 1;
return route.fulfill({
status: 206,
body: bytes.subarray(start, end + 1),
contentType: "video/mp4",
headers: {
"Accept-Ranges": "bytes",
"Content-Range": `bytes ${start}-${end}/${bytes.length}`,
"Content-Length": String(end - start + 1),
},
});
}
return route.fulfill({
body: bytes,
contentType: "video/mp4",
headers: {
"Accept-Ranges": "bytes",
"Content-Length": String(bytes.length),
},
});
}
return route.fulfill({
body:
imageFixture ||
Buffer.from(
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+jR1kAAAAASUVORK5CYII=",
"base64",
),
contentType: "image/png",
});
}
if (req.method() !== "GET") {
result.mutations.push({ path: u.pathname, method: req.method() });
const m = u.pathname.match(/^\/api\/jobs\/(\d+)\/(cancel|retry)$/);
if (m) {
const j = jobs.find((j) => j.id === +m[1]);
j.status = m[2] === "cancel" ? "failed" : "pending";
j.error = m[2] === "cancel" ? "cancelled" : null;
return json(j);
}
const d = u.pathname.match(/^\/api\/playground\/jobs\/(\d+)$/);
if (d && req.method() === "DELETE") {
jobs = jobs.filter((j) => j.id !== +d[1]);
return json({ ok: true });
}
if (u.pathname.includes("test")) return json({ ok: true });
throw Error(`Unexpected mutation ${req.method()} ${u.pathname}`);
}
result.apiReads.push(u.pathname);
if (u.pathname === "/api/jobs") {
if (apiError)
return route.fulfill({ status: 500, body: "HEL751 isolated error" });
return json(jobs);
}
if (u.pathname === "/api/workflows") return json(fixture.workflows);
if (u.pathname === "/api/projects")
return json([{ id: 1, title: "雾港来信" }]);
if (u.pathname === "/api/settings")
return json({
comfyui_endpoints: [],
llm_profiles: [],
queue_concurrency: 1,
dry_run: false,
});
if (u.pathname === "/api/comfyui/endpoints")
return json({ endpoints: [], active_id: null });
if (u.pathname.includes("/jobs")) return json([]);
if (u.pathname.includes("/sessions")) return json([]);
if (
u.pathname === "/api/library/media" ||
u.pathname === "/api/agent/agents" ||
u.pathname === "/api/agent/memories"
)
return json([]);
const wf = u.pathname.match(/^\/api\/workflows\/(\d+)$/);
if (wf)
return json({
...fixture.workflows.find((w) => w.id === +wf[1]),
workflow_json: {},
output_map: [
{
nodeId: "2",
kind: fixture.workflows.find((w) => w.id === +wf[1]).kind,
},
],
});
return json({});
});
const page = await context.newPage();
page.on("pageerror", (e) => result.errors.push(e.message));
const ref = await context.newPage();
await ref.goto(
pathToFileURL(path.resolve("../attachments/夜片场-多主题交互样本.html"))
.href,
);
await ref.evaluate(() => {
go("tasks");
document.documentElement.classList.add("capture");
});
// Reuse the corresponding original synthetic still only inside this API fake.
// Canvas avoids capturing a parent page during its opacity entrance animation.
imageFixture = Buffer.from(
await ref.evaluate(async () => {
const img = new Image();
img.src = still(64, 96, 64);
await img.decode();
const canvas = document.createElement("canvas");
canvas.width = 48;
canvas.height = 32;
canvas.getContext("2d").drawImage(img, 0, 0, 48, 32);
return canvas.toDataURL("image/png").split(",")[1];
}),
"base64",
);
const progress = () =>
page.evaluate(() => {
for (const es of window.__sse)
for (const [id, pct] of [
[1, 62],
[2, 35],
])
es.handlers?.["job.progress"]?.({
data: JSON.stringify({
type: "job.progress",
data: { job_id: id, progress: pct, phase: "comfy_running" },
}),
});
});
const measure = (p, selectors) =>
p.evaluate(
(selectors) =>
Object.fromEntries(
Object.entries(selectors).map(([k, s]) => {
const e = document.querySelector(s);
if (!e) return [k, null];
const r = e.getBoundingClientRect(),
c = getComputedStyle(e);
return [
k,
{
x: r.x,
y: r.y,
width: r.width,
height: r.height,
font: c.fontSize,
radius: c.borderRadius,
color: c.color,
background: c.backgroundColor,
},
];
}),
),
selectors,
);
for (const [width, height] of process.env.HEL751_SKIP_VISUAL
? []
: [
[1440, 900],
[1366, 768],
[1280, 720],
])
for (const theme of ["night", "day", "grade", "bluehour"]) {
await page.setViewportSize({ width, height });
await ref.setViewportSize({ width, height });
await page.goto(base + "/tasks");
await page.locator(".task-table tbody tr").first().waitFor();
await progress();
await page.evaluate((theme) => {
localStorage.setItem("aigc-theme", theme);
window.dispatchEvent(
new StorageEvent("storage", { key: "aigc-theme", newValue: theme }),
);
}, theme);
await ref.evaluate((theme) => setTheme(theme), theme);
await page.waitForTimeout(650);
const name = `tasks-${theme}-${width}`;
await page.screenshot({ path: path.join(out, name + "-actual.png") });
await ref.screenshot({ path: path.join(out, name + "-sample.png") });
result.measurements.push({
theme,
width,
height,
actual: await measure(page, {
header: ".management-header",
table: ".task-table",
head: ".task-table thead",
row: ".task-table tbody tr",
thumbnail: ".task-thumbnail",
segment: ".task-tabs",
search: ".task-search",
progress: ".task-progress",
tag: ".task-table .mg-tag",
}),
sample: await measure(ref, {
header: "#p-tasks .ph",
table: "#p-tasks .tbl",
head: "#p-tasks thead",
row: "#taskTable tr",
thumbnail: "#taskTable td>div",
segment: "#p-tasks .seg",
search: "#p-tasks .input",
progress: "#taskTable .bar",
tag: "#taskTable .tag",
}),
});
const measured = result.measurements.at(-1);
for (const key of [
"header",
"table",
"head",
"row",
"thumbnail",
"segment",
"search",
"progress",
])
for (const axis of ["width", "height"])
check(
Math.abs(measured.actual[key][axis] - measured.sample[key][axis]) <
0.1,
`${name}: ${key} ${axis} matches sample`,
);
check(
(await page.locator(".mg-side,.mg-detail").count()) === 0,
`${name}: no permanent sidebars`,
);
check(
await page.evaluate(
() => document.documentElement.scrollWidth <= innerWidth,
),
`${name}: no document overflow`,
);
}
await page.setViewportSize({ width: 1440, height: 900 });
if (process.env.HEL751_SKIP_VISUAL) {
await page.goto(base + "/tasks");
await page.locator("tbody tr").first().waitFor();
}
await page.locator(".task-tabs button").nth(1).click();
check((await page.locator("tbody tr").count()) === 3, "active filter");
await page.locator(".task-tabs button").nth(2).click();
check((await page.locator("tbody tr").count()) === 1, "failed filter");
await page.locator(".task-tabs button").nth(3).click();
check((await page.locator("tbody tr").count()) === 3, "image filter");
await page.locator(".task-tabs button").nth(4).click();
check((await page.locator("tbody tr").count()) === 3, "video filter");
await page.locator(".task-tabs button").first().click();
await page.getByRole("searchbox").fill("镜头 07");
await page.waitForTimeout(100);
check((await page.locator("tbody tr").count()) === 1, "search by task title");
await page.getByRole("searchbox").fill("HEL751 absent");
await page.waitForTimeout(100);
check((await page.locator("tbody tr").count()) === 0, "search empty");
await page.screenshot({ path: path.join(out, "search-empty.png") });
await page.getByRole("searchbox").fill("");
await page.getByRole("button", { name: "查看任务 #6", exact: true }).click();
await page.getByRole("dialog").waitFor();
const video = page.locator("video");
await video.evaluate(
(v) =>
new Promise((r) =>
v.readyState >= 1
? r()
: v.addEventListener("loadedmetadata", r, { once: true }),
),
);
check(
await video.evaluate(
(v) =>
v.paused && v.controls && !v.autoplay && !v.muted && v.volume === 1,
),
"video manual playback with sound",
);
await video.hover();
const videoBox = await video.boundingBox();
await video.click({ position: { x: 26, y: videoBox.height - 28 } });
await page.waitForTimeout(250);
check(
await video.evaluate((v) => !v.paused && v.currentTime > 0),
"explicit video play",
);
await video.evaluate((v) => {
v.pause();
v.currentTime = 0.7;
v.volume = 0.4;
});
await page.waitForTimeout(300);
result.videoState = await video.evaluate((v) => ({
paused: v.paused,
time: v.currentTime,
volume: v.volume,
duration: v.duration,
seekable: v.seekable.length,
error: v.error?.message,
}));
check(
result.videoState.paused &&
result.videoState.time > 0.6 &&
Math.abs(result.videoState.volume - 0.4) < 0.001,
"pause seek volume",
);
await page.screenshot({ path: path.join(out, "video-preview.png") });
await page.keyboard.press("Tab");
check(
await page
.getByRole("dialog")
.evaluate((e) => e.contains(document.activeElement)),
"modal focus stays inside",
);
await page.getByRole("dialog").locator(".modal-close").focus();
await page.keyboard.press("Escape");
await page.getByRole("dialog").waitFor({ state: "hidden" });
check((await page.locator("video").count()) === 0, "close unmounts video");
check(
await page
.getByRole("button", { name: "查看任务 #6", exact: true })
.evaluate((e) => e === document.activeElement),
"modal restores trigger focus",
);
await page.getByRole("button", { name: "查看任务 #5", exact: true }).click();
await page.getByRole("dialog").waitFor();
check(
(await page.getByRole("dialog").locator("img").count()) === 1,
"image preview",
);
const [download] = await Promise.all([
page.waitForEvent("download"),
page.getByRole("link", { name: "下载", exact: true }).click(),
]);
check(!(await download.failure()), "native image download");
await download.saveAs(path.join(out, "downloaded-fixture.png"));
await page.getByRole("button", { name: "删除", exact: true }).click();
check(
(await page.locator("tbody tr").count()) === 5,
"delete removes row immediately",
);
await page.getByRole("button", { name: "撤销", exact: true }).click();
check((await page.locator("tbody tr").count()) === 6, "undo restores row");
check(
!result.mutations.some((m) => m.method === "DELETE"),
"undo prevents DELETE",
);
await page.getByRole("button", { name: "查看任务 #5", exact: true }).click();
await page.getByRole("button", { name: "删除", exact: true }).click();
await page.waitForTimeout(5300);
check(
result.mutations.some((m) => m.method === "DELETE"),
"delete commits after undo window",
);
await page
.locator("tbody tr")
.filter({ hasText: "参考生视频 #21" })
.getByRole("button", { name: "重试", exact: true })
.click();
await page.waitForTimeout(150);
check(
jobs.find((j) => j.id === 4).status === "pending",
"retry uses real API contract",
);
await page
.locator("tbody tr")
.filter({ hasText: "Qwen 局部重绘" })
.getByRole("button", { name: "取消", exact: true })
.click();
await page.waitForTimeout(150);
check(
jobs.find((j) => j.id === 3).error === "cancelled",
"cancel uses real API contract",
);
await page.getByRole("button", { name: "查看任务 #1", exact: true }).click();
await page.getByRole("button", { name: "创作台", exact: true }).click();
check(
(await page.locator("tbody tr").count()) === 2,
"source filter retained in details",
);
await page.getByRole("button", { name: "全部来源", exact: true }).click();
const testJob = {
...structuredClone(fixture.jobs[4]),
id: 8,
origin: "workflow_test",
payload: { source: "workflow_test", title: "HEL751 workflow test" },
output_paths: ["HEL751-fixture-4.png"],
};
jobs.push(testJob);
await page.goto(base + "/tasks");
await page.getByRole("button", { name: "查看任务 #8", exact: true }).click();
await page.getByRole("button", { name: "工作流试跑", exact: true }).click();
check(
(await page.locator("tbody tr").count()) === 1,
"workflow test source isolated",
);
await page.getByRole("button", { name: "全部来源", exact: true }).click();
// Pending/running without a numeric report must remain unknown.
await page.goto(base + "/tasks");
await page.locator("tbody tr").first().waitFor();
check(
(await page.locator(".task-progress").count()) === 0,
"no fabricated percentages after reload",
);
jobs = [];
await page.goto(base + "/tasks");
await page.waitForTimeout(400);
check((await page.locator("tbody tr").count()) === 0, "true empty data");
await page.screenshot({ path: path.join(out, "true-empty.png") });
apiError = true;
await page.goto(base + "/tasks");
await page
.getByRole("alert")
.filter({ hasText: "任务加载失败" })
.waitFor({ timeout: 15000 });
check(true, "API failure shown honestly");
apiError = false;
jobs = structuredClone(fixture.jobs);
await page.getByRole("button", { name: "重试", exact: true }).click();
await page.locator("tbody tr").first().waitFor();
check(true, "load retry restores tasks");
for (const route of [
"workflows",
"connections",
"library",
"settings",
"create?kind=image",
"create?kind=video",
]) {
await page.goto(base + "/" + route);
await page.waitForTimeout(400);
await page.screenshot({
path: path.join(out, "regression-" + route.replace(/\W/g, "-") + ".png"),
});
check(
(await page.locator(".task-table").count()) === 0,
`${route}: task table styles scoped`,
);
if (route.startsWith("create?")) {
const textarea = page.locator(".studio-form textarea").first();
await textarea.fill("HEL751 isolated creative draft");
check(
(await textarea.inputValue()) === "HEL751 isolated creative draft",
`${route}: prompt remains editable`,
);
await page.reload();
await page.locator(".studio-form textarea").first().waitFor();
check(
(await page.locator(".studio-form textarea").first().inputValue()) ===
"HEL751 isolated creative draft",
`${route}: draft survives reload`,
);
}
if (route === "workflows")
check(
(await page
.locator(".mg-side")
.evaluate((e) => e.getBoundingClientRect().width)) === 220,
"workflow sidebar retains 220px",
);
}
check(result.errors.length === 0, "no browser page errors");
await context.close();
} finally {
await fs.writeFile(
path.join(out, "results.json"),
JSON.stringify(result, null, 2),
);
await browser.close();
await new Promise((r) => server.close(r));
}
+119
View File
@@ -0,0 +1,119 @@
"""Foreground isolated evidence run. No user data or external generation services."""
import json
import os
from pathlib import Path
import sqlite3
import subprocess
import tempfile
import imageio_ffmpeg
from PIL import Image, ImageDraw
ROOT = Path(__file__).resolve().parents[1]
OUT = ROOT.parent / "hel751-evidence"
OUT.mkdir(exist_ok=True)
# This database exists only for the duration of this foreground verification.
with tempfile.TemporaryDirectory(prefix="hel751-isolated-", dir=OUT) as data:
db = sqlite3.connect(Path(data) / "fixture.sqlite")
db.execute("create table fixture_jobs (id integer primary key, purpose text, body text)")
names = [
"镜头 07 · 首尾帧", "分镜草图 8/12", "Qwen 局部重绘 #62",
"参考生视频 #21", "Krea2 文生图 #55", "镜头 06 · 首尾帧",
]
workflow_names = [
"H3 首尾帧生视频", "Krea2 · 4 宫格故事板", "Qwen 2.1 · 局部重绘",
"H3 参考生视频", "Krea2 · 文生图", "H3 首尾帧生视频",
]
workflow_rows = []
for i in range(6):
kind = "video" if i in (0, 3, 5) else "image"
job = {
"id": i + 1,
"project_id": 1 if i in (0, 1, 5) else 0,
"scene_id": 1 if i in (0, 1, 5) else None,
"clip_id": None,
"kind": kind,
"workflow_id": i + 1,
"status": ["running", "running", "pending", "failed", "done", "done"][i],
"payload": {
"title": names[i],
"source": "workbench" if i in (0, 1, 5) else "studio-" + kind,
"endpoint": {"id": "hel751-fake", "name": "5090D"},
},
"output_paths": (
["HEL751-fixture-4.png" if i == 4 else "HEL751-fixture-video.mp4"]
if i > 3 else []
),
"error": "OOM" if i == 3 else None,
"created_at": f"2026-10-21T13:0{i}:00Z",
"started_at": None,
"completed_at": None,
"retry_count": 0,
"phase": "comfy_running" if i < 2 else None,
}
db.execute(
"insert into fixture_jobs values (?,?,?)",
(i + 1, "HEL-751 isolated sample correspondence; never production",
json.dumps(job, ensure_ascii=False)),
)
workflow_rows.append({
"id": i + 1, "name": workflow_names[i], "kind": kind,
"media_io": {"in": [], "out": [kind]}, "tags": [],
"favorite": False, "category": None, "model_family": None,
"usage": "text2video" if kind == "video" else "text2image",
"is_enabled": True, "needs_mapping": False,
"input_schema": [{
"nodeId": "1", "label": "HEL751 fixture prompt",
"role": "prompt", "kind": "textarea", "defaultValue": "", "required": True,
}],
"output_schema": [{"nodeId": "2", "label": "Output", "kind": kind}],
"endpoint_id": "hel751-fake",
})
db.commit()
jobs = [json.loads(row[0]) for row in db.execute("select body from fixture_jobs order by id")]
db.close()
(Path(data) / "fixtures.json").write_text(
json.dumps({"jobs": jobs, "workflows": workflow_rows}, ensure_ascii=False),
encoding="utf-8",
)
subprocess.run(
[
imageio_ffmpeg.get_ffmpeg_exe(), "-y", "-f", "lavfi", "-i",
"color=c=navy:s=320x180:r=24", "-f", "lavfi", "-i",
"sine=frequency=440:sample_rate=44100", "-t", "2", "-c:v",
"libx264", "-pix_fmt", "yuv420p", "-c:a", "aac", str(Path(data) / "video.mp4"),
],
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, check=True,
)
env = os.environ | {"HEL751_OUT": str(OUT), "HEL751_DATA": data}
subprocess.run(["node", "scripts/verify-hel751.mjs"], cwd=ROOT, env=env, check=True)
print("Isolated database and media removed; all run-owned servers closed.")
# Compose unchanged captures into review artifacts; never alter the original HTML.
if not os.environ.get("HEL751_SKIP_VISUAL"):
evidence = json.loads((OUT / "results.json").read_text())
lines = [
"| Theme / viewport | Element | Sample x,y,w,h | Actual x,y,w,h | Delta x,y,w,h |",
"|---|---|---|---|---|",
]
for measurement in evidence["measurements"]:
name = f"tasks-{measurement['theme']}-{measurement['width']}"
sample = Image.open(OUT / f"{name}-sample.png").convert("RGB")
actual = Image.open(OUT / f"{name}-actual.png").convert("RGB")
pair = Image.new("RGB", (sample.width * 2, sample.height + 28), "#151515")
ImageDraw.Draw(pair).text((12, 8), "Confirmed sample (left) / Isolated implementation (right)", fill="white")
pair.paste(sample, (0, 28))
pair.paste(actual, (sample.width, 28))
pair.save(OUT / f"{name}-pair.png")
for key, expected in measurement["sample"].items():
observed = measurement["actual"][key]
coordinates = ("x", "y", "width", "height")
values = lambda row: ", ".join(f"{row[c]:.3f}" for c in coordinates)
delta = {c: observed[c] - expected[c] for c in coordinates}
lines.append(
f"| {measurement['theme']} {measurement['width']}×{measurement['height']} "
f"| {key} | {values(expected)} | {values(observed)} | {values(delta)} |"
)
(OUT / "dimensions.md").write_text("\n".join(lines) + "\n", encoding="utf-8")
+936
View File
@@ -0,0 +1,936 @@
/** Run via verify-hel767.py; explicit API fake, local-only origins, no user data. */
import fs from "node:fs/promises";
import http from "node:http";
import path from "node:path";
import { pathToFileURL } from "node:url";
const { chromium } = await import(
process.env.PLAYWRIGHT_MODULE || "playwright"
);
const out = process.env.HEL767_OUT,
data = process.env.HEL767_DATA;
const fixture = JSON.parse(
await fs.readFile(path.join(data, "fixtures.json"), "utf8"),
);
let jobs = structuredClone(fixture.jobs),
apiError = false,
imageFixture;
const result = {
checks: [],
measurements: [],
errors: [],
mutations: [],
externalBlocked: [],
apiReads: [],
};
const check = (v, n) => {
result.checks.push({ name: n, pass: !!v });
if (!v) throw Error(n);
};
const server = http.createServer(async (req, res) => {
try {
const p = new URL(req.url, "http://local").pathname;
let f = path.join("calliope-web/build", decodeURIComponent(p));
if (!path.extname(f)) f = path.join("calliope-web/build", "index.html");
const bytes = await fs.readFile(f);
res.setHeader(
"Content-Type",
f.endsWith(".js")
? "text/javascript"
: f.endsWith(".css")
? "text/css"
: f.endsWith(".html")
? "text/html"
: "application/octet-stream",
);
res.end(bytes);
} catch {
res.writeHead(404);
res.end();
}
});
await new Promise((r) => server.listen(0, "127.0.0.1", r));
const base = `http://127.0.0.1:${server.address().port}`;
const browser = await chromium.launch({
headless: true,
args: ["--no-sandbox"],
});
try {
const context = await browser.newContext({
viewport: { width: 1440, height: 900 },
timezoneId: "Asia/Shanghai",
recordVideo: {
dir: path.join(out, "recordings"),
size: { width: 1440, height: 900 },
},
});
await context.addInitScript(() => {
localStorage.setItem("aigc-lang", "zh");
localStorage.setItem("aigc-reduce-motion", "1");
window.__sse = [];
window.EventSource = class {
constructor() {
window.__sse.push(this);
setTimeout(() => this.onopen?.(), 0);
}
addEventListener(t, f) {
(this.handlers ??= {})[t] = f;
}
close() {}
};
});
await context.route("**/*", async (route) => {
const req = route.request(),
u = new URL(req.url());
if (["data:", "blob:", "file:"].includes(u.protocol))
return route.continue();
if (u.origin !== base) {
result.externalBlocked.push(u.origin);
return route.abort();
}
if (!u.pathname.startsWith("/api/")) return route.continue();
const json = (body) => route.fulfill({ json: body });
if (u.pathname === "/api/file") {
if (u.searchParams.get("path")?.endsWith(".mp4")) {
const bytes = await fs.readFile(path.join(data, "video.mp4"));
const range = req.headers()["range"]?.match(/bytes=(\d+)-(\d*)/);
if (range) {
const start = +range[1],
end = range[2]
? Math.min(+range[2], bytes.length - 1)
: bytes.length - 1;
return route.fulfill({
status: 206,
body: bytes.subarray(start, end + 1),
contentType: "video/mp4",
headers: {
"Accept-Ranges": "bytes",
"Content-Range": `bytes ${start}-${end}/${bytes.length}`,
"Content-Length": String(end - start + 1),
},
});
}
return route.fulfill({
body: bytes,
contentType: "video/mp4",
headers: {
"Accept-Ranges": "bytes",
"Content-Length": String(bytes.length),
},
});
}
return route.fulfill({
body:
imageFixture ||
Buffer.from(
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+jR1kAAAAASUVORK5CYII=",
"base64",
),
contentType: "image/png",
});
}
const session = {
id: 767,
title: "HEL767 isolated session",
project_id: null,
origin: u.searchParams.get("origin") === "scene" ? "scene" : "chat",
messages: [],
plan: null,
running: false,
};
const graph = {
canvas: {
id: 767,
title: "HEL767 isolated canvas",
project_id: null,
agent_session_id: 767,
viewport_json: null,
},
nodes: [],
edges: [],
};
if (u.pathname === "/api/agent/sessions" && req.method() === "GET")
return json([session]);
if (u.pathname === "/api/agent/sessions/767") return json(session);
if (u.pathname === "/api/canvas" && req.method() === "POST") {
result.mutations.push({
path: u.pathname,
method: "POST",
purpose: "isolated canvas ensure only",
});
return json(graph);
}
if (u.pathname === "/api/canvas/767") return json(graph);
if (u.pathname === "/api/shots/by-session/767")
return json({
id: 767,
title: "HEL767 isolated composition",
scene: { objects: [], cameraTrack: [] },
});
if (u.pathname === "/api/shots/767/captures") return json([]);
if (u.pathname === "/api/shots") return json([]);
if (
u.pathname === "/api/agent/projects" ||
u.pathname === "/api/agent/skills"
)
return json([]);
if (u.pathname === "/api/agent/sessions" && req.method() === "POST")
return json(session);
if (req.method() !== "GET") {
result.mutations.push({ path: u.pathname, method: req.method() });
const m = u.pathname.match(/^\/api\/jobs\/(\d+)\/(cancel|retry)$/);
if (m) {
const j = jobs.find((j) => j.id === +m[1]);
j.status = m[2] === "cancel" ? "failed" : "pending";
j.error = m[2] === "cancel" ? "cancelled" : null;
return json(j);
}
const d = u.pathname.match(/^\/api\/playground\/jobs\/(\d+)$/);
if (d && req.method() === "DELETE") {
jobs = jobs.filter((j) => j.id !== +d[1]);
return json({ ok: true });
}
if (u.pathname.includes("test")) return json({ ok: true });
result.errors.push(`Unexpected mutation ${req.method()} ${u.pathname}`);
return route.fulfill({
status: 503,
body: "Unlisted fixture mutation blocked",
});
}
result.apiReads.push(u.pathname);
if (u.pathname === "/api/jobs") {
if (apiError)
return route.fulfill({ status: 500, body: "HEL767 isolated error" });
return json(jobs);
}
if (u.pathname === "/api/workflows") return json(fixture.workflows);
if (u.pathname === "/api/projects")
return json([{ id: 1, title: "雾港来信" }]);
if (u.pathname === "/api/settings")
return json({
comfyui_endpoints: [],
llm_profiles: [],
queue_concurrency: 2,
queue_poll_timeout_sec: 1800,
queue_poll_interval_sec: 1,
queue_max_retries: 1,
data_dir: "HEL767-isolated-fixture",
dry_run: false,
});
if (u.pathname === "/api/comfyui/endpoints")
return json({ endpoints: [], active_id: null });
if (u.pathname.includes("/jobs")) return json([]);
if (u.pathname.includes("/sessions")) return json([]);
if (
u.pathname === "/api/library/media" ||
u.pathname === "/api/agent/agents" ||
u.pathname === "/api/agent/memories"
)
return json([]);
const wf = u.pathname.match(/^\/api\/workflows\/(\d+)$/);
if (wf)
return json({
...fixture.workflows.find((w) => w.id === +wf[1]),
workflow_json: {},
output_map: [
{
nodeId: "2",
kind: fixture.workflows.find((w) => w.id === +wf[1]).kind,
},
],
});
return json({});
});
const page = await context.newPage();
page.on("pageerror", (e) => result.errors.push(e.message));
const ref = await context.newPage();
await ref.goto(
pathToFileURL(path.resolve("../attachments/夜片场-多主题交互样本.html"))
.href,
);
await ref.evaluate(() => {
go("tasks");
document.documentElement.classList.add("capture", "reduce");
});
// Reuse the corresponding original synthetic still only inside this API fake.
// Canvas avoids capturing a parent page during its opacity entrance animation.
imageFixture = Buffer.from(
await ref.evaluate(async () => {
const img = new Image();
img.src = still(64, 96, 64);
await img.decode();
const canvas = document.createElement("canvas");
canvas.width = 48;
canvas.height = 32;
canvas.getContext("2d").drawImage(img, 0, 0, 48, 32);
return canvas.toDataURL("image/png").split(",")[1];
}),
"base64",
);
const progress = () =>
page.evaluate(() => {
for (const es of window.__sse)
for (const [id, pct] of [
[1, 62],
[2, 35],
])
es.handlers?.["job.progress"]?.({
data: JSON.stringify({
type: "job.progress",
data: { job_id: id, progress: pct, phase: "comfy_running" },
}),
});
});
const measure = (p, selectors) =>
p.evaluate(
(selectors) =>
Object.fromEntries(
Object.entries(selectors).map(([k, s]) => {
const e = document.querySelector(s);
if (!e) return [k, null];
const r = e.getBoundingClientRect(),
c = getComputedStyle(e);
return [
k,
{
x: r.x,
y: r.y,
width: r.width,
height: r.height,
font: c.fontSize,
line: c.lineHeight,
weight: c.fontWeight,
shadow: c.boxShadow,
svg: e.matches("svg") ? e.innerHTML : undefined,
radius: c.borderRadius,
color: c.color,
background: c.backgroundColor,
},
];
}),
),
selectors,
);
for (const [width, height] of process.env.HEL767_SKIP_VISUAL
? []
: [
[1440, 900],
[1366, 768],
[1280, 720],
])
for (const theme of ["night", "day", "grade", "bluehour"])
for (const [route, sample] of [
["workflows", "workflows"],
["connections", "models"],
["tasks", "tasks"],
["library", "library"],
["settings", "settings"],
]) {
await page.setViewportSize({ width, height });
await ref.setViewportSize({ width, height });
await page.goto(base + "/" + route);
await page.locator(".management-header").waitFor();
if (route === "tasks") {
await page.locator(".task-table tbody tr").first().waitFor();
await progress();
}
if (route === "settings")
await page.locator(".choices.tiles").waitFor();
await page.evaluate((theme) => {
localStorage.setItem("aigc-theme", theme);
window.dispatchEvent(
new StorageEvent("storage", { key: "aigc-theme", newValue: theme }),
);
}, theme);
await ref.evaluate(
({ sample, theme }) => {
go(sample);
setTheme(theme);
},
{ sample, theme },
);
await page.waitForTimeout(650);
const name = `${route}-${theme}-${width}`;
const actualSelectors = {
header: ".management-header",
brand: ".refract-shell > .sidebar .brand",
divider: ".refract-shell > .sidebar hr",
foot: ".sidebar-bottom",
queue: ".queue-dot",
theme: ".sidebar-bottom button",
settings: '.sidebar-bottom a[href="/settings"]',
themeCard: ".tiles .choice",
themePreview: ".tiles .preview",
themeName: ".tiles .copy strong",
themeTone: ".tiles .copy small",
};
const sampleSelectors = {
header: `#p-${sample} .ph`,
brand: ".rail .brand",
divider: ".rail hr",
foot: ".rail .foot",
queue: ".rail .queue-dot",
theme: "#themeBtn",
settings: '.rail [data-go="settings"]',
themeCard: ".ttile",
themePreview: ".ttile .tprev",
themeName: ".ttile b",
themeTone: ".ttile small",
};
for (let i = 0; i < 9; i++) {
actualSelectors[`nav${i}`] =
`.refract-shell > .sidebar nav a:nth-child(${i < 6 ? i + 1 : i - 5})`;
if (i >= 6)
actualSelectors[`nav${i}`] =
`.refract-shell > .sidebar .management a:nth-child(${i - 5})`;
sampleSelectors[`nav${i}`] = `.rail > .nav:nth-of-type(${i + 1})`;
}
if (route === "settings")
for (let i = 1; i <= 4; i++)
for (const [key, a, b] of [
["Card", ".choice", ".ttile"],
["Preview", ".preview", ".tprev"],
["Name", ".copy strong", "b"],
["Tone", ".copy small", "small"],
]) {
actualSelectors[`theme${key}${i}`] =
key === "Card"
? `.tiles .choice:nth-child(${i})`
: `.tiles .choice:nth-child(${i}) ${a}`;
sampleSelectors[`theme${key}${i}`] =
key === "Card"
? `.ttile:nth-child(${i})`
: `.ttile:nth-child(${i}) ${b}`;
}
if (route !== "settings")
for (const key of [
"themeCard",
"themePreview",
"themeName",
"themeTone",
]) {
delete actualSelectors[key];
delete sampleSelectors[key];
}
if (route === "tasks") {
Object.assign(actualSelectors, {
table: ".task-table",
head: ".task-table thead",
row: ".task-table tbody tr",
thumbnail: ".task-thumbnail",
segment: ".task-tabs",
search: ".task-search",
progress: ".task-progress",
});
Object.assign(sampleSelectors, {
table: "#p-tasks .tbl",
head: "#p-tasks thead",
row: "#taskTable tr",
thumbnail: "#taskTable td>div",
segment: "#p-tasks .seg",
search: "#p-tasks .input",
progress: "#taskTable .bar",
});
}
const railState = (p, actual) =>
p.evaluate((actual) => {
const selectors = actual
? [
".refract-shell > .sidebar nav a",
'.sidebar-bottom a[href="/settings"]',
".sidebar-bottom button:last-of-type",
]
: [
".rail > .nav",
'.rail [data-go="settings"]',
'.rail [data-go="spec"]',
];
const nodes = [
...document.querySelectorAll(selectors[0]),
...selectors.slice(1).map((x) => document.querySelector(x)),
];
const geometry = (svg) => {
const use = svg.querySelector("use");
const root = use
? document.querySelector(use.getAttribute("href"))
: svg;
return [...root.children].map((n) => [
n.tagName,
[...n.attributes].map((a) => [a.name, a.value]).sort(),
]);
};
const selected = nodes.find((n) =>
actual
? n.getAttribute("aria-current") === "page"
: n.classList.contains("on"),
);
const pseudo = selected
? getComputedStyle(selected, "::before")
: null;
return {
icons: nodes
.filter((n) => n?.querySelector("svg"))
.map((n) => {
const svg = n.querySelector("svg"),
c = getComputedStyle(svg);
return {
geometry: geometry(svg),
width: c.width,
height: c.height,
stroke: c.strokeWidth,
color: c.color,
};
}),
selected: pseudo
? Object.fromEntries(
[
"left",
"top",
"bottom",
"width",
"borderRadius",
"backgroundColor",
"boxShadow",
].map((k) => [k, pseudo[k]]),
)
: null,
};
}, actual);
const railActual = await railState(page, true),
railSample = await railState(ref, false);
result.railProbe = { railActual, railSample };
check(
JSON.stringify(railActual) === JSON.stringify(railSample),
`${name}: rail icon paths, sizes, strokes, colors and selected indicator match`,
);
const measured = {
route,
theme,
width,
height,
railActual,
railSample,
actual: await measure(page, actualSelectors),
sample: await measure(ref, sampleSelectors),
};
result.measurements.push(measured);
await page.screenshot({ path: path.join(out, name + "-actual.png") });
await ref.screenshot({ path: path.join(out, name + "-sample.png") });
for (const key of Object.keys(sampleSelectors)) {
if (key === "progress") continue; // auto columns depend on honest source text; reported separately
for (const axis of ["x", "y", "width", "height"]) {
// Table column width distribution is data-dependent, the outer table and thumbnail dimensions are fixed.
if (key === "thumbnail" && axis === "x") continue;
check(
measured.actual[key] &&
measured.sample[key] &&
Math.abs(
measured.actual[key][axis] - measured.sample[key][axis],
) < 0.1,
`${name}: ${key} ${axis} matches sample`,
);
}
}
for (const key of [
"brand",
"queue",
"theme",
"settings",
...Array.from({ length: 9 }, (_, i) => `nav${i}`),
])
check(
measured.actual[key].radius === measured.sample[key].radius,
`${name}: ${key} radius matches sample`,
);
if (route === "settings")
for (const key of [
"themeName",
"themeTone",
...Array.from({ length: 4 }, (_, i) => `themeName${i + 1}`),
...Array.from({ length: 4 }, (_, i) => `themeTone${i + 1}`),
])
check(
measured.actual[key].line === measured.sample[key].line,
`${name}: ${key} line height`,
);
check(
await page.evaluate(
() => document.documentElement.scrollWidth <= innerWidth,
),
`${name}: no overflow`,
);
}
await page.goto(base + "/tasks");
await page.locator(".task-table tbody tr").first().waitFor();
await page.setViewportSize({ width: 1440, height: 900 });
if (process.env.HEL767_SKIP_VISUAL) {
await page.goto(base + "/tasks");
await page.locator("tbody tr").first().waitFor();
}
await page.locator(".task-tabs button").nth(1).click();
check((await page.locator("tbody tr").count()) === 3, "active filter");
await page.locator(".task-tabs button").nth(2).click();
check((await page.locator("tbody tr").count()) === 1, "failed filter");
await page.locator(".task-tabs button").nth(3).click();
check((await page.locator("tbody tr").count()) === 3, "image filter");
await page.locator(".task-tabs button").nth(4).click();
check((await page.locator("tbody tr").count()) === 3, "video filter");
await page.locator(".task-tabs button").first().click();
await page.getByRole("searchbox").fill("镜头 07");
await page.waitForTimeout(100);
check((await page.locator("tbody tr").count()) === 1, "search by task title");
await page.getByRole("searchbox").fill("HEL767 absent");
await page.waitForTimeout(100);
check((await page.locator("tbody tr").count()) === 0, "search empty");
await page.screenshot({ path: path.join(out, "search-empty.png") });
await page.getByRole("searchbox").fill("");
await page.getByRole("button", { name: "查看任务 #6", exact: true }).click();
await page.getByRole("dialog").waitFor();
const video = page.locator("video");
await video.evaluate(
(v) =>
new Promise((r) =>
v.readyState >= 1
? r()
: v.addEventListener("loadedmetadata", r, { once: true }),
),
);
check(
await video.evaluate(
(v) =>
v.paused && v.controls && !v.autoplay && !v.muted && v.volume === 1,
),
"video manual playback with sound",
);
await video.hover();
const videoBox = await video.boundingBox();
await video.click({ position: { x: 26, y: videoBox.height - 28 } });
await page.waitForTimeout(250);
check(
await video.evaluate((v) => !v.paused && v.currentTime > 0),
"explicit video play",
);
await video.evaluate((v) => {
v.pause();
v.currentTime = 0.7;
v.volume = 0.4;
});
await page.waitForTimeout(300);
result.videoState = await video.evaluate((v) => ({
paused: v.paused,
time: v.currentTime,
volume: v.volume,
duration: v.duration,
seekable: v.seekable.length,
error: v.error?.message,
}));
check(
result.videoState.paused &&
result.videoState.time > 0.6 &&
Math.abs(result.videoState.volume - 0.4) < 0.001,
"pause seek volume",
);
await page.screenshot({ path: path.join(out, "video-preview.png") });
await page.keyboard.press("Tab");
check(
await page
.getByRole("dialog")
.evaluate((e) => e.contains(document.activeElement)),
"modal focus stays inside",
);
await page.getByRole("dialog").locator(".modal-close").focus();
await page.keyboard.press("Escape");
await page.getByRole("dialog").waitFor({ state: "hidden" });
check((await page.locator("video").count()) === 0, "close unmounts video");
check(
await page
.getByRole("button", { name: "查看任务 #6", exact: true })
.evaluate((e) => e === document.activeElement),
"modal restores trigger focus",
);
await page.getByRole("button", { name: "查看任务 #5", exact: true }).click();
await page.getByRole("dialog").waitFor();
check(
(await page.getByRole("dialog").locator("img").count()) === 1,
"image preview",
);
const [download] = await Promise.all([
page.waitForEvent("download"),
page.getByRole("link", { name: "下载", exact: true }).click(),
]);
check(!(await download.failure()), "native image download");
await download.saveAs(path.join(out, "downloaded-fixture.png"));
await page.getByRole("button", { name: "删除", exact: true }).click();
check(
(await page.locator("tbody tr").count()) === 5,
"delete removes row immediately",
);
await page.getByRole("button", { name: "撤销", exact: true }).click();
check((await page.locator("tbody tr").count()) === 6, "undo restores row");
check(
!result.mutations.some((m) => m.method === "DELETE"),
"undo prevents DELETE",
);
await page.getByRole("button", { name: "查看任务 #5", exact: true }).click();
await page.getByRole("button", { name: "删除", exact: true }).click();
await page.waitForTimeout(5300);
check(
result.mutations.some((m) => m.method === "DELETE"),
"delete commits after undo window",
);
await page
.locator("tbody tr")
.filter({ hasText: "参考生视频 #21" })
.getByRole("button", { name: "重试", exact: true })
.click();
await page.waitForTimeout(150);
check(
jobs.find((j) => j.id === 4).status === "pending",
"retry uses real API contract",
);
await page
.locator("tbody tr")
.filter({ hasText: "Qwen 局部重绘" })
.getByRole("button", { name: "取消", exact: true })
.click();
await page.waitForTimeout(150);
check(
jobs.find((j) => j.id === 3).error === "cancelled",
"cancel uses real API contract",
);
await page.getByRole("button", { name: "查看任务 #1", exact: true }).click();
await page.getByRole("button", { name: "创作台", exact: true }).click();
check(
(await page.locator("tbody tr").count()) === 2,
"source filter retained in details",
);
await page.getByRole("button", { name: "全部来源", exact: true }).click();
const testJob = {
...structuredClone(fixture.jobs[4]),
id: 8,
origin: "workflow_test",
payload: { source: "workflow_test", title: "HEL767 workflow test" },
output_paths: ["HEL767-fixture-4.png"],
};
jobs.push(testJob);
await page.goto(base + "/tasks");
await page.getByRole("button", { name: "查看任务 #8", exact: true }).click();
await page.getByRole("button", { name: "工作流试跑", exact: true }).click();
check(
(await page.locator("tbody tr").count()) === 1,
"workflow test source isolated",
);
await page.getByRole("button", { name: "全部来源", exact: true }).click();
// Pending/running without a numeric report must remain unknown.
await page.goto(base + "/tasks");
await page.locator("tbody tr").first().waitFor();
check(
(await page.locator(".task-progress").count()) === 0,
"no fabricated percentages after reload",
);
jobs = [];
await page.goto(base + "/tasks");
await page.waitForTimeout(400);
check((await page.locator("tbody tr").count()) === 0, "true empty data");
await page.screenshot({ path: path.join(out, "true-empty.png") });
apiError = true;
await page.goto(base + "/tasks");
await page
.getByRole("alert")
.filter({ hasText: "任务加载失败" })
.waitFor({ timeout: 15000 });
check(true, "API failure shown honestly");
apiError = false;
jobs = structuredClone(fixture.jobs);
await page.getByRole("button", { name: "重试", exact: true }).click();
await page.locator("tbody tr").first().waitFor();
check(true, "load retry restores tasks");
for (const route of [
"workflows",
"connections",
"library",
"settings",
"create?kind=image",
"create?kind=video",
]) {
await page.goto(base + "/" + route);
await page.waitForTimeout(400);
await page.screenshot({
path: path.join(out, "regression-" + route.replace(/\W/g, "-") + ".png"),
});
check(
(await page.locator(".task-table").count()) === 0,
`${route}: task table styles scoped`,
);
if (route.startsWith("create?")) {
const textarea = page.locator(".studio-form textarea").first();
await textarea.fill("HEL767 isolated creative draft");
check(
(await textarea.inputValue()) === "HEL767 isolated creative draft",
`${route}: prompt remains editable`,
);
await page.reload();
await page.locator(".studio-form textarea").first().waitFor();
check(
(await page.locator(".studio-form textarea").first().inputValue()) ===
"HEL767 isolated creative draft",
`${route}: draft survives reload`,
);
}
if (route === "workflows")
check(
(await page
.locator(".mg-side")
.evaluate((e) => e.getBoundingClientRect().width)) === 220,
"workflow sidebar retains 220px",
);
}
for (const route of [
"/",
"/workbench",
"/agents",
"/canvas",
"/build-scene",
"/models",
"/model-connections",
"/image",
"/video",
]) {
await page.goto(base + route);
await page.locator(".refract-shell > .sidebar").waitFor({ timeout: 15000 });
await page.waitForTimeout(350);
check(
(await page.locator(".refract-shell > .sidebar").count()) === 1,
`${route}: shared rail reachable`,
);
if (route === "/models")
check(
page.url().includes("/connections?tab=llm"),
"legacy models redirect",
);
if (route === "/model-connections")
check(page.url().includes("/connections"), "connections alias redirect");
}
await page.goto(base + "/create?kind=image");
await page.locator(".studio-form textarea").first().waitFor();
check(
(await page.locator(".studio-form input[type=file]").count()) === 0,
"t2i without image input keeps no padding slot",
);
await page.keyboard.press("Control+k");
await page.getByRole("dialog").waitFor();
check(true, "Ctrl+K opens global search");
await page.keyboard.press("Escape");
await page.getByRole("dialog").waitFor({ state: "hidden" });
await page.goto(base + "/tasks");
await page.locator(".task-table tbody tr").first().waitFor();
await ref.setViewportSize({ width: 1440, height: 900 });
for (const theme of ["night", "day", "grade", "bluehour"]) {
await page.evaluate((theme) => {
localStorage.setItem("aigc-theme", theme);
window.dispatchEvent(
new StorageEvent("storage", { key: "aigc-theme", newValue: theme }),
);
}, theme);
await ref.evaluate((theme) => {
go("tasks");
setTheme(theme);
}, theme);
await page.waitForTimeout(650);
await page
.locator('.refract-shell > .sidebar a[href="/workflows"]')
.hover();
await ref.locator('.rail [data-go="workflows"]').hover();
await page.waitForTimeout(250);
result.checks.push({
name: `${theme}: rail hover`,
pass: true,
actual: await measure(page, {
hover: '.refract-shell > .sidebar a[href="/workflows"]',
}),
sample: await measure(ref, { hover: '.rail [data-go="workflows"]' }),
});
const hover = result.checks.at(-1);
for (const prop of ["color", "background", "radius"])
check(
hover.actual.hover[prop] === hover.sample.hover[prop],
`${theme}: hover ${prop} matches sample`,
);
await page.screenshot({
path: path.join(out, `hover-${theme}-actual.png`),
});
await ref.screenshot({ path: path.join(out, `hover-${theme}-sample.png`) });
}
await page.locator('.sidebar-bottom button[title="主题"]').click();
await page.getByRole("dialog").waitFor();
const firstChoice = page.getByRole("dialog").locator(".choice").first();
await firstChoice.focus();
await page.keyboard.press("ArrowRight");
await page.keyboard.press("Enter");
check(
(await page.evaluate(() => document.documentElement.dataset.theme)) ===
"day",
"theme keyboard selection",
);
await page.keyboard.press("Escape");
await page.getByRole("dialog").waitFor({ state: "hidden" });
check(
await page
.locator('.sidebar-bottom button[title="主题"]')
.evaluate((e) => e === document.activeElement),
"theme dialog restores focus",
);
await page.locator('button[title="展开导航"]').click();
check(
(await page.locator(".refract-shell.expanded").count()) === 1,
"existing navigation expansion retained",
);
await page.locator('button[title="收起导航"]').click();
await page.goto(base + "/tasks");
await page.locator('.sidebar-bottom button[title="主题"]').click();
await page.getByRole("dialog").waitFor();
await page.getByRole("dialog").getByRole("switch").click();
await page.getByRole("dialog").locator(".choice").nth(3).click();
await page.waitForFunction(
() => document.documentElement.dataset.theme === "bluehour",
);
await page.waitForTimeout(50);
result.themeTransition = await page.evaluate(() => ({
theme: document.documentElement.dataset.theme,
animations: document.getAnimations().map((a) => ({
name: a.animationName,
duration: a.effect?.getTiming().duration,
})),
reduced: document.documentElement.dataset.reduce,
}));
check(
result.themeTransition.theme === "bluehour",
"full-motion theme switch applies",
);
await page.waitForTimeout(650);
check(
(await page.locator(".queue-ring").count()) === 1,
"real running jobs restore original queue ring in full motion",
);
await page.getByRole("dialog").getByRole("switch").click();
check(
(await page.locator(".queue-ring").count()) === 0,
"reduced motion removes queue ring",
);
await page.keyboard.press("Escape");
check(result.errors.length === 0, "no browser page errors");
const recordings = [await page.video().path(), await ref.video().path()];
await context.close();
result.recordings = recordings.map((p) => path.relative(out, p));
} finally {
await fs.writeFile(
path.join(
out,
process.env.HEL767_SKIP_VISUAL
? "interaction-results.json"
: "results.json",
),
JSON.stringify(result, null, 2),
);
await browser.close();
await new Promise((r) => server.close(r));
}
+127
View File
@@ -0,0 +1,127 @@
"""Foreground isolated evidence run. No user data or external generation services."""
import json
import os
from pathlib import Path
import sqlite3
import subprocess
import tempfile
import imageio_ffmpeg
from PIL import Image, ImageDraw
ROOT = Path(__file__).resolve().parents[1]
OUT = ROOT.parent / "hel767-evidence"
OUT.mkdir(exist_ok=True)
# This database exists only for the duration of this foreground verification.
with tempfile.TemporaryDirectory(prefix="hel767-isolated-", dir=OUT) as data:
db = sqlite3.connect(Path(data) / "fixture.sqlite")
db.execute("create table fixture_jobs (id integer primary key, purpose text, body text)")
names = [
"镜头 07 · 首尾帧", "分镜草图 8/12", "Qwen 局部重绘 #62",
"参考生视频 #21", "Krea2 文生图 #55", "镜头 06 · 首尾帧",
]
workflow_names = [
"H3 首尾帧生视频", "Krea2 · 4 宫格故事板", "Qwen 2.1 · 局部重绘",
"H3 参考生视频", "Krea2 · 文生图", "H3 首尾帧生视频",
]
workflow_rows = []
for i in range(6):
kind = "video" if i in (0, 3, 5) else "image"
job = {
"id": i + 1,
"project_id": 1 if i in (0, 1, 5) else 0,
"scene_id": 1 if i in (0, 1, 5) else None,
"clip_id": None,
"kind": kind,
"workflow_id": i + 1,
"status": ["running", "running", "pending", "failed", "done", "done"][i],
"payload": {
"title": names[i],
"source": "workbench" if i in (0, 1, 5) else "studio-" + kind,
"endpoint": {"id": "hel767-fake", "name": "5090D"},
},
"output_paths": (
["HEL767-fixture-4.png" if i == 4 else "HEL767-fixture-video.mp4"]
if i > 3 else []
),
"error": "OOM" if i == 3 else None,
"created_at": f"2026-10-21T13:0{i}:00Z",
"started_at": None,
"completed_at": None,
"retry_count": 0,
"phase": "comfy_running" if i < 2 else None,
}
db.execute(
"insert into fixture_jobs values (?,?,?)",
(i + 1, "HEL-751 isolated sample correspondence; never production",
json.dumps(job, ensure_ascii=False)),
)
workflow_rows.append({
"id": i + 1, "name": workflow_names[i], "kind": kind,
"media_io": {"in": [], "out": [kind]}, "tags": [],
"favorite": False, "category": None, "model_family": None,
"usage": "text2video" if kind == "video" else "text2image",
"is_enabled": True, "needs_mapping": False,
"input_schema": [{
"nodeId": "1", "label": "HEL767 fixture prompt",
"role": "prompt", "kind": "textarea", "defaultValue": "", "required": True,
}],
"output_schema": [{"nodeId": "2", "label": "Output", "kind": kind}],
"endpoint_id": "hel767-fake",
})
db.commit()
jobs = [json.loads(row[0]) for row in db.execute("select body from fixture_jobs order by id")]
db.close()
(Path(data) / "fixtures.json").write_text(
json.dumps({"jobs": jobs, "workflows": workflow_rows}, ensure_ascii=False),
encoding="utf-8",
)
subprocess.run(
[
imageio_ffmpeg.get_ffmpeg_exe(), "-y", "-f", "lavfi", "-i",
"color=c=navy:s=320x180:r=24", "-f", "lavfi", "-i",
"sine=frequency=440:sample_rate=44100", "-t", "2", "-c:v",
"libx264", "-pix_fmt", "yuv420p", "-c:a", "aac", str(Path(data) / "video.mp4"),
],
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, check=True,
)
env = os.environ | {"HEL767_OUT": str(OUT), "HEL767_DATA": data}
subprocess.run(["node", "scripts/verify-hel767.mjs"], cwd=ROOT, env=env, check=True)
print("Isolated database and media removed; all run-owned servers closed.")
# Compose unchanged captures into review artifacts; never alter the original HTML.
if not os.environ.get("HEL767_SKIP_VISUAL"):
evidence = json.loads((OUT / "results.json").read_text())
lines = [
"| Theme / viewport | Element | Sample x,y,w,h | Actual x,y,w,h | Delta x,y,w,h |",
"|---|---|---|---|---|",
]
for measurement in evidence["measurements"]:
name = f"{measurement['route']}-{measurement['theme']}-{measurement['width']}"
sample = Image.open(OUT / f"{name}-sample.png").convert("RGB")
actual = Image.open(OUT / f"{name}-actual.png").convert("RGB")
pair = Image.new("RGB", (sample.width * 2, sample.height + 28), "#151515")
ImageDraw.Draw(pair).text((12, 8), "Confirmed sample (left) / Isolated implementation (right)", fill="white")
pair.paste(sample, (0, 28))
pair.paste(actual, (sample.width, 28))
pair.save(OUT / f"{name}-pair.png")
for key, expected in measurement["sample"].items():
observed = measurement["actual"][key]
if not expected or not observed:
continue
coordinates = ("x", "y", "width", "height")
values = lambda row: ", ".join(f"{row[c]:.3f}" for c in coordinates)
delta = {c: observed[c] - expected[c] for c in coordinates}
lines.append(
f"| {measurement['route']} {measurement['theme']} {measurement['width']}×{measurement['height']} "
f"| {key} | {values(expected)} | {values(observed)} | {values(delta)} |"
)
if key in ("themeName", "themeTone"):
lines.append(f"| typography | {key} | {expected['font']} / {expected['line']} | {observed['font']} / {observed['line']} | font-size / line-height |")
(OUT / "dimensions.md").write_text("\n".join(lines) + "\n", encoding="utf-8")
import hashlib
files = sorted(OUT.glob("*.png"))
manifest = [{"file": f.name, "bytes": f.stat().st_size, "sha256": hashlib.sha256(f.read_bytes()).hexdigest()} for f in files]
(OUT / "manifest.json").write_text(json.dumps(manifest, indent=2), encoding="utf-8")
+996
View File
@@ -0,0 +1,996 @@
/** Run via verify-hel769.py; explicit API fake, local-only origins, no user data. */
import fs from "node:fs/promises";
import http from "node:http";
import path from "node:path";
import { pathToFileURL } from "node:url";
const { chromium } = await import(
process.env.PLAYWRIGHT_MODULE || "playwright"
);
const out = process.env.HEL769_OUT,
data = process.env.HEL769_DATA;
const fixture = JSON.parse(
await fs.readFile(path.join(data, "fixtures.json"), "utf8"),
);
let jobs = structuredClone(fixture.jobs),
apiError = false,
imageFixture;
const result = {
checks: [],
measurements: [],
errors: [],
mutations: [],
externalBlocked: [],
apiReads: [],
};
const check = (v, n) => {
result.checks.push({ name: n, pass: !!v });
if (!v) throw Error(n);
};
const server = http.createServer(async (req, res) => {
try {
const p = new URL(req.url, "http://local").pathname;
let f = path.join("calliope-web/build", decodeURIComponent(p));
if (!path.extname(f)) f = path.join("calliope-web/build", "index.html");
const bytes = await fs.readFile(f);
res.setHeader(
"Content-Type",
f.endsWith(".js")
? "text/javascript"
: f.endsWith(".css")
? "text/css"
: f.endsWith(".html")
? "text/html"
: "application/octet-stream",
);
res.end(bytes);
} catch {
res.writeHead(404);
res.end();
}
});
await new Promise((r) => server.listen(0, "127.0.0.1", r));
const base = `http://127.0.0.1:${server.address().port}`;
const browser = await chromium.launch({
headless: true,
args: ["--no-sandbox"],
});
try {
const context = await browser.newContext({
viewport: { width: 1440, height: 900 },
timezoneId: "Asia/Shanghai",
recordVideo: {
dir: path.join(out, "recordings"),
size: { width: 1440, height: 900 },
},
});
await context.addInitScript(() => {
localStorage.setItem("aigc-lang", "zh");
localStorage.setItem("aigc-reduce-motion", "1");
window.__sse = [];
window.EventSource = class {
constructor() {
window.__sse.push(this);
setTimeout(() => this.onopen?.(), 0);
}
addEventListener(t, f) {
(this.handlers ??= {})[t] = f;
}
close() {}
};
});
await context.route("**/*", async (route) => {
const req = route.request(),
u = new URL(req.url());
if (["data:", "blob:", "file:"].includes(u.protocol))
return route.continue();
if (u.origin !== base) {
result.externalBlocked.push(u.origin);
return route.abort();
}
if (!u.pathname.startsWith("/api/")) return route.continue();
const json = (body) => route.fulfill({ json: body });
if (u.pathname === "/api/file") {
if (u.searchParams.get("path")?.endsWith(".mp4")) {
const bytes = await fs.readFile(path.join(data, "video.mp4"));
const range = req.headers()["range"]?.match(/bytes=(\d+)-(\d*)/);
if (range) {
const start = +range[1],
end = range[2]
? Math.min(+range[2], bytes.length - 1)
: bytes.length - 1;
return route.fulfill({
status: 206,
body: bytes.subarray(start, end + 1),
contentType: "video/mp4",
headers: {
"Accept-Ranges": "bytes",
"Content-Range": `bytes ${start}-${end}/${bytes.length}`,
"Content-Length": String(end - start + 1),
},
});
}
return route.fulfill({
body: bytes,
contentType: "video/mp4",
headers: {
"Accept-Ranges": "bytes",
"Content-Length": String(bytes.length),
},
});
}
return route.fulfill({
body:
imageFixture ||
Buffer.from(
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+jR1kAAAAASUVORK5CYII=",
"base64",
),
contentType: "image/png",
});
}
const session = {
id: 769,
title: "HEL769 isolated session",
project_id: null,
origin: u.searchParams.get("origin") === "scene" ? "scene" : "chat",
messages: [],
plan: null,
running: false,
};
const graph = {
canvas: {
id: 769,
title: "HEL769 isolated canvas",
project_id: null,
agent_session_id: 769,
viewport_json: null,
},
nodes: [],
edges: [],
};
if (u.pathname === "/api/agent/sessions" && req.method() === "GET")
return json([session]);
if (u.pathname === "/api/agent/sessions/769") return json(session);
if (u.pathname === "/api/canvas" && req.method() === "POST") {
result.mutations.push({
path: u.pathname,
method: "POST",
purpose: "isolated canvas ensure only",
});
return json(graph);
}
if (u.pathname === "/api/canvas/769") return json(graph);
if (u.pathname === "/api/shots/by-session/769")
return json({
id: 769,
title: "HEL769 isolated composition",
scene: { objects: [], cameraTrack: [] },
});
if (u.pathname === "/api/shots/769/captures") return json([]);
if (u.pathname === "/api/shots") return json([]);
if (
u.pathname === "/api/agent/projects" ||
u.pathname === "/api/agent/skills"
)
return json([]);
if (u.pathname === "/api/agent/sessions" && req.method() === "POST")
return json(session);
if (req.method() !== "GET") {
result.mutations.push({ path: u.pathname, method: req.method() });
const m = u.pathname.match(/^\/api\/jobs\/(\d+)\/(cancel|retry)$/);
if (m) {
const j = jobs.find((j) => j.id === +m[1]);
j.status = m[2] === "cancel" ? "failed" : "pending";
j.error = m[2] === "cancel" ? "cancelled" : null;
return json(j);
}
const d = u.pathname.match(/^\/api\/playground\/jobs\/(\d+)$/);
if (d && req.method() === "DELETE") {
jobs = jobs.filter((j) => j.id !== +d[1]);
return json({ ok: true });
}
if (u.pathname.includes("test")) return json({ ok: true });
result.errors.push(`Unexpected mutation ${req.method()} ${u.pathname}`);
return route.fulfill({
status: 503,
body: "Unlisted fixture mutation blocked",
});
}
result.apiReads.push(u.pathname);
if (u.pathname === "/api/jobs") {
if (apiError)
return route.fulfill({ status: 500, body: "HEL769 isolated error" });
return json(jobs);
}
if (u.pathname === "/api/workflows") return json(fixture.workflows);
if (u.pathname === "/api/projects")
return json([{ id: 1, title: "雾港来信" }]);
if (u.pathname === "/api/settings")
return json({
comfyui_endpoints: [],
llm_profiles: [],
queue_concurrency: 2,
queue_poll_timeout_sec: 1800,
queue_poll_interval_sec: 1,
queue_max_retries: 1,
data_dir: "HEL769-isolated-fixture",
dry_run: false,
});
if (u.pathname === "/api/comfyui/endpoints")
return json({ endpoints: [], active_id: null });
if (u.pathname.includes("/jobs")) return json([]);
if (u.pathname.includes("/sessions")) return json([]);
if (
u.pathname === "/api/library/media" ||
u.pathname === "/api/agent/agents" ||
u.pathname === "/api/agent/memories"
)
return json([]);
const wf = u.pathname.match(/^\/api\/workflows\/(\d+)$/);
if (wf)
return json({
...fixture.workflows.find((w) => w.id === +wf[1]),
workflow_json: {},
output_map: [
{
nodeId: "2",
kind: fixture.workflows.find((w) => w.id === +wf[1]).kind,
},
],
});
return json({});
});
const page = await context.newPage();
page.on("pageerror", (e) => result.errors.push(e.message));
const ref = await context.newPage();
await ref.goto(
pathToFileURL(path.resolve("../materials/夜片场-多主题交互样本.html"))
.href,
);
await ref.evaluate(() => {
go("tasks");
document.documentElement.classList.add("capture", "reduce");
});
// Reuse the corresponding original synthetic still only inside this API fake.
// Canvas avoids capturing a parent page during its opacity entrance animation.
imageFixture = Buffer.from(
await ref.evaluate(async () => {
const img = new Image();
img.src = still(64, 96, 64);
await img.decode();
const canvas = document.createElement("canvas");
canvas.width = 48;
canvas.height = 32;
canvas.getContext("2d").drawImage(img, 0, 0, 48, 32);
return canvas.toDataURL("image/png").split(",")[1];
}),
"base64",
);
const progress = () =>
page.evaluate(() => {
for (const es of window.__sse)
for (const [id, pct] of [
[1, 62],
[2, 35],
])
es.handlers?.["job.progress"]?.({
data: JSON.stringify({
type: "job.progress",
data: { job_id: id, progress: pct, phase: "comfy_running" },
}),
});
});
const measure = (p, selectors) =>
p.evaluate(
(selectors) =>
Object.fromEntries(
Object.entries(selectors).map(([k, s]) => {
const e = document.querySelector(s);
if (!e) return [k, null];
const r = e.getBoundingClientRect(),
c = getComputedStyle(e);
return [
k,
{
x: r.x,
y: r.y,
width: r.width,
height: r.height,
font: c.fontSize,
line: c.lineHeight,
weight: c.fontWeight,
border: c.border,
borderRight: c.borderRight,
backgroundImage: c.backgroundImage,
opacity: c.opacity,
shadow: c.boxShadow,
svg: e.matches("svg") ? e.innerHTML : undefined,
radius: c.borderRadius,
color: c.color,
background: c.backgroundColor,
},
];
}),
),
selectors,
);
for (const [width, height] of process.env.HEL769_SKIP_VISUAL
? []
: [
[1440, 900],
[1366, 768],
[1280, 720],
])
for (const theme of ["night", "day", "grade", "bluehour"])
for (const [route, sample] of [
["workflows", "workflows"],
["connections", "models"],
["tasks", "tasks"],
["library", "library"],
["settings", "settings"],
]) {
await page.setViewportSize({ width, height });
await ref.setViewportSize({ width, height });
await page.goto(base + "/" + route);
await page.locator(".management-header").waitFor();
if (route === "tasks") {
await page.locator(".task-table tbody tr").first().waitFor();
await progress();
}
if (route === "settings")
await page.locator(".choices.tiles").waitFor();
await page.evaluate((theme) => {
localStorage.setItem("aigc-theme", theme);
window.dispatchEvent(
new StorageEvent("storage", { key: "aigc-theme", newValue: theme }),
);
}, theme);
await ref.evaluate(
({ sample, theme }) => {
go(sample);
setTheme(theme);
},
{ sample, theme },
);
await page.waitForTimeout(650);
const name = `${route}-${theme}-${width}`;
const actualSelectors = {
header: ".management-header",
brand: ".refract-shell > .sidebar .brand",
divider: ".refract-shell > .sidebar hr",
foot: ".sidebar-bottom",
queue: ".queue-dot",
theme: ".sidebar-bottom button",
settings: '.sidebar-bottom a[href="/settings"]',
themeCard: ".tiles .choice",
themePreview: ".tiles .preview",
themeName: ".tiles .copy strong",
themeTone: ".tiles .copy small",
};
const sampleSelectors = {
header: `#p-${sample} .ph`,
brand: ".rail .brand",
divider: ".rail hr",
foot: ".rail .foot",
queue: ".rail .queue-dot",
theme: "#themeBtn",
settings: '.rail [data-go="settings"]',
themeCard: ".ttile",
themePreview: ".ttile .tprev",
themeName: ".ttile b",
themeTone: ".ttile small",
};
for (let i = 0; i < 9; i++) {
actualSelectors[`nav${i}`] =
`.refract-shell > .sidebar nav a:nth-child(${i < 6 ? i + 1 : i - 5})`;
if (i >= 6)
actualSelectors[`nav${i}`] =
`.refract-shell > .sidebar .management a:nth-child(${i - 5})`;
sampleSelectors[`nav${i}`] = `.rail > .nav:nth-of-type(${i + 1})`;
}
if (route === "settings")
for (let i = 1; i <= 4; i++)
for (const [key, a, b] of [
["Card", ".choice", ".ttile"],
["Preview", ".preview", ".tprev"],
["Name", ".copy strong", "b"],
["Tone", ".copy small", "small"],
]) {
actualSelectors[`theme${key}${i}`] =
key === "Card"
? `.tiles .choice:nth-child(${i})`
: `.tiles .choice:nth-child(${i}) ${a}`;
sampleSelectors[`theme${key}${i}`] =
key === "Card"
? `.ttile:nth-child(${i})`
: `.ttile:nth-child(${i}) ${b}`;
}
if (route === "settings") {
for (let i = 1; i <= 4; i++) {
for (const [key, a, b] of [
["Rail", ".rail", ".r"], ["List", ".list", ".l"],
["Stage", ".stage", ".m"], ["Art", ".stage > i", ".art"],
["Dock", ".stage > b", ".dk"], ["Badge", ".stage em", ".dk > span"],
["Text", ".preview-text", ".tx"],
...[1,2,3,4].map(n => [`Line${n}`, `.list i:nth-child(${n})`, `.l i:nth-child(${n})`]),
]) {
actualSelectors[`internal${key}${i}`] = `.tiles .choice:nth-child(${i}) .preview ${a}`;
sampleSelectors[`internal${key}${i}`] = `.ttile:nth-child(${i}) .tprev ${b}`;
}
}
}
if (route !== "settings")
for (const key of [
"themeCard",
"themePreview",
"themeName",
"themeTone",
]) {
delete actualSelectors[key];
delete sampleSelectors[key];
}
if (route === "tasks") {
Object.assign(actualSelectors, {
table: ".task-table",
head: ".task-table thead",
row: ".task-table tbody tr",
thumbnail: ".task-thumbnail",
segment: ".task-tabs",
search: ".task-search",
progress: ".task-progress",
});
Object.assign(sampleSelectors, {
table: "#p-tasks .tbl",
head: "#p-tasks thead",
row: "#taskTable tr",
thumbnail: "#taskTable td>div",
segment: "#p-tasks .seg",
search: "#p-tasks .input",
progress: "#taskTable .bar",
});
}
const railState = (p, actual) =>
p.evaluate((actual) => {
const selectors = actual
? [
".refract-shell > .sidebar nav a",
'.sidebar-bottom a[href="/settings"]',
".sidebar-bottom button:last-of-type",
]
: [
".rail > .nav",
'.rail [data-go="settings"]',
'.rail [data-go="spec"]',
];
const nodes = [
...document.querySelectorAll(selectors[0]),
...selectors.slice(1).map((x) => document.querySelector(x)),
];
const geometry = (svg) => {
const use = svg.querySelector("use");
const root = use
? document.querySelector(use.getAttribute("href"))
: svg;
return [...root.children].map((n) => [
n.tagName,
[...n.attributes].map((a) => [a.name, a.value]).sort(),
]);
};
const selected = nodes.find((n) =>
actual
? n.getAttribute("aria-current") === "page"
: n.classList.contains("on"),
);
const pseudo = selected
? getComputedStyle(selected, "::before")
: null;
return {
icons: nodes
.filter((n) => n?.querySelector("svg"))
.map((n) => {
const svg = n.querySelector("svg"),
c = getComputedStyle(svg);
return {
geometry: geometry(svg),
width: c.width,
height: c.height,
stroke: c.strokeWidth,
color: c.color,
};
}),
selected: pseudo
? Object.fromEntries(
[
"left",
"top",
"bottom",
"width",
"borderRadius",
"backgroundColor",
"boxShadow",
].map((k) => [k, pseudo[k]]),
)
: null,
};
}, actual);
const railActual = await railState(page, true),
railSample = await railState(ref, false);
result.railProbe = { railActual, railSample };
check(
JSON.stringify(railActual) === JSON.stringify(railSample),
`${name}: rail icon paths, sizes, strokes, colors and selected indicator match`,
);
const measured = {
route,
theme,
width,
height,
railActual,
railSample,
actual: await measure(page, actualSelectors),
sample: await measure(ref, sampleSelectors),
};
result.measurements.push(measured);
await page.screenshot({ path: path.join(out, name + "-actual.png") });
await ref.screenshot({ path: path.join(out, name + "-sample.png") });
for (const key of Object.keys(sampleSelectors)) {
if (key === "progress") continue; // auto columns depend on honest source text; reported separately
for (const axis of ["x", "y", "width", "height"]) {
// Table column width distribution is data-dependent, the outer table and thumbnail dimensions are fixed.
if (key === "thumbnail" && axis === "x") continue;
check(
measured.actual[key] &&
measured.sample[key] &&
Math.abs(
measured.actual[key][axis] - measured.sample[key][axis],
) < 0.1,
`${name}: ${key} ${axis} matches sample`,
);
}
}
if (route === "settings") {
for (const key of Object.keys(sampleSelectors).filter(k => k.startsWith("internal") || k.startsWith("themePreview"))) {
for (const prop of ["radius", "shadow", "background", "backgroundImage", "opacity", "borderRight"]) {
check(measured.actual[key][prop] === measured.sample[key][prop], `${name}: ${key} ${prop} matches`);
}
}
for (let card = 1; card <= 4; card++) {
await page.locator(`.tiles .choice:nth-child(${card})`).hover();
await ref.locator(`.ttile:nth-child(${card})`).hover();
await page.locator(`.tiles .choice:nth-child(${card}) .preview`).screenshot({path:path.join(out, `${name}-card${card}-hover-actual.png`)});
await ref.locator(`.ttile:nth-child(${card}) .tprev`).screenshot({path:path.join(out, `${name}-card${card}-hover-sample.png`)});
}
await page.mouse.move(1200, 600);
await ref.mouse.move(1200, 600);
await page.locator(".choices.tiles").screenshot({path:path.join(out, name + "-internal-actual.png")});
await ref.locator("#themeTiles").screenshot({path:path.join(out, name + "-internal-sample.png")});
}
for (const key of [
"brand",
"queue",
"theme",
"settings",
...Array.from({ length: 9 }, (_, i) => `nav${i}`),
])
check(
measured.actual[key].radius === measured.sample[key].radius,
`${name}: ${key} radius matches sample`,
);
if (route === "settings")
for (const key of [
"themeName",
"themeTone",
...Array.from({ length: 4 }, (_, i) => `themeName${i + 1}`),
...Array.from({ length: 4 }, (_, i) => `themeTone${i + 1}`),
])
check(
measured.actual[key].line === measured.sample[key].line,
`${name}: ${key} line height`,
);
check(
await page.evaluate(
() => document.documentElement.scrollWidth <= innerWidth,
),
`${name}: no overflow`,
);
}
await page.goto(base + "/tasks");
await page.locator(".task-table tbody tr").first().waitFor();
await page.setViewportSize({ width: 1440, height: 900 });
if (process.env.HEL769_SKIP_VISUAL) {
await page.goto(base + "/tasks");
await page.locator("tbody tr").first().waitFor();
}
await page.locator(".task-tabs button").nth(1).click();
check((await page.locator("tbody tr").count()) === 3, "active filter");
await page.locator(".task-tabs button").nth(2).click();
check((await page.locator("tbody tr").count()) === 1, "failed filter");
await page.locator(".task-tabs button").nth(3).click();
check((await page.locator("tbody tr").count()) === 3, "image filter");
await page.locator(".task-tabs button").nth(4).click();
check((await page.locator("tbody tr").count()) === 3, "video filter");
await page.locator(".task-tabs button").first().click();
await page.getByRole("searchbox").fill("镜头 07");
await page.waitForTimeout(100);
check((await page.locator("tbody tr").count()) === 1, "search by task title");
await page.getByRole("searchbox").fill("HEL769 absent");
await page.waitForTimeout(100);
check((await page.locator("tbody tr").count()) === 0, "search empty");
await page.screenshot({ path: path.join(out, "search-empty.png") });
await page.getByRole("searchbox").fill("");
await page.getByRole("button", { name: "查看任务 #6", exact: true }).click();
await page.getByRole("dialog").waitFor();
const video = page.locator("video");
await video.evaluate(
(v) =>
new Promise((r) =>
v.readyState >= 1
? r()
: v.addEventListener("loadedmetadata", r, { once: true }),
),
);
check(
await video.evaluate(
(v) =>
v.paused && v.controls && !v.autoplay && !v.muted && v.volume === 1,
),
"video manual playback with sound",
);
await video.hover();
const videoBox = await video.boundingBox();
await video.click({ position: { x: 26, y: videoBox.height - 28 } });
await page.waitForTimeout(250);
check(
await video.evaluate((v) => !v.paused && v.currentTime > 0),
"explicit video play",
);
await video.evaluate((v) => {
v.pause();
v.currentTime = 0.7;
v.volume = 0.4;
});
await page.waitForTimeout(300);
result.videoState = await video.evaluate((v) => ({
paused: v.paused,
time: v.currentTime,
volume: v.volume,
duration: v.duration,
seekable: v.seekable.length,
error: v.error?.message,
}));
check(
result.videoState.paused &&
result.videoState.time > 0.6 &&
Math.abs(result.videoState.volume - 0.4) < 0.001,
"pause seek volume",
);
await page.screenshot({ path: path.join(out, "video-preview.png") });
await page.keyboard.press("Tab");
check(
await page
.getByRole("dialog")
.evaluate((e) => e.contains(document.activeElement)),
"modal focus stays inside",
);
await page.getByRole("dialog").locator(".modal-close").focus();
await page.keyboard.press("Escape");
await page.getByRole("dialog").waitFor({ state: "hidden" });
check((await page.locator("video").count()) === 0, "close unmounts video");
check(
await page
.getByRole("button", { name: "查看任务 #6", exact: true })
.evaluate((e) => e === document.activeElement),
"modal restores trigger focus",
);
await page.getByRole("button", { name: "查看任务 #5", exact: true }).click();
await page.getByRole("dialog").waitFor();
check(
(await page.getByRole("dialog").locator("img").count()) === 1,
"image preview",
);
const [download] = await Promise.all([
page.waitForEvent("download"),
page.getByRole("link", { name: "下载", exact: true }).click(),
]);
check(!(await download.failure()), "native image download");
await download.saveAs(path.join(out, "downloaded-fixture.png"));
await page.getByRole("button", { name: "删除", exact: true }).click();
check(
(await page.locator("tbody tr").count()) === 5,
"delete removes row immediately",
);
await page.getByRole("button", { name: "撤销", exact: true }).click();
check((await page.locator("tbody tr").count()) === 6, "undo restores row");
check(
!result.mutations.some((m) => m.method === "DELETE"),
"undo prevents DELETE",
);
await page.getByRole("button", { name: "查看任务 #5", exact: true }).click();
await page.getByRole("button", { name: "删除", exact: true }).click();
await page.waitForTimeout(5300);
check(
result.mutations.some((m) => m.method === "DELETE"),
"delete commits after undo window",
);
await page
.locator("tbody tr")
.filter({ hasText: "参考生视频 #21" })
.getByRole("button", { name: "重试", exact: true })
.click();
await page.waitForTimeout(150);
check(
jobs.find((j) => j.id === 4).status === "pending",
"retry uses real API contract",
);
await page
.locator("tbody tr")
.filter({ hasText: "Qwen 局部重绘" })
.getByRole("button", { name: "取消", exact: true })
.click();
await page.waitForTimeout(150);
check(
jobs.find((j) => j.id === 3).error === "cancelled",
"cancel uses real API contract",
);
await page.getByRole("button", { name: "查看任务 #1", exact: true }).click();
await page.getByRole("button", { name: "创作台", exact: true }).click();
check(
(await page.locator("tbody tr").count()) === 2,
"source filter retained in details",
);
await page.getByRole("button", { name: "全部来源", exact: true }).click();
const testJob = {
...structuredClone(fixture.jobs[4]),
id: 8,
origin: "workflow_test",
payload: { source: "workflow_test", title: "HEL769 workflow test" },
output_paths: ["HEL769-fixture-4.png"],
};
jobs.push(testJob);
await page.goto(base + "/tasks");
await page.getByRole("button", { name: "查看任务 #8", exact: true }).click();
await page.getByRole("button", { name: "工作流试跑", exact: true }).click();
check(
(await page.locator("tbody tr").count()) === 1,
"workflow test source isolated",
);
await page.getByRole("button", { name: "全部来源", exact: true }).click();
// Pending/running without a numeric report must remain unknown.
await page.goto(base + "/tasks");
await page.locator("tbody tr").first().waitFor();
check(
(await page.locator(".task-progress").count()) === 0,
"no fabricated percentages after reload",
);
jobs = [];
await page.goto(base + "/tasks");
await page.waitForTimeout(400);
check((await page.locator("tbody tr").count()) === 0, "true empty data");
await page.screenshot({ path: path.join(out, "true-empty.png") });
apiError = true;
await page.goto(base + "/tasks");
await page
.getByRole("alert")
.filter({ hasText: "任务加载失败" })
.waitFor({ timeout: 15000 });
check(true, "API failure shown honestly");
apiError = false;
jobs = structuredClone(fixture.jobs);
await page.getByRole("button", { name: "重试", exact: true }).click();
await page.locator("tbody tr").first().waitFor();
check(true, "load retry restores tasks");
for (const route of [
"workflows",
"connections",
"library",
"settings",
"create?kind=image",
"create?kind=video",
]) {
await page.goto(base + "/" + route);
await page.waitForTimeout(400);
await page.screenshot({
path: path.join(out, "regression-" + route.replace(/\W/g, "-") + ".png"),
});
check(
(await page.locator(".task-table").count()) === 0,
`${route}: task table styles scoped`,
);
if (route.startsWith("create?")) {
const textarea = page.locator(".studio-form textarea").first();
await textarea.fill("HEL769 isolated creative draft");
check(
(await textarea.inputValue()) === "HEL769 isolated creative draft",
`${route}: prompt remains editable`,
);
await page.reload();
await page.locator(".studio-form textarea").first().waitFor();
check(
(await page.locator(".studio-form textarea").first().inputValue()) ===
"HEL769 isolated creative draft",
`${route}: draft survives reload`,
);
}
if (route === "workflows")
check(
(await page
.locator(".mg-side")
.evaluate((e) => e.getBoundingClientRect().width)) === 220,
"workflow sidebar retains 220px",
);
}
for (const route of [
"/",
"/workbench",
"/agents",
"/canvas",
"/build-scene",
"/models",
"/model-connections",
"/image",
"/video",
]) {
await page.goto(base + route);
await page.locator(".refract-shell > .sidebar").waitFor({ timeout: 15000 });
await page.waitForTimeout(350);
check(
(await page.locator(".refract-shell > .sidebar").count()) === 1,
`${route}: shared rail reachable`,
);
if (route === "/models")
check(
page.url().includes("/connections?tab=llm"),
"legacy models redirect",
);
if (route === "/model-connections")
check(page.url().includes("/connections"), "connections alias redirect");
}
await page.goto(base + "/create?kind=image");
await page.locator(".studio-form textarea").first().waitFor();
check(
(await page.locator(".studio-form input[type=file]").count()) === 0,
"t2i without image input keeps no padding slot",
);
await page.keyboard.press("Control+k");
await page.getByRole("dialog").waitFor();
check(true, "Ctrl+K opens global search");
await page.keyboard.press("Escape");
await page.getByRole("dialog").waitFor({ state: "hidden" });
await page.goto(base + "/tasks");
await page.locator(".task-table tbody tr").first().waitFor();
await ref.setViewportSize({ width: 1440, height: 900 });
for (const theme of ["night", "day", "grade", "bluehour"]) {
await page.evaluate((theme) => {
localStorage.setItem("aigc-theme", theme);
window.dispatchEvent(
new StorageEvent("storage", { key: "aigc-theme", newValue: theme }),
);
}, theme);
await ref.evaluate((theme) => {
go("tasks");
setTheme(theme);
}, theme);
await page.waitForTimeout(650);
await page
.locator('.refract-shell > .sidebar a[href="/workflows"]')
.hover();
await ref.locator('.rail [data-go="workflows"]').hover();
await page.waitForTimeout(250);
result.checks.push({
name: `${theme}: rail hover`,
pass: true,
actual: await measure(page, {
hover: '.refract-shell > .sidebar a[href="/workflows"]',
}),
sample: await measure(ref, { hover: '.rail [data-go="workflows"]' }),
});
const hover = result.checks.at(-1);
for (const prop of ["color", "background", "radius"])
check(
hover.actual.hover[prop] === hover.sample.hover[prop],
`${theme}: hover ${prop} matches sample`,
);
await page.screenshot({
path: path.join(out, `hover-${theme}-actual.png`),
});
await ref.screenshot({ path: path.join(out, `hover-${theme}-sample.png`) });
}
await page.locator('.sidebar-bottom button[title="主题"]').click();
await page.getByRole("dialog").waitFor();
const firstChoice = page.getByRole("dialog").locator(".choice").first();
await firstChoice.focus();
await page.keyboard.press("ArrowRight");
await page.keyboard.press("Enter");
check(
(await page.evaluate(() => document.documentElement.dataset.theme)) ===
"day",
"theme keyboard selection",
);
await page.keyboard.press("Escape");
await page.getByRole("dialog").waitFor({ state: "hidden" });
check(
await page
.locator('.sidebar-bottom button[title="主题"]')
.evaluate((e) => e === document.activeElement),
"theme dialog restores focus",
);
await page.locator('button[title="展开导航"]').click();
check(
(await page.locator(".refract-shell.expanded").count()) === 1,
"existing navigation expansion retained",
);
await page.locator('button[title="收起导航"]').click();
await page.goto(base + "/tasks");
await page.locator('.sidebar-bottom button[title="主题"]').click();
await page.getByRole("dialog").waitFor();
await page.getByRole("dialog").getByRole("switch").click();
await page.getByRole("dialog").locator(".choice").nth(3).click();
await page.waitForFunction(
() => document.documentElement.dataset.theme === "bluehour",
);
await page.waitForTimeout(50);
result.themeTransition = await page.evaluate(() => ({
theme: document.documentElement.dataset.theme,
animations: document.getAnimations().map((a) => ({
name: a.animationName,
duration: a.effect?.getTiming().duration,
})),
reduced: document.documentElement.dataset.reduce,
}));
check(
result.themeTransition.theme === "bluehour",
"full-motion theme switch applies",
);
await page.waitForTimeout(650);
check(
(await page.locator(".queue-ring").count()) === 1,
"real running jobs restore original queue ring in full motion",
);
await page.getByRole("dialog").getByRole("switch").click();
check(
(await page.locator(".queue-ring").count()) === 0,
"reduced motion removes queue ring",
);
await page.keyboard.press("Escape");
if (process.env.HEL769_SKIP_VISUAL) {
await page.goto(base + "/settings");
for (const [index, id] of ["night", "day", "grade", "bluehour"].entries()) {
const tile = page.locator(".choices.tiles .choice").nth(index);
await tile.click();
check(await tile.getAttribute("aria-pressed") === "true", `${id}: settings selection`);
check(await page.evaluate(() => localStorage.getItem("aigc-theme")) === id, `${id}: saved theme`);
await page.reload();
await page.locator(".choices.tiles").waitFor();
check(await page.locator(".choices.tiles .choice").nth(index).getAttribute("aria-pressed") === "true", `${id}: selected theme survives reload`);
}
for (const [width,height] of [[1440,900],[1366,768],[1280,720]]) {
await page.setViewportSize({width,height});
for (const theme of ["night", "day", "grade", "bluehour"]) {
await page.evaluate(theme => {localStorage.setItem("aigc-theme", theme);window.dispatchEvent(new StorageEvent("storage", {key:"aigc-theme",newValue:theme}));}, theme);
await page.goto(base + "/workflows");
await page.locator(".mg-layout").waitFor();
check(await page.locator(".mg-layout").evaluate(e => getComputedStyle(e).gridTemplateColumns.split(" ").length === 3), `${width}/${theme}: workflow three columns`);
await page.goto(base + "/tasks");
await page.locator(".task-table tbody tr").first().waitFor();
check(await page.locator(".task-table th").count() === 8, `${width}/${theme}: tasks eight columns`);
check(await page.locator(".task-table").evaluate(e => e.getBoundingClientRect().width > innerWidth - 150), `${width}/${theme}: task table uses full content width`);
}
}
}
check(result.errors.length === 0, "no browser page errors");
const recordings = [await page.video().path(), await ref.video().path()];
await context.close();
result.recordings = recordings.map((p) => path.relative(out, p));
} finally {
await fs.writeFile(
path.join(
out,
process.env.HEL769_SKIP_VISUAL
? "interaction-results.json"
: "results.json",
),
JSON.stringify(result, null, 2),
);
await browser.close();
await new Promise((r) => server.close(r));
}
+137
View File
@@ -0,0 +1,137 @@
"""Foreground isolated evidence run. No user data or external generation services."""
import json
import os
from pathlib import Path
import sqlite3
import subprocess
import tempfile
import imageio_ffmpeg
from PIL import Image, ImageDraw
ROOT = Path(__file__).resolve().parents[1]
OUT = ROOT.parent / "hel769-evidence"
OUT.mkdir(exist_ok=True)
# This database exists only for the duration of this foreground verification.
with tempfile.TemporaryDirectory(prefix="hel769-isolated-", dir=OUT) as data:
db = sqlite3.connect(Path(data) / "fixture.sqlite")
db.execute("create table fixture_jobs (id integer primary key, purpose text, body text)")
names = [
"镜头 07 · 首尾帧", "分镜草图 8/12", "Qwen 局部重绘 #62",
"参考生视频 #21", "Krea2 文生图 #55", "镜头 06 · 首尾帧",
]
workflow_names = [
"H3 首尾帧生视频", "Krea2 · 4 宫格故事板", "Qwen 2.1 · 局部重绘",
"H3 参考生视频", "Krea2 · 文生图", "H3 首尾帧生视频",
]
workflow_rows = []
for i in range(6):
kind = "video" if i in (0, 3, 5) else "image"
job = {
"id": i + 1,
"project_id": 1 if i in (0, 1, 5) else 0,
"scene_id": 1 if i in (0, 1, 5) else None,
"clip_id": None,
"kind": kind,
"workflow_id": i + 1,
"status": ["running", "running", "pending", "failed", "done", "done"][i],
"payload": {
"title": names[i],
"source": "workbench" if i in (0, 1, 5) else "studio-" + kind,
"endpoint": {"id": "hel769-fake", "name": "5090D"},
},
"output_paths": (
["HEL769-fixture-4.png" if i == 4 else "HEL769-fixture-video.mp4"]
if i > 3 else []
),
"error": "OOM" if i == 3 else None,
"created_at": f"2026-10-21T13:0{i}:00Z",
"started_at": None,
"completed_at": None,
"retry_count": 0,
"phase": "comfy_running" if i < 2 else None,
}
db.execute(
"insert into fixture_jobs values (?,?,?)",
(i + 1, "HEL-751 isolated sample correspondence; never production",
json.dumps(job, ensure_ascii=False)),
)
workflow_rows.append({
"id": i + 1, "name": workflow_names[i], "kind": kind,
"media_io": {"in": [], "out": [kind]}, "tags": [],
"favorite": False, "category": None, "model_family": None,
"usage": "text2video" if kind == "video" else "text2image",
"is_enabled": True, "needs_mapping": False,
"input_schema": [{
"nodeId": "1", "label": "HEL769 fixture prompt",
"role": "prompt", "kind": "textarea", "defaultValue": "", "required": True,
}],
"output_schema": [{"nodeId": "2", "label": "Output", "kind": kind}],
"endpoint_id": "hel769-fake",
})
db.commit()
jobs = [json.loads(row[0]) for row in db.execute("select body from fixture_jobs order by id")]
db.close()
(Path(data) / "fixtures.json").write_text(
json.dumps({"jobs": jobs, "workflows": workflow_rows}, ensure_ascii=False),
encoding="utf-8",
)
subprocess.run(
[
imageio_ffmpeg.get_ffmpeg_exe(), "-y", "-f", "lavfi", "-i",
"color=c=navy:s=320x180:r=24", "-f", "lavfi", "-i",
"sine=frequency=440:sample_rate=44100", "-t", "2", "-c:v",
"libx264", "-pix_fmt", "yuv420p", "-c:a", "aac", str(Path(data) / "video.mp4"),
],
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, check=True,
)
env = os.environ | {"HEL769_OUT": str(OUT), "HEL769_DATA": data}
subprocess.run(["node", "scripts/verify-hel769.mjs"], cwd=ROOT, env=env, check=True)
print("Isolated database and media removed; all run-owned servers closed.")
# Compose unchanged captures into review artifacts; never alter the original HTML.
if not os.environ.get("HEL769_SKIP_VISUAL"):
evidence = json.loads((OUT / "results.json").read_text())
lines = [
"| Theme / viewport | Element | Sample x,y,w,h | Actual x,y,w,h | Delta x,y,w,h |",
"|---|---|---|---|---|",
]
for measurement in evidence["measurements"]:
name = f"{measurement['route']}-{measurement['theme']}-{measurement['width']}"
sample = Image.open(OUT / f"{name}-sample.png").convert("RGB")
actual = Image.open(OUT / f"{name}-actual.png").convert("RGB")
pair = Image.new("RGB", (sample.width * 2, sample.height + 28), "#151515")
ImageDraw.Draw(pair).text((12, 8), "Confirmed sample (left) / Isolated implementation (right)", fill="white")
pair.paste(sample, (0, 28))
pair.paste(actual, (sample.width, 28))
pair.save(OUT / f"{name}-pair.png")
for key, expected in measurement["sample"].items():
observed = measurement["actual"][key]
if not expected or not observed:
continue
coordinates = ("x", "y", "width", "height")
values = lambda row: ", ".join(f"{row[c]:.3f}" for c in coordinates)
delta = {c: observed[c] - expected[c] for c in coordinates}
lines.append(
f"| {measurement['route']} {measurement['theme']} {measurement['width']}×{measurement['height']} "
f"| {key} | {values(expected)} | {values(observed)} | {values(delta)} |"
)
if key in ("themeName", "themeTone"):
lines.append(f"| typography | {key} | {expected['font']} / {expected['line']} | {observed['font']} / {observed['line']} | font-size / line-height |")
(OUT / "dimensions.md").write_text("\n".join(lines) + "\n", encoding="utf-8")
import hashlib
for f in sorted(OUT.glob("*-internal-sample.png")):
actual = Image.open(str(f).replace("-sample.png", "-actual.png")).convert("RGB")
sample = Image.open(f).convert("RGB")
pair = Image.new("RGB", (sample.width, sample.height * 2 + 48), "#151515")
draw = ImageDraw.Draw(pair)
draw.text((8, 5), "Confirmed sample", fill="white")
pair.paste(sample, (0, 24))
draw.text((8, sample.height + 29), "Isolated implementation", fill="white")
pair.paste(actual, (0, sample.height + 48))
pair.resize((pair.width * 3, pair.height * 3), Image.Resampling.NEAREST).save(str(f).replace("-sample.png", "-pair-3x.png"))
files = sorted(OUT.glob("*.png"))
manifest = [{"file": f.name, "bytes": f.stat().st_size, "sha256": hashlib.sha256(f.read_bytes()).hexdigest()} for f in files]
(OUT / "manifest.json").write_text(json.dumps(manifest, indent=2), encoding="utf-8")
+239
View File
@@ -0,0 +1,239 @@
/** HEL-771 targeted verification; measurement helper adapted from HEL-770 review.
* Serves the frozen build locally, fakes the API (no backend, no user data),
* renders app settings theme tiles and the original sample side by side,
* and records geometry, computed styles and screenshots for offline comparison.
*/
import fs from "node:fs/promises";
import http from "node:http";
import path from "node:path";
const { chromium } = await import(process.env.PLAYWRIGHT_MODULE || "playwright");
const ROOT = process.cwd();
const BUILD = path.join(ROOT, "calliope-web/build");
const SAMPLE = path.join(ROOT, "../materials/夜片场-多主题交互样本.html");
const OUT = process.env.HEL771_OUT || path.join(ROOT, "../evidence/tiles");
await fs.mkdir(OUT, { recursive: true });
const THEMES = ["night", "day", "grade", "bluehour"];
const VIEWPORTS = [1280, 1366, 1440];
const apiLog = [];
const server = http.createServer(async (req, res) => {
try {
const p = decodeURIComponent(new URL(req.url, "http://local").pathname);
if (p.startsWith("/api/")) {
apiLog.push(`${req.method} ${p}`);
let body = [];
if (p === "/api/settings")
body = {
queue_concurrency: 2,
queue_poll_timeout_sec: 1800,
comfyui_base_url: "http://127.0.0.1:8188",
projects_root: "D:\\AIGC\\projects",
dry_run: false,
};
else if (
p === "/api/projects" ||
p === "/api/workflows" ||
p === "/api/library/media" ||
p === "/api/agent/sessions" ||
p === "/api/agent/agents" ||
p === "/api/canvas" ||
p.startsWith("/api/tasks") ||
p.startsWith("/api/models")
)
body = [];
else if (p === "/api/jobs/queue-status") body = { paused: false };
res.setHeader("Content-Type", "application/json");
res.end(JSON.stringify(body));
return;
}
if (p.startsWith("/sample")) {
const bytes = await fs.readFile(SAMPLE);
res.setHeader("Content-Type", "text/html; charset=utf-8");
res.end(bytes);
return;
}
let f = path.join(BUILD, p);
if (!path.extname(f)) f = path.join(BUILD, "index.html");
const bytes = await fs.readFile(f);
res.setHeader(
"Content-Type",
f.endsWith(".js")
? "text/javascript"
: f.endsWith(".css")
? "text/css"
: f.endsWith(".html")
? "text/html"
: "application/octet-stream",
);
res.end(bytes);
} catch {
res.writeHead(404);
res.end();
}
});
await new Promise((r) => (server.listing = server.listen(0, "127.0.0.1", r)));
const base = `http://127.0.0.1:${server.address().port}`;
console.log("serving at", base);
const browser = await chromium.launch({ headless: true, args: ["--no-sandbox"] });
async function freshContext(width) {
const ctx = await browser.newContext({
viewport: { width, height: width === 1440 ? 900 : width === 1366 ? 768 : 720 },
timezoneId: "Asia/Shanghai",
deviceScaleFactor: 1,
});
await ctx.addInitScript(() => {
localStorage.setItem("aigc-lang", "zh");
localStorage.setItem("aigc-reduce-motion", "1");
window.__sse = [];
window.EventSource = class {
constructor() {
window.__sse.push(this);
setTimeout(() => this.onopen?.(), 0);
}
addEventListener(t, f) {
(this.handlers ??= {})[t] = f;
}
close() {}
};
});
await ctx.route("**/*", async (route) => {
const u = new URL(route.request().url());
if (["data:", "blob:", "file:"].includes(u.protocol)) return route.continue();
if (u.origin !== base) return route.abort();
route.continue();
});
return ctx;
}
/** Shared DOM measurement: given a card element handle's root, measure all internals. */
const MEASURE = (cardSel) => {
const card = document.querySelector(cardSel);
if (!card) return null;
const num = (v) => parseFloat(v) || 0;
const r = (el) => {
const b = el.getBoundingClientRect();
return { x: +b.x.toFixed(3), y: +b.y.toFixed(3), w: +b.width.toFixed(3), h: +b.height.toFixed(3) };
};
const st = (el) => {
const s = getComputedStyle(el);
return {
rect: r(el),
background: s.backgroundColor,
backgroundImage: s.backgroundImage,
borderTopWidth: s.borderTopWidth,
borderRightWidth: s.borderRightWidth,
borderBottomWidth: s.borderBottomWidth,
borderLeftWidth: s.borderLeftWidth,
borderTopColor: s.borderTopColor,
borderRightColor: s.borderRightColor,
borderBottomColor: s.borderBottomColor,
borderLeftColor: s.borderLeftColor,
borderTopLeftRadius: s.borderTopLeftRadius,
borderTopRightRadius: s.borderTopRightRadius,
borderBottomLeftRadius: s.borderBottomLeftRadius,
borderBottomRightRadius: s.borderBottomRightRadius,
boxShadow: s.boxShadow,
paddingTop: s.paddingTop,
paddingRight: s.paddingRight,
paddingBottom: s.paddingBottom,
paddingLeft: s.paddingLeft,
opacity: s.opacity,
color: s.color,
overflow: s.overflow,
position: s.position,
};
};
const cardS = st(card);
const tag = card.querySelector(".theme-default, .tag");
const prev = card.querySelector(".preview") || card.querySelector(".tprev");
const rail = prev?.querySelector(".rail") || prev?.querySelector(".r");
const list = prev?.querySelector(".list") || prev?.querySelector(".l");
const items = list ? [...list.querySelectorAll("i")] : [];
const stage = prev?.querySelector(".stage") || prev?.querySelector(".m");
const art = stage?.querySelector("i") || stage?.querySelector(".art");
const dock = stage?.querySelector("b") || stage?.querySelector(".dk");
const badge = dock?.querySelector("em") || dock?.querySelector("span");
const textLine = prev?.querySelector(".preview-text") || prev?.querySelector(".tx");
const strong = card.querySelector(".copy strong") || card.querySelector("b");
const small = card.querySelector(".copy small") || card.querySelector("small");
const dot = rail ? getComputedStyle(rail, "::before") : null;
return {
card: cardS,
defaultTag: tag ? st(tag) : null,
cardClass: card.className,
ariaChecked: card.getAttribute("aria-checked") ?? card.getAttribute("aria-pressed"),
dataTheme: prev?.getAttribute("data-theme"),
preview: prev ? st(prev) : null,
rail: rail ? st(rail) : null,
railDot: dot
? { w: dot.width, h: dot.height, bg: dot.backgroundColor, mt: dot.marginTop, mr: dot.marginRight, ml: dot.marginLeft, mb: dot.marginBottom, radius: dot.borderRadius }
: null,
list: list ? st(list) : null,
listItemCount: items.length,
listItems: items.map((i) => st(i)),
stage: stage ? st(stage) : null,
art: art ? st(art) : null,
dock: dock ? st(dock) : null,
badge: badge ? st(badge) : null,
textLine: textLine ? st(textLine) : null,
copyStrong: strong
? { text: strong.textContent.trim().replace(/\s+/g, " "), fontSize: getComputedStyle(strong).fontSize, lineHeight: getComputedStyle(strong).lineHeight, fontWeight: getComputedStyle(strong).fontWeight }
: null,
copySmall: small
? { text: small.textContent.trim(), fontSize: getComputedStyle(small).fontSize, lineHeight: getComputedStyle(small).lineHeight, color: getComputedStyle(small).color, fontFamily: getComputedStyle(small).fontFamily.slice(0, 40) }
: null,
};
};
const results = { groups: [], errors: [], apiCalls: [] };
try {
for (const width of VIEWPORTS) {
const ctx = await freshContext(width);
const app = await ctx.newPage(), ref = await ctx.newPage();
for (const p of [app, ref]) p.on("pageerror", e => results.errors.push(e.message));
await app.goto(`${base}/settings`);
await app.locator(".choices.tiles").waitFor();
await ref.goto(`${base}/sample`);
await ref.evaluate(() => { go("settings"); document.documentElement.classList.add("capture", "reduce"); });
for (const theme of THEMES) {
await app.evaluate(t => { localStorage.setItem("aigc-theme", t); window.dispatchEvent(new StorageEvent("storage", {key:"aigc-theme", newValue:t})); }, theme);
await ref.evaluate(t => setTheme(t), theme);
await app.mouse.move(1200, 600); await ref.mouse.move(1200, 600);
await app.waitForTimeout(350);
const key = `${theme}-${width}`;
await app.screenshot({path:path.join(OUT, `${key}-full-app.png`)});
await ref.screenshot({path:path.join(OUT, `${key}-full-sample.png`)});
for (let i=0;i<4;i++) {
const a = `.choices.tiles .choice:nth-child(${i+1})`, b = `#themeTiles .ttile:nth-child(${i+1})`;
for (const state of ["normal", "selected", "hover"]) {
// Hold page theme fixed to test every card/state. Class changes are fixture-only;
// natural click/selection/persistence are exercised by verify-hel769.py separately.
await app.locator(a).evaluate((e,on)=>e.classList.toggle("selected",on), state === "selected");
await ref.locator(b).evaluate((e,on)=>e.classList.toggle("on",on), state === "selected");
await app.mouse.move(1200,600); await ref.mouse.move(1200,600);
if (state === "hover") { await app.locator(a).hover(); await ref.locator(b).hover(); }
await app.waitForTimeout(300);
const name = `${key}-card${i}-${state}`;
results.groups.push({name, theme, width, card:i, state, app:await app.evaluate(MEASURE,a), sample:await ref.evaluate(MEASURE,b)});
await app.locator(a).screenshot({path:path.join(OUT,`${name}-app.png`)});
await ref.locator(b).screenshot({path:path.join(OUT,`${name}-sample.png`)});
}
await app.locator(a).evaluate((e,on)=>e.classList.toggle("selected",on), THEMES[i]===theme);
await ref.locator(b).evaluate((e,on)=>e.classList.toggle("on",on), THEMES[i]===theme);
}
}
await ctx.close();
}
} finally {
results.apiCalls = [...new Set(apiLog)];
await fs.writeFile(path.join(OUT,"measurements.json"), JSON.stringify(results,null,2));
await browser.close();
await new Promise(r=>server.close(r));
}
if(results.errors.length || results.groups.length !== 144) throw Error("Incomplete matrix or page errors");
console.log(`Collected ${results.groups.length} card/state pairs; zero page errors; browser and server closed.`);