import { create } from 'zustand'; import type { CreationMode, ModelOption, AspectRatio, Duration, GenerationType, UploadedFile } from '../types'; import { showToast } from '../components/Toast'; import { mediaApi } 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 { 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; 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; // 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[]; // @ 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((set, get) => ({ generationType: 'video', setGenerationType: (generationType) => set({ generationType }), mode: 'universal', setMode: (mode) => set({ mode }), model: 'seedance_2.0', setModel: (model) => 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, 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 ref = state.references.find((r) => r.id === id); if (ref) URL.revokeObjectURL(ref.previewUrl); set({ references: state.references.filter((r) => r.id !== id) }); }, clearReferences: () => { const state = get(); state.references.forEach((r) => URL.revokeObjectURL(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) URL.revokeObjectURL(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) URL.revokeObjectURL(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) => (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) => { 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) URL.revokeObjectURL(state.firstFrame.previewUrl); if (state.lastFrame) URL.revokeObjectURL(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) => URL.revokeObjectURL(r.previewUrl)); state.prevReferences.forEach((r) => URL.revokeObjectURL(r.previewUrl)); if (state.firstFrame) URL.revokeObjectURL(state.firstFrame.previewUrl); if (state.lastFrame) URL.revokeObjectURL(state.lastFrame.previewUrl); set({ mode: 'universal', model: 'seedance_2.0', aspectRatio: '21:9', prevAspectRatio: '21:9', duration: 15, prevDuration: 15, 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 }) => { useInputBarStore.setState((s) => ({ references: s.references.map((r) => r.id === refId ? { ...r, tosUrl: data.url, uploading: false, uploadError: false } : r ), })); }).catch(() => { useInputBarStore.setState((s) => ({ references: s.references.map((r) => r.id === refId ? { ...r, uploading: false, uploadError: true } : r ), })); }); } /** 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) => m.type === 'image').length; const refId = `ref_${fileCounter}`; const newRef: UploadedFile = { id: refId, file, type: 'image', previewUrl: URL.createObjectURL(file), 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) => m.type === type).length; const refId = `ref_${fileCounter}`; const newRef: UploadedFile = { id: refId, file, type, previewUrl: type === 'audio' ? '' : URL.createObjectURL(file), label: `${labelPrefix}${existingSameType + 1}`, uploading: true, duration: Math.round(dur * 10) / 10, }; useInputBarStore.setState((s) => ({ references: [...s.references, newRef], })); // Start upload _uploadRef(refId, file); } }