HEL-729: 建成生图生视频合一的夜片场创作台 #3
@@ -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;
|
||||
|
||||
@@ -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}
|
||||
@@ -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}`);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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顶栏或已裁定非偏差项;没有收费生成、合并或部署。
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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();
|
||||
}
|
||||
})();
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user