Files
xiaoxia-saas/apps/web/src/pages/editing-planner/EditingPlanner.tsx
T
灵应 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
fix(P0-2): 画中画模式切换全链路修复 - 初始addType跟随模式 + 切换模式时转换已有片段类型
2026-07-08 15:35:55 +08:00

787 lines
25 KiB
TypeScript
Executable File
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 剪辑计划编辑器 — 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 和完整 planConfigJSON 序列化)
* 一键生成页面从 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;