3862996045
CI/CD Pipeline / Production Browser E2E (push) Failing after 1312h43m40s
CI/CD Pipeline / Deploy Production (push) Failing after 1312h43m42s
CI/CD Pipeline / Build Production Worker Image (push) Failing after 1312h48m16s
CI/CD Pipeline / Build Production API Image (push) Failing after 1312h48m20s
CI/CD Pipeline / PR Build Worker Image (push) Failing after 1312h49m4s
CI/CD Pipeline / Check if frontend-only change (push) Failing after 1312h51m34s
CI/CD Pipeline / PR Build API Image (push) Failing after 1312h49m8s
CI/CD Pipeline / Build Staging API Image (push) Successful in 13m4s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 2m5s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 4m56s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 58s
CI/CD Pipeline / Validate - Code Quality (push) Failing after 2m0s
CI/CD Pipeline / Validate - Type Check (mypy) (push) Successful in 1m5s
CI/CD Pipeline / Validate - Migration (alembic) (push) Successful in 1m12s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 21s
CI/CD Pipeline / Unit Tests (push) Successful in 6m21s
CI/CD Pipeline / Integration Tests (push) Successful in 2m38s
CI/CD Pipeline / Frontend Lint (push) Successful in 46s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 2m39s
CI/CD Pipeline / PR Build Web Image (push) Failing after 1313h21m29s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 4m40s
CI/CD Pipeline / Build Production Web Image (push) Failing after 1313h20m41s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 35s
635 lines
23 KiB
TypeScript
635 lines
23 KiB
TypeScript
/**
|
||
* 右栏设置面板 — 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: "混剪",
|
||
}
|
||
|
||
const ClipPropertiesPanel: React.FC<ClipPropertiesPanelProps> = ({
|
||
selectedClip,
|
||
subtitleSettings,
|
||
bgmSettings,
|
||
clipsCount,
|
||
totalDuration,
|
||
currentMode,
|
||
onSubtitleSettingsChange,
|
||
onBgmSettingsChange: _onBgmSettingsChange,
|
||
onClipUpdate,
|
||
onOpenBgmDrawer,
|
||
onOpenSubtitleDrawer,
|
||
voiceMaterials = [],
|
||
voiceMaterialsLoading = false,
|
||
onRefreshVoiceMaterials,
|
||
onClipVoiceSelect,
|
||
onOpenTransitionDrawer,
|
||
onOpenSpeedDrawer,
|
||
onOpenTtsDrawer,
|
||
onOpenWatermarkDrawer,
|
||
onOpenIntroOutroDrawer,
|
||
onOpenPipDrawer,
|
||
onOpenFilterDrawer,
|
||
onOpenGreenScreenDrawer,
|
||
onOpenStickerDrawer,
|
||
}) => {
|
||
const navigate = useNavigate()
|
||
|
||
/* ── 配音试听 ── */
|
||
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>
|
||
|
||
{/* ═══ 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>
|
||
|
||
{/* ═══ 水印设置 ═══ */}
|
||
<div className="ep-settings-section">
|
||
<div className="ep-section-title">
|
||
<span className="ep-section-icon">🔖</span>
|
||
水印设置
|
||
</div>
|
||
{onOpenWatermarkDrawer && (
|
||
<button className="ep-advanced-btn" onClick={onOpenWatermarkDrawer}>
|
||
<span className="ep-advanced-btn-icon">🔖</span>
|
||
<span className="ep-advanced-btn-label">水印配置</span>
|
||
<span className="ep-advanced-btn-arrow">›</span>
|
||
</button>
|
||
)}
|
||
</div>
|
||
|
||
{/* ═══ 片头片尾设置 ═══ */}
|
||
<div className="ep-settings-section">
|
||
<div className="ep-section-title">
|
||
<span className="ep-section-icon">🎬</span>
|
||
片头片尾
|
||
</div>
|
||
{onOpenIntroOutroDrawer && (
|
||
<button className="ep-advanced-btn" onClick={onOpenIntroOutroDrawer}>
|
||
<span className="ep-advanced-btn-icon">🎬</span>
|
||
<span className="ep-advanced-btn-label">片头片尾配置</span>
|
||
<span className="ep-advanced-btn-arrow">›</span>
|
||
</button>
|
||
)}
|
||
</div>
|
||
|
||
{/* ═══ 混剪 ═══ */}
|
||
<div className="ep-settings-section">
|
||
<div className="ep-section-title">
|
||
<span className="ep-section-icon">🖼️</span>
|
||
混剪
|
||
</div>
|
||
{onOpenPipDrawer && (
|
||
<button className="ep-advanced-btn" onClick={onOpenPipDrawer}>
|
||
<span className="ep-advanced-btn-icon">🖼️</span>
|
||
<span className="ep-advanced-btn-label">配置混剪图层</span>
|
||
<span className="ep-advanced-btn-arrow">›</span>
|
||
</button>
|
||
)}
|
||
</div>
|
||
|
||
{/* ═══ 滤镜调色 ═══ */}
|
||
<div className="ep-settings-section">
|
||
<div className="ep-section-title">
|
||
<span className="ep-section-icon">🎨</span>
|
||
滤镜调色
|
||
</div>
|
||
{onOpenFilterDrawer && (
|
||
<button className="ep-advanced-btn" onClick={onOpenFilterDrawer}>
|
||
<span className="ep-advanced-btn-icon">🎨</span>
|
||
<span className="ep-advanced-btn-label">配置滤镜调色</span>
|
||
<span className="ep-advanced-btn-arrow">›</span>
|
||
</button>
|
||
)}
|
||
</div>
|
||
|
||
{/* ═══ 绿幕抠像 ═══ */}
|
||
<div className="ep-settings-section">
|
||
<div className="ep-section-title">
|
||
<span className="ep-section-icon">🟩</span>
|
||
绿幕抠像
|
||
</div>
|
||
{onOpenGreenScreenDrawer && (
|
||
<button className="ep-advanced-btn" onClick={onOpenGreenScreenDrawer}>
|
||
<span className="ep-advanced-btn-icon">🟩</span>
|
||
<span className="ep-advanced-btn-label">配置绿幕抠像</span>
|
||
<span className="ep-advanced-btn-arrow">›</span>
|
||
</button>
|
||
)}
|
||
</div>
|
||
|
||
{/* ═══ 贴纸 ═══ */}
|
||
<div className="ep-settings-section">
|
||
<div className="ep-section-title">
|
||
<span className="ep-section-icon">🏷️</span>
|
||
贴纸
|
||
</div>
|
||
{onOpenStickerDrawer && (
|
||
<button className="ep-advanced-btn" onClick={onOpenStickerDrawer}>
|
||
<span className="ep-advanced-btn-icon">🏷️</span>
|
||
<span className="ep-advanced-btn-label">配置贴纸花字</span>
|
||
<span className="ep-advanced-btn-arrow">›</span>
|
||
</button>
|
||
)}
|
||
</div>
|
||
|
||
{/* ═══ 片段详情(选中时显示) ═══ */}
|
||
{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>
|
||
)}
|
||
|
||
{/* ═══ 统计信息(始终显示) ═══ */}
|
||
{!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>
|
||
)}
|
||
</div>
|
||
)
|
||
}
|
||
|
||
export default ClipPropertiesPanel
|