修改AI创作界面和后台视频模型添加选择是否支持全能或者收尾帧
This commit is contained in:
+548
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-DveJ8Oia.js"></script>
|
||||
<script type="module" crossorigin src="/assets/index-DfKYC1kL.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-D7ShJUt4.css">
|
||||
</head>
|
||||
<body>
|
||||
|
||||
@@ -20,6 +20,8 @@ interface VideoEngine {
|
||||
maxDuration: number;
|
||||
maxImageCount: number;
|
||||
maxVideoCount: number;
|
||||
supportsFirstLastFrame: boolean;
|
||||
supportsUniversalReference: boolean;
|
||||
isActive: boolean;
|
||||
priority: number;
|
||||
}
|
||||
@@ -72,6 +74,8 @@ const AdminVideoEngines: React.FC = () => {
|
||||
max_duration: values.maxDuration ?? 15,
|
||||
max_image_count: values.maxImageCount ?? 2,
|
||||
max_video_count: values.maxVideoCount ?? 0,
|
||||
supports_first_last_frame: values.supportsFirstLastFrame ?? false,
|
||||
supports_universal_reference: values.supportsUniversalReference ?? true,
|
||||
is_active: values.isActive ?? true,
|
||||
priority: values.priority ?? 0,
|
||||
};
|
||||
@@ -112,6 +116,8 @@ const AdminVideoEngines: React.FC = () => {
|
||||
maxDuration: 15,
|
||||
maxImageCount: 2,
|
||||
maxVideoCount: 0,
|
||||
supportsFirstLastFrame: false,
|
||||
supportsUniversalReference: true,
|
||||
supportedRatios: ['16:9', '4:3', '1:1', '3:4', '9:16', '21:9'],
|
||||
supportedResolutions: ['480p', '720p', '1080p'],
|
||||
supportedDurations: [4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15],
|
||||
@@ -159,6 +165,14 @@ const AdminVideoEngines: React.FC = () => {
|
||||
title: '最大视频', dataIndex: 'maxVideoCount', width: 100,
|
||||
render: (v: number) => <Tag color="cyan">{v} 个</Tag>,
|
||||
},
|
||||
{
|
||||
title: '首尾帧', dataIndex: 'supportsFirstLastFrame', width: 90,
|
||||
render: (v: boolean) => <Tag color={v ? 'green' : 'default'}>{v ? '支持' : '不支持'}</Tag>,
|
||||
},
|
||||
{
|
||||
title: '全能参考', dataIndex: 'supportsUniversalReference', width: 100,
|
||||
render: (v: boolean) => <Tag color={v ? 'purple' : 'default'}>{v ? '支持' : '不支持'}</Tag>,
|
||||
},
|
||||
{
|
||||
title: '状态', dataIndex: 'isActive', width: 80,
|
||||
render: (v: boolean) => <Tag color={v ? 'green' : 'default'}>{v ? '启用' : '停用'}</Tag>,
|
||||
@@ -196,7 +210,7 @@ const AdminVideoEngines: React.FC = () => {
|
||||
rowKey="id"
|
||||
loading={loading}
|
||||
pagination={false}
|
||||
scroll={{ x: 900 }}
|
||||
scroll={{ x: 1100 }}
|
||||
/>
|
||||
</Card>
|
||||
|
||||
@@ -263,7 +277,18 @@ const AdminVideoEngines: React.FC = () => {
|
||||
</Form.Item>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 16 }}>
|
||||
<Form.Item name="priority" label="优先级">
|
||||
<Form.Item name="supportsFirstLastFrame" label="首尾帧模式" valuePropName="checked" style={{ paddingTop: 30, flex: 1 }}>
|
||||
<Switch checkedChildren="支持" unCheckedChildren="不支持" />
|
||||
</Form.Item>
|
||||
<Form.Item name="supportsUniversalReference" label="全能参考模式" valuePropName="checked" style={{ paddingTop: 30, flex: 1 }}>
|
||||
<Switch checkedChildren="支持" unCheckedChildren="不支持" />
|
||||
</Form.Item>
|
||||
<Form.Item name="isActive" label="启用状态" valuePropName="checked" style={{ paddingTop: 30, flex: 1 }}>
|
||||
<Switch />
|
||||
</Form.Item>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 16 }}>
|
||||
<Form.Item name="priority" label="优先级" style={{ flex: 1 }}>
|
||||
<Select size="large" options={[
|
||||
{ value: 0, label: '0 (默认)' },
|
||||
{ value: 1, label: '1' },
|
||||
@@ -273,9 +298,6 @@ const AdminVideoEngines: React.FC = () => {
|
||||
{ value: 10, label: '10 (最高)' },
|
||||
]} />
|
||||
</Form.Item>
|
||||
<Form.Item name="isActive" label="启用状态" valuePropName="checked" style={{ paddingTop: 30 }}>
|
||||
<Switch />
|
||||
</Form.Item>
|
||||
</div>
|
||||
</Form>
|
||||
</Modal>
|
||||
|
||||
+46
@@ -0,0 +1,46 @@
|
||||
"""d4e5f6a7b8c9 - 视频引擎增加首尾帧和全能参考支持字段
|
||||
|
||||
Revision ID: d4e5f6a7b8c9
|
||||
Revises: 6idufv2q1c
|
||||
Create Date: 2026-07-02 00:00:00.000000
|
||||
|
||||
该文件包含 2026-07-02 的数据库迁移内容:
|
||||
1. 视频引擎表增加 supports_first_last_frame 字段(是否支持首尾帧模式)
|
||||
2. 视频引擎表增加 supports_universal_reference 字段(是否支持全能参考模式)
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
revision: str = 'd4e5f6a7b8c9'
|
||||
down_revision: Union[str, None] = '6idufv2q1c'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column(
|
||||
'video_engines',
|
||||
sa.Column(
|
||||
'supports_first_last_frame',
|
||||
sa.Boolean(),
|
||||
server_default=sa.text('false'),
|
||||
nullable=False,
|
||||
),
|
||||
)
|
||||
op.add_column(
|
||||
'video_engines',
|
||||
sa.Column(
|
||||
'supports_universal_reference',
|
||||
sa.Boolean(),
|
||||
server_default=sa.text('true'),
|
||||
nullable=False,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_column('video_engines', 'supports_universal_reference')
|
||||
op.drop_column('video_engines', 'supports_first_last_frame')
|
||||
@@ -51,5 +51,7 @@ async def list_active_engines(
|
||||
"supported_durations": durations,
|
||||
"max_image_count": e.max_image_count,
|
||||
"max_video_count": e.max_video_count,
|
||||
"supports_first_last_frame": e.supports_first_last_frame,
|
||||
"supports_universal_reference": e.supports_universal_reference,
|
||||
})
|
||||
return {"items": items}
|
||||
|
||||
@@ -19,6 +19,8 @@ class VideoEngine(Base, TimestampMixin):
|
||||
max_duration: Mapped[int] = mapped_column(Integer, default=15)
|
||||
max_image_count: Mapped[int] = mapped_column(Integer, default=2)
|
||||
max_video_count: Mapped[int] = mapped_column(Integer, default=0)
|
||||
supports_first_last_frame: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||
supports_universal_reference: Mapped[bool] = mapped_column(Boolean, default=True)
|
||||
generate_url: Mapped[str | None] = mapped_column(String(512), nullable=True, default="")
|
||||
query_url: Mapped[str | None] = mapped_column(String(512), nullable=True, default="")
|
||||
is_active: Mapped[bool] = mapped_column(Boolean, default=True)
|
||||
|
||||
@@ -15,6 +15,8 @@ class VideoEngineCreate(BaseModel):
|
||||
max_duration: int = Field(default=15)
|
||||
max_image_count: int = Field(default=2)
|
||||
max_video_count: int = Field(default=0)
|
||||
supports_first_last_frame: bool = Field(default=False, description="是否支持首尾帧模式")
|
||||
supports_universal_reference: bool = Field(default=True, description="是否支持全能参考模式")
|
||||
generate_url: str = Field(default="", max_length=512)
|
||||
query_url: str = Field(default="", max_length=512)
|
||||
is_active: bool = True
|
||||
@@ -37,6 +39,8 @@ class VideoEnginePublic(BaseModel):
|
||||
supported_durations: list[int] = []
|
||||
max_image_count: int = 2
|
||||
max_video_count: int = 0
|
||||
supports_first_last_frame: bool = False
|
||||
supports_universal_reference: bool = True
|
||||
|
||||
|
||||
class VideoEngineListResponse(BaseModel):
|
||||
|
||||
@@ -126,12 +126,15 @@ async def submit_video_task(
|
||||
for ref in refs:
|
||||
ref_type = ref.get("type")
|
||||
ref_url = ref.get("url", "")
|
||||
ref_role = ref.get("role")
|
||||
if ref_type == "image" and ref_url:
|
||||
resolved = _resolve_url(ref_url)
|
||||
content.append({"type": "image_url", "image_url": {"url": resolved},"role":"reference_image"})
|
||||
role = ref_role if ref_role in ("first_frame", "last_frame") else "reference_image"
|
||||
content.append({"type": "image_url", "image_url": {"url": resolved}, "role": role})
|
||||
elif ref_type == "video" and ref_url:
|
||||
resolved = _resolve_url(ref_url)
|
||||
content.append({"type": "video_url", "video_url": {"url": resolved},"role":"reference_video"})
|
||||
role = ref_role if ref_role else "reference_video"
|
||||
content.append({"type": "video_url", "video_url": {"url": resolved}, "role": role})
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
pass
|
||||
|
||||
|
||||
+442
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-DjAbNPLm.js"></script>
|
||||
<script type="module" crossorigin src="/assets/index-dEsTkAeV.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-Bi8mcSs8.css">
|
||||
</head>
|
||||
<body>
|
||||
|
||||
@@ -65,6 +65,8 @@ interface MediaReference {
|
||||
type: 'image' | 'video';
|
||||
url: string;
|
||||
duration?: number;
|
||||
role?: string;
|
||||
label?: string;
|
||||
}
|
||||
|
||||
interface Message {
|
||||
@@ -156,6 +158,13 @@ const AIChatPage: React.FC = () => {
|
||||
|
||||
const [loading, setLoading] = useState<boolean>(false);
|
||||
|
||||
const [referenceMode, setReferenceMode] = useState<'universal' | 'first_last_frame'>('universal');
|
||||
const [firstFrame, setFirstFrame] = useState<MediaReference | null>(null);
|
||||
const [lastFrame, setLastFrame] = useState<MediaReference | null>(null);
|
||||
const [uploadTarget, setUploadTarget] = useState<'first' | 'last' | null>(null);
|
||||
const [referenceModeDropdownVisible, setReferenceModeDropdownVisible] = useState(false);
|
||||
const [mediaStackHovered, setMediaStackHovered] = useState(false);
|
||||
|
||||
// @ 提及相关状态
|
||||
const [mentionVisible, setMentionVisible] = useState(false);
|
||||
const mentionInputRef = useRef<any>(null);
|
||||
@@ -408,6 +417,24 @@ const AIChatPage: React.FC = () => {
|
||||
};
|
||||
}, [showEngineModal, showImageSettingsModal, showVideoSettingsModal]);
|
||||
|
||||
useEffect(() => {
|
||||
if (mediaType !== 'video') return;
|
||||
const engine = enginesele?.video?.find((e: any) => e.id === countType);
|
||||
if (!engine) return;
|
||||
|
||||
const supportsFLF = engine.supportsFirstLastFrame ?? false;
|
||||
const supportsUR = engine.supportsUniversalReference ?? true;
|
||||
|
||||
setReferenceMode((prevMode) => {
|
||||
if (prevMode === 'first_last_frame' && !supportsFLF) {
|
||||
if (supportsUR) return 'universal';
|
||||
} else if (prevMode === 'universal' && !supportsUR) {
|
||||
if (supportsFLF) return 'first_last_frame';
|
||||
}
|
||||
return prevMode;
|
||||
});
|
||||
}, [countType, mediaType, enginesele]);
|
||||
|
||||
// 初始化获取参数 - 只在组件挂载时执行一次
|
||||
useEffect(() => {
|
||||
getEngine()
|
||||
@@ -648,6 +675,31 @@ const AIChatPage: React.FC = () => {
|
||||
setHeight(width);
|
||||
};
|
||||
|
||||
const currentVideoEngine = enginesele?.video?.find((e: any) => e.id === countType);
|
||||
const supportsFirstLastFrame = currentVideoEngine?.supportsFirstLastFrame ?? false;
|
||||
const supportsUniversalReference = currentVideoEngine?.supportsUniversalReference ?? true;
|
||||
|
||||
const handleReferenceModeChange = (mode: 'universal' | 'first_last_frame') => {
|
||||
if (mode === 'first_last_frame' && !supportsFirstLastFrame) return;
|
||||
if (mode === 'universal' && !supportsUniversalReference) return;
|
||||
setReferenceMode(mode);
|
||||
setReferenceModeDropdownVisible(false);
|
||||
};
|
||||
|
||||
const handleSwapFrames = () => {
|
||||
const temp = firstFrame;
|
||||
setFirstFrame(lastFrame);
|
||||
setLastFrame(temp);
|
||||
};
|
||||
|
||||
const handleRemoveFirstFrame = () => {
|
||||
setFirstFrame(null);
|
||||
};
|
||||
|
||||
const handleRemoveLastFrame = () => {
|
||||
setLastFrame(null);
|
||||
};
|
||||
|
||||
// ==================== 事处理函数 ====================
|
||||
|
||||
const handleNewChat = () => {
|
||||
@@ -668,6 +720,8 @@ const AIChatPage: React.FC = () => {
|
||||
// 清空输入框和已上传媒体
|
||||
setInputValue('');
|
||||
setCurrentMedia([]);
|
||||
setFirstFrame(null);
|
||||
setLastFrame(null);
|
||||
// 显示提示消息
|
||||
message.info('已开启新对话');
|
||||
|
||||
@@ -693,14 +747,44 @@ const AIChatPage: React.FC = () => {
|
||||
const handleSelectChat = (conversationId: string) => {
|
||||
setCurrentConversationId(conversationId);
|
||||
setCurrentMedia([]);
|
||||
setFirstFrame(null);
|
||||
setLastFrame(null);
|
||||
};
|
||||
|
||||
|
||||
const handleSend = async () => {
|
||||
// 验证:必须有内容或图片或视频
|
||||
if (!inputValue.trim() && currentMedia.length === 0) {
|
||||
message.warning('请输入内容或上传图片/视频');
|
||||
return;
|
||||
const isFirstLastFrameMode = mediaType === 'video' && referenceMode === 'first_last_frame';
|
||||
|
||||
if (isFirstLastFrameMode) {
|
||||
if (!inputValue.trim() && !firstFrame) {
|
||||
message.warning('请输入内容或上传首帧图片');
|
||||
return;
|
||||
}
|
||||
if (!firstFrame) {
|
||||
message.warning('请上传首帧图片');
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
if (!inputValue.trim() && currentMedia.length === 0) {
|
||||
message.warning('请输入内容或上传图片/视频');
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
let mediaReferences: MediaReference[] | undefined;
|
||||
if (isFirstLastFrameMode) {
|
||||
mediaReferences = [];
|
||||
if (firstFrame) {
|
||||
mediaReferences.push({ ...firstFrame, role: 'first_frame' });
|
||||
}
|
||||
if (lastFrame) {
|
||||
mediaReferences.push({ ...lastFrame, role: 'last_frame' });
|
||||
}
|
||||
if (mediaReferences.length === 0) {
|
||||
mediaReferences = undefined;
|
||||
}
|
||||
} else {
|
||||
mediaReferences = currentMedia.length > 0 ? [...currentMedia] : undefined;
|
||||
}
|
||||
|
||||
// 创建用户消息对象
|
||||
@@ -711,8 +795,7 @@ const AIChatPage: React.FC = () => {
|
||||
engine_id: countType,
|
||||
|
||||
idempotency_key: new Date().toLocaleString('zh-CN'),
|
||||
// 统一的媒体数组,包含 name、type、url
|
||||
media_references: currentMedia.length > 0 ? [...currentMedia] : undefined,
|
||||
media_references: mediaReferences,
|
||||
// 图片参数(仅图片模式时添加)
|
||||
...(mediaType === 'image' && {
|
||||
image_size: selectedResolution,
|
||||
@@ -739,6 +822,8 @@ const AIChatPage: React.FC = () => {
|
||||
// 创建任务成功后,清空输入框和已上传媒体
|
||||
setInputValue('');
|
||||
setCurrentMedia([]);
|
||||
setFirstFrame(null);
|
||||
setLastFrame(null);
|
||||
|
||||
// 创建任务成功后,重置页数为1,获取最新列表
|
||||
const newPagebreak = { ...Pagebreak, page: 1 };
|
||||
@@ -866,49 +951,79 @@ const AIChatPage: React.FC = () => {
|
||||
};
|
||||
|
||||
const handleUpload = async (file: File) => {
|
||||
// 验证文件类型
|
||||
const isImage = file.type.startsWith('image/');
|
||||
const isVideo = file.type.startsWith('video/');
|
||||
|
||||
if (mediaType === 'video' && referenceMode === 'first_last_frame') {
|
||||
if (!isImage) {
|
||||
message.error('首尾帧模式仅支持上传图片');
|
||||
return false;
|
||||
}
|
||||
if (file.size / 1024 / 1024 > 10) {
|
||||
message.error('图片大小不能超过10MB');
|
||||
return false;
|
||||
}
|
||||
if (!uploadTarget) {
|
||||
return false;
|
||||
}
|
||||
|
||||
setUploading(true);
|
||||
try {
|
||||
const res = await uploadImage(file);
|
||||
const mediaRef: MediaReference = {
|
||||
name: file.name,
|
||||
type: 'image',
|
||||
url: res.url,
|
||||
role: uploadTarget === 'first' ? 'first_frame' : 'last_frame',
|
||||
};
|
||||
if (uploadTarget === 'first') {
|
||||
setFirstFrame(mediaRef);
|
||||
} else {
|
||||
setLastFrame(mediaRef);
|
||||
}
|
||||
message.success('图片上传成功');
|
||||
} catch (error) {
|
||||
message.error('上传失败');
|
||||
} finally {
|
||||
setUploading(false);
|
||||
setUploadTarget(null);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!isImage && !isVideo) {
|
||||
message.error('仅支持图片或视频文件');
|
||||
return false;
|
||||
}
|
||||
|
||||
// 获取当前选中引擎的限制
|
||||
const currentEngineList = mediaType === 'image' ? enginesele.image : enginesele.video;
|
||||
const currentEngine = currentEngineList?.find((e: any) => e.id === countType);
|
||||
const maxImage = currentEngine?.maxImageCount ?? 4;
|
||||
const maxVideo = currentEngine?.maxVideoCount ?? 1;
|
||||
|
||||
// 根据 mediaType 判断是否允许该文件类型
|
||||
if (mediaType === 'image' && isVideo) {
|
||||
message.error('图片模式仅支持上传图片');
|
||||
return false;
|
||||
}
|
||||
|
||||
// 验证文件大小
|
||||
const maxMB = isVideo ? 100 : 10;
|
||||
if (file.size / 1024 / 1024 > maxMB) {
|
||||
message.error(`${isVideo ? '视频' : '图片'}大小不能超过${maxMB}MB`);
|
||||
return false;
|
||||
}
|
||||
|
||||
// 验证图片数量
|
||||
const imageCount = currentMedia.filter((m) => m.type === 'image').length;
|
||||
if (isImage && imageCount >= maxImage) {
|
||||
message.error(`该引擎最多上传${maxImage}张图片`);
|
||||
return false;
|
||||
}
|
||||
|
||||
// 验证视频数量
|
||||
const videoCount = currentMedia.filter((m) => m.type === 'video').length;
|
||||
if (isVideo && videoCount >= maxVideo) {
|
||||
message.error(`该引擎最多上传${maxVideo}个视频`);
|
||||
return false;
|
||||
}
|
||||
|
||||
// 获取视频时长并验证
|
||||
let videoDuration = 0;
|
||||
if (isVideo) {
|
||||
try {
|
||||
@@ -930,14 +1045,11 @@ const AIChatPage: React.FC = () => {
|
||||
}
|
||||
}
|
||||
|
||||
// 设置上传状态
|
||||
setUploading(true);
|
||||
|
||||
try {
|
||||
// 根据文件类型调用相应的上传函数
|
||||
const uploadFn = isImage ? uploadImage : uploadVideo;
|
||||
const res = await uploadFn(file);
|
||||
// 添加到统一的媒体列表
|
||||
const mediaType: 'image' | 'video' = isImage ? 'image' : 'video';
|
||||
const newList = [...currentMedia, {
|
||||
name: file.name,
|
||||
@@ -955,7 +1067,6 @@ const AIChatPage: React.FC = () => {
|
||||
setUploading(false);
|
||||
}
|
||||
|
||||
// 返回false阻止Ant Design的自动上传行为
|
||||
return false;
|
||||
};
|
||||
|
||||
@@ -1341,14 +1452,30 @@ const AIChatPage: React.FC = () => {
|
||||
e.stopPropagation();
|
||||
setInputValue(msg.originalPrompt || '');
|
||||
if (msg.mediaReferences && msg.mediaReferences.length > 0) {
|
||||
setCurrentMedia(msg.mediaReferences.map((ref: any) => ({
|
||||
name: ref.name,
|
||||
type: ref.type,
|
||||
url: ref.url,
|
||||
label: ref.label || '',
|
||||
})));
|
||||
const hasFirstLastFrame = msg.mediaReferences.some((ref: any) => ref.role === 'first_frame' || ref.role === 'last_frame');
|
||||
if (hasFirstLastFrame && msg.genType === 'video') {
|
||||
const first = msg.mediaReferences.find((ref: any) => ref.role === 'first_frame');
|
||||
const last = msg.mediaReferences.find((ref: any) => ref.role === 'last_frame');
|
||||
setFirstFrame(first ? { ...first, label: first.label || '' } : null);
|
||||
setLastFrame(last ? { ...last, label: last.label || '' } : null);
|
||||
setCurrentMedia([]);
|
||||
setReferenceMode('first_last_frame');
|
||||
} else {
|
||||
setCurrentMedia(msg.mediaReferences.map((ref: any) => ({
|
||||
name: ref.name,
|
||||
type: ref.type,
|
||||
url: ref.url,
|
||||
label: ref.label || '',
|
||||
role: ref.role,
|
||||
})));
|
||||
setFirstFrame(null);
|
||||
setLastFrame(null);
|
||||
setReferenceMode('universal');
|
||||
}
|
||||
} else {
|
||||
setCurrentMedia([]);
|
||||
setFirstFrame(null);
|
||||
setLastFrame(null);
|
||||
}
|
||||
msgApi.success('已加载到编辑区');
|
||||
}}
|
||||
@@ -1680,8 +1807,8 @@ const AIChatPage: React.FC = () => {
|
||||
style={{
|
||||
background: 'rgba(255,255,255,0.1)',
|
||||
backdropFilter: 'blur(20px)',
|
||||
borderRadius: 24,
|
||||
padding: 16,
|
||||
borderRadius: 28,
|
||||
padding: 20,
|
||||
boxShadow: '0 8px 32px rgba(99, 102, 241, 0.1), 0 2px 8px rgba(0,0,0,0.04)',
|
||||
transition: 'all 0.3s ease',
|
||||
border: '1px solid rgba(99, 102, 241, 0.08)',
|
||||
@@ -1689,63 +1816,275 @@ const AIChatPage: React.FC = () => {
|
||||
}}
|
||||
>
|
||||
{/* 已上传媒体预览 */}
|
||||
{currentMedia.length > 0 && (
|
||||
<div style={{ display: 'flex', gap: 8, marginBottom: 12, overflowX: 'auto' }}>
|
||||
{currentMedia.map((media, idx) => (
|
||||
<div key={`media-${idx}`} style={{ position: 'relative', width: media.type === 'video' ? 120 : 80, flexShrink: 0, display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 4 }}>
|
||||
{media.type === 'image' ? (
|
||||
{mediaType === 'video' && referenceMode === 'first_last_frame' ? (
|
||||
<div style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', gap: 16, marginBottom: 16, padding: '8px 0' }}>
|
||||
{/* 首帧 */}
|
||||
<div style={{ position: 'relative', width: 100, height: 100, display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 4 }}>
|
||||
<span style={{ position: 'absolute', top: -20, left: 0, fontSize: 11, fontWeight: 600, color: '#6366f1', zIndex: 10 }}>首帧</span>
|
||||
{firstFrame ? (
|
||||
<div style={{ position: 'relative', width: 100, height: 100 }}>
|
||||
<img
|
||||
src={`${import.meta.env.VITE_API_BASE || "http://localhost:8000"}${media.url}`}
|
||||
alt={media.name}
|
||||
src={`${import.meta.env.VITE_API_BASE || "http://localhost:8000"}${firstFrame.url}`}
|
||||
alt={firstFrame.name}
|
||||
onClick={() => {
|
||||
setAttachmentPreviewUrl(media.url);
|
||||
setAttachmentPreviewUrl(firstFrame.url);
|
||||
setAttachmentPreviewType('image');
|
||||
setAttachmentPreviewName(media.name);
|
||||
setAttachmentPreviewName(firstFrame.name);
|
||||
setAttachmentPreviewVisible(true);
|
||||
}}
|
||||
style={{ width: '100%', height: 80, objectFit: 'cover', borderRadius: 8, cursor: 'pointer' }}
|
||||
style={{ width: 100, height: 100, objectFit: 'cover', borderRadius: 12, cursor: 'pointer', border: '2px solid rgba(99, 102, 241, 0.3)', boxShadow: '0 4px 12px rgba(99, 102, 241, 0.15)' }}
|
||||
/>
|
||||
) : (
|
||||
<video
|
||||
src={`${import.meta.env.VITE_API_BASE || "http://localhost:8000"}${media.url}`}
|
||||
muted
|
||||
onClick={() => {
|
||||
setAttachmentPreviewUrl(media.url);
|
||||
setAttachmentPreviewType('video');
|
||||
setAttachmentPreviewName(media.name);
|
||||
setAttachmentPreviewVisible(true);
|
||||
<button
|
||||
onClick={handleRemoveFirstFrame}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: -6,
|
||||
right: -6,
|
||||
width: 22,
|
||||
height: 22,
|
||||
border: 'none',
|
||||
background: '#ef4444',
|
||||
borderRadius: 50,
|
||||
cursor: 'pointer',
|
||||
color: '#fff',
|
||||
fontSize: 12,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
boxShadow: '0 2px 8px rgba(239, 68, 68, 0.4)',
|
||||
zIndex: 10,
|
||||
}}
|
||||
style={{ width: '100%', height: 80, objectFit: 'cover', borderRadius: 8, cursor: 'pointer' }}
|
||||
/>
|
||||
)}
|
||||
<span style={{ fontSize: 11, color: '#64748b', fontWeight: 500 }}>
|
||||
{media.label}
|
||||
</span>
|
||||
{/* 删除已上传媒体按钮 */}
|
||||
<button
|
||||
onClick={() => handleRemoveMedia(idx)}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
right: 0,
|
||||
width: 20,
|
||||
height: 20,
|
||||
border: 'none',
|
||||
background: '#ff4d4f',
|
||||
borderRadius: 50,
|
||||
cursor: 'pointer',
|
||||
color: '#fff',
|
||||
fontSize: 12,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
}}
|
||||
>
|
||||
<DeleteOutlined style={{ fontSize: 11 }} />
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<Upload
|
||||
accept="image/*"
|
||||
showUploadList={false}
|
||||
beforeUpload={(file) => { setUploadTarget('first'); return handleUpload(file); }}
|
||||
>
|
||||
<DeleteOutlined style={{ fontSize: 12 }} />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
<div
|
||||
style={{
|
||||
width: 100,
|
||||
height: 100,
|
||||
borderRadius: 12,
|
||||
border: '2px dashed rgba(99, 102, 241, 0.3)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.25s ease',
|
||||
backgroundColor: 'rgba(99, 102, 241, 0.04)',
|
||||
flexDirection: 'column',
|
||||
gap: 4,
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.borderColor = '#6366f1';
|
||||
e.currentTarget.style.backgroundColor = 'rgba(99, 102, 241, 0.08)';
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.borderColor = 'rgba(99, 102, 241, 0.3)';
|
||||
e.currentTarget.style.backgroundColor = 'rgba(99, 102, 241, 0.04)';
|
||||
}}
|
||||
>
|
||||
<PlusOutlined style={{ fontSize: 20, color: '#6366f1' }} />
|
||||
<span style={{ fontSize: 10, color: '#6366f1', fontWeight: 500 }}>上传首帧</span>
|
||||
</div>
|
||||
</Upload>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 调换按钮 */}
|
||||
<button
|
||||
onClick={handleSwapFrames}
|
||||
disabled={!firstFrame || !lastFrame}
|
||||
style={{
|
||||
width: 36,
|
||||
height: 36,
|
||||
borderRadius: 50,
|
||||
border: 'none',
|
||||
background: firstFrame && lastFrame ? 'linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%)' : '#e2e8f0',
|
||||
cursor: firstFrame && lastFrame ? 'pointer' : 'not-allowed',
|
||||
color: '#fff',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
boxShadow: firstFrame && lastFrame ? '0 4px 12px rgba(99, 102, 241, 0.4)' : 'none',
|
||||
transition: 'all 0.2s ease',
|
||||
flexShrink: 0,
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
if (firstFrame && lastFrame) {
|
||||
e.currentTarget.style.transform = 'scale(1.1)';
|
||||
}
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.transform = 'scale(1)';
|
||||
}}
|
||||
>
|
||||
<SwapOutlined style={{ fontSize: 16 }} />
|
||||
</button>
|
||||
|
||||
{/* 尾帧 */}
|
||||
<div style={{ position: 'relative', width: 100, height: 100, display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 4 }}>
|
||||
<span style={{ position: 'absolute', top: -20, left: 0, fontSize: 11, fontWeight: 600, color: '#8b5cf6', zIndex: 10 }}>尾帧</span>
|
||||
{lastFrame ? (
|
||||
<div style={{ position: 'relative', width: 100, height: 100 }}>
|
||||
<img
|
||||
src={`${import.meta.env.VITE_API_BASE || "http://localhost:8000"}${lastFrame.url}`}
|
||||
alt={lastFrame.name}
|
||||
onClick={() => {
|
||||
setAttachmentPreviewUrl(lastFrame.url);
|
||||
setAttachmentPreviewType('image');
|
||||
setAttachmentPreviewName(lastFrame.name);
|
||||
setAttachmentPreviewVisible(true);
|
||||
}}
|
||||
style={{ width: 100, height: 100, objectFit: 'cover', borderRadius: 12, cursor: 'pointer', border: '2px solid rgba(139, 92, 246, 0.3)', boxShadow: '0 4px 12px rgba(139, 92, 246, 0.15)' }}
|
||||
/>
|
||||
<button
|
||||
onClick={handleRemoveLastFrame}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: -6,
|
||||
right: -6,
|
||||
width: 22,
|
||||
height: 22,
|
||||
border: 'none',
|
||||
background: '#ef4444',
|
||||
borderRadius: 50,
|
||||
cursor: 'pointer',
|
||||
color: '#fff',
|
||||
fontSize: 12,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
boxShadow: '0 2px 8px rgba(239, 68, 68, 0.4)',
|
||||
zIndex: 10,
|
||||
}}
|
||||
>
|
||||
<DeleteOutlined style={{ fontSize: 11 }} />
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<Upload
|
||||
accept="image/*"
|
||||
showUploadList={false}
|
||||
beforeUpload={(file) => { setUploadTarget('last'); return handleUpload(file); }}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
width: 100,
|
||||
height: 100,
|
||||
borderRadius: 12,
|
||||
border: '2px dashed rgba(139, 92, 246, 0.3)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.25s ease',
|
||||
backgroundColor: 'rgba(139, 92, 246, 0.04)',
|
||||
flexDirection: 'column',
|
||||
gap: 4,
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.borderColor = '#8b5cf6';
|
||||
e.currentTarget.style.backgroundColor = 'rgba(139, 92, 246, 0.08)';
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.borderColor = 'rgba(139, 92, 246, 0.3)';
|
||||
e.currentTarget.style.backgroundColor = 'rgba(139, 92, 246, 0.04)';
|
||||
}}
|
||||
>
|
||||
<PlusOutlined style={{ fontSize: 20, color: '#8b5cf6' }} />
|
||||
<span style={{ fontSize: 10, color: '#8b5cf6', fontWeight: 500 }}>上传尾帧</span>
|
||||
</div>
|
||||
</Upload>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
currentMedia.length > 0 && (
|
||||
<div
|
||||
style={{ position: 'relative', marginBottom: 12, minHeight: 90, paddingLeft: 8 }}
|
||||
onMouseEnter={() => setMediaStackHovered(true)}
|
||||
onMouseLeave={() => setMediaStackHovered(false)}
|
||||
>
|
||||
<div style={{ display: 'flex', position: 'relative' }}>
|
||||
{currentMedia.map((media, idx) => {
|
||||
const reversedIdx = currentMedia.length - 1 - idx;
|
||||
const offset = mediaStackHovered ? idx * 88 : idx * 4;
|
||||
return (
|
||||
<div
|
||||
key={`media-${idx}`}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
left: offset,
|
||||
top: 0,
|
||||
zIndex: idx + 1,
|
||||
transition: 'all 0.3s cubic-bezier(0.4, 0, 0.2, 1)',
|
||||
transform: `scale(${1 - reversedIdx * 0.03})`,
|
||||
opacity: mediaStackHovered ? 1 : (1 - reversedIdx * 0.15),
|
||||
}}
|
||||
>
|
||||
<div style={{ position: 'relative', width: media.type === 'video' ? 100 : 80, display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 4 }}>
|
||||
{media.type === 'image' ? (
|
||||
<img
|
||||
src={`${import.meta.env.VITE_API_BASE || "http://localhost:8000"}${media.url}`}
|
||||
alt={media.name}
|
||||
onClick={() => {
|
||||
setAttachmentPreviewUrl(media.url);
|
||||
setAttachmentPreviewType('image');
|
||||
setAttachmentPreviewName(media.name);
|
||||
setAttachmentPreviewVisible(true);
|
||||
}}
|
||||
style={{ width: '100%', height: 80, objectFit: 'cover', borderRadius: 10, cursor: 'pointer', border: '2px solid rgba(255,255,255,0.8)', boxShadow: '0 4px 12px rgba(99, 102, 241, 0.15)' }}
|
||||
/>
|
||||
) : (
|
||||
<video
|
||||
src={`${import.meta.env.VITE_API_BASE || "http://localhost:8000"}${media.url}`}
|
||||
muted
|
||||
onClick={() => {
|
||||
setAttachmentPreviewUrl(media.url);
|
||||
setAttachmentPreviewType('video');
|
||||
setAttachmentPreviewName(media.name);
|
||||
setAttachmentPreviewVisible(true);
|
||||
}}
|
||||
style={{ width: '100%', height: 80, objectFit: 'cover', borderRadius: 10, cursor: 'pointer', border: '2px solid rgba(255,255,255,0.8)', boxShadow: '0 4px 12px rgba(99, 102, 241, 0.15)' }}
|
||||
/>
|
||||
)}
|
||||
<span style={{ fontSize: 10, color: '#64748b', fontWeight: 500 }}>
|
||||
{media.label}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => handleRemoveMedia(idx)}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: -4,
|
||||
right: -4,
|
||||
width: 20,
|
||||
height: 20,
|
||||
border: 'none',
|
||||
background: '#ef4444',
|
||||
borderRadius: 50,
|
||||
cursor: 'pointer',
|
||||
color: '#fff',
|
||||
fontSize: 10,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
boxShadow: '0 2px 6px rgba(239, 68, 68, 0.4)',
|
||||
}}
|
||||
>
|
||||
<DeleteOutlined style={{ fontSize: 10 }} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
|
||||
{/* 输入框区域 */}
|
||||
@@ -1753,57 +2092,59 @@ const AIChatPage: React.FC = () => {
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 12,
|
||||
padding: '12px 16px',
|
||||
padding: '14px 18px',
|
||||
backgroundColor: 'rgba(248,250,252,0.6)',
|
||||
borderRadius: 16,
|
||||
borderRadius: 18,
|
||||
border: '1px solid rgba(99, 102, 241, 0.08)',
|
||||
boxShadow: '0 2px 8px rgba(99, 102, 241, 0.04)',
|
||||
transition: 'all 0.2s ease',
|
||||
position: 'relative',
|
||||
}}>
|
||||
{/* 上传按钮 */}
|
||||
<Upload
|
||||
accept={mediaType === 'image' ? 'image/*' : 'image/*,video/*'}
|
||||
showUploadList={false}
|
||||
beforeUpload={handleUpload}
|
||||
>
|
||||
<Tooltip title={mediaType === 'image'
|
||||
? `图片${currentMedia.filter(m => m.type === 'image').length}/${maxImageCount}`
|
||||
: `图片${currentMedia.filter(m => m.type === 'image').length}/${maxImageCount},视频${currentMedia.filter(m => m.type === 'video').length}/${maxVideoCount}`
|
||||
}>
|
||||
<div
|
||||
style={{
|
||||
width: 48,
|
||||
height: 48,
|
||||
borderRadius: 14,
|
||||
border: '2px dashed rgba(99, 102, 241, 0.2)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.25s ease',
|
||||
flexShrink: 0,
|
||||
backgroundColor: 'rgba(255,255,255,0.7)',
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.borderColor = '#6366f1';
|
||||
e.currentTarget.style.backgroundColor = 'rgba(99, 102, 241, 0.06)';
|
||||
e.currentTarget.style.transform = 'scale(1.05)';
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.borderColor = 'rgba(99, 102, 241, 0.2)';
|
||||
e.currentTarget.style.backgroundColor = 'rgba(255,255,255,0.7)';
|
||||
e.currentTarget.style.transform = 'scale(1)';
|
||||
}}
|
||||
>
|
||||
{uploading ? (
|
||||
<LoadingOutlined style={{ fontSize: 18, color: '#6366f1' }} />
|
||||
) : (
|
||||
<PlusOutlined style={{ fontSize: 18, color: '#64748b' }} />
|
||||
)}
|
||||
</div>
|
||||
</Tooltip>
|
||||
</Upload>
|
||||
{/* 上传按钮 - 首尾帧模式下隐藏 */}
|
||||
{!(mediaType === 'video' && referenceMode === 'first_last_frame') && (
|
||||
<Upload
|
||||
accept={mediaType === 'image' ? 'image/*' : 'image/*,video/*'}
|
||||
showUploadList={false}
|
||||
beforeUpload={handleUpload}
|
||||
>
|
||||
<Tooltip title={mediaType === 'image'
|
||||
? `图片${currentMedia.filter(m => m.type === 'image').length}/${maxImageCount}`
|
||||
: `图片${currentMedia.filter(m => m.type === 'image').length}/${maxImageCount},视频${currentMedia.filter(m => m.type === 'video').length}/${maxVideoCount}`
|
||||
}>
|
||||
<div
|
||||
style={{
|
||||
width: 50,
|
||||
height: 50,
|
||||
borderRadius: 14,
|
||||
border: '2px dashed rgba(99, 102, 241, 0.2)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.25s ease',
|
||||
flexShrink: 0,
|
||||
backgroundColor: 'rgba(255,255,255,0.7)',
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.borderColor = '#6366f1';
|
||||
e.currentTarget.style.backgroundColor = 'rgba(99, 102, 241, 0.06)';
|
||||
e.currentTarget.style.transform = 'scale(1.05)';
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.borderColor = 'rgba(99, 102, 241, 0.2)';
|
||||
e.currentTarget.style.backgroundColor = 'rgba(255,255,255,0.7)';
|
||||
e.currentTarget.style.transform = 'scale(1)';
|
||||
}}
|
||||
>
|
||||
{uploading ? (
|
||||
<LoadingOutlined style={{ fontSize: 20, color: '#6366f1' }} />
|
||||
) : (
|
||||
<PlusOutlined style={{ fontSize: 20, color: '#64748b' }} />
|
||||
)}
|
||||
</div>
|
||||
</Tooltip>
|
||||
</Upload>
|
||||
)}
|
||||
|
||||
{/* 文本输入框 */}
|
||||
<TextArea
|
||||
@@ -1814,7 +2155,9 @@ const AIChatPage: React.FC = () => {
|
||||
onKeyPress={handleKeyPress}
|
||||
placeholder={mediaType === 'image'
|
||||
? `上传最多${maxImageCount}张参考图,输入提示词描述您想生成的画面...`
|
||||
: `上传参考图(最多${maxImageCount}张)和视频(最多${maxVideoCount}个),输入提示词描述您想生成的画面...`
|
||||
: referenceMode === 'first_last_frame'
|
||||
? '上传首帧图片(必填)和尾帧图片(可选),输入提示词描述您想生成的视频...'
|
||||
: `上传参考图(最多${maxImageCount}张)和视频(最多${maxVideoCount}个),输入提示词描述您想生成的画面...`
|
||||
}
|
||||
autoSize={{ minRows: 1, maxRows: 4 }}
|
||||
style={{
|
||||
@@ -1824,15 +2167,16 @@ const AIChatPage: React.FC = () => {
|
||||
border: 'none',
|
||||
outline: 'none',
|
||||
boxShadow: 'none',
|
||||
fontSize: 14,
|
||||
lineHeight: 1.5,
|
||||
fontSize: 15,
|
||||
lineHeight: 1.6,
|
||||
color: '#1e293b',
|
||||
fontWeight: 400,
|
||||
}}
|
||||
disabled={loading}
|
||||
/>
|
||||
|
||||
{/* @ 提及下拉列表 */}
|
||||
{mentionVisible && currentMedia.length > 0 && (
|
||||
{mentionVisible && currentMedia.length > 0 && referenceMode === 'universal' && (
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
@@ -1891,18 +2235,30 @@ const AIChatPage: React.FC = () => {
|
||||
shape="circle"
|
||||
icon={<ArrowUpOutlined />}
|
||||
onClick={handleSend}
|
||||
disabled={!inputValue.trim() && currentMedia.length === 0}
|
||||
disabled={
|
||||
mediaType === 'video' && referenceMode === 'first_last_frame'
|
||||
? !inputValue.trim() && !firstFrame
|
||||
: !inputValue.trim() && currentMedia.length === 0
|
||||
}
|
||||
loading={loading}
|
||||
style={{
|
||||
flexShrink: 0,
|
||||
width: 40,
|
||||
height: 40,
|
||||
width: 44,
|
||||
height: 44,
|
||||
borderRadius: 14,
|
||||
background: (inputValue.trim() || currentMedia.length > 0)
|
||||
? 'linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%)'
|
||||
background: (
|
||||
mediaType === 'video' && referenceMode === 'first_last_frame'
|
||||
? (inputValue.trim() || firstFrame)
|
||||
: (inputValue.trim() || currentMedia.length > 0)
|
||||
)
|
||||
? 'linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%)'
|
||||
: '#c7cfdaff',
|
||||
boxShadow: (inputValue.trim() || currentMedia.length > 0)
|
||||
? '0 4px 16px rgba(99, 102, 241, 0.4)'
|
||||
boxShadow: (
|
||||
mediaType === 'video' && referenceMode === 'first_last_frame'
|
||||
? (inputValue.trim() || firstFrame)
|
||||
: (inputValue.trim() || currentMedia.length > 0)
|
||||
)
|
||||
? '0 4px 16px rgba(99, 102, 241, 0.4)'
|
||||
: 'none',
|
||||
transition: 'all 0.2s ease',
|
||||
border: 'none',
|
||||
@@ -2089,6 +2445,170 @@ const AIChatPage: React.FC = () => {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 参考模式切换按钮 - 仅视频模式显示 */}
|
||||
{mediaType === 'video' && (supportsFirstLastFrame || supportsUniversalReference) && (
|
||||
<div style={{ position: 'relative', display: 'inline-block' }}>
|
||||
<button
|
||||
onClick={() => setReferenceModeDropdownVisible(!referenceModeDropdownVisible)}
|
||||
className="image-settings-trigger"
|
||||
style={{
|
||||
minWidth: 120,
|
||||
padding: '6px 14px',
|
||||
height: 34,
|
||||
borderRadius: 10,
|
||||
border: 'none',
|
||||
backgroundColor: '#f1f5f9',
|
||||
cursor: 'pointer',
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
gap: 6,
|
||||
transition: 'all 0.2s',
|
||||
boxShadow: '0 2px 8px rgba(99, 102, 241, 0.04)',
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.boxShadow = '0 4px 12px rgba(99, 102, 241, 0.08)';
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.boxShadow = '0 2px 8px rgba(99, 102, 241, 0.04)';
|
||||
}}
|
||||
>
|
||||
<PictureOutlined style={{ fontSize: 14, color: '#6366f1' }} />
|
||||
<Text style={{
|
||||
fontSize: 14,
|
||||
fontWeight: 500,
|
||||
color: '#6366f1',
|
||||
}}>
|
||||
{referenceMode === 'universal' ? '全能参考' : '首尾帧'}
|
||||
</Text>
|
||||
<CaretDownOutlined style={{ fontSize: 10, color: '#6366f1', marginLeft: 2 }} />
|
||||
</button>
|
||||
|
||||
{referenceModeDropdownVisible && (
|
||||
<>
|
||||
<div
|
||||
style={{
|
||||
position: 'fixed',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
zIndex: 9998,
|
||||
}}
|
||||
onClick={() => setReferenceModeDropdownVisible(false)}
|
||||
/>
|
||||
<div
|
||||
className="image-settings-popover"
|
||||
style={{
|
||||
position: 'absolute',
|
||||
bottom: 'calc(100% + 8px)',
|
||||
left: 0,
|
||||
minWidth: 160,
|
||||
backgroundColor: 'rgba(255,255,255,0.95)',
|
||||
backdropFilter: 'blur(20px)',
|
||||
borderRadius: 12,
|
||||
boxShadow: '0 12px 48px rgba(99, 102, 241, 0.15)',
|
||||
padding: 8,
|
||||
border: '1px solid rgba(99, 102, 241, 0.1)',
|
||||
zIndex: 9999,
|
||||
}}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<button
|
||||
onClick={() => handleReferenceModeChange('universal')}
|
||||
disabled={!supportsUniversalReference}
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '10px 14px',
|
||||
borderRadius: 8,
|
||||
border: referenceMode === 'universal'
|
||||
? '1px solid rgba(99, 102, 241, 0.3)'
|
||||
: '1px solid transparent',
|
||||
backgroundColor: referenceMode === 'universal'
|
||||
? 'rgba(99, 102, 241, 0.08)'
|
||||
: 'transparent',
|
||||
cursor: supportsUniversalReference ? 'pointer' : 'not-allowed',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 8,
|
||||
transition: 'all 0.2s',
|
||||
textAlign: 'left',
|
||||
opacity: supportsUniversalReference ? 1 : 0.4,
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
if (supportsUniversalReference && referenceMode !== 'universal') {
|
||||
e.currentTarget.style.backgroundColor = 'rgba(99, 102, 241, 0.04)';
|
||||
}
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
if (referenceMode !== 'universal') {
|
||||
e.currentTarget.style.backgroundColor = 'transparent';
|
||||
}
|
||||
}}
|
||||
>
|
||||
<PictureOutlined style={{ fontSize: 14, color: '#6366f1' }} />
|
||||
<span style={{
|
||||
fontSize: 13,
|
||||
fontWeight: referenceMode === 'universal' ? 600 : 500,
|
||||
color: referenceMode === 'universal' ? '#6366f1' : '#4b5563',
|
||||
flex: 1,
|
||||
}}>
|
||||
全能参考
|
||||
</span>
|
||||
{!supportsUniversalReference && (
|
||||
<span style={{ fontSize: 10, color: '#9ca3af' }}>不支持</span>
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleReferenceModeChange('first_last_frame')}
|
||||
disabled={!supportsFirstLastFrame}
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '10px 14px',
|
||||
borderRadius: 8,
|
||||
border: referenceMode === 'first_last_frame'
|
||||
? '1px solid rgba(139, 92, 246, 0.3)'
|
||||
: '1px solid transparent',
|
||||
backgroundColor: referenceMode === 'first_last_frame'
|
||||
? 'rgba(139, 92, 246, 0.08)'
|
||||
: 'transparent',
|
||||
cursor: supportsFirstLastFrame ? 'pointer' : 'not-allowed',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 8,
|
||||
transition: 'all 0.2s',
|
||||
textAlign: 'left',
|
||||
opacity: supportsFirstLastFrame ? 1 : 0.4,
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
if (supportsFirstLastFrame && referenceMode !== 'first_last_frame') {
|
||||
e.currentTarget.style.backgroundColor = 'rgba(139, 92, 246, 0.04)';
|
||||
}
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
if (referenceMode !== 'first_last_frame') {
|
||||
e.currentTarget.style.backgroundColor = 'transparent';
|
||||
}
|
||||
}}
|
||||
>
|
||||
<SwapOutlined style={{ fontSize: 14, color: '#8b5cf6' }} />
|
||||
<span style={{
|
||||
fontSize: 13,
|
||||
fontWeight: referenceMode === 'first_last_frame' ? 600 : 500,
|
||||
color: referenceMode === 'first_last_frame' ? '#8b5cf6' : '#4b5563',
|
||||
flex: 1,
|
||||
}}>
|
||||
首尾帧
|
||||
</span>
|
||||
{!supportsFirstLastFrame && (
|
||||
<span style={{ fontSize: 10, color: '#9ca3af' }}>不支持</span>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 图片设置按钮 */}
|
||||
{mediaType === 'image' && (
|
||||
<div style={{ position: 'relative', display: 'inline-block' }}>
|
||||
|
||||
Reference in New Issue
Block a user