From a1bda3e484c89ec053dd2af3e0b3a05f53b058fe Mon Sep 17 00:00:00 2001 From: SaaS Frontend Date: Sun, 26 Jul 2026 03:39:36 +0800 Subject: [PATCH 1/2] refactor(editing-planner): Phase 3 - extract business hooks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extract 4 business hooks from EditingPlanner.tsx: - useEditorDrawers (78行): 11个抽屉开关 + 目标ID + 快捷打开 - usePlaybackControl (56行): 播放/暂停、rAF帧推进、缩放、seek - useClipOperations (231行): 片段CRUD、重排、裁剪、分割、转场、调速、TTS、配音 - useTemplateManagement (459行): 模板列表/详情/计划加载、保存、模式切换、筛选搜索 主文件: 997→415行(-58%) --- .../pages/editing-planner/EditingPlanner.tsx | 941 ++++-------------- .../editing-planner/components/RightPanel.tsx | 0 .../editing-planner/components/TopBar.tsx | 2 +- .../hooks/useClipOperations.ts | 238 +++++ .../editing-planner/hooks/useEditorDrawers.ts | 78 ++ .../hooks/usePlaybackControl.ts | 56 ++ .../hooks/useTemplateManagement.ts | 466 +++++++++ .../test/pages/editing-planner/smoke.test.tsx | 4 + 8 files changed, 1022 insertions(+), 763 deletions(-) mode change 100755 => 100644 apps/web/src/pages/editing-planner/EditingPlanner.tsx mode change 100755 => 100644 apps/web/src/pages/editing-planner/components/RightPanel.tsx create mode 100644 apps/web/src/pages/editing-planner/hooks/useClipOperations.ts create mode 100644 apps/web/src/pages/editing-planner/hooks/useEditorDrawers.ts create mode 100644 apps/web/src/pages/editing-planner/hooks/usePlaybackControl.ts create mode 100644 apps/web/src/pages/editing-planner/hooks/useTemplateManagement.ts mode change 100755 => 100644 apps/web/src/test/pages/editing-planner/smoke.test.tsx diff --git a/apps/web/src/pages/editing-planner/EditingPlanner.tsx b/apps/web/src/pages/editing-planner/EditingPlanner.tsx old mode 100755 new mode 100644 index aca676760..bc202becf --- a/apps/web/src/pages/editing-planner/EditingPlanner.tsx +++ b/apps/web/src/pages/editing-planner/EditingPlanner.tsx @@ -2,35 +2,34 @@ * 模板编辑器 — 制作/编辑剪辑模板 * 四行布局:顶栏(42px) → 模式栏(48px) → 三栏主体 → 底栏(40px) */ -import React, { useState, useCallback, useEffect, useRef } from "react" +import React, { useState } from "react" import { useSearchParams } from "react-router-dom" -import { message, Modal } from "antd" import { useQuery } from "@tanstack/react-query" -import type { - EditingTemplate, - TemplateCategory, - TemplateMode, - SaveTemplatePayload, -} from "@/api/editing-planner" -import { - getEditingTemplates, - getEditingTemplate, - createEditingTemplate, - updateEditingTemplate, - getTemplateCategories, - MODE_LABELS, -} from "@/api/editing-planner" -import type { MediaAsset, TransitionEffect, TitleConfig } from "@/api/template-editor" -import { getMediaAssets, getEditPlan, getEditPlanClips } from "@/api/template-editor" +import type { TemplateMode } from "@/api/editing-planner" +import { MODE_LABELS } from "@/api/editing-planner" +import { MODE_LIST } from "./constants" +import type { MediaAsset, TitleConfig } from "@/api/template-editor" +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 TopBar from "./components/TopBar" +import ModeBar from "./components/ModeBar" +import RightPanel from "./components/RightPanel" +import StatusBar from "./components/StatusBar" +import EditorDrawers from "./components/EditorDrawers" +import SaveModal from "./components/SaveModal" + import { useUndoRedo } from "./hooks/useUndoRedo" +import { useEditorDrawers } from "./hooks/useEditorDrawers" +import { usePlaybackControl } from "./hooks/usePlaybackControl" +import { useClipOperations } from "./hooks/useClipOperations" +import { useTemplateManagement, FILTER_CATEGORIES } from "./hooks/useTemplateManagement" + import type { ClipData, - ClipType, - TransitionConfig, - SpeedConfig, - TtsConfig, - TtsMode, - TrimConfig, WatermarkConfig, IntroOutroConfig, PipConfig, @@ -48,30 +47,14 @@ import { 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 TopBar from "./components/TopBar" -import ModeBar from "./components/ModeBar" -import RightPanel from "./components/RightPanel" -import StatusBar from "./components/StatusBar" -import EditorDrawers from "./components/EditorDrawers" -import SaveModal from "./components/SaveModal" import type { SubtitleStyleConfig } from "./types/subtitle" import { DEFAULT_SUBTITLE_STYLE } from "./types/subtitle" import { DEFAULT_BGM_MIX_CONFIG, type BgmMixConfig } from "@/api/bgm" -import { MODE_LIST, FILTER_CATEGORIES } from "./constants" -import { - calculateTotalDuration, - getCurrentTemplate, - getFilteredTemplates, - getSelectedClip, -} from "./utils/selectors" import "./EditingPlanner.css" +/* ──────────── 常量 ──────────── */ + /* ──────────── 组件 ──────────── */ const EditingPlanner: React.FC = () => { @@ -79,13 +62,6 @@ const EditingPlanner: React.FC = () => { const urlTemplateId = searchParams.get("templateId") || "" const urlPlanId = searchParams.get("planId") || "" - /* ── 模板列表 ── */ - const [templates, setTemplates] = useState([]) - const [categories, setCategories] = useState([]) - const [loadingTemplates, setLoadingTemplates] = useState(false) - const [loadedTemplateId, setLoadedTemplateId] = useState(urlTemplateId || null) - const [currentMode, setCurrentMode] = useState("pip") - /* ── 片段(撤销/重做) ── */ const { state: clips, @@ -96,20 +72,15 @@ const EditingPlanner: React.FC = () => { canRedo, reset: resetClips, } = useUndoRedo([]) - const [selectedClipId, setSelectedClipId] = useState(null) - /* ── 左栏筛选 ── */ - const [currentFilter, setCurrentFilter] = useState("全部") - const [searchQuery, setSearchQuery] = useState("") - - /* ── 标题配置(只读,从模板/计划继承) ── */ + /* ── 全局配置 state ── */ const [titleConfig, setTitleConfig] = useState({ ai_auto_select: false, content: "", position: "bottom", font_preset: "思源黑体", - font_color: "#ffffff", font_size: 28, + font_color: "#ffffff", }) const [subtitleSettings, setSubtitleSettings] = useState({ @@ -120,68 +91,36 @@ const EditingPlanner: React.FC = () => { ...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(null) - /** 当前正在调速的片段 ID */ - const [speedTargetClipId, setSpeedTargetClipId] = useState(null) - /** TTS 配音面板是否打开 */ - const [ttsDrawerOpen, setTtsDrawerOpen] = useState(false) - /** 当前正在配置 TTS 的片段 ID */ - const [ttsTargetClipId, setTtsTargetClipId] = useState(null) - - /* ── 水印 / 片头片尾 ── */ const [watermarkSettings, setWatermarkSettings] = useState({ ...DEFAULT_WATERMARK, }) const [introOutroSettings, setIntroOutroSettings] = useState({ ...DEFAULT_INTRO_OUTRO, }) - const [watermarkDrawerOpen, setWatermarkDrawerOpen] = useState(false) - const [introOutroDrawerOpen, setIntroOutroDrawerOpen] = useState(false) - /* ── 混剪 ── */ const [pipSettings, setPipSettings] = useState({ ...DEFAULT_PIP_CONFIG, }) - const [pipDrawerOpen, setPipDrawerOpen] = useState(false) - /* ── 滤镜调色 ── */ const [filterSettings, setFilterSettings] = useState({ ...DEFAULT_FILTER_CONFIG, }) - const [filterDrawerOpen, setFilterDrawerOpen] = useState(false) - /* ── 绿幕抠像 ── */ const [chromaKeySettings, setChromaKeySettings] = useState({ ...DEFAULT_CHROMA_KEY_CONFIG, }) - const [chromaKeyDrawerOpen, setChromaKeyDrawerOpen] = useState(false) - /* ── 贴纸 ── */ const [stickerSettings, setStickerSettings] = useState({ ...DEFAULT_STICKER_CONFIG, }) - const [stickerDrawerOpen, setStickerDrawerOpen] = useState(false) - /* ── 封面配置(只读,从模板/计划继承) ── */ const [coverConfig, setCoverConfig] = useState({ ...DEFAULT_COVER_CONFIG, }) + /* ── 右侧栏 Tab ── */ const [rightTab, setRightTab] = useState<"properties" | "clips">("properties") - /* ── 保存弹窗 ── */ - 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([]) const [selectedAssetIds, setSelectedAssetIds] = useState([]) @@ -190,25 +129,6 @@ const EditingPlanner: React.FC = () => { setSelectedAssetIds(ids) } - /* ── 模板草稿(从模板列表编辑进入时) ── */ - const [loadedPlanId] = useState(urlPlanId || null) - - /* ── 播放 ── */ - const [isPlaying, setIsPlaying] = useState(false) - const [currentTime, setCurrentTime] = useState(0) - const [pixelsPerSecond, setPixelsPerSecond] = useState(40) - const prevFrameTimeRef = useRef(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"], @@ -222,576 +142,67 @@ const EditingPlanner: React.FC = () => { }) 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], - ) + /* ── 派生计算 ── */ + const totalDuration = clips.reduce((sum, c) => sum + c.duration, 0) - /* ──────────── 加载 ──────────── */ + /* ── Hook: 抽屉管理 ── */ + const drawers = useEditorDrawers() - /** - * 并行加载模板列表、分类、素材库 - * 首次挂载时调用,三个接口无依赖关系,用 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) - } - }, []) + /* ── Hook: 播放控制 ── */ + const playback = usePlaybackControl(totalDuration) - useEffect(() => { - loadTemplates() - }, [loadTemplates]) + /* ── Hook: 片段操作 ── */ + const clipOps = useClipOperations({ clips, setClips }) - /** - * 加载模板详情并初始化片段列表 - * 将后端 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) + /* ── Hook: 模板管理 ── */ + const tpl = useTemplateManagement({ + urlTemplateId, + urlPlanId, + resetClips, + setClips, + setSelectedClipId: clipOps.setSelectedClipId, + setMediaAssets, + setTitleConfig, + setSubtitleSettings, + setBgmSettings, + setCoverConfig, + clips, + totalDuration, + titleConfig, + subtitleSettings, + bgmSettings, + watermarkSettings, + introOutroSettings, + pipSettings, + filterSettings, + chromaKeySettings, + stickerSettings, + coverConfig, + }) - setTitleConfig({ - ai_auto_select: tpl.title_config.ai_auto_select, - content: tpl.title_config.content, - position: tpl.title_config.position, - font_preset: tpl.title_config.font_preset, - font_size: tpl.title_config.font_size, - font_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 - - // 并行加载计划基本信息 + 片段列表 - Promise.all([ - getEditPlan(loadedPlanId), - getEditPlanClips(loadedPlanId, { limit: 500 }).catch(() => ({ - items: [], - total: 0, - })), - ]) - .then(([plan, clipsRes]) => { - // 设置关联的模板(触发模板加载 effect) - setLoadedTemplateId(plan.template_id) - - // 还原基本信息 - setDraftName(plan.name) - - // 还原 config 中的编辑器状态 - const cfg = plan.config - if (cfg.title_config) { - setTitleConfig({ - ai_auto_select: cfg.title_config!.ai_auto_select, - content: cfg.title_config!.content, - position: cfg.title_config!.position, - font_preset: cfg.title_config!.font_preset, - font_size: cfg.title_config!.font_size, - font_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 || "", - })) - } - // 还原封面配置 - if (cfg.cover_config) { - setCoverConfig((prev) => ({ - ...prev, - enabled: cfg.cover_config!.enabled ?? prev.enabled, - mode: (cfg.cover_config!.mode as CoverConfig["mode"]) || prev.mode, - frame_time: cfg.cover_config!.frame_time ?? prev.frame_time, - upload_url: cfg.cover_config!.upload_url || prev.upload_url, - thumbnail_url: cfg.cover_config!.thumbnail_url || prev.thumbnail_url, - ai_suggested_time: cfg.cover_config!.ai_suggested_time ?? prev.ai_suggested_time, - })) - } - - // 还原片段:优先从后端 clips 表,其次从 config.segments 兜底 - const backendClips = clipsRes?.items || [] - if (backendClips.length > 0) { - // 从后端 clips 表还原 - const sorted = [...backendClips].sort((a, b) => a.order - b.order) - const mapped: ClipData[] = sorted.map((clip) => ({ - id: clip.id, - template_segment_id: (clip.config?.template_segment_id as string) || "", - type: (clip.clip_type === "voiceover" ? "voice" : "pip") as ClipType, - duration: clip.duration || 3, - startOffset: 0, - script_text: clip.text_content || "", - order: clip.order, - media_asset_id: clip.asset_id || undefined, - transition: - clip.transition_effect && clip.transition_effect !== "none" - ? { - type: clip.transition_effect as TransitionEffect["type"], - duration: clip.transition_duration || 0.3, - } - : undefined, - speed: clip.playback_speed - ? { rate: clip.playback_speed, pitchCorrection: true } - : undefined, - tts_config: (clip.config?.tts_config as TtsConfig) || undefined, - trim_config: (clip.config?.trim_config as TrimConfig) || undefined, - })) - setTimeout(() => resetClips(mapped), 100) - } else if (cfg.segments && cfg.segments.length > 0) { - // 兜底:从 config.segments 还原(老数据兼容) - 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 = getCurrentTemplate(templates, loadedTemplateId) - const totalDuration = calculateTotalDuration(clips) - const selectedClip = getSelectedClip(clips, selectedClipId) - - /* 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 = getFilteredTemplates(templates, currentFilter, searchQuery) - - /* ──────────── 事件 ──────────── */ - - 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) => { - Modal.confirm({ - title: "删除片段", - content: "确定要删除这个片段吗?此操作可通过撤销恢复。", - okText: "删除", - okType: "danger", - cancelText: "取消", - onOk: () => { - setClips((prev) => prev.filter((c) => c.id !== clipId)) - if (selectedClipId === clipId) setSelectedClipId(null) - }, - }) - } - - const handleClipUpdate = useCallback( - (clipId: string, data: Partial) => { - 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) => { + /* ── 配置变更 handlers ── */ + const handleWatermarkChange = (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 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: titleConfig, - 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: { ...coverConfig }, - } - if (loadedTemplateId) { - await updateEditingTemplate(loadedTemplateId, payload) - } else { - await createEditingTemplate(payload) - } - message.success(loadedTemplateId ? "模板保存成功" : "模板创建成功") - setSaveModalOpen(false) - loadTemplates() - } catch { - message.error("保存失败") - } finally { - setSaveLoading(false) - } + const handleIntroOutroChange = (config: IntroOutroConfig) => { + setIntroOutroSettings(config) + } + + const handlePipChange = (config: PipConfig) => { + setPipSettings(config) + } + + const handleFilterChange = (config: FilterConfig) => { + setFilterSettings(config) + } + + const handleChromaKeyChange = (config: ChromaKeyConfig) => { + setChromaKeySettings(config) + } + + const handleStickerChange = (config: StickerConfig) => { + setStickerSettings(config) } /* ──────────── 渲染 ──────────── */ @@ -800,30 +211,34 @@ const EditingPlanner: React.FC = () => {
{/* ═══ 第1行:顶栏 42px ═══ */} {/* ═══ 第2行:模式栏 56px ═══ */} - + {/* ═══ 第3行:三栏主体 ═══ */}
{/* 左栏 220px:模板列表 */} { {/* 上半部:视频预览 + 封面预览 */} { size: subtitleSettings.fontSize, animation: subtitleSettings.animation, }} - onClipSelect={handleClipSelect} - onPlayPause={() => setIsPlaying(!isPlaying)} + onClipSelect={clipOps.handleClipSelect} + onPlayPause={() => playback.setIsPlaying(!playback.isPlaying)} /> {/* 下半部:水平时间线 */}
@@ -873,45 +288,45 @@ const EditingPlanner: React.FC = () => { setSubtitleSettings((prev) => ({ ...prev, ...partial }) as SubtitleStyleConfig) } onBgmSettingsChange={(partial) => setBgmSettings((prev) => ({ ...prev, ...partial }))} - onClipUpdate={handleClipUpdate} - onOpenBgmDrawer={() => setBgmDrawerOpen(true)} - onOpenSubtitleDrawer={() => setSubtitleDrawerOpen(true)} + onClipUpdate={clipOps.handleClipUpdate} + onOpenBgmDrawer={() => drawers.setBgmDrawerOpen(true)} + onOpenSubtitleDrawer={() => drawers.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)} + onClipVoiceSelect={clipOps.handleClipVoiceSelect} + onOpenTransitionDrawer={drawers.openTransitionDrawer} + onOpenSpeedDrawer={drawers.openSpeedDrawer} + onOpenTtsDrawer={drawers.openTtsDrawer} + onOpenWatermarkDrawer={() => drawers.setWatermarkDrawerOpen(true)} + onOpenIntroOutroDrawer={() => drawers.setIntroOutroDrawerOpen(true)} + onOpenPipDrawer={() => drawers.setPipDrawerOpen(true)} + onOpenFilterDrawer={() => drawers.setFilterDrawerOpen(true)} + onOpenGreenScreenDrawer={() => drawers.setChromaKeyDrawerOpen(true)} + onOpenStickerDrawer={() => drawers.setStickerDrawerOpen(true)} clips={clips} - selectedClipId={selectedClipId} - onClipSelect={handleClipSelect} + selectedClipId={clipOps.selectedClipId} + onClipSelect={clipOps.handleClipSelect} onClipMoveUp={(clipId) => { const idx = clips.findIndex((c) => c.id === clipId) - if (idx > 0) handleClipReorder(idx, idx - 1) + if (idx > 0) clipOps.handleClipReorder(idx, idx - 1) }} onClipMoveDown={(clipId) => { const idx = clips.findIndex((c) => c.id === clipId) - if (idx < clips.length - 1) handleClipReorder(idx, idx + 1) + if (idx < clips.length - 1) clipOps.handleClipReorder(idx, idx + 1) }} - onClipRemove={handleClipRemove} - onClipAdd={() => handleAddClip("pip", 3)} + onClipRemove={clipOps.handleClipRemove} + onClipAdd={() => clipOps.handleAddClip("pip", 3)} />
@@ -919,76 +334,78 @@ const EditingPlanner: React.FC = () => { {/* ═══ 弹窗 ═══ */} setSaveModalOpen(false)} + onNameChange={tpl.setDraftName} + onCategoryChange={tpl.setDraftCategory} + onTagsChange={tpl.setDraftTags} + onSave={tpl.handleSave} + onCancel={() => tpl.setSaveModalOpen(false)} /> {/* ═══ Drawer 集合 ═══ */} setBgmDrawerOpen(false)} - subtitleDrawerOpen={subtitleDrawerOpen} + onCloseBgmDrawer={() => drawers.setBgmDrawerOpen(false)} + subtitleDrawerOpen={drawers.subtitleDrawerOpen} subtitleSettings={subtitleSettings} onSubtitleSettingsChange={setSubtitleSettings} - onCloseSubtitleDrawer={() => setSubtitleDrawerOpen(false)} - transitionDrawerOpen={transitionDrawerOpen} - transitionTargetClipId={transitionTargetClipId} + onCloseSubtitleDrawer={() => drawers.setSubtitleDrawerOpen(false)} + transitionDrawerOpen={drawers.transitionDrawerOpen} + transitionTargetClipId={drawers.transitionTargetClipId} clips={clips} - onTransitionChange={handleTransitionChange} - onCloseTransitionDrawer={() => setTransitionDrawerOpen(false)} - speedDrawerOpen={speedDrawerOpen} - speedTargetClipId={speedTargetClipId} - onSpeedChange={handleSpeedChange} - onApplySpeedAll={handleApplySpeedAll} - onCloseSpeedDrawer={() => setSpeedDrawerOpen(false)} - ttsDrawerOpen={ttsDrawerOpen} - ttsTargetClipId={ttsTargetClipId} - onTtsChange={handleTtsChange} - onCloseTtsDrawer={() => setTtsDrawerOpen(false)} - watermarkDrawerOpen={watermarkDrawerOpen} + onTransitionChange={(config) => + clipOps.handleTransitionChange(drawers.transitionTargetClipId, config) + } + onCloseTransitionDrawer={() => drawers.setTransitionDrawerOpen(false)} + speedDrawerOpen={drawers.speedDrawerOpen} + speedTargetClipId={drawers.speedTargetClipId} + onSpeedChange={(config) => clipOps.handleSpeedChange(drawers.speedTargetClipId, config)} + onApplySpeedAll={clipOps.handleApplySpeedAll} + onCloseSpeedDrawer={() => drawers.setSpeedDrawerOpen(false)} + ttsDrawerOpen={drawers.ttsDrawerOpen} + ttsTargetClipId={drawers.ttsTargetClipId} + onTtsChange={(config) => clipOps.handleTtsChange(drawers.ttsTargetClipId, config)} + onCloseTtsDrawer={() => drawers.setTtsDrawerOpen(false)} + watermarkDrawerOpen={drawers.watermarkDrawerOpen} watermarkSettings={watermarkSettings} onWatermarkChange={handleWatermarkChange} - onCloseWatermarkDrawer={() => setWatermarkDrawerOpen(false)} - introOutroDrawerOpen={introOutroDrawerOpen} + onCloseWatermarkDrawer={() => drawers.setWatermarkDrawerOpen(false)} + introOutroDrawerOpen={drawers.introOutroDrawerOpen} introOutroSettings={introOutroSettings} onIntroOutroChange={handleIntroOutroChange} - onCloseIntroOutroDrawer={() => setIntroOutroDrawerOpen(false)} - pipDrawerOpen={pipDrawerOpen} + onCloseIntroOutroDrawer={() => drawers.setIntroOutroDrawerOpen(false)} + pipDrawerOpen={drawers.pipDrawerOpen} pipSettings={pipSettings} totalDuration={totalDuration} onPipChange={handlePipChange} - onClosePipDrawer={() => setPipDrawerOpen(false)} - filterDrawerOpen={filterDrawerOpen} + onClosePipDrawer={() => drawers.setPipDrawerOpen(false)} + filterDrawerOpen={drawers.filterDrawerOpen} filterSettings={filterSettings} onFilterChange={handleFilterChange} - onCloseFilterDrawer={() => setFilterDrawerOpen(false)} - chromaKeyDrawerOpen={chromaKeyDrawerOpen} + onCloseFilterDrawer={() => drawers.setFilterDrawerOpen(false)} + chromaKeyDrawerOpen={drawers.chromaKeyDrawerOpen} chromaKeySettings={chromaKeySettings} onChromaKeyChange={handleChromaKeyChange} - onCloseChromaKeyDrawer={() => setChromaKeyDrawerOpen(false)} - stickerDrawerOpen={stickerDrawerOpen} + onCloseChromaKeyDrawer={() => drawers.setChromaKeyDrawerOpen(false)} + stickerDrawerOpen={drawers.stickerDrawerOpen} stickerSettings={stickerSettings} onStickerChange={handleStickerChange} - onCloseStickerDrawer={() => setStickerDrawerOpen(false)} + onCloseStickerDrawer={() => drawers.setStickerDrawerOpen(false)} /> ) diff --git a/apps/web/src/pages/editing-planner/components/RightPanel.tsx b/apps/web/src/pages/editing-planner/components/RightPanel.tsx old mode 100755 new mode 100644 diff --git a/apps/web/src/pages/editing-planner/components/TopBar.tsx b/apps/web/src/pages/editing-planner/components/TopBar.tsx index e27e48b03..e109166c4 100644 --- a/apps/web/src/pages/editing-planner/components/TopBar.tsx +++ b/apps/web/src/pages/editing-planner/components/TopBar.tsx @@ -2,7 +2,7 @@ import React from "react" import type { EditingTemplate } from "@/api/editing-planner" interface TopBarProps { - currentTemplate: EditingTemplate | undefined + currentTemplate: EditingTemplate | null canUndo: boolean canRedo: boolean onUndo: () => void diff --git a/apps/web/src/pages/editing-planner/hooks/useClipOperations.ts b/apps/web/src/pages/editing-planner/hooks/useClipOperations.ts new file mode 100644 index 000000000..ad1dade67 --- /dev/null +++ b/apps/web/src/pages/editing-planner/hooks/useClipOperations.ts @@ -0,0 +1,238 @@ +import { useState, useCallback, useMemo } from "react" +import { Modal, message } from "antd" +import type { + ClipData, + ClipType, + TransitionConfig, + SpeedConfig, + TtsConfig, + TrimConfig, +} from "../types" +import type { AssetItem } from "@/api/assets" + +interface UseClipOperationsParams { + clips: ClipData[] + setClips: (updater: (prev: ClipData[]) => ClipData[]) => void +} + +/** + * 片段操作 Hook + * 片段增删改查、排序、裁剪、分割、转场、调速、TTS、配音选择 + */ +export const useClipOperations = ({ clips, setClips }: UseClipOperationsParams) => { + const [selectedClipId, setSelectedClipId] = useState(null) + + const selectedClip = useMemo( + () => clips.find((c) => c.id === selectedClipId) || null, + [clips, selectedClipId], + ) + + /* ── 选中 / 重排 / 删除 ── */ + + const handleClipSelect = useCallback((clipId: string) => { + setSelectedClipId(clipId) + }, []) + + const handleClipReorder = useCallback( + (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 })) + }) + }, + [setClips], + ) + + const handleClipRemove = useCallback( + (clipId: string) => { + Modal.confirm({ + title: "删除片段", + content: "确定要删除这个片段吗?此操作可通过撤销恢复。", + okText: "删除", + okType: "danger", + cancelText: "取消", + onOk: () => { + setClips((prev) => prev.filter((c) => c.id !== clipId)) + if (selectedClipId === clipId) setSelectedClipId(null) + }, + }) + }, + [setClips, selectedClipId], + ) + + const handleClipUpdate = useCallback( + (clipId: string, data: Partial) => { + 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], + ) + + /* ── 裁剪 / 分割 / 重置 ── */ + + 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], + ) + + /* ── 转场 / 调速 / TTS ── */ + + const handleTransitionChange = useCallback( + (targetClipId: string | null, config: TransitionConfig) => { + if (targetClipId) { + handleClipUpdate(targetClipId, { transition: config }) + } + // 同时更新全局默认转场(供新片段使用)—— 暂未实现全局默认 + }, + [handleClipUpdate], + ) + + const handleSpeedChange = useCallback( + (targetClipId: string | null, config: SpeedConfig) => { + if (targetClipId) { + handleClipUpdate(targetClipId, { speed: config }) + } + }, + [handleClipUpdate], + ) + + const handleApplySpeedAll = useCallback( + (config: SpeedConfig) => { + setClips((prev) => prev.map((c) => ({ ...c, speed: { ...config } }))) + message.success("已应用到所有片段") + }, + [setClips], + ) + + const handleTtsChange = useCallback( + (targetClipId: string | null, ttsConfig: TtsConfig) => { + if (!targetClipId) return + handleClipUpdate(targetClipId, { tts_config: ttsConfig }) + }, + [handleClipUpdate], + ) + + /** 为片段选择配音素材 */ + 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], + ) + + return { + selectedClipId, + setSelectedClipId, + selectedClip, + handleClipSelect, + handleClipReorder, + handleClipRemove, + handleClipUpdate, + handleAddClip, + handleClipTrim, + handleClipSplit, + handleClipResetTrim, + handleTransitionChange, + handleSpeedChange, + handleApplySpeedAll, + handleTtsChange, + handleClipVoiceSelect, + } +} diff --git a/apps/web/src/pages/editing-planner/hooks/useEditorDrawers.ts b/apps/web/src/pages/editing-planner/hooks/useEditorDrawers.ts new file mode 100644 index 000000000..310ae7010 --- /dev/null +++ b/apps/web/src/pages/editing-planner/hooks/useEditorDrawers.ts @@ -0,0 +1,78 @@ +import { useState, useCallback } from "react" + +/** + * 编辑器 Drawer 开关管理 + * 集中管理 11 个抽屉的开关状态 + 3 个目标片段 ID + 快捷打开方法 + */ +export const useEditorDrawers = () => { + /* ── 抽屉开关 ── */ + const [bgmDrawerOpen, setBgmDrawerOpen] = useState(false) + const [subtitleDrawerOpen, setSubtitleDrawerOpen] = useState(false) + const [transitionDrawerOpen, setTransitionDrawerOpen] = useState(false) + const [speedDrawerOpen, setSpeedDrawerOpen] = useState(false) + const [ttsDrawerOpen, setTtsDrawerOpen] = useState(false) + const [watermarkDrawerOpen, setWatermarkDrawerOpen] = useState(false) + const [introOutroDrawerOpen, setIntroOutroDrawerOpen] = useState(false) + const [pipDrawerOpen, setPipDrawerOpen] = useState(false) + const [filterDrawerOpen, setFilterDrawerOpen] = useState(false) + const [chromaKeyDrawerOpen, setChromaKeyDrawerOpen] = useState(false) + const [stickerDrawerOpen, setStickerDrawerOpen] = useState(false) + + /* ── 目标片段 ID ── */ + /** 当前正在编辑转场的片段 ID(null = 全局默认转场) */ + const [transitionTargetClipId, setTransitionTargetClipId] = useState(null) + /** 当前正在调速的片段 ID */ + const [speedTargetClipId, setSpeedTargetClipId] = useState(null) + /** 当前正在配置 TTS 的片段 ID */ + const [ttsTargetClipId, setTtsTargetClipId] = useState(null) + + /* ── 快捷打开 ── */ + const openTransitionDrawer = useCallback((clipId?: string) => { + setTransitionTargetClipId(clipId ?? null) + setTransitionDrawerOpen(true) + }, []) + + const openSpeedDrawer = useCallback((clipId: string) => { + setSpeedTargetClipId(clipId) + setSpeedDrawerOpen(true) + }, []) + + const openTtsDrawer = useCallback((clipId: string) => { + setTtsTargetClipId(clipId) + setTtsDrawerOpen(true) + }, []) + + return { + // 开关 state + bgmDrawerOpen, + setBgmDrawerOpen, + subtitleDrawerOpen, + setSubtitleDrawerOpen, + transitionDrawerOpen, + setTransitionDrawerOpen, + speedDrawerOpen, + setSpeedDrawerOpen, + ttsDrawerOpen, + setTtsDrawerOpen, + watermarkDrawerOpen, + setWatermarkDrawerOpen, + introOutroDrawerOpen, + setIntroOutroDrawerOpen, + pipDrawerOpen, + setPipDrawerOpen, + filterDrawerOpen, + setFilterDrawerOpen, + chromaKeyDrawerOpen, + setChromaKeyDrawerOpen, + stickerDrawerOpen, + setStickerDrawerOpen, + // 目标 ID + transitionTargetClipId, + speedTargetClipId, + ttsTargetClipId, + // 快捷方法 + openTransitionDrawer, + openSpeedDrawer, + openTtsDrawer, + } +} diff --git a/apps/web/src/pages/editing-planner/hooks/usePlaybackControl.ts b/apps/web/src/pages/editing-planner/hooks/usePlaybackControl.ts new file mode 100644 index 000000000..2f7e4626b --- /dev/null +++ b/apps/web/src/pages/editing-planner/hooks/usePlaybackControl.ts @@ -0,0 +1,56 @@ +import { useState, useCallback, useEffect, useRef } from "react" + +/** + * 播放控制 Hook + * 播放/暂停、rAF 帧推进、时间线缩放、seek + */ +export const usePlaybackControl = (totalDuration: number) => { + const [isPlaying, setIsPlaying] = useState(false) + const [currentTime, setCurrentTime] = useState(0) + const [pixelsPerSecond, setPixelsPerSecond] = useState(40) + const prevFrameTimeRef = useRef(null) + + /** 播放头跳转 */ + const handleSeek = useCallback((time: number) => { + setCurrentTime(Math.max(0, time)) + }, []) + + /** 轨道缩放 */ + const handleZoomChange = useCallback((pps: number) => { + setPixelsPerSecond(pps) + }, []) + + /** 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]) + + return { + isPlaying, + setIsPlaying, + currentTime, + pixelsPerSecond, + handleSeek, + handleZoomChange, + } +} diff --git a/apps/web/src/pages/editing-planner/hooks/useTemplateManagement.ts b/apps/web/src/pages/editing-planner/hooks/useTemplateManagement.ts new file mode 100644 index 000000000..b9db52ddf --- /dev/null +++ b/apps/web/src/pages/editing-planner/hooks/useTemplateManagement.ts @@ -0,0 +1,466 @@ +import { useState, useCallback, useEffect, type Dispatch, type SetStateAction } from "react" +import { FILTER_CATEGORIES } from "../constants" +import { message } from "antd" +import type { + EditingTemplate, + TemplateCategory, + TemplateMode, + SaveTemplatePayload, +} from "@/api/editing-planner" +import { + getEditingTemplates, + getEditingTemplate, + createEditingTemplate, + updateEditingTemplate, + getTemplateCategories, +} from "@/api/editing-planner" +import type { MediaAsset, TitleConfig, TransitionEffect } from "@/api/template-editor" +import { getMediaAssets, getEditPlan, getEditPlanClips } from "@/api/template-editor" +import type { + ClipData, + ClipType, + TtsConfig, + TtsMode, + TrimConfig, + WatermarkConfig, + IntroOutroConfig, + PipConfig, + FilterConfig, + ChromaKeyConfig, + StickerConfig, + CoverConfig, +} from "../types" +import type { SubtitleStyleConfig } from "../types/subtitle" +import type { BgmMixConfig } from "@/api/bgm" + +interface UseTemplateManagementParams { + urlTemplateId: string + urlPlanId: string + resetClips: (clips: ClipData[]) => void + setClips: (updater: (prev: ClipData[]) => ClipData[]) => void + setSelectedClipId: (id: string | null) => void + setMediaAssets: (assets: MediaAsset[]) => void + setTitleConfig: Dispatch> + setSubtitleSettings: Dispatch> + setBgmSettings: Dispatch> + setCoverConfig: Dispatch> + // 保存时需要的配置 + clips: ClipData[] + totalDuration: number + titleConfig: TitleConfig + subtitleSettings: SubtitleStyleConfig + bgmSettings: BgmMixConfig + watermarkSettings: WatermarkConfig + introOutroSettings: IntroOutroConfig + pipSettings: PipConfig + filterSettings: FilterConfig + chromaKeySettings: ChromaKeyConfig + stickerSettings: StickerConfig + coverConfig: CoverConfig +} + +/** + * 模板管理 Hook + * 模板列表/分类/加载/保存/模式切换/筛选搜索 + 3 个 useEffect + */ +export const useTemplateManagement = (params: UseTemplateManagementParams) => { + const { + urlTemplateId, + urlPlanId, + resetClips, + setClips, + setSelectedClipId, + setMediaAssets, + setTitleConfig, + setSubtitleSettings, + setBgmSettings, + setCoverConfig, + clips, + totalDuration, + titleConfig, + subtitleSettings, + bgmSettings, + watermarkSettings, + introOutroSettings, + pipSettings, + filterSettings, + chromaKeySettings, + stickerSettings, + coverConfig, + } = params + + /* ── 模板列表 ── */ + const [templates, setTemplates] = useState([]) + const [categories, setCategories] = useState([]) + const [loadingTemplates, setLoadingTemplates] = useState(false) + const [loadedTemplateId, setLoadedTemplateId] = useState(urlTemplateId || null) + const [currentMode, setCurrentMode] = useState("pip") + + /* ── 左栏筛选 ── */ + const [currentFilter, setCurrentFilter] = useState("全部") + const [searchQuery, setSearchQuery] = useState("") + + /* ── 保存弹窗 ── */ + const [saveModalOpen, setSaveModalOpen] = useState(false) + const [draftName, setDraftName] = useState("") + const [draftCategory, setDraftCategory] = useState("") + const [draftTags, setDraftTags] = useState("") + const [saveLoading, setSaveLoading] = useState(false) + + /* ── 计划 ID(从 URL 传入,不变) ── */ + const [loadedPlanId] = useState(urlPlanId || 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 currentTemplate = templates.find((t) => t.id === loadedTemplateId) || null + + /* ──────────── 加载 ──────────── */ + + /** + * 并行加载模板列表、分类、素材库 + * 首次挂载时调用,三个接口无依赖关系,用 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) + } + }, [setMediaAssets]) + + 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) + + setTitleConfig({ + ai_auto_select: tpl.title_config.ai_auto_select, + content: tpl.title_config.content, + position: tpl.title_config.position, + font_preset: tpl.title_config.font_preset, + font_size: tpl.title_config.font_size, + font_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, setTitleConfig, setSubtitleSettings, setBgmSettings]) + + /** + * 加载已有模板草稿数据到编辑器 + * 从列表页"编辑"按钮进入时,URL 带 planId,需要还原计划配置 + */ + useEffect(() => { + if (!loadedPlanId) return + + // 并行加载计划基本信息 + 片段列表 + Promise.all([ + getEditPlan(loadedPlanId), + getEditPlanClips(loadedPlanId, { limit: 500 }).catch(() => ({ + items: [], + total: 0, + })), + ]) + .then(([plan, clipsRes]) => { + // 设置关联的模板(触发模板加载 effect) + setLoadedTemplateId(plan.template_id) + + // 还原基本信息 + setDraftName(plan.name) + + // 还原 config 中的编辑器状态 + const cfg = plan.config + if (cfg.title_config) { + setTitleConfig({ + ai_auto_select: cfg.title_config!.ai_auto_select, + content: cfg.title_config!.content, + position: cfg.title_config!.position, + font_preset: cfg.title_config!.font_preset, + font_size: cfg.title_config!.font_size, + font_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 || "", + })) + } + // 还原封面配置 + if (cfg.cover_config) { + setCoverConfig((prev) => ({ + ...prev, + enabled: cfg.cover_config!.enabled ?? prev.enabled, + mode: (cfg.cover_config!.mode as CoverConfig["mode"]) || prev.mode, + frame_time: cfg.cover_config!.frame_time ?? prev.frame_time, + upload_url: cfg.cover_config!.upload_url || prev.upload_url, + thumbnail_url: cfg.cover_config!.thumbnail_url || prev.thumbnail_url, + ai_suggested_time: cfg.cover_config!.ai_suggested_time ?? prev.ai_suggested_time, + })) + } + + // 还原片段:优先从后端 clips 表,其次从 config.segments 兜底 + const backendClips = clipsRes?.items || [] + if (backendClips.length > 0) { + // 从后端 clips 表还原 + const sorted = [...backendClips].sort((a, b) => a.order - b.order) + const mapped: ClipData[] = sorted.map((clip) => ({ + id: clip.id, + template_segment_id: (clip.config?.template_segment_id as string) || "", + type: (clip.clip_type === "voiceover" ? "voice" : "pip") as ClipType, + duration: clip.duration || 3, + startOffset: 0, + script_text: clip.text_content || "", + order: clip.order, + media_asset_id: clip.asset_id || undefined, + transition: + clip.transition_effect && clip.transition_effect !== "none" + ? { + type: clip.transition_effect as TransitionEffect["type"], + duration: clip.transition_duration || 0.3, + } + : undefined, + speed: clip.playback_speed + ? { rate: clip.playback_speed, pitchCorrection: true } + : undefined, + tts_config: (clip.config?.tts_config as TtsConfig) || undefined, + trim_config: (clip.config?.trim_config as TrimConfig) || undefined, + })) + setTimeout(() => resetClips(mapped), 100) + } else if (cfg.segments && cfg.segments.length > 0) { + // 兜底:从 config.segments 还原(老数据兼容) + 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, + setTitleConfig, + setSubtitleSettings, + setBgmSettings, + setCoverConfig, + ]) + + /* ──────────── 事件 ──────────── */ + + 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 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: titleConfig, + 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: { ...coverConfig }, + } + if (loadedTemplateId) { + await updateEditingTemplate(loadedTemplateId, payload) + } else { + await createEditingTemplate(payload) + } + message.success(loadedTemplateId ? "模板保存成功" : "模板创建成功") + setSaveModalOpen(false) + loadTemplates() + } catch { + message.error("保存失败") + } finally { + setSaveLoading(false) + } + } + + return { + // state + templates, + categories, + loadingTemplates, + loadedTemplateId, + setLoadedTemplateId, + currentMode, + setCurrentMode, + currentFilter, + setCurrentFilter, + searchQuery, + setSearchQuery, + saveModalOpen, + setSaveModalOpen, + draftName, + setDraftName, + draftCategory, + setDraftCategory, + draftTags, + setDraftTags, + saveLoading, + filteredTemplates, + currentTemplate, + // methods + loadTemplates, + handleLoadTemplate, + handleModeChange, + handleOpenSaveModal, + handleSave, + } +} + +export { FILTER_CATEGORIES } diff --git a/apps/web/src/test/pages/editing-planner/smoke.test.tsx b/apps/web/src/test/pages/editing-planner/smoke.test.tsx old mode 100755 new mode 100644 index f4f1f990b..772e58786 --- a/apps/web/src/test/pages/editing-planner/smoke.test.tsx +++ b/apps/web/src/test/pages/editing-planner/smoke.test.tsx @@ -48,6 +48,10 @@ import "@/pages/editing-planner/types/subtitle" // Hooks import "@/pages/editing-planner/hooks/useUndoRedo" import "@/pages/editing-planner/hooks/useEditPlanClips" +import "@/pages/editing-planner/hooks/useEditorDrawers" +import "@/pages/editing-planner/hooks/usePlaybackControl" +import "@/pages/editing-planner/hooks/useClipOperations" +import "@/pages/editing-planner/hooks/useTemplateManagement" describe("EditingPlanner module smoke test", () => { it("should load all editing-planner modules", () => { -- 2.54.0 From 4105a4df41a5690fafe99b2a249d0fd932118bfe Mon Sep 17 00:00:00 2001 From: SaaS Frontend Date: Sun, 26 Jul 2026 07:41:35 +0800 Subject: [PATCH 2/2] fix(ts): remove unused TemplateMode import (TS6133) --- apps/web/src/pages/editing-planner/EditingPlanner.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/apps/web/src/pages/editing-planner/EditingPlanner.tsx b/apps/web/src/pages/editing-planner/EditingPlanner.tsx index bc202becf..df4b05940 100644 --- a/apps/web/src/pages/editing-planner/EditingPlanner.tsx +++ b/apps/web/src/pages/editing-planner/EditingPlanner.tsx @@ -5,7 +5,6 @@ import React, { useState } from "react" import { useSearchParams } from "react-router-dom" import { useQuery } from "@tanstack/react-query" -import type { TemplateMode } from "@/api/editing-planner" import { MODE_LABELS } from "@/api/editing-planner" import { MODE_LIST } from "./constants" import type { MediaAsset, TitleConfig } from "@/api/template-editor" -- 2.54.0