From f9d245bb5e8ab67e21cf54cb616db31dec64b918 Mon Sep 17 00:00:00 2001 From: xiaoxia Date: Sun, 26 Jul 2026 15:04:31 +0800 Subject: [PATCH 1/2] refactor(editing-planner): Phase 2 - refactor 9 panel components with 3-phase pattern MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ClipPropertiesPanel: 634→174行 (-73%) - 抽 types/clipProperties, constants/clipProperties, utils/clipProperties - 抽 4 个 UI 子组件: SubtitleSettingsSection/BgmSettingsSection/ClipDetailSection/StatsSection - 抽 useVoicePreview Hook PipConfigPanel: 547→91行 (-83%) - 抽 constants/pipConfig - 抽 2 个 UI 子组件: LayerList/LayerConfig - 抽 usePipLayers Hook StickerPanel: 516→93行 (-82%) - 抽 constants/sticker - 抽 3 个 UI 子组件: StickerLibrary/StickerList/StickerPropsEditor - 抽 useStickerItems Hook WatermarkPanel: 391→123行 (-69%) - 抽 constants/watermark - 抽 5 个 UI 子组件 - 抽 useWatermarkConfig Hook TtsPanel: 332→177行 (-47%) - 抽 constants/tts - 抽 2 个 UI 子组件: VoiceSelector/TtsSlider - 抽 useTtsPanel Hook IntroOutroPanel: 291→94行 (-68%) - 抽 constants/introOutro - 抽 IntroOutroBlock 通用组件(片头片尾对称复用) FilterPanel: 224→109行 (-51%) - 抽 constants/filter - 抽 2 个 UI 子组件: FilterPresetGrid/FilterManualAdjust SubtitleStylePanel: 223→180行 (-19%) - 抽 constants/subtitleStyle - 抽 SubtitlePreview 组件 smoke.test.tsx: 新增所有子模块 import --- .../components/ClipPropertiesPanel.tsx | 530 ++---------------- .../components/PipConfigPanel.tsx | 526 ++--------------- .../components/StickerPanel.tsx | 487 +--------------- .../clip-properties/BgmSettingsSection.tsx | 41 ++ .../clip-properties/ClipDetailSection.tsx | 266 +++++++++ .../clip-properties/StatsSection.tsx | 39 ++ .../SubtitleSettingsSection.tsx | 113 ++++ .../components/filter/FilterManualAdjust.tsx | 40 ++ .../components/filter/FilterPresetGrid.tsx | 40 ++ .../intro-outro/IntroOutroBlock.tsx | 125 +++++ .../components/pip-config/LayerConfig.tsx | 281 ++++++++++ .../components/pip-config/LayerList.tsx | 65 +++ .../components/sticker/StickerLibrary.tsx | 142 +++++ .../components/sticker/StickerList.tsx | 54 ++ .../components/sticker/StickerPropsEditor.tsx | 200 +++++++ .../subtitle-style/SubtitlePreview.tsx | 37 ++ .../components/tts/TtsSlider.tsx | 62 ++ .../components/tts/VoiceSelector.tsx | 50 ++ .../watermark/ImageWatermarkSection.tsx | 78 +++ .../watermark/ScrollWatermarkSection.tsx | 107 ++++ .../watermark/TextWatermarkSection.tsx | 67 +++ .../watermark/WatermarkCommonSection.tsx | 60 ++ .../watermark/WatermarkTypeTabs.tsx | 30 + .../constants/clipProperties.ts | 34 ++ .../pages/editing-planner/constants/filter.ts | 42 ++ .../editing-planner/constants/introOutro.ts | 10 + .../editing-planner/constants/pipConfig.ts | 55 ++ .../editing-planner/constants/sticker.ts | 64 +++ .../constants/subtitleStyle.ts | 30 + .../pages/editing-planner/constants/tts.ts | 19 + .../editing-planner/constants/watermark.ts | 25 + .../editing-planner/hooks/usePipLayers.ts | 129 +++++ .../editing-planner/hooks/useStickerItems.ts | 83 +++ .../editing-planner/hooks/useTtsPanel.ts | 152 +++++ .../editing-planner/hooks/useVoicePreview.ts | 42 ++ .../hooks/useWatermarkConfig.ts | 131 +++++ .../editing-planner/types/clipProperties.ts | 71 +++ .../editing-planner/utils/clipProperties.ts | 21 + .../test/pages/editing-planner/smoke.test.tsx | 35 ++ 39 files changed, 2940 insertions(+), 1443 deletions(-) create mode 100644 apps/web/src/pages/editing-planner/components/clip-properties/BgmSettingsSection.tsx create mode 100644 apps/web/src/pages/editing-planner/components/clip-properties/ClipDetailSection.tsx create mode 100644 apps/web/src/pages/editing-planner/components/clip-properties/StatsSection.tsx create mode 100644 apps/web/src/pages/editing-planner/components/clip-properties/SubtitleSettingsSection.tsx create mode 100644 apps/web/src/pages/editing-planner/components/filter/FilterManualAdjust.tsx create mode 100644 apps/web/src/pages/editing-planner/components/filter/FilterPresetGrid.tsx create mode 100644 apps/web/src/pages/editing-planner/components/intro-outro/IntroOutroBlock.tsx create mode 100644 apps/web/src/pages/editing-planner/components/pip-config/LayerConfig.tsx create mode 100644 apps/web/src/pages/editing-planner/components/pip-config/LayerList.tsx create mode 100644 apps/web/src/pages/editing-planner/components/sticker/StickerLibrary.tsx create mode 100644 apps/web/src/pages/editing-planner/components/sticker/StickerList.tsx create mode 100644 apps/web/src/pages/editing-planner/components/sticker/StickerPropsEditor.tsx create mode 100644 apps/web/src/pages/editing-planner/components/subtitle-style/SubtitlePreview.tsx create mode 100644 apps/web/src/pages/editing-planner/components/tts/TtsSlider.tsx create mode 100644 apps/web/src/pages/editing-planner/components/tts/VoiceSelector.tsx create mode 100644 apps/web/src/pages/editing-planner/components/watermark/ImageWatermarkSection.tsx create mode 100644 apps/web/src/pages/editing-planner/components/watermark/ScrollWatermarkSection.tsx create mode 100644 apps/web/src/pages/editing-planner/components/watermark/TextWatermarkSection.tsx create mode 100644 apps/web/src/pages/editing-planner/components/watermark/WatermarkCommonSection.tsx create mode 100644 apps/web/src/pages/editing-planner/components/watermark/WatermarkTypeTabs.tsx create mode 100644 apps/web/src/pages/editing-planner/constants/clipProperties.ts create mode 100644 apps/web/src/pages/editing-planner/constants/filter.ts create mode 100644 apps/web/src/pages/editing-planner/constants/introOutro.ts create mode 100644 apps/web/src/pages/editing-planner/constants/pipConfig.ts create mode 100644 apps/web/src/pages/editing-planner/constants/sticker.ts create mode 100644 apps/web/src/pages/editing-planner/constants/subtitleStyle.ts create mode 100644 apps/web/src/pages/editing-planner/constants/tts.ts create mode 100644 apps/web/src/pages/editing-planner/constants/watermark.ts create mode 100644 apps/web/src/pages/editing-planner/hooks/usePipLayers.ts create mode 100644 apps/web/src/pages/editing-planner/hooks/useStickerItems.ts create mode 100644 apps/web/src/pages/editing-planner/hooks/useTtsPanel.ts create mode 100644 apps/web/src/pages/editing-planner/hooks/useVoicePreview.ts create mode 100644 apps/web/src/pages/editing-planner/hooks/useWatermarkConfig.ts create mode 100644 apps/web/src/pages/editing-planner/types/clipProperties.ts create mode 100644 apps/web/src/pages/editing-planner/utils/clipProperties.ts diff --git a/apps/web/src/pages/editing-planner/components/ClipPropertiesPanel.tsx b/apps/web/src/pages/editing-planner/components/ClipPropertiesPanel.tsx index 04f998c3b..ce9674d0f 100644 --- a/apps/web/src/pages/editing-planner/components/ClipPropertiesPanel.tsx +++ b/apps/web/src/pages/editing-planner/components/ClipPropertiesPanel.tsx @@ -2,99 +2,13 @@ * 右栏设置面板 — V8 原型 1:1 还原 * 字幕设置 + BGM设置 + 片段详情 */ -import React, { useRef, useState, useCallback } from "react" -import { useNavigate } from "react-router-dom" -import type { TemplateMode } from "@/api/editing-planner" -import type { ClipData, ClipType } from "../types" -import { TRANSITION_OPTIONS } from "@/api/template-editor" -import type { AssetItem } from "@/api/assets" - -interface SubtitleSettings { - enabled: boolean - position: string - font: string - fontSize: number - fontColor: string - animation: string - mode?: string - stroke?: boolean - shadow?: boolean - asrLanguage?: string -} - -interface BgmSettings { - enabled: boolean - music_id: string - volume?: number - fade_in?: number - fade_out?: number - voice_dodge?: boolean -} - -interface ClipPropertiesPanelProps { - selectedClip: ClipData | null - subtitleSettings: SubtitleSettings - bgmSettings: BgmSettings - clipsCount: number - totalDuration: number - currentMode: TemplateMode - onSubtitleSettingsChange: (partial: Partial) => void - onBgmSettingsChange: (partial: Partial) => void - onClipUpdate: (clipId: string, data: Partial) => void - /** 打开 BGM 选择器 Drawer */ - onOpenBgmDrawer?: () => void - /** 打开字幕样式配置 Drawer */ - onOpenSubtitleDrawer?: () => void - /** 配音素材列表(从配音库 API 获取) */ - voiceMaterials?: AssetItem[] - /** 配音素材加载中 */ - voiceMaterialsLoading?: boolean - /** 刷新配音素材列表 */ - onRefreshVoiceMaterials?: () => void - /** 为片段选择配音素材 */ - onClipVoiceSelect?: (clipId: string, asset: AssetItem | null) => void - /** 打开转场特效选择器 Drawer */ - onOpenTransitionDrawer?: (clipId: string) => void - /** 打开片段调速面板 Drawer */ - onOpenSpeedDrawer?: (clipId: string) => void - /** 打开 TTS 配音面板 Drawer */ - onOpenTtsDrawer?: (clipId: string) => void - /** 打开水印设置面板 Drawer */ - onOpenWatermarkDrawer?: () => void - /** 打开片头片尾设置面板 Drawer */ - onOpenIntroOutroDrawer?: () => void - /** 打开混剪设置面板 Drawer */ - onOpenPipDrawer?: () => void - /** 打开滤镜调色面板 Drawer */ - onOpenFilterDrawer?: () => void - /** 打开绿幕抠像面板 Drawer */ - onOpenGreenScreenDrawer?: () => void - /** 打开贴纸面板 Drawer */ - onOpenStickerDrawer?: () => void - /** 打开封面选择器 Drawer */ -} - -const POSITION_OPTIONS = [ - { value: "top", label: "顶部" }, - { value: "center", label: "居中" }, - { value: "bottom", label: "底部" }, -] - -const FONT_OPTIONS = ["思源黑体", "思源宋体", "苹方", "PingFang", "微软雅黑", "楷体", "华康俪金黑"] - -const ANIMATION_OPTIONS = [ - { value: "none", label: "无" }, - { value: "fade", label: "淡入淡出" }, - { value: "slide", label: "滑动" }, - { value: "typewriter", label: "打字机" }, -] - -/** 片段类型图标/标签 */ -const CLIP_TYPE_ICONS: Record = { voice: "🎙️", pip: "🖼️" } -const CLIP_TYPE_LABELS: Record = { - voice: "口播", - pip: "混剪", -} +import React from "react" +import type { ClipPropertiesPanelProps } from "@/pages/editing-planner/types/clipProperties" +import SubtitleSettingsSection from "./clip-properties/SubtitleSettingsSection" +import BgmSettingsSection from "./clip-properties/BgmSettingsSection" +import ClipDetailSection from "./clip-properties/ClipDetailSection" +import StatsSection from "./clip-properties/StatsSection" +import { useVoicePreview } from "@/pages/editing-planner/hooks/useVoicePreview" const ClipPropertiesPanel: React.FC = ({ selectedClip, @@ -122,165 +36,19 @@ const ClipPropertiesPanel: React.FC = ({ onOpenGreenScreenDrawer, onOpenStickerDrawer, }) => { - const navigate = useNavigate() + const { previewingId, handlePreviewVoice, stopPreview } = useVoicePreview() - /* ── 配音试听 ── */ - const audioRef = useRef(null) - const [previewingId, setPreviewingId] = useState(null) - - /** 试听配音素材 */ - const handlePreviewVoice = useCallback( - (asset: AssetItem) => { - // 点击同一个 → 暂停 - if (previewingId === asset.id) { - audioRef.current?.pause() - setPreviewingId(null) - return - } - // 停止上一个 - audioRef.current?.pause() - const url = asset.file_url || (asset.metadata?.preview_url as string) - if (!url) return - const audio = new Audio(url) - audioRef.current = audio - audio.play().catch(() => {}) - audio.onended = () => setPreviewingId(null) - setPreviewingId(asset.id) - }, - [previewingId], - ) - - /** 从 metadata 取性别标签 */ - const getGenderLabel = (m: AssetItem): string => { - const g = (m.metadata?.gender as string) || "" - if (g === "male") return "男" - if (g === "female") return "女" - return "" - } return (
{/* ═══ 字幕设置 ═══ */} -
-
- 💬 - 字幕设置 -
- -
- 启用字幕 -
- onSubtitleSettingsChange({ - enabled: !subtitleSettings.enabled, - }) - } - > -
-
-
- - {subtitleSettings.enabled && ( - <> -
- - -
- -
- - -
- -
- -
- - onSubtitleSettingsChange({ - fontSize: Number(e.target.value), - }) - } - /> - {subtitleSettings.fontSize}px -
-
- -
- - -
- - {/* 高级配置按钮 */} - {onOpenSubtitleDrawer && ( - - )} - - )} -
+ {/* ═══ BGM 设置 ═══ */} -
-
- 🎵 - BGM 设置 -
- - {bgmSettings.enabled && bgmSettings.music_id ? ( -
- 🎵 已选择 BGM - {bgmSettings.music_id} - {bgmSettings.volume !== undefined && ( - 音量 {bgmSettings.volume}% - )} -
- ) : ( -
未选择背景音乐
- )} - - {onOpenBgmDrawer && ( - - )} -
+ {/* ═══ 水印设置 ═══ */}
@@ -374,258 +142,30 @@ const ClipPropertiesPanel: React.FC = ({ {/* ═══ 片段详情(选中时显示) ═══ */} {selectedClip && ( -
-
- 🎞️ - 片段详情 -
- -
- {/* 类型选择器 */} -
-
类型
-
- {(["voice", "pip"] as ClipType[]).map((t) => { - const disabled = - currentMode === "pip" - ? t !== "pip" - : currentMode === "voice_over" - ? t !== "voice" - : false // voice_pip 可切换 - return ( - - ) - })} -
-
- - {/* 时长 */} -
-
时长
-
- - onClipUpdate(selectedClip.id, { - duration: Math.max(1, Math.min(120, Number(e.target.value) || 1)), - }) - } - /> - -
-
- - {/* 转场效果入口 */} - {onOpenTransitionDrawer && ( -
- -
- )} - - {/* 播放速度入口 */} - {onOpenSpeedDrawer && ( -
- -
- )} - - {/* TTS 配音入口 */} - {onOpenTtsDrawer && ( -
- -
- )} - - {/* 素材起始时间 — 仅 voice 类型显示 */} - {selectedClip.type === "voice" && ( -
-
素材起始时间
-
- - onClipUpdate(selectedClip.id, { - startOffset: Math.max(0, Math.min(9999, Number(e.target.value) || 0)), - }) - } - /> - -
-
- )} - - {/* 配音素材选择 — 仅 voice 类型显示 */} - {selectedClip.type === "voice" && ( -
-
- 配音素材 - {onRefreshVoiceMaterials && ( - - )} -
- - {voiceMaterialsLoading && voiceMaterials.length === 0 ? ( -
加载中...
- ) : ( - <> -
- - {/* 试听按钮 */} - {selectedClip.voice_asset_id && ( - - )} -
- {voiceMaterials.length === 0 && ( -
暂无配音素材,请先上传
- )} - - )} - - -
- )} -
-
+ )} - {/* ═══ 统计信息(始终显示) ═══ */} + {/* ═══ 统计信息(未选中时显示) ═══ */} {!selectedClip && ( -
-
- 📊 - 编辑统计 -
-
-
-
片段数
-
{clipsCount}
-
-
-
总时长
-
{totalDuration.toFixed(1)}s
-
-
-
当前模式
-
- {currentMode === "pip" - ? "混剪" - : currentMode === "voice_over" - ? "人物口播" - : currentMode === "one_take" - ? "一镜到底" - : "口播+混剪"} -
-
-
-
+ )}
) diff --git a/apps/web/src/pages/editing-planner/components/PipConfigPanel.tsx b/apps/web/src/pages/editing-planner/components/PipConfigPanel.tsx index 51b24252b..0c9240ac6 100644 --- a/apps/web/src/pages/editing-planner/components/PipConfigPanel.tsx +++ b/apps/web/src/pages/editing-planner/components/PipConfigPanel.tsx @@ -2,65 +2,12 @@ * 混剪配置面板 — Drawer 形式 * 左侧图层列表 + 右侧单图层配置 + 迷你预览区 */ -import React, { useCallback, useMemo } from "react" +import React from "react" import { Drawer, Switch } from "antd" -import type { PipConfig, PipLayer, PipGridPosition, PipAnimType, PipSlideDirection } from "../types" -import { DEFAULT_PIP_LAYER, DEFAULT_PIP_CONFIG } from "../types" - -/* ──────────── 常量 ──────────── */ - -/** 九宫格位置 → 百分比坐标映射 */ -const GRID_POSITION_MAP: Record = { - top_left: { x: 5, y: 5 }, - top_center: { x: 37.5, y: 5 }, - top_right: { x: 70, y: 5 }, - center_left: { x: 5, y: 37.5 }, - center: { x: 37.5, y: 37.5 }, - center_right: { x: 70, y: 37.5 }, - bottom_left: { x: 5, y: 70 }, - bottom_center: { x: 37.5, y: 70 }, - bottom_right: { x: 70, y: 70 }, -} - -/** 九宫格位置选项 */ -const GRID_POSITIONS: PipGridPosition[] = [ - "top_left", - "top_center", - "top_right", - "center_left", - "center", - "center_right", - "bottom_left", - "bottom_center", - "bottom_right", -] - -/** 入场动画选项 */ -const ANIM_OPTIONS: { value: PipAnimType; label: string }[] = [ - { value: "none", label: "无" }, - { value: "fade_in", label: "淡入" }, - { value: "slide_in", label: "滑入" }, -] - -/** 滑入方向选项 */ -const SLIDE_DIR_OPTIONS: { value: PipSlideDirection; label: string }[] = [ - { value: "left", label: "← 左" }, - { value: "right", label: "→ 右" }, - { value: "up", label: "↑ 上" }, - { value: "down", label: "↓ 下" }, -] - -/** 预览图层颜色池 */ -const LAYER_COLORS = [ - "rgba(22,119,255,0.5)", - "rgba(82,196,26,0.5)", - "rgba(250,173,20,0.5)", - "rgba(255,77,79,0.5)", - "rgba(114,46,209,0.5)", - "rgba(19,194,194,0.5)", -] - -/* ──────────── Props ──────────── */ +import type { PipConfig } from "@/pages/editing-planner/types" +import LayerList from "./pip-config/LayerList" +import LayerConfig from "./pip-config/LayerConfig" +import { usePipLayers } from "@/pages/editing-planner/hooks/usePipLayers" interface PipConfigPanelProps { open: boolean @@ -70,13 +17,6 @@ interface PipConfigPanelProps { totalDuration: number } -/* ──────────── 辅助函数 ──────────── */ - -let layerIdCounter = 0 -const genLayerId = () => `pip_layer_${Date.now()}_${++layerIdCounter}` - -/* ──────────── 组件 ──────────── */ - const PipConfigPanel: React.FC = ({ open, onClose, @@ -84,106 +24,19 @@ const PipConfigPanel: React.FC = ({ onChange, totalDuration, }) => { - /** 当前选中图层 ID */ - const [selectedId, setSelectedId] = React.useState("") - - /** 当前选中图层 */ - const selectedLayer = useMemo( - () => config.layers.find((l) => l.id === selectedId) ?? null, - [config.layers, selectedId], - ) - - /* ── 添加图层 ── */ - const handleAddLayer = useCallback(() => { - const newLayer: PipLayer = { - ...DEFAULT_PIP_LAYER, - id: genLayerId(), - name: `图层 ${config.layers.length + 1}`, - z_index: config.layers.length + 1, - } - onChange({ - ...config, - layers: [...config.layers, newLayer], - }) - setSelectedId(newLayer.id) - }, [config, onChange]) - - /* ── 删除图层 ── */ - const handleDeleteLayer = useCallback( - (id: string) => { - const newLayers = config.layers.filter((l) => l.id !== id) - onChange({ ...config, layers: newLayers }) - if (selectedId === id) { - setSelectedId(newLayers.length > 0 ? newLayers[0].id : "") - } - }, - [config, onChange, selectedId], - ) - - /* ── 更新图层 ── */ - const updateLayer = useCallback( - (id: string, partial: Partial) => { - onChange({ - ...config, - layers: config.layers.map((l) => (l.id === id ? { ...l, ...partial } : l)), - }) - }, - [config, onChange], - ) - - /* ── 切换启用 ── */ - const handleEnableToggle = useCallback( - (checked: boolean) => { - onChange({ ...config, enabled: checked }) - }, - [config, onChange], - ) - - /* ── 重置 ── */ - const handleReset = useCallback(() => { - onChange({ ...DEFAULT_PIP_CONFIG }) - setSelectedId("") - }, [onChange]) - - /* ── 九宫格点击 ── */ - const handleGridClick = useCallback( - (pos: PipGridPosition) => { - if (!selectedLayer) return - const coords = GRID_POSITION_MAP[pos] - updateLayer(selectedLayer.id, { - grid_position: pos, - x: coords.x, - y: coords.y, - }) - }, - [selectedLayer, updateLayer], - ) - - /* ── 宽高比锁定 ── */ - const handleWidthChange = useCallback( - (val: number) => { - if (!selectedLayer) return - const partial: Partial = { width: val } - if (selectedLayer.aspect_lock) { - // 保持宽高比 1:1(百分比相同) - partial.height = val - } - updateLayer(selectedLayer.id, partial) - }, - [selectedLayer, updateLayer], - ) - - const handleHeightChange = useCallback( - (val: number) => { - if (!selectedLayer) return - const partial: Partial = { height: val } - if (selectedLayer.aspect_lock) { - partial.width = val - } - updateLayer(selectedLayer.id, partial) - }, - [selectedLayer, updateLayer], - ) + const { + selectedId, + setSelectedId, + selectedLayer, + handleAddLayer, + handleDeleteLayer, + updateLayer, + handleEnableToggle, + handleReset, + handleGridClick, + handleWidthChange, + handleHeightChange, + } = usePipLayers({ config, onChange }) return ( = ({ {/* ═══ 顶部工具栏 ═══ */}
- + 共 {config.layers.length} 个图层
启用 @@ -209,329 +60,22 @@ const PipConfigPanel: React.FC = ({ {/* ═══ 主体:图层列表 + 配置区 ═══ */}
- {/* 左侧图层列表 */} -
- {config.layers.length === 0 ? ( -
暂无图层,点击上方添加
- ) : ( - config.layers.map((layer, idx) => ( -
setSelectedId(layer.id)} - > - {layer.thumbnail_url || layer.material_url ? ( - {layer.name} - ) : ( -
- )} - {layer.name} - -
- )) - )} -
- - {/* 右侧配置区 */} -
- {!selectedLayer ? ( -
选择或添加图层以配置
- ) : ( - <> - {/* ── 迷你预览 ── */} -
- {config.layers.map((layer, idx) => ( -
- {layer.name} -
- ))} -
- - {/* ── 素材类型 ── */} -
- -
- - -
-
- - {/* ── 素材 URL ── */} -
- - - updateLayer(selectedLayer.id, { - material_url: e.target.value, - }) - } - /> -
- - {/* ── 位置:九宫格 + 坐标 ── */} -
- -
-
- {GRID_POSITIONS.map((pos) => ( - - ))} -
-
-
- - - updateLayer(selectedLayer.id, { - x: Number(e.target.value), - }) - } - /> -
-
- - - updateLayer(selectedLayer.id, { - y: Number(e.target.value), - }) - } - /> -
-
-
-
- - {/* ── 尺寸 ── */} -
- -
- - handleWidthChange(Number(e.target.value))} - /> - {selectedLayer.width}% -
-
- - handleHeightChange(Number(e.target.value))} - /> - {selectedLayer.height}% -
-
- updateLayer(selectedLayer.id, { - aspect_lock: !selectedLayer.aspect_lock, - }) - } - > - {selectedLayer.aspect_lock ? "🔒" : "🔓"} - {selectedLayer.aspect_lock ? "已锁定比例" : "锁定宽高比"} -
-
- - {/* ── 圆角 ── */} -
- -
- - updateLayer(selectedLayer.id, { - border_radius: Number(e.target.value), - }) - } - /> - {selectedLayer.border_radius}% -
-
- - {/* ── 透明度 ── */} -
- -
- - updateLayer(selectedLayer.id, { - opacity: Number(e.target.value), - }) - } - /> - {selectedLayer.opacity}% -
-
- - {/* ── 时间 ── */} -
- -
-
- - - updateLayer(selectedLayer.id, { - start_time: Number(e.target.value), - }) - } - /> -
-
- - - updateLayer(selectedLayer.id, { - duration: Number(e.target.value), - }) - } - /> -
-
-
- - {/* ── 入场动画 ── */} -
- - -
- - {/* 滑入方向(仅 slide_in 时显示) */} - {selectedLayer.animation === "slide_in" && ( -
- - -
- )} - - )} -
+ +
{/* ── 底部操作 ── */} diff --git a/apps/web/src/pages/editing-planner/components/StickerPanel.tsx b/apps/web/src/pages/editing-planner/components/StickerPanel.tsx index 12074bd92..a92532867 100644 --- a/apps/web/src/pages/editing-planner/components/StickerPanel.tsx +++ b/apps/web/src/pages/editing-planner/components/StickerPanel.tsx @@ -2,10 +2,13 @@ * 贴纸配置面板 * 贴纸素材库(emoji / 图片)+ 文字花字 + 位置大小调整 */ -import React, { useCallback, useState } from "react" +import React from "react" import { Drawer, Switch } from "antd" -import type { StickerConfig, StickerItem, StickerType, TextStickerPreset } from "../types" -import { DEFAULT_STICKER_CONFIG, DEFAULT_STICKER_ITEM, TEXT_STICKER_PRESET_LABELS } from "../types" +import type { StickerConfig } from "@/pages/editing-planner/types" +import StickerLibrary from "./sticker/StickerLibrary" +import StickerList from "./sticker/StickerList" +import StickerPropsEditor from "./sticker/StickerPropsEditor" +import { useStickerItems } from "@/pages/editing-planner/hooks/useStickerItems" interface StickerPanelProps { open: boolean @@ -15,59 +18,6 @@ interface StickerPanelProps { totalDuration: number } -/** 常用 emoji 素材 */ -const EMOJI_LIST = [ - "😀", - "😂", - "🥰", - "😎", - "🤩", - "😱", - "🤔", - "😴", - "🥳", - "😍", - "❤️", - "🔥", - "⭐", - "✨", - "💯", - "👍", - "👏", - "🎉", - "🎵", - "💪", - "📌", - "💡", - "🎯", - "✅", - "❌", - "⬆️", - "⬇️", - "➡️", - "⭕", - "🔔", -] - -/** 文字花字预设对应的 CSS 样式预览 */ -const TEXT_PRESET_STYLES: Record = { - normal: { color: "#fff", textShadow: "none" }, - highlight: { color: "#FFD700", textShadow: "0 0 8px rgba(255,215,0,0.6)" }, - bubble: { color: "#fff", background: "rgba(0,0,0,0.5)", borderRadius: 8 }, - neon: { color: "#0ff", textShadow: "0 0 6px #0ff, 0 0 12px #0ff" }, - shadow: { color: "#fff", textShadow: "2px 2px 4px rgba(0,0,0,0.8)" }, - outline: { color: "#fff", WebkitTextStroke: "1px #000" }, - gradient: { - color: "transparent", - background: "linear-gradient(90deg,#f093fb,#f5576c)", - WebkitBackgroundClip: "text", - }, - handwrite: { color: "#333", fontStyle: "italic", fontFamily: "cursive" }, -} - -/** 生成唯一 ID */ -const genId = () => `sticker_${Date.now()}_${Math.random().toString(36).slice(2, 8)}` - const StickerPanel: React.FC = ({ open, onClose, @@ -75,63 +25,17 @@ const StickerPanel: React.FC = ({ onChange, totalDuration, }) => { - const [selectedId, setSelectedId] = useState(null) - const [activeTab, setActiveTab] = useState("emoji") - - const selectedSticker = config.items.find((s) => s.id === selectedId) ?? null - - /** 更新单个贴纸 */ - const updateItem = useCallback( - (id: string, partial: Partial) => { - onChange({ - ...config, - items: config.items.map((s) => (s.id === id ? { ...s, ...partial } : s)), - }) - }, - [config, onChange], - ) - - /** 添加贴纸 */ - const addSticker = useCallback( - (type: StickerType, content: string) => { - const newItem: StickerItem = { - ...DEFAULT_STICKER_ITEM, - id: genId(), - type, - content, - duration: totalDuration > 0 ? totalDuration : 5, - z_index: config.items.length + 1, - } - onChange({ - ...config, - enabled: true, - items: [...config.items, newItem], - }) - setSelectedId(newItem.id) - }, - [config, onChange, totalDuration], - ) - - /** 删除贴纸 */ - const removeSticker = useCallback( - (id: string) => { - onChange({ - ...config, - items: config.items.filter((s) => s.id !== id), - }) - if (selectedId === id) setSelectedId(null) - }, - [config, onChange, selectedId], - ) - - /** 重置所有 */ - const handleReset = useCallback(() => { - onChange({ ...DEFAULT_STICKER_CONFIG, enabled: config.enabled }) - setSelectedId(null) - }, [config.enabled, onChange]) - - /** 文字花字输入 */ - const [textInput, setTextInput] = useState("") + const { + selectedId, + setSelectedId, + activeTab, + setActiveTab, + selectedSticker, + updateItem, + addSticker, + removeSticker, + handleReset, + } = useStickerItems({ config, onChange, totalDuration }) return ( = ({ />
- {/* 类型 Tab */} -
- {(["emoji", "image", "text"] as StickerType[]).map((t) => ( - - ))} -
- - {/* Tab 内容区 */} -
- {/* Emoji 素材库 */} - {activeTab === "emoji" && ( -
- {EMOJI_LIST.map((emoji) => ( - - ))} -
- )} - - {/* 图片贴纸 */} - {activeTab === "image" && ( -
- { - if (e.key === "Enter" && e.currentTarget.value.trim()) { - addSticker("image", e.currentTarget.value.trim()) - e.currentTarget.value = "" - } - }} - /> - -
- )} - - {/* 文字花字 */} - {activeTab === "text" && ( -
-
- setTextInput(e.target.value)} - /> - -
-
-
花字预设预览
-
- {(Object.keys(TEXT_STICKER_PRESET_LABELS) as TextStickerPreset[]).map((p) => ( -
- 示例 -
{TEXT_STICKER_PRESET_LABELS[p]}
-
- ))} -
-
-
- )} -
+ {/* 素材库 + 类型Tab */} + {/* 已添加贴纸列表 */} - {config.items.length > 0 && ( -
-
已添加贴纸 ({config.items.length})
-
- {config.items.map((item) => ( -
setSelectedId(item.id)} - > - - {item.type === "emoji" ? item.content : item.type === "text" ? "T" : "🖼"} - - - {item.type === "text" - ? item.content.slice(0, 10) - : item.type === "emoji" - ? "表情贴纸" - : "图片贴纸"} - - -
- ))} -
-
- )} + {/* 选中贴纸的属性编辑 */} {selectedSticker && ( -
-
属性调整
- - {/* 位置 */} -
- 位置 X - updateItem(selectedSticker.id, { x: Number(e.target.value) })} - /> - {selectedSticker.x}% -
-
- 位置 Y - updateItem(selectedSticker.id, { y: Number(e.target.value) })} - /> - {selectedSticker.y}% -
- - {/* 尺寸 */} -
- 大小 - - updateItem(selectedSticker.id, { - width: Number(e.target.value), - height: Number(e.target.value), - }) - } - /> - {selectedSticker.width}% -
- - {/* 旋转 */} -
- 旋转 - - updateItem(selectedSticker.id, { - rotation: Number(e.target.value), - }) - } - /> - {selectedSticker.rotation}° -
- - {/* 透明度 */} -
- 透明度 - - updateItem(selectedSticker.id, { - opacity: Number(e.target.value), - }) - } - /> - {selectedSticker.opacity}% -
- - {/* 时间 */} -
- 开始 - - updateItem(selectedSticker.id, { - start_time: Number(e.target.value), - }) - } - /> - 时长 - - updateItem(selectedSticker.id, { - duration: Number(e.target.value), - }) - } - /> -
- - {/* 文字贴纸特有属性 */} - {selectedSticker.type === "text" && ( - <> -
- 花字 - -
-
- 字号 - - updateItem(selectedSticker.id, { - font_size: Number(e.target.value), - }) - } - /> - {selectedSticker.font_size}px -
-
- 颜色 - - updateItem(selectedSticker.id, { - text_color: e.target.value, - }) - } - /> -
- - )} - - {/* 预览 */} -
-
- {selectedSticker.type === "emoji" && selectedSticker.content} - {selectedSticker.type === "text" && selectedSticker.content} - {selectedSticker.type === "image" && ( - sticker - )} -
-
-
+ )} {/* 底部重置 */} diff --git a/apps/web/src/pages/editing-planner/components/clip-properties/BgmSettingsSection.tsx b/apps/web/src/pages/editing-planner/components/clip-properties/BgmSettingsSection.tsx new file mode 100644 index 000000000..e128e9d25 --- /dev/null +++ b/apps/web/src/pages/editing-planner/components/clip-properties/BgmSettingsSection.tsx @@ -0,0 +1,41 @@ +/** + * BGM 设置区块 + */ +import React from "react" +import type { BgmSettings } from "@/pages/editing-planner/types/clipProperties" + +interface BgmSettingsSectionProps { + settings: BgmSettings + onOpenBgmDrawer?: () => void +} + +const BgmSettingsSection: React.FC = ({ settings, onOpenBgmDrawer }) => { + return ( +
+
+ 🎵 + BGM 设置 +
+ + {settings.enabled && settings.music_id ? ( +
+ 🎵 已选择 BGM + {settings.music_id} + {settings.volume !== undefined && ( + 音量 {settings.volume}% + )} +
+ ) : ( +
未选择背景音乐
+ )} + + {onOpenBgmDrawer && ( + + )} +
+ ) +} + +export default BgmSettingsSection diff --git a/apps/web/src/pages/editing-planner/components/clip-properties/ClipDetailSection.tsx b/apps/web/src/pages/editing-planner/components/clip-properties/ClipDetailSection.tsx new file mode 100644 index 000000000..f2a48dbe9 --- /dev/null +++ b/apps/web/src/pages/editing-planner/components/clip-properties/ClipDetailSection.tsx @@ -0,0 +1,266 @@ +/** + * 片段详情区块 + */ +import React from "react" +import { useNavigate } from "react-router-dom" +import { TRANSITION_OPTIONS } from "@/api/template-editor" +import type { ClipData, ClipType } from "@/pages/editing-planner/types" +import { CLIP_TYPE_ICONS, CLIP_TYPE_LABELS } from "@/pages/editing-planner/constants/clipProperties" +import { getGenderLabel } from "@/pages/editing-planner/utils/clipProperties" +import type { AssetItem } from "@/api/assets" +import type { TemplateMode } from "@/api/editing-planner" + +interface ClipDetailSectionProps { + clip: ClipData + currentMode: TemplateMode + voiceMaterials?: AssetItem[] + voiceMaterialsLoading?: boolean + onClipUpdate: (clipId: string, data: Partial) => void + onRefreshVoiceMaterials?: () => void + onClipVoiceSelect?: (clipId: string, asset: AssetItem | null) => void + onOpenTransitionDrawer?: (clipId: string) => void + onOpenSpeedDrawer?: (clipId: string) => void + onOpenTtsDrawer?: (clipId: string) => void + previewingId: string | null + onPreviewVoice: (asset: AssetItem) => void + onStopPreview: () => void +} + +const ClipDetailSection: React.FC = ({ + clip, + currentMode, + voiceMaterials = [], + voiceMaterialsLoading = false, + onClipUpdate, + onRefreshVoiceMaterials, + onClipVoiceSelect, + onOpenTransitionDrawer, + onOpenSpeedDrawer, + onOpenTtsDrawer, + previewingId, + onPreviewVoice, + onStopPreview, +}) => { + const navigate = useNavigate() + + const isTypeDisabled = (t: ClipType) => { + if (currentMode === "pip") return t !== "pip" + if (currentMode === "voice_over") return t !== "voice" + return false + } + + const transitionLabel = (() => { + const t = clip.transition + if (!t || t.type === "none") return "无转场" + const opt = TRANSITION_OPTIONS.find((o) => o.value === t.type) + return `${opt?.label ?? t.type} · ${t.duration.toFixed(1)}s` + })() + + const speedLabel = clip.speed ? `${clip.speed.rate.toFixed(2)}x` : "1.00x" + + const ttsLabel = (() => { + const tts = clip.tts_config + if (!tts || tts.mode === "none") return "无配音" + if (tts.mode === "upload") return "上传配音" + return `TTS · ${tts.voice_id ? "已选音色" : "未选音色"}` + })() + + return ( +
+
+ 🎞️ + 片段详情 +
+ +
+ {/* 类型选择器 */} +
+
类型
+
+ {(["voice", "pip"] as ClipType[]).map((t) => { + const disabled = isTypeDisabled(t) + return ( + + ) + })} +
+
+ + {/* 时长 */} +
+
时长
+
+ + onClipUpdate(clip.id, { + duration: Math.max(1, Math.min(120, Number(e.target.value) || 1)), + }) + } + /> + +
+
+ + {/* 转场效果入口 */} + {onOpenTransitionDrawer && ( +
+ +
+ )} + + {/* 播放速度入口 */} + {onOpenSpeedDrawer && ( +
+ +
+ )} + + {/* TTS 配音入口 */} + {onOpenTtsDrawer && ( +
+ +
+ )} + + {/* 素材起始时间 — 仅 voice 类型显示 */} + {clip.type === "voice" && ( +
+
素材起始时间
+
+ + onClipUpdate(clip.id, { + startOffset: Math.max(0, Math.min(9999, Number(e.target.value) || 0)), + }) + } + /> + +
+
+ )} + + {/* 配音素材选择 — 仅 voice 类型显示 */} + {clip.type === "voice" && ( +
+
+ 配音素材 + {onRefreshVoiceMaterials && ( + + )} +
+ + {voiceMaterialsLoading && voiceMaterials.length === 0 ? ( +
加载中...
+ ) : ( + <> +
+ + {clip.voice_asset_id && ( + + )} +
+ {voiceMaterials.length === 0 && ( +
暂无配音素材,请先上传
+ )} + + )} + + +
+ )} +
+
+ ) +} + +export default ClipDetailSection diff --git a/apps/web/src/pages/editing-planner/components/clip-properties/StatsSection.tsx b/apps/web/src/pages/editing-planner/components/clip-properties/StatsSection.tsx new file mode 100644 index 000000000..53a0d079d --- /dev/null +++ b/apps/web/src/pages/editing-planner/components/clip-properties/StatsSection.tsx @@ -0,0 +1,39 @@ +/** + * 编辑统计区块 + */ +import React from "react" +import { formatModeLabel } from "@/pages/editing-planner/utils/clipProperties" +import type { TemplateMode } from "@/api/editing-planner" + +interface StatsSectionProps { + clipsCount: number + totalDuration: number + currentMode: TemplateMode +} + +const StatsSection: React.FC = ({ clipsCount, totalDuration, currentMode }) => { + return ( +
+
+ 📊 + 编辑统计 +
+
+
+
片段数
+
{clipsCount}
+
+
+
总时长
+
{totalDuration.toFixed(1)}s
+
+
+
当前模式
+
{formatModeLabel(currentMode)}
+
+
+
+ ) +} + +export default StatsSection diff --git a/apps/web/src/pages/editing-planner/components/clip-properties/SubtitleSettingsSection.tsx b/apps/web/src/pages/editing-planner/components/clip-properties/SubtitleSettingsSection.tsx new file mode 100644 index 000000000..8b5bbc8c7 --- /dev/null +++ b/apps/web/src/pages/editing-planner/components/clip-properties/SubtitleSettingsSection.tsx @@ -0,0 +1,113 @@ +/** + * 字幕设置区块 + */ +import React from "react" +import { + POSITION_OPTIONS, + FONT_OPTIONS, + ANIMATION_OPTIONS, +} from "@/pages/editing-planner/constants/clipProperties" +import type { SubtitleSettings } from "@/pages/editing-planner/types/clipProperties" + +interface SubtitleSettingsSectionProps { + settings: SubtitleSettings + onChange: (partial: Partial) => void + onOpenSubtitleDrawer?: () => void +} + +const SubtitleSettingsSection: React.FC = ({ + settings, + onChange, + onOpenSubtitleDrawer, +}) => { + return ( +
+
+ 💬 + 字幕设置 +
+ +
+ 启用字幕 +
onChange({ enabled: !settings.enabled })} + > +
+
+
+ + {settings.enabled && ( + <> +
+ + +
+ +
+ + +
+ +
+ +
+ onChange({ fontSize: Number(e.target.value) })} + /> + {settings.fontSize}px +
+
+ +
+ + +
+ + {onOpenSubtitleDrawer && ( + + )} + + )} +
+ ) +} + +export default SubtitleSettingsSection diff --git a/apps/web/src/pages/editing-planner/components/filter/FilterManualAdjust.tsx b/apps/web/src/pages/editing-planner/components/filter/FilterManualAdjust.tsx new file mode 100644 index 000000000..835814a71 --- /dev/null +++ b/apps/web/src/pages/editing-planner/components/filter/FilterManualAdjust.tsx @@ -0,0 +1,40 @@ +/** + * 滤镜手动调节组件 + */ +import React from "react" +import type { FilterConfig } from "../../types" +import { MANUAL_ADJUST_ITEMS } from "../../constants/filter" + +type FilterKey = keyof Pick< + FilterConfig, + "brightness" | "contrast" | "saturation" | "temperature" | "tint" | "sharpness" +> + +interface FilterManualAdjustProps { + config: FilterConfig + onChange: (key: FilterKey, value: number) => void +} + +const FilterManualAdjust: React.FC = ({ config, onChange }) => { + return ( +
+
手动调节
+ {MANUAL_ADJUST_ITEMS.map((item) => ( +
+ {item.label} + onChange(item.key as FilterKey, Number(e.target.value))} + /> + {config[item.key as FilterKey]} +
+ ))} +
+ ) +} + +export default FilterManualAdjust diff --git a/apps/web/src/pages/editing-planner/components/filter/FilterPresetGrid.tsx b/apps/web/src/pages/editing-planner/components/filter/FilterPresetGrid.tsx new file mode 100644 index 000000000..364292993 --- /dev/null +++ b/apps/web/src/pages/editing-planner/components/filter/FilterPresetGrid.tsx @@ -0,0 +1,40 @@ +/** + * 滤镜预设选择组件 + */ +import React from "react" +import type { FilterPreset } from "../../types" +import { FILTER_PRESET_LABELS } from "../../types" +import { PRESET_LIST, PRESET_GRADIENTS } from "../../constants/filter" + +interface FilterPresetGridProps { + selectedPreset: FilterPreset + onPresetSelect: (preset: FilterPreset) => void +} + +const FilterPresetGrid: React.FC = ({ + selectedPreset, + onPresetSelect, +}) => { + return ( +
+
预设滤镜
+
+ {PRESET_LIST.map((p) => ( + + ))} +
+
+ ) +} + +export default FilterPresetGrid diff --git a/apps/web/src/pages/editing-planner/components/intro-outro/IntroOutroBlock.tsx b/apps/web/src/pages/editing-planner/components/intro-outro/IntroOutroBlock.tsx new file mode 100644 index 000000000..7a4977580 --- /dev/null +++ b/apps/web/src/pages/editing-planner/components/intro-outro/IntroOutroBlock.tsx @@ -0,0 +1,125 @@ +/** + * 片头/片尾通用区块组件(片头片尾结构对称,复用同一个组件) + */ +import React from "react" +import type { IntroOutroItem, IntroOutroKind, TransitionType } from "../../types" +import { TRANSITION_OPTIONS } from "@/api/template-editor" +import { KIND_OPTIONS } from "../../constants/introOutro" + +interface IntroOutroBlockProps { + title: string + icon: string + item: IntroOutroItem + transitionLabel: string + onKindChange: (kind: IntroOutroKind) => void + onChange: (partial: Partial) => void +} + +const IntroOutroBlock: React.FC = ({ + title, + icon, + item, + transitionLabel, + onKindChange, + onChange, +}) => { + const hasContent = item.kind !== "none" + + return ( +
+
+ {icon} + {title} +
+ + {/* 类型选择 */} +
+ {KIND_OPTIONS.map((opt) => ( + + ))} +
+ + {/* 视频/图片配置 */} + {hasContent && ( + <> +
+ + onChange({ url: e.target.value })} + /> +
+ +
+ +
+ onChange({ duration: Number(e.target.value) })} + /> + {item.duration}s +
+
+ +
+ + +
+ + {item.transition && item.transition !== "none" && ( +
+ +
+ onChange({ transition_duration: Number(e.target.value) })} + /> + + {(item.transition_duration ?? 0.5).toFixed(1)}s + +
+
+ )} + + )} +
+ ) +} + +export default IntroOutroBlock diff --git a/apps/web/src/pages/editing-planner/components/pip-config/LayerConfig.tsx b/apps/web/src/pages/editing-planner/components/pip-config/LayerConfig.tsx new file mode 100644 index 000000000..b90fcb476 --- /dev/null +++ b/apps/web/src/pages/editing-planner/components/pip-config/LayerConfig.tsx @@ -0,0 +1,281 @@ +/** + * 混剪单图层配置区 + */ +import React from "react" +import type { PipLayer, PipAnimType, PipSlideDirection } from "@/pages/editing-planner/types" +import { + GRID_POSITIONS, + ANIM_OPTIONS, + SLIDE_DIR_OPTIONS, + LAYER_COLORS, +} from "@/pages/editing-planner/constants/pipConfig" + +interface LayerConfigProps { + layer: PipLayer | null + layers: PipLayer[] + totalDuration: number + onUpdate: (id: string, partial: Partial) => void + onGridClick: (pos: any) => void + onWidthChange: (val: number) => void + onHeightChange: (val: number) => void +} + +const LayerConfig: React.FC = ({ + layer, + layers, + totalDuration, + onUpdate, + onGridClick, + onWidthChange, + onHeightChange, +}) => { + if (!layer) { + return ( +
+
选择或添加图层以配置
+
+ ) + } + + return ( +
+ {/* ── 迷你预览 ── */} +
+ {layers.map((l, idx) => ( +
+ {l.name} +
+ ))} +
+ + {/* ── 素材类型 ── */} +
+ +
+ + +
+
+ + {/* ── 素材 URL ── */} +
+ + onUpdate(layer.id, { material_url: e.target.value })} + /> +
+ + {/* ── 位置:九宫格 + 坐标 ── */} +
+ +
+
+ {GRID_POSITIONS.map((pos) => ( + + ))} +
+
+
+ + onUpdate(layer.id, { x: Number(e.target.value) })} + /> +
+
+ + onUpdate(layer.id, { y: Number(e.target.value) })} + /> +
+
+
+
+ + {/* ── 尺寸 ── */} +
+ +
+ + onWidthChange(Number(e.target.value))} + /> + {layer.width}% +
+
+ + onHeightChange(Number(e.target.value))} + /> + {layer.height}% +
+
onUpdate(layer.id, { aspect_lock: !layer.aspect_lock })} + > + {layer.aspect_lock ? "🔒" : "🔓"} + {layer.aspect_lock ? "已锁定比例" : "锁定宽高比"} +
+
+ + {/* ── 圆角 ── */} +
+ +
+ onUpdate(layer.id, { border_radius: Number(e.target.value) })} + /> + {layer.border_radius}% +
+
+ + {/* ── 透明度 ── */} +
+ +
+ onUpdate(layer.id, { opacity: Number(e.target.value) })} + /> + {layer.opacity}% +
+
+ + {/* ── 时间 ── */} +
+ +
+
+ + onUpdate(layer.id, { start_time: Number(e.target.value) })} + /> +
+
+ + onUpdate(layer.id, { duration: Number(e.target.value) })} + /> +
+
+
+ + {/* ── 入场动画 ── */} +
+ + +
+ + {/* 滑入方向(仅 slide_in 时显示) */} + {layer.animation === "slide_in" && ( +
+ + +
+ )} +
+ ) +} + +export default LayerConfig diff --git a/apps/web/src/pages/editing-planner/components/pip-config/LayerList.tsx b/apps/web/src/pages/editing-planner/components/pip-config/LayerList.tsx new file mode 100644 index 000000000..3366c8c26 --- /dev/null +++ b/apps/web/src/pages/editing-planner/components/pip-config/LayerList.tsx @@ -0,0 +1,65 @@ +/** + * 混剪图层列表 + */ +import React from "react" +import type { PipLayer } from "@/pages/editing-planner/types" +import { LAYER_COLORS } from "@/pages/editing-planner/constants/pipConfig" + +interface LayerListProps { + layers: PipLayer[] + selectedId: string + onSelect: (id: string) => void + onAdd: () => void + onDelete: (id: string) => void +} + +const LayerList: React.FC = ({ layers, selectedId, onSelect, onAdd, onDelete }) => { + return ( +
+
+ +
+ {layers.length === 0 ? ( +
暂无图层,点击上方添加
+ ) : ( + layers.map((layer, idx) => ( +
onSelect(layer.id)} + > + {layer.thumbnail_url || layer.material_url ? ( + {layer.name} + ) : ( +
+ )} + {layer.name} + +
+ )) + )} +
+ ) +} + +export default LayerList diff --git a/apps/web/src/pages/editing-planner/components/sticker/StickerLibrary.tsx b/apps/web/src/pages/editing-planner/components/sticker/StickerLibrary.tsx new file mode 100644 index 000000000..cfcdb9c47 --- /dev/null +++ b/apps/web/src/pages/editing-planner/components/sticker/StickerLibrary.tsx @@ -0,0 +1,142 @@ +/** + * 贴纸素材库(emoji / 图片 / 文字花字) + */ +import React, { useState } from "react" +import type { StickerType, TextStickerPreset } from "@/pages/editing-planner/types" +import { + EMOJI_LIST, + STICKER_TYPE_TABS, + TEXT_PRESET_STYLES, + TEXT_STICKER_PRESET_LABELS, +} from "@/pages/editing-planner/constants/sticker" + +interface StickerLibraryProps { + activeTab: StickerType + onTabChange: (tab: StickerType) => void + onAddSticker: (type: StickerType, content: string) => void +} + +const StickerLibrary: React.FC = ({ + activeTab, + onTabChange, + onAddSticker, +}) => { + const [textInput, setTextInput] = useState("") + const imageInputRef = React.useRef(null) + + const handleAddImage = () => { + const val = imageInputRef.current?.value.trim() + if (val) { + onAddSticker("image", val) + if (imageInputRef.current) imageInputRef.current.value = "" + } + } + + const handleAddText = () => { + if (textInput.trim()) { + onAddSticker("text", textInput.trim()) + setTextInput("") + } + } + + return ( + <> + {/* 类型 Tab */} +
+ {STICKER_TYPE_TABS.map((t) => ( + + ))} +
+ + {/* Tab 内容区 */} +
+ {/* Emoji 素材库 */} + {activeTab === "emoji" && ( +
+ {EMOJI_LIST.map((emoji) => ( + + ))} +
+ )} + + {/* 图片贴纸 */} + {activeTab === "image" && ( +
+ { + if (e.key === "Enter" && e.currentTarget.value.trim()) { + handleAddImage() + } + }} + /> + +
+ )} + + {/* 文字花字 */} + {activeTab === "text" && ( +
+
+ setTextInput(e.target.value)} + /> + +
+
+
花字预设预览
+
+ {(Object.keys(TEXT_STICKER_PRESET_LABELS) as TextStickerPreset[]).map((p) => ( +
+ 示例 +
{TEXT_STICKER_PRESET_LABELS[p]}
+
+ ))} +
+
+
+ )} +
+ + ) +} + +export default StickerLibrary diff --git a/apps/web/src/pages/editing-planner/components/sticker/StickerList.tsx b/apps/web/src/pages/editing-planner/components/sticker/StickerList.tsx new file mode 100644 index 000000000..b7285b534 --- /dev/null +++ b/apps/web/src/pages/editing-planner/components/sticker/StickerList.tsx @@ -0,0 +1,54 @@ +/** + * 已添加贴纸列表 + */ +import React from "react" +import type { StickerItem } from "@/pages/editing-planner/types" + +interface StickerListProps { + items: StickerItem[] + selectedId: string | null + onSelect: (id: string) => void + onDelete: (id: string) => void +} + +const StickerList: React.FC = ({ items, selectedId, onSelect, onDelete }) => { + if (items.length === 0) return null + + const getDisplayContent = (item: StickerItem) => { + if (item.type === "emoji") return { icon: item.content, name: "表情贴纸" } + if (item.type === "text") return { icon: "T", name: item.content.slice(0, 10) } + return { icon: "🖼", name: "图片贴纸" } + } + + return ( +
+
已添加贴纸 ({items.length})
+
+ {items.map((item) => { + const { icon, name } = getDisplayContent(item) + return ( +
onSelect(item.id)} + > + {icon} + {name} + +
+ ) + })} +
+
+ ) +} + +export default StickerList diff --git a/apps/web/src/pages/editing-planner/components/sticker/StickerPropsEditor.tsx b/apps/web/src/pages/editing-planner/components/sticker/StickerPropsEditor.tsx new file mode 100644 index 000000000..4612d6476 --- /dev/null +++ b/apps/web/src/pages/editing-planner/components/sticker/StickerPropsEditor.tsx @@ -0,0 +1,200 @@ +/** + * 选中贴纸的属性编辑器 + */ +import React from "react" +import type { StickerItem, TextStickerPreset } from "@/pages/editing-planner/types" +import { + TEXT_PRESET_STYLES, + TEXT_STICKER_PRESET_LABELS, +} from "@/pages/editing-planner/constants/sticker" + +interface StickerPropsEditorProps { + sticker: StickerItem + totalDuration: number + onUpdate: (id: string, partial: Partial) => void +} + +const StickerPropsEditor: React.FC = ({ + sticker, + totalDuration, + onUpdate, +}) => { + return ( +
+
属性调整
+ + {/* 位置 */} +
+ 位置 X + onUpdate(sticker.id, { x: Number(e.target.value) })} + /> + {sticker.x}% +
+
+ 位置 Y + onUpdate(sticker.id, { y: Number(e.target.value) })} + /> + {sticker.y}% +
+ + {/* 尺寸 */} +
+ 大小 + + onUpdate(sticker.id, { + width: Number(e.target.value), + height: Number(e.target.value), + }) + } + /> + {sticker.width}% +
+ + {/* 旋转 */} +
+ 旋转 + onUpdate(sticker.id, { rotation: Number(e.target.value) })} + /> + {sticker.rotation}° +
+ + {/* 透明度 */} +
+ 透明度 + onUpdate(sticker.id, { opacity: Number(e.target.value) })} + /> + {sticker.opacity}% +
+ + {/* 时间 */} +
+ 开始 + onUpdate(sticker.id, { start_time: Number(e.target.value) })} + /> + 时长 + onUpdate(sticker.id, { duration: Number(e.target.value) })} + /> +
+ + {/* 文字贴纸特有属性 */} + {sticker.type === "text" && ( + <> +
+ 花字 + +
+
+ 字号 + onUpdate(sticker.id, { font_size: Number(e.target.value) })} + /> + {sticker.font_size}px +
+
+ 颜色 + onUpdate(sticker.id, { text_color: e.target.value })} + /> +
+ + )} + + {/* 预览 */} +
+
+ {sticker.type === "emoji" && sticker.content} + {sticker.type === "text" && sticker.content} + {sticker.type === "image" && ( + sticker + )} +
+
+
+ ) +} + +export default StickerPropsEditor diff --git a/apps/web/src/pages/editing-planner/components/subtitle-style/SubtitlePreview.tsx b/apps/web/src/pages/editing-planner/components/subtitle-style/SubtitlePreview.tsx new file mode 100644 index 000000000..1f76931e9 --- /dev/null +++ b/apps/web/src/pages/editing-planner/components/subtitle-style/SubtitlePreview.tsx @@ -0,0 +1,37 @@ +/** + * 字幕预览组件 + */ +import React from "react" +import type { SubtitleStyleConfig } from "../../types/subtitle" + +interface SubtitlePreviewProps { + config: SubtitleStyleConfig + previewText?: string +} + +const SubtitlePreview: React.FC = ({ + config, + previewText = "这是一段字幕预览", +}) => { + return ( +
+ +
+ + {previewText} + +
+
+ ) +} + +export default SubtitlePreview diff --git a/apps/web/src/pages/editing-planner/components/tts/TtsSlider.tsx b/apps/web/src/pages/editing-planner/components/tts/TtsSlider.tsx new file mode 100644 index 000000000..69d077ae0 --- /dev/null +++ b/apps/web/src/pages/editing-planner/components/tts/TtsSlider.tsx @@ -0,0 +1,62 @@ +/** + * TTS 滑块组件(语速/语调/音量) + */ +import React from "react" +import { Slider } from "antd" + +interface TtsSliderProps { + label: string + value: number + min: number + max: number + step: number + unit?: string + onChange: (val: number) => void + marks?: string[] + tooltipFormatter?: (v: number) => string +} + +const TtsSlider: React.FC = ({ + label, + value, + min, + max, + step, + unit = "", + onChange, + marks, + tooltipFormatter, +}) => { + const displayValue = + unit === "x" + ? `${value.toFixed(2)}x` + : label === "语调" + ? `${value > 0 ? "+" : ""}${value} 半音` + : `${value}${unit}` + + return ( +
+
+ {label} + {displayValue} +
+ onChange(v as number)} + tooltip={tooltipFormatter ? { formatter: (v) => tooltipFormatter(v as number) } : false} + /> + {marks && ( +
+ {marks.map((m, i) => ( + {m} + ))} +
+ )} +
+ ) +} + +export default TtsSlider diff --git a/apps/web/src/pages/editing-planner/components/tts/VoiceSelector.tsx b/apps/web/src/pages/editing-planner/components/tts/VoiceSelector.tsx new file mode 100644 index 000000000..6baa2afaf --- /dev/null +++ b/apps/web/src/pages/editing-planner/components/tts/VoiceSelector.tsx @@ -0,0 +1,50 @@ +/** + * TTS 音色选择组件 + */ +import React from "react" +import type { TTSVoice } from "@/api/tts" +import { VOICE_CATEGORY_MAP } from "../../constants/tts" + +interface VoiceSelectorProps { + voices: TTSVoice[] + voicesLoading: boolean + selectedVoiceId: string + onVoiceSelect: (voiceId: string) => void +} + +const VoiceSelector: React.FC = ({ + voices, + voicesLoading, + selectedVoiceId, + onVoiceSelect, +}) => { + const voiceCategories = Object.entries(VOICE_CATEGORY_MAP) + + return ( +
+
+ 选择音色 + {voicesLoading && 加载中...} +
+
+ {voiceCategories.map(([cat, info]) => { + const voice = voices.find((v) => v.category === cat) + const isSelected = voice && selectedVoiceId === voice.id + return ( + + ) + })} +
+
+ ) +} + +export default VoiceSelector diff --git a/apps/web/src/pages/editing-planner/components/watermark/ImageWatermarkSection.tsx b/apps/web/src/pages/editing-planner/components/watermark/ImageWatermarkSection.tsx new file mode 100644 index 000000000..b01db2b1d --- /dev/null +++ b/apps/web/src/pages/editing-planner/components/watermark/ImageWatermarkSection.tsx @@ -0,0 +1,78 @@ +/** + * 图片水印配置组件 + */ +import React from "react" + +interface ImageWatermarkSectionProps { + imageUrl: string + localImageUrl: string + width: number | undefined + height: number | undefined + onImageUrlChange: (url: string) => void + onWidthChange: (val: number) => void + onHeightChange: (val: number) => void +} + +const ImageWatermarkSection: React.FC = ({ + imageUrl, + localImageUrl, + width, + height, + onImageUrlChange, + onWidthChange, + onHeightChange, +}) => { + const displayUrl = localImageUrl || imageUrl || "" + + return ( +
+
+ + onImageUrlChange(e.target.value)} + /> + {displayUrl && ( +
+ 水印预览 { + ;(e.target as HTMLImageElement).style.display = "none" + }} + /> +
+ )} +
+ +
+ + onWidthChange(Number(e.target.value))} + /> +
+ +
+ + onHeightChange(Number(e.target.value))} + /> +
+
+ ) +} + +export default ImageWatermarkSection diff --git a/apps/web/src/pages/editing-planner/components/watermark/ScrollWatermarkSection.tsx b/apps/web/src/pages/editing-planner/components/watermark/ScrollWatermarkSection.tsx new file mode 100644 index 000000000..4237fa0e5 --- /dev/null +++ b/apps/web/src/pages/editing-planner/components/watermark/ScrollWatermarkSection.tsx @@ -0,0 +1,107 @@ +/** + * 滚动水印配置组件 + */ +import React from "react" +import type { ScrollDirection } from "../../types" +import { SCROLL_DIRECTION_OPTIONS } from "../../constants/watermark" + +interface ScrollWatermarkSectionProps { + text: string | undefined + scrollDirection: ScrollDirection | undefined + scrollSpeed: number | undefined + fontSize: number | undefined + color: string | undefined + onTextChange: (text: string) => void + onScrollDirectionChange: (dir: ScrollDirection) => void + onScrollSpeedChange: (speed: number) => void + onFontSizeChange: (size: number) => void + onColorChange: (color: string) => void +} + +const ScrollWatermarkSection: React.FC = ({ + text, + scrollDirection, + scrollSpeed, + fontSize, + color, + onTextChange, + onScrollDirectionChange, + onScrollSpeedChange, + onFontSizeChange, + onColorChange, +}) => { + return ( +
+
+ +