All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 6m0s
Seedance API 不支持"纯音频"和"文本+音频"输入,必须搭配图片或视频。 - canSubmit() 校验同时检查 references 和 assetMentions - Toolbar 点击禁用按钮时弹出 toast 提示原因 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
558 lines
17 KiB
TypeScript
558 lines
17 KiB
TypeScript
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);
|
||
}
|
||
}
|