This commit is contained in:
2026-07-21 19:22:50 +08:00
parent b374a541ea
commit 7683ebb186
65 changed files with 5067 additions and 1741 deletions
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+36 -36
View File
@@ -1,37 +1,37 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Outfit:wght@300;400;500;600;700&display=swap" rel="stylesheet" />
<title>民众智创</title>
<script>
(function() {
var cached = localStorage.getItem('siteInfo');
if (cached) {
try {
var info = JSON.parse(cached);
if (info.siteName) {
document.title = info.siteName;
}
if (info.siteLogo) {
var link = document.querySelector('link[rel="icon"]');
if (link) {
link.href = info.siteLogo;
link.type = 'image/png';
}
}
} catch (e) {}
}
})();
</script>
<script type="module" crossorigin src="/assets/index-D7Lr6Blo.js"></script>
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Outfit:wght@300;400;500;600;700&display=swap" rel="stylesheet" />
<title>民众智创</title>
<script>
(function() {
var cached = localStorage.getItem('siteInfo');
if (cached) {
try {
var info = JSON.parse(cached);
if (info.siteName) {
document.title = info.siteName;
}
if (info.siteLogo) {
var link = document.querySelector('link[rel="icon"]');
if (link) {
link.href = info.siteLogo;
link.type = 'image/png';
}
}
} catch (e) {}
}
})();
</script>
<script type="module" crossorigin src="/assets/index-DZg1zviY.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-Bsz_Xon-.css">
</head>
<body>
<div id="root"></div>
</body>
</html>
</head>
<body>
<div id="root"></div>
</body>
</html>
+63 -9
View File
@@ -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}`);
}
// 获取地区信息
+215 -31
View File
@@ -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 {
+2 -2
View File
@@ -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') {
+256 -67
View File
@@ -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 }}
>
+120 -78
View File
@@ -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) {
+145 -81
View File
@@ -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,
+236 -65
View File
@@ -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 }}>
+27 -1
View File
@@ -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 {