爆款/拆镜生成简化3个步骤 | 项目生成可携带附件控制

This commit is contained in:
2026-07-21 14:01:08 +08:00
parent 40efcf55cf
commit 79c09151ba
60 changed files with 4250 additions and 924 deletions
+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 {