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/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/k8s/celery-deployment.yaml b/k8s/celery-deployment.yaml index d7f99f4..4d1d054 100644 --- a/k8s/celery-deployment.yaml +++ b/k8s/celery-deployment.yaml @@ -20,7 +20,7 @@ spec: - name: celery-worker image: ${CI_REGISTRY_IMAGE}/video-backend:latest imagePullPolicy: Always - command: ["celery", "-A", "config", "worker", "--loglevel=info", "--concurrency=4", "-B"] + command: ["celery", "-A", "config", "worker", "--loglevel=info", "--concurrency=50", "--pool=threads", "-B"] env: - name: USE_MYSQL value: "true" @@ -92,5 +92,5 @@ spec: memory: "128Mi" cpu: "100m" limits: - memory: "512Mi" - cpu: "500m" + memory: "1Gi" + cpu: "1000m" 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}
(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} /> { +/** Read image dimensions via HTML5 Image element. */ +function getImageDimensions(file: File): Promise<{ width: number; height: number }> { return new Promise((resolve, reject) => { - const el = file.type.startsWith('audio/') ? new Audio() : document.createElement('video'); + 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); - resolve(el.duration); + 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', () => { @@ -167,25 +200,9 @@ export const useInputBarStore = create((set, get) => ({ } } - // ── Sync: add images immediately + start upload ── + // ── Async: validate image dimensions, then add + upload ── if (imageFiles.length > 0) { - const baseCount = state.references.filter(r => r.type === 'image').length - + (state.assetMentions || []).filter((m: Record) => 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] }); + _validateAndAddImages(imageFiles); } // ── Async: validate audio/video duration, then add + upload ── @@ -363,23 +380,84 @@ function _uploadRef(refId: string, file: File) { }); } +/** 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, then add to store + start upload. */ +/** 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 - let dur: number; + // Read duration (+ dimensions for video) + let info: MediaInfo; try { - dur = await getMediaDuration(file); + 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) { @@ -391,6 +469,33 @@ async function _validateAndAddMedia(files: File[]) { 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