Merge branch 'main' of https://gitee.com/wg123/video-gen
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -3,6 +3,7 @@ import json
|
||||
import logging
|
||||
import os
|
||||
from datetime import datetime, timezone
|
||||
from urllib.parse import urlparse
|
||||
|
||||
from sqlalchemy import or_, select
|
||||
|
||||
@@ -49,6 +50,31 @@ def _source_date_dir(record: GenerationRecord) -> str:
|
||||
return created.strftime("%Y/%m/%d")
|
||||
|
||||
|
||||
def _normalize_image_extension(output_format: str | None, remote_url: str | None = None) -> str:
|
||||
value = str(output_format or "").strip().lower()
|
||||
if value in {"jpg", "jpeg"}:
|
||||
return "jpg"
|
||||
if value == "png":
|
||||
return "png"
|
||||
if value == "webp":
|
||||
return "webp"
|
||||
|
||||
if remote_url:
|
||||
try:
|
||||
path = urlparse(remote_url).path or ""
|
||||
except Exception:
|
||||
path = str(remote_url)
|
||||
suffix = os.path.splitext(path)[1].lower().lstrip(".")
|
||||
if suffix in {"jpg", "jpeg"}:
|
||||
return "jpg"
|
||||
if suffix == "png":
|
||||
return "png"
|
||||
if suffix == "webp":
|
||||
return "webp"
|
||||
|
||||
return "jpg"
|
||||
|
||||
|
||||
async def _download_generation_record_upscale_source(
|
||||
record: GenerationRecord,
|
||||
remote_url: str,
|
||||
@@ -308,10 +334,10 @@ class TaskQueue:
|
||||
await asyncio.sleep(POLL_INTERVAL)
|
||||
await self.queue.put(record_id)
|
||||
|
||||
async def _process_image(self, db, record):
|
||||
async def _process_image(self, db, record: GenerationRecord):
|
||||
"""Process image generation task - calls API directly."""
|
||||
record_id = record.id
|
||||
from app.services.image_gen import submit_image_task, _log_image_response
|
||||
from app.services.image_gen import submit_image_task
|
||||
|
||||
try:
|
||||
engine = await get_active_image_engine(db)
|
||||
@@ -323,60 +349,81 @@ class TaskQueue:
|
||||
include_media_references=False,
|
||||
)
|
||||
|
||||
if poll_result["error"] == "":
|
||||
remote_url = poll_result.get("image_url")
|
||||
storage_path = None
|
||||
file_size_bytes = 0
|
||||
if settings.STORAGE_TYPE == "local" and remote_url:
|
||||
try:
|
||||
date_dir = datetime.now().strftime("%Y/%m/%d")
|
||||
dest_dir = os.path.join(settings.STORAGE_IMAGE_LOCAL_PATH, date_dir)
|
||||
os.makedirs(dest_dir, exist_ok=True)
|
||||
dest = os.path.join(dest_dir, f"{record_id}.png")
|
||||
await download_image(remote_url, dest)
|
||||
record.image_url = f"/generate/images/{date_dir}/{record_id}.png"
|
||||
storage_path = dest
|
||||
file_size_bytes = safe_file_size(dest)
|
||||
except Exception as e:
|
||||
logger.warning(f"Download failed, using remote URL: {e}")
|
||||
record.image_url = remote_url
|
||||
else:
|
||||
record.image_url = remote_url
|
||||
record.image_tokens_used = poll_result.get("image_tokens", 0)
|
||||
await sync_generation_record_media_token_snapshot(db, record, provider_response=poll_result)
|
||||
record.status = "completed"
|
||||
record.generated_at = datetime.now()
|
||||
if record.image_url:
|
||||
await record_generation_record_generated_resource(
|
||||
db,
|
||||
record,
|
||||
resource_url=record.image_url,
|
||||
storage_path=storage_path,
|
||||
file_size_bytes=file_size_bytes,
|
||||
remote_url=remote_url,
|
||||
generated_at=record.generated_at,
|
||||
)
|
||||
await db.commit()
|
||||
logger.info(f"Image task completed: {record_id}")
|
||||
else:
|
||||
await mark_generation_record_failed_and_refund_once(
|
||||
db,
|
||||
record=record,
|
||||
error_message=poll_result.get("error", "图片生成失败"),
|
||||
)
|
||||
await db.commit()
|
||||
logger.info(f"Image task failed: {record_id}")
|
||||
_log_image_response(record_id, poll_result)
|
||||
if not isinstance(poll_result, dict):
|
||||
raise RuntimeError("图片供应商返回结构异常")
|
||||
|
||||
except Exception as e:
|
||||
items = poll_result.get("items") or []
|
||||
if not isinstance(items, list):
|
||||
raise RuntimeError("图片供应商返回结果列表异常")
|
||||
if not items:
|
||||
raise RuntimeError("图片供应商未返回图片结果")
|
||||
if len(items) != 1:
|
||||
raise RuntimeError(f"图片供应商单图返回数量异常,期望 1,实际 {len(items)}")
|
||||
|
||||
item = items[0] or {}
|
||||
if not isinstance(item, dict):
|
||||
raise RuntimeError("图片供应商返回单项结果结构异常")
|
||||
|
||||
item_error = item.get("error_message") or item.get("error_code")
|
||||
if item_error:
|
||||
raise RuntimeError(str(item_error))
|
||||
|
||||
remote_url = str(item.get("remote_result_url") or "").strip()
|
||||
if not remote_url:
|
||||
raise RuntimeError("图片供应商成功响应但没有图片地址")
|
||||
|
||||
storage_path = None
|
||||
file_size_bytes = 0
|
||||
if settings.STORAGE_TYPE == "local":
|
||||
try:
|
||||
date_dir = _source_date_dir(record)
|
||||
dest_dir = os.path.join(settings.STORAGE_IMAGE_LOCAL_PATH, date_dir)
|
||||
os.makedirs(dest_dir, exist_ok=True)
|
||||
extension = _normalize_image_extension(item.get("output_format"), remote_url)
|
||||
dest = os.path.join(dest_dir, f"{record_id}.{extension}")
|
||||
await download_image(remote_url, dest)
|
||||
record.image_url = f"/generate/images/{date_dir}/{record_id}.{extension}"
|
||||
storage_path = dest
|
||||
file_size_bytes = safe_file_size(dest)
|
||||
except Exception as exc:
|
||||
logger.warning("GenerationRecord 图片本地保存失败,回退远程地址: record_id=%s error=%s", record_id, exc)
|
||||
record.image_url = remote_url
|
||||
else:
|
||||
record.image_url = remote_url
|
||||
|
||||
record.image_tokens_used = int(poll_result.get("image_tokens", 0) or 0)
|
||||
provider_response = poll_result.get("response_data") or {}
|
||||
await sync_generation_record_media_token_snapshot(
|
||||
db,
|
||||
record,
|
||||
provider_response=provider_response if isinstance(provider_response, dict) else {},
|
||||
)
|
||||
record.status = "completed"
|
||||
record.pipeline_stage = GenerationRecordPipelineStage.DONE.value
|
||||
record.generated_at = datetime.now(timezone.utc)
|
||||
record.error_message = None
|
||||
if record.image_url:
|
||||
await record_generation_record_generated_resource(
|
||||
db,
|
||||
record,
|
||||
resource_url=record.image_url,
|
||||
storage_path=storage_path,
|
||||
file_size_bytes=file_size_bytes,
|
||||
remote_url=remote_url,
|
||||
generated_at=record.generated_at,
|
||||
)
|
||||
await db.commit()
|
||||
logger.info("Image task completed: %s", record_id)
|
||||
|
||||
except Exception as exc:
|
||||
record.pipeline_stage = GenerationRecordPipelineStage.FAILED.value
|
||||
await mark_generation_record_failed_and_refund_once(
|
||||
db,
|
||||
record=record,
|
||||
error_message=str(e),
|
||||
error_message=(getattr(exc, "safe_message", None) or str(exc) or "图片生成失败"),
|
||||
)
|
||||
_log_image_response(record_id, {}, str(e))
|
||||
await db.commit()
|
||||
logger.error(f"Image task failed: {record_id}, error: {e}")
|
||||
logger.error("Image task failed: %s, error: %s", record_id, exc, exc_info=True)
|
||||
|
||||
def stop(self):
|
||||
"""Signal the queue to stop."""
|
||||
|
||||
+38
-38
File diff suppressed because one or more lines are too long
Vendored
+1
-1
@@ -28,7 +28,7 @@
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
<script type="module" crossorigin src="/assets/index-DOSsz052.js"></script>
|
||||
<script type="module" crossorigin src="/assets/index-DYI2idb2.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-CKeRPhR_.css">
|
||||
</head>
|
||||
<body>
|
||||
|
||||
@@ -551,8 +551,13 @@ const AppLayout: React.FC = () => {
|
||||
if (data.alipay) setPaymentMethod('alipay');
|
||||
else if (data.wechat) setPaymentMethod('wechat');
|
||||
}).catch(() => { });
|
||||
loadUnreadCount();
|
||||
}, [user]);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (user) {
|
||||
loadUnreadCount();
|
||||
}
|
||||
}, [user?.id, user?.credits]);
|
||||
|
||||
const selectedKey = location.pathname.startsWith('/records') ? '/records' : location.pathname;
|
||||
const sidebarW = SIDEBAR_W;
|
||||
|
||||
@@ -31,6 +31,8 @@ interface UploadSelectorProps {
|
||||
maxAudioDuration?: number;
|
||||
usedAudioDuration?: number;
|
||||
hideLimitHint?: boolean;
|
||||
/** 创作类型:'image' 时隐藏真人素材库/虚拟素材库选项 */
|
||||
mediaType?: string;
|
||||
}
|
||||
|
||||
const UploadSelector: React.FC<UploadSelectorProps> = ({
|
||||
@@ -54,6 +56,7 @@ const UploadSelector: React.FC<UploadSelectorProps> = ({
|
||||
maxAudioDuration,
|
||||
usedAudioDuration,
|
||||
hideLimitHint,
|
||||
mediaType,
|
||||
}) => {
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const [portraitPickerOpen, setPortraitPickerOpen] = useState(false);
|
||||
@@ -146,46 +149,50 @@ const UploadSelector: React.FC<UploadSelectorProps> = ({
|
||||
<DatabaseOutlined style={{ fontSize: 14, color: '#64748b' }} />
|
||||
<span style={{ fontSize: 14, color: '#334155' }}>从资产中选择</span>
|
||||
</div>
|
||||
<div
|
||||
onClick={() => openPortraitPicker('real_person')}
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 12,
|
||||
padding: '8px 16px',
|
||||
cursor: 'pointer',
|
||||
transition: 'background 0.15s ease',
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.background = '#f1f5f9';
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.background = 'transparent';
|
||||
}}
|
||||
>
|
||||
<UserOutlined style={{ fontSize: 14, color: '#64748b' }} />
|
||||
<span style={{ fontSize: 14, color: '#334155' }}>真人素材库</span>
|
||||
</div>
|
||||
<div
|
||||
onClick={() => openPortraitPicker('aigc_virtual')}
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 12,
|
||||
padding: '8px 16px',
|
||||
cursor: 'pointer',
|
||||
transition: 'background 0.15s ease',
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.background = '#f1f5f9';
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.background = 'transparent';
|
||||
}}
|
||||
>
|
||||
<TeamOutlined style={{ fontSize: 14, color: '#64748b' }} />
|
||||
<span style={{ fontSize: 14, color: '#334155' }}>虚拟素材库</span>
|
||||
</div>
|
||||
{mediaType !== 'image' && (
|
||||
<div
|
||||
onClick={() => openPortraitPicker('real_person')}
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 12,
|
||||
padding: '8px 16px',
|
||||
cursor: 'pointer',
|
||||
transition: 'background 0.15s ease',
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.background = '#f1f5f9';
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.background = 'transparent';
|
||||
}}
|
||||
>
|
||||
<UserOutlined style={{ fontSize: 14, color: '#64748b' }} />
|
||||
<span style={{ fontSize: 14, color: '#334155' }}>真人素材库</span>
|
||||
</div>
|
||||
)}
|
||||
{mediaType !== 'image' && (
|
||||
<div
|
||||
onClick={() => openPortraitPicker('aigc_virtual')}
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 12,
|
||||
padding: '8px 16px',
|
||||
cursor: 'pointer',
|
||||
transition: 'background 0.15s ease',
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.background = '#f1f5f9';
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.background = 'transparent';
|
||||
}}
|
||||
>
|
||||
<TeamOutlined style={{ fontSize: 14, color: '#64748b' }} />
|
||||
<span style={{ fontSize: 14, color: '#334155' }}>虚拟素材库</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React from 'react';
|
||||
import React, { useState, useEffect, useRef } from 'react';
|
||||
import { LoadingOutlined, PlayCircleFilled, WarningOutlined } from '@ant-design/icons';
|
||||
|
||||
export interface GenerationTaskResourceItem {
|
||||
@@ -12,6 +12,7 @@ export interface GenerationTaskResourceItem {
|
||||
videoCoverUrl?: string | null;
|
||||
errorMessage?: string | null;
|
||||
generationIndex?: number | null;
|
||||
createdAt?: string;
|
||||
}
|
||||
|
||||
export interface GenerationTaskResourceGroup extends GenerationTaskResourceItem {
|
||||
@@ -49,6 +50,125 @@ const isPending = (item: GenerationTaskResourceItem): boolean => {
|
||||
return !status || ['pending', 'queued', 'preparing', 'generating', 'creating_provider_task', 'waiting_remote', 'polling', 'result_ready', 'download_queued', 'downloading', 'retry_waiting'].includes(status);
|
||||
};
|
||||
|
||||
const MAX_DURATION_SECONDS = 180;
|
||||
|
||||
interface ProgressItemProps {
|
||||
item: GenerationTaskResourceItem;
|
||||
isPending: boolean;
|
||||
isCompleted: boolean;
|
||||
onAnimationComplete?: () => void;
|
||||
onProgressChange?: (progress: number) => void;
|
||||
}
|
||||
|
||||
const PROGRESS_RATE = 0.6;
|
||||
|
||||
const calculateProgressValue = (createdAt?: string): number => {
|
||||
if (!createdAt) return 0;
|
||||
const createdTime = new Date(createdAt).getTime();
|
||||
const now = Date.now();
|
||||
const elapsedSeconds = (now - createdTime) / 1000;
|
||||
if (elapsedSeconds >= MAX_DURATION_SECONDS) {
|
||||
return 99;
|
||||
}
|
||||
return Math.min(99, elapsedSeconds * PROGRESS_RATE);
|
||||
};
|
||||
|
||||
const ProgressItem: React.FC<ProgressItemProps> = ({ item, isPending: isPendingProp, isCompleted, onAnimationComplete, onProgressChange }) => {
|
||||
const [progress, setProgress] = useState<number>(0);
|
||||
const [displayProgress, setDisplayProgress] = useState<number>(0);
|
||||
const [isFinishing, setIsFinishing] = useState(false);
|
||||
const intervalRef = useRef<number | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (onProgressChange) {
|
||||
onProgressChange(displayProgress);
|
||||
}
|
||||
}, [displayProgress, onProgressChange]);
|
||||
|
||||
useEffect(() => {
|
||||
const newProgress = calculateProgressValue(item.createdAt);
|
||||
setProgress(newProgress);
|
||||
setDisplayProgress(newProgress);
|
||||
}, [item]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isCompleted && !isFinishing) {
|
||||
setIsFinishing(true);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!isPendingProp && !isFinishing) {
|
||||
if (intervalRef.current) {
|
||||
clearInterval(intervalRef.current);
|
||||
intervalRef.current = null;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const updateProgress = () => {
|
||||
const newProgress = calculateProgressValue(item.createdAt);
|
||||
setProgress(newProgress);
|
||||
setDisplayProgress(newProgress);
|
||||
};
|
||||
|
||||
intervalRef.current = window.setInterval(updateProgress, 1000);
|
||||
|
||||
return () => {
|
||||
if (intervalRef.current) {
|
||||
clearInterval(intervalRef.current);
|
||||
intervalRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [item.createdAt, isPendingProp, isCompleted, isFinishing, item.id]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isFinishing) {
|
||||
const targetProgress = 100;
|
||||
const currentProgress = displayProgress;
|
||||
const duration = 800;
|
||||
const startTime = Date.now();
|
||||
|
||||
const animate = () => {
|
||||
const elapsed = Date.now() - startTime;
|
||||
const progress = Math.min(elapsed / duration, 1);
|
||||
const easeProgress = 1 - Math.pow(1 - progress, 3);
|
||||
const newProgress = currentProgress + (targetProgress - currentProgress) * easeProgress;
|
||||
|
||||
setDisplayProgress(newProgress);
|
||||
|
||||
if (progress < 1) {
|
||||
requestAnimationFrame(animate);
|
||||
} else {
|
||||
setTimeout(() => {
|
||||
onAnimationComplete?.();
|
||||
}, 100);
|
||||
}
|
||||
};
|
||||
|
||||
requestAnimationFrame(animate);
|
||||
}
|
||||
}, [isFinishing, displayProgress, onAnimationComplete]);
|
||||
|
||||
if (!isPendingProp && !isFinishing) return null;
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
marginTop: 4,
|
||||
}}>
|
||||
<div style={{
|
||||
textAlign: 'center',
|
||||
fontSize: 13,
|
||||
color: '#8b5cf6',
|
||||
fontWeight: 700,
|
||||
textShadow: '0 0 8px rgba(139, 92, 246, 0.3)',
|
||||
transition: 'all 0.3s ease-out',
|
||||
}}>
|
||||
{Math.round(displayProgress)}%
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const GenerationTaskResourceGrid: React.FC<Props> = ({ task, onPreview, resolveUrl = (url) => url || '' }) => {
|
||||
const count = Math.max(1, Math.min(5, Number(task.generationCount || task.childItems?.length || 1)));
|
||||
const children = [...(task.childItems || [])].sort((a, b) => Number(a.generationIndex || 0) - Number(b.generationIndex || 0));
|
||||
@@ -62,8 +182,49 @@ const GenerationTaskResourceGrid: React.FC<Props> = ({ task, onPreview, resolveU
|
||||
pipelineStage: task.pipelineStage,
|
||||
generationIndex: index + 1,
|
||||
errorMessage: task.errorMessage,
|
||||
createdAt: task.createdAt,
|
||||
})) : [task]);
|
||||
|
||||
const [finishingItems, setFinishingItems] = useState<Set<string>>(new Set());
|
||||
const [fullProgressItems, setFullProgressItems] = useState<Set<string>>(new Set());
|
||||
const processedItems = useRef<Set<string>>(new Set());
|
||||
|
||||
const handleProgressChange = (itemId: string, progress: number) => {
|
||||
if (progress >= 100) {
|
||||
setFullProgressItems(prev => {
|
||||
if (prev.has(itemId)) return prev;
|
||||
const next = new Set(prev);
|
||||
next.add(itemId);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
items.slice(0, 5).forEach((item, index) => {
|
||||
const displayStatus = item.displayStatus || item.pipelineStage || item.status || 'generating';
|
||||
const pending = isPending(item);
|
||||
const completed = !pending && displayStatus !== 'failed' && displayStatus !== 'download_failed' && displayStatus !== 'deleted';
|
||||
const itemId = item.id || `${index}`;
|
||||
if (completed && !processedItems.current.has(itemId) && pending === false) {
|
||||
processedItems.current.add(itemId);
|
||||
setFinishingItems(prev => {
|
||||
const next = new Set(prev);
|
||||
next.add(itemId);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
});
|
||||
}, [items]);
|
||||
|
||||
const handleFinishAnimation = (itemId: string) => {
|
||||
setFinishingItems(prev => {
|
||||
const next = new Set(prev);
|
||||
next.delete(itemId);
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{ width: '100%', height: '100%', display: 'grid', gridTemplateColumns: 'repeat(6, minmax(0, 1fr))', gridAutoRows: 'minmax(0, 1fr)', gap: count > 1 ? 6 : 0 }}>
|
||||
{items.slice(0, 5).map((item, index) => {
|
||||
@@ -73,6 +234,14 @@ const GenerationTaskResourceGrid: React.FC<Props> = ({ task, onPreview, resolveU
|
||||
const coverUrl = resolveUrl(`/static${item.videoCoverUrl}&w=300&q=50`);
|
||||
const isVideo = (item.genType || task.genType) === 'video';
|
||||
const hasResource = isVideo ? !!videoUrl : !!imageUrl;
|
||||
const pending = isPending(item);
|
||||
const completed = !pending && displayStatus !== 'failed' && displayStatus !== 'download_failed' && displayStatus !== 'deleted';
|
||||
const itemId = item.id || `${index}`;
|
||||
const isFinishing = finishingItems.has(itemId);
|
||||
const isFullProgress = fullProgressItems.has(itemId);
|
||||
const shouldShowContent = completed && !isFinishing;
|
||||
const statusLabel = isFinishing ? '加载中' : statusText(item);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={item.id || `${index}`}
|
||||
@@ -87,7 +256,7 @@ const GenerationTaskResourceGrid: React.FC<Props> = ({ task, onPreview, resolveU
|
||||
border: '1px solid #E7EAF0',
|
||||
}}
|
||||
>
|
||||
{hasResource && displayStatus !== 'deleted' && displayStatus !== 'failed' && displayStatus !== 'download_failed' && !isPending(item) ? (
|
||||
{hasResource && displayStatus !== 'deleted' && displayStatus !== 'failed' && displayStatus !== 'download_failed' && shouldShowContent ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onPreview(isVideo ? videoUrl : imageUrl, isVideo ? 'video' : 'image')}
|
||||
@@ -103,9 +272,10 @@ const GenerationTaskResourceGrid: React.FC<Props> = ({ task, onPreview, resolveU
|
||||
</button>
|
||||
) : (
|
||||
<div style={{ width: '100%', height: '100%', minHeight: 0, display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', gap: 7, padding: 8, textAlign: 'center' }}>
|
||||
{isPending(item) ? <LoadingOutlined spin style={{ color: '#8b5cf6', fontSize: items.length > 2 ? 20 : 34 }} /> : <WarningOutlined style={{ color: displayStatus === 'deleted' ? '#98A2B3' : '#A45B5B', fontSize: items.length > 2 ? 20 : 34 }} />}
|
||||
<span style={{ fontSize: items.length > 2 ? 10 : 12, color: isPending(item) ? '#8b5cf6' : (displayStatus === 'deleted' ? '#98A2B3' : '#A45B5B'), fontWeight: 500 }}>{statusText(item)}</span>
|
||||
{!isPending(item) && item.errorMessage && items.length <= 2 ? <span style={{ fontSize: 10, color: '#A45B5B', lineHeight: 1.3, maxHeight: 28, overflow: 'hidden' }}>{item.errorMessage}</span> : null}
|
||||
{pending || isFinishing ? <LoadingOutlined spin style={{ color: '#8b5cf6', fontSize: items.length > 2 ? 20 : 34 }} /> : <WarningOutlined style={{ color: displayStatus === 'deleted' ? '#98A2B3' : '#A45B5B', fontSize: items.length > 2 ? 20 : 34 }} />}
|
||||
<span style={{ fontSize: items.length > 2 ? 10 : 12, color: pending || isFinishing ? '#8b5cf6' : (displayStatus === 'deleted' ? '#98A2B3' : '#A45B5B'), fontWeight: 500 }}>{statusLabel}</span>
|
||||
<ProgressItem item={item} isPending={pending || isFinishing} isCompleted={completed} onAnimationComplete={() => handleFinishAnimation(itemId)} onProgressChange={(progress) => handleProgressChange(itemId, progress)} />
|
||||
{!pending && item.errorMessage && items.length <= 2 ? <span style={{ fontSize: 10, color: '#A45B5B', lineHeight: 1.3, maxHeight: 28, overflow: 'hidden' }}>{item.errorMessage}</span> : null}
|
||||
</div>
|
||||
)}
|
||||
{items.length > 1 ? <span style={{ position: 'absolute', top: 5, left: 5, zIndex: 2, padding: '1px 6px', borderRadius: 10, background: 'rgba(17,24,39,.58)', color: '#fff', fontSize: 10 }}>#{item.generationIndex || index + 1}</span> : null}
|
||||
|
||||
@@ -29,7 +29,7 @@ import GenerationTaskResourceGrid from '../components/generation/GenerationTaskR
|
||||
|
||||
|
||||
import {
|
||||
getParameters, createGenerationTask, getgen_list, getEngine, uploadImage,uploadAudio,
|
||||
getParameters, createGenerationTask, getgen_list, getEngine, uploadImage, uploadAudio,
|
||||
uploadVideo, getCreditRatios, deleteHistory, calculateCredits
|
||||
} from '../api';
|
||||
|
||||
@@ -285,10 +285,10 @@ const AIChatPage: React.FC = () => {
|
||||
if (previewVisible && previewType === 'video') {
|
||||
const playVideo = () => {
|
||||
if (videoRef.current) {
|
||||
videoRef.current.play().catch(() => {});
|
||||
videoRef.current.play().catch(() => { });
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
if (videoRef.current) {
|
||||
if (videoRef.current.readyState >= 2) {
|
||||
playVideo();
|
||||
@@ -296,9 +296,9 @@ const AIChatPage: React.FC = () => {
|
||||
videoRef.current.addEventListener('loadedmetadata', playVideo);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
const timer = setTimeout(playVideo, 300);
|
||||
|
||||
|
||||
return () => {
|
||||
clearTimeout(timer);
|
||||
if (videoRef.current) {
|
||||
@@ -376,7 +376,7 @@ const AIChatPage: React.FC = () => {
|
||||
playPromise.catch(() => {
|
||||
// 自动播放被阻止时静音重试
|
||||
v.muted = true;
|
||||
v.play().catch(() => {});
|
||||
v.play().catch(() => { });
|
||||
});
|
||||
}
|
||||
} else if (!attachmentPreviewVisible && attachmentPreviewVideoRef.current) {
|
||||
@@ -517,7 +517,7 @@ const AIChatPage: React.FC = () => {
|
||||
|
||||
useEffect(() => {
|
||||
const state = location.state as any;
|
||||
|
||||
|
||||
if (state) {
|
||||
if (state.generationPrompt) {
|
||||
setInputValue(state.generationPrompt);
|
||||
@@ -754,10 +754,8 @@ const AIChatPage: React.FC = () => {
|
||||
setCreditCalculationData(data);
|
||||
})
|
||||
getgen_list(Pagebreak).then((data: any) => {
|
||||
// API 按创建时间倒序返回;对话区按时间正序展示,最新消息保持在底部。
|
||||
const mess_list = data.items
|
||||
const total = data.total
|
||||
setGen_list(mess_list)
|
||||
setGen_list(data.items)
|
||||
setTotalnumber(total)
|
||||
})
|
||||
// getParameters()
|
||||
@@ -809,16 +807,12 @@ const AIChatPage: React.FC = () => {
|
||||
// 设置5秒轮询
|
||||
pollingRef.current = window.setInterval(() => {
|
||||
getgen_list(Pagebreak).then((data: any) => {
|
||||
// 只更新正在生成的任务,不影响其他已加载的数据
|
||||
setGen_list((prevList: any[]) => {
|
||||
return prevList.map((prevItem: any) => {
|
||||
// 只更新正在生成的任务
|
||||
if (prevItem.status === 'generating') {
|
||||
const newItem = data.items.find((item: any) => item.id === prevItem.id);
|
||||
// 如果找到了对应的新数据,使用新数据;否则保持旧数据
|
||||
return newItem || prevItem;
|
||||
}
|
||||
// 非生成中的任务保持不变
|
||||
return prevItem;
|
||||
});
|
||||
});
|
||||
@@ -898,7 +892,7 @@ const AIChatPage: React.FC = () => {
|
||||
}
|
||||
|
||||
const supportedRatios = currentEngineSupportedSizes[resolution] ? Object.keys(currentEngineSupportedSizes[resolution]) : [];
|
||||
|
||||
|
||||
const newRatioOptions = supportedRatios.map((res: any) => ({
|
||||
value: res,
|
||||
label: res,
|
||||
@@ -1020,14 +1014,23 @@ const AIChatPage: React.FC = () => {
|
||||
}
|
||||
}
|
||||
|
||||
const toSnakeCase = (str: string) => str.replace(/([A-Z])/g, '_$1').toLowerCase();
|
||||
const convertToSnake = (obj: any) => {
|
||||
if (!obj || typeof obj !== 'object') return obj;
|
||||
if (Array.isArray(obj)) return obj.map(convertToSnake);
|
||||
return Object.fromEntries(
|
||||
Object.entries(obj).map(([k, v]) => [toSnakeCase(k), convertToSnake(v)])
|
||||
);
|
||||
};
|
||||
|
||||
let mediaReferences: MediaReference[] | undefined;
|
||||
if (isFirstLastFrameMode) {
|
||||
mediaReferences = [];
|
||||
if (firstFrame) {
|
||||
mediaReferences.push({ ...firstFrame, role: 'first_frame' });
|
||||
mediaReferences.push(convertToSnake({ ...firstFrame, role: 'first_frame' }));
|
||||
}
|
||||
if (lastFrame) {
|
||||
mediaReferences.push({ ...lastFrame, role: 'last_frame' });
|
||||
mediaReferences.push(convertToSnake({ ...lastFrame, role: 'last_frame' }));
|
||||
}
|
||||
if (mediaReferences.length === 0) {
|
||||
mediaReferences = undefined;
|
||||
@@ -1037,9 +1040,9 @@ const AIChatPage: React.FC = () => {
|
||||
mediaReferences = currentMedia.map((m) => {
|
||||
if (m.type === 'image') {
|
||||
const { duration, ...rest } = m;
|
||||
return rest;
|
||||
return convertToSnake(rest);
|
||||
}
|
||||
return m;
|
||||
return convertToSnake(m);
|
||||
});
|
||||
} else {
|
||||
mediaReferences = undefined;
|
||||
@@ -1075,6 +1078,11 @@ const AIChatPage: React.FC = () => {
|
||||
|
||||
|
||||
|
||||
console.log(newMessage);
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -1094,9 +1102,21 @@ const AIChatPage: React.FC = () => {
|
||||
setPagebreak(newPagebreak);
|
||||
|
||||
getgen_list(newPagebreak).then((data: any) => {
|
||||
// 将data.items的最后一个元素添加到gen_list末尾
|
||||
const newestItem = Array.isArray(data.items) && data.items.length > 0
|
||||
? data.items[data.items.length - 1]
|
||||
const convertedItems = data.items.map((item: any) => {
|
||||
if (item.media_references && Array.isArray(item.media_references)) {
|
||||
item.mediaReferences = item.media_references.map((ref: any) => ({
|
||||
...ref,
|
||||
display_url: ref.display_url || ref.displayUrl,
|
||||
preview_url: ref.preview_url || ref.previewUrl,
|
||||
private_asset_id: ref.private_asset_id || ref.privateAssetId,
|
||||
provider_url: ref.provider_url || ref.providerUrl,
|
||||
remote_asset_id: ref.remote_asset_id || ref.remoteAssetId,
|
||||
}));
|
||||
}
|
||||
return item;
|
||||
});
|
||||
const newestItem = Array.isArray(convertedItems) && convertedItems.length > 0
|
||||
? convertedItems[convertedItems.length - 1]
|
||||
: null;
|
||||
if (newestItem) {
|
||||
setGen_list((prev: any[]) => [...prev, newestItem]);
|
||||
@@ -1452,7 +1472,7 @@ const AIChatPage: React.FC = () => {
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
if (isAudio) {
|
||||
try {
|
||||
@@ -1498,7 +1518,7 @@ const AIChatPage: React.FC = () => {
|
||||
setCurrentMedia(newList.map((m, i) => ({ ...m, label: labels[i] })));
|
||||
} catch (error) {
|
||||
const errorMsg = error?.response?.data?.message || error?.response?.data?.detail || error?.message || '上传失败';
|
||||
antdMessage.error(errorMsg);
|
||||
antdMessage.error(errorMsg);
|
||||
} finally {
|
||||
setUploading(false);
|
||||
}
|
||||
@@ -1634,7 +1654,7 @@ const AIChatPage: React.FC = () => {
|
||||
};
|
||||
} catch (error) {
|
||||
const errorMsg = error?.response?.data?.message || error?.response?.data?.detail || error?.message || '上传失败';
|
||||
antdMessage.error(errorMsg);
|
||||
antdMessage.error(errorMsg);
|
||||
return false;
|
||||
}
|
||||
};
|
||||
@@ -1642,12 +1662,12 @@ const AIChatPage: React.FC = () => {
|
||||
const handleBatchUpload = async (files: File[]) => {
|
||||
let successCount = 0;
|
||||
let failCount = 0;
|
||||
|
||||
|
||||
setUploading(true);
|
||||
|
||||
|
||||
for (const file of files) {
|
||||
const result = await doUpload(file);
|
||||
|
||||
|
||||
if (result) {
|
||||
const latestMedia = useAppStore.getState().currentMedia;
|
||||
const newList = [...latestMedia, result];
|
||||
@@ -1658,9 +1678,9 @@ const AIChatPage: React.FC = () => {
|
||||
failCount++;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
setUploading(false);
|
||||
|
||||
|
||||
if (successCount > 0) {
|
||||
antdMessage.success(`成功上传${successCount}个文件${failCount > 0 ? `,${failCount}个文件上传失败` : ''}`);
|
||||
}
|
||||
@@ -1924,7 +1944,7 @@ const AIChatPage: React.FC = () => {
|
||||
const link = document.createElement('a');
|
||||
link.href = downloadUrl;
|
||||
console.log(downloadUrl);
|
||||
|
||||
|
||||
link.download = previewType === 'image' ? 'image.png' : 'video.mp4';
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
@@ -2038,7 +2058,7 @@ const AIChatPage: React.FC = () => {
|
||||
|
||||
{!collapsed && (
|
||||
<Button
|
||||
block
|
||||
block
|
||||
type="primary"
|
||||
icon={<PlusOutlined />}
|
||||
onClick={handleNewChat}
|
||||
@@ -2047,7 +2067,7 @@ const AIChatPage: React.FC = () => {
|
||||
新对话
|
||||
</Button>
|
||||
)}
|
||||
|
||||
|
||||
|
||||
{!collapsed && conversations.length > 0 && (
|
||||
<div style={{ maxHeight: 'calc(100vh - 120px)', overflowY: 'auto' }}>
|
||||
@@ -2349,6 +2369,7 @@ const AIChatPage: React.FC = () => {
|
||||
setReferenceMode('first_last_frame');
|
||||
} else {
|
||||
setCurrentMedia(msg.mediaReferences.map((ref: any) => ({
|
||||
...ref,
|
||||
name: ref.name,
|
||||
type: ref.type,
|
||||
url: ref.url,
|
||||
@@ -2817,7 +2838,7 @@ const AIChatPage: React.FC = () => {
|
||||
|
||||
{/* 上方输入布局 */}
|
||||
<div style={{ display: 'flex', gap: isFirstLastFrameComposer ? 18 : 18, alignItems: 'flex-end', marginBottom: 14 }}>
|
||||
|
||||
|
||||
{/* 左侧附件区域 */}
|
||||
<div style={{ width: isFirstLastFrameComposer ? 220 : 70, minWidth: isFirstLastFrameComposer ? 220 : 70, display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'flex-start', paddingTop: 2, gap: 8 }}>
|
||||
|
||||
@@ -2966,22 +2987,22 @@ const AIChatPage: React.FC = () => {
|
||||
}
|
||||
}}
|
||||
onPortraitSelect={(assets) => {
|
||||
if (assets.length > 0) {
|
||||
const asset = assets[0];
|
||||
const previewUrl = asset.previewUrl || asset.displayUrl || asset.videoCoverUrl || asset.providerUrl || '';
|
||||
const mediaRef: MediaReference = {
|
||||
name: asset.name || '尾帧图片',
|
||||
type: 'image',
|
||||
url: previewUrl,
|
||||
role: 'last_frame',
|
||||
label: '',
|
||||
source: 'private_portrait_asset',
|
||||
private_asset_id: asset.id,
|
||||
};
|
||||
setLastFrame(mediaRef);
|
||||
antdMessage.success('成功添加尾帧');
|
||||
}
|
||||
}}
|
||||
if (assets.length > 0) {
|
||||
const asset = assets[0];
|
||||
const previewUrl = asset.previewUrl || asset.displayUrl || asset.videoCoverUrl || asset.providerUrl || '';
|
||||
const mediaRef: MediaReference = {
|
||||
name: asset.name || '尾帧图片',
|
||||
type: 'image',
|
||||
url: previewUrl,
|
||||
role: 'last_frame',
|
||||
label: '',
|
||||
source: 'private_portrait_asset',
|
||||
private_asset_id: asset.id,
|
||||
};
|
||||
setLastFrame(mediaRef);
|
||||
antdMessage.success('成功添加尾帧');
|
||||
}
|
||||
}}
|
||||
uploading={uploading}
|
||||
>
|
||||
<div
|
||||
@@ -3045,6 +3066,7 @@ const AIChatPage: React.FC = () => {
|
||||
maxAudioDuration={15}
|
||||
usedAudioDuration={currentMedia.filter(m => m.type === 'audio').reduce((sum, m) => sum + (m.duration || 0), 0)}
|
||||
hideLimitHint={mediaType === 'image'}
|
||||
mediaType={mediaType}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
@@ -3227,6 +3249,7 @@ const AIChatPage: React.FC = () => {
|
||||
maxAudioDuration={15}
|
||||
usedAudioDuration={currentMedia.filter(m => m.type === 'audio').reduce((sum, m) => sum + (m.duration || 0), 0)}
|
||||
hideLimitHint={mediaType === 'image'}
|
||||
mediaType={mediaType}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
@@ -3939,7 +3962,7 @@ const AIChatPage: React.FC = () => {
|
||||
fontWeight: 500,
|
||||
color: '#2f3440',
|
||||
}}>
|
||||
{selectedRatio === 'auto' ? '智能' : selectedRatio} · {selectedResolution == '2K' ? '2K高清' : '4K超清'} · {width}×{height}
|
||||
{selectedRatio === 'auto' ? '智能' : selectedRatio} · {selectedResolution === '1K' ? '1K标清' : selectedResolution === '2K' ? '2K高清' : '4K超清'} · {width}×{height}
|
||||
</Text>
|
||||
<CaretDownOutlined style={{ fontSize: 10, color: '#8b5cf6', marginLeft: 'auto' }} />
|
||||
</button>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useEffect, useRef, useState } from "react";
|
||||
import React, { useEffect, useRef, useState, useCallback } from "react";
|
||||
import { copyToClipboard } from "../utils/clipboard";
|
||||
import { createPortal } from "react-dom";
|
||||
import {
|
||||
@@ -199,6 +199,101 @@ const GeneratePage: React.FC = () => {
|
||||
const [recordStates, setRecordStates] = useState<
|
||||
Record<string, "idle" | "generating" | "done" | "failed">
|
||||
>({});
|
||||
const [generationProgress, setGenerationProgress] = useState<Record<string, number>>({});
|
||||
const [generationDisplayProgress, setGenerationDisplayProgress] = useState<Record<string, number>>({});
|
||||
const [generationFinishing, setGenerationFinishing] = useState<Record<string, boolean>>({});
|
||||
const generationIntervalRef = useRef<number | null>(null);
|
||||
|
||||
const MAX_DURATION_SECONDS = 180;
|
||||
const PROGRESS_RATE = 0.6;
|
||||
|
||||
const calculateProgressValue = useCallback((createdAt?: string): number => {
|
||||
if (!createdAt) return 0;
|
||||
const createdTime = new Date(createdAt).getTime();
|
||||
const now = Date.now();
|
||||
const elapsedSeconds = (now - createdTime) / 1000;
|
||||
if (elapsedSeconds >= MAX_DURATION_SECONDS) {
|
||||
return 99;
|
||||
}
|
||||
return Math.min(99, elapsedSeconds * PROGRESS_RATE);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const generatingIds = Object.entries(recordStates)
|
||||
.filter(([, state]) => state === "generating")
|
||||
.map(([id]) => id);
|
||||
|
||||
if (generatingIds.length === 0) {
|
||||
if (generationIntervalRef.current) {
|
||||
clearInterval(generationIntervalRef.current);
|
||||
generationIntervalRef.current = null;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const updateProgress = () => {
|
||||
generatingIds.forEach((id) => {
|
||||
const record = useAppStore.getState().records.items.find((r: any) => r.id === id);
|
||||
if (record?.createdAt) {
|
||||
const newProgress = calculateProgressValue(record.createdAt);
|
||||
setGenerationProgress((prev) => ({ ...prev, [id]: newProgress }));
|
||||
setGenerationDisplayProgress((prev) => ({ ...prev, [id]: newProgress }));
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
updateProgress();
|
||||
generationIntervalRef.current = window.setInterval(updateProgress, 1000);
|
||||
|
||||
return () => {
|
||||
if (generationIntervalRef.current) {
|
||||
clearInterval(generationIntervalRef.current);
|
||||
generationIntervalRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [recordStates, calculateProgressValue]);
|
||||
|
||||
useEffect(() => {
|
||||
Object.entries(recordStates).forEach(([id, state]) => {
|
||||
if (state === "done" && !generationFinishing[id]) {
|
||||
setGenerationFinishing((prev) => ({ ...prev, [id]: true }));
|
||||
|
||||
const currentProgress = generationDisplayProgress[id] || 0;
|
||||
const targetProgress = 100;
|
||||
const duration = 800;
|
||||
const startTime = Date.now();
|
||||
|
||||
const animate = () => {
|
||||
const elapsed = Date.now() - startTime;
|
||||
const progress = Math.min(elapsed / duration, 1);
|
||||
const easeProgress = 1 - Math.pow(1 - progress, 3);
|
||||
const newProgress = currentProgress + (targetProgress - currentProgress) * easeProgress;
|
||||
|
||||
setGenerationDisplayProgress((prev) => ({ ...prev, [id]: newProgress }));
|
||||
|
||||
if (progress < 1) {
|
||||
requestAnimationFrame(animate);
|
||||
}
|
||||
};
|
||||
|
||||
requestAnimationFrame(animate);
|
||||
}
|
||||
});
|
||||
}, [recordStates]);
|
||||
|
||||
useEffect(() => {
|
||||
Object.entries(recordStates).forEach(([id, state]) => {
|
||||
if (state === "generating") {
|
||||
const record = useAppStore.getState().records.items.find((r: any) => r.id === id);
|
||||
if (record?.createdAt) {
|
||||
const newProgress = calculateProgressValue(record.createdAt);
|
||||
setGenerationProgress((prev) => ({ ...prev, [id]: newProgress }));
|
||||
setGenerationDisplayProgress((prev) => ({ ...prev, [id]: newProgress }));
|
||||
}
|
||||
}
|
||||
});
|
||||
}, [currentRecord]);
|
||||
|
||||
const [editablePrompts, setEditablePrompts] = useState<
|
||||
Record<string, string>
|
||||
>({});
|
||||
@@ -452,30 +547,33 @@ const GeneratePage: React.FC = () => {
|
||||
return 0;
|
||||
};
|
||||
|
||||
// 根据比例计算尺寸
|
||||
const calculateSizeFromRatione = (ratio: string) => {
|
||||
setBlindex(ratio);
|
||||
|
||||
// const resolution = selectedResolution === '4k' ? 4096 : 2048;
|
||||
// const ratioParts = ratio.split(':');
|
||||
// if (ratio === 'auto') {
|
||||
// // 智能模式保持当前尺寸
|
||||
// return;
|
||||
// }
|
||||
// const w = parseInt(ratioParts[0]);
|
||||
// const h = parseInt(ratioParts[1]);
|
||||
// if (w >= h) {
|
||||
// // 横向或正方形
|
||||
// setWidth(resolution);
|
||||
// setHeight(Math.round(resolution * h / w));
|
||||
// } else {
|
||||
// // 纵向
|
||||
// setHeight(resolution);
|
||||
// setWidth(Math.round(resolution * w / h));
|
||||
// }
|
||||
const size = supportedSizes[selectedResolution]?.[ratio];
|
||||
if (size) {
|
||||
const [w, h] = size.split(/x/i).map(Number);
|
||||
setWidth(w);
|
||||
setHeight(h);
|
||||
}
|
||||
};
|
||||
const calculateSizeFromRatiotwo = (ratio: string) => {
|
||||
setFBlindex(parseInt(ratio));
|
||||
const calculateSizeFromRatiotwo = (resolution: string) => {
|
||||
setFBlindex(resolution);
|
||||
const resolutionRatios = Object.keys(supportedSizes[resolution] || {});
|
||||
const ratioOpts = resolutionRatios.map((key) => ({
|
||||
value: key,
|
||||
label: key,
|
||||
}));
|
||||
setRatioOptions(ratioOpts);
|
||||
const newRatio = resolutionRatios.includes(selectedRatio) ? selectedRatio : resolutionRatios[0] || "1:1";
|
||||
if (newRatio !== selectedRatio) {
|
||||
setSelectedRatio(newRatio);
|
||||
}
|
||||
const size = supportedSizes[resolution]?.[newRatio];
|
||||
if (size) {
|
||||
const [w, h] = size.split(/x/i).map(Number);
|
||||
setWidth(w);
|
||||
setHeight(h);
|
||||
}
|
||||
};
|
||||
|
||||
// 图片比例选项
|
||||
@@ -486,6 +584,7 @@ const GeneratePage: React.FC = () => {
|
||||
const [widthandheight, setWidthandHeight] = useState([]);
|
||||
const [blindex, setBlindex] = useState<any>(0);
|
||||
const [fblindex, setFBlindex] = useState<any>(0);
|
||||
const [supportedSizes, setSupportedSizes] = useState<Record<string, Record<string, string>>>({});
|
||||
|
||||
// 监听 blindex 状态变化(解决异步问题)
|
||||
useEffect(() => {
|
||||
@@ -556,40 +655,34 @@ const GeneratePage: React.FC = () => {
|
||||
|
||||
getParameters()
|
||||
.then((data) => {
|
||||
let supportedSizes = (data as any).items?.[0]?.supportedSizes || {};
|
||||
let supportedResolutions = [];
|
||||
let twokwidth = [];
|
||||
let fourkwidth = [];
|
||||
const sizes = (data as any).items?.[0]?.supportedSizes || {};
|
||||
setSupportedSizes(sizes);
|
||||
|
||||
for (let key in supportedSizes["2K"]) {
|
||||
supportedResolutions.push(key);
|
||||
twokwidth.push(supportedSizes["2K"][key]);
|
||||
const resolutionKeys = Object.keys(sizes);
|
||||
const resolutionOpts = resolutionKeys.map((key) => ({
|
||||
value: key,
|
||||
label: key,
|
||||
}));
|
||||
setResolutionOptions(resolutionOpts);
|
||||
|
||||
const initResolution = resolutionKeys.includes(selectedResolution) ? selectedResolution : resolutionKeys[0] || "2K";
|
||||
const ratioKeys = Object.keys(sizes[initResolution] || {});
|
||||
const ratioOpts = ratioKeys.map((key) => ({
|
||||
value: key,
|
||||
label: key,
|
||||
}));
|
||||
setRatioOptions(ratioOpts);
|
||||
|
||||
const initRatio = ratioKeys.includes(selectedRatio) ? selectedRatio : ratioKeys[0] || "1:1";
|
||||
if (initRatio !== selectedRatio) {
|
||||
setSelectedRatio(initRatio);
|
||||
}
|
||||
|
||||
supportedResolutions.forEach((res, index) => {
|
||||
setRatioOptions((prev) => [
|
||||
...prev,
|
||||
{ value: res, label: String(index) },
|
||||
]);
|
||||
});
|
||||
|
||||
for (let key in supportedSizes["4K"]) {
|
||||
fourkwidth.push(supportedSizes["4K"][key]);
|
||||
const initSize = sizes[initResolution]?.[initRatio];
|
||||
if (initSize) {
|
||||
const [w, h] = initSize.split(/x/i).map(Number);
|
||||
setWidth(w);
|
||||
setHeight(h);
|
||||
}
|
||||
|
||||
const newWidthandHeight = [twokwidth, fourkwidth];
|
||||
setWidthandHeight(newWidthandHeight);
|
||||
|
||||
let supportedRatios = [];
|
||||
for (let key in supportedSizes) {
|
||||
supportedRatios.push(key);
|
||||
}
|
||||
supportedRatios.forEach((ratio, index) => {
|
||||
setResolutionOptions((prev) => [
|
||||
...prev,
|
||||
{ value: ratio, label: String(index) },
|
||||
]);
|
||||
});
|
||||
})
|
||||
.catch(() => { });
|
||||
getVideoEngines()
|
||||
@@ -980,9 +1073,14 @@ const GeneratePage: React.FC = () => {
|
||||
if (currentPollingIds.includes(item.id)) {
|
||||
const latest = allRecords.items.find((r: any) => r.id === item.id);
|
||||
if (latest) {
|
||||
// 如果状态不再是generating,标记为完成
|
||||
const frontendState = recordStates[item.id];
|
||||
// 如果后端状态不是generating,说明任务已经完成(成功或失败),需要处理
|
||||
// 不管前端当前是什么状态,都要处理完成的任务
|
||||
if (latest.status !== "generating") {
|
||||
completedIds.push(item.id);
|
||||
} else if (frontendState !== "generating") {
|
||||
// 如果后端状态是generating,但前端不是,更新为generating(处理页面刷新后状态丢失的情况)
|
||||
setRecordStates((p) => ({ ...p, [item.id]: "generating" }));
|
||||
}
|
||||
return latest;
|
||||
}
|
||||
@@ -1052,6 +1150,14 @@ const GeneratePage: React.FC = () => {
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const generatingRecords = recordItems.filter((r) => r.status === "generating");
|
||||
generatingRecords.forEach((record) => {
|
||||
setRecordStates((p) => ({ ...p, [record.id]: "generating" }));
|
||||
startPolling(record.id);
|
||||
});
|
||||
}, [recordItems]);
|
||||
|
||||
const handleGenerate = async (recordId: string) => {
|
||||
if (!canAffordVideo) {
|
||||
message.error("积分不足,请先充值");
|
||||
@@ -1114,6 +1220,9 @@ const GeneratePage: React.FC = () => {
|
||||
if (!record) return;
|
||||
setGenerating((p) => ({ ...p, [recordId]: true }));
|
||||
setRecordStates((p) => ({ ...p, [recordId]: "generating" }));
|
||||
setGenerationProgress((p) => ({ ...p, [recordId]: 0 }));
|
||||
setGenerationDisplayProgress((p) => ({ ...p, [recordId]: 0 }));
|
||||
setGenerationFinishing((p) => ({ ...p, [recordId]: false }));
|
||||
message.loading({
|
||||
content: `「${projectName}」正在重新提交...`,
|
||||
duration: 0,
|
||||
@@ -2245,9 +2354,8 @@ const GeneratePage: React.FC = () => {
|
||||
<Typography.Text
|
||||
style={{ fontSize: 12, color: "#9ca3af" }}
|
||||
>
|
||||
{selectedResolution == "2K" ? "高清 2K" : "超清 4K"}
|
||||
{selectedResolution === "1K" ? "标清 1K" : selectedResolution === "2K" ? "高清 2K" : `${selectedResolution}分辨率`}
|
||||
| {width}×{height}
|
||||
{/* {selectedResolution === '2k' ? '高清 2K' : '超清 4K'} | {width}×{height} */}
|
||||
</Typography.Text>
|
||||
</div>
|
||||
</div>
|
||||
@@ -2676,8 +2784,22 @@ const GeneratePage: React.FC = () => {
|
||||
: "#6366f1",
|
||||
}}
|
||||
>
|
||||
{recordStates[currentRecord.id] === "generating" &&
|
||||
`${mediaType === "image" ? "图片" : "视频"}生成中,请稍候...`}
|
||||
{recordStates[currentRecord.id] === "generating" && (
|
||||
<span>
|
||||
{mediaType === "image" ? "图片" : "视频"}生成中,请稍候...
|
||||
<span
|
||||
style={{
|
||||
marginLeft: 8,
|
||||
fontSize: 14,
|
||||
fontWeight: 700,
|
||||
color: "#6366f1",
|
||||
textShadow: "0 0 8px rgba(99, 102, 241, 0.3)",
|
||||
}}
|
||||
>
|
||||
{Math.round(generationDisplayProgress[currentRecord.id] || 0)}%
|
||||
</span>
|
||||
</span>
|
||||
)}
|
||||
{recordStates[currentRecord.id] === "done" &&
|
||||
`${mediaType === "image" ? "图片" : "视频"}生成成功`}
|
||||
{recordStates[currentRecord.id] === "failed" &&
|
||||
@@ -3585,7 +3707,7 @@ const GeneratePage: React.FC = () => {
|
||||
className="record-actions"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<Space>
|
||||
{/* <Space>
|
||||
{status === "failed" && (
|
||||
<Button
|
||||
type="primary"
|
||||
@@ -3599,7 +3721,7 @@ const GeneratePage: React.FC = () => {
|
||||
重试
|
||||
</Button>
|
||||
)}
|
||||
</Space>
|
||||
</Space> */}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -4324,22 +4446,20 @@ const GeneratePage: React.FC = () => {
|
||||
aspectRatio: params?.aspectRatio,
|
||||
resolution: params?.resolution,
|
||||
});
|
||||
setRecordStates((p) => ({
|
||||
...p,
|
||||
[record.id]: "done",
|
||||
}));
|
||||
startPolling(record.id);
|
||||
message.success({
|
||||
content: `「${projectName}」${mediaText}生成成功!`,
|
||||
content: `「${projectName}」${mediaText}已提交,正在生成中...`,
|
||||
key: record.id,
|
||||
duration: 3,
|
||||
});
|
||||
} catch {
|
||||
} catch (error: any) {
|
||||
setRecordStates((p) => ({
|
||||
...p,
|
||||
[record.id]: "failed",
|
||||
}));
|
||||
const errorMsg = error?.response?.data?.message || error?.message || `${mediaText}生成失败`;
|
||||
message.error({
|
||||
content: `「${projectName}」${mediaText}生成失败`,
|
||||
content: `「${projectName}」${errorMsg}`,
|
||||
key: record.id,
|
||||
duration: 3,
|
||||
});
|
||||
@@ -4401,7 +4521,7 @@ const GeneratePage: React.FC = () => {
|
||||
right: 20,
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
{/* <Button
|
||||
type="primary"
|
||||
danger
|
||||
size="large"
|
||||
@@ -4416,7 +4536,7 @@ const GeneratePage: React.FC = () => {
|
||||
}}
|
||||
>
|
||||
重新生成{type === "video" ? "视频" : "图片"}
|
||||
</Button>
|
||||
</Button> */}
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
@@ -98,14 +98,7 @@ const ProjectsPage: React.FC = () => {
|
||||
|
||||
return (
|
||||
<div
|
||||
style={
|
||||
{
|
||||
margin: '-24px -32px -32px',
|
||||
borderRadius: 20,
|
||||
height: 'calc(100vh - 34px)',
|
||||
padding:'24px'
|
||||
}
|
||||
}>
|
||||
className="content_box" >
|
||||
{/* Header banner */}
|
||||
<div className="animate-fadeInUp" style={{
|
||||
display: 'flex', justifyContent: 'space-between', alignItems: 'center',
|
||||
|
||||
Reference in New Issue
Block a user