feat: 生成页面剪辑模式选择 + 智能编排时间轴
- 新增剪辑模式选择器(one-take/pip/voiceover/voice_pip) - 新增智能编排 API(auto-generate, getEditPlans, deleteEditPlan 等) - 新增时间轴组件展示编排预览 - 新增 AutoArrangePanel 智能编排面板 - GenerationTaskItem 和 GeneratedVideoItem 新增 editing_mode 字段 - 生成弹窗新增剪辑模式选择 UI
This commit is contained in:
@@ -1,4 +1,9 @@
|
||||
import apiClient from './client';
|
||||
/**
|
||||
* 编辑计划 API
|
||||
*/
|
||||
import apiClient from "./client";
|
||||
|
||||
export type EditingMode = "one-take" | "pip" | "voiceover" | "voice_pip";
|
||||
|
||||
export interface EditTemplateItem {
|
||||
id: string;
|
||||
@@ -19,6 +24,8 @@ export interface EditPlanClipItem {
|
||||
start_time: number;
|
||||
duration: number;
|
||||
reason: string;
|
||||
layer?: "main" | "pip" | "broll";
|
||||
thumbnail_url?: string;
|
||||
}
|
||||
|
||||
export interface EditPlanItem {
|
||||
@@ -29,8 +36,11 @@ export interface EditPlanItem {
|
||||
asset_library_id: string;
|
||||
title_id: string;
|
||||
status: string;
|
||||
editing_mode?: EditingMode;
|
||||
summary: string;
|
||||
clips: EditPlanClipItem[];
|
||||
created_at?: string;
|
||||
updated_at?: string;
|
||||
}
|
||||
|
||||
export const getEditTemplates = async (projectId: string, workspaceId: string): Promise<EditTemplateItem[]> => {
|
||||
@@ -47,3 +57,45 @@ export const createEditPlan = async ({ projectId, data }: { projectId: string; d
|
||||
const response = await apiClient.post(`/projects/${projectId}/edit-plans`, data);
|
||||
return response.data;
|
||||
};
|
||||
|
||||
/**
|
||||
* 获取编排计划列表
|
||||
*/
|
||||
export const getEditPlans = async (projectId: string): Promise<EditPlanItem[]> => {
|
||||
const response = await apiClient.get(`/projects/${projectId}/edit-plans/`);
|
||||
return response.data.items || [];
|
||||
};
|
||||
|
||||
/**
|
||||
* 获取单个编排计划
|
||||
*/
|
||||
export const getEditPlan = async (projectId: string, planId: string): Promise<EditPlanItem> => {
|
||||
const response = await apiClient.get(`/projects/${projectId}/edit-plans/${planId}`);
|
||||
return response.data;
|
||||
};
|
||||
|
||||
/**
|
||||
* 智能编排 - 自动生成编辑计划
|
||||
*/
|
||||
export const autoGenerateEditPlan = async (projectId: string, params: {
|
||||
editing_mode: EditingMode;
|
||||
target_duration?: number;
|
||||
}): Promise<EditPlanItem> => {
|
||||
const response = await apiClient.post(`/projects/${projectId}/edit-plans/auto-generate/`, params);
|
||||
return response.data;
|
||||
};
|
||||
|
||||
/**
|
||||
* 删除编辑计划
|
||||
*/
|
||||
export const deleteEditPlan = async (projectId: string, planId: string): Promise<void> => {
|
||||
await apiClient.delete(`/projects/${projectId}/edit-plans/${planId}`);
|
||||
};
|
||||
|
||||
/**
|
||||
* 更新编辑计划
|
||||
*/
|
||||
export const updateEditPlan = async (projectId: string, planId: string, data: Partial<EditPlanItem>): Promise<EditPlanItem> => {
|
||||
const response = await apiClient.patch(`/projects/${projectId}/edit-plans/${planId}`, data);
|
||||
return response.data;
|
||||
};
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
import apiClient from './client';
|
||||
/**
|
||||
* 视频生成 API
|
||||
*/
|
||||
import apiClient from "./client";
|
||||
import type { EditingMode } from "./editPlans";
|
||||
|
||||
export interface GenerationTaskItem {
|
||||
id: string;
|
||||
@@ -8,10 +12,13 @@ export interface GenerationTaskItem {
|
||||
strategy_id?: string | null;
|
||||
voice_library_id?: string | null;
|
||||
edit_plan_id?: string | null;
|
||||
status: 'pending' | 'running' | 'completed' | 'failed' | 'cancelled';
|
||||
editing_mode?: EditingMode;
|
||||
status: "pending" | "running" | "completed" | "failed" | "cancelled";
|
||||
progress: number;
|
||||
result_count: number;
|
||||
error_message?: string | null;
|
||||
created_at?: string;
|
||||
updated_at?: string;
|
||||
}
|
||||
|
||||
export interface GeneratedVideoItem {
|
||||
@@ -28,8 +35,9 @@ export interface GeneratedVideoItem {
|
||||
height: number;
|
||||
fps: number;
|
||||
status?: string;
|
||||
review_status?: 'pending_review' | 'approved' | 'rejected';
|
||||
review_status?: "pending_review" | "approved" | "rejected";
|
||||
generation_params?: Record<string, unknown>;
|
||||
editing_mode?: EditingMode;
|
||||
}
|
||||
|
||||
export const createGenerationTask = async (data: {
|
||||
@@ -39,9 +47,10 @@ export const createGenerationTask = async (data: {
|
||||
strategy_id?: string;
|
||||
voice_library_id?: string;
|
||||
edit_plan_id?: string;
|
||||
editing_mode?: EditingMode;
|
||||
created_by_user_id?: string;
|
||||
}): Promise<GenerationTaskItem> => {
|
||||
const response = await apiClient.post('/generation/tasks', data);
|
||||
const response = await apiClient.post("/generation/tasks", data);
|
||||
return response.data;
|
||||
};
|
||||
|
||||
@@ -56,11 +65,11 @@ export const getGenerationResults = async (taskId: string): Promise<GeneratedVid
|
||||
};
|
||||
|
||||
export const getGeneratedVideos = async (projectId: string): Promise<GeneratedVideoItem[]> => {
|
||||
const response = await apiClient.get('/generated-videos', { params: { project_id: projectId } });
|
||||
const response = await apiClient.get("/generated-videos", { params: { project_id: projectId } });
|
||||
return response.data.items;
|
||||
};
|
||||
|
||||
export const updateGeneratedVideoReviewStatus = async (videoId: string, reviewStatus: 'pending_review' | 'approved' | 'rejected'): Promise<GeneratedVideoItem> => {
|
||||
export const updateGeneratedVideoReviewStatus = async (videoId: string, reviewStatus: "pending_review" | "approved" | "rejected"): Promise<GeneratedVideoItem> => {
|
||||
const response = await apiClient.patch(`/generated-videos/${videoId}/review`, { review_status: reviewStatus });
|
||||
return response.data;
|
||||
};
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
/* V21 视频剪辑 */
|
||||
/* V21 视频剪辑 - 增强版 */
|
||||
.xx-project-generation {
|
||||
}
|
||||
|
||||
/* 模板网格 */
|
||||
.xx-template-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
@@ -76,3 +77,409 @@
|
||||
font-size: 12px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
/* 智能编排面板 */
|
||||
.auto-arrange-panel {
|
||||
padding: 16px;
|
||||
background: linear-gradient(135deg, #f8fafc 0%, #f1f5f9 100%);
|
||||
border-radius: 12px;
|
||||
border: 1px solid #e2e8f0;
|
||||
}
|
||||
|
||||
.panel-header {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.panel-header h4 {
|
||||
margin: 0 0 4px;
|
||||
font-size: 16px;
|
||||
color: #1e293b;
|
||||
}
|
||||
|
||||
.panel-header p {
|
||||
margin: 0;
|
||||
font-size: 13px;
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
/* 模式选择器 */
|
||||
.mode-selector-section {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.mode-selector-section label {
|
||||
display: block;
|
||||
margin-bottom: 8px;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: #475569;
|
||||
}
|
||||
|
||||
.mode-cards {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.mode-cards {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
}
|
||||
|
||||
.mode-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
padding: 12px 8px;
|
||||
background: white;
|
||||
border: 2px solid #e2e8f0;
|
||||
border-radius: 10px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.mode-card:hover {
|
||||
border-color: #94a3b8;
|
||||
}
|
||||
|
||||
.mode-card.selected {
|
||||
border-width: 2px;
|
||||
}
|
||||
|
||||
.mode-icon {
|
||||
font-size: 24px;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.mode-label {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: #334155;
|
||||
}
|
||||
|
||||
/* 时长选择器 */
|
||||
.duration-selector {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.duration-selector label {
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: #475569;
|
||||
}
|
||||
|
||||
/* 生成按钮 */
|
||||
.generate-btn {
|
||||
width: 100%;
|
||||
height: 40px;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
border: none;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
/* 编排列表 */
|
||||
.plans-list {
|
||||
border-top: 1px solid #e2e8f0;
|
||||
padding-top: 16px;
|
||||
}
|
||||
|
||||
.plans-list h5 {
|
||||
margin: 0 0 12px;
|
||||
font-size: 14px;
|
||||
color: #475569;
|
||||
}
|
||||
|
||||
.plan-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 12px;
|
||||
background: white;
|
||||
border: 2px solid #e2e8f0;
|
||||
border-radius: 10px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.plan-item:hover {
|
||||
border-color: #94a3b8;
|
||||
}
|
||||
|
||||
.plan-item.selected {
|
||||
border-color: #4f46e5;
|
||||
background: #f5f3ff;
|
||||
}
|
||||
|
||||
.plan-info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.plan-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.plan-summary {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: #1e293b;
|
||||
}
|
||||
|
||||
.plan-meta {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
margin-top: 4px;
|
||||
font-size: 12px;
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
.plan-actions {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
/* 时间轴区域 */
|
||||
.timeline-section {
|
||||
margin-top: 20px;
|
||||
padding-top: 20px;
|
||||
border-top: 1px solid #e2e8f0;
|
||||
}
|
||||
|
||||
.section-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.section-header h4 {
|
||||
margin: 0;
|
||||
font-size: 15px;
|
||||
color: #1e293b;
|
||||
}
|
||||
|
||||
/* 时间轴容器 */
|
||||
.timeline-container {
|
||||
background: white;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 10px;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.timeline-controls {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 12px;
|
||||
font-size: 13px;
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
.timeline-duration {
|
||||
font-weight: 600;
|
||||
color: #334155;
|
||||
}
|
||||
|
||||
.timeline-clips-count {
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
.timeline-wrapper {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.timeline-layers {
|
||||
flex-shrink: 0;
|
||||
width: 70px;
|
||||
}
|
||||
|
||||
.layer-label {
|
||||
height: 28px;
|
||||
line-height: 28px;
|
||||
font-size: 11px;
|
||||
color: #64748b;
|
||||
text-align: right;
|
||||
padding-right: 8px;
|
||||
}
|
||||
|
||||
.layer-label.main {
|
||||
color: #4f46e5;
|
||||
}
|
||||
|
||||
.layer-label.pip {
|
||||
color: #f97316;
|
||||
}
|
||||
|
||||
.layer-label.broll {
|
||||
color: #10b981;
|
||||
}
|
||||
|
||||
.timeline-body {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
/* 时间刻度 */
|
||||
.timeline-ruler {
|
||||
position: relative;
|
||||
height: 24px;
|
||||
border-bottom: 1px solid #e2e8f0;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.timeline-tick {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
transform: translateX(-50%);
|
||||
}
|
||||
|
||||
.timeline-tick::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
bottom: 0;
|
||||
width: 1px;
|
||||
height: 6px;
|
||||
background: #cbd5e1;
|
||||
}
|
||||
|
||||
.timeline-tick span {
|
||||
font-size: 10px;
|
||||
color: #94a3b8;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* 轨道 */
|
||||
.timeline-tracks {
|
||||
position: relative;
|
||||
height: 88px;
|
||||
}
|
||||
|
||||
.timeline-track {
|
||||
height: 28px;
|
||||
border-bottom: 1px dashed #f1f5f9;
|
||||
}
|
||||
|
||||
.timeline-track:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
/* 片段 */
|
||||
.timeline-clip {
|
||||
position: absolute;
|
||||
height: 24px;
|
||||
top: 2px;
|
||||
border-radius: 4px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0 6px;
|
||||
font-size: 10px;
|
||||
cursor: pointer;
|
||||
transition: opacity 0.2s;
|
||||
overflow: hidden;
|
||||
min-width: 30px;
|
||||
}
|
||||
|
||||
.timeline-clip:hover {
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
.timeline-clip-main {
|
||||
background: linear-gradient(135deg, #4f46e5 0%, #6366f1 100%);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.timeline-clip-pip {
|
||||
background: linear-gradient(135deg, #f97316 0%, #fb923c 100%);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.timeline-clip-broll {
|
||||
background: linear-gradient(135deg, #10b981 0%, #34d399 100%);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.clip-label {
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.clip-duration {
|
||||
flex-shrink: 0;
|
||||
margin-left: 4px;
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
/* 弹窗样式 */
|
||||
.modal-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
/* 剪辑模式选择 */
|
||||
.editing-mode-group {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.editing-mode-group .ant-radio-button-wrapper {
|
||||
flex: 1;
|
||||
min-width: 130px;
|
||||
height: auto !important;
|
||||
padding: 12px !important;
|
||||
border-radius: 8px !important;
|
||||
}
|
||||
|
||||
.mode-option {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.mode-emoji {
|
||||
font-size: 20px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.mode-text {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.mode-name {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: #1e293b;
|
||||
}
|
||||
|
||||
.mode-desc {
|
||||
font-size: 11px;
|
||||
color: #64748b;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
/* 选中方案提示 */
|
||||
.selected-plan-hint {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 12px;
|
||||
background: #f0fdf4;
|
||||
border: 1px solid #bbf7d0;
|
||||
border-radius: 8px;
|
||||
font-size: 13px;
|
||||
color: #166534;
|
||||
}
|
||||
|
||||
@@ -1,33 +1,322 @@
|
||||
/**
|
||||
* 视频剪辑/生成页面 - V21 完全对标
|
||||
* 功能:剪辑模式选择器 + 智能编排时间轴
|
||||
*/
|
||||
import React, { useState } from 'react';
|
||||
import { Select, Modal, Form, message } from 'antd';
|
||||
import './ProjectGeneration.css';
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { Select, Modal, Form, message, Radio, Spin, Tooltip, Button, Empty, Tag } from "antd";
|
||||
import { AutoGenerateOutlined, PlayCircleOutlined, DeleteOutlined, CheckCircleOutlined } from "ant-design/icons";
|
||||
import "./ProjectGeneration.css";
|
||||
import { autoGenerateEditPlan, getEditPlans, deleteEditPlan, EditingMode, EditPlanItem, EditPlanClipItem } from "@/api/editPlans";
|
||||
|
||||
const TITLE_OPTIONS = [
|
||||
{ id: '1', name: '产品介绍', content: '完整介绍产品功能和优势' },
|
||||
{ id: '2', name: '用户评价', content: '展示用户真实反馈和评分' },
|
||||
{ id: '3', name: '功能演示', content: '详细演示产品各项功能' },
|
||||
/**
|
||||
* 剪辑模式配置
|
||||
*/
|
||||
const EDITING_MODE_OPTIONS: {
|
||||
value: EditingMode;
|
||||
label: string;
|
||||
icon: string;
|
||||
description: string;
|
||||
color: string;
|
||||
}[] = [
|
||||
{
|
||||
value: "one-take",
|
||||
label: "一镜到底",
|
||||
icon: "🎬",
|
||||
description: "所有素材顺序拼接,适合连续场景",
|
||||
color: "#4f46e5",
|
||||
},
|
||||
{
|
||||
value: "pip",
|
||||
label: "画中画",
|
||||
icon: "🖼️",
|
||||
description: "主画面 + 角落小窗口,适合对比展示",
|
||||
color: "#f97316",
|
||||
},
|
||||
{
|
||||
value: "voiceover",
|
||||
label: "口播",
|
||||
icon: "🎙️",
|
||||
description: "人物口播为主,穿插 B-roll",
|
||||
color: "#10b981",
|
||||
},
|
||||
{
|
||||
value: "voice_pip",
|
||||
label: "口播+画中画",
|
||||
icon: "🎙️🖼️",
|
||||
description: "口播主体 + 画中画叠加",
|
||||
color: "#8b5cf6",
|
||||
},
|
||||
];
|
||||
|
||||
const TITLE_OPTIONS = [
|
||||
{ id: "1", name: "产品介绍", content: "完整介绍产品功能和优势" },
|
||||
{ id: "2", name: "用户评价", content: "展示用户真实反馈和评分" },
|
||||
{ id: "3", name: "功能演示", content: "详细演示产品各项功能" },
|
||||
];
|
||||
|
||||
/**
|
||||
* 时间轴片段组件
|
||||
*/
|
||||
const TimelineClip: React.FC<{
|
||||
clip: EditPlanClipItem;
|
||||
scale: number;
|
||||
totalDuration: number;
|
||||
}> = ({ clip, scale, totalDuration }) => {
|
||||
const left = (clip.start_time / totalDuration) * 100;
|
||||
const width = (clip.duration / totalDuration) * 100;
|
||||
const layerIndex = clip.layer === "pip" ? 1 : clip.layer === "broll" ? 2 : 0;
|
||||
|
||||
return (
|
||||
<Tooltip
|
||||
title={
|
||||
<div>
|
||||
<div><strong>{clip.asset_name}</strong></div>
|
||||
<div>时长: {clip.duration.toFixed(1)}s</div>
|
||||
<div>理由: {clip.reason}</div>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div
|
||||
className={`timeline-clip timeline-clip-${clip.layer || "main"}`}
|
||||
style={{
|
||||
left: `${left}%`,
|
||||
width: `${Math.max(width, 2)}%`,
|
||||
top: `${layerIndex * 28}px`,
|
||||
}}
|
||||
>
|
||||
<span className="clip-label">{clip.asset_name.substring(0, 8)}</span>
|
||||
<span className="clip-duration">{clip.duration.toFixed(0)}s</span>
|
||||
</div>
|
||||
</Tooltip>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* 时间轴组件
|
||||
*/
|
||||
const Timeline: React.FC<{
|
||||
clips: EditPlanClipItem[];
|
||||
totalDuration: number;
|
||||
}> = ({ clips, totalDuration }) => {
|
||||
const [scale, setScale] = useState(1);
|
||||
|
||||
const tickCount = 10;
|
||||
const ticks = Array.from({ length: tickCount + 1 }, (_, i) => {
|
||||
const time = (totalDuration / tickCount) * i;
|
||||
return { time, position: (time / totalDuration) * 100 };
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="timeline-container">
|
||||
<div className="timeline-controls">
|
||||
<span className="timeline-duration">总时长: {totalDuration.toFixed(1)}s</span>
|
||||
<span className="timeline-clips-count">{clips.length} 个片段</span>
|
||||
</div>
|
||||
<div className="timeline-wrapper">
|
||||
<div className="timeline-layers">
|
||||
<div className="layer-label main">主画面</div>
|
||||
<div className="layer-label pip">画中画</div>
|
||||
<div className="layer-label broll">B-roll</div>
|
||||
</div>
|
||||
<div className="timeline-body">
|
||||
<div className="timeline-ruler">
|
||||
{ticks.map((tick, i) => (
|
||||
<div key={i} className="timeline-tick" style={{ left: `${tick.position}%` }}>
|
||||
<span>{tick.time.toFixed(0)}s</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="timeline-tracks">
|
||||
<div className="timeline-track main-track" />
|
||||
<div className="timeline-track pip-track" />
|
||||
<div className="timeline-track broll-track" />
|
||||
{clips.map((clip) => (
|
||||
<TimelineClip key={clip.id} clip={clip} scale={scale} totalDuration={totalDuration} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* 智能编排面板
|
||||
*/
|
||||
const AutoArrangePanel: React.FC<{
|
||||
projectId: string;
|
||||
onSelectPlan: (plan: EditPlanItem) => void;
|
||||
selectedPlanId?: string;
|
||||
}> = ({ projectId, onSelectPlan, selectedPlanId }) => {
|
||||
const [plans, setPlans] = useState<EditPlanItem[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [generating, setGenerating] = useState(false);
|
||||
const [selectedMode, setSelectedMode] = useState<EditingMode>("one-take");
|
||||
const [targetDuration, setTargetDuration] = useState<number | undefined>(60);
|
||||
|
||||
useEffect(() => { fetchPlans(); }, [projectId]);
|
||||
|
||||
const fetchPlans = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const data = await getEditPlans(projectId);
|
||||
setPlans(data);
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch edit plans:", error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleAutoGenerate = async () => {
|
||||
setGenerating(true);
|
||||
try {
|
||||
const plan = await autoGenerateEditPlan(projectId, { editing_mode: selectedMode, target_duration: targetDuration });
|
||||
message.success("智能编排完成");
|
||||
setPlans((prev) => [plan, ...prev]);
|
||||
onSelectPlan(plan);
|
||||
} catch (error: any) {
|
||||
message.error(error?.response?.data?.message || "智能编排失败");
|
||||
} finally {
|
||||
setGenerating(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeletePlan = async (planId: string, e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
try {
|
||||
await deleteEditPlan(projectId, planId);
|
||||
setPlans((prev) => prev.filter((p) => p.id !== planId));
|
||||
message.success("删除成功");
|
||||
} catch (error) {
|
||||
message.error("删除失败");
|
||||
}
|
||||
};
|
||||
|
||||
const getModeLabel = (mode?: EditingMode) => {
|
||||
if (!mode) return null;
|
||||
const option = EDITING_MODE_OPTIONS.find((o) => o.value === mode);
|
||||
return option ? `${option.icon} ${option.label}` : mode;
|
||||
};
|
||||
|
||||
const getModeColor = (mode?: EditingMode) => {
|
||||
if (!mode) return "#64748b";
|
||||
const option = EDITING_MODE_OPTIONS.find((o) => o.value === mode);
|
||||
return option?.color || "#64748b";
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="auto-arrange-panel">
|
||||
<div className="panel-header">
|
||||
<h4>🪄 智能编排</h4>
|
||||
<p>AI 自动分析素材并生成剪辑方案</p>
|
||||
</div>
|
||||
<div className="mode-selector-section">
|
||||
<label>选择剪辑模式:</label>
|
||||
<div className="mode-cards">
|
||||
{EDITING_MODE_OPTIONS.map((mode) => (
|
||||
<div
|
||||
key={mode.value}
|
||||
className={`mode-card ${selectedMode === mode.value ? "selected" : ""}`}
|
||||
onClick={() => setSelectedMode(mode.value)}
|
||||
style={{
|
||||
borderColor: selectedMode === mode.value ? mode.color : undefined,
|
||||
background: selectedMode === mode.value ? `${mode.color}10` : undefined,
|
||||
}}
|
||||
>
|
||||
<span className="mode-icon">{mode.icon}</span>
|
||||
<span className="mode-label">{mode.label}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="duration-selector">
|
||||
<label>目标时长:</label>
|
||||
<Select value={targetDuration} onChange={setTargetDuration} style={{ width: 120 }}>
|
||||
<Select.Option value={15}>15秒</Select.Option>
|
||||
<Select.Option value={30}>30秒</Select.Option>
|
||||
<Select.Option value={60}>60秒</Select.Option>
|
||||
<Select.Option value={90}>90秒</Select.Option>
|
||||
<Select.Option value={120}>120秒</Select.Option>
|
||||
</Select>
|
||||
</div>
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<AutoGenerateOutlined />}
|
||||
loading={generating}
|
||||
onClick={handleAutoGenerate}
|
||||
className="generate-btn"
|
||||
style={{ background: EDITING_MODE_OPTIONS.find((m) => m.value === selectedMode)?.color }}
|
||||
>
|
||||
智能编排生成
|
||||
</Button>
|
||||
<div className="plans-list">
|
||||
<h5>编排方案列表</h5>
|
||||
{loading ? (
|
||||
<Spin size="small" />
|
||||
) : plans.length === 0 ? (
|
||||
<Empty description="暂无编排方案" image={Empty.PRESENTED_IMAGE_SIMPLE} />
|
||||
) : (
|
||||
plans.map((plan) => (
|
||||
<div key={plan.id} className={`plan-item ${selectedPlanId === plan.id ? "selected" : ""}`} onClick={() => onSelectPlan(plan)}>
|
||||
<div className="plan-info">
|
||||
<div className="plan-header">
|
||||
<span className="plan-summary">{plan.summary || "未命名方案"}</span>
|
||||
<Tag color={getModeColor(plan.editing_mode)} style={{ marginLeft: 8 }}>{getModeLabel(plan.editing_mode)}</Tag>
|
||||
</div>
|
||||
<div className="plan-meta">
|
||||
<span>📎 {plan.clips.length} 个片段</span>
|
||||
<span>⏱️ {plan.clips.reduce((sum, c) => sum + c.duration, 0).toFixed(1)}s</span>
|
||||
{plan.created_at && <span>{new Date(plan.created_at).toLocaleDateString()}</span>}
|
||||
</div>
|
||||
</div>
|
||||
<div className="plan-actions">
|
||||
<Tooltip title="使用此方案生成">
|
||||
<Button type="text" icon={<PlayCircleOutlined />} onClick={(e) => { e.stopPropagation(); onSelectPlan(plan); }} />
|
||||
</Tooltip>
|
||||
<Tooltip title="删除方案">
|
||||
<Button type="text" danger icon={<DeleteOutlined />} onClick={(e) => handleDeletePlan(plan.id, e)} />
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const ProjectGeneration: React.FC = () => {
|
||||
const [selectedTitle, setSelectedTitle] = useState<string>('');
|
||||
const [selectedTitle, setSelectedTitle] = useState<string>("");
|
||||
const [generateOpen, setGenerateOpen] = useState(false);
|
||||
const [editingMode, setEditingMode] = useState<EditingMode>("one-take");
|
||||
const [selectedPlan, setSelectedPlan] = useState<EditPlanItem | null>(null);
|
||||
const [form] = Form.useForm();
|
||||
|
||||
const projectId = "demo-project-id";
|
||||
|
||||
const handleGenerate = async () => {
|
||||
await form.validateFields();
|
||||
try {
|
||||
message.success('成片生成任务已创建');
|
||||
const values = form.getFieldsValue();
|
||||
console.log("生成任务参数:", { ...values, editing_mode: editingMode, edit_plan_id: selectedPlan?.id });
|
||||
message.success("成片生成任务已创建");
|
||||
setGenerateOpen(false);
|
||||
form.resetFields();
|
||||
setSelectedTitle('');
|
||||
setSelectedTitle("");
|
||||
setSelectedPlan(null);
|
||||
} catch (error: any) {
|
||||
message.error('创建生成任务失败');
|
||||
message.error("创建生成任务失败");
|
||||
}
|
||||
};
|
||||
|
||||
const handlePlanSelect = (plan: EditPlanItem) => {
|
||||
setSelectedPlan(plan);
|
||||
setEditingMode(plan.editing_mode || "one-take");
|
||||
};
|
||||
|
||||
const getModeOption = (mode: EditingMode) => EDITING_MODE_OPTIONS.find((o) => o.value === mode);
|
||||
|
||||
return (
|
||||
<div className="xx-project-generation">
|
||||
<div className="xx-page-head">
|
||||
@@ -35,9 +324,7 @@ const ProjectGeneration: React.FC = () => {
|
||||
<h2>视频剪辑</h2>
|
||||
<p>生成您的成片</p>
|
||||
</div>
|
||||
<button className="btn primary" onClick={() => setGenerateOpen(true)}>
|
||||
+ 开始生成
|
||||
</button>
|
||||
<button className="btn primary" onClick={() => setGenerateOpen(true)}>+ 开始生成</button>
|
||||
</div>
|
||||
|
||||
<div className="xx-card">
|
||||
@@ -45,20 +332,14 @@ const ProjectGeneration: React.FC = () => {
|
||||
<div className="xx-template-grid">
|
||||
{TITLE_OPTIONS.map((title, idx) => {
|
||||
const colors = [
|
||||
'linear-gradient(135deg,#4f46e5,#818cf8)',
|
||||
'linear-gradient(135deg,#f97316,#ef4444)',
|
||||
'linear-gradient(135deg,#10b981,#0284c7)',
|
||||
'linear-gradient(135deg,#64748b,#334155)',
|
||||
"linear-gradient(135deg,#4f46e5,#818cf8)",
|
||||
"linear-gradient(135deg,#f97316,#ef4444)",
|
||||
"linear-gradient(135deg,#10b981,#0284c7)",
|
||||
"linear-gradient(135deg,#64748b,#334155)",
|
||||
];
|
||||
return (
|
||||
<div
|
||||
key={title.id}
|
||||
className={`xx-choice ${selectedTitle === title.id ? 'selected' : ''}`}
|
||||
onClick={() => setSelectedTitle(title.id)}
|
||||
>
|
||||
<div className="cover" style={{ background: colors[idx % 4] }}>
|
||||
📹
|
||||
</div>
|
||||
<div key={title.id} className={`xx-choice ${selectedTitle === title.id ? "selected" : ""}`} onClick={() => setSelectedTitle(title.id)}>
|
||||
<div className="cover" style={{ background: colors[idx % 4] }}>📹</div>
|
||||
{selectedTitle === title.id && <div className="check">✓</div>}
|
||||
<b>{title.name}</b>
|
||||
<p>{title.content.substring(0, 50)}...</p>
|
||||
@@ -68,37 +349,71 @@ const ProjectGeneration: React.FC = () => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="xx-card">
|
||||
<AutoArrangePanel projectId={projectId} onSelectPlan={handlePlanSelect} selectedPlanId={selectedPlan?.id} />
|
||||
{selectedPlan && selectedPlan.clips.length > 0 && (
|
||||
<div className="timeline-section">
|
||||
<div className="section-header">
|
||||
<h4>📺 编排预览</h4>
|
||||
<Button type="primary" icon={<PlayCircleOutlined />} onClick={() => setGenerateOpen(true)}>使用此方案生成</Button>
|
||||
</div>
|
||||
<Timeline clips={selectedPlan.clips} totalDuration={selectedPlan.clips.reduce((sum, c) => sum + c.duration, 0)} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Modal
|
||||
title="生成新成片"
|
||||
title={
|
||||
<div className="modal-title">
|
||||
<span>生成新成片</span>
|
||||
{editingMode && <Tag color={getModeOption(editingMode)?.color}>{getModeOption(editingMode)?.icon} {getModeOption(editingMode)?.label}</Tag>}
|
||||
</div>
|
||||
}
|
||||
open={generateOpen}
|
||||
okText="开始生成"
|
||||
cancelText="取消"
|
||||
onOk={handleGenerate}
|
||||
onCancel={() => setGenerateOpen(false)}
|
||||
width={600}
|
||||
>
|
||||
<Form form={form} layout="vertical">
|
||||
<Form.Item
|
||||
name="template_id"
|
||||
label="选择模板"
|
||||
rules={[{ required: true, message: '请选择模板' }]}
|
||||
>
|
||||
<Form.Item label="剪辑模式" required>
|
||||
<Radio.Group value={editingMode} onChange={(e) => setEditingMode(e.target.value)} className="editing-mode-group">
|
||||
{EDITING_MODE_OPTIONS.map((mode) => (
|
||||
<Radio.Button key={mode.value} value={mode.value} className="editing-mode-btn">
|
||||
<div className="mode-option">
|
||||
<span className="mode-emoji">{mode.icon}</span>
|
||||
<div className="mode-text">
|
||||
<span className="mode-name">{mode.label}</span>
|
||||
<span className="mode-desc">{mode.description}</span>
|
||||
</div>
|
||||
</div>
|
||||
</Radio.Button>
|
||||
))}
|
||||
</Radio.Group>
|
||||
</Form.Item>
|
||||
<Form.Item name="template_id" label="选择模板" rules={[{ required: true, message: "请选择模板" }]}>
|
||||
<Select placeholder="选择剪辑模板">
|
||||
<Select.Option value="basic">基础模板</Select.Option>
|
||||
<Select.Option value="product">产品介绍</Select.Option>
|
||||
<Select.Option value="lifestyle">生活方式</Select.Option>
|
||||
</Select>
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name="duration"
|
||||
label="视频时长"
|
||||
rules={[{ required: true, message: '请选择时长' }]}
|
||||
>
|
||||
<Form.Item name="duration" label="视频时长" rules={[{ required: true, message: "请选择时长" }]}>
|
||||
<Select placeholder="选择时长">
|
||||
<Select.Option value="15">15秒</Select.Option>
|
||||
<Select.Option value="30">30秒</Select.Option>
|
||||
<Select.Option value="60">60秒</Select.Option>
|
||||
</Select>
|
||||
</Form.Item>
|
||||
{selectedPlan && (
|
||||
<Form.Item>
|
||||
<div className="selected-plan-hint">
|
||||
<CheckCircleOutlined style={{ color: "#52c41a" }} />
|
||||
<span>已选择编排方案: <strong>{selectedPlan.summary || "未命名方案"}</strong> ({selectedPlan.clips.length} 个片段)</span>
|
||||
</div>
|
||||
</Form.Item>
|
||||
)}
|
||||
</Form>
|
||||
</Modal>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user