diff --git a/backend/apps/generation/views.py b/backend/apps/generation/views.py
index 685e3a7..fb3a1c1 100644
--- a/backend/apps/generation/views.py
+++ b/backend/apps/generation/views.py
@@ -333,6 +333,13 @@ def video_generate_view(request):
continue
seen_urls.add(original_url)
+ # 拦截 blob: URL(前端上传失败的兜底)
+ if url.startswith('blob:'):
+ return Response({
+ 'error': 'upload_failed',
+ 'message': f'素材「{label}」上传失败,请删除后重新添加',
+ }, status=status.HTTP_400_BAD_REQUEST)
+
# 快照存原始 URL(前端重建 reEdit 需要 asset://group-{id} 格式)
snap = {'url': original_url, 'type': ref_type, 'role': role, 'label': label}
thumb_url = ref.get('thumb_url', '')
diff --git a/backend/db.sqlite3.bak b/backend/db.sqlite3.bak
new file mode 100644
index 0000000..1f65b45
Binary files /dev/null and b/backend/db.sqlite3.bak differ
diff --git a/backend/utils/airdrama_client.py b/backend/utils/airdrama_client.py
index 6eeaf6a..2796085 100644
--- a/backend/utils/airdrama_client.py
+++ b/backend/utils/airdrama_client.py
@@ -21,6 +21,8 @@ ERROR_MESSAGES = {
'InvalidImage': '图片格式或尺寸不符合要求,请检查后重试',
'InvalidVideo': '视频格式或尺寸不符合要求,请检查后重试',
'InvalidAudio': '音频格式不符合要求,请检查后重试',
+ 'AudioDurationExceeded': '音频总时长超过15秒限制,请缩短音频后重试',
+ 'AudioFormatNotSupported': '音频格式不支持,请使用 MP3 或 WAV 格式',
# Rate limit
'RateLimitExceeded': '请求过于频繁,请稍后重试',
'ConcurrencyLimitExceeded': '当前生成任务过多,请稍后重试',
@@ -41,6 +43,8 @@ _MESSAGE_KEYWORDS = {
'sensitive': '内容包含敏感信息,请修改后重试',
'not found': '引用的素材不存在或已被删除,请检查素材库',
'not valid': '请求参数无效,请检查输入内容',
+ 'audio duration': '音频总时长超过15秒限制,请缩短音频后重试',
+ 'audio': '音频不符合要求(支持MP3/WAV,单条2-15秒,总时长≤15秒)',
}
diff --git a/backend/utils/assets_client.py b/backend/utils/assets_client.py
index 5c6048c..d34d589 100644
--- a/backend/utils/assets_client.py
+++ b/backend/utils/assets_client.py
@@ -82,6 +82,7 @@ def _get_service():
'GetAssetGroup': ApiInfo('POST', '/', {'Action': 'GetAssetGroup', 'Version': API_VERSION}, {}, {}),
'UpdateAssetGroup': ApiInfo('POST', '/', {'Action': 'UpdateAssetGroup', 'Version': API_VERSION}, {}, {}),
'UpdateAsset': ApiInfo('POST', '/', {'Action': 'UpdateAsset', 'Version': API_VERSION}, {}, {}),
+ 'DeleteAsset': ApiInfo('POST', '/', {'Action': 'DeleteAsset', 'Version': API_VERSION}, {}, {}),
}
return Service(service_info, api_info)
@@ -218,3 +219,9 @@ def update_asset(asset_id: str, name: str = None):
if name is not None:
body['Name'] = name
_do_request('UpdateAsset', body)
+
+
+def delete_asset(asset_id: str):
+ """Delete a single asset from the remote API."""
+ body = {'Id': asset_id, 'ProjectName': PROJECT_NAME}
+ _do_request('DeleteAsset', body)
diff --git a/web/nginx.conf b/web/nginx.conf
index 2f0346d..a3a2203 100644
--- a/web/nginx.conf
+++ b/web/nginx.conf
@@ -24,6 +24,11 @@ server {
client_max_body_size 50m;
}
+ # SPA client-side routes — must return index.html, not match Vite's dist/assets/ dir
+ location ~ ^/(assets|login|profile|admin|team)(/|$) {
+ try_files /index.html =404;
+ }
+
# SPA fallback
location / {
try_files $uri $uri/ /index.html;
diff --git a/web/src/components/GenerationCard.tsx b/web/src/components/GenerationCard.tsx
index d618817..0539b0d 100644
--- a/web/src/components/GenerationCard.tsx
+++ b/web/src/components/GenerationCard.tsx
@@ -479,6 +479,13 @@ export function GenerationCard({ task, onOpenDetail }: Props) {
{/* Bottom action buttons */}
+ {isGenerating && (
+
+
+
+ )}
{!isGenerating && (
+ {scrollBottomBtn}
+ !
{message}
);
diff --git a/web/src/components/UniversalUpload.module.css b/web/src/components/UniversalUpload.module.css
index b4923b1..a24bce6 100644
--- a/web/src/components/UniversalUpload.module.css
+++ b/web/src/components/UniversalUpload.module.css
@@ -281,3 +281,28 @@
background: #1a1a24;
color: var(--color-text-secondary);
}
+
+/* Upload status overlay */
+.uploadOverlay {
+ position: absolute;
+ inset: 0;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ background: rgba(0, 0, 0, 0.5);
+ border-radius: var(--radius-thumbnail);
+ z-index: 2;
+}
+
+.uploadError {
+ background: rgba(239, 68, 68, 0.25);
+ cursor: pointer;
+}
+
+@keyframes spin {
+ to { transform: rotate(360deg); }
+}
+
+.spinner {
+ animation: spin 1s linear infinite;
+}
diff --git a/web/src/components/UniversalUpload.tsx b/web/src/components/UniversalUpload.tsx
index 2804955..647888f 100644
--- a/web/src/components/UniversalUpload.tsx
+++ b/web/src/components/UniversalUpload.tsx
@@ -5,6 +5,19 @@ import { ImageLightbox } from './ImageLightbox';
import { tosThumb } from '../lib/api';
import styles from './UniversalUpload.module.css';
+const Spinner = () => (
+
+);
+
+const ErrorIcon = () => (
+
+);
+
const MAX_IMAGE_SIZE = 30 * 1024 * 1024; // 30MB per API doc
const MAX_VIDEO_SIZE = 50 * 1024 * 1024; // 50MB per API doc
const MAX_AUDIO_SIZE = 15 * 1024 * 1024; // 15MB per API doc
@@ -25,6 +38,7 @@ export function UniversalUpload() {
const references = useInputBarStore((s) => s.references);
const addReferences = useInputBarStore((s) => s.addReferences);
const removeReference = useInputBarStore((s) => s.removeReference);
+ const retryUpload = useInputBarStore((s) => s.retryUpload);
const fileInputRef = useRef(null);
const [expanded, setExpanded] = useState(false);
const [badgeHover, setBadgeHover] = useState(false);
@@ -40,6 +54,16 @@ export function UniversalUpload() {
const valid: File[] = [];
for (const f of files) {
+ // Format validation
+ if (f.type.startsWith('video/') && f.type !== 'video/mp4' && f.type !== 'video/quicktime') {
+ showToast('仅支持 MP4 和 MOV 格式的视频');
+ continue;
+ }
+ if (f.type.startsWith('audio/') && f.type !== 'audio/mpeg' && f.type !== 'audio/wav') {
+ showToast('仅支持 MP3 和 WAV 格式的音频');
+ continue;
+ }
+ // Size validation
let limit: number;
let limitLabel: string;
if (f.type.startsWith('video/')) {
@@ -83,7 +107,7 @@ export function UniversalUpload() {
{ e.stopPropagation(); setLightboxSrc(ref.previewUrl); }} />
)}
+ {/* Upload status overlay */}
+ {ref.uploading && (
+
+
+
+ )}
+ {ref.uploadError && (
+ { e.stopPropagation(); retryUpload(ref.id); }}
+ title="点击重试"
+ >
+
+
+ )}
{ e.stopPropagation(); removeReference(ref.id); }}
diff --git a/web/src/components/VideoGenerationPage.module.css b/web/src/components/VideoGenerationPage.module.css
index 314048d..407a6cb 100644
--- a/web/src/components/VideoGenerationPage.module.css
+++ b/web/src/components/VideoGenerationPage.module.css
@@ -52,3 +52,4 @@
color: var(--color-text-disabled);
font-size: 12px;
}
+
diff --git a/web/src/components/VideoGenerationPage.tsx b/web/src/components/VideoGenerationPage.tsx
index e7c00e0..b99080f 100644
--- a/web/src/components/VideoGenerationPage.tsx
+++ b/web/src/components/VideoGenerationPage.tsx
@@ -27,6 +27,7 @@ export function VideoGenerationPage() {
const [detailTaskId, setDetailTaskId] = useState(null);
const [showAnnouncement, setShowAnnouncement] = useState(false);
const [autoAnnouncementDone, setAutoAnnouncementDone] = useState(false);
+ const [showScrollBottom, setShowScrollBottom] = useState(false);
const detailTask = useMemo(() => detailTaskId ? tasks.find((t) => t.id === detailTaskId) || null : null, [detailTaskId, tasks]);
const setDetailTask = useCallback((t: GenerationTask | null) => setDetailTaskId(t?.id || null), []);
@@ -41,9 +42,10 @@ export function VideoGenerationPage() {
if (initialLoadRef.current) {
initialLoadRef.current = false;
// Use requestAnimationFrame to ensure DOM has rendered
+ const restoreTop = savedScrollTop;
requestAnimationFrame(() => {
- if (savedScrollTop !== null && scrollRef.current) {
- scrollRef.current.scrollTop = savedScrollTop;
+ if (restoreTop !== null && scrollRef.current) {
+ scrollRef.current.scrollTop = restoreTop;
} else if (scrollRef.current) {
scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
}
@@ -55,13 +57,19 @@ export function VideoGenerationPage() {
scrollRef.current.scrollTo({ top: scrollRef.current.scrollHeight, behavior: 'smooth' });
}
prevCountRef.current = tasks.length;
- }, [tasks.length, savedScrollTop]);
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [tasks.length]);
// Save scroll position + auto-load older tasks when scrolled near top
const handleScroll = useCallback(() => {
if (!scrollRef.current) return;
saveScrollPosition(scrollRef.current.scrollTop);
+ // Show "scroll to bottom" button when not near bottom
+ const el = scrollRef.current;
+ const distanceFromBottom = el.scrollHeight - el.scrollTop - el.clientHeight;
+ setShowScrollBottom(distanceFromBottom > 300);
+
// Trigger loadMore when scrolled within 100px of the top
if (scrollRef.current.scrollTop < 100) {
const el = scrollRef.current;
@@ -166,7 +174,26 @@ export function VideoGenerationPage() {
)}
-
+ scrollRef.current?.scrollTo({ top: scrollRef.current.scrollHeight, behavior: 'smooth' })}
+ style={{
+ marginLeft: 'auto',
+ background: 'rgba(255, 255, 255, 0.06)',
+ backdropFilter: 'blur(24px) saturate(180%)',
+ WebkitBackdropFilter: 'blur(24px) saturate(180%)',
+ border: '1px solid rgba(255, 255, 255, 0.10)',
+ boxShadow: '0 0 0 1px rgba(255,255,255,0.05) inset, 0 4px 16px rgba(0,0,0,0.3)',
+ borderRadius: 6, padding: '4px 12px', fontSize: 12,
+ color: 'var(--color-text-secondary)', cursor: 'pointer',
+ transition: 'all 0.15s', whiteSpace: 'nowrap',
+ }}
+ onMouseEnter={(e) => { (e.currentTarget as HTMLElement).style.background = 'rgba(255,255,255,0.10)'; (e.currentTarget as HTMLElement).style.color = 'var(--color-text-primary)'; }}
+ onMouseLeave={(e) => { (e.currentTarget as HTMLElement).style.background = 'rgba(255,255,255,0.06)'; (e.currentTarget as HTMLElement).style.color = 'var(--color-text-secondary)'; }}
+ >
+ 回到底部 ↓
+
+ ) : null} />
('/media/upload', formData, {
diff --git a/web/src/pages/AssetsPage.tsx b/web/src/pages/AssetsPage.tsx
index 8f827b6..d1c3759 100644
--- a/web/src/pages/AssetsPage.tsx
+++ b/web/src/pages/AssetsPage.tsx
@@ -88,6 +88,9 @@ function VideoThumbnail({
export function AssetsPage() {
const tasks = useGenerationStore((s) => s.tasks);
const loadTasks = useGenerationStore((s) => s.loadTasks);
+ const loadMore = useGenerationStore((s) => s.loadMore);
+ const hasMore = useGenerationStore((s) => s.hasMore);
+ const isLoadingMore = useGenerationStore((s) => s.isLoadingMore);
const reEdit = useGenerationStore((s) => s.reEdit);
const regenerate = useGenerationStore((s) => s.regenerate);
const removeTask = useGenerationStore((s) => s.removeTask);
@@ -102,8 +105,9 @@ export function AssetsPage() {
const [subTab, setSubTab] = useState<'all' | 'favorites'>('all');
+ // Reverse: newest first for asset page (store keeps oldest-first for generation page)
const completedTasks = useMemo(
- () => tasks.filter((t) => t.status === 'completed'),
+ () => tasks.filter((t) => t.status === 'completed').slice().reverse(),
[tasks],
);
@@ -160,20 +164,41 @@ export function AssetsPage() {
{subTab === 'favorites' ? '暂无收藏的视频' : '暂无已完成的视频'}
) : (
- dateGroups.map((group) => (
-
- {group.label}
-
- {group.tasks.map((task) => (
-
setDetailTask(task)}
- />
- ))}
+ <>
+ {dateGroups.map((group) => (
+
+ {group.label}
+
+ {group.tasks.map((task) => (
+ setDetailTask(task)}
+ />
+ ))}
+
+
+ ))}
+ {hasMore && (
+
+
-
- ))
+ )}
+ >
)}
diff --git a/web/src/store/generation.ts b/web/src/store/generation.ts
index 65b03ea..f7503af 100644
--- a/web/src/store/generation.ts
+++ b/web/src/store/generation.ts
@@ -409,13 +409,14 @@ export const useGenerationStore = create((set, get) => ({
});
try {
- // Upload files to TOS (or reuse existing TOS URLs)
+ // Use pre-uploaded TOS URLs (immediate upload), fallback to upload here if needed
const uploadedRefs: { url: string; type: string; role: string; label: string; thumb_url?: string }[] = [];
for (const item of filesToUpload) {
- if (item.tosUrl) {
+ if (item.tosUrl && !item.tosUrl.startsWith('blob:')) {
uploadedRefs.push({ url: item.tosUrl, type: item.type, role: item.role, label: item.label });
} else if (item.file) {
+ // Fallback: file wasn't pre-uploaded (shouldn't normally happen with immediate upload)
const { data: uploadResult } = await mediaApi.upload(item.file);
uploadedRefs.push({ url: uploadResult.url, type: item.type, role: item.role, label: item.label });
}
diff --git a/web/src/store/inputBar.ts b/web/src/store/inputBar.ts
index 05066a0..14e7a44 100644
--- a/web/src/store/inputBar.ts
+++ b/web/src/store/inputBar.ts
@@ -1,9 +1,64 @@
import { create } from 'zustand';
import type { CreationMode, ModelOption, AspectRatio, Duration, GenerationType, UploadedFile } from '../types';
import { showToast } from '../components/Toast';
+import { mediaApi } from '../lib/api';
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 {
+ 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;
@@ -46,6 +101,7 @@ interface InputBarState {
addReferences: (files: File[]) => void;
removeReference: (id: string) => void;
clearReferences: () => void;
+ retryUpload: (refId: string) => void;
// Keyframe
firstFrame: UploadedFile | null;
@@ -118,7 +174,10 @@ export const useInputBarStore = create((set, get) => ({
const counts = { image: 0, video: 0, audio: 0 };
for (const ref of state.references) counts[ref.type]++;
- const newRefs: UploadedFile[] = [];
+ // 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'
@@ -132,24 +191,23 @@ export const useInputBarStore = create((set, get) => ({
showToast(label);
continue;
}
-
- fileCounter++;
- const labelPrefix = type === 'video' ? '视频' : type === 'audio' ? '音频' : '图片';
- // 编号接着已有的同类型素材(包括 @ 引用的 assetMentions)
- const existingSameType = state.references.filter(r => r.type === type).length
- + newRefs.filter(r => r.type === type).length
- + (state.assetMentions || []).filter((m: Record) => m.type === type).length;
- newRefs.push({
- id: `ref_${fileCounter}`,
- file,
- type,
- previewUrl: type === 'audio' ? '' : URL.createObjectURL(file),
- label: `${labelPrefix}${existingSameType + 1}`,
- });
counts[type]++;
+
+ if (type === 'image') {
+ imageFiles.push(file);
+ } else {
+ mediaFiles.push(file);
+ }
}
- if (newRefs.length > 0) {
- set({ references: [...state.references, ...newRefs] });
+
+ // ── 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) => {
@@ -163,6 +221,16 @@ export const useInputBarStore = create((set, 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,
@@ -216,6 +284,8 @@ export const useInputBarStore = create((set, get) => ({
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;
},
@@ -290,3 +360,181 @@ export const useInputBarStore = create((set, get) => ({
});
},
}));
+
+// ── 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) => 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)
+ const state = useInputBarStore.getState();
+ const existingDuration = state.references
+ .filter((r) => r.type === type && r.duration)
+ .reduce((sum, r) => sum + (r.duration || 0), 0);
+ 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) => 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);
+ }
+}
diff --git a/web/src/types/index.ts b/web/src/types/index.ts
index 55b8156..cc60b32 100644
--- a/web/src/types/index.ts
+++ b/web/src/types/index.ts
@@ -12,6 +12,9 @@ export interface UploadedFile {
previewUrl: string;
label: string;
tosUrl?: string; // TOS URL after upload
+ uploading?: boolean;
+ uploadError?: boolean;
+ duration?: number; // media duration in seconds (audio/video only)
}
export interface DropdownOption {