832 lines
21 KiB
Svelte
832 lines
21 KiB
Svelte
<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>
|