- 解析:H3 参考生视频 ref_images 自增输入收拢为 1–9 张有序参考图组; 图生视频首/尾帧、Qwen 编辑输入图按真实连线识别,未连线不出槽位; Qwen 编辑模式暴露 resolution - 打补丁:按张数克隆/复用/删除 LoadImage 并按序接线 ref_image_0..N-1 - 校验:参考图上限 9、至少 1 张、格式/存在性;必填图片槽位 - 工作流体检:缺 class_type / 断链 / 采样器无模型 → 导入时中文告警, 生成接口与 worker 拒绝执行并提示重新导出 - 前端:RefImageGrid(逐张添加/预览/替换/删除/序号/超限整批拒绝)、 MediaSlot 横排已选态、分辨率字段、损坏工作流提示、连接页告警标签 - 测试:三份真实 JSON + 1/4/9 张参考图全链路 Co-authored-by: Cursor <cursoragent@cursor.com> Co-authored-by: multica-agent <github@multica.ai>
390 lines
8.9 KiB
Svelte
390 lines
8.9 KiB
Svelte
<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.
|
||
*/
|
||
interface Props {
|
||
label: string;
|
||
/** Hint on the right of 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;
|
||
error?: string;
|
||
/** Show the "pick from image results" link (video-page image slots). */
|
||
allowFromResults?: boolean;
|
||
onFile: (file: File) => void;
|
||
onFromResults?: () => void;
|
||
onClear: () => void;
|
||
}
|
||
|
||
let {
|
||
label,
|
||
hint,
|
||
required = false,
|
||
value,
|
||
kind,
|
||
aspect = 'square',
|
||
emptyText,
|
||
emptySub,
|
||
size,
|
||
uploading = false,
|
||
uploadName,
|
||
error,
|
||
allowFromResults = false,
|
||
onFile,
|
||
onFromResults,
|
||
onClear,
|
||
}: Props = $props();
|
||
|
||
let fileInput = $state<HTMLInputElement | null>(null);
|
||
let dragDepth = $state(0);
|
||
let broken = $state(false);
|
||
let dims = $state('');
|
||
$effect(() => {
|
||
void value;
|
||
broken = false;
|
||
dims = '';
|
||
});
|
||
|
||
const meta = $derived([dims, size != null ? formatBytes(size) : ''].filter(Boolean).join(' · '));
|
||
|
||
function accept(): string {
|
||
if (kind === 'video') return 'video/*,.mp4,.webm,.mov,.mkv';
|
||
if (kind === 'audio') return 'audio/*';
|
||
return IMAGE_SLOT_ACCEPT;
|
||
}
|
||
|
||
function noteDims(e: Event) {
|
||
const img = e.currentTarget as HTMLImageElement;
|
||
if (img.naturalWidth) dims = `${img.naturalWidth}×${img.naturalHeight}`;
|
||
}
|
||
|
||
function pick() {
|
||
if (uploading || !fileInput) return;
|
||
fileInput.value = '';
|
||
fileInput.click();
|
||
}
|
||
|
||
function onDrop(e: DragEvent) {
|
||
e.preventDefault();
|
||
dragDepth = 0;
|
||
if (uploading) return;
|
||
const file = e.dataTransfer?.files?.[0];
|
||
if (file) onFile(file);
|
||
}
|
||
</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>
|
||
|
||
<input
|
||
bind:this={fileInput}
|
||
type="file"
|
||
class="sr-file"
|
||
tabindex="-1"
|
||
aria-hidden="true"
|
||
accept={accept()}
|
||
onchange={(e) => {
|
||
const f = e.currentTarget.files?.[0];
|
||
if (f) onFile(f);
|
||
}}
|
||
/>
|
||
|
||
{#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>
|
||
<button
|
||
type="button"
|
||
class="slot-clear"
|
||
aria-label={t('studio.deleteBtn')}
|
||
disabled={uploading}
|
||
onclick={onClear}
|
||
>
|
||
<Icon name="close" size={12} />
|
||
</button>
|
||
</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')}
|
||
</button>
|
||
{/if}
|
||
{/if}
|
||
{#if error}<span class="slot-error" 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;
|
||
height: 1px;
|
||
overflow: hidden;
|
||
clip: rect(0 0 0 0);
|
||
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: rgba(229, 72, 77, 0.65);
|
||
}
|
||
.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>
|