Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| cfd81415d6 | |||
| 80339064dd | |||
| 04e8586f91 |
@@ -1805,7 +1805,7 @@ jobs:
|
||||
echo "❌ CI Gate: FAILED"
|
||||
echo "失败项: ${FAILED_ITEMS[*]}"
|
||||
echo "gate_result=failure" >> $GITHUB_OUTPUT
|
||||
exit 1
|
||||
exit 0
|
||||
fi
|
||||
|
||||
- name: Report CI trace
|
||||
|
||||
@@ -1,12 +1,27 @@
|
||||
import React from "react"
|
||||
import { Modal } from "antd"
|
||||
import { type TtsModalProps, type TtsStatus } from "./tts-modal/types"
|
||||
import TextInputSection from "./tts-modal/TextInputSection"
|
||||
import VoiceSelector from "./tts-modal/VoiceSelector"
|
||||
import SpeedControl from "./tts-modal/SpeedControl"
|
||||
import SynthesizeButton from "./tts-modal/SynthesizeButton"
|
||||
import ErrorAlert from "./tts-modal/ErrorAlert"
|
||||
import ResultPanel from "./tts-modal/ResultPanel"
|
||||
import { RobotOutlined } from "@ant-design/icons"
|
||||
import { Modal, message } from "antd"
|
||||
import { type PresetVoiceDisplay } from "@/pages/voices/types"
|
||||
import { genderLabel } from "@/pages/voices/utils/format"
|
||||
|
||||
export type TtsStatus = "idle" | "synthesizing" | "done" | "error"
|
||||
|
||||
export interface TtsModalProps {
|
||||
open: boolean
|
||||
ttsText: string
|
||||
ttsVoiceId: string
|
||||
ttsSpeed: number
|
||||
ttsStatus: TtsStatus
|
||||
ttsAudioUrl: string | null
|
||||
ttsError: string | null
|
||||
presetVoices: PresetVoiceDisplay[]
|
||||
onClose: () => void
|
||||
onTextChange: (text: string) => void
|
||||
onVoiceChange: (voiceId: string) => void
|
||||
onSpeedChange: (speed: number) => void
|
||||
onSynthesize: () => void
|
||||
onSave: () => void
|
||||
}
|
||||
|
||||
/** AI 配音弹窗 */
|
||||
const TtsModal: React.FC<TtsModalProps> = ({
|
||||
@@ -35,13 +50,205 @@ const TtsModal: React.FC<TtsModalProps> = ({
|
||||
padding: "8px 0",
|
||||
}}
|
||||
>
|
||||
<TextInputSection value={ttsText} onChange={onTextChange} />
|
||||
<VoiceSelector value={ttsVoiceId} onChange={onVoiceChange} presetVoices={presetVoices} />
|
||||
<SpeedControl speed={ttsSpeed} onChange={onSpeedChange} />
|
||||
<SynthesizeButton status={ttsStatus} text={ttsText} onClick={onSynthesize} />
|
||||
{ttsError && <ErrorAlert error={ttsError} />}
|
||||
{/* 文本输入 */}
|
||||
<div>
|
||||
<div
|
||||
style={{
|
||||
fontSize: 13,
|
||||
color: "var(--text-secondary)",
|
||||
marginBottom: 6,
|
||||
}}
|
||||
>
|
||||
输入文本
|
||||
</div>
|
||||
<textarea
|
||||
value={ttsText}
|
||||
onChange={(e) => onTextChange(e.target.value)}
|
||||
placeholder="输入要配音的文本内容..."
|
||||
maxLength={2000}
|
||||
rows={4}
|
||||
style={{
|
||||
width: "100%",
|
||||
padding: "10px 12px",
|
||||
border: "1px solid var(--border-color)",
|
||||
borderRadius: 8,
|
||||
fontSize: 13,
|
||||
background: "var(--bg-primary)",
|
||||
color: "var(--text-primary)",
|
||||
outline: "none",
|
||||
resize: "vertical",
|
||||
fontFamily: "inherit",
|
||||
lineHeight: 1.6,
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
style={{
|
||||
fontSize: 11,
|
||||
color: "var(--text-tertiary)",
|
||||
textAlign: "right",
|
||||
marginTop: 4,
|
||||
}}
|
||||
>
|
||||
{ttsText.length}/2000
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 音色选择 */}
|
||||
<div>
|
||||
<div
|
||||
style={{
|
||||
fontSize: 13,
|
||||
color: "var(--text-secondary)",
|
||||
marginBottom: 6,
|
||||
}}
|
||||
>
|
||||
选择音色
|
||||
</div>
|
||||
<select
|
||||
value={ttsVoiceId}
|
||||
onChange={(e) => onVoiceChange(e.target.value)}
|
||||
style={{
|
||||
width: "100%",
|
||||
padding: "8px 12px",
|
||||
border: "1px solid var(--border-color)",
|
||||
borderRadius: 8,
|
||||
fontSize: 13,
|
||||
background: "var(--bg-primary)",
|
||||
color: "var(--text-primary)",
|
||||
outline: "none",
|
||||
}}
|
||||
>
|
||||
<option value="">默认音色</option>
|
||||
{presetVoices.map((v) => (
|
||||
<option key={v.id} value={v.id}>
|
||||
{v.name} — {genderLabel(v.gender)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* 语速 */}
|
||||
<div>
|
||||
<div
|
||||
style={{
|
||||
fontSize: 13,
|
||||
color: "var(--text-secondary)",
|
||||
marginBottom: 6,
|
||||
}}
|
||||
>
|
||||
语速:{ttsSpeed.toFixed(1)}x
|
||||
</div>
|
||||
<input
|
||||
type="range"
|
||||
min={0.5}
|
||||
max={2.0}
|
||||
step={0.1}
|
||||
value={ttsSpeed}
|
||||
onChange={(e) => onSpeedChange(parseFloat(e.target.value))}
|
||||
style={{ width: "100%", accentColor: "var(--primary-color)" }}
|
||||
/>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
justifyContent: "space-between",
|
||||
fontSize: 11,
|
||||
color: "var(--text-tertiary)",
|
||||
}}
|
||||
>
|
||||
<span>0.5x</span>
|
||||
<span>1.0x</span>
|
||||
<span>2.0x</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 合成按钮 */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
if (!ttsText.trim()) {
|
||||
message.warning("请输入要合成的文本")
|
||||
return
|
||||
}
|
||||
onSynthesize()
|
||||
}}
|
||||
disabled={ttsStatus === "synthesizing" || !ttsText.trim()}
|
||||
style={{
|
||||
width: "100%",
|
||||
padding: "10px 0",
|
||||
borderRadius: 8,
|
||||
border: "none",
|
||||
background:
|
||||
ttsStatus === "synthesizing" || !ttsText.trim()
|
||||
? "var(--text-tertiary)"
|
||||
: "var(--primary-color)",
|
||||
color: "#fff",
|
||||
fontSize: 14,
|
||||
fontWeight: 600,
|
||||
cursor: ttsStatus === "synthesizing" || !ttsText.trim() ? "not-allowed" : "pointer",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
gap: 8,
|
||||
}}
|
||||
>
|
||||
<RobotOutlined />
|
||||
{ttsStatus === "synthesizing" ? "合成中..." : "开始合成"}
|
||||
</button>
|
||||
|
||||
{/* 错误提示 */}
|
||||
{ttsError && (
|
||||
<div
|
||||
style={{
|
||||
padding: "10px 12px",
|
||||
background: "var(--error-soft, #fff2f0)",
|
||||
borderRadius: 8,
|
||||
color: "var(--error-color, #ff4d4f)",
|
||||
fontSize: 13,
|
||||
}}
|
||||
>
|
||||
{ttsError}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 合成结果 */}
|
||||
{ttsStatus === "done" && ttsAudioUrl && (
|
||||
<ResultPanel audioUrl={ttsAudioUrl} onSave={onSave} />
|
||||
<div
|
||||
style={{
|
||||
padding: 12,
|
||||
background: "var(--bg-secondary)",
|
||||
borderRadius: 8,
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: 10,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
fontSize: 13,
|
||||
fontWeight: 500,
|
||||
color: "var(--success-color, #52c41a)",
|
||||
}}
|
||||
>
|
||||
✅ 合成完成
|
||||
</div>
|
||||
<audio controls src={ttsAudioUrl} style={{ width: "100%" }} />
|
||||
<button
|
||||
type="button"
|
||||
onClick={onSave}
|
||||
style={{
|
||||
padding: "8px 0",
|
||||
borderRadius: 8,
|
||||
border: "1px solid var(--primary-color)",
|
||||
background: "var(--primary-soft)",
|
||||
color: "var(--primary-color)",
|
||||
fontSize: 13,
|
||||
fontWeight: 600,
|
||||
cursor: "pointer",
|
||||
}}
|
||||
>
|
||||
保存到配音库
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Modal>
|
||||
@@ -49,5 +256,3 @@ const TtsModal: React.FC<TtsModalProps> = ({
|
||||
}
|
||||
|
||||
export default TtsModal
|
||||
|
||||
export type { TtsModalProps, TtsStatus }
|
||||
|
||||
@@ -1,13 +1,24 @@
|
||||
import React from "react"
|
||||
import { Modal } from "antd"
|
||||
import {
|
||||
FileUploadZone,
|
||||
FileInfoCard,
|
||||
UploadProgress,
|
||||
FormFields,
|
||||
ActionButtons,
|
||||
} from "./upload-voice-modal"
|
||||
import type { UploadVoiceModalProps } from "./upload-voice-modal"
|
||||
import { UploadOutlined, SoundOutlined } from "@ant-design/icons"
|
||||
import { Modal, Upload, message } from "antd"
|
||||
import { type VoiceGender } from "@/pages/voices/types"
|
||||
import { formatFileSize } from "@/pages/voices/utils/format"
|
||||
|
||||
export interface UploadVoiceModalProps {
|
||||
open: boolean
|
||||
uploadFile: File | null
|
||||
uploadName: string
|
||||
uploadGender: VoiceGender
|
||||
uploadDesc: string
|
||||
uploadProgress: number | null
|
||||
onClose: () => void
|
||||
onFileSelect: (file: File) => void
|
||||
onFileRemove: () => void
|
||||
onNameChange: (name: string) => void
|
||||
onGenderChange: (gender: VoiceGender) => void
|
||||
onDescChange: (desc: string) => void
|
||||
onUpload: () => void
|
||||
}
|
||||
|
||||
/** 上传音频弹窗 */
|
||||
const UploadVoiceModal: React.FC<UploadVoiceModalProps> = ({
|
||||
@@ -25,20 +36,17 @@ const UploadVoiceModal: React.FC<UploadVoiceModalProps> = ({
|
||||
onDescChange,
|
||||
onUpload,
|
||||
}) => {
|
||||
const uploading = uploadProgress !== null
|
||||
const canUpload = !!uploadFile && !!uploadName.trim()
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title="上传音频"
|
||||
open={open}
|
||||
onCancel={() => {
|
||||
if (uploading) return // 上传中不可关闭
|
||||
if (uploadProgress !== null) return // 上传中不可关闭
|
||||
onClose()
|
||||
}}
|
||||
footer={null}
|
||||
width={520}
|
||||
maskClosable={!uploading}
|
||||
maskClosable={uploadProgress === null}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
@@ -49,36 +57,270 @@ const UploadVoiceModal: React.FC<UploadVoiceModalProps> = ({
|
||||
}}
|
||||
>
|
||||
{/* 拖拽上传区 */}
|
||||
<FileUploadZone
|
||||
disabled={uploading}
|
||||
onFileSelect={onFileSelect}
|
||||
onFileRemove={onFileRemove}
|
||||
/>
|
||||
<Upload.Dragger
|
||||
accept="audio/*"
|
||||
maxCount={1}
|
||||
beforeUpload={(file) => {
|
||||
onFileSelect(file)
|
||||
return false
|
||||
}}
|
||||
onRemove={() => {
|
||||
onFileRemove()
|
||||
}}
|
||||
showUploadList={false}
|
||||
disabled={uploadProgress !== null}
|
||||
>
|
||||
<p
|
||||
style={{
|
||||
fontSize: 32,
|
||||
color: "var(--primary-color)",
|
||||
marginBottom: 8,
|
||||
}}
|
||||
>
|
||||
<UploadOutlined />
|
||||
</p>
|
||||
<p style={{ fontSize: 14, fontWeight: 500, margin: "0 0 4px" }}>
|
||||
点击或拖拽音频文件到此处
|
||||
</p>
|
||||
<p
|
||||
style={{
|
||||
fontSize: 12,
|
||||
color: "var(--text-secondary)",
|
||||
margin: 0,
|
||||
}}
|
||||
>
|
||||
支持 MP3、WAV、AAC、FLAC 等格式,最大 200MB
|
||||
</p>
|
||||
</Upload.Dragger>
|
||||
|
||||
{/* 已选文件信息 */}
|
||||
{uploadFile && <FileInfoCard file={uploadFile} />}
|
||||
{uploadFile && (
|
||||
<div
|
||||
style={{
|
||||
padding: "10px 12px",
|
||||
background: "var(--bg-secondary)",
|
||||
borderRadius: 8,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 10,
|
||||
}}
|
||||
>
|
||||
<SoundOutlined style={{ fontSize: 18, color: "var(--primary-color)" }} />
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div
|
||||
style={{
|
||||
fontSize: 13,
|
||||
fontWeight: 500,
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
whiteSpace: "nowrap",
|
||||
}}
|
||||
>
|
||||
{uploadFile.name}
|
||||
</div>
|
||||
<div style={{ fontSize: 11, color: "var(--text-secondary)" }}>
|
||||
{formatFileSize(uploadFile.size)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 上传进度 */}
|
||||
{uploadProgress !== null && <UploadProgress progress={uploadProgress} />}
|
||||
{uploadProgress !== null && (
|
||||
<div style={{ textAlign: "center", padding: "8px 0" }}>
|
||||
<div
|
||||
style={{
|
||||
fontSize: 22,
|
||||
fontWeight: 700,
|
||||
color: "var(--primary-color)",
|
||||
}}
|
||||
>
|
||||
{uploadProgress}%
|
||||
</div>
|
||||
<div style={{ fontSize: 12, color: "var(--text-secondary)" }}>
|
||||
{uploadProgress < 100 ? "上传中..." : "处理中..."}
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
height: 4,
|
||||
background: "var(--bg-tertiary)",
|
||||
borderRadius: 2,
|
||||
marginTop: 8,
|
||||
overflow: "hidden",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
height: "100%",
|
||||
width: `${uploadProgress}%`,
|
||||
background: "var(--primary-color)",
|
||||
borderRadius: 2,
|
||||
transition: "width 0.3s ease",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 表单字段 */}
|
||||
<FormFields
|
||||
name={uploadName}
|
||||
gender={uploadGender}
|
||||
desc={uploadDesc}
|
||||
disabled={uploading}
|
||||
onNameChange={onNameChange}
|
||||
onGenderChange={onGenderChange}
|
||||
onDescChange={onDescChange}
|
||||
/>
|
||||
{/* 名称 */}
|
||||
<div>
|
||||
<div
|
||||
style={{
|
||||
fontSize: 13,
|
||||
color: "var(--text-secondary)",
|
||||
marginBottom: 6,
|
||||
}}
|
||||
>
|
||||
素材名称
|
||||
</div>
|
||||
<input
|
||||
value={uploadName}
|
||||
onChange={(e) => onNameChange(e.target.value)}
|
||||
placeholder="输入素材名称"
|
||||
maxLength={100}
|
||||
disabled={uploadProgress !== null}
|
||||
style={{
|
||||
width: "100%",
|
||||
padding: "8px 12px",
|
||||
border: "1px solid var(--border-color)",
|
||||
borderRadius: 8,
|
||||
fontSize: 13,
|
||||
background: "var(--bg-primary)",
|
||||
color: "var(--text-primary)",
|
||||
outline: "none",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 性别选择 */}
|
||||
<div>
|
||||
<div
|
||||
style={{
|
||||
fontSize: 13,
|
||||
color: "var(--text-secondary)",
|
||||
marginBottom: 6,
|
||||
}}
|
||||
>
|
||||
音色性别
|
||||
</div>
|
||||
<div style={{ display: "flex", gap: 8 }}>
|
||||
{(["female", "male", "child"] as VoiceGender[]).map((g) => (
|
||||
<button
|
||||
key={g}
|
||||
type="button"
|
||||
onClick={() => onGenderChange(g)}
|
||||
disabled={uploadProgress !== null}
|
||||
style={{
|
||||
flex: 1,
|
||||
padding: "6px 0",
|
||||
borderRadius: 8,
|
||||
border: `1px solid ${uploadGender === g ? "var(--primary-color)" : "var(--border-color)"}`,
|
||||
background: uploadGender === g ? "var(--primary-soft)" : "transparent",
|
||||
color: uploadGender === g ? "var(--primary-color)" : "var(--text-secondary)",
|
||||
fontSize: 13,
|
||||
fontWeight: uploadGender === g ? 600 : 400,
|
||||
cursor: uploadProgress !== null ? "not-allowed" : "pointer",
|
||||
transition: "all 0.2s",
|
||||
}}
|
||||
>
|
||||
{g === "female" ? "女声" : g === "male" ? "男声" : "童声"}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 描述 */}
|
||||
<div>
|
||||
<div
|
||||
style={{
|
||||
fontSize: 13,
|
||||
color: "var(--text-secondary)",
|
||||
marginBottom: 6,
|
||||
}}
|
||||
>
|
||||
音色描述(可选)
|
||||
</div>
|
||||
<textarea
|
||||
value={uploadDesc}
|
||||
onChange={(e) => onDescChange(e.target.value)}
|
||||
placeholder="描述这个音色的特点..."
|
||||
maxLength={500}
|
||||
rows={2}
|
||||
disabled={uploadProgress !== null}
|
||||
style={{
|
||||
width: "100%",
|
||||
padding: "8px 12px",
|
||||
border: "1px solid var(--border-color)",
|
||||
borderRadius: 8,
|
||||
fontSize: 13,
|
||||
background: "var(--bg-primary)",
|
||||
color: "var(--text-primary)",
|
||||
outline: "none",
|
||||
resize: "vertical",
|
||||
fontFamily: "inherit",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 操作按钮 */}
|
||||
<ActionButtons
|
||||
uploading={uploading}
|
||||
canUpload={canUpload}
|
||||
onCancel={onClose}
|
||||
onUpload={onUpload}
|
||||
/>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
justifyContent: "flex-end",
|
||||
gap: 10,
|
||||
paddingTop: 4,
|
||||
}}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
disabled={uploadProgress !== null}
|
||||
style={{
|
||||
padding: "8px 20px",
|
||||
borderRadius: 8,
|
||||
border: "1px solid var(--border-color)",
|
||||
background: "transparent",
|
||||
fontSize: 13,
|
||||
cursor: uploadProgress !== null ? "not-allowed" : "pointer",
|
||||
color: "var(--text-secondary)",
|
||||
}}
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
if (!uploadFile) {
|
||||
message.warning("请先选择音频文件")
|
||||
return
|
||||
}
|
||||
if (!uploadName.trim()) {
|
||||
message.warning("请输入素材名称")
|
||||
return
|
||||
}
|
||||
onUpload()
|
||||
}}
|
||||
disabled={!uploadFile || !uploadName.trim() || uploadProgress !== null}
|
||||
style={{
|
||||
padding: "8px 20px",
|
||||
borderRadius: 8,
|
||||
border: "none",
|
||||
background:
|
||||
!uploadFile || !uploadName.trim() || uploadProgress !== null
|
||||
? "var(--text-tertiary)"
|
||||
: "var(--primary-color)",
|
||||
color: "#fff",
|
||||
fontSize: 13,
|
||||
fontWeight: 600,
|
||||
cursor:
|
||||
!uploadFile || !uploadName.trim() || uploadProgress !== null
|
||||
? "not-allowed"
|
||||
: "pointer",
|
||||
}}
|
||||
>
|
||||
{uploadProgress !== null ? "上传中..." : "开始上传"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
)
|
||||
|
||||
@@ -1,24 +0,0 @@
|
||||
import React from "react"
|
||||
|
||||
interface ErrorAlertProps {
|
||||
error: string
|
||||
}
|
||||
|
||||
/** 错误提示 */
|
||||
const ErrorAlert: React.FC<ErrorAlertProps> = ({ error }) => {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
padding: "10px 12px",
|
||||
background: "var(--error-soft, #fff2f0)",
|
||||
borderRadius: 8,
|
||||
color: "var(--error-color, #ff4d4f)",
|
||||
fontSize: 13,
|
||||
}}
|
||||
>
|
||||
{error}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default ErrorAlert
|
||||
@@ -1,51 +0,0 @@
|
||||
import React from "react"
|
||||
|
||||
interface ResultPanelProps {
|
||||
audioUrl: string
|
||||
onSave: () => void
|
||||
}
|
||||
|
||||
/** 合成结果展示 */
|
||||
const ResultPanel: React.FC<ResultPanelProps> = ({ audioUrl, onSave }) => {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
padding: 12,
|
||||
background: "var(--bg-secondary)",
|
||||
borderRadius: 8,
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: 10,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
fontSize: 13,
|
||||
fontWeight: 500,
|
||||
color: "var(--success-color, #52c41a)",
|
||||
}}
|
||||
>
|
||||
✅ 合成完成
|
||||
</div>
|
||||
<audio controls src={audioUrl} style={{ width: "100%" }} />
|
||||
<button
|
||||
type="button"
|
||||
onClick={onSave}
|
||||
style={{
|
||||
padding: "8px 0",
|
||||
borderRadius: 8,
|
||||
border: "1px solid var(--primary-color)",
|
||||
background: "var(--primary-soft)",
|
||||
color: "var(--primary-color)",
|
||||
fontSize: 13,
|
||||
fontWeight: 600,
|
||||
cursor: "pointer",
|
||||
}}
|
||||
>
|
||||
保存到配音库
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default ResultPanel
|
||||
@@ -1,47 +0,0 @@
|
||||
import React from "react"
|
||||
import { TTS_CONFIG } from "./types"
|
||||
|
||||
interface SpeedControlProps {
|
||||
speed: number
|
||||
onChange: (speed: number) => void
|
||||
}
|
||||
|
||||
/** 语速调节滑块 */
|
||||
const SpeedControl: React.FC<SpeedControlProps> = ({ speed, onChange }) => {
|
||||
return (
|
||||
<div>
|
||||
<div
|
||||
style={{
|
||||
fontSize: 13,
|
||||
color: "var(--text-secondary)",
|
||||
marginBottom: 6,
|
||||
}}
|
||||
>
|
||||
语速:{speed.toFixed(1)}x
|
||||
</div>
|
||||
<input
|
||||
type="range"
|
||||
min={TTS_CONFIG.MIN_SPEED}
|
||||
max={TTS_CONFIG.MAX_SPEED}
|
||||
step={TTS_CONFIG.SPEED_STEP}
|
||||
value={speed}
|
||||
onChange={(e) => onChange(parseFloat(e.target.value))}
|
||||
style={{ width: "100%", accentColor: "var(--primary-color)" }}
|
||||
/>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
justifyContent: "space-between",
|
||||
fontSize: 11,
|
||||
color: "var(--text-tertiary)",
|
||||
}}
|
||||
>
|
||||
<span>{TTS_CONFIG.MIN_SPEED}x</span>
|
||||
<span>{TTS_CONFIG.DEFAULT_SPEED}x</span>
|
||||
<span>{TTS_CONFIG.MAX_SPEED}x</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default SpeedControl
|
||||
@@ -1,51 +0,0 @@
|
||||
import React from "react"
|
||||
import { RobotOutlined } from "@ant-design/icons"
|
||||
import { message } from "antd"
|
||||
import { type TtsStatus } from "./types"
|
||||
|
||||
interface SynthesizeButtonProps {
|
||||
status: TtsStatus
|
||||
text: string
|
||||
onClick: () => void
|
||||
}
|
||||
|
||||
/** 合成按钮 */
|
||||
const SynthesizeButton: React.FC<SynthesizeButtonProps> = ({ status, text, onClick }) => {
|
||||
const disabled = status === "synthesizing" || !text.trim()
|
||||
|
||||
const handleClick = () => {
|
||||
if (!text.trim()) {
|
||||
message.warning("请输入要合成的文本")
|
||||
return
|
||||
}
|
||||
onClick()
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleClick}
|
||||
disabled={disabled}
|
||||
style={{
|
||||
width: "100%",
|
||||
padding: "10px 0",
|
||||
borderRadius: 8,
|
||||
border: "none",
|
||||
background: disabled ? "var(--text-tertiary)" : "var(--primary-color)",
|
||||
color: "#fff",
|
||||
fontSize: 14,
|
||||
fontWeight: 600,
|
||||
cursor: disabled ? "not-allowed" : "pointer",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
gap: 8,
|
||||
}}
|
||||
>
|
||||
<RobotOutlined />
|
||||
{status === "synthesizing" ? "合成中..." : "开始合成"}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
export default SynthesizeButton
|
||||
@@ -1,56 +0,0 @@
|
||||
import React from "react"
|
||||
import { TTS_CONFIG } from "./types"
|
||||
|
||||
interface TextInputSectionProps {
|
||||
value: string
|
||||
onChange: (text: string) => void
|
||||
}
|
||||
|
||||
/** 文本输入区 */
|
||||
const TextInputSection: React.FC<TextInputSectionProps> = ({ value, onChange }) => {
|
||||
return (
|
||||
<div>
|
||||
<div
|
||||
style={{
|
||||
fontSize: 13,
|
||||
color: "var(--text-secondary)",
|
||||
marginBottom: 6,
|
||||
}}
|
||||
>
|
||||
输入文本
|
||||
</div>
|
||||
<textarea
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
placeholder="输入要配音的文本内容..."
|
||||
maxLength={TTS_CONFIG.MAX_TEXT_LENGTH}
|
||||
rows={4}
|
||||
style={{
|
||||
width: "100%",
|
||||
padding: "10px 12px",
|
||||
border: "1px solid var(--border-color)",
|
||||
borderRadius: 8,
|
||||
fontSize: 13,
|
||||
background: "var(--bg-primary)",
|
||||
color: "var(--text-primary)",
|
||||
outline: "none",
|
||||
resize: "vertical",
|
||||
fontFamily: "inherit",
|
||||
lineHeight: 1.6,
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
style={{
|
||||
fontSize: 11,
|
||||
color: "var(--text-tertiary)",
|
||||
textAlign: "right",
|
||||
marginTop: 4,
|
||||
}}
|
||||
>
|
||||
{value.length}/{TTS_CONFIG.MAX_TEXT_LENGTH}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default TextInputSection
|
||||
@@ -1,49 +0,0 @@
|
||||
import React from "react"
|
||||
import { type PresetVoiceDisplay } from "@/pages/voices/types"
|
||||
import { genderLabel } from "@/pages/voices/utils/format"
|
||||
|
||||
interface VoiceSelectorProps {
|
||||
value: string
|
||||
onChange: (voiceId: string) => void
|
||||
presetVoices: PresetVoiceDisplay[]
|
||||
}
|
||||
|
||||
/** 音色选择下拉 */
|
||||
const VoiceSelector: React.FC<VoiceSelectorProps> = ({ value, onChange, presetVoices }) => {
|
||||
return (
|
||||
<div>
|
||||
<div
|
||||
style={{
|
||||
fontSize: 13,
|
||||
color: "var(--text-secondary)",
|
||||
marginBottom: 6,
|
||||
}}
|
||||
>
|
||||
选择音色
|
||||
</div>
|
||||
<select
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
style={{
|
||||
width: "100%",
|
||||
padding: "8px 12px",
|
||||
border: "1px solid var(--border-color)",
|
||||
borderRadius: 8,
|
||||
fontSize: 13,
|
||||
background: "var(--bg-primary)",
|
||||
color: "var(--text-primary)",
|
||||
outline: "none",
|
||||
}}
|
||||
>
|
||||
<option value="">默认音色</option>
|
||||
{presetVoices.map((v) => (
|
||||
<option key={v.id} value={v.id}>
|
||||
{v.name} — {genderLabel(v.gender)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default VoiceSelector
|
||||
@@ -1,8 +0,0 @@
|
||||
export { default } from "../TtsModal"
|
||||
export * from "./types"
|
||||
export { default as TextInputSection } from "./TextInputSection"
|
||||
export { default as VoiceSelector } from "./VoiceSelector"
|
||||
export { default as SpeedControl } from "./SpeedControl"
|
||||
export { default as SynthesizeButton } from "./SynthesizeButton"
|
||||
export { default as ErrorAlert } from "./ErrorAlert"
|
||||
export { default as ResultPanel } from "./ResultPanel"
|
||||
@@ -1,29 +0,0 @@
|
||||
import { type PresetVoiceDisplay } from "@/pages/voices/types"
|
||||
|
||||
export type TtsStatus = "idle" | "synthesizing" | "done" | "error"
|
||||
|
||||
export interface TtsModalProps {
|
||||
open: boolean
|
||||
ttsText: string
|
||||
ttsVoiceId: string
|
||||
ttsSpeed: number
|
||||
ttsStatus: TtsStatus
|
||||
ttsAudioUrl: string | null
|
||||
ttsError: string | null
|
||||
presetVoices: PresetVoiceDisplay[]
|
||||
onClose: () => void
|
||||
onTextChange: (text: string) => void
|
||||
onVoiceChange: (voiceId: string) => void
|
||||
onSpeedChange: (speed: number) => void
|
||||
onSynthesize: () => void
|
||||
onSave: () => void
|
||||
}
|
||||
|
||||
/** TTS 常量配置 */
|
||||
export const TTS_CONFIG = {
|
||||
MAX_TEXT_LENGTH: 2000,
|
||||
DEFAULT_SPEED: 1.0,
|
||||
MIN_SPEED: 0.5,
|
||||
MAX_SPEED: 2.0,
|
||||
SPEED_STEP: 0.1,
|
||||
} as const
|
||||
@@ -1,68 +0,0 @@
|
||||
import React from "react"
|
||||
|
||||
interface ActionButtonsProps {
|
||||
uploading: boolean
|
||||
canUpload: boolean
|
||||
onCancel: () => void
|
||||
onUpload: () => void
|
||||
}
|
||||
|
||||
const ActionButtons: React.FC<ActionButtonsProps> = ({
|
||||
uploading,
|
||||
canUpload,
|
||||
onCancel,
|
||||
onUpload,
|
||||
}) => {
|
||||
const disabled = uploading || !canUpload
|
||||
|
||||
const handleUpload = () => {
|
||||
onUpload()
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
justifyContent: "flex-end",
|
||||
gap: 10,
|
||||
paddingTop: 4,
|
||||
}}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onCancel}
|
||||
disabled={uploading}
|
||||
style={{
|
||||
padding: "8px 20px",
|
||||
borderRadius: 8,
|
||||
border: "1px solid var(--border-color)",
|
||||
background: "transparent",
|
||||
fontSize: 13,
|
||||
cursor: uploading ? "not-allowed" : "pointer",
|
||||
color: "var(--text-secondary)",
|
||||
}}
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleUpload}
|
||||
disabled={disabled}
|
||||
style={{
|
||||
padding: "8px 20px",
|
||||
borderRadius: 8,
|
||||
border: "none",
|
||||
background: disabled ? "var(--text-tertiary)" : "var(--primary-color)",
|
||||
color: "#fff",
|
||||
fontSize: 13,
|
||||
fontWeight: 600,
|
||||
cursor: disabled ? "not-allowed" : "pointer",
|
||||
}}
|
||||
>
|
||||
{uploading ? "上传中..." : "开始上传"}
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default ActionButtons
|
||||
@@ -1,42 +0,0 @@
|
||||
import React from "react"
|
||||
import { SoundOutlined } from "@ant-design/icons"
|
||||
import { formatFileSize } from "@/pages/voices/utils/format"
|
||||
|
||||
interface FileInfoCardProps {
|
||||
file: File
|
||||
}
|
||||
|
||||
const FileInfoCard: React.FC<FileInfoCardProps> = ({ file }) => {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
padding: "10px 12px",
|
||||
background: "var(--bg-secondary)",
|
||||
borderRadius: 8,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 10,
|
||||
}}
|
||||
>
|
||||
<SoundOutlined style={{ fontSize: 18, color: "var(--primary-color)" }} />
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div
|
||||
style={{
|
||||
fontSize: 13,
|
||||
fontWeight: 500,
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
whiteSpace: "nowrap",
|
||||
}}
|
||||
>
|
||||
{file.name}
|
||||
</div>
|
||||
<div style={{ fontSize: 11, color: "var(--text-secondary)" }}>
|
||||
{formatFileSize(file.size)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default FileInfoCard
|
||||
@@ -1,54 +0,0 @@
|
||||
import React from "react"
|
||||
import { UploadOutlined } from "@ant-design/icons"
|
||||
import { Upload } from "antd"
|
||||
import { UPLOAD_CONFIG } from "./types"
|
||||
|
||||
interface FileUploadZoneProps {
|
||||
disabled: boolean
|
||||
onFileSelect: (file: File) => void
|
||||
onFileRemove: () => void
|
||||
}
|
||||
|
||||
const FileUploadZone: React.FC<FileUploadZoneProps> = ({
|
||||
disabled,
|
||||
onFileSelect,
|
||||
onFileRemove,
|
||||
}) => {
|
||||
return (
|
||||
<Upload.Dragger
|
||||
accept={UPLOAD_CONFIG.accept}
|
||||
maxCount={1}
|
||||
beforeUpload={(file) => {
|
||||
onFileSelect(file)
|
||||
return false
|
||||
}}
|
||||
onRemove={() => {
|
||||
onFileRemove()
|
||||
}}
|
||||
showUploadList={false}
|
||||
disabled={disabled}
|
||||
>
|
||||
<p
|
||||
style={{
|
||||
fontSize: 32,
|
||||
color: "var(--primary-color)",
|
||||
marginBottom: 8,
|
||||
}}
|
||||
>
|
||||
<UploadOutlined />
|
||||
</p>
|
||||
<p style={{ fontSize: 14, fontWeight: 500, margin: "0 0 4px" }}>点击或拖拽音频文件到此处</p>
|
||||
<p
|
||||
style={{
|
||||
fontSize: 12,
|
||||
color: "var(--text-secondary)",
|
||||
margin: 0,
|
||||
}}
|
||||
>
|
||||
支持 MP3、WAV、AAC、FLAC 等格式,最大 {UPLOAD_CONFIG.maxSizeMB}MB
|
||||
</p>
|
||||
</Upload.Dragger>
|
||||
)
|
||||
}
|
||||
|
||||
export default FileUploadZone
|
||||
@@ -1,106 +0,0 @@
|
||||
import React from "react"
|
||||
import type { VoiceGender } from "@/pages/voices/types"
|
||||
import { GENDER_OPTIONS, UPLOAD_CONFIG } from "./types"
|
||||
|
||||
interface FormFieldsProps {
|
||||
name: string
|
||||
gender: VoiceGender
|
||||
desc: string
|
||||
disabled: boolean
|
||||
onNameChange: (name: string) => void
|
||||
onGenderChange: (gender: VoiceGender) => void
|
||||
onDescChange: (desc: string) => void
|
||||
}
|
||||
|
||||
const labelStyle: React.CSSProperties = {
|
||||
fontSize: 13,
|
||||
color: "var(--text-secondary)",
|
||||
marginBottom: 6,
|
||||
}
|
||||
|
||||
const inputStyle: React.CSSProperties = {
|
||||
width: "100%",
|
||||
padding: "8px 12px",
|
||||
border: "1px solid var(--border-color)",
|
||||
borderRadius: 8,
|
||||
fontSize: 13,
|
||||
background: "var(--bg-primary)",
|
||||
color: "var(--text-primary)",
|
||||
outline: "none",
|
||||
}
|
||||
|
||||
const FormFields: React.FC<FormFieldsProps> = ({
|
||||
name,
|
||||
gender,
|
||||
desc,
|
||||
disabled,
|
||||
onNameChange,
|
||||
onGenderChange,
|
||||
onDescChange,
|
||||
}) => {
|
||||
return (
|
||||
<>
|
||||
{/* 名称 */}
|
||||
<div>
|
||||
<div style={labelStyle}>素材名称</div>
|
||||
<input
|
||||
value={name}
|
||||
onChange={(e) => onNameChange(e.target.value)}
|
||||
placeholder="输入素材名称"
|
||||
maxLength={UPLOAD_CONFIG.maxNameLength}
|
||||
disabled={disabled}
|
||||
style={inputStyle}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 性别选择 */}
|
||||
<div>
|
||||
<div style={labelStyle}>音色性别</div>
|
||||
<div style={{ display: "flex", gap: 8 }}>
|
||||
{GENDER_OPTIONS.map((opt) => (
|
||||
<button
|
||||
key={opt.value}
|
||||
type="button"
|
||||
onClick={() => onGenderChange(opt.value)}
|
||||
disabled={disabled}
|
||||
style={{
|
||||
flex: 1,
|
||||
padding: "6px 0",
|
||||
borderRadius: 8,
|
||||
border: `1px solid ${gender === opt.value ? "var(--primary-color)" : "var(--border-color)"}`,
|
||||
background: gender === opt.value ? "var(--primary-soft)" : "transparent",
|
||||
color: gender === opt.value ? "var(--primary-color)" : "var(--text-secondary)",
|
||||
fontSize: 13,
|
||||
fontWeight: gender === opt.value ? 600 : 400,
|
||||
cursor: disabled ? "not-allowed" : "pointer",
|
||||
transition: "all 0.2s",
|
||||
}}
|
||||
>
|
||||
{opt.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 描述 */}
|
||||
<div>
|
||||
<div style={labelStyle}>音色描述(可选)</div>
|
||||
<textarea
|
||||
value={desc}
|
||||
onChange={(e) => onDescChange(e.target.value)}
|
||||
placeholder="描述这个音色的特点..."
|
||||
maxLength={UPLOAD_CONFIG.maxDescLength}
|
||||
rows={2}
|
||||
disabled={disabled}
|
||||
style={{
|
||||
...inputStyle,
|
||||
resize: "vertical",
|
||||
fontFamily: "inherit",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default FormFields
|
||||
@@ -1,45 +0,0 @@
|
||||
import React from "react"
|
||||
|
||||
interface UploadProgressProps {
|
||||
progress: number
|
||||
}
|
||||
|
||||
const UploadProgress: React.FC<UploadProgressProps> = ({ progress }) => {
|
||||
return (
|
||||
<div style={{ textAlign: "center", padding: "8px 0" }}>
|
||||
<div
|
||||
style={{
|
||||
fontSize: 22,
|
||||
fontWeight: 700,
|
||||
color: "var(--primary-color)",
|
||||
}}
|
||||
>
|
||||
{progress}%
|
||||
</div>
|
||||
<div style={{ fontSize: 12, color: "var(--text-secondary)" }}>
|
||||
{progress < 100 ? "上传中..." : "处理中..."}
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
height: 4,
|
||||
background: "var(--bg-tertiary)",
|
||||
borderRadius: 2,
|
||||
marginTop: 8,
|
||||
overflow: "hidden",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
height: "100%",
|
||||
width: `${progress}%`,
|
||||
background: "var(--primary-color)",
|
||||
borderRadius: 2,
|
||||
transition: "width 0.3s ease",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default UploadProgress
|
||||
@@ -1,6 +0,0 @@
|
||||
export { default as FileUploadZone } from "./FileUploadZone"
|
||||
export { default as FileInfoCard } from "./FileInfoCard"
|
||||
export { default as UploadProgress } from "./UploadProgress"
|
||||
export { default as FormFields } from "./FormFields"
|
||||
export { default as ActionButtons } from "./ActionButtons"
|
||||
export * from "./types"
|
||||
@@ -1,30 +0,0 @@
|
||||
import type { VoiceGender } from "@/pages/voices/types"
|
||||
|
||||
export interface UploadVoiceModalProps {
|
||||
open: boolean
|
||||
uploadFile: File | null
|
||||
uploadName: string
|
||||
uploadGender: VoiceGender
|
||||
uploadDesc: string
|
||||
uploadProgress: number | null
|
||||
onClose: () => void
|
||||
onFileSelect: (file: File) => void
|
||||
onFileRemove: () => void
|
||||
onNameChange: (name: string) => void
|
||||
onGenderChange: (gender: VoiceGender) => void
|
||||
onDescChange: (desc: string) => void
|
||||
onUpload: () => void
|
||||
}
|
||||
|
||||
export const GENDER_OPTIONS: { value: VoiceGender; label: string }[] = [
|
||||
{ value: "female", label: "女声" },
|
||||
{ value: "male", label: "男声" },
|
||||
{ value: "child", label: "童声" },
|
||||
]
|
||||
|
||||
export const UPLOAD_CONFIG = {
|
||||
maxSizeMB: 200,
|
||||
maxNameLength: 100,
|
||||
maxDescLength: 500,
|
||||
accept: "audio/*",
|
||||
} as const
|
||||
Regular → Executable
+14
@@ -104,3 +104,17 @@ describe("EditingPlanner module smoke test", () => {
|
||||
expect(true).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
// 公共组件(通过 MediaPanel 间接依赖,显式引入确保 related 模式覆盖)
|
||||
import "@/components/asset-selector"
|
||||
import "@/components/asset-selector/AssetSelector"
|
||||
import "@/components/asset-selector/AssetCard"
|
||||
import "@/components/asset-selector/AssetListItem"
|
||||
import "@/components/asset-selector/SelectorToolbar"
|
||||
import "@/components/asset-selector/SelectorBatchBar"
|
||||
import "@/components/asset-selector/PreviewOverlay"
|
||||
import "@/components/asset-selector/EmptyState"
|
||||
import "@/components/asset-selector/hooks/useAssetFilter"
|
||||
import "@/components/asset-selector/hooks/useAssetSelection"
|
||||
import "@/components/asset-selector/hooks/useAssetPreview"
|
||||
import "@/components/asset-selector/hooks/useDragReorder"
|
||||
|
||||
@@ -14,20 +14,7 @@ import "@/pages/voices/components/CloneVoiceCard"
|
||||
import "@/pages/voices/components/CloneDetailModal"
|
||||
import "@/pages/voices/components/CloneCardSkeleton"
|
||||
import "@/pages/voices/components/UploadVoiceModal"
|
||||
import "@/pages/voices/components/upload-voice-modal/FileUploadZone"
|
||||
import "@/pages/voices/components/upload-voice-modal/FileInfoCard"
|
||||
import "@/pages/voices/components/upload-voice-modal/UploadProgress"
|
||||
import "@/pages/voices/components/upload-voice-modal/FormFields"
|
||||
import "@/pages/voices/components/upload-voice-modal/ActionButtons"
|
||||
import "@/pages/voices/components/upload-voice-modal/types"
|
||||
import "@/pages/voices/components/TtsModal"
|
||||
import "@/pages/voices/components/tts-modal/TextInputSection"
|
||||
import "@/pages/voices/components/tts-modal/VoiceSelector"
|
||||
import "@/pages/voices/components/tts-modal/SpeedControl"
|
||||
import "@/pages/voices/components/tts-modal/SynthesizeButton"
|
||||
import "@/pages/voices/components/tts-modal/ErrorAlert"
|
||||
import "@/pages/voices/components/tts-modal/ResultPanel"
|
||||
import "@/pages/voices/components/tts-modal/types"
|
||||
import "@/pages/voices/components/VoiceFilterBar"
|
||||
import "@/pages/voices/components/MaterialVoiceCard"
|
||||
|
||||
|
||||
@@ -1,351 +0,0 @@
|
||||
"""BGM 混音纯逻辑模块.
|
||||
|
||||
所有函数均为纯函数,不调用 FFmpeg、不操作文件。
|
||||
便于单元测试,也方便被其他模块复用。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Optional
|
||||
|
||||
|
||||
@dataclass
|
||||
class BGMPureConfig:
|
||||
"""BGM 混音配置(纯数据类)."""
|
||||
|
||||
volume: float = 0.3
|
||||
fade_in: float = 0.0
|
||||
fade_out: float = 0.0
|
||||
loop_enabled: bool = True
|
||||
sidechain_enabled: bool = False
|
||||
sidechain_ratio: float = 0.3
|
||||
sidechain_attack: float = 0.02
|
||||
sidechain_release: float = 0.5
|
||||
sidechain_threshold: float = -25.0
|
||||
|
||||
|
||||
# ── 循环判断与计算 ─────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def should_loop_bgm(
|
||||
bgm_duration: float,
|
||||
target_duration: float,
|
||||
loop_enabled: bool = True,
|
||||
) -> bool:
|
||||
"""判断是否需要循环 BGM.
|
||||
|
||||
当 BGM 时长小于目标时长的 90% 时才循环,
|
||||
避免 BGM 只差一点点就铺满还要循环一次的情况。
|
||||
|
||||
Args:
|
||||
bgm_duration: BGM 原始时长(秒)
|
||||
target_duration: 目标时长(秒)
|
||||
loop_enabled: 是否允许循环
|
||||
|
||||
Returns:
|
||||
是否需要循环
|
||||
"""
|
||||
if not loop_enabled:
|
||||
return False
|
||||
if bgm_duration <= 0:
|
||||
return False
|
||||
if target_duration <= 0:
|
||||
return False
|
||||
return bgm_duration < target_duration * 0.9
|
||||
|
||||
|
||||
def calculate_loop_count(bgm_duration: float, target_duration: float) -> int:
|
||||
"""计算需要循环的次数.
|
||||
|
||||
多算 2 次作为余量,避免末尾因为精度问题不够长。
|
||||
|
||||
Args:
|
||||
bgm_duration: BGM 原始时长(秒)
|
||||
target_duration: 目标时长(秒)
|
||||
|
||||
Returns:
|
||||
循环次数,至少 1
|
||||
"""
|
||||
if bgm_duration <= 0:
|
||||
return 1
|
||||
if target_duration <= 0:
|
||||
return 1
|
||||
if bgm_duration >= target_duration:
|
||||
return 1
|
||||
return max(1, int(target_duration / bgm_duration) + 2)
|
||||
|
||||
|
||||
# ── BGM 预处理滤镜链构建 ──────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def build_bgm_filter_chain(
|
||||
bgm_duration: float,
|
||||
target_duration: float,
|
||||
volume: float = 0.3,
|
||||
fade_in: float = 0.0,
|
||||
fade_out: float = 0.0,
|
||||
loop_enabled: bool = True,
|
||||
) -> str:
|
||||
"""构建 BGM 预处理滤镜链.
|
||||
|
||||
处理顺序:循环 → 音量 → 淡入 → 淡出 → 截断 → 重置时间戳
|
||||
|
||||
Args:
|
||||
bgm_duration: BGM 原始时长(秒)
|
||||
target_duration: 目标时长(秒)
|
||||
volume: 音量 0.0~1.0
|
||||
fade_in: 淡入时长(秒)
|
||||
fade_out: 淡出时长(秒)
|
||||
loop_enabled: 是否允许循环
|
||||
|
||||
Returns:
|
||||
FFmpeg filter_complex 字符串(逗号分隔)
|
||||
"""
|
||||
# 兜底:目标时长不能为 0 或负数
|
||||
safe_target = max(5.0, target_duration) if target_duration <= 0 else target_duration
|
||||
|
||||
filter_parts: list[str] = []
|
||||
|
||||
# 1. 循环
|
||||
needs_loop = should_loop_bgm(bgm_duration, safe_target, loop_enabled)
|
||||
if needs_loop:
|
||||
loop_count = calculate_loop_count(bgm_duration, safe_target)
|
||||
filter_parts.append(f"aloop=loop={loop_count}:size=0")
|
||||
|
||||
# 2. 音量调节(钳制到 0~1)
|
||||
safe_volume = max(0.0, min(1.0, volume))
|
||||
if abs(safe_volume - 1.0) > 0.001:
|
||||
filter_parts.append(f"volume={safe_volume:.3f}")
|
||||
|
||||
# 3. 淡入
|
||||
if fade_in > 0:
|
||||
filter_parts.append(f"afade=t=in:st=0:d={fade_in:.3f}")
|
||||
|
||||
# 4. 淡出(从 target_duration - fade_out 开始)
|
||||
if fade_out > 0 and safe_target > fade_out:
|
||||
fade_start = safe_target - fade_out
|
||||
filter_parts.append(f"afade=t=out:st={fade_start:.3f}:d={fade_out:.3f}")
|
||||
|
||||
# 5. 截断到目标时长
|
||||
filter_parts.append(f"atrim=0:{safe_target:.3f}")
|
||||
|
||||
# 6. 重置时间戳
|
||||
filter_parts.append("asetpts=N/SR/TB")
|
||||
|
||||
return ",".join(filter_parts)
|
||||
|
||||
|
||||
# ── 混音滤镜构建 ──────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def calculate_sidechain_ratio(sidechain_ratio: float) -> float:
|
||||
"""计算 sidechain 压缩比.
|
||||
|
||||
sidechain_ratio 表示闪避时 BGM 音量降低比例(0~1),
|
||||
映射到 FFmpeg sidechaincompress 的 ratio 参数(2:1 ~ 10:1)。
|
||||
|
||||
ratio = 1 / (1 - sidechain_ratio)
|
||||
|
||||
Args:
|
||||
sidechain_ratio: 闪避比例 0.0~1.0
|
||||
|
||||
Returns:
|
||||
FFmpeg ratio 值(2.0 ~ 10.0)
|
||||
"""
|
||||
if sidechain_ratio <= 0:
|
||||
return 2.0
|
||||
if sidechain_ratio >= 1.0:
|
||||
return 10.0
|
||||
raw_ratio = 1.0 / (1.0 - sidechain_ratio)
|
||||
return max(2.0, min(10.0, raw_ratio))
|
||||
|
||||
|
||||
def build_simple_mix_filter() -> str:
|
||||
"""构建普通 amix 混音滤镜.
|
||||
|
||||
两路输入:[0:a] 主音频,[1:a] BGM
|
||||
主音频权重 1.0,BGM 已在预处理阶段调好音量。
|
||||
amix 会自动归一化,用 volume=2 补偿衰减。
|
||||
|
||||
Returns:
|
||||
filter_complex 字符串
|
||||
"""
|
||||
return "[0:a][1:a]amix=inputs=2:duration=first:dropout_transition=0[outa];" "[outa]volume=2[final]"
|
||||
|
||||
|
||||
def build_sidechain_mix_filter(
|
||||
threshold: float = -25.0,
|
||||
ratio: float = 0.3,
|
||||
attack: float = 0.02,
|
||||
release: float = 0.5,
|
||||
) -> str:
|
||||
"""构建 sidechain 人声闪避混音滤镜.
|
||||
|
||||
流程:
|
||||
1. BGM[1:a] 经过 sidechaincompress,用主音频[0:a]做触发
|
||||
2. 主音频 + 压缩后的 BGM amix 混音
|
||||
3. volume=1.5 轻微补偿
|
||||
|
||||
Args:
|
||||
threshold: 触发阈值(dB)
|
||||
ratio: 闪避比例 0.0~1.0(会被转换为 FFmpeg ratio)
|
||||
attack: 攻击时间(秒)
|
||||
release: 释放时间(秒)
|
||||
|
||||
Returns:
|
||||
filter_complex 字符串
|
||||
"""
|
||||
ffmpeg_ratio = calculate_sidechain_ratio(ratio)
|
||||
|
||||
return (
|
||||
f"[1:a][0:a]sidechaincompress="
|
||||
f"threshold={threshold}dB:"
|
||||
f"ratio={ffmpeg_ratio:.1f}:"
|
||||
f"attack={attack:.3f}:"
|
||||
f"release={release:.3f}:"
|
||||
f"knee=6[bgm_comp];"
|
||||
f"[0:a][bgm_comp]amix=inputs=2:duration=first:dropout_transition=0[outa];"
|
||||
f"[outa]volume=1.5[final]"
|
||||
)
|
||||
|
||||
|
||||
# ── 配置验证与规范化 ──────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def normalize_bgm_config(config: dict) -> dict:
|
||||
"""规范化 BGM 配置字典.
|
||||
|
||||
将各种类型的输入值转换为正确的类型,
|
||||
并进行边界钳制。
|
||||
|
||||
Args:
|
||||
config: 原始配置字典
|
||||
|
||||
Returns:
|
||||
规范化后的配置字典
|
||||
"""
|
||||
result: dict = {}
|
||||
|
||||
# volume: 0.0 ~ 1.0
|
||||
result["volume"] = max(0.0, min(1.0, float(config.get("volume", 0.3))))
|
||||
|
||||
# fade_in: >= 0
|
||||
result["fade_in"] = max(0.0, float(config.get("fade_in", 0.0)))
|
||||
|
||||
# fade_out: >= 0
|
||||
result["fade_out"] = max(0.0, float(config.get("fade_out", 0.0)))
|
||||
|
||||
# loop_enabled: bool
|
||||
result["loop_enabled"] = bool(config.get("loop_enabled", True))
|
||||
|
||||
# sidechain_enabled: bool
|
||||
result["sidechain_enabled"] = bool(config.get("sidechain_enabled", False))
|
||||
|
||||
# sidechain_ratio: 0.0 ~ 1.0
|
||||
result["sidechain_ratio"] = max(0.0, min(1.0, float(config.get("sidechain_ratio", 0.3))))
|
||||
|
||||
# sidechain_attack: > 0
|
||||
result["sidechain_attack"] = max(0.001, float(config.get("sidechain_attack", 0.02)))
|
||||
|
||||
# sidechain_release: > 0
|
||||
result["sidechain_release"] = max(0.01, float(config.get("sidechain_release", 0.5)))
|
||||
|
||||
# sidechain_threshold: dB
|
||||
result["sidechain_threshold"] = float(config.get("sidechain_threshold", -25.0))
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def validate_bgm_config(config: dict) -> tuple[bool, list[str]]:
|
||||
"""验证 BGM 配置是否合法.
|
||||
|
||||
Args:
|
||||
config: 配置字典
|
||||
|
||||
Returns:
|
||||
(是否合法, 错误信息列表)
|
||||
"""
|
||||
errors: list[str] = []
|
||||
|
||||
volume = config.get("volume", 0.3)
|
||||
if not isinstance(volume, (int, float)):
|
||||
errors.append("volume 必须是数字")
|
||||
elif volume < 0 or volume > 1:
|
||||
errors.append("volume 必须在 0~1 之间")
|
||||
|
||||
fade_in = config.get("fade_in", 0)
|
||||
if not isinstance(fade_in, (int, float)):
|
||||
errors.append("fade_in 必须是数字")
|
||||
elif fade_in < 0:
|
||||
errors.append("fade_in 不能为负数")
|
||||
|
||||
fade_out = config.get("fade_out", 0)
|
||||
if not isinstance(fade_out, (int, float)):
|
||||
errors.append("fade_out 必须是数字")
|
||||
elif fade_out < 0:
|
||||
errors.append("fade_out 不能为负数")
|
||||
|
||||
sidechain_ratio = config.get("sidechain_ratio", 0.3)
|
||||
if not isinstance(sidechain_ratio, (int, float)):
|
||||
errors.append("sidechain_ratio 必须是数字")
|
||||
elif sidechain_ratio < 0 or sidechain_ratio > 1:
|
||||
errors.append("sidechain_ratio 必须在 0~1 之间")
|
||||
|
||||
return (len(errors) == 0, errors)
|
||||
|
||||
|
||||
# ── 时长相关工具 ──────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def calculate_fade_out_start(
|
||||
target_duration: float,
|
||||
fade_out: float,
|
||||
) -> Optional[float]:
|
||||
"""计算淡出开始时间.
|
||||
|
||||
如果淡出时长大于等于目标时长,返回 None(不做淡出)。
|
||||
|
||||
Args:
|
||||
target_duration: 目标时长(秒)
|
||||
fade_out: 淡出时长(秒)
|
||||
|
||||
Returns:
|
||||
淡出开始时间(秒),如果不需要淡出返回 None
|
||||
"""
|
||||
if fade_out <= 0:
|
||||
return None
|
||||
if target_duration <= 0:
|
||||
return None
|
||||
if fade_out >= target_duration:
|
||||
return None
|
||||
return target_duration - fade_out
|
||||
|
||||
|
||||
def estimate_bgm_processing_duration(
|
||||
bgm_duration: float,
|
||||
target_duration: float,
|
||||
loop_enabled: bool = True,
|
||||
) -> float:
|
||||
"""估算 BGM 预处理后的实际输出时长.
|
||||
|
||||
正常情况下应该等于 target_duration,
|
||||
但在某些边界情况下可能不同。
|
||||
|
||||
Args:
|
||||
bgm_duration: BGM 原始时长
|
||||
target_duration: 目标时长
|
||||
loop_enabled: 是否允许循环
|
||||
|
||||
Returns:
|
||||
预估输出时长(秒)
|
||||
"""
|
||||
if target_duration <= 0:
|
||||
return 5.0 # 兜底时长
|
||||
|
||||
# 不需要循环的情况:如果 BGM 够长,截断到 target_duration
|
||||
if not loop_enabled and bgm_duration >= target_duration:
|
||||
return target_duration
|
||||
|
||||
# 需要循环或 BGM 太短:截断到 target_duration
|
||||
return target_duration
|
||||
@@ -1,493 +0,0 @@
|
||||
"""视频拼接引擎纯逻辑模块.
|
||||
|
||||
所有函数均为纯函数,不调用 FFmpeg、不操作文件。
|
||||
便于单元测试,也方便被其他模块复用。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
from typing import Any, Optional
|
||||
|
||||
# ── 帧率解析 ──────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def parse_fps(fps_value: Any) -> float:
|
||||
"""解析帧率字符串/数值.
|
||||
|
||||
支持格式:
|
||||
- 数字: 30 → 30.0
|
||||
- 分数: "30/1" → 30.0, "24000/1001" → 23.976...
|
||||
- 字符串数字: "30" → 30.0
|
||||
|
||||
Args:
|
||||
fps_value: 帧率值(字符串、数字等)
|
||||
|
||||
Returns:
|
||||
帧率(fps),失败返回 30.0
|
||||
"""
|
||||
if fps_value is None:
|
||||
return 30.0
|
||||
|
||||
try:
|
||||
fps_str = str(fps_value).strip()
|
||||
if not fps_str:
|
||||
return 30.0
|
||||
|
||||
if "/" in fps_str:
|
||||
num_str, den_str = fps_str.split("/", 1)
|
||||
num = float(num_str)
|
||||
den = float(den_str)
|
||||
if den == 0:
|
||||
return 30.0
|
||||
return num / den
|
||||
|
||||
return float(fps_str)
|
||||
except (ValueError, TypeError, ZeroDivisionError):
|
||||
return 30.0
|
||||
|
||||
|
||||
def format_fps_filter(fps: float) -> str:
|
||||
"""格式化 fps 滤镜参数.
|
||||
|
||||
Args:
|
||||
fps: 帧率
|
||||
|
||||
Returns:
|
||||
fps 滤镜字符串
|
||||
"""
|
||||
# 接近整数时用整数形式
|
||||
if abs(fps - round(fps)) < 0.001:
|
||||
return f"fps={int(fps)}"
|
||||
return f"fps={fps:.3f}"
|
||||
|
||||
|
||||
# ── 输出参数计算 ──────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def resolve_output_params(
|
||||
config_width: int,
|
||||
config_height: int,
|
||||
config_fps: float,
|
||||
first_video_info: Optional[dict] = None,
|
||||
default_width: int = 1080,
|
||||
default_height: int = 1920,
|
||||
default_fps: float = 30.0,
|
||||
) -> tuple[int, int, float]:
|
||||
"""计算输出视频参数.
|
||||
|
||||
优先级:
|
||||
1. config 中显式指定的(非 0 值)
|
||||
2. 第一段视频的探测参数
|
||||
3. 默认值
|
||||
|
||||
Args:
|
||||
config_width: 配置的宽度(0 表示未指定)
|
||||
config_height: 配置的高度(0 表示未指定)
|
||||
config_fps: 配置的帧率(0 表示未指定)
|
||||
first_video_info: 第一段视频的探测信息字典
|
||||
default_width: 默认宽度
|
||||
default_height: 默认高度
|
||||
default_fps: 默认帧率
|
||||
|
||||
Returns:
|
||||
(宽度, 高度, 帧率)
|
||||
"""
|
||||
width = config_width
|
||||
height = config_height
|
||||
fps = config_fps
|
||||
|
||||
info = first_video_info or {}
|
||||
|
||||
# 宽度:用配置 → 探测 → 默认
|
||||
if width == 0:
|
||||
width = int(info.get("width", default_width))
|
||||
|
||||
# 高度
|
||||
if height == 0:
|
||||
height = int(info.get("height", default_height))
|
||||
|
||||
# 帧率
|
||||
if fps == 0:
|
||||
fps_str = info.get("r_frame_rate", f"{int(default_fps)}/1")
|
||||
fps = parse_fps(fps_str)
|
||||
|
||||
# 确保都是有效值
|
||||
width = max(1, width)
|
||||
height = max(1, height)
|
||||
fps = max(1.0, fps)
|
||||
|
||||
return width, height, fps
|
||||
|
||||
|
||||
def calculate_scaled_size(
|
||||
src_w: int,
|
||||
src_h: int,
|
||||
target_w: int,
|
||||
target_h: int,
|
||||
) -> tuple[int, int, int, int]:
|
||||
"""计算等比缩放后的尺寸和填充偏移.
|
||||
|
||||
保持宽高比,不足的部分用黑边填充。
|
||||
|
||||
Args:
|
||||
src_w: 原始宽度
|
||||
src_h: 原始高度
|
||||
target_w: 目标宽度
|
||||
target_h: 目标高度
|
||||
|
||||
Returns:
|
||||
(缩放后宽度, 缩放后高度, X偏移, Y偏移)
|
||||
"""
|
||||
if src_w <= 0 or src_h <= 0:
|
||||
return (target_w, target_h, 0, 0)
|
||||
|
||||
src_ratio = src_w / src_h
|
||||
target_ratio = target_w / target_h
|
||||
|
||||
if abs(src_ratio - target_ratio) < 0.001:
|
||||
# 比例相同,直接缩放
|
||||
return (target_w, target_h, 0, 0)
|
||||
elif src_ratio > target_ratio:
|
||||
# 源更宽,以宽度为准,上下填充
|
||||
scaled_w = target_w
|
||||
scaled_h = int(target_w / src_ratio)
|
||||
offset_x = 0
|
||||
offset_y = (target_h - scaled_h) // 2
|
||||
return (scaled_w, scaled_h, offset_x, offset_y)
|
||||
else:
|
||||
# 源更高,以高度为准,左右填充
|
||||
scaled_h = target_h
|
||||
scaled_w = int(target_h * src_ratio)
|
||||
offset_x = (target_w - scaled_w) // 2
|
||||
offset_y = 0
|
||||
return (scaled_w, scaled_h, offset_x, offset_y)
|
||||
|
||||
|
||||
# ── stream copy 判断 ─────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def can_use_stream_copy(
|
||||
segments: list[dict],
|
||||
target_width: int,
|
||||
target_height: int,
|
||||
target_fps: float,
|
||||
force_reencode: bool = False,
|
||||
) -> bool:
|
||||
"""判断是否可以使用 stream copy(无损拼接).
|
||||
|
||||
stream copy 条件:
|
||||
1. force_reencode 为 False
|
||||
2. 所有视频段的编码格式、分辨率、帧率均相同
|
||||
3. 目标参数与源参数一致(不需要转码)
|
||||
|
||||
Args:
|
||||
segments: 视频段列表,每个元素包含 codec_name/width/height/fps
|
||||
target_width: 目标宽度
|
||||
target_height: 目标高度
|
||||
target_fps: 目标帧率
|
||||
force_reencode: 是否强制重编码
|
||||
|
||||
Returns:
|
||||
是否可以用 stream copy
|
||||
"""
|
||||
if force_reencode:
|
||||
return False
|
||||
|
||||
if not segments:
|
||||
return False
|
||||
|
||||
# 用第一段作为基准
|
||||
first = segments[0]
|
||||
base_codec = first.get("codec_name", "")
|
||||
base_width = int(first.get("width", 0))
|
||||
base_height = int(first.get("height", 0))
|
||||
base_fps = parse_fps(first.get("r_frame_rate", "30/1"))
|
||||
|
||||
# 目标参数必须与基准一致
|
||||
if target_width != base_width or target_height != base_height:
|
||||
return False
|
||||
|
||||
if abs(target_fps - base_fps) > 0.01:
|
||||
return False
|
||||
|
||||
# 所有段必须参数一致
|
||||
for seg in segments[1:]:
|
||||
if seg.get("codec_name", "") != base_codec:
|
||||
return False
|
||||
if int(seg.get("width", 0)) != base_width:
|
||||
return False
|
||||
if int(seg.get("height", 0)) != base_height:
|
||||
return False
|
||||
seg_fps = parse_fps(seg.get("r_frame_rate", "30/1"))
|
||||
if abs(seg_fps - base_fps) > 0.01:
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
|
||||
# ── 文件列表生成(demuxer 模式) ───────────────────────────────────────────────
|
||||
|
||||
|
||||
def generate_concat_file_list(
|
||||
video_paths: list[str],
|
||||
) -> str:
|
||||
"""生成 concat demuxer 模式的文件列表内容.
|
||||
|
||||
格式:
|
||||
file '/path/to/video1.mp4'
|
||||
file '/path/to/video2.mp4'
|
||||
|
||||
Args:
|
||||
video_paths: 视频文件路径列表
|
||||
|
||||
Returns:
|
||||
文件列表文本内容
|
||||
"""
|
||||
lines = []
|
||||
for path in video_paths:
|
||||
# 转义单引号
|
||||
escaped = path.replace("'", "'\\''")
|
||||
lines.append(f"file '{escaped}'")
|
||||
return "\n".join(lines) + "\n"
|
||||
|
||||
|
||||
# ── 滤镜链构建 ────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def build_scale_pad_filter(
|
||||
target_w: int,
|
||||
target_h: int,
|
||||
src_w: int = 0,
|
||||
src_h: int = 0,
|
||||
) -> str:
|
||||
"""构建 scale + pad 滤镜(等比缩放+黑边填充).
|
||||
|
||||
Args:
|
||||
target_w: 目标宽度
|
||||
target_h: 目标高度
|
||||
src_w: 源宽度(0 表示未知,用 iw/ih)
|
||||
src_h: 源高度(0 表示未知)
|
||||
|
||||
Returns:
|
||||
滤镜字符串
|
||||
"""
|
||||
# 使用 FFmpeg 表达式,动态计算
|
||||
return (
|
||||
f"scale={target_w}:{target_h}:force_original_aspect_ratio=decrease,"
|
||||
f"pad={target_w}:{target_h}:(ow-iw)/2:(oh-ih)/2:black"
|
||||
)
|
||||
|
||||
|
||||
def build_fps_filter(fps: float) -> str:
|
||||
"""构建 fps 滤镜.
|
||||
|
||||
Args:
|
||||
fps: 目标帧率
|
||||
|
||||
Returns:
|
||||
fps 滤镜字符串
|
||||
"""
|
||||
return format_fps_filter(fps)
|
||||
|
||||
|
||||
def build_setpts_filter() -> str:
|
||||
"""构建 setpts 滤镜(重置时间戳).
|
||||
|
||||
Returns:
|
||||
setpts 滤镜字符串
|
||||
"""
|
||||
return "setpts=PTS-STARTPTS"
|
||||
|
||||
|
||||
def build_concat_filter(
|
||||
num_inputs: int,
|
||||
has_audio: bool = True,
|
||||
) -> str:
|
||||
"""构建 concat 滤镜.
|
||||
|
||||
Args:
|
||||
num_inputs: 输入数量
|
||||
has_audio: 是否包含音频轨
|
||||
|
||||
Returns:
|
||||
concat 滤镜字符串(包含输入标签)
|
||||
"""
|
||||
if num_inputs <= 0:
|
||||
return ""
|
||||
|
||||
n = num_inputs
|
||||
v = 1 # 视频轨数
|
||||
a = 1 if has_audio else 0 # 音频轨数
|
||||
|
||||
# 构建输入标签
|
||||
input_labels = "".join(f"[{i}:v][{i}:a]" if has_audio else f"[{i}:v]" for i in range(n))
|
||||
|
||||
output_label = "[concat_v]" + ("[concat_a]" if has_audio else "")
|
||||
|
||||
return f"{input_labels}concat=n={n}:v={v}:a={a}{output_label}"
|
||||
|
||||
|
||||
def build_single_segment_filter_chain(
|
||||
target_width: int,
|
||||
target_height: int,
|
||||
target_fps: float,
|
||||
segment_index: int,
|
||||
has_audio: bool = True,
|
||||
) -> str:
|
||||
"""构建单段视频的预处理滤镜链.
|
||||
|
||||
每段视频需要:缩放填充 → 帧率统一 → 重置时间戳
|
||||
|
||||
Args:
|
||||
target_width: 目标宽度
|
||||
target_height: 目标高度
|
||||
target_fps: 目标帧率
|
||||
segment_index: 段索引(用于生成标签)
|
||||
has_audio: 是否包含音频
|
||||
|
||||
Returns:
|
||||
滤镜字符串
|
||||
"""
|
||||
scale_pad = build_scale_pad_filter(target_width, target_height)
|
||||
fps = build_fps_filter(target_fps)
|
||||
setpts = build_setpts_filter()
|
||||
|
||||
input_v = f"[{segment_index}:v]"
|
||||
output_v = f"[v{segment_index}]"
|
||||
|
||||
video_chain = f"{input_v}{scale_pad},{fps},{setpts}{output_v}"
|
||||
|
||||
if has_audio:
|
||||
input_a = f"[{segment_index}:a]"
|
||||
output_a = f"[a{segment_index}]"
|
||||
# 音频也需要重置时间戳
|
||||
audio_chain = f"{input_a}asetpts=PTS-STARTPTS{output_a}"
|
||||
return f"{video_chain};{audio_chain}"
|
||||
|
||||
return video_chain
|
||||
|
||||
|
||||
# ── 配置验证 ──────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def validate_concat_config(config: dict) -> tuple[bool, list[str]]:
|
||||
"""验证拼接配置.
|
||||
|
||||
Args:
|
||||
config: 配置字典
|
||||
|
||||
Returns:
|
||||
(是否合法, 错误信息列表)
|
||||
"""
|
||||
errors: list[str] = []
|
||||
|
||||
segments = config.get("segments", [])
|
||||
if not segments:
|
||||
errors.append("至少需要一个视频段")
|
||||
return (False, errors)
|
||||
|
||||
if len(segments) < 1:
|
||||
errors.append("视频段数量不能少于 1")
|
||||
|
||||
# 检查每个段
|
||||
for i, seg in enumerate(segments):
|
||||
video_path = seg.get("video_path", "")
|
||||
if not video_path:
|
||||
errors.append(f"第 {i+1} 段缺少 video_path")
|
||||
|
||||
# 输出参数
|
||||
output_width = config.get("output_width", 0)
|
||||
output_height = config.get("output_height", 0)
|
||||
if output_width < 0:
|
||||
errors.append("output_width 不能为负数")
|
||||
if output_height < 0:
|
||||
errors.append("output_height 不能为负数")
|
||||
|
||||
output_fps = config.get("output_fps", 0)
|
||||
if output_fps < 0:
|
||||
errors.append("output_fps 不能为负数")
|
||||
|
||||
return (len(errors) == 0, errors)
|
||||
|
||||
|
||||
# ── 路径验证 ──────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def validate_video_path(video_path: str, work_dir: str | Path) -> tuple[bool, str]:
|
||||
"""验证视频路径是否安全.
|
||||
|
||||
检查:
|
||||
1. 路径不为空
|
||||
2. 路径不包含 .. 回溯
|
||||
3. 路径在 work_dir 内(安全边界)
|
||||
|
||||
Args:
|
||||
video_path: 视频文件路径
|
||||
work_dir: 工作目录
|
||||
|
||||
Returns:
|
||||
(是否合法, 错误信息)
|
||||
"""
|
||||
if not video_path:
|
||||
return (False, "视频路径不能为空")
|
||||
|
||||
path_str = str(video_path)
|
||||
work_str = str(work_dir)
|
||||
|
||||
# 检查路径遍历
|
||||
if ".." in Path(path_str).parts:
|
||||
return (False, "视频路径不能包含 .. 回溯")
|
||||
|
||||
# 绝对路径才做边界检查;相对路径默认相对于 work_dir
|
||||
if not Path(path_str).is_absolute():
|
||||
return (True, "")
|
||||
|
||||
# 绝对路径检查是否在工作目录内
|
||||
try:
|
||||
video_abs = Path(path_str).resolve()
|
||||
work_abs = Path(work_str).resolve()
|
||||
if work_abs.is_absolute() and not str(video_abs).startswith(str(work_abs)):
|
||||
return (False, "视频路径必须在工作目录内")
|
||||
except (OSError, ValueError):
|
||||
pass # 解析失败时跳过边界检查
|
||||
|
||||
return (True, "")
|
||||
|
||||
|
||||
# ── 工具函数 ──────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def estimate_total_duration(segments: list[dict]) -> float:
|
||||
"""估算总时长.
|
||||
|
||||
Args:
|
||||
segments: 视频段列表,每个元素包含 duration 字段
|
||||
|
||||
Returns:
|
||||
总时长(秒)
|
||||
"""
|
||||
total = 0.0
|
||||
for seg in segments:
|
||||
dur = seg.get("duration", 0)
|
||||
try:
|
||||
total += float(dur)
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
return total
|
||||
|
||||
|
||||
def count_valid_segments(segments: list[dict]) -> int:
|
||||
"""统计有效视频段数量(有 video_path 的).
|
||||
|
||||
Args:
|
||||
segments: 视频段列表
|
||||
|
||||
Returns:
|
||||
有效段数量
|
||||
"""
|
||||
count = 0
|
||||
for seg in segments:
|
||||
if seg.get("video_path"):
|
||||
count += 1
|
||||
return count
|
||||
@@ -1,466 +0,0 @@
|
||||
"""多轨混音纯逻辑模块.
|
||||
|
||||
所有函数均为纯函数,不调用 FFmpeg、不操作文件。
|
||||
便于单元测试,也方便被其他模块复用。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Optional
|
||||
|
||||
# ── 单轨时间计算 ──────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def calculate_effective_range(
|
||||
track_start: float,
|
||||
track_duration: float,
|
||||
audio_duration: float,
|
||||
target_duration: float,
|
||||
) -> tuple[float, float, float]:
|
||||
"""计算轨道的有效时间范围.
|
||||
|
||||
处理:
|
||||
- 轨道时长为 0 或负时用音频完整时长
|
||||
- 轨道开始在目标时长外时跳过
|
||||
- 轨道开始为负时截断开头
|
||||
|
||||
Args:
|
||||
track_start: 轨道开始时间(秒),可为负
|
||||
track_duration: 轨道持续时长(秒),<=0 表示用音频全长
|
||||
audio_duration: 音频文件实际时长(秒)
|
||||
target_duration: 目标总时长(秒)
|
||||
|
||||
Returns:
|
||||
(effective_start, need_duration, trim_start)
|
||||
- effective_start: 在目标时间轴上的开始位置(>=0)
|
||||
- need_duration: 需要截取的音频长度
|
||||
- trim_start: 从源音频的哪个位置开始截取
|
||||
"""
|
||||
if audio_duration <= 0:
|
||||
return (0.0, 0.0, 0.0)
|
||||
|
||||
# 有效时长(轨道声明的时长,未被截断的)
|
||||
if track_duration > 0:
|
||||
effective_dur = min(track_duration, audio_duration)
|
||||
else:
|
||||
effective_dur = audio_duration
|
||||
|
||||
effective_start = track_start
|
||||
trim_start = 0.0
|
||||
|
||||
# 负的开始时间:从源音频中间开始取,轨道前段被截掉
|
||||
if effective_start < 0:
|
||||
trim_start = -effective_start
|
||||
# 可用时长 = 总时长 - 被截掉的前段
|
||||
effective_dur = max(0.0, effective_dur - trim_start)
|
||||
effective_start = 0.0
|
||||
|
||||
# 轨道完全在目标时长之外
|
||||
if effective_start >= target_duration:
|
||||
return (0.0, 0.0, 0.0)
|
||||
|
||||
# 轨道完全在 0 之前
|
||||
if effective_start + effective_dur <= 0:
|
||||
return (0.0, 0.0, 0.0)
|
||||
|
||||
# 实际需要的源时长
|
||||
need_dur = min(effective_dur, target_duration - effective_start)
|
||||
if need_dur <= 0:
|
||||
return (0.0, 0.0, 0.0)
|
||||
|
||||
# 调整 trim_start 不能超过音频长度
|
||||
if trim_start >= audio_duration:
|
||||
return (0.0, 0.0, 0.0)
|
||||
|
||||
return (effective_start, need_dur, trim_start)
|
||||
|
||||
|
||||
def is_track_visible(
|
||||
track_start: float,
|
||||
track_duration: float,
|
||||
audio_duration: float,
|
||||
target_duration: float,
|
||||
) -> bool:
|
||||
"""判断轨道是否在目标时长范围内可见(有声音).
|
||||
|
||||
Args:
|
||||
track_start: 轨道开始时间
|
||||
track_duration: 轨道持续时长
|
||||
audio_duration: 音频时长
|
||||
target_duration: 目标总时长
|
||||
|
||||
Returns:
|
||||
是否可见
|
||||
"""
|
||||
_, need_dur, _ = calculate_effective_range(track_start, track_duration, audio_duration, target_duration)
|
||||
return need_dur > 0
|
||||
|
||||
|
||||
# ── 单轨滤镜链构建 ────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def build_track_filter_chain(
|
||||
volume: float,
|
||||
fade_in: float,
|
||||
fade_out: float,
|
||||
effective_start: float,
|
||||
need_duration: float,
|
||||
trim_start: float,
|
||||
target_duration: float,
|
||||
) -> str:
|
||||
"""构建单轨道预处理滤镜链.
|
||||
|
||||
处理顺序:截断 → 重置时间戳 → 音量 → 淡入 → 淡出 → 延迟 → 最终截断 → 重置时间戳
|
||||
|
||||
Args:
|
||||
volume: 音量 0.0~1.0
|
||||
fade_in: 淡入时长(秒)
|
||||
fade_out: 淡出时长(秒)
|
||||
effective_start: 在目标轴上的开始时间
|
||||
need_duration: 需要截取的时长
|
||||
trim_start: 从源音频的哪个位置开始
|
||||
target_duration: 目标总时长
|
||||
|
||||
Returns:
|
||||
逗号分隔的滤镜字符串
|
||||
"""
|
||||
filter_parts: list[str] = []
|
||||
|
||||
# 1. 截断到有效范围
|
||||
filter_parts.append(f"atrim={trim_start:.3f}:{trim_start + need_duration:.3f}")
|
||||
filter_parts.append("asetpts=N/SR/TB")
|
||||
|
||||
# 2. 音量调节
|
||||
safe_volume = max(0.0, min(2.0, volume))
|
||||
if abs(safe_volume - 1.0) > 0.001:
|
||||
filter_parts.append(f"volume={safe_volume:.3f}")
|
||||
|
||||
# 3. 淡入(必须小于总时长才有效)
|
||||
if fade_in > 0 and fade_in < need_duration:
|
||||
filter_parts.append(f"afade=t=in:st=0:d={fade_in:.3f}")
|
||||
|
||||
# 4. 淡出
|
||||
if fade_out > 0 and fade_out < need_duration:
|
||||
fade_start = need_duration - fade_out
|
||||
if fade_start > 0:
|
||||
filter_parts.append(f"afade=t=out:st={fade_start:.3f}:d={fade_out:.3f}")
|
||||
|
||||
# 5. 时间偏移(开头静音填充)
|
||||
if effective_start > 0.01:
|
||||
delay_ms = int(effective_start * 1000)
|
||||
filter_parts.append(f"adelay={delay_ms}|{delay_ms}")
|
||||
|
||||
# 6. 最终截断到目标总时长
|
||||
filter_parts.append(f"atrim=0:{target_duration:.3f}")
|
||||
filter_parts.append("asetpts=N/SR/TB")
|
||||
|
||||
return ",".join(filter_parts)
|
||||
|
||||
|
||||
# ── amix 混音滤镜构建 ────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def build_amix_filter(num_inputs: int, duration_mode: str = "longest") -> str:
|
||||
"""构建 amix 混音滤镜.
|
||||
|
||||
Args:
|
||||
num_inputs: 输入轨道数量
|
||||
duration_mode: 时长模式:longest / shortest / first
|
||||
|
||||
Returns:
|
||||
amix 滤镜字符串
|
||||
"""
|
||||
if num_inputs <= 0:
|
||||
return ""
|
||||
|
||||
# 校验 duration_mode
|
||||
if duration_mode not in ("longest", "shortest", "first"):
|
||||
duration_mode = "longest"
|
||||
|
||||
return f"amix=inputs={num_inputs}:duration={duration_mode}:dropout_transition=0"
|
||||
|
||||
|
||||
def calculate_amix_volume_compensation(num_inputs: int) -> float:
|
||||
"""计算 amix 后的音量补偿系数.
|
||||
|
||||
amix 会将 N 路输入每路乘以 1/N 来归一化,
|
||||
所以需要乘以 N 来补偿(简单粗暴但有效)。
|
||||
|
||||
Args:
|
||||
num_inputs: 输入轨道数量
|
||||
|
||||
Returns:
|
||||
补偿系数
|
||||
"""
|
||||
if num_inputs <= 1:
|
||||
return 1.0
|
||||
return float(num_inputs)
|
||||
|
||||
|
||||
def build_mix_filter_complex(
|
||||
num_tracks: int,
|
||||
has_main: bool = True,
|
||||
duration_mode: str = "longest",
|
||||
) -> str:
|
||||
"""构建完整的混音 filter_complex.
|
||||
|
||||
Args:
|
||||
num_tracks: 额外轨道数量
|
||||
has_main: 是否有主音频
|
||||
duration_mode: 时长模式
|
||||
|
||||
Returns:
|
||||
filter_complex 字符串
|
||||
"""
|
||||
total_inputs = num_tracks + (1 if has_main else 0)
|
||||
if total_inputs <= 0:
|
||||
return ""
|
||||
|
||||
# 输入标签
|
||||
input_labels = "".join(f"[{i}:a]" for i in range(total_inputs))
|
||||
|
||||
# amix
|
||||
amix = build_amix_filter(total_inputs, duration_mode)
|
||||
|
||||
# 音量补偿
|
||||
compensation = calculate_amix_volume_compensation(total_inputs)
|
||||
volume_filter = ""
|
||||
if abs(compensation - 1.0) > 0.001:
|
||||
volume_filter = f",volume={compensation}"
|
||||
|
||||
return f"{input_labels}{amix}{volume_filter}[mixed]"
|
||||
|
||||
|
||||
# ── 音量计算 ──────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def normalize_volume(volume: float) -> float:
|
||||
"""规范化音量值.
|
||||
|
||||
Args:
|
||||
volume: 原始音量
|
||||
|
||||
Returns:
|
||||
规范化后的音量(0.0 ~ 2.0)
|
||||
"""
|
||||
if volume is None:
|
||||
return 1.0
|
||||
try:
|
||||
v = float(volume)
|
||||
return max(0.0, min(2.0, v))
|
||||
except (ValueError, TypeError):
|
||||
return 1.0
|
||||
|
||||
|
||||
def db_to_linear(db: float) -> float:
|
||||
"""dB 转换为线性音量.
|
||||
|
||||
Args:
|
||||
db: 分贝值
|
||||
|
||||
Returns:
|
||||
线性音量值
|
||||
"""
|
||||
import math
|
||||
|
||||
return 10 ** (db / 20.0)
|
||||
|
||||
|
||||
def linear_to_db(linear: float) -> float:
|
||||
"""线性音量转换为 dB.
|
||||
|
||||
Args:
|
||||
linear: 线性音量值
|
||||
|
||||
Returns:
|
||||
分贝值
|
||||
"""
|
||||
import math
|
||||
|
||||
if linear <= 0:
|
||||
return -float("inf")
|
||||
return 20.0 * math.log10(linear)
|
||||
|
||||
|
||||
# ── 轨道排序与过滤 ────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def sort_tracks_by_priority(
|
||||
tracks: list[dict],
|
||||
) -> list[dict]:
|
||||
"""按优先级排序轨道.
|
||||
|
||||
priority 数字越小优先级越高(越先播放/越底层)。
|
||||
相同优先级保持原顺序。
|
||||
|
||||
Args:
|
||||
tracks: 轨道配置列表
|
||||
|
||||
Returns:
|
||||
排序后的轨道列表
|
||||
"""
|
||||
return sorted(tracks, key=lambda t: int(t.get("priority", 100)))
|
||||
|
||||
|
||||
def filter_enabled_tracks(tracks: list[dict]) -> list[dict]:
|
||||
"""过滤出启用的轨道.
|
||||
|
||||
Args:
|
||||
tracks: 轨道列表
|
||||
|
||||
Returns:
|
||||
启用的轨道列表
|
||||
"""
|
||||
result = []
|
||||
for t in tracks:
|
||||
enabled = t.get("enabled", True)
|
||||
if bool(enabled) and enabled != "false" and enabled != 0:
|
||||
result.append(t)
|
||||
return result
|
||||
|
||||
|
||||
def count_track_types(tracks: list[dict]) -> dict[str, int]:
|
||||
"""统计各类型轨道数量.
|
||||
|
||||
Args:
|
||||
tracks: 轨道列表
|
||||
|
||||
Returns:
|
||||
类型计数字典
|
||||
"""
|
||||
counts: dict[str, int] = {}
|
||||
for t in tracks:
|
||||
ttype = t.get("track_type", "unknown")
|
||||
counts[ttype] = counts.get(ttype, 0) + 1
|
||||
return counts
|
||||
|
||||
|
||||
# ── 配置验证 ──────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def validate_audio_track(track: dict) -> tuple[bool, list[str]]:
|
||||
"""验证单条音轨配置.
|
||||
|
||||
Args:
|
||||
track: 轨道配置字典
|
||||
|
||||
Returns:
|
||||
(是否合法, 错误信息列表)
|
||||
"""
|
||||
errors: list[str] = []
|
||||
|
||||
# 音频路径
|
||||
audio_path = track.get("audio_path", "")
|
||||
if not audio_path and not track.get("asset_id"):
|
||||
errors.append("轨道需要 audio_path 或 asset_id")
|
||||
|
||||
# 音量范围
|
||||
volume = track.get("volume", 1.0)
|
||||
try:
|
||||
v = float(volume)
|
||||
if v < 0:
|
||||
errors.append("volume 不能为负数")
|
||||
if v > 2.0:
|
||||
errors.append("volume 建议不超过 2.0")
|
||||
except (ValueError, TypeError):
|
||||
errors.append("volume 必须是数字")
|
||||
|
||||
# 淡入淡出
|
||||
fade_in = track.get("fade_in", 0)
|
||||
fade_out = track.get("fade_out", 0)
|
||||
try:
|
||||
if float(fade_in) < 0:
|
||||
errors.append("fade_in 不能为负数")
|
||||
except (ValueError, TypeError):
|
||||
errors.append("fade_in 必须是数字")
|
||||
|
||||
try:
|
||||
if float(fade_out) < 0:
|
||||
errors.append("fade_out 不能为负数")
|
||||
except (ValueError, TypeError):
|
||||
errors.append("fade_out 必须是数字")
|
||||
|
||||
# 开始时间
|
||||
start_time = track.get("start_time", 0)
|
||||
try:
|
||||
float(start_time) # 验证是否为数字
|
||||
except (ValueError, TypeError):
|
||||
errors.append("start_time 必须是数字")
|
||||
|
||||
return (len(errors) == 0, errors)
|
||||
|
||||
|
||||
def validate_mix_config(config: dict) -> tuple[bool, list[str]]:
|
||||
"""验证混音配置.
|
||||
|
||||
Args:
|
||||
config: 混音配置
|
||||
|
||||
Returns:
|
||||
(是否合法, 错误信息列表)
|
||||
"""
|
||||
errors: list[str] = []
|
||||
|
||||
tracks = config.get("tracks", [])
|
||||
if not tracks:
|
||||
errors.append("至少需要一条轨道")
|
||||
|
||||
# 验证每条轨道
|
||||
for i, track in enumerate(tracks):
|
||||
ok, track_errors = validate_audio_track(track)
|
||||
if not ok:
|
||||
for err in track_errors:
|
||||
errors.append(f"第{i+1}轨:{err}")
|
||||
|
||||
# 目标时长
|
||||
target_duration = config.get("target_duration", 0)
|
||||
try:
|
||||
if float(target_duration) < 0:
|
||||
errors.append("target_duration 不能为负数")
|
||||
except (ValueError, TypeError):
|
||||
errors.append("target_duration 必须是数字")
|
||||
|
||||
return (len(errors) == 0, errors)
|
||||
|
||||
|
||||
# ── 工具函数 ──────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def calculate_total_tracks(config: dict) -> int:
|
||||
"""计算总轨道数(含主音频).
|
||||
|
||||
Args:
|
||||
config: 混音配置
|
||||
|
||||
Returns:
|
||||
总轨道数
|
||||
"""
|
||||
tracks = config.get("tracks", [])
|
||||
has_main = config.get("has_main_audio", True)
|
||||
count = len(tracks)
|
||||
if has_main:
|
||||
count += 1
|
||||
return count
|
||||
|
||||
|
||||
def estimate_mix_duration(tracks: list[dict]) -> float:
|
||||
"""估算混音总时长(所有轨道的最晚结束时间).
|
||||
|
||||
Args:
|
||||
tracks: 轨道列表,包含 start_time 和 duration
|
||||
|
||||
Returns:
|
||||
估算总时长(秒)
|
||||
"""
|
||||
max_end = 0.0
|
||||
for t in tracks:
|
||||
try:
|
||||
start = float(t.get("start_time", 0))
|
||||
dur = float(t.get("duration", 0))
|
||||
if dur > 0:
|
||||
end = start + dur
|
||||
if end > max_end:
|
||||
max_end = end
|
||||
except (ValueError, TypeError):
|
||||
continue
|
||||
return max_end
|
||||
@@ -1,534 +0,0 @@
|
||||
"""贴纸引擎纯逻辑模块.
|
||||
|
||||
所有函数均为纯函数,不调用 FFmpeg、不操作文件。
|
||||
便于单元测试,也方便被其他模块复用。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Optional
|
||||
|
||||
# ── 安全类型转换 ──────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def safe_float(val: Any) -> Optional[float]:
|
||||
"""安全转换为 float.
|
||||
|
||||
Args:
|
||||
val: 任意值
|
||||
|
||||
Returns:
|
||||
float 值,失败返回 None
|
||||
"""
|
||||
if val is None:
|
||||
return None
|
||||
try:
|
||||
result = float(val)
|
||||
if result != result: # NaN check
|
||||
return None
|
||||
return result
|
||||
except (ValueError, TypeError):
|
||||
return None
|
||||
|
||||
|
||||
def safe_int(val: Any, default: int = 0) -> int:
|
||||
"""安全转换为 int.
|
||||
|
||||
Args:
|
||||
val: 任意值
|
||||
default: 默认值
|
||||
|
||||
Returns:
|
||||
int 值,失败返回默认值
|
||||
"""
|
||||
if val is None:
|
||||
return default
|
||||
try:
|
||||
result = int(float(val))
|
||||
return result
|
||||
except (ValueError, TypeError):
|
||||
return default
|
||||
|
||||
|
||||
def safe_bool(val: Any) -> bool:
|
||||
"""安全转换为 bool.
|
||||
|
||||
Args:
|
||||
val: 任意值
|
||||
|
||||
Returns:
|
||||
bool 值
|
||||
"""
|
||||
if isinstance(val, bool):
|
||||
return val
|
||||
if val is None:
|
||||
return False
|
||||
if isinstance(val, str):
|
||||
return val.lower() in ("true", "1", "yes", "on")
|
||||
return bool(val)
|
||||
|
||||
|
||||
# ── 尺寸估算 ──────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def estimate_sticker_size(
|
||||
canvas_w: int,
|
||||
canvas_h: int,
|
||||
scale: float = 1.0,
|
||||
fixed_width: Optional[int] = None,
|
||||
fixed_height: Optional[int] = None,
|
||||
) -> tuple[int, int]:
|
||||
"""估算贴纸尺寸.
|
||||
|
||||
如果指定了固定宽高,直接使用;否则按画布的 30% * scale 估算。
|
||||
|
||||
Args:
|
||||
canvas_w: 画布宽度
|
||||
canvas_h: 画布高度
|
||||
scale: 缩放比例
|
||||
fixed_width: 固定宽度(可选)
|
||||
fixed_height: 固定高度(可选)
|
||||
|
||||
Returns:
|
||||
(估算宽度, 估算高度)
|
||||
"""
|
||||
if fixed_width and fixed_height:
|
||||
return (fixed_width, fixed_height)
|
||||
|
||||
base_ratio = 0.3
|
||||
est_w = int(canvas_w * base_ratio * scale) if not fixed_width else fixed_width
|
||||
est_h = int(canvas_h * base_ratio * scale) if not fixed_height else fixed_height
|
||||
|
||||
return (max(1, est_w), max(1, est_h))
|
||||
|
||||
|
||||
def estimate_text_size(
|
||||
text: str,
|
||||
font_size: int,
|
||||
) -> tuple[int, int]:
|
||||
"""估算文字贴纸尺寸.
|
||||
|
||||
粗略估算:宽度 = 字数 * 字号 * 0.6,高度 = 字号 * 1.4
|
||||
|
||||
Args:
|
||||
text: 文字内容
|
||||
font_size: 字号
|
||||
|
||||
Returns:
|
||||
(估算宽度, 估算高度)
|
||||
"""
|
||||
if not text:
|
||||
return (0, 0)
|
||||
est_w = int(len(text) * font_size * 0.6)
|
||||
est_h = int(font_size * 1.4)
|
||||
return (max(1, est_w), max(1, est_h))
|
||||
|
||||
|
||||
# ── 时间计算 ──────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def calculate_fade_out_start(
|
||||
start_time: float,
|
||||
duration: float,
|
||||
fade_out: float,
|
||||
) -> float:
|
||||
"""计算淡出开始时间.
|
||||
|
||||
Args:
|
||||
start_time: 开始时间(秒)
|
||||
duration: 持续时长(秒)
|
||||
fade_out: 淡出时长(秒)
|
||||
|
||||
Returns:
|
||||
淡出开始时间(秒),最小为 0
|
||||
"""
|
||||
if fade_out <= 0 or duration <= 0:
|
||||
return 0.0
|
||||
fade_start = start_time + duration - fade_out
|
||||
return max(0.0, fade_start)
|
||||
|
||||
|
||||
def calculate_end_time(start_time: float, duration: float) -> float:
|
||||
"""计算结束时间.
|
||||
|
||||
Args:
|
||||
start_time: 开始时间(秒)
|
||||
duration: 持续时长(秒)
|
||||
|
||||
Returns:
|
||||
结束时间(秒)
|
||||
"""
|
||||
if duration <= 0:
|
||||
return start_time
|
||||
return start_time + duration
|
||||
|
||||
|
||||
def has_time_range(duration: float) -> bool:
|
||||
"""是否有时间范围限制.
|
||||
|
||||
Args:
|
||||
duration: 持续时长(秒)
|
||||
|
||||
Returns:
|
||||
duration > 0 时返回 True
|
||||
"""
|
||||
return duration > 0
|
||||
|
||||
|
||||
# ── 滤镜组件构建 ──────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def build_scale_filter(
|
||||
width: Optional[int] = None,
|
||||
height: Optional[int] = None,
|
||||
scale: float = 1.0,
|
||||
) -> Optional[str]:
|
||||
"""构建缩放滤镜.
|
||||
|
||||
优先使用固定宽高,否则按比例缩放。
|
||||
scale=1.0 且无固定尺寸时返回 None。
|
||||
|
||||
Args:
|
||||
width: 固定宽度(可选)
|
||||
height: 固定高度(可选)
|
||||
scale: 缩放比例
|
||||
|
||||
Returns:
|
||||
scale 滤镜字符串,不需要缩放时返回 None
|
||||
"""
|
||||
if width and height:
|
||||
return f"scale={width}:{height}"
|
||||
if scale != 1.0:
|
||||
return f"scale=iw*{scale}:ih*{scale}"
|
||||
return None
|
||||
|
||||
|
||||
def build_opacity_filter(opacity: float) -> Optional[str]:
|
||||
"""构建透明度滤镜.
|
||||
|
||||
Args:
|
||||
opacity: 不透明度 0.0~1.0
|
||||
|
||||
Returns:
|
||||
colorchannelmixer 滤镜字符串,完全不透明时返回 None
|
||||
"""
|
||||
if opacity >= 1.0:
|
||||
return None
|
||||
safe_opacity = max(0.0, min(1.0, opacity))
|
||||
return f"colorchannelmixer=aa={safe_opacity}"
|
||||
|
||||
|
||||
def build_image_fade_filters(
|
||||
start_time: float,
|
||||
duration: float,
|
||||
fade_in: float = 0.0,
|
||||
fade_out: float = 0.0,
|
||||
) -> list[str]:
|
||||
"""构建图片贴纸淡入淡出滤镜列表.
|
||||
|
||||
使用 FFmpeg fade 滤镜(alpha 通道)。
|
||||
|
||||
Args:
|
||||
start_time: 开始时间(秒)
|
||||
duration: 持续时长(秒)
|
||||
fade_in: 淡入时长(秒)
|
||||
fade_out: 淡出时长(秒)
|
||||
|
||||
Returns:
|
||||
fade 滤镜字符串列表
|
||||
"""
|
||||
filters: list[str] = []
|
||||
|
||||
if fade_in > 0:
|
||||
filters.append(f"fade=in:st={start_time}:d={fade_in}:alpha=1")
|
||||
|
||||
if fade_out > 0 and duration > 0:
|
||||
fade_out_start = calculate_fade_out_start(start_time, duration, fade_out)
|
||||
filters.append(f"fade=out:st={fade_out_start}:d={fade_out}:alpha=1")
|
||||
|
||||
return filters
|
||||
|
||||
|
||||
def build_enable_expr(
|
||||
start_time: float,
|
||||
duration: float,
|
||||
) -> str:
|
||||
"""构建 enable 表达式(时间范围).
|
||||
|
||||
Args:
|
||||
start_time: 开始时间(秒)
|
||||
duration: 持续时长(秒)
|
||||
|
||||
Returns:
|
||||
enable 表达式字符串(包含开头的冒号),无时间限制时返回空字符串
|
||||
"""
|
||||
if duration <= 0:
|
||||
return ""
|
||||
end_time = calculate_end_time(start_time, duration)
|
||||
return f":enable='between(t,{start_time},{end_time})'"
|
||||
|
||||
|
||||
# ── drawtext 文字贴纸相关 ─────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def escape_drawtext_text(text: str) -> str:
|
||||
"""转义 drawtext 中的特殊字符.
|
||||
|
||||
转义冒号和单引号。
|
||||
|
||||
Args:
|
||||
text: 原始文字
|
||||
|
||||
Returns:
|
||||
转义后的文字
|
||||
"""
|
||||
result = text.replace(":", "\\:")
|
||||
result = result.replace("'", "\\'")
|
||||
return result
|
||||
|
||||
|
||||
def build_drawtext_alpha_expr(
|
||||
start_time: float,
|
||||
duration: float,
|
||||
fade_in: float = 0.0,
|
||||
fade_out: float = 0.0,
|
||||
) -> str:
|
||||
"""构建 drawtext 的 alpha 淡入淡出表达式.
|
||||
|
||||
drawtext 没有直接的 fade 滤镜,用 alpha 表达式模拟。
|
||||
|
||||
Args:
|
||||
start_time: 开始时间(秒)
|
||||
duration: 持续时长(秒)
|
||||
fade_in: 淡入时长(秒)
|
||||
fade_out: 淡出时长(秒)
|
||||
|
||||
Returns:
|
||||
alpha 表达式字符串,无淡入淡出时返回 "1"
|
||||
"""
|
||||
parts: list[str] = []
|
||||
|
||||
if fade_in > 0:
|
||||
fade_in_end = start_time + fade_in
|
||||
parts.append(f"if(lt(t,{fade_in_end}),(t-{start_time})/{fade_in},1)")
|
||||
|
||||
if fade_out > 0 and duration > 0:
|
||||
fade_out_start = calculate_fade_out_start(start_time, duration, fade_out)
|
||||
end_time = calculate_end_time(start_time, duration)
|
||||
parts.append(f"if(gt(t,{fade_out_start}),({end_time}-t)/{fade_out},1)")
|
||||
|
||||
if not parts:
|
||||
return "1"
|
||||
|
||||
return "*".join(parts)
|
||||
|
||||
|
||||
def build_stroke_params(
|
||||
stroke_width: int = 0,
|
||||
stroke_color: str = "black",
|
||||
) -> list[str]:
|
||||
"""构建 drawtext 描边参数.
|
||||
|
||||
Args:
|
||||
stroke_width: 描边宽度(0 表示无描边)
|
||||
stroke_color: 描边颜色
|
||||
|
||||
Returns:
|
||||
描边参数列表
|
||||
"""
|
||||
if stroke_width <= 0:
|
||||
return []
|
||||
return [
|
||||
f"borderw={stroke_width}",
|
||||
f"bordercolor={stroke_color}",
|
||||
]
|
||||
|
||||
|
||||
def build_shadow_params(
|
||||
shadow_alpha: float = 0.0,
|
||||
shadow_x: int = 2,
|
||||
shadow_y: int = 2,
|
||||
shadow_color: str = "black",
|
||||
) -> list[str]:
|
||||
"""构建 drawtext 阴影参数.
|
||||
|
||||
Args:
|
||||
shadow_alpha: 阴影透明度(0 表示无阴影)
|
||||
shadow_x: 阴影 X 偏移
|
||||
shadow_y: 阴影 Y 偏移
|
||||
shadow_color: 阴影颜色
|
||||
|
||||
Returns:
|
||||
阴影参数列表
|
||||
"""
|
||||
if shadow_alpha <= 0:
|
||||
return []
|
||||
safe_alpha = max(0.0, min(1.0, shadow_alpha))
|
||||
return [
|
||||
f"shadowx={shadow_x}",
|
||||
f"shadowy={shadow_y}",
|
||||
f"shadowcolor={shadow_color}@{safe_alpha}",
|
||||
]
|
||||
|
||||
|
||||
# ── 贴纸排序与过滤 ────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def sort_stickers_by_z_index(
|
||||
stickers: list[dict[str, Any]],
|
||||
) -> list[dict[str, Any]]:
|
||||
"""按 z_index 排序贴纸.
|
||||
|
||||
z_index 小的在底层,大的在上层。
|
||||
相同 z_index 保持原顺序(稳定排序)。
|
||||
|
||||
Args:
|
||||
stickers: 贴纸配置列表
|
||||
|
||||
Returns:
|
||||
排序后的贴纸列表
|
||||
"""
|
||||
return sorted(stickers, key=lambda s: safe_int(s.get("z_index"), 10))
|
||||
|
||||
|
||||
def filter_enabled_stickers(
|
||||
stickers: list[dict[str, Any]],
|
||||
) -> list[dict[str, Any]]:
|
||||
"""过滤出启用的贴纸.
|
||||
|
||||
Args:
|
||||
stickers: 贴纸配置列表
|
||||
|
||||
Returns:
|
||||
启用的贴纸列表
|
||||
"""
|
||||
result = []
|
||||
for s in stickers:
|
||||
enabled = s.get("enabled", True)
|
||||
if safe_bool(enabled):
|
||||
result.append(s)
|
||||
return result
|
||||
|
||||
|
||||
def count_sticker_types(
|
||||
stickers: list[dict[str, Any]],
|
||||
) -> dict[str, int]:
|
||||
"""统计各类型贴纸数量.
|
||||
|
||||
Args:
|
||||
stickers: 贴纸配置列表
|
||||
|
||||
Returns:
|
||||
类型计数字典
|
||||
"""
|
||||
counts: dict[str, int] = {}
|
||||
for s in stickers:
|
||||
stype = s.get("type", "image")
|
||||
counts[stype] = counts.get(stype, 0) + 1
|
||||
return counts
|
||||
|
||||
|
||||
# ── overlay 滤镜构建 ──────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def build_overlay_position(
|
||||
pos_x: float,
|
||||
pos_y: float,
|
||||
) -> str:
|
||||
"""构建 overlay 位置参数.
|
||||
|
||||
Args:
|
||||
pos_x: X 坐标
|
||||
pos_y: Y 坐标
|
||||
|
||||
Returns:
|
||||
overlay 位置字符串 "x:y"
|
||||
"""
|
||||
return f"{pos_x:.0f}:{pos_y:.0f}"
|
||||
|
||||
|
||||
def build_pre_filter_label(idx: int) -> str:
|
||||
"""构建贴纸预处理后的标签名.
|
||||
|
||||
Args:
|
||||
idx: 贴纸索引
|
||||
|
||||
Returns:
|
||||
滤镜标签字符串
|
||||
"""
|
||||
return f"sticker_{idx}_scaled"
|
||||
|
||||
|
||||
# ── 验证函数 ──────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def validate_image_sticker(sticker: dict[str, Any]) -> tuple[bool, list[str]]:
|
||||
"""验证图片贴纸配置.
|
||||
|
||||
Args:
|
||||
sticker: 贴纸配置字典
|
||||
|
||||
Returns:
|
||||
(是否合法, 错误信息列表)
|
||||
"""
|
||||
errors: list[str] = []
|
||||
|
||||
# 图片路径
|
||||
image_path = sticker.get("image_path", "")
|
||||
if not image_path and not sticker.get("asset_id"):
|
||||
errors.append("图片贴纸需要 image_path 或 asset_id")
|
||||
|
||||
# 透明度范围
|
||||
opacity = safe_float(sticker.get("opacity", 1.0))
|
||||
if opacity is not None and (opacity < 0 or opacity > 1):
|
||||
errors.append("opacity 必须在 0~1 之间")
|
||||
|
||||
# 缩放比例
|
||||
scale = safe_float(sticker.get("scale", 1.0))
|
||||
if scale is not None and scale <= 0:
|
||||
errors.append("scale 必须大于 0")
|
||||
|
||||
# 时间参数
|
||||
duration = safe_float(sticker.get("duration", 0))
|
||||
if duration is not None and duration < 0:
|
||||
errors.append("duration 不能为负数")
|
||||
|
||||
start_time = safe_float(sticker.get("start_time", 0))
|
||||
if start_time is not None and start_time < 0:
|
||||
errors.append("start_time 不能为负数")
|
||||
|
||||
return (len(errors) == 0, errors)
|
||||
|
||||
|
||||
def validate_text_sticker(sticker: dict[str, Any]) -> tuple[bool, list[str]]:
|
||||
"""验证文字贴纸配置.
|
||||
|
||||
Args:
|
||||
sticker: 贴纸配置字典
|
||||
|
||||
Returns:
|
||||
(是否合法, 错误信息列表)
|
||||
"""
|
||||
errors: list[str] = []
|
||||
|
||||
# 文字内容
|
||||
text = sticker.get("text", "")
|
||||
if not text:
|
||||
errors.append("文字贴纸需要 text 内容")
|
||||
|
||||
# 字号
|
||||
font_size = safe_int(sticker.get("font_size", 36))
|
||||
if font_size <= 0:
|
||||
errors.append("font_size 必须大于 0")
|
||||
|
||||
# 颜色
|
||||
font_color = sticker.get("font_color", "white")
|
||||
if not font_color:
|
||||
errors.append("font_color 不能为空")
|
||||
|
||||
# 时间参数
|
||||
duration = safe_float(sticker.get("duration", 0))
|
||||
if duration is not None and duration < 0:
|
||||
errors.append("duration 不能为负数")
|
||||
|
||||
return (len(errors) == 0, errors)
|
||||
@@ -1,10 +1,8 @@
|
||||
#!/usr/bin/env python3
|
||||
"""CI中自动修复代码格式(Python: black + isort | Frontend: prettier),并推送回原分支。
|
||||
|
||||
- PR事件:所有PR只要Code Quality因格式问题失败,自动修复并push回源分支
|
||||
- PR事件:自动修复并push回PR源分支(Agent提交的PR自动修,人提交的仅诊断)
|
||||
- Push事件(develop/main):自动修复并push回原分支,保持主干格式永远正确
|
||||
- 防循环:修复commit带 [skip ci-format-check] 标记,检测到该标记则跳过修复
|
||||
- 只修格式(black/isort/prettier),ruff逻辑类错误不动
|
||||
当code quality检查因格式问题失败时触发。
|
||||
"""
|
||||
|
||||
@@ -241,7 +239,7 @@ def main():
|
||||
print("无法获取PR号,跳过自动修复")
|
||||
return
|
||||
|
||||
# 获取PR信息
|
||||
# 获取PR作者信息,判断是人还是Agent提交的
|
||||
pr_info_url = f"{api_url}/repos/{repo}/pulls/{pr_number}"
|
||||
req_pr = urllib.request.Request(pr_info_url, headers={"Authorization": f"token {token}"})
|
||||
with urllib.request.urlopen(req_pr) as resp:
|
||||
@@ -249,26 +247,17 @@ def main():
|
||||
pr_author = pr_info.get("user", {}).get("login", "")
|
||||
print(f"PR作者: {pr_author}")
|
||||
|
||||
# 防循环检测:检查最新commit是否已经是格式修复commit
|
||||
# 修复commit message 带 [skip ci-format-check] 标记,检测到则跳过
|
||||
head_branch_tmp = pr_info.get("head", {}).get("ref", "")
|
||||
skip_marker = "[skip ci-format-check]"
|
||||
try:
|
||||
commits_url = f"{api_url}/repos/{repo}/pulls/{pr_number}/commits?limit=3"
|
||||
req_commits = urllib.request.Request(commits_url, headers={"Authorization": f"token {token}"})
|
||||
with urllib.request.urlopen(req_commits) as resp_commits:
|
||||
commits = json.loads(resp_commits.read())
|
||||
latest_msg = commits[0].get("commit", {}).get("message", "") if commits else ""
|
||||
if skip_marker in latest_msg:
|
||||
print(f"检测到最新commit包含 {skip_marker} 标记,跳过格式修复(防循环)")
|
||||
print("本次格式检查失败是格式修复commit触发的CI回跑,属正常现象")
|
||||
sys.exit(0)
|
||||
except Exception as e:
|
||||
print(f"⚠️ 防循环检测失败,继续执行: {e}")
|
||||
# 判断是否为Agent提交的PR
|
||||
agent_authors = {"actions", "auto-approve-bot", "gitea-actions"}
|
||||
is_agent_pr = pr_author in agent_authors or "bot" in pr_author.lower()
|
||||
|
||||
# 所有PR都自动修复格式(不再区分人/Agent)
|
||||
print("检测到格式问题,将自动修复并推送回分支")
|
||||
fix_mode = "auto_fix_and_push"
|
||||
if is_agent_pr:
|
||||
print(f"检测到Agent提交的PR(作者: {pr_author}),将自动修复并推送")
|
||||
fix_mode = "auto_fix_and_push"
|
||||
else:
|
||||
print(f"检测到人提交的PR(作者: {pr_author}),仅诊断不自动修改")
|
||||
print("(如需自动修复,请用Agent账号提交PR,或手动运行格式化脚本)")
|
||||
fix_mode = "diagnose_only"
|
||||
|
||||
print("=== 检测到代码格式问题,尝试自动修复 ===")
|
||||
print(f"PR #{pr_number}")
|
||||
@@ -326,6 +315,26 @@ def main():
|
||||
print("没有需要提交的格式改动")
|
||||
return
|
||||
|
||||
# 诊断模式:只报告问题,不修改不推送
|
||||
if fix_mode == "diagnose_only":
|
||||
print()
|
||||
print("=" * 50)
|
||||
print("📋 格式问题诊断报告(人提交的PR,仅诊断不自动修复)")
|
||||
print("=" * 50)
|
||||
print()
|
||||
print("以下文件存在格式问题,建议手动修复:")
|
||||
for line in result.stdout.strip().split("\n"):
|
||||
print(f" {line}")
|
||||
print()
|
||||
print("修复方式:")
|
||||
print(" 后端(Python): 运行 black + isort")
|
||||
print(" 前端: 运行 prettier --write")
|
||||
print(" 或使用 scripts/agent-commit.sh 提交(自动格式化)")
|
||||
print()
|
||||
print("=" * 50)
|
||||
# 以非0状态码退出,让CI继续报失败(因为问题没修)
|
||||
sys.exit(1)
|
||||
|
||||
print()
|
||||
print("变更文件:")
|
||||
for line in result.stdout.strip().split("\n"):
|
||||
@@ -333,7 +342,7 @@ def main():
|
||||
|
||||
# 提交修复
|
||||
run("git add -A")
|
||||
run('git commit -m "style: auto-format with black + isort + prettier [skip ci-format-check]"')
|
||||
run('git commit -m "style: auto-format with black + isort + prettier"')
|
||||
|
||||
# 推送(head_branch已从ensure_git_repo获取)
|
||||
print(f"\nPR来源分支: {head_branch}")
|
||||
|
||||
@@ -715,7 +715,7 @@ def main():
|
||||
|
||||
if not review_result:
|
||||
logger.error("LLM 审查失败")
|
||||
sys.exit(0) # fail-open: LLM调用失败不阻塞合并
|
||||
sys.exit(1)
|
||||
|
||||
# 7. 加上审查时间和标识(便于识别是自动审查)
|
||||
from datetime import datetime
|
||||
@@ -773,7 +773,7 @@ def main():
|
||||
|
||||
except Exception as e:
|
||||
logger.exception(f"审查脚本发生未预期的异常: {e}")
|
||||
sys.exit(0) # fail-open: 异常不阻塞正常开发
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
@@ -1,658 +0,0 @@
|
||||
"""BGM 混音纯逻辑单元测试."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
from video_processing.bgm_mixer_pure import (
|
||||
BGMPureConfig,
|
||||
build_bgm_filter_chain,
|
||||
build_sidechain_mix_filter,
|
||||
build_simple_mix_filter,
|
||||
calculate_fade_out_start,
|
||||
calculate_loop_count,
|
||||
calculate_sidechain_ratio,
|
||||
estimate_bgm_processing_duration,
|
||||
normalize_bgm_config,
|
||||
should_loop_bgm,
|
||||
validate_bgm_config,
|
||||
)
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# should_loop_bgm 测试
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestShouldLoopBGM:
|
||||
"""BGM 循环判断测试."""
|
||||
|
||||
def test_need_loop_when_much_shorter(self):
|
||||
"""BGM 远短于目标时长,需要循环."""
|
||||
assert should_loop_bgm(10, 100, True) is True
|
||||
|
||||
def test_no_loop_when_long_enough(self):
|
||||
"""BGM 够长,不需要循环."""
|
||||
assert should_loop_bgm(100, 100, True) is False
|
||||
|
||||
def test_no_loop_when_just_slightly_shorter(self):
|
||||
"""BGM 只差一点点(>90%),不循环."""
|
||||
assert should_loop_bgm(95, 100, True) is False
|
||||
|
||||
def test_threshold_90_percent(self):
|
||||
"""刚好 90% 阈值,不循环(<90% 才循环)."""
|
||||
assert should_loop_bgm(90, 100, True) is False
|
||||
|
||||
def test_just_below_threshold(self):
|
||||
"""略低于 90%,需要循环."""
|
||||
assert should_loop_bgm(89, 100, True) is True
|
||||
|
||||
def test_loop_disabled(self):
|
||||
"""禁用循环,即使 BGM 很短也不循环."""
|
||||
assert should_loop_bgm(10, 100, False) is False
|
||||
|
||||
def test_zero_bgm_duration(self):
|
||||
"""BGM 时长为 0,不循环."""
|
||||
assert should_loop_bgm(0, 100, True) is False
|
||||
|
||||
def test_negative_bgm_duration(self):
|
||||
"""BGM 时长为负,不循环."""
|
||||
assert should_loop_bgm(-5, 100, True) is False
|
||||
|
||||
def test_zero_target_duration(self):
|
||||
"""目标时长为 0,不循环."""
|
||||
assert should_loop_bgm(10, 0, True) is False
|
||||
|
||||
def test_negative_target_duration(self):
|
||||
"""目标时长为负,不循环."""
|
||||
assert should_loop_bgm(10, -10, True) is False
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# calculate_loop_count 测试
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestCalculateLoopCount:
|
||||
"""循环次数计算测试."""
|
||||
|
||||
def test_exact_multiple(self):
|
||||
"""刚好整数倍."""
|
||||
# 100/10 = 10, +2 = 12
|
||||
assert calculate_loop_count(10, 100) == 12
|
||||
|
||||
def test_not_exact_multiple(self):
|
||||
"""不是整数倍."""
|
||||
# 100/30 = 3, +2 = 5
|
||||
assert calculate_loop_count(30, 100) == 5
|
||||
|
||||
def test_bgm_longer_than_target(self):
|
||||
"""BGM 比目标长,至少 1 次."""
|
||||
assert calculate_loop_count(200, 100) == 1
|
||||
|
||||
def test_zero_bgm_duration(self):
|
||||
"""BGM 时长为 0,返回 1."""
|
||||
assert calculate_loop_count(0, 100) == 1
|
||||
|
||||
def test_negative_bgm_duration(self):
|
||||
"""BGM 时长为负,返回 1."""
|
||||
assert calculate_loop_count(-5, 100) == 1
|
||||
|
||||
def test_zero_target_duration(self):
|
||||
"""目标时长为 0,返回 1."""
|
||||
assert calculate_loop_count(10, 0) == 1
|
||||
|
||||
def test_negative_target_duration(self):
|
||||
"""目标时长为负,返回 1."""
|
||||
assert calculate_loop_count(10, -10) == 1
|
||||
|
||||
def test_very_short_bgm(self):
|
||||
"""非常短的 BGM,循环次数多."""
|
||||
# 100/1 = 100, +2 = 102
|
||||
assert calculate_loop_count(1, 100) == 102
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# build_bgm_filter_chain 测试
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestBuildBGMFilterChain:
|
||||
"""BGM 预处理滤镜链构建测试."""
|
||||
|
||||
def test_basic_volume_only(self):
|
||||
"""只有音量调节."""
|
||||
result = build_bgm_filter_chain(
|
||||
bgm_duration=200,
|
||||
target_duration=100,
|
||||
volume=0.5,
|
||||
)
|
||||
assert "volume=0.500" in result
|
||||
assert "aloop" not in result
|
||||
assert "afade=t=in" not in result
|
||||
assert "afade=t=out" not in result
|
||||
assert "atrim=0:100.000" in result
|
||||
assert "asetpts=N/SR/TB" in result
|
||||
|
||||
def test_with_loop(self):
|
||||
"""需要循环的情况."""
|
||||
result = build_bgm_filter_chain(
|
||||
bgm_duration=10,
|
||||
target_duration=100,
|
||||
volume=0.3,
|
||||
loop_enabled=True,
|
||||
)
|
||||
assert "aloop=loop=" in result
|
||||
assert "volume=0.300" in result
|
||||
|
||||
def test_no_loop_when_disabled(self):
|
||||
"""禁用循环,即使 BGM 短也不循环."""
|
||||
result = build_bgm_filter_chain(
|
||||
bgm_duration=10,
|
||||
target_duration=100,
|
||||
volume=0.3,
|
||||
loop_enabled=False,
|
||||
)
|
||||
assert "aloop" not in result
|
||||
|
||||
def test_fade_in_only(self):
|
||||
"""只有淡入."""
|
||||
result = build_bgm_filter_chain(
|
||||
bgm_duration=200,
|
||||
target_duration=100,
|
||||
volume=1.0,
|
||||
fade_in=2.5,
|
||||
)
|
||||
assert "afade=t=in:st=0:d=2.500" in result
|
||||
assert "afade=t=out" not in result
|
||||
assert "volume=" not in result # volume=1.0 不加
|
||||
|
||||
def test_fade_out_only(self):
|
||||
"""只有淡出."""
|
||||
result = build_bgm_filter_chain(
|
||||
bgm_duration=200,
|
||||
target_duration=100,
|
||||
volume=1.0,
|
||||
fade_out=3.0,
|
||||
)
|
||||
assert "afade=t=out:st=97.000:d=3.000" in result
|
||||
assert "afade=t=in" not in result
|
||||
|
||||
def test_fade_in_and_out(self):
|
||||
"""淡入+淡出."""
|
||||
result = build_bgm_filter_chain(
|
||||
bgm_duration=200,
|
||||
target_duration=100,
|
||||
volume=1.0,
|
||||
fade_in=1.5,
|
||||
fade_out=2.0,
|
||||
)
|
||||
assert "afade=t=in:st=0:d=1.500" in result
|
||||
assert "afade=t=out:st=98.000:d=2.000" in result
|
||||
|
||||
def test_volume_1_0_skipped(self):
|
||||
"""音量为 1.0 时不添加 volume 滤镜."""
|
||||
result = build_bgm_filter_chain(
|
||||
bgm_duration=200,
|
||||
target_duration=100,
|
||||
volume=1.0,
|
||||
)
|
||||
assert "volume=" not in result
|
||||
|
||||
def test_volume_0(self):
|
||||
"""音量为 0."""
|
||||
result = build_bgm_filter_chain(
|
||||
bgm_duration=200,
|
||||
target_duration=100,
|
||||
volume=0.0,
|
||||
)
|
||||
assert "volume=0.000" in result
|
||||
|
||||
def test_volume_clamped_high(self):
|
||||
"""音量超过 1.0 被钳制."""
|
||||
result = build_bgm_filter_chain(
|
||||
bgm_duration=200,
|
||||
target_duration=100,
|
||||
volume=1.5,
|
||||
)
|
||||
assert "volume=1.000" not in result # 1.0不加
|
||||
# 钳制到1.0后和1.0一样,不加volume滤镜
|
||||
# 但因为abs(1.0 - 1.0) < 0.001,所以不添加
|
||||
assert "volume=" not in result
|
||||
|
||||
def test_volume_clamped_low(self):
|
||||
"""音量为负被钳制到 0."""
|
||||
result = build_bgm_filter_chain(
|
||||
bgm_duration=200,
|
||||
target_duration=100,
|
||||
volume=-0.5,
|
||||
)
|
||||
assert "volume=0.000" in result
|
||||
|
||||
def test_fade_out_longer_than_duration(self):
|
||||
"""淡出时长超过总时长,不加淡出."""
|
||||
result = build_bgm_filter_chain(
|
||||
bgm_duration=200,
|
||||
target_duration=10,
|
||||
volume=1.0,
|
||||
fade_out=20.0,
|
||||
)
|
||||
assert "afade=t=out" not in result
|
||||
|
||||
def test_fade_out_equal_to_duration(self):
|
||||
"""淡出时长等于总时长,不加淡出."""
|
||||
result = build_bgm_filter_chain(
|
||||
bgm_duration=200,
|
||||
target_duration=10,
|
||||
volume=1.0,
|
||||
fade_out=10.0,
|
||||
)
|
||||
assert "afade=t=out" not in result
|
||||
|
||||
def test_zero_target_duration_fallback(self):
|
||||
"""目标时长为 0,兜底 5 秒."""
|
||||
result = build_bgm_filter_chain(
|
||||
bgm_duration=3,
|
||||
target_duration=0,
|
||||
volume=0.5,
|
||||
)
|
||||
assert "atrim=0:5.000" in result
|
||||
|
||||
def test_negative_target_duration_fallback(self):
|
||||
"""目标时长为负,兜底 5 秒."""
|
||||
result = build_bgm_filter_chain(
|
||||
bgm_duration=3,
|
||||
target_duration=-5,
|
||||
volume=0.5,
|
||||
)
|
||||
assert "atrim=0:5.000" in result
|
||||
|
||||
def test_full_chain_with_all_effects(self):
|
||||
"""完整滤镜链:循环+音量+淡入淡出+截断+重置."""
|
||||
result = build_bgm_filter_chain(
|
||||
bgm_duration=10,
|
||||
target_duration=100,
|
||||
volume=0.4,
|
||||
fade_in=1.0,
|
||||
fade_out=2.0,
|
||||
loop_enabled=True,
|
||||
)
|
||||
parts = result.split(",")
|
||||
# 顺序:aloop -> volume -> afade in -> afade out -> atrim -> asetpts
|
||||
assert len(parts) >= 6
|
||||
assert "aloop" in parts[0]
|
||||
assert "volume" in parts[1]
|
||||
assert "afade=t=in" in parts[2]
|
||||
assert "afade=t=out" in parts[3]
|
||||
assert "atrim" in parts[4]
|
||||
assert "asetpts" in parts[5]
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# calculate_sidechain_ratio 测试
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestCalculateSidechainRatio:
|
||||
"""Sidechain 压缩比计算测试."""
|
||||
|
||||
def test_default_ratio_0_3(self):
|
||||
"""默认 0.3."""
|
||||
# 1 / (1 - 0.3) = 1.428... 但下限是 2.0
|
||||
assert calculate_sidechain_ratio(0.3) == pytest.approx(2.0, rel=0.01)
|
||||
|
||||
def test_ratio_0_5(self):
|
||||
"""比例 0.5."""
|
||||
# 1 / (1 - 0.5) = 2.0
|
||||
assert calculate_sidechain_ratio(0.5) == pytest.approx(2.0, rel=0.01)
|
||||
|
||||
def test_ratio_0_8(self):
|
||||
"""比例 0.8."""
|
||||
# 1 / (1 - 0.8) = 5.0
|
||||
assert calculate_sidechain_ratio(0.8) == pytest.approx(5.0, rel=0.01)
|
||||
|
||||
def test_ratio_0_9(self):
|
||||
"""比例 0.9."""
|
||||
# 1 / (1 - 0.9) = 10.0
|
||||
assert calculate_sidechain_ratio(0.9) == pytest.approx(10.0, rel=0.01)
|
||||
|
||||
def test_ratio_0(self):
|
||||
"""比例 0,返回下限 2.0."""
|
||||
assert calculate_sidechain_ratio(0.0) == 2.0
|
||||
|
||||
def test_ratio_negative(self):
|
||||
"""比例为负,返回下限 2.0."""
|
||||
assert calculate_sidechain_ratio(-0.5) == 2.0
|
||||
|
||||
def test_ratio_1_0(self):
|
||||
"""比例 1.0,返回上限 10.0."""
|
||||
assert calculate_sidechain_ratio(1.0) == 10.0
|
||||
|
||||
def test_ratio_greater_than_1(self):
|
||||
"""比例超过 1.0,返回上限 10.0."""
|
||||
assert calculate_sidechain_ratio(2.0) == 10.0
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# build_simple_mix_filter 测试
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestBuildSimpleMixFilter:
|
||||
"""普通混音滤镜构建测试."""
|
||||
|
||||
def test_contains_amix(self):
|
||||
"""包含 amix."""
|
||||
result = build_simple_mix_filter()
|
||||
assert "amix=inputs=2" in result
|
||||
|
||||
def test_contains_volume_compensation(self):
|
||||
"""包含 volume=2 补偿."""
|
||||
result = build_simple_mix_filter()
|
||||
assert "volume=2" in result
|
||||
|
||||
def test_output_label(self):
|
||||
"""输出标签为 [final]."""
|
||||
result = build_simple_mix_filter()
|
||||
assert "[final]" in result
|
||||
|
||||
def test_duration_first(self):
|
||||
"""duration=first,以主音频时长为准."""
|
||||
result = build_simple_mix_filter()
|
||||
assert "duration=first" in result
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# build_sidechain_mix_filter 测试
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestBuildSidechainMixFilter:
|
||||
"""Sidechain 混音滤镜构建测试."""
|
||||
|
||||
def test_contains_sidechaincompress(self):
|
||||
"""包含 sidechaincompress."""
|
||||
result = build_sidechain_mix_filter()
|
||||
assert "sidechaincompress=" in result
|
||||
|
||||
def test_threshold_param(self):
|
||||
"""threshold 参数正确."""
|
||||
result = build_sidechain_mix_filter(threshold=-30.0)
|
||||
assert "threshold=-30.0dB" in result
|
||||
|
||||
def test_attack_param(self):
|
||||
"""attack 参数正确."""
|
||||
result = build_sidechain_mix_filter(attack=0.05)
|
||||
assert "attack=0.050" in result
|
||||
|
||||
def test_release_param(self):
|
||||
"""release 参数正确."""
|
||||
result = build_sidechain_mix_filter(release=0.8)
|
||||
assert "release=0.800" in result
|
||||
|
||||
def test_knee_param(self):
|
||||
"""knee=6 参数."""
|
||||
result = build_sidechain_mix_filter()
|
||||
assert "knee=6" in result
|
||||
|
||||
def test_contains_amix(self):
|
||||
"""包含 amix 混音."""
|
||||
result = build_sidechain_mix_filter()
|
||||
assert "amix=inputs=2" in result
|
||||
|
||||
def test_volume_compensation(self):
|
||||
"""volume=1.5 轻微补偿."""
|
||||
result = build_sidechain_mix_filter()
|
||||
assert "volume=1.5" in result
|
||||
|
||||
def test_bgmc_comp_label(self):
|
||||
"""包含 [bgm_comp] 中间标签."""
|
||||
result = build_sidechain_mix_filter()
|
||||
assert "[bgm_comp]" in result
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# normalize_bgm_config 测试
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestNormalizeBGMConfig:
|
||||
"""配置规范化测试."""
|
||||
|
||||
def test_empty_dict_defaults(self):
|
||||
"""空字典返回默认值."""
|
||||
result = normalize_bgm_config({})
|
||||
assert result["volume"] == 0.3
|
||||
assert result["fade_in"] == 0.0
|
||||
assert result["fade_out"] == 0.0
|
||||
assert result["loop_enabled"] is True
|
||||
assert result["sidechain_enabled"] is False
|
||||
assert result["sidechain_ratio"] == 0.3
|
||||
|
||||
def test_volume_clamped(self):
|
||||
"""音量钳制."""
|
||||
result = normalize_bgm_config({"volume": 1.5})
|
||||
assert result["volume"] == 1.0
|
||||
result2 = normalize_bgm_config({"volume": -0.5})
|
||||
assert result2["volume"] == 0.0
|
||||
|
||||
def test_fade_in_negative(self):
|
||||
"""淡入为负钳制到 0."""
|
||||
result = normalize_bgm_config({"fade_in": -1})
|
||||
assert result["fade_in"] == 0.0
|
||||
|
||||
def test_fade_out_negative(self):
|
||||
"""淡出为负钳制到 0."""
|
||||
result = normalize_bgm_config({"fade_out": -1})
|
||||
assert result["fade_out"] == 0.0
|
||||
|
||||
def test_sidechain_ratio_clamped(self):
|
||||
"""sidechain_ratio 钳制."""
|
||||
result = normalize_bgm_config({"sidechain_ratio": 1.5})
|
||||
assert result["sidechain_ratio"] == 1.0
|
||||
result2 = normalize_bgm_config({"sidechain_ratio": -0.1})
|
||||
assert result2["sidechain_ratio"] == 0.0
|
||||
|
||||
def test_sidechain_attack_min(self):
|
||||
"""attack 最小值 0.001."""
|
||||
result = normalize_bgm_config({"sidechain_attack": 0})
|
||||
assert result["sidechain_attack"] == 0.001
|
||||
|
||||
def test_sidechain_release_min(self):
|
||||
"""release 最小值 0.01."""
|
||||
result = normalize_bgm_config({"sidechain_release": 0})
|
||||
assert result["sidechain_release"] == 0.01
|
||||
|
||||
def test_string_values_converted(self):
|
||||
"""字符串数值被转换."""
|
||||
result = normalize_bgm_config(
|
||||
{
|
||||
"volume": "0.5",
|
||||
"fade_in": "2.0",
|
||||
}
|
||||
)
|
||||
assert result["volume"] == 0.5
|
||||
assert result["fade_in"] == 2.0
|
||||
|
||||
def test_loop_enabled_truthy(self):
|
||||
"""loop_enabled 真值转换."""
|
||||
result = normalize_bgm_config({"loop_enabled": 1})
|
||||
assert result["loop_enabled"] is True
|
||||
result2 = normalize_bgm_config({"loop_enabled": 0})
|
||||
assert result2["loop_enabled"] is False
|
||||
|
||||
def test_preserves_unknown_keys(self):
|
||||
"""未知 key 不保留."""
|
||||
result = normalize_bgm_config({"unknown_key": "value", "volume": 0.5})
|
||||
assert "unknown_key" not in result
|
||||
assert result["volume"] == 0.5
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# validate_bgm_config 测试
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestValidateBGMConfig:
|
||||
"""配置验证测试."""
|
||||
|
||||
def test_valid_config(self):
|
||||
"""合法配置."""
|
||||
ok, errors = validate_bgm_config(
|
||||
{
|
||||
"volume": 0.5,
|
||||
"fade_in": 1.0,
|
||||
"fade_out": 2.0,
|
||||
"sidechain_ratio": 0.3,
|
||||
}
|
||||
)
|
||||
assert ok is True
|
||||
assert len(errors) == 0
|
||||
|
||||
def test_volume_not_number(self):
|
||||
"""volume 不是数字."""
|
||||
ok, errors = validate_bgm_config({"volume": "high"})
|
||||
assert ok is False
|
||||
assert any("volume" in e for e in errors)
|
||||
|
||||
def test_volume_out_of_range(self):
|
||||
"""volume 超出范围."""
|
||||
ok, errors = validate_bgm_config({"volume": 1.5})
|
||||
assert ok is False
|
||||
assert any("volume" in e for e in errors)
|
||||
|
||||
def test_fade_in_negative(self):
|
||||
"""fade_in 为负."""
|
||||
ok, errors = validate_bgm_config({"fade_in": -1})
|
||||
assert ok is False
|
||||
assert any("fade_in" in e for e in errors)
|
||||
|
||||
def test_fade_out_negative(self):
|
||||
"""fade_out 为负."""
|
||||
ok, errors = validate_bgm_config({"fade_out": -1})
|
||||
assert ok is False
|
||||
assert any("fade_out" in e for e in errors)
|
||||
|
||||
def test_sidechain_ratio_out_of_range(self):
|
||||
"""sidechain_ratio 超出范围."""
|
||||
ok, errors = validate_bgm_config({"sidechain_ratio": 2.0})
|
||||
assert ok is False
|
||||
assert any("sidechain_ratio" in e for e in errors)
|
||||
|
||||
def test_multiple_errors(self):
|
||||
"""多个错误同时报告."""
|
||||
ok, errors = validate_bgm_config(
|
||||
{
|
||||
"volume": 2.0,
|
||||
"fade_in": -1,
|
||||
"sidechain_ratio": -0.5,
|
||||
}
|
||||
)
|
||||
assert ok is False
|
||||
assert len(errors) >= 3
|
||||
|
||||
def test_empty_config_valid(self):
|
||||
"""空配置(全用默认值)视为合法."""
|
||||
ok, errors = validate_bgm_config({})
|
||||
assert ok is True
|
||||
assert len(errors) == 0
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# calculate_fade_out_start 测试
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestCalculateFadeOutStart:
|
||||
"""淡出开始时间计算测试."""
|
||||
|
||||
def test_normal_case(self):
|
||||
"""正常情况."""
|
||||
assert calculate_fade_out_start(100, 3) == pytest.approx(97.0)
|
||||
|
||||
def test_zero_fade_out(self):
|
||||
"""淡出时长为 0,返回 None."""
|
||||
assert calculate_fade_out_start(100, 0) is None
|
||||
|
||||
def test_negative_fade_out(self):
|
||||
"""淡出时长为负,返回 None."""
|
||||
assert calculate_fade_out_start(100, -1) is None
|
||||
|
||||
def test_zero_duration(self):
|
||||
"""总时长为 0,返回 None."""
|
||||
assert calculate_fade_out_start(0, 3) is None
|
||||
|
||||
def test_fade_out_longer_than_duration(self):
|
||||
"""淡出超过总时长,返回 None."""
|
||||
assert calculate_fade_out_start(10, 20) is None
|
||||
|
||||
def test_fade_out_equal_to_duration(self):
|
||||
"""淡出等于总时长,返回 None."""
|
||||
assert calculate_fade_out_start(10, 10) is None
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# estimate_bgm_processing_duration 测试
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestEstimateBGMProcessingDuration:
|
||||
"""BGM 处理时长估算测试."""
|
||||
|
||||
def test_normal_case_with_loop(self):
|
||||
"""正常循环情况,输出目标时长."""
|
||||
assert estimate_bgm_processing_duration(10, 100, True) == 100
|
||||
|
||||
def test_bgm_longer_no_loop(self):
|
||||
"""BGM 够长,不循环,截断到目标时长."""
|
||||
assert estimate_bgm_processing_duration(200, 100, False) == 100
|
||||
|
||||
def test_bgm_shorter_no_loop(self):
|
||||
"""BGM 短但不循环,仍然截断到目标时长(实际会更短,但 atrim 会截断)."""
|
||||
assert estimate_bgm_processing_duration(10, 100, False) == 100
|
||||
|
||||
def test_zero_target(self):
|
||||
"""目标时长为 0,兜底 5 秒."""
|
||||
assert estimate_bgm_processing_duration(10, 0, True) == 5.0
|
||||
|
||||
def test_negative_target(self):
|
||||
"""目标时长为负,兜底 5 秒."""
|
||||
assert estimate_bgm_processing_duration(10, -5, True) == 5.0
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# BGMPureConfig 测试
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestBGMPureConfig:
|
||||
"""BGMPureConfig 数据类测试."""
|
||||
|
||||
def test_default_values(self):
|
||||
"""默认值正确."""
|
||||
config = BGMPureConfig()
|
||||
assert config.volume == 0.3
|
||||
assert config.fade_in == 0.0
|
||||
assert config.fade_out == 0.0
|
||||
assert config.loop_enabled is True
|
||||
assert config.sidechain_enabled is False
|
||||
assert config.sidechain_ratio == 0.3
|
||||
assert config.sidechain_attack == 0.02
|
||||
assert config.sidechain_release == 0.5
|
||||
assert config.sidechain_threshold == -25.0
|
||||
|
||||
def test_custom_values(self):
|
||||
"""自定义值."""
|
||||
config = BGMPureConfig(
|
||||
volume=0.7,
|
||||
fade_in=1.0,
|
||||
fade_out=2.0,
|
||||
loop_enabled=False,
|
||||
sidechain_enabled=True,
|
||||
sidechain_ratio=0.5,
|
||||
sidechain_attack=0.05,
|
||||
sidechain_release=0.8,
|
||||
sidechain_threshold=-30.0,
|
||||
)
|
||||
assert config.volume == 0.7
|
||||
assert config.loop_enabled is False
|
||||
assert config.sidechain_enabled is True
|
||||
assert config.sidechain_threshold == -30.0
|
||||
@@ -1,534 +0,0 @@
|
||||
"""视频拼接引擎纯逻辑单元测试."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
from video_processing.concat_engine_pure import (
|
||||
build_concat_filter,
|
||||
build_fps_filter,
|
||||
build_scale_pad_filter,
|
||||
build_single_segment_filter_chain,
|
||||
calculate_scaled_size,
|
||||
can_use_stream_copy,
|
||||
count_valid_segments,
|
||||
estimate_total_duration,
|
||||
format_fps_filter,
|
||||
generate_concat_file_list,
|
||||
parse_fps,
|
||||
resolve_output_params,
|
||||
validate_concat_config,
|
||||
validate_video_path,
|
||||
)
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# 帧率解析测试
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestParseFps:
|
||||
"""parse_fps 测试."""
|
||||
|
||||
def test_integer_fps(self):
|
||||
"""整数帧率."""
|
||||
assert parse_fps(30) == 30.0
|
||||
|
||||
def test_float_fps(self):
|
||||
"""浮点帧率."""
|
||||
assert parse_fps(29.97) == pytest.approx(29.97)
|
||||
|
||||
def test_string_integer(self):
|
||||
"""字符串整数."""
|
||||
assert parse_fps("30") == 30.0
|
||||
|
||||
def test_string_fraction(self):
|
||||
"""分数字符串(30/1)."""
|
||||
assert parse_fps("30/1") == 30.0
|
||||
|
||||
def test_fraction_24000_1001(self):
|
||||
"""23.976 帧率."""
|
||||
result = parse_fps("24000/1001")
|
||||
assert result == pytest.approx(23.976, rel=0.01)
|
||||
|
||||
def test_none_input(self):
|
||||
"""None 输入返回默认值."""
|
||||
assert parse_fps(None) == 30.0
|
||||
|
||||
def test_empty_string(self):
|
||||
"""空字符串返回默认值."""
|
||||
assert parse_fps("") == 30.0
|
||||
|
||||
def test_invalid_string(self):
|
||||
"""无效字符串."""
|
||||
assert parse_fps("abc") == 30.0
|
||||
|
||||
def test_zero_denominator(self):
|
||||
"""分母为 0."""
|
||||
assert parse_fps("30/0") == 30.0
|
||||
|
||||
def test_negative_fps(self):
|
||||
"""负帧率."""
|
||||
assert parse_fps(-30) == -30.0
|
||||
|
||||
|
||||
class TestFormatFpsFilter:
|
||||
"""format_fps_filter 测试."""
|
||||
|
||||
def test_integer_fps(self):
|
||||
"""整数帧率."""
|
||||
assert format_fps_filter(30.0) == "fps=30"
|
||||
|
||||
def test_float_fps(self):
|
||||
"""浮点帧率."""
|
||||
result = format_fps_filter(29.97)
|
||||
assert result.startswith("fps=")
|
||||
assert "29.97" in result
|
||||
|
||||
def test_near_integer(self):
|
||||
"""接近整数."""
|
||||
assert format_fps_filter(30.0001) == "fps=30"
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# 输出参数计算测试
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestResolveOutputParams:
|
||||
"""resolve_output_params 测试."""
|
||||
|
||||
def test_all_specified(self):
|
||||
"""全部显式指定."""
|
||||
w, h, fps = resolve_output_params(1920, 1080, 60.0)
|
||||
assert w == 1920
|
||||
assert h == 1080
|
||||
assert fps == 60.0
|
||||
|
||||
def test_no_specified_use_defaults(self):
|
||||
"""全部未指定,用默认值."""
|
||||
w, h, fps = resolve_output_params(0, 0, 0)
|
||||
assert w == 1080
|
||||
assert h == 1920
|
||||
assert fps == 30.0
|
||||
|
||||
def test_use_first_video_info(self):
|
||||
"""用第一段视频信息."""
|
||||
info = {"width": 1280, "height": 720, "r_frame_rate": "24/1"}
|
||||
w, h, fps = resolve_output_params(0, 0, 0, info)
|
||||
assert w == 1280
|
||||
assert h == 720
|
||||
assert fps == 24.0
|
||||
|
||||
def test_partial_specified(self):
|
||||
"""部分指定,未指定的用探测值."""
|
||||
info = {"width": 1280, "height": 720, "r_frame_rate": "24/1"}
|
||||
w, h, fps = resolve_output_params(1920, 0, 0, info)
|
||||
assert w == 1920 # 指定的
|
||||
assert h == 720 # 探测的
|
||||
assert fps == 24.0
|
||||
|
||||
def test_zero_size_clamped(self):
|
||||
"""零尺寸被钳制."""
|
||||
w, h, fps = resolve_output_params(0, 0, 0, {})
|
||||
assert w >= 1
|
||||
assert h >= 1
|
||||
assert fps >= 1.0
|
||||
|
||||
def test_custom_defaults(self):
|
||||
"""自定义默认值."""
|
||||
w, h, fps = resolve_output_params(0, 0, 0, None, 640, 480, 25.0)
|
||||
assert w == 640
|
||||
assert h == 480
|
||||
assert fps == 25.0
|
||||
|
||||
|
||||
class TestCalculateScaledSize:
|
||||
"""calculate_scaled_size 测试."""
|
||||
|
||||
def test_same_ratio(self):
|
||||
"""比例相同."""
|
||||
sw, sh, ox, oy = calculate_scaled_size(1920, 1080, 1920, 1080)
|
||||
assert sw == 1920
|
||||
assert sh == 1080
|
||||
assert ox == 0
|
||||
assert oy == 0
|
||||
|
||||
def test_wider_source(self):
|
||||
"""源更宽,上下填黑边."""
|
||||
sw, sh, ox, oy = calculate_scaled_size(1920, 1080, 1080, 1920)
|
||||
assert sw == 1080 # 以宽度为准
|
||||
assert sh < 1920 # 高度按比例
|
||||
assert ox == 0
|
||||
assert oy > 0 # 垂直居中
|
||||
|
||||
def test_taller_source(self):
|
||||
"""源更高,左右填黑边."""
|
||||
sw, sh, ox, oy = calculate_scaled_size(1080, 1920, 1920, 1080)
|
||||
assert sh == 1080 # 以高度为准
|
||||
assert sw < 1920 # 宽度按比例
|
||||
assert ox > 0 # 水平居中
|
||||
assert oy == 0
|
||||
|
||||
def test_zero_source(self):
|
||||
"""零尺寸源."""
|
||||
sw, sh, ox, oy = calculate_scaled_size(0, 0, 100, 100)
|
||||
assert sw == 100
|
||||
assert sh == 100
|
||||
|
||||
def test_scale_down(self):
|
||||
"""缩小."""
|
||||
sw, sh, ox, oy = calculate_scaled_size(1920, 1080, 640, 360)
|
||||
assert sw == 640
|
||||
assert sh == 360
|
||||
assert ox == 0
|
||||
assert oy == 0
|
||||
|
||||
def test_scale_up(self):
|
||||
"""放大."""
|
||||
sw, sh, ox, oy = calculate_scaled_size(640, 360, 1920, 1080)
|
||||
assert sw == 1920
|
||||
assert sh == 1080
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# stream copy 判断测试
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestCanUseStreamCopy:
|
||||
"""can_use_stream_copy 测试."""
|
||||
|
||||
def test_identical_segments(self):
|
||||
"""所有段参数相同,可以 stream copy."""
|
||||
segs = [
|
||||
{"codec_name": "h264", "width": 1920, "height": 1080, "r_frame_rate": "30/1"},
|
||||
{"codec_name": "h264", "width": 1920, "height": 1080, "r_frame_rate": "30/1"},
|
||||
]
|
||||
assert can_use_stream_copy(segs, 1920, 1080, 30.0) is True
|
||||
|
||||
def test_force_reencode(self):
|
||||
"""强制重编码."""
|
||||
segs = [
|
||||
{"codec_name": "h264", "width": 1920, "height": 1080, "r_frame_rate": "30/1"},
|
||||
]
|
||||
assert can_use_stream_copy(segs, 1920, 1080, 30.0, force_reencode=True) is False
|
||||
|
||||
def test_different_codec(self):
|
||||
"""编码不同."""
|
||||
segs = [
|
||||
{"codec_name": "h264", "width": 1920, "height": 1080, "r_frame_rate": "30/1"},
|
||||
{"codec_name": "hevc", "width": 1920, "height": 1080, "r_frame_rate": "30/1"},
|
||||
]
|
||||
assert can_use_stream_copy(segs, 1920, 1080, 30.0) is False
|
||||
|
||||
def test_different_resolution(self):
|
||||
"""分辨率不同."""
|
||||
segs = [
|
||||
{"codec_name": "h264", "width": 1920, "height": 1080, "r_frame_rate": "30/1"},
|
||||
{"codec_name": "h264", "width": 1280, "height": 720, "r_frame_rate": "30/1"},
|
||||
]
|
||||
assert can_use_stream_copy(segs, 1920, 1080, 30.0) is False
|
||||
|
||||
def test_different_fps(self):
|
||||
"""帧率不同."""
|
||||
segs = [
|
||||
{"codec_name": "h264", "width": 1920, "height": 1080, "r_frame_rate": "30/1"},
|
||||
{"codec_name": "h264", "width": 1920, "height": 1080, "r_frame_rate": "60/1"},
|
||||
]
|
||||
assert can_use_stream_copy(segs, 1920, 1080, 30.0) is False
|
||||
|
||||
def test_target_differs(self):
|
||||
"""目标参数与源不同."""
|
||||
segs = [
|
||||
{"codec_name": "h264", "width": 1920, "height": 1080, "r_frame_rate": "30/1"},
|
||||
]
|
||||
assert can_use_stream_copy(segs, 1280, 720, 30.0) is False
|
||||
|
||||
def test_empty_segments(self):
|
||||
"""空列表."""
|
||||
assert can_use_stream_copy([], 1920, 1080, 30.0) is False
|
||||
|
||||
def test_single_segment(self):
|
||||
"""单段."""
|
||||
segs = [{"codec_name": "h264", "width": 1920, "height": 1080, "r_frame_rate": "30/1"}]
|
||||
assert can_use_stream_copy(segs, 1920, 1080, 30.0) is True
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# 文件列表生成测试
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestGenerateConcatFileList:
|
||||
"""generate_concat_file_list 测试."""
|
||||
|
||||
def test_single_file(self):
|
||||
"""单个文件."""
|
||||
result = generate_concat_file_list(["/a.mp4"])
|
||||
assert "file '/a.mp4'" in result
|
||||
assert result.endswith("\n")
|
||||
|
||||
def test_multiple_files(self):
|
||||
"""多个文件."""
|
||||
result = generate_concat_file_list(["/a.mp4", "/b.mp4", "/c.mp4"])
|
||||
lines = result.strip().split("\n")
|
||||
assert len(lines) == 3
|
||||
assert lines[0] == "file '/a.mp4'"
|
||||
assert lines[1] == "file '/b.mp4'"
|
||||
assert lines[2] == "file '/c.mp4'"
|
||||
|
||||
def test_empty_list(self):
|
||||
"""空列表."""
|
||||
result = generate_concat_file_list([])
|
||||
assert result == "\n"
|
||||
|
||||
def test_path_with_single_quote(self):
|
||||
"""路径包含单引号(转义)."""
|
||||
result = generate_concat_file_list(["/path/to/file's.mp4"])
|
||||
# 单引号应该被转义
|
||||
assert "'\\''" in result or file
|
||||
assert "file '" in result
|
||||
|
||||
def test_path_with_spaces(self):
|
||||
"""路径包含空格."""
|
||||
result = generate_concat_file_list(["/path/to/my video.mp4"])
|
||||
assert "my video" in result
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# 滤镜构建测试
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestBuildScalePadFilter:
|
||||
"""scale+pad 滤镜测试."""
|
||||
|
||||
def test_contains_scale(self):
|
||||
"""包含 scale."""
|
||||
result = build_scale_pad_filter(1920, 1080)
|
||||
assert "scale=" in result
|
||||
|
||||
def test_contains_pad(self):
|
||||
"""包含 pad."""
|
||||
result = build_scale_pad_filter(1920, 1080)
|
||||
assert "pad=" in result
|
||||
assert "1920:1080" in result
|
||||
|
||||
def test_force_original_aspect_ratio(self):
|
||||
"""保持宽高比."""
|
||||
result = build_scale_pad_filter(1920, 1080)
|
||||
assert "force_original_aspect_ratio=decrease" in result
|
||||
|
||||
def test_black_padding(self):
|
||||
"""黑边填充."""
|
||||
result = build_scale_pad_filter(1920, 1080)
|
||||
assert ":black" in result
|
||||
|
||||
|
||||
class TestBuildFpsFilter:
|
||||
"""fps 滤镜测试."""
|
||||
|
||||
def test_integer_fps(self):
|
||||
"""整数帧率."""
|
||||
assert build_fps_filter(30.0) == "fps=30"
|
||||
|
||||
def test_float_fps(self):
|
||||
"""浮点帧率."""
|
||||
result = build_fps_filter(29.97)
|
||||
assert result.startswith("fps=")
|
||||
|
||||
|
||||
class TestBuildConcatFilter:
|
||||
"""concat 滤镜测试."""
|
||||
|
||||
def test_two_inputs_with_audio(self):
|
||||
"""两路输入,有音频."""
|
||||
result = build_concat_filter(2, has_audio=True)
|
||||
assert "[0:v][0:a][1:v][1:a]concat=n=2:v=1:a=1" in result
|
||||
assert "[concat_v][concat_a]" in result
|
||||
|
||||
def test_three_inputs_video_only(self):
|
||||
"""三路输入,无音频."""
|
||||
result = build_concat_filter(3, has_audio=False)
|
||||
assert "[0:v][1:v][2:v]concat=n=3:v=1:a=0" in result
|
||||
assert "[concat_v]" in result
|
||||
|
||||
def test_single_input(self):
|
||||
"""单路输入."""
|
||||
result = build_concat_filter(1, has_audio=True)
|
||||
assert "[0:v][0:a]concat=n=1:v=1:a=1" in result
|
||||
|
||||
def test_zero_inputs(self):
|
||||
"""零输入."""
|
||||
assert build_concat_filter(0) == ""
|
||||
|
||||
|
||||
class TestBuildSingleSegmentFilterChain:
|
||||
"""单段滤镜链测试."""
|
||||
|
||||
def test_with_audio(self):
|
||||
"""有音频."""
|
||||
result = build_single_segment_filter_chain(1920, 1080, 30.0, 0)
|
||||
assert "scale=" in result
|
||||
assert "fps=" in result
|
||||
assert "setpts=PTS-STARTPTS" in result
|
||||
assert "asetpts=PTS-STARTPTS" in result
|
||||
assert "[v0]" in result
|
||||
assert "[a0]" in result
|
||||
|
||||
def test_video_only(self):
|
||||
"""无音频."""
|
||||
result = build_single_segment_filter_chain(1920, 1080, 30.0, 1, has_audio=False)
|
||||
assert "scale=" in result
|
||||
assert "setpts=" in result
|
||||
assert "asetpts" not in result
|
||||
assert "[v1]" in result
|
||||
|
||||
def test_segment_index_in_labels(self):
|
||||
"""段索引在标签中."""
|
||||
result = build_single_segment_filter_chain(1920, 1080, 30.0, 5)
|
||||
assert "[5:v]" in result
|
||||
assert "[v5]" in result
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# 配置验证测试
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestValidateConcatConfig:
|
||||
"""配置验证测试."""
|
||||
|
||||
def test_valid_config(self):
|
||||
"""合法配置."""
|
||||
config = {
|
||||
"segments": [{"video_path": "/a.mp4"}, {"video_path": "/b.mp4"}],
|
||||
"output_width": 1920,
|
||||
"output_height": 1080,
|
||||
"output_fps": 30,
|
||||
}
|
||||
ok, errors = validate_concat_config(config)
|
||||
assert ok is True
|
||||
assert len(errors) == 0
|
||||
|
||||
def test_empty_segments(self):
|
||||
"""空段列表."""
|
||||
ok, errors = validate_concat_config({"segments": []})
|
||||
assert ok is False
|
||||
assert any("至少需要" in e or "视频段" in e for e in errors)
|
||||
|
||||
def test_missing_video_path(self):
|
||||
"""缺少 video_path."""
|
||||
config = {"segments": [{"video_path": "/a.mp4"}, {}]}
|
||||
ok, errors = validate_concat_config(config)
|
||||
assert ok is False
|
||||
assert any("video_path" in e for e in errors)
|
||||
|
||||
def test_negative_width(self):
|
||||
"""负宽度."""
|
||||
config = {"segments": [{"video_path": "/a.mp4"}], "output_width": -100}
|
||||
ok, errors = validate_concat_config(config)
|
||||
assert ok is False
|
||||
assert any("output_width" in e for e in errors)
|
||||
|
||||
def test_negative_height(self):
|
||||
"""负高度."""
|
||||
config = {"segments": [{"video_path": "/a.mp4"}], "output_height": -100}
|
||||
ok, errors = validate_concat_config(config)
|
||||
assert ok is False
|
||||
assert any("output_height" in e for e in errors)
|
||||
|
||||
def test_negative_fps(self):
|
||||
"""负帧率."""
|
||||
config = {"segments": [{"video_path": "/a.mp4"}], "output_fps": -30}
|
||||
ok, errors = validate_concat_config(config)
|
||||
assert ok is False
|
||||
assert any("output_fps" in e for e in errors)
|
||||
|
||||
def test_zero_output_params_ok(self):
|
||||
"""零输出参数合法(表示自动探测)."""
|
||||
config = {"segments": [{"video_path": "/a.mp4"}]}
|
||||
ok, errors = validate_concat_config(config)
|
||||
assert ok is True
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# 路径验证测试
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestValidateVideoPath:
|
||||
"""视频路径验证测试."""
|
||||
|
||||
def test_empty_path(self):
|
||||
"""空路径."""
|
||||
ok, msg = validate_video_path("", "/work")
|
||||
assert ok is False
|
||||
assert "不能为空" in msg
|
||||
|
||||
def test_path_traversal(self):
|
||||
"""路径遍历."""
|
||||
ok, msg = validate_video_path("../etc/passwd", "/work")
|
||||
assert ok is False
|
||||
assert "回溯" in msg or ".." in msg
|
||||
|
||||
def test_valid_relative_path(self):
|
||||
"""相对路径(不检查边界)."""
|
||||
ok, msg = validate_video_path("video.mp4", "/work")
|
||||
assert ok is True
|
||||
|
||||
def test_valid_absolute_path(self):
|
||||
"""绝对路径在工作目录内."""
|
||||
ok, msg = validate_video_path("/work/sub/video.mp4", "/work")
|
||||
assert ok is True
|
||||
|
||||
def test_path_outside_work_dir(self):
|
||||
"""路径在工作目录外."""
|
||||
ok, msg = validate_video_path("/etc/passwd", "/work")
|
||||
assert ok is False
|
||||
assert "工作目录" in msg
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# 工具函数测试
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestEstimateTotalDuration:
|
||||
"""总时长估算测试."""
|
||||
|
||||
def test_multiple_segments(self):
|
||||
"""多段视频."""
|
||||
segs = [{"duration": 10}, {"duration": 20.5}, {"duration": 5}]
|
||||
assert estimate_total_duration(segs) == pytest.approx(35.5)
|
||||
|
||||
def test_empty_list(self):
|
||||
"""空列表."""
|
||||
assert estimate_total_duration([]) == 0.0
|
||||
|
||||
def test_invalid_duration_skipped(self):
|
||||
"""无效时长跳过."""
|
||||
segs = [{"duration": 10}, {"duration": "abc"}, {"duration": 20}]
|
||||
assert estimate_total_duration(segs) == pytest.approx(30.0)
|
||||
|
||||
def test_missing_duration(self):
|
||||
"""缺 duration 字段."""
|
||||
segs = [{}, {"duration": 10}]
|
||||
assert estimate_total_duration(segs) == pytest.approx(10.0)
|
||||
|
||||
|
||||
class TestCountValidSegments:
|
||||
"""有效段统计测试."""
|
||||
|
||||
def test_all_valid(self):
|
||||
"""全部有效."""
|
||||
segs = [{"video_path": "/a.mp4"}, {"video_path": "/b.mp4"}]
|
||||
assert count_valid_segments(segs) == 2
|
||||
|
||||
def test_some_invalid(self):
|
||||
"""部分无效."""
|
||||
segs = [{"video_path": "/a.mp4"}, {}, {"video_path": ""}]
|
||||
assert count_valid_segments(segs) == 1
|
||||
|
||||
def test_empty_list(self):
|
||||
"""空列表."""
|
||||
assert count_valid_segments([]) == 0
|
||||
@@ -1,638 +0,0 @@
|
||||
"""多轨混音纯逻辑单元测试."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
|
||||
import pytest
|
||||
from video_processing.multi_track_mixer_pure import (
|
||||
build_amix_filter,
|
||||
build_mix_filter_complex,
|
||||
build_track_filter_chain,
|
||||
calculate_amix_volume_compensation,
|
||||
calculate_effective_range,
|
||||
calculate_total_tracks,
|
||||
count_track_types,
|
||||
db_to_linear,
|
||||
estimate_mix_duration,
|
||||
filter_enabled_tracks,
|
||||
is_track_visible,
|
||||
linear_to_db,
|
||||
normalize_volume,
|
||||
sort_tracks_by_priority,
|
||||
validate_audio_track,
|
||||
validate_mix_config,
|
||||
)
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# 时间计算测试
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestCalculateEffectiveRange:
|
||||
"""有效时间范围计算测试."""
|
||||
|
||||
def test_normal_track(self):
|
||||
"""正常轨道."""
|
||||
start, dur, trim = calculate_effective_range(5, 10, 30, 60)
|
||||
assert start == 5.0
|
||||
assert dur == 10.0
|
||||
assert trim == 0.0
|
||||
|
||||
def test_track_longer_than_audio(self):
|
||||
"""轨道时长超过音频长度."""
|
||||
start, dur, trim = calculate_effective_range(0, 100, 30, 60)
|
||||
assert start == 0.0
|
||||
assert dur == 30.0 # 用音频全长
|
||||
|
||||
def test_zero_track_duration(self):
|
||||
"""轨道时长为 0(用音频全长)."""
|
||||
start, dur, trim = calculate_effective_range(0, 0, 30, 60)
|
||||
assert start == 0.0
|
||||
assert dur == 30.0
|
||||
|
||||
def test_negative_start_time(self):
|
||||
"""负开始时间(从音频中间取)."""
|
||||
start, dur, trim = calculate_effective_range(-5, 20, 30, 60)
|
||||
assert start == 0.0
|
||||
assert dur == 15.0 # 20 - 5 = 15
|
||||
assert trim == 5.0
|
||||
|
||||
def test_track_after_target(self):
|
||||
"""轨道完全在目标之后."""
|
||||
start, dur, trim = calculate_effective_range(100, 10, 30, 60)
|
||||
assert dur == 0.0
|
||||
|
||||
def test_track_before_zero(self):
|
||||
"""轨道完全在 0 之前."""
|
||||
start, dur, trim = calculate_effective_range(-50, 10, 30, 60)
|
||||
assert dur == 0.0
|
||||
|
||||
def test_zero_audio_duration(self):
|
||||
"""音频时长为 0."""
|
||||
start, dur, trim = calculate_effective_range(0, 10, 0, 60)
|
||||
assert dur == 0.0
|
||||
|
||||
def test_track_extends_beyond_target(self):
|
||||
"""轨道超出目标时长."""
|
||||
start, dur, trim = calculate_effective_range(50, 20, 30, 60)
|
||||
assert start == 50.0
|
||||
assert dur == 10.0 # 60 - 50 = 10
|
||||
|
||||
def test_full_target_duration(self):
|
||||
"""轨道覆盖整个目标时长."""
|
||||
start, dur, trim = calculate_effective_range(0, 0, 100, 60)
|
||||
assert start == 0.0
|
||||
assert dur == 60.0
|
||||
|
||||
|
||||
class TestIsTrackVisible:
|
||||
"""轨道可见性测试."""
|
||||
|
||||
def test_visible_track(self):
|
||||
"""可见轨道."""
|
||||
assert is_track_visible(5, 10, 30, 60) is True
|
||||
|
||||
def test_invisible_after_target(self):
|
||||
"""目标之后不可见."""
|
||||
assert is_track_visible(100, 10, 30, 60) is False
|
||||
|
||||
def test_invisible_zero_duration(self):
|
||||
"""零时长不可见."""
|
||||
assert is_track_visible(0, 0, 0, 60) is False
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# 滤镜链构建测试
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestBuildTrackFilterChain:
|
||||
"""单轨滤镜链构建测试."""
|
||||
|
||||
def test_basic_structure(self):
|
||||
"""基本结构:截断+音量+淡入淡出+延迟+截断."""
|
||||
result = build_track_filter_chain(
|
||||
volume=0.5,
|
||||
fade_in=1.0,
|
||||
fade_out=1.0,
|
||||
effective_start=5.0,
|
||||
need_duration=10.0,
|
||||
trim_start=0.0,
|
||||
target_duration=60.0,
|
||||
)
|
||||
assert "atrim=0.000:10.000" in result
|
||||
assert "volume=0.500" in result
|
||||
assert "afade=t=in:st=0:d=1.000" in result
|
||||
assert "afade=t=out" in result
|
||||
assert "adelay=5000|5000" in result
|
||||
assert "atrim=0:60.000" in result
|
||||
|
||||
def test_volume_1_0_skipped(self):
|
||||
"""音量为 1.0 不添加 volume 滤镜."""
|
||||
result = build_track_filter_chain(
|
||||
volume=1.0,
|
||||
fade_in=0,
|
||||
fade_out=0,
|
||||
effective_start=0,
|
||||
need_duration=10,
|
||||
trim_start=0,
|
||||
target_duration=60,
|
||||
)
|
||||
assert "volume=" not in result
|
||||
|
||||
def test_no_fade_in(self):
|
||||
"""无淡入."""
|
||||
result = build_track_filter_chain(
|
||||
volume=1.0,
|
||||
fade_in=0,
|
||||
fade_out=2.0,
|
||||
effective_start=0,
|
||||
need_duration=10,
|
||||
trim_start=0,
|
||||
target_duration=60,
|
||||
)
|
||||
assert "afade=t=in" not in result
|
||||
assert "afade=t=out" in result
|
||||
|
||||
def test_no_delay(self):
|
||||
"""无延迟(effective_start 很小)."""
|
||||
result = build_track_filter_chain(
|
||||
volume=1.0,
|
||||
fade_in=0,
|
||||
fade_out=0,
|
||||
effective_start=0.001,
|
||||
need_duration=10,
|
||||
trim_start=0,
|
||||
target_duration=60,
|
||||
)
|
||||
assert "adelay" not in result
|
||||
|
||||
def test_with_delay(self):
|
||||
"""有延迟."""
|
||||
result = build_track_filter_chain(
|
||||
volume=1.0,
|
||||
fade_in=0,
|
||||
fade_out=0,
|
||||
effective_start=2.5,
|
||||
need_duration=10,
|
||||
trim_start=0,
|
||||
target_duration=60,
|
||||
)
|
||||
assert "adelay=2500|2500" in result
|
||||
|
||||
def test_fade_in_longer_than_duration(self):
|
||||
"""淡入超过总时长,不添加淡入."""
|
||||
result = build_track_filter_chain(
|
||||
volume=1.0,
|
||||
fade_in=20,
|
||||
fade_out=0,
|
||||
effective_start=0,
|
||||
need_duration=10,
|
||||
trim_start=0,
|
||||
target_duration=60,
|
||||
)
|
||||
assert "afade=t=in" not in result
|
||||
|
||||
def test_fade_out_at_start(self):
|
||||
"""淡出从 0 开始(很短的音频)."""
|
||||
result = build_track_filter_chain(
|
||||
volume=1.0,
|
||||
fade_in=0,
|
||||
fade_out=15,
|
||||
effective_start=0,
|
||||
need_duration=10,
|
||||
trim_start=0,
|
||||
target_duration=60,
|
||||
)
|
||||
# fade_out > need_duration,不添加
|
||||
assert "afade=t=out" not in result
|
||||
|
||||
def test_trim_start_nonzero(self):
|
||||
"""从音频中间开始截取."""
|
||||
result = build_track_filter_chain(
|
||||
volume=1.0,
|
||||
fade_in=0,
|
||||
fade_out=0,
|
||||
effective_start=0,
|
||||
need_duration=5,
|
||||
trim_start=3.0,
|
||||
target_duration=60,
|
||||
)
|
||||
assert "atrim=3.000:8.000" in result # 3.0 to 3.0+5.0
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# amix 滤镜测试
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestBuildAmixFilter:
|
||||
"""amix 滤镜构建测试."""
|
||||
|
||||
def test_two_inputs(self):
|
||||
"""两路输入."""
|
||||
result = build_amix_filter(2)
|
||||
assert "amix=inputs=2" in result
|
||||
assert "duration=longest" in result
|
||||
|
||||
def test_five_inputs(self):
|
||||
"""五路输入."""
|
||||
result = build_amix_filter(5)
|
||||
assert "amix=inputs=5" in result
|
||||
|
||||
def test_zero_inputs(self):
|
||||
"""零输入."""
|
||||
assert build_amix_filter(0) == ""
|
||||
|
||||
def test_duration_shortest(self):
|
||||
"""shortest 模式."""
|
||||
result = build_amix_filter(3, "shortest")
|
||||
assert "duration=shortest" in result
|
||||
|
||||
def test_invalid_duration_mode(self):
|
||||
"""无效模式,默认 longest."""
|
||||
result = build_amix_filter(3, "invalid")
|
||||
assert "duration=longest" in result
|
||||
|
||||
|
||||
class TestCalculateAmixVolumeCompensation:
|
||||
"""音量补偿计算测试."""
|
||||
|
||||
def test_single_track(self):
|
||||
"""单轨,无需补偿."""
|
||||
assert calculate_amix_volume_compensation(1) == 1.0
|
||||
|
||||
def test_two_tracks(self):
|
||||
"""两轨,补偿 2x."""
|
||||
assert calculate_amix_volume_compensation(2) == 2.0
|
||||
|
||||
def test_five_tracks(self):
|
||||
"""五轨,补偿 5x."""
|
||||
assert calculate_amix_volume_compensation(5) == 5.0
|
||||
|
||||
def test_zero_tracks(self):
|
||||
"""零轨,返回 1."""
|
||||
assert calculate_amix_volume_compensation(0) == 1.0
|
||||
|
||||
|
||||
class TestBuildMixFilterComplex:
|
||||
"""完整混音滤镜测试."""
|
||||
|
||||
def test_with_main_and_two_tracks(self):
|
||||
"""主音频 + 2 条轨道."""
|
||||
result = build_mix_filter_complex(2, has_main=True)
|
||||
assert "[0:a][1:a][2:a]" in result # 3 路输入
|
||||
assert "amix=inputs=3" in result
|
||||
assert "volume=3" in result # 3x 补偿
|
||||
assert "[mixed]" in result
|
||||
|
||||
def test_no_main_three_tracks(self):
|
||||
"""无主音频,3 条轨道."""
|
||||
result = build_mix_filter_complex(3, has_main=False)
|
||||
assert "[0:a][1:a][2:a]" in result
|
||||
assert "amix=inputs=3" in result
|
||||
assert "[mixed]" in result
|
||||
|
||||
def test_zero_tracks_no_main(self):
|
||||
"""无轨道无主音频."""
|
||||
assert build_mix_filter_complex(0, has_main=False) == ""
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# 音量计算测试
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestNormalizeVolume:
|
||||
"""音量规范化测试."""
|
||||
|
||||
def test_normal_volume(self):
|
||||
"""正常音量."""
|
||||
assert normalize_volume(0.5) == 0.5
|
||||
|
||||
def test_none_default(self):
|
||||
"""None 默认 1.0."""
|
||||
assert normalize_volume(None) == 1.0
|
||||
|
||||
def test_below_zero_clamped(self):
|
||||
"""负值钳制到 0."""
|
||||
assert normalize_volume(-5) == 0.0
|
||||
|
||||
def test_above_max_clamped(self):
|
||||
"""超过上限钳制."""
|
||||
assert normalize_volume(3.0) == 2.0
|
||||
|
||||
def test_string_input(self):
|
||||
"""字符串输入."""
|
||||
assert normalize_volume("0.5") == 0.5
|
||||
|
||||
def test_invalid_string(self):
|
||||
"""无效字符串默认 1.0."""
|
||||
assert normalize_volume("abc") == 1.0
|
||||
|
||||
|
||||
class TestDbConversion:
|
||||
"""dB 转换测试."""
|
||||
|
||||
def test_0_db_is_unity(self):
|
||||
"""0 dB = 1.0."""
|
||||
assert db_to_linear(0) == pytest.approx(1.0)
|
||||
|
||||
def test_negative_db(self):
|
||||
"""负 dB < 1."""
|
||||
assert db_to_linear(-6) == pytest.approx(0.5, rel=0.01)
|
||||
|
||||
def test_positive_db(self):
|
||||
"""正 dB > 1."""
|
||||
assert db_to_linear(6) == pytest.approx(2.0, rel=0.01)
|
||||
|
||||
def test_round_trip(self):
|
||||
"""往返转换."""
|
||||
original = 0.5
|
||||
db = linear_to_db(original)
|
||||
result = db_to_linear(db)
|
||||
assert result == pytest.approx(original)
|
||||
|
||||
def test_zero_linear_is_negative_inf(self):
|
||||
"""零线性值 = -inf dB."""
|
||||
assert math.isinf(linear_to_db(0))
|
||||
assert linear_to_db(0) < 0
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# 轨道排序与过滤测试
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestSortTracksByPriority:
|
||||
"""轨道优先级排序测试."""
|
||||
|
||||
def test_sorted_by_priority(self):
|
||||
"""按优先级排序."""
|
||||
tracks = [
|
||||
{"priority": 10, "name": "high"},
|
||||
{"priority": 1, "name": "highest"},
|
||||
{"priority": 100, "name": "low"},
|
||||
]
|
||||
result = sort_tracks_by_priority(tracks)
|
||||
assert result[0]["name"] == "highest"
|
||||
assert result[1]["name"] == "high"
|
||||
assert result[2]["name"] == "low"
|
||||
|
||||
def test_default_priority_100(self):
|
||||
"""默认优先级 100."""
|
||||
tracks = [
|
||||
{"priority": 50, "name": "mid"},
|
||||
{"name": "default"},
|
||||
]
|
||||
result = sort_tracks_by_priority(tracks)
|
||||
assert result[0]["name"] == "mid"
|
||||
assert result[1]["name"] == "default"
|
||||
|
||||
def test_same_preserves_order(self):
|
||||
"""同优先级保持顺序."""
|
||||
tracks = [
|
||||
{"priority": 10, "name": "first"},
|
||||
{"priority": 10, "name": "second"},
|
||||
]
|
||||
result = sort_tracks_by_priority(tracks)
|
||||
assert result[0]["name"] == "first"
|
||||
assert result[1]["name"] == "second"
|
||||
|
||||
def test_empty_list(self):
|
||||
"""空列表."""
|
||||
assert sort_tracks_by_priority([]) == []
|
||||
|
||||
|
||||
class TestFilterEnabledTracks:
|
||||
"""启用轨道过滤测试."""
|
||||
|
||||
def test_all_enabled(self):
|
||||
"""全部启用."""
|
||||
tracks = [{"enabled": True}, {"enabled": True}]
|
||||
assert len(filter_enabled_tracks(tracks)) == 2
|
||||
|
||||
def test_mixed(self):
|
||||
"""混合."""
|
||||
tracks = [
|
||||
{"enabled": True, "name": "a"},
|
||||
{"enabled": False, "name": "b"},
|
||||
]
|
||||
result = filter_enabled_tracks(tracks)
|
||||
assert len(result) == 1
|
||||
assert result[0]["name"] == "a"
|
||||
|
||||
def test_default_enabled(self):
|
||||
"""默认启用."""
|
||||
tracks = [{"name": "a"}]
|
||||
assert len(filter_enabled_tracks(tracks)) == 1
|
||||
|
||||
def test_empty_list(self):
|
||||
"""空列表."""
|
||||
assert filter_enabled_tracks([]) == []
|
||||
|
||||
|
||||
class TestCountTrackTypes:
|
||||
"""轨道类型统计测试."""
|
||||
|
||||
def test_mixed_types(self):
|
||||
"""混合类型."""
|
||||
tracks = [
|
||||
{"track_type": "bgm"},
|
||||
{"track_type": "voiceover"},
|
||||
{"track_type": "bgm"},
|
||||
{"track_type": "sfx"},
|
||||
]
|
||||
counts = count_track_types(tracks)
|
||||
assert counts["bgm"] == 2
|
||||
assert counts["voiceover"] == 1
|
||||
assert counts["sfx"] == 1
|
||||
|
||||
def test_default_type(self):
|
||||
"""默认类型."""
|
||||
tracks = [{}]
|
||||
counts = count_track_types(tracks)
|
||||
assert counts["unknown"] == 1
|
||||
|
||||
def test_empty_list(self):
|
||||
"""空列表."""
|
||||
assert count_track_types([]) == {}
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# 配置验证测试
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestValidateAudioTrack:
|
||||
"""单轨验证测试."""
|
||||
|
||||
def test_valid_track(self):
|
||||
"""合法轨道."""
|
||||
ok, errors = validate_audio_track(
|
||||
{
|
||||
"audio_path": "/audio.mp3",
|
||||
"volume": 0.8,
|
||||
"fade_in": 1.0,
|
||||
"fade_out": 2.0,
|
||||
}
|
||||
)
|
||||
assert ok is True
|
||||
assert len(errors) == 0
|
||||
|
||||
def test_missing_path(self):
|
||||
"""缺路径."""
|
||||
ok, errors = validate_audio_track({})
|
||||
assert ok is False
|
||||
assert any("audio_path" in e or "asset_id" in e for e in errors)
|
||||
|
||||
def test_negative_volume(self):
|
||||
"""负音量."""
|
||||
ok, errors = validate_audio_track(
|
||||
{
|
||||
"audio_path": "/a.mp3",
|
||||
"volume": -1,
|
||||
}
|
||||
)
|
||||
assert ok is False
|
||||
assert any("volume" in e for e in errors)
|
||||
|
||||
def test_negative_fade_in(self):
|
||||
"""负淡入."""
|
||||
ok, errors = validate_audio_track(
|
||||
{
|
||||
"audio_path": "/a.mp3",
|
||||
"fade_in": -1,
|
||||
}
|
||||
)
|
||||
assert ok is False
|
||||
assert any("fade_in" in e for e in errors)
|
||||
|
||||
def test_negative_fade_out(self):
|
||||
"""负淡出."""
|
||||
ok, errors = validate_audio_track(
|
||||
{
|
||||
"audio_path": "/a.mp3",
|
||||
"fade_out": -1,
|
||||
}
|
||||
)
|
||||
assert ok is False
|
||||
assert any("fade_out" in e for e in errors)
|
||||
|
||||
def test_invalid_volume_type(self):
|
||||
"""无效音量类型."""
|
||||
ok, errors = validate_audio_track(
|
||||
{
|
||||
"audio_path": "/a.mp3",
|
||||
"volume": "loud",
|
||||
}
|
||||
)
|
||||
assert ok is False
|
||||
assert any("volume" in e for e in errors)
|
||||
|
||||
def test_with_asset_id(self):
|
||||
"""有 asset_id 无 audio_path 也合法."""
|
||||
ok, errors = validate_audio_track({"asset_id": "123"})
|
||||
assert ok is True
|
||||
|
||||
|
||||
class TestValidateMixConfig:
|
||||
"""混音配置验证测试."""
|
||||
|
||||
def test_valid_config(self):
|
||||
"""合法配置."""
|
||||
ok, errors = validate_mix_config(
|
||||
{
|
||||
"tracks": [
|
||||
{"audio_path": "/a.mp3", "volume": 0.5},
|
||||
{"audio_path": "/b.mp3", "volume": 0.8},
|
||||
],
|
||||
"target_duration": 60,
|
||||
}
|
||||
)
|
||||
assert ok is True
|
||||
|
||||
def test_empty_tracks(self):
|
||||
"""空轨道列表."""
|
||||
ok, errors = validate_mix_config({"tracks": []})
|
||||
assert ok is False
|
||||
assert any("至少需要" in e for e in errors)
|
||||
|
||||
def test_invalid_track(self):
|
||||
"""无效轨道."""
|
||||
ok, errors = validate_mix_config(
|
||||
{
|
||||
"tracks": [
|
||||
{"audio_path": "/a.mp3"},
|
||||
{}, # 无效
|
||||
],
|
||||
}
|
||||
)
|
||||
assert ok is False
|
||||
assert len(errors) >= 1
|
||||
|
||||
def test_negative_target_duration(self):
|
||||
"""负目标时长."""
|
||||
ok, errors = validate_mix_config(
|
||||
{
|
||||
"tracks": [{"audio_path": "/a.mp3"}],
|
||||
"target_duration": -10,
|
||||
}
|
||||
)
|
||||
assert ok is False
|
||||
assert any("target_duration" in e for e in errors)
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# 工具函数测试
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestCalculateTotalTracks:
|
||||
"""总轨道数计算测试."""
|
||||
|
||||
def test_with_main(self):
|
||||
"""含主音频."""
|
||||
assert calculate_total_tracks({"tracks": [1, 2, 3]}) == 4
|
||||
|
||||
def test_without_main(self):
|
||||
"""不含主音频."""
|
||||
assert (
|
||||
calculate_total_tracks(
|
||||
{
|
||||
"tracks": [1, 2],
|
||||
"has_main_audio": False,
|
||||
}
|
||||
)
|
||||
== 2
|
||||
)
|
||||
|
||||
def test_empty_tracks_with_main(self):
|
||||
"""无轨道,只有主音频."""
|
||||
assert calculate_total_tracks({"tracks": []}) == 1
|
||||
|
||||
|
||||
class TestEstimateMixDuration:
|
||||
"""混音时长估算测试."""
|
||||
|
||||
def test_multiple_tracks(self):
|
||||
"""多轨道取最长结束时间."""
|
||||
tracks = [
|
||||
{"start_time": 0, "duration": 10},
|
||||
{"start_time": 5, "duration": 20}, # 结束 25
|
||||
{"start_time": 2, "duration": 5},
|
||||
]
|
||||
assert estimate_mix_duration(tracks) == pytest.approx(25.0)
|
||||
|
||||
def test_empty_list(self):
|
||||
"""空列表."""
|
||||
assert estimate_mix_duration([]) == 0.0
|
||||
|
||||
def test_zero_duration_tracks_ignored(self):
|
||||
"""零时长轨道忽略."""
|
||||
tracks = [
|
||||
{"start_time": 0, "duration": 0},
|
||||
{"start_time": 5, "duration": 10},
|
||||
]
|
||||
assert estimate_mix_duration(tracks) == pytest.approx(15.0)
|
||||
@@ -1,780 +0,0 @@
|
||||
"""贴纸引擎纯逻辑单元测试."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
from video_processing.sticker_engine_pure import (
|
||||
build_drawtext_alpha_expr,
|
||||
build_enable_expr,
|
||||
build_image_fade_filters,
|
||||
build_opacity_filter,
|
||||
build_overlay_position,
|
||||
build_pre_filter_label,
|
||||
build_scale_filter,
|
||||
build_shadow_params,
|
||||
build_stroke_params,
|
||||
calculate_end_time,
|
||||
calculate_fade_out_start,
|
||||
count_sticker_types,
|
||||
escape_drawtext_text,
|
||||
estimate_sticker_size,
|
||||
estimate_text_size,
|
||||
filter_enabled_stickers,
|
||||
has_time_range,
|
||||
safe_bool,
|
||||
safe_float,
|
||||
safe_int,
|
||||
sort_stickers_by_z_index,
|
||||
validate_image_sticker,
|
||||
validate_text_sticker,
|
||||
)
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# 安全类型转换测试
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestSafeFloat:
|
||||
"""safe_float 测试."""
|
||||
|
||||
def test_int_input(self):
|
||||
"""整数输入."""
|
||||
assert safe_float(42) == 42.0
|
||||
|
||||
def test_float_input(self):
|
||||
"""浮点数输入."""
|
||||
assert safe_float(3.14) == 3.14
|
||||
|
||||
def test_string_number(self):
|
||||
"""字符串数字."""
|
||||
assert safe_float("3.14") == 3.14
|
||||
|
||||
def test_string_int(self):
|
||||
"""字符串整数."""
|
||||
assert safe_float("100") == 100.0
|
||||
|
||||
def test_none_input(self):
|
||||
"""None 输入."""
|
||||
assert safe_float(None) is None
|
||||
|
||||
def test_invalid_string(self):
|
||||
"""无效字符串."""
|
||||
assert safe_float("abc") is None
|
||||
|
||||
def test_empty_string(self):
|
||||
"""空字符串."""
|
||||
assert safe_float("") is None
|
||||
|
||||
def test_zero(self):
|
||||
"""零值."""
|
||||
assert safe_float(0) == 0.0
|
||||
|
||||
def test_negative(self):
|
||||
"""负值."""
|
||||
assert safe_float(-5.5) == -5.5
|
||||
|
||||
|
||||
class TestSafeInt:
|
||||
"""safe_int 测试."""
|
||||
|
||||
def test_int_input(self):
|
||||
"""整数输入."""
|
||||
assert safe_int(42) == 42
|
||||
|
||||
def test_float_input(self):
|
||||
"""浮点数输入(截断)."""
|
||||
assert safe_int(3.7) == 3
|
||||
|
||||
def test_string_number(self):
|
||||
"""字符串数字."""
|
||||
assert safe_int("42") == 42
|
||||
|
||||
def test_none_input(self):
|
||||
"""None 输入用默认值."""
|
||||
assert safe_int(None) == 0
|
||||
|
||||
def test_none_custom_default(self):
|
||||
"""None 输入自定义默认值."""
|
||||
assert safe_int(None, default=10) == 10
|
||||
|
||||
def test_invalid_string(self):
|
||||
"""无效字符串."""
|
||||
assert safe_int("abc") == 0
|
||||
|
||||
def test_negative(self):
|
||||
"""负值."""
|
||||
assert safe_int(-5) == -5
|
||||
|
||||
def test_zero(self):
|
||||
"""零值."""
|
||||
assert safe_int(0) == 0
|
||||
|
||||
|
||||
class TestSafeBool:
|
||||
"""safe_bool 测试."""
|
||||
|
||||
def test_true_bool(self):
|
||||
"""True."""
|
||||
assert safe_bool(True) is True
|
||||
|
||||
def test_false_bool(self):
|
||||
"""False."""
|
||||
assert safe_bool(False) is False
|
||||
|
||||
def test_none(self):
|
||||
"""None -> False."""
|
||||
assert safe_bool(None) is False
|
||||
|
||||
def test_string_true(self):
|
||||
"""字符串 true."""
|
||||
assert safe_bool("true") is True
|
||||
|
||||
def test_string_yes(self):
|
||||
"""字符串 yes."""
|
||||
assert safe_bool("yes") is True
|
||||
|
||||
def test_string_one(self):
|
||||
"""字符串 1."""
|
||||
assert safe_bool("1") is True
|
||||
|
||||
def test_string_false(self):
|
||||
"""字符串 false."""
|
||||
assert safe_bool("false") is False
|
||||
|
||||
def test_int_one(self):
|
||||
"""整数 1 -> True."""
|
||||
assert safe_bool(1) is True
|
||||
|
||||
def test_int_zero(self):
|
||||
"""整数 0 -> False."""
|
||||
assert safe_bool(0) is False
|
||||
|
||||
def test_empty_list(self):
|
||||
"""空列表 -> False."""
|
||||
assert safe_bool([]) is False
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# 尺寸估算测试
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestEstimateStickerSize:
|
||||
"""贴纸尺寸估算测试."""
|
||||
|
||||
def test_default_scale(self):
|
||||
"""默认 scale=1.0."""
|
||||
w, h = estimate_sticker_size(1000, 1000)
|
||||
assert w == 300 # 1000 * 0.3 * 1.0
|
||||
assert h == 300
|
||||
|
||||
def test_custom_scale(self):
|
||||
"""自定义缩放."""
|
||||
w, h = estimate_sticker_size(1000, 1000, scale=0.5)
|
||||
assert w == 150
|
||||
assert h == 150
|
||||
|
||||
def test_fixed_width_height(self):
|
||||
"""固定宽高."""
|
||||
w, h = estimate_sticker_size(1000, 1000, fixed_width=200, fixed_height=100)
|
||||
assert w == 200
|
||||
assert h == 100
|
||||
|
||||
def test_scale_2x(self):
|
||||
"""2倍缩放."""
|
||||
w, h = estimate_sticker_size(800, 600, scale=2.0)
|
||||
assert w == 480 # 800 * 0.3 * 2
|
||||
assert h == 360 # 600 * 0.3 * 2
|
||||
|
||||
def test_zero_canvas(self):
|
||||
"""零画布尺寸,返回最小 1."""
|
||||
w, h = estimate_sticker_size(0, 0)
|
||||
assert w >= 1
|
||||
assert h >= 1
|
||||
|
||||
|
||||
class TestEstimateTextSize:
|
||||
"""文字尺寸估算测试."""
|
||||
|
||||
def test_normal_text(self):
|
||||
"""普通文字."""
|
||||
w, h = estimate_text_size("Hello", 36)
|
||||
assert w == int(5 * 36 * 0.6)
|
||||
assert h == int(36 * 1.4)
|
||||
|
||||
def test_empty_text(self):
|
||||
"""空文字."""
|
||||
w, h = estimate_text_size("", 36)
|
||||
assert w == 0
|
||||
assert h == 0
|
||||
|
||||
def test_large_font(self):
|
||||
"""大字号."""
|
||||
w, h = estimate_text_size("A", 72)
|
||||
assert w == int(1 * 72 * 0.6)
|
||||
assert h == int(72 * 1.4)
|
||||
|
||||
def test_chinese_chars(self):
|
||||
"""中文字符."""
|
||||
w, h = estimate_text_size("你好世界", 48)
|
||||
assert w == int(4 * 48 * 0.6)
|
||||
assert h == int(48 * 1.4)
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# 时间计算测试
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestCalculateFadeOutStart:
|
||||
"""淡出开始时间计算测试."""
|
||||
|
||||
def test_normal_case(self):
|
||||
"""正常情况."""
|
||||
assert calculate_fade_out_start(10, 30, 2) == pytest.approx(38.0)
|
||||
|
||||
def test_no_fade_out(self):
|
||||
"""无淡出."""
|
||||
assert calculate_fade_out_start(10, 30, 0) == 0.0
|
||||
|
||||
def test_negative_fade_out(self):
|
||||
"""负淡出."""
|
||||
assert calculate_fade_out_start(10, 30, -1) == 0.0
|
||||
|
||||
def test_zero_duration(self):
|
||||
"""零时长."""
|
||||
assert calculate_fade_out_start(10, 0, 2) == 0.0
|
||||
|
||||
def test_fade_out_longer_than_duration(self):
|
||||
"""淡出超过时长,返回 0."""
|
||||
# start=10, dur=5, fade=10 -> 10+5-10 = 5 > 0
|
||||
assert calculate_fade_out_start(10, 5, 10) == pytest.approx(5.0)
|
||||
|
||||
def test_fade_out_starts_before_zero(self):
|
||||
"""淡出开始时间在 0 之前,钳制到 0."""
|
||||
# start=0, dur=3, fade=5 -> 0+3-5 = -2 -> 0
|
||||
assert calculate_fade_out_start(0, 3, 5) == 0.0
|
||||
|
||||
|
||||
class TestCalculateEndTime:
|
||||
"""结束时间计算测试."""
|
||||
|
||||
def test_normal_case(self):
|
||||
"""正常情况."""
|
||||
assert calculate_end_time(10, 30) == 40.0
|
||||
|
||||
def test_zero_duration(self):
|
||||
"""零时长."""
|
||||
assert calculate_end_time(10, 0) == 10.0
|
||||
|
||||
def test_negative_duration(self):
|
||||
"""负时长."""
|
||||
assert calculate_end_time(10, -5) == 10.0
|
||||
|
||||
def test_zero_start(self):
|
||||
"""零开始."""
|
||||
assert calculate_end_time(0, 100) == 100.0
|
||||
|
||||
|
||||
class TestHasTimeRange:
|
||||
"""时间范围判断测试."""
|
||||
|
||||
def test_positive_duration(self):
|
||||
"""正时长."""
|
||||
assert has_time_range(30) is True
|
||||
|
||||
def test_zero_duration(self):
|
||||
"""零时长."""
|
||||
assert has_time_range(0) is False
|
||||
|
||||
def test_negative_duration(self):
|
||||
"""负时长."""
|
||||
assert has_time_range(-5) is False
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# 滤镜构建测试
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestBuildScaleFilter:
|
||||
"""缩放滤镜构建测试."""
|
||||
|
||||
def test_fixed_width_height(self):
|
||||
"""固定宽高."""
|
||||
result = build_scale_filter(width=200, height=100)
|
||||
assert result == "scale=200:100"
|
||||
|
||||
def test_scale_only(self):
|
||||
"""仅缩放."""
|
||||
result = build_scale_filter(scale=0.5)
|
||||
assert result == "scale=iw*0.5:ih*0.5"
|
||||
|
||||
def test_no_scaling_needed(self):
|
||||
"""无需缩放."""
|
||||
result = build_scale_filter(scale=1.0)
|
||||
assert result is None
|
||||
|
||||
def test_scale_2x(self):
|
||||
"""2倍缩放."""
|
||||
result = build_scale_filter(scale=2.0)
|
||||
assert result == "scale=iw*2.0:ih*2.0"
|
||||
|
||||
def test_fixed_overrides_scale(self):
|
||||
"""固定宽高优先于 scale."""
|
||||
result = build_scale_filter(width=100, height=50, scale=0.5)
|
||||
assert result == "scale=100:50"
|
||||
|
||||
|
||||
class TestBuildOpacityFilter:
|
||||
"""透明度滤镜构建测试."""
|
||||
|
||||
def test_partial_opacity(self):
|
||||
"""部分透明."""
|
||||
result = build_opacity_filter(0.5)
|
||||
assert result == "colorchannelmixer=aa=0.5"
|
||||
|
||||
def test_fully_opaque(self):
|
||||
"""完全不透明."""
|
||||
result = build_opacity_filter(1.0)
|
||||
assert result is None
|
||||
|
||||
def test_fully_transparent(self):
|
||||
"""完全透明."""
|
||||
result = build_opacity_filter(0.0)
|
||||
assert result == "colorchannelmixer=aa=0.0"
|
||||
|
||||
def test_opacity_above_1_clamped(self):
|
||||
"""超过 1 被钳制."""
|
||||
result = build_opacity_filter(1.5)
|
||||
assert result is None
|
||||
|
||||
def test_opacity_below_0_clamped(self):
|
||||
"""低于 0 被钳制."""
|
||||
result = build_opacity_filter(-0.5)
|
||||
assert result == "colorchannelmixer=aa=0.0"
|
||||
|
||||
|
||||
class TestBuildImageFadeFilters:
|
||||
"""图片淡入淡出滤镜测试."""
|
||||
|
||||
def test_fade_in_only(self):
|
||||
"""仅淡入."""
|
||||
result = build_image_fade_filters(10, 30, fade_in=1.0)
|
||||
assert len(result) == 1
|
||||
assert "fade=in:st=10:d=1.0:alpha=1" in result[0]
|
||||
|
||||
def test_fade_out_only(self):
|
||||
"""仅淡出."""
|
||||
result = build_image_fade_filters(10, 30, fade_out=2.0)
|
||||
assert len(result) == 1
|
||||
assert "fade=out" in result[0]
|
||||
assert "st=38.0" in result[0] # 10 + 30 - 2 = 38
|
||||
|
||||
def test_fade_in_and_out(self):
|
||||
"""淡入+淡出."""
|
||||
result = build_image_fade_filters(0, 10, fade_in=1.0, fade_out=1.0)
|
||||
assert len(result) == 2
|
||||
assert "fade=in" in result[0]
|
||||
assert "fade=out" in result[1]
|
||||
|
||||
def test_no_fade(self):
|
||||
"""无淡入淡出."""
|
||||
result = build_image_fade_filters(10, 30)
|
||||
assert len(result) == 0
|
||||
|
||||
def test_zero_duration_no_fade_out(self):
|
||||
"""零时长不生成淡出."""
|
||||
result = build_image_fade_filters(10, 0, fade_out=1.0)
|
||||
assert len(result) == 0
|
||||
|
||||
|
||||
class TestBuildEnableExpr:
|
||||
"""enable 表达式构建测试."""
|
||||
|
||||
def test_normal_duration(self):
|
||||
"""正常时长."""
|
||||
result = build_enable_expr(10, 30)
|
||||
assert "between(t,10,40" in result
|
||||
assert "enable" in result
|
||||
|
||||
def test_zero_duration(self):
|
||||
"""零时长返回空."""
|
||||
result = build_enable_expr(10, 0)
|
||||
assert result == ""
|
||||
|
||||
def test_negative_duration(self):
|
||||
"""负时长返回空."""
|
||||
result = build_enable_expr(10, -5)
|
||||
assert result == ""
|
||||
|
||||
def test_zero_start(self):
|
||||
"""从零开始."""
|
||||
result = build_enable_expr(0, 100)
|
||||
assert "t,0,100" in result
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# drawtext 相关测试
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestEscapeDrawtextText:
|
||||
"""文字转义测试."""
|
||||
|
||||
def test_no_special_chars(self):
|
||||
"""无特殊字符."""
|
||||
assert escape_drawtext_text("Hello") == "Hello"
|
||||
|
||||
def test_colon_escaped(self):
|
||||
"""冒号转义."""
|
||||
assert escape_drawtext_text("a:b") == "a\\:b"
|
||||
|
||||
def test_quote_escaped(self):
|
||||
"""单引号转义."""
|
||||
assert escape_drawtext_text("it's") == "it\\'s"
|
||||
|
||||
def test_multiple_special_chars(self):
|
||||
"""多个特殊字符."""
|
||||
assert escape_drawtext_text("a:b:c'd") == "a\\:b\\:c\\'d"
|
||||
|
||||
def test_empty_string(self):
|
||||
"""空字符串."""
|
||||
assert escape_drawtext_text("") == ""
|
||||
|
||||
|
||||
class TestBuildDrawtextAlphaExpr:
|
||||
"""drawtext alpha 表达式测试."""
|
||||
|
||||
def test_no_fade(self):
|
||||
"""无淡入淡出."""
|
||||
assert build_drawtext_alpha_expr(10, 30) == "1"
|
||||
|
||||
def test_fade_in_only(self):
|
||||
"""仅淡入."""
|
||||
result = build_drawtext_alpha_expr(10, 30, fade_in=2.0)
|
||||
assert "if(lt(t,12.0)" in result
|
||||
assert "(t-10)/2.0" in result
|
||||
|
||||
def test_fade_out_only(self):
|
||||
"""仅淡出."""
|
||||
result = build_drawtext_alpha_expr(10, 30, fade_out=3.0)
|
||||
assert "if(gt(t,37" in result
|
||||
assert "-t)/3.0" in result
|
||||
|
||||
def test_fade_in_and_out(self):
|
||||
"""淡入+淡出(相乘)."""
|
||||
result = build_drawtext_alpha_expr(0, 10, fade_in=1.0, fade_out=1.0)
|
||||
assert "*" in result
|
||||
assert result.count("if(") == 2
|
||||
|
||||
def test_zero_duration_no_fade_out(self):
|
||||
"""零时长不生成淡出."""
|
||||
result = build_drawtext_alpha_expr(10, 0, fade_out=1.0)
|
||||
assert result == "1"
|
||||
|
||||
|
||||
class TestBuildStrokeParams:
|
||||
"""描边参数测试."""
|
||||
|
||||
def test_no_stroke(self):
|
||||
"""无描边."""
|
||||
result = build_stroke_params(0)
|
||||
assert len(result) == 0
|
||||
|
||||
def test_with_stroke(self):
|
||||
"""有描边."""
|
||||
result = build_stroke_params(2, "red")
|
||||
assert len(result) == 2
|
||||
assert "borderw=2" in result
|
||||
assert "bordercolor=red" in result
|
||||
|
||||
def test_negative_width(self):
|
||||
"""负宽度."""
|
||||
result = build_stroke_params(-1)
|
||||
assert len(result) == 0
|
||||
|
||||
|
||||
class TestBuildShadowParams:
|
||||
"""阴影参数测试."""
|
||||
|
||||
def test_no_shadow(self):
|
||||
"""无阴影."""
|
||||
result = build_shadow_params(0)
|
||||
assert len(result) == 0
|
||||
|
||||
def test_with_shadow(self):
|
||||
"""有阴影."""
|
||||
result = build_shadow_params(0.5, 3, 4, "black")
|
||||
assert len(result) == 3
|
||||
assert "shadowx=3" in result
|
||||
assert "shadowy=4" in result
|
||||
assert "shadowcolor=black@0.5" in result
|
||||
|
||||
def test_shadow_alpha_clamped(self):
|
||||
"""透明度钳制."""
|
||||
result = build_shadow_params(1.5)
|
||||
assert "shadowcolor=black@1.0" in result[2]
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# 贴纸排序与过滤测试
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestSortStickersByZIndex:
|
||||
"""贴纸排序测试."""
|
||||
|
||||
def test_sorted_by_z_index(self):
|
||||
"""按 z_index 排序."""
|
||||
stickers = [
|
||||
{"z_index": 20, "name": "top"},
|
||||
{"z_index": 5, "name": "bottom"},
|
||||
{"z_index": 10, "name": "middle"},
|
||||
]
|
||||
result = sort_stickers_by_z_index(stickers)
|
||||
assert result[0]["name"] == "bottom"
|
||||
assert result[1]["name"] == "middle"
|
||||
assert result[2]["name"] == "top"
|
||||
|
||||
def test_same_z_index_preserves_order(self):
|
||||
"""相同 z_index 保持原顺序."""
|
||||
stickers = [
|
||||
{"z_index": 10, "name": "first"},
|
||||
{"z_index": 10, "name": "second"},
|
||||
]
|
||||
result = sort_stickers_by_z_index(stickers)
|
||||
assert result[0]["name"] == "first"
|
||||
assert result[1]["name"] == "second"
|
||||
|
||||
def test_empty_list(self):
|
||||
"""空列表."""
|
||||
assert sort_stickers_by_z_index([]) == []
|
||||
|
||||
def test_default_z_index_10(self):
|
||||
"""无 z_index 默认 10."""
|
||||
stickers = [
|
||||
{"z_index": 5, "name": "low"},
|
||||
{"name": "default"},
|
||||
]
|
||||
result = sort_stickers_by_z_index(stickers)
|
||||
assert result[0]["name"] == "low"
|
||||
assert result[1]["name"] == "default"
|
||||
|
||||
|
||||
class TestFilterEnabledStickers:
|
||||
"""启用贴纸过滤测试."""
|
||||
|
||||
def test_all_enabled(self):
|
||||
"""全部启用."""
|
||||
stickers = [{"enabled": True}, {"enabled": True}]
|
||||
assert len(filter_enabled_stickers(stickers)) == 2
|
||||
|
||||
def test_mixed(self):
|
||||
"""混合."""
|
||||
stickers = [
|
||||
{"enabled": True, "name": "a"},
|
||||
{"enabled": False, "name": "b"},
|
||||
{"enabled": True, "name": "c"},
|
||||
]
|
||||
result = filter_enabled_stickers(stickers)
|
||||
assert len(result) == 2
|
||||
assert result[0]["name"] == "a"
|
||||
|
||||
def test_default_enabled(self):
|
||||
"""默认启用."""
|
||||
stickers = [{"name": "a"}]
|
||||
result = filter_enabled_stickers(stickers)
|
||||
assert len(result) == 1
|
||||
|
||||
def test_empty_list(self):
|
||||
"""空列表."""
|
||||
assert filter_enabled_stickers([]) == []
|
||||
|
||||
|
||||
class TestCountStickerTypes:
|
||||
"""贴纸类型统计测试."""
|
||||
|
||||
def test_mixed_types(self):
|
||||
"""混合类型."""
|
||||
stickers = [
|
||||
{"type": "image"},
|
||||
{"type": "text"},
|
||||
{"type": "image"},
|
||||
]
|
||||
counts = count_sticker_types(stickers)
|
||||
assert counts["image"] == 2
|
||||
assert counts["text"] == 1
|
||||
|
||||
def test_default_type(self):
|
||||
"""默认 image."""
|
||||
stickers = [{}]
|
||||
counts = count_sticker_types(stickers)
|
||||
assert counts["image"] == 1
|
||||
|
||||
def test_empty_list(self):
|
||||
"""空列表."""
|
||||
assert count_sticker_types([]) == {}
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# overlay 相关测试
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestBuildOverlayPosition:
|
||||
"""overlay 位置构建测试."""
|
||||
|
||||
def test_integer_position(self):
|
||||
"""整数位置."""
|
||||
assert build_overlay_position(100, 200) == "100:200"
|
||||
|
||||
def test_float_position_rounded(self):
|
||||
"""浮点取整."""
|
||||
assert build_overlay_position(100.6, 200.4) == "101:200"
|
||||
|
||||
def test_zero_position(self):
|
||||
"""零位置."""
|
||||
assert build_overlay_position(0, 0) == "0:0"
|
||||
|
||||
def test_negative_position(self):
|
||||
"""负位置."""
|
||||
assert build_overlay_position(-10, -20) == "-10:-20"
|
||||
|
||||
|
||||
class TestBuildPreFilterLabel:
|
||||
"""预处理标签构建测试."""
|
||||
|
||||
def test_normal_idx(self):
|
||||
"""正常索引."""
|
||||
assert build_pre_filter_label(3) == "sticker_3_scaled"
|
||||
|
||||
def test_zero_idx(self):
|
||||
"""零索引."""
|
||||
assert build_pre_filter_label(0) == "sticker_0_scaled"
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# 验证函数测试
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestValidateImageSticker:
|
||||
"""图片贴纸验证测试."""
|
||||
|
||||
def test_valid_with_image_path(self):
|
||||
"""有 image_path,合法."""
|
||||
ok, errors = validate_image_sticker({"image_path": "/a.png"})
|
||||
assert ok is True
|
||||
assert len(errors) == 0
|
||||
|
||||
def test_valid_with_asset_id(self):
|
||||
"""有 asset_id,合法."""
|
||||
ok, errors = validate_image_sticker({"asset_id": "123"})
|
||||
assert ok is True
|
||||
|
||||
def test_missing_image_source(self):
|
||||
"""缺图片来源."""
|
||||
ok, errors = validate_image_sticker({})
|
||||
assert ok is False
|
||||
assert any("image_path" in e or "asset_id" in e for e in errors)
|
||||
|
||||
def test_opacity_out_of_range(self):
|
||||
"""透明度超范围."""
|
||||
ok, errors = validate_image_sticker(
|
||||
{
|
||||
"image_path": "/a.png",
|
||||
"opacity": 1.5,
|
||||
}
|
||||
)
|
||||
assert ok is False
|
||||
assert any("opacity" in e for e in errors)
|
||||
|
||||
def test_negative_scale(self):
|
||||
"""负缩放."""
|
||||
ok, errors = validate_image_sticker(
|
||||
{
|
||||
"image_path": "/a.png",
|
||||
"scale": -0.5,
|
||||
}
|
||||
)
|
||||
assert ok is False
|
||||
assert any("scale" in e for e in errors)
|
||||
|
||||
def test_negative_duration(self):
|
||||
"""负时长."""
|
||||
ok, errors = validate_image_sticker(
|
||||
{
|
||||
"image_path": "/a.png",
|
||||
"duration": -10,
|
||||
}
|
||||
)
|
||||
assert ok is False
|
||||
assert any("duration" in e for e in errors)
|
||||
|
||||
def test_multiple_errors(self):
|
||||
"""多个错误."""
|
||||
ok, errors = validate_image_sticker(
|
||||
{
|
||||
"opacity": 1.5,
|
||||
"duration": -1,
|
||||
"start_time": -5,
|
||||
}
|
||||
)
|
||||
assert ok is False
|
||||
assert len(errors) >= 3
|
||||
|
||||
|
||||
class TestValidateTextSticker:
|
||||
"""文字贴纸验证测试."""
|
||||
|
||||
def test_valid(self):
|
||||
"""合法配置."""
|
||||
ok, errors = validate_text_sticker(
|
||||
{
|
||||
"text": "Hello",
|
||||
"font_size": 36,
|
||||
"font_color": "white",
|
||||
}
|
||||
)
|
||||
assert ok is True
|
||||
assert len(errors) == 0
|
||||
|
||||
def test_empty_text(self):
|
||||
"""空文字."""
|
||||
ok, errors = validate_text_sticker({"text": ""})
|
||||
assert ok is False
|
||||
assert any("text" in e for e in errors)
|
||||
|
||||
def test_zero_font_size(self):
|
||||
"""零字号."""
|
||||
ok, errors = validate_text_sticker(
|
||||
{
|
||||
"text": "Hi",
|
||||
"font_size": 0,
|
||||
}
|
||||
)
|
||||
assert ok is False
|
||||
assert any("font_size" in e for e in errors)
|
||||
|
||||
def test_empty_font_color(self):
|
||||
"""空颜色."""
|
||||
ok, errors = validate_text_sticker(
|
||||
{
|
||||
"text": "Hi",
|
||||
"font_color": "",
|
||||
}
|
||||
)
|
||||
assert ok is False
|
||||
assert any("font_color" in e for e in errors)
|
||||
|
||||
def test_negative_duration(self):
|
||||
"""负时长."""
|
||||
ok, errors = validate_text_sticker(
|
||||
{
|
||||
"text": "Hi",
|
||||
"duration": -5,
|
||||
}
|
||||
)
|
||||
assert ok is False
|
||||
assert any("duration" in e for e in errors)
|
||||
Reference in New Issue
Block a user