7dd407fb3a
CI/CD Pipeline / Build & Push Staging (Watchtower auto-deploy) (push) Has been skipped
CI/CD Pipeline / Staging E2E Tests (push) Has been skipped
CI/CD Pipeline / Build Production Runtime Images (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
CI/CD Pipeline / Frontend Lint (push) Failing after 21h18m33s
CI/CD Pipeline / Validate Code Quality And Tests (push) Failing after 21h18m33s
787 lines
25 KiB
TypeScript
Executable File
787 lines
25 KiB
TypeScript
Executable File
/**
|
||
* 剪辑计划编辑器 — V8 原型 1:1 还原
|
||
* 四行布局:顶栏(42px) → 模式栏(48px) → 三栏主体 → 底栏(40px)
|
||
*/
|
||
import React, { useState, useCallback, useEffect } from "react";
|
||
import { useSearchParams, useNavigate } from "react-router-dom";
|
||
import { message } from "antd";
|
||
import { useQuery } from "@tanstack/react-query";
|
||
import type {
|
||
EditingTemplate,
|
||
TemplateCategory,
|
||
TemplateMode,
|
||
SaveTemplatePayload,
|
||
} from "@/api/editingPlanner";
|
||
import {
|
||
getEditingTemplates,
|
||
getEditingTemplate,
|
||
createEditingTemplate,
|
||
updateEditingTemplate,
|
||
getTemplateCategories,
|
||
generateFromTemplate,
|
||
MODE_LABELS,
|
||
} from "@/api/editingPlanner";
|
||
import type { EditPlanGeneration, MediaAsset } from "@/api/editPlans";
|
||
import {
|
||
getMediaAssets,
|
||
getEditPlanGenerations,
|
||
generateCover,
|
||
} from "@/api/editPlans";
|
||
import { useUndoRedo } from "./hooks/useUndoRedo";
|
||
import type { TaskItem } from "@/api/tasks";
|
||
import { createGenerationTask, getTask, retryTask } from "@/api/tasks";
|
||
import type { ClipData, ClipType } from "./types";
|
||
import {
|
||
ensureDefaultLibrary,
|
||
getAssetsByKind,
|
||
type AssetItem,
|
||
} from "@/api/assets";
|
||
import { getOrCreateDefaultProject } from "@/api/projects";
|
||
|
||
import MediaPanel from "./components/MediaPanel";
|
||
import PreviewPlayer from "./components/PreviewPlayer";
|
||
import TimelinePanel from "./components/TimelinePanel";
|
||
import ClipPropertiesPanel from "./components/ClipPropertiesPanel";
|
||
import SaveModal from "./components/SaveModal";
|
||
import GenerationProgressModal from "./components/GenerationProgressModal";
|
||
import type { GenPhase } from "./components/GenerationProgressModal";
|
||
import GenerationHistoryModal from "./components/GenerationHistoryModal";
|
||
import "./EditingPlanner.css";
|
||
|
||
/* ──────────── 常量 ──────────── */
|
||
|
||
const MODE_LIST: { key: TemplateMode; label: string; icon: string }[] = [
|
||
{ key: "pip", label: "画中画", icon: "🖼️" },
|
||
{ key: "voice_over", label: "人物口播", icon: "🎙️" },
|
||
{ key: "one_take", label: "一镜到底", icon: "🎥" },
|
||
{ key: "voice_pip", label: "口播+画中画", icon: "🎭" },
|
||
];
|
||
|
||
const COVER_SCHEMES = [
|
||
{ key: "ai_frame", label: "AI选帧" },
|
||
{ key: "manual", label: "手动选" },
|
||
{ key: "upload", label: "上传" },
|
||
{ key: "ai_reselect", label: "AI重选" },
|
||
];
|
||
|
||
const FILTER_CATEGORIES = ["全部", "种草", "知识", "日常", "推荐"];
|
||
|
||
/* ──────────── 组件 ──────────── */
|
||
|
||
const EditingPlanner: React.FC = () => {
|
||
const [searchParams] = useSearchParams();
|
||
const navigate = useNavigate();
|
||
const urlTemplateId = searchParams.get("templateId") || "";
|
||
|
||
/* ── 模板列表 ── */
|
||
const [templates, setTemplates] = useState<EditingTemplate[]>([]);
|
||
const [categories, setCategories] = useState<TemplateCategory[]>([]);
|
||
const [loadingTemplates, setLoadingTemplates] = useState(false);
|
||
const [loadedTemplateId, setLoadedTemplateId] = useState<string | null>(
|
||
urlTemplateId || null,
|
||
);
|
||
const [currentMode, setCurrentMode] = useState<TemplateMode>("pip");
|
||
|
||
/* ── 片段(撤销/重做) ── */
|
||
const {
|
||
state: clips,
|
||
set: setClips,
|
||
undo,
|
||
redo,
|
||
canUndo,
|
||
canRedo,
|
||
reset: resetClips,
|
||
} = useUndoRedo<ClipData[]>([]);
|
||
const [selectedClipId, setSelectedClipId] = useState<string | null>(null);
|
||
|
||
/* ── AI 操作状态 ── */
|
||
|
||
const [aiCoverLoading, setAiCoverLoading] = useState(false);
|
||
|
||
/* ── 封面方案 ── */
|
||
const [currentCoverScheme, setCurrentCoverScheme] =
|
||
useState<string>("ai_frame");
|
||
|
||
/* ── 左栏筛选 ── */
|
||
const [currentFilter, setCurrentFilter] = useState("全部");
|
||
const [searchQuery, setSearchQuery] = useState("");
|
||
|
||
/* ── 标题/字幕/BGM 设置 ── */
|
||
const [titleSettings, setTitleSettings] = useState({
|
||
aiAutoSelect: false,
|
||
title: "",
|
||
position: "top",
|
||
font: "思源黑体",
|
||
size: 24,
|
||
bold: false,
|
||
italic: false,
|
||
stroke: true,
|
||
shadow: true,
|
||
color: "#ffffff",
|
||
});
|
||
|
||
const [subtitleSettings, setSubtitleSettings] = useState({
|
||
enabled: true,
|
||
position: "bottom",
|
||
font: "思源黑体",
|
||
size: 16,
|
||
animation: "none",
|
||
});
|
||
|
||
const [bgmSettings, setBgmSettings] = useState({
|
||
music: "none",
|
||
});
|
||
|
||
/* ── 保存弹窗 ── */
|
||
const [saveModalOpen, setSaveModalOpen] = useState(false);
|
||
const [draftName, setDraftName] = useState("");
|
||
const [draftCategory, setDraftCategory] = useState("");
|
||
const [draftTags, setDraftTags] = useState("");
|
||
const [saveLoading, setSaveLoading] = useState(false);
|
||
|
||
/* ── 生成弹窗 ── */
|
||
const [genModalOpen, setGenModalOpen] = useState(false);
|
||
const [genPhase, setGenPhase] = useState<GenPhase>("setup");
|
||
const [genTask, setGenTask] = useState<TaskItem | null>(null);
|
||
const [genSubmitting, setGenSubmitting] = useState(false);
|
||
const [voiceoverDuration, setVoiceoverDuration] = useState<number | null>(
|
||
null,
|
||
);
|
||
|
||
/* ── 素材库 ── */
|
||
const [mediaAssets, setMediaAssets] = useState<MediaAsset[]>([]);
|
||
const [selectedAssetIds, setSelectedAssetIds] = useState<string[]>([]);
|
||
|
||
const handleAssetSelect = (ids: string[]) => {
|
||
setSelectedAssetIds(ids);
|
||
};
|
||
|
||
/* ── 生成历史 ── */
|
||
const [genHistoryOpen, setGenHistoryOpen] = useState(false);
|
||
const [genHistory, setGenHistory] = useState<EditPlanGeneration[]>([]);
|
||
const [genHistoryLoading, setGenHistoryLoading] = useState(false);
|
||
|
||
/* ── 播放 ── */
|
||
const [isPlaying, setIsPlaying] = useState(false);
|
||
|
||
/* ── 配音素材(queryKey 与 VoiceMaterialLibrary 共享缓存) ── */
|
||
const voiceMaterialsQuery = useQuery({
|
||
queryKey: ["assets", "voice"],
|
||
queryFn: async () => {
|
||
const project = await getOrCreateDefaultProject();
|
||
await ensureDefaultLibrary({ project_id: project.id, kind: "voice" });
|
||
const assets = await getAssetsByKind("voice");
|
||
return assets;
|
||
},
|
||
staleTime: 30_000,
|
||
});
|
||
const voiceMaterials: AssetItem[] = voiceMaterialsQuery.data ?? [];
|
||
|
||
/** 为片段选择配音素材 */
|
||
const handleClipVoiceSelect = useCallback(
|
||
(clipId: string, asset: AssetItem | null) => {
|
||
setClips((prev) =>
|
||
prev.map((c) =>
|
||
c.id === clipId
|
||
? {
|
||
...c,
|
||
voice_asset_id: asset?.id ?? undefined,
|
||
voice_file_url: asset?.file_url ?? undefined,
|
||
}
|
||
: c,
|
||
),
|
||
);
|
||
},
|
||
[setClips],
|
||
);
|
||
|
||
/* ──────────── 加载 ──────────── */
|
||
|
||
/**
|
||
* 并行加载模板列表、分类、素材库
|
||
* 首次挂载时调用,三个接口无依赖关系,用 Promise.all 并发
|
||
*/
|
||
const loadTemplates = useCallback(async () => {
|
||
setLoadingTemplates(true);
|
||
try {
|
||
const [tpls, cats, assets] = await Promise.all([
|
||
getEditingTemplates(),
|
||
getTemplateCategories(),
|
||
getMediaAssets(),
|
||
]);
|
||
setTemplates(tpls);
|
||
setCategories(cats);
|
||
setMediaAssets(assets);
|
||
} catch {
|
||
message.error("加载模板失败");
|
||
} finally {
|
||
setLoadingTemplates(false);
|
||
}
|
||
}, []);
|
||
|
||
useEffect(() => {
|
||
loadTemplates();
|
||
}, [loadTemplates]);
|
||
|
||
/**
|
||
* 加载模板详情并初始化片段列表
|
||
* 将后端 segments 映射为前端 ClipData,取 duration_min/max 均值作为默认时长
|
||
* 同时还原标题/字幕/BGM 配置
|
||
*/
|
||
useEffect(() => {
|
||
if (!loadedTemplateId) return;
|
||
getEditingTemplate(loadedTemplateId)
|
||
.then((tpl) => {
|
||
if (!tpl) return;
|
||
setCurrentMode(tpl.mode);
|
||
const mapped: ClipData[] = tpl.segments.map((seg, idx) => ({
|
||
id: seg.id || `seg-${idx}`,
|
||
template_segment_id: seg.id || `seg-${idx}`,
|
||
type: (seg.material_type === "voiceover"
|
||
? "voice"
|
||
: "pip") as ClipType,
|
||
duration: (seg.duration_min + seg.duration_max) / 2,
|
||
startOffset: 0,
|
||
script_text: "",
|
||
order: seg.segment_order,
|
||
}));
|
||
resetClips(mapped);
|
||
|
||
setTitleSettings((prev) => ({
|
||
...prev,
|
||
aiAutoSelect: tpl.title_config.ai_auto_select,
|
||
title: tpl.title_config.content,
|
||
position: tpl.title_config.position,
|
||
font: tpl.title_config.font_preset,
|
||
size: tpl.title_config.font_size,
|
||
color: tpl.title_config.font_color || "#ffffff",
|
||
}));
|
||
setSubtitleSettings({
|
||
enabled: tpl.subtitle_config.enabled,
|
||
position: tpl.subtitle_config.position,
|
||
font: tpl.subtitle_config.font,
|
||
size: tpl.subtitle_config.size,
|
||
animation: tpl.subtitle_config.animation,
|
||
});
|
||
setBgmSettings({
|
||
music: tpl.bgm_config.music_id || "none",
|
||
});
|
||
setDraftName(tpl.name);
|
||
setDraftCategory(tpl.category);
|
||
setDraftTags(tpl.tags.join(", "));
|
||
})
|
||
.catch(() => message.error("加载模板详情失败"));
|
||
}, [loadedTemplateId, resetClips]);
|
||
|
||
/* ──────────── 计算 ──────────── */
|
||
|
||
const currentTemplate = templates.find((t) => t.id === loadedTemplateId);
|
||
const totalDuration = clips.reduce((sum, c) => sum + c.duration, 0);
|
||
const selectedClip = clips.find((c) => c.id === selectedClipId) || null;
|
||
|
||
const filteredTemplates = templates.filter((t) => {
|
||
if (currentFilter !== "全部" && t.category !== currentFilter) return false;
|
||
if (
|
||
searchQuery &&
|
||
!t.name.toLowerCase().includes(searchQuery.toLowerCase())
|
||
)
|
||
return false;
|
||
return true;
|
||
});
|
||
|
||
/* ──────────── 事件 ──────────── */
|
||
|
||
const handleLoadTemplate = (templateId: string) => {
|
||
setLoadedTemplateId(templateId);
|
||
setSelectedClipId(null);
|
||
};
|
||
|
||
const handleModeChange = (mode: TemplateMode) => {
|
||
setCurrentMode(mode);
|
||
// 切换纯单类型模式时,自动转换所有已有片段的类型
|
||
if (mode === "voice_over") {
|
||
setClips((prev) => prev.map((c) => ({ ...c, type: "voice" as const })));
|
||
} else if (mode === "pip") {
|
||
setClips((prev) => prev.map((c) => ({ ...c, type: "pip" as const })));
|
||
}
|
||
// 混合模式(voice_pip)和一镜到底(one_take)不自动转换,保留原有类型
|
||
};
|
||
|
||
const handleClipSelect = (clipId: string) => {
|
||
setSelectedClipId(clipId);
|
||
};
|
||
|
||
const handleClipReorder = (fromIdx: number, toIdx: number) => {
|
||
setClips((prev) => {
|
||
const updated = [...prev];
|
||
const [moved] = updated.splice(fromIdx, 1);
|
||
updated.splice(toIdx, 0, moved);
|
||
return updated.map((c, i) => ({ ...c, order: i }));
|
||
});
|
||
};
|
||
|
||
const handleClipRemove = (clipId: string) => {
|
||
setClips((prev) => prev.filter((c) => c.id !== clipId));
|
||
if (selectedClipId === clipId) setSelectedClipId(null);
|
||
};
|
||
|
||
const handleClipUpdate = (clipId: string, data: Partial<ClipData>) => {
|
||
setClips((prev) =>
|
||
prev.map((c) => (c.id === clipId ? { ...c, ...data } : c)),
|
||
);
|
||
};
|
||
|
||
/**
|
||
* 添加片段(不绑定任何素材)
|
||
* 片段 = 时间规划 + 类型标记
|
||
*/
|
||
const handleAddClip = useCallback(
|
||
(type: ClipType, duration: number) => {
|
||
const newClip: ClipData = {
|
||
id: `clip-${Date.now()}`,
|
||
type,
|
||
duration,
|
||
startOffset: 0,
|
||
order: clips.length,
|
||
};
|
||
setClips((prev) => [...prev, newClip]);
|
||
},
|
||
[clips.length, setClips],
|
||
);
|
||
|
||
/* AI 封面生成 */
|
||
const handleAiGenerateCover = async (
|
||
coverType: "ai_frame" | "ai_regenerate",
|
||
) => {
|
||
if (!loadedTemplateId) return;
|
||
const assetIds = selectedAssetIds;
|
||
if (assetIds.length === 0) {
|
||
message.warning("请先在素材库中选择素材");
|
||
return;
|
||
}
|
||
setAiCoverLoading(true);
|
||
try {
|
||
await generateCover(loadedTemplateId, {
|
||
asset_ids: assetIds,
|
||
cover_type: coverType,
|
||
});
|
||
setCurrentCoverScheme(
|
||
coverType === "ai_frame" ? "ai_frame" : "ai_reselect",
|
||
);
|
||
message.success("AI 封面生成成功");
|
||
} catch {
|
||
message.error("AI 封面生成失败");
|
||
} finally {
|
||
setAiCoverLoading(false);
|
||
}
|
||
};
|
||
|
||
/* 保存 — 无论是否已加载模板,都打开保存弹窗;未加载时创建新模板 */
|
||
const handleOpenSaveModal = () => {
|
||
setSaveModalOpen(true);
|
||
};
|
||
|
||
const handleSave = async () => {
|
||
if (!draftName.trim()) {
|
||
message.warning("请输入模板名称");
|
||
return;
|
||
}
|
||
setSaveLoading(true);
|
||
try {
|
||
const payload: SaveTemplatePayload = {
|
||
name: draftName,
|
||
mode: currentMode,
|
||
category: draftCategory,
|
||
tags: draftTags
|
||
.split(",")
|
||
.map((t) => t.trim())
|
||
.filter(Boolean),
|
||
title_config: {
|
||
ai_auto_select: titleSettings.aiAutoSelect,
|
||
content: titleSettings.title,
|
||
font_preset: titleSettings.font,
|
||
font_color: titleSettings.color,
|
||
font_size: titleSettings.size,
|
||
position: titleSettings.position,
|
||
},
|
||
subtitle_config: {
|
||
enabled: subtitleSettings.enabled,
|
||
position: subtitleSettings.position,
|
||
font: subtitleSettings.font,
|
||
color: "#ffffff",
|
||
size: subtitleSettings.size,
|
||
animation: subtitleSettings.animation,
|
||
},
|
||
bgm_config: {
|
||
enabled: bgmSettings.music !== "none",
|
||
music_id: bgmSettings.music,
|
||
},
|
||
estimated_duration: totalDuration,
|
||
segments: clips.map((c, i) => ({
|
||
segment_order: i,
|
||
duration_min: Math.max(1, c.duration - 2),
|
||
duration_max: c.duration + 2,
|
||
material_type: c.type === "voice" ? "voiceover" : "video",
|
||
})),
|
||
};
|
||
if (loadedTemplateId) {
|
||
await updateEditingTemplate(loadedTemplateId, payload);
|
||
} else {
|
||
await createEditingTemplate(payload);
|
||
}
|
||
message.success(loadedTemplateId ? "模板保存成功" : "模板创建成功");
|
||
setSaveModalOpen(false);
|
||
loadTemplates();
|
||
} catch {
|
||
message.error("保存失败");
|
||
} finally {
|
||
setSaveLoading(false);
|
||
}
|
||
};
|
||
|
||
/**
|
||
* 跳转到一键生成页面
|
||
* 通过 URL SearchParams 传递 edit_plan_id 和完整 planConfig(JSON 序列化)
|
||
* 一键生成页面从 params 解析配置,无需重复请求接口
|
||
*/
|
||
const handleGoToGenerate = () => {
|
||
const planConfig = {
|
||
title_config: {
|
||
ai_auto_select: titleSettings.aiAutoSelect,
|
||
content: titleSettings.title,
|
||
position: titleSettings.position,
|
||
font_preset: titleSettings.font,
|
||
font_color: titleSettings.color,
|
||
font_size: titleSettings.size,
|
||
bold: titleSettings.bold,
|
||
italic: titleSettings.italic,
|
||
stroke: titleSettings.stroke,
|
||
shadow: titleSettings.shadow,
|
||
},
|
||
subtitle_config: {
|
||
enabled: subtitleSettings.enabled,
|
||
position: subtitleSettings.position,
|
||
font: subtitleSettings.font,
|
||
size: subtitleSettings.size,
|
||
animation: subtitleSettings.animation,
|
||
},
|
||
bgm_config: {
|
||
enabled: bgmSettings.music !== "none",
|
||
music_id: bgmSettings.music,
|
||
},
|
||
mode: currentMode,
|
||
total_duration: totalDuration,
|
||
segments: clips.map((c, i) => ({
|
||
order: i,
|
||
material_type: c.type === "voice" ? "voiceover" : "video",
|
||
duration: c.duration,
|
||
template_segment_id: c.template_segment_id,
|
||
script_text: c.script_text,
|
||
voice_asset_id: c.voice_asset_id,
|
||
voice_file_url: c.voice_file_url,
|
||
})),
|
||
};
|
||
const params = new URLSearchParams();
|
||
if (loadedTemplateId) {
|
||
params.set("edit_plan_id", loadedTemplateId);
|
||
}
|
||
params.set("plan_config", JSON.stringify(planConfig));
|
||
navigate(`/app/generate?${params.toString()}`);
|
||
};
|
||
|
||
/**
|
||
* 创建生成任务(两步)
|
||
* 1. generateFromTemplate — 通知后端基于模板生成视频
|
||
* 2. createGenerationTask — 创建任务记录,返回精简响应
|
||
* 再用 getTask 查询完整 TaskItem 供轮询使用
|
||
*/
|
||
const handleGenerate = async () => {
|
||
if (!loadedTemplateId) return;
|
||
setGenSubmitting(true);
|
||
try {
|
||
await generateFromTemplate(loadedTemplateId, {
|
||
voiceover_duration: voiceoverDuration || totalDuration,
|
||
});
|
||
// 收集所有 voice 类型片段的配音素材 ID
|
||
const voiceIds = clips
|
||
.filter((c) => c.type === "voice" && c.voice_asset_id)
|
||
.map((c) => c.voice_asset_id as string);
|
||
const res = await createGenerationTask({
|
||
template_id: loadedTemplateId,
|
||
asset_ids: [],
|
||
title_ids: [],
|
||
voice_ids: voiceIds,
|
||
});
|
||
/* 创建接口返回的是精简响应,需查询完整 TaskItem 用于轮询 */
|
||
const task = await getTask(res.id);
|
||
setGenTask(task);
|
||
setGenPhase("progress");
|
||
message.info("生成任务已创建");
|
||
} catch {
|
||
message.error("创建生成任务失败");
|
||
} finally {
|
||
setGenSubmitting(false);
|
||
}
|
||
};
|
||
|
||
/**
|
||
* 轮询生成任务状态(每 3 秒)
|
||
* 仅在 genPhase === "progress" 且有任务 ID 时启动
|
||
* 任务完成/失败时自动停止轮询
|
||
*/
|
||
useEffect(() => {
|
||
if (genPhase !== "progress" || !genTask?.id) return;
|
||
const timer = setInterval(async () => {
|
||
try {
|
||
const t = await getTask(genTask.id);
|
||
setGenTask(t);
|
||
if (t.status === "completed") {
|
||
setGenPhase("completed");
|
||
clearInterval(timer);
|
||
} else if (t.status === "failed") {
|
||
setGenPhase("failed");
|
||
clearInterval(timer);
|
||
}
|
||
} catch {
|
||
/* ignore */
|
||
}
|
||
}, 3000);
|
||
return () => clearInterval(timer);
|
||
}, [genPhase, genTask?.id]);
|
||
|
||
const handleRetry = async () => {
|
||
if (!genTask?.id) return;
|
||
setGenSubmitting(true);
|
||
try {
|
||
const t = await retryTask(genTask.id);
|
||
setGenTask(t);
|
||
setGenPhase("progress");
|
||
} catch {
|
||
message.error("重试失败");
|
||
} finally {
|
||
setGenSubmitting(false);
|
||
}
|
||
};
|
||
|
||
const handleCancelGen = () => {
|
||
setGenModalOpen(false);
|
||
setGenPhase("setup");
|
||
setGenTask(null);
|
||
setVoiceoverDuration(null);
|
||
};
|
||
|
||
/* 查看生成历史 */
|
||
const handleViewGenHistory = async () => {
|
||
if (!loadedTemplateId) {
|
||
message.warning("请先加载一个模板");
|
||
return;
|
||
}
|
||
setGenHistoryOpen(true);
|
||
setGenHistoryLoading(true);
|
||
try {
|
||
const items = await getEditPlanGenerations(loadedTemplateId);
|
||
setGenHistory(items);
|
||
} catch {
|
||
message.error("加载生成历史失败");
|
||
} finally {
|
||
setGenHistoryLoading(false);
|
||
}
|
||
};
|
||
|
||
/* ──────────── 渲染 ──────────── */
|
||
|
||
return (
|
||
<div className="ep-v8-root">
|
||
{/* ═══ 第1行:顶栏 42px ═══ */}
|
||
<div className="ep-top-bar">
|
||
<div className="ep-top-bar-left">
|
||
<span className="ep-logo">✂️</span>
|
||
<span className="ep-app-title">小虾剪辑编排器</span>
|
||
<span className="ep-divider">|</span>
|
||
<span className="ep-template-name">
|
||
{currentTemplate?.name || "未选择模板"}
|
||
</span>
|
||
</div>
|
||
<div className="ep-top-bar-right">
|
||
<button
|
||
className="ep-btn ep-btn-secondary"
|
||
onClick={undo}
|
||
disabled={!canUndo}
|
||
title="撤销 (Ctrl+Z)"
|
||
>
|
||
⬅️ 撤销
|
||
</button>
|
||
<button
|
||
className="ep-btn ep-btn-secondary"
|
||
onClick={redo}
|
||
disabled={!canRedo}
|
||
title="重做 (Ctrl+Shift+Z)"
|
||
>
|
||
➡️ 重做
|
||
</button>
|
||
<button
|
||
className="ep-btn ep-btn-secondary"
|
||
onClick={handleOpenSaveModal}
|
||
>
|
||
💾 保存模板
|
||
</button>
|
||
<button
|
||
className="ep-btn ep-btn-primary"
|
||
onClick={handleGoToGenerate}
|
||
>
|
||
🎬 使用此模板生成
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
{/* ═══ 第2行:模式栏 56px(V21风格)═══ */}
|
||
<div className="ep-mode-bar">
|
||
<span className="ep-mode-bar-label">剪辑模式:</span>
|
||
{MODE_LIST.map((m) => (
|
||
<button
|
||
key={m.key}
|
||
className={`ep-mode-btn ${currentMode === m.key ? "active" : ""}`}
|
||
onClick={() => handleModeChange(m.key)}
|
||
>
|
||
<span className="ep-mode-icon">{m.icon}</span>
|
||
<span className="ep-mode-label">{m.label}</span>
|
||
</button>
|
||
))}
|
||
</div>
|
||
|
||
{/* ═══ 第3行:三栏主体 ═══ */}
|
||
<div className="ep-main-body">
|
||
{/* 左栏 220px:模板列表 */}
|
||
<MediaPanel
|
||
templates={filteredTemplates}
|
||
loading={loadingTemplates}
|
||
searchQuery={searchQuery}
|
||
currentFilter={currentFilter}
|
||
filterCategories={FILTER_CATEGORIES}
|
||
loadedTemplateId={loadedTemplateId}
|
||
onLoadTemplate={handleLoadTemplate}
|
||
onSearchChange={setSearchQuery}
|
||
onFilterChange={setCurrentFilter}
|
||
mediaAssets={mediaAssets}
|
||
onAssetSelect={handleAssetSelect}
|
||
selectedAssetIds={selectedAssetIds}
|
||
/>
|
||
|
||
{/* 中栏 flex-1 */}
|
||
<div className="ep-center-col">
|
||
{/* 上半部:视频预览 + 封面预览 */}
|
||
<PreviewPlayer
|
||
clips={clips}
|
||
selectedClipId={selectedClipId}
|
||
isPlaying={isPlaying}
|
||
currentCoverScheme={currentCoverScheme}
|
||
coverSchemes={COVER_SCHEMES}
|
||
aiCoverLoading={aiCoverLoading}
|
||
titleSettings={titleSettings}
|
||
subtitleSettings={subtitleSettings}
|
||
onClipSelect={handleClipSelect}
|
||
onCoverSchemeChange={setCurrentCoverScheme}
|
||
onPlayPause={() => setIsPlaying(!isPlaying)}
|
||
onAiGenerateCover={handleAiGenerateCover}
|
||
/>
|
||
|
||
{/* 下半部:水平时间线 */}
|
||
<TimelinePanel
|
||
clips={clips}
|
||
selectedClipId={selectedClipId}
|
||
currentMode={currentMode}
|
||
onClipSelect={handleClipSelect}
|
||
onClipReorder={handleClipReorder}
|
||
onClipRemove={handleClipRemove}
|
||
onAddClip={handleAddClip}
|
||
/>
|
||
</div>
|
||
|
||
{/* 右栏 260px:设置面板 */}
|
||
<ClipPropertiesPanel
|
||
selectedClip={selectedClip}
|
||
titleSettings={titleSettings}
|
||
subtitleSettings={subtitleSettings}
|
||
bgmSettings={bgmSettings}
|
||
clipsCount={clips.length}
|
||
totalDuration={totalDuration}
|
||
currentMode={currentMode}
|
||
onTitleSettingsChange={(partial) =>
|
||
setTitleSettings((prev) => ({ ...prev, ...partial }))
|
||
}
|
||
onSubtitleSettingsChange={(partial) =>
|
||
setSubtitleSettings((prev) => ({ ...prev, ...partial }))
|
||
}
|
||
onBgmSettingsChange={(partial) =>
|
||
setBgmSettings((prev) => ({ ...prev, ...partial }))
|
||
}
|
||
onClipUpdate={handleClipUpdate}
|
||
voiceMaterials={voiceMaterials}
|
||
voiceMaterialsLoading={voiceMaterialsQuery.isLoading}
|
||
onRefreshVoiceMaterials={() => voiceMaterialsQuery.refetch()}
|
||
onClipVoiceSelect={handleClipVoiceSelect}
|
||
/>
|
||
</div>
|
||
|
||
{/* ═══ 第4行:底栏 40px ═══ */}
|
||
<div className="ep-status-bar">
|
||
<div className="ep-status-left">
|
||
<span>📋 片段: {clips.length}</span>
|
||
<span className="ep-status-sep">|</span>
|
||
<span>⏱️ 总时长: {totalDuration.toFixed(1)}s</span>
|
||
</div>
|
||
<div className="ep-status-right">
|
||
<span>🎬 {MODE_LABELS[currentMode]}</span>
|
||
<span className="ep-status-sep">|</span>
|
||
<span>📐 模板片段: {currentTemplate?.segments.length || 0}</span>
|
||
<span className="ep-status-sep">|</span>
|
||
<button className="ep-status-link" onClick={handleViewGenHistory}>
|
||
📋 生成历史
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
{/* ═══ 弹窗 ═══ */}
|
||
<SaveModal
|
||
open={saveModalOpen}
|
||
loading={saveLoading}
|
||
isUpdate={!!loadedTemplateId}
|
||
draftName={draftName}
|
||
draftCategory={draftCategory}
|
||
draftTags={draftTags}
|
||
categories={categories}
|
||
estimatedDuration={totalDuration}
|
||
onNameChange={setDraftName}
|
||
onCategoryChange={setDraftCategory}
|
||
onTagsChange={setDraftTags}
|
||
onSave={handleSave}
|
||
onCancel={() => setSaveModalOpen(false)}
|
||
/>
|
||
|
||
<GenerationProgressModal
|
||
open={genModalOpen}
|
||
phase={genPhase}
|
||
voiceoverDuration={voiceoverDuration}
|
||
estimatedDuration={totalDuration}
|
||
onDurationChange={setVoiceoverDuration}
|
||
onGenerate={handleGenerate}
|
||
task={genTask}
|
||
submitting={genSubmitting}
|
||
onCancel={handleCancelGen}
|
||
onRetry={handleRetry}
|
||
onClose={handleCancelGen}
|
||
/>
|
||
|
||
{/* ═══ 生成历史弹窗 ═══ */}
|
||
<GenerationHistoryModal
|
||
open={genHistoryOpen}
|
||
loading={genHistoryLoading}
|
||
history={genHistory}
|
||
onClose={() => setGenHistoryOpen(false)}
|
||
/>
|
||
</div>
|
||
);
|
||
};
|
||
|
||
export default EditingPlanner;
|