video-shuoshan/web/src/store/inputBar.ts
seaislee1209 2281c64ee8
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 6m0s
fix: 音频不能作为唯一参考素材 — 前端校验 + toast 提示
Seedance API 不支持"纯音频"和"文本+音频"输入,必须搭配图片或视频。
- canSubmit() 校验同时检查 references 和 assetMentions
- Toolbar 点击禁用按钮时弹出 toast 提示原因

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 14:10:39 +08:00

558 lines
17 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { create } from 'zustand';
import type { CreationMode, ModelOption, AspectRatio, Duration, GenerationType, UploadedFile } from '../types';
import { showToast } from '../components/Toast';
import { mediaApi } from '../lib/api';
import { parseAssetMentions } from '../lib/assetMentions';
let fileCounter = 0;
/** Read image dimensions via HTML5 Image element. */
function getImageDimensions(file: File): Promise<{ width: number; height: number }> {
return new Promise((resolve, reject) => {
const img = new Image();
const url = URL.createObjectURL(file);
const cleanup = () => URL.revokeObjectURL(url);
const timeout = setTimeout(() => { cleanup(); reject(new Error('timeout')); }, 10000);
img.onload = () => {
clearTimeout(timeout);
resolve({ width: img.naturalWidth, height: img.naturalHeight });
cleanup();
};
img.onerror = () => {
clearTimeout(timeout);
reject(new Error('无法读取图片'));
cleanup();
};
img.src = url;
});
}
/** Read duration (and dimensions for video) of an audio or video file. */
interface MediaInfo {
duration: number;
width?: number;
height?: number;
}
function getMediaInfo(file: File): Promise<MediaInfo> {
return new Promise((resolve, reject) => {
const isAudio = file.type.startsWith('audio/');
const el = isAudio ? new Audio() : document.createElement('video');
const url = URL.createObjectURL(file);
const cleanup = () => URL.revokeObjectURL(url);
const timeout = setTimeout(() => { cleanup(); reject(new Error('timeout')); }, 10000);
el.addEventListener('loadedmetadata', () => {
clearTimeout(timeout);
if (isAudio) {
resolve({ duration: el.duration });
} else {
const vid = el as HTMLVideoElement;
resolve({ duration: vid.duration, width: vid.videoWidth, height: vid.videoHeight });
}
cleanup();
});
el.addEventListener('error', () => {
clearTimeout(timeout);
reject(new Error('无法读取媒体文件'));
cleanup();
});
el.src = url;
});
}
// 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;
retryUpload: (refId: string) => void;
// Keyframe
firstFrame: UploadedFile | null;
lastFrame: UploadedFile | null;
setFirstFrame: (file: File | null) => void;
setLastFrame: (file: File | null) => void;
// Computed
canSubmit: () => boolean;
// Search mode (联网搜索)
searchMode: 'smart' | 'off';
setSearchMode: (mode: 'smart' | 'off') => void;
// Seed (种子值)
seed: number;
seedEnabled: boolean;
setSeed: (seed: number) => void;
setSeedEnabled: (enabled: boolean) => void;
// Asset mentions (for reEdit/regenerate to pass asset data to PromptInput rebuild)
// eslint-disable-next-line @typescript-eslint/no-explicit-any
assetMentions: Record<string, any>[];
// @ 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 + merge @ asset mentions
const counts = { image: 0, video: 0, audio: 0 };
for (const ref of state.references) counts[ref.type]++;
const { counts: assetCounts } = parseAssetMentions(state.editorHtml);
counts.image += assetCounts.image;
counts.video += assetCounts.video;
counts.audio += assetCounts.audio;
// Separate images (sync) from audio/video (need async duration check)
const imageFiles: File[] = [];
const mediaFiles: File[] = []; // audio + video
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;
}
counts[type]++;
if (type === 'image') {
imageFiles.push(file);
} else {
mediaFiles.push(file);
}
}
// ── Async: validate image dimensions, then add + upload ──
if (imageFiles.length > 0) {
_validateAndAddImages(imageFiles);
}
// ── Async: validate audio/video duration, then add + upload ──
if (mediaFiles.length > 0) {
_validateAndAddMedia(mediaFiles);
}
},
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: [] });
},
retryUpload: (refId) => {
const ref = get().references.find((r) => r.id === refId);
if (!ref?.file) return;
set({
references: get().references.map((r) =>
r.id === refId ? { ...r, uploading: true, uploadError: false } : r
),
});
_uploadRef(refId, ref.file);
},
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 the only reference — Seedance API requires image or video alongside
if (state.mode === 'universal') {
const hasAudioRef = state.references.some((r) => r.type === 'audio');
const hasAudioAsset = (state.assetMentions || []).some((m: Record<string, string>) =>
(m.assetType || '').toLowerCase() === 'audio');
if (hasAudioRef || hasAudioAsset) {
const hasImageOrVideoRef = state.references.some((r) => r.type === 'image' || r.type === 'video');
const hasImageOrVideoAsset = (state.assetMentions || []).some((m: Record<string, string>) => {
const t = (m.assetType || '').toLowerCase();
return t === 'image' || t === 'video';
});
if (!hasImageOrVideoRef && !hasImageOrVideoAsset) return false;
}
}
// Block submit if any reference is still uploading or failed
if (state.references.some((r) => r.uploading || r.uploadError)) return false;
return true;
},
searchMode: 'off',
setSearchMode: (searchMode) => set({ searchMode }),
seed: -1,
seedEnabled: false,
setSeed: (seed) => set({ seed }),
setSeedEnabled: (seedEnabled) => set({ seedEnabled, seed: -1 }),
assetMentions: [],
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: [],
assetMentions: [],
aspectRatio: '16:9',
duration: 5,
});
} else {
// Clear keyframe
if (state.firstFrame) URL.revokeObjectURL(state.firstFrame.previewUrl);
if (state.lastFrame) URL.revokeObjectURL(state.lastFrame.previewUrl);
set({
mode,
assetMentions: [],
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: [],
assetMentions: [],
firstFrame: null,
lastFrame: null,
generationType: 'video',
});
},
}));
// ── Module-level helpers (use useInputBarStore late-binding) ──
/** Upload a single reference file to TOS and update the store. */
function _uploadRef(refId: string, file: File) {
mediaApi.upload(file).then(({ data }) => {
useInputBarStore.setState((s) => ({
references: s.references.map((r) =>
r.id === refId ? { ...r, tosUrl: data.url, uploading: false, uploadError: false } : r
),
}));
}).catch(() => {
useInputBarStore.setState((s) => ({
references: s.references.map((r) =>
r.id === refId ? { ...r, uploading: false, uploadError: true } : r
),
}));
});
}
/** Validate image dimensions per Seedance API spec, then add to store + start upload. */
async function _validateAndAddImages(files: File[]) {
for (const file of files) {
// Read dimensions
let dims: { width: number; height: number };
try {
dims = await getImageDimensions(file);
} catch {
showToast('无法读取图片信息');
continue;
}
const { width, height } = dims;
// API spec: width/height in open interval (300, 6000)
if (width >= 6000 || height >= 6000) {
showToast(`图片尺寸过大(${width}×${height}),宽高需小于 6000 像素`);
continue;
}
if (width <= 300 || height <= 300) {
showToast(`图片尺寸过小(${width}×${height}),宽高需大于 300 像素`);
continue;
}
// API spec: aspect ratio (width/height) in open interval (0.4, 2.5)
const ratio = width / height;
if (ratio <= 0.4 || ratio >= 2.5) {
showToast(`图片比例不支持(${width}×${height}),宽高比需在 0.4 到 2.5 之间`);
continue;
}
// Re-check count
const state = useInputBarStore.getState();
const currentCount = state.references.filter((r) => r.type === 'image').length;
if (currentCount >= MAX_IMAGES) {
showToast(`最多上传${MAX_IMAGES}张图片`);
continue;
}
// Passed — add to store + upload
fileCounter++;
const existingSameType = state.references.filter(r => r.type === 'image').length
+ (state.assetMentions || []).filter((m: Record<string, string>) => m.type === 'image').length;
const refId = `ref_${fileCounter}`;
const newRef: UploadedFile = {
id: refId,
file,
type: 'image',
previewUrl: URL.createObjectURL(file),
label: `图片${existingSameType + 1}`,
uploading: true,
};
useInputBarStore.setState((s) => ({
references: [...s.references, newRef],
}));
_uploadRef(refId, file);
}
}
const MAX_MEDIA_DURATION = 15; // seconds per item and total
/** Validate audio/video duration (+ video dimensions), then add to store + start upload. */
async function _validateAndAddMedia(files: File[]) {
for (const file of files) {
const type: 'video' | 'audio' = file.type.startsWith('video/') ? 'video' : 'audio';
const typeLabel = type === 'video' ? '视频' : '音频';
// Read duration (+ dimensions for video)
let info: MediaInfo;
try {
info = await getMediaInfo(file);
} catch {
showToast(`无法读取${typeLabel}文件信息`);
continue;
}
const dur = info.duration;
// Single item duration check
// API specifies [2, 15]s — lower bound strict, upper bound +0.4s for codec imprecision
if (dur < 2) {
showToast(`${typeLabel}时长不能少于2秒`);
continue;
}
if (dur > MAX_MEDIA_DURATION + 0.4) {
showToast(`单条${typeLabel}时长不能超过${MAX_MEDIA_DURATION}`);
continue;
}
// Video dimension checks — API spec: [300, 6000]px, ratio [0.4, 2.5], pixels [409600, 927408]
if (type === 'video' && info.width && info.height) {
const { width, height } = info;
if (width > 6000 || height > 6000) {
showToast(`视频尺寸过大(${width}×${height}),宽高不能超过 6000 像素`);
continue;
}
if (width < 300 || height < 300) {
showToast(`视频尺寸过小(${width}×${height}),宽高不能小于 300 像素`);
continue;
}
const ratio = width / height;
if (ratio < 0.4 || ratio > 2.5) {
showToast(`视频比例不支持(${width}×${height}),宽高比需在 0.4 到 2.5 之间`);
continue;
}
const pixels = width * height;
if (pixels < 409600) {
showToast(`视频像素过低(${width}×${height}=${pixels.toLocaleString()}),最低需 409,600 像素`);
continue;
}
if (pixels > 927408) {
showToast(`视频像素过高(${width}×${height}=${pixels.toLocaleString()}),最高 927,408 像素`);
continue;
}
}
// Total duration check (same type) — merge @ asset mention durations
const state = useInputBarStore.getState();
const { durations: assetDurations } = parseAssetMentions(state.editorHtml);
const refDuration = state.references
.filter((r) => r.type === type && r.duration)
.reduce((sum, r) => sum + (r.duration || 0), 0);
const existingDuration = refDuration + (type === 'video' ? assetDurations.video : assetDurations.audio);
if (existingDuration + dur > MAX_MEDIA_DURATION + 0.4) {
showToast(`${typeLabel}总时长不能超过${MAX_MEDIA_DURATION}`);
continue;
}
// Re-check count (may have changed since initial check)
const currentCount = state.references.filter((r) => r.type === type).length;
const max = type === 'video' ? MAX_VIDEOS : MAX_AUDIO;
if (currentCount >= max) {
showToast(`最多上传${max}${typeLabel}`);
continue;
}
// Passed all checks — add to store
fileCounter++;
const labelPrefix = type === 'video' ? '视频' : '音频';
const existingSameType = state.references.filter(r => r.type === type).length
+ (state.assetMentions || []).filter((m: Record<string, string>) => m.type === type).length;
const refId = `ref_${fileCounter}`;
const newRef: UploadedFile = {
id: refId,
file,
type,
previewUrl: type === 'audio' ? '' : URL.createObjectURL(file),
label: `${labelPrefix}${existingSameType + 1}`,
uploading: true,
duration: Math.round(dur * 10) / 10,
};
useInputBarStore.setState((s) => ({
references: [...s.references, newRef],
}));
// Start upload
_uploadRef(refId, file);
}
}