- web/: React + Vite + TypeScript 前端 - backend/: Django + DRF + SimpleJWT 后端 - prototype/: HTML 设计原型 - docs/: PRD 和设计评审文档 - test: 单元测试 + E2E 极限测试
228 lines
5.9 KiB
TypeScript
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',
|
|
});
|
|
},
|
|
}));
|