切换到 keyframe 模式时自动设为 adaptive,API 根据首帧图片比例 自动匹配最接近的输出比例,避免图片与视频比例不匹配。 用户仍可手动选择固定比例覆盖。 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
263 lines
7.1 KiB
TypeScript
263 lines
7.1 KiB
TypeScript
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<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: [],
|
|
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',
|
|
});
|
|
},
|
|
}));
|