video-shuoshan/web/src/store/inputBar.ts
zyc a08234e54b
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 9m47s
fix(web): require uploaded material urls before generation
2026-05-25 15:47:18 +08:00

683 lines
22 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, Resolution, GenerationType, UploadedFile } from '../types';
import { showToast } from '../components/Toast';
import { mediaApi, tosThumb } 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;
function isBlobUrl(url?: string): boolean {
return !!url && url.startsWith('blob:');
}
function revokePreviewUrl(url?: string) {
if (url && isBlobUrl(url)) URL.revokeObjectURL(url);
}
function syncEditorReferencePreview(editorHtml: string, refId: string, previewUrl: string): string {
if (!editorHtml) return editorHtml;
const doc = new DOMParser().parseFromString(`<div>${editorHtml}</div>`, 'text/html');
const container = doc.body.firstElementChild as HTMLElement | null;
if (!container) return editorHtml;
let changed = false;
container.querySelectorAll('[data-ref-id]').forEach((span) => {
const el = span as HTMLElement;
if (el.dataset.refId !== refId || el.dataset.refType === 'asset') return;
el.dataset.thumbUrl = previewUrl;
if (el.dataset.refType !== 'audio') {
let img = el.querySelector('img') as HTMLImageElement | null;
if (!img) {
const newImg = document.createElement('img');
newImg.className = 'mentionImg';
newImg.setAttribute('width', '16');
newImg.setAttribute('height', '16');
newImg.style.cssText = 'width:16px;height:16px;border-radius:3px;object-fit:cover;vertical-align:middle;margin-right:3px;display:inline-block;pointer-events:none';
newImg.onerror = () => { newImg.style.display = 'none'; };
el.insertBefore(newImg, el.firstChild);
img = newImg;
}
img.src = tosThumb(previewUrl, 32);
}
changed = true;
});
return changed ? container.innerHTML : editorHtml;
}
function markReferenceUploadSuccess(
refs: UploadedFile[],
refId: string,
uploadedUrl: string,
blobUrlsToRevoke: Set<string>,
): UploadedFile[] {
return refs.map((ref) => {
if (ref.id !== refId) return ref;
if (isBlobUrl(ref.previewUrl)) blobUrlsToRevoke.add(ref.previewUrl);
return {
...ref,
tosUrl: uploadedUrl,
previewUrl: uploadedUrl,
uploading: false,
uploadError: false,
};
});
}
function markReferenceUploadError(refs: UploadedFile[], refId: string): UploadedFile[] {
return refs.map((ref) =>
ref.id === refId ? { ...ref, uploading: false, uploadError: true } : ref
);
}
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;
// Resolution (480p/720p/1080p) — 1080p 仅 Seedance 2.0 支持
resolution: Resolution;
setResolution: (resolution: Resolution) => void;
// 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) => {
// Fast + 1080P 为非法组合官方文档约束。UI Dropdown 已对 Fast 项置灰,
// 此处为 UI 被绕过时的防御性拦截depth defense不做静默降级
// 阻止切换 + toast 引导用户手动改分辨率,让用户选择始终被尊重。
const state = get();
if (model === 'seedance_2.0_fast' && state.resolution === '1080p') {
showToast('1080P 仅 AirDrama 模型支持,请先切换分辨率到 720P 或 480P');
return;
}
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,
resolution: '720p' as Resolution,
setResolution: (resolution) => {
// Fast + 1080P 非法组合(对称 setModel 的拦截)— UI Dropdown 已置灰,此处防御性拦截
const state = get();
if (resolution === '1080p' && state.model === 'seedance_2.0_fast') {
showToast('AirDrama Fast 不支持 1080P请先切换模型到 AirDrama');
return;
}
set({ resolution });
},
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 removedRef = state.references.find((r) => r.id === id);
if (!removedRef) return;
revokePreviewUrl(removedRef.previewUrl);
// 删除后同类型的剩余引用按顺序重新编号(即梦逻辑:保持 1/2/3 连续)
const remaining = state.references.filter((r) => r.id !== id);
const labelPrefix = removedRef.type === 'image' ? '图片' : removedRef.type === 'video' ? '视频' : '音频';
const labelUpdates = new Map<string, string>(); // refId -> newLabel
let idx = 1;
const relabeled = remaining.map((r) => {
if (r.type !== removedRef.type) return r;
const newLabel = `${labelPrefix}${idx++}`;
if (r.label !== newLabel) labelUpdates.set(r.id, newLabel);
return r.label === newLabel ? r : { ...r, label: newLabel };
});
// 同步更新 editorHtml 里对应 refId 的 @mention span 文本
let newEditorHtml = state.editorHtml;
if (labelUpdates.size > 0 && newEditorHtml) {
const doc = new DOMParser().parseFromString(`<div>${newEditorHtml}</div>`, 'text/html');
const container = doc.body.firstChild as HTMLElement | null;
if (container) {
container.querySelectorAll('[data-ref-id]').forEach((span) => {
const el = span as HTMLElement;
const refId = el.dataset.refId;
if (refId && labelUpdates.has(refId)) {
const newLabel = labelUpdates.get(refId)!;
// span 结构:[icon/img] + atHidden(@) + textNode(label)
const labelNode = [...el.childNodes].reverse().find((n) => n.nodeType === Node.TEXT_NODE);
if (labelNode) labelNode.textContent = newLabel;
}
});
newEditorHtml = container.innerHTML;
}
}
set({ references: relabeled, editorHtml: newEditorHtml });
},
clearReferences: () => {
const state = get();
state.references.forEach((r) => revokePreviewUrl(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) revokePreviewUrl(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) revokePreviewUrl(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) revokePreviewUrl(state.firstFrame.previewUrl);
if (state.lastFrame) revokePreviewUrl(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) => revokePreviewUrl(r.previewUrl));
state.prevReferences.forEach((r) => revokePreviewUrl(r.previewUrl));
if (state.firstFrame) revokePreviewUrl(state.firstFrame.previewUrl);
if (state.lastFrame) revokePreviewUrl(state.lastFrame.previewUrl);
set({
mode: 'universal',
model: 'seedance_2.0',
aspectRatio: '21:9',
prevAspectRatio: '21:9',
duration: 15,
prevDuration: 15,
resolution: '720p',
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 }) => {
const blobUrlsToRevoke = new Set<string>();
useInputBarStore.setState((s) => ({
references: markReferenceUploadSuccess(s.references, refId, data.url, blobUrlsToRevoke),
prevReferences: markReferenceUploadSuccess(s.prevReferences, refId, data.url, blobUrlsToRevoke),
editorHtml: syncEditorReferencePreview(s.editorHtml, refId, data.url),
}));
blobUrlsToRevoke.forEach((url) => URL.revokeObjectURL(url));
}).catch(() => {
useInputBarStore.setState((s) => ({
references: markReferenceUploadError(s.references, refId),
prevReferences: markReferenceUploadError(s.prevReferences, refId),
}));
});
}
/** 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: '',
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: '',
label: `${labelPrefix}${existingSameType + 1}`,
uploading: true,
duration: Math.round(dur * 10) / 10,
};
useInputBarStore.setState((s) => ({
references: [...s.references, newRef],
}));
// Start upload
_uploadRef(refId, file);
}
}