HEL-729: 建成生图生视频合一的夜片场创作台 #3

Open
leefer wants to merge 6 commits from leefer/hel-729-creative-desk AGit into feat/hel-728-night-foundation
26 changed files with 3096 additions and 350 deletions
@@ -59,6 +59,9 @@
onCancel: (job: Job) => void;
onSendToSlot: (path: string) => void;
onDelete: (job: Job) => void;
canAttach?: boolean;
attachBusy?: boolean;
onAttach?: () => void;
}
let {
@@ -80,9 +83,13 @@
onCancel,
onSendToSlot,
onDelete,
canAttach = false,
attachBusy = false,
onAttach,
}: Props = $props();
let detailsOpen = $state(false);
let deleteOpen = $state(false);
let outIndex = $state(0);
let compare = $state(false),
compareAt = $state(50),
@@ -90,6 +97,7 @@
$effect(() => {
void job?.id;
detailsOpen = false;
deleteOpen = false;
compare = false;
lightbox = false;
});
@@ -132,16 +140,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}`;
}
@@ -175,40 +194,29 @@
</script>
<div class="artboard" aria-label="生成结果">
<header class="artboard-toolbar">
<button
class="artboard-toolbar"
aria-label="作品详情与操作"
onclick={() => (detailsOpen = true)}
>
<div class="result-title">
<span>第 {job?.id} 次生成</span><small>{wf?.name ?? '历史作品'}</small>
</div>
<div class="canvas-actions">
{#if output && !video}<button
class="canvas-btn"
onclick={() => (lightbox = true)}
aria-label="放大查看">⤢</button
>
{#if compareSource && compareSource !== output}<button
class="canvas-btn"
aria-pressed={compare}
onclick={() => (compare = !compare)}
>◫ {compare ? '结束对比' : '对比'}</button
>{/if}
{/if}
{#if output}<a
class="canvas-btn"
href={assetUrl(output)}
download
aria-label="下载生成结果">↓ 下载</a
>{/if}
<button class="canvas-btn" onclick={() => (detailsOpen = true)}
>详情与操作</button
>
</div>
</header>
</button>
<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 +225,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 +242,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,19 +313,72 @@
</div>{/if}
</div>
<footer class="artboard-footer">
{#if video}<button
class="canvas-btn play-button"
onclick={togglePlayback}
aria-label={paused ? '播放视频' : '暂停视频'}
>{paused ? '▶ 播放' : 'Ⅱ 暂停'}</button
><button
class="canvas-btn"
onclick={() => videoEl?.requestFullscreen().catch(() => {})}
>全屏</button
>{/if}
<span class="result-spec"
>{dims || st?.label} · {job ? fmtDur(elapsedSec(job, nowMs)) : ''}</span
<button
class="canvas-btn"
disabled={!output}
onclick={() =>
video
? videoEl?.requestFullscreen().catch(() => {})
: (lightbox = true)}
aria-label="放大查看"><Icon name="zoom-in" size={14} />放大</button
>
<a
class="canvas-btn"
href={output ? assetUrl(output) : undefined}
aria-disabled={!output}
download
aria-label="下载生成结果"><Icon name="download" size={14} />下载</a
>
<span class="action-separator" aria-hidden="true"></span>
<button
class="canvas-btn"
disabled={!job || !output}
onclick={() => job && onReuse(job)}
><Icon name="retry" size={14} />复用参数</button
>
<button
class="canvas-btn"
disabled={!output || video || !canSendToSlot}
title={canSendToSlot
? '放入当前工作流图片输入'
: '当前工作流没有图片输入'}
onclick={() => output && onSendToSlot(output)}
><Icon name="image" size={14} />作为参考图</button
>
<button
class="canvas-btn"
disabled
title="跨工作流送图尚未具备,请选择生视频工作流后从已有结果选取"
><Icon name="film" size={14} />送去生视频</button
>
<span class="action-separator" aria-hidden="true"></span>
<button
class="canvas-btn"
disabled={!canAttach || attachBusy}
title={canAttach
? '保存到来源项目'
: '通用选择项目与阶段将在后续批次提供'}
onclick={onAttach}><Icon name="folder" size={14} />存入项目</button
>
<a class="canvas-btn" href="/assets"
><Icon name="assets" size={14} />素材库</a
>
<button
class="canvas-btn delete-output"
aria-label="删除作品记录"
title="删除作品记录"
disabled={!job || job.status === 'pending' || job.status === 'running'}
onclick={() => (deleteOpen = true)}
><Icon name="close" size={14} /></button
>
{#if job?.status === 'pending' || job?.status === 'running'}<button
class="canvas-btn"
onclick={() => job && onCancel(job)}>取消生成</button
>{/if}
{#if st?.tone === 'fail'}<button
class="canvas-btn"
onclick={() => job && onRetry(job)}>重试</button
>{/if}
{#if compare && output && !video}<label class="compare-slider"
>参考<input
type="range"
@@ -309,19 +388,41 @@
aria-label="拖动对比画面"
/>结果</label
>{/if}
{#if job?.status === 'pending' || job?.status === 'running'}<button
class="canvas-btn"
onclick={() => job && onCancel(job)}>取消生成</button
>{/if}
{#if st?.tone === 'fail'}<button
class="canvas-btn"
onclick={() => job && onRetry(job)}>重试</button
>{/if}
</footer>
</div>
<Modal bind:open={detailsOpen} size="lg" title="作品详情与操作">
{#if job && st}
<div class="detail-actions">
{#if video}<button
class="canvas-btn play-button"
onclick={togglePlayback}
aria-label={paused ? '播放视频' : '暂停视频'}
>{paused ? '▶ 播放' : 'Ⅱ 暂停'}</button
><button
class="canvas-btn"
onclick={() => videoEl?.requestFullscreen().catch(() => {})}
>全屏</button
>{/if}
<span class="result-spec"
>{dims || st?.label} · {job ? fmtDur(elapsedSec(job, nowMs)) : ''}</span
>
{#if compare && output && !video}<label class="compare-slider"
>参考<input
type="range"
min="0"
max="100"
bind:value={compareAt}
aria-label="拖动对比画面"
/>结果</label
>{/if}
{#if output && !video && compareSource && compareSource !== output}<button
class="canvas-btn"
aria-pressed={compare}
onclick={() => {
compare = !compare;
detailsOpen = false;
}}>◫ {compare ? '结束对比' : '对比'}</button
>{/if}
<button
class="a-btn secondary sm"
onclick={() => {
@@ -348,7 +449,7 @@
{#if job.status !== 'pending' && job.status !== 'running'}<button
class="a-btn danger sm"
onclick={() => {
onDelete(job);
deleteOpen = true;
detailsOpen = false;
}}>移除记录</button
>{/if}
@@ -409,6 +510,23 @@
</dl>
{/if}
</Modal>
<Modal bind:open={deleteOpen} title="删除作品记录" size="md">
<p>
确认移除这条生成记录及生成文件?关联项目中的回流记录也会移除。确认后有 5
秒可撤销,提交后无法恢复。
</p>
{#snippet footer()}<button
class="a-btn secondary"
onclick={() => (deleteOpen = false)}>取消</button
>
<button
class="a-btn danger"
onclick={() => {
if (job) onDelete(job);
deleteOpen = false;
}}>确认移除</button
>{/snippet}
</Modal>
{#if lightbox && output && !video}<ImageLightbox
src={assetUrl(output) ?? ''}
alt={prompt || '创作结果'}
@@ -417,13 +535,14 @@
<style>
.artboard {
position: relative;
display: grid;
grid-template-rows: 38px minmax(0, 1fr) 32px;
grid-template-rows: 0 minmax(0, 1fr) 40px;
height: 100%;
min-height: 0;
border: 1px solid #ffffff0d;
border-radius: 12px;
background: radial-gradient(ellipse at 50% 20%, #202028, #131316 70%);
border: 0;
border-radius: 0;
background: transparent;
overflow: hidden;
}
.artboard-toolbar,
@@ -431,10 +550,24 @@
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
gap: 6px;
padding: 0 12px;
min-width: 0;
}
.artboard-toolbar {
position: absolute;
top: 10px;
left: 10px;
z-index: 2;
border-radius: 4px;
background: var(--media-scrim);
color: var(--on-media);
padding: 2px 6px;
max-width: calc(100% - 20px);
border: 0;
cursor: pointer;
text-align: left;
}
.result-title {
display: flex;
align-items: center;
@@ -451,25 +584,46 @@
text-overflow: ellipsis;
max-width: 220px;
}
.canvas-actions {
display: flex;
gap: 4px;
flex-shrink: 0;
}
.canvas-btn {
background: #ffffff04;
display: inline-flex;
align-items: center;
gap: 6px;
height: 26px;
box-sizing: border-box;
background: var(--bg-2);
color: var(--text-2);
border: 1px solid #ffffff0a;
border-radius: 6px;
padding: 5px 8px;
font-size: 10px;
border: 1px solid var(--line);
border-radius: var(--desk-action-radius);
padding: 0 9px;
font-size: 12px;
font-weight: 500;
cursor: pointer;
text-decoration: none;
white-space: nowrap;
}
.action-separator {
width: 1px;
height: 18px;
flex-shrink: 0;
margin: 0 4px;
background: var(--line-2);
}
.delete-output {
width: 26px;
padding: 0;
justify-content: center;
color: var(--err);
background: transparent;
border-color: transparent;
}
.canvas-btn:disabled,
.canvas-btn[aria-disabled='true'] {
opacity: 0.45;
cursor: not-allowed;
}
.canvas-btn:hover {
color: var(--text);
background: #ffffff0a;
background: var(--bg-3);
}
.canvas-btn[aria-pressed='true'],
.play-button {
@@ -477,6 +631,7 @@
border-color: var(--accent-line);
}
.stage {
grid-row: 2;
min-height: 0;
min-width: 0;
position: relative;
@@ -485,7 +640,7 @@
.stage-media {
position: absolute;
inset: 0;
padding: 8px 20px;
padding: 0;
display: grid;
place-items: center;
min-height: 0;
@@ -503,6 +658,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 +709,7 @@
.comparison-layer {
position: absolute;
inset: 0;
background: #17171d;
background: var(--bg-0);
pointer-events: none;
}
.comparison-divider {
@@ -552,7 +721,10 @@
pointer-events: none;
}
.artboard-footer {
justify-content: flex-start;
grid-row: 3;
justify-content: center;
gap: 6px;
padding: 0;
font-size: 10px;
color: var(--text-3);
}
@@ -578,7 +750,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,13 +1,16 @@
<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';
import { usageLabel } from '$lib/components/generate/schema';
import { spotlight } from '$lib/actions/spotlight';
import { toast } from '$lib/toast';
import Icon from '$lib/components/ui/Icon.svelte';
let {
kind,
initialUsage = 'all',
metas,
selectedId,
pendingCount,
@@ -15,9 +18,14 @@
onKind,
onDetails,
disabled = false,
historyCollapsed = false,
onToggleHistory,
}: {
disabled?: boolean;
historyCollapsed?: boolean;
onToggleHistory: () => void;
kind: 'image' | 'video';
initialUsage?: string;
metas: WorkflowMeta[];
selectedId: number | null;
pendingCount: number;
@@ -25,11 +33,47 @@
onKind: (kind: 'image' | 'video') => void;
onDetails: () => void;
} = $props();
let searchInput: HTMLInputElement;
let searchOrigin: HTMLElement | null = null;
function searchKeydown(e: KeyboardEvent) {
if (e.defaultPrevented || e.isComposing || e.keyCode === 229) return;
if (
[
...document.querySelectorAll('.shell-pop, .modal, [role="dialog"]'),
].some((el) => el.getClientRects().length)
)
return;
if (e.key === 'Escape' && e.target === searchInput) {
e.preventDefault();
const origin = searchOrigin?.isConnected
? searchOrigin
: searchInput.closest('aside')?.querySelector('button');
if (origin instanceof HTMLElement) origin.focus();
else searchInput.blur();
return;
}
const el = e.target instanceof HTMLElement ? e.target : null;
if (
e.key !== '/' ||
e.ctrlKey ||
e.metaKey ||
e.altKey ||
el?.isContentEditable ||
el?.closest(
'input, textarea, select, [contenteditable]:not([contenteditable="false"])',
) ||
!searchInput?.getClientRects().length
)
return;
e.preventDefault();
searchInput.focus();
}
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,10 +95,18 @@
metas
.filter(
(m) =>
(!runnableOnly || runnable(m)) &&
(!favOnly || m.favorite) &&
(usage === 'all' || m.usage === usage) &&
(family === 'all' || m.model_family === family) &&
[m.name, m.description, m.model_family, ...(m.tags ?? [])]
[
m.name,
m.description,
m.source_path,
m.usage ? usageLabel(m.usage) : null,
m.model_family,
...(m.tags ?? []),
]
.join(' ')
.toLowerCase()
.includes(search.trim().toLowerCase()),
@@ -63,6 +115,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 +133,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) =>
@@ -92,50 +159,89 @@
});
</script>
<svelte:window onkeydown={searchKeydown} />
<aside class="workflow-rail" aria-label="创作工作流列表">
<header>
<div><strong>创作台</strong><small>选择工作流,开始创作</small></div>
<strong>创作台</strong><button
class="history-toggle a-btn ghost sm"
onclick={onToggleHistory}
aria-label={historyCollapsed ? '展开历史栏' : '收起历史栏'}
aria-expanded={!historyCollapsed}>◫</button
>
</header>
<div class="rail-tools">
<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>
<input
type="search"
bind:value={search}
aria-label="搜索工作流"
placeholder="搜索工作流或用途…"
/>
<div class="filters">
<select aria-label="筛选工作流用途" bind:value={usage}
><option value="all">所有用途</option>{#each usages as u}<option
value={u!}>{usageLabel(u!)}</option
>{/each}</select
><select aria-label="筛选模型家族" bind:value={family}
><option value="all">全部模型</option>{#each families as f}<option
value={f!}>{f}</option
>{/each}</select
<div class="rail-search">
<Icon name="search" size={14} />
<input
type="search"
bind:this={searchInput}
onfocus={(e) => {
searchOrigin =
e.relatedTarget instanceof HTMLElement ? e.relatedTarget : null;
}}
bind:value={search}
aria-label="搜索工作流"
placeholder="搜用途、模型或文件名"
/>
<kbd>/</kbd>
</div>
<div class="filters" role="group" aria-label="筛选工作流用途">
<button aria-pressed={usage === 'all'} onclick={() => (usage = 'all')}
>全部</button
>
{#each usages as u}<button
aria-pressed={usage === u}
onclick={() => (usage = u!)}>{usageLabel(u!)}</button
>{/each}
<button aria-pressed={favOnly} onclick={() => (favOnly = !favOnly)}
>☆ 收藏</button
>
</div>
{#if families.length}<div
class="filters"
role="group"
aria-label="筛选模型家族"
>
<button aria-pressed={family === 'all'} onclick={() => (family = 'all')}
>全部模型</button
>
{#each families as f}<button
aria-pressed={family === f}
onclick={() => (family = f!)}>{f}</button
>{/each}
</div>{/if}
<div class="list-meta">
<span title="目录接口不提供仓库文件总数"
>{filtered.length} 个 · 仓库文件数未知</span
>
<label class="runnable-filter"
><input type="checkbox" bind:checked={runnableOnly} />仅可运行</label
>
</div>
</div>
<div class="list-meta">
<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 +249,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
@@ -180,7 +289,7 @@
<footer>
{#if pendingCount}<a href="/workflows?manage=1"
>{pendingCount} 个工作流待配置 →</a
>{/if}<button class="manage" onclick={onDetails}
>{/if}<button class="manage" data-workflow-details onclick={onDetails}
><span>+</span>
<div>工作流说明与管理<small>输入要求 / 导入 / 分类</small></div>
<b>›</b></button
@@ -190,96 +299,172 @@
<style>
.workflow-rail {
width: 232px;
box-sizing: border-box;
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 {
padding: 21px 18px 16px;
border-bottom: 1px solid var(--line);
padding: 12px 12px 0;
height: var(--desk-rail-title-height);
box-sizing: border-box;
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: space-between;
}
header strong {
font-size: 18px;
font-size: 14px;
font-weight: 600;
display: block;
}
header small {
display: block;
font-size: 11px;
color: var(--text-3);
margin-top: 6px;
}
.rail-tools {
padding: 14px 12px 7px;
padding: var(--desk-rail-gap) 12px;
border-bottom: 1px solid var(--line);
}
.kind-tabs {
display: flex;
gap: 3px;
background: #09090c;
border: 1px solid var(--line);
border-radius: 9px;
gap: 2px;
padding: 3px;
height: 36px;
box-sizing: border-box;
border: 1px solid var(--line);
border-radius: var(--desk-seg-radius);
background: var(--well);
}
.kind-tabs button {
flex: 1;
height: 28px;
padding: 0 12px;
font-size: 12.5px;
border: 0;
padding: 7px 3px;
background: none;
color: var(--text-3);
font-size: 12px;
border-radius: 6px;
border-radius: var(--desk-seg-selected-radius);
background: transparent;
color: var(--tx-2);
cursor: pointer;
}
.kind-tabs [aria-selected='true'] {
color: #062f35;
background: var(--accent);
font-weight: 600;
background: var(--bg-3);
box-shadow: var(--sh-1);
color: var(--tx-1);
}
.rail-tools input {
width: 100%;
margin-top: 10px;
font-size: 11px;
padding: 9px 10px;
background: #1d1b16;
border: 1px solid #f5b04228;
border-radius: 8px;
.kind-tabs button:disabled {
cursor: wait;
}
.rail-search {
display: flex;
align-items: center;
gap: 8px;
margin-top: var(--desk-rail-gap);
height: var(--desk-search-height);
box-sizing: border-box;
font-size: var(--desk-search-size);
padding: 0 10px;
background: var(--well);
border: 1px solid var(--line-2);
border-radius: var(--desk-search-radius);
color: var(--text);
}
.rail-search input {
min-width: 0;
flex: 1;
padding: 0;
border: 0;
background: none;
color: inherit;
font: inherit;
outline: none;
}
.rail-search kbd {
font: var(--desk-search-key-font);
padding: var(--desk-search-key-padding);
border-radius: var(--desk-search-key-radius);
border: 1px solid var(--line-2);
color: var(--tx-3);
background: var(--bg-1);
}
.rail-search:focus-within {
border-color: var(--scene);
}
.filters {
display: flex;
gap: 5px;
flex-wrap: wrap;
gap: 6px;
margin-top: 8px;
}
.filters select {
width: 50%;
min-width: 0;
.filters button {
height: var(--desk-filter-height);
padding: 0 9px;
font-size: var(--desk-filter-size);
border-radius: var(--desk-filter-radius);
border: 1px solid var(--line);
background: var(--bg-3);
border-radius: 5px;
padding: 5px;
font-size: 10px;
color: var(--text-3);
background: var(--bg-2);
color: var(--tx-2);
cursor: pointer;
}
.filters button[aria-pressed='true'] {
color: var(--tx-1);
border-color: color-mix(in srgb, var(--scene) 60%, transparent);
background: color-mix(in srgb, var(--scene) 13%, var(--bg-2));
}
.runnable-filter {
display: flex;
align-items: center;
gap: 6px;
white-space: nowrap;
cursor: pointer;
}
.runnable-filter input {
margin: 0;
appearance: none;
width: var(--desk-switch-width);
height: var(--desk-switch-height);
border: 0;
border-radius: var(--r-pill);
background: var(--bg-4);
position: relative;
cursor: pointer;
flex-shrink: 0;
}
.runnable-filter input::after {
content: '';
position: absolute;
left: 2px;
top: 2px;
width: var(--desk-switch-knob);
height: var(--desk-switch-knob);
border-radius: 50%;
background: var(--tx-2);
transition: transform var(--t-base);
}
.runnable-filter input:checked {
background: color-mix(in srgb, var(--scene) 55%, var(--bg-4));
}
.runnable-filter input:checked::after {
transform: translateX(11px);
background: var(--on-media);
}
.pending {
color: var(--warn);
}
.list-meta {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
padding: 8px 16px;
font-size: 10px;
color: var(--text-3);
gap: 6px;
padding: 8px 2px 0;
font-size: var(--desk-filter-size);
color: var(--tx-3);
}
.list-meta button {
border: 0;
background: none;
color: var(--text-3);
font-size: 10px;
cursor: pointer;
}
.list-meta button[aria-pressed='true'] {
color: var(--warn);
.family-label {
font-size: 11px;
color: var(--tx-3);
padding: 8px;
}
.rail-list {
flex: 1;
@@ -297,8 +482,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 +495,7 @@
height: 22px;
position: absolute;
left: 0;
background: var(--accent);
background: var(--scene);
border-radius: 3px;
}
.workflow-pick {
@@ -330,9 +519,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 +572,7 @@
opacity: 1;
}
.workflow-row:hover {
background-color: #ffffff05;
background-color: var(--bg-2);
}
.empty {
color: var(--text-3);
@@ -434,7 +623,7 @@
}
@media (max-width: 1100px) {
.workflow-rail {
width: 210px;
width: var(--list);
}
}
</style>
@@ -0,0 +1,157 @@
<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,
collapsed = false,
}: {
collapsed?: boolean;
jobs: Job[];
selectedId: number | null;
onSelect: (job: Job) => void;
onExpand: () => void;
} = $props();
const sorted = $derived([...jobs].sort((a, b) => b.id - a.id));
</script>
<aside class="history-rail" class:collapsed aria-label="生成历史">
<header>历史</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 {
box-sizing: border-box;
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: 0;
border: 0;
overflow: hidden;
visibility: hidden;
}
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';
@@ -263,6 +265,8 @@
let paramEndpoint = $state('');
let paramShowErrors = $state(false);
let historyOpen = $state(false);
let historyCollapsed = $state(false);
let dockAnchor = $state<HTMLElement | null>(null);
let focusMode = $state(false);
let bayActiveId = $state<number | null>(null);
let bay = $state<WorkflowBay | null>(null);
@@ -276,7 +280,9 @@
function closeBay() {
bayOpen = false;
void tick().then(() =>
document.querySelector<HTMLButtonElement>('.model-selector')?.focus(),
document
.querySelector<HTMLButtonElement>('[data-workflow-details]')
?.focus(),
);
}
@@ -286,6 +292,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 +315,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 +345,6 @@
draftContext?.projectId,
draftContext?.stage,
draftContext?.sourceId ?? draftContext?.label,
page.url.searchParams.get('wf'),
]),
);
const sourceFingerprint = JSON.stringify([
@@ -343,9 +352,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 +380,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 +411,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 +477,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 +515,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 +523,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 +703,7 @@
return;
}
const wf = currentWorkflow!;
historyJobId = null;
submitting = true;
try {
const res = await playgroundApi.generate({
@@ -710,6 +743,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 +781,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 +801,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 +877,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 +933,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 +1184,17 @@
{/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}
{historyCollapsed}
onToggleHistory={() => (historyCollapsed = !historyCollapsed)}
metas={allMetas.filter((m) => m.kind === kind)}
selectedId={workflowId}
{pendingCount}
onPick={pickWorkflow}
@@ -1117,28 +1204,53 @@
/>
<div class="refract-studio">
<header class="studio-heading">
<div class="heading-actions">
<button class="a-btn ghost sm" onclick={() => (historyOpen = true)}
>◷ 生成历史</button
><button id="desk-wf" class="model-selector" onclick={openBay}
>{currentWorkflow?.name ?? currentMeta?.name ?? '选择工作流'} ⌄</button
<div class="heading-actions workflow-identity">
<span class="fam-dot"
>{currentMeta?.model_family?.slice(0, 3) ??
(kind === 'image' ? '◈' : '▷')}</span
>
</div>
<div class="heading-actions">
{#if selectedJob && workbenchCtxOf(selectedJob) && selectedJob.status === 'done'}<button
class="a-btn secondary sm"
disabled={attachBusyId === selectedJob.id}
onclick={() => selectedJob && attachToProject(selectedJob)}
>保存到来源项目 ↗</button
<b id="desk-wf" class="model-selector"
>{currentWorkflow?.name ?? currentMeta?.name ?? '选择工作流'}</b
>
{#if currentMeta?.usage}<span class="a-tag"
>{usageLabel(currentMeta.usage)}</span
>{/if}
<button class="a-btn secondary sm" onclick={openParams}
>创作设置</button
><button class="a-btn ghost sm" onclick={toggleInspector}
>{focusMode ? '退出专注' : '专注创作'} ⤢</button
</div>
<span
class="workflow-file"
title={currentMeta?.source_path ?? '此工作流未记录源文件名'}
>{currentMeta?.source_path?.replaceAll('\\', '/').split('/').pop() ??
'源文件名未知'}</span
>
<div class="heading-actions">
<span
class="connection-status"
class:failed={connState.state === 'fail'}
>
<i class:connected={connState.state === 'ok'}></i>
{endpoints.find((e) => e.id === endpointId)?.name ?? '未选择连接'} · {connState.state ===
'ok' && connState.ms != null
? `${connState.ms}ms`
: connState.state === 'fail'
? '连接失败'
: '延迟未知'}
</span>
</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">
@@ -1157,6 +1269,11 @@
open={true}
inactive={false}
canSendToSlot={!!imageSlotTarget || !!imageList}
canAttach={!!selectedJob &&
!!workbenchCtxOf(selectedJob) &&
selectedJob.status === 'done'}
attachBusy={attachBusyId === selectedJob?.id}
onAttach={() => selectedJob && attachToProject(selectedJob)}
{crossfade}
bind:videoEl
params={paramsTab}
@@ -1186,7 +1303,11 @@
</div>{/if}
</div>
</section>
<aside class="studio-form" aria-label="提示词、素材与参数">
<aside
bind:this={dockAnchor}
class="studio-form"
aria-label="提示词、素材与参数"
>
<Composer
bind:this={composer}
dock
@@ -1219,6 +1340,13 @@
</aside>
</div>
</div>
<HistoryRail
collapsed={historyCollapsed}
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="工作流说明与管理"
@@ -1332,17 +1460,25 @@
</Modal>
<Modal
bind:open={paramsOpen}
anchor={dockAnchor}
size="lg"
title="创作设置"
onclose={() => (paramDraft = null)}
>
{#snippet headerActions()}<button
class="a-btn ghost sm"
onclick={resetParamDraft}>恢复工作流默认</button
>{/snippet}
{#snippet footer()}<div class="param-actions">
<span>保存后应用到当前工作流</span><button
class="a-btn secondary"
onclick={() => (paramsOpen = false)}>取消</button
><button class="a-btn purple" onclick={saveParams}>保存设置</button>
><button class="a-btn primary" onclick={saveParams}>保存设置</button>
</div>{/snippet}
<div class="reference-preferences">
<button class="a-btn ghost sm" onclick={toggleInspector}
>{focusMode ? '退出专注' : '专注创作'} ⤢</button
>
<div class="runtime-setting">
<label for="comfy-endpoint">使用哪台 ComfyUI</label><select
class="a-select"
@@ -1407,9 +1543,40 @@
justify-content: space-between;
align-items: center;
gap: 8px;
height: 44px;
height: var(--desk-head-height);
margin: 0 calc(-1 * var(--desk-head-inset));
padding: 0 var(--desk-head-inset);
box-sizing: border-box;
border-bottom: 1px solid var(--line);
background: var(--desk-head-bg);
backdrop-filter: blur(10px);
flex-shrink: 0;
}
@media (max-width: 1366px) {
.workflow-file {
display: none;
}
}
.history-banner {
display: flex;
align-items: center;
gap: 8px;
flex-shrink: 0;
padding: 6px 8px;
border: 1px solid var(--info);
border-radius: var(--r-pill);
color: var(--info);
font-size: 12.5px;
background: color-mix(in srgb, var(--bg-2) 92%, transparent);
backdrop-filter: blur(8px);
box-shadow: var(--sh-2);
align-self: center;
max-width: 100%;
box-sizing: border-box;
}
.history-banner span {
margin-right: auto;
}
.heading-actions {
display: flex;
align-items: center;
@@ -1420,22 +1587,71 @@
border: 0;
background: none;
color: var(--text-2);
padding: 6px 8px;
font-size: 12px;
cursor: pointer;
padding: 0;
font-size: var(--desk-head-name-size);
font-weight: var(--desk-head-name-weight);
line-height: var(--desk-head-name-line);
max-width: 320px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.studio-heading :global(.a-btn) {
min-height: 28px;
font-size: 11px;
padding: 4px 8px;
.workflow-identity {
flex-shrink: 1;
}
.fam-dot {
width: 22px;
height: 22px;
flex-shrink: 0;
border-radius: var(--desk-search-radius);
display: grid;
place-items: center;
font: 700 9px var(--mono);
color: var(--bg-1);
background: var(--scene);
}
.workflow-file {
font: 11px var(--mono);
color: var(--tx-3);
max-width: 260px;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
margin-right: auto;
}
.connection-status {
display: inline-flex;
align-items: center;
gap: 6px;
font-size: 12px;
height: 26px;
padding: 0 10px;
border-radius: var(--desk-connection-radius);
border: 1px solid var(--line);
background: var(--bg-2);
color: var(--tx-2);
white-space: nowrap;
max-width: 260px;
overflow: hidden;
text-overflow: ellipsis;
}
.connection-status i {
width: 6px;
height: 6px;
border-radius: 50%;
background: var(--tx-3);
flex-shrink: 0;
}
.connection-status i.connected {
background: var(--ok);
}
.connection-status.failed i {
background: var(--err);
}
.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 +1678,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,12 +1758,9 @@
}
@media (max-height: 740px) {
.studio-layout {
grid-template-rows: minmax(0, 1fr) 172px;
grid-template-rows: minmax(0, 1fr) auto;
gap: 10px;
}
.refract-studio {
padding-bottom: 10px;
}
}
@media (max-width: 1100px) {
.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,20 +190,36 @@
</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}
<button class="expand-prompt manage-media" onclick={openMedia}
>{slotBusy ? '正在上传…' : '管理素材'}</button
>
{:else}<small>描述你的画面</small>{/if}
<small class="input-shortcut">Ctrl + Enter 生成</small>
{/if}
</div>{/if}
<div class="prompt-pair" class:dual={dock && !!ds.negative}>
{#if ds.prompt}<div class="prompt-field">
<label for="desk-prompt"
>{ds.negative ? '正向提示词' : '提示词'}
{#if ds.prompt.required}<span>必填</span>{/if}</label
><textarea
<div class="prompt-label">
<label for="desk-prompt"
>{ds.negative ? '正向提示词' : '提示词'}
{#if ds.prompt.required}<span>必填</span>{/if}</label
>
<button
class="expand-prompt"
aria-expanded={expanded}
onclick={() => (expanded = !expanded)}
>{expanded ? '收起提示词' : '展开提示词'}</button
>
</div>
<textarea
bind:this={promptEl}
id="desk-prompt"
class="prompt"
@@ -333,7 +353,7 @@
disabled={!!block || submitting || slotBusy}
onclick={onSubmit}
>
<Icon name="sparkle" size={16} />{submitting
<Icon name="sparkle" size={14} />{submitting
? '正在提交…'
: slotBusy
? '正在上传素材…'
@@ -482,7 +502,7 @@
background: var(--surface);
display: grid;
gap: 10px;
box-shadow: 0 -8px 20px #292a2d05;
box-shadow: var(--sh-1);
}
.gen {
width: 100%;
@@ -520,24 +540,26 @@
}
.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(--desk-dock-radius);
background: var(--composer-bg);
overflow: hidden;
box-shadow: inset 0 0 0 1px #f5b04206;
box-shadow: var(--desk-dock-shadow);
}
.dock .composer:focus-within {
border-color: #f5b04280;
border-color: color-mix(in srgb, var(--gold) 45%, transparent);
box-shadow: var(--desk-dock-focus);
}
.dock .composer-scroll {
padding: 9px 14px 4px;
overflow: hidden;
scrollbar-gutter: auto;
padding: 0;
overflow: auto;
display: flex;
flex-direction: column;
}
@@ -557,50 +579,65 @@
display: flex;
align-items: center;
gap: 6px;
height: 28px;
margin-bottom: 6px;
min-height: 0;
margin: 0;
flex-shrink: 0;
overflow: auto;
}
.media-summary > small {
color: var(--text-3);
font-size: 10px;
white-space: nowrap;
.dock .input-section.has-slots {
display: grid;
grid-template-columns: minmax(0, var(--desk-slot-column)) minmax(0, 1fr);
gap: 0;
}
.has-slots .media-summary {
align-self: start;
flex-wrap: wrap;
margin: 0;
padding: 10px 12px 8px;
border-right: 1px solid var(--line);
}
.media-token {
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;
grid-template-columns: minmax(0, 1fr);
min-height: 0;
flex: 1;
gap: 18px;
gap: 0;
}
.dock .prompt-pair.dual {
grid-template-columns: minmax(0, 1fr) minmax(0, 0.65fr);
grid-template-columns: minmax(0, 1.35fr) minmax(0, 1fr);
}
.dock .prompt-field {
padding: 10px 14px 6px;
display: flex;
flex-direction: column;
min-height: 0;
@@ -608,32 +645,41 @@
}
.dock .negative {
border-left: 1px solid var(--line);
padding-left: 18px;
}
.dock .prompt-field label {
font-size: 10px;
font-size: 11.5px;
height: 20px;
color: var(--text-3);
margin: 0 0 4px;
margin: 0;
display: flex;
gap: 6px;
}
.prompt-label {
display: flex;
align-items: center;
justify-content: space-between;
height: 20px;
}
.dock .prompt-field:first-child label {
color: #d0b37d;
color: var(--tx-2);
}
.dock .prompt {
background: none;
border: 0;
border-radius: 0;
padding: 0;
font-size: 12px;
font-size: var(--fs-body);
line-height: 1.6;
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;
}
@@ -645,15 +691,18 @@
margin-top: 3px;
}
.dock .prompt-counter {
display: none;
height: 22px;
align-items: center;
font-size: 11px;
margin-top: 4px;
}
.dock .submit {
display: flex;
flex-wrap: nowrap;
align-items: center;
gap: 8px;
padding: 7px 14px;
height: 45px;
padding: 8px 10px 10px 12px;
height: 57px;
box-sizing: border-box;
background: none;
border-top: 1px solid var(--line);
@@ -668,12 +717,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,20 +731,18 @@
flex-shrink: 0;
}
.parameter:hover {
border-color: #f5b04255;
border-color: var(--line-3);
}
.dock .gen {
width: auto;
min-height: 32px;
font-size: 11px;
padding: 7px 18px;
border-radius: 20px;
width: var(--desk-gen-width);
height: var(--desk-gen-height);
min-height: var(--desk-gen-height);
font-size: 14px;
font-weight: var(--desk-gen-weight);
padding: 0 16px;
border-radius: var(--desk-gen-radius);
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 +751,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);
@@ -12,6 +12,9 @@
onclose?: () => void;
footer?: Snippet;
children: Snippet;
/** Open above a dock, retaining the shared dialog focus and keyboard behavior. */
anchor?: HTMLElement | null;
headerActions?: Snippet;
}
let {
@@ -22,6 +25,8 @@
onclose,
footer,
children,
anchor = null,
headerActions,
}: Props = $props();
const FOCUSABLE =
@@ -30,6 +35,28 @@
const titleId = `modal-title-${Math.random().toString(36).slice(2, 9)}`;
let panel = $state<HTMLDivElement | null>(null);
let anchorBox = $state({ left: 0, bottom: 0, width: 620, available: 560 });
$effect(() => {
if (!open || !anchor) return;
const source = anchor;
const place = () => {
const r = source.getBoundingClientRect();
anchorBox = {
left: r.left + 12,
bottom: innerHeight - r.top + 8,
width: r.width - 24,
available: Math.max(100, r.top - 64),
};
};
place();
const observer = new ResizeObserver(place);
observer.observe(source);
window.addEventListener('resize', place);
return () => {
observer.disconnect();
window.removeEventListener('resize', place);
};
});
const layer = Symbol('dialog');
function requestClose() {
@@ -100,10 +127,20 @@
{#if open}
<!-- svelte-ignore a11y_click_events_have_key_events, a11y_no_static_element_interactions -->
<div class="modal-backdrop" onclick={handleBackdropClick}>
<div
class="modal-backdrop"
class:anchored={!!anchor}
onclick={handleBackdropClick}
>
<div
bind:this={panel}
class="modal-panel size-{size}"
style:left={anchor ? `${anchorBox.left}px` : undefined}
style:bottom={anchor ? `${anchorBox.bottom}px` : undefined}
style:width={anchor
? `min(var(--desk-pop-width), ${anchorBox.width}px)`
: undefined}
style:--anchor-available={`${anchorBox.available}px`}
role="dialog"
aria-modal="true"
aria-labelledby={title ? titleId : undefined}
@@ -115,6 +152,7 @@
{#if title}
<h2 class="modal-title" id={titleId}>{title}</h2>
{/if}
{@render headerActions?.()}
{#if dismissible}
<button
type="button"
@@ -168,6 +206,38 @@
.modal-panel.size-xl {
width: min(1180px, 100%);
}
.anchored {
background: transparent;
padding: 0;
}
.anchored .modal-panel {
box-sizing: border-box;
position: absolute;
height: var(--desk-pop-height);
max-height: min(64vh, var(--anchor-available));
border: 1px solid var(--line-2);
border-radius: var(--desk-dock-radius);
background: var(--desk-pop-bg);
box-shadow: var(--desk-pop-shadow);
backdrop-filter: blur(14px);
transform-origin: bottom left;
}
.anchored .modal-header {
padding: 12px 14px;
border-bottom: 1px solid var(--line);
}
.anchored .modal-title {
font-size: var(--fs-body);
}
.anchored .modal-body {
flex: 1;
min-height: 0;
padding: 12px 14px;
}
.anchored .modal-footer {
padding: 10px 14px;
border-top: 1px solid var(--line);
}
.modal-header {
display: flex;
align-items: center;
+18 -16
View File
@@ -92,14 +92,14 @@ Status → color mapping (unknown strings fall back to muted and show the raw st
`import ProgressBar from '$lib/components/ui/ProgressBar.svelte';`
| Prop | Type | Default | Notes |
| --------------- | -------------- | ------- | ----------------------------------------- |
| `value` | `number` | `0` | clamped to 0–100 |
| `indeterminate` | `boolean` | `false` | sliding accent gradient, no % readout |
| `label` | `string` | — | text above the track, also the aria-label |
| `size` | `'sm' \| 'md'` | `'md'` | sm = 4px, md = 6px track |
| `tone` | `'accent' \| 'success' \| 'error'` | `'accent'` | done = success, failed = error (§5) |
| `bare` | `boolean` | `false` | track only, no label / % row |
| Prop | Type | Default | Notes |
| --------------- | ---------------------------------- | ---------- | ----------------------------------------- |
| `value` | `number` | `0` | clamped to 0–100 |
| `indeterminate` | `boolean` | `false` | sliding accent gradient, no % readout |
| `label` | `string` | — | text above the track, also the aria-label |
| `size` | `'sm' \| 'md'` | `'md'` | sm = 4px, md = 6px track |
| `tone` | `'accent' \| 'success' \| 'error'` | `'accent'` | done = success, failed = error (§5) |
| `bare` | `boolean` | `false` | track only, no label / % row |
```svelte
<ProgressBar value={job.progress} label={job.name} />
@@ -126,14 +126,16 @@ Status → color mapping (unknown strings fall back to muted and show the raw st
`import Modal from '$lib/components/ui/Modal.svelte';`
| Prop | Type | Default | Notes |
| ------------- | ------------ | -------- | --------------------------------------------------------------------------- |
| `open` | `boolean` | `false` | **bindable** — use `bind:open` |
| `title` | `string` | — | wired to `aria-labelledby`; falls back to `aria-label="Dialog"` |
| `dismissible` | `boolean` | `true` | gates Esc, backdrop click, and the close button |
| `onclose` | `() => void` | — | fires once when the modal closes (any cause, incl. external `open = false`) |
| `footer` | `Snippet` | — | right-aligned action row |
| `children` | `Snippet` | required | body content |
| Prop | Type | Default | Notes |
| --------------- | --------------------- | -------- | -------------------------------------------------------------------------------------------- |
| `open` | `boolean` | `false` | **bindable** — use `bind:open` |
| `title` | `string` | — | wired to `aria-labelledby`; falls back to `aria-label="Dialog"` |
| `dismissible` | `boolean` | `true` | gates Esc, backdrop click, and the close button |
| `onclose` | `() => void` | — | fires once when the modal closes (any cause, incl. external `open = false`) |
| `anchor` | `HTMLElement \| null` | `null` | Optional dock anchor: opens above it, clamps to viewport, retains the dialog focus behavior. |
| `headerActions` | `Snippet` | — | Optional controls beside the title (for example restore workflow defaults). |
| `footer` | `Snippet` | — | right-aligned action row |
| `children` | `Snippet` | required | body content |
Behavior: Esc and backdrop click close when dismissible; focus is trapped while
open; initial focus goes to the first focusable element (or the panel); focus is
@@ -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>
+63 -1
View File
@@ -44,6 +44,52 @@
--fs-body: 13px;
--fs-small: 12px;
--fs-micro: 11px;
--slot-size: 56px;
--prompt-height: 60px;
--desk-dock-radius: 14px;
--desk-dock-bg: linear-gradient(
180deg,
color-mix(in srgb, var(--bg-3) 92%, transparent),
color-mix(in srgb, var(--bg-2) 96%, transparent)
);
--desk-dock-shadow: var(--sh-2);
--desk-dock-focus:
var(--sh-2), 0 0 0 3px color-mix(in srgb, var(--gold) 8%, transparent),
0 0 40px color-mix(in srgb, var(--gold) 8%, transparent);
--desk-head-height: var(--head);
--desk-head-inset: 16px;
--desk-head-bg: var(--glass);
--desk-head-name-size: 14px;
--desk-head-name-weight: 600;
--desk-head-name-line: 1.55;
--desk-search-height: 34px;
--desk-search-size: 13px;
--desk-search-key-font: 500 10.5px/1 var(--mono);
--desk-search-key-padding: 3px 5px;
--desk-search-key-radius: 4px;
--desk-search-radius: var(--r-sm);
--desk-filter-height: 24px;
--desk-filter-size: 11.5px;
--desk-filter-radius: 999px;
--desk-rail-title-height: 40px;
--desk-rail-gap: 8px;
--desk-connection-radius: var(--r-pill);
--desk-switch-width: 26px;
--desk-switch-height: 15px;
--desk-switch-knob: 11px;
--desk-action-radius: 5px;
--desk-gen-width: 131px;
--desk-gen-radius: 10px;
--desk-gen-height: 38px;
--desk-gen-weight: 700;
--desk-seg-radius: 9px;
--desk-seg-selected-radius: 7px;
--desk-slot-column: 272px;
--desk-pop-width: 620px;
--desk-pop-height: 560px;
--desk-pop-bg: color-mix(in srgb, var(--bg-2) 98%, transparent);
--desk-pop-shadow: 0 24px 60px rgba(0, 0, 0, 0.6);
--prompt-expanded: 28vh;
/* space (4 base) */
--s1: 4px;
--s2: 8px;
@@ -227,6 +273,8 @@
}
[data-theme="grade"] {
--desk-filter-radius: 3px;
--desk-connection-radius: 3px;
color-scheme: dark;
--bg-0: #1b1b1c;
--bg-1: #222223;
@@ -245,6 +293,10 @@
--r-sm: 4px;
--r-md: 6px;
--r-lg: 8px;
--desk-dock-radius: 8px;
--desk-gen-radius: 4px;
--desk-seg-radius: 4px;
--desk-seg-selected-radius: 4px;
--r-xl: 10px;
--gold: #d9dde2;
--gold-hi: #f2f4f6;
@@ -312,6 +364,7 @@
}
[data-theme="bluehour"] {
--desk-head-bg: color-mix(in srgb, #0f1838 55%, transparent);
color-scheme: dark;
--bg-0: #0a1029;
--bg-1: #0f1838;
@@ -321,6 +374,15 @@
--r-sm: 8px;
--r-md: 12px;
--r-lg: 18px;
--desk-dock-radius: 18px;
--desk-gen-radius: 999px;
--desk-gen-width: 140.7px;
--desk-action-radius: 8px;
--desk-dock-bg: linear-gradient(
180deg,
rgba(31, 44, 92, 0.82),
rgba(22, 33, 72, 0.9)
);
--r-xl: 26px;
--ink: 214 224 255;
--line: rgb(214 224 255 / 0.07);
@@ -425,7 +487,7 @@
--accent-ring: color-mix(in srgb, var(--gold) 25%, transparent);
--accent-wash: var(--accent-soft);
--btn-glow-hover: var(--sh-gold-hover);
--composer-bg: var(--glass);
--composer-bg: var(--desk-dock-bg);
--composer-focus: var(--bg-2);
--float-bg: var(--bg-2);
--on-media: #fff;
@@ -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。本批交总工独立对照样本审核,不能以自动化数量替代视觉验收。
@@ -0,0 +1,27 @@
# HEL-740 · B1 创作台样本返工
继续原 `feat/hel-729-creative-desk`,以前次审核 head `2304ab1538a4fd5f301efbf58f1a28b987b78809` 为起点;PR #3 目标仍为 `feat/hel-728-night-foundation@8e84e4c21f22febe7bd4f20777d06fa1d4914e8e`。未部署、合并或修改生成 API、数据库和正式数据。
## 实现
- `static/themes.css` 统一底座、按钮、输入与弹层 token。夜片场/日光棚按钮 131×38、r10、14px/700;调色间遵样本 r4,蓝调时刻遵样本胶囊。底座渐变、边框、阴影、距两边16/底14恢复,样本浮层圆角随主题变化。
- `Composer` 恢复60px/13px正负提示词、1.35:1比例与计数栏,去掉固定滚动槽占位。1440有媒体输入时正375.3×60、负269.7×60,对原样本375.6/270误差小于1px。长文本内部滚动/展开与真实输入要求保留。
- `Modal` 增加可选底座锚点与头部操作槽,复用原焦点层栈、Tab循环、Escape、外部点击和焦点归还。参数620×560;较小窗口按64vh与顶栏可用空间缩短、内容滚动。恢复默认只改草稿,取消丢弃、保存才应用。
- `Artboard` 恢复媒体下40px行内八入口,放大/下载/复用参数/作为参考图/送去生视频/存入项目/素材库/更多。真实来源项目回流沿用原接口;更多保留详情、对比、尾帧导出等。视频默认暂停、contain完整显示、中央64px播放按钮与仅播放器聚焦空格保留。
- `DeskWorkflowRail`/`HistoryRail` 恢复标题行历史切换、收起归零、247×36分段控件。`StudioDesk` 恢复胶囊历史提示、14px/600工作流名和头部连接状态;连接名/延迟来自真实配置与测试结果,未知显示未知。
## 验证
- `e2e/hel729.cjs` 42项全过,只按恢复后的真实入口更新记录/来源项目选择器;覆盖上传、遮罩、首尾帧、多参考、草稿隔离、历史不改输入、主动/跨媒介复用、旧入口、来源项目回流与手动播放。
- `e2e/hel740.cjs` 69项全过:三档尺寸×四主题逐项尺寸/比例、首屏/contain、八入口、锚定弹层及恢复默认/取消/保存/焦点、历史归零/胶囊、视频暂停播放。附原HTML与实际应用并排图、完整DOM实测JSON和全程WebM,另含悬停、选中、生成中与播放逐帧。
- 隔离FastAPI+假ComfyUI;后端四组相关测试47项全过。`check` 0错误、1条既有ShotStagePanel字幕警告;`test`、`build`、定向格式检查、`git diff --check`通过。全仓lint仍受63个文件基线格式与缺失ESLint配置影响,不扩全仓治理。
复现:下载主卡已确认HTML,通过 `HEL740_SAMPLE` 指定原样本路径;在原隔离栈运行 `HEL729_TESTS=e2e/hel729.cjs,e2e/hel740.cjs python3 e2e/run-hel729.py`,环境按原脚本配置Playwright/Chromium。截图和日志通过任务卡附件交付,不将临时数据提交进仓库。
## 明确边界与剩余事实
- 默认文生图没有媒体输入,样本可选垫图槽位仍待总管组织决定;未新增映射或伪造上传槽。此次同状态尺寸对照使用已支持图片和遮罩的真实工作流类型。
- 参数字段来自工作流声明;隔离夹具仅有种子和一个高级参数,没有伪造样本中的分辨率、步数等能力。实际工作流声明这些字段时沿用动态表单显示。
- 跨工作流送图能力未具备,送去生视频入口禁用并解释;通用项目/阶段选择留B4,存入项目只在真实来源上下文存在时启用。
- 已接受B0的48px顶栏保留,其造成的作品高度差不计作已还原。没有宣称全页逐像素一致或交部署。
- PR #3为AGit请求,已用原topic `hel-729-creative-desk` 推送到 `refs/for/feat/hel-728-night-foundation` 更新现有审核引用(仅推送普通分支不会更新该请求);Gitea API `/api/v1/user` 返回401,本运行只有SSH推送身份,不能修改PR标题/正文。任务卡附拟更新文案供总管完成元数据更新,不新建重复请求。
@@ -0,0 +1,13 @@
# HEL-741 创作台 R1—R4 剩余呈现修复
沿用 feat/hel-729-creative-desk 和原 PR #3,基于 7bf63d05873b1e473905763d13fe999ac688cc6d。目标分支仍为 feat/hel-728-night-foundation@8e84e4c21f22febe7bd4f20777d06fa1d4914e8e。未部署、未合并、未新增依赖、未修改后端或正式数据。
- DeskWorkflowRail 复用统一 token,原用途/家族选择改为 chips;保留收藏、仅可运行、搜索、置顶及待配置导向;仅可运行按原样本26×15开关绘制,保留原生复选框语义与键盘操作。搜索加入原图标结构,34px/13px;metadata 展示实时筛选数,仓库文件数因接口未知如实说明,不用数据库条数代替。真实用途仍为现有五类;没有独立局部重绘元数据,不臆测新分类。
- StudioDesk 头行按样本48px、1280下44px;名字裸14px/600、21.6875px行盒。家族标识、用途、等宽源文件名、真实连接信息保留。源文件名来自已有source_path,无值显示未知。管理详情入口复用侧栏页脚,参数仍由dock chips进入,专注移入参数层;弹层焦点归还更新为真实入口。
- Artboard 子按钮依样本夜/日/调色r5、蓝调r8;两条1px×18px分隔线、尾部26px图标删除。已有作品编号处打开详情、对比、尾帧导出等能力。删除先确认,再调用原5秒撤销队列;提示如实说明生成文件和项目回流记录也会删除,后端规则不变。
- Composer 生成图标14px。蓝调按钮token为140.7px,浏览器140.6875px;样本140.65625px。夜/日/调色仍保留已验收131px,对样本130.65625px有0.34375px差值,不填入虚构的演示计费后缀。
- 原样本的主题差异通过token保持:搜索框圆角6/6/4/8,筛选chips圆角999/999/3/999,连接胶囊调色间r3。样本原件保持不动。
验证:check 0错误、1条既有ShotStagePanel字幕警告;npm test/build通过;后端生成、图像输入、项目回流、连接四组67项通过。浏览器在隔离FastAPI和假ComfyUI运行,原42项回归和新增138项检查均通过,覆盖三档×四主题测量、筛选/入口/专注/焦点/删除确认取消撤销及真实提交;逐项结果、实测值、并排截图和录屏由任务附件交付。全仓lint仍有62个既有格式文件且ESLint缺少配置,定向格式通过,不做全仓治理。
真实数据与样本的剩余差异明确列于附件,不宣称整页差异清零;全站B0顶栏保留。通用存入项目留B4,跨工作流送视频如实禁用;文生图不具备媒体输入时没有垫图槽,符合主卡最新“文生图不垫图”决定。
@@ -0,0 +1,24 @@
# HEL-743 搜索提示与头行定点返工
基于 `e62499c517ccfbd78e7d3c13df98199b6699a6b2`,沿用分支 `feat/hel-729-creative-desk` 和 PR #3,base 保持 `feat/hel-728-night-foundation@8e84e4c21f22febe7bd4f20777d06fa1d4914e8e`。
- `DeskWorkflowRail.svelte` 补原件 `/` kbd,字体、内边距、圆角进入 `static/themes.css`,颜色和边框复用现有主题 token。输入按原件flex:1分配剩余宽度。最终输入样本/实际为176.671875/176.6875px(另两档152.671875/152.6875、140.671875/140.6875),有1/64px排版量化差,未把实测值写成完全相等;kbd字体/内边距/圆角/边框/颜色均一致。非编辑态 `/` 聚焦搜索,编辑器、contenteditable、组合输入、修饰键和可见弹层不会劫持;Esc 保留筛选并归还先前焦点,无可用来源时回到工作流栏控制按钮。
- `StudioDesk.svelte` 修改原头行规则,左右负边距和内边距各16px,背景、底线通到内容列边缘;使用统一玻璃和线条 token,蓝调使用原件55%玻璃背景。内容起点、头行高度及真实连接信息保持。
- 源文件名增加原件 `max-width:1366px` 隐藏规则,1440显示真实source_path的文件名,完整路径仍在title。未伪造业务数据。
- 从 HEL-742 复审的本地材料读取并纳入 `e2e/hel742.cjs`,此前任务附件确实只有5张PNG,脚本并非从附件下载。保留无图片声明时无槽位/素材入口、图视频切换、遮罩双槽对照7项。
- `e2e/hel743.cjs` 新增三档×四主题几何/玻璃/底线/kbd/文件名测量,1367/1366断点、搜索/焦点/输入法/弹层回归。只在隔离数据库中把夹具实际JSON文件的路径写为source_path,以验证真实文件名展示。`e2e/compare-hel743.py` 将测量区域并排,左样本、右实际。
复跑(沿用已有依赖,不新增依赖):
```bash
# HEL740_SAMPLE 指向从主卡独立下载且SHA256核对的原件
# Linux按本机环境提供 PLAYWRIGHT_MODULE_PATH / CHROMIUM_EXECUTABLE_PATH / LD_LIBRARY_PATH
HEL740_SAMPLE=/path/to/approved.html \
HEL729_TESTS=e2e/hel729.cjs,e2e/hel740.cjs,e2e/hel742.cjs,e2e/hel743.cjs \
calliope-backend/.venv/bin/python e2e/run-hel729.py
calliope-backend/.venv/bin/python e2e/compare-hel743.py
```
原件 SHA256:`0473d80d22b90194375f3566c1b8ca30dbd7433581b18f60956eabca252242a4`。runner在前台运行,finally停止并回收本轮服务;隔离夹具为 `e2e/shots/reference`。并排图、逐项结果和实测值随本卡附件交付。
验证:前端 check 0错误、1条既有ShotStagePanel字幕警告;test/build通过;相关后端4组47项通过。全仓lint仍为既有62文件格式问题、ESLint缺少配置;本次组件定向Prettier和 `git diff --check` 通过。浏览器42项原回归、138项R1—R4回归、7项模式切换和91项本轮定向断言通过(结果和实测值见任务附件)。中文输入法组合态用合成isComposing及229事件验证,未接入真实中文输入法/GPU/云生成。浏览器首次尝试因运行库路径不完整退出,修正本地LD_LIBRARY_PATH后正常执行;新增脚本曾因旧“参数”按钮名超时,已改用现有“创作设置⌄”入口复跑。没有修改正式数据、后端、B0顶栏或已裁定非偏差项;没有收费生成、合并或部署。
+21
View File
@@ -0,0 +1,21 @@
"""Assemble measured HEL-743 search/header crops, sample left and actual right."""
from pathlib import Path
import json
from PIL import Image, ImageDraw
root=Path(__file__).resolve().parents[1]
out=root/'e2e/shots/hel743'
rows=json.loads((out/'results.json').read_text())['measurements']
for width in (1440,1366,1280):
entries=[r for r in rows if r['width']==width]
panel_width=1040
canvas=Image.new('RGB',(panel_width*2,4*164),(35,36,40))
draw=ImageDraw.Draw(canvas)
for index,r in enumerate(entries):
for column,side in enumerate(('sample','actual')):
x=column*panel_width+10;y=index*164+8;m=r[side]
draw.text((x,y),f"{width} / {r['theme']} / {side} | head {m['head']['width']}x{m['head']['height']} | input {m['search']['width']} | file {m['file']['width']}",fill='white')
im=Image.open(out/f"{width}-{r['theme']}-{side}.png")
for key,dy in (('head',26),('box',90)):
box=m[key];left=int(box['x']);top=int(box['y']);right=round(box['x']+box['width']);bottom=round(box['y']+box['height'])
canvas.paste(im.crop((left,top,right,bottom)),(x,y+dy))
canvas.save(out/f'compare-{width}.png')
+590
View File
@@ -0,0 +1,590 @@
// 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").catch(() => {});
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;
});
+605
View File
@@ -0,0 +1,605 @@
// HEL-740: same-viewport sample comparisons plus anchored-dialog/state evidence.
const { chromium } = require(
process.env.PLAYWRIGHT_MODULE_PATH || "playwright",
);
const fs = require("node:fs"),
path = require("node:path"),
assert = require("node:assert/strict");
const out = "e2e/shots/hel740";
fs.mkdirSync(out, { recursive: true });
let currentPage;
const checks = [],
measurements = [],
errors = [];
const ok = (name, value) => {
assert(value, name);
checks.push(name);
console.log("PASS", name);
};
const api = async (p) => (await fetch("http://127.0.0.1:8247" + p)).json();
const pause = (ms) => new Promise((r) => setTimeout(r, ms));
async function wait(fn) {
for (let i = 0; i < 160; i++) {
if (await fn()) return;
await pause(100);
}
throw Error("timeout");
}
async function metrics(page, sample = false) {
return page.evaluate((sample) => {
const selectors = sample
? {
button: "#genBtn",
dock: "#dock",
positive: "#posTa",
negative: "#negTa",
segment: "#kindSeg",
name: "#hdName",
history: ".hist",
actions: "#actbar",
pop: "#paramPop",
search: "#wfSearch",
searchBox: "#wfSearch",
filter: "#taskChips .chip",
meta: ".wfb-meta",
head: ".cc > .ph",
file: "#hdFile",
action: "#actbar > button",
separator: "#actbar .sep",
remove: "#actbar [data-act=del]",
icon: "#genBtn svg",
}
: {
button: ".dock .gen",
dock: ".dock .composer",
positive: "#desk-prompt",
negative: "#desk-negative",
segment: ".kind-tabs",
name: ".model-selector",
history: ".history-rail",
actions: ".artboard-footer",
pop: ".anchored .modal-panel",
search: ".rail-search input",
searchBox: ".rail-search",
filter: ".filters button",
meta: ".list-meta",
head: ".studio-heading",
file: ".workflow-file",
action: ".artboard-footer > button",
separator: ".action-separator",
remove: ".delete-output",
icon: ".dock .gen svg",
};
return Object.fromEntries(
Object.entries(selectors).map(([key, sel]) => {
let e = document.querySelector(sel);
if (!e) return [key, null];
if (sample && key === "searchBox") e = e.parentElement;
let r = e.getBoundingClientRect(),
s = getComputedStyle(e);
return [
key,
{
x: r.x,
y: r.y,
width: r.width,
height: r.height,
bottom: r.bottom,
radius: s.borderRadius,
font: s.fontSize,
weight: s.fontWeight,
background: s.backgroundImage,
border: s.borderTopWidth,
shadow: s.boxShadow,
},
];
}),
);
}, sample);
}
async function shot(page, name) {
await page.screenshot({ path: out + "/" + name + ".png" });
}
(async () => {
const b = await chromium.launch({
executablePath: process.env.CHROMIUM_EXECUTABLE_PATH,
args: ["--no-sandbox", "--disable-dev-shm-usage"],
});
const saved = fs.existsSync("e2e/fake_mode.json")
? fs.readFileSync("e2e/fake_mode.json")
: null;
try {
const c = await b.newContext({
viewport: { width: 1440, height: 900 },
recordVideo: {
dir: out + "/video",
size: { width: 1440, height: 900 },
},
}),
p = await c.newPage(),
s = await c.newPage();
currentPage = p;
p.on("pageerror", (e) => errors.push(e.message));
p.setDefaultTimeout(12000);
await s.goto(
"file://" +
path.resolve(
process.env.HEL740_SAMPLE ||
"../references/夜片场-多主题交互样本.html",
) +
"?still=1&fill=1#create/image",
);
await s.locator("#posTa").waitFor();
const data = await s.evaluate(async () => {
const img = new Image();
img.src = st.hist.image[0].img;
await img.decode();
const cv = document.createElement("canvas");
cv.width = img.naturalWidth;
cv.height = img.naturalHeight;
cv.getContext("2d").drawImage(img, 0, 0);
return {
img: cv.toDataURL("image/png"),
pos: $("#posTa").value,
neg: $("#negTa").value,
};
});
fs.writeFileSync(
out + "/sample-media.png",
Buffer.from(data.img.split(",")[1], "base64"),
);
const workflows = await api("/api/workflows"),
mask = workflows.find((w) => w.name === "遮罩图生图测试");
assert(mask);
await p.goto("http://127.0.0.1:5173/create?wf=" + mask.id);
await p.locator("#desk-negative").waitFor();
const jobs = await api("/api/playground/jobs"),
job = jobs.find((j) => j.workflow_id === mask.id && j.status === "done");
assert(job);
await p
.locator(".history-take")
.filter({ hasText: "#" + job.id })
.click();
await p.getByRole("button", { name: "复用参数", exact: true }).click();
await p.locator("#desk-prompt").fill(data.pos);
await p.locator("#desk-negative").fill(data.neg);
fs.writeFileSync(
"e2e/fake_mode.json",
JSON.stringify({
mode: "ok",
unique_filename: true,
image_file: path.resolve(out + "/sample-media.png"),
}),
);
const before = Math.max(...jobs.map((j) => j.id));
await p.locator(".gen").click();
await wait(async () =>
(await api("/api/playground/jobs")).some(
(j) => j.id > before && j.status === "done",
),
);
await wait(() =>
p.locator(".print img").evaluate((e) => e.complete && e.naturalWidth > 0),
);
await pause(1000);
for (const [width, height] of [
[1440, 900],
[1366, 768],
[1280, 720],
]) {
await p.setViewportSize({ width, height });
await s.setViewportSize({ width, height });
for (const theme of ["night", "day", "grade", "bluehour"]) {
await p.getByRole("button", { name: "主题", exact: true }).click();
await p
.locator(".choice")
.filter({
hasText: {
night: "夜片场",
day: "日光棚",
grade: "调色间",
bluehour: "蓝调时刻",
}[theme],
})
.click();
await p.keyboard.press("Escape");
await s.evaluate((t) => {
document.documentElement.dataset.theme = t;
renderCreate();
}, theme);
await pause(650);
const actual = await metrics(p),
sample = await metrics(s, true);
measurements.push({ width, height, theme, actual, sample });
ok(
`${width}/${theme} 正负提示词宽度与样本误差不超过1px`,
Math.abs(actual.positive.width - sample.positive.width) <= 1 &&
Math.abs(actual.negative.width - sample.negative.width) <= 1,
);
ok(
`${width}/${theme} 按钮38高、提示词60高13px、操作条40高、分段36高`,
actual.button.height === 38 &&
actual.positive.height === 60 &&
actual.positive.font === "13px" &&
actual.actions.height === 40 &&
actual.segment.height === 36,
);
ok(
`${width}/${theme} 首屏按钮与作品完整`,
actual.button.bottom <= height &&
(await p.locator(".print img").evaluate((e) => {
let r = e.getBoundingClientRect();
return (
r.top >= 48 &&
r.bottom < innerHeight &&
getComputedStyle(e).objectFit === "contain"
);
})),
);
ok(
`${width}/${theme} R1 搜索与筛选chips几何`,
actual.searchBox.height === sample.searchBox.height &&
actual.searchBox.radius === sample.searchBox.radius &&
actual.search.font === sample.search.font &&
actual.filter.height === sample.filter.height &&
actual.filter.font === sample.filter.font &&
actual.filter.radius === sample.filter.radius,
);
ok(
`${width}/${theme} R2 头行与裸文本文件名`,
actual.head.height === sample.head.height &&
Math.abs(actual.name.height - sample.name.height) < 0.1 &&
actual.name.font === sample.name.font &&
actual.name.weight === sample.name.weight &&
actual.file.font === sample.file.font &&
(await p.locator(".studio-heading button").count()) === 0,
);
ok(
`${width}/${theme} R3 删除钮与分隔线`,
actual.action.radius === sample.action.radius &&
actual.remove.width === 26 &&
actual.remove.height === 26 &&
actual.separator.width === 1 &&
(await p.locator(".action-separator").count()) === 2,
);
ok(
`${width}/${theme} R4 14px图标及主题按钮宽度`,
actual.icon.width === 14 &&
Math.abs(actual.button.width - sample.button.width) <= 0.5,
);
ok(
`${width}/${theme} meta真实未知且无横向溢出`,
(await p
.locator(".list-meta")
.innerText()
.then((t) => t.includes("仓库文件数未知"))) &&
(await p.evaluate(
() => document.documentElement.scrollWidth <= innerWidth,
)),
);
await shot(s, `${width}-${theme}-sample`);
await shot(p, `${width}-${theme}-actual`);
const actions = p.locator(
".artboard-footer > button,.artboard-footer > a",
);
ok(`${width}/${theme} 八入口显式存在`, (await actions.count()) === 8);
}
await p
.getByRole("button", { name: "创作设置⌄", exact: true })
.first()
.click();
await pause(250);
const popup = await metrics(p);
ok(
`${width} 参数锚定底座上方且不遮主按钮`,
popup.pop.bottom <= popup.dock.y - 7 &&
popup.pop.y >= 48 &&
popup.pop.width === 620,
);
await s.evaluate(() => openPop());
await pause(250);
measurements.push({
width,
height,
state: "params",
actual: await metrics(p),
sample: await metrics(s, true),
});
await shot(p, `${width}-params-actual`);
await shot(s, `${width}-params-sample`);
// Focus cycle and draft-only reset.
await p.locator(".anchored input[type=number]").first().fill("333");
await p
.getByRole("button", { name: "恢复工作流默认", exact: true })
.click();
ok(
`${width} 恢复默认只改弹层草稿`,
(await p
.locator(".anchored input[type=number]")
.first()
.inputValue()) === "42",
);
await p.keyboard.press("Escape");
ok(
`${width} Escape归还焦点`,
await p
.getByRole("button", { name: "创作设置⌄", exact: true })
.first()
.evaluate((e) => e === document.activeElement),
);
await s.evaluate(() => $("#paramPop").classList.remove("on"));
}
await p.setViewportSize({ width: 1440, height: 900 });
await p.getByRole("button", { name: "收起历史栏", exact: true }).click();
ok(
"历史收起列宽归零",
(await p
.locator(".history-rail")
.evaluate((e) => e.getBoundingClientRect().width)) === 0,
);
await shot(p, "history-collapsed");
await p.getByRole("button", { name: "展开历史栏", exact: true }).click();
await p
.locator(".history-take")
.filter({ hasText: "#" + job.id })
.click();
await shot(p, "history-banner");
ok(
"历史胶囊999圆角",
(await p
.locator(".history-banner")
.evaluate((e) => getComputedStyle(e).borderRadius)) === "999px",
);
await p.getByRole("button", { name: "返回当前创作", exact: true }).click();
await p
.getByRole("button", { name: "创作设置⌄", exact: true })
.first()
.click();
const dialog = p.locator(".anchored .modal-panel");
await dialog.locator("button").last().focus();
await p.keyboard.press("Tab");
ok(
"参数Tab焦点循环",
await dialog.evaluate((e) => e.contains(document.activeElement)),
);
await p.locator(".anchored input[type=number]").first().fill("234");
await p.getByRole("button", { name: "取消", exact: true }).click();
await p
.getByRole("button", { name: "创作设置⌄", exact: true })
.first()
.click();
ok(
"参数取消丢弃修改",
(await p.locator(".anchored input[type=number]").first().inputValue()) !==
"234",
);
await p.locator(".anchored input[type=number]").first().fill("235");
await p.getByRole("button", { name: "保存设置", exact: true }).click();
await p
.getByRole("button", { name: "创作设置⌄", exact: true })
.first()
.click();
ok(
"参数保存草稿持久化",
(await p.locator(".anchored input[type=number]").first().inputValue()) ===
"235",
);
await p
.getByRole("button", { name: "恢复工作流默认", exact: true })
.click();
await p.getByRole("button", { name: "保存设置", exact: true }).click();
await p
.getByRole("button", { name: "创作设置⌄", exact: true })
.first()
.click();
ok(
"恢复默认可保存",
(await p.locator(".anchored input[type=number]").first().inputValue()) ===
"42",
);
await p.keyboard.press("Escape");
// Actual existing disabled abilities have explanatory titles; no fake success.
ok(
"未具备送视频能力如实禁用",
await p
.getByRole("button", { name: "送去生视频", exact: true })
.isDisabled(),
);
ok(
"无来源时通用存入项目禁用",
await p
.getByRole("button", { name: "存入项目", exact: true })
.isDisabled(),
);
await p.getByRole("button", { name: "放大查看", exact: true }).click();
await shot(p, "zoom");
await p.keyboard.press("Escape");
await p
.getByRole("button", { name: "作品详情与操作", exact: true })
.click();
await shot(p, "more-actions");
await p.keyboard.press("Escape");
// HEL-741: real filtering, retained entries and deletion of isolated data.
const search = p.getByRole("searchbox", { name: "搜索工作流" });
await search.fill("遮罩图生图测试");
ok(
"搜索更新真实筛选数",
await p
.locator(".list-meta")
.innerText()
.then((t) => t.startsWith("1 个")),
);
const usageGroup = p.getByRole("group", { name: "筛选工作流用途" });
await usageGroup
.getByRole("button", { name: "文生图", exact: true })
.click();
ok(
"用途chips与搜索组合筛选",
(await p.locator(".workflow-pick").count()) === 0,
);
await usageGroup.getByRole("button", { name: "全部", exact: true }).click();
if (
(await p
.locator(".row-actions button")
.last()
.getAttribute("aria-pressed")) !== "true"
)
await p.locator(".row-actions button").last().click();
await usageGroup
.getByRole("button", { name: "☆ 收藏", exact: true })
.click();
ok(
"收藏chip保留真实收藏筛选",
(await p.locator(".workflow-pick").count()) === 1,
);
await shot(p, "filters-favorite");
await usageGroup
.getByRole("button", { name: "☆ 收藏", exact: true })
.click();
await search.fill("");
await p.locator("[data-workflow-details]").click();
await p.getByRole("dialog").waitFor();
await shot(p, "workflow-details");
await p.keyboard.press("Escape");
ok(
"说明入口关闭归还焦点",
await p
.locator("[data-workflow-details]")
.evaluate((e) => e === document.activeElement),
);
await p.getByRole("button", { name: "创作设置⌄", exact: true }).click();
await p.getByRole("button", { name: "专注创作 ⤢", exact: true }).click();
await p.keyboard.press("Escape");
ok("参数层保留专注功能", !(await p.locator(".workflow-rail").isVisible()));
await shot(p, "focus-mode");
await p.getByRole("button", { name: "创作设置⌄", exact: true }).click();
await p.getByRole("button", { name: "退出专注 ⤢", exact: true }).click();
await p.keyboard.press("Escape");
const deleteTarget = (await api("/api/playground/jobs")).find(
(j) => j.workflow_id === mask.id && j.status === "done",
);
assert(deleteTarget);
await p.locator(".delete-output").click();
ok(
"删除图标先确认且不发DELETE",
(await api("/api/playground/jobs")).some((j) => j.id === deleteTarget.id),
);
await shot(p, "delete-confirm");
await p.getByRole("button", { name: "取消", exact: true }).click();
ok(
"删除取消保留记录并归还焦点",
(await api("/api/playground/jobs")).some(
(j) => j.id === deleteTarget.id,
) &&
(await p
.locator(".delete-output")
.evaluate((e) => e === document.activeElement)),
);
await p.locator(".delete-output").click();
await p.getByRole("button", { name: "确认移除", exact: true }).click();
await p.getByRole("button", { name: "撤销", exact: true }).click();
ok(
"确认后沿用撤销窗口",
(await api("/api/playground/jobs")).some((j) => j.id === deleteTarget.id),
);
await shot(p, "delete-undone");
await p.locator(".delete-output").click();
await p.getByRole("button", { name: "确认移除", exact: true }).click();
await wait(
async () =>
!(await api("/api/playground/jobs")).some(
(j) => j.id === deleteTarget.id,
),
);
ok(
"隔离数据确认后真实删除",
!(await api("/api/playground/jobs")).some(
(j) => j.id === deleteTarget.id,
),
);
// Video state evidence across the same three viewports.
await p.getByRole("tab", { name: "视频", exact: true }).click();
await p.locator("#desk-prompt").waitFor();
const videos = (await api("/api/playground/jobs")).filter(
(j) => j.kind === "video" && j.status === "done",
);
const vj = videos.find((j) => j.workflow_id === 4);
assert(vj);
await p
.locator(".history-take")
.filter({ hasText: "#" + vj.id })
.click();
await p.getByRole("button", { name: "复用参数", exact: true }).click();
await p.locator(".print video").waitFor();
await s.evaluate(() => go("create", { kind: "video" }));
for (const [width, height] of [
[1440, 900],
[1366, 768],
[1280, 720],
]) {
await p.setViewportSize({ width, height });
await s.setViewportSize({ width, height });
await pause(350);
await shot(p, `${width}-video-actual`);
await shot(s, `${width}-video-sample`);
ok(
`${width} 视频默认暂停且播放键64`,
(await p.locator(".print video").evaluate((e) => e.paused)) &&
(await p
.locator(".big-play")
.evaluate((e) => e.getBoundingClientRect().width === 64)),
);
}
await p.getByRole("button", { name: "播放此视频", exact: true }).click();
await pause(500);
await shot(p, "video-playing");
await p.locator(".print video").focus();
await p.keyboard.press("Space");
await shot(p, "video-paused");
await p.getByRole("tab", { name: "图片", exact: true }).click();
await p.locator(".workflow-pick").first().hover();
await shot(p, "workflow-hover");
await p.locator(".gen").hover();
await shot(p, "generate-hover");
fs.writeFileSync(
"e2e/fake_mode.json",
JSON.stringify({
mode: "ok",
delay_sec: 10,
unique_filename: true,
image_file: path.resolve(out + "/sample-media.png"),
}),
);
await p.locator(".gen").click();
await p.getByRole("button", { name: "取消生成", exact: true }).waitFor();
await shot(p, "generating");
await p.getByRole("button", { name: "取消生成", exact: true }).click();
ok("无浏览器异常", errors.length === 0);
fs.writeFileSync(
out + "/measurements.json",
JSON.stringify(measurements, null, 2),
);
fs.writeFileSync(
out + "/results.json",
JSON.stringify({ checks, errors }, null, 2),
);
await c.close();
fs.copyFileSync(await p.video().path(), out + "/actual-interactions.webm");
fs.copyFileSync(await s.video().path(), out + "/sample-interactions.webm");
} catch (e) {
fs.writeFileSync(
out + "/measurements.json",
JSON.stringify(measurements, null, 2),
);
if (currentPage) await shot(currentPage, "failure").catch(() => {});
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 b.close();
}
})().catch((e) => {
console.error(e);
process.exitCode = 1;
});
+115
View File
@@ -0,0 +1,115 @@
// HEL-742 independent verification: pure text-to-image / text-to-video
// workflows expose no pad-slot UI at all (no 管理素材 entry, no .a-slot),
// no stale slot UI after mode/workflow switches; positive control on the
// mask workflow (2 slots). Run inside the isolated stack.
const { chromium } = require(
process.env.PLAYWRIGHT_MODULE_PATH || "playwright",
);
const fs = require("node:fs");
const out = "e2e/shots/hel742";
fs.mkdirSync(out, { recursive: true });
const checks = [];
const errors = [];
const ok = (name, value) => {
if (!value) throw new Error("FAIL " + name);
checks.push(name);
console.log("PASS", name);
};
const api = async (p, body) =>
body
? (
await fetch("http://127.0.0.1:8247" + p, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify(body),
})
).json()
: (await fetch("http://127.0.0.1:8247" + p)).json();
const pause = (ms) => new Promise((r) => setTimeout(r, ms));
(async () => {
const b = await chromium.launch({
executablePath: process.env.CHROMIUM_EXECUTABLE_PATH,
args: ["--no-sandbox", "--disable-dev-shm-usage"],
});
try {
const c = await b.newContext({ viewport: { width: 1440, height: 900 } });
const p = await c.newPage();
p.on("pageerror", (e) => errors.push(e.message));
p.setDefaultTimeout(15000);
const wfs = await api("/api/workflows");
const t2i = wfs.find((w) => w.name === "文生图·回归");
const t2v = wfs.find((w) => w.name === "文生视频·回归");
let mask = wfs.find((w) => w.name === "遮罩图生图测试");
ok("隔离夹具含文生图/文生视频工作流", t2i && t2v);
if (!mask) {
const base = await api("/api/workflows/" + t2i.id);
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) 遮罩" },
},
};
mask = await api("/api/workflows", {
name: "遮罩图生图测试",
kind: "image",
workflow_json: maskBody,
});
}
const noSlotUi = async () =>
(await p.locator(".a-slot").count()) === 0 &&
(await p
.getByRole("button", { name: "管理素材", exact: true })
.count()) === 0;
await p.goto("http://127.0.0.1:5173/create?wf=" + t2i.id);
await p.locator("#desk-prompt").waitFor();
await pause(400);
await p.screenshot({ path: out + "/t2i-no-slot.png" });
ok("文生图无图片输入:无垫图槽、无素材入口", await noSlotUi());
await p.getByRole("tab", { name: "视频", exact: true }).click();
await p.locator(".rail-tools input[type=search]").fill("文生视频");
await p.locator(".workflow-pick").first().click();
await pause(600);
await p.screenshot({ path: out + "/t2v-no-slot.png" });
ok("文生视频无图片输入:无首尾帧槽、无素材入口", await noSlotUi());
await p.getByRole("tab", { name: "图片", exact: true }).click();
await p.locator(".rail-tools input[type=search]").fill("文生图");
await p.locator(".workflow-pick").first().click();
await pause(600);
ok("切回图片后无残留垫图槽", await noSlotUi());
await p.locator(".rail-tools input[type=search]").fill("遮罩");
await p.locator(".workflow-pick").first().click();
await pause(600);
await p.getByRole("button", { name: "管理素材", exact: true }).click();
await pause(400);
ok(
"遮罩工作流仍有两个图片槽(对照)",
(await p.locator(".a-slot").count()) === 2,
);
await p.getByRole("button", { name: "完成", exact: true }).click();
await p.locator(".rail-tools input[type=search]").fill("文生图");
await p.locator(".workflow-pick").first().click();
await pause(600);
ok("从遮罩切回文生图后槽位入口消失", await noSlotUi());
ok("无浏览器异常", errors.length === 0);
fs.writeFileSync(
out + "/results.json",
JSON.stringify({ checks, errors }, null, 2),
);
} finally {
await b.close();
}
})();
+351
View File
@@ -0,0 +1,351 @@
// HEL-743: sample geometry, themes, breakpoint and search keyboard regression.
// Run using e2e/run-hel729.py with HEL729_TESTS=e2e/hel743.cjs.
const { chromium } = require(
process.env.PLAYWRIGHT_MODULE_PATH || "playwright",
);
const fs = require("node:fs"),
path = require("node:path"),
assert = require("node:assert/strict");
const out = "e2e/shots/hel743";
fs.mkdirSync(out, { recursive: true });
const checks = [],
measurements = [],
errors = [];
function ok(name, v) {
assert(v, name);
checks.push(name);
console.log("PASS", name);
}
const pause = (ms) => new Promise((r) => setTimeout(r, ms));
async function metrics(p, sample = false) {
return p.evaluate((sample) => {
const sel = sample
? {
head: ".cc > .ph",
name: "#hdName",
file: "#hdFile",
search: "#wfSearch",
box: "#wfSearch",
key: ".wfb-top kbd",
}
: {
head: ".studio-heading",
name: ".model-selector",
file: ".workflow-file",
search: ".rail-search input",
box: ".rail-search",
key: ".rail-search kbd",
};
const entries = Object.entries(sel).map(([k, sel]) => {
let e = document.querySelector(sel);
if (sample && k === "box") e = e.parentElement;
if (sample && k === "key")
e = document
.querySelector("#wfSearch")
.parentElement.querySelector("kbd");
let r = e.getBoundingClientRect(),
s = getComputedStyle(e);
return [
k,
{
x: r.x,
y: r.y,
width: r.width,
height: r.height,
font: s.font,
fontSize: s.fontSize,
fontWeight: s.fontWeight,
lineHeight: s.lineHeight,
padding: s.padding,
radius: s.borderRadius,
border: s.border,
bottomBorder: s.borderBottom,
background: s.backgroundColor,
color: s.color,
blur: s.backdropFilter,
text: e.textContent,
},
];
});
return {
...Object.fromEntries(entries),
overflow: document.documentElement.scrollWidth > innerWidth,
};
}, sample);
}
(async () => {
const b = await chromium.launch({
executablePath: process.env.CHROMIUM_EXECUTABLE_PATH,
args: ["--no-sandbox", "--disable-dev-shm-usage"],
});
try {
const c = await b.newContext({ viewport: { width: 1440, height: 900 } }),
p = await c.newPage(),
s = await c.newPage();
p.on("pageerror", (e) => errors.push(e.message));
p.setDefaultTimeout(10000);
const wfs = await (
await fetch("http://127.0.0.1:8247/api/workflows")
).json(),
wf = wfs.find((w) => w.name === "遮罩图生图测试");
assert(wf);
// The fixture file and metadata live only under the isolated test data directory.
const settings = await (
await fetch("http://127.0.0.1:8247/api/settings")
).json();
assert.equal(
path.resolve(settings.data_dir),
path.resolve("e2e/shots/reference/data"),
);
const detail = await (
await fetch("http://127.0.0.1:8247/api/workflows/" + wf.id)
).json();
const source = path.join(settings.data_dir, "hel743-mask-workflow.json");
fs.writeFileSync(source, JSON.stringify(detail.workflow_json, null, 2));
require("node:child_process").execFileSync(
"calliope-backend/.venv/bin/python",
[
"-c",
'import sqlite3,sys; c=sqlite3.connect(sys.argv[1]); c.execute("UPDATE workflows SET source_path=? WHERE id=?",(sys.argv[2],int(sys.argv[3]))); c.commit(); c.close()',
path.join(settings.data_dir, "calliope.db"),
source,
String(wf.id),
],
);
await p.goto("http://127.0.0.1:5173/create?wf=" + wf.id);
await p.locator("#desk-prompt").waitFor();
await s.goto(
"file://" +
path.resolve(process.env.HEL740_SAMPLE) +
"?still=1&fill=1#create/image",
);
await s.locator("#wfSearch").waitFor();
for (const [width, height] of [
[1440, 900],
[1366, 768],
[1280, 720],
]) {
await p.setViewportSize({ width, height });
await s.setViewportSize({ width, height });
for (const theme of ["night", "day", "grade", "bluehour"]) {
await p.getByRole("button", { name: "主题", exact: true }).click();
await p
.locator(".choice")
.filter({
hasText: {
night: "夜片场",
day: "日光棚",
grade: "调色间",
bluehour: "蓝调时刻",
}[theme],
})
.click();
await p.keyboard.press("Escape");
await s.evaluate((t) => {
document.documentElement.dataset.theme = t;
renderCreate();
}, theme);
await pause(450);
const actual = await metrics(p),
sample = await metrics(s, true);
measurements.push({ width, height, theme, actual, sample });
const tag = width + "/" + theme;
ok(
tag + " head width/height/name x",
actual.head.width === sample.head.width &&
actual.head.height === sample.head.height &&
actual.name.x === sample.name.x,
);
ok(
tag + " glass/bottom line/blur",
actual.head.background === sample.head.background &&
actual.head.bottomBorder === sample.head.bottomBorder &&
actual.head.blur === sample.head.blur,
);
ok(
tag + " search/input width",
Math.abs(actual.search.width - sample.search.width) < 0.1 &&
actual.box.width === sample.box.width,
);
ok(
tag + " kbd exact appearance",
[
"fontSize",
"fontWeight",
"lineHeight",
"padding",
"radius",
"border",
"background",
"color",
].every((k) => actual.key[k] === sample.key[k]) &&
actual.key.text === "/",
);
ok(
tag + " filename breakpoint",
width <= 1366
? actual.file.width === 0 && sample.file.width === 0
: actual.file.width > 0 && sample.file.width > 0,
);
ok(tag + " no overflow", !actual.overflow);
await p.screenshot({ path: out + `/${width}-${theme}-actual.png` });
await s.screenshot({ path: out + `/${width}-${theme}-sample.png` });
}
}
for (const width of [1367, 1366]) {
await p.setViewportSize({ width, height: 768 });
await s.setViewportSize({ width, height: 768 });
const a = await metrics(p),
v = await metrics(s, true);
ok(
width + " boundary filename matches sample",
(a.file.width === 0) === (v.file.width === 0),
);
}
await p.setViewportSize({ width: 1440, height: 900 });
const search = p.getByRole("searchbox", { name: "搜索工作流" }),
origin = p.getByRole("tab", { name: "图片", exact: true });
await origin.focus();
await p.keyboard.press("/");
ok(
"/ focuses search",
await search.evaluate((e) => e === document.activeElement),
);
await search.fill("遮罩");
await pause(200);
ok("search filters", (await p.locator(".workflow-pick").count()) === 1);
await p.keyboard.press("Escape");
ok(
"Esc returns prior focus",
await origin.evaluate((e) => e === document.activeElement),
);
ok("Esc preserves filter", (await search.inputValue()) === "遮罩");
await search.fill("");
await search.focus();
await p.keyboard.press("/");
ok(
"search typing slash remains literal",
(await search.inputValue()) === "/",
);
await search.fill("");
const prompt = p.locator("#desk-prompt");
await prompt.fill("");
await prompt.focus();
await p.keyboard.press("/");
ok("textarea keeps slash", (await prompt.inputValue()) === "/");
await p.evaluate(() => {
let e = document.createElement("div");
e.id = "editable-probe";
e.contentEditable = "true";
e.textContent = "IME";
document.body.append(e);
e.focus();
});
await p.keyboard.press("/");
ok(
"contenteditable keeps focus",
await p
.locator("#editable-probe")
.evaluate(
(e) => document.activeElement === e && e.textContent.includes("/"),
),
);
await p.locator("#editable-probe").evaluate((e) => e.remove());
await origin.focus();
ok(
"composition slash ignored",
await origin.evaluate((e) => {
let k = new KeyboardEvent("keydown", {
key: "/",
bubbles: true,
cancelable: true,
isComposing: true,
});
e.dispatchEvent(k);
return document.activeElement === e && !k.defaultPrevented;
}),
);
ok(
"legacy IME 229 ignored",
await origin.evaluate((e) => {
let k = new KeyboardEvent("keydown", {
key: "/",
keyCode: 229,
bubbles: true,
cancelable: true,
});
e.dispatchEvent(k);
return document.activeElement === e && !k.defaultPrevented;
}),
);
await p.keyboard.press("Control+/");
ok(
"modified slash ignored",
await origin.evaluate((e) => document.activeElement === e),
);
await prompt.focus();
ok(
"composition Escape keeps editor focus",
await prompt.evaluate((e) => {
let k = new KeyboardEvent("keydown", {
key: "Escape",
bubbles: true,
cancelable: true,
isComposing: true,
});
e.dispatchEvent(k);
return document.activeElement === e && !k.defaultPrevented;
}),
);
await p.setViewportSize({ width: 1440, height: 900 });
ok(
"1440 filename is genuine source basename",
(await p.locator(".workflow-file").textContent()) ===
path.basename(source) &&
(await p.locator(".workflow-file").getAttribute("title")) === source,
);
// Mouse focus and removal of the prior node use a stable rail control fallback.
await search.click();
await p.keyboard.press("Escape");
ok(
"mouse search Esc returns control",
await p.evaluate(
() =>
document.activeElement !==
document.querySelector(".rail-search input"),
),
);
await p
.getByRole("button", { name: "创作设置⌄", exact: true })
.first()
.click();
await p.locator(".modal-panel").waitFor();
await p.locator(".modal-panel button").first().focus();
await p.keyboard.press("/");
ok(
"dialog slash never jumps to rail",
!(await search.evaluate((e) => e === document.activeElement)),
);
await p.keyboard.press("Escape");
ok(
"dialog Escape closes normally",
(await p.locator(".modal-panel").count()) === 0,
);
await p.getByRole("button", { name: "收起历史栏", exact: true }).focus();
await p.keyboard.press("/");
await p.keyboard.press("Escape");
ok(
"keyboard origin restored exactly",
await p
.getByRole("button", { name: "收起历史栏", exact: true })
.evaluate((e) => document.activeElement === e),
);
ok("no browser errors", errors.length === 0);
fs.writeFileSync(
out + "/results.json",
JSON.stringify({ checks, measurements, errors }, null, 2),
);
} finally {
await b.close();
}
})();
+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()