From a37100af95d65a2405c6c71224cc2c9a8d5cb486 Mon Sep 17 00:00:00 2001 From: Coze Agent Date: Fri, 26 Jun 2026 21:10:04 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E7=94=9F=E6=88=90=E9=A1=B5=E9=9D=A2?= =?UTF-8?q?=E5=89=AA=E8=BE=91=E6=A8=A1=E5=BC=8F=E9=80=89=E6=8B=A9=20+=20?= =?UTF-8?q?=E6=99=BA=E8=83=BD=E7=BC=96=E6=8E=92=E6=97=B6=E9=97=B4=E8=BD=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增剪辑模式选择器(one-take/pip/voiceover/voice_pip) - 新增智能编排 API(auto-generate, getEditPlans, deleteEditPlan 等) - 新增时间轴组件展示编排预览 - 新增 AutoArrangePanel 智能编排面板 - GenerationTaskItem 和 GeneratedVideoItem 新增 editing_mode 字段 - 生成弹窗新增剪辑模式选择 UI --- apps/web/src/api/editPlans.ts | 54 ++- apps/web/src/api/generation.ts | 21 +- .../src/pages/workspace/ProjectGeneration.css | 409 +++++++++++++++++- .../src/pages/workspace/ProjectGeneration.tsx | 389 +++++++++++++++-- 4 files changed, 828 insertions(+), 45 deletions(-) diff --git a/apps/web/src/api/editPlans.ts b/apps/web/src/api/editPlans.ts index bbfe3bfc1..98dd71cf6 100644 --- a/apps/web/src/api/editPlans.ts +++ b/apps/web/src/api/editPlans.ts @@ -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 => { @@ -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 => { + const response = await apiClient.get(`/projects/${projectId}/edit-plans/`); + return response.data.items || []; +}; + +/** + * 获取单个编排计划 + */ +export const getEditPlan = async (projectId: string, planId: string): Promise => { + 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 => { + const response = await apiClient.post(`/projects/${projectId}/edit-plans/auto-generate/`, params); + return response.data; +}; + +/** + * 删除编辑计划 + */ +export const deleteEditPlan = async (projectId: string, planId: string): Promise => { + await apiClient.delete(`/projects/${projectId}/edit-plans/${planId}`); +}; + +/** + * 更新编辑计划 + */ +export const updateEditPlan = async (projectId: string, planId: string, data: Partial): Promise => { + const response = await apiClient.patch(`/projects/${projectId}/edit-plans/${planId}`, data); + return response.data; +}; diff --git a/apps/web/src/api/generation.ts b/apps/web/src/api/generation.ts index 7d07a23df..83e4f6f9c 100644 --- a/apps/web/src/api/generation.ts +++ b/apps/web/src/api/generation.ts @@ -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; + 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 => { - 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 => { - 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 => { +export const updateGeneratedVideoReviewStatus = async (videoId: string, reviewStatus: "pending_review" | "approved" | "rejected"): Promise => { const response = await apiClient.patch(`/generated-videos/${videoId}/review`, { review_status: reviewStatus }); return response.data; }; diff --git a/apps/web/src/pages/workspace/ProjectGeneration.css b/apps/web/src/pages/workspace/ProjectGeneration.css index db7a1de78..b9330ed7f 100644 --- a/apps/web/src/pages/workspace/ProjectGeneration.css +++ b/apps/web/src/pages/workspace/ProjectGeneration.css @@ -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; +} diff --git a/apps/web/src/pages/workspace/ProjectGeneration.tsx b/apps/web/src/pages/workspace/ProjectGeneration.tsx index e5ef7de03..acd0d9d84 100644 --- a/apps/web/src/pages/workspace/ProjectGeneration.tsx +++ b/apps/web/src/pages/workspace/ProjectGeneration.tsx @@ -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 ( + +
{clip.asset_name}
+
时长: {clip.duration.toFixed(1)}s
+
理由: {clip.reason}
+ + } + > +
+ {clip.asset_name.substring(0, 8)} + {clip.duration.toFixed(0)}s +
+
+ ); +}; + +/** + * 时间轴组件 + */ +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 ( +
+
+ 总时长: {totalDuration.toFixed(1)}s + {clips.length} 个片段 +
+
+
+
主画面
+
画中画
+
B-roll
+
+
+
+ {ticks.map((tick, i) => ( +
+ {tick.time.toFixed(0)}s +
+ ))} +
+
+
+
+
+ {clips.map((clip) => ( + + ))} +
+
+
+
+ ); +}; + +/** + * 智能编排面板 + */ +const AutoArrangePanel: React.FC<{ + projectId: string; + onSelectPlan: (plan: EditPlanItem) => void; + selectedPlanId?: string; +}> = ({ projectId, onSelectPlan, selectedPlanId }) => { + const [plans, setPlans] = useState([]); + const [loading, setLoading] = useState(false); + const [generating, setGenerating] = useState(false); + const [selectedMode, setSelectedMode] = useState("one-take"); + const [targetDuration, setTargetDuration] = useState(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 ( +
+
+

🪄 智能编排

+

AI 自动分析素材并生成剪辑方案

+
+
+ +
+ {EDITING_MODE_OPTIONS.map((mode) => ( +
setSelectedMode(mode.value)} + style={{ + borderColor: selectedMode === mode.value ? mode.color : undefined, + background: selectedMode === mode.value ? `${mode.color}10` : undefined, + }} + > + {mode.icon} + {mode.label} +
+ ))} +
+
+
+ + +
+ +
+
编排方案列表
+ {loading ? ( + + ) : plans.length === 0 ? ( + + ) : ( + plans.map((plan) => ( +
onSelectPlan(plan)}> +
+
+ {plan.summary || "未命名方案"} + {getModeLabel(plan.editing_mode)} +
+
+ 📎 {plan.clips.length} 个片段 + ⏱️ {plan.clips.reduce((sum, c) => sum + c.duration, 0).toFixed(1)}s + {plan.created_at && {new Date(plan.created_at).toLocaleDateString()}} +
+
+
+ +
+
+ )) + )} +
+
+ ); +}; + const ProjectGeneration: React.FC = () => { - const [selectedTitle, setSelectedTitle] = useState(''); + const [selectedTitle, setSelectedTitle] = useState(""); const [generateOpen, setGenerateOpen] = useState(false); + const [editingMode, setEditingMode] = useState("one-take"); + const [selectedPlan, setSelectedPlan] = useState(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 (
@@ -35,9 +324,7 @@ const ProjectGeneration: React.FC = () => {

视频剪辑

生成您的成片

- +
@@ -45,20 +332,14 @@ const ProjectGeneration: React.FC = () => {
{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 ( -
setSelectedTitle(title.id)} - > -
- 📹 -
+
setSelectedTitle(title.id)}> +
📹
{selectedTitle === title.id &&
✓
} {title.name}

{title.content.substring(0, 50)}...

@@ -68,37 +349,71 @@ const ProjectGeneration: React.FC = () => {
+
+ + {selectedPlan && selectedPlan.clips.length > 0 && ( +
+
+

📺 编排预览

+ +
+ sum + c.duration, 0)} /> +
+ )} +
+ + 生成新成片 + {editingMode && {getModeOption(editingMode)?.icon} {getModeOption(editingMode)?.label}} +
+ } open={generateOpen} okText="开始生成" cancelText="取消" onOk={handleGenerate} onCancel={() => setGenerateOpen(false)} + width={600} >
- + + setEditingMode(e.target.value)} className="editing-mode-group"> + {EDITING_MODE_OPTIONS.map((mode) => ( + +
+ {mode.icon} +
+ {mode.label} + {mode.description} +
+
+
+ ))} +
+
+ - + + {selectedPlan && ( + +
+ + 已选择编排方案: {selectedPlan.summary || "未命名方案"} ({selectedPlan.clips.length} 个片段) +
+
+ )}