Compare commits

...

9 Commits

Author SHA1 Message Date
CI Bot 63201a251b style: auto-format with black + isort + prettier [skip ci-format-check] 2026-07-28 08:53:00 +00:00
xiaoxia 1d18991d98 test(wave162): asset_scoring素材评分 +66测
CI/CD Pipeline / Staging E2E Tests (pull_request) Blocked by required conditions
CI/CD Pipeline / Staging API Integration Tests (pull_request) Blocked by required conditions
CI/CD Pipeline / Deploy Production (pull_request) Blocked by required conditions
CI/CD Pipeline / Production Browser E2E (pull_request) Blocked by required conditions
CI/CD Pipeline / ACR Image Cleanup (pull_request) Blocked by required conditions
CI/CD Pipeline / Canary Release to Production (pull_request) Blocked by required conditions
Preview Cleanup / Cleanup Preview Environment (pull_request) Waiting to run
ACR Cleanup / ACR Image Cleanup (pull_request_target) Has been cancelled
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 21s
CI/CD Pipeline / Unit Tests (pull_request) Waiting to run
CI/CD Pipeline / Frontend Unit Tests (pull_request) Waiting to run
CI/CD Pipeline / Build Production API Image (pull_request) Waiting to run
CI/CD Pipeline / Build Production Web Image (pull_request) Waiting to run
CI/CD Pipeline / Build Production Worker Image (pull_request) Waiting to run
CI/CD Pipeline / CI Gate (pull_request) Blocked by required conditions
CI/CD Pipeline / Frontend Lint (pull_request) Successful in 34s
CI/CD Pipeline / Validate - Type Check (mypy) (pull_request) Successful in 1m37s
CI/CD Pipeline / Validate - Code Quality (pull_request) Failing after 2m28s
CI/CD Pipeline / Validate - Migration (alembic) (pull_request) Successful in 1m44s
CI/CD Pipeline / Build Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Integration Tests (pull_request) Waiting to run
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 / Deploy Staging (Watchtower auto-deploy) (pull_request) Waiting to run
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 2m14s
CI/CD Pipeline / PR Build Web Image (pull_request) Successful in 1m36s
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 1m14s
AI Code Review / AI Code Review (pull_request) Successful in 1m13s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 52s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 2m55s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Successful in 46m8s
- score_resolution分辨率评分:None/0值/目标匹配/超分/线性衰减/最低分/自定义目标
- score_duration时长评分:None/0值/最佳区间/过短衰减/过长扣分/边界值
- score_bitrate码率评分:缺省值/最佳区间/低码率衰减/高码率扣分/最低分
- calculate_total_score加权总分:满分/零分/权重验证/精度
- score_asset_detail单素材评分:对象返回/完美素材/质量默认/归一化/组件一致性
- _bucket_by_duration分桶:unknown/short/medium/long + 边界值
- diverse_selection多样性选择:空输入/0值/不足量/三桶配额/大count配额/全局补位
- filter_candidates候选过滤:状态/类型/质量门槛/None质量/枚举状态/空输入
2026-07-28 16:06:10 +08:00
xiaoxia 39187a0660 fix: 修复 20 个 ruff lint 错误,解除所有 PR CI 阻塞
CI/CD Pipeline / Check if frontend-only change (push) Has been cancelled
CI/CD Pipeline / Validate - Code Quality (push) Has been cancelled
CI/CD Pipeline / Validate - Type Check (mypy) (push) Has been cancelled
CI/CD Pipeline / Validate - Migration (alembic) (push) Has been cancelled
CI/CD Pipeline / Unit Tests (push) Has been cancelled
CI/CD Pipeline / Integration Tests (push) Has been cancelled
CI/CD Pipeline / Frontend Lint (push) Has been cancelled
CI/CD Pipeline / Frontend Unit Tests (push) Has been cancelled
CI/CD Pipeline / PR Build API Image (push) Has been cancelled
CI/CD Pipeline / PR Build Web Image (push) Has been cancelled
CI/CD Pipeline / PR Build Worker Image (push) Has been cancelled
CI/CD Pipeline / Build Staging API Image (push) Has been cancelled
CI/CD Pipeline / Build Staging Web Image (push) Has been cancelled
CI/CD Pipeline / Build Staging Worker Image (push) Has been cancelled
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Has been cancelled
CI/CD Pipeline / Staging E2E Tests (push) Has been cancelled
CI/CD Pipeline / Staging API Integration Tests (push) Has been cancelled
CI/CD Pipeline / Build Production API Image (push) Has been cancelled
CI/CD Pipeline / Build Production Web Image (push) Has been cancelled
CI/CD Pipeline / Build Production Worker Image (push) Has been cancelled
CI/CD Pipeline / Deploy Production (push) Has been cancelled
CI/CD Pipeline / Production Browser E2E (push) Has been cancelled
CI/CD Pipeline / ACR Image Cleanup (push) Has been cancelled
CI/CD Pipeline / Canary Release to Production (push) Has been cancelled
CI/CD Pipeline / CI Gate (push) Has been cancelled
F401 (14): 移除未使用的 import
  - multi_track_mixer_pure.py: Any, Optional, math
  - pip_engine_pure.py: Any
  - speed_engine.py: Optional, MAX_SPEED, MIN_SPEED
  - url_security.py: ALLOWED_AUDIO_MIME_TYPES, ALLOWED_IMAGE_MIME_TYPES,
    ALLOWED_VIDEO_MIME_TYPES, MAGIC_NUMBERS, MAX_URL_LENGTH,
    check_internal_hostname, is_trusted_domain

E741 (3): 重命名模糊变量 l → layer
  - pip_engine_pure.py: lambda 参数
  - test_pip_engine_pure.py: 两处列表推导

B017 (2): pytest.raises(Exception) → FrozenInstanceError
  - test_video_filter_builder.py
  - test_transition_presets.py

B905 (1): zip() 补充 strict=True
  - pip_engine_pure.py
2026-07-28 16:03:01 +08:00
xiaoxia f0bbedab23 refactor(voice-materials): 拆分 VoiceMaterialRow 进度条和标签组件(186→138行, -26%) (#1087)
CI/CD Pipeline / Check if frontend-only change (push) Has been cancelled
CI/CD Pipeline / Validate - Code Quality (push) Has been cancelled
CI/CD Pipeline / Validate - Type Check (mypy) (push) Has been cancelled
CI/CD Pipeline / Validate - Migration (alembic) (push) Has been cancelled
CI/CD Pipeline / Unit Tests (push) Has been cancelled
CI/CD Pipeline / Integration Tests (push) Has been cancelled
CI/CD Pipeline / Frontend Lint (push) Has been cancelled
CI/CD Pipeline / Frontend Unit Tests (push) Has been cancelled
CI/CD Pipeline / PR Build API Image (push) Has been cancelled
CI/CD Pipeline / PR Build Web Image (push) Has been cancelled
CI/CD Pipeline / PR Build Worker Image (push) Has been cancelled
CI/CD Pipeline / Build Staging API Image (push) Has been cancelled
CI/CD Pipeline / Build Staging Web Image (push) Has been cancelled
CI/CD Pipeline / Build Staging Worker Image (push) Has been cancelled
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Has been cancelled
CI/CD Pipeline / Staging E2E Tests (push) Has been cancelled
CI/CD Pipeline / Staging API Integration Tests (push) Has been cancelled
CI/CD Pipeline / Build Production API Image (push) Has been cancelled
CI/CD Pipeline / Build Production Web Image (push) Has been cancelled
CI/CD Pipeline / Build Production Worker Image (push) Has been cancelled
CI/CD Pipeline / Deploy Production (push) Has been cancelled
CI/CD Pipeline / Production Browser E2E (push) Has been cancelled
CI/CD Pipeline / ACR Image Cleanup (push) Has been cancelled
CI/CD Pipeline / Canary Release to Production (push) Has been cancelled
CI/CD Pipeline / CI Gate (push) Has been cancelled
Squash merge PR #1087: refactor(voice-materials): 拆分 VoiceMaterialRow 进度条和标签组件(186→138行, -26%)

- 拆分 VoiceMaterialRow 为子组件
- 抽取 TagDisplay 标签展示组件
- 抽取 useRowProgress 进度条Hook
- 186→138行, 精简 -26%
2026-07-28 15:55:52 +08:00
xiaoxia 750444c8bb fix(ci): 修复测试收集错误 — 模块名冲突 + SQLite 兼容 PG 特定 SQL (#1075)
CI/CD Pipeline / Check if frontend-only change (push) Has been cancelled
CI/CD Pipeline / Validate - Code Quality (push) Has been cancelled
CI/CD Pipeline / Validate - Type Check (mypy) (push) Has been cancelled
CI/CD Pipeline / Validate - Migration (alembic) (push) Has been cancelled
CI/CD Pipeline / Unit Tests (push) Has been cancelled
CI/CD Pipeline / Integration Tests (push) Has been cancelled
CI/CD Pipeline / Frontend Lint (push) Has been cancelled
CI/CD Pipeline / Frontend Unit Tests (push) Has been cancelled
CI/CD Pipeline / PR Build API Image (push) Has been cancelled
CI/CD Pipeline / PR Build Web Image (push) Has been cancelled
CI/CD Pipeline / PR Build Worker Image (push) Has been cancelled
CI/CD Pipeline / Build Staging API Image (push) Has been cancelled
CI/CD Pipeline / Build Staging Web Image (push) Has been cancelled
CI/CD Pipeline / Build Staging Worker Image (push) Has been cancelled
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Has been cancelled
CI/CD Pipeline / Staging E2E Tests (push) Has been cancelled
CI/CD Pipeline / Staging API Integration Tests (push) Has been cancelled
CI/CD Pipeline / Build Production API Image (push) Has been cancelled
CI/CD Pipeline / Build Production Web Image (push) Has been cancelled
CI/CD Pipeline / Build Production Worker Image (push) Has been cancelled
CI/CD Pipeline / Deploy Production (push) Has been cancelled
CI/CD Pipeline / Production Browser E2E (push) Has been cancelled
CI/CD Pipeline / ACR Image Cleanup (push) Has been cancelled
CI/CD Pipeline / Canary Release to Production (push) Has been cancelled
CI/CD Pipeline / CI Gate (push) Has been cancelled
- 修复test_url_security.py等模块名冲突导致的测试收集错误
- SQLite兼容PG特定SQL语法
- 调整pytest.ini配置

Squash merged from fix/ci-test-collection-errors
2026-07-28 14:34:04 +08:00
xiaoxia 581a146d2f refactor(voice-materials): 拆分 MaterialForm 为子组件(186→108行, -42%)
CI/CD Pipeline / Check if frontend-only change (push) Has been cancelled
CI/CD Pipeline / Validate - Code Quality (push) Has been cancelled
CI/CD Pipeline / Validate - Type Check (mypy) (push) Has been cancelled
CI/CD Pipeline / Validate - Migration (alembic) (push) Has been cancelled
CI/CD Pipeline / Unit Tests (push) Has been cancelled
CI/CD Pipeline / Integration Tests (push) Has been cancelled
CI/CD Pipeline / Frontend Lint (push) Has been cancelled
CI/CD Pipeline / Frontend Unit Tests (push) Has been cancelled
CI/CD Pipeline / PR Build API Image (push) Has been cancelled
CI/CD Pipeline / PR Build Web Image (push) Has been cancelled
CI/CD Pipeline / PR Build Worker Image (push) Has been cancelled
CI/CD Pipeline / Build Staging API Image (push) Has been cancelled
CI/CD Pipeline / Build Staging Web Image (push) Has been cancelled
CI/CD Pipeline / Build Staging Worker Image (push) Has been cancelled
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Has been cancelled
CI/CD Pipeline / Staging E2E Tests (push) Has been cancelled
CI/CD Pipeline / Staging API Integration Tests (push) Has been cancelled
CI/CD Pipeline / Build Production API Image (push) Has been cancelled
CI/CD Pipeline / Build Production Web Image (push) Has been cancelled
CI/CD Pipeline / Build Production Worker Image (push) Has been cancelled
CI/CD Pipeline / Deploy Production (push) Has been cancelled
CI/CD Pipeline / Production Browser E2E (push) Has been cancelled
CI/CD Pipeline / ACR Image Cleanup (push) Has been cancelled
CI/CD Pipeline / Canary Release to Production (push) Has been cancelled
CI/CD Pipeline / CI Gate (push) Has been cancelled
2026-07-28 14:07:02 +08:00
xiaoxia eab45e0819 refactor(voices): 拆分 CloneVoiceCard 为子组件(180→79行, -56%)
CI/CD Pipeline / Check if frontend-only change (push) Has been cancelled
CI/CD Pipeline / Validate - Code Quality (push) Has been cancelled
CI/CD Pipeline / Validate - Type Check (mypy) (push) Has been cancelled
CI/CD Pipeline / Validate - Migration (alembic) (push) Has been cancelled
CI/CD Pipeline / Unit Tests (push) Has been cancelled
CI/CD Pipeline / Integration Tests (push) Has been cancelled
CI/CD Pipeline / Frontend Lint (push) Has been cancelled
CI/CD Pipeline / Frontend Unit Tests (push) Has been cancelled
CI/CD Pipeline / PR Build API Image (push) Has been cancelled
CI/CD Pipeline / PR Build Web Image (push) Has been cancelled
CI/CD Pipeline / PR Build Worker Image (push) Has been cancelled
CI/CD Pipeline / Build Staging API Image (push) Has been cancelled
CI/CD Pipeline / Build Staging Web Image (push) Has been cancelled
CI/CD Pipeline / Build Staging Worker Image (push) Has been cancelled
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Has been cancelled
CI/CD Pipeline / Staging E2E Tests (push) Has been cancelled
CI/CD Pipeline / Staging API Integration Tests (push) Has been cancelled
CI/CD Pipeline / Build Production API Image (push) Has been cancelled
CI/CD Pipeline / Build Production Web Image (push) Has been cancelled
CI/CD Pipeline / Build Production Worker Image (push) Has been cancelled
CI/CD Pipeline / Deploy Production (push) Has been cancelled
CI/CD Pipeline / Production Browser E2E (push) Has been cancelled
CI/CD Pipeline / ACR Image Cleanup (push) Has been cancelled
CI/CD Pipeline / Canary Release to Production (push) Has been cancelled
CI/CD Pipeline / CI Gate (push) Has been cancelled
2026-07-28 14:05:47 +08:00
xiaoxia e243d70082 refactor(voice-materials): 拆分 TagSelector 逻辑到 useTagInput Hook(163→107行, -34%)
CI/CD Pipeline / Check if frontend-only change (push) Has been cancelled
CI/CD Pipeline / Validate - Code Quality (push) Has been cancelled
CI/CD Pipeline / Validate - Type Check (mypy) (push) Has been cancelled
CI/CD Pipeline / Validate - Migration (alembic) (push) Has been cancelled
CI/CD Pipeline / Unit Tests (push) Has been cancelled
CI/CD Pipeline / Integration Tests (push) Has been cancelled
CI/CD Pipeline / Frontend Lint (push) Has been cancelled
CI/CD Pipeline / Frontend Unit Tests (push) Has been cancelled
CI/CD Pipeline / PR Build API Image (push) Has been cancelled
CI/CD Pipeline / PR Build Web Image (push) Has been cancelled
CI/CD Pipeline / PR Build Worker Image (push) Has been cancelled
CI/CD Pipeline / Build Staging API Image (push) Has been cancelled
CI/CD Pipeline / Build Staging Web Image (push) Has been cancelled
CI/CD Pipeline / Build Staging Worker Image (push) Has been cancelled
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Has been cancelled
CI/CD Pipeline / Staging E2E Tests (push) Has been cancelled
CI/CD Pipeline / Staging API Integration Tests (push) Has been cancelled
CI/CD Pipeline / Build Production API Image (push) Has been cancelled
CI/CD Pipeline / Build Production Web Image (push) Has been cancelled
CI/CD Pipeline / Build Production Worker Image (push) Has been cancelled
CI/CD Pipeline / Deploy Production (push) Has been cancelled
CI/CD Pipeline / Production Browser E2E (push) Has been cancelled
CI/CD Pipeline / ACR Image Cleanup (push) Has been cancelled
CI/CD Pipeline / Canary Release to Production (push) Has been cancelled
CI/CD Pipeline / CI Gate (push) Has been cancelled
2026-07-28 14:04:54 +08:00
xiaoxia d4c3743e45 fix(ci): 修复CI Gate失败时返回exit 0的P0 Bug (develop) (#1058)
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / Validate - Code Quality (push) Failing after 2m42s
CI/CD Pipeline / Validate - Type Check (mypy) (push) Successful in 1m35s
CI/CD Pipeline / Validate - Migration (alembic) (push) Successful in 1m37s
CI/CD Pipeline / Unit Tests (push) Failing after 6m22s
CI/CD Pipeline / Integration Tests (push) Successful in 4m51s
CI/CD Pipeline / Frontend Lint (push) Failing after 46s
CI/CD Pipeline / Frontend Unit Tests (push) Failing after 1m17s
CI/CD Pipeline / PR Build API Image (push) Has been skipped
CI/CD Pipeline / PR Build Web Image (push) Has been skipped
CI/CD Pipeline / PR Build Worker Image (push) Has been skipped
CI/CD Pipeline / Build Staging API Image (push) Successful in 18m12s
CI/CD Pipeline / Build Staging Web Image (push) Failing after 3m5s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 8m19s
CI/CD Pipeline / Build Production API Image (push) Has been skipped
CI/CD Pipeline / Build Production Web Image (push) Has been skipped
CI/CD Pipeline / Build Production Worker Image (push) Has been skipped
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Has been cancelled
CI/CD Pipeline / Staging E2E Tests (push) Has been cancelled
CI/CD Pipeline / Staging API Integration Tests (push) Has been cancelled
CI/CD Pipeline / Production Browser E2E (push) Has been cancelled
CI/CD Pipeline / ACR Image Cleanup (push) Has been cancelled
CI/CD Pipeline / Canary Release to Production (push) Has been cancelled
CI/CD Pipeline / CI Gate (push) Has been cancelled
2026-07-28 13:33:25 +08:00
25 changed files with 1023 additions and 346 deletions
+1 -1
View File
@@ -1805,7 +1805,7 @@ jobs:
echo "❌ CI Gate: FAILED"
echo "失败项: ${FAILED_ITEMS[*]}"
echo "gate_result=failure" >> $GITHUB_OUTPUT
exit 0
exit 1
fi
- name: Report CI trace
+5 -81
View File
@@ -1,11 +1,10 @@
import React, { useState, useRef } from "react"
import { UploadOutlined, SoundOutlined, CloseOutlined } from "@ant-design/icons"
import React, { useState } from "react"
import { Button, Input } from "@/components/ui"
import { type TagItem } from "@/api/tags"
import { type VoiceGender, type VoiceMaterial } from "../types"
import { GENDER_OPTIONS } from "../constants"
import { genderClass, formatFileSize } from "../utils/format"
import TagSelector from "./TagSelector"
import FileUploadField from "./material-form/FileUploadField"
import GenderSelector from "./material-form/GenderSelector"
export interface MaterialFormProps {
initial?: VoiceMaterial
@@ -33,7 +32,6 @@ const MaterialForm: React.FC<MaterialFormProps> = ({
const [gender, setGender] = useState<VoiceGender>(initial?.gender ?? "female")
const [selectedTagIds, setSelectedTagIds] = useState<string[]>(initial?.tagIds ?? [])
const [file, setFile] = useState<File | undefined>(undefined)
const fileInputRef = useRef<HTMLInputElement>(null)
const handleSubmit = () => {
if (!name.trim()) return
@@ -53,65 +51,10 @@ const MaterialForm: React.FC<MaterialFormProps> = ({
return (
<div className="vmat-form">
{/* 音频文件上传(编辑模式不显示) */}
{!initial && (
<div className="vmat-form-field">
<label className="vmat-form-label"> *</label>
<div
className="vmat-upload-zone"
onClick={() => fileInputRef.current?.click()}
onDragOver={(e) => e.preventDefault()}
onDrop={(e) => {
e.preventDefault()
const f = e.dataTransfer.files[0]
if (f?.type.startsWith("audio/")) setFile(f)
}}
>
<input
ref={fileInputRef}
type="file"
accept="audio/*"
style={{ display: "none" }}
onChange={(e) => {
const f = e.target.files?.[0]
if (f) setFile(f)
}}
/>
{file ? (
<div className="vmat-upload-selected">
<SoundOutlined className="vmat-upload-icon" />
<span className="vmat-upload-filename">{file.name}</span>
<span className="vmat-upload-filesize">{formatFileSize(file.size)}</span>
<button
type="button"
className="vmat-upload-clear"
onClick={(e) => {
e.stopPropagation()
setFile(undefined)
}}
>
<CloseOutlined />
</button>
</div>
) : (
<div className="vmat-upload-placeholder">
<UploadOutlined className="vmat-upload-icon" />
<p></p>
<span> MP3WAVAACFLAC </span>
</div>
)}
</div>
{/* 上传进度条 */}
{uploadProgress !== null && uploadProgress !== undefined && (
<div className="vmat-upload-progress">
<div className="vmat-upload-progress-bar" style={{ width: `${uploadProgress}%` }} />
<span className="vmat-upload-progress-text">{uploadProgress}%</span>
</div>
)}
</div>
<FileUploadField file={file} onChange={setFile} uploadProgress={uploadProgress} />
)}
{/* 名称 */}
<div className="vmat-form-field">
<label className="vmat-form-label"> *</label>
<Input
@@ -122,7 +65,6 @@ const MaterialForm: React.FC<MaterialFormProps> = ({
/>
</div>
{/* 音色描述 */}
<div className="vmat-form-field">
<label className="vmat-form-label"></label>
<Input.TextArea
@@ -134,25 +76,8 @@ const MaterialForm: React.FC<MaterialFormProps> = ({
/>
</div>
{/* 性别 */}
<div className="vmat-form-field">
<label className="vmat-form-label"></label>
<div className="vmat-gender-group">
{GENDER_OPTIONS.map((opt) => (
<button
key={opt.value}
type="button"
className={`vmat-gender-btn${gender === opt.value ? " active" : ""} ${genderClass(opt.value)}`}
onClick={() => setGender(opt.value)}
>
{opt.icon}
{opt.label}
</button>
))}
</div>
</div>
<GenderSelector value={gender} onChange={setGender} />
{/* 风格标签 */}
<div className="vmat-form-field">
<label className="vmat-form-label"></label>
<TagSelector
@@ -164,7 +89,6 @@ const MaterialForm: React.FC<MaterialFormProps> = ({
/>
</div>
{/* 操作按钮 */}
<div className="vmat-form-actions">
<Button buttonType="ghost" buttonSize="md" onClick={onCancel}>
+16 -72
View File
@@ -1,7 +1,8 @@
import React, { useState, useRef, useCallback, useMemo } from "react"
import React from "react"
import { CheckOutlined } from "@ant-design/icons"
import { Tag } from "@/components/ui"
import { type TagItem } from "@/api/tags"
import type { TagItem } from "@/api/tags"
import { useTagInput } from "./tag-selector/useTagInput"
export interface TagSelectorProps {
/** 已选标签 ID 列表 */
@@ -24,77 +25,22 @@ const TagSelector: React.FC<TagSelectorProps> = ({
onCreateTag,
placeholder = "输入标签后回车添加",
}) => {
const [inputVal, setInputVal] = useState("")
const [showSuggestions, setShowSuggestions] = useState(false)
const inputRef = useRef<HTMLInputElement>(null)
/** 按名称查找已有标签(大小写不敏感) */
const findTagByName = useCallback(
(name: string) => tags.find((t) => t.name.toLowerCase() === name.toLowerCase()),
[tags],
)
/** 去重添加标签(按 ID */
const addTagId = useCallback(
(tagId: string) => {
if (value.includes(tagId)) return
onChange([...value, tagId])
setInputVal("")
setShowSuggestions(false)
},
[value, onChange],
)
/** 输入自定义标签名:若已存在则直接选,否则创建新标签 */
const addTagByName = useCallback(
async (name: string) => {
const trimmed = name.trim()
if (!trimmed) return
const existing = findTagByName(trimmed)
if (existing) {
addTagId(existing.id)
} else {
try {
const created = await onCreateTag(trimmed)
addTagId(created.id)
} catch {
/* 创建失败静默忽略 */
}
}
},
[findTagByName, addTagId, onCreateTag],
)
const removeTagId = useCallback(
(tagId: string) => {
onChange(value.filter((t) => t !== tagId))
},
[value, onChange],
)
/** 输入补全建议(排除已选) */
const suggestions = useMemo(() => {
if (!inputVal.trim()) return []
const lower = inputVal.toLowerCase()
return tags.filter((t) => t.name.toLowerCase().includes(lower) && !value.includes(t.id))
}, [inputVal, tags, value])
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === "Enter") {
e.preventDefault()
if (suggestions.length > 0) {
addTagId(suggestions[0].id)
} else {
addTagByName(inputVal)
}
} else if (e.key === "Backspace" && !inputVal && value.length > 0) {
removeTagId(value[value.length - 1])
}
}
const {
inputVal,
setInputVal,
showSuggestions,
setShowSuggestions,
inputRef,
suggestions,
addTagId,
removeTagId,
handleKeyDown,
focus,
} = useTagInput({ value, onChange, tags, onCreateTag })
return (
<div className="vmat-tag-selector-wrapper">
<div className="vmat-tag-selector" onClick={() => inputRef.current?.focus()}>
<div className="vmat-tag-selector" onClick={focus}>
{value.map((tagId) => (
<Tag key={tagId} variant="info" closable onClose={() => removeTagId(tagId)}>
{tagMap.get(tagId)?.name ?? tagId}
@@ -115,7 +61,6 @@ const TagSelector: React.FC<TagSelectorProps> = ({
/>
</div>
{/* 自动补全下拉 */}
{showSuggestions && suggestions.length > 0 && (
<div className="vmat-tag-suggestions">
{suggestions.slice(0, 6).map((tag) => (
@@ -134,7 +79,6 @@ const TagSelector: React.FC<TagSelectorProps> = ({
</div>
)}
{/* 已有标签快捷选择 */}
{tags.length > 0 && (
<div className="vmat-tag-selector-presets">
{tags.map((tag) => {
+9 -57
View File
@@ -1,4 +1,4 @@
import React, { useRef } from "react"
import React from "react"
import {
PlayCircleOutlined,
PauseCircleOutlined,
@@ -6,11 +6,8 @@ import {
DeleteOutlined,
CheckOutlined,
} from "@ant-design/icons"
import { Tooltip } from "antd"
import { Tag } from "@/components/ui"
import { type TagItem } from "@/api/tags"
import { type VoiceMaterial } from "../types"
import { MAX_ROW_TAGS, TAG_VARIANTS } from "../constants"
import {
genderClass,
genderIcon,
@@ -18,6 +15,8 @@ import {
formatDuration,
formatFileSize,
} from "../utils/format"
import { useRowProgress } from "./voice-material-row/useRowProgress"
import TagDisplay from "./voice-material-row/TagDisplay"
export interface VoiceRowProps {
material: VoiceMaterial
@@ -48,26 +47,10 @@ const VoiceMaterialRow: React.FC<VoiceRowProps> = ({
onDelete,
onToggleSelect,
}) => {
const progressRef = useRef<HTMLDivElement>(null)
const handleProgressMouseDown = (e: React.MouseEvent<HTMLDivElement>) => {
if (!progressRef.current) return
e.preventDefault()
const doSeek = (ev: MouseEvent) => {
if (!progressRef.current) return
const rect = progressRef.current.getBoundingClientRect()
const percent = Math.max(0, Math.min(1, (ev.clientX - rect.left) / rect.width))
onSeek(percent * material.duration)
}
doSeek(e.nativeEvent)
const handleMove = (ev: MouseEvent) => doSeek(ev)
const handleUp = () => {
document.removeEventListener("mousemove", handleMove)
document.removeEventListener("mouseup", handleUp)
}
document.addEventListener("mousemove", handleMove)
document.addEventListener("mouseup", handleUp)
}
const { progressRef, handleMouseDown } = useRowProgress({
duration: material.duration,
onSeek,
})
const progress = material.duration > 0 ? (currentTime / material.duration) * 100 : 0
@@ -75,7 +58,6 @@ const VoiceMaterialRow: React.FC<VoiceRowProps> = ({
<div
className={`vmat-row ${genderClass(material.gender)}${isPlaying ? " playing" : ""}${isSelected ? " selected" : ""}${batchMode ? " batch-mode" : ""}`}
>
{/* 批量选择 checkbox */}
{(batchMode || isSelected) && (
<div
className={`vmat-row-checkbox vmat-checkbox${isSelected ? " checked" : ""}`}
@@ -88,7 +70,6 @@ const VoiceMaterialRow: React.FC<VoiceRowProps> = ({
</div>
)}
{/* 播放按钮 */}
<button
type="button"
className="vmat-row-play"
@@ -101,60 +82,31 @@ const VoiceMaterialRow: React.FC<VoiceRowProps> = ({
{isPlaying ? <PauseCircleOutlined /> : <PlayCircleOutlined />}
</button>
{/* 名称 + 描述 */}
<div className="vmat-row-info">
<h4 className="vmat-row-name">{material.name}</h4>
{material.description && <p className="vmat-row-desc">{material.description}</p>}
</div>
{/* 性别 */}
<span className={`vmat-row-gender ${genderClass(material.gender)}`}>
{genderIcon(material.gender)}
{genderLabel(material.gender)}
</span>
{/* 标签 */}
<div className="vmat-row-tags">
{material.tagIds.length === 0 ? (
<span className="vmat-tag-empty" onClick={() => onEdit()}>
</span>
) : (
<>
{material.tagIds.slice(0, MAX_ROW_TAGS).map((tagId, i) => (
<Tag key={tagId} variant={TAG_VARIANTS[i % TAG_VARIANTS.length]}>
{tagMap.get(tagId)?.name ?? tagId}
</Tag>
))}
{material.tagIds.length > MAX_ROW_TAGS && (
<Tooltip
title={material.tagIds
.slice(MAX_ROW_TAGS)
.map((id) => tagMap.get(id)?.name ?? id)
.join("、")}
>
<Tag className="vmat-tag-overflow">+{material.tagIds.length - MAX_ROW_TAGS}</Tag>
</Tooltip>
)}
</>
)}
<TagDisplay tagIds={material.tagIds} tagMap={tagMap} onAddTag={() => onEdit()} />
</div>
{/* 进度条(可拖拽) */}
<div ref={progressRef} className="vmat-row-progress" onMouseDown={handleProgressMouseDown}>
<div ref={progressRef} className="vmat-row-progress" onMouseDown={handleMouseDown}>
<div className="vmat-row-progress-bar" style={{ width: `${progress}%` }} />
{isPlaying && <div className="vmat-progress-thumb" style={{ left: `${progress}%` }} />}
</div>
{/* 时长 */}
<span className="vmat-row-time">
{isPlaying ? formatDuration(currentTime) : formatDuration(material.duration)}
</span>
{/* 文件大小 */}
<span className="vmat-row-size">{formatFileSize(material.fileSize)}</span>
{/* 操作 */}
<div className="vmat-row-actions">
<button
type="button"
@@ -0,0 +1,73 @@
import React, { useRef } from "react"
import { UploadOutlined, SoundOutlined, CloseOutlined } from "@ant-design/icons"
import { formatFileSize } from "../../utils/format"
interface FileUploadFieldProps {
file: File | undefined
onChange: (file: File | undefined) => void
uploadProgress?: number | null
}
const FileUploadField: React.FC<FileUploadFieldProps> = ({ file, onChange, uploadProgress }) => {
const fileInputRef = useRef<HTMLInputElement>(null)
const handleDrop = (e: React.DragEvent) => {
e.preventDefault()
const f = e.dataTransfer.files[0]
if (f?.type.startsWith("audio/")) onChange(f)
}
return (
<div className="vmat-form-field">
<label className="vmat-form-label"> *</label>
<div
className="vmat-upload-zone"
onClick={() => fileInputRef.current?.click()}
onDragOver={(e) => e.preventDefault()}
onDrop={handleDrop}
>
<input
ref={fileInputRef}
type="file"
accept="audio/*"
style={{ display: "none" }}
onChange={(e) => {
const f = e.target.files?.[0]
if (f) onChange(f)
}}
/>
{file ? (
<div className="vmat-upload-selected">
<SoundOutlined className="vmat-upload-icon" />
<span className="vmat-upload-filename">{file.name}</span>
<span className="vmat-upload-filesize">{formatFileSize(file.size)}</span>
<button
type="button"
className="vmat-upload-clear"
onClick={(e) => {
e.stopPropagation()
onChange(undefined)
}}
>
<CloseOutlined />
</button>
</div>
) : (
<div className="vmat-upload-placeholder">
<UploadOutlined className="vmat-upload-icon" />
<p></p>
<span> MP3WAVAACFLAC </span>
</div>
)}
</div>
{uploadProgress !== null && uploadProgress !== undefined && (
<div className="vmat-upload-progress">
<div className="vmat-upload-progress-bar" style={{ width: `${uploadProgress}%` }} />
<span className="vmat-upload-progress-text">{uploadProgress}%</span>
</div>
)}
</div>
)
}
export default FileUploadField
@@ -0,0 +1,32 @@
import React from "react"
import type { VoiceGender } from "../../types"
import { GENDER_OPTIONS } from "../../constants"
import { genderClass } from "../../utils/format"
interface GenderSelectorProps {
value: VoiceGender
onChange: (value: VoiceGender) => void
}
const GenderSelector: React.FC<GenderSelectorProps> = ({ value, onChange }) => {
return (
<div className="vmat-form-field">
<label className="vmat-form-label"></label>
<div className="vmat-gender-group">
{GENDER_OPTIONS.map((opt) => (
<button
key={opt.value}
type="button"
className={`vmat-gender-btn${value === opt.value ? " active" : ""} ${genderClass(opt.value)}`}
onClick={() => onChange(opt.value)}
>
{opt.icon}
{opt.label}
</button>
))}
</div>
</div>
)
}
export default GenderSelector
@@ -0,0 +1,90 @@
import { useState, useRef, useCallback, useMemo } from "react"
import type { TagItem } from "@/api/tags"
interface UseTagInputOptions {
value: string[]
onChange: (tagIds: string[]) => void
tags: TagItem[]
onCreateTag: (name: string) => Promise<TagItem>
}
export function useTagInput({ value, onChange, tags, onCreateTag }: UseTagInputOptions) {
const [inputVal, setInputVal] = useState("")
const [showSuggestions, setShowSuggestions] = useState(false)
const inputRef = useRef<HTMLInputElement>(null)
const findTagByName = useCallback(
(name: string) => tags.find((t) => t.name.toLowerCase() === name.toLowerCase()),
[tags],
)
const addTagId = useCallback(
(tagId: string) => {
if (value.includes(tagId)) return
onChange([...value, tagId])
setInputVal("")
setShowSuggestions(false)
},
[value, onChange],
)
const addTagByName = useCallback(
async (name: string) => {
const trimmed = name.trim()
if (!trimmed) return
const existing = findTagByName(trimmed)
if (existing) {
addTagId(existing.id)
} else {
try {
const created = await onCreateTag(trimmed)
addTagId(created.id)
} catch {
/* 创建失败静默忽略 */
}
}
},
[findTagByName, addTagId, onCreateTag],
)
const removeTagId = useCallback(
(tagId: string) => {
onChange(value.filter((t) => t !== tagId))
},
[value, onChange],
)
const suggestions = useMemo(() => {
if (!inputVal.trim()) return []
const lower = inputVal.toLowerCase()
return tags.filter((t) => t.name.toLowerCase().includes(lower) && !value.includes(t.id))
}, [inputVal, tags, value])
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === "Enter") {
e.preventDefault()
if (suggestions.length > 0) {
addTagId(suggestions[0].id)
} else {
addTagByName(inputVal)
}
} else if (e.key === "Backspace" && !inputVal && value.length > 0) {
removeTagId(value[value.length - 1])
}
}
const focus = () => inputRef.current?.focus()
return {
inputVal,
setInputVal,
showSuggestions,
setShowSuggestions,
inputRef,
suggestions,
addTagId,
removeTagId,
handleKeyDown,
focus,
}
}
@@ -0,0 +1,41 @@
import React from "react"
import { Tooltip } from "antd"
import { Tag } from "@/components/ui"
import { type TagItem } from "@/api/tags"
import { MAX_ROW_TAGS, TAG_VARIANTS } from "../../constants"
interface TagDisplayProps {
tagIds: string[]
tagMap: Map<string, TagItem>
onAddTag?: () => void
}
const TagDisplay: React.FC<TagDisplayProps> = ({ tagIds, tagMap, onAddTag }) => {
if (tagIds.length === 0) {
return (
<span className="vmat-tag-empty" onClick={onAddTag}>
</span>
)
}
const visible = tagIds.slice(0, MAX_ROW_TAGS)
const overflow = tagIds.slice(MAX_ROW_TAGS)
return (
<>
{visible.map((tagId, i) => (
<Tag key={tagId} variant={TAG_VARIANTS[i % TAG_VARIANTS.length]}>
{tagMap.get(tagId)?.name ?? tagId}
</Tag>
))}
{overflow.length > 0 && (
<Tooltip title={overflow.map((id) => tagMap.get(id)?.name ?? id).join("、")}>
<Tag className="vmat-tag-overflow">+{overflow.length}</Tag>
</Tooltip>
)}
</>
)
}
export default TagDisplay
@@ -0,0 +1,38 @@
import { useRef, useCallback } from "react"
interface UseRowProgressOptions {
duration: number
onSeek: (time: number) => void
}
export function useRowProgress({ duration, onSeek }: UseRowProgressOptions) {
const progressRef = useRef<HTMLDivElement>(null)
const handleMouseDown = useCallback(
(e: React.MouseEvent<HTMLDivElement>) => {
if (!progressRef.current) return
e.preventDefault()
const doSeek = (ev: MouseEvent) => {
if (!progressRef.current) return
const rect = progressRef.current.getBoundingClientRect()
const percent = Math.max(0, Math.min(1, (ev.clientX - rect.left) / rect.width))
onSeek(percent * duration)
}
doSeek(e.nativeEvent)
const handleMove = (ev: MouseEvent) => doSeek(ev)
const handleUp = () => {
document.removeEventListener("mousemove", handleMove)
document.removeEventListener("mouseup", handleUp)
}
document.addEventListener("mousemove", handleMove)
document.addEventListener("mouseup", handleUp)
},
[duration, onSeek],
)
return { progressRef, handleMouseDown }
}
@@ -1,5 +1,5 @@
import { useState } from "react"
import { type VoiceMaterial } from "../../../types"
import { type VoiceMaterial } from "../../types"
import { type AssetLibraryItem } from "@/api/assets"
import { useVoiceUpload } from "./actions/useVoiceUpload"
import { useVoiceEdit } from "./actions/useVoiceEdit"
@@ -1,16 +1,9 @@
import React from "react"
import {
SoundOutlined,
PlayCircleOutlined,
PauseCircleOutlined,
DeleteOutlined,
ReloadOutlined,
CloseCircleOutlined,
UserOutlined,
} from "@ant-design/icons"
import { DeleteOutlined, ReloadOutlined, CloseCircleOutlined } from "@ant-design/icons"
import { Tooltip } from "antd"
import { type ClonedVoiceDisplay } from "@/pages/voices/types"
import { CLONE_STATUS_CONFIG } from "@/pages/voices/constants"
import CardHeader from "./clone-voice-card/CardHeader"
import CardFooter from "./clone-voice-card/CardFooter"
export interface CloneVoiceCardProps {
voice: ClonedVoiceDisplay
@@ -28,26 +21,18 @@ export interface CloneVoiceCardProps {
const CloneVoiceCard: React.FC<CloneVoiceCardProps> = ({
voice,
isPlaying,
currentTime,
onPlay,
onPause,
onUse,
onDelete,
onRetry,
onShowDetail,
...footerProps
}) => {
const statusCfg = CLONE_STATUS_CONFIG[voice.status]
const isFailed = voice.status === "failed"
const isProcessing = voice.status === "processing"
const genderText =
voice.gender === "male" ? "男声" : voice.gender === "female" ? "女声" : voice.gender
return (
<div
className={`xx-clone-card${isPlaying ? " playing" : ""}${isFailed ? " failed" : ""}`}
onClick={isFailed ? undefined : onShowDetail}
>
{/* 右上角操作按钮 */}
<div className="xx-clone-card-actions">
<Tooltip title="删除">
<button
@@ -77,38 +62,8 @@ const CloneVoiceCard: React.FC<CloneVoiceCardProps> = ({
)}
</div>
{/* 头部:头像 + 名称 + 状态 */}
<div className="xx-clone-card-header">
<div className={`xx-clone-avatar${isProcessing ? " xx-clone-avatar--processing" : ""}`}>
<SoundOutlined />
</div>
<div className="xx-clone-header-info">
<h4 className="xx-clone-name" title={voice.name}>
{voice.name}
</h4>
<span className={`xx-clone-status ${statusCfg.className}`}>
<span className="xx-clone-status-dot" />
{statusCfg.label}
</span>
</div>
</div>
<CardHeader voice={voice} />
{/* 描述 */}
{voice.description && <p className="xx-clone-desc">{voice.description}</p>}
{/* 元信息 */}
<div className="xx-clone-meta">
{(voice.gender || voice.language) && (
<span className="xx-clone-meta-item">
<UserOutlined />
{genderText}
{voice.language ? ` · ${voice.language}` : ""}
</span>
)}
<span className="xx-clone-meta-item">{voice.createdAt}</span>
</div>
{/* 错误信息 */}
{isFailed && voice.errorMessage && (
<div className="xx-clone-error">
<CloseCircleOutlined />
@@ -116,63 +71,7 @@ const CloneVoiceCard: React.FC<CloneVoiceCardProps> = ({
</div>
)}
{/* 底部操作区 */}
<div className="xx-clone-footer">
{voice.status === "ready" && (
<>
<button
type="button"
className="xx-clone-play-btn"
onClick={(e) => {
e.stopPropagation()
isPlaying ? onPause() : onPlay()
}}
title={isPlaying ? "暂停" : "试听"}
>
{isPlaying ? <PauseCircleOutlined /> : <PlayCircleOutlined />}
</button>
<div className="xx-clone-progress">
<div
className="xx-clone-progress-bar"
style={{
width: isPlaying
? `${Math.min((currentTime / Math.max(voice.duration, 1)) * 100, 100)}%`
: "0%",
}}
/>
</div>
<button
type="button"
className="xx-clone-use-btn"
onClick={(e) => {
e.stopPropagation()
onUse()
}}
>
使
</button>
</>
)}
{isProcessing && (
<div className="xx-clone-processing-hint">
<ReloadOutlined spin />
...
</div>
)}
{isFailed && (
<button
type="button"
className="xx-clone-retry-btn"
onClick={(e) => {
e.stopPropagation()
onRetry()
}}
>
<ReloadOutlined />
</button>
)}
</div>
<CardFooter voice={voice} isPlaying={isPlaying} onRetry={onRetry} {...footerProps} />
</div>
)
}
@@ -0,0 +1,87 @@
import React from "react"
import { PlayCircleOutlined, PauseCircleOutlined, ReloadOutlined } from "@ant-design/icons"
import { type ClonedVoiceDisplay } from "@/pages/voices/types"
interface CardFooterProps {
voice: ClonedVoiceDisplay
isPlaying: boolean
currentTime: number
onPlay: () => void
onPause: () => void
onUse: () => void
onRetry: () => void
}
const CardFooter: React.FC<CardFooterProps> = ({
voice,
isPlaying,
currentTime,
onPlay,
onPause,
onUse,
onRetry,
}) => {
const isFailed = voice.status === "failed"
const isProcessing = voice.status === "processing"
return (
<div className="xx-clone-footer">
{voice.status === "ready" && (
<>
<button
type="button"
className="xx-clone-play-btn"
onClick={(e) => {
e.stopPropagation()
isPlaying ? onPause() : onPlay()
}}
title={isPlaying ? "暂停" : "试听"}
>
{isPlaying ? <PauseCircleOutlined /> : <PlayCircleOutlined />}
</button>
<div className="xx-clone-progress">
<div
className="xx-clone-progress-bar"
style={{
width: isPlaying
? `${Math.min((currentTime / Math.max(voice.duration, 1)) * 100, 100)}%`
: "0%",
}}
/>
</div>
<button
type="button"
className="xx-clone-use-btn"
onClick={(e) => {
e.stopPropagation()
onUse()
}}
>
使
</button>
</>
)}
{isProcessing && (
<div className="xx-clone-processing-hint">
<ReloadOutlined spin />
...
</div>
)}
{isFailed && (
<button
type="button"
className="xx-clone-retry-btn"
onClick={(e) => {
e.stopPropagation()
onRetry()
}}
>
<ReloadOutlined />
</button>
)}
</div>
)
}
export default CardFooter
@@ -0,0 +1,51 @@
import React from "react"
import { SoundOutlined, UserOutlined } from "@ant-design/icons"
import { type ClonedVoiceDisplay } from "@/pages/voices/types"
import { CLONE_STATUS_CONFIG } from "@/pages/voices/constants"
interface CardHeaderProps {
voice: ClonedVoiceDisplay
}
const genderTextOf = (gender: string) =>
gender === "male" ? "男声" : gender === "female" ? "女声" : gender
const CardHeader: React.FC<CardHeaderProps> = ({ voice }) => {
const statusCfg = CLONE_STATUS_CONFIG[voice.status]
const isProcessing = voice.status === "processing"
const genderText = genderTextOf(voice.gender ?? "")
return (
<>
<div className="xx-clone-card-header">
<div className={`xx-clone-avatar${isProcessing ? " xx-clone-avatar--processing" : ""}`}>
<SoundOutlined />
</div>
<div className="xx-clone-header-info">
<h4 className="xx-clone-name" title={voice.name}>
{voice.name}
</h4>
<span className={`xx-clone-status ${statusCfg.className}`}>
<span className="xx-clone-status-dot" />
{statusCfg.label}
</span>
</div>
</div>
{voice.description && <p className="xx-clone-desc">{voice.description}</p>}
<div className="xx-clone-meta">
{(voice.gender || voice.language) && (
<span className="xx-clone-meta-item">
<UserOutlined />
{genderText}
{voice.language ? ` · ${voice.language}` : ""}
</span>
)}
<span className="xx-clone-meta-item">{voice.createdAt}</span>
</div>
</>
)
}
export default CardHeader
@@ -6,8 +6,6 @@
from __future__ import annotations
from typing import Any, Optional
# ── 单轨时间计算 ──────────────────────────────────────────────────────────────
@@ -261,7 +259,6 @@ def db_to_linear(db: float) -> float:
Returns:
线性音量值
"""
import math
return 10 ** (db / 20.0)
@@ -14,7 +14,6 @@ from __future__ import annotations
import logging
from pathlib import Path
from typing import Any
from packages.domain.pip_config import (
ANIMATION_FADE,
@@ -349,7 +348,7 @@ def build_pip_filters(
input_args: list[str] = []
current_label = base_label
for i, (layer, path) in enumerate(zip(layers, source_paths)):
for i, (layer, path) in enumerate(zip(layers, source_paths, strict=False)):
# 计算实际大小
pip_w, pip_h = compute_pip_size(layer, output_width, output_height)
@@ -483,4 +482,4 @@ def count_visible_layers(layers: list[PiPLayerConfig]) -> int:
def sort_layers_by_z_index(layers: list[PiPLayerConfig]) -> list[PiPLayerConfig]:
"""按 z_index 从小到大排序图层(z_index 小的先画,在底层)."""
return sorted(layers, key=lambda l: l.z_index)
return sorted(layers, key=lambda layer: layer.z_index)
@@ -13,12 +13,8 @@
from __future__ import annotations
from typing import Optional
from packages.domain.speed_config import (
DEFAULT_SPEED,
MAX_SPEED,
MIN_SPEED,
SpeedConfig,
_split_atempo_stages,
)
+3 -2
View File
@@ -8,9 +8,10 @@ from packages.adapters.sqlalchemy_impl import (
from packages.adapters.sqlalchemy_impl.schema_guard import assert_auto_create_schema_allowed
settings = get_settings()
ensure_database_exists(settings.database_url)
_db_url = settings.effective_database_url
ensure_database_exists(_db_url)
engine, SessionLocal = build_session_factory(
settings.database_url,
_db_url,
pool_size=settings.database_pool_size,
max_overflow=settings.database_max_overflow,
pool_timeout=settings.database_pool_timeout,
@@ -48,12 +48,20 @@ def build_session_factory(
return engine, session_factory
def _is_sqlite(database_url: str) -> bool:
"""检测是否为 SQLite 数据库 URL."""
return database_url.startswith("sqlite")
def _build_admin_url(database_url: str) -> URL:
url = make_url(database_url)
return url.set(database="postgres")
def ensure_database_exists(database_url: str) -> None:
"""确保数据库存在(仅 PostgreSQL 需要,SQLite 自动创建)."""
if _is_sqlite(database_url):
return
target_url = make_url(database_url)
admin_engine = create_engine(_build_admin_url(database_url), isolation_level="AUTOCOMMIT")
try:
@@ -70,6 +78,14 @@ def ensure_database_exists(database_url: str) -> None:
def initialize_database(engine) -> None:
"""初始化数据库 schema。
PostgreSQL 使用 advisory lock 防止并发初始化冲突;
SQLite 直接 create_all(单文件,无并发风险)。
"""
if _is_sqlite(str(engine.url)):
Base.metadata.create_all(bind=engine)
return
with engine.connect() as connection:
connection.execute(text("SELECT pg_advisory_lock(:lock_id)"), {"lock_id": SCHEMA_INIT_LOCK_ID})
try:
+13
View File
@@ -34,6 +34,9 @@ class SharedSettings(BaseSettings):
database_pool_timeout: int = 30
database_pool_recycle: int = 3600
# 测试用:使用 SQLite 内存数据库(CI 环境无需 PostgreSQL
use_in_memory_db: bool = False
# ── Redis ────────────────────────────────────────────────────────────
redis_url: str = "redis://localhost:6379/0"
@@ -66,6 +69,16 @@ class SharedSettings(BaseSettings):
doubao_timeout: int = 30
doubao_max_retries: int = 2
@property
def effective_database_url(self) -> str:
"""返回实际使用的数据库 URL。
当 USE_IN_MEMORY_DB=True 时返回 SQLite 内存 URL,否则返回 database_url。
"""
if self.use_in_memory_db:
return "sqlite:///./test.db"
return self.database_url
model_config = SettingsConfigDict(
env_file=".env",
env_file_encoding="utf-8",
-11
View File
@@ -22,22 +22,11 @@ import urllib.error
import urllib.request
from urllib.parse import urljoin
from packages.domain.url_security import (
ALLOWED_AUDIO_MIME_TYPES,
ALLOWED_IMAGE_MIME_TYPES,
)
from packages.domain.url_security import ALLOWED_PORTS as _allowed_ports_base
from packages.domain.url_security import ALLOWED_SCHEMES as _allowed_schemes_base
from packages.domain.url_security import (
ALLOWED_VIDEO_MIME_TYPES,
MAGIC_NUMBERS,
MAX_URL_LENGTH,
)
from packages.domain.url_security import UrlSecurityError as _UrlSecurityError_base
from packages.domain.url_security import check_internal_hostname as _check_internal_hostname_base
from packages.domain.url_security import check_ssrf_ip as _check_ssrf_ip_base
from packages.domain.url_security import is_ip_address as _is_ip_address_base
from packages.domain.url_security import is_trusted_domain as _is_trusted_domain_base
from packages.domain.url_security import validate_magic_number as _validate_magic_number_base
from packages.domain.url_security import validate_url_basic as _validate_url_basic_base
+2
View File
@@ -1,6 +1,8 @@
[pytest]
pythonpath = . apps/api apps/worker packages
testpaths = tests
# importlib 模式避免同名测试文件的模块名冲突
addopts = --import-mode=importlib
# ===== 覆盖率配置 =====
# 覆盖率统计范围(供 --cov 使用时的默认源)
+530
View File
@@ -0,0 +1,530 @@
"""asset_scoring 单元测试 - wave162
覆盖:
- 分辨率评分 score_resolution
- 时长评分 score_duration
- 码率评分 score_bitrate
- 加权总分 calculate_total_score
- 单个素材评分 score_asset_detail
- 时长分桶 _bucket_by_duration
- 多样性选择 diverse_selection
- 候选过滤 filter_candidates
"""
from dataclasses import dataclass
import pytest
from packages.domain.asset_scoring import (
AssetScoreDetail,
SmartSelectResult,
_bucket_by_duration,
calculate_total_score,
diverse_selection,
filter_candidates,
score_asset_detail,
score_bitrate,
score_duration,
score_resolution,
)
# ============================================================
# score_resolution
# ============================================================
class TestScoreResolution:
def test_none_width_returns_mid(self):
assert score_resolution(None, 1080) == 0.5
def test_none_height_returns_mid(self):
assert score_resolution(1920, None) == 0.5
def test_zero_dimension_returns_mid(self):
assert score_resolution(0, 1080) == 0.5
assert score_resolution(1920, 0) == 0.5
assert score_resolution(-1, 1080) == 0.5
def test_exact_target_returns_1(self):
assert score_resolution(1920, 1080) == 1.0
def test_higher_than_target_returns_1(self):
assert score_resolution(3840, 2160) == 1.0 # 4K
assert score_resolution(2560, 1440) == 1.0 # 2K
def test_lower_than_target_linear_decay(self):
# 720p = 1280*720 / 1920*1080 = 0.444 ratio
# score = 0.3 + 0.7 * 0.444 = 0.611
score = score_resolution(1280, 720)
assert 0.55 < score < 0.7
def test_very_low_has_floor(self):
# 最低不低于 0.1
score = score_resolution(100, 100)
assert score >= 0.1
def test_480p_still_reasonable(self):
score = score_resolution(640, 480)
assert 0.3 < score < 0.5
def test_custom_target(self):
score = score_resolution(1280, 720, target_width=1280, target_height=720)
assert score == 1.0
def test_between_0_and_1(self):
for w, h in [(1920, 1080), (1280, 720), (640, 480), (3840, 2160)]:
s = score_resolution(w, h)
assert 0.0 <= s <= 1.0
# ============================================================
# score_duration
# ============================================================
class TestScoreDuration:
def test_none_returns_mid(self):
assert score_duration(None) == 0.5
def test_zero_or_negative_returns_mid(self):
assert score_duration(0) == 0.5
assert score_duration(-1) == 0.5
def test_optimal_range_returns_1(self):
assert score_duration(3.0) == 1.0
assert score_duration(10.0) == 1.0
assert score_duration(30.0) == 1.0
assert score_duration(15.0) == 1.0
def test_short_duration_linear_decay(self):
# 1.5s: ratio = 1.5/3 = 0.5, score = 0.3 + 0.7*0.5 = 0.65
score = score_duration(1.5)
assert score == pytest.approx(0.65)
def test_very_short_above_floor(self):
score = score_duration(0.1)
assert 0.3 <= score < 0.5
def test_long_duration_penalty(self):
# 40s: excess=10, penalty=10/10*0.1=0.1, score=0.9
score = score_duration(40.0)
assert score == pytest.approx(0.9)
def test_very_long_minimum_floor(self):
# 超过很多,最低 0.2
score = score_duration(1000.0)
assert score >= 0.2
assert score < 0.5
def test_just_below_optimal(self):
score = score_duration(2.9)
assert 0.9 < score < 1.0
def test_just_above_optimal(self):
score = score_duration(30.1)
assert 0.9 < score < 1.0
# ============================================================
# score_bitrate
# ============================================================
class TestScoreBitrate:
def test_no_file_size_returns_mid(self):
assert score_bitrate(0, 10.0) == 0.5
def test_no_duration_returns_mid(self):
assert score_bitrate(1000000, None) == 0.5
assert score_bitrate(1000000, 0) == 0.5
assert score_bitrate(1000000, -1) == 0.5
def test_optimal_range_returns_1(self):
# 5 Mbps for 10s = 5*10^6 * 10 / 8 = 6,250,000 bytes
size_5mbps_10s = int(5_000_000 * 10 / 8)
assert score_bitrate(size_5mbps_10s, 10.0) == 1.0
def test_low_bitrate_decay(self):
# 500 Kbps for 10s
size_500kbps = int(500_000 * 10 / 8)
score = score_bitrate(size_500kbps, 10.0)
assert 0.3 < score < 0.7
def test_high_bitrate_moderate_penalty(self):
# 16 Mbps (2x optimal high), excess=1.0, penalty=min(0.5, 1.0*0.2)=0.2
# score = 0.8
size_16mbps = int(16_000_000 * 10 / 8)
score = score_bitrate(size_16mbps, 10.0)
assert 0.7 < score < 0.9
def test_very_high_bitrate_floor(self):
# 极高码率,最低 0.5
huge_size = 10**9 # 1GB for 1s = 8Gbps
score = score_bitrate(huge_size, 1.0)
assert score >= 0.5
def test_between_0_and_1(self):
for size, dur in [(1000, 1), (1000000, 10), (100000000, 5)]:
s = score_bitrate(size, dur)
assert 0.0 <= s <= 1.0
# ============================================================
# calculate_total_score
# ============================================================
class TestCalculateTotalScore:
def test_all_perfect_equals_1(self):
assert calculate_total_score(1.0, 1.0, 1.0, 1.0) == 1.0
def test_all_zero_equals_0(self):
assert calculate_total_score(0.0, 0.0, 0.0, 0.0) == 0.0
def test_weighted_sum(self):
# 0.5*0.5 + 0.2*0.5 + 0.2*0.5 + 0.1*0.5 = 0.25+0.1+0.1+0.05 = 0.5
assert calculate_total_score(0.5, 0.5, 0.5, 0.5) == pytest.approx(0.5)
def test_quality_has_highest_weight(self):
# 只提高质量分,对比只提高其他
q_high = calculate_total_score(1.0, 0.0, 0.0, 0.0)
r_high = calculate_total_score(0.0, 1.0, 0.0, 0.0)
assert q_high > r_high # 0.5 > 0.2
def test_bitrate_has_lowest_weight(self):
b_high = calculate_total_score(0.0, 0.0, 0.0, 1.0)
q_high = calculate_total_score(1.0, 0.0, 0.0, 0.0)
assert b_high < q_high # 0.1 < 0.5
def test_rounded_to_4_decimals(self):
result = calculate_total_score(0.3333, 0.3333, 0.3333, 0.3333)
assert round(result, 4) == result
# ============================================================
# score_asset_detail
# ============================================================
class TestScoreAssetDetail:
def test_returns_detail_object(self):
detail = score_asset_detail(
asset_id="a1",
quality=80.0,
width=1920,
height=1080,
duration=10.0,
file_size=5_000_000,
)
assert isinstance(detail, AssetScoreDetail)
assert detail.asset_id == "a1"
assert 0.0 <= detail.total_score <= 1.0
def test_perfect_asset_high_score(self):
detail = score_asset_detail(
asset_id="perfect",
quality=100.0,
width=1920,
height=1080,
duration=10.0,
file_size=6_250_000, # 5Mbps for 10s
)
assert detail.total_score > 0.9
def test_quality_none_defaults_mid(self):
detail = score_asset_detail(
asset_id="a1",
quality=None,
width=1920,
height=1080,
duration=10.0,
file_size=5_000_000,
)
assert detail.quality_score == 0.5
def test_quality_normalized(self):
detail = score_asset_detail(
asset_id="a1",
quality=50.0,
width=1920,
height=1080,
duration=10.0,
file_size=5_000_000,
)
assert detail.quality_score == pytest.approx(0.5)
def test_custom_target_resolution(self):
detail = score_asset_detail(
asset_id="a1",
quality=100.0,
width=1280,
height=720,
duration=10.0,
file_size=5_000_000,
target_width=1280,
target_height=720,
)
assert detail.resolution_score == 1.0
def test_total_score_matches_components(self):
detail = score_asset_detail(
asset_id="a1",
quality=80.0,
width=1920,
height=1080,
duration=10.0,
file_size=5_000_000,
)
expected = calculate_total_score(
detail.quality_score,
detail.resolution_score,
detail.duration_score,
detail.bitrate_score,
)
assert detail.total_score == pytest.approx(expected, abs=0.001)
# ============================================================
# _bucket_by_duration
# ============================================================
class TestBucketByDuration:
def test_none_is_unknown(self):
item = AssetScoreDetail("a", 0.5, 0.5, 0.5, 0.5, 0.5, None)
assert _bucket_by_duration(item) == "unknown"
def test_short(self):
item = AssetScoreDetail("a", 0.5, 0.5, 0.5, 0.5, 0.5, 3.0)
assert _bucket_by_duration(item) == "short"
def test_short_boundary(self):
item = AssetScoreDetail("a", 0.5, 0.5, 0.5, 0.5, 0.5, 4.9)
assert _bucket_by_duration(item) == "short"
def test_medium(self):
item = AssetScoreDetail("a", 0.5, 0.5, 0.5, 0.5, 0.5, 10.0)
assert _bucket_by_duration(item) == "medium"
def test_medium_boundary(self):
item = AssetScoreDetail("a", 0.5, 0.5, 0.5, 0.5, 0.5, 5.0)
assert _bucket_by_duration(item) == "medium"
def test_medium_upper_boundary(self):
item = AssetScoreDetail("a", 0.5, 0.5, 0.5, 0.5, 0.5, 14.9)
assert _bucket_by_duration(item) == "medium"
def test_long(self):
item = AssetScoreDetail("a", 0.5, 0.5, 0.5, 0.5, 0.5, 20.0)
assert _bucket_by_duration(item) == "long"
def test_long_boundary(self):
item = AssetScoreDetail("a", 0.5, 0.5, 0.5, 0.5, 0.5, 15.0)
assert _bucket_by_duration(item) == "long"
# ============================================================
# diverse_selection
# ============================================================
def _make_detail(asset_id: str, score: float, duration: float) -> AssetScoreDetail:
return AssetScoreDetail(
asset_id=asset_id,
total_score=score,
quality_score=score,
resolution_score=score,
duration_score=score,
bitrate_score=score,
duration=duration,
)
class TestDiverseSelection:
def test_empty_input_returns_empty(self):
assert diverse_selection([], 5) == []
def test_zero_count_returns_empty(self):
items = [_make_detail("a1", 0.9, 10.0)]
assert diverse_selection(items, 0) == []
def test_negative_count_returns_empty(self):
items = [_make_detail("a1", 0.9, 10.0)]
assert diverse_selection(items, -1) == []
def test_fewer_items_than_count(self):
items = [_make_detail("a1", 0.9, 10.0), _make_detail("a2", 0.8, 3.0)]
result = diverse_selection(items, 10)
assert len(result) == 2
def test_picks_top_from_each_bucket(self):
# 3个桶各有3个素材,选3个
items = [
_make_detail("s1", 0.95, 2.0),
_make_detail("m1", 0.9, 10.0),
_make_detail("l1", 0.85, 20.0),
_make_detail("s2", 0.8, 3.0),
_make_detail("m2", 0.75, 8.0),
_make_detail("l2", 0.7, 25.0),
]
result = diverse_selection(items, 3)
assert len(result) == 3
ids = [d.asset_id for d in result]
assert "s1" in ids
assert "m1" in ids
assert "l1" in ids
def test_base_quota_when_count_large(self):
# count=6, base_quota=max(1, 6//3)=2
items = [
_make_detail("s1", 1.0, 2.0),
_make_detail("s2", 0.9, 3.0),
_make_detail("s3", 0.8, 4.0),
_make_detail("m1", 0.95, 10.0),
_make_detail("m2", 0.85, 12.0),
_make_detail("l1", 0.92, 20.0),
_make_detail("l2", 0.82, 30.0),
]
result = diverse_selection(items, 6)
assert len(result) == 6
ids = [d.asset_id for d in result]
# 每桶至少2个
short_count = sum(1 for d in result if d.duration and d.duration < 5)
assert short_count >= 2
def test_remaining_filled_by_global_score(self):
# 只有2个桶有内容,count=5,配额用完后剩余从全局取
items = [
_make_detail("s1", 1.0, 2.0),
_make_detail("s2", 0.9, 3.0),
_make_detail("m1", 0.95, 10.0),
_make_detail("m2", 0.8, 12.0),
_make_detail("s3", 0.7, 4.0),
_make_detail("s4", 0.6, 1.0),
_make_detail("m3", 0.5, 8.0),
]
result = diverse_selection(items, 5)
assert len(result) == 5
# 最高分的都应该在
ids = [d.asset_id for d in result]
assert "s1" in ids
assert "m1" in ids
def test_single_bucket(self):
items = [
_make_detail("s1", 1.0, 2.0),
_make_detail("s2", 0.9, 3.0),
_make_detail("s3", 0.8, 4.0),
]
result = diverse_selection(items, 2)
assert len(result) == 2
assert result[0].asset_id == "s1"
assert result[1].asset_id == "s2"
def test_unknown_duration_fallback(self):
# 已知素材不够时用未知时长的补充
items = [
_make_detail("s1", 1.0, 2.0),
_make_detail("u1", 0.95, None),
_make_detail("u2", 0.9, None),
]
result = diverse_selection(items, 3)
assert len(result) == 3
ids = [d.asset_id for d in result]
assert "s1" in ids
assert "u1" in ids
def test_no_duplicates(self):
items = [
_make_detail("s1", 1.0, 2.0),
_make_detail("m1", 0.9, 10.0),
]
result = diverse_selection(items, 5)
ids = [d.asset_id for d in result]
assert len(ids) == len(set(ids))
# ============================================================
# filter_candidates
# ============================================================
@dataclass
class FakeAsset:
status: str = "ready"
mime_type: str = "video/mp4"
quality_score: float | None = 50.0
class TestFilterCandidates:
def test_ready_video_passes(self):
assets = [FakeAsset()]
candidates, filtered = filter_candidates(assets)
assert len(candidates) == 1
assert filtered == 0
def test_non_ready_filtered(self):
assets = [FakeAsset(status="uploading"), FakeAsset(status="processing")]
candidates, filtered = filter_candidates(assets)
assert len(candidates) == 0
assert filtered == 0 # 被状态过滤的不计入质量门槛
def test_non_video_filtered(self):
assets = [FakeAsset(mime_type="image/jpeg"), FakeAsset(mime_type="audio/mp3")]
candidates, filtered = filter_candidates(assets)
assert len(candidates) == 0
def test_low_quality_filtered(self):
assets = [FakeAsset(quality_score=10.0), FakeAsset(quality_score=80.0)]
candidates, filtered = filter_candidates(assets, min_quality_score=30.0)
assert len(candidates) == 1
assert filtered == 1
def test_quality_none_passes(self):
assets = [FakeAsset(quality_score=None)]
candidates, filtered = filter_candidates(assets)
assert len(candidates) == 1
assert filtered == 0
def test_exactly_min_quality_passes(self):
assets = [FakeAsset(quality_score=30.0)]
candidates, filtered = filter_candidates(assets, min_quality_score=30.0)
assert len(candidates) == 1
def test_custom_min_quality(self):
assets = [
FakeAsset(quality_score=40.0),
FakeAsset(quality_score=60.0),
FakeAsset(quality_score=80.0),
]
candidates, filtered = filter_candidates(assets, min_quality_score=50.0)
assert len(candidates) == 2
assert filtered == 1
def test_empty_input(self):
candidates, filtered = filter_candidates([])
assert candidates == []
assert filtered == 0
def test_mime_type_none(self):
# None 的 mime_type 也应该被过滤掉(不是video开头)
asset = FakeAsset(mime_type="")
candidates, _ = filter_candidates([asset])
assert len(candidates) == 0
def test_with_enum_status(self):
from enum import Enum
class StatusEnum(Enum):
READY = "ready"
UPLOADING = "uploading"
@dataclass
class EnumAsset:
status: StatusEnum = StatusEnum.READY
mime_type: str = "video/mp4"
quality_score: float = 50.0
assets = [EnumAsset()]
candidates, filtered = filter_candidates(assets)
assert len(candidates) == 1
@@ -5,6 +5,7 @@ domain 层纯逻辑模块,0 FFmpeg 依赖,快速轻量。
from __future__ import annotations
import dataclasses
from unittest.mock import MagicMock
import pytest
@@ -131,7 +132,7 @@ class TestClipFilterChain:
def test_frozen(self):
"""frozen dataclass 不可修改."""
chain = _make_chain()
with pytest.raises(Exception):
with pytest.raises(dataclasses.FrozenInstanceError):
chain.duration = 10.0 # type: ignore
+2 -2
View File
@@ -934,7 +934,7 @@ class TestSortLayersByZIndex:
_make_layer(z_index=3, source="/tmp/c.mp4"),
]
sorted_layers = sort_layers_by_z_index(layers)
assert [l.z_index for l in sorted_layers] == [1, 3, 5]
assert [layer.z_index for layer in sorted_layers] == [1, 3, 5]
def test_same_z_index_stable(self):
"""相同 z_index 保持相对顺序."""
@@ -963,4 +963,4 @@ class TestSortLayersByZIndex:
_make_layer(z_index=3, source="/tmp/3.mp4"),
]
sorted_layers = sort_layers_by_z_index(layers)
assert [l.z_index for l in sorted_layers] == [-5, 0, 3]
assert [layer.z_index for layer in sorted_layers] == [-5, 0, 3]
+3 -1
View File
@@ -2,6 +2,8 @@
from __future__ import annotations
import dataclasses
import pytest
from packages.domain.transition_presets import (
@@ -57,7 +59,7 @@ class TestTransitionPreset:
def test_frozen_immutable(self):
"""frozen dataclass 不可修改."""
p = TransitionPreset(id="t", name="T", category="basic")
with pytest.raises(Exception): # FrozenInstanceError
with pytest.raises(dataclasses.FrozenInstanceError):
p.name = "新名字"
def test_not_hashable_due_to_list(self):