fix(lint): prettier format fixes for TimelinePanel Phase 3
AI Code Review / AI Code Review (pull_request) Failing after 0s
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 15s
CI/CD Pipeline / Validate - Type Check (mypy) (pull_request) Successful in 1m25s
CI/CD Pipeline / Validate - Migration (alembic) (pull_request) Successful in 1m27s
CI/CD Pipeline / Frontend Lint (pull_request) Failing after 1m4s
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 25s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 1m4s
Preview Deploy / Deploy Preview Environment (pull_request) Failing after 41s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Successful in 1m0s
CI/CD Pipeline / Frontend Unit Tests (pull_request) Successful in 21s
CI/CD Pipeline / PR Build Web Image (pull_request) Successful in 1m53s
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 3m20s
CI/CD Pipeline / Validate - Code Quality (pull_request) Successful in 5m20s
CI/CD Pipeline / Integration Tests (pull_request) Failing after 1248h14m45s
CI/CD Pipeline / ACR Image Cleanup (pull_request) Failing after 1248h16m50s
CI/CD Pipeline / Canary Release to Production (pull_request) Failing after 1248h16m48s
CI/CD Pipeline / Staging API Integration Tests (pull_request) Failing after 1248h16m52s
CI/CD Pipeline / Staging E2E Tests (pull_request) Failing after 1248h16m52s
CI/CD Pipeline / Deploy Production (pull_request) Failing after 1248h16m54s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Failing after 1248h17m0s
CI/CD Pipeline / Build Production Worker Image (pull_request) Failing after 1248h17m2s
CI/CD Pipeline / Build Production Web Image (pull_request) Failing after 1248h17m4s
CI/CD Pipeline / Build Production API Image (pull_request) Failing after 1248h17m6s
CI/CD Pipeline / Build Staging Worker Image (pull_request) Failing after 1248h18m17s
CI/CD Pipeline / Unit Tests (pull_request) Failing after 1248h17m8s
CI/CD Pipeline / Build Staging API Image (pull_request) Failing after 1248h18m21s
CI/CD Pipeline / Build Staging Web Image (pull_request) Failing after 1248h50m58s
CI/CD Pipeline / Production Browser E2E (pull_request) Failing after 1248h49m29s

This commit is contained in:
2026-07-26 14:16:06 +08:00
parent 04319eb8eb
commit 2d44978dd9
20 changed files with 1687 additions and 14 deletions
@@ -10,9 +10,7 @@
*/
import React, { useState, useRef, useCallback, useEffect } from "react"
import type { ClipData, ClipType, TrimConfig } from "../types"
import {
DEFAULT_PIXELS_PER_SECOND,
} from "../constants/timeline"
import { DEFAULT_PIXELS_PER_SECOND } from "../constants/timeline"
import { formatTime } from "../utils/timeline"
import { useClipDrag } from "../hooks/useClipDrag"
import { useTrimDrag } from "../hooks/useTrimDrag"
@@ -0,0 +1,44 @@
/**
* BGM 设置区块
*/
import React from "react"
import type { BgmSettings } from "../../types/clipProperties"
interface BgmSettingsSectionProps {
settings: BgmSettings
onOpenBgmDrawer?: () => void
}
const BgmSettingsSection: React.FC<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,264 @@
/**
* 片段详情区块(选中片段时显示)
*/
import React from "react"
import { useNavigate } from "react-router-dom"
import type { ClipData, ClipType } from "../../types"
import type { AssetItem } from "@/api/assets"
import { TRANSITION_OPTIONS } from "@/api/template-editor"
import { CLIP_TYPE_ICONS, CLIP_TYPE_LABELS } from "../../constants/clipProperties"
import { getGenderLabel } from "../../utils/clipProperties"
import { useVoicePreview } from "../../hooks/useVoicePreview"
interface ClipDetailSectionProps {
clip: ClipData
currentMode: string
onClipUpdate: (clipId: string, data: Partial<ClipData>) => void
voiceMaterials?: AssetItem[]
voiceMaterialsLoading?: boolean
onRefreshVoiceMaterials?: () => void
onClipVoiceSelect?: (clipId: string, asset: AssetItem | null) => void
onOpenTransitionDrawer?: (clipId: string) => void
onOpenSpeedDrawer?: (clipId: string) => void
onOpenTtsDrawer?: (clipId: string) => void
}
const ClipDetailSection: React.FC<ClipDetailSectionProps> = ({
clip,
currentMode,
onClipUpdate,
voiceMaterials = [],
voiceMaterialsLoading = false,
onRefreshVoiceMaterials,
onClipVoiceSelect,
onOpenTransitionDrawer,
onOpenSpeedDrawer,
onOpenTtsDrawer,
}) => {
const navigate = useNavigate()
const { previewingId, handlePreviewVoice, stopPreview } = useVoicePreview()
/** 转场效果显示文本 */
const getTransitionLabel = () => {
const t = clip.transition
if (!t || t.type === "none") return "无转场"
const opt = TRANSITION_OPTIONS.find((o) => o.value === t.type)
return `${opt?.label ?? t.type} · ${t.duration.toFixed(1)}s`
}
/** 配音状态显示文本 */
const getTtsLabel = () => {
const tts = clip.tts_config
if (!tts || tts.mode === "none") return "无配音"
if (tts.mode === "upload") return "上传配音"
return `TTS · ${tts.voice_id ? "已选音色" : "未选音色"}`
}
/** 类型是否被禁用 */
const isTypeDisabled = (t: ClipType): boolean => {
if (currentMode === "pip") return t !== "pip"
if (currentMode === "voice_over") return t !== "voice"
return false // voice_pip 可切换
}
return (
<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">{getTransitionLabel()}</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">
{clip.speed ? `${clip.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(clip.id)}
>
<span className="ep-advanced-btn-icon">🎙️</span>
<span className="ep-advanced-btn-label">TTS 配音</span>
<span className="ep-advanced-btn-value">{getTtsLabel()}</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)
}
stopPreview()
}}
>
<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) handlePreviewVoice(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,43 @@
/**
* 编辑统计区块
*/
import React from "react"
import type { TemplateMode } from "@/api/editing-planner"
import { formatModeLabel } from "../../utils/clipProperties"
interface StatsSectionProps {
clipsCount: number
totalDuration: number
currentMode: TemplateMode
}
const StatsSection: React.FC<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 type { SubtitleSettings } from "../../types/clipProperties"
import { POSITION_OPTIONS, FONT_OPTIONS, ANIMATION_OPTIONS } from "../../constants/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,306 @@
/**
* 混剪单图层配置区组件
*/
import React, { useCallback } from "react"
import type { PipLayer, PipGridPosition, PipAnimType, PipSlideDirection } from "../../types"
import {
GRID_POSITIONS,
GRID_POSITION_MAP,
ANIM_OPTIONS,
SLIDE_DIR_OPTIONS,
LAYER_COLORS,
} from "../../constants/pipConfig"
interface LayerConfigProps {
layer: PipLayer
layers: PipLayer[]
selectedId: string
totalDuration: number
onUpdate: (id: string, partial: Partial<PipLayer>) => void
}
const LayerConfig: React.FC<LayerConfigProps> = ({
layer,
layers,
selectedId,
totalDuration,
onUpdate,
}) => {
/* ── 九宫格点击 ── */
const handleGridClick = useCallback(
(pos: PipGridPosition) => {
const coords = GRID_POSITION_MAP[pos]
onUpdate(layer.id, {
grid_position: pos,
x: coords.x,
y: coords.y,
})
},
[layer.id, onUpdate],
)
/* ── 宽高比锁定 ── */
const handleWidthChange = useCallback(
(val: number) => {
const partial: Partial<PipLayer> = { width: val }
if (layer.aspect_lock) {
partial.height = val
}
onUpdate(layer.id, partial)
},
[layer.id, layer.aspect_lock, onUpdate],
)
const handleHeightChange = useCallback(
(val: number) => {
const partial: Partial<PipLayer> = { height: val }
if (layer.aspect_lock) {
partial.width = val
}
onUpdate(layer.id, partial)
},
[layer.id, layer.aspect_lock, onUpdate],
)
return (
<div className="pip-config-area">
{/* ── 迷你预览 ── */}
<div className="pip-preview-box">
{layers.map((l, idx) => (
<div
key={l.id}
className={`pip-preview-layer${selectedId === 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={() => 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={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) => handleWidthChange(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) => handleHeightChange(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,59 @@
/**
* 混剪图层列表组件
*/
import React from "react"
import type { PipLayer } from "../../types"
import { LAYER_COLORS } from "../../constants/pipConfig"
interface LayerListProps {
layers: PipLayer[]
selectedId: string
onSelect: (id: string) => void
onDelete: (id: string) => void
}
const LayerList: React.FC<LayerListProps> = ({ layers, selectedId, onSelect, onDelete }) => {
return (
<div className="pip-layer-list">
{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,131 @@
/**
* 贴纸素材库组件(emoji / 图片 / 文字花字)
*/
import React, { useState } from "react"
import type { StickerType, TextStickerPreset } from "../../types"
import { TEXT_STICKER_PRESET_LABELS } from "../../types"
import { EMOJI_LIST, STICKER_TYPE_TABS, TEXT_PRESET_STYLES } from "../../constants/sticker"
interface StickerLibraryProps {
onAddSticker: (type: StickerType, content: string) => void
}
const StickerLibrary: React.FC<StickerLibraryProps> = ({ onAddSticker }) => {
const [activeTab, setActiveTab] = useState<StickerType>("emoji")
const [textInput, setTextInput] = useState("")
const [imageUrlInput, setImageUrlInput] = useState("")
const handleAddImage = () => {
const url = imageUrlInput.trim()
if (!url) return
onAddSticker("image", url)
setImageUrlInput("")
}
const handleAddText = () => {
const text = textInput.trim()
if (!text) return
onAddSticker("text", text)
setTextInput("")
}
return (
<>
{/* 类型 Tab */}
<div className="sticker-tabs">
{STICKER_TYPE_TABS.map((t) => (
<button
key={t.value}
className={`sticker-tab${activeTab === t.value ? " active" : ""}`}
onClick={() => setActiveTab(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
type="text"
className="sticker-url-input"
placeholder="输入图片 URL 添加贴纸..."
value={imageUrlInput}
onChange={(e) => setImageUrlInput(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") 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,57 @@
/**
* 已添加贴纸列表组件
*/
import React from "react"
import type { StickerItem } from "../../types"
interface StickerListProps {
items: StickerItem[]
selectedId: string | null
onSelect: (id: string) => void
onRemove: (id: string) => void
}
const StickerList: React.FC<StickerListProps> = ({ items, selectedId, onSelect, onRemove }) => {
if (items.length === 0) return null
const getDisplayName = (item: StickerItem) => {
if (item.type === "text") return item.content.slice(0, 10)
if (item.type === "emoji") return "表情贴纸"
return "图片贴纸"
}
const getDisplayIcon = (item: StickerItem) => {
if (item.type === "emoji") return item.content
if (item.type === "text") return "T"
return "🖼"
}
return (
<div className="sticker-list-section">
<div className="sticker-section-title">已添加贴纸 ({items.length})</div>
<div className="sticker-list">
{items.map((item) => (
<div
key={item.id}
className={`sticker-list-item${selectedId === item.id ? " active" : ""}`}
onClick={() => onSelect(item.id)}
>
<span className="sticker-list-icon">{getDisplayIcon(item)}</span>
<span className="sticker-list-name">{getDisplayName(item)}</span>
<button
className="sticker-list-delete"
onClick={(e) => {
e.stopPropagation()
onRemove(item.id)
}}
>
✕
</button>
</div>
))}
</div>
</div>
)
}
export default StickerList
@@ -0,0 +1,194 @@
/**
* 贴纸属性编辑面板 + 预览
*/
import React from "react"
import type { StickerItem, TextStickerPreset } from "../../types"
import { TEXT_STICKER_PRESET_LABELS } from "../../types"
import { TEXT_PRESET_STYLES } from "../../constants/sticker"
interface StickerPropsEditorProps {
sticker: StickerItem
totalDuration: number
onUpdate: (partial: Partial<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({ 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({ 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({
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({ 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({ 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({ 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({ 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({ 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({ 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({ 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,39 @@
/**
* ClipPropertiesPanel 相关常量
*/
import type { ClipType } from "../types"
export const POSITION_OPTIONS = [
{ value: "top", label: "顶部" },
{ value: "center", label: "居中" },
{ value: "bottom", label: "底部" },
]
export const FONT_OPTIONS = [
"思源黑体",
"思源宋体",
"苹方",
"PingFang",
"微软雅黑",
"楷体",
"华康俪金黑",
]
export const ANIMATION_OPTIONS = [
{ value: "none", label: "无" },
{ value: "fade", label: "淡入淡出" },
{ value: "slide", label: "滑动" },
{ value: "typewriter", label: "打字机" },
]
/** 片段类型图标映射 */
export const CLIP_TYPE_ICONS: Record<ClipType, string> = {
voice: "🎙️",
pip: "🖼️",
}
/** 片段类型标签映射 */
export const CLIP_TYPE_LABELS: Record<ClipType, string> = {
voice: "口播",
pip: "混剪",
}
+55
View File
@@ -0,0 +1,55 @@
/**
* PipConfigPanel 相关常量
*/
import type { PipGridPosition, PipAnimType, PipSlideDirection } from "../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)",
]
+61
View File
@@ -0,0 +1,61 @@
/**
* StickerPanel 相关常量
*/
import type { StickerType, TextStickerPreset } from "../types"
/** 常用 emoji 素材 */
export const EMOJI_LIST = [
"😀",
"😂",
"🥰",
"😎",
"🤩",
"😱",
"🤔",
"😴",
"🥳",
"😍",
"❤️",
"🔥",
"⭐",
"✨",
"💯",
"👍",
"👏",
"🎉",
"🎵",
"💪",
"📌",
"💡",
"🎯",
"✅",
"❌",
"⬆️",
"⬇️",
"➡️",
"⭕",
"🔔",
]
/** 贴纸类型 Tab 选项 */
export const STICKER_TYPE_TABS: { value: StickerType; label: string }[] = [
{ value: "emoji", label: "表情贴纸" },
{ value: "image", label: "图片贴纸" },
{ value: "text", label: "文字花字" },
]
/** 文字花字预设对应的 CSS 样式预览 */
export const TEXT_PRESET_STYLES: Record<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" },
}
+90
View File
@@ -0,0 +1,90 @@
/**
* 混剪图层管理 Hook
* 管理图层增删改、选中、重置等业务逻辑
*/
import { useState, useCallback, useMemo } from "react"
import type { PipConfig, PipLayer } from "../types"
import { DEFAULT_PIP_LAYER, DEFAULT_PIP_CONFIG } from "../types"
let layerIdCounter = 0
const genLayerId = () => `pip_layer_${Date.now()}_${++layerIdCounter}`
interface UsePipLayersOptions {
config: PipConfig
onChange: (config: PipConfig) => void
}
export const usePipLayers = ({ config, onChange }: UsePipLayersOptions) => {
const [selectedId, setSelectedId] = useState<string>("")
/** 当前选中图层 */
const selectedLayer = useMemo(
() => config.layers.find((l) => l.id === selectedId) ?? null,
[config.layers, selectedId],
)
/* ── 添加图层 ── */
const addLayer = useCallback(() => {
const newLayer: PipLayer = {
...DEFAULT_PIP_LAYER,
id: genLayerId(),
name: `图层 ${config.layers.length + 1}`,
z_index: config.layers.length + 1,
}
onChange({
...config,
layers: [...config.layers, newLayer],
})
setSelectedId(newLayer.id)
}, [config, onChange])
/* ── 删除图层 ── */
const deleteLayer = useCallback(
(id: string) => {
const newLayers = config.layers.filter((l) => l.id !== id)
onChange({ ...config, layers: newLayers })
if (selectedId === id) {
setSelectedId(newLayers.length > 0 ? newLayers[0].id : "")
}
},
[config, onChange, selectedId],
)
/* ── 更新图层 ── */
const updateLayer = useCallback(
(id: string, partial: Partial<PipLayer>) => {
onChange({
...config,
layers: config.layers.map((l) => (l.id === id ? { ...l, ...partial } : l)),
})
},
[config, onChange],
)
/* ── 切换启用 ── */
const toggleEnable = useCallback(
(checked: boolean) => {
onChange({ ...config, enabled: checked })
},
[config, onChange],
)
/* ── 重置 ── */
const resetConfig = useCallback(() => {
onChange({ ...DEFAULT_PIP_CONFIG })
setSelectedId("")
}, [onChange])
return {
selectedId,
selectedLayer,
setSelectedId,
addLayer,
deleteLayer,
updateLayer,
toggleEnable,
resetConfig,
}
}
export default usePipLayers
@@ -0,0 +1,84 @@
/**
* 贴纸管理 Hook
* 管理贴纸增删改、选中、重置等业务逻辑
*/
import { useState, useCallback } from "react"
import type { StickerConfig, StickerItem, StickerType } from "../types"
import { DEFAULT_STICKER_CONFIG, DEFAULT_STICKER_ITEM } from "../types"
/** 生成唯一 ID */
const genId = () => `sticker_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`
interface UseStickerItemsOptions {
config: StickerConfig
onChange: (config: StickerConfig) => void
totalDuration: number
}
export const useStickerItems = ({ config, onChange, totalDuration }: UseStickerItemsOptions) => {
const [selectedId, setSelectedId] = useState<string | null>(null)
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 resetAll = useCallback(() => {
onChange({ ...DEFAULT_STICKER_CONFIG, enabled: config.enabled })
setSelectedId(null)
}, [config.enabled, onChange])
return {
selectedId,
selectedSticker,
setSelectedId,
updateItem,
addSticker,
removeSticker,
resetAll,
}
}
export default useStickerItems
@@ -1,10 +1,6 @@
import { useState, useRef, useCallback, useEffect, useMemo, useLayoutEffect } from "react"
import type { ClipData, ClipType } from "../types"
import {
DEFAULT_ADD_DURATION,
ADD_PICKER_WIDTH,
TRACK_GAP,
} from "../constants/timeline"
import { DEFAULT_ADD_DURATION, ADD_PICKER_WIDTH, TRACK_GAP } from "../constants/timeline"
interface ContextMenuState {
x: number
@@ -40,11 +36,7 @@ export const useTimelineMenus = (
/* ── 根据模式决定可选类型 ── */
const availableTypes: ClipType[] = useMemo(
() =>
currentMode === "voice_over"
? ["voice"]
: currentMode === "pip"
? ["pip"]
: ["voice", "pip"],
currentMode === "voice_over" ? ["voice"] : currentMode === "pip" ? ["pip"] : ["voice", "pip"],
[currentMode],
)
@@ -74,7 +74,10 @@ export const useTrimDrag = (
if (trimDrag.direction === "left") {
newStart = Math.max(0, Math.min(origTrim.start_time + dtSec, newEnd - MIN_TRIM_DURATION))
} else {
newEnd = Math.max(origTrim.start_time + MIN_TRIM_DURATION, Math.min(origTrim.end_time + dtSec, origDur))
newEnd = Math.max(
origTrim.start_time + MIN_TRIM_DURATION,
Math.min(origTrim.end_time + dtSec, origDur),
)
}
const newDuration = Math.round((newEnd - newStart) * 10) / 10
@@ -0,0 +1,48 @@
/**
* 配音试听 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 {
audioRef,
previewingId,
handlePreviewVoice,
stopPreview,
}
}
export default useVoicePreview
+71
View File
@@ -0,0 +1,71 @@
/**
* ClipPropertiesPanel 相关类型定义
*/
import type { ClipData } from "../types"
import type { AssetItem } from "@/api/assets"
import type { TemplateMode } from "@/api/editing-planner"
export interface SubtitleSettings {
enabled: boolean
position: string
font: string
fontSize: number
fontColor: string
animation: string
mode?: string
stroke?: boolean
shadow?: boolean
asrLanguage?: string
}
export interface BgmSettings {
enabled: boolean
music_id: string
volume?: number
fade_in?: number
fade_out?: number
voice_dodge?: boolean
}
export interface ClipPropertiesPanelProps {
selectedClip: ClipData | null
subtitleSettings: SubtitleSettings
bgmSettings: BgmSettings
clipsCount: number
totalDuration: number
currentMode: TemplateMode
onSubtitleSettingsChange: (partial: Partial<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 */
}
+21
View File
@@ -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 "口播+混剪"
}