爆款/拆镜生成简化3个步骤 | 项目生成可携带附件控制
This commit is contained in:
@@ -618,28 +618,26 @@ export async function getOpenTypeAll(): Promise<{ data: OpenTypeItem[] }> {
|
||||
// ── Generation Records (Admin) ─────────────────────────────
|
||||
|
||||
export async function getAdminGenerationRecords(params?: {
|
||||
userId?: string; status?: string; page?: number; pageSize?: number;
|
||||
userId?: string;
|
||||
status?: string;
|
||||
engineId?: string;
|
||||
includeMediaReferences?: boolean;
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
}): Promise<{ total: number; items: any[] }> {
|
||||
const q = new URLSearchParams();
|
||||
if (params?.userId) q.set('user_id', params.userId);
|
||||
if (params?.status) q.set('status', params.status);
|
||||
if (params?.engineId) q.set('engine_id', params.engineId);
|
||||
if (params?.includeMediaReferences !== undefined) {
|
||||
q.set('include_media_references', String(params.includeMediaReferences));
|
||||
}
|
||||
if (params?.page) q.set('page', String(params.page));
|
||||
if (params?.pageSize) q.set('page_size', String(params.pageSize));
|
||||
const qs = q.toString();
|
||||
return api.get(`/admin/generation-records${qs ? `?${qs}` : ''}`);
|
||||
}
|
||||
|
||||
export async function adminUpdateGenerationStatus(
|
||||
recordId: string, status: string, videoUrl?: string
|
||||
): Promise<void> {
|
||||
await api.put(`/admin/generation-records/${recordId}/status`, { status, video_url: videoUrl });
|
||||
}
|
||||
|
||||
export async function adminGenerateVideo(
|
||||
recordId: string, aspectRatio: string, resolution: string, image_size: string
|
||||
): Promise<void> {
|
||||
await api.post(`/admin/generation-records/${recordId}/generate`, { aspect_ratio: aspectRatio, resolution, image_size });
|
||||
}
|
||||
|
||||
// ── Generation AI Engines (Admin) ─────────────────────────────
|
||||
|
||||
@@ -685,8 +683,13 @@ export async function getAdminHotOpeningTasks(params?: AdminHotOpeningTaskQueryP
|
||||
return api.get<HotOpeningTaskListOut>(`/hot-opening-replications/tasks${qs ? `?${qs}` : ''}`);
|
||||
}
|
||||
|
||||
export async function getAdminHotOpeningTaskDetail(projectId: string): Promise<ReplicationProjectDetailOut> {
|
||||
return api.get<ReplicationProjectDetailOut>(`/hot-opening-replications/tasks/${projectId}`);
|
||||
export async function getAdminHotOpeningTaskDetail(
|
||||
projectId: string,
|
||||
flowVersion: 'v1' | 'v2',
|
||||
): Promise<ReplicationProjectDetailOut> {
|
||||
return flowVersion === 'v2'
|
||||
? api.get<ReplicationProjectDetailOut>(`/v2/hot-opening-replications/tasks/${projectId}`)
|
||||
: api.get<ReplicationProjectDetailOut>(`/hot-opening-replications/tasks/${projectId}`);
|
||||
}
|
||||
|
||||
export async function getAdminShotTaskSets(params?: AdminShotTaskSetQueryParams): Promise<ShotTaskSetListOut> {
|
||||
@@ -725,8 +728,13 @@ export async function getAdminShotSegmentDetail(segmentId: string): Promise<Shot
|
||||
return api.get<ShotSegmentDetailOut>(`/shot-replications/segments/${segmentId}`);
|
||||
}
|
||||
|
||||
export async function getAdminShotProjectDetail(projectId: string): Promise<ReplicationProjectDetailOut> {
|
||||
return api.get<ReplicationProjectDetailOut>(`/shot-replications/projects/${projectId}`);
|
||||
export async function getAdminShotProjectDetail(
|
||||
projectId: string,
|
||||
flowVersion: 'v1' | 'v2',
|
||||
): Promise<ReplicationProjectDetailOut> {
|
||||
return flowVersion === 'v2'
|
||||
? api.get<ReplicationProjectDetailOut>(`/v2/shot-replications/projects/${projectId}`)
|
||||
: api.get<ReplicationProjectDetailOut>(`/shot-replications/projects/${projectId}`);
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -22,10 +22,9 @@ import {
|
||||
CloseCircleOutlined,
|
||||
SearchOutlined,
|
||||
VideoCameraOutlined,
|
||||
ExclamationCircleOutlined,
|
||||
FileImageOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import { getAdminGenerationRecords, adminUpdateGenerationStatus, adminGenerateVideo } from '../api';
|
||||
import { getAdminGenerationRecords, getVideoEngines, getImageEngines } from '../api';
|
||||
import type { AdminGenerationRecord, GenerationAIMediaReference } from '../types';
|
||||
import { formatDate } from '../utils/formatDate';
|
||||
|
||||
@@ -194,13 +193,14 @@ const AdminGenerationRecords: React.FC = () => {
|
||||
const [pageSize] = useState(20);
|
||||
const [filterStatus, setFilterStatus] = useState<string>('');
|
||||
const [filterUserId, setFilterUserId] = useState<string>('');
|
||||
const [filterEngineId, setFilterEngineId] = useState<string>('');
|
||||
const [filterIncludeMedia, setFilterIncludeMedia] = useState<'' | 'true' | 'false'>('');
|
||||
const [engineOptions, setEngineOptions] = useState<Array<{ value: string; label: string }>>([]);
|
||||
const [reloadKey, setReloadKey] = useState(0);
|
||||
const [preview, setPreview] = useState<AdminGenerationRecord | null>(null);
|
||||
const [resourceState, setResourceState] = useState<PreviewResourceState>(EMPTY_RESOURCE_STATE);
|
||||
const [videoPlaying, setVideoPlaying] = useState(false);
|
||||
const videoRef = useRef<HTMLVideoElement | null>(null);
|
||||
const [updating, setUpdating] = useState<string | null>(null);
|
||||
const [genModal, setGenModal] = useState<{ record: AdminGenerationRecord; ratio: string; resolution: string; image_size: string } | null>(null);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true);
|
||||
@@ -208,6 +208,8 @@ const AdminGenerationRecords: React.FC = () => {
|
||||
const res = await getAdminGenerationRecords({
|
||||
userId: filterUserId.trim() || undefined,
|
||||
status: filterStatus || undefined,
|
||||
engineId: filterEngineId || undefined,
|
||||
includeMediaReferences: filterIncludeMedia === '' ? undefined : filterIncludeMedia === 'true',
|
||||
page,
|
||||
pageSize,
|
||||
});
|
||||
@@ -240,6 +242,10 @@ const AdminGenerationRecords: React.FC = () => {
|
||||
imageTokensUsed: item.imageTokensUsed || 0,
|
||||
imageProportion: item.imageProportion,
|
||||
imagePx: item.imagePx,
|
||||
engineId: item.engineId,
|
||||
engineName: item.engineName,
|
||||
engineSnapshot: item.engineSnapshot,
|
||||
includeMediaReferences: item.includeMediaReferences,
|
||||
})));
|
||||
setTotal(res.total || 0);
|
||||
} catch {
|
||||
@@ -247,12 +253,32 @@ const AdminGenerationRecords: React.FC = () => {
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [filterStatus, filterUserId, page, pageSize]);
|
||||
}, [filterStatus, filterUserId, filterEngineId, filterIncludeMedia, page, pageSize]);
|
||||
|
||||
useEffect(() => {
|
||||
load();
|
||||
}, [load, reloadKey]);
|
||||
|
||||
useEffect(() => {
|
||||
Promise.all([
|
||||
getImageEngines({ includeDeleted: true }),
|
||||
getVideoEngines({ includeDeleted: true }),
|
||||
])
|
||||
.then(([imageEngines, videoEngines]) => {
|
||||
const items = [...(imageEngines || []), ...(videoEngines || [])];
|
||||
const seen = new Set<string>();
|
||||
setEngineOptions(items.reduce<Array<{ value: string; label: string }>>((acc, item: any) => {
|
||||
const id = String(item?.id || '');
|
||||
if (!id || seen.has(id)) return acc;
|
||||
seen.add(id);
|
||||
const deletedSuffix = item?.deletedAt ? '(已删除)' : '';
|
||||
acc.push({ value: id, label: item?.name ? `${item.name}${deletedSuffix} (${id})` : `${id}${deletedSuffix}` });
|
||||
return acc;
|
||||
}, []));
|
||||
})
|
||||
.catch(() => setEngineOptions([]));
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!preview) {
|
||||
setResourceState(EMPTY_RESOURCE_STATE);
|
||||
@@ -349,33 +375,7 @@ const AdminGenerationRecords: React.FC = () => {
|
||||
}, 0);
|
||||
};
|
||||
|
||||
const handleStatusUpdate = async (recordId: string, newStatus: string, videoUrl?: string) => {
|
||||
setUpdating(recordId);
|
||||
try {
|
||||
await adminUpdateGenerationStatus(recordId, newStatus, videoUrl);
|
||||
message.success('状态已更新');
|
||||
load();
|
||||
} catch (e: any) {
|
||||
message.error(e?.message || '更新失败');
|
||||
} finally {
|
||||
setUpdating(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleGenerate = async () => {
|
||||
if (!genModal) return;
|
||||
setUpdating(genModal.record.id);
|
||||
try {
|
||||
await adminGenerateVideo(genModal.record.id, genModal.ratio, genModal.resolution, genModal.image_size);
|
||||
message.success(`已提交${genModal.record.genType === 'video' ? '视频' : '图片'}生成`);
|
||||
setGenModal(null);
|
||||
load();
|
||||
} catch (e: any) {
|
||||
message.error(e?.message || '生成失败');
|
||||
} finally {
|
||||
setUpdating(null);
|
||||
}
|
||||
};
|
||||
|
||||
const columns = useMemo(() => [
|
||||
{
|
||||
@@ -416,21 +416,27 @@ const AdminGenerationRecords: React.FC = () => {
|
||||
title: '参数', key: 'params', width: 160,
|
||||
render: (_: any, r: AdminGenerationRecord) => (
|
||||
r.genType === 'video' ? (
|
||||
r.duration || r.aspectRatio || r.resolution ? (
|
||||
<Space size={4} wrap>
|
||||
{r.duration ? <Tag>{r.duration}s</Tag> : null}
|
||||
{r.aspectRatio ? <Tag>{r.aspectRatio}</Tag> : null}
|
||||
{r.resolution ? <Tag>{r.resolution}</Tag> : null}
|
||||
</Space>
|
||||
) : <Tag color="default">待配置</Tag>
|
||||
<Space size={4} wrap>
|
||||
{r.duration ? <Tag>{r.duration}s</Tag> : null}
|
||||
{r.aspectRatio ? <Tag>{r.aspectRatio}</Tag> : null}
|
||||
{r.resolution ? <Tag>{r.resolution}</Tag> : null}
|
||||
{!r.duration && !r.aspectRatio && !r.resolution ? <Tag color="default">待配置</Tag> : null}
|
||||
{r.engineName || r.engineId ? <Tag color="purple">{r.engineName || truncateId(r.engineId || '')}</Tag> : null}
|
||||
<Tag color={r.includeMediaReferences ? 'green' : 'default'}>
|
||||
{r.includeMediaReferences ? '携带附件' : '不携带附件'}{r.references?.length ? `(${r.references.length})` : ''}
|
||||
</Tag>
|
||||
</Space>
|
||||
) : (
|
||||
r.imageSize || r.imageProportion || r.imagePx ? (
|
||||
<Space size={4} wrap>
|
||||
{r.imageSize ? <Tag>{r.imageSize}</Tag> : null}
|
||||
{r.imageProportion ? <Tag>{r.imageProportion}</Tag> : null}
|
||||
{r.imagePx ? <Tag>{r.imagePx}</Tag> : null}
|
||||
</Space>
|
||||
) : <Tag color="default">待配置</Tag>
|
||||
<Space size={4} wrap>
|
||||
{r.imageSize ? <Tag>{r.imageSize}</Tag> : null}
|
||||
{r.imageProportion ? <Tag>{r.imageProportion}</Tag> : null}
|
||||
{r.imagePx ? <Tag>{r.imagePx}</Tag> : null}
|
||||
{!r.imageSize && !r.imageProportion && !r.imagePx ? <Tag color="default">待配置</Tag> : null}
|
||||
{r.engineName || r.engineId ? <Tag color="purple">{r.engineName || truncateId(r.engineId || '')}</Tag> : null}
|
||||
<Tag color={r.includeMediaReferences ? 'green' : 'default'}>
|
||||
{r.includeMediaReferences ? '携带附件' : '不携带附件'}{r.references?.length ? `(${r.references.length})` : ''}
|
||||
</Tag>
|
||||
</Space>
|
||||
)
|
||||
),
|
||||
},
|
||||
@@ -467,72 +473,14 @@ const AdminGenerationRecords: React.FC = () => {
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '操作', key: 'action', width: 150, fixed: 'right' as const,
|
||||
title: '操作', key: 'action', width: 90, fixed: 'right' as const,
|
||||
render: (_: any, r: AdminGenerationRecord) => (
|
||||
<Space size={4} wrap>
|
||||
<Button size="small" icon={<EyeOutlined />} onClick={() => handleOpenPreview(r)}>
|
||||
详情
|
||||
</Button>
|
||||
{r.status === 'generating' ? (
|
||||
<Button
|
||||
size="small"
|
||||
danger
|
||||
loading={updating === r.id}
|
||||
onClick={() => {
|
||||
Modal.confirm({
|
||||
title: '确认操作',
|
||||
icon: <ExclamationCircleOutlined />,
|
||||
content: '确定将此记录标记为失败?',
|
||||
onOk: () => handleStatusUpdate(r.id, 'failed'),
|
||||
});
|
||||
}}
|
||||
>
|
||||
标记失败
|
||||
</Button>
|
||||
) : null}
|
||||
{r.status === 'failed' ? (
|
||||
<Button
|
||||
size="small"
|
||||
type="primary"
|
||||
danger
|
||||
loading={updating === r.id}
|
||||
onClick={() => setGenModal({ record: r, ratio: r.aspectRatio || '16:9', resolution: r.resolution || '720p', image_size: r.imageSize || '2K' })}
|
||||
>
|
||||
重试生成
|
||||
</Button>
|
||||
) : null}
|
||||
{r.status === 'prompt_optimized' ? (
|
||||
<>
|
||||
<Button
|
||||
size="small"
|
||||
type="primary"
|
||||
loading={updating === r.id}
|
||||
onClick={() => setGenModal({ record: r, ratio: r.aspectRatio || '16:9', resolution: r.resolution || '720p', image_size: r.imageSize || '2K' })}
|
||||
style={{ background: '#6366f1', border: 'none' }}
|
||||
>
|
||||
生成{r.genType === 'video' ? '视频' : '图片'}
|
||||
</Button>
|
||||
<Button
|
||||
size="small"
|
||||
danger
|
||||
loading={updating === r.id}
|
||||
onClick={() => {
|
||||
Modal.confirm({
|
||||
title: '确认操作',
|
||||
icon: <ExclamationCircleOutlined />,
|
||||
content: '确定将此记录标记为失败?',
|
||||
onOk: () => handleStatusUpdate(r.id, 'failed'),
|
||||
});
|
||||
}}
|
||||
>
|
||||
标记失败
|
||||
</Button>
|
||||
</>
|
||||
) : null}
|
||||
</Space>
|
||||
<Button size="small" icon={<EyeOutlined />} onClick={() => handleOpenPreview(r)}>
|
||||
详情
|
||||
</Button>
|
||||
),
|
||||
},
|
||||
], [handleOpenPreview, updating]);
|
||||
], [handleOpenPreview]);
|
||||
|
||||
const previewTypeConfig = preview ? (GEN_TYPE_MAP[preview.genType || ''] || { text: preview.genType || '-', color: 'default', icon: null }) : null;
|
||||
const previewStatusConfig = preview ? (STATUS_MAP[preview.status] || { color: 'default', text: preview.status || '-', icon: null }) : null;
|
||||
@@ -894,6 +842,27 @@ const AdminGenerationRecords: React.FC = () => {
|
||||
{ value: 'failed', label: '失败' },
|
||||
]}
|
||||
/>
|
||||
<Select
|
||||
placeholder="引擎筛选"
|
||||
allowClear
|
||||
showSearch
|
||||
optionFilterProp="label"
|
||||
style={{ width: 220 }}
|
||||
value={filterEngineId || undefined}
|
||||
onChange={(v) => { setFilterEngineId(v || ''); setPage(1); }}
|
||||
options={engineOptions}
|
||||
/>
|
||||
<Select
|
||||
placeholder="附件状态"
|
||||
allowClear
|
||||
style={{ width: 130 }}
|
||||
value={filterIncludeMedia || undefined}
|
||||
onChange={(v) => { setFilterIncludeMedia((v || '') as '' | 'true' | 'false'); setPage(1); }}
|
||||
options={[
|
||||
{ value: 'true', label: '携带附件' },
|
||||
{ value: 'false', label: '不携带附件' },
|
||||
]}
|
||||
/>
|
||||
<Input
|
||||
placeholder="用户ID搜索"
|
||||
prefix={<SearchOutlined style={{ color: '#94a3b8' }} />}
|
||||
@@ -1062,73 +1031,6 @@ const AdminGenerationRecords: React.FC = () => {
|
||||
)}
|
||||
</Modal>
|
||||
|
||||
{/* Generate modal */}
|
||||
<Modal
|
||||
title={(
|
||||
<Space>
|
||||
{genModal && (genModal.record.genType === 'video' ? <PlayCircleOutlined /> : <FileImageOutlined />)}
|
||||
{genModal && (genModal.record.genType === 'video' ? '生成视频' : '生成图片')}
|
||||
</Space>
|
||||
)}
|
||||
open={!!genModal}
|
||||
onCancel={() => setGenModal(null)}
|
||||
onOk={handleGenerate}
|
||||
okText="提交生成"
|
||||
cancelText="取消"
|
||||
confirmLoading={genModal ? updating === genModal.record.id : false}
|
||||
width={420}
|
||||
>
|
||||
{genModal ? (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 16, marginTop: 16 }}>
|
||||
{genModal.record.genType === 'video' ? (
|
||||
<>
|
||||
<div style={{ padding: 12, borderRadius: 10, background: '#f8f9fc' }}>
|
||||
<Typography.Text style={{ fontSize: 11, color: '#94a3b8', display: 'block' }}>时长</Typography.Text>
|
||||
<Typography.Text strong>{genModal.record.duration || 5}s</Typography.Text>
|
||||
</div>
|
||||
<div>
|
||||
<Typography.Text style={{ fontSize: 12, color: '#64748b', display: 'block', marginBottom: 6 }}>画面比例</Typography.Text>
|
||||
<Select
|
||||
value={genModal.ratio}
|
||||
onChange={(v) => setGenModal((prev) => (prev ? { ...prev, ratio: v } : null))}
|
||||
style={{ width: '100%' }}
|
||||
options={['16:9', '4:3', '1:1', '3:4', '9:16', '21:9'].map((r) => ({ value: r, label: r }))}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Typography.Text style={{ fontSize: 12, color: '#64748b', display: 'block', marginBottom: 6 }}>分辨率</Typography.Text>
|
||||
<Select
|
||||
value={genModal.resolution}
|
||||
onChange={(v) => setGenModal((prev) => (prev ? { ...prev, resolution: v } : null))}
|
||||
style={{ width: '100%' }}
|
||||
options={['480p', '720p', '1080p'].map((r) => ({ value: r, label: r }))}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div style={{ padding: 12, borderRadius: 10, background: '#f8f9fc' }}>
|
||||
<Typography.Text style={{ fontSize: 11, color: '#94a3b8', display: 'block' }}>尺寸</Typography.Text>
|
||||
<Typography.Text strong>{genModal.record.imagePx || '-'}</Typography.Text>
|
||||
</div>
|
||||
<div style={{ padding: 12, borderRadius: 10, background: '#f8f9fc' }}>
|
||||
<Typography.Text style={{ fontSize: 11, color: '#94a3b8', display: 'block' }}>比例</Typography.Text>
|
||||
<Typography.Text strong>{genModal.record.imageProportion || '-'}</Typography.Text>
|
||||
</div>
|
||||
<div>
|
||||
<Typography.Text style={{ fontSize: 12, color: '#64748b', display: 'block', marginBottom: 6 }}>分辨率</Typography.Text>
|
||||
<Select
|
||||
value={genModal.image_size}
|
||||
onChange={(v) => setGenModal((prev) => (prev ? { ...prev, image_size: v } : null))}
|
||||
style={{ width: '100%' }}
|
||||
options={['2K', '4K'].map((r) => ({ value: r, label: r }))}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -207,6 +207,7 @@ const AdminHotOpeningReplications: React.FC = () => {
|
||||
),
|
||||
},
|
||||
{ title: '状态', dataIndex: 'status', width: 130, render: (v: string) => <StatusTag status={v} /> },
|
||||
{ title: '流程版本', dataIndex: 'flowVersion', width: 100, render: (v: string) => <Tag color={v === 'v2' ? 'blue' : 'default'}>{String(v || 'v1').toUpperCase()}</Tag> },
|
||||
{ title: '当前步骤', dataIndex: 'currentStepCode', width: 140, render: (v: string) => STEP_MAP[v] || v || '-' },
|
||||
{ title: '图片结果', dataIndex: 'finalImageUrl', width: 90, render: (v: string) => v ? <Tag color="success">有</Tag> : <Tag>无</Tag> },
|
||||
{ title: '视频结果', dataIndex: 'finalVideoUrl', width: 90, render: (v: string) => v ? <Tag color="success">有</Tag> : <Tag>无</Tag> },
|
||||
@@ -218,7 +219,7 @@ const AdminHotOpeningReplications: React.FC = () => {
|
||||
fixed: 'right',
|
||||
width: 110,
|
||||
render: (_, record) => (
|
||||
<Button type="link" icon={<EyeOutlined />} onClick={() => navigate(`/hot-opening-replications/${record.id}`)}>详情</Button>
|
||||
<Button type="link" icon={<EyeOutlined />} onClick={() => navigate(`/hot-opening-replications/${record.id}?flow_version=${record.flowVersion === 'v2' ? 'v2' : 'v1'}`)}>详情</Button>
|
||||
),
|
||||
},
|
||||
]}
|
||||
|
||||
@@ -21,7 +21,7 @@ import {
|
||||
PlayCircleOutlined,
|
||||
VideoCameraOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import { useNavigate, useParams } from 'react-router-dom';
|
||||
import { useNavigate, useParams, useSearchParams } from 'react-router-dom';
|
||||
import { getAdminHotOpeningTaskDetail, getAdminShotProjectDetail } from '../api';
|
||||
import type { ReplicationProjectDetailOut, ReplicationStepOut } from '../types';
|
||||
import { formatDate } from '../utils/formatDate';
|
||||
@@ -36,7 +36,7 @@ interface AdminReplicationProjectDetailProps {
|
||||
moduleType?: ReplicationModuleType;
|
||||
}
|
||||
|
||||
const STEP_ORDER = [
|
||||
const V1_STEP_ORDER = [
|
||||
'material_input',
|
||||
'image_prompt_optimize',
|
||||
'image_generate',
|
||||
@@ -44,6 +44,12 @@ const STEP_ORDER = [
|
||||
'video_generate',
|
||||
];
|
||||
|
||||
const V2_STEP_ORDER = [
|
||||
'material_input',
|
||||
'video_prompt_optimize',
|
||||
'video_generate',
|
||||
];
|
||||
|
||||
const STEP_DESCRIPTIONS: Record<string, string> = {
|
||||
material_input: '参考素材、项目名称和核心内容点',
|
||||
image_prompt_optimize: '图片 AI 提词优化结果',
|
||||
@@ -104,6 +110,8 @@ const renderPromptText = (value?: string | null, empty = '暂无提词') => {
|
||||
const AdminReplicationProjectDetail: React.FC<AdminReplicationProjectDetailProps> = ({ moduleType = 'shot_replicate' }) => {
|
||||
const { projectId } = useParams<{ projectId: string }>();
|
||||
const navigate = useNavigate();
|
||||
const [searchParams] = useSearchParams();
|
||||
const flowVersion: 'v1' | 'v2' = searchParams.get('flow_version') === 'v2' ? 'v2' : 'v1';
|
||||
const [detail, setDetail] = useState<ReplicationProjectDetailOut | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
@@ -112,15 +120,15 @@ const AdminReplicationProjectDetail: React.FC<AdminReplicationProjectDetailProps
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = moduleType === 'hot_opening_replicate'
|
||||
? await getAdminHotOpeningTaskDetail(projectId)
|
||||
: await getAdminShotProjectDetail(projectId);
|
||||
? await getAdminHotOpeningTaskDetail(projectId, flowVersion)
|
||||
: await getAdminShotProjectDetail(projectId, flowVersion);
|
||||
setDetail(res);
|
||||
} catch (e: any) {
|
||||
message.error(e?.message || '加载复刻项目详情失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [moduleType, projectId]);
|
||||
}, [flowVersion, moduleType, projectId]);
|
||||
|
||||
useEffect(() => {
|
||||
load();
|
||||
@@ -134,8 +142,10 @@ const AdminReplicationProjectDetail: React.FC<AdminReplicationProjectDetailProps
|
||||
|
||||
const moduleValue = detail?.module || moduleType;
|
||||
const moduleName = getModuleLabel(moduleValue);
|
||||
const isV2 = detail?.flowVersion === 'v2';
|
||||
const effectiveStepOrder = isV2 ? V2_STEP_ORDER : V1_STEP_ORDER;
|
||||
|
||||
const stepItems = useMemo(() => STEP_ORDER.map(code => {
|
||||
const stepItems = useMemo(() => effectiveStepOrder.map(code => {
|
||||
const step = stepsByCode[code];
|
||||
let status: 'wait' | 'process' | 'finish' | 'error' = 'wait';
|
||||
if (step?.status === 'completed') status = 'finish';
|
||||
@@ -146,7 +156,7 @@ const AdminReplicationProjectDetail: React.FC<AdminReplicationProjectDetailProps
|
||||
description: step ? <StatusTag status={step.status} /> : '未创建',
|
||||
status,
|
||||
};
|
||||
}), [stepsByCode]);
|
||||
}), [effectiveStepOrder, stepsByCode]);
|
||||
|
||||
const defaultActiveKeys = useMemo(() => buildDefaultActiveKeys(detail, stepsByCode), [detail, stepsByCode]);
|
||||
|
||||
@@ -242,7 +252,7 @@ const AdminReplicationProjectDetail: React.FC<AdminReplicationProjectDetailProps
|
||||
},
|
||||
{
|
||||
key: 'video_prompt_optimize',
|
||||
label: <StepHeader index={4} stepCode="video_prompt_optimize" step={videoPromptStep} current={detail.currentStepCode === 'video_prompt_optimize'} />,
|
||||
label: <StepHeader index={isV2 ? 2 : 4} stepCode="video_prompt_optimize" step={videoPromptStep} current={detail.currentStepCode === 'video_prompt_optimize'} />,
|
||||
children: (
|
||||
<Space direction="vertical" size={16} style={{ width: '100%' }}>
|
||||
<Descriptions column={3} bordered size="small">
|
||||
@@ -265,7 +275,7 @@ const AdminReplicationProjectDetail: React.FC<AdminReplicationProjectDetailProps
|
||||
},
|
||||
{
|
||||
key: 'video_generate',
|
||||
label: <StepHeader index={5} stepCode="video_generate" step={videoGenerateStep} current={detail.currentStepCode === 'video_generate'} />,
|
||||
label: <StepHeader index={isV2 ? 3 : 5} stepCode="video_generate" step={videoGenerateStep} current={detail.currentStepCode === 'video_generate'} />,
|
||||
children: (
|
||||
<Space direction="vertical" size={16} style={{ width: '100%' }}>
|
||||
<Descriptions column={3} bordered size="small">
|
||||
@@ -300,6 +310,9 @@ const AdminReplicationProjectDetail: React.FC<AdminReplicationProjectDetailProps
|
||||
),
|
||||
},
|
||||
];
|
||||
const visibleCollapseItems = isV2
|
||||
? collapseItems.filter(item => !['image_prompt_optimize', 'image_generate'].includes(String(item.key)))
|
||||
: collapseItems;
|
||||
|
||||
return (
|
||||
<div style={{ padding: 24 }}>
|
||||
@@ -322,6 +335,8 @@ const AdminReplicationProjectDetail: React.FC<AdminReplicationProjectDetailProps
|
||||
<Descriptions.Item label="用户名">{detail.userName || '-'}</Descriptions.Item>
|
||||
<Descriptions.Item label="标题">{detail.title || '-'}</Descriptions.Item>
|
||||
<Descriptions.Item label="当前步骤"><Tooltip title={detail.currentStepCode || ''}>{getStepCodeLabel(detail.currentStepCode)}</Tooltip></Descriptions.Item>
|
||||
<Descriptions.Item label="流程版本"><Tag color={isV2 ? 'blue' : 'default'}>{String(detail.flowVersion || 'v1').toUpperCase()}</Tag></Descriptions.Item>
|
||||
<Descriptions.Item label="步骤数量">{detail.stepCount || effectiveStepOrder.length}</Descriptions.Item>
|
||||
<Descriptions.Item label="状态"><StatusTag status={detail.status} /></Descriptions.Item>
|
||||
<Descriptions.Item label="创建时间">{safeDate(detail.createdAt)}</Descriptions.Item>
|
||||
<Descriptions.Item label="更新时间">{safeDate(detail.updatedAt)}</Descriptions.Item>
|
||||
@@ -336,9 +351,11 @@ const AdminReplicationProjectDetail: React.FC<AdminReplicationProjectDetailProps
|
||||
|
||||
<Card title="最终结果预览">
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(280px, 1fr))', gap: 16 }}>
|
||||
<Card size="small" title="最终图片">
|
||||
<MediaPreview type="image" url={detail.finalImageUrl || detail.imageGeneration?.resultImageUrl} height={220} emptyDescription="暂无最终图片" />
|
||||
</Card>
|
||||
{!isV2 ? (
|
||||
<Card size="small" title="最终图片">
|
||||
<MediaPreview type="image" url={detail.finalImageUrl || detail.imageGeneration?.resultImageUrl} height={220} emptyDescription="暂无最终图片" />
|
||||
</Card>
|
||||
) : null}
|
||||
<Card size="small" title="最终视频封面">
|
||||
<MediaPreview type="image" url={detail.finalVideoCoverUrl || detail.videoGeneration?.resultVideoCoverUrl} height={220} emptyDescription="暂无最终视频封面" />
|
||||
</Card>
|
||||
@@ -348,7 +365,7 @@ const AdminReplicationProjectDetail: React.FC<AdminReplicationProjectDetailProps
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Collapse defaultActiveKey={defaultActiveKeys} items={collapseItems} />
|
||||
<Collapse defaultActiveKey={defaultActiveKeys} items={visibleCollapseItems} />
|
||||
|
||||
<Collapse
|
||||
items={[
|
||||
|
||||
@@ -284,9 +284,14 @@ const AdminShotTaskSetDetail: React.FC = () => {
|
||||
width: 220,
|
||||
render: (_, record) => record.moduleProjectId ? (
|
||||
<Space direction="vertical" size={0}>
|
||||
<Button type="link" style={{ padding: 0 }} onClick={() => navigate(`/shot-replications/projects/${record.moduleProjectId}`)}>{shortId(record.moduleProjectId)}</Button>
|
||||
<Button type="link" style={{ padding: 0 }} onClick={() => navigate(`/shot-replications/projects/${record.moduleProjectId}?flow_version=${record.moduleProjectFlowVersion === 'v2' ? 'v2' : 'v1'}`)}>{shortId(record.moduleProjectId)}</Button>
|
||||
<Typography.Text type="secondary" style={{ fontSize: 12 }}>{record.moduleProjectTitle || getStepCodeLabel(record.moduleProjectCurrentStepCode)}</Typography.Text>
|
||||
<StatusTag status={record.moduleProjectStatus} />
|
||||
<Space size={4}>
|
||||
<StatusTag status={record.moduleProjectStatus} />
|
||||
<Tag color={record.moduleProjectFlowVersion === 'v2' ? 'blue' : 'default'}>
|
||||
{String(record.moduleProjectFlowVersion || 'v1').toUpperCase()}
|
||||
</Tag>
|
||||
</Space>
|
||||
</Space>
|
||||
) : <Tag>未创建</Tag>,
|
||||
},
|
||||
@@ -313,7 +318,8 @@ const AdminShotTaskSetDetail: React.FC = () => {
|
||||
<Descriptions.Item label="切割状态"><StatusTag status={segmentDetail.splitStatus} /></Descriptions.Item>
|
||||
<Descriptions.Item label="分析状态"><StatusTag status={segmentDetail.analysisStatus} /></Descriptions.Item>
|
||||
<Descriptions.Item label="复刻状态"><StatusTag status={segmentDetail.replicateStatus} /></Descriptions.Item>
|
||||
<Descriptions.Item label="关联项目">{segmentDetail.moduleProjectId ? <Button type="link" onClick={() => navigate(`/shot-replications/projects/${segmentDetail.moduleProjectId}`)}>{segmentDetail.moduleProjectId}</Button> : '-'}</Descriptions.Item>
|
||||
<Descriptions.Item label="关联项目">{segmentDetail.moduleProjectId ? <Button type="link" onClick={() => navigate(`/shot-replications/projects/${segmentDetail.moduleProjectId}?flow_version=${segmentDetail.moduleProjectFlowVersion === 'v2' ? 'v2' : 'v1'}`)}>{segmentDetail.moduleProjectId}</Button> : '-'}</Descriptions.Item>
|
||||
<Descriptions.Item label="项目流程版本">{segmentDetail.moduleProjectId ? <Tag color={segmentDetail.moduleProjectFlowVersion === 'v2' ? 'blue' : 'default'}>{String(segmentDetail.moduleProjectFlowVersion || 'v1').toUpperCase()}</Tag> : '-'}</Descriptions.Item>
|
||||
<Descriptions.Item label="片段内容" span={2}>{segmentDetail.segmentContent || '-'}</Descriptions.Item>
|
||||
<Descriptions.Item label="片段分类">{segmentDetail.segmentCategory || '-'}</Descriptions.Item>
|
||||
<Descriptions.Item label="片段受众">{segmentDetail.segmentAudience || '-'}</Descriptions.Item>
|
||||
|
||||
@@ -372,6 +372,10 @@ export interface AdminGenerationRecord {
|
||||
imageTokensUsed?: number;
|
||||
imageProportion?: string;
|
||||
imagePx?: string;
|
||||
engineId?: string;
|
||||
engineName?: string;
|
||||
engineSnapshot?: Record<string, unknown> | null;
|
||||
includeMediaReferences?: boolean;
|
||||
}
|
||||
|
||||
export type GenerationAITaskStatus = 'pending' | 'generating' | 'completed' | 'failed' | string;
|
||||
@@ -550,6 +554,9 @@ export interface ReplicationProjectDetailOut {
|
||||
title?: string | null;
|
||||
status: ModuleReplicationStatus;
|
||||
currentStepCode?: string | null;
|
||||
flowVersion?: 'v1' | 'v2' | string | null;
|
||||
stepCount?: number;
|
||||
stepIoSchemaVersion?: string | null;
|
||||
finalImageUrl?: string | null;
|
||||
finalVideoUrl?: string | null;
|
||||
finalVideoCoverUrl?: string | null;
|
||||
@@ -572,6 +579,8 @@ export interface HotOpeningTaskListItemOut {
|
||||
title?: string | null;
|
||||
status: ModuleReplicationStatus;
|
||||
currentStepCode?: string | null;
|
||||
flowVersion?: 'v1' | 'v2' | string | null;
|
||||
stepCount?: number;
|
||||
sourceProjectName?: string | null;
|
||||
targetProjectName?: string | null;
|
||||
coreContentPoint?: string | null;
|
||||
@@ -685,6 +694,7 @@ export interface ShotSegmentOut {
|
||||
moduleProjectTitle?: string | null;
|
||||
moduleProjectStatus?: string | null;
|
||||
moduleProjectCurrentStepCode?: string | null;
|
||||
moduleProjectFlowVersion?: 'v1' | 'v2' | string | null;
|
||||
createdAt?: string | null;
|
||||
updatedAt?: string | null;
|
||||
}
|
||||
|
||||
+87
@@ -0,0 +1,87 @@
|
||||
"""add module flow version and generation reference option
|
||||
|
||||
Revision ID: d8ebe79ab575
|
||||
Revises: e6eac828ff61
|
||||
Create Date: 2026-07-21 09:28:04.318458
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
revision: str = "d8ebe79ab575"
|
||||
down_revision: Union[str, None] = "e6eac828ff61"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def _column_names(table_name: str) -> set[str]:
|
||||
inspector = sa.inspect(op.get_bind())
|
||||
return {str(column["name"]) for column in inspector.get_columns(table_name)}
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
generation_record_columns = _column_names("generation_records")
|
||||
if "include_media_references" not in generation_record_columns:
|
||||
op.add_column(
|
||||
"generation_records",
|
||||
sa.Column(
|
||||
"include_media_references",
|
||||
sa.Boolean(),
|
||||
server_default=sa.text("false"),
|
||||
nullable=False,
|
||||
),
|
||||
)
|
||||
else:
|
||||
op.execute(
|
||||
sa.text(
|
||||
"UPDATE generation_records "
|
||||
"SET include_media_references = false "
|
||||
"WHERE include_media_references IS NULL"
|
||||
)
|
||||
)
|
||||
op.alter_column(
|
||||
"generation_records",
|
||||
"include_media_references",
|
||||
existing_type=sa.Boolean(),
|
||||
nullable=False,
|
||||
server_default=sa.text("false"),
|
||||
)
|
||||
|
||||
project_columns = _column_names("module_generation_projects")
|
||||
if "flow_version" not in project_columns:
|
||||
op.add_column(
|
||||
"module_generation_projects",
|
||||
sa.Column(
|
||||
"flow_version",
|
||||
sa.String(length=16),
|
||||
server_default=sa.text("'v1'"),
|
||||
nullable=False,
|
||||
),
|
||||
)
|
||||
else:
|
||||
op.execute(
|
||||
sa.text(
|
||||
"UPDATE module_generation_projects "
|
||||
"SET flow_version = 'v1' "
|
||||
"WHERE flow_version IS NULL OR btrim(flow_version) = ''"
|
||||
)
|
||||
)
|
||||
op.alter_column(
|
||||
"module_generation_projects",
|
||||
"flow_version",
|
||||
existing_type=sa.String(length=16),
|
||||
nullable=False,
|
||||
server_default=sa.text("'v1'"),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
project_columns = _column_names("module_generation_projects")
|
||||
if "flow_version" in project_columns:
|
||||
op.drop_column("module_generation_projects", "flow_version")
|
||||
|
||||
generation_record_columns = _column_names("generation_records")
|
||||
if "include_media_references" in generation_record_columns:
|
||||
op.drop_column("generation_records", "include_media_references")
|
||||
@@ -1,7 +1,7 @@
|
||||
from datetime import datetime, timezone, timedelta
|
||||
import json
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from sqlalchemy import delete, func, select, update
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
@@ -24,7 +24,6 @@ from app.models.credit_ratio import CreditRatio
|
||||
from app.models.operation_log import OperationLog
|
||||
from app.enums.user import FrontendUserKind, UserType
|
||||
from app.enums.team import TEAM_UNASSIGNED_VALUE
|
||||
from app.enums.generation_status import GenerationRecordPipelineStage
|
||||
from app.schemas.admin import (
|
||||
CreditAdjustRequest,
|
||||
ModelConfigCreate,
|
||||
@@ -38,17 +37,12 @@ from app.schemas.admin import (
|
||||
UpdateMenusRequest,
|
||||
ResetPasswordRequest,
|
||||
UpdateFrontendUserKindRequest,
|
||||
OperationLogOut,
|
||||
)
|
||||
from app.schemas.team import UpdateUserTeamRequest
|
||||
from app.schemas.industry import IndustryConfigCreate, IndustryConfigOut
|
||||
from app.schemas.industry import IndustryConfigCreate
|
||||
from app.schemas.video_engine import VideoEngineCreate, VideoEngineOut
|
||||
from app.schemas.image_engine import ImageEngineCreate, ImageEngineOut
|
||||
from app.schemas.credit_ratio import CreditRatioCreate, CreditRatioOut
|
||||
from app.services.generation.pipeline.db_lock_service import (
|
||||
DatabaseRowLockBusy,
|
||||
execute_with_lock_timeout,
|
||||
)
|
||||
from app.services.credits import add_credits, deduct_credits
|
||||
from app.services.credit_record_meta_service import build_admin_adjust_meta
|
||||
from app.services.admin_credit_record_service import list_admin_credit_records
|
||||
@@ -57,23 +51,26 @@ from app.services.auth import hash_password, verify_password
|
||||
from app.services.operation_log import log_operation
|
||||
from app.services.private_portrait.reference_resolver import batch_resolve_private_portrait_reference_display_urls
|
||||
from app.services.resource_signed_url_service import build_resource_signed_url
|
||||
from app.services.payment import sync_pending_orders, process_refund
|
||||
from app.services.payment import process_refund
|
||||
from app.services.resource_capacity_service import batch_get_user_resource_capacity_usage, get_user_resource_capacity_usage
|
||||
from app.services.team_service import batch_get_team_name_map, set_frontend_user_team
|
||||
|
||||
from app.services.generation.billing_service import (
|
||||
OWNER_GENERATION_RECORD,
|
||||
charge_generation_media_for_record,
|
||||
get_next_credit_attempt_no,
|
||||
)
|
||||
from app.services.generation.refund_service import mark_generation_record_failed_and_refund_once
|
||||
from app.utils.id_gen import generate_id
|
||||
from app.schemas.generation import GenerationType, ASPECT_RATIOS, RESOLUTIONS
|
||||
|
||||
|
||||
CST = timezone(timedelta(hours=8))
|
||||
|
||||
|
||||
def _safe_json_object(value: str | None) -> dict | None:
|
||||
if not value:
|
||||
return None
|
||||
try:
|
||||
parsed = json.loads(value)
|
||||
except (TypeError, json.JSONDecodeError):
|
||||
return None
|
||||
return parsed if isinstance(parsed, dict) else None
|
||||
|
||||
|
||||
def _iso(dt):
|
||||
"""Serialize datetime as naive ISO string (UTC→CST, strip tzinfo)."""
|
||||
if dt is None:
|
||||
@@ -1917,6 +1914,8 @@ async def list_token_usage(
|
||||
async def admin_list_generation_records(
|
||||
user_id: str | None = Query(None),
|
||||
status: str | None = Query(None),
|
||||
engine_id: str | None = Query(None),
|
||||
include_media_references: bool | None = Query(None),
|
||||
page: int = Query(1, ge=1),
|
||||
page_size: int = Query(20, ge=1, le=500),
|
||||
admin: User = Depends(get_admin_user),
|
||||
@@ -1935,13 +1934,25 @@ async def admin_list_generation_records(
|
||||
query = query.where(GenerationRecord.user_id == user_id)
|
||||
if status:
|
||||
query = query.where(GenerationRecord.status == status)
|
||||
if engine_id:
|
||||
query = query.where(GenerationRecord.engine_id == engine_id)
|
||||
if include_media_references is not None:
|
||||
query = query.where(GenerationRecord.include_media_references.is_(include_media_references))
|
||||
|
||||
# Count total
|
||||
count_query = select(func.count(GenerationRecord.id)).where(GenerationRecord.deleted_at.is_(None))
|
||||
count_query = (
|
||||
select(func.count(GenerationRecord.id))
|
||||
.join(Project, GenerationRecord.project_id == Project.id)
|
||||
.where(GenerationRecord.deleted_at.is_(None), Project.deleted_at.is_(None))
|
||||
)
|
||||
if user_id:
|
||||
count_query = count_query.where(GenerationRecord.user_id == user_id)
|
||||
if status:
|
||||
count_query = count_query.where(GenerationRecord.status == status)
|
||||
if engine_id:
|
||||
count_query = count_query.where(GenerationRecord.engine_id == engine_id)
|
||||
if include_media_references is not None:
|
||||
count_query = count_query.where(GenerationRecord.include_media_references.is_(include_media_references))
|
||||
total_result = await db.execute(count_query)
|
||||
total = total_result.scalar() or 0
|
||||
|
||||
@@ -1977,6 +1988,10 @@ async def admin_list_generation_records(
|
||||
"video_url": build_resource_signed_url(record.video_url) if record.video_url else '',
|
||||
"video_cover_url": build_resource_signed_url(record.video_cover_url) if record.video_cover_url else '',
|
||||
"references": refs,
|
||||
"engine_id": record.engine_id,
|
||||
"engine_name": (_safe_json_object(record.engine_snapshot_json) or {}).get("name"),
|
||||
"engine_snapshot": _safe_json_object(record.engine_snapshot_json),
|
||||
"include_media_references": bool(record.include_media_references),
|
||||
"credits_cost": record.credits_cost or 0,
|
||||
"text_credits_cost": record.text_credits_cost or 0,
|
||||
"text_tokens_used": record.text_tokens_used or 0,
|
||||
@@ -1997,245 +2012,6 @@ async def admin_list_generation_records(
|
||||
return {"total": total, "items": items}
|
||||
|
||||
|
||||
@router.put("/generation-records/{record_id}/status")
|
||||
async def admin_update_generation_status(
|
||||
record_id: str,
|
||||
body: dict,
|
||||
admin: User = Depends(get_admin_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""管理员只能终止正在执行或待生成的记录,禁止绕过流水线裸改生成/完成状态。"""
|
||||
try:
|
||||
result = await execute_with_lock_timeout(
|
||||
db,
|
||||
select(GenerationRecord).where(
|
||||
GenerationRecord.id == record_id,
|
||||
GenerationRecord.deleted_at.is_(None),
|
||||
)
|
||||
.with_for_update()
|
||||
.limit(1),
|
||||
)
|
||||
except DatabaseRowLockBusy as exc:
|
||||
raise HTTPException(status_code=409, detail=exc.detail) from exc
|
||||
record = result.scalar_one_or_none()
|
||||
if not record:
|
||||
raise HTTPException(status_code=404, detail="记录不存在")
|
||||
|
||||
new_status = str(body.get("status") or "").strip()
|
||||
if new_status in {"generating", "completed", "prompt_optimized"}:
|
||||
raise HTTPException(
|
||||
status_code=409,
|
||||
detail="禁止直接修改为该状态;生成请调用生成接口,完成必须由下载/超分流水线落库",
|
||||
)
|
||||
if new_status != "failed":
|
||||
raise HTTPException(status_code=400, detail="该接口仅允许管理员终止任务")
|
||||
if record.status == "completed":
|
||||
raise HTTPException(status_code=409, detail="已完成记录不能直接改为失败")
|
||||
|
||||
error_message = body.get("error_message") or record.error_message or "管理员终止生成任务"
|
||||
await mark_generation_record_failed_and_refund_once(
|
||||
db,
|
||||
record=record,
|
||||
error_message=error_message,
|
||||
generation_attempt_no=int(record.generation_attempt_no or 1),
|
||||
)
|
||||
record.pipeline_stage = GenerationRecordPipelineStage.FAILED.value
|
||||
record.provider_create_claim_token = None
|
||||
record.provider_create_lease_until = None
|
||||
record.poll_claim_token = None
|
||||
record.poll_lease_until = None
|
||||
record.next_poll_at = None
|
||||
record.download_claim_token = None
|
||||
record.download_lease_until = None
|
||||
record.download_next_retry_at = None
|
||||
|
||||
# 若任务已进入超分,必须同时撤销超分数据库租约;执行中的超分 Worker
|
||||
# 在回填前校验 lease_token,发现 token 被清除后会中止,不得覆盖管理员终止状态。
|
||||
from app.enums.video_upscale import VideoUpscaleStage, VideoUpscaleTaskStatus
|
||||
from app.models.video_upscale_task import VideoUpscaleTask
|
||||
|
||||
try:
|
||||
upscale_result = await execute_with_lock_timeout(
|
||||
db,
|
||||
select(VideoUpscaleTask)
|
||||
.where(VideoUpscaleTask.generation_record_id == record.id)
|
||||
.with_for_update()
|
||||
.limit(1),
|
||||
)
|
||||
except DatabaseRowLockBusy as exc:
|
||||
raise HTTPException(status_code=409, detail=exc.detail) from exc
|
||||
upscale = upscale_result.scalar_one_or_none()
|
||||
if upscale and upscale.status not in {
|
||||
VideoUpscaleTaskStatus.COMPLETED.value,
|
||||
VideoUpscaleTaskStatus.FAILED.value,
|
||||
}:
|
||||
upscale.status = VideoUpscaleTaskStatus.FAILED.value
|
||||
upscale.stage = VideoUpscaleStage.FAILED.value
|
||||
upscale.last_error = error_message
|
||||
upscale.failed_at = datetime.now(CST)
|
||||
upscale.next_retry_at = None
|
||||
upscale.lease_token = None
|
||||
upscale.lease_until = None
|
||||
|
||||
await db.flush()
|
||||
await log_operation(
|
||||
db,
|
||||
admin.id,
|
||||
admin.username,
|
||||
"管理员终止生成记录",
|
||||
"PUT",
|
||||
f"/admin/generation-records/{record_id}/status",
|
||||
detail=json.dumps(
|
||||
{
|
||||
"record_id": record_id,
|
||||
"new_status": new_status,
|
||||
"generation_attempt_no": int(record.generation_attempt_no or 1),
|
||||
"error_message": error_message,
|
||||
},
|
||||
ensure_ascii=False,
|
||||
),
|
||||
)
|
||||
await db.commit()
|
||||
|
||||
# Redis 注册表只做调度加速;删除失败不回滚已提交的业务终止状态。
|
||||
try:
|
||||
from app.services.celery_download_recovery_service import remove_download_active
|
||||
from app.services.generation.pipeline.owner_service import redis_owner_item_id
|
||||
from app.services.redis_registry_service import redis_remove_registry_item
|
||||
from app.config import settings
|
||||
|
||||
registry_id = redis_owner_item_id(
|
||||
"generation_record",
|
||||
record_id,
|
||||
int(record.generation_attempt_no or 1),
|
||||
)
|
||||
await remove_download_active(registry_id)
|
||||
await redis_remove_registry_item(
|
||||
hash_key=settings.POLL_ACTIVE_REDIS_HASH_KEY,
|
||||
zset_key=settings.POLL_ACTIVE_REDIS_ZSET_KEY,
|
||||
item_id=registry_id,
|
||||
log_context="admin_generation_record_terminate",
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
return {"message": "ok"}
|
||||
|
||||
|
||||
@router.post("/generation-records/{record_id}/generate")
|
||||
async def admin_generate_record_resource(
|
||||
record_id: str,
|
||||
body: dict,
|
||||
admin: User = Depends(get_admin_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""管理员触发 GenerationRecord 图片或视频资源生成。"""
|
||||
from app.services.generation.pipeline.generation_record_service import (
|
||||
commit_and_enqueue_generation_record,
|
||||
prepare_generation_record_execution,
|
||||
)
|
||||
|
||||
try:
|
||||
result = await execute_with_lock_timeout(
|
||||
db,
|
||||
select(GenerationRecord, Project.name)
|
||||
.join(Project, GenerationRecord.project_id == Project.id)
|
||||
.where(
|
||||
GenerationRecord.id == record_id,
|
||||
GenerationRecord.deleted_at.is_(None),
|
||||
Project.deleted_at.is_(None),
|
||||
)
|
||||
.with_for_update(),
|
||||
)
|
||||
except DatabaseRowLockBusy as exc:
|
||||
raise HTTPException(status_code=409, detail=exc.detail) from exc
|
||||
row = result.first()
|
||||
if not row:
|
||||
raise HTTPException(status_code=404, detail="记录不存在")
|
||||
|
||||
record, project_name = row
|
||||
type_str = "视频" if record.gen_type == GenerationType.video else "图片"
|
||||
if record.status not in ("prompt_optimized", "failed"):
|
||||
raise HTTPException(status_code=400, detail=f"当前状态不允许生成{type_str}")
|
||||
if record.pipeline_stage == GenerationRecordPipelineStage.UPSCALE_FAILED.value:
|
||||
raise HTTPException(status_code=409, detail="该任务为画质增强失败,请使用超分恢复命令处理")
|
||||
|
||||
attempt_no = await get_next_credit_attempt_no(
|
||||
db,
|
||||
owner_type=OWNER_GENERATION_RECORD,
|
||||
owner_id=record.id,
|
||||
)
|
||||
|
||||
if record.gen_type == GenerationType.video:
|
||||
aspect_ratio = body.get("aspect_ratio", "16:9")
|
||||
resolution = body.get("resolution", "720p")
|
||||
if aspect_ratio not in ASPECT_RATIOS:
|
||||
raise HTTPException(status_code=400, detail="不支持的画面比例")
|
||||
if resolution not in RESOLUTIONS:
|
||||
raise HTTPException(status_code=400, detail="不支持的分辨率")
|
||||
|
||||
from app.services.video_gen import get_active_engine
|
||||
from app.services.video_upscale.snapshot_service import build_video_upscale_snapshot
|
||||
|
||||
engine = await get_active_engine(db)
|
||||
try:
|
||||
supported_provider_resolutions = json.loads(engine.supported_resolutions or "[]")
|
||||
except (TypeError, json.JSONDecodeError):
|
||||
supported_provider_resolutions = []
|
||||
provider_resolution, upscale_enabled, upscale_snapshot_json = await build_video_upscale_snapshot(
|
||||
db,
|
||||
target_resolution=resolution,
|
||||
aspect_ratio=aspect_ratio,
|
||||
supported_provider_resolutions=supported_provider_resolutions,
|
||||
)
|
||||
record.aspect_ratio = aspect_ratio
|
||||
record.resolution = resolution
|
||||
record.provider_generation_resolution = provider_resolution
|
||||
record.video_upscale_enabled_snapshot = upscale_enabled
|
||||
record.video_upscale_snapshot_json = upscale_snapshot_json
|
||||
elif record.gen_type == GenerationType.image:
|
||||
from app.services.image_gen import get_active_image_engine
|
||||
|
||||
engine = await get_active_image_engine(db)
|
||||
record.image_size = body.get("image_size") or record.image_size or "2K"
|
||||
record.provider_generation_resolution = None
|
||||
record.video_upscale_enabled_snapshot = False
|
||||
record.video_upscale_snapshot_json = None
|
||||
else:
|
||||
raise HTTPException(status_code=400, detail="不支持的生成类型")
|
||||
|
||||
media_billing = await charge_generation_media_for_record(
|
||||
db,
|
||||
record=record,
|
||||
project_name=project_name,
|
||||
description_prefix=f"{type_str}生成(管理后台)-",
|
||||
attempt_no=attempt_no,
|
||||
engine_id=engine.id,
|
||||
)
|
||||
record.credits_cost = round(float(record.credits_cost or 0) + float(media_billing.total_charged or 0), 2)
|
||||
prepare_generation_record_execution(record, engine=engine, attempt_no=attempt_no)
|
||||
await db.flush()
|
||||
await commit_and_enqueue_generation_record(db, record, reason="generation_record_admin_generate")
|
||||
|
||||
await log_operation(
|
||||
db,
|
||||
admin.id,
|
||||
admin.username,
|
||||
f"管理员触发生成{type_str}: {record_id}",
|
||||
"POST",
|
||||
f"/admin/generation-records/{record_id}/generate",
|
||||
detail=json.dumps(
|
||||
{
|
||||
"record_id": record_id,
|
||||
"gen_type": record.gen_type,
|
||||
"project_name": project_name,
|
||||
"generation_attempt_no": record.generation_attempt_no,
|
||||
},
|
||||
ensure_ascii=False,
|
||||
),
|
||||
)
|
||||
return {"message": "ok", "record_id": record_id}
|
||||
|
||||
|
||||
# ── File Uploads ─────────────────────────────────────────
|
||||
|
||||
import os
|
||||
@@ -2251,7 +2027,6 @@ async def upload_pdf(
|
||||
):
|
||||
"""Upload a PDF file and save URL to system config."""
|
||||
from app.config import settings
|
||||
from app.utils.id_gen import generate_id
|
||||
|
||||
if not file.filename:
|
||||
raise HTTPException(status_code=400, detail="请选择文件")
|
||||
@@ -2313,7 +2088,6 @@ async def upload_logo(
|
||||
):
|
||||
"""Upload a Logo image file and save URL to system config."""
|
||||
from app.config import settings
|
||||
from app.utils.id_gen import generate_id
|
||||
|
||||
if not file.filename:
|
||||
raise HTTPException(status_code=400, detail="请选择文件")
|
||||
|
||||
@@ -55,6 +55,16 @@ from app.services.generation.billing_service import (
|
||||
get_next_credit_attempt_no,
|
||||
)
|
||||
from app.services.generation.refund_service import mark_generation_record_failed_and_refund_once
|
||||
from app.services.generation.ai.engine_service import (
|
||||
get_image_engine,
|
||||
get_video_engine,
|
||||
image_supported_sizes,
|
||||
parse_json_list,
|
||||
)
|
||||
from app.services.generation.media_reference_service import (
|
||||
calculate_media_reference_usage,
|
||||
validate_media_reference_usage_for_engine,
|
||||
)
|
||||
from app.services.media_token_usage_snapshot_service import sync_generation_record_media_token_snapshot
|
||||
from app.services.credit_record_meta_service import build_generation_record_prompt_meta
|
||||
from app.services.video_cover_service import async_create_video_cover_for_local_video
|
||||
@@ -70,6 +80,36 @@ router = APIRouter(prefix="/generation-records", tags=["generation"])
|
||||
logger = logging.getLogger("videogen")
|
||||
|
||||
|
||||
def _engine_snapshot(record: GenerationRecord) -> dict | None:
|
||||
if not record.engine_snapshot_json:
|
||||
return None
|
||||
try:
|
||||
value = json.loads(record.engine_snapshot_json)
|
||||
except (TypeError, json.JSONDecodeError):
|
||||
return None
|
||||
return value if isinstance(value, dict) else None
|
||||
|
||||
|
||||
def _validate_video_engine_selection(engine, *, aspect_ratio: str, resolution: str, duration: int) -> None:
|
||||
ratios = [str(item) for item in parse_json_list(engine.supported_ratios, [])]
|
||||
resolutions = [str(item) for item in parse_json_list(engine.supported_resolutions, [])]
|
||||
durations = [int(item) for item in parse_json_list(engine.supported_durations, []) if str(item).isdigit()]
|
||||
if ratios and aspect_ratio not in ratios:
|
||||
raise HTTPException(status_code=400, detail="当前视频引擎不支持所选画面比例")
|
||||
if resolutions and resolution not in resolutions:
|
||||
raise HTTPException(status_code=400, detail="当前视频引擎不支持所选分辨率")
|
||||
if durations and duration not in durations:
|
||||
raise HTTPException(status_code=400, detail="当前视频引擎不支持所选时长")
|
||||
if int(engine.max_duration or 0) > 0 and duration > int(engine.max_duration):
|
||||
raise HTTPException(status_code=400, detail="生成时长超过当前视频引擎上限")
|
||||
|
||||
|
||||
def _validate_image_engine_selection(engine, *, image_size: str) -> None:
|
||||
sizes = image_supported_sizes(engine)
|
||||
if sizes and image_size not in sizes:
|
||||
raise HTTPException(status_code=400, detail="当前图片引擎不支持所选画面分辨率")
|
||||
|
||||
|
||||
def _record_to_out(record: GenerationRecord, project_name: str, refs_override: list[dict] | None = None) -> GenerationRecordOut:
|
||||
refs = refs_override
|
||||
if refs is None and record.media_references:
|
||||
@@ -112,6 +152,10 @@ def _record_to_out(record: GenerationRecord, project_name: str, refs_override: l
|
||||
video_cover_url=build_resource_signed_url(record.video_cover_url) if record.video_cover_url else '',
|
||||
image_url=build_resource_signed_url(record.image_url) if record.image_url else '',
|
||||
references=refs,
|
||||
engine_id=record.engine_id,
|
||||
engine_name=(_engine_snapshot(record) or {}).get("name"),
|
||||
engine_snapshot=_engine_snapshot(record),
|
||||
include_media_references=bool(record.include_media_references),
|
||||
text_credits_cost=round(record.text_credits_cost or 0.00, 2),
|
||||
# text_tokens_used=record.text_tokens_used or 0,
|
||||
credits_cost=round(record.credits_cost or 0.00, 2),
|
||||
@@ -436,7 +480,11 @@ async def generate_record_resource(
|
||||
raise InvalidStatusError("该任务生成失败,请联系客服进行修复")
|
||||
|
||||
await assert_user_resource_capacity_available(db, current_user.id)
|
||||
attempt_no = await get_next_credit_attempt_no(db, owner_type=OWNER_GENERATION_RECORD, owner_id=record.id)
|
||||
attempt_no = await get_next_credit_attempt_no(
|
||||
db, owner_type=OWNER_GENERATION_RECORD, owner_id=record.id
|
||||
)
|
||||
selected_engine_id = req.engine_id or record.engine_id
|
||||
record.include_media_references = bool(req.include_media_references)
|
||||
|
||||
from app.services.generation.pipeline.generation_record_service import (
|
||||
commit_and_enqueue_generation_record,
|
||||
@@ -444,56 +492,83 @@ async def generate_record_resource(
|
||||
)
|
||||
|
||||
if record.gen_type == GenerationType.video:
|
||||
if req.aspect_ratio not in ASPECT_RATIOS:
|
||||
aspect_ratio = req.aspect_ratio or record.aspect_ratio
|
||||
resolution = req.resolution or record.resolution
|
||||
if aspect_ratio not in ASPECT_RATIOS:
|
||||
raise HTTPException(status_code=400, detail="不支持的画面比例")
|
||||
if req.resolution not in RESOLUTIONS:
|
||||
if resolution not in RESOLUTIONS:
|
||||
raise HTTPException(status_code=400, detail="不支持的分辨率")
|
||||
from app.services.video_gen import get_active_engine
|
||||
engine = await get_video_engine(db, selected_engine_id)
|
||||
_validate_video_engine_selection(
|
||||
engine,
|
||||
aspect_ratio=aspect_ratio,
|
||||
resolution=resolution,
|
||||
duration=int(record.duration or 5),
|
||||
)
|
||||
from app.services.video_upscale.snapshot_service import build_video_upscale_snapshot
|
||||
|
||||
engine = await get_active_engine(db)
|
||||
try:
|
||||
supported_provider_resolutions = json.loads(engine.supported_resolutions or "[]")
|
||||
except (TypeError, json.JSONDecodeError):
|
||||
supported_provider_resolutions = []
|
||||
supported_provider_resolutions = parse_json_list(engine.supported_resolutions, [])
|
||||
provider_resolution, upscale_enabled, upscale_snapshot_json = await build_video_upscale_snapshot(
|
||||
db,
|
||||
target_resolution=req.resolution,
|
||||
aspect_ratio=req.aspect_ratio,
|
||||
target_resolution=resolution,
|
||||
aspect_ratio=aspect_ratio,
|
||||
supported_provider_resolutions=supported_provider_resolutions,
|
||||
)
|
||||
billing = await charge_generation_media_by_params(
|
||||
db, user_id=current_user.id, record_id=record.id, gen_type="video",
|
||||
duration=record.duration or 5, resolution=req.resolution, engine_id=engine.id,
|
||||
project_name=project_name, description_prefix=project_name + "-",
|
||||
owner_type=OWNER_GENERATION_RECORD, attempt_no=attempt_no,
|
||||
)
|
||||
record.aspect_ratio = req.aspect_ratio
|
||||
record.resolution = req.resolution
|
||||
record.aspect_ratio = aspect_ratio
|
||||
record.resolution = resolution
|
||||
record.provider_generation_resolution = provider_resolution
|
||||
record.video_upscale_enabled_snapshot = upscale_enabled
|
||||
record.video_upscale_snapshot_json = upscale_snapshot_json
|
||||
else:
|
||||
from app.services.image_gen import get_active_image_engine
|
||||
engine = await get_active_image_engine(db)
|
||||
engine = await get_image_engine(db, selected_engine_id)
|
||||
image_size = req.image_size or record.image_size or engine.default_size or "2K"
|
||||
billing = await charge_generation_media_by_params(
|
||||
db, user_id=current_user.id, record_id=record.id, gen_type="image",
|
||||
image_size=image_size, engine_id=engine.id, project_name=project_name,
|
||||
description_prefix=project_name + "-", owner_type=OWNER_GENERATION_RECORD, attempt_no=attempt_no,
|
||||
)
|
||||
_validate_image_engine_selection(engine, image_size=image_size)
|
||||
record.image_size = image_size
|
||||
record.provider_generation_resolution = None
|
||||
record.video_upscale_enabled_snapshot = False
|
||||
record.video_upscale_snapshot_json = None
|
||||
|
||||
record.credits_cost = round(float(record.credits_cost or 0) + float(billing.total_charged or 0), 2)
|
||||
reference_usage = calculate_media_reference_usage(
|
||||
record.media_references,
|
||||
include=bool(record.include_media_references),
|
||||
)
|
||||
validate_media_reference_usage_for_engine(
|
||||
reference_usage,
|
||||
gen_type=record.gen_type,
|
||||
engine=engine,
|
||||
)
|
||||
billing = await charge_generation_media_for_record(
|
||||
db,
|
||||
record=record,
|
||||
project_name=project_name,
|
||||
description_prefix=project_name + "-",
|
||||
attempt_no=attempt_no,
|
||||
engine_id=engine.id,
|
||||
)
|
||||
record.credits_cost = round(
|
||||
float(record.credits_cost or 0) + float(billing.total_charged or 0), 2
|
||||
)
|
||||
prepare_generation_record_execution(record, engine=engine, attempt_no=attempt_no)
|
||||
await db.flush()
|
||||
await commit_and_enqueue_generation_record(db, record, reason="generation_record_api_generate")
|
||||
record_id_snapshot = str(record.id)
|
||||
await commit_and_enqueue_generation_record(
|
||||
db, record, reason="generation_record_api_generate"
|
||||
)
|
||||
|
||||
refreshed = await db.execute(
|
||||
select(GenerationRecord, Project.name)
|
||||
.join(Project, GenerationRecord.project_id == Project.id)
|
||||
.where(GenerationRecord.id == record_id_snapshot)
|
||||
.limit(1)
|
||||
)
|
||||
refreshed_row = refreshed.first()
|
||||
if not refreshed_row:
|
||||
raise RecordNotFoundError()
|
||||
record, project_name = refreshed_row
|
||||
refs = await resolve_private_portrait_reference_display_urls(
|
||||
db, json.loads(record.media_references) if record.media_references else None, user_id=current_user.id
|
||||
db,
|
||||
json.loads(record.media_references) if record.media_references else None,
|
||||
user_id=current_user.id,
|
||||
)
|
||||
return _record_to_out(record, project_name, refs_override=refs)
|
||||
|
||||
@@ -529,43 +604,82 @@ async def retry_generation(
|
||||
raise InvalidStatusError("该任务生成失败,请联系客服进行修复")
|
||||
|
||||
await assert_user_resource_capacity_available(db, current_user.id)
|
||||
attempt_no = await get_next_credit_attempt_no(db, owner_type=OWNER_GENERATION_RECORD, owner_id=record.id)
|
||||
attempt_no = await get_next_credit_attempt_no(
|
||||
db, owner_type=OWNER_GENERATION_RECORD, owner_id=record.id
|
||||
)
|
||||
from app.services.generation.pipeline.generation_record_service import (
|
||||
commit_and_enqueue_generation_record,
|
||||
prepare_generation_record_execution,
|
||||
)
|
||||
|
||||
if record.gen_type == GenerationType.video:
|
||||
from app.services.video_gen import get_active_engine
|
||||
engine = await get_video_engine(db, record.engine_id)
|
||||
_validate_video_engine_selection(
|
||||
engine,
|
||||
aspect_ratio=record.aspect_ratio or "16:9",
|
||||
resolution=record.resolution or "480p",
|
||||
duration=int(record.duration or 5),
|
||||
)
|
||||
from app.services.video_upscale.snapshot_service import build_video_upscale_snapshot
|
||||
engine = await get_active_engine(db)
|
||||
try:
|
||||
supported_provider_resolutions = json.loads(engine.supported_resolutions or "[]")
|
||||
except (TypeError, json.JSONDecodeError):
|
||||
supported_provider_resolutions = []
|
||||
|
||||
provider_resolution, upscale_enabled, upscale_snapshot_json = await build_video_upscale_snapshot(
|
||||
db, target_resolution=record.resolution or "480p", aspect_ratio=record.aspect_ratio or "16:9",
|
||||
supported_provider_resolutions=supported_provider_resolutions,
|
||||
db,
|
||||
target_resolution=record.resolution or "480p",
|
||||
aspect_ratio=record.aspect_ratio or "16:9",
|
||||
supported_provider_resolutions=parse_json_list(engine.supported_resolutions, []),
|
||||
)
|
||||
record.provider_generation_resolution = provider_resolution
|
||||
record.video_upscale_enabled_snapshot = upscale_enabled
|
||||
record.video_upscale_snapshot_json = upscale_snapshot_json
|
||||
else:
|
||||
from app.services.image_gen import get_active_image_engine
|
||||
engine = await get_active_image_engine(db)
|
||||
engine = await get_image_engine(db, record.engine_id)
|
||||
_validate_image_engine_selection(
|
||||
engine, image_size=record.image_size or engine.default_size or "2K"
|
||||
)
|
||||
|
||||
reference_usage = calculate_media_reference_usage(
|
||||
record.media_references,
|
||||
include=bool(record.include_media_references),
|
||||
)
|
||||
validate_media_reference_usage_for_engine(
|
||||
reference_usage,
|
||||
gen_type=record.gen_type,
|
||||
engine=engine,
|
||||
)
|
||||
billing = await charge_generation_media_for_record(
|
||||
db, record=record, project_name=project_name, description_prefix="资源生成重试-", attempt_no=attempt_no, engine_id=engine.id
|
||||
db,
|
||||
record=record,
|
||||
project_name=project_name,
|
||||
description_prefix="资源生成重试-",
|
||||
attempt_no=attempt_no,
|
||||
engine_id=engine.id,
|
||||
)
|
||||
record.credits_cost = round(
|
||||
float(record.credits_cost or 0) + float(billing.total_charged or 0), 2
|
||||
)
|
||||
record.credits_cost = round(float(record.credits_cost or 0) + float(billing.total_charged or 0), 2)
|
||||
record.manual_retry_count = int(record.manual_retry_count or 0) + 1
|
||||
record.retry_count = int(record.manual_retry_count or 0)
|
||||
prepare_generation_record_execution(record, engine=engine, attempt_no=attempt_no)
|
||||
await db.flush()
|
||||
await commit_and_enqueue_generation_record(db, record, reason="generation_record_api_retry")
|
||||
record_id_snapshot = str(record.id)
|
||||
await commit_and_enqueue_generation_record(
|
||||
db, record, reason="generation_record_api_retry"
|
||||
)
|
||||
|
||||
refreshed = await db.execute(
|
||||
select(GenerationRecord, Project.name)
|
||||
.join(Project, GenerationRecord.project_id == Project.id)
|
||||
.where(GenerationRecord.id == record_id_snapshot)
|
||||
.limit(1)
|
||||
)
|
||||
refreshed_row = refreshed.first()
|
||||
if not refreshed_row:
|
||||
raise RecordNotFoundError()
|
||||
record, project_name = refreshed_row
|
||||
refs = await resolve_private_portrait_reference_display_urls(
|
||||
db, json.loads(record.media_references) if record.media_references else None, user_id=current_user.id
|
||||
db,
|
||||
json.loads(record.media_references) if record.media_references else None,
|
||||
user_id=current_user.id,
|
||||
)
|
||||
return _record_to_out(record, project_name, refs_override=refs)
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.dependencies import get_current_user, get_db
|
||||
from app.models.user import User
|
||||
from app.enums.common import ModuleProjectStatusEnum
|
||||
from app.enums.common import ModuleProjectStatusEnum, ModuleEventTypeEnum
|
||||
from app.enums.generation_task import GenerationOwnerType
|
||||
from app.enums.hot_opening_replicate import HotOpeningLogEventEnum, HotOpeningStepCodeEnum, ModuleCodeEnum
|
||||
from app.schemas.hot_opening_replicate import (
|
||||
@@ -42,7 +42,7 @@ from app.services.hot_opening_replicate_service import (
|
||||
update_hot_opening_material_input,
|
||||
update_hot_opening_video_prompt_schema,
|
||||
)
|
||||
from app.services.module_generation_log_service import log_module_error
|
||||
from app.services.module_generation_log_service import log_module_error, log_module_event_file
|
||||
from app.services.module_async_recovery_service import (
|
||||
TASK_HOT_IMAGE_PROMPT,
|
||||
TASK_HOT_VIDEO_PROMPT,
|
||||
@@ -283,29 +283,17 @@ async def create_task(
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
try:
|
||||
project = await create_hot_opening_project(db, current_user, req)
|
||||
project_id_value = str(project.id)
|
||||
await bind_upload_resources(
|
||||
db,
|
||||
user_id=current_user.id,
|
||||
module=UploadResourceModuleEnum.HOT_OPENING_REPLICATE.value,
|
||||
source_model=UploadResourceSourceModelEnum.MODULE_GENERATION_PROJECT.value,
|
||||
source_id=project_id_value,
|
||||
resource_ids=[req.material_video_resource_id, req.material_image_resource_id],
|
||||
urls=[req.material_video_url, req.material_image_url],
|
||||
allow_common_migrate=True,
|
||||
)
|
||||
await db.commit()
|
||||
except HTTPException:
|
||||
await db.rollback()
|
||||
raise
|
||||
except Exception as exc:
|
||||
await db.rollback()
|
||||
_log_api_exception_from_locals(exc, locals(), f"创建爆款开头复刻项目失败: {exc}")
|
||||
raise HTTPException(status_code=500, detail=f"创建爆款开头复刻项目失败: {exc}")
|
||||
|
||||
return await _reload_project_detail(db, current_user, project_id_value)
|
||||
log_module_event_file(
|
||||
module=MODULE,
|
||||
event_type=ModuleEventTypeEnum.V1_CREATE_BLOCKED.value,
|
||||
user_id=current_user.id,
|
||||
message="拦截爆款开头复刻 V1 创建请求",
|
||||
detail={"api_version": "v1", "flow_version": "v1"},
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=410,
|
||||
detail="V1 创建流程已停止,请使用 V2 API",
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
|
||||
@@ -10,6 +10,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from app.config import settings
|
||||
from app.dependencies import get_current_user, get_db
|
||||
from app.models.user import User
|
||||
from app.enums.common import ModuleEventTypeEnum
|
||||
from app.enums.generation_task import GenerationOwnerType
|
||||
from app.enums.shot_replicate import (
|
||||
ModuleCodeEnum,
|
||||
@@ -862,24 +863,17 @@ async def create_replication_project_from_segment(
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
try:
|
||||
segment = await get_segment_for_user(db, segment_id=segment_id, user=current_user, for_update=True)
|
||||
project = await create_shot_replicate_project_from_segment(db, current_user=current_user, segment=segment, req=req)
|
||||
project_id = project.id
|
||||
await db.commit()
|
||||
except HTTPException:
|
||||
await db.rollback()
|
||||
raise
|
||||
except Exception as exc:
|
||||
await db.rollback()
|
||||
_log_api_exception_from_locals(exc, locals(), f"创建拆镜复刻项目失败: {exc}")
|
||||
raise HTTPException(status_code=500, detail=f"创建拆镜复刻项目失败: {exc}")
|
||||
|
||||
return ShotReplicateActionOut(
|
||||
message="已从拆镜片段创建复刻项目,素材视频已锁定",
|
||||
project_id=project_id,
|
||||
step_id=None,
|
||||
detail=await _reload_project_detail(db, current_user, project_id),
|
||||
log_module_event_file(
|
||||
module=MODULE,
|
||||
event_type=ModuleEventTypeEnum.V1_CREATE_BLOCKED.value,
|
||||
user_id=current_user.id,
|
||||
step_id=segment_id,
|
||||
message="拦截拆镜复刻 V1 创建请求",
|
||||
detail={"api_version": "v1", "flow_version": "v1", "segment_id": segment_id},
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=410,
|
||||
detail="V1 创建流程已停止,请使用 V2 API",
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
from fastapi import APIRouter
|
||||
|
||||
from app.api.v2.hot_opening_replicate import router as hot_opening_router
|
||||
from app.api.v2.shot_replicate import router as shot_replicate_router
|
||||
|
||||
api_router_v2 = APIRouter()
|
||||
api_router_v2.include_router(hot_opening_router)
|
||||
api_router_v2.include_router(shot_replicate_router)
|
||||
@@ -0,0 +1,255 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter, Body, Depends, HTTPException, Path
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.dependencies import get_current_user, get_db
|
||||
from app.models.chat_generation_task import ChatGenerationTask
|
||||
from app.models.user import User
|
||||
from app.schemas.hot_opening_replicate import HotOpeningActionOut, HotOpeningDeleteOut, HotOpeningTaskDetailOut
|
||||
from app.schemas.module_generation_v2 import (
|
||||
HotOpeningTaskCreateV2,
|
||||
ModuleVideoPromptRetryV2,
|
||||
ModuleVideoPromptSchemaUpdateV2,
|
||||
)
|
||||
from app.services.generation.pipeline.enqueue_service import enqueue_generation_create
|
||||
from app.services.hot_opening_replicate_service import project_to_detail_out
|
||||
from app.services.module_generation_v2.config import HOT_OPENING_V2
|
||||
from app.services.module_generation_v2.dispatch_service import (
|
||||
dispatch_video_prompt_v2,
|
||||
ensure_v2_celery_enabled,
|
||||
)
|
||||
from app.services.module_generation_v2.flow_service import (
|
||||
create_hot_opening_project_v2,
|
||||
delete_project_v2,
|
||||
generate_video_from_prompt_v2,
|
||||
get_v2_project_for_user,
|
||||
is_project_idempotency_conflict,
|
||||
mark_video_prompt_dispatch_failed_v2,
|
||||
rebuild_video_prompt_step_v2,
|
||||
update_video_prompt_schema_v2,
|
||||
)
|
||||
from app.services.upload_resource import cleanup_upload_resource_files_after_commit
|
||||
|
||||
router = APIRouter(prefix="/hot-opening-replications", tags=["hot-opening-replications-v2"])
|
||||
|
||||
|
||||
async def _detail(db: AsyncSession, current_user: User, project_id: str) -> HotOpeningTaskDetailOut:
|
||||
project = await get_v2_project_for_user(
|
||||
db,
|
||||
config=HOT_OPENING_V2,
|
||||
project_id=project_id,
|
||||
current_user=current_user,
|
||||
)
|
||||
return await project_to_detail_out(db, project)
|
||||
|
||||
|
||||
async def _dispatch_or_mark_failed(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
project_id: str,
|
||||
step_id: str,
|
||||
) -> None:
|
||||
dispatch = await dispatch_video_prompt_v2(
|
||||
config=HOT_OPENING_V2,
|
||||
project_id=project_id,
|
||||
step_id=step_id,
|
||||
)
|
||||
if dispatch.recoverable:
|
||||
return
|
||||
error_message = "视频提词任务的 Redis 注册和 Celery 投递均失败,请重新执行步骤2"
|
||||
await mark_video_prompt_dispatch_failed_v2(
|
||||
db,
|
||||
config=HOT_OPENING_V2,
|
||||
project_id=project_id,
|
||||
step_id=step_id,
|
||||
error_message=error_message,
|
||||
)
|
||||
raise HTTPException(status_code=503, detail=error_message)
|
||||
|
||||
|
||||
@router.post("/tasks", response_model=HotOpeningTaskDetailOut)
|
||||
async def create_task_v2(
|
||||
req: HotOpeningTaskCreateV2 = Body(...),
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
ensure_v2_celery_enabled()
|
||||
try:
|
||||
result = await create_hot_opening_project_v2(db, current_user=current_user, req=req)
|
||||
project_id = str(result.project.id)
|
||||
step_id = str(result.prompt_step.id)
|
||||
created_new = bool(result.created_new)
|
||||
await db.commit()
|
||||
except IntegrityError as exc:
|
||||
await db.rollback()
|
||||
if not req.idempotency_key or not is_project_idempotency_conflict(exc):
|
||||
raise HTTPException(status_code=500, detail="项目创建失败") from exc
|
||||
# 同幂等键并发请求由唯一索引收敛;回查已提交项目并按幂等成功返回。
|
||||
result = await create_hot_opening_project_v2(db, current_user=current_user, req=req)
|
||||
project_id = str(result.project.id)
|
||||
step_id = str(result.prompt_step.id)
|
||||
created_new = bool(result.created_new)
|
||||
await db.commit()
|
||||
except HTTPException:
|
||||
await db.rollback()
|
||||
raise
|
||||
except Exception as exc:
|
||||
await db.rollback()
|
||||
raise HTTPException(status_code=500, detail="创建爆款复刻 V2 项目失败") from exc
|
||||
|
||||
if created_new:
|
||||
await _dispatch_or_mark_failed(db, project_id=project_id, step_id=step_id)
|
||||
return await _detail(db, current_user, project_id)
|
||||
|
||||
|
||||
@router.get("/tasks/{project_id}", response_model=HotOpeningTaskDetailOut)
|
||||
async def get_task_v2(
|
||||
project_id: str = Path(...),
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
return await _detail(db, current_user, project_id)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/tasks/{project_id}/steps/{step_id}/retry-video-prompt",
|
||||
response_model=HotOpeningActionOut,
|
||||
)
|
||||
async def retry_video_prompt_v2(
|
||||
project_id: str,
|
||||
step_id: str,
|
||||
req: ModuleVideoPromptRetryV2,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
ensure_v2_celery_enabled()
|
||||
try:
|
||||
project, new_step = await rebuild_video_prompt_step_v2(
|
||||
db,
|
||||
config=HOT_OPENING_V2,
|
||||
current_user=current_user,
|
||||
project_id=project_id,
|
||||
source_prompt_step_id=step_id,
|
||||
video_config=req.video_config,
|
||||
)
|
||||
project_id_value = str(project.id)
|
||||
step_id_value = str(new_step.id)
|
||||
await db.commit()
|
||||
except HTTPException:
|
||||
await db.rollback()
|
||||
raise
|
||||
await _dispatch_or_mark_failed(db, project_id=project_id_value, step_id=step_id_value)
|
||||
return HotOpeningActionOut(
|
||||
message="视频提词已重新提交",
|
||||
project_id=project_id_value,
|
||||
step_id=step_id_value,
|
||||
detail=await _detail(db, current_user, project_id_value),
|
||||
)
|
||||
|
||||
|
||||
@router.put(
|
||||
"/tasks/{project_id}/steps/{step_id}/video-prompt-schema",
|
||||
response_model=HotOpeningActionOut,
|
||||
)
|
||||
async def update_video_prompt_schema_route_v2(
|
||||
project_id: str,
|
||||
step_id: str,
|
||||
req: ModuleVideoPromptSchemaUpdateV2,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
try:
|
||||
project, step = await update_video_prompt_schema_v2(
|
||||
db,
|
||||
config=HOT_OPENING_V2,
|
||||
current_user=current_user,
|
||||
project_id=project_id,
|
||||
step_id=step_id,
|
||||
req=req,
|
||||
)
|
||||
project_id_value = str(project.id)
|
||||
step_id_value = str(step.id)
|
||||
await db.commit()
|
||||
except HTTPException:
|
||||
await db.rollback()
|
||||
raise
|
||||
return HotOpeningActionOut(
|
||||
message="视频提词已保存",
|
||||
project_id=project_id_value,
|
||||
step_id=step_id_value,
|
||||
detail=await _detail(db, current_user, project_id_value),
|
||||
)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/tasks/{project_id}/steps/{step_id}/generate-video",
|
||||
response_model=HotOpeningActionOut,
|
||||
)
|
||||
async def generate_video_v2(
|
||||
project_id: str,
|
||||
step_id: str,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
ensure_v2_celery_enabled()
|
||||
try:
|
||||
project, step, task = await generate_video_from_prompt_v2(
|
||||
db,
|
||||
config=HOT_OPENING_V2,
|
||||
current_user=current_user,
|
||||
project_id=project_id,
|
||||
prompt_step_id=step_id,
|
||||
)
|
||||
project_id_value = str(project.id)
|
||||
step_id_value = str(step.id)
|
||||
task_id = str(task.id)
|
||||
await db.commit()
|
||||
except HTTPException:
|
||||
await db.rollback()
|
||||
raise
|
||||
|
||||
# commit 后重新读取,避免 ORM expire/lazy-load 风险。
|
||||
queued_task = await db.get(ChatGenerationTask, task_id)
|
||||
if queued_task is None:
|
||||
raise HTTPException(status_code=500, detail="视频生成任务提交后无法重新读取")
|
||||
try:
|
||||
await enqueue_generation_create(queued_task, reason="hot_opening_v2_generate_video")
|
||||
except Exception as exc:
|
||||
# queued 状态已持久化,周期生成恢复任务会使用确定性 task_id 补投。
|
||||
raise HTTPException(status_code=503, detail="视频生成任务暂未投递,将由恢复任务自动补投") from exc
|
||||
|
||||
return HotOpeningActionOut(
|
||||
message="视频生成任务已提交",
|
||||
project_id=project_id_value,
|
||||
step_id=step_id_value,
|
||||
detail=await _detail(db, current_user, project_id_value),
|
||||
)
|
||||
|
||||
|
||||
@router.delete("/tasks/{project_id}", response_model=HotOpeningDeleteOut)
|
||||
async def delete_project_route_v2(
|
||||
project_id: str,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
try:
|
||||
payload = await delete_project_v2(
|
||||
db,
|
||||
config=HOT_OPENING_V2,
|
||||
current_user=current_user,
|
||||
project_id=project_id,
|
||||
)
|
||||
pending_ids = list(payload.get("pending_delete_resource_ids") or [])
|
||||
await db.commit()
|
||||
except HTTPException:
|
||||
await db.rollback()
|
||||
raise
|
||||
if pending_ids:
|
||||
try:
|
||||
await cleanup_upload_resource_files_after_commit(db, resource_ids=pending_ids)
|
||||
await db.commit()
|
||||
except Exception:
|
||||
await db.rollback()
|
||||
return HotOpeningDeleteOut(**payload)
|
||||
@@ -0,0 +1,272 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter, Body, Depends, HTTPException, Path
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.dependencies import get_current_user, get_db
|
||||
from app.models.chat_generation_task import ChatGenerationTask
|
||||
from app.models.user import User
|
||||
from app.schemas.module_generation_v2 import (
|
||||
ModuleVideoPromptRetryV2,
|
||||
ModuleVideoPromptSchemaUpdateV2,
|
||||
ShotReplicateProjectCreateV2,
|
||||
)
|
||||
from app.schemas.shot_replicate import ShotReplicateActionOut, ShotReplicateDeleteOut, ShotReplicateTaskDetailOut
|
||||
from app.services.generation.pipeline.enqueue_service import enqueue_generation_create
|
||||
from app.services.module_generation_v2.config import SHOT_REPLICATE_V2
|
||||
from app.services.module_generation_v2.dispatch_service import (
|
||||
dispatch_video_prompt_v2,
|
||||
ensure_v2_celery_enabled,
|
||||
)
|
||||
from app.services.module_generation_v2.flow_service import (
|
||||
create_shot_replicate_project_v2,
|
||||
delete_project_v2,
|
||||
generate_video_from_prompt_v2,
|
||||
get_v2_project_for_user,
|
||||
is_project_idempotency_conflict,
|
||||
mark_video_prompt_dispatch_failed_v2,
|
||||
rebuild_video_prompt_step_v2,
|
||||
update_video_prompt_schema_v2,
|
||||
)
|
||||
from app.services.shot_replicate_flow_service import project_to_detail_out
|
||||
from app.services.shot_replicate_taskset_service import get_segment_for_user
|
||||
from app.services.upload_resource import cleanup_upload_resource_files_after_commit
|
||||
|
||||
router = APIRouter(prefix="/shot-replications", tags=["shot-replications-v2"])
|
||||
|
||||
|
||||
async def _detail(db: AsyncSession, current_user: User, project_id: str) -> ShotReplicateTaskDetailOut:
|
||||
project = await get_v2_project_for_user(
|
||||
db,
|
||||
config=SHOT_REPLICATE_V2,
|
||||
project_id=project_id,
|
||||
current_user=current_user,
|
||||
)
|
||||
return await project_to_detail_out(db, project)
|
||||
|
||||
|
||||
async def _dispatch_or_mark_failed(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
project_id: str,
|
||||
step_id: str,
|
||||
) -> None:
|
||||
dispatch = await dispatch_video_prompt_v2(
|
||||
config=SHOT_REPLICATE_V2,
|
||||
project_id=project_id,
|
||||
step_id=step_id,
|
||||
)
|
||||
if dispatch.recoverable:
|
||||
return
|
||||
error_message = "视频提词任务的 Redis 注册和 Celery 投递均失败,请重新执行步骤2"
|
||||
await mark_video_prompt_dispatch_failed_v2(
|
||||
db,
|
||||
config=SHOT_REPLICATE_V2,
|
||||
project_id=project_id,
|
||||
step_id=step_id,
|
||||
error_message=error_message,
|
||||
)
|
||||
raise HTTPException(status_code=503, detail=error_message)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/segments/{segment_id}/replication-projects",
|
||||
response_model=ShotReplicateActionOut,
|
||||
)
|
||||
async def create_project_v2(
|
||||
segment_id: str = Path(...),
|
||||
req: ShotReplicateProjectCreateV2 = Body(...),
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
ensure_v2_celery_enabled()
|
||||
try:
|
||||
segment = await get_segment_for_user(
|
||||
db, segment_id=segment_id, user=current_user, for_update=True
|
||||
)
|
||||
result = await create_shot_replicate_project_v2(
|
||||
db, current_user=current_user, segment=segment, req=req
|
||||
)
|
||||
project_id = str(result.project.id)
|
||||
step_id = str(result.prompt_step.id)
|
||||
created_new = bool(result.created_new)
|
||||
await db.commit()
|
||||
except IntegrityError as exc:
|
||||
await db.rollback()
|
||||
if not req.idempotency_key or not is_project_idempotency_conflict(exc):
|
||||
raise HTTPException(status_code=500, detail="项目创建失败") from exc
|
||||
segment = await get_segment_for_user(
|
||||
db, segment_id=segment_id, user=current_user, for_update=True
|
||||
)
|
||||
result = await create_shot_replicate_project_v2(
|
||||
db, current_user=current_user, segment=segment, req=req
|
||||
)
|
||||
project_id = str(result.project.id)
|
||||
step_id = str(result.prompt_step.id)
|
||||
created_new = bool(result.created_new)
|
||||
await db.commit()
|
||||
except HTTPException:
|
||||
await db.rollback()
|
||||
raise
|
||||
except Exception as exc:
|
||||
await db.rollback()
|
||||
raise HTTPException(status_code=500, detail="创建拆镜复刻 V2 项目失败") from exc
|
||||
|
||||
if created_new:
|
||||
await _dispatch_or_mark_failed(db, project_id=project_id, step_id=step_id)
|
||||
return ShotReplicateActionOut(
|
||||
message="V2 项目已创建,视频提词已自动提交" if created_new else "已返回现有幂等项目",
|
||||
project_id=project_id,
|
||||
step_id=step_id,
|
||||
detail=await _detail(db, current_user, project_id),
|
||||
)
|
||||
|
||||
|
||||
@router.get("/projects/{project_id}", response_model=ShotReplicateTaskDetailOut)
|
||||
async def get_project_v2(
|
||||
project_id: str,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
return await _detail(db, current_user, project_id)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/projects/{project_id}/steps/{step_id}/retry-video-prompt",
|
||||
response_model=ShotReplicateActionOut,
|
||||
)
|
||||
async def retry_video_prompt_v2(
|
||||
project_id: str,
|
||||
step_id: str,
|
||||
req: ModuleVideoPromptRetryV2,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
ensure_v2_celery_enabled()
|
||||
try:
|
||||
project, new_step = await rebuild_video_prompt_step_v2(
|
||||
db,
|
||||
config=SHOT_REPLICATE_V2,
|
||||
current_user=current_user,
|
||||
project_id=project_id,
|
||||
source_prompt_step_id=step_id,
|
||||
video_config=req.video_config,
|
||||
)
|
||||
project_id_value = str(project.id)
|
||||
step_id_value = str(new_step.id)
|
||||
await db.commit()
|
||||
except HTTPException:
|
||||
await db.rollback()
|
||||
raise
|
||||
await _dispatch_or_mark_failed(db, project_id=project_id_value, step_id=step_id_value)
|
||||
return ShotReplicateActionOut(
|
||||
message="视频提词已重新提交",
|
||||
project_id=project_id_value,
|
||||
step_id=step_id_value,
|
||||
detail=await _detail(db, current_user, project_id_value),
|
||||
)
|
||||
|
||||
|
||||
@router.put(
|
||||
"/projects/{project_id}/steps/{step_id}/video-prompt-schema",
|
||||
response_model=ShotReplicateActionOut,
|
||||
)
|
||||
async def update_video_prompt_schema_route_v2(
|
||||
project_id: str,
|
||||
step_id: str,
|
||||
req: ModuleVideoPromptSchemaUpdateV2,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
try:
|
||||
project, step = await update_video_prompt_schema_v2(
|
||||
db,
|
||||
config=SHOT_REPLICATE_V2,
|
||||
current_user=current_user,
|
||||
project_id=project_id,
|
||||
step_id=step_id,
|
||||
req=req,
|
||||
)
|
||||
project_id_value = str(project.id)
|
||||
step_id_value = str(step.id)
|
||||
await db.commit()
|
||||
except HTTPException:
|
||||
await db.rollback()
|
||||
raise
|
||||
return ShotReplicateActionOut(
|
||||
message="视频提词已保存",
|
||||
project_id=project_id_value,
|
||||
step_id=step_id_value,
|
||||
detail=await _detail(db, current_user, project_id_value),
|
||||
)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/projects/{project_id}/steps/{step_id}/generate-video",
|
||||
response_model=ShotReplicateActionOut,
|
||||
)
|
||||
async def generate_video_v2(
|
||||
project_id: str,
|
||||
step_id: str,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
ensure_v2_celery_enabled()
|
||||
try:
|
||||
project, step, task = await generate_video_from_prompt_v2(
|
||||
db,
|
||||
config=SHOT_REPLICATE_V2,
|
||||
current_user=current_user,
|
||||
project_id=project_id,
|
||||
prompt_step_id=step_id,
|
||||
)
|
||||
project_id_value = str(project.id)
|
||||
step_id_value = str(step.id)
|
||||
task_id = str(task.id)
|
||||
await db.commit()
|
||||
except HTTPException:
|
||||
await db.rollback()
|
||||
raise
|
||||
|
||||
queued_task = await db.get(ChatGenerationTask, task_id)
|
||||
if queued_task is None:
|
||||
raise HTTPException(status_code=500, detail="视频生成任务提交后无法重新读取")
|
||||
try:
|
||||
await enqueue_generation_create(queued_task, reason="shot_replicate_v2_generate_video")
|
||||
except Exception as exc:
|
||||
raise HTTPException(status_code=503, detail="视频生成任务暂未投递,将由恢复任务自动补投") from exc
|
||||
|
||||
return ShotReplicateActionOut(
|
||||
message="视频生成任务已提交",
|
||||
project_id=project_id_value,
|
||||
step_id=step_id_value,
|
||||
detail=await _detail(db, current_user, project_id_value),
|
||||
)
|
||||
|
||||
|
||||
@router.delete("/projects/{project_id}", response_model=ShotReplicateDeleteOut)
|
||||
async def delete_project_route_v2(
|
||||
project_id: str,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
try:
|
||||
payload = await delete_project_v2(
|
||||
db,
|
||||
config=SHOT_REPLICATE_V2,
|
||||
current_user=current_user,
|
||||
project_id=project_id,
|
||||
)
|
||||
pending_ids = list(payload.get("pending_delete_resource_ids") or [])
|
||||
await db.commit()
|
||||
except HTTPException:
|
||||
await db.rollback()
|
||||
raise
|
||||
if pending_ids:
|
||||
try:
|
||||
await cleanup_upload_resource_files_after_commit(db, resource_ids=pending_ids)
|
||||
await db.commit()
|
||||
except Exception:
|
||||
await db.rollback()
|
||||
return ShotReplicateDeleteOut(**payload)
|
||||
@@ -206,6 +206,9 @@ class Settings(BaseSettings):
|
||||
# - poll active 使用独立 Redis key,避免影响稳定的下载 active 注册表。
|
||||
GENERATION_RECOVERY_BATCH_SIZE: int = 20
|
||||
GENERATION_RECOVERY_MAX_ROUNDS: int = 1
|
||||
GENERATION_CREATE_RECOVERY_INTERVAL_SECONDS: int = 60
|
||||
GENERATION_CREATE_QUEUE_TIMEOUT_SECONDS: int = 5 * 60
|
||||
MODULE_ASYNC_RECOVERY_INTERVAL_SECONDS: int = 60
|
||||
POLL_RECOVERY_BATCH_SIZE: int = 20
|
||||
POLL_TASK_LEASE_SECONDS: int = 5 * 60
|
||||
POLL_TASK_QUEUE_TIMEOUT_SECONDS: int = 2 * 60
|
||||
|
||||
@@ -18,6 +18,7 @@ class CeleryTaskName(str, Enum):
|
||||
DOWNLOAD_GENERATION_RESULT = "generation.download_generation_result_task"
|
||||
RECOVER_DOWNLOAD = "generation.recover_download_tasks_once"
|
||||
RECOVER_GENERATION = "generation.recover_generation_tasks_once"
|
||||
RECOVER_CREATE = "generation.recover_create_tasks_once"
|
||||
VIDEO_UPSCALE_EXECUTE_LOCAL = "video_upscale.execute_local"
|
||||
VIDEO_UPSCALE_SUBMIT_REMOTE = "video_upscale.submit_remote"
|
||||
VIDEO_UPSCALE_POLL_REMOTE = "video_upscale.poll_remote"
|
||||
|
||||
@@ -27,6 +27,13 @@ class LogSourceEnum(StrEnum):
|
||||
RECOVERY = "recovery"
|
||||
REMOTE_API = "remote_api"
|
||||
|
||||
class ModuleGenerationFlowVersionEnum(StrEnum):
|
||||
"""模块生成项目流程版本。"""
|
||||
|
||||
V1 = "v1"
|
||||
V2 = "v2"
|
||||
|
||||
|
||||
class ModuleProjectStatusEnum(StrEnum):
|
||||
"""通用模块项目状态。"""
|
||||
|
||||
@@ -72,6 +79,19 @@ class ModuleEventTypeEnum(StrEnum):
|
||||
MEDIA_REFUND = "MEDIA_REFUND"
|
||||
PROMPT_BILLING_SUCCESS = "PROMPT_BILLING_SUCCESS"
|
||||
PROMPT_BILLING_FAILED = "PROMPT_BILLING_FAILED"
|
||||
V1_CREATE_BLOCKED = "V1_CREATE_BLOCKED"
|
||||
FLOW_VERSION_MISMATCH = "FLOW_VERSION_MISMATCH"
|
||||
V2_PROJECT_CREATED = "V2_PROJECT_CREATED"
|
||||
V2_VIDEO_PROMPT_AUTO_CREATED = "V2_VIDEO_PROMPT_AUTO_CREATED"
|
||||
V2_VIDEO_PROMPT_REGENERATED = "V2_VIDEO_PROMPT_REGENERATED"
|
||||
V2_VIDEO_PROMPT_DISPATCHED = "V2_VIDEO_PROMPT_DISPATCHED"
|
||||
V2_VIDEO_PROMPT_REGISTRY_FAILED = "V2_VIDEO_PROMPT_REGISTRY_FAILED"
|
||||
V2_VIDEO_PROMPT_DISPATCH_FAILED = "V2_VIDEO_PROMPT_DISPATCH_FAILED"
|
||||
STEP_SUPERSEDED = "STEP_SUPERSEDED"
|
||||
STALE_STEP_RESULT_DISCARDED = "STALE_STEP_RESULT_DISCARDED"
|
||||
GENERATION_REFERENCE_OPTION_SAVED = "GENERATION_REFERENCE_OPTION_SAVED"
|
||||
GENERATION_REFERENCE_INCLUDED = "GENERATION_REFERENCE_INCLUDED"
|
||||
GENERATION_REFERENCE_EXCLUDED = "GENERATION_REFERENCE_EXCLUDED"
|
||||
|
||||
|
||||
class ModulePromptTypeEnum(StrEnum):
|
||||
|
||||
@@ -30,6 +30,7 @@ class HotOpeningStepIOSchemaVersionEnum(StrEnum):
|
||||
"""爆款开头复刻子任务 input_json/output_json 结构版本。"""
|
||||
|
||||
V1 = "hot_opening_step_io_v1"
|
||||
V2 = "hot_opening_step_io_v2"
|
||||
|
||||
|
||||
class HotOpeningLogEventEnum(StrEnum):
|
||||
|
||||
@@ -24,3 +24,4 @@ class ModuleGenerationFlowConfig:
|
||||
cancel_chat_task_error_message: str
|
||||
material_video_url_editable: bool = True
|
||||
step_io_schema_version: str = "module_generation_step_io_v1"
|
||||
expected_flow_version: str | None = None
|
||||
|
||||
@@ -29,6 +29,7 @@ class ShotReplicateStepIOSchemaVersionEnum(StrEnum):
|
||||
"""拆镜复刻子任务 input_json/output_json 结构版本。"""
|
||||
|
||||
V1 = "shot_replicate_step_io_v1"
|
||||
V2 = "shot_replicate_step_io_v2"
|
||||
|
||||
|
||||
class ShotTaskSetStatusEnum(StrEnum):
|
||||
|
||||
@@ -12,6 +12,7 @@ from app.config import settings
|
||||
from app.models import init_database, close_database
|
||||
from app.utils.redis import init_redis, close_redis
|
||||
from app.api.v1 import api_router
|
||||
from app.api.v2 import api_router_v2
|
||||
from app.middleware.logging import RequestLoggingMiddleware
|
||||
from app.middleware.anti_crawler import AntiCrawlerMiddleware
|
||||
from app.middleware.rate_limit import RateLimitMiddleware
|
||||
@@ -545,6 +546,7 @@ def create_app() -> FastAPI:
|
||||
|
||||
# Routes
|
||||
application.include_router(api_router, prefix="/api")
|
||||
application.include_router(api_router_v2, prefix="/api/v2")
|
||||
|
||||
# Static files for uploads
|
||||
upload_dir = os.path.abspath(settings.UPLOAD_LOCAL_PATH)
|
||||
|
||||
@@ -37,6 +37,9 @@ class GenerationRecord(Base, TimestampMixin, SoftDeleteMixin):
|
||||
video_cover_url: Mapped[str | None] = mapped_column(String(512), nullable=True)
|
||||
image_url: Mapped[str | None] = mapped_column(String(512), nullable=True)
|
||||
media_references: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
include_media_references: Mapped[bool] = mapped_column(
|
||||
Boolean, nullable=False, default=False, server_default="false"
|
||||
)
|
||||
video_url_expires_at: Mapped[datetime | None] = mapped_column(
|
||||
DateTime(timezone=True), nullable=True
|
||||
)
|
||||
|
||||
@@ -5,6 +5,7 @@ from datetime import datetime
|
||||
from sqlalchemy import DateTime, ForeignKey, Index, String, Text, text
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.enums.common import ModuleGenerationFlowVersionEnum
|
||||
from app.models.base import Base, SoftDeleteMixin, TimestampMixin
|
||||
|
||||
|
||||
@@ -36,6 +37,12 @@ class ModuleGenerationProject(Base, TimestampMixin, SoftDeleteMixin):
|
||||
String(32), ForeignKey("users.id", ondelete="CASCADE"), index=True, nullable=False
|
||||
)
|
||||
module: Mapped[str] = mapped_column(String(64), index=True, nullable=False)
|
||||
flow_version: Mapped[str] = mapped_column(
|
||||
String(16),
|
||||
nullable=False,
|
||||
default=ModuleGenerationFlowVersionEnum.V1.value,
|
||||
server_default=ModuleGenerationFlowVersionEnum.V1.value,
|
||||
)
|
||||
title: Mapped[str | None] = mapped_column(String(160), nullable=True)
|
||||
status: Mapped[str] = mapped_column(String(32), default="pending", index=True)
|
||||
current_step_code: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True)
|
||||
|
||||
@@ -15,12 +15,12 @@ _STEP_JSON_TYPE = JSON().with_variant(JSONB, "postgresql")
|
||||
class ModuleGenerationStep(Base, TimestampMixin, SoftDeleteMixin):
|
||||
"""通用模块生成步骤表。
|
||||
|
||||
爆款开头复刻固定步骤:
|
||||
1 material_input
|
||||
2 image_prompt_optimize
|
||||
3 image_generate
|
||||
4 video_prompt_optimize
|
||||
5 video_generate
|
||||
V1 固定五步:material_input / image_prompt_optimize / image_generate /
|
||||
video_prompt_optimize / video_generate。
|
||||
|
||||
V2 固定三步:material_input / video_prompt_optimize / video_generate。
|
||||
version 只表示同一步骤的重建版本,不表示项目流程版本;项目版本由
|
||||
ModuleGenerationProject.flow_version 保存。
|
||||
|
||||
input_json / output_json 使用 JSON/JSONB 存储。
|
||||
建议结构:
|
||||
|
||||
@@ -25,6 +25,8 @@ class OptimizeParams(BaseModel):
|
||||
|
||||
|
||||
class GenerateParams(BaseModel):
|
||||
engine_id: str | None = Field(None, description="生成引擎ID;为空时优先沿用记录引擎,再回退默认引擎")
|
||||
include_media_references: bool = Field(False, description="最终生成时是否携带提词阶段保存的附件")
|
||||
aspect_ratio: str | None = None
|
||||
resolution: str | None = None
|
||||
image_size: str | None = None
|
||||
@@ -56,6 +58,10 @@ class GenerationRecordOut(BaseModel):
|
||||
video_cover_url: str | None = None
|
||||
image_url: str | None = None
|
||||
references: list[dict] | None = None
|
||||
engine_id: str | None = None
|
||||
engine_name: str | None = None
|
||||
engine_snapshot: dict | None = None
|
||||
include_media_references: bool = False
|
||||
text_credits_cost: float = 0.0
|
||||
# text_tokens_used: int = 0
|
||||
credits_cost: float = 0.0
|
||||
|
||||
@@ -404,6 +404,8 @@ class HotOpeningMaterialOut(BaseModel):
|
||||
source_project_name: str | None = Field(None, description="视频素材内容项目名称")
|
||||
target_project_name: str | None = Field(None, description="生成项目名称")
|
||||
core_content_point: str | None = Field(None, description="生成项目核心内容点")
|
||||
project_description: str | None = Field(None, description="V2 可选项目描述")
|
||||
video_config: dict[str, Any] | None = Field(None, description="V2 创建时保存的视频引擎、时长、比例和分辨率")
|
||||
|
||||
|
||||
class HotOpeningImageGenerationOut(BaseModel):
|
||||
@@ -445,6 +447,9 @@ class HotOpeningTaskDetailOut(BaseModel):
|
||||
user_id: str | None = Field(None, description="所属用户ID;管理员后台排查使用")
|
||||
user_name: str | None = Field(None, description="所属用户名;管理员后台排查使用")
|
||||
module: str = Field(..., description="模块标识,爆款开头复刻固定为 hot_opening_replicate")
|
||||
flow_version: str = Field("v1", description="流程版本:v1=历史五步骤,v2=简化三步骤")
|
||||
step_count: int = Field(5, description="当前流程步骤数")
|
||||
step_io_schema_version: str | None = Field(None, description="当前流程步骤 IO Schema 版本")
|
||||
title: str | None = Field(None, description="项目标题,默认取生成项目名称")
|
||||
status: str = Field(..., description="总任务状态:pending=已创建,waiting_user=等待用户操作,processing=处理中,completed=完成,failed=失败,cancelled=取消")
|
||||
current_step_code: str | None = Field(None, description="当前所处步骤编码")
|
||||
@@ -467,6 +472,8 @@ class HotOpeningTaskListItemOut(BaseModel):
|
||||
user_id: str | None = Field(None, description="所属用户ID;管理员后台排查使用")
|
||||
user_name: str | None = Field(None, description="所属用户名;管理员后台排查使用")
|
||||
module: str = Field(..., description="模块标识")
|
||||
flow_version: str = Field("v1", description="流程版本")
|
||||
step_count: int = Field(5, description="流程步骤数")
|
||||
title: str | None = Field(None, description="项目标题")
|
||||
status: str = Field(..., description="总任务状态:pending=已创建,waiting_user=等待用户操作,processing=处理中,completed=完成,failed=失败,cancelled=取消")
|
||||
current_step_code: str | None = Field(None, description="当前步骤")
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field, field_validator
|
||||
|
||||
|
||||
class ModuleGenerationVideoConfigV2(BaseModel):
|
||||
model_config = ConfigDict(extra="ignore")
|
||||
|
||||
engine_id: str = Field(..., min_length=1, max_length=32, description="视频引擎ID")
|
||||
duration: int = Field(..., ge=1, le=120, description="视频时长,单位秒")
|
||||
aspect_ratio: str = Field(..., min_length=1, max_length=16, description="视频比例")
|
||||
resolution: str = Field(..., min_length=1, max_length=16, description="目标分辨率")
|
||||
|
||||
@field_validator("engine_id", "aspect_ratio", "resolution", mode="before")
|
||||
@classmethod
|
||||
def _strip_required(cls, value: str) -> str:
|
||||
value = str(value or "").strip()
|
||||
if not value:
|
||||
raise ValueError("字段不能为空")
|
||||
return value
|
||||
|
||||
|
||||
class HotOpeningTaskCreateV2(BaseModel):
|
||||
model_config = ConfigDict(extra="ignore")
|
||||
|
||||
material_video_url: str = Field(..., min_length=1, description="爆款参考视频,步骤2分析使用,步骤3绝不携带")
|
||||
material_video_resource_id: str | None = Field(None, max_length=32)
|
||||
material_video_duration_seconds: float | None = Field(None, gt=0)
|
||||
material_image_url: str | None = Field(None, description="可选参考图片;仅支持本地上传/历史素材")
|
||||
material_image_resource_id: str | None = Field(None, max_length=32)
|
||||
source_project_name: str | None = Field(None, max_length=40)
|
||||
target_project_name: str | None = Field(None, max_length=40)
|
||||
core_content_point: str | None = Field(None, max_length=200)
|
||||
project_description: str | None = Field(None, max_length=1000)
|
||||
target_platform: str | None = Field(None, max_length=64)
|
||||
video_config: ModuleGenerationVideoConfigV2
|
||||
idempotency_key: str | None = Field(None, max_length=64)
|
||||
|
||||
@field_validator(
|
||||
"material_video_url",
|
||||
"material_image_url",
|
||||
"source_project_name",
|
||||
"target_project_name",
|
||||
"core_content_point",
|
||||
"project_description",
|
||||
"target_platform",
|
||||
"idempotency_key",
|
||||
mode="before",
|
||||
)
|
||||
@classmethod
|
||||
def _strip_text(cls, value: str | None) -> str | None:
|
||||
if value is None:
|
||||
return None
|
||||
value = str(value).strip()
|
||||
return value or None
|
||||
|
||||
|
||||
class ShotReplicateProjectCreateV2(BaseModel):
|
||||
model_config = ConfigDict(extra="ignore")
|
||||
|
||||
material_image_url: str | None = Field(None, description="可选参考图片;仅支持本地上传/历史素材")
|
||||
material_image_resource_id: str | None = Field(None, max_length=32)
|
||||
target_project_name: str | None = Field(None, max_length=40)
|
||||
core_content_point: str | None = Field(None, max_length=200)
|
||||
project_description: str | None = Field(None, max_length=1000)
|
||||
target_platform: str | None = Field(None, max_length=64)
|
||||
video_config: ModuleGenerationVideoConfigV2
|
||||
idempotency_key: str | None = Field(None, max_length=64)
|
||||
|
||||
@field_validator(
|
||||
"material_image_url",
|
||||
"target_project_name",
|
||||
"core_content_point",
|
||||
"project_description",
|
||||
"target_platform",
|
||||
"idempotency_key",
|
||||
mode="before",
|
||||
)
|
||||
@classmethod
|
||||
def _strip_text(cls, value: str | None) -> str | None:
|
||||
if value is None:
|
||||
return None
|
||||
value = str(value).strip()
|
||||
return value or None
|
||||
|
||||
|
||||
class ModuleVideoPromptRetryV2(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
video_config: ModuleGenerationVideoConfigV2
|
||||
|
||||
|
||||
class ModuleVideoPromptSchemaUpdateV2(BaseModel):
|
||||
model_config = ConfigDict(extra="ignore")
|
||||
|
||||
prompt_schema: dict[str, Any] = Field(..., description="允许编辑字段的 JSON patch")
|
||||
|
||||
@field_validator("prompt_schema")
|
||||
@classmethod
|
||||
def _non_empty_schema(cls, value: dict[str, Any]) -> dict[str, Any]:
|
||||
if not isinstance(value, dict) or not value:
|
||||
raise ValueError("prompt_schema 必须为非空对象")
|
||||
return value
|
||||
|
||||
|
||||
class ModuleGenerationV2ActionOut(BaseModel):
|
||||
message: str
|
||||
project_id: str
|
||||
step_id: str | None = None
|
||||
flow_version: str = "v2"
|
||||
detail: Any | None = None
|
||||
@@ -56,6 +56,10 @@ class RecentGenerationItemOut(BaseModel):
|
||||
None,
|
||||
description="关联拆镜片段ID。仅 shot_replicate 模块可能有值,来源 shot_replicate_segments.id;其他模块返回 null。",
|
||||
)
|
||||
module_project_flow_version: str | None = Field(
|
||||
None,
|
||||
description="关联模块项目流程版本。hot_opening_replicate/shot_replicate 返回 v1/v2;其他模块返回 null。",
|
||||
)
|
||||
module_project_id: str | None = Field(
|
||||
None,
|
||||
description="通用模块项目ID。hot_opening_replicate/shot_replicate 模块可能有值,来源 module_generation_steps.project_id;project/chat_ai 返回 null。",
|
||||
|
||||
@@ -395,6 +395,8 @@ class ShotReplicateMaterialOut(BaseModel):
|
||||
source_project_name: str | None = Field(None, description="视频素材内容项目名称")
|
||||
target_project_name: str | None = Field(None, description="生成项目名称")
|
||||
core_content_point: str | None = Field(None, description="生成项目核心内容点")
|
||||
project_description: str | None = Field(None, description="V2 可选项目描述")
|
||||
video_config: dict[str, Any] | None = Field(None, description="V2 创建时保存的视频引擎、时长、比例和分辨率")
|
||||
|
||||
|
||||
class ShotReplicateImageGenerationOut(BaseModel):
|
||||
@@ -436,6 +438,9 @@ class ShotReplicateTaskDetailOut(BaseModel):
|
||||
user_id: str | None = Field(None, description="所属用户ID;管理员后台排查使用")
|
||||
user_name: str | None = Field(None, description="所属用户名;管理员后台排查使用")
|
||||
module: str = Field(..., description="模块标识,拆镜复刻固定为 shot_replicate")
|
||||
flow_version: str = Field("v1", description="流程版本:v1=历史五步骤,v2=简化三步骤")
|
||||
step_count: int = Field(5, description="当前流程步骤数")
|
||||
step_io_schema_version: str | None = Field(None, description="当前流程步骤 IO Schema 版本")
|
||||
title: str | None = Field(None, description="项目标题,默认取生成项目名称")
|
||||
status: str = Field(..., description="总任务状态:pending/waiting_user/processing/completed/failed/cancelled")
|
||||
current_step_code: str | None = Field(None, description="当前所处步骤编码")
|
||||
@@ -456,6 +461,8 @@ class ShotReplicateTaskListItemOut(BaseModel):
|
||||
id: str = Field(..., description="总任务项目ID。这个ID就是前端项目ID")
|
||||
project_id: str = Field(..., description="兼容前端命名,等同于 id")
|
||||
module: str = Field(..., description="模块标识")
|
||||
flow_version: str = Field("v1", description="流程版本")
|
||||
step_count: int = Field(5, description="流程步骤数")
|
||||
title: str | None = Field(None, description="项目标题")
|
||||
status: str = Field(..., description="总任务状态")
|
||||
current_step_code: str | None = Field(None, description="当前步骤")
|
||||
@@ -702,6 +709,7 @@ class ShotSegmentOut(BaseModel):
|
||||
module_project_title: str | None = Field(None, description="关联拆镜复刻项目标题;后台片段列表展示使用")
|
||||
module_project_status: str | None = Field(None, description="关联拆镜复刻项目状态;后台片段列表展示使用")
|
||||
module_project_current_step_code: str | None = Field(None, description="关联拆镜复刻项目当前步骤;后台片段列表展示使用")
|
||||
module_project_flow_version: str | None = Field(None, description="关联复刻项目流程版本:v1/v2")
|
||||
created_at: NaiveDatetimeOptional = Field(None, description="创建时间")
|
||||
updated_at: NaiveDatetimeOptional = Field(None, description="更新时间")
|
||||
|
||||
|
||||
@@ -113,7 +113,11 @@ def build_video_snapshot(engine: VideoEngine, ratio: str, resolution: str, durat
|
||||
"supported_resolutions": parse_json_list(engine.supported_resolutions, []),
|
||||
"supported_durations": parse_json_list(engine.supported_durations, []),
|
||||
"max_duration": engine.max_duration,
|
||||
"max_audio_count": engine.max_audio_count,
|
||||
"max_image_count": int(getattr(engine, "max_image_count", 0) or 0),
|
||||
"max_video_count": int(getattr(engine, "max_video_count", 0) or 0),
|
||||
"max_audio_count": int(getattr(engine, "max_audio_count", 0) or 0),
|
||||
"supports_universal_reference": bool(getattr(engine, "supports_universal_reference", False)),
|
||||
"supports_first_last_frame": bool(getattr(engine, "supports_first_last_frame", False)),
|
||||
"multi_generation_enabled": bool(getattr(engine, "multi_generation_enabled", False)),
|
||||
"max_generation_count": normalize_generation_count(getattr(engine, "max_generation_count", 1)),
|
||||
"selected_ratio": ratio,
|
||||
|
||||
@@ -10,6 +10,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from app.enums.credit_record import CreditRecordBillingScene, CreditRecordChargeKind, CreditRecordOwnerType, CreditRecordSourceModule
|
||||
from app.models.credit_record import CreditRecord
|
||||
from app.models.generation_record import GenerationRecord
|
||||
from app.services.generation.media_reference_service import calculate_media_reference_usage
|
||||
from app.models.module_generation_step import ModuleGenerationStep
|
||||
from app.models.token_usage import TokenUsage
|
||||
from app.models.system_config import SystemConfig
|
||||
@@ -461,6 +462,7 @@ async def charge_generation_media_by_params(
|
||||
resolution: str | None = None,
|
||||
engine_id: str | None = None,
|
||||
input_video_duration: float | None = None,
|
||||
input_image_count: int | None = None,
|
||||
project_name: str | None = None,
|
||||
description_prefix: str = "AI创作-",
|
||||
owner_type: str = OWNER_CHAT_GENERATION_TASK,
|
||||
@@ -510,7 +512,9 @@ async def charge_generation_media_by_params(
|
||||
|
||||
if gen_type == "image":
|
||||
size = image_size or "2K"
|
||||
unit_amount = await calc_image_credits(db, size, engine_id=engine_id)
|
||||
unit_amount = await calc_image_credits(
|
||||
db, size, engine_id=engine_id, input_image_count=input_image_count
|
||||
)
|
||||
amount = round(unit_amount * quantity, 2)
|
||||
items.append(
|
||||
await deduct_credits_locked_once(
|
||||
@@ -530,6 +534,7 @@ async def charge_generation_media_by_params(
|
||||
db, duration or 5, resolution or "720p",
|
||||
engine_id=engine_id,
|
||||
input_video_duration=input_video_duration,
|
||||
input_image_count=input_image_count,
|
||||
)
|
||||
amount = round(unit_amount * quantity, 2)
|
||||
items.append(
|
||||
@@ -560,6 +565,10 @@ async def charge_generation_media_for_record(
|
||||
attempt_no: int | None = None,
|
||||
engine_id: str | None = None,
|
||||
) -> BillingSummary:
|
||||
reference_usage = calculate_media_reference_usage(
|
||||
record.media_references,
|
||||
include=bool(record.include_media_references),
|
||||
)
|
||||
return await charge_generation_media_by_params(
|
||||
db,
|
||||
user_id=record.user_id,
|
||||
@@ -569,6 +578,8 @@ async def charge_generation_media_for_record(
|
||||
duration=record.duration,
|
||||
resolution=record.resolution,
|
||||
engine_id=engine_id or getattr(record, "engine_id", None),
|
||||
input_video_duration=reference_usage.input_video_duration or None,
|
||||
input_image_count=reference_usage.image_count or None,
|
||||
project_name=project_name,
|
||||
description_prefix=description_prefix,
|
||||
owner_type=OWNER_GENERATION_RECORD,
|
||||
|
||||
@@ -0,0 +1,148 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, Iterable
|
||||
|
||||
from fastapi import HTTPException
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class MediaReferenceUsage:
|
||||
image_count: int = 0
|
||||
video_count: int = 0
|
||||
audio_count: int = 0
|
||||
input_video_duration: float = 0.0
|
||||
input_audio_duration: float = 0.0
|
||||
|
||||
|
||||
def parse_media_references(value: str | list[dict] | None) -> list[dict]:
|
||||
if not value:
|
||||
return []
|
||||
data: Any = value
|
||||
if isinstance(value, str):
|
||||
try:
|
||||
data = json.loads(value)
|
||||
except (TypeError, json.JSONDecodeError):
|
||||
return []
|
||||
if not isinstance(data, list):
|
||||
return []
|
||||
return [item for item in data if isinstance(item, dict)]
|
||||
|
||||
|
||||
def _reference_type(item: dict) -> str:
|
||||
value = str(item.get("type") or item.get("media_type") or "").strip().lower()
|
||||
if value in {"image", "video", "audio"}:
|
||||
return value
|
||||
mime = str(item.get("mime_type") or item.get("content_type") or "").lower()
|
||||
if mime.startswith("image/"):
|
||||
return "image"
|
||||
if mime.startswith("video/"):
|
||||
return "video"
|
||||
if mime.startswith("audio/"):
|
||||
return "audio"
|
||||
url = str(item.get("url") or item.get("file_url") or "").lower().split("?", 1)[0]
|
||||
if url.endswith((".png", ".jpg", ".jpeg", ".webp", ".gif", ".bmp")):
|
||||
return "image"
|
||||
if url.endswith((".mp4", ".mov", ".webm", ".mkv", ".avi")):
|
||||
return "video"
|
||||
if url.endswith((".mp3", ".wav", ".m4a", ".aac", ".ogg", ".flac")):
|
||||
return "audio"
|
||||
return ""
|
||||
|
||||
|
||||
def _duration(item: dict) -> float:
|
||||
for key in ("duration", "duration_seconds", "video_duration", "audio_duration"):
|
||||
try:
|
||||
value = float(item.get(key) or 0)
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
if value > 0:
|
||||
return value
|
||||
return 0.0
|
||||
|
||||
|
||||
def calculate_media_reference_usage(
|
||||
references: str | list[dict] | None,
|
||||
*,
|
||||
include: bool,
|
||||
) -> MediaReferenceUsage:
|
||||
if not include:
|
||||
return MediaReferenceUsage()
|
||||
image_count = video_count = audio_count = 0
|
||||
video_duration = audio_duration = 0.0
|
||||
for item in parse_media_references(references):
|
||||
media_type = _reference_type(item)
|
||||
if media_type == "image":
|
||||
image_count += 1
|
||||
elif media_type == "video":
|
||||
video_count += 1
|
||||
video_duration += _duration(item)
|
||||
elif media_type == "audio":
|
||||
audio_count += 1
|
||||
audio_duration += _duration(item)
|
||||
return MediaReferenceUsage(
|
||||
image_count=image_count,
|
||||
video_count=video_count,
|
||||
audio_count=audio_count,
|
||||
input_video_duration=round(video_duration, 3),
|
||||
input_audio_duration=round(audio_duration, 3),
|
||||
)
|
||||
|
||||
|
||||
def filter_references_by_type(
|
||||
references: str | list[dict] | None,
|
||||
*,
|
||||
allowed_types: Iterable[str],
|
||||
max_count: int | None = None,
|
||||
) -> list[dict]:
|
||||
allowed = {str(item).lower() for item in allowed_types}
|
||||
result = [item for item in parse_media_references(references) if _reference_type(item) in allowed]
|
||||
if max_count is not None:
|
||||
return result[: max(0, int(max_count))]
|
||||
return result
|
||||
|
||||
|
||||
def validate_media_reference_usage_for_engine(
|
||||
usage: MediaReferenceUsage,
|
||||
*,
|
||||
gen_type: str,
|
||||
engine: Any,
|
||||
) -> None:
|
||||
"""按所选引擎能力校验最终实际会发送的附件。"""
|
||||
normalized = str(gen_type or "").strip().lower()
|
||||
if normalized == "video":
|
||||
if not bool(getattr(engine, "supports_universal_reference", True)) and (
|
||||
usage.image_count or usage.video_count or usage.audio_count
|
||||
):
|
||||
raise HTTPException(status_code=400, detail="当前视频引擎不支持参考附件")
|
||||
limits = {
|
||||
"图片": int(getattr(engine, "max_image_count", 0) or 0),
|
||||
"视频": int(getattr(engine, "max_video_count", 0) or 0),
|
||||
"音频": int(getattr(engine, "max_audio_count", 0) or 0),
|
||||
}
|
||||
counts = {"图片": usage.image_count, "视频": usage.video_count, "音频": usage.audio_count}
|
||||
for label, count in counts.items():
|
||||
limit = limits[label]
|
||||
if count > limit:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"当前视频引擎最多支持 {limit} 个{label}附件,当前为 {count} 个",
|
||||
)
|
||||
return
|
||||
if normalized == "image":
|
||||
if usage.video_count or usage.audio_count:
|
||||
raise HTTPException(status_code=400, detail="图片生成只能携带图片附件")
|
||||
limit = int(
|
||||
getattr(engine, "max_reference_image_count", None)
|
||||
if getattr(engine, "max_reference_image_count", None) is not None
|
||||
else getattr(engine, "max_image_count", 0)
|
||||
or 0
|
||||
)
|
||||
if usage.image_count > limit:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"当前图片引擎最多支持 {limit} 张参考图,当前为 {usage.image_count} 张",
|
||||
)
|
||||
return
|
||||
raise HTTPException(status_code=400, detail="不支持的生成类型")
|
||||
@@ -58,6 +58,18 @@ def owner_type_of(owner: GenerationOwner) -> str:
|
||||
raise TypeError(f"不支持的生成任务对象: {type(owner)!r}")
|
||||
|
||||
|
||||
def owner_include_media_references(owner: GenerationOwner) -> bool:
|
||||
"""返回本次供应商创建是否应携带附件。
|
||||
|
||||
ChatGenerationTask 延续原有行为;GenerationRecord 使用用户提交并持久化的开关。
|
||||
"""
|
||||
if isinstance(owner, ChatGenerationTask):
|
||||
return True
|
||||
if isinstance(owner, GenerationRecord):
|
||||
return bool(owner.include_media_references)
|
||||
raise TypeError(f"不支持的生成任务对象: {type(owner)!r}")
|
||||
|
||||
|
||||
def owner_mode(owner: GenerationOwner) -> str:
|
||||
if isinstance(owner, ChatGenerationTask):
|
||||
return str(owner.generation_mode or GenerationMode.CHATAPI_ASYNC.value)
|
||||
|
||||
@@ -10,8 +10,11 @@ from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.config import settings
|
||||
from app.models.chat_generation_task import ChatGenerationTask
|
||||
from app.services.generation.pipeline.owner_service import GenerationOwner, owner_provider_task_id
|
||||
from app.services.generation.pipeline.owner_service import (
|
||||
GenerationOwner,
|
||||
owner_include_media_references,
|
||||
owner_provider_task_id,
|
||||
)
|
||||
from app.models.image_engine import ImageEngine
|
||||
from app.models.video_engine import VideoEngine
|
||||
from app.services.generation.log_service import log_provider_call
|
||||
@@ -105,7 +108,7 @@ async def _create_video_task(db: AsyncSession, task: GenerationOwner) -> dict:
|
||||
started = time.perf_counter()
|
||||
async with provider_limit("ark_video_create", settings.ARK_VIDEO_CREATE_MAX_CONCURRENCY):
|
||||
try:
|
||||
provider_task_id = await submit_video_task(None, engine, task, include_media_references=isinstance(task, ChatGenerationTask))
|
||||
provider_task_id = await submit_video_task(None, engine, task, include_media_references=owner_include_media_references(task))
|
||||
response = {"task_id": provider_task_id}
|
||||
await log_provider_call(
|
||||
task,
|
||||
@@ -169,7 +172,7 @@ async def create_image_sync_batch_result_with_engine(
|
||||
None,
|
||||
engine,
|
||||
task,
|
||||
include_media_references=isinstance(task, ChatGenerationTask),
|
||||
include_media_references=owner_include_media_references(task),
|
||||
generation_count=count,
|
||||
)
|
||||
response_data = result.get("response_data") or result
|
||||
|
||||
@@ -606,6 +606,8 @@ async def recover_one_generation_task(
|
||||
ChatGenerationPipelineStage.CREATING_PROVIDER_TASK.value,
|
||||
):
|
||||
task.pipeline_stage = ChatGenerationPipelineStage.QUEUED.value
|
||||
# 刷新更新时间形成创建队列保护窗口,避免 Beat 在任务尚未消费时每轮重复补投。
|
||||
task.updated_at = current_time
|
||||
# Release the recovery row lock before writing an event through the
|
||||
# independent logging session or talking to the broker.
|
||||
await db.commit()
|
||||
@@ -622,12 +624,17 @@ async def recover_one_generation_task(
|
||||
kwargs={"owner_type": GenerationOwnerType.CHAT_GENERATION_TASK.value, "generation_attempt_no": int(task.generation_attempt_no or 1)},
|
||||
queue=CeleryQueue.GEN_CHATAPI_CREATE.value,
|
||||
countdown=0,
|
||||
task_id=(
|
||||
f"generation-create:{GenerationOwnerType.CHAT_GENERATION_TASK.value}:"
|
||||
f"{task.id}:attempt:{int(task.generation_attempt_no or 1)}"
|
||||
),
|
||||
)
|
||||
return "recover_create_no_remote_no_provider_before_deadline"
|
||||
|
||||
# result_ready 但没有 URL 是脏状态;未过 deadline 时回创建队列重新处理,过期上面已标记超时。
|
||||
if task.pipeline_stage == ChatGenerationPipelineStage.RESULT_READY.value:
|
||||
task.pipeline_stage = ChatGenerationPipelineStage.QUEUED.value
|
||||
task.updated_at = current_time
|
||||
await db.commit()
|
||||
await _remove_poll_active(_chat_registry_id(task))
|
||||
await log_task_event(
|
||||
@@ -641,6 +648,10 @@ async def recover_one_generation_task(
|
||||
kwargs={"owner_type": GenerationOwnerType.CHAT_GENERATION_TASK.value, "generation_attempt_no": int(task.generation_attempt_no or 1)},
|
||||
queue=CeleryQueue.GEN_CHATAPI_CREATE.value,
|
||||
countdown=0,
|
||||
task_id=(
|
||||
f"generation-create:{GenerationOwnerType.CHAT_GENERATION_TASK.value}:"
|
||||
f"{task.id}:attempt:{int(task.generation_attempt_no or 1)}"
|
||||
),
|
||||
)
|
||||
return "recover_create_result_ready_no_url_before_deadline"
|
||||
|
||||
@@ -887,6 +898,61 @@ async def recover_generation_tasks_once(db: AsyncSession) -> dict[str, Any]:
|
||||
"results": results,
|
||||
}
|
||||
|
||||
|
||||
async def recover_stale_create_tasks_once(db: AsyncSession) -> dict[str, Any]:
|
||||
"""轻量恢复长时间未消费的创建阶段 ChatGenerationTask。
|
||||
|
||||
只扫描 queued/preparing/creating_provider_task,避免周期任务重复执行完整
|
||||
provider poll、下载和主任务汇总逻辑。
|
||||
"""
|
||||
current_time = _now()
|
||||
cutoff = current_time - timedelta(
|
||||
seconds=max(1, int(settings.GENERATION_CREATE_QUEUE_TIMEOUT_SECONDS or 300))
|
||||
)
|
||||
lease_expired_at = current_time
|
||||
batch_size = max(1, int(settings.GENERATION_RECOVERY_BATCH_SIZE or 20))
|
||||
result = await db.execute(
|
||||
select(ChatGenerationTask.id)
|
||||
.where(
|
||||
ChatGenerationTask.deleted_at.is_(None),
|
||||
ChatGenerationTask.generation_mode.in_(list(ALLOWED_GENERATION_MODES)),
|
||||
ChatGenerationTask.status == ChatGenerationTaskStatus.GENERATING.value,
|
||||
ChatGenerationTask.pipeline_stage.in_(
|
||||
[
|
||||
ChatGenerationPipelineStage.QUEUED.value,
|
||||
ChatGenerationPipelineStage.PREPARING.value,
|
||||
ChatGenerationPipelineStage.CREATING_PROVIDER_TASK.value,
|
||||
]
|
||||
),
|
||||
ChatGenerationTask.remote_result_url.is_(None),
|
||||
ChatGenerationTask.provider_task_id.is_(None),
|
||||
ChatGenerationTask.seedance_task_id.is_(None),
|
||||
ChatGenerationTask.updated_at <= cutoff,
|
||||
(
|
||||
ChatGenerationTask.provider_create_lease_until.is_(None)
|
||||
| (ChatGenerationTask.provider_create_lease_until <= lease_expired_at)
|
||||
),
|
||||
)
|
||||
.order_by(ChatGenerationTask.updated_at.asc(), ChatGenerationTask.id.asc())
|
||||
.limit(batch_size)
|
||||
)
|
||||
task_ids = [str(value) for value in result.scalars().all()]
|
||||
counts: dict[str, int] = {}
|
||||
for task_id in task_ids:
|
||||
task = await _load_chat_task_for_update(db, task_id)
|
||||
if task is None:
|
||||
await db.rollback()
|
||||
action = "skip_missing_task"
|
||||
else:
|
||||
action = await recover_one_generation_task(
|
||||
db,
|
||||
task,
|
||||
payload=None,
|
||||
source="periodic_create_recovery",
|
||||
)
|
||||
counts[action] = counts.get(action, 0) + 1
|
||||
return {"checked": len(task_ids), "results": counts}
|
||||
|
||||
async def dispatch_due_poll_tasks_once(db: AsyncSession) -> dict[str, Any]:
|
||||
"""周期性轻量到期轮询调度。
|
||||
|
||||
|
||||
@@ -26,6 +26,10 @@ from app.services.generation.ai.engine_service import (
|
||||
parse_json_list as _parse_list,
|
||||
)
|
||||
from app.services.generation.billing_service import OWNER_CHAT_GENERATION_TASK, charge_generation_media_by_params
|
||||
from app.services.generation.media_reference_service import (
|
||||
calculate_media_reference_usage,
|
||||
validate_media_reference_usage_for_engine,
|
||||
)
|
||||
from app.services.resource_capacity_service import assert_user_resource_capacity_available
|
||||
from app.services.video_upscale.snapshot_service import build_video_upscale_snapshot
|
||||
from app.services.private_portrait.reference_resolver import resolve_private_portrait_references
|
||||
@@ -111,6 +115,8 @@ async def create_chat_generation_task_for_module(
|
||||
raise HTTPException(status_code=400, detail=f"图片比例不支持: {proportion}")
|
||||
px = px or normalize_px((sizes.get(size) or {}).get(proportion))
|
||||
px = px or IMAGE_DEFAULT_PX
|
||||
reference_usage = calculate_media_reference_usage(refs, include=True)
|
||||
validate_media_reference_usage_for_engine(reference_usage, gen_type="image", engine=engine)
|
||||
media_billing = await charge_generation_media_by_params(
|
||||
db,
|
||||
user_id=current_user.id,
|
||||
@@ -118,6 +124,7 @@ async def create_chat_generation_task_for_module(
|
||||
gen_type="image",
|
||||
image_size=size,
|
||||
engine_id=engine.id,
|
||||
input_image_count=reference_usage.image_count or None,
|
||||
project_name=billing_project_name,
|
||||
description_prefix=billing_description_prefix,
|
||||
owner_type=OWNER_CHAT_GENERATION_TASK,
|
||||
@@ -174,6 +181,8 @@ async def create_chat_generation_task_for_module(
|
||||
aspect_ratio=ratio,
|
||||
supported_provider_resolutions=resolutions,
|
||||
)
|
||||
reference_usage = calculate_media_reference_usage(refs, include=True)
|
||||
validate_media_reference_usage_for_engine(reference_usage, gen_type="video", engine=engine)
|
||||
media_billing = await charge_generation_media_by_params(
|
||||
db,
|
||||
user_id=current_user.id,
|
||||
@@ -182,6 +191,8 @@ async def create_chat_generation_task_for_module(
|
||||
duration=selected_duration,
|
||||
resolution=selected_resolution,
|
||||
engine_id=engine.id,
|
||||
input_video_duration=reference_usage.input_video_duration or None,
|
||||
input_image_count=reference_usage.image_count or None,
|
||||
project_name=billing_project_name,
|
||||
description_prefix=billing_description_prefix,
|
||||
owner_type=OWNER_CHAT_GENERATION_TASK,
|
||||
|
||||
@@ -117,6 +117,7 @@ FLOW_CONFIG = ModuleGenerationFlowConfig(
|
||||
cancel_chat_task_error_message="爆款开头复刻步骤被重新生成或删除,旧生成任务已取消",
|
||||
material_video_url_editable=True,
|
||||
step_io_schema_version=STEP_IO_SCHEMA_VERSION,
|
||||
expected_flow_version="v1",
|
||||
)
|
||||
|
||||
|
||||
@@ -422,12 +423,19 @@ async def project_to_detail_out(db: AsyncSession, project: ModuleGenerationProje
|
||||
user_result = await db.execute(select(User.username).where(User.id == project.user_id).limit(1))
|
||||
user_name = user_result.scalar_one_or_none()
|
||||
|
||||
flow_version = str(getattr(project, "flow_version", None) or "v1")
|
||||
video_prompt_config = dict(video_prompt_input.get("video_config") or {})
|
||||
video_prompt_engine_snapshot = dict(video_prompt_config.get("engine_snapshot") or {})
|
||||
|
||||
return HotOpeningTaskDetailOut(
|
||||
id=project.id,
|
||||
project_id=project.id,
|
||||
user_id=project.user_id,
|
||||
user_name=user_name,
|
||||
module=project.module,
|
||||
flow_version=flow_version,
|
||||
step_count=3 if flow_version == "v2" else 5,
|
||||
step_io_schema_version=("hot_opening_step_io_v2" if project.module == "hot_opening_replicate" else "shot_replicate_step_io_v2") if flow_version == "v2" else STEP_IO_SCHEMA_VERSION,
|
||||
title=project.title,
|
||||
status=project.status,
|
||||
current_step_code=project.current_step_code,
|
||||
@@ -442,6 +450,8 @@ async def project_to_detail_out(db: AsyncSession, project: ModuleGenerationProje
|
||||
source_project_name=material_input.get("source_project_name"),
|
||||
target_project_name=material_input.get("target_project_name"),
|
||||
core_content_point=material_input.get("core_content_point"),
|
||||
project_description=material_input.get("project_description"),
|
||||
video_config=None if flow_version == "v2" else material_input.get("video_config"),
|
||||
),
|
||||
image_generation=HotOpeningImageGenerationOut(
|
||||
prompt_step_id=image_prompt_step.id if image_prompt_step else None,
|
||||
@@ -465,9 +475,9 @@ async def project_to_detail_out(db: AsyncSession, project: ModuleGenerationProje
|
||||
schema_config_source=schema_config_source,
|
||||
schema_config_version=schema_config_version,
|
||||
schema_config_is_fallback=schema_config_is_fallback,
|
||||
engine_id=video_snapshot.get("id") or video_generate_input.get("engine_id"),
|
||||
engine_name=video_snapshot.get("name") or video_generate_input.get("engine_name"),
|
||||
params=video_generate_input.get("params") or video_generate_input,
|
||||
engine_id=video_snapshot.get("id") or video_generate_input.get("engine_id") or video_prompt_config.get("engine_id"),
|
||||
engine_name=video_snapshot.get("name") or video_generate_input.get("engine_name") or video_prompt_engine_snapshot.get("name"),
|
||||
params=video_generate_input.get("params") or video_prompt_config or video_generate_input,
|
||||
chat_task_id=video_generate_step.chat_task_id if video_generate_step else None,
|
||||
status=video_chat.status if video_chat else (video_generate_step.status if video_generate_step else None),
|
||||
result_video_url=build_resource_signed_url(video_url) if video_url else None,
|
||||
@@ -660,6 +670,8 @@ async def list_hot_opening_projects(
|
||||
user_id=project.user_id,
|
||||
user_name=user_name_map.get(project.user_id) if current_user.is_admin and project.user_id else None,
|
||||
module=project.module,
|
||||
flow_version=str(getattr(project, "flow_version", None) or "v1"),
|
||||
step_count=3 if str(getattr(project, "flow_version", None) or "v1") == "v2" else 5,
|
||||
title=project.title,
|
||||
status=project.status,
|
||||
current_step_code=project.current_step_code,
|
||||
@@ -1532,6 +1544,9 @@ async def generate_video_from_prompt(
|
||||
async def handle_chat_generation_task_completed(db: AsyncSession, task: ChatGenerationTask) -> None:
|
||||
if not task or task.generation_mode != GENERATION_MODE:
|
||||
return
|
||||
from app.services.module_generation_v2.flow_service import handle_chat_generation_task_finished_v2
|
||||
if await handle_chat_generation_task_finished_v2(db, task=task):
|
||||
return
|
||||
meta_result = await db.execute(
|
||||
select(ModuleGenerationStep.id, ModuleGenerationStep.project_id).where(
|
||||
ModuleGenerationStep.chat_task_id == task.id,
|
||||
@@ -1614,6 +1629,9 @@ async def handle_chat_generation_task_completed(db: AsyncSession, task: ChatGene
|
||||
async def handle_chat_generation_task_failed(db: AsyncSession, task: ChatGenerationTask) -> None:
|
||||
if not task or task.generation_mode != GENERATION_MODE:
|
||||
return
|
||||
from app.services.module_generation_v2.flow_service import handle_chat_generation_task_finished_v2
|
||||
if await handle_chat_generation_task_finished_v2(db, task=task):
|
||||
return
|
||||
meta_result = await db.execute(
|
||||
select(ModuleGenerationStep.id, ModuleGenerationStep.project_id).where(
|
||||
ModuleGenerationStep.chat_task_id == task.id,
|
||||
|
||||
@@ -1527,7 +1527,7 @@ async def optimize_hot_opening_video_prompt(
|
||||
target_project_name: str,
|
||||
core_content_point: str,
|
||||
material_video_url: str,
|
||||
generated_image_url: str,
|
||||
generated_image_url: str | None,
|
||||
video_config: dict[str, Any],
|
||||
target_platform: str = "抖音",
|
||||
schema_config_snapshot: Any | None = None,
|
||||
@@ -1538,16 +1538,19 @@ async def optimize_hot_opening_video_prompt(
|
||||
) -> tuple[dict[str, Any], str, dict[str, Any]]:
|
||||
duration = int(video_config["duration"])
|
||||
from app.utils.media import media_to_base64, get_llm_media_as_base64
|
||||
if await get_llm_media_as_base64(db):
|
||||
use_base64 = await get_llm_media_as_base64(db)
|
||||
if use_base64:
|
||||
video_url_final = await media_to_base64(material_video_url, "video/mp4")
|
||||
image_url_final = await media_to_base64(generated_image_url, "image/png")
|
||||
else:
|
||||
video_url_final = _build_file_url_or_data_uri(material_video_url)
|
||||
image_url_final = _build_file_url_or_data_uri(generated_image_url)
|
||||
references = [
|
||||
{"type": "video", "url": video_url_final},
|
||||
{"type": "image", "url": image_url_final},
|
||||
]
|
||||
references = [{"type": "video", "url": video_url_final}]
|
||||
if generated_image_url:
|
||||
image_url_final = (
|
||||
await media_to_base64(generated_image_url, "image/png")
|
||||
if use_base64
|
||||
else _build_file_url_or_data_uri(generated_image_url)
|
||||
)
|
||||
references.append({"type": "image", "url": image_url_final})
|
||||
client_schema = build_dynamic_schema(video_config, schema_config_snapshot)
|
||||
reference_video_fps = int(video_config.get("reference_video_fps") or DEFAULT_REFERENCE_VIDEO_FPS)
|
||||
|
||||
@@ -1690,7 +1693,7 @@ async def optimize_hot_opening_video_prompt(
|
||||
"input_tokens": int(usage.get("prompt_tokens") or 0),
|
||||
"output_tokens": int(usage.get("completion_tokens") or 0),
|
||||
"total_tokens": int(usage.get("total_tokens") or 0),
|
||||
"log_user_message": log_user_message,
|
||||
# "log_user_message": log_user_message,
|
||||
}
|
||||
token_usage_id = generate_id()
|
||||
db.add(
|
||||
|
||||
@@ -17,6 +17,7 @@ from app.enums.shot_replicate import (
|
||||
ShotSegmentAnalysisStatusEnum,
|
||||
ShotSplitStatusEnum,
|
||||
)
|
||||
from app.models.module_generation_project import ModuleGenerationProject
|
||||
from app.models.module_generation_step import ModuleGenerationStep
|
||||
from app.models.shot_replicate_segment import ShotReplicateSegment
|
||||
from app.models.shot_replicate_task_set import ShotReplicateTaskSet
|
||||
@@ -47,6 +48,7 @@ TASK_HOT_IMAGE_PROMPT = "hot_opening.start_image_prompt_optimize"
|
||||
TASK_HOT_VIDEO_PROMPT = "hot_opening.start_video_prompt_optimize"
|
||||
TASK_SHOT_IMAGE_PROMPT = "shot_replicate.start_image_prompt_optimize"
|
||||
TASK_SHOT_VIDEO_PROMPT = "shot_replicate.start_video_prompt_optimize"
|
||||
TASK_MODULE_V2_VIDEO_PROMPT = "module_generation_v2.start_video_prompt_optimize"
|
||||
TASK_SHOT_ANALYZE_ORIGINAL = "shot_replicate.analyze_original_video"
|
||||
TASK_SHOT_ANALYZE_CUSTOM_SEGMENT = "shot_replicate.analyze_custom_segment_video"
|
||||
TASK_SHOT_SPLIT_ONE = "shot_replicate.split_one_segment"
|
||||
@@ -511,9 +513,24 @@ async def _recover_stale_module_steps(db: AsyncSession, *, limit: int) -> dict[s
|
||||
.with_for_update(skip_locked=True)
|
||||
)
|
||||
steps = list(result.scalars().all())
|
||||
project_flow_map: dict[str, str] = {}
|
||||
project_ids = list({step.project_id for step in steps})
|
||||
if project_ids:
|
||||
project_result = await db.execute(
|
||||
select(ModuleGenerationProject.id, ModuleGenerationProject.flow_version).where(
|
||||
ModuleGenerationProject.id.in_(project_ids),
|
||||
ModuleGenerationProject.deleted_at.is_(None),
|
||||
)
|
||||
)
|
||||
project_flow_map = {
|
||||
str(project_id): str(flow_version or "v1")
|
||||
for project_id, flow_version in project_result.all()
|
||||
}
|
||||
results: dict[str, int] = {}
|
||||
for step in steps:
|
||||
if step.module == HOT_MODULE and step.step_code == HotOpeningStepCodeEnum.IMAGE_PROMPT_OPTIMIZE.value:
|
||||
if project_flow_map.get(step.project_id, "v1") == "v2" and step.step_code == HotOpeningStepCodeEnum.VIDEO_PROMPT_OPTIMIZE.value:
|
||||
task_name = TASK_MODULE_V2_VIDEO_PROMPT
|
||||
elif step.module == HOT_MODULE and step.step_code == HotOpeningStepCodeEnum.IMAGE_PROMPT_OPTIMIZE.value:
|
||||
task_name = TASK_HOT_IMAGE_PROMPT
|
||||
elif step.module == HOT_MODULE and step.step_code == HotOpeningStepCodeEnum.VIDEO_PROMPT_OPTIMIZE.value:
|
||||
task_name = TASK_HOT_VIDEO_PROMPT
|
||||
|
||||
@@ -59,6 +59,18 @@ async def get_project_for_user(
|
||||
project = result.scalar_one_or_none()
|
||||
if not project:
|
||||
raise HTTPException(status_code=404, detail=config.project_not_found_message)
|
||||
if config.expected_flow_version:
|
||||
actual_flow_version = str(getattr(project, "flow_version", None) or "v1")
|
||||
if actual_flow_version != config.expected_flow_version:
|
||||
raise HTTPException(
|
||||
status_code=409,
|
||||
detail={
|
||||
"message": "项目流程版本与当前接口版本不匹配",
|
||||
"project_id": project.id,
|
||||
"flow_version": actual_flow_version,
|
||||
"expected_flow_version": config.expected_flow_version,
|
||||
},
|
||||
)
|
||||
return project
|
||||
|
||||
|
||||
|
||||
@@ -81,7 +81,19 @@ def build_step_output(
|
||||
|
||||
|
||||
def is_wrapped_step_io(value: Any, *, schema_version: str = STEP_IO_SCHEMA_VERSION) -> bool:
|
||||
return isinstance(value, dict) and value.get("schema_version") == schema_version
|
||||
if not isinstance(value, dict):
|
||||
return False
|
||||
current = str(value.get("schema_version") or "")
|
||||
if current == schema_version:
|
||||
return True
|
||||
# V1/V2 查询接口共用同一组解析器。只要结构符合统一步骤 IO 包装,
|
||||
# 即按包装结构解析,避免 V2 被当成普通 payload。
|
||||
return bool(
|
||||
current
|
||||
and value.get("step_code")
|
||||
and "payload" in value
|
||||
and ("status" in value or "source" in value)
|
||||
)
|
||||
|
||||
|
||||
def step_payload(value: Any, *, schema_version: str = STEP_IO_SCHEMA_VERSION) -> dict[str, Any]:
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
"""爆款复刻/拆镜复刻 V2 三步骤公共流程。"""
|
||||
@@ -0,0 +1,75 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
from app.enums.hot_opening_replicate import (
|
||||
HotOpeningGenerationModeEnum,
|
||||
HotOpeningStepIOSchemaVersionEnum,
|
||||
ModuleCodeEnum as HotModuleCodeEnum,
|
||||
)
|
||||
from app.enums.shot_replicate import (
|
||||
ModuleCodeEnum as ShotModuleCodeEnum,
|
||||
ShotReplicateGenerationModeEnum,
|
||||
ShotReplicateStepIOSchemaVersionEnum,
|
||||
)
|
||||
from app.enums.module_generation_flow import ModuleGenerationFlowConfig
|
||||
|
||||
MATERIAL_INPUT = "material_input"
|
||||
VIDEO_PROMPT_OPTIMIZE = "video_prompt_optimize"
|
||||
VIDEO_GENERATE = "video_generate"
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class ModuleGenerationV2Config:
|
||||
module: str
|
||||
generation_mode: str
|
||||
io_schema_version: str
|
||||
display_name: str
|
||||
project_not_found_message: str
|
||||
material_video_locked: bool
|
||||
|
||||
@property
|
||||
def flow_config(self) -> ModuleGenerationFlowConfig:
|
||||
return ModuleGenerationFlowConfig(
|
||||
module=self.module,
|
||||
step_index_map={
|
||||
MATERIAL_INPUT: 1,
|
||||
VIDEO_PROMPT_OPTIMIZE: 2,
|
||||
VIDEO_GENERATE: 3,
|
||||
},
|
||||
material_step_code=MATERIAL_INPUT,
|
||||
image_prompt_step_code="__v2_no_image_prompt__",
|
||||
image_generate_step_code="__v2_no_image_generate__",
|
||||
video_prompt_step_code=VIDEO_PROMPT_OPTIMIZE,
|
||||
video_generate_step_code=VIDEO_GENERATE,
|
||||
project_not_found_message=self.project_not_found_message,
|
||||
step_not_found_message="V2 子任务不存在",
|
||||
cancel_chat_task_error_message=f"{self.display_name}步骤被重建或删除,旧生成任务已取消",
|
||||
material_video_url_editable=not self.material_video_locked,
|
||||
step_io_schema_version=self.io_schema_version,
|
||||
expected_flow_version="v2",
|
||||
)
|
||||
|
||||
|
||||
HOT_OPENING_V2 = ModuleGenerationV2Config(
|
||||
module=HotModuleCodeEnum.HOT_OPENING_REPLICATE.value,
|
||||
generation_mode=HotOpeningGenerationModeEnum.HOT_OPENING_REPLICATE.value,
|
||||
io_schema_version=HotOpeningStepIOSchemaVersionEnum.V2.value,
|
||||
display_name="爆款开头复刻",
|
||||
project_not_found_message="爆款开头复刻 V2 项目不存在",
|
||||
material_video_locked=True,
|
||||
)
|
||||
|
||||
SHOT_REPLICATE_V2 = ModuleGenerationV2Config(
|
||||
module=ShotModuleCodeEnum.SHOT_REPLICATE.value,
|
||||
generation_mode=ShotReplicateGenerationModeEnum.SHOT_REPLICATE.value,
|
||||
io_schema_version=ShotReplicateStepIOSchemaVersionEnum.V2.value,
|
||||
display_name="拆镜复刻",
|
||||
project_not_found_message="拆镜复刻 V2 项目不存在",
|
||||
material_video_locked=True,
|
||||
)
|
||||
|
||||
CONFIG_BY_MODULE = {
|
||||
HOT_OPENING_V2.module: HOT_OPENING_V2,
|
||||
SHOT_REPLICATE_V2.module: SHOT_REPLICATE_V2,
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
from fastapi import HTTPException
|
||||
|
||||
from app.enums.celery_queue import CeleryQueue
|
||||
from app.enums.common import ModuleEventTypeEnum
|
||||
from app.services.module_async_recovery_service import (
|
||||
TASK_MODULE_V2_VIDEO_PROMPT,
|
||||
register_module_step_task,
|
||||
)
|
||||
from app.services.module_generation_log_service import log_module_error, log_module_event_file
|
||||
from app.services.module_generation_v2.config import VIDEO_PROMPT_OPTIMIZE, ModuleGenerationV2Config
|
||||
from app.tasks.celery_app import celery_app
|
||||
from app.tasks.module_generation_v2_tasks import start_video_prompt_optimize_v2
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class VideoPromptDispatchResult:
|
||||
registry_success: bool
|
||||
celery_success: bool
|
||||
registry_error: str | None = None
|
||||
celery_error: str | None = None
|
||||
|
||||
@property
|
||||
def recoverable(self) -> bool:
|
||||
return self.registry_success or self.celery_success
|
||||
|
||||
|
||||
def ensure_v2_celery_enabled() -> None:
|
||||
if celery_app is None:
|
||||
raise HTTPException(status_code=503, detail="Celery未启用")
|
||||
|
||||
|
||||
async def dispatch_video_prompt_v2(
|
||||
*,
|
||||
config: ModuleGenerationV2Config,
|
||||
project_id: str,
|
||||
step_id: str,
|
||||
) -> VideoPromptDispatchResult:
|
||||
"""注册并投递 V2 视频提词任务。
|
||||
|
||||
Redis 注册成功但 Celery 直投失败时,由周期恢复任务补投;Celery 成功但
|
||||
Redis 注册失败时任务仍可正常执行。只有两个通道都失败时由 API 补偿落库为失败。
|
||||
"""
|
||||
registry_error: Exception | None = None
|
||||
try:
|
||||
await register_module_step_task(
|
||||
module=config.module,
|
||||
project_id=project_id,
|
||||
step_id=step_id,
|
||||
step_code=VIDEO_PROMPT_OPTIMIZE,
|
||||
task_name=TASK_MODULE_V2_VIDEO_PROMPT,
|
||||
)
|
||||
except Exception as exc:
|
||||
registry_error = exc
|
||||
log_module_error(
|
||||
module=config.module,
|
||||
event_type=ModuleEventTypeEnum.V2_VIDEO_PROMPT_REGISTRY_FAILED.value,
|
||||
project_id=project_id,
|
||||
step_id=step_id,
|
||||
message="V2 视频提词 Redis 活跃注册失败,将继续尝试 Celery 直投",
|
||||
exc=exc,
|
||||
)
|
||||
|
||||
celery_error: Exception | None = None
|
||||
try:
|
||||
start_video_prompt_optimize_v2.apply_async(
|
||||
args=[project_id, step_id],
|
||||
queue=CeleryQueue.GEN_CHATAPI_CREATE.value,
|
||||
countdown=0,
|
||||
task_id=f"module-v2-video-prompt:{step_id}",
|
||||
)
|
||||
except Exception as exc:
|
||||
celery_error = exc
|
||||
log_module_error(
|
||||
module=config.module,
|
||||
event_type=ModuleEventTypeEnum.V2_VIDEO_PROMPT_DISPATCH_FAILED.value,
|
||||
project_id=project_id,
|
||||
step_id=step_id,
|
||||
message="V2 视频提词 Celery 投递失败",
|
||||
detail={"redis_registry_available": registry_error is None},
|
||||
exc=exc,
|
||||
)
|
||||
|
||||
result = VideoPromptDispatchResult(
|
||||
registry_success=registry_error is None,
|
||||
celery_success=celery_error is None,
|
||||
registry_error=str(registry_error) if registry_error else None,
|
||||
celery_error=str(celery_error) if celery_error else None,
|
||||
)
|
||||
if result.celery_success:
|
||||
log_module_event_file(
|
||||
module=config.module,
|
||||
event_type=ModuleEventTypeEnum.V2_VIDEO_PROMPT_DISPATCHED.value,
|
||||
project_id=project_id,
|
||||
step_id=step_id,
|
||||
message="V2 视频提词任务已投递",
|
||||
detail={
|
||||
"queue": CeleryQueue.GEN_CHATAPI_CREATE.value,
|
||||
"redis_registry_available": result.registry_success,
|
||||
},
|
||||
)
|
||||
return result
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,35 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from fastapi import HTTPException
|
||||
|
||||
from app.services.module_generation_step_common_service import build_file_url_or_data_uri
|
||||
|
||||
|
||||
def build_v2_video_generation_references(material_payload: dict[str, Any]) -> list[dict[str, Any]]:
|
||||
"""V2 最终视频只能携带可选素材图片,绝不携带素材视频/音频。"""
|
||||
image_url = str(material_payload.get("material_image_url") or "").strip()
|
||||
if not image_url:
|
||||
return []
|
||||
return [
|
||||
{
|
||||
"type": "image",
|
||||
"url": build_file_url_or_data_uri(image_url),
|
||||
"name": "素材参考图片",
|
||||
"upload_resource_id": material_payload.get("material_image_resource_id"),
|
||||
"role": "reference_image",
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
def assert_v2_video_generation_references(references: list[dict[str, Any]]) -> None:
|
||||
image_count = 0
|
||||
for item in references:
|
||||
media_type = str(item.get("type") or item.get("media_type") or "").lower()
|
||||
if media_type == "image":
|
||||
image_count += 1
|
||||
continue
|
||||
raise HTTPException(status_code=400, detail="V2 视频生成只允许携带一张素材图片,禁止视频或音频附件")
|
||||
if image_count > 1:
|
||||
raise HTTPException(status_code=400, detail="V2 视频生成最多携带一张素材图片")
|
||||
@@ -19,6 +19,7 @@ from app.enums.recent_generation import (
|
||||
)
|
||||
from app.models.chat_generation_task import ChatGenerationTask
|
||||
from app.models.generation_record import GenerationRecord
|
||||
from app.models.module_generation_project import ModuleGenerationProject
|
||||
from app.models.module_generation_step import ModuleGenerationStep
|
||||
from app.models.shot_replicate_segment import ShotReplicateSegment
|
||||
from app.schemas.recent_generation import RecentGenerationGroupOut, RecentGenerationItemOut
|
||||
@@ -30,6 +31,7 @@ MAX_RECENT_GENERATION_LIMIT = 100
|
||||
|
||||
class _StepLinkInfo(TypedDict):
|
||||
module_project_id: str | None
|
||||
module_project_flow_version: str | None
|
||||
module_step_id: str | None
|
||||
module: str | None
|
||||
|
||||
@@ -115,6 +117,7 @@ def _build_item(
|
||||
module=module,
|
||||
shot_task_set_id=shot_info["shot_task_set_id"] if shot_info else None,
|
||||
shot_segment_id=shot_info["shot_segment_id"] if shot_info else None,
|
||||
module_project_flow_version=step_info["module_project_flow_version"] if step_info else None,
|
||||
module_project_id=step_info["module_project_id"] if step_info else None,
|
||||
module_step_id=step_info["module_step_id"] if step_info else None,
|
||||
generation_id=generation_id,
|
||||
@@ -248,11 +251,14 @@ async def _load_step_link_map(
|
||||
ModuleGenerationStep.id.label("module_step_id"),
|
||||
ModuleGenerationStep.project_id.label("module_project_id"),
|
||||
ModuleGenerationStep.module.label("module"),
|
||||
ModuleGenerationProject.flow_version.label("module_project_flow_version"),
|
||||
ModuleGenerationStep.is_current.label("is_current"),
|
||||
ModuleGenerationStep.updated_at.label("updated_at"),
|
||||
)
|
||||
.join(ModuleGenerationProject, ModuleGenerationProject.id == ModuleGenerationStep.project_id)
|
||||
.where(
|
||||
ModuleGenerationStep.deleted_at.is_(None),
|
||||
ModuleGenerationProject.deleted_at.is_(None),
|
||||
ModuleGenerationStep.chat_task_id.in_(chat_task_ids),
|
||||
ModuleGenerationStep.module.in_(
|
||||
[
|
||||
@@ -276,6 +282,7 @@ async def _load_step_link_map(
|
||||
continue
|
||||
link_map[chat_task_id] = {
|
||||
"module_project_id": row["module_project_id"],
|
||||
"module_project_flow_version": str(row["module_project_flow_version"] or "v1"),
|
||||
"module_step_id": row["module_step_id"],
|
||||
"module": row["module"],
|
||||
}
|
||||
|
||||
@@ -125,6 +125,7 @@ FLOW_CONFIG = ModuleGenerationFlowConfig(
|
||||
cancel_chat_task_error_message="拆镜复刻步骤被重新生成或删除,旧生成任务已取消",
|
||||
material_video_url_editable=False,
|
||||
step_io_schema_version=STEP_IO_SCHEMA_VERSION,
|
||||
expected_flow_version="v1",
|
||||
)
|
||||
|
||||
|
||||
@@ -434,12 +435,19 @@ async def project_to_detail_out(db: AsyncSession, project: ModuleGenerationProje
|
||||
user_result = await db.execute(select(User.username).where(User.id == project.user_id).limit(1))
|
||||
user_name = user_result.scalar_one_or_none()
|
||||
|
||||
flow_version = str(getattr(project, "flow_version", None) or "v1")
|
||||
video_prompt_config = dict(video_prompt_input.get("video_config") or {})
|
||||
video_prompt_engine_snapshot = dict(video_prompt_config.get("engine_snapshot") or {})
|
||||
|
||||
return ShotReplicateTaskDetailOut(
|
||||
id=project.id,
|
||||
project_id=project.id,
|
||||
user_id=project.user_id,
|
||||
user_name=user_name,
|
||||
module=project.module,
|
||||
flow_version=flow_version,
|
||||
step_count=3 if flow_version == "v2" else 5,
|
||||
step_io_schema_version=("hot_opening_step_io_v2" if project.module == "hot_opening_replicate" else "shot_replicate_step_io_v2") if flow_version == "v2" else STEP_IO_SCHEMA_VERSION,
|
||||
title=project.title,
|
||||
status=project.status,
|
||||
current_step_code=project.current_step_code,
|
||||
@@ -454,6 +462,8 @@ async def project_to_detail_out(db: AsyncSession, project: ModuleGenerationProje
|
||||
source_project_name=material_input.get("source_project_name"),
|
||||
target_project_name=material_input.get("target_project_name"),
|
||||
core_content_point=material_input.get("core_content_point"),
|
||||
project_description=material_input.get("project_description"),
|
||||
video_config=None if flow_version == "v2" else material_input.get("video_config"),
|
||||
),
|
||||
image_generation=ShotReplicateImageGenerationOut(
|
||||
prompt_step_id=image_prompt_step.id if image_prompt_step else None,
|
||||
@@ -477,9 +487,9 @@ async def project_to_detail_out(db: AsyncSession, project: ModuleGenerationProje
|
||||
schema_config_source=schema_config_source,
|
||||
schema_config_version=schema_config_version,
|
||||
schema_config_is_fallback=schema_config_is_fallback,
|
||||
engine_id=video_snapshot.get("id") or video_generate_input.get("engine_id"),
|
||||
engine_name=video_snapshot.get("name") or video_generate_input.get("engine_name"),
|
||||
params=video_generate_input.get("params") or video_generate_input,
|
||||
engine_id=video_snapshot.get("id") or video_generate_input.get("engine_id") or video_prompt_config.get("engine_id"),
|
||||
engine_name=video_snapshot.get("name") or video_generate_input.get("engine_name") or video_prompt_engine_snapshot.get("name"),
|
||||
params=video_generate_input.get("params") or video_prompt_config or video_generate_input,
|
||||
chat_task_id=video_generate_step.chat_task_id if video_generate_step else None,
|
||||
status=video_chat.status if video_chat else (video_generate_step.status if video_generate_step else None),
|
||||
result_video_url=build_resource_signed_url(video_url) if video_url else None,
|
||||
@@ -606,6 +616,8 @@ async def list_shot_replicate_projects(
|
||||
id=project.id,
|
||||
project_id=project.id,
|
||||
module=project.module,
|
||||
flow_version=str(getattr(project, "flow_version", None) or "v1"),
|
||||
step_count=3 if str(getattr(project, "flow_version", None) or "v1") == "v2" else 5,
|
||||
title=project.title,
|
||||
status=project.status,
|
||||
current_step_code=project.current_step_code,
|
||||
@@ -1497,6 +1509,9 @@ async def generate_video_from_prompt(
|
||||
async def handle_chat_generation_task_completed(db: AsyncSession, task: ChatGenerationTask) -> None:
|
||||
if not task or task.generation_mode != GENERATION_MODE:
|
||||
return
|
||||
from app.services.module_generation_v2.flow_service import handle_chat_generation_task_finished_v2
|
||||
if await handle_chat_generation_task_finished_v2(db, task=task):
|
||||
return
|
||||
meta_result = await db.execute(
|
||||
select(ModuleGenerationStep.id, ModuleGenerationStep.project_id).where(
|
||||
ModuleGenerationStep.chat_task_id == task.id,
|
||||
@@ -1579,6 +1594,9 @@ async def handle_chat_generation_task_completed(db: AsyncSession, task: ChatGene
|
||||
async def handle_chat_generation_task_failed(db: AsyncSession, task: ChatGenerationTask) -> None:
|
||||
if not task or task.generation_mode != GENERATION_MODE:
|
||||
return
|
||||
from app.services.module_generation_v2.flow_service import handle_chat_generation_task_finished_v2
|
||||
if await handle_chat_generation_task_finished_v2(db, task=task):
|
||||
return
|
||||
meta_result = await db.execute(
|
||||
select(ModuleGenerationStep.id, ModuleGenerationStep.project_id).where(
|
||||
ModuleGenerationStep.chat_task_id == task.id,
|
||||
|
||||
@@ -32,6 +32,7 @@ from app.schemas.shot_replicate import (
|
||||
ShotSegmentListOut,
|
||||
ShotSegmentSplitRetryOut,
|
||||
ShotReanalyzeOut,
|
||||
ShotReplicateDeleteOut,
|
||||
ShotSegmentOut,
|
||||
ShotSplitByAIOut,
|
||||
ShotSplitByAIRequest,
|
||||
@@ -116,6 +117,7 @@ def _segment_to_out(segment: ShotReplicateSegment, project: ModuleGenerationProj
|
||||
data.module_project_title = project.title
|
||||
data.module_project_status = project.status
|
||||
data.module_project_current_step_code = project.current_step_code
|
||||
data.module_project_flow_version = str(getattr(project, "flow_version", None) or "v1")
|
||||
return data
|
||||
|
||||
|
||||
@@ -126,6 +128,7 @@ def _segment_to_detail_out(segment: ShotReplicateSegment, project: ModuleGenerat
|
||||
data.module_project_title = project.title
|
||||
data.module_project_status = project.status
|
||||
data.module_project_current_step_code = project.current_step_code
|
||||
data.module_project_flow_version = str(getattr(project, "flow_version", None) or "v1")
|
||||
return data
|
||||
|
||||
|
||||
@@ -716,6 +719,44 @@ async def segment_detail(db: AsyncSession, *, current_user: User, segment_id: st
|
||||
project = project_result.scalar_one_or_none()
|
||||
return _segment_to_detail_out(segment, project)
|
||||
|
||||
async def _delete_linked_replication_project(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
current_user: User,
|
||||
project_id: str,
|
||||
) -> ShotReplicateDeleteOut:
|
||||
"""按项目保存的流程版本分发 V1/V2 删除,供片段和任务集联动删除复用。"""
|
||||
result = await db.execute(
|
||||
select(ModuleGenerationProject)
|
||||
.where(
|
||||
ModuleGenerationProject.id == project_id,
|
||||
ModuleGenerationProject.deleted_at.is_(None),
|
||||
)
|
||||
.limit(1)
|
||||
)
|
||||
project = result.scalar_one_or_none()
|
||||
if project and str(getattr(project, "flow_version", None) or "v1") == "v2":
|
||||
from app.services.module_generation_v2.config import SHOT_REPLICATE_V2
|
||||
from app.services.module_generation_v2.flow_service import delete_project_v2
|
||||
|
||||
payload = await delete_project_v2(
|
||||
db,
|
||||
config=SHOT_REPLICATE_V2,
|
||||
current_user=current_user,
|
||||
project_id=project_id,
|
||||
)
|
||||
return ShotReplicateDeleteOut(**payload)
|
||||
|
||||
from app.services.shot_replicate_flow_service import delete_shot_replicate_project
|
||||
|
||||
return await delete_shot_replicate_project(
|
||||
db,
|
||||
current_user=current_user,
|
||||
project_id=project_id,
|
||||
refund_unfinished=False,
|
||||
)
|
||||
|
||||
|
||||
async def delete_segment(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
@@ -775,13 +816,10 @@ async def delete_segment(
|
||||
|
||||
deleted_module_project_id: str | None = None
|
||||
if module_project_id:
|
||||
from app.services.shot_replicate_flow_service import delete_shot_replicate_project
|
||||
|
||||
project_delete_out = await delete_shot_replicate_project(
|
||||
project_delete_out = await _delete_linked_replication_project(
|
||||
db,
|
||||
current_user=current_user,
|
||||
project_id=module_project_id,
|
||||
refund_unfinished=False,
|
||||
)
|
||||
deleted_module_project_id = project_delete_out.project_id
|
||||
released_size_bytes += int(project_delete_out.released_size_bytes or 0)
|
||||
@@ -894,15 +932,12 @@ async def delete_task_set(
|
||||
upload_resource_released += int(segment_upload_release.get("released") or 0)
|
||||
pending_delete_resource_ids.extend(segment_upload_release.get("released_resource_ids") or [])
|
||||
|
||||
from app.services.shot_replicate_flow_service import delete_shot_replicate_project
|
||||
|
||||
deleted_module_project_count = 0
|
||||
for module_project_id in dict.fromkeys(module_project_ids):
|
||||
project_delete_out = await delete_shot_replicate_project(
|
||||
project_delete_out = await _delete_linked_replication_project(
|
||||
db,
|
||||
current_user=current_user,
|
||||
project_id=module_project_id,
|
||||
refund_unfinished=False,
|
||||
)
|
||||
deleted_module_project_count += 1
|
||||
released_size_bytes += int(project_delete_out.released_size_bytes or 0)
|
||||
|
||||
@@ -23,6 +23,7 @@ CELERY_TASK_IMPORTS = (
|
||||
"app.tasks.shot_replicate_tasks",
|
||||
"app.tasks.shot_replicate_flow_tasks",
|
||||
"app.tasks.module_async_recovery_tasks",
|
||||
"app.tasks.module_generation_v2_tasks",
|
||||
"app.tasks.user_oauth_tasks",
|
||||
"app.tasks.cleanup",
|
||||
"app.tasks.private_portrait_asset_tasks",
|
||||
@@ -53,6 +54,22 @@ def _beat_schedule() -> dict:
|
||||
"priority": settings.DOWNLOAD_TASK_PRIORITY_RECOVER,
|
||||
},
|
||||
}
|
||||
schedule["generation-create-recovery"] = {
|
||||
"task": CeleryTaskName.RECOVER_CREATE.value,
|
||||
"schedule": max(1, int(settings.GENERATION_CREATE_RECOVERY_INTERVAL_SECONDS or 60)),
|
||||
"options": {
|
||||
"queue": RECOVERY_QUEUE,
|
||||
"priority": settings.DOWNLOAD_TASK_PRIORITY_RECOVER,
|
||||
},
|
||||
}
|
||||
schedule["module-async-recovery"] = {
|
||||
"task": CeleryTaskName.MODULE_ASYNC_RECOVERY.value,
|
||||
"schedule": max(1, int(settings.MODULE_ASYNC_RECOVERY_INTERVAL_SECONDS or 60)),
|
||||
"options": {
|
||||
"queue": RECOVERY_QUEUE,
|
||||
"priority": settings.DOWNLOAD_TASK_PRIORITY_RECOVER,
|
||||
},
|
||||
}
|
||||
schedule["video-upscale-recovery-every-minute"] = {
|
||||
"task": CeleryTaskName.VIDEO_UPSCALE_RECOVER.value,
|
||||
"schedule": 60,
|
||||
@@ -109,6 +126,7 @@ if broker_url:
|
||||
"shot_replicate.split_one_segment": {"ignore_result": True},
|
||||
"shot_replicate.start_image_prompt_optimize": {"ignore_result": True},
|
||||
"shot_replicate.start_video_prompt_optimize": {"ignore_result": True},
|
||||
"module_generation_v2.start_video_prompt_optimize": {"ignore_result": True},
|
||||
CeleryTaskName.VIDEO_UPSCALE_EXECUTE_LOCAL.value: {
|
||||
"ignore_result": True,
|
||||
"soft_time_limit": max(60, int(settings.VIDEO_UPSCALE_LOCAL_TIMEOUT_SECONDS or 3600)) + 60,
|
||||
@@ -119,6 +137,8 @@ if broker_url:
|
||||
CeleryTaskName.VIDEO_UPSCALE_DOWNLOAD_REMOTE_RESULT.value: {"ignore_result": True},
|
||||
CeleryTaskName.VIDEO_UPSCALE_FINALIZE.value: {"ignore_result": True},
|
||||
CeleryTaskName.VIDEO_UPSCALE_RECOVER.value: {"ignore_result": True},
|
||||
CeleryTaskName.RECOVER_CREATE.value: {"ignore_result": True},
|
||||
CeleryTaskName.MODULE_ASYNC_RECOVERY.value: {"ignore_result": True},
|
||||
},
|
||||
worker_prefetch_multiplier=1,
|
||||
broker_transport_options={
|
||||
@@ -159,11 +179,13 @@ if broker_url:
|
||||
"shot_replicate.split_one_segment": {"queue": CeleryQueue.GEN_RESULT_DOWNLOAD.value},
|
||||
"shot_replicate.start_image_prompt_optimize": {"queue": CeleryQueue.GEN_CHATAPI_CREATE.value},
|
||||
"shot_replicate.start_video_prompt_optimize": {"queue": CeleryQueue.GEN_CHATAPI_CREATE.value},
|
||||
"module_generation_v2.start_video_prompt_optimize": {"queue": CeleryQueue.GEN_CHATAPI_CREATE.value},
|
||||
# 恢复扫描统一走独立队列,避免占用下载/轮询/创建业务 worker。
|
||||
CeleryTaskName.STARTUP_RECOVERY.value: {"queue": RECOVERY_QUEUE},
|
||||
CeleryTaskName.SHOT_SPLIT_RECOVERY.value: {"queue": RECOVERY_QUEUE},
|
||||
CeleryTaskName.RECOVER_DOWNLOAD.value: {"queue": RECOVERY_QUEUE},
|
||||
CeleryTaskName.RECOVER_GENERATION.value: {"queue": RECOVERY_QUEUE},
|
||||
CeleryTaskName.RECOVER_CREATE.value: {"queue": RECOVERY_QUEUE},
|
||||
CeleryTaskName.MODULE_ASYNC_RECOVERY.value: {"queue": RECOVERY_QUEUE},
|
||||
"user_oauth.update_oauth_accounts": {"queue": CeleryQueue.DEFAULT.value},
|
||||
"app.tasks.cleanup.*": {"queue": CeleryQueue.DEFAULT.value},
|
||||
|
||||
@@ -40,6 +40,7 @@ async def _recover_generation_records_once(*, include_create: bool, include_poll
|
||||
args=[ref.owner_id],
|
||||
kwargs={"owner_type": ref.owner_type, "generation_attempt_no": ref.generation_attempt_no},
|
||||
queue=CeleryQueue.GEN_CHATAPI_CREATE.value,
|
||||
task_id=f"generation-create:{ref.owner_type}:{ref.owner_id}:attempt:{ref.generation_attempt_no}",
|
||||
)
|
||||
counts["create"] += 1
|
||||
except Exception as exc:
|
||||
@@ -116,6 +117,17 @@ async def _run_generation_once() -> Dict[str, Any]:
|
||||
return {"chat_generation_task": chat_result, "generation_record": record_result}
|
||||
|
||||
|
||||
async def _run_create_once() -> Dict[str, Any]:
|
||||
from app.services.generation.recovery_service import recover_stale_create_tasks_once
|
||||
|
||||
async with async_session() as db:
|
||||
chat_result = await recover_stale_create_tasks_once(db)
|
||||
record_result = await _recover_generation_records_once(
|
||||
include_create=True, include_poll=False, include_download=False
|
||||
)
|
||||
return {"chat_generation_task": chat_result, "generation_record": record_result}
|
||||
|
||||
|
||||
async def _run_due_poll_dispatch_once() -> Dict[str, Any]:
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
@@ -396,6 +408,23 @@ if celery_app:
|
||||
)
|
||||
|
||||
|
||||
@celery_app.task(
|
||||
name="generation.recover_create_tasks_once",
|
||||
bind=True,
|
||||
soft_time_limit=settings.CELERY_RECOVERY_SOFT_TIME_LIMIT_SECONDS,
|
||||
time_limit=settings.CELERY_RECOVERY_TIME_LIMIT_SECONDS,
|
||||
)
|
||||
def recover_create_tasks_once(self) -> Dict[str, Any]:
|
||||
return run_async(
|
||||
_run_with_execution_lock(
|
||||
lock_key=f"{settings.GENERATION_RECOVERY_LOCK_KEY}:create",
|
||||
log_context="generation_create_recovery",
|
||||
runner=_run_create_once,
|
||||
ttl_seconds=max(55, int(settings.GENERATION_CREATE_RECOVERY_INTERVAL_SECONDS or 60) - 5),
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@celery_app.task(
|
||||
name="generation.dispatch_due_poll_tasks",
|
||||
bind=True,
|
||||
@@ -424,4 +453,5 @@ else:
|
||||
startup_recovery_once = _DisabledTask()
|
||||
recover_download_tasks_once = _DisabledTask()
|
||||
recover_generation_tasks_once = _DisabledTask()
|
||||
recover_create_tasks_once = _DisabledTask()
|
||||
dispatch_due_poll_tasks = _DisabledTask()
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.models.base import async_session
|
||||
from app.models.module_generation_project import ModuleGenerationProject
|
||||
from app.services.module_async_recovery_service import (
|
||||
OBJECT_MODULE_STEP,
|
||||
TASK_MODULE_V2_VIDEO_PROMPT,
|
||||
acquire_object_lock,
|
||||
cleanup_active_if_terminal,
|
||||
mark_active_started,
|
||||
register_module_step_task,
|
||||
release_object_lock,
|
||||
)
|
||||
from app.services.module_generation_v2.config import VIDEO_PROMPT_OPTIMIZE
|
||||
from app.services.module_generation_v2.flow_service import run_video_prompt_optimize_v2
|
||||
from app.tasks.async_runner import run_async
|
||||
from app.tasks.celery_app import celery_app
|
||||
|
||||
|
||||
async def _run_video_prompt(project_id: str, step_id: str) -> None:
|
||||
lock_token = await acquire_object_lock(object_type=OBJECT_MODULE_STEP, object_id=step_id)
|
||||
if not lock_token:
|
||||
return None
|
||||
try:
|
||||
async with async_session() as db:
|
||||
result = await db.execute(
|
||||
select(ModuleGenerationProject.module)
|
||||
.where(
|
||||
ModuleGenerationProject.id == project_id,
|
||||
ModuleGenerationProject.deleted_at.is_(None),
|
||||
)
|
||||
.limit(1)
|
||||
)
|
||||
module = result.scalar_one_or_none()
|
||||
if not module:
|
||||
return None
|
||||
await register_module_step_task(
|
||||
module=str(module),
|
||||
project_id=project_id,
|
||||
step_id=step_id,
|
||||
step_code=VIDEO_PROMPT_OPTIMIZE,
|
||||
task_name=TASK_MODULE_V2_VIDEO_PROMPT,
|
||||
)
|
||||
await mark_active_started(object_type=OBJECT_MODULE_STEP, object_id=step_id)
|
||||
await run_video_prompt_optimize_v2(db, project_id=project_id, step_id=step_id)
|
||||
await cleanup_active_if_terminal(db, object_type=OBJECT_MODULE_STEP, object_id=step_id)
|
||||
finally:
|
||||
await release_object_lock(
|
||||
object_type=OBJECT_MODULE_STEP,
|
||||
object_id=step_id,
|
||||
token=lock_token,
|
||||
)
|
||||
|
||||
|
||||
if celery_app:
|
||||
@celery_app.task(
|
||||
name="module_generation_v2.start_video_prompt_optimize",
|
||||
bind=True,
|
||||
max_retries=3,
|
||||
default_retry_delay=30,
|
||||
ignore_result=True,
|
||||
)
|
||||
def start_video_prompt_optimize_v2(self, project_id: str, step_id: str):
|
||||
try:
|
||||
run_async(_run_video_prompt(project_id, step_id))
|
||||
return None
|
||||
except Exception as exc:
|
||||
raise self.retry(exc=exc) from exc
|
||||
else:
|
||||
class _DisabledTask:
|
||||
def delay(self, *args: Any, **kwargs: Any):
|
||||
raise RuntimeError("Celery is disabled")
|
||||
|
||||
def apply_async(self, *args: Any, **kwargs: Any):
|
||||
raise RuntimeError("Celery is disabled")
|
||||
|
||||
start_video_prompt_optimize_v2 = _DisabledTask()
|
||||
@@ -269,8 +269,11 @@ export async function updateRecordPrompt(recordId: string, optimizedPrompt: stri
|
||||
export async function generateVideo(recordId: string, params: GenerateParams): Promise<GenerationRecord> {
|
||||
if (USE_MOCK) return mock.mockGenerateVideo(recordId);
|
||||
return api.post<GenerationRecord>(`/generation-records/${recordId}/generate`, {
|
||||
engine_id: params.engineId,
|
||||
include_media_references: params.includeMediaReferences ?? false,
|
||||
aspect_ratio: params.aspectRatio,
|
||||
resolution: params.resolution,
|
||||
image_size: params.imageSize,
|
||||
});
|
||||
}
|
||||
// ── Credits ───────────────────────────────────────────────
|
||||
@@ -485,7 +488,7 @@ export async function getAuthorizationList(params: OAuthAppParam): Promise<OAuth
|
||||
|
||||
// 爆款开头复刻
|
||||
export async function generateReplication(params: any): Promise<any> {
|
||||
return api.post('/hot-opening-replications/tasks', params);
|
||||
return api.post('/v2/hot-opening-replications/tasks', params);
|
||||
}
|
||||
// 获取爆款开头复刻任务列表
|
||||
export async function getReplicationList(page: number, page_size: number, keyword?: string): Promise<any[]> {
|
||||
@@ -495,9 +498,37 @@ export async function getReplicationList(page: number, page_size: number, keywor
|
||||
}
|
||||
return api.get(url);
|
||||
}
|
||||
// 获取爆款开头复刻任务详情
|
||||
export async function getReplicationDetail(id: string): Promise<any> {
|
||||
return api.get(`/hot-opening-replications/tasks/${id}`);
|
||||
// 获取爆款开头复刻任务详情。版本必须由列表/创建结果/URL 明确传入,禁止错误降级。
|
||||
export async function getReplicationDetail(id: string, flowVersion: 'v1' | 'v2'): Promise<any> {
|
||||
return flowVersion === 'v2'
|
||||
? api.get(`/v2/hot-opening-replications/tasks/${id}`)
|
||||
: api.get(`/hot-opening-replications/tasks/${id}`);
|
||||
}
|
||||
|
||||
export interface RetryVideoPromptV2Params {
|
||||
video_config: {
|
||||
engine_id: string;
|
||||
duration: number;
|
||||
aspect_ratio: string;
|
||||
resolution: string;
|
||||
};
|
||||
}
|
||||
|
||||
export async function retryHotOpeningVideoPromptV2(
|
||||
projectId: string,
|
||||
stepId: string,
|
||||
params: RetryVideoPromptV2Params,
|
||||
): Promise<any> {
|
||||
return api.post(`/v2/hot-opening-replications/tasks/${projectId}/steps/${stepId}/retry-video-prompt`, params);
|
||||
}
|
||||
|
||||
|
||||
export async function updateHotOpeningVideoPromptSchemaV2(projectId: string, stepId: string, params: { prompt_schema: Record<string, any> }): Promise<any> {
|
||||
return api.put(`/v2/hot-opening-replications/tasks/${projectId}/steps/${stepId}/video-prompt-schema`, params);
|
||||
}
|
||||
|
||||
export async function generateHotOpeningVideoV2(projectId: string, stepId: string): Promise<any> {
|
||||
return api.post(`/v2/hot-opening-replications/tasks/${projectId}/steps/${stepId}/generate-video`);
|
||||
}
|
||||
// 第一步,生成提示词
|
||||
export async function getone(projectId: string, stepId: string): Promise<any> {
|
||||
@@ -645,11 +676,30 @@ export async function Removelist(taskSetId: string): Promise<any> {
|
||||
}
|
||||
// 生成视频
|
||||
export async function removeCreate(recordId: string, params: any): Promise<any> {
|
||||
return api.post(`/shot-replications/segments/${recordId}/replication-projects`, params);
|
||||
return api.post(`/v2/shot-replications/segments/${recordId}/replication-projects`, params);
|
||||
}
|
||||
// 获取爆款开头复刻任务详情
|
||||
export async function removeDetail(id: string): Promise<any> {
|
||||
return api.get(`/shot-replications/projects/${id}`);
|
||||
// 获取拆镜复刻项目详情。版本必须明确传入,禁止任何异常回退 V1。
|
||||
export async function removeDetail(id: string, flowVersion: 'v1' | 'v2'): Promise<any> {
|
||||
return flowVersion === 'v2'
|
||||
? api.get(`/v2/shot-replications/projects/${id}`)
|
||||
: api.get(`/shot-replications/projects/${id}`);
|
||||
}
|
||||
|
||||
export async function retryShotVideoPromptV2(
|
||||
projectId: string,
|
||||
stepId: string,
|
||||
params: RetryVideoPromptV2Params,
|
||||
): Promise<any> {
|
||||
return api.post(`/v2/shot-replications/projects/${projectId}/steps/${stepId}/retry-video-prompt`, params);
|
||||
}
|
||||
|
||||
|
||||
export async function updateShotVideoPromptSchemaV2(projectId: string, stepId: string, params: any): Promise<any> {
|
||||
return api.put(`/v2/shot-replications/projects/${projectId}/steps/${stepId}/video-prompt-schema`, params);
|
||||
}
|
||||
|
||||
export async function generateShotVideoV2(projectId: string, stepId: string): Promise<any> {
|
||||
return api.post(`/v2/shot-replications/projects/${projectId}/steps/${stepId}/generate-video`);
|
||||
}
|
||||
// 第一步,生成提示词
|
||||
export async function removeone(projectId: string, stepId: string): Promise<any> {
|
||||
@@ -697,7 +747,11 @@ export async function deleteShotReplicationProject(taskSetId: string): Promise<v
|
||||
await api.delete(`/shot-replications/task-sets/${taskSetId}`);
|
||||
}
|
||||
// 删除爆款开头复刻任务
|
||||
export async function deleteHotOpeningReplicationTask(taskId: string): Promise<void> {
|
||||
export async function deleteHotOpeningReplicationTask(taskId: string, flowVersion?: string): Promise<void> {
|
||||
if (flowVersion === 'v2') {
|
||||
await api.delete(`/v2/hot-opening-replications/tasks/${taskId}`);
|
||||
return;
|
||||
}
|
||||
await api.delete(`/hot-opening-replications/tasks/${taskId}`);
|
||||
}
|
||||
// 获取地区信息
|
||||
|
||||
@@ -15,6 +15,8 @@ import {
|
||||
Typography,
|
||||
Upload,
|
||||
Image,
|
||||
Select,
|
||||
Switch,
|
||||
} from "antd";
|
||||
import {
|
||||
ArrowLeftOutlined,
|
||||
@@ -635,7 +637,7 @@ const GeneratePage: React.FC = () => {
|
||||
const [expandedGroup, setExpandedGroup] = useState<string | null>(null);
|
||||
// Per-record param selections for history prompt_optimized records
|
||||
const [historyParams, setHistoryParams] = useState<
|
||||
Record<string, { aspectRatio: AspectRatio; resolution: Resolution }>
|
||||
Record<string, { aspectRatio?: AspectRatio; resolution?: Resolution; engineId?: string; includeMediaReferences?: boolean }>
|
||||
>({});
|
||||
|
||||
// Video preview modal
|
||||
@@ -645,6 +647,10 @@ const GeneratePage: React.FC = () => {
|
||||
const [videoDuration, setVideoDuration] = useState(5);
|
||||
const [videoAspectRatio, setVideoAspectRatio] = useState<AspectRatio>("16:9");
|
||||
const [videoResolution, setVideoResolution] = useState<Resolution>("720p");
|
||||
const [videoEngines, setVideoEngines] = useState<any[]>([]);
|
||||
const [imageEngines, setImageEngines] = useState<any[]>([]);
|
||||
const [selectedEngineId, setSelectedEngineId] = useState("");
|
||||
const [includeMediaReferences, setIncludeMediaReferences] = useState(false);
|
||||
const [expandedEngine, setExpandedEngine] = useState<string | null>(null);
|
||||
const [engineOptions, setEngineOptions] = useState<{
|
||||
ratios: string[];
|
||||
@@ -864,29 +870,46 @@ const GeneratePage: React.FC = () => {
|
||||
}, [showImageSettingsModal]);
|
||||
|
||||
|
||||
const calcVideoCredits = (duration: number, resolution: Resolution): any => {
|
||||
const getReferenceUsage = (items?: MediaReference[]) => {
|
||||
const refs = Array.isArray(items) ? items : [];
|
||||
return {
|
||||
inputImageCount: refs.filter((item) => item.type === 'image').length,
|
||||
inputVideoDuration: refs
|
||||
.filter((item) => item.type === 'video')
|
||||
.reduce((sum, item: any) => sum + Number(item.duration || 0), 0),
|
||||
};
|
||||
};
|
||||
|
||||
// console.log(duration,resolution);
|
||||
for (let i = 0; i < creditRatios.length; i++) {
|
||||
const ratio = creditRatios[i];
|
||||
if (ratio.resolution === resolution) {
|
||||
const cfg = ratio;
|
||||
return Math.round((cfg.baseCredits + cfg.perSecondCredits * duration) * cfg.ratio);
|
||||
const calcVideoCredits = (
|
||||
duration: number,
|
||||
resolution: Resolution,
|
||||
engineId: string = selectedEngineId,
|
||||
includeReferences: boolean = includeMediaReferences,
|
||||
referenceItems: MediaReference[] | undefined = currentRecord?.references,
|
||||
): number => {
|
||||
const cfg = creditRatios.find((item: any) =>
|
||||
item.resolution === resolution && (!engineId || item.modelConfigId === engineId),
|
||||
) || creditRatios.find((item: any) => item.resolution === resolution);
|
||||
if (!cfg) return 0;
|
||||
let total = (Number(cfg.baseCredits || 0) + Number(cfg.perSecondCredits || 0) * duration) * Number(cfg.ratio || 1);
|
||||
if (includeReferences) {
|
||||
const usage = getReferenceUsage(referenceItems);
|
||||
if (usage.inputVideoDuration > 0) {
|
||||
total += (Number(cfg.inputVideoBaseCredits || 0) + Number(cfg.inputVideoPerSecondCredits || 0) * usage.inputVideoDuration) * Number(cfg.inputVideoRatio || 1);
|
||||
}
|
||||
if (usage.inputImageCount > 0) {
|
||||
total += (Number(cfg.inputImageBaseCredits || 0) + Number(cfg.inputImagePerImageCredits || 0) * usage.inputImageCount) * Number(cfg.inputImageRatio || 1);
|
||||
}
|
||||
}
|
||||
|
||||
// if (condition) {
|
||||
|
||||
// }
|
||||
|
||||
// return Math.round((cfg.base + cfg.perSecond * duration) * cfg.ratio);
|
||||
return Number(total.toFixed(2));
|
||||
};
|
||||
|
||||
// 根据图片分辨率获取积分
|
||||
const getImageCredits = (imageSize: string): any => {
|
||||
|
||||
// 将 imageSize 转换为 creditRatios 中的 resolution 格式
|
||||
const resolution = imageSize === '2k' ? '2048' : imageSize === '4k' ? '4096' : imageSize;
|
||||
const normalizedSize = String(imageSize || '').toLowerCase();
|
||||
const resolution = normalizedSize === '2k' ? '2048' : normalizedSize === '4k' ? '4096' : imageSize;
|
||||
|
||||
for (let i = 0; i < cimage.length; i++) {
|
||||
const ratio = cimage[i];
|
||||
@@ -1005,7 +1028,9 @@ const GeneratePage: React.FC = () => {
|
||||
|
||||
getParameters()
|
||||
.then((data) => {
|
||||
const sizes = (data as any).items?.[0]?.supportedSizes || {};
|
||||
const imageItems = (data as any).items || [];
|
||||
setImageEngines(imageItems);
|
||||
const sizes = imageItems?.[0]?.supportedSizes || {};
|
||||
setSupportedSizes(sizes);
|
||||
|
||||
const resolutionKeys = Object.keys(sizes);
|
||||
@@ -1037,8 +1062,10 @@ const GeneratePage: React.FC = () => {
|
||||
.catch(() => { });
|
||||
getVideoEngines()
|
||||
.then((data) => {
|
||||
setVideoEngines(data.items || []);
|
||||
if (data.items?.length) {
|
||||
const e = data.items[0];
|
||||
setSelectedEngineId((prev) => prev || e.id);
|
||||
setEngineOptions({
|
||||
ratios: e.supportedRatios?.length
|
||||
? e.supportedRatios
|
||||
@@ -1055,6 +1082,87 @@ const GeneratePage: React.FC = () => {
|
||||
.catch(() => { });
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!currentRecord) return;
|
||||
const type = currentRecord.genType || mediaType;
|
||||
const engines = type === 'image' ? imageEngines : videoEngines;
|
||||
const savedEngineId = currentRecord.engineId || '';
|
||||
const engine = engines.find((item: any) => item.id === savedEngineId) || engines[0];
|
||||
if (engine) {
|
||||
setSelectedEngineId(engine.id);
|
||||
if (type === 'image') {
|
||||
const sizes = engine.supportedSizes || {};
|
||||
setSupportedSizes(sizes);
|
||||
const resolutionKeys = Object.keys(sizes);
|
||||
const nextResolution = resolutionKeys.includes(currentRecord.imageSize || '')
|
||||
? currentRecord.imageSize
|
||||
: resolutionKeys[0] || currentRecord.imageSize || '2K';
|
||||
setSelectedResolution(nextResolution);
|
||||
const ratioKeys = Object.keys(sizes[nextResolution] || {});
|
||||
const nextRatio = ratioKeys.includes(currentRecord.imageProportion || '')
|
||||
? currentRecord.imageProportion
|
||||
: ratioKeys[0] || currentRecord.imageProportion || '1:1';
|
||||
setSelectedRatio(nextRatio);
|
||||
const pixelSize = sizes[nextResolution]?.[nextRatio] || currentRecord.imagePx || '';
|
||||
if (pixelSize) {
|
||||
const [nextWidth, nextHeight] = String(pixelSize).split(/x/i).map(Number);
|
||||
if (nextWidth > 0 && nextHeight > 0) {
|
||||
setWidth(nextWidth);
|
||||
setHeight(nextHeight);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
setEngineOptions({
|
||||
ratios: engine.supportedRatios?.length ? engine.supportedRatios : ['16:9'],
|
||||
resolutions: engine.supportedResolutions?.length ? engine.supportedResolutions : ['720p'],
|
||||
durations: engine.supportedDurations?.length ? engine.supportedDurations : [5],
|
||||
});
|
||||
if (!engine.supportedRatios?.includes(videoAspectRatio)) setVideoAspectRatio((engine.supportedRatios?.[0] || '16:9') as AspectRatio);
|
||||
if (!engine.supportedResolutions?.includes(videoResolution)) setVideoResolution((engine.supportedResolutions?.[0] || '720p') as Resolution);
|
||||
if (!engine.supportedDurations?.includes(videoDuration)) setVideoDuration(engine.supportedDurations?.[0] || currentRecord.duration || 5);
|
||||
}
|
||||
}
|
||||
setIncludeMediaReferences(Boolean(currentRecord.includeMediaReferences));
|
||||
}, [currentRecord?.id, currentRecord?.engineId, videoEngines, imageEngines]);
|
||||
|
||||
const getVideoEngineOptions = (engineId?: string) => {
|
||||
const engine = videoEngines.find((item: any) => item.id === engineId) || videoEngines[0];
|
||||
return {
|
||||
ratios: engine?.supportedRatios?.length ? engine.supportedRatios : ['16:9'],
|
||||
resolutions: engine?.supportedResolutions?.length ? engine.supportedResolutions : ['720p'],
|
||||
durations: engine?.supportedDurations?.length ? engine.supportedDurations : [5],
|
||||
};
|
||||
};
|
||||
|
||||
const handleGenerationEngineChange = (engineId: string) => {
|
||||
setSelectedEngineId(engineId);
|
||||
const engines = mediaType === 'image' ? imageEngines : videoEngines;
|
||||
const engine = engines.find((item: any) => item.id === engineId);
|
||||
if (!engine) return;
|
||||
if (mediaType === 'image') {
|
||||
const sizes = engine.supportedSizes || {};
|
||||
setSupportedSizes(sizes);
|
||||
const resolution = Object.keys(sizes)[0] || '2K';
|
||||
const ratio = Object.keys(sizes[resolution] || {})[0] || '1:1';
|
||||
setSelectedResolution(resolution);
|
||||
setSelectedRatio(ratio);
|
||||
const pixelSize = sizes[resolution]?.[ratio] || '';
|
||||
if (pixelSize) {
|
||||
const [nextWidth, nextHeight] = String(pixelSize).split(/x/i).map(Number);
|
||||
if (nextWidth > 0 && nextHeight > 0) {
|
||||
setWidth(nextWidth);
|
||||
setHeight(nextHeight);
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
const options = getVideoEngineOptions(engineId);
|
||||
setEngineOptions(options);
|
||||
setVideoAspectRatio(options.ratios[0] as AspectRatio);
|
||||
setVideoResolution(options.resolutions[0] as Resolution);
|
||||
setVideoDuration(options.durations[0]);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchProjects();
|
||||
fetchRecords({ projectId, page: 1, pageSize: 100 });
|
||||
@@ -1239,17 +1347,25 @@ const GeneratePage: React.FC = () => {
|
||||
const userCredits = user?.credits ?? 0;
|
||||
|
||||
// 根据图片分辨率从 cimage 获取积分
|
||||
const getImageCreditsFromCimage = (imageSize: string): number => {
|
||||
// 将 imageSize 转换为 cimage 中的 resolution 格式
|
||||
const resolution = imageSize === '2k' ? '2048' : imageSize === '4k' ? '4096' : imageSize;
|
||||
|
||||
for (let i = 0; i < cimage.length; i++) {
|
||||
const item = cimage[i];
|
||||
if (item.resolution === resolution) {
|
||||
return item.baseCredits;
|
||||
const getImageCreditsFromCimage = (
|
||||
imageSize: string,
|
||||
engineId: string = selectedEngineId,
|
||||
includeReferences: boolean = includeMediaReferences,
|
||||
referenceItems: MediaReference[] | undefined = currentRecord?.references,
|
||||
): number => {
|
||||
const normalizedSize = String(imageSize || '').toLowerCase();
|
||||
const resolution = normalizedSize === '2k' ? '2048' : normalizedSize === '4k' ? '4096' : imageSize;
|
||||
const item = cimage.find((rule: any) => rule.resolution === resolution && (!engineId || rule.modelConfigId === engineId))
|
||||
|| cimage.find((rule: any) => rule.resolution === resolution);
|
||||
if (!item) return 0;
|
||||
let total = Number(item.baseCredits || 0) * Number(item.ratio || 1);
|
||||
if (includeReferences) {
|
||||
const inputImageCount = getReferenceUsage(referenceItems).inputImageCount;
|
||||
if (inputImageCount > 0) {
|
||||
total += (Number(item.inputImageBaseCredits || 0) + Number(item.inputImagePerImageCredits || 0) * inputImageCount) * Number(item.inputImageRatio || 1);
|
||||
}
|
||||
}
|
||||
return 0;
|
||||
return Number(total.toFixed(2));
|
||||
};
|
||||
|
||||
// Media credits estimate for step 2 (video or image)
|
||||
@@ -1522,8 +1638,11 @@ const GeneratePage: React.FC = () => {
|
||||
});
|
||||
try {
|
||||
const result = await generateVideo(recordId, {
|
||||
engineId: selectedEngineId || undefined,
|
||||
includeMediaReferences,
|
||||
aspectRatio: videoAspectRatio,
|
||||
resolution: videoResolution,
|
||||
imageSize: currentRecord?.imageSize || selectedResolution,
|
||||
});
|
||||
if (result.status === "failed") {
|
||||
setRecordStates((p) => ({ ...p, [recordId]: "failed" }));
|
||||
@@ -1585,8 +1704,11 @@ const GeneratePage: React.FC = () => {
|
||||
await updateRecordPrompt(recordId, editedPrompt);
|
||||
}
|
||||
const result = await generateVideo(recordId, {
|
||||
engineId: record.engineId || selectedEngineId || undefined,
|
||||
includeMediaReferences: Boolean(record.includeMediaReferences),
|
||||
aspectRatio: record.aspectRatio || "16:9",
|
||||
resolution: record.resolution || "720p",
|
||||
imageSize: record.imageSize,
|
||||
});
|
||||
|
||||
if (result.status === "failed") {
|
||||
@@ -3238,6 +3360,22 @@ const GeneratePage: React.FC = () => {
|
||||
border: "1px solid #f0f0f5",
|
||||
}}
|
||||
>
|
||||
<div style={{ minWidth: 220, marginRight: 16 }}>
|
||||
<Typography.Text style={{ fontSize: 12, color: '#64748b', display: 'block', marginBottom: 6 }}>生成引擎</Typography.Text>
|
||||
<Select
|
||||
value={selectedEngineId || undefined}
|
||||
onChange={handleGenerationEngineChange}
|
||||
style={{ width: '100%' }}
|
||||
options={(mediaType === 'image' ? imageEngines : videoEngines).map((item: any) => ({ value: item.id, label: item.name }))}
|
||||
/>
|
||||
{currentRecord?.references?.length ? (
|
||||
<div style={{ marginTop: 10, display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<Switch size="small" checked={includeMediaReferences} onChange={setIncludeMediaReferences} />
|
||||
<Typography.Text style={{ fontSize: 12, color: '#64748b' }}>携带参考附件生成</Typography.Text>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{/* Video params selection */}
|
||||
{mediaType !== "image" && (
|
||||
<div
|
||||
@@ -4326,6 +4464,29 @@ const GeneratePage: React.FC = () => {
|
||||
backgroundColor: "transparent",
|
||||
}}
|
||||
>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 12, flexWrap: 'wrap' }}>
|
||||
<Select
|
||||
size="small"
|
||||
value={historyParams[record.id]?.engineId || record.engineId || (type === 'image' ? imageEngines[0]?.id : videoEngines[0]?.id)}
|
||||
onChange={(value) => {
|
||||
const options = type === 'video' ? getVideoEngineOptions(value) : null;
|
||||
setHistoryParams((prev) => ({
|
||||
...prev,
|
||||
[record.id]: {
|
||||
...prev[record.id],
|
||||
engineId: value,
|
||||
aspectRatio: type === 'video' ? options!.ratios[0] as AspectRatio : prev[record.id]?.aspectRatio,
|
||||
resolution: type === 'video' ? options!.resolutions[0] as Resolution : prev[record.id]?.resolution,
|
||||
},
|
||||
}));
|
||||
}}
|
||||
options={(type === 'image' ? imageEngines : videoEngines).map((item: any) => ({ value: item.id, label: item.name }))}
|
||||
style={{ minWidth: 160 }}
|
||||
/>
|
||||
{record.references?.length ? (
|
||||
<><Switch size="small" checked={Boolean(historyParams[record.id]?.includeMediaReferences ?? record.includeMediaReferences)} onChange={(checked) => setHistoryParams((prev) => ({ ...prev, [record.id]: { ...prev[record.id], includeMediaReferences: checked, aspectRatio: prev[record.id]?.aspectRatio || '' as AspectRatio, resolution: prev[record.id]?.resolution || '' as Resolution } }))} /><Typography.Text style={{ fontSize: 12 }}>携带附件</Typography.Text></>
|
||||
) : null}
|
||||
</div>
|
||||
{type === "video" && (
|
||||
<div
|
||||
style={{
|
||||
@@ -4367,7 +4528,7 @@ const GeneratePage: React.FC = () => {
|
||||
historyParams[record.id]?.aspectRatio ||
|
||||
"选择比例"
|
||||
}
|
||||
options={engineOptions.ratios}
|
||||
options={getVideoEngineOptions(historyParams[record.id]?.engineId || record.engineId || videoEngines[0]?.id).ratios}
|
||||
expanded={
|
||||
expandedEngine === `ratio-${record.id}`
|
||||
}
|
||||
@@ -4397,7 +4558,7 @@ const GeneratePage: React.FC = () => {
|
||||
historyParams[record.id]?.resolution ||
|
||||
"选择分辨率"
|
||||
}
|
||||
options={engineOptions.resolutions}
|
||||
options={getVideoEngineOptions(historyParams[record.id]?.engineId || record.engineId || videoEngines[0]?.id).resolutions}
|
||||
expanded={
|
||||
expandedEngine === `res-${record.id}`
|
||||
}
|
||||
@@ -4450,6 +4611,9 @@ const GeneratePage: React.FC = () => {
|
||||
calcVideoCredits(
|
||||
record.duration || 5,
|
||||
params.resolution,
|
||||
params.engineId || record.engineId || videoEngines[0]?.id,
|
||||
Boolean(params.includeMediaReferences ?? record.includeMediaReferences),
|
||||
record.references,
|
||||
)
|
||||
) {
|
||||
message.error("积分不足,请先充值");
|
||||
@@ -4473,8 +4637,11 @@ const GeneratePage: React.FC = () => {
|
||||
});
|
||||
try {
|
||||
await generateVideo(record.id, {
|
||||
engineId: params?.engineId || record.engineId || (type === 'image' ? imageEngines[0]?.id : videoEngines[0]?.id),
|
||||
includeMediaReferences: Boolean(params?.includeMediaReferences ?? record.includeMediaReferences),
|
||||
aspectRatio: params?.aspectRatio,
|
||||
resolution: params?.resolution,
|
||||
imageSize: record.imageSize,
|
||||
});
|
||||
startPolling(record.id);
|
||||
message.success({
|
||||
@@ -4529,14 +4696,31 @@ const GeneratePage: React.FC = () => {
|
||||
)}{" "}
|
||||
{/* {type === "video" &&
|
||||
historyParams[record.id]?.resolution
|
||||
? `(${calcVideoCredits(record.duration || 5, historyParams[record.id].resolution)}积分)`
|
||||
? `(${calcVideoCredits(
|
||||
record.duration || 5,
|
||||
historyParams[record.id].resolution as Resolution,
|
||||
historyParams[record.id].engineId || record.engineId || videoEngines[0]?.id,
|
||||
Boolean(historyParams[record.id].includeMediaReferences ?? record.includeMediaReferences),
|
||||
record.references,
|
||||
)}积分)`
|
||||
: ""} */}
|
||||
{type === "video" &&
|
||||
historyParams[record.id]?.resolution
|
||||
? `(${calcVideoCredits(record.duration || 5, historyParams[record.id].resolution)}积分)`
|
||||
? `(${calcVideoCredits(
|
||||
record.duration || 5,
|
||||
historyParams[record.id].resolution as Resolution,
|
||||
historyParams[record.id].engineId || record.engineId || videoEngines[0]?.id,
|
||||
Boolean(historyParams[record.id].includeMediaReferences ?? record.includeMediaReferences),
|
||||
record.references,
|
||||
)}积分)`
|
||||
: ""}
|
||||
{type === "image" && record.imageSize
|
||||
? `(${getImageCredits(record.imageSize)}积分)`
|
||||
? `(${getImageCreditsFromCimage(
|
||||
record.imageSize,
|
||||
historyParams[record.id]?.engineId || record.engineId || imageEngines[0]?.id,
|
||||
Boolean(historyParams[record.id]?.includeMediaReferences ?? record.includeMediaReferences),
|
||||
record.references,
|
||||
)}积分)`
|
||||
: type === "image"
|
||||
? `(积分)`
|
||||
: ""}
|
||||
@@ -4807,7 +4991,7 @@ const GeneratePage: React.FC = () => {
|
||||
setRecordStates((p) => ({ ...p, [record.id]: 'generating' }));
|
||||
message.loading({ content: `「${projectName}」正在生成视频...`, duration: 0, key: record.id });
|
||||
try {
|
||||
await generateVideo(record.id, { aspectRatio: params.aspectRatio, resolution: params.resolution });
|
||||
await generateVideo(record.id, { engineId: params.engineId || record.engineId || videoEngines[0]?.id, includeMediaReferences: Boolean(params.includeMediaReferences ?? record.includeMediaReferences), aspectRatio: params.aspectRatio, resolution: params.resolution, imageSize: record.imageSize });
|
||||
setRecordStates((p) => ({ ...p, [record.id]: 'done' }));
|
||||
message.success({ content: `「${projectName}」视频生成成功!`, key: record.id, duration: 3 });
|
||||
} catch {
|
||||
|
||||
@@ -901,9 +901,9 @@ const HomePage: React.FC = () => {
|
||||
onClick={() => {
|
||||
const id = video.moduleProjectId;
|
||||
if (video.type === 'hotOpeningReplicate' && id != null) {
|
||||
navigate(`/initial/${id}/initialinfo`);
|
||||
navigate(`/initial/${id}/initialinfo?flow_version=${video.moduleProjectFlowVersion === 'v2' ? 'v2' : 'v1'}`);
|
||||
} else if (video.type === 'shotReplicate' && id != null) {
|
||||
navigate(`/removelens/${id}/removefenbu`);
|
||||
navigate(`/removelens/${id}/removefenbu?flow_version=${video.moduleProjectFlowVersion === 'v2' ? 'v2' : 'v1'}`);
|
||||
} else if (video.type === 'chatAi') {
|
||||
navigate(`/conversation`);
|
||||
} else if (video.type === 'project') {
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import React, { useState, useEffect, useRef } from 'react';
|
||||
import { Button, Typography, Collapse, Space, Modal, Input, Table, message, Tooltip } from 'antd';
|
||||
import { ArrowLeftOutlined, PlayCircleOutlined, CheckCircleOutlined, EditOutlined, DownloadOutlined, SettingOutlined, LayoutOutlined, WarningOutlined } from '@ant-design/icons';
|
||||
import { useNavigate, useParams } from 'react-router-dom';
|
||||
import { getReplicationList, getReplicationDetail, gettwo, getthree, getfour, getEngine, updateHotOpeningVideoPromptSchema, updateImagePrompt, calculateCredits } from '../api/index';
|
||||
import { useNavigate, useParams, useSearchParams } from 'react-router-dom';
|
||||
import { getReplicationList, getReplicationDetail, gettwo, getthree, getfour, getEngine, updateHotOpeningVideoPromptSchema, updateImagePrompt, calculateCredits, retryHotOpeningVideoPromptV2, updateHotOpeningVideoPromptSchemaV2, generateHotOpeningVideoV2 } from '../api/index';
|
||||
import { useAuthStore } from '../store/useAuthStore';
|
||||
import VideoPromptSchemaEditor from '../components/VideoPromptSchemaEditor';
|
||||
import { validateVideoPromptSchemaByConfig } from '../utils/videoPromptSchema';
|
||||
@@ -25,6 +25,8 @@ const buildMediaUrl = (url: string): string => {
|
||||
function InitialInfo() {
|
||||
const navigate = useNavigate();
|
||||
const { creatID } = useParams<{ creatID: string }>();
|
||||
const [searchParams] = useSearchParams();
|
||||
const flowVersion: 'v1' | 'v2' = searchParams.get('flow_version') === 'v2' ? 'v2' : 'v1';
|
||||
const { user } = useAuthStore();
|
||||
|
||||
const [modalVisible, setModalVisible] = useState(false);
|
||||
@@ -35,7 +37,10 @@ function InitialInfo() {
|
||||
const [videoSchemaConfigSnapshot, setVideoSchemaConfigSnapshot] = useState<any>(null);
|
||||
const [editingPromptStepId, setEditingPromptStepId] = useState<string>('');
|
||||
const [promptSaving, setPromptSaving] = useState(false);
|
||||
const [pollingTimer, setPollingTimer] = useState<any>(null);
|
||||
const pollingTimerRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
const [retryPromptModalVisible, setRetryPromptModalVisible] = useState(false);
|
||||
const [retryPromptStepId, setRetryPromptStepId] = useState<string>('');
|
||||
const [retryPromptSubmitting, setRetryPromptSubmitting] = useState(false);
|
||||
|
||||
// 引擎和视频参数相关状态
|
||||
const [enginesele, setEnginesele] = useState<any>({});
|
||||
@@ -139,14 +144,21 @@ function InitialInfo() {
|
||||
document.body.removeChild(link);
|
||||
};
|
||||
|
||||
//
|
||||
const baseSteps = [
|
||||
{ id: 1, title: '原始素材', description: '上传原始视频素材', childId: 1 },
|
||||
{ id: 2, title: '生成提示词', description: '根据素材生成描述词', childId: 2 },
|
||||
{ id: 3, title: '生成产品融合图', description: '生成产品与场景融合图', childId: 3 },
|
||||
{ id: 4, title: '生成视频提示词', description: '生成视频生成提示词', childId: 4 },
|
||||
{ id: 5, title: '生成最终视频', description: '合成最终视频', childId: 5 },
|
||||
];
|
||||
const isV2 = flowVersion === 'v2';
|
||||
|
||||
const baseSteps = isV2
|
||||
? [
|
||||
{ id: 1, title: '素材与项目信息', description: '固定素材和项目描述', childId: 1 },
|
||||
{ id: 2, title: '生成视频提示词', description: '生成并确认视频提示词', childId: 4 },
|
||||
{ id: 3, title: '生成最终视频', description: '使用当前视频提示词生成视频', childId: 5 },
|
||||
]
|
||||
: [
|
||||
{ id: 1, title: '原始素材', description: '上传原始视频素材', childId: 1 },
|
||||
{ id: 2, title: '生成提示词', description: '根据素材生成描述词', childId: 2 },
|
||||
{ id: 3, title: '生成产品融合图', description: '生成产品与场景融合图', childId: 3 },
|
||||
{ id: 4, title: '生成视频提示词', description: '生成视频生成提示词', childId: 4 },
|
||||
{ id: 5, title: '生成最终视频', description: '合成最终视频', childId: 5 },
|
||||
];
|
||||
|
||||
// 合并基础步骤和API返回的状态
|
||||
const steps = baseSteps.map((step, index) => ({
|
||||
@@ -295,7 +307,9 @@ function InitialInfo() {
|
||||
total += inputVideoCost;
|
||||
}
|
||||
|
||||
const inputImageCount = taskDetail?.videoGeneration?.inputMedia?.image?.length || 0;
|
||||
const inputImageCount = isV2
|
||||
? (taskDetail?.material?.materialImageUrl ? 1 : 0)
|
||||
: (taskDetail?.videoGeneration?.inputMedia?.image?.length || 0);
|
||||
if (inputImageCount > 0) {
|
||||
const inputImageCost = ((config.inputImageBaseCredits || 0) + (config.inputImagePerImageCredits || 0) * inputImageCount) * (config.inputImageRatio || 1);
|
||||
total += inputImageCost;
|
||||
@@ -313,12 +327,12 @@ function InitialInfo() {
|
||||
// 组件卸载时清理定时器
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (pollingTimer) {
|
||||
clearInterval(pollingTimer);
|
||||
setPollingTimer(null);
|
||||
if (pollingTimerRef.current) {
|
||||
clearInterval(pollingTimerRef.current);
|
||||
pollingTimerRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [pollingTimer]);
|
||||
}, []);
|
||||
|
||||
// 点击外部关闭弹窗
|
||||
useEffect(() => {
|
||||
@@ -338,39 +352,30 @@ function InitialInfo() {
|
||||
// 获取任务详情数据
|
||||
useEffect(() => {
|
||||
if (creatID) {
|
||||
getReplicationDetail(creatID).then((res: any) => {
|
||||
getReplicationDetail(creatID, flowVersion).then((res: any) => {
|
||||
setTaskDetail(res);
|
||||
if (res.steps) {
|
||||
setApiSteps(res.steps);
|
||||
}
|
||||
}).catch((error: any) => {
|
||||
message.error(error?.message || '加载任务详情失败');
|
||||
});
|
||||
}
|
||||
}, [creatID]);
|
||||
}, [creatID, flowVersion]);
|
||||
|
||||
// 监听合并后的 steps 数据,判断第五步状态并启动/停止轮询
|
||||
// 仅在项目或任一步骤处于 processing 时轮询;waiting_user 必须停止。
|
||||
useEffect(() => {
|
||||
const shouldPoll = taskDetail?.status === 'processing'
|
||||
|| apiSteps.some((step: any) => step?.status === 'processing');
|
||||
|
||||
// 检查第五步的状态(索引 4)
|
||||
const fifthStep = steps[4];
|
||||
|
||||
if (fifthStep && fifthStep.status !== 'completed' && fifthStep.status !== 'failed') {
|
||||
// 第五步未完成,启动轮询
|
||||
if (!pollingTimer) {
|
||||
const timer = setInterval(pollTaskDetail, 15000);
|
||||
setPollingTimer(timer);
|
||||
} else {
|
||||
}
|
||||
} else {
|
||||
// 第五步已完成或失败,停止轮询
|
||||
if (fifthStep) {
|
||||
if (pollingTimer) {
|
||||
clearInterval(pollingTimer);
|
||||
setPollingTimer(null);
|
||||
}
|
||||
}
|
||||
if (shouldPoll && !pollingTimerRef.current) {
|
||||
pollingTimerRef.current = setInterval(pollTaskDetail, 15000);
|
||||
} else if (!shouldPoll && pollingTimerRef.current) {
|
||||
clearInterval(pollingTimerRef.current);
|
||||
pollingTimerRef.current = null;
|
||||
}
|
||||
}, [steps]);
|
||||
}, [taskDetail?.status, apiSteps, creatID, flowVersion]);
|
||||
|
||||
|
||||
const handleOpenModal = (prompt?: any, type?: string, stepId?: string | number, schemaConfigSnapshot?: any) => {
|
||||
setCurrentType(type || 'image');
|
||||
@@ -436,7 +441,8 @@ function InitialInfo() {
|
||||
|
||||
setPromptSaving(true);
|
||||
try {
|
||||
await updateHotOpeningVideoPromptSchema(taskDetail.id, editingPromptStepId, {
|
||||
const updateVideoPrompt = isV2 ? updateHotOpeningVideoPromptSchemaV2 : updateHotOpeningVideoPromptSchema;
|
||||
await updateVideoPrompt(taskDetail.id, editingPromptStepId, {
|
||||
prompt_schema: formData,
|
||||
});
|
||||
message.success('视频提示词已保存');
|
||||
@@ -456,12 +462,13 @@ function InitialInfo() {
|
||||
return;
|
||||
}
|
||||
|
||||
getReplicationDetail(creatID).then((res: any) => {
|
||||
getReplicationDetail(creatID, flowVersion).then((res: any) => {
|
||||
setTaskDetail(res);
|
||||
if (res.steps) {
|
||||
setApiSteps(res.steps);
|
||||
}
|
||||
}).catch((error: any) => {
|
||||
console.error('轮询任务详情失败', error);
|
||||
});
|
||||
};
|
||||
|
||||
@@ -469,12 +476,13 @@ function InitialInfo() {
|
||||
const refreshTaskDetail = () => {
|
||||
if (!creatID) return;
|
||||
|
||||
getReplicationDetail(creatID).then((res: any) => {
|
||||
getReplicationDetail(creatID, flowVersion).then((res: any) => {
|
||||
setTaskDetail(res);
|
||||
if (res.steps) {
|
||||
setApiSteps(res.steps);
|
||||
}
|
||||
}).catch((error: any) => {
|
||||
message.error(error?.message || '刷新任务详情失败');
|
||||
});
|
||||
};
|
||||
|
||||
@@ -539,35 +547,109 @@ function InitialInfo() {
|
||||
// 这里可以添加下一步的逻辑,比如调用接口等
|
||||
};
|
||||
|
||||
const createvideo = (stepId: number, engineId: string) => {
|
||||
let params = {
|
||||
engine_id: '',
|
||||
}
|
||||
|
||||
|
||||
getfour(taskDetail.id, stepId.toString(), params).then((res: any) => {
|
||||
// 重新获取任务详情以更新数据
|
||||
message.info('正在生成视频,请稍候...');
|
||||
const createvideo = async (stepId: number, engineId: string) => {
|
||||
try {
|
||||
if (isV2) {
|
||||
await generateHotOpeningVideoV2(taskDetail.id, String(stepId));
|
||||
} else {
|
||||
await getfour(taskDetail.id, String(stepId), { engine_id: engineId || '' });
|
||||
}
|
||||
message.info('正在生成视频,请稍候...');
|
||||
refreshTaskDetail();
|
||||
}).catch((error: any) => {
|
||||
const errorMsg = error?.message?.split(': ')?.[1] || error?.message || '生成失败';
|
||||
} catch (error: any) {
|
||||
const errorMsg = error?.message?.split(': ')?.[1] || error?.message || '生成失败';
|
||||
message.error(errorMsg);
|
||||
});
|
||||
}
|
||||
const agincreatevideo = () => {
|
||||
let params = {
|
||||
engine_id: '',
|
||||
}
|
||||
};
|
||||
|
||||
const agincreatevideo = async () => {
|
||||
const promptStep = isV2 ? steps[1] : steps[3];
|
||||
if (!promptStep?.id) return;
|
||||
await createvideo(promptStep.id, promptStep.engineId || '');
|
||||
};
|
||||
|
||||
getfour(taskDetail.id, steps[3].id.toString(), params).then((res: any) => {
|
||||
// 重新获取任务详情以更新数据
|
||||
message.info('正在生成视频,请稍候...');
|
||||
const applyRetryVideoEngine = (
|
||||
engineId: string,
|
||||
preferred?: { duration?: number; aspectRatio?: string; resolution?: string },
|
||||
) => {
|
||||
const engine = (enginesele.video || []).find((item: any) => String(item.id) === String(engineId));
|
||||
if (!engine) {
|
||||
return false;
|
||||
}
|
||||
const ratios = Array.isArray(engine.supportedRatios) && engine.supportedRatios.length > 0
|
||||
? engine.supportedRatios.map((item: any) => String(item))
|
||||
: ['16:9', '4:3', '1:1', '3:4', '9:16', '21:9'];
|
||||
const resolutions = Array.isArray(engine.supportedResolutions) && engine.supportedResolutions.length > 0
|
||||
? engine.supportedResolutions.map((item: any) => String(item))
|
||||
: ['480p', '720p', '1080p'];
|
||||
const parsedDurations = Array.isArray(engine.supportedDurations)
|
||||
? engine.supportedDurations
|
||||
.map((item: any) => Number(item))
|
||||
.filter((item: number) => Number.isFinite(item) && item > 0)
|
||||
: [];
|
||||
const durations = parsedDurations.length > 0 ? parsedDurations : [5, 8, 10, 12, 15];
|
||||
|
||||
const preferredDuration = Number(preferred?.duration ?? videoDuration);
|
||||
const preferredRatio = String(preferred?.aspectRatio || videoAspectRatio || '');
|
||||
const preferredResolution = String(preferred?.resolution || videoResolution || '');
|
||||
|
||||
setCountType(String(engine.id));
|
||||
setEngineOptions({ ratios, resolutions, durations });
|
||||
setVideoDuration(durations.includes(preferredDuration) ? preferredDuration : durations[0]);
|
||||
setVideoAspectRatio(ratios.includes(preferredRatio) ? preferredRatio : ratios[0]);
|
||||
setVideoResolution(resolutions.includes(preferredResolution) ? preferredResolution : resolutions[0]);
|
||||
return true;
|
||||
};
|
||||
|
||||
const openRegenerateVideoPrompt = (stepId: number) => {
|
||||
if (!isV2 || !taskDetail?.id) return;
|
||||
const currentConfig = taskDetail?.videoGeneration?.promptParams || taskDetail?.videoGeneration?.params || {};
|
||||
const requestedEngineId = String(
|
||||
currentConfig.engineId
|
||||
|| currentConfig.engine_id
|
||||
|| taskDetail?.videoGeneration?.engineId
|
||||
|| countType
|
||||
|| '',
|
||||
);
|
||||
const currentEngineId = String(
|
||||
(enginesele.video || []).some((engine: any) => String(engine.id) === requestedEngineId)
|
||||
? requestedEngineId
|
||||
: enginesele.video?.[0]?.id || '',
|
||||
);
|
||||
if (!currentEngineId || !applyRetryVideoEngine(currentEngineId, {
|
||||
duration: Number(currentConfig.duration || videoDuration),
|
||||
aspectRatio: String(currentConfig.aspectRatio || currentConfig.aspect_ratio || videoAspectRatio),
|
||||
resolution: String(currentConfig.resolution || videoResolution),
|
||||
})) {
|
||||
message.warning('当前没有可用的视频生成引擎');
|
||||
return;
|
||||
}
|
||||
setRetryPromptStepId(String(stepId));
|
||||
setRetryPromptModalVisible(true);
|
||||
};
|
||||
|
||||
const submitRegenerateVideoPrompt = async () => {
|
||||
if (!isV2 || !taskDetail?.id || !retryPromptStepId || !countType) return;
|
||||
setRetryPromptSubmitting(true);
|
||||
try {
|
||||
await retryHotOpeningVideoPromptV2(taskDetail.id, retryPromptStepId, {
|
||||
video_config: {
|
||||
engine_id: countType,
|
||||
duration: videoDuration,
|
||||
aspect_ratio: videoAspectRatio,
|
||||
resolution: videoResolution,
|
||||
},
|
||||
});
|
||||
message.info('正在按新视频参数重新生成视频提示词,请稍候...');
|
||||
setRetryPromptModalVisible(false);
|
||||
setRetryPromptStepId('');
|
||||
refreshTaskDetail();
|
||||
}).catch((error: any) => {
|
||||
});
|
||||
}
|
||||
} catch (error: any) {
|
||||
message.error(error?.message || '重新生成视频提示词失败');
|
||||
} finally {
|
||||
setRetryPromptSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
|
||||
@@ -896,6 +978,7 @@ function InitialInfo() {
|
||||
重新生成
|
||||
</Button>
|
||||
</div>
|
||||
{!isV2 && (<>
|
||||
{/* 引擎选择器和视频参数设置 */}
|
||||
<p style={{marginBottom:6,fontSize: 14, background: 'linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%)', WebkitBackgroundClip: 'text', WebkitTextFillColor: 'transparent', backgroundClip: 'text' }}>视频参数选择:</p>
|
||||
<div style={{ width:'100%', display: 'flex', justifyContent: 'space-between', marginBottom: 16 }}>
|
||||
@@ -1287,6 +1370,7 @@ function InitialInfo() {
|
||||
</span>
|
||||
</Button>
|
||||
</Tooltip>
|
||||
</>)}
|
||||
</>
|
||||
)}
|
||||
{/* 步骤4: 生成视频提示词 */}
|
||||
@@ -1307,6 +1391,16 @@ function InitialInfo() {
|
||||
>
|
||||
查看/修改视频提词
|
||||
</Button>
|
||||
{isV2 && (
|
||||
<Button
|
||||
type="default"
|
||||
onClick={() => openRegenerateVideoPrompt(step.id)}
|
||||
style={{ flex: 1, borderRadius: 10, borderColor: 'rgba(99, 102, 241, 0.3)', color: '#6366f1', height: 36, fontWeight: 500 }}
|
||||
disabled={step.status === 'processing' || steps[2]?.status === 'processing'}
|
||||
>
|
||||
重新生成视频提词
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
onClick={() => { createvideo(step.id, step.engineId); }}
|
||||
type="primary"
|
||||
@@ -1341,9 +1435,9 @@ function InitialInfo() {
|
||||
type="default"
|
||||
icon={<EditOutlined />}
|
||||
style={{ flex: 1, borderRadius: 10, borderColor: 'rgba(99, 102, 241, 0.3)', color: '#6366f1', height: 36, fontWeight: 500, background: 'rgba(99, 102, 241, 0.04)' }}
|
||||
disabled={step.status !== 'completed'}
|
||||
disabled={isV2 ? !['completed', 'failed'].includes(step.status) : step.status !== 'completed'}
|
||||
>
|
||||
重新生成
|
||||
{step.status === 'failed' ? '重试生成' : '重新生成'}
|
||||
</Button>
|
||||
<Button
|
||||
type="primary"
|
||||
@@ -1373,6 +1467,101 @@ function InitialInfo() {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<Modal
|
||||
title="重新生成视频提词"
|
||||
open={retryPromptModalVisible}
|
||||
onCancel={() => {
|
||||
if (!retryPromptSubmitting) {
|
||||
setRetryPromptModalVisible(false);
|
||||
setRetryPromptStepId('');
|
||||
}
|
||||
}}
|
||||
onOk={submitRegenerateVideoPrompt}
|
||||
confirmLoading={retryPromptSubmitting}
|
||||
okText="按新参数生成提词"
|
||||
cancelText="取消"
|
||||
width={720}
|
||||
destroyOnClose
|
||||
>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 20 }}>
|
||||
<div>
|
||||
<Text strong style={{ display: 'block', marginBottom: 10 }}>视频引擎</Text>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(2, minmax(0, 1fr))', gap: 10 }}>
|
||||
{(enginesele.video || []).map((engine: any) => (
|
||||
<button
|
||||
key={engine.id}
|
||||
type="button"
|
||||
onClick={() => applyRetryVideoEngine(String(engine.id), {
|
||||
duration: videoDuration,
|
||||
aspectRatio: videoAspectRatio,
|
||||
resolution: videoResolution,
|
||||
})}
|
||||
style={{
|
||||
minHeight: 46,
|
||||
padding: '8px 12px',
|
||||
borderRadius: 8,
|
||||
border: countType === String(engine.id) ? '2px solid #6366f1' : '1px solid #e5e7eb',
|
||||
background: countType === String(engine.id) ? 'rgba(99,102,241,0.08)' : '#fff',
|
||||
color: countType === String(engine.id) ? '#4f46e5' : '#374151',
|
||||
cursor: 'pointer',
|
||||
textAlign: 'left',
|
||||
fontWeight: countType === String(engine.id) ? 600 : 400,
|
||||
}}
|
||||
>
|
||||
{engine.name || engine.id}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<Text strong style={{ display: 'block', marginBottom: 10 }}>视频时长</Text>
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 8 }}>
|
||||
{engineOptions.durations.map((duration) => (
|
||||
<Button
|
||||
key={duration}
|
||||
type={videoDuration === duration ? 'primary' : 'default'}
|
||||
onClick={() => setVideoDuration(duration)}
|
||||
>
|
||||
{duration} 秒
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<Text strong style={{ display: 'block', marginBottom: 10 }}>画面比例</Text>
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 8 }}>
|
||||
{engineOptions.ratios.map((ratio) => (
|
||||
<Button
|
||||
key={ratio}
|
||||
type={videoAspectRatio === ratio ? 'primary' : 'default'}
|
||||
onClick={() => setVideoAspectRatio(ratio)}
|
||||
>
|
||||
{ratio}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<Text strong style={{ display: 'block', marginBottom: 10 }}>分辨率</Text>
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 8 }}>
|
||||
{engineOptions.resolutions.map((resolution) => (
|
||||
<Button
|
||||
key={resolution}
|
||||
type={videoResolution === resolution ? 'primary' : 'default'}
|
||||
onClick={() => setVideoResolution(resolution)}
|
||||
>
|
||||
{resolution}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ padding: '10px 12px', borderRadius: 8, background: '#f8fafc', color: '#64748b' }}>
|
||||
当前参数:{enginesele.video?.find((engine: any) => String(engine.id) === countType)?.name || countType}
|
||||
{' · '}{videoDuration} 秒 · {videoAspectRatio} · {videoResolution}
|
||||
<span style={{ marginLeft: 12 }}>最终视频预估积分:{estimatedCredits}</span>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
<Modal
|
||||
title={
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
|
||||
@@ -1586,7 +1775,7 @@ function InitialInfo() {
|
||||
|
||||
render: (_, record) => (
|
||||
<button
|
||||
onClick={() => navigate(`/initial/${record.id}/initialinfo`)}
|
||||
onClick={() => navigate(`/initial/${record.id}/initialinfo?flow_version=${record.flowVersion === 'v2' ? 'v2' : 'v1'}`)}
|
||||
style={{ color: '#6366f1', textDecoration: 'none', fontSize: 12, border: 'none', background: 'rgba(99, 102, 241, 0.08)', padding: '4px 12px', borderRadius: 8, cursor: 'pointer', transition: 'all 0.2s', fontWeight: 500 }}
|
||||
>
|
||||
查看详情
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
Space,
|
||||
Pagination,
|
||||
Popconfirm,
|
||||
Select,
|
||||
} from 'antd';
|
||||
import {
|
||||
PlusOutlined,
|
||||
@@ -19,8 +20,9 @@ import {
|
||||
LoadingOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { uploadHotOpeningVideo, uploadHotOpeningImage, generateReplication, getReplicationList, getone, getReplicationDetail, deleteHotOpeningReplicationTask } from '../api';
|
||||
import { uploadHotOpeningVideo, uploadHotOpeningImage, generateReplication, getReplicationList, deleteHotOpeningReplicationTask, getEngine, calculateCredits } from '../api';
|
||||
import bg1 from '../assets/bg1.png';
|
||||
import UploadSelector from '../components/UploadSelector';
|
||||
|
||||
|
||||
|
||||
@@ -29,6 +31,13 @@ const { TextArea } = Input;
|
||||
|
||||
const API_BASE = import.meta.env.VITE_API_BASE || 'http://localhost:8000';
|
||||
|
||||
const supportsReferenceImage = (engine: any): boolean => {
|
||||
if (!engine) return false;
|
||||
const supports = engine.supportsUniversalReference ?? engine.supports_universal_reference;
|
||||
const maxImages = engine.maxImageCount ?? engine.max_image_count;
|
||||
return supports !== false && (maxImages === undefined || maxImages === null || Number(maxImages) >= 1);
|
||||
};
|
||||
|
||||
const buildAssetUrl = (url?: string): string => {
|
||||
if (!url) return '';
|
||||
if (/^https?:\/\//i.test(url) || url.startsWith('blob:')) return url;
|
||||
@@ -70,6 +79,12 @@ const GenerateConver: React.FC = () => {
|
||||
const [imageResourceId, setImageResourceId] = useState<string>('');
|
||||
const [videoUploading, setVideoUploading] = useState(false);
|
||||
const [imageUploading, setImageUploading] = useState(false);
|
||||
const [videoEngines, setVideoEngines] = useState<any[]>([]);
|
||||
const [engineId, setEngineId] = useState('');
|
||||
const [videoDuration, setVideoDuration] = useState(5);
|
||||
const [videoAspectRatio, setVideoAspectRatio] = useState('16:9');
|
||||
const [videoResolution, setVideoResolution] = useState('480p');
|
||||
const [creditRules, setCreditRules] = useState<any[]>([]);
|
||||
|
||||
// 弹窗状态
|
||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||
@@ -79,6 +94,35 @@ const GenerateConver: React.FC = () => {
|
||||
|
||||
// 表格轮询定时器(使用 ref 避免闭包问题)
|
||||
const tablePollingTimer = useRef<any>(null);
|
||||
// 同一次创建在网络重试时复用幂等键;成功后再生成新键。
|
||||
const createIdempotencyKeyRef = useRef<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
getEngine().then((data: any) => {
|
||||
const engines = data?.engine?.video || [];
|
||||
setVideoEngines(engines);
|
||||
if (engines.length > 0) {
|
||||
const first = engines[0];
|
||||
setEngineId(first.id);
|
||||
setVideoAspectRatio(first.supportedRatios?.[0] || '16:9');
|
||||
setVideoResolution(first.supportedResolutions?.[0] || '480p');
|
||||
setVideoDuration(first.supportedDurations?.[0] || 5);
|
||||
}
|
||||
}).catch(() => message.error('视频引擎加载失败'));
|
||||
calculateCredits().then((rules: any) => setCreditRules(Array.isArray(rules) ? rules : [])).catch(() => setCreditRules([]));
|
||||
}, []);
|
||||
|
||||
const selectedEngine = videoEngines.find((item: any) => item.id === engineId);
|
||||
const selectedEngineSupportsImage = !imageUrl || supportsReferenceImage(selectedEngine);
|
||||
const estimatedCredits = (() => {
|
||||
const rule = creditRules.find((item: any) => item.modelConfigId === engineId && item.genType === 'video' && item.resolution === videoResolution);
|
||||
if (!rule) return 0;
|
||||
let total = (Number(rule.baseCredits || 0) + Number(rule.perSecondCredits || 0) * videoDuration) * Number(rule.ratio || 1);
|
||||
if (imageUrl) {
|
||||
total += (Number(rule.inputImageBaseCredits || 0) + Number(rule.inputImagePerImageCredits || 0)) * Number(rule.inputImageRatio || 1);
|
||||
}
|
||||
return Number(total.toFixed(2));
|
||||
})();
|
||||
|
||||
// 获取卡片列表数据(独立于表格)
|
||||
const fetchCardList = (page: number, size: number, isPolling = false) => {
|
||||
@@ -355,90 +399,52 @@ const GenerateConver: React.FC = () => {
|
||||
message.warning('请上传复刻视频');
|
||||
return;
|
||||
}
|
||||
if (!imageUrl) {
|
||||
message.warning('请上传产品图片');
|
||||
if (!engineId || !videoDuration || !videoAspectRatio || !videoResolution) {
|
||||
message.warning('请选择完整的视频生成参数');
|
||||
return;
|
||||
}
|
||||
if (!originalProductName.trim()) {
|
||||
message.warning('请输入原视频产品名称');
|
||||
if (!selectedEngineSupportsImage) {
|
||||
message.warning('当前视频引擎不支持参考图片,请移除素材图片或切换引擎');
|
||||
return;
|
||||
}
|
||||
if (!ownProductName.trim()) {
|
||||
message.warning('请输入自有产品名称');
|
||||
return;
|
||||
}
|
||||
if (!productSellingPoints.trim()) {
|
||||
message.warning('请输入产品卖点');
|
||||
return;
|
||||
}
|
||||
// message.success('正在生成爆款开头复刻视频...');
|
||||
// console.log('生成参数:', {
|
||||
// videoUrl,
|
||||
// imageUrl,
|
||||
// originalProductName,
|
||||
// ownProductName,
|
||||
// productSellingPoints,
|
||||
// });
|
||||
let params = {
|
||||
const idempotencyKey = createIdempotencyKeyRef.current
|
||||
|| `hot_v2_${Date.now()}_${Math.random().toString(16).slice(2)}`;
|
||||
createIdempotencyKeyRef.current = idempotencyKey;
|
||||
const params = {
|
||||
material_video_url: videoUrl,
|
||||
material_image_url: imageUrl,
|
||||
material_image_url: imageUrl || undefined,
|
||||
material_video_resource_id: videoResourceId || undefined,
|
||||
material_image_resource_id: imageResourceId || undefined,
|
||||
material_video_duration_seconds: videoDurationSeconds || undefined,
|
||||
source_project_name: originalProductName,
|
||||
target_project_name: ownProductName,
|
||||
core_content_point: productSellingPoints,
|
||||
idempotency_key: Date.now().toString(),
|
||||
}
|
||||
|
||||
// 在这里调用生成视频的API,并传递上述参数
|
||||
generateReplication(params).then((res) => {
|
||||
|
||||
|
||||
let projectId = '';
|
||||
let childId = '';
|
||||
// console.log('生成视频成功:', res);
|
||||
// message.success('任务开始');
|
||||
|
||||
// 清空上传的媒体和文本
|
||||
source_project_name: originalProductName.trim() || undefined,
|
||||
target_project_name: ownProductName.trim() || undefined,
|
||||
project_description: productSellingPoints.trim() || undefined,
|
||||
core_content_point: productSellingPoints.trim() || undefined,
|
||||
video_config: {
|
||||
engine_id: engineId,
|
||||
duration: videoDuration,
|
||||
aspect_ratio: videoAspectRatio,
|
||||
resolution: videoResolution,
|
||||
},
|
||||
idempotency_key: idempotencyKey,
|
||||
};
|
||||
generateReplication(params).then((res: any) => {
|
||||
const projectId = res.id || res.projectId || res.detail?.id;
|
||||
createIdempotencyKeyRef.current = null;
|
||||
setVideoUrl('');
|
||||
setVideoResourceId('');
|
||||
setVideoDurationSeconds(null);
|
||||
setImageUrl('');
|
||||
setImageResourceId('');
|
||||
setImageFile(null);
|
||||
setOriginalProductName('');
|
||||
setOwnProductName('');
|
||||
setProductSellingPoints('');
|
||||
|
||||
fetchCardList(1, 8);
|
||||
|
||||
// 获取第一个的id直接进行下一步
|
||||
getReplicationList(1, 20).then((res: any) => {
|
||||
if (res.items) {
|
||||
const firstId = res.items[0].id;
|
||||
setTableData(res.items);
|
||||
getReplicationDetail(res.items[0].id).then((res: any) => {
|
||||
projectId = res.id;
|
||||
childId = res.steps[0].id;
|
||||
getone(projectId, childId).then((res: any) => {
|
||||
message.loading('创建中...', 3);
|
||||
setTimeout(() => {
|
||||
navigate(`/initial/${firstId}/initialinfo`);
|
||||
}, 3000);
|
||||
})
|
||||
}).catch((error: any) => {
|
||||
});
|
||||
}
|
||||
|
||||
}).catch((error: any) => {
|
||||
});
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
}).catch((error) => {
|
||||
message.error('视频开头复刻失败');
|
||||
message.success('项目已创建,视频提词正在生成');
|
||||
if (projectId) navigate(`/initial/${projectId}/initialinfo?flow_version=v2`);
|
||||
}).catch((error: any) => {
|
||||
message.error(error?.message || '视频开头复刻失败');
|
||||
});
|
||||
};
|
||||
|
||||
@@ -548,7 +554,7 @@ const GenerateConver: React.FC = () => {
|
||||
transition: 'transform 0.25s cubic-bezier(0.4, 0, 0.2, 1), box-shadow 0.25s cubic-bezier(0.4, 0, 0.2, 1)',
|
||||
// border: '1px solid rgba(99, 102, 241, 0.06)',
|
||||
}}
|
||||
onClick={() => navigate(`/initial/${item.id}/initialinfo`)}
|
||||
onClick={() => navigate(`/initial/${item.id}/initialinfo?flow_version=${item.flowVersion === 'v2' ? 'v2' : 'v1'}`)}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.transform = 'translateY(-6px)';
|
||||
e.currentTarget.style.boxShadow = '0 12px 32px rgba(99, 102, 241, 0.12)';
|
||||
@@ -1021,12 +1027,22 @@ const GenerateConver: React.FC = () => {
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<Upload
|
||||
style={{ width: '100%' }}
|
||||
|
||||
beforeUpload={beforeImageUpload}
|
||||
showUploadList={false}
|
||||
accept="image/jpeg,image/jpg,image/png,.jpg,.jpeg,.png"
|
||||
<UploadSelector
|
||||
accept="image/*"
|
||||
multiple={false}
|
||||
mediaType="image"
|
||||
uploading={imageUploading}
|
||||
maxImageCount={1}
|
||||
usedImageCount={0}
|
||||
onLocalSelect={(files) => { if (files[0]) void beforeImageUpload(files[0]); }}
|
||||
onHistorySelect={(items) => {
|
||||
const item = items[0];
|
||||
if (!item) return;
|
||||
setImageFile(null);
|
||||
setImageUrl(item.resourceUrl || item.previewUrl || item.displayUrl || '');
|
||||
setImageResourceId(item.id);
|
||||
message.success('历史图片已选择');
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
@@ -1088,7 +1104,7 @@ const GenerateConver: React.FC = () => {
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</Upload>
|
||||
</UploadSelector>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -1161,12 +1177,38 @@ const GenerateConver: React.FC = () => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ marginBottom: 20, display: 'grid', gap: 10 }}>
|
||||
<p style={{ margin: 0, fontSize: 13, fontWeight: 600, color: '#475569' }}>视频生成参数(必选)</p>
|
||||
<Select value={engineId || undefined} placeholder="选择视频引擎" onChange={(value) => {
|
||||
setEngineId(value);
|
||||
const engine = videoEngines.find((item: any) => item.id === value);
|
||||
setVideoAspectRatio(engine?.supportedRatios?.[0] || '16:9');
|
||||
setVideoResolution(engine?.supportedResolutions?.[0] || '480p');
|
||||
setVideoDuration(engine?.supportedDurations?.[0] || 5);
|
||||
}} options={videoEngines.map((item: any) => ({
|
||||
value: item.id,
|
||||
label: imageUrl && !supportsReferenceImage(item) ? `${item.name}(不支持参考图)` : item.name,
|
||||
disabled: Boolean(imageUrl) && !supportsReferenceImage(item),
|
||||
}))} />
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(3, 1fr)', gap: 8 }}>
|
||||
<Select value={videoDuration} onChange={setVideoDuration} options={(selectedEngine?.supportedDurations || [5]).map((value: number) => ({ value, label: `${value}秒` }))} />
|
||||
<Select value={videoAspectRatio} onChange={setVideoAspectRatio} options={(selectedEngine?.supportedRatios || ['16:9']).map((value: string) => ({ value, label: value }))} />
|
||||
<Select value={videoResolution} onChange={setVideoResolution} options={(selectedEngine?.supportedResolutions || ['480p']).map((value: string) => ({ value, label: value }))} />
|
||||
</div>
|
||||
<span style={{ fontSize: 12, color: selectedEngineSupportsImage ? '#8b5cf6' : '#ef4444' }}>
|
||||
{selectedEngineSupportsImage
|
||||
? `预估视频积分:${estimatedCredits || '-'};素材图片可不传,最终生成只会携带图片,不会携带参考视频。`
|
||||
: '当前引擎不支持参考图片,请移除素材图片或切换引擎。'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* 立即生成按钮 */}
|
||||
<Button
|
||||
type="primary"
|
||||
block
|
||||
size="large"
|
||||
onClick={handleGenerate}
|
||||
disabled={!selectedEngineSupportsImage}
|
||||
style={{
|
||||
borderRadius: 12,
|
||||
height: 44,
|
||||
@@ -1382,7 +1424,7 @@ const GenerateConver: React.FC = () => {
|
||||
render: (_, record) => (
|
||||
<Space>
|
||||
<button
|
||||
onClick={() => navigate(`/initial/${record.id}/initialinfo`)}
|
||||
onClick={() => navigate(`/initial/${record.id}/initialinfo?flow_version=${record.flowVersion === 'v2' ? 'v2' : 'v1'}`)}
|
||||
style={{
|
||||
color: '#6366f1',
|
||||
textDecoration: 'none',
|
||||
@@ -1402,7 +1444,7 @@ const GenerateConver: React.FC = () => {
|
||||
title="确认删除这个爆款开头复刻任务吗?"
|
||||
onConfirm={async () => {
|
||||
try {
|
||||
await deleteHotOpeningReplicationTask(record.id);
|
||||
await deleteHotOpeningReplicationTask(record.id, record.flowVersion);
|
||||
message.success('删除成功');
|
||||
fetchList(1, pageSize, false, searchKeyword);
|
||||
} catch (err) {
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useNavigate, useParams } from 'react-router-dom';
|
||||
import { Button, Drawer, Input, Modal, Popconfirm, Spin, Table, Tag, Tooltip, Upload, message } from 'antd';
|
||||
import { Button, Drawer, Input, Modal, Popconfirm, Select, Spin, Table, Tag, Tooltip, message } from 'antd';
|
||||
import { ArrowLeftOutlined, DeleteOutlined, PlusOutlined, XOutlined } from '@ant-design/icons';
|
||||
|
||||
import {uploadShotReplicateImage, createRemoveLens, deleteSegment, getShotReplicationDetail, reanalyzeSegment, retrySplit, Removelist, removeCreate, reanalyzeShotReplication, splitCustom, uploadImage } from '../api';
|
||||
import { uploadShotReplicateImage, createRemoveLens, deleteSegment, getShotReplicationDetail, reanalyzeSegment, retrySplit, Removelist, removeCreate, reanalyzeShotReplication, splitCustom, getEngine, calculateCredits } from '../api';
|
||||
|
||||
import VideoTrimPicker from '../components/VideoTrimPicker';
|
||||
import UploadSelector from '../components/UploadSelector';
|
||||
import { useAuthStore } from '../store/useAuthStore';
|
||||
|
||||
const { TextArea } = Input;
|
||||
|
||||
@@ -13,6 +15,13 @@ const API_BASE = import.meta.env.VITE_API_BASE || 'http://localhost:8000';
|
||||
const MIN_TRIM_SECONDS = 2;
|
||||
const MAX_TRIM_SECONDS = 15;
|
||||
|
||||
const supportsReferenceImage = (engine: any): boolean => {
|
||||
if (!engine) return false;
|
||||
const supports = engine.supportsUniversalReference ?? engine.supports_universal_reference;
|
||||
const maxImages = engine.maxImageCount ?? engine.max_image_count;
|
||||
return supports !== false && (maxImages === undefined || maxImages === null || Number(maxImages) >= 1);
|
||||
};
|
||||
|
||||
function buildAssetUrl(url?: string): string {
|
||||
if (!url) return '';
|
||||
if (/^https?:\/\//i.test(url)) return url;
|
||||
@@ -22,6 +31,7 @@ function buildAssetUrl(url?: string): string {
|
||||
function RemoveInfo() {
|
||||
const { creatID } = useParams<{ creatID: string }>();
|
||||
const navigate = useNavigate();
|
||||
const { user } = useAuthStore();
|
||||
const [drawerVisible, setDrawerVisible] = useState(false);
|
||||
const [trimModalVisible, setTrimModalVisible] = useState(false);
|
||||
const [currentSegment, setCurrentSegment] = useState<string | null>(null);
|
||||
@@ -33,6 +43,12 @@ function RemoveInfo() {
|
||||
const [productSellingPoint, setProductSellingPoint] = useState('');
|
||||
const [productImage, setProductImage] = useState('');
|
||||
const [productImageResourceId, setProductImageResourceId] = useState('');
|
||||
const [videoEngines, setVideoEngines] = useState<any[]>([]);
|
||||
const [engineId, setEngineId] = useState('');
|
||||
const [videoDuration, setVideoDuration] = useState(5);
|
||||
const [videoAspectRatio, setVideoAspectRatio] = useState('16:9');
|
||||
const [videoResolution, setVideoResolution] = useState('480p');
|
||||
const [creditRules, setCreditRules] = useState<any[]>([]);
|
||||
const [taskDetail, setTaskDetail] = useState<any>(null);
|
||||
const [tableData, setTableData] = useState<any[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
@@ -43,6 +59,45 @@ function RemoveInfo() {
|
||||
|
||||
const videoUrl = useMemo(() => buildAssetUrl(taskDetail?.videoUrl), [taskDetail?.videoUrl]);
|
||||
|
||||
useEffect(() => {
|
||||
getEngine().then((data: any) => {
|
||||
const engines = data?.engine?.video || [];
|
||||
setVideoEngines(engines);
|
||||
if (engines.length > 0) {
|
||||
const first = engines[0];
|
||||
setEngineId(first.id);
|
||||
setVideoAspectRatio(first.supportedRatios?.[0] || '16:9');
|
||||
setVideoResolution(first.supportedResolutions?.[0] || '480p');
|
||||
setVideoDuration(first.supportedDurations?.[0] || 5);
|
||||
}
|
||||
}).catch(() => message.error('视频引擎加载失败'));
|
||||
calculateCredits().then((rules: any) => setCreditRules(Array.isArray(rules) ? rules : [])).catch(() => setCreditRules([]));
|
||||
}, []);
|
||||
|
||||
const createIdempotencyRef = useRef<{ segmentId: string; key: string } | null>(null);
|
||||
|
||||
const selectedEngine = videoEngines.find((item: any) => item.id === engineId);
|
||||
const selectedEngineSupportsImage = !productImage || supportsReferenceImage(selectedEngine);
|
||||
const estimatedCredits = useMemo(() => {
|
||||
const rule = creditRules.find((item: any) => item.modelConfigId === engineId && item.genType === 'video' && item.resolution === videoResolution);
|
||||
if (!rule) return 0;
|
||||
let total = (Number(rule.baseCredits || 0) + Number(rule.perSecondCredits || 0) * videoDuration) * Number(rule.ratio || 1);
|
||||
if (productImage) {
|
||||
total += (Number(rule.inputImageBaseCredits || 0) + Number(rule.inputImagePerImageCredits || 0)) * Number(rule.inputImageRatio || 1);
|
||||
}
|
||||
return Number(total.toFixed(2));
|
||||
}, [creditRules, engineId, productImage, videoDuration, videoResolution]);
|
||||
const creditsInsufficient = Number(user?.credits || 0) < estimatedCredits;
|
||||
|
||||
const handleEngineChange = (value: string) => {
|
||||
setEngineId(value);
|
||||
const engine = videoEngines.find((item: any) => item.id === value);
|
||||
if (!engine) return;
|
||||
setVideoAspectRatio(engine.supportedRatios?.[0] || '16:9');
|
||||
setVideoResolution(engine.supportedResolutions?.[0] || '480p');
|
||||
setVideoDuration(engine.supportedDurations?.[0] || 5);
|
||||
};
|
||||
|
||||
const autoSplitButtonText = useMemo(() => {
|
||||
if (tableData.length === 0) {
|
||||
return 'AI自动拆分';
|
||||
@@ -292,13 +347,6 @@ function RemoveInfo() {
|
||||
setProductImageResourceId('');
|
||||
};
|
||||
|
||||
const handleProductImageChange: any = (info: any) => {
|
||||
if (info.fileList.length === 0) {
|
||||
setProductImage('');
|
||||
setProductImageResourceId('');
|
||||
}
|
||||
};
|
||||
|
||||
const beforeUploadProductImage = async (file: File) => {
|
||||
try {
|
||||
const uploadResult = await uploadShotReplicateImage(file);
|
||||
@@ -316,44 +364,53 @@ function RemoveInfo() {
|
||||
message.warning('请先选择拆镜片段');
|
||||
return;
|
||||
}
|
||||
if (!productImage) {
|
||||
message.warning('请上传产品图');
|
||||
if (!engineId) {
|
||||
message.warning('请选择视频引擎');
|
||||
return;
|
||||
}
|
||||
if (!productName.trim()) {
|
||||
message.warning('请输入产品名称');
|
||||
if (!selectedEngineSupportsImage) {
|
||||
message.warning('当前视频引擎不支持参考图片,请移除素材图片或切换引擎');
|
||||
return;
|
||||
}
|
||||
if (!productSellingPoint.trim()) {
|
||||
message.warning('请输入产品卖点');
|
||||
if (creditsInsufficient) {
|
||||
message.warning('积分不足,请充值后再创建复刻项目');
|
||||
return;
|
||||
}
|
||||
|
||||
const existingIdempotency = createIdempotencyRef.current;
|
||||
const idempotencyKey = existingIdempotency?.segmentId === currentSegment
|
||||
? existingIdempotency.key
|
||||
: `shot_v2_${currentSegment}_${Date.now()}_${Math.random().toString(16).slice(2)}`;
|
||||
createIdempotencyRef.current = { segmentId: currentSegment, key: idempotencyKey };
|
||||
|
||||
const params = {
|
||||
|
||||
target_project_name: productName.trim(),
|
||||
core_content_point: productSellingPoint.trim(),
|
||||
material_image_url: productImage,
|
||||
project_description: productSellingPoint.trim() || undefined,
|
||||
material_image_url: productImage || undefined,
|
||||
material_image_resource_id: productImageResourceId || undefined,
|
||||
idempotency_key: `replication_${Date.now()}`,
|
||||
video_config: {
|
||||
engine_id: engineId,
|
||||
duration: videoDuration,
|
||||
aspect_ratio: videoAspectRatio,
|
||||
resolution: videoResolution,
|
||||
},
|
||||
idempotency_key: idempotencyKey,
|
||||
};
|
||||
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
await removeCreate(currentSegment, params);
|
||||
message.success('视频生成任务创建成功');
|
||||
const result = await removeCreate(currentSegment, params);
|
||||
const projectId = result?.projectId || result?.id || result?.detail?.id;
|
||||
createIdempotencyRef.current = null;
|
||||
message.success('项目已创建,视频提词正在生成');
|
||||
handleCloseDrawer();
|
||||
Removelist(creatID).then((res: any) => {
|
||||
|
||||
const targetItem = res.items.find((item: any) => item.id === currentSegment);
|
||||
message.loading('创建中...', 3);
|
||||
|
||||
setTimeout(() => {
|
||||
navigate(`/removelens/${targetItem.moduleProjectId}/removefenbu`);
|
||||
}, 3000);
|
||||
});
|
||||
await fetchSegments();
|
||||
if (projectId) {
|
||||
navigate(`/removelens/${projectId}/removefenbu?flow_version=v2`);
|
||||
}
|
||||
// fetchSegments().then((res: any) => {
|
||||
// console.log('123123123123',res);
|
||||
|
||||
@@ -677,7 +734,7 @@ function RemoveInfo() {
|
||||
{record.moduleProjectId ? (
|
||||
<Button
|
||||
type="text"
|
||||
onClick={() => navigate(`/removelens/${record.moduleProjectId}/removefenbu`)}
|
||||
onClick={() => navigate(`/removelens/${record.moduleProjectId}/removefenbu?flow_version=${record.moduleProjectFlowVersion === 'v2' ? 'v2' : 'v1'}`)}
|
||||
style={{ color: '#10b981', fontSize: 12, padding: 0, display: 'flex', alignItems: 'center', gap: 4 }}
|
||||
>
|
||||
详情任务
|
||||
@@ -1130,65 +1187,47 @@ function RemoveInfo() {
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 20 }}>
|
||||
<div style={{ background: '#fff', borderRadius: 12, padding: 16, border: '1px solid rgba(99, 102, 241, 0.1)' }}>
|
||||
<label style={{ fontWeight: 500, color: '#334155', marginBottom: 8, display: 'block', fontSize: 14 }}>
|
||||
产品白底图 <span style={{ color: '#ef4444' }}>*</span>
|
||||
素材图片(可选)
|
||||
</label>
|
||||
<div style={{ display: 'flex', gap: 12 }}>
|
||||
<div style={{ width: 120, height: 120, borderRadius: 8, border: '2px dashed rgba(99, 102, 241, 0.3)', background: 'rgba(99, 102, 241, 0.02)', position: 'relative', overflow: 'hidden' }}>
|
||||
{productImage ? (
|
||||
<>
|
||||
<img
|
||||
src={buildAssetUrl(productImage)}
|
||||
alt="产品图"
|
||||
style={{ width: '100%', height: '100%', objectFit: 'cover' }}
|
||||
/>
|
||||
<button
|
||||
onClick={() => {
|
||||
setProductImage('');
|
||||
setProductImageResourceId('');
|
||||
}}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
bottom: 4,
|
||||
right: 4,
|
||||
width: 24,
|
||||
height: 24,
|
||||
borderRadius: '50%',
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.5)',
|
||||
border: 'none',
|
||||
cursor: 'pointer',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
}}
|
||||
>
|
||||
<DeleteOutlined style={{ color: '#fff', fontSize: 12 }} />
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<Upload
|
||||
beforeUpload={beforeUploadProductImage}
|
||||
maxCount={1}
|
||||
accept="image/*"
|
||||
style={{ width: '100%', height: '100%' }}
|
||||
>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', gap: 6, width: '100%', height: '100%' }}>
|
||||
<PlusOutlined style={{ fontSize: 20, color: '#6366f1' }} />
|
||||
<span style={{ fontSize: 12, color: '#64748b' }}>上传产品图</span>
|
||||
</div>
|
||||
</Upload>
|
||||
)}
|
||||
{productImage ? (
|
||||
<div style={{ width: 120, height: 120, borderRadius: 8, position: 'relative', overflow: 'hidden' }}>
|
||||
<img src={buildAssetUrl(productImage)} alt="素材图" style={{ width: '100%', height: '100%', objectFit: 'cover' }} />
|
||||
<button onClick={() => { setProductImage(''); setProductImageResourceId(''); }} style={{ position: 'absolute', bottom: 4, right: 4, width: 24, height: 24, borderRadius: '50%', backgroundColor: 'rgba(0,0,0,.5)', border: 'none', cursor: 'pointer' }}>
|
||||
<DeleteOutlined style={{ color: '#fff', fontSize: 12 }} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<UploadSelector
|
||||
accept="image/*"
|
||||
multiple={false}
|
||||
mediaType="image"
|
||||
maxImageCount={1}
|
||||
usedImageCount={0}
|
||||
onLocalSelect={(files) => { if (files[0]) void beforeUploadProductImage(files[0]); }}
|
||||
onHistorySelect={(items) => {
|
||||
const item = items[0];
|
||||
if (!item) return;
|
||||
setProductImage(item.resourceUrl || item.previewUrl || item.displayUrl || '');
|
||||
setProductImageResourceId(item.id);
|
||||
message.success('历史图片已选择');
|
||||
}}
|
||||
>
|
||||
<div style={{ width: 120, height: 120, borderRadius: 8, border: '2px dashed rgba(99,102,241,.3)', display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', cursor: 'pointer', gap: 6 }}>
|
||||
<PlusOutlined style={{ fontSize: 20, color: '#6366f1' }} />
|
||||
<span style={{ fontSize: 12, color: '#64748b' }}>上传或选择历史图片</span>
|
||||
</div>
|
||||
</UploadSelector>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div style={{ background: '#fff', borderRadius: 12, padding: 16, border: '1px solid rgba(99, 102, 241, 0.1)' }}>
|
||||
<label style={{ fontWeight: 500, color: '#334155', marginBottom: 8, display: 'block', fontSize: 14 }}>
|
||||
产品名称 <span style={{ color: '#ef4444' }}>*</span>
|
||||
项目名称(可选)
|
||||
</label>
|
||||
<Input
|
||||
value={productName}
|
||||
onChange={(e) => setProductName(e.target.value)}
|
||||
placeholder="请输入产品名称"
|
||||
placeholder="请输入项目名称"
|
||||
style={{ height: 44, borderRadius: 8, border: '1px solid rgba(99, 102, 241, 0.2)' }}
|
||||
maxLength={10}
|
||||
showCount
|
||||
@@ -1197,12 +1236,12 @@ function RemoveInfo() {
|
||||
|
||||
<div style={{ background: '#fff', borderRadius: 12, padding: 16, border: '1px solid rgba(99, 102, 241, 0.1)' }}>
|
||||
<label style={{ fontWeight: 500, color: '#334155', marginBottom: 8, display: 'block', fontSize: 14 }}>
|
||||
产品卖点 <span style={{ color: '#ef4444' }}>*</span>
|
||||
项目描述(可选)
|
||||
</label>
|
||||
<TextArea
|
||||
value={productSellingPoint}
|
||||
onChange={(e) => setProductSellingPoint(e.target.value)}
|
||||
placeholder="请输入产品卖点"
|
||||
placeholder="请输入项目描述"
|
||||
style={{ borderRadius: 8, border: '1px solid rgba(99, 102, 241, 0.2)' }}
|
||||
maxLength={100}
|
||||
showCount
|
||||
@@ -1210,12 +1249,37 @@ function RemoveInfo() {
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div style={{ background: '#fff', borderRadius: 12, padding: 16, border: '1px solid rgba(99, 102, 241, 0.1)' }}>
|
||||
<label style={{ fontWeight: 500, color: '#334155', marginBottom: 8, display: 'block', fontSize: 14 }}>视频参数</label>
|
||||
<Select
|
||||
value={engineId || undefined}
|
||||
onChange={handleEngineChange}
|
||||
placeholder="选择视频引擎"
|
||||
style={{ width: '100%', marginBottom: 12 }}
|
||||
options={videoEngines.map((item: any) => ({
|
||||
value: item.id,
|
||||
label: productImage && !supportsReferenceImage(item) ? `${item.name}(不支持参考图)` : item.name,
|
||||
disabled: Boolean(productImage) && !supportsReferenceImage(item),
|
||||
}))}
|
||||
/>
|
||||
<div style={{ display: 'flex', gap: 8, marginBottom: 12 }}>
|
||||
<Select value={videoDuration} onChange={setVideoDuration} style={{ flex: 1 }} options={(selectedEngine?.supportedDurations || [5]).map((item: number) => ({ value: item, label: `${item}秒` }))} />
|
||||
<Select value={videoAspectRatio} onChange={setVideoAspectRatio} style={{ flex: 1 }} options={(selectedEngine?.supportedRatios || ['16:9']).map((item: string) => ({ value: item, label: item }))} />
|
||||
<Select value={videoResolution} onChange={setVideoResolution} style={{ flex: 1 }} options={(selectedEngine?.supportedResolutions || ['480p']).map((item: string) => ({ value: item, label: item }))} />
|
||||
</div>
|
||||
<div style={{ color: creditsInsufficient || !selectedEngineSupportsImage ? '#ef4444' : '#6366f1', fontSize: 13 }}>
|
||||
{selectedEngineSupportsImage
|
||||
? `预估积分:${estimatedCredits},当前积分:${Number(user?.credits || 0).toFixed(2)}`
|
||||
: '当前引擎不支持参考图片,请移除素材图片或切换引擎。'}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', gap: 12, marginTop: 8 }}>
|
||||
<Button
|
||||
type="primary"
|
||||
onClick={handleManualGenerate}
|
||||
loading={loading}
|
||||
disabled={loading}
|
||||
disabled={loading || creditsInsufficient || !engineId || !selectedEngineSupportsImage}
|
||||
style={{
|
||||
flex: 1,
|
||||
height: 48,
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import React, { useState, useEffect, useRef } from 'react';
|
||||
import { Button, Typography, Collapse, Space, Modal, Input, Table, message, Tooltip } from 'antd';
|
||||
import { ArrowLeftOutlined, PlayCircleOutlined, CheckCircleOutlined, EditOutlined, DownloadOutlined, SettingOutlined, LayoutOutlined } from '@ant-design/icons';
|
||||
import { useNavigate, useParams } from 'react-router-dom';
|
||||
import { getShotReplicationList, removeDetail, removeone, removetwo, removethree, removefour, getEngine, updateShotImagePrompt, updateShotVideoPromptSchema, calculateCredits } from '../api/index';
|
||||
import { useNavigate, useParams, useSearchParams } from 'react-router-dom';
|
||||
import { getShotReplicationList, removeDetail, removeone, removetwo, removethree, removefour, getEngine, updateShotImagePrompt, updateShotVideoPromptSchema, calculateCredits, retryShotVideoPromptV2, updateShotVideoPromptSchemaV2, generateShotVideoV2 } from '../api/index';
|
||||
import { useAuthStore } from '../store/useAuthStore';
|
||||
import VideoPromptSchemaEditor from '../components/VideoPromptSchemaEditor';
|
||||
import { validateVideoPromptSchemaByConfig } from '../utils/videoPromptSchema';
|
||||
@@ -25,6 +25,8 @@ const buildMediaUrl = (url: string): string => {
|
||||
function InitialInfo() {
|
||||
const navigate = useNavigate();
|
||||
const { creatID } = useParams<{ creatID: string }>();
|
||||
const [searchParams] = useSearchParams();
|
||||
const flowVersion: 'v1' | 'v2' = searchParams.get('flow_version') === 'v2' ? 'v2' : 'v1';
|
||||
const { user } = useAuthStore();
|
||||
|
||||
const [modalVisible, setModalVisible] = useState(false);
|
||||
@@ -35,9 +37,12 @@ function InitialInfo() {
|
||||
const [currentType, setCurrentType] = useState<string>('image');
|
||||
const [formData, setFormData] = useState<any>({});
|
||||
const [videoSchemaConfigSnapshot, setVideoSchemaConfigSnapshot] = useState<any>(null);
|
||||
const [pollingTimer, setPollingTimer] = useState<any>(null);
|
||||
const pollingTimerRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
const [editingPromptStepId, setEditingPromptStepId] = useState('');
|
||||
const [promptSaving, setPromptSaving] = useState(false);
|
||||
const [retryPromptModalVisible, setRetryPromptModalVisible] = useState(false);
|
||||
const [retryPromptStepId, setRetryPromptStepId] = useState<string>('');
|
||||
const [retryPromptSubmitting, setRetryPromptSubmitting] = useState(false);
|
||||
|
||||
// 引擎和视频参数相关状态
|
||||
const [enginesele, setEnginesele] = useState<any>({});
|
||||
@@ -111,14 +116,21 @@ function InitialInfo() {
|
||||
}
|
||||
}, [previewVisible, previewType]);
|
||||
|
||||
//
|
||||
const baseSteps = [
|
||||
{ id: 1, title: '原始素材', description: '上传原始视频素材', childId: 1 },
|
||||
{ id: 2, title: '生成提示词', description: '根据素材生成描述词', childId: 2 },
|
||||
{ id: 3, title: '生成产品融合图', description: '生成产品与场景融合图', childId: 3 },
|
||||
{ id: 4, title: '生成视频提示词', description: '生成视频生成提示词', childId: 4 },
|
||||
{ id: 5, title: '生成最终视频', description: '合成最终视频', childId: 5 },
|
||||
];
|
||||
const isV2 = flowVersion === 'v2';
|
||||
|
||||
const baseSteps = isV2
|
||||
? [
|
||||
{ id: 1, title: '素材与项目信息', description: '固定片段素材和项目描述', childId: 1 },
|
||||
{ id: 2, title: '生成视频提示词', description: '生成并确认视频提示词', childId: 4 },
|
||||
{ id: 3, title: '生成最终视频', description: '使用当前视频提示词生成视频', childId: 5 },
|
||||
]
|
||||
: [
|
||||
{ id: 1, title: '原始素材', description: '上传原始视频素材', childId: 1 },
|
||||
{ id: 2, title: '生成提示词', description: '根据素材生成描述词', childId: 2 },
|
||||
{ id: 3, title: '生成产品融合图', description: '生成产品与场景融合图', childId: 3 },
|
||||
{ id: 4, title: '生成视频提示词', description: '生成视频生成提示词', childId: 4 },
|
||||
{ id: 5, title: '生成最终视频', description: '合成最终视频', childId: 5 },
|
||||
];
|
||||
|
||||
// 合并基础步骤和API返回的状态
|
||||
const steps = baseSteps.map((step, index) => ({
|
||||
@@ -223,12 +235,12 @@ function InitialInfo() {
|
||||
// 组件卸载时清理定时器
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (pollingTimer) {
|
||||
clearInterval(pollingTimer);
|
||||
setPollingTimer(null);
|
||||
if (pollingTimerRef.current) {
|
||||
clearInterval(pollingTimerRef.current);
|
||||
pollingTimerRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [pollingTimer]);
|
||||
}, []);
|
||||
|
||||
// 点击外部关闭弹窗
|
||||
useEffect(() => {
|
||||
@@ -250,39 +262,30 @@ function InitialInfo() {
|
||||
|
||||
|
||||
if (creatID) {
|
||||
removeDetail(creatID).then((res: any) => {
|
||||
removeDetail(creatID, flowVersion).then((res: any) => {
|
||||
setTaskDetail(res);
|
||||
if (res.steps) {
|
||||
setApiSteps(res.steps);
|
||||
}
|
||||
}).catch((error: any) => {
|
||||
message.error(error?.message || '加载任务详情失败');
|
||||
});
|
||||
}
|
||||
}, [creatID]);
|
||||
}, [creatID, flowVersion]);
|
||||
|
||||
// 监听合并后的 steps 数据,判断第五步状态并启动/停止轮询
|
||||
// 仅在项目或任一步骤处于 processing 时轮询;waiting_user 必须停止。
|
||||
useEffect(() => {
|
||||
const shouldPoll = taskDetail?.status === 'processing'
|
||||
|| apiSteps.some((step: any) => step?.status === 'processing');
|
||||
|
||||
// 检查第五步的状态(索引 4)
|
||||
const fifthStep = steps[4];
|
||||
|
||||
if (fifthStep && fifthStep.status !== 'completed' && fifthStep.status !== 'failed') {
|
||||
// 第五步未完成,启动轮询
|
||||
if (!pollingTimer) {
|
||||
const timer = setInterval(pollTaskDetail, 30000);
|
||||
setPollingTimer(timer);
|
||||
} else {
|
||||
}
|
||||
} else {
|
||||
// 第五步已完成或失败,停止轮询
|
||||
if (fifthStep) {
|
||||
if (pollingTimer) {
|
||||
clearInterval(pollingTimer);
|
||||
setPollingTimer(null);
|
||||
}
|
||||
}
|
||||
if (shouldPoll && !pollingTimerRef.current) {
|
||||
pollingTimerRef.current = setInterval(pollTaskDetail, 30000);
|
||||
} else if (!shouldPoll && pollingTimerRef.current) {
|
||||
clearInterval(pollingTimerRef.current);
|
||||
pollingTimerRef.current = null;
|
||||
}
|
||||
}, [steps]);
|
||||
}, [taskDetail?.status, apiSteps, creatID, flowVersion]);
|
||||
|
||||
|
||||
useEffect(() => {
|
||||
calculateCredits().then((data: any) => {
|
||||
@@ -320,7 +323,9 @@ function InitialInfo() {
|
||||
total += inputVideoCost;
|
||||
}
|
||||
|
||||
const inputImageCount = taskDetail?.videoGeneration?.inputMedia?.image?.length || 0;
|
||||
const inputImageCount = isV2
|
||||
? (taskDetail?.material?.materialImageUrl ? 1 : 0)
|
||||
: (taskDetail?.videoGeneration?.inputMedia?.image?.length || 0);
|
||||
if (inputImageCount > 0) {
|
||||
const inputImageCost = ((config.inputImageBaseCredits || 0) + (config.inputImagePerImageCredits || 0) * inputImageCount) * (config.inputImageRatio || 1);
|
||||
total += inputImageCost;
|
||||
@@ -399,7 +404,8 @@ function InitialInfo() {
|
||||
|
||||
setPromptSaving(true);
|
||||
try {
|
||||
await updateShotVideoPromptSchema(taskDetail.id, editingPromptStepId, {
|
||||
const updateVideoPrompt = isV2 ? updateShotVideoPromptSchemaV2 : updateShotVideoPromptSchema;
|
||||
await updateVideoPrompt(taskDetail.id, editingPromptStepId, {
|
||||
prompt_schema: formData,
|
||||
});
|
||||
message.success('视频提示词已保存');
|
||||
@@ -419,12 +425,13 @@ function InitialInfo() {
|
||||
return;
|
||||
}
|
||||
|
||||
removeDetail(creatID).then((res: any) => {
|
||||
removeDetail(creatID, flowVersion).then((res: any) => {
|
||||
setTaskDetail(res);
|
||||
if (res.steps) {
|
||||
setApiSteps(res.steps);
|
||||
}
|
||||
}).catch((error: any) => {
|
||||
console.error('轮询任务详情失败', error);
|
||||
});
|
||||
};
|
||||
|
||||
@@ -432,12 +439,13 @@ function InitialInfo() {
|
||||
const refreshTaskDetail = () => {
|
||||
if (!creatID) return;
|
||||
|
||||
removeDetail(creatID).then((res: any) => {
|
||||
removeDetail(creatID, flowVersion).then((res: any) => {
|
||||
setTaskDetail(res);
|
||||
if (res.steps) {
|
||||
setApiSteps(res.steps);
|
||||
}
|
||||
}).catch((error: any) => {
|
||||
message.error(error?.message || '刷新任务详情失败');
|
||||
});
|
||||
};
|
||||
|
||||
@@ -540,34 +548,109 @@ function InitialInfo() {
|
||||
// 这里可以添加下一步的逻辑,比如调用接口等
|
||||
};
|
||||
|
||||
const createvideo = (stepId: number, engineId: string) => {
|
||||
let params = {
|
||||
engine_id: '',
|
||||
}
|
||||
|
||||
|
||||
removefour(taskDetail.id, stepId.toString(), params).then((res: any) => {
|
||||
// 重新获取任务详情以更新数据
|
||||
const createvideo = async (stepId: number, engineId: string) => {
|
||||
try {
|
||||
if (isV2) {
|
||||
await generateShotVideoV2(taskDetail.id, String(stepId));
|
||||
} else {
|
||||
await removefour(taskDetail.id, String(stepId), { engine_id: engineId || '' });
|
||||
}
|
||||
message.info('正在生成视频,请稍候...');
|
||||
refreshTaskDetail();
|
||||
}).catch((error: any) => {
|
||||
const errorMsg = error?.message?.split(': ')?.[1] || error?.message || '生成失败';
|
||||
} catch (error: any) {
|
||||
const errorMsg = error?.message?.split(': ')?.[1] || error?.message || '生成失败';
|
||||
message.error(errorMsg);
|
||||
|
||||
});
|
||||
}
|
||||
const agincreatevideo = () => {
|
||||
let params = {
|
||||
engine_id: '',
|
||||
}
|
||||
};
|
||||
|
||||
const agincreatevideo = async () => {
|
||||
const promptStep = isV2 ? steps[1] : steps[3];
|
||||
if (!promptStep?.id) return;
|
||||
await createvideo(promptStep.id, promptStep.engineId || '');
|
||||
};
|
||||
|
||||
removefour(taskDetail.id, steps[3].id.toString(), params).then((res: any) => {
|
||||
// 重新获取任务详情以更新数据
|
||||
const applyRetryVideoEngine = (
|
||||
engineId: string,
|
||||
preferred?: { duration?: number; aspectRatio?: string; resolution?: string },
|
||||
) => {
|
||||
const engine = (enginesele.video || []).find((item: any) => String(item.id) === String(engineId));
|
||||
if (!engine) {
|
||||
return false;
|
||||
}
|
||||
const ratios = Array.isArray(engine.supportedRatios) && engine.supportedRatios.length > 0
|
||||
? engine.supportedRatios.map((item: any) => String(item))
|
||||
: ['16:9', '4:3', '1:1', '3:4', '9:16', '21:9'];
|
||||
const resolutions = Array.isArray(engine.supportedResolutions) && engine.supportedResolutions.length > 0
|
||||
? engine.supportedResolutions.map((item: any) => String(item))
|
||||
: ['480p', '720p', '1080p'];
|
||||
const parsedDurations = Array.isArray(engine.supportedDurations)
|
||||
? engine.supportedDurations
|
||||
.map((item: any) => Number(item))
|
||||
.filter((item: number) => Number.isFinite(item) && item > 0)
|
||||
: [];
|
||||
const durations = parsedDurations.length > 0 ? parsedDurations : [5, 8, 10, 12, 15];
|
||||
|
||||
const preferredDuration = Number(preferred?.duration ?? videoDuration);
|
||||
const preferredRatio = String(preferred?.aspectRatio || videoAspectRatio || '');
|
||||
const preferredResolution = String(preferred?.resolution || videoResolution || '');
|
||||
|
||||
setCountType(String(engine.id));
|
||||
setEngineOptions({ ratios, resolutions, durations });
|
||||
setVideoDuration(durations.includes(preferredDuration) ? preferredDuration : durations[0]);
|
||||
setVideoAspectRatio(ratios.includes(preferredRatio) ? preferredRatio : ratios[0]);
|
||||
setVideoResolution(resolutions.includes(preferredResolution) ? preferredResolution : resolutions[0]);
|
||||
return true;
|
||||
};
|
||||
|
||||
const openRegenerateVideoPrompt = (stepId: number) => {
|
||||
if (!isV2 || !taskDetail?.id) return;
|
||||
const currentConfig = taskDetail?.videoGeneration?.promptParams || taskDetail?.videoGeneration?.params || {};
|
||||
const requestedEngineId = String(
|
||||
currentConfig.engineId
|
||||
|| currentConfig.engine_id
|
||||
|| taskDetail?.videoGeneration?.engineId
|
||||
|| countType
|
||||
|| '',
|
||||
);
|
||||
const currentEngineId = String(
|
||||
(enginesele.video || []).some((engine: any) => String(engine.id) === requestedEngineId)
|
||||
? requestedEngineId
|
||||
: enginesele.video?.[0]?.id || '',
|
||||
);
|
||||
if (!currentEngineId || !applyRetryVideoEngine(currentEngineId, {
|
||||
duration: Number(currentConfig.duration || videoDuration),
|
||||
aspectRatio: String(currentConfig.aspectRatio || currentConfig.aspect_ratio || videoAspectRatio),
|
||||
resolution: String(currentConfig.resolution || videoResolution),
|
||||
})) {
|
||||
message.warning('当前没有可用的视频生成引擎');
|
||||
return;
|
||||
}
|
||||
setRetryPromptStepId(String(stepId));
|
||||
setRetryPromptModalVisible(true);
|
||||
};
|
||||
|
||||
const submitRegenerateVideoPrompt = async () => {
|
||||
if (!isV2 || !taskDetail?.id || !retryPromptStepId || !countType) return;
|
||||
setRetryPromptSubmitting(true);
|
||||
try {
|
||||
await retryShotVideoPromptV2(taskDetail.id, retryPromptStepId, {
|
||||
video_config: {
|
||||
engine_id: countType,
|
||||
duration: videoDuration,
|
||||
aspect_ratio: videoAspectRatio,
|
||||
resolution: videoResolution,
|
||||
},
|
||||
});
|
||||
message.info('正在按新视频参数重新生成视频提示词,请稍候...');
|
||||
setRetryPromptModalVisible(false);
|
||||
setRetryPromptStepId('');
|
||||
refreshTaskDetail();
|
||||
}).catch((error: any) => {
|
||||
});
|
||||
}
|
||||
} catch (error: any) {
|
||||
message.error(error?.message || '重新生成视频提示词失败');
|
||||
} finally {
|
||||
setRetryPromptSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
|
||||
@@ -1298,6 +1381,11 @@ function InitialInfo() {
|
||||
>
|
||||
查看/修改视频提词
|
||||
</Button>
|
||||
{isV2 && (
|
||||
<Button type="default" onClick={() => openRegenerateVideoPrompt(step.id)} style={{ flex: 1, borderRadius: 10, borderColor: 'rgba(99, 102, 241, 0.3)', color: '#6366f1', height: 36, fontWeight: 500 }} disabled={step.status === 'processing' || steps[2]?.status === 'processing'}>
|
||||
重新生成视频提词
|
||||
</Button>
|
||||
)}
|
||||
<Button onClick={() => { createvideo(step.id, step.engineId); }} type="primary" style={{ flex: 1, borderRadius: 10, background: 'linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%)', border: 'none', height: 36, fontWeight: 500, boxShadow: '0 4px 12px rgba(99, 102, 241, 0.3)' }} disabled={step.status !== 'completed'}>
|
||||
下一步:生成视频
|
||||
</Button>
|
||||
@@ -1322,8 +1410,8 @@ function InitialInfo() {
|
||||
)}
|
||||
</div>
|
||||
<Space style={{ width: '100%', gap: 12 }}>
|
||||
<Button onClick={() => agincreatevideo()} type="default" icon={<EditOutlined />} style={{ flex: 1, borderRadius: 10, borderColor: 'rgba(99, 102, 241, 0.3)', color: '#6366f1', height: 36, fontWeight: 500, background: 'rgba(99, 102, 241, 0.04)' }} disabled={step.status !== 'completed'}>
|
||||
重新生成
|
||||
<Button onClick={() => agincreatevideo()} type="default" icon={<EditOutlined />} style={{ flex: 1, borderRadius: 10, borderColor: 'rgba(99, 102, 241, 0.3)', color: '#6366f1', height: 36, fontWeight: 500, background: 'rgba(99, 102, 241, 0.04)' }} disabled={isV2 ? !['completed', 'failed'].includes(step.status) : step.status !== 'completed'}>
|
||||
{step.status === 'failed' ? '重试生成' : '重新生成'}
|
||||
</Button>
|
||||
<Button
|
||||
type="primary"
|
||||
@@ -1353,6 +1441,89 @@ function InitialInfo() {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<Modal
|
||||
title="重新生成视频提词"
|
||||
open={retryPromptModalVisible}
|
||||
onCancel={() => {
|
||||
if (!retryPromptSubmitting) {
|
||||
setRetryPromptModalVisible(false);
|
||||
setRetryPromptStepId('');
|
||||
}
|
||||
}}
|
||||
onOk={submitRegenerateVideoPrompt}
|
||||
confirmLoading={retryPromptSubmitting}
|
||||
okText="按新参数生成提词"
|
||||
cancelText="取消"
|
||||
width={720}
|
||||
destroyOnClose
|
||||
>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 20 }}>
|
||||
<div>
|
||||
<Text strong style={{ display: 'block', marginBottom: 10 }}>视频引擎</Text>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(2, minmax(0, 1fr))', gap: 10 }}>
|
||||
{(enginesele.video || []).map((engine: any) => (
|
||||
<button
|
||||
key={engine.id}
|
||||
type="button"
|
||||
onClick={() => applyRetryVideoEngine(String(engine.id), {
|
||||
duration: videoDuration,
|
||||
aspectRatio: videoAspectRatio,
|
||||
resolution: videoResolution,
|
||||
})}
|
||||
style={{
|
||||
minHeight: 46,
|
||||
padding: '8px 12px',
|
||||
borderRadius: 8,
|
||||
border: countType === String(engine.id) ? '2px solid #6366f1' : '1px solid #e5e7eb',
|
||||
background: countType === String(engine.id) ? 'rgba(99,102,241,0.08)' : '#fff',
|
||||
color: countType === String(engine.id) ? '#4f46e5' : '#374151',
|
||||
cursor: 'pointer',
|
||||
textAlign: 'left',
|
||||
fontWeight: countType === String(engine.id) ? 600 : 400,
|
||||
}}
|
||||
>
|
||||
{engine.name || engine.id}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<Text strong style={{ display: 'block', marginBottom: 10 }}>视频时长</Text>
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 8 }}>
|
||||
{engineOptions.durations.map((duration) => (
|
||||
<Button key={duration} type={videoDuration === duration ? 'primary' : 'default'} onClick={() => setVideoDuration(duration)}>
|
||||
{duration} 秒
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<Text strong style={{ display: 'block', marginBottom: 10 }}>画面比例</Text>
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 8 }}>
|
||||
{engineOptions.ratios.map((ratio) => (
|
||||
<Button key={ratio} type={videoAspectRatio === ratio ? 'primary' : 'default'} onClick={() => setVideoAspectRatio(ratio)}>
|
||||
{ratio}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<Text strong style={{ display: 'block', marginBottom: 10 }}>分辨率</Text>
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 8 }}>
|
||||
{engineOptions.resolutions.map((resolution) => (
|
||||
<Button key={resolution} type={videoResolution === resolution ? 'primary' : 'default'} onClick={() => setVideoResolution(resolution)}>
|
||||
{resolution}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ padding: '10px 12px', borderRadius: 8, background: '#f8fafc', color: '#64748b' }}>
|
||||
当前参数:{enginesele.video?.find((engine: any) => String(engine.id) === countType)?.name || countType}
|
||||
{' · '}{videoDuration} 秒 · {videoAspectRatio} · {videoResolution}
|
||||
<span style={{ marginLeft: 12 }}>最终视频预估积分:{estimatedCredits}</span>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
<Modal
|
||||
title={
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
|
||||
|
||||
@@ -169,7 +169,10 @@ export interface GenerationRecord {
|
||||
imageProportion: string;
|
||||
imagePx: string;
|
||||
imageUrl: string;
|
||||
|
||||
engineId?: string;
|
||||
engineName?: string;
|
||||
engineSnapshot?: Record<string, any>;
|
||||
includeMediaReferences?: boolean;
|
||||
}
|
||||
|
||||
export interface OptimizeParams {
|
||||
@@ -189,8 +192,31 @@ export interface OptimizeParams {
|
||||
}
|
||||
|
||||
export interface GenerateParams {
|
||||
engineId?: string;
|
||||
includeMediaReferences?: boolean;
|
||||
aspectRatio?: AspectRatio;
|
||||
resolution?: Resolution;
|
||||
imageSize?: string;
|
||||
}
|
||||
|
||||
export type ModuleGenerationFlowVersion = 'v1' | 'v2';
|
||||
|
||||
export interface ModuleGenerationVideoConfigV2 {
|
||||
engineId: string;
|
||||
duration: number;
|
||||
aspectRatio: string;
|
||||
resolution: string;
|
||||
}
|
||||
|
||||
export interface ModuleGenerationProjectV2Create {
|
||||
materialImageUrl?: string;
|
||||
materialImageResourceId?: string;
|
||||
targetProjectName?: string;
|
||||
coreContentPoint?: string;
|
||||
projectDescription?: string;
|
||||
targetPlatform?: string;
|
||||
videoConfig: ModuleGenerationVideoConfigV2;
|
||||
idempotencyKey?: string;
|
||||
}
|
||||
|
||||
export interface OptimizeResult {
|
||||
|
||||
Reference in New Issue
Block a user