feat: v0.16.0 即时上传 + 音频视频前端校验 + 资产页修复 + Toast UI

- 即时上传:拖入文件后立刻上传 TOS,spinner/红色重试/禁用提交
- 音频校验:格式(MP3/WAV) + 时长[2,15.4]s + 总时长≤15.4s
- 视频校验:格式(MP4/MOV) + 时长[2,15.4]s + 总时长≤15.4s
- 后端 blob: URL 兜底拦截 + 音频错误文案优化
- 资产页:nginx 403 修复 + 倒序排列 + 加载更多按钮
- Toast:glass-card 毛玻璃风格 + 橙色感叹号图标

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
seaislee1209 2026-04-01 11:12:06 +08:00
parent a4c36e4fee
commit 34e56ddf86
13 changed files with 322 additions and 36 deletions

View File

@ -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', '')

View File

@ -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秒',
}

View File

@ -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;

View File

@ -43,6 +43,16 @@ export function InputBar() {
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/')) {

View File

@ -3,8 +3,14 @@
top: 20px;
left: 50%;
transform: translateX(-50%) translateY(-20px);
background: var(--color-bg-dropdown);
border: 1px solid var(--color-border-input-bar);
background: rgba(255, 255, 255, 0.06);
backdrop-filter: blur(24px) saturate(180%);
-webkit-backdrop-filter: blur(24px) saturate(180%);
border: 1px solid rgba(255, 255, 255, 0.10);
box-shadow:
0 0 0 1px rgba(255, 255, 255, 0.05) inset,
0 8px 32px rgba(0, 0, 0, 0.4),
0 1px 0 rgba(255, 255, 255, 0.12) inset;
color: #fff;
padding: 10px 24px;
border-radius: 10px;
@ -13,6 +19,23 @@
pointer-events: none;
transition: all 0.3s cubic-bezier(0.16, 1, 0.3, 1);
z-index: 999;
display: flex;
align-items: center;
gap: 8px;
}
.icon {
display: inline-flex;
align-items: center;
justify-content: center;
width: 18px;
height: 18px;
border-radius: 50%;
background: #e8952e;
color: #fff;
font-size: 12px;
font-weight: bold;
flex-shrink: 0;
}
.show {

View File

@ -24,6 +24,7 @@ export function Toast() {
return (
<div className={`${styles.toast} ${visible ? styles.show : ''}`}>
<span className={styles.icon}>!</span>
{message}
</div>
);

View File

@ -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;
}

View File

@ -5,6 +5,19 @@ import { ImageLightbox } from './ImageLightbox';
import { tosThumb } from '../lib/api';
import styles from './UniversalUpload.module.css';
const Spinner = () => (
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="#fff" strokeWidth="2" strokeLinecap="round" className={styles.spinner}>
<path d="M12 2v4M12 18v4M4.93 4.93l2.83 2.83M16.24 16.24l2.83 2.83M2 12h4M18 12h4M4.93 19.07l2.83-2.83M16.24 7.76l2.83-2.83" />
</svg>
);
const ErrorIcon = () => (
<svg width="22" height="22" viewBox="0 0 24 24" fill="none">
<circle cx="12" cy="12" r="10" fill="rgba(239,68,68,0.85)" />
<text x="12" y="16" textAnchor="middle" fill="#fff" fontSize="14" fontWeight="bold">!</text>
</svg>
);
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<HTMLInputElement>(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() {
<input
ref={fileInputRef}
type="file"
accept="image/*,video/*,audio/*"
accept="image/*,video/mp4,video/quicktime,audio/mpeg,audio/wav"
multiple
className={styles.hiddenInput}
onChange={handleFileChange}
@ -127,6 +151,21 @@ export function UniversalUpload() {
) : (
<img src={tosThumb(ref.previewUrl, 200)} alt={ref.label} className={styles.thumbMedia} style={{ cursor: 'zoom-in' }} onClick={(e) => { e.stopPropagation(); setLightboxSrc(ref.previewUrl); }} />
)}
{/* Upload status overlay */}
{ref.uploading && (
<div className={styles.uploadOverlay}>
<Spinner />
</div>
)}
{ref.uploadError && (
<div
className={`${styles.uploadOverlay} ${styles.uploadError}`}
onClick={(e) => { e.stopPropagation(); retryUpload(ref.id); }}
title="点击重试"
>
<ErrorIcon />
</div>
)}
<div
className={styles.thumbClose}
onClick={(e) => { e.stopPropagation(); removeReference(ref.id); }}

View File

@ -129,7 +129,7 @@ export const mediaApi = {
formData.append('file', file);
return api.post<{
url: string;
type: 'image' | 'video';
type: 'image' | 'video' | 'audio';
filename: string;
size: number;
}>('/media/upload', formData, {

View File

@ -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() {
<p>{subTab === 'favorites' ? '暂无收藏的视频' : '暂无已完成的视频'}</p>
</div>
) : (
dateGroups.map((group) => (
<section key={group.label} className={styles.dateGroup}>
<h3 className={styles.dateLabel}>{group.label}</h3>
<div className={styles.grid}>
{group.tasks.map((task) => (
<VideoThumbnail
key={task.id}
task={task}
onClick={() => setDetailTask(task)}
/>
))}
<>
{dateGroups.map((group) => (
<section key={group.label} className={styles.dateGroup}>
<h3 className={styles.dateLabel}>{group.label}</h3>
<div className={styles.grid}>
{group.tasks.map((task) => (
<VideoThumbnail
key={task.id}
task={task}
onClick={() => setDetailTask(task)}
/>
))}
</div>
</section>
))}
{hasMore && (
<div style={{ textAlign: 'center', padding: '24px 0' }}>
<button
onClick={loadMore}
disabled={isLoadingMore}
style={{
background: 'rgba(255,255,255,0.06)',
border: '1px solid rgba(255,255,255,0.1)',
borderRadius: 8,
padding: '8px 32px',
color: 'var(--color-text-secondary)',
cursor: isLoadingMore ? 'not-allowed' : 'pointer',
fontSize: 13,
}}
>
{isLoadingMore ? '加载中...' : '加载更多'}
</button>
</div>
</section>
))
)}
</>
)}
</div>
</main>

View File

@ -409,13 +409,14 @@ export const useGenerationStore = create<GenerationState>((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 });
}

View File

@ -1,9 +1,31 @@
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 duration of an audio or video file via HTML5 media element. */
function getMediaDuration(file: File): Promise<number> {
return new Promise((resolve, reject) => {
const el = file.type.startsWith('audio/') ? 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);
resolve(el.duration);
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 +68,7 @@ interface InputBarState {
addReferences: (files: File[]) => void;
removeReference: (id: string) => void;
clearReferences: () => void;
retryUpload: (refId: string) => void;
// Keyframe
firstFrame: UploadedFile | null;
@ -118,7 +141,10 @@ export const useInputBarStore = create<InputBarState>((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,25 +158,40 @@ export const useInputBarStore = create<InputBarState>((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<string, string>) => 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) {
// ── Sync: add images immediately + start upload ──
if (imageFiles.length > 0) {
const baseCount = state.references.filter(r => r.type === 'image').length
+ (state.assetMentions || []).filter((m: Record<string, string>) => m.type === 'image').length;
const newRefs: UploadedFile[] = imageFiles.map((file, i) => {
fileCounter++;
const ref: UploadedFile = {
id: `ref_${fileCounter}`,
file,
type: 'image',
previewUrl: URL.createObjectURL(file),
label: `图片${baseCount + i + 1}`,
uploading: true,
};
// Fire upload in background
_uploadRef(ref.id, file);
return ref;
});
set({ references: [...state.references, ...newRefs] });
}
// ── Async: validate audio/video duration, then add + upload ──
if (mediaFiles.length > 0) {
_validateAndAddMedia(mediaFiles);
}
},
removeReference: (id) => {
const state = get();
@ -163,6 +204,16 @@ export const useInputBarStore = create<InputBarState>((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 +267,8 @@ export const useInputBarStore = create<InputBarState>((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 +343,93 @@ export const useInputBarStore = create<InputBarState>((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
),
}));
});
}
const MAX_MEDIA_DURATION = 15; // seconds per item and total
/** Validate audio/video duration, 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
let dur: number;
try {
dur = await getMediaDuration(file);
} catch {
showToast(`无法读取${typeLabel}文件信息`);
continue;
}
// 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;
}
// 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<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);
}
}

View File

@ -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<T = string> {