Compare commits

...
Author SHA1 Message Date
总工 0c620e61fe HEL-661: merge C-batch generate desk (feat/hel-653-c-generate) 2026-09-30 11:39:13 +08:00
6f4d50f4f0 HEL-653: C 批生成台——拆分输入台/工作流选择/输入槽/记录流/检视区
- StudioGenerate 拆为 generate/ 下的 Composer、WorkflowPicker、SlotTray、ParamChip、
  TakeFeed/TakeCard、Inspector、ParamsPanel、ContextBar,逻辑收进 schema/takes/context
- 输入槽只按工作流 JSON 实际接线渲染;常用参数芯片 + 高级参数折叠,显示原值与已改标记
- 工作台素材/镜头卡新增「在生成台调参」,带项目上下文进入;结果不回填(后端无目标)
- 共享样式(a-chip/a-seg/a-slot/a-tile 等)进 app.css;ShellPop 支持向上弹出
- /image、/video 仍是同一生成台,按用途预选默认工作流与筛选

Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: multica-agent <github@multica.ai>
2026-09-30 10:59:46 +08:00
8b03291327 HEL-652: B 批导航壳层——60px 图标轨、顶栏与手机底栏
- 216px 文字侧栏改为 60px 图标轨:金色渐变 Logo、当前页金色指示条滑动、350ms 延迟悬停提示,底部连接/模型/设置/语言
- 顶栏:标题+副标题、项目范围胶囊(全部项目列表/最近项目)、Ctrl/⌘K 搜索、减少动态开关、任务托盘胶囊、本次连接胶囊(名称+延迟)
- ≤760px:隐藏图标轨,底部 项目/创作/生成/更多 四个标签,更多为底部抽屉(其余页面、搜索、减少动态、语言);删除「手机端暂不适配」拦截
- 新增 ShellPop(Esc/↑↓/Tab 循环/外部点击关闭、焦点归还)与 CommandPalette(复用 Modal)
- 减少动态:跟随系统设置,另可手动开启并持久化(data-reduce)
- .a-darkroom 背景接入 --page-glow;新增壳层尺寸 token 与 .a-kbd/.a-menu-* 公共类
- 生图/生视频顶栏去掉与全局胶囊重复的连接状态(参数栏仍显示所选连接与队列)
- 路由 URL 不变(/projects 仍重定向到项目总览 /)

Co-authored-by: multica-agent <github@multica.ai>
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-30 09:26:36 +08:00
36 changed files with 6294 additions and 3560 deletions
@@ -17,6 +17,10 @@
allowUpload?: boolean;
/** Force required-field error rings visible (e.g. after a blocked Generate). */
showErrors?: boolean;
/** Show each field's workflow value (原值) and mark fields that differ from it. */
showOrig?: boolean;
/** Extra per-field messages keyed by nodeId (e.g. range checks); shown with showErrors. */
errors?: Record<string, string>;
/** Emits labels of required-but-empty inputs whenever the set changes. */
onValidityChange?: (missing: string[]) => void;
onChange?: (values: Record<string, string | number>) => void;
@@ -30,10 +34,23 @@
assetOptions = [],
allowUpload = false,
showErrors = false,
showOrig = false,
errors = {},
onValidityChange,
onChange,
}: Props = $props();
function hasOrig(inp: ComfyDynamicInput): boolean {
return inp.defaultValue !== undefined && inp.defaultValue !== '';
}
function isChanged(inp: ComfyDynamicInput): boolean {
if (!showOrig) return false;
const v = values[inp.nodeId];
if (isBlank(v)) return hasOrig(inp);
return !hasOrig(inp) || String(v) !== String(inp.defaultValue);
}
// Unique per mounted form so label/id pairs never collide across instances.
const uid = Math.random().toString(36).slice(2, 8);
let touched = $state<Record<string, boolean>>({});
@@ -80,6 +97,11 @@
return isMissing(inp) && (showErrors || !!touched[inp.nodeId]);
}
function extraError(inp: ComfyDynamicInput): string | null {
const msg = errors[inp.nodeId]?.trim();
return msg && !isMissing(inp) && (showErrors || !!touched[inp.nodeId]) ? msg : null;
}
function thumbFor(current: string): string | null {
if (!current) return null;
const opt = assetOptions.find((o) => o.path === current);
@@ -224,7 +246,9 @@
{#each inputs as inp (inp.nodeId)}
{@const current = String(values[inp.nodeId] ?? '')}
{@const id = inputId(inp.nodeId)}
{@const errored = showError(inp)}
{@const extra = extraError(inp)}
{@const errored = showError(inp) || !!extra}
{@const changed = isChanged(inp)}
<div class="field">
<label class="label" for={id}>
{inp.label}
@@ -235,6 +259,9 @@
{#if inp.role}<span class="role">{inp.role}</span>{/if}
<span class="kind">{inp.kind}</span>
{/if}
{#if showOrig && hasOrig(inp)}
<span class="orig">{t('cdf.orig', { v: String(inp.defaultValue) })}</span>
{/if}
</label>
{#if readonly}
<div class="readonly">{values[inp.nodeId] ?? inp.defaultValue ?? '—'}</div>
@@ -243,6 +270,7 @@
{id}
rows="3"
class:invalid={errored}
class:changed
aria-required={inp.required || undefined}
aria-invalid={errored || undefined}
value={current}
@@ -254,6 +282,7 @@
{id}
type="number"
class:invalid={errored}
class:changed
aria-required={inp.required || undefined}
aria-invalid={errored || undefined}
value={values[inp.nodeId] ?? ''}
@@ -264,6 +293,7 @@
<select
{id}
class:invalid={errored}
class:changed
value={current || String(inp.defaultValue ?? '')}
onchange={(e) => setValue(inp.nodeId, e.currentTarget.value)}
onblur={() => markTouched(inp.nodeId)}
@@ -445,6 +475,7 @@
{id}
type="text"
class:invalid={errored}
class:changed
aria-required={inp.required || undefined}
aria-invalid={errored || undefined}
value={current}
@@ -453,7 +484,7 @@
/>
{/if}
{#if errored}
<span class="error-msg" role="alert">{t('cdf.requiredMsg', { label: inp.label })}</span>
<span class="error-msg" role="alert">{extra ?? t('cdf.requiredMsg', { label: inp.label })}</span>
{/if}
</div>
{/each}
@@ -516,11 +547,23 @@
border-color: var(--accent);
box-shadow: 0 0 0 3px var(--accent-glow);
}
input.changed,
textarea.changed,
select.changed {
border-color: var(--accent-line);
}
input.invalid,
textarea.invalid,
select.invalid {
border-color: var(--error);
}
.orig {
float: right;
font-weight: 400;
font-size: var(--fs-mini);
font-family: var(--mono);
color: var(--text-muted);
}
input.invalid:focus-visible,
textarea.invalid:focus-visible,
select.invalid:focus-visible {
@@ -146,7 +146,7 @@
}
@media (max-width: 760px) {
.toast-host {
bottom: 20px;
bottom: calc(var(--tabbar-h) + var(--sp-5) + env(safe-area-inset-bottom));
right: 16px;
left: 16px;
max-width: none;
@@ -0,0 +1,410 @@
<script lang="ts">
import Icon from '$lib/components/ui/Icon.svelte';
import ShellPop from '$lib/components/shell/ShellPop.svelte';
import type { ComfyDynamicInput, Workflow } from '$lib/comfy/types';
import { t } from '$lib/i18n.svelte';
import ParamChip from './ParamChip.svelte';
import SlotTray from './SlotTray.svelte';
import WorkflowPicker from './WorkflowPicker.svelte';
import { chipValue, type BlockReason, type DeskSchema, type FormValue, type FormValues } from './schema';
/**
* 输入台 (spec-v2 §3.3): slots from the workflow JSON, the prompt, and one
* row of chips — workflow, connection, common params, 参数 — ending in
* 生成 with its reason line. The whole composer takes dropped files.
* ≤760px it collapses to one line and rises as a bottom sheet on focus.
*/
interface Props {
ds: DeskSchema;
values: FormValues;
workflows: Workflow[];
current: Workflow | null;
endpoints: Array<{ id: string; name: string }>;
endpointId: string;
conn: { state: 'ok' | 'fail' | 'untested' | 'dry'; ms?: number | null };
errors: Record<string, string>;
showErrors: boolean;
block: BlockReason | null;
changedCount: number;
submitting: boolean;
/** Shows 已提交 on the button for --d-flash. */
sent: boolean;
slotBusy?: boolean;
promptEl?: HTMLTextAreaElement | null;
onSet: (nodeId: string, value: FormValue) => void;
onPickWorkflow: (wf: Workflow) => void;
onPickEndpoint: (id: string) => void;
onSubmit: () => void;
onReveal: () => void;
onOpenParams: () => void;
onFromResults: (nodeId: string) => void;
}
let {
ds,
values,
workflows,
current,
endpoints,
endpointId,
conn,
errors,
showErrors,
block,
changedCount,
submitting,
sent,
slotBusy = $bindable(false),
promptEl = $bindable(null),
onSet,
onPickWorkflow,
onPickEndpoint,
onSubmit,
onReveal,
onOpenParams,
onFromResults,
}: Props = $props();
let tray = $state<ReturnType<typeof SlotTray> | null>(null);
let root = $state<HTMLElement | null>(null);
let dragDepth = $state(0);
let expanded = $state(false);
let connOpen = $state(false);
const hasSlots = $derived(ds.slots.length + ds.lists.length > 0);
const promptVal = $derived(ds.prompt ? String(values[ds.prompt.nodeId] ?? '') : '');
const promptErr = $derived(showErrors && ds.prompt ? errors[ds.prompt.nodeId] : undefined);
const endpointName = $derived(endpoints.find((e) => e.id === endpointId)?.name ?? t('desk.noConn'));
/** Composer chips: ratio, the width × height pair, resolution, duration, seed. */
const chips = $derived.by(() => {
const out: Array<{ key: string; inputs: ComfyDynamicInput[]; text: string }> = [];
if (ds.ratio) out.push({ key: ds.ratio.nodeId, inputs: [ds.ratio], text: chipValue(ds.ratio, values, ds) });
if (ds.width && ds.height) {
const w = values[ds.width.nodeId] ?? ds.width.defaultValue ?? '?';
const h = values[ds.height.nodeId] ?? ds.height.defaultValue ?? '?';
out.push({ key: ds.width.nodeId, inputs: [ds.width, ds.height], text: `${w}×${h}` });
} else {
for (const inp of [ds.width, ds.height]) {
if (inp) out.push({ key: inp.nodeId, inputs: [inp], text: `${inp.label} ${chipValue(inp, values, ds)}` });
}
}
for (const inp of [ds.resolution, ds.duration, ds.seed]) {
if (inp) out.push({ key: inp.nodeId, inputs: [inp], text: chipValue(inp, values, ds) });
}
return out;
});
function onDrop(e: DragEvent) {
e.preventDefault();
dragDepth = 0;
const files = Array.from(e.dataTransfer?.files ?? []);
if (files.length) tray?.dropFiles(files);
}
function hasFiles(e: DragEvent): boolean {
return Array.from(e.dataTransfer?.types ?? []).includes('Files');
}
function onFocusOut(e: FocusEvent) {
const next = e.relatedTarget as Node | null;
if (!next || !root?.contains(next)) expanded = false;
}
function onKeydown(e: KeyboardEvent) {
if (e.key === 'Escape' && expanded && !e.defaultPrevented) {
expanded = false;
(document.activeElement as HTMLElement | null)?.blur();
}
}
</script>
<div class="composer-wrap">
<div class="a-sprocket" aria-hidden="true"></div>
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
bind:this={root}
class="composer"
class:drag={dragDepth > 0}
class:expanded
onfocusin={() => (expanded = true)}
onfocusout={onFocusOut}
onkeydown={onKeydown}
ondragenter={(e) => {
if (!hasFiles(e)) return;
e.preventDefault();
dragDepth += 1;
}}
ondragleave={() => (dragDepth = Math.max(0, dragDepth - 1))}
ondragover={(e) => hasFiles(e) && e.preventDefault()}
ondrop={onDrop}
>
{#if hasSlots}
<div class="slots">
<SlotTray bind:this={tray} bind:busy={slotBusy} {ds} {values} {showErrors} {errors} {onSet} {onFromResults} />
</div>
{/if}
{#if ds.prompt}
<textarea
bind:this={promptEl}
id="desk-prompt"
class="prompt"
class:invalid={!!promptErr}
rows="2"
value={promptVal}
placeholder={t('desk.promptPh')}
aria-label={t('studio.promptLabel')}
aria-invalid={!!promptErr || undefined}
aria-keyshortcuts="Control+Enter Meta+Enter /"
oninput={(e) => onSet(ds.prompt!.nodeId, e.currentTarget.value)}
></textarea>
{#if promptErr}<p class="a-slot-hint err prompt-err" role="alert">{promptErr}</p>{/if}
{:else if current}
<p class="no-prompt">{t('desk.noPromptInput')}</p>
{/if}
<div class="foot">
<WorkflowPicker {workflows} {current} {endpoints} onPick={onPickWorkflow} />
<div class="conn opt">
<button
type="button"
id="desk-conn"
class="a-chip"
class:err={conn.state === 'fail'}
aria-haspopup="dialog"
aria-expanded={connOpen}
title={t('studio.connLabel')}
onclick={() => (connOpen = !connOpen)}
>
<span class="status-chip-dot {conn.state === 'ok' ? 'run' : conn.state === 'fail' ? 'fail' : 'idle'}"></span>
<span class="lbl">{endpointName}</span>
{#if conn.state === 'ok' && conn.ms != null}<span class="mono">· {Math.round(conn.ms)} ms</span>{/if}
{#if conn.state === 'dry'}<span>· {t('desk.dryRun')}</span>{/if}
</button>
<ShellPop bind:open={connOpen} label={t('studio.connLabel')} placement="top" width="280px">
<div class="a-menu-group">{t('studio.connLabel')}</div>
{#each endpoints as ep (ep.id)}
<button
type="button"
class="a-menu-item"
aria-current={ep.id === endpointId ? 'true' : undefined}
onclick={() => {
onPickEndpoint(ep.id);
connOpen = false;
}}
>
<span class="a-menu-label">{ep.name}</span>
</button>
{/each}
<a class="a-menu-item" href="/connections">
<Icon name="link" size={15} />
<span class="a-menu-label">{t('studio.connManage')}</span>
</a>
</ShellPop>
</div>
{#each chips as c (c.key)}
<div class="opt">
<ParamChip
inputs={c.inputs}
{values}
text={c.text}
invalid={showErrors && c.inputs.some((i) => !!errors[i.nodeId]?.trim())}
{onSet}
/>
</div>
{/each}
{#if ds.common.length + ds.advanced.length > 0}
<button type="button" class="a-chip opt" class:changed={changedCount > 0} onclick={onOpenParams}>
<Icon name="settings" size={14} />
<span class="lbl">
{changedCount > 0 ? t('desk.paramsChanged', { n: changedCount }) : t('desk.paramsAll')}
</span>
</button>
{/if}
<div class="submit">
{#if block}
<button type="button" class="why" class:err={block.tone === 'err'} onclick={onReveal}>
{block.text}
</button>
{/if}
<button
type="button"
class="a-btn primary gen"
class:sent
disabled={!!block || submitting || slotBusy}
aria-keyshortcuts="Control+Enter Meta+Enter"
title={t('desk.genHint')}
onclick={onSubmit}
>
<span class="l-idle"><Icon name="sparkle" size={13} />{t('desk.generate')}</span>
<span class="l-sent" aria-hidden={!sent}><Icon name="check" size={13} />{t('desk.sent')}</span>
</button>
</div>
</div>
</div>
</div>
<style>
.composer-wrap {
padding: var(--sp-2) var(--sp-7) var(--sp-6);
}
.a-sprocket {
margin: 0 var(--sp-2) var(--sp-3);
}
.composer {
border: 1px solid var(--line-2);
border-radius: var(--r-lg);
background: var(--composer-bg);
transition:
border-color var(--d-state) ease,
box-shadow var(--d-state) ease;
}
.composer:focus-within {
border-color: var(--accent-line);
box-shadow: var(--composer-focus);
}
.composer.drag {
border-color: var(--accent-line);
box-shadow: 0 0 0 3px var(--accent-soft);
}
.slots {
max-height: 40vh;
overflow-y: auto;
}
.prompt {
display: block;
box-sizing: border-box;
width: 100%;
min-height: 52px;
max-height: 180px;
padding: var(--sp-5) var(--sp-6) var(--sp-2);
border: 0;
outline: 0;
background: transparent;
color: var(--text);
font: inherit;
line-height: 1.6;
resize: none;
field-sizing: content;
}
.prompt:focus-visible {
outline: 0;
}
.prompt::placeholder {
color: var(--text-3);
}
.prompt-err,
.no-prompt {
margin: 0;
padding: 0 var(--sp-6);
}
.no-prompt {
padding-top: var(--sp-5);
font-size: var(--fs-aux);
color: var(--text-3);
}
.foot {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: var(--sp-2);
padding: var(--sp-2) var(--sp-3) var(--sp-3);
}
.conn {
position: relative;
}
.submit {
display: flex;
align-items: center;
gap: var(--sp-4);
margin-left: auto;
}
.why {
padding: 0;
border: 0;
background: none;
font: inherit;
font-size: var(--fs-aux);
color: var(--warn);
white-space: nowrap;
text-decoration: underline dotted;
text-underline-offset: 3px;
cursor: pointer;
}
.why.err {
color: var(--err);
}
/* 生成:按下即缩,提交后文字换成「已提交」约 --d-flash,期间仍可再次提交 */
.gen {
display: inline-grid;
min-width: 84px;
border-radius: var(--r-pill);
}
.gen > span {
grid-area: 1 / 1;
display: inline-flex;
align-items: center;
justify-content: center;
gap: var(--sp-1);
transition:
opacity var(--d-state) ease,
transform var(--d-pop) var(--ease-out);
}
.gen .l-sent {
opacity: 0;
transform: translateY(8px);
}
.gen.sent .l-idle {
opacity: 0;
transform: translateY(-8px);
}
.gen.sent .l-sent {
opacity: 1;
transform: none;
}
:global(:root[data-reduce='1']) .gen > span {
transform: none;
}
@media (max-width: 1179px) {
.composer-wrap {
padding: var(--sp-2) var(--sp-6) var(--sp-5);
}
}
@media (max-width: 760px) {
.composer-wrap {
padding: var(--sp-1) var(--sp-4) var(--sp-4);
}
.composer:not(.expanded) .slots,
.composer:not(.expanded) .opt {
display: none;
}
.composer:not(.expanded) .prompt {
min-height: 40px;
max-height: 40px;
padding: var(--sp-4) var(--sp-5) var(--sp-1);
white-space: nowrap;
overflow: hidden;
}
.composer.expanded {
position: fixed;
left: 0;
right: 0;
bottom: calc(var(--tabbar-h) + env(safe-area-inset-bottom));
z-index: 45;
border-bottom: 0;
border-radius: var(--r-lg) var(--r-lg) 0 0;
box-shadow: var(--shadow-float);
max-height: calc(100dvh - var(--topbar-h) - var(--tabbar-h));
overflow-y: auto;
}
.composer.expanded .slots {
max-height: none;
}
.submit {
gap: var(--sp-3);
}
}
</style>
@@ -0,0 +1,70 @@
<script lang="ts">
import Icon from '$lib/components/ui/Icon.svelte';
import { t } from '$lib/i18n.svelte';
import { backHref, type DeskContext } from './context';
/**
* 上下文条 (spec-v2 §3.3): the desk was opened from a creation stage.
* Says where from, and plainly that takes stay in the desk feed — the
* desk's generate call has no shot/asset link to fill back.
*/
interface Props {
ctx: DeskContext;
projectTitle: string;
onDismiss: () => void;
}
let { ctx, projectTitle, onDismiss }: Props = $props();
const stageName = $derived(t(`workbench.stage.${ctx.stage}`));
</script>
<div class="context-bar" role="status">
<Icon name="link" size={14} />
<span class="from">
<b>{t('desk.ctxFrom', { project: projectTitle })}</b>{#if ctx.label}&nbsp;· {ctx.label}{/if}
</span>
<span class="note">{t('desk.ctxNoBackfill', { stage: stageName })}</span>
<a class="a-btn ghost xs back" href={backHref(ctx)}>{t('desk.ctxBack', { stage: stageName })}</a>
<button type="button" class="a-btn ghost xs" aria-label={t('desk.ctxDismiss')} onclick={onDismiss}>
<Icon name="close" size={12} />
</button>
</div>
<style>
.context-bar {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: var(--sp-2) var(--sp-4);
margin: var(--sp-4) var(--sp-7) 0;
padding: var(--sp-3) var(--sp-5);
border-radius: var(--r-md);
background: var(--run-soft);
color: var(--text);
font-size: var(--fs-aux);
}
.context-bar > :global(svg) {
color: var(--run);
}
.from b {
color: var(--run);
font-weight: 600;
}
.note {
color: var(--text-2);
}
.back {
margin-left: auto;
}
@media (max-width: 1179px) {
.context-bar {
margin: var(--sp-4) var(--sp-6) 0;
}
}
@media (max-width: 760px) {
.context-bar {
margin: var(--sp-3) var(--sp-5) 0;
}
}
</style>
@@ -0,0 +1,434 @@
<script lang="ts">
import type { Snippet } from 'svelte';
import Icon from '$lib/components/ui/Icon.svelte';
import { assetUrl } from '$lib/api';
import type { Job, Workflow } from '$lib/comfy/types';
import { progressFor } from '$lib/jobProgress';
import { jobStatusInfo } from '$lib/jobStatus';
import { parseDbTs } from '$lib/dbtime';
import { t } from '$lib/i18n.svelte';
import { usageLabel } from './schema';
import { elapsedSec, fmtClock, fmtDur, inputsOf, isVideoOutput, promptOf, specOf } from './takes';
/**
* 检视区 (spec-v2 §3.3): 结果 shows the selected take on the projection
* stage with its spec, inputs and workflow; 参数 hosts the full form.
* ≥1180 it is a column (hide with ]), 761–1179 a right overlay, ≤760
* fullscreen — `overlay` / `open` drive the last two.
*/
interface Props {
tab: 'result' | 'params';
job: Job | null;
wf: Workflow | undefined;
nowMs: number;
open: boolean;
/** Collapsed or off-screen — nothing inside may take focus. */
inactive: boolean;
/** Current workflow can take an image output (送入当前图片槽). */
canSendToSlot: boolean;
params: Snippet;
onClose: () => void;
onReuse: (job: Job) => void;
onSendToSlot: (path: string) => void;
onDelete: (job: Job) => void;
}
let {
tab = $bindable(),
job,
wf,
nowMs,
open,
inactive,
canSendToSlot,
params,
onClose,
onReuse,
onSendToSlot,
onDelete,
}: Props = $props();
let outIndex = $state(0);
$effect.pre(() => {
void job?.id;
outIndex = 0;
});
const st = $derived(job ? jobStatusInfo(job) : null);
const outputs = $derived(job && st?.tone === 'ok' ? job.output_paths : []);
const output = $derived(outputs[Math.min(outIndex, outputs.length - 1)] ?? null);
const video = $derived(!!job && !!output && isVideoOutput(job, output));
const prompt = $derived(job ? promptOf(job, wf) : '');
const inputs = $derived(job ? inputsOf(job, wf) : []);
const spec = $derived(job ? specOf(job, wf) : []);
const endpoint = $derived(
((job?.payload?.endpoint as Record<string, unknown> | undefined)?.name as string | undefined) ?? '',
);
const progress = $derived.by(() => {
if (!job || job.status !== 'running') return 0;
const live = progressFor(job.id);
const phase = live?.phase ?? job.phase;
if (phase !== 'comfy_running' && phase !== 'saving') return 0;
return live?.progress ?? 3;
});
let dims = $state('');
$effect.pre(() => {
void output;
dims = '';
});
function noteImg(e: Event) {
const img = e.currentTarget as HTMLImageElement;
dims = `${img.naturalWidth}×${img.naturalHeight}`;
}
function noteVideo(e: Event) {
const v = e.currentTarget as HTMLVideoElement;
dims = `${v.videoWidth}×${v.videoHeight}`;
}
</script>
<aside class="inspector" class:open inert={inactive} aria-label={t('desk.inspector')}>
<div class="tabs">
<div class="a-seg" role="group" aria-label={t('desk.inspector')}>
<button type="button" aria-pressed={tab === 'result'} onclick={() => (tab = 'result')}>{t('desk.tabResult')}</button>
<button type="button" aria-pressed={tab === 'params'} onclick={() => (tab = 'params')}>{t('desk.tabParams')}</button>
</div>
<span class="keys"><span class="a-kbd">]</span>{t('desk.keyInspector')}</span>
<button type="button" class="a-btn ghost sm close" aria-label={t('desk.closeInspector')} onclick={onClose}>
<Icon name="close" size={14} />
</button>
</div>
<div class="body">
{#if tab === 'params'}
{@render params()}
{:else if !job || !st}
<div class="a-stage stage"><div class="stage-empty">{t('desk.pickTake')}</div></div>
{:else}
<div class="a-stage stage">
{#if output && video}
{#key output}
<!-- svelte-ignore a11y_media_has_caption -->
<video src={assetUrl(output)} controls playsinline preload="metadata" onloadedmetadata={noteVideo}></video>
{/key}
{:else if output}
<img src={assetUrl(output)} alt={prompt || t('desk.tabResult')} onload={noteImg} />
{:else}
<div class="stage-empty">
<span>{st.label}</span>
<span class="sub">{st.tone === 'fail' ? t('desk.notGenerated') : st.detail}</span>
{#if progress > 0}
<div class="a-progress stage-bar" style:--p={progress / 100}><i></i></div>
{/if}
</div>
{/if}
</div>
{#if outputs.length > 1}
<div class="strip" role="group" aria-label={t('desk.outputs')}>
{#each outputs as p, i (p)}
<button
type="button"
class="strip-item"
aria-pressed={i === outIndex}
aria-label={t('desk.outputN', { n: i + 1 })}
onclick={() => (outIndex = i)}
>
{#if isVideoOutput(job, p)}
<Icon name="film" size={14} />
{:else}
<img src={assetUrl(p)} alt="" loading="lazy" />
{/if}
</button>
{/each}
</div>
{/if}
<div class="meta">
{#if dims}<span class="mono"><b>{dims}</b></span>{/if}
{#each spec as s (s.label)}
{#if !(dims && s.label === t('desk.specSize'))}
<span>{s.label} <b class="mono">{s.value}</b></span>
{/if}
{/each}
<span>{t('desk.elapsed')} <b class="mono">{fmtDur(elapsedSec(job, nowMs))}</b></span>
{#if endpoint}<span>{endpoint}</span>{/if}
</div>
<div class="actions">
<button type="button" class="a-btn secondary sm" onclick={() => onReuse(job)}>{t('desk.reuseToComposer')}</button>
{#if output && !video && canSendToSlot}
<button type="button" class="a-btn secondary sm" onclick={() => onSendToSlot(output)}>
{t('desk.sendToSlot')}
</button>
{/if}
{#if output}
<a class="a-btn secondary sm" href={assetUrl(output)} download>{t('desk.download')}</a>
{/if}
{#if job.status !== 'pending' && job.status !== 'running'}
<button type="button" class="a-btn danger sm" onclick={() => onDelete(job)}>{t('studio.deleteBtn')}</button>
{/if}
</div>
{#if st.tone === 'fail'}
<div class="note err" role="alert">{st.detail}</div>
{/if}
{#if inputs.length || prompt}
<div class="section-title">{t('desk.inputs')}</div>
{#if inputs.length}
<div class="inputs">
{#each inputs as inp (inp.label + inp.path)}
{#if inp.kind === 'image'}
<img class="mini" src={assetUrl(inp.path)} alt={inp.label} title={inp.label} loading="lazy" />
{:else}
<span class="mini glyph" title={inp.label}><Icon name={inp.kind === 'video' ? 'film' : 'music'} size={14} /></span>
{/if}
{/each}
</div>
{/if}
{#if prompt}<p class="prompt">{prompt}</p>{/if}
{/if}
<div class="section-title">{t('desk.workflow')}</div>
<dl class="kv">
<dt>{t('desk.kvName')}</dt>
<dd>{wf?.name ?? t('studio.jobName', { id: job.id })}</dd>
{#if wf?.usage}
<dt>{t('desk.kvUsage')}</dt>
<dd>{usageLabel(wf.usage)}</dd>
{/if}
<dt>{t('desk.kvCreated')}</dt>
<dd class="mono">{fmtClock(parseDbTs(job.created_at))}</dd>
{#if job.comfy_prompt_id}
<dt>{t('desk.kvPromptId')}</dt>
<dd class="mono">{job.comfy_prompt_id}</dd>
{/if}
</dl>
{/if}
</div>
</aside>
<style>
.inspector {
display: flex;
flex-direction: column;
min-width: 0;
min-height: 0;
overflow: hidden;
border-left: 1px solid var(--line);
background: var(--bg);
}
.tabs {
display: flex;
align-items: center;
gap: var(--sp-2);
padding: var(--sp-4) var(--sp-5);
border-bottom: 1px solid var(--line);
}
.keys {
display: inline-flex;
align-items: center;
gap: var(--sp-1);
margin-left: auto;
font-size: var(--fs-mini);
color: var(--text-3);
}
.close {
padding: 0 var(--sp-3);
}
.body {
flex: 1;
min-height: 0;
overflow-y: auto;
padding: var(--sp-6);
}
.stage {
aspect-ratio: 16 / 10;
display: grid;
place-items: center;
}
.stage > img,
.stage > video {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
object-fit: contain;
}
.stage-empty {
display: grid;
place-items: center;
gap: var(--sp-2);
padding: var(--sp-6);
text-align: center;
color: var(--text-3);
font-size: var(--fs-aux);
}
.stage-empty .sub {
color: var(--text-3);
font-size: var(--fs-mini);
}
.stage-bar {
width: 160px;
}
.strip {
display: flex;
flex-wrap: wrap;
gap: var(--sp-2);
margin-top: var(--sp-4);
}
.strip-item {
display: grid;
place-items: center;
width: 44px;
height: 44px;
padding: 0;
overflow: hidden;
border: 1px solid var(--line-2);
border-radius: var(--r-sm);
background: var(--bg-3);
color: var(--text-3);
cursor: pointer;
}
.strip-item[aria-pressed='true'] {
border-color: var(--accent);
}
.strip-item img {
width: 100%;
height: 100%;
object-fit: cover;
}
.meta {
display: flex;
flex-wrap: wrap;
gap: var(--sp-2) var(--sp-5);
margin: var(--sp-5) 0;
font-size: var(--fs-aux);
color: var(--text-3);
}
.meta b {
color: var(--text-2);
font-weight: 500;
}
.actions {
display: flex;
flex-wrap: wrap;
gap: var(--sp-2);
}
.note {
margin-top: var(--sp-5);
padding: var(--sp-3) var(--sp-4);
border-radius: var(--r-md);
font-size: var(--fs-aux);
word-break: break-word;
}
.note.err {
background: var(--err-soft);
color: var(--err);
}
.section-title {
margin: var(--sp-7) 0 var(--sp-3);
font-size: var(--fs-aux);
color: var(--text-3);
}
.inputs {
display: flex;
flex-wrap: wrap;
gap: var(--sp-2);
margin-bottom: var(--sp-3);
}
.mini {
width: 40px;
height: 40px;
border-radius: var(--r-sm);
border: 1px solid var(--line-2);
object-fit: cover;
}
.mini.glyph {
display: grid;
place-items: center;
background: var(--bg-3);
color: var(--text-3);
box-sizing: border-box;
}
.prompt {
margin: 0;
padding: var(--sp-4) var(--sp-5);
border-radius: var(--r-md);
background: var(--bg-3);
color: var(--text-2);
font-size: var(--fs-aux);
white-space: pre-wrap;
word-break: break-word;
}
.kv {
display: grid;
grid-template-columns: 92px 1fr;
gap: var(--sp-2) var(--sp-4);
margin: 0;
font-size: var(--fs-aux);
}
.kv dt {
color: var(--text-3);
}
.kv dd {
margin: 0;
color: var(--text-2);
word-break: break-all;
}
/* ≥1180: a column of the desk grid; the close button hides it (]) */
@media (max-width: 1179px) {
.inspector {
position: fixed;
top: var(--topbar-h);
right: 0;
bottom: 0;
z-index: 40;
width: min(420px, 100vw);
box-shadow: var(--shadow-float);
transform: translateX(100%);
visibility: hidden;
transition:
transform var(--d-drawer) var(--ease-drawer),
visibility 0s linear var(--d-drawer);
}
.inspector.open {
transform: none;
visibility: visible;
transition:
transform var(--d-drawer) var(--ease-drawer),
visibility 0s;
}
:global(:root[data-reduce='1']) .inspector {
transform: none;
opacity: 0;
transition:
opacity var(--d-drawer) ease,
visibility 0s linear var(--d-drawer);
}
:global(:root[data-reduce='1']) .inspector.open {
opacity: 1;
transition:
opacity var(--d-drawer) ease,
visibility 0s;
}
.keys {
display: none;
}
.close {
margin-left: auto;
}
}
@media (max-width: 760px) {
.inspector {
top: 0;
z-index: 70;
width: 100vw;
border-left: 0;
}
.body {
padding: var(--sp-5);
}
}
</style>
@@ -0,0 +1,179 @@
<script lang="ts">
import ShellPop from '$lib/components/shell/ShellPop.svelte';
import type { ComfyDynamicInput } from '$lib/comfy/types';
import { t } from '$lib/i18n.svelte';
import { inputRole, isChanged, type FormValue, type FormValues } from './schema';
/**
* One common parameter as a composer chip (spec-v2 §3.3 参数芯片): the
* pop edits it in place — options list for selects, a number field for
* the rest (seed adds 随机) — and 恢复原值 returns to the workflow value.
* `inputs` holds two entries for the width × height pair.
*/
interface Props {
inputs: ComfyDynamicInput[];
values: FormValues;
text: string;
title?: string;
invalid?: boolean;
onSet: (nodeId: string, value: FormValue) => void;
}
let { inputs, values, text, title, invalid = false, onSet }: Props = $props();
let open = $state(false);
const changed = $derived(inputs.some((i) => isChanged(i, values)));
const single = $derived(inputs.length === 1 ? inputs[0] : null);
const uid = Math.random().toString(36).slice(2, 8);
function current(inp: ComfyDynamicInput): string {
const v = values[inp.nodeId] ?? inp.defaultValue ?? '';
return Array.isArray(v) ? v.join(',') : String(v);
}
function setNumber(inp: ComfyDynamicInput, raw: string) {
if (raw === '') return onSet(inp.nodeId, '');
const n = Number(raw);
onSet(inp.nodeId, Number.isNaN(n) ? raw : n);
}
function restore() {
for (const inp of inputs) onSet(inp.nodeId, inp.defaultValue ?? '');
}
function randomSeed(inp: ComfyDynamicInput) {
const max = Math.min(inp.max ?? 2 ** 32 - 1, Number.MAX_SAFE_INTEGER);
const min = Math.max(inp.min ?? 0, 0);
onSet(inp.nodeId, min + Math.floor(Math.random() * (max - min + 1)));
}
function pickOption(inp: ComfyDynamicInput, opt: string) {
onSet(inp.nodeId, opt);
open = false;
}
</script>
<div class="param-chip">
<button
type="button"
class="a-chip"
class:changed={changed && !invalid}
class:err={invalid}
aria-haspopup="dialog"
aria-expanded={open}
title={title ?? inputs.map((i) => i.label).join(' × ')}
onclick={() => (open = !open)}
>
<span class="lbl">{text}</span>
</button>
<ShellPop bind:open label={inputs.map((i) => i.label).join(' × ')} placement="top" width="260px">
{#if single?.kind === 'select' && single.options?.length}
<div class="a-menu-group">{single.label}</div>
{#each single.options as opt (opt)}
<button
type="button"
class="a-menu-item"
aria-current={current(single) === opt ? 'true' : undefined}
onclick={() => pickOption(single, opt)}
>
<span class="a-menu-label">{opt}</span>
{#if String(single.defaultValue ?? '') === opt}
<span class="a-menu-meta">{t('desk.origValue')}</span>
{/if}
</button>
{/each}
{:else}
<div class="fields">
{#each inputs as inp (inp.nodeId)}
{@const id = `pc-${uid}-${inp.nodeId.replace(/[^a-zA-Z0-9_-]/g, '')}`}
<div class="field-row">
<label class="field-label" for={id}>
{inp.label}
{#if inp.defaultValue !== undefined && inp.defaultValue !== ''}
<span class="orig mono">{t('cdf.orig', { v: String(inp.defaultValue) })}</span>
{/if}
</label>
<div class="inline">
{#if inp.kind === 'number'}
<input
{id}
class="a-input mono"
class:invalid
type="number"
inputmode="numeric"
value={current(inp)}
oninput={(e) => setNumber(inp, e.currentTarget.value)}
onkeydown={(e) => e.key === 'Enter' && (open = false)}
/>
{:else}
<input
{id}
class="a-input"
class:invalid
type="text"
value={current(inp)}
oninput={(e) => onSet(inp.nodeId, e.currentTarget.value)}
onkeydown={(e) => e.key === 'Enter' && (open = false)}
/>
{/if}
{#if inputRole(inp) === 'seed'}
<button type="button" class="a-btn secondary sm" onclick={() => randomSeed(inp)}>
{t('desk.seedRandom')}
</button>
{/if}
</div>
{#if inp.min != null || inp.max != null}
<span class="field-hint mono">{inp.min ?? '−∞'} – {inp.max ?? '∞'}</span>
{/if}
</div>
{/each}
</div>
{/if}
{#if changed}
<div class="foot">
<button type="button" class="a-btn ghost sm" onclick={restore}>{t('desk.restoreOrig')}</button>
</div>
{/if}
</ShellPop>
</div>
<style>
.param-chip {
position: relative;
min-width: 0;
}
.fields {
display: grid;
gap: var(--sp-5);
padding: var(--sp-3);
}
.field-row {
display: grid;
gap: var(--sp-2);
}
.field-row .field-label {
display: flex;
justify-content: space-between;
gap: var(--sp-3);
margin: 0;
}
.orig {
font-size: var(--fs-mini);
color: var(--text-3);
font-weight: 400;
}
.inline {
display: flex;
gap: var(--sp-2);
}
.field-row .field-hint {
margin: 0;
}
.foot {
display: flex;
justify-content: flex-end;
padding: var(--sp-2) var(--sp-1) 0;
border-top: 1px solid var(--line);
margin-top: var(--sp-2);
}
</style>
@@ -0,0 +1,198 @@
<script lang="ts">
import ComfyDynamicForm from '$lib/components/ComfyDynamicForm.svelte';
import Icon from '$lib/components/ui/Icon.svelte';
import type { ComfyDynamicInput, Workflow } from '$lib/comfy/types';
import { t } from '$lib/i18n.svelte';
import type { DeskSchema, FormValue, FormValues } from './schema';
/**
* 参数 (spec-v2 §3.3 检视区 · 参数): every non-media input the workflow
* declares, each with its 原值 and a gold edge once changed. Common ones
* first; the rest fold under 高级参数 — untouched, they keep the
* workflow's own values.
*/
interface Props {
workflow: Workflow | null;
ds: DeskSchema;
values: FormValues;
errors: Record<string, string>;
showErrors: boolean;
changedCount: number;
onSet: (nodeId: string, value: FormValue) => void;
onRestoreAll: () => void;
}
let { workflow, ds, values, errors, showErrors, changedCount, onSet, onRestoreAll }: Props = $props();
let advancedOpen = $state(false);
const brokenReasons = $derived(
(workflow?.warnings ?? []).filter((w) => w.level === 'error').map((w) => w.message),
);
const advancedErr = $derived(showErrors && ds.advanced.some((i) => !!errors[i.nodeId]?.trim()));
$effect(() => {
if (advancedErr) advancedOpen = true;
});
function patch(inputs: ComfyDynamicInput[], next: Record<string, string | number>) {
for (const inp of inputs) {
const v = next[inp.nodeId];
if (v !== undefined && v !== values[inp.nodeId]) onSet(inp.nodeId, v);
}
}
</script>
<div class="params">
{#if !workflow}
<p class="note">{t('desk.pickWorkflow')}</p>
{:else}
{#if brokenReasons.length}
<div class="note err" role="alert">
<strong>{t('studio.wfBrokenTitle')}</strong>
<ul>
{#each brokenReasons as r (r)}<li>{r}</li>{/each}
</ul>
<span>{t('studio.wfBrokenHint')}</span>
</div>
{/if}
{#if !(workflow.input_schema ?? []).length}
<p class="note">{t('studio.wfNoParamsNote')}</p>
{:else if (workflow.input_schema ?? []).some((i) => i.auto)}
<p class="note">{t('studio.wfAutoParamsNote', { n: workflow.input_schema.length })}</p>
{/if}
{#if ds.common.length}
<div class="section-title">{t('desk.paramsCommon')}</div>
<ComfyDynamicForm
inputs={ds.common}
values={values as Record<string, string | number>}
quiet
showOrig
{errors}
{showErrors}
onChange={(v) => patch(ds.common, v)}
/>
{/if}
{#if ds.advanced.length}
<div class="fold">
<button
type="button"
class="fold-head"
class:err={advancedErr}
aria-expanded={advancedOpen}
aria-controls="desk-advanced"
onclick={() => (advancedOpen = !advancedOpen)}
>
<span class="chev" aria-hidden="true"><Icon name="play" size={10} /></span>
{t('desk.advanced', { n: ds.advanced.length })}
</button>
<div id="desk-advanced" class="collapse" class:open={advancedOpen} inert={!advancedOpen}>
<div class="collapse-in">
<ComfyDynamicForm
inputs={ds.advanced}
values={values as Record<string, string | number>}
quiet
showOrig
{errors}
{showErrors}
onChange={(v) => patch(ds.advanced, v)}
/>
</div>
</div>
</div>
{/if}
{#if changedCount > 0}
<div class="restore">
<button type="button" class="a-btn ghost sm" onclick={onRestoreAll}>
<Icon name="retry" size={13} />
{t('desk.restoreAll')}
</button>
</div>
{/if}
{/if}
</div>
<style>
.params {
display: grid;
gap: var(--sp-4);
}
.note {
margin: 0;
padding: var(--sp-3) var(--sp-4);
border-radius: var(--r-md);
background: var(--bg-3);
color: var(--text-3);
font-size: var(--fs-aux);
}
.note.err {
background: var(--err-soft);
color: var(--err);
}
.note ul {
margin: var(--sp-2) 0;
padding-left: 18px;
}
.section-title {
font-size: var(--fs-aux);
color: var(--text-3);
}
.fold {
border-top: 1px solid var(--line);
}
.fold-head {
display: flex;
align-items: center;
gap: var(--sp-2);
width: 100%;
padding: var(--sp-4) 0;
border: 0;
background: none;
color: var(--text-2);
font: inherit;
font-size: var(--fs-aux);
text-align: left;
cursor: pointer;
transition: color var(--d-state) ease;
}
.fold-head.err {
color: var(--err);
}
.chev {
display: inline-grid;
transition: transform var(--d-pop) var(--ease-out);
}
.fold-head[aria-expanded='true'] .chev {
transform: rotate(90deg);
}
@media (hover: hover) and (pointer: fine) {
.fold-head:hover {
color: var(--text);
}
}
.collapse {
display: grid;
grid-template-rows: 0fr;
opacity: 0;
transition:
grid-template-rows var(--d-enter) var(--ease-out),
opacity var(--d-state) ease;
}
.collapse.open {
grid-template-rows: 1fr;
opacity: 1;
}
.collapse-in {
min-height: 0;
overflow: hidden;
}
.collapse.open .collapse-in {
padding-bottom: var(--sp-2);
}
.restore {
display: flex;
justify-content: flex-end;
}
</style>
@@ -0,0 +1,227 @@
<script lang="ts">
import MediaSlot from '$lib/components/studio/MediaSlot.svelte';
import RefImageGrid from '$lib/components/studio/RefImageGrid.svelte';
import { mediaFileProblem } from '$lib/comfy/imageFile';
import { createUploadManager } from '$lib/comfy/useUpload.svelte';
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';
/**
* 输入槽 (spec-v2 §3.3): exactly the media inputs the workflow JSON wires —
* text-to-image shows none, image-to-image one input, image-to-video a
* first frame, ref-to-video the 1–N list. Owns uploads and rejects;
* values flow back through `onSet`.
*/
interface Props {
ds: DeskSchema;
values: FormValues;
showErrors: boolean;
errors: Record<string, string>;
/** True while any upload runs (generate waits for it). */
busy?: boolean;
onSet: (nodeId: string, value: FormValue) => void;
onFromResults: (nodeId: string) => void;
}
let { ds, values, showErrors, errors, busy = $bindable(false), onSet, onFromResults }: Props = $props();
const uploads = createUploadManager();
type Reject = { name: string; message: string };
let rejects = $state<Record<string, Reject[]>>({});
let fileSizes = $state<Record<string, number>>({});
let replacingKey = $state('');
let replacingIndex = $state(-1);
$effect(() => {
busy = Object.keys(uploads.uploading).length > 0;
});
// A different workflow starts with no stale rejects.
$effect(() => {
void ds;
rejects = {};
});
const warns = $derived(h3Warnings(ds, values));
function setRejects(nodeId: string, list: Reject[]) {
rejects = { ...rejects, [nodeId]: list };
}
async function upload(nodeId: string, file: File): Promise<string | null> {
const path = await uploads.uploadSafe(nodeId, file);
if (path) fileSizes = { ...fileSizes, [path]: file.size };
return path;
}
async function slotFile(s: SlotDef, file: File) {
const problem = mediaFileProblem(s.mediaKind, file);
if (problem) {
setRejects(s.inp.nodeId, [{ name: file.name, message: problem }]);
return;
}
setRejects(s.inp.nodeId, []);
const path = await upload(s.inp.nodeId, file);
if (path) onSet(s.inp.nodeId, path);
}
/** Add picked/dropped files in order; a batch that would pass `max` is refused whole. */
async function groupAdd(entry: ComfyDynamicInput, files: File[]) {
const key = entry.nodeId;
const mk = listKind(entry);
const max = entry.max ?? 9;
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)! })),
);
const start = listPaths(values, entry);
if (start.length + ok.length > max) {
toast.error(t('studio.errRefMax', { max, n: start.length, k: ok.length }));
return;
}
for (const f of ok) {
const path = await upload(key, f);
if (!path) break;
onSet(key, [...listPaths(values, entry), path]);
}
}
async function groupReplace(entry: ComfyDynamicInput, index: number, file: File) {
const key = entry.nodeId;
const problem = mediaFileProblem(listKind(entry), file);
if (problem) {
setRejects(key, [{ name: file.name, message: problem }]);
return;
}
setRejects(key, []);
replacingKey = key;
replacingIndex = index;
try {
const path = await upload(key, file);
if (path) onSet(key, listPaths(values, entry).map((p, i) => (i === index ? path : p)));
} finally {
replacingKey = '';
replacingIndex = -1;
}
}
function fileKind(f: File): 'image' | 'video' | 'audio' {
if (f.type.startsWith('video/')) return 'video';
if (f.type.startsWith('audio/')) return 'audio';
return 'image';
}
/** Composer-wide drop: each file goes to the first wired slot of its kind with room. */
export function dropFiles(files: File[]) {
const pending = new Map<string, File[]>();
const taken = new Set<string>();
let unplaced = 0;
for (const f of files) {
const mk = fileKind(f);
const slot = ds.slots.find(
(s) =>
s.mediaKind === mk &&
!s.mask &&
!taken.has(s.inp.nodeId) &&
!String(values[s.inp.nodeId] ?? ''),
);
if (slot) {
taken.add(slot.inp.nodeId);
void slotFile(slot, f);
continue;
}
const list = ds.lists.find((g) => listKind(g) === mk);
if (list) {
pending.set(list.nodeId, [...(pending.get(list.nodeId) ?? []), f]);
continue;
}
unplaced += 1;
}
for (const [nodeId, batch] of pending) {
const entry = ds.lists.find((g) => g.nodeId === nodeId);
if (entry) void groupAdd(entry, batch);
}
if (unplaced) toast.error(t('desk.dropNoSlot', { n: unplaced }));
}
</script>
{#if ds.slots.length || ds.lists.length}
<div class="tray">
{#each ds.slots as s (s.inp.nodeId)}
{@const reject = rejects[s.inp.nodeId]?.[0]}
<MediaSlot
label={s.label}
required={s.inp.required}
value={String(values[s.inp.nodeId] ?? '')}
kind={s.mediaKind}
size={fileSizes[String(values[s.inp.nodeId] ?? '')]}
uploading={uploads.isUploading(s.inp.nodeId)}
uploadName={uploads.uploading[s.inp.nodeId]}
need={showErrors && !!errors[s.inp.nodeId]}
error={reject?.message ?? (showErrors ? errors[s.inp.nodeId] : undefined)}
allowFromResults={s.fromResults}
anchorId={`slot-${s.inp.nodeId}`}
onFile={(f) => slotFile(s, f)}
onFromResults={() => onFromResults(s.inp.nodeId)}
onClear={() => {
setRejects(s.inp.nodeId, []);
onSet(s.inp.nodeId, '');
}}
/>
{/each}
{#each ds.lists as entry (entry.nodeId)}
<RefImageGrid
label={entry.label}
mediaKind={listKind(entry)}
paths={listPaths(values, entry)}
max={entry.max ?? 9}
sizes={fileSizes}
uploadingName={uploads.uploading[entry.nodeId]}
uploadingIndex={replacingKey === entry.nodeId ? replacingIndex : -1}
rejects={rejects[entry.nodeId] ?? []}
need={showErrors && !!errors[entry.nodeId]}
error={showErrors ? errors[entry.nodeId] : undefined}
allowFromResults={entry.kind === 'image_list'}
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))}
onFromResults={entry.kind === 'image_list' ? () => onFromResults(entry.nodeId) : undefined}
/>
{/each}
</div>
{#if showErrors && errors.firstLast}
<p class="a-slot-hint err note">{errors.firstLast}</p>
{/if}
{#if ds.hasH3Hint}
<div class="note" role="note">
<p class="a-slot-hint">{t('wfMedia.h3Limits')}</p>
{#each warns as w (w)}
<p class="a-slot-hint warn">{w}</p>
{/each}
</div>
{/if}
{/if}
<style>
.tray {
display: flex;
flex-wrap: wrap;
gap: var(--sp-3) var(--sp-6);
padding: var(--sp-4) var(--sp-5) 0;
}
.note {
margin: 0;
padding: var(--sp-2) var(--sp-5) 0;
}
.note p {
margin: 0;
}
.warn {
color: var(--warn);
}
</style>
@@ -0,0 +1,392 @@
<script lang="ts">
import Icon from '$lib/components/ui/Icon.svelte';
import { assetUrl } from '$lib/api';
import type { Job, Workflow } from '$lib/comfy/types';
import { progressFor } from '$lib/jobProgress';
import { jobStatusInfo } from '$lib/jobStatus';
import { parseDbTs } from '$lib/dbtime';
import { t } from '$lib/i18n.svelte';
import { usageLabel } from './schema';
import { elapsedSec, fmtClock, fmtDur, inputsOf, isVideoOutput, promptOf } from './takes';
/**
* 一条记录 (spec-v2 §3.3 记录卡): workflow · usage · time, the prompt, the
* inputs it used, its real state (phase / progress / time / error) and
* the first output. The whole card selects; actions sit above it.
*/
interface Props {
job: Job;
wf: Workflow | undefined;
selected: boolean;
nowMs: number;
busy?: boolean;
onSelect: () => void;
onReuse: () => void;
onRetry: () => void;
onCancel: () => void;
onDelete: () => void;
}
let { job, wf, selected, nowMs, busy = false, onSelect, onReuse, onRetry, onCancel, onDelete }: Props = $props();
const st = $derived(jobStatusInfo(job));
const tagTone = $derived(st.tone === 'queue' ? '' : st.tone === 'fail' ? 'err' : st.tone);
const prompt = $derived(promptOf(job, wf));
const inputs = $derived(inputsOf(job, wf).filter((i) => i.kind === 'image'));
const output = $derived(st.tone === 'ok' ? (job.output_paths[0] ?? null) : null);
const progress = $derived.by(() => {
if (job.status !== 'running') return 0;
const live = progressFor(job.id);
const phase = live?.phase ?? job.phase;
if (phase !== 'comfy_running' && phase !== 'saving') return 0;
return live?.progress ?? 3;
});
const active = $derived(job.status === 'pending' || job.status === 'running');
const name = $derived(wf?.name ?? t('studio.jobName', { id: job.id }));
let portrait = $state(false);
let clipSec = $state<number | null>(null);
function noteImage(e: Event) {
const img = e.currentTarget as HTMLImageElement;
portrait = img.naturalHeight > img.naturalWidth;
}
function noteVideo(e: Event) {
const v = e.currentTarget as HTMLVideoElement;
portrait = v.videoHeight > v.videoWidth;
if (Number.isFinite(v.duration)) clipSec = v.duration;
}
</script>
<article class="take" class:selected data-job={job.id}>
<button
type="button"
class="hit"
aria-pressed={selected}
aria-label={`${name} · ${st.label}`}
onclick={onSelect}
></button>
<div class="head">
<span class="name">{name}</span>
{#if wf?.usage}<span class="a-tag">{usageLabel(wf.usage)}</span>{/if}
<span class="meta mono">{fmtClock(parseDbTs(job.created_at))}</span>
</div>
{#if prompt}<p class="prompt">{prompt}</p>{/if}
{#if inputs.length}
<div class="inputs" aria-label={t('desk.inputs')}>
{#each inputs.slice(0, 5) as inp (inp.path + inp.label)}
<img class="mini" src={assetUrl(inp.path)} alt={inp.label} title={inp.label} loading="lazy" />
{/each}
{#if inputs.length > 5}<span class="more">+{inputs.length - 5}</span>{/if}
</div>
{/if}
<div class="state">
<span class="a-tag {tagTone}">{st.label}</span>
{#if st.tone === 'run'}
{#if progress > 0}
<div class="a-progress bar" style:--p={progress / 100}><i></i></div>
{/if}
<span class="txt">{fmtDur(elapsedSec(job, nowMs))}</span>
{:else if st.tone === 'ok'}
<span class="txt">{t('desk.took', { d: fmtDur(elapsedSec(job, nowMs)) })}</span>
{:else if st.tone === 'fail'}
<span class="err-txt" title={st.detail}>{st.detail}</span>
{:else}
<span class="txt">{st.detail}</span>
{/if}
<div class="actions">
<button type="button" class="a-btn ghost xs" onclick={onReuse}>{t('desk.reuse')}</button>
{#if st.tone === 'fail' && job.error !== 'cancelled'}
<button type="button" class="a-btn ghost xs" disabled={busy} onclick={onRetry}>{t('common.retry')}</button>
{/if}
{#if active}
<button type="button" class="a-btn ghost xs" disabled={busy} onclick={onCancel}>{t('common.cancel')}</button>
{/if}
{#if output}
<a class="a-btn ghost xs" href={assetUrl(output)} download>{t('desk.download')}</a>
{/if}
{#if !active}
<button type="button" class="a-btn danger xs" onclick={onDelete}>{t('studio.deleteBtn')}</button>
{/if}
</div>
</div>
<div class="out">
{#if output}
<div class="thumb" class:portrait>
{#if isVideoOutput(job, output)}
<video src={assetUrl(output)} preload="metadata" muted playsinline onloadedmetadata={noteVideo}></video>
<span class="play" aria-hidden="true"><Icon name="play" size={10} /></span>
{#if clipSec != null}<span class="badge mono">{fmtDur(clipSec)}</span>{/if}
{:else}
<img src={assetUrl(output)} alt="" loading="lazy" onload={noteImage} />
{/if}
{#if job.output_paths.length > 1}<span class="count mono">×{job.output_paths.length}</span>{/if}
</div>
{:else if active}
<div class="thumb pending">{st.label}</div>
{:else}
<div class="thumb missing">{t('desk.notGenerated')}</div>
{/if}
</div>
</article>
<style>
.take {
position: relative;
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: var(--sp-4) var(--sp-6);
margin-bottom: var(--sp-3);
padding: var(--sp-5) var(--sp-6);
border: 1px solid var(--line);
border-radius: var(--r-lg);
background: var(--bg-2);
transition:
border-color var(--d-state) ease,
background-color var(--d-state) ease,
box-shadow var(--d-state) ease;
}
.take.selected {
border-color: var(--accent-line);
background: var(--accent-wash), var(--bg-3);
box-shadow: var(--sel-shadow);
}
@media (hover: hover) and (pointer: fine) {
.take:hover:not(.selected) {
border-color: var(--line-2);
background: var(--bg-3);
}
}
.hit {
position: absolute;
inset: 0;
z-index: 0;
padding: 0;
border: 0;
border-radius: inherit;
background: transparent;
cursor: pointer;
}
.hit:focus-visible {
outline-offset: -2px;
border-radius: var(--r-lg);
}
.head,
.prompt,
.inputs,
.state {
grid-column: 1;
position: relative;
pointer-events: none;
}
.head {
display: flex;
align-items: center;
gap: var(--sp-3);
min-width: 0;
}
.name {
font-weight: 600;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.meta {
color: var(--text-3);
font-size: var(--fs-aux);
white-space: nowrap;
}
.prompt {
margin: 0;
color: var(--text-2);
font-size: var(--fs-body-sm);
display: -webkit-box;
-webkit-line-clamp: 2;
line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.inputs {
display: flex;
align-items: center;
gap: var(--sp-1);
}
.mini {
width: 26px;
height: 26px;
border-radius: 5px;
object-fit: cover;
border: 1px solid var(--line-2);
}
.more {
padding-left: 2px;
font-size: var(--fs-mini);
color: var(--text-3);
}
.state {
display: flex;
align-items: center;
gap: var(--sp-3);
min-height: 22px;
font-size: var(--fs-aux);
}
.bar {
flex: 0 0 140px;
}
.txt {
color: var(--text-2);
white-space: nowrap;
}
.err-txt {
min-width: 0;
color: var(--err);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.actions {
display: flex;
gap: var(--sp-1);
margin-left: auto;
pointer-events: auto;
opacity: 0;
transition: opacity var(--d-state) ease;
}
.take:focus-within .actions,
.take.selected .actions {
opacity: 1;
}
@media (hover: hover) and (pointer: fine) {
.take:hover .actions {
opacity: 1;
}
}
@media (hover: none) {
.actions {
opacity: 1;
}
}
.out {
grid-column: 2;
grid-row: 1 / span 4;
align-self: start;
position: relative;
pointer-events: none;
}
.thumb {
position: relative;
width: 150px;
aspect-ratio: 16 / 9;
overflow: hidden;
border-radius: var(--r-md);
background: var(--bg-4);
box-shadow: inset 0 0 0 1px var(--on-media-line);
}
.thumb.portrait {
width: 96px;
aspect-ratio: 3 / 4;
}
.thumb img,
.thumb video {
display: block;
width: 100%;
height: 100%;
object-fit: cover;
}
.badge,
.count {
position: absolute;
bottom: var(--sp-2);
padding: 1px 5px;
border-radius: 4px;
background: var(--media-scrim);
color: var(--on-media);
font-size: var(--fs-mini);
}
.badge {
right: var(--sp-2);
}
.count {
right: var(--sp-2);
top: var(--sp-2);
bottom: auto;
}
.play {
position: absolute;
left: var(--sp-2);
bottom: var(--sp-2);
display: grid;
place-items: center;
width: 22px;
height: 22px;
border-radius: 50%;
background: var(--media-scrim);
color: var(--on-media);
}
.thumb.pending,
.thumb.missing {
display: grid;
place-items: center;
padding: var(--sp-2);
box-sizing: border-box;
text-align: center;
font-size: var(--fs-mini);
color: var(--text-3);
border: 1px dashed var(--line-2);
background: var(--bg-2);
box-shadow: none;
}
.thumb.pending {
border-style: solid;
color: var(--run);
}
.thumb.pending::after {
content: '';
position: absolute;
inset: 0;
background: linear-gradient(100deg, transparent 30%, var(--run-soft) 50%, transparent 70%);
animation: take-sheen 1.4s linear infinite;
}
@keyframes take-sheen {
from {
transform: translateX(-100%);
}
to {
transform: translateX(100%);
}
}
:global(:root[data-reduce='1']) .thumb.pending::after {
animation: none;
}
@media (max-width: 760px) {
.take {
padding: var(--sp-4);
}
.head {
flex-wrap: wrap;
row-gap: 2px;
}
.out {
grid-row: 1 / span 2;
}
.state {
grid-column: 1 / -1;
flex-wrap: wrap;
}
.thumb {
width: 108px;
}
.thumb.portrait {
width: 72px;
}
.bar {
flex-basis: 80px;
}
}
</style>
@@ -0,0 +1,199 @@
<script lang="ts">
import type { Snippet } from 'svelte';
import Icon from '$lib/components/ui/Icon.svelte';
import type { Job, Workflow } from '$lib/comfy/types';
import { parseDbTs } from '$lib/dbtime';
import { t } from '$lib/i18n.svelte';
import TakeCard from './TakeCard.svelte';
import { dayLabel, matchesFilter, type FeedFilter } from './takes';
/**
* 记录流 (spec-v2 §3.3): every desk take, newest last above the composer,
* filtered 全部 / 图片 / 视频 / 失败 and grouped by day.
*/
interface Props {
jobs: Job[];
workflowOf: (job: Job) => Workflow | undefined;
filter: FeedFilter;
selectedId: number | null;
nowMs: number;
busyId: number | null;
loading: boolean;
/** Shown under the filters (e.g. the workbench context bar). */
banner?: Snippet;
/** Inspector toggle when it is hidden (desktop) or an overlay (mid). */
inspectorToggle?: Snippet;
onSelect: (job: Job) => void;
onReuse: (job: Job) => void;
onRetry: (job: Job) => void;
onCancel: (job: Job) => void;
onDelete: (job: Job) => void;
}
let {
jobs,
workflowOf,
filter = $bindable(),
selectedId,
nowMs,
busyId,
loading,
banner,
inspectorToggle,
onSelect,
onReuse,
onRetry,
onCancel,
onDelete,
}: Props = $props();
const FILTERS: Array<{ id: FeedFilter; key: string }> = [
{ id: 'all', key: 'desk.filterAll' },
{ id: 'image', key: 'desk.filterImage' },
{ id: 'video', key: 'desk.filterVideo' },
{ id: 'failed', key: 'desk.filterFailed' },
];
let scroller = $state<HTMLElement | null>(null);
/** Oldest first so the newest take sits right above the composer. */
const shown = $derived(
jobs.filter((j) => matchesFilter(j, filter)).sort((a, b) => parseDbTs(a.created_at) - parseDbTs(b.created_at)),
);
const activeCount = $derived(jobs.filter((j) => j.status === 'pending' || j.status === 'running').length);
const days = $derived.by(() => {
const out: Array<{ label: string; items: Job[] }> = [];
for (const job of shown) {
const label = dayLabel(parseDbTs(job.created_at), nowMs);
const last = out[out.length - 1];
if (last?.label === label) last.items.push(job);
else out.push({ label, items: [job] });
}
return out;
});
// Stick to the bottom as takes arrive, unless the user scrolled up to read.
let pinned = true;
function onScroll() {
if (!scroller) return;
pinned = scroller.scrollHeight - scroller.scrollTop - scroller.clientHeight < 80;
}
$effect(() => {
void shown.length;
void filter;
if (!scroller || !pinned) return;
const el = scroller;
requestAnimationFrame(() => (el.scrollTop = el.scrollHeight));
});
</script>
<div class="filters">
<div class="a-seg" role="group" aria-label={t('desk.filterAria')}>
{#each FILTERS as f (f.id)}
<button type="button" aria-pressed={filter === f.id} onclick={() => (filter = f.id)}>{t(f.key)}</button>
{/each}
</div>
<span class="count">
{t('desk.countTakes', { n: shown.length })}{#if activeCount}&nbsp;· {t('desk.countActive', { n: activeCount })}{/if}
</span>
{@render inspectorToggle?.()}
</div>
{@render banner?.()}
<div class="feed" bind:this={scroller} onscroll={onScroll}>
{#if loading && !jobs.length}
<p class="empty">{t('common.loading')}</p>
{:else if !shown.length}
<div class="empty">
<Icon name={filter === 'video' ? 'film' : filter === 'failed' ? 'check' : 'image'} size={20} />
<p>{filter === 'failed' ? t('desk.emptyFailed') : t('desk.emptyFeed')}</p>
</div>
{:else}
{#each days as day (day.label)}
<div class="day">{day.label}</div>
{#each day.items as job (job.id)}
<TakeCard
{job}
wf={workflowOf(job)}
selected={job.id === selectedId}
{nowMs}
busy={busyId === job.id}
onSelect={() => onSelect(job)}
onReuse={() => onReuse(job)}
onRetry={() => onRetry(job)}
onCancel={() => onCancel(job)}
onDelete={() => onDelete(job)}
/>
{/each}
{/each}
{/if}
</div>
<style>
.filters {
display: flex;
align-items: center;
gap: var(--sp-2);
padding: var(--sp-4) var(--sp-7);
border-bottom: 1px solid var(--line);
}
.count {
margin-left: auto;
color: var(--text-3);
font-size: var(--fs-aux);
white-space: nowrap;
}
.feed {
flex: 1;
min-height: 0;
overflow-y: auto;
padding: var(--sp-6) var(--sp-7) var(--sp-3);
}
.day {
display: flex;
align-items: center;
gap: var(--sp-3);
margin: var(--sp-4) 0 var(--sp-3);
color: var(--text-3);
font-size: var(--fs-aux);
}
.day::after {
content: '';
flex: 1;
height: 1px;
background: var(--line);
}
.empty {
display: grid;
place-items: center;
gap: var(--sp-3);
margin: 0;
padding: var(--sp-8) var(--sp-6);
color: var(--text-3);
text-align: center;
font-size: var(--fs-aux);
}
.empty p {
margin: 0;
}
@media (max-width: 1179px) {
.filters {
padding: var(--sp-4) var(--sp-6);
}
.feed {
padding: var(--sp-5) var(--sp-6) var(--sp-3);
}
}
@media (max-width: 760px) {
.filters {
padding: var(--sp-3) var(--sp-5);
overflow-x: auto;
}
.count {
display: none;
}
.feed {
padding: var(--sp-5) var(--sp-5) var(--sp-2);
}
}
</style>
@@ -0,0 +1,223 @@
<script lang="ts">
import Icon from '$lib/components/ui/Icon.svelte';
import ShellPop from '$lib/components/shell/ShellPop.svelte';
import { WORKFLOW_USAGES, type Workflow } from '$lib/comfy/types';
import { t } from '$lib/i18n.svelte';
import { slotSummary, usageLabel } from './schema';
/**
* 工作流选择 (spec-v2 §3.3 工作流面板): the composer's first chip opens a
* pop grouped by usage with search; each row shows what the workflow
* really takes (slot summary from its JSON), its connection and whether
* it last ran. Picking keeps the prompt and carried-over images.
*/
interface Props {
workflows: Workflow[];
current: Workflow | null;
endpoints: Array<{ id: string; name: string }>;
onPick: (wf: Workflow) => void;
}
let { workflows, current, endpoints, onPick }: Props = $props();
let open = $state(false);
let query = $state('');
type Status = { tone: '' | 'ok' | 'warn' | 'err'; label: string };
function statusOf(wf: Workflow): Status {
if ((wf.warnings ?? []).some((w) => w.level === 'error')) return { tone: 'err', label: t('desk.wfBroken') };
const lt = wf.last_test;
if (!lt) return { tone: '', label: t('desk.wfUntested') };
if (lt.status === 'done' && lt.output_count > 0) return { tone: 'ok', label: t('desk.wfOk') };
if (lt.status === 'failed' || lt.status === 'done') return { tone: 'warn', label: t('desk.wfLastFailed') };
return { tone: '', label: t('desk.wfTesting') };
}
function endpointLabel(wf: Workflow): string {
const ep = endpoints.find((e) => e.id === wf.endpoint_id);
return ep ? ep.name : t('desk.wfDefaultConn');
}
const rows = $derived(
workflows.map((wf) => ({
wf,
summary: slotSummary(wf),
conn: endpointLabel(wf),
status: statusOf(wf),
usage: wf.usage ? usageLabel(wf.usage) : '',
})),
);
const groups = $derived.by(() => {
const q = query.trim().toLowerCase();
const hit = (r: (typeof rows)[number]) =>
!q || `${r.wf.name} ${r.usage} ${r.summary}`.toLowerCase().includes(q);
return WORKFLOW_USAGES.map((u) => ({
usage: u,
items: rows.filter((r) => r.wf.usage === u && hit(r)),
})).filter((g) => g.items.length);
});
function pick(wf: Workflow) {
open = false;
query = '';
if (wf.id !== current?.id) onPick(wf);
}
function onSearchKey(e: KeyboardEvent) {
if (e.key !== 'Enter') return;
e.preventDefault();
const first = groups[0]?.items[0];
if (first) pick(first.wf);
}
</script>
<div class="picker">
<button
type="button"
id="desk-wf"
class="a-chip wf"
aria-haspopup="dialog"
aria-expanded={open}
title={current?.name ?? t('desk.pickWorkflow')}
onclick={() => (open = !open)}
>
<Icon name="playground" size={14} />
<span class="lbl">{current?.name ?? t('desk.pickWorkflow')}</span>
</button>
<ShellPop bind:open label={t('desk.pickWorkflow')} placement="top" width="520px" onclose={() => (query = '')}>
<div class="search">
<Icon name="search" size={14} />
<input
class="search-input"
type="search"
bind:value={query}
placeholder={t('desk.wfSearchPh')}
aria-label={t('desk.wfSearchPh')}
onkeydown={onSearchKey}
/>
</div>
<div class="list">
{#each groups as g (g.usage)}
<div class="a-menu-group">{usageLabel(g.usage)} · {g.items.length}</div>
{#each g.items as r (r.wf.id)}
{@const cur = r.wf.id === current?.id}
<button
type="button"
class="a-menu-item row"
class:off={r.status.tone === 'err'}
aria-current={cur ? 'true' : undefined}
onclick={() => pick(r.wf)}
>
<span class="main">
<span class="a-menu-label">{r.wf.name}{cur ? ` · ${t('desk.wfCurrent')}` : ''}</span>
<span class="sub">
<span class="a-tag">{r.summary}</span>
<span class="a-tag">{r.conn}</span>
</span>
</span>
<span class="a-tag {r.status.tone}">{r.status.label}</span>
</button>
{/each}
{:else}
<p class="empty">{t('desk.wfNoMatch')}</p>
{/each}
</div>
<div class="foot">
<a class="a-btn ghost sm" href="/connections">
<Icon name="upload" size={13} />
{t('desk.wfImport')}
</a>
<span class="keys">
<span class="a-kbd">↑↓</span>{t('desk.keySelect')}
<span class="a-kbd">Enter</span>{t('desk.keyUse')}
<span class="a-kbd">Esc</span>{t('desk.keyClose')}
</span>
</div>
</ShellPop>
</div>
<style>
.picker {
position: relative;
min-width: 0;
}
.search {
display: flex;
align-items: center;
gap: var(--sp-3);
padding: var(--sp-3) var(--sp-4) var(--sp-4);
border-bottom: 1px solid var(--line);
color: var(--text-3);
}
.search-input {
flex: 1;
min-width: 0;
border: 0;
outline: 0;
background: transparent;
color: var(--text);
font: inherit;
font-size: var(--fs-section);
}
.search-input::placeholder {
color: var(--text-3);
}
/* Search and foot stay put; only the list scrolls inside the pop's max-height. */
.list {
max-height: min(380px, 50vh);
overflow-y: auto;
padding: var(--sp-1) 0;
}
.row {
align-items: center;
}
.main {
display: grid;
gap: 2px;
flex: 1;
min-width: 0;
}
.main .a-menu-label {
font-weight: 500;
}
.row.off .a-menu-label {
color: var(--text-3);
}
.sub {
display: flex;
flex-wrap: wrap;
gap: var(--sp-1);
}
.empty {
margin: 0;
padding: var(--sp-6) var(--sp-4);
color: var(--text-3);
font-size: var(--fs-aux);
}
.foot {
display: flex;
align-items: center;
gap: var(--sp-2);
padding: var(--sp-3) var(--sp-1) 0;
border-top: 1px solid var(--line);
}
.keys {
display: inline-flex;
align-items: center;
gap: var(--sp-1);
margin-left: auto;
font-size: var(--fs-mini);
color: var(--text-3);
white-space: nowrap;
}
.keys .a-kbd:not(:first-child) {
margin-left: var(--sp-2);
}
@media (max-width: 760px) {
.keys {
display: none;
}
}
</style>
@@ -0,0 +1,55 @@
import type { WorkbenchStageKey } from '$lib/api';
import type { DeskKind } from './schema';
/**
* 五段创作 → 生成台 handoff, carried in the URL so a reload keeps it.
* The desk only prefills from it; the playground generate API has no
* project/shot link, so results stay in the desk feed (no backfill).
*/
export interface DeskContext {
kind: DeskKind;
projectId: number;
stage: WorkbenchStageKey;
/** Human label of the source row, e.g. 镜头 S1 / 角色「林夏」. */
label: string;
prompt?: string;
negative?: string;
durationSec?: number;
}
const STAGES: WorkbenchStageKey[] = ['script', 'style', 'storyboard', 'assets', 'shot'];
export function deskHref(ctx: DeskContext): string {
const q = new URLSearchParams({
from: 'workbench',
project: String(ctx.projectId),
stage: ctx.stage,
label: ctx.label,
});
if (ctx.prompt) q.set('prompt', ctx.prompt);
if (ctx.negative) q.set('negative', ctx.negative);
if (ctx.durationSec != null) q.set('duration', String(ctx.durationSec));
return `/${ctx.kind}?${q.toString()}`;
}
export function readDeskContext(url: URL, kind: DeskKind): DeskContext | null {
const q = url.searchParams;
if (q.get('from') !== 'workbench') return null;
const projectId = Number(q.get('project'));
const stage = q.get('stage') as WorkbenchStageKey | null;
if (!Number.isInteger(projectId) || projectId <= 0 || !stage || !STAGES.includes(stage)) return null;
const duration = Number(q.get('duration'));
return {
kind,
projectId,
stage,
label: q.get('label') ?? '',
prompt: q.get('prompt') ?? undefined,
negative: q.get('negative') ?? undefined,
durationSec: Number.isFinite(duration) && duration > 0 ? duration : undefined,
};
}
export function backHref(ctx: DeskContext): string {
return `/workbench/${ctx.projectId}?stage=${ctx.stage}`;
}
@@ -0,0 +1,372 @@
import type { ComfyDynamicInput, Workflow, WorkflowUsage } from '$lib/comfy/types';
import { normalizeInputRole } from '$lib/comfy/parser';
import { ratioShort, resolutionSelectorSize } from '$lib/comfy/resolution';
import { t } from '$lib/i18n.svelte';
/**
* 生成台表单识别 (HEL-653, moved verbatim from StudioGenerate): slots and
* structured params come from what the workflow JSON actually declares —
* never from model names. No wiring → no slot.
*/
export type DeskKind = 'image' | 'video';
export type FormValue = string | number | string[];
export type FormValues = Record<string, FormValue>;
export interface SlotDef {
inp: ComfyDynamicInput;
label: string;
mediaKind: 'image' | 'video' | 'audio';
fromResults: boolean;
/** Required-slot message when left empty (ref-upload-spec §3). */
missing: string;
firstFrame: boolean;
lastFrame: boolean;
mask: boolean;
}
/** Why 生成 is disabled — the line next to the button (spec-v2 §1.2). */
export interface BlockReason {
text: string;
tone: 'warn' | 'err';
/** Element id to focus when the reason is clicked. */
target?: string;
/** Open the params tab instead of focusing. */
params?: boolean;
}
export interface DeskSchema {
prompt: ComfyDynamicInput | null;
negative: ComfyDynamicInput | null;
seed: ComfyDynamicInput | null;
width: ComfyDynamicInput | null;
height: ComfyDynamicInput | null;
resolution: ComfyDynamicInput | null;
duration: ComfyDynamicInput | null;
batch: ComfyDynamicInput | null;
/** ComfyUI ResolutionSelector: the ratio + megapixels pair decides width/height. */
ratio: ComfyDynamicInput | null;
megapixels: ComfyDynamicInput | null;
/** Single media inputs (input image, first/last frame, mask, ref video/audio). */
slots: SlotDef[];
/** Autogrow media lists actually wired (image / video / audio). */
lists: ComfyDynamicInput[];
/** Parameters shown as composer chips (比例 / 尺寸 / 分辨率 / 时长 / 种子). */
common: ComfyDynamicInput[];
/** Everything else that is not media and not the prompt — folded. */
advanced: ComfyDynamicInput[];
hasH3Hint: boolean;
}
export function inputRole(inp: ComfyDynamicInput): string | null {
return normalizeInputRole(inp.role ?? null);
}
function hay(inp: ComfyDynamicInput): string {
return `${inp.role ?? ''} ${inp.label}`.toLowerCase();
}
export function isMedia(inp: ComfyDynamicInput): boolean {
return (
inp.kind === 'image' ||
inp.kind === 'image_url' ||
inp.kind === 'video' ||
inp.kind === 'audio' ||
inp.kind === 'image_list' ||
inp.kind === 'video_list' ||
inp.kind === 'audio_list'
);
}
function isList(inp: ComfyDynamicInput): boolean {
return inp.kind === 'image_list' || inp.kind === 'video_list' || inp.kind === 'audio_list';
}
function isMask(inp: ComfyDynamicInput): boolean {
return inputRole(inp) === 'mask' || /mask|遮罩/.test(hay(inp));
}
// Auto-detected slots carry their role from the wiring; tagged (Input:…)
// workflows still name frames only in the title.
export function isFirstFrame(inp: ComfyDynamicInput, kind: DeskKind): boolean {
return inputRole(inp) === 'first_frame' || (!inp.auto && kind === 'video' && /first|start|首帧/.test(hay(inp)));
}
export function isLastFrame(inp: ComfyDynamicInput, kind: DeskKind): boolean {
return inputRole(inp) === 'last_frame' || (!inp.auto && kind === 'video' && /last|end|尾帧/.test(hay(inp)));
}
export function listKind(entry: ComfyDynamicInput): 'image' | 'video' | 'audio' {
if (entry.kind === 'video_list') return 'video';
if (entry.kind === 'audio_list') return 'audio';
return 'image';
}
export function listPaths(values: FormValues, entry: ComfyDynamicInput): string[] {
const v = values[entry.nodeId];
if (Array.isArray(v)) return v;
return typeof v === 'string' && v ? [v] : [];
}
export function classifySchema(schema: ComfyDynamicInput[], kind: DeskKind): DeskSchema {
const plain = (pred: (i: ComfyDynamicInput) => boolean) =>
schema.find((i) => !isMedia(i) && pred(i)) ?? null;
const byRole = (r: string) => plain((i) => inputRole(i) === r);
const prompt = byRole('prompt');
const negative = byRole('negative');
const seed = byRole('seed');
const width = byRole('width');
const height = byRole('height');
const resolution = byRole('resolution');
const duration = byRole('duration');
const batch = plain((i) => /批次|batch/.test(hay(i)));
const ratio =
schema.find((i) => i.kind === 'select' && inputRole(i) === 'aspect_ratio' && i.resolution) ?? null;
const megapixels = ratio ? (schema.find((i) => i.nodeId === ratio.resolution!.megapixels) ?? null) : null;
const media = schema.filter((i) => isMedia(i) && !isList(i));
const plainImages = media.filter(
(i) => i.kind === 'image' && !isMask(i) && !isFirstFrame(i, kind) && !isLastFrame(i, kind),
);
const editInput = kind === 'image' && plainImages.length === 1 ? plainImages[0] : null;
const slots: SlotDef[] = [];
let refIdx = 0;
for (const inp of media) {
const base = { inp, missing: '', firstFrame: false, lastFrame: false, mask: false };
if (inp.kind === 'video') {
slots.push({ ...base, label: t('studio.slotRefVideo'), mediaKind: 'video', fromResults: false });
} else if (inp.kind === 'audio') {
slots.push({ ...base, label: t('studio.slotAudio'), mediaKind: 'audio', fromResults: false });
} else if (isMask(inp)) {
slots.push({ ...base, mask: true, label: t('studio.slotMask'), mediaKind: 'image', fromResults: false });
} else if (isFirstFrame(inp, kind)) {
slots.push({
...base,
firstFrame: true,
label: t('studio.slotFirst'),
mediaKind: 'image',
fromResults: true,
missing: t('studio.errNeedFirstFrame'),
});
} else if (isLastFrame(inp, kind)) {
slots.push({ ...base, lastFrame: true, label: t('studio.slotLast'), mediaKind: 'image', fromResults: true });
} else if (inp === editInput) {
slots.push({
...base,
label: t('studio.slotInput'),
mediaKind: 'image',
fromResults: true,
missing: t('studio.errNeedInputImage'),
});
} else {
refIdx += 1;
const label = inp.auto
? inp.label
: refIdx === 1 && media.length === 1
? t('studio.slotRef')
: t('studio.slotRefN', { n: refIdx });
slots.push({
...base,
label,
mediaKind: 'image',
fromResults: true,
missing: t('studio.errUpload', { label }),
});
}
}
const lists = schema.filter(isList);
const common = [ratio, width, height, resolution, duration, seed].filter(
(i): i is ComfyDynamicInput => !!i,
);
const commonIds = new Set(common.map((i) => i.nodeId));
const advanced = schema.filter((i) => !isMedia(i) && i !== prompt && !commonIds.has(i.nodeId));
return {
prompt,
negative,
seed,
width,
height,
resolution,
duration,
batch,
ratio,
megapixels,
slots,
lists,
common,
advanced,
hasH3Hint: lists.some((e) => e.media_hint === 'minimax_h3_ref2va'),
};
}
/** Workflow JSON defaults — the 原值 every parameter falls back to. */
export function schemaDefaults(schema: ComfyDynamicInput[]): FormValues {
const out: FormValues = {};
for (const inp of schema) {
if (inp.defaultValue !== undefined && inp.defaultValue !== '') out[inp.nodeId] = inp.defaultValue;
}
return out;
}
export function isChanged(inp: ComfyDynamicInput, values: FormValues): boolean {
const v = values[inp.nodeId];
const orig = inp.defaultValue;
if (v === undefined || v === '') return orig !== undefined && orig !== '';
if (orig === undefined || orig === '') return true;
return String(v) !== String(orig);
}
export function changedParams(ds: DeskSchema, values: FormValues): ComfyDynamicInput[] {
return [...ds.common, ...ds.advanced].filter((i) => isChanged(i, values));
}
export function outputSize(ds: DeskSchema, values: FormValues): { width: number; height: number } | null {
if (!ds.ratio?.resolution) return null;
const mp = Number(values[ds.megapixels?.nodeId ?? ''] ?? ds.megapixels?.defaultValue);
return resolutionSelectorSize(
String(values[ds.ratio.nodeId] ?? ds.ratio.defaultValue ?? ''),
mp,
ds.ratio.resolution.multiple,
);
}
export function batchCount(ds: DeskSchema, values: FormValues): number {
if (!ds.batch) return 1;
const n = Number(values[ds.batch.nodeId]);
return Number.isFinite(n) && n > 0 ? Math.floor(n) : 1;
}
/** H3 Ref2VA official limits (soft hint, keyed off the node class only). */
export function h3Warnings(ds: DeskSchema, values: FormValues): string[] {
if (!ds.hasH3Hint) return [];
const warns: string[] = [];
const counts = { image: 0, video: 0, audio: 0 };
for (const g of ds.lists) counts[listKind(g)] += listPaths(values, g).length;
const total = counts.image + counts.video + counts.audio;
if (total > 12) warns.push(t('wfMedia.h3TotalOver', { n: total }));
if (counts.audio > 0 && counts.image === 0 && counts.video === 0) warns.push(t('wfMedia.h3AudioAlone'));
return warns;
}
/** Same rules the backend enforces before queueing (comfyui/validate.py). */
export function valueError(inp: ComfyDynamicInput, raw: string | number): string | null {
const label = inp.label;
if (inp.kind === 'select') {
return inp.options?.length && !inp.options.includes(String(raw)) ? t('studio.errOption', { label }) : null;
}
if (inp.kind !== 'number') return null;
const n = Number(raw);
if (!Number.isFinite(n)) return t('studio.errNumber', { label });
if (inp.integer && !Number.isInteger(n)) return t('studio.errInteger', { label });
if (inp.min != null && n < inp.min) return t('studio.errMin', { label, v: inp.min });
if (inp.max != null && n > inp.max) return t('studio.errMax', { label, v: inp.max });
return null;
}
/** Field errors keyed by nodeId (spec §6.2 wording); '' keys never block. */
export function validateForm(
schema: ComfyDynamicInput[],
ds: DeskSchema,
values: FormValues,
): Record<string, string> {
const errs: Record<string, string> = {};
if (ds.prompt?.required) {
const v = String(values[ds.prompt.nodeId] ?? '');
if (!v.trim()) errs[ds.prompt.nodeId] = t('studio.errPrompt');
else if (v.length > 2000) errs[ds.prompt.nodeId] = t('studio.errPromptLong');
}
const first = ds.slots.find((s) => s.firstFrame);
const last = ds.slots.find((s) => s.lastFrame);
if (first && last) {
const a = String(values[first.inp.nodeId] ?? '');
const b = String(values[last.inp.nodeId] ?? '');
if ((!a || !b) && (a || b)) {
errs.firstLast = t('studio.errFirstLast');
if (!a) errs[first.inp.nodeId] = ' ';
if (!b) errs[last.inp.nodeId] = ' ';
}
}
for (const s of ds.slots) {
if (!s.inp.required) continue;
if (!String(values[s.inp.nodeId] ?? '').trim()) {
errs[s.inp.nodeId] = s.missing || t('studio.errUpload', { label: s.label });
}
}
for (const g of ds.lists) {
const n = listPaths(values, g).length;
const max = g.max ?? 9;
if (n < (g.min ?? 1)) {
errs[g.nodeId] =
g.kind === 'image_list'
? t('studio.errNeedRef')
: t('studio.errNeedRefMedia', { n: g.min ?? 1, label: g.label });
} else if (n > max) {
errs[g.nodeId] = t('studio.errRefMax', { max, n, k: n - max });
}
}
for (const inp of [...ds.common, ...ds.advanced]) {
if (inp.required && !String(values[inp.nodeId] ?? '').trim()) {
errs[inp.nodeId] = t('studio.errFill', { label: inp.label });
}
}
for (const inp of schema) {
const raw = values[inp.nodeId];
if (raw === undefined || Array.isArray(raw) || String(raw).trim() === '' || errs[inp.nodeId]) continue;
const msg = valueError(inp, raw);
if (msg) errs[inp.nodeId] = msg;
}
return errs;
}
/** Media a slot still waits for — the 「还缺:…」 hint next to 生成. */
export function missingMedia(ds: DeskSchema, values: FormValues): Array<{ nodeId: string; label: string }> {
const out: Array<{ nodeId: string; label: string }> = [];
for (const s of ds.slots) {
if (s.inp.required && !String(values[s.inp.nodeId] ?? '').trim()) out.push({ nodeId: s.inp.nodeId, label: s.label });
}
for (const g of ds.lists) {
if (listPaths(values, g).length < (g.min ?? 1)) out.push({ nodeId: g.nodeId, label: g.label });
}
return out;
}
export function usageLabel(u: WorkflowUsage): string {
return t(`usage.${u}`);
}
/** Mode badge: the stored usage when we have one, else the wiring shape. */
export function modeLabel(wf: Workflow | null, ds: DeskSchema, kind: DeskKind): string {
if (wf?.usage) return usageLabel(wf.usage);
if (ds.lists.some((g) => g.kind === 'image_list')) return t('studio.modeRefToVideo');
if (ds.slots.some((s) => s.firstFrame)) return t('studio.modeImageToVideo');
if (kind === 'image' && ds.slots.some((s) => s.mediaKind === 'image' && !s.mask))
return t('studio.modeImageEdit');
if (ds.slots.length) return ds.slots.map((s) => s.label).join(' · ');
return kind === 'image' ? t('studio.modeTextToImage') : t('studio.modeTextToVideo');
}
/** Picker row summary of a workflow's real media inputs, e.g. 参考图 1–9 张. */
export function slotSummary(wf: Workflow): string {
const ds = classifySchema(wf.input_schema ?? [], wf.kind);
const parts = [
...ds.slots.map((s) => t('desk.slotOne', { label: s.label })),
...ds.lists.map((g) => t('desk.slotRange', { label: g.label, min: g.min ?? 1, max: g.max ?? 9 })),
];
return parts.length ? parts.join(' · ') : t('desk.promptOnly');
}
/** Short chip text for one common parameter value. */
export function chipValue(inp: ComfyDynamicInput, values: FormValues, ds: DeskSchema): string {
const raw = values[inp.nodeId] ?? inp.defaultValue ?? '';
const v = Array.isArray(raw) ? raw.join(',') : String(raw);
if (inp === ds.ratio) {
const size = outputSize(ds, values);
return size ? `${ratioShort(v)} · ${size.width}×${size.height}` : ratioShort(v);
}
if (inp === ds.duration) return v ? t('desk.chipSeconds', { v }) : t('studio.durationLabel');
if (inp === ds.seed) return t('desk.chipSeed', { v: v.length > 8 ? `${v.slice(0, 8)}…` : v || '—' });
if (inp === ds.resolution) return t('desk.chipResolution', { v: v || '—' });
return v || inp.label;
}
@@ -0,0 +1,129 @@
import type { Job, Workflow } from '$lib/comfy/types';
import { parseDbTs } from '$lib/dbtime';
import { jobStatusInfo } from '$lib/jobStatus';
import { resolutionSelectorSize } from '$lib/comfy/resolution';
import { t } from '$lib/i18n.svelte';
import { classifySchema, inputRole, isFirstFrame, isLastFrame, isMedia } from './schema';
/** Read-only views of a finished / running take (one playground job). */
export type FeedFilter = 'all' | 'image' | 'video' | 'failed';
export function jobValues(job: Job): Record<string, unknown> {
return (job.payload?.input_values ?? {}) as Record<string, unknown>;
}
export function jobValueByRole(job: Job, wf: Workflow | undefined, ...roles: string[]): string | null {
if (!wf) return null;
const vals = jobValues(job);
for (const inp of wf.input_schema ?? []) {
const r = inputRole(inp);
if (r && roles.includes(r)) {
const v = vals[inp.nodeId];
if (v !== undefined && v !== null && String(v).trim() !== '') return String(v);
}
}
return null;
}
export function promptOf(job: Job, wf: Workflow | undefined): string {
return jobValueByRole(job, wf, 'prompt') ?? '';
}
export interface TakeInput {
label: string;
path: string;
kind: 'image' | 'video' | 'audio';
}
/** Media the take was generated from, in workflow order. */
export function inputsOf(job: Job, wf: Workflow | undefined): TakeInput[] {
if (!wf) return [];
const kind = wf.kind;
const vals = jobValues(job);
const out: TakeInput[] = [];
let refIdx = 0;
for (const inp of wf.input_schema ?? []) {
if (!isMedia(inp)) continue;
const v = vals[inp.nodeId];
const list = Array.isArray(v) ? v : typeof v === 'string' && v ? [v] : [];
const mk = inp.kind.startsWith('video') ? 'video' : inp.kind.startsWith('audio') ? 'audio' : 'image';
list.forEach((p, i) => {
if (typeof p !== 'string' || !p) return;
let label = inp.label;
if (inp.kind === 'image_list') label = t('studio.slotRefN', { n: i + 1 });
else if (isFirstFrame(inp, kind)) label = t('studio.slotFirst');
else if (isLastFrame(inp, kind)) label = t('studio.slotLast');
else if (inp.kind === 'image' && inputRole(inp) !== 'mask') label = t('studio.slotRefN', { n: ++refIdx });
out.push({ label, path: p, kind: mk });
});
}
return out;
}
/** 888×1184 · 种子 42 · 5 秒 — values that differ per take. */
export function specOf(job: Job, wf: Workflow | undefined): Array<{ label: string; value: string }> {
const out: Array<{ label: string; value: string }> = [];
if (!wf) return out;
const w = jobValueByRole(job, wf, 'width');
const h = jobValueByRole(job, wf, 'height');
if (w && h) out.push({ label: t('desk.specSize'), value: `${w}×${h}` });
else {
const ds = classifySchema(wf.input_schema ?? [], wf.kind);
if (ds.ratio?.resolution) {
const vals = jobValues(job);
const size = resolutionSelectorSize(
String(vals[ds.ratio.nodeId] ?? ds.ratio.defaultValue ?? ''),
Number(vals[ds.ratio.resolution.megapixels] ?? ds.megapixels?.defaultValue),
ds.ratio.resolution.multiple,
);
if (size) out.push({ label: t('desk.specSize'), value: `${size.width}×${size.height}` });
}
}
const seed = jobValueByRole(job, wf, 'seed');
if (seed) out.push({ label: t('studio.seedLabel'), value: seed });
const dur = jobValueByRole(job, wf, 'duration');
if (dur) out.push({ label: t('studio.durationLabel'), value: t('desk.chipSeconds', { v: dur }) });
return out;
}
export function isVideoOutput(job: Job, path: string): boolean {
return job.kind === 'video' && !/\.(png|jpe?g|webp)$/i.test(path);
}
export function elapsedSec(job: Job, nowMs: number): number | null {
const from = job.started_at ? parseDbTs(job.started_at) : parseDbTs(job.created_at);
if (!Number.isFinite(from)) return null;
const to = job.completed_at ? parseDbTs(job.completed_at) : nowMs;
return Math.max(0, (to - from) / 1000);
}
export function fmtDur(sec: number | null): string {
if (sec == null) return '—';
if (sec < 60) return t('desk.durSec', { s: Math.floor(sec) });
const m = Math.floor(sec / 60);
const s = Math.floor(sec % 60);
return t('desk.durMin', { m, s: String(s).padStart(2, '0') });
}
export function fmtClock(ms: number): string {
const d = new Date(ms);
return `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`;
}
/** 今天 / 昨天 / 9月28日 heading for the feed. */
export function dayLabel(ms: number, nowMs: number): string {
const d = new Date(ms);
const today = new Date(nowMs);
const startOf = (x: Date) => new Date(x.getFullYear(), x.getMonth(), x.getDate()).getTime();
const diff = Math.round((startOf(today) - startOf(d)) / 86_400_000);
if (diff === 0) return t('desk.today');
if (diff === 1) return t('desk.yesterday');
return t('desk.dayDate', { m: d.getMonth() + 1, d: d.getDate() });
}
export function matchesFilter(job: Job, filter: FeedFilter): boolean {
if (filter === 'all') return true;
if (filter === 'failed') return jobStatusInfo(job).tone === 'fail';
return job.kind === filter;
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,218 @@
<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';
/** Ctrl/⌘K 搜索:跳到任一页面或项目(↑↓ 选择,Enter 打开,Esc 关闭). */
let { open = $bindable(false) }: { open?: boolean } = $props();
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 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;
});
$effect(() => {
void query;
activeIndex = 0;
});
$effect(() => {
if (!open) query = '';
});
function optionId(index: number) {
return `${listId}-${index}`;
}
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 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());
}
</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>
<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;
}
}
</style>
@@ -0,0 +1,127 @@
<script lang="ts">
import { createQuery } from '@tanstack/svelte-query';
import { toStore } from 'svelte/store';
import { projects } from '$lib/api';
import { parseDbTs } from '$lib/dbtime';
import { t } from '$lib/i18n.svelte';
import Icon from '$lib/components/ui/Icon.svelte';
import ShellPop from './ShellPop.svelte';
/**
* 顶栏范围胶囊 — the project picker that replaced the old sidebar project
* entry: shows the project the page belongs to (or 全部项目) and lists
* recent projects, each opening its five-stage workbench.
*/
interface Props {
projectId: number | null;
onProjectsList: boolean;
}
let { projectId, onProjectsList }: Props = $props();
const RECENT_LIMIT = 8;
let open = $state(false);
const projectsQuery = createQuery(
toStore(() => ({
queryKey: ['projects'],
queryFn: projects.list,
enabled: open || projectId != null,
staleTime: 30_000,
})),
);
const all = $derived($projectsQuery.data ?? []);
const current = $derived(projectId == null ? null : (all.find((p) => p.id === projectId) ?? null));
const recent = $derived(
[...all]
.sort((a, b) => (parseDbTs(b.updated_at) || 0) - (parseDbTs(a.updated_at) || 0))
.slice(0, RECENT_LIMIT),
);
</script>
<div class="scope">
<button
type="button"
class="status-chip scope-btn"
aria-haspopup="dialog"
aria-expanded={open}
title={t('shell.scopeHint')}
onclick={() => (open = !open)}
>
<span class="scope-label">{current?.title ?? t('shell.scopeAll')}</span>
<Icon name="chevron-down" size={12} />
</button>
<ShellPop bind:open label={t('shell.scopeHint')} width="300px">
<a
class="a-menu-item"
href="/"
aria-current={onProjectsList ? 'page' : undefined}
onclick={() => (open = false)}
>
<Icon name="folder" size={15} />
<span class="a-menu-label">{t('shell.scopeAllList')}</span>
{#if all.length > 0}<span class="a-menu-meta mono">{all.length}</span>{/if}
</a>
<div class="a-menu-group">{t('shell.scopeRecent')}</div>
{#if $projectsQuery.isPending}
<p class="scope-note">{t('common.loading')}…</p>
{:else if $projectsQuery.isError}
<p class="scope-note err">{t('shell.scopeLoadFailed')}</p>
{:else if recent.length === 0}
<a class="a-menu-item" href="/" onclick={() => (open = false)}>
<Icon name="plus" size={15} />
<span class="a-menu-label">{t('shell.scopeEmpty')}</span>
</a>
{:else}
{#each recent as project (project.id)}
<a
class="a-menu-item"
href="/workbench/{project.id}"
aria-current={project.id === projectId ? 'true' : undefined}
onclick={() => (open = false)}
>
<Icon name="story" size={15} />
<span class="a-menu-label">{project.title}</span>
{#if project.stats}
<span class="a-menu-meta">
{t('shell.scopeStage', { n: project.stats.stage_confirmed })}
</span>
{/if}
</a>
{/each}
{/if}
</ShellPop>
</div>
<style>
.scope {
position: relative;
display: flex;
flex-shrink: 0;
}
.scope-btn {
max-width: 220px;
gap: var(--sp-1);
}
.scope-label {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
}
.scope-note {
margin: 0;
padding: var(--sp-3) var(--sp-4);
font-size: var(--fs-aux);
color: var(--text-3);
}
.scope-note.err {
color: var(--err);
}
@media (max-width: 760px) {
.scope-btn {
max-width: 132px;
}
}
</style>
@@ -0,0 +1,164 @@
<script lang="ts">
import { createQuery } from '@tanstack/svelte-query';
import { toStore } from 'svelte/store';
import { workflows, type Job } from '$lib/api';
import { parseDbTs } from '$lib/dbtime';
import { t } from '$lib/i18n.svelte';
import { jobSourceLabel } from '$lib/jobLabels';
import { jobStatusInfo, type JobStatusTone } from '$lib/jobStatus';
import ShellPop from './ShellPop.svelte';
/**
* 顶栏队列胶囊 + 任务托盘 (§二). The dot is cyan only while something is
* actually generating — queued work stays gray, as on the task page.
*/
let { jobs }: { jobs: Job[] } = $props();
const TRAY_LIMIT = 6;
const TAG_CLASS: Record<JobStatusTone, string> = { ok: 'ok', run: 'run', queue: '', fail: 'err' };
let open = $state(false);
let capsule = $state<HTMLButtonElement | null>(null);
const workflowsQuery = createQuery(
toStore(() => ({
queryKey: ['workflows'],
queryFn: workflows.list,
enabled: open,
staleTime: 60_000,
})),
);
const active = $derived(jobs.filter((j) => j.status === 'pending' || j.status === 'running'));
const running = $derived(active.filter((j) => jobStatusInfo(j).tone === 'run').length);
const queued = $derived(active.length - running);
const recent = $derived([...jobs].sort((a, b) => b.id - a.id).slice(0, TRAY_LIMIT));
const label = $derived.by(() => {
const parts: string[] = [];
if (running > 0) parts.push(t('shell.queueRunning', { n: running }));
if (queued > 0) parts.push(t('shell.queueQueued', { n: queued }));
return parts.length ? parts.join(' · ') : t('shell.queueIdle');
});
// 任务数变化时胶囊轻弹一次(320ms,只播一次).
let lastCount: number | null = null;
$effect(() => {
const count = active.length;
if (lastCount !== null && count !== lastCount && capsule) {
capsule.classList.remove('bump');
void capsule.offsetWidth;
capsule.classList.add('bump');
}
lastCount = count;
});
function workflowName(job: Job): string {
return (
($workflowsQuery.data ?? []).find((w) => w.id === job.workflow_id)?.name ??
t('studio.jobName', { id: job.id })
);
}
function timeOf(job: Job): string {
const ts = parseDbTs(job.created_at);
if (!Number.isFinite(ts)) return '—';
const d = new Date(ts);
const p = (n: number) => String(n).padStart(2, '0');
return `${p(d.getMonth() + 1)}-${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}`;
}
</script>
<div class="tray">
<button
bind:this={capsule}
type="button"
class="status-chip queue-btn"
aria-haspopup="dialog"
aria-expanded={open}
aria-label={label}
title={label}
onclick={() => (open = !open)}
onanimationend={() => capsule?.classList.remove('bump')}
>
<span class="status-chip-dot {running > 0 ? 'run' : 'idle'}" aria-hidden="true"></span>
<span class="queue-label">{label}</span>
<span class="queue-count mono">{active.length}</span>
</button>
<ShellPop bind:open label={t('shell.trayTitle')} align="end" width="340px">
<div class="a-menu-group">{t('shell.trayRecent')}</div>
{#if recent.length === 0}
<p class="tray-note">{t('shell.trayEmpty')}</p>
{:else}
{#each recent as job (job.id)}
{@const info = jobStatusInfo(job)}
<a class="a-menu-item tray-row" href="/tasks" title={info.detail} onclick={() => (open = false)}>
<span class="tray-copy">
<span class="a-menu-label">{workflowName(job)}</span>
<span class="tray-meta">{jobSourceLabel(job)} · <span class="mono">{timeOf(job)}</span></span>
</span>
<span class="a-tag {TAG_CLASS[info.tone]}">{info.label}</span>
</a>
{/each}
{/if}
<a class="a-menu-item tray-all" href="/tasks" onclick={() => (open = false)}>
<span class="a-menu-label">{t('shell.trayAll')}</span>
</a>
</ShellPop>
</div>
<style>
.tray {
position: relative;
flex-shrink: 0;
}
.queue-btn:global(.bump) {
animation: queue-bump 320ms var(--ease-out);
}
@keyframes queue-bump {
40% {
transform: scale(1.06);
}
}
.queue-count {
display: none;
}
@media (max-width: 1179px) {
.queue-label {
display: none;
}
.queue-count {
display: inline;
}
}
.tray-row {
align-items: center;
}
.tray-copy {
display: flex;
flex-direction: column;
flex: 1;
min-width: 0;
line-height: 1.4;
}
.tray-meta {
font-size: var(--fs-mini);
color: var(--text-3);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.tray-note {
margin: 0;
padding: var(--sp-3) var(--sp-4) var(--sp-5);
font-size: var(--fs-aux);
color: var(--text-3);
}
.tray-all {
margin-top: var(--sp-1);
border-top: 1px solid var(--line);
border-radius: 0 0 var(--r-md) var(--r-md);
justify-content: center;
}
</style>
@@ -0,0 +1,196 @@
<script lang="ts">
import type { Snippet } from 'svelte';
import { cubicOut, quintOut } from 'svelte/easing';
/**
* Anchored pop for the shell (§四.3 pop): opens 180ms from its trigger,
* closes on Esc / outside press, Tab cycles inside, ↑↓ move between rows.
* At ≤760px it becomes a bottom sheet. Render it next to its trigger
* inside a `position: relative` wrapper — that wrapper counts as inside.
*/
interface Props {
open?: boolean;
label: string;
align?: 'start' | 'end';
/** `top` opens above the trigger (triggers near the bottom edge, e.g. the composer). */
placement?: 'bottom' | 'top';
width?: string;
onclose?: () => void;
children: Snippet;
}
let {
open = $bindable(false),
label,
align = 'start',
placement = 'bottom',
width = '300px',
onclose,
children,
}: Props = $props();
const FOCUSABLE =
'a[href], button:not([disabled]), input:not([disabled]), select:not([disabled]), [tabindex]:not([tabindex="-1"])';
const SHEET_QUERY = '(max-width: 760px)';
let panel = $state<HTMLDivElement | null>(null);
let scrim = $state<HTMLDivElement | null>(null);
function focusables(): HTMLElement[] {
if (!panel) return [];
return Array.from(panel.querySelectorAll<HTMLElement>(FOCUSABLE)).filter(
(el) => el.getClientRects().length > 0,
);
}
function handleKeydown(event: KeyboardEvent) {
if (event.key === 'Escape') {
event.preventDefault();
event.stopPropagation();
open = false;
return;
}
const list = focusables();
if (list.length === 0) return;
const current = document.activeElement as HTMLElement | null;
const index = current ? list.indexOf(current) : -1;
if (event.key === 'ArrowDown' || event.key === 'ArrowUp') {
if (current?.tagName === 'SELECT') return;
event.preventDefault();
const step = event.key === 'ArrowDown' ? 1 : -1;
const next = index < 0 ? (step > 0 ? 0 : list.length - 1) : index + step;
list[(next + list.length) % list.length].focus();
} else if (event.key === 'Tab') {
if (event.shiftKey && index <= 0) {
event.preventDefault();
list[list.length - 1].focus();
} else if (!event.shiftKey && (index < 0 || index === list.length - 1)) {
event.preventDefault();
list[0].focus();
}
}
}
function handlePointerDown(event: PointerEvent) {
const host = panel?.parentElement;
const target = event.target as Node;
if (!host) return;
if (!host.contains(target) || target === scrim) open = false;
}
$effect(() => {
if (!open) return;
const invoker = document.activeElement as HTMLElement | null;
document.addEventListener('keydown', handleKeydown, true);
document.addEventListener('pointerdown', handlePointerDown, true);
requestAnimationFrame(() => focusables()[0]?.focus());
return () => {
document.removeEventListener('keydown', handleKeydown, true);
document.removeEventListener('pointerdown', handlePointerDown, true);
const focusWasInside =
document.activeElement === document.body || !!panel?.contains(document.activeElement);
if (focusWasInside && invoker?.isConnected) invoker.focus();
onclose?.();
};
});
function cssMs(name: string, fallback: number): number {
const raw = getComputedStyle(document.documentElement).getPropertyValue(name);
const ms = parseFloat(raw);
return Number.isFinite(ms) ? ms : fallback;
}
function popIn(_node: Element) {
const reduced = document.documentElement.dataset.reduce === '1';
const sheet = window.matchMedia(SHEET_QUERY).matches;
if (reduced) {
return { duration: cssMs('--d-state', 120), css: (t: number) => `opacity: ${t}` };
}
if (sheet) {
return {
duration: cssMs('--d-drawer', 280),
easing: quintOut,
css: (t: number) => `transform: translateY(${(1 - t) * 100}%)`,
};
}
return {
duration: cssMs('--d-pop', 180),
easing: cubicOut,
css: (t: number) => `opacity: ${t}; transform: scale(${0.96 + 0.04 * t})`,
};
}
function fadeScrim(_node: Element) {
return { duration: cssMs('--d-pop', 180), css: (t: number) => `opacity: ${t}` };
}
</script>
{#if open}
<div class="pop-scrim" bind:this={scrim} aria-hidden="true" transition:fadeScrim></div>
<div
bind:this={panel}
class="shell-pop a-float align-{align} place-{placement}"
style:--pop-w={width}
role="dialog"
aria-label={label}
transition:popIn
>
{@render children()}
</div>
{/if}
<style>
.shell-pop {
position: absolute;
top: calc(100% + var(--sp-2));
z-index: 60;
width: var(--pop-w);
max-width: calc(100vw - 2 * var(--sp-5));
max-height: min(520px, 70vh);
overflow-y: auto;
padding: var(--sp-2);
box-sizing: border-box;
transform-origin: top left;
}
.shell-pop.align-start {
left: 0;
}
.shell-pop.align-end {
right: 0;
transform-origin: top right;
}
.shell-pop.place-top {
top: auto;
bottom: calc(100% + var(--sp-2));
transform-origin: bottom left;
}
.shell-pop.place-top.align-end {
transform-origin: bottom right;
}
.pop-scrim {
display: none;
}
@media (max-width: 760px) {
.pop-scrim {
display: block;
position: fixed;
inset: 0;
z-index: 59;
background: var(--overlay);
}
.shell-pop,
.shell-pop.place-top {
position: fixed;
top: auto;
left: 0;
right: 0;
bottom: 0;
width: 100vw;
max-width: none;
max-height: 78vh;
border-radius: var(--r-lg) var(--r-lg) 0 0;
padding: var(--sp-3) var(--sp-3) calc(var(--sp-3) + env(safe-area-inset-bottom));
}
}
</style>
@@ -0,0 +1,70 @@
import type { IconName } from '$lib/components/ui/icons';
export type NavId =
| 'projects'
| 'workbench'
| 'image'
| 'video'
| 'tasks'
| 'agents'
| 'connections'
| 'models'
| 'settings';
export interface NavItem {
id: NavId;
labelKey: string;
icon: IconName;
href: string;
}
// 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: 'agents', labelKey: 'shell.navAgents', icon: 'sparkle', href: '/agents' },
];
export const NAV_FOOT: NavItem[] = [
{ id: 'connections', labelKey: 'shell.navConnections', icon: 'link', href: '/connections' },
{ id: 'models', labelKey: 'shell.navModels', icon: 'assets', href: '/models' },
{ id: 'settings', labelKey: 'shell.navSettings', icon: 'settings', href: '/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),
);
@@ -1,37 +1,33 @@
<script lang="ts">
import Icon from '$lib/components/ui/Icon.svelte';
import Spinner from '$lib/components/ui/Spinner.svelte';
import { assetUrl } from '$lib/api';
import { IMAGE_SLOT_ACCEPT, formatBytes, uploadBaseName } from '$lib/comfy/imageFile';
import { t } from '$lib/i18n.svelte';
/**
* A-style media upload slot (spec-v2 §5 上传槽, ref-upload-spec §2):
* empty = 1.5px dashed border + "click or drop" + format line, drag-hover
* = amber dashed, filled = solid border + thumb + name + size + 替换 + ✕,
* error = red border + red reason.
* Single media input slot as a composer tile (spec-v2 §3.3 输入槽):
* empty = dashed + "+", drag-hover = gold, uploading = ring, filled = thumb
* (click to replace) with a ✕ on hover/focus. `need` = required but empty
* after a blocked 生成 (amber); `error` = rejected file / invalid (red).
*/
interface Props {
label: string;
/** Hint on the right of the label, e.g. 必填 · 1 张 / 可从生图结果送入. */
/** Hint after the label, e.g. 必填 · 1 张. */
hint?: string;
required?: boolean;
/** Current value: a server-side path ('' = empty). */
value: string;
kind: 'image' | 'video' | 'audio';
/** Thumb aspect: wide = 16:9 (first/last frame slots). */
aspect?: 'square' | 'wide';
/** Empty-state main line (defaults to 点击或拖入). */
emptyText?: string;
/** Empty-state mono line, e.g. accepted formats and size limit. */
emptySub?: string;
/** Byte size of the current file when known. */
size?: number;
uploading?: boolean;
uploadName?: string;
need?: boolean;
error?: string;
/** Show the "pick from image results" link (video-page image slots). */
/** Show the 从结果选 action (image slots fed from finished takes). */
allowFromResults?: boolean;
/** Anchor id so 「还缺:…」 can jump focus here. */
anchorId?: string;
onFile: (file: File) => void;
onFromResults?: () => void;
onClear: () => void;
@@ -43,14 +39,13 @@
required = false,
value,
kind,
aspect = 'square',
emptyText,
emptySub,
size,
uploading = false,
uploadName,
need = false,
error,
allowFromResults = false,
anchorId,
onFile,
onFromResults,
onClear,
@@ -66,7 +61,10 @@
dims = '';
});
const meta = $derived([dims, size != null ? formatBytes(size) : ''].filter(Boolean).join(' · '));
const filled = $derived(!!value && !broken);
const meta = $derived(
[uploadBaseName(value), dims, size != null ? formatBytes(size) : ''].filter(Boolean).join(' · '),
);
function accept(): string {
if (kind === 'video') return 'video/*,.mp4,.webm,.mov,.mkv';
@@ -87,17 +85,34 @@
function onDrop(e: DragEvent) {
e.preventDefault();
e.stopPropagation();
dragDepth = 0;
if (uploading) return;
const file = e.dataTransfer?.files?.[0];
if (file) onFile(file);
}
const dragHandlers = {
ondragenter: (e: DragEvent) => {
e.preventDefault();
dragDepth += 1;
},
ondragleave: () => (dragDepth = Math.max(0, dragDepth - 1)),
ondragover: (e: DragEvent) => e.preventDefault(),
ondrop: onDrop,
};
</script>
<div class="media-slot" class:wide={aspect === 'wide'}>
<div class="slot-label">
<span class="lbl">{label}{#if required}<em>*</em>{/if}</span>
{#if hint}<span class="slot-hint">{hint}</span>{/if}
<div class="a-slot">
<div class="a-slot-label">
<span>{label}</span>
{#if required}<span class="req" aria-hidden="true">*</span>{/if}
{#if hint}<span>{hint}</span>{/if}
{#if allowFromResults && onFromResults}
<button type="button" class="a-btn ghost xs" onclick={onFromResults}>
{t('studio.slotFromResultsShort')}
</button>
{/if}
</div>
<input
@@ -113,110 +128,61 @@
}}
/>
{#if value && !broken}
<div class="slot-filled" class:invalid={!!error}>
<div class="slot-thumb">
{#if uploading}
<Spinner size="sm" />
{:else if kind === 'image'}
<img src={assetUrl(value)} alt={label} onload={noteDims} onerror={() => (broken = true)} />
{:else if kind === 'video'}
<video src={assetUrl(value)} preload="metadata" muted></video>
<span class="kind-badge" aria-hidden="true"><Icon name="film" size={12} /></span>
{:else}
<span class="kind-badge" aria-hidden="true"><Icon name="music" size={12} /></span>
{/if}
</div>
<div class="slot-meta">
<span class="name" title={uploading ? uploadName : uploadBaseName(value)}>
{uploading ? uploadName : uploadBaseName(value)}
</span>
{#if meta && !uploading}<span class="mono dims">{meta}</span>{/if}
</div>
<div class="slot-actions">
{#if allowFromResults && onFromResults}
<button type="button" class="mini-link" onclick={onFromResults}>
{t('studio.slotFromResultsShort')}
</button>
{/if}
<button type="button" class="mini-link" disabled={uploading} onclick={pick}>
{t('studio.refReplace')}
</button>
<div class="a-slot-row">
{#if filled}
<div class="a-tile" class:drag={dragDepth > 0} class:bad={!!error} {...dragHandlers}>
<button
type="button"
class="slot-clear"
aria-label={t('studio.deleteBtn')}
id={anchorId}
class="a-tile-hit"
title={`${t('studio.refReplace')} · ${meta}`}
aria-label={`${t('studio.refReplace')} ${label}`}
disabled={uploading}
onclick={onClear}
onclick={pick}
>
<Icon name="close" size={12} />
{#if kind === 'image'}
<img src={assetUrl(value)} alt="" onload={noteDims} onerror={() => (broken = true)} />
{:else if kind === 'video'}
<video src={assetUrl(value)} preload="metadata" muted playsinline></video>
{:else}
<Icon name="music" size={18} />
{/if}
</button>
{#if uploading}
<span class="a-tile-ring" title={uploadName}><i></i></span>
{:else}
<button type="button" class="a-tile-x" aria-label={`${t('studio.deleteBtn')} ${label}`} onclick={onClear}>
<Icon name="close" size={10} />
</button>
{/if}
</div>
</div>
{:else}
<button
type="button"
class="slot-empty"
class:drag={dragDepth > 0}
class:invalid={!!error}
class:wide={aspect === 'wide'}
disabled={uploading}
aria-busy={uploading || undefined}
onclick={pick}
ondragenter={(e) => {
e.preventDefault();
dragDepth += 1;
}}
ondragleave={() => (dragDepth = Math.max(0, dragDepth - 1))}
ondragover={(e) => e.preventDefault()}
ondrop={onDrop}
>
{#if uploading}
<Spinner size="sm" />
<span class="pick-text">{uploadName ?? t('studio.slotPick')}</span>
{:else}
<Icon name={kind === 'video' ? 'film' : kind === 'audio' ? 'music' : 'image'} size={16} />
<span class="pick-text">{emptyText ?? t('studio.slotPick')}</span>
{#if emptySub}<span class="pick-sub mono">{emptySub}</span>{/if}
{/if}
</button>
{#if allowFromResults && onFromResults}
<button type="button" class="from-results-link" onclick={onFromResults}>
{t('studio.slotFromResults')}
{:else}
<button
type="button"
id={anchorId}
class="a-tile add"
class:drag={dragDepth > 0}
class:need={need && !error}
class:bad={!!error}
title={t('studio.slotPick')}
aria-label={`${label} · ${t('studio.slotPick')}`}
aria-busy={uploading || undefined}
disabled={uploading}
onclick={pick}
{...dragHandlers}
>
{#if uploading}
<span class="a-tile-ring" title={uploadName}><i></i></span>
{:else}
<Icon name={kind === 'video' ? 'film' : kind === 'audio' ? 'music' : 'plus'} size={18} />
{/if}
</button>
{/if}
{/if}
{#if error}<span class="slot-error" role="alert">{error}</span>{/if}
</div>
{#if error?.trim()}<span class="a-slot-hint err" role="alert">{error}</span>{/if}
</div>
<style>
.media-slot {
display: flex;
flex-direction: column;
gap: 5px;
min-width: 0;
}
.slot-label {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 8px;
}
.lbl {
font-size: 12px;
font-weight: 600;
color: var(--text-2);
}
.lbl em {
color: var(--error);
font-style: normal;
margin-left: 2px;
}
.slot-hint {
font-size: 10.5px;
color: var(--text-3);
white-space: nowrap;
}
.sr-file {
position: absolute;
width: 1px;
@@ -226,164 +192,4 @@
white-space: nowrap;
border: 0;
}
.slot-empty {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 6px;
min-height: 86px;
border: 1.5px dashed var(--border-strong);
border-radius: 8px;
background: var(--surface-2);
color: var(--text-3);
font-family: inherit;
font-size: 11.5px;
cursor: pointer;
transition:
border-color 150ms,
color 150ms;
}
.media-slot.wide .slot-empty {
aspect-ratio: 16 / 9;
min-height: 0;
width: 100%;
}
.slot-empty:hover:not(:disabled) {
color: var(--text-2);
border-color: rgba(255, 255, 255, 0.28);
}
.slot-empty.drag {
border-color: var(--accent);
color: var(--accent);
}
.slot-empty.invalid {
border-color: var(--err-line);
}
.slot-empty:disabled {
cursor: default;
opacity: 0.7;
}
.pick-text {
font-size: 12.5px;
color: var(--text-2);
}
.pick-sub {
font-size: 11px;
color: var(--text-3);
}
.from-results-link {
align-self: flex-end;
background: none;
border: none;
padding: 0;
font-family: inherit;
font-size: 11px;
color: var(--accent);
cursor: pointer;
}
.from-results-link:hover {
text-decoration: underline;
}
.slot-filled {
display: flex;
align-items: center;
gap: 10px;
border: 1px solid var(--border-strong);
border-radius: 8px;
background: var(--surface-2);
padding: 8px;
}
.slot-filled.invalid {
border-color: var(--error);
}
.slot-thumb {
width: 56px;
height: 56px;
flex-shrink: 0;
border-radius: 6px;
overflow: hidden;
background: var(--bg);
border: 1px solid var(--border);
display: flex;
align-items: center;
justify-content: center;
position: relative;
}
.media-slot.wide .slot-thumb {
width: 88px;
height: 50px;
}
.slot-thumb img,
.slot-thumb video {
display: block;
width: 100%;
height: 100%;
object-fit: cover;
}
.slot-thumb .kind-badge {
color: var(--text-3);
}
.slot-meta {
flex: 1;
display: flex;
flex-direction: column;
gap: 3px;
min-width: 0;
}
.name {
font-size: 12.5px;
color: var(--text);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.dims {
font-size: 10.5px;
color: var(--text-3);
white-space: nowrap;
}
.slot-actions {
display: inline-flex;
align-items: center;
gap: 8px;
flex-shrink: 0;
}
.mini-link {
background: none;
border: none;
padding: 0;
font-family: inherit;
font-size: 11.5px;
color: var(--text-2);
text-decoration: underline;
cursor: pointer;
}
.mini-link:hover:not(:disabled) {
color: var(--accent);
}
.mini-link:disabled {
opacity: 0.5;
cursor: default;
}
.slot-clear {
display: inline-flex;
align-items: center;
justify-content: center;
width: 22px;
height: 22px;
border: none;
border-radius: 6px;
background: var(--surface-3);
color: var(--text-3);
cursor: pointer;
}
.slot-clear:hover {
color: var(--error);
background: var(--error-dim);
}
.slot-error {
font-size: 11.5px;
color: var(--error);
}
</style>
@@ -1,6 +1,5 @@
<script lang="ts">
import Icon from '$lib/components/ui/Icon.svelte';
import Spinner from '$lib/components/ui/Spinner.svelte';
import { assetUrl } from '$lib/api';
import {
IMAGE_SLOT_ACCEPT,
@@ -12,11 +11,10 @@
import { t } from '$lib/i18n.svelte';
/**
* Reference-media grid (ref-upload-spec §2 `.refgrid`, generalized to
* image/video/audio in HEL-643): numbered cells 参考图/参考视频/参考音频
* 1…N in the order they reach the workflow, each with replace and ✕; a
* dashed add cell while below `max`; rejected files show as error cells
* that take no place in the list.
* Reference-media list as numbered composer tiles (spec-v2 §3.3 参考图):
* 1…N in the order they reach the workflow — click a tile to replace it,
* ✕ to remove; a dashed add tile while below `max`; rejected files show
* as red tiles that take no place in the list.
*/
interface Reject {
name: string;
@@ -35,8 +33,12 @@
/** Index being replaced while uploading (-1 = adding). */
uploadingIndex?: number;
rejects?: Reject[];
/** Below the minimum after a blocked 生成 (amber add tile). */
need?: boolean;
error?: string;
allowFromResults?: boolean;
/** Anchor id on the add tile so 「还缺:…」 can jump focus here. */
anchorId?: string;
onAdd: (files: File[]) => void;
onReplace: (index: number, file: File) => void;
onRemove: (index: number) => void;
@@ -53,8 +55,10 @@
uploadingName,
uploadingIndex = -1,
rejects = [],
need = false,
error,
allowFromResults = false,
anchorId,
onAdd,
onReplace,
onRemove,
@@ -78,16 +82,27 @@
? t('studio.refHintMedia', { n: paths.length, max })
: t('studio.refHint', { n: paths.length, max }),
);
function durationOf(path: string): string {
const d = durations[path];
if (d == null || !Number.isFinite(d)) return '';
return `${d.toFixed(1)}s`;
}
function noteDuration(path: string, e: Event): void {
const el = e.currentTarget as HTMLVideoElement | HTMLAudioElement | null;
if (el && Number.isFinite(el.duration)) {
durations = { ...durations, [path]: el.duration };
}
if (el && Number.isFinite(el.duration)) durations = { ...durations, [path]: el.duration };
}
function noteDims(path: string, e: Event) {
const img = e.currentTarget as HTMLImageElement;
if (img.naturalWidth) dims = { ...dims, [path]: `${img.naturalWidth}×${img.naturalHeight}` };
}
function metaOf(path: string): string {
const d = durations[path];
return [
uploadBaseName(path),
dims[path],
d != null && Number.isFinite(d) ? `${d.toFixed(1)}s` : null,
sizes[path] != null ? formatBytes(sizes[path]) : null,
]
.filter(Boolean)
.join(' · ');
}
function pickAdd() {
@@ -105,28 +120,24 @@
function onDrop(e: DragEvent) {
e.preventDefault();
e.stopPropagation();
dragDepth = 0;
if (uploading) return;
const files = Array.from(e.dataTransfer?.files ?? []);
if (files.length) onAdd(files);
}
function noteDims(path: string, e: Event) {
const img = e.currentTarget as HTMLImageElement;
if (img.naturalWidth) dims = { ...dims, [path]: `${img.naturalWidth}×${img.naturalHeight}` };
}
function metaOf(path: string): string {
return [dims[path], durationOf(path), sizes[path] != null ? formatBytes(sizes[path]) : null]
.filter(Boolean)
.join(' · ');
}
</script>
<div class="refgrid-field">
<div class="slot-label">
<span class="lbl">{label}<em>*</em></span>
<span class="slot-hint">{hintLabel}</span>
<div class="a-slot">
<div class="a-slot-label">
<span>{label}</span>
<span class="req" aria-hidden="true">*</span>
<span class="mono">{hintLabel}</span>
{#if allowFromResults && onFromResults && remaining > 0}
<button type="button" class="a-btn ghost xs" onclick={onFromResults}>
{t('studio.slotFromResultsShort')}
</button>
{/if}
</div>
<input
@@ -135,7 +146,7 @@
class="sr-file"
tabindex="-1"
aria-hidden="true"
accept={accept}
{accept}
multiple
onchange={(e) => {
const files = Array.from(e.currentTarget.files ?? []);
@@ -148,28 +159,26 @@
class="sr-file"
tabindex="-1"
aria-hidden="true"
accept={accept}
{accept}
onchange={(e) => {
const f = e.currentTarget.files?.[0];
if (f && replaceIndex >= 0) onReplace(replaceIndex, f);
}}
/>
<div class="refgrid" class:audio={mediaKind === 'audio'}>
<div class="a-slot-row">
{#each paths as path, i (path + i)}
<div class="ref-cell" class:busy={uploadingIndex === i}>
<div class="a-tile">
<button
type="button"
class="ref-thumb"
title={t('studio.refReplace')}
class="a-tile-hit"
title={`${t('studio.refReplace')} · ${metaOf(path)}`}
aria-label={`${t('studio.refReplace')} ${label} ${i + 1}`}
disabled={uploading}
onclick={() => pickReplace(i)}
>
{#if mediaKind === 'image'}
<img
src={assetUrl(path)}
alt={t('studio.slotRefN', { n: i + 1 })}
onload={(e) => noteDims(path, e)}
/>
<img src={assetUrl(path)} alt="" onload={(e) => noteDims(path, e)} />
{:else if mediaKind === 'video'}
<video
src={assetUrl(path)}
@@ -177,64 +186,59 @@
muted
playsinline
onloadedmetadata={(e) => noteDuration(path, e)}
title={uploadBaseName(path)}
></video>
{:else}
<span class="audio-glyph"><Icon name="music" size={18} /></span>
<audio
src={assetUrl(path)}
preload="metadata"
onloadedmetadata={(e) => noteDuration(path, e)}
title={uploadBaseName(path)}
<Icon name="music" size={18} />
<audio src={assetUrl(path)} preload="metadata" onloadedmetadata={(e) => noteDuration(path, e)}
></audio>
{/if}
<span class="ref-no mono">{i + 1}</span>
{#if uploadingIndex === i}
<span class="ref-busy"><Spinner size="sm" /></span>
{/if}
</button>
<button
type="button"
class="ref-x"
aria-label={isMedia ? t('studio.refRemoveMedia', { kind: label, n: i + 1 }) : t('studio.refRemove', { n: i + 1 })}
onclick={() => onRemove(i)}
>
<Icon name="close" size={11} />
</button>
<span class="ref-name" title={uploadBaseName(path)}>{uploadBaseName(path)}</span>
<span class="ref-meta mono">
{metaOf(path)}
<button type="button" class="mini-link" onclick={() => pickReplace(i)}>{t('studio.refReplace')}</button>
</span>
<span class="a-tile-idx">{i + 1}</span>
{#if uploadingIndex === i}
<span class="a-tile-ring" title={uploadingName}><i></i></span>
{:else}
<button
type="button"
class="a-tile-x"
aria-label={isMedia
? t('studio.refRemoveMedia', { kind: label, n: i + 1 })
: t('studio.refRemove', { n: i + 1 })}
onclick={() => onRemove(i)}
>
<Icon name="close" size={10} />
</button>
{/if}
</div>
{/each}
{#each rejects as rej, i (rej.name + i)}
<div class="ref-cell rejected" role="alert">
<div class="ref-thumb error">
<Icon name="close" size={16} />
<span class="mono">{t('studio.refRejectedShort')}</span>
</div>
<div class="a-tile bad" title={`${rej.name} · ${rej.message}`} role="img" aria-label={rej.name}>
<Icon name="alert" size={16} />
<button
type="button"
class="ref-x"
aria-label={t('studio.deleteBtn')}
class="a-tile-x"
aria-label={`${t('studio.deleteBtn')} ${rej.name}`}
onclick={() => onDismissReject(i)}
>
<Icon name="close" size={11} />
<Icon name="close" size={10} />
</button>
<span class="ref-name err" title={rej.name}>{rej.name}</span>
</div>
{/each}
{#if remaining > 0}
<button
type="button"
class="ref-add"
id={anchorId}
class="a-tile add"
class:drag={dragDepth > 0}
class:invalid={!!error}
disabled={uploading}
class:need={need && !error?.trim()}
class:bad={!!error?.trim()}
title={isMedia
? t('studio.refAddSubMedia', { n: remaining, max })
: t('studio.refAddSub', { n: remaining, max })}
aria-label={isMedia ? t('studio.refAddMedia', { kind: label }) : t('studio.refAdd')}
aria-busy={uploading || undefined}
disabled={uploading}
onclick={pickAdd}
ondragenter={(e) => {
e.preventDefault();
@@ -245,83 +249,24 @@
ondrop={onDrop}
>
{#if uploading && uploadingIndex < 0}
<Spinner size="sm" />
<span class="add-main">{uploadingName}</span>
<span class="a-tile-ring" title={uploadingName}><i></i></span>
{:else}
<Icon name="plus" size={16} />
<span class="add-main">{isMedia ? t('studio.refAddMedia', { kind: label }) : t('studio.refAdd')}</span>
<span class="add-sub mono">{isMedia ? t('studio.refAddSubMedia', { n: remaining, max }) : t('studio.refAddSub', { n: remaining, max })}</span>
<Icon name="plus" size={18} />
{/if}
</button>
{/if}
</div>
{#if allowFromResults && onFromResults && remaining > 0}
<button type="button" class="from-results-link" onclick={onFromResults}>
{t('studio.slotFromResults')}
</button>
{/if}
{#if remaining === 0}
<span class="full-note">{isMedia ? t('studio.refFullMedia', { max }) : t('studio.refFull', { max })}</span>
<span class="a-slot-hint">{isMedia ? t('studio.refFullMedia', { max }) : t('studio.refFull', { max })}</span>
{/if}
{#each rejects as rej, i (rej.name + i)}
<span class="slot-error">{rej.message}</span>
<span class="a-slot-hint err" role="alert">{rej.message}</span>
{/each}
{#if error?.trim()}<span class="slot-error" role="alert">{error}</span>{/if}
{#if error?.trim()}<span class="a-slot-hint err" role="alert">{error}</span>{/if}
</div>
<style>
.refgrid-field {
display: flex;
flex-direction: column;
gap: 6px;
min-width: 0;
}
.refgrid.audio {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.refgrid.audio .ref-thumb {
aspect-ratio: auto;
min-height: 64px;
flex-direction: column;
gap: 2px;
padding: 8px 4px;
}
.refgrid.audio audio {
width: 90%;
height: 28px;
}
.refgrid video {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
.audio-glyph {
color: var(--text-3);
display: inline-flex;
}
.slot-label {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 8px;
}
.lbl {
font-size: 12px;
font-weight: 600;
color: var(--text-2);
}
.lbl em {
color: var(--error);
font-style: normal;
margin-left: 2px;
}
.slot-hint {
font-size: 10.5px;
color: var(--text-3);
white-space: nowrap;
}
.sr-file {
position: absolute;
width: 1px;
@@ -331,194 +276,7 @@
white-space: nowrap;
border: 0;
}
.refgrid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 8px;
}
.ref-cell {
position: relative;
display: flex;
flex-direction: column;
gap: 3px;
min-width: 0;
padding: 5px;
border: 1px solid var(--border-strong);
border-radius: 8px;
background: var(--surface-2);
}
.ref-cell.rejected {
border-color: var(--error);
}
.ref-thumb {
position: relative;
display: flex;
align-items: center;
justify-content: center;
aspect-ratio: 1;
padding: 0;
border: 1px solid var(--border);
border-radius: 6px;
overflow: hidden;
background: var(--bg);
cursor: pointer;
}
.ref-thumb img {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
.ref-thumb.error {
flex-direction: column;
gap: 4px;
cursor: default;
color: var(--error);
font-size: 10px;
}
.ref-no {
position: absolute;
left: 4px;
top: 4px;
min-width: 16px;
padding: 0 4px;
border-radius: 4px;
background: rgba(0, 0, 0, 0.62);
color: var(--text);
font-size: 10px;
line-height: 16px;
text-align: center;
}
.ref-busy {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
background: rgba(0, 0, 0, 0.5);
}
.ref-x {
position: absolute;
top: 9px;
right: 9px;
display: inline-flex;
align-items: center;
justify-content: center;
width: 18px;
height: 18px;
padding: 0;
border: none;
border-radius: 5px;
background: rgba(0, 0, 0, 0.62);
color: var(--text);
cursor: pointer;
}
.ref-x:hover {
color: var(--error);
}
.ref-name {
font-size: 11px;
color: var(--text);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.ref-name.err {
color: var(--error);
}
.ref-meta {
display: flex;
align-items: center;
justify-content: space-between;
gap: 4px;
font-size: 9.5px;
color: var(--text-3);
white-space: nowrap;
overflow: hidden;
}
.mini-link {
background: none;
border: none;
padding: 0;
font-family: inherit;
font-size: 10.5px;
color: var(--text-2);
text-decoration: underline;
cursor: pointer;
flex-shrink: 0;
}
.mini-link:hover {
color: var(--accent);
}
.ref-add {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 4px;
min-height: 110px;
padding: 8px 4px;
border: 1.5px dashed var(--border-strong);
border-radius: 8px;
background: var(--surface-2);
color: var(--text-3);
font-family: inherit;
cursor: pointer;
transition:
border-color 150ms,
color 150ms;
}
.ref-add:hover:not(:disabled) {
color: var(--text-2);
}
.ref-add.drag {
border-color: var(--accent);
color: var(--accent);
}
.ref-add.invalid {
border-color: var(--error);
}
.ref-add:disabled {
cursor: default;
opacity: 0.7;
}
.add-main {
font-size: 12px;
color: var(--text-2);
text-align: center;
overflow: hidden;
text-overflow: ellipsis;
max-width: 100%;
}
.add-sub {
font-size: 10px;
text-align: center;
}
.from-results-link {
align-self: flex-end;
background: none;
border: none;
padding: 0;
font-family: inherit;
font-size: 11px;
color: var(--accent);
cursor: pointer;
}
.from-results-link:hover {
text-decoration: underline;
}
.full-note {
font-size: 11px;
color: var(--text-3);
}
.slot-error {
font-size: 11.5px;
color: var(--error);
line-height: 1.5;
}
@media (max-width: 1023px) {
.refgrid {
grid-template-columns: repeat(auto-fill, minmax(120px, 1fr));
}
audio {
display: none;
}
</style>
File diff suppressed because it is too large Load Diff
@@ -4,10 +4,13 @@
import Icon from '$lib/components/ui/Icon.svelte';
import JobChip from './JobChip.svelte';
import PromptBlock from './PromptBlock.svelte';
import { deskHref } from '$lib/components/generate/context';
import { t } from '$lib/i18n.svelte';
interface Props {
stage: WorkbenchStage;
/** Enables the 在生成台调参 link (desk opens with this asset's context). */
projectId?: number;
onSave?: (content: Record<string, unknown>) => void;
onRun?: (instruction?: string) => void;
onGenerate?: (payload: {
@@ -20,7 +23,7 @@
busy?: boolean;
}
let { stage, onSave, onRun, onGenerate, onRetryJob, busy = false }: Props = $props();
let { stage, projectId, onSave, onRun, onGenerate, onRetryJob, busy = false }: Props = $props();
let editing = $state(false);
let draft = $state<WorkbenchAsset[]>([]);
@@ -168,6 +171,25 @@
<PromptBlock label={t('workbench.style.negativeLabel')} text={asset.negative_prompt} />
{/if}
<div class="asset-foot">
{#if projectId}
<a
class="a-btn ghost sm"
href={deskHref({
kind: 'image',
projectId,
stage: 'assets',
label: t('desk.ctxAsset', {
kind: t(KIND_LABEL[asset.kind] ?? 'workbench.asset.kindProp'),
name: asset.name,
}),
prompt: asset.prompt,
negative: asset.negative_prompt || undefined,
})}
title={t('desk.openInDeskHint')}
>
{t('desk.openInDesk')}
</a>
{/if}
<button
type="button"
class="btn ghost gen"
@@ -294,7 +316,9 @@
}
.asset-foot {
display: flex;
align-items: center;
justify-content: flex-end;
gap: var(--sp-2);
}
.retry-link {
@@ -4,10 +4,13 @@
import Icon from '$lib/components/ui/Icon.svelte';
import JobChip from './JobChip.svelte';
import PromptBlock from './PromptBlock.svelte';
import { deskHref } from '$lib/components/generate/context';
import { t } from '$lib/i18n.svelte';
interface Props {
stage: WorkbenchStage;
/** Enables the 在生成台调参 link (desk opens with this shot's context). */
projectId?: number;
onSave?: (content: Record<string, unknown>) => void;
onRun?: (instruction?: string) => void;
onGenerate?: (payload: {
@@ -20,7 +23,7 @@
busy?: boolean;
}
let { stage, onSave, onRun, onGenerate, onRetryJob, busy = false }: Props = $props();
let { stage, projectId, onSave, onRun, onGenerate, onRetryJob, busy = false }: Props = $props();
let editing = $state(false);
let draft = $state<VideoShot[]>([]);
@@ -159,6 +162,22 @@
{/if}
<PromptBlock label={t('workbench.vshot.promptLabel')} text={shot.prompt} />
<div class="vshot-foot">
{#if projectId}
<a
class="a-btn ghost sm"
href={deskHref({
kind: 'video',
projectId,
stage: 'shot',
label: t('desk.ctxShot', { no: shot.shot_no }),
prompt: shot.prompt,
durationSec: shot.duration_sec,
})}
title={t('desk.openInDeskHint')}
>
{t('desk.openInDesk')}
</a>
{/if}
<button
type="button"
class="btn ghost gen"
@@ -266,7 +285,9 @@
}
.vshot-foot {
display: flex;
align-items: center;
justify-content: flex-end;
gap: var(--sp-2);
}
.retry-link {
+123 -6
View File
@@ -1260,14 +1260,44 @@ export const de: Dict = {
'shell.navConnections': 'ComfyUI-Verbindungen',
'shell.navModels': 'Modelle',
'shell.navSettings': 'Einstellungen',
'shell.wip': 'In Entwicklung',
'shell.wipHint': 'Diese Seite ist noch im Bau und folgt später',
'shell.comfy_ok': '{name} verbunden',
'shell.comfy_fail': '{name} nicht erreichbar',
'shell.comfy_testing': '{name} wird getestet…',
'shell.comfyUnset': 'ComfyUI · nicht konfiguriert',
'shell.comfyManage': 'ComfyUI-Adresse in den Einstellungen pflegen',
'shell.mobileNotice': 'Bitte öffne diese Werkbank in einem Desktop-Browser (mindestens 768px breit); Handys werden noch nicht unterstützt.',
'shell.scopeHint': 'Projekt wechseln',
'shell.scopeAll': 'Alle Projekte',
'shell.scopeAllList': 'Liste aller Projekte',
'shell.scopeRecent': 'Letzte Projekte',
'shell.scopeLoadFailed': 'Projekte konnten nicht geladen werden',
'shell.scopeEmpty': 'Noch keine Projekte – jetzt anlegen',
'shell.scopeStage': '{n}/5 bestätigt',
'shell.queueRunning': '{n} in Arbeit',
'shell.queueQueued': '{n} in der Warteschlange',
'shell.queueIdle': 'Warteschlange leer',
'shell.queueActive': '{n} aktiv',
'shell.trayTitle': 'Auftragsablage',
'shell.trayRecent': 'Letzte Aufträge · klicken für alle Aufträge',
'shell.trayEmpty': 'Noch keine Generierungsaufträge',
'shell.trayAll': 'Alle Aufträge anzeigen',
'shell.searchTitle': 'Seiten und Projekte suchen',
'shell.searchPlaceholder': 'Seiten, Projekte suchen',
'shell.searchShortcut': 'Suchen (Strg/⌘ + K)',
'shell.searchEmpty': 'Keine Seite und kein Projekt passt zu „{q}“',
'shell.searchGroupPages': 'Seiten',
'shell.searchGroupProjects': 'Projekte · Werkbank öffnen',
'shell.searchHintMove': 'auswählen',
'shell.searchHintOpen': 'öffnen',
'shell.searchHintClose': 'schließen',
'shell.reduceMotion': 'Weniger Bewegung',
'shell.reduceHint': 'Bewegungs-, Zoom- und Glüheffekte aus; nur kurze Überblendungen',
'shell.reduceSystem': 'Folgt der Systemeinstellung „Bewegung reduzieren“',
'shell.tabAria': 'Hauptnavigation (schmal)',
'shell.tabProjects': 'Projekte',
'shell.tabCreate': 'Erstellen',
'shell.tabGenerate': 'Generieren',
'shell.tabMore': 'Mehr',
'shell.morePages': 'Weitere Seiten',
'shell.moreTools': 'Werkzeuge',
'home.title': 'Projektübersicht',
'home.subtitle': 'Verwalte deine Projekte — starte mit einer Idee oder einem fertigen Drehbuch.',
'home.projectCount': '{n} Projekte · {m} in Arbeit',
@@ -1456,8 +1486,6 @@ export const de: Dict = {
'studio.genParams': 'Parameter',
'studio.modeTextToImage': 'Text zu Bild',
'studio.modeTextToVideo': 'Text zu Video',
'studio.chipIdle': '{name} · Warteschlange frei',
'studio.chipBusy': '{name} · {n} in Warteschlange',
'studio.video.resultsTitle': 'Vorschau',
'studio.image.historyTitle': 'Bildverlauf',
'studio.video.historyTitle': 'Videoverlauf',
@@ -1924,4 +1952,93 @@ export const de: Dict = {
'wft.errListMin': '„{label}“ benötigt mindestens {n} {unit}',
'tasks.testBadge': 'Test',
'tasks.sourceWorkflowTest': 'Workflow-Test',
// --- 生成台 (HEL-653) ---
'cdf.orig': 'Original {v}',
'desk.title': 'Generieren',
'desk.advanced': 'Erweitert · {n} · unverändert gelten die Workflow-Werte',
'desk.chipResolution': 'Auflösung {v}',
'desk.chipSeconds': '{v} s',
'desk.chipSeed': 'Seed {v}',
'desk.closeInspector': 'Inspektor schließen',
'desk.countActive': '{n} laufen',
'desk.countTakes': '{n} Einträge',
'desk.ctxAsset': '{kind} „{name}“',
'desk.ctxShot': 'Einstellung {no}',
'desk.ctxBack': 'Zurück zu {stage}',
'desk.ctxDismiss': 'Herkunftshinweis schließen',
'desk.ctxFrom': 'Aus „{project}“',
'desk.ctxNoBackfill': 'Ergebnisse bleiben im Verlauf und werden nicht in {stage} übernommen',
'desk.dayDate': '{d}.{m}.',
'desk.download': 'Herunterladen',
'desk.dropNoSlot': '{n} Datei(en) ohne passenden Slot: dieser Workflow hat keinen solchen Eingang',
'desk.dryRun': 'Probelauf',
'desk.durMin': '{m} min {s} s',
'desk.durSec': '{s} s',
'desk.elapsed': 'Dauer',
'desk.emptyFailed': 'Keine fehlgeschlagenen Einträge',
'desk.emptyFeed': 'Noch keine Einträge. Prompt unten eingeben und „Generieren“ drücken.',
'desk.filterAria': 'Einträge filtern',
'desk.filterAll': 'Alle',
'desk.filterImage': 'Bilder',
'desk.filterVideo': 'Videos',
'desk.filterFailed': 'Fehlgeschlagen',
'desk.generate': 'Generieren',
'desk.genHint': 'Generieren (Strg/⌘ + Enter)',
'desk.inputs': 'Eingaben',
'desk.inspector': 'Inspektor',
'desk.keyClose': 'Schließen',
'desk.keyInspector': 'Ein- / ausblenden',
'desk.keySelect': 'Auswählen',
'desk.keyUse': 'Verwenden',
'desk.kvCreated': 'Eingereicht',
'desk.kvName': 'Name',
'desk.kvPromptId': 'ComfyUI-Auftrags-ID',
'desk.kvUsage': 'Zweck',
'desk.needWorkflow': 'Fehlt: Workflow',
'desk.noConn': 'Nicht verbunden',
'desk.noPromptInput': 'Dieser Workflow hat keinen Prompt-Eingang und läuft mit seinen eigenen Werten.',
'desk.notGenerated': 'Nicht erzeugt',
'desk.noWorkflows': 'Noch kein Bild- oder Video-Workflow bereit',
'desk.openInDesk': 'Im Generator anpassen',
'desk.openInDeskHint': 'Generator mit diesem Prompt öffnen, Workflow und Parameter selbst wählen; Ergebnisse bleiben im Verlauf',
'desk.openInspector': 'Inspektor',
'desk.origValue': 'Workflow-Wert',
'desk.outputN': 'Ergebnis {n}',
'desk.outputs': 'Alle Ergebnisse',
'desk.paramError': 'Ungültiger Parameter: {msg}',
'desk.paramsAll': 'Alle Parameter',
'desk.paramsChanged': 'Parameter · {n} geändert',
'desk.paramsCommon': 'Häufige Parameter',
'desk.pickTake': 'Eintrag im Verlauf wählen, um das Ergebnis zu sehen',
'desk.pickWorkflow': 'Workflow wählen',
'desk.promptOnly': 'Nur Prompt',
'desk.promptPh': 'Motiv beschreiben… (Strg/⌘ + Enter generiert, / fokussiert)',
'desk.restoreAll': 'Alle Workflow-Werte wiederherstellen',
'desk.restoreOrig': 'Original wiederherstellen',
'desk.reuse': 'Wiederverwenden',
'desk.reuseToComposer': 'In Eingabe übernehmen',
'desk.seedRandom': 'Zufall',
'desk.sendToSlot': 'In Bild-Slot senden',
'desk.sent': 'Eingereicht',
'desk.sentToSlotToast': 'In den Bild-Slot des aktuellen Workflows gesendet',
'desk.slotOne': '{label} ×1',
'desk.slotRange': '{label} {min}–{max}',
'desk.specSize': 'Größe',
'desk.stillNeed': 'Fehlt: {what}',
'desk.tabParams': 'Parameter',
'desk.tabResult': 'Ergebnis',
'desk.today': 'Heute',
'desk.took': 'Dauer {d}',
'desk.wfBroken': 'Nicht nutzbar',
'desk.wfCurrent': 'aktuell',
'desk.wfDefaultConn': 'Standardverbindung',
'desk.wfImport': 'Workflows importieren / verwalten',
'desk.wfLastFailed': 'Letzter Lauf fehlgeschlagen',
'desk.wfNoMatch': 'Kein passender Workflow',
'desk.wfOk': 'Bereit',
'desk.wfSearchPh': 'Workflows oder Zweck suchen: Bild zu Video, Referenz…',
'desk.wfTesting': 'Testlauf läuft',
'desk.wfUntested': 'Nicht getestet',
'desk.workflow': 'Workflow',
'desk.yesterday': 'Gestern',
};
+123 -6
View File
@@ -1262,14 +1262,44 @@ export const en = {
'shell.navConnections': 'ComfyUI Connections',
'shell.navModels': 'Models',
'shell.navSettings': 'Settings',
'shell.wip': 'In development',
'shell.wipHint': 'This page is under construction and will open later',
'shell.comfy_ok': '{name} connected',
'shell.comfy_fail': '{name} unreachable',
'shell.comfy_testing': 'Testing {name}…',
'shell.comfyUnset': 'ComfyUI · not configured',
'shell.comfyManage': 'Configure the ComfyUI address in Settings',
'shell.mobileNotice': 'Please open this workbench in a desktop browser (at least 768px wide). Phones are not supported yet.',
'shell.scopeHint': 'Switch project',
'shell.scopeAll': 'All projects',
'shell.scopeAllList': 'All projects list',
'shell.scopeRecent': 'Recent projects',
'shell.scopeLoadFailed': 'Could not load projects',
'shell.scopeEmpty': 'No projects yet — create one',
'shell.scopeStage': '{n}/5 confirmed',
'shell.queueRunning': '{n} generating',
'shell.queueQueued': '{n} queued',
'shell.queueIdle': 'Queue idle',
'shell.queueActive': '{n} active',
'shell.trayTitle': 'Job tray',
'shell.trayRecent': 'Recent jobs · click to see all jobs',
'shell.trayEmpty': 'No generation jobs yet',
'shell.trayAll': 'View all jobs',
'shell.searchTitle': 'Search pages and projects',
'shell.searchPlaceholder': 'Search pages, projects',
'shell.searchShortcut': 'Search (Ctrl/⌘ + K)',
'shell.searchEmpty': 'No page or project matches “{q}”',
'shell.searchGroupPages': 'Pages',
'shell.searchGroupProjects': 'Projects · open the workbench',
'shell.searchHintMove': 'select',
'shell.searchHintOpen': 'open',
'shell.searchHintClose': 'close',
'shell.reduceMotion': 'Reduce motion',
'shell.reduceHint': 'Turn off movement, scaling and glow animations; keep only short fades',
'shell.reduceSystem': 'Following the system “Reduce motion” setting',
'shell.tabAria': 'Primary (narrow screens)',
'shell.tabProjects': 'Projects',
'shell.tabCreate': 'Create',
'shell.tabGenerate': 'Generate',
'shell.tabMore': 'More',
'shell.morePages': 'More pages',
'shell.moreTools': 'Tools',
'home.title': 'Projects overview',
'home.subtitle': 'Manage your projects — start from an idea or a ready-made script.',
'home.projectCount': '{n} projects · {m} in progress',
@@ -1455,8 +1485,6 @@ export const en = {
'studio.genParams': 'Parameters',
'studio.modeTextToImage': 'Text to image',
'studio.modeTextToVideo': 'Text to video',
'studio.chipIdle': '{name} · queue idle',
'studio.chipBusy': '{name} · {n} in queue',
'studio.video.resultsTitle': 'Preview',
'studio.image.historyTitle': 'Image history',
'studio.video.historyTitle': 'Video history',
@@ -1923,6 +1951,95 @@ export const en = {
'wft.errListMin': '"{label}" needs at least {n} {unit}',
'tasks.testBadge': 'Test',
'tasks.sourceWorkflowTest': 'Workflow test',
// --- 生成台 (HEL-653) ---
'cdf.orig': 'Original {v}',
'desk.title': 'Generate',
'desk.advanced': 'Advanced · {n} · untouched ones keep the workflow values',
'desk.chipResolution': 'Resolution {v}',
'desk.chipSeconds': '{v}s',
'desk.chipSeed': 'Seed {v}',
'desk.closeInspector': 'Close inspector',
'desk.countActive': '{n} running',
'desk.countTakes': '{n} takes',
'desk.ctxAsset': '{kind} “{name}”',
'desk.ctxShot': 'Shot {no}',
'desk.ctxBack': 'Back to {stage}',
'desk.ctxDismiss': 'Dismiss source note',
'desk.ctxFrom': 'From “{project}”',
'desk.ctxNoBackfill': 'Results stay in the desk feed and are not filled back into {stage}',
'desk.dayDate': '{m}/{d}',
'desk.download': 'Download',
'desk.dropNoSlot': '{n} file(s) have no slot: this workflow has no input of that kind',
'desk.dryRun': 'Dry run',
'desk.durMin': '{m}m {s}s',
'desk.durSec': '{s}s',
'desk.elapsed': 'Time',
'desk.emptyFailed': 'No failed takes',
'desk.emptyFeed': 'No takes yet. Write a prompt below and press Generate.',
'desk.filterAria': 'Filter takes',
'desk.filterAll': 'All',
'desk.filterImage': 'Images',
'desk.filterVideo': 'Videos',
'desk.filterFailed': 'Failed',
'desk.generate': 'Generate',
'desk.genHint': 'Generate (Ctrl/⌘ + Enter)',
'desk.inputs': 'Inputs',
'desk.inspector': 'Inspector',
'desk.keyClose': 'Close',
'desk.keyInspector': 'Show / hide',
'desk.keySelect': 'Select',
'desk.keyUse': 'Use',
'desk.kvCreated': 'Submitted',
'desk.kvName': 'Name',
'desk.kvPromptId': 'ComfyUI prompt id',
'desk.kvUsage': 'Usage',
'desk.needWorkflow': 'Missing: workflow',
'desk.noConn': 'Not connected',
'desk.noPromptInput': 'This workflow takes no prompt; it runs with its own values.',
'desk.notGenerated': 'Not generated',
'desk.noWorkflows': 'No image or video workflow is ready yet',
'desk.openInDesk': 'Tune in desk',
'desk.openInDeskHint': 'Open the desk with this prompt and pick the workflow and params yourself; results stay in the desk feed',
'desk.openInspector': 'Inspector',
'desk.origValue': 'Workflow value',
'desk.outputN': 'Result {n}',
'desk.outputs': 'All results',
'desk.paramError': 'Invalid parameter: {msg}',
'desk.paramsAll': 'All params',
'desk.paramsChanged': 'Params · {n} changed',
'desk.paramsCommon': 'Common params',
'desk.pickTake': 'Pick a take in the feed to see its result',
'desk.pickWorkflow': 'Choose workflow',
'desk.promptOnly': 'Prompt only',
'desk.promptPh': 'Describe the shot… (Ctrl/⌘ + Enter to generate, / to focus)',
'desk.restoreAll': 'Restore all workflow values',
'desk.restoreOrig': 'Restore original',
'desk.reuse': 'Reuse',
'desk.reuseToComposer': 'Reuse in composer',
'desk.seedRandom': 'Random',
'desk.sendToSlot': 'Send to image slot',
'desk.sent': 'Submitted',
'desk.sentToSlotToast': 'Sent to the current workflow’s image slot',
'desk.slotOne': '{label} ×1',
'desk.slotRange': '{label} {min}–{max}',
'desk.specSize': 'Size',
'desk.stillNeed': 'Missing: {what}',
'desk.tabParams': 'Params',
'desk.tabResult': 'Result',
'desk.today': 'Today',
'desk.took': 'Took {d}',
'desk.wfBroken': 'Unavailable',
'desk.wfCurrent': 'current',
'desk.wfDefaultConn': 'Default connection',
'desk.wfImport': 'Import / manage workflows',
'desk.wfLastFailed': 'Last run failed',
'desk.wfNoMatch': 'No matching workflow',
'desk.wfOk': 'Ready',
'desk.wfSearchPh': 'Search workflows or a usage: image to video, reference…',
'desk.wfTesting': 'Test running',
'desk.wfUntested': 'Not tested',
'desk.workflow': 'Workflow',
'desk.yesterday': 'Yesterday',
};
export type Dict = typeof en;
+123 -6
View File
@@ -1260,14 +1260,44 @@ export const es: Dict = {
'shell.navConnections': 'Conexiones ComfyUI',
'shell.navModels': 'Modelos',
'shell.navSettings': 'Ajustes',
'shell.wip': 'En desarrollo',
'shell.wipHint': 'Esta página está en construcción y llegará más adelante',
'shell.comfy_ok': '{name} conectado',
'shell.comfy_fail': '{name} sin conexión',
'shell.comfy_testing': 'Probando {name}…',
'shell.comfyUnset': 'ComfyUI · sin configurar',
'shell.comfyManage': 'Configura la dirección de ComfyUI en Ajustes',
'shell.mobileNotice': 'Abre este banco de trabajo en un navegador de escritorio (mínimo 768 px); los móviles aún no se admiten.',
'shell.scopeHint': 'Cambiar de proyecto',
'shell.scopeAll': 'Todos los proyectos',
'shell.scopeAllList': 'Lista de proyectos',
'shell.scopeRecent': 'Proyectos recientes',
'shell.scopeLoadFailed': 'No se pudieron cargar los proyectos',
'shell.scopeEmpty': 'Aún no hay proyectos: crea uno',
'shell.scopeStage': '{n}/5 confirmadas',
'shell.queueRunning': '{n} generando',
'shell.queueQueued': '{n} en cola',
'shell.queueIdle': 'Cola libre',
'shell.queueActive': '{n} activos',
'shell.trayTitle': 'Bandeja de tareas',
'shell.trayRecent': 'Tareas recientes · clic para ver todas',
'shell.trayEmpty': 'Aún no hay tareas de generación',
'shell.trayAll': 'Ver todas las tareas',
'shell.searchTitle': 'Buscar páginas y proyectos',
'shell.searchPlaceholder': 'Buscar páginas, proyectos',
'shell.searchShortcut': 'Buscar (Ctrl/⌘ + K)',
'shell.searchEmpty': 'Ninguna página ni proyecto coincide con «{q}»',
'shell.searchGroupPages': 'Páginas',
'shell.searchGroupProjects': 'Proyectos · abrir el banco de trabajo',
'shell.searchHintMove': 'elegir',
'shell.searchHintOpen': 'abrir',
'shell.searchHintClose': 'cerrar',
'shell.reduceMotion': 'Reducir movimiento',
'shell.reduceHint': 'Desactiva desplazamientos, escalados y brillos; solo quedan fundidos breves',
'shell.reduceSystem': 'Se sigue el ajuste del sistema «Reducir movimiento»',
'shell.tabAria': 'Navegación principal (estrecha)',
'shell.tabProjects': 'Proyectos',
'shell.tabCreate': 'Crear',
'shell.tabGenerate': 'Generar',
'shell.tabMore': 'Más',
'shell.morePages': 'Más páginas',
'shell.moreTools': 'Herramientas',
'home.title': 'Resumen de proyectos',
'home.subtitle': 'Gestiona tus proyectos: empieza con una idea o un guion terminado.',
'home.projectCount': '{n} proyectos · {m} en curso',
@@ -1456,8 +1486,6 @@ export const es: Dict = {
'studio.genParams': 'Parámetros',
'studio.modeTextToImage': 'Texto a imagen',
'studio.modeTextToVideo': 'Texto a vídeo',
'studio.chipIdle': '{name} · cola libre',
'studio.chipBusy': '{name} · {n} en cola',
'studio.video.resultsTitle': 'Vista previa',
'studio.image.historyTitle': 'Historial de imágenes',
'studio.video.historyTitle': 'Historial de vídeos',
@@ -1924,4 +1952,93 @@ export const es: Dict = {
'wft.errListMin': '«{label}» necesita al menos {n} {unit}',
'tasks.testBadge': 'Prueba',
'tasks.sourceWorkflowTest': 'Prueba de flujo',
// --- 生成台 (HEL-653) ---
'cdf.orig': 'Original {v}',
'desk.title': 'Generar',
'desk.advanced': 'Avanzado · {n} · sin cambios se usan los valores del flujo',
'desk.chipResolution': 'Resolución {v}',
'desk.chipSeconds': '{v} s',
'desk.chipSeed': 'Semilla {v}',
'desk.closeInspector': 'Cerrar inspector',
'desk.countActive': '{n} en curso',
'desk.countTakes': '{n} registros',
'desk.ctxAsset': '{kind} «{name}»',
'desk.ctxShot': 'Plano {no}',
'desk.ctxBack': 'Volver a {stage}',
'desk.ctxDismiss': 'Cerrar aviso de origen',
'desk.ctxFrom': 'De «{project}»',
'desk.ctxNoBackfill': 'Los resultados quedan en el registro y no se devuelven a {stage}',
'desk.dayDate': '{d}/{m}',
'desk.download': 'Descargar',
'desk.dropNoSlot': '{n} archivo(s) sin ranura: este flujo no tiene esa entrada',
'desk.dryRun': 'Simulación',
'desk.durMin': '{m} min {s} s',
'desk.durSec': '{s} s',
'desk.elapsed': 'Tiempo',
'desk.emptyFailed': 'No hay registros fallidos',
'desk.emptyFeed': 'Aún no hay registros. Escribe un prompt abajo y pulsa Generar.',
'desk.filterAria': 'Filtrar registros',
'desk.filterAll': 'Todo',
'desk.filterImage': 'Imágenes',
'desk.filterVideo': 'Vídeos',
'desk.filterFailed': 'Fallidos',
'desk.generate': 'Generar',
'desk.genHint': 'Generar (Ctrl/⌘ + Enter)',
'desk.inputs': 'Entradas',
'desk.inspector': 'Inspector',
'desk.keyClose': 'Cerrar',
'desk.keyInspector': 'Mostrar / ocultar',
'desk.keySelect': 'Elegir',
'desk.keyUse': 'Usar',
'desk.kvCreated': 'Enviado',
'desk.kvName': 'Nombre',
'desk.kvPromptId': 'ID de ComfyUI',
'desk.kvUsage': 'Uso',
'desk.needWorkflow': 'Falta: flujo',
'desk.noConn': 'Sin conexión',
'desk.noPromptInput': 'Este flujo no tiene prompt; se ejecuta con sus propios valores.',
'desk.notGenerated': 'No generado',
'desk.noWorkflows': 'Aún no hay flujos de imagen o vídeo listos',
'desk.openInDesk': 'Ajustar en el generador',
'desk.openInDeskHint': 'Abre el generador con este prompt y elige flujo y parámetros; los resultados quedan en el registro',
'desk.openInspector': 'Inspector',
'desk.origValue': 'Valor del flujo',
'desk.outputN': 'Resultado {n}',
'desk.outputs': 'Todos los resultados',
'desk.paramError': 'Parámetro no válido: {msg}',
'desk.paramsAll': 'Todos los parámetros',
'desk.paramsChanged': 'Parámetros · {n} cambiados',
'desk.paramsCommon': 'Parámetros comunes',
'desk.pickTake': 'Elige un registro para ver su resultado',
'desk.pickWorkflow': 'Elegir flujo',
'desk.promptOnly': 'Solo prompt',
'desk.promptPh': 'Describe la escena… (Ctrl/⌘ + Enter genera, / enfoca)',
'desk.restoreAll': 'Restaurar todos los valores del flujo',
'desk.restoreOrig': 'Restaurar original',
'desk.reuse': 'Reutilizar',
'desk.reuseToComposer': 'Reutilizar en el editor',
'desk.seedRandom': 'Aleatoria',
'desk.sendToSlot': 'Enviar a la ranura de imagen',
'desk.sent': 'Enviado',
'desk.sentToSlotToast': 'Enviado a la ranura de imagen del flujo actual',
'desk.slotOne': '{label} ×1',
'desk.slotRange': '{label} {min}–{max}',
'desk.specSize': 'Tamaño',
'desk.stillNeed': 'Falta: {what}',
'desk.tabParams': 'Parámetros',
'desk.tabResult': 'Resultado',
'desk.today': 'Hoy',
'desk.took': 'Tardó {d}',
'desk.wfBroken': 'No disponible',
'desk.wfCurrent': 'actual',
'desk.wfDefaultConn': 'Conexión predeterminada',
'desk.wfImport': 'Importar / gestionar flujos',
'desk.wfLastFailed': 'Falló la última vez',
'desk.wfNoMatch': 'Ningún flujo coincide',
'desk.wfOk': 'Listo',
'desk.wfSearchPh': 'Busca flujos o un uso: imagen a vídeo, referencia…',
'desk.wfTesting': 'Prueba en curso',
'desk.wfUntested': 'Sin probar',
'desk.workflow': 'Flujo',
'desk.yesterday': 'Ayer',
};
+123 -6
View File
@@ -1260,14 +1260,44 @@ export const fr: Dict = {
'shell.navConnections': 'Connexions ComfyUI',
'shell.navModels': 'Modèles',
'shell.navSettings': 'Réglages',
'shell.wip': 'En développement',
'shell.wipHint': 'Cette page est en construction et arrivera plus tard',
'shell.comfy_ok': '{name} connecté',
'shell.comfy_fail': '{name} injoignable',
'shell.comfy_testing': 'Test de {name}…',
'shell.comfyUnset': 'ComfyUI · non configuré',
'shell.comfyManage': 'Configurez l\'adresse ComfyUI dans les Réglages',
'shell.mobileNotice': 'Ouvrez cet atelier dans un navigateur de bureau (largeur minimum 768 px) ; les téléphones ne sont pas encore pris en charge.',
'shell.scopeHint': 'Changer de projet',
'shell.scopeAll': 'Tous les projets',
'shell.scopeAllList': 'Liste des projets',
'shell.scopeRecent': 'Projets récents',
'shell.scopeLoadFailed': 'Impossible de charger les projets',
'shell.scopeEmpty': 'Aucun projet — en créer un',
'shell.scopeStage': '{n}/5 validées',
'shell.queueRunning': '{n} en cours',
'shell.queueQueued': '{n} en file',
'shell.queueIdle': 'File libre',
'shell.queueActive': '{n} actifs',
'shell.trayTitle': 'Plateau des tâches',
'shell.trayRecent': 'Tâches récentes · cliquer pour tout voir',
'shell.trayEmpty': 'Aucune tâche de génération',
'shell.trayAll': 'Voir toutes les tâches',
'shell.searchTitle': 'Rechercher pages et projets',
'shell.searchPlaceholder': 'Rechercher pages, projets',
'shell.searchShortcut': 'Rechercher (Ctrl/⌘ + K)',
'shell.searchEmpty': 'Aucune page ni projet ne correspond à « {q} »',
'shell.searchGroupPages': 'Pages',
'shell.searchGroupProjects': 'Projets · ouvrir l’atelier',
'shell.searchHintMove': 'choisir',
'shell.searchHintOpen': 'ouvrir',
'shell.searchHintClose': 'fermer',
'shell.reduceMotion': 'Réduire les animations',
'shell.reduceHint': 'Coupe déplacements, zooms et halos ; garde seulement de courts fondus',
'shell.reduceSystem': 'Réglage système « Réduire les animations » appliqué',
'shell.tabAria': 'Navigation principale (étroite)',
'shell.tabProjects': 'Projets',
'shell.tabCreate': 'Créer',
'shell.tabGenerate': 'Générer',
'shell.tabMore': 'Plus',
'shell.morePages': 'Autres pages',
'shell.moreTools': 'Outils',
'home.title': 'Aperçu des projets',
'home.subtitle': 'Gérez vos projets — commencez avec une idée ou un scénario prêt.',
'home.projectCount': '{n} projets · {m} en cours',
@@ -1456,8 +1486,6 @@ export const fr: Dict = {
'studio.genParams': 'Paramètres',
'studio.modeTextToImage': 'Texte vers image',
'studio.modeTextToVideo': 'Texte vers vidéo',
'studio.chipIdle': '{name} · file libre',
'studio.chipBusy': '{name} · {n} en file',
'studio.video.resultsTitle': 'Aperçu',
'studio.image.historyTitle': 'Historique images',
'studio.video.historyTitle': 'Historique vidéos',
@@ -1924,4 +1952,93 @@ export const fr: Dict = {
'wft.errListMin': '« {label} » exige au moins {n} {unit}',
'tasks.testBadge': 'Test',
'tasks.sourceWorkflowTest': 'Test de flux',
// --- 生成台 (HEL-653) ---
'cdf.orig': 'Original {v}',
'desk.title': 'Générer',
'desk.advanced': 'Avancé · {n} · inchangés, les valeurs du workflow s’appliquent',
'desk.chipResolution': 'Résolution {v}',
'desk.chipSeconds': '{v} s',
'desk.chipSeed': 'Graine {v}',
'desk.closeInspector': 'Fermer l’inspecteur',
'desk.countActive': '{n} en cours',
'desk.countTakes': '{n} prises',
'desk.ctxAsset': '{kind} « {name} »',
'desk.ctxShot': 'Plan {no}',
'desk.ctxBack': 'Retour à {stage}',
'desk.ctxDismiss': 'Fermer la note d’origine',
'desk.ctxFrom': 'Depuis « {project} »',
'desk.ctxNoBackfill': 'Les résultats restent dans le fil et ne sont pas renvoyés vers {stage}',
'desk.dayDate': '{d}/{m}',
'desk.download': 'Télécharger',
'desk.dropNoSlot': '{n} fichier(s) sans emplacement : ce workflow n’a pas cette entrée',
'desk.dryRun': 'Simulation',
'desk.durMin': '{m} min {s} s',
'desk.durSec': '{s} s',
'desk.elapsed': 'Durée',
'desk.emptyFailed': 'Aucune prise échouée',
'desk.emptyFeed': 'Aucune prise. Écrivez un prompt ci-dessous et cliquez sur Générer.',
'desk.filterAria': 'Filtrer les prises',
'desk.filterAll': 'Tout',
'desk.filterImage': 'Images',
'desk.filterVideo': 'Vidéos',
'desk.filterFailed': 'Échecs',
'desk.generate': 'Générer',
'desk.genHint': 'Générer (Ctrl/⌘ + Entrée)',
'desk.inputs': 'Entrées',
'desk.inspector': 'Inspecteur',
'desk.keyClose': 'Fermer',
'desk.keyInspector': 'Afficher / masquer',
'desk.keySelect': 'Choisir',
'desk.keyUse': 'Utiliser',
'desk.kvCreated': 'Soumis',
'desk.kvName': 'Nom',
'desk.kvPromptId': 'ID ComfyUI',
'desk.kvUsage': 'Usage',
'desk.needWorkflow': 'Manque : workflow',
'desk.noConn': 'Non connecté',
'desk.noPromptInput': 'Ce workflow n’a pas de prompt ; il s’exécute avec ses propres valeurs.',
'desk.notGenerated': 'Non généré',
'desk.noWorkflows': 'Aucun workflow image ou vidéo prêt',
'desk.openInDesk': 'Régler dans le générateur',
'desk.openInDeskHint': 'Ouvre le générateur avec ce prompt ; choisissez workflow et paramètres, les résultats restent dans le fil',
'desk.openInspector': 'Inspecteur',
'desk.origValue': 'Valeur du workflow',
'desk.outputN': 'Résultat {n}',
'desk.outputs': 'Tous les résultats',
'desk.paramError': 'Paramètre invalide : {msg}',
'desk.paramsAll': 'Tous les paramètres',
'desk.paramsChanged': 'Paramètres · {n} modifiés',
'desk.paramsCommon': 'Paramètres courants',
'desk.pickTake': 'Choisissez une prise pour voir son résultat',
'desk.pickWorkflow': 'Choisir un workflow',
'desk.promptOnly': 'Prompt seul',
'desk.promptPh': 'Décrivez l’image… (Ctrl/⌘ + Entrée pour générer, / pour cibler)',
'desk.restoreAll': 'Rétablir toutes les valeurs du workflow',
'desk.restoreOrig': 'Rétablir l’original',
'desk.reuse': 'Réutiliser',
'desk.reuseToComposer': 'Réutiliser dans l’éditeur',
'desk.seedRandom': 'Aléatoire',
'desk.sendToSlot': 'Envoyer vers l’emplacement image',
'desk.sent': 'Soumis',
'desk.sentToSlotToast': 'Envoyé vers l’emplacement image du workflow actuel',
'desk.slotOne': '{label} ×1',
'desk.slotRange': '{label} {min}–{max}',
'desk.specSize': 'Taille',
'desk.stillNeed': 'Manque : {what}',
'desk.tabParams': 'Paramètres',
'desk.tabResult': 'Résultat',
'desk.today': 'Aujourd’hui',
'desk.took': 'Durée {d}',
'desk.wfBroken': 'Indisponible',
'desk.wfCurrent': 'actuel',
'desk.wfDefaultConn': 'Connexion par défaut',
'desk.wfImport': 'Importer / gérer les workflows',
'desk.wfLastFailed': 'Dernier essai échoué',
'desk.wfNoMatch': 'Aucun workflow correspondant',
'desk.wfOk': 'Prêt',
'desk.wfSearchPh': 'Chercher un workflow ou un usage : image vers vidéo, référence…',
'desk.wfTesting': 'Test en cours',
'desk.wfUntested': 'Non testé',
'desk.workflow': 'Workflow',
'desk.yesterday': 'Hier',
};
+123 -6
View File
@@ -1260,14 +1260,44 @@ export const ja: Dict = {
'shell.navConnections': 'ComfyUI 接続',
'shell.navModels': 'モデル設定',
'shell.navSettings': '設定',
'shell.wip': '開発中',
'shell.wipHint': 'このページは準備中です。後ほど公開されます',
'shell.comfy_ok': '{name} 接続済み',
'shell.comfy_fail': '{name} 接続失敗',
'shell.comfy_testing': '{name} テスト中…',
'shell.comfyUnset': 'ComfyUI · 未設定',
'shell.comfyManage': '「設定」で ComfyUI のアドレスを設定できます',
'shell.mobileNotice': 'このワークベンチはデスクトップブラウザ(幅 768px 以上)で開いてください。スマートフォンには未対応です。',
'shell.scopeHint': 'プロジェクト切替',
'shell.scopeAll': 'すべてのプロジェクト',
'shell.scopeAllList': 'プロジェクト一覧',
'shell.scopeRecent': '最近のプロジェクト',
'shell.scopeLoadFailed': 'プロジェクトを読み込めませんでした',
'shell.scopeEmpty': 'プロジェクトはまだありません — 作成する',
'shell.scopeStage': '{n}/5 確定',
'shell.queueRunning': '{n} 件生成中',
'shell.queueQueued': '{n} 件待機',
'shell.queueIdle': 'キューは空です',
'shell.queueActive': '{n} 件進行中',
'shell.trayTitle': 'タスクトレイ',
'shell.trayRecent': '最近のタスク · クリックで全タスク',
'shell.trayEmpty': '生成タスクはまだありません',
'shell.trayAll': 'すべてのタスクを見る',
'shell.searchTitle': 'ページとプロジェクトを検索',
'shell.searchPlaceholder': 'ページ・プロジェクトを検索',
'shell.searchShortcut': '検索(Ctrl/⌘ + K)',
'shell.searchEmpty': '「{q}」に一致するページやプロジェクトはありません',
'shell.searchGroupPages': 'ページ',
'shell.searchGroupProjects': 'プロジェクト · ワークベンチを開く',
'shell.searchHintMove': '選択',
'shell.searchHintOpen': '開く',
'shell.searchHintClose': '閉じる',
'shell.reduceMotion': '動きを減らす',
'shell.reduceHint': '移動・拡大・発光アニメーションを止め、短いフェードのみ残します',
'shell.reduceSystem': 'システムの「視差効果を減らす」設定に従っています',
'shell.tabAria': 'メインナビ(狭い画面)',
'shell.tabProjects': 'プロジェクト',
'shell.tabCreate': '創作',
'shell.tabGenerate': '生成',
'shell.tabMore': 'その他',
'shell.morePages': 'その他のページ',
'shell.moreTools': 'ツール',
'home.title': 'プロジェクト概要',
'home.subtitle': '創作プロジェクトを管理 — アイデアでも完成台本でも始められます。',
'home.projectCount': 'プロジェクト {n} 件 · 進行中 {m} 件',
@@ -1456,8 +1486,6 @@ export const ja: Dict = {
'studio.genParams': '生成パラメータ',
'studio.modeTextToImage': 'テキストから画像',
'studio.modeTextToVideo': 'テキストから動画',
'studio.chipIdle': '{name} · キュー空き',
'studio.chipBusy': '{name} · キュー {n} 件',
'studio.video.resultsTitle': 'プレビュー',
'studio.image.historyTitle': '画像生成履歴',
'studio.video.historyTitle': '動画生成履歴',
@@ -1924,4 +1952,93 @@ export const ja: Dict = {
'wft.errListMin': '「{label}」は最低 {n} {unit} 必要です',
'tasks.testBadge': 'テスト',
'tasks.sourceWorkflowTest': 'ワークフローテスト',
// --- 生成台 (HEL-653) ---
'cdf.orig': '元の値 {v}',
'desk.title': '生成台',
'desk.advanced': '詳細パラメータ {n} 項目 · 変更しなければワークフローの値を使用',
'desk.chipResolution': '解像度 {v}',
'desk.chipSeconds': '{v} 秒',
'desk.chipSeed': 'シード {v}',
'desk.closeInspector': 'インスペクターを閉じる',
'desk.countActive': '{n} 件実行中',
'desk.countTakes': '{n} 件の記録',
'desk.ctxAsset': '{kind}「{name}」',
'desk.ctxShot': 'ショット {no}',
'desk.ctxBack': '{stage}に戻る',
'desk.ctxDismiss': '出典の表示を閉じる',
'desk.ctxFrom': '『{project}』から',
'desk.ctxNoBackfill': '結果は生成台の記録にのみ残り、{stage}へ自動で戻りません',
'desk.dayDate': '{m}月{d}日',
'desk.download': 'ダウンロード',
'desk.dropNoSlot': '{n} 件のファイルを入れる枠がありません:このワークフローにはその入力がありません',
'desk.dryRun': 'ドライラン',
'desk.durMin': '{m} 分 {s} 秒',
'desk.durSec': '{s} 秒',
'desk.elapsed': '所要時間',
'desk.emptyFailed': '失敗した記録はありません',
'desk.emptyFeed': 'まだ記録がありません。下にプロンプトを書いて「生成」を押してください。',
'desk.filterAria': '記録を絞り込む',
'desk.filterAll': 'すべて',
'desk.filterImage': '画像',
'desk.filterVideo': '動画',
'desk.filterFailed': '失敗',
'desk.generate': '生成',
'desk.genHint': '生成(Ctrl/⌘ + Enter)',
'desk.inputs': '入力',
'desk.inspector': 'インスペクター',
'desk.keyClose': '閉じる',
'desk.keyInspector': '表示 / 非表示',
'desk.keySelect': '選択',
'desk.keyUse': '使用',
'desk.kvCreated': '送信時刻',
'desk.kvName': '名前',
'desk.kvPromptId': 'ComfyUI タスク ID',
'desk.kvUsage': '用途',
'desk.needWorkflow': '不足:ワークフロー',
'desk.noConn': '未接続',
'desk.noPromptInput': 'このワークフローにはプロンプト入力がなく、元の値で生成します。',
'desk.notGenerated': '未生成',
'desk.noWorkflows': '使える画像 / 動画ワークフローがまだありません',
'desk.openInDesk': '生成台で調整',
'desk.openInDeskHint': 'このプロンプトで生成台を開き、ワークフローとパラメータを選びます。結果は生成台の記録に残ります',
'desk.openInspector': 'インスペクター',
'desk.origValue': 'ワークフローの値',
'desk.outputN': '結果 {n}',
'desk.outputs': 'すべての結果',
'desk.paramError': 'パラメータが不正です:{msg}',
'desk.paramsAll': 'すべてのパラメータ',
'desk.paramsChanged': 'パラメータ · {n} 項目変更',
'desk.paramsCommon': 'よく使うパラメータ',
'desk.pickTake': '記録を選ぶと結果を表示します',
'desk.pickWorkflow': 'ワークフローを選択',
'desk.promptOnly': 'プロンプトのみ',
'desk.promptPh': '欲しい画面を説明…(Ctrl/⌘ + Enter で生成、/ でフォーカス)',
'desk.restoreAll': 'すべてワークフローの値に戻す',
'desk.restoreOrig': '元に戻す',
'desk.reuse': '再利用',
'desk.reuseToComposer': '入力台で再利用',
'desk.seedRandom': 'ランダム',
'desk.sendToSlot': '現在の画像枠へ送る',
'desk.sent': '送信済み',
'desk.sentToSlotToast': '現在のワークフローの画像枠へ送りました',
'desk.slotOne': '{label} ×1',
'desk.slotRange': '{label} {min}–{max}',
'desk.specSize': 'サイズ',
'desk.stillNeed': '不足:{what}',
'desk.tabParams': 'パラメータ',
'desk.tabResult': '結果',
'desk.today': '今日',
'desk.took': '所要 {d}',
'desk.wfBroken': '使用不可',
'desk.wfCurrent': '現在',
'desk.wfDefaultConn': '既定の接続に従う',
'desk.wfImport': 'ワークフローを取り込む / 管理',
'desk.wfLastFailed': '前回失敗',
'desk.wfNoMatch': '一致するワークフローがありません',
'desk.wfOk': '使用可',
'desk.wfSearchPh': 'ワークフローや用途を検索:画像から動画、参照画像…',
'desk.wfTesting': 'テスト実行中',
'desk.wfUntested': '未テスト',
'desk.workflow': 'ワークフロー',
'desk.yesterday': '昨日',
};
+123 -6
View File
@@ -1260,14 +1260,44 @@ export const ko: Dict = {
'shell.navConnections': 'ComfyUI 연결',
'shell.navModels': '모델 설정',
'shell.navSettings': '설정',
'shell.wip': '개발 중',
'shell.wipHint': '이 페이지는 준비 중이며 곧 열립니다',
'shell.comfy_ok': '{name} 연결됨',
'shell.comfy_fail': '{name} 연결 실패',
'shell.comfy_testing': '{name} 테스트 중…',
'shell.comfyUnset': 'ComfyUI · 미설정',
'shell.comfyManage': '「설정」에서 ComfyUI 주소를 설정하세요',
'shell.mobileNotice': '이 워크벤치는 데스크톱 브라우저(폭 768px 이상)에서 열어 주세요. 휴대폰은 아직 지원되지 않습니다.',
'shell.scopeHint': '프로젝트 전환',
'shell.scopeAll': '전체 프로젝트',
'shell.scopeAllList': '프로젝트 목록',
'shell.scopeRecent': '최근 프로젝트',
'shell.scopeLoadFailed': '프로젝트를 불러오지 못했습니다',
'shell.scopeEmpty': '아직 프로젝트가 없습니다 — 만들기',
'shell.scopeStage': '{n}/5 확정',
'shell.queueRunning': '{n}개 생성 중',
'shell.queueQueued': '{n}개 대기',
'shell.queueIdle': '대기열 비어 있음',
'shell.queueActive': '{n}개 진행 중',
'shell.trayTitle': '작업 트레이',
'shell.trayRecent': '최근 작업 · 클릭하면 전체 작업',
'shell.trayEmpty': '아직 생성 작업이 없습니다',
'shell.trayAll': '전체 작업 보기',
'shell.searchTitle': '페이지와 프로젝트 검색',
'shell.searchPlaceholder': '페이지, 프로젝트 검색',
'shell.searchShortcut': '검색 (Ctrl/⌘ + K)',
'shell.searchEmpty': '‘{q}’와 일치하는 페이지나 프로젝트가 없습니다',
'shell.searchGroupPages': '페이지',
'shell.searchGroupProjects': '프로젝트 · 워크벤치 열기',
'shell.searchHintMove': '선택',
'shell.searchHintOpen': '열기',
'shell.searchHintClose': '닫기',
'shell.reduceMotion': '동작 줄이기',
'shell.reduceHint': '이동·확대·발광 애니메이션을 끄고 짧은 페이드만 남깁니다',
'shell.reduceSystem': '시스템의 ‘동작 줄이기’ 설정을 따르는 중',
'shell.tabAria': '주 탐색 (좁은 화면)',
'shell.tabProjects': '프로젝트',
'shell.tabCreate': '창작',
'shell.tabGenerate': '생성',
'shell.tabMore': '더보기',
'shell.morePages': '더 많은 페이지',
'shell.moreTools': '도구',
'home.title': '프로젝트 개요',
'home.subtitle': '창작 프로젝트를 관리하세요 — 아이디어나 완성된 대본으로 시작할 수 있습니다.',
'home.projectCount': '프로젝트 {n}개 · 진행 중 {m}개',
@@ -1456,8 +1486,6 @@ export const ko: Dict = {
'studio.genParams': '생성 매개변수',
'studio.modeTextToImage': '텍스트→이미지',
'studio.modeTextToVideo': '텍스트→영상',
'studio.chipIdle': '{name} · 대기열 비어 있음',
'studio.chipBusy': '{name} · 대기열 {n}건',
'studio.video.resultsTitle': '미리보기',
'studio.image.historyTitle': '이미지 생성 기록',
'studio.video.historyTitle': '영상 생성 기록',
@@ -1924,4 +1952,93 @@ export const ko: Dict = {
'wft.errListMin': '«{label}»에 최소 {n}{unit}이(가) 필요합니다',
'tasks.testBadge': '테스트',
'tasks.sourceWorkflowTest': '워크플로 테스트',
// --- 生成台 (HEL-653) ---
'cdf.orig': '원래 값 {v}',
'desk.title': '생성대',
'desk.advanced': '고급 매개변수 {n}개 · 바꾸지 않으면 워크플로 값을 사용',
'desk.chipResolution': '해상도 {v}',
'desk.chipSeconds': '{v}초',
'desk.chipSeed': '시드 {v}',
'desk.closeInspector': '인스펙터 닫기',
'desk.countActive': '{n}개 진행 중',
'desk.countTakes': '기록 {n}개',
'desk.ctxAsset': '{kind} 「{name}」',
'desk.ctxShot': '샷 {no}',
'desk.ctxBack': '{stage}(으)로 돌아가기',
'desk.ctxDismiss': '출처 안내 닫기',
'desk.ctxFrom': '《{project}》에서',
'desk.ctxNoBackfill': '결과는 생성대 기록에만 남고 {stage}에 자동으로 반영되지 않습니다',
'desk.dayDate': '{m}월 {d}일',
'desk.download': '다운로드',
'desk.dropNoSlot': '파일 {n}개를 넣을 슬롯이 없습니다: 이 워크플로에는 해당 입력이 없습니다',
'desk.dryRun': '드라이런',
'desk.durMin': '{m}분 {s}초',
'desk.durSec': '{s}초',
'desk.elapsed': '소요 시간',
'desk.emptyFailed': '실패한 기록이 없습니다',
'desk.emptyFeed': '아직 기록이 없습니다. 아래에 프롬프트를 쓰고 「생성」을 누르세요.',
'desk.filterAria': '기록 필터',
'desk.filterAll': '전체',
'desk.filterImage': '이미지',
'desk.filterVideo': '동영상',
'desk.filterFailed': '실패',
'desk.generate': '생성',
'desk.genHint': '생성 (Ctrl/⌘ + Enter)',
'desk.inputs': '입력',
'desk.inspector': '인스펙터',
'desk.keyClose': '닫기',
'desk.keyInspector': '표시 / 숨기기',
'desk.keySelect': '선택',
'desk.keyUse': '사용',
'desk.kvCreated': '제출 시각',
'desk.kvName': '이름',
'desk.kvPromptId': 'ComfyUI 작업 ID',
'desk.kvUsage': '용도',
'desk.needWorkflow': '부족: 워크플로',
'desk.noConn': '연결 안 됨',
'desk.noPromptInput': '이 워크플로에는 프롬프트 입력이 없어 원래 값으로 생성합니다.',
'desk.notGenerated': '생성 안 됨',
'desk.noWorkflows': '사용할 수 있는 이미지 / 동영상 워크플로가 아직 없습니다',
'desk.openInDesk': '생성대에서 조정',
'desk.openInDeskHint': '이 프롬프트로 생성대를 열고 워크플로와 매개변수를 직접 고릅니다. 결과는 생성대 기록에 남습니다',
'desk.openInspector': '인스펙터',
'desk.origValue': '워크플로 값',
'desk.outputN': '결과 {n}',
'desk.outputs': '모든 결과',
'desk.paramError': '잘못된 매개변수: {msg}',
'desk.paramsAll': '모든 매개변수',
'desk.paramsChanged': '매개변수 · {n}개 변경',
'desk.paramsCommon': '자주 쓰는 매개변수',
'desk.pickTake': '기록을 선택하면 결과가 표시됩니다',
'desk.pickWorkflow': '워크플로 선택',
'desk.promptOnly': '프롬프트만',
'desk.promptPh': '원하는 장면을 설명… (Ctrl/⌘ + Enter 생성, / 포커스)',
'desk.restoreAll': '모두 워크플로 값으로 되돌리기',
'desk.restoreOrig': '원래 값으로',
'desk.reuse': '재사용',
'desk.reuseToComposer': '입력대에서 재사용',
'desk.seedRandom': '무작위',
'desk.sendToSlot': '현재 이미지 슬롯으로 보내기',
'desk.sent': '제출됨',
'desk.sentToSlotToast': '현재 워크플로의 이미지 슬롯으로 보냈습니다',
'desk.slotOne': '{label} ×1',
'desk.slotRange': '{label} {min}–{max}',
'desk.specSize': '크기',
'desk.stillNeed': '부족: {what}',
'desk.tabParams': '매개변수',
'desk.tabResult': '결과',
'desk.today': '오늘',
'desk.took': '소요 {d}',
'desk.wfBroken': '사용 불가',
'desk.wfCurrent': '현재',
'desk.wfDefaultConn': '기본 연결 따름',
'desk.wfImport': '워크플로 가져오기 / 관리',
'desk.wfLastFailed': '지난번 실패',
'desk.wfNoMatch': '일치하는 워크플로가 없습니다',
'desk.wfOk': '사용 가능',
'desk.wfSearchPh': '워크플로나 용도 검색: 이미지→동영상, 참조 이미지…',
'desk.wfTesting': '테스트 실행 중',
'desk.wfUntested': '테스트 안 함',
'desk.workflow': '워크플로',
'desk.yesterday': '어제',
};
+123 -6
View File
@@ -1262,14 +1262,44 @@ export const zh: Dict = {
'shell.navConnections': 'ComfyUI 连接',
'shell.navModels': '模型设置',
'shell.navSettings': '设置',
'shell.wip': '开发中',
'shell.wipHint': '该页面正在开发中,稍后开放',
'shell.comfy_ok': '{name} 已连接',
'shell.comfy_fail': '{name} 连接失败',
'shell.comfy_testing': '{name} 测试中…',
'shell.comfyUnset': 'ComfyUI · 未配置',
'shell.comfyManage': '管理 ComfyUI 连接',
'shell.mobileNotice': '请用桌面浏览器(宽 768px 以上)打开影工坊,手机端暂不适配。',
'shell.scopeHint': '切换项目',
'shell.scopeAll': '全部项目',
'shell.scopeAllList': '全部项目列表',
'shell.scopeRecent': '最近项目',
'shell.scopeLoadFailed': '项目列表加载失败',
'shell.scopeEmpty': '还没有项目,去创建',
'shell.scopeStage': '已确认 {n}/5',
'shell.queueRunning': '{n} 个生成中',
'shell.queueQueued': '{n} 个排队',
'shell.queueIdle': '队列空闲',
'shell.queueActive': '{n} 个进行中',
'shell.trayTitle': '任务托盘',
'shell.trayRecent': '最近任务 · 点击查看全部任务',
'shell.trayEmpty': '还没有生成任务',
'shell.trayAll': '查看全部任务',
'shell.searchTitle': '搜索页面和项目',
'shell.searchPlaceholder': '搜索页面、项目',
'shell.searchShortcut': '搜索(Ctrl/⌘ + K)',
'shell.searchEmpty': '没有找到与「{q}」相关的页面或项目',
'shell.searchGroupPages': '页面',
'shell.searchGroupProjects': '项目 · 打开创作工作台',
'shell.searchHintMove': '选择',
'shell.searchHintOpen': '打开',
'shell.searchHintClose': '关闭',
'shell.reduceMotion': '减少动态',
'shell.reduceHint': '关闭位移、缩放和光晕动画,只保留短暂淡入淡出',
'shell.reduceSystem': '已跟随系统的「减少动态效果」设置',
'shell.tabAria': '主导航(窄屏)',
'shell.tabProjects': '项目',
'shell.tabCreate': '创作',
'shell.tabGenerate': '生成',
'shell.tabMore': '更多',
'shell.morePages': '更多页面',
'shell.moreTools': '工具',
'home.title': '项目总览',
'home.subtitle': '管理创作项目,从一个创意或一份现成剧本开始。',
'home.projectCount': '{n} 个项目 · {m} 个进行中',
@@ -1455,8 +1485,6 @@ export const zh: Dict = {
'studio.genParams': '生成参数',
'studio.modeTextToImage': '文生图',
'studio.modeTextToVideo': '文生视频',
'studio.chipIdle': '{name} · 队列空闲',
'studio.chipBusy': '{name} · 队列 {n} 个任务',
'studio.video.resultsTitle': '成片预览',
'studio.image.historyTitle': '生图历史',
'studio.video.historyTitle': '生视频历史',
@@ -1924,4 +1952,93 @@ export const zh: Dict = {
'wft.errListMin': '「{label}」需要至少 {n} {unit}',
'tasks.testBadge': '测试',
'tasks.sourceWorkflowTest': '工作流测试',
// --- 生成台 (HEL-653) ---
'cdf.orig': '原值 {v}',
'desk.title': '生成台',
'desk.advanced': '高级参数 {n} 项 · 不改就沿用工作流原值',
'desk.chipResolution': '分辨率 {v}',
'desk.chipSeconds': '{v} 秒',
'desk.chipSeed': '种子 {v}',
'desk.closeInspector': '关闭检视区',
'desk.countActive': '{n} 个进行中',
'desk.countTakes': '{n} 条记录',
'desk.ctxAsset': '{kind}「{name}」',
'desk.ctxShot': '镜头 {no}',
'desk.ctxBack': '返回{stage}',
'desk.ctxDismiss': '关闭来源提示',
'desk.ctxFrom': '来自《{project}》',
'desk.ctxNoBackfill': '结果只留在生成台记录,不会自动回填到{stage}',
'desk.dayDate': '{m}月{d}日',
'desk.download': '下载',
'desk.dropNoSlot': '{n} 个文件没有可放的槽位:当前工作流没有接这类输入',
'desk.dryRun': '演练模式',
'desk.durMin': '{m} 分 {s} 秒',
'desk.durSec': '{s} 秒',
'desk.elapsed': '用时',
'desk.emptyFailed': '没有失败的记录',
'desk.emptyFeed': '还没有记录。在下方写提示词,点「生成」。',
'desk.filterAria': '筛选记录',
'desk.filterAll': '全部',
'desk.filterImage': '图片',
'desk.filterVideo': '视频',
'desk.filterFailed': '失败',
'desk.generate': '生成',
'desk.genHint': '生成(Ctrl/⌘ + Enter)',
'desk.inputs': '输入',
'desk.inspector': '检视区',
'desk.keyClose': '关闭',
'desk.keyInspector': '显示 / 收起',
'desk.keySelect': '选择',
'desk.keyUse': '使用',
'desk.kvCreated': '提交时间',
'desk.kvName': '名称',
'desk.kvPromptId': 'ComfyUI 任务号',
'desk.kvUsage': '用途',
'desk.needWorkflow': '还缺:工作流',
'desk.noConn': '未连接',
'desk.noPromptInput': '这个工作流没有提示词输入,按工作流原值生成。',
'desk.notGenerated': '未生成',
'desk.noWorkflows': '还没有可用的生图 / 生视频工作流',
'desk.openInDesk': '在生成台调参',
'desk.openInDeskHint': '带上提示词打开生成台,自选工作流和参数;结果留在生成台记录',
'desk.openInspector': '检视区',
'desk.origValue': '工作流原值',
'desk.outputN': '第 {n} 个结果',
'desk.outputs': '全部结果',
'desk.paramError': '参数有误:{msg}',
'desk.paramsAll': '全部参数',
'desk.paramsChanged': '参数 · 已改 {n} 项',
'desk.paramsCommon': '常用参数',
'desk.pickTake': '在记录流里选一条查看结果',
'desk.pickWorkflow': '选择工作流',
'desk.promptOnly': '仅提示词',
'desk.promptPh': '描述想要的画面…(Ctrl/⌘ + Enter 生成,/ 快速聚焦)',
'desk.restoreAll': '全部恢复工作流原值',
'desk.restoreOrig': '恢复原值',
'desk.reuse': '复用',
'desk.reuseToComposer': '复用到输入台',
'desk.seedRandom': '随机',
'desk.sendToSlot': '送入当前图片槽',
'desk.sent': '已提交',
'desk.sentToSlotToast': '已送入当前工作流的图片槽',
'desk.slotOne': '{label} ×1',
'desk.slotRange': '{label} {min}–{max}',
'desk.specSize': '尺寸',
'desk.stillNeed': '还缺:{what}',
'desk.tabParams': '参数',
'desk.tabResult': '结果',
'desk.today': '今天',
'desk.took': '用时 {d}',
'desk.wfBroken': '不可用',
'desk.wfCurrent': '当前',
'desk.wfDefaultConn': '跟随默认连接',
'desk.wfImport': '导入 / 管理工作流',
'desk.wfLastFailed': '上次失败',
'desk.wfNoMatch': '没有匹配的工作流',
'desk.wfOk': '可用',
'desk.wfSearchPh': '搜索工作流,或输入用途:图生视频、参考图…',
'desk.wfTesting': '试运行中',
'desk.wfUntested': '未试运行',
'desk.workflow': '工作流',
'desk.yesterday': '昨天',
};
@@ -0,0 +1,45 @@
import { storageGet, storageSet } from './storage';
/**
* 减少动态效果 (HEL-649 §五). The OS setting always wins (app.css also reacts
* to `prefers-reduced-motion` directly, so it cannot be switched off here);
* otherwise the top-bar switch decides. The effective value is mirrored to
* `<html data-reduce="1">`, which is what every reduced-motion rule keys on.
*/
const STORAGE_KEY = 'aigc-reduce-motion';
const QUERY = '(prefers-reduced-motion: reduce)';
export const reduceMotion = $state({ system: false, manual: false });
let stop: (() => void) | null = null;
function apply() {
document.documentElement.dataset.reduce =
reduceMotion.system || reduceMotion.manual ? '1' : '0';
}
/** Call once on the client (root layout). Returns the teardown. */
export function initReduceMotion(): () => void {
if (typeof window === 'undefined') return () => {};
stop?.();
const mq = window.matchMedia(QUERY);
reduceMotion.system = mq.matches;
reduceMotion.manual = storageGet(STORAGE_KEY) === '1';
apply();
const onChange = (e: MediaQueryListEvent) => {
reduceMotion.system = e.matches;
apply();
};
mq.addEventListener('change', onChange);
stop = () => mq.removeEventListener('change', onChange);
return () => {
stop?.();
stop = null;
};
}
export function setReduceMotion(on: boolean) {
reduceMotion.manual = on;
storageSet(STORAGE_KEY, on ? '1' : '0');
apply();
}
+4
View File
@@ -1,13 +1,17 @@
<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';
const queryClient = new QueryClient({
defaultOptions: {
queries: { refetchOnWindowFocus: false, retry: 1 },
},
});
onMount(() => initReduceMotion());
</script>
<QueryClientProvider client={queryClient}>
+363 -1
View File
@@ -24,6 +24,8 @@
--media-scrim: rgba(7, 9, 14, 0.65);
--on-media: #fff;
--on-media-line: rgba(255, 255, 255, 0.05);
--composer-bg: linear-gradient(180deg, #0e1220, var(--bg-2));
--composer-focus: 0 0 0 1px rgba(242, 199, 132, 0.12), 0 8px 28px rgba(0, 0, 0, 0.4);
/* 放映金:主按钮、当前选中、改过的参数、聚焦框、Toast 动作 (§1.2) */
--accent: #f2c784;
@@ -38,6 +40,8 @@
--accent-ring: 0 0 0 1px rgba(242, 199, 132, 0.7), 0 0 22px var(--accent-glow);
--btn-glow: 0 2px 14px var(--accent-glow), inset 0 1px 0 rgba(255, 255, 255, 0.35);
--btn-glow-hover: 0 4px 18px var(--accent-glow), inset 0 1px 0 rgba(255, 255, 255, 0.4);
--accent-wash: linear-gradient(180deg, rgba(242, 199, 132, 0.05), transparent 40%);
--sel-shadow: 0 0 0 1px rgba(242, 199, 132, 0.15), 0 6px 24px rgba(0, 0, 0, 0.35);
/* 信号色:绿=完成/已确认/通过,青=进行中/在线,红=失败/危险,
琥珀=待确认/文件缺失/限额 (§1.3) */
@@ -83,6 +87,11 @@
--r-pill: 999px;
--shadow-float: 0 14px 44px rgba(0, 0, 0, 0.6), 0 0 0 1px var(--line-2);
/* 壳层尺寸 (§二):60px 图标轨、顶栏、≤760px 底部标签栏 */
--rail-w: 60px;
--topbar-h: 52px;
--tabbar-h: 56px;
/* 专属符号 (§1.7):齿孔只在记录流/输入台分隔带,光晕只在播放舞台 */
--sprocket: radial-gradient(circle 2px, var(--line-2) 98%, transparent);
--stage-bg:
@@ -204,7 +213,7 @@ body {
}
.a-darkroom {
background: var(--bg);
background: var(--page-glow), var(--bg);
color: var(--text);
font-family: var(--font);
font-size: var(--fs-body);
@@ -344,6 +353,38 @@ code,
border-color: var(--accent-line);
color: var(--accent);
}
a.status-chip,
button.status-chip {
font-family: inherit;
text-decoration: none;
cursor: pointer;
transition:
border-color var(--d-state) ease,
color var(--d-state) ease,
transform var(--d-press) var(--ease-out);
}
a.status-chip:active,
button.status-chip:active {
transform: scale(var(--press-scale));
}
@media (hover: hover) and (pointer: fine) {
a.status-chip:hover,
button.status-chip:hover {
border-color: var(--text-3);
color: var(--text);
}
}
/* Keyboard shortcut hint, e.g. the Ctrl K badge on the search capsule. */
.a-kbd {
font: var(--fs-mini) var(--mono);
color: var(--text-3);
border: 1px solid var(--line-2);
border-bottom-width: 2px;
border-radius: var(--r-sm);
padding: 0 var(--sp-2);
line-height: 16px;
white-space: nowrap;
}
.status-chip-dot {
width: 6px;
height: 6px;
@@ -434,6 +475,270 @@ code,
opacity: 0.3;
}
/* Segmented control (§四 分段): the pressed option is lifted, never gold. */
.a-seg {
display: inline-flex;
padding: 2px;
border: 1px solid var(--line);
border-radius: var(--r-md);
background: var(--bg-2);
}
.a-seg > button {
height: 26px;
padding: 0 var(--sp-4);
border: 0;
border-radius: 8px;
background: transparent;
color: var(--text-3);
font-family: inherit;
font-size: var(--fs-body-sm);
white-space: nowrap;
cursor: pointer;
transition:
color var(--d-state) ease,
background-color var(--d-state) ease;
}
.a-seg > button[aria-pressed='true'] {
background: var(--bg-4);
color: var(--text);
box-shadow: inset 0 0 0 1px var(--line-2);
}
@media (hover: hover) and (pointer: fine) {
.a-seg > button:hover {
color: var(--text);
}
}
/* Composer chips (§3.3 输入台底行): workflow / connection / parameter chips.
Gold (`changed`) only marks a parameter that differs from the workflow value. */
.a-chip {
display: inline-flex;
align-items: center;
gap: var(--sp-2);
height: 28px;
max-width: 280px;
padding: 0 var(--sp-4);
border: 0;
border-radius: var(--r-pill);
background: var(--bg-3);
color: var(--text-2);
font-family: inherit;
font-size: var(--fs-aux);
white-space: nowrap;
text-decoration: none;
cursor: pointer;
box-sizing: border-box;
transition:
background-color var(--d-state) ease,
color var(--d-state) ease,
transform var(--d-press) var(--ease-out);
}
.a-chip:active {
transform: scale(var(--press-scale));
}
.a-chip > .lbl {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
}
.a-chip.wf {
background: var(--bg-4);
color: var(--text);
font-weight: 500;
box-shadow: inset 0 0 0 1px var(--line-2);
}
.a-chip.changed {
color: var(--accent);
}
.a-chip.warn {
color: var(--warn);
}
.a-chip.err {
color: var(--err);
}
@media (hover: hover) and (pointer: fine) {
.a-chip:hover {
background: var(--bg-4);
color: var(--text);
}
.a-chip.changed:hover {
color: var(--accent);
}
}
/* Composer input slots (§3.3 输入槽): 58px tiles — empty dashed / drag gold /
uploading ring / filled with hover ✕. MediaSlot and RefImageGrid render these. */
.a-slot {
display: flex;
flex-direction: column;
gap: var(--sp-2);
min-width: 0;
}
.a-slot-label {
display: flex;
align-items: center;
gap: var(--sp-2);
min-height: 20px;
font-size: var(--fs-mini);
color: var(--text-3);
white-space: nowrap;
}
.a-slot-label .req {
color: var(--warn);
}
.a-slot-row {
display: flex;
flex-wrap: wrap;
gap: var(--sp-2);
}
.a-slot-hint {
font-size: var(--fs-mini);
color: var(--text-3);
}
.a-slot-hint.err {
color: var(--err);
}
.a-tile {
position: relative;
flex: none;
display: grid;
place-items: center;
width: 58px;
height: 58px;
padding: 0;
border: 1px solid var(--line-2);
border-radius: var(--r-md);
background: var(--bg-3);
color: var(--text-3);
font-family: inherit;
box-sizing: border-box;
transition:
border-color var(--d-state) ease,
background-color var(--d-state) ease,
color var(--d-state) ease,
transform var(--d-press) var(--ease-out);
}
button.a-tile {
cursor: pointer;
}
button.a-tile:disabled {
cursor: default;
}
.a-tile.add {
border-style: dashed;
}
.a-tile.add:active:not(:disabled) {
transform: scale(var(--press-scale));
}
.a-tile.drag {
border-color: var(--accent);
background-color: var(--accent-soft);
color: var(--accent);
}
.a-tile.need {
border-color: var(--warn);
}
.a-tile.bad {
border-color: var(--err);
color: var(--err);
}
.a-tile-hit {
position: absolute;
inset: 0;
display: grid;
place-items: center;
padding: 0;
border: 0;
border-radius: inherit;
overflow: hidden;
background: transparent;
color: inherit;
cursor: pointer;
}
.a-tile-hit img,
.a-tile-hit video {
display: block;
width: 100%;
height: 100%;
object-fit: cover;
}
/* Uploads have no byte progress (fetch), so the ring spins instead of filling. */
.a-tile-ring {
position: absolute;
inset: 0;
display: grid;
place-items: center;
border-radius: inherit;
background: var(--media-scrim);
pointer-events: none;
}
.a-tile-ring > i {
width: 26px;
height: 26px;
border-radius: 50%;
background: conic-gradient(var(--accent) 0.3turn, var(--line-2) 0);
-webkit-mask: radial-gradient(circle, transparent 9.5px, #000 10.5px);
mask: radial-gradient(circle, transparent 9.5px, #000 10.5px);
animation: a-tile-spin 900ms linear infinite;
}
@keyframes a-tile-spin {
to {
transform: rotate(1turn);
}
}
:root[data-reduce='1'] .a-tile-ring > i {
animation: none;
}
.a-tile-x {
position: absolute;
top: -6px;
right: -6px;
z-index: 1;
display: grid;
place-items: center;
width: 18px;
height: 18px;
padding: 0;
border: 1px solid var(--line-2);
border-radius: 50%;
background: var(--bg-4);
color: var(--text-2);
cursor: pointer;
opacity: 0;
transition:
opacity var(--d-state) ease,
color var(--d-state) ease;
}
.a-tile:focus-within .a-tile-x {
opacity: 1;
}
.a-tile-idx {
position: absolute;
left: 3px;
top: 2px;
font: 10px var(--mono);
color: var(--on-media);
text-shadow: 0 1px 2px var(--bg);
pointer-events: none;
}
@media (hover: hover) and (pointer: fine) {
.a-tile:hover .a-tile-x {
opacity: 1;
}
.a-tile-x:hover {
color: var(--err);
}
button.a-tile.add:hover:not(:disabled) {
border-color: var(--text-3);
color: var(--text);
}
}
@media (hover: none) {
.a-tile-x {
opacity: 1;
}
}
/* Projection stage — only behind media in the inspector (§1.7). */
.a-stage {
position: relative;
@@ -450,6 +755,63 @@ code,
box-shadow: var(--shadow-float);
}
/* Rows inside pops, sheets and the Ctrl+K palette (§四.3 pop). Gold marks
only the current page / project; keyboard and hover highlight is neutral. */
.a-menu-group {
padding: var(--sp-4) var(--sp-4) var(--sp-1);
font-size: var(--fs-mini);
color: var(--text-3);
}
.a-menu-item {
display: flex;
align-items: center;
gap: var(--sp-3);
width: 100%;
min-height: 40px;
padding: var(--sp-3) var(--sp-4);
border: 0;
border-radius: var(--r-md);
background: transparent;
color: var(--text-2);
font-family: inherit;
font-size: var(--fs-body);
text-align: left;
text-decoration: none;
cursor: pointer;
box-sizing: border-box;
transition:
background-color var(--d-state) ease,
color var(--d-state) ease;
}
.a-menu-item.active,
.a-menu-item:focus-visible {
background: var(--bg-4);
color: var(--text);
}
@media (hover: hover) and (pointer: fine) {
.a-menu-item:hover {
background: var(--bg-4);
color: var(--text);
}
}
.a-menu-item[aria-current='page'],
.a-menu-item[aria-current='true'] {
color: var(--accent);
}
.a-menu-label {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.a-menu-meta {
flex-shrink: 0;
font-size: var(--fs-mini);
color: var(--text-3);
white-space: nowrap;
}
/* Card / panel surface (§1.5:卡片不投影,用底色差分层). */
.a-panel {
background: var(--bg-2);
@@ -471,6 +471,7 @@
{:else if stageKey === 'assets'}
<AssetsStagePanel
stage={panelProps.stage}
{projectId}
busy={busyFor('assets')}
onSave={(content) => $saveMutation.mutate({ key: 'assets', content })}
onRun={() => handleRun('assets')}
@@ -480,6 +481,7 @@
{:else}
<ShotStagePanel
stage={panelProps.stage}
{projectId}
busy={busyFor('shot')}
onSave={(content) => $saveMutation.mutate({ key: 'shot', content })}
onRun={() => handleRun('shot')}