- lib/media/exportFrame.ts:隐藏 video 取片尾前约 1 帧,黑帧/空帧逐级回退(≤1s), 原生尺寸 canvas 导出 PNG;加载/损坏/404/403/跨域/超时/无法定位结尾分类报错 - lib/media/lastFrame.ts:下载 + 中文 Toast 反馈 - Inspector.svelte 操作区、ImageLightbox.svelte 视频工具栏加「导出尾帧」 - ToastHost 层级提到灯箱之上,弹层内反馈可见 - 七语言文案;npm test 新增 check-export-frame;e2e/lastframe.mjs 浏览器验收 Co-authored-by: multica-agent <github@multica.ai>
566 lines
15 KiB
Svelte
566 lines
15 KiB
Svelte
<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 { clearOutputMissing, markOutputMissing, missingOutputsOf } from '$lib/missingOutputs.svelte';
|
||
import { t } from '$lib/i18n.svelte';
|
||
import { exportLastFrame } from '$lib/media/lastFrame';
|
||
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.
|
||
* D 批 (HEL-654): pointer picks cross-fade the stage 150ms (键盘
|
||
* ↑↓/J/K 即时无动画), a finished/just-picked video auto-plays muted,
|
||
* failed takes get 重试, and a done take whose files no longer load
|
||
* shows the amber 文件缺失 state with 用相同参数重做 / 移除记录.
|
||
*/
|
||
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;
|
||
/** Animate this result change (pointer pick) — 150ms cross-fade. */
|
||
crossfade: boolean;
|
||
/** Bump to auto-play the stage video (pointer pick / completion). */
|
||
playSignal: number;
|
||
/** The stage <video> element, for the desk's space-bar toggle. */
|
||
videoEl?: HTMLVideoElement | null;
|
||
params: Snippet;
|
||
onClose: () => void;
|
||
onReuse: (job: Job) => void;
|
||
onRetry: (job: Job) => void;
|
||
onSendToSlot: (path: string) => void;
|
||
onDelete: (job: Job) => void;
|
||
}
|
||
|
||
let {
|
||
tab = $bindable(),
|
||
job,
|
||
wf,
|
||
nowMs,
|
||
open,
|
||
inactive,
|
||
canSendToSlot,
|
||
crossfade,
|
||
playSignal,
|
||
videoEl = $bindable(null),
|
||
params,
|
||
onClose,
|
||
onReuse,
|
||
onRetry,
|
||
onSendToSlot,
|
||
onDelete,
|
||
}: Props = $props();
|
||
|
||
let outIndex = $state(0);
|
||
$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 allOutputs = $derived(job && st?.tone === 'ok' ? job.output_paths : []);
|
||
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 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}`;
|
||
}
|
||
|
||
// 完成或点选后自动播放(静音);键盘切换不触发。
|
||
$effect(() => {
|
||
void playSignal;
|
||
if (inactive || tab !== 'result' || !videoEl) return;
|
||
videoEl.muted = true;
|
||
videoEl.play().catch(() => {});
|
||
});
|
||
|
||
function reduced(): boolean {
|
||
return (
|
||
document.documentElement.dataset.reduce === '1' ||
|
||
window.matchMedia('(prefers-reduced-motion: reduce)').matches
|
||
);
|
||
}
|
||
|
||
/** 150ms 交叉淡变 on pointer picks; keyboard switches stay instant. */
|
||
function xfade(_node: Element) {
|
||
const ms = crossfade ? (reduced() ? 120 : 150) : 0;
|
||
return { duration: ms, css: (t: number) => `opacity: ${t}` };
|
||
}
|
||
|
||
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>
|
||
|
||
<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">
|
||
{#key mediaKey}
|
||
<div class="stage-media" in:xfade out:xfade>
|
||
{#if output && video}
|
||
<!-- svelte-ignore a11y_media_has_caption -->
|
||
<video
|
||
bind:this={videoEl}
|
||
src={assetUrl(output)}
|
||
controls
|
||
playsinline
|
||
preload="metadata"
|
||
onloadedmetadata={(e) => {
|
||
clearOutputMissing(output);
|
||
noteVideo(e);
|
||
}}
|
||
onerror={() => output && markOutputMissing(output)}
|
||
></video>
|
||
{:else if output}
|
||
<img
|
||
src={assetUrl(output)}
|
||
alt={prompt || t('desk.tabResult')}
|
||
onload={(e) => {
|
||
clearOutputMissing(output);
|
||
noteImg(e);
|
||
}}
|
||
onerror={() => output && markOutputMissing(output)}
|
||
/>
|
||
{: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}
|
||
</div>
|
||
|
||
{#if allOutputs.length > 1}
|
||
<div class="strip" role="group" aria-label={t('desk.outputs')}>
|
||
{#each allOutputs as p, i (p)}
|
||
<button
|
||
type="button"
|
||
class="strip-item"
|
||
class:gone={gonePaths.includes(p)}
|
||
aria-pressed={p === output}
|
||
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">
|
||
{#if fileGone && !outputs.length}
|
||
<button type="button" class="a-btn secondary sm" onclick={() => onRetry(job)}>{t('desk.redo')}</button>
|
||
<button type="button" class="a-btn danger sm" onclick={() => onDelete(job)}>{t('desk.removeRecord')}</button>
|
||
{:else}
|
||
<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 output && video}
|
||
<button
|
||
type="button"
|
||
class="a-btn secondary sm"
|
||
title={t('lastFrame.hint')}
|
||
disabled={frameBusy}
|
||
aria-busy={frameBusy}
|
||
onclick={exportTail}
|
||
>
|
||
{frameBusy ? t('lastFrame.exporting') : t('lastFrame.export')}
|
||
</button>
|
||
{/if}
|
||
{#if st.tone === 'fail'}
|
||
<button type="button" class="a-btn secondary sm" onclick={() => onRetry(job)}>{t('common.retry')}</button>
|
||
{/if}
|
||
{#if job.status !== 'pending' && job.status !== 'running'}
|
||
<button type="button" class="a-btn danger sm" onclick={() => onDelete(job)}>{t('studio.deleteBtn')}</button>
|
||
{/if}
|
||
{/if}
|
||
</div>
|
||
|
||
{#if st.tone === 'fail'}
|
||
<div class="note err" role="alert">{st.detail}</div>
|
||
{:else if fileGone}
|
||
<div class="note warn" role="alert">{t('jobst.missingDetail')}</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 })}
|
||
{#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}
|
||
</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-media {
|
||
position: absolute;
|
||
inset: 0;
|
||
display: grid;
|
||
place-items: center;
|
||
}
|
||
.stage-media > img,
|
||
.stage-media > 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.gone {
|
||
color: var(--warn);
|
||
}
|
||
.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.gone {
|
||
border-style: dashed;
|
||
border-color: var(--warn);
|
||
color: var(--warn);
|
||
}
|
||
.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);
|
||
}
|
||
.note.warn {
|
||
background: var(--warn-soft);
|
||
color: var(--warn);
|
||
}
|
||
.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;
|
||
}
|
||
.kv .wf-gone {
|
||
margin-left: var(--sp-2);
|
||
}
|
||
|
||
/* ≥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>
|