Files
aigc-home/calliope-web/src/lib/components/desk/Artboard.svelte
T

832 lines
21 KiB
Svelte
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script lang="ts">
import { resolve } from '$app/paths';
import { untrack, type Snippet } from 'svelte';
import Modal from '$lib/components/ui/Modal.svelte';
import ImageLightbox from '$lib/components/ImageLightbox.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 {
clearOutputMissing,
markOutputMissing,
missingOutputsOf,
} from '$lib/missingOutputs.svelte';
import { t } from '$lib/i18n.svelte';
import { exportLastFrame } from '$lib/media/lastFrame';
import { usageLabel } from '../generate/schema';
import {
elapsedSec,
fmtClock,
fmtDur,
inputsOf,
isVideoOutput,
promptOf,
specOf,
} from '../generate/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.
* D 批 (HEL-654): pointer picks cross-fade the stage 150ms (键盘
* ↑↓/J/K 即时无动画), video playback is always explicit,
* failed takes get 重试, and a done take whose files no longer load
* shows the amber 文件缺失 state with 用相同参数重做 / 移除记录.
*/
interface Props {
main?: boolean;
comparePath?: string | null;
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;
/** Animate this result change (pointer pick) — 150ms cross-fade. */
crossfade: boolean;
/** The stage <video> element, for explicit playback controls. */
videoEl?: HTMLVideoElement | null;
params: Snippet;
onClose: () => void;
onReuse: (job: Job) => void;
onRetry: (job: Job) => void;
onCancel: (job: Job) => void;
onSendToSlot: (path: string) => void;
onDelete: (job: Job) => void;
canAttach?: boolean;
attachBusy?: boolean;
onAttach?: () => void;
}
let {
main = false,
comparePath = null,
tab = $bindable(),
job,
wf,
nowMs,
canSendToSlot,
videoEl = $bindable(null),
onReuse,
onRetry,
onCancel,
onSendToSlot,
onDelete,
canAttach = false,
attachBusy = false,
onAttach,
}: Props = $props();
let detailsOpen = $state(false);
let deleteOpen = $state(false);
let outIndex = $state(0);
let compare = $state(false),
compareAt = $state(50),
lightbox = $state(false);
$effect(() => {
void job?.id;
detailsOpen = false;
deleteOpen = false;
compare = false;
lightbox = false;
});
$effect.pre(() => {
void job?.id;
outIndex = 0;
});
const st = $derived(job ? jobStatusInfo(job) : null);
// §3.4 红/琥珀界线: amber only when the task SUCCEEDED but its files
// cannot be fetched — the failed case stays red.
const gonePaths = $derived(missingOutputsOf(job));
const fileGone = $derived(gonePaths.length > 0);
const outputs = $derived(
job && st?.tone === 'ok'
? job.output_paths.filter((p) => !gonePaths.includes(p))
: [],
);
const output = $derived(
outputs[Math.min(outIndex, outputs.length - 1)] ?? null,
);
const video = $derived(!!job && !!output && isVideoOutput(job, output));
const wfDeleted = $derived(!!job && job.workflow_id == null);
const prompt = $derived(job ? promptOf(job, wf) : '');
const inputs = $derived(job ? inputsOf(job, wf) : []);
const compareSource = $derived(
inputs.find((i) => i.kind === 'image')?.path ?? comparePath,
);
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('');
let naturalWidth = $state(1),
naturalHeight = $state(1);
let stageWidth = $state(0),
stageHeight = $state(0);
const fitted = $derived(
Math.min(stageWidth / naturalWidth, stageHeight / naturalHeight),
);
$effect.pre(() => {
void output;
dims = '';
});
function noteImg(e: Event) {
const img = e.currentTarget as HTMLImageElement;
naturalWidth = img.naturalWidth;
naturalHeight = img.naturalHeight;
dims = `${img.naturalWidth}×${img.naturalHeight}`;
}
function noteVideo(e: Event) {
const v = e.currentTarget as HTMLVideoElement;
naturalWidth = v.videoWidth;
naturalHeight = v.videoHeight;
dims = `${v.videoWidth}×${v.videoHeight}`;
}
let paused = $state(true);
$effect.pre(() => {
void output;
untrack(() => {
videoEl?.pause();
paused = true;
});
});
function togglePlayback() {
if (!videoEl) return;
if (videoEl.paused) videoEl.play().catch(() => {});
else videoEl.pause();
}
const mediaKey = $derived(`${job?.id ?? 'none'}:${output ?? st?.tone ?? ''}`);
let frameBusy = $state(false);
async function exportTail() {
const src = assetUrl(output);
if (!src || frameBusy) return;
frameBusy = true;
try {
await exportLastFrame(src);
} finally {
frameBusy = false;
}
}
</script>
<div class="artboard" aria-label="生成结果">
<button
class="artboard-toolbar"
aria-label="作品详情与操作"
onclick={() => (detailsOpen = true)}
>
<div class="result-title">
<span>第 {job?.id} 次生成</span><small>{wf?.name ?? '历史作品'}</small>
</div>
</button>
<div class="stage" class:comparing={compare}>
{#if job && st}{#key mediaKey}
<div
class="stage-media"
bind:clientWidth={stageWidth}
bind:clientHeight={stageHeight}
>
{#if output && video}
<!-- svelte-ignore a11y_media_has_caption -->
<div
class="print a-photo"
style:width={fitted > 0 ? `${naturalWidth * fitted}px` : '100%'}
style:height={fitted > 0 ? `${naturalHeight * fitted}px` : '100%'}
>
{#if st.tone === 'ok' && !fileGone}<span class="a-fix"
>{t('jobst.done')}</span
>{/if}
<video
bind:this={videoEl}
src={assetUrl(output)}
controls
aria-label="生成的视频,使用播放按钮开始播放"
onkeydown={(e) => {
if (e.code === 'Space' && e.target === e.currentTarget) {
e.preventDefault();
togglePlayback();
}
}}
onplay={() => (paused = false)}
onpause={() => (paused = true)}
onended={() => (paused = true)}
playsinline
preload="metadata"
onloadedmetadata={(e) => {
clearOutputMissing(output);
noteVideo(e);
}}
onerror={() => output && markOutputMissing(output)}
></video>
{#if paused}<button
class="big-play"
aria-label="播放此视频"
onclick={() => {
videoEl?.focus();
togglePlayback();
}}>▶</button
>{/if}
</div>
{:else if output}
<div
class="print a-photo"
style:width={fitted > 0 ? `${naturalWidth * fitted}px` : '100%'}
style:height={fitted > 0 ? `${naturalHeight * fitted}px` : '100%'}
>
{#if st.tone === 'ok' && !fileGone}<span class="a-fix"
>{t('jobst.done')}</span
>{/if}
<img
src={assetUrl(output)}
alt={prompt || t('desk.tabResult')}
onload={(e) => {
clearOutputMissing(output);
noteImg(e);
}}
onerror={() => output && markOutputMissing(output)}
/>
{#if main && compare && compareSource}<div
class="comparison-layer"
style:clip-path={`inset(0 ${100 - compareAt}% 0 0)`}
>
<img src={assetUrl(compareSource)} alt="对比参考画面" />
</div>
<div
class="comparison-divider"
style:left={`${compareAt}%`}
></div>{/if}
</div>
{:else if fileGone}
<div class="stage-empty gone">
<span>{t('desk.missingTitle')}</span>
<span class="sub">{t('desk.missingSub')}</span>
</div>
{: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>
{/key}{/if}
{#if outputs.length > 1}<div
class="output-picker"
role="group"
aria-label="本次生成的作品"
>
<!-- Output paths may repeat; the button number and outIndex refer to this position. -->
{#each outputs as p, i (i)}<button
aria-pressed={output === p}
onclick={() => (outIndex = i)}
aria-label={'查看第 ' + (i + 1) + ' 件作品'}>{i + 1}</button
>{/each}
</div>{/if}
</div>
<footer class="artboard-footer">
<button
class="canvas-btn"
disabled={!output}
onclick={() =>
video
? videoEl?.requestFullscreen().catch(() => {})
: (lightbox = true)}
aria-label="放大查看"><Icon name="zoom-in" size={14} />放大</button
>
<a
class="canvas-btn"
href={output ? assetUrl(output) : undefined}
aria-disabled={!output}
download
rel="external"
aria-label="下载生成结果"><Icon name="download" size={14} />下载</a
>
<span class="action-separator" aria-hidden="true"></span>
<button
class="canvas-btn"
disabled={!job || !output}
onclick={() => job && onReuse(job)}
><Icon name="retry" size={14} />复用参数</button
>
<button
class="canvas-btn"
disabled={!output || video || !canSendToSlot}
title={canSendToSlot
? '放入当前工作流图片输入'
: '当前工作流没有图片输入'}
onclick={() => output && onSendToSlot(output)}
><Icon name="image" size={14} />作为参考图</button
>
<button
class="canvas-btn"
disabled
title="跨工作流送图尚未具备,请选择生视频工作流后从已有结果选取"
><Icon name="film" size={14} />送去生视频</button
>
<span class="action-separator" aria-hidden="true"></span>
<button
class="canvas-btn"
disabled={!canAttach || attachBusy}
title={canAttach
? '保存到来源项目'
: '通用选择项目与阶段将在后续批次提供'}
onclick={onAttach}><Icon name="folder" size={14} />存入项目</button
>
<a class="canvas-btn" href={resolve('/library')}
><Icon name="assets" size={14} />素材库</a
>
<button
class="canvas-btn delete-output"
aria-label="删除作品记录"
title="删除作品记录"
disabled={!job || job.status === 'pending' || job.status === 'running'}
onclick={() => (deleteOpen = true)}
><Icon name="close" size={14} /></button
>
{#if job?.status === 'pending' || job?.status === 'running'}<button
class="canvas-btn"
onclick={() => job && onCancel(job)}>取消生成</button
>{/if}
{#if st?.tone === 'fail'}<button
class="canvas-btn"
onclick={() => job && onRetry(job)}>重试</button
>{/if}
{#if compare && output && !video}<label class="compare-slider"
>参考<input
type="range"
min="0"
max="100"
bind:value={compareAt}
aria-label="拖动对比画面"
/>结果</label
>{/if}
</footer>
</div>
<Modal bind:open={detailsOpen} size="lg" title="作品详情与操作">
{#if job && st}
<div class="detail-actions">
{#if video}<button
class="canvas-btn play-button"
onclick={togglePlayback}
aria-label={paused ? '播放视频' : '暂停视频'}
>{paused ? '▶ 播放' : 'Ⅱ 暂停'}</button
><button
class="canvas-btn"
onclick={() => videoEl?.requestFullscreen().catch(() => {})}
>全屏</button
>{/if}
<span class="result-spec"
>{dims || st?.label} · {job ? fmtDur(elapsedSec(job, nowMs)) : ''}</span
>
{#if compare && output && !video}<label class="compare-slider"
>参考<input
type="range"
min="0"
max="100"
bind:value={compareAt}
aria-label="拖动对比画面"
/>结果</label
>{/if}
{#if output && !video && compareSource && compareSource !== output}<button
class="canvas-btn"
aria-pressed={compare}
onclick={() => {
compare = !compare;
detailsOpen = false;
}}>◫ {compare ? '结束对比' : '对比'}</button
>{/if}
<button
class="a-btn secondary sm"
onclick={() => {
onReuse(job);
detailsOpen = false;
}}>复用到输入区</button
>
{#if output && !video && canSendToSlot}<button
class="a-btn secondary sm"
onclick={() => {
onSendToSlot(output);
detailsOpen = false;
}}>送入当前图片槽</button
>{/if}
{#if video && output}<button
class="a-btn secondary sm"
disabled={frameBusy}
onclick={exportTail}>{frameBusy ? '正在导出…' : '导出尾帧'}</button
>{/if}
{#if st.tone === 'fail' || fileGone}<button
class="a-btn secondary sm"
onclick={() => onRetry(job)}>用相同参数重做</button
>{/if}
{#if job.status !== 'pending' && job.status !== 'running'}<button
class="a-btn danger sm"
onclick={() => {
deleteOpen = true;
detailsOpen = false;
}}>移除记录</button
>{/if}
</div>
{#if st.tone === 'fail' || fileGone}<p class="error-note" role="alert">
{fileGone ? t('jobst.missingDetail') : st.detail}
</p>{/if}
<div class="detail-meta">
<!-- Display-only spec rows have no local state; preserve their existing positional reuse. -->
{#each spec as s, i (i)}<span>{s.label} · {s.value}</span
>{/each}{#if endpoint}<span>{endpoint}</span>{/if}
</div>
{#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 })}
{#if wfDeleted}<span class="a-tag wf-gone"
>{t('studio.wfDeletedTag')}</span
>{/if}
</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}
</Modal>
<Modal bind:open={deleteOpen} title="删除作品记录" size="md">
<p>
确认移除这条生成记录及生成文件?关联项目中的回流记录也会移除。确认后有 5
秒可撤销,提交后无法恢复。
</p>
{#snippet footer()}<button
class="a-btn secondary"
onclick={() => (deleteOpen = false)}>取消</button
>
<button
class="a-btn danger"
onclick={() => {
if (job) onDelete(job);
deleteOpen = false;
}}>确认移除</button
>{/snippet}
</Modal>
{#if lightbox && output && !video}<ImageLightbox
src={assetUrl(output) ?? ''}
alt={prompt || '创作结果'}
onClose={() => (lightbox = false)}
/>{/if}
<style>
.artboard {
position: relative;
display: grid;
grid-template-rows: 0 minmax(0, 1fr) 40px;
height: 100%;
min-height: 0;
border: 0;
border-radius: 0;
background: transparent;
overflow: hidden;
}
.artboard-toolbar,
.artboard-footer {
display: flex;
align-items: center;
justify-content: space-between;
gap: 6px;
padding: 0 12px;
min-width: 0;
}
.artboard-toolbar {
position: absolute;
top: 10px;
left: 10px;
z-index: 2;
border-radius: 4px;
background: var(--media-scrim);
color: var(--on-media);
padding: 2px 6px;
max-width: calc(100% - 20px);
border: 0;
cursor: pointer;
text-align: left;
}
.result-title {
display: flex;
align-items: center;
gap: 12px;
min-width: 0;
font-size: 11px;
color: var(--text-2);
}
.result-title small {
color: var(--text-3);
font-size: 10px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 220px;
}
.canvas-btn {
display: inline-flex;
align-items: center;
gap: 6px;
height: 26px;
box-sizing: border-box;
background: var(--bg-2);
color: var(--text-2);
border: 1px solid var(--line);
border-radius: var(--desk-action-radius);
padding: 0 9px;
font-size: 12px;
font-weight: 500;
cursor: pointer;
text-decoration: none;
white-space: nowrap;
}
.action-separator {
width: 1px;
height: 18px;
flex-shrink: 0;
margin: 0 4px;
background: var(--line-2);
}
.delete-output {
width: 26px;
padding: 0;
justify-content: center;
color: var(--err);
background: transparent;
border-color: transparent;
}
.canvas-btn:disabled,
.canvas-btn[aria-disabled='true'] {
opacity: 0.45;
cursor: not-allowed;
}
.canvas-btn:hover {
color: var(--text);
background: var(--bg-3);
}
.canvas-btn[aria-pressed='true'],
.play-button {
color: var(--accent);
border-color: var(--accent-line);
}
.stage {
grid-row: 2;
min-height: 0;
min-width: 0;
position: relative;
overflow: hidden;
}
.stage-media {
position: absolute;
inset: 0;
padding: 0;
display: grid;
place-items: center;
min-height: 0;
}
.print {
position: relative;
display: grid;
grid-template: minmax(0, 1fr) / minmax(0, 1fr);
width: 100%;
height: 100%;
min-height: 0;
min-width: 0;
border: 0;
padding: 0;
background: none;
box-shadow: none;
}
.big-play {
position: absolute;
left: 50%;
top: 50%;
transform: translate(-50%, -50%);
width: 64px;
height: 64px;
border-radius: 50%;
border: 1px solid var(--on-media-line);
color: var(--on-media);
background: var(--media-scrim);
font-size: 26px;
cursor: pointer;
}
.print img,
.print video {
width: 100%;
height: 100%;
max-width: 100%;
max-height: 100%;
min-height: 0;
min-width: 0;
object-fit: contain;
display: block;
}
.print :global(.a-fix) {
display: none;
}
.stage-empty {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 100%;
gap: 12px;
font-size: 14px;
color: var(--text-2);
}
.stage-empty .sub {
color: var(--text-3);
font-size: 11px;
text-align: center;
max-width: 480px;
overflow-wrap: anywhere;
}
.stage-bar {
width: 200px;
}
.comparison-layer {
position: absolute;
inset: 0;
background: var(--bg-0);
pointer-events: none;
}
.comparison-divider {
position: absolute;
top: 0;
bottom: 0;
width: 2px;
background: var(--accent);
pointer-events: none;
}
.artboard-footer {
grid-row: 3;
justify-content: center;
gap: 6px;
padding: 0;
font-size: 10px;
color: var(--text-3);
}
.result-spec {
margin-left: auto;
}
.compare-slider {
display: flex;
align-items: center;
gap: 8px;
font-size: 10px;
}
.compare-slider input {
width: 120px;
accent-color: var(--accent);
}
.output-picker {
position: absolute;
bottom: 8px;
left: 50%;
transform: translateX(-50%);
display: flex;
gap: 4px;
max-width: 90%;
overflow: auto;
background: var(--media-scrim);
border: 1px solid var(--line);
padding: 4px;
border-radius: 8px;
}
.output-picker button {
background: none;
border: 0;
color: var(--text-3);
font-size: 10px;
padding: 4px 8px;
cursor: pointer;
border-radius: 4px;
}
.output-picker button[aria-pressed='true'] {
background: var(--accent-soft);
color: var(--accent);
}
.detail-actions,
.detail-meta,
.inputs {
display: flex;
flex-wrap: wrap;
gap: 10px;
margin-bottom: 16px;
}
.detail-meta {
color: var(--text-3);
font-size: 11px;
}
.section-title {
font-size: 12px;
color: var(--text-3);
margin: 20px 0 10px;
}
.mini {
width: 60px;
height: 60px;
object-fit: cover;
border-radius: 8px;
background: var(--surface);
}
.mini.glyph {
display: grid;
place-items: center;
}
.prompt {
white-space: pre-wrap;
overflow-wrap: anywhere;
padding: 16px;
border: 1px solid var(--line);
border-radius: 10px;
background: var(--bg-2);
max-height: 260px;
overflow: auto;
}
.kv {
display: grid;
grid-template-columns: 90px minmax(0, 1fr);
font-size: 12px;
gap: 8px;
}
.kv dt {
color: var(--text-3);
}
.kv dd {
margin: 0;
overflow-wrap: anywhere;
}
.error-note {
color: var(--warn);
}
@media (max-width: 1100px) {
.result-title small {
display: none;
}
.stage-media {
padding: 8px 12px;
}
}
</style>