import { create } from 'zustand'; import type { CreationMode, ModelOption, AspectRatio, Duration, GenerationType, UploadedFile } from '../types'; import { showToast } from '../components/Toast'; let fileCounter = 0; // 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; // Keyframe firstFrame: UploadedFile | null; lastFrame: UploadedFile | null; setFirstFrame: (file: File | null) => void; setLastFrame: (file: File | null) => void; // Computed canSubmit: () => boolean; // @ 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 const counts = { image: 0, video: 0, audio: 0 }; for (const ref of state.references) counts[ref.type]++; const newRefs: UploadedFile[] = []; 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; } fileCounter++; const labelPrefix = type === 'video' ? '视频' : type === 'audio' ? '音频' : '图片'; newRefs.push({ id: `ref_${fileCounter}`, file, type, previewUrl: type === 'audio' ? '' : URL.createObjectURL(file), label: `${labelPrefix}${fileCounter}`, }); counts[type]++; } if (newRefs.length > 0) { set({ references: [...state.references, ...newRefs] }); } }, 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: [] }); }, 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 sent alone — must have image or video if (state.mode === 'universal' && state.references.length > 0) { const hasImageOrVideo = state.references.some((r) => r.type === 'image' || r.type === 'video'); if (!hasImageOrVideo && !hasText) return false; } return true; }, 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: [], aspectRatio: 'adaptive', duration: 5, }); } else { // Clear keyframe if (state.firstFrame) URL.revokeObjectURL(state.firstFrame.previewUrl); if (state.lastFrame) URL.revokeObjectURL(state.lastFrame.previewUrl); set({ mode, 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: [], firstFrame: null, lastFrame: null, generationType: 'video', }); }, }));