diff --git a/calliope-web/src/lib/components/agent/workspace.css b/calliope-web/src/lib/components/agent/workspace.css index c514c06c..84024146 100644 --- a/calliope-web/src/lib/components/agent/workspace.css +++ b/calliope-web/src/lib/components/agent/workspace.css @@ -539,11 +539,11 @@ } .orb .ring { position: absolute; - width: 250px; - height: 250px; + width: var(--agent-orbit-size); + height: var(--agent-orbit-size); border-radius: 50%; border: 1px solid rgb(var(--ink) / 0.12); - animation: spin 24s linear infinite; + animation: agent-orbit var(--agent-orbit-period) linear infinite; animation-play-state: var(--loop-play-state); } .orb .ring::before { @@ -558,9 +558,9 @@ box-shadow: var(--agent-ring-shadow); } .orb .ring.r2 { - width: 300px; - height: 300px; - animation-duration: 40s; + width: var(--agent-orbit-outer-size); + height: var(--agent-orbit-outer-size); + animation-duration: var(--agent-orbit-outer-period); animation-direction: reverse; border-style: dashed; border-color: rgb(var(--ink) / 0.07); @@ -1004,3 +1004,9 @@ transform: translateY(-2px); } } + +@keyframes agent-orbit { + to { + transform: rotate(360deg); + } +} diff --git a/calliope-web/src/lib/components/desk/Artboard.svelte b/calliope-web/src/lib/components/desk/Artboard.svelte index 8fc5dff1..a1f211c5 100644 --- a/calliope-web/src/lib/components/desk/Artboard.svelte +++ b/calliope-web/src/lib/components/desk/Artboard.svelte @@ -126,13 +126,13 @@ previous = id && status ? { id, status } : null; }); $effect(() => { - if (!arriving) return; - const skip = () => (arriving = false); - // v3: any explicit pointer action may skip an arrival, including actions - // outside the media surface. The listener exists only during this reveal. - window.addEventListener('pointerdown', skip, true); - return () => window.removeEventListener('pointerdown', skip, true); - }); + if (!arriving) return; + const skip = () => (arriving = false); + // v3: any explicit pointer action may skip an arrival, including actions + // outside the media surface. The listener exists only during this reveal. + window.addEventListener('pointerdown', skip, true); + return () => window.removeEventListener('pointerdown', skip, true); + }); const st = $derived(job ? jobStatusInfo(job) : null); // §3.4 红/琥珀界线: amber only when the task SUCCEEDED but its files // cannot be fetched — the failed case stays red. @@ -171,7 +171,10 @@ let stageWidth = $state(0), stageHeight = $state(0); const fitted = $derived( - Math.min(stageWidth / naturalWidth, stageHeight / naturalHeight), + Math.min( + stageWidth / naturalWidth, + (stageHeight - (video ? 40 : 0)) / naturalHeight, + ), ); $effect.pre(() => { void output; @@ -191,6 +194,16 @@ } let paused = $state(true); + let playTime = $state(0); + let duration = $state(0); + let loopVideo = $state(false); + function clock(seconds: number) { + return `${Math.floor(seconds / 60) + .toString() + .padStart(2, '0')}:${Math.floor(seconds % 60) + .toString() + .padStart(2, '0')}`; + } $effect.pre(() => { void output; untrack(() => { @@ -219,16 +232,20 @@ } -
+{#snippet frameMeta()} +{/snippet} +
{#if job && st}{#key mediaKey}
(arriving = false)} - style:width={fitted > 0 ? `${Math.floor(naturalWidth * fitted)}px` : '100%'} - style:height={fitted > 0 ? `${Math.floor(naturalHeight * fitted)}px` : '100%'} + style:width={fitted > 0 + ? `${Math.floor(naturalWidth * fitted)}px` + : '100%'} + style:height={fitted > 0 + ? `${Math.floor(naturalHeight * fitted) + 40}px` + : '100%'} > - {#if st.tone === 'ok' && !fileGone}{t('jobst.done')}{/if} - - {#if paused}{/if} +
+ {@render frameMeta()} + {#if st.tone === 'ok' && !fileGone}{t('jobst.done')}{/if} + + {#if paused}{/if} +
+
+ + {clock(playTime)} / {clock(duration)} + { + if (videoEl) + videoEl.currentTime = Number(e.currentTarget.value); + }} + /> + + +
{:else if output}
(arriving = false)} - style:width={fitted > 0 ? `${Math.floor(naturalWidth * fitted)}px` : '100%'} - style:height={fitted > 0 ? `${Math.floor(naturalHeight * fitted)}px` : '100%'} + style:width={fitted > 0 + ? `${Math.floor(naturalWidth * fitted)}px` + : '100%'} + style:height={fitted > 0 + ? `${Math.floor(naturalHeight * fitted)}px` + : '100%'} > + {@render frameMeta()} {#if st.tone === 'ok' && !fileGone}{t('jobst.done')}{/if} @@ -371,7 +441,7 @@ video ? videoEl?.requestFullscreen().catch(() => {}) : (lightbox = true)} - aria-label="放大查看">放大放大 下载下载 复用参数 - {video + ? '作为首帧' + : '作为参考图'} + {#if !video} + {/if} 存入项目 素材库素材库 onKind('image')} + >图片 onKind('video')} + >视频
{#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)}
+ (closedGroups[groupKey(m)] = !closedGroups[groupKey(m)])} > - {pins.includes(m.id) ? '置顶工作流' : m.model_family || '本地工作流'} -
{/if} -
- -
- + + {pins.includes(m.id) + ? '置顶' + : `本地 ComfyUI · ${m.model_family || '未标注模型'}`} -
-
{/each} + {filtered.filter((item) => groupKey(item) === groupKey(m)) + .length} + {/if} + {#if !closedGroups[groupKey(m)]} +
+ +
+ +
+
{/if}{/each} {#if !filtered.length}

没有符合条件的工作流。可以清空搜索或更改筛选。

{/if} @@ -293,9 +321,9 @@ >{pendingCount} 个工作流待配置 →{/if}云端模型与接口 ↗ + >云端接口 ↗ @@ -320,9 +348,21 @@ justify-content: space-between; } header strong { + display: flex; + align-items: center; + gap: 8px; font-size: 14px; font-weight: 600; - display: block; + } + .desk-glyph { + width: var(--desk-glyph-size); + height: var(--desk-glyph-size); + display: grid; + place-items: center; + border-radius: var(--desk-slot-radius); + color: var(--scene); + background: var(--desk-glyph-bg); + box-shadow: var(--desk-glyph-shadow); } .rail-tools { padding: var(--desk-rail-gap) 12px; @@ -339,6 +379,10 @@ background: var(--well); } .kind-tabs button { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 6px; flex: 1; height: 28px; padding: 0 12px; @@ -464,23 +508,43 @@ color: var(--tx-3); } .family-label { + width: 100%; + border: 0; + background: var(--bg-1); + gap: 6px; + text-align: left; + cursor: pointer; font-size: 11px; color: var(--tx-3); - padding: 8px; + padding: 0 6px; + height: var(--desk-group-height); + display: flex; + align-items: center; + margin-top: 8px; + } + .family-label span { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + .family-label small { + margin-left: auto; + font: 10.5px var(--mono); } .rail-list { flex: 1; min-height: 0; overflow: auto; - padding: 0 8px 8px; - scrollbar-gutter: stable; + padding: 4px 8px 12px; + scrollbar-gutter: auto; } .workflow-row { border: 1px solid transparent; position: relative; display: flex; align-items: center; - border-radius: 10px; + border-radius: var(--desk-row-radius); margin: 2px 0; } .workflow-row.selected { @@ -491,21 +555,12 @@ var(--bg-3) ); } - .workflow-row.selected::before { - content: ''; - width: 3px; - height: 22px; - position: absolute; - left: 0; - background: var(--scene); - border-radius: 3px; - } .workflow-pick { display: flex; align-items: center; - gap: 9px; - padding: 9px 8px; - min-height: 55px; + gap: var(--desk-row-gap); + padding: var(--desk-row-padding); + min-height: var(--desk-row-height); width: 100%; min-width: 0; text-align: left; @@ -517,14 +572,14 @@ .workflow-pick i { display: grid; place-items: center; - width: 32px; - height: 32px; + width: var(--desk-family-size); + height: var(--desk-family-size); flex-shrink: 0; border-radius: 9px; border: 1px solid var(--line-2); - color: var(--scene); - background: var(--bg-3); - font-size: 16px; + color: var(--desk-family-ink); + background: var(--scene); + font: var(--desk-family-font); font-style: normal; } .workflow-pick > span { @@ -533,16 +588,16 @@ } .workflow-pick b { display: block; - font-size: 12px; - font-weight: 550; + font-size: var(--desk-row-name-size); + font-weight: var(--desk-row-name-weight); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; - margin-bottom: 4px; + margin-bottom: 1px; } .workflow-pick small { display: block; - font-size: 10px; + font-size: var(--desk-row-detail-size); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; @@ -558,7 +613,7 @@ opacity: 0; padding: 2px; height: 24px; - width: 20px; + width: 24px; border: 0; color: var(--text-3); background: none; @@ -592,25 +647,33 @@ cursor: pointer; } footer { - padding: 12px; - display: grid; + padding: 8px; + display: flex; + flex-wrap: wrap; gap: 6px; border-top: 1px solid var(--line); } footer a, .manage { - font-size: 11px; - color: var(--text-3); + font-size: 12px; + color: var(--tx-2); text-decoration: none; } + footer a, + .manage { + flex: 1; + white-space: nowrap; + border: 1px solid var(--line-2); + border-radius: var(--desk-action-radius); + background: var(--bg-3); + } .manage { text-align: left; display: flex; align-items: center; gap: 7px; - background: none; - border: 0; - padding: 7px 8px; + padding: 0 9px; + height: 26px; cursor: pointer; } .manage small { diff --git a/calliope-web/src/lib/components/desk/HistoryRail.svelte b/calliope-web/src/lib/components/desk/HistoryRail.svelte index 2d918fe7..e6a13466 100644 --- a/calliope-web/src/lib/components/desk/HistoryRail.svelte +++ b/calliope-web/src/lib/components/desk/HistoryRail.svelte @@ -1,4 +1,6 @@ -
+
0 && + ds.slots.every((s) => s.firstFrame || s.lastFrame)} +>
2 · 准备这次创作 {#if hasSlots && !dock}
{/if} - {#if dock}
- {#if hasSlots} - {#each ds.slots.slice(0, 3) as slot (slot.inp.nodeId)}{/each} - {#if ds.lists.length}{/if} - - {/if} + {#if dock && hasSlots}
+ +
{/if}
{#if ds.prompt}
@@ -314,10 +311,13 @@ onchange={(e) => onPickEndpoint(e.currentTarget.value)} > {#if !endpoints.length}{/if} - {#each endpoints as ep (ep.id)}{ep.name}{/each} - 配置或检查连接 ↗配置或检查连接 ↗ {/if}
@@ -377,7 +377,7 @@ + import Icon from '$lib/components/ui/Icon.svelte'; import SceneCapturePicker from './SceneCapturePicker.svelte'; import MediaSlot from '$lib/components/studio/MediaSlot.svelte'; import RefImageGrid from '$lib/components/studio/RefImageGrid.svelte'; @@ -7,7 +8,15 @@ import type { ComfyDynamicInput } from '$lib/comfy/types'; import { toast } from '$lib/toast'; import { t } from '$lib/i18n.svelte'; - import { h3Warnings, listKind, listPaths, type DeskSchema, type FormValue, type FormValues, type SlotDef } from './schema'; + import { + h3Warnings, + listKind, + listPaths, + type DeskSchema, + type FormValue, + type FormValues, + type SlotDef, + } from './schema'; /** * 输入槽 (spec-v2 §3.3): exactly the media inputs the workflow JSON wires — @@ -17,6 +26,7 @@ */ interface Props { ds: DeskSchema; + compact?: boolean; allowScene?: boolean; values: FormValues; showErrors: boolean; @@ -27,36 +37,51 @@ onFromResults: (nodeId: string) => void; } - let { ds, allowScene = false, values, showErrors, errors, busy = $bindable(false), onSet, onFromResults }: Props = $props(); - - let sceneOpen = $state(false); - let sceneNode = $state(""); - $effect(() => { - void ds; - sceneOpen = false; - sceneNode = ""; - }); - function pickScene(path: string) { - const slot = ds.slots.find( - (s) => s.inp.nodeId === sceneNode && s.mediaKind === "image" && !s.mask, - ); - const list = ds.lists.find( - (g) => g.nodeId === sceneNode && g.kind === "image_list", - ); - if (!allowScene) return; - if (slot) onSet(sceneNode, path); - else if (list) { - const paths = listPaths(values, list); - if (paths.length >= (list.max ?? 9)) { - toast.error("参考图数量已达上限"); - return; - } - onSet(sceneNode, [...paths, path]); - } - } + let { + ds, + compact = false, + allowScene = false, + values, + showErrors, + errors, + busy = $bindable(false), + onSet, + onFromResults, + }: Props = $props(); + let sceneOpen = $state(false); + let sceneNode = $state(''); + $effect(() => { + void ds; + sceneOpen = false; + sceneNode = ''; + }); + function pickScene(path: string) { + const slot = ds.slots.find( + (s) => s.inp.nodeId === sceneNode && s.mediaKind === 'image' && !s.mask, + ); + const list = ds.lists.find( + (g) => g.nodeId === sceneNode && g.kind === 'image_list', + ); + if (!allowScene) return; + if (slot) onSet(sceneNode, path); + else if (list) { + const paths = listPaths(values, list); + if (paths.length >= (list.max ?? 9)) { + toast.error('参考图数量已达上限'); + return; + } + onSet(sceneNode, [...paths, path]); + } + } const uploads = createUploadManager(); + const paired = $derived( + compact && + ds.lists.length === 0 && + ds.slots.length > 0 && + ds.slots.every((s) => s.firstFrame || s.lastFrame), + ); type Reject = { name: string; message: string }; let rejects = $state>({}); let fileSizes = $state>({}); @@ -104,11 +129,15 @@ const ok = files.filter((f) => !mediaFileProblem(mk, f)); setRejects( key, - files.filter((f) => mediaFileProblem(mk, f)).map((f) => ({ name: f.name, message: mediaFileProblem(mk, f)! })), + files + .filter((f) => mediaFileProblem(mk, f)) + .map((f) => ({ name: f.name, message: mediaFileProblem(mk, f)! })), ); const start = listPaths(values, entry); if (start.length + ok.length > max) { - toast.error(t('studio.errRefMax', { max, n: start.length, k: ok.length })); + toast.error( + t('studio.errRefMax', { max, n: start.length, k: ok.length }), + ); return; } for (const f of ok) { @@ -118,7 +147,11 @@ } } - async function groupReplace(entry: ComfyDynamicInput, index: number, file: File) { + async function groupReplace( + entry: ComfyDynamicInput, + index: number, + file: File, + ) { const key = entry.nodeId; const problem = mediaFileProblem(listKind(entry), file); if (problem) { @@ -130,7 +163,11 @@ replacingIndex = index; try { const path = await upload(key, file); - if (path) onSet(key, listPaths(values, entry).map((p, i) => (i === index ? path : p))); + if (path) + onSet( + key, + listPaths(values, entry).map((p, i) => (i === index ? path : p)), + ); } finally { replacingKey = ''; replacingIndex = -1; @@ -178,15 +215,23 @@ if (unplaced) toast.error(t('desk.dropNoSlot', { n: unplaced })); } - + + {#if ds.slots.length || ds.lists.length} -
- {#each ds.slots as s (s.inp.nodeId)} +
+ {#if paired}
+ 首尾帧 · {ds.slots[0]?.inp.required ? '首帧必填' : '按工作流要求填写'} +
{/if} + {#each ds.slots as s, index (s.inp.nodeId)} + {#if paired && index > 0}{/if} {@const reject = rejects[s.inp.nodeId]?.[0]} slotFile(s, f)} onFromResults={() => onFromResults(s.inp.nodeId)} - onFromScene={allowScene && s.mediaKind === "image" && !s.mask ? () => { sceneNode = s.inp.nodeId; sceneOpen = true; } : undefined} + onFromScene={!compact && + allowScene && + s.mediaKind === 'image' && + !s.mask + ? () => { + sceneNode = s.inp.nodeId; + sceneOpen = true; + } + : undefined} onClear={() => { setRejects(s.inp.nodeId, []); onSet(s.inp.nodeId, ''); @@ -222,17 +276,32 @@ anchorId={`slot-${entry.nodeId}`} onAdd={(files) => groupAdd(entry, files)} onReplace={(i, file) => groupReplace(entry, i, file)} - onRemove={(i) => onSet(entry.nodeId, listPaths(values, entry).filter((_, j) => j !== i))} - onDismissReject={(i) => setRejects(entry.nodeId, (rejects[entry.nodeId] ?? []).filter((_, j) => j !== i))} - onFromScene={allowScene && entry.kind === "image_list" ? () => { sceneNode = entry.nodeId; sceneOpen = true; } : undefined} - onFromResults={entry.kind === 'image_list' ? () => onFromResults(entry.nodeId) : undefined} + onRemove={(i) => + onSet( + entry.nodeId, + listPaths(values, entry).filter((_, j) => j !== i), + )} + onDismissReject={(i) => + setRejects( + entry.nodeId, + (rejects[entry.nodeId] ?? []).filter((_, j) => j !== i), + )} + onFromScene={!compact && allowScene && entry.kind === 'image_list' + ? () => { + sceneNode = entry.nodeId; + sceneOpen = true; + } + : undefined} + onFromResults={entry.kind === 'image_list' + ? () => onFromResults(entry.nodeId) + : undefined} /> {/each}
{#if showErrors && errors.firstLast}

{errors.firstLast}

{/if} - {#if ds.hasH3Hint} + {#if ds.hasH3Hint && !compact}

{t('wfMedia.h3Limits')}

{#each warns as w (w)} @@ -246,8 +315,29 @@ .tray { display: flex; flex-wrap: wrap; - gap: var(--sp-3) var(--sp-6); - padding: var(--sp-4) var(--sp-5) 0; + gap: var(--tray-gap, var(--sp-3) var(--sp-6)); + padding: var(--tray-padding, var(--sp-4) var(--sp-5) 0); + } + .compact { + --tray-gap: 14px; + --tray-padding: 0; + --input-tile-size: var(--slot-size); + --input-tile-radius: var(--desk-slot-radius); + --input-slot-gap: var(--desk-slot-gap); + --input-slot-font: 11px; + } + .paired { + display: grid; + grid-template-columns: var(--slot-size) 14px var(--slot-size); + column-gap: var(--desk-slot-gap); + row-gap: var(--desk-slot-gap); + align-items: center; + } + .frame-label { + grid-column: 1 / -1; + height: 20px; + font-size: 11px; + color: var(--tx-3); } .note { margin: 0; diff --git a/calliope-web/src/lib/components/studio/MediaSlot.svelte b/calliope-web/src/lib/components/studio/MediaSlot.svelte index 1ff8fd24..00c0b363 100644 --- a/calliope-web/src/lib/components/studio/MediaSlot.svelte +++ b/calliope-web/src/lib/components/studio/MediaSlot.svelte @@ -1,7 +1,11 @@
-
- {label} - {#if required}{/if} - {#if hint}{hint}{/if} - {#if onFromScene}{/if} - {#if allowFromResults && onFromResults} - - {/if} -
+ {#if !caption}
+ {label} + {#if required}{/if} + {#if hint}{hint}{/if} + {#if onFromScene}{/if} + {#if allowFromResults && onFromResults} + + {/if} +
{/if} {#if filled} -
0} class:bad={!!error} {...dragHandlers}> +
0} + class:bad={!!error} + {...dragHandlers} + > + {#if caption}{label}{hint ? ` · ${hint}` : ''}{/if} {#if uploading} {:else} - {/if} @@ -177,12 +208,23 @@ {#if uploading} {:else} - + + {#if caption}{label}{hint ? ` · ${hint}` : ''}{/if} {/if} {/if}
- {#if error?.trim()}{error}{/if} + {#if error?.trim()}{error}{/if}