HEL-836: 按最初定版还原创作台并修复双环圆点 #13

Merged
leefer merged 1 commits from fix/hel-836-original-desk-orbits into leefer/hel-731-studio-project 2026-10-24 21:33:06 +08:00
15 changed files with 1757 additions and 356 deletions
@@ -539,11 +539,11 @@
}
.orb .ring {
position: absolute;
width: 250px;
height: 250px;
width: var(--agent-orbit-size);
height: var(--agent-orbit-size);
border-radius: 50%;
border: 1px solid rgb(var(--ink) / 0.12);
animation: spin 24s linear infinite;
animation: agent-orbit var(--agent-orbit-period) linear infinite;
animation-play-state: var(--loop-play-state);
}
.orb .ring::before {
@@ -558,9 +558,9 @@
box-shadow: var(--agent-ring-shadow);
}
.orb .ring.r2 {
width: 300px;
height: 300px;
animation-duration: 40s;
width: var(--agent-orbit-outer-size);
height: var(--agent-orbit-outer-size);
animation-duration: var(--agent-orbit-outer-period);
animation-direction: reverse;
border-style: dashed;
border-color: rgb(var(--ink) / 0.07);
@@ -1004,3 +1004,9 @@
transform: translateY(-2px);
}
}
@keyframes agent-orbit {
to {
transform: rotate(360deg);
}
}
@@ -126,13 +126,13 @@
previous = id && status ? { id, status } : null;
});
$effect(() => {
if (!arriving) return;
const skip = () => (arriving = false);
// v3: any explicit pointer action may skip an arrival, including actions
// outside the media surface. The listener exists only during this reveal.
window.addEventListener('pointerdown', skip, true);
return () => window.removeEventListener('pointerdown', skip, true);
});
if (!arriving) return;
const skip = () => (arriving = false);
// v3: any explicit pointer action may skip an arrival, including actions
// outside the media surface. The listener exists only during this reveal.
window.addEventListener('pointerdown', skip, true);
return () => window.removeEventListener('pointerdown', skip, true);
});
const st = $derived(job ? jobStatusInfo(job) : null);
// §3.4 红/琥珀界线: amber only when the task SUCCEEDED but its files
// cannot be fetched — the failed case stays red.
@@ -171,7 +171,10 @@
let stageWidth = $state(0),
stageHeight = $state(0);
const fitted = $derived(
Math.min(stageWidth / naturalWidth, stageHeight / naturalHeight),
Math.min(
stageWidth / naturalWidth,
(stageHeight - (video ? 40 : 0)) / naturalHeight,
),
);
$effect.pre(() => {
void output;
@@ -191,6 +194,16 @@
}
let paused = $state(true);
let playTime = $state(0);
let duration = $state(0);
let loopVideo = $state(false);
function clock(seconds: number) {
return `${Math.floor(seconds / 60)
.toString()
.padStart(2, '0')}:${Math.floor(seconds % 60)
.toString()
.padStart(2, '0')}`;
}
$effect.pre(() => {
void output;
untrack(() => {
@@ -219,16 +232,20 @@
}
</script>
<div class="artboard" aria-label="生成结果">
{#snippet frameMeta()}
<button
class="artboard-toolbar"
aria-label="作品详情与操作"
onclick={() => (detailsOpen = true)}
>
<div class="result-title">
<span>第 {job?.id} 次生成</span><small>{wf?.name ?? '历史作品'}</small>
</div>
<span class="result-title"
><span class="media-tag">第 {job?.id} 次生成</span><small
class="media-tag">{wf?.name ?? '历史作品'}</small
>{#if dims}<span class="media-tag">{dims}</span>{/if}</span
>
</button>
{/snippet}
<div class="artboard" aria-label="生成结果">
<div class="stage" class:comparing={compare}>
{#if job && st}{#key mediaKey}
<div
@@ -239,46 +256,94 @@
{#if output && video}
<!-- svelte-ignore a11y_media_has_caption -->
<div
class="print a-photo a-viewfinder a-in-frame"
class="print video-print a-photo a-viewfinder a-in-frame"
class:a-result-develop={arriving}
use:finishAnimations
onanimationend={() => (arriving = false)}
style:width={fitted > 0 ? `${Math.floor(naturalWidth * fitted)}px` : '100%'}
style:height={fitted > 0 ? `${Math.floor(naturalHeight * fitted)}px` : '100%'}
style:width={fitted > 0
? `${Math.floor(naturalWidth * fitted)}px`
: '100%'}
style:height={fitted > 0
? `${Math.floor(naturalHeight * fitted) + 40}px`
: '100%'}
>
{#if st.tone === 'ok' && !fileGone}<span class="a-fix"
>{t('jobst.done')}</span
>{/if}
<video
bind:this={videoEl}
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)}
playsinline
preload="metadata"
onloadedmetadata={(e) => {
clearOutputMissing(output);
noteVideo(e);
}}
onerror={() => output && markOutputMissing(output)}
></video>
{#if paused}<button
class="big-play"
aria-label="播放此视频"
onclick={() => {
videoEl?.focus();
togglePlayback();
}}>▶</button
>{/if}
<div class="video-screen">
{@render frameMeta()}
{#if st.tone === 'ok' && !fileGone}<span class="a-fix"
>{t('jobst.done')}</span
>{/if}
<video
bind:this={videoEl}
src={assetUrl(output)}
loop={loopVideo}
ontimeupdate={() => (playTime = videoEl?.currentTime ?? 0)}
aria-label="生成的视频,使用播放按钮开始播放"
onkeydown={(e) => {
if (e.code === 'Space' && e.target === e.currentTarget) {
e.preventDefault();
togglePlayback();
}
}}
onplay={() => (paused = false)}
onpause={() => (paused = true)}
onended={() => (paused = true)}
playsinline
preload="metadata"
onloadedmetadata={(e) => {
clearOutputMissing(output);
noteVideo(e);
duration = Number.isFinite(videoEl?.duration)
? videoEl!.duration
: 0;
}}
onerror={() => output && markOutputMissing(output)}
></video>
{#if paused}<button
class="big-play"
aria-label="播放此视频"
onclick={() => {
videoEl?.focus();
togglePlayback();
}}><Icon name="play" size={26} /></button
>{/if}
</div>
<div class="player-controls">
<button
class="player-toggle"
onclick={togglePlayback}
aria-label={paused ? '播放视频' : '暂停视频'}
><Icon name={paused ? 'play' : 'pause'} size={13} /></button
>
<span class="player-clock"
>{clock(playTime)} / {clock(duration)}</span
>
<input
class="player-seek"
type="range"
aria-label="播放进度"
min="0"
max={duration || 1}
step="0.01"
value={playTime}
oninput={(e) => {
if (videoEl)
videoEl.currentTime = Number(e.currentTarget.value);
}}
/>
<button
class="player-action"
aria-label="循环播放"
aria-pressed={loopVideo}
onclick={() => (loopVideo = !loopVideo)}
><Icon name="result-reuse" size={14} /></button
>
<button
class="player-action"
aria-label="全屏播放"
onclick={() => videoEl?.requestFullscreen().catch(() => {})}
><Icon name="result-expand" size={14} /></button
>
</div>
</div>
{:else if output}
<div
@@ -286,9 +351,14 @@
class:a-result-develop={arriving}
use:finishAnimations
onanimationend={() => (arriving = false)}
style:width={fitted > 0 ? `${Math.floor(naturalWidth * fitted)}px` : '100%'}
style:height={fitted > 0 ? `${Math.floor(naturalHeight * fitted)}px` : '100%'}
style:width={fitted > 0
? `${Math.floor(naturalWidth * fitted)}px`
: '100%'}
style:height={fitted > 0
? `${Math.floor(naturalHeight * fitted)}px`
: '100%'}
>
{@render frameMeta()}
{#if st.tone === 'ok' && !fileGone}<span class="a-fix"
>{t('jobst.done')}</span
>{/if}
@@ -371,7 +441,7 @@
video
? videoEl?.requestFullscreen().catch(() => {})
: (lightbox = true)}
aria-label="放大查看"><Icon name="zoom-in" size={14} />放大</button
aria-label="放大查看"><Icon name="result-expand" size={14} />放大</button
>
<a
class="canvas-btn"
@@ -379,14 +449,14 @@
aria-disabled={!output}
download
rel="external"
aria-label="下载生成结果"><Icon name="download" size={14} />下载</a
aria-label="下载生成结果"><Icon name="result-down" 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
><Icon name="result-reuse" size={14} />复用参数</button
>
<button
class="canvas-btn"
@@ -395,25 +465,28 @@
? '放入当前工作流图片输入'
: '当前工作流没有图片输入'}
onclick={() => output && onSendToSlot(output)}
><Icon name="image" size={14} />作为参考图</button
>
<button
class="canvas-btn"
disabled
title="跨工作流送图尚未具备,请选择生视频工作流后从已有结果选取"
><Icon name="film" size={14} />送去生视频</button
><Icon name="project-image" size={14} />{video
? '作为首帧'
: '作为参考图'}</button
>
{#if !video}
<button
class="canvas-btn"
disabled
title="跨工作流送图尚未具备,请选择生视频工作流后从已有结果选取"
><Icon name="project-film" size={14} />送去生视频</button
>{/if}
<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
onclick={onAttach}><Icon name="nav-project" size={14} />存入项目</button
>
<a class="canvas-btn" href={resolve('/library')}
><Icon name="assets" size={14} />素材库</a
><Icon name="project-lib" size={14} />素材库</a
>
<button
class="canvas-btn delete-output"
@@ -609,13 +682,13 @@
}
.artboard-toolbar {
position: absolute;
top: 10px;
left: 10px;
top: var(--desk-meta-offset);
left: var(--desk-meta-offset);
z-index: 2;
border-radius: 4px;
background: var(--media-scrim);
background: none;
color: var(--on-media);
padding: 2px 6px;
padding: 0;
max-width: calc(100% - 20px);
border: 0;
cursor: pointer;
@@ -624,14 +697,24 @@
.result-title {
display: flex;
align-items: center;
gap: 12px;
gap: 6px;
min-width: 0;
font-size: 11px;
color: var(--text-2);
}
.media-tag {
display: inline-flex;
align-items: center;
height: 18px;
padding: 0 6px;
border-radius: 4px;
border: 1px solid var(--on-media-line);
background: var(--media-scrim);
color: var(--on-media);
}
.result-title small {
color: var(--text-3);
font-size: 10px;
color: var(--on-media);
font-size: 11px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
@@ -692,7 +775,7 @@
}
.stage-media {
position: absolute;
inset: 0;
inset: var(--desk-viewport-top, 0px) 0 0;
padding: 0;
display: grid;
place-items: center;
@@ -713,6 +796,90 @@
box-shadow: var(--media-shadow);
border-radius: var(--desk-media-radius);
}
.video-print {
display: flex;
flex-direction: column;
}
.video-screen {
position: relative;
flex: 1;
min-height: 0;
}
.player-controls {
box-sizing: border-box;
display: flex;
flex: none;
align-items: center;
gap: 10px;
height: var(--desk-player-controls-height);
padding: 0 10px;
background: var(--desk-player-controls-bg);
color: var(--on-media);
border-top: 1px solid var(--line);
}
.player-toggle {
display: grid;
place-items: center;
width: 28px;
height: 28px;
padding: 0;
border: 0;
border-radius: 50%;
background: var(--desk-player-toggle-bg);
color: var(--desk-media-bg);
cursor: pointer;
}
.player-clock {
font: 500 11.5px var(--mono);
color: var(--desk-player-muted);
min-width: 88px;
}
.player-seek {
appearance: none;
background: transparent;
flex: 1;
min-width: 0;
height: 18px;
accent-color: var(--scene);
cursor: pointer;
}
.player-seek::-webkit-slider-runnable-track {
height: var(--desk-player-track-height);
border-radius: 4px;
background: var(--desk-player-track-bg);
}
.player-seek::-webkit-slider-thumb {
appearance: none;
width: var(--desk-player-thumb-size);
height: var(--desk-player-thumb-size);
border-radius: 50%;
background: var(--on-media);
margin-top: -4px;
box-shadow: 0 0 0 3px color-mix(in srgb, var(--scene) 40%, transparent);
}
.player-toggle :global(svg),
.big-play :global(svg) {
fill: currentColor;
stroke: none;
}
.big-play:hover {
transform: scale(var(--desk-player-hover-scale));
}
.player-action {
display: grid;
place-items: center;
width: 26px;
height: 26px;
padding: 0;
border: 0;
border-radius: 5px;
color: var(--desk-player-muted);
background: none;
cursor: pointer;
}
.player-action[aria-pressed='true'] {
color: var(--scene);
}
.big-play {
position: absolute;
left: 50%;
@@ -909,6 +1076,16 @@
color: var(--warn);
}
@media (max-width: 1100px) {
.media-tag {
display: inline-flex;
align-items: center;
height: 18px;
padding: 0 6px;
border-radius: 4px;
border: 1px solid var(--on-media-line);
background: var(--media-scrim);
color: var(--on-media);
}
.result-title small {
display: none;
}
@@ -1,6 +1,7 @@
<script lang="ts">
import { resolve } from '$app/paths';
import { isWorkflowRunnable } from '$lib/workflowState';
import { workflowFamily } from './workflowFamily';
import { isWorkflowRunnable } from '$lib/workflowState';
import { onMount, untrack } from 'svelte';
import { goto } from '$app/navigation';
import { createMutation, useQueryClient } from '@tanstack/svelte-query';
@@ -35,6 +36,10 @@
onKind: (kind: 'image' | 'video') => void;
onDetails: () => void;
} = $props();
const closedGroups = $state<Record<string, boolean>>({});
function groupKey(m: WorkflowMeta) {
return pins.includes(m.id) ? 'pinned' : (m.model_family ?? 'local');
}
let searchInput: HTMLInputElement;
let searchOrigin: HTMLElement | null = null;
function searchKeydown(e: KeyboardEvent) {
@@ -116,11 +121,11 @@
.sort(
(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',
) ||
Number(b.favorite) - Number(a.favorite) ||
a.name.localeCompare(b.name, 'zh'),
),
);
@@ -165,7 +170,10 @@
<aside class="workflow-rail" aria-label="创作工作流列表">
<header>
<strong>创作台</strong><button
<strong
><span class="desk-glyph"><Icon name="nav-create" size={18} /></span
>创作台</strong
><button
class="history-toggle a-btn ghost sm"
onclick={onToggleHistory}
aria-label={historyCollapsed ? '展开历史栏' : '收起历史栏'}
@@ -178,13 +186,15 @@
role="tab"
aria-selected={kind === 'image'}
{disabled}
onclick={() => onKind('image')}>图片</button
onclick={() => onKind('image')}
><Icon name="project-image" size={14} />图片</button
>
<button
role="tab"
aria-selected={kind === 'video'}
{disabled}
onclick={() => onKind('video')}>视频</button
onclick={() => onKind('video')}
><Icon name="project-film" size={14} />视频</button
>
</div>
<div class="rail-search">
@@ -238,47 +248,65 @@
</div>
<div class="rail-list">
{#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
{#if index === 0 || groupKey(m) !== groupKey(filtered[index - 1])}<button
class="family-label"
aria-expanded={!closedGroups[groupKey(m)]}
onclick={() =>
(closedGroups[groupKey(m)] = !closedGroups[groupKey(m)])}
>
{pins.includes(m.id) ? '置顶工作流' : m.model_family || '本地工作流'}
</div>{/if}
<div
class="workflow-row reference-option"
use:spotlight
class:selected={selectedId === m.id}
>
<button
class="workflow-pick"
{disabled}
onclick={() => choose(m)}
aria-pressed={selectedId === m.id}
title={m.name}
><i class:video={kind === 'video'}
>{m.model_family?.slice(0, 3) || (kind === 'image' ? '◈' : '▷')}</i
><span
><b>{m.name}</b>{#if !runnable(m)}<small class="pending"
>待配置 · 映射 / 测试 →</small
>{/if}<small
>{m.description ||
(m.usage ? usageLabel(m.usage) : '本地 ComfyUI')}</small
></span
></button
>
<div class="row-actions">
<button
class:pressed={pins.includes(m.id)}
aria-pressed={pins.includes(m.id)}
aria-label={(pins.includes(m.id) ? '取消置顶' : '置顶') + m.name}
onclick={() => pin(m.id)}>⌖</button
><button
aria-pressed={!!m.favorite}
aria-label={(m.favorite ? '取消收藏' : '收藏') + m.name}
disabled={$favorite.isPending}
onclick={() => $favorite.mutate(m)}>{m.favorite ? '★' : '☆'}</button
<Icon
name={closedGroups[groupKey(m)] ? 'chevron-right' : 'chevron-down'}
size={14}
/>
<Icon name={pins.includes(m.id) ? 'pin' : 'agent-cpu'} size={14} />
<span
>{pins.includes(m.id)
? '置顶'
: `本地 ComfyUI · ${m.model_family || '未标注模型'}`}</span
>
</div>
</div>{/each}
<small
>{filtered.filter((item) => groupKey(item) === groupKey(m))
.length}</small
>
</button>{/if}
{#if !closedGroups[groupKey(m)]}
<div
class="workflow-row reference-option"
use:spotlight
class:selected={selectedId === m.id}
>
<button
class="workflow-pick"
{disabled}
onclick={() => choose(m)}
aria-pressed={selectedId === m.id}
title={m.name}
><i style:background={workflowFamily(m.model_family).color}
>{workflowFamily(m.model_family).label}</i
><span
><b>{m.name}</b>{#if !runnable(m)}<small class="pending"
>待配置 · 映射 / 测试 →</small
>{/if}<small
>{m.description ||
(m.usage ? usageLabel(m.usage) : '本地 ComfyUI')}</small
></span
></button
>
<div class="row-actions">
<button
class:pressed={pins.includes(m.id)}
aria-pressed={pins.includes(m.id)}
aria-label={(pins.includes(m.id) ? '取消置顶' : '置顶') + m.name}
onclick={() => pin(m.id)}><Icon name="pin" size={14} /></button
><button
aria-pressed={!!m.favorite}
aria-label={(m.favorite ? '取消收藏' : '收藏') + m.name}
disabled={$favorite.isPending}
onclick={() => $favorite.mutate(m)}
>{m.favorite ? '★' : '☆'}</button
>
</div>
</div>{/if}{/each}
{#if !filtered.length}<p class="empty">
没有符合条件的工作流。可以清空搜索或更改筛选。
</p>{/if}
@@ -293,9 +321,9 @@
>{pendingCount} 个工作流待配置 →</a
>{/if}<button class="manage" data-workflow-details onclick={onDetails}
><span>+</span>
<div>工作流说明与管理<small>输入要求 / 导入 / 分类</small></div>
<div>工作流管理<small>输入要求 / 导入 / 分类</small></div>
<b>›</b></button
><a href={resolve('/models')}>云端模型与接口 ↗</a>
><a href={resolve('/models')}>云端接口 ↗</a>
</footer>
</aside>
@@ -320,9 +348,21 @@
justify-content: space-between;
}
header strong {
display: flex;
align-items: center;
gap: 8px;
font-size: 14px;
font-weight: 600;
display: block;
}
.desk-glyph {
width: var(--desk-glyph-size);
height: var(--desk-glyph-size);
display: grid;
place-items: center;
border-radius: var(--desk-slot-radius);
color: var(--scene);
background: var(--desk-glyph-bg);
box-shadow: var(--desk-glyph-shadow);
}
.rail-tools {
padding: var(--desk-rail-gap) 12px;
@@ -339,6 +379,10 @@
background: var(--well);
}
.kind-tabs button {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 6px;
flex: 1;
height: 28px;
padding: 0 12px;
@@ -464,23 +508,43 @@
color: var(--tx-3);
}
.family-label {
width: 100%;
border: 0;
background: var(--bg-1);
gap: 6px;
text-align: left;
cursor: pointer;
font-size: 11px;
color: var(--tx-3);
padding: 8px;
padding: 0 6px;
height: var(--desk-group-height);
display: flex;
align-items: center;
margin-top: 8px;
}
.family-label span {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.family-label small {
margin-left: auto;
font: 10.5px var(--mono);
}
.rail-list {
flex: 1;
min-height: 0;
overflow: auto;
padding: 0 8px 8px;
scrollbar-gutter: stable;
padding: 4px 8px 12px;
scrollbar-gutter: auto;
}
.workflow-row {
border: 1px solid transparent;
position: relative;
display: flex;
align-items: center;
border-radius: 10px;
border-radius: var(--desk-row-radius);
margin: 2px 0;
}
.workflow-row.selected {
@@ -491,21 +555,12 @@
var(--bg-3)
);
}
.workflow-row.selected::before {
content: '';
width: 3px;
height: 22px;
position: absolute;
left: 0;
background: var(--scene);
border-radius: 3px;
}
.workflow-pick {
display: flex;
align-items: center;
gap: 9px;
padding: 9px 8px;
min-height: 55px;
gap: var(--desk-row-gap);
padding: var(--desk-row-padding);
min-height: var(--desk-row-height);
width: 100%;
min-width: 0;
text-align: left;
@@ -517,14 +572,14 @@
.workflow-pick i {
display: grid;
place-items: center;
width: 32px;
height: 32px;
width: var(--desk-family-size);
height: var(--desk-family-size);
flex-shrink: 0;
border-radius: 9px;
border: 1px solid var(--line-2);
color: var(--scene);
background: var(--bg-3);
font-size: 16px;
color: var(--desk-family-ink);
background: var(--scene);
font: var(--desk-family-font);
font-style: normal;
}
.workflow-pick > span {
@@ -533,16 +588,16 @@
}
.workflow-pick b {
display: block;
font-size: 12px;
font-weight: 550;
font-size: var(--desk-row-name-size);
font-weight: var(--desk-row-name-weight);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
margin-bottom: 4px;
margin-bottom: 1px;
}
.workflow-pick small {
display: block;
font-size: 10px;
font-size: var(--desk-row-detail-size);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
@@ -558,7 +613,7 @@
opacity: 0;
padding: 2px;
height: 24px;
width: 20px;
width: 24px;
border: 0;
color: var(--text-3);
background: none;
@@ -592,25 +647,33 @@
cursor: pointer;
}
footer {
padding: 12px;
display: grid;
padding: 8px;
display: flex;
flex-wrap: wrap;
gap: 6px;
border-top: 1px solid var(--line);
}
footer a,
.manage {
font-size: 11px;
color: var(--text-3);
font-size: 12px;
color: var(--tx-2);
text-decoration: none;
}
footer a,
.manage {
flex: 1;
white-space: nowrap;
border: 1px solid var(--line-2);
border-radius: var(--desk-action-radius);
background: var(--bg-3);
}
.manage {
text-align: left;
display: flex;
align-items: center;
gap: 7px;
background: none;
border: 0;
padding: 7px 8px;
padding: 0 9px;
height: 26px;
cursor: pointer;
}
.manage small {
@@ -1,4 +1,6 @@
<script lang="ts">
import { parseDbTs } from '$lib/dbtime';
import { dayLabel } from '../generate/takes';
import { assetUrl } from '$lib/api';
import { jobStatusInfo } from '$lib/jobStatus';
import { isVideoOutput } from '../generate/takes';
@@ -23,7 +25,12 @@
<header>历史</header>
{#if !collapsed}
<div class="history-list">
{#each sorted as job (job.id)}
{#each sorted as job, index (job.id)}
{#if index === 0 || dayLabel(parseDbTs(job.created_at), Date.now()) !== dayLabel(parseDbTs(sorted[index - 1].created_at), Date.now())}<div
class="history-date"
>
{dayLabel(parseDbTs(job.created_at), Date.now())}
</div>{/if}
{@const status = jobStatusInfo(job)}
<button
class="history-take a-viewfinder"
@@ -33,7 +40,10 @@
aria-label={'查看历史 #' + job.id + ' · ' + status.label}
aria-pressed={selectedId === job.id}
>
<div class="thumb" class:placeholder={!job.output_paths[0] || status.tone !== 'ok'}>
<div
class="thumb"
class:placeholder={!job.output_paths[0] || status.tone !== 'ok'}
>
{#if job.output_paths[0] && status.tone === 'ok'}
{#if isVideoOutput(job, job.output_paths[0])}<span
class="video-mark">▷</span
@@ -113,7 +123,9 @@
background: var(--desk-media-bg);
flex: none;
}
.history-take.selected { border-color: transparent; }
.history-take.selected {
border-color: transparent;
}
.thumb {
position: relative;
background: var(--desk-media-bg);
@@ -121,8 +133,13 @@
overflow: hidden;
font-size: 10px;
}
.thumb.placeholder { aspect-ratio: 1; display: grid; place-items: center; }
img, video {
.thumb.placeholder {
aspect-ratio: 1;
display: grid;
place-items: center;
}
img,
video {
width: 100%;
height: auto;
display: block;
@@ -155,6 +172,13 @@
background: var(--desk-history-label-bg);
color: var(--desk-history-label-color);
}
.history-date {
font-size: 10px;
color: var(--tx-3);
align-self: stretch;
text-align: center;
padding-top: 4px;
}
p {
text-align: center;
font-size: 10px;
@@ -1,7 +1,8 @@
<script lang="ts">
import { workflowFamily } from './workflowFamily';
import { resolve } from '$app/paths';
import { createHref } from '$lib/createHref';
import { isWorkflowRunnable } from '$lib/workflowState';
import { isWorkflowRunnable } from '$lib/workflowState';
import { readDeskDraft, writeDeskDraft } from './draftCache';
import HistoryRail from './HistoryRail.svelte';
import { createQuery, useQueryClient } from '@tanstack/svelte-query';
@@ -125,10 +126,7 @@
const allMetas = $derived($metaQuery.data ?? []);
const metaById = $derived(new Map(allMetas.map((m) => [m.id, m])));
const deskMetas = $derived(
allMetas.filter(
(m) =>
isWorkflowRunnable(m) && m.kind === kind,
),
allMetas.filter((m) => isWorkflowRunnable(m) && m.kind === kind),
);
const pendingCount = $derived(
allMetas.filter((m) => m.needs_mapping && m.kind === kind).length,
@@ -327,7 +325,6 @@
}
});
// ------------------------------------------------------------------
// Form state
// ------------------------------------------------------------------
@@ -744,7 +741,6 @@
let crossfade = $state(false);
let videoEl = $state<HTMLVideoElement | null>(null);
let nowMs = $state(Date.now());
const anyActive = $derived(
allJobs.some((j) => j.status === 'pending' || j.status === 'running'),
@@ -1038,7 +1034,10 @@
let storeJobId = $state<number | null>(null);
async function attachToProject(job: Job) {
if (!workbenchCtxOf(job)) { storeJobId = job.id; return; }
if (!workbenchCtxOf(job)) {
storeJobId = job.id;
return;
}
if (attachBusyId != null) return;
attachBusyId = job.id;
try {
@@ -1174,9 +1173,10 @@
<div class="refract-studio">
<header class="studio-heading">
<div class="heading-actions workflow-identity">
<span class="fam-dot"
>{currentMeta?.model_family?.slice(0, 3) ??
(kind === 'image' ? '◈' : '▷')}</span
<span
class="fam-dot"
style:background={workflowFamily(currentMeta?.model_family).color}
>{workflowFamily(currentMeta?.model_family).label}</span
>
<b id="desk-wf" class="model-selector"
>{currentWorkflow?.name ?? currentMeta?.name ?? '选择工作流'}</b
@@ -1211,17 +1211,28 @@
class="history-banner"
role="status"
>
<span>正在查看历史 #{historyJobId} · 当前输入保持不变</span><button
class="a-btn ghost sm"
onclick={() => (historyJobId = null)}>返回当前创作</button
><button
<Icon name="clock" size={14} />
<span>正在查看 <b>历史 #{historyJobId}</b> · 当前输入未改动</span>
<button
class="a-btn secondary sm"
aria-label="复用到输入区"
onclick={() => selectedJob && reuse(selectedJob)}
>复用到输入区</button
><Icon name="result-reuse" size={14} />复用这组参数</button
>
<button
class="a-btn ghost sm"
aria-label="返回当前创作"
onclick={() => (historyJobId = null)}>回到最新</button
>
</div>{/if}
<div class="studio-layout">
<section class="viewer" aria-label="查看生成结果" style:background="var(--desk-stage-background)">
<section
class="viewer"
class:history-viewing={historyJobId != null &&
selectedJob?.id === historyJobId}
aria-label="查看生成结果"
style:background="var(--desk-stage-background)"
>
<div class="preview-area">
{#if selectedJob}<Artboard
main
@@ -1238,8 +1249,7 @@
open={true}
inactive={false}
canSendToSlot={!!imageSlotTarget || !!imageList}
canAttach={!!selectedJob &&
selectedJob.status === 'done'}
canAttach={!!selectedJob && selectedJob.status === 'done'}
attachBusy={attachBusyId === selectedJob?.id}
onAttach={() => selectedJob && attachToProject(selectedJob)}
{crossfade}
@@ -1252,9 +1262,13 @@
onSendToSlot={sendToSlot}
onDelete={requestDelete}
/>{:else}<div class="desk-welcome">
<div class="desk-emblem"><Icon name={kind === 'image' ? 'image' : 'film'} size={22} /></div>
<div class="desk-emblem">
<Icon name={kind === 'image' ? 'image' : 'film'} size={22} />
</div>
<div class="empty-title">作品会在这里完整显示</div>
<div class="empty-hint">选工作流 → 填素材与提示词 → 开始生成</div>
<div class="empty-hint">
选工作流 → 填素材与提示词 → 开始生成
</div>
</div>{/if}
</div>
</section>
@@ -1440,7 +1454,8 @@
id="comfy-endpoint"
bind:value={paramEndpoint}
>{#if !endpoints.length}<option value="">尚未配置服务</option
>{/if}{#each endpoints as ep (ep.id)}<option value={ep.id}>{ep.name}</option
>{/if}{#each endpoints as ep (ep.id)}<option value={ep.id}
>{ep.name}</option
>{/each}</select
><a href={resolve('/connections')}>配置或检查连接 ↗</a>
</div>
@@ -1474,8 +1489,11 @@
{/if}
</Modal>
<StoreResultModal jobId={storeJobId} onclose={() => (storeJobId = null)} />
<style>
.reference-desk {
--icon-stroke: var(--desk-icon-stroke);
display: flex;
flex: 1;
min-width: 0;
@@ -1486,12 +1504,13 @@
display: none;
}
.refract-studio {
position: relative;
display: flex;
flex: 1;
min-width: 0;
min-height: 0;
flex-direction: column;
padding: 0 16px 14px;
padding: 0;
}
.studio-heading {
display: flex;
@@ -1499,7 +1518,7 @@
align-items: center;
gap: 8px;
height: var(--desk-head-height);
margin: 0 calc(-1 * var(--desk-head-inset));
margin: 0;
padding: 0 var(--desk-head-inset);
box-sizing: border-box;
border-bottom: 1px solid var(--line);
@@ -1513,14 +1532,21 @@
}
}
.history-banner {
position: absolute;
top: calc(var(--desk-head-height) + 6px);
left: 50%;
translate: -50% 0;
z-index: 3;
display: flex;
align-items: center;
gap: 8px;
gap: var(--desk-history-banner-gap);
height: var(--desk-history-banner-height);
flex-shrink: 0;
padding: 6px 8px;
border: 1px solid var(--info);
padding: var(--desk-history-banner-padding);
border: 1px solid var(--desk-history-banner-border);
border-radius: var(--r-pill);
color: var(--info);
color: var(--tx-1);
white-space: nowrap;
font-size: 12.5px;
background: color-mix(in srgb, var(--bg-2) 92%, transparent);
backdrop-filter: blur(8px);
@@ -1529,6 +1555,12 @@
max-width: 100%;
box-sizing: border-box;
}
.history-banner b {
color: var(--info);
}
.viewer.history-viewing {
--desk-viewport-top: var(--desk-history-banner-height);
}
.history-banner span {
margin-right: auto;
}
@@ -1541,7 +1573,7 @@
.model-selector {
border: 0;
background: none;
color: var(--text-2);
color: var(--tx-1);
padding: 0;
font-size: var(--desk-head-name-size);
font-weight: var(--desk-head-name-weight);
@@ -1626,6 +1658,7 @@
.studio-form {
min-height: 0;
min-width: 0;
margin: 0 var(--desk-inset) var(--desk-bottom);
}
.desk-welcome {
box-sizing: border-box;
@@ -1649,10 +1682,12 @@
place-items: center;
color: color-mix(in srgb, var(--scene) 70%, var(--hi));
}
.empty-title { font-size: var(--desk-empty-title-size); color: var(--tx-2); }
.empty-hint { font-size: var(--desk-empty-hint-size); }
.history-full {
height: min(680px, 68vh);
.empty-title {
font-size: var(--desk-empty-title-size);
color: var(--tx-2);
}
.empty-hint {
font-size: var(--desk-empty-hint-size);
}
.param-actions {
display: flex;
@@ -1679,29 +1714,9 @@
.runtime-setting a {
color: var(--accent);
}
.picker-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(120px, 1fr));
gap: 12px;
}
.picker-item {
border: 1px solid var(--line);
border-radius: 8px;
padding: 0;
overflow: hidden;
background: var(--surface);
cursor: pointer;
}
.picker-item img {
width: 100%;
height: 120px;
object-fit: cover;
}
.picker-empty {
color: var(--text-3);
}
@media (max-width: 1100px) {
.refract-studio {
position: relative;
padding-left: 12px;
padding-right: 12px;
}
@@ -1826,5 +1841,3 @@
color: var(--text-3);
}
</style>
<StoreResultModal jobId={storeJobId} onclose={()=>storeJobId=null} />
@@ -0,0 +1,17 @@
/** Abbreviations in the approved sample, applied only to declared metadata. */
const families: Record<string, [string, string]> = {
Qwen: ["Qwen", "qwen"],
Krea2: ["Kr2", "krea"],
Flux: ["FLX", "flux"],
"Z-Image": ["Z", "zimg"],
"MiniMax-H3": ["H3", "h3"],
Wan: ["Wan", "wan"],
Anima: ["Ani", "anima"],
};
export function workflowFamily(family: string | null | undefined) {
const entry = family ? families[family] : undefined;
return {
label: entry?.[0] ?? family?.slice(0, 3) ?? "—",
color: entry ? `var(--desk-family-${entry[1]})` : "var(--scene)",
};
}
@@ -2,7 +2,6 @@
import { resolve } from '$app/paths';
import Modal from '$lib/components/ui/Modal.svelte';
import { tick } from 'svelte';
import { assetUrl } from '$lib/api';
import ParameterPopover from './ParameterPopover.svelte';
import Icon from '$lib/components/ui/Icon.svelte';
import type { ComfyDynamicInput, Workflow } from '$lib/comfy/types';
@@ -73,6 +72,11 @@
}: Props = $props();
let mediaOpen = $state(false);
let inlineBusy = $state(false);
let editorBusy = $state(false);
$effect(() => {
slotBusy = inlineBusy || editorBusy;
});
let expanded = $state(false);
let popoverInputs = $state<ComfyDynamicInput[]>([]);
let popoverAnchor = $state<HTMLElement | null>(null);
@@ -139,7 +143,14 @@
}
</script>
<div class="composer-wrap" class:dock class:expanded>
<div
class="composer-wrap"
class:dock
class:expanded
class:paired-frames={ds.lists.length === 0 &&
ds.slots.length > 0 &&
ds.slots.every((s) => s.firstFrame || s.lastFrame)}
>
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
bind:this={root}
@@ -165,9 +176,9 @@
<h3>2 · 准备这次创作</h3>
{#if hasSlots && !dock}<div class="slots">
<SlotTray
allowScene={current?.kind === 'video'}
allowScene={current?.kind === 'video'}
bind:this={tray}
bind:busy={slotBusy}
bind:busy={inlineBusy}
{ds}
{values}
{showErrors}
@@ -176,35 +187,21 @@
{onFromResults}
/>
</div>{/if}
{#if dock}<div class="media-summary" class:has-slots={hasSlots}>
{#if hasSlots}
{#each ds.slots.slice(0, 3) as slot (slot.inp.nodeId)}<button
class="media-token"
onclick={openMedia}
aria-label={'设置' + slot.label}
>
{#if values[slot.inp.nodeId] && slot.mediaKind === 'image'}<img
src={assetUrl(String(values[slot.inp.nodeId]))}
alt=""
/>{:else}<Icon name="plus" size={12} />{/if}
{slot.label}{slot.inp.required ? ' *' : ''}
</button>{/each}
{#if ds.lists.length}<button
class="media-token"
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="expand-prompt manage-media" onclick={openMedia}
>{slotBusy ? '正在上传…' : '管理素材'}</button
>
{/if}
{#if dock && hasSlots}<div class="media-summary">
<SlotTray
compact
allowScene={current?.kind === 'video'}
bind:busy={inlineBusy}
{ds}
{values}
{showErrors}
{errors}
{onSet}
{onFromResults}
/>
<button class="expand-prompt manage-media" onclick={openMedia}
>管理素材</button
>
</div>{/if}
<div class="prompt-pair" class:dual={dock && !!ds.negative}>
{#if ds.prompt}<div class="prompt-field">
@@ -314,10 +311,13 @@
onchange={(e) => onPickEndpoint(e.currentTarget.value)}
>
{#if !endpoints.length}<option value="">尚未配置服务</option>{/if}
{#each endpoints as ep (ep.id)}<option value={ep.id}>{ep.name}</option
{#each endpoints as ep (ep.id)}<option value={ep.id}
>{ep.name}</option
>{/each}
</select><a href={resolve('/connections')} target="_blank" rel="noreferrer"
>配置或检查连接 ↗</a
</select><a
href={resolve('/connections')}
target="_blank"
rel="noreferrer">配置或检查连接 ↗</a
>
</details>{/if}
</div>
@@ -377,7 +377,7 @@
<SlotTray
allowScene={current?.kind === 'video'}
bind:this={tray}
bind:busy={slotBusy}
bind:busy={editorBusy}
{ds}
{values}
{showErrors}
@@ -578,6 +578,7 @@
display: none;
}
.media-summary {
position: relative;
display: flex;
align-items: center;
gap: 6px;
@@ -587,37 +588,26 @@
overflow: auto;
}
.paired-frames {
--desk-slot-column: var(--desk-first-last-column);
}
.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;
align-self: stretch;
flex-direction: column;
align-items: stretch;
margin: 0;
padding: 10px 12px 8px;
border-right: 1px solid var(--line);
}
.media-token {
display: flex;
align-items: center;
gap: 5px;
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: var(--slot-size);
}
.media-token img {
height: 36px;
width: 36px;
object-fit: cover;
border-radius: 3px;
.manage-media {
position: absolute;
bottom: 6px;
right: 12px;
}
.expand-prompt {
margin-left: auto;
@@ -669,7 +659,7 @@
background: none;
border: 0;
border-radius: 0;
padding: 0;
padding: 2px 0;
font-size: var(--fs-body);
line-height: 1.6;
resize: none;
@@ -712,18 +702,19 @@
}
.dock-chips {
display: flex;
gap: 5px;
gap: var(--desk-slot-gap);
align-items: center;
flex: 1;
min-width: 0;
overflow: auto;
}
.parameter {
background: var(--bg-3);
background: var(--desk-chip-bg);
border: 1px solid var(--line);
border-radius: 7px;
padding: 5px 8px;
font-size: 10px;
border-radius: var(--desk-chip-radius);
height: var(--desk-chip-height);
padding: var(--desk-chip-padding);
font-size: var(--desk-chip-size);
color: var(--tx-2);
cursor: pointer;
white-space: nowrap;
@@ -742,6 +733,7 @@
font-size: 14px;
font-weight: var(--desk-gen-weight);
padding: 0 16px;
border: 0;
border-radius: var(--desk-gen-radius);
margin-left: auto;
flex-shrink: 0;
@@ -1,4 +1,5 @@
<script lang="ts">
import Icon from '$lib/components/ui/Icon.svelte';
import SceneCapturePicker from './SceneCapturePicker.svelte';
import MediaSlot from '$lib/components/studio/MediaSlot.svelte';
import RefImageGrid from '$lib/components/studio/RefImageGrid.svelte';
@@ -7,7 +8,15 @@
import type { ComfyDynamicInput } from '$lib/comfy/types';
import { toast } from '$lib/toast';
import { t } from '$lib/i18n.svelte';
import { h3Warnings, listKind, listPaths, type DeskSchema, type FormValue, type FormValues, type SlotDef } from './schema';
import {
h3Warnings,
listKind,
listPaths,
type DeskSchema,
type FormValue,
type FormValues,
type SlotDef,
} from './schema';
/**
* 输入槽 (spec-v2 §3.3): exactly the media inputs the workflow JSON wires —
@@ -17,6 +26,7 @@
*/
interface Props {
ds: DeskSchema;
compact?: boolean;
allowScene?: boolean;
values: FormValues;
showErrors: boolean;
@@ -27,36 +37,51 @@
onFromResults: (nodeId: string) => void;
}
let { ds, allowScene = false, values, showErrors, errors, busy = $bindable(false), onSet, onFromResults }: Props = $props();
let sceneOpen = $state(false);
let sceneNode = $state("");
$effect(() => {
void ds;
sceneOpen = false;
sceneNode = "";
});
function pickScene(path: string) {
const slot = ds.slots.find(
(s) => s.inp.nodeId === sceneNode && s.mediaKind === "image" && !s.mask,
);
const list = ds.lists.find(
(g) => g.nodeId === sceneNode && g.kind === "image_list",
);
if (!allowScene) return;
if (slot) onSet(sceneNode, path);
else if (list) {
const paths = listPaths(values, list);
if (paths.length >= (list.max ?? 9)) {
toast.error("参考图数量已达上限");
return;
}
onSet(sceneNode, [...paths, path]);
}
}
let {
ds,
compact = false,
allowScene = false,
values,
showErrors,
errors,
busy = $bindable(false),
onSet,
onFromResults,
}: Props = $props();
let sceneOpen = $state(false);
let sceneNode = $state('');
$effect(() => {
void ds;
sceneOpen = false;
sceneNode = '';
});
function pickScene(path: string) {
const slot = ds.slots.find(
(s) => s.inp.nodeId === sceneNode && s.mediaKind === 'image' && !s.mask,
);
const list = ds.lists.find(
(g) => g.nodeId === sceneNode && g.kind === 'image_list',
);
if (!allowScene) return;
if (slot) onSet(sceneNode, path);
else if (list) {
const paths = listPaths(values, list);
if (paths.length >= (list.max ?? 9)) {
toast.error('参考图数量已达上限');
return;
}
onSet(sceneNode, [...paths, path]);
}
}
const uploads = createUploadManager();
const paired = $derived(
compact &&
ds.lists.length === 0 &&
ds.slots.length > 0 &&
ds.slots.every((s) => s.firstFrame || s.lastFrame),
);
type Reject = { name: string; message: string };
let rejects = $state<Record<string, Reject[]>>({});
let fileSizes = $state<Record<string, number>>({});
@@ -104,11 +129,15 @@
const ok = files.filter((f) => !mediaFileProblem(mk, f));
setRejects(
key,
files.filter((f) => mediaFileProblem(mk, f)).map((f) => ({ name: f.name, message: mediaFileProblem(mk, f)! })),
files
.filter((f) => mediaFileProblem(mk, f))
.map((f) => ({ name: f.name, message: mediaFileProblem(mk, f)! })),
);
const start = listPaths(values, entry);
if (start.length + ok.length > max) {
toast.error(t('studio.errRefMax', { max, n: start.length, k: ok.length }));
toast.error(
t('studio.errRefMax', { max, n: start.length, k: ok.length }),
);
return;
}
for (const f of ok) {
@@ -118,7 +147,11 @@
}
}
async function groupReplace(entry: ComfyDynamicInput, index: number, file: File) {
async function groupReplace(
entry: ComfyDynamicInput,
index: number,
file: File,
) {
const key = entry.nodeId;
const problem = mediaFileProblem(listKind(entry), file);
if (problem) {
@@ -130,7 +163,11 @@
replacingIndex = index;
try {
const path = await upload(key, file);
if (path) onSet(key, listPaths(values, entry).map((p, i) => (i === index ? path : p)));
if (path)
onSet(
key,
listPaths(values, entry).map((p, i) => (i === index ? path : p)),
);
} finally {
replacingKey = '';
replacingIndex = -1;
@@ -178,15 +215,23 @@
if (unplaced) toast.error(t('desk.dropNoSlot', { n: unplaced }));
}
</script>
<SceneCapturePicker bind:open={sceneOpen} onPick={pickScene}/>
<SceneCapturePicker bind:open={sceneOpen} onPick={pickScene} />
{#if ds.slots.length || ds.lists.length}
<div class="tray">
{#each ds.slots as s (s.inp.nodeId)}
<div class="tray" class:compact class:paired>
{#if paired}<div class="frame-label">
首尾帧 · {ds.slots[0]?.inp.required ? '首帧必填' : '按工作流要求填写'}
</div>{/if}
{#each ds.slots as s, index (s.inp.nodeId)}
{#if paired && index > 0}<Icon name="arrow-right" size={14} />{/if}
{@const reject = rejects[s.inp.nodeId]?.[0]}
<MediaSlot
caption={paired}
label={s.label}
hint={s.lastFrame && !s.inp.required ? t('studio.slotOptional') : undefined}
hint={s.lastFrame && !s.inp.required
? t('studio.slotOptional')
: undefined}
required={s.inp.required}
value={String(values[s.inp.nodeId] ?? '')}
kind={s.mediaKind}
@@ -194,12 +239,21 @@
uploading={uploads.isUploading(s.inp.nodeId)}
uploadName={uploads.uploading[s.inp.nodeId]}
need={showErrors && !!errors[s.inp.nodeId]}
error={reject?.message ?? (showErrors ? errors[s.inp.nodeId] : undefined)}
error={reject?.message ??
(showErrors ? errors[s.inp.nodeId] : undefined)}
allowFromResults={s.fromResults}
anchorId={`slot-${s.inp.nodeId}`}
onFile={(f) => slotFile(s, f)}
onFromResults={() => onFromResults(s.inp.nodeId)}
onFromScene={allowScene && s.mediaKind === "image" && !s.mask ? () => { sceneNode = s.inp.nodeId; sceneOpen = true; } : undefined}
onFromScene={!compact &&
allowScene &&
s.mediaKind === 'image' &&
!s.mask
? () => {
sceneNode = s.inp.nodeId;
sceneOpen = true;
}
: undefined}
onClear={() => {
setRejects(s.inp.nodeId, []);
onSet(s.inp.nodeId, '');
@@ -222,17 +276,32 @@
anchorId={`slot-${entry.nodeId}`}
onAdd={(files) => groupAdd(entry, files)}
onReplace={(i, file) => groupReplace(entry, i, file)}
onRemove={(i) => onSet(entry.nodeId, listPaths(values, entry).filter((_, j) => j !== i))}
onDismissReject={(i) => setRejects(entry.nodeId, (rejects[entry.nodeId] ?? []).filter((_, j) => j !== i))}
onFromScene={allowScene && entry.kind === "image_list" ? () => { sceneNode = entry.nodeId; sceneOpen = true; } : undefined}
onFromResults={entry.kind === 'image_list' ? () => onFromResults(entry.nodeId) : undefined}
onRemove={(i) =>
onSet(
entry.nodeId,
listPaths(values, entry).filter((_, j) => j !== i),
)}
onDismissReject={(i) =>
setRejects(
entry.nodeId,
(rejects[entry.nodeId] ?? []).filter((_, j) => j !== i),
)}
onFromScene={!compact && allowScene && entry.kind === 'image_list'
? () => {
sceneNode = entry.nodeId;
sceneOpen = true;
}
: undefined}
onFromResults={entry.kind === 'image_list'
? () => onFromResults(entry.nodeId)
: undefined}
/>
{/each}
</div>
{#if showErrors && errors.firstLast}
<p class="a-slot-hint err note">{errors.firstLast}</p>
{/if}
{#if ds.hasH3Hint}
{#if ds.hasH3Hint && !compact}
<div class="note" role="note">
<p class="a-slot-hint">{t('wfMedia.h3Limits')}</p>
{#each warns as w (w)}
@@ -246,8 +315,29 @@
.tray {
display: flex;
flex-wrap: wrap;
gap: var(--sp-3) var(--sp-6);
padding: var(--sp-4) var(--sp-5) 0;
gap: var(--tray-gap, var(--sp-3) var(--sp-6));
padding: var(--tray-padding, var(--sp-4) var(--sp-5) 0);
}
.compact {
--tray-gap: 14px;
--tray-padding: 0;
--input-tile-size: var(--slot-size);
--input-tile-radius: var(--desk-slot-radius);
--input-slot-gap: var(--desk-slot-gap);
--input-slot-font: 11px;
}
.paired {
display: grid;
grid-template-columns: var(--slot-size) 14px var(--slot-size);
column-gap: var(--desk-slot-gap);
row-gap: var(--desk-slot-gap);
align-items: center;
}
.frame-label {
grid-column: 1 / -1;
height: 20px;
font-size: 11px;
color: var(--tx-3);
}
.note {
margin: 0;
@@ -1,7 +1,11 @@
<script lang="ts">
import Icon from '$lib/components/ui/Icon.svelte';
import { assetUrl } from '$lib/api';
import { IMAGE_SLOT_ACCEPT, formatBytes, uploadBaseName } from '$lib/comfy/imageFile';
import {
IMAGE_SLOT_ACCEPT,
formatBytes,
uploadBaseName,
} from '$lib/comfy/imageFile';
import { t } from '$lib/i18n.svelte';
/**
@@ -12,6 +16,7 @@
*/
interface Props {
label: string;
caption?: boolean;
/** Hint after the label, e.g. 必填 · 1 张. */
hint?: string;
required?: boolean;
@@ -36,6 +41,7 @@
let {
label,
caption = false,
hint,
required = false,
value,
@@ -65,7 +71,9 @@
const filled = $derived(!!value && !broken);
const meta = $derived(
[uploadBaseName(value), dims, size != null ? formatBytes(size) : ''].filter(Boolean).join(' · '),
[uploadBaseName(value), dims, size != null ? formatBytes(size) : '']
.filter(Boolean)
.join(' · '),
);
function accept(): string {
@@ -106,17 +114,21 @@
</script>
<div class="a-slot">
<div class="a-slot-label">
<span>{label}</span>
{#if required}<span class="req" aria-hidden="true">*</span>{/if}
{#if hint}<span>{hint}</span>{/if}
{#if onFromScene}<button type="button" class="a-btn ghost xs" onclick={onFromScene}>从三维搭景截取</button>{/if}
{#if allowFromResults && onFromResults}
<button type="button" class="a-btn ghost xs" onclick={onFromResults}>
{t('studio.slotFromResultsShort')}
</button>
{/if}
</div>
{#if !caption}<div class="a-slot-label">
<span>{label}</span>
{#if required}<span class="req" aria-hidden="true">*</span>{/if}
{#if hint}<span>{hint}</span>{/if}
{#if onFromScene}<button
type="button"
class="a-btn ghost xs"
onclick={onFromScene}>从三维搭景截取</button
>{/if}
{#if allowFromResults && onFromResults}
<button type="button" class="a-btn ghost xs" onclick={onFromResults}>
{t('studio.slotFromResultsShort')}
</button>
{/if}
</div>{/if}
<input
bind:this={fileInput}
@@ -133,7 +145,12 @@
<div class="a-slot-row">
{#if filled}
<div class="a-tile" class:drag={dragDepth > 0} class:bad={!!error} {...dragHandlers}>
<div
class="a-tile"
class:drag={dragDepth > 0}
class:bad={!!error}
{...dragHandlers}
>
<button
type="button"
id={anchorId}
@@ -144,17 +161,31 @@
onclick={pick}
>
{#if kind === 'image'}
<img src={assetUrl(value)} alt="" onload={noteDims} onerror={() => (broken = true)} />
<img
src={assetUrl(value)}
alt=""
onload={noteDims}
onerror={() => (broken = true)}
/>
{:else if kind === 'video'}
<video src={assetUrl(value)} preload="metadata" muted playsinline></video>
<video src={assetUrl(value)} preload="metadata" muted playsinline
></video>
{:else}
<Icon name="music" size={18} />
{/if}
</button>
{#if caption}<span class="a-slot-caption"
>{label}{hint ? ` · ${hint}` : ''}</span
>{/if}
{#if uploading}
<span class="a-tile-ring" title={uploadName}><i></i></span>
{:else}
<button type="button" class="a-tile-x" aria-label={`${t('studio.deleteBtn')} ${label}`} onclick={onClear}>
<button
type="button"
class="a-tile-x"
aria-label={`${t('studio.deleteBtn')} ${label}`}
onclick={onClear}
>
<Icon name="close" size={10} />
</button>
{/if}
@@ -177,12 +208,23 @@
{#if uploading}
<span class="a-tile-ring" title={uploadName}><i></i></span>
{:else}
<Icon name={kind === 'video' ? 'film' : kind === 'audio' ? 'music' : 'plus'} size={18} />
<Icon
name={kind === 'video'
? 'film'
: kind === 'audio'
? 'music'
: 'plus'}
size={18}
/>
{#if caption}<span class="a-slot-caption"
>{label}{hint ? ` · ${hint}` : ''}</span
>{/if}
{/if}
</button>
{/if}
</div>
{#if error?.trim()}<span class="a-slot-hint err" role="alert">{error}</span>{/if}
{#if error?.trim()}<span class="a-slot-hint err" role="alert">{error}</span
>{/if}
</div>
<style>
@@ -4,6 +4,11 @@
* Each entry is SVG inner content rendered by Icon.svelte via {@html}.
*/
export const icons = {
// Result controls from the approved HEL-716/726 sample.
"result-expand": '<path d="M4 9V4h5M20 9V4h-5M4 15v5h5M20 15v5h-5"/>',
"result-down": '<path d="M12 4v12M6 11l6 6 6-6M4 20h16"/>',
"result-reuse":
'<path d="M4 12a8 8 0 0 1 14-5.3L20 9M20 4v5h-5M20 12a8 8 0 0 1-14 5.3L4 15M4 20v-5h5"/>',
"agent-cpu":
'<rect x="6" y="6" width="12" height="12" rx="2"/><path d="M9 2v4M15 2v4M9 18v4M15 18v4M2 9h4M2 15h4M18 9h4M18 15h4"/>',
"agent-send": '<path d="M4 12 20 4l-6 16-3-7z"/>',
+19 -6
View File
@@ -475,7 +475,7 @@ button.status-chip:active {
.a-slot {
display: flex;
flex-direction: column;
gap: var(--sp-2);
gap: var(--input-slot-gap, var(--sp-2));
min-width: 0;
}
.a-slot-label {
@@ -483,7 +483,7 @@ button.status-chip:active {
align-items: center;
gap: var(--sp-2);
min-height: 20px;
font-size: var(--fs-mini);
font-size: var(--input-slot-font, var(--fs-mini));
color: var(--text-3);
white-space: nowrap;
}
@@ -507,11 +507,11 @@ button.status-chip:active {
flex: none;
display: grid;
place-items: center;
width: 58px;
height: 58px;
width: var(--input-tile-size, 58px);
height: var(--input-tile-size, 58px);
padding: 0;
border: 1px solid var(--line-2);
border-radius: var(--r-md);
border-radius: var(--input-tile-radius, var(--r-md));
background: var(--bg-3);
color: var(--text-3);
font-family: inherit;
@@ -546,6 +546,19 @@ button.a-tile:disabled {
border-color: var(--err);
color: var(--err);
}
.a-slot-caption {
position: absolute;
left: 0;
right: 0;
bottom: 0;
z-index: 1;
font-size: 10px;
text-align: center;
color: var(--on-media);
background: var(--desk-slot-caption-bg);
padding: 6px 0 1px;
pointer-events: none;
}
.a-tile-hit {
position: absolute;
inset: 0;
@@ -782,7 +795,7 @@ button.a-tile:disabled {
}
.a-menu-meta {
flex-shrink: 0;
font-size: var(--fs-mini);
font-size: var(--input-slot-font, var(--fs-mini));
color: var(--text-3);
white-space: nowrap;
}
+59
View File
@@ -118,7 +118,46 @@
--project-card-radius: var(--r-lg);
--project-confirm-radius: 10px;
--project-draft-radius: 12px;
--agent-orbit-size: 250px;
--agent-orbit-outer-size: 300px;
--agent-orbit-period: 24s;
--agent-orbit-outer-period: 40s;
--slot-size: 56px;
--desk-inset: 16px;
--desk-bottom: 14px;
--desk-slot-gap: 6px;
--desk-slot-radius: 8px;
--desk-first-last-column: calc(2 * var(--slot-size) + 51px);
--desk-slot-caption-bg: linear-gradient(transparent, rgba(0, 0, 0, 0.8));
--desk-chip-radius: 7px;
--desk-icon-stroke: 1.7;
--desk-row-radius: 10px;
--desk-chip-height: 28px;
--desk-chip-size: 12px;
--desk-chip-padding: 0 10px;
--desk-chip-bg: rgb(var(--ink) / 0.04);
--desk-meta-offset: 10px;
--desk-glyph-size: 28px;
--desk-glyph-bg: color-mix(in srgb, var(--scene) 16%, transparent);
--desk-glyph-shadow: 0 0 18px
color-mix(in srgb, var(--scene) 25%, transparent);
--desk-group-height: 26px;
--desk-row-height: 50px;
--desk-row-gap: 10px;
--desk-row-padding: 8px;
--desk-family-qwen: #8aa2ff;
--desk-family-krea: #ff86b0;
--desk-family-flux: #62d2ff;
--desk-family-zimg: #a6e16a;
--desk-family-h3: #ff9a52;
--desk-family-wan: #c79bff;
--desk-family-anima: #ffb86b;
--desk-family-ink: #0c0e14;
--desk-family-size: 34px;
--desk-family-font: 700 11px/1 var(--mono);
--desk-row-name-size: 13px;
--desk-row-name-weight: 500;
--desk-row-detail-size: 11px;
--prompt-height: 60px;
--desk-dock-radius: 14px;
--desk-dock-bg: linear-gradient(
@@ -244,11 +283,27 @@
--viewfinder-inset: -1px;
--viewfinder-selected-inset: -3px;
--desk-player-offset: -50%;
--desk-player-controls-height: 40px;
--desk-player-controls-bg: #0b0d13;
--desk-player-toggle-bg: #f3efe6;
--desk-player-muted: #aaa79e;
--desk-player-track-height: 4px;
--desk-player-track-bg: rgb(var(--ink) / 0.14);
--desk-player-thumb-size: 12px;
--desk-player-hover-scale: 1.08;
--desk-media-bg: #000;
--desk-media-radius: 8px;
--desk-stage-padding: 12px 16px 8px;
--desk-layout-gap: 0px;
--desk-action-height: 40px;
--desk-history-banner-height: 34px;
--desk-history-banner-gap: 10px;
--desk-history-banner-padding: 0 6px 0 14px;
--desk-history-banner-border: color-mix(
in srgb,
var(--info) 50%,
transparent
);
--desk-history-padding: 8px;
--desk-history-gap: 8px;
--desk-history-label-bg: rgba(0, 0, 0, 0.6);
@@ -568,6 +623,8 @@
--r-md: 6px;
--r-lg: 8px;
--desk-dock-radius: 8px;
--desk-chip-radius: 4px;
--desk-row-radius: 4px;
--desk-gen-radius: 4px;
--desk-seg-radius: 4px;
--desk-seg-selected-radius: 4px;
@@ -668,6 +725,8 @@
--r-md: 12px;
--r-lg: 18px;
--desk-dock-radius: 18px;
--desk-chip-radius: 14px;
--desk-row-radius: 14px;
--desk-gen-radius: 999px;
--desk-gen-width: 140.7px;
--desk-action-radius: 8px;
@@ -0,0 +1,41 @@
# HEL-836 · 按原件还原创作台与双环动效
从 `leefer/hel-731-studio-project@a45b6c94f93f2571517d4fad125b0066002ffec0` 新建 `fix/hel-836-original-desk-orbits`,另开 PR;未续交已合并的 #12,未合并或部署。
## 原件与实际偏差
使用任务附件的 HEL-716 原始 HTML 与 HEL-726 四主题 HTML,独立保存、原件不改。716 SHA256 `f9c0d5d8f1092f23575c2045ca41d05473c68a8c000f99feab20601fb37357db`;726 SHA256 `0473d80d22b90194375f3566c1b8ca30dbd7433581b18f60956eabca252242a4`。仓库 `design/nocturne/reference.html` 的 SHA 与附件不同,本次没有把它或后来的实现截图当作定版。
716→726 的 `renderSlots`、`renderPrompts`、`renderViewport`、`fitFrame`、`renderActbar`、`renderHist`、`renderCreate` 逐字相同;`renderChips` 的变化只是尚未接入云端项不显示费用。四主题改变外观,没有授权更换创作台结构或双环轨迹。
修前实际偏差:舞台左右 inset 重复;素材成为文字按钮;参数胶囊字号/高度偏小;信息条游离于作品之外;视频用原生悬浮控件;历史条占用高度与原件不一致;工作流行的字号、族色块和组头偏离原件。双环 `animation-name: spin` 存在,但该全局样式文件没有相应 keyframes,实际 animation 实例数为 0,两个点停在相同角度。
## 实现
- `static/themes.css` 是新增和修正的视觉值来源,既有组件消费 token;不增加依赖或另一套覆盖样式。
- `StudioDesk` 还原页头、舞台、底座各自 inset;历史提示使用原件 34px 浮条及舞台内 34px 留位,查看历史不改当前输入。
- `Composer` / `SlotTray` / `MediaSlot` 复用同一上传、替换、清除、结果选择组件。底座直接显示真实声明的素材槽;首尾帧并排显示,完整素材编辑器与搭景入口保留。无图片/遮罩声明的工作流不新增槽位。
- 参数胶囊恢复 28px/12px 与各主题圆角。`Artboard` 把真实信息标签移入作品,恢复原件图标、40px 视频控制栏、手动播放/暂停/进度/循环/全屏;视频画面与控制栏一起计入 contain。所有素材和生成逻辑沿用原接口。
- `DeskWorkflowRail` 还原 34px 家族色块、13px/500 名称、组头计数/展开收起、置顶图标和底部管理入口;家族色只使用已声明 metadata,未知模型保持未知。既有收藏与模型筛选继续可用。
- `HistoryRail` 用真实日期分组,保持缩略图比例。清除 `StudioDesk` 原有重复的历史/选择器 CSS 定义。
- 智能体只修环:同一中心、250/300px、内环顺时针 24s、外环反向 40s,增加有命名空间的 `agent-orbit` keyframes。保持原件初始相位,未人为把两个点改为对向。克制/关闭/系统减少动态及既有切回完整不在当前场景重启循环的规则保留。
## 自测与证据
`npm run lint`、`npm run check`(0 errors / 0 warnings)、`npm test`、`npm run build` 和 `git diff --check`。构建仅保留既有第三方导入及大包提示。
`e2e/hel729.cjs` 保留真实隔离 API→假 ComfyUI 回归,选择器适配复用的素材组件:提示词、独立草稿、参数取消/保存、上传/遮罩/首尾帧/多参考、历史显式复用、失败/重试/取消、图片/视频切换、手动播放、四主题不重建编辑器、项目回流和存储受限。
`e2e/hel836.cjs` 对冻结修前构建、修后构建和附件原件实际运行比较:四主题 × 1440×900 / 1366×768 / 1280×720,输出图片、参数弹层、历史、视频的三方截图和关键 DOM 数表。原件的演示媒体转为 PNG/MP4 后经过隔离的真实上传/生成 API,原文件不改;运行时内容映射独立记录在 JSON。原样本重排后重新调用自身 `renderViewport` 测量稳定布局,避免读取上一布局的尺寸。
双环分别在 0/3000/9000ms 停帧测量矩阵、中心、圆点坐标,并另检验自然流逝 600ms 的位移。矩阵与原件逐项相同;修前无动画实例的事实也记录。静态截图不伪称录屏。
证据通过任务附件交付;本地临时路径不作为读者可访问的交付地址。复现需要运行环境已有 Playwright/Chromium、浏览器系统库及 ffmpeg;未加入项目依赖。通过 `HEL836_ORIGINAL`、`HEL836_THEMED`、`FFMPEG_BINARY` 指定原件与工具;保留 `../evidence/before/build` 的基底冻结构建后执行既有 `e2e/run-hel733.py`,用 `HEL733_TESTS` 指定两个脚本。
## 验收边界
这是针对本卡两处返工的待审核代码,不沿用旧“全部通过”结论。数表中的结构几何对齐不等于整页像素差为零:真实工作流数量、可运行用途、名称、连接与延迟、参数声明、日期、编号、图片分辨率和任务状态仍由真实 API 决定;样本有演示字段,未伪造为后端能力。模型筛选/收藏与完整素材编辑器属于保留能力。跨工作流直接送视频仍诚实禁用;视频结果作为首帧需要先导出帧,未伪造演示图代替视频。
项目/阶段/待确认语义未改,未添加翻译润色控件,未修改素材库/工作室设计,未动真实 GPU、收费服务、线上数据库、配置、密钥或端口。独立视觉审核、测试站部署及真实模型质量不在本次本地验证内。
隔离脚本在 finally 中先核对 Multica daemon PID,再精确停止自建服务并删除临时 SQLite/媒体;保留取证产物。无后台交付服务。
+5 -5
View File
@@ -198,10 +198,10 @@ async function shot(p, name) {
await p.locator("#desk-prompt").fill("视频独立草稿");
await p.getByRole("button", { name: "管理素材", exact: true }).click();
const first = p
.locator(".a-slot")
.locator(".media-editor .a-slot")
.filter({ has: p.getByText("首帧", { exact: true }) }),
last = p
.locator(".a-slot")
.locator(".media-editor .a-slot")
.filter({ has: p.getByText("尾帧", { exact: true }) });
await first
.locator("input[type=file]")
@@ -224,7 +224,7 @@ async function shot(p, name) {
ok(
"图视频切换恢复视频工作流和上传素材",
(await p.locator("#desk-prompt").inputValue()) === "视频独立草稿" &&
(await p.locator(".media-token img").count()) === 2,
(await p.locator(".media-summary img").count()) === 2,
);
const v = await submit(p, f.h3, true);
const player = p.locator(".print video");
@@ -299,7 +299,7 @@ async function shot(p, name) {
(await p
.locator("#desk-prompt")
.evaluate((e) => e === window.__editor)) &&
(await p.locator(".media-token img").count()) === 2,
(await p.locator(".media-summary img").count()) === 2,
);
await shot(p, "1280-theme-" + id);
}
@@ -327,7 +327,7 @@ async function shot(p, name) {
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");
const imageSlots = p.locator(".media-editor .a-slot");
ok("图生图与遮罩独立槽位", (await imageSlots.count()) === 2);
for (let i = 0; i < 2; i++) {
await imageSlots
+859
View File
@@ -0,0 +1,859 @@
// Frozen original, before and after builds; isolated API, no external generation.
const { chromium } = require(
process.env.PLAYWRIGHT_MODULE_PATH || "playwright",
);
const fs = require("node:fs"),
path = require("node:path"),
http = require("node:http"),
assert = require("node:assert/strict");
const OUT = "../evidence/hel836";
fs.mkdirSync(OUT, { recursive: true });
const original = path.resolve(
process.env.HEL836_ORIGINAL ||
"../originals/01a15a1e-dd09-719d-88ee-d05a5a67e5f6/夜片场-交互样本.html",
);
const themed = path.resolve(
process.env.HEL836_THEMED ||
"../originals/01a15ddb-c076-7a58-a0e0-72b98a338c63/夜片场-多主题交互样本.html",
);
const { execFileSync } = require("node:child_process");
const API = "http://127.0.0.1:8247",
WEB = "http://127.0.0.1:5173";
const report = {
geometry: [],
motion: [],
checks: [],
errors: [],
mapping: {},
};
const check = (name, value) => {
report.checks.push({ name, pass: !!value });
assert(value, name);
console.log("PASS", name);
};
const pause = (ms) => new Promise((r) => setTimeout(r, ms));
const api = async (p, b, method) => {
const r = await fetch(API + p, {
method: method || (b ? "POST" : "GET"),
headers: { "Content-Type": "application/json" },
body: b ? JSON.stringify(b) : undefined,
});
assert(r.ok, await r.clone().text());
return r.json();
};
const server = http.createServer((req, res) => {
if (req.url.startsWith("/api/")) {
const q = http.request(
API + req.url,
{ method: req.method, headers: req.headers },
(r) => {
res.writeHead(r.statusCode, r.headers);
r.pipe(res);
},
);
req.pipe(q);
res.on("close", () => q.destroy());
q.on("error", () => res.end());
return;
}
if (req.url.startsWith("/sample")) {
res.setHeader("Content-Type", "text/html;charset=utf-8");
res.end(fs.readFileSync(req.url.includes("716") ? original : themed));
return;
}
let file = path.join(
path.resolve("../evidence/before/build"),
decodeURIComponent(req.url.split("?")[0]),
);
if (!fs.existsSync(file) || fs.statSync(file).isDirectory())
file = path.resolve("../evidence/before/build/index.html");
res.setHeader(
"Content-Type",
{
".js": "text/javascript",
".css": "text/css",
".html": "text/html",
".woff2": "font/woff2",
".svg": "image/svg+xml",
".jpg": "image/jpeg",
}[path.extname(file)] || "application/octet-stream",
);
fs.createReadStream(file).pipe(res);
});
const sockets = new Set();
server.on("connection", (s) => {
sockets.add(s);
s.on("close", () => sockets.delete(s));
});
async function geometry(p, sample) {
return p.evaluate((sample) => {
const sel = sample
? {
rail: ".wfb",
head: ".cc>.ph",
stage: ".stage",
viewport: "#viewport",
frame: ".frame",
actions: "#actbar",
dock: "#dock",
slots: "#slots",
positive: "#posTa",
negative: "#negTa",
params: ".pchip",
gen: "#genBtn",
history: ".hist",
}
: {
rail: ".workflow-rail",
head: ".studio-heading",
stage: ".viewer",
viewport: ".stage-media",
frame: ".print",
actions: ".artboard-footer",
dock: ".dock .composer",
slots: ".media-summary",
positive: "#desk-prompt",
negative: "#desk-negative",
params: ".parameter",
gen: ".gen",
history: ".history-rail",
};
return Object.fromEntries(
Object.entries(sel).map(([k, s]) => {
const e = document.querySelector(s);
if (!e) return [k, null];
const r = e.getBoundingClientRect(),
c = getComputedStyle(e);
return [
k,
{
x: r.x,
y: r.y,
w: r.width,
h: r.height,
font: c.fontSize,
weight: c.fontWeight,
radius: c.borderRadius,
border: c.borderTopWidth,
padding: c.padding,
},
];
}),
);
}, sample);
}
(async () => {
await new Promise((r) => server.listen(0, "127.0.0.1", r));
const OLD = "http://127.0.0.1:" + server.address().port;
const b = await chromium.launch({
args: ["--no-sandbox", "--disable-dev-shm-usage"],
});
try {
const ctx = await b.newContext({ viewport: { width: 1440, height: 900 } });
await ctx.addInitScript(() => localStorage.setItem("aigc-lang", "zh"));
const p = await ctx.newPage(),
before = await ctx.newPage(),
s = await ctx.newPage(),
v1 = await ctx.newPage();
for (const q of [p, before])
q.on("pageerror", (e) => report.errors.push(e.message));
await s.goto(OLD + "/sample726?still=1&fill=1#create/image");
await v1.goto(OLD + "/sample716?still=1&fill=1#create/image");
const data = await s.evaluate(async () => {
const i = new Image();
i.src = st.hist.image[0].img;
await i.decode();
const c = document.createElement("canvas");
c.width = i.naturalWidth;
c.height = i.naturalHeight;
c.getContext("2d").drawImage(i, 0, 0);
const fills = {};
for (const [key, src] of Object.entries(st.fills[curWF().id])) {
const img = new Image();
img.src = src;
await img.decode();
const cv = document.createElement("canvas");
cv.width = img.naturalWidth;
cv.height = img.naturalHeight;
cv.getContext("2d").drawImage(img, 0, 0);
fills[key] = cv.toDataURL("image/png");
}
return {
img: c.toDataURL("image/png"),
pos: $("#posTa").value,
neg: $("#negTa").value,
wf: curWF(),
fills,
};
});
fs.writeFileSync(
OUT + "/sample-media.png",
Buffer.from(data.img.split(",")[1], "base64"),
);
for (const old of await api("/api/playground/jobs"))
await api("/api/playground/jobs/" + old.id, undefined, "DELETE");
const f = JSON.parse(fs.readFileSync("e2e/shots/reference/fixtures.json"));
const base = await api("/api/workflows/" + f.dual);
const wf = await api("/api/workflows", {
name: data.wf.name,
kind: "image",
workflow_json: {
...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 form = new FormData();
form.append(
"file",
new Blob([fs.readFileSync(OUT + "/sample-media.png")], {
type: "image/png",
}),
"sample.png",
);
const upload = await (
await fetch(API + "/api/playground/uploads", {
method: "POST",
body: form,
})
).json();
const filled = {};
for (const [key, src] of Object.entries(data.fills)) {
const form = new FormData();
form.append(
"file",
new Blob([Buffer.from(src.split(",")[1], "base64")], {
type: "image/png",
}),
key + ".png",
);
filled[key] = await (
await fetch(API + "/api/playground/uploads", {
method: "POST",
body: form,
})
).json();
}
fs.writeFileSync(
"e2e/fake_mode.json",
JSON.stringify({
mode: "ok",
unique_filename: true,
image_file: path.resolve(OUT + "/sample-media.png"),
}),
);
const j = await api("/api/playground/generate", {
workflow_id: wf.id,
input_values: {
1: data.pos,
2: data.neg,
3: 42,
5: Object.entries(filled).find(([key]) => key.startsWith("input"))[1]
.path,
6: Object.entries(filled).find(([key]) => key.startsWith("mask"))[1]
.path,
},
});
let job;
for (let i = 0; i < 150; i++) {
job = (await api("/api/playground/jobs")).find((x) => x.id === j.job.id);
if (job.status === "done") break;
await pause(100);
}
assert.equal(job.status, "done");
await api(
"/api/workflows/" + wf.id,
{ model_family: "Qwen", source_path: data.wf.file },
"PATCH",
);
for (const q of [s, v1])
await q.evaluate((id) => {
st.hist.image = [{ ...st.hist.image[0], n: id }];
renderCreate();
}, job.id);
report.mapping = {
workflow: wf.id,
job: job.id,
input_values: job.payload.input_values,
original: data.wf,
media: "same sample PNG through isolated real API",
};
for (const q of [p, before]) {
await q.goto((q === p ? WEB : OLD) + "/create?wf=" + wf.id);
await q.locator("#desk-negative").waitFor();
await q
.locator(".history-take")
.filter({ hasText: "#" + job.id })
.click();
await q.getByRole("button", { name: "复用参数", exact: true }).click();
if (
await q
.getByRole("button", { name: "返回当前创作", exact: true })
.count()
)
await q
.getByRole("button", { name: "返回当前创作", exact: true })
.click();
await q.locator(".print img").waitFor();
await q.locator(".print img").evaluate((e) => e.decode());
}
const count = await p.locator(".workflow-pick").count();
await p.locator(".family-label").first().click();
check(
"workflow family collapses without selecting another workflow",
(await p.locator(".workflow-pick").count()) < count &&
(await p.locator(".model-selector").innerText()) === data.wf.name,
);
await p.locator(".family-label").first().click();
check(
"workflow family expands",
(await p.locator(".workflow-pick").count()) === count,
);
for (const q of [p, before]) {
await q
.locator(".workflow-row.selected .row-actions button")
.first()
.click();
await q.locator(".rail-list").evaluate((e) => (e.scrollTop = 0));
}
for (const q of [p, before])
await q.locator("#desk-prompt").evaluate((e) => {
e.blur();
e.scrollTop = 0;
});
await pause(3500);
for (const [width, height] of [
[1440, 900],
[1366, 768],
[1280, 720],
])
for (const theme of ["night", "day", "grade", "bluehour"]) {
for (const q of [p, before, s, v1]) {
await q.setViewportSize({ width, height });
await q.evaluate(
(t) => (document.documentElement.dataset.theme = t),
theme,
);
}
await s.evaluate(() => renderCreate());
await v1.evaluate(() => renderCreate());
await pause(350);
const row = {
width,
height,
theme,
after: await geometry(p, false),
before: await geometry(before, false),
sample: await geometry(s, true),
v1: await geometry(v1, true),
};
report.geometry.push(row);
for (const key of [
"head",
"stage",
"viewport",
"actions",
"dock",
"positive",
"negative",
"history",
])
check(
`${width}/${theme}/${key} geometry matches original`,
["x", "y", "w", "h"].every(
(k) => Math.abs(row.after[key][k] - row.sample[key][k]) <= 1,
),
);
check(
`${width}/${theme} parameter capsules`,
row.after.params.h === 28 && row.after.params.font === "12px",
);
check(
`${width}/${theme} no horizontal overflow`,
await p.evaluate(
() => document.documentElement.scrollWidth <= innerWidth,
),
);
for (const [q, label] of [
[p, "after"],
[before, "before"],
[s, "726"],
[v1, "716"],
])
if (theme === "night" || label !== "716")
await q.screenshot({
path: `${OUT}/${width}-${theme}-${label}.png`,
});
}
// Same viewport parameter dialogs, historical state and landscape video.
for (const [width, height] of [
[1440, 900],
[1366, 768],
[1280, 720],
])
for (const theme of ["night", "day", "grade", "bluehour"]) {
for (const q of [p, before, s]) {
await q.setViewportSize({ width, height });
await q.evaluate(
(t) => (document.documentElement.dataset.theme = t),
theme,
);
}
for (const q of [p, before])
await q
.getByRole("button", { name: "创作设置⌄", exact: true })
.click();
await s.evaluate(() => openPop());
await pause(250);
for (const [q, label] of [
[p, "after"],
[before, "before"],
[s, "726"],
])
await q.screenshot({
path: `${OUT}/params-${width}-${theme}-${label}.png`,
});
const pop = await p.locator(".anchored .modal-panel").boundingBox();
check(
`parameter dialog reachable ${width}/${theme}`,
pop.y >= 0 &&
pop.x >= 0 &&
pop.x + pop.width <= width &&
pop.y + pop.height <= height,
);
for (const q of [p, before]) await q.keyboard.press("Escape");
await s.evaluate(() => $("#paramPop").classList.remove("on"));
}
for (const q of [p, before])
await q
.locator(".history-take")
.filter({ hasText: "#" + job.id })
.click();
await s.evaluate(() => {
st.viewing = st.hist.image[0];
renderHist();
renderViewport();
});
const currentInput = await p.locator("#desk-prompt").inputValue();
for (const [width, height] of [
[1440, 900],
[1366, 768],
[1280, 720],
])
for (const theme of ["night", "day", "grade", "bluehour"]) {
for (const q of [p, before, s]) {
await q.setViewportSize({ width, height });
await q.evaluate(
(t) => (document.documentElement.dataset.theme = t),
theme,
);
}
await pause(200);
await s.evaluate(() => renderViewport());
for (const [q, label] of [
[p, "after"],
[before, "before"],
[s, "726"],
])
await q.screenshot({
path: `${OUT}/history-${width}-${theme}-${label}.png`,
});
const actual = await geometry(p, false),
sample = await geometry(s, true);
report.geometry.push({
width,
height,
theme,
state: "history",
actual,
sample,
});
check(
`history preserves input and stage geometry ${width}/${theme}`,
currentInput === (await p.locator("#desk-prompt").inputValue()) &&
["x", "y", "w", "h"].every(
(k) => Math.abs(actual.viewport[k] - sample.viewport[k]) <= 1,
) &&
(await p
.locator(".history-banner")
.evaluate((e) => e.getBoundingClientRect().height === 34)),
);
}
for (const q of [p, before])
await q
.getByRole("button", { name: "返回当前创作", exact: true })
.click();
await s.evaluate(() => {
st.viewing = null;
renderHist();
renderViewport();
});
// Fixture video uses the exact still from the original player; only playback is real.
await s.evaluate(() => {
st.kind = "video";
renderCreate();
});
const videoData = await s.evaluate(async () => {
const it = st.hist.video[0],
i = new Image();
i.src = it.img;
await i.decode();
const c = document.createElement("canvas");
c.width = i.naturalWidth;
c.height = i.naturalHeight;
c.getContext("2d").drawImage(i, 0, 0);
const fills = {};
for (const [key, src] of Object.entries(st.fills[curWF().id])) {
const im = new Image();
im.src = src;
await im.decode();
const cv = document.createElement("canvas");
cv.width = im.naturalWidth;
cv.height = im.naturalHeight;
cv.getContext("2d").drawImage(im, 0, 0);
fills[key] = cv.toDataURL("image/png");
}
return {
img: c.toDataURL("image/png"),
pos: prompts.video[0],
neg: prompts.video[1],
wf: curWF(),
fills,
};
});
fs.writeFileSync(
OUT + "/video-still.png",
Buffer.from(videoData.img.split(",")[1], "base64"),
);
execFileSync(
process.env.FFMPEG_BINARY || "ffmpeg",
[
"-y",
"-loop",
"1",
"-i",
path.resolve(OUT + "/video-still.png"),
"-t",
"5",
"-r",
"12",
"-pix_fmt",
"yuv420p",
path.resolve(OUT + "/sample.mp4"),
],
{ stdio: "ignore" },
);
fs.writeFileSync(
"e2e/fake_mode.json",
JSON.stringify({
mode: "ok",
videos: true,
unique_filename: true,
video_file: path.resolve(OUT + "/sample.mp4"),
}),
);
const h3 = await api("/api/workflows/" + f.h3);
const videoWF = await api("/api/workflows", {
name: videoData.wf.name,
kind: "video",
workflow_json: {
...h3.workflow_json,
4: {
class_type: "CLIPTextEncode",
inputs: { text: videoData.neg },
_meta: { title: "(Input:negative) 反向提示词" },
},
},
});
await api(
"/api/workflows/" + videoWF.id,
{ model_family: "MiniMax-H3", source_path: videoData.wf.file },
"PATCH",
);
const vfill = {};
for (const [key, src] of Object.entries(videoData.fills)) {
const form = new FormData();
form.append(
"file",
new Blob([Buffer.from(src.split(",")[1], "base64")], {
type: "image/png",
}),
key + ".png",
);
vfill[key] = await (
await fetch(API + "/api/playground/uploads", {
method: "POST",
body: form,
})
).json();
}
const v = await api("/api/playground/generate", {
workflow_id: videoWF.id,
input_values: {
1: videoData.pos,
2: vfill.first0.path,
3: vfill.last0.path,
4: videoData.neg,
},
});
let vjob;
for (let i = 0; i < 150; i++) {
vjob = (await api("/api/playground/jobs")).find((x) => x.id === v.job.id);
if (vjob.status === "done") break;
await pause(100);
}
assert.equal(vjob.status, "done");
for (const q of [p, before]) {
await q.goto(
(q === p ? WEB : OLD) + "/create?kind=video&wf=" + videoWF.id,
);
await q
.locator(".history-take")
.filter({ hasText: "#" + vjob.id })
.click();
await q.getByRole("button", { name: "复用参数", exact: true }).click();
await q.locator(".print video").waitFor();
await q
.locator(".print video")
.evaluate(
(v) =>
new Promise((r) =>
v.readyState >= 1
? r()
: v.addEventListener("loadedmetadata", r, { once: true }),
),
);
}
await s.evaluate((id) => {
st.hist.video = [{ ...st.hist.video[0], n: id }];
$("#posTa").value = prompts.video[0];
renderCreate();
}, vjob.id);
await pause(3500);
for (const [width, height] of [
[1440, 900],
[1366, 768],
[1280, 720],
])
for (const theme of ["night", "day", "grade", "bluehour"]) {
for (const q of [p, before, s]) {
await q.setViewportSize({ width, height });
await q.evaluate(
(t) => (document.documentElement.dataset.theme = t),
theme,
);
}
await s.evaluate(() => renderCreate());
await pause(250);
await s.evaluate(() => renderViewport());
for (const [q, label] of [
[p, "after"],
[before, "before"],
[s, "726"],
])
await q.screenshot({
path: `${OUT}/video-${width}-${theme}-${label}.png`,
});
const frame = await p.locator(".print").boundingBox(),
ref = await s.locator(".player").boundingBox();
report.geometry.push({
width,
height,
theme,
state: "video",
frame,
ref,
});
check(
`video 40px controls and contain ${width}/${theme}`,
(await p
.locator(".player-controls")
.evaluate((e) => e.getBoundingClientRect().height === 40)) &&
(await p
.locator("video")
.first()
.evaluate(
(v) => v.paused && getComputedStyle(v).objectFit === "contain",
)),
);
check(
`video frame geometry ${width}/${theme}`,
["x", "y", "width", "height"].every(
(k) => Math.abs(frame[k] - ref[k]) <= 1,
),
);
}
await p.getByRole("button", { name: "播放此视频", exact: true }).click();
await pause(500);
check(
"custom video button advances real playback",
await p
.locator(".print video")
.evaluate((v) => !v.paused && v.currentTime > 0),
);
await p.getByRole("button", { name: "暂停视频", exact: true }).click();
check(
"custom pause works",
await p.locator(".print video").evaluate((v) => v.paused),
);
await p.locator(".player-seek").fill("2");
check(
"seek updates real media",
await p
.locator(".print video")
.evaluate((v) => Math.abs(v.currentTime - 2) < 0.1),
);
await p.goto(WEB + "/agents");
await before.goto(OLD + "/agents");
await s.goto(OLD + "/sample726#agents");
await p.locator(".orb").waitFor();
await pause(400);
for (const [q, label] of [
[p, "after"],
[before, "before"],
[s, "sample"],
]) {
for (const t of [0, 3000, 9000]) {
const r = await q.evaluate(
(t) =>
[...document.querySelectorAll(".orb .ring")].map((e) => {
const animations = e.getAnimations();
for (const a of animations) {
a.pause();
a.currentTime = t;
}
const c = getComputedStyle(e),
dot = getComputedStyle(e, "::before"),
m = new DOMMatrix(c.transform),
r = e.getBoundingClientRect(),
w = e.offsetWidth,
h = e.offsetHeight;
const x =
parseFloat(dot.left) + parseFloat(dot.width) / 2 - w / 2,
y = parseFloat(dot.top) + parseFloat(dot.height) / 2 - h / 2;
return {
w,
h,
animation: c.animationName,
count: animations.length,
center: { x: r.x + r.width / 2, y: r.y + r.height / 2 },
dot: {
x: r.x + r.width / 2 + m.a * x + m.c * y,
y: r.y + r.height / 2 + m.b * x + m.d * y,
},
transform: c.transform,
};
}),
t,
);
report.motion.push({ label, t, rings: r });
await q.screenshot({ path: `${OUT}/rings-${label}-${t}.png` });
}
}
for (const row of report.motion.filter((r) => r.label === "after")) {
const original = report.motion.find(
(r) => r.label === "sample" && r.t === row.t,
);
check(
`ring paths match sample at ${row.t}ms`,
row.rings.every(
(r, i) =>
r.count === 1 &&
r.transform === original.rings[i].transform &&
r.w === original.rings[i].w &&
r.h === original.rings[i].h,
),
);
}
await p.evaluate(() =>
document.querySelectorAll(".orb .ring").forEach((e) =>
e.getAnimations().forEach((a) => {
a.currentTime = 0;
a.play();
}),
),
);
const start = await p
.locator(".orb .ring")
.first()
.evaluate((e) => getComputedStyle(e).transform);
await pause(600);
check(
"rings advance with real elapsed time",
(await p
.locator(".orb .ring")
.first()
.evaluate((e) => getComputedStyle(e).transform)) !== start,
);
await p.screenshot({ path: OUT + "/rings-natural-600ms.png" });
const mode = async (label) => {
await p.getByRole("button", { name: "主题", exact: true }).click();
await p
.locator(".theme-motion")
.getByRole("button", { name: label, exact: true })
.click();
await p.keyboard.press("Escape");
};
await mode("克制");
check(
"calm stops loops but keeps static rings",
(await p.locator(".orb").isVisible()) &&
(await p.evaluate(() =>
document
.querySelector(".orb")
.getAnimations({ subtree: true })
.every((a) => a.playState !== "running"),
)),
);
await mode("关闭");
check("off hides decorative rings", !(await p.locator(".orb").isVisible()));
await mode("完整");
await p.emulateMedia({ reducedMotion: "reduce" });
await p.waitForFunction(
() => document.documentElement.dataset.motion === "off",
);
check(
"OS reduce overrides full without changing saved choice",
await p.evaluate(
() =>
document.documentElement.dataset.motion === "off" &&
localStorage.getItem("aigc-motion") === "full",
),
);
await p.emulateMedia({ reducedMotion: "no-preference" });
await p.waitForFunction(
() => document.documentElement.dataset.motion === "full",
);
check(
"OS restoration keeps preference",
await p.evaluate(
() => document.documentElement.dataset.motion === "full",
),
);
check("no browser errors", report.errors.length === 0);
fs.writeFileSync(OUT + "/report.json", JSON.stringify(report, null, 2));
console.log("CAPTURED", report.geometry.length, report.motion.length);
for (const socket of sockets) socket.destroy();
await ctx.close();
} finally {
fs.writeFileSync(OUT + "/report.json", JSON.stringify(report, null, 2));
await b.close();
for (const socket of sockets) socket.destroy();
await new Promise((r) => server.close(r));
}
})().catch((e) => {
console.error(e);
process.exitCode = 1;
});