Compare commits

...

2 Commits

Author SHA1 Message Date
xiaoxia 9aea90342e refactor(editing-planner): replace 5 main panel files with refactored versions
AI Code Review / AI Code Review (pull_request) Failing after 0s
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 21s
CI/CD Pipeline / Validate - Type Check (mypy) (pull_request) Successful in 1m46s
CI/CD Pipeline / Validate - Migration (alembic) (pull_request) Successful in 1m46s
CI/CD Pipeline / Build Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 18s
CI/CD Pipeline / Frontend Lint (pull_request) Failing after 2m31s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 48s
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 2m42s
CI/CD Pipeline / PR Build Web Image (pull_request) Failing after 2m23s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Successful in 42s
Preview Deploy / Deploy Preview Environment (pull_request) Failing after 21s
CI/CD Pipeline / Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / Build Production API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Validate - Code Quality (pull_request) Successful in 5m40s
CI/CD Pipeline / Frontend Unit Tests (pull_request) Successful in 1m46s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI/CD Pipeline / Deploy Production (pull_request) Has been skipped
CI/CD Pipeline / Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (pull_request) Has been skipped
CI/CD Pipeline / Production Browser E2E (pull_request) Has been skipped
CI/CD Pipeline / Canary Release to Production (pull_request) Has been skipped
Preview Cleanup / Cleanup Preview Environment (pull_request) Successful in 21s
ACR Cleanup / ACR Image Cleanup (pull_request_target) Failing after 22s
Replace original large panel files with their refactored versions that
delegate to extracted sub-components and hooks:

- WatermarkPanel: 391→122行 (-69%)
- TtsPanel: 332→174行 (-48%)
- IntroOutroPanel: 291→100行 (-66%)
- FilterPanel: 224→105行 (-53%)
- SubtitleStylePanel: 223→190行 (-15%)

All constants and sub-components were already created in previous commits.
2026-07-26 15:19:19 +08:00
xiaoxia f9d245bb5e refactor(editing-planner): Phase 2 - refactor 9 panel components with 3-phase pattern
AI Code Review / AI Code Review (pull_request) Failing after 0s
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 18s
CI/CD Pipeline / Frontend Lint (pull_request) Failing after 22s
CI/CD Pipeline / Validate - Migration (alembic) (pull_request) Successful in 1m15s
CI/CD Pipeline / Validate - Type Check (mypy) (pull_request) Successful in 1m17s
CI/CD Pipeline / Build Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 32s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 1m6s
CI/CD Pipeline / Unit Tests (pull_request) Has been skipped
PR Automation / Auto Merge on CI Green + Approved (pull_request) Successful in 1m7s
CI/CD Pipeline / Build Production API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been skipped
Preview Deploy / Deploy Preview Environment (pull_request) Failing after 58s
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 2m17s
CI/CD Pipeline / Frontend Unit Tests (pull_request) Successful in 54s
CI/CD Pipeline / Validate - Code Quality (pull_request) Successful in 4m21s
CI/CD Pipeline / PR Build Web Image (pull_request) Failing after 3m46s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI/CD Pipeline / Deploy Production (pull_request) Has been skipped
CI/CD Pipeline / Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / Production Browser E2E (pull_request) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (pull_request) Has been skipped
CI/CD Pipeline / Canary Release to Production (pull_request) Has been cancelled
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
2026-07-26 15:04:56 +08:00
44 changed files with 3106 additions and 2379 deletions
@@ -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<SubtitleSettings>) => void
onBgmSettingsChange: (partial: Partial<BgmSettings>) => void
onClipUpdate: (clipId: string, data: Partial<ClipData>) => 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<ClipType, string> = { voice: "🎙️", pip: "🖼️" }
const CLIP_TYPE_LABELS: Record<ClipType, string> = {
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<ClipPropertiesPanelProps> = ({
selectedClip,
@@ -122,165 +36,19 @@ const ClipPropertiesPanel: React.FC<ClipPropertiesPanelProps> = ({
onOpenGreenScreenDrawer,
onOpenStickerDrawer,
}) => {
const navigate = useNavigate()
const { previewingId, handlePreviewVoice, stopPreview } = useVoicePreview()
/* ── 配音试听 ── */
const audioRef = useRef<HTMLAudioElement | null>(null)
const [previewingId, setPreviewingId] = useState<string | null>(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 (
<div className="ep-right-panel">
{/* ═══ 字幕设置 ═══ */}
<div className="ep-settings-section">
<div className="ep-section-title">
<span className="ep-section-icon">💬</span>
</div>
<div className="ep-toggle-row">
<span className="ep-toggle-label"></span>
<div
className={`ep-toggle ${subtitleSettings.enabled ? "active" : ""}`}
onClick={() =>
onSubtitleSettingsChange({
enabled: !subtitleSettings.enabled,
})
}
>
<div className="ep-toggle-knob" />
</div>
</div>
{subtitleSettings.enabled && (
<>
<div className="ep-field">
<label className="ep-field-label"></label>
<select
className="ep-form-select"
value={subtitleSettings.position}
onChange={(e) => onSubtitleSettingsChange({ position: e.target.value })}
>
{POSITION_OPTIONS.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
</div>
<div className="ep-field">
<label className="ep-field-label"></label>
<select
className="ep-form-select"
value={subtitleSettings.font}
onChange={(e) => onSubtitleSettingsChange({ font: e.target.value })}
>
{FONT_OPTIONS.map((f) => (
<option key={f} value={f}>
{f}
</option>
))}
</select>
</div>
<div className="ep-field">
<label className="ep-field-label"></label>
<div className="ep-slider-row">
<input
className="ep-slider"
type="range"
min={12}
max={48}
value={subtitleSettings.fontSize}
onChange={(e) =>
onSubtitleSettingsChange({
fontSize: Number(e.target.value),
})
}
/>
<span className="ep-slider-value">{subtitleSettings.fontSize}px</span>
</div>
</div>
<div className="ep-field">
<label className="ep-field-label"></label>
<select
className="ep-form-select"
value={subtitleSettings.animation}
onChange={(e) => onSubtitleSettingsChange({ animation: e.target.value })}
>
{ANIMATION_OPTIONS.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
</div>
{/* 高级配置按钮 */}
{onOpenSubtitleDrawer && (
<button className="ep-advanced-btn" onClick={onOpenSubtitleDrawer}>
🎨
</button>
)}
</>
)}
</div>
<SubtitleSettingsSection
settings={subtitleSettings}
onChange={onSubtitleSettingsChange}
onOpenSubtitleDrawer={onOpenSubtitleDrawer}
/>
{/* ═══ BGM 设置 ═══ */}
<div className="ep-settings-section">
<div className="ep-section-title">
<span className="ep-section-icon">🎵</span>
BGM
</div>
{bgmSettings.enabled && bgmSettings.music_id ? (
<div className="ep-bgm-current">
<span className="ep-bgm-current-label">🎵 BGM</span>
<span className="ep-bgm-current-id">{bgmSettings.music_id}</span>
{bgmSettings.volume !== undefined && (
<span className="ep-bgm-current-vol"> {bgmSettings.volume}%</span>
)}
</div>
) : (
<div className="ep-bgm-empty"></div>
)}
{onOpenBgmDrawer && (
<button className="ep-advanced-btn" onClick={onOpenBgmDrawer}>
🎵 {bgmSettings.enabled ? "更换 BGM / 调整混音" : "选择 BGM 音乐"}
</button>
)}
</div>
<BgmSettingsSection settings={bgmSettings} onOpenBgmDrawer={onOpenBgmDrawer} />
{/* ═══ 水印设置 ═══ */}
<div className="ep-settings-section">
@@ -374,258 +142,30 @@ const ClipPropertiesPanel: React.FC<ClipPropertiesPanelProps> = ({
{/* ═══ 片段详情(选中时显示) ═══ */}
{selectedClip && (
<div className="ep-settings-section">
<div className="ep-section-title">
<span className="ep-section-icon">🎞</span>
</div>
<div className="ep-clip-detail">
{/* 类型选择器 */}
<div className="ep-clip-detail-field">
<div className="ep-clip-detail-label"></div>
<div className="ep-clip-type-selector">
{(["voice", "pip"] as ClipType[]).map((t) => {
const disabled =
currentMode === "pip"
? t !== "pip"
: currentMode === "voice_over"
? t !== "voice"
: false // voice_pip 可切换
return (
<button
key={t}
className={`ep-clip-type-btn${selectedClip.type === t ? " active" : ""}${disabled ? " disabled" : ""}`}
disabled={disabled}
onClick={() => !disabled && onClipUpdate(selectedClip.id, { type: t })}
>
{CLIP_TYPE_ICONS[t]} {CLIP_TYPE_LABELS[t]}
</button>
)
})}
</div>
</div>
{/* 时长 */}
<div className="ep-clip-detail-field">
<div className="ep-clip-detail-label"></div>
<div className="ep-clip-detail-row">
<input
className="ep-duration-input"
type="number"
min={1}
max={120}
value={selectedClip.duration}
onChange={(e) =>
onClipUpdate(selectedClip.id, {
duration: Math.max(1, Math.min(120, Number(e.target.value) || 1)),
})
}
/>
<span className="ep-clip-detail-unit"></span>
</div>
</div>
{/* 转场效果入口 */}
{onOpenTransitionDrawer && (
<div className="ep-clip-detail-field">
<button
className="ep-advanced-btn ep-advanced-btn--transition"
onClick={() => onOpenTransitionDrawer(selectedClip.id)}
>
<span className="ep-advanced-btn-icon">🎬</span>
<span className="ep-advanced-btn-label"></span>
<span className="ep-advanced-btn-value">
{(() => {
const t = selectedClip.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`
})()}
</span>
<span className="ep-advanced-btn-arrow"></span>
</button>
</div>
)}
{/* 播放速度入口 */}
{onOpenSpeedDrawer && (
<div className="ep-clip-detail-field">
<button
className="ep-advanced-btn ep-advanced-btn--speed"
onClick={() => onOpenSpeedDrawer(selectedClip.id)}
>
<span className="ep-advanced-btn-icon"></span>
<span className="ep-advanced-btn-label"></span>
<span className="ep-advanced-btn-value">
{selectedClip.speed ? `${selectedClip.speed.rate.toFixed(2)}x` : "1.00x"}
</span>
<span className="ep-advanced-btn-arrow"></span>
</button>
</div>
)}
{/* TTS 配音入口 */}
{onOpenTtsDrawer && (
<div className="ep-clip-detail-field">
<button
className="ep-advanced-btn ep-advanced-btn--tts"
onClick={() => onOpenTtsDrawer(selectedClip.id)}
>
<span className="ep-advanced-btn-icon">🎙</span>
<span className="ep-advanced-btn-label">TTS </span>
<span className="ep-advanced-btn-value">
{(() => {
const tts = selectedClip.tts_config
if (!tts || tts.mode === "none") return "无配音"
if (tts.mode === "upload") return "上传配音"
return `TTS · ${tts.voice_id ? "已选音色" : "未选音色"}`
})()}
</span>
<span className="ep-advanced-btn-arrow"></span>
</button>
</div>
)}
{/* 素材起始时间 — 仅 voice 类型显示 */}
{selectedClip.type === "voice" && (
<div className="ep-clip-detail-field">
<div className="ep-clip-detail-label"></div>
<div className="ep-clip-detail-row">
<input
className="ep-duration-input"
type="number"
min={0}
max={9999}
step={0.1}
value={selectedClip.startOffset}
onChange={(e) =>
onClipUpdate(selectedClip.id, {
startOffset: Math.max(0, Math.min(9999, Number(e.target.value) || 0)),
})
}
/>
<span className="ep-clip-detail-unit"></span>
</div>
</div>
)}
{/* 配音素材选择 — 仅 voice 类型显示 */}
{selectedClip.type === "voice" && (
<div className="ep-clip-detail-field">
<div className="ep-clip-detail-label">
{onRefreshVoiceMaterials && (
<button
type="button"
className="ep-voice-refresh-btn"
title="刷新配音列表"
onClick={() => onRefreshVoiceMaterials()}
disabled={voiceMaterialsLoading}
>
{voiceMaterialsLoading ? "⏳" : "🔄"}
</button>
)}
</div>
{voiceMaterialsLoading && voiceMaterials.length === 0 ? (
<div className="ep-voice-loading">...</div>
) : (
<>
<div className="ep-voice-select-row">
<select
className="ep-clip-detail-select"
value={selectedClip.voice_asset_id ?? ""}
onChange={(e) => {
const assetId = e.target.value
if (!onClipVoiceSelect) return
if (!assetId) {
onClipVoiceSelect(selectedClip.id, null)
} else {
const asset = voiceMaterials.find((m) => m.id === assetId)
if (asset) onClipVoiceSelect(selectedClip.id, asset)
}
// 切换选择时停止试听
audioRef.current?.pause()
setPreviewingId(null)
}}
>
<option value=""></option>
{voiceMaterials.map((m) => {
const gender = getGenderLabel(m)
const label = gender ? `${m.name}${gender}` : m.name
return (
<option key={m.id} value={m.id}>
{label}
</option>
)
})}
</select>
{/* 试听按钮 */}
{selectedClip.voice_asset_id && (
<button
type="button"
className="ep-voice-preview-btn"
title={previewingId === selectedClip.voice_asset_id ? "暂停" : "试听"}
onClick={() => {
const asset = voiceMaterials.find(
(m) => m.id === selectedClip.voice_asset_id,
)
if (asset) handlePreviewVoice(asset)
}}
>
{previewingId === selectedClip.voice_asset_id ? "⏸" : "▶️"}
</button>
)}
</div>
{voiceMaterials.length === 0 && (
<div className="ep-voice-empty"></div>
)}
</>
)}
<button
className="ep-voice-upload-btn"
onClick={() => navigate("/app/voice-materials")}
>
+
</button>
</div>
)}
</div>
</div>
<ClipDetailSection
clip={selectedClip}
currentMode={currentMode}
voiceMaterials={voiceMaterials}
voiceMaterialsLoading={voiceMaterialsLoading}
onClipUpdate={onClipUpdate}
onRefreshVoiceMaterials={onRefreshVoiceMaterials}
onClipVoiceSelect={onClipVoiceSelect}
onOpenTransitionDrawer={onOpenTransitionDrawer}
onOpenSpeedDrawer={onOpenSpeedDrawer}
onOpenTtsDrawer={onOpenTtsDrawer}
previewingId={previewingId}
onPreviewVoice={handlePreviewVoice}
onStopPreview={stopPreview}
/>
)}
{/* ═══ 统计信息(始终显示) ═══ */}
{/* ═══ 统计信息(未选中时显示) ═══ */}
{!selectedClip && (
<div className="ep-settings-section">
<div className="ep-section-title">
<span className="ep-section-icon">📊</span>
</div>
<div className="ep-clip-detail">
<div className="ep-clip-detail-field">
<div className="ep-clip-detail-label"></div>
<div className="ep-clip-detail-value">{clipsCount}</div>
</div>
<div className="ep-clip-detail-field">
<div className="ep-clip-detail-label"></div>
<div className="ep-clip-detail-value">{totalDuration.toFixed(1)}s</div>
</div>
<div className="ep-clip-detail-field">
<div className="ep-clip-detail-label"></div>
<div className="ep-clip-detail-value">
{currentMode === "pip"
? "混剪"
: currentMode === "voice_over"
? "人物口播"
: currentMode === "one_take"
? "一镜到底"
: "口播+混剪"}
</div>
</div>
</div>
</div>
<StatsSection
clipsCount={clipsCount}
totalDuration={totalDuration}
currentMode={currentMode}
/>
)}
</div>
)
@@ -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<FilterPreset, string> = {
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<FilterPanelProps> = ({ open, onClose, config, onChange }) => {
const update = useCallback(
(partial: Partial<FilterConfig>) => {
@@ -54,7 +29,6 @@ const FilterPanel: React.FC<FilterPanelProps> = ({ 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<FilterPanelProps> = ({ open, onClose, config, onChan
[config.enabled, onChange],
)
const handleManualChange = useCallback(
(key: keyof FilterConfig, value: number) => {
onChange({ ...config, [key]: value })
},
[config, onChange],
)
return (
<Drawer
title="滤镜调色"
@@ -90,110 +71,10 @@ const FilterPanel: React.FC<FilterPanelProps> = ({ open, onClose, config, onChan
</div>
{/* 预设滤镜选择 */}
<div className="filter-section">
<div className="filter-section-title"></div>
<div className="filter-presets">
{PRESET_LIST.map((p) => (
<button
key={p}
className={`filter-preset-item${config.preset === p ? " active" : ""}`}
onClick={() => handlePresetSelect(p)}
>
<div className="filter-preset-preview" style={{ background: PRESET_GRADIENTS[p] }} />
<span className="filter-preset-label">{FILTER_PRESET_LABELS[p]}</span>
</button>
))}
</div>
</div>
<FilterPresetGrid selectedPreset={config.preset} onPresetSelect={handlePresetSelect} />
{/* 手动调节 */}
<div className="filter-section">
<div className="filter-section-title"></div>
{/* 亮度 */}
<div className="filter-slider-row">
<span className="filter-slider-label"></span>
<input
type="range"
className="filter-slider"
min={-100}
max={100}
value={config.brightness}
onChange={(e) => update({ brightness: Number(e.target.value) })}
/>
<span className="filter-slider-value">{config.brightness}</span>
</div>
{/* 对比度 */}
<div className="filter-slider-row">
<span className="filter-slider-label"></span>
<input
type="range"
className="filter-slider"
min={-100}
max={100}
value={config.contrast}
onChange={(e) => update({ contrast: Number(e.target.value) })}
/>
<span className="filter-slider-value">{config.contrast}</span>
</div>
{/* 饱和度 */}
<div className="filter-slider-row">
<span className="filter-slider-label"></span>
<input
type="range"
className="filter-slider"
min={-100}
max={100}
value={config.saturation}
onChange={(e) => update({ saturation: Number(e.target.value) })}
/>
<span className="filter-slider-value">{config.saturation}</span>
</div>
{/* 色温 */}
<div className="filter-slider-row">
<span className="filter-slider-label"></span>
<input
type="range"
className="filter-slider"
min={-100}
max={100}
value={config.temperature}
onChange={(e) => update({ temperature: Number(e.target.value) })}
/>
<span className="filter-slider-value">{config.temperature}</span>
</div>
{/* 色调 */}
<div className="filter-slider-row">
<span className="filter-slider-label"></span>
<input
type="range"
className="filter-slider"
min={-100}
max={100}
value={config.tint}
onChange={(e) => update({ tint: Number(e.target.value) })}
/>
<span className="filter-slider-value">{config.tint}</span>
</div>
{/* 锐度 */}
<div className="filter-slider-row">
<span className="filter-slider-label"></span>
<input
type="range"
className="filter-slider"
min={0}
max={100}
value={config.sharpness}
onChange={(e) => update({ sharpness: Number(e.target.value) })}
/>
<span className="filter-slider-value">{config.sharpness}</span>
</div>
</div>
<FilterManualAdjust config={config} onChange={handleManualChange} />
{/* 预览色块 */}
<div className="filter-section">
@@ -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<IntroOutroPanelProps> = ({ open, onClose, config
className="intro-outro-panel-drawer"
>
{/* ═══ 片头区块 ═══ */}
<div className="iop-block">
<div className="iop-block-header">
<span className="iop-block-icon">🎞</span>
<span className="iop-block-title"></span>
</div>
{/* 类型选择 */}
<div className="iop-kind-row">
{KIND_OPTIONS.map((opt) => (
<button
key={opt.value}
className={`iop-kind-btn${config.intro.kind === opt.value ? " active" : ""}`}
onClick={() => handleIntroKindChange(opt.value)}
>
<span className="iop-kind-icon">{opt.icon}</span>
<span className="iop-kind-label">{opt.label}</span>
</button>
))}
</div>
{/* 视频/图片配置 */}
{config.intro.kind !== "none" && (
<>
<div className="iop-field">
<label className="iop-field-label">
{config.intro.kind === "video" ? "视频" : "图片"} URL
</label>
<input
className="iop-input"
type="text"
placeholder={
config.intro.kind === "video"
? "https://example.com/intro.mp4"
: "https://example.com/intro.png"
}
value={config.intro.url ?? ""}
onChange={(e) => handleIntroChange({ url: e.target.value })}
/>
</div>
<div className="iop-field">
<label className="iop-field-label"></label>
<div className="iop-slider-row">
<input
className="iop-slider"
type="range"
min={1}
max={15}
step={0.5}
value={config.intro.duration}
onChange={(e) => handleIntroChange({ duration: Number(e.target.value) })}
/>
<span className="iop-slider-value">{config.intro.duration}s</span>
</div>
</div>
<div className="iop-field">
<label className="iop-field-label"></label>
<select
className="iop-select"
value={config.intro.transition ?? "none"}
onChange={(e) =>
handleIntroChange({
transition: e.target.value as TransitionType,
})
}
>
{TRANSITION_OPTIONS.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
</div>
{config.intro.transition && config.intro.transition !== "none" && (
<div className="iop-field">
<label className="iop-field-label"></label>
<div className="iop-slider-row">
<input
className="iop-slider"
type="range"
min={0.3}
max={2.0}
step={0.1}
value={config.intro.transition_duration ?? 0.5}
onChange={(e) =>
handleIntroChange({
transition_duration: Number(e.target.value),
})
}
/>
<span className="iop-slider-value">
{(config.intro.transition_duration ?? 0.5).toFixed(1)}s
</span>
</div>
</div>
)}
</>
)}
</div>
<IntroOutroBlock
title="片头"
icon="🎞️"
item={config.intro}
transitionLabel="进入过渡动画"
onKindChange={handleIntroKindChange}
onChange={handleIntroChange}
/>
{/* ═══ 片尾区块 ═══ */}
<div className="iop-block">
<div className="iop-block-header">
<span className="iop-block-icon">🏁</span>
<span className="iop-block-title"></span>
</div>
{/* 类型选择 */}
<div className="iop-kind-row">
{KIND_OPTIONS.map((opt) => (
<button
key={opt.value}
className={`iop-kind-btn${config.outro.kind === opt.value ? " active" : ""}`}
onClick={() => handleOutroKindChange(opt.value)}
>
<span className="iop-kind-icon">{opt.icon}</span>
<span className="iop-kind-label">{opt.label}</span>
</button>
))}
</div>
{/* 视频/图片配置 */}
{config.outro.kind !== "none" && (
<>
<div className="iop-field">
<label className="iop-field-label">
{config.outro.kind === "video" ? "视频" : "图片"} URL
</label>
<input
className="iop-input"
type="text"
placeholder={
config.outro.kind === "video"
? "https://example.com/outro.mp4"
: "https://example.com/outro.png"
}
value={config.outro.url ?? ""}
onChange={(e) => handleOutroChange({ url: e.target.value })}
/>
</div>
<div className="iop-field">
<label className="iop-field-label"></label>
<div className="iop-slider-row">
<input
className="iop-slider"
type="range"
min={1}
max={15}
step={0.5}
value={config.outro.duration}
onChange={(e) => handleOutroChange({ duration: Number(e.target.value) })}
/>
<span className="iop-slider-value">{config.outro.duration}s</span>
</div>
</div>
<div className="iop-field">
<label className="iop-field-label">退</label>
<select
className="iop-select"
value={config.outro.transition ?? "none"}
onChange={(e) =>
handleOutroChange({
transition: e.target.value as TransitionType,
})
}
>
{TRANSITION_OPTIONS.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
</div>
{config.outro.transition && config.outro.transition !== "none" && (
<div className="iop-field">
<label className="iop-field-label"></label>
<div className="iop-slider-row">
<input
className="iop-slider"
type="range"
min={0.3}
max={2.0}
step={0.1}
value={config.outro.transition_duration ?? 0.5}
onChange={(e) =>
handleOutroChange({
transition_duration: Number(e.target.value),
})
}
/>
<span className="iop-slider-value">
{(config.outro.transition_duration ?? 0.5).toFixed(1)}s
</span>
</div>
</div>
)}
</>
)}
</div>
<IntroOutroBlock
title="片尾"
icon="🏁"
item={config.outro}
transitionLabel="退出过渡动画"
onKindChange={handleOutroKindChange}
onChange={handleOutroChange}
/>
{/* ── 底部操作 ── */}
<div className="iop-footer">
@@ -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<PipGridPosition, { x: number; y: number }> = {
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<PipConfigPanelProps> = ({
open,
onClose,
@@ -84,106 +24,19 @@ const PipConfigPanel: React.FC<PipConfigPanelProps> = ({
onChange,
totalDuration,
}) => {
/** 当前选中图层 ID */
const [selectedId, setSelectedId] = React.useState<string>("")
/** 当前选中图层 */
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<PipLayer>) => {
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<PipLayer> = { 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<PipLayer> = { 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 (
<Drawer
@@ -197,9 +50,7 @@ const PipConfigPanel: React.FC<PipConfigPanelProps> = ({
{/* ═══ 顶部工具栏 ═══ */}
<div className="pip-toolbar">
<div className="pip-toolbar-left">
<button className="pip-add-btn" onClick={handleAddLayer}>
+
</button>
<span style={{ fontSize: 13, color: "#666" }}> {config.layers.length} </span>
</div>
<div className="pip-enable-switch">
<span></span>
@@ -209,329 +60,22 @@ const PipConfigPanel: React.FC<PipConfigPanelProps> = ({
{/* ═══ 主体:图层列表 + 配置区 ═══ */}
<div className="pip-body">
{/* 左侧图层列表 */}
<div className="pip-layer-list">
{config.layers.length === 0 ? (
<div className="pip-layer-empty"></div>
) : (
config.layers.map((layer, idx) => (
<div
key={layer.id}
className={`pip-layer-item${selectedId === layer.id ? " active" : ""}`}
onClick={() => setSelectedId(layer.id)}
>
{layer.thumbnail_url || layer.material_url ? (
<img
className="pip-layer-thumb"
src={layer.thumbnail_url || layer.material_url}
alt={layer.name}
/>
) : (
<div
className="pip-layer-thumb"
style={{
background: LAYER_COLORS[idx % LAYER_COLORS.length],
}}
/>
)}
<span className="pip-layer-name">{layer.name}</span>
<button
className="pip-layer-delete"
onClick={(e) => {
e.stopPropagation()
handleDeleteLayer(layer.id)
}}
title="删除图层"
>
</button>
</div>
))
)}
</div>
{/* 右侧配置区 */}
<div className="pip-config-area">
{!selectedLayer ? (
<div className="pip-config-empty"></div>
) : (
<>
{/* ── 迷你预览 ── */}
<div className="pip-preview-box">
{config.layers.map((layer, idx) => (
<div
key={layer.id}
className={`pip-preview-layer${selectedId === layer.id ? " selected" : ""}`}
style={{
left: `${layer.x}%`,
top: `${layer.y}%`,
width: `${layer.width}%`,
height: `${layer.height}%`,
background: LAYER_COLORS[idx % LAYER_COLORS.length],
opacity: layer.opacity / 100,
borderRadius: `${layer.border_radius}%`,
}}
>
<span className="pip-preview-label">{layer.name}</span>
</div>
))}
</div>
{/* ── 素材类型 ── */}
<div className="pip-field">
<label className="pip-field-label"></label>
<div className="pip-type-btns">
<button
className={`pip-type-btn${selectedLayer.material_type === "image" ? " active" : ""}`}
onClick={() => updateLayer(selectedLayer.id, { material_type: "image" })}
>
🖼
</button>
<button
className={`pip-type-btn${selectedLayer.material_type === "video" ? " active" : ""}`}
onClick={() => updateLayer(selectedLayer.id, { material_type: "video" })}
>
🎬
</button>
</div>
</div>
{/* ── 素材 URL ── */}
<div className="pip-field">
<label className="pip-field-label">
{selectedLayer.material_type === "image" ? "图片" : "视频"} URL
</label>
<input
className="pip-input"
type="text"
placeholder={
selectedLayer.material_type === "image"
? "https://example.com/image.png"
: "https://example.com/video.mp4"
}
value={selectedLayer.material_url}
onChange={(e) =>
updateLayer(selectedLayer.id, {
material_url: e.target.value,
})
}
/>
</div>
{/* ── 位置:九宫格 + 坐标 ── */}
<div className="pip-field">
<label className="pip-field-label"></label>
<div style={{ display: "flex", gap: 16, alignItems: "flex-start" }}>
<div className="pip-grid">
{GRID_POSITIONS.map((pos) => (
<button
key={pos}
className={`pip-grid-btn${selectedLayer.grid_position === pos ? " active" : ""}`}
onClick={() => handleGridClick(pos)}
>
<span className="pip-grid-dot" />
</button>
))}
</div>
<div className="pip-field-row" style={{ flex: 1 }}>
<div>
<label className="pip-field-label">X (%)</label>
<input
className="pip-number"
type="number"
min={0}
max={100}
value={selectedLayer.x}
onChange={(e) =>
updateLayer(selectedLayer.id, {
x: Number(e.target.value),
})
}
/>
</div>
<div>
<label className="pip-field-label">Y (%)</label>
<input
className="pip-number"
type="number"
min={0}
max={100}
value={selectedLayer.y}
onChange={(e) =>
updateLayer(selectedLayer.id, {
y: Number(e.target.value),
})
}
/>
</div>
</div>
</div>
</div>
{/* ── 尺寸 ── */}
<div className="pip-field">
<label className="pip-field-label"></label>
<div className="pip-slider-row">
<span style={{ fontSize: 12, color: "#999", width: 20 }}></span>
<input
className="pip-slider"
type="range"
min={10}
max={80}
value={selectedLayer.width}
onChange={(e) => handleWidthChange(Number(e.target.value))}
/>
<span className="pip-slider-value">{selectedLayer.width}%</span>
</div>
<div className="pip-slider-row" style={{ marginTop: 6 }}>
<span style={{ fontSize: 12, color: "#999", width: 20 }}></span>
<input
className="pip-slider"
type="range"
min={10}
max={80}
value={selectedLayer.height}
onChange={(e) => handleHeightChange(Number(e.target.value))}
/>
<span className="pip-slider-value">{selectedLayer.height}%</span>
</div>
<div
className="pip-lock-row"
style={{ marginTop: 6 }}
onClick={() =>
updateLayer(selectedLayer.id, {
aspect_lock: !selectedLayer.aspect_lock,
})
}
>
<span className="pip-lock-icon">{selectedLayer.aspect_lock ? "🔒" : "🔓"}</span>
<span>{selectedLayer.aspect_lock ? "已锁定比例" : "锁定宽高比"}</span>
</div>
</div>
{/* ── 圆角 ── */}
<div className="pip-field">
<label className="pip-field-label"></label>
<div className="pip-slider-row">
<input
className="pip-slider"
type="range"
min={0}
max={50}
value={selectedLayer.border_radius}
onChange={(e) =>
updateLayer(selectedLayer.id, {
border_radius: Number(e.target.value),
})
}
/>
<span className="pip-slider-value">{selectedLayer.border_radius}%</span>
</div>
</div>
{/* ── 透明度 ── */}
<div className="pip-field">
<label className="pip-field-label"></label>
<div className="pip-slider-row">
<input
className="pip-slider"
type="range"
min={0}
max={100}
value={selectedLayer.opacity}
onChange={(e) =>
updateLayer(selectedLayer.id, {
opacity: Number(e.target.value),
})
}
/>
<span className="pip-slider-value">{selectedLayer.opacity}%</span>
</div>
</div>
{/* ── 时间 ── */}
<div className="pip-field">
<label className="pip-field-label"></label>
<div className="pip-field-row">
<div>
<label className="pip-field-label"> (s)</label>
<input
className="pip-number"
type="number"
min={0}
max={totalDuration || 999}
step={0.1}
value={selectedLayer.start_time}
onChange={(e) =>
updateLayer(selectedLayer.id, {
start_time: Number(e.target.value),
})
}
/>
</div>
<div>
<label className="pip-field-label"> (s)</label>
<input
className="pip-number"
type="number"
min={0.1}
max={totalDuration || 999}
step={0.1}
value={selectedLayer.duration}
onChange={(e) =>
updateLayer(selectedLayer.id, {
duration: Number(e.target.value),
})
}
/>
</div>
</div>
</div>
{/* ── 入场动画 ── */}
<div className="pip-field">
<label className="pip-field-label"></label>
<select
className="pip-select"
value={selectedLayer.animation}
onChange={(e) =>
updateLayer(selectedLayer.id, {
animation: e.target.value as PipAnimType,
})
}
>
{ANIM_OPTIONS.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
</div>
{/* 滑入方向(仅 slide_in 时显示) */}
{selectedLayer.animation === "slide_in" && (
<div className="pip-field">
<label className="pip-field-label"></label>
<select
className="pip-select"
value={selectedLayer.slide_direction}
onChange={(e) =>
updateLayer(selectedLayer.id, {
slide_direction: e.target.value as PipSlideDirection,
})
}
>
{SLIDE_DIR_OPTIONS.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
</div>
)}
</>
)}
</div>
<LayerList
layers={config.layers}
selectedId={selectedId}
onSelect={setSelectedId}
onAdd={handleAddLayer}
onDelete={handleDeleteLayer}
/>
<LayerConfig
layer={selectedLayer}
layers={config.layers}
totalDuration={totalDuration}
onUpdate={updateLayer}
onGridClick={handleGridClick}
onWidthChange={handleWidthChange}
onHeightChange={handleHeightChange}
/>
</div>
{/* ── 底部操作 ── */}
@@ -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<TextStickerPreset, React.CSSProperties> = {
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<StickerPanelProps> = ({
open,
onClose,
@@ -75,63 +25,17 @@ const StickerPanel: React.FC<StickerPanelProps> = ({
onChange,
totalDuration,
}) => {
const [selectedId, setSelectedId] = useState<string | null>(null)
const [activeTab, setActiveTab] = useState<StickerType>("emoji")
const selectedSticker = config.items.find((s) => s.id === selectedId) ?? null
/** 更新单个贴纸 */
const updateItem = useCallback(
(id: string, partial: Partial<StickerItem>) => {
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 (
<Drawer
@@ -152,355 +56,24 @@ const StickerPanel: React.FC<StickerPanelProps> = ({
/>
</div>
{/* 类型 Tab */}
<div className="sticker-tabs">
{(["emoji", "image", "text"] as StickerType[]).map((t) => (
<button
key={t}
className={`sticker-tab${activeTab === t ? " active" : ""}`}
onClick={() => setActiveTab(t)}
>
{t === "emoji" ? "表情贴纸" : t === "image" ? "图片贴纸" : "文字花字"}
</button>
))}
</div>
{/* Tab 内容区 */}
<div className="sticker-tab-content">
{/* Emoji 素材库 */}
{activeTab === "emoji" && (
<div className="sticker-emoji-grid">
{EMOJI_LIST.map((emoji) => (
<button
key={emoji}
className="sticker-emoji-btn"
onClick={() => addSticker("emoji", emoji)}
>
{emoji}
</button>
))}
</div>
)}
{/* 图片贴纸 */}
{activeTab === "image" && (
<div className="sticker-image-input">
<input
type="text"
className="sticker-url-input"
placeholder="输入图片 URL 添加贴纸..."
onKeyDown={(e) => {
if (e.key === "Enter" && e.currentTarget.value.trim()) {
addSticker("image", e.currentTarget.value.trim())
e.currentTarget.value = ""
}
}}
/>
<button
className="sticker-url-add-btn"
onClick={() => {
const input = document.querySelector<HTMLInputElement>(".sticker-url-input")
if (input?.value.trim()) {
addSticker("image", input.value.trim())
input.value = ""
}
}}
>
</button>
</div>
)}
{/* 文字花字 */}
{activeTab === "text" && (
<div className="sticker-text-section">
<div className="sticker-text-input-row">
<input
type="text"
className="sticker-text-input"
placeholder="输入文字内容..."
value={textInput}
onChange={(e) => setTextInput(e.target.value)}
/>
<button
className="sticker-text-add-btn"
disabled={!textInput.trim()}
onClick={() => {
if (textInput.trim()) {
addSticker("text", textInput.trim())
setTextInput("")
}
}}
>
</button>
</div>
<div className="sticker-text-presets">
<div className="sticker-preset-title"></div>
<div className="sticker-preset-grid">
{(Object.keys(TEXT_STICKER_PRESET_LABELS) as TextStickerPreset[]).map((p) => (
<div
key={p}
className="sticker-preset-preview"
style={{
...TEXT_PRESET_STYLES[p],
background:
p === "bubble"
? "rgba(0,0,0,0.5)"
: p === "gradient"
? "linear-gradient(90deg,#f093fb,#f5576c)"
: "#1a1a2e",
}}
>
<span style={TEXT_PRESET_STYLES[p]}></span>
<div className="sticker-preset-name">{TEXT_STICKER_PRESET_LABELS[p]}</div>
</div>
))}
</div>
</div>
</div>
)}
</div>
{/* 素材库 + 类型Tab */}
<StickerLibrary activeTab={activeTab} onTabChange={setActiveTab} onAddSticker={addSticker} />
{/* 已添加贴纸列表 */}
{config.items.length > 0 && (
<div className="sticker-list-section">
<div className="sticker-section-title"> ({config.items.length})</div>
<div className="sticker-list">
{config.items.map((item) => (
<div
key={item.id}
className={`sticker-list-item${selectedId === item.id ? " active" : ""}`}
onClick={() => setSelectedId(item.id)}
>
<span className="sticker-list-icon">
{item.type === "emoji" ? item.content : item.type === "text" ? "T" : "🖼"}
</span>
<span className="sticker-list-name">
{item.type === "text"
? item.content.slice(0, 10)
: item.type === "emoji"
? "表情贴纸"
: "图片贴纸"}
</span>
<button
className="sticker-list-delete"
onClick={(e) => {
e.stopPropagation()
removeSticker(item.id)
}}
>
</button>
</div>
))}
</div>
</div>
)}
<StickerList
items={config.items}
selectedId={selectedId}
onSelect={setSelectedId}
onDelete={removeSticker}
/>
{/* 选中贴纸的属性编辑 */}
{selectedSticker && (
<div className="sticker-props-section">
<div className="sticker-section-title"></div>
{/* 位置 */}
<div className="sticker-prop-row">
<span className="sticker-prop-label"> X</span>
<input
type="range"
className="sticker-prop-slider"
min={0}
max={100}
value={selectedSticker.x}
onChange={(e) => updateItem(selectedSticker.id, { x: Number(e.target.value) })}
/>
<span className="sticker-prop-value">{selectedSticker.x}%</span>
</div>
<div className="sticker-prop-row">
<span className="sticker-prop-label"> Y</span>
<input
type="range"
className="sticker-prop-slider"
min={0}
max={100}
value={selectedSticker.y}
onChange={(e) => updateItem(selectedSticker.id, { y: Number(e.target.value) })}
/>
<span className="sticker-prop-value">{selectedSticker.y}%</span>
</div>
{/* 尺寸 */}
<div className="sticker-prop-row">
<span className="sticker-prop-label"></span>
<input
type="range"
className="sticker-prop-slider"
min={5}
max={50}
value={selectedSticker.width}
onChange={(e) =>
updateItem(selectedSticker.id, {
width: Number(e.target.value),
height: Number(e.target.value),
})
}
/>
<span className="sticker-prop-value">{selectedSticker.width}%</span>
</div>
{/* 旋转 */}
<div className="sticker-prop-row">
<span className="sticker-prop-label"></span>
<input
type="range"
className="sticker-prop-slider"
min={-180}
max={180}
value={selectedSticker.rotation}
onChange={(e) =>
updateItem(selectedSticker.id, {
rotation: Number(e.target.value),
})
}
/>
<span className="sticker-prop-value">{selectedSticker.rotation}°</span>
</div>
{/* 透明度 */}
<div className="sticker-prop-row">
<span className="sticker-prop-label"></span>
<input
type="range"
className="sticker-prop-slider"
min={0}
max={100}
value={selectedSticker.opacity}
onChange={(e) =>
updateItem(selectedSticker.id, {
opacity: Number(e.target.value),
})
}
/>
<span className="sticker-prop-value">{selectedSticker.opacity}%</span>
</div>
{/* 时间 */}
<div className="sticker-prop-row">
<span className="sticker-prop-label"></span>
<input
type="number"
className="sticker-prop-number"
min={0}
max={totalDuration}
step={0.1}
value={selectedSticker.start_time}
onChange={(e) =>
updateItem(selectedSticker.id, {
start_time: Number(e.target.value),
})
}
/>
<span className="sticker-prop-label"></span>
<input
type="number"
className="sticker-prop-number"
min={0}
max={totalDuration}
step={0.1}
value={selectedSticker.duration}
onChange={(e) =>
updateItem(selectedSticker.id, {
duration: Number(e.target.value),
})
}
/>
</div>
{/* 文字贴纸特有属性 */}
{selectedSticker.type === "text" && (
<>
<div className="sticker-prop-row">
<span className="sticker-prop-label"></span>
<select
className="sticker-prop-select"
value={selectedSticker.text_preset}
onChange={(e) =>
updateItem(selectedSticker.id, {
text_preset: e.target.value as TextStickerPreset,
})
}
>
{(Object.keys(TEXT_STICKER_PRESET_LABELS) as TextStickerPreset[]).map((p) => (
<option key={p} value={p}>
{TEXT_STICKER_PRESET_LABELS[p]}
</option>
))}
</select>
</div>
<div className="sticker-prop-row">
<span className="sticker-prop-label"></span>
<input
type="range"
className="sticker-prop-slider"
min={12}
max={72}
value={selectedSticker.font_size}
onChange={(e) =>
updateItem(selectedSticker.id, {
font_size: Number(e.target.value),
})
}
/>
<span className="sticker-prop-value">{selectedSticker.font_size}px</span>
</div>
<div className="sticker-prop-row">
<span className="sticker-prop-label"></span>
<input
type="color"
className="sticker-prop-color"
value={selectedSticker.text_color}
onChange={(e) =>
updateItem(selectedSticker.id, {
text_color: e.target.value,
})
}
/>
</div>
</>
)}
{/* 预览 */}
<div className="sticker-preview-box">
<div
className="sticker-preview-item"
style={{
left: `${selectedSticker.x}%`,
top: `${selectedSticker.y}%`,
width: `${selectedSticker.width}%`,
height: `${selectedSticker.width}%`,
transform: `translate(-50%, -50%) rotate(${selectedSticker.rotation}deg)`,
opacity: selectedSticker.opacity / 100,
fontSize:
selectedSticker.type === "text" ? `${selectedSticker.font_size}px` : undefined,
...TEXT_PRESET_STYLES[selectedSticker.text_preset],
}}
>
{selectedSticker.type === "emoji" && selectedSticker.content}
{selectedSticker.type === "text" && selectedSticker.content}
{selectedSticker.type === "image" && (
<img
src={selectedSticker.content}
alt="sticker"
style={{
width: "100%",
height: "100%",
objectFit: "contain",
}}
/>
)}
</div>
</div>
</div>
<StickerPropsEditor
sticker={selectedSticker}
totalDuration={totalDuration}
onUpdate={updateItem}
/>
)}
{/* 底部重置 */}
@@ -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<SubtitleStylePanelProps> = ({
</div>
{/* ── 预览 ── */}
<div className="sub-field">
<label className="sub-label"></label>
<div className="sub-preview-box">
<span
className="sub-preview-text"
style={{
fontSize: `${Math.min(config.fontSize, 28)}px`,
color: config.fontColor,
fontFamily: config.font,
WebkitTextStroke: config.stroke ? "1px #000" : undefined,
textShadow: config.shadow ? "2px 2px 4px rgba(0,0,0,0.8)" : undefined,
}}
>
</span>
</div>
</div>
<SubtitlePreview config={config} />
</>
)}
</Drawer>
@@ -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<string, { icon: string; label: string }> = {
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<TtsPanelProps> = ({ open, onClose, config, onChange }) => {
/* ── 音色列表 ── */
const [voices, setVoices] = useState<TTSVoice[]>([])
const [voicesLoading, setVoicesLoading] = useState(false)
/* ── 试听状态 ── */
const [previewLoading, setPreviewLoading] = useState(false)
const audioRef = useRef<HTMLAudioElement | null>(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<HTMLTextAreaElement>) => {
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 (
<Drawer
@@ -162,11 +48,7 @@ const TtsPanel: React.FC<TtsPanelProps> = ({ open, onClose, config, onChange })
<div className="tts-mode-section">
<div className="tts-mode-label"></div>
<div className="tts-mode-group">
{[
{ 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) => (
<button
key={m.mode}
className={`tts-mode-btn${config.mode === m.mode ? " active" : ""}`}
@@ -199,90 +81,50 @@ const TtsPanel: React.FC<TtsPanelProps> = ({ open, onClose, config, onChange })
</div>
{/* 音色选择 */}
<div className="tts-voice-section">
<div className="tts-voice-label">
{voicesLoading && <span className="tts-voice-loading">...</span>}
</div>
<div className="tts-voice-grid">
{voiceCategories.map(([cat, info]) => {
const voice = voices.find((v) => v.category === cat)
const isSelected = voice && config.voice_id === voice.id
return (
<button
key={cat}
className={`tts-voice-card${isSelected ? " active" : ""}`}
onClick={() => voice && handleVoiceSelect(voice.id)}
disabled={!voice || voicesLoading}
>
<span className="tts-voice-card-icon">{info.icon}</span>
<span className="tts-voice-card-name">{voice?.name || info.label}</span>
</button>
)
})}
</div>
</div>
<VoiceSelector
voices={voices}
voicesLoading={voicesLoading}
selectedVoiceId={config.voice_id}
onVoiceSelect={handleVoiceSelect}
/>
{/* 语速滑块 */}
<div className="tts-slider-section">
<div className="tts-slider-header">
<span className="tts-slider-label"></span>
<span className="tts-slider-value">{config.speed.toFixed(2)}x</span>
</div>
<Slider
min={0.5}
max={2.0}
step={0.05}
value={config.speed}
onChange={handleSpeedChange}
tooltip={{ formatter: (v) => `${(v as number).toFixed(2)}x` }}
/>
<div className="tts-slider-marks">
<span>0.5x</span>
<span>1.0x</span>
<span>2.0x</span>
</div>
</div>
<TtsSlider
label="语速"
value={config.speed}
min={0.5}
max={2.0}
step={0.05}
unit="x"
onChange={handleSpeedChange}
marks={["0.5x", "1.0x", "2.0x"]}
tooltipFormatter={(v) => `${v.toFixed(2)}x`}
/>
{/* 语调滑块 */}
<div className="tts-slider-section">
<div className="tts-slider-header">
<span className="tts-slider-label"></span>
<span className="tts-slider-value">
{config.pitch > 0 ? "+" : ""}
{config.pitch}
</span>
</div>
<Slider
min={-12}
max={12}
step={1}
value={config.pitch}
onChange={handlePitchChange}
tooltip={{ formatter: (v) => `${v}半音` }}
/>
<div className="tts-slider-marks">
<span>-12</span>
<span>0</span>
<span>+12</span>
</div>
</div>
<TtsSlider
label="语调"
value={config.pitch}
min={-12}
max={12}
step={1}
unit="半音"
onChange={handlePitchChange}
marks={["-12", "0", "+12"]}
tooltipFormatter={(v) => `${v}半音`}
/>
{/* 音量滑块 */}
<div className="tts-slider-section">
<div className="tts-slider-header">
<span className="tts-slider-label"></span>
<span className="tts-slider-value">{config.volume}%</span>
</div>
<Slider
min={0}
max={100}
step={1}
value={config.volume}
onChange={handleVolumeChange}
tooltip={{ formatter: (v) => `${v}%` }}
/>
</div>
<TtsSlider
label="音量"
value={config.volume}
min={0}
max={100}
step={1}
unit="%"
onChange={handleVolumeChange}
tooltipFormatter={(v) => `${v}%`}
/>
{/* 试听按钮 */}
<div className="tts-preview-section">
@@ -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<WatermarkPanelProps> = ({ open, onClose, config, onChange }) => {
/* ── 图片上传预览 URL(本地预览用) ── */
const [localImageUrl, setLocalImageUrl] = useState<string>("")
/* ── 切换水印类型 ── */
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 (
<Drawer
@@ -148,18 +47,7 @@ const WatermarkPanel: React.FC<WatermarkPanelProps> = ({ open, onClose, config,
className="watermark-panel-drawer"
>
{/* ── Tab 切换 ── */}
<div className="wp-tabs">
{WATERMARK_TABS.map((tab) => (
<button
key={tab.key}
className={`wp-tab${activeTab === tab.key ? " active" : ""}`}
onClick={() => handleTypeChange(tab.key)}
>
<span className="wp-tab-icon">{tab.icon}</span>
<span className="wp-tab-label">{tab.label}</span>
</button>
))}
</div>
<WatermarkTypeTabs activeTab={config.type} onTypeChange={handleTypeChange} />
{/* ── 无水印提示 ── */}
{config.type === "none" && (
@@ -172,210 +60,53 @@ const WatermarkPanel: React.FC<WatermarkPanelProps> = ({ open, onClose, config,
{/* ── 图片水印配置 ── */}
{config.type === "image" && (
<div className="wp-section">
<div className="wp-field">
<label className="wp-field-label"> URL</label>
<input
className="wp-input"
type="text"
placeholder="https://example.com/logo.png"
value={localImageUrl || config.image_url || ""}
onChange={(e) => handleImageUrlChange(e.target.value)}
/>
{(localImageUrl || config.image_url) && (
<div className="wp-image-preview">
<img
src={localImageUrl || config.image_url}
alt="水印预览"
onError={(e) => {
;(e.target as HTMLImageElement).style.display = "none"
}}
/>
</div>
)}
</div>
<div className="wp-field">
<label className="wp-field-label">0 </label>
<input
className="wp-input"
type="number"
min={0}
max={2000}
value={config.width ?? 0}
onChange={(e) => handleImageWidthChange(Number(e.target.value))}
/>
</div>
<div className="wp-field">
<label className="wp-field-label">0 </label>
<input
className="wp-input"
type="number"
min={0}
max={2000}
value={config.height ?? 0}
onChange={(e) => handleImageHeightChange(Number(e.target.value))}
/>
</div>
</div>
<ImageWatermarkSection
imageUrl={config.image_url || ""}
localImageUrl={localImageUrl}
width={config.width}
height={config.height}
onImageUrlChange={handleImageUrlChange}
onWidthChange={handleImageWidthChange}
onHeightChange={handleImageHeightChange}
/>
)}
{/* ── 文字水印配置 ── */}
{config.type === "text" && (
<div className="wp-section">
<div className="wp-field">
<label className="wp-field-label"></label>
<textarea
className="wp-textarea"
placeholder="输入水印文字内容"
rows={3}
value={config.text ?? ""}
onChange={(e) => handleTextChange(e.target.value)}
/>
</div>
<div className="wp-field">
<label className="wp-field-label"></label>
<div className="wp-slider-row">
<input
className="wp-slider"
type="range"
min={12}
max={72}
value={config.font_size ?? 24}
onChange={(e) => handleFontSizeChange(Number(e.target.value))}
/>
<span className="wp-slider-value">{config.font_size ?? 24}px</span>
</div>
</div>
<div className="wp-field">
<label className="wp-field-label"></label>
<div className="wp-color-row">
<input
className="wp-color-input"
type="color"
value={config.color ?? "#ffffff"}
onChange={(e) => handleColorChange(e.target.value)}
/>
<span className="wp-color-value">{config.color ?? "#ffffff"}</span>
</div>
</div>
</div>
<TextWatermarkSection
text={config.text || ""}
fontSize={config.font_size ?? 24}
color={config.color ?? "#ffffff"}
onTextChange={handleTextChange}
onFontSizeChange={handleFontSizeChange}
onColorChange={handleColorChange}
/>
)}
{/* ── 滚动水印配置 ── */}
{config.type === "scroll" && (
<div className="wp-section">
<div className="wp-field">
<label className="wp-field-label"></label>
<textarea
className="wp-textarea"
placeholder="输入滚动水印文字"
rows={2}
value={config.text ?? ""}
onChange={(e) => handleTextChange(e.target.value)}
/>
</div>
<div className="wp-field">
<label className="wp-field-label"></label>
<select
className="wp-select"
value={config.scroll_direction ?? "horizontal"}
onChange={(e) => handleScrollDirectionChange(e.target.value as ScrollDirection)}
>
{SCROLL_DIRECTION_OPTIONS.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
</div>
<div className="wp-field">
<label className="wp-field-label"></label>
<div className="wp-slider-row">
<input
className="wp-slider"
type="range"
min={10}
max={200}
value={config.scroll_speed ?? 50}
onChange={(e) => handleScrollSpeedChange(Number(e.target.value))}
/>
<span className="wp-slider-value">{config.scroll_speed ?? 50}px/s</span>
</div>
</div>
<div className="wp-field">
<label className="wp-field-label"></label>
<div className="wp-slider-row">
<input
className="wp-slider"
type="range"
min={12}
max={72}
value={config.font_size ?? 24}
onChange={(e) => handleFontSizeChange(Number(e.target.value))}
/>
<span className="wp-slider-value">{config.font_size ?? 24}px</span>
</div>
</div>
<div className="wp-field">
<label className="wp-field-label"></label>
<div className="wp-color-row">
<input
className="wp-color-input"
type="color"
value={config.color ?? "#ffffff"}
onChange={(e) => handleColorChange(e.target.value)}
/>
<span className="wp-color-value">{config.color ?? "#ffffff"}</span>
</div>
</div>
</div>
<ScrollWatermarkSection
text={config.text || ""}
scrollDirection={config.scroll_direction ?? "horizontal"}
scrollSpeed={config.scroll_speed ?? 50}
fontSize={config.font_size ?? 24}
color={config.color ?? "#ffffff"}
onTextChange={handleTextChange}
onScrollDirectionChange={handleScrollDirectionChange}
onScrollSpeedChange={handleScrollSpeedChange}
onFontSizeChange={handleFontSizeChange}
onColorChange={handleColorChange}
/>
)}
{/* ── 通用设置(非 none 时显示) ── */}
{config.type !== "none" && (
<div className="wp-section wp-common-section">
<div className="wp-section-divider" />
<div className="wp-common-title"></div>
<div className="wp-field">
<label className="wp-field-label"></label>
<select
className="wp-select"
value={config.position}
onChange={(e) => handlePositionChange(e.target.value as WatermarkPosition)}
>
{POSITION_OPTIONS.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
</div>
<div className="wp-field">
<label className="wp-field-label"></label>
<div className="wp-slider-row">
<input
className="wp-slider"
type="range"
min={0}
max={1}
step={0.05}
value={config.opacity}
onChange={(e) => handleOpacityChange(Number(e.target.value))}
/>
<span className="wp-slider-value">{Math.round(config.opacity * 100)}%</span>
</div>
</div>
</div>
<WatermarkCommonSection
position={config.position}
opacity={config.opacity}
onPositionChange={handlePositionChange}
onOpacityChange={handleOpacityChange}
/>
)}
{/* ── 底部操作 ── */}
@@ -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<BgmSettingsSectionProps> = ({ settings, onOpenBgmDrawer }) => {
return (
<div className="ep-settings-section">
<div className="ep-section-title">
<span className="ep-section-icon">🎵</span>
BGM
</div>
{settings.enabled && settings.music_id ? (
<div className="ep-bgm-current">
<span className="ep-bgm-current-label">🎵 BGM</span>
<span className="ep-bgm-current-id">{settings.music_id}</span>
{settings.volume !== undefined && (
<span className="ep-bgm-current-vol"> {settings.volume}%</span>
)}
</div>
) : (
<div className="ep-bgm-empty"></div>
)}
{onOpenBgmDrawer && (
<button className="ep-advanced-btn" onClick={onOpenBgmDrawer}>
🎵 {settings.enabled ? "更换 BGM / 调整混音" : "选择 BGM 音乐"}
</button>
)}
</div>
)
}
export default BgmSettingsSection
@@ -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<ClipData>) => 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<ClipDetailSectionProps> = ({
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 (
<div className="ep-settings-section">
<div className="ep-section-title">
<span className="ep-section-icon">🎞</span>
</div>
<div className="ep-clip-detail">
{/* 类型选择器 */}
<div className="ep-clip-detail-field">
<div className="ep-clip-detail-label"></div>
<div className="ep-clip-type-selector">
{(["voice", "pip"] as ClipType[]).map((t) => {
const disabled = isTypeDisabled(t)
return (
<button
key={t}
className={`ep-clip-type-btn${clip.type === t ? " active" : ""}${disabled ? " disabled" : ""}`}
disabled={disabled}
onClick={() => !disabled && onClipUpdate(clip.id, { type: t })}
>
{CLIP_TYPE_ICONS[t]} {CLIP_TYPE_LABELS[t]}
</button>
)
})}
</div>
</div>
{/* 时长 */}
<div className="ep-clip-detail-field">
<div className="ep-clip-detail-label"></div>
<div className="ep-clip-detail-row">
<input
className="ep-duration-input"
type="number"
min={1}
max={120}
value={clip.duration}
onChange={(e) =>
onClipUpdate(clip.id, {
duration: Math.max(1, Math.min(120, Number(e.target.value) || 1)),
})
}
/>
<span className="ep-clip-detail-unit"></span>
</div>
</div>
{/* 转场效果入口 */}
{onOpenTransitionDrawer && (
<div className="ep-clip-detail-field">
<button
className="ep-advanced-btn ep-advanced-btn--transition"
onClick={() => onOpenTransitionDrawer(clip.id)}
>
<span className="ep-advanced-btn-icon">🎬</span>
<span className="ep-advanced-btn-label"></span>
<span className="ep-advanced-btn-value">{transitionLabel}</span>
<span className="ep-advanced-btn-arrow"></span>
</button>
</div>
)}
{/* 播放速度入口 */}
{onOpenSpeedDrawer && (
<div className="ep-clip-detail-field">
<button
className="ep-advanced-btn ep-advanced-btn--speed"
onClick={() => onOpenSpeedDrawer(clip.id)}
>
<span className="ep-advanced-btn-icon"></span>
<span className="ep-advanced-btn-label"></span>
<span className="ep-advanced-btn-value">{speedLabel}</span>
<span className="ep-advanced-btn-arrow"></span>
</button>
</div>
)}
{/* TTS 配音入口 */}
{onOpenTtsDrawer && (
<div className="ep-clip-detail-field">
<button
className="ep-advanced-btn ep-advanced-btn--tts"
onClick={() => onOpenTtsDrawer(clip.id)}
>
<span className="ep-advanced-btn-icon">🎙</span>
<span className="ep-advanced-btn-label">TTS </span>
<span className="ep-advanced-btn-value">{ttsLabel}</span>
<span className="ep-advanced-btn-arrow"></span>
</button>
</div>
)}
{/* 素材起始时间 — 仅 voice 类型显示 */}
{clip.type === "voice" && (
<div className="ep-clip-detail-field">
<div className="ep-clip-detail-label"></div>
<div className="ep-clip-detail-row">
<input
className="ep-duration-input"
type="number"
min={0}
max={9999}
step={0.1}
value={clip.startOffset}
onChange={(e) =>
onClipUpdate(clip.id, {
startOffset: Math.max(0, Math.min(9999, Number(e.target.value) || 0)),
})
}
/>
<span className="ep-clip-detail-unit"></span>
</div>
</div>
)}
{/* 配音素材选择 — 仅 voice 类型显示 */}
{clip.type === "voice" && (
<div className="ep-clip-detail-field">
<div className="ep-clip-detail-label">
{onRefreshVoiceMaterials && (
<button
type="button"
className="ep-voice-refresh-btn"
title="刷新配音列表"
onClick={() => onRefreshVoiceMaterials()}
disabled={voiceMaterialsLoading}
>
{voiceMaterialsLoading ? "⏳" : "🔄"}
</button>
)}
</div>
{voiceMaterialsLoading && voiceMaterials.length === 0 ? (
<div className="ep-voice-loading">...</div>
) : (
<>
<div className="ep-voice-select-row">
<select
className="ep-clip-detail-select"
value={clip.voice_asset_id ?? ""}
onChange={(e) => {
const assetId = e.target.value
if (!onClipVoiceSelect) return
if (!assetId) {
onClipVoiceSelect(clip.id, null)
} else {
const asset = voiceMaterials.find((m) => m.id === assetId)
if (asset) onClipVoiceSelect(clip.id, asset)
}
onStopPreview()
}}
>
<option value=""></option>
{voiceMaterials.map((m) => {
const gender = getGenderLabel(m)
const label = gender ? `${m.name}${gender}` : m.name
return (
<option key={m.id} value={m.id}>
{label}
</option>
)
})}
</select>
{clip.voice_asset_id && (
<button
type="button"
className="ep-voice-preview-btn"
title={previewingId === clip.voice_asset_id ? "暂停" : "试听"}
onClick={() => {
const asset = voiceMaterials.find((m) => m.id === clip.voice_asset_id)
if (asset) onPreviewVoice(asset)
}}
>
{previewingId === clip.voice_asset_id ? "⏸" : "▶️"}
</button>
)}
</div>
{voiceMaterials.length === 0 && (
<div className="ep-voice-empty"></div>
)}
</>
)}
<button
className="ep-voice-upload-btn"
onClick={() => navigate("/app/voice-materials")}
>
+
</button>
</div>
)}
</div>
</div>
)
}
export default ClipDetailSection
@@ -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<StatsSectionProps> = ({ clipsCount, totalDuration, currentMode }) => {
return (
<div className="ep-settings-section">
<div className="ep-section-title">
<span className="ep-section-icon">📊</span>
</div>
<div className="ep-clip-detail">
<div className="ep-clip-detail-field">
<div className="ep-clip-detail-label"></div>
<div className="ep-clip-detail-value">{clipsCount}</div>
</div>
<div className="ep-clip-detail-field">
<div className="ep-clip-detail-label"></div>
<div className="ep-clip-detail-value">{totalDuration.toFixed(1)}s</div>
</div>
<div className="ep-clip-detail-field">
<div className="ep-clip-detail-label"></div>
<div className="ep-clip-detail-value">{formatModeLabel(currentMode)}</div>
</div>
</div>
</div>
)
}
export default StatsSection
@@ -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<SubtitleSettings>) => void
onOpenSubtitleDrawer?: () => void
}
const SubtitleSettingsSection: React.FC<SubtitleSettingsSectionProps> = ({
settings,
onChange,
onOpenSubtitleDrawer,
}) => {
return (
<div className="ep-settings-section">
<div className="ep-section-title">
<span className="ep-section-icon">💬</span>
</div>
<div className="ep-toggle-row">
<span className="ep-toggle-label"></span>
<div
className={`ep-toggle ${settings.enabled ? "active" : ""}`}
onClick={() => onChange({ enabled: !settings.enabled })}
>
<div className="ep-toggle-knob" />
</div>
</div>
{settings.enabled && (
<>
<div className="ep-field">
<label className="ep-field-label"></label>
<select
className="ep-form-select"
value={settings.position}
onChange={(e) => onChange({ position: e.target.value })}
>
{POSITION_OPTIONS.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
</div>
<div className="ep-field">
<label className="ep-field-label"></label>
<select
className="ep-form-select"
value={settings.font}
onChange={(e) => onChange({ font: e.target.value })}
>
{FONT_OPTIONS.map((f) => (
<option key={f} value={f}>
{f}
</option>
))}
</select>
</div>
<div className="ep-field">
<label className="ep-field-label"></label>
<div className="ep-slider-row">
<input
className="ep-slider"
type="range"
min={12}
max={48}
value={settings.fontSize}
onChange={(e) => onChange({ fontSize: Number(e.target.value) })}
/>
<span className="ep-slider-value">{settings.fontSize}px</span>
</div>
</div>
<div className="ep-field">
<label className="ep-field-label"></label>
<select
className="ep-form-select"
value={settings.animation}
onChange={(e) => onChange({ animation: e.target.value })}
>
{ANIMATION_OPTIONS.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
</div>
{onOpenSubtitleDrawer && (
<button className="ep-advanced-btn" onClick={onOpenSubtitleDrawer}>
🎨
</button>
)}
</>
)}
</div>
)
}
export default SubtitleSettingsSection
@@ -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<FilterManualAdjustProps> = ({ config, onChange }) => {
return (
<div className="filter-section">
<div className="filter-section-title"></div>
{MANUAL_ADJUST_ITEMS.map((item) => (
<div key={item.key} className="filter-slider-row">
<span className="filter-slider-label">{item.label}</span>
<input
type="range"
className="filter-slider"
min={item.min}
max={item.max}
value={config[item.key as FilterKey]}
onChange={(e) => onChange(item.key as FilterKey, Number(e.target.value))}
/>
<span className="filter-slider-value">{config[item.key as FilterKey]}</span>
</div>
))}
</div>
)
}
export default FilterManualAdjust
@@ -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<FilterPresetGridProps> = ({
selectedPreset,
onPresetSelect,
}) => {
return (
<div className="filter-section">
<div className="filter-section-title"></div>
<div className="filter-presets">
{PRESET_LIST.map((p) => (
<button
key={p}
className={`filter-preset-item${selectedPreset === p ? " active" : ""}`}
onClick={() => onPresetSelect(p)}
>
<div
className="filter-preset-preview"
style={{ background: PRESET_GRADIENTS[p] }}
/>
<span className="filter-preset-label">{FILTER_PRESET_LABELS[p]}</span>
</button>
))}
</div>
</div>
)
}
export default FilterPresetGrid
@@ -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<IntroOutroItem>) => void
}
const IntroOutroBlock: React.FC<IntroOutroBlockProps> = ({
title,
icon,
item,
transitionLabel,
onKindChange,
onChange,
}) => {
const hasContent = item.kind !== "none"
return (
<div className="iop-block">
<div className="iop-block-header">
<span className="iop-block-icon">{icon}</span>
<span className="iop-block-title">{title}</span>
</div>
{/* 类型选择 */}
<div className="iop-kind-row">
{KIND_OPTIONS.map((opt) => (
<button
key={opt.value}
className={`iop-kind-btn${item.kind === opt.value ? " active" : ""}`}
onClick={() => onKindChange(opt.value)}
>
<span className="iop-kind-icon">{opt.icon}</span>
<span className="iop-kind-label">{opt.label}</span>
</button>
))}
</div>
{/* 视频/图片配置 */}
{hasContent && (
<>
<div className="iop-field">
<label className="iop-field-label">
{item.kind === "video" ? "视频" : "图片"} URL
</label>
<input
className="iop-input"
type="text"
placeholder={
item.kind === "video"
? `https://example.com/${title}.mp4`
: `https://example.com/${title}.png`
}
value={item.url ?? ""}
onChange={(e) => onChange({ url: e.target.value })}
/>
</div>
<div className="iop-field">
<label className="iop-field-label"></label>
<div className="iop-slider-row">
<input
className="iop-slider"
type="range"
min={1}
max={15}
step={0.5}
value={item.duration}
onChange={(e) => onChange({ duration: Number(e.target.value) })}
/>
<span className="iop-slider-value">{item.duration}s</span>
</div>
</div>
<div className="iop-field">
<label className="iop-field-label">{transitionLabel}</label>
<select
className="iop-select"
value={item.transition ?? "none"}
onChange={(e) => onChange({ transition: e.target.value as TransitionType })}
>
{TRANSITION_OPTIONS.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
</div>
{item.transition && item.transition !== "none" && (
<div className="iop-field">
<label className="iop-field-label"></label>
<div className="iop-slider-row">
<input
className="iop-slider"
type="range"
min={0.3}
max={2.0}
step={0.1}
value={item.transition_duration ?? 0.5}
onChange={(e) => onChange({ transition_duration: Number(e.target.value) })}
/>
<span className="iop-slider-value">
{(item.transition_duration ?? 0.5).toFixed(1)}s
</span>
</div>
</div>
)}
</>
)}
</div>
)
}
export default IntroOutroBlock
@@ -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<PipLayer>) => void
onGridClick: (pos: any) => void
onWidthChange: (val: number) => void
onHeightChange: (val: number) => void
}
const LayerConfig: React.FC<LayerConfigProps> = ({
layer,
layers,
totalDuration,
onUpdate,
onGridClick,
onWidthChange,
onHeightChange,
}) => {
if (!layer) {
return (
<div className="pip-config-area">
<div className="pip-config-empty"></div>
</div>
)
}
return (
<div className="pip-config-area">
{/* ── 迷你预览 ── */}
<div className="pip-preview-box">
{layers.map((l, idx) => (
<div
key={l.id}
className={`pip-preview-layer${layer.id === l.id ? " selected" : ""}`}
style={{
left: `${l.x}%`,
top: `${l.y}%`,
width: `${l.width}%`,
height: `${l.height}%`,
background: LAYER_COLORS[idx % LAYER_COLORS.length],
opacity: l.opacity / 100,
borderRadius: `${l.border_radius}%`,
}}
>
<span className="pip-preview-label">{l.name}</span>
</div>
))}
</div>
{/* ── 素材类型 ── */}
<div className="pip-field">
<label className="pip-field-label"></label>
<div className="pip-type-btns">
<button
className={`pip-type-btn${layer.material_type === "image" ? " active" : ""}`}
onClick={() => onUpdate(layer.id, { material_type: "image" })}
>
🖼
</button>
<button
className={`pip-type-btn${layer.material_type === "video" ? " active" : ""}`}
onClick={() => onUpdate(layer.id, { material_type: "video" })}
>
🎬
</button>
</div>
</div>
{/* ── 素材 URL ── */}
<div className="pip-field">
<label className="pip-field-label">
{layer.material_type === "image" ? "图片" : "视频"} URL
</label>
<input
className="pip-input"
type="text"
placeholder={
layer.material_type === "image"
? "https://example.com/image.png"
: "https://example.com/video.mp4"
}
value={layer.material_url}
onChange={(e) => onUpdate(layer.id, { material_url: e.target.value })}
/>
</div>
{/* ── 位置:九宫格 + 坐标 ── */}
<div className="pip-field">
<label className="pip-field-label"></label>
<div style={{ display: "flex", gap: 16, alignItems: "flex-start" }}>
<div className="pip-grid">
{GRID_POSITIONS.map((pos) => (
<button
key={pos}
className={`pip-grid-btn${layer.grid_position === pos ? " active" : ""}`}
onClick={() => onGridClick(pos)}
>
<span className="pip-grid-dot" />
</button>
))}
</div>
<div className="pip-field-row" style={{ flex: 1 }}>
<div>
<label className="pip-field-label">X (%)</label>
<input
className="pip-number"
type="number"
min={0}
max={100}
value={layer.x}
onChange={(e) => onUpdate(layer.id, { x: Number(e.target.value) })}
/>
</div>
<div>
<label className="pip-field-label">Y (%)</label>
<input
className="pip-number"
type="number"
min={0}
max={100}
value={layer.y}
onChange={(e) => onUpdate(layer.id, { y: Number(e.target.value) })}
/>
</div>
</div>
</div>
</div>
{/* ── 尺寸 ── */}
<div className="pip-field">
<label className="pip-field-label"></label>
<div className="pip-slider-row">
<span style={{ fontSize: 12, color: "#999", width: 20 }}></span>
<input
className="pip-slider"
type="range"
min={10}
max={80}
value={layer.width}
onChange={(e) => onWidthChange(Number(e.target.value))}
/>
<span className="pip-slider-value">{layer.width}%</span>
</div>
<div className="pip-slider-row" style={{ marginTop: 6 }}>
<span style={{ fontSize: 12, color: "#999", width: 20 }}></span>
<input
className="pip-slider"
type="range"
min={10}
max={80}
value={layer.height}
onChange={(e) => onHeightChange(Number(e.target.value))}
/>
<span className="pip-slider-value">{layer.height}%</span>
</div>
<div
className="pip-lock-row"
style={{ marginTop: 6 }}
onClick={() => onUpdate(layer.id, { aspect_lock: !layer.aspect_lock })}
>
<span className="pip-lock-icon">{layer.aspect_lock ? "🔒" : "🔓"}</span>
<span>{layer.aspect_lock ? "已锁定比例" : "锁定宽高比"}</span>
</div>
</div>
{/* ── 圆角 ── */}
<div className="pip-field">
<label className="pip-field-label"></label>
<div className="pip-slider-row">
<input
className="pip-slider"
type="range"
min={0}
max={50}
value={layer.border_radius}
onChange={(e) => onUpdate(layer.id, { border_radius: Number(e.target.value) })}
/>
<span className="pip-slider-value">{layer.border_radius}%</span>
</div>
</div>
{/* ── 透明度 ── */}
<div className="pip-field">
<label className="pip-field-label"></label>
<div className="pip-slider-row">
<input
className="pip-slider"
type="range"
min={0}
max={100}
value={layer.opacity}
onChange={(e) => onUpdate(layer.id, { opacity: Number(e.target.value) })}
/>
<span className="pip-slider-value">{layer.opacity}%</span>
</div>
</div>
{/* ── 时间 ── */}
<div className="pip-field">
<label className="pip-field-label"></label>
<div className="pip-field-row">
<div>
<label className="pip-field-label"> (s)</label>
<input
className="pip-number"
type="number"
min={0}
max={totalDuration || 999}
step={0.1}
value={layer.start_time}
onChange={(e) => onUpdate(layer.id, { start_time: Number(e.target.value) })}
/>
</div>
<div>
<label className="pip-field-label"> (s)</label>
<input
className="pip-number"
type="number"
min={0.1}
max={totalDuration || 999}
step={0.1}
value={layer.duration}
onChange={(e) => onUpdate(layer.id, { duration: Number(e.target.value) })}
/>
</div>
</div>
</div>
{/* ── 入场动画 ── */}
<div className="pip-field">
<label className="pip-field-label"></label>
<select
className="pip-select"
value={layer.animation}
onChange={(e) => onUpdate(layer.id, { animation: e.target.value as PipAnimType })}
>
{ANIM_OPTIONS.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
</div>
{/* 滑入方向(仅 slide_in 时显示) */}
{layer.animation === "slide_in" && (
<div className="pip-field">
<label className="pip-field-label"></label>
<select
className="pip-select"
value={layer.slide_direction}
onChange={(e) =>
onUpdate(layer.id, { slide_direction: e.target.value as PipSlideDirection })
}
>
{SLIDE_DIR_OPTIONS.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
</div>
)}
</div>
)
}
export default LayerConfig
@@ -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<LayerListProps> = ({ layers, selectedId, onSelect, onAdd, onDelete }) => {
return (
<div className="pip-layer-list">
<div className="pip-toolbar" style={{ marginBottom: 8 }}>
<button className="pip-add-btn" onClick={onAdd}>
+
</button>
</div>
{layers.length === 0 ? (
<div className="pip-layer-empty"></div>
) : (
layers.map((layer, idx) => (
<div
key={layer.id}
className={`pip-layer-item${selectedId === layer.id ? " active" : ""}`}
onClick={() => onSelect(layer.id)}
>
{layer.thumbnail_url || layer.material_url ? (
<img
className="pip-layer-thumb"
src={layer.thumbnail_url || layer.material_url}
alt={layer.name}
/>
) : (
<div
className="pip-layer-thumb"
style={{
background: LAYER_COLORS[idx % LAYER_COLORS.length],
}}
/>
)}
<span className="pip-layer-name">{layer.name}</span>
<button
className="pip-layer-delete"
onClick={(e) => {
e.stopPropagation()
onDelete(layer.id)
}}
title="删除图层"
>
</button>
</div>
))
)}
</div>
)
}
export default LayerList
@@ -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<StickerLibraryProps> = ({
activeTab,
onTabChange,
onAddSticker,
}) => {
const [textInput, setTextInput] = useState("")
const imageInputRef = React.useRef<HTMLInputElement>(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 */}
<div className="sticker-tabs">
{STICKER_TYPE_TABS.map((t) => (
<button
key={t.value}
className={`sticker-tab${activeTab === t.value ? " active" : ""}`}
onClick={() => onTabChange(t.value)}
>
{t.label}
</button>
))}
</div>
{/* Tab 内容区 */}
<div className="sticker-tab-content">
{/* Emoji 素材库 */}
{activeTab === "emoji" && (
<div className="sticker-emoji-grid">
{EMOJI_LIST.map((emoji) => (
<button
key={emoji}
className="sticker-emoji-btn"
onClick={() => onAddSticker("emoji", emoji)}
>
{emoji}
</button>
))}
</div>
)}
{/* 图片贴纸 */}
{activeTab === "image" && (
<div className="sticker-image-input">
<input
ref={imageInputRef}
type="text"
className="sticker-url-input"
placeholder="输入图片 URL 添加贴纸..."
onKeyDown={(e) => {
if (e.key === "Enter" && e.currentTarget.value.trim()) {
handleAddImage()
}
}}
/>
<button className="sticker-url-add-btn" onClick={handleAddImage}>
</button>
</div>
)}
{/* 文字花字 */}
{activeTab === "text" && (
<div className="sticker-text-section">
<div className="sticker-text-input-row">
<input
type="text"
className="sticker-text-input"
placeholder="输入文字内容..."
value={textInput}
onChange={(e) => setTextInput(e.target.value)}
/>
<button
className="sticker-text-add-btn"
disabled={!textInput.trim()}
onClick={handleAddText}
>
</button>
</div>
<div className="sticker-text-presets">
<div className="sticker-preset-title"></div>
<div className="sticker-preset-grid">
{(Object.keys(TEXT_STICKER_PRESET_LABELS) as TextStickerPreset[]).map((p) => (
<div
key={p}
className="sticker-preset-preview"
style={{
background:
p === "bubble"
? "rgba(0,0,0,0.5)"
: p === "gradient"
? "linear-gradient(90deg,#f093fb,#f5576c)"
: "#1a1a2e",
}}
>
<span style={TEXT_PRESET_STYLES[p]}></span>
<div className="sticker-preset-name">{TEXT_STICKER_PRESET_LABELS[p]}</div>
</div>
))}
</div>
</div>
</div>
)}
</div>
</>
)
}
export default StickerLibrary
@@ -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<StickerListProps> = ({ 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 (
<div className="sticker-list-section">
<div className="sticker-section-title"> ({items.length})</div>
<div className="sticker-list">
{items.map((item) => {
const { icon, name } = getDisplayContent(item)
return (
<div
key={item.id}
className={`sticker-list-item${selectedId === item.id ? " active" : ""}`}
onClick={() => onSelect(item.id)}
>
<span className="sticker-list-icon">{icon}</span>
<span className="sticker-list-name">{name}</span>
<button
className="sticker-list-delete"
onClick={(e) => {
e.stopPropagation()
onDelete(item.id)
}}
>
</button>
</div>
)
})}
</div>
</div>
)
}
export default StickerList
@@ -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<StickerItem>) => void
}
const StickerPropsEditor: React.FC<StickerPropsEditorProps> = ({
sticker,
totalDuration,
onUpdate,
}) => {
return (
<div className="sticker-props-section">
<div className="sticker-section-title"></div>
{/* 位置 */}
<div className="sticker-prop-row">
<span className="sticker-prop-label"> X</span>
<input
type="range"
className="sticker-prop-slider"
min={0}
max={100}
value={sticker.x}
onChange={(e) => onUpdate(sticker.id, { x: Number(e.target.value) })}
/>
<span className="sticker-prop-value">{sticker.x}%</span>
</div>
<div className="sticker-prop-row">
<span className="sticker-prop-label"> Y</span>
<input
type="range"
className="sticker-prop-slider"
min={0}
max={100}
value={sticker.y}
onChange={(e) => onUpdate(sticker.id, { y: Number(e.target.value) })}
/>
<span className="sticker-prop-value">{sticker.y}%</span>
</div>
{/* 尺寸 */}
<div className="sticker-prop-row">
<span className="sticker-prop-label"></span>
<input
type="range"
className="sticker-prop-slider"
min={5}
max={50}
value={sticker.width}
onChange={(e) =>
onUpdate(sticker.id, {
width: Number(e.target.value),
height: Number(e.target.value),
})
}
/>
<span className="sticker-prop-value">{sticker.width}%</span>
</div>
{/* 旋转 */}
<div className="sticker-prop-row">
<span className="sticker-prop-label"></span>
<input
type="range"
className="sticker-prop-slider"
min={-180}
max={180}
value={sticker.rotation}
onChange={(e) => onUpdate(sticker.id, { rotation: Number(e.target.value) })}
/>
<span className="sticker-prop-value">{sticker.rotation}°</span>
</div>
{/* 透明度 */}
<div className="sticker-prop-row">
<span className="sticker-prop-label"></span>
<input
type="range"
className="sticker-prop-slider"
min={0}
max={100}
value={sticker.opacity}
onChange={(e) => onUpdate(sticker.id, { opacity: Number(e.target.value) })}
/>
<span className="sticker-prop-value">{sticker.opacity}%</span>
</div>
{/* 时间 */}
<div className="sticker-prop-row">
<span className="sticker-prop-label"></span>
<input
type="number"
className="sticker-prop-number"
min={0}
max={totalDuration}
step={0.1}
value={sticker.start_time}
onChange={(e) => onUpdate(sticker.id, { start_time: Number(e.target.value) })}
/>
<span className="sticker-prop-label"></span>
<input
type="number"
className="sticker-prop-number"
min={0}
max={totalDuration}
step={0.1}
value={sticker.duration}
onChange={(e) => onUpdate(sticker.id, { duration: Number(e.target.value) })}
/>
</div>
{/* 文字贴纸特有属性 */}
{sticker.type === "text" && (
<>
<div className="sticker-prop-row">
<span className="sticker-prop-label"></span>
<select
className="sticker-prop-select"
value={sticker.text_preset}
onChange={(e) =>
onUpdate(sticker.id, { text_preset: e.target.value as TextStickerPreset })
}
>
{(Object.keys(TEXT_STICKER_PRESET_LABELS) as TextStickerPreset[]).map((p) => (
<option key={p} value={p}>
{TEXT_STICKER_PRESET_LABELS[p]}
</option>
))}
</select>
</div>
<div className="sticker-prop-row">
<span className="sticker-prop-label"></span>
<input
type="range"
className="sticker-prop-slider"
min={12}
max={72}
value={sticker.font_size}
onChange={(e) => onUpdate(sticker.id, { font_size: Number(e.target.value) })}
/>
<span className="sticker-prop-value">{sticker.font_size}px</span>
</div>
<div className="sticker-prop-row">
<span className="sticker-prop-label"></span>
<input
type="color"
className="sticker-prop-color"
value={sticker.text_color}
onChange={(e) => onUpdate(sticker.id, { text_color: e.target.value })}
/>
</div>
</>
)}
{/* 预览 */}
<div className="sticker-preview-box">
<div
className="sticker-preview-item"
style={{
left: `${sticker.x}%`,
top: `${sticker.y}%`,
width: `${sticker.width}%`,
height: `${sticker.width}%`,
transform: `translate(-50%, -50%) rotate(${sticker.rotation}deg)`,
opacity: sticker.opacity / 100,
fontSize: sticker.type === "text" ? `${sticker.font_size}px` : undefined,
...TEXT_PRESET_STYLES[sticker.text_preset],
}}
>
{sticker.type === "emoji" && sticker.content}
{sticker.type === "text" && sticker.content}
{sticker.type === "image" && (
<img
src={sticker.content}
alt="sticker"
style={{
width: "100%",
height: "100%",
objectFit: "contain",
}}
/>
)}
</div>
</div>
</div>
)
}
export default StickerPropsEditor
@@ -0,0 +1,37 @@
/**
* 字幕预览组件
*/
import React from "react"
import type { SubtitleStyleConfig } from "../../types/subtitle"
interface SubtitlePreviewProps {
config: SubtitleStyleConfig
previewText?: string
}
const SubtitlePreview: React.FC<SubtitlePreviewProps> = ({
config,
previewText = "这是一段字幕预览",
}) => {
return (
<div className="sub-field">
<label className="sub-label"></label>
<div className="sub-preview-box">
<span
className="sub-preview-text"
style={{
fontSize: `${Math.min(config.fontSize, 28)}px`,
color: config.fontColor,
fontFamily: config.font,
WebkitTextStroke: config.stroke ? "1px #000" : undefined,
textShadow: config.shadow ? "2px 2px 4px rgba(0,0,0,0.8)" : undefined,
}}
>
{previewText}
</span>
</div>
</div>
)
}
export default SubtitlePreview
@@ -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<TtsSliderProps> = ({
label,
value,
min,
max,
step,
unit = "",
onChange,
marks,
tooltipFormatter,
}) => {
const displayValue =
unit === "x"
? `${value.toFixed(2)}x`
: label === "语调"
? `${value > 0 ? "+" : ""}${value} 半音`
: `${value}${unit}`
return (
<div className="tts-slider-section">
<div className="tts-slider-header">
<span className="tts-slider-label">{label}</span>
<span className="tts-slider-value">{displayValue}</span>
</div>
<Slider
min={min}
max={max}
step={step}
value={value}
onChange={(v) => onChange(v as number)}
tooltip={tooltipFormatter ? { formatter: (v) => tooltipFormatter(v as number) } : false}
/>
{marks && (
<div className="tts-slider-marks">
{marks.map((m, i) => (
<span key={i}>{m}</span>
))}
</div>
)}
</div>
)
}
export default TtsSlider
@@ -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<VoiceSelectorProps> = ({
voices,
voicesLoading,
selectedVoiceId,
onVoiceSelect,
}) => {
const voiceCategories = Object.entries(VOICE_CATEGORY_MAP)
return (
<div className="tts-voice-section">
<div className="tts-voice-label">
{voicesLoading && <span className="tts-voice-loading">...</span>}
</div>
<div className="tts-voice-grid">
{voiceCategories.map(([cat, info]) => {
const voice = voices.find((v) => v.category === cat)
const isSelected = voice && selectedVoiceId === voice.id
return (
<button
key={cat}
className={`tts-voice-card${isSelected ? " active" : ""}`}
onClick={() => voice && onVoiceSelect(voice.id)}
disabled={!voice || voicesLoading}
>
<span className="tts-voice-card-icon">{info.icon}</span>
<span className="tts-voice-card-name">{voice?.name || info.label}</span>
</button>
)
})}
</div>
</div>
)
}
export default VoiceSelector
@@ -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<ImageWatermarkSectionProps> = ({
imageUrl,
localImageUrl,
width,
height,
onImageUrlChange,
onWidthChange,
onHeightChange,
}) => {
const displayUrl = localImageUrl || imageUrl || ""
return (
<div className="wp-section">
<div className="wp-field">
<label className="wp-field-label"> URL</label>
<input
className="wp-input"
type="text"
placeholder="https://example.com/logo.png"
value={displayUrl}
onChange={(e) => onImageUrlChange(e.target.value)}
/>
{displayUrl && (
<div className="wp-image-preview">
<img
src={displayUrl}
alt="水印预览"
onError={(e) => {
;(e.target as HTMLImageElement).style.display = "none"
}}
/>
</div>
)}
</div>
<div className="wp-field">
<label className="wp-field-label">0 </label>
<input
className="wp-input"
type="number"
min={0}
max={2000}
value={width ?? 0}
onChange={(e) => onWidthChange(Number(e.target.value))}
/>
</div>
<div className="wp-field">
<label className="wp-field-label">0 </label>
<input
className="wp-input"
type="number"
min={0}
max={2000}
value={height ?? 0}
onChange={(e) => onHeightChange(Number(e.target.value))}
/>
</div>
</div>
)
}
export default ImageWatermarkSection
@@ -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<ScrollWatermarkSectionProps> = ({
text,
scrollDirection,
scrollSpeed,
fontSize,
color,
onTextChange,
onScrollDirectionChange,
onScrollSpeedChange,
onFontSizeChange,
onColorChange,
}) => {
return (
<div className="wp-section">
<div className="wp-field">
<label className="wp-field-label"></label>
<textarea
className="wp-textarea"
placeholder="输入滚动水印文字"
rows={2}
value={text ?? ""}
onChange={(e) => onTextChange(e.target.value)}
/>
</div>
<div className="wp-field">
<label className="wp-field-label"></label>
<select
className="wp-select"
value={scrollDirection ?? "horizontal"}
onChange={(e) => onScrollDirectionChange(e.target.value as ScrollDirection)}
>
{SCROLL_DIRECTION_OPTIONS.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
</div>
<div className="wp-field">
<label className="wp-field-label"></label>
<div className="wp-slider-row">
<input
className="wp-slider"
type="range"
min={10}
max={200}
value={scrollSpeed ?? 50}
onChange={(e) => onScrollSpeedChange(Number(e.target.value))}
/>
<span className="wp-slider-value">{scrollSpeed ?? 50}px/s</span>
</div>
</div>
<div className="wp-field">
<label className="wp-field-label"></label>
<div className="wp-slider-row">
<input
className="wp-slider"
type="range"
min={12}
max={72}
value={fontSize ?? 24}
onChange={(e) => onFontSizeChange(Number(e.target.value))}
/>
<span className="wp-slider-value">{fontSize ?? 24}px</span>
</div>
</div>
<div className="wp-field">
<label className="wp-field-label"></label>
<div className="wp-color-row">
<input
className="wp-color-input"
type="color"
value={color ?? "#ffffff"}
onChange={(e) => onColorChange(e.target.value)}
/>
<span className="wp-color-value">{color ?? "#ffffff"}</span>
</div>
</div>
</div>
)
}
export default ScrollWatermarkSection
@@ -0,0 +1,67 @@
/**
* 文字水印配置组件
*/
import React from "react"
interface TextWatermarkSectionProps {
text: string | undefined
fontSize: number | undefined
color: string | undefined
onTextChange: (text: string) => void
onFontSizeChange: (size: number) => void
onColorChange: (color: string) => void
}
const TextWatermarkSection: React.FC<TextWatermarkSectionProps> = ({
text,
fontSize,
color,
onTextChange,
onFontSizeChange,
onColorChange,
}) => {
return (
<div className="wp-section">
<div className="wp-field">
<label className="wp-field-label"></label>
<textarea
className="wp-textarea"
placeholder="输入水印文字内容"
rows={3}
value={text ?? ""}
onChange={(e) => onTextChange(e.target.value)}
/>
</div>
<div className="wp-field">
<label className="wp-field-label"></label>
<div className="wp-slider-row">
<input
className="wp-slider"
type="range"
min={12}
max={72}
value={fontSize ?? 24}
onChange={(e) => onFontSizeChange(Number(e.target.value))}
/>
<span className="wp-slider-value">{fontSize ?? 24}px</span>
</div>
</div>
<div className="wp-field">
<label className="wp-field-label"></label>
<div className="wp-color-row">
<input
className="wp-color-input"
type="color"
value={color ?? "#ffffff"}
onChange={(e) => onColorChange(e.target.value)}
/>
<span className="wp-color-value">{color ?? "#ffffff"}</span>
</div>
</div>
</div>
)
}
export default TextWatermarkSection
@@ -0,0 +1,60 @@
/**
* 水印通用设置组件(位置 + 透明度)
*/
import React from "react"
import type { WatermarkPosition } from "../../types"
import { POSITION_OPTIONS } from "../../constants/watermark"
interface WatermarkCommonSectionProps {
position: WatermarkPosition
opacity: number
onPositionChange: (pos: WatermarkPosition) => void
onOpacityChange: (opacity: number) => void
}
const WatermarkCommonSection: React.FC<WatermarkCommonSectionProps> = ({
position,
opacity,
onPositionChange,
onOpacityChange,
}) => {
return (
<div className="wp-section wp-common-section">
<div className="wp-section-divider" />
<div className="wp-common-title"></div>
<div className="wp-field">
<label className="wp-field-label"></label>
<select
className="wp-select"
value={position}
onChange={(e) => onPositionChange(e.target.value as WatermarkPosition)}
>
{POSITION_OPTIONS.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
</div>
<div className="wp-field">
<label className="wp-field-label"></label>
<div className="wp-slider-row">
<input
className="wp-slider"
type="range"
min={0}
max={1}
step={0.05}
value={opacity}
onChange={(e) => onOpacityChange(Number(e.target.value))}
/>
<span className="wp-slider-value">{Math.round(opacity * 100)}%</span>
</div>
</div>
</div>
)
}
export default WatermarkCommonSection
@@ -0,0 +1,30 @@
/**
* 水印类型 Tab 组件
*/
import React from "react"
import type { WatermarkType } from "../../types"
import { WATERMARK_TABS } from "../../constants/watermark"
interface WatermarkTypeTabsProps {
activeTab: WatermarkType
onTypeChange: (type: WatermarkType) => void
}
const WatermarkTypeTabs: React.FC<WatermarkTypeTabsProps> = ({ activeTab, onTypeChange }) => {
return (
<div className="wp-tabs">
{WATERMARK_TABS.map((tab) => (
<button
key={tab.key}
className={`wp-tab${activeTab === tab.key ? " active" : ""}`}
onClick={() => onTypeChange(tab.key)}
>
<span className="wp-tab-icon">{tab.icon}</span>
<span className="wp-tab-label">{tab.label}</span>
</button>
))}
</div>
)
}
export default WatermarkTypeTabs
@@ -0,0 +1,34 @@
/**
* ClipPropertiesPanel 常量定义
*/
import type { ClipType } from "@/pages/editing-planner/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<ClipType, string> = { voice: "🎙️", pip: "🖼️" }
export const CLIP_TYPE_LABELS: Record<ClipType, string> = {
voice: "口播",
pip: "混剪",
}
@@ -0,0 +1,42 @@
/**
* FilterPanel 相关常量
*/
import type { FilterPreset } from "../types"
/** 所有预设列表 */
export const PRESET_LIST: FilterPreset[] = [
"none",
"original",
"fresh",
"warm",
"cool",
"vintage",
"cinema",
"bw",
"sunshine",
"film",
]
/** 预设对应的示例渐变色(用于视觉预览) */
export const PRESET_GRADIENTS: Record<FilterPreset, string> = {
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%)",
}
/** 手动调节项配置 */
export const MANUAL_ADJUST_ITEMS = [
{ key: "brightness", label: "亮度", min: -100, max: 100 },
{ key: "contrast", label: "对比度", min: -100, max: 100 },
{ key: "saturation", label: "饱和度", min: -100, max: 100 },
{ key: "temperature", label: "色温", min: -100, max: 100 },
{ key: "tint", label: "色调", min: -100, max: 100 },
{ key: "sharpness", label: "锐度", min: 0, max: 100 },
] as const
@@ -0,0 +1,10 @@
/**
* IntroOutroPanel 相关常量
*/
import type { IntroOutroKind } from "../types"
export const KIND_OPTIONS: { value: IntroOutroKind; label: string; icon: string }[] = [
{ value: "none", label: "无", icon: "🚫" },
{ value: "video", label: "视频", icon: "🎬" },
{ value: "image", label: "图片", icon: "🖼️" },
]
@@ -0,0 +1,55 @@
/**
* PipConfigPanel 常量定义
*/
import type { PipGridPosition, PipAnimType, PipSlideDirection } from "@/pages/editing-planner/types"
/** 九宫格位置 → 百分比坐标映射 */
export const GRID_POSITION_MAP: Record<PipGridPosition, { x: number; y: number }> = {
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)",
]
@@ -0,0 +1,64 @@
/**
* StickerPanel 常量定义
*/
import type { TextStickerPreset } from "@/pages/editing-planner/types"
import { TEXT_STICKER_PRESET_LABELS } from "@/pages/editing-planner/types"
export { TEXT_STICKER_PRESET_LABELS }
/** 常用 emoji 素材 */
export const EMOJI_LIST = [
"😀",
"😂",
"🥰",
"😎",
"🤩",
"😱",
"🤔",
"😴",
"🥳",
"😍",
"❤️",
"🔥",
"⭐",
"✨",
"💯",
"👍",
"👏",
"🎉",
"🎵",
"💪",
"📌",
"💡",
"🎯",
"✅",
"❌",
"⬆️",
"⬇️",
"➡️",
"⭕",
"🔔",
]
/** 贴纸类型 Tab */
export const STICKER_TYPE_TABS = [
{ value: "emoji" as const, label: "表情贴纸" },
{ value: "image" as const, label: "图片贴纸" },
{ value: "text" as const, label: "文字花字" },
]
/** 文字花字预设对应的 CSS 样式预览 */
export const TEXT_PRESET_STYLES: Record<TextStickerPreset, React.CSSProperties> = {
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" },
}
@@ -0,0 +1,30 @@
/**
* SubtitleStylePanel 相关常量
*/
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 ASR_LANGUAGE_OPTIONS = [
{ value: "zh", label: "中文" },
{ value: "en", label: "English" },
]
@@ -0,0 +1,19 @@
/**
* TtsPanel 相关常量
*/
import type { TtsMode } from "../types"
export const VOICE_CATEGORY_MAP: Record<string, { icon: string; label: string }> = {
male: { icon: "👨", label: "男声" },
female: { icon: "👩", label: "女声" },
young: { icon: "🧑", label: "少年" },
service: { icon: "🎧", label: "客服" },
news: { icon: "📰", label: "新闻" },
emotion: { icon: "🎭", label: "情感" },
}
export const TTS_MODE_OPTIONS: { mode: TtsMode; icon: string; label: string }[] = [
{ mode: "none", icon: "🔇", label: "无配音" },
{ mode: "upload", icon: "📁", label: "上传配音" },
{ mode: "tts", icon: "🤖", label: "TTS 合成" },
]
@@ -0,0 +1,25 @@
/**
* WatermarkPanel 相关常量
*/
import type { WatermarkType, WatermarkPosition, ScrollDirection } from "../types"
export 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: "📜" },
]
export 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: "居中" },
]
export const SCROLL_DIRECTION_OPTIONS: { value: ScrollDirection; label: string }[] = [
{ value: "horizontal", label: "水平滚动" },
{ value: "vertical", label: "垂直滚动" },
{ value: "diagonal", label: "对角滚动" },
]
@@ -0,0 +1,129 @@
/**
* 混剪图层管理 Hook
*/
import { useState, useCallback, useMemo } from "react"
import type { PipConfig, PipLayer, PipGridPosition } from "@/pages/editing-planner/types"
import { DEFAULT_PIP_LAYER, DEFAULT_PIP_CONFIG } from "@/pages/editing-planner/types"
import { GRID_POSITION_MAP } from "@/pages/editing-planner/constants/pipConfig"
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<string>("")
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<PipLayer>) => {
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<PipLayer> = { width: val }
if (selectedLayer.aspect_lock) {
partial.height = val
}
updateLayer(selectedLayer.id, partial)
},
[selectedLayer, updateLayer],
)
const handleHeightChange = useCallback(
(val: number) => {
if (!selectedLayer) return
const partial: Partial<PipLayer> = { height: val }
if (selectedLayer.aspect_lock) {
partial.width = val
}
updateLayer(selectedLayer.id, partial)
},
[selectedLayer, updateLayer],
)
return {
selectedId,
setSelectedId,
selectedLayer,
handleAddLayer,
handleDeleteLayer,
updateLayer,
handleEnableToggle,
handleReset,
handleGridClick,
handleWidthChange,
handleHeightChange,
}
}
@@ -0,0 +1,83 @@
/**
* 贴纸项管理 Hook
*/
import { useState, useCallback } from "react"
import type { StickerConfig, StickerItem, StickerType } from "@/pages/editing-planner/types"
import { DEFAULT_STICKER_ITEM, DEFAULT_STICKER_CONFIG } from "@/pages/editing-planner/types"
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<string | null>(null)
const [activeTab, setActiveTab] = useState<StickerType>("emoji")
const selectedSticker = config.items.find((s) => s.id === selectedId) ?? null
/** 更新单个贴纸 */
const updateItem = useCallback(
(id: string, partial: Partial<StickerItem>) => {
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])
return {
selectedId,
setSelectedId,
activeTab,
setActiveTab,
selectedSticker,
updateItem,
addSticker,
removeSticker,
handleReset,
}
}
@@ -0,0 +1,152 @@
/**
* TTS 配音 Hook
* 管理音色加载、试听、配置变更
*/
import { useState, useCallback, useEffect, useRef } from "react"
import { message } from "antd"
import type { TtsConfig, TtsMode } from "../types"
import { DEFAULT_TTS_CONFIG } from "../types"
import { getTtsVoices, previewTts, type TTSVoice } from "@/api/tts"
interface UseTtsPanelOptions {
open: boolean
config: TtsConfig
onChange: (config: TtsConfig) => void
onClose: () => void
}
export const useTtsPanel = ({ open, config, onChange, onClose }: UseTtsPanelOptions) => {
/* ── 音色列表 ── */
const [voices, setVoices] = useState<TTSVoice[]>([])
const [voicesLoading, setVoicesLoading] = useState(false)
/* ── 试听状态 ── */
const [previewLoading, setPreviewLoading] = useState(false)
const audioRef = useRef<HTMLAudioElement | null>(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(
(text: string) => {
onChange({ ...config, text: text.slice(0, 5000) })
},
[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),
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])
return {
voices,
voicesLoading,
previewLoading,
handleModeChange,
handleTextChange,
handleVoiceSelect,
handleSpeedChange,
handlePitchChange,
handleVolumeChange,
handleSubtitleSyncToggle,
handlePreview,
handleReset,
handleClose,
}
}
export default useTtsPanel
@@ -0,0 +1,42 @@
/**
* 配音试听 Hook
*/
import { useRef, useState, useCallback } from "react"
import type { AssetItem } from "@/api/assets"
export const useVoicePreview = () => {
const audioRef = useRef<HTMLAudioElement | null>(null)
const [previewingId, setPreviewingId] = useState<string | null>(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 {
previewingId,
handlePreviewVoice,
stopPreview,
}
}
@@ -0,0 +1,131 @@
/**
* 水印配置 Hook
* 管理水印类型切换、各项配置变更
*/
import { useState, useCallback } from "react"
import type {
WatermarkConfig,
WatermarkType,
WatermarkPosition,
ScrollDirection,
} from "../types"
import { DEFAULT_WATERMARK } from "../types"
interface UseWatermarkConfigOptions {
config: WatermarkConfig
onChange: (config: WatermarkConfig) => void
}
export const useWatermarkConfig = ({ config, onChange }: UseWatermarkConfigOptions) => {
/* ── 图片上传预览 URL(本地预览用) ── */
const [localImageUrl, setLocalImageUrl] = useState<string>("")
/* ── 切换水印类型 ── */
const handleTypeChange = useCallback(
(type: WatermarkType) => {
setLocalImageUrl("")
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])
return {
localImageUrl,
activeTab: config.type,
handleTypeChange,
handlePositionChange,
handleOpacityChange,
handleImageUrlChange,
handleImageWidthChange,
handleImageHeightChange,
handleTextChange,
handleFontSizeChange,
handleColorChange,
handleScrollDirectionChange,
handleScrollSpeedChange,
handleReset,
}
}
export default useWatermarkConfig
@@ -0,0 +1,71 @@
/**
* ClipPropertiesPanel 相关类型定义
*/
import type { ClipData, ClipType } from "@/pages/editing-planner/types"
import type { TemplateMode } from "@/api/editing-planner"
import type { AssetItem } from "@/api/assets"
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<SubtitleSettings>) => void
onBgmSettingsChange: (partial: Partial<BgmSettings>) => void
onClipUpdate: (clipId: string, data: Partial<ClipData>) => 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 */
}
@@ -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 "口播+混剪"
}
@@ -11,10 +11,19 @@ import "@/pages/editing-planner/EditingPlanner"
// 常量
import "@/pages/editing-planner/constants"
import "@/pages/editing-planner/constants/timeline"
import "@/pages/editing-planner/constants/clipProperties"
import "@/pages/editing-planner/constants/pipConfig"
import "@/pages/editing-planner/constants/sticker"
import "@/pages/editing-planner/constants/filter"
import "@/pages/editing-planner/constants/introOutro"
import "@/pages/editing-planner/constants/subtitleStyle"
import "@/pages/editing-planner/constants/tts"
import "@/pages/editing-planner/constants/watermark"
// 工具函数
import "@/pages/editing-planner/utils/selectors"
import "@/pages/editing-planner/utils/timeline"
import "@/pages/editing-planner/utils/clipProperties"
// 子组件
import "@/pages/editing-planner/components/BgmSelector"
@@ -43,6 +52,26 @@ import "@/pages/editing-planner/components/timeline/TimeRuler"
import "@/pages/editing-planner/components/timeline/AddClipPicker"
import "@/pages/editing-planner/components/timeline/TrimPreview"
import "@/pages/editing-planner/components/timeline/ContextMenu"
import "@/pages/editing-planner/components/clip-properties/SubtitleSettingsSection"
import "@/pages/editing-planner/components/clip-properties/BgmSettingsSection"
import "@/pages/editing-planner/components/clip-properties/ClipDetailSection"
import "@/pages/editing-planner/components/clip-properties/StatsSection"
import "@/pages/editing-planner/components/pip-config/LayerList"
import "@/pages/editing-planner/components/pip-config/LayerConfig"
import "@/pages/editing-planner/components/sticker/StickerLibrary"
import "@/pages/editing-planner/components/sticker/StickerList"
import "@/pages/editing-planner/components/sticker/StickerPropsEditor"
import "@/pages/editing-planner/components/filter/FilterPresetGrid"
import "@/pages/editing-planner/components/filter/FilterManualAdjust"
import "@/pages/editing-planner/components/intro-outro/IntroOutroBlock"
import "@/pages/editing-planner/components/subtitle-style/SubtitlePreview"
import "@/pages/editing-planner/components/tts/VoiceSelector"
import "@/pages/editing-planner/components/tts/TtsSlider"
import "@/pages/editing-planner/components/watermark/WatermarkTypeTabs"
import "@/pages/editing-planner/components/watermark/ImageWatermarkSection"
import "@/pages/editing-planner/components/watermark/TextWatermarkSection"
import "@/pages/editing-planner/components/watermark/ScrollWatermarkSection"
import "@/pages/editing-planner/components/watermark/WatermarkCommonSection"
import "@/pages/editing-planner/components/TopBar"
import "@/pages/editing-planner/components/TransitionSelector"
import "@/pages/editing-planner/components/TtsPanel"
@@ -51,6 +80,7 @@ import "@/pages/editing-planner/components/WatermarkPanel"
// 类型
import "@/pages/editing-planner/types"
import "@/pages/editing-planner/types/subtitle"
import "@/pages/editing-planner/types/clipProperties"
// Hooks
import "@/pages/editing-planner/hooks/useUndoRedo"
@@ -59,6 +89,11 @@ import "@/pages/editing-planner/hooks/useEditorDrawers"
import "@/pages/editing-planner/hooks/usePlaybackControl"
import "@/pages/editing-planner/hooks/useClipOperations"
import "@/pages/editing-planner/hooks/useTemplateManagement"
import "@/pages/editing-planner/hooks/useVoicePreview"
import "@/pages/editing-planner/hooks/usePipLayers"
import "@/pages/editing-planner/hooks/useStickerItems"
import "@/pages/editing-planner/hooks/useTtsPanel"
import "@/pages/editing-planner/hooks/useWatermarkConfig"
describe("EditingPlanner module smoke test", () => {
it("should load all editing-planner modules", () => {