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