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