add .
This commit is contained in:
parent
34e56ddf86
commit
e885d92745
BIN
backend/db.sqlite3.bak
Normal file
BIN
backend/db.sqlite3.bak
Normal file
Binary file not shown.
@ -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)
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -479,6 +479,13 @@ export function GenerationCard({ task, onOpenDetail }: Props) {
|
||||
</div>
|
||||
|
||||
{/* Bottom action buttons */}
|
||||
{isGenerating && (
|
||||
<div className={styles.actions}>
|
||||
<button className={styles.actionBtn} onClick={() => reEdit(task.id)}>
|
||||
<EditIcon /> <span>重新编辑</span>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{!isGenerating && (
|
||||
<div className={styles.actions}>
|
||||
<button className={styles.actionBtn} onClick={() => reEdit(task.id)}>
|
||||
|
||||
@ -8,7 +8,7 @@ import { AssetLibraryModal } from './AssetLibraryModal';
|
||||
import { showToast } from './Toast';
|
||||
import styles from './InputBar.module.css';
|
||||
|
||||
export function InputBar() {
|
||||
export function InputBar({ scrollBottomBtn }: { scrollBottomBtn?: React.ReactNode }) {
|
||||
const mode = useInputBarStore((s) => s.mode);
|
||||
const addReferences = useInputBarStore((s) => s.addReferences);
|
||||
const setFirstFrame = useInputBarStore((s) => s.setFirstFrame);
|
||||
@ -144,6 +144,7 @@ export function InputBar() {
|
||||
>
|
||||
种子值
|
||||
</button>
|
||||
{scrollBottomBtn}
|
||||
</div>
|
||||
|
||||
<div
|
||||
|
||||
@ -52,3 +52,4 @@
|
||||
color: var(--color-text-disabled);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
|
||||
@ -27,6 +27,7 @@ export function VideoGenerationPage() {
|
||||
const [detailTaskId, setDetailTaskId] = useState<string | null>(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() {
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<InputBar />
|
||||
<InputBar scrollBottomBtn={showScrollBottom ? (
|
||||
<button
|
||||
onClick={() => 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)'; }}
|
||||
>
|
||||
回到底部 ↓
|
||||
</button>
|
||||
) : null} />
|
||||
</main>
|
||||
<VideoDetailModal
|
||||
task={detailTask}
|
||||
|
||||
@ -35,7 +35,7 @@
|
||||
--radius-thumbnail: 8px;
|
||||
--radius-dropdown: 12px;
|
||||
|
||||
--input-bar-max-width: 900px;
|
||||
--input-bar-max-width: 950px;
|
||||
--send-btn-size: 36px;
|
||||
--thumbnail-size: 80px;
|
||||
--toolbar-height: 44px;
|
||||
|
||||
@ -5,16 +5,49 @@ 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> {
|
||||
/** 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<MediaInfo> {
|
||||
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<InputBarState>((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<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] });
|
||||
_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<string, string>) => 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
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user