ee8c6c1a3b
CI Build & Deploy Pipeline / Staging E2E Tests (push) Has been cancelled
CI Build & Deploy Pipeline / Staging API Integration Tests (push) Has been cancelled
CI Build & Deploy Pipeline / Production Browser E2E (push) Failing after 1470h9m55s
CI Build & Deploy Pipeline / Deploy Production (push) Failing after 1470h9m57s
CI Build & Deploy Pipeline / Build Production API Image (push) Failing after 1470h9m58s
CI Build & Deploy Pipeline / Build Production Worker Image (push) Failing after 1470h9m58s
CI/CD Pipeline / Check if frontend-only change (push) Failing after 1470h11m14s
CI Build & Deploy Pipeline / Build Staging API Image (push) Has been skipped
CI Build & Deploy Pipeline / Build Staging Web Image (push) Has been skipped
CI Build & Deploy Pipeline / Build Staging Worker Image (push) Has been skipped
CI Build & Deploy Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Has been skipped
CI Build & Deploy Pipeline / Build Production Web Image (push) Failing after 1470h41m55s
CI/CD Pipeline / Validate Code Quality And Tests (push) Has been skipped
CI/CD Pipeline / Unit Tests (push) Has been skipped
CI/CD Pipeline / Integration Tests (push) Has been skipped
CI/CD Pipeline / Frontend Lint (push) Has been skipped
pr_body
1602 lines
52 KiB
TypeScript
Executable File
1602 lines
52 KiB
TypeScript
Executable File
/**
|
||
* 剪辑计划编辑器 — V8 原型 1:1 还原
|
||
* 四行布局:顶栏(42px) → 模式栏(48px) → 三栏主体 → 底栏(40px)
|
||
*/
|
||
import React, { useState, useCallback, useEffect, useRef } from "react";
|
||
import { useSearchParams } from "react-router-dom";
|
||
import { message, Modal, Progress, Button } from "antd";
|
||
import { useQuery } from "@tanstack/react-query";
|
||
import type {
|
||
EditingTemplate,
|
||
TemplateCategory,
|
||
TemplateMode,
|
||
SaveTemplatePayload,
|
||
} from "@/api/editingPlanner";
|
||
import {
|
||
getEditingTemplates,
|
||
getEditingTemplate,
|
||
createEditingTemplate,
|
||
updateEditingTemplate,
|
||
getTemplateCategories,
|
||
MODE_LABELS,
|
||
} from "@/api/editingPlanner";
|
||
import type {
|
||
EditPlanGeneration,
|
||
EditPlanConfig,
|
||
GeneratedVideo,
|
||
MediaAsset,
|
||
TransitionEffect,
|
||
} from "@/api/editPlans";
|
||
import {
|
||
getMediaAssets,
|
||
getEditPlanGenerations,
|
||
generateCover,
|
||
getEditPlan,
|
||
createEditPlan,
|
||
updateEditPlan,
|
||
generateEditPlan,
|
||
getGenerationStatus,
|
||
getGenerationTaskResults,
|
||
cancelGeneration,
|
||
} from "@/api/editPlans";
|
||
import { useUndoRedo } from "./hooks/useUndoRedo";
|
||
import type {
|
||
ClipData,
|
||
ClipType,
|
||
TransitionConfig,
|
||
SpeedConfig,
|
||
TtsConfig,
|
||
TtsMode,
|
||
TrimConfig,
|
||
WatermarkConfig,
|
||
IntroOutroConfig,
|
||
PipConfig,
|
||
FilterConfig,
|
||
ChromaKeyConfig,
|
||
StickerConfig,
|
||
CoverConfig,
|
||
TitleSettings,
|
||
} from "./types";
|
||
import {
|
||
DEFAULT_TRANSITION,
|
||
DEFAULT_SPEED,
|
||
DEFAULT_TTS_CONFIG,
|
||
DEFAULT_WATERMARK,
|
||
DEFAULT_INTRO_OUTRO,
|
||
DEFAULT_PIP_CONFIG,
|
||
DEFAULT_FILTER_CONFIG,
|
||
DEFAULT_CHROMA_KEY_CONFIG,
|
||
DEFAULT_STICKER_CONFIG,
|
||
DEFAULT_COVER_CONFIG,
|
||
} 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 BgmSelector from "./components/BgmSelector";
|
||
import SubtitleStylePanel from "./components/SubtitleStylePanel";
|
||
import type { SubtitleStyleConfig } from "./types/subtitle";
|
||
import { DEFAULT_SUBTITLE_STYLE } from "./types/subtitle";
|
||
import TransitionSelector from "./components/TransitionSelector";
|
||
import SpeedPanel from "./components/SpeedPanel";
|
||
import TtsPanel from "./components/TtsPanel";
|
||
import WatermarkPanel from "./components/WatermarkPanel";
|
||
import IntroOutroPanel from "./components/IntroOutroPanel";
|
||
import PipConfigPanel from "./components/PipConfigPanel";
|
||
import FilterPanel from "./components/FilterPanel";
|
||
import GreenScreenPanel from "./components/GreenScreenPanel";
|
||
import StickerPanel from "./components/StickerPanel";
|
||
import CoverSelector from "./components/CoverSelector";
|
||
import SaveModal from "./components/SaveModal";
|
||
import GenerationHistoryModal from "./components/GenerationHistoryModal";
|
||
import { DEFAULT_BGM_MIX_CONFIG, type BgmMixConfig } from "@/api/bgm";
|
||
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 urlTemplateId = searchParams.get("templateId") || "";
|
||
const urlPlanId = searchParams.get("planId") || "";
|
||
|
||
/* ── 模板列表 ── */
|
||
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<TitleSettings>({
|
||
aiAutoSelect: false,
|
||
title: "",
|
||
position: "top",
|
||
font: "思源黑体",
|
||
size: 24,
|
||
bold: false,
|
||
italic: false,
|
||
stroke: true,
|
||
shadow: true,
|
||
color: "#ffffff",
|
||
});
|
||
|
||
const [subtitleSettings, setSubtitleSettings] = useState<SubtitleStyleConfig>(
|
||
{
|
||
...DEFAULT_SUBTITLE_STYLE,
|
||
},
|
||
);
|
||
|
||
const [bgmSettings, setBgmSettings] = useState<BgmMixConfig>({
|
||
...DEFAULT_BGM_MIX_CONFIG,
|
||
});
|
||
|
||
/* ── Drawer 开关 ── */
|
||
const [bgmDrawerOpen, setBgmDrawerOpen] = useState(false);
|
||
const [subtitleDrawerOpen, setSubtitleDrawerOpen] = useState(false);
|
||
const [transitionDrawerOpen, setTransitionDrawerOpen] = useState(false);
|
||
const [speedDrawerOpen, setSpeedDrawerOpen] = useState(false);
|
||
/** 当前正在编辑转场的片段 ID(null = 全局默认转场) */
|
||
const [transitionTargetClipId, setTransitionTargetClipId] = useState<
|
||
string | null
|
||
>(null);
|
||
/** 当前正在调速的片段 ID */
|
||
const [speedTargetClipId, setSpeedTargetClipId] = useState<string | null>(
|
||
null,
|
||
);
|
||
/** TTS 配音面板是否打开 */
|
||
const [ttsDrawerOpen, setTtsDrawerOpen] = useState(false);
|
||
/** 当前正在配置 TTS 的片段 ID */
|
||
const [ttsTargetClipId, setTtsTargetClipId] = useState<string | null>(null);
|
||
|
||
/* ── 水印 / 片头片尾 ── */
|
||
const [watermarkSettings, setWatermarkSettings] = useState<WatermarkConfig>({
|
||
...DEFAULT_WATERMARK,
|
||
});
|
||
const [introOutroSettings, setIntroOutroSettings] =
|
||
useState<IntroOutroConfig>({ ...DEFAULT_INTRO_OUTRO });
|
||
const [watermarkDrawerOpen, setWatermarkDrawerOpen] = useState(false);
|
||
const [introOutroDrawerOpen, setIntroOutroDrawerOpen] = useState(false);
|
||
|
||
/* ── 画中画 ── */
|
||
const [pipSettings, setPipSettings] = useState<PipConfig>({
|
||
...DEFAULT_PIP_CONFIG,
|
||
});
|
||
const [pipDrawerOpen, setPipDrawerOpen] = useState(false);
|
||
|
||
/* ── 滤镜调色 ── */
|
||
const [filterSettings, setFilterSettings] = useState<FilterConfig>({
|
||
...DEFAULT_FILTER_CONFIG,
|
||
});
|
||
const [filterDrawerOpen, setFilterDrawerOpen] = useState(false);
|
||
|
||
/* ── 绿幕抠像 ── */
|
||
const [chromaKeySettings, setChromaKeySettings] = useState<ChromaKeyConfig>({
|
||
...DEFAULT_CHROMA_KEY_CONFIG,
|
||
});
|
||
const [chromaKeyDrawerOpen, setChromaKeyDrawerOpen] = useState(false);
|
||
|
||
/* ── 贴纸 ── */
|
||
const [stickerSettings, setStickerSettings] = useState<StickerConfig>({
|
||
...DEFAULT_STICKER_CONFIG,
|
||
});
|
||
const [stickerDrawerOpen, setStickerDrawerOpen] = useState(false);
|
||
|
||
/* ── 封面 ── */
|
||
const [coverSettings, setCoverSettings] = useState<CoverConfig>({
|
||
...DEFAULT_COVER_CONFIG,
|
||
});
|
||
const [coverDrawerOpen, setCoverDrawerOpen] = useState(false);
|
||
|
||
/* ── 保存弹窗 ── */
|
||
const [saveModalOpen, setSaveModalOpen] = useState(false);
|
||
const [draftName, setDraftName] = useState("");
|
||
const [draftCategory, setDraftCategory] = useState("");
|
||
const [draftTags, setDraftTags] = useState("");
|
||
const [saveLoading, setSaveLoading] = useState(false);
|
||
|
||
/* ── 素材库 ── */
|
||
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 [loadedPlanId, setLoadedPlanId] = useState<string | null>(
|
||
urlPlanId || null,
|
||
);
|
||
|
||
/* ── 生成进度 ── */
|
||
const [generating, setGenerating] = useState(false);
|
||
const [genProgress, setGenProgress] = useState(0);
|
||
const [genTotalClips, setGenTotalClips] = useState(0);
|
||
const [genDoneClips, setGenDoneClips] = useState(0);
|
||
const [generated, setGenerated] = useState(false);
|
||
const [generatedVideos, setGeneratedVideos] = useState<GeneratedVideo[]>([]);
|
||
const [genError, setGenError] = useState<string | null>(null);
|
||
const [cancelling, setCancelling] = useState(false);
|
||
const genTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||
|
||
/* ── 播放 ── */
|
||
const [isPlaying, setIsPlaying] = useState(false);
|
||
const [currentTime, setCurrentTime] = useState(0);
|
||
const [pixelsPerSecond, setPixelsPerSecond] = useState(40);
|
||
const prevFrameTimeRef = useRef<number | null>(null);
|
||
|
||
/** 播放头跳转 */
|
||
const handleSeek = useCallback((time: number) => {
|
||
setCurrentTime(Math.max(0, time));
|
||
}, []);
|
||
|
||
/** 轨道缩放 */
|
||
const handleZoomChange = useCallback((pps: number) => {
|
||
setPixelsPerSecond(pps);
|
||
}, []);
|
||
|
||
/* ── 配音素材(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((prev) => ({
|
||
...prev,
|
||
enabled: tpl.subtitle_config.enabled,
|
||
position: (tpl.subtitle_config.position ||
|
||
"bottom") as SubtitleStyleConfig["position"],
|
||
font: tpl.subtitle_config.font,
|
||
fontSize: tpl.subtitle_config.size,
|
||
fontColor: tpl.subtitle_config.color || "#ffffff",
|
||
animation: tpl.subtitle_config.animation,
|
||
}));
|
||
setBgmSettings((prev) => ({
|
||
...prev,
|
||
enabled: tpl.bgm_config.enabled,
|
||
music_id: tpl.bgm_config.music_id || "",
|
||
}));
|
||
setDraftName(tpl.name);
|
||
setDraftCategory(tpl.category);
|
||
setDraftTags(tpl.tags.join(", "));
|
||
})
|
||
.catch(() => message.error("加载模板详情失败"));
|
||
}, [loadedTemplateId, resetClips]);
|
||
|
||
/**
|
||
* 加载已有剪辑计划数据到编辑器
|
||
* 从列表页"编辑"按钮进入时,URL 带 planId,需要还原计划配置
|
||
*/
|
||
useEffect(() => {
|
||
if (!loadedPlanId) return;
|
||
getEditPlan(loadedPlanId)
|
||
.then((plan) => {
|
||
// 设置关联的模板(触发模板加载 effect)
|
||
setLoadedTemplateId(plan.template_id);
|
||
|
||
// 还原基本信息
|
||
setDraftName(plan.name);
|
||
|
||
// 还原 config 中的编辑器状态
|
||
const cfg = plan.config;
|
||
if (cfg.title_config) {
|
||
setTitleSettings((prev) => ({
|
||
...prev,
|
||
aiAutoSelect: cfg.title_config!.ai_auto_select,
|
||
title: cfg.title_config!.content,
|
||
position: cfg.title_config!.position,
|
||
font: cfg.title_config!.font_preset,
|
||
size: cfg.title_config!.font_size,
|
||
color: cfg.title_config!.font_color || "#ffffff",
|
||
}));
|
||
}
|
||
if (cfg.subtitle_config) {
|
||
setSubtitleSettings((prev) => ({
|
||
...prev,
|
||
enabled: cfg.subtitle_config!.enabled,
|
||
position: (cfg.subtitle_config!.position ||
|
||
"bottom") as SubtitleStyleConfig["position"],
|
||
font: cfg.subtitle_config!.font,
|
||
fontSize: cfg.subtitle_config!.size,
|
||
fontColor: cfg.subtitle_config!.color || "#ffffff",
|
||
animation: cfg.subtitle_config!.animation,
|
||
}));
|
||
}
|
||
if (cfg.bgm_config) {
|
||
setBgmSettings((prev) => ({
|
||
...prev,
|
||
enabled: cfg.bgm_config!.enabled,
|
||
music_id: cfg.bgm_config!.music_id || "",
|
||
}));
|
||
}
|
||
|
||
// 还原片段 — 延迟设置,等模板加载 effect 先执行 resetClips
|
||
if (cfg.segments && cfg.segments.length > 0) {
|
||
const mapped: ClipData[] = cfg.segments.map((seg, idx) => ({
|
||
id: `seg-${idx}`,
|
||
template_segment_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,
|
||
transition: seg.transition
|
||
? {
|
||
type: seg.transition.type as TransitionEffect["type"],
|
||
duration: seg.transition.duration,
|
||
}
|
||
: undefined,
|
||
speed: seg.playback_speed
|
||
? { rate: seg.playback_speed, pitchCorrection: true }
|
||
: undefined,
|
||
tts_config: seg.tts_config
|
||
? { ...seg.tts_config, mode: seg.tts_config.mode as TtsMode }
|
||
: undefined,
|
||
trim_config: seg.trim_config || undefined,
|
||
}));
|
||
setTimeout(() => resetClips(mapped), 100);
|
||
}
|
||
})
|
||
.catch(() => message.error("加载剪辑计划失败"));
|
||
}, [loadedPlanId, 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;
|
||
|
||
/* rAF 帧推进 — 播放时平滑更新播放头位置 */
|
||
useEffect(() => {
|
||
if (!isPlaying) {
|
||
prevFrameTimeRef.current = null;
|
||
return;
|
||
}
|
||
let rafId: number;
|
||
const tick = (timestamp: number) => {
|
||
if (prevFrameTimeRef.current !== null) {
|
||
const delta = (timestamp - prevFrameTimeRef.current) / 1000;
|
||
setCurrentTime((prev) => {
|
||
const next = prev + delta;
|
||
return next >= totalDuration ? totalDuration : next;
|
||
});
|
||
}
|
||
prevFrameTimeRef.current = timestamp;
|
||
rafId = requestAnimationFrame(tick);
|
||
};
|
||
rafId = requestAnimationFrame(tick);
|
||
return () => {
|
||
cancelAnimationFrame(rafId);
|
||
prevFrameTimeRef.current = null;
|
||
};
|
||
}, [isPlaying, totalDuration]);
|
||
|
||
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 = useCallback(
|
||
(clipId: string, data: Partial<ClipData>) => {
|
||
setClips((prev) =>
|
||
prev.map((c) => (c.id === clipId ? { ...c, ...data } : c)),
|
||
);
|
||
},
|
||
[setClips],
|
||
);
|
||
|
||
/**
|
||
* 添加片段(不绑定任何素材)
|
||
* 片段 = 时间规划 + 类型标记
|
||
*/
|
||
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],
|
||
);
|
||
|
||
/* ── 裁剪更新:调整片段的 trim_config 和 duration ── */
|
||
const handleClipTrim = useCallback(
|
||
(clipId: string, trimConfig: TrimConfig, newDuration: number) => {
|
||
setClips((prev) =>
|
||
prev.map((c) =>
|
||
c.id === clipId
|
||
? { ...c, trim_config: trimConfig, duration: newDuration }
|
||
: c,
|
||
),
|
||
);
|
||
},
|
||
[setClips],
|
||
);
|
||
|
||
/* ── 片段分割:在指定比例位置将片段一分为二 ── */
|
||
const handleClipSplit = useCallback(
|
||
(clipId: string, splitRatio: number) => {
|
||
setClips((prev) => {
|
||
const idx = prev.findIndex((c) => c.id === clipId);
|
||
if (idx === -1) return prev;
|
||
const clip = prev[idx];
|
||
const splitPoint = Math.round(clip.duration * splitRatio * 10) / 10;
|
||
if (splitPoint < 0.5 || splitPoint >= clip.duration - 0.5) return prev;
|
||
|
||
// 前半段
|
||
const firstHalf: ClipData = {
|
||
...clip,
|
||
duration: splitPoint,
|
||
trim_config: clip.trim_config
|
||
? {
|
||
...clip.trim_config,
|
||
end_time: clip.trim_config.start_time + splitPoint,
|
||
}
|
||
: undefined,
|
||
};
|
||
|
||
// 后半段
|
||
const secondHalf: ClipData = {
|
||
...clip,
|
||
id: `clip-${Date.now()}`,
|
||
duration: clip.duration - splitPoint,
|
||
startOffset: clip.startOffset + splitPoint,
|
||
trim_config: clip.trim_config
|
||
? {
|
||
...clip.trim_config,
|
||
start_time: clip.trim_config.start_time + splitPoint,
|
||
}
|
||
: undefined,
|
||
order: (clip.order ?? idx) + 1,
|
||
};
|
||
|
||
const updated = [...prev];
|
||
updated[idx] = firstHalf;
|
||
updated.splice(idx + 1, 0, secondHalf);
|
||
return updated.map((c, i) => ({ ...c, order: i }));
|
||
});
|
||
},
|
||
[setClips],
|
||
);
|
||
|
||
/* ── 恢复片段原始长度 ── */
|
||
const handleClipResetTrim = useCallback(
|
||
(clipId: string) => {
|
||
setClips((prev) =>
|
||
prev.map((c) => {
|
||
if (c.id !== clipId || !c.trim_config) return c;
|
||
const originalDuration =
|
||
c.trim_config.original_duration ?? c.duration;
|
||
return {
|
||
...c,
|
||
duration: originalDuration,
|
||
trim_config: undefined,
|
||
};
|
||
}),
|
||
);
|
||
},
|
||
[setClips],
|
||
);
|
||
|
||
/* ── 转场特效变更 ── */
|
||
const handleTransitionChange = useCallback(
|
||
(config: TransitionConfig) => {
|
||
if (transitionTargetClipId) {
|
||
// 更新指定片段的转场
|
||
handleClipUpdate(transitionTargetClipId, { transition: config });
|
||
}
|
||
// 同时更新全局默认转场(供新片段使用)
|
||
},
|
||
[transitionTargetClipId, handleClipUpdate],
|
||
);
|
||
|
||
/* ── 打开转场选择器 ── */
|
||
const handleOpenTransitionDrawer = useCallback((clipId?: string) => {
|
||
setTransitionTargetClipId(clipId ?? null);
|
||
setTransitionDrawerOpen(true);
|
||
}, []);
|
||
|
||
/* ── 调速变更 ── */
|
||
const handleSpeedChange = useCallback(
|
||
(config: SpeedConfig) => {
|
||
if (speedTargetClipId) {
|
||
handleClipUpdate(speedTargetClipId, { speed: config });
|
||
}
|
||
},
|
||
[speedTargetClipId, handleClipUpdate],
|
||
);
|
||
|
||
/* ── 打开调速面板 ── */
|
||
const handleOpenSpeedDrawer = useCallback((clipId: string) => {
|
||
setSpeedTargetClipId(clipId);
|
||
setSpeedDrawerOpen(true);
|
||
}, []);
|
||
|
||
/* ── TTS 配音变更 ── */
|
||
const handleTtsChange = useCallback(
|
||
(ttsConfig: TtsConfig) => {
|
||
if (!ttsTargetClipId) return;
|
||
handleClipUpdate(ttsTargetClipId, { tts_config: ttsConfig });
|
||
},
|
||
[ttsTargetClipId, handleClipUpdate],
|
||
);
|
||
|
||
/* ── 打开 TTS 配音面板 ── */
|
||
const handleOpenTtsDrawer = useCallback((clipId: string) => {
|
||
setTtsTargetClipId(clipId);
|
||
setTtsDrawerOpen(true);
|
||
}, []);
|
||
|
||
/* ── 调速应用到所有片段 ── */
|
||
const handleApplySpeedAll = useCallback(
|
||
(config: SpeedConfig) => {
|
||
setClips((prev) => prev.map((c) => ({ ...c, speed: { ...config } })));
|
||
message.success("已应用到所有片段");
|
||
},
|
||
[setClips],
|
||
);
|
||
|
||
/* ── 水印配置变更 ── */
|
||
const handleWatermarkChange = useCallback((config: WatermarkConfig) => {
|
||
setWatermarkSettings(config);
|
||
}, []);
|
||
|
||
/* ── 片头片尾配置变更 ── */
|
||
const handleIntroOutroChange = useCallback((config: IntroOutroConfig) => {
|
||
setIntroOutroSettings(config);
|
||
}, []);
|
||
|
||
/* ── 画中画配置变更 ── */
|
||
const handlePipChange = useCallback((config: PipConfig) => {
|
||
setPipSettings(config);
|
||
}, []);
|
||
|
||
/* ── 滤镜调色配置变更 ── */
|
||
const handleFilterChange = useCallback((config: FilterConfig) => {
|
||
setFilterSettings(config);
|
||
}, []);
|
||
|
||
/* ── 绿幕抠像配置变更 ── */
|
||
const handleChromaKeyChange = useCallback((config: ChromaKeyConfig) => {
|
||
setChromaKeySettings(config);
|
||
}, []);
|
||
|
||
/* ── 贴纸配置变更 ── */
|
||
const handleStickerChange = useCallback((config: StickerConfig) => {
|
||
setStickerSettings(config);
|
||
}, []);
|
||
|
||
/* ── 封面配置变更 ── */
|
||
const handleCoverChange = useCallback((config: CoverConfig) => {
|
||
setCoverSettings(config);
|
||
}, []);
|
||
|
||
/* 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);
|
||
}
|
||
};
|
||
|
||
/** 构建剪辑计划 config(编辑器状态 → API config) */
|
||
const buildPlanConfig = (): EditPlanConfig => ({
|
||
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,
|
||
},
|
||
subtitle_config: {
|
||
enabled: subtitleSettings.enabled,
|
||
position: subtitleSettings.position,
|
||
font: subtitleSettings.font,
|
||
color: subtitleSettings.fontColor,
|
||
size: subtitleSettings.fontSize,
|
||
animation: subtitleSettings.animation,
|
||
},
|
||
bgm_config: {
|
||
enabled: bgmSettings.enabled,
|
||
music_id: bgmSettings.music_id,
|
||
},
|
||
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",
|
||
transition: c.transition
|
||
? { type: c.transition.type, duration: c.transition.duration }
|
||
: undefined,
|
||
playback_speed: c.speed ? c.speed.rate : undefined,
|
||
tts_config: c.tts_config
|
||
? {
|
||
mode: c.tts_config.mode,
|
||
text: c.tts_config.text,
|
||
voice_id: c.tts_config.voice_id,
|
||
speed: c.tts_config.speed,
|
||
pitch: c.tts_config.pitch,
|
||
volume: c.tts_config.volume,
|
||
subtitle_sync: c.tts_config.subtitle_sync,
|
||
}
|
||
: undefined,
|
||
trim_config: c.trim_config
|
||
? {
|
||
start_time: c.trim_config.start_time,
|
||
end_time: c.trim_config.end_time,
|
||
}
|
||
: undefined,
|
||
})),
|
||
watermark_config: { ...watermarkSettings },
|
||
intro_outro_config: { ...introOutroSettings },
|
||
pip_config: { ...pipSettings },
|
||
filter_config: { ...filterSettings },
|
||
green_screen_config: { ...chromaKeySettings },
|
||
sticker_config: { ...stickerSettings },
|
||
cover_config: { ...coverSettings },
|
||
});
|
||
|
||
/* 保存 — 无论是否已加载模板,都打开保存弹窗;未加载时创建新模板 */
|
||
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: subtitleSettings.fontColor,
|
||
size: subtitleSettings.fontSize,
|
||
animation: subtitleSettings.animation,
|
||
},
|
||
bgm_config: {
|
||
enabled: bgmSettings.enabled,
|
||
music_id: bgmSettings.music_id,
|
||
},
|
||
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",
|
||
transition: c.transition
|
||
? { type: c.transition.type, duration: c.transition.duration }
|
||
: undefined,
|
||
playback_speed: c.speed ? c.speed.rate : undefined,
|
||
tts_config: c.tts_config
|
||
? {
|
||
mode: c.tts_config.mode,
|
||
text: c.tts_config.text,
|
||
voice_id: c.tts_config.voice_id,
|
||
speed: c.tts_config.speed,
|
||
pitch: c.tts_config.pitch,
|
||
volume: c.tts_config.volume,
|
||
subtitle_sync: c.tts_config.subtitle_sync,
|
||
}
|
||
: undefined,
|
||
trim_config: c.trim_config
|
||
? {
|
||
start_time: c.trim_config.start_time,
|
||
end_time: c.trim_config.end_time,
|
||
}
|
||
: undefined,
|
||
})),
|
||
watermark_config: { ...watermarkSettings },
|
||
intro_outro_config: { ...introOutroSettings },
|
||
pip_config: { ...pipSettings },
|
||
filter_config: { ...filterSettings },
|
||
green_screen_config: { ...chromaKeySettings },
|
||
sticker_config: { ...stickerSettings },
|
||
cover_config: { ...coverSettings },
|
||
};
|
||
if (loadedTemplateId) {
|
||
await updateEditingTemplate(loadedTemplateId, payload);
|
||
} else {
|
||
await createEditingTemplate(payload);
|
||
}
|
||
message.success(loadedTemplateId ? "模板保存成功" : "模板创建成功");
|
||
setSaveModalOpen(false);
|
||
loadTemplates();
|
||
} catch {
|
||
message.error("保存失败");
|
||
} finally {
|
||
setSaveLoading(false);
|
||
}
|
||
};
|
||
|
||
/**
|
||
* 剪辑计划生成
|
||
* 1. 有 planId → 更新计划配置 + 触发生成
|
||
* 2. 无 planId(从模板库直接进入)→ 先创建计划 + 触发生成
|
||
* 3. 触发生成后轮询状态,完成后获取视频结果
|
||
*/
|
||
const handleGoToGenerate = async () => {
|
||
if (!loadedTemplateId) {
|
||
message.warning("请先选择一个模板");
|
||
return;
|
||
}
|
||
if (clips.length === 0) {
|
||
message.warning("请先添加片段");
|
||
return;
|
||
}
|
||
|
||
setGenerating(true);
|
||
setGenerated(false);
|
||
setGeneratedVideos([]);
|
||
setGenError(null);
|
||
setGenProgress(0);
|
||
|
||
try {
|
||
const config = buildPlanConfig();
|
||
let planId = loadedPlanId;
|
||
|
||
if (planId) {
|
||
// 已有计划 → 先重置状态为 draft(failed/editing 等非 draft 状态会被后端拒绝更新和生成)
|
||
try {
|
||
await updateEditPlan(planId, { status: "draft" });
|
||
} catch (resetErr) {
|
||
console.warn("[状态重置跳过]", resetErr);
|
||
}
|
||
// 再更新配置
|
||
try {
|
||
await updateEditPlan(planId, {
|
||
config,
|
||
total_duration: totalDuration,
|
||
});
|
||
} catch (updateErr) {
|
||
console.warn("[计划更新跳过]", updateErr);
|
||
}
|
||
} else {
|
||
// 无计划 → 创建新计划
|
||
const plan = await createEditPlan({
|
||
template_id: loadedTemplateId,
|
||
name: draftName || "未命名计划",
|
||
config,
|
||
total_duration: totalDuration,
|
||
});
|
||
planId = plan.id;
|
||
setLoadedPlanId(planId);
|
||
// 更新 URL 参数(不刷新页面)
|
||
const params = new URLSearchParams(window.location.search);
|
||
params.set("planId", planId);
|
||
window.history.replaceState(null, "", `?${params.toString()}`);
|
||
}
|
||
|
||
// 触发生成
|
||
const genRes = await generateEditPlan(planId);
|
||
setGenTotalClips(genRes.clip_count);
|
||
message.info("已提交生成,等待处理...");
|
||
|
||
// 开始轮询
|
||
startPolling(planId);
|
||
} catch (err) {
|
||
console.error("[生成失败]", err);
|
||
setGenError("生成提交失败,请重试");
|
||
setGenerating(false);
|
||
}
|
||
};
|
||
|
||
/** 轮询生成状态,每 2 秒一次 */
|
||
const startPolling = (planId: string) => {
|
||
const poll = async () => {
|
||
try {
|
||
const status = await getGenerationStatus(planId);
|
||
|
||
// 计算进度
|
||
const total = status.clips.length || genTotalClips;
|
||
const done = status.clips.filter(
|
||
(c) => c.status === "completed" || c.status === "failed",
|
||
).length;
|
||
setGenDoneClips(done);
|
||
setGenTotalClips(total);
|
||
setGenProgress(total > 0 ? Math.round((done / total) * 100) : 5);
|
||
|
||
if (status.plan_status === "completed") {
|
||
setGenProgress(100);
|
||
setGenerating(false);
|
||
setGenerated(true);
|
||
|
||
// 获取视频结果
|
||
if (status.generation_task_id) {
|
||
try {
|
||
const videos = await getGenerationTaskResults(
|
||
status.generation_task_id,
|
||
);
|
||
setGeneratedVideos(videos);
|
||
} catch (e) {
|
||
console.error("[获取视频结果失败]", e);
|
||
}
|
||
}
|
||
message.success("视频生成完成!");
|
||
return; // 停止轮询
|
||
}
|
||
|
||
if (status.plan_status === "failed") {
|
||
setGenerating(false);
|
||
setGenError("生成失败,请重试");
|
||
return; // 停止轮询
|
||
}
|
||
|
||
if (status.plan_status === "cancelled") {
|
||
setGenerating(false);
|
||
setGenError("生成已取消");
|
||
message.info("生成任务已取消");
|
||
return; // 停止轮询
|
||
}
|
||
|
||
// 继续轮询
|
||
genTimerRef.current = setTimeout(poll, 2000);
|
||
} catch (err) {
|
||
console.error("[轮询状态失败]", err);
|
||
genTimerRef.current = setTimeout(poll, 5000); // 出错后 5 秒重试
|
||
}
|
||
};
|
||
|
||
// 首次延迟 2 秒后开始
|
||
genTimerRef.current = setTimeout(poll, 2000);
|
||
};
|
||
|
||
/** 清理轮询定时器 */
|
||
useEffect(() => {
|
||
return () => {
|
||
if (genTimerRef.current) clearTimeout(genTimerRef.current);
|
||
};
|
||
}, []);
|
||
|
||
/** 取消生成任务 */
|
||
const handleCancelGeneration = async () => {
|
||
const targetId = loadedPlanId;
|
||
if (!targetId) return;
|
||
|
||
Modal.confirm({
|
||
title: "确认取消生成",
|
||
content: "取消后已开始的生成任务,已生成的片段不会保留。确定要取消吗?",
|
||
okText: "确认取消",
|
||
cancelText: "继续生成",
|
||
okButtonProps: { danger: true },
|
||
onOk: async () => {
|
||
try {
|
||
setCancelling(true);
|
||
await cancelGeneration(targetId);
|
||
message.success("已提交取消请求");
|
||
// 轮询会继续运行直到检测到 cancelled 状态
|
||
} catch (err) {
|
||
console.error("[取消失败]", err);
|
||
message.error("取消失败,请稍后重试");
|
||
} finally {
|
||
setCancelling(false);
|
||
}
|
||
},
|
||
});
|
||
};
|
||
|
||
/* 查看生成历史 */
|
||
const handleViewGenHistory = async () => {
|
||
const targetId = loadedPlanId || loadedTemplateId;
|
||
if (!targetId) {
|
||
message.warning("请先加载一个模板或计划");
|
||
return;
|
||
}
|
||
setGenHistoryOpen(true);
|
||
setGenHistoryLoading(true);
|
||
try {
|
||
const items = await getEditPlanGenerations(targetId);
|
||
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}
|
||
disabled={generating}
|
||
>
|
||
{loadedPlanId ? "🎬 生成视频" : "🎬 创建计划并生成"}
|
||
</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={{
|
||
enabled: subtitleSettings.enabled,
|
||
position: subtitleSettings.position,
|
||
font: subtitleSettings.font,
|
||
size: subtitleSettings.fontSize,
|
||
animation: subtitleSettings.animation,
|
||
}}
|
||
onClipSelect={handleClipSelect}
|
||
onCoverSchemeChange={setCurrentCoverScheme}
|
||
onPlayPause={() => setIsPlaying(!isPlaying)}
|
||
onAiGenerateCover={handleAiGenerateCover}
|
||
/>
|
||
|
||
{/* 下半部:水平时间线 */}
|
||
<TimelinePanel
|
||
clips={clips}
|
||
selectedClipId={selectedClipId}
|
||
currentMode={currentMode}
|
||
onClipSelect={handleClipSelect}
|
||
onClipReorder={handleClipReorder}
|
||
onClipRemove={handleClipRemove}
|
||
onAddClip={handleAddClip}
|
||
onClipTrim={handleClipTrim}
|
||
onClipSplit={handleClipSplit}
|
||
onClipResetTrim={handleClipResetTrim}
|
||
currentTime={currentTime}
|
||
pixelsPerSecond={pixelsPerSecond}
|
||
onZoomChange={handleZoomChange}
|
||
onSeek={handleSeek}
|
||
totalDuration={totalDuration}
|
||
/>
|
||
</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 }) as SubtitleStyleConfig,
|
||
)
|
||
}
|
||
onBgmSettingsChange={(partial) =>
|
||
setBgmSettings((prev) => ({ ...prev, ...partial }))
|
||
}
|
||
onClipUpdate={handleClipUpdate}
|
||
onOpenBgmDrawer={() => setBgmDrawerOpen(true)}
|
||
onOpenSubtitleDrawer={() => setSubtitleDrawerOpen(true)}
|
||
voiceMaterials={voiceMaterials}
|
||
voiceMaterialsLoading={voiceMaterialsQuery.isLoading}
|
||
onRefreshVoiceMaterials={() => voiceMaterialsQuery.refetch()}
|
||
onClipVoiceSelect={handleClipVoiceSelect}
|
||
onOpenTransitionDrawer={handleOpenTransitionDrawer}
|
||
onOpenSpeedDrawer={handleOpenSpeedDrawer}
|
||
onOpenTtsDrawer={handleOpenTtsDrawer}
|
||
onOpenWatermarkDrawer={() => setWatermarkDrawerOpen(true)}
|
||
onOpenIntroOutroDrawer={() => setIntroOutroDrawerOpen(true)}
|
||
onOpenPipDrawer={() => setPipDrawerOpen(true)}
|
||
onOpenFilterDrawer={() => setFilterDrawerOpen(true)}
|
||
onOpenGreenScreenDrawer={() => setChromaKeyDrawerOpen(true)}
|
||
onOpenStickerDrawer={() => setStickerDrawerOpen(true)}
|
||
onOpenCoverDrawer={() => setCoverDrawerOpen(true)}
|
||
/>
|
||
</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)}
|
||
/>
|
||
|
||
{/* ═══ 生成历史弹窗 ═══ */}
|
||
<GenerationHistoryModal
|
||
open={genHistoryOpen}
|
||
loading={genHistoryLoading}
|
||
history={genHistory}
|
||
onClose={() => setGenHistoryOpen(false)}
|
||
onCancel={async () => {
|
||
Modal.confirm({
|
||
title: "确认取消生成",
|
||
content: "确定要取消这个生成任务吗?此操作不可恢复。",
|
||
okText: "确认取消",
|
||
cancelText: "再等等",
|
||
okButtonProps: { danger: true },
|
||
onOk: async () => {
|
||
if (!loadedPlanId) return;
|
||
try {
|
||
await cancelGeneration(loadedPlanId);
|
||
message.success("已提交取消请求");
|
||
// 刷新历史列表
|
||
handleViewGenHistory();
|
||
} catch (err) {
|
||
console.error("[取消失败]", err);
|
||
message.error("取消失败,请稍后重试");
|
||
}
|
||
},
|
||
});
|
||
}}
|
||
cancelLoading={cancelling}
|
||
/>
|
||
|
||
{/* ═══ 生成进度弹窗 ═══ */}
|
||
<Modal
|
||
title={genError ? "生成失败" : generated ? "生成完成" : "正在生成视频"}
|
||
open={generating || generated || !!genError}
|
||
footer={
|
||
generated
|
||
? [
|
||
<Button
|
||
key="close"
|
||
onClick={() => {
|
||
setGenerated(false);
|
||
setGenerating(false);
|
||
setGenError(null);
|
||
}}
|
||
>
|
||
关闭
|
||
</Button>,
|
||
generatedVideos.length > 0 && (
|
||
<Button
|
||
key="download"
|
||
type="primary"
|
||
onClick={() => {
|
||
const v = generatedVideos[0];
|
||
const url = v.download_url || v.file_url;
|
||
if (url) {
|
||
const a = document.createElement("a");
|
||
a.href = url;
|
||
a.download = v.name || "video.mp4";
|
||
a.target = "_blank";
|
||
document.body.appendChild(a);
|
||
a.click();
|
||
document.body.removeChild(a);
|
||
}
|
||
}}
|
||
>
|
||
下载视频
|
||
</Button>
|
||
),
|
||
]
|
||
: generating
|
||
? [
|
||
<Button
|
||
key="cancel"
|
||
danger
|
||
loading={cancelling}
|
||
onClick={handleCancelGeneration}
|
||
>
|
||
取消生成
|
||
</Button>,
|
||
]
|
||
: genError
|
||
? [
|
||
<Button
|
||
key="close"
|
||
onClick={() => {
|
||
setGenError(null);
|
||
setGenerating(false);
|
||
}}
|
||
>
|
||
关闭
|
||
</Button>,
|
||
]
|
||
: null
|
||
}
|
||
closable={!generating}
|
||
maskClosable={false}
|
||
width={520}
|
||
>
|
||
{generating && (
|
||
<div style={{ padding: "16px 0" }}>
|
||
<Progress percent={genProgress} status="active" />
|
||
<p style={{ marginTop: 8, color: "var(--text-secondary)" }}>
|
||
已处理 {genDoneClips}/{genTotalClips} 个片段
|
||
</p>
|
||
<p style={{ color: "var(--text-secondary)", fontSize: 12 }}>
|
||
请耐心等待,生成过程中请勿关闭页面
|
||
</p>
|
||
</div>
|
||
)}
|
||
{generated && generatedVideos.length > 0 && (
|
||
<div style={{ padding: "8px 0" }}>
|
||
<video
|
||
src={
|
||
generatedVideos[0].file_url || generatedVideos[0].download_url
|
||
}
|
||
controls
|
||
preload="metadata"
|
||
style={{ width: "100%", maxHeight: 320, borderRadius: 8 }}
|
||
/>
|
||
<p
|
||
style={{
|
||
marginTop: 8,
|
||
textAlign: "center",
|
||
color: "var(--text-secondary)",
|
||
}}
|
||
>
|
||
{generatedVideos[0].name}
|
||
</p>
|
||
</div>
|
||
)}
|
||
{generated && !generatedVideos.length && (
|
||
<div style={{ padding: "24px 0", textAlign: "center" }}>
|
||
<p>生成完成,但暂未获取到视频结果</p>
|
||
<p style={{ color: "var(--text-secondary)", fontSize: 12 }}>
|
||
请稍后在剪辑计划列表中查看
|
||
</p>
|
||
</div>
|
||
)}
|
||
{genError && (
|
||
<div
|
||
style={{
|
||
padding: "16px 0",
|
||
textAlign: "center",
|
||
color: "#ff4d4f",
|
||
}}
|
||
>
|
||
<p>{genError}</p>
|
||
<Button
|
||
onClick={() => {
|
||
setGenError(null);
|
||
setGenerating(false);
|
||
}}
|
||
>
|
||
关闭
|
||
</Button>
|
||
</div>
|
||
)}
|
||
</Modal>
|
||
|
||
{/* ═══ BGM 选择器 Drawer ═══ */}
|
||
<BgmSelector
|
||
open={bgmDrawerOpen}
|
||
onClose={() => setBgmDrawerOpen(false)}
|
||
config={bgmSettings}
|
||
onChange={setBgmSettings}
|
||
/>
|
||
|
||
{/* ═══ 字幕样式配置 Drawer ═══ */}
|
||
<SubtitleStylePanel
|
||
open={subtitleDrawerOpen}
|
||
onClose={() => setSubtitleDrawerOpen(false)}
|
||
config={subtitleSettings}
|
||
onChange={setSubtitleSettings}
|
||
/>
|
||
|
||
{/* ═══ 转场特效选择器 Drawer ═══ */}
|
||
<TransitionSelector
|
||
open={transitionDrawerOpen}
|
||
onClose={() => setTransitionDrawerOpen(false)}
|
||
config={
|
||
transitionTargetClipId
|
||
? (clips.find((c) => c.id === transitionTargetClipId)?.transition ??
|
||
DEFAULT_TRANSITION)
|
||
: DEFAULT_TRANSITION
|
||
}
|
||
onChange={handleTransitionChange}
|
||
title={transitionTargetClipId ? "片段转场设置" : "全局默认转场"}
|
||
/>
|
||
|
||
{/* ═══ 片段调速面板 Drawer ═══ */}
|
||
{speedTargetClipId && (
|
||
<SpeedPanel
|
||
open={speedDrawerOpen}
|
||
onClose={() => setSpeedDrawerOpen(false)}
|
||
config={
|
||
clips.find((c) => c.id === speedTargetClipId)?.speed ??
|
||
DEFAULT_SPEED
|
||
}
|
||
onChange={handleSpeedChange}
|
||
onApplyAll={handleApplySpeedAll}
|
||
/>
|
||
)}
|
||
|
||
{/* ═══ TTS 配音面板 Drawer ═══ */}
|
||
{ttsTargetClipId && (
|
||
<TtsPanel
|
||
open={ttsDrawerOpen}
|
||
onClose={() => setTtsDrawerOpen(false)}
|
||
config={
|
||
clips.find((c) => c.id === ttsTargetClipId)?.tts_config ??
|
||
DEFAULT_TTS_CONFIG
|
||
}
|
||
onChange={handleTtsChange}
|
||
/>
|
||
)}
|
||
|
||
{/* ═══ 水印配置面板 ═══ */}
|
||
<WatermarkPanel
|
||
open={watermarkDrawerOpen}
|
||
onClose={() => setWatermarkDrawerOpen(false)}
|
||
config={watermarkSettings}
|
||
onChange={handleWatermarkChange}
|
||
/>
|
||
|
||
{/* ═══ 片头片尾配置面板 ═══ */}
|
||
<IntroOutroPanel
|
||
open={introOutroDrawerOpen}
|
||
onClose={() => setIntroOutroDrawerOpen(false)}
|
||
config={introOutroSettings}
|
||
onChange={handleIntroOutroChange}
|
||
/>
|
||
|
||
{/* ═══ 画中画配置面板 ═══ */}
|
||
<PipConfigPanel
|
||
open={pipDrawerOpen}
|
||
onClose={() => setPipDrawerOpen(false)}
|
||
config={pipSettings}
|
||
onChange={handlePipChange}
|
||
totalDuration={totalDuration}
|
||
/>
|
||
|
||
{/* ═══ 滤镜调色面板 ═══ */}
|
||
<FilterPanel
|
||
open={filterDrawerOpen}
|
||
onClose={() => setFilterDrawerOpen(false)}
|
||
config={filterSettings}
|
||
onChange={handleFilterChange}
|
||
/>
|
||
|
||
{/* ═══ 绿幕抠像面板 ═══ */}
|
||
<GreenScreenPanel
|
||
open={chromaKeyDrawerOpen}
|
||
onClose={() => setChromaKeyDrawerOpen(false)}
|
||
config={chromaKeySettings}
|
||
onChange={handleChromaKeyChange}
|
||
/>
|
||
|
||
{/* ═══ 贴纸面板 ═══ */}
|
||
<StickerPanel
|
||
open={stickerDrawerOpen}
|
||
onClose={() => setStickerDrawerOpen(false)}
|
||
config={stickerSettings}
|
||
onChange={handleStickerChange}
|
||
totalDuration={totalDuration}
|
||
/>
|
||
|
||
{/* ═══ 封面选择器 ═══ */}
|
||
<CoverSelector
|
||
open={coverDrawerOpen}
|
||
onClose={() => setCoverDrawerOpen(false)}
|
||
config={coverSettings}
|
||
onChange={handleCoverChange}
|
||
totalDuration={totalDuration}
|
||
/>
|
||
</div>
|
||
);
|
||
};
|
||
|
||
export default EditingPlanner;
|