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