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 {