722 lines
24 KiB
TypeScript
722 lines
24 KiB
TypeScript
import { useRef, useState, type CSSProperties } from 'react';
|
|
import {
|
|
FileText,
|
|
Image as ImageIcon,
|
|
LoaderCircle,
|
|
Music2,
|
|
Paperclip,
|
|
Plus,
|
|
Repeat2,
|
|
Video,
|
|
X,
|
|
} from 'lucide-react';
|
|
import { uploadFileToStorage } from '../api';
|
|
import type { PlaygroundMode } from '../types';
|
|
|
|
export type PlaygroundUploadKind = 'audio' | 'file' | 'image' | 'video';
|
|
export type PlaygroundUploadRole = 'first_frame' | 'last_frame';
|
|
export type PlaygroundVideoCreateMode = 'text_to_video' | 'first_last_frame' | 'omni_reference';
|
|
|
|
export interface PlaygroundUpload {
|
|
contentType: string;
|
|
id: string;
|
|
kind: PlaygroundUploadKind;
|
|
name: string;
|
|
raw: Record<string, unknown>;
|
|
role?: PlaygroundUploadRole;
|
|
size: number;
|
|
url: string;
|
|
}
|
|
|
|
export type OpenAIChatContentPart =
|
|
| { type: 'text'; text: string }
|
|
| { type: 'image_url'; image_url: { url: string } }
|
|
| { type: 'video_url'; video_url: { url: string } }
|
|
| { type: 'audio_url'; audio_url: { url: string } }
|
|
| { type: 'file_url'; file_url: { filename: string; url: string } };
|
|
|
|
export const mediaUploadAccept = 'image/*,video/*,audio/*';
|
|
export const imageOnlyUploadAccept = 'image/*';
|
|
export const chatUploadAccept = [
|
|
mediaUploadAccept,
|
|
'.csv',
|
|
'.doc',
|
|
'.docx',
|
|
'.json',
|
|
'.jsonl',
|
|
'.md',
|
|
'.markdown',
|
|
'.pdf',
|
|
'.ppt',
|
|
'.pptx',
|
|
'.txt',
|
|
'.xls',
|
|
'.xlsx',
|
|
'.yaml',
|
|
'.yml',
|
|
'application/json',
|
|
'application/msword',
|
|
'application/pdf',
|
|
'application/vnd.ms-excel',
|
|
'application/vnd.ms-powerpoint',
|
|
'application/vnd.openxmlformats-officedocument.presentationml.presentation',
|
|
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
|
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
|
'text/*',
|
|
].join(',');
|
|
|
|
export function ComposerUploadButton(props: {
|
|
accept: string;
|
|
active?: boolean;
|
|
disabled?: boolean;
|
|
uploading?: boolean;
|
|
onFiles?: (files: File[]) => void;
|
|
}) {
|
|
const inputRef = useRef<HTMLInputElement>(null);
|
|
const disabled = props.disabled || props.uploading;
|
|
|
|
return (
|
|
<>
|
|
<button
|
|
type="button"
|
|
className="composerUpload"
|
|
aria-label="上传附件"
|
|
data-active={props.active === true}
|
|
disabled={disabled}
|
|
onClick={() => inputRef.current?.click()}
|
|
>
|
|
{props.uploading ? <LoaderCircle className="composerUploadSpinner" size={18} /> : <Paperclip size={18} />}
|
|
</button>
|
|
<input
|
|
ref={inputRef}
|
|
type="file"
|
|
multiple
|
|
hidden
|
|
accept={props.accept}
|
|
disabled={disabled}
|
|
onChange={(event) => {
|
|
const files = Array.from(event.currentTarget.files ?? []);
|
|
event.currentTarget.value = '';
|
|
props.onFiles?.(files);
|
|
}}
|
|
/>
|
|
</>
|
|
);
|
|
}
|
|
|
|
export function PlaygroundReferencePicker(props: {
|
|
accept: string;
|
|
disabled?: boolean;
|
|
mode: PlaygroundMode;
|
|
uploadLabel?: string;
|
|
uploads: PlaygroundUpload[];
|
|
uploading?: boolean;
|
|
videoMode?: PlaygroundVideoCreateMode;
|
|
onFiles?: (files: File[], targetRole?: PlaygroundUploadRole) => void;
|
|
onRemove?: (id: string) => void;
|
|
onSwapFrames?: () => void;
|
|
}) {
|
|
if (props.mode === 'video' && props.videoMode === 'first_last_frame') {
|
|
return (
|
|
<FirstLastFramePicker
|
|
accept={props.accept}
|
|
disabled={props.disabled}
|
|
uploads={props.uploads}
|
|
uploading={props.uploading}
|
|
onFiles={props.onFiles}
|
|
onRemove={props.onRemove}
|
|
onSwapFrames={props.onSwapFrames}
|
|
/>
|
|
);
|
|
}
|
|
return (
|
|
<StackedReferencePicker
|
|
accept={props.accept}
|
|
disabled={props.disabled}
|
|
uploadLabel={props.uploadLabel ?? (props.mode === 'chat' ? '上传附件' : '参考内容')}
|
|
uploads={props.uploads}
|
|
uploading={props.uploading}
|
|
onFiles={props.onFiles}
|
|
onRemove={props.onRemove}
|
|
/>
|
|
);
|
|
}
|
|
|
|
function StackedReferencePicker(props: {
|
|
accept: string;
|
|
disabled?: boolean;
|
|
uploadLabel: string;
|
|
uploads: PlaygroundUpload[];
|
|
uploading?: boolean;
|
|
onFiles?: (files: File[]) => void;
|
|
onRemove?: (id: string) => void;
|
|
}) {
|
|
const inputRef = useRef<HTMLInputElement>(null);
|
|
const [hoveredId, setHoveredId] = useState('');
|
|
const [expanded, setExpanded] = useState(false);
|
|
const hoveredUpload = props.uploads.find((item) => item.id === hoveredId);
|
|
const disabled = props.disabled || props.uploading || !props.onFiles;
|
|
const uploadCardIndex = props.uploads.length;
|
|
|
|
return (
|
|
<div
|
|
className="mediaReferenceStack"
|
|
data-expanded={expanded}
|
|
onMouseLeave={() => {
|
|
setExpanded(false);
|
|
setHoveredId('');
|
|
}}
|
|
>
|
|
{hoveredUpload && <div className="mediaReferenceTooltip">{hoveredUpload.name}</div>}
|
|
<div
|
|
className="mediaReferenceStackCards"
|
|
data-empty={!props.uploads.length}
|
|
style={{ '--reference-count': Math.max(1, props.uploads.length + 1) } as CSSProperties}
|
|
>
|
|
{props.uploads.map((item, index) => (
|
|
<div
|
|
className="mediaReferenceCard"
|
|
data-hovered={hoveredId === item.id}
|
|
data-kind={item.kind}
|
|
key={item.id}
|
|
style={referenceCardStyle(index)}
|
|
title={item.name}
|
|
onMouseEnter={() => {
|
|
setExpanded(true);
|
|
setHoveredId(item.id);
|
|
}}
|
|
>
|
|
<ReferencePreview item={item} />
|
|
{item.kind !== 'image' && <span className="mediaReferenceDuration">{uploadKindLabel(item.kind)}</span>}
|
|
{props.onRemove && hoveredId === item.id && (
|
|
<button type="button" className="mediaReferenceRemove" aria-label={`删除 ${item.name}`} onClick={() => props.onRemove?.(item.id)}>
|
|
<X size={13} />
|
|
</button>
|
|
)}
|
|
</div>
|
|
))}
|
|
<button
|
|
type="button"
|
|
className="mediaReferenceCard mediaReferenceUploadCard"
|
|
aria-label={props.uploadLabel}
|
|
data-has-uploads={props.uploads.length > 0}
|
|
data-uploading={Boolean(props.uploading)}
|
|
disabled={disabled}
|
|
style={referenceCardStyle(uploadCardIndex)}
|
|
title={props.uploadLabel}
|
|
onClick={() => inputRef.current?.click()}
|
|
onMouseEnter={() => setHoveredId('')}
|
|
>
|
|
{props.uploading ? <LoaderCircle className="composerUploadSpinner" size={18} /> : <Plus size={20} />}
|
|
<span>{props.uploadLabel}</span>
|
|
</button>
|
|
{props.uploads.length > 0 && (
|
|
<button
|
|
type="button"
|
|
className="mediaReferenceAdd"
|
|
aria-label={props.uploadLabel}
|
|
data-uploading={Boolean(props.uploading)}
|
|
disabled={disabled}
|
|
title={props.uploadLabel}
|
|
onClick={() => inputRef.current?.click()}
|
|
onMouseEnter={() => {
|
|
setExpanded(false);
|
|
setHoveredId('');
|
|
}}
|
|
>
|
|
{props.uploading ? <LoaderCircle className="composerUploadSpinner" size={15} /> : <Plus size={17} />}
|
|
</button>
|
|
)}
|
|
</div>
|
|
<input
|
|
ref={inputRef}
|
|
type="file"
|
|
multiple
|
|
hidden
|
|
accept={props.accept}
|
|
disabled={disabled}
|
|
onChange={(event) => {
|
|
const files = Array.from(event.currentTarget.files ?? []);
|
|
event.currentTarget.value = '';
|
|
props.onFiles?.(files);
|
|
}}
|
|
/>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
const referenceTiltValues = [-7, 6, -3, 8, -5, 4, -8, 5];
|
|
const referenceXValues = [0, -5, 6, -3, 4, -6, 3, -4];
|
|
const referenceYValues = [0, 3, -1, 4, 1, 5, 2, -2];
|
|
|
|
function referenceCardStyle(index: number) {
|
|
const valueIndex = index % referenceTiltValues.length;
|
|
return {
|
|
'--reference-index': index,
|
|
'--reference-tilt': `${referenceTiltValues[valueIndex]}deg`,
|
|
'--reference-x': `${referenceXValues[valueIndex]}px`,
|
|
'--reference-y': `${referenceYValues[valueIndex]}px`,
|
|
} as CSSProperties;
|
|
}
|
|
|
|
function FirstLastFramePicker(props: {
|
|
accept: string;
|
|
disabled?: boolean;
|
|
uploads: PlaygroundUpload[];
|
|
uploading?: boolean;
|
|
onFiles?: (files: File[], targetRole?: PlaygroundUploadRole) => void;
|
|
onRemove?: (id: string) => void;
|
|
onSwapFrames?: () => void;
|
|
}) {
|
|
const first = frameUploadByRole(props.uploads, 'first_frame');
|
|
const last = frameUploadByRole(props.uploads, 'last_frame');
|
|
const canSwap = Boolean(first && last);
|
|
|
|
return (
|
|
<div className="firstLastFramePicker">
|
|
<FrameSlot
|
|
accept={props.accept}
|
|
disabled={props.disabled}
|
|
item={first}
|
|
label="首帧"
|
|
role="first_frame"
|
|
uploading={props.uploading}
|
|
onFiles={props.onFiles}
|
|
onRemove={props.onRemove}
|
|
/>
|
|
<button type="button" className="frameSwapButton" aria-label="交换首尾帧" disabled={!canSwap} onClick={props.onSwapFrames}>
|
|
<Repeat2 size={19} />
|
|
</button>
|
|
<FrameSlot
|
|
accept={props.accept}
|
|
disabled={props.disabled}
|
|
item={last}
|
|
label="尾帧"
|
|
role="last_frame"
|
|
uploading={props.uploading}
|
|
onFiles={props.onFiles}
|
|
onRemove={props.onRemove}
|
|
/>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function FrameSlot(props: {
|
|
accept: string;
|
|
disabled?: boolean;
|
|
item?: PlaygroundUpload;
|
|
label: string;
|
|
role: PlaygroundUploadRole;
|
|
uploading?: boolean;
|
|
onFiles?: (files: File[], targetRole?: PlaygroundUploadRole) => void;
|
|
onRemove?: (id: string) => void;
|
|
}) {
|
|
const inputRef = useRef<HTMLInputElement>(null);
|
|
const disabled = props.disabled || props.uploading || !props.onFiles;
|
|
|
|
return (
|
|
<div className="frameSlot">
|
|
<button
|
|
type="button"
|
|
className="frameSlotButton"
|
|
data-filled={Boolean(props.item)}
|
|
disabled={disabled}
|
|
title={props.item?.name ?? props.label}
|
|
onClick={() => inputRef.current?.click()}
|
|
>
|
|
{props.item ? <ReferencePreview item={props.item} /> : <Plus size={20} />}
|
|
<span>{props.label}</span>
|
|
</button>
|
|
{props.item && props.onRemove && (
|
|
<button type="button" className="frameSlotRemove" aria-label={`删除 ${props.label}`} onClick={() => props.onRemove?.(props.item!.id)}>
|
|
<X size={13} />
|
|
</button>
|
|
)}
|
|
<input
|
|
ref={inputRef}
|
|
type="file"
|
|
hidden
|
|
accept={props.accept}
|
|
disabled={disabled}
|
|
onChange={(event) => {
|
|
const files = Array.from(event.currentTarget.files ?? []);
|
|
event.currentTarget.value = '';
|
|
props.onFiles?.(files, props.role);
|
|
}}
|
|
/>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function ReferencePreview(props: { item: PlaygroundUpload }) {
|
|
if (props.item.kind === 'image') {
|
|
return <img src={props.item.url} alt="" draggable={false} />;
|
|
}
|
|
if (props.item.kind === 'video') {
|
|
return <video src={props.item.url} muted playsInline preload="metadata" />;
|
|
}
|
|
if (props.item.kind === 'audio') {
|
|
return (
|
|
<span className="mediaReferencePlaceholder">
|
|
<Music2 size={18} />
|
|
</span>
|
|
);
|
|
}
|
|
return (
|
|
<span className="mediaReferencePlaceholder">
|
|
<FileText size={18} />
|
|
</span>
|
|
);
|
|
}
|
|
|
|
export function UploadAttachmentList(props: {
|
|
message?: string;
|
|
uploads: PlaygroundUpload[];
|
|
onRemove?: (id: string) => void;
|
|
}) {
|
|
if (!props.uploads.length && !props.message) return null;
|
|
return (
|
|
<div className="composerUploadArea">
|
|
{props.uploads.length > 0 && (
|
|
<div className="composerUploadList">
|
|
{props.uploads.map((item) => (
|
|
<span className="composerUploadChip" key={item.id} title={`${item.name} · ${item.url}`}>
|
|
{uploadKindIcon(item.kind)}
|
|
<span>{item.name}</span>
|
|
<small>{formatFileSize(item.size)}</small>
|
|
{props.onRemove && (
|
|
<button type="button" aria-label={`移除 ${item.name}`} onClick={() => props.onRemove?.(item.id)}>
|
|
<X size={13} />
|
|
</button>
|
|
)}
|
|
</span>
|
|
))}
|
|
</div>
|
|
)}
|
|
{props.message && <div className="composerUploadMessage">{props.message}</div>}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function uploadKindIcon(kind: PlaygroundUploadKind) {
|
|
if (kind === 'image') return <ImageIcon size={14} />;
|
|
if (kind === 'video') return <Video size={14} />;
|
|
if (kind === 'audio') return <Music2 size={14} />;
|
|
return <FileText size={14} />;
|
|
}
|
|
|
|
export async function uploadPlaygroundFiles(
|
|
token: string,
|
|
files: File[],
|
|
options: { allowFiles: boolean; allowedKinds?: PlaygroundUploadKind[]; source: string },
|
|
): Promise<{ items: PlaygroundUpload[]; warnings: string[] }> {
|
|
const allowedKinds = options.allowedKinds ?? (options.allowFiles ? ['audio', 'file', 'image', 'video'] : ['audio', 'image', 'video']);
|
|
const accepted: Array<{ file: File; kind: PlaygroundUploadKind }> = [];
|
|
const warnings: string[] = [];
|
|
files.forEach((file) => {
|
|
const kind = acceptedUploadKind(file, options.allowFiles);
|
|
if (!kind || !allowedKinds.includes(kind)) {
|
|
warnings.push(options.allowFiles
|
|
? `已跳过 ${file.name},聊天仅支持图片、视频、音频和常见文档。`
|
|
: `已跳过 ${file.name},当前场景仅支持${allowedUploadKindLabel(allowedKinds)}。`);
|
|
return;
|
|
}
|
|
accepted.push({ file, kind });
|
|
});
|
|
if (!accepted.length) return { items: [], warnings };
|
|
const items = await Promise.all(accepted.map(async ({ file, kind }) => {
|
|
const response = await uploadFileToStorage(token, file, options.source);
|
|
const url = uploadResponseUrl(response);
|
|
if (!url) {
|
|
throw new Error(`${file.name} 上传成功,但网关没有返回可用文件 URL。`);
|
|
}
|
|
return {
|
|
contentType: file.type || '',
|
|
id: newLocalId(),
|
|
kind,
|
|
name: file.name || '未命名文件',
|
|
raw: response,
|
|
size: file.size,
|
|
url,
|
|
};
|
|
}));
|
|
return { items, warnings };
|
|
}
|
|
|
|
function acceptedUploadKind(file: File, allowFiles: boolean): PlaygroundUploadKind | undefined {
|
|
const mime = file.type.toLowerCase();
|
|
const extension = fileExtension(file.name);
|
|
if (mime.startsWith('image/') || imageExtensions.has(extension)) return 'image';
|
|
if (mime.startsWith('video/') || videoExtensions.has(extension)) return 'video';
|
|
if (mime.startsWith('audio/') || audioExtensions.has(extension)) return 'audio';
|
|
if (allowFiles && (documentExtensions.has(extension) || documentMimes.has(mime))) return 'file';
|
|
return undefined;
|
|
}
|
|
|
|
const imageExtensions = new Set(['avif', 'bmp', 'gif', 'heic', 'heif', 'jpeg', 'jpg', 'png', 'svg', 'tif', 'tiff', 'webp']);
|
|
const videoExtensions = new Set(['avi', 'm4v', 'mkv', 'mov', 'mp4', 'mpeg', 'mpg', 'webm']);
|
|
const audioExtensions = new Set(['aac', 'flac', 'm4a', 'mp3', 'oga', 'ogg', 'opus', 'wav', 'weba']);
|
|
const documentExtensions = new Set(['csv', 'doc', 'docx', 'json', 'jsonl', 'md', 'markdown', 'pdf', 'ppt', 'pptx', 'txt', 'xls', 'xlsx', 'yaml', 'yml']);
|
|
const documentMimes = new Set([
|
|
'application/json',
|
|
'application/msword',
|
|
'application/pdf',
|
|
'application/vnd.ms-excel',
|
|
'application/vnd.ms-powerpoint',
|
|
'application/vnd.openxmlformats-officedocument.presentationml.presentation',
|
|
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
|
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
|
'text/csv',
|
|
'text/markdown',
|
|
'text/plain',
|
|
'text/yaml',
|
|
]);
|
|
|
|
function fileExtension(name: string) {
|
|
const index = name.lastIndexOf('.');
|
|
return index >= 0 ? name.slice(index + 1).toLowerCase() : '';
|
|
}
|
|
|
|
function uploadResponseUrl(response: Record<string, unknown>) {
|
|
const data = recordFromUnknown(response.data);
|
|
const file = recordFromUnknown(response.file);
|
|
const result = recordFromUnknown(response.result);
|
|
return firstString(
|
|
response.url,
|
|
response.fileUrl,
|
|
response.file_url,
|
|
response.objectUrl,
|
|
response.object_url,
|
|
response.downloadUrl,
|
|
response.download_url,
|
|
data?.url,
|
|
data?.fileUrl,
|
|
data?.file_url,
|
|
file?.url,
|
|
file?.fileUrl,
|
|
file?.file_url,
|
|
result?.url,
|
|
result?.fileUrl,
|
|
result?.file_url,
|
|
);
|
|
}
|
|
|
|
export function openAIContentFromPromptAndUploads(prompt: string, uploads: PlaygroundUpload[]): OpenAIChatContentPart[] {
|
|
const content: OpenAIChatContentPart[] = [];
|
|
const text = prompt.trim();
|
|
if (text) {
|
|
content.push({ type: 'text', text });
|
|
}
|
|
uploads.forEach((item) => {
|
|
const part = openAIContentPartFromUpload(item);
|
|
if (part) content.push(part);
|
|
});
|
|
return content.length ? content : [{ type: 'text', text: '' }];
|
|
}
|
|
|
|
function openAIContentPartFromUpload(item: PlaygroundUpload): OpenAIChatContentPart | undefined {
|
|
if (!item.url) return undefined;
|
|
if (item.kind === 'image') return { type: 'image_url', image_url: { url: item.url } };
|
|
if (item.kind === 'video') return { type: 'video_url', video_url: { url: item.url } };
|
|
if (item.kind === 'audio') return { type: 'audio_url', audio_url: { url: item.url } };
|
|
return { type: 'file_url', file_url: { filename: item.name, url: item.url } };
|
|
}
|
|
|
|
export function mediaUploadRequestPayload(uploads: PlaygroundUpload[], mode: Exclude<PlaygroundMode, 'chat'>, videoMode: PlaygroundVideoCreateMode) {
|
|
const images = uploads.filter((item) => item.kind === 'image').map((item) => item.url);
|
|
const videos = uploads.filter((item) => item.kind === 'video').map((item) => item.url);
|
|
const audios = uploads.filter((item) => item.kind === 'audio').map((item) => item.url);
|
|
const payload: Record<string, string | string[]> = {};
|
|
if (mode === 'image') {
|
|
if (images.length) {
|
|
payload.image = singleOrMany(images);
|
|
payload.images = images;
|
|
}
|
|
return payload;
|
|
}
|
|
if (videoMode === 'first_last_frame') {
|
|
const first = frameUploadByRole(uploads, 'first_frame');
|
|
const last = frameUploadByRole(uploads, 'last_frame');
|
|
if (first) {
|
|
payload.first_frame = first.url;
|
|
}
|
|
if (last) {
|
|
payload.last_frame = last.url;
|
|
}
|
|
return payload;
|
|
}
|
|
if (images.length) {
|
|
payload.reference_image = singleOrMany(images);
|
|
}
|
|
if (videos.length) {
|
|
payload.reference_video = singleOrMany(videos);
|
|
}
|
|
if (audios.length) {
|
|
payload.reference_audio = singleOrMany(audios);
|
|
}
|
|
return payload;
|
|
}
|
|
|
|
function singleOrMany(values: string[]) {
|
|
return values.length === 1 ? values[0] : values;
|
|
}
|
|
|
|
export function uploadKindLabel(kind: PlaygroundUploadKind) {
|
|
if (kind === 'image') return '图片';
|
|
if (kind === 'video') return '视频';
|
|
if (kind === 'audio') return '音频';
|
|
return '文件';
|
|
}
|
|
|
|
export function allowedUploadKindLabel(kinds: PlaygroundUploadKind[]) {
|
|
const labels = kinds.map(uploadKindLabel);
|
|
return labels.length ? labels.join('、') : '当前文件类型';
|
|
}
|
|
|
|
export function formatFileSize(size: number) {
|
|
if (!Number.isFinite(size) || size <= 0) return '';
|
|
if (size < 1024) return `${size} B`;
|
|
if (size < 1024 * 1024) return `${(size / 1024).toFixed(1)} KB`;
|
|
return `${(size / 1024 / 1024).toFixed(1)} MB`;
|
|
}
|
|
|
|
export function mediaUploadAcceptForMode(mode: PlaygroundMode, videoMode: PlaygroundVideoCreateMode) {
|
|
if (mode === 'image') return imageOnlyUploadAccept;
|
|
if (mode === 'video' && videoMode === 'first_last_frame') return imageOnlyUploadAccept;
|
|
return mediaUploadAccept;
|
|
}
|
|
|
|
export function allowedMediaUploadKinds(mode: PlaygroundMode, videoMode: PlaygroundVideoCreateMode): PlaygroundUploadKind[] {
|
|
if (mode === 'image') return ['image'];
|
|
if (mode === 'video' && videoMode === 'first_last_frame') return ['image'];
|
|
if (mode === 'video') return ['audio', 'image', 'video'];
|
|
return ['audio', 'file', 'image', 'video'];
|
|
}
|
|
|
|
export function mediaUploadSummaryMessage(uploads: PlaygroundUpload[], mode: PlaygroundMode, videoMode: PlaygroundVideoCreateMode) {
|
|
if (!uploads.length) return '';
|
|
const images = uploads.filter((item) => item.kind === 'image').length;
|
|
const videos = uploads.filter((item) => item.kind === 'video').length;
|
|
const audios = uploads.filter((item) => item.kind === 'audio').length;
|
|
const files = uploads.filter((item) => item.kind === 'file').length;
|
|
if (mode === 'image') {
|
|
return `已上传 ${images} 张参考图。`;
|
|
}
|
|
if (mode === 'video' && videoMode === 'first_last_frame') {
|
|
const first = frameUploadByRole(uploads, 'first_frame');
|
|
const last = frameUploadByRole(uploads, 'last_frame');
|
|
if (first && last) return '已上传首帧、尾帧参考图。';
|
|
if (first) return '已上传首帧参考图。';
|
|
if (last) return '已上传尾帧参考图。';
|
|
return `已上传 ${images} 张首尾帧参考图。`;
|
|
}
|
|
const parts = [
|
|
images ? `${images} 张图片` : '',
|
|
videos ? `${videos} 个视频` : '',
|
|
audios ? `${audios} 段音频` : '',
|
|
files ? `${files} 个文件` : '',
|
|
].filter(Boolean);
|
|
return parts.length ? `已上传 ${parts.join('、')}。` : '';
|
|
}
|
|
|
|
export function mergeMediaUploadsForMode(
|
|
current: PlaygroundUpload[],
|
|
items: PlaygroundUpload[],
|
|
mode: PlaygroundMode,
|
|
videoMode: PlaygroundVideoCreateMode,
|
|
targetRole?: PlaygroundUploadRole,
|
|
) {
|
|
if (mode === 'image') {
|
|
return [...current.filter((item) => item.kind === 'image'), ...items.filter((item) => item.kind === 'image')];
|
|
}
|
|
if (mode === 'video' && videoMode === 'first_last_frame') {
|
|
return mergeFirstLastFrameUploads(current, items, targetRole);
|
|
}
|
|
if (mode === 'video') {
|
|
return [...current, ...items.filter((item) => item.kind === 'image' || item.kind === 'video' || item.kind === 'audio')];
|
|
}
|
|
return [...current, ...items];
|
|
}
|
|
|
|
export function normalizeFirstLastFrameUploads(uploads: PlaygroundUpload[]) {
|
|
const images = uploads.filter((item) => item.kind === 'image');
|
|
if (!images.length) return uploads.length ? [] : uploads;
|
|
const first = frameUploadByRole(images, 'first_frame') ?? images[0];
|
|
const last = frameUploadByRole(images, 'last_frame') ?? images.find((item) => item.id !== first?.id);
|
|
const next: PlaygroundUpload[] = [];
|
|
if (first) next.push({ ...first, role: 'first_frame' });
|
|
if (last) next.push({ ...last, role: 'last_frame' });
|
|
return uploadListsEqual(uploads, next) ? uploads : next;
|
|
}
|
|
|
|
function mergeFirstLastFrameUploads(current: PlaygroundUpload[], items: PlaygroundUpload[], targetRole?: PlaygroundUploadRole) {
|
|
const incoming = items.filter((item) => item.kind === 'image');
|
|
let next = normalizeFirstLastFrameUploads(current);
|
|
if (!incoming.length) return next;
|
|
const assignUpload = (item: PlaygroundUpload, role: PlaygroundUploadRole) => {
|
|
next = next.filter((upload) => upload.role !== role);
|
|
next.push({ ...item, role });
|
|
};
|
|
if (targetRole) {
|
|
assignUpload(incoming[0]!, targetRole);
|
|
const oppositeRole: PlaygroundUploadRole = targetRole === 'first_frame' ? 'last_frame' : 'first_frame';
|
|
incoming.slice(1).forEach((item) => {
|
|
if (!frameUploadByRole(next, oppositeRole)) assignUpload(item, oppositeRole);
|
|
});
|
|
return sortFrameUploads(next);
|
|
}
|
|
incoming.forEach((item) => {
|
|
if (!frameUploadByRole(next, 'first_frame')) {
|
|
assignUpload(item, 'first_frame');
|
|
} else if (!frameUploadByRole(next, 'last_frame')) {
|
|
assignUpload(item, 'last_frame');
|
|
}
|
|
});
|
|
return sortFrameUploads(next);
|
|
}
|
|
|
|
export function swapFirstLastFrameUploads(uploads: PlaygroundUpload[]) {
|
|
return sortFrameUploads(uploads.map((item) => {
|
|
if (item.role === 'first_frame') return { ...item, role: 'last_frame' as const };
|
|
if (item.role === 'last_frame') return { ...item, role: 'first_frame' as const };
|
|
return item;
|
|
}));
|
|
}
|
|
|
|
function sortFrameUploads(uploads: PlaygroundUpload[]) {
|
|
const first = frameUploadByRole(uploads, 'first_frame');
|
|
const last = frameUploadByRole(uploads, 'last_frame');
|
|
return [first, last].filter((item): item is PlaygroundUpload => Boolean(item));
|
|
}
|
|
|
|
export function frameUploadByRole(uploads: PlaygroundUpload[], role: PlaygroundUploadRole) {
|
|
return uploads.find((item) => item.role === role);
|
|
}
|
|
|
|
function uploadListsEqual(left: PlaygroundUpload[], right: PlaygroundUpload[]) {
|
|
if (left.length !== right.length) return false;
|
|
return left.every((item, index) => {
|
|
const next = right[index];
|
|
return next && item.id === next.id && item.role === next.role && item.kind === next.kind;
|
|
});
|
|
}
|
|
|
|
function recordFromUnknown(value: unknown): Record<string, unknown> | undefined {
|
|
if (!value || typeof value !== 'object' || Array.isArray(value)) return undefined;
|
|
return value as Record<string, unknown>;
|
|
}
|
|
|
|
function firstString(...values: unknown[]) {
|
|
for (const value of values) {
|
|
const text = typeof value === 'string' ? value.trim() : '';
|
|
if (text) return text;
|
|
}
|
|
return '';
|
|
}
|
|
|
|
function newLocalId() {
|
|
return typeof crypto !== 'undefined' && 'randomUUID' in crypto
|
|
? crypto.randomUUID()
|
|
: `${Date.now()}-${Math.random().toString(36).slice(2)}`;
|
|
}
|