video-shuoshan/web/src/store/inputBar.ts
zyc ffe92f7b15 Initial commit: 即梦视频生成平台
- web/: React + Vite + TypeScript 前端
- backend/: Django + DRF + SimpleJWT 后端
- prototype/: HTML 设计原型
- docs/: PRD 和设计评审文档
- test: 单元测试 + E2E 极限测试
2026-03-13 09:59:33 +08:00

228 lines
5.9 KiB
TypeScript

import { create } from 'zustand';
import type { CreationMode, ModelOption, AspectRatio, Duration, GenerationType, UploadedFile } from '../types';
let fileCounter = 0;
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[];
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<InputBarState>((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: [],
addReferences: (files) => {
const state = get();
const remaining = 5 - state.references.length;
if (remaining <= 0) return;
const toAdd = files.slice(0, remaining);
const newRefs: UploadedFile[] = toAdd.map((file) => {
fileCounter++;
const type = file.type.startsWith('video') ? 'video' as const : 'image' as const;
const labelPrefix = type === 'video' ? '视频' : '图片';
return {
id: `ref_${fileCounter}`,
file,
type,
previewUrl: URL.createObjectURL(file),
label: `${labelPrefix}${fileCounter}`,
};
});
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;
return hasText || hasFiles;
},
insertAtTrigger: 0,
triggerInsertAt: () => set((s) => ({ insertAtTrigger: s.insertAtTrigger + 1 })),
switchMode: (mode) => {
const state = get();
if (state.mode === mode) return;
if (mode === 'keyframe') {
// Clear universal references
state.references.forEach((r) => URL.revokeObjectURL(r.previewUrl));
set({
mode,
references: [],
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,
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));
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: [],
firstFrame: null,
lastFrame: null,
generationType: 'video',
});
},
}));