video-shuoshan/web/src/store/inputBar.ts
seaislee1209 4c0605e589 fix: 首尾帧模式 aspect ratio 默认改为 adaptive(自适应)
切换到 keyframe 模式时自动设为 adaptive,API 根据首帧图片比例
自动匹配最接近的输出比例,避免图片与视频比例不匹配。
用户仍可手动选择固定比例覆盖。

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-16 01:33:53 +08:00

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',
});
},
}));