All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 9m47s
683 lines
22 KiB
TypeScript
683 lines
22 KiB
TypeScript
import { create } from 'zustand';
|
||
import type { CreationMode, ModelOption, AspectRatio, Duration, Resolution, GenerationType, UploadedFile } from '../types';
|
||
import { showToast } from '../components/Toast';
|
||
import { mediaApi, tosThumb } from '../lib/api';
|
||
import { parseAssetMentions } from '../lib/assetMentions';
|
||
|
||
let fileCounter = 0;
|
||
|
||
/** Read image dimensions via HTML5 Image element. */
|
||
function getImageDimensions(file: File): Promise<{ width: number; height: number }> {
|
||
return new Promise((resolve, reject) => {
|
||
const img = new Image();
|
||
const url = URL.createObjectURL(file);
|
||
const cleanup = () => URL.revokeObjectURL(url);
|
||
const timeout = setTimeout(() => { cleanup(); reject(new Error('timeout')); }, 10000);
|
||
img.onload = () => {
|
||
clearTimeout(timeout);
|
||
resolve({ width: img.naturalWidth, height: img.naturalHeight });
|
||
cleanup();
|
||
};
|
||
img.onerror = () => {
|
||
clearTimeout(timeout);
|
||
reject(new Error('无法读取图片'));
|
||
cleanup();
|
||
};
|
||
img.src = url;
|
||
});
|
||
}
|
||
|
||
/** Read duration (and dimensions for video) of an audio or video file. */
|
||
interface MediaInfo {
|
||
duration: number;
|
||
width?: number;
|
||
height?: number;
|
||
}
|
||
|
||
function getMediaInfo(file: File): Promise<MediaInfo> {
|
||
return new Promise((resolve, reject) => {
|
||
const isAudio = file.type.startsWith('audio/');
|
||
const el = isAudio ? new Audio() : document.createElement('video');
|
||
const url = URL.createObjectURL(file);
|
||
const cleanup = () => URL.revokeObjectURL(url);
|
||
const timeout = setTimeout(() => { cleanup(); reject(new Error('timeout')); }, 10000);
|
||
el.addEventListener('loadedmetadata', () => {
|
||
clearTimeout(timeout);
|
||
if (isAudio) {
|
||
resolve({ duration: el.duration });
|
||
} else {
|
||
const vid = el as HTMLVideoElement;
|
||
resolve({ duration: vid.duration, width: vid.videoWidth, height: vid.videoHeight });
|
||
}
|
||
cleanup();
|
||
});
|
||
el.addEventListener('error', () => {
|
||
clearTimeout(timeout);
|
||
reject(new Error('无法读取媒体文件'));
|
||
cleanup();
|
||
});
|
||
el.src = url;
|
||
});
|
||
}
|
||
|
||
// API limits per Seedance 2.0 official doc
|
||
const MAX_IMAGES = 9;
|
||
const MAX_VIDEOS = 3;
|
||
const MAX_AUDIO = 3;
|
||
|
||
function isBlobUrl(url?: string): boolean {
|
||
return !!url && url.startsWith('blob:');
|
||
}
|
||
|
||
function revokePreviewUrl(url?: string) {
|
||
if (url && isBlobUrl(url)) URL.revokeObjectURL(url);
|
||
}
|
||
|
||
function syncEditorReferencePreview(editorHtml: string, refId: string, previewUrl: string): string {
|
||
if (!editorHtml) return editorHtml;
|
||
const doc = new DOMParser().parseFromString(`<div>${editorHtml}</div>`, 'text/html');
|
||
const container = doc.body.firstElementChild as HTMLElement | null;
|
||
if (!container) return editorHtml;
|
||
|
||
let changed = false;
|
||
container.querySelectorAll('[data-ref-id]').forEach((span) => {
|
||
const el = span as HTMLElement;
|
||
if (el.dataset.refId !== refId || el.dataset.refType === 'asset') return;
|
||
el.dataset.thumbUrl = previewUrl;
|
||
if (el.dataset.refType !== 'audio') {
|
||
let img = el.querySelector('img') as HTMLImageElement | null;
|
||
if (!img) {
|
||
const newImg = document.createElement('img');
|
||
newImg.className = 'mentionImg';
|
||
newImg.setAttribute('width', '16');
|
||
newImg.setAttribute('height', '16');
|
||
newImg.style.cssText = 'width:16px;height:16px;border-radius:3px;object-fit:cover;vertical-align:middle;margin-right:3px;display:inline-block;pointer-events:none';
|
||
newImg.onerror = () => { newImg.style.display = 'none'; };
|
||
el.insertBefore(newImg, el.firstChild);
|
||
img = newImg;
|
||
}
|
||
img.src = tosThumb(previewUrl, 32);
|
||
}
|
||
changed = true;
|
||
});
|
||
|
||
return changed ? container.innerHTML : editorHtml;
|
||
}
|
||
|
||
function markReferenceUploadSuccess(
|
||
refs: UploadedFile[],
|
||
refId: string,
|
||
uploadedUrl: string,
|
||
blobUrlsToRevoke: Set<string>,
|
||
): UploadedFile[] {
|
||
return refs.map((ref) => {
|
||
if (ref.id !== refId) return ref;
|
||
if (isBlobUrl(ref.previewUrl)) blobUrlsToRevoke.add(ref.previewUrl);
|
||
return {
|
||
...ref,
|
||
tosUrl: uploadedUrl,
|
||
previewUrl: uploadedUrl,
|
||
uploading: false,
|
||
uploadError: false,
|
||
};
|
||
});
|
||
}
|
||
|
||
function markReferenceUploadError(refs: UploadedFile[], refId: string): UploadedFile[] {
|
||
return refs.map((ref) =>
|
||
ref.id === refId ? { ...ref, uploading: false, uploadError: true } : ref
|
||
);
|
||
}
|
||
|
||
interface InputBarState {
|
||
// Generation type
|
||
generationType: GenerationType;
|
||
setGenerationType: (type: GenerationType) => void;
|
||
|
||
// Mode
|
||
mode: CreationMode;
|
||
setMode: (mode: CreationMode) => void;
|
||
|
||
// Model
|
||
model: ModelOption;
|
||
setModel: (model: ModelOption) => void;
|
||
|
||
// Aspect ratio
|
||
aspectRatio: AspectRatio;
|
||
setAspectRatio: (ratio: AspectRatio) => void;
|
||
prevAspectRatio: AspectRatio;
|
||
|
||
// Duration
|
||
duration: Duration;
|
||
setDuration: (duration: Duration) => void;
|
||
prevDuration: Duration;
|
||
|
||
// Resolution (480p/720p/1080p) — 1080p 仅 Seedance 2.0 支持
|
||
resolution: Resolution;
|
||
setResolution: (resolution: Resolution) => void;
|
||
|
||
// Prompt
|
||
prompt: string;
|
||
setPrompt: (prompt: string) => void;
|
||
|
||
// Editor HTML (rich content with mention tags)
|
||
editorHtml: string;
|
||
setEditorHtml: (html: string) => void;
|
||
|
||
// Universal references
|
||
references: UploadedFile[];
|
||
prevReferences: UploadedFile[];
|
||
addReferences: (files: File[]) => void;
|
||
removeReference: (id: string) => void;
|
||
clearReferences: () => void;
|
||
retryUpload: (refId: string) => void;
|
||
|
||
// Keyframe
|
||
firstFrame: UploadedFile | null;
|
||
lastFrame: UploadedFile | null;
|
||
setFirstFrame: (file: File | null) => void;
|
||
setLastFrame: (file: File | null) => void;
|
||
|
||
// Computed
|
||
canSubmit: () => boolean;
|
||
|
||
// Search mode (联网搜索)
|
||
searchMode: 'smart' | 'off';
|
||
setSearchMode: (mode: 'smart' | 'off') => void;
|
||
|
||
// Seed (种子值)
|
||
seed: number;
|
||
seedEnabled: boolean;
|
||
setSeed: (seed: number) => void;
|
||
setSeedEnabled: (enabled: boolean) => void;
|
||
|
||
// Asset mentions (for reEdit/regenerate to pass asset data to PromptInput rebuild)
|
||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||
assetMentions: Record<string, any>[];
|
||
|
||
// @ trigger (for toolbar button to insert @ in contentEditable)
|
||
insertAtTrigger: number;
|
||
triggerInsertAt: () => void;
|
||
|
||
// Actions
|
||
switchMode: (mode: CreationMode) => void;
|
||
submit: () => void;
|
||
reset: () => void;
|
||
}
|
||
|
||
export const useInputBarStore = create<InputBarState>((set, get) => ({
|
||
generationType: 'video',
|
||
setGenerationType: (generationType) => set({ generationType }),
|
||
|
||
mode: 'universal',
|
||
setMode: (mode) => set({ mode }),
|
||
|
||
model: 'seedance_2.0',
|
||
setModel: (model) => {
|
||
// Fast + 1080P 为非法组合(官方文档约束)。UI Dropdown 已对 Fast 项置灰,
|
||
// 此处为 UI 被绕过时的防御性拦截(depth defense),不做静默降级:
|
||
// 阻止切换 + toast 引导用户手动改分辨率,让用户选择始终被尊重。
|
||
const state = get();
|
||
if (model === 'seedance_2.0_fast' && state.resolution === '1080p') {
|
||
showToast('1080P 仅 AirDrama 模型支持,请先切换分辨率到 720P 或 480P');
|
||
return;
|
||
}
|
||
set({ model });
|
||
},
|
||
|
||
aspectRatio: '21:9',
|
||
setAspectRatio: (aspectRatio) => set({ aspectRatio, prevAspectRatio: aspectRatio }),
|
||
prevAspectRatio: '21:9',
|
||
|
||
duration: 15,
|
||
setDuration: (duration) => {
|
||
const state = get();
|
||
if (state.mode === 'universal') {
|
||
set({ duration, prevDuration: duration });
|
||
} else {
|
||
set({ duration });
|
||
}
|
||
},
|
||
prevDuration: 15,
|
||
|
||
resolution: '720p' as Resolution,
|
||
setResolution: (resolution) => {
|
||
// Fast + 1080P 非法组合(对称 setModel 的拦截)— UI Dropdown 已置灰,此处防御性拦截
|
||
const state = get();
|
||
if (resolution === '1080p' && state.model === 'seedance_2.0_fast') {
|
||
showToast('AirDrama Fast 不支持 1080P,请先切换模型到 AirDrama');
|
||
return;
|
||
}
|
||
set({ resolution });
|
||
},
|
||
|
||
prompt: '',
|
||
setPrompt: (prompt) => set({ prompt }),
|
||
|
||
editorHtml: '',
|
||
setEditorHtml: (editorHtml) => set({ editorHtml }),
|
||
|
||
references: [],
|
||
prevReferences: [],
|
||
addReferences: (files) => {
|
||
const state = get();
|
||
// Count existing references by type + merge @ asset mentions
|
||
const counts = { image: 0, video: 0, audio: 0 };
|
||
for (const ref of state.references) counts[ref.type]++;
|
||
const { counts: assetCounts } = parseAssetMentions(state.editorHtml);
|
||
counts.image += assetCounts.image;
|
||
counts.video += assetCounts.video;
|
||
counts.audio += assetCounts.audio;
|
||
|
||
// Separate images (sync) from audio/video (need async duration check)
|
||
const imageFiles: File[] = [];
|
||
const mediaFiles: File[] = []; // audio + video
|
||
|
||
for (const file of files) {
|
||
const type: 'image' | 'video' | 'audio' = file.type.startsWith('video/')
|
||
? 'video'
|
||
: file.type.startsWith('audio/')
|
||
? 'audio'
|
||
: 'image';
|
||
|
||
const max = type === 'image' ? MAX_IMAGES : type === 'video' ? MAX_VIDEOS : MAX_AUDIO;
|
||
if (counts[type] >= max) {
|
||
const label = type === 'image' ? `最多上传${MAX_IMAGES}张图片` : type === 'video' ? `最多上传${MAX_VIDEOS}个视频` : `最多上传${MAX_AUDIO}个音频`;
|
||
showToast(label);
|
||
continue;
|
||
}
|
||
counts[type]++;
|
||
|
||
if (type === 'image') {
|
||
imageFiles.push(file);
|
||
} else {
|
||
mediaFiles.push(file);
|
||
}
|
||
}
|
||
|
||
// ── Async: validate image dimensions, then add + upload ──
|
||
if (imageFiles.length > 0) {
|
||
_validateAndAddImages(imageFiles);
|
||
}
|
||
|
||
// ── Async: validate audio/video duration, then add + upload ──
|
||
if (mediaFiles.length > 0) {
|
||
_validateAndAddMedia(mediaFiles);
|
||
}
|
||
},
|
||
removeReference: (id) => {
|
||
const state = get();
|
||
const removedRef = state.references.find((r) => r.id === id);
|
||
if (!removedRef) return;
|
||
revokePreviewUrl(removedRef.previewUrl);
|
||
|
||
// 删除后同类型的剩余引用按顺序重新编号(即梦逻辑:保持 1/2/3 连续)
|
||
const remaining = state.references.filter((r) => r.id !== id);
|
||
const labelPrefix = removedRef.type === 'image' ? '图片' : removedRef.type === 'video' ? '视频' : '音频';
|
||
const labelUpdates = new Map<string, string>(); // refId -> newLabel
|
||
let idx = 1;
|
||
const relabeled = remaining.map((r) => {
|
||
if (r.type !== removedRef.type) return r;
|
||
const newLabel = `${labelPrefix}${idx++}`;
|
||
if (r.label !== newLabel) labelUpdates.set(r.id, newLabel);
|
||
return r.label === newLabel ? r : { ...r, label: newLabel };
|
||
});
|
||
|
||
// 同步更新 editorHtml 里对应 refId 的 @mention span 文本
|
||
let newEditorHtml = state.editorHtml;
|
||
if (labelUpdates.size > 0 && newEditorHtml) {
|
||
const doc = new DOMParser().parseFromString(`<div>${newEditorHtml}</div>`, 'text/html');
|
||
const container = doc.body.firstChild as HTMLElement | null;
|
||
if (container) {
|
||
container.querySelectorAll('[data-ref-id]').forEach((span) => {
|
||
const el = span as HTMLElement;
|
||
const refId = el.dataset.refId;
|
||
if (refId && labelUpdates.has(refId)) {
|
||
const newLabel = labelUpdates.get(refId)!;
|
||
// span 结构:[icon/img] + atHidden(@) + textNode(label)
|
||
const labelNode = [...el.childNodes].reverse().find((n) => n.nodeType === Node.TEXT_NODE);
|
||
if (labelNode) labelNode.textContent = newLabel;
|
||
}
|
||
});
|
||
newEditorHtml = container.innerHTML;
|
||
}
|
||
}
|
||
|
||
set({ references: relabeled, editorHtml: newEditorHtml });
|
||
},
|
||
clearReferences: () => {
|
||
const state = get();
|
||
state.references.forEach((r) => revokePreviewUrl(r.previewUrl));
|
||
set({ references: [] });
|
||
},
|
||
retryUpload: (refId) => {
|
||
const ref = get().references.find((r) => r.id === refId);
|
||
if (!ref?.file) return;
|
||
set({
|
||
references: get().references.map((r) =>
|
||
r.id === refId ? { ...r, uploading: true, uploadError: false } : r
|
||
),
|
||
});
|
||
_uploadRef(refId, ref.file);
|
||
},
|
||
|
||
firstFrame: null,
|
||
lastFrame: null,
|
||
setFirstFrame: (file) => {
|
||
const state = get();
|
||
if (state.firstFrame) revokePreviewUrl(state.firstFrame.previewUrl);
|
||
if (file) {
|
||
fileCounter++;
|
||
set({
|
||
firstFrame: {
|
||
id: `first_${fileCounter}`,
|
||
file,
|
||
type: 'image',
|
||
previewUrl: URL.createObjectURL(file),
|
||
label: '首帧',
|
||
},
|
||
});
|
||
} else {
|
||
set({ firstFrame: null });
|
||
}
|
||
},
|
||
setLastFrame: (file) => {
|
||
const state = get();
|
||
if (state.lastFrame) revokePreviewUrl(state.lastFrame.previewUrl);
|
||
if (file) {
|
||
fileCounter++;
|
||
set({
|
||
lastFrame: {
|
||
id: `last_${fileCounter}`,
|
||
file,
|
||
type: 'image',
|
||
previewUrl: URL.createObjectURL(file),
|
||
label: '尾帧',
|
||
},
|
||
});
|
||
} else {
|
||
set({ lastFrame: null });
|
||
}
|
||
},
|
||
|
||
canSubmit: () => {
|
||
const state = get();
|
||
const hasText = state.prompt.trim().length > 0;
|
||
const hasFiles =
|
||
state.mode === 'universal'
|
||
? state.references.length > 0
|
||
: state.firstFrame !== null || state.lastFrame !== null;
|
||
if (!hasText && !hasFiles) return false;
|
||
// Audio cannot be the only reference — Seedance API requires image or video alongside
|
||
if (state.mode === 'universal') {
|
||
const hasAudioRef = state.references.some((r) => r.type === 'audio');
|
||
const hasAudioAsset = (state.assetMentions || []).some((m: Record<string, string>) =>
|
||
(m.assetType || '').toLowerCase() === 'audio');
|
||
if (hasAudioRef || hasAudioAsset) {
|
||
const hasImageOrVideoRef = state.references.some((r) => r.type === 'image' || r.type === 'video');
|
||
const hasImageOrVideoAsset = (state.assetMentions || []).some((m: Record<string, string>) => {
|
||
const t = (m.assetType || '').toLowerCase();
|
||
return t === 'image' || t === 'video';
|
||
});
|
||
if (!hasImageOrVideoRef && !hasImageOrVideoAsset) return false;
|
||
}
|
||
}
|
||
// Block submit if any reference is still uploading or failed
|
||
if (state.references.some((r) => r.uploading || r.uploadError)) return false;
|
||
return true;
|
||
},
|
||
|
||
searchMode: 'off',
|
||
setSearchMode: (searchMode) => set({ searchMode }),
|
||
|
||
seed: -1,
|
||
seedEnabled: false,
|
||
setSeed: (seed) => set({ seed }),
|
||
setSeedEnabled: (seedEnabled) => set({ seedEnabled, seed: -1 }),
|
||
|
||
assetMentions: [],
|
||
|
||
insertAtTrigger: 0,
|
||
triggerInsertAt: () => set((s) => ({ insertAtTrigger: s.insertAtTrigger + 1 })),
|
||
|
||
switchMode: (mode) => {
|
||
const state = get();
|
||
if (state.mode === mode) return;
|
||
|
||
if (mode === 'keyframe') {
|
||
set({
|
||
mode,
|
||
prevReferences: state.references,
|
||
references: [],
|
||
assetMentions: [],
|
||
aspectRatio: '16:9',
|
||
duration: 5,
|
||
});
|
||
} else {
|
||
// Clear keyframe
|
||
if (state.firstFrame) revokePreviewUrl(state.firstFrame.previewUrl);
|
||
if (state.lastFrame) revokePreviewUrl(state.lastFrame.previewUrl);
|
||
set({
|
||
mode,
|
||
assetMentions: [],
|
||
firstFrame: null,
|
||
lastFrame: null,
|
||
references: state.prevReferences,
|
||
prevReferences: [],
|
||
aspectRatio: state.prevAspectRatio,
|
||
duration: state.prevDuration,
|
||
});
|
||
}
|
||
},
|
||
|
||
submit: () => {
|
||
// Just show a toast - no real API call
|
||
},
|
||
|
||
reset: () => {
|
||
const state = get();
|
||
state.references.forEach((r) => revokePreviewUrl(r.previewUrl));
|
||
state.prevReferences.forEach((r) => revokePreviewUrl(r.previewUrl));
|
||
if (state.firstFrame) revokePreviewUrl(state.firstFrame.previewUrl);
|
||
if (state.lastFrame) revokePreviewUrl(state.lastFrame.previewUrl);
|
||
set({
|
||
mode: 'universal',
|
||
model: 'seedance_2.0',
|
||
aspectRatio: '21:9',
|
||
prevAspectRatio: '21:9',
|
||
duration: 15,
|
||
prevDuration: 15,
|
||
resolution: '720p',
|
||
prompt: '',
|
||
editorHtml: '',
|
||
references: [],
|
||
prevReferences: [],
|
||
assetMentions: [],
|
||
firstFrame: null,
|
||
lastFrame: null,
|
||
generationType: 'video',
|
||
});
|
||
},
|
||
}));
|
||
|
||
// ── Module-level helpers (use useInputBarStore late-binding) ──
|
||
|
||
/** Upload a single reference file to TOS and update the store. */
|
||
function _uploadRef(refId: string, file: File) {
|
||
mediaApi.upload(file).then(({ data }) => {
|
||
const blobUrlsToRevoke = new Set<string>();
|
||
useInputBarStore.setState((s) => ({
|
||
references: markReferenceUploadSuccess(s.references, refId, data.url, blobUrlsToRevoke),
|
||
prevReferences: markReferenceUploadSuccess(s.prevReferences, refId, data.url, blobUrlsToRevoke),
|
||
editorHtml: syncEditorReferencePreview(s.editorHtml, refId, data.url),
|
||
}));
|
||
blobUrlsToRevoke.forEach((url) => URL.revokeObjectURL(url));
|
||
}).catch(() => {
|
||
useInputBarStore.setState((s) => ({
|
||
references: markReferenceUploadError(s.references, refId),
|
||
prevReferences: markReferenceUploadError(s.prevReferences, refId),
|
||
}));
|
||
});
|
||
}
|
||
|
||
/** Validate image dimensions per Seedance API spec, then add to store + start upload. */
|
||
async function _validateAndAddImages(files: File[]) {
|
||
for (const file of files) {
|
||
// Read dimensions
|
||
let dims: { width: number; height: number };
|
||
try {
|
||
dims = await getImageDimensions(file);
|
||
} catch {
|
||
showToast('无法读取图片信息');
|
||
continue;
|
||
}
|
||
|
||
const { width, height } = dims;
|
||
// API spec: width/height in open interval (300, 6000)
|
||
if (width >= 6000 || height >= 6000) {
|
||
showToast(`图片尺寸过大(${width}×${height}),宽高需小于 6000 像素`);
|
||
continue;
|
||
}
|
||
if (width <= 300 || height <= 300) {
|
||
showToast(`图片尺寸过小(${width}×${height}),宽高需大于 300 像素`);
|
||
continue;
|
||
}
|
||
// API spec: aspect ratio (width/height) in open interval (0.4, 2.5)
|
||
const ratio = width / height;
|
||
if (ratio <= 0.4 || ratio >= 2.5) {
|
||
showToast(`图片比例不支持(${width}×${height}),宽高比需在 0.4 到 2.5 之间`);
|
||
continue;
|
||
}
|
||
|
||
// Re-check count
|
||
const state = useInputBarStore.getState();
|
||
const currentCount = state.references.filter((r) => r.type === 'image').length;
|
||
if (currentCount >= MAX_IMAGES) {
|
||
showToast(`最多上传${MAX_IMAGES}张图片`);
|
||
continue;
|
||
}
|
||
|
||
// Passed — add to store + upload
|
||
fileCounter++;
|
||
const existingSameType = state.references.filter(r => r.type === 'image').length
|
||
+ (state.assetMentions || []).filter((m: Record<string, string>) => m.type === 'image').length;
|
||
const refId = `ref_${fileCounter}`;
|
||
const newRef: UploadedFile = {
|
||
id: refId,
|
||
file,
|
||
type: 'image',
|
||
previewUrl: '',
|
||
label: `图片${existingSameType + 1}`,
|
||
uploading: true,
|
||
};
|
||
|
||
useInputBarStore.setState((s) => ({
|
||
references: [...s.references, newRef],
|
||
}));
|
||
|
||
_uploadRef(refId, file);
|
||
}
|
||
}
|
||
|
||
const MAX_MEDIA_DURATION = 15; // seconds per item and total
|
||
|
||
/** Validate audio/video duration (+ video dimensions), then add to store + start upload. */
|
||
async function _validateAndAddMedia(files: File[]) {
|
||
for (const file of files) {
|
||
const type: 'video' | 'audio' = file.type.startsWith('video/') ? 'video' : 'audio';
|
||
const typeLabel = type === 'video' ? '视频' : '音频';
|
||
|
||
// Read duration (+ dimensions for video)
|
||
let info: MediaInfo;
|
||
try {
|
||
info = await getMediaInfo(file);
|
||
} catch {
|
||
showToast(`无法读取${typeLabel}文件信息`);
|
||
continue;
|
||
}
|
||
|
||
const dur = info.duration;
|
||
|
||
// Single item duration check
|
||
// API specifies [2, 15]s — lower bound strict, upper bound +0.4s for codec imprecision
|
||
if (dur < 2) {
|
||
showToast(`${typeLabel}时长不能少于2秒`);
|
||
continue;
|
||
}
|
||
if (dur > MAX_MEDIA_DURATION + 0.4) {
|
||
showToast(`单条${typeLabel}时长不能超过${MAX_MEDIA_DURATION}秒`);
|
||
continue;
|
||
}
|
||
|
||
// Video dimension checks — API spec: [300, 6000]px, ratio [0.4, 2.5], pixels [409600, 927408]
|
||
if (type === 'video' && info.width && info.height) {
|
||
const { width, height } = info;
|
||
if (width > 6000 || height > 6000) {
|
||
showToast(`视频尺寸过大(${width}×${height}),宽高不能超过 6000 像素`);
|
||
continue;
|
||
}
|
||
if (width < 300 || height < 300) {
|
||
showToast(`视频尺寸过小(${width}×${height}),宽高不能小于 300 像素`);
|
||
continue;
|
||
}
|
||
const ratio = width / height;
|
||
if (ratio < 0.4 || ratio > 2.5) {
|
||
showToast(`视频比例不支持(${width}×${height}),宽高比需在 0.4 到 2.5 之间`);
|
||
continue;
|
||
}
|
||
const pixels = width * height;
|
||
if (pixels < 409600) {
|
||
showToast(`视频像素过低(${width}×${height}=${pixels.toLocaleString()}),最低需 409,600 像素`);
|
||
continue;
|
||
}
|
||
if (pixels > 927408) {
|
||
showToast(`视频像素过高(${width}×${height}=${pixels.toLocaleString()}),最高 927,408 像素`);
|
||
continue;
|
||
}
|
||
}
|
||
|
||
// Total duration check (same type) — merge @ asset mention durations
|
||
const state = useInputBarStore.getState();
|
||
const { durations: assetDurations } = parseAssetMentions(state.editorHtml);
|
||
const refDuration = state.references
|
||
.filter((r) => r.type === type && r.duration)
|
||
.reduce((sum, r) => sum + (r.duration || 0), 0);
|
||
const existingDuration = refDuration + (type === 'video' ? assetDurations.video : assetDurations.audio);
|
||
if (existingDuration + dur > MAX_MEDIA_DURATION + 0.4) {
|
||
showToast(`${typeLabel}总时长不能超过${MAX_MEDIA_DURATION}秒`);
|
||
continue;
|
||
}
|
||
|
||
// Re-check count (may have changed since initial check)
|
||
const currentCount = state.references.filter((r) => r.type === type).length;
|
||
const max = type === 'video' ? MAX_VIDEOS : MAX_AUDIO;
|
||
if (currentCount >= max) {
|
||
showToast(`最多上传${max}个${typeLabel}`);
|
||
continue;
|
||
}
|
||
|
||
// Passed all checks — add to store
|
||
fileCounter++;
|
||
const labelPrefix = type === 'video' ? '视频' : '音频';
|
||
const existingSameType = state.references.filter(r => r.type === type).length
|
||
+ (state.assetMentions || []).filter((m: Record<string, string>) => m.type === type).length;
|
||
const refId = `ref_${fileCounter}`;
|
||
const newRef: UploadedFile = {
|
||
id: refId,
|
||
file,
|
||
type,
|
||
previewUrl: '',
|
||
label: `${labelPrefix}${existingSameType + 1}`,
|
||
uploading: true,
|
||
duration: Math.round(dur * 10) / 10,
|
||
};
|
||
|
||
useInputBarStore.setState((s) => ({
|
||
references: [...s.references, newRef],
|
||
}));
|
||
|
||
// Start upload
|
||
_uploadRef(refId, file);
|
||
}
|
||
}
|