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/FilterPanel.tsx b/apps/web/src/pages/editing-planner/components/FilterPanel.tsx index d1453bd54..4314137f5 100644 --- a/apps/web/src/pages/editing-planner/components/FilterPanel.tsx +++ b/apps/web/src/pages/editing-planner/components/FilterPanel.tsx @@ -4,8 +4,11 @@ */ import React, { useCallback } from "react" import { Drawer, Switch } from "antd" -import type { FilterConfig, FilterPreset } from "../types" -import { DEFAULT_FILTER_CONFIG, FILTER_PRESET_LABELS } from "../types" +import type { FilterConfig, FilterPreset } from "@/pages/editing-planner/types" +import { DEFAULT_FILTER_CONFIG } from "@/pages/editing-planner/types" +import { PRESET_GRADIENTS } from "@/pages/editing-planner/constants/filter" +import FilterPresetGrid from "./filter/FilterPresetGrid" +import FilterManualAdjust from "./filter/FilterManualAdjust" interface FilterPanelProps { open: boolean @@ -14,34 +17,6 @@ interface FilterPanelProps { onChange: (config: FilterConfig) => void } -/** 所有预设列表 */ -const PRESET_LIST: FilterPreset[] = [ - "none", - "original", - "fresh", - "warm", - "cool", - "vintage", - "cinema", - "bw", - "sunshine", - "film", -] - -/** 预设对应的示例渐变色(用于视觉预览) */ -const PRESET_GRADIENTS: Record = { - none: "linear-gradient(135deg, #667eea 0%, #764ba2 100%)", - original: "linear-gradient(135deg, #667eea 0%, #764ba2 100%)", - fresh: "linear-gradient(135deg, #a8edea 0%, #fed6e3 100%)", - warm: "linear-gradient(135deg, #f093fb 0%, #f5576c 100%)", - cool: "linear-gradient(135deg, #4facfe 0%, #00f2fe 100%)", - vintage: "linear-gradient(135deg, #c79081 0%, #dfa579 100%)", - cinema: "linear-gradient(135deg, #2c3e50 0%, #4ca1af 100%)", - bw: "linear-gradient(135deg, #434343 0%, #000000 100%)", - sunshine: "linear-gradient(135deg, #f6d365 0%, #fda085 100%)", - film: "linear-gradient(135deg, #8e9eab 0%, #eef2f3 100%)", -} - const FilterPanel: React.FC = ({ open, onClose, config, onChange }) => { const update = useCallback( (partial: Partial) => { @@ -54,7 +29,6 @@ const FilterPanel: React.FC = ({ open, onClose, config, onChan onChange({ ...DEFAULT_FILTER_CONFIG, enabled: config.enabled }) }, [config.enabled, onChange]) - /** 选择预设时重置手动参数 */ const handlePresetSelect = useCallback( (preset: FilterPreset) => { if (preset === "none") { @@ -70,6 +44,13 @@ const FilterPanel: React.FC = ({ open, onClose, config, onChan [config.enabled, onChange], ) + const handleManualChange = useCallback( + (key: keyof FilterConfig, value: number) => { + onChange({ ...config, [key]: value }) + }, + [config, onChange], + ) + return ( = ({ open, onClose, config, onChan
{/* 预设滤镜选择 */} -
-
预设滤镜
-
- {PRESET_LIST.map((p) => ( - - ))} -
-
+ {/* 手动调节 */} -
-
手动调节
- - {/* 亮度 */} -
- 亮度 - update({ brightness: Number(e.target.value) })} - /> - {config.brightness} -
- - {/* 对比度 */} -
- 对比度 - update({ contrast: Number(e.target.value) })} - /> - {config.contrast} -
- - {/* 饱和度 */} -
- 饱和度 - update({ saturation: Number(e.target.value) })} - /> - {config.saturation} -
- - {/* 色温 */} -
- 色温 - update({ temperature: Number(e.target.value) })} - /> - {config.temperature} -
- - {/* 色调 */} -
- 色调 - update({ tint: Number(e.target.value) })} - /> - {config.tint} -
- - {/* 锐度 */} -
- 锐度 - update({ sharpness: Number(e.target.value) })} - /> - {config.sharpness} -
-
+ {/* 预览色块 */}
diff --git a/apps/web/src/pages/editing-planner/components/IntroOutroPanel.tsx b/apps/web/src/pages/editing-planner/components/IntroOutroPanel.tsx index 1f9101788..87b67f58c 100644 --- a/apps/web/src/pages/editing-planner/components/IntroOutroPanel.tsx +++ b/apps/web/src/pages/editing-planner/components/IntroOutroPanel.tsx @@ -5,19 +5,14 @@ */ import React, { useCallback } from "react" import { Drawer } from "antd" -import type { IntroOutroConfig, IntroOutroItem, IntroOutroKind, TransitionType } from "../types" -import { DEFAULT_INTRO_OUTRO } from "../types" -import { TRANSITION_OPTIONS } from "@/api/template-editor" +import type { + IntroOutroConfig, + IntroOutroItem, + IntroOutroKind, +} from "@/pages/editing-planner/types" +import { DEFAULT_INTRO_OUTRO } from "@/pages/editing-planner/types" +import IntroOutroBlock from "./intro-outro/IntroOutroBlock" -/* ──────────── 常量 ──────────── */ - -const KIND_OPTIONS: { value: IntroOutroKind; label: string; icon: string }[] = [ - { value: "none", label: "无", icon: "🚫" }, - { value: "video", label: "视频", icon: "🎬" }, - { value: "image", label: "图片", icon: "🖼️" }, -] - -/* ──────────── Props ──────────── */ interface IntroOutroPanelProps { open: boolean onClose: () => void @@ -73,210 +68,24 @@ const IntroOutroPanel: React.FC = ({ open, onClose, config className="intro-outro-panel-drawer" > {/* ═══ 片头区块 ═══ */} -
-
- 🎞️ - 片头 -
- - {/* 类型选择 */} -
- {KIND_OPTIONS.map((opt) => ( - - ))} -
- - {/* 视频/图片配置 */} - {config.intro.kind !== "none" && ( - <> -
- - handleIntroChange({ url: e.target.value })} - /> -
- -
- -
- handleIntroChange({ duration: Number(e.target.value) })} - /> - {config.intro.duration}s -
-
- -
- - -
- - {config.intro.transition && config.intro.transition !== "none" && ( -
- -
- - handleIntroChange({ - transition_duration: Number(e.target.value), - }) - } - /> - - {(config.intro.transition_duration ?? 0.5).toFixed(1)}s - -
-
- )} - - )} -
+ {/* ═══ 片尾区块 ═══ */} -
-
- 🏁 - 片尾 -
- - {/* 类型选择 */} -
- {KIND_OPTIONS.map((opt) => ( - - ))} -
- - {/* 视频/图片配置 */} - {config.outro.kind !== "none" && ( - <> -
- - handleOutroChange({ url: e.target.value })} - /> -
- -
- -
- handleOutroChange({ duration: Number(e.target.value) })} - /> - {config.outro.duration}s -
-
- -
- - -
- - {config.outro.transition && config.outro.transition !== "none" && ( -
- -
- - handleOutroChange({ - transition_duration: Number(e.target.value), - }) - } - /> - - {(config.outro.transition_duration ?? 0.5).toFixed(1)}s - -
-
- )} - - )} -
+ {/* ── 底部操作 ── */}
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/SubtitleStylePanel.tsx b/apps/web/src/pages/editing-planner/components/SubtitleStylePanel.tsx index 2f7913aae..017566089 100644 --- a/apps/web/src/pages/editing-planner/components/SubtitleStylePanel.tsx +++ b/apps/web/src/pages/editing-planner/components/SubtitleStylePanel.tsx @@ -5,31 +5,14 @@ import React from "react" import { Drawer, Slider, ColorPicker, Select } from "antd" import type { Color } from "antd/es/color-picker" -import type { SubtitleStyleConfig } from "../types/subtitle" - -/* ──────────── 选项常量 ──────────── */ - -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 ASR_LANGUAGE_OPTIONS = [ - { value: "zh", label: "中文" }, - { value: "en", label: "English" }, -] - -/* ──────────── Props ──────────── */ +import type { SubtitleStyleConfig } from "@/pages/editing-planner/types/subtitle" +import { + POSITION_OPTIONS, + FONT_OPTIONS, + ANIMATION_OPTIONS, + ASR_LANGUAGE_OPTIONS, +} from "@/pages/editing-planner/constants/subtitleStyle" +import SubtitlePreview from "./subtitle-style/SubtitlePreview" interface SubtitleStylePanelProps { open: boolean @@ -197,23 +180,7 @@ const SubtitleStylePanel: React.FC = ({
{/* ── 预览 ── */} -
- -
- - 这是一段字幕预览 - -
-
+ )} diff --git a/apps/web/src/pages/editing-planner/components/TtsPanel.tsx b/apps/web/src/pages/editing-planner/components/TtsPanel.tsx index 28aa9ce4e..3ba825bf3 100644 --- a/apps/web/src/pages/editing-planner/components/TtsPanel.tsx +++ b/apps/web/src/pages/editing-planner/components/TtsPanel.tsx @@ -2,23 +2,14 @@ * TTS 配音面板 — Drawer 形式 * 配音模式切换 + 文本输入 + 音色选择 + 语速/语调/音量 + 试听 + 字幕联动 */ -import React, { useState, useCallback, useEffect, useRef } from "react" -import { Drawer, Slider, message } from "antd" -import type { TtsConfig, TtsMode } from "../types" -import { DEFAULT_TTS_CONFIG } from "../types" -import { getTtsVoices, previewTts, type TTSVoice } from "@/api/tts" +import React from "react" +import { Drawer } from "antd" +import type { TtsConfig } from "@/pages/editing-planner/types" +import { TTS_MODE_OPTIONS } from "@/pages/editing-planner/constants/tts" +import VoiceSelector from "./tts/VoiceSelector" +import TtsSlider from "./tts/TtsSlider" +import { useTtsPanel } from "@/pages/editing-planner/hooks/useTtsPanel" -/* ──────────── 音色卡片分类图标 ──────────── */ -const VOICE_CATEGORY_MAP: Record = { - male: { icon: "👨", label: "男声" }, - female: { icon: "👩", label: "女声" }, - young: { icon: "🧑", label: "少年" }, - service: { icon: "🎧", label: "客服" }, - news: { icon: "📰", label: "新闻" }, - emotion: { icon: "🎭", label: "情感" }, -} - -/* ──────────── Props ──────────── */ interface TtsPanelProps { open: boolean onClose: () => void @@ -28,126 +19,21 @@ interface TtsPanelProps { } const TtsPanel: React.FC = ({ open, onClose, config, onChange }) => { - /* ── 音色列表 ── */ - const [voices, setVoices] = useState([]) - const [voicesLoading, setVoicesLoading] = useState(false) - - /* ── 试听状态 ── */ - const [previewLoading, setPreviewLoading] = useState(false) - const audioRef = useRef(null) - - /* ── 加载音色列表 ── */ - useEffect(() => { - if (!open) return - setVoicesLoading(true) - getTtsVoices() - .then((v) => setVoices(v)) - .catch(() => message.error("加载音色列表失败")) - .finally(() => setVoicesLoading(false)) - }, [open]) - - /* ── 切换配音模式 ── */ - const handleModeChange = useCallback( - (mode: TtsMode) => { - onChange({ ...config, mode }) - }, - [config, onChange], - ) - - /* ── 文本输入 ── */ - const handleTextChange = useCallback( - (e: React.ChangeEvent) => { - const text = e.target.value.slice(0, 5000) - onChange({ ...config, text }) - }, - [config, onChange], - ) - - /* ── 选择音色 ── */ - const handleVoiceSelect = useCallback( - (voiceId: string) => { - onChange({ ...config, voice_id: voiceId }) - }, - [config, onChange], - ) - - /* ── 语速 ── */ - const handleSpeedChange = useCallback( - (speed: number) => { - onChange({ ...config, speed }) - }, - [config, onChange], - ) - - /* ── 语调 ── */ - const handlePitchChange = useCallback( - (pitch: number) => { - onChange({ ...config, pitch }) - }, - [config, onChange], - ) - - /* ── 音量 ── */ - const handleVolumeChange = useCallback( - (volume: number) => { - onChange({ ...config, volume }) - }, - [config, onChange], - ) - - /* ── 字幕联动 ── */ - const handleSubtitleSyncToggle = useCallback(() => { - onChange({ ...config, subtitle_sync: !config.subtitle_sync }) - }, [config, onChange]) - - /* ── 试听 ── */ - const handlePreview = useCallback(async () => { - if (!config.text.trim()) { - message.warning("请先输入合成文本") - return - } - if (!config.voice_id) { - message.warning("请先选择音色") - return - } - setPreviewLoading(true) - try { - const res = await previewTts({ - text: config.text.slice(0, 200), // 试听截取前200字 - voice_id: config.voice_id, - speed: config.speed, - pitch: config.pitch, - }) - // 停止上一个 - audioRef.current?.pause() - const audio = new Audio(res.audio_url) - audioRef.current = audio - audio.play().catch(() => message.error("播放失败")) - audio.onended = () => { - audioRef.current = null - } - message.success("试听播放中") - } catch { - message.error("试听生成失败") - } finally { - setPreviewLoading(false) - } - }, [config]) - - /* ── 重置 ── */ - const handleReset = useCallback(() => { - onChange({ ...DEFAULT_TTS_CONFIG }) - }, [onChange]) - - /* ── 关闭时停止音频 ── */ - const handleClose = useCallback(() => { - audioRef.current?.pause() - audioRef.current = null - onClose() - }, [onClose]) - - /* ── 音色分类分组 ── */ - const voiceCategories = Object.entries(VOICE_CATEGORY_MAP) + const { + voices, + voicesLoading, + previewLoading, + handleModeChange, + handleTextChange, + handleVoiceSelect, + handleSpeedChange, + handlePitchChange, + handleVolumeChange, + handleSubtitleSyncToggle, + handlePreview, + handleReset, + handleClose, + } = useTtsPanel({ open, config, onChange, onClose }) return ( = ({ open, onClose, config, onChange })
配音模式
- {[ - { mode: "none" as TtsMode, icon: "🔇", label: "无配音" }, - { mode: "upload" as TtsMode, icon: "📁", label: "上传配音" }, - { mode: "tts" as TtsMode, icon: "🤖", label: "TTS 合成" }, - ].map((m) => ( + {TTS_MODE_OPTIONS.map((m) => (
{/* 音色选择 */} -
-
- 选择音色 - {voicesLoading && 加载中...} -
-
- {voiceCategories.map(([cat, info]) => { - const voice = voices.find((v) => v.category === cat) - const isSelected = voice && config.voice_id === voice.id - return ( - - ) - })} -
-
+ {/* 语速滑块 */} -
-
- 语速 - {config.speed.toFixed(2)}x -
- `${(v as number).toFixed(2)}x` }} - /> -
- 0.5x - 1.0x - 2.0x -
-
+ `${v.toFixed(2)}x`} + /> {/* 语调滑块 */} -
-
- 语调 - - {config.pitch > 0 ? "+" : ""} - {config.pitch} 半音 - -
- `${v}半音` }} - /> -
- -12 - 0 - +12 -
-
+ `${v}半音`} + /> {/* 音量滑块 */} -
-
- 音量 - {config.volume}% -
- `${v}%` }} - /> -
+ `${v}%`} + /> {/* 试听按钮 */}
diff --git a/apps/web/src/pages/editing-planner/components/WatermarkPanel.tsx b/apps/web/src/pages/editing-planner/components/WatermarkPanel.tsx index fefa9121d..cef414809 100644 --- a/apps/web/src/pages/editing-planner/components/WatermarkPanel.tsx +++ b/apps/web/src/pages/editing-planner/components/WatermarkPanel.tsx @@ -3,38 +3,16 @@ * 三个 Tab:图片水印 / 文字水印 / 滚动水印 * 通用设置:位置、不透明度 */ -import React, { useCallback, useState } from "react" +import React from "react" import { Drawer } from "antd" -import type { WatermarkConfig, WatermarkType, WatermarkPosition, ScrollDirection } from "../types" -import { DEFAULT_WATERMARK } from "../types" +import type { WatermarkConfig } from "@/pages/editing-planner/types" +import WatermarkTypeTabs from "./watermark/WatermarkTypeTabs" +import ImageWatermarkSection from "./watermark/ImageWatermarkSection" +import TextWatermarkSection from "./watermark/TextWatermarkSection" +import ScrollWatermarkSection from "./watermark/ScrollWatermarkSection" +import WatermarkCommonSection from "./watermark/WatermarkCommonSection" +import { useWatermarkConfig } from "@/pages/editing-planner/hooks/useWatermarkConfig" -/* ──────────── 常量 ──────────── */ - -const WATERMARK_TABS: { key: WatermarkType; label: string; icon: string }[] = [ - { key: "none", label: "无水印", icon: "🚫" }, - { key: "image", label: "图片水印", icon: "🖼️" }, - { key: "text", label: "文字水印", icon: "📝" }, - { key: "scroll", label: "滚动水印", icon: "📜" }, -] - -const POSITION_OPTIONS: { value: WatermarkPosition; label: string }[] = [ - { value: "top_left", label: "左上角" }, - { value: "top_right", label: "右上角" }, - { value: "bottom_left", label: "左下角" }, - { value: "bottom_right", label: "右下角" }, - { value: "center", label: "居中" }, -] - -const SCROLL_DIRECTION_OPTIONS: { - value: ScrollDirection - label: string -}[] = [ - { value: "horizontal", label: "水平滚动" }, - { value: "vertical", label: "垂直滚动" }, - { value: "diagonal", label: "对角滚动" }, -] - -/* ──────────── Props ──────────── */ interface WatermarkPanelProps { open: boolean onClose: () => void @@ -43,100 +21,21 @@ interface WatermarkPanelProps { } const WatermarkPanel: React.FC = ({ open, onClose, config, onChange }) => { - /* ── 图片上传预览 URL(本地预览用) ── */ - const [localImageUrl, setLocalImageUrl] = useState("") - - /* ── 切换水印类型 ── */ - const handleTypeChange = useCallback( - (type: WatermarkType) => { - onChange({ ...DEFAULT_WATERMARK, type }) - }, - [onChange], - ) - - /* ── 通用设置变更 ── */ - const handlePositionChange = useCallback( - (position: WatermarkPosition) => { - onChange({ ...config, position }) - }, - [config, onChange], - ) - - const handleOpacityChange = useCallback( - (opacity: number) => { - onChange({ ...config, opacity }) - }, - [config, onChange], - ) - - /* ── 图片水印设置 ── */ - const handleImageUrlChange = useCallback( - (url: string) => { - setLocalImageUrl(url) - onChange({ ...config, image_url: url }) - }, - [config, onChange], - ) - - const handleImageWidthChange = useCallback( - (width: number) => { - onChange({ ...config, width }) - }, - [config, onChange], - ) - - const handleImageHeightChange = useCallback( - (height: number) => { - onChange({ ...config, height }) - }, - [config, onChange], - ) - - /* ── 文字水印设置 ── */ - const handleTextChange = useCallback( - (text: string) => { - onChange({ ...config, text }) - }, - [config, onChange], - ) - - const handleFontSizeChange = useCallback( - (font_size: number) => { - onChange({ ...config, font_size }) - }, - [config, onChange], - ) - - const handleColorChange = useCallback( - (color: string) => { - onChange({ ...config, color }) - }, - [config, onChange], - ) - - /* ── 滚动水印设置 ── */ - const handleScrollDirectionChange = useCallback( - (scroll_direction: ScrollDirection) => { - onChange({ ...config, scroll_direction }) - }, - [config, onChange], - ) - - const handleScrollSpeedChange = useCallback( - (scroll_speed: number) => { - onChange({ ...config, scroll_speed }) - }, - [config, onChange], - ) - - /* ── 重置 ── */ - const handleReset = useCallback(() => { - setLocalImageUrl("") - onChange({ ...DEFAULT_WATERMARK }) - }, [onChange]) - - /* ── 当前激活的 Tab ── */ - const activeTab = config.type + const { + localImageUrl, + handleTypeChange, + handlePositionChange, + handleOpacityChange, + handleImageUrlChange, + handleImageWidthChange, + handleImageHeightChange, + handleTextChange, + handleFontSizeChange, + handleColorChange, + handleScrollDirectionChange, + handleScrollSpeedChange, + handleReset, + } = useWatermarkConfig({ config, onChange }) return ( = ({ open, onClose, config, className="watermark-panel-drawer" > {/* ── Tab 切换 ── */} -
- {WATERMARK_TABS.map((tab) => ( - - ))} -
+ {/* ── 无水印提示 ── */} {config.type === "none" && ( @@ -172,210 +60,53 @@ const WatermarkPanel: React.FC = ({ open, onClose, config, {/* ── 图片水印配置 ── */} {config.type === "image" && ( -
-
- - handleImageUrlChange(e.target.value)} - /> - {(localImageUrl || config.image_url) && ( -
- 水印预览 { - ;(e.target as HTMLImageElement).style.display = "none" - }} - /> -
- )} -
- -
- - handleImageWidthChange(Number(e.target.value))} - /> -
- -
- - handleImageHeightChange(Number(e.target.value))} - /> -
-
+ )} {/* ── 文字水印配置 ── */} {config.type === "text" && ( -
-
- -