video-shuoshan/web/src/store/inputBar.ts
seaislee1209 da9a1413c3 v0.18.0 素材库多类型支持 + @ 引用改为单素材
对齐火山 API 文档(Asset URI 小写、HEIC/HEIF、DeleteAsset)
素材库支持视频/音频上传(按类型分三区显示、前端校验、拖拽上传)
@ 引用从素材组改为单个素材(搜索返回具体素材、即时数量/时长检查)
ffmpeg 视频封面帧提取 + 音频时长读取(Celery 异步)
生产级安全修复(跨团队校验、异常信息脱敏、下载大小限制)
2026-04-04 17:36:35 +08:00

549 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 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;
}
// 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);
}
}