Compare commits

...
61 changed files with 10653 additions and 2755 deletions
+29 -18
View File
@@ -1,19 +1,30 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<link rel="icon" href="%sveltekit.assets%/favicon.png" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>影工坊</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
href="https://fonts.googleapis.com/css2?family=Outfit:wght@400;500;600;700&family=IBM+Plex+Sans:wght@400;500;600&family=JetBrains+Mono:wght@400;500&family=Noto+Sans+SC:wght@400;500;600&display=swap"
rel="stylesheet"
/>
%sveltekit.head%
</head>
<body data-sveltekit-preload-data="hover">
<div style="display: contents">%sveltekit.body%</div>
</body>
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8" />
<script>
(function () {
var saved;
try {
saved = localStorage.getItem("aigc-theme");
} catch (_) {}
document.documentElement.dataset.theme =
/^(night|day|grade|bluehour)$/.test(saved || "") ? saved : "night";
})();
</script>
<link rel="stylesheet" href="%sveltekit.assets%/themes.css" />
<link rel="icon" href="%sveltekit.assets%/favicon.png" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>影工坊</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
href="https://fonts.googleapis.com/css2?family=Outfit:wght@400;500;600;700&family=IBM+Plex+Sans:wght@400;500;600&family=JetBrains+Mono:wght@400;500&family=Noto+Sans+SC:wght@400;500;600&display=swap"
rel="stylesheet"
/>
%sveltekit.head%
</head>
<body data-sveltekit-preload-data="hover">
<div style="display: contents">%sveltekit.body%</div>
</body>
</html>
@@ -59,6 +59,9 @@
onCancel: (job: Job) => void;
onSendToSlot: (path: string) => void;
onDelete: (job: Job) => void;
canAttach?: boolean;
attachBusy?: boolean;
onAttach?: () => void;
}
let {
@@ -80,9 +83,13 @@
onCancel,
onSendToSlot,
onDelete,
canAttach = false,
attachBusy = false,
onAttach,
}: Props = $props();
let detailsOpen = $state(false);
let deleteOpen = $state(false);
let outIndex = $state(0);
let compare = $state(false),
compareAt = $state(50),
@@ -90,6 +97,7 @@
$effect(() => {
void job?.id;
detailsOpen = false;
deleteOpen = false;
compare = false;
lightbox = false;
});
@@ -132,16 +140,27 @@
return live?.progress ?? 3;
});
let dims = $state('');
let naturalWidth = $state(1),
naturalHeight = $state(1);
let stageWidth = $state(0),
stageHeight = $state(0);
const fitted = $derived(
Math.min(stageWidth / naturalWidth, stageHeight / naturalHeight),
);
$effect.pre(() => {
void output;
dims = '';
});
function noteImg(e: Event) {
const img = e.currentTarget as HTMLImageElement;
naturalWidth = img.naturalWidth;
naturalHeight = img.naturalHeight;
dims = `${img.naturalWidth}×${img.naturalHeight}`;
}
function noteVideo(e: Event) {
const v = e.currentTarget as HTMLVideoElement;
naturalWidth = v.videoWidth;
naturalHeight = v.videoHeight;
dims = `${v.videoWidth}×${v.videoHeight}`;
}
@@ -175,40 +194,29 @@
</script>
<div class="artboard" aria-label="生成结果">
<header class="artboard-toolbar">
<button
class="artboard-toolbar"
aria-label="作品详情与操作"
onclick={() => (detailsOpen = true)}
>
<div class="result-title">
<span>第 {job?.id} 次生成</span><small>{wf?.name ?? '历史作品'}</small>
</div>
<div class="canvas-actions">
{#if output && !video}<button
class="canvas-btn"
onclick={() => (lightbox = true)}
aria-label="放大查看">⤢</button
>
{#if compareSource && compareSource !== output}<button
class="canvas-btn"
aria-pressed={compare}
onclick={() => (compare = !compare)}
>◫ {compare ? '结束对比' : '对比'}</button
>{/if}
{/if}
{#if output}<a
class="canvas-btn"
href={assetUrl(output)}
download
aria-label="下载生成结果">↓ 下载</a
>{/if}
<button class="canvas-btn" onclick={() => (detailsOpen = true)}
>详情与操作</button
>
</div>
</header>
</button>
<div class="stage" class:comparing={compare}>
{#if job && st}{#key mediaKey}
<div class="stage-media">
<div
class="stage-media"
bind:clientWidth={stageWidth}
bind:clientHeight={stageHeight}
>
{#if output && video}
<!-- svelte-ignore a11y_media_has_caption -->
<div class="print a-photo">
<div
class="print a-photo"
style:width={fitted > 0 ? `${naturalWidth * fitted}px` : '100%'}
style:height={fitted > 0 ? `${naturalHeight * fitted}px` : '100%'}
>
{#if st.tone === 'ok' && !fileGone}<span class="a-fix"
>{t('jobst.done')}</span
>{/if}
@@ -217,6 +225,12 @@
src={assetUrl(output)}
controls
aria-label="生成的视频,使用播放按钮开始播放"
onkeydown={(e) => {
if (e.code === 'Space' && e.target === e.currentTarget) {
e.preventDefault();
togglePlayback();
}
}}
onplay={() => (paused = false)}
onpause={() => (paused = true)}
onended={() => (paused = true)}
@@ -228,9 +242,21 @@
}}
onerror={() => output && markOutputMissing(output)}
></video>
{#if paused}<button
class="big-play"
aria-label="播放此视频"
onclick={() => {
videoEl?.focus();
togglePlayback();
}}>▶</button
>{/if}
</div>
{:else if output}
<div class="print a-photo">
<div
class="print a-photo"
style:width={fitted > 0 ? `${naturalWidth * fitted}px` : '100%'}
style:height={fitted > 0 ? `${naturalHeight * fitted}px` : '100%'}
>
{#if st.tone === 'ok' && !fileGone}<span class="a-fix"
>{t('jobst.done')}</span
>{/if}
@@ -287,19 +313,72 @@
</div>{/if}
</div>
<footer class="artboard-footer">
{#if video}<button
class="canvas-btn play-button"
onclick={togglePlayback}
aria-label={paused ? '播放视频' : '暂停视频'}
>{paused ? '▶ 播放' : 'Ⅱ 暂停'}</button
><button
class="canvas-btn"
onclick={() => videoEl?.requestFullscreen().catch(() => {})}
>全屏</button
>{/if}
<span class="result-spec"
>{dims || st?.label} · {job ? fmtDur(elapsedSec(job, nowMs)) : ''}</span
<button
class="canvas-btn"
disabled={!output}
onclick={() =>
video
? videoEl?.requestFullscreen().catch(() => {})
: (lightbox = true)}
aria-label="放大查看"><Icon name="zoom-in" size={14} />放大</button
>
<a
class="canvas-btn"
href={output ? assetUrl(output) : undefined}
aria-disabled={!output}
download
aria-label="下载生成结果"><Icon name="download" size={14} />下载</a
>
<span class="action-separator" aria-hidden="true"></span>
<button
class="canvas-btn"
disabled={!job || !output}
onclick={() => job && onReuse(job)}
><Icon name="retry" size={14} />复用参数</button
>
<button
class="canvas-btn"
disabled={!output || video || !canSendToSlot}
title={canSendToSlot
? '放入当前工作流图片输入'
: '当前工作流没有图片输入'}
onclick={() => output && onSendToSlot(output)}
><Icon name="image" size={14} />作为参考图</button
>
<button
class="canvas-btn"
disabled
title="跨工作流送图尚未具备,请选择生视频工作流后从已有结果选取"
><Icon name="film" size={14} />送去生视频</button
>
<span class="action-separator" aria-hidden="true"></span>
<button
class="canvas-btn"
disabled={!canAttach || attachBusy}
title={canAttach
? '保存到来源项目'
: '通用选择项目与阶段将在后续批次提供'}
onclick={onAttach}><Icon name="folder" size={14} />存入项目</button
>
<a class="canvas-btn" href="/assets"
><Icon name="assets" size={14} />素材库</a
>
<button
class="canvas-btn delete-output"
aria-label="删除作品记录"
title="删除作品记录"
disabled={!job || job.status === 'pending' || job.status === 'running'}
onclick={() => (deleteOpen = true)}
><Icon name="close" size={14} /></button
>
{#if job?.status === 'pending' || job?.status === 'running'}<button
class="canvas-btn"
onclick={() => job && onCancel(job)}>取消生成</button
>{/if}
{#if st?.tone === 'fail'}<button
class="canvas-btn"
onclick={() => job && onRetry(job)}>重试</button
>{/if}
{#if compare && output && !video}<label class="compare-slider"
>参考<input
type="range"
@@ -309,19 +388,41 @@
aria-label="拖动对比画面"
/>结果</label
>{/if}
{#if job?.status === 'pending' || job?.status === 'running'}<button
class="canvas-btn"
onclick={() => job && onCancel(job)}>取消生成</button
>{/if}
{#if st?.tone === 'fail'}<button
class="canvas-btn"
onclick={() => job && onRetry(job)}>重试</button
>{/if}
</footer>
</div>
<Modal bind:open={detailsOpen} size="lg" title="作品详情与操作">
{#if job && st}
<div class="detail-actions">
{#if video}<button
class="canvas-btn play-button"
onclick={togglePlayback}
aria-label={paused ? '播放视频' : '暂停视频'}
>{paused ? '▶ 播放' : 'Ⅱ 暂停'}</button
><button
class="canvas-btn"
onclick={() => videoEl?.requestFullscreen().catch(() => {})}
>全屏</button
>{/if}
<span class="result-spec"
>{dims || st?.label} · {job ? fmtDur(elapsedSec(job, nowMs)) : ''}</span
>
{#if compare && output && !video}<label class="compare-slider"
>参考<input
type="range"
min="0"
max="100"
bind:value={compareAt}
aria-label="拖动对比画面"
/>结果</label
>{/if}
{#if output && !video && compareSource && compareSource !== output}<button
class="canvas-btn"
aria-pressed={compare}
onclick={() => {
compare = !compare;
detailsOpen = false;
}}>◫ {compare ? '结束对比' : '对比'}</button
>{/if}
<button
class="a-btn secondary sm"
onclick={() => {
@@ -348,7 +449,7 @@
{#if job.status !== 'pending' && job.status !== 'running'}<button
class="a-btn danger sm"
onclick={() => {
onDelete(job);
deleteOpen = true;
detailsOpen = false;
}}>移除记录</button
>{/if}
@@ -409,6 +510,23 @@
</dl>
{/if}
</Modal>
<Modal bind:open={deleteOpen} title="删除作品记录" size="md">
<p>
确认移除这条生成记录及生成文件?关联项目中的回流记录也会移除。确认后有 5
秒可撤销,提交后无法恢复。
</p>
{#snippet footer()}<button
class="a-btn secondary"
onclick={() => (deleteOpen = false)}>取消</button
>
<button
class="a-btn danger"
onclick={() => {
if (job) onDelete(job);
deleteOpen = false;
}}>确认移除</button
>{/snippet}
</Modal>
{#if lightbox && output && !video}<ImageLightbox
src={assetUrl(output) ?? ''}
alt={prompt || '创作结果'}
@@ -417,13 +535,14 @@
<style>
.artboard {
position: relative;
display: grid;
grid-template-rows: 38px minmax(0, 1fr) 32px;
grid-template-rows: 0 minmax(0, 1fr) 40px;
height: 100%;
min-height: 0;
border: 1px solid #ffffff0d;
border-radius: 12px;
background: radial-gradient(ellipse at 50% 20%, #202028, #131316 70%);
border: 0;
border-radius: 0;
background: transparent;
overflow: hidden;
}
.artboard-toolbar,
@@ -431,10 +550,24 @@
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
gap: 6px;
padding: 0 12px;
min-width: 0;
}
.artboard-toolbar {
position: absolute;
top: 10px;
left: 10px;
z-index: 2;
border-radius: 4px;
background: var(--media-scrim);
color: var(--on-media);
padding: 2px 6px;
max-width: calc(100% - 20px);
border: 0;
cursor: pointer;
text-align: left;
}
.result-title {
display: flex;
align-items: center;
@@ -451,25 +584,46 @@
text-overflow: ellipsis;
max-width: 220px;
}
.canvas-actions {
display: flex;
gap: 4px;
flex-shrink: 0;
}
.canvas-btn {
background: #ffffff04;
display: inline-flex;
align-items: center;
gap: 6px;
height: 26px;
box-sizing: border-box;
background: var(--bg-2);
color: var(--text-2);
border: 1px solid #ffffff0a;
border-radius: 6px;
padding: 5px 8px;
font-size: 10px;
border: 1px solid var(--line);
border-radius: var(--desk-action-radius);
padding: 0 9px;
font-size: 12px;
font-weight: 500;
cursor: pointer;
text-decoration: none;
white-space: nowrap;
}
.action-separator {
width: 1px;
height: 18px;
flex-shrink: 0;
margin: 0 4px;
background: var(--line-2);
}
.delete-output {
width: 26px;
padding: 0;
justify-content: center;
color: var(--err);
background: transparent;
border-color: transparent;
}
.canvas-btn:disabled,
.canvas-btn[aria-disabled='true'] {
opacity: 0.45;
cursor: not-allowed;
}
.canvas-btn:hover {
color: var(--text);
background: #ffffff0a;
background: var(--bg-3);
}
.canvas-btn[aria-pressed='true'],
.play-button {
@@ -477,6 +631,7 @@
border-color: var(--accent-line);
}
.stage {
grid-row: 2;
min-height: 0;
min-width: 0;
position: relative;
@@ -485,7 +640,7 @@
.stage-media {
position: absolute;
inset: 0;
padding: 8px 20px;
padding: 0;
display: grid;
place-items: center;
min-height: 0;
@@ -503,6 +658,20 @@
background: none;
box-shadow: none;
}
.big-play {
position: absolute;
left: 50%;
top: 50%;
transform: translate(-50%, -50%);
width: 64px;
height: 64px;
border-radius: 50%;
border: 1px solid var(--on-media-line);
color: var(--on-media);
background: var(--media-scrim);
font-size: 26px;
cursor: pointer;
}
.print img,
.print video {
width: 100%;
@@ -540,7 +709,7 @@
.comparison-layer {
position: absolute;
inset: 0;
background: #17171d;
background: var(--bg-0);
pointer-events: none;
}
.comparison-divider {
@@ -552,7 +721,10 @@
pointer-events: none;
}
.artboard-footer {
justify-content: flex-start;
grid-row: 3;
justify-content: center;
gap: 6px;
padding: 0;
font-size: 10px;
color: var(--text-3);
}
@@ -578,7 +750,7 @@
gap: 4px;
max-width: 90%;
overflow: auto;
background: #101015d9;
background: var(--media-scrim);
border: 1px solid var(--line);
padding: 4px;
border-radius: 8px;
@@ -1,13 +1,16 @@
<script lang="ts">
import { onMount } from 'svelte';
import { onMount, untrack } from 'svelte';
import { goto } from '$app/navigation';
import { createMutation, useQueryClient } from '@tanstack/svelte-query';
import { workflows, apiErrorMessage } from '$lib/api';
import type { WorkflowMeta } from '$lib/comfy/types';
import { usageLabel } from '$lib/components/generate/schema';
import { spotlight } from '$lib/actions/spotlight';
import { toast } from '$lib/toast';
import Icon from '$lib/components/ui/Icon.svelte';
let {
kind,
initialUsage = 'all',
metas,
selectedId,
pendingCount,
@@ -15,9 +18,14 @@
onKind,
onDetails,
disabled = false,
historyCollapsed = false,
onToggleHistory,
}: {
disabled?: boolean;
historyCollapsed?: boolean;
onToggleHistory: () => void;
kind: 'image' | 'video';
initialUsage?: string;
metas: WorkflowMeta[];
selectedId: number | null;
pendingCount: number;
@@ -25,11 +33,47 @@
onKind: (kind: 'image' | 'video') => void;
onDetails: () => void;
} = $props();
let searchInput: HTMLInputElement;
let searchOrigin: HTMLElement | null = null;
function searchKeydown(e: KeyboardEvent) {
if (e.defaultPrevented || e.isComposing || e.keyCode === 229) return;
if (
[
...document.querySelectorAll('.shell-pop, .modal, [role="dialog"]'),
].some((el) => el.getClientRects().length)
)
return;
if (e.key === 'Escape' && e.target === searchInput) {
e.preventDefault();
const origin = searchOrigin?.isConnected
? searchOrigin
: searchInput.closest('aside')?.querySelector('button');
if (origin instanceof HTMLElement) origin.focus();
else searchInput.blur();
return;
}
const el = e.target instanceof HTMLElement ? e.target : null;
if (
e.key !== '/' ||
e.ctrlKey ||
e.metaKey ||
e.altKey ||
el?.isContentEditable ||
el?.closest(
'input, textarea, select, [contenteditable]:not([contenteditable="false"])',
) ||
!searchInput?.getClientRects().length
)
return;
e.preventDefault();
searchInput.focus();
}
const client = useQueryClient();
let search = $state(''),
usage = $state('all'),
usage = $state(untrack(() => initialUsage)),
family = $state('all'),
favOnly = $state(false),
runnableOnly = $state(true),
limit = $state(48),
pins = $state<number[]>([]);
onMount(() => {
@@ -51,10 +95,18 @@
metas
.filter(
(m) =>
(!runnableOnly || runnable(m)) &&
(!favOnly || m.favorite) &&
(usage === 'all' || m.usage === usage) &&
(family === 'all' || m.model_family === family) &&
[m.name, m.description, m.model_family, ...(m.tags ?? [])]
[
m.name,
m.description,
m.source_path,
m.usage ? usageLabel(m.usage) : null,
m.model_family,
...(m.tags ?? []),
]
.join(' ')
.toLowerCase()
.includes(search.trim().toLowerCase()),
@@ -63,6 +115,10 @@
(a, b) =>
Number(pins.includes(b.id)) - Number(pins.includes(a.id)) ||
Number(b.favorite) - Number(a.favorite) ||
(a.model_family ?? '本地工作流').localeCompare(
b.model_family ?? '本地工作流',
'zh',
) ||
a.name.localeCompare(b.name, 'zh'),
),
);
@@ -77,12 +133,23 @@
const index = filtered.findIndex((m) => m.id === selectedId);
if (index >= limit) limit = index + 24;
});
function runnable(m: WorkflowMeta) {
return m.is_enabled && !!m.usage && !m.needs_mapping;
}
function choose(m: WorkflowMeta) {
if (runnable(m)) onPick(m.id);
else void goto('/workflows?manage=1&wf=' + m.id);
}
function pin(id: number) {
pins = pins.includes(id) ? pins.filter((p) => p !== id) : [...pins, id];
localStorage.setItem(
'calliope.reference.workflowPins',
JSON.stringify(pins),
);
try {
localStorage.setItem(
'calliope.reference.workflowPins',
JSON.stringify(pins),
);
} catch {
/* Pins remain usable this visit. */
}
}
const favorite = createMutation({
mutationFn: (m: WorkflowMeta) =>
@@ -92,50 +159,89 @@
});
</script>
<svelte:window onkeydown={searchKeydown} />
<aside class="workflow-rail" aria-label="创作工作流列表">
<header>
<div><strong>创作台</strong><small>选择工作流,开始创作</small></div>
<strong>创作台</strong><button
class="history-toggle a-btn ghost sm"
onclick={onToggleHistory}
aria-label={historyCollapsed ? '展开历史栏' : '收起历史栏'}
aria-expanded={!historyCollapsed}>◫</button
>
</header>
<div class="rail-tools">
<div class="kind-tabs" role="tablist" aria-label="创作类型">
<button
role="tab"
{disabled}
aria-selected={kind === 'image'}
onclick={() => onKind('image')}>图片</button
><button
role="tab"
{disabled}
onclick={() => onKind('image')}>图片</button
>
<button
role="tab"
aria-selected={kind === 'video'}
{disabled}
onclick={() => onKind('video')}>视频</button
>
</div>
<input
type="search"
bind:value={search}
aria-label="搜索工作流"
placeholder="搜索工作流或用途…"
/>
<div class="filters">
<select aria-label="筛选工作流用途" bind:value={usage}
><option value="all">所有用途</option>{#each usages as u}<option
value={u!}>{usageLabel(u!)}</option
>{/each}</select
><select aria-label="筛选模型家族" bind:value={family}
><option value="all">全部模型</option>{#each families as f}<option
value={f!}>{f}</option
>{/each}</select
<div class="rail-search">
<Icon name="search" size={14} />
<input
type="search"
bind:this={searchInput}
onfocus={(e) => {
searchOrigin =
e.relatedTarget instanceof HTMLElement ? e.relatedTarget : null;
}}
bind:value={search}
aria-label="搜索工作流"
placeholder="搜用途、模型或文件名"
/>
<kbd>/</kbd>
</div>
<div class="filters" role="group" aria-label="筛选工作流用途">
<button aria-pressed={usage === 'all'} onclick={() => (usage = 'all')}
>全部</button
>
{#each usages as u}<button
aria-pressed={usage === u}
onclick={() => (usage = u!)}>{usageLabel(u!)}</button
>{/each}
<button aria-pressed={favOnly} onclick={() => (favOnly = !favOnly)}
>☆ 收藏</button
>
</div>
{#if families.length}<div
class="filters"
role="group"
aria-label="筛选模型家族"
>
<button aria-pressed={family === 'all'} onclick={() => (family = 'all')}
>全部模型</button
>
{#each families as f}<button
aria-pressed={family === f}
onclick={() => (family = f!)}>{f}</button
>{/each}
</div>{/if}
<div class="list-meta">
<span title="目录接口不提供仓库文件总数"
>{filtered.length} 个 · 仓库文件数未知</span
>
<label class="runnable-filter"
><input type="checkbox" bind:checked={runnableOnly} />仅可运行</label
>
</div>
</div>
<div class="list-meta">
<span>{filtered.length} 个可用工作流</span><button
aria-pressed={favOnly}
onclick={() => (favOnly = !favOnly)}>{favOnly ? '★' : '☆'} 收藏</button
>
</div>
<div class="rail-list">
{#each filtered.slice(0, limit) as m (m.id)}<div
{#each filtered.slice(0, limit) as m, index (m.id)}
{#if index === 0 || pins.includes(m.id) !== pins.includes(filtered[index - 1].id) || (!pins.includes(m.id) && m.model_family !== filtered[index - 1].model_family)}<div
class="family-label"
>
{pins.includes(m.id) ? '置顶工作流' : m.model_family || '本地工作流'}
</div>{/if}
<div
class="workflow-row reference-option"
use:spotlight
class:selected={selectedId === m.id}
@@ -143,12 +249,15 @@
<button
class="workflow-pick"
{disabled}
onclick={() => onPick(m.id)}
onclick={() => choose(m)}
aria-pressed={selectedId === m.id}
title={m.name}
><i class:video={kind === 'video'}>{kind === 'image' ? '◈' : '▷'}</i
><i class:video={kind === 'video'}
>{m.model_family?.slice(0, 3) || (kind === 'image' ? '◈' : '▷')}</i
><span
><b>{m.name}</b><small
><b>{m.name}</b>{#if !runnable(m)}<small class="pending"
>待配置 · 映射 / 测试 →</small
>{/if}<small
>{m.description ||
(m.usage ? usageLabel(m.usage) : '本地 ComfyUI')}</small
></span
@@ -180,7 +289,7 @@
<footer>
{#if pendingCount}<a href="/workflows?manage=1"
>{pendingCount} 个工作流待配置 →</a
>{/if}<button class="manage" onclick={onDetails}
>{/if}<button class="manage" data-workflow-details onclick={onDetails}
><span>+</span>
<div>工作流说明与管理<small>输入要求 / 导入 / 分类</small></div>
<b>›</b></button
@@ -190,96 +299,172 @@
<style>
.workflow-rail {
width: 232px;
box-sizing: border-box;
width: var(--list);
flex-shrink: 0;
display: flex;
flex-direction: column;
min-height: 0;
background: #121216;
background: var(--bg-1);
border-right: 1px solid var(--line);
}
.workflow-rail > header {
padding: 21px 18px 16px;
border-bottom: 1px solid var(--line);
padding: 12px 12px 0;
height: var(--desk-rail-title-height);
box-sizing: border-box;
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: space-between;
}
header strong {
font-size: 18px;
font-size: 14px;
font-weight: 600;
display: block;
}
header small {
display: block;
font-size: 11px;
color: var(--text-3);
margin-top: 6px;
}
.rail-tools {
padding: 14px 12px 7px;
padding: var(--desk-rail-gap) 12px;
border-bottom: 1px solid var(--line);
}
.kind-tabs {
display: flex;
gap: 3px;
background: #09090c;
border: 1px solid var(--line);
border-radius: 9px;
gap: 2px;
padding: 3px;
height: 36px;
box-sizing: border-box;
border: 1px solid var(--line);
border-radius: var(--desk-seg-radius);
background: var(--well);
}
.kind-tabs button {
flex: 1;
height: 28px;
padding: 0 12px;
font-size: 12.5px;
border: 0;
padding: 7px 3px;
background: none;
color: var(--text-3);
font-size: 12px;
border-radius: 6px;
border-radius: var(--desk-seg-selected-radius);
background: transparent;
color: var(--tx-2);
cursor: pointer;
}
.kind-tabs [aria-selected='true'] {
color: #062f35;
background: var(--accent);
font-weight: 600;
background: var(--bg-3);
box-shadow: var(--sh-1);
color: var(--tx-1);
}
.rail-tools input {
width: 100%;
margin-top: 10px;
font-size: 11px;
padding: 9px 10px;
background: #1d1b16;
border: 1px solid #f5b04228;
border-radius: 8px;
.kind-tabs button:disabled {
cursor: wait;
}
.rail-search {
display: flex;
align-items: center;
gap: 8px;
margin-top: var(--desk-rail-gap);
height: var(--desk-search-height);
box-sizing: border-box;
font-size: var(--desk-search-size);
padding: 0 10px;
background: var(--well);
border: 1px solid var(--line-2);
border-radius: var(--desk-search-radius);
color: var(--text);
}
.rail-search input {
min-width: 0;
flex: 1;
padding: 0;
border: 0;
background: none;
color: inherit;
font: inherit;
outline: none;
}
.rail-search kbd {
font: var(--desk-search-key-font);
padding: var(--desk-search-key-padding);
border-radius: var(--desk-search-key-radius);
border: 1px solid var(--line-2);
color: var(--tx-3);
background: var(--bg-1);
}
.rail-search:focus-within {
border-color: var(--scene);
}
.filters {
display: flex;
gap: 5px;
flex-wrap: wrap;
gap: 6px;
margin-top: 8px;
}
.filters select {
width: 50%;
min-width: 0;
.filters button {
height: var(--desk-filter-height);
padding: 0 9px;
font-size: var(--desk-filter-size);
border-radius: var(--desk-filter-radius);
border: 1px solid var(--line);
background: var(--bg-3);
border-radius: 5px;
padding: 5px;
font-size: 10px;
color: var(--text-3);
background: var(--bg-2);
color: var(--tx-2);
cursor: pointer;
}
.filters button[aria-pressed='true'] {
color: var(--tx-1);
border-color: color-mix(in srgb, var(--scene) 60%, transparent);
background: color-mix(in srgb, var(--scene) 13%, var(--bg-2));
}
.runnable-filter {
display: flex;
align-items: center;
gap: 6px;
white-space: nowrap;
cursor: pointer;
}
.runnable-filter input {
margin: 0;
appearance: none;
width: var(--desk-switch-width);
height: var(--desk-switch-height);
border: 0;
border-radius: var(--r-pill);
background: var(--bg-4);
position: relative;
cursor: pointer;
flex-shrink: 0;
}
.runnable-filter input::after {
content: '';
position: absolute;
left: 2px;
top: 2px;
width: var(--desk-switch-knob);
height: var(--desk-switch-knob);
border-radius: 50%;
background: var(--tx-2);
transition: transform var(--t-base);
}
.runnable-filter input:checked {
background: color-mix(in srgb, var(--scene) 55%, var(--bg-4));
}
.runnable-filter input:checked::after {
transform: translateX(11px);
background: var(--on-media);
}
.pending {
color: var(--warn);
}
.list-meta {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
padding: 8px 16px;
font-size: 10px;
color: var(--text-3);
gap: 6px;
padding: 8px 2px 0;
font-size: var(--desk-filter-size);
color: var(--tx-3);
}
.list-meta button {
border: 0;
background: none;
color: var(--text-3);
font-size: 10px;
cursor: pointer;
}
.list-meta button[aria-pressed='true'] {
color: var(--warn);
.family-label {
font-size: 11px;
color: var(--tx-3);
padding: 8px;
}
.rail-list {
flex: 1;
@@ -297,8 +482,12 @@
margin: 2px 0;
}
.workflow-row.selected {
border-color: #00cae039;
background: linear-gradient(120deg, #00cae016, #00cae003);
border-color: color-mix(in srgb, var(--scene) 35%, transparent);
background: linear-gradient(
90deg,
color-mix(in srgb, var(--scene) 12%, var(--bg-3)),
var(--bg-3)
);
}
.workflow-row.selected::before {
content: '';
@@ -306,7 +495,7 @@
height: 22px;
position: absolute;
left: 0;
background: var(--accent);
background: var(--scene);
border-radius: 3px;
}
.workflow-pick {
@@ -330,9 +519,9 @@
height: 32px;
flex-shrink: 0;
border-radius: 9px;
border: 1px solid #00cae01f;
color: var(--accent);
background: #00cae008;
border: 1px solid var(--line-2);
color: var(--scene);
background: var(--bg-3);
font-size: 16px;
font-style: normal;
}
@@ -383,7 +572,7 @@
opacity: 1;
}
.workflow-row:hover {
background-color: #ffffff05;
background-color: var(--bg-2);
}
.empty {
color: var(--text-3);
@@ -434,7 +623,7 @@
}
@media (max-width: 1100px) {
.workflow-rail {
width: 210px;
width: var(--list);
}
}
</style>
@@ -0,0 +1,157 @@
<script lang="ts">
import { assetUrl } from '$lib/api';
import { jobStatusInfo } from '$lib/jobStatus';
import { isVideoOutput } from '../generate/takes';
import type { Job } from '$lib/comfy/types';
let {
jobs,
selectedId,
onSelect,
onExpand,
collapsed = false,
}: {
collapsed?: boolean;
jobs: Job[];
selectedId: number | null;
onSelect: (job: Job) => void;
onExpand: () => void;
} = $props();
const sorted = $derived([...jobs].sort((a, b) => b.id - a.id));
</script>
<aside class="history-rail" class:collapsed aria-label="生成历史">
<header>历史</header>
{#if !collapsed}
<div class="history-list">
{#each sorted as job (job.id)}
{@const status = jobStatusInfo(job)}
<button
class="history-take"
class:selected={selectedId === job.id}
onclick={() => onSelect(job)}
aria-label={'查看历史 #' + job.id + ' · ' + status.label}
aria-pressed={selectedId === job.id}
>
<div class="thumb">
{#if job.output_paths[0] && status.tone === 'ok'}
{#if isVideoOutput(job, job.output_paths[0])}<span
class="video-mark">▷</span
><video
src={assetUrl(job.output_paths[0])}
muted
preload="metadata"
aria-label="视频缩略图"
></video>
{:else}<img
src={assetUrl(job.output_paths[0])}
alt=""
loading="lazy"
/>{/if}
{:else}<span class:failed={status.tone === 'fail'}
>{status.label}</span
>{/if}
</div>
<small>#{job.id}</small>
</button>
{/each}
{#if !sorted.length}<p>生成后<br />记录留在这里</p>{/if}
</div>
<footer><button onclick={onExpand}>全部记录</button></footer>
{/if}
</aside>
<style>
.history-rail {
box-sizing: border-box;
width: var(--hist);
flex-shrink: 0;
display: flex;
flex-direction: column;
min-height: 0;
border-left: 1px solid var(--line);
background: var(--bg-1);
}
.history-rail.collapsed {
width: 0;
border: 0;
overflow: hidden;
visibility: hidden;
}
header {
height: var(--head);
display: grid;
place-items: center;
flex-shrink: 0;
border-bottom: 1px solid var(--line);
}
button {
font: inherit;
font-size: 11px;
background: none;
color: var(--tx-2);
border: 0;
cursor: pointer;
}
.history-list {
flex: 1;
min-height: 0;
overflow: auto;
padding: 8px 6px;
}
.history-take {
display: block;
width: 100%;
padding: 3px;
margin-bottom: 8px;
border: 1px solid transparent;
border-radius: var(--r-sm);
}
.history-take.selected {
border-color: var(--scene);
background: color-mix(in srgb, var(--scene) 10%, transparent);
}
.thumb {
position: relative;
aspect-ratio: 1;
display: grid;
place-items: center;
background: var(--bg-0);
border-radius: var(--r-xs);
overflow: hidden;
font-size: 10px;
}
img,
video {
width: 100%;
height: 100%;
object-fit: contain;
position: absolute;
inset: 0;
}
.video-mark {
position: absolute;
z-index: 1;
color: var(--on-media);
font-size: 24px;
text-shadow: 0 1px 6px var(--media-scrim);
}
.failed {
color: var(--err);
}
small {
font-family: var(--mono);
font-size: 10px;
color: var(--tx-3);
}
p {
text-align: center;
font-size: 10px;
color: var(--tx-3);
line-height: 1.7;
}
footer {
padding: 10px 2px;
text-align: center;
border-top: 1px solid var(--line);
}
</style>
@@ -1,4 +1,6 @@
<script lang="ts">
import { readDeskDraft, writeDeskDraft } from './draftCache';
import HistoryRail from './HistoryRail.svelte';
import { createQuery, useQueryClient } from '@tanstack/svelte-query';
import { toStore } from 'svelte/store';
import { tick, untrack } from 'svelte';
@@ -263,6 +265,8 @@
let paramEndpoint = $state('');
let paramShowErrors = $state(false);
let historyOpen = $state(false);
let historyCollapsed = $state(false);
let dockAnchor = $state<HTMLElement | null>(null);
let focusMode = $state(false);
let bayActiveId = $state<number | null>(null);
let bay = $state<WorkflowBay | null>(null);
@@ -276,7 +280,9 @@
function closeBay() {
bayOpen = false;
void tick().then(() =>
document.querySelector<HTMLButtonElement>('.model-selector')?.focus(),
document
.querySelector<HTMLButtonElement>('[data-workflow-details]')
?.focus(),
);
}
@@ -286,6 +292,7 @@
if (slotBusy || submitting) return;
actionRevision++;
selectedJobId = workflowResults.get(id) ?? null;
historyJobId = null;
if (id !== workflowId) workflowId = id;
void tick().then(() => promptEl?.focus());
}
@@ -308,7 +315,10 @@
workflowId = restoredWorkflowId;
return;
}
if (deskMetas.some((m) => m.id === raw)) workflowId = raw;
if (deskMetas.some((m) => m.id === raw)) {
if (pendingDraft?.workflowId !== raw) pendingDraft = null;
workflowId = raw;
}
});
async function toggleFavorite(id: number, fav: boolean) {
@@ -335,7 +345,6 @@
draftContext?.projectId,
draftContext?.stage,
draftContext?.sourceId ?? draftContext?.label,
page.url.searchParams.get('wf'),
]),
);
const sourceFingerprint = JSON.stringify([
@@ -343,9 +352,13 @@
draftContext?.negative,
draftContext?.durationSec,
]);
function readDraft(): { workflowId: number; values: FormValues } | null {
function readDraft(): {
workflowId: number;
values: FormValues;
requestId?: string | null;
} | null {
try {
const d = JSON.parse(window.sessionStorage.getItem(draftKey) ?? 'null');
const d = JSON.parse(readDeskDraft(draftKey) ?? 'null');
if (
!d ||
d.source !== sourceFingerprint ||
@@ -367,19 +380,27 @@
)
clean[key] = value;
}
return { workflowId: d.workflowId, values: clean };
return {
workflowId: d.workflowId,
values: clean,
requestId: d.requestId,
};
} catch {
return null;
}
}
let pendingDraft = untrack(readDraft);
// Keep the reload decision stable while detail and metadata queries arrive in either order.
const restoredWorkflowId = untrack(() =>
pendingDraft?.requestId === page.url.searchParams.get('wf')
? pendingDraft?.workflowId
: null,
);
const workflowDrafts = new SvelteMap<number, FormValues>();
const workflowEndpoints = new SvelteMap<number, string>();
const workflowResults = new SvelteMap<number, number>();
try {
const saved = JSON.parse(
window.sessionStorage.getItem(draftKey + ':workflows') ?? 'null',
);
const saved = JSON.parse(readDeskDraft(draftKey + ':workflows') ?? 'null');
if (saved?.source === sourceFingerprint) {
for (const [id, draft] of Object.entries(saved.drafts ?? {}))
if (Number.isInteger(Number(id)) && draft && typeof draft === 'object')
@@ -390,7 +411,7 @@
} catch {
/* The form works without storage. */
}
const restoredWorkflowId = pendingDraft?.workflowId ?? null;
let workflowId = $state<number | null>(pendingDraft?.workflowId ?? null);
let values = $state<FormValues>({});
let showErrors = $state(false);
@@ -456,6 +477,16 @@
...schemaDefaults(wf.input_schema ?? []),
...(workflowDrafts.get(wf.id) ?? {}),
};
if (lastWf && !workflowDrafts.has(wf.id)) {
const previous = classifySchema(lastWf.input_schema ?? [], lastWf.kind);
const incoming = classifySchema(wf.input_schema ?? [], wf.kind);
for (const role of ['prompt', 'negative'] as const) {
const oldInput = previous[role],
newInput = incoming[role];
if (oldInput && newInput && values[oldInput.nodeId] !== undefined)
next[newInput.nodeId] = values[oldInput.nodeId];
}
}
endpointTouched = true;
endpointId =
workflowEndpoints.get(wf.id) ?? wf.endpoint_id ?? activeId ?? '';
@@ -484,7 +515,7 @@
try {
workflowDrafts.set(wf.id, structuredClone($state.snapshot(inputs)));
workflowEndpoints.set(wf.id, ep);
window.sessionStorage.setItem(
writeDeskDraft(
draftKey + ':workflows',
JSON.stringify({
source: sourceFingerprint,
@@ -492,10 +523,11 @@
endpoints: Object.fromEntries(workflowEndpoints),
}),
);
window.sessionStorage.setItem(
writeDeskDraft(
draftKey,
JSON.stringify({
workflowId: wf.id,
requestId: page.url.searchParams.get('wf'),
values: inputs,
source: sourceFingerprint,
}),
@@ -671,6 +703,7 @@
return;
}
const wf = currentWorkflow!;
historyJobId = null;
submitting = true;
try {
const res = await playgroundApi.generate({
@@ -710,6 +743,7 @@
// ------------------------------------------------------------------
let filter = $state<FeedFilter>(untrack(() => kind));
let selectedJobId = $state<number | null>(null);
let historyJobId = $state<number | null>(null);
let busyId = $state<number | null>(null);
let tab = $state<'result' | 'params'>('result');
let inspOff = $state(false);
@@ -747,7 +781,8 @@
/** Picked take, else the newest finished one in view (undo-pending rows don't count). */
const selectedJob = $derived(
allJobs.find((j) => j.id === selectedJobId && !pendingIds.has(j.id)) ??
allJobs.find((j) => j.id === historyJobId && !pendingIds.has(j.id)) ??
allJobs.find((j) => j.id === selectedJobId && !pendingIds.has(j.id)) ??
allJobs
.filter(
(j) =>
@@ -766,9 +801,8 @@
*/
function selectTake(job: Job, via: 'pointer' | 'keyboard' = 'pointer') {
const from = via === 'pointer' ? (feed?.markRect() ?? null) : null;
selectedJobId = job.id;
if (workflowId != null && job.workflow_id === workflowId)
workflowResults.set(workflowId, job.id);
historyJobId = job.id;
tab = 'result';
crossfade = via === 'pointer';
if (via === 'pointer') {
@@ -843,10 +877,11 @@
function mediumLink(next: DeskKind) {
if (slotBusy || submitting) return;
const url = new URL(page.url);
url.pathname = '/' + next;
url.pathname = '/create';
url.searchParams.set('kind', next);
url.searchParams.delete('wf');
url.searchParams.delete('mode');
goto(url.pathname + url.search);
goto(url.pathname + url.search + url.hash);
}
const imageSlotTarget = $derived(
@@ -898,15 +933,60 @@
next[k] = v as string[];
}
if (slotBusy || submitting || revision !== actionRevision) return;
const stampedEndpoint = job.payload?.endpoint_id;
const reuseEndpoint =
typeof stampedEndpoint === 'string' &&
endpoints.some((ep) => ep.id === stampedEndpoint)
? stampedEndpoint
: (wf.endpoint_id ?? activeId ?? '');
if (wf.kind !== kind) {
const parts = JSON.parse(draftKey.slice('creative-draft:'.length));
parts[0] = wf.kind;
const targetKey = 'creative-draft:' + JSON.stringify(parts);
let saved: {
source?: string;
drafts?: Record<string, FormValues>;
endpoints?: Record<string, string>;
} = {};
try {
saved =
JSON.parse(readDeskDraft(targetKey + ':workflows') ?? '{}') ?? {};
} catch {
/* No prior draft. */
}
if (saved.source !== sourceFingerprint) saved = {};
writeDeskDraft(
targetKey + ':workflows',
JSON.stringify({
source: sourceFingerprint,
drafts: { ...saved.drafts, [wf.id]: next },
endpoints: { ...saved.endpoints, [wf.id]: reuseEndpoint },
}),
);
writeDeskDraft(
targetKey,
JSON.stringify({
workflowId: wf.id,
requestId: String(wf.id),
values: next,
source: sourceFingerprint,
}),
);
const url = new URL(page.url);
url.searchParams.set('kind', wf.kind);
url.searchParams.set('wf', String(wf.id));
await goto('/create' + url.search + url.hash);
return;
}
if (lastWf) workflowEndpoints.set(lastWf.id, endpointId);
endpointTouched = true;
endpointId =
workflowEndpoints.get(wf.id) ?? wf.endpoint_id ?? activeId ?? '';
endpointId = reuseEndpoint;
if (lastWf && lastWf.id !== wf.id)
workflowDrafts.set(lastWf.id, structuredClone($state.snapshot(values)));
workflowDrafts.set(wf.id, next);
workflowId = wf.id;
selectedJobId = job.id;
historyJobId = null;
workflowResults.set(wf.id, job.id);
lastWf = wf;
values = next;
@@ -1104,10 +1184,17 @@
{/snippet}
<AppShell active={kind} title="创作台" workspace>
<div class="reference-desk" class:focused={focusMode}>
<div
class="reference-desk"
class:focused={focusMode}
style:--scene={kind === 'image' ? 'var(--sc-image)' : 'var(--sc-video)'}
>
<DeskWorkflowRail
{kind}
metas={deskMetas}
initialUsage={mode}
{historyCollapsed}
onToggleHistory={() => (historyCollapsed = !historyCollapsed)}
metas={allMetas.filter((m) => m.kind === kind)}
selectedId={workflowId}
{pendingCount}
onPick={pickWorkflow}
@@ -1117,28 +1204,53 @@
/>
<div class="refract-studio">
<header class="studio-heading">
<div class="heading-actions">
<button class="a-btn ghost sm" onclick={() => (historyOpen = true)}
>◷ 生成历史</button
><button id="desk-wf" class="model-selector" onclick={openBay}
>{currentWorkflow?.name ?? currentMeta?.name ?? '选择工作流'} ⌄</button
<div class="heading-actions workflow-identity">
<span class="fam-dot"
>{currentMeta?.model_family?.slice(0, 3) ??
(kind === 'image' ? '◈' : '▷')}</span
>
</div>
<div class="heading-actions">
{#if selectedJob && workbenchCtxOf(selectedJob) && selectedJob.status === 'done'}<button
class="a-btn secondary sm"
disabled={attachBusyId === selectedJob.id}
onclick={() => selectedJob && attachToProject(selectedJob)}
>保存到来源项目 ↗</button
<b id="desk-wf" class="model-selector"
>{currentWorkflow?.name ?? currentMeta?.name ?? '选择工作流'}</b
>
{#if currentMeta?.usage}<span class="a-tag"
>{usageLabel(currentMeta.usage)}</span
>{/if}
<button class="a-btn secondary sm" onclick={openParams}
>创作设置</button
><button class="a-btn ghost sm" onclick={toggleInspector}
>{focusMode ? '退出专注' : '专注创作'} ⤢</button
</div>
<span
class="workflow-file"
title={currentMeta?.source_path ?? '此工作流未记录源文件名'}
>{currentMeta?.source_path?.replaceAll('\\', '/').split('/').pop() ??
'源文件名未知'}</span
>
<div class="heading-actions">
<span
class="connection-status"
class:failed={connState.state === 'fail'}
>
<i class:connected={connState.state === 'ok'}></i>
{endpoints.find((e) => e.id === endpointId)?.name ?? '未选择连接'} · {connState.state ===
'ok' && connState.ms != null
? `${connState.ms}ms`
: connState.state === 'fail'
? '连接失败'
: '延迟未知'}
</span>
</div>
</header>
{@render ctxBanner()}
{#if historyJobId != null && selectedJob?.id === historyJobId}<div
class="history-banner"
role="status"
>
<span>正在查看历史 #{historyJobId} · 当前输入保持不变</span><button
class="a-btn ghost sm"
onclick={() => (historyJobId = null)}>返回当前创作</button
><button
class="a-btn secondary sm"
onclick={() => selectedJob && reuse(selectedJob)}
>复用到输入区</button
>
</div>{/if}
<div class="studio-layout">
<section class="viewer" aria-label="查看生成结果">
<div class="preview-area">
@@ -1157,6 +1269,11 @@
open={true}
inactive={false}
canSendToSlot={!!imageSlotTarget || !!imageList}
canAttach={!!selectedJob &&
!!workbenchCtxOf(selectedJob) &&
selectedJob.status === 'done'}
attachBusy={attachBusyId === selectedJob?.id}
onAttach={() => selectedJob && attachToProject(selectedJob)}
{crossfade}
bind:videoEl
params={paramsTab}
@@ -1186,7 +1303,11 @@
</div>{/if}
</div>
</section>
<aside class="studio-form" aria-label="提示词、素材与参数">
<aside
bind:this={dockAnchor}
class="studio-form"
aria-label="提示词、素材与参数"
>
<Composer
bind:this={composer}
dock
@@ -1219,6 +1340,13 @@
</aside>
</div>
</div>
<HistoryRail
collapsed={historyCollapsed}
jobs={allJobs.filter((j) => j.kind === kind && !pendingIds.has(j.id))}
selectedId={selectedJob?.id ?? null}
onSelect={selectTake}
onExpand={() => (historyOpen = true)}
/>
</div>
</AppShell>
<Modal bind:open={bayOpen} size="xl" title="工作流说明与管理"
@@ -1332,17 +1460,25 @@
</Modal>
<Modal
bind:open={paramsOpen}
anchor={dockAnchor}
size="lg"
title="创作设置"
onclose={() => (paramDraft = null)}
>
{#snippet headerActions()}<button
class="a-btn ghost sm"
onclick={resetParamDraft}>恢复工作流默认</button
>{/snippet}
{#snippet footer()}<div class="param-actions">
<span>保存后应用到当前工作流</span><button
class="a-btn secondary"
onclick={() => (paramsOpen = false)}>取消</button
><button class="a-btn purple" onclick={saveParams}>保存设置</button>
><button class="a-btn primary" onclick={saveParams}>保存设置</button>
</div>{/snippet}
<div class="reference-preferences">
<button class="a-btn ghost sm" onclick={toggleInspector}
>{focusMode ? '退出专注' : '专注创作'} ⤢</button
>
<div class="runtime-setting">
<label for="comfy-endpoint">使用哪台 ComfyUI</label><select
class="a-select"
@@ -1407,9 +1543,40 @@
justify-content: space-between;
align-items: center;
gap: 8px;
height: 44px;
height: var(--desk-head-height);
margin: 0 calc(-1 * var(--desk-head-inset));
padding: 0 var(--desk-head-inset);
box-sizing: border-box;
border-bottom: 1px solid var(--line);
background: var(--desk-head-bg);
backdrop-filter: blur(10px);
flex-shrink: 0;
}
@media (max-width: 1366px) {
.workflow-file {
display: none;
}
}
.history-banner {
display: flex;
align-items: center;
gap: 8px;
flex-shrink: 0;
padding: 6px 8px;
border: 1px solid var(--info);
border-radius: var(--r-pill);
color: var(--info);
font-size: 12.5px;
background: color-mix(in srgb, var(--bg-2) 92%, transparent);
backdrop-filter: blur(8px);
box-shadow: var(--sh-2);
align-self: center;
max-width: 100%;
box-sizing: border-box;
}
.history-banner span {
margin-right: auto;
}
.heading-actions {
display: flex;
align-items: center;
@@ -1420,22 +1587,71 @@
border: 0;
background: none;
color: var(--text-2);
padding: 6px 8px;
font-size: 12px;
cursor: pointer;
padding: 0;
font-size: var(--desk-head-name-size);
font-weight: var(--desk-head-name-weight);
line-height: var(--desk-head-name-line);
max-width: 320px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.studio-heading :global(.a-btn) {
min-height: 28px;
font-size: 11px;
padding: 4px 8px;
.workflow-identity {
flex-shrink: 1;
}
.fam-dot {
width: 22px;
height: 22px;
flex-shrink: 0;
border-radius: var(--desk-search-radius);
display: grid;
place-items: center;
font: 700 9px var(--mono);
color: var(--bg-1);
background: var(--scene);
}
.workflow-file {
font: 11px var(--mono);
color: var(--tx-3);
max-width: 260px;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
margin-right: auto;
}
.connection-status {
display: inline-flex;
align-items: center;
gap: 6px;
font-size: 12px;
height: 26px;
padding: 0 10px;
border-radius: var(--desk-connection-radius);
border: 1px solid var(--line);
background: var(--bg-2);
color: var(--tx-2);
white-space: nowrap;
max-width: 260px;
overflow: hidden;
text-overflow: ellipsis;
}
.connection-status i {
width: 6px;
height: 6px;
border-radius: 50%;
background: var(--tx-3);
flex-shrink: 0;
}
.connection-status i.connected {
background: var(--ok);
}
.connection-status.failed i {
background: var(--err);
}
.studio-layout {
display: grid;
grid-template-rows: minmax(0, 1fr) 190px;
grid-template-rows: minmax(0, 1fr) auto;
gap: 12px;
flex: 1;
min-height: 0;
@@ -1462,7 +1678,7 @@
gap: 12px;
border: 1px solid var(--line);
border-radius: 12px;
background: radial-gradient(ellipse at 50% 20%, #202028, #131316 70%);
background: var(--bg-1);
}
.desk-welcome h1 {
font-size: 24px;
@@ -1542,12 +1758,9 @@
}
@media (max-height: 740px) {
.studio-layout {
grid-template-rows: minmax(0, 1fr) 172px;
grid-template-rows: minmax(0, 1fr) auto;
gap: 10px;
}
.refract-studio {
padding-bottom: 10px;
}
}
@media (max-width: 1100px) {
.refract-studio {
@@ -0,0 +1,17 @@
// Same-tab fallback for browsers that deny session storage. No user data leaves the browser.
const drafts = new Map<string, string>();
export function readDeskDraft(key: string): string | null {
try {
return sessionStorage.getItem(key) ?? drafts.get(key) ?? null;
} catch {
return drafts.get(key) ?? null;
}
}
export function writeDeskDraft(key: string, value: string) {
drafts.set(key, value);
try {
sessionStorage.setItem(key, value);
} catch {
/* In-memory switching still works. */
}
}
@@ -73,6 +73,7 @@
}: Props = $props();
let mediaOpen = $state(false);
let expanded = $state(false);
let popoverInputs = $state<ComfyDynamicInput[]>([]);
let popoverAnchor = $state<HTMLElement | null>(null);
export function openMedia() {
@@ -138,7 +139,7 @@
}
</script>
<div class="composer-wrap" class:dock>
<div class="composer-wrap" class:dock class:expanded>
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
bind:this={root}
@@ -157,7 +158,10 @@
<div class="composer-scroll" aria-label="创作输入">
{@render header?.()}
{#if current}
<section class="input-section prompt-section">
<section
class="input-section prompt-section"
class:has-slots={dock && hasSlots}
>
<h3>2 · 准备这次创作</h3>
{#if hasSlots && !dock}<div class="slots">
<SlotTray
@@ -171,9 +175,9 @@
{onFromResults}
/>
</div>{/if}
{#if dock}<div class="media-summary">
{#if dock}<div class="media-summary" class:has-slots={hasSlots}>
{#if hasSlots}
{#each ds.slots.slice(0, 4) as slot}<button
{#each ds.slots.slice(0, 3) as slot}<button
class="media-token"
onclick={openMedia}
aria-label={'设置' + slot.label}
@@ -186,20 +190,36 @@
</button>{/each}
{#if ds.lists.length}<button
class="media-token"
onclick={openMedia}>+ 参考材料</button
onclick={openMedia}
>+ 参考材料 {ds.lists.reduce(
(n, g) =>
n +
(Array.isArray(values[g.nodeId])
? (values[g.nodeId] as string[]).length
: 0),
0,
)}</button
>{/if}
<button class="media-token" onclick={openMedia}
<button class="expand-prompt manage-media" onclick={openMedia}
>{slotBusy ? '正在上传…' : '管理素材'}</button
>
{:else}<small>描述你的画面</small>{/if}
<small class="input-shortcut">Ctrl + Enter 生成</small>
{/if}
</div>{/if}
<div class="prompt-pair" class:dual={dock && !!ds.negative}>
{#if ds.prompt}<div class="prompt-field">
<label for="desk-prompt"
>{ds.negative ? '正向提示词' : '提示词'}
{#if ds.prompt.required}<span>必填</span>{/if}</label
><textarea
<div class="prompt-label">
<label for="desk-prompt"
>{ds.negative ? '正向提示词' : '提示词'}
{#if ds.prompt.required}<span>必填</span>{/if}</label
>
<button
class="expand-prompt"
aria-expanded={expanded}
onclick={() => (expanded = !expanded)}
>{expanded ? '收起提示词' : '展开提示词'}</button
>
</div>
<textarea
bind:this={promptEl}
id="desk-prompt"
class="prompt"
@@ -333,7 +353,7 @@
disabled={!!block || submitting || slotBusy}
onclick={onSubmit}
>
<Icon name="sparkle" size={16} />{submitting
<Icon name="sparkle" size={14} />{submitting
? '正在提交…'
: slotBusy
? '正在上传素材…'
@@ -482,7 +502,7 @@
background: var(--surface);
display: grid;
gap: 10px;
box-shadow: 0 -8px 20px #292a2d05;
box-shadow: var(--sh-1);
}
.gen {
width: 100%;
@@ -520,24 +540,26 @@
}
.dock {
height: 100%;
height: auto;
min-height: 0;
}
.dock .composer {
height: 100%;
height: auto;
min-height: 0;
border: 1px solid #f5b04246;
border-radius: 16px;
background: linear-gradient(125deg, #1b1a1d, #17161a);
border: 1px solid var(--line-2);
border-radius: var(--desk-dock-radius);
background: var(--composer-bg);
overflow: hidden;
box-shadow: inset 0 0 0 1px #f5b04206;
box-shadow: var(--desk-dock-shadow);
}
.dock .composer:focus-within {
border-color: #f5b04280;
border-color: color-mix(in srgb, var(--gold) 45%, transparent);
box-shadow: var(--desk-dock-focus);
}
.dock .composer-scroll {
padding: 9px 14px 4px;
overflow: hidden;
scrollbar-gutter: auto;
padding: 0;
overflow: auto;
display: flex;
flex-direction: column;
}
@@ -557,50 +579,65 @@
display: flex;
align-items: center;
gap: 6px;
height: 28px;
margin-bottom: 6px;
min-height: 0;
margin: 0;
flex-shrink: 0;
overflow: auto;
}
.media-summary > small {
color: var(--text-3);
font-size: 10px;
white-space: nowrap;
.dock .input-section.has-slots {
display: grid;
grid-template-columns: minmax(0, var(--desk-slot-column)) minmax(0, 1fr);
gap: 0;
}
.has-slots .media-summary {
align-self: start;
flex-wrap: wrap;
margin: 0;
padding: 10px 12px 8px;
border-right: 1px solid var(--line);
}
.media-token {
display: flex;
align-items: center;
gap: 5px;
background: #ffffff04;
border: 1px dashed #ffffff25;
background: var(--bg-2);
border: 1px dashed var(--line-3);
border-radius: 6px;
padding: 3px 7px;
font-size: 10px;
color: var(--text-2);
cursor: pointer;
white-space: nowrap;
height: 26px;
height: var(--slot-size);
}
.media-token img {
height: 20px;
width: 20px;
height: 36px;
width: 36px;
object-fit: cover;
border-radius: 3px;
}
.input-shortcut {
.expand-prompt {
margin-left: auto;
flex-shrink: 0;
border: 0;
background: none;
color: var(--tx-2);
font-size: 11px;
cursor: pointer;
}
.dock .prompt-pair {
display: grid;
grid-template-columns: minmax(0, 1fr);
min-height: 0;
flex: 1;
gap: 18px;
gap: 0;
}
.dock .prompt-pair.dual {
grid-template-columns: minmax(0, 1fr) minmax(0, 0.65fr);
grid-template-columns: minmax(0, 1.35fr) minmax(0, 1fr);
}
.dock .prompt-field {
padding: 10px 14px 6px;
display: flex;
flex-direction: column;
min-height: 0;
@@ -608,32 +645,41 @@
}
.dock .negative {
border-left: 1px solid var(--line);
padding-left: 18px;
}
.dock .prompt-field label {
font-size: 10px;
font-size: 11.5px;
height: 20px;
color: var(--text-3);
margin: 0 0 4px;
margin: 0;
display: flex;
gap: 6px;
}
.prompt-label {
display: flex;
align-items: center;
justify-content: space-between;
height: 20px;
}
.dock .prompt-field:first-child label {
color: #d0b37d;
color: var(--tx-2);
}
.dock .prompt {
background: none;
border: 0;
border-radius: 0;
padding: 0;
font-size: 12px;
font-size: var(--fs-body);
line-height: 1.6;
resize: none;
min-height: 0;
max-height: none;
height: 100%;
flex: 1;
height: var(--prompt-height);
flex: none;
overflow: auto;
}
.dock.expanded .prompt {
height: var(--prompt-expanded);
}
.dock .prompt:focus {
outline: 0;
}
@@ -645,15 +691,18 @@
margin-top: 3px;
}
.dock .prompt-counter {
display: none;
height: 22px;
align-items: center;
font-size: 11px;
margin-top: 4px;
}
.dock .submit {
display: flex;
flex-wrap: nowrap;
align-items: center;
gap: 8px;
padding: 7px 14px;
height: 45px;
padding: 8px 10px 10px 12px;
height: 57px;
box-sizing: border-box;
background: none;
border-top: 1px solid var(--line);
@@ -668,12 +717,12 @@
overflow: auto;
}
.parameter {
background: #25242a;
border: 1px solid #ffffff07;
background: var(--bg-3);
border: 1px solid var(--line);
border-radius: 7px;
padding: 5px 8px;
font-size: 10px;
color: #c4c2cb;
color: var(--tx-2);
cursor: pointer;
white-space: nowrap;
max-width: 180px;
@@ -682,20 +731,18 @@
flex-shrink: 0;
}
.parameter:hover {
border-color: #f5b04255;
border-color: var(--line-3);
}
.dock .gen {
width: auto;
min-height: 32px;
font-size: 11px;
padding: 7px 18px;
border-radius: 20px;
width: var(--desk-gen-width);
height: var(--desk-gen-height);
min-height: var(--desk-gen-height);
font-size: 14px;
font-weight: var(--desk-gen-weight);
padding: 0 16px;
border-radius: var(--desk-gen-radius);
margin-left: auto;
flex-shrink: 0;
background: linear-gradient(140deg, #ffc65e, #f5b042);
color: #30210c;
border-color: #edb458;
box-shadow: 0 3px 12px #f5b04217;
}
.dock .why {
position: absolute;
@@ -704,14 +751,14 @@
max-width: 65%;
padding: 3px 8px;
border-radius: 5px;
background: #241e17;
background: var(--bg-2);
font-size: 10px;
color: var(--warn);
z-index: 1;
}
.dock .why.err {
color: var(--err);
background: #241717;
background: var(--bg-2);
}
.dock .ready {
display: none;
File diff suppressed because it is too large Load Diff
@@ -1,218 +1,232 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { createQuery } from '@tanstack/svelte-query';
import { toStore } from 'svelte/store';
import { projects } from '$lib/api';
import { t } from '$lib/i18n.svelte';
import Icon from '$lib/components/ui/Icon.svelte';
import Modal from '$lib/components/ui/Modal.svelte';
import type { IconName } from '$lib/components/ui/icons';
import { NAV } from './nav';
import { goto } from "$app/navigation";
import { createQuery } from "@tanstack/svelte-query";
import { toStore } from "svelte/store";
import { projects } from "$lib/api";
import { t } from "$lib/i18n.svelte";
import Icon from "$lib/components/ui/Icon.svelte";
import Modal from "$lib/components/ui/Modal.svelte";
import type { IconName } from "$lib/components/ui/icons";
import { NAV } from "./nav";
/** Ctrl/⌘K 搜索:跳到任一页面或项目(↑↓ 选择,Enter 打开,Esc 关闭). */
let { open = $bindable(false) }: { open?: boolean } = $props();
/** Ctrl/⌘K 搜索:跳到任一页面或项目(↑↓ 选择,Enter 打开,Esc 关闭). */
let { open = $bindable(false) }: { open?: boolean } = $props();
interface Entry {
key: string;
group: 'pages' | 'projects';
label: string;
icon: IconName;
href: string;
}
interface Entry {
key: string;
group: "pages" | "projects";
label: string;
icon: IconName;
href: string;
}
const listId = `palette-${Math.random().toString(36).slice(2, 9)}`;
let query = $state('');
let activeIndex = $state(0);
let list = $state<HTMLDivElement | null>(null);
const listId = `palette-${Math.random().toString(36).slice(2, 9)}`;
let query = $state("");
let activeIndex = $state(0);
let list = $state<HTMLDivElement | null>(null);
const projectsQuery = createQuery(
toStore(() => ({
queryKey: ['projects'],
queryFn: projects.list,
enabled: open,
staleTime: 30_000,
})),
);
const projectsQuery = createQuery(
toStore(() => ({
queryKey: ["projects"],
queryFn: projects.list,
enabled: open,
staleTime: 30_000,
})),
);
const entries = $derived.by<Entry[]>(() => {
const pages: Entry[] = NAV.map((item) => ({
key: `page-${item.id}`,
group: 'pages',
label: t(item.labelKey),
icon: item.icon,
href: item.href,
}));
const projectEntries: Entry[] = ($projectsQuery.data ?? []).map((p) => ({
key: `project-${p.id}`,
group: 'projects',
label: p.title,
icon: 'story',
href: `/workbench/${p.id}`,
}));
const q = query.trim().toLowerCase();
const all = [...pages, ...projectEntries];
return q ? all.filter((e) => e.label.toLowerCase().includes(q)) : all;
});
const entries = $derived.by<Entry[]>(() => {
const pages: Entry[] = NAV.map((item) => ({
key: `page-${item.href}`,
group: "pages",
label: item.label,
icon: item.icon,
href: item.href,
}));
const projectEntries: Entry[] = ($projectsQuery.data ?? []).map((p) => ({
key: `project-${p.id}`,
group: "projects",
label: p.title,
icon: "story",
href: `/workbench/${p.id}`,
}));
const q = query.trim().toLowerCase();
const all = [...pages, ...projectEntries];
return q ? all.filter((e) => e.label.toLowerCase().includes(q)) : all;
});
$effect(() => {
void query;
activeIndex = 0;
});
$effect(() => {
void query;
activeIndex = 0;
});
$effect(() => {
if (!open) query = '';
});
$effect(() => {
if (!open) query = "";
});
function optionId(index: number) {
return `${listId}-${index}`;
}
function optionId(index: number) {
return `${listId}-${index}`;
}
function go(entry: Entry) {
open = false;
void goto(entry.href);
}
function go(entry: Entry) {
open = false;
void goto(entry.href);
}
function move(step: number) {
if (entries.length === 0) return;
activeIndex = (activeIndex + step + entries.length) % entries.length;
list?.querySelector(`#${optionId(activeIndex)}`)?.scrollIntoView({ block: 'nearest' });
}
function move(step: number) {
if (entries.length === 0) return;
activeIndex = (activeIndex + step + entries.length) % entries.length;
list
?.querySelector(`#${optionId(activeIndex)}`)
?.scrollIntoView({ block: "nearest" });
}
function handleKeydown(event: KeyboardEvent) {
if (event.key === 'ArrowDown') {
event.preventDefault();
move(1);
} else if (event.key === 'ArrowUp') {
event.preventDefault();
move(-1);
} else if (event.key === 'Enter') {
const entry = entries[activeIndex];
if (entry) {
event.preventDefault();
go(entry);
}
}
}
function handleKeydown(event: KeyboardEvent) {
if (event.key === "ArrowDown") {
event.preventDefault();
move(1);
} else if (event.key === "ArrowUp") {
event.preventDefault();
move(-1);
} else if (event.key === "Enter") {
const entry = entries[activeIndex];
if (entry) {
event.preventDefault();
go(entry);
}
}
}
function focusOnMount(node: HTMLInputElement) {
requestAnimationFrame(() => node.focus());
}
function focusOnMount(node: HTMLInputElement) {
requestAnimationFrame(() => node.focus());
}
</script>
<Modal bind:open title={t('shell.searchTitle')}>
<div class="palette">
<div class="palette-search">
<Icon name="search" size={16} />
<input
use:focusOnMount
class="palette-input"
type="text"
bind:value={query}
placeholder={t('shell.searchPlaceholder')}
role="combobox"
aria-expanded="true"
aria-controls={listId}
aria-autocomplete="list"
aria-activedescendant={entries.length ? optionId(activeIndex) : undefined}
aria-label={t('shell.searchTitle')}
onkeydown={handleKeydown}
/>
</div>
<div class="palette-list" id={listId} role="listbox" bind:this={list} aria-label={t('shell.searchTitle')}>
{#if entries.length === 0}
<p class="palette-empty">{t('shell.searchEmpty', { q: query.trim() })}</p>
{/if}
{#each entries as entry, i (entry.key)}
{#if i === 0 || entries[i - 1].group !== entry.group}
<div class="a-menu-group" role="presentation">
{entry.group === 'pages' ? t('shell.searchGroupPages') : t('shell.searchGroupProjects')}
</div>
{/if}
<div
id={optionId(i)}
class="a-menu-item"
class:active={i === activeIndex}
role="option"
tabindex="-1"
aria-selected={i === activeIndex}
onpointermove={() => (activeIndex = i)}
onmousedown={(e) => e.preventDefault()}
onclick={() => go(entry)}
onkeydown={handleKeydown}
>
<Icon name={entry.icon} size={15} />
<span class="a-menu-label">{entry.label}</span>
<span class="a-menu-meta mono">{entry.href}</span>
</div>
{/each}
</div>
<p class="palette-hint">
<span class="a-kbd">↑↓</span>{t('shell.searchHintMove')}
<span class="a-kbd">Enter</span>{t('shell.searchHintOpen')}
<span class="a-kbd">Esc</span>{t('shell.searchHintClose')}
</p>
</div>
<Modal bind:open title={t("shell.searchTitle")}>
<div class="palette">
<div class="palette-search">
<Icon name="search" size={16} />
<input
use:focusOnMount
class="palette-input"
type="text"
bind:value={query}
placeholder={t("shell.searchPlaceholder")}
role="combobox"
aria-expanded="true"
aria-controls={listId}
aria-autocomplete="list"
aria-activedescendant={entries.length
? optionId(activeIndex)
: undefined}
aria-label={t("shell.searchTitle")}
onkeydown={handleKeydown}
/>
</div>
<div
class="palette-list"
id={listId}
role="listbox"
bind:this={list}
aria-label={t("shell.searchTitle")}
>
{#if entries.length === 0}
<p class="palette-empty">
{t("shell.searchEmpty", { q: query.trim() })}
</p>
{/if}
{#each entries as entry, i (entry.key)}
{#if i === 0 || entries[i - 1].group !== entry.group}
<div class="a-menu-group" role="presentation">
{entry.group === "pages"
? t("shell.searchGroupPages")
: t("shell.searchGroupProjects")}
</div>
{/if}
<div
id={optionId(i)}
class="a-menu-item"
class:active={i === activeIndex}
role="option"
tabindex="-1"
aria-selected={i === activeIndex}
onpointermove={() => (activeIndex = i)}
onmousedown={(e) => e.preventDefault()}
onclick={() => go(entry)}
onkeydown={handleKeydown}
>
<Icon name={entry.icon} size={15} />
<span class="a-menu-label">{entry.label}</span>
<span class="a-menu-meta mono">{entry.href}</span>
</div>
{/each}
</div>
<p class="palette-hint">
<span class="a-kbd">↑↓</span>{t("shell.searchHintMove")}
<span class="a-kbd">Enter</span>{t("shell.searchHintOpen")}
<span class="a-kbd">Esc</span>{t("shell.searchHintClose")}
</p>
</div>
</Modal>
<style>
.palette {
display: flex;
flex-direction: column;
gap: var(--sp-3);
}
.palette-search {
display: flex;
align-items: center;
gap: var(--sp-3);
padding: 0 var(--sp-5);
border: 1px solid var(--line);
border-radius: var(--r-md);
background: var(--bg-3);
color: var(--text-3);
transition: border-color var(--d-state) ease;
}
.palette-search:focus-within {
border-color: var(--accent-line);
}
.palette-input {
flex: 1;
min-width: 0;
height: 40px;
border: 0;
outline: 0;
background: transparent;
color: var(--text);
font-family: inherit;
font-size: var(--fs-section);
}
.palette-input::placeholder {
color: var(--text-3);
}
.palette-list {
max-height: min(360px, 50vh);
overflow-y: auto;
}
.palette-empty {
margin: 0;
padding: var(--sp-6) var(--sp-4);
font-size: var(--fs-aux);
color: var(--text-3);
text-align: center;
}
.palette-hint {
display: flex;
align-items: center;
gap: var(--sp-2);
margin: 0;
font-size: var(--fs-mini);
color: var(--text-3);
}
.palette-hint .a-kbd:not(:first-child) {
margin-left: var(--sp-3);
}
@media (hover: none) {
.palette-hint {
display: none;
}
}
.palette {
display: flex;
flex-direction: column;
gap: var(--sp-3);
}
.palette-search {
display: flex;
align-items: center;
gap: var(--sp-3);
padding: 0 var(--sp-5);
border: 1px solid var(--line);
border-radius: var(--r-md);
background: var(--bg-3);
color: var(--text-3);
transition: border-color var(--d-state) ease;
}
.palette-search:focus-within {
border-color: var(--accent-line);
}
.palette-input {
flex: 1;
min-width: 0;
height: 40px;
border: 0;
outline: 0;
background: transparent;
color: var(--text);
font-family: inherit;
font-size: var(--fs-section);
}
.palette-input::placeholder {
color: var(--text-3);
}
.palette-list {
max-height: min(360px, 50vh);
overflow-y: auto;
}
.palette-empty {
margin: 0;
padding: var(--sp-6) var(--sp-4);
font-size: var(--fs-aux);
color: var(--text-3);
text-align: center;
}
.palette-hint {
display: flex;
align-items: center;
gap: var(--sp-2);
margin: 0;
font-size: var(--fs-mini);
color: var(--text-3);
}
.palette-hint .a-kbd:not(:first-child) {
margin-left: var(--sp-3);
}
@media (hover: none) {
.palette-hint {
display: none;
}
}
</style>
@@ -0,0 +1,189 @@
<script lang="ts">
import { THEMES, theme, setTheme, themeStorageUnavailable } from "$lib/theme";
let { compact = false }: { compact?: boolean } = $props();
function move(event: KeyboardEvent) {
const steps: Record<string, number> = {
ArrowRight: 1,
ArrowDown: 1,
ArrowLeft: -1,
ArrowUp: -1,
};
const step = steps[event.key];
if (!step && event.key !== "Home" && event.key !== "End") return;
event.preventDefault();
const group = (event.currentTarget as HTMLElement).closest(
".choices",
) as HTMLElement;
const buttons = [...group.querySelectorAll<HTMLButtonElement>("button")];
const current = buttons.indexOf(
document.activeElement as HTMLButtonElement,
);
const next =
event.key === "Home"
? 0
: event.key === "End"
? buttons.length - 1
: (current + step + buttons.length) % buttons.length;
buttons[next]?.focus();
}
</script>
<div class="choices" class:compact role="group" aria-label="外观主题">
{#each THEMES as item}
<button
class="choice"
class:selected={$theme === item.id}
aria-pressed={$theme === item.id}
onkeydown={move}
onclick={() => setTheme(item.id)}
>
<span class="preview" data-theme={item.id} aria-hidden="true"
><span class="rail"></span><span class="list"
><i></i><i></i><i></i></span
><span class="stage"><i></i><b></b></span></span
>
<span class="copy"
><strong>{item.name}</strong><small>{item.english}</small><span
>{item.description}</span
></span
>
<span class="check" aria-hidden="true"
>{$theme === item.id ? "✓" : ""}</span
>
</button>
{/each}
</div>
{#if $themeStorageUnavailable}<p class="notice" role="status">
浏览器禁止本地存储:本次有效,其他操作不受影响
</p>{/if}
<style>
.choices {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 10px;
}
.choices.compact {
grid-template-columns: 1fr;
}
.choice {
display: grid;
grid-template-columns: minmax(0, 1fr) 18px;
gap: 8px;
padding: 8px;
border: 1px solid var(--line-2);
border-radius: var(--r-md);
background: var(--bg-2);
color: var(--tx-1);
text-align: left;
cursor: pointer;
}
.choice:hover {
border-color: var(--line-3);
background: var(--bg-3);
}
.choice.selected {
border-color: var(--gold);
}
.choice:focus-visible {
outline: 2px solid var(--info);
outline-offset: 2px;
}
.compact .choice {
grid-template-columns: 76px minmax(0, 1fr) 18px;
align-items: center;
}
.preview {
grid-column: 1 / -1;
height: 64px;
border-radius: var(--r-sm);
overflow: hidden;
display: grid;
grid-template-columns: 12px 22px 1fr;
background: var(--bg-0);
border: 1px solid var(--line-2);
}
.compact .preview {
grid-column: auto;
height: 48px;
}
.rail,
.list {
background: var(--bg-1);
border-right: 1px solid var(--line);
}
.rail::before {
content: "";
display: block;
width: 4px;
height: 4px;
margin: 6px auto;
border-radius: 50%;
background: var(--gold);
}
.list {
display: flex;
flex-direction: column;
gap: 3px;
padding: 6px 3px;
}
.list i {
height: 4px;
border-radius: 2px;
background: var(--line-3);
}
.list i:first-child {
background: var(--sc-image);
}
.stage {
padding: 5px 6px;
display: flex;
flex-direction: column;
gap: 4px;
}
.stage i {
flex: 1;
border-radius: 3px;
background: var(--bg-3);
border: 1px solid var(--line-2);
}
.stage b {
height: 9px;
width: 18px;
align-self: end;
border-radius: 2px;
background: var(--grad-gold);
}
.copy {
display: flex;
flex-wrap: wrap;
gap: 2px 6px;
min-width: 0;
}
.copy strong {
font-size: 13px;
}
.copy small {
font: 10px var(--mono);
color: var(--tx-3);
align-self: center;
}
.copy > span {
width: 100%;
font-size: 11px;
color: var(--tx-2);
}
.check {
color: var(--gold);
}
.notice {
font-size: 12px;
color: var(--tx-2);
margin: 12px 0 0;
}
@media (max-width: 1366px) {
.choices:not(.compact) {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
</style>
+122 -115
View File
@@ -1,123 +1,130 @@
import type { IconName } from '$lib/components/ui/icons';
import type { IconName } from "$lib/components/ui/icons";
// IDs are retained for the existing page contracts while B1–B4 replace layouts.
export type NavId =
| 'projects'
| 'workbench'
| 'image'
| 'video'
| 'tasks'
| 'agents'
| 'connections'
| 'models'
| 'library'
| 'settings'
| 'canvas'
| 'workflows';
| "projects"
| "workbench"
| "image"
| "video"
| "tasks"
| "agents"
| "connections"
| "models"
| "library"
| "settings"
| "canvas"
| "workflows";
export interface NavItem {
id: NavId;
labelKey: string;
icon: IconName;
href: string;
id: NavId;
label: string;
icon: IconName;
href: string;
scene: "project" | "image" | "agent" | "manage" | "asset";
matches: (path: string) => boolean;
}
// Order is fixed by spec-v2 §3: 项目 / 创作工作台 / 生图 / 生视频 / 生成任务 /
// 智能体 / ComfyUI 连接 / 模型设置 / 设置. HEL-649 §二 puts the connection
// and settings entries at the foot of the 60px rail.
export const NAV_MAIN: NavItem[] = [
{ id: 'projects', labelKey: 'shell.navProjects', icon: 'home', href: '/' },
{
id: 'workbench',
labelKey: 'shell.navWorkbench',
icon: 'story',
href: '/workbench',
},
{ id: 'image', labelKey: 'shell.navImage', icon: 'image', href: '/image' },
{ id: 'video', labelKey: 'shell.navVideo', icon: 'film', href: '/video' },
{ id: 'tasks', labelKey: 'shell.navTasks', icon: 'queue', href: '/tasks' },
{ id: 'canvas', labelKey: 'nav.canvas', icon: 'story', href: '/canvas' },
{
id: 'agents',
labelKey: 'shell.navAgents',
icon: 'sparkle',
href: '/agents',
},
{
id: "projects",
label: "工作室",
icon: "home",
href: "/",
scene: "project",
matches: (p) => p === "/",
},
{
id: "workbench",
label: "项目",
icon: "folder",
href: "/workbench",
scene: "project",
matches: (p) => /^\/(workbench|project)(\/|$)/.test(p),
},
{
id: "image",
label: "创作台",
icon: "image",
href: "/create",
scene: "image",
matches: (p) =>
["/create", "/image", "/video", "/playground", "/build-scene"].includes(
p,
),
},
{
id: "agents",
label: "智能体",
icon: "sparkle",
href: "/agents",
scene: "agent",
matches: (p) => p === "/agents",
},
{
id: "canvas",
label: "画布",
icon: "story",
href: "/canvas",
scene: "manage",
matches: (p) => p.startsWith("/canvas"),
},
{
id: "library",
label: "素材库",
icon: "assets",
href: "/library",
scene: "asset",
matches: (p) => p === "/library",
},
];
export const NAV_MANAGE: NavItem[] = [
{
id: "tasks",
label: "任务",
icon: "clock",
href: "/tasks",
scene: "manage",
matches: (p) => p === "/tasks",
},
{
id: "workflows",
label: "工作流",
icon: "playground",
href: "/workflows",
scene: "manage",
matches: (p) => p === "/workflows",
},
{
id: "connections",
label: "模型连接",
icon: "link",
href: "/model-connections",
scene: "manage",
matches: (p) =>
["/connections", "/models", "/model-connections"].includes(p),
},
];
export const NAV_FOOT: NavItem[] = [
{
id: 'connections',
labelKey: 'shell.navConnections',
icon: 'link',
href: '/connections',
},
{
id: 'models',
labelKey: 'shell.navModels',
icon: 'assets',
href: '/models',
},
{
id: 'workflows',
labelKey: 'connections.wfTitle',
icon: 'playground',
href: '/workflows',
},
{
id: 'library',
labelKey: 'shell.navLibrary',
icon: 'folder',
href: '/library',
},
{
id: 'settings',
labelKey: 'shell.navSettings',
icon: 'settings',
href: '/settings',
},
{
id: "settings",
label: "设置",
icon: "settings",
href: "/settings",
scene: "manage",
matches: (p) => p === "/settings",
},
];
export const NAV: NavItem[] = [...NAV_MAIN, ...NAV_FOOT];
export interface TabItem {
id: 'projects' | 'workbench' | 'generate';
labelKey: string;
icon: IconName;
href: string;
/** Pages that light this tab up. */
covers: NavId[];
}
/** ≤760px bottom tab bar (§二): 项目 / 创作 / 生成 / 更多. */
export const TABS: TabItem[] = [
{
id: 'projects',
labelKey: 'shell.tabProjects',
icon: 'home',
href: '/',
covers: ['projects'],
},
{
id: 'workbench',
labelKey: 'shell.tabCreate',
icon: 'story',
href: '/workbench',
covers: ['workbench'],
},
{
id: 'generate',
labelKey: 'shell.tabGenerate',
icon: 'image',
href: '/image',
covers: ['image', 'video'],
},
];
/**
* Every page a tab does not link to directly lives in the 更多 sheet. 生视频
* is still its own route, so it is listed there too until the two
* generation pages merge.
*/
export const MORE_ITEMS: NavItem[] = NAV.filter(
(item) => !TABS.some((tab) => tab.href === item.href),
);
// Tools awaiting their replacement keep reachable entries in global search.
export const NAV = [
...NAV_MAIN,
...NAV_MANAGE,
...NAV_FOOT,
{ id: "video", label: "视频创作", icon: "film", href: "/video" },
{ id: "models", label: "大语言模型", icon: "assets", href: "/models" },
{
id: "connections",
label: "ComfyUI 连接",
icon: "link",
href: "/connections",
},
{ id: "image", label: "试验台", icon: "playground", href: "/playground" },
{ id: "video", label: "三维搭景", icon: "story", href: "/build-scene" },
] as const;
@@ -12,6 +12,9 @@
onclose?: () => void;
footer?: Snippet;
children: Snippet;
/** Open above a dock, retaining the shared dialog focus and keyboard behavior. */
anchor?: HTMLElement | null;
headerActions?: Snippet;
}
let {
@@ -22,6 +25,8 @@
onclose,
footer,
children,
anchor = null,
headerActions,
}: Props = $props();
const FOCUSABLE =
@@ -30,6 +35,28 @@
const titleId = `modal-title-${Math.random().toString(36).slice(2, 9)}`;
let panel = $state<HTMLDivElement | null>(null);
let anchorBox = $state({ left: 0, bottom: 0, width: 620, available: 560 });
$effect(() => {
if (!open || !anchor) return;
const source = anchor;
const place = () => {
const r = source.getBoundingClientRect();
anchorBox = {
left: r.left + 12,
bottom: innerHeight - r.top + 8,
width: r.width - 24,
available: Math.max(100, r.top - 64),
};
};
place();
const observer = new ResizeObserver(place);
observer.observe(source);
window.addEventListener('resize', place);
return () => {
observer.disconnect();
window.removeEventListener('resize', place);
};
});
const layer = Symbol('dialog');
function requestClose() {
@@ -100,10 +127,20 @@
{#if open}
<!-- svelte-ignore a11y_click_events_have_key_events, a11y_no_static_element_interactions -->
<div class="modal-backdrop" onclick={handleBackdropClick}>
<div
class="modal-backdrop"
class:anchored={!!anchor}
onclick={handleBackdropClick}
>
<div
bind:this={panel}
class="modal-panel size-{size}"
style:left={anchor ? `${anchorBox.left}px` : undefined}
style:bottom={anchor ? `${anchorBox.bottom}px` : undefined}
style:width={anchor
? `min(var(--desk-pop-width), ${anchorBox.width}px)`
: undefined}
style:--anchor-available={`${anchorBox.available}px`}
role="dialog"
aria-modal="true"
aria-labelledby={title ? titleId : undefined}
@@ -115,6 +152,7 @@
{#if title}
<h2 class="modal-title" id={titleId}>{title}</h2>
{/if}
{@render headerActions?.()}
{#if dismissible}
<button
type="button"
@@ -168,6 +206,38 @@
.modal-panel.size-xl {
width: min(1180px, 100%);
}
.anchored {
background: transparent;
padding: 0;
}
.anchored .modal-panel {
box-sizing: border-box;
position: absolute;
height: var(--desk-pop-height);
max-height: min(64vh, var(--anchor-available));
border: 1px solid var(--line-2);
border-radius: var(--desk-dock-radius);
background: var(--desk-pop-bg);
box-shadow: var(--desk-pop-shadow);
backdrop-filter: blur(14px);
transform-origin: bottom left;
}
.anchored .modal-header {
padding: 12px 14px;
border-bottom: 1px solid var(--line);
}
.anchored .modal-title {
font-size: var(--fs-body);
}
.anchored .modal-body {
flex: 1;
min-height: 0;
padding: 12px 14px;
}
.anchored .modal-footer {
padding: 10px 14px;
border-top: 1px solid var(--line);
}
.modal-header {
display: flex;
align-items: center;
+18 -16
View File
@@ -92,14 +92,14 @@ Status → color mapping (unknown strings fall back to muted and show the raw st
`import ProgressBar from '$lib/components/ui/ProgressBar.svelte';`
| Prop | Type | Default | Notes |
| --------------- | -------------- | ------- | ----------------------------------------- |
| `value` | `number` | `0` | clamped to 0–100 |
| `indeterminate` | `boolean` | `false` | sliding accent gradient, no % readout |
| `label` | `string` | — | text above the track, also the aria-label |
| `size` | `'sm' \| 'md'` | `'md'` | sm = 4px, md = 6px track |
| `tone` | `'accent' \| 'success' \| 'error'` | `'accent'` | done = success, failed = error (§5) |
| `bare` | `boolean` | `false` | track only, no label / % row |
| Prop | Type | Default | Notes |
| --------------- | ---------------------------------- | ---------- | ----------------------------------------- |
| `value` | `number` | `0` | clamped to 0–100 |
| `indeterminate` | `boolean` | `false` | sliding accent gradient, no % readout |
| `label` | `string` | — | text above the track, also the aria-label |
| `size` | `'sm' \| 'md'` | `'md'` | sm = 4px, md = 6px track |
| `tone` | `'accent' \| 'success' \| 'error'` | `'accent'` | done = success, failed = error (§5) |
| `bare` | `boolean` | `false` | track only, no label / % row |
```svelte
<ProgressBar value={job.progress} label={job.name} />
@@ -126,14 +126,16 @@ Status → color mapping (unknown strings fall back to muted and show the raw st
`import Modal from '$lib/components/ui/Modal.svelte';`
| Prop | Type | Default | Notes |
| ------------- | ------------ | -------- | --------------------------------------------------------------------------- |
| `open` | `boolean` | `false` | **bindable** — use `bind:open` |
| `title` | `string` | — | wired to `aria-labelledby`; falls back to `aria-label="Dialog"` |
| `dismissible` | `boolean` | `true` | gates Esc, backdrop click, and the close button |
| `onclose` | `() => void` | — | fires once when the modal closes (any cause, incl. external `open = false`) |
| `footer` | `Snippet` | — | right-aligned action row |
| `children` | `Snippet` | required | body content |
| Prop | Type | Default | Notes |
| --------------- | --------------------- | -------- | -------------------------------------------------------------------------------------------- |
| `open` | `boolean` | `false` | **bindable** — use `bind:open` |
| `title` | `string` | — | wired to `aria-labelledby`; falls back to `aria-label="Dialog"` |
| `dismissible` | `boolean` | `true` | gates Esc, backdrop click, and the close button |
| `onclose` | `() => void` | — | fires once when the modal closes (any cause, incl. external `open = false`) |
| `anchor` | `HTMLElement \| null` | `null` | Optional dock anchor: opens above it, clamps to viewport, retains the dialog focus behavior. |
| `headerActions` | `Snippet` | — | Optional controls beside the title (for example restore workflow defaults). |
| `footer` | `Snippet` | — | right-aligned action row |
| `children` | `Snippet` | required | body content |
Behavior: Esc and backdrop click close when dismissible; focus is trapped while
open; initial focus goes to the first focusable element (or the panel); focus is
@@ -1,4 +1,5 @@
<script lang="ts">
import { page } from '$app/state';
import { createMutation, useQueryClient } from '@tanstack/svelte-query';
import Button from '$lib/components/ui/Button.svelte';
import ConfirmDialog from '$lib/components/ui/ConfirmDialog.svelte';
@@ -35,6 +36,18 @@
let { rows, endpoints }: Props = $props();
// A pending workflow on the creative desk leads to its actual mapping/test row.
let deepLinkDone = false;
$effect(() => {
if (deepLinkDone || !rows.length) return;
const id = Number(page.url.searchParams.get('wf'));
const row = rows.find((r) => r.id === id);
deepLinkDone = true;
if (!row) return;
search = row.name;
if (rowNeedsOutputMap(row)) void toggleWfMapEdit(row);
});
const client = useQueryClient();
// ------------------------------------------------------------------
+68
View File
@@ -0,0 +1,68 @@
import { writable } from "svelte/store";
/** Approved HEL-726 themes. Colours live only in static/themes.css. */
export const THEMES = [
{
id: "night",
name: "夜片场",
english: "Nocturne",
description: "墨黑与钨丝金 · 沉浸式创作",
},
{
id: "day",
name: "日光棚",
english: "Daylight",
description: "暖白纸面 · 白天办公与长时间审稿",
},
{
id: "grade",
name: "调色间",
english: "Grading",
description: "中性深灰 · 判断作品色彩",
},
{
id: "bluehour",
name: "蓝调时刻",
english: "Blue Hour",
description: "靛蓝与珊瑚 · 提案与灵感发散",
},
] as const;
export type ThemeId = (typeof THEMES)[number]["id"];
export const THEME_KEY = "aigc-theme";
export const theme = writable<ThemeId>("night");
export const themeStorageUnavailable = writable(false);
export function isTheme(value: unknown): value is ThemeId {
return THEMES.some((item) => item.id === value);
}
export function setTheme(id: ThemeId) {
if (typeof document === "undefined" || !isTheme(id)) return;
// Only the root attribute changes: editors, uploads and jobs stay mounted.
document.documentElement.dataset.theme = id;
theme.set(id);
try {
localStorage.setItem(THEME_KEY, id);
themeStorageUnavailable.set(false);
} catch {
themeStorageUnavailable.set(true);
}
}
export function initTheme() {
const current = document.documentElement.dataset.theme;
theme.set(isTheme(current) ? current : "night");
try {
localStorage.getItem(THEME_KEY);
} catch {
themeStorageUnavailable.set(true);
}
const sync = (event: StorageEvent) => {
if (event.key !== THEME_KEY && event.key !== null) return;
const id = isTheme(event.newValue) ? event.newValue : "night";
document.documentElement.dataset.theme = id;
theme.set(id);
};
window.addEventListener("storage", sync);
return () => window.removeEventListener("storage", sync);
}
+15 -13
View File
@@ -1,20 +1,22 @@
<script lang="ts">
import './app.css';
import { onMount } from 'svelte';
import { QueryClient, QueryClientProvider } from '@tanstack/svelte-query';
import ToastHost from '$lib/components/ToastHost.svelte';
import { initReduceMotion } from '$lib/reduceMotion.svelte';
import "./app.css";
import { initTheme } from "$lib/theme";
import { onMount } from "svelte";
import { QueryClient, QueryClientProvider } from "@tanstack/svelte-query";
import ToastHost from "$lib/components/ToastHost.svelte";
import { initReduceMotion } from "$lib/reduceMotion.svelte";
const queryClient = new QueryClient({
defaultOptions: {
queries: { refetchOnWindowFocus: false, retry: 1 },
},
});
const queryClient = new QueryClient({
defaultOptions: {
queries: { refetchOnWindowFocus: false, retry: 1 },
},
});
onMount(() => initReduceMotion());
onMount(() => initReduceMotion());
onMount(initTheme);
</script>
<QueryClientProvider client={queryClient}>
<slot />
<ToastHost />
<slot />
<ToastHost />
</QueryClientProvider>
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,9 @@
<script lang="ts">
import { page } from '$app/state';
import StudioDesk from '$lib/components/desk/StudioDesk.svelte';
const kind = $derived(
page.url.searchParams.get('kind') === 'video' ? 'video' : 'image',
);
</script>
{#key kind + page.url.search}<StudioDesk {kind} />{/key}
+1
View File
@@ -0,0 +1 @@
export const ssr = false;
+8 -2
View File
@@ -1,6 +1,12 @@
<script lang="ts">
import StudioDesk from '$lib/components/desk/StudioDesk.svelte';
import { onMount } from 'svelte';
import { goto } from '$app/navigation';
import { page } from '$app/state';
onMount(() => {
const url = new URL(page.url);
url.searchParams.set('kind', 'image');
void goto('/create' + url.search + url.hash, { replaceState: true });
});
</script>
{#key page.url.pathname + page.url.search}<StudioDesk kind="image" />{/key}
<p>正在打开创作台…</p>
@@ -0,0 +1,9 @@
import { redirect } from "@sveltejs/kit";
export const ssr = false;
export const prerender = false;
// Both existing management pages stay usable until B2 combines their tabs.
export function load({ url }: { url: URL }) {
const destination =
url.searchParams.get("tab") === "llm" ? "/models" : "/connections";
redirect(307, destination + url.search + new URL(url.href).hash);
}
@@ -1,41 +1,12 @@
<script lang="ts">
import { onMount } from 'svelte';
import { goto } from '$app/navigation';
import { agentApi, canvasApi } from '$lib/api';
import { t } from '$lib/i18n.svelte';
let failed = $state('');
onMount(async () => {
try {
const sessions = await agentApi.listSessions();
const sandbox = sessions.find((s) => s.project_id == null);
const s = sandbox ?? (await agentApi.createSession({}));
const graph = await canvasApi.ensureForSession(s.id);
goto(`/canvas/${graph.canvas.id}?session=${s.id}`, { replaceState: true });
} catch (err) {
failed = err instanceof Error ? err.message : t('playground.openFailed');
}
import { page } from '$app/state';
onMount(() => {
void goto('/create' + page.url.search + page.url.hash, {
replaceState: true,
});
});
</script>
{#if failed}
<div class="redirect-error" role="alert">{failed}</div>
{:else}
<div class="redirecting">{t('playground.opening')}</div>
{/if}
<style>
.redirecting,
.redirect-error {
display: flex;
align-items: center;
justify-content: center;
height: 100vh;
color: var(--text-secondary);
font-size: 14px;
}
.redirect-error {
color: var(--err);
}
</style>
<p>正在打开创作台…</p>
@@ -1,9 +1,12 @@
import { redirect } from '@sveltejs/kit';
import type { PageLoad } from './$types';
import { redirect } from "@sveltejs/kit";
import type { PageLoad } from "./$types";
export const prerender = false;
export const ssr = false;
export const load: PageLoad = ({ params }) => {
redirect(308, `/workbench/${params.id}`);
export const load: PageLoad = ({ params, url }) => {
redirect(
308,
`/workbench/${params.id}${url.search}${new URL(url.href).hash}`,
);
};
+3 -3
View File
@@ -1,8 +1,8 @@
import { redirect } from '@sveltejs/kit';
import { redirect } from "@sveltejs/kit";
export const ssr = false;
export const prerender = false;
export function load() {
redirect(308, '/');
export function load({ url }: { url: URL }) {
redirect(308, "/" + url.search + new URL(url.href).hash);
}
File diff suppressed because it is too large Load Diff
+8 -2
View File
@@ -1,6 +1,12 @@
<script lang="ts">
import StudioDesk from '$lib/components/desk/StudioDesk.svelte';
import { onMount } from 'svelte';
import { goto } from '$app/navigation';
import { page } from '$app/state';
onMount(() => {
const url = new URL(page.url);
url.searchParams.set('kind', 'video');
void goto('/create' + url.search + url.hash, { replaceState: true });
});
</script>
{#key page.url.pathname + page.url.search}<StudioDesk kind="video" />{/key}
<p>正在打开创作台…</p>
+609
View File
@@ -0,0 +1,609 @@
/* HEL-726 approved themes. Loaded before the app for first-paint safety. */
:root,
[data-theme="night"] {
color-scheme: dark;
/* surfaces: ink black with a faint blue cast, never pure #000 */
--bg-0: #07080c;
--bg-1: #0c0e14;
--bg-2: #12151d;
--bg-3: #1a1e29;
--bg-4: #232838;
--line: rgb(var(--ink) / 0.06);
--line-2: rgb(var(--ink) / 0.11);
--line-3: rgb(var(--ink) / 0.18);
/* text: warm paper white */
--tx-1: #f3efe6;
--tx-2: #aaa79e;
--tx-3: #6f6d68;
--tx-inv: #1a1206;
/* key light: tungsten gold */
--gold: #e9b04b;
--gold-hi: #f8d68c;
--gold-lo: #b8771f;
--grad-gold: linear-gradient(135deg, #fbe1a1 0%, #efb54f 45%, #d98a26 100%);
/* scene lights — each workspace has its own colour temperature */
--sc-image: #3fd8c6;
--sc-video: #ff8a3d;
--sc-agent: #a879ff;
--sc-project: #e9b04b;
--sc-manage: #7f93b5;
--scene: var(--gold);
/* status */
--ok: #43d39a;
--warn: #f2b33d;
--err: #ff5d6c;
--info: #5aa9ff;
/* type */
--font:
"PingFang SC", "HarmonyOS Sans SC", "Microsoft YaHei UI", "Microsoft YaHei",
system-ui, sans-serif;
--mono: "JetBrains Mono", "Cascadia Mono", Consolas, monospace;
--fs-display: 28px;
--fs-h1: 20px;
--fs-h2: 16px;
--fs-body: 13px;
--fs-small: 12px;
--fs-micro: 11px;
--slot-size: 56px;
--prompt-height: 60px;
--desk-dock-radius: 14px;
--desk-dock-bg: linear-gradient(
180deg,
color-mix(in srgb, var(--bg-3) 92%, transparent),
color-mix(in srgb, var(--bg-2) 96%, transparent)
);
--desk-dock-shadow: var(--sh-2);
--desk-dock-focus:
var(--sh-2), 0 0 0 3px color-mix(in srgb, var(--gold) 8%, transparent),
0 0 40px color-mix(in srgb, var(--gold) 8%, transparent);
--desk-head-height: var(--head);
--desk-head-inset: 16px;
--desk-head-bg: var(--glass);
--desk-head-name-size: 14px;
--desk-head-name-weight: 600;
--desk-head-name-line: 1.55;
--desk-search-height: 34px;
--desk-search-size: 13px;
--desk-search-key-font: 500 10.5px/1 var(--mono);
--desk-search-key-padding: 3px 5px;
--desk-search-key-radius: 4px;
--desk-search-radius: var(--r-sm);
--desk-filter-height: 24px;
--desk-filter-size: 11.5px;
--desk-filter-radius: 999px;
--desk-rail-title-height: 40px;
--desk-rail-gap: 8px;
--desk-connection-radius: var(--r-pill);
--desk-switch-width: 26px;
--desk-switch-height: 15px;
--desk-switch-knob: 11px;
--desk-action-radius: 5px;
--desk-gen-width: 131px;
--desk-gen-radius: 10px;
--desk-gen-height: 38px;
--desk-gen-weight: 700;
--desk-seg-radius: 9px;
--desk-seg-selected-radius: 7px;
--desk-slot-column: 272px;
--desk-pop-width: 620px;
--desk-pop-height: 560px;
--desk-pop-bg: color-mix(in srgb, var(--bg-2) 98%, transparent);
--desk-pop-shadow: 0 24px 60px rgba(0, 0, 0, 0.6);
--prompt-expanded: 28vh;
/* space (4 base) */
--s1: 4px;
--s2: 8px;
--s3: 12px;
--s4: 16px;
--s5: 20px;
--s6: 24px;
--s8: 32px;
/* radius */
--r-xs: 4px;
--r-sm: 6px;
--r-md: 10px;
--r-lg: 14px;
--r-xl: 20px;
--r-pill: 999px;
/* elevation */
--sh-1: 0 1px 0 rgb(var(--ink) / 0.04) inset, 0 2px 8px rgba(0, 0, 0, 0.35);
--sh-2: 0 1px 0 rgb(var(--ink) / 0.05) inset, 0 12px 32px rgba(0, 0, 0, 0.5);
--sh-gold:
0 6px 22px rgba(233, 160, 60, 0.35),
0 0 0 1px rgba(255, 220, 150, 0.35) inset;
/* motion */
--ease-out: cubic-bezier(0.22, 1, 0.36, 1);
--ease-io: cubic-bezier(0.65, 0, 0.35, 1);
--ease-spring: cubic-bezier(0.34, 1.56, 0.64, 1);
--t-fast: 120ms;
--t-base: 200ms;
--t-slow: 320ms;
--t-reveal: 700ms;
/* layout */
--rail: 68px;
--list: 272px;
--hist: 92px;
--head: 48px;
/* ---- theme-semantic tokens (v2 · 主题化) ---- */
--ink: 255 255 255; /* overlay channel: hover tint, hairlines (white in dark, ink in light) */
--hi: white; /* scene-coloured text is mixed toward this for contrast */
--on-scene: #140a03; /* text on a solid scene/status colour */
--on-ok: #06140e;
--sc-asset: #ff86b0; /* 素材资产 helper colour (agent roster) */
--glass: color-mix(in srgb, var(--bg-1) 70%, transparent);
--scrim: rgba(4, 5, 8, 0.6);
--well: var(--bg-0); /* inputs, segmented track */
--media-shadow:
0 30px 80px rgba(0, 0, 0, 0.6), 0 0 0 1px rgb(255 255 255 / 0.07);
--sh-gold-hover:
0 8px 28px rgba(233, 160, 60, 0.5), 0 0 0 1px rgba(255, 230, 170, 0.5) inset;
--brand-grad: radial-gradient(
circle at 30% 25%,
#fff3cf 0,
#efb54f 35%,
#9b5a12 100%
);
--brand-glow: 0 0 24px rgba(233, 176, 75, 0.35);
--brand-ink: #1b1206;
--display-grad: linear-gradient(
90deg,
var(--gold-hi),
var(--gold) 60%,
#ff9b5a
);
--glow: 1; /* 1 = glows on (dark themes), 0 = off (light / neutral) */
/* signature effects */
--gen-bg: #0b0d12;
--grain: rgba(255, 255, 255, 0.08);
--beam: linear-gradient(
90deg,
transparent,
color-mix(in srgb, var(--scene) 30%, transparent),
rgba(255, 255, 255, 0.25),
transparent
);
--lattice: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='14' height='24' viewBox='0 0 14 24'%3E%3Cpath d='M7 0l7 4v8l-7 4-7-4V4zM7 16v8' fill='none' stroke='white' stroke-opacity='.16' stroke-width='.8'/%3E%3C/svg%3E");
--scanline: rgb(var(--ink) / 0.012);
--stage-tint: 7%;
/* agent atmosphere */
--aur-1: #7c4dff;
--aur-2: #ff5fa8;
--aur-3: #2fd5c5;
--aur-op: 0.33;
--agent-hero: linear-gradient(
120deg,
rgba(32, 20, 58, 0.9),
rgba(14, 12, 26, 0.92) 55%,
rgba(10, 22, 30, 0.9)
);
--agent-title: linear-gradient(90deg, #fff, #d9c6ff 50%, #ffb3d9);
--orb-core: radial-gradient(
circle at 35% 30%,
#fff 0,
#e3d4ff 12%,
#a879ff 40%,
#4b1fa8 75%,
transparent 76%
);
--particle: 214, 190, 255;
--fail-a: #1a1215;
--fail-b: #140f12;
}
[data-theme="day"] {
color-scheme: light;
--bg-0: #e9e4da;
--bg-1: #f4f1ea;
--bg-2: #fbf9f5;
--bg-3: #ffffff;
--bg-4: #efe9df;
--ink: 38 30 18;
--line: rgb(38 30 18 / 0.09);
--line-2: rgb(38 30 18 / 0.15);
--line-3: rgb(38 30 18 / 0.26);
--tx-1: #1f1b15;
--tx-2: #55504a;
--tx-3: #736d65;
--tx-inv: #ffffff;
--gold: #a65a0c;
--gold-hi: #8a4a06;
--gold-lo: #c98a3a;
--grad-gold: linear-gradient(135deg, #bd6512 0%, #a9580d 55%, #984d08 100%);
--sc-image: #0b8378;
--sc-video: #c2511a;
--sc-agent: #6d45d0;
--sc-project: #a65a0c;
--sc-manage: #4c6286;
--sc-asset: #b93a72;
--ok: #11804f;
--warn: #9a6400;
--err: #c42b41;
--info: #2263c2;
--hi: #141109;
--on-scene: #ffffff;
--on-ok: #ffffff;
--sh-1:
0 1px 0 rgba(255, 255, 255, 0.7) inset, 0 1px 2px rgba(60, 44, 20, 0.1);
--sh-2:
0 1px 0 rgba(255, 255, 255, 0.8) inset, 0 12px 32px rgba(60, 44, 20, 0.14),
0 2px 6px rgba(60, 44, 20, 0.06);
--sh-gold:
0 4px 14px rgba(166, 90, 12, 0.28), 0 0 0 1px rgba(120, 60, 0, 0.18) inset;
--sh-gold-hover:
0 6px 20px rgba(166, 90, 12, 0.38), 0 0 0 1px rgba(120, 60, 0, 0.24) inset;
--glass: color-mix(in srgb, var(--bg-1) 88%, transparent);
--scrim: rgba(40, 32, 20, 0.34);
--well: #ffffff;
--media-shadow:
0 14px 36px rgba(50, 38, 18, 0.2), 0 0 0 1px rgba(38, 30, 18, 0.1);
--brand-grad: linear-gradient(145deg, #2c261d, #14110c);
--brand-glow: 0 2px 8px rgba(40, 30, 15, 0.25);
--brand-ink: #f2c46d;
--display-grad: linear-gradient(90deg, #a65a0c, #c2511a);
--glow: 0;
--gen-bg: #e3ddd1;
--grain: rgba(40, 30, 18, 0.1);
--beam: linear-gradient(
90deg,
transparent,
color-mix(in srgb, var(--scene) 24%, transparent),
rgba(255, 255, 255, 0.85),
transparent
);
--lattice: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='14' height='24' viewBox='0 0 14 24'%3E%3Cpath d='M7 0l7 4v8l-7 4-7-4V4zM7 16v8' fill='none' stroke='%23261e12' stroke-opacity='.16' stroke-width='.8'/%3E%3C/svg%3E");
--scanline: transparent;
--stage-tint: 6%;
--aur-1: #b9a0ff;
--aur-2: #ffb39a;
--aur-3: #8fe0d4;
--aur-op: 0.42;
--agent-hero: linear-gradient(120deg, #efe7ff, #faf7f1 55%, #e4f3ef);
--agent-title: linear-gradient(90deg, #24163f, #5b34c4 55%, #a2346f);
--orb-core: radial-gradient(
circle at 35% 30%,
#fff 0,
#efe6ff 14%,
#a98cf0 42%,
#6d45d0 75%,
transparent 76%
);
--particle: 109, 69, 208;
--fail-a: #f6e3e3;
--fail-b: #efd6d7;
}
[data-theme="grade"] {
--desk-filter-radius: 3px;
--desk-connection-radius: 3px;
color-scheme: dark;
--bg-0: #1b1b1c;
--bg-1: #222223;
--bg-2: #29292b;
--bg-3: #323234;
--bg-4: #3c3c3f;
--ink: 255 255 255;
--line: rgb(255 255 255 / 0.075);
--line-2: rgb(255 255 255 / 0.13);
--line-3: rgb(255 255 255 / 0.22);
--tx-1: #ecebe8;
--tx-2: #acaba6;
--tx-3: #85847f;
--tx-inv: #141516;
--r-xs: 2px;
--r-sm: 4px;
--r-md: 6px;
--r-lg: 8px;
--desk-dock-radius: 8px;
--desk-gen-radius: 4px;
--desk-seg-radius: 4px;
--desk-seg-selected-radius: 4px;
--r-xl: 10px;
--gold: #d9dde2;
--gold-hi: #f2f4f6;
--gold-lo: #9aa1aa;
--grad-gold: linear-gradient(180deg, #f6f7f8 0%, #d5d9de 100%);
--sc-image: #6fc2b7;
--sc-video: #d8a06c;
--sc-agent: #a598d8;
--sc-project: #d4bc8a;
--sc-manage: #909cae;
--sc-asset: #d394b0;
--ok: #4cc38f;
--warn: #e3a93b;
--err: #ee5d70;
--info: #5d9fe3;
--hi: white;
--on-scene: #141516;
--on-ok: #06140e;
--sh-1:
0 1px 0 rgba(255, 255, 255, 0.035) inset, 0 2px 6px rgba(0, 0, 0, 0.35);
--sh-2:
0 1px 0 rgba(255, 255, 255, 0.04) inset, 0 12px 30px rgba(0, 0, 0, 0.45);
--sh-gold:
0 4px 14px rgba(0, 0, 0, 0.45), 0 0 0 1px rgba(255, 255, 255, 0.55) inset;
--sh-gold-hover:
0 6px 18px rgba(0, 0, 0, 0.5), 0 0 0 1px rgba(255, 255, 255, 0.7) inset;
--glass: color-mix(in srgb, var(--bg-1) 82%, transparent);
--scrim: rgba(0, 0, 0, 0.55);
--well: #18181a;
--media-shadow:
0 18px 44px rgba(0, 0, 0, 0.5), 0 0 0 1px rgba(255, 255, 255, 0.07);
--brand-grad: linear-gradient(145deg, #f1f2f3, #aeb3ba);
--brand-glow: none;
--brand-ink: #17181a;
--display-grad: linear-gradient(90deg, #ffffff, #bfc5cc);
--glow: 0;
--gen-bg: #1a1b1d;
--grain: rgba(255, 255, 255, 0.06);
--beam: linear-gradient(
90deg,
transparent,
rgba(255, 255, 255, 0.07),
rgba(255, 255, 255, 0.22),
transparent
);
--scanline: transparent;
--stage-tint: 0%;
--aur-1: #5d5a80;
--aur-2: #7a6070;
--aur-3: #4f7470;
--aur-op: 0.16;
--agent-hero: linear-gradient(120deg, #202027, #1b1c1e 60%, #1b2020);
--agent-title: linear-gradient(90deg, #ffffff, #d4d0ea);
--orb-core: radial-gradient(
circle at 35% 30%,
#fff 0,
#e7e5f2 14%,
#a598d8 44%,
#3e3a5c 76%,
transparent 77%
);
--particle: 205, 205, 215;
--fail-a: #211a1b;
--fail-b: #1b1617;
}
[data-theme="bluehour"] {
--desk-head-bg: color-mix(in srgb, #0f1838 55%, transparent);
color-scheme: dark;
--bg-0: #0a1029;
--bg-1: #0f1838;
--bg-2: #162148;
--bg-3: #1f2c5c;
--bg-4: #2a386e;
--r-sm: 8px;
--r-md: 12px;
--r-lg: 18px;
--desk-dock-radius: 18px;
--desk-gen-radius: 999px;
--desk-gen-width: 140.7px;
--desk-action-radius: 8px;
--desk-dock-bg: linear-gradient(
180deg,
rgba(31, 44, 92, 0.82),
rgba(22, 33, 72, 0.9)
);
--r-xl: 26px;
--ink: 214 224 255;
--line: rgb(214 224 255 / 0.07);
--line-2: rgb(214 224 255 / 0.12);
--line-3: rgb(214 224 255 / 0.2);
--tx-1: #eef1fb;
--tx-2: #a9b1cc;
--tx-3: #7680a2;
--tx-inv: #2b0c05;
--gold: #ff9a6c;
--gold-hi: #ffc7a8;
--gold-lo: #d9603f;
--grad-gold: linear-gradient(135deg, #ffd3b4 0%, #ff9a6c 45%, #f0644f 100%);
--sc-image: #4fd6ea;
--sc-video: #ffbb55;
--sc-agent: #b592ff;
--sc-project: #ff9a6c;
--sc-manage: #8aa0dc;
--sc-asset: #ff86c0;
--ok: #4fd69c;
--warn: #ffc94d;
--err: #ff4d6d;
--info: #69b4ff;
--hi: white;
--on-scene: #1a0b04;
--on-ok: #05150e;
--sh-1:
0 1px 0 rgba(214, 224, 255, 0.05) inset, 0 2px 8px rgba(0, 0, 10, 0.4);
--sh-2:
0 1px 0 rgba(214, 224, 255, 0.06) inset, 0 12px 32px rgba(0, 0, 12, 0.55);
--sh-gold:
0 6px 24px rgba(255, 120, 90, 0.38),
0 0 0 1px rgba(255, 214, 186, 0.42) inset;
--sh-gold-hover:
0 8px 30px rgba(255, 120, 90, 0.52),
0 0 0 1px rgba(255, 226, 206, 0.55) inset;
--scrim: rgba(4, 6, 16, 0.62);
--media-shadow:
0 30px 80px rgba(0, 0, 14, 0.62), 0 0 0 1px rgba(214, 224, 255, 0.08);
--brand-grad: radial-gradient(
circle at 30% 25%,
#ffe4d2 0,
#ff9a6c 38%,
#a3363a 100%
);
--brand-glow: 0 0 26px rgba(255, 140, 100, 0.42);
--brand-ink: #2b0c05;
--display-grad: linear-gradient(90deg, #ffd3b4, #ff9a6c 55%, #ff7096);
--glow: 1;
--gen-bg: #0b1124;
--grain: rgba(214, 224, 255, 0.08);
--beam: linear-gradient(
90deg,
transparent,
color-mix(in srgb, var(--scene) 34%, transparent),
rgba(255, 236, 220, 0.3),
transparent
);
--stage-tint: 10%;
--aur-1: #3e5bff;
--aur-2: #ff7a8a;
--aur-3: #2fd5e5;
--aur-op: 0.36;
--agent-hero: linear-gradient(
120deg,
rgba(26, 32, 84, 0.92),
rgba(12, 16, 40, 0.94) 55%,
rgba(44, 18, 52, 0.9)
);
--agent-title: linear-gradient(90deg, #ffffff, #c9d4ff 50%, #ffb8a0);
--orb-core: radial-gradient(
circle at 35% 30%,
#fff 0,
#ffe1cf 12%,
#ff9a6c 38%,
#5a2fb0 76%,
transparent 77%
);
--particle: 255, 206, 186;
--fail-a: #1d1220;
--fail-b: #160e1a;
}
@media (max-width: 1366px) {
:root {
--list: 248px;
--hist: 84px;
}
}
@media (max-width: 1280px) {
:root {
--rail: 60px;
--list: 236px;
--hist: 76px;
--head: 44px;
}
}
/* Compatibility names for existing pages; no independent colour palette. */
:root {
--accent-grad-hover: var(--grad-gold);
--accent-ring: color-mix(in srgb, var(--gold) 25%, transparent);
--accent-wash: var(--accent-soft);
--btn-glow-hover: var(--sh-gold-hover);
--composer-bg: var(--desk-dock-bg);
--composer-focus: var(--bg-2);
--float-bg: var(--bg-2);
--on-media: #fff;
--on-media-line: #ffffff66;
--sel-shadow: 0 0 0 1px var(--gold);
--bg: var(--bg-0);
--paper: var(--bg-2);
--lime: color-mix(in srgb, var(--sc-image) 14%, var(--bg-2));
--rose: color-mix(in srgb, var(--sc-asset) 14%, var(--bg-2));
--text: var(--tx-1);
--text-2: var(--tx-2);
--text-3: var(--tx-3);
--accent: var(--gold);
--accent-hi: var(--gold-hi);
--accent-lo: var(--gold-lo);
--accent-ink: var(--tx-inv);
--accent-soft: color-mix(in srgb, var(--gold) 9%, transparent);
--accent-line: color-mix(in srgb, var(--gold) 35%, transparent);
--accent-grad: var(--grad-gold);
--accent-glow: var(--sh-gold);
--btn-glow: var(--sh-gold);
--page-glow: linear-gradient(transparent, transparent);
--ok-soft: color-mix(in srgb, var(--ok) 9%, transparent);
--run: var(--info);
--run-soft: color-mix(in srgb, var(--info) 9%, transparent);
--run-grad: linear-gradient(90deg, var(--info), var(--sc-agent));
--err-solid: var(--err);
--err-soft: color-mix(in srgb, var(--err) 9%, transparent);
--err-line: color-mix(in srgb, var(--err) 40%, transparent);
--warn-soft: color-mix(in srgb, var(--warn) 9%, transparent);
--overlay: var(--scrim);
--media-scrim: #101017ee;
--media-text: #fff;
--media-line: #ffffff66;
--stage-bg: var(--bg-0);
--fs-title: 15px;
--fs-section: 14px;
--fs-body-sm: 12.5px;
--fs-aux: 12px;
--fs-mini: 11px;
--lh-body: 1.55;
--sp-1: 4px;
--sp-2: 6px;
--sp-3: 8px;
--sp-4: 10px;
--sp-5: 12px;
--sp-6: 16px;
--sp-7: 20px;
--sp-8: 28px;
--shadow-float: var(--sh-2);
--rail-w: var(--rail);
--topbar-h: var(--head);
--tabbar-h: 0px;
--sprocket: none;
--frame-w: 0px;
--frame-arm: 12px;
--frame-inset: -4px;
--paper-w: 4px;
--ease-in-out: cubic-bezier(0.77, 0, 0.175, 1);
--ease-drawer: cubic-bezier(0.32, 0.72, 0, 1);
--d-press: 160ms;
--d-state: 160ms;
--d-pop: 180ms;
--d-leave: 200ms;
--d-enter: 220ms;
--d-drawer: 250ms;
--d-flash: 900ms;
--d-progress: 400ms;
--press-scale: 0.97;
--avatar-1: color-mix(in srgb, var(--sc-project) 20%, var(--bg-2));
--avatar-2: color-mix(in srgb, var(--sc-image) 20%, var(--bg-2));
--avatar-3: color-mix(in srgb, var(--sc-video) 20%, var(--bg-2));
--avatar-4: color-mix(in srgb, var(--sc-asset) 20%, var(--bg-2));
--avatar-5: color-mix(in srgb, var(--sc-agent) 20%, var(--bg-2));
--id-1: var(--sc-project);
--id-2: var(--sc-image);
--id-3: var(--sc-video);
--id-4: var(--sc-asset);
--id-5: var(--sc-agent);
--surface: var(--bg-2);
--surface-2: var(--bg-3);
--surface-3: var(--bg-4);
--border: var(--line);
--border-strong: var(--line-2);
--border-hover: var(--line-2);
--accent-dim: var(--accent-soft);
--success: var(--ok);
--success-dim: var(--ok-soft);
--info-dim: color-mix(in srgb, var(--info) 9%, transparent);
--error: var(--err);
--error-dim: var(--err-soft);
--warning: var(--warn);
--bg-primary: var(--bg);
--bg-surface: var(--bg-2);
--bg-elevated: var(--bg-3);
--text-primary: var(--text);
--text-secondary: var(--text-2);
--text-muted: var(--text-3);
--font-a-mono: var(--mono);
--font-display: var(--font);
--font-body: var(--font);
--font-mono: var(--mono);
--radius-sm: var(--r-sm);
--radius-md: var(--r-md);
--radius-lg: var(--r-lg);
--space-sm: var(--sp-3);
--space-md: var(--sp-6);
--pill-bg: var(--bg-3);
--pill-border: var(--line-2);
--pill-radius: var(--r-pill);
--pill-height: 36px;
--pill-padding: 0 14px;
--pill-font-size: var(--fs-body);
}
html {
background: var(--bg-0);
color: var(--tx-1);
}
+140
View File
@@ -0,0 +1,140 @@
# 夜片场 Nocturne Set — AIGC 工作台设计规范 v1(待白栖知确认)
> 视觉总监出具。本文件和 `index.html`(交互样本)、`shots/*.png` 一起,是施工和验收的唯一品味依据。样本中的画面都是程序生成的示意图,不是真实生成结果,也不调用任何接口。
## 0. 一句话方向
**深夜片场:墨黑的棚里只亮一盏钨丝灯,作品是唯一被照亮的东西。**
本项目本质是“从故事到成片”的制片工具,所以主题元素取自片场,而不是暗房或印刷:取景框角标(选中)、扫描光束(生成中)、显影(结果到达)、场记编号与时间码(等宽数字)、每个工作区一种“场景色温”。参考站学的是它的骨架:紧凑、左侧双栏、底部输入座、参数胶囊、按场景变化的主题色;没有照抄它的配色。
## 1. 事实盘点(已核实)
| 项 | 事实 | 来源 |
|---|---|---|
| 前端路由 | 项目首页 `/`、工作台 `/workbench/[id]`、生图 `/image`、生视频 `/video`、任务 `/tasks`、智能体 `/agents`、画布 `/canvas/[id]`、素材库 `/library`、ComfyUI 连接 `/connections`、模型设置 `/models`、设置 `/settings`、三维搭景 `/build-scene`、试验台 `/playground` | 仓库 `calliope-web/src/routes` |
| 五阶段 | 剧本 → 视觉 → 分镜 → 素材 → 镜头脚本;每阶段一个智能体,草稿需人工确认后才交给下一阶段,可恢复已确认版 | `i18n/zh.ts` workbench.* |
| 工作流文件 | 仓库 `example_ComfyUI_workflows` 共 **156** 个文件:**10** 个是 API 格式,**146** 个是 ComfyUI 界面格式(需重新导出 API 格式并做输入映射才能执行) | 逐个文件检查 `nodes`/`last_node_id` 字段 |
| 已可执行 | 测试站 `/api/workflows` 当前 **9** 条(图片 5、视频 4) | 测试站接口,只读 |
| 素材槽 | 参考图(1–N)、遮罩、首帧、尾帧(可选)、参考视频、参考音频;按工作流声明出现 | studio.* 文案 + 组件 |
| 模型设置 | 当前仅开放 LLM;图片/视频模型标“后续开放” | models.* |
| 历次投诉 | 结果被挤小、历史穿插在生成区、大片空白、窗口不全、生成按钮要滚动才看到、视频自动播放且空格乱切、找不到播放键、百余工作流难找、智能体只做成了聊天 | 主卡 10/13–10/14 评论 |
## 2. 页面分工与核心路径
**用户要做的三件事**,决定了三个主场;其余页面都是它们的仓库或后台。
| 用户要做什么 | 从哪里进入 | 连续操作 | 看到/处理结果 | 回到项目 |
|---|---|---|---|---|
| A. 推进一个片子 | 工作室首页「继续创作」大卡 / 项目卡 | 项目页顶部五阶段 → 当前阶段的内容网格 → 右侧本阶段助手(补充要求/重新生成) | 草稿条提示“待确认”,底栏固定「确认,进入下一阶段」 | 本身就在项目里 |
| B. 生成一张图/一段视频 | 侧栏「创作台」或项目镜头卡「去生视频」 | 左列找工作流 → 底座放素材、写正反提示词 → 参数胶囊 → 生成 | 中间舞台整幅显示;右侧历史竖条单独存在,点开时顶部提示“正在查看历史”,可复用或回到最新 | 结果下方「存入项目」→ 选项目与阶段,进入“待确认” |
| C. 让助手干活 | 侧栏「智能体」或项目阶段的「对话」 | 首页大输入框写想法、绑定项目/模型/技能 → 任务页看计划、工具调用 | 右侧成果栏是可编辑草稿(分镜表/角色/提示词),单镜可重画 | 「带回项目待确认」→ 项目页出现紫色草稿条 |
**导航(左侧 68px 总导航,图标+文字常显)**:工作室、项目、创作台、智能体、画布、素材库 | 任务、工作流、模型连接 | 底部:队列、动效开关、设置。
- 双左栏只在创作台出现(总导航 68 + 工作流列 272),两栏分别回答“去哪个功能”和“用哪个工作流”,宽度差一倍,不会混淆。
- 原「生图」「生视频」两个路由合并为创作台,用顶部分段切换;切换时各自记住工作流、参数和输入。
- 原「ComfyUI 连接」与「模型设置」合为「模型连接」,四个页签:ComfyUI 连接 / 大语言模型 / 图片模型 / 视频模型。
**设计判断(为什么这样分)**
1. 创作台舞台是“作品优先”:不放详情、不放历史,结果按真实比例 `contain` 适配剩余空间;操作条固定在作品正下方,不覆盖画面。
2. 历史是独立竖条(92px),只承担“回看”;回看是显式状态(蓝色提示条),绝不替换当前输入。
3. 工作流先按“我要做什么”(任务胶囊)缩小,再按模型族分组,置顶永远在最上;长文件名只在悬停/表头次要位置出现,主名是人话名。
4. “待配置”的工作流默认隐藏(开关「仅可运行」),避免把 146 个未映射文件冒充可用工具;关掉开关能看到并引导去映射。
5. 智能体不是聊天:任务列表 + 计划 + 工具卡 + 成果栏三栏,成果有归属(项目·阶段)和确认流程。
6. 管理页(任务/工作流/模型连接/素材库)共用“左筛选 220 / 中列表 / 右详情 360”,详情不跳页。
## 3. 设计 Token
```css
/* 表面 */ --bg-0:#07080c; --bg-1:#0c0e14; --bg-2:#12151d; --bg-3:#1a1e29; --bg-4:#232838;
/* 描边 */ --line:rgba(255,255,255,.06); --line-2:.11; --line-3:.18;
/* 文字 */ --tx-1:#f3efe6; --tx-2:#aaa79e; --tx-3:#6f6d68; --tx-inv:#1a1206;
/* 主光 */ --gold:#e9b04b; --gold-hi:#f8d68c; --gold-lo:#b8771f;
--grad-gold:linear-gradient(135deg,#fbe1a1,#efb54f 45%,#d98a26);
/* 场景色温 */ --sc-image:#3fd8c6; --sc-video:#ff8a3d; --sc-agent:#a879ff; --sc-project:#e9b04b; --sc-manage:#7f93b5;
/* 状态 */ --ok:#43d39a; --warn:#f2b33d; --err:#ff5d6c; --info:#5aa9ff;
/* 字号 */ --fs-display:28px/700; --fs-h1:20px/600; --fs-h2:16px/600; --fs-body:13px/400 lh1.55; --fs-small:12px; --fs-micro:11px;
/* 字体 */ --font:"PingFang SC","HarmonyOS Sans SC","Microsoft YaHei UI",sans-serif; --mono:"JetBrains Mono",Consolas,monospace;
/* 间距 */ --s1:4 --s2:8 --s3:12 --s4:16 --s5:20 --s6:24 --s8:32;
/* 圆角 */ --r-xs:4 --r-sm:6 --r-md:10 --r-lg:14 --r-xl:20 --r-pill:999;
/* 阴影 */ --sh-1 内高光+2/8; --sh-2 内高光+12/32 黑50%; --sh-gold 金色外发光(仅主按钮);
/* 动效 */ --ease-out:cubic-bezier(.22,1,.36,1); --ease-io:(.65,0,.35,1); --ease-spring:(.34,1.56,.64,1);
--t-fast:120ms; --t-base:200ms; --t-slow:320ms; --t-reveal:700ms;
/* 布局 */ --rail:68; --list:272; --hist:92; --head:48;
```
**色彩规则**
- 一个页面只有一个金色主按钮(生成 / 确认 / 带回项目 / 开始任务)。
- 场景色只用于“位置与焦点”:选中边框、取景框角标、进度条、焦点光环、导航指示条;禁止做大面积底色。智能体页是唯一允许场景色做氛围(极光、光球)的页面。
- 数字信息(编号、时间码、比例、种子、延迟)一律等宽字体。
## 4. 组件与状态
| 组件 | 尺寸 | 默认 | 悬停 | 选中/按下 | 禁用/异常 |
|---|---|---|---|---|---|
| 按钮 `.btn` | 26/32/38 高 | bg-3 + line-2 | bg-4 + line-3 | 下移 1px | — |
| 主按钮 `.btn.primary` | 32/38 | 金渐变 + sh-gold | 提亮 6% + 扫光一次 | 下移 1px | 去饱和 60% 亮度,旁边红字写“还缺:…” |
| 胶囊 `.chip` | 24–26 | bg-2 | 文字变亮 | 场景色 13% 底 + 60% 描边 | — |
| 参数胶囊 `.pchip` | 28 | 透明 4% | 描边变亮 | 点开弹层 | — |
| 工作流行 `.wfi` | 50 | 透明 | 光栅悬停 + 3% 底 | 场景色渐变底 + 35% 描边 | 待配置:族色块灰化、名字降一级、黄标签 |
| 素材槽 `.slot` | 56(矮屏 50) | 虚线 line-3 | 场景色虚线 | 实线 + 缩略图 + 悬停出现 × | 必填未填:黄色虚线 |
| 作品框 `.frame` | 适配 | 取景框角标常显 | — | — | 失败:斜纹底 + 原因 + 重试 |
| 视频播放器 | 画面 + 40 控制栏 | **暂停**,中央大播放键 | 大播放键放大 1.08 | 播放后大键隐藏,控制栏常显 | — |
| 草稿条 | 44 | 紫色渐变 + 描边 | — | — | — |
| 弹层 `.pop` | ≤620×64vh | 底部固定“取消/保存”,内部滚动 | — | — | — |
**交互铁律**
- 视频永不自动播放;切换结果保持暂停;空格只在播放器获得焦点时切换。
- 生成按钮、确认按钮、带回项目按钮永远在首屏固定位置,不随内容滚走。
- 参数弹层“取消不改参数”;图片与视频分别记忆参数。
- 切换工作流保留提示词;新工作流有反向提示词时自动出现右栏(左正右反)。
- 置顶不切换当前工作流、不清空输入;置顶分图片/视频保存。
## 5. 图标与插图
- 图标:1.7px 线性描边、圆角端点、24 网格;尺寸 14/18/22。不用填充图标,置顶激活例外(填充金色)。
- 工作流族色块:34px 圆角方块 + 族缩写(Qwen/Kr2/FLX/Z/H3/Wan/GPT/NB/可灵/即梦),待配置时灰化。
- 插图:阶段助手卡与工作室大卡用真实项目产物做底图(无产物时用场景色渐变 + 胶片颗粒),不用卡通吉祥物。
## 6. 动效规范
| 名称 | 触发 | 时长 · 曲线 | 可打断 | 减少动效 |
|---|---|---|---|---|
| 页面进入 | 切一级页 | 320ms · ease-out,上移 6px 淡入 | 是 | 直接出现 |
| 光栅悬停 | 指针进入列表行/卡片 | 320ms 渐显,跟随指针的光斑 + 蜂窝纹 | 是 | 只变背景亮度 |
| 取景框 | 选中工作流/作品/历史 | 200ms · ease-out,角标外扩 2px | 是 | 保留角标 |
| 扫描光束 | 生成中 | 1.6s 循环 · ease-io | 取消即停 | 只留进度条 |
| 显影 | 结果到达 | 700ms · ease-out(模糊/过曝 → 清晰) | 点击跳过 | 淡入 |
| 主按钮扫光 | 悬停主按钮 | 700ms 一次 | 是 | 关闭 |
| 弹层 | 打开参数/菜单 | 200ms · 上移 6px + 缩放 .98 | Esc / 点外部 | 直接出现 |
| 提示 Toast | 操作完成 | 320ms spring,3.2s 消失 | 是 | 淡入 |
| 智能体氛围 | 进入智能体页 | 极光 18s 漂移、粒子上升、光球呼吸 4.5s | 离开页面即停 | 全部关闭 |
**性能降级**:粒子用单 canvas、≤46 个,离开页面停止 rAF;极光用 3 个模糊圆、transform 动画;`prefers-reduced-motion` 或侧栏「动效」开关关闭时加 `html.reduce`,去掉所有位移/循环,仅保留颜色与状态变化。
## 7. 桌面断点
| 窗口宽 | 总导航 | 工作流列 | 历史栏 | 其他 |
|---|---|---|---|---|
| ≥1600 | 68 | 272 | 92 | 工作室右栏 340,智能体成果栏 420 |
| 1367–1599 | 68 | 272 | 92 | 标准(1440×900 样稿) |
| 1281–1366 | 68 | 248 | 84 | 页头隐藏工作流文件名 |
| ≤1280 | 60 | 236 | 76 | 页头 44;历史栏可收起 |
| 高 ≤800 | 导航压缩 | 提示词 48 高、素材槽 50;长提示词点「展开」到 28vh,内部滚动 |
最小支持 1280×720(已出样稿 `14-create-image-1280x720.png`、`15-agent-task-1280x720.png`)。不做手机版。
## 8. 待确认事项(请总管转白栖知)
1. **主题方向**:「夜片场」(墨黑 + 钨丝金 + 各区场景色温)是否认可?这是本卡唯一主方向,不再并列给多套。
2. **生图/生视频合并**为一个「创作台」,顶部切换图片/视频(各自记忆)——是否同意?
3. **试验台(Playground)并入创作台**、旧入口下线;**三维搭景**不进一级导航,入口放在项目「镜头脚本」阶段和视频参考槽——是否同意?
4. 「仅可运行」默认打开:146 个未映射文件默认不出现在创作台,只在「工作流库」里显示待配置——是否接受?
5. ComfyUI 连接与模型设置合并为「模型连接」一页四个页签——是否同意?
6. 云端图片/视频模型(GPT-Image-2、Nano Banana、可灵、即梦)在样本中按“已接入”展示;当前后端只开放 LLM,接入属于新增能力,需另行排期。
## 9. 施工提示(给总工/施工员)
- Token 直接替换现有 `app.css` 变量体系;组件按第 4 节状态表逐一对照。
- 舞台尺寸用容器实测(ResizeObserver)计算 contain 尺寸,不用固定高度;视频控件高度计入。
- 历史栏、工作流列、成果栏、弹层内容都各自滚动;页面本身不滚(工作室、规范页除外)。
- 验收看图看操作:1440×900、1366×768、1280×720 三档下,作品完整、主按钮可见、视频默认暂停。
+105
View File
@@ -0,0 +1,105 @@
# 夜片场 · 多主题规范 v2(待白栖知看样确认)
> 视觉总监出具,在 HEL-716《夜片场》规范 v1 的基础上**增补**主题。v1 中的布局、组件、动效和断点全部继续有效,本文件只写新增和改动的部分。样本使用的是示例画面和演示状态,不调用真实生成、不产生费用。
## 0. 原则
**同一个片场,换一种灯光。** 四套主题共用同一套布局、功能、操作顺序和动效,只改变语义变量和少量“气质”参数(圆角、发光开关、氛围层)。组件 CSS 里只能引用语义变量,不允许写死颜色;主题差异只能写在 `[data-theme="…"]` 块中。
| ID | 中文名 | 气质与适用场景 | 默认 |
|---|---|---|---|
| `night` | **夜片场** Nocturne | 墨黑底、钨丝金主键、各工作区有自己的场景色温。夜间、沉浸式创作。 | ✅ 默认(即已认可的原版,一个像素都没改) |
| `day` | **日光棚** Daylight | 暖白纸面、墨色文字、深琥珀主键;不使用发光,靠描边、实色和对比度区分。白天办公、长时间审稿。 | |
| `grade` | **调色间** Grading | 中性深灰、白色主键、场景色降低饱和度、舞台不加色调;圆角收紧到 2–8px,更像工具。用于判断作品色彩,适合长时间看片。 | |
| `bluehour` | **蓝调时刻** Blue Hour | 靛蓝到紫的天色渐变、珊瑚橙主键、圆角放大到 8–26px、主按钮做成胶囊形,氛围最浓。用于提案、展示和灵感发散。 | |
## 1. 语义变量(施工直接使用)
基础变量名沿用 v1;v2 新增的语义变量标 ★。
| 语义 | 变量 | 夜片场 | 日光棚 | 调色间 | 蓝调时刻 |
|---|---|---|---|---|---|
| 页面底 / 输入凹槽 | `--bg-0` | #07080c | #e9e4da | #1b1b1c | #0a1029 |
| 面板(导航、列表、历史栏) | `--bg-1` | #0c0e14 | #f4f1ea | #222223 | #0f1838 |
| 卡片 | `--bg-2` | #12151d | #fbf9f5 | #29292b | #162148 |
| 浮起层 / 按钮 | `--bg-3` | #1a1e29 | #ffffff | #323234 | #1f2c5c |
| 悬停 | `--bg-4` | #232838 | #efe9df | #3c3c3f | #2a386e |
| 主文字 | `--tx-1` | #f3efe6 | #1f1b15 | #ecebe8 | #eef1fb |
| 次要文字 | `--tx-2` | #aaa79e | #55504a | #acaba6 | #a9b1cc |
| 弱文字(≥4.3:1,浅色主题同样适用) | `--tx-3` | #6f6d68 | #736d65 | #85847f | #7680a2 |
| 叠加通道 ★(悬停、细线用 `rgb(var(--ink)/a)`) | `--ink` | 255 255 255 | 38 30 18 | 255 255 255 | 214 224 255 |
| 边框 1/2/3 | `--line*` | 白 .06/.11/.18 | 墨 .09/.15/.26 | 白 .075/.13/.22 | 冷白 .07/.12/.2 |
| 主操作(“去做”,全页唯一) | `--gold` / `--grad-gold` | #e9b04b 金 | #a65a0c 深琥珀 | #d9dde2 校色白 | #ff9a6c 珊瑚 |
| 主操作上的文字 | `--tx-inv` | #1a1206 | #ffffff | #141516 | #2b0c05 |
| 场景 · 图片 | `--sc-image` | #3fd8c6 | #0b8378 | #6fc2b7 | #4fd6ea |
| 场景 · 视频 | `--sc-video` | #ff8a3d | #c2511a | #d8a06c | #ffbb55 |
| 场景 · 智能体 | `--sc-agent` | #a879ff | #6d45d0 | #a598d8 | #b592ff |
| 场景 · 项目 | `--sc-project` | #e9b04b | #a65a0c | #d4bc8a | #ff9a6c |
| 场景 · 管理 | `--sc-manage` | #7f93b5 | #4c6286 | #909cae | #8aa0dc |
| 场景 · 素材资产 ★ | `--sc-asset` | #ff86b0 | #b93a72 | #d394b0 | #ff86c0 |
| 成功 | `--ok` | #43d39a | #11804f | #4cc38f | #4fd69c |
| 提醒 / 待配置 | `--warn` | #f2b33d | #9a6400 | #e3a93b | #ffc94d |
| 错误 / 失败 | `--err` | #ff5d6c | #c42b41 | #ee5d70 | #ff4d6d |
| 信息 / 历史回看 | `--info` | #5aa9ff | #2263c2 | #5d9fe3 | #69b4ff |
| 场景色文字混色端 ★ | `--hi` | white | #141109 | white | white |
| 场景/状态实底上的文字 ★ | `--on-scene` / `--on-ok` | 深色 | #fff | 深色 | 深色 |
| 输入框底 ★ | `--well` | bg-0 | #ffffff | #18181a | bg-0 |
| 页头毛玻璃 ★ | `--glass` | bg-1 70% | bg-1 88% | bg-1 82% | 定制 55% |
| 遮罩层 ★ | `--scrim` | 黑 .6 | 暖墨 .34 | 黑 .55 | 深蓝 .62 |
| 作品阴影 ★ | `--media-shadow` | 重 | 轻、暖 | 中 | 重、偏蓝 |
| 发光开关 ★ | `--glow` | 1 | 0 | 0 | 1 |
另有特效变量 ★:`--gen-bg`(生成中底色)、`--beam`(扫描光)、`--grain`(颗粒)、`--lattice`(光栅蜂窝纹)、`--scanline`、`--stage-tint`(舞台场景色晕 0–10%)、`--aur-1/2/3`、`--aur-op`(极光)、`--agent-hero`、`--agent-title`、`--orb-core`、`--particle`、`--brand-grad/-glow/-ink`、`--display-grad`、`--fail-a/b`。完整取值见交互样本 `<style>` 中的四个主题块;也可以在样本「规范 → 05 主题」里查看,那里直接读取实际变量。
**场景色与状态色**:两组颜色在每套主题里都单独取值,不互相借用。场景色只表示“你在哪个工作区”(选中边框、取景框、进度、焦点);状态色只表示“结果怎么样”。调色间里场景色降低饱和度,状态色保持鲜明,两者不会混淆。
**不随主题变的元素**:作品框底色、视频控制条、作品上的角标(编号、时长、草图标签)、灯箱,在所有主题都保持深色,避免主题给作品染色。工作流模型族色块也保持不变,方便跨主题识别。
## 2. 特色效果按主题适配
| 效果 | 夜片场 | 日光棚 | 调色间 | 蓝调时刻 |
|---|---|---|---|---|
| 扫描光(生成中) | 场景色 + 白光 | 场景色 24% + 纸白高光,不发光 | 中性白,无色彩 | 场景色 34% + 暖白 |
| 显影(结果到达) | 模糊/过曝 → 清晰 | `developDay`:模糊 + 棕褐色调 → 清晰(像相纸显影) | `developGrade`:去饱和 → 清晰 | 同夜片场 |
| 取景框角标 | 场景色偏白 | 场景色偏墨(更深) | 场景色 | 场景色 |
| 光栅悬停 | 白色蜂窝纹 | 墨色蜂窝纹 | 白色 | 冷白色 |
| 发光(导航条、进度条、阶段下划线、状态点) | 有 | **无** | **无** | 有 |
| 智能体氛围 | 紫粉青极光 .33 | 淡紫/桃/薄荷 .42 | 低饱和 .16 | 蓝/珊瑚/青 .36 |
浅色主题不靠发光,也不靠低对比文字来区分信息:选中用实色描边加浅底,禁用的主按钮用浅灰底加灰字(不只是降低透明度)。
## 3. 切换与记忆规则
- **入口**:侧栏底部「主题」(图标是当前主题的四色圆点)→ 弹层列出四个主题:实时缩略预览、中文名、英文名、色调和一句话用途,当前项有 ✓。「设置 → 外观主题」有同样的四张卡片。键盘可用:方向键在选项间移动,Enter 选择,Esc 关闭并把焦点还给入口按钮。
- **只改 `html[data-theme]`**:不重新渲染页面,所以提示词、上传素材、参数、工作流选择、历史回看、项目阶段草稿、智能体任务和成果都保持原样(样本自测已覆盖,见第 6 节)。
- **记忆**:存 `localStorage['aigc-theme']`;`<head>` 里的内联脚本在首次绘制前读取,避免先闪一下夜片场。读到非法值或读取失败时回退到 `night`。
- **存储不可用**:切换照常生效,弹层底部提示“浏览器禁止本地存储:本次有效,其他操作不受影响”,不报错,也不阻止其他操作。
- **默认是夜片场**,不跟随系统的深浅色设置(深浅色只是主题的一个属性,不是选择主题的依据)。正式产品如需按账号同步,再把同一个值存到用户设置里,本地存储作为缓存。
- 「动效开/关」已移到主题弹层底部(同属外观设置),侧栏底部仍然是 4 个按钮,1280×720 下不会溢出。
## 4. 动效与减少动效
| 名称 | 触发 | 时长 · 曲线 | 可否打断 | 减少动效时 |
|---|---|---|---|---|
| 切换主题「开灯」 | 选择新主题 | 520ms · `--ease-out`;新主题从点击位置圆形展开(View Transitions) | 是,再次选择会接管 | 立即替换 |
| 不支持 View Transitions 时 | 同上 | 280ms 颜色淡入淡出(`html.theme-anim`,340ms 后移除) | 是 | 立即替换 |
| 其余动效 | 同 v1 | 同 v1 | 同 v1 | 同 v1 |
`prefers-reduced-motion` 或手动关闭动效时加上 `html.reduce`:主题立即切换,极光、粒子、光束隐藏,只保留颜色和状态变化。粒子颜色在切换主题时从 `--particle` 重新读取。
## 5. 本卡同时修正(按已定分工)
- 云端 GPT-Image-2、Nano Banana Pro、可灵、即梦:在创作台归入「云端模型 · 尚未接入(后续批次)」,灰色色块加虚线「未接入」标签;选中后,生成按钮禁用并提示“尚未接入:云端接口属后续批次,当前不可生成”,页头连接状态为灰点“云端接口尚未接入”。「模型连接」页的云端卡片改为虚线卡片“未接入 / 等待接入”,图片模型、视频模型页签标注“后续”。历史和任务里不再出现云端生成的结果。
- 三维搭景和试验台的组织方式标签由“待确认”改为“已确认”。
- 工作流库表格改为固定列宽,长文件名用省略号截断,1366 宽时“状态”列不再被挤出可视区。
## 6. 检查结果
- 尺寸:四套主题都检查了 1440×900、1366×768(视频首尾帧)和 1280×720(图片竖图)。主按钮在首屏、作品按比例完整显示、视频默认暂停、列表各自滚动、侧栏底部不溢出(见附图 04、05)。
- 全站一致性:工作室、创作台(图片/视频)、智能体首页/任务、项目工作台、工作流库、模型连接、设置、规范页都只使用语义变量(附图 02、03、10–16)。
- 自测(样本地址加 `?selftest=1`,在无界面浏览器中运行):17/17 通过,包括在四套主题之间依次切换后提示词、上传、参数、工作流、历史回看、项目阶段和智能体状态不变;切换后选择被记住;存储被禁止时仍能切换且有提示;云端未接入时生成按钮被禁用。另外验证了重新打开页面会沿用上次的选择。
## 7. 施工提示
1. 先把 v1 组件中写死的 `rgba(...)` 和十六进制颜色换成本文的语义变量(样本已完成,可直接对照)。规则是:白色叠加 → `rgb(var(--ink)/a)`;场景色/状态色叠加 → `color-mix(in srgb,var(--x) N%,transparent)`;场景色文字 → 与 `var(--hi)` 混合。
2. 主题块的选择器写成 `[data-theme="x"]`,不要写成 `html[...]`,这样局部预览(缩略图、规范对照表)也能套用主题。
3. 新主题须等白栖知看样确认后再正式施工;最终上线哪几套、是否保留切换入口,以他的确认为准。
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,26 @@
# HEL-729 · B1 夜片场创作台
从 B0 已审核、已部署的 `8e84e4c21f22febe7bd4f20777d06fa1d4914e8e` 继续,目标分支为 `feat/hel-728-night-foundation`。本批只改创作台与其兼容入口,不改生成 API、数据库、连接配置与项目确认语义,不部署。
## 实现
- `/create` 成为真实创作台,内部切换图片/视频。旧 `/image`、`/video`、`/playground` 保留所有查询参数与片段转入创作台。
- 复用 StudioDesk、Composer、SlotTray、ParamsPanel、ParameterPopover、TakeFeed 和真实生成/回流 API。工作流列采用 B0 的 `--list`,搜索、用途/模型筛选、收藏与置顶保留;仅可运行默认开启,关闭后待配置项进入对应的真实映射/测试管理行。
- 草稿按媒介和来源分别保存,内部工作流独立记忆输入及连接。首次切换工作流延续正反向提示词;显式深链优先于不同请求的缓存,同一请求刷新恢复用户的选择,异步详情与列表加载顺序不改变归属。存储受限时保留同标签页内存回退。
- 素材与提示词并排放在底座,长提示词可展开并内部滚动。参数弹层沿用草稿取消/保存。槽尺寸与提示词高度统一归入 `static/themes.css`,四套主题继续可用。
- 历史是独立右栏,可收起。回看只改变观看对象,显示历史状态条;返回当前创作保留原输入。主动复用才回填,跨媒介复用切到对应编辑器,并恢复生成时使用的可用连接。
- 作品按容器实测尺寸与媒体宽高计算 contain 大小。视频无自动播放,中央大播放键清晰,空格仅在视频自身获得焦点时控制;切换结果暂停。下载、取消、重试、详情及已有来源项目回流均沿用原链路。
## 自测与证据
`e2e/run-hel729.py` 在前台启动并收集隔离 FastAPI、假 ComfyUI、假 LLM 与真实前端,结束清理子进程。测试断言数据目录为本次隔离目录,未读取用户数据或调用收费服务。测试输入、遮罩、首尾帧、多参考素材通过真实上传、队列与输出保存链路;来源结果通过原 attach-workbench 接口显式回流。
浏览器脚本 `e2e/hel729.cjs` 检查三档桌面(1440×900、1366×768、1280×720)、横竖图完整显示、首屏主操作、手动播放、图视频与工作流输入归属、刷新、主题切换、参数取消/保存、历史与复用、失败重试、取消和存储受限。PNG 和 results.json 通过任务卡附件交付;样图均为隔离测试内容。
相关后端回归:`test_image_input_workflows.py`、`test_playground_uploads.py`、`test_playground_workbench_flowback.py`、`test_playground.py` 共 47 项通过。前端 `npm run check`、`npm run test`、`npm run build` 通过;类型检查保留基线 ShotStagePanel 视频字幕警告一条。
全仓 `npm run lint` 仍被既有格式问题阻断,直接 ESLint 也因仓库缺少 eslint.config 配置退出;这两项为 B0 既有工具链问题,不在本批扩面格式化或新增依赖。本批主要组件另做 Prettier 定向检查和 `git diff --check`。
## 尚未覆盖
未连接真实 ComfyUI/GPU 或云模型做付费生成,未部署测试站。通用“选择项目/阶段存入”和智能体任务成果能力仍按分批安排留给 B4。本批交总工独立对照样本审核,不能以自动化数量替代视觉验收。
@@ -0,0 +1,27 @@
# HEL-740 · B1 创作台样本返工
继续原 `feat/hel-729-creative-desk`,以前次审核 head `2304ab1538a4fd5f301efbf58f1a28b987b78809` 为起点;PR #3 目标仍为 `feat/hel-728-night-foundation@8e84e4c21f22febe7bd4f20777d06fa1d4914e8e`。未部署、合并或修改生成 API、数据库和正式数据。
## 实现
- `static/themes.css` 统一底座、按钮、输入与弹层 token。夜片场/日光棚按钮 131×38、r10、14px/700;调色间遵样本 r4,蓝调时刻遵样本胶囊。底座渐变、边框、阴影、距两边16/底14恢复,样本浮层圆角随主题变化。
- `Composer` 恢复60px/13px正负提示词、1.35:1比例与计数栏,去掉固定滚动槽占位。1440有媒体输入时正375.3×60、负269.7×60,对原样本375.6/270误差小于1px。长文本内部滚动/展开与真实输入要求保留。
- `Modal` 增加可选底座锚点与头部操作槽,复用原焦点层栈、Tab循环、Escape、外部点击和焦点归还。参数620×560;较小窗口按64vh与顶栏可用空间缩短、内容滚动。恢复默认只改草稿,取消丢弃、保存才应用。
- `Artboard` 恢复媒体下40px行内八入口,放大/下载/复用参数/作为参考图/送去生视频/存入项目/素材库/更多。真实来源项目回流沿用原接口;更多保留详情、对比、尾帧导出等。视频默认暂停、contain完整显示、中央64px播放按钮与仅播放器聚焦空格保留。
- `DeskWorkflowRail`/`HistoryRail` 恢复标题行历史切换、收起归零、247×36分段控件。`StudioDesk` 恢复胶囊历史提示、14px/600工作流名和头部连接状态;连接名/延迟来自真实配置与测试结果,未知显示未知。
## 验证
- `e2e/hel729.cjs` 42项全过,只按恢复后的真实入口更新记录/来源项目选择器;覆盖上传、遮罩、首尾帧、多参考、草稿隔离、历史不改输入、主动/跨媒介复用、旧入口、来源项目回流与手动播放。
- `e2e/hel740.cjs` 69项全过:三档尺寸×四主题逐项尺寸/比例、首屏/contain、八入口、锚定弹层及恢复默认/取消/保存/焦点、历史归零/胶囊、视频暂停播放。附原HTML与实际应用并排图、完整DOM实测JSON和全程WebM,另含悬停、选中、生成中与播放逐帧。
- 隔离FastAPI+假ComfyUI;后端四组相关测试47项全过。`check` 0错误、1条既有ShotStagePanel字幕警告;`test`、`build`、定向格式检查、`git diff --check`通过。全仓lint仍受63个文件基线格式与缺失ESLint配置影响,不扩全仓治理。
复现:下载主卡已确认HTML,通过 `HEL740_SAMPLE` 指定原样本路径;在原隔离栈运行 `HEL729_TESTS=e2e/hel729.cjs,e2e/hel740.cjs python3 e2e/run-hel729.py`,环境按原脚本配置Playwright/Chromium。截图和日志通过任务卡附件交付,不将临时数据提交进仓库。
## 明确边界与剩余事实
- 默认文生图没有媒体输入,样本可选垫图槽位仍待总管组织决定;未新增映射或伪造上传槽。此次同状态尺寸对照使用已支持图片和遮罩的真实工作流类型。
- 参数字段来自工作流声明;隔离夹具仅有种子和一个高级参数,没有伪造样本中的分辨率、步数等能力。实际工作流声明这些字段时沿用动态表单显示。
- 跨工作流送图能力未具备,送去生视频入口禁用并解释;通用项目/阶段选择留B4,存入项目只在真实来源上下文存在时启用。
- 已接受B0的48px顶栏保留,其造成的作品高度差不计作已还原。没有宣称全页逐像素一致或交部署。
- PR #3为AGit请求,已用原topic `hel-729-creative-desk` 推送到 `refs/for/feat/hel-728-night-foundation` 更新现有审核引用(仅推送普通分支不会更新该请求);Gitea API `/api/v1/user` 返回401,本运行只有SSH推送身份,不能修改PR标题/正文。任务卡附拟更新文案供总管完成元数据更新,不新建重复请求。
@@ -0,0 +1,13 @@
# HEL-741 创作台 R1—R4 剩余呈现修复
沿用 feat/hel-729-creative-desk 和原 PR #3,基于 7bf63d05873b1e473905763d13fe999ac688cc6d。目标分支仍为 feat/hel-728-night-foundation@8e84e4c21f22febe7bd4f20777d06fa1d4914e8e。未部署、未合并、未新增依赖、未修改后端或正式数据。
- DeskWorkflowRail 复用统一 token,原用途/家族选择改为 chips;保留收藏、仅可运行、搜索、置顶及待配置导向;仅可运行按原样本26×15开关绘制,保留原生复选框语义与键盘操作。搜索加入原图标结构,34px/13px;metadata 展示实时筛选数,仓库文件数因接口未知如实说明,不用数据库条数代替。真实用途仍为现有五类;没有独立局部重绘元数据,不臆测新分类。
- StudioDesk 头行按样本48px、1280下44px;名字裸14px/600、21.6875px行盒。家族标识、用途、等宽源文件名、真实连接信息保留。源文件名来自已有source_path,无值显示未知。管理详情入口复用侧栏页脚,参数仍由dock chips进入,专注移入参数层;弹层焦点归还更新为真实入口。
- Artboard 子按钮依样本夜/日/调色r5、蓝调r8;两条1px×18px分隔线、尾部26px图标删除。已有作品编号处打开详情、对比、尾帧导出等能力。删除先确认,再调用原5秒撤销队列;提示如实说明生成文件和项目回流记录也会删除,后端规则不变。
- Composer 生成图标14px。蓝调按钮token为140.7px,浏览器140.6875px;样本140.65625px。夜/日/调色仍保留已验收131px,对样本130.65625px有0.34375px差值,不填入虚构的演示计费后缀。
- 原样本的主题差异通过token保持:搜索框圆角6/6/4/8,筛选chips圆角999/999/3/999,连接胶囊调色间r3。样本原件保持不动。
验证:check 0错误、1条既有ShotStagePanel字幕警告;npm test/build通过;后端生成、图像输入、项目回流、连接四组67项通过。浏览器在隔离FastAPI和假ComfyUI运行,原42项回归和新增138项检查均通过,覆盖三档×四主题测量、筛选/入口/专注/焦点/删除确认取消撤销及真实提交;逐项结果、实测值、并排截图和录屏由任务附件交付。全仓lint仍有62个既有格式文件且ESLint缺少配置,定向格式通过,不做全仓治理。
真实数据与样本的剩余差异明确列于附件,不宣称整页差异清零;全站B0顶栏保留。通用存入项目留B4,跨工作流送视频如实禁用;文生图不具备媒体输入时没有垫图槽,符合主卡最新“文生图不垫图”决定。
@@ -0,0 +1,24 @@
# HEL-743 搜索提示与头行定点返工
基于 `e62499c517ccfbd78e7d3c13df98199b6699a6b2`,沿用分支 `feat/hel-729-creative-desk` 和 PR #3,base 保持 `feat/hel-728-night-foundation@8e84e4c21f22febe7bd4f20777d06fa1d4914e8e`。
- `DeskWorkflowRail.svelte` 补原件 `/` kbd,字体、内边距、圆角进入 `static/themes.css`,颜色和边框复用现有主题 token。输入按原件flex:1分配剩余宽度。最终输入样本/实际为176.671875/176.6875px(另两档152.671875/152.6875、140.671875/140.6875),有1/64px排版量化差,未把实测值写成完全相等;kbd字体/内边距/圆角/边框/颜色均一致。非编辑态 `/` 聚焦搜索,编辑器、contenteditable、组合输入、修饰键和可见弹层不会劫持;Esc 保留筛选并归还先前焦点,无可用来源时回到工作流栏控制按钮。
- `StudioDesk.svelte` 修改原头行规则,左右负边距和内边距各16px,背景、底线通到内容列边缘;使用统一玻璃和线条 token,蓝调使用原件55%玻璃背景。内容起点、头行高度及真实连接信息保持。
- 源文件名增加原件 `max-width:1366px` 隐藏规则,1440显示真实source_path的文件名,完整路径仍在title。未伪造业务数据。
- 从 HEL-742 复审的本地材料读取并纳入 `e2e/hel742.cjs`,此前任务附件确实只有5张PNG,脚本并非从附件下载。保留无图片声明时无槽位/素材入口、图视频切换、遮罩双槽对照7项。
- `e2e/hel743.cjs` 新增三档×四主题几何/玻璃/底线/kbd/文件名测量,1367/1366断点、搜索/焦点/输入法/弹层回归。只在隔离数据库中把夹具实际JSON文件的路径写为source_path,以验证真实文件名展示。`e2e/compare-hel743.py` 将测量区域并排,左样本、右实际。
复跑(沿用已有依赖,不新增依赖):
```bash
# HEL740_SAMPLE 指向从主卡独立下载且SHA256核对的原件
# Linux按本机环境提供 PLAYWRIGHT_MODULE_PATH / CHROMIUM_EXECUTABLE_PATH / LD_LIBRARY_PATH
HEL740_SAMPLE=/path/to/approved.html \
HEL729_TESTS=e2e/hel729.cjs,e2e/hel740.cjs,e2e/hel742.cjs,e2e/hel743.cjs \
calliope-backend/.venv/bin/python e2e/run-hel729.py
calliope-backend/.venv/bin/python e2e/compare-hel743.py
```
原件 SHA256:`0473d80d22b90194375f3566c1b8ca30dbd7433581b18f60956eabca252242a4`。runner在前台运行,finally停止并回收本轮服务;隔离夹具为 `e2e/shots/reference`。并排图、逐项结果和实测值随本卡附件交付。
验证:前端 check 0错误、1条既有ShotStagePanel字幕警告;test/build通过;相关后端4组47项通过。全仓lint仍为既有62文件格式问题、ESLint缺少配置;本次组件定向Prettier和 `git diff --check` 通过。浏览器42项原回归、138项R1—R4回归、7项模式切换和91项本轮定向断言通过(结果和实测值见任务附件)。中文输入法组合态用合成isComposing及229事件验证,未接入真实中文输入法/GPU/云生成。浏览器首次尝试因运行库路径不完整退出,修正本地LD_LIBRARY_PATH后正常执行;新增脚本曾因旧“参数”按钮名超时,已改用现有“创作设置⌄”入口复跑。没有修改正式数据、后端、B0顶栏或已裁定非偏差项;没有收费生成、合并或部署。
+5
View File
@@ -0,0 +1,5 @@
c32f1f7e6814350d2f5470e900b5a697bf8f34f02eac3837eca9a7a3fe9b25d4 /opt/aigc-home/releases/formal-v5-35fd07a.tgz
f1fb2e4167a7e552de69f7857347ca95a1ebf0f1a5ab1d075692ccf1ae5a103b /opt/aigc-home/releases/backup-HEL665-v5-35fd07a-20261015.tgz
8374951e292df80f43aac3425a7aaa312e59f3a0b953b45a4d051934612ff304 /opt/aigc-home/releases/rollback-HEL665-v5-35fd07a-20261015.sh
old-source-present
old-static-present
+31
View File
@@ -0,0 +1,31 @@
{"image_wf": 1, "video_wf": 2, "endpoint": "cba0eaa4-57f2-4f57-99b1-ba7b6f0b0835"}
{'dual': 3, 'h3': 4, 'project': 1}
RUN e2e/reference/check.cjs
PASS 真实工作流显示正反向左右分栏
PASS 工作流列表可应对超过 48 项
PASS 列表搜索过滤真实元数据
PASS 置顶不切换工作流、不清空草稿
PASS 参数取消不改当前值
PASS 参数保存应用到当前工作流
PASS 真实生成请求提交且取得结果
PASS 正反向与种子写入任务
locator.click: Timeout 12000ms exceeded.
Call log:
 - waiting for locator('.result-item').first()
at /home/xiaomo/multica_workspaces/hello-af23464d222e/hel-728-a07b128af8f7/workdir/aigc-home/e2e/reference/check.cjs:146:45 {
log: [ " - waiting for locator('.result-item').first()" ],
name: 'TimeoutError'
}
RUN e2e/reference/check-copy-fallback.cjs
locator.waitFor: Timeout 15000ms exceeded.
Call log:
 - waiting for getByRole('button', { name: /编辑内容并带回项目/ }) to be visible
at /home/xiaomo/multica_workspaces/hello-af23464d222e/hel-728-a07b128af8f7/workdir/aigc-home/e2e/reference/check-copy-fallback.cjs:44:54 {
log: [
" - waiting for getByRole('button', { name: /编辑内容并带回项目/ }) to be visible"
],
name: 'TimeoutError'
}
FAILED SCRIPTS ['e2e/reference/check.cjs', 'e2e/reference/check-copy-fallback.cjs']
+74
View File
@@ -0,0 +1,74 @@
> calliope-web@1.5.8 lint
> prettier --check . && eslint .
Checking formatting...
[warn] package-lock.json
[warn] package.json
[warn] scripts/check-storage-ssr.mjs
[warn] src/app.html
[warn] src/hooks.client.ts
[warn] src/hooks.server.ts
[warn] src/lib/actions/spotlight.ts
[warn] src/lib/agentComposer.ts
[warn] src/lib/agentTasks.ts
[warn] src/lib/assetPicker.ts
[warn] src/lib/comfy/classifyInput.ts
[warn] src/lib/comfy/parser.ts
[warn] src/lib/comfy/promptInput.ts
[warn] src/lib/comfy/resolution.ts
[warn] src/lib/comfy/types.ts
[warn] src/lib/comfy/useUpload.svelte.ts
[warn] src/lib/components/agent/agentPalette.ts
[warn] src/lib/components/generate/context.ts
[warn] src/lib/components/generate/schema.ts
[warn] src/lib/components/generate/takeDelete.svelte.ts
[warn] src/lib/components/generate/takes.ts
[warn] src/lib/components/shell/nav.ts
[warn] src/lib/components/ui/icons.ts
[warn] src/lib/components/ui/modalLayers.ts
[warn] src/lib/components/ui/README.md
[warn] src/lib/dbtime.ts
[warn] src/lib/durationBudget.ts
[warn] src/lib/endpointKind.ts
[warn] src/lib/events.ts
[warn] src/lib/formOptions.ts
[warn] src/lib/i18n.svelte.ts
[warn] src/lib/i18n/de.ts
[warn] src/lib/i18n/en.ts
[warn] src/lib/i18n/es.ts
[warn] src/lib/i18n/fr.ts
[warn] src/lib/i18n/ja.ts
[warn] src/lib/i18n/ko.ts
[warn] src/lib/i18n/zh.ts
[warn] src/lib/jobProgress.ts
[warn] src/lib/jobStatus.ts
[warn] src/lib/missingOutputs.svelte.ts
[warn] src/lib/promptTemplates.ts
[warn] src/lib/reduceMotion.svelte.ts
[warn] src/lib/referenceAgent.ts
[warn] src/lib/shot/api.ts
[warn] src/lib/shot/calibration/compositionPresets.ts
[warn] src/lib/shot/calibration/shotSolver.ts
[warn] src/lib/shot/helpers/cameraUtils.ts
[warn] src/lib/shot/helpers/jointConfig.ts
[warn] src/lib/shot/helpers/mannequinFactory.ts
[warn] src/lib/shot/helpers/mirror.ts
[warn] src/lib/shot/helpers/posture.ts
[warn] src/lib/shot/helpers/primitiveFactory.ts
[warn] src/lib/shot/motion/sampleTrack.ts
[warn] src/lib/shot/shotStore.svelte.ts
[warn] src/lib/shot/types/mannequin-js.d.ts
[warn] src/lib/storage.ts
[warn] src/lib/toast.ts
[warn] src/lib/wideLayout.ts
[warn] src/routes/+layout.ts
[warn] src/routes/+page.ts
[warn] src/routes/app.css
[warn] src/routes/project/[id]/+page.ts
[warn] src/routes/projects/+page.ts
[warn] svelte.config.js
[warn] tailwind.config.js
[warn] tsconfig.json
[warn] vite.config.ts
[warn] Code style issues found in 68 files. Run Prettier with --write to fix.
+413
View File
@@ -0,0 +1,413 @@
FFFFF [100%]
=================================== FAILURES ===================================
________________________ test_comfy_server_info_scoping ________________________
def test_comfy_server_info_scoping():
"""Native health check stays visible in both blind and linked sessions."""
from calliope.agent.harness import tools as harness_tools
blind = ToolContext(session_id=1, project_id=None)
linked = ToolContext(session_id=1, project_id=99)
> blind_names = {e["function"]["name"] for e in harness_tools.openai_tools_payload(blind)}
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
tests/test_agent_comfy_mcp.py:370:
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _
src/calliope/agent/harness/tools.py:27: in openai_tools_payload
return _registry.openai_payload(ctx)
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
src/calliope/agent/harness/registry.py:219: in openai_payload
if self._visible(t, ctx)
^^^^^^^^^^^^^^^^^^^^^
src/calliope/agent/harness/registry.py:203: in _visible
if not user_allows_render(ctx):
^^^^^^^^^^^^^^^^^^^^^^^
src/calliope/agent/harness/policy.py:156: in user_allows_render
if has_structured_approval(ctx, "render"):
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
src/calliope/agent/harness/policy.py:277: in has_structured_approval
answer = latest_answer(ctx.session_id)
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
src/calliope/agent/harness/policy.py:260: in latest_answer
events = read_events(session_id)
^^^^^^^^^^^^^^^^^^^^^^^
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _
session_id = 1
def read_events(session_id: int) -> list[SessionEvent]:
conn = _db()
try:
> rows = conn.execute(
"SELECT * FROM agent_events WHERE session_id = ? ORDER BY seq", (session_id,)
).fetchall()
E sqlite3.OperationalError: no such table: agent_events
src/calliope/agent/harness/log.py:146: OperationalError
----------------------------- Captured stderr call -----------------------------
2026-10-21 20:00:31,637 | INFO | calliope.harness | Harness composed: 79 tools
------------------------------ Captured log call -------------------------------
INFO calliope.harness:__init__.py:214 Harness composed: 79 tools
___________ test_h3_messages_bind_neighbor_shots_and_keep_file_slots ___________
def test_h3_messages_bind_neighbor_shots_and_keep_file_slots():
from calliope.agent.prompts import build_minimax_h3_ref_messages
from calliope.agent.video_agent import resolve_h3_references
from calliope.comfyui.parser import parse_dynamic_inputs
plan = {
"overview": {"style": "noir", "lighting": "sodium"},
"requirements": {"lighting": "sodium lamps only"},
"shots": [
{
"clip_id": 1,
"window": "00:00.000-00:04.000",
"action": "enter",
"camera": "push in",
"lighting": "dim sodium",
"speakers": "MAYA: hello",
"spatial": "screen left",
"state_after": "door open",
},
{
"clip_id": 2,
"window": "00:04.000-00:08.000",
"action": "turn",
"camera": "hold",
"lighting": "dim sodium",
"speakers": "",
"spatial": "screen left",
"state_after": "",
},
{
"clip_id": 3,
"window": "00:08.000-00:12.000",
"action": "leave",
"camera": "pull out",
"lighting": "brighter",
"speakers": "",
"spatial": "",
"state_after": "hallway empty",
},
],
}
lock = continuity_lock_text(plan, 2)
assert "Previous shot" in lock
assert "Next shot" in lock
assert "Do not recast a subject" in lock
assert "screen direction" in lock
assert "enter" in lock and "leave" in lock
scene = {"heading": "INT. HALL", "action": "She turns.", "duration_sec": 4, "dialog": ""}
subjects = [
{
"index": 1,
"kind": "reference",
"name": "mercs",
"appearance": "",
"path": r"E:\assets\mercs.png",
}
]
messages = build_minimax_h3_ref_messages(scene, subjects, continuity=lock)
text = messages[1]["content"]
assert "Previous shot" in text
assert "Next shot" in text
assert "screen direction" in text
assert "do not substitute another character or location" in text
workflow = {
"148": {
"class_type": "LoadImage",
"inputs": {"image": ""},
"_meta": {"title": "Ref 1 (Input:image)"},
},
"149": {
"class_type": "LoadImage",
"inputs": {"image": ""},
"_meta": {"title": "Ref 2 (Input:image)"},
},
}
inputs = parse_dynamic_inputs(workflow)
resolved, paths, _videos = resolve_h3_references(
inputs,
{"148": r"E:\assets\hero.png", "149": r"E:\assets\mercs.png"},
[{"name": "Maya", "appearance": "olive jacket", "sheet_path": r"E:\assets\maya.png"}],
{"name": "Metro Line", "description": "rain-slick rooftops"},
r"E:\assets\metro.png",
)
> assert resolved[1]["name"] == "mercs"
E AssertionError: assert 'E:\\assets\\mercs' == 'mercs'
E
E - mercs
E + E:\assets\mercs
tests/test_continuity.py:249: AssertionError
___________ test_resolve_h3_references_prefers_form_files_over_story ___________
def test_resolve_h3_references_prefers_form_files_over_story():
"""Slot files the user picked replace the story cast; empty slots keep it."""
from calliope.agent.video_agent import resolve_h3_references
from calliope.comfyui.parser import parse_dynamic_inputs
workflow = {
"148": {
"class_type": "LoadImage",
"inputs": {"image": ""},
"_meta": {"title": "Ref 1 (Input:image)"},
},
"149": {
"class_type": "LoadImage",
"inputs": {"image": ""},
"_meta": {"title": "Ref 2 (Input:image)"},
},
"150": {
"class_type": "LoadVideo",
"inputs": {"video": ""},
"_meta": {"title": "Motion (Input:video)"},
},
}
inputs = parse_dynamic_inputs(workflow)
characters = [
{
"name": "Maya",
"appearance": "olive jacket",
"sheet_path": r"E:\assets\maya.png",
}
]
location = {"name": "Metro Line", "description": "rain-slick rooftops"}
loc_image = r"E:\assets\metro.png"
empty_subjects, empty_paths, empty_videos = resolve_h3_references(
inputs, {}, characters, location, loc_image
)
assert [s["name"] for s in empty_subjects] == ["Maya", "Metro Line"]
assert empty_paths == [r"E:\assets\maya.png", r"E:\assets\metro.png"]
assert empty_videos == []
subjects, paths, videos = resolve_h3_references(
inputs,
{
"148": r"E:\assets\maya.png",
"149": r"E:\assets\mercs.png",
"150": r"E:\assets\fight.mp4",
},
characters,
location,
loc_image,
)
assert subjects[0]["kind"] == "character"
assert subjects[0]["name"] == "Maya"
assert subjects[1]["kind"] == "reference"
> assert subjects[1]["name"] == "mercs"
E AssertionError: assert 'E:\\assets\\mercs' == 'mercs'
E
E - mercs
E + E:\assets\mercs
tests/test_h3_profile.py:215: AssertionError
___________ test_preview_prompt_uses_form_references_not_story_cast ____________
client = <starlette.testclient.TestClient object at 0x7f3b525d3830>
monkeypatch = <_pytest.monkeypatch.MonkeyPatch object at 0x7f3b525d0c80>
def test_preview_prompt_uses_form_references_not_story_cast(client, monkeypatch):
"""The Video-tab image and video slots are the prompt's subjects.
Story character + location text must not replace files the user picked,
and Regenerate (force) must not return a draft written before those files.
"""
pid = _mk_project(client, "Preview refs")
scene = _add_scene(client, pid, 1, action="She swings the extinguisher.")
clip_id = _scene_default_clip(pid, scene["id"])
wf = {
"10": {
"class_type": "CLIPTextEncode",
"inputs": {"text": ""},
"_meta": {"title": "Main Prompt (Input:prompt)"},
},
"148": {
"class_type": "LoadImage",
"inputs": {"image": ""},
"_meta": {"title": "Ref 1 (Input:image)"},
},
"149": {
"class_type": "LoadImage",
"inputs": {"image": ""},
"_meta": {"title": "Ref 2 (Input:image)"},
},
"150": {
"class_type": "LoadVideo",
"inputs": {"video": ""},
"_meta": {"title": "Motion (Input:video)"},
},
}
conn = get_db(settings.db_path)
try:
cur = conn.execute(
"""
INSERT INTO workflows (name, kind, workflow_json, input_schema, output_schema,
prompt_profile, is_enabled)
VALUES (?, 'video', ?, '[]', '[]', 'minimax_h3_ref', 1)
""",
("H3 refs", json.dumps(wf)),
)
wf_id = cur.lastrowid
char = conn.execute(
"""
INSERT INTO characters (project_id, name, appearance, sheet_path)
VALUES (?, 'Maya', 'olive jacket', ?)
""",
(pid, r"E:\story\maya-sheet.png"),
)
conn.execute(
"INSERT INTO scene_characters (scene_id, character_id) VALUES (?, ?)",
(scene["id"], char.lastrowid),
)
loc = conn.execute(
"""
INSERT INTO locations (project_id, name, description, reference_image_path)
VALUES (?, 'Metro Line', 'rain-slick rooftops', ?)
""",
(pid, r"E:\story\metro.png"),
)
conn.execute(
"UPDATE scenes SET location_id = ? WHERE id = ?",
(loc.lastrowid, scene["id"]),
)
conn.execute(
"UPDATE clips SET workflow_id = ? WHERE id = ?", (wf_id, clip_id)
)
conn.commit()
finally:
conn.close()
seen: dict = {}
async def fake_rewrite(scene_, subjects, **kwargs):
seen["subjects"] = subjects
seen["videos"] = kwargs.get("videos")
seen["calls"] = seen.get("calls", 0) + 1
return "REWRITE FROM REFS"
monkeypatch.setattr("calliope.agent.video_agent._h3_rewrite", fake_rewrite)
form = {
"148": r"E:\refs\hero.png",
"149": r"E:\refs\mercs.png",
"150": r"E:\refs\fight.mp4",
}
r = client.post(
f"/api/jobs/projects/{pid}/preview-prompt",
json={"clip_id": clip_id, "input_values": form},
)
assert r.status_code == 200
body = r.json()
assert body["prompt"] == "REWRITE FROM REFS"
assert body["from_draft"] is False
names = [s["name"] for s in seen["subjects"]]
> assert names == ["hero", "mercs"]
E AssertionError: assert ['E:\\refs\\h...\refs\\mercs'] == ['hero', 'mercs']
E
E At index 0 diff: 'E:\\refs\\hero' != 'hero'
E Use -v to get more diff
tests/test_scene_settings.py:487: AssertionError
---------------------------- Captured stderr setup -----------------------------
2026-10-21 20:00:31,702 | INFO | calliope | Calliope started — db=/tmp/multica-task-3084682389/tmpj2vinw90/calliope.db dry_run=True
2026-10-21 20:00:31,702 | INFO | calliope.worker | Queue worker started
------------------------------ Captured log setup ------------------------------
INFO calliope:main.py:112 Calliope started — db=/tmp/multica-task-3084682389/tmpj2vinw90/calliope.db dry_run=True
INFO calliope.worker:worker.py:79 Queue worker started
----------------------------- Captured stderr call -----------------------------
2026-10-21 20:00:31,707 | INFO | httpx2 | HTTP Request: POST http://testserver/api/projects "HTTP/1.1 200 OK"
2026-10-21 20:00:31,721 | INFO | httpx2 | HTTP Request: POST http://testserver/api/projects/1/scenes "HTTP/1.1 200 OK"
2026-10-21 20:00:31,733 | INFO | httpx2 | HTTP Request: POST http://testserver/api/jobs/projects/1/preview-prompt "HTTP/1.1 200 OK"
------------------------------ Captured log call -------------------------------
INFO httpx2:_client.py:1085 HTTP Request: POST http://testserver/api/projects "HTTP/1.1 200 OK"
INFO httpx2:_client.py:1085 HTTP Request: POST http://testserver/api/projects/1/scenes "HTTP/1.1 200 OK"
INFO httpx2:_client.py:1085 HTTP Request: POST http://testserver/api/jobs/projects/1/preview-prompt "HTTP/1.1 200 OK"
--------------------------- Captured stderr teardown ---------------------------
2026-10-21 20:00:32,204 | INFO | calliope.worker | Queue worker stopped
2026-10-21 20:00:32,204 | INFO | calliope | Calliope shutting down
---------------------------- Captured log teardown -----------------------------
INFO calliope.worker:worker.py:185 Queue worker stopped
INFO calliope:main.py:118 Calliope shutting down
_________________ test_script_regenerate_keeps_expanded_count __________________
client = <starlette.testclient.TestClient object at 0x7f3b524d76b0>
monkeypatch = <_pytest.monkeypatch.MonkeyPatch object at 0x7f3b525d2180>
def test_script_regenerate_keeps_expanded_count(client, monkeypatch):
calls: list[int] = []
async def fake_structured(messages, temperature=0.7):
user = messages[1]["content"]
# Parse required count from prompt
n = 4
for line in user.splitlines():
if line.startswith("required_scene_count:"):
n = int(line.split(":", 1)[1].strip())
break
calls.append(n)
return {
"scenes": [
{
"order_index": i,
"heading": f"SCENE {i}",
"action": f"action {i}",
"dialog": "",
"duration_sec": 5,
"character_ids": [],
"location_id": None,
}
for i in range(1, n + 1)
]
}
monkeypatch.setattr("calliope.agent.script_agent.generate_structured", fake_structured)
r = client.post("/api/projects", json={"title": "Expand", "idea": "desert", "target_duration": "30 seconds"})
pid = r.json()["id"]
# Seed 4 scenes via generate
r = client.post(f"/api/projects/{pid}/generate-script", json={"replace": True})
> assert r.status_code == 200
E assert 500 == 200
E + where 500 = <Response [500 Internal Server Error]>.status_code
tests/test_script_scene_count.py:59: AssertionError
---------------------------- Captured stderr setup -----------------------------
2026-10-21 20:00:32,234 | INFO | calliope | Calliope started — db=/tmp/multica-task-3084682389/tmp2hqlzdiy/calliope.db dry_run=True
2026-10-21 20:00:32,234 | INFO | calliope.worker | Queue worker started
------------------------------ Captured log setup ------------------------------
INFO calliope:main.py:112 Calliope started — db=/tmp/multica-task-3084682389/tmp2hqlzdiy/calliope.db dry_run=True
INFO calliope.worker:worker.py:79 Queue worker started
----------------------------- Captured stderr call -----------------------------
2026-10-21 20:00:32,238 | INFO | httpx2 | HTTP Request: POST http://testserver/api/projects "HTTP/1.1 200 OK"
2026-10-21 20:00:32,264 | INFO | calliope.llm | LLM stream request to http://127.0.0.1:11434/v1/chat/completions with model llama3.2
2026-10-21 20:00:32,266 | INFO | httpx2 | HTTP Request: POST http://testserver/api/projects/1/generate-script "HTTP/1.1 500 Internal Server Error"
------------------------------ Captured log call -------------------------------
INFO httpx2:_client.py:1085 HTTP Request: POST http://testserver/api/projects "HTTP/1.1 200 OK"
INFO calliope.llm:llm.py:360 LLM stream request to http://127.0.0.1:11434/v1/chat/completions with model llama3.2
INFO httpx2:_client.py:1085 HTTP Request: POST http://testserver/api/projects/1/generate-script "HTTP/1.1 500 Internal Server Error"
--------------------------- Captured stderr teardown ---------------------------
2026-10-21 20:00:32,735 | INFO | calliope.worker | Queue worker stopped
2026-10-21 20:00:32,736 | INFO | calliope | Calliope shutting down
---------------------------- Captured log teardown -----------------------------
INFO calliope.worker:worker.py:185 Queue worker stopped
INFO calliope:main.py:118 Calliope shutting down
=============================== warnings summary ===============================
../../aigc-home/calliope-backend/.venv/lib/python3.12/site-packages/starlette/testclient.py:53
/home/xiaomo/multica_workspaces/hello-af23464d222e/hel-728-a07b128af8f7/workdir/aigc-home/calliope-backend/.venv/lib/python3.12/site-packages/starlette/testclient.py:53: DeprecationWarning: The anyio.abc.BlockingPortal alias is deprecated, use anyio.from_thread.BlockingPortal instead.
_PortalFactoryType = Callable[[], AbstractContextManager[anyio.abc.BlockingPortal]]
-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html
=========================== short test summary info ============================
FAILED tests/test_agent_comfy_mcp.py::test_comfy_server_info_scoping - sqlite...
FAILED tests/test_continuity.py::test_h3_messages_bind_neighbor_shots_and_keep_file_slots
FAILED tests/test_h3_profile.py::test_resolve_h3_references_prefers_form_files_over_story
FAILED tests/test_scene_settings.py::test_preview_prompt_uses_form_references_not_story_cast
FAILED tests/test_script_scene_count.py::test_script_regenerate_keeps_expanded_count
5 failed, 1 warning in 1.19s
+33
View File
@@ -0,0 +1,33 @@
# HEL-728 B0 版本核实与回退记录
核实日期:2026-10-21。服务器仅执行只读查询;未部署、重启服务或修改配置/数据。
| 对象 | 精确版本 / 结论 |
| --- | --- |
| `origin/main` | `1fc8bdb8bb2a88ccace6f21a507fd77bcf22f532` |
| `origin/deploy/hel-691` | `2b3bbf9`,是第五版的祖先,不是当前部署版本 |
| `origin/feat/hel-665-refract`、PR #1 head | `35fd07af24e62add1f96f6a9c38e363f8e23d778` |
| 当前测试站 | `DEPLOYED_REVISION` 标为第五版 `35fd07a`;前端 191 文件、后端 79 个 Python 文件逐项 SHA-256 全匹配该提交 |
| 静态构建 | 当前 static 与 `stage-HEL665-v5-35fd07a-20261015/build` 全目录 `diff -qr` 无差异;两份 index.html 的 SHA-256 均为 `acd3ba6e0ab29b92f5133358398f3544baa0ce2bea03560004a42e812b6abc8a` |
| PR #1 | https://gitea.xbay.cc/leefer/aigc-home/pulls/1 :open、未合并、目标 main、head=35fd07a |
| B0 底稿 | 从完整第五版另开 `feat/hel-728-night-foundation`,没有合并旧 PR 或更改 main |
第五版领先 main 22 个提交、领先 deploy/hel-691 10 个提交。HEL-692、HEL-695 的实装和后续修复均在该祖先链上:从 aaddd3d 到 35fd07a 的创作草稿/端点归属、手动播放、智能体编辑成果、阶段建议与确认隔离保留。第五版也完整包含 HEL-681/682 批量导入和 HEL-684 生成结果回流。
HEL-727 把两个 API 返回 422 作为部署版本依据的结论已由源码/发布包实证纠正。当前差异均已解释,没有以旧 deploy 分支覆盖后续工作。
本任务专用检出初始 `git status --short` 为空,无未提交/未推送工作被覆盖。服务器应用目录不是 Git 检出,以全部源码哈希替代 git status 核实,没有发现与第五版不同的产品源码。已有 `.before-*` 和发布备份保留。未触碰其他任务私有工作区。
本地保全引用:`backup/hel-728-main-1fc8bdb` 与 `backup/hel-728-v5-35fd07a`。完整证据见同目录 `server-audit.txt`、`server-backend.txt`、`backup-hashes.txt`。
## 已存在的服务器备份
目录 `/opt/aigc-home/releases`:
- `formal-v5-35fd07a.tgz`:当前第五版发布包。
- `backup-HEL665-v5-35fd07a-20261015.tgz`:部署第五版之前的静态页、前端源码、workbench 路由及版本标记。
- `rollback-HEL665-v5-35fd07a-20261015.sh`:第五版回退脚本,旧源码/静态目录均仍存在。**此脚本会回到第五版之前,不能当成本批将来的回退脚本。**
备份和脚本哈希已留档。本批尚未部署,因此当前站点无需回退。后续由总工部署 B0 时,须先重新核对当前源码/构建哈希及活动任务,并备份当前第五版,才能替换 B0 静态页、前端源码和新增的 app.html/themes.css。B0 回退应恢复这份第五版完整备份;无需数据库回退。本批无后端、数据迁移或用户配置改动。
代码回退:以 `35fd07af24e62add1f96f6a9c38e363f8e23d778` 为基线重新构建第五版,或恢复总工部署前生成的完整备份。不要只切回 `deploy/hel-691`。
+49
View File
@@ -0,0 +1,49 @@
{
"checks": [
"默认主题为夜片场",
"night 保留原编辑组件和正反向草稿",
"day 保留原编辑组件和正反向草稿",
"grade 保留原编辑组件和正反向草稿",
"bluehour 保留原编辑组件和正反向草稿",
"刷新恢复主题和草稿",
"方向键移动主题选项焦点",
"Esc 关闭并恢复入口焦点",
"创作台兼容入口保留全部参数及片段",
"隔离真实队列已收到任务",
"night 上传素材和运行中编辑状态保留",
"day 上传素材和运行中编辑状态保留",
"grade 上传素材和运行中编辑状态保留",
"bluehour 上传素材和运行中编辑状态保留",
"切主题后原任务完成且产物归属不变",
"切主题保留视频节点和暂停状态",
"切主题保留智能体任务输入组件和草稿",
"切主题保留未保存设置",
"1440,900 night 导航入口和四主题完整可见",
"1440,900 day 导航入口和四主题完整可见",
"1440,900 grade 导航入口和四主题完整可见",
"1440,900 bluehour 导航入口和四主题完整可见",
"1366,768 night 导航入口和四主题完整可见",
"1366,768 day 导航入口和四主题完整可见",
"1366,768 grade 导航入口和四主题完整可见",
"1366,768 bluehour 导航入口和四主题完整可见",
"1280,720 night 导航入口和四主题完整可见",
"1280,720 day 导航入口和四主题完整可见",
"1280,720 grade 导航入口和四主题完整可见",
"1280,720 bluehour 导航入口和四主题完整可见",
"模型连接入口保留页签参数",
"旧项目链接保留阶段参数",
"全局搜索保留 视频创作",
"全局搜索保留 大语言模型",
"全局搜索保留 试验台",
"全局搜索保留 三维搭景",
"night 首次绘制前恢复主题及底色",
"day 首次绘制前恢复主题及底色",
"grade 首次绘制前恢复主题及底色",
"bluehour 首次绘制前恢复主题及底色",
"存储被禁用仍可切换且显示提示",
"存储被禁用保留输入",
"无效存储回退夜片场",
"浏览器无运行错误"
],
"errors": []
}
+264
View File
@@ -0,0 +1,264 @@
> calliope-web@1.5.8 build
> vite build
vite v6.4.3 building SSR bundle for production...
transforming...
8:10:50 PM [vite-plugin-svelte] src/lib/components/workbench/ShotStagePanel.svelte:156:5 `<video>` elements must have a `<track kind="captions">`
https://svelte.dev/e/a11y_media_has_caption
154: {#if shot.description}<p class="vshot-desc">{shot.description}</p>{/if}
155: {#if shot.job?.status === 'done' && shot.job.output}
156: <video
^
157: class="vshot-video"
158: src={assetUrl(shot.job.output) ?? undefined}
"handleConnectionChange" and "handleAttributionWarning" are imported from external module "@xyflow/system" but never used in "node_modules/@xyflow/svelte/dist/lib/hooks/useNodeConnections.svelte.js", "node_modules/@xyflow/svelte/dist/lib/components/Handle/Handle.svelte" and "node_modules/@xyflow/svelte/dist/lib/components/Attribution/Attribution.svelte".
✓ 533 modules transformed.
rendering chunks...
vite v6.4.3 building for production...
transforming...
8:10:53 PM [vite-plugin-svelte] src/lib/components/workbench/ShotStagePanel.svelte:156:5 `<video>` elements must have a `<track kind="captions">`
https://svelte.dev/e/a11y_media_has_caption
154: {#if shot.description}<p class="vshot-desc">{shot.description}</p>{/if}
155: {#if shot.job?.status === 'done' && shot.job.output}
156: <video
^
157: class="vshot-video"
158: src={assetUrl(shot.job.output) ?? undefined}
✓ 778 modules transformed.
rendering chunks...
computing gzip size...
.svelte-kit/output/client/_app/version.json 0.03 kB │ gzip: 0.05 kB
.svelte-kit/output/client/.vite/manifest.json 28.72 kB │ gzip: 3.35 kB
.svelte-kit/output/client/_app/immutable/assets/reduceMotion.BrXLPzfy.css 0.08 kB │ gzip: 0.10 kB
.svelte-kit/output/client/_app/immutable/assets/14.IyyrLGyK.css 0.22 kB │ gzip: 0.15 kB
.svelte-kit/output/client/_app/immutable/assets/6.C62bpzl4.css 0.22 kB │ gzip: 0.15 kB
.svelte-kit/output/client/_app/immutable/assets/Skeleton.BQeWIsLh.css 0.39 kB │ gzip: 0.22 kB
.svelte-kit/output/client/_app/immutable/assets/SafeMedia.Cf_dgjFv.css 0.51 kB │ gzip: 0.31 kB
.svelte-kit/output/client/_app/immutable/assets/EmptyState.BXjpyO2u.css 0.58 kB │ gzip: 0.31 kB
.svelte-kit/output/client/_app/immutable/assets/Spinner.us8fwMZC.css 0.73 kB │ gzip: 0.37 kB
.svelte-kit/output/client/_app/immutable/assets/ConfirmDialog.DOfiQL0c.css 0.91 kB │ gzip: 0.38 kB
.svelte-kit/output/client/_app/immutable/assets/ProgressBar.DG8dH-U_.css 1.44 kB │ gzip: 0.58 kB
.svelte-kit/output/client/_app/immutable/assets/22.ChQ8T8dK.css 1.50 kB │ gzip: 0.56 kB
.svelte-kit/output/client/_app/immutable/assets/20.D7w-Sx1Y.css 1.66 kB │ gzip: 0.55 kB
.svelte-kit/output/client/_app/immutable/assets/AgentPlanPanel.BMGRQZdH.css 2.24 kB │ gzip: 0.74 kB
.svelte-kit/output/client/_app/immutable/assets/ImageLightbox.DN4QbnbA.css 2.55 kB │ gzip: 0.76 kB
.svelte-kit/output/client/_app/immutable/assets/types.DUMMEwmy.css 4.86 kB │ gzip: 1.13 kB
.svelte-kit/output/client/_app/immutable/assets/17.CdHWgDYY.css 4.90 kB │ gzip: 1.33 kB
.svelte-kit/output/client/_app/immutable/assets/AgentComposer.BhJ_F-aX.css 4.90 kB │ gzip: 1.31 kB
.svelte-kit/output/client/_app/immutable/assets/18.D3s-Wa9z.css 5.02 kB │ gzip: 1.31 kB
.svelte-kit/output/client/_app/immutable/assets/WorkflowBay.CrZOCTCD.css 5.20 kB │ gzip: 1.40 kB
.svelte-kit/output/client/_app/immutable/assets/AProjectCard.BhK9fW6H.css 5.39 kB │ gzip: 1.47 kB
.svelte-kit/output/client/_app/immutable/assets/11.CpN-OyyL.css 5.63 kB │ gzip: 1.45 kB
.svelte-kit/output/client/_app/immutable/assets/8.BK-8H9u5.css 9.05 kB │ gzip: 2.13 kB
.svelte-kit/output/client/_app/immutable/assets/3.CxKDlY5x.css 9.73 kB │ gzip: 2.04 kB
.svelte-kit/output/client/_app/immutable/assets/AgentChat.C5wVAvmg.css 11.44 kB │ gzip: 2.53 kB
.svelte-kit/output/client/_app/immutable/assets/13.DdkN80tf.css 11.76 kB │ gzip: 2.55 kB
.svelte-kit/output/client/_app/immutable/assets/AppShell.Bk5S4WfJ.css 14.10 kB │ gzip: 3.27 kB
.svelte-kit/output/client/_app/immutable/assets/5.DoWQHLuE.css 15.17 kB │ gzip: 3.13 kB
.svelte-kit/output/client/_app/immutable/assets/0.C2N0LW81.css 19.69 kB │ gzip: 3.99 kB
.svelte-kit/output/client/_app/immutable/assets/WorkflowLibrary.CN-_i5Sc.css 27.67 kB │ gzip: 4.81 kB
.svelte-kit/output/client/_app/immutable/assets/4.BQ063dE_.css 29.80 kB │ gzip: 5.74 kB
.svelte-kit/output/client/_app/immutable/assets/7.B1E9ZlPG.css 29.94 kB │ gzip: 5.07 kB
.svelte-kit/output/client/_app/immutable/assets/21.BY02oOwl.css 30.01 kB │ gzip: 4.94 kB
.svelte-kit/output/client/_app/immutable/assets/StudioDesk.DiLnYEIx.css 31.15 kB │ gzip: 6.17 kB
.svelte-kit/output/client/_app/immutable/nodes/5.hx3kxUVa.js 0.09 kB │ gzip: 0.10 kB
.svelte-kit/output/client/_app/immutable/chunks/Bzak7iHL.js 0.10 kB │ gzip: 0.10 kB
.svelte-kit/output/client/_app/immutable/chunks/IKQcdn9t.js 0.12 kB │ gzip: 0.12 kB
.svelte-kit/output/client/_app/immutable/entry/start.BUwQ6lIJ.js 0.12 kB │ gzip: 0.11 kB
.svelte-kit/output/client/_app/immutable/chunks/B0XwC4Ot.js 0.15 kB │ gzip: 0.14 kB
.svelte-kit/output/client/_app/immutable/chunks/BsCRBkET.js 0.18 kB │ gzip: 0.16 kB
.svelte-kit/output/client/_app/immutable/chunks/Sbfne8nC.js 0.19 kB │ gzip: 0.17 kB
.svelte-kit/output/client/_app/immutable/chunks/FwfFJvU_.js 0.19 kB │ gzip: 0.16 kB
.svelte-kit/output/client/_app/immutable/chunks/DEfCzHLx.js 0.23 kB │ gzip: 0.18 kB
.svelte-kit/output/client/_app/immutable/chunks/Ba1assjq.js 0.26 kB │ gzip: 0.22 kB
.svelte-kit/output/client/_app/immutable/nodes/16.sWMaj3Bi.js 0.26 kB │ gzip: 0.23 kB
.svelte-kit/output/client/_app/immutable/chunks/BXFzAH0P.js 0.28 kB │ gzip: 0.18 kB
.svelte-kit/output/client/_app/immutable/nodes/15.DYsaZg84.js 0.29 kB │ gzip: 0.25 kB
.svelte-kit/output/client/_app/immutable/chunks/CK7WZZfM.js 0.31 kB │ gzip: 0.18 kB
.svelte-kit/output/client/_app/immutable/nodes/12.CF9Zu6pN.js 0.33 kB │ gzip: 0.27 kB
.svelte-kit/output/client/_app/immutable/nodes/9.BVfZlOMu.js 0.34 kB │ gzip: 0.28 kB
.svelte-kit/output/client/_app/immutable/nodes/2.BDtoJguU.js 0.37 kB │ gzip: 0.25 kB
.svelte-kit/output/client/_app/immutable/chunks/BWaKaORA.js 0.37 kB │ gzip: 0.26 kB
.svelte-kit/output/client/_app/immutable/nodes/1.Bm5c1i2N.js 0.40 kB │ gzip: 0.28 kB
.svelte-kit/output/client/_app/immutable/nodes/10.DPw0t24L.js 0.41 kB │ gzip: 0.26 kB
.svelte-kit/output/client/_app/immutable/nodes/19.EN81mzhv.js 0.41 kB │ gzip: 0.26 kB
.svelte-kit/output/client/_app/immutable/chunks/6n2EhuN1.js 0.46 kB │ gzip: 0.30 kB
.svelte-kit/output/client/_app/immutable/chunks/DNKIhv1n.js 0.47 kB │ gzip: 0.34 kB
.svelte-kit/output/client/_app/immutable/chunks/BpBwGJjl.js 0.49 kB │ gzip: 0.34 kB
.svelte-kit/output/client/_app/immutable/chunks/-UyI9lYi.js 0.51 kB │ gzip: 0.30 kB
.svelte-kit/output/client/_app/immutable/chunks/D41pZ3kD.js 0.56 kB │ gzip: 0.32 kB
.svelte-kit/output/client/_app/immutable/chunks/5hKhZLI5.js 0.62 kB │ gzip: 0.36 kB
.svelte-kit/output/client/_app/immutable/chunks/BXakyGcx.js 0.71 kB │ gzip: 0.43 kB
.svelte-kit/output/client/_app/immutable/chunks/8OLyFVGd.js 0.75 kB │ gzip: 0.42 kB
.svelte-kit/output/client/_app/immutable/chunks/CN9eJxB2.js 0.76 kB │ gzip: 0.42 kB
.svelte-kit/output/client/_app/immutable/chunks/CNhvvmZJ.js 0.83 kB │ gzip: 0.42 kB
.svelte-kit/output/client/_app/immutable/chunks/DCtQckrV.js 1.09 kB │ gzip: 0.62 kB
.svelte-kit/output/client/_app/immutable/nodes/14.CvNq-suX.js 1.09 kB │ gzip: 0.66 kB
.svelte-kit/output/client/_app/immutable/nodes/6.BpRKot_7.js 1.20 kB │ gzip: 0.71 kB
.svelte-kit/output/client/_app/immutable/chunks/Dp1pzeXC.js 1.27 kB │ gzip: 0.73 kB
.svelte-kit/output/client/_app/immutable/chunks/wbPk3Yxo.js 1.37 kB │ gzip: 0.66 kB
.svelte-kit/output/client/_app/immutable/chunks/D3mdsp0g.js 1.43 kB │ gzip: 0.66 kB
.svelte-kit/output/client/_app/immutable/chunks/PEmPcvxY.js 1.49 kB │ gzip: 0.78 kB
.svelte-kit/output/client/_app/immutable/chunks/whH4D4QZ.js 1.49 kB │ gzip: 0.75 kB
.svelte-kit/output/client/_app/immutable/chunks/LuZC9yWf.js 1.99 kB │ gzip: 1.05 kB
.svelte-kit/output/client/_app/immutable/chunks/t712vir_.js 2.24 kB │ gzip: 0.99 kB
.svelte-kit/output/client/_app/immutable/nodes/20.COu3iy7d.js 2.37 kB │ gzip: 1.38 kB
.svelte-kit/output/client/_app/immutable/chunks/C5fjXKkg.js 2.47 kB │ gzip: 1.12 kB
.svelte-kit/output/client/_app/immutable/chunks/BPNbeFLK.js 2.77 kB │ gzip: 1.28 kB
.svelte-kit/output/client/_app/immutable/nodes/22.C5Nwx_zE.js 2.92 kB │ gzip: 1.61 kB
.svelte-kit/output/client/_app/immutable/chunks/CnYh2b86.js 2.95 kB │ gzip: 1.24 kB
.svelte-kit/output/client/_app/immutable/chunks/Bie-hupT.js 3.08 kB │ gzip: 1.57 kB
.svelte-kit/output/client/_app/immutable/chunks/DK7AMg8o.js 3.23 kB │ gzip: 1.55 kB
.svelte-kit/output/client/_app/immutable/chunks/DDlx89yg.js 4.27 kB │ gzip: 1.51 kB
.svelte-kit/output/client/_app/immutable/chunks/C1VtnVx0.js 6.86 kB │ gzip: 3.09 kB
.svelte-kit/output/client/_app/immutable/chunks/BFgrkAWo.js 8.09 kB │ gzip: 3.27 kB
.svelte-kit/output/client/_app/immutable/chunks/PWQRmEWD.js 10.09 kB │ gzip: 2.31 kB
.svelte-kit/output/client/_app/immutable/nodes/18.Dqn9lNj5.js 10.20 kB │ gzip: 3.92 kB
.svelte-kit/output/client/_app/immutable/chunks/CELneDUs.js 10.41 kB │ gzip: 4.44 kB
.svelte-kit/output/client/_app/immutable/nodes/0.B9UHNpdV.js 11.98 kB │ gzip: 4.40 kB
.svelte-kit/output/client/_app/immutable/entry/app.BgS0U9Aq.js 12.58 kB │ gzip: 4.11 kB
.svelte-kit/output/client/_app/immutable/chunks/StFt4UCs.js 14.41 kB │ gzip: 5.54 kB
.svelte-kit/output/client/_app/immutable/nodes/11.CYMf0Aop.js 17.10 kB │ gzip: 6.44 kB
.svelte-kit/output/client/_app/immutable/chunks/CHDY03l9.js 17.32 kB │ gzip: 6.96 kB
.svelte-kit/output/client/_app/immutable/chunks/CI5J-U9v.js 17.44 kB │ gzip: 6.33 kB
.svelte-kit/output/client/_app/immutable/nodes/3.lt50pTEv.js 19.56 kB │ gzip: 6.87 kB
.svelte-kit/output/client/_app/immutable/chunks/Cj6gCTdL.js 20.25 kB │ gzip: 7.21 kB
.svelte-kit/output/client/_app/immutable/nodes/8.c25p5xbs.js 21.27 kB │ gzip: 7.28 kB
.svelte-kit/output/client/_app/immutable/nodes/17.C8PkQczY.js 23.50 kB │ gzip: 7.73 kB
.svelte-kit/output/client/_app/immutable/chunks/Dmrd7xQR.js 23.98 kB │ gzip: 9.32 kB
.svelte-kit/output/client/_app/immutable/nodes/13.CGct_Wju.js 24.87 kB │ gzip: 8.36 kB
.svelte-kit/output/client/_app/immutable/chunks/CKnAzW_q.js 36.67 kB │ gzip: 14.42 kB
.svelte-kit/output/client/_app/immutable/chunks/BwCNePTm.js 41.30 kB │ gzip: 15.68 kB
.svelte-kit/output/client/_app/immutable/chunks/BR687OLR.js 49.24 kB │ gzip: 17.84 kB
.svelte-kit/output/client/_app/immutable/nodes/4.C6iXVhZu.js 50.59 kB │ gzip: 17.70 kB
.svelte-kit/output/client/_app/immutable/nodes/21.Cz9clG7U.js 54.65 kB │ gzip: 15.14 kB
.svelte-kit/output/client/_app/immutable/chunks/Bmp6xnce.js 71.50 kB │ gzip: 26.91 kB
.svelte-kit/output/client/_app/immutable/chunks/C5t8Nts6.js 87.12 kB │ gzip: 27.47 kB
.svelte-kit/output/client/_app/immutable/chunks/Bvurq_58.js 92.46 kB │ gzip: 32.40 kB
.svelte-kit/output/client/_app/immutable/nodes/7.D95zSIUA.js 211.51 kB │ gzip: 68.00 kB
.svelte-kit/output/client/_app/immutable/chunks/BQUFPnBP.js 684.72 kB │ gzip: 180.05 kB
.svelte-kit/output/client/_app/immutable/chunks/vMwlJ6hm.js 774.61 kB │ gzip: 226.22 kB
(!) Some chunks are larger than 500 kB after minification. Consider:
- Using dynamic import() to code-split the application
- Use build.rollupOptions.output.manualChunks to improve chunking: https://rollupjs.org/configuration-options/#output-manualchunks
- Adjust chunk size limit for this warning via build.chunkSizeWarningLimit.
✓ built in 3.57s
.svelte-kit/output/server/.vite/manifest.json 25.84 kB
.svelte-kit/output/server/_app/immutable/assets/Icon.BrXLPzfy.css 0.08 kB
.svelte-kit/output/server/_app/immutable/assets/_page.C62bpzl4.css 0.22 kB
.svelte-kit/output/server/_app/immutable/assets/_page.IyyrLGyK.css 0.22 kB
.svelte-kit/output/server/_app/immutable/assets/Skeleton.BQeWIsLh.css 0.39 kB
.svelte-kit/output/server/_app/immutable/assets/SafeMedia.Cf_dgjFv.css 0.51 kB
.svelte-kit/output/server/_app/immutable/assets/EmptyState.BXjpyO2u.css 0.58 kB
.svelte-kit/output/server/_app/immutable/assets/Spinner.us8fwMZC.css 0.73 kB
.svelte-kit/output/server/_app/immutable/assets/ConfirmDialog.C_pBcjz1.css 0.89 kB
.svelte-kit/output/server/_app/immutable/assets/ProgressBar.DG8dH-U_.css 1.44 kB
.svelte-kit/output/server/_app/immutable/assets/_page.ChQ8T8dK.css 1.50 kB
.svelte-kit/output/server/_app/immutable/assets/_page.D7w-Sx1Y.css 1.66 kB
.svelte-kit/output/server/_app/immutable/assets/AgentPlanPanel.BMGRQZdH.css 2.24 kB
.svelte-kit/output/server/_app/immutable/assets/ImageLightbox.DhBJATfN.css 2.53 kB
.svelte-kit/output/server/_app/immutable/assets/_page.Bn3DGoQS.css 3.55 kB
.svelte-kit/output/server/_app/immutable/assets/AgentComposer.JZyE_n9K.css 4.85 kB
.svelte-kit/output/server/_app/immutable/assets/RefImageGrid.DUMMEwmy.css 4.86 kB
.svelte-kit/output/server/_app/immutable/assets/_page.D3s-Wa9z.css 5.02 kB
.svelte-kit/output/server/_app/immutable/assets/WorkflowBay.CrZOCTCD.css 5.20 kB
.svelte-kit/output/server/_app/immutable/assets/AProjectCard.BHJv_kAx.css 5.30 kB
.svelte-kit/output/server/_app/immutable/assets/_page.CpN-OyyL.css 5.63 kB
.svelte-kit/output/server/_app/immutable/assets/_page.u1ByS4kk.css 9.02 kB
.svelte-kit/output/server/_app/immutable/assets/_page.k-tm080m.css 9.35 kB
.svelte-kit/output/server/_app/immutable/assets/_page.CxKDlY5x.css 9.73 kB
.svelte-kit/output/server/_app/immutable/assets/AgentChat.DGaLhJTa.css 11.41 kB
.svelte-kit/output/server/_app/immutable/assets/_page.CHFzkBke.css 11.66 kB
.svelte-kit/output/server/_app/immutable/assets/AppShell.q20u3EwN.css 14.02 kB
.svelte-kit/output/server/_app/immutable/assets/_layout.MRWQDt5E.css 19.46 kB
.svelte-kit/output/server/_app/immutable/assets/WorkflowLibrary.QeU6Pxlx.css 27.65 kB
.svelte-kit/output/server/_app/immutable/assets/_page.BPi-Y6Y8.css 29.59 kB
.svelte-kit/output/server/_app/immutable/assets/_page.UfnkmHg7.css 29.89 kB
.svelte-kit/output/server/_app/immutable/assets/_page.BY02oOwl.css 30.01 kB
.svelte-kit/output/server/_app/immutable/assets/StudioDesk.y8URPnQJ.css 31.03 kB
.svelte-kit/output/server/entries/pages/build-scene/_page.ts.js 0.04 kB
.svelte-kit/output/server/entries/pages/canvas/_page.ts.js 0.04 kB
.svelte-kit/output/server/entries/pages/canvas/_id_/_page.ts.js 0.04 kB
.svelte-kit/output/server/entries/pages/playground/_page.ts.js 0.04 kB
.svelte-kit/output/server/entries/pages/workflows/_page.ts.js 0.04 kB
.svelte-kit/output/server/entries/pages/_layout.ts.js 0.06 kB
.svelte-kit/output/server/entries/pages/agents/_page.ts.js 0.08 kB
.svelte-kit/output/server/entries/pages/library/_page.ts.js 0.08 kB
.svelte-kit/output/server/entries/pages/models/_page.ts.js 0.08 kB
.svelte-kit/output/server/entries/pages/settings/_page.ts.js 0.08 kB
.svelte-kit/output/server/entries/pages/workbench/_page.ts.js 0.08 kB
.svelte-kit/output/server/entries/pages/workbench/_id_/_page.ts.js 0.08 kB
.svelte-kit/output/server/entries/pages/_page.ts.js 0.12 kB
.svelte-kit/output/server/entries/hooks.server.js 0.13 kB
.svelte-kit/output/server/entries/pages/projects/_page.ts.js 0.21 kB
.svelte-kit/output/server/chunks/index-server.js 0.22 kB
.svelte-kit/output/server/env.js 0.23 kB
.svelte-kit/output/server/entries/pages/project/_id_/_page.ts.js 0.26 kB
.svelte-kit/output/server/entries/pages/create/_page.ts.js 0.30 kB
.svelte-kit/output/server/chunks/client.js 0.31 kB
.svelte-kit/output/server/entries/pages/model-connections/_page.ts.js 0.31 kB
.svelte-kit/output/server/internal.js 0.35 kB
.svelte-kit/output/server/entries/fallbacks/error.svelte.js 0.35 kB
.svelte-kit/output/server/chunks/state.svelte.js 0.45 kB
.svelte-kit/output/server/chunks/Skeleton.js 0.48 kB
.svelte-kit/output/server/chunks/Spinner.js 0.52 kB
.svelte-kit/output/server/entries/pages/canvas/_id_/_layout.svelte.js 0.55 kB
.svelte-kit/output/server/entries/pages/image/_page.svelte.js 0.55 kB
.svelte-kit/output/server/entries/pages/video/_page.svelte.js 0.55 kB
.svelte-kit/output/server/chunks/index2.js 0.57 kB
.svelte-kit/output/server/chunks/server.js 0.62 kB
.svelte-kit/output/server/entries/pages/canvas/_page.svelte.js 0.66 kB
.svelte-kit/output/server/entries/pages/playground/_page.svelte.js 0.67 kB
.svelte-kit/output/server/chunks/utils2.js 0.72 kB
.svelte-kit/output/server/chunks/ids.js 0.77 kB
.svelte-kit/output/server/chunks/Icon.svelte_svelte_type_style_lang.js 0.86 kB
.svelte-kit/output/server/chunks/endpointKind.js 0.95 kB
.svelte-kit/output/server/chunks/createMutation.js 1.02 kB
.svelte-kit/output/server/chunks/EmptyState.js 1.10 kB
.svelte-kit/output/server/chunks/toast.js 1.14 kB
.svelte-kit/output/server/chunks/SafeMedia.js 1.65 kB
.svelte-kit/output/server/chunks/ProgressBar.js 1.93 kB
.svelte-kit/output/server/chunks/useUpload.svelte.js 2.15 kB
.svelte-kit/output/server/chunks/context.js 2.27 kB
.svelte-kit/output/server/chunks/clone.js 2.33 kB
.svelte-kit/output/server/chunks/takeDelete.svelte.js 2.56 kB
.svelte-kit/output/server/chunks/index3.js 2.77 kB
.svelte-kit/output/server/entries/pages/workbench/_page.svelte.js 2.82 kB
.svelte-kit/output/server/entries/pages/_layout.svelte.js 3.08 kB
.svelte-kit/output/server/chunks/ConfirmDialog.js 3.25 kB
.svelte-kit/output/server/entries/pages/workflows/_page.svelte.js 4.10 kB
.svelte-kit/output/server/chunks/internal.js 4.19 kB
.svelte-kit/output/server/chunks/exports.js 5.54 kB
.svelte-kit/output/server/chunks/Icon.js 6.16 kB
.svelte-kit/output/server/entries/pages/agents/_page.svelte.js 8.49 kB
.svelte-kit/output/server/chunks/AProjectCard.js 10.17 kB
.svelte-kit/output/server/entries/pages/tasks/_page.svelte.js 13.41 kB
.svelte-kit/output/server/entries/pages/settings/_page.svelte.js 15.31 kB
.svelte-kit/output/server/entries/pages/_page.svelte.js 18.29 kB
.svelte-kit/output/server/entries/pages/library/_page.svelte.js 20.35 kB
.svelte-kit/output/server/chunks/AppShell.svelte_svelte_type_style_lang.js 20.49 kB
.svelte-kit/output/server/chunks/WorkflowBay.js 20.80 kB
.svelte-kit/output/server/chunks/RefImageGrid.js 26.16 kB
.svelte-kit/output/server/chunks/root.js 29.42 kB
.svelte-kit/output/server/chunks/AgentComposer.js 31.59 kB
.svelte-kit/output/server/chunks/utils.js 34.14 kB
.svelte-kit/output/server/entries/pages/models/_page.svelte.js 36.45 kB
.svelte-kit/output/server/remote-entry.js 36.81 kB
.svelte-kit/output/server/chunks/AppShell.js 39.08 kB
.svelte-kit/output/server/entries/pages/build-scene/_page.svelte.js 51.56 kB
.svelte-kit/output/server/entries/pages/workbench/_id_/_page.svelte.js 52.95 kB
.svelte-kit/output/server/chunks/index.js 101.49 kB
.svelte-kit/output/server/chunks/StudioDesk.js 113.69 kB
.svelte-kit/output/server/index.js 130.14 kB
.svelte-kit/output/server/entries/pages/canvas/_id_/_page.svelte.js 144.05 kB
.svelte-kit/output/server/entries/pages/connections/_page.svelte.js 150.23 kB
.svelte-kit/output/server/chunks/i18n.svelte.js 846.64 kB
✓ built in 6.69s
Run npm run preview to preview your production build locally.
> Using @sveltejs/adapter-static
Wrote site to "build"
✔ done
+2
View File
@@ -0,0 +1,2 @@
Checking formatting...
All matched files use Prettier code style!
+21
View File
@@ -0,0 +1,21 @@
> calliope-web@1.5.8 check
> svelte-kit sync && svelte-check --tsconfig ./tsconfig.json
Loading svelte-check in workspace: /home/xiaomo/multica_workspaces/hello-af23464d222e/hel-728-a07b128af8f7/workdir/aigc-home/calliope-web
Getting Svelte diagnostics...
/home/xiaomo/multica_workspaces/hello-af23464d222e/hel-728-a07b128af8f7/workdir/aigc-home/calliope-web/src/lib/components/workbench/ShotStagePanel.svelte:156:6
Warn: `<video>` elements must have a `<track kind="captions">`
https://svelte.dev/e/a11y_media_has_caption (svelte)
{#if shot.job?.status === 'done' && shot.job.output}
<video
class="vshot-video"
src={assetUrl(shot.job.output) ?? undefined}
controls
preload="metadata"
></video>
{/if}
====================================
svelte-check found 0 errors and 1 warning in 1 file
+15
View File
@@ -0,0 +1,15 @@
Oops! Something went wrong! :(
ESLint: 9.39.5
ESLint couldn't find an eslint.config.(js|mjs|cjs) file.
From ESLint v9.0.0, the default configuration file is now eslint.config.js.
If you are using a .eslintrc.* file, please follow the migration guide
to update your configuration file to the new format:
https://eslint.org/docs/latest/use/configure/migration-guide
If you still have problems after following the migration guide, please stop by
https://eslint.org/chat/help to chat with the team.
+51
View File
@@ -0,0 +1,51 @@
> calliope-web@1.5.8 test
> node scripts/check-storage-ssr.mjs && node scripts/check-random-uuid.mjs && node scripts/check-skill-frontmatter.mjs && node scripts/check-export-frame.mjs
ok method-less localStorage (Node SSR shape) degrades to null without throwing
ok absent localStorage degrades to null
ok throwing localStorage access degrades to null
ok setItem failure is swallowed
ok working storage round-trips and removes
ok no unsafe `typeof localStorage` guards in src/
storage SSR checks passed.
ok insecure-context crypto (getRandomValues only) yields unique v4 UUIDs
ok method-less crypto degrades to unique best-effort ids without throwing
ok absent crypto degrades to unique best-effort ids without throwing
ok native randomUUID preferred when available
ok no direct `crypto.randomUUID()` calls in src/
randomId insecure-context checks passed.
ok edit load strips the frontmatter block
ok edit load keeps the body
ok strip boundary is exact
ok strip is idempotent
ok second edit-save-reload adds no layer
ok plain body unchanged
ok page imports the shared strip helper
ok no \A-anchored strip regex left behind
skill frontmatter checks passed.
ok first try is ~1 frame before the end, never the end itself
ok later tries step strictly further back
ok tries stay within ~1s of the end
ok short clip clamps to 0 without duplicates
ok unknown / zero duration falls back to t=0
ok base name from /api/file?path=…
ok base name from a Windows path
ok base name from a plain URL
ok unsafe characters are replaced
ok blob URL without a name falls back to 'video'
ok PNG name names the video, the tag and the frame time
ok spaces in the tag become dashes
ok fully transparent sample → empty
ok pure black → black
ok near-black encoder noise (16..18) → black
ok mid-grey picture → not blank
ok dim but visible picture (≈50) → not blank
ok dark night scene with a bright highlight → not blank
ok 404 → not-found
ok 403 → forbidden
ok 500 → network
ok 206 / 200 are not errors
export-frame checks passed.
+13
View File
@@ -0,0 +1,13 @@
# HEL-728 B0 实现说明
以 HEL-716/726 确认附件和 HEL-727 模块边界为依据,只建设主题、导航与兼容基础;完整业务页面重排和动效分别留在 B1–B5。
- `calliope-web/static/themes.css` 是四套语义颜色、字体、尺寸、圆角和特效 token 的唯一产品定义来源。取值直接来自 HEL-726 HTML;现有组件变量在此映射到语义变量,没有另设配色。作品控制、灯箱的固定深色保留。`routes/app.css` 保留公共组件与动效规则,已有金色/紫色按钮和光栅改为消费 token。
- `src/app.html` 在绘制前验证 `aigc-theme`,失败回退夜片场;同步加载阻塞渲染的 themes.css,框架代码尚未执行时也有正确底色。`lib/theme.ts` 只改根元素属性,不改路由、不创建 QueryClient、不重建编辑器。写存储失败时继续本次切换,并明确提示;跨标签页存储事件同步主题。
- `ThemeChoices.svelte` 在侧栏统一 Modal 和设置外观区复用,含四主题实时 token 预览、中文名/用途、方向键/Enter 操作。Esc/焦点返回复用既有 Modal。侧栏与主题选择适配三档桌面;减少动效沿用现有全局机制。
- `shell/nav.ts` 为外壳和全局搜索提供单一导航定义。工作室/项目/创作台/智能体/画布/素材库,以及任务/工作流/模型连接按确认顺序组织。模型页与 ComfyUI 页面保留可见切换链接,原视频、试验台、三维搭景仍可从全局搜索到达。
- `/create?kind=video`、`/model-connections?tab=llm` 是 B0 兼容入口,分别转到已有视频/LLM 页面;默认转到图片/ComfyUI 页面。后续页面未实现前不转向空页。原 `/image`、`/video`、`/connections`、`/models`、`/playground`、`/build-scene` 保持现有功能。
- 修复既有 `/projects`、`/project/[id]` 的跳转参数丢失;新入口和旧项目入口保留查询及片段,项目来源、工作流、模式、提示词等不改写。设置的旧页签转向也保留原参数。
- `design/nocturne` 保存已确认的 v1/v2 规范与 HTML,仅清除行尾空白;均为设计演示,未作为产品运行代码或假生成结果使用。
B0 没有新增依赖、生成接口、主题测试副本、数据库变更、密钥或线上配置变更。已有页面局部硬编码视觉的全站替换与最终特效验收仍属于 B5,本批没有宣称完成全站重设计。
+48
View File
@@ -0,0 +1,48 @@
{"image_wf": 71, "video_wf": 72, "endpoint": "1c758e36-793b-4c11-8e08-c139bba68b02"}
{'dual': 3, 'h3': 4, 'project': 6}
RUN e2e/reference/check.cjs
PASS 真实工作流显示正反向左右分栏
PASS 工作流列表可应对超过 48 项
PASS 列表搜索过滤真实元数据
PASS 置顶不切换工作流、不清空草稿
PASS 参数取消不改当前值
PASS 参数保存应用到当前工作流
PASS 真实生成请求提交且取得结果
PASS 正反向与种子写入任务
locator.click: Timeout 12000ms exceeded.
Call log:
 - waiting for locator('.result-item').first()
at /home/xiaomo/multica_workspaces/hello-af23464d222e/hel-728-a07b128af8f7/workdir/aigc-home/e2e/reference/check.cjs:146:45 {
log: [ " - waiting for locator('.result-item').first()" ],
name: 'TimeoutError'
}
RUN e2e/reference/check-copy-fallback.cjs
locator.waitFor: Timeout 15000ms exceeded.
Call log:
 - waiting for getByRole('button', { name: /编辑内容并带回项目/ }) to be visible
at /home/xiaomo/multica_workspaces/hello-af23464d222e/hel-728-a07b128af8f7/workdir/aigc-home/e2e/reference/check-copy-fallback.cjs:44:54 {
log: [
" - waiting for getByRole('button', { name: /编辑内容并带回项目/ }) to be visible"
],
name: 'TimeoutError'
}
RUN e2e/hel728.cjs
PASS 默认主题为夜片场
PASS night 保留原编辑组件和正反向草稿
PASS day 保留原编辑组件和正反向草稿
PASS grade 保留原编辑组件和正反向草稿
PASS bluehour 保留原编辑组件和正反向草稿
PASS 刷新恢复主题和草稿
PASS 方向键移动主题选项焦点
PASS Esc 关闭并恢复入口焦点
locator.waitFor: Timeout 30000ms exceeded.
Call log:
 - waiting for locator('#desk-prompt') to be visible
at /home/xiaomo/multica_workspaces/hello-af23464d222e/hel-728-a07b128af8f7/workdir/aigc-home/e2e/hel728.cjs:23:74 {
log: [ " - waiting for locator('#desk-prompt') to be visible" ],
name: 'TimeoutError'
}
FAILED SCRIPTS ['e2e/reference/check.cjs', 'e2e/reference/check-copy-fallback.cjs', 'e2e/hel728.cjs']
+69
View File
@@ -0,0 +1,69 @@
> calliope-web@1.5.8 lint
> prettier --check . && eslint .
Checking formatting...
[warn] package-lock.json
[warn] package.json
[warn] scripts/check-storage-ssr.mjs
[warn] src/hooks.client.ts
[warn] src/hooks.server.ts
[warn] src/lib/actions/spotlight.ts
[warn] src/lib/agentComposer.ts
[warn] src/lib/agentTasks.ts
[warn] src/lib/assetPicker.ts
[warn] src/lib/comfy/classifyInput.ts
[warn] src/lib/comfy/parser.ts
[warn] src/lib/comfy/promptInput.ts
[warn] src/lib/comfy/resolution.ts
[warn] src/lib/comfy/types.ts
[warn] src/lib/comfy/useUpload.svelte.ts
[warn] src/lib/components/agent/agentPalette.ts
[warn] src/lib/components/generate/context.ts
[warn] src/lib/components/generate/schema.ts
[warn] src/lib/components/generate/takeDelete.svelte.ts
[warn] src/lib/components/generate/takes.ts
[warn] src/lib/components/ui/icons.ts
[warn] src/lib/components/ui/modalLayers.ts
[warn] src/lib/components/ui/README.md
[warn] src/lib/dbtime.ts
[warn] src/lib/durationBudget.ts
[warn] src/lib/endpointKind.ts
[warn] src/lib/events.ts
[warn] src/lib/formOptions.ts
[warn] src/lib/i18n.svelte.ts
[warn] src/lib/i18n/de.ts
[warn] src/lib/i18n/en.ts
[warn] src/lib/i18n/es.ts
[warn] src/lib/i18n/fr.ts
[warn] src/lib/i18n/ja.ts
[warn] src/lib/i18n/ko.ts
[warn] src/lib/i18n/zh.ts
[warn] src/lib/jobProgress.ts
[warn] src/lib/jobStatus.ts
[warn] src/lib/missingOutputs.svelte.ts
[warn] src/lib/promptTemplates.ts
[warn] src/lib/reduceMotion.svelte.ts
[warn] src/lib/referenceAgent.ts
[warn] src/lib/shot/api.ts
[warn] src/lib/shot/calibration/compositionPresets.ts
[warn] src/lib/shot/calibration/shotSolver.ts
[warn] src/lib/shot/helpers/cameraUtils.ts
[warn] src/lib/shot/helpers/jointConfig.ts
[warn] src/lib/shot/helpers/mannequinFactory.ts
[warn] src/lib/shot/helpers/mirror.ts
[warn] src/lib/shot/helpers/posture.ts
[warn] src/lib/shot/helpers/primitiveFactory.ts
[warn] src/lib/shot/motion/sampleTrack.ts
[warn] src/lib/shot/shotStore.svelte.ts
[warn] src/lib/shot/types/mannequin-js.d.ts
[warn] src/lib/storage.ts
[warn] src/lib/toast.ts
[warn] src/lib/wideLayout.ts
[warn] src/routes/+layout.ts
[warn] src/routes/+page.ts
[warn] svelte.config.js
[warn] tailwind.config.js
[warn] tsconfig.json
[warn] vite.config.ts
[warn] Code style issues found in 63 files. Run Prettier with --write to fix.
+442
View File
@@ -0,0 +1,442 @@
.................................F...................................... [ 8%]
........................................................................ [ 17%]
........................................................................ [ 25%]
........................................................................ [ 34%]
..................................F..................................... [ 42%]
........F............................................................... [ 51%]
........................................................................ [ 59%]
........................................................................ [ 68%]
...F..........F......................................................... [ 76%]
.........s.............................................................. [ 85%]
........................................................................ [ 93%]
sssss................................................ [100%]
=================================== FAILURES ===================================
________________________ test_comfy_server_info_scoping ________________________
def test_comfy_server_info_scoping():
"""Native health check stays visible in both blind and linked sessions."""
from calliope.agent.harness import tools as harness_tools
blind = ToolContext(session_id=1, project_id=None)
linked = ToolContext(session_id=1, project_id=99)
> blind_names = {e["function"]["name"] for e in harness_tools.openai_tools_payload(blind)}
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
tests/test_agent_comfy_mcp.py:370:
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _
src/calliope/agent/harness/tools.py:27: in openai_tools_payload
return _registry.openai_payload(ctx)
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
src/calliope/agent/harness/registry.py:219: in openai_payload
if self._visible(t, ctx)
^^^^^^^^^^^^^^^^^^^^^
src/calliope/agent/harness/registry.py:203: in _visible
if not user_allows_render(ctx):
^^^^^^^^^^^^^^^^^^^^^^^
src/calliope/agent/harness/policy.py:156: in user_allows_render
if has_structured_approval(ctx, "render"):
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
src/calliope/agent/harness/policy.py:277: in has_structured_approval
answer = latest_answer(ctx.session_id)
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
src/calliope/agent/harness/policy.py:260: in latest_answer
events = read_events(session_id)
^^^^^^^^^^^^^^^^^^^^^^^
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _
session_id = 1
def read_events(session_id: int) -> list[SessionEvent]:
conn = _db()
try:
> rows = conn.execute(
"SELECT * FROM agent_events WHERE session_id = ? ORDER BY seq", (session_id,)
).fetchall()
E sqlite3.OperationalError: no such table: agent_events
src/calliope/agent/harness/log.py:146: OperationalError
___________ test_h3_messages_bind_neighbor_shots_and_keep_file_slots ___________
def test_h3_messages_bind_neighbor_shots_and_keep_file_slots():
from calliope.agent.prompts import build_minimax_h3_ref_messages
from calliope.agent.video_agent import resolve_h3_references
from calliope.comfyui.parser import parse_dynamic_inputs
plan = {
"overview": {"style": "noir", "lighting": "sodium"},
"requirements": {"lighting": "sodium lamps only"},
"shots": [
{
"clip_id": 1,
"window": "00:00.000-00:04.000",
"action": "enter",
"camera": "push in",
"lighting": "dim sodium",
"speakers": "MAYA: hello",
"spatial": "screen left",
"state_after": "door open",
},
{
"clip_id": 2,
"window": "00:04.000-00:08.000",
"action": "turn",
"camera": "hold",
"lighting": "dim sodium",
"speakers": "",
"spatial": "screen left",
"state_after": "",
},
{
"clip_id": 3,
"window": "00:08.000-00:12.000",
"action": "leave",
"camera": "pull out",
"lighting": "brighter",
"speakers": "",
"spatial": "",
"state_after": "hallway empty",
},
],
}
lock = continuity_lock_text(plan, 2)
assert "Previous shot" in lock
assert "Next shot" in lock
assert "Do not recast a subject" in lock
assert "screen direction" in lock
assert "enter" in lock and "leave" in lock
scene = {"heading": "INT. HALL", "action": "She turns.", "duration_sec": 4, "dialog": ""}
subjects = [
{
"index": 1,
"kind": "reference",
"name": "mercs",
"appearance": "",
"path": r"E:\assets\mercs.png",
}
]
messages = build_minimax_h3_ref_messages(scene, subjects, continuity=lock)
text = messages[1]["content"]
assert "Previous shot" in text
assert "Next shot" in text
assert "screen direction" in text
assert "do not substitute another character or location" in text
workflow = {
"148": {
"class_type": "LoadImage",
"inputs": {"image": ""},
"_meta": {"title": "Ref 1 (Input:image)"},
},
"149": {
"class_type": "LoadImage",
"inputs": {"image": ""},
"_meta": {"title": "Ref 2 (Input:image)"},
},
}
inputs = parse_dynamic_inputs(workflow)
resolved, paths, _videos = resolve_h3_references(
inputs,
{"148": r"E:\assets\hero.png", "149": r"E:\assets\mercs.png"},
[{"name": "Maya", "appearance": "olive jacket", "sheet_path": r"E:\assets\maya.png"}],
{"name": "Metro Line", "description": "rain-slick rooftops"},
r"E:\assets\metro.png",
)
> assert resolved[1]["name"] == "mercs"
E AssertionError: assert 'E:\\assets\\mercs' == 'mercs'
E
E - mercs
E + E:\assets\mercs
tests/test_continuity.py:249: AssertionError
___________ test_resolve_h3_references_prefers_form_files_over_story ___________
def test_resolve_h3_references_prefers_form_files_over_story():
"""Slot files the user picked replace the story cast; empty slots keep it."""
from calliope.agent.video_agent import resolve_h3_references
from calliope.comfyui.parser import parse_dynamic_inputs
workflow = {
"148": {
"class_type": "LoadImage",
"inputs": {"image": ""},
"_meta": {"title": "Ref 1 (Input:image)"},
},
"149": {
"class_type": "LoadImage",
"inputs": {"image": ""},
"_meta": {"title": "Ref 2 (Input:image)"},
},
"150": {
"class_type": "LoadVideo",
"inputs": {"video": ""},
"_meta": {"title": "Motion (Input:video)"},
},
}
inputs = parse_dynamic_inputs(workflow)
characters = [
{
"name": "Maya",
"appearance": "olive jacket",
"sheet_path": r"E:\assets\maya.png",
}
]
location = {"name": "Metro Line", "description": "rain-slick rooftops"}
loc_image = r"E:\assets\metro.png"
empty_subjects, empty_paths, empty_videos = resolve_h3_references(
inputs, {}, characters, location, loc_image
)
assert [s["name"] for s in empty_subjects] == ["Maya", "Metro Line"]
assert empty_paths == [r"E:\assets\maya.png", r"E:\assets\metro.png"]
assert empty_videos == []
subjects, paths, videos = resolve_h3_references(
inputs,
{
"148": r"E:\assets\maya.png",
"149": r"E:\assets\mercs.png",
"150": r"E:\assets\fight.mp4",
},
characters,
location,
loc_image,
)
assert subjects[0]["kind"] == "character"
assert subjects[0]["name"] == "Maya"
assert subjects[1]["kind"] == "reference"
> assert subjects[1]["name"] == "mercs"
E AssertionError: assert 'E:\\assets\\mercs' == 'mercs'
E
E - mercs
E + E:\assets\mercs
tests/test_h3_profile.py:215: AssertionError
___________ test_preview_prompt_uses_form_references_not_story_cast ____________
client = <starlette.testclient.TestClient object at 0x7ff7be3182c0>
monkeypatch = <_pytest.monkeypatch.MonkeyPatch object at 0x7ff7bc9b2180>
def test_preview_prompt_uses_form_references_not_story_cast(client, monkeypatch):
"""The Video-tab image and video slots are the prompt's subjects.
Story character + location text must not replace files the user picked,
and Regenerate (force) must not return a draft written before those files.
"""
pid = _mk_project(client, "Preview refs")
scene = _add_scene(client, pid, 1, action="She swings the extinguisher.")
clip_id = _scene_default_clip(pid, scene["id"])
wf = {
"10": {
"class_type": "CLIPTextEncode",
"inputs": {"text": ""},
"_meta": {"title": "Main Prompt (Input:prompt)"},
},
"148": {
"class_type": "LoadImage",
"inputs": {"image": ""},
"_meta": {"title": "Ref 1 (Input:image)"},
},
"149": {
"class_type": "LoadImage",
"inputs": {"image": ""},
"_meta": {"title": "Ref 2 (Input:image)"},
},
"150": {
"class_type": "LoadVideo",
"inputs": {"video": ""},
"_meta": {"title": "Motion (Input:video)"},
},
}
conn = get_db(settings.db_path)
try:
cur = conn.execute(
"""
INSERT INTO workflows (name, kind, workflow_json, input_schema, output_schema,
prompt_profile, is_enabled)
VALUES (?, 'video', ?, '[]', '[]', 'minimax_h3_ref', 1)
""",
("H3 refs", json.dumps(wf)),
)
wf_id = cur.lastrowid
char = conn.execute(
"""
INSERT INTO characters (project_id, name, appearance, sheet_path)
VALUES (?, 'Maya', 'olive jacket', ?)
""",
(pid, r"E:\story\maya-sheet.png"),
)
conn.execute(
"INSERT INTO scene_characters (scene_id, character_id) VALUES (?, ?)",
(scene["id"], char.lastrowid),
)
loc = conn.execute(
"""
INSERT INTO locations (project_id, name, description, reference_image_path)
VALUES (?, 'Metro Line', 'rain-slick rooftops', ?)
""",
(pid, r"E:\story\metro.png"),
)
conn.execute(
"UPDATE scenes SET location_id = ? WHERE id = ?",
(loc.lastrowid, scene["id"]),
)
conn.execute(
"UPDATE clips SET workflow_id = ? WHERE id = ?", (wf_id, clip_id)
)
conn.commit()
finally:
conn.close()
seen: dict = {}
async def fake_rewrite(scene_, subjects, **kwargs):
seen["subjects"] = subjects
seen["videos"] = kwargs.get("videos")
seen["calls"] = seen.get("calls", 0) + 1
return "REWRITE FROM REFS"
monkeypatch.setattr("calliope.agent.video_agent._h3_rewrite", fake_rewrite)
form = {
"148": r"E:\refs\hero.png",
"149": r"E:\refs\mercs.png",
"150": r"E:\refs\fight.mp4",
}
r = client.post(
f"/api/jobs/projects/{pid}/preview-prompt",
json={"clip_id": clip_id, "input_values": form},
)
assert r.status_code == 200
body = r.json()
assert body["prompt"] == "REWRITE FROM REFS"
assert body["from_draft"] is False
names = [s["name"] for s in seen["subjects"]]
> assert names == ["hero", "mercs"]
E AssertionError: assert ['E:\\refs\\h...\refs\\mercs'] == ['hero', 'mercs']
E
E At index 0 diff: 'E:\\refs\\hero' != 'hero'
E Use -v to get more diff
tests/test_scene_settings.py:487: AssertionError
---------------------------- Captured stderr setup -----------------------------
2026-10-21 19:58:18,478 | INFO | calliope | Calliope started — db=/tmp/multica-task-3084682389/tmp_95dfjk4/calliope.db dry_run=True
2026-10-21 19:58:18,478 | INFO | calliope.worker | Queue worker started
------------------------------ Captured log setup ------------------------------
INFO calliope:main.py:112 Calliope started — db=/tmp/multica-task-3084682389/tmp_95dfjk4/calliope.db dry_run=True
INFO calliope.worker:worker.py:79 Queue worker started
----------------------------- Captured stderr call -----------------------------
2026-10-21 19:58:18,482 | INFO | httpx2 | HTTP Request: POST http://testserver/api/projects "HTTP/1.1 200 OK"
2026-10-21 19:58:18,495 | INFO | httpx2 | HTTP Request: POST http://testserver/api/projects/1/scenes "HTTP/1.1 200 OK"
2026-10-21 19:58:18,506 | INFO | httpx2 | HTTP Request: POST http://testserver/api/jobs/projects/1/preview-prompt "HTTP/1.1 200 OK"
------------------------------ Captured log call -------------------------------
INFO httpx2:_client.py:1085 HTTP Request: POST http://testserver/api/projects "HTTP/1.1 200 OK"
INFO httpx2:_client.py:1085 HTTP Request: POST http://testserver/api/projects/1/scenes "HTTP/1.1 200 OK"
INFO httpx2:_client.py:1085 HTTP Request: POST http://testserver/api/jobs/projects/1/preview-prompt "HTTP/1.1 200 OK"
--------------------------- Captured stderr teardown ---------------------------
2026-10-21 19:58:18,980 | INFO | calliope.worker | Queue worker stopped
2026-10-21 19:58:18,980 | INFO | calliope | Calliope shutting down
---------------------------- Captured log teardown -----------------------------
INFO calliope.worker:worker.py:185 Queue worker stopped
INFO calliope:main.py:118 Calliope shutting down
_________________ test_script_regenerate_keeps_expanded_count __________________
client = <starlette.testclient.TestClient object at 0x7ff7bd902660>
monkeypatch = <_pytest.monkeypatch.MonkeyPatch object at 0x7ff7bc9b2180>
def test_script_regenerate_keeps_expanded_count(client, monkeypatch):
calls: list[int] = []
async def fake_structured(messages, temperature=0.7):
user = messages[1]["content"]
# Parse required count from prompt
n = 4
for line in user.splitlines():
if line.startswith("required_scene_count:"):
n = int(line.split(":", 1)[1].strip())
break
calls.append(n)
return {
"scenes": [
{
"order_index": i,
"heading": f"SCENE {i}",
"action": f"action {i}",
"dialog": "",
"duration_sec": 5,
"character_ids": [],
"location_id": None,
}
for i in range(1, n + 1)
]
}
monkeypatch.setattr("calliope.agent.script_agent.generate_structured", fake_structured)
r = client.post("/api/projects", json={"title": "Expand", "idea": "desert", "target_duration": "30 seconds"})
pid = r.json()["id"]
# Seed 4 scenes via generate
r = client.post(f"/api/projects/{pid}/generate-script", json={"replace": True})
> assert r.status_code == 200
E assert 500 == 200
E + where 500 = <Response [500 Internal Server Error]>.status_code
tests/test_script_scene_count.py:59: AssertionError
---------------------------- Captured stderr setup -----------------------------
2026-10-21 19:58:19,584 | INFO | calliope | Calliope started — db=/tmp/multica-task-3084682389/tmpl0t0r_mx/calliope.db dry_run=True
2026-10-21 19:58:19,584 | INFO | calliope.worker | Queue worker started
------------------------------ Captured log setup ------------------------------
INFO calliope:main.py:112 Calliope started — db=/tmp/multica-task-3084682389/tmpl0t0r_mx/calliope.db dry_run=True
INFO calliope.worker:worker.py:79 Queue worker started
----------------------------- Captured stderr call -----------------------------
2026-10-21 19:58:19,588 | INFO | httpx2 | HTTP Request: POST http://testserver/api/projects "HTTP/1.1 200 OK"
2026-10-21 19:58:19,604 | INFO | calliope.llm | LLM stream request to http://127.0.0.1:11434/v1/chat/completions with model llama3.2
2026-10-21 19:58:19,606 | INFO | httpx2 | HTTP Request: POST http://testserver/api/projects/1/generate-script "HTTP/1.1 500 Internal Server Error"
------------------------------ Captured log call -------------------------------
INFO httpx2:_client.py:1085 HTTP Request: POST http://testserver/api/projects "HTTP/1.1 200 OK"
INFO calliope.llm:llm.py:360 LLM stream request to http://127.0.0.1:11434/v1/chat/completions with model llama3.2
INFO httpx2:_client.py:1085 HTTP Request: POST http://testserver/api/projects/1/generate-script "HTTP/1.1 500 Internal Server Error"
--------------------------- Captured stderr teardown ---------------------------
2026-10-21 19:58:20,086 | INFO | calliope.worker | Queue worker stopped
2026-10-21 19:58:20,086 | INFO | calliope | Calliope shutting down
---------------------------- Captured log teardown -----------------------------
INFO calliope.worker:worker.py:185 Queue worker stopped
INFO calliope:main.py:118 Calliope shutting down
=============================== warnings summary ===============================
.venv/lib/python3.12/site-packages/starlette/testclient.py:53
/home/xiaomo/multica_workspaces/hello-af23464d222e/hel-728-a07b128af8f7/workdir/aigc-home/calliope-backend/.venv/lib/python3.12/site-packages/starlette/testclient.py:53: DeprecationWarning: The anyio.abc.BlockingPortal alias is deprecated, use anyio.from_thread.BlockingPortal instead.
_PortalFactoryType = Callable[[], AbstractContextManager[anyio.abc.BlockingPortal]]
tests/test_shell_tool.py::test_settings_roundtrip_for_new_keys
/home/xiaomo/multica_workspaces/hello-af23464d222e/hel-728-a07b128af8f7/workdir/aigc-home/calliope-backend/.venv/lib/python3.12/site-packages/_pytest/unraisableexception.py:67: PytestUnraisableExceptionWarning: Exception ignored in: <function BaseSubprocessTransport.__del__ at 0x7ff7c055cb80>
Traceback (most recent call last):
File "/home/xiaomo/.local/share/uv/python/cpython-3.12.14-linux-x86_64-gnu/lib/python3.12/asyncio/base_subprocess.py", line 126, in __del__
self.close()
File "/home/xiaomo/.local/share/uv/python/cpython-3.12.14-linux-x86_64-gnu/lib/python3.12/asyncio/base_subprocess.py", line 104, in close
proto.pipe.close()
File "/home/xiaomo/.local/share/uv/python/cpython-3.12.14-linux-x86_64-gnu/lib/python3.12/asyncio/unix_events.py", line 568, in close
self._close(None)
File "/home/xiaomo/.local/share/uv/python/cpython-3.12.14-linux-x86_64-gnu/lib/python3.12/asyncio/unix_events.py", line 592, in _close
self._loop.call_soon(self._call_connection_lost, exc)
File "/home/xiaomo/.local/share/uv/python/cpython-3.12.14-linux-x86_64-gnu/lib/python3.12/asyncio/base_events.py", line 799, in call_soon
self._check_closed()
File "/home/xiaomo/.local/share/uv/python/cpython-3.12.14-linux-x86_64-gnu/lib/python3.12/asyncio/base_events.py", line 545, in _check_closed
raise RuntimeError('Event loop is closed')
RuntimeError: Event loop is closed
Enable tracemalloc to get traceback where the object was allocated.
See https://docs.pytest.org/en/stable/how-to/capture-warnings.html#resource-warnings for more info.
warnings.warn(pytest.PytestUnraisableExceptionWarning(msg))
-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html
=========================== short test summary info ============================
FAILED tests/test_agent_comfy_mcp.py::test_comfy_server_info_scoping - sqlite...
FAILED tests/test_continuity.py::test_h3_messages_bind_neighbor_shots_and_keep_file_slots
FAILED tests/test_h3_profile.py::test_resolve_h3_references_prefers_form_files_over_story
FAILED tests/test_scene_settings.py::test_preview_prompt_uses_form_references_not_story_cast
FAILED tests/test_script_scene_count.py::test_script_regenerate_keeps_expanded_count
5 failed, 834 passed, 6 skipped, 2 warnings in 368.55s (0:06:08)
+201
View File
@@ -0,0 +1,201 @@
feat/hel-665-refract @ 35fd07a (v5 formal interfaces; stage advice API; user database/config unchanged; demos retained; rollback /opt/aigc-home/releases/rollback-HEL665-v5-35fd07a-20261015.sh)
total 64
drwxr-xr-x 4 root root 4096 Oct 15 00:33 .
drwxr-xr-x 10 root root 12288 Oct 15 00:33 ..
drwxrwxr-x 6 mobai mobai 4096 Oct 15 00:33 build
drwxrwxr-x 4 mobai mobai 4096 Oct 10 20:35 src
-rw-rw-r-- 1 mobai mobai 40144 Oct 15 00:13 workbench.py
6864465552ed31ae9179bf6cb8b45bc7df4e43b1b5d452abaa04007346cfa50c /opt/aigc-home/app/calliope-web/src/app.html
9fcd4c28d99551f12eebc3e58d2d2e715a2d2fae91315078e25ad301a3fe0101 /opt/aigc-home/app/calliope-web/src/hooks.client.ts
016119d7d2f2f77dee4441ce8c0e0c337461775be19589ab25eb8e6d93d9325b /opt/aigc-home/app/calliope-web/src/hooks.server.ts
90ef4a1441f5ea1b9b1191dcd90bdedfad038a834393860071f4ac658217234a /opt/aigc-home/app/calliope-web/src/lib/actions/spotlight.ts
b73c6e2c597d2ee06071c279ea7f51b8318702ec09617a29361d906fd72e3f12 /opt/aigc-home/app/calliope-web/src/lib/agentComposer.ts
f5b7e10ecef0822206220c8c2827740c3e35254ca92ce130d52a786945dc3af8 /opt/aigc-home/app/calliope-web/src/lib/agentTasks.ts
859129cc60fab17c6104f82c82eb0dc42322d9f81fb8b210ba4ff5aace234e4a /opt/aigc-home/app/calliope-web/src/lib/api.ts
53fa1c405c185b2ec07cd379011dde429de28c965a8d7e21771e1a5b21e3e79c /opt/aigc-home/app/calliope-web/src/lib/assetPicker.ts
b0adfec622f00ee5dbe1f786c9d487229ddb33eb2077e2166bd7b391c0d32d6c /opt/aigc-home/app/calliope-web/src/lib/canvas/ArtifactNode.svelte
4d76e5fd32270d79be2814c2bd6b9ba19f1c3e5319133d915966052ecc92f6f3 /opt/aigc-home/app/calliope-web/src/lib/canvas/EntityNode.svelte
8bc6d779cdfc98885b5203dabdca38999e12e9d503dfd6d7cd8452162b0bd78e /opt/aigc-home/app/calliope-web/src/lib/comfy/classifyInput.ts
b0a8e133e68635ba08f700c23196a7ba9524d510ac5d28d8aa674cf18c21bd70 /opt/aigc-home/app/calliope-web/src/lib/comfy/imageFile.ts
e8372ec223a04e4aef7af92cbe11656acc1339bfbc6029876efb4495efac60b1 /opt/aigc-home/app/calliope-web/src/lib/comfy/parser.ts
233160c907bcf7a6b3a6d78d5500cecdf2e0a14dbd67b2f7a63555f721106c91 /opt/aigc-home/app/calliope-web/src/lib/comfy/promptInput.ts
4cbfcb95e28e2d5061614977831b2a9fe1c82449ab9ed5b692d963e1d99f6505 /opt/aigc-home/app/calliope-web/src/lib/comfy/resolution.ts
0edcc73d9a03a38804032b8799705f85d5a8560838ddbafd33651ea167a354c6 /opt/aigc-home/app/calliope-web/src/lib/comfy/types.ts
aa91e6d36c34dd9a5246fff3c24e4b437dccb8f9c55141959a9b1df0639205b9 /opt/aigc-home/app/calliope-web/src/lib/comfy/useUpload.svelte.ts
3722615c86e150d61fee60b287cc66fc1ff5f32c4a13e55f7f2e4ec6e3b7e95c /opt/aigc-home/app/calliope-web/src/lib/components/AppHeader.svelte
b6844910420c977167b7beee5afa6ee3f649a74bd6e8128ced432e3a51079e57 /opt/aigc-home/app/calliope-web/src/lib/components/AttachToProject.svelte
70b01844b1b8bb3c1c79e49aa61d3a37fe93ccf30c1f28a1e05867efa8f58582 /opt/aigc-home/app/calliope-web/src/lib/components/ComfyDynamicForm.svelte
b67433cd628d0507665fdd594bbf38ba366cb71cf6e1bcf32c8be05cbc8bddc5 /opt/aigc-home/app/calliope-web/src/lib/components/GeneratingOverlay.svelte
6675bd98205aa274938874dba40e4c95f0f871b9ea29433cb5edf207a8963a3d /opt/aigc-home/app/calliope-web/src/lib/components/ImageLightbox.svelte
37fb771f258dd4dfe7b4c1738b84ea6aa74cd35508505c0c03bbb2121226c4ac /opt/aigc-home/app/calliope-web/src/lib/components/LanguageSwitcher.svelte
4552e1fd1972a55b7267949fe6f6ce57e18b0c500698e2f919562a77b3ffa636 /opt/aigc-home/app/calliope-web/src/lib/components/OmniComposer.svelte
d53835230e1d584e59c98f5b6aa965b605ab73011f33eac61a0a0e44158b6d0b /opt/aigc-home/app/calliope-web/src/lib/components/SafeMedia.svelte
4becfa6663461e91602b74322f9807aedd403522ccd54603c66b80b872363953 /opt/aigc-home/app/calliope-web/src/lib/components/ToastHost.svelte
d744fc2278a627ddb329a0ba9c86b80905d09e58a476297a35c4c77874c35b07 /opt/aigc-home/app/calliope-web/src/lib/components/WorkflowImportDrawer.svelte
a75f0602d09cf72d6821a69be537cfda284ff589a9f5452e7ed8dd64337b7bd4 /opt/aigc-home/app/calliope-web/src/lib/components/WorkflowTestDialog.svelte
52d9b005c65f6d83ef7ead5caa81dc61058a7049c29779b76d01979361c480b7 /opt/aigc-home/app/calliope-web/src/lib/components/agent/AgentArtifactCard.svelte
075ab299124430e41ca9ab1e314d3e616dd0fbdc9e36cf92cd7b0479fdd493f4 /opt/aigc-home/app/calliope-web/src/lib/components/agent/AgentChat.svelte
74545d012fa615b92d69ce6c5b660d4f61ba2501fe593a3cb92fd8df40136df6 /opt/aigc-home/app/calliope-web/src/lib/components/agent/AgentComposer.svelte
abc7741fd784affd82a7a38b2d10d1714b77170da866cf2ea89ced3d43bfaa46 /opt/aigc-home/app/calliope-web/src/lib/components/agent/AgentPlanPanel.svelte
3b87fdf4e8f2077cf0407bb2eadc418ca9e8aa4e781640b58ff9d88df73fc67f /opt/aigc-home/app/calliope-web/src/lib/components/agent/AgentSessionSidebar.svelte
c1af4f03f60b8c677253c804316bfbc9629eb78d9846b5cff7d574befa1216e6 /opt/aigc-home/app/calliope-web/src/lib/components/agent/AgentToolCard.svelte
932dcf780eec9d3f294bf90dbb04d25098b4a8c36a31113a7bb10162251c1e4e /opt/aigc-home/app/calliope-web/src/lib/components/agent/ReasoningPanel.svelte
6dfc7d0b3a2372e3dd5213c48a7e5c6a7c2bb109c7fe102b9ddfab8f42217e77 /opt/aigc-home/app/calliope-web/src/lib/components/agent/WorkflowMentionMenu.svelte
a84cf30f6cf6356c09b2623201e1f099f2843408553c724f76e362f5d844d2f7 /opt/aigc-home/app/calliope-web/src/lib/components/agent/agentPalette.ts
770c0feb2009286ebdc455228a67117a80f94f58e4728475c17c57d968910f4c /opt/aigc-home/app/calliope-web/src/lib/components/desk/Artboard.svelte
fe8483c2f5a4fdf9df112f0feae1d3170162b5c0fbf5bae73c1b6cc3126f2a31 /opt/aigc-home/app/calliope-web/src/lib/components/desk/DeskWorkflowRail.svelte
f445d83de06804f132680eab34a6412444f1885a67a1aa832e62307f649ddaf3 /opt/aigc-home/app/calliope-web/src/lib/components/desk/InputRequirementBadge.svelte
da087b4e93feeee3def4bafdf0ac931630fcd5b87b2293c4102e9b090eb3734f /opt/aigc-home/app/calliope-web/src/lib/components/desk/LibraryFilters.svelte
cbdad9bfe085513184e2c30aefabaa92dbef38de4cbf2af763e2f74ad29e75e3 /opt/aigc-home/app/calliope-web/src/lib/components/desk/LibraryRow.svelte
28583dfa4bcd6e605d3ba41ac889fdbd29d592949153a93bdd3670246a0a33cc /opt/aigc-home/app/calliope-web/src/lib/components/desk/ResultRail.svelte
d78d68e3e3858d8cf0f22d06c9bb616187241264e3c67710f9f7561bb5ec4133 /opt/aigc-home/app/calliope-web/src/lib/components/desk/StudioDesk.svelte
77518af58ceaaf4026eda2df5191ba244fff4b3ef21db54ae8692b759be86a6a /opt/aigc-home/app/calliope-web/src/lib/components/desk/TaskModeRail.svelte
7bd0699f0d83702e04a06adc4cff8cdc4f62f5e6581b5c2deeb0e0b05d2077d7 /opt/aigc-home/app/calliope-web/src/lib/components/desk/WorkflowBay.svelte
c093d1664194022d3edfb782d735df60e93a6fe838afb5ccbfb9b6079b2b1023 /opt/aigc-home/app/calliope-web/src/lib/components/desk/WorkflowPreview.svelte
281bf5a5795b17d468b12dd4973004e5bdac458cd55e061feaaed69aa2c218c5 /opt/aigc-home/app/calliope-web/src/lib/components/generate/Composer.svelte
ef72dc2367471507a4b5d15621854c761b99bba571876b94b07852369646260e /opt/aigc-home/app/calliope-web/src/lib/components/generate/ContextBar.svelte
0e2943e5120efcf9ad620ba1d8a4e4c1658c88560c7c1d9b4032cd960f0a097a /opt/aigc-home/app/calliope-web/src/lib/components/generate/Inspector.svelte
32f4c7c1a3a571a240741255fdf83173ef5d9d0f0a4626e02ea19d88fe613980 /opt/aigc-home/app/calliope-web/src/lib/components/generate/ParamChip.svelte
1c7726496d94f822b1f6a72ccd7e0fd1a40b91e3adf64edc45f995d71df5da2b /opt/aigc-home/app/calliope-web/src/lib/components/generate/ParameterPopover.svelte
18e20e2685e840f018b2eb9ba1928af5bd4fdf4cd8c2b2d65fd1f01da644aca8 /opt/aigc-home/app/calliope-web/src/lib/components/generate/ParamsPanel.svelte
dd3d58c3bf9901d25790f44cc2eaa5835ea15335c3695202f565bb640efb2ed7 /opt/aigc-home/app/calliope-web/src/lib/components/generate/SlotTray.svelte
f8205126227507fe1b50c9f57fec6412b830ad2234bf1ee61a5a458bb82ca3a2 /opt/aigc-home/app/calliope-web/src/lib/components/generate/TakeCard.svelte
2307ee5f013d81f20315ec2118d801a3367837c8d018048d501f65770a173f26 /opt/aigc-home/app/calliope-web/src/lib/components/generate/TakeFeed.svelte
beb37354c781810c670661be8ac35ccdf6b8fc9d23525a4fcc543cdb10b6ad0a /opt/aigc-home/app/calliope-web/src/lib/components/generate/context.ts
da980feb2ea7e0eec93fac5cc0ee0c01a6be6f80d3c685e7eb22ecd00cc8741b /opt/aigc-home/app/calliope-web/src/lib/components/generate/schema.ts
5cfda8936144415a0e001511128e39268cf4db39430b59882ff4ae80da9c93c6 /opt/aigc-home/app/calliope-web/src/lib/components/generate/takeDelete.svelte.ts
ea14c626f5bedec1b8fb607af318e05454b36f913a63ad4808d9026380e0687d /opt/aigc-home/app/calliope-web/src/lib/components/generate/takes.ts
eed1c80c2bb5ed54737c94adcc8f6461e57fb182218fca6ae0f87c61b0a1a0f8 /opt/aigc-home/app/calliope-web/src/lib/components/home/AProjectCard.svelte
82de633bef1c92fecedd0618faaf4f3c6e869f365f8d22eda1631921f8cf56a5 /opt/aigc-home/app/calliope-web/src/lib/components/omni/AssetPickerModal.svelte
0ba296a2ea05bb78ab3fdcd1ea526d894eb3eed0ed74f9149025abd68774413a /opt/aigc-home/app/calliope-web/src/lib/components/omni/MediaTile.svelte
e587b7b07272f5f3c9935dce6092cb5fb4e678d0fa3653a02360f4dec7f478ae /opt/aigc-home/app/calliope-web/src/lib/components/omni/PillPopover.svelte
47f04edf92ad2336893f77744762effef6e4acfeb53d657524ce1005505fb240 /opt/aigc-home/app/calliope-web/src/lib/components/omni/PillSelect.svelte
5ef968def3ce3d7e134ef5ac10d07b098a9ec7a336593494ff7d967f7ff3158e /opt/aigc-home/app/calliope-web/src/lib/components/omni/PillStepper.svelte
b895e524829484dd7306f1bb4cc2e9ce652a1968662c4f4d6a7d586cea6ba4f8 /opt/aigc-home/app/calliope-web/src/lib/components/refract/Hero.svelte
e7399561fa10f35aaaffe4d085b1822e626a0396057883d4b5b5c24462f26a15 /opt/aigc-home/app/calliope-web/src/lib/components/settings/MemoryPanel.svelte
24b303e3e0f2db36dafebef71353611f34fb75ac851730a1551621aaf1d0e219 /opt/aigc-home/app/calliope-web/src/lib/components/shell/AppShell.svelte
25e74bb4200011048065b5c7de7566da902e2ed456316fdf786de1b1652d767a /opt/aigc-home/app/calliope-web/src/lib/components/shell/CommandPalette.svelte
87a214fb486b27a8a7ed38caaef39b736a2fddb8be9b5a09c051762caf263442 /opt/aigc-home/app/calliope-web/src/lib/components/shell/ProjectScope.svelte
9ebcf08567795b431537942481b461e400ab6a37a60e927e3fe559ddce6e65eb /opt/aigc-home/app/calliope-web/src/lib/components/shell/QueueTray.svelte
fd205da624befed52999700d8a00ec48f167c376c51258b791ec4d8a2a345ac9 /opt/aigc-home/app/calliope-web/src/lib/components/shell/ShellPop.svelte
f07a172763a2b0a7755ea20721ae20d7b2cb63aaeb5a6f3229c4ddedf99199e9 /opt/aigc-home/app/calliope-web/src/lib/components/shell/nav.ts
16a3e74a27530b0a0065e425c128de12168243c5b35897f47bb5b7c9be2ae0c3 /opt/aigc-home/app/calliope-web/src/lib/components/studio/MediaSlot.svelte
8b0e79f5d2563d2a73c61dad5dc721680d441787b26081dd2887097a9d9d1424 /opt/aigc-home/app/calliope-web/src/lib/components/studio/RefImageGrid.svelte
b55a01704dad5ecdab05d3bddeb1d6e44923322fa7a9d4aa60558a702a231e07 /opt/aigc-home/app/calliope-web/src/lib/components/ui/Button.svelte
624cbcb67c9dc143a75b78359cd8c50c067dbff57cea528a7a309e0be29d8d96 /opt/aigc-home/app/calliope-web/src/lib/components/ui/Card.svelte
a72c982126332293887705350c8825f6232e2e1067abaf4a1bbfe1773aac473c /opt/aigc-home/app/calliope-web/src/lib/components/ui/ConfirmDialog.svelte
bd56ad82b41fa23f64e908984c7a4c3ae6f24bdd5d1cfe2e66e21de25446d5ca /opt/aigc-home/app/calliope-web/src/lib/components/ui/Drawer.svelte
7ab297b1f73b8bde8b7389f52721e2216fecdd4df91e5fefb21e424336194f30 /opt/aigc-home/app/calliope-web/src/lib/components/ui/EmptyState.svelte
81798dc7007582e1025c246d4c3e9bb9ccfe7306f3199475b1581e58382005a5 /opt/aigc-home/app/calliope-web/src/lib/components/ui/Icon.svelte
db6130e0930b5a92d6af7fd613a898dcd117a9a7268823c1544d9c150cefe92d /opt/aigc-home/app/calliope-web/src/lib/components/ui/Modal.svelte
0925761862b026b218cea0d6464cff4e67f1181bf4df4ff06f91e8ae472ea292 /opt/aigc-home/app/calliope-web/src/lib/components/ui/ProgressBar.svelte
8f9304f189cb51702be7d426aad14edc05d6c68707baf8448cfe6de6a7951fed /opt/aigc-home/app/calliope-web/src/lib/components/ui/README.md
08e0d4f85cc945a1fa3773edbbb8c725b7f9737bf5be5e7cd2a679bd9f9f90c1 /opt/aigc-home/app/calliope-web/src/lib/components/ui/Skeleton.svelte
1460161d521f5ffaf1853ccb85e33240bce1ce736fd4a2e0e081acdd7cd2c584 /opt/aigc-home/app/calliope-web/src/lib/components/ui/Spinner.svelte
c67bc6d80dc82801f9e9be21c201781dd2d0708dad857ee3425f42066ce5c6be /opt/aigc-home/app/calliope-web/src/lib/components/ui/StatusChip.svelte
bbe0468da5495496bb76b75624eecd18438346b9c111319a77e3a2936f0a15f8 /opt/aigc-home/app/calliope-web/src/lib/components/ui/Switch.svelte
c1c0c2f9edca20d964eddad0d02e53a1b332c53cef2dc8d5636cdf7af5bdef33 /opt/aigc-home/app/calliope-web/src/lib/components/ui/icons.ts
0ab42d1c4781f6aa63aaabe90d40062d5f633a9941b10ed9c2dc7f5fc5a2472d /opt/aigc-home/app/calliope-web/src/lib/components/ui/modalLayers.ts
61c450b53fc546a2f1b68551b691326abefd705fc6145287f25ea48b3c4b269f /opt/aigc-home/app/calliope-web/src/lib/components/video/ClipMonitor.svelte
62649ec0b2e02c33b60651b31927bca0a589e3f68bc665d50438aa71bfe16161 /opt/aigc-home/app/calliope-web/src/lib/components/video/ClipSourceModal.svelte
23225ace57828aa80cbd37fc00156b4170d31ddfbebb693c747059ad2032a280 /opt/aigc-home/app/calliope-web/src/lib/components/video/JobInputsDrawer.svelte
38df3060885270e2e0d4e7a0eecad7d460fd88b5d65f3f65e6d3ba87ea9e3744 /opt/aigc-home/app/calliope-web/src/lib/components/video/PromptPreviewModal.svelte
cde0d361ab0b5eb220da310d6297092c9c0cc8fb3b44a53eab84a1acc898ac1b /opt/aigc-home/app/calliope-web/src/lib/components/video/SceneFilmstrip.svelte
116be76c6f77cd763279bbc632fefe9d5e1b8a220533ee92776e49a34e73e6cd /opt/aigc-home/app/calliope-web/src/lib/components/video/SceneScriptDrawer.svelte
bcaa50dd928ae2b1ecf95697fad51852c2bf091ff391e94e749eb09035521322 /opt/aigc-home/app/calliope-web/src/lib/components/video/ShotBrief.svelte
fe5823233fd94847258f02a8dbba7df1d1c84f0f45a9fde459dad297221f7e52 /opt/aigc-home/app/calliope-web/src/lib/components/video/VideoEditWorkspace.svelte
f0d8365d82b65dcbb995cf93fe4769705bf9d9136b1bd60eb0e26387ccd488ec /opt/aigc-home/app/calliope-web/src/lib/components/workbench/AssetsStagePanel.svelte
91c7eb1b74135fdb4780e86871d646dcaa23c6c7cd1d8d0cc69bc4ec044bfd99 /opt/aigc-home/app/calliope-web/src/lib/components/workbench/JobChip.svelte
a9b75b36f0c2f061caf61a3fe897ad4799a2cbeb3fbdf4cd90c907321a9e3869 /opt/aigc-home/app/calliope-web/src/lib/components/workbench/PromptBlock.svelte
6a01205bf0e4118f38a988f359650a0e9c3522567e7328e6783a32abb7a4ce03 /opt/aigc-home/app/calliope-web/src/lib/components/workbench/ScriptStagePanel.svelte
0cc25c48ed8bcc5d1312cae5da8ec8d43a719b88de1e9f1aecc34d216884ffbd /opt/aigc-home/app/calliope-web/src/lib/components/workbench/ShotStagePanel.svelte
b64c479ec6e262c10607fd7bb9c5e3096e7ec9870369754e288f95293abb6fb2 /opt/aigc-home/app/calliope-web/src/lib/components/workbench/StageStepper.svelte
afd4ffe76429630d4ee6e30c06cc9e121a637e252bff78b0ff902990b1827c66 /opt/aigc-home/app/calliope-web/src/lib/components/workbench/StoryboardStagePanel.svelte
1b23da03f59c5d186e7e760c606e11e7fe22cb37ae8d90cfb813efc0dad9d597 /opt/aigc-home/app/calliope-web/src/lib/components/workbench/StyleStagePanel.svelte
58407122bbb4c9cf4a4d3c280b7e27af7a8a51141ce2dd3eabe91644ea7843f7 /opt/aigc-home/app/calliope-web/src/lib/components/workflows/WorkflowBatchImportWizard.svelte
77a2ac092a4c0bbb1f952e191b88704edd5815b783f439a022cd4f4baca5d16a /opt/aigc-home/app/calliope-web/src/lib/components/workflows/WorkflowLibrary.svelte
d193924f8d50ac86de09cb8b1d44511c637811dbe5fb12bf8826633f5cac6d88 /opt/aigc-home/app/calliope-web/src/lib/components/workflows/WorkflowMetaDialog.svelte
f7e318b7c2be62b8075568eaa1cfebbb002a5ac554cf26b7c6367ee6429de224 /opt/aigc-home/app/calliope-web/src/lib/dbtime.ts
cdbcb72007dbc24c5fc13377aeeae8851644b306eb376e872155fda179c7acd3 /opt/aigc-home/app/calliope-web/src/lib/durationBudget.ts
161c8c35241d55c6643f946e092d65748b8d55c6fdf5dad0fadf6e37e4fb3208 /opt/aigc-home/app/calliope-web/src/lib/endpointKind.ts
d8cfc840dcccffadf3546d07ed1fe32c43e86b85599149e756ea80871ca3706a /opt/aigc-home/app/calliope-web/src/lib/events.ts
9ae9634200ef0aaec1050a3638b32e2e1cf1dfdae089dca067a35cd2155fa7ec /opt/aigc-home/app/calliope-web/src/lib/formOptions.ts
e22719d77911c34c6d49ce6fbffb655fbb9053bbe0a471cfd7bb36804e1adb89 /opt/aigc-home/app/calliope-web/src/lib/i18n.svelte.ts
b48252457d2ce992db05b82dad361f3f971504a5b7c4e1f78f7e4f59eb062afc /opt/aigc-home/app/calliope-web/src/lib/i18n/de.ts
8cc8df905076d916cbde2ba83ad3202ab3cfb87e38d4d2119023404dfaa6b7fc /opt/aigc-home/app/calliope-web/src/lib/i18n/en.ts
837bf76c0ae34be1f6348bea071f47227ca0573d4c466574ad2ed99eb6db7df0 /opt/aigc-home/app/calliope-web/src/lib/i18n/es.ts
332522881a6fc1ea9d227745f681ce484ae75367d56e61bcdad13a17487f3ce3 /opt/aigc-home/app/calliope-web/src/lib/i18n/fr.ts
b2c9d3b530fe388781f6b85dbef4b9c71ff0652a42ad8faf6e7864e8ef5438c2 /opt/aigc-home/app/calliope-web/src/lib/i18n/ja.ts
89862053863f268a6f3dff975be041b526ef6b5bb937c9660f97b4e80ce241e8 /opt/aigc-home/app/calliope-web/src/lib/i18n/ko.ts
42b332e23e881092977434ee0bd446bb410834de262d96f13e0a7150e0da4bb4 /opt/aigc-home/app/calliope-web/src/lib/i18n/zh.ts
60895e55f0860fba53ef9d80981b7ec13bc1d17c0ab5dd7d4aeb5ec930131b76 /opt/aigc-home/app/calliope-web/src/lib/ids.ts
8354c545da56aa7d05edba6820614611db5c2846407f9ec69e4d2b0da4e29814 /opt/aigc-home/app/calliope-web/src/lib/jobLabels.ts
b17722f478a633982c02191e02ef908297357d33b15d01944f59dce0c13c3ed9 /opt/aigc-home/app/calliope-web/src/lib/jobProgress.ts
536733c170cc5266cd1f5d0a0eeda9a2a56c8f8c9613058c32a7a9d103063842 /opt/aigc-home/app/calliope-web/src/lib/jobStatus.ts
d57e21fb3549b5c773073cb7d4c9b62bc79c16a9fa24d5d488450940be27ed9e /opt/aigc-home/app/calliope-web/src/lib/media/exportFrame.ts
81762ea6ec15806d62298b838c48919744a6431df8a3300e28b6d214892b842e /opt/aigc-home/app/calliope-web/src/lib/media/lastFrame.ts
345d9b8f82fb4893ecc2a0866581838e7b5d45e45259c40d40234d915545f929 /opt/aigc-home/app/calliope-web/src/lib/missingOutputs.svelte.ts
3975f02ba220365dd32d4053d199800355c80c1a5d2a26942779df304fa042b8 /opt/aigc-home/app/calliope-web/src/lib/promptTemplates.ts
57900f9c597c868a40ff6d142ef3d3b0e3ef364f4adddd967414688ecfbd7293 /opt/aigc-home/app/calliope-web/src/lib/reduceMotion.svelte.ts
abd820a9a7a47f8eede494583eaa18b5f34b1abfe73260a6fb311f2e8ea01494 /opt/aigc-home/app/calliope-web/src/lib/referenceAgent.ts
c037acebc7b878e00f680e65c5bfd6934b9eb8f7cc2e7d8c58afc4c87f93a927 /opt/aigc-home/app/calliope-web/src/lib/shot/api.ts
7495bcb0d98e4dd91ad72c132b31724d97f226ae0420f862a5791d20888e2526 /opt/aigc-home/app/calliope-web/src/lib/shot/calibration/compositionPresets.ts
e9301e261d4a12184f820ee0b0a547bf8c061bb65c58ee2a1967d89537b0c9b2 /opt/aigc-home/app/calliope-web/src/lib/shot/calibration/shotAxes.ts
24ebe82dc55cc53cf3dfe0bc40275a9613353dc9116539acc6faf0b0aa597d80 /opt/aigc-home/app/calliope-web/src/lib/shot/calibration/shotSolver.ts
5de1596d7827248cd5be5e576a54848d18b63225061beeccd2bd144ed678a2ea /opt/aigc-home/app/calliope-web/src/lib/shot/components/CaptureStrip.svelte
d8450affdb98ce82325f9b0f860ca1f5493a0404c4f2dc52792758f25e7e145e /opt/aigc-home/app/calliope-web/src/lib/shot/components/ScenePanel.svelte
abbc4622691782aa8ccddd55e43324642d715f2c5f78d32ef4f8d6ad1c86be6f /opt/aigc-home/app/calliope-web/src/lib/shot/components/ShotInspector.svelte
810a24fb53b6458747ca981039fcde44a4b2f3b89242ec6f2be96e5f11e1d95d /opt/aigc-home/app/calliope-web/src/lib/shot/components/ShotSessionRail.svelte
6c6b55ab12443cb7086a41448c2245c526784af60df5b8d3078f046de5cdc574 /opt/aigc-home/app/calliope-web/src/lib/shot/components/ShotViewport.svelte
cc871911957ac769829dd487095488e0cf4cdbf44f99ace78df906fa351ae71c /opt/aigc-home/app/calliope-web/src/lib/shot/components/TimelineStrip.svelte
6b18ef874ccd3c3508ac35788a8b9a1e924a79b4a7af9f7b9373e4f0b383e7e3 /opt/aigc-home/app/calliope-web/src/lib/shot/components/ViewportToolbar.svelte
7de37792057307fd93a2df295308ccef609d9ec16ed17793eb8e2faf2295c626 /opt/aigc-home/app/calliope-web/src/lib/shot/helpers/cameraUtils.ts
2a07e4133ccc93420b64fcde15c43638219359cf42b1c8d9c849c44eb85cd2fe /opt/aigc-home/app/calliope-web/src/lib/shot/helpers/jointConfig.ts
a64e0938354f7363195285dfc564949b3dfac5f0be254177ce1378fdc00bea59 /opt/aigc-home/app/calliope-web/src/lib/shot/helpers/mannequinFactory.ts
0f94e29cb1d0b69d785dfaa326cee6a993c60472328f16664a5291d7e6caecf8 /opt/aigc-home/app/calliope-web/src/lib/shot/helpers/mirror.ts
d6569792a3a9e2dbff0f82b527e4d3b9d35874864c019f5dbc7aa8e4da947b25 /opt/aigc-home/app/calliope-web/src/lib/shot/helpers/posture.ts
18385f2f998666e864d5b67b6531b85e6d42c0d6de021a261f11199cb78b2fec /opt/aigc-home/app/calliope-web/src/lib/shot/helpers/primitiveFactory.ts
480d36f1dabed4944b34471ed55d869e28f6e81875e6558c62a0b75d694cb783 /opt/aigc-home/app/calliope-web/src/lib/shot/motion/sampleTrack.ts
7af90bd8c957299c282e12fca917f8fa56d2069b4ded99cc4798d1e730506f1c /opt/aigc-home/app/calliope-web/src/lib/shot/shotStore.svelte.ts
f17a0e08c4c42336a8983f46f4970896daee72577e38fd4739f322d99c20e27d /opt/aigc-home/app/calliope-web/src/lib/shot/types/mannequin-js.d.ts
d9c276a046c0ba7326d65293a56711913b8be107439470342220e98c6736af0b /opt/aigc-home/app/calliope-web/src/lib/skillFrontmatter.ts
0a2075b0f882d9b354ed3d7a0a8913d9d9f2e0e380ad8ea4f655fc89f1986d97 /opt/aigc-home/app/calliope-web/src/lib/storage.ts
07a9b47b8d793c23bc490b23d4bc7e4e5083e3780851525edc60c92118c43716 /opt/aigc-home/app/calliope-web/src/lib/toast.ts
baa828f60b258b06032da3a2d289d353e1ba61b00bc3b06852d184936663d3ee /opt/aigc-home/app/calliope-web/src/lib/wideLayout.ts
363320f14113146f617b12ead186db995fa99de71ad04be336ea1cb3e6f28453 /opt/aigc-home/app/calliope-web/src/routes/+layout.svelte
c9a6362f526374fe88546908b976f897bccc8bb1b12a3797e176afc0d37a1172 /opt/aigc-home/app/calliope-web/src/routes/+layout.ts
6c6481bf7b09e2e74400a112b66ad2430be6cdc843ebdc1c20d370bdacc174d8 /opt/aigc-home/app/calliope-web/src/routes/+page.svelte
82665e45d52d9b8f182fe7395e4c8b2726beb62320de2f581b5b73d9c9d3987f /opt/aigc-home/app/calliope-web/src/routes/+page.ts
bce9c599232e064eb946d9090391066ee8adcdc269797b8a874cca8effa4427d /opt/aigc-home/app/calliope-web/src/routes/agents/+page.svelte
58ca28942740b2ff08d6416473a2e8038f8a660017ce0b8b0d9ab3bb44453659 /opt/aigc-home/app/calliope-web/src/routes/agents/+page.ts
9949bca2d32eac10b2f9dfe91d0f6b539205259fa15b0a4c6443a6c1e600dc35 /opt/aigc-home/app/calliope-web/src/routes/app.css
e7212b5a4b2eedcd6f107e5fcfb5ae8a2ded5bce3d12f7c279f6dfa4c0007660 /opt/aigc-home/app/calliope-web/src/routes/build-scene/+page.svelte
f21b64d615dfbac548e7298fe8d130dfc7e0e148ffb00d6945ac470da791e002 /opt/aigc-home/app/calliope-web/src/routes/build-scene/+page.ts
370b52d8e87153b57ef73eed1846a770eaf9b7c356b310a3fdcc8d3c295a403e /opt/aigc-home/app/calliope-web/src/routes/canvas/+page.svelte
f21b64d615dfbac548e7298fe8d130dfc7e0e148ffb00d6945ac470da791e002 /opt/aigc-home/app/calliope-web/src/routes/canvas/+page.ts
1e0197911d9752767374ca1fa1157ea534f24553fbea1e9db5fde0294af4f034 /opt/aigc-home/app/calliope-web/src/routes/canvas/[id]/+layout.svelte
23de1dcb2b94ba7050764b7bfa04fd447aa36f50241f867783646ccb669a8658 /opt/aigc-home/app/calliope-web/src/routes/canvas/[id]/+page.svelte
f21b64d615dfbac548e7298fe8d130dfc7e0e148ffb00d6945ac470da791e002 /opt/aigc-home/app/calliope-web/src/routes/canvas/[id]/+page.ts
28c91d13beac10eba7964e562e90c8c4a3ec2652e69710970cd4733e9d42e1e5 /opt/aigc-home/app/calliope-web/src/routes/connections/+page.svelte
bc76b47b593f0ae5e6946df26524d70e46b939889ccec090dc27a47cf71af140 /opt/aigc-home/app/calliope-web/src/routes/image/+page.svelte
743d590d5e52e74a5b209448759c603c5b1ee5ae6540916354d496820c7acd7f /opt/aigc-home/app/calliope-web/src/routes/library/+page.svelte
58ca28942740b2ff08d6416473a2e8038f8a660017ce0b8b0d9ab3bb44453659 /opt/aigc-home/app/calliope-web/src/routes/library/+page.ts
5684b7f04e3eef40f349a5e98b22c6ebb05c9fa75bb4606a988b0f2ee65cf13a /opt/aigc-home/app/calliope-web/src/routes/models/+page.svelte
58ca28942740b2ff08d6416473a2e8038f8a660017ce0b8b0d9ab3bb44453659 /opt/aigc-home/app/calliope-web/src/routes/models/+page.ts
9f29a23ba5aed307d4f28c7091723269e6583d53357805f7e8893d468832fc53 /opt/aigc-home/app/calliope-web/src/routes/playground/+page.svelte
f21b64d615dfbac548e7298fe8d130dfc7e0e148ffb00d6945ac470da791e002 /opt/aigc-home/app/calliope-web/src/routes/playground/+page.ts
79812eaac33b12b43f539f0b957ebe4aeb64cf80eac03741ebfd4ba1cbf76335 /opt/aigc-home/app/calliope-web/src/routes/project/[id]/+page.ts
f6ba8f210c2b8db20d401c57b6c84119ebda467b15ad9e41e8ba5ae89477354d /opt/aigc-home/app/calliope-web/src/routes/projects/+page.ts
675613be31a3a5f95cb8675a42790425f4f864380d6b3c141ab028f5069fc9fa /opt/aigc-home/app/calliope-web/src/routes/settings/+page.svelte
58ca28942740b2ff08d6416473a2e8038f8a660017ce0b8b0d9ab3bb44453659 /opt/aigc-home/app/calliope-web/src/routes/settings/+page.ts
3c0f1d1b8d92c7b3e6372a83aaf5b3318442d135eb0de267cebfefb795d8ae49 /opt/aigc-home/app/calliope-web/src/routes/tasks/+page.svelte
f2a395573851fe6916670ac392bedceaa5f93312c6560a33470b661e80132329 /opt/aigc-home/app/calliope-web/src/routes/video/+page.svelte
32aa5a552f98d3a2ee487ed236de09daea24bd33ccf97f48bbe1f29fc639abbe /opt/aigc-home/app/calliope-web/src/routes/workbench/+page.svelte
58ca28942740b2ff08d6416473a2e8038f8a660017ce0b8b0d9ab3bb44453659 /opt/aigc-home/app/calliope-web/src/routes/workbench/+page.ts
31c0251a0f09a0540021a621410c084ff4d0b1fb9d409de0e461fde224f858db /opt/aigc-home/app/calliope-web/src/routes/workbench/[id]/+page.svelte
58ca28942740b2ff08d6416473a2e8038f8a660017ce0b8b0d9ab3bb44453659 /opt/aigc-home/app/calliope-web/src/routes/workbench/[id]/+page.ts
bc6dc994baaaf02c80ad390cb71965f2f15422704fa64c81f5b2c31f33b2ab13 /opt/aigc-home/app/calliope-web/src/routes/workflows/+page.svelte
f21b64d615dfbac548e7298fe8d130dfc7e0e148ffb00d6945ac470da791e002 /opt/aigc-home/app/calliope-web/src/routes/workflows/+page.ts
294450c3d45d4820d0bbf7f1c6e69aa0f3686efb6e3dbcdacf13e34e2d07b35e /opt/aigc-home/app/calliope-backend/src/calliope/routers/workbench.py
acd3ba6e0ab29b92f5133358398f3544baa0ce2bea03560004a42e812b6abc8a /opt/aigc-home/app/calliope-backend/src/calliope/static/index.html
acd3ba6e0ab29b92f5133358398f3544baa0ce2bea03560004a42e812b6abc8a /opt/aigc-home/releases/stage-HEL665-v5-35fd07a-20261015/build/index.html
+79
View File
@@ -0,0 +1,79 @@
660a6de4471ec76986e56f8d4467e4ed85a3ee5fdfc47f438410131c0f47f051 /opt/aigc-home/app/calliope-backend/src/calliope/__init__.py
b00189efb92d1f8f77fdd9b912e7357bd6a7baaff998f364d55aa0029f7f9417 /opt/aigc-home/app/calliope-backend/src/calliope/agent/__init__.py
7d0a16a798d89d7c8932c63698b8f4af91a61f7ec22b29f9e2efde75f06890b9 /opt/aigc-home/app/calliope-backend/src/calliope/agent/agents_registry.py
f3ae5d869628d1cf2347e6b216d60dc7d4ea4fb82945fb348180bd04aca1b5d2 /opt/aigc-home/app/calliope-backend/src/calliope/agent/asset_agent.py
45daa038f5dbfca19ec351e2e2c4480ed8abc3fa51adf62b46dff714221afe45 /opt/aigc-home/app/calliope-backend/src/calliope/agent/continuity.py
300f043668e003bdf98f73cce40ba0251d94356092e5ad1aafe29ee1d4c97e22 /opt/aigc-home/app/calliope-backend/src/calliope/agent/coverage_agent.py
2245f0e117e728382554155c8f3e93f5304913c595cf070d9ad29daafb9bd088 /opt/aigc-home/app/calliope-backend/src/calliope/agent/harness/__init__.py
077cebb46d3ecccc1336b9c92a5c82ad21cdd57e55644df9298a5495374bc09d /opt/aigc-home/app/calliope-backend/src/calliope/agent/harness/log.py
c9429604ecde92dfc95f463fcfbbf2dd0153d91fd3155696238a1713a2cb8dd6 /opt/aigc-home/app/calliope-backend/src/calliope/agent/harness/loop.py
5233ad11b93f072d456861829d0f856436a9f330c2e4870aa2dad15e3bfc117a /opt/aigc-home/app/calliope-backend/src/calliope/agent/harness/orchestrator.py
f9e2f2f5ab4084c53866621e9c17eb388fd6dd9b4cb06be6c067ee03490f4da5 /opt/aigc-home/app/calliope-backend/src/calliope/agent/harness/plugins/__init__.py
ab17c10c8e372ff94659047dd94b56bdeecf79aa88bce60821538ab4d5e4d875 /opt/aigc-home/app/calliope-backend/src/calliope/agent/harness/plugins/canvas.py
1e7d44d9c9d8031bca9d3537b2d96d05bf9087e64a3ff6a33eee76c09001157e /opt/aigc-home/app/calliope-backend/src/calliope/agent/harness/plugins/comfy_mcp.py
03c9041e6cc8c443e56d2e0e09effa15107ad7b6bc4048e6b56fdfccd623a2d7 /opt/aigc-home/app/calliope-backend/src/calliope/agent/harness/plugins/interaction.py
7fe0366e33a1600766914a0b7bcc2bc0968bb39850370dec35697055ba8e8c1a /opt/aigc-home/app/calliope-backend/src/calliope/agent/harness/plugins/memory.py
58cc3518674cd689eade1c2d401d3fb943c5f2429af66da537b6b55ed00d95df /opt/aigc-home/app/calliope-backend/src/calliope/agent/harness/plugins/render.py
15d479331934ed05c23e19f24b131e566ef528550ab9720fd570d4b1bf3e977b /opt/aigc-home/app/calliope-backend/src/calliope/agent/harness/plugins/script.py
e9f736490d5ef6951ee0ce2ace3516bc2f6902c6fa3b1ef6ea9b6e7123f5f8d1 /opt/aigc-home/app/calliope-backend/src/calliope/agent/harness/plugins/shot_builder.py
ca11d12eb274a6852af2d091dbe453df582543cfb2d03051528bc629c8f0d024 /opt/aigc-home/app/calliope-backend/src/calliope/agent/harness/plugins/skills.py
8c49aea52a52a59e31269c8e79c988f4a1b3d676c23040f6506fab94789e4a20 /opt/aigc-home/app/calliope-backend/src/calliope/agent/harness/plugins/story.py
5dc7f0e6db53eaebe523b1b22b0e77ef06683c9182a52be7ab54f3c8d9d66395 /opt/aigc-home/app/calliope-backend/src/calliope/agent/harness/plugins/system.py
2a2d4606539c42f2f3acb7b93c73f571b66f53d4ead934bb1d47547825226978 /opt/aigc-home/app/calliope-backend/src/calliope/agent/harness/plugins/workspace.py
7001d912ae819bd25a27db28cffb77b62c53ec28eae9bef320d46d967d195a81 /opt/aigc-home/app/calliope-backend/src/calliope/agent/harness/policy.py
09e935288ad6c5c3579c26e948beac6d14df74978334fe4ffa4b000e8a600edb /opt/aigc-home/app/calliope-backend/src/calliope/agent/harness/prompts.py
25e0716d36a6598fe516d2d4838cfe54eedf00f390385ede5c252edc3a4a94c0 /opt/aigc-home/app/calliope-backend/src/calliope/agent/harness/registry.py
0495ee7f9ffefe13256688e47b23f68d6361bbef527b7ed4a5bc7f4a8998e1b8 /opt/aigc-home/app/calliope-backend/src/calliope/agent/harness/runner.py
90405bbb07c7cbe1283ead1ae901c8183756a93667e71f4ce585c0a543853198 /opt/aigc-home/app/calliope-backend/src/calliope/agent/harness/tools.py
5d225d80e8886e04e9b02d18b0bae358b65f4e66beec1280c4f500e761b1d752 /opt/aigc-home/app/calliope-backend/src/calliope/agent/llm.py
beadb0e82e8eb47356f81d81ff41271da97cc51c90707c89ee2908ed095d0380 /opt/aigc-home/app/calliope-backend/src/calliope/agent/prompts.py
feffad04e8a668d4426fc6f9021213a9d1122221e685aa5ba30bcaa9611faf45 /opt/aigc-home/app/calliope-backend/src/calliope/agent/script_agent.py
483c6658b7573382f3a83add57b9f7e27e83ff1388bf0b900837f81c2b47c56e /opt/aigc-home/app/calliope-backend/src/calliope/agent/skills_store.py
2caa50c0e246b542f331c7a52778b5c13331cf3623a431c14bbdc4362674f138 /opt/aigc-home/app/calliope-backend/src/calliope/agent/video_agent.py
1d3488bcc2eadcd8a8f2e84ab4f6e9bdf52096b8af637c4da399db041d5a0543 /opt/aigc-home/app/calliope-backend/src/calliope/comfyui/__init__.py
e6faad052e3139d921936c7a73e2ee7fb55a3bb3c9fc256535dca5fa6f01ac14 /opt/aigc-home/app/calliope-backend/src/calliope/comfyui/client.py
f8bf4d1b91d959044de1dbc4aeb11bf0bb521f51f1928d36a46c670381a08ed7 /opt/aigc-home/app/calliope-backend/src/calliope/comfyui/dry_run.py
fc99a623850b581ae38843bbc3baf1b71ca9fd633e6103a34cb809a345e5a647 /opt/aigc-home/app/calliope-backend/src/calliope/comfyui/endpoints.py
da82aad77fa561fea5fd6bdb553feaff61ce753a890eea2caa146a5813981952 /opt/aigc-home/app/calliope-backend/src/calliope/comfyui/lint.py
12848b11ca346ee3c7703becb96469609b1b68612405a7be3cb7e8fca04291b5 /opt/aigc-home/app/calliope-backend/src/calliope/comfyui/metadata.py
460d9463dabc8b055d1bdd81e5b908ad8bfd198da1560e3fe0f859c9cd1dfdf6 /opt/aigc-home/app/calliope-backend/src/calliope/comfyui/output_map.py
d300c6f33f30bc9a1d9a4c5163b3a2f71795f9e349849205ec42f85f3a202824 /opt/aigc-home/app/calliope-backend/src/calliope/comfyui/parser.py
8ac1417f0f78d952cdb219b10811a6ed7d4ce3bb0d561708858c4a8f5d15b45d /opt/aigc-home/app/calliope-backend/src/calliope/comfyui/patcher.py
6629961d33204cbe5b3fc3d8a5a0a4de0c86d235e73f542d191429cf7f4bd2ec /opt/aigc-home/app/calliope-backend/src/calliope/comfyui/preflight.py
4ca33343fc3f20e0fb82558cb428548f6415af04fb90213a694fb759515732f2 /opt/aigc-home/app/calliope-backend/src/calliope/comfyui/profiles.py
21b024f44a3cba96db1b95a4c3a82591e6af921a31756378970da506ca0f9acf /opt/aigc-home/app/calliope-backend/src/calliope/comfyui/registry.py
355aca29ad4283f3cc600181fc0397cd06f8a63d3fe537b6fd10fc92bfcbe5a9 /opt/aigc-home/app/calliope-backend/src/calliope/comfyui/roles.py
adc34e80cca7a2b38cc834a713c9e64001caac84413af288389e5554e304bab6 /opt/aigc-home/app/calliope-backend/src/calliope/comfyui/smart_fill.py
ebb91f0d291b042d74a44019cfd69dcd553b0f0f96277b7795e52d4dbb7201d1 /opt/aigc-home/app/calliope-backend/src/calliope/comfyui/ui_convert.py
db00a79a92d0add6ea3c2509d629c36cbe457c667e388fff50eeec7158d5b9d8 /opt/aigc-home/app/calliope-backend/src/calliope/comfyui/ui_widget_orders.py
c27fca69a604705b06809fdd692975ca0e7a08d05d558172962f8910a6a0491d /opt/aigc-home/app/calliope-backend/src/calliope/comfyui/validate.py
94cda57274c512feac82ab3c97d9c680abf6706009727d6a6791cef690994001 /opt/aigc-home/app/calliope-backend/src/calliope/config.py
9c404874ccbf1d3d49ab509b112f52ecd6a037488f3b0e05e5a08c5ad74123dd /opt/aigc-home/app/calliope-backend/src/calliope/db.py
4b3f2620e25c17bf9587beda3b5e73284bf83c6b5fb6beaf1c6e73a73dc560f3 /opt/aigc-home/app/calliope-backend/src/calliope/events/__init__.py
a9be4db2e810135a3482432316925b0bed8b2fab9e1395333831b7f5246f987b /opt/aigc-home/app/calliope-backend/src/calliope/events/bus.py
e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 /opt/aigc-home/app/calliope-backend/src/calliope/export/__init__.py
cabc42a31999fa1e185b07c74f09b303f793a809127ce6039547c86657d9491b /opt/aigc-home/app/calliope-backend/src/calliope/export/runner.py
ee776991e7fbeef9d0a370a734bb9428e015ef9f18b0cbf8f3a8e772614922d9 /opt/aigc-home/app/calliope-backend/src/calliope/main.py
ff156ab234a8ea57a120e31c83baa591e613fa776d74cf29f2e75468bcff4e71 /opt/aigc-home/app/calliope-backend/src/calliope/models/__init__.py
81bbb9b938d452f41369982b7434520d173ff68b0242278c602f5d489c00527a /opt/aigc-home/app/calliope-backend/src/calliope/models/schemas.py
e4a4676265849f0dce021abccc1e7cc9ae6605a60eac8cc7e0a47246ff56bfa8 /opt/aigc-home/app/calliope-backend/src/calliope/poses.py
dbe2691f035386e14a3be26725cfba67baf7f6f712fc0061740ea920a05d9f4c /opt/aigc-home/app/calliope-backend/src/calliope/queue/__init__.py
22724a06ad9d6fced7572d52392cf2d22c793614097dbab42be9c1bc373b2ab0 /opt/aigc-home/app/calliope-backend/src/calliope/queue/manager.py
ee3e91bf7e330ff1981aece7fc4bb475acfd71153ab59db99736ac765a57ce2d /opt/aigc-home/app/calliope-backend/src/calliope/queue/worker.py
1805a0888c8dc007500cc1e24030a03555c6619e6fa56f937844831995cba5b8 /opt/aigc-home/app/calliope-backend/src/calliope/queue/workflow_test.py
cbacc8761833b64aa017a3df2d933c3897f9f19997044efaea49f206ec1b205a /opt/aigc-home/app/calliope-backend/src/calliope/routers/__init__.py
780483a9389c939d632a79e248e58053e0c468f65a27b0a90337412cc0fe1ec8 /opt/aigc-home/app/calliope-backend/src/calliope/routers/agent.py
315b6318864370449c3570ae705046501f696ac86cb370de189b05be26d79a05 /opt/aigc-home/app/calliope-backend/src/calliope/routers/assets.py
a05c24f6c26d8a8432e8c73deb8acfb1e71fa4f611c10e80ba19dd6c10985e9d /opt/aigc-home/app/calliope-backend/src/calliope/routers/canvas.py
6195a37ef48e80a7f423fefcc2b2939c2270542ca4ce81ef58ca06da356b312d /opt/aigc-home/app/calliope-backend/src/calliope/routers/comfyui.py
be7f140608380aa93d23893ba97b222fcf5b2a288ca81e5ccb53731875d2cdff /opt/aigc-home/app/calliope-backend/src/calliope/routers/events.py
45c1952d866f76ab84cbc221541250ad6e9ae79d06d4e7c7cb4176d395f9d196 /opt/aigc-home/app/calliope-backend/src/calliope/routers/jobs.py
3827af29c2645baee4a9ccbbc1d77155e830585bd033955b061bc320b6fb7de3 /opt/aigc-home/app/calliope-backend/src/calliope/routers/library.py
ce4f8f21cff507cafdeece1f9427b02172c550da53327095167964cb97ca2145 /opt/aigc-home/app/calliope-backend/src/calliope/routers/playground.py
c36d32df6571ecb1bf3bd17be48e47c0cc3864b79db51d764dcf95befbe78383 /opt/aigc-home/app/calliope-backend/src/calliope/routers/projects.py
398cf96846e60c8e60907121b4a21f9086c3b03f86e0ad2ad4082acaa9fb4678 /opt/aigc-home/app/calliope-backend/src/calliope/routers/scenes.py
a4fedd5c2285ffa0d28b4176e9014d46c27d83b4150a81b2de63ef9b921865cd /opt/aigc-home/app/calliope-backend/src/calliope/routers/settings.py
bd235fde191c512e0592e269c8d1c8d8784ef8bce07de6d62a93626fac249744 /opt/aigc-home/app/calliope-backend/src/calliope/routers/shots.py
64b3b8e96beaf6b5fa50114188f5f101c09794dcfc5b47ce79174f43445b4bbf /opt/aigc-home/app/calliope-backend/src/calliope/routers/story.py
294450c3d45d4820d0bbf7f1c6e69aa0f3686efb6e3dbcdacf13e34e2d07b35e /opt/aigc-home/app/calliope-backend/src/calliope/routers/workbench.py
455073355ada5eb2807f90496ff3a28b3e1f9f06bbc9e262a8da354d6aec15ae /opt/aigc-home/app/calliope-backend/src/calliope/routers/workflows.py
+49
View File
@@ -0,0 +1,49 @@
{"image_wf": 1, "video_wf": 2, "endpoint": "e9c0ef15-ab9a-4956-81d4-2d42425feea2"}
{'dual': 3, 'h3': 4, 'project': 1}
RUN e2e/hel728.cjs
PASS 默认主题为夜片场
PASS night 保留原编辑组件和正反向草稿
PASS day 保留原编辑组件和正反向草稿
PASS grade 保留原编辑组件和正反向草稿
PASS bluehour 保留原编辑组件和正反向草稿
PASS 刷新恢复主题和草稿
PASS 方向键移动主题选项焦点
PASS Esc 关闭并恢复入口焦点
PASS 创作台兼容入口保留全部参数及片段
PASS 隔离真实队列已收到任务
PASS night 上传素材和运行中编辑状态保留
PASS day 上传素材和运行中编辑状态保留
PASS grade 上传素材和运行中编辑状态保留
PASS bluehour 上传素材和运行中编辑状态保留
PASS 切主题后原任务完成且产物归属不变
PASS 切主题保留视频节点和暂停状态
PASS 切主题保留智能体任务输入组件和草稿
PASS 切主题保留未保存设置
PASS 1440,900 night 导航入口和四主题完整可见
PASS 1440,900 day 导航入口和四主题完整可见
PASS 1440,900 grade 导航入口和四主题完整可见
PASS 1440,900 bluehour 导航入口和四主题完整可见
PASS 1366,768 night 导航入口和四主题完整可见
PASS 1366,768 day 导航入口和四主题完整可见
PASS 1366,768 grade 导航入口和四主题完整可见
PASS 1366,768 bluehour 导航入口和四主题完整可见
PASS 1280,720 night 导航入口和四主题完整可见
PASS 1280,720 day 导航入口和四主题完整可见
PASS 1280,720 grade 导航入口和四主题完整可见
PASS 1280,720 bluehour 导航入口和四主题完整可见
PASS 模型连接入口保留页签参数
PASS 旧项目链接保留阶段参数
PASS 全局搜索保留 视频创作
PASS 全局搜索保留 大语言模型
PASS 全局搜索保留 试验台
PASS 全局搜索保留 三维搭景
PASS night 首次绘制前恢复主题及底色
PASS day 首次绘制前恢复主题及底色
PASS grade 首次绘制前恢复主题及底色
PASS bluehour 首次绘制前恢复主题及底色
PASS 存储被禁用仍可切换且显示提示
PASS 存储被禁用保留输入
PASS 无效存储回退夜片场
PASS 浏览器无运行错误
TOTAL 44
FAILED SCRIPTS []
+47
View File
@@ -0,0 +1,47 @@
# HEL-728 B0 自测与已知限制
## 通过
- `npm run check`:0 错误;1 个第五版已存在的视频字幕警告。干净第五版副本同为 0 错误、1 警告。
- `npm test`:存储 SSR 安全、局域网随机 ID、技能正文、视频尾帧算法四组既有校验全部通过。
- `npm run build`:静态构建成功;`git diff --check` 通过。
- 本批全部修改的前端文件:Prettier 检查通过。
- `e2e/hel728.cjs`:**44 项通过、0 个浏览器运行错误**。在一次性本地后端、假 ComfyUI、假 LLM 上检查;真实 HTTP 上传首尾帧和队列生成,四主题之间切换时原编辑 DOM 节点、正反向草稿、已上传文件、运行中的任务 ID/工作流归属不变,完成后仍可显示视频且默认暂停。另检查智能体输入、未保存设置、刷新恢复、方向键/Enter/Esc 与焦点返回、非法/禁用存储、旧项目链接及完整 URL 来源上下文。
- 框架脚本被阻止时检查四个主题:head 脚本与阻塞样式表独立恢复主题与正确页面底色,没有依赖框架完成后才着色。
- 人工查看 1440×900、1366×768、1280×720 截图:主题卡片完整可见、侧栏不溢出、浅色文字与面板正常。截图采用减少动效模式消除过渡帧;产品开关与系统减少动效沿用既有机制。
## 存量失败及原版对照
本批未修改 `calliope-backend`:`git diff --quiet 35fd07a -- calliope-backend` 退出 0。
后端全量 `pytest -q`:**834 通过、5 失败、6 跳过**。在 git archive 导出的干净 `35fd07a` 副本中,用相同解释器/依赖和 `PYTHONPATH=src` 单独复跑这五项,全部复现:
1. `test_agent_comfy_mcp.py::test_comfy_server_info_scoping`:`agent_events` 表不存在。
2. `test_continuity.py::test_h3_messages_bind_neighbor_shots_and_keep_file_slots`:Linux 上 Windows 路径的名称提取与断言不符。
3. `test_h3_profile.py::test_resolve_h3_references_prefers_form_files_over_story`:Linux 上 Windows 路径的名称提取与断言不符。
4. `test_scene_settings.py::test_preview_prompt_uses_form_references_not_story_cast`:同类 Windows 路径名称问题。
5. `test_script_scene_count.py::test_script_regenerate_keeps_expanded_count`:重新生成剧本返回 500,断言预期 200。
全仓 `npm run lint`:原版 68 个文件的 Prettier 失败,本批剩余 63 个,均为未修改文件;ESLint 本身缺少 v9 配置文件,原仓未提供。没有为本批新增独立 lint 规则或格式覆盖。完整输出附在本目录。
现有两份端到端脚本均在本批和干净第五版副本中失败,未删断言或改正式功能迎合旧测试:
- `e2e/reference/check.cjs`:前 8 项通过,随后等待第五版已移除的 `.result-item` 历史栏超时;当前 UI 用作品画布与独立生成历史弹层。
- `e2e/reference/check-copy-fallback.cjs`:等待旧版「编辑内容并带回项目」按钮超时;当前第五版已重排成果编辑流程。
这些脚本没有被本批修改;原版和本批失败日志都保留。不能据此宣称现有端到端全绿。
## 运行方式
安装仓库锁定依赖后,从仓库根目录运行:
```sh
# 指向环境已有的 Playwright 和 Chromium;按环境需要提供系统库路径。
PLAYWRIGHT_MODULE_PATH=/path/to/playwright-core \
CHROMIUM_EXECUTABLE_PATH=/path/to/chromium \
HEL728_TESTS=e2e/hel728.cjs python3 e2e/run-hel728.py
```
runner 检查端口空闲,保留上次一次性数据/证据到带时间戳的目录,再启动自己的本地服务;结束时停止自己创建的进程组。默认还会运行两份旧脚本并报告失败。用 `HEL728_WEB_DIR` 可将前端改为干净基线副本,完成同环境对照。
未验证真实 GPU/付费模型的生成质量;没有操作测试站用户资料或执行真实收费生成。B0 只验证主题与导航地基,B1–B5 的新页面布局、通用存入项目及最终全站动效不在本次完成范围。
+21
View File
@@ -0,0 +1,21 @@
"""Assemble measured HEL-743 search/header crops, sample left and actual right."""
from pathlib import Path
import json
from PIL import Image, ImageDraw
root=Path(__file__).resolve().parents[1]
out=root/'e2e/shots/hel743'
rows=json.loads((out/'results.json').read_text())['measurements']
for width in (1440,1366,1280):
entries=[r for r in rows if r['width']==width]
panel_width=1040
canvas=Image.new('RGB',(panel_width*2,4*164),(35,36,40))
draw=ImageDraw.Draw(canvas)
for index,r in enumerate(entries):
for column,side in enumerate(('sample','actual')):
x=column*panel_width+10;y=index*164+8;m=r[side]
draw.text((x,y),f"{width} / {r['theme']} / {side} | head {m['head']['width']}x{m['head']['height']} | input {m['search']['width']} | file {m['file']['width']}",fill='white')
im=Image.open(out/f"{width}-{r['theme']}-{side}.png")
for key,dy in (('head',26),('box',90)):
box=m[key];left=int(box['x']);top=int(box['y']);right=round(box['x']+box['width']);bottom=round(box['y']+box['height'])
canvas.paste(im.crop((left,top,right,bottom)),(x,y+dy))
canvas.save(out/f'compare-{width}.png')
+397
View File
@@ -0,0 +1,397 @@
// B0 state preservation and navigation checks on the disposable reference stack.
const { chromium } = require(
process.env.PLAYWRIGHT_MODULE_PATH || "playwright",
);
const assert = require("node:assert/strict"),
fs = require("node:fs"),
path = require("node:path");
const out = path.resolve("e2e/shots/hel728"),
f = JSON.parse(fs.readFileSync("e2e/shots/reference/fixtures.json"));
const WEB = "http://127.0.0.1:5173",
API = "http://127.0.0.1:8247";
const themes = ["night", "day", "grade", "bluehour"],
names = ["夜片场", "日光棚", "调色间", "蓝调时刻"];
const checks = [];
function ok(name, value) {
assert(value, name);
checks.push(name);
console.log("PASS", name);
}
async function change(p, id) {
await p.getByRole("button", { name: "主题", exact: true }).click();
await p
.locator(".choice")
.filter({ hasText: names[themes.indexOf(id)] })
.click();
await p.keyboard.press("Escape");
await p.waitForFunction(
(id) => document.documentElement.dataset.theme === id,
id,
);
}
async function waitJob(id) {
for (let i = 0; i < 120; i++) {
const j = (
await fetch(API + "/api/playground/jobs").then((r) => r.json())
).find((j) => j.id === id);
assert(j, "submitted job remains in queue");
if (j.status === "done") return j;
assert(!["failed", "cancelled"].includes(j.status), JSON.stringify(j));
await new Promise((r) => setTimeout(r, 200));
}
throw Error("job timed out");
}
(async () => {
const browser = await chromium.launch({
executablePath: process.env.CHROMIUM_EXECUTABLE_PATH,
headless: true,
args: ["--no-sandbox"],
});
const errors = [];
const modepath = "e2e/fake_mode.json",
saved = fs.existsSync(modepath) ? fs.readFileSync(modepath) : null;
try {
const context = await browser.newContext({
viewport: { width: 1440, height: 900 },
}),
p = await context.newPage();
p.on(
"pageerror",
(e) => (errors.push(e.message), console.error("PAGEERROR", e.stack)),
);
await p.goto(WEB + "/image?wf=" + f.dual);
await p.locator("#desk-prompt").waitFor();
ok(
"默认主题为夜片场",
await p.evaluate(
() => document.documentElement.dataset.theme === "night",
),
);
await p.locator("#desk-prompt").fill("切换主题时保留的半篇提示词");
await p.locator("#desk-negative").fill("反向草稿");
await p
.locator("#desk-prompt")
.evaluate((e) => (window.__promptElement = e));
for (const id of themes) {
await change(p, id);
ok(
id + " 保留原编辑组件和正反向草稿",
(await p
.locator("#desk-prompt")
.evaluate(
(e) =>
e === window.__promptElement &&
e.value === "切换主题时保留的半篇提示词",
)) && (await p.locator("#desk-negative").inputValue()) === "反向草稿",
);
}
await p.reload();
await p.locator("#desk-prompt").waitFor();
ok(
"刷新恢复主题和草稿",
(await p.evaluate(
() => document.documentElement.dataset.theme === "bluehour",
)) &&
(await p.locator("#desk-prompt").inputValue()) ===
"切换主题时保留的半篇提示词",
);
// Keyboard selection and focus return use the existing layered Modal.
const trigger = p.getByRole("button", { name: "主题", exact: true });
await trigger.click();
await p.locator(".choice").first().focus();
await p.keyboard.press("ArrowDown");
ok(
"方向键移动主题选项焦点",
await p
.locator(".choice")
.nth(1)
.evaluate((e) => document.activeElement === e),
);
await p.keyboard.press("Enter");
await p.keyboard.press("Escape");
ok(
"Esc 关闭并恢复入口焦点",
await trigger.evaluate((e) => document.activeElement === e),
);
// All source URL parameters survive the temporary gateway.
const q = new URLSearchParams({
kind: "video",
wf: String(f.h3),
from: "workbench",
project: String(f.project),
stage: "shot",
source_kind: "shot",
source_id: "s1",
label: "镜头一",
prompt: "上下文提示词",
negative: "上下文反向",
duration: "6",
mode: "image2video",
});
await p.goto(WEB + "/create?" + q + "#source");
await p.locator("#desk-prompt").waitFor();
ok(
"创作台兼容入口保留全部参数及片段",
new URL(p.url()).pathname === "/video" &&
[...q].every(([k, v]) => new URL(p.url()).searchParams.get(k) === v) &&
new URL(p.url()).hash === "#source",
);
await p.goto(WEB + "/video?wf=" + f.h3);
await p.getByRole("button", { name: "管理素材", exact: true }).click();
const first = p
.locator(".a-slot")
.filter({ has: p.getByText("首帧", { exact: true }) }),
last = p
.locator(".a-slot")
.filter({ has: p.getByText("尾帧", { exact: true }) });
await first.waitFor();
await first
.locator("input[type=file]")
.setInputFiles("calliope-web/static/refract/coast.jpg");
await last
.locator("input[type=file]")
.setInputFiles("calliope-web/static/refract/arch.jpg");
await first.locator("img").waitFor();
await last.locator("img").waitFor();
await p.locator("#desk-prompt").fill("上传和进行中任务保留");
const sources = [
await first.locator("img").getAttribute("src"),
await last.locator("img").getAttribute("src"),
];
await p.getByRole("button", { name: "完成", exact: true }).click();
await p.locator("#desk-prompt").evaluate((e) => (window.__videoPrompt = e));
fs.writeFileSync(
modepath,
JSON.stringify({ mode: "ok", delay_sec: 12, videos: true }),
);
const response = p.waitForResponse(
(r) =>
r.url().endsWith("/api/playground/generate") &&
r.request().method() === "POST",
);
await p.locator(".gen").click();
const job = (await (await response).json()).job;
ok("隔离真实队列已收到任务", !!job.id);
for (const id of themes) {
await change(p, id);
ok(
id + " 上传素材和运行中编辑状态保留",
(await p.locator(".media-token img").nth(0).getAttribute("src")) ===
sources[0] &&
(await p.locator(".media-token img").nth(1).getAttribute("src")) ===
sources[1] &&
(await p
.locator("#desk-prompt")
.evaluate(
(e) =>
e === window.__videoPrompt &&
e.value === "上传和进行中任务保留",
)),
);
}
const completed = await waitJob(job.id);
ok(
"切主题后原任务完成且产物归属不变",
completed.workflow_id === f.h3 && completed.output_paths.length > 0,
);
await p.locator(".artboard video").waitFor();
await change(p, "day");
ok(
"切主题保留视频节点和暂停状态",
await p
.locator(".artboard video")
.evaluate((v) => v.paused && !v.autoplay),
);
await p.screenshot({ path: path.join(out, "uploaded-video-state.png") });
await p.goto(WEB + "/agents");
await p.locator(".agent-composer textarea").fill("智能体任务输入草稿");
await p
.locator(".agent-composer textarea")
.evaluate((e) => (window.__agentElement = e));
await change(p, "night");
ok(
"切主题保留智能体任务输入组件和草稿",
await p
.locator(".agent-composer textarea")
.evaluate(
(e) =>
e === window.__agentElement && e.value === "智能体任务输入草稿",
),
);
// Preserve dirty settings separately from the appearance section.
await p.goto(WEB + "/settings");
await p.locator("input[type=number]").first().fill("2");
await change(p, "grade");
ok(
"切主题保留未保存设置",
(await p.locator("input[type=number]").first().inputValue()) === "2",
);
await p.getByRole("button", { name: "放弃", exact: true }).click();
await p.goto(WEB + "/settings?tab=appearance");
await p.locator(".choices").waitFor();
await p.emulateMedia({ reducedMotion: "reduce" });
for (const size of [
[1440, 900],
[1366, 768],
[1280, 720],
]) {
await p.setViewportSize({ width: size[0], height: size[1] });
for (const id of themes) {
await p
.locator(".choice")
.filter({ hasText: names[themes.indexOf(id)] })
.click();
ok(
`${size} ${id} 导航入口和四主题完整可见`,
(await p
.locator(".sidebar")
.evaluate((e) => e.scrollHeight <= e.clientHeight + 1)) &&
(await p
.locator(".choices")
.evaluate((e) => e.getBoundingClientRect().right <= innerWidth)),
);
await p.screenshot({
path: path.join(out, `${id}-${size[0]}x${size[1]}.png`),
});
}
}
await p.goto(WEB + "/model-connections?tab=llm&keep=hello#models");
await p.locator(".sidebar").waitFor();
ok(
"模型连接入口保留页签参数",
new URL(p.url()).pathname === "/models" &&
new URL(p.url()).searchParams.get("keep") === "hello" &&
new URL(p.url()).hash === "#models",
);
await p.goto(
WEB + "/project/" + f.project + "?stage=assets&keep=source#draft",
);
await p.locator(".sidebar").waitFor();
ok(
"旧项目链接保留阶段参数",
new URL(p.url()).pathname === "/workbench/" + f.project &&
new URL(p.url()).searchParams.get("keep") === "source" &&
new URL(p.url()).hash === "#draft",
);
// Explicit legacy tools remain searchable until B1/B2 relocate them.
await p.keyboard.press("Control+k");
for (const label of ["视频创作", "大语言模型", "试验台", "三维搭景"])
ok(
"全局搜索保留 " + label,
(await p.getByRole("option").filter({ hasText: label }).count()) > 0,
);
await p.keyboard.press("Escape");
// Block framework scripts to verify the remembered theme before hydration.
const colours = [
"rgb(7, 8, 12)",
"rgb(233, 228, 218)",
"rgb(27, 27, 28)",
"rgb(10, 16, 41)",
];
for (let n = 0; n < themes.length; n++) {
const cold = await browser.newContext();
await cold.addInitScript(
(id) => localStorage.setItem("aigc-theme", id),
themes[n],
);
const coldPage = await cold.newPage();
await coldPage.route("**/*", (route) =>
route.request().resourceType() === "script"
? route.abort()
: route.continue(),
);
await coldPage.goto(WEB + "/", { waitUntil: "domcontentloaded" });
ok(
themes[n] + " 首次绘制前恢复主题及底色",
await coldPage.evaluate(
({ id, colour }) =>
document.documentElement.dataset.theme === id &&
getComputedStyle(document.documentElement).backgroundColor ===
colour &&
!document.querySelector(".refract-shell"),
{ id: themes[n], colour: colours[n] },
),
);
await cold.close();
}
const reference = await browser.newPage();
await reference.goto(
"file://" + path.resolve("design/nocturne/reference.html"),
);
await reference.evaluate(() => {
document.documentElement.classList.add("capture", "reduce");
go("settings");
});
for (const [width, height] of [
[1440, 900],
[1366, 768],
[1280, 720],
]) {
await reference.setViewportSize({ width, height });
await reference.screenshot({
path: path.join(out, `reference-${width}x${height}.png`),
});
}
await reference.close();
const blocked = await browser.newContext();
await blocked.addInitScript(() =>
Object.defineProperty(window, "localStorage", {
get() {
throw new DOMException("blocked", "SecurityError");
},
}),
);
const b = await blocked.newPage();
b.on(
"pageerror",
(e) => (errors.push(e.message), console.error("PAGEERROR", e.stack)),
);
await b.goto(WEB + "/image?wf=" + f.dual);
await b.locator("#desk-prompt").fill("禁止存储仍可编辑");
await change(b, "day");
await b.getByRole("button", { name: "主题", exact: true }).click();
ok(
"存储被禁用仍可切换且显示提示",
await b
.getByText("浏览器禁止本地存储:本次有效,其他操作不受影响")
.isVisible(),
);
await b.keyboard.press("Escape");
ok(
"存储被禁用保留输入",
(await b.locator("#desk-prompt").inputValue()) === "禁止存储仍可编辑",
);
await b.screenshot({ path: path.join(out, "storage-blocked.png") });
const invalid = await browser.newContext();
await invalid.addInitScript(() =>
localStorage.setItem("aigc-theme", "invalid"),
);
const i = await invalid.newPage();
await i.goto(WEB + "/settings?tab=appearance");
await i.locator(".choices").waitFor();
ok(
"无效存储回退夜片场",
await i.evaluate(
() => document.documentElement.dataset.theme === "night",
),
);
if (errors.length) console.error("BROWSER ERRORS", errors);
fs.writeFileSync(
path.join(out, "results.json"),
JSON.stringify({ checks, errors }, null, 2),
);
ok("浏览器无运行错误", errors.length === 0);
fs.writeFileSync(
path.join(out, "results.json"),
JSON.stringify({ checks, errors }, null, 2),
);
console.log("TOTAL", checks.length);
} finally {
if (saved) fs.writeFileSync(modepath, saved);
else if (fs.existsSync(modepath)) fs.unlinkSync(modepath);
await browser.close();
}
})().catch((e) => {
console.error(e);
process.exitCode = 1;
});
+590
View File
@@ -0,0 +1,590 @@
// B1 integration: disposable FastAPI -> fake ComfyUI, real UI and HTTP only.
const { chromium } = require(
process.env.PLAYWRIGHT_MODULE_PATH || "playwright",
);
const assert = require("node:assert/strict"),
fs = require("node:fs"),
path = require("node:path");
const API = "http://127.0.0.1:8247",
WEB = "http://127.0.0.1:5173",
out = "e2e/shots/hel729";
fs.mkdirSync(out, { recursive: true });
const f = JSON.parse(fs.readFileSync("e2e/shots/reference/fixtures.json"));
const checks = [];
function ok(n, v) {
assert(v, n);
checks.push(n);
console.log("PASS", n);
}
async function api(p, b, method) {
const r = await fetch(API + p, {
method: method || (b ? "POST" : "GET"),
headers: { "Content-Type": "application/json" },
body: b ? JSON.stringify(b) : undefined,
});
if (!r.ok) throw Error(p + " " + (await r.text()));
return r.json();
}
async function wait(fn) {
for (let i = 0; i < 120; i++) {
const v = await fn();
if (v) return v;
await new Promise((r) => setTimeout(r, 150));
}
throw Error("condition timeout");
}
function fixtureDimensions(file) {
const b = fs.readFileSync(file);
return b.readUInt32BE(16) === 480 && b.readUInt32BE(20) === 720;
}
function mode(video = false, extra = {}) {
fs.writeFileSync(
"e2e/fake_mode.json",
JSON.stringify({
mode: "ok",
delay_sec: 0,
unique_filename: true,
videos: video,
image_file: path.resolve("calliope-web/static/refract/coast.jpg"),
...extra,
}),
);
}
async function submit(p, wf, video = false) {
mode(video);
const before = Math.max(
0,
...(await api("/api/playground/jobs")).map((j) => j.id),
);
await p.locator(".gen").click();
const j = await wait(async () =>
(await api("/api/playground/jobs")).find(
(j) => j.id > before && j.workflow_id === wf && j.status === "done",
),
);
await p
.locator(".result-title")
.filter({ hasText: "第 " + j.id + " 次生成" })
.waitFor();
return j;
}
async function shot(p, name) {
await p.screenshot({ path: out + "/" + name + ".png" });
}
(async () => {
const browser = await chromium.launch({
executablePath: process.env.CHROMIUM_EXECUTABLE_PATH,
args: ["--no-sandbox", "--disable-dev-shm-usage"],
});
const errors = [];
let p;
const saved = fs.existsSync("e2e/fake_mode.json")
? fs.readFileSync("e2e/fake_mode.json")
: null;
try {
const c = await browser.newContext({
viewport: { width: 1440, height: 900 },
});
p = await c.newPage();
p.setDefaultTimeout(15000);
p.on("pageerror", (e) => errors.push(e.message));
await p.goto(WEB + "/image?wf=" + f.dual + "#source");
await p.locator("#desk-negative").waitFor();
ok(
"旧生图入口进入统一创作台并保留工作流和片段",
new URL(p.url()).pathname === "/create" &&
new URL(p.url()).hash === "#source" &&
new URL(p.url()).searchParams.get("wf") === String(f.dual),
);
await p.locator("#desk-prompt").fill("一位女孩站在海岸,清晨柔光。");
await p.locator("#desk-negative").fill("模糊、畸形");
await p.locator(".rail-tools input[type=search]").fill("Qwen");
ok("检索工作流", (await p.locator(".workflow-pick").count()) === 1);
await p.locator(".row-actions button").first().click();
ok(
"置顶保留输入",
(await p.locator("#desk-prompt").inputValue()) ===
"一位女孩站在海岸,清晨柔光。",
);
await p
.getByRole("button", { name: "创作设置⌄", exact: true })
.first()
.click();
await p.locator(".modal-panel input[type=number]").first().fill("99");
await p.getByRole("button", { name: "取消", exact: true }).click();
await p
.getByRole("button", { name: "创作设置⌄", exact: true })
.first()
.click();
ok(
"参数取消不保存",
(await p
.locator(".modal-panel input[type=number]")
.first()
.inputValue()) === "42",
);
await p.locator(".modal-panel input[type=number]").first().fill("101");
await p.getByRole("button", { name: "保存设置", exact: true }).click();
const j = await submit(p, f.dual);
ok(
"真实图片生成写入正反向和参数",
JSON.stringify(j.payload).includes("101") &&
JSON.stringify(j.payload).includes("模糊、畸形"),
);
await p.locator("#desk-prompt").fill("这是当前输入,不随历史变化");
await p
.locator(".history-take")
.filter({ hasText: "#" + j.id })
.click();
ok(
"历史状态明确且不修改输入",
(await p.locator(".history-banner").isVisible()) &&
(await p.locator("#desk-prompt").inputValue()) ===
"这是当前输入,不随历史变化",
);
await shot(p, "1440-history");
await p.getByRole("button", { name: "返回当前创作", exact: true }).click();
await p.getByRole("button", { name: "展开提示词", exact: true }).click();
await p.locator("#desk-prompt").fill("长提示词描述。".repeat(600));
ok(
"长提示词可展开且生成键可见",
(await p.locator("#desk-prompt").evaluate((e) => e.clientHeight > 180)) &&
(await p.locator(".gen").isVisible()),
);
await shot(p, "1440-long-prompt");
await p.getByRole("button", { name: "收起提示词", exact: true }).click();
await p.locator("#desk-prompt").fill("这是当前输入,不随历史变化");
// Previously unseen workflow inherits semantic prompts, returning restores its own parameters.
await p.locator(".rail-tools input[type=search]").fill("文生图·回归");
await p
.locator(".workflow-pick")
.filter({ hasText: "文生图·回归" })
.click();
await p.waitForFunction(() => !document.querySelector("#desk-negative"));
ok(
"切换新工作流保留提示词",
(await p.locator("#desk-prompt").inputValue()) ===
"这是当前输入,不随历史变化",
);
await p.reload();
await p.locator("#desk-prompt").waitFor();
ok(
"刷新恢复切换后的工作流及草稿",
(await p
.locator(".model-selector")
.innerText()
.then((s) => s.includes("文生图"))) &&
(await p.locator("#desk-prompt").inputValue()) ===
"这是当前输入,不随历史变化",
);
await p.locator(".rail-tools input[type=search]").fill("Qwen");
await p.locator(".workflow-pick").first().click();
await p
.getByRole("button", { name: "创作设置⌄", exact: true })
.first()
.click();
ok(
"切回工作流恢复独立参数",
(await p
.locator(".modal-panel input[type=number]")
.first()
.inputValue()) === "101",
);
await p.keyboard.press("Escape");
await p.getByRole("tab", { name: "视频", exact: true }).click();
await p.locator("#desk-prompt").waitFor();
await p.locator(".rail-tools input[type=search]").fill("H3");
await p.locator(".workflow-pick").first().click();
await p.locator("#desk-prompt").fill("视频独立草稿");
await p.getByRole("button", { name: "管理素材", exact: true }).click();
const first = p
.locator(".a-slot")
.filter({ has: p.getByText("首帧", { exact: true }) }),
last = p
.locator(".a-slot")
.filter({ has: p.getByText("尾帧", { exact: true }) });
await first
.locator("input[type=file]")
.setInputFiles("calliope-web/static/refract/coast.jpg");
await first.locator("img").waitFor();
await last
.locator("input[type=file]")
.setInputFiles("calliope-web/static/refract/arch.jpg");
await last.locator("img").waitFor();
await p.getByRole("button", { name: "完成", exact: true }).click();
await p.getByRole("tab", { name: "图片", exact: true }).click();
await p.locator("#desk-negative").waitFor();
ok(
"图视频切换恢复图片草稿",
(await p.locator("#desk-prompt").inputValue()) ===
"这是当前输入,不随历史变化",
);
await p.getByRole("tab", { name: "视频", exact: true }).click();
await p.locator("#desk-prompt").waitFor();
ok(
"图视频切换恢复视频工作流和上传素材",
(await p.locator("#desk-prompt").inputValue()) === "视频独立草稿" &&
(await p.locator(".media-token img").count()) === 2,
);
const v = await submit(p, f.h3, true);
const player = p.locator(".print video");
await player.waitFor();
await wait(() => player.evaluate((e) => e.readyState >= 1));
ok(
"视频结果默认暂停",
await player.evaluate((e) => e.paused && !e.autoplay),
);
await p.locator("#desk-prompt").focus();
await p.keyboard.press("Space");
ok("提示词中的空格不播放视频", await player.evaluate((e) => e.paused));
await p.getByRole("button", { name: "播放此视频", exact: true }).click();
await wait(() => player.evaluate((e) => !e.paused));
await player.focus();
await p.keyboard.press("Space");
await wait(() => player.evaluate((e) => e.paused));
ok("中央大播放键与聚焦空格可控", true);
for (const [width, height] of [
[1440, 900],
[1366, 768],
[1280, 720],
]) {
await p.setViewportSize({ width, height });
await p.waitForTimeout(150);
ok(
width + " 视频完整且主按钮首屏",
(await p.locator(".gen").evaluate((e) => {
const r = e.getBoundingClientRect();
return r.top >= 0 && r.bottom <= innerHeight && r.right <= innerWidth;
})) &&
(await player.evaluate(
(e) => getComputedStyle(e).objectFit === "contain",
)),
);
await shot(p, width + "-video");
}
// Cross-medium reuse from the complete history switches the editor kind explicitly.
await p.getByRole("button", { name: "全部记录", exact: true }).click();
await p
.locator(".history-full .a-seg")
.getByRole("button", { name: "全部", exact: true })
.click();
await p
.locator('.history-full [data-job="' + j.id + '"]')
.getByRole("button", { name: "复用", exact: true })
.click();
await p
.getByRole("tab", { name: "图片", exact: true, selected: true })
.waitFor();
await p.locator("#desk-negative").waitFor();
ok(
"跨媒介历史复用切到所属类型并回填",
(await p.locator("#desk-prompt").inputValue()) ===
"一位女孩站在海岸,清晨柔光。",
);
await p.getByRole("tab", { name: "视频", exact: true }).click();
await p.locator(".print video").waitFor();
// Theme switches preserve the same editor node and state.
await p.locator("#desk-prompt").evaluate((e) => (window.__editor = e));
for (const [id, name] of [
["night", "夜片场"],
["day", "日光棚"],
["grade", "调色间"],
["bluehour", "蓝调时刻"],
]) {
await p.getByRole("button", { name: "主题", exact: true }).click();
await p.locator(".choice").filter({ hasText: name }).click();
await p.keyboard.press("Escape");
ok(
id + " 切换保持编辑和素材",
(await p
.locator("#desk-prompt")
.evaluate((e) => e === window.__editor)) &&
(await p.locator(".media-token img").count()) === 2,
);
await shot(p, "1280-theme-" + id);
}
// Import minimal API fixtures with the same parser roles used by user workflows.
const base = await api("/api/workflows/" + f.dual);
const maskBody = {
...base.workflow_json,
5: {
class_type: "LoadImage",
inputs: { image: "" },
_meta: { title: "(Input:image) 输入图" },
},
6: {
class_type: "LoadImageMask",
inputs: { image: "", channel: "red" },
_meta: { title: "(Input:mask) 遮罩" },
},
};
const maskWf = await api("/api/workflows", {
name: "遮罩图生图测试",
kind: "image",
workflow_json: maskBody,
});
await p.goto(WEB + "/create?wf=" + maskWf.id);
await p.locator("#desk-prompt").waitFor();
await p.getByRole("button", { name: "管理素材", exact: true }).click();
const imageSlots = p.locator(".a-slot");
ok("图生图与遮罩独立槽位", (await imageSlots.count()) === 2);
for (let i = 0; i < 2; i++) {
await imageSlots
.nth(i)
.locator("input[type=file]")
.setInputFiles("calliope-web/static/refract/coast.jpg");
await imageSlots.nth(i).locator("img").waitFor();
}
await p.getByRole("button", { name: "完成", exact: true }).click();
const masked = await submit(p, maskWf.id);
ok(
"上传和遮罩经真实队列生成",
!!masked.payload.input_values["5"] && !!masked.payload.input_values["6"],
);
const refBody = {
1: {
class_type: "CLIPTextEncode",
inputs: { text: "多参考视频" },
_meta: { title: "提示词" },
},
5: { class_type: "LoadImage", inputs: { image: "" } },
7: {
class_type: "MiniMaxH3ReferenceToVideo",
inputs: { "ref_images.ref_image_0": ["5", 0] },
},
9: {
class_type: "VHS_VideoCombine",
inputs: { images: "x" },
_meta: { title: "(Output:video) 视频输出" },
},
};
const refWf = await api("/api/workflows", {
name: "H3多参考测试",
kind: "video",
workflow_json: refBody,
});
await p.goto(WEB + "/create?kind=video&wf=" + refWf.id);
await p.locator("#desk-prompt").waitFor();
await p.getByRole("button", { name: "管理素材", exact: true }).click();
await p
.locator(".media-editor input[type=file]")
.first()
.setInputFiles([
"calliope-web/static/refract/coast.jpg",
"calliope-web/static/refract/arch.jpg",
]);
await wait(
async () => (await p.locator(".media-editor img").count()) === 2,
);
await p.getByRole("button", { name: "完成", exact: true }).click();
const refs = await submit(p, refWf.id, true);
ok(
"多参考素材真实上传并生成",
Array.isArray(refs.payload.input_values["7@ref_images"]) &&
refs.payload.input_values["7@ref_images"].length === 2,
);
await shot(p, "1280-multi-reference");
// Preserve failure, retry and cancellation paths.
await p.goto(WEB + "/create?wf=" + f.dual);
await p.locator("#desk-prompt").waitFor();
mode(false, { mode: "fail" });
await p.locator(".gen").click();
const failed = await wait(async () =>
(await api("/api/playground/jobs")).find(
(j) => j.workflow_id === f.dual && j.status === "failed",
),
);
await p.getByRole("button", { name: "重试", exact: true }).waitFor();
mode();
await p.getByRole("button", { name: "重试", exact: true }).click();
await wait(async () =>
(await api("/api/playground/jobs")).some(
(j) => j.id === failed.id && j.status === "done",
),
);
ok("失败后重试取得真实结果", true);
mode(false, { delay_sec: 10 });
await p.locator(".gen").click();
await p.getByRole("button", { name: "取消生成", exact: true }).waitFor();
await p.getByRole("button", { name: "取消生成", exact: true }).click();
await wait(async () =>
(await api("/api/playground/jobs")).some(
(j) =>
j.workflow_id === f.dual &&
j.status === "failed" &&
j.error === "cancelled",
),
);
ok("进行中生成可以取消", true);
mode();
// Pending rows are hidden by default and point to the actual mapping/testing row.
const w = await api("/api/workflows/" + f.dual);
const pending = await api("/api/workflows", {
name: "待配置测试工具",
kind: "image",
workflow_json: w.workflow_json,
});
await api("/api/workflows/" + pending.id, { is_enabled: false }, "PATCH");
await p.goto(WEB + "/create");
await p.locator("#desk-prompt").waitFor();
await p.locator(".rail-tools input[type=search]").fill("待配置测试");
ok("默认仅显示可运行", (await p.locator(".workflow-pick").count()) === 0);
await p.getByRole("checkbox", { name: "仅可运行" }).uncheck();
await p
.locator(".workflow-pick")
.filter({ hasText: "待配置测试工具" })
.click();
await p.waitForURL("**/workflows?manage=1&wf=" + pending.id);
ok(
"待配置导向真实管理和测试入口",
await p.locator(".wf-test-btn").first().isVisible(),
);
// Source project round-trip uses original API and explicit attachment.
await api(
"/api/workbench/projects/" + f.project + "/stages/assets",
{
content: {
assets: [
{
id: "b1-source",
kind: "character",
name: "海边女孩",
prompt: "源项目提示词",
description: "",
},
],
},
},
"PATCH",
);
const q = new URLSearchParams({
kind: "image",
wf: String(f.dual),
from: "workbench",
project: String(f.project),
stage: "assets",
source_kind: "asset",
source_id: "b1-source",
label: "海边女孩",
prompt: "源项目提示词",
});
await p.goto(WEB + "/create?" + q);
await p.locator("#desk-prompt").waitFor();
const flow = await submit(p, f.dual);
ok(
"来源随真实生成请求发送",
flow.payload?.workbench_context?.source_id === "b1-source" ||
JSON.stringify(flow.payload).includes("b1-source"),
);
await p.getByRole("button", { name: "存入项目", exact: true }).click();
await wait(async () =>
JSON.stringify(
await api("/api/workbench/projects/" + f.project),
).includes(flow.output_paths[0]),
);
ok("结果显式回流来源项目", true);
// Create a deterministic portrait fixture locally (no external image request).
const fixture = await c.newPage();
await fixture.setViewportSize({ width: 480, height: 720 });
await fixture.setContent(
'<body style="margin:0;background:linear-gradient(160deg,#213b42,#d9a86c);height:100vh;display:grid;place-items:center"><div style="width:220px;height:360px;background:#192a32;border-radius:50% 50% 10% 10%;box-shadow:0 0 80px #ecbd7b"></div></body>',
);
await fixture.screenshot({ path: out + "/portrait.png" });
await fixture.close();
ok("竖图测试素材为480×720", await fixtureDimensions(out + "/portrait.png"));
// Portrait & landscape outputs in the real application.
for (const file of ["coast.jpg", "portrait.png"]) {
const img = path.resolve(
file === "portrait.png"
? out + "/portrait.png"
: "calliope-web/static/refract/" + file,
);
mode(false, { image_file: img });
const before = Math.max(
...(await api("/api/playground/jobs")).map((j) => j.id),
);
await p.locator(".gen").click();
const image = await wait(async () =>
(await api("/api/playground/jobs")).find(
(j) => j.id > before && j.status === "done",
),
);
await p
.locator(".result-title")
.filter({ hasText: "第 " + image.id + " 次生成" })
.waitFor();
await p.locator(".print img").waitFor();
for (const [width, height] of [
[1440, 900],
[1366, 768],
[1280, 720],
]) {
await p.setViewportSize({ width, height });
await p.waitForTimeout(120);
ok(
width + " " + file + " 媒体比例正确",
await p.locator(".print img").evaluate((e) => {
const r = e.getBoundingClientRect();
return (
Math.abs(r.width / r.height - e.naturalWidth / e.naturalHeight) <
0.02 &&
r.bottom <= innerHeight &&
r.top >= 0
);
}),
);
await shot(p, width + "-" + file);
}
}
await p.goto(
WEB + "/playground?kind=video&wf=" + f.h3 + "&from=test#legacy",
);
await p.locator("#desk-prompt").waitFor();
ok(
"试验台兼容入口保留参数及片段",
new URL(p.url()).pathname === "/create" &&
new URL(p.url()).searchParams.get("from") === "test" &&
new URL(p.url()).hash === "#legacy",
);
const blocked = await browser.newContext({
viewport: { width: 1280, height: 720 },
});
await blocked.addInitScript(() => {
Storage.prototype.getItem = function () {
throw Error("denied");
};
Storage.prototype.setItem = function () {
throw Error("denied");
};
});
const bp = await blocked.newPage();
await bp.goto(WEB + "/create?wf=" + f.dual);
await bp.locator("#desk-prompt").fill("存储禁止也保留草稿");
await bp.getByRole("tab", { name: "视频", exact: true }).click();
await bp.locator("#desk-prompt").fill("视频草稿");
await bp.getByRole("tab", { name: "图片", exact: true }).click();
await bp.locator("#desk-negative").waitFor();
ok(
"浏览器存储被禁止时仍可切换并恢复草稿",
(await bp.locator("#desk-prompt").inputValue()) === "存储禁止也保留草稿",
);
await blocked.close();
ok("没有浏览器运行异常", errors.length === 0);
fs.writeFileSync(
out + "/results.json",
JSON.stringify({ checks, errors }, null, 2),
);
} catch (e) {
if (p) await shot(p, "failure").catch(() => {});
throw e;
} finally {
if (saved) fs.writeFileSync("e2e/fake_mode.json", saved);
else if (fs.existsSync("e2e/fake_mode.json"))
fs.unlinkSync("e2e/fake_mode.json");
await browser.close();
}
})().catch((e) => {
console.error(e);
process.exitCode = 1;
});
+605
View File
@@ -0,0 +1,605 @@
// HEL-740: same-viewport sample comparisons plus anchored-dialog/state evidence.
const { chromium } = require(
process.env.PLAYWRIGHT_MODULE_PATH || "playwright",
);
const fs = require("node:fs"),
path = require("node:path"),
assert = require("node:assert/strict");
const out = "e2e/shots/hel740";
fs.mkdirSync(out, { recursive: true });
let currentPage;
const checks = [],
measurements = [],
errors = [];
const ok = (name, value) => {
assert(value, name);
checks.push(name);
console.log("PASS", name);
};
const api = async (p) => (await fetch("http://127.0.0.1:8247" + p)).json();
const pause = (ms) => new Promise((r) => setTimeout(r, ms));
async function wait(fn) {
for (let i = 0; i < 160; i++) {
if (await fn()) return;
await pause(100);
}
throw Error("timeout");
}
async function metrics(page, sample = false) {
return page.evaluate((sample) => {
const selectors = sample
? {
button: "#genBtn",
dock: "#dock",
positive: "#posTa",
negative: "#negTa",
segment: "#kindSeg",
name: "#hdName",
history: ".hist",
actions: "#actbar",
pop: "#paramPop",
search: "#wfSearch",
searchBox: "#wfSearch",
filter: "#taskChips .chip",
meta: ".wfb-meta",
head: ".cc > .ph",
file: "#hdFile",
action: "#actbar > button",
separator: "#actbar .sep",
remove: "#actbar [data-act=del]",
icon: "#genBtn svg",
}
: {
button: ".dock .gen",
dock: ".dock .composer",
positive: "#desk-prompt",
negative: "#desk-negative",
segment: ".kind-tabs",
name: ".model-selector",
history: ".history-rail",
actions: ".artboard-footer",
pop: ".anchored .modal-panel",
search: ".rail-search input",
searchBox: ".rail-search",
filter: ".filters button",
meta: ".list-meta",
head: ".studio-heading",
file: ".workflow-file",
action: ".artboard-footer > button",
separator: ".action-separator",
remove: ".delete-output",
icon: ".dock .gen svg",
};
return Object.fromEntries(
Object.entries(selectors).map(([key, sel]) => {
let e = document.querySelector(sel);
if (!e) return [key, null];
if (sample && key === "searchBox") e = e.parentElement;
let r = e.getBoundingClientRect(),
s = getComputedStyle(e);
return [
key,
{
x: r.x,
y: r.y,
width: r.width,
height: r.height,
bottom: r.bottom,
radius: s.borderRadius,
font: s.fontSize,
weight: s.fontWeight,
background: s.backgroundImage,
border: s.borderTopWidth,
shadow: s.boxShadow,
},
];
}),
);
}, sample);
}
async function shot(page, name) {
await page.screenshot({ path: out + "/" + name + ".png" });
}
(async () => {
const b = await chromium.launch({
executablePath: process.env.CHROMIUM_EXECUTABLE_PATH,
args: ["--no-sandbox", "--disable-dev-shm-usage"],
});
const saved = fs.existsSync("e2e/fake_mode.json")
? fs.readFileSync("e2e/fake_mode.json")
: null;
try {
const c = await b.newContext({
viewport: { width: 1440, height: 900 },
recordVideo: {
dir: out + "/video",
size: { width: 1440, height: 900 },
},
}),
p = await c.newPage(),
s = await c.newPage();
currentPage = p;
p.on("pageerror", (e) => errors.push(e.message));
p.setDefaultTimeout(12000);
await s.goto(
"file://" +
path.resolve(
process.env.HEL740_SAMPLE ||
"../references/夜片场-多主题交互样本.html",
) +
"?still=1&fill=1#create/image",
);
await s.locator("#posTa").waitFor();
const data = await s.evaluate(async () => {
const img = new Image();
img.src = st.hist.image[0].img;
await img.decode();
const cv = document.createElement("canvas");
cv.width = img.naturalWidth;
cv.height = img.naturalHeight;
cv.getContext("2d").drawImage(img, 0, 0);
return {
img: cv.toDataURL("image/png"),
pos: $("#posTa").value,
neg: $("#negTa").value,
};
});
fs.writeFileSync(
out + "/sample-media.png",
Buffer.from(data.img.split(",")[1], "base64"),
);
const workflows = await api("/api/workflows"),
mask = workflows.find((w) => w.name === "遮罩图生图测试");
assert(mask);
await p.goto("http://127.0.0.1:5173/create?wf=" + mask.id);
await p.locator("#desk-negative").waitFor();
const jobs = await api("/api/playground/jobs"),
job = jobs.find((j) => j.workflow_id === mask.id && j.status === "done");
assert(job);
await p
.locator(".history-take")
.filter({ hasText: "#" + job.id })
.click();
await p.getByRole("button", { name: "复用参数", exact: true }).click();
await p.locator("#desk-prompt").fill(data.pos);
await p.locator("#desk-negative").fill(data.neg);
fs.writeFileSync(
"e2e/fake_mode.json",
JSON.stringify({
mode: "ok",
unique_filename: true,
image_file: path.resolve(out + "/sample-media.png"),
}),
);
const before = Math.max(...jobs.map((j) => j.id));
await p.locator(".gen").click();
await wait(async () =>
(await api("/api/playground/jobs")).some(
(j) => j.id > before && j.status === "done",
),
);
await wait(() =>
p.locator(".print img").evaluate((e) => e.complete && e.naturalWidth > 0),
);
await pause(1000);
for (const [width, height] of [
[1440, 900],
[1366, 768],
[1280, 720],
]) {
await p.setViewportSize({ width, height });
await s.setViewportSize({ width, height });
for (const theme of ["night", "day", "grade", "bluehour"]) {
await p.getByRole("button", { name: "主题", exact: true }).click();
await p
.locator(".choice")
.filter({
hasText: {
night: "夜片场",
day: "日光棚",
grade: "调色间",
bluehour: "蓝调时刻",
}[theme],
})
.click();
await p.keyboard.press("Escape");
await s.evaluate((t) => {
document.documentElement.dataset.theme = t;
renderCreate();
}, theme);
await pause(650);
const actual = await metrics(p),
sample = await metrics(s, true);
measurements.push({ width, height, theme, actual, sample });
ok(
`${width}/${theme} 正负提示词宽度与样本误差不超过1px`,
Math.abs(actual.positive.width - sample.positive.width) <= 1 &&
Math.abs(actual.negative.width - sample.negative.width) <= 1,
);
ok(
`${width}/${theme} 按钮38高、提示词60高13px、操作条40高、分段36高`,
actual.button.height === 38 &&
actual.positive.height === 60 &&
actual.positive.font === "13px" &&
actual.actions.height === 40 &&
actual.segment.height === 36,
);
ok(
`${width}/${theme} 首屏按钮与作品完整`,
actual.button.bottom <= height &&
(await p.locator(".print img").evaluate((e) => {
let r = e.getBoundingClientRect();
return (
r.top >= 48 &&
r.bottom < innerHeight &&
getComputedStyle(e).objectFit === "contain"
);
})),
);
ok(
`${width}/${theme} R1 搜索与筛选chips几何`,
actual.searchBox.height === sample.searchBox.height &&
actual.searchBox.radius === sample.searchBox.radius &&
actual.search.font === sample.search.font &&
actual.filter.height === sample.filter.height &&
actual.filter.font === sample.filter.font &&
actual.filter.radius === sample.filter.radius,
);
ok(
`${width}/${theme} R2 头行与裸文本文件名`,
actual.head.height === sample.head.height &&
Math.abs(actual.name.height - sample.name.height) < 0.1 &&
actual.name.font === sample.name.font &&
actual.name.weight === sample.name.weight &&
actual.file.font === sample.file.font &&
(await p.locator(".studio-heading button").count()) === 0,
);
ok(
`${width}/${theme} R3 删除钮与分隔线`,
actual.action.radius === sample.action.radius &&
actual.remove.width === 26 &&
actual.remove.height === 26 &&
actual.separator.width === 1 &&
(await p.locator(".action-separator").count()) === 2,
);
ok(
`${width}/${theme} R4 14px图标及主题按钮宽度`,
actual.icon.width === 14 &&
Math.abs(actual.button.width - sample.button.width) <= 0.5,
);
ok(
`${width}/${theme} meta真实未知且无横向溢出`,
(await p
.locator(".list-meta")
.innerText()
.then((t) => t.includes("仓库文件数未知"))) &&
(await p.evaluate(
() => document.documentElement.scrollWidth <= innerWidth,
)),
);
await shot(s, `${width}-${theme}-sample`);
await shot(p, `${width}-${theme}-actual`);
const actions = p.locator(
".artboard-footer > button,.artboard-footer > a",
);
ok(`${width}/${theme} 八入口显式存在`, (await actions.count()) === 8);
}
await p
.getByRole("button", { name: "创作设置⌄", exact: true })
.first()
.click();
await pause(250);
const popup = await metrics(p);
ok(
`${width} 参数锚定底座上方且不遮主按钮`,
popup.pop.bottom <= popup.dock.y - 7 &&
popup.pop.y >= 48 &&
popup.pop.width === 620,
);
await s.evaluate(() => openPop());
await pause(250);
measurements.push({
width,
height,
state: "params",
actual: await metrics(p),
sample: await metrics(s, true),
});
await shot(p, `${width}-params-actual`);
await shot(s, `${width}-params-sample`);
// Focus cycle and draft-only reset.
await p.locator(".anchored input[type=number]").first().fill("333");
await p
.getByRole("button", { name: "恢复工作流默认", exact: true })
.click();
ok(
`${width} 恢复默认只改弹层草稿`,
(await p
.locator(".anchored input[type=number]")
.first()
.inputValue()) === "42",
);
await p.keyboard.press("Escape");
ok(
`${width} Escape归还焦点`,
await p
.getByRole("button", { name: "创作设置⌄", exact: true })
.first()
.evaluate((e) => e === document.activeElement),
);
await s.evaluate(() => $("#paramPop").classList.remove("on"));
}
await p.setViewportSize({ width: 1440, height: 900 });
await p.getByRole("button", { name: "收起历史栏", exact: true }).click();
ok(
"历史收起列宽归零",
(await p
.locator(".history-rail")
.evaluate((e) => e.getBoundingClientRect().width)) === 0,
);
await shot(p, "history-collapsed");
await p.getByRole("button", { name: "展开历史栏", exact: true }).click();
await p
.locator(".history-take")
.filter({ hasText: "#" + job.id })
.click();
await shot(p, "history-banner");
ok(
"历史胶囊999圆角",
(await p
.locator(".history-banner")
.evaluate((e) => getComputedStyle(e).borderRadius)) === "999px",
);
await p.getByRole("button", { name: "返回当前创作", exact: true }).click();
await p
.getByRole("button", { name: "创作设置⌄", exact: true })
.first()
.click();
const dialog = p.locator(".anchored .modal-panel");
await dialog.locator("button").last().focus();
await p.keyboard.press("Tab");
ok(
"参数Tab焦点循环",
await dialog.evaluate((e) => e.contains(document.activeElement)),
);
await p.locator(".anchored input[type=number]").first().fill("234");
await p.getByRole("button", { name: "取消", exact: true }).click();
await p
.getByRole("button", { name: "创作设置⌄", exact: true })
.first()
.click();
ok(
"参数取消丢弃修改",
(await p.locator(".anchored input[type=number]").first().inputValue()) !==
"234",
);
await p.locator(".anchored input[type=number]").first().fill("235");
await p.getByRole("button", { name: "保存设置", exact: true }).click();
await p
.getByRole("button", { name: "创作设置⌄", exact: true })
.first()
.click();
ok(
"参数保存草稿持久化",
(await p.locator(".anchored input[type=number]").first().inputValue()) ===
"235",
);
await p
.getByRole("button", { name: "恢复工作流默认", exact: true })
.click();
await p.getByRole("button", { name: "保存设置", exact: true }).click();
await p
.getByRole("button", { name: "创作设置⌄", exact: true })
.first()
.click();
ok(
"恢复默认可保存",
(await p.locator(".anchored input[type=number]").first().inputValue()) ===
"42",
);
await p.keyboard.press("Escape");
// Actual existing disabled abilities have explanatory titles; no fake success.
ok(
"未具备送视频能力如实禁用",
await p
.getByRole("button", { name: "送去生视频", exact: true })
.isDisabled(),
);
ok(
"无来源时通用存入项目禁用",
await p
.getByRole("button", { name: "存入项目", exact: true })
.isDisabled(),
);
await p.getByRole("button", { name: "放大查看", exact: true }).click();
await shot(p, "zoom");
await p.keyboard.press("Escape");
await p
.getByRole("button", { name: "作品详情与操作", exact: true })
.click();
await shot(p, "more-actions");
await p.keyboard.press("Escape");
// HEL-741: real filtering, retained entries and deletion of isolated data.
const search = p.getByRole("searchbox", { name: "搜索工作流" });
await search.fill("遮罩图生图测试");
ok(
"搜索更新真实筛选数",
await p
.locator(".list-meta")
.innerText()
.then((t) => t.startsWith("1 个")),
);
const usageGroup = p.getByRole("group", { name: "筛选工作流用途" });
await usageGroup
.getByRole("button", { name: "文生图", exact: true })
.click();
ok(
"用途chips与搜索组合筛选",
(await p.locator(".workflow-pick").count()) === 0,
);
await usageGroup.getByRole("button", { name: "全部", exact: true }).click();
if (
(await p
.locator(".row-actions button")
.last()
.getAttribute("aria-pressed")) !== "true"
)
await p.locator(".row-actions button").last().click();
await usageGroup
.getByRole("button", { name: "☆ 收藏", exact: true })
.click();
ok(
"收藏chip保留真实收藏筛选",
(await p.locator(".workflow-pick").count()) === 1,
);
await shot(p, "filters-favorite");
await usageGroup
.getByRole("button", { name: "☆ 收藏", exact: true })
.click();
await search.fill("");
await p.locator("[data-workflow-details]").click();
await p.getByRole("dialog").waitFor();
await shot(p, "workflow-details");
await p.keyboard.press("Escape");
ok(
"说明入口关闭归还焦点",
await p
.locator("[data-workflow-details]")
.evaluate((e) => e === document.activeElement),
);
await p.getByRole("button", { name: "创作设置⌄", exact: true }).click();
await p.getByRole("button", { name: "专注创作 ⤢", exact: true }).click();
await p.keyboard.press("Escape");
ok("参数层保留专注功能", !(await p.locator(".workflow-rail").isVisible()));
await shot(p, "focus-mode");
await p.getByRole("button", { name: "创作设置⌄", exact: true }).click();
await p.getByRole("button", { name: "退出专注 ⤢", exact: true }).click();
await p.keyboard.press("Escape");
const deleteTarget = (await api("/api/playground/jobs")).find(
(j) => j.workflow_id === mask.id && j.status === "done",
);
assert(deleteTarget);
await p.locator(".delete-output").click();
ok(
"删除图标先确认且不发DELETE",
(await api("/api/playground/jobs")).some((j) => j.id === deleteTarget.id),
);
await shot(p, "delete-confirm");
await p.getByRole("button", { name: "取消", exact: true }).click();
ok(
"删除取消保留记录并归还焦点",
(await api("/api/playground/jobs")).some(
(j) => j.id === deleteTarget.id,
) &&
(await p
.locator(".delete-output")
.evaluate((e) => e === document.activeElement)),
);
await p.locator(".delete-output").click();
await p.getByRole("button", { name: "确认移除", exact: true }).click();
await p.getByRole("button", { name: "撤销", exact: true }).click();
ok(
"确认后沿用撤销窗口",
(await api("/api/playground/jobs")).some((j) => j.id === deleteTarget.id),
);
await shot(p, "delete-undone");
await p.locator(".delete-output").click();
await p.getByRole("button", { name: "确认移除", exact: true }).click();
await wait(
async () =>
!(await api("/api/playground/jobs")).some(
(j) => j.id === deleteTarget.id,
),
);
ok(
"隔离数据确认后真实删除",
!(await api("/api/playground/jobs")).some(
(j) => j.id === deleteTarget.id,
),
);
// Video state evidence across the same three viewports.
await p.getByRole("tab", { name: "视频", exact: true }).click();
await p.locator("#desk-prompt").waitFor();
const videos = (await api("/api/playground/jobs")).filter(
(j) => j.kind === "video" && j.status === "done",
);
const vj = videos.find((j) => j.workflow_id === 4);
assert(vj);
await p
.locator(".history-take")
.filter({ hasText: "#" + vj.id })
.click();
await p.getByRole("button", { name: "复用参数", exact: true }).click();
await p.locator(".print video").waitFor();
await s.evaluate(() => go("create", { kind: "video" }));
for (const [width, height] of [
[1440, 900],
[1366, 768],
[1280, 720],
]) {
await p.setViewportSize({ width, height });
await s.setViewportSize({ width, height });
await pause(350);
await shot(p, `${width}-video-actual`);
await shot(s, `${width}-video-sample`);
ok(
`${width} 视频默认暂停且播放键64`,
(await p.locator(".print video").evaluate((e) => e.paused)) &&
(await p
.locator(".big-play")
.evaluate((e) => e.getBoundingClientRect().width === 64)),
);
}
await p.getByRole("button", { name: "播放此视频", exact: true }).click();
await pause(500);
await shot(p, "video-playing");
await p.locator(".print video").focus();
await p.keyboard.press("Space");
await shot(p, "video-paused");
await p.getByRole("tab", { name: "图片", exact: true }).click();
await p.locator(".workflow-pick").first().hover();
await shot(p, "workflow-hover");
await p.locator(".gen").hover();
await shot(p, "generate-hover");
fs.writeFileSync(
"e2e/fake_mode.json",
JSON.stringify({
mode: "ok",
delay_sec: 10,
unique_filename: true,
image_file: path.resolve(out + "/sample-media.png"),
}),
);
await p.locator(".gen").click();
await p.getByRole("button", { name: "取消生成", exact: true }).waitFor();
await shot(p, "generating");
await p.getByRole("button", { name: "取消生成", exact: true }).click();
ok("无浏览器异常", errors.length === 0);
fs.writeFileSync(
out + "/measurements.json",
JSON.stringify(measurements, null, 2),
);
fs.writeFileSync(
out + "/results.json",
JSON.stringify({ checks, errors }, null, 2),
);
await c.close();
fs.copyFileSync(await p.video().path(), out + "/actual-interactions.webm");
fs.copyFileSync(await s.video().path(), out + "/sample-interactions.webm");
} catch (e) {
fs.writeFileSync(
out + "/measurements.json",
JSON.stringify(measurements, null, 2),
);
if (currentPage) await shot(currentPage, "failure").catch(() => {});
throw e;
} finally {
if (saved) fs.writeFileSync("e2e/fake_mode.json", saved);
else if (fs.existsSync("e2e/fake_mode.json"))
fs.unlinkSync("e2e/fake_mode.json");
await b.close();
}
})().catch((e) => {
console.error(e);
process.exitCode = 1;
});
+115
View File
@@ -0,0 +1,115 @@
// HEL-742 independent verification: pure text-to-image / text-to-video
// workflows expose no pad-slot UI at all (no 管理素材 entry, no .a-slot),
// no stale slot UI after mode/workflow switches; positive control on the
// mask workflow (2 slots). Run inside the isolated stack.
const { chromium } = require(
process.env.PLAYWRIGHT_MODULE_PATH || "playwright",
);
const fs = require("node:fs");
const out = "e2e/shots/hel742";
fs.mkdirSync(out, { recursive: true });
const checks = [];
const errors = [];
const ok = (name, value) => {
if (!value) throw new Error("FAIL " + name);
checks.push(name);
console.log("PASS", name);
};
const api = async (p, body) =>
body
? (
await fetch("http://127.0.0.1:8247" + p, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify(body),
})
).json()
: (await fetch("http://127.0.0.1:8247" + p)).json();
const pause = (ms) => new Promise((r) => setTimeout(r, ms));
(async () => {
const b = await chromium.launch({
executablePath: process.env.CHROMIUM_EXECUTABLE_PATH,
args: ["--no-sandbox", "--disable-dev-shm-usage"],
});
try {
const c = await b.newContext({ viewport: { width: 1440, height: 900 } });
const p = await c.newPage();
p.on("pageerror", (e) => errors.push(e.message));
p.setDefaultTimeout(15000);
const wfs = await api("/api/workflows");
const t2i = wfs.find((w) => w.name === "文生图·回归");
const t2v = wfs.find((w) => w.name === "文生视频·回归");
let mask = wfs.find((w) => w.name === "遮罩图生图测试");
ok("隔离夹具含文生图/文生视频工作流", t2i && t2v);
if (!mask) {
const base = await api("/api/workflows/" + t2i.id);
const maskBody = {
...base.workflow_json,
5: {
class_type: "LoadImage",
inputs: { image: "" },
_meta: { title: "(Input:image) 输入图" },
},
6: {
class_type: "LoadImageMask",
inputs: { image: "", channel: "red" },
_meta: { title: "(Input:mask) 遮罩" },
},
};
mask = await api("/api/workflows", {
name: "遮罩图生图测试",
kind: "image",
workflow_json: maskBody,
});
}
const noSlotUi = async () =>
(await p.locator(".a-slot").count()) === 0 &&
(await p
.getByRole("button", { name: "管理素材", exact: true })
.count()) === 0;
await p.goto("http://127.0.0.1:5173/create?wf=" + t2i.id);
await p.locator("#desk-prompt").waitFor();
await pause(400);
await p.screenshot({ path: out + "/t2i-no-slot.png" });
ok("文生图无图片输入:无垫图槽、无素材入口", await noSlotUi());
await p.getByRole("tab", { name: "视频", exact: true }).click();
await p.locator(".rail-tools input[type=search]").fill("文生视频");
await p.locator(".workflow-pick").first().click();
await pause(600);
await p.screenshot({ path: out + "/t2v-no-slot.png" });
ok("文生视频无图片输入:无首尾帧槽、无素材入口", await noSlotUi());
await p.getByRole("tab", { name: "图片", exact: true }).click();
await p.locator(".rail-tools input[type=search]").fill("文生图");
await p.locator(".workflow-pick").first().click();
await pause(600);
ok("切回图片后无残留垫图槽", await noSlotUi());
await p.locator(".rail-tools input[type=search]").fill("遮罩");
await p.locator(".workflow-pick").first().click();
await pause(600);
await p.getByRole("button", { name: "管理素材", exact: true }).click();
await pause(400);
ok(
"遮罩工作流仍有两个图片槽(对照)",
(await p.locator(".a-slot").count()) === 2,
);
await p.getByRole("button", { name: "完成", exact: true }).click();
await p.locator(".rail-tools input[type=search]").fill("文生图");
await p.locator(".workflow-pick").first().click();
await pause(600);
ok("从遮罩切回文生图后槽位入口消失", await noSlotUi());
ok("无浏览器异常", errors.length === 0);
fs.writeFileSync(
out + "/results.json",
JSON.stringify({ checks, errors }, null, 2),
);
} finally {
await b.close();
}
})();
+351
View File
@@ -0,0 +1,351 @@
// HEL-743: sample geometry, themes, breakpoint and search keyboard regression.
// Run using e2e/run-hel729.py with HEL729_TESTS=e2e/hel743.cjs.
const { chromium } = require(
process.env.PLAYWRIGHT_MODULE_PATH || "playwright",
);
const fs = require("node:fs"),
path = require("node:path"),
assert = require("node:assert/strict");
const out = "e2e/shots/hel743";
fs.mkdirSync(out, { recursive: true });
const checks = [],
measurements = [],
errors = [];
function ok(name, v) {
assert(v, name);
checks.push(name);
console.log("PASS", name);
}
const pause = (ms) => new Promise((r) => setTimeout(r, ms));
async function metrics(p, sample = false) {
return p.evaluate((sample) => {
const sel = sample
? {
head: ".cc > .ph",
name: "#hdName",
file: "#hdFile",
search: "#wfSearch",
box: "#wfSearch",
key: ".wfb-top kbd",
}
: {
head: ".studio-heading",
name: ".model-selector",
file: ".workflow-file",
search: ".rail-search input",
box: ".rail-search",
key: ".rail-search kbd",
};
const entries = Object.entries(sel).map(([k, sel]) => {
let e = document.querySelector(sel);
if (sample && k === "box") e = e.parentElement;
if (sample && k === "key")
e = document
.querySelector("#wfSearch")
.parentElement.querySelector("kbd");
let r = e.getBoundingClientRect(),
s = getComputedStyle(e);
return [
k,
{
x: r.x,
y: r.y,
width: r.width,
height: r.height,
font: s.font,
fontSize: s.fontSize,
fontWeight: s.fontWeight,
lineHeight: s.lineHeight,
padding: s.padding,
radius: s.borderRadius,
border: s.border,
bottomBorder: s.borderBottom,
background: s.backgroundColor,
color: s.color,
blur: s.backdropFilter,
text: e.textContent,
},
];
});
return {
...Object.fromEntries(entries),
overflow: document.documentElement.scrollWidth > innerWidth,
};
}, sample);
}
(async () => {
const b = await chromium.launch({
executablePath: process.env.CHROMIUM_EXECUTABLE_PATH,
args: ["--no-sandbox", "--disable-dev-shm-usage"],
});
try {
const c = await b.newContext({ viewport: { width: 1440, height: 900 } }),
p = await c.newPage(),
s = await c.newPage();
p.on("pageerror", (e) => errors.push(e.message));
p.setDefaultTimeout(10000);
const wfs = await (
await fetch("http://127.0.0.1:8247/api/workflows")
).json(),
wf = wfs.find((w) => w.name === "遮罩图生图测试");
assert(wf);
// The fixture file and metadata live only under the isolated test data directory.
const settings = await (
await fetch("http://127.0.0.1:8247/api/settings")
).json();
assert.equal(
path.resolve(settings.data_dir),
path.resolve("e2e/shots/reference/data"),
);
const detail = await (
await fetch("http://127.0.0.1:8247/api/workflows/" + wf.id)
).json();
const source = path.join(settings.data_dir, "hel743-mask-workflow.json");
fs.writeFileSync(source, JSON.stringify(detail.workflow_json, null, 2));
require("node:child_process").execFileSync(
"calliope-backend/.venv/bin/python",
[
"-c",
'import sqlite3,sys; c=sqlite3.connect(sys.argv[1]); c.execute("UPDATE workflows SET source_path=? WHERE id=?",(sys.argv[2],int(sys.argv[3]))); c.commit(); c.close()',
path.join(settings.data_dir, "calliope.db"),
source,
String(wf.id),
],
);
await p.goto("http://127.0.0.1:5173/create?wf=" + wf.id);
await p.locator("#desk-prompt").waitFor();
await s.goto(
"file://" +
path.resolve(process.env.HEL740_SAMPLE) +
"?still=1&fill=1#create/image",
);
await s.locator("#wfSearch").waitFor();
for (const [width, height] of [
[1440, 900],
[1366, 768],
[1280, 720],
]) {
await p.setViewportSize({ width, height });
await s.setViewportSize({ width, height });
for (const theme of ["night", "day", "grade", "bluehour"]) {
await p.getByRole("button", { name: "主题", exact: true }).click();
await p
.locator(".choice")
.filter({
hasText: {
night: "夜片场",
day: "日光棚",
grade: "调色间",
bluehour: "蓝调时刻",
}[theme],
})
.click();
await p.keyboard.press("Escape");
await s.evaluate((t) => {
document.documentElement.dataset.theme = t;
renderCreate();
}, theme);
await pause(450);
const actual = await metrics(p),
sample = await metrics(s, true);
measurements.push({ width, height, theme, actual, sample });
const tag = width + "/" + theme;
ok(
tag + " head width/height/name x",
actual.head.width === sample.head.width &&
actual.head.height === sample.head.height &&
actual.name.x === sample.name.x,
);
ok(
tag + " glass/bottom line/blur",
actual.head.background === sample.head.background &&
actual.head.bottomBorder === sample.head.bottomBorder &&
actual.head.blur === sample.head.blur,
);
ok(
tag + " search/input width",
Math.abs(actual.search.width - sample.search.width) < 0.1 &&
actual.box.width === sample.box.width,
);
ok(
tag + " kbd exact appearance",
[
"fontSize",
"fontWeight",
"lineHeight",
"padding",
"radius",
"border",
"background",
"color",
].every((k) => actual.key[k] === sample.key[k]) &&
actual.key.text === "/",
);
ok(
tag + " filename breakpoint",
width <= 1366
? actual.file.width === 0 && sample.file.width === 0
: actual.file.width > 0 && sample.file.width > 0,
);
ok(tag + " no overflow", !actual.overflow);
await p.screenshot({ path: out + `/${width}-${theme}-actual.png` });
await s.screenshot({ path: out + `/${width}-${theme}-sample.png` });
}
}
for (const width of [1367, 1366]) {
await p.setViewportSize({ width, height: 768 });
await s.setViewportSize({ width, height: 768 });
const a = await metrics(p),
v = await metrics(s, true);
ok(
width + " boundary filename matches sample",
(a.file.width === 0) === (v.file.width === 0),
);
}
await p.setViewportSize({ width: 1440, height: 900 });
const search = p.getByRole("searchbox", { name: "搜索工作流" }),
origin = p.getByRole("tab", { name: "图片", exact: true });
await origin.focus();
await p.keyboard.press("/");
ok(
"/ focuses search",
await search.evaluate((e) => e === document.activeElement),
);
await search.fill("遮罩");
await pause(200);
ok("search filters", (await p.locator(".workflow-pick").count()) === 1);
await p.keyboard.press("Escape");
ok(
"Esc returns prior focus",
await origin.evaluate((e) => e === document.activeElement),
);
ok("Esc preserves filter", (await search.inputValue()) === "遮罩");
await search.fill("");
await search.focus();
await p.keyboard.press("/");
ok(
"search typing slash remains literal",
(await search.inputValue()) === "/",
);
await search.fill("");
const prompt = p.locator("#desk-prompt");
await prompt.fill("");
await prompt.focus();
await p.keyboard.press("/");
ok("textarea keeps slash", (await prompt.inputValue()) === "/");
await p.evaluate(() => {
let e = document.createElement("div");
e.id = "editable-probe";
e.contentEditable = "true";
e.textContent = "IME";
document.body.append(e);
e.focus();
});
await p.keyboard.press("/");
ok(
"contenteditable keeps focus",
await p
.locator("#editable-probe")
.evaluate(
(e) => document.activeElement === e && e.textContent.includes("/"),
),
);
await p.locator("#editable-probe").evaluate((e) => e.remove());
await origin.focus();
ok(
"composition slash ignored",
await origin.evaluate((e) => {
let k = new KeyboardEvent("keydown", {
key: "/",
bubbles: true,
cancelable: true,
isComposing: true,
});
e.dispatchEvent(k);
return document.activeElement === e && !k.defaultPrevented;
}),
);
ok(
"legacy IME 229 ignored",
await origin.evaluate((e) => {
let k = new KeyboardEvent("keydown", {
key: "/",
keyCode: 229,
bubbles: true,
cancelable: true,
});
e.dispatchEvent(k);
return document.activeElement === e && !k.defaultPrevented;
}),
);
await p.keyboard.press("Control+/");
ok(
"modified slash ignored",
await origin.evaluate((e) => document.activeElement === e),
);
await prompt.focus();
ok(
"composition Escape keeps editor focus",
await prompt.evaluate((e) => {
let k = new KeyboardEvent("keydown", {
key: "Escape",
bubbles: true,
cancelable: true,
isComposing: true,
});
e.dispatchEvent(k);
return document.activeElement === e && !k.defaultPrevented;
}),
);
await p.setViewportSize({ width: 1440, height: 900 });
ok(
"1440 filename is genuine source basename",
(await p.locator(".workflow-file").textContent()) ===
path.basename(source) &&
(await p.locator(".workflow-file").getAttribute("title")) === source,
);
// Mouse focus and removal of the prior node use a stable rail control fallback.
await search.click();
await p.keyboard.press("Escape");
ok(
"mouse search Esc returns control",
await p.evaluate(
() =>
document.activeElement !==
document.querySelector(".rail-search input"),
),
);
await p
.getByRole("button", { name: "创作设置⌄", exact: true })
.first()
.click();
await p.locator(".modal-panel").waitFor();
await p.locator(".modal-panel button").first().focus();
await p.keyboard.press("/");
ok(
"dialog slash never jumps to rail",
!(await search.evaluate((e) => e === document.activeElement)),
);
await p.keyboard.press("Escape");
ok(
"dialog Escape closes normally",
(await p.locator(".modal-panel").count()) === 0,
);
await p.getByRole("button", { name: "收起历史栏", exact: true }).focus();
await p.keyboard.press("/");
await p.keyboard.press("Escape");
ok(
"keyboard origin restored exactly",
await p
.getByRole("button", { name: "收起历史栏", exact: true })
.evaluate((e) => document.activeElement === e),
);
ok("no browser errors", errors.length === 0);
fs.writeFileSync(
out + "/results.json",
JSON.stringify({ checks, measurements, errors }, null, 2),
);
} finally {
await b.close();
}
})();
+43
View File
@@ -0,0 +1,43 @@
"""Run the isolated regression stack in the foreground and always stop children."""
import os, signal, socket, subprocess, time, urllib.request
from pathlib import Path
root=Path(__file__).resolve().parents[1]
os.chdir(root)
py=str(root/'calliope-backend/.venv/bin/python')
logs=root/'e2e/shots/hel728'; logs.mkdir(parents=True,exist_ok=True)
env=os.environ.copy()
for port in [8247,5173,18188,18189]:
with socket.socket() as probe:
assert probe.connect_ex(('127.0.0.1',port)) != 0, f'Test port {port} is already in use'
# Every run gets fresh disposable state; retain prior evidence instead of deleting it.
reference=root/'e2e/shots/reference'
if reference.exists():reference.rename(reference.with_name('reference.previous-'+str(time.time_ns())))
processes=[]; handles=[]
try:
for name,cmd in [('api',[py,'e2e/reference/server.py']),('comfy',[py,'e2e/fake_comfy.py']),('llm',[py,'e2e/reference/fake_llm.py']),('web',['npm','--prefix',os.environ.get('HEL728_WEB_DIR','calliope-web'),'run','dev'])]:
h=open(logs/(name+'.log'),'w');handles.append(h);processes.append(subprocess.Popen(cmd,stdout=h,stderr=subprocess.STDOUT,env=env,start_new_session=True))
for url in ['http://127.0.0.1:8247/api/health','http://127.0.0.1:5173']:
for i in range(100):
try:
urllib.request.urlopen(url,timeout=2); break
except Exception:
if any(p.poll() is not None for p in processes): raise RuntimeError('A test service failed')
if i==99:raise
time.sleep(.2)
for cmd in [[py,'e2e/seed.py'],[py,'e2e/reference/seed.py']]:subprocess.run(cmd,env=env,check=True)
failures=[]
scripts=os.environ.get('HEL728_TESTS','e2e/reference/check.cjs,e2e/reference/check-copy-fallback.cjs,e2e/hel728.cjs').split(',')
for script in scripts:
print('RUN',script,flush=True)
result=subprocess.run(['node',script],env=env)
if result.returncode:failures.append(script)
print('FAILED SCRIPTS',failures,flush=True)
if failures:raise SystemExit(1)
finally:
for p in processes:
if p.poll() is None:os.killpg(p.pid,signal.SIGTERM)
for p in processes:
try:p.wait(timeout=10)
except subprocess.TimeoutExpired:os.killpg(p.pid,signal.SIGKILL);p.wait()
for h in handles:h.close()
+44
View File
@@ -0,0 +1,44 @@
"""Run the isolated regression stack in the foreground and always stop children."""
import os, signal, socket, subprocess, time, urllib.request
from pathlib import Path
root=Path(__file__).resolve().parents[1]
os.chdir(root)
py=str(root/'calliope-backend/.venv/bin/python')
logs=root/'e2e/shots/hel729'; logs.mkdir(parents=True,exist_ok=True)
env=os.environ.copy()
env['PYTHONPATH']=str(root/'calliope-backend/src')
for port in [8247,5173,18188,18189]:
with socket.socket() as probe:
assert probe.connect_ex(('127.0.0.1',port)) != 0, f'Test port {port} is already in use'
# Every run gets fresh disposable state; retain prior evidence instead of deleting it.
reference=root/'e2e/shots/reference'
if reference.exists():reference.rename(reference.with_name('reference.previous-'+str(time.time_ns())))
processes=[]; handles=[]
try:
for name,cmd in [('api',[py,'e2e/reference/server.py']),('comfy',[py,'e2e/fake_comfy.py']),('llm',[py,'e2e/reference/fake_llm.py']),('web',['npm','--prefix',os.environ.get('HEL729_WEB_DIR','calliope-web'),'run','dev'])]:
h=open(logs/(name+'.log'),'w');handles.append(h);processes.append(subprocess.Popen(cmd,stdout=h,stderr=subprocess.STDOUT,env=env,start_new_session=True))
for url in ['http://127.0.0.1:8247/api/health','http://127.0.0.1:5173']:
for i in range(100):
try:
urllib.request.urlopen(url,timeout=2); break
except Exception:
if any(p.poll() is not None for p in processes): raise RuntimeError('A test service failed')
if i==99:raise
time.sleep(.2)
for cmd in [[py,'e2e/seed.py'],[py,'e2e/reference/seed.py']]:subprocess.run(cmd,env=env,check=True)
failures=[]
scripts=os.environ.get('HEL729_TESTS','e2e/hel729.cjs').split(',')
for script in scripts:
print('RUN',script,flush=True)
result=subprocess.run(['node',script],env=env)
if result.returncode:failures.append(script)
print('FAILED SCRIPTS',failures,flush=True)
if failures:raise SystemExit(1)
finally:
for p in processes:
if p.poll() is None:os.killpg(p.pid,signal.SIGTERM)
for p in processes:
try:p.wait(timeout=10)
except subprocess.TimeoutExpired:os.killpg(p.pid,signal.SIGKILL);p.wait()
for h in handles:h.close()