feat(HEL-729): unify the Nocturne creative desk and isolate history

Co-authored-by: multica-agent <github@multica.ai>
This commit is contained in:
leefer
2026-10-21 21:07:19 +08:00
co-authored by multica-agent
parent 8e84e4c21f
commit 2304ab1538
17 changed files with 1271 additions and 161 deletions
@@ -132,16 +132,27 @@
return live?.progress ?? 3;
});
let dims = $state('');
let naturalWidth = $state(1),
naturalHeight = $state(1);
let stageWidth = $state(0),
stageHeight = $state(0);
const fitted = $derived(
Math.min(stageWidth / naturalWidth, stageHeight / naturalHeight),
);
$effect.pre(() => {
void output;
dims = '';
});
function noteImg(e: Event) {
const img = e.currentTarget as HTMLImageElement;
naturalWidth = img.naturalWidth;
naturalHeight = img.naturalHeight;
dims = `${img.naturalWidth}×${img.naturalHeight}`;
}
function noteVideo(e: Event) {
const v = e.currentTarget as HTMLVideoElement;
naturalWidth = v.videoWidth;
naturalHeight = v.videoHeight;
dims = `${v.videoWidth}×${v.videoHeight}`;
}
@@ -205,10 +216,18 @@
</header>
<div class="stage" class:comparing={compare}>
{#if job && st}{#key mediaKey}
<div class="stage-media">
<div
class="stage-media"
bind:clientWidth={stageWidth}
bind:clientHeight={stageHeight}
>
{#if output && video}
<!-- svelte-ignore a11y_media_has_caption -->
<div class="print a-photo">
<div
class="print a-photo"
style:width={fitted > 0 ? `${naturalWidth * fitted}px` : '100%'}
style:height={fitted > 0 ? `${naturalHeight * fitted}px` : '100%'}
>
{#if st.tone === 'ok' && !fileGone}<span class="a-fix"
>{t('jobst.done')}</span
>{/if}
@@ -217,6 +236,12 @@
src={assetUrl(output)}
controls
aria-label="生成的视频,使用播放按钮开始播放"
onkeydown={(e) => {
if (e.code === 'Space' && e.target === e.currentTarget) {
e.preventDefault();
togglePlayback();
}
}}
onplay={() => (paused = false)}
onpause={() => (paused = true)}
onended={() => (paused = true)}
@@ -228,9 +253,21 @@
}}
onerror={() => output && markOutputMissing(output)}
></video>
{#if paused}<button
class="big-play"
aria-label="播放此视频"
onclick={() => {
videoEl?.focus();
togglePlayback();
}}>▶</button
>{/if}
</div>
{:else if output}
<div class="print a-photo">
<div
class="print a-photo"
style:width={fitted > 0 ? `${naturalWidth * fitted}px` : '100%'}
style:height={fitted > 0 ? `${naturalHeight * fitted}px` : '100%'}
>
{#if st.tone === 'ok' && !fileGone}<span class="a-fix"
>{t('jobst.done')}</span
>{/if}
@@ -287,6 +324,10 @@
</div>{/if}
</div>
<footer class="artboard-footer">
{#if job && output}<button
class="canvas-btn"
onclick={() => job && onReuse(job)}>复用到输入区</button
>{/if}
{#if video}<button
class="canvas-btn play-button"
onclick={togglePlayback}
@@ -421,9 +462,9 @@
grid-template-rows: 38px minmax(0, 1fr) 32px;
height: 100%;
min-height: 0;
border: 1px solid #ffffff0d;
border-radius: 12px;
background: radial-gradient(ellipse at 50% 20%, #202028, #131316 70%);
border: 1px solid var(--line);
border-radius: var(--r-md);
background: var(--bg-1);
overflow: hidden;
}
.artboard-toolbar,
@@ -457,9 +498,9 @@
flex-shrink: 0;
}
.canvas-btn {
background: #ffffff04;
background: var(--bg-2);
color: var(--text-2);
border: 1px solid #ffffff0a;
border: 1px solid var(--line);
border-radius: 6px;
padding: 5px 8px;
font-size: 10px;
@@ -469,7 +510,7 @@
}
.canvas-btn:hover {
color: var(--text);
background: #ffffff0a;
background: var(--bg-3);
}
.canvas-btn[aria-pressed='true'],
.play-button {
@@ -485,7 +526,7 @@
.stage-media {
position: absolute;
inset: 0;
padding: 8px 20px;
padding: 0;
display: grid;
place-items: center;
min-height: 0;
@@ -503,6 +544,20 @@
background: none;
box-shadow: none;
}
.big-play {
position: absolute;
left: 50%;
top: 50%;
transform: translate(-50%, -50%);
width: 64px;
height: 64px;
border-radius: 50%;
border: 1px solid var(--on-media-line);
color: var(--on-media);
background: var(--media-scrim);
font-size: 26px;
cursor: pointer;
}
.print img,
.print video {
width: 100%;
@@ -540,7 +595,7 @@
.comparison-layer {
position: absolute;
inset: 0;
background: #17171d;
background: var(--bg-0);
pointer-events: none;
}
.comparison-divider {
@@ -578,7 +633,7 @@
gap: 4px;
max-width: 90%;
overflow: auto;
background: #101015d9;
background: var(--media-scrim);
border: 1px solid var(--line);
padding: 4px;
border-radius: 8px;
@@ -1,5 +1,6 @@
<script lang="ts">
import { onMount } from 'svelte';
import { onMount, untrack } from 'svelte';
import { goto } from '$app/navigation';
import { createMutation, useQueryClient } from '@tanstack/svelte-query';
import { workflows, apiErrorMessage } from '$lib/api';
import type { WorkflowMeta } from '$lib/comfy/types';
@@ -8,6 +9,7 @@
import { toast } from '$lib/toast';
let {
kind,
initialUsage = 'all',
metas,
selectedId,
pendingCount,
@@ -18,6 +20,7 @@
}: {
disabled?: boolean;
kind: 'image' | 'video';
initialUsage?: string;
metas: WorkflowMeta[];
selectedId: number | null;
pendingCount: number;
@@ -27,9 +30,10 @@
} = $props();
const client = useQueryClient();
let search = $state(''),
usage = $state('all'),
usage = $state(untrack(() => initialUsage)),
family = $state('all'),
favOnly = $state(false),
runnableOnly = $state(true),
limit = $state(48),
pins = $state<number[]>([]);
onMount(() => {
@@ -51,6 +55,7 @@
metas
.filter(
(m) =>
(!runnableOnly || runnable(m)) &&
(!favOnly || m.favorite) &&
(usage === 'all' || m.usage === usage) &&
(family === 'all' || m.model_family === family) &&
@@ -63,6 +68,10 @@
(a, b) =>
Number(pins.includes(b.id)) - Number(pins.includes(a.id)) ||
Number(b.favorite) - Number(a.favorite) ||
(a.model_family ?? '本地工作流').localeCompare(
b.model_family ?? '本地工作流',
'zh',
) ||
a.name.localeCompare(b.name, 'zh'),
),
);
@@ -77,12 +86,23 @@
const index = filtered.findIndex((m) => m.id === selectedId);
if (index >= limit) limit = index + 24;
});
function runnable(m: WorkflowMeta) {
return m.is_enabled && !!m.usage && !m.needs_mapping;
}
function choose(m: WorkflowMeta) {
if (runnable(m)) onPick(m.id);
else void goto('/workflows?manage=1&wf=' + m.id);
}
function pin(id: number) {
pins = pins.includes(id) ? pins.filter((p) => p !== id) : [...pins, id];
localStorage.setItem(
'calliope.reference.workflowPins',
JSON.stringify(pins),
);
try {
localStorage.setItem(
'calliope.reference.workflowPins',
JSON.stringify(pins),
);
} catch {
/* Pins remain usable this visit. */
}
}
const favorite = createMutation({
mutationFn: (m: WorkflowMeta) =>
@@ -100,13 +120,14 @@
<div class="kind-tabs" role="tablist" aria-label="创作类型">
<button
role="tab"
{disabled}
aria-selected={kind === 'image'}
onclick={() => onKind('image')}>图片</button
><button
role="tab"
{disabled}
onclick={() => onKind('image')}>图片</button
>
<button
role="tab"
aria-selected={kind === 'video'}
{disabled}
onclick={() => onKind('video')}>视频</button
>
</div>
@@ -128,14 +149,23 @@
>
</div>
</div>
<label class="runnable-filter"
><input type="checkbox" bind:checked={runnableOnly} />仅可运行</label
>
<div class="list-meta">
<span>{filtered.length} 个可用工作流</span><button
<span>{filtered.length} 个工作流</span><button
aria-pressed={favOnly}
onclick={() => (favOnly = !favOnly)}>{favOnly ? '★' : '☆'} 收藏</button
>
</div>
<div class="rail-list">
{#each filtered.slice(0, limit) as m (m.id)}<div
{#each filtered.slice(0, limit) as m, index (m.id)}
{#if index === 0 || pins.includes(m.id) !== pins.includes(filtered[index - 1].id) || (!pins.includes(m.id) && m.model_family !== filtered[index - 1].model_family)}<div
class="family-label"
>
{pins.includes(m.id) ? '置顶工作流' : m.model_family || '本地工作流'}
</div>{/if}
<div
class="workflow-row reference-option"
use:spotlight
class:selected={selectedId === m.id}
@@ -143,12 +173,15 @@
<button
class="workflow-pick"
{disabled}
onclick={() => onPick(m.id)}
onclick={() => choose(m)}
aria-pressed={selectedId === m.id}
title={m.name}
><i class:video={kind === 'video'}>{kind === 'image' ? '◈' : '▷'}</i
><i class:video={kind === 'video'}
>{m.model_family?.slice(0, 3) || (kind === 'image' ? '◈' : '▷')}</i
><span
><b>{m.name}</b><small
><b>{m.name}</b>{#if !runnable(m)}<small class="pending"
>待配置 · 映射 / 测试 →</small
>{/if}<small
>{m.description ||
(m.usage ? usageLabel(m.usage) : '本地 ComfyUI')}</small
></span
@@ -190,12 +223,12 @@
<style>
.workflow-rail {
width: 232px;
width: var(--list);
flex-shrink: 0;
display: flex;
flex-direction: column;
min-height: 0;
background: #121216;
background: var(--bg-1);
border-right: 1px solid var(--line);
}
.workflow-rail > header {
@@ -218,34 +251,33 @@
}
.kind-tabs {
display: flex;
gap: 3px;
background: #09090c;
border: 1px solid var(--line);
border-radius: 9px;
padding: 3px;
gap: 4px;
}
.kind-tabs button {
flex: 1;
border: 0;
padding: 7px 3px;
background: none;
color: var(--text-3);
font-size: 12px;
border-radius: 6px;
padding: 5px 12px;
border: 1px solid var(--line-2);
border-radius: var(--r-pill);
background: var(--bg-2);
color: var(--tx-2);
cursor: pointer;
}
.kind-tabs [aria-selected='true'] {
color: #062f35;
background: var(--accent);
font-weight: 600;
border-color: var(--scene);
color: var(--scene);
}
.kind-tabs button:disabled {
cursor: wait;
}
.kind-tabs button {
flex: 1;
}
.rail-tools input {
width: 100%;
margin-top: 10px;
font-size: 11px;
padding: 9px 10px;
background: #1d1b16;
border: 1px solid #f5b04228;
background: var(--well);
border: 1px solid var(--line-2);
border-radius: 8px;
color: var(--text);
}
@@ -264,6 +296,17 @@
font-size: 10px;
color: var(--text-3);
}
.runnable-filter {
display: flex;
align-items: center;
gap: 6px;
padding: 4px 14px;
font-size: 11px;
color: var(--tx-2);
}
.pending {
color: var(--warn);
}
.list-meta {
display: flex;
justify-content: space-between;
@@ -281,6 +324,11 @@
.list-meta button[aria-pressed='true'] {
color: var(--warn);
}
.family-label {
font-size: 11px;
color: var(--tx-3);
padding: 8px;
}
.rail-list {
flex: 1;
min-height: 0;
@@ -297,8 +345,12 @@
margin: 2px 0;
}
.workflow-row.selected {
border-color: #00cae039;
background: linear-gradient(120deg, #00cae016, #00cae003);
border-color: color-mix(in srgb, var(--scene) 35%, transparent);
background: linear-gradient(
90deg,
color-mix(in srgb, var(--scene) 12%, var(--bg-3)),
var(--bg-3)
);
}
.workflow-row.selected::before {
content: '';
@@ -306,7 +358,7 @@
height: 22px;
position: absolute;
left: 0;
background: var(--accent);
background: var(--scene);
border-radius: 3px;
}
.workflow-pick {
@@ -330,9 +382,9 @@
height: 32px;
flex-shrink: 0;
border-radius: 9px;
border: 1px solid #00cae01f;
color: var(--accent);
background: #00cae008;
border: 1px solid var(--line-2);
color: var(--scene);
background: var(--bg-3);
font-size: 16px;
font-style: normal;
}
@@ -383,7 +435,7 @@
opacity: 1;
}
.workflow-row:hover {
background-color: #ffffff05;
background-color: var(--bg-2);
}
.empty {
color: var(--text-3);
@@ -434,7 +486,7 @@
}
@media (max-width: 1100px) {
.workflow-rail {
width: 210px;
width: var(--list);
}
}
</style>
@@ -0,0 +1,159 @@
<script lang="ts">
import { assetUrl } from '$lib/api';
import { jobStatusInfo } from '$lib/jobStatus';
import { isVideoOutput } from '../generate/takes';
import type { Job } from '$lib/comfy/types';
let {
jobs,
selectedId,
onSelect,
onExpand,
}: {
jobs: Job[];
selectedId: number | null;
onSelect: (job: Job) => void;
onExpand: () => void;
} = $props();
const sorted = $derived([...jobs].sort((a, b) => b.id - a.id));
let collapsed = $state(false);
</script>
<aside class="history-rail" class:collapsed aria-label="生成历史">
<header>
<button
onclick={() => (collapsed = !collapsed)}
aria-expanded={!collapsed}
aria-label={collapsed ? '展开历史栏' : '收起历史栏'}
>{collapsed ? '◷' : '历史'}</button
>
</header>
{#if !collapsed}
<div class="history-list">
{#each sorted as job (job.id)}
{@const status = jobStatusInfo(job)}
<button
class="history-take"
class:selected={selectedId === job.id}
onclick={() => onSelect(job)}
aria-label={'查看历史 #' + job.id + ' · ' + status.label}
aria-pressed={selectedId === job.id}
>
<div class="thumb">
{#if job.output_paths[0] && status.tone === 'ok'}
{#if isVideoOutput(job, job.output_paths[0])}<span
class="video-mark">▷</span
><video
src={assetUrl(job.output_paths[0])}
muted
preload="metadata"
aria-label="视频缩略图"
></video>
{:else}<img
src={assetUrl(job.output_paths[0])}
alt=""
loading="lazy"
/>{/if}
{:else}<span class:failed={status.tone === 'fail'}
>{status.label}</span
>{/if}
</div>
<small>#{job.id}</small>
</button>
{/each}
{#if !sorted.length}<p>生成后<br />记录留在这里</p>{/if}
</div>
<footer><button onclick={onExpand}>全部记录</button></footer>
{/if}
</aside>
<style>
.history-rail {
width: var(--hist);
flex-shrink: 0;
display: flex;
flex-direction: column;
min-height: 0;
border-left: 1px solid var(--line);
background: var(--bg-1);
}
.history-rail.collapsed {
width: 34px;
}
header {
height: var(--head);
display: grid;
place-items: center;
flex-shrink: 0;
border-bottom: 1px solid var(--line);
}
button {
font: inherit;
font-size: 11px;
background: none;
color: var(--tx-2);
border: 0;
cursor: pointer;
}
.history-list {
flex: 1;
min-height: 0;
overflow: auto;
padding: 8px 6px;
}
.history-take {
display: block;
width: 100%;
padding: 3px;
margin-bottom: 8px;
border: 1px solid transparent;
border-radius: var(--r-sm);
}
.history-take.selected {
border-color: var(--scene);
background: color-mix(in srgb, var(--scene) 10%, transparent);
}
.thumb {
position: relative;
aspect-ratio: 1;
display: grid;
place-items: center;
background: var(--bg-0);
border-radius: var(--r-xs);
overflow: hidden;
font-size: 10px;
}
img,
video {
width: 100%;
height: 100%;
object-fit: contain;
position: absolute;
inset: 0;
}
.video-mark {
position: absolute;
z-index: 1;
color: var(--on-media);
font-size: 24px;
text-shadow: 0 1px 6px var(--media-scrim);
}
.failed {
color: var(--err);
}
small {
font-family: var(--mono);
font-size: 10px;
color: var(--tx-3);
}
p {
text-align: center;
font-size: 10px;
color: var(--tx-3);
line-height: 1.7;
}
footer {
padding: 10px 2px;
text-align: center;
border-top: 1px solid var(--line);
}
</style>
@@ -1,4 +1,6 @@
<script lang="ts">
import { readDeskDraft, writeDeskDraft } from './draftCache';
import HistoryRail from './HistoryRail.svelte';
import { createQuery, useQueryClient } from '@tanstack/svelte-query';
import { toStore } from 'svelte/store';
import { tick, untrack } from 'svelte';
@@ -286,6 +288,7 @@
if (slotBusy || submitting) return;
actionRevision++;
selectedJobId = workflowResults.get(id) ?? null;
historyJobId = null;
if (id !== workflowId) workflowId = id;
void tick().then(() => promptEl?.focus());
}
@@ -308,7 +311,10 @@
workflowId = restoredWorkflowId;
return;
}
if (deskMetas.some((m) => m.id === raw)) workflowId = raw;
if (deskMetas.some((m) => m.id === raw)) {
if (pendingDraft?.workflowId !== raw) pendingDraft = null;
workflowId = raw;
}
});
async function toggleFavorite(id: number, fav: boolean) {
@@ -335,7 +341,6 @@
draftContext?.projectId,
draftContext?.stage,
draftContext?.sourceId ?? draftContext?.label,
page.url.searchParams.get('wf'),
]),
);
const sourceFingerprint = JSON.stringify([
@@ -343,9 +348,13 @@
draftContext?.negative,
draftContext?.durationSec,
]);
function readDraft(): { workflowId: number; values: FormValues } | null {
function readDraft(): {
workflowId: number;
values: FormValues;
requestId?: string | null;
} | null {
try {
const d = JSON.parse(window.sessionStorage.getItem(draftKey) ?? 'null');
const d = JSON.parse(readDeskDraft(draftKey) ?? 'null');
if (
!d ||
d.source !== sourceFingerprint ||
@@ -367,19 +376,27 @@
)
clean[key] = value;
}
return { workflowId: d.workflowId, values: clean };
return {
workflowId: d.workflowId,
values: clean,
requestId: d.requestId,
};
} catch {
return null;
}
}
let pendingDraft = untrack(readDraft);
// Keep the reload decision stable while detail and metadata queries arrive in either order.
const restoredWorkflowId = untrack(() =>
pendingDraft?.requestId === page.url.searchParams.get('wf')
? pendingDraft?.workflowId
: null,
);
const workflowDrafts = new SvelteMap<number, FormValues>();
const workflowEndpoints = new SvelteMap<number, string>();
const workflowResults = new SvelteMap<number, number>();
try {
const saved = JSON.parse(
window.sessionStorage.getItem(draftKey + ':workflows') ?? 'null',
);
const saved = JSON.parse(readDeskDraft(draftKey + ':workflows') ?? 'null');
if (saved?.source === sourceFingerprint) {
for (const [id, draft] of Object.entries(saved.drafts ?? {}))
if (Number.isInteger(Number(id)) && draft && typeof draft === 'object')
@@ -390,7 +407,7 @@
} catch {
/* The form works without storage. */
}
const restoredWorkflowId = pendingDraft?.workflowId ?? null;
let workflowId = $state<number | null>(pendingDraft?.workflowId ?? null);
let values = $state<FormValues>({});
let showErrors = $state(false);
@@ -456,6 +473,16 @@
...schemaDefaults(wf.input_schema ?? []),
...(workflowDrafts.get(wf.id) ?? {}),
};
if (lastWf && !workflowDrafts.has(wf.id)) {
const previous = classifySchema(lastWf.input_schema ?? [], lastWf.kind);
const incoming = classifySchema(wf.input_schema ?? [], wf.kind);
for (const role of ['prompt', 'negative'] as const) {
const oldInput = previous[role],
newInput = incoming[role];
if (oldInput && newInput && values[oldInput.nodeId] !== undefined)
next[newInput.nodeId] = values[oldInput.nodeId];
}
}
endpointTouched = true;
endpointId =
workflowEndpoints.get(wf.id) ?? wf.endpoint_id ?? activeId ?? '';
@@ -484,7 +511,7 @@
try {
workflowDrafts.set(wf.id, structuredClone($state.snapshot(inputs)));
workflowEndpoints.set(wf.id, ep);
window.sessionStorage.setItem(
writeDeskDraft(
draftKey + ':workflows',
JSON.stringify({
source: sourceFingerprint,
@@ -492,10 +519,11 @@
endpoints: Object.fromEntries(workflowEndpoints),
}),
);
window.sessionStorage.setItem(
writeDeskDraft(
draftKey,
JSON.stringify({
workflowId: wf.id,
requestId: page.url.searchParams.get('wf'),
values: inputs,
source: sourceFingerprint,
}),
@@ -671,6 +699,7 @@
return;
}
const wf = currentWorkflow!;
historyJobId = null;
submitting = true;
try {
const res = await playgroundApi.generate({
@@ -710,6 +739,7 @@
// ------------------------------------------------------------------
let filter = $state<FeedFilter>(untrack(() => kind));
let selectedJobId = $state<number | null>(null);
let historyJobId = $state<number | null>(null);
let busyId = $state<number | null>(null);
let tab = $state<'result' | 'params'>('result');
let inspOff = $state(false);
@@ -747,7 +777,8 @@
/** Picked take, else the newest finished one in view (undo-pending rows don't count). */
const selectedJob = $derived(
allJobs.find((j) => j.id === selectedJobId && !pendingIds.has(j.id)) ??
allJobs.find((j) => j.id === historyJobId && !pendingIds.has(j.id)) ??
allJobs.find((j) => j.id === selectedJobId && !pendingIds.has(j.id)) ??
allJobs
.filter(
(j) =>
@@ -766,9 +797,8 @@
*/
function selectTake(job: Job, via: 'pointer' | 'keyboard' = 'pointer') {
const from = via === 'pointer' ? (feed?.markRect() ?? null) : null;
selectedJobId = job.id;
if (workflowId != null && job.workflow_id === workflowId)
workflowResults.set(workflowId, job.id);
historyJobId = job.id;
tab = 'result';
crossfade = via === 'pointer';
if (via === 'pointer') {
@@ -843,10 +873,11 @@
function mediumLink(next: DeskKind) {
if (slotBusy || submitting) return;
const url = new URL(page.url);
url.pathname = '/' + next;
url.pathname = '/create';
url.searchParams.set('kind', next);
url.searchParams.delete('wf');
url.searchParams.delete('mode');
goto(url.pathname + url.search);
goto(url.pathname + url.search + url.hash);
}
const imageSlotTarget = $derived(
@@ -898,15 +929,60 @@
next[k] = v as string[];
}
if (slotBusy || submitting || revision !== actionRevision) return;
const stampedEndpoint = job.payload?.endpoint_id;
const reuseEndpoint =
typeof stampedEndpoint === 'string' &&
endpoints.some((ep) => ep.id === stampedEndpoint)
? stampedEndpoint
: (wf.endpoint_id ?? activeId ?? '');
if (wf.kind !== kind) {
const parts = JSON.parse(draftKey.slice('creative-draft:'.length));
parts[0] = wf.kind;
const targetKey = 'creative-draft:' + JSON.stringify(parts);
let saved: {
source?: string;
drafts?: Record<string, FormValues>;
endpoints?: Record<string, string>;
} = {};
try {
saved =
JSON.parse(readDeskDraft(targetKey + ':workflows') ?? '{}') ?? {};
} catch {
/* No prior draft. */
}
if (saved.source !== sourceFingerprint) saved = {};
writeDeskDraft(
targetKey + ':workflows',
JSON.stringify({
source: sourceFingerprint,
drafts: { ...saved.drafts, [wf.id]: next },
endpoints: { ...saved.endpoints, [wf.id]: reuseEndpoint },
}),
);
writeDeskDraft(
targetKey,
JSON.stringify({
workflowId: wf.id,
requestId: String(wf.id),
values: next,
source: sourceFingerprint,
}),
);
const url = new URL(page.url);
url.searchParams.set('kind', wf.kind);
url.searchParams.set('wf', String(wf.id));
await goto('/create' + url.search + url.hash);
return;
}
if (lastWf) workflowEndpoints.set(lastWf.id, endpointId);
endpointTouched = true;
endpointId =
workflowEndpoints.get(wf.id) ?? wf.endpoint_id ?? activeId ?? '';
endpointId = reuseEndpoint;
if (lastWf && lastWf.id !== wf.id)
workflowDrafts.set(lastWf.id, structuredClone($state.snapshot(values)));
workflowDrafts.set(wf.id, next);
workflowId = wf.id;
selectedJobId = job.id;
historyJobId = null;
workflowResults.set(wf.id, job.id);
lastWf = wf;
values = next;
@@ -1104,10 +1180,15 @@
{/snippet}
<AppShell active={kind} title="创作台" workspace>
<div class="reference-desk" class:focused={focusMode}>
<div
class="reference-desk"
class:focused={focusMode}
style:--scene={kind === 'image' ? 'var(--sc-image)' : 'var(--sc-video)'}
>
<DeskWorkflowRail
{kind}
metas={deskMetas}
initialUsage={mode}
metas={allMetas.filter((m) => m.kind === kind)}
selectedId={workflowId}
{pendingCount}
onPick={pickWorkflow}
@@ -1139,6 +1220,19 @@
</div>
</header>
{@render ctxBanner()}
{#if historyJobId != null && selectedJob?.id === historyJobId}<div
class="history-banner"
role="status"
>
<span>正在查看历史 #{historyJobId} · 当前输入保持不变</span><button
class="a-btn ghost sm"
onclick={() => (historyJobId = null)}>返回当前创作</button
><button
class="a-btn secondary sm"
onclick={() => selectedJob && reuse(selectedJob)}
>复用到输入区</button
>
</div>{/if}
<div class="studio-layout">
<section class="viewer" aria-label="查看生成结果">
<div class="preview-area">
@@ -1219,6 +1313,12 @@
</aside>
</div>
</div>
<HistoryRail
jobs={allJobs.filter((j) => j.kind === kind && !pendingIds.has(j.id))}
selectedId={selectedJob?.id ?? null}
onSelect={selectTake}
onExpand={() => (historyOpen = true)}
/>
</div>
</AppShell>
<Modal bind:open={bayOpen} size="xl" title="工作流说明与管理"
@@ -1410,6 +1510,21 @@
height: 44px;
flex-shrink: 0;
}
.history-banner {
display: flex;
align-items: center;
gap: 8px;
flex-shrink: 0;
padding: 6px 8px;
border: 1px solid var(--info);
border-radius: var(--r-md);
color: var(--info);
font-size: 11px;
background: var(--bg-2);
}
.history-banner span {
margin-right: auto;
}
.heading-actions {
display: flex;
align-items: center;
@@ -1435,7 +1550,7 @@
}
.studio-layout {
display: grid;
grid-template-rows: minmax(0, 1fr) 190px;
grid-template-rows: minmax(0, 1fr) auto;
gap: 12px;
flex: 1;
min-height: 0;
@@ -1462,7 +1577,7 @@
gap: 12px;
border: 1px solid var(--line);
border-radius: 12px;
background: radial-gradient(ellipse at 50% 20%, #202028, #131316 70%);
background: var(--bg-1);
}
.desk-welcome h1 {
font-size: 24px;
@@ -1542,7 +1657,7 @@
}
@media (max-height: 740px) {
.studio-layout {
grid-template-rows: minmax(0, 1fr) 172px;
grid-template-rows: minmax(0, 1fr) auto;
gap: 10px;
}
.refract-studio {
@@ -0,0 +1,17 @@
// Same-tab fallback for browsers that deny session storage. No user data leaves the browser.
const drafts = new Map<string, string>();
export function readDeskDraft(key: string): string | null {
try {
return sessionStorage.getItem(key) ?? drafts.get(key) ?? null;
} catch {
return drafts.get(key) ?? null;
}
}
export function writeDeskDraft(key: string, value: string) {
drafts.set(key, value);
try {
sessionStorage.setItem(key, value);
} catch {
/* In-memory switching still works. */
}
}
@@ -73,6 +73,7 @@
}: Props = $props();
let mediaOpen = $state(false);
let expanded = $state(false);
let popoverInputs = $state<ComfyDynamicInput[]>([]);
let popoverAnchor = $state<HTMLElement | null>(null);
export function openMedia() {
@@ -138,7 +139,7 @@
}
</script>
<div class="composer-wrap" class:dock>
<div class="composer-wrap" class:dock class:expanded>
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
bind:this={root}
@@ -157,7 +158,10 @@
<div class="composer-scroll" aria-label="创作输入">
{@render header?.()}
{#if current}
<section class="input-section prompt-section">
<section
class="input-section prompt-section"
class:has-slots={dock && hasSlots}
>
<h3>2 · 准备这次创作</h3>
{#if hasSlots && !dock}<div class="slots">
<SlotTray
@@ -171,9 +175,9 @@
{onFromResults}
/>
</div>{/if}
{#if dock}<div class="media-summary">
{#if dock}<div class="media-summary" class:has-slots={hasSlots}>
{#if hasSlots}
{#each ds.slots.slice(0, 4) as slot}<button
{#each ds.slots.slice(0, 3) as slot}<button
class="media-token"
onclick={openMedia}
aria-label={'设置' + slot.label}
@@ -186,13 +190,26 @@
</button>{/each}
{#if ds.lists.length}<button
class="media-token"
onclick={openMedia}>+ 参考材料</button
onclick={openMedia}
>+ 参考材料 {ds.lists.reduce(
(n, g) =>
n +
(Array.isArray(values[g.nodeId])
? (values[g.nodeId] as string[]).length
: 0),
0,
)}</button
>{/if}
<button class="media-token" onclick={openMedia}
>{slotBusy ? '正在上传…' : '管理素材'}</button
>
{:else}<small>描述你的画面</small>{/if}
<small class="input-shortcut">Ctrl + Enter 生成</small>
<button
class="expand-prompt"
aria-expanded={expanded}
onclick={() => (expanded = !expanded)}
>{expanded ? '收起提示词' : '展开提示词'}</button
>
</div>{/if}
<div class="prompt-pair" class:dual={dock && !!ds.negative}>
{#if ds.prompt}<div class="prompt-field">
@@ -482,7 +499,7 @@
background: var(--surface);
display: grid;
gap: 10px;
box-shadow: 0 -8px 20px #292a2d05;
box-shadow: var(--sh-1);
}
.gen {
width: 100%;
@@ -520,24 +537,24 @@
}
.dock {
height: 100%;
height: auto;
min-height: 0;
}
.dock .composer {
height: 100%;
height: auto;
min-height: 0;
border: 1px solid #f5b04246;
border-radius: 16px;
background: linear-gradient(125deg, #1b1a1d, #17161a);
border: 1px solid var(--line-2);
border-radius: var(--r-lg);
background: var(--composer-bg);
overflow: hidden;
box-shadow: inset 0 0 0 1px #f5b04206;
box-shadow: var(--sh-2);
}
.dock .composer:focus-within {
border-color: #f5b04280;
border-color: var(--accent-line);
}
.dock .composer-scroll {
padding: 9px 14px 4px;
overflow: hidden;
overflow: auto;
display: flex;
flex-direction: column;
}
@@ -557,11 +574,28 @@
display: flex;
align-items: center;
gap: 6px;
height: 28px;
min-height: 24px;
margin-bottom: 6px;
flex-shrink: 0;
overflow: auto;
}
.dock .input-section.has-slots {
display: grid;
grid-template-columns: minmax(160px, 32%) minmax(0, 1fr);
gap: 12px;
}
.has-slots .media-summary {
align-self: start;
flex-wrap: wrap;
margin: 0;
padding-right: 10px;
border-right: 1px solid var(--line);
}
.has-slots .expand-prompt {
width: 100%;
text-align: right;
}
.media-summary > small {
color: var(--text-3);
font-size: 10px;
@@ -571,24 +605,30 @@
display: flex;
align-items: center;
gap: 5px;
background: #ffffff04;
border: 1px dashed #ffffff25;
background: var(--bg-2);
border: 1px dashed var(--line-3);
border-radius: 6px;
padding: 3px 7px;
font-size: 10px;
color: var(--text-2);
cursor: pointer;
white-space: nowrap;
height: 26px;
height: var(--slot-size);
}
.media-token img {
height: 20px;
width: 20px;
height: 36px;
width: 36px;
object-fit: cover;
border-radius: 3px;
}
.input-shortcut {
.expand-prompt {
margin-left: auto;
flex-shrink: 0;
border: 0;
background: none;
color: var(--tx-2);
font-size: 11px;
cursor: pointer;
}
.dock .prompt-pair {
display: grid;
@@ -618,7 +658,7 @@
gap: 6px;
}
.dock .prompt-field:first-child label {
color: #d0b37d;
color: var(--tx-2);
}
.dock .prompt {
background: none;
@@ -630,10 +670,13 @@
resize: none;
min-height: 0;
max-height: none;
height: 100%;
flex: 1;
height: var(--prompt-height);
flex: none;
overflow: auto;
}
.dock.expanded .prompt {
height: var(--prompt-expanded);
}
.dock .prompt:focus {
outline: 0;
}
@@ -668,12 +711,12 @@
overflow: auto;
}
.parameter {
background: #25242a;
border: 1px solid #ffffff07;
background: var(--bg-3);
border: 1px solid var(--line);
border-radius: 7px;
padding: 5px 8px;
font-size: 10px;
color: #c4c2cb;
color: var(--tx-2);
cursor: pointer;
white-space: nowrap;
max-width: 180px;
@@ -682,7 +725,7 @@
flex-shrink: 0;
}
.parameter:hover {
border-color: #f5b04255;
border-color: var(--line-3);
}
.dock .gen {
width: auto;
@@ -692,10 +735,6 @@
border-radius: 20px;
margin-left: auto;
flex-shrink: 0;
background: linear-gradient(140deg, #ffc65e, #f5b042);
color: #30210c;
border-color: #edb458;
box-shadow: 0 3px 12px #f5b04217;
}
.dock .why {
position: absolute;
@@ -704,14 +743,14 @@
max-width: 65%;
padding: 3px 8px;
border-radius: 5px;
background: #241e17;
background: var(--bg-2);
font-size: 10px;
color: var(--warn);
z-index: 1;
}
.dock .why.err {
color: var(--err);
background: #241717;
background: var(--bg-2);
}
.dock .ready {
display: none;
@@ -156,7 +156,11 @@
),
);
const scene = $derived(
page.url.pathname === "/video" ? "video" : (currentNav?.scene ?? "manage"),
page.url.pathname === "/video" ||
(page.url.pathname === "/create" &&
page.url.searchParams.get("kind") === "video")
? "video"
: (currentNav?.scene ?? "manage"),
);
let themeOpen = $state(false);
let focus = $state(false);
@@ -1,4 +1,5 @@
<script lang="ts">
import { page } from '$app/state';
import { createMutation, useQueryClient } from '@tanstack/svelte-query';
import Button from '$lib/components/ui/Button.svelte';
import ConfirmDialog from '$lib/components/ui/ConfirmDialog.svelte';
@@ -35,6 +36,18 @@
let { rows, endpoints }: Props = $props();
// A pending workflow on the creative desk leads to its actual mapping/test row.
let deepLinkDone = false;
$effect(() => {
if (deepLinkDone || !rows.length) return;
const id = Number(page.url.searchParams.get('wf'));
const row = rows.find((r) => r.id === id);
deepLinkDone = true;
if (!row) return;
search = row.name;
if (rowNeedsOutputMap(row)) void toggleWfMapEdit(row);
});
const client = useQueryClient();
// ------------------------------------------------------------------
@@ -0,0 +1,9 @@
<script lang="ts">
import { page } from '$app/state';
import StudioDesk from '$lib/components/desk/StudioDesk.svelte';
const kind = $derived(
page.url.searchParams.get('kind') === 'video' ? 'video' : 'image',
);
</script>
{#key kind + page.url.search}<StudioDesk {kind} />{/key}
-8
View File
@@ -1,9 +1 @@
import { redirect } from "@sveltejs/kit";
export const ssr = false;
export const prerender = false;
// B0 compatibility gateway: existing working editors remain the destination
// until the unified editor is delivered in B1. Preserve all source context.
export function load({ url }: { url: URL }) {
const kind = url.searchParams.get("kind") === "video" ? "video" : "image";
redirect(307, `/${kind}${url.search}${new URL(url.href).hash}`);
}
+8 -2
View File
@@ -1,6 +1,12 @@
<script lang="ts">
import StudioDesk from '$lib/components/desk/StudioDesk.svelte';
import { onMount } from 'svelte';
import { goto } from '$app/navigation';
import { page } from '$app/state';
onMount(() => {
const url = new URL(page.url);
url.searchParams.set('kind', 'image');
void goto('/create' + url.search + url.hash, { replaceState: true });
});
</script>
{#key page.url.pathname + page.url.search}<StudioDesk kind="image" />{/key}
<p>正在打开创作台…</p>
@@ -1,41 +1,12 @@
<script lang="ts">
import { onMount } from 'svelte';
import { goto } from '$app/navigation';
import { agentApi, canvasApi } from '$lib/api';
import { t } from '$lib/i18n.svelte';
let failed = $state('');
onMount(async () => {
try {
const sessions = await agentApi.listSessions();
const sandbox = sessions.find((s) => s.project_id == null);
const s = sandbox ?? (await agentApi.createSession({}));
const graph = await canvasApi.ensureForSession(s.id);
goto(`/canvas/${graph.canvas.id}?session=${s.id}`, { replaceState: true });
} catch (err) {
failed = err instanceof Error ? err.message : t('playground.openFailed');
}
import { page } from '$app/state';
onMount(() => {
void goto('/create' + page.url.search + page.url.hash, {
replaceState: true,
});
});
</script>
{#if failed}
<div class="redirect-error" role="alert">{failed}</div>
{:else}
<div class="redirecting">{t('playground.opening')}</div>
{/if}
<style>
.redirecting,
.redirect-error {
display: flex;
align-items: center;
justify-content: center;
height: 100vh;
color: var(--text-secondary);
font-size: 14px;
}
.redirect-error {
color: var(--err);
}
</style>
<p>正在打开创作台…</p>
+8 -2
View File
@@ -1,6 +1,12 @@
<script lang="ts">
import StudioDesk from '$lib/components/desk/StudioDesk.svelte';
import { onMount } from 'svelte';
import { goto } from '$app/navigation';
import { page } from '$app/state';
onMount(() => {
const url = new URL(page.url);
url.searchParams.set('kind', 'video');
void goto('/create' + url.search + url.hash, { replaceState: true });
});
</script>
{#key page.url.pathname + page.url.search}<StudioDesk kind="video" />{/key}
<p>正在打开创作台…</p>
+10
View File
@@ -44,6 +44,9 @@
--fs-body: 13px;
--fs-small: 12px;
--fs-micro: 11px;
--slot-size: 56px;
--prompt-height: 64px;
--prompt-expanded: 28vh;
/* space (4 base) */
--s1: 4px;
--s2: 8px;
@@ -545,3 +548,10 @@ html {
background: var(--bg-0);
color: var(--tx-1);
}
@media (max-height: 800px) {
:root {
--slot-size: 50px;
--prompt-height: 48px;
}
}
@@ -0,0 +1,26 @@
# HEL-729 · B1 夜片场创作台
从 B0 已审核、已部署的 `8e84e4c21f22febe7bd4f20777d06fa1d4914e8e` 继续,目标分支为 `feat/hel-728-night-foundation`。本批只改创作台与其兼容入口,不改生成 API、数据库、连接配置与项目确认语义,不部署。
## 实现
- `/create` 成为真实创作台,内部切换图片/视频。旧 `/image`、`/video`、`/playground` 保留所有查询参数与片段转入创作台。
- 复用 StudioDesk、Composer、SlotTray、ParamsPanel、ParameterPopover、TakeFeed 和真实生成/回流 API。工作流列采用 B0 的 `--list`,搜索、用途/模型筛选、收藏与置顶保留;仅可运行默认开启,关闭后待配置项进入对应的真实映射/测试管理行。
- 草稿按媒介和来源分别保存,内部工作流独立记忆输入及连接。首次切换工作流延续正反向提示词;显式深链优先于不同请求的缓存,同一请求刷新恢复用户的选择,异步详情与列表加载顺序不改变归属。存储受限时保留同标签页内存回退。
- 素材与提示词并排放在底座,长提示词可展开并内部滚动。参数弹层沿用草稿取消/保存。槽尺寸与提示词高度统一归入 `static/themes.css`,四套主题继续可用。
- 历史是独立右栏,可收起。回看只改变观看对象,显示历史状态条;返回当前创作保留原输入。主动复用才回填,跨媒介复用切到对应编辑器,并恢复生成时使用的可用连接。
- 作品按容器实测尺寸与媒体宽高计算 contain 大小。视频无自动播放,中央大播放键清晰,空格仅在视频自身获得焦点时控制;切换结果暂停。下载、取消、重试、详情及已有来源项目回流均沿用原链路。
## 自测与证据
`e2e/run-hel729.py` 在前台启动并收集隔离 FastAPI、假 ComfyUI、假 LLM 与真实前端,结束清理子进程。测试断言数据目录为本次隔离目录,未读取用户数据或调用收费服务。测试输入、遮罩、首尾帧、多参考素材通过真实上传、队列与输出保存链路;来源结果通过原 attach-workbench 接口显式回流。
浏览器脚本 `e2e/hel729.cjs` 检查三档桌面(1440×900、1366×768、1280×720)、横竖图完整显示、首屏主操作、手动播放、图视频与工作流输入归属、刷新、主题切换、参数取消/保存、历史与复用、失败重试、取消和存储受限。PNG 和 results.json 通过任务卡附件交付;样图均为隔离测试内容。
相关后端回归:`test_image_input_workflows.py`、`test_playground_uploads.py`、`test_playground_workbench_flowback.py`、`test_playground.py` 共 47 项通过。前端 `npm run check`、`npm run test`、`npm run build` 通过;类型检查保留基线 ShotStagePanel 视频字幕警告一条。
全仓 `npm run lint` 仍被既有格式问题阻断,直接 ESLint 也因仓库缺少 eslint.config 配置退出;这两项为 B0 既有工具链问题,不在本批扩面格式化或新增依赖。本批主要组件另做 Prettier 定向检查和 `git diff --check`。
## 尚未覆盖
未连接真实 ComfyUI/GPU 或云模型做付费生成,未部署测试站。通用“选择项目/阶段存入”和智能体任务成果能力仍按分批安排留给 B4。本批交总工独立对照样本审核,不能以自动化数量替代视觉验收。
+592
View File
@@ -0,0 +1,592 @@
// B1 integration: disposable FastAPI -> fake ComfyUI, real UI and HTTP only.
const { chromium } = require(
process.env.PLAYWRIGHT_MODULE_PATH || "playwright",
);
const assert = require("node:assert/strict"),
fs = require("node:fs"),
path = require("node:path");
const API = "http://127.0.0.1:8247",
WEB = "http://127.0.0.1:5173",
out = "e2e/shots/hel729";
fs.mkdirSync(out, { recursive: true });
const f = JSON.parse(fs.readFileSync("e2e/shots/reference/fixtures.json"));
const checks = [];
function ok(n, v) {
assert(v, n);
checks.push(n);
console.log("PASS", n);
}
async function api(p, b, method) {
const r = await fetch(API + p, {
method: method || (b ? "POST" : "GET"),
headers: { "Content-Type": "application/json" },
body: b ? JSON.stringify(b) : undefined,
});
if (!r.ok) throw Error(p + " " + (await r.text()));
return r.json();
}
async function wait(fn) {
for (let i = 0; i < 120; i++) {
const v = await fn();
if (v) return v;
await new Promise((r) => setTimeout(r, 150));
}
throw Error("condition timeout");
}
function fixtureDimensions(file) {
const b = fs.readFileSync(file);
return b.readUInt32BE(16) === 480 && b.readUInt32BE(20) === 720;
}
function mode(video = false, extra = {}) {
fs.writeFileSync(
"e2e/fake_mode.json",
JSON.stringify({
mode: "ok",
delay_sec: 0,
unique_filename: true,
videos: video,
image_file: path.resolve("calliope-web/static/refract/coast.jpg"),
...extra,
}),
);
}
async function submit(p, wf, video = false) {
mode(video);
const before = Math.max(
0,
...(await api("/api/playground/jobs")).map((j) => j.id),
);
await p.locator(".gen").click();
const j = await wait(async () =>
(await api("/api/playground/jobs")).find(
(j) => j.id > before && j.workflow_id === wf && j.status === "done",
),
);
await p
.locator(".result-title")
.filter({ hasText: "第 " + j.id + " 次生成" })
.waitFor();
return j;
}
async function shot(p, name) {
await p.screenshot({ path: out + "/" + name + ".png" });
}
(async () => {
const browser = await chromium.launch({
executablePath: process.env.CHROMIUM_EXECUTABLE_PATH,
args: ["--no-sandbox", "--disable-dev-shm-usage"],
});
const errors = [];
let p;
const saved = fs.existsSync("e2e/fake_mode.json")
? fs.readFileSync("e2e/fake_mode.json")
: null;
try {
const c = await browser.newContext({
viewport: { width: 1440, height: 900 },
});
p = await c.newPage();
p.setDefaultTimeout(15000);
p.on("pageerror", (e) => errors.push(e.message));
await p.goto(WEB + "/image?wf=" + f.dual + "#source");
await p.locator("#desk-negative").waitFor();
ok(
"旧生图入口进入统一创作台并保留工作流和片段",
new URL(p.url()).pathname === "/create" &&
new URL(p.url()).hash === "#source" &&
new URL(p.url()).searchParams.get("wf") === String(f.dual),
);
await p.locator("#desk-prompt").fill("一位女孩站在海岸,清晨柔光。");
await p.locator("#desk-negative").fill("模糊、畸形");
await p.locator(".rail-tools input[type=search]").fill("Qwen");
ok("检索工作流", (await p.locator(".workflow-pick").count()) === 1);
await p.locator(".row-actions button").first().click();
ok(
"置顶保留输入",
(await p.locator("#desk-prompt").inputValue()) ===
"一位女孩站在海岸,清晨柔光。",
);
await p
.getByRole("button", { name: "创作设置", exact: true })
.first()
.click();
await p.locator(".modal-panel input[type=number]").first().fill("99");
await p.getByRole("button", { name: "取消", exact: true }).click();
await p
.getByRole("button", { name: "创作设置", exact: true })
.first()
.click();
ok(
"参数取消不保存",
(await p
.locator(".modal-panel input[type=number]")
.first()
.inputValue()) === "42",
);
await p.locator(".modal-panel input[type=number]").first().fill("101");
await p.getByRole("button", { name: "保存设置", exact: true }).click();
const j = await submit(p, f.dual);
ok(
"真实图片生成写入正反向和参数",
JSON.stringify(j.payload).includes("101") &&
JSON.stringify(j.payload).includes("模糊、畸形"),
);
await p.locator("#desk-prompt").fill("这是当前输入,不随历史变化");
await p
.locator(".history-take")
.filter({ hasText: "#" + j.id })
.click();
ok(
"历史状态明确且不修改输入",
(await p.locator(".history-banner").isVisible()) &&
(await p.locator("#desk-prompt").inputValue()) ===
"这是当前输入,不随历史变化",
);
await shot(p, "1440-history");
await p.getByRole("button", { name: "返回当前创作", exact: true }).click();
await p.getByRole("button", { name: "展开提示词", exact: true }).click();
await p.locator("#desk-prompt").fill("长提示词描述。".repeat(600));
ok(
"长提示词可展开且生成键可见",
(await p.locator("#desk-prompt").evaluate((e) => e.clientHeight > 180)) &&
(await p.locator(".gen").isVisible()),
);
await shot(p, "1440-long-prompt");
await p.getByRole("button", { name: "收起提示词", exact: true }).click();
await p.locator("#desk-prompt").fill("这是当前输入,不随历史变化");
// Previously unseen workflow inherits semantic prompts, returning restores its own parameters.
await p.locator(".rail-tools input[type=search]").fill("文生图");
await p
.locator(".workflow-pick")
.filter({ hasText: "文生图·回归" })
.click();
await p.waitForFunction(() => !document.querySelector("#desk-negative"));
ok(
"切换新工作流保留提示词",
(await p.locator("#desk-prompt").inputValue()) ===
"这是当前输入,不随历史变化",
);
await p.reload();
await p.locator("#desk-prompt").waitFor();
ok(
"刷新恢复切换后的工作流及草稿",
(await p
.locator(".model-selector")
.innerText()
.then((s) => s.includes("文生图"))) &&
(await p.locator("#desk-prompt").inputValue()) ===
"这是当前输入,不随历史变化",
);
await p.locator(".rail-tools input[type=search]").fill("Qwen");
await p.locator(".workflow-pick").first().click();
await p
.getByRole("button", { name: "创作设置", exact: true })
.first()
.click();
ok(
"切回工作流恢复独立参数",
(await p
.locator(".modal-panel input[type=number]")
.first()
.inputValue()) === "101",
);
await p.keyboard.press("Escape");
await p.getByRole("tab", { name: "视频", exact: true }).click();
await p.locator("#desk-prompt").waitFor();
await p.locator(".rail-tools input[type=search]").fill("H3");
await p.locator(".workflow-pick").first().click();
await p.locator("#desk-prompt").fill("视频独立草稿");
await p.getByRole("button", { name: "管理素材", exact: true }).click();
const first = p
.locator(".a-slot")
.filter({ has: p.getByText("首帧", { exact: true }) }),
last = p
.locator(".a-slot")
.filter({ has: p.getByText("尾帧", { exact: true }) });
await first
.locator("input[type=file]")
.setInputFiles("calliope-web/static/refract/coast.jpg");
await first.locator("img").waitFor();
await last
.locator("input[type=file]")
.setInputFiles("calliope-web/static/refract/arch.jpg");
await last.locator("img").waitFor();
await p.getByRole("button", { name: "完成", exact: true }).click();
await p.getByRole("tab", { name: "图片", exact: true }).click();
await p.locator("#desk-negative").waitFor();
ok(
"图视频切换恢复图片草稿",
(await p.locator("#desk-prompt").inputValue()) ===
"这是当前输入,不随历史变化",
);
await p.getByRole("tab", { name: "视频", exact: true }).click();
await p.locator("#desk-prompt").waitFor();
ok(
"图视频切换恢复视频工作流和上传素材",
(await p.locator("#desk-prompt").inputValue()) === "视频独立草稿" &&
(await p.locator(".media-token img").count()) === 2,
);
const v = await submit(p, f.h3, true);
const player = p.locator(".print video");
await player.waitFor();
await wait(() => player.evaluate((e) => e.readyState >= 1));
ok(
"视频结果默认暂停",
await player.evaluate((e) => e.paused && !e.autoplay),
);
await p.locator("#desk-prompt").focus();
await p.keyboard.press("Space");
ok("提示词中的空格不播放视频", await player.evaluate((e) => e.paused));
await p.getByRole("button", { name: "播放此视频", exact: true }).click();
await wait(() => player.evaluate((e) => !e.paused));
await player.focus();
await p.keyboard.press("Space");
await wait(() => player.evaluate((e) => e.paused));
ok("中央大播放键与聚焦空格可控", true);
for (const [width, height] of [
[1440, 900],
[1366, 768],
[1280, 720],
]) {
await p.setViewportSize({ width, height });
await p.waitForTimeout(150);
ok(
width + " 视频完整且主按钮首屏",
(await p.locator(".gen").evaluate((e) => {
const r = e.getBoundingClientRect();
return r.top >= 0 && r.bottom <= innerHeight && r.right <= innerWidth;
})) &&
(await player.evaluate(
(e) => getComputedStyle(e).objectFit === "contain",
)),
);
await shot(p, width + "-video");
}
// Cross-medium reuse from the complete history switches the editor kind explicitly.
await p.getByRole("button", { name: "◷ 生成历史", exact: true }).click();
await p
.locator(".history-full .a-seg")
.getByRole("button", { name: "全部", exact: true })
.click();
await p
.locator('.history-full [data-job="' + j.id + '"]')
.getByRole("button", { name: "复用", exact: true })
.click();
await p
.getByRole("tab", { name: "图片", exact: true, selected: true })
.waitFor();
await p.locator("#desk-negative").waitFor();
ok(
"跨媒介历史复用切到所属类型并回填",
(await p.locator("#desk-prompt").inputValue()) ===
"一位女孩站在海岸,清晨柔光。",
);
await p.getByRole("tab", { name: "视频", exact: true }).click();
await p.locator(".print video").waitFor();
// Theme switches preserve the same editor node and state.
await p.locator("#desk-prompt").evaluate((e) => (window.__editor = e));
for (const [id, name] of [
["night", "夜片场"],
["day", "日光棚"],
["grade", "调色间"],
["bluehour", "蓝调时刻"],
]) {
await p.getByRole("button", { name: "主题", exact: true }).click();
await p.locator(".choice").filter({ hasText: name }).click();
await p.keyboard.press("Escape");
ok(
id + " 切换保持编辑和素材",
(await p
.locator("#desk-prompt")
.evaluate((e) => e === window.__editor)) &&
(await p.locator(".media-token img").count()) === 2,
);
await shot(p, "1280-theme-" + id);
}
// Import minimal API fixtures with the same parser roles used by user workflows.
const base = await api("/api/workflows/" + f.dual);
const maskBody = {
...base.workflow_json,
5: {
class_type: "LoadImage",
inputs: { image: "" },
_meta: { title: "(Input:image) 输入图" },
},
6: {
class_type: "LoadImageMask",
inputs: { image: "", channel: "red" },
_meta: { title: "(Input:mask) 遮罩" },
},
};
const maskWf = await api("/api/workflows", {
name: "遮罩图生图测试",
kind: "image",
workflow_json: maskBody,
});
await p.goto(WEB + "/create?wf=" + maskWf.id);
await p.locator("#desk-prompt").waitFor();
await p.getByRole("button", { name: "管理素材", exact: true }).click();
const imageSlots = p.locator(".a-slot");
ok("图生图与遮罩独立槽位", (await imageSlots.count()) === 2);
for (let i = 0; i < 2; i++) {
await imageSlots
.nth(i)
.locator("input[type=file]")
.setInputFiles("calliope-web/static/refract/coast.jpg");
await imageSlots.nth(i).locator("img").waitFor();
}
await p.getByRole("button", { name: "完成", exact: true }).click();
const masked = await submit(p, maskWf.id);
ok(
"上传和遮罩经真实队列生成",
!!masked.payload.input_values["5"] && !!masked.payload.input_values["6"],
);
const refBody = {
1: {
class_type: "CLIPTextEncode",
inputs: { text: "多参考视频" },
_meta: { title: "提示词" },
},
5: { class_type: "LoadImage", inputs: { image: "" } },
7: {
class_type: "MiniMaxH3ReferenceToVideo",
inputs: { "ref_images.ref_image_0": ["5", 0] },
},
9: {
class_type: "VHS_VideoCombine",
inputs: { images: "x" },
_meta: { title: "(Output:video) 视频输出" },
},
};
const refWf = await api("/api/workflows", {
name: "H3多参考测试",
kind: "video",
workflow_json: refBody,
});
await p.goto(WEB + "/create?kind=video&wf=" + refWf.id);
await p.locator("#desk-prompt").waitFor();
await p.getByRole("button", { name: "管理素材", exact: true }).click();
await p
.locator(".media-editor input[type=file]")
.first()
.setInputFiles([
"calliope-web/static/refract/coast.jpg",
"calliope-web/static/refract/arch.jpg",
]);
await wait(
async () => (await p.locator(".media-editor img").count()) === 2,
);
await p.getByRole("button", { name: "完成", exact: true }).click();
const refs = await submit(p, refWf.id, true);
ok(
"多参考素材真实上传并生成",
Array.isArray(refs.payload.input_values["7@ref_images"]) &&
refs.payload.input_values["7@ref_images"].length === 2,
);
await shot(p, "1280-multi-reference");
// Preserve failure, retry and cancellation paths.
await p.goto(WEB + "/create?wf=" + f.dual);
await p.locator("#desk-prompt").waitFor();
mode(false, { mode: "fail" });
await p.locator(".gen").click();
const failed = await wait(async () =>
(await api("/api/playground/jobs")).find(
(j) => j.workflow_id === f.dual && j.status === "failed",
),
);
await p.getByRole("button", { name: "重试", exact: true }).waitFor();
mode();
await p.getByRole("button", { name: "重试", exact: true }).click();
await wait(async () =>
(await api("/api/playground/jobs")).some(
(j) => j.id === failed.id && j.status === "done",
),
);
ok("失败后重试取得真实结果", true);
mode(false, { delay_sec: 10 });
await p.locator(".gen").click();
await p.getByRole("button", { name: "取消生成", exact: true }).waitFor();
await p.getByRole("button", { name: "取消生成", exact: true }).click();
await wait(async () =>
(await api("/api/playground/jobs")).some(
(j) =>
j.workflow_id === f.dual &&
j.status === "failed" &&
j.error === "cancelled",
),
);
ok("进行中生成可以取消", true);
mode();
// Pending rows are hidden by default and point to the actual mapping/testing row.
const w = await api("/api/workflows/" + f.dual);
const pending = await api("/api/workflows", {
name: "待配置测试工具",
kind: "image",
workflow_json: w.workflow_json,
});
await api("/api/workflows/" + pending.id, { is_enabled: false }, "PATCH");
await p.goto(WEB + "/create");
await p.locator("#desk-prompt").waitFor();
await p.locator(".rail-tools input[type=search]").fill("待配置测试");
ok("默认仅显示可运行", (await p.locator(".workflow-pick").count()) === 0);
await p.getByRole("checkbox", { name: "仅可运行" }).uncheck();
await p
.locator(".workflow-pick")
.filter({ hasText: "待配置测试工具" })
.click();
await p.waitForURL("**/workflows?manage=1&wf=" + pending.id);
ok(
"待配置导向真实管理和测试入口",
await p.locator(".wf-test-btn").first().isVisible(),
);
// Source project round-trip uses original API and explicit attachment.
await api(
"/api/workbench/projects/" + f.project + "/stages/assets",
{
content: {
assets: [
{
id: "b1-source",
kind: "character",
name: "海边女孩",
prompt: "源项目提示词",
description: "",
},
],
},
},
"PATCH",
);
const q = new URLSearchParams({
kind: "image",
wf: String(f.dual),
from: "workbench",
project: String(f.project),
stage: "assets",
source_kind: "asset",
source_id: "b1-source",
label: "海边女孩",
prompt: "源项目提示词",
});
await p.goto(WEB + "/create?" + q);
await p.locator("#desk-prompt").waitFor();
const flow = await submit(p, f.dual);
ok(
"来源随真实生成请求发送",
flow.payload?.workbench_context?.source_id === "b1-source" ||
JSON.stringify(flow.payload).includes("b1-source"),
);
await p
.getByRole("button", { name: "保存到来源项目 ↗", exact: true })
.click();
await wait(async () =>
JSON.stringify(
await api("/api/workbench/projects/" + f.project),
).includes(flow.output_paths[0]),
);
ok("结果显式回流来源项目", true);
// Create a deterministic portrait fixture locally (no external image request).
const fixture = await c.newPage();
await fixture.setViewportSize({ width: 480, height: 720 });
await fixture.setContent(
'<body style="margin:0;background:linear-gradient(160deg,#213b42,#d9a86c);height:100vh;display:grid;place-items:center"><div style="width:220px;height:360px;background:#192a32;border-radius:50% 50% 10% 10%;box-shadow:0 0 80px #ecbd7b"></div></body>',
);
await fixture.screenshot({ path: out + "/portrait.png" });
await fixture.close();
ok("竖图测试素材为480×720", await fixtureDimensions(out + "/portrait.png"));
// Portrait & landscape outputs in the real application.
for (const file of ["coast.jpg", "portrait.png"]) {
const img = path.resolve(
file === "portrait.png"
? out + "/portrait.png"
: "calliope-web/static/refract/" + file,
);
mode(false, { image_file: img });
const before = Math.max(
...(await api("/api/playground/jobs")).map((j) => j.id),
);
await p.locator(".gen").click();
const image = await wait(async () =>
(await api("/api/playground/jobs")).find(
(j) => j.id > before && j.status === "done",
),
);
await p
.locator(".result-title")
.filter({ hasText: "第 " + image.id + " 次生成" })
.waitFor();
await p.locator(".print img").waitFor();
for (const [width, height] of [
[1440, 900],
[1366, 768],
[1280, 720],
]) {
await p.setViewportSize({ width, height });
await p.waitForTimeout(120);
ok(
width + " " + file + " 媒体比例正确",
await p.locator(".print img").evaluate((e) => {
const r = e.getBoundingClientRect();
return (
Math.abs(r.width / r.height - e.naturalWidth / e.naturalHeight) <
0.02 &&
r.bottom <= innerHeight &&
r.top >= 0
);
}),
);
await shot(p, width + "-" + file);
}
}
await p.goto(
WEB + "/playground?kind=video&wf=" + f.h3 + "&from=test#legacy",
);
await p.locator("#desk-prompt").waitFor();
ok(
"试验台兼容入口保留参数及片段",
new URL(p.url()).pathname === "/create" &&
new URL(p.url()).searchParams.get("from") === "test" &&
new URL(p.url()).hash === "#legacy",
);
const blocked = await browser.newContext({
viewport: { width: 1280, height: 720 },
});
await blocked.addInitScript(() => {
Storage.prototype.getItem = function () {
throw Error("denied");
};
Storage.prototype.setItem = function () {
throw Error("denied");
};
});
const bp = await blocked.newPage();
await bp.goto(WEB + "/create?wf=" + f.dual);
await bp.locator("#desk-prompt").fill("存储禁止也保留草稿");
await bp.getByRole("tab", { name: "视频", exact: true }).click();
await bp.locator("#desk-prompt").fill("视频草稿");
await bp.getByRole("tab", { name: "图片", exact: true }).click();
await bp.locator("#desk-negative").waitFor();
ok(
"浏览器存储被禁止时仍可切换并恢复草稿",
(await bp.locator("#desk-prompt").inputValue()) === "存储禁止也保留草稿",
);
await blocked.close();
ok("没有浏览器运行异常", errors.length === 0);
fs.writeFileSync(
out + "/results.json",
JSON.stringify({ checks, errors }, null, 2),
);
} catch (e) {
if (p) await shot(p, "failure");
throw e;
} finally {
if (saved) fs.writeFileSync("e2e/fake_mode.json", saved);
else if (fs.existsSync("e2e/fake_mode.json"))
fs.unlinkSync("e2e/fake_mode.json");
await browser.close();
}
})().catch((e) => {
console.error(e);
process.exitCode = 1;
});
+44
View File
@@ -0,0 +1,44 @@
"""Run the isolated regression stack in the foreground and always stop children."""
import os, signal, socket, subprocess, time, urllib.request
from pathlib import Path
root=Path(__file__).resolve().parents[1]
os.chdir(root)
py=str(root/'calliope-backend/.venv/bin/python')
logs=root/'e2e/shots/hel729'; logs.mkdir(parents=True,exist_ok=True)
env=os.environ.copy()
env['PYTHONPATH']=str(root/'calliope-backend/src')
for port in [8247,5173,18188,18189]:
with socket.socket() as probe:
assert probe.connect_ex(('127.0.0.1',port)) != 0, f'Test port {port} is already in use'
# Every run gets fresh disposable state; retain prior evidence instead of deleting it.
reference=root/'e2e/shots/reference'
if reference.exists():reference.rename(reference.with_name('reference.previous-'+str(time.time_ns())))
processes=[]; handles=[]
try:
for name,cmd in [('api',[py,'e2e/reference/server.py']),('comfy',[py,'e2e/fake_comfy.py']),('llm',[py,'e2e/reference/fake_llm.py']),('web',['npm','--prefix',os.environ.get('HEL729_WEB_DIR','calliope-web'),'run','dev'])]:
h=open(logs/(name+'.log'),'w');handles.append(h);processes.append(subprocess.Popen(cmd,stdout=h,stderr=subprocess.STDOUT,env=env,start_new_session=True))
for url in ['http://127.0.0.1:8247/api/health','http://127.0.0.1:5173']:
for i in range(100):
try:
urllib.request.urlopen(url,timeout=2); break
except Exception:
if any(p.poll() is not None for p in processes): raise RuntimeError('A test service failed')
if i==99:raise
time.sleep(.2)
for cmd in [[py,'e2e/seed.py'],[py,'e2e/reference/seed.py']]:subprocess.run(cmd,env=env,check=True)
failures=[]
scripts=os.environ.get('HEL729_TESTS','e2e/hel729.cjs').split(',')
for script in scripts:
print('RUN',script,flush=True)
result=subprocess.run(['node',script],env=env)
if result.returncode:failures.append(script)
print('FAILED SCRIPTS',failures,flush=True)
if failures:raise SystemExit(1)
finally:
for p in processes:
if p.poll() is None:os.killpg(p.pid,signal.SIGTERM)
for p in processes:
try:p.wait(timeout=10)
except subprocess.TimeoutExpired:os.killpg(p.pid,signal.SIGKILL);p.wait()
for h in handles:h.close()