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 { 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(`
${editorHtml}
`, '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, ): 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[]; // @ 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) => { // 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(); // 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(`
${newEditorHtml}
`, '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) => (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) 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(); 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) => 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) => 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); } }