Compare commits

..

4 Commits

Author SHA1 Message Date
xiaoxia 994e14585c fix: 重命名BgmMixConfig类型避免与组件名冲突
CI/CD Pipeline / CI Gate (pull_request) CI运行中 [frontend-only]
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 13s
CI/CD Pipeline / Frontend Lint (pull_request) Successful in 59s
CI/CD Pipeline / Validate - Type Check (mypy) (pull_request) Successful in 1m25s
CI/CD Pipeline / Validate - Migration (alembic) (pull_request) Successful in 1m30s
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 23s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 1m2s
CI/CD Pipeline / Validate - Code Quality (pull_request) Failing after 3m8s
CI/CD Pipeline / PR Build Web Image (pull_request) Successful in 2m4s
AI Code Review / AI Code Review (pull_request) Successful in 2m5s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 1m11s
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 6m46s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Successful in 7m47s
ACR Cleanup / ACR Image Cleanup (pull_request_target) Has been cancelled
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 / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI/CD Pipeline / Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / Frontend Unit Tests (pull_request) Failing after 15s
Preview Cleanup / Cleanup Preview Environment (pull_request) Successful in 13s
CI/CD Pipeline / Deploy Production (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 / Canary Release to Production (pull_request) Has been cancelled
CI/CD Pipeline / Production Browser E2E (pull_request) Has been skipped
2026-07-27 15:25:17 +08:00
xiaoxia 7f3ca7c029 fix(bgm-selector): 恢复主入口文件,re-export 子目录保持路径兼容
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 8s
CI/CD Pipeline / Validate - Code Quality (pull_request) Failing after 1m36s
CI/CD Pipeline / Validate - Type Check (mypy) (pull_request) Successful in 59s
CI/CD Pipeline / Validate - Migration (alembic) (pull_request) Successful in 1m7s
CI/CD Pipeline / Frontend Lint (pull_request) Failing after 45s
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 24s
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 API Image (pull_request) Successful in 2m6s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 36s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Successful in 38s
Preview Deploy / Deploy Preview Environment (pull_request) Failing after 24s
CI/CD Pipeline / PR Build Web Image (pull_request) Failing after 3m11s
AI Code Review / AI Code Review (pull_request) Successful in 3m26s
CI/CD Pipeline / Unit Tests (pull_request) Has been cancelled
CI/CD Pipeline / Integration Tests (pull_request) Has been cancelled
CI/CD Pipeline / Frontend Unit Tests (pull_request) Has been cancelled
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been cancelled
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been cancelled
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been cancelled
CI/CD Pipeline / Build Production API Image (pull_request) Has been cancelled
CI/CD Pipeline / Build Production Web Image (pull_request) Has been cancelled
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been cancelled
CI/CD Pipeline / Deploy Production (pull_request) Has been cancelled
CI/CD Pipeline / Production Browser E2E (pull_request) Has been cancelled
CI/CD Pipeline / ACR Image Cleanup (pull_request) Has been cancelled
CI/CD Pipeline / Canary Release to Production (pull_request) Has been cancelled
2026-07-27 12:40:31 +08:00
xiaoxia e4fa8492a5 Merge branch 'develop' into refactor/bgm-selector-split
CI/CD Pipeline / Check if frontend-only change (pull_request) Has been cancelled
CI/CD Pipeline / Validate - Code Quality (pull_request) Has been cancelled
CI/CD Pipeline / Validate - Type Check (mypy) (pull_request) Has been cancelled
CI/CD Pipeline / Validate - Migration (alembic) (pull_request) Has been cancelled
CI/CD Pipeline / Unit Tests (pull_request) Has been cancelled
CI/CD Pipeline / Integration Tests (pull_request) Has been cancelled
CI/CD Pipeline / Frontend Lint (pull_request) Has been cancelled
CI/CD Pipeline / Frontend Unit Tests (pull_request) Has been cancelled
CI/CD Pipeline / PR Build API Image (pull_request) Has been cancelled
CI/CD Pipeline / PR Build Web Image (pull_request) Has been cancelled
CI/CD Pipeline / PR Build Worker Image (pull_request) Has been cancelled
CI/CD Pipeline / Build Staging API Image (pull_request) Has been cancelled
CI/CD Pipeline / Build Staging Web Image (pull_request) Has been cancelled
CI/CD Pipeline / Build Staging Worker Image (pull_request) Has been cancelled
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been cancelled
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been cancelled
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been cancelled
CI/CD Pipeline / Build Production API Image (pull_request) Has been cancelled
CI/CD Pipeline / Build Production Web Image (pull_request) Has been cancelled
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been cancelled
CI/CD Pipeline / Deploy Production (pull_request) Has been cancelled
CI/CD Pipeline / Production Browser E2E (pull_request) Has been cancelled
CI/CD Pipeline / ACR Image Cleanup (pull_request) Has been cancelled
CI/CD Pipeline / Canary Release to Production (pull_request) Has been cancelled
AI Code Review / AI Code Review (pull_request) Has been cancelled
Preview Deploy / Deploy Preview Environment (pull_request) Has been cancelled
PR Automation / Auto Approve on CI Green (pull_request) Successful in 41s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Successful in 48s
2026-07-27 12:34:25 +08:00
xiaoxia 192947b884 refactor(editing-planner): 拆分 BgmSelector 组件,抽离 Hook + 子组件
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 26s
CI/CD Pipeline / Validate - Type Check (mypy) (pull_request) Successful in 1m8s
CI/CD Pipeline / Validate - Code Quality (pull_request) Failing after 1m53s
CI/CD Pipeline / Frontend Lint (pull_request) Failing after 38s
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 16s
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 / Validate - Migration (alembic) (pull_request) Successful in 1m3s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 1m3s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Successful in 1m15s
Preview Deploy / Deploy Preview Environment (pull_request) Failing after 42s
CI/CD Pipeline / PR Build Web Image (pull_request) Failing after 2m25s
AI Code Review / AI Code Review (pull_request) Successful in 2m10s
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 6m53s
CI/CD Pipeline / Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / Frontend Unit Tests (pull_request) Failing after 37s
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 / Integration Tests (pull_request) Has been skipped
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 / 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 / Canary Release to Production (pull_request) Has been cancelled
CI/CD Pipeline / Production Browser E2E (pull_request) Has been skipped
主文件 273→120 行(-56%),拆分为:
- useBgmSelector: 列表加载、搜索、分类、试听逻辑
- BgmItem: 单个 BGM 列表项
- BgmMixConfig: 混音配置面板
- index: 组合入口 + Drawer 布局

导入路径保持向后兼容
2026-07-27 07:31:45 +08:00
24 changed files with 434 additions and 306 deletions
+1
View File
@@ -12,6 +12,7 @@
from __future__ import annotations
import json
import logging
from typing import Any, Dict, List, Optional
@@ -1,273 +1,5 @@
/**
* BGM 选择器 — Drawer 形式
* 预设 BGM 列表(按风格分类)、搜索、试听、音量/淡入淡出/人声闪避配置
* BGM 选择器入口(向后兼容)
* 实际实现位于 ./bgm-selector/ 目录
*/
import React, { useState, useRef, useCallback, useEffect } from "react"
import { Drawer, Slider, Input, Tag, message } from "antd"
import {
getBgmPresets,
type BgmPreset,
type BgmCategory,
type BgmMixConfig,
DEFAULT_BGM_MIX_CONFIG,
} from "@/api/bgm"
const { Search } = Input
/* ──────────── 分类标签 ──────────── */
const CATEGORY_LIST: {
key: BgmCategory | "all"
label: string
icon: string
}[] = [
{ key: "all", label: "全部", icon: "🎶" },
{ key: "轻快", label: "轻快", icon: "🎉" },
{ key: "治愈", label: "治愈", icon: "🌿" },
{ key: "科技", label: "科技", icon: "🔬" },
{ key: "电商", label: "电商", icon: "🛒" },
]
/* ──────────── Props ──────────── */
interface BgmSelectorProps {
open: boolean
onClose: () => void
config: BgmMixConfig
onChange: (config: BgmMixConfig) => void
}
const BgmSelector: React.FC<BgmSelectorProps> = ({ open, onClose, config, onChange }) => {
const [presets, setPresets] = useState<BgmPreset[]>([])
const [loading, setLoading] = useState(false)
const [activeCategory, setActiveCategory] = useState<BgmCategory | "all">("all")
const [keyword, setKeyword] = useState("")
const [previewingId, setPreviewingId] = useState<string | null>(null)
const audioRef = useRef<HTMLAudioElement | null>(null)
/* ── 加载 BGM 列表 ── */
const loadPresets = useCallback(async () => {
setLoading(true)
try {
const params: { category?: string; keyword?: string } = {}
if (activeCategory !== "all") params.category = activeCategory
if (keyword.trim()) params.keyword = keyword.trim()
const data = await getBgmPresets(params)
setPresets(data)
} catch {
message.error("加载 BGM 列表失败")
} finally {
setLoading(false)
}
}, [activeCategory, keyword])
useEffect(() => {
if (open) loadPresets()
}, [open, loadPresets])
/* ── 试听 ── */
const handlePreview = useCallback(
(bgm: BgmPreset) => {
if (previewingId === bgm.id) {
audioRef.current?.pause()
setPreviewingId(null)
return
}
audioRef.current?.pause()
const audio = new Audio(bgm.url)
audioRef.current = audio
audio.play().catch(() => {})
audio.onended = () => setPreviewingId(null)
setPreviewingId(bgm.id)
},
[previewingId],
)
/* ── 选中 BGM ── */
const handleSelect = useCallback(
(bgm: BgmPreset) => {
onChange({
...config,
enabled: true,
music_id: bgm.id,
})
},
[config, onChange],
)
/* ── 关闭时停止播放 ── */
const handleClose = useCallback(() => {
audioRef.current?.pause()
setPreviewingId(null)
onClose()
}, [onClose])
/* ── 移除 BGM ── */
const handleClear = useCallback(() => {
audioRef.current?.pause()
setPreviewingId(null)
onChange({ ...DEFAULT_BGM_MIX_CONFIG })
}, [onChange])
/* ── 当前选中的 BGM ── */
const selectedBgm = presets.find((p) => p.id === config.music_id)
return (
<Drawer
title="🎵 BGM 音乐选择"
placement="right"
width={420}
open={open}
onClose={handleClose}
className="bgm-selector-drawer"
>
{/* ── 搜索框 ── */}
<div className="bgm-search-row">
<Search
placeholder="搜索 BGM 名称..."
allowClear
value={keyword}
onChange={(e) => setKeyword(e.target.value)}
onSearch={() => loadPresets()}
/>
</div>
{/* ── 分类标签 ── */}
<div className="bgm-category-bar">
{CATEGORY_LIST.map((cat) => (
<Tag
key={cat.key}
className={`bgm-category-tag${activeCategory === cat.key ? " active" : ""}`}
onClick={() => setActiveCategory(cat.key)}
>
{cat.icon} {cat.label}
</Tag>
))}
</div>
{/* ── BGM 列表 ── */}
<div className="bgm-list">
{loading && <div className="bgm-loading">...</div>}
{!loading && presets.length === 0 && <div className="bgm-empty"> BGM </div>}
{presets.map((bgm) => {
const isSelected = config.music_id === bgm.id
const isPlaying = previewingId === bgm.id
return (
<div
key={bgm.id}
className={`bgm-item${isSelected ? " selected" : ""}`}
onClick={() => handleSelect(bgm)}
>
<div className="bgm-item-cover">
{bgm.cover_url ? (
<img src={bgm.cover_url} alt={bgm.name} />
) : (
<span className="bgm-item-cover-icon">🎵</span>
)}
</div>
<div className="bgm-item-info">
<div className="bgm-item-name">{bgm.name}</div>
<div className="bgm-item-meta">
<span className="bgm-item-category">{bgm.category}</span>
<span className="bgm-item-duration">
{Math.floor(bgm.duration / 60)}:
{String(Math.floor(bgm.duration % 60)).padStart(2, "0")}
</span>
</div>
{bgm.tags.length > 0 && (
<div className="bgm-item-tags">
{bgm.tags.slice(0, 3).map((t) => (
<span key={t} className="bgm-item-tag">
{t}
</span>
))}
</div>
)}
</div>
<button
className={`bgm-item-preview-btn${isPlaying ? " playing" : ""}`}
onClick={(e) => {
e.stopPropagation()
handlePreview(bgm)
}}
title={isPlaying ? "暂停" : "试听"}
>
{isPlaying ? "⏸" : "▶️"}
</button>
{isSelected && <span className="bgm-item-check"></span>}
</div>
)
})}
</div>
{/* ── 混音配置 ── */}
{config.enabled && config.music_id && (
<div className="bgm-mix-config">
<div className="bgm-mix-header">
<span></span>
<button className="bgm-mix-clear" onClick={handleClear}>
BGM
</button>
</div>
<div className="bgm-mix-selected">
{selectedBgm ? `当前:${selectedBgm.name}` : `当前:${config.music_id}`}
</div>
{/* 音量 */}
<div className="bgm-mix-field">
<label className="bgm-mix-label">
<span className="bgm-mix-value">{config.volume}%</span>
</label>
<Slider
min={0}
max={100}
value={config.volume}
onChange={(v) => onChange({ ...config, volume: v })}
/>
</div>
{/* 淡入 */}
<div className="bgm-mix-field">
<label className="bgm-mix-label">
<span className="bgm-mix-value">{config.fade_in.toFixed(1)}s</span>
</label>
<Slider
min={0}
max={3}
step={0.1}
value={config.fade_in}
onChange={(v) => onChange({ ...config, fade_in: v })}
/>
</div>
{/* 淡出 */}
<div className="bgm-mix-field">
<label className="bgm-mix-label">
<span className="bgm-mix-value">{config.fade_out.toFixed(1)}s</span>
</label>
<Slider
min={0}
max={3}
step={0.1}
value={config.fade_out}
onChange={(v) => onChange({ ...config, fade_out: v })}
/>
</div>
{/* 人声闪避 */}
<div className="bgm-mix-field bgm-mix-toggle-row">
<label className="bgm-mix-label">sidechain</label>
<div
className={`ep-toggle${config.voice_dodge ? " active" : ""}`}
onClick={() => onChange({ ...config, voice_dodge: !config.voice_dodge })}
>
<div className="ep-toggle-knob" />
</div>
</div>
</div>
)}
</Drawer>
)
}
export default BgmSelector
export { default } from "./bgm-selector"
@@ -0,0 +1,66 @@
import React from "react"
import type { BgmPreset } from "@/api/bgm"
interface BgmItemProps {
bgm: BgmPreset
isSelected: boolean
isPlaying: boolean
onSelect: () => void
onPreview: () => void
}
/**
* 单个 BGM 列表项组件
*/
export const BgmItem: React.FC<BgmItemProps> = ({
bgm,
isSelected,
isPlaying,
onSelect,
onPreview,
}) => {
const formatDuration = (seconds: number) => {
const mins = Math.floor(seconds / 60)
const secs = String(Math.floor(seconds % 60)).padStart(2, "0")
return `${mins}:${secs}`
}
return (
<div className={`bgm-item${isSelected ? " selected" : ""}`} onClick={onSelect}>
<div className="bgm-item-cover">
{bgm.cover_url ? (
<img src={bgm.cover_url} alt={bgm.name} />
) : (
<span className="bgm-item-cover-icon">🎵</span>
)}
</div>
<div className="bgm-item-info">
<div className="bgm-item-name">{bgm.name}</div>
<div className="bgm-item-meta">
<span className="bgm-item-category">{bgm.category}</span>
<span className="bgm-item-duration">{formatDuration(bgm.duration)}</span>
</div>
{bgm.tags.length > 0 && (
<div className="bgm-item-tags">
{bgm.tags.slice(0, 3).map((t) => (
<span key={t} className="bgm-item-tag">
{t}
</span>
))}
</div>
)}
</div>
<button
className={`bgm-item-preview-btn${isPlaying ? " playing" : ""}`}
onClick={(e) => {
e.stopPropagation()
onPreview()
}}
title={isPlaying ? "暂停" : "试听"}
>
{isPlaying ? "⏸" : "▶️"}
</button>
{isSelected && <span className="bgm-item-check"></span>}
</div>
)
}
@@ -0,0 +1,88 @@
import React from "react"
import { Slider } from "antd"
import type { BgmMixConfig as BgmMixConfigType, BgmPreset } from "@/api/bgm"
interface BgmMixConfigProps {
config: BgmMixConfigType
selectedBgm: BgmPreset | undefined
onChange: (config: BgmMixConfigType) => void
onClear: () => void
}
/**
* BGM 混音配置面板
* 音量、淡入淡出、人声闪避等设置
*/
export const BgmMixConfig: React.FC<BgmMixConfigProps> = ({
config,
selectedBgm,
onChange,
onClear,
}) => {
return (
<div className="bgm-mix-config">
<div className="bgm-mix-header">
<span></span>
<button className="bgm-mix-clear" onClick={onClear}>
BGM
</button>
</div>
<div className="bgm-mix-selected">
{selectedBgm ? `当前:${selectedBgm.name}` : `当前:${config.music_id}`}
</div>
{/* 音量 */}
<div className="bgm-mix-field">
<label className="bgm-mix-label">
<span className="bgm-mix-value">{config.volume}%</span>
</label>
<Slider
min={0}
max={100}
value={config.volume}
onChange={(v) => onChange({ ...config, volume: v })}
/>
</div>
{/* 淡入 */}
<div className="bgm-mix-field">
<label className="bgm-mix-label">
<span className="bgm-mix-value">{config.fade_in.toFixed(1)}s</span>
</label>
<Slider
min={0}
max={3}
step={0.1}
value={config.fade_in}
onChange={(v) => onChange({ ...config, fade_in: v })}
/>
</div>
{/* 淡出 */}
<div className="bgm-mix-field">
<label className="bgm-mix-label">
<span className="bgm-mix-value">{config.fade_out.toFixed(1)}s</span>
</label>
<Slider
min={0}
max={3}
step={0.1}
value={config.fade_out}
onChange={(v) => onChange({ ...config, fade_out: v })}
/>
</div>
{/* 人声闪避 */}
<div className="bgm-mix-field bgm-mix-toggle-row">
<label className="bgm-mix-label">sidechain</label>
<div
className={`ep-toggle${config.voice_dodge ? " active" : ""}`}
onClick={() => onChange({ ...config, voice_dodge: !config.voice_dodge })}
>
<div className="ep-toggle-knob" />
</div>
</div>
</div>
)
}
@@ -0,0 +1,124 @@
/**
* BGM 选择器 — Drawer 形式
* 预设 BGM 列表(按风格分类)、搜索、试听、音量/淡入淡出/人声闪避配置
*/
import React, { useCallback } from "react"
import { Drawer, Input, Tag } from "antd"
import { type BgmMixConfig, DEFAULT_BGM_MIX_CONFIG } from "@/api/bgm"
import { useBgmSelector, CATEGORY_LIST } from "./useBgmSelector"
import { BgmItem } from "./BgmItem"
import { BgmMixConfig as BgmMixConfigPanel } from "./BgmMixConfig"
const { Search } = Input
interface BgmSelectorProps {
open: boolean
onClose: () => void
config: BgmMixConfig
onChange: (config: BgmMixConfig) => void
}
const BgmSelector: React.FC<BgmSelectorProps> = ({ open, onClose, config, onChange }) => {
const {
presets,
loading,
activeCategory,
setActiveCategory,
keyword,
setKeyword,
previewingId,
loadPresets,
handlePreview,
stopPreview,
} = useBgmSelector(open)
/* ── 选中 BGM ── */
const handleSelect = useCallback(
(bgmId: string) => {
onChange({
...config,
enabled: true,
music_id: bgmId,
})
},
[config, onChange],
)
/* ── 关闭时停止播放 ── */
const handleClose = useCallback(() => {
stopPreview()
onClose()
}, [stopPreview, onClose])
/* ── 移除 BGM ── */
const handleClear = useCallback(() => {
stopPreview()
onChange({ ...DEFAULT_BGM_MIX_CONFIG })
}, [stopPreview, onChange])
/* ── 当前选中的 BGM ── */
const selectedBgm = presets.find((p) => p.id === config.music_id)
return (
<Drawer
title="🎵 BGM 音乐选择"
placement="right"
width={420}
open={open}
onClose={handleClose}
className="bgm-selector-drawer"
>
{/* 搜索框 */}
<div className="bgm-search-row">
<Search
placeholder="搜索 BGM 名称..."
allowClear
value={keyword}
onChange={(e) => setKeyword(e.target.value)}
onSearch={() => loadPresets()}
/>
</div>
{/* 分类标签 */}
<div className="bgm-category-bar">
{CATEGORY_LIST.map((cat) => (
<Tag
key={cat.key}
className={`bgm-category-tag${activeCategory === cat.key ? " active" : ""}`}
onClick={() => setActiveCategory(cat.key)}
>
{cat.icon} {cat.label}
</Tag>
))}
</div>
{/* BGM 列表 */}
<div className="bgm-list">
{loading && <div className="bgm-loading">...</div>}
{!loading && presets.length === 0 && <div className="bgm-empty"> BGM </div>}
{presets.map((bgm) => (
<BgmItem
key={bgm.id}
bgm={bgm}
isSelected={config.music_id === bgm.id}
isPlaying={previewingId === bgm.id}
onSelect={() => handleSelect(bgm.id)}
onPreview={() => handlePreview(bgm)}
/>
))}
</div>
{/* 混音配置 */}
{config.enabled && config.music_id && (
<BgmMixConfigPanel
config={config}
selectedBgm={selectedBgm}
onChange={onChange}
onClear={handleClear}
/>
)}
</Drawer>
)
}
export default BgmSelector
@@ -0,0 +1,87 @@
import { useState, useRef, useCallback, useEffect } from "react"
import { message } from "antd"
import { getBgmPresets, type BgmPreset, type BgmCategory } from "@/api/bgm"
/* ──────────── 分类标签 ──────────── */
export const CATEGORY_LIST: {
key: BgmCategory | "all"
label: string
icon: string
}[] = [
{ key: "all", label: "全部", icon: "🎶" },
{ key: "轻快", label: "轻快", icon: "🎉" },
{ key: "治愈", label: "治愈", icon: "🌿" },
{ key: "科技", label: "科技", icon: "🔬" },
{ key: "电商", label: "电商", icon: "🛒" },
]
/**
* BGM 选择器数据与交互 Hook
* 封装列表加载、搜索、分类筛选、试听播放逻辑
*/
export function useBgmSelector(open: boolean) {
const [presets, setPresets] = useState<BgmPreset[]>([])
const [loading, setLoading] = useState(false)
const [activeCategory, setActiveCategory] = useState<BgmCategory | "all">("all")
const [keyword, setKeyword] = useState("")
const [previewingId, setPreviewingId] = useState<string | null>(null)
const audioRef = useRef<HTMLAudioElement | null>(null)
/* ── 加载 BGM 列表 ── */
const loadPresets = useCallback(async () => {
setLoading(true)
try {
const params: { category?: string; keyword?: string } = {}
if (activeCategory !== "all") params.category = activeCategory
if (keyword.trim()) params.keyword = keyword.trim()
const data = await getBgmPresets(params)
setPresets(data)
} catch {
message.error("加载 BGM 列表失败")
} finally {
setLoading(false)
}
}, [activeCategory, keyword])
useEffect(() => {
if (open) loadPresets()
}, [open, loadPresets])
/* ── 试听 ── */
const handlePreview = useCallback(
(bgm: BgmPreset) => {
if (previewingId === bgm.id) {
audioRef.current?.pause()
setPreviewingId(null)
return
}
audioRef.current?.pause()
const audio = new Audio(bgm.url)
audioRef.current = audio
audio.play().catch(() => {})
audio.onended = () => setPreviewingId(null)
setPreviewingId(bgm.id)
},
[previewingId],
)
/* ── 停止播放(关闭/移除时调用) ── */
const stopPreview = useCallback(() => {
audioRef.current?.pause()
setPreviewingId(null)
}, [])
return {
presets,
loading,
activeCategory,
setActiveCategory,
keyword,
setKeyword,
previewingId,
loadPresets,
handlePreview,
stopPreview,
}
}
@@ -9,11 +9,12 @@
from __future__ import annotations
import logging
from typing import Optional
from packages.domain.chroma_key_config import CHROMA_KEY_PRESETS # noqa: F401
from packages.domain.chroma_key_config import apply_chroma_key_if_needed # noqa: F401
from packages.domain.chroma_key_config import (
CHROMA_KEY_PRESETS,
ChromaKeyConfig,
apply_chroma_key_if_needed,
)
from packages.domain.chroma_key_config import ( # noqa: F401 — 向后兼容
build_chromakey_filter as _build_chromakey_filter_base,
@@ -195,3 +195,17 @@ class ColorGradeEngine:
# ── 便捷函数 ──────────────────────────────────────────────────────────────────
def get_preset_names() -> list[tuple[str, str]]:
"""获取所有预设名称列表.
Returns:
[(preset_key, display_name), ...]
"""
return [(key, PRESET_DISPLAY_NAMES.get(key, key)) for key in PRESET_PARAMS.keys()]
def get_preset_params(preset: str) -> dict[str, float] | None:
"""获取指定预设的参数."""
return PRESET_PARAMS.get(preset)
+5 -3
View File
@@ -22,9 +22,11 @@ from shared.ffmpeg_utils import ( # noqa: F401
# xfade 转场纯逻辑已抽离到 domain 层,这里 re-export 保持向后兼容
from packages.domain.xfade_builder import DEFAULT_TRANSITION_DURATION as _default_transition_duration_base # noqa: F401
from packages.domain.xfade_builder import SUPPORTED_TRANSITIONS # noqa: F401
from packages.domain.xfade_builder import XFADE_TRANSITION_MAP # noqa: F401
from packages.domain.xfade_builder import XFade_TRANSITION_NAMES # noqa: F401
from packages.domain.xfade_builder import (
SUPPORTED_TRANSITIONS,
XFADE_TRANSITION_MAP,
XFade_TRANSITION_NAMES,
)
from packages.domain.xfade_builder import build_xfade_filter_chain as _build_xfade_filter_chain_base
from packages.domain.xfade_builder import chain_filters as _chain_filters_base
from packages.domain.xfade_builder import resolve_xfade_transition as _resolve_xfade_transition_base
@@ -9,21 +9,16 @@ from __future__ import annotations
import logging
# isort: off
from packages.domain.noise_reduction_config import (
NoiseReductionConfig,
NoiseReductionLevel, # noqa: F401
NoiseReductionLevel,
)
from packages.domain.noise_reduction_config import (
apply_noise_reduction_if_needed as _apply_noise_reduction_if_needed_base,
)
from packages.domain.noise_reduction_config import (
build_afftdn_filter as _build_afftdn_filter_base,
) # noqa: F401 — 向后兼容
from packages.domain.noise_reduction_config import build_afftdn_filter as _build_afftdn_filter_base # noqa: F401 — 向后兼容
from packages.domain.noise_reduction_config import build_arnndn_filter as _build_arnndn_filter_base
# isort: on
logger = logging.getLogger(__name__)
+10 -1
View File
@@ -18,13 +18,22 @@ from pathlib import Path
# 向后兼容:POSITION_BOTTOM_CENTER 也从 pip_config 再导出
from packages.domain.pip_config import POSITION_BOTTOM_CENTER # noqa: E402, F401
from packages.domain.pip_config import PiPConfig # noqa: F401
from packages.domain.pip_config import (
ANIMATION_FADE,
ANIMATION_SCALE,
ANIMATION_SLIDE_BOTTOM,
ANIMATION_SLIDE_LEFT,
ANIMATION_SLIDE_RIGHT,
ANIMATION_SLIDE_TOP,
POSITION_BOTTOM_LEFT,
POSITION_BOTTOM_RIGHT,
POSITION_CENTER,
POSITION_CENTER_LEFT,
POSITION_CENTER_RIGHT,
POSITION_TOP_CENTER,
POSITION_TOP_LEFT,
POSITION_TOP_RIGHT,
PiPConfig,
PiPLayerConfig,
)
from packages.domain.pip_config import ( # noqa: F401 — 向后兼容:保留模块级导出
@@ -13,6 +13,9 @@ from pathlib import Path
from typing import Any
from packages.domain.ass_subtitle_builder import (
TITLE_MARGIN_BOTTOM,
TITLE_MARGIN_SIDE,
TITLE_MARGIN_TOP,
build_ass_content,
)
from packages.domain.ass_subtitle_builder import build_ass_style as _build_ass_style_base # noqa: F401 — 向后兼容
@@ -14,20 +14,16 @@ import logging
from pathlib import Path
from typing import Any
# isort: off
from packages.domain.sticker_config import (
POSITION_PRESETS, # noqa: F401
STICKER_CATEGORIES, # noqa: F401
POSITION_PRESETS,
STICKER_CATEGORIES,
ImageStickerConfig,
StickerOverlayResult,
TextStickerConfig,
)
from packages.domain.sticker_config import (
get_sticker_categories as _get_sticker_categories_base,
) # noqa: F401 向后兼容导出
from packages.domain.sticker_config import get_sticker_categories as _get_sticker_categories_base # noqa: F401 向后兼容导出
from packages.domain.sticker_config import parse_stickers_from_config as _parse_stickers_base
from packages.domain.sticker_config import (
# isort: on
resolve_sticker_position,
)
@@ -25,22 +25,28 @@ from __future__ import annotations
import logging
from pathlib import Path
from typing import Any
from video_processing.path_security import PathSecurityError, is_in_allowed_dirs, safe_resolve_path
from packages.domain.subtitle_style import (
ALLOWED_SUBTITLE_EXTENSIONS,
DEFAULT_COLOR,
DEFAULT_FONT,
DEFAULT_FONT_SIZE,
DEFAULT_MAX_CHARS_PER_LINE,
DEFAULT_POSITION,
DEFAULT_STROKE_COLOR,
DEFAULT_STROKE_WIDTH,
POSITION_ALIASES,
POSITION_ALIGNMENT,
SubtitleSegment,
SubtitleStyle,
)
from packages.domain.subtitle_style import escape_ass_text as _escape_ass_text # noqa: F401 向后兼容导出
from packages.domain.subtitle_style import format_ass_time as _format_ass_time
from packages.domain.subtitle_style import hex_to_ass_bgr as _hex_to_ass_bgr # noqa: F401
from packages.domain.subtitle_style import hex_to_ass_color as _hex_to_ass_color # noqa: F401
from packages.domain.subtitle_style import opacity_to_ass_alpha as _opacity_to_ass_alpha # noqa: F401
from packages.domain.subtitle_style import hex_to_ass_bgr as _hex_to_ass_bgr
from packages.domain.subtitle_style import hex_to_ass_color as _hex_to_ass_color
from packages.domain.subtitle_style import opacity_to_ass_alpha as _opacity_to_ass_alpha
from packages.domain.subtitle_style import wrap_text as _wrap_text
logger = logging.getLogger(__name__)
+4 -2
View File
@@ -15,14 +15,16 @@ from __future__ import annotations
import logging
from typing import Any
from packages.domain.trim_config import MIN_TRIM_DURATION # noqa: F401
from packages.domain.trim_config import extract_trim_from_clip_config # noqa: F401
from packages.domain.trim_config import (
MIN_TRIM_DURATION,
TrimConfig,
TrimSegment,
)
from packages.domain.trim_config import build_audio_trim_filter as _build_audio_trim_filter # noqa: F401 — 向后兼容
from packages.domain.trim_config import build_video_trim_filter as _build_video_trim_filter
from packages.domain.trim_config import (
extract_trim_from_clip_config,
)
from packages.domain.trim_config import parse_segments_from_config as _parse_segments_from_config
from packages.domain.trim_config import resolve_segments as _resolve_segments
@@ -53,6 +53,7 @@ from video_processing.tts_engine import TtsEngine
from video_processing.watermark_engine import WatermarkConfig, WatermarkEngine
from packages.domain.render_layer_utils import LAYER_Z_INDEX as _IMPORTED_LAYER_Z_INDEX
from packages.domain.render_layer_utils import can_pass_through as _can_pass_through_pure
from packages.domain.render_layer_utils import clip_adjusted_duration as _clip_adjusted_duration_pure
from packages.domain.render_layer_utils import clip_effective_duration as _clip_effective_duration_pure
from packages.domain.render_layer_utils import clip_playback_speed as _clip_playback_speed_pure
@@ -14,9 +14,10 @@
from __future__ import annotations
import logging
from typing import Any
from packages.domain.watermark_config import WATERMARK_POSITIONS # noqa: F401
from packages.domain.watermark_config import (
WATERMARK_POSITIONS,
WatermarkConfig,
)
from packages.domain.watermark_config import ( # noqa: F401 — 向后兼容
+1 -1
View File
@@ -186,7 +186,7 @@ class PiPConfig:
"""最大 z_index."""
if not self.layers:
return 0
return max(layer.z_index for layer in self.layers)
return max(l.z_index for l in self.layers)
# ── 纯逻辑工具函数 ───────────────────────────────────────────────────────────
+1 -1
View File
@@ -6,7 +6,7 @@
from __future__ import annotations
from dataclasses import dataclass
from dataclasses import dataclass, field
from typing import Any
# ── 常量 ──────────────────────────────────────────────────────────────────────
+1 -1
View File
@@ -11,7 +11,7 @@
from __future__ import annotations
import logging
from dataclasses import dataclass
from dataclasses import dataclass, field
from typing import Any
logger = logging.getLogger(__name__)
+1 -1
View File
@@ -10,7 +10,7 @@
from __future__ import annotations
import logging
from dataclasses import dataclass
from dataclasses import dataclass, field
from typing import Any
logger = logging.getLogger(__name__)
+2 -2
View File
@@ -39,7 +39,7 @@ class TestConstants:
def test_preset_params_complete(self):
assert set(PRESET_PARAMS.keys()) == VALID_PRESETS
for _preset, params in PRESET_PARAMS.items():
for preset, params in PRESET_PARAMS.items():
assert set(params.keys()) == set(ALL_PARAM_KEYS)
def test_default_params_keys(self):
@@ -54,7 +54,7 @@ class TestConstants:
assert min_val <= DEFAULT_PARAMS[key] <= max_val
def test_all_presets_within_ranges(self):
for _preset, params in PRESET_PARAMS.items():
for preset, params in PRESET_PARAMS.items():
for key in ALL_PARAM_KEYS:
min_val, max_val = PARAM_RANGES[key]
assert min_val <= params[key] <= max_val, f"{preset}.{key}={params[key]} out of range"
+1 -1
View File
@@ -208,7 +208,7 @@ class TestPiPConfigFromDict:
}
)
assert cfg.layer_count == 3
assert [layer.source for layer in cfg.layers] == ["bottom", "mid", "top"]
assert [l.source for l in cfg.layers] == ["bottom", "mid", "top"]
def test_invalid_layer_skipped(self):
cfg = PiPConfig.from_dict(
+1 -1
View File
@@ -23,7 +23,7 @@ class TestConstants:
assert len(POSITION_PRESETS) == 9
def test_position_presets_normalized(self):
for _name, (x, y) in POSITION_PRESETS.items():
for name, (x, y) in POSITION_PRESETS.items():
assert 0.0 <= x <= 1.0
assert 0.0 <= y <= 1.0