dev app commit
This commit is contained in:
@@ -393,6 +393,11 @@ export async function getfour(projectId: string, stepId: string ,params: any): P
|
|||||||
return api.post(`/hot-opening-replications/tasks/${projectId}/steps/${stepId}/generate-video`, params);
|
return api.post(`/hot-opening-replications/tasks/${projectId}/steps/${stepId}/generate-video`, params);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 修改第四步视频 AI 提词 JSON schema
|
||||||
|
export async function updateHotOpeningVideoPromptSchema(projectId: string, stepId: string, params: { prompt_schema: Record<string, any> }): Promise<any> {
|
||||||
|
return api.put(`/hot-opening-replications/tasks/${projectId}/steps/${stepId}/video-prompt-schema`, params);
|
||||||
|
}
|
||||||
|
|
||||||
// 镜头复刻
|
// 镜头复刻
|
||||||
export async function createShotReplication(params: any): Promise<any> {
|
export async function createShotReplication(params: any): Promise<any> {
|
||||||
return api.post('/shot-replications/task-sets', params);
|
return api.post('/shot-replications/task-sets', params);
|
||||||
|
|||||||
@@ -0,0 +1,274 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { Button, Input, Space, Tag, Typography } from 'antd';
|
||||||
|
|
||||||
|
const { Text } = Typography;
|
||||||
|
const { TextArea } = Input;
|
||||||
|
|
||||||
|
type JsonValue = any;
|
||||||
|
|
||||||
|
type VideoPromptSchemaEditorProps = {
|
||||||
|
value: Record<string, JsonValue>;
|
||||||
|
onChange: (nextValue: Record<string, JsonValue>) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
const LOCKED_TOP_LEVEL_KEYS = new Set([
|
||||||
|
'schema_version',
|
||||||
|
'schema_usage',
|
||||||
|
'动态时间规划',
|
||||||
|
'输出规格限制',
|
||||||
|
'合规控制',
|
||||||
|
'质量控制',
|
||||||
|
]);
|
||||||
|
|
||||||
|
const LOCKED_FRAME_KEYS = new Set([
|
||||||
|
'视频时长',
|
||||||
|
'视频比例',
|
||||||
|
'清晰度',
|
||||||
|
'帧率',
|
||||||
|
'推荐分辨率',
|
||||||
|
]);
|
||||||
|
|
||||||
|
const EDITABLE_FRAME_KEYS = new Set([
|
||||||
|
'主体描述',
|
||||||
|
'主体数量',
|
||||||
|
'主体位置',
|
||||||
|
'主体占比',
|
||||||
|
'场景描述',
|
||||||
|
'构图方式',
|
||||||
|
'画面风格',
|
||||||
|
'光影色彩',
|
||||||
|
]);
|
||||||
|
|
||||||
|
const EDITABLE_FINAL_PROMPT_KEYS = new Set([
|
||||||
|
'主提示词',
|
||||||
|
'动作提示词',
|
||||||
|
'镜头提示词',
|
||||||
|
'字幕提示词',
|
||||||
|
'音频提示词',
|
||||||
|
'风格提示词',
|
||||||
|
'负面提示词',
|
||||||
|
]);
|
||||||
|
|
||||||
|
function clonePlain<T>(value: T): T {
|
||||||
|
return value === undefined ? value : JSON.parse(JSON.stringify(value));
|
||||||
|
}
|
||||||
|
|
||||||
|
function pathIncludes(path: Array<string | number>, key: string): boolean {
|
||||||
|
return path.some((item) => String(item) === key);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getPathValue(root: JsonValue, path: Array<string | number>): JsonValue {
|
||||||
|
let current = root;
|
||||||
|
for (const key of path) {
|
||||||
|
if (current === undefined || current === null) return undefined;
|
||||||
|
current = current[key as keyof typeof current];
|
||||||
|
}
|
||||||
|
return current;
|
||||||
|
}
|
||||||
|
|
||||||
|
function setPathValue(root: JsonValue, path: Array<string | number>, value: JsonValue): JsonValue {
|
||||||
|
const next = clonePlain(root);
|
||||||
|
let current = next;
|
||||||
|
for (let index = 0; index < path.length - 1; index += 1) {
|
||||||
|
current = current[path[index] as keyof typeof current];
|
||||||
|
}
|
||||||
|
current[path[path.length - 1] as keyof typeof current] = value;
|
||||||
|
return next;
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeArrayItem(root: JsonValue, path: Array<string | number>, index: number): JsonValue {
|
||||||
|
const arrayValue = getPathValue(root, path);
|
||||||
|
if (!Array.isArray(arrayValue)) return root;
|
||||||
|
return setPathValue(root, path, arrayValue.filter((_, itemIndex) => itemIndex !== index));
|
||||||
|
}
|
||||||
|
|
||||||
|
function addArrayItem(root: JsonValue, path: Array<string | number>, sampleValue: JsonValue): JsonValue {
|
||||||
|
const arrayValue = getPathValue(root, path);
|
||||||
|
if (!Array.isArray(arrayValue)) return root;
|
||||||
|
const nextItem = typeof sampleValue === 'object' && sampleValue !== null ? clonePlain(sampleValue) : '';
|
||||||
|
return setPathValue(root, path, [...arrayValue, nextItem]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function stringifyReadonly(value: JsonValue): string {
|
||||||
|
if (value === null || value === undefined) return '';
|
||||||
|
if (typeof value === 'object') return JSON.stringify(value, null, 2);
|
||||||
|
return String(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function shouldUseTextArea(value: JsonValue): boolean {
|
||||||
|
const text = stringifyReadonly(value);
|
||||||
|
return text.length > 40 || text.includes('\n') || text.includes(',') || text.includes('。');
|
||||||
|
}
|
||||||
|
|
||||||
|
function isLockedPath(path: Array<string | number>): boolean {
|
||||||
|
const rootKey = String(path[0] ?? '');
|
||||||
|
const currentKey = String(path[path.length - 1] ?? '');
|
||||||
|
|
||||||
|
if (LOCKED_TOP_LEVEL_KEYS.has(rootKey)) return true;
|
||||||
|
if (rootKey === '画面属性') {
|
||||||
|
if (LOCKED_FRAME_KEYS.has(currentKey)) return true;
|
||||||
|
if (!EDITABLE_FRAME_KEYS.has(currentKey)) return false;
|
||||||
|
}
|
||||||
|
if (rootKey === '最终提示词' && path.length === 2) {
|
||||||
|
return !EDITABLE_FINAL_PROMPT_KEYS.has(currentKey);
|
||||||
|
}
|
||||||
|
if ((rootKey === '动作流程' || rootKey === '镜头流程') && currentKey === '时间段') {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function canAddOrRemoveArray(path: Array<string | number>): boolean {
|
||||||
|
const rootKey = String(path[0] ?? '');
|
||||||
|
if (LOCKED_TOP_LEVEL_KEYS.has(rootKey)) return false;
|
||||||
|
if (rootKey === '动作流程' || rootKey === '镜头流程') return false;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function fieldTitle(key: string | number): string {
|
||||||
|
return typeof key === 'number' ? `第 ${key + 1} 项` : key;
|
||||||
|
}
|
||||||
|
|
||||||
|
function ReadonlyBlock({ value }: { value: JsonValue }) {
|
||||||
|
const text = stringifyReadonly(value);
|
||||||
|
return shouldUseTextArea(value) ? (
|
||||||
|
<TextArea value={text} rows={Math.min(6, Math.max(2, Math.ceil(text.length / 42)))} disabled style={{ borderRadius: 8, color: '#64748b' }} />
|
||||||
|
) : (
|
||||||
|
<Input value={text} disabled style={{ borderRadius: 8, color: '#64748b' }} />
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function EditableInput({ value, onChange }: { value: JsonValue; onChange: (nextValue: JsonValue) => void }) {
|
||||||
|
const text = stringifyReadonly(value);
|
||||||
|
if (shouldUseTextArea(value)) {
|
||||||
|
return (
|
||||||
|
<TextArea
|
||||||
|
value={text}
|
||||||
|
onChange={(event) => onChange(event.target.value)}
|
||||||
|
rows={Math.min(8, Math.max(3, Math.ceil(text.length / 42)))}
|
||||||
|
style={{ borderRadius: 8 }}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return <Input value={text} onChange={(event) => onChange(event.target.value)} style={{ borderRadius: 8 }} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
const VideoPromptSchemaEditor: React.FC<VideoPromptSchemaEditorProps> = ({ value, onChange }) => {
|
||||||
|
const safeValue = value && typeof value === 'object' ? value : {};
|
||||||
|
|
||||||
|
const updatePath = (path: Array<string | number>, nextValue: JsonValue) => {
|
||||||
|
onChange(setPathValue(safeValue, path, nextValue));
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderNode = (key: string | number, nodeValue: JsonValue, path: Array<string | number>, depth = 0): React.ReactNode => {
|
||||||
|
const locked = isLockedPath(path);
|
||||||
|
const rootKey = String(path[0] ?? '');
|
||||||
|
|
||||||
|
if (Array.isArray(nodeValue)) {
|
||||||
|
const editableArray = !locked && canAddOrRemoveArray(path);
|
||||||
|
const sample = nodeValue.find((item) => item !== undefined) ?? '';
|
||||||
|
return (
|
||||||
|
<div key={path.join('.')} style={{ marginBottom: 18 }}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 8 }}>
|
||||||
|
<Space>
|
||||||
|
<Text strong style={{ color: '#334155', fontSize: 13 }}>{fieldTitle(key)}</Text>
|
||||||
|
{!editableArray && <Tag color="default">锁定结构</Tag>}
|
||||||
|
</Space>
|
||||||
|
{editableArray && (
|
||||||
|
<Button size="small" type="link" onClick={() => onChange(addArrayItem(safeValue, path, sample))}>
|
||||||
|
+ 添加
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ border: '1px solid #e5e7eb', borderRadius: 10, padding: 12, background: locked ? '#f8fafc' : '#fff' }}>
|
||||||
|
{nodeValue.length === 0 ? (
|
||||||
|
<Text style={{ color: '#94a3b8', fontSize: 12 }}>暂无内容</Text>
|
||||||
|
) : (
|
||||||
|
nodeValue.map((item, index) => (
|
||||||
|
<div key={`${path.join('.')}.${index}`} style={{ marginBottom: index === nodeValue.length - 1 ? 0 : 12 }}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'flex-start', gap: 8 }}>
|
||||||
|
<Text style={{ color: '#94a3b8', fontSize: 12, marginTop: 7, minWidth: 28 }}>{index + 1}.</Text>
|
||||||
|
<div style={{ flex: 1 }}>
|
||||||
|
{typeof item === 'object' && item !== null ? (
|
||||||
|
renderObjectFields(item, [...path, index], depth + 1)
|
||||||
|
) : locked ? (
|
||||||
|
<ReadonlyBlock value={item} />
|
||||||
|
) : (
|
||||||
|
<EditableInput value={item} onChange={(nextText) => updatePath([...path, index], nextText)} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{editableArray && (
|
||||||
|
<Button size="small" type="text" danger onClick={() => onChange(removeArrayItem(safeValue, path, index))}>
|
||||||
|
删除
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{(rootKey === '动作流程' || rootKey === '镜头流程') && (
|
||||||
|
<Text style={{ display: 'block', marginTop: 6, color: '#94a3b8', fontSize: 12 }}>
|
||||||
|
时间段和条目数量由后端锁定,只允许编辑每段里的动作、镜头、说明等内容。
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof nodeValue === 'object' && nodeValue !== null) {
|
||||||
|
const lockedSection = locked || LOCKED_TOP_LEVEL_KEYS.has(String(key));
|
||||||
|
return (
|
||||||
|
<div key={path.join('.')} style={{ marginBottom: 18 }}>
|
||||||
|
<Space style={{ marginBottom: 8 }}>
|
||||||
|
<Text strong style={{ color: '#334155', fontSize: 13 }}>{fieldTitle(key)}</Text>
|
||||||
|
{lockedSection && <Tag color="default">只读</Tag>}
|
||||||
|
</Space>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
borderLeft: depth === 0 ? '3px solid #6366f1' : '2px solid #e2e8f0',
|
||||||
|
paddingLeft: 12,
|
||||||
|
marginLeft: 4,
|
||||||
|
background: lockedSection ? '#f8fafc' : 'transparent',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{renderObjectFields(nodeValue, path, depth + 1)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={path.join('.')} style={{ marginBottom: 12 }}>
|
||||||
|
<Space style={{ marginBottom: 4 }}>
|
||||||
|
<Text style={{ color: '#64748b', fontSize: 12 }}>{fieldTitle(key)}</Text>
|
||||||
|
{locked && <Tag color="default">只读</Tag>}
|
||||||
|
</Space>
|
||||||
|
{locked ? (
|
||||||
|
<ReadonlyBlock value={nodeValue} />
|
||||||
|
) : (
|
||||||
|
<EditableInput value={nodeValue} onChange={(nextText) => updatePath(path, nextText)} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderObjectFields = (objectValue: Record<string, JsonValue>, parentPath: Array<string | number>, depth = 0): React.ReactNode => {
|
||||||
|
return Object.entries(objectValue).map(([childKey, childValue]) => renderNode(childKey, childValue, [...parentPath, childKey], depth));
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div style={{ marginBottom: 14, padding: 12, borderRadius: 10, background: '#f8fafc', color: '#64748b', fontSize: 13, lineHeight: 1.7 }}>
|
||||||
|
视频时长、比例、清晰度、帧率、推荐分辨率、动态时间规划、输出规格、质量控制、合规控制、schema 协议字段为只读;动作/镜头流程保留原时间段和条目数量,只允许编辑内容描述。
|
||||||
|
</div>
|
||||||
|
{Object.entries(safeValue).map(([key, childValue]) => renderNode(key, childValue, [key]))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default VideoPromptSchemaEditor;
|
||||||
@@ -0,0 +1,624 @@
|
|||||||
|
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
|
import { Button, Modal, Slider, Spin, Typography } from 'antd';
|
||||||
|
import { PauseOutlined, PlayCircleFilled } from '@ant-design/icons';
|
||||||
|
|
||||||
|
const { Text } = Typography;
|
||||||
|
|
||||||
|
const MIN_TRIM_SECONDS = 2;
|
||||||
|
const MAX_TRIM_SECONDS = 15;
|
||||||
|
const DEFAULT_TRIM_SECONDS = 7;
|
||||||
|
const FRAME_WIDTH = 160;
|
||||||
|
const FRAME_HEIGHT = 90;
|
||||||
|
|
||||||
|
type VideoTrimPickerProps = {
|
||||||
|
open: boolean;
|
||||||
|
videoUrl: string;
|
||||||
|
title?: string;
|
||||||
|
loading?: boolean;
|
||||||
|
minDuration?: number;
|
||||||
|
maxDuration?: number;
|
||||||
|
onCancel: () => void;
|
||||||
|
onConfirm: (range: { startSecond: number; endSecond: number; durationSecond: number }) => void | Promise<void>;
|
||||||
|
};
|
||||||
|
|
||||||
|
type FrameItem = {
|
||||||
|
second: number;
|
||||||
|
captureSecond?: number;
|
||||||
|
image: string;
|
||||||
|
status: 'loading' | 'success' | 'failed';
|
||||||
|
fallback?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
function pad2(value: number): string {
|
||||||
|
return String(value).padStart(2, '0');
|
||||||
|
}
|
||||||
|
|
||||||
|
function toIntegerSecond(value: number): number {
|
||||||
|
if (!Number.isFinite(value)) return 0;
|
||||||
|
return Math.max(0, Math.round(value));
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatTime(secondValue: number): string {
|
||||||
|
const total = toIntegerSecond(secondValue);
|
||||||
|
const minutes = Math.floor(total / 60);
|
||||||
|
const seconds = total % 60;
|
||||||
|
return `${pad2(minutes)}:${pad2(seconds)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function clamp(value: number, min: number, max: number): number {
|
||||||
|
return Math.min(Math.max(value, min), max);
|
||||||
|
}
|
||||||
|
|
||||||
|
function sameRange(a: [number, number], b: [number, number]): boolean {
|
||||||
|
return a[0] === b[0] && a[1] === b[1];
|
||||||
|
}
|
||||||
|
|
||||||
|
function waitForEvent(target: EventTarget, eventName: string, timeout = 8000): Promise<void> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
let timer: number | undefined;
|
||||||
|
|
||||||
|
const cleanup = () => {
|
||||||
|
if (timer) window.clearTimeout(timer);
|
||||||
|
target.removeEventListener(eventName, onOk);
|
||||||
|
target.removeEventListener('error', onError);
|
||||||
|
};
|
||||||
|
|
||||||
|
const onOk = () => {
|
||||||
|
cleanup();
|
||||||
|
resolve();
|
||||||
|
};
|
||||||
|
|
||||||
|
const onError = () => {
|
||||||
|
cleanup();
|
||||||
|
reject(new Error(`视频${eventName}失败`));
|
||||||
|
};
|
||||||
|
|
||||||
|
target.addEventListener(eventName, onOk, { once: true });
|
||||||
|
target.addEventListener('error', onError, { once: true });
|
||||||
|
timer = window.setTimeout(() => {
|
||||||
|
cleanup();
|
||||||
|
reject(new Error(`视频${eventName}超时`));
|
||||||
|
}, timeout);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function waitNextFrame(): Promise<void> {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
window.requestAnimationFrame(() => {
|
||||||
|
window.requestAnimationFrame(() => resolve());
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function seekVideo(video: HTMLVideoElement, targetSecond: number): Promise<void> {
|
||||||
|
const safeTarget = Math.max(0, targetSecond);
|
||||||
|
if (Math.abs(video.currentTime - safeTarget) > 0.01) {
|
||||||
|
const seeked = waitForEvent(video, 'seeked');
|
||||||
|
video.currentTime = safeTarget;
|
||||||
|
await seeked;
|
||||||
|
}
|
||||||
|
await waitNextFrame();
|
||||||
|
}
|
||||||
|
|
||||||
|
function isMostlyBlackFrame(ctx: CanvasRenderingContext2D, width: number, height: number): boolean {
|
||||||
|
let data: Uint8ClampedArray;
|
||||||
|
try {
|
||||||
|
data = ctx.getImageData(0, 0, width, height).data;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
let sampled = 0;
|
||||||
|
let dark = 0;
|
||||||
|
const pixelStride = 8;
|
||||||
|
for (let y = 0; y < height; y += pixelStride) {
|
||||||
|
for (let x = 0; x < width; x += pixelStride) {
|
||||||
|
const index = (y * width + x) * 4;
|
||||||
|
const r = data[index];
|
||||||
|
const g = data[index + 1];
|
||||||
|
const b = data[index + 2];
|
||||||
|
const a = data[index + 3];
|
||||||
|
if (a < 20) continue;
|
||||||
|
const luma = 0.299 * r + 0.587 * g + 0.114 * b;
|
||||||
|
sampled += 1;
|
||||||
|
if (luma < 18) dark += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return sampled > 0 && dark / sampled >= 0.85;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function captureOneFrame(
|
||||||
|
video: HTMLVideoElement,
|
||||||
|
ctx: CanvasRenderingContext2D,
|
||||||
|
canvas: HTMLCanvasElement,
|
||||||
|
displaySecond: number,
|
||||||
|
realDuration: number,
|
||||||
|
): Promise<FrameItem> {
|
||||||
|
const lastSafeSecond = Math.max(0, realDuration - 0.05);
|
||||||
|
const candidates = Array.from(
|
||||||
|
new Set(
|
||||||
|
[displaySecond, displaySecond + 0.2, displaySecond + 0.5, displaySecond + 1, displaySecond + 2]
|
||||||
|
.map((value) => Math.min(value, lastSafeSecond))
|
||||||
|
.filter((value) => value >= 0 && value <= lastSafeSecond),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
for (const captureSecond of candidates) {
|
||||||
|
try {
|
||||||
|
await seekVideo(video, captureSecond);
|
||||||
|
ctx.clearRect(0, 0, FRAME_WIDTH, FRAME_HEIGHT);
|
||||||
|
ctx.drawImage(video, 0, 0, FRAME_WIDTH, FRAME_HEIGHT);
|
||||||
|
|
||||||
|
if (isMostlyBlackFrame(ctx, FRAME_WIDTH, FRAME_HEIGHT)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
second: displaySecond,
|
||||||
|
captureSecond,
|
||||||
|
image: canvas.toDataURL('image/jpeg', 0.76),
|
||||||
|
status: 'success',
|
||||||
|
fallback: Math.abs(captureSecond - displaySecond) > 0.01,
|
||||||
|
};
|
||||||
|
} catch {
|
||||||
|
// 当前候选时间失败时继续尝试后面的候选时间。
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
second: displaySecond,
|
||||||
|
image: '',
|
||||||
|
status: 'failed',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeRange(
|
||||||
|
rawRange: [number, number],
|
||||||
|
prevRange: [number, number],
|
||||||
|
integerDuration: number,
|
||||||
|
minDuration: number,
|
||||||
|
maxDuration: number,
|
||||||
|
): [number, number] {
|
||||||
|
if (!integerDuration || integerDuration <= 0) {
|
||||||
|
return [0, 0];
|
||||||
|
}
|
||||||
|
|
||||||
|
const maxSelectableDuration = Math.min(maxDuration, integerDuration);
|
||||||
|
const minSelectableDuration = Math.min(minDuration, maxSelectableDuration);
|
||||||
|
|
||||||
|
let [start, end] = rawRange;
|
||||||
|
start = toIntegerSecond(clamp(start, 0, integerDuration));
|
||||||
|
end = toIntegerSecond(clamp(end, 0, integerDuration));
|
||||||
|
|
||||||
|
if (end < start) {
|
||||||
|
[start, end] = [end, start];
|
||||||
|
}
|
||||||
|
|
||||||
|
const movedStart = Math.abs(start - prevRange[0]) >= Math.abs(end - prevRange[1]);
|
||||||
|
let selectedDuration = end - start;
|
||||||
|
|
||||||
|
if (selectedDuration < minSelectableDuration) {
|
||||||
|
if (movedStart) {
|
||||||
|
start = toIntegerSecond(clamp(end - minSelectableDuration, 0, Math.max(0, integerDuration - minSelectableDuration)));
|
||||||
|
end = start + minSelectableDuration;
|
||||||
|
} else {
|
||||||
|
end = toIntegerSecond(clamp(start + minSelectableDuration, minSelectableDuration, integerDuration));
|
||||||
|
start = end - minSelectableDuration;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
selectedDuration = end - start;
|
||||||
|
if (selectedDuration > maxSelectableDuration) {
|
||||||
|
if (movedStart) {
|
||||||
|
start = toIntegerSecond(clamp(end - maxSelectableDuration, 0, Math.max(0, integerDuration - maxSelectableDuration)));
|
||||||
|
} else {
|
||||||
|
end = toIntegerSecond(clamp(start + maxSelectableDuration, maxSelectableDuration, integerDuration));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
start = toIntegerSecond(clamp(start, 0, integerDuration));
|
||||||
|
end = toIntegerSecond(clamp(end, start, integerDuration));
|
||||||
|
|
||||||
|
return [start, end];
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildInitialRange(integerDuration: number, minDuration: number, maxDuration: number): [number, number] {
|
||||||
|
if (!integerDuration || integerDuration <= 0) return [0, minDuration];
|
||||||
|
const initialEnd = Math.min(integerDuration, Math.max(minDuration, Math.min(DEFAULT_TRIM_SECONDS, maxDuration)));
|
||||||
|
return [0, initialEnd];
|
||||||
|
}
|
||||||
|
|
||||||
|
const VideoTrimPicker: React.FC<VideoTrimPickerProps> = ({
|
||||||
|
open,
|
||||||
|
videoUrl,
|
||||||
|
title = '手动拆镜',
|
||||||
|
loading = false,
|
||||||
|
minDuration = MIN_TRIM_SECONDS,
|
||||||
|
maxDuration = MAX_TRIM_SECONDS,
|
||||||
|
onCancel,
|
||||||
|
onConfirm,
|
||||||
|
}) => {
|
||||||
|
const videoRef = useRef<HTMLVideoElement | null>(null);
|
||||||
|
const abortRef = useRef(false);
|
||||||
|
const rangeRef = useRef<[number, number]>([0, minDuration]);
|
||||||
|
const currentTimeRef = useRef(0);
|
||||||
|
|
||||||
|
const [duration, setDuration] = useState(0);
|
||||||
|
const [currentTime, setCurrentTime] = useState(0);
|
||||||
|
const [range, setRangeState] = useState<[number, number]>([0, minDuration]);
|
||||||
|
const [frames, setFrames] = useState<FrameItem[]>([]);
|
||||||
|
const [frameLoading, setFrameLoading] = useState(false);
|
||||||
|
const [playing, setPlaying] = useState(false);
|
||||||
|
const [localError, setLocalError] = useState('');
|
||||||
|
|
||||||
|
const integerDuration = useMemo(() => Math.max(0, Math.floor(duration || 0)), [duration]);
|
||||||
|
const selectedDuration = useMemo(() => Math.max(0, range[1] - range[0]), [range]);
|
||||||
|
const disabledByDuration = integerDuration > 0 && integerDuration < minDuration;
|
||||||
|
|
||||||
|
const setRange = useCallback((next: [number, number] | ((prev: [number, number]) => [number, number])) => {
|
||||||
|
setRangeState((prev) => {
|
||||||
|
const resolved = typeof next === 'function' ? next(prev) : next;
|
||||||
|
if (sameRange(prev, resolved)) return prev;
|
||||||
|
rangeRef.current = resolved;
|
||||||
|
return resolved;
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const setDisplayedTime = useCallback((second: number) => {
|
||||||
|
const next = toIntegerSecond(second);
|
||||||
|
if (currentTimeRef.current === next) return;
|
||||||
|
currentTimeRef.current = next;
|
||||||
|
setCurrentTime(next);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const seekPreview = useCallback((second: number) => {
|
||||||
|
const video = videoRef.current;
|
||||||
|
if (!video || !integerDuration) return;
|
||||||
|
const next = toIntegerSecond(clamp(second, 0, integerDuration));
|
||||||
|
try {
|
||||||
|
video.currentTime = next;
|
||||||
|
} catch {
|
||||||
|
// ignore seek error
|
||||||
|
}
|
||||||
|
setDisplayedTime(next);
|
||||||
|
}, [integerDuration, setDisplayedTime]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) {
|
||||||
|
abortRef.current = true;
|
||||||
|
setPlaying(false);
|
||||||
|
setFrames([]);
|
||||||
|
setFrameLoading(false);
|
||||||
|
setDuration(0);
|
||||||
|
setDisplayedTime(0);
|
||||||
|
setLocalError('');
|
||||||
|
setRange([0, minDuration]);
|
||||||
|
if (videoRef.current) {
|
||||||
|
videoRef.current.pause();
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
abortRef.current = false;
|
||||||
|
setPlaying(false);
|
||||||
|
setFrameLoading(true);
|
||||||
|
setFrames([]);
|
||||||
|
setDuration(0);
|
||||||
|
setDisplayedTime(0);
|
||||||
|
setLocalError('');
|
||||||
|
setRange([0, minDuration]);
|
||||||
|
|
||||||
|
const extractor = document.createElement('video');
|
||||||
|
extractor.crossOrigin = 'anonymous';
|
||||||
|
extractor.muted = true;
|
||||||
|
extractor.playsInline = true;
|
||||||
|
extractor.preload = 'auto';
|
||||||
|
extractor.src = videoUrl;
|
||||||
|
|
||||||
|
const buildFrames = async () => {
|
||||||
|
try {
|
||||||
|
await waitForEvent(extractor, 'loadedmetadata');
|
||||||
|
await waitForEvent(extractor, 'loadeddata').catch(() => undefined);
|
||||||
|
if (abortRef.current) return;
|
||||||
|
|
||||||
|
const realDuration = Number.isFinite(extractor.duration) ? extractor.duration : 0;
|
||||||
|
const nextIntegerDuration = Math.max(0, Math.floor(realDuration));
|
||||||
|
setDuration(realDuration);
|
||||||
|
setRange(buildInitialRange(nextIntegerDuration, minDuration, maxDuration));
|
||||||
|
|
||||||
|
if (!realDuration || nextIntegerDuration <= 0) {
|
||||||
|
setLocalError('视频时长异常,无法抽取帧');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const canvas = document.createElement('canvas');
|
||||||
|
canvas.width = FRAME_WIDTH;
|
||||||
|
canvas.height = FRAME_HEIGHT;
|
||||||
|
const ctx = canvas.getContext('2d', { willReadFrequently: true });
|
||||||
|
if (!ctx) {
|
||||||
|
setLocalError('当前浏览器不支持 Canvas 抽帧');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const initialFrames: FrameItem[] = Array.from({ length: nextIntegerDuration }, (_, index) => ({
|
||||||
|
second: index,
|
||||||
|
image: '',
|
||||||
|
status: 'loading',
|
||||||
|
}));
|
||||||
|
setFrames(initialFrames);
|
||||||
|
|
||||||
|
const collected = [...initialFrames];
|
||||||
|
for (let second = 0; second < nextIntegerDuration; second += 1) {
|
||||||
|
if (abortRef.current) return;
|
||||||
|
const frame = await captureOneFrame(extractor, ctx, canvas, second, realDuration);
|
||||||
|
if (abortRef.current) return;
|
||||||
|
collected[second] = frame;
|
||||||
|
setFrames([...collected]);
|
||||||
|
}
|
||||||
|
} catch (error: any) {
|
||||||
|
if (!abortRef.current) {
|
||||||
|
setLocalError(error?.message || '视频帧抽取失败,请确认视频资源允许跨域访问');
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
if (!abortRef.current) {
|
||||||
|
setFrameLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
buildFrames();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
abortRef.current = true;
|
||||||
|
extractor.pause();
|
||||||
|
extractor.removeAttribute('src');
|
||||||
|
extractor.load();
|
||||||
|
};
|
||||||
|
}, [open, videoUrl, minDuration, maxDuration, setDisplayedTime, setRange]);
|
||||||
|
|
||||||
|
const handleRangeChange = useCallback((value: number[]) => {
|
||||||
|
if (!integerDuration) return;
|
||||||
|
setLocalError('');
|
||||||
|
setRange((prev) => normalizeRange([Number(value[0]), Number(value[1])], prev, integerDuration, minDuration, maxDuration));
|
||||||
|
}, [integerDuration, maxDuration, minDuration, setRange]);
|
||||||
|
|
||||||
|
const handleRangeChangeComplete = useCallback(() => {
|
||||||
|
seekPreview(rangeRef.current[0]);
|
||||||
|
}, [seekPreview]);
|
||||||
|
|
||||||
|
const handleFrameClick = useCallback((second: number) => {
|
||||||
|
if (!integerDuration || disabledByDuration) return;
|
||||||
|
const keepDuration = clamp(selectedDuration || Math.min(DEFAULT_TRIM_SECONDS, maxDuration), minDuration, Math.min(maxDuration, integerDuration));
|
||||||
|
let start = clamp(second, 0, Math.max(0, integerDuration - keepDuration));
|
||||||
|
start = toIntegerSecond(start);
|
||||||
|
const next: [number, number] = [start, start + keepDuration];
|
||||||
|
setRange(next);
|
||||||
|
seekPreview(next[0]);
|
||||||
|
}, [disabledByDuration, integerDuration, maxDuration, minDuration, seekPreview, selectedDuration, setRange]);
|
||||||
|
|
||||||
|
const handlePlaySelected = async () => {
|
||||||
|
const video = videoRef.current;
|
||||||
|
if (!video || !integerDuration) return;
|
||||||
|
|
||||||
|
if (playing) {
|
||||||
|
video.pause();
|
||||||
|
setPlaying(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (video.currentTime < range[0] || video.currentTime >= range[1]) {
|
||||||
|
video.currentTime = range[0];
|
||||||
|
setDisplayedTime(range[0]);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await video.play();
|
||||||
|
setPlaying(true);
|
||||||
|
setLocalError('');
|
||||||
|
} catch {
|
||||||
|
setLocalError('视频播放失败,请检查视频地址');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleTimeUpdate = () => {
|
||||||
|
const video = videoRef.current;
|
||||||
|
if (!video) return;
|
||||||
|
|
||||||
|
const current = toIntegerSecond(video.currentTime || 0);
|
||||||
|
setDisplayedTime(current);
|
||||||
|
if (playing && video.currentTime >= rangeRef.current[1]) {
|
||||||
|
video.pause();
|
||||||
|
video.currentTime = rangeRef.current[1];
|
||||||
|
setDisplayedTime(rangeRef.current[1]);
|
||||||
|
setPlaying(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleConfirm = async () => {
|
||||||
|
if (!integerDuration || integerDuration <= 0) {
|
||||||
|
setLocalError('请等待视频加载完成');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (disabledByDuration) {
|
||||||
|
setLocalError(`视频总时长不足 ${minDuration} 秒,无法手动拆镜`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (selectedDuration < minDuration) {
|
||||||
|
setLocalError(`拆镜片段不能低于 ${minDuration} 秒`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (selectedDuration > maxDuration) {
|
||||||
|
setLocalError(`拆镜片段不能超过 ${maxDuration} 秒`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setLocalError('');
|
||||||
|
await onConfirm({
|
||||||
|
startSecond: range[0],
|
||||||
|
endSecond: range[1],
|
||||||
|
durationSecond: selectedDuration,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
title={title}
|
||||||
|
open={open}
|
||||||
|
onCancel={onCancel}
|
||||||
|
width={980}
|
||||||
|
destroyOnHidden
|
||||||
|
footer={[
|
||||||
|
<Button key="cancel" onClick={onCancel} disabled={loading}>
|
||||||
|
取消
|
||||||
|
</Button>,
|
||||||
|
<Button key="confirm" type="primary" onClick={handleConfirm} loading={loading} disabled={loading || disabledByDuration || !integerDuration}>
|
||||||
|
确认拆镜
|
||||||
|
</Button>,
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 18 }}>
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'center', background: '#fff' }}>
|
||||||
|
<video
|
||||||
|
ref={videoRef}
|
||||||
|
src={videoUrl}
|
||||||
|
crossOrigin="anonymous"
|
||||||
|
preload="metadata"
|
||||||
|
playsInline
|
||||||
|
onLoadedMetadata={(event) => {
|
||||||
|
const realDuration = Number.isFinite(event.currentTarget.duration) ? event.currentTarget.duration : 0;
|
||||||
|
if (!realDuration) return;
|
||||||
|
const nextIntegerDuration = Math.max(0, Math.floor(realDuration));
|
||||||
|
setDuration((prev) => (Math.floor(prev || 0) === nextIntegerDuration ? prev : realDuration));
|
||||||
|
setRange((prev) => {
|
||||||
|
if (prev[1] > minDuration || prev[0] !== 0) return prev;
|
||||||
|
return buildInitialRange(nextIntegerDuration, minDuration, maxDuration);
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
onTimeUpdate={handleTimeUpdate}
|
||||||
|
onPause={() => setPlaying(false)}
|
||||||
|
style={{ width: '100%', maxHeight: 420, objectFit: 'contain', background: '#111', borderRadius: 8 }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
borderRadius: 24,
|
||||||
|
background: '#f3f6ff',
|
||||||
|
padding: '26px 34px 18px',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 28, marginBottom: 22 }}>
|
||||||
|
<Button
|
||||||
|
type="text"
|
||||||
|
icon={playing ? <PauseOutlined /> : <PlayCircleFilled />}
|
||||||
|
onClick={handlePlaySelected}
|
||||||
|
disabled={!integerDuration || disabledByDuration}
|
||||||
|
style={{ fontSize: 22, color: '#111' }}
|
||||||
|
/>
|
||||||
|
<span style={{ fontSize: 26, color: '#111', letterSpacing: 1 }}>
|
||||||
|
{formatTime(currentTime)} / {formatTime(integerDuration)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ position: 'relative', padding: '0 6px' }}>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'grid',
|
||||||
|
gridTemplateColumns: `repeat(${Math.max(frames.length, 1)}, minmax(42px, 1fr))`,
|
||||||
|
height: 86,
|
||||||
|
overflow: 'hidden',
|
||||||
|
borderRadius: 10,
|
||||||
|
background: '#dbe2ff',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{frameLoading && frames.length === 0 ? (
|
||||||
|
<div style={{ gridColumn: '1 / -1', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
||||||
|
<Spin size="small" />
|
||||||
|
<Text style={{ marginLeft: 8, color: '#64748b' }}>正在抽取每秒帧...</Text>
|
||||||
|
</div>
|
||||||
|
) : frames.length > 0 ? (
|
||||||
|
frames.map((frame) => (
|
||||||
|
<button
|
||||||
|
key={frame.second}
|
||||||
|
type="button"
|
||||||
|
onClick={() => handleFrameClick(frame.second)}
|
||||||
|
title={`${frame.second}s${frame.fallback ? `,实际取帧 ${frame.captureSecond?.toFixed(1)}s` : ''}`}
|
||||||
|
style={{
|
||||||
|
minWidth: 0,
|
||||||
|
height: 86,
|
||||||
|
border: 'none',
|
||||||
|
padding: 0,
|
||||||
|
background: '#eef2ff',
|
||||||
|
overflow: 'hidden',
|
||||||
|
cursor: disabledByDuration ? 'not-allowed' : 'pointer',
|
||||||
|
position: 'relative',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{frame.status === 'success' && frame.image ? (
|
||||||
|
<img src={frame.image} alt={`${frame.second}s`} style={{ width: '100%', height: '100%', objectFit: 'cover', display: 'block' }} />
|
||||||
|
) : frame.status === 'loading' ? (
|
||||||
|
<div style={{ width: '100%', height: '100%', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
||||||
|
<Spin size="small" />
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div style={{ width: '100%', height: '100%', display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', color: '#64748b', fontSize: 12, background: '#eef2ff' }}>
|
||||||
|
<span>{frame.second}s</span>
|
||||||
|
<span style={{ fontSize: 11 }}>无有效帧</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
left: 4,
|
||||||
|
bottom: 3,
|
||||||
|
color: '#fff',
|
||||||
|
fontSize: 11,
|
||||||
|
textShadow: '0 1px 3px rgba(0,0,0,.7)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{frame.second}s
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<div style={{ gridColumn: '1 / -1', display: 'flex', alignItems: 'center', justifyContent: 'center', color: '#64748b' }}>
|
||||||
|
暂无帧预览,请确认视频是否加载完成
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ marginTop: -54, padding: '0 8px 0' }}>
|
||||||
|
<Slider
|
||||||
|
range
|
||||||
|
min={0}
|
||||||
|
max={integerDuration || maxDuration}
|
||||||
|
step={1}
|
||||||
|
value={range}
|
||||||
|
onChange={handleRangeChange}
|
||||||
|
onChangeComplete={handleRangeChangeComplete}
|
||||||
|
tooltip={{ formatter: (value) => `${toIntegerSecond(Number(value || 0))}s` }}
|
||||||
|
disabled={!integerDuration || disabledByDuration}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ marginTop: 30, textAlign: 'center', color: '#64748b', fontSize: 16 }}>
|
||||||
|
已选取 {selectedDuration}s
|
||||||
|
<span style={{ marginLeft: 12, fontSize: 13, color: '#94a3b8' }}>
|
||||||
|
最小 {minDuration}s,最大 {maxDuration}s,开始/结束秒数均为整数
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{localError && (
|
||||||
|
<div style={{ marginTop: 12, textAlign: 'center', color: '#ef4444', fontSize: 13 }}>
|
||||||
|
{localError}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default VideoTrimPicker;
|
||||||
@@ -2,12 +2,17 @@ import React, { useState, useEffect } from 'react';
|
|||||||
import { Button, Typography, Collapse, Space, Modal, Input, Table, message } from 'antd';
|
import { Button, Typography, Collapse, Space, Modal, Input, Table, message } from 'antd';
|
||||||
import { ArrowLeftOutlined, PlayCircleOutlined, CheckCircleOutlined, EditOutlined, DownloadOutlined, SettingOutlined, LayoutOutlined } from '@ant-design/icons';
|
import { ArrowLeftOutlined, PlayCircleOutlined, CheckCircleOutlined, EditOutlined, DownloadOutlined, SettingOutlined, LayoutOutlined } from '@ant-design/icons';
|
||||||
import { useNavigate, useParams } from 'react-router-dom';
|
import { useNavigate, useParams } from 'react-router-dom';
|
||||||
import { getReplicationList, getReplicationDetail, gettwo,getthree, getfour,getEngine } from '../api/index';
|
import { getReplicationList, getReplicationDetail, gettwo, getthree, getfour, getEngine, updateHotOpeningVideoPromptSchema } from '../api/index';
|
||||||
|
import VideoPromptSchemaEditor from '../components/VideoPromptSchemaEditor';
|
||||||
import './css/InitialInfo.css';
|
import './css/InitialInfo.css';
|
||||||
|
|
||||||
const { Title, Text } = Typography;
|
const { Title, Text } = Typography;
|
||||||
const { TextArea } = Input;
|
const { TextArea } = Input;
|
||||||
|
|
||||||
|
function clonePlain<T>(value: T): T {
|
||||||
|
return value === undefined ? value : JSON.parse(JSON.stringify(value));
|
||||||
|
}
|
||||||
|
|
||||||
function InitialInfo() {
|
function InitialInfo() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { creatID } = useParams<{ creatID: string }>();
|
const { creatID } = useParams<{ creatID: string }>();
|
||||||
@@ -17,6 +22,8 @@ function InitialInfo() {
|
|||||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||||
const [currentType, setCurrentType] = useState<string>('image');
|
const [currentType, setCurrentType] = useState<string>('image');
|
||||||
const [formData, setFormData] = useState<any>({});
|
const [formData, setFormData] = useState<any>({});
|
||||||
|
const [editingPromptStepId, setEditingPromptStepId] = useState<string>('');
|
||||||
|
const [promptSaving, setPromptSaving] = useState(false);
|
||||||
const [pollingTimer, setPollingTimer] = useState<any>(null);
|
const [pollingTimer, setPollingTimer] = useState<any>(null);
|
||||||
|
|
||||||
// 引擎和视频参数相关状态
|
// 引擎和视频参数相关状态
|
||||||
@@ -180,12 +187,12 @@ function InitialInfo() {
|
|||||||
}
|
}
|
||||||
}, [steps]);
|
}, [steps]);
|
||||||
|
|
||||||
const handleOpenModal = (prompt?: any, type?: string) => {
|
const handleOpenModal = (prompt?: any, type?: string, stepId?: string | number) => {
|
||||||
|
|
||||||
setCurrentType(type || 'image');
|
setCurrentType(type || 'image');
|
||||||
|
setEditingPromptStepId(stepId ? String(stepId) : '');
|
||||||
|
|
||||||
if (type === 'video' && typeof prompt === 'object') {
|
if (type === 'video' && typeof prompt === 'object' && prompt) {
|
||||||
setFormData(prompt);
|
setFormData(clonePlain(prompt));
|
||||||
setPromptText('');
|
setPromptText('');
|
||||||
} else {
|
} else {
|
||||||
setPromptText(prompt || '');
|
setPromptText(prompt || '');
|
||||||
@@ -195,8 +202,42 @@ function InitialInfo() {
|
|||||||
setModalVisible(true);
|
setModalVisible(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleConfirm = () => {
|
const handleConfirm = async () => {
|
||||||
setModalVisible(false);
|
if (currentType !== 'video') {
|
||||||
|
setModalVisible(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!taskDetail?.id || !editingPromptStepId) {
|
||||||
|
message.warning('缺少任务或步骤 ID,无法保存视频提示词');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!formData || typeof formData !== 'object' || Object.keys(formData).length === 0) {
|
||||||
|
message.warning('视频提示词不能为空');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setPromptSaving(true);
|
||||||
|
try {
|
||||||
|
const res: any = await updateHotOpeningVideoPromptSchema(taskDetail.id, editingPromptStepId, {
|
||||||
|
prompt_schema: formData,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (res?.detail) {
|
||||||
|
setTaskDetail(res.detail);
|
||||||
|
setApiSteps(res.detail.steps || []);
|
||||||
|
} else {
|
||||||
|
refreshTaskDetail();
|
||||||
|
}
|
||||||
|
|
||||||
|
message.success(res?.message || '视频提示词已保存');
|
||||||
|
setModalVisible(false);
|
||||||
|
setEditingPromptStepId('');
|
||||||
|
} catch (error: any) {
|
||||||
|
message.error(error?.message || '保存视频提示词失败');
|
||||||
|
} finally {
|
||||||
|
setPromptSaving(false);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// 轮询任务详情
|
// 轮询任务详情
|
||||||
@@ -905,7 +946,7 @@ function InitialInfo() {
|
|||||||
<Button
|
<Button
|
||||||
type="default"
|
type="default"
|
||||||
icon={<EditOutlined />}
|
icon={<EditOutlined />}
|
||||||
onClick={() => handleOpenModal(step?.output?.payload?.promptSchema,'video')}
|
onClick={() => handleOpenModal(step?.output?.payload?.promptSchema, 'video', step.id)}
|
||||||
style={{ flex: 1, borderRadius: 8, borderColor: '#6366f1', color: '#6366f1', height: 36 }}
|
style={{ flex: 1, borderRadius: 8, borderColor: '#6366f1', color: '#6366f1', height: 36 }}
|
||||||
disabled={step.status !== 'completed'}
|
disabled={step.status !== 'completed'}
|
||||||
>
|
>
|
||||||
@@ -967,12 +1008,12 @@ function InitialInfo() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<Modal
|
<Modal
|
||||||
title="修改提示词"
|
title={currentType === 'video' ? '修改视频提示词' : '修改提示词'}
|
||||||
open={modalVisible}
|
open={modalVisible}
|
||||||
onCancel={() => setModalVisible(false)}
|
onCancel={() => { if (!promptSaving) setModalVisible(false); }}
|
||||||
footer={[
|
footer={[
|
||||||
<Button key="cancel" onClick={() => setModalVisible(false)}>取消</Button>,
|
<Button key="cancel" onClick={() => setModalVisible(false)} disabled={promptSaving}>取消</Button>,
|
||||||
<Button key="confirm" type="primary" onClick={handleConfirm}>确认</Button>,
|
<Button key="confirm" type="primary" onClick={handleConfirm} loading={promptSaving} disabled={promptSaving}>确认</Button>,
|
||||||
]}
|
]}
|
||||||
width={800}
|
width={800}
|
||||||
>
|
>
|
||||||
@@ -986,7 +1027,7 @@ function InitialInfo() {
|
|||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<div style={{ maxHeight: 500, overflowY: 'auto', paddingRight: 10 }}>
|
<div style={{ maxHeight: 500, overflowY: 'auto', paddingRight: 10 }}>
|
||||||
<FormRenderer data={formData} onChange={setFormData} />
|
<VideoPromptSchemaEditor value={formData} onChange={setFormData} />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</Modal>
|
</Modal>
|
||||||
@@ -1084,135 +1125,4 @@ function InitialInfo() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const FormRenderer = ({ data, onChange }: { data: any; onChange: (data: any) => void }) => {
|
|
||||||
const handleFieldChange = (path: string[], value: any) => {
|
|
||||||
const newData = { ...data };
|
|
||||||
let current = newData;
|
|
||||||
for (let i = 0; i < path.length - 1; i++) {
|
|
||||||
current = current[path[i]];
|
|
||||||
}
|
|
||||||
current[path[path.length - 1]] = value;
|
|
||||||
onChange(newData);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleArrayItemChange = (path: string[], index: number, value: any) => {
|
|
||||||
const newData = { ...data };
|
|
||||||
let current = newData;
|
|
||||||
for (let i = 0; i < path.length; i++) {
|
|
||||||
if (i === path.length - 1) {
|
|
||||||
current[path[i]] = [...current[path[i]]];
|
|
||||||
current[path[i]][index] = value;
|
|
||||||
} else {
|
|
||||||
current = current[path[i]];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
onChange(newData);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleArrayAdd = (path: string[]) => {
|
|
||||||
const newData = { ...data };
|
|
||||||
let current = newData;
|
|
||||||
for (let i = 0; i < path.length; i++) {
|
|
||||||
if (i === path.length - 1) {
|
|
||||||
current[path[i]] = [...current[path[i]], ''];
|
|
||||||
} else {
|
|
||||||
current = current[path[i]];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
onChange(newData);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleArrayRemove = (path: string[], index: number) => {
|
|
||||||
const newData = { ...data };
|
|
||||||
let current = newData;
|
|
||||||
for (let i = 0; i < path.length; i++) {
|
|
||||||
if (i === path.length - 1) {
|
|
||||||
current[path[i]] = current[path[i]].filter((_: any, i: number) => i !== index);
|
|
||||||
} else {
|
|
||||||
current = current[path[i]];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
onChange(newData);
|
|
||||||
};
|
|
||||||
|
|
||||||
const renderField = (key: string, value: any, path: string[]) => {
|
|
||||||
if (Array.isArray(value)) {
|
|
||||||
return (
|
|
||||||
<div key={key} style={{ marginBottom: 16 }}>
|
|
||||||
<div style={{ display: 'flex', alignItems: 'center', marginBottom: 8 }}>
|
|
||||||
<Text strong style={{ color: '#374151', fontSize: 13 }}>{key}</Text>
|
|
||||||
<Button
|
|
||||||
type="text"
|
|
||||||
size="small"
|
|
||||||
onClick={() => handleArrayAdd(path)}
|
|
||||||
style={{ marginLeft: 8, color: '#6366f1', fontSize: 12 }}
|
|
||||||
>
|
|
||||||
+ 添加
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
<div style={{ border: '1px solid #e5e7eb', borderRadius: 8, padding: 12, background: '#f9fafb' }}>
|
|
||||||
{value.map((item: any, index: number) => (
|
|
||||||
<div key={index} style={{ display: 'flex', alignItems: 'flex-start', gap: 8, marginBottom: 8 }}>
|
|
||||||
<span style={{ color: '#9ca3af', fontSize: 12, marginTop: 6 }}>{index + 1}.</span>
|
|
||||||
<div style={{ flex: 1 }}>
|
|
||||||
{typeof item === 'object' ? (
|
|
||||||
<FormRenderer
|
|
||||||
data={item}
|
|
||||||
onChange={(newItem) => handleArrayItemChange(path, index, newItem)}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<Input
|
|
||||||
value={item}
|
|
||||||
onChange={(e) => handleArrayItemChange(path, index, e.target.value)}
|
|
||||||
style={{ width: '100%', borderRadius: 6 }}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<Button
|
|
||||||
type="text"
|
|
||||||
danger
|
|
||||||
onClick={() => handleArrayRemove(path, index)}
|
|
||||||
style={{ marginTop: 4 }}
|
|
||||||
>
|
|
||||||
删除
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (typeof value === 'object' && value !== null) {
|
|
||||||
return (
|
|
||||||
<div key={key} style={{ marginBottom: 16 }}>
|
|
||||||
<Text strong style={{ color: '#374151', fontSize: 13, marginBottom: 8, display: 'block' }}>
|
|
||||||
{key}
|
|
||||||
</Text>
|
|
||||||
<div style={{ borderLeft: '3px solid #6366f1', paddingLeft: 12, marginLeft: 4 }}>
|
|
||||||
<FormRenderer data={value} onChange={(newValue) => handleFieldChange(path, newValue)} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div key={key} style={{ marginBottom: 12 }}>
|
|
||||||
<Text style={{ color: '#6b7280', fontSize: 12, marginBottom: 4, display: 'block' }}>{key}</Text>
|
|
||||||
<Input
|
|
||||||
value={value}
|
|
||||||
onChange={(e) => handleFieldChange(path, e.target.value)}
|
|
||||||
style={{ width: '100%', borderRadius: 6 }}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
{Object.entries(data).map(([key, value]) => renderField(key, value, [key]))}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default InitialInfo;
|
export default InitialInfo;
|
||||||
|
|||||||
@@ -1,26 +1,68 @@
|
|||||||
import React, { useState, useEffect } from 'react';
|
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
||||||
import { useParams, useNavigate } from 'react-router-dom';
|
import { useNavigate, useParams } from 'react-router-dom';
|
||||||
import { Button, Table, Tag, Drawer, Input, Upload, message } from 'antd';
|
import { Button, Drawer, Input, Table, Tag, Upload, message } from 'antd';
|
||||||
import { ArrowLeftOutlined, PlayCircleOutlined, XOutlined, PlusOutlined, UploadOutlined } from '@ant-design/icons';
|
import { ArrowLeftOutlined, PlusOutlined, XOutlined } from '@ant-design/icons';
|
||||||
import type { UploadFile } from 'antd';
|
import { createRemoveLens, getShotReplicationDetail, Removelist, removeCreate, splitCustom, uploadImage } from '../api';
|
||||||
import { getShotReplicationDetail, createRemoveLens, Removelist, uploadImage, removeCreate, splitCustom } from '../api';
|
import VideoTrimPicker from '../components/VideoTrimPicker';
|
||||||
|
|
||||||
const { TextArea } = Input;
|
const { TextArea } = Input;
|
||||||
|
|
||||||
|
const API_BASE = import.meta.env.VITE_API_BASE || 'http://localhost:8000';
|
||||||
|
const MIN_TRIM_SECONDS = 2;
|
||||||
|
const MAX_TRIM_SECONDS = 15;
|
||||||
|
|
||||||
|
function buildAssetUrl(url?: string): string {
|
||||||
|
if (!url) return '';
|
||||||
|
if (/^https?:\/\//i.test(url)) return url;
|
||||||
|
return `${API_BASE}${url}`;
|
||||||
|
}
|
||||||
|
|
||||||
function RemoveInfo() {
|
function RemoveInfo() {
|
||||||
const { creatID } = useParams<{ creatID: string }>();
|
const { creatID } = useParams<{ creatID: string }>();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const [drawerVisible, setDrawerVisible] = useState(false);
|
const [drawerVisible, setDrawerVisible] = useState(false);
|
||||||
const [currentSegment, setCurrentSegment] = useState<number | null>(null);
|
const [trimModalVisible, setTrimModalVisible] = useState(false);
|
||||||
|
const [currentSegment, setCurrentSegment] = useState<string | null>(null);
|
||||||
const [productName, setProductName] = useState('');
|
const [productName, setProductName] = useState('');
|
||||||
const [productSellingPoint, setProductSellingPoint] = useState('');
|
const [productSellingPoint, setProductSellingPoint] = useState('');
|
||||||
const [productImage, setProductImage] = useState('');
|
const [productImage, setProductImage] = useState('');
|
||||||
const [detailImage, setDetailImage] = useState('');
|
|
||||||
const [taskDetail, setTaskDetail] = useState<any>(null);
|
const [taskDetail, setTaskDetail] = useState<any>(null);
|
||||||
const [tableData, setTableData] = useState<any[]>([]);
|
const [tableData, setTableData] = useState<any[]>([]);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [splitLoading, setSplitLoading] = useState(false);
|
||||||
|
const [autoSplitLoading, setAutoSplitLoading] = useState(false);
|
||||||
|
|
||||||
const handleGenerate = (segmentId: number) => {
|
const videoUrl = useMemo(() => buildAssetUrl(taskDetail?.videoUrl), [taskDetail?.videoUrl]);
|
||||||
|
|
||||||
|
const fetchTaskDetail = useCallback(async () => {
|
||||||
|
if (!creatID) return;
|
||||||
|
try {
|
||||||
|
const res = await getShotReplicationDetail(creatID);
|
||||||
|
setTaskDetail(res);
|
||||||
|
} catch {
|
||||||
|
message.error('获取任务详情失败');
|
||||||
|
}
|
||||||
|
}, [creatID]);
|
||||||
|
|
||||||
|
const fetchSegments = useCallback(async () => {
|
||||||
|
if (!creatID) return;
|
||||||
|
try {
|
||||||
|
const res = await Removelist(creatID);
|
||||||
|
setTableData(res.items || []);
|
||||||
|
} catch {
|
||||||
|
message.error('获取拆镜列表失败');
|
||||||
|
}
|
||||||
|
}, [creatID]);
|
||||||
|
|
||||||
|
const refreshPageData = useCallback(async () => {
|
||||||
|
await Promise.all([fetchTaskDetail(), fetchSegments()]);
|
||||||
|
}, [fetchTaskDetail, fetchSegments]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
refreshPageData();
|
||||||
|
}, [refreshPageData]);
|
||||||
|
|
||||||
|
const handleGenerate = (segmentId: string) => {
|
||||||
setCurrentSegment(segmentId);
|
setCurrentSegment(segmentId);
|
||||||
setDrawerVisible(true);
|
setDrawerVisible(true);
|
||||||
};
|
};
|
||||||
@@ -31,28 +73,8 @@ function RemoveInfo() {
|
|||||||
setProductName('');
|
setProductName('');
|
||||||
setProductSellingPoint('');
|
setProductSellingPoint('');
|
||||||
setProductImage('');
|
setProductImage('');
|
||||||
setDetailImage('');
|
|
||||||
};
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
console.log('ididi', creatID);
|
|
||||||
if (creatID) {
|
|
||||||
getShotReplicationDetail(creatID).then((res: any) => {
|
|
||||||
setTaskDetail(res);
|
|
||||||
console.log('res', res);
|
|
||||||
}).catch((error: any) => {
|
|
||||||
message.error('获取任务详情失败');
|
|
||||||
});
|
|
||||||
|
|
||||||
Removelist(creatID).then((res: any) => {
|
|
||||||
console.log('Removelist res', res);
|
|
||||||
setTableData(res.items || []);
|
|
||||||
}).catch((error: any) => {
|
|
||||||
message.error('获取拆镜列表失败');
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}, [creatID]);
|
|
||||||
|
|
||||||
const handleProductImageChange: any = (info: any) => {
|
const handleProductImageChange: any = (info: any) => {
|
||||||
if (info.fileList.length === 0) {
|
if (info.fileList.length === 0) {
|
||||||
setProductImage('');
|
setProductImage('');
|
||||||
@@ -64,28 +86,17 @@ function RemoveInfo() {
|
|||||||
const uploadResult = await uploadImage(file);
|
const uploadResult = await uploadImage(file);
|
||||||
setProductImage(uploadResult.url);
|
setProductImage(uploadResult.url);
|
||||||
message.success('图片上传成功');
|
message.success('图片上传成功');
|
||||||
} catch (err) {
|
} catch {
|
||||||
message.error('图片上传失败,请重试');
|
message.error('图片上传失败,请重试');
|
||||||
}
|
}
|
||||||
return false;
|
return false;
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleDetailImageChange: any = (info: any) => {
|
|
||||||
if (info.fileList.length > 0) {
|
|
||||||
const file = info.fileList[0];
|
|
||||||
if (file.originFileObj) {
|
|
||||||
const reader = new FileReader();
|
|
||||||
reader.onload = (e) => {
|
|
||||||
setDetailImage(e.target?.result as string);
|
|
||||||
};
|
|
||||||
reader.readAsDataURL(file.originFileObj);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
setDetailImage('');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleManualGenerate = async () => {
|
const handleManualGenerate = async () => {
|
||||||
|
if (!currentSegment) {
|
||||||
|
message.warning('请先选择拆镜片段');
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (!productImage) {
|
if (!productImage) {
|
||||||
message.warning('请上传产品图');
|
message.warning('请上传产品图');
|
||||||
return;
|
return;
|
||||||
@@ -104,124 +115,152 @@ function RemoveInfo() {
|
|||||||
const params = {
|
const params = {
|
||||||
target_project_name: productName.trim(),
|
target_project_name: productName.trim(),
|
||||||
core_content_point: productSellingPoint.trim(),
|
core_content_point: productSellingPoint.trim(),
|
||||||
material_image_url: `${import.meta.env.VITE_API_BASE || "http://localhost:8000"}${productImage}`,
|
material_image_url: buildAssetUrl(productImage),
|
||||||
idempotency_key: `replication_${Date.now()}`,
|
idempotency_key: `replication_${Date.now()}`,
|
||||||
};
|
};
|
||||||
console.log(params);
|
|
||||||
|
|
||||||
|
await removeCreate(currentSegment, params);
|
||||||
await removeCreate(String(currentSegment), params);
|
|
||||||
message.success('视频生成任务创建成功');
|
message.success('视频生成任务创建成功');
|
||||||
// handleCloseDrawer();
|
handleCloseDrawer();
|
||||||
} catch (err) {
|
await fetchSegments();
|
||||||
message.error('创建失败,请重试');
|
} catch (err: any) {
|
||||||
|
message.error(err?.message || '创建失败,请重试');
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleAutoGenerate = () => {
|
const handleAutoGenerate = async () => {
|
||||||
// console.log('123123');
|
if (!creatID) return;
|
||||||
let params = {
|
setAutoSplitLoading(true);
|
||||||
selected_indices: [],
|
try {
|
||||||
replace_existing: false,
|
await createRemoveLens(creatID, {
|
||||||
|
selected_indices: [],
|
||||||
|
replace_existing: false,
|
||||||
|
});
|
||||||
|
message.success('AI 拆镜任务已提交');
|
||||||
|
await refreshPageData();
|
||||||
|
} catch (error: any) {
|
||||||
|
message.error(error?.message || '拆镜失败');
|
||||||
|
} finally {
|
||||||
|
setAutoSplitLoading(false);
|
||||||
}
|
}
|
||||||
createRemoveLens(creatID, params).then((res: any) => {
|
};
|
||||||
console.log('res', res);
|
|
||||||
}).catch((error: any) => {
|
|
||||||
message.error('拆镜失败');
|
|
||||||
});
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
|
const handleOpenTrimModal = () => {
|
||||||
|
if (!videoUrl) {
|
||||||
|
message.warning('原视频地址不存在');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setTrimModalVisible(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCustomSplit = async (range: { startSecond: number; endSecond: number; durationSecond: number }) => {
|
||||||
|
if (!creatID) return;
|
||||||
|
if (range.durationSecond < MIN_TRIM_SECONDS) {
|
||||||
|
message.warning(`拆镜片段不能低于 ${MIN_TRIM_SECONDS} 秒`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (range.durationSecond > MAX_TRIM_SECONDS) {
|
||||||
|
message.warning(`拆镜片段不能超过 ${MAX_TRIM_SECONDS} 秒`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setSplitLoading(true);
|
||||||
|
try {
|
||||||
|
await splitCustom(creatID, {
|
||||||
|
start_second: range.startSecond,
|
||||||
|
end_second: range.endSecond,
|
||||||
|
});
|
||||||
|
message.success('手动拆镜任务已提交');
|
||||||
|
setTrimModalVisible(false);
|
||||||
|
await refreshPageData();
|
||||||
|
} catch (error: any) {
|
||||||
|
message.error(error?.message || '手动拆镜失败');
|
||||||
|
} finally {
|
||||||
|
setSplitLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const canCreateReplication = (record: any) => {
|
||||||
|
return record?.splitStatus === 'completed' && !!record?.segmentVideoUrl;
|
||||||
|
};
|
||||||
|
|
||||||
const columns = [
|
const columns = [
|
||||||
{
|
{
|
||||||
title: '片段',
|
title: '片段',
|
||||||
width: 100,
|
width: 100,
|
||||||
align: 'center' as const,
|
align: 'center' as const,
|
||||||
render: (text: any, record: any) => (
|
render: (_: any, record: any) => (
|
||||||
<div>
|
<div>
|
||||||
<div style={{ fontSize: 16, fontWeight: 600, color: '#333' }}>{record.segmentName}</div>
|
<div style={{ fontSize: 16, fontWeight: 600, color: '#333' }}>{record.segmentName || `片段${record.segmentIndex || ''}`}</div>
|
||||||
<div style={{ fontSize: 12, color: '#999' }}>{record.timeNode}</div>
|
<div style={{ fontSize: 12, color: '#999' }}>{record.timeNode}</div>
|
||||||
|
{record.sourceMode === 'custom' && <Tag color="blue" style={{ marginTop: 6 }}>手动</Tag>}
|
||||||
</div>
|
</div>
|
||||||
)
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: '片段视频',
|
title: '片段视频',
|
||||||
width: 150,
|
width: 150,
|
||||||
align: 'center' as const,
|
align: 'center' as const,
|
||||||
|
render: (_: any, record: any) => (
|
||||||
render: (text: any, record: any) => (
|
<div style={{ position: 'relative', width: 120, height: 80, borderRadius: 6, overflow: 'hidden', background: '#f1f5f9' }}>
|
||||||
<div style={{ position: 'relative', width: 120, height: 80, borderRadius: 6, overflow: 'hidden' }}>
|
{record.segmentVideoUrl ? (
|
||||||
<video
|
<video
|
||||||
controls
|
controls
|
||||||
src={`${import.meta.env.VITE_API_BASE || "http://localhost:8000"}${record.segmentVideoUrl}`}
|
src={buildAssetUrl(record.segmentVideoUrl)}
|
||||||
// alt={`片段${record.id}`}
|
style={{ width: '100%', height: '100%', objectFit: 'cover' }}
|
||||||
style={{ width: '100%', height: '100%', objectFit: 'cover' }}
|
/>
|
||||||
/>
|
) : (
|
||||||
{/* <div style={{
|
<div style={{ width: '100%', height: '100%', display: 'flex', alignItems: 'center', justifyContent: 'center', color: '#94a3b8', fontSize: 12 }}>
|
||||||
position: 'absolute',
|
{record.splitStatus === 'failed' ? '切割失败' : '切割中'}
|
||||||
top: '50%',
|
</div>
|
||||||
left: '50%',
|
)}
|
||||||
transform: 'translate(-50%, -50%)',
|
|
||||||
width: 28,
|
|
||||||
height: 28,
|
|
||||||
background: 'rgba(0,0,0,0.6)',
|
|
||||||
borderRadius: '50%',
|
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
justifyContent: 'center'
|
|
||||||
}}>
|
|
||||||
<PlayCircleOutlined style={{ fontSize: 16, color: '#fff' }} />
|
|
||||||
</div> */}
|
|
||||||
</div>
|
</div>
|
||||||
)
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: '视频内容',
|
title: '视频内容',
|
||||||
width: 250,
|
width: 250,
|
||||||
align: 'left' as const,
|
align: 'left' as const,
|
||||||
|
render: (_: any, record: any) => (
|
||||||
render: (text: any, record: any) => (
|
|
||||||
<div style={{ fontSize: 14, color: '#333', lineHeight: 1.6 }}>
|
<div style={{ fontSize: 14, color: '#333', lineHeight: 1.6 }}>
|
||||||
{record.segmentContent}
|
{record.segmentContent || record.lastError || '-'}
|
||||||
</div>
|
</div>
|
||||||
)
|
),
|
||||||
},
|
},
|
||||||
// {
|
|
||||||
// title: '台词',
|
|
||||||
// width: 250,
|
|
||||||
|
|
||||||
// render: (text: any, record: any) => (
|
|
||||||
// <div style={{ fontSize: 14, color: '#333', lineHeight: 1.6 }}>
|
|
||||||
// {record.lines}
|
|
||||||
// </div>
|
|
||||||
// )
|
|
||||||
// },
|
|
||||||
{
|
{
|
||||||
title: '视频类型',
|
title: '视频类型',
|
||||||
|
|
||||||
width: 120,
|
width: 120,
|
||||||
align: 'center' as const,
|
align: 'center' as const,
|
||||||
render: (text: any, record: any) => (
|
render: (_: any, record: any) => (
|
||||||
<div style={{ fontSize: 14, color: '#333', fontWeight: 500 }}>
|
<div style={{ fontSize: 14, color: '#333', fontWeight: 500 }}>
|
||||||
{record.segmentCategory || '-'}
|
{record.segmentCategory || '-'}
|
||||||
</div>
|
</div>
|
||||||
)
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '状态',
|
||||||
|
width: 120,
|
||||||
|
align: 'center' as const,
|
||||||
|
render: (_: any, record: any) => {
|
||||||
|
const statusMap: Record<string, { text: string; color: string }> = {
|
||||||
|
pending: { text: '待切割', color: 'default' },
|
||||||
|
processing: { text: '切割中', color: 'processing' },
|
||||||
|
retry_waiting: { text: '等待重试', color: 'warning' },
|
||||||
|
completed: { text: '已完成', color: 'success' },
|
||||||
|
failed: { text: '失败', color: 'error' },
|
||||||
|
};
|
||||||
|
const item = statusMap[record.splitStatus] || { text: record.splitStatus || '-', color: 'default' };
|
||||||
|
return <Tag color={item.color}>{item.text}</Tag>;
|
||||||
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: '素材',
|
title: '素材',
|
||||||
width: 140,
|
width: 140,
|
||||||
|
|
||||||
align: 'center' as const,
|
align: 'center' as const,
|
||||||
render: (text: any, record: any) => (
|
render: (_: any, record: any) => (
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 8 }}>
|
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 8 }}>
|
||||||
|
|
||||||
|
|
||||||
{record.moduleProjectId ? (
|
{record.moduleProjectId ? (
|
||||||
<Button
|
<Button
|
||||||
type="text"
|
type="text"
|
||||||
@@ -233,27 +272,29 @@ function RemoveInfo() {
|
|||||||
) : (
|
) : (
|
||||||
<Button
|
<Button
|
||||||
type="text"
|
type="text"
|
||||||
onClick={() => handleGenerate(record.id)}
|
onClick={() => handleGenerate(String(record.id))}
|
||||||
style={{ color: '#656efa', fontSize: 12, padding: 0, display: 'flex', alignItems: 'center', gap: 4 }}
|
disabled={!canCreateReplication(record)}
|
||||||
|
style={{ color: canCreateReplication(record) ? '#656efa' : '#94a3b8', fontSize: 12, padding: 0, display: 'flex', alignItems: 'center', gap: 4 }}
|
||||||
>
|
>
|
||||||
视频生成
|
{canCreateReplication(record) ? '视频生成' : '待切割完成'}
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
),
|
||||||
}
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div style={{
|
<div
|
||||||
// height: '100%',
|
style={{
|
||||||
minHeight: 'calc(100vh - 90px)',
|
minHeight: 'calc(100vh - 90px)',
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
flexDirection: 'column',
|
flexDirection: 'column',
|
||||||
overflow: 'hidden',
|
overflow: 'hidden',
|
||||||
}}>
|
}}
|
||||||
<div style={{ background: '#fff',}}>
|
>
|
||||||
|
<div style={{ background: '#fff' }}>
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
|
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
|
||||||
<Button
|
<Button
|
||||||
type="text"
|
type="text"
|
||||||
@@ -266,14 +307,12 @@ function RemoveInfo() {
|
|||||||
|
|
||||||
{taskDetail ? (
|
{taskDetail ? (
|
||||||
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', justifyContent: 'space-between', overflow: 'hidden' }}>
|
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', justifyContent: 'space-between', overflow: 'hidden' }}>
|
||||||
<div style={{ flex: 0.4, background: '#fff', borderRadius: 12, }}>
|
<div style={{ flex: 0.4, background: '#fff', borderRadius: 12 }}>
|
||||||
|
|
||||||
{/* 213123 */}
|
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: 30, marginBottom: 12 }}>
|
<div style={{ display: 'flex', alignItems: 'center', gap: 30, marginBottom: 12 }}>
|
||||||
<div style={{ position: 'relative', width: 280, height: 160, borderRadius: 8, overflow: 'hidden', flexShrink: 0 }}>
|
<div style={{ position: 'relative', width: 280, height: 160, borderRadius: 8, overflow: 'hidden', flexShrink: 0 }}>
|
||||||
<video
|
<video
|
||||||
controls
|
controls
|
||||||
src={`${import.meta.env.VITE_API_BASE || "http://localhost:8000"}${taskDetail.videoUrl}`}
|
src={videoUrl}
|
||||||
style={{ width: '100%', height: '100%', objectFit: 'cover' }}
|
style={{ width: '100%', height: '100%', objectFit: 'cover' }}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -285,7 +324,6 @@ function RemoveInfo() {
|
|||||||
<div style={{ textAlign: 'right' }}>
|
<div style={{ textAlign: 'right' }}>
|
||||||
<span style={{ color: '#999', fontSize: 12 }}>上传时间: {taskDetail.createdAt}</span>
|
<span style={{ color: '#999', fontSize: 12 }}>上传时间: {taskDetail.createdAt}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
<div style={{ display: 'flex', marginBottom: 12 }}>
|
<div style={{ display: 'flex', marginBottom: 12 }}>
|
||||||
<span style={{ color: '#999', fontSize: 14, marginRight: 12, width: 80, flexShrink: 0 }}>产品名称:</span>
|
<span style={{ color: '#999', fontSize: 14, marginRight: 12, width: 80, flexShrink: 0 }}>产品名称:</span>
|
||||||
@@ -294,7 +332,7 @@ function RemoveInfo() {
|
|||||||
<div style={{ display: 'flex', marginBottom: 12 }}>
|
<div style={{ display: 'flex', marginBottom: 12 }}>
|
||||||
<span style={{ color: '#999', fontSize: 14, marginRight: 12, width: 80, flexShrink: 0 }}>受众群体:</span>
|
<span style={{ color: '#999', fontSize: 14, marginRight: 12, width: 80, flexShrink: 0 }}>受众群体:</span>
|
||||||
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap' }}>
|
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap' }}>
|
||||||
{(taskDetail.originalVideoAudience?.split('、') || []).map((point, index) => (
|
{(taskDetail.originalVideoAudience?.split('、') || []).map((point: string, index: number) => (
|
||||||
<Tag key={index} color="purple" style={{ fontSize: 12 }}>
|
<Tag key={index} color="purple" style={{ fontSize: 12 }}>
|
||||||
{point}
|
{point}
|
||||||
</Tag>
|
</Tag>
|
||||||
@@ -306,8 +344,6 @@ function RemoveInfo() {
|
|||||||
<span style={{ color: '#333' }}>{taskDetail.originalVideoContent}</span>
|
<span style={{ color: '#333' }}>{taskDetail.originalVideoContent}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -315,28 +351,31 @@ function RemoveInfo() {
|
|||||||
<div style={{ display: 'flex', gap: 16, marginBottom: 20, marginTop: 20 }}>
|
<div style={{ display: 'flex', gap: 16, marginBottom: 20, marginTop: 20 }}>
|
||||||
<Button
|
<Button
|
||||||
type="default"
|
type="default"
|
||||||
onClick={() => handleAutoGenerate()}
|
onClick={handleOpenTrimModal}
|
||||||
|
disabled={!videoUrl || splitLoading}
|
||||||
style={{
|
style={{
|
||||||
flex: 1,
|
flex: 1,
|
||||||
height: 48,
|
height: 48,
|
||||||
borderRadius: 8,
|
borderRadius: 8,
|
||||||
borderColor: '#6366f1',
|
borderColor: '#6366f1',
|
||||||
color: '#6366f1',
|
color: '#6366f1',
|
||||||
fontWeight: 500
|
fontWeight: 500,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
手动
|
手动
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
type="primary"
|
type="primary"
|
||||||
onClick={() => handleAutoGenerate()}
|
onClick={handleAutoGenerate}
|
||||||
|
loading={autoSplitLoading}
|
||||||
|
disabled={autoSplitLoading}
|
||||||
style={{
|
style={{
|
||||||
flex: 1,
|
flex: 1,
|
||||||
height: 48,
|
height: 48,
|
||||||
borderRadius: 8,
|
borderRadius: 8,
|
||||||
background: 'linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%)',
|
background: 'linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%)',
|
||||||
fontWeight: 500,
|
fontWeight: 500,
|
||||||
border: 'none'
|
border: 'none',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
自动
|
自动
|
||||||
@@ -350,11 +389,9 @@ function RemoveInfo() {
|
|||||||
pagination={false}
|
pagination={false}
|
||||||
bordered={false}
|
bordered={false}
|
||||||
rowKey="id"
|
rowKey="id"
|
||||||
scroll={{ y: '1005'}}
|
scroll={{ y: '1005' }}
|
||||||
// style={{ flex: 1 }}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div style={{ flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
<div style={{ flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
||||||
@@ -363,6 +400,17 @@ function RemoveInfo() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<VideoTrimPicker
|
||||||
|
open={trimModalVisible}
|
||||||
|
videoUrl={videoUrl}
|
||||||
|
title="手动视频切片"
|
||||||
|
loading={splitLoading}
|
||||||
|
minDuration={MIN_TRIM_SECONDS}
|
||||||
|
maxDuration={MAX_TRIM_SECONDS}
|
||||||
|
onCancel={() => setTrimModalVisible(false)}
|
||||||
|
onConfirm={handleCustomSplit}
|
||||||
|
/>
|
||||||
|
|
||||||
<Drawer
|
<Drawer
|
||||||
title={
|
title={
|
||||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', width: '100%' }}>
|
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', width: '100%' }}>
|
||||||
@@ -384,7 +432,7 @@ function RemoveInfo() {
|
|||||||
open={drawerVisible}
|
open={drawerVisible}
|
||||||
size={480}
|
size={480}
|
||||||
styles={{
|
styles={{
|
||||||
body: { padding: '24px' }
|
body: { padding: '24px' },
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 24 }}>
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 24 }}>
|
||||||
@@ -452,12 +500,11 @@ function RemoveInfo() {
|
|||||||
borderRadius: 8,
|
borderRadius: 8,
|
||||||
borderColor: '#6366f1',
|
borderColor: '#6366f1',
|
||||||
color: '#6366f1',
|
color: '#6366f1',
|
||||||
fontWeight: 500
|
fontWeight: 500,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{loading ? '生成中...' : '手动生成'}
|
{loading ? '生成中...' : '手动生成'}
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Drawer>
|
</Drawer>
|
||||||
|
|||||||
Reference in New Issue
Block a user