From 2d44978dd95e12e51c7d02b93be948ab250ff4a9 Mon Sep 17 00:00:00 2001 From: xiaoxia Date: Sun, 26 Jul 2026 14:16:06 +0800 Subject: [PATCH] fix(lint): prettier format fixes for TimelinePanel Phase 3 --- .../components/TimelinePanel.tsx | 4 +- .../clip-properties/BgmSettingsSection.tsx | 44 +++ .../clip-properties/ClipDetailSection.tsx | 264 +++++++++++++++ .../clip-properties/StatsSection.tsx | 43 +++ .../SubtitleSettingsSection.tsx | 113 +++++++ .../components/pip-config/LayerConfig.tsx | 306 ++++++++++++++++++ .../components/pip-config/LayerList.tsx | 59 ++++ .../components/sticker/StickerLibrary.tsx | 131 ++++++++ .../components/sticker/StickerList.tsx | 57 ++++ .../components/sticker/StickerPropsEditor.tsx | 194 +++++++++++ .../constants/clipProperties.ts | 39 +++ .../editing-planner/constants/pipConfig.ts | 55 ++++ .../editing-planner/constants/sticker.ts | 61 ++++ .../editing-planner/hooks/usePipLayers.ts | 90 ++++++ .../editing-planner/hooks/useStickerItems.ts | 84 +++++ .../editing-planner/hooks/useTimelineMenus.ts | 12 +- .../editing-planner/hooks/useTrimDrag.ts | 5 +- .../editing-planner/hooks/useVoicePreview.ts | 48 +++ .../editing-planner/types/clipProperties.ts | 71 ++++ .../editing-planner/utils/clipProperties.ts | 21 ++ 20 files changed, 1687 insertions(+), 14 deletions(-) create mode 100755 apps/web/src/pages/editing-planner/components/clip-properties/BgmSettingsSection.tsx create mode 100755 apps/web/src/pages/editing-planner/components/clip-properties/ClipDetailSection.tsx create mode 100755 apps/web/src/pages/editing-planner/components/clip-properties/StatsSection.tsx create mode 100755 apps/web/src/pages/editing-planner/components/clip-properties/SubtitleSettingsSection.tsx create mode 100755 apps/web/src/pages/editing-planner/components/pip-config/LayerConfig.tsx create mode 100755 apps/web/src/pages/editing-planner/components/pip-config/LayerList.tsx create mode 100755 apps/web/src/pages/editing-planner/components/sticker/StickerLibrary.tsx create mode 100755 apps/web/src/pages/editing-planner/components/sticker/StickerList.tsx create mode 100755 apps/web/src/pages/editing-planner/components/sticker/StickerPropsEditor.tsx create mode 100755 apps/web/src/pages/editing-planner/constants/clipProperties.ts create mode 100755 apps/web/src/pages/editing-planner/constants/pipConfig.ts create mode 100755 apps/web/src/pages/editing-planner/constants/sticker.ts create mode 100755 apps/web/src/pages/editing-planner/hooks/usePipLayers.ts create mode 100755 apps/web/src/pages/editing-planner/hooks/useStickerItems.ts create mode 100755 apps/web/src/pages/editing-planner/hooks/useVoicePreview.ts create mode 100755 apps/web/src/pages/editing-planner/types/clipProperties.ts create mode 100755 apps/web/src/pages/editing-planner/utils/clipProperties.ts diff --git a/apps/web/src/pages/editing-planner/components/TimelinePanel.tsx b/apps/web/src/pages/editing-planner/components/TimelinePanel.tsx index f7654e9c5..f9e1f35ec 100644 --- a/apps/web/src/pages/editing-planner/components/TimelinePanel.tsx +++ b/apps/web/src/pages/editing-planner/components/TimelinePanel.tsx @@ -10,9 +10,7 @@ */ import React, { useState, useRef, useCallback, useEffect } from "react" import type { ClipData, ClipType, TrimConfig } from "../types" -import { - DEFAULT_PIXELS_PER_SECOND, -} from "../constants/timeline" +import { DEFAULT_PIXELS_PER_SECOND } from "../constants/timeline" import { formatTime } from "../utils/timeline" import { useClipDrag } from "../hooks/useClipDrag" import { useTrimDrag } from "../hooks/useTrimDrag" 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 100755 index 000000000..b816fbc81 --- /dev/null +++ b/apps/web/src/pages/editing-planner/components/clip-properties/BgmSettingsSection.tsx @@ -0,0 +1,44 @@ +/** + * BGM 设置区块 + */ +import React from "react" +import type { BgmSettings } from "../../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 100755 index 000000000..2beea928a --- /dev/null +++ b/apps/web/src/pages/editing-planner/components/clip-properties/ClipDetailSection.tsx @@ -0,0 +1,264 @@ +/** + * 片段详情区块(选中片段时显示) + */ +import React from "react" +import { useNavigate } from "react-router-dom" +import type { ClipData, ClipType } from "../../types" +import type { AssetItem } from "@/api/assets" +import { TRANSITION_OPTIONS } from "@/api/template-editor" +import { CLIP_TYPE_ICONS, CLIP_TYPE_LABELS } from "../../constants/clipProperties" +import { getGenderLabel } from "../../utils/clipProperties" +import { useVoicePreview } from "../../hooks/useVoicePreview" + +interface ClipDetailSectionProps { + clip: ClipData + currentMode: string + onClipUpdate: (clipId: string, data: Partial) => void + voiceMaterials?: AssetItem[] + voiceMaterialsLoading?: boolean + onRefreshVoiceMaterials?: () => void + onClipVoiceSelect?: (clipId: string, asset: AssetItem | null) => void + onOpenTransitionDrawer?: (clipId: string) => void + onOpenSpeedDrawer?: (clipId: string) => void + onOpenTtsDrawer?: (clipId: string) => void +} + +const ClipDetailSection: React.FC = ({ + clip, + currentMode, + onClipUpdate, + voiceMaterials = [], + voiceMaterialsLoading = false, + onRefreshVoiceMaterials, + onClipVoiceSelect, + onOpenTransitionDrawer, + onOpenSpeedDrawer, + onOpenTtsDrawer, +}) => { + const navigate = useNavigate() + const { previewingId, handlePreviewVoice, stopPreview } = useVoicePreview() + + /** 转场效果显示文本 */ + const getTransitionLabel = () => { + 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 getTtsLabel = () => { + const tts = clip.tts_config + if (!tts || tts.mode === "none") return "无配音" + if (tts.mode === "upload") return "上传配音" + return `TTS · ${tts.voice_id ? "已选音色" : "未选音色"}` + } + + /** 类型是否被禁用 */ + const isTypeDisabled = (t: ClipType): boolean => { + if (currentMode === "pip") return t !== "pip" + if (currentMode === "voice_over") return t !== "voice" + return false // voice_pip 可切换 + } + + 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 100755 index 000000000..cf336a31b --- /dev/null +++ b/apps/web/src/pages/editing-planner/components/clip-properties/StatsSection.tsx @@ -0,0 +1,43 @@ +/** + * 编辑统计区块 + */ +import React from "react" +import type { TemplateMode } from "@/api/editing-planner" +import { formatModeLabel } from "../../utils/clipProperties" + +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 100755 index 000000000..425a2889d --- /dev/null +++ b/apps/web/src/pages/editing-planner/components/clip-properties/SubtitleSettingsSection.tsx @@ -0,0 +1,113 @@ +/** + * 字幕设置区块 + */ +import React from "react" +import type { SubtitleSettings } from "../../types/clipProperties" +import { POSITION_OPTIONS, FONT_OPTIONS, ANIMATION_OPTIONS } from "../../constants/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/pip-config/LayerConfig.tsx b/apps/web/src/pages/editing-planner/components/pip-config/LayerConfig.tsx new file mode 100755 index 000000000..74888a260 --- /dev/null +++ b/apps/web/src/pages/editing-planner/components/pip-config/LayerConfig.tsx @@ -0,0 +1,306 @@ +/** + * 混剪单图层配置区组件 + */ +import React, { useCallback } from "react" +import type { PipLayer, PipGridPosition, PipAnimType, PipSlideDirection } from "../../types" +import { + GRID_POSITIONS, + GRID_POSITION_MAP, + ANIM_OPTIONS, + SLIDE_DIR_OPTIONS, + LAYER_COLORS, +} from "../../constants/pipConfig" + +interface LayerConfigProps { + layer: PipLayer + layers: PipLayer[] + selectedId: string + totalDuration: number + onUpdate: (id: string, partial: Partial) => void +} + +const LayerConfig: React.FC = ({ + layer, + layers, + selectedId, + totalDuration, + onUpdate, +}) => { + /* ── 九宫格点击 ── */ + const handleGridClick = useCallback( + (pos: PipGridPosition) => { + const coords = GRID_POSITION_MAP[pos] + onUpdate(layer.id, { + grid_position: pos, + x: coords.x, + y: coords.y, + }) + }, + [layer.id, onUpdate], + ) + + /* ── 宽高比锁定 ── */ + const handleWidthChange = useCallback( + (val: number) => { + const partial: Partial = { width: val } + if (layer.aspect_lock) { + partial.height = val + } + onUpdate(layer.id, partial) + }, + [layer.id, layer.aspect_lock, onUpdate], + ) + + const handleHeightChange = useCallback( + (val: number) => { + const partial: Partial = { height: val } + if (layer.aspect_lock) { + partial.width = val + } + onUpdate(layer.id, partial) + }, + [layer.id, layer.aspect_lock, onUpdate], + ) + + 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) })} + /> +
+
+
+
+ + {/* ── 尺寸 ── */} +
+ +
+ 宽 + handleWidthChange(Number(e.target.value))} + /> + {layer.width}% +
+
+ 高 + handleHeightChange(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 100755 index 000000000..346392ffb --- /dev/null +++ b/apps/web/src/pages/editing-planner/components/pip-config/LayerList.tsx @@ -0,0 +1,59 @@ +/** + * 混剪图层列表组件 + */ +import React from "react" +import type { PipLayer } from "../../types" +import { LAYER_COLORS } from "../../constants/pipConfig" + +interface LayerListProps { + layers: PipLayer[] + selectedId: string + onSelect: (id: string) => void + onDelete: (id: string) => void +} + +const LayerList: React.FC = ({ layers, selectedId, onSelect, 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 100755 index 000000000..d517940c9 --- /dev/null +++ b/apps/web/src/pages/editing-planner/components/sticker/StickerLibrary.tsx @@ -0,0 +1,131 @@ +/** + * 贴纸素材库组件(emoji / 图片 / 文字花字) + */ +import React, { useState } from "react" +import type { StickerType, TextStickerPreset } from "../../types" +import { TEXT_STICKER_PRESET_LABELS } from "../../types" +import { EMOJI_LIST, STICKER_TYPE_TABS, TEXT_PRESET_STYLES } from "../../constants/sticker" + +interface StickerLibraryProps { + onAddSticker: (type: StickerType, content: string) => void +} + +const StickerLibrary: React.FC = ({ onAddSticker }) => { + const [activeTab, setActiveTab] = useState("emoji") + const [textInput, setTextInput] = useState("") + const [imageUrlInput, setImageUrlInput] = useState("") + + const handleAddImage = () => { + const url = imageUrlInput.trim() + if (!url) return + onAddSticker("image", url) + setImageUrlInput("") + } + + const handleAddText = () => { + const text = textInput.trim() + if (!text) return + onAddSticker("text", text) + setTextInput("") + } + + return ( + <> + {/* 类型 Tab */} +
+ {STICKER_TYPE_TABS.map((t) => ( + + ))} +
+ + {/* Tab 内容区 */} +
+ {/* Emoji 素材库 */} + {activeTab === "emoji" && ( +
+ {EMOJI_LIST.map((emoji) => ( + + ))} +
+ )} + + {/* 图片贴纸 */} + {activeTab === "image" && ( +
+ setImageUrlInput(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter") 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 100755 index 000000000..e50c90d7c --- /dev/null +++ b/apps/web/src/pages/editing-planner/components/sticker/StickerList.tsx @@ -0,0 +1,57 @@ +/** + * 已添加贴纸列表组件 + */ +import React from "react" +import type { StickerItem } from "../../types" + +interface StickerListProps { + items: StickerItem[] + selectedId: string | null + onSelect: (id: string) => void + onRemove: (id: string) => void +} + +const StickerList: React.FC = ({ items, selectedId, onSelect, onRemove }) => { + if (items.length === 0) return null + + const getDisplayName = (item: StickerItem) => { + if (item.type === "text") return item.content.slice(0, 10) + if (item.type === "emoji") return "表情贴纸" + return "图片贴纸" + } + + const getDisplayIcon = (item: StickerItem) => { + if (item.type === "emoji") return item.content + if (item.type === "text") return "T" + return "🖼" + } + + return ( +
+
已添加贴纸 ({items.length})
+
+ {items.map((item) => ( +
onSelect(item.id)} + > + {getDisplayIcon(item)} + {getDisplayName(item)} + +
+ ))} +
+
+ ) +} + +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 100755 index 000000000..80326c104 --- /dev/null +++ b/apps/web/src/pages/editing-planner/components/sticker/StickerPropsEditor.tsx @@ -0,0 +1,194 @@ +/** + * 贴纸属性编辑面板 + 预览 + */ +import React from "react" +import type { StickerItem, TextStickerPreset } from "../../types" +import { TEXT_STICKER_PRESET_LABELS } from "../../types" +import { TEXT_PRESET_STYLES } from "../../constants/sticker" + +interface StickerPropsEditorProps { + sticker: StickerItem + totalDuration: number + onUpdate: (partial: Partial) => void +} + +const StickerPropsEditor: React.FC = ({ + sticker, + totalDuration, + onUpdate, +}) => { + return ( +
+
属性调整
+ + {/* 位置 */} +
+ 位置 X + onUpdate({ x: Number(e.target.value) })} + /> + {sticker.x}% +
+
+ 位置 Y + onUpdate({ y: Number(e.target.value) })} + /> + {sticker.y}% +
+ + {/* 尺寸 */} +
+ 大小 + + onUpdate({ + width: Number(e.target.value), + height: Number(e.target.value), + }) + } + /> + {sticker.width}% +
+ + {/* 旋转 */} +
+ 旋转 + onUpdate({ rotation: Number(e.target.value) })} + /> + {sticker.rotation}° +
+ + {/* 透明度 */} +
+ 透明度 + onUpdate({ opacity: Number(e.target.value) })} + /> + {sticker.opacity}% +
+ + {/* 时间 */} +
+ 开始 + onUpdate({ start_time: Number(e.target.value) })} + /> + 时长 + onUpdate({ duration: Number(e.target.value) })} + /> +
+ + {/* 文字贴纸特有属性 */} + {sticker.type === "text" && ( + <> +
+ 花字 + +
+
+ 字号 + onUpdate({ font_size: Number(e.target.value) })} + /> + {sticker.font_size}px +
+
+ 颜色 + onUpdate({ 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/constants/clipProperties.ts b/apps/web/src/pages/editing-planner/constants/clipProperties.ts new file mode 100755 index 000000000..bf9507c6f --- /dev/null +++ b/apps/web/src/pages/editing-planner/constants/clipProperties.ts @@ -0,0 +1,39 @@ +/** + * ClipPropertiesPanel 相关常量 + */ +import type { ClipType } from "../types" + +export const POSITION_OPTIONS = [ + { value: "top", label: "顶部" }, + { value: "center", label: "居中" }, + { value: "bottom", label: "底部" }, +] + +export const FONT_OPTIONS = [ + "思源黑体", + "思源宋体", + "苹方", + "PingFang", + "微软雅黑", + "楷体", + "华康俪金黑", +] + +export const ANIMATION_OPTIONS = [ + { value: "none", label: "无" }, + { value: "fade", label: "淡入淡出" }, + { value: "slide", label: "滑动" }, + { value: "typewriter", label: "打字机" }, +] + +/** 片段类型图标映射 */ +export const CLIP_TYPE_ICONS: Record = { + voice: "🎙️", + pip: "🖼️", +} + +/** 片段类型标签映射 */ +export const CLIP_TYPE_LABELS: Record = { + voice: "口播", + pip: "混剪", +} diff --git a/apps/web/src/pages/editing-planner/constants/pipConfig.ts b/apps/web/src/pages/editing-planner/constants/pipConfig.ts new file mode 100755 index 000000000..02194fa72 --- /dev/null +++ b/apps/web/src/pages/editing-planner/constants/pipConfig.ts @@ -0,0 +1,55 @@ +/** + * PipConfigPanel 相关常量 + */ +import type { PipGridPosition, PipAnimType, PipSlideDirection } from "../types" + +/** 九宫格位置 → 百分比坐标映射 */ +export 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 }, +} + +/** 九宫格位置选项 */ +export const GRID_POSITIONS: PipGridPosition[] = [ + "top_left", + "top_center", + "top_right", + "center_left", + "center", + "center_right", + "bottom_left", + "bottom_center", + "bottom_right", +] + +/** 入场动画选项 */ +export const ANIM_OPTIONS: { value: PipAnimType; label: string }[] = [ + { value: "none", label: "无" }, + { value: "fade_in", label: "淡入" }, + { value: "slide_in", label: "滑入" }, +] + +/** 滑入方向选项 */ +export const SLIDE_DIR_OPTIONS: { value: PipSlideDirection; label: string }[] = [ + { value: "left", label: "← 左" }, + { value: "right", label: "→ 右" }, + { value: "up", label: "↑ 上" }, + { value: "down", label: "↓ 下" }, +] + +/** 预览图层颜色池 */ +export 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)", +] diff --git a/apps/web/src/pages/editing-planner/constants/sticker.ts b/apps/web/src/pages/editing-planner/constants/sticker.ts new file mode 100755 index 000000000..67a8efbc3 --- /dev/null +++ b/apps/web/src/pages/editing-planner/constants/sticker.ts @@ -0,0 +1,61 @@ +/** + * StickerPanel 相关常量 + */ +import type { StickerType, TextStickerPreset } from "../types" + +/** 常用 emoji 素材 */ +export const EMOJI_LIST = [ + "😀", + "😂", + "🥰", + "😎", + "🤩", + "😱", + "🤔", + "😴", + "🥳", + "😍", + "❤️", + "🔥", + "⭐", + "✨", + "💯", + "👍", + "👏", + "🎉", + "🎵", + "💪", + "📌", + "💡", + "🎯", + "✅", + "❌", + "⬆️", + "⬇️", + "➡️", + "⭕", + "🔔", +] + +/** 贴纸类型 Tab 选项 */ +export const STICKER_TYPE_TABS: { value: StickerType; label: string }[] = [ + { value: "emoji", label: "表情贴纸" }, + { value: "image", label: "图片贴纸" }, + { value: "text", label: "文字花字" }, +] + +/** 文字花字预设对应的 CSS 样式预览 */ +export 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" }, +} diff --git a/apps/web/src/pages/editing-planner/hooks/usePipLayers.ts b/apps/web/src/pages/editing-planner/hooks/usePipLayers.ts new file mode 100755 index 000000000..b3f8a3b0d --- /dev/null +++ b/apps/web/src/pages/editing-planner/hooks/usePipLayers.ts @@ -0,0 +1,90 @@ +/** + * 混剪图层管理 Hook + * 管理图层增删改、选中、重置等业务逻辑 + */ +import { useState, useCallback, useMemo } from "react" +import type { PipConfig, PipLayer } from "../types" +import { DEFAULT_PIP_LAYER, DEFAULT_PIP_CONFIG } from "../types" + +let layerIdCounter = 0 +const genLayerId = () => `pip_layer_${Date.now()}_${++layerIdCounter}` + +interface UsePipLayersOptions { + config: PipConfig + onChange: (config: PipConfig) => void +} + +export const usePipLayers = ({ config, onChange }: UsePipLayersOptions) => { + const [selectedId, setSelectedId] = useState("") + + /** 当前选中图层 */ + const selectedLayer = useMemo( + () => config.layers.find((l) => l.id === selectedId) ?? null, + [config.layers, selectedId], + ) + + /* ── 添加图层 ── */ + const addLayer = 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 deleteLayer = 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 toggleEnable = useCallback( + (checked: boolean) => { + onChange({ ...config, enabled: checked }) + }, + [config, onChange], + ) + + /* ── 重置 ── */ + const resetConfig = useCallback(() => { + onChange({ ...DEFAULT_PIP_CONFIG }) + setSelectedId("") + }, [onChange]) + + return { + selectedId, + selectedLayer, + setSelectedId, + addLayer, + deleteLayer, + updateLayer, + toggleEnable, + resetConfig, + } +} + +export default usePipLayers diff --git a/apps/web/src/pages/editing-planner/hooks/useStickerItems.ts b/apps/web/src/pages/editing-planner/hooks/useStickerItems.ts new file mode 100755 index 000000000..f65412242 --- /dev/null +++ b/apps/web/src/pages/editing-planner/hooks/useStickerItems.ts @@ -0,0 +1,84 @@ +/** + * 贴纸管理 Hook + * 管理贴纸增删改、选中、重置等业务逻辑 + */ +import { useState, useCallback } from "react" +import type { StickerConfig, StickerItem, StickerType } from "../types" +import { DEFAULT_STICKER_CONFIG, DEFAULT_STICKER_ITEM } from "../types" + +/** 生成唯一 ID */ +const genId = () => `sticker_${Date.now()}_${Math.random().toString(36).slice(2, 8)}` + +interface UseStickerItemsOptions { + config: StickerConfig + onChange: (config: StickerConfig) => void + totalDuration: number +} + +export const useStickerItems = ({ config, onChange, totalDuration }: UseStickerItemsOptions) => { + const [selectedId, setSelectedId] = useState(null) + + 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 resetAll = useCallback(() => { + onChange({ ...DEFAULT_STICKER_CONFIG, enabled: config.enabled }) + setSelectedId(null) + }, [config.enabled, onChange]) + + return { + selectedId, + selectedSticker, + setSelectedId, + updateItem, + addSticker, + removeSticker, + resetAll, + } +} + +export default useStickerItems diff --git a/apps/web/src/pages/editing-planner/hooks/useTimelineMenus.ts b/apps/web/src/pages/editing-planner/hooks/useTimelineMenus.ts index c7e210b33..a2b45d5c0 100644 --- a/apps/web/src/pages/editing-planner/hooks/useTimelineMenus.ts +++ b/apps/web/src/pages/editing-planner/hooks/useTimelineMenus.ts @@ -1,10 +1,6 @@ import { useState, useRef, useCallback, useEffect, useMemo, useLayoutEffect } from "react" import type { ClipData, ClipType } from "../types" -import { - DEFAULT_ADD_DURATION, - ADD_PICKER_WIDTH, - TRACK_GAP, -} from "../constants/timeline" +import { DEFAULT_ADD_DURATION, ADD_PICKER_WIDTH, TRACK_GAP } from "../constants/timeline" interface ContextMenuState { x: number @@ -40,11 +36,7 @@ export const useTimelineMenus = ( /* ── 根据模式决定可选类型 ── */ const availableTypes: ClipType[] = useMemo( () => - currentMode === "voice_over" - ? ["voice"] - : currentMode === "pip" - ? ["pip"] - : ["voice", "pip"], + currentMode === "voice_over" ? ["voice"] : currentMode === "pip" ? ["pip"] : ["voice", "pip"], [currentMode], ) diff --git a/apps/web/src/pages/editing-planner/hooks/useTrimDrag.ts b/apps/web/src/pages/editing-planner/hooks/useTrimDrag.ts index f1ca6127b..23b4f4753 100644 --- a/apps/web/src/pages/editing-planner/hooks/useTrimDrag.ts +++ b/apps/web/src/pages/editing-planner/hooks/useTrimDrag.ts @@ -74,7 +74,10 @@ export const useTrimDrag = ( if (trimDrag.direction === "left") { newStart = Math.max(0, Math.min(origTrim.start_time + dtSec, newEnd - MIN_TRIM_DURATION)) } else { - newEnd = Math.max(origTrim.start_time + MIN_TRIM_DURATION, Math.min(origTrim.end_time + dtSec, origDur)) + newEnd = Math.max( + origTrim.start_time + MIN_TRIM_DURATION, + Math.min(origTrim.end_time + dtSec, origDur), + ) } const newDuration = Math.round((newEnd - newStart) * 10) / 10 diff --git a/apps/web/src/pages/editing-planner/hooks/useVoicePreview.ts b/apps/web/src/pages/editing-planner/hooks/useVoicePreview.ts new file mode 100755 index 000000000..9ac9e13f2 --- /dev/null +++ b/apps/web/src/pages/editing-planner/hooks/useVoicePreview.ts @@ -0,0 +1,48 @@ +/** + * 配音试听 Hook + * 管理音频播放、暂停、切换逻辑 + */ +import { useRef, useState, useCallback } from "react" +import type { AssetItem } from "@/api/assets" + +export const 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], + ) + + /** 停止试听 */ + const stopPreview = useCallback(() => { + audioRef.current?.pause() + setPreviewingId(null) + }, []) + + return { + audioRef, + previewingId, + handlePreviewVoice, + stopPreview, + } +} + +export default useVoicePreview diff --git a/apps/web/src/pages/editing-planner/types/clipProperties.ts b/apps/web/src/pages/editing-planner/types/clipProperties.ts new file mode 100755 index 000000000..3ab1fb598 --- /dev/null +++ b/apps/web/src/pages/editing-planner/types/clipProperties.ts @@ -0,0 +1,71 @@ +/** + * ClipPropertiesPanel 相关类型定义 + */ +import type { ClipData } from "../types" +import type { AssetItem } from "@/api/assets" +import type { TemplateMode } from "@/api/editing-planner" + +export interface SubtitleSettings { + enabled: boolean + position: string + font: string + fontSize: number + fontColor: string + animation: string + mode?: string + stroke?: boolean + shadow?: boolean + asrLanguage?: string +} + +export interface BgmSettings { + enabled: boolean + music_id: string + volume?: number + fade_in?: number + fade_out?: number + voice_dodge?: boolean +} + +export 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 */ +} diff --git a/apps/web/src/pages/editing-planner/utils/clipProperties.ts b/apps/web/src/pages/editing-planner/utils/clipProperties.ts new file mode 100755 index 000000000..046a59187 --- /dev/null +++ b/apps/web/src/pages/editing-planner/utils/clipProperties.ts @@ -0,0 +1,21 @@ +/** + * ClipPropertiesPanel 相关工具函数 + */ +import type { AssetItem } from "@/api/assets" +import type { TemplateMode } from "@/api/editing-planner" + +/** 从 metadata 取性别标签 */ +export const getGenderLabel = (m: AssetItem): string => { + const g = (m.metadata?.gender as string) || "" + if (g === "male") return "男" + if (g === "female") return "女" + return "" +} + +/** 格式化模式标签 */ +export const formatModeLabel = (mode: TemplateMode): string => { + if (mode === "pip") return "混剪" + if (mode === "voice_over") return "人物口播" + if (mode === "one_take") return "一镜到底" + return "口播+混剪" +}