Compare commits
69 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 73cb7e1479 | |||
| 6a07b43d7e | |||
| 4c31026f81 | |||
| 44ee898aa0 | |||
| 049dd028c0 | |||
| e64e1c2938 | |||
| 93e59df866 | |||
| 577f744912 | |||
| d727220562 | |||
| 0ce9a22b91 | |||
| 28173f1713 | |||
| 23edd869c6 | |||
| a863324942 | |||
| 3859ce1750 | |||
| ba35e3f518 | |||
| ec231ac88f | |||
| ccfaf9aa7e | |||
| fe97f016b2 | |||
| 8a0b9b8878 | |||
| 15f12ead3f | |||
| cfabbd3d61 | |||
| 19ca924087 | |||
| 0830b3ac45 | |||
| cb7daa0674 | |||
| 33fd762eea | |||
| 3eb541ca39 | |||
| b353ac0328 | |||
| 0638c685b6 | |||
| fdcdd4c6ef | |||
| 8378c2e8ff | |||
| 575a83fcaf | |||
| 17ad2235b6 | |||
| 7c47856671 | |||
| 8fa161cfb8 | |||
| cbf481a7fb | |||
| fe485cf0fc | |||
| 04886e0b30 | |||
| cbc7b48a05 | |||
| f10f691e40 | |||
| 4c2509648e | |||
| e6006e849b | |||
| 1076ce4216 | |||
| 39187a0660 | |||
| f0bbedab23 | |||
| 750444c8bb | |||
| 581a146d2f | |||
| eab45e0819 | |||
| e243d70082 | |||
| d4c3743e45 | |||
| 07d9b56fe7 | |||
| 11b3f83368 | |||
| 1dc40b4760 | |||
| 29448aaf9b | |||
| c73c4be367 | |||
| c31bc96855 | |||
| 002384bad4 | |||
| db6b742ebb | |||
| 88ca8b4406 | |||
| 005b34d0dd | |||
| e2ddc679bb | |||
| 39990f7a07 | |||
| 6fcb4cd70c | |||
| 14fefc7b1a | |||
| fa8ab58fc0 | |||
| 4765d83c8b | |||
| 395bc37cf0 | |||
| 15b6e17552 | |||
| a6e147ed30 | |||
| 0d46d71b2d |
@@ -458,6 +458,8 @@ jobs:
|
|||||||
name: Frontend Lint
|
name: Frontend Lint
|
||||||
runs-on: ci-l2
|
runs-on: ci-l2
|
||||||
timeout-minutes: 10
|
timeout-minutes: 10
|
||||||
|
needs: check-frontend-only
|
||||||
|
if: needs.check-frontend-only.outputs.skip_frontend != 'true'
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
shell: sh
|
shell: sh
|
||||||
@@ -576,7 +578,12 @@ jobs:
|
|||||||
name: PR Build ${{ matrix.service_display }} Image
|
name: PR Build ${{ matrix.service_display }} Image
|
||||||
runs-on: runtime-builder
|
runs-on: runtime-builder
|
||||||
timeout-minutes: ${{ matrix.timeout }}
|
timeout-minutes: ${{ matrix.timeout }}
|
||||||
if: github.event_name == 'pull_request'
|
needs: check-frontend-only
|
||||||
|
if: |
|
||||||
|
github.event_name == 'pull_request' && (
|
||||||
|
(matrix.service == 'web' && needs.check-frontend-only.outputs.skip_frontend != 'true') ||
|
||||||
|
(matrix.service != 'web' && needs.check-frontend-only.outputs.skip_backend != 'true')
|
||||||
|
)
|
||||||
strategy:
|
strategy:
|
||||||
fail-fast: false
|
fail-fast: false
|
||||||
matrix:
|
matrix:
|
||||||
@@ -1192,6 +1199,11 @@ jobs:
|
|||||||
runs-on: runtime-builder
|
runs-on: runtime-builder
|
||||||
timeout-minutes: ${{ matrix.timeout }}
|
timeout-minutes: ${{ matrix.timeout }}
|
||||||
needs:
|
needs:
|
||||||
|
- validate-code-quality
|
||||||
|
- validate-type-check
|
||||||
|
- unit-tests
|
||||||
|
- frontend-lint
|
||||||
|
- frontend-unit-test
|
||||||
if: startsWith(github.ref, 'refs/tags/v') || (github.event_name == 'push' && github.ref_name == 'main')
|
if: startsWith(github.ref, 'refs/tags/v') || (github.event_name == 'push' && github.ref_name == 'main')
|
||||||
strategy:
|
strategy:
|
||||||
fail-fast: false
|
fail-fast: false
|
||||||
@@ -1265,7 +1277,7 @@ jobs:
|
|||||||
docker buildx inspect --bootstrap
|
docker buildx inspect --bootstrap
|
||||||
|
|
||||||
- name: Build and push production ${{ matrix.service_display }} image (with retry)
|
- name: Build and push production ${{ matrix.service_display }} image (with retry)
|
||||||
shell: sh
|
shell: bash
|
||||||
run: |
|
run: |
|
||||||
set -eu
|
set -eu
|
||||||
REGISTRY="xiaoxia-registry.cn-hangzhou.cr.aliyuncs.com/xiaoxiakeji"
|
REGISTRY="xiaoxia-registry.cn-hangzhou.cr.aliyuncs.com/xiaoxiakeji"
|
||||||
@@ -1718,6 +1730,30 @@ jobs:
|
|||||||
echo " frontend-lint: $RESULT_FRONTEND_LINT"
|
echo " frontend-lint: $RESULT_FRONTEND_LINT"
|
||||||
echo " frontend-unit-test: $RESULT_FRONTEND_UNIT"
|
echo " frontend-unit-test: $RESULT_FRONTEND_UNIT"
|
||||||
echo " build-pr: $RESULT_BUILD_PR"
|
echo " build-pr: $RESULT_BUILD_PR"
|
||||||
|
|
||||||
|
# 查询 AI Code Review 状态(跨workflow,读commit status)
|
||||||
|
AI_REVIEW_STATUS="pending"
|
||||||
|
AI_REVIEW_DESC=""
|
||||||
|
STATUS_JSON=$(curl -sH "Authorization: token $GITHUB_TOKEN" \
|
||||||
|
"${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/commits/${PR_HEAD_SHA}/status" 2>/dev/null || true)
|
||||||
|
if [ -n "$STATUS_JSON" ]; then
|
||||||
|
AI_STATUS=$(echo "$STATUS_JSON" | python3 -c "
|
||||||
|
import json,sys
|
||||||
|
try:
|
||||||
|
data=json.load(sys.stdin)
|
||||||
|
for s in data.get('statuses',[]):
|
||||||
|
if 'AI Code Review' in s.get('context',''):
|
||||||
|
print(s['state']+'|'+s.get('description',''))
|
||||||
|
break
|
||||||
|
except: pass
|
||||||
|
" 2>/dev/null)
|
||||||
|
if [ -n "$AI_STATUS" ]; then
|
||||||
|
AI_REVIEW_STATUS="${AI_STATUS%%|*}"
|
||||||
|
AI_REVIEW_DESC="${AI_STATUS#*|}"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
echo " ai-code-review: $AI_REVIEW_STATUS ($AI_REVIEW_DESC)"
|
||||||
|
|
||||||
echo ""
|
echo ""
|
||||||
|
|
||||||
# 判断PR类型
|
# 判断PR类型
|
||||||
@@ -1733,6 +1769,7 @@ jobs:
|
|||||||
"validate-migration:$RESULT_MIGRATION"
|
"validate-migration:$RESULT_MIGRATION"
|
||||||
"frontend-lint:$RESULT_FRONTEND_LINT"
|
"frontend-lint:$RESULT_FRONTEND_LINT"
|
||||||
"build-pr:$RESULT_BUILD_PR"
|
"build-pr:$RESULT_BUILD_PR"
|
||||||
|
"ai-code-review:$AI_REVIEW_STATUS"
|
||||||
)
|
)
|
||||||
|
|
||||||
# 后端检查
|
# 后端检查
|
||||||
@@ -1767,6 +1804,11 @@ jobs:
|
|||||||
for item in "${REQUIRED_GENERAL[@]}"; do
|
for item in "${REQUIRED_GENERAL[@]}"; do
|
||||||
name="${item%%:*}"
|
name="${item%%:*}"
|
||||||
result="${item##*:}"
|
result="${item##*:}"
|
||||||
|
# AI Code Review pending时不阻塞(可能还在跑),等它跑完自然会重跑Gate
|
||||||
|
if [ "$name" = "ai-code-review" ] && [ "$result" = "pending" ]; then
|
||||||
|
echo " ⏳ $name: pending(审查中,暂不阻塞)"
|
||||||
|
continue
|
||||||
|
fi
|
||||||
check_job "$name" "$result"
|
check_job "$name" "$result"
|
||||||
done
|
done
|
||||||
|
|
||||||
@@ -1805,7 +1847,7 @@ jobs:
|
|||||||
echo "❌ CI Gate: FAILED"
|
echo "❌ CI Gate: FAILED"
|
||||||
echo "失败项: ${FAILED_ITEMS[*]}"
|
echo "失败项: ${FAILED_ITEMS[*]}"
|
||||||
echo "gate_result=failure" >> $GITHUB_OUTPUT
|
echo "gate_result=failure" >> $GITHUB_OUTPUT
|
||||||
exit 0
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
- name: Report CI trace
|
- name: Report CI trace
|
||||||
|
|||||||
@@ -87,7 +87,7 @@ jobs:
|
|||||||
runs-on: ci-l2
|
runs-on: ci-l2
|
||||||
timeout-minutes: 10
|
timeout-minutes: 10
|
||||||
outputs:
|
outputs:
|
||||||
report: ${{ steps.smoke.outputs.report }}
|
report: ${{ steps.report.outputs.report }}
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
@@ -201,7 +201,7 @@ jobs:
|
|||||||
runs-on: ci-l2
|
runs-on: ci-l2
|
||||||
timeout-minutes: 15
|
timeout-minutes: 15
|
||||||
outputs:
|
outputs:
|
||||||
report: ${{ steps.smoke.outputs.report }}
|
report: ${{ steps.e2e.outputs.report }}
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
|
|||||||
Executable → Regular
+6
-1
@@ -8,6 +8,11 @@ on:
|
|||||||
permissions:
|
permissions:
|
||||||
contents: read
|
contents: read
|
||||||
|
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: pr-automation-${{ gitea.event.pull_request.number }}
|
||||||
|
cancel-in-progress: true
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
auto-approve:
|
auto-approve:
|
||||||
name: Auto Approve on CI Green
|
name: Auto Approve on CI Green
|
||||||
@@ -56,7 +61,7 @@ jobs:
|
|||||||
name: Auto Merge on CI Green + Approved
|
name: Auto Merge on CI Green + Approved
|
||||||
runs-on: ci-check
|
runs-on: ci-check
|
||||||
if: github.event_name == 'pull_request' && !github.event.pull_request.draft && github.event.pull_request.base.ref == 'develop'
|
if: github.event_name == 'pull_request' && !github.event.pull_request.draft && github.event.pull_request.base.ref == 'develop'
|
||||||
timeout-minutes: 45 # 长等待模式:等CI全绿后自动合并,不遗漏任何PR
|
timeout-minutes: 3 # 短作业模式:检查一次,不满足就退出,由pr-auto-scan每5分钟定时兜底
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
shell: sh
|
shell: sh
|
||||||
|
|||||||
@@ -120,7 +120,7 @@ jobs:
|
|||||||
PREVIEW_SSH_KEY: ${{ secrets.PREVIEW_SSH_KEY }}
|
PREVIEW_SSH_KEY: ${{ secrets.PREVIEW_SSH_KEY }}
|
||||||
run: |
|
run: |
|
||||||
set -eux
|
set -eux
|
||||||
preview_host="${PREVIEW_SSH_HOST:-172.30.18.197}"
|
preview_host="${PREVIEW_SSH_HOST:-47.98.113.167}"
|
||||||
preview_user="${PREVIEW_SSH_USER:-deploy}"
|
preview_user="${PREVIEW_SSH_USER:-deploy}"
|
||||||
preview_port="${PREVIEW_SSH_PORT:-22222}"
|
preview_port="${PREVIEW_SSH_PORT:-22222}"
|
||||||
preview_dir="/var/www/preview/pr-${PR_NUMBER}"
|
preview_dir="/var/www/preview/pr-${PR_NUMBER}"
|
||||||
|
|||||||
@@ -101,7 +101,7 @@ jobs:
|
|||||||
# Install dependencies with retry
|
# Install dependencies with retry
|
||||||
for i in 1 2 3; do
|
for i in 1 2 3; do
|
||||||
npm ci --no-audit --no-fund && break
|
npm ci --no-audit --no-fund && break
|
||||||
echo "npm ci failed, retry $i/3..."
|
echo "npm install failed, retry $i/3..."
|
||||||
[ $i -eq 3 ] && exit 1
|
[ $i -eq 3 ] && exit 1
|
||||||
rm -rf node_modules
|
rm -rf node_modules
|
||||||
sleep 5
|
sleep 5
|
||||||
|
|||||||
@@ -0,0 +1,134 @@
|
|||||||
|
import { useState, useRef, useCallback, useEffect } from "react"
|
||||||
|
import type { ModalPhase } from "../types/cloneModal"
|
||||||
|
import { MIN_VOICE_NAME_LENGTH, MAX_VOICE_NAME_LENGTH } from "../constants/cloneModal"
|
||||||
|
import useAudioRecorder from "./useAudioRecorder"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 克隆弹窗表单状态 Hook
|
||||||
|
* 管理表单字段、录音、文件选择、验证逻辑
|
||||||
|
*/
|
||||||
|
export function useCloneFormState({ open, onClose }: { open: boolean; onClose: () => void }) {
|
||||||
|
const [phase, setPhase] = useState<ModalPhase>("input")
|
||||||
|
const [voiceName, setVoiceName] = useState("")
|
||||||
|
const [voiceDescription, setVoiceDescription] = useState("")
|
||||||
|
const [selectedFile, setSelectedFile] = useState<File | null>(null)
|
||||||
|
const [dragActive, setDragActive] = useState(false)
|
||||||
|
const [errorMessage, setErrorMessage] = useState("")
|
||||||
|
|
||||||
|
const { isRecording, recordTime, recordedBlob, toggleRecording, resetRecording } =
|
||||||
|
useAudioRecorder()
|
||||||
|
|
||||||
|
/** 默认音色名称计数器 */
|
||||||
|
const cloneCounterRef = useRef(1)
|
||||||
|
|
||||||
|
const getNextDefaultName = useCallback((): string => {
|
||||||
|
const name = `我的声音 ${cloneCounterRef.current}`
|
||||||
|
cloneCounterRef.current += 1
|
||||||
|
return name
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const hasAudio = selectedFile !== null || recordedBlob !== null
|
||||||
|
|
||||||
|
const canSubmit =
|
||||||
|
voiceName.trim().length >= MIN_VOICE_NAME_LENGTH &&
|
||||||
|
voiceName.trim().length <= MAX_VOICE_NAME_LENGTH &&
|
||||||
|
hasAudio
|
||||||
|
|
||||||
|
const isProcessing = phase === "uploading" || phase === "cloning"
|
||||||
|
|
||||||
|
/** 重置弹窗状态 */
|
||||||
|
const resetState = useCallback(() => {
|
||||||
|
setPhase("input")
|
||||||
|
setVoiceName(getNextDefaultName())
|
||||||
|
setVoiceDescription("")
|
||||||
|
setSelectedFile(null)
|
||||||
|
setDragActive(false)
|
||||||
|
setErrorMessage("")
|
||||||
|
resetRecording()
|
||||||
|
}, [getNextDefaultName, resetRecording])
|
||||||
|
|
||||||
|
/** 关闭弹窗 */
|
||||||
|
const handleClose = useCallback(() => {
|
||||||
|
resetState()
|
||||||
|
onClose()
|
||||||
|
}, [resetState, onClose])
|
||||||
|
|
||||||
|
/** 弹窗打开时重置状态 */
|
||||||
|
useEffect(() => {
|
||||||
|
if (open) {
|
||||||
|
resetState()
|
||||||
|
}
|
||||||
|
}, [open, resetState])
|
||||||
|
|
||||||
|
/** 选择文件(来自上传或拖拽) */
|
||||||
|
const handleFileSelect = useCallback(
|
||||||
|
(file: File | null, error: string) => {
|
||||||
|
if (error) {
|
||||||
|
setErrorMessage(error)
|
||||||
|
setSelectedFile(null)
|
||||||
|
} else {
|
||||||
|
setErrorMessage("")
|
||||||
|
setSelectedFile(file)
|
||||||
|
// 清除录音
|
||||||
|
resetRecording()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[resetRecording],
|
||||||
|
)
|
||||||
|
|
||||||
|
/** 录音切换 */
|
||||||
|
const handleRecordToggle = useCallback(() => {
|
||||||
|
setErrorMessage("")
|
||||||
|
if (isRecording) {
|
||||||
|
toggleRecording()
|
||||||
|
} else {
|
||||||
|
// 开始录制前清除已选文件
|
||||||
|
setSelectedFile(null)
|
||||||
|
toggleRecording()
|
||||||
|
}
|
||||||
|
}, [isRecording, toggleRecording])
|
||||||
|
|
||||||
|
/** 表单验证 */
|
||||||
|
const validateForm = useCallback((): string | null => {
|
||||||
|
const name = voiceName.trim()
|
||||||
|
if (!name) {
|
||||||
|
return "请输入音色名称"
|
||||||
|
}
|
||||||
|
if (name.length < MIN_VOICE_NAME_LENGTH || name.length > MAX_VOICE_NAME_LENGTH) {
|
||||||
|
return `音色名称需在 ${MIN_VOICE_NAME_LENGTH}-${MAX_VOICE_NAME_LENGTH} 个字符之间`
|
||||||
|
}
|
||||||
|
if (!hasAudio) {
|
||||||
|
return "请上传音频文件或录制一段声音"
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}, [voiceName, hasAudio])
|
||||||
|
|
||||||
|
return {
|
||||||
|
// 状态
|
||||||
|
phase,
|
||||||
|
setPhase,
|
||||||
|
voiceName,
|
||||||
|
setVoiceName,
|
||||||
|
voiceDescription,
|
||||||
|
setVoiceDescription,
|
||||||
|
selectedFile,
|
||||||
|
dragActive,
|
||||||
|
setDragActive,
|
||||||
|
errorMessage,
|
||||||
|
setErrorMessage,
|
||||||
|
// 录音
|
||||||
|
isRecording,
|
||||||
|
recordTime,
|
||||||
|
recordedBlob,
|
||||||
|
// 计算属性
|
||||||
|
hasAudio,
|
||||||
|
canSubmit,
|
||||||
|
isProcessing,
|
||||||
|
// handlers
|
||||||
|
handleFileSelect,
|
||||||
|
handleRecordToggle,
|
||||||
|
handleClose,
|
||||||
|
validateForm,
|
||||||
|
resetState,
|
||||||
|
}
|
||||||
|
}
|
||||||
Regular → Executable
+33
-202
@@ -1,213 +1,44 @@
|
|||||||
import { useState, useRef, useCallback, useEffect } from "react"
|
import type { CloneModalProps } from "../types/cloneModal"
|
||||||
import { createVoiceClone, toVoiceClone } from "@/api/voice-clone"
|
import { useCloneFormState } from "./useCloneFormState"
|
||||||
import { uploadAsset } from "@/api/assets"
|
import { useCloneSubmit } from "./useCloneSubmit"
|
||||||
import type { ModalPhase, CloneModalProps } from "../types/cloneModal"
|
|
||||||
import { MIN_VOICE_NAME_LENGTH, MAX_VOICE_NAME_LENGTH } from "../constants/cloneModal"
|
|
||||||
import useAudioRecorder from "./useAudioRecorder"
|
|
||||||
|
|
||||||
interface UseCloneModalReturn {
|
|
||||||
phase: ModalPhase
|
|
||||||
voiceName: string
|
|
||||||
voiceDescription: string
|
|
||||||
selectedFile: File | null
|
|
||||||
dragActive: boolean
|
|
||||||
errorMessage: string
|
|
||||||
isRecording: boolean
|
|
||||||
recordTime: number
|
|
||||||
recordedBlob: Blob | null
|
|
||||||
canSubmit: boolean
|
|
||||||
isProcessing: boolean
|
|
||||||
setVoiceName: (value: string) => void
|
|
||||||
setVoiceDescription: (value: string) => void
|
|
||||||
setDragActive: (active: boolean) => void
|
|
||||||
handleFileSelect: (file: File | null, error: string) => void
|
|
||||||
handleRecordToggle: () => void
|
|
||||||
handleClose: () => void
|
|
||||||
handleSubmit: () => void
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 音色克隆弹窗主业务 Hook
|
* 音色克隆弹窗主业务 Hook
|
||||||
|
* 组合表单状态 + 提交流程两个子 Hook
|
||||||
*/
|
*/
|
||||||
const useCloneModal = ({ open, onClose, onSuccess }: CloneModalProps): UseCloneModalReturn => {
|
const useCloneModal = ({ open, onClose, onSuccess }: CloneModalProps) => {
|
||||||
const [phase, setPhase] = useState<ModalPhase>("input")
|
const formState = useCloneFormState({ open, onClose })
|
||||||
const [voiceName, setVoiceName] = useState("")
|
|
||||||
const [voiceDescription, setVoiceDescription] = useState("")
|
|
||||||
const [selectedFile, setSelectedFile] = useState<File | null>(null)
|
|
||||||
const [dragActive, setDragActive] = useState(false)
|
|
||||||
const [errorMessage, setErrorMessage] = useState("")
|
|
||||||
|
|
||||||
const { isRecording, recordTime, recordedBlob, toggleRecording, resetRecording } =
|
const { handleSubmit } = useCloneSubmit({
|
||||||
useAudioRecorder()
|
voiceName: formState.voiceName,
|
||||||
|
voiceDescription: formState.voiceDescription,
|
||||||
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
selectedFile: formState.selectedFile,
|
||||||
/** 默认音色名称计数器 */
|
recordedBlob: formState.recordedBlob,
|
||||||
const cloneCounterRef = useRef(1)
|
setPhase: formState.setPhase,
|
||||||
|
setErrorMessage: formState.setErrorMessage,
|
||||||
const getNextDefaultName = useCallback((): string => {
|
validateForm: formState.validateForm,
|
||||||
const name = `我的声音 ${cloneCounterRef.current}`
|
|
||||||
cloneCounterRef.current += 1
|
|
||||||
return name
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
const hasAudio = selectedFile !== null || recordedBlob !== null
|
|
||||||
|
|
||||||
const canSubmit =
|
|
||||||
voiceName.trim().length >= MIN_VOICE_NAME_LENGTH &&
|
|
||||||
voiceName.trim().length <= MAX_VOICE_NAME_LENGTH &&
|
|
||||||
hasAudio
|
|
||||||
|
|
||||||
const isProcessing = phase === "uploading" || phase === "cloning"
|
|
||||||
|
|
||||||
/** 重置弹窗状态 */
|
|
||||||
const resetState = useCallback(() => {
|
|
||||||
setPhase("input")
|
|
||||||
setVoiceName(getNextDefaultName())
|
|
||||||
setVoiceDescription("")
|
|
||||||
setSelectedFile(null)
|
|
||||||
setDragActive(false)
|
|
||||||
setErrorMessage("")
|
|
||||||
resetRecording()
|
|
||||||
}, [getNextDefaultName, resetRecording])
|
|
||||||
|
|
||||||
/** 关闭弹窗 */
|
|
||||||
const handleClose = useCallback(() => {
|
|
||||||
resetState()
|
|
||||||
onClose()
|
|
||||||
}, [resetState, onClose])
|
|
||||||
|
|
||||||
/** 弹窗打开时重置状态 */
|
|
||||||
useEffect(() => {
|
|
||||||
if (open) {
|
|
||||||
resetState()
|
|
||||||
}
|
|
||||||
}, [open, resetState])
|
|
||||||
|
|
||||||
/** 组件卸载时清理定时器 */
|
|
||||||
useEffect(() => {
|
|
||||||
return () => {
|
|
||||||
if (timerRef.current) clearTimeout(timerRef.current)
|
|
||||||
}
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
/** 选择文件(来自上传或拖拽) */
|
|
||||||
const handleFileSelect = useCallback(
|
|
||||||
(file: File | null, error: string) => {
|
|
||||||
if (error) {
|
|
||||||
setErrorMessage(error)
|
|
||||||
setSelectedFile(null)
|
|
||||||
} else {
|
|
||||||
setErrorMessage("")
|
|
||||||
setSelectedFile(file)
|
|
||||||
// 清除录音
|
|
||||||
resetRecording()
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[resetRecording],
|
|
||||||
)
|
|
||||||
|
|
||||||
/** 录音切换 */
|
|
||||||
const handleRecordToggle = useCallback(() => {
|
|
||||||
setErrorMessage("")
|
|
||||||
if (isRecording) {
|
|
||||||
toggleRecording()
|
|
||||||
} else {
|
|
||||||
// 开始录制前清除已选文件
|
|
||||||
setSelectedFile(null)
|
|
||||||
toggleRecording()
|
|
||||||
}
|
|
||||||
}, [isRecording, toggleRecording])
|
|
||||||
|
|
||||||
/** 表单验证 */
|
|
||||||
const validateForm = useCallback((): string | null => {
|
|
||||||
const name = voiceName.trim()
|
|
||||||
if (!name) {
|
|
||||||
return "请输入音色名称"
|
|
||||||
}
|
|
||||||
if (name.length < MIN_VOICE_NAME_LENGTH || name.length > MAX_VOICE_NAME_LENGTH) {
|
|
||||||
return `音色名称需在 ${MIN_VOICE_NAME_LENGTH}-${MAX_VOICE_NAME_LENGTH} 个字符之间`
|
|
||||||
}
|
|
||||||
if (!hasAudio) {
|
|
||||||
return "请上传音频文件或录制一段声音"
|
|
||||||
}
|
|
||||||
return null
|
|
||||||
}, [voiceName, hasAudio])
|
|
||||||
|
|
||||||
/** 提交克隆 */
|
|
||||||
const handleSubmit = useCallback(async () => {
|
|
||||||
const formError = validateForm()
|
|
||||||
if (formError) {
|
|
||||||
setErrorMessage(formError)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
setErrorMessage("")
|
|
||||||
|
|
||||||
try {
|
|
||||||
// 阶段 1:上传音频
|
|
||||||
setPhase("uploading")
|
|
||||||
|
|
||||||
let fileToUpload: File
|
|
||||||
if (selectedFile) {
|
|
||||||
fileToUpload = selectedFile
|
|
||||||
} else {
|
|
||||||
fileToUpload = new File([recordedBlob!], `recorded-${Date.now()}.webm`, {
|
|
||||||
type: "audio/webm",
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
const formData = new FormData()
|
|
||||||
formData.append("file", fileToUpload)
|
|
||||||
const uploadResult = await uploadAsset(formData)
|
|
||||||
|
|
||||||
// 阶段 2:克隆
|
|
||||||
setPhase("cloning")
|
|
||||||
const result = await createVoiceClone({
|
|
||||||
name: voiceName.trim(),
|
|
||||||
description: voiceDescription.trim() || undefined,
|
|
||||||
audio_url: uploadResult.url,
|
|
||||||
})
|
|
||||||
|
|
||||||
// 阶段 3:完成
|
|
||||||
setPhase("done")
|
|
||||||
|
|
||||||
// 2秒后自动关闭
|
|
||||||
timerRef.current = setTimeout(() => {
|
|
||||||
onSuccess?.(toVoiceClone(result))
|
|
||||||
handleClose()
|
|
||||||
}, 2000)
|
|
||||||
} catch (err) {
|
|
||||||
setPhase("input")
|
|
||||||
setErrorMessage(err instanceof Error ? err.message : "克隆失败,请重试")
|
|
||||||
}
|
|
||||||
}, [
|
|
||||||
validateForm,
|
|
||||||
selectedFile,
|
|
||||||
recordedBlob,
|
|
||||||
voiceName,
|
|
||||||
voiceDescription,
|
|
||||||
onSuccess,
|
onSuccess,
|
||||||
handleClose,
|
onClose: formState.handleClose,
|
||||||
])
|
})
|
||||||
|
|
||||||
return {
|
return {
|
||||||
phase,
|
phase: formState.phase,
|
||||||
voiceName,
|
voiceName: formState.voiceName,
|
||||||
voiceDescription,
|
voiceDescription: formState.voiceDescription,
|
||||||
selectedFile,
|
selectedFile: formState.selectedFile,
|
||||||
dragActive,
|
dragActive: formState.dragActive,
|
||||||
errorMessage,
|
errorMessage: formState.errorMessage,
|
||||||
isRecording,
|
isRecording: formState.isRecording,
|
||||||
recordTime,
|
recordTime: formState.recordTime,
|
||||||
recordedBlob,
|
recordedBlob: formState.recordedBlob,
|
||||||
canSubmit,
|
canSubmit: formState.canSubmit,
|
||||||
isProcessing,
|
isProcessing: formState.isProcessing,
|
||||||
setVoiceName,
|
setVoiceName: formState.setVoiceName,
|
||||||
setVoiceDescription,
|
setVoiceDescription: formState.setVoiceDescription,
|
||||||
setDragActive,
|
setDragActive: formState.setDragActive,
|
||||||
handleFileSelect,
|
handleFileSelect: formState.handleFileSelect,
|
||||||
handleRecordToggle,
|
handleRecordToggle: formState.handleRecordToggle,
|
||||||
handleClose,
|
handleClose: formState.handleClose,
|
||||||
handleSubmit,
|
handleSubmit,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+108
@@ -0,0 +1,108 @@
|
|||||||
|
import { useRef, useCallback, useEffect } from "react"
|
||||||
|
import { createVoiceClone, toVoiceClone } from "@/api/voice-clone"
|
||||||
|
import { uploadAsset, ensureDefaultLibrary } from "@/api/assets"
|
||||||
|
import { getOrCreateDefaultProject } from "@/api/projects"
|
||||||
|
import type { VoiceClone } from "@/api/voice-clone"
|
||||||
|
|
||||||
|
interface UseCloneSubmitOptions {
|
||||||
|
voiceName: string
|
||||||
|
voiceDescription: string
|
||||||
|
selectedFile: File | null
|
||||||
|
recordedBlob: Blob | null
|
||||||
|
setPhase: (phase: "input" | "uploading" | "cloning" | "done") => void
|
||||||
|
setErrorMessage: (msg: string) => void
|
||||||
|
validateForm: () => string | null
|
||||||
|
onSuccess?: (clone: VoiceClone) => void
|
||||||
|
onClose: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 克隆提交流程 Hook
|
||||||
|
* 封装上传 + 克隆 + 完成的三阶段流程
|
||||||
|
*/
|
||||||
|
export function useCloneSubmit({
|
||||||
|
voiceName,
|
||||||
|
voiceDescription,
|
||||||
|
selectedFile,
|
||||||
|
recordedBlob,
|
||||||
|
setPhase,
|
||||||
|
setErrorMessage,
|
||||||
|
validateForm,
|
||||||
|
onSuccess,
|
||||||
|
onClose,
|
||||||
|
}: UseCloneSubmitOptions) {
|
||||||
|
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||||
|
|
||||||
|
/** 组件卸载时清理定时器 */
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
if (timerRef.current) clearTimeout(timerRef.current)
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const handleSubmit = useCallback(async () => {
|
||||||
|
const formError = validateForm()
|
||||||
|
if (formError) {
|
||||||
|
setErrorMessage(formError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setErrorMessage("")
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 阶段 1:上传音频
|
||||||
|
setPhase("uploading")
|
||||||
|
|
||||||
|
let fileToUpload: File
|
||||||
|
if (selectedFile) {
|
||||||
|
fileToUpload = selectedFile
|
||||||
|
} else {
|
||||||
|
fileToUpload = new File([recordedBlob!], `recorded-${Date.now()}.webm`, {
|
||||||
|
type: "audio/webm",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取默认项目和素材库(后端 /upload 接口必填)
|
||||||
|
const project = await getOrCreateDefaultProject()
|
||||||
|
const library = await ensureDefaultLibrary({ project_id: project.id, kind: "voice" })
|
||||||
|
|
||||||
|
const formData = new FormData()
|
||||||
|
formData.append("file", fileToUpload)
|
||||||
|
formData.append("project_id", project.id)
|
||||||
|
formData.append("library_id", library.id)
|
||||||
|
const uploadResult = await uploadAsset(formData)
|
||||||
|
|
||||||
|
// 阶段 2:克隆
|
||||||
|
setPhase("cloning")
|
||||||
|
const result = await createVoiceClone({
|
||||||
|
name: voiceName.trim(),
|
||||||
|
description: voiceDescription.trim() || undefined,
|
||||||
|
audio_url: uploadResult.url,
|
||||||
|
})
|
||||||
|
|
||||||
|
// 阶段 3:完成
|
||||||
|
setPhase("done")
|
||||||
|
|
||||||
|
// 2秒后自动关闭
|
||||||
|
timerRef.current = setTimeout(() => {
|
||||||
|
onSuccess?.(toVoiceClone(result))
|
||||||
|
onClose()
|
||||||
|
}, 2000)
|
||||||
|
} catch (err) {
|
||||||
|
setPhase("input")
|
||||||
|
setErrorMessage(err instanceof Error ? err.message : "克隆失败,请重试")
|
||||||
|
}
|
||||||
|
}, [
|
||||||
|
validateForm,
|
||||||
|
selectedFile,
|
||||||
|
recordedBlob,
|
||||||
|
voiceName,
|
||||||
|
voiceDescription,
|
||||||
|
setPhase,
|
||||||
|
setErrorMessage,
|
||||||
|
onSuccess,
|
||||||
|
onClose,
|
||||||
|
])
|
||||||
|
|
||||||
|
return { handleSubmit }
|
||||||
|
}
|
||||||
Regular → Executable
+36
-184
@@ -1,116 +1,35 @@
|
|||||||
/**
|
/**
|
||||||
* 查重上传页面 — V21 设计系统
|
* 查重上传页面 — V21 设计系统
|
||||||
* 左右分栏:拖拽上传区 + 格式说明
|
* 左右分栏:拖拽上传区 + 格式说明
|
||||||
* 零 antd 依赖
|
|
||||||
*/
|
*/
|
||||||
import React, { useState, useRef, useCallback } from "react"
|
import React from "react"
|
||||||
import { useMutation } from "@tanstack/react-query"
|
|
||||||
import { Button, Card, Tag } from "@/components/ui"
|
|
||||||
import { uploadForDuplication } from "@/api/duplication"
|
|
||||||
import { useNavigate } from "react-router-dom"
|
import { useNavigate } from "react-router-dom"
|
||||||
import "./duplication.css"
|
import { Card } from "@/components/ui"
|
||||||
import PageHead from "@/components/layout/PageHead"
|
import PageHead from "@/components/layout/PageHead"
|
||||||
|
import UploadZone from "./duplication-upload/UploadZone"
|
||||||
/** 支持的视频格式 */
|
import UploadActions from "./duplication-upload/UploadActions"
|
||||||
const ACCEPT_FORMATS = ".mp4,.avi,.mov,.mkv,.wmv,.flv,.webm"
|
import UploadProgress from "./duplication-upload/UploadProgress"
|
||||||
const FORMAT_LIST = ["MP4", "AVI", "MOV", "MKV", "WMV", "FLV", "WebM"]
|
import UploadResultPanel from "./duplication-upload/UploadResultPanel"
|
||||||
/** 最大文件大小:2GB */
|
import InfoSidebar from "./duplication-upload/InfoSidebar"
|
||||||
const MAX_FILE_SIZE = 2 * 1024 * 1024 * 1024
|
import { useDuplicationUpload } from "./duplication-upload/useDuplicationUpload"
|
||||||
|
import { ACCEPT_FORMATS } from "./duplication-upload/constants"
|
||||||
/** 简易 toast */
|
import "./duplication.css"
|
||||||
interface ToastState {
|
|
||||||
message: string
|
|
||||||
type: "success" | "error" | "warning"
|
|
||||||
}
|
|
||||||
|
|
||||||
const DuplicationUpload: React.FC = () => {
|
const DuplicationUpload: React.FC = () => {
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null)
|
const {
|
||||||
const [dragging, setDragging] = useState(false)
|
fileInputRef,
|
||||||
const [uploading, setUploading] = useState(false)
|
dragging,
|
||||||
const [uploadResult, setUploadResult] = useState<{
|
uploading,
|
||||||
id: string
|
uploadResult,
|
||||||
message: string
|
toast,
|
||||||
} | null>(null)
|
handleDragOver,
|
||||||
const [toast, setToast] = useState<ToastState | null>(null)
|
handleDragLeave,
|
||||||
|
handleDrop,
|
||||||
/** 显示 toast */
|
handleSelectFile,
|
||||||
const showToast = useCallback((message: string, type: "success" | "error" | "warning") => {
|
handleFileChange,
|
||||||
setToast({ message, type })
|
handleReset,
|
||||||
setTimeout(() => setToast(null), 3000)
|
} = useDuplicationUpload()
|
||||||
}, [])
|
|
||||||
|
|
||||||
// 上传查重 mutation
|
|
||||||
const uploadMutation = useMutation({
|
|
||||||
mutationFn: (file: File) => uploadForDuplication(file),
|
|
||||||
onSuccess: (data) => {
|
|
||||||
setUploading(false)
|
|
||||||
setUploadResult({ id: data.id, message: data.message })
|
|
||||||
showToast("查重任务已提交", "success")
|
|
||||||
},
|
|
||||||
onError: () => {
|
|
||||||
setUploading(false)
|
|
||||||
showToast("上传失败,请重试", "error")
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
/** 校验并上传文件 */
|
|
||||||
const handleFile = useCallback(
|
|
||||||
(file: File) => {
|
|
||||||
if (file.size > MAX_FILE_SIZE) {
|
|
||||||
showToast("文件大小不能超过 2GB", "error")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
const ext = file.name.toLowerCase().split(".").pop()
|
|
||||||
const allowedExts = ACCEPT_FORMATS.replace(/\./g, "").split(",")
|
|
||||||
if (!allowedExts.includes(ext || "")) {
|
|
||||||
showToast(`不支持的文件格式,支持:${FORMAT_LIST.join("、")}`, "error")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
setUploading(true)
|
|
||||||
setUploadResult(null)
|
|
||||||
uploadMutation.mutate(file)
|
|
||||||
},
|
|
||||||
[uploadMutation, showToast],
|
|
||||||
)
|
|
||||||
|
|
||||||
/** 拖拽事件 */
|
|
||||||
const handleDragOver = useCallback((e: React.DragEvent) => {
|
|
||||||
e.preventDefault()
|
|
||||||
setDragging(true)
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
const handleDragLeave = useCallback(() => {
|
|
||||||
setDragging(false)
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
const handleDrop = useCallback(
|
|
||||||
(e: React.DragEvent) => {
|
|
||||||
e.preventDefault()
|
|
||||||
setDragging(false)
|
|
||||||
const file = e.dataTransfer.files[0]
|
|
||||||
if (file) handleFile(file)
|
|
||||||
},
|
|
||||||
[handleFile],
|
|
||||||
)
|
|
||||||
|
|
||||||
/** 点击选择文件 */
|
|
||||||
const handleSelectFile = () => {
|
|
||||||
fileInputRef.current?.click()
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
|
||||||
const file = e.target.files?.[0]
|
|
||||||
if (file) handleFile(file)
|
|
||||||
// 重置 input 以便重复选择同一文件
|
|
||||||
e.target.value = ""
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 重置状态 */
|
|
||||||
const handleReset = () => {
|
|
||||||
setUploadResult(null)
|
|
||||||
setUploading(false)
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="dup-page">
|
<div className="dup-page">
|
||||||
@@ -125,25 +44,14 @@ const DuplicationUpload: React.FC = () => {
|
|||||||
<div className="dup-upload-grid">
|
<div className="dup-upload-grid">
|
||||||
{/* 左侧:上传区域 */}
|
{/* 左侧:上传区域 */}
|
||||||
<Card>
|
<Card>
|
||||||
{/* 拖拽上传区 */}
|
<UploadZone
|
||||||
<div
|
dragging={dragging}
|
||||||
className={`dup-upload-zone ${dragging ? "dragging" : ""} ${uploading ? "disabled" : ""}`}
|
uploading={uploading}
|
||||||
onDragOver={handleDragOver}
|
onDragOver={handleDragOver}
|
||||||
onDragLeave={handleDragLeave}
|
onDragLeave={handleDragLeave}
|
||||||
onDrop={handleDrop}
|
onDrop={handleDrop}
|
||||||
onClick={uploading ? undefined : handleSelectFile}
|
onClick={handleSelectFile}
|
||||||
>
|
/>
|
||||||
<div className="dup-upload-icon">{uploading ? "⏳" : "📁"}</div>
|
|
||||||
<h3>{uploading ? "正在上传并查重..." : "点击或拖拽视频文件到此区域"}</h3>
|
|
||||||
<p>支持 MP4、AVI、MOV、MKV 等格式,单个文件不超过 2GB</p>
|
|
||||||
<div className="dup-upload-formats">
|
|
||||||
{FORMAT_LIST.map((fmt) => (
|
|
||||||
<Tag key={fmt} variant="info">
|
|
||||||
{fmt}
|
|
||||||
</Tag>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 隐藏的文件 input */}
|
{/* 隐藏的文件 input */}
|
||||||
<input
|
<input
|
||||||
@@ -154,77 +62,21 @@ const DuplicationUpload: React.FC = () => {
|
|||||||
onChange={handleFileChange}
|
onChange={handleFileChange}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* 上传按钮 */}
|
<UploadActions uploading={uploading} onSelectFile={handleSelectFile} />
|
||||||
<div className="dup-upload-actions">
|
|
||||||
<Button
|
|
||||||
buttonType="primary"
|
|
||||||
buttonSize="md"
|
|
||||||
onClick={handleSelectFile}
|
|
||||||
disabled={uploading}
|
|
||||||
>
|
|
||||||
📂 选择文件
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 上传进度 */}
|
{uploading && <UploadProgress />}
|
||||||
{uploading && (
|
|
||||||
<div className="dup-progress">
|
|
||||||
<div className="dup-progress-circle">
|
|
||||||
<span className="dup-progress-icon">⏳</span>
|
|
||||||
<span className="dup-progress-text">查重中...</span>
|
|
||||||
</div>
|
|
||||||
<p>正在分析视频内容,请稍候...</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* 上传结果 */}
|
|
||||||
{uploadResult && !uploading && (
|
{uploadResult && !uploading && (
|
||||||
<div className="dup-result">
|
<UploadResultPanel
|
||||||
<div className="dup-result-icon">✅</div>
|
result={uploadResult}
|
||||||
<h3>查重任务已提交</h3>
|
onViewResult={() => navigate("/app/duplication/results")}
|
||||||
<p>{uploadResult.message}</p>
|
onReset={handleReset}
|
||||||
<div className="dup-result-actions">
|
/>
|
||||||
<Button
|
|
||||||
buttonType="primary"
|
|
||||||
buttonSize="md"
|
|
||||||
onClick={() => navigate("/app/duplication/results")}
|
|
||||||
>
|
|
||||||
查看结果
|
|
||||||
</Button>
|
|
||||||
<Button buttonType="secondary" buttonSize="md" onClick={handleReset}>
|
|
||||||
继续上传
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{/* 右侧:格式说明 + 提示 */}
|
{/* 右侧:格式说明 + 提示 */}
|
||||||
<div className="dup-info-card">
|
<InfoSidebar />
|
||||||
<h3>📋 查重说明</h3>
|
|
||||||
<ul className="dup-info-list">
|
|
||||||
<li>系统会对比您上传的视频与视频库中的已有视频</li>
|
|
||||||
<li>查重完成后,可查看重复片段的具体位置</li>
|
|
||||||
<li>查重过程通常需要几分钟,取决于视频大小</li>
|
|
||||||
<li>高相似度片段建议进行替换或裁剪</li>
|
|
||||||
</ul>
|
|
||||||
|
|
||||||
<h3 style={{ marginTop: 24 }}>🎬 支持格式</h3>
|
|
||||||
<div className="dup-format-tags">
|
|
||||||
{FORMAT_LIST.map((fmt) => (
|
|
||||||
<Tag key={fmt} variant="info">
|
|
||||||
{fmt}
|
|
||||||
</Tag>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<h3 style={{ marginTop: 24 }}>💡 温馨提示</h3>
|
|
||||||
<ul className="dup-info-list">
|
|
||||||
<li>单个文件不超过 2GB</li>
|
|
||||||
<li>视频时长建议不超过 60 分钟</li>
|
|
||||||
<li>查重结果可在「查重记录」中随时查看</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
Regular → Executable
+7
-1
@@ -51,7 +51,13 @@ export const FILTER_OPTIONS: { key: RiskFilter; label: string }[] = [
|
|||||||
{ key: "high", label: "高风险" },
|
{ key: "high", label: "高风险" },
|
||||||
]
|
]
|
||||||
|
|
||||||
/** Toast 类型 */
|
/** 支持的视频格式 */
|
||||||
|
export const ACCEPT_FORMATS = ".mp4,.avi,.mov,.mkv,.wmv,.flv,.webm"
|
||||||
|
export const FORMAT_LIST = ["MP4", "AVI", "MOV", "MKV", "WMV", "FLV", "WebM"]
|
||||||
|
/** 最大文件大小:2GB */
|
||||||
|
export const MAX_FILE_SIZE = 2 * 1024 * 1024 * 1024
|
||||||
|
|
||||||
|
/** 简易 toast */
|
||||||
export interface ToastState {
|
export interface ToastState {
|
||||||
message: string
|
message: string
|
||||||
type: "success" | "error" | "warning"
|
type: "success" | "error" | "warning"
|
||||||
|
|||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import React from "react"
|
||||||
|
import { Tag } from "@/components/ui"
|
||||||
|
import { FORMAT_LIST } from "./constants"
|
||||||
|
|
||||||
|
/** 右侧说明卡 */
|
||||||
|
const InfoSidebar: React.FC = () => {
|
||||||
|
return (
|
||||||
|
<div className="dup-info-card">
|
||||||
|
<h3>📋 查重说明</h3>
|
||||||
|
<ul className="dup-info-list">
|
||||||
|
<li>系统会对比您上传的视频与视频库中的已有视频</li>
|
||||||
|
<li>查重完成后,可查看重复片段的具体位置</li>
|
||||||
|
<li>查重过程通常需要几分钟,取决于视频大小</li>
|
||||||
|
<li>高相似度片段建议进行替换或裁剪</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<h3 style={{ marginTop: 24 }}>🎬 支持格式</h3>
|
||||||
|
<div className="dup-format-tags">
|
||||||
|
{FORMAT_LIST.map((fmt) => (
|
||||||
|
<Tag key={fmt} variant="info">
|
||||||
|
{fmt}
|
||||||
|
</Tag>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h3 style={{ marginTop: 24 }}>💡 温馨提示</h3>
|
||||||
|
<ul className="dup-info-list">
|
||||||
|
<li>单个文件不超过 2GB</li>
|
||||||
|
<li>视频时长建议不超过 60 分钟</li>
|
||||||
|
<li>查重结果可在「查重记录」中随时查看</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default InfoSidebar
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
import React from "react"
|
||||||
|
import { Button } from "@/components/ui"
|
||||||
|
|
||||||
|
interface UploadActionsProps {
|
||||||
|
uploading: boolean
|
||||||
|
onSelectFile: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 上传按钮区 */
|
||||||
|
const UploadActions: React.FC<UploadActionsProps> = ({ uploading, onSelectFile }) => {
|
||||||
|
return (
|
||||||
|
<div className="dup-upload-actions">
|
||||||
|
<Button buttonType="primary" buttonSize="md" onClick={onSelectFile} disabled={uploading}>
|
||||||
|
📂 选择文件
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default UploadActions
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import React from "react"
|
||||||
|
|
||||||
|
/** 上传进度展示 */
|
||||||
|
const UploadProgress: React.FC = () => {
|
||||||
|
return (
|
||||||
|
<div className="dup-progress">
|
||||||
|
<div className="dup-progress-circle">
|
||||||
|
<span className="dup-progress-icon">⏳</span>
|
||||||
|
<span className="dup-progress-text">查重中...</span>
|
||||||
|
</div>
|
||||||
|
<p>正在分析视频内容,请稍候...</p>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default UploadProgress
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
import React from "react"
|
||||||
|
import { Button } from "@/components/ui"
|
||||||
|
import type { UploadResult } from "./constants"
|
||||||
|
|
||||||
|
interface UploadResultPanelProps {
|
||||||
|
result: UploadResult
|
||||||
|
onViewResult: () => void
|
||||||
|
onReset: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 上传结果展示 */
|
||||||
|
const UploadResultPanel: React.FC<UploadResultPanelProps> = ({ result, onViewResult, onReset }) => {
|
||||||
|
return (
|
||||||
|
<div className="dup-result">
|
||||||
|
<div className="dup-result-icon">✅</div>
|
||||||
|
<h3>查重任务已提交</h3>
|
||||||
|
<p>{result.message}</p>
|
||||||
|
<div className="dup-result-actions">
|
||||||
|
<Button buttonType="primary" buttonSize="md" onClick={onViewResult}>
|
||||||
|
查看结果
|
||||||
|
</Button>
|
||||||
|
<Button buttonType="secondary" buttonSize="md" onClick={onReset}>
|
||||||
|
继续上传
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default UploadResultPanel
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
import React from "react"
|
||||||
|
import { Tag } from "@/components/ui"
|
||||||
|
import { FORMAT_LIST } from "./constants"
|
||||||
|
|
||||||
|
interface UploadZoneProps {
|
||||||
|
dragging: boolean
|
||||||
|
uploading: boolean
|
||||||
|
onDragOver: (e: React.DragEvent) => void
|
||||||
|
onDragLeave: () => void
|
||||||
|
onDrop: (e: React.DragEvent) => void
|
||||||
|
onClick: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 拖拽上传区 */
|
||||||
|
const UploadZone: React.FC<UploadZoneProps> = ({
|
||||||
|
dragging,
|
||||||
|
uploading,
|
||||||
|
onDragOver,
|
||||||
|
onDragLeave,
|
||||||
|
onDrop,
|
||||||
|
onClick,
|
||||||
|
}) => {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`dup-upload-zone ${dragging ? "dragging" : ""} ${uploading ? "disabled" : ""}`}
|
||||||
|
onDragOver={onDragOver}
|
||||||
|
onDragLeave={onDragLeave}
|
||||||
|
onDrop={onDrop}
|
||||||
|
onClick={uploading ? undefined : onClick}
|
||||||
|
>
|
||||||
|
<div className="dup-upload-icon">{uploading ? "⏳" : "📁"}</div>
|
||||||
|
<h3>{uploading ? "正在上传并查重..." : "点击或拖拽视频文件到此区域"}</h3>
|
||||||
|
<p>支持 MP4、AVI、MOV、MKV 等格式,单个文件不超过 2GB</p>
|
||||||
|
<div className="dup-upload-formats">
|
||||||
|
{FORMAT_LIST.map((fmt) => (
|
||||||
|
<Tag key={fmt} variant="info">
|
||||||
|
{fmt}
|
||||||
|
</Tag>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default UploadZone
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
/** 支持的视频格式 */
|
||||||
|
export const ACCEPT_FORMATS = ".mp4,.avi,.mov,.mkv,.wmv,.flv,.webm"
|
||||||
|
export const FORMAT_LIST = ["MP4", "AVI", "MOV", "MKV", "WMV", "FLV", "WebM"]
|
||||||
|
/** 最大文件大小:2GB */
|
||||||
|
export const MAX_FILE_SIZE = 2 * 1024 * 1024 * 1024
|
||||||
|
|
||||||
|
/** 简易 toast */
|
||||||
|
export interface ToastState {
|
||||||
|
message: string
|
||||||
|
type: "success" | "error" | "warning"
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 上传结果 */
|
||||||
|
export interface UploadResult {
|
||||||
|
id: string
|
||||||
|
message: string
|
||||||
|
}
|
||||||
@@ -0,0 +1,115 @@
|
|||||||
|
import { useState, useRef, useCallback } from "react"
|
||||||
|
import { useMutation } from "@tanstack/react-query"
|
||||||
|
import { uploadForDuplication } from "@/api/duplication"
|
||||||
|
import { ACCEPT_FORMATS, FORMAT_LIST, MAX_FILE_SIZE } from "./constants"
|
||||||
|
import type { ToastState, UploadResult } from "./constants"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 查重上传逻辑 Hook
|
||||||
|
* 封装文件校验、上传 mutation、toast 提示
|
||||||
|
*/
|
||||||
|
export function useDuplicationUpload() {
|
||||||
|
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||||
|
const [dragging, setDragging] = useState(false)
|
||||||
|
const [uploading, setUploading] = useState(false)
|
||||||
|
const [uploadResult, setUploadResult] = useState<UploadResult | null>(null)
|
||||||
|
const [toast, setToast] = useState<ToastState | null>(null)
|
||||||
|
|
||||||
|
/** 显示 toast */
|
||||||
|
const showToast = useCallback((message: string, type: "success" | "error" | "warning") => {
|
||||||
|
setToast({ message, type })
|
||||||
|
setTimeout(() => setToast(null), 3000)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
// 上传查重 mutation
|
||||||
|
const uploadMutation = useMutation({
|
||||||
|
mutationFn: (file: File) => uploadForDuplication(file),
|
||||||
|
onSuccess: (data) => {
|
||||||
|
setUploading(false)
|
||||||
|
setUploadResult({ id: data.id, message: data.message })
|
||||||
|
showToast("查重任务已提交", "success")
|
||||||
|
},
|
||||||
|
onError: () => {
|
||||||
|
setUploading(false)
|
||||||
|
showToast("上传失败,请重试", "error")
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
/** 校验并上传文件 */
|
||||||
|
const handleFile = useCallback(
|
||||||
|
(file: File) => {
|
||||||
|
if (file.size > MAX_FILE_SIZE) {
|
||||||
|
showToast("文件大小不能超过 2GB", "error")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const ext = file.name.toLowerCase().split(".").pop()
|
||||||
|
const allowedExts = ACCEPT_FORMATS.replace(/\./g, "").split(",")
|
||||||
|
if (!allowedExts.includes(ext || "")) {
|
||||||
|
showToast(`不支持的文件格式,支持:${FORMAT_LIST.join("、")}`, "error")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setUploading(true)
|
||||||
|
setUploadResult(null)
|
||||||
|
uploadMutation.mutate(file)
|
||||||
|
},
|
||||||
|
[uploadMutation, showToast],
|
||||||
|
)
|
||||||
|
|
||||||
|
/** 拖拽事件 */
|
||||||
|
const handleDragOver = useCallback((e: React.DragEvent) => {
|
||||||
|
e.preventDefault()
|
||||||
|
setDragging(true)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const handleDragLeave = useCallback(() => {
|
||||||
|
setDragging(false)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const handleDrop = useCallback(
|
||||||
|
(e: React.DragEvent) => {
|
||||||
|
e.preventDefault()
|
||||||
|
setDragging(false)
|
||||||
|
const file = e.dataTransfer.files[0]
|
||||||
|
if (file) handleFile(file)
|
||||||
|
},
|
||||||
|
[handleFile],
|
||||||
|
)
|
||||||
|
|
||||||
|
/** 点击选择文件 */
|
||||||
|
const handleSelectFile = useCallback(() => {
|
||||||
|
fileInputRef.current?.click()
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const handleFileChange = useCallback(
|
||||||
|
(e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const file = e.target.files?.[0]
|
||||||
|
if (file) handleFile(file)
|
||||||
|
// 重置 input 以便重复选择同一文件
|
||||||
|
e.target.value = ""
|
||||||
|
},
|
||||||
|
[handleFile],
|
||||||
|
)
|
||||||
|
|
||||||
|
/** 重置状态 */
|
||||||
|
const handleReset = useCallback(() => {
|
||||||
|
setUploadResult(null)
|
||||||
|
setUploading(false)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
return {
|
||||||
|
// refs
|
||||||
|
fileInputRef,
|
||||||
|
// 状态
|
||||||
|
dragging,
|
||||||
|
uploading,
|
||||||
|
uploadResult,
|
||||||
|
toast,
|
||||||
|
// 事件
|
||||||
|
handleDragOver,
|
||||||
|
handleDragLeave,
|
||||||
|
handleDrop,
|
||||||
|
handleSelectFile,
|
||||||
|
handleFileChange,
|
||||||
|
handleReset,
|
||||||
|
}
|
||||||
|
}
|
||||||
Regular → Executable
+21
-130
@@ -1,15 +1,11 @@
|
|||||||
/**
|
|
||||||
* 绿幕抠像配置面板
|
|
||||||
* 5 种颜色预设 + 自定义颜色 + 相似度/边缘平滑/溢色抑制
|
|
||||||
*/
|
|
||||||
import React, { useCallback } from "react"
|
import React, { useCallback } from "react"
|
||||||
import { Drawer, Switch } from "antd"
|
import { Drawer, Switch } from "antd"
|
||||||
import type { ChromaKeyConfig, ChromaKeyColorPreset } from "../types"
|
import type { ChromaKeyConfig, ChromaKeyColorPreset } from "../types"
|
||||||
import {
|
import { DEFAULT_CHROMA_KEY_CONFIG, CHROMA_KEY_PRESET_COLORS } from "../types"
|
||||||
DEFAULT_CHROMA_KEY_CONFIG,
|
import { GreenScreenPresets } from "./green-screen/GreenScreenPresets"
|
||||||
CHROMA_KEY_PRESET_LABELS,
|
import { GreenScreenCustomColor } from "./green-screen/GreenScreenCustomColor"
|
||||||
CHROMA_KEY_PRESET_COLORS,
|
import { GreenScreenSliders } from "./green-screen/GreenScreenSliders"
|
||||||
} from "../types"
|
import { GreenScreenPreview } from "./green-screen/GreenScreenPreview"
|
||||||
|
|
||||||
interface GreenScreenPanelProps {
|
interface GreenScreenPanelProps {
|
||||||
open: boolean
|
open: boolean
|
||||||
@@ -18,9 +14,6 @@ interface GreenScreenPanelProps {
|
|||||||
onChange: (config: ChromaKeyConfig) => void
|
onChange: (config: ChromaKeyConfig) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 预设列表 */
|
|
||||||
const PRESET_LIST: ChromaKeyColorPreset[] = ["green", "blue", "red", "pure_green", "soft_green"]
|
|
||||||
|
|
||||||
const GreenScreenPanel: React.FC<GreenScreenPanelProps> = ({ open, onClose, config, onChange }) => {
|
const GreenScreenPanel: React.FC<GreenScreenPanelProps> = ({ open, onClose, config, onChange }) => {
|
||||||
const update = useCallback(
|
const update = useCallback(
|
||||||
(partial: Partial<ChromaKeyConfig>) => {
|
(partial: Partial<ChromaKeyConfig>) => {
|
||||||
@@ -33,7 +26,6 @@ const GreenScreenPanel: React.FC<GreenScreenPanelProps> = ({ open, onClose, conf
|
|||||||
onChange({ ...DEFAULT_CHROMA_KEY_CONFIG, enabled: config.enabled })
|
onChange({ ...DEFAULT_CHROMA_KEY_CONFIG, enabled: config.enabled })
|
||||||
}, [config.enabled, onChange])
|
}, [config.enabled, onChange])
|
||||||
|
|
||||||
/** 选择颜色预设时同步更新 color 字段 */
|
|
||||||
const handlePresetSelect = useCallback(
|
const handlePresetSelect = useCallback(
|
||||||
(preset: ChromaKeyColorPreset) => {
|
(preset: ChromaKeyColorPreset) => {
|
||||||
update({
|
update({
|
||||||
@@ -44,10 +36,9 @@ const GreenScreenPanel: React.FC<GreenScreenPanelProps> = ({ open, onClose, conf
|
|||||||
[update],
|
[update],
|
||||||
)
|
)
|
||||||
|
|
||||||
/** 自定义颜色变化时清除预设标记 */
|
|
||||||
const handleColorChange = useCallback(
|
const handleColorChange = useCallback(
|
||||||
(e: React.ChangeEvent<HTMLInputElement>) => {
|
(color: string) => {
|
||||||
update({ color: e.target.value })
|
update({ color })
|
||||||
},
|
},
|
||||||
[update],
|
[update],
|
||||||
)
|
)
|
||||||
@@ -61,7 +52,6 @@ const GreenScreenPanel: React.FC<GreenScreenPanelProps> = ({ open, onClose, conf
|
|||||||
onClose={onClose}
|
onClose={onClose}
|
||||||
className="green-screen-panel-drawer"
|
className="green-screen-panel-drawer"
|
||||||
>
|
>
|
||||||
{/* 顶部开关 */}
|
|
||||||
<div className="green-header">
|
<div className="green-header">
|
||||||
<span className="green-header-label">启用绿幕抠像</span>
|
<span className="green-header-label">启用绿幕抠像</span>
|
||||||
<Switch
|
<Switch
|
||||||
@@ -71,123 +61,24 @@ const GreenScreenPanel: React.FC<GreenScreenPanelProps> = ({ open, onClose, conf
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 颜色预设 */}
|
<GreenScreenPresets
|
||||||
<div className="green-section">
|
selectedPreset={config.color_preset}
|
||||||
<div className="green-section-title">颜色预设</div>
|
onPresetSelect={handlePresetSelect}
|
||||||
<div className="green-presets">
|
/>
|
||||||
{PRESET_LIST.map((p) => (
|
|
||||||
<button
|
|
||||||
key={p}
|
|
||||||
className={`green-preset-btn${config.color_preset === p ? " active" : ""}`}
|
|
||||||
onClick={() => handlePresetSelect(p)}
|
|
||||||
>
|
|
||||||
<span
|
|
||||||
className="green-preset-dot"
|
|
||||||
style={{ background: CHROMA_KEY_PRESET_COLORS[p] }}
|
|
||||||
/>
|
|
||||||
<span className="green-preset-label">{CHROMA_KEY_PRESET_LABELS[p]}</span>
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 自定义颜色 */}
|
<GreenScreenCustomColor color={config.color} onColorChange={handleColorChange} />
|
||||||
<div className="green-section">
|
|
||||||
<div className="green-section-title">自定义颜色</div>
|
|
||||||
<div className="green-color-row">
|
|
||||||
<input
|
|
||||||
type="color"
|
|
||||||
className="green-color-picker"
|
|
||||||
value={config.color}
|
|
||||||
onChange={handleColorChange}
|
|
||||||
/>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
className="green-color-hex"
|
|
||||||
value={config.color}
|
|
||||||
onChange={handleColorChange}
|
|
||||||
placeholder="#00FF00"
|
|
||||||
/>
|
|
||||||
<div className="green-color-swatch" style={{ background: config.color }} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 参数调节 */}
|
<GreenScreenSliders
|
||||||
<div className="green-section">
|
similarity={config.similarity}
|
||||||
<div className="green-section-title">参数调节</div>
|
blend={config.blend}
|
||||||
|
spill={config.spill}
|
||||||
|
onSimilarityChange={(v) => update({ similarity: v })}
|
||||||
|
onBlendChange={(v) => update({ blend: v })}
|
||||||
|
onSpillChange={(v) => update({ spill: v })}
|
||||||
|
/>
|
||||||
|
|
||||||
{/* 相似度 */}
|
<GreenScreenPreview color={config.color} blend={config.blend} />
|
||||||
<div className="green-slider-row">
|
|
||||||
<div className="green-slider-header">
|
|
||||||
<span className="green-slider-label">相似度</span>
|
|
||||||
<span className="green-slider-value">{config.similarity}%</span>
|
|
||||||
</div>
|
|
||||||
<input
|
|
||||||
type="range"
|
|
||||||
className="green-slider"
|
|
||||||
min={0}
|
|
||||||
max={100}
|
|
||||||
value={config.similarity}
|
|
||||||
onChange={(e) => update({ similarity: Number(e.target.value) })}
|
|
||||||
/>
|
|
||||||
<div className="green-slider-desc">越大容忍的色差范围越广</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 边缘平滑 */}
|
|
||||||
<div className="green-slider-row">
|
|
||||||
<div className="green-slider-header">
|
|
||||||
<span className="green-slider-label">边缘平滑</span>
|
|
||||||
<span className="green-slider-value">{config.blend}%</span>
|
|
||||||
</div>
|
|
||||||
<input
|
|
||||||
type="range"
|
|
||||||
className="green-slider"
|
|
||||||
min={0}
|
|
||||||
max={100}
|
|
||||||
value={config.blend}
|
|
||||||
onChange={(e) => update({ blend: Number(e.target.value) })}
|
|
||||||
/>
|
|
||||||
<div className="green-slider-desc">越大边缘越柔和自然</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 溢色抑制 */}
|
|
||||||
<div className="green-slider-row">
|
|
||||||
<div className="green-slider-header">
|
|
||||||
<span className="green-slider-label">溢色抑制</span>
|
|
||||||
<span className="green-slider-value">{config.spill}%</span>
|
|
||||||
</div>
|
|
||||||
<input
|
|
||||||
type="range"
|
|
||||||
className="green-slider"
|
|
||||||
min={0}
|
|
||||||
max={100}
|
|
||||||
value={config.spill}
|
|
||||||
onChange={(e) => update({ spill: Number(e.target.value) })}
|
|
||||||
/>
|
|
||||||
<div className="green-slider-desc">去除边缘颜色溢出</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 预览 */}
|
|
||||||
<div className="green-section">
|
|
||||||
<div className="green-section-title">效果预览</div>
|
|
||||||
<div className="green-preview-box">
|
|
||||||
<div className="green-preview-bg" style={{ background: config.color, opacity: 0.3 }} />
|
|
||||||
<div className="green-preview-subject">
|
|
||||||
<div className="green-preview-circle" />
|
|
||||||
<div className="green-preview-text">主体</div>
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
className="green-preview-edge"
|
|
||||||
style={{
|
|
||||||
borderColor: config.color,
|
|
||||||
filter: `blur(${config.blend / 10}px)`,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 底部重置 */}
|
|
||||||
<div className="green-footer">
|
<div className="green-footer">
|
||||||
<button className="green-reset-btn" onClick={handleReset}>
|
<button className="green-reset-btn" onClick={handleReset}>
|
||||||
重置参数
|
重置参数
|
||||||
|
|||||||
+37
@@ -0,0 +1,37 @@
|
|||||||
|
import React from "react"
|
||||||
|
|
||||||
|
interface GreenScreenCustomColorProps {
|
||||||
|
color: string
|
||||||
|
onColorChange: (color: string) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export const GreenScreenCustomColor: React.FC<GreenScreenCustomColorProps> = ({
|
||||||
|
color,
|
||||||
|
onColorChange,
|
||||||
|
}) => {
|
||||||
|
const handleColorInput = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
onColorChange(e.target.value)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="green-section">
|
||||||
|
<div className="green-section-title">自定义颜色</div>
|
||||||
|
<div className="green-color-row">
|
||||||
|
<input
|
||||||
|
type="color"
|
||||||
|
className="green-color-picker"
|
||||||
|
value={color}
|
||||||
|
onChange={handleColorInput}
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
className="green-color-hex"
|
||||||
|
value={color}
|
||||||
|
onChange={handleColorInput}
|
||||||
|
placeholder="#00FF00"
|
||||||
|
/>
|
||||||
|
<div className="green-color-swatch" style={{ background: color }} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
+36
@@ -0,0 +1,36 @@
|
|||||||
|
import React from "react"
|
||||||
|
import type { ChromaKeyColorPreset } from "../../types"
|
||||||
|
import { CHROMA_KEY_PRESET_LABELS, CHROMA_KEY_PRESET_COLORS } from "../../types"
|
||||||
|
|
||||||
|
interface GreenScreenPresetsProps {
|
||||||
|
selectedPreset: ChromaKeyColorPreset | null
|
||||||
|
onPresetSelect: (preset: ChromaKeyColorPreset) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
const PRESET_LIST: ChromaKeyColorPreset[] = ["green", "blue", "red", "pure_green", "soft_green"]
|
||||||
|
|
||||||
|
export const GreenScreenPresets: React.FC<GreenScreenPresetsProps> = ({
|
||||||
|
selectedPreset,
|
||||||
|
onPresetSelect,
|
||||||
|
}) => {
|
||||||
|
return (
|
||||||
|
<div className="green-section">
|
||||||
|
<div className="green-section-title">颜色预设</div>
|
||||||
|
<div className="green-presets">
|
||||||
|
{PRESET_LIST.map((p) => (
|
||||||
|
<button
|
||||||
|
key={p}
|
||||||
|
className={`green-preset-btn${selectedPreset === p ? " active" : ""}`}
|
||||||
|
onClick={() => onPresetSelect(p)}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className="green-preset-dot"
|
||||||
|
style={{ background: CHROMA_KEY_PRESET_COLORS[p] }}
|
||||||
|
/>
|
||||||
|
<span className="green-preset-label">{CHROMA_KEY_PRESET_LABELS[p]}</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
+28
@@ -0,0 +1,28 @@
|
|||||||
|
import React from "react"
|
||||||
|
|
||||||
|
interface GreenScreenPreviewProps {
|
||||||
|
color: string
|
||||||
|
blend: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export const GreenScreenPreview: React.FC<GreenScreenPreviewProps> = ({ color, blend }) => {
|
||||||
|
return (
|
||||||
|
<div className="green-section">
|
||||||
|
<div className="green-section-title">效果预览</div>
|
||||||
|
<div className="green-preview-box">
|
||||||
|
<div className="green-preview-bg" style={{ background: color, opacity: 0.3 }} />
|
||||||
|
<div className="green-preview-subject">
|
||||||
|
<div className="green-preview-circle" />
|
||||||
|
<div className="green-preview-text">主体</div>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className="green-preview-edge"
|
||||||
|
style={{
|
||||||
|
borderColor: color,
|
||||||
|
filter: `blur(${blend / 10}px)`,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
+68
@@ -0,0 +1,68 @@
|
|||||||
|
import React from "react"
|
||||||
|
|
||||||
|
interface SliderConfig {
|
||||||
|
label: string
|
||||||
|
value: number
|
||||||
|
description: string
|
||||||
|
onChange: (value: number) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
interface GreenScreenSlidersProps {
|
||||||
|
similarity: number
|
||||||
|
blend: number
|
||||||
|
spill: number
|
||||||
|
onSimilarityChange: (value: number) => void
|
||||||
|
onBlendChange: (value: number) => void
|
||||||
|
onSpillChange: (value: number) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
const SliderRow: React.FC<SliderConfig> = ({ label, value, description, onChange }) => (
|
||||||
|
<div className="green-slider-row">
|
||||||
|
<div className="green-slider-header">
|
||||||
|
<span className="green-slider-label">{label}</span>
|
||||||
|
<span className="green-slider-value">{value}%</span>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
className="green-slider"
|
||||||
|
min={0}
|
||||||
|
max={100}
|
||||||
|
value={value}
|
||||||
|
onChange={(e) => onChange(Number(e.target.value))}
|
||||||
|
/>
|
||||||
|
<div className="green-slider-desc">{description}</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
|
||||||
|
export const GreenScreenSliders: React.FC<GreenScreenSlidersProps> = ({
|
||||||
|
similarity,
|
||||||
|
blend,
|
||||||
|
spill,
|
||||||
|
onSimilarityChange,
|
||||||
|
onBlendChange,
|
||||||
|
onSpillChange,
|
||||||
|
}) => {
|
||||||
|
return (
|
||||||
|
<div className="green-section">
|
||||||
|
<div className="green-section-title">参数调节</div>
|
||||||
|
<SliderRow
|
||||||
|
label="相似度"
|
||||||
|
value={similarity}
|
||||||
|
description="越大容忍的色差范围越广"
|
||||||
|
onChange={onSimilarityChange}
|
||||||
|
/>
|
||||||
|
<SliderRow
|
||||||
|
label="边缘平滑"
|
||||||
|
value={blend}
|
||||||
|
description="越大边缘越柔和自然"
|
||||||
|
onChange={onBlendChange}
|
||||||
|
/>
|
||||||
|
<SliderRow
|
||||||
|
label="溢色抑制"
|
||||||
|
value={spill}
|
||||||
|
description="去除边缘颜色溢出"
|
||||||
|
onChange={onSpillChange}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,116 @@
|
|||||||
|
import { useState, useRef, useCallback, useEffect, useMemo, useLayoutEffect } from "react"
|
||||||
|
import type { ClipType } from "../../types"
|
||||||
|
import { DEFAULT_ADD_DURATION, ADD_PICKER_WIDTH, TRACK_GAP } from "../../constants/timeline"
|
||||||
|
|
||||||
|
interface UseAddPickerOptions {
|
||||||
|
currentMode: string
|
||||||
|
onAddClip: (type: ClipType, duration: number) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useAddPicker({ currentMode, onAddClip }: UseAddPickerOptions) {
|
||||||
|
const [showAddPicker, setShowAddPicker] = useState(false)
|
||||||
|
const pickerRef = useRef<HTMLDivElement>(null)
|
||||||
|
const addCardRef = useRef<HTMLDivElement>(null)
|
||||||
|
const [pickerPos, setPickerPos] = useState<{ top: number; right: number }>({ top: 0, right: 0 })
|
||||||
|
|
||||||
|
const availableTypes: ClipType[] = useMemo(
|
||||||
|
() =>
|
||||||
|
currentMode === "voice_over" ? ["voice"] : currentMode === "pip" ? ["pip"] : ["voice", "pip"],
|
||||||
|
[currentMode],
|
||||||
|
)
|
||||||
|
|
||||||
|
const defaultAddType: ClipType = useMemo(() => {
|
||||||
|
if (currentMode === "voice_over") return "voice"
|
||||||
|
if (currentMode === "pip") return "pip"
|
||||||
|
return "voice"
|
||||||
|
}, [currentMode])
|
||||||
|
|
||||||
|
const [addType, setAddType] = useState<ClipType>(defaultAddType)
|
||||||
|
const [addDuration, setAddDuration] = useState<number>(DEFAULT_ADD_DURATION)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!availableTypes.includes(addType)) {
|
||||||
|
setAddType(defaultAddType)
|
||||||
|
}
|
||||||
|
}, [currentMode, addType, availableTypes, defaultAddType])
|
||||||
|
|
||||||
|
const updatePickerPosition = useCallback(() => {
|
||||||
|
if (!addCardRef.current) return
|
||||||
|
const rect = addCardRef.current.getBoundingClientRect()
|
||||||
|
const vw = window.innerWidth
|
||||||
|
const roughHeight = 180
|
||||||
|
let top = rect.top - TRACK_GAP - roughHeight
|
||||||
|
if (top < 8) top = 8
|
||||||
|
let right = vw - rect.right
|
||||||
|
if (rect.right - ADD_PICKER_WIDTH < 8) {
|
||||||
|
right = vw - ADD_PICKER_WIDTH - 8
|
||||||
|
}
|
||||||
|
setPickerPos({ top, right })
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const handleTogglePicker = useCallback(() => {
|
||||||
|
if (!showAddPicker) {
|
||||||
|
setAddType(defaultAddType)
|
||||||
|
updatePickerPosition()
|
||||||
|
}
|
||||||
|
setShowAddPicker((v) => !v)
|
||||||
|
}, [showAddPicker, defaultAddType, updatePickerPosition])
|
||||||
|
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
if (!showAddPicker || !pickerRef.current || !addCardRef.current) return
|
||||||
|
const pickerEl = pickerRef.current
|
||||||
|
const addRect = addCardRef.current.getBoundingClientRect()
|
||||||
|
const pickerH = pickerEl.offsetHeight
|
||||||
|
const vh = window.innerHeight
|
||||||
|
const vw = window.innerWidth
|
||||||
|
|
||||||
|
let top = addRect.top - TRACK_GAP - pickerH
|
||||||
|
if (top < 8) {
|
||||||
|
top = addRect.bottom + TRACK_GAP
|
||||||
|
if (top + pickerH > vh - 8) {
|
||||||
|
top = vh - 8 - pickerH
|
||||||
|
if (top < 8) top = 8
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let right = vw - addRect.right
|
||||||
|
const pickerRect = pickerEl.getBoundingClientRect()
|
||||||
|
if (pickerRect.left < 8) {
|
||||||
|
right = vw - ADD_PICKER_WIDTH - 8
|
||||||
|
}
|
||||||
|
|
||||||
|
setPickerPos({ top, right })
|
||||||
|
}, [showAddPicker])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handleClickOutside = (e: MouseEvent) => {
|
||||||
|
if (pickerRef.current && !pickerRef.current.contains(e.target as Node)) {
|
||||||
|
setShowAddPicker(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (showAddPicker) {
|
||||||
|
document.addEventListener("mousedown", handleClickOutside)
|
||||||
|
}
|
||||||
|
return () => document.removeEventListener("mousedown", handleClickOutside)
|
||||||
|
}, [showAddPicker])
|
||||||
|
|
||||||
|
const handleConfirmAdd = useCallback(() => {
|
||||||
|
onAddClip(addType, addDuration)
|
||||||
|
setShowAddPicker(false)
|
||||||
|
}, [onAddClip, addType, addDuration])
|
||||||
|
|
||||||
|
return {
|
||||||
|
showAddPicker,
|
||||||
|
setShowAddPicker,
|
||||||
|
pickerRef,
|
||||||
|
addCardRef,
|
||||||
|
pickerPos,
|
||||||
|
availableTypes,
|
||||||
|
addType,
|
||||||
|
addDuration,
|
||||||
|
setAddType,
|
||||||
|
setAddDuration,
|
||||||
|
handleTogglePicker,
|
||||||
|
handleConfirmAdd,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
import { useState, useRef, useCallback, useEffect } from "react"
|
||||||
|
|
||||||
|
interface ContextMenuState {
|
||||||
|
x: number
|
||||||
|
y: number
|
||||||
|
clipId: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface UseContextMenuOptions {
|
||||||
|
onClipSplit?: (clipId: string, splitRatio: number) => void
|
||||||
|
onClipResetTrim?: (clipId: string) => void
|
||||||
|
onClipRemove?: (clipId: string) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useContextMenu({
|
||||||
|
onClipSplit,
|
||||||
|
onClipResetTrim,
|
||||||
|
onClipRemove,
|
||||||
|
}: UseContextMenuOptions) {
|
||||||
|
const [contextMenu, setContextMenu] = useState<ContextMenuState | null>(null)
|
||||||
|
const contextMenuRef = useRef<HTMLDivElement>(null)
|
||||||
|
|
||||||
|
const handleContextMenu = useCallback((e: React.MouseEvent, clipId: string) => {
|
||||||
|
e.preventDefault()
|
||||||
|
e.stopPropagation()
|
||||||
|
setContextMenu({ x: e.clientX, y: e.clientY, clipId })
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const handleContextSplit = useCallback(() => {
|
||||||
|
if (!contextMenu) return
|
||||||
|
onClipSplit?.(contextMenu.clipId, 0.5)
|
||||||
|
setContextMenu(null)
|
||||||
|
}, [contextMenu, onClipSplit])
|
||||||
|
|
||||||
|
const handleContextResetTrim = useCallback(() => {
|
||||||
|
if (!contextMenu) return
|
||||||
|
onClipResetTrim?.(contextMenu.clipId)
|
||||||
|
setContextMenu(null)
|
||||||
|
}, [contextMenu, onClipResetTrim])
|
||||||
|
|
||||||
|
const handleContextDelete = useCallback(() => {
|
||||||
|
if (!contextMenu) return
|
||||||
|
onClipRemove?.(contextMenu.clipId)
|
||||||
|
setContextMenu(null)
|
||||||
|
}, [contextMenu, onClipRemove])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handleClickOutside = (e: MouseEvent) => {
|
||||||
|
if (contextMenuRef.current && !contextMenuRef.current.contains(e.target as Node)) {
|
||||||
|
setContextMenu(null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (contextMenu) {
|
||||||
|
document.addEventListener("mousedown", handleClickOutside)
|
||||||
|
}
|
||||||
|
return () => document.removeEventListener("mousedown", handleClickOutside)
|
||||||
|
}, [contextMenu])
|
||||||
|
|
||||||
|
return {
|
||||||
|
contextMenu,
|
||||||
|
setContextMenu,
|
||||||
|
contextMenuRef,
|
||||||
|
handleContextMenu,
|
||||||
|
handleContextSplit,
|
||||||
|
handleContextResetTrim,
|
||||||
|
handleContextDelete,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
import { useCallback } from "react"
|
||||||
|
import { message } from "antd"
|
||||||
|
import { useQueryClient, useMutation } from "@tanstack/react-query"
|
||||||
|
import { createClipsFromAssets } from "@/api/template-editor"
|
||||||
|
|
||||||
|
const QUERY_KEY = "editPlanClips"
|
||||||
|
|
||||||
|
export function useClipImport(planId: string | undefined) {
|
||||||
|
const queryClient = useQueryClient()
|
||||||
|
|
||||||
|
const invalidate = () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: [QUERY_KEY, planId] })
|
||||||
|
}
|
||||||
|
|
||||||
|
const importFromAssetsMutation = useMutation({
|
||||||
|
mutationFn: (assetIds: string[]) => createClipsFromAssets(planId!, assetIds),
|
||||||
|
onSuccess: (res) => {
|
||||||
|
invalidate()
|
||||||
|
message.success(`已导入 ${res.created_count} 个素材片段`)
|
||||||
|
},
|
||||||
|
onError: () => {
|
||||||
|
message.error("导入素材失败")
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const importFromAssets = useCallback(
|
||||||
|
(assetIds: string[]) => {
|
||||||
|
if (!planId || assetIds.length === 0) return
|
||||||
|
importFromAssetsMutation.mutate(assetIds)
|
||||||
|
},
|
||||||
|
[planId, importFromAssetsMutation],
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
importFromAssets,
|
||||||
|
isImporting: importFromAssetsMutation.isPending,
|
||||||
|
}
|
||||||
|
}
|
||||||
+39
@@ -0,0 +1,39 @@
|
|||||||
|
import { useCallback } from "react"
|
||||||
|
import { message } from "antd"
|
||||||
|
import { useQueryClient, useMutation } from "@tanstack/react-query"
|
||||||
|
import type { ClipReorderItem } from "@/api/template-editor"
|
||||||
|
import { reorderEditPlanClips } from "@/api/template-editor"
|
||||||
|
|
||||||
|
const QUERY_KEY = "editPlanClips"
|
||||||
|
|
||||||
|
export function useClipReorder(planId: string | undefined) {
|
||||||
|
const queryClient = useQueryClient()
|
||||||
|
|
||||||
|
const invalidate = () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: [QUERY_KEY, planId] })
|
||||||
|
}
|
||||||
|
|
||||||
|
const reorderMutation = useMutation({
|
||||||
|
mutationFn: (items: ClipReorderItem[]) => reorderEditPlanClips(planId!, items),
|
||||||
|
onSuccess: () => {
|
||||||
|
invalidate()
|
||||||
|
},
|
||||||
|
onError: () => {
|
||||||
|
message.error("排序失败")
|
||||||
|
invalidate()
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const reorderClips = useCallback(
|
||||||
|
(items: ClipReorderItem[]) => {
|
||||||
|
if (!planId || items.length === 0) return
|
||||||
|
reorderMutation.mutate(items)
|
||||||
|
},
|
||||||
|
[planId, reorderMutation],
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
reorderClips,
|
||||||
|
isReordering: reorderMutation.isPending,
|
||||||
|
}
|
||||||
|
}
|
||||||
Regular → Executable
+7
-56
@@ -1,19 +1,15 @@
|
|||||||
import { useCallback } from "react"
|
import { useCallback } from "react"
|
||||||
import { message } from "antd"
|
import { message } from "antd"
|
||||||
import { useQueryClient, useMutation } from "@tanstack/react-query"
|
import { useQueryClient, useMutation } from "@tanstack/react-query"
|
||||||
import type {
|
import type { CreateEditPlanClipRequest, UpdateEditPlanClipRequest } from "@/api/template-editor"
|
||||||
CreateEditPlanClipRequest,
|
|
||||||
UpdateEditPlanClipRequest,
|
|
||||||
ClipReorderItem,
|
|
||||||
} from "@/api/template-editor"
|
|
||||||
import {
|
import {
|
||||||
createEditPlanClip,
|
createEditPlanClip,
|
||||||
updateEditPlanClip,
|
updateEditPlanClip,
|
||||||
deleteEditPlanClip,
|
deleteEditPlanClip,
|
||||||
reorderEditPlanClips,
|
|
||||||
batchDeleteEditPlanClips,
|
batchDeleteEditPlanClips,
|
||||||
createClipsFromAssets,
|
|
||||||
} from "@/api/template-editor"
|
} from "@/api/template-editor"
|
||||||
|
import { useClipReorder } from "./useClipReorder"
|
||||||
|
import { useClipImport } from "./useClipImport"
|
||||||
|
|
||||||
const QUERY_KEY = "editPlanClips"
|
const QUERY_KEY = "editPlanClips"
|
||||||
|
|
||||||
@@ -24,10 +20,6 @@ interface UseEditPlanClipMutationsOptions {
|
|||||||
clipsLength: number
|
clipsLength: number
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* 编辑计划片段 CRUD Hook
|
|
||||||
* 封装创建、更新、删除、批量删除、重排序、素材导入等操作
|
|
||||||
*/
|
|
||||||
export function useEditPlanClipMutations({
|
export function useEditPlanClipMutations({
|
||||||
planId,
|
planId,
|
||||||
selectedClipId,
|
selectedClipId,
|
||||||
@@ -40,7 +32,6 @@ export function useEditPlanClipMutations({
|
|||||||
queryClient.invalidateQueries({ queryKey: [QUERY_KEY, planId] })
|
queryClient.invalidateQueries({ queryKey: [QUERY_KEY, planId] })
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── 创建片段 ── */
|
|
||||||
const createMutation = useMutation({
|
const createMutation = useMutation({
|
||||||
mutationFn: (data: CreateEditPlanClipRequest) => createEditPlanClip(planId!, data),
|
mutationFn: (data: CreateEditPlanClipRequest) => createEditPlanClip(planId!, data),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
@@ -61,7 +52,6 @@ export function useEditPlanClipMutations({
|
|||||||
[planId, clipsLength, createMutation],
|
[planId, clipsLength, createMutation],
|
||||||
)
|
)
|
||||||
|
|
||||||
/* ── 更新片段 ── */
|
|
||||||
const updateMutation = useMutation({
|
const updateMutation = useMutation({
|
||||||
mutationFn: ({ clipId, data }: { clipId: string; data: UpdateEditPlanClipRequest }) =>
|
mutationFn: ({ clipId, data }: { clipId: string; data: UpdateEditPlanClipRequest }) =>
|
||||||
updateEditPlanClip(planId!, clipId, data),
|
updateEditPlanClip(planId!, clipId, data),
|
||||||
@@ -81,7 +71,6 @@ export function useEditPlanClipMutations({
|
|||||||
[planId, updateMutation],
|
[planId, updateMutation],
|
||||||
)
|
)
|
||||||
|
|
||||||
/* ── 删除片段 ── */
|
|
||||||
const deleteMutation = useMutation({
|
const deleteMutation = useMutation({
|
||||||
mutationFn: (clipId: string) => deleteEditPlanClip(planId!, clipId),
|
mutationFn: (clipId: string) => deleteEditPlanClip(planId!, clipId),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
@@ -104,7 +93,6 @@ export function useEditPlanClipMutations({
|
|||||||
[planId, selectedClipId, setSelectedClipId, deleteMutation],
|
[planId, selectedClipId, setSelectedClipId, deleteMutation],
|
||||||
)
|
)
|
||||||
|
|
||||||
/* ── 批量删除 ── */
|
|
||||||
const batchDeleteMutation = useMutation({
|
const batchDeleteMutation = useMutation({
|
||||||
mutationFn: (clipIds: string[]) => batchDeleteEditPlanClips(planId!, clipIds),
|
mutationFn: (clipIds: string[]) => batchDeleteEditPlanClips(planId!, clipIds),
|
||||||
onSuccess: (res) => {
|
onSuccess: (res) => {
|
||||||
@@ -127,45 +115,8 @@ export function useEditPlanClipMutations({
|
|||||||
[planId, selectedClipId, setSelectedClipId, batchDeleteMutation],
|
[planId, selectedClipId, setSelectedClipId, batchDeleteMutation],
|
||||||
)
|
)
|
||||||
|
|
||||||
/* ── 重排序 ── */
|
const { reorderClips, isReordering } = useClipReorder(planId)
|
||||||
const reorderMutation = useMutation({
|
const { importFromAssets, isImporting } = useClipImport(planId)
|
||||||
mutationFn: (items: ClipReorderItem[]) => reorderEditPlanClips(planId!, items),
|
|
||||||
onSuccess: () => {
|
|
||||||
invalidate()
|
|
||||||
},
|
|
||||||
onError: () => {
|
|
||||||
message.error("排序失败")
|
|
||||||
invalidate()
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
const reorderClips = useCallback(
|
|
||||||
(items: ClipReorderItem[]) => {
|
|
||||||
if (!planId || items.length === 0) return
|
|
||||||
reorderMutation.mutate(items)
|
|
||||||
},
|
|
||||||
[planId, reorderMutation],
|
|
||||||
)
|
|
||||||
|
|
||||||
/* ── 从素材批量导入 ── */
|
|
||||||
const importFromAssetsMutation = useMutation({
|
|
||||||
mutationFn: (assetIds: string[]) => createClipsFromAssets(planId!, assetIds),
|
|
||||||
onSuccess: (res) => {
|
|
||||||
invalidate()
|
|
||||||
message.success(`已导入 ${res.created_count} 个素材片段`)
|
|
||||||
},
|
|
||||||
onError: () => {
|
|
||||||
message.error("导入素材失败")
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
const importFromAssets = useCallback(
|
|
||||||
(assetIds: string[]) => {
|
|
||||||
if (!planId || assetIds.length === 0) return
|
|
||||||
importFromAssetsMutation.mutate(assetIds)
|
|
||||||
},
|
|
||||||
[planId, importFromAssetsMutation],
|
|
||||||
)
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
addClip,
|
addClip,
|
||||||
@@ -177,7 +128,7 @@ export function useEditPlanClipMutations({
|
|||||||
isCreating: createMutation.isPending,
|
isCreating: createMutation.isPending,
|
||||||
isUpdating: updateMutation.isPending,
|
isUpdating: updateMutation.isPending,
|
||||||
isDeleting: deleteMutation.isPending,
|
isDeleting: deleteMutation.isPending,
|
||||||
isReordering: reorderMutation.isPending,
|
isReordering,
|
||||||
isImporting: importFromAssetsMutation.isPending,
|
isImporting,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Regular → Executable
+24
-149
@@ -1,12 +1,7 @@
|
|||||||
import { useState, useRef, useCallback, useEffect, useMemo, useLayoutEffect } from "react"
|
import { useState } from "react"
|
||||||
import type { ClipData, ClipType } from "../types"
|
import type { ClipData, ClipType } from "../types"
|
||||||
import { DEFAULT_ADD_DURATION, ADD_PICKER_WIDTH, TRACK_GAP } from "../constants/timeline"
|
import { useContextMenu } from "./timeline-menus/useContextMenu"
|
||||||
|
import { useAddPicker } from "./timeline-menus/useAddPicker"
|
||||||
interface ContextMenuState {
|
|
||||||
x: number
|
|
||||||
y: number
|
|
||||||
clipId: string
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 时间线菜单 Hook
|
* 时间线菜单 Hook
|
||||||
@@ -20,150 +15,30 @@ export const useTimelineMenus = (
|
|||||||
onClipResetTrim?: (clipId: string) => void,
|
onClipResetTrim?: (clipId: string) => void,
|
||||||
onClipRemove?: (clipId: string) => void,
|
onClipRemove?: (clipId: string) => void,
|
||||||
) => {
|
) => {
|
||||||
/* ── 右键菜单 ── */
|
|
||||||
const [contextMenu, setContextMenu] = useState<ContextMenuState | null>(null)
|
|
||||||
const contextMenuRef = useRef<HTMLDivElement>(null)
|
|
||||||
|
|
||||||
/* ── 添加片段面板 ── */
|
|
||||||
const [showAddPicker, setShowAddPicker] = useState(false)
|
|
||||||
const pickerRef = useRef<HTMLDivElement>(null)
|
|
||||||
const addCardRef = useRef<HTMLDivElement>(null)
|
|
||||||
const [pickerPos, setPickerPos] = useState<{ top: number; right: number }>({ top: 0, right: 0 })
|
|
||||||
|
|
||||||
/* ── 悬停的片段 ID(显示裁剪手柄) ── */
|
|
||||||
const [hoveredClipId, setHoveredClipId] = useState<string | null>(null)
|
const [hoveredClipId, setHoveredClipId] = useState<string | null>(null)
|
||||||
|
|
||||||
/* ── 根据模式决定可选类型 ── */
|
const {
|
||||||
const availableTypes: ClipType[] = useMemo(
|
contextMenu,
|
||||||
() =>
|
contextMenuRef,
|
||||||
currentMode === "voice_over" ? ["voice"] : currentMode === "pip" ? ["pip"] : ["voice", "pip"],
|
handleContextMenu,
|
||||||
[currentMode],
|
handleContextSplit,
|
||||||
)
|
handleContextResetTrim,
|
||||||
|
handleContextDelete,
|
||||||
|
} = useContextMenu({ onClipSplit, onClipResetTrim, onClipRemove })
|
||||||
|
|
||||||
/* ── 默认添加类型:跟随模式 ── */
|
const {
|
||||||
const defaultAddType: ClipType = useMemo(() => {
|
showAddPicker,
|
||||||
if (currentMode === "voice_over") return "voice"
|
pickerRef,
|
||||||
if (currentMode === "pip") return "pip"
|
addCardRef,
|
||||||
return "voice"
|
pickerPos,
|
||||||
}, [currentMode])
|
availableTypes,
|
||||||
|
addType,
|
||||||
/* ── "+" 卡片:类型+时长选择状态 ── */
|
addDuration,
|
||||||
const [addType, setAddType] = useState<ClipType>(defaultAddType)
|
setAddType,
|
||||||
const [addDuration, setAddDuration] = useState<number>(DEFAULT_ADD_DURATION)
|
setAddDuration,
|
||||||
|
handleTogglePicker,
|
||||||
/* ── 模式切换时自动同步默认添加类型 ── */
|
handleConfirmAdd,
|
||||||
useEffect(() => {
|
} = useAddPicker({ currentMode, onAddClip })
|
||||||
if (!availableTypes.includes(addType)) {
|
|
||||||
setAddType(defaultAddType)
|
|
||||||
}
|
|
||||||
}, [currentMode, addType, availableTypes, defaultAddType])
|
|
||||||
|
|
||||||
/* ── 计算 picker 初始位置 ── */
|
|
||||||
const updatePickerPosition = useCallback(() => {
|
|
||||||
if (!addCardRef.current) return
|
|
||||||
const rect = addCardRef.current.getBoundingClientRect()
|
|
||||||
const vw = window.innerWidth
|
|
||||||
const roughHeight = 180
|
|
||||||
let top = rect.top - TRACK_GAP - roughHeight
|
|
||||||
if (top < 8) top = 8
|
|
||||||
let right = vw - rect.right
|
|
||||||
if (rect.right - ADD_PICKER_WIDTH < 8) {
|
|
||||||
right = vw - ADD_PICKER_WIDTH - 8
|
|
||||||
}
|
|
||||||
setPickerPos({ top, right })
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
const handleTogglePicker = useCallback(() => {
|
|
||||||
if (!showAddPicker) {
|
|
||||||
setAddType(defaultAddType)
|
|
||||||
updatePickerPosition()
|
|
||||||
}
|
|
||||||
setShowAddPicker((v) => !v)
|
|
||||||
}, [showAddPicker, defaultAddType, updatePickerPosition])
|
|
||||||
|
|
||||||
/* ── 渲染后精确边界校正 ── */
|
|
||||||
useLayoutEffect(() => {
|
|
||||||
if (!showAddPicker || !pickerRef.current || !addCardRef.current) return
|
|
||||||
const pickerEl = pickerRef.current
|
|
||||||
const addRect = addCardRef.current.getBoundingClientRect()
|
|
||||||
const pickerH = pickerEl.offsetHeight
|
|
||||||
const vh = window.innerHeight
|
|
||||||
const vw = window.innerWidth
|
|
||||||
|
|
||||||
let top = addRect.top - TRACK_GAP - pickerH
|
|
||||||
if (top < 8) {
|
|
||||||
top = addRect.bottom + TRACK_GAP
|
|
||||||
if (top + pickerH > vh - 8) {
|
|
||||||
top = vh - 8 - pickerH
|
|
||||||
if (top < 8) top = 8
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let right = vw - addRect.right
|
|
||||||
const pickerRect = pickerEl.getBoundingClientRect()
|
|
||||||
if (pickerRect.left < 8) {
|
|
||||||
right = vw - ADD_PICKER_WIDTH - 8
|
|
||||||
}
|
|
||||||
|
|
||||||
setPickerPos({ top, right })
|
|
||||||
}, [showAddPicker])
|
|
||||||
|
|
||||||
/* ── 点击外部关闭添加面板 ── */
|
|
||||||
useEffect(() => {
|
|
||||||
const handleClickOutside = (e: MouseEvent) => {
|
|
||||||
if (pickerRef.current && !pickerRef.current.contains(e.target as Node)) {
|
|
||||||
setShowAddPicker(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (showAddPicker) {
|
|
||||||
document.addEventListener("mousedown", handleClickOutside)
|
|
||||||
}
|
|
||||||
return () => document.removeEventListener("mousedown", handleClickOutside)
|
|
||||||
}, [showAddPicker])
|
|
||||||
|
|
||||||
/* ── 点击外部关闭右键菜单 ── */
|
|
||||||
useEffect(() => {
|
|
||||||
const handleClickOutside = (e: MouseEvent) => {
|
|
||||||
if (contextMenuRef.current && !contextMenuRef.current.contains(e.target as Node)) {
|
|
||||||
setContextMenu(null)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (contextMenu) {
|
|
||||||
document.addEventListener("mousedown", handleClickOutside)
|
|
||||||
}
|
|
||||||
return () => document.removeEventListener("mousedown", handleClickOutside)
|
|
||||||
}, [contextMenu])
|
|
||||||
|
|
||||||
/* ── 确认添加片段 ── */
|
|
||||||
const handleConfirmAdd = useCallback(() => {
|
|
||||||
onAddClip(addType, addDuration)
|
|
||||||
setShowAddPicker(false)
|
|
||||||
}, [onAddClip, addType, addDuration])
|
|
||||||
|
|
||||||
/* ── 右键菜单操作 ── */
|
|
||||||
const handleContextMenu = useCallback((e: React.MouseEvent, clipId: string) => {
|
|
||||||
e.preventDefault()
|
|
||||||
e.stopPropagation()
|
|
||||||
setContextMenu({ x: e.clientX, y: e.clientY, clipId })
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
const handleContextSplit = useCallback(() => {
|
|
||||||
if (!contextMenu) return
|
|
||||||
onClipSplit?.(contextMenu.clipId, 0.5)
|
|
||||||
setContextMenu(null)
|
|
||||||
}, [contextMenu, onClipSplit])
|
|
||||||
|
|
||||||
const handleContextResetTrim = useCallback(() => {
|
|
||||||
if (!contextMenu) return
|
|
||||||
onClipResetTrim?.(contextMenu.clipId)
|
|
||||||
setContextMenu(null)
|
|
||||||
}, [contextMenu, onClipResetTrim])
|
|
||||||
|
|
||||||
const handleContextDelete = useCallback(() => {
|
|
||||||
if (!contextMenu) return
|
|
||||||
onClipRemove?.(contextMenu.clipId)
|
|
||||||
setContextMenu(null)
|
|
||||||
}, [contextMenu, onClipRemove])
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
// 右键菜单
|
// 右键菜单
|
||||||
|
|||||||
Regular → Executable
+11
-102
@@ -1,37 +1,21 @@
|
|||||||
/**
|
|
||||||
* 智能剪辑右侧生成结果面板
|
|
||||||
*/
|
|
||||||
import React from "react"
|
import React from "react"
|
||||||
import { Typography } from "antd"
|
import { Typography } from "antd"
|
||||||
import {
|
import { PlayCircleOutlined, CloseCircleOutlined } from "@ant-design/icons"
|
||||||
PlayCircleOutlined,
|
|
||||||
CloseCircleOutlined,
|
|
||||||
DownloadOutlined,
|
|
||||||
ShareAltOutlined,
|
|
||||||
} from "@ant-design/icons"
|
|
||||||
import type { GeneratedVideo } from "@/api/template-editor"
|
import type { GeneratedVideo } from "@/api/template-editor"
|
||||||
import { formatDuration } from "@/api/voice-clone"
|
import { ProgressIndicator } from "./result-panel/ProgressIndicator"
|
||||||
|
import { ResultVideoCard } from "./result-panel/ResultVideoCard"
|
||||||
|
|
||||||
const { Text } = Typography
|
const { Text } = Typography
|
||||||
|
|
||||||
interface GenerateResultPanelProps {
|
interface GenerateResultPanelProps {
|
||||||
/** 是否已生成完成 */
|
|
||||||
generated: boolean
|
generated: boolean
|
||||||
/** 是否正在生成中 */
|
|
||||||
generating: boolean
|
generating: boolean
|
||||||
/** 生成进度(0-100) */
|
|
||||||
progress: number
|
progress: number
|
||||||
/** 生成错误信息 */
|
|
||||||
generateError: string | null
|
generateError: string | null
|
||||||
/** 生成的视频列表 */
|
|
||||||
generatedVideos: GeneratedVideo[]
|
generatedVideos: GeneratedVideo[]
|
||||||
/** 点击视频卡片预览回调 */
|
|
||||||
onVideoPreview: (video: GeneratedVideo) => void
|
onVideoPreview: (video: GeneratedVideo) => void
|
||||||
/** 下载回调 */
|
|
||||||
onDownload: () => void
|
onDownload: () => void
|
||||||
/** 分享回调 */
|
|
||||||
onShare: () => void
|
onShare: () => void
|
||||||
/** 前往成片库回调 */
|
|
||||||
onGoToLibrary: () => void
|
onGoToLibrary: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -55,45 +39,8 @@ const GenerateResultPanel: React.FC<GenerateResultPanelProps> = ({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 生成中进度 */}
|
{generating && <ProgressIndicator progress={progress} />}
|
||||||
{generating && (
|
|
||||||
<div className="xx-result-progress">
|
|
||||||
<div className="xx-progress-circle">
|
|
||||||
<svg viewBox="0 0 80 80">
|
|
||||||
<circle
|
|
||||||
cx="40"
|
|
||||||
cy="40"
|
|
||||||
r="36"
|
|
||||||
fill="none"
|
|
||||||
stroke="var(--border-color)"
|
|
||||||
strokeWidth="6"
|
|
||||||
/>
|
|
||||||
<circle
|
|
||||||
cx="40"
|
|
||||||
cy="40"
|
|
||||||
r="36"
|
|
||||||
fill="none"
|
|
||||||
stroke="var(--primary-color)"
|
|
||||||
strokeWidth="6"
|
|
||||||
strokeDasharray={`${Math.round(progress) * 2.26} 226`}
|
|
||||||
strokeLinecap="round"
|
|
||||||
transform="rotate(-90 40 40)"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
<span className="xx-progress-percent">{Math.round(progress)}%</span>
|
|
||||||
</div>
|
|
||||||
<div className="xx-progress-text">
|
|
||||||
<Text strong style={{ fontSize: 14, display: "block", marginBottom: 4 }}>
|
|
||||||
正在生成视频
|
|
||||||
</Text>
|
|
||||||
<Text style={{ fontSize: 12, color: "var(--text-secondary)" }}>
|
|
||||||
AI 正在处理素材,请稍候…
|
|
||||||
</Text>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* 生成失败 */}
|
|
||||||
{generateError && !generating && (
|
{generateError && !generating && (
|
||||||
<div className="xx-result-empty">
|
<div className="xx-result-empty">
|
||||||
<CloseCircleOutlined style={{ fontSize: 40, color: "#ff4d4f", marginBottom: 12 }} />
|
<CloseCircleOutlined style={{ fontSize: 40, color: "#ff4d4f", marginBottom: 12 }} />
|
||||||
@@ -106,7 +53,6 @@ const GenerateResultPanel: React.FC<GenerateResultPanelProps> = ({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* 空状态 */}
|
|
||||||
{!generated && !generating && !generateError && (
|
{!generated && !generating && !generateError && (
|
||||||
<div className="xx-result-empty">
|
<div className="xx-result-empty">
|
||||||
<PlayCircleOutlined
|
<PlayCircleOutlined
|
||||||
@@ -121,54 +67,17 @@ const GenerateResultPanel: React.FC<GenerateResultPanelProps> = ({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* 生成结果卡片列表 */}
|
|
||||||
{generated && generatedVideos.length > 0 && (
|
{generated && generatedVideos.length > 0 && (
|
||||||
<div className="xx-video-grid">
|
<div className="xx-video-grid">
|
||||||
{generatedVideos.map((video, idx) => (
|
{generatedVideos.map((video, idx) => (
|
||||||
<div
|
<ResultVideoCard
|
||||||
key={video.id || idx}
|
key={video.id || idx}
|
||||||
className="xx-video-card"
|
video={video}
|
||||||
onClick={() => onVideoPreview(video)}
|
index={idx}
|
||||||
>
|
onPreview={onVideoPreview}
|
||||||
<div className="xx-video-thumb">
|
onDownload={onDownload}
|
||||||
{video.thumbnail_url ? (
|
onShare={onShare}
|
||||||
<img src={video.thumbnail_url} alt="" />
|
/>
|
||||||
) : (
|
|
||||||
<div className="xx-video-thumb-placeholder">
|
|
||||||
<PlayCircleOutlined style={{ fontSize: 32, opacity: 0.5 }} />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<div className="xx-video-play-overlay">
|
|
||||||
<PlayCircleOutlined style={{ fontSize: 36, color: "#fff" }} />
|
|
||||||
</div>
|
|
||||||
{video.duration && (
|
|
||||||
<span className="xx-video-duration">{formatDuration(video.duration)}</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div className="xx-video-info">
|
|
||||||
<div className="xx-video-title">视频 {idx + 1}</div>
|
|
||||||
<div className="xx-video-actions">
|
|
||||||
<button
|
|
||||||
className="xx-video-action-btn"
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation()
|
|
||||||
onDownload()
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<DownloadOutlined />
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
className="xx-video-action-btn"
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation()
|
|
||||||
onShare()
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<ShareAltOutlined />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
Regular → Executable
+16
-101
@@ -1,9 +1,9 @@
|
|||||||
/**
|
|
||||||
* Step 6 封面设置组件
|
|
||||||
*/
|
|
||||||
import React from "react"
|
import React from "react"
|
||||||
import type { CoverConfig } from "../../editing-planner/types"
|
import type { CoverConfig } from "../../editing-planner/types"
|
||||||
import { useStep6Cover } from "../hooks/useStep6Cover"
|
import { useStep6Cover } from "../hooks/useStep6Cover"
|
||||||
|
import { CoverModeSelector } from "./cover-settings/CoverModeSelector"
|
||||||
|
import { FrameCoverPicker } from "./cover-settings/FrameCoverPicker"
|
||||||
|
import { UploadCoverPicker } from "./cover-settings/UploadCoverPicker"
|
||||||
|
|
||||||
interface Step6CoverSettingsProps {
|
interface Step6CoverSettingsProps {
|
||||||
coverSettings: CoverConfig
|
coverSettings: CoverConfig
|
||||||
@@ -28,7 +28,6 @@ const Step6CoverSettings: React.FC<Step6CoverSettingsProps> = (props) => {
|
|||||||
<div className="xx-form-section">
|
<div className="xx-form-section">
|
||||||
<h3>🖼️ 选择封面</h3>
|
<h3>🖼️ 选择封面</h3>
|
||||||
|
|
||||||
{/* 启用开关 */}
|
|
||||||
<div className="xx-cover-header">
|
<div className="xx-cover-header">
|
||||||
<span className="xx-cover-header-label">启用自定义封面</span>
|
<span className="xx-cover-header-label">启用自定义封面</span>
|
||||||
<label className="xx-switch">
|
<label className="xx-switch">
|
||||||
@@ -43,22 +42,14 @@ const Step6CoverSettings: React.FC<Step6CoverSettingsProps> = (props) => {
|
|||||||
|
|
||||||
{coverSettings.enabled && (
|
{coverSettings.enabled && (
|
||||||
<>
|
<>
|
||||||
{/* 模式选择 */}
|
|
||||||
<div className="xx-section-title">封面来源</div>
|
<div className="xx-section-title">封面来源</div>
|
||||||
<div className="xx-cover-mode-tabs">
|
<CoverModeSelector
|
||||||
{(["auto", "frame", "upload"] as const).map((m) => (
|
mode={coverSettings.mode}
|
||||||
<button
|
onModeChange={setMode}
|
||||||
key={m}
|
modeLabels={COVER_MODE_LABELS}
|
||||||
className={`xx-cover-mode-tab${coverSettings.mode === m ? " active" : ""}`}
|
modeIcons={COVER_MODE_ICONS}
|
||||||
onClick={() => setMode(m)}
|
/>
|
||||||
>
|
|
||||||
<span className="xx-cover-mode-icon">{COVER_MODE_ICONS[m]}</span>
|
|
||||||
<span className="xx-cover-mode-label">{COVER_MODE_LABELS[m]}</span>
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 智能封面 */}
|
|
||||||
{coverSettings.mode === "auto" && (
|
{coverSettings.mode === "auto" && (
|
||||||
<div className="xx-cover-auto">
|
<div className="xx-cover-auto">
|
||||||
<div className="xx-cover-auto-desc">
|
<div className="xx-cover-auto-desc">
|
||||||
@@ -71,95 +62,19 @@ const Step6CoverSettings: React.FC<Step6CoverSettingsProps> = (props) => {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* 抽帧选封面 */}
|
|
||||||
{coverSettings.mode === "frame" && (
|
{coverSettings.mode === "frame" && (
|
||||||
<div className="xx-cover-frame">
|
<FrameCoverPicker
|
||||||
<div className="xx-cover-frame-preview">
|
frameTime={coverSettings.frame_time}
|
||||||
<div className="xx-cover-frame-placeholder">
|
totalDuration={totalDuration}
|
||||||
<span className="xx-cover-frame-icon">🎞️</span>
|
formatTime={formatTime}
|
||||||
<span className="xx-cover-frame-time">
|
onFrameTimeChange={setFrameTime}
|
||||||
{formatTime(coverSettings.frame_time)}
|
/>
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="xx-cover-frame-slider">
|
|
||||||
<div className="xx-cover-frame-slider-header">
|
|
||||||
<span>拖动选择封面帧</span>
|
|
||||||
<span className="xx-cover-frame-value">
|
|
||||||
{formatTime(coverSettings.frame_time)}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<input
|
|
||||||
type="range"
|
|
||||||
min={0}
|
|
||||||
max={Math.max(totalDuration, 1)}
|
|
||||||
step={0.1}
|
|
||||||
value={coverSettings.frame_time}
|
|
||||||
onChange={(e) => setFrameTime(Number(e.target.value))}
|
|
||||||
className="xx-cover-range"
|
|
||||||
/>
|
|
||||||
<div className="xx-cover-frame-range">
|
|
||||||
<span>00:00</span>
|
|
||||||
<span>{formatTime(totalDuration)}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="xx-cover-frame-quick">
|
|
||||||
<span className="xx-cover-quick-label">快捷选帧:</span>
|
|
||||||
{[0, 0.25, 0.5, 0.75].map((ratio) => {
|
|
||||||
const t = totalDuration * ratio
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
key={ratio}
|
|
||||||
className="xx-cover-quick-btn"
|
|
||||||
onClick={() => setFrameTime(t)}
|
|
||||||
>
|
|
||||||
{formatTime(t)}
|
|
||||||
</button>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* 上传封面 */}
|
|
||||||
{coverSettings.mode === "upload" && (
|
{coverSettings.mode === "upload" && (
|
||||||
<div className="xx-cover-upload">
|
<UploadCoverPicker uploadUrl={coverSettings.upload_url} onUpload={handleUpload} />
|
||||||
<div
|
|
||||||
className="xx-cover-upload-area"
|
|
||||||
onClick={() => {
|
|
||||||
const input = document.getElementById("cover-upload-input")
|
|
||||||
input?.click()
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{coverSettings.upload_url ? (
|
|
||||||
<div className="xx-cover-upload-preview">
|
|
||||||
<img src={coverSettings.upload_url} alt="封面预览" />
|
|
||||||
<div className="xx-cover-upload-overlay">点击更换</div>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="xx-cover-upload-placeholder">
|
|
||||||
<span style={{ fontSize: 32 }}>📤</span>
|
|
||||||
<span className="xx-cover-upload-text">点击上传封面图片</span>
|
|
||||||
<span className="xx-cover-upload-hint">支持 JPG / PNG,建议 16:9 比例</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<input
|
|
||||||
id="cover-upload-input"
|
|
||||||
type="file"
|
|
||||||
accept="image/*"
|
|
||||||
style={{ display: "none" }}
|
|
||||||
onChange={(e) => {
|
|
||||||
const file = e.target.files?.[0]
|
|
||||||
if (file) {
|
|
||||||
handleUpload(file)
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* 封面预览 */}
|
|
||||||
<div className="xx-section-title">封面预览</div>
|
<div className="xx-section-title">封面预览</div>
|
||||||
<div className="xx-cover-preview-box">
|
<div className="xx-cover-preview-box">
|
||||||
{coverSettings.upload_url ? (
|
{coverSettings.upload_url ? (
|
||||||
|
|||||||
Regular → Executable
+24
-130
@@ -2,19 +2,14 @@
|
|||||||
* Step 7 确认生成组件
|
* Step 7 确认生成组件
|
||||||
*/
|
*/
|
||||||
import React from "react"
|
import React from "react"
|
||||||
import {
|
|
||||||
LoadingOutlined,
|
|
||||||
CheckCircleFilled,
|
|
||||||
CloseCircleOutlined,
|
|
||||||
MinusOutlined,
|
|
||||||
PlusOutlined,
|
|
||||||
} from "@ant-design/icons"
|
|
||||||
import type { EditingTemplate } from "@/api/editing-planner"
|
import type { EditingTemplate } from "@/api/editing-planner"
|
||||||
import type { GeneratedVideo } from "@/api/template-editor"
|
import type { GeneratedVideo } from "@/api/template-editor"
|
||||||
import type { CoverConfig } from "../../editing-planner/types"
|
import type { CoverConfig } from "../../editing-planner/types"
|
||||||
import type { VoiceClone } from "@/api/voice-clone"
|
import type { VoiceClone } from "@/api/voice-clone"
|
||||||
import type { PresetVoiceItem } from "@/api/voices"
|
import type { PresetVoiceItem } from "@/api/voices"
|
||||||
import { useStep7Generate } from "../hooks/useStep7Generate"
|
import { useStep7Generate } from "../hooks/useStep7Generate"
|
||||||
|
import SummaryCard from "./step7-confirm/SummaryCard"
|
||||||
|
import GenerationStatus from "./step7-confirm/GenerationStatus"
|
||||||
|
|
||||||
interface Step7ConfirmGenerateProps {
|
interface Step7ConfirmGenerateProps {
|
||||||
templates: EditingTemplate[]
|
templates: EditingTemplate[]
|
||||||
@@ -64,129 +59,28 @@ const Step7ConfirmGenerate: React.FC<Step7ConfirmGenerateProps> = (props) => {
|
|||||||
return (
|
return (
|
||||||
<div className="xx-form-section">
|
<div className="xx-form-section">
|
||||||
<h3>✨ 确认生成</h3>
|
<h3>✨ 确认生成</h3>
|
||||||
<div className="xx-summary-card">
|
<SummaryCard
|
||||||
<div className="xx-summary-row">
|
templateName={templateName}
|
||||||
<span className="xx-summary-label">模板</span>
|
materialSummary={materialSummary}
|
||||||
<span className="xx-summary-value">{templateName}</span>
|
title={title}
|
||||||
</div>
|
voiceName={voiceName}
|
||||||
<div className="xx-summary-row">
|
coverSummary={coverSummary}
|
||||||
<span className="xx-summary-label">素材</span>
|
generateCount={generateCount}
|
||||||
<span className="xx-summary-value">{materialSummary}</span>
|
generating={generating}
|
||||||
</div>
|
onDecrement={handleDecrement}
|
||||||
<div className="xx-summary-row">
|
onIncrement={handleIncrement}
|
||||||
<span className="xx-summary-label">标题</span>
|
/>
|
||||||
<span className="xx-summary-value">{title || "未选择"}</span>
|
<GenerationStatus
|
||||||
</div>
|
generating={generating}
|
||||||
<div className="xx-summary-row">
|
generated={generated}
|
||||||
<span className="xx-summary-label">配音</span>
|
generateError={generateError}
|
||||||
<span className="xx-summary-value">{voiceName}</span>
|
progress={progress}
|
||||||
</div>
|
generatedVideos={generatedVideos}
|
||||||
<div className="xx-summary-row">
|
getGenerationPhase={getGenerationPhase}
|
||||||
<span className="xx-summary-label">封面</span>
|
onScrollToPreview={handleScrollToPreview}
|
||||||
<span className="xx-summary-value">{coverSummary}</span>
|
onRetry={onRetry}
|
||||||
</div>
|
onDismissError={onDismissError}
|
||||||
<div className="xx-summary-row">
|
/>
|
||||||
<span className="xx-summary-label">生成数量</span>
|
|
||||||
<span className="xx-summary-value">
|
|
||||||
<div className="xx-count-stepper">
|
|
||||||
<button
|
|
||||||
className="xx-count-stepper-btn"
|
|
||||||
disabled={generateCount <= 1 || generating}
|
|
||||||
onClick={handleDecrement}
|
|
||||||
>
|
|
||||||
<MinusOutlined />
|
|
||||||
</button>
|
|
||||||
<span className="xx-count-stepper-value">{generateCount}</span>
|
|
||||||
<button
|
|
||||||
className="xx-count-stepper-btn"
|
|
||||||
disabled={generateCount >= 10 || generating}
|
|
||||||
onClick={handleIncrement}
|
|
||||||
>
|
|
||||||
<PlusOutlined />
|
|
||||||
</button>
|
|
||||||
<span className="xx-count-stepper-hint">条视频</span>
|
|
||||||
</div>
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 生成进度 / 结果反馈 */}
|
|
||||||
{(generating || generated || generateError) && (
|
|
||||||
<div style={{ marginTop: 16 }}>
|
|
||||||
{generating && (
|
|
||||||
<div className="xx-gen-progress-card">
|
|
||||||
<div className="xx-gen-progress-header">
|
|
||||||
<div className="xx-gen-progress-icon">
|
|
||||||
<LoadingOutlined />
|
|
||||||
</div>
|
|
||||||
<div className="xx-gen-progress-info">
|
|
||||||
<div className="xx-gen-progress-phase">
|
|
||||||
{getGenerationPhase(progress).icon} {getGenerationPhase(progress).label}
|
|
||||||
</div>
|
|
||||||
<div className="xx-gen-progress-sub">预计还需 1-2 分钟,请稍候…</div>
|
|
||||||
</div>
|
|
||||||
<div className="xx-gen-progress-percent">{Math.round(progress)}%</div>
|
|
||||||
</div>
|
|
||||||
<div className="xx-gen-progress-bar">
|
|
||||||
<div
|
|
||||||
className="xx-gen-progress-bar-fill"
|
|
||||||
style={{ width: `${Math.min(Math.round(progress), 100)}%` }}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="xx-gen-progress-tip">
|
|
||||||
💡 生成过程中可以切换到其他页面操作,完成后会自动通知
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{generated && !generating && (
|
|
||||||
<div className="xx-gen-success-card">
|
|
||||||
<div className="xx-gen-success-icon">
|
|
||||||
<CheckCircleFilled style={{ fontSize: 32, color: "#52c41a" }} />
|
|
||||||
</div>
|
|
||||||
<div className="xx-gen-success-info">
|
|
||||||
<div className="xx-gen-success-title">视频生成完成!</div>
|
|
||||||
<div className="xx-gen-success-sub">
|
|
||||||
共生成 {generatedVideos.length} 条视频,可在右侧预览或前往成片库查看
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="xx-btn xx-btn-primary xx-btn-sm"
|
|
||||||
onClick={handleScrollToPreview}
|
|
||||||
>
|
|
||||||
查看结果
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{generateError && !generating && (
|
|
||||||
<div className="xx-gen-error-card">
|
|
||||||
<div className="xx-gen-error-icon">
|
|
||||||
<CloseCircleOutlined style={{ fontSize: 28, color: "#ef4444" }} />
|
|
||||||
</div>
|
|
||||||
<div className="xx-gen-error-info">
|
|
||||||
<div className="xx-gen-error-title">生成失败</div>
|
|
||||||
<div className="xx-gen-error-msg">
|
|
||||||
{typeof generateError === "string"
|
|
||||||
? generateError
|
|
||||||
: JSON.stringify(generateError)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div style={{ display: "flex", gap: 8 }}>
|
|
||||||
<button type="button" className="xx-btn xx-btn-primary xx-btn-sm" onClick={onRetry}>
|
|
||||||
🔄 重试
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="xx-btn xx-btn-ghost xx-btn-sm"
|
|
||||||
onClick={onDismissError}
|
|
||||||
>
|
|
||||||
知道了
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
+32
@@ -0,0 +1,32 @@
|
|||||||
|
import React from "react"
|
||||||
|
import type { CoverMode } from "../../../editing-planner/types"
|
||||||
|
|
||||||
|
interface CoverModeSelectorProps {
|
||||||
|
mode: CoverMode
|
||||||
|
onModeChange: (mode: CoverMode) => void
|
||||||
|
modeLabels: Record<CoverMode, string>
|
||||||
|
modeIcons: Record<CoverMode, string>
|
||||||
|
}
|
||||||
|
|
||||||
|
export const CoverModeSelector: React.FC<CoverModeSelectorProps> = ({
|
||||||
|
mode,
|
||||||
|
onModeChange,
|
||||||
|
modeLabels,
|
||||||
|
modeIcons,
|
||||||
|
}) => {
|
||||||
|
const modes: CoverMode[] = ["auto", "frame", "upload"]
|
||||||
|
return (
|
||||||
|
<div className="xx-cover-mode-tabs">
|
||||||
|
{modes.map((m) => (
|
||||||
|
<button
|
||||||
|
key={m}
|
||||||
|
className={`xx-cover-mode-tab${mode === m ? " active" : ""}`}
|
||||||
|
onClick={() => onModeChange(m)}
|
||||||
|
>
|
||||||
|
<span className="xx-cover-mode-icon">{modeIcons[m]}</span>
|
||||||
|
<span className="xx-cover-mode-label">{modeLabels[m]}</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
import React from "react"
|
||||||
|
|
||||||
|
interface FrameCoverPickerProps {
|
||||||
|
frameTime: number
|
||||||
|
totalDuration: number
|
||||||
|
formatTime: (seconds: number) => string
|
||||||
|
onFrameTimeChange: (time: number) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export const FrameCoverPicker: React.FC<FrameCoverPickerProps> = ({
|
||||||
|
frameTime,
|
||||||
|
totalDuration,
|
||||||
|
formatTime,
|
||||||
|
onFrameTimeChange,
|
||||||
|
}) => {
|
||||||
|
const quickRatios = [0, 0.25, 0.5, 0.75]
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="xx-cover-frame">
|
||||||
|
<div className="xx-cover-frame-preview">
|
||||||
|
<div className="xx-cover-frame-placeholder">
|
||||||
|
<span className="xx-cover-frame-icon">🎞️</span>
|
||||||
|
<span className="xx-cover-frame-time">{formatTime(frameTime)}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="xx-cover-frame-slider">
|
||||||
|
<div className="xx-cover-frame-slider-header">
|
||||||
|
<span>拖动选择封面帧</span>
|
||||||
|
<span className="xx-cover-frame-value">{formatTime(frameTime)}</span>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
min={0}
|
||||||
|
max={Math.max(totalDuration, 1)}
|
||||||
|
step={0.1}
|
||||||
|
value={frameTime}
|
||||||
|
onChange={(e) => onFrameTimeChange(Number(e.target.value))}
|
||||||
|
className="xx-cover-range"
|
||||||
|
/>
|
||||||
|
<div className="xx-cover-frame-range">
|
||||||
|
<span>00:00</span>
|
||||||
|
<span>{formatTime(totalDuration)}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="xx-cover-frame-quick">
|
||||||
|
<span className="xx-cover-quick-label">快捷选帧:</span>
|
||||||
|
{quickRatios.map((ratio) => {
|
||||||
|
const t = totalDuration * ratio
|
||||||
|
return (
|
||||||
|
<button key={ratio} className="xx-cover-quick-btn" onClick={() => onFrameTimeChange(t)}>
|
||||||
|
{formatTime(t)}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
+46
@@ -0,0 +1,46 @@
|
|||||||
|
import React from "react"
|
||||||
|
|
||||||
|
interface UploadCoverPickerProps {
|
||||||
|
uploadUrl: string
|
||||||
|
onUpload: (file: File) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export const UploadCoverPicker: React.FC<UploadCoverPickerProps> = ({ uploadUrl, onUpload }) => {
|
||||||
|
const handleClick = () => {
|
||||||
|
const input = document.getElementById("cover-upload-input")
|
||||||
|
input?.click()
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const file = e.target.files?.[0]
|
||||||
|
if (file) {
|
||||||
|
onUpload(file)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="xx-cover-upload">
|
||||||
|
<div className="xx-cover-upload-area" onClick={handleClick}>
|
||||||
|
{uploadUrl ? (
|
||||||
|
<div className="xx-cover-upload-preview">
|
||||||
|
<img src={uploadUrl} alt="封面预览" />
|
||||||
|
<div className="xx-cover-upload-overlay">点击更换</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="xx-cover-upload-placeholder">
|
||||||
|
<span style={{ fontSize: 32 }}>📤</span>
|
||||||
|
<span className="xx-cover-upload-text">点击上传封面图片</span>
|
||||||
|
<span className="xx-cover-upload-hint">支持 JPG / PNG,建议 16:9 比例</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<input
|
||||||
|
id="cover-upload-input"
|
||||||
|
type="file"
|
||||||
|
accept="image/*"
|
||||||
|
style={{ display: "none" }}
|
||||||
|
onChange={handleFileChange}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
import React from "react"
|
||||||
|
import { Typography } from "antd"
|
||||||
|
|
||||||
|
const { Text } = Typography
|
||||||
|
|
||||||
|
interface ProgressIndicatorProps {
|
||||||
|
progress: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ProgressIndicator: React.FC<ProgressIndicatorProps> = ({ progress }) => {
|
||||||
|
return (
|
||||||
|
<div className="xx-result-progress">
|
||||||
|
<div className="xx-progress-circle">
|
||||||
|
<svg viewBox="0 0 80 80">
|
||||||
|
<circle cx="40" cy="40" r="36" fill="none" stroke="var(--border-color)" strokeWidth="6" />
|
||||||
|
<circle
|
||||||
|
cx="40"
|
||||||
|
cy="40"
|
||||||
|
r="36"
|
||||||
|
fill="none"
|
||||||
|
stroke="var(--primary-color)"
|
||||||
|
strokeWidth="6"
|
||||||
|
strokeDasharray={`${Math.round(progress) * 2.26} 226`}
|
||||||
|
strokeLinecap="round"
|
||||||
|
transform="rotate(-90 40 40)"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
<span className="xx-progress-percent">{Math.round(progress)}%</span>
|
||||||
|
</div>
|
||||||
|
<div className="xx-progress-text">
|
||||||
|
<Text strong style={{ fontSize: 14, display: "block", marginBottom: 4 }}>
|
||||||
|
正在生成视频
|
||||||
|
</Text>
|
||||||
|
<Text style={{ fontSize: 12, color: "var(--text-secondary)" }}>
|
||||||
|
AI 正在处理素材,请稍候…
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
import React from "react"
|
||||||
|
import { PlayCircleOutlined, DownloadOutlined, ShareAltOutlined } from "@ant-design/icons"
|
||||||
|
import type { GeneratedVideo } from "@/api/template-editor"
|
||||||
|
import { formatDuration } from "@/api/voice-clone"
|
||||||
|
|
||||||
|
interface ResultVideoCardProps {
|
||||||
|
video: GeneratedVideo
|
||||||
|
index: number
|
||||||
|
onPreview: (video: GeneratedVideo) => void
|
||||||
|
onDownload: () => void
|
||||||
|
onShare: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ResultVideoCard: React.FC<ResultVideoCardProps> = ({
|
||||||
|
video,
|
||||||
|
index,
|
||||||
|
onPreview,
|
||||||
|
onDownload,
|
||||||
|
onShare,
|
||||||
|
}) => {
|
||||||
|
return (
|
||||||
|
<div className="xx-video-card" onClick={() => onPreview(video)}>
|
||||||
|
<div className="xx-video-thumb">
|
||||||
|
{video.thumbnail_url ? (
|
||||||
|
<img src={video.thumbnail_url} alt="" />
|
||||||
|
) : (
|
||||||
|
<div className="xx-video-thumb-placeholder">
|
||||||
|
<PlayCircleOutlined style={{ fontSize: 32, opacity: 0.5 }} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="xx-video-play-overlay">
|
||||||
|
<PlayCircleOutlined style={{ fontSize: 36, color: "#fff" }} />
|
||||||
|
</div>
|
||||||
|
{video.duration && (
|
||||||
|
<span className="xx-video-duration">{formatDuration(video.duration)}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="xx-video-info">
|
||||||
|
<div className="xx-video-title">视频 {index + 1}</div>
|
||||||
|
<div className="xx-video-actions">
|
||||||
|
<button
|
||||||
|
className="xx-video-action-btn"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
onDownload()
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<DownloadOutlined />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="xx-video-action-btn"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
onShare()
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<ShareAltOutlined />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,106 @@
|
|||||||
|
import React from "react"
|
||||||
|
import { LoadingOutlined, CheckCircleFilled, CloseCircleOutlined } from "@ant-design/icons"
|
||||||
|
import type { GeneratedVideo } from "@/api/template-editor"
|
||||||
|
|
||||||
|
interface GenerationStatusProps {
|
||||||
|
generating: boolean
|
||||||
|
generated: boolean
|
||||||
|
generateError: string | null
|
||||||
|
progress: number
|
||||||
|
generatedVideos: GeneratedVideo[]
|
||||||
|
getGenerationPhase: (progress: number) => { icon: string; label: string }
|
||||||
|
onScrollToPreview: () => void
|
||||||
|
onRetry: () => void
|
||||||
|
onDismissError: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
const GenerationStatus: React.FC<GenerationStatusProps> = ({
|
||||||
|
generating,
|
||||||
|
generated,
|
||||||
|
generateError,
|
||||||
|
progress,
|
||||||
|
generatedVideos,
|
||||||
|
getGenerationPhase,
|
||||||
|
onScrollToPreview,
|
||||||
|
onRetry,
|
||||||
|
onDismissError,
|
||||||
|
}) => {
|
||||||
|
if (!generating && !generated && !generateError) return null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ marginTop: 16 }}>
|
||||||
|
{generating && (
|
||||||
|
<div className="xx-gen-progress-card">
|
||||||
|
<div className="xx-gen-progress-header">
|
||||||
|
<div className="xx-gen-progress-icon">
|
||||||
|
<LoadingOutlined />
|
||||||
|
</div>
|
||||||
|
<div className="xx-gen-progress-info">
|
||||||
|
<div className="xx-gen-progress-phase">
|
||||||
|
{getGenerationPhase(progress).icon} {getGenerationPhase(progress).label}
|
||||||
|
</div>
|
||||||
|
<div className="xx-gen-progress-sub">预计还需 1-2 分钟,请稍候…</div>
|
||||||
|
</div>
|
||||||
|
<div className="xx-gen-progress-percent">{Math.round(progress)}%</div>
|
||||||
|
</div>
|
||||||
|
<div className="xx-gen-progress-bar">
|
||||||
|
<div
|
||||||
|
className="xx-gen-progress-bar-fill"
|
||||||
|
style={{ width: `${Math.min(Math.round(progress), 100)}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="xx-gen-progress-tip">
|
||||||
|
💡 生成过程中可以切换到其他页面操作,完成后会自动通知
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{generated && !generating && (
|
||||||
|
<div className="xx-gen-success-card">
|
||||||
|
<div className="xx-gen-success-icon">
|
||||||
|
<CheckCircleFilled style={{ fontSize: 32, color: "#52c41a" }} />
|
||||||
|
</div>
|
||||||
|
<div className="xx-gen-success-info">
|
||||||
|
<div className="xx-gen-success-title">视频生成完成!</div>
|
||||||
|
<div className="xx-gen-success-sub">
|
||||||
|
共生成 {generatedVideos.length} 条视频,可在右侧预览或前往成片库查看
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="xx-btn xx-btn-primary xx-btn-sm"
|
||||||
|
onClick={onScrollToPreview}
|
||||||
|
>
|
||||||
|
查看结果
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{generateError && !generating && (
|
||||||
|
<div className="xx-gen-error-card">
|
||||||
|
<div className="xx-gen-error-icon">
|
||||||
|
<CloseCircleOutlined style={{ fontSize: 28, color: "#ef4444" }} />
|
||||||
|
</div>
|
||||||
|
<div className="xx-gen-error-info">
|
||||||
|
<div className="xx-gen-error-title">生成失败</div>
|
||||||
|
<div className="xx-gen-error-msg">
|
||||||
|
{typeof generateError === "string" ? generateError : JSON.stringify(generateError)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style={{ display: "flex", gap: 8 }}>
|
||||||
|
<button type="button" className="xx-btn xx-btn-primary xx-btn-sm" onClick={onRetry}>
|
||||||
|
🔄 重试
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="xx-btn xx-btn-ghost xx-btn-sm"
|
||||||
|
onClick={onDismissError}
|
||||||
|
>
|
||||||
|
知道了
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default GenerationStatus
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
import React from "react"
|
||||||
|
import { MinusOutlined, PlusOutlined } from "@ant-design/icons"
|
||||||
|
|
||||||
|
interface SummaryCardProps {
|
||||||
|
templateName: string
|
||||||
|
materialSummary: string
|
||||||
|
title: string
|
||||||
|
voiceName: string
|
||||||
|
coverSummary: string
|
||||||
|
generateCount: number
|
||||||
|
generating: boolean
|
||||||
|
onDecrement: () => void
|
||||||
|
onIncrement: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
const SummaryCard: React.FC<SummaryCardProps> = ({
|
||||||
|
templateName,
|
||||||
|
materialSummary,
|
||||||
|
title,
|
||||||
|
voiceName,
|
||||||
|
coverSummary,
|
||||||
|
generateCount,
|
||||||
|
generating,
|
||||||
|
onDecrement,
|
||||||
|
onIncrement,
|
||||||
|
}) => {
|
||||||
|
return (
|
||||||
|
<div className="xx-summary-card">
|
||||||
|
<div className="xx-summary-row">
|
||||||
|
<span className="xx-summary-label">模板</span>
|
||||||
|
<span className="xx-summary-value">{templateName}</span>
|
||||||
|
</div>
|
||||||
|
<div className="xx-summary-row">
|
||||||
|
<span className="xx-summary-label">素材</span>
|
||||||
|
<span className="xx-summary-value">{materialSummary}</span>
|
||||||
|
</div>
|
||||||
|
<div className="xx-summary-row">
|
||||||
|
<span className="xx-summary-label">标题</span>
|
||||||
|
<span className="xx-summary-value">{title || "未选择"}</span>
|
||||||
|
</div>
|
||||||
|
<div className="xx-summary-row">
|
||||||
|
<span className="xx-summary-label">配音</span>
|
||||||
|
<span className="xx-summary-value">{voiceName}</span>
|
||||||
|
</div>
|
||||||
|
<div className="xx-summary-row">
|
||||||
|
<span className="xx-summary-label">封面</span>
|
||||||
|
<span className="xx-summary-value">{coverSummary}</span>
|
||||||
|
</div>
|
||||||
|
<div className="xx-summary-row">
|
||||||
|
<span className="xx-summary-label">生成数量</span>
|
||||||
|
<span className="xx-summary-value">
|
||||||
|
<div className="xx-count-stepper">
|
||||||
|
<button
|
||||||
|
className="xx-count-stepper-btn"
|
||||||
|
disabled={generateCount <= 1 || generating}
|
||||||
|
onClick={onDecrement}
|
||||||
|
>
|
||||||
|
<MinusOutlined />
|
||||||
|
</button>
|
||||||
|
<span className="xx-count-stepper-value">{generateCount}</span>
|
||||||
|
<button
|
||||||
|
className="xx-count-stepper-btn"
|
||||||
|
disabled={generateCount >= 10 || generating}
|
||||||
|
onClick={onIncrement}
|
||||||
|
>
|
||||||
|
<PlusOutlined />
|
||||||
|
</button>
|
||||||
|
<span className="xx-count-stepper-hint">条视频</span>
|
||||||
|
</div>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default SummaryCard
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
import { useState, useEffect } from "react"
|
||||||
|
import { useQuery } from "@tanstack/react-query"
|
||||||
|
import { getAssets, getAssetLibraries } from "@/api/assets"
|
||||||
|
import type { AssetItem } from "@/api/assets"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 素材库加载 Hook
|
||||||
|
* 管理素材库列表、当前选中库、素材列表加载
|
||||||
|
*/
|
||||||
|
export function useMaterialLibrary() {
|
||||||
|
/* ── 素材库数据 API ── */
|
||||||
|
const { data: libraries = [] } = useQuery({
|
||||||
|
queryKey: ["asset-libraries"],
|
||||||
|
queryFn: getAssetLibraries,
|
||||||
|
})
|
||||||
|
const [selectedLibraryId, setSelectedLibraryId] = useState<string>("")
|
||||||
|
|
||||||
|
// 自动选中第一个视频库
|
||||||
|
useEffect(() => {
|
||||||
|
if (libraries.length > 0 && !selectedLibraryId) {
|
||||||
|
setSelectedLibraryId(libraries[0].id)
|
||||||
|
}
|
||||||
|
}, [libraries, selectedLibraryId])
|
||||||
|
|
||||||
|
const { data: materials = { items: [], total: 0 }, isLoading: materialsLoading } = useQuery<{
|
||||||
|
items: AssetItem[]
|
||||||
|
total: number
|
||||||
|
}>({
|
||||||
|
queryKey: ["generate-assets", selectedLibraryId],
|
||||||
|
queryFn: () => getAssets(selectedLibraryId),
|
||||||
|
enabled: !!selectedLibraryId,
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
libraries,
|
||||||
|
selectedLibraryId,
|
||||||
|
setSelectedLibraryId,
|
||||||
|
materials,
|
||||||
|
materialsLoading,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,143 @@
|
|||||||
|
import { useState, useCallback, useMemo } from "react"
|
||||||
|
import { message } from "antd"
|
||||||
|
import type { AssetItem } from "@/api/assets"
|
||||||
|
import { SMART_MATCH_REASONS } from "../../constants"
|
||||||
|
|
||||||
|
interface SmartMatchedResult {
|
||||||
|
asset: AssetItem
|
||||||
|
matchScore: number
|
||||||
|
matchReason: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface UseSmartMatchOptions {
|
||||||
|
materials: { items: AssetItem[]; total: number }
|
||||||
|
smartSelectedIds: string[]
|
||||||
|
onSmartSelectedIdsChange: (ids: string[]) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 智能素材匹配 Hook
|
||||||
|
* 封装 AI 匹配、换一批、全选/清空等逻辑
|
||||||
|
*/
|
||||||
|
export function useSmartMatch({
|
||||||
|
materials,
|
||||||
|
smartSelectedIds,
|
||||||
|
onSmartSelectedIdsChange,
|
||||||
|
}: UseSmartMatchOptions) {
|
||||||
|
const [smartMatchInput, setSmartMatchInput] = useState("")
|
||||||
|
const [smartMatching, setSmartMatching] = useState(false)
|
||||||
|
const [smartMatchedResults, setSmartMatchedResults] = useState<SmartMatchedResult[]>([])
|
||||||
|
const [hasMatched, setHasMatched] = useState(false)
|
||||||
|
|
||||||
|
/* ── 智能素材匹配 ── */
|
||||||
|
const handleSmartMatch = useCallback(async () => {
|
||||||
|
if (!smartMatchInput.trim()) {
|
||||||
|
message.warning("请先输入视频内容描述")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (materials.items.length === 0) {
|
||||||
|
message.warning("当前视频库暂无素材")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setSmartMatching(true)
|
||||||
|
setHasMatched(true)
|
||||||
|
|
||||||
|
// 模拟 AI 匹配延迟
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 1500))
|
||||||
|
|
||||||
|
// 从素材库中随机选取 5-8 个作为推荐结果
|
||||||
|
const shuffled = [...materials.items].sort(() => Math.random() - 0.5)
|
||||||
|
const count = Math.min(shuffled.length, 5 + Math.floor(Math.random() * 4))
|
||||||
|
const picked = shuffled.slice(0, count)
|
||||||
|
|
||||||
|
const results = picked.map((asset, idx) => ({
|
||||||
|
asset,
|
||||||
|
matchScore: Math.round(85 + Math.random() * 14), // 85-99 分
|
||||||
|
matchReason:
|
||||||
|
SMART_MATCH_REASONS[idx % SMART_MATCH_REASONS.length] +
|
||||||
|
(Math.random() > 0.5 ? ",画面质感优秀" : ""),
|
||||||
|
}))
|
||||||
|
|
||||||
|
// 按匹配度从高到低排序
|
||||||
|
results.sort((a, b) => b.matchScore - a.matchScore)
|
||||||
|
|
||||||
|
setSmartMatchedResults(results)
|
||||||
|
// 默认选中匹配度 >= 90 的素材
|
||||||
|
const defaultSelected = results.filter((r) => r.matchScore >= 90).map((r) => r.asset.id)
|
||||||
|
onSmartSelectedIdsChange(
|
||||||
|
defaultSelected.length > 0 ? defaultSelected : results.slice(0, 3).map((r) => r.asset.id),
|
||||||
|
)
|
||||||
|
setSmartMatching(false)
|
||||||
|
}, [smartMatchInput, materials.items, onSmartSelectedIdsChange])
|
||||||
|
|
||||||
|
const handleToggleSmartSelect = useCallback(
|
||||||
|
(assetId: string) => {
|
||||||
|
onSmartSelectedIdsChange(
|
||||||
|
smartSelectedIds.includes(assetId)
|
||||||
|
? smartSelectedIds.filter((id) => id !== assetId)
|
||||||
|
: [...smartSelectedIds, assetId],
|
||||||
|
)
|
||||||
|
},
|
||||||
|
[smartSelectedIds, onSmartSelectedIdsChange],
|
||||||
|
)
|
||||||
|
|
||||||
|
const handleRefreshMatch = useCallback(async () => {
|
||||||
|
if (materials.items.length <= 5) {
|
||||||
|
message.info("视频库素材较少,无法换一批")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setSmartMatching(true)
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 800))
|
||||||
|
|
||||||
|
const remaining = materials.items.filter(
|
||||||
|
(m) => !smartMatchedResults.some((r) => r.asset.id === m.id),
|
||||||
|
)
|
||||||
|
const shuffled = [...remaining].sort(() => Math.random() - 0.5)
|
||||||
|
const count = Math.min(shuffled.length, 5 + Math.floor(Math.random() * 3))
|
||||||
|
const picked = shuffled.slice(0, count)
|
||||||
|
|
||||||
|
const results = picked.map((asset, idx) => ({
|
||||||
|
asset,
|
||||||
|
matchScore: Math.round(80 + Math.random() * 19),
|
||||||
|
matchReason:
|
||||||
|
SMART_MATCH_REASONS[(idx + 2) % SMART_MATCH_REASONS.length] +
|
||||||
|
(Math.random() > 0.5 ? ",节奏明快" : ""),
|
||||||
|
}))
|
||||||
|
results.sort((a, b) => b.matchScore - a.matchScore)
|
||||||
|
|
||||||
|
setSmartMatchedResults(results)
|
||||||
|
onSmartSelectedIdsChange([])
|
||||||
|
setSmartMatching(false)
|
||||||
|
}, [materials.items, smartMatchedResults, onSmartSelectedIdsChange])
|
||||||
|
|
||||||
|
const handleSelectAllMatched = useCallback(() => {
|
||||||
|
onSmartSelectedIdsChange(smartMatchedResults.map((r) => r.asset.id))
|
||||||
|
}, [smartMatchedResults, onSmartSelectedIdsChange])
|
||||||
|
|
||||||
|
const handleClearSmartSelect = useCallback(() => {
|
||||||
|
onSmartSelectedIdsChange([])
|
||||||
|
}, [onSmartSelectedIdsChange])
|
||||||
|
|
||||||
|
/* ── 计算已选智能匹配素材的总时长 ── */
|
||||||
|
const smartSelectedTotalDuration = useMemo(() => {
|
||||||
|
return smartMatchedResults
|
||||||
|
.filter((r) => smartSelectedIds.includes(r.asset.id))
|
||||||
|
.reduce((sum, r) => sum + (r.asset.duration || 0), 0)
|
||||||
|
}, [smartMatchedResults, smartSelectedIds])
|
||||||
|
|
||||||
|
return {
|
||||||
|
smartMatchInput,
|
||||||
|
setSmartMatchInput,
|
||||||
|
smartMatching,
|
||||||
|
smartMatchedResults,
|
||||||
|
hasMatched,
|
||||||
|
smartSelectedIds,
|
||||||
|
handleSmartMatch,
|
||||||
|
handleToggleSmartSelect,
|
||||||
|
handleRefreshMatch,
|
||||||
|
handleSelectAllMatched,
|
||||||
|
handleClearSmartSelect,
|
||||||
|
smartSelectedTotalDuration,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
import { useCallback } from "react"
|
||||||
|
import type { VoiceClone } from "@/api/voice-clone"
|
||||||
|
|
||||||
|
interface UseVoiceModeSelectionOptions {
|
||||||
|
selectedVoice: string
|
||||||
|
onSelectedVoiceChange: (voiceId: string) => void
|
||||||
|
voiceMode: "preset" | "custom" | "clone"
|
||||||
|
onVoiceModeChange: (mode: "preset" | "custom" | "clone") => void
|
||||||
|
selectedClonedVoice: string
|
||||||
|
onSelectedClonedVoiceChange: (voiceId: string) => void
|
||||||
|
addClone: (voice: VoiceClone) => void
|
||||||
|
cloneModalOpen: boolean
|
||||||
|
onCloneModalOpenChange: (open: boolean) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useVoiceModeSelection({
|
||||||
|
onSelectedVoiceChange,
|
||||||
|
onVoiceModeChange,
|
||||||
|
onSelectedClonedVoiceChange,
|
||||||
|
addClone,
|
||||||
|
onCloneModalOpenChange,
|
||||||
|
}: UseVoiceModeSelectionOptions) {
|
||||||
|
const handleSelectRecommendedVoice = useCallback(
|
||||||
|
(voiceId: string) => {
|
||||||
|
onVoiceModeChange("preset")
|
||||||
|
onSelectedVoiceChange(voiceId)
|
||||||
|
},
|
||||||
|
[onVoiceModeChange, onSelectedVoiceChange],
|
||||||
|
)
|
||||||
|
|
||||||
|
const handleSelectPresetVoice = useCallback(
|
||||||
|
(voiceId: string) => {
|
||||||
|
onVoiceModeChange("preset")
|
||||||
|
onSelectedVoiceChange(voiceId)
|
||||||
|
},
|
||||||
|
[onVoiceModeChange, onSelectedVoiceChange],
|
||||||
|
)
|
||||||
|
|
||||||
|
const handleSelectCloneVoice = useCallback(() => {
|
||||||
|
onVoiceModeChange("clone")
|
||||||
|
}, [onVoiceModeChange])
|
||||||
|
|
||||||
|
const handleSelectClonedVoice = useCallback(
|
||||||
|
(voiceId: string) => {
|
||||||
|
onSelectedClonedVoiceChange(voiceId)
|
||||||
|
},
|
||||||
|
[onSelectedClonedVoiceChange],
|
||||||
|
)
|
||||||
|
|
||||||
|
const handleOpenCloneModal = useCallback(() => {
|
||||||
|
onCloneModalOpenChange(true)
|
||||||
|
}, [onCloneModalOpenChange])
|
||||||
|
|
||||||
|
const handleCloneSuccess = useCallback(
|
||||||
|
(voice: VoiceClone) => {
|
||||||
|
addClone(voice)
|
||||||
|
onCloneModalOpenChange(false)
|
||||||
|
},
|
||||||
|
[addClone, onCloneModalOpenChange],
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
handleSelectRecommendedVoice,
|
||||||
|
handleSelectPresetVoice,
|
||||||
|
handleSelectCloneVoice,
|
||||||
|
handleSelectClonedVoice,
|
||||||
|
handleOpenCloneModal,
|
||||||
|
handleCloneSuccess,
|
||||||
|
}
|
||||||
|
}
|
||||||
Regular → Executable
+23
-149
@@ -1,20 +1,11 @@
|
|||||||
/**
|
/**
|
||||||
* Step 2 素材选择 Hook
|
* Step 2 素材选择 Hook
|
||||||
* 封装素材库加载、手动选择、智能匹配等逻辑
|
* 组合素材库加载 + 智能匹配两个子 Hook
|
||||||
*/
|
*/
|
||||||
import { useState, useCallback, useEffect, useMemo } from "react"
|
import { useCallback } from "react"
|
||||||
import { message } from "antd"
|
|
||||||
import { useQuery } from "@tanstack/react-query"
|
|
||||||
import { getAssets, getAssetLibraries } from "@/api/assets"
|
|
||||||
import type { AssetItem } from "@/api/assets"
|
|
||||||
import { formatDuration } from "../utils/formatDuration"
|
import { formatDuration } from "../utils/formatDuration"
|
||||||
import { SMART_MATCH_REASONS } from "../constants"
|
import { useMaterialLibrary } from "./step2-materials/useMaterialLibrary"
|
||||||
|
import { useSmartMatch } from "./step2-materials/useSmartMatch"
|
||||||
interface SmartMatchedResult {
|
|
||||||
asset: AssetItem
|
|
||||||
matchScore: number
|
|
||||||
matchReason: string
|
|
||||||
}
|
|
||||||
|
|
||||||
interface UseStep2MaterialsProps {
|
interface UseStep2MaterialsProps {
|
||||||
materialMode: "manual" | "auto"
|
materialMode: "manual" | "auto"
|
||||||
@@ -33,34 +24,14 @@ export function useStep2Materials({
|
|||||||
smartSelectedIds,
|
smartSelectedIds,
|
||||||
onSmartSelectedIdsChange,
|
onSmartSelectedIdsChange,
|
||||||
}: UseStep2MaterialsProps) {
|
}: UseStep2MaterialsProps) {
|
||||||
/* ── 素材库数据 API ── */
|
const { libraries, selectedLibraryId, setSelectedLibraryId, materials, materialsLoading } =
|
||||||
const { data: libraries = [] } = useQuery({
|
useMaterialLibrary()
|
||||||
queryKey: ["asset-libraries"],
|
|
||||||
queryFn: getAssetLibraries,
|
const smartMatch = useSmartMatch({
|
||||||
|
materials,
|
||||||
|
smartSelectedIds,
|
||||||
|
onSmartSelectedIdsChange,
|
||||||
})
|
})
|
||||||
const [selectedLibraryId, setSelectedLibraryId] = useState<string>("")
|
|
||||||
|
|
||||||
// 自动选中第一个视频库
|
|
||||||
useEffect(() => {
|
|
||||||
if (libraries.length > 0 && !selectedLibraryId) {
|
|
||||||
setSelectedLibraryId(libraries[0].id)
|
|
||||||
}
|
|
||||||
}, [libraries, selectedLibraryId])
|
|
||||||
|
|
||||||
const { data: materials = { items: [], total: 0 }, isLoading: materialsLoading } = useQuery<{
|
|
||||||
items: AssetItem[]
|
|
||||||
total: number
|
|
||||||
}>({
|
|
||||||
queryKey: ["generate-assets", selectedLibraryId],
|
|
||||||
queryFn: () => getAssets(selectedLibraryId),
|
|
||||||
enabled: !!selectedLibraryId,
|
|
||||||
})
|
|
||||||
|
|
||||||
/* ── 智能素材匹配状态 ── */
|
|
||||||
const [smartMatchInput, setSmartMatchInput] = useState("")
|
|
||||||
const [smartMatching, setSmartMatching] = useState(false)
|
|
||||||
const [smartMatchedResults, setSmartMatchedResults] = useState<SmartMatchedResult[]>([])
|
|
||||||
const [hasMatched, setHasMatched] = useState(false)
|
|
||||||
|
|
||||||
/* ── 手动选择素材 ── */
|
/* ── 手动选择素材 ── */
|
||||||
const handleToggleMaterial = useCallback(
|
const handleToggleMaterial = useCallback(
|
||||||
@@ -74,103 +45,6 @@ export function useStep2Materials({
|
|||||||
[selectedMaterials, onSelectedMaterialsChange],
|
[selectedMaterials, onSelectedMaterialsChange],
|
||||||
)
|
)
|
||||||
|
|
||||||
/* ── 智能素材匹配 ── */
|
|
||||||
const handleSmartMatch = useCallback(async () => {
|
|
||||||
if (!smartMatchInput.trim()) {
|
|
||||||
message.warning("请先输入视频内容描述")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (materials.items.length === 0) {
|
|
||||||
message.warning("当前视频库暂无素材")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
setSmartMatching(true)
|
|
||||||
setHasMatched(true)
|
|
||||||
|
|
||||||
// 模拟 AI 匹配延迟
|
|
||||||
await new Promise((resolve) => setTimeout(resolve, 1500))
|
|
||||||
|
|
||||||
// 从素材库中随机选取 5-8 个作为推荐结果
|
|
||||||
const shuffled = [...materials.items].sort(() => Math.random() - 0.5)
|
|
||||||
const count = Math.min(shuffled.length, 5 + Math.floor(Math.random() * 4))
|
|
||||||
const picked = shuffled.slice(0, count)
|
|
||||||
|
|
||||||
const results = picked.map((asset, idx) => ({
|
|
||||||
asset,
|
|
||||||
matchScore: Math.round(85 + Math.random() * 14), // 85-99 分
|
|
||||||
matchReason:
|
|
||||||
SMART_MATCH_REASONS[idx % SMART_MATCH_REASONS.length] +
|
|
||||||
(Math.random() > 0.5 ? ",画面质感优秀" : ""),
|
|
||||||
}))
|
|
||||||
|
|
||||||
// 按匹配度从高到低排序
|
|
||||||
results.sort((a, b) => b.matchScore - a.matchScore)
|
|
||||||
|
|
||||||
setSmartMatchedResults(results)
|
|
||||||
// 默认选中匹配度 >= 90 的素材
|
|
||||||
const defaultSelected = results.filter((r) => r.matchScore >= 90).map((r) => r.asset.id)
|
|
||||||
onSmartSelectedIdsChange(
|
|
||||||
defaultSelected.length > 0 ? defaultSelected : results.slice(0, 3).map((r) => r.asset.id),
|
|
||||||
)
|
|
||||||
setSmartMatching(false)
|
|
||||||
}, [smartMatchInput, materials.items, onSmartSelectedIdsChange])
|
|
||||||
|
|
||||||
const handleToggleSmartSelect = useCallback(
|
|
||||||
(assetId: string) => {
|
|
||||||
onSmartSelectedIdsChange(
|
|
||||||
smartSelectedIds.includes(assetId)
|
|
||||||
? smartSelectedIds.filter((id) => id !== assetId)
|
|
||||||
: [...smartSelectedIds, assetId],
|
|
||||||
)
|
|
||||||
},
|
|
||||||
[smartSelectedIds, onSmartSelectedIdsChange],
|
|
||||||
)
|
|
||||||
|
|
||||||
const handleRefreshMatch = useCallback(async () => {
|
|
||||||
if (materials.items.length <= 5) {
|
|
||||||
message.info("视频库素材较少,无法换一批")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
setSmartMatching(true)
|
|
||||||
await new Promise((resolve) => setTimeout(resolve, 800))
|
|
||||||
|
|
||||||
const remaining = materials.items.filter(
|
|
||||||
(m) => !smartMatchedResults.some((r) => r.asset.id === m.id),
|
|
||||||
)
|
|
||||||
const shuffled = [...remaining].sort(() => Math.random() - 0.5)
|
|
||||||
const count = Math.min(shuffled.length, 5 + Math.floor(Math.random() * 3))
|
|
||||||
const picked = shuffled.slice(0, count)
|
|
||||||
|
|
||||||
const results = picked.map((asset, idx) => ({
|
|
||||||
asset,
|
|
||||||
matchScore: Math.round(80 + Math.random() * 19),
|
|
||||||
matchReason:
|
|
||||||
SMART_MATCH_REASONS[(idx + 2) % SMART_MATCH_REASONS.length] +
|
|
||||||
(Math.random() > 0.5 ? ",节奏明快" : ""),
|
|
||||||
}))
|
|
||||||
results.sort((a, b) => b.matchScore - a.matchScore)
|
|
||||||
|
|
||||||
setSmartMatchedResults(results)
|
|
||||||
onSmartSelectedIdsChange([])
|
|
||||||
setSmartMatching(false)
|
|
||||||
}, [materials.items, smartMatchedResults, onSmartSelectedIdsChange])
|
|
||||||
|
|
||||||
const handleSelectAllMatched = useCallback(() => {
|
|
||||||
onSmartSelectedIdsChange(smartMatchedResults.map((r) => r.asset.id))
|
|
||||||
}, [smartMatchedResults, onSmartSelectedIdsChange])
|
|
||||||
|
|
||||||
const handleClearSmartSelect = useCallback(() => {
|
|
||||||
onSmartSelectedIdsChange([])
|
|
||||||
}, [onSmartSelectedIdsChange])
|
|
||||||
|
|
||||||
/* ── 计算已选智能匹配素材的总时长 ── */
|
|
||||||
const smartSelectedTotalDuration = useMemo(() => {
|
|
||||||
return smartMatchedResults
|
|
||||||
.filter((r) => smartSelectedIds.includes(r.asset.id))
|
|
||||||
.reduce((sum, r) => sum + (r.asset.duration || 0), 0)
|
|
||||||
}, [smartMatchedResults, smartSelectedIds])
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
// 素材库
|
// 素材库
|
||||||
libraries,
|
libraries,
|
||||||
@@ -185,18 +59,18 @@ export function useStep2Materials({
|
|||||||
selectedMaterials,
|
selectedMaterials,
|
||||||
handleToggleMaterial,
|
handleToggleMaterial,
|
||||||
// 智能匹配
|
// 智能匹配
|
||||||
smartMatchInput,
|
smartMatchInput: smartMatch.smartMatchInput,
|
||||||
setSmartMatchInput,
|
setSmartMatchInput: smartMatch.setSmartMatchInput,
|
||||||
smartMatching,
|
smartMatching: smartMatch.smartMatching,
|
||||||
smartMatchedResults,
|
smartMatchedResults: smartMatch.smartMatchedResults,
|
||||||
hasMatched,
|
hasMatched: smartMatch.hasMatched,
|
||||||
smartSelectedIds,
|
smartSelectedIds: smartMatch.smartSelectedIds,
|
||||||
handleSmartMatch,
|
handleSmartMatch: smartMatch.handleSmartMatch,
|
||||||
handleToggleSmartSelect,
|
handleToggleSmartSelect: smartMatch.handleToggleSmartSelect,
|
||||||
handleRefreshMatch,
|
handleRefreshMatch: smartMatch.handleRefreshMatch,
|
||||||
handleSelectAllMatched,
|
handleSelectAllMatched: smartMatch.handleSelectAllMatched,
|
||||||
handleClearSmartSelect,
|
handleClearSmartSelect: smartMatch.handleClearSmartSelect,
|
||||||
smartSelectedTotalDuration,
|
smartSelectedTotalDuration: smartMatch.smartSelectedTotalDuration,
|
||||||
// utils
|
// utils
|
||||||
formatDuration,
|
formatDuration,
|
||||||
}
|
}
|
||||||
|
|||||||
Regular → Executable
+24
-43
@@ -2,15 +2,15 @@
|
|||||||
* Step 5 配音选择 Hook
|
* Step 5 配音选择 Hook
|
||||||
* 封装 AI 推荐、预设音色试听、TTS 自定义合成、存为素材等逻辑
|
* 封装 AI 推荐、预设音色试听、TTS 自定义合成、存为素材等逻辑
|
||||||
*/
|
*/
|
||||||
import { useCallback } from "react"
|
|
||||||
import type { VoiceClone } from "@/api/voice-clone"
|
|
||||||
import { message } from "antd"
|
import { message } from "antd"
|
||||||
|
import type { VoiceClone } from "@/api/voice-clone"
|
||||||
import { VOICE_GENDER_ICON, CLONE_STATUS_CONFIG } from "../constants"
|
import { VOICE_GENDER_ICON, CLONE_STATUS_CONFIG } from "../constants"
|
||||||
import { formatDuration } from "../utils/formatDuration"
|
import { formatDuration } from "../utils/formatDuration"
|
||||||
import { useVoiceAudio } from "./step5-voice/useVoiceAudio"
|
import { useVoiceAudio } from "./step5-voice/useVoiceAudio"
|
||||||
import { useVoiceRecommend } from "./step5-voice/useVoiceRecommend"
|
import { useVoiceRecommend } from "./step5-voice/useVoiceRecommend"
|
||||||
import { useTtsSynthesis } from "./step5-voice/useTtsSynthesis"
|
import { useTtsSynthesis } from "./step5-voice/useTtsSynthesis"
|
||||||
import { useSaveToLibrary } from "./step5-voice/useSaveToLibrary"
|
import { useSaveToLibrary } from "./step5-voice/useSaveToLibrary"
|
||||||
|
import { useVoiceModeSelection } from "./step5-voice/useVoiceModeSelection"
|
||||||
|
|
||||||
interface UseStep5VoiceProps {
|
interface UseStep5VoiceProps {
|
||||||
selectedVoice: string
|
selectedVoice: string
|
||||||
@@ -81,48 +81,29 @@ export function useStep5Voice({
|
|||||||
handleAddTagInModal,
|
handleAddTagInModal,
|
||||||
} = useSaveToLibrary(completedTtsJobId, resetTtsState)
|
} = useSaveToLibrary(completedTtsJobId, resetTtsState)
|
||||||
|
|
||||||
/* ── 推荐音色选择 ── */
|
const {
|
||||||
const handleSelectRecommendedVoice = useCallback(
|
handleSelectRecommendedVoice,
|
||||||
(voiceId: string) => {
|
handleSelectPresetVoice,
|
||||||
onVoiceModeChange("preset")
|
handleSelectCloneVoice,
|
||||||
onSelectedVoiceChange(voiceId)
|
handleSelectClonedVoice,
|
||||||
},
|
handleOpenCloneModal,
|
||||||
[onVoiceModeChange, onSelectedVoiceChange],
|
handleCloneSuccess: rawCloneSuccess,
|
||||||
)
|
} = useVoiceModeSelection({
|
||||||
|
selectedVoice,
|
||||||
|
onSelectedVoiceChange,
|
||||||
|
voiceMode,
|
||||||
|
onVoiceModeChange,
|
||||||
|
selectedClonedVoice,
|
||||||
|
onSelectedClonedVoiceChange,
|
||||||
|
addClone,
|
||||||
|
cloneModalOpen,
|
||||||
|
onCloneModalOpenChange,
|
||||||
|
})
|
||||||
|
|
||||||
/* ── 克隆成功回调 ── */
|
const handleCloneSuccess = (voice: VoiceClone) => {
|
||||||
const handleCloneSuccess = useCallback(
|
rawCloneSuccess(voice)
|
||||||
(voice: VoiceClone) => {
|
message.success("音色克隆成功!")
|
||||||
addClone(voice)
|
}
|
||||||
onCloneModalOpenChange(false)
|
|
||||||
message.success("音色克隆成功!")
|
|
||||||
},
|
|
||||||
[addClone, onCloneModalOpenChange],
|
|
||||||
)
|
|
||||||
|
|
||||||
/* ── 预设音色选择操作 ── */
|
|
||||||
const handleSelectPresetVoice = useCallback(
|
|
||||||
(voiceId: string) => {
|
|
||||||
onVoiceModeChange("preset")
|
|
||||||
onSelectedVoiceChange(voiceId)
|
|
||||||
},
|
|
||||||
[onVoiceModeChange, onSelectedVoiceChange],
|
|
||||||
)
|
|
||||||
|
|
||||||
const handleSelectCloneVoice = useCallback(() => {
|
|
||||||
onVoiceModeChange("clone")
|
|
||||||
}, [onVoiceModeChange])
|
|
||||||
|
|
||||||
const handleSelectClonedVoice = useCallback(
|
|
||||||
(voiceId: string) => {
|
|
||||||
onSelectedClonedVoiceChange(voiceId)
|
|
||||||
},
|
|
||||||
[onSelectedClonedVoiceChange],
|
|
||||||
)
|
|
||||||
|
|
||||||
const handleOpenCloneModal = useCallback(() => {
|
|
||||||
onCloneModalOpenChange(true)
|
|
||||||
}, [onCloneModalOpenChange])
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
// 数据
|
// 数据
|
||||||
|
|||||||
@@ -0,0 +1,30 @@
|
|||||||
|
import { useQueryClient } from "@tanstack/react-query"
|
||||||
|
import { message } from "antd"
|
||||||
|
import { deleteProduct } from "@/api/products"
|
||||||
|
|
||||||
|
interface UseBatchDeleteOptions {
|
||||||
|
selectedIds: Set<string>
|
||||||
|
clearSelection: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useBatchDelete({ selectedIds, clearSelection }: UseBatchDeleteOptions) {
|
||||||
|
const queryClient = useQueryClient()
|
||||||
|
|
||||||
|
const handleBatchDelete = async () => {
|
||||||
|
const ids = Array.from(selectedIds)
|
||||||
|
let successCount = 0
|
||||||
|
for (const id of ids) {
|
||||||
|
try {
|
||||||
|
await deleteProduct(id)
|
||||||
|
successCount++
|
||||||
|
} catch {
|
||||||
|
// 忽略单个失败
|
||||||
|
}
|
||||||
|
}
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["products"] })
|
||||||
|
clearSelection()
|
||||||
|
message.success(`已批量删除 ${successCount}/${ids.length} 个视频`)
|
||||||
|
}
|
||||||
|
|
||||||
|
return { handleBatchDelete }
|
||||||
|
}
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
import { useState, useCallback } from "react"
|
||||||
|
import { message } from "antd"
|
||||||
|
import { batchDownload, getBatchDownloadStatus } from "@/api/products"
|
||||||
|
|
||||||
|
interface UseBatchDownloadOptions {
|
||||||
|
selectedIds: Set<string>
|
||||||
|
clearSelection: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useBatchDownload({ selectedIds, clearSelection }: UseBatchDownloadOptions) {
|
||||||
|
const [batchDownloading, setBatchDownloading] = useState(false)
|
||||||
|
|
||||||
|
const handleBatchDownload = useCallback(async () => {
|
||||||
|
const ids = Array.from(selectedIds)
|
||||||
|
if (ids.length === 0) return
|
||||||
|
setBatchDownloading(true)
|
||||||
|
try {
|
||||||
|
const { job_id } = await batchDownload(ids)
|
||||||
|
message.info(`批量下载任务已创建,正在打包 ${ids.length} 个视频...`)
|
||||||
|
|
||||||
|
let attempts = 0
|
||||||
|
const maxAttempts = 60
|
||||||
|
const poll = async (): Promise<void> => {
|
||||||
|
if (attempts >= maxAttempts) {
|
||||||
|
message.warning("打包超时,请稍后在消息中心查看")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
attempts++
|
||||||
|
await new Promise((r) => setTimeout(r, 2000))
|
||||||
|
const status = await getBatchDownloadStatus(job_id)
|
||||||
|
if (status.status === "completed" && status.download_url) {
|
||||||
|
const a = document.createElement("a")
|
||||||
|
a.href = status.download_url
|
||||||
|
a.download = ""
|
||||||
|
a.click()
|
||||||
|
message.success(`已打包下载 ${ids.length} 个视频`)
|
||||||
|
clearSelection()
|
||||||
|
} else if (status.status === "failed") {
|
||||||
|
message.error("批量下载失败,请重试")
|
||||||
|
} else {
|
||||||
|
await poll()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await poll()
|
||||||
|
} catch {
|
||||||
|
message.error("发起批量下载失败")
|
||||||
|
} finally {
|
||||||
|
setBatchDownloading(false)
|
||||||
|
}
|
||||||
|
}, [selectedIds, clearSelection])
|
||||||
|
|
||||||
|
return { batchDownloading, handleBatchDownload }
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
import { useMutation, useQueryClient } from "@tanstack/react-query"
|
||||||
|
import { message } from "antd"
|
||||||
|
import { deleteProduct, updateReviewStatus, type ReviewStatus } from "@/api/products"
|
||||||
|
|
||||||
|
export function useProductMutations() {
|
||||||
|
const queryClient = useQueryClient()
|
||||||
|
|
||||||
|
const deleteMutation = useMutation({
|
||||||
|
mutationFn: deleteProduct,
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["products"] })
|
||||||
|
message.success("已删除")
|
||||||
|
},
|
||||||
|
onError: () => {
|
||||||
|
message.error("删除失败")
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const reviewMutation = useMutation({
|
||||||
|
mutationFn: ({ id, status }: { id: string; status: ReviewStatus }) =>
|
||||||
|
updateReviewStatus(id, status),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["products"] })
|
||||||
|
message.success("复核状态已更新")
|
||||||
|
},
|
||||||
|
onError: () => {
|
||||||
|
message.error("更新复核状态失败")
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
deleteMutation,
|
||||||
|
reviewMutation,
|
||||||
|
isDeleting: deleteMutation.isPending,
|
||||||
|
isUpdatingReview: reviewMutation.isPending,
|
||||||
|
}
|
||||||
|
}
|
||||||
Regular → Executable
+12
-104
@@ -1,17 +1,11 @@
|
|||||||
import { useState } from "react"
|
|
||||||
import { useMutation, useQueryClient } from "@tanstack/react-query"
|
|
||||||
import { useNavigate } from "react-router-dom"
|
import { useNavigate } from "react-router-dom"
|
||||||
import { message } from "antd"
|
import { message } from "antd"
|
||||||
import {
|
import { getProductDownloadUrl } from "@/api/products"
|
||||||
deleteProduct,
|
|
||||||
getProductDownloadUrl,
|
|
||||||
updateReviewStatus,
|
|
||||||
batchDownload,
|
|
||||||
getBatchDownloadStatus,
|
|
||||||
type ReviewStatus,
|
|
||||||
} from "@/api/products"
|
|
||||||
import type { ProductItem } from "../types"
|
import type { ProductItem } from "../types"
|
||||||
import { getNextReviewStatus } from "../utils"
|
import { getNextReviewStatus } from "../utils"
|
||||||
|
import { useBatchDownload } from "./product-actions/useBatchDownload"
|
||||||
|
import { useBatchDelete } from "./product-actions/useBatchDelete"
|
||||||
|
import { useProductMutations } from "./product-actions/useProductMutations"
|
||||||
|
|
||||||
interface UseProductActionsOptions {
|
interface UseProductActionsOptions {
|
||||||
selectedIds: Set<string>
|
selectedIds: Set<string>
|
||||||
@@ -26,35 +20,17 @@ export const useProductActions = ({
|
|||||||
products,
|
products,
|
||||||
setPlayingProduct,
|
setPlayingProduct,
|
||||||
}: UseProductActionsOptions) => {
|
}: UseProductActionsOptions) => {
|
||||||
const queryClient = useQueryClient()
|
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
|
|
||||||
/* ── 删除 mutation ── */
|
const { deleteMutation, reviewMutation, isDeleting, isUpdatingReview } = useProductMutations()
|
||||||
const deleteMutation = useMutation({
|
|
||||||
mutationFn: deleteProduct,
|
const { batchDownloading, handleBatchDownload } = useBatchDownload({
|
||||||
onSuccess: () => {
|
selectedIds,
|
||||||
queryClient.invalidateQueries({ queryKey: ["products"] })
|
clearSelection,
|
||||||
message.success("已删除")
|
|
||||||
},
|
|
||||||
onError: () => {
|
|
||||||
message.error("删除失败")
|
|
||||||
},
|
|
||||||
})
|
})
|
||||||
|
|
||||||
/* ── 复核状态 mutation ── */
|
const { handleBatchDelete } = useBatchDelete({ selectedIds, clearSelection })
|
||||||
const reviewMutation = useMutation({
|
|
||||||
mutationFn: ({ id, status }: { id: string; status: ReviewStatus }) =>
|
|
||||||
updateReviewStatus(id, status),
|
|
||||||
onSuccess: () => {
|
|
||||||
queryClient.invalidateQueries({ queryKey: ["products"] })
|
|
||||||
message.success("复核状态已更新")
|
|
||||||
},
|
|
||||||
onError: () => {
|
|
||||||
message.error("更新复核状态失败")
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
/* 下载 — 调用真实 API 获取下载链接 */
|
|
||||||
const handleDownload = async (product: ProductItem) => {
|
const handleDownload = async (product: ProductItem) => {
|
||||||
if (product.status !== "completed") return
|
if (product.status !== "completed") return
|
||||||
try {
|
try {
|
||||||
@@ -69,7 +45,6 @@ export const useProductActions = ({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 分享 */
|
|
||||||
const handleShare = (product: ProductItem) => {
|
const handleShare = (product: ProductItem) => {
|
||||||
if (product.status !== "completed") return
|
if (product.status !== "completed") return
|
||||||
const link = `${window.location.origin}/share/${product.id}`
|
const link = `${window.location.origin}/share/${product.id}`
|
||||||
@@ -79,109 +54,42 @@ export const useProductActions = ({
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 查看详情 — 跳转到产品详情页 */
|
|
||||||
const handleViewDetail = (product: ProductItem) => {
|
const handleViewDetail = (product: ProductItem) => {
|
||||||
setPlayingProduct(null)
|
setPlayingProduct(null)
|
||||||
navigate(`/app/products/${product.id}`)
|
navigate(`/app/products/${product.id}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 删除 */
|
|
||||||
const handleDelete = (id: string) => {
|
const handleDelete = (id: string) => {
|
||||||
deleteMutation.mutate(id)
|
deleteMutation.mutate(id)
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 发布 — TODO: 后端发布 API 待实现 */
|
|
||||||
const handlePublish = (_product: ProductItem) => {
|
const handlePublish = (_product: ProductItem) => {
|
||||||
message.info("发布功能待后端 API 补齐")
|
message.info("发布功能待后端 API 补齐")
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 切换复核状态 */
|
|
||||||
const handleReviewStatusChange = (id: string) => {
|
const handleReviewStatusChange = (id: string) => {
|
||||||
const current = products.find((p) => p.id === id)?.reviewStatus
|
const current = products.find((p) => p.id === id)?.reviewStatus
|
||||||
const nextStatus = getNextReviewStatus(current)
|
const nextStatus = getNextReviewStatus(current)
|
||||||
reviewMutation.mutate({ id, status: nextStatus })
|
reviewMutation.mutate({ id, status: nextStatus })
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 批量下载 */
|
|
||||||
const [batchDownloading, setBatchDownloading] = useState(false)
|
|
||||||
|
|
||||||
const handleBatchDownload = async () => {
|
|
||||||
const ids = Array.from(selectedIds)
|
|
||||||
if (ids.length === 0) return
|
|
||||||
setBatchDownloading(true)
|
|
||||||
try {
|
|
||||||
const { job_id } = await batchDownload(ids)
|
|
||||||
message.info(`批量下载任务已创建,正在打包 ${ids.length} 个视频...`)
|
|
||||||
|
|
||||||
let attempts = 0
|
|
||||||
const maxAttempts = 60
|
|
||||||
const poll = async (): Promise<void> => {
|
|
||||||
if (attempts >= maxAttempts) {
|
|
||||||
message.warning("打包超时,请稍后在消息中心查看")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
attempts++
|
|
||||||
await new Promise((r) => setTimeout(r, 2000))
|
|
||||||
const status = await getBatchDownloadStatus(job_id)
|
|
||||||
if (status.status === "completed" && status.download_url) {
|
|
||||||
const a = document.createElement("a")
|
|
||||||
a.href = status.download_url
|
|
||||||
a.download = ""
|
|
||||||
a.click()
|
|
||||||
message.success(`已打包下载 ${ids.length} 个视频`)
|
|
||||||
clearSelection()
|
|
||||||
} else if (status.status === "failed") {
|
|
||||||
message.error("批量下载失败,请重试")
|
|
||||||
} else {
|
|
||||||
await poll()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
await poll()
|
|
||||||
} catch {
|
|
||||||
message.error("发起批量下载失败")
|
|
||||||
} finally {
|
|
||||||
setBatchDownloading(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 批量删除 */
|
|
||||||
const handleBatchDelete = async () => {
|
|
||||||
const ids = Array.from(selectedIds)
|
|
||||||
let successCount = 0
|
|
||||||
for (const id of ids) {
|
|
||||||
try {
|
|
||||||
await deleteProduct(id)
|
|
||||||
successCount++
|
|
||||||
} catch {
|
|
||||||
// 忽略单个失败
|
|
||||||
}
|
|
||||||
}
|
|
||||||
queryClient.invalidateQueries({ queryKey: ["products"] })
|
|
||||||
clearSelection()
|
|
||||||
message.success(`已批量删除 ${successCount}/${ids.length} 个视频`)
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 批量发布 */
|
|
||||||
const handleBatchPublish = () => {
|
const handleBatchPublish = () => {
|
||||||
message.info("批量发布功能待后端 API 补齐")
|
message.info("批量发布功能待后端 API 补齐")
|
||||||
clearSelection()
|
clearSelection()
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
// 单个操作
|
|
||||||
handleDownload,
|
handleDownload,
|
||||||
handleShare,
|
handleShare,
|
||||||
handleViewDetail,
|
handleViewDetail,
|
||||||
handleDelete,
|
handleDelete,
|
||||||
handlePublish,
|
handlePublish,
|
||||||
handleReviewStatusChange,
|
handleReviewStatusChange,
|
||||||
// 批量操作
|
|
||||||
handleBatchDownload,
|
handleBatchDownload,
|
||||||
handleBatchDelete,
|
handleBatchDelete,
|
||||||
handleBatchPublish,
|
handleBatchPublish,
|
||||||
batchDownloading,
|
batchDownloading,
|
||||||
// mutation 状态
|
isDeleting,
|
||||||
isDeleting: deleteMutation.isPending,
|
isUpdatingReview,
|
||||||
isUpdatingReview: reviewMutation.isPending,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Regular → Executable
+20
-136
@@ -1,25 +1,13 @@
|
|||||||
import React from "react"
|
import React from "react"
|
||||||
import { Button, Descriptions, Tooltip } from "antd"
|
import { Descriptions } from "antd"
|
||||||
import { CopyOutlined, ThunderboltOutlined } from "@ant-design/icons"
|
import type { TemplateDetailModalProps } from "./template-detail-modal/types"
|
||||||
import type { TemplateItem, TemplateSegment } from "@/api/templates"
|
import PreviewArea from "./template-detail-modal/PreviewArea"
|
||||||
import {
|
import SegmentList from "./template-detail-modal/SegmentList"
|
||||||
gradientForCategory,
|
import StyleConfig from "./template-detail-modal/StyleConfig"
|
||||||
getTypeColor,
|
import DetailFooter from "./template-detail-modal/DetailFooter"
|
||||||
formatDuration,
|
import { getTypeColor, formatDuration } from "../../utils/templateLibrary"
|
||||||
formatConfig,
|
|
||||||
getMaterialTypeLabel,
|
|
||||||
calcTotalSegmentDuration,
|
|
||||||
} from "../../utils/templateLibrary"
|
|
||||||
import { TEMPLATE_TYPES } from "../../constants/templateLibrary"
|
import { TEMPLATE_TYPES } from "../../constants/templateLibrary"
|
||||||
|
import { calcTotalSegmentDuration } from "../../utils/templateLibrary"
|
||||||
interface TemplateDetailModalProps {
|
|
||||||
template: TemplateItem
|
|
||||||
isFavorite: boolean
|
|
||||||
onClose: () => void
|
|
||||||
onToggleFavorite: (id: string) => void
|
|
||||||
onUse: (template: TemplateItem) => void
|
|
||||||
onCopy: (template: TemplateItem) => void
|
|
||||||
}
|
|
||||||
|
|
||||||
export const TemplateDetailModal: React.FC<TemplateDetailModalProps> = ({
|
export const TemplateDetailModal: React.FC<TemplateDetailModalProps> = ({
|
||||||
template,
|
template,
|
||||||
@@ -31,6 +19,7 @@ export const TemplateDetailModal: React.FC<TemplateDetailModalProps> = ({
|
|||||||
}) => {
|
}) => {
|
||||||
const segments = template.segments ?? []
|
const segments = template.segments ?? []
|
||||||
const totalSegmentDuration = calcTotalSegmentDuration(segments)
|
const totalSegmentDuration = calcTotalSegmentDuration(segments)
|
||||||
|
const typeInfo = TEMPLATE_TYPES.find((t) => t.type === template.category)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="xx-template-modal-overlay" onClick={onClose}>
|
<div className="xx-template-modal-overlay" onClick={onClose}>
|
||||||
@@ -38,33 +27,8 @@ export const TemplateDetailModal: React.FC<TemplateDetailModalProps> = ({
|
|||||||
className="xx-template-modal xx-template-modal-wide"
|
className="xx-template-modal xx-template-modal-wide"
|
||||||
onClick={(e) => e.stopPropagation()}
|
onClick={(e) => e.stopPropagation()}
|
||||||
>
|
>
|
||||||
{/* 关闭按钮 */}
|
<PreviewArea template={template} onClose={onClose} />
|
||||||
<button className="xx-template-modal-close" onClick={onClose} title="关闭">
|
|
||||||
✕
|
|
||||||
</button>
|
|
||||||
|
|
||||||
{/* 预览区域 */}
|
|
||||||
<div
|
|
||||||
className="xx-template-modal-preview"
|
|
||||||
style={{ background: gradientForCategory(template.category) }}
|
|
||||||
>
|
|
||||||
{template.thumbnail_url ? (
|
|
||||||
<img
|
|
||||||
src={template.thumbnail_url}
|
|
||||||
alt={template.name}
|
|
||||||
className="xx-template-modal-thumb-img"
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<div className="xx-template-modal-preview-content">
|
|
||||||
<span className="xx-template-preview-icon">
|
|
||||||
{TEMPLATE_TYPES.find((t) => t.type === template.category)?.icon ?? "📋"}
|
|
||||||
</span>
|
|
||||||
<span className="xx-template-preview-title">{template.name}</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 内容区域 */}
|
|
||||||
<div className="xx-template-modal-content">
|
<div className="xx-template-modal-content">
|
||||||
{/* 标题行 */}
|
{/* 标题行 */}
|
||||||
<div className="xx-template-modal-title-row">
|
<div className="xx-template-modal-title-row">
|
||||||
@@ -76,7 +40,7 @@ export const TemplateDetailModal: React.FC<TemplateDetailModalProps> = ({
|
|||||||
background: `${getTypeColor(template.category)}18`,
|
background: `${getTypeColor(template.category)}18`,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{TEMPLATE_TYPES.find((t) => t.type === template.category)?.icon} {template.category}
|
{typeInfo?.icon} {template.category}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -123,95 +87,15 @@ export const TemplateDetailModal: React.FC<TemplateDetailModalProps> = ({
|
|||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* 素材规则(片段配置) */}
|
<SegmentList segments={segments} totalDuration={totalSegmentDuration} />
|
||||||
{segments.length > 0 && (
|
<StyleConfig template={template} />
|
||||||
<div className="xx-template-modal-section">
|
<DetailFooter
|
||||||
<h4>🎬 素材规则</h4>
|
template={template}
|
||||||
<div className="xx-template-modal-clip-list">
|
isFavorite={isFavorite}
|
||||||
{segments
|
onToggleFavorite={onToggleFavorite}
|
||||||
.sort((a, b) => a.segment_order - b.segment_order)
|
onUse={onUse}
|
||||||
.map((seg: TemplateSegment, idx: number) => (
|
onCopy={onCopy}
|
||||||
<div key={seg.id ?? idx} className="xx-template-modal-clip-item">
|
/>
|
||||||
<span className="xx-template-modal-clip-order">#{seg.segment_order}</span>
|
|
||||||
<span
|
|
||||||
className="xx-template-modal-clip-badge"
|
|
||||||
style={{
|
|
||||||
color: seg.material_type ? getTypeColor(seg.material_type) : "#64748b",
|
|
||||||
background: seg.material_type
|
|
||||||
? `${getTypeColor(seg.material_type)}18`
|
|
||||||
: "#f1f5f9",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{getMaterialTypeLabel(seg.material_type)}
|
|
||||||
</span>
|
|
||||||
<span className="xx-template-modal-clip-desc">
|
|
||||||
{seg.description || `片段 ${seg.segment_order}`}
|
|
||||||
</span>
|
|
||||||
<Tooltip title={`时长范围: ${seg.duration_min}秒 - ${seg.duration_max}秒`}>
|
|
||||||
<span className="xx-template-modal-clip-duration">
|
|
||||||
{seg.duration_min}-{seg.duration_max}秒
|
|
||||||
</span>
|
|
||||||
</Tooltip>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
<div className="xx-template-modal-total-duration">
|
|
||||||
预估总时长:{formatDuration(Math.round(totalSegmentDuration))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* 样式配置 */}
|
|
||||||
<div className="xx-template-modal-section">
|
|
||||||
<h4>🎨 样式配置</h4>
|
|
||||||
<div className="xx-template-modal-style-grid">
|
|
||||||
<div className="xx-template-modal-style-item">
|
|
||||||
<span className="xx-template-modal-style-label">字幕样式</span>
|
|
||||||
<span className="xx-template-modal-style-value">
|
|
||||||
{formatConfig(template.subtitle_config)}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className="xx-template-modal-style-item">
|
|
||||||
<span className="xx-template-modal-style-label">标题样式</span>
|
|
||||||
<span className="xx-template-modal-style-value">
|
|
||||||
{formatConfig(template.title_config)}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className="xx-template-modal-style-item">
|
|
||||||
<span className="xx-template-modal-style-label">BGM 配置</span>
|
|
||||||
<span className="xx-template-modal-style-value">
|
|
||||||
{formatConfig(template.bgm_config)}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className="xx-template-modal-style-item">
|
|
||||||
<span className="xx-template-modal-style-label">视频比例</span>
|
|
||||||
<span className="xx-template-modal-style-value">
|
|
||||||
{template.aspect_ratio ?? "16:9"}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 统计信息 */}
|
|
||||||
<div className="xx-template-modal-stats">
|
|
||||||
<span>已使用 {template.usage_count ?? 0} 次</span>
|
|
||||||
<button
|
|
||||||
className={`xx-template-modal-fav-btn${isFavorite ? " is-favorite" : ""}`}
|
|
||||||
onClick={() => onToggleFavorite(template.id)}
|
|
||||||
>
|
|
||||||
{isFavorite ? "★ 已收藏" : "☆ 收藏"}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 操作按钮 */}
|
|
||||||
<div className="xx-template-modal-actions">
|
|
||||||
<Button icon={<CopyOutlined />} onClick={() => onCopy(template)}>
|
|
||||||
复制模板
|
|
||||||
</Button>
|
|
||||||
<Button type="primary" icon={<ThunderboltOutlined />} onClick={() => onUse(template)}>
|
|
||||||
使用此模板生成
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Executable
+46
@@ -0,0 +1,46 @@
|
|||||||
|
import React from "react"
|
||||||
|
import { Button } from "antd"
|
||||||
|
import { CopyOutlined, ThunderboltOutlined } from "@ant-design/icons"
|
||||||
|
import type { TemplateItem } from "@/api/templates"
|
||||||
|
|
||||||
|
interface DetailFooterProps {
|
||||||
|
template: TemplateItem
|
||||||
|
isFavorite: boolean
|
||||||
|
onToggleFavorite: (id: string) => void
|
||||||
|
onUse: (template: TemplateItem) => void
|
||||||
|
onCopy: (template: TemplateItem) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 底部操作区:统计 + 收藏 + 按钮 */
|
||||||
|
const DetailFooter: React.FC<DetailFooterProps> = ({
|
||||||
|
template,
|
||||||
|
isFavorite,
|
||||||
|
onToggleFavorite,
|
||||||
|
onUse,
|
||||||
|
onCopy,
|
||||||
|
}) => {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="xx-template-modal-stats">
|
||||||
|
<span>已使用 {template.usage_count ?? 0} 次</span>
|
||||||
|
<button
|
||||||
|
className={`xx-template-modal-fav-btn${isFavorite ? " is-favorite" : ""}`}
|
||||||
|
onClick={() => onToggleFavorite(template.id)}
|
||||||
|
>
|
||||||
|
{isFavorite ? "★ 已收藏" : "☆ 收藏"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="xx-template-modal-actions">
|
||||||
|
<Button icon={<CopyOutlined />} onClick={() => onCopy(template)}>
|
||||||
|
复制模板
|
||||||
|
</Button>
|
||||||
|
<Button type="primary" icon={<ThunderboltOutlined />} onClick={() => onUse(template)}>
|
||||||
|
使用此模板生成
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default DetailFooter
|
||||||
Executable
+39
@@ -0,0 +1,39 @@
|
|||||||
|
import React from "react"
|
||||||
|
import type { TemplateItem } from "@/api/templates"
|
||||||
|
import { gradientForCategory } from "../../../utils/templateLibrary"
|
||||||
|
import { TEMPLATE_TYPES } from "../../../constants/templateLibrary"
|
||||||
|
|
||||||
|
interface PreviewAreaProps {
|
||||||
|
template: TemplateItem
|
||||||
|
onClose: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 预览区域 */
|
||||||
|
const PreviewArea: React.FC<PreviewAreaProps> = ({ template, onClose }) => {
|
||||||
|
const typeInfo = TEMPLATE_TYPES.find((t) => t.type === template.category)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="xx-template-modal-preview"
|
||||||
|
style={{ background: gradientForCategory(template.category) }}
|
||||||
|
>
|
||||||
|
<button className="xx-template-modal-close" onClick={onClose} title="关闭">
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
|
{template.thumbnail_url ? (
|
||||||
|
<img
|
||||||
|
src={template.thumbnail_url}
|
||||||
|
alt={template.name}
|
||||||
|
className="xx-template-modal-thumb-img"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="xx-template-modal-preview-content">
|
||||||
|
<span className="xx-template-preview-icon">{typeInfo?.icon ?? "📋"}</span>
|
||||||
|
<span className="xx-template-preview-title">{template.name}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default PreviewArea
|
||||||
Executable
+53
@@ -0,0 +1,53 @@
|
|||||||
|
import React from "react"
|
||||||
|
import { Tooltip } from "antd"
|
||||||
|
import type { TemplateSegment } from "@/api/templates"
|
||||||
|
import { getMaterialTypeLabel, getTypeColor, formatDuration } from "../../../utils/templateLibrary"
|
||||||
|
|
||||||
|
interface SegmentListProps {
|
||||||
|
segments: TemplateSegment[]
|
||||||
|
totalDuration: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 素材规则 / 片段列表 */
|
||||||
|
const SegmentList: React.FC<SegmentListProps> = ({ segments, totalDuration }) => {
|
||||||
|
if (segments.length === 0) return null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="xx-template-modal-section">
|
||||||
|
<h4>🎬 素材规则</h4>
|
||||||
|
<div className="xx-template-modal-clip-list">
|
||||||
|
{segments
|
||||||
|
.sort((a, b) => a.segment_order - b.segment_order)
|
||||||
|
.map((seg, idx) => (
|
||||||
|
<div key={seg.id ?? idx} className="xx-template-modal-clip-item">
|
||||||
|
<span className="xx-template-modal-clip-order">#{seg.segment_order}</span>
|
||||||
|
<span
|
||||||
|
className="xx-template-modal-clip-badge"
|
||||||
|
style={{
|
||||||
|
color: seg.material_type ? getTypeColor(seg.material_type) : "#64748b",
|
||||||
|
background: seg.material_type
|
||||||
|
? `${getTypeColor(seg.material_type)}18`
|
||||||
|
: "#f1f5f9",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{getMaterialTypeLabel(seg.material_type)}
|
||||||
|
</span>
|
||||||
|
<span className="xx-template-modal-clip-desc">
|
||||||
|
{seg.description || `片段 ${seg.segment_order}`}
|
||||||
|
</span>
|
||||||
|
<Tooltip title={`时长范围: ${seg.duration_min}秒 - ${seg.duration_max}秒`}>
|
||||||
|
<span className="xx-template-modal-clip-duration">
|
||||||
|
{seg.duration_min}-{seg.duration_max}秒
|
||||||
|
</span>
|
||||||
|
</Tooltip>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="xx-template-modal-total-duration">
|
||||||
|
预估总时长:{formatDuration(Math.round(totalDuration))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default SegmentList
|
||||||
Executable
+33
@@ -0,0 +1,33 @@
|
|||||||
|
import React from "react"
|
||||||
|
import type { TemplateItem } from "@/api/templates"
|
||||||
|
import { formatConfig } from "../../../utils/templateLibrary"
|
||||||
|
|
||||||
|
interface StyleConfigProps {
|
||||||
|
template: TemplateItem
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 样式配置网格 */
|
||||||
|
const StyleConfig: React.FC<StyleConfigProps> = ({ template }) => {
|
||||||
|
const items = [
|
||||||
|
{ label: "字幕样式", value: formatConfig(template.subtitle_config) },
|
||||||
|
{ label: "标题样式", value: formatConfig(template.title_config) },
|
||||||
|
{ label: "BGM 配置", value: formatConfig(template.bgm_config) },
|
||||||
|
{ label: "视频比例", value: template.aspect_ratio ?? "16:9" },
|
||||||
|
]
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="xx-template-modal-section">
|
||||||
|
<h4>🎨 样式配置</h4>
|
||||||
|
<div className="xx-template-modal-style-grid">
|
||||||
|
{items.map((item) => (
|
||||||
|
<div key={item.label} className="xx-template-modal-style-item">
|
||||||
|
<span className="xx-template-modal-style-label">{item.label}</span>
|
||||||
|
<span className="xx-template-modal-style-value">{item.value}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default StyleConfig
|
||||||
+6
@@ -0,0 +1,6 @@
|
|||||||
|
export { TemplateDetailModal } from "../TemplateDetailModal"
|
||||||
|
export * from "./types"
|
||||||
|
export { default as PreviewArea } from "./PreviewArea"
|
||||||
|
export { default as SegmentList } from "./SegmentList"
|
||||||
|
export { default as StyleConfig } from "./StyleConfig"
|
||||||
|
export { default as DetailFooter } from "./DetailFooter"
|
||||||
+10
@@ -0,0 +1,10 @@
|
|||||||
|
import type { TemplateItem } from "@/api/templates"
|
||||||
|
|
||||||
|
export interface TemplateDetailModalProps {
|
||||||
|
template: TemplateItem
|
||||||
|
isFavorite: boolean
|
||||||
|
onClose: () => void
|
||||||
|
onToggleFavorite: (id: string) => void
|
||||||
|
onUse: (template: TemplateItem) => void
|
||||||
|
onCopy: (template: TemplateItem) => void
|
||||||
|
}
|
||||||
Regular → Executable
+5
-81
@@ -1,11 +1,10 @@
|
|||||||
import React, { useState, useRef } from "react"
|
import React, { useState } from "react"
|
||||||
import { UploadOutlined, SoundOutlined, CloseOutlined } from "@ant-design/icons"
|
|
||||||
import { Button, Input } from "@/components/ui"
|
import { Button, Input } from "@/components/ui"
|
||||||
import { type TagItem } from "@/api/tags"
|
import { type TagItem } from "@/api/tags"
|
||||||
import { type VoiceGender, type VoiceMaterial } from "../types"
|
import { type VoiceGender, type VoiceMaterial } from "../types"
|
||||||
import { GENDER_OPTIONS } from "../constants"
|
|
||||||
import { genderClass, formatFileSize } from "../utils/format"
|
|
||||||
import TagSelector from "./TagSelector"
|
import TagSelector from "./TagSelector"
|
||||||
|
import FileUploadField from "./material-form/FileUploadField"
|
||||||
|
import GenderSelector from "./material-form/GenderSelector"
|
||||||
|
|
||||||
export interface MaterialFormProps {
|
export interface MaterialFormProps {
|
||||||
initial?: VoiceMaterial
|
initial?: VoiceMaterial
|
||||||
@@ -33,7 +32,6 @@ const MaterialForm: React.FC<MaterialFormProps> = ({
|
|||||||
const [gender, setGender] = useState<VoiceGender>(initial?.gender ?? "female")
|
const [gender, setGender] = useState<VoiceGender>(initial?.gender ?? "female")
|
||||||
const [selectedTagIds, setSelectedTagIds] = useState<string[]>(initial?.tagIds ?? [])
|
const [selectedTagIds, setSelectedTagIds] = useState<string[]>(initial?.tagIds ?? [])
|
||||||
const [file, setFile] = useState<File | undefined>(undefined)
|
const [file, setFile] = useState<File | undefined>(undefined)
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null)
|
|
||||||
|
|
||||||
const handleSubmit = () => {
|
const handleSubmit = () => {
|
||||||
if (!name.trim()) return
|
if (!name.trim()) return
|
||||||
@@ -53,65 +51,10 @@ const MaterialForm: React.FC<MaterialFormProps> = ({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="vmat-form">
|
<div className="vmat-form">
|
||||||
{/* 音频文件上传(编辑模式不显示) */}
|
|
||||||
{!initial && (
|
{!initial && (
|
||||||
<div className="vmat-form-field">
|
<FileUploadField file={file} onChange={setFile} uploadProgress={uploadProgress} />
|
||||||
<label className="vmat-form-label">音频文件 *</label>
|
|
||||||
<div
|
|
||||||
className="vmat-upload-zone"
|
|
||||||
onClick={() => fileInputRef.current?.click()}
|
|
||||||
onDragOver={(e) => e.preventDefault()}
|
|
||||||
onDrop={(e) => {
|
|
||||||
e.preventDefault()
|
|
||||||
const f = e.dataTransfer.files[0]
|
|
||||||
if (f?.type.startsWith("audio/")) setFile(f)
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<input
|
|
||||||
ref={fileInputRef}
|
|
||||||
type="file"
|
|
||||||
accept="audio/*"
|
|
||||||
style={{ display: "none" }}
|
|
||||||
onChange={(e) => {
|
|
||||||
const f = e.target.files?.[0]
|
|
||||||
if (f) setFile(f)
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
{file ? (
|
|
||||||
<div className="vmat-upload-selected">
|
|
||||||
<SoundOutlined className="vmat-upload-icon" />
|
|
||||||
<span className="vmat-upload-filename">{file.name}</span>
|
|
||||||
<span className="vmat-upload-filesize">{formatFileSize(file.size)}</span>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="vmat-upload-clear"
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation()
|
|
||||||
setFile(undefined)
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<CloseOutlined />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="vmat-upload-placeholder">
|
|
||||||
<UploadOutlined className="vmat-upload-icon" />
|
|
||||||
<p>点击或拖拽音频文件到此处</p>
|
|
||||||
<span>支持 MP3、WAV、AAC、FLAC 等格式</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
{/* 上传进度条 */}
|
|
||||||
{uploadProgress !== null && uploadProgress !== undefined && (
|
|
||||||
<div className="vmat-upload-progress">
|
|
||||||
<div className="vmat-upload-progress-bar" style={{ width: `${uploadProgress}%` }} />
|
|
||||||
<span className="vmat-upload-progress-text">{uploadProgress}%</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* 名称 */}
|
|
||||||
<div className="vmat-form-field">
|
<div className="vmat-form-field">
|
||||||
<label className="vmat-form-label">名称 *</label>
|
<label className="vmat-form-label">名称 *</label>
|
||||||
<Input
|
<Input
|
||||||
@@ -122,7 +65,6 @@ const MaterialForm: React.FC<MaterialFormProps> = ({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 音色描述 */}
|
|
||||||
<div className="vmat-form-field">
|
<div className="vmat-form-field">
|
||||||
<label className="vmat-form-label">音色描述</label>
|
<label className="vmat-form-label">音色描述</label>
|
||||||
<Input.TextArea
|
<Input.TextArea
|
||||||
@@ -134,25 +76,8 @@ const MaterialForm: React.FC<MaterialFormProps> = ({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 性别 */}
|
<GenderSelector value={gender} onChange={setGender} />
|
||||||
<div className="vmat-form-field">
|
|
||||||
<label className="vmat-form-label">性别</label>
|
|
||||||
<div className="vmat-gender-group">
|
|
||||||
{GENDER_OPTIONS.map((opt) => (
|
|
||||||
<button
|
|
||||||
key={opt.value}
|
|
||||||
type="button"
|
|
||||||
className={`vmat-gender-btn${gender === opt.value ? " active" : ""} ${genderClass(opt.value)}`}
|
|
||||||
onClick={() => setGender(opt.value)}
|
|
||||||
>
|
|
||||||
{opt.icon}
|
|
||||||
{opt.label}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 风格标签 */}
|
|
||||||
<div className="vmat-form-field">
|
<div className="vmat-form-field">
|
||||||
<label className="vmat-form-label">风格标签</label>
|
<label className="vmat-form-label">风格标签</label>
|
||||||
<TagSelector
|
<TagSelector
|
||||||
@@ -164,7 +89,6 @@ const MaterialForm: React.FC<MaterialFormProps> = ({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 操作按钮 */}
|
|
||||||
<div className="vmat-form-actions">
|
<div className="vmat-form-actions">
|
||||||
<Button buttonType="ghost" buttonSize="md" onClick={onCancel}>
|
<Button buttonType="ghost" buttonSize="md" onClick={onCancel}>
|
||||||
取消
|
取消
|
||||||
|
|||||||
Regular → Executable
+16
-72
@@ -1,7 +1,8 @@
|
|||||||
import React, { useState, useRef, useCallback, useMemo } from "react"
|
import React from "react"
|
||||||
import { CheckOutlined } from "@ant-design/icons"
|
import { CheckOutlined } from "@ant-design/icons"
|
||||||
import { Tag } from "@/components/ui"
|
import { Tag } from "@/components/ui"
|
||||||
import { type TagItem } from "@/api/tags"
|
import type { TagItem } from "@/api/tags"
|
||||||
|
import { useTagInput } from "./tag-selector/useTagInput"
|
||||||
|
|
||||||
export interface TagSelectorProps {
|
export interface TagSelectorProps {
|
||||||
/** 已选标签 ID 列表 */
|
/** 已选标签 ID 列表 */
|
||||||
@@ -24,77 +25,22 @@ const TagSelector: React.FC<TagSelectorProps> = ({
|
|||||||
onCreateTag,
|
onCreateTag,
|
||||||
placeholder = "输入标签后回车添加",
|
placeholder = "输入标签后回车添加",
|
||||||
}) => {
|
}) => {
|
||||||
const [inputVal, setInputVal] = useState("")
|
const {
|
||||||
const [showSuggestions, setShowSuggestions] = useState(false)
|
inputVal,
|
||||||
const inputRef = useRef<HTMLInputElement>(null)
|
setInputVal,
|
||||||
|
showSuggestions,
|
||||||
/** 按名称查找已有标签(大小写不敏感) */
|
setShowSuggestions,
|
||||||
const findTagByName = useCallback(
|
inputRef,
|
||||||
(name: string) => tags.find((t) => t.name.toLowerCase() === name.toLowerCase()),
|
suggestions,
|
||||||
[tags],
|
addTagId,
|
||||||
)
|
removeTagId,
|
||||||
|
handleKeyDown,
|
||||||
/** 去重添加标签(按 ID) */
|
focus,
|
||||||
const addTagId = useCallback(
|
} = useTagInput({ value, onChange, tags, onCreateTag })
|
||||||
(tagId: string) => {
|
|
||||||
if (value.includes(tagId)) return
|
|
||||||
onChange([...value, tagId])
|
|
||||||
setInputVal("")
|
|
||||||
setShowSuggestions(false)
|
|
||||||
},
|
|
||||||
[value, onChange],
|
|
||||||
)
|
|
||||||
|
|
||||||
/** 输入自定义标签名:若已存在则直接选,否则创建新标签 */
|
|
||||||
const addTagByName = useCallback(
|
|
||||||
async (name: string) => {
|
|
||||||
const trimmed = name.trim()
|
|
||||||
if (!trimmed) return
|
|
||||||
const existing = findTagByName(trimmed)
|
|
||||||
if (existing) {
|
|
||||||
addTagId(existing.id)
|
|
||||||
} else {
|
|
||||||
try {
|
|
||||||
const created = await onCreateTag(trimmed)
|
|
||||||
addTagId(created.id)
|
|
||||||
} catch {
|
|
||||||
/* 创建失败静默忽略 */
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[findTagByName, addTagId, onCreateTag],
|
|
||||||
)
|
|
||||||
|
|
||||||
const removeTagId = useCallback(
|
|
||||||
(tagId: string) => {
|
|
||||||
onChange(value.filter((t) => t !== tagId))
|
|
||||||
},
|
|
||||||
[value, onChange],
|
|
||||||
)
|
|
||||||
|
|
||||||
/** 输入补全建议(排除已选) */
|
|
||||||
const suggestions = useMemo(() => {
|
|
||||||
if (!inputVal.trim()) return []
|
|
||||||
const lower = inputVal.toLowerCase()
|
|
||||||
return tags.filter((t) => t.name.toLowerCase().includes(lower) && !value.includes(t.id))
|
|
||||||
}, [inputVal, tags, value])
|
|
||||||
|
|
||||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
|
||||||
if (e.key === "Enter") {
|
|
||||||
e.preventDefault()
|
|
||||||
if (suggestions.length > 0) {
|
|
||||||
addTagId(suggestions[0].id)
|
|
||||||
} else {
|
|
||||||
addTagByName(inputVal)
|
|
||||||
}
|
|
||||||
} else if (e.key === "Backspace" && !inputVal && value.length > 0) {
|
|
||||||
removeTagId(value[value.length - 1])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="vmat-tag-selector-wrapper">
|
<div className="vmat-tag-selector-wrapper">
|
||||||
<div className="vmat-tag-selector" onClick={() => inputRef.current?.focus()}>
|
<div className="vmat-tag-selector" onClick={focus}>
|
||||||
{value.map((tagId) => (
|
{value.map((tagId) => (
|
||||||
<Tag key={tagId} variant="info" closable onClose={() => removeTagId(tagId)}>
|
<Tag key={tagId} variant="info" closable onClose={() => removeTagId(tagId)}>
|
||||||
{tagMap.get(tagId)?.name ?? tagId}
|
{tagMap.get(tagId)?.name ?? tagId}
|
||||||
@@ -115,7 +61,6 @@ const TagSelector: React.FC<TagSelectorProps> = ({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 自动补全下拉 */}
|
|
||||||
{showSuggestions && suggestions.length > 0 && (
|
{showSuggestions && suggestions.length > 0 && (
|
||||||
<div className="vmat-tag-suggestions">
|
<div className="vmat-tag-suggestions">
|
||||||
{suggestions.slice(0, 6).map((tag) => (
|
{suggestions.slice(0, 6).map((tag) => (
|
||||||
@@ -134,7 +79,6 @@ const TagSelector: React.FC<TagSelectorProps> = ({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* 已有标签快捷选择 */}
|
|
||||||
{tags.length > 0 && (
|
{tags.length > 0 && (
|
||||||
<div className="vmat-tag-selector-presets">
|
<div className="vmat-tag-selector-presets">
|
||||||
{tags.map((tag) => {
|
{tags.map((tag) => {
|
||||||
|
|||||||
Regular → Executable
+35
-208
@@ -1,45 +1,12 @@
|
|||||||
import React, { useRef } from "react"
|
import React from "react"
|
||||||
import {
|
import { type VoiceCardProps } from "./voice-material-card/types"
|
||||||
AudioOutlined,
|
import BatchCheckbox from "./voice-material-card/BatchCheckbox"
|
||||||
PlayCircleOutlined,
|
import CardActions from "./voice-material-card/CardActions"
|
||||||
PauseCircleOutlined,
|
import CardHeader from "./voice-material-card/CardHeader"
|
||||||
EditOutlined,
|
import CardTags from "./voice-material-card/CardTags"
|
||||||
DeleteOutlined,
|
import CardMeta from "./voice-material-card/CardMeta"
|
||||||
CheckOutlined,
|
import CardPlayer from "./voice-material-card/CardPlayer"
|
||||||
SoundOutlined,
|
import { genderClass } from "../utils/format"
|
||||||
MutedOutlined,
|
|
||||||
} from "@ant-design/icons"
|
|
||||||
import { Tooltip } from "antd"
|
|
||||||
import { Tag } from "@/components/ui"
|
|
||||||
import { type TagItem } from "@/api/tags"
|
|
||||||
import { type VoiceMaterial } from "../types"
|
|
||||||
import { MAX_CARD_TAGS, TAG_VARIANTS } from "../constants"
|
|
||||||
import {
|
|
||||||
genderClass,
|
|
||||||
genderIcon,
|
|
||||||
genderLabel,
|
|
||||||
formatDuration,
|
|
||||||
formatFileSize,
|
|
||||||
formatDate,
|
|
||||||
} from "../utils/format"
|
|
||||||
|
|
||||||
export interface VoiceCardProps {
|
|
||||||
material: VoiceMaterial
|
|
||||||
isPlaying: boolean
|
|
||||||
currentTime: number
|
|
||||||
isSelected: boolean
|
|
||||||
batchMode: boolean
|
|
||||||
volume: number
|
|
||||||
tagMap: Map<string, TagItem>
|
|
||||||
onPlay: () => void
|
|
||||||
onPause: () => void
|
|
||||||
onSeek: (time: number) => void
|
|
||||||
onEdit: () => void
|
|
||||||
onDelete: () => void
|
|
||||||
onToggleSelect: (id: string) => void
|
|
||||||
onVolumeChange: (e: React.ChangeEvent<HTMLInputElement>) => void
|
|
||||||
onToggleMute: () => void
|
|
||||||
}
|
|
||||||
|
|
||||||
const VoiceMaterialCard: React.FC<VoiceCardProps> = ({
|
const VoiceMaterialCard: React.FC<VoiceCardProps> = ({
|
||||||
material,
|
material,
|
||||||
@@ -58,29 +25,6 @@ const VoiceMaterialCard: React.FC<VoiceCardProps> = ({
|
|||||||
onVolumeChange,
|
onVolumeChange,
|
||||||
onToggleMute,
|
onToggleMute,
|
||||||
}) => {
|
}) => {
|
||||||
const progressRef = useRef<HTMLDivElement>(null)
|
|
||||||
|
|
||||||
const handleProgressMouseDown = (e: React.MouseEvent<HTMLDivElement>) => {
|
|
||||||
if (!progressRef.current) return
|
|
||||||
e.preventDefault()
|
|
||||||
const doSeek = (ev: MouseEvent) => {
|
|
||||||
if (!progressRef.current) return
|
|
||||||
const rect = progressRef.current.getBoundingClientRect()
|
|
||||||
const percent = Math.max(0, Math.min(1, (ev.clientX - rect.left) / rect.width))
|
|
||||||
onSeek(percent * material.duration)
|
|
||||||
}
|
|
||||||
doSeek(e.nativeEvent)
|
|
||||||
const handleMove = (ev: MouseEvent) => doSeek(ev)
|
|
||||||
const handleUp = () => {
|
|
||||||
document.removeEventListener("mousemove", handleMove)
|
|
||||||
document.removeEventListener("mouseup", handleUp)
|
|
||||||
}
|
|
||||||
document.addEventListener("mousemove", handleMove)
|
|
||||||
document.addEventListener("mouseup", handleUp)
|
|
||||||
}
|
|
||||||
|
|
||||||
const progress = material.duration > 0 ? (currentTime / material.duration) * 100 : 0
|
|
||||||
|
|
||||||
const handleCardClick = () => {
|
const handleCardClick = () => {
|
||||||
if (batchMode) {
|
if (batchMode) {
|
||||||
onToggleSelect(material.id)
|
onToggleSelect(material.id)
|
||||||
@@ -92,154 +36,37 @@ const VoiceMaterialCard: React.FC<VoiceCardProps> = ({
|
|||||||
className={`vmat-card ${genderClass(material.gender)}${isPlaying ? " playing" : ""}${isSelected ? " selected" : ""}${batchMode ? " batch-mode" : ""}`}
|
className={`vmat-card ${genderClass(material.gender)}${isPlaying ? " playing" : ""}${isSelected ? " selected" : ""}${batchMode ? " batch-mode" : ""}`}
|
||||||
onClick={handleCardClick}
|
onClick={handleCardClick}
|
||||||
>
|
>
|
||||||
{/* 批量选择 checkbox */}
|
<BatchCheckbox
|
||||||
{(batchMode || isSelected) && (
|
isSelected={isSelected}
|
||||||
<div
|
visible={batchMode || isSelected}
|
||||||
className={`vmat-card-checkbox vmat-checkbox${isSelected ? " checked" : ""}`}
|
onToggle={() => onToggleSelect(material.id)}
|
||||||
onClick={(e) => {
|
/>
|
||||||
e.stopPropagation()
|
<CardActions onEdit={onEdit} onDelete={onDelete} />
|
||||||
onToggleSelect(material.id)
|
<CardHeader material={material} />
|
||||||
}}
|
|
||||||
>
|
|
||||||
{isSelected && <CheckOutlined />}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* 操作按钮 */}
|
|
||||||
<div className="vmat-card-actions">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="vmat-card-action-btn"
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation()
|
|
||||||
onEdit()
|
|
||||||
}}
|
|
||||||
title="编辑"
|
|
||||||
>
|
|
||||||
<EditOutlined />
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="vmat-card-action-btn vmat-card-action-btn--danger"
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation()
|
|
||||||
onDelete()
|
|
||||||
}}
|
|
||||||
title="删除"
|
|
||||||
>
|
|
||||||
<DeleteOutlined />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 头部:图标 + 名称 + 性别 */}
|
|
||||||
<div className="vmat-card-header">
|
|
||||||
<div className="vmat-card-avatar">
|
|
||||||
<AudioOutlined />
|
|
||||||
</div>
|
|
||||||
<div className="vmat-card-title-area">
|
|
||||||
<h4 className="vmat-card-name" title={material.name}>
|
|
||||||
{material.name}
|
|
||||||
</h4>
|
|
||||||
<span className={`vmat-card-gender ${genderClass(material.gender)}`}>
|
|
||||||
{genderIcon(material.gender)}
|
|
||||||
{genderLabel(material.gender)}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 描述 */}
|
|
||||||
{material.description && <p className="vmat-card-desc">{material.description}</p>}
|
{material.description && <p className="vmat-card-desc">{material.description}</p>}
|
||||||
|
|
||||||
{/* 标签 */}
|
<CardTags tagIds={material.tagIds} tagMap={tagMap} onEdit={onEdit} />
|
||||||
<div className="vmat-card-tags">
|
<CardMeta
|
||||||
{material.tagIds.length === 0 ? (
|
duration={material.duration}
|
||||||
<span
|
fileSize={material.fileSize}
|
||||||
className="vmat-tag-empty"
|
createdAt={material.createdAt}
|
||||||
onClick={(e) => {
|
/>
|
||||||
e.stopPropagation()
|
<CardPlayer
|
||||||
onEdit()
|
isPlaying={isPlaying}
|
||||||
}}
|
currentTime={currentTime}
|
||||||
>
|
duration={material.duration}
|
||||||
添加标签
|
volume={volume}
|
||||||
</span>
|
fileUrl={material.fileUrl}
|
||||||
) : (
|
onPlay={onPlay}
|
||||||
<>
|
onPause={onPause}
|
||||||
{material.tagIds.slice(0, MAX_CARD_TAGS).map((tagId, i) => (
|
onSeek={onSeek}
|
||||||
<Tag key={tagId} variant={TAG_VARIANTS[i % TAG_VARIANTS.length]}>
|
onVolumeChange={onVolumeChange}
|
||||||
{tagMap.get(tagId)?.name ?? tagId}
|
onToggleMute={onToggleMute}
|
||||||
</Tag>
|
/>
|
||||||
))}
|
|
||||||
{material.tagIds.length > MAX_CARD_TAGS && (
|
|
||||||
<Tooltip
|
|
||||||
title={material.tagIds
|
|
||||||
.slice(MAX_CARD_TAGS)
|
|
||||||
.map((id) => tagMap.get(id)?.name ?? id)
|
|
||||||
.join("、")}
|
|
||||||
>
|
|
||||||
<Tag className="vmat-tag-overflow">+{material.tagIds.length - MAX_CARD_TAGS}</Tag>
|
|
||||||
</Tooltip>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 元信息 */}
|
|
||||||
<div className="vmat-card-meta">
|
|
||||||
<span>{formatDuration(material.duration)}</span>
|
|
||||||
<span>{formatFileSize(material.fileSize)}</span>
|
|
||||||
<span>{formatDate(material.createdAt)}</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 播放控制 */}
|
|
||||||
<div className="vmat-card-player">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="vmat-play-btn"
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation()
|
|
||||||
isPlaying ? onPause() : onPlay()
|
|
||||||
}}
|
|
||||||
disabled={!material.fileUrl}
|
|
||||||
>
|
|
||||||
{isPlaying ? <PauseCircleOutlined /> : <PlayCircleOutlined />}
|
|
||||||
</button>
|
|
||||||
<div ref={progressRef} className="vmat-progress" onMouseDown={handleProgressMouseDown}>
|
|
||||||
<div className="vmat-progress-bar" style={{ width: `${progress}%` }} />
|
|
||||||
{isPlaying && <div className="vmat-progress-thumb" style={{ left: `${progress}%` }} />}
|
|
||||||
</div>
|
|
||||||
<span className="vmat-time">
|
|
||||||
{isPlaying ? formatDuration(currentTime) : formatDuration(material.duration)}
|
|
||||||
</span>
|
|
||||||
{/* 音量控制 */}
|
|
||||||
<div className="vmat-volume">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="vmat-volume-btn"
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation()
|
|
||||||
onToggleMute()
|
|
||||||
}}
|
|
||||||
title={volume === 0 ? "取消静音" : "静音"}
|
|
||||||
>
|
|
||||||
{volume === 0 ? <MutedOutlined /> : <SoundOutlined />}
|
|
||||||
</button>
|
|
||||||
<input
|
|
||||||
type="range"
|
|
||||||
className="vmat-volume-slider"
|
|
||||||
min={0}
|
|
||||||
max={1}
|
|
||||||
step={0.05}
|
|
||||||
value={volume}
|
|
||||||
onChange={(e) => {
|
|
||||||
e.stopPropagation()
|
|
||||||
onVolumeChange(e)
|
|
||||||
}}
|
|
||||||
onClick={(e) => e.stopPropagation()}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export default VoiceMaterialCard
|
export default VoiceMaterialCard
|
||||||
|
export type { VoiceCardProps }
|
||||||
|
|||||||
Regular → Executable
+9
-57
@@ -1,4 +1,4 @@
|
|||||||
import React, { useRef } from "react"
|
import React from "react"
|
||||||
import {
|
import {
|
||||||
PlayCircleOutlined,
|
PlayCircleOutlined,
|
||||||
PauseCircleOutlined,
|
PauseCircleOutlined,
|
||||||
@@ -6,11 +6,8 @@ import {
|
|||||||
DeleteOutlined,
|
DeleteOutlined,
|
||||||
CheckOutlined,
|
CheckOutlined,
|
||||||
} from "@ant-design/icons"
|
} from "@ant-design/icons"
|
||||||
import { Tooltip } from "antd"
|
|
||||||
import { Tag } from "@/components/ui"
|
|
||||||
import { type TagItem } from "@/api/tags"
|
import { type TagItem } from "@/api/tags"
|
||||||
import { type VoiceMaterial } from "../types"
|
import { type VoiceMaterial } from "../types"
|
||||||
import { MAX_ROW_TAGS, TAG_VARIANTS } from "../constants"
|
|
||||||
import {
|
import {
|
||||||
genderClass,
|
genderClass,
|
||||||
genderIcon,
|
genderIcon,
|
||||||
@@ -18,6 +15,8 @@ import {
|
|||||||
formatDuration,
|
formatDuration,
|
||||||
formatFileSize,
|
formatFileSize,
|
||||||
} from "../utils/format"
|
} from "../utils/format"
|
||||||
|
import { useRowProgress } from "./voice-material-row/useRowProgress"
|
||||||
|
import TagDisplay from "./voice-material-row/TagDisplay"
|
||||||
|
|
||||||
export interface VoiceRowProps {
|
export interface VoiceRowProps {
|
||||||
material: VoiceMaterial
|
material: VoiceMaterial
|
||||||
@@ -48,26 +47,10 @@ const VoiceMaterialRow: React.FC<VoiceRowProps> = ({
|
|||||||
onDelete,
|
onDelete,
|
||||||
onToggleSelect,
|
onToggleSelect,
|
||||||
}) => {
|
}) => {
|
||||||
const progressRef = useRef<HTMLDivElement>(null)
|
const { progressRef, handleMouseDown } = useRowProgress({
|
||||||
|
duration: material.duration,
|
||||||
const handleProgressMouseDown = (e: React.MouseEvent<HTMLDivElement>) => {
|
onSeek,
|
||||||
if (!progressRef.current) return
|
})
|
||||||
e.preventDefault()
|
|
||||||
const doSeek = (ev: MouseEvent) => {
|
|
||||||
if (!progressRef.current) return
|
|
||||||
const rect = progressRef.current.getBoundingClientRect()
|
|
||||||
const percent = Math.max(0, Math.min(1, (ev.clientX - rect.left) / rect.width))
|
|
||||||
onSeek(percent * material.duration)
|
|
||||||
}
|
|
||||||
doSeek(e.nativeEvent)
|
|
||||||
const handleMove = (ev: MouseEvent) => doSeek(ev)
|
|
||||||
const handleUp = () => {
|
|
||||||
document.removeEventListener("mousemove", handleMove)
|
|
||||||
document.removeEventListener("mouseup", handleUp)
|
|
||||||
}
|
|
||||||
document.addEventListener("mousemove", handleMove)
|
|
||||||
document.addEventListener("mouseup", handleUp)
|
|
||||||
}
|
|
||||||
|
|
||||||
const progress = material.duration > 0 ? (currentTime / material.duration) * 100 : 0
|
const progress = material.duration > 0 ? (currentTime / material.duration) * 100 : 0
|
||||||
|
|
||||||
@@ -75,7 +58,6 @@ const VoiceMaterialRow: React.FC<VoiceRowProps> = ({
|
|||||||
<div
|
<div
|
||||||
className={`vmat-row ${genderClass(material.gender)}${isPlaying ? " playing" : ""}${isSelected ? " selected" : ""}${batchMode ? " batch-mode" : ""}`}
|
className={`vmat-row ${genderClass(material.gender)}${isPlaying ? " playing" : ""}${isSelected ? " selected" : ""}${batchMode ? " batch-mode" : ""}`}
|
||||||
>
|
>
|
||||||
{/* 批量选择 checkbox */}
|
|
||||||
{(batchMode || isSelected) && (
|
{(batchMode || isSelected) && (
|
||||||
<div
|
<div
|
||||||
className={`vmat-row-checkbox vmat-checkbox${isSelected ? " checked" : ""}`}
|
className={`vmat-row-checkbox vmat-checkbox${isSelected ? " checked" : ""}`}
|
||||||
@@ -88,7 +70,6 @@ const VoiceMaterialRow: React.FC<VoiceRowProps> = ({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* 播放按钮 */}
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="vmat-row-play"
|
className="vmat-row-play"
|
||||||
@@ -101,60 +82,31 @@ const VoiceMaterialRow: React.FC<VoiceRowProps> = ({
|
|||||||
{isPlaying ? <PauseCircleOutlined /> : <PlayCircleOutlined />}
|
{isPlaying ? <PauseCircleOutlined /> : <PlayCircleOutlined />}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{/* 名称 + 描述 */}
|
|
||||||
<div className="vmat-row-info">
|
<div className="vmat-row-info">
|
||||||
<h4 className="vmat-row-name">{material.name}</h4>
|
<h4 className="vmat-row-name">{material.name}</h4>
|
||||||
{material.description && <p className="vmat-row-desc">{material.description}</p>}
|
{material.description && <p className="vmat-row-desc">{material.description}</p>}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 性别 */}
|
|
||||||
<span className={`vmat-row-gender ${genderClass(material.gender)}`}>
|
<span className={`vmat-row-gender ${genderClass(material.gender)}`}>
|
||||||
{genderIcon(material.gender)}
|
{genderIcon(material.gender)}
|
||||||
{genderLabel(material.gender)}
|
{genderLabel(material.gender)}
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
{/* 标签 */}
|
|
||||||
<div className="vmat-row-tags">
|
<div className="vmat-row-tags">
|
||||||
{material.tagIds.length === 0 ? (
|
<TagDisplay tagIds={material.tagIds} tagMap={tagMap} onAddTag={() => onEdit()} />
|
||||||
<span className="vmat-tag-empty" onClick={() => onEdit()}>
|
|
||||||
添加标签
|
|
||||||
</span>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
{material.tagIds.slice(0, MAX_ROW_TAGS).map((tagId, i) => (
|
|
||||||
<Tag key={tagId} variant={TAG_VARIANTS[i % TAG_VARIANTS.length]}>
|
|
||||||
{tagMap.get(tagId)?.name ?? tagId}
|
|
||||||
</Tag>
|
|
||||||
))}
|
|
||||||
{material.tagIds.length > MAX_ROW_TAGS && (
|
|
||||||
<Tooltip
|
|
||||||
title={material.tagIds
|
|
||||||
.slice(MAX_ROW_TAGS)
|
|
||||||
.map((id) => tagMap.get(id)?.name ?? id)
|
|
||||||
.join("、")}
|
|
||||||
>
|
|
||||||
<Tag className="vmat-tag-overflow">+{material.tagIds.length - MAX_ROW_TAGS}</Tag>
|
|
||||||
</Tooltip>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 进度条(可拖拽) */}
|
<div ref={progressRef} className="vmat-row-progress" onMouseDown={handleMouseDown}>
|
||||||
<div ref={progressRef} className="vmat-row-progress" onMouseDown={handleProgressMouseDown}>
|
|
||||||
<div className="vmat-row-progress-bar" style={{ width: `${progress}%` }} />
|
<div className="vmat-row-progress-bar" style={{ width: `${progress}%` }} />
|
||||||
{isPlaying && <div className="vmat-progress-thumb" style={{ left: `${progress}%` }} />}
|
{isPlaying && <div className="vmat-progress-thumb" style={{ left: `${progress}%` }} />}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 时长 */}
|
|
||||||
<span className="vmat-row-time">
|
<span className="vmat-row-time">
|
||||||
{isPlaying ? formatDuration(currentTime) : formatDuration(material.duration)}
|
{isPlaying ? formatDuration(currentTime) : formatDuration(material.duration)}
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
{/* 文件大小 */}
|
|
||||||
<span className="vmat-row-size">{formatFileSize(material.fileSize)}</span>
|
<span className="vmat-row-size">{formatFileSize(material.fileSize)}</span>
|
||||||
|
|
||||||
{/* 操作 */}
|
|
||||||
<div className="vmat-row-actions">
|
<div className="vmat-row-actions">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
|||||||
+73
@@ -0,0 +1,73 @@
|
|||||||
|
import React, { useRef } from "react"
|
||||||
|
import { UploadOutlined, SoundOutlined, CloseOutlined } from "@ant-design/icons"
|
||||||
|
import { formatFileSize } from "../../utils/format"
|
||||||
|
|
||||||
|
interface FileUploadFieldProps {
|
||||||
|
file: File | undefined
|
||||||
|
onChange: (file: File | undefined) => void
|
||||||
|
uploadProgress?: number | null
|
||||||
|
}
|
||||||
|
|
||||||
|
const FileUploadField: React.FC<FileUploadFieldProps> = ({ file, onChange, uploadProgress }) => {
|
||||||
|
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||||
|
|
||||||
|
const handleDrop = (e: React.DragEvent) => {
|
||||||
|
e.preventDefault()
|
||||||
|
const f = e.dataTransfer.files[0]
|
||||||
|
if (f?.type.startsWith("audio/")) onChange(f)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="vmat-form-field">
|
||||||
|
<label className="vmat-form-label">音频文件 *</label>
|
||||||
|
<div
|
||||||
|
className="vmat-upload-zone"
|
||||||
|
onClick={() => fileInputRef.current?.click()}
|
||||||
|
onDragOver={(e) => e.preventDefault()}
|
||||||
|
onDrop={handleDrop}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
ref={fileInputRef}
|
||||||
|
type="file"
|
||||||
|
accept="audio/*"
|
||||||
|
style={{ display: "none" }}
|
||||||
|
onChange={(e) => {
|
||||||
|
const f = e.target.files?.[0]
|
||||||
|
if (f) onChange(f)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{file ? (
|
||||||
|
<div className="vmat-upload-selected">
|
||||||
|
<SoundOutlined className="vmat-upload-icon" />
|
||||||
|
<span className="vmat-upload-filename">{file.name}</span>
|
||||||
|
<span className="vmat-upload-filesize">{formatFileSize(file.size)}</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="vmat-upload-clear"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
onChange(undefined)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<CloseOutlined />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="vmat-upload-placeholder">
|
||||||
|
<UploadOutlined className="vmat-upload-icon" />
|
||||||
|
<p>点击或拖拽音频文件到此处</p>
|
||||||
|
<span>支持 MP3、WAV、AAC、FLAC 等格式</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{uploadProgress !== null && uploadProgress !== undefined && (
|
||||||
|
<div className="vmat-upload-progress">
|
||||||
|
<div className="vmat-upload-progress-bar" style={{ width: `${uploadProgress}%` }} />
|
||||||
|
<span className="vmat-upload-progress-text">{uploadProgress}%</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default FileUploadField
|
||||||
+32
@@ -0,0 +1,32 @@
|
|||||||
|
import React from "react"
|
||||||
|
import type { VoiceGender } from "../../types"
|
||||||
|
import { GENDER_OPTIONS } from "../../constants"
|
||||||
|
import { genderClass } from "../../utils/format"
|
||||||
|
|
||||||
|
interface GenderSelectorProps {
|
||||||
|
value: VoiceGender
|
||||||
|
onChange: (value: VoiceGender) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
const GenderSelector: React.FC<GenderSelectorProps> = ({ value, onChange }) => {
|
||||||
|
return (
|
||||||
|
<div className="vmat-form-field">
|
||||||
|
<label className="vmat-form-label">性别</label>
|
||||||
|
<div className="vmat-gender-group">
|
||||||
|
{GENDER_OPTIONS.map((opt) => (
|
||||||
|
<button
|
||||||
|
key={opt.value}
|
||||||
|
type="button"
|
||||||
|
className={`vmat-gender-btn${value === opt.value ? " active" : ""} ${genderClass(opt.value)}`}
|
||||||
|
onClick={() => onChange(opt.value)}
|
||||||
|
>
|
||||||
|
{opt.icon}
|
||||||
|
{opt.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default GenderSelector
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
import { useState, useRef, useCallback, useMemo } from "react"
|
||||||
|
import type { TagItem } from "@/api/tags"
|
||||||
|
|
||||||
|
interface UseTagInputOptions {
|
||||||
|
value: string[]
|
||||||
|
onChange: (tagIds: string[]) => void
|
||||||
|
tags: TagItem[]
|
||||||
|
onCreateTag: (name: string) => Promise<TagItem>
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useTagInput({ value, onChange, tags, onCreateTag }: UseTagInputOptions) {
|
||||||
|
const [inputVal, setInputVal] = useState("")
|
||||||
|
const [showSuggestions, setShowSuggestions] = useState(false)
|
||||||
|
const inputRef = useRef<HTMLInputElement>(null)
|
||||||
|
|
||||||
|
const findTagByName = useCallback(
|
||||||
|
(name: string) => tags.find((t) => t.name.toLowerCase() === name.toLowerCase()),
|
||||||
|
[tags],
|
||||||
|
)
|
||||||
|
|
||||||
|
const addTagId = useCallback(
|
||||||
|
(tagId: string) => {
|
||||||
|
if (value.includes(tagId)) return
|
||||||
|
onChange([...value, tagId])
|
||||||
|
setInputVal("")
|
||||||
|
setShowSuggestions(false)
|
||||||
|
},
|
||||||
|
[value, onChange],
|
||||||
|
)
|
||||||
|
|
||||||
|
const addTagByName = useCallback(
|
||||||
|
async (name: string) => {
|
||||||
|
const trimmed = name.trim()
|
||||||
|
if (!trimmed) return
|
||||||
|
const existing = findTagByName(trimmed)
|
||||||
|
if (existing) {
|
||||||
|
addTagId(existing.id)
|
||||||
|
} else {
|
||||||
|
try {
|
||||||
|
const created = await onCreateTag(trimmed)
|
||||||
|
addTagId(created.id)
|
||||||
|
} catch {
|
||||||
|
/* 创建失败静默忽略 */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[findTagByName, addTagId, onCreateTag],
|
||||||
|
)
|
||||||
|
|
||||||
|
const removeTagId = useCallback(
|
||||||
|
(tagId: string) => {
|
||||||
|
onChange(value.filter((t) => t !== tagId))
|
||||||
|
},
|
||||||
|
[value, onChange],
|
||||||
|
)
|
||||||
|
|
||||||
|
const suggestions = useMemo(() => {
|
||||||
|
if (!inputVal.trim()) return []
|
||||||
|
const lower = inputVal.toLowerCase()
|
||||||
|
return tags.filter((t) => t.name.toLowerCase().includes(lower) && !value.includes(t.id))
|
||||||
|
}, [inputVal, tags, value])
|
||||||
|
|
||||||
|
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||||
|
if (e.key === "Enter") {
|
||||||
|
e.preventDefault()
|
||||||
|
if (suggestions.length > 0) {
|
||||||
|
addTagId(suggestions[0].id)
|
||||||
|
} else {
|
||||||
|
addTagByName(inputVal)
|
||||||
|
}
|
||||||
|
} else if (e.key === "Backspace" && !inputVal && value.length > 0) {
|
||||||
|
removeTagId(value[value.length - 1])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const focus = () => inputRef.current?.focus()
|
||||||
|
|
||||||
|
return {
|
||||||
|
inputVal,
|
||||||
|
setInputVal,
|
||||||
|
showSuggestions,
|
||||||
|
setShowSuggestions,
|
||||||
|
inputRef,
|
||||||
|
suggestions,
|
||||||
|
addTagId,
|
||||||
|
removeTagId,
|
||||||
|
handleKeyDown,
|
||||||
|
focus,
|
||||||
|
}
|
||||||
|
}
|
||||||
+26
@@ -0,0 +1,26 @@
|
|||||||
|
import React from "react"
|
||||||
|
import { CheckOutlined } from "@ant-design/icons"
|
||||||
|
|
||||||
|
interface BatchCheckboxProps {
|
||||||
|
isSelected: boolean
|
||||||
|
visible: boolean
|
||||||
|
onToggle: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 批量选择 checkbox */
|
||||||
|
const BatchCheckbox: React.FC<BatchCheckboxProps> = ({ isSelected, visible, onToggle }) => {
|
||||||
|
if (!visible) return null
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`vmat-card-checkbox vmat-checkbox${isSelected ? " checked" : ""}`}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
onToggle()
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{isSelected && <CheckOutlined />}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default BatchCheckbox
|
||||||
+39
@@ -0,0 +1,39 @@
|
|||||||
|
import React from "react"
|
||||||
|
import { EditOutlined, DeleteOutlined } from "@ant-design/icons"
|
||||||
|
|
||||||
|
interface CardActionsProps {
|
||||||
|
onEdit: () => void
|
||||||
|
onDelete: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 卡片操作按钮:编辑 / 删除 */
|
||||||
|
const CardActions: React.FC<CardActionsProps> = ({ onEdit, onDelete }) => {
|
||||||
|
return (
|
||||||
|
<div className="vmat-card-actions">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="vmat-card-action-btn"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
onEdit()
|
||||||
|
}}
|
||||||
|
title="编辑"
|
||||||
|
>
|
||||||
|
<EditOutlined />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="vmat-card-action-btn vmat-card-action-btn--danger"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
onDelete()
|
||||||
|
}}
|
||||||
|
title="删除"
|
||||||
|
>
|
||||||
|
<DeleteOutlined />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default CardActions
|
||||||
+30
@@ -0,0 +1,30 @@
|
|||||||
|
import React from "react"
|
||||||
|
import { AudioOutlined } from "@ant-design/icons"
|
||||||
|
import { type VoiceMaterial } from "../../types"
|
||||||
|
import { genderClass, genderIcon, genderLabel } from "../../utils/format"
|
||||||
|
|
||||||
|
interface CardHeaderProps {
|
||||||
|
material: VoiceMaterial
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 卡片头部:头像 + 名称 + 性别标签 */
|
||||||
|
const CardHeader: React.FC<CardHeaderProps> = ({ material }) => {
|
||||||
|
return (
|
||||||
|
<div className="vmat-card-header">
|
||||||
|
<div className="vmat-card-avatar">
|
||||||
|
<AudioOutlined />
|
||||||
|
</div>
|
||||||
|
<div className="vmat-card-title-area">
|
||||||
|
<h4 className="vmat-card-name" title={material.name}>
|
||||||
|
{material.name}
|
||||||
|
</h4>
|
||||||
|
<span className={`vmat-card-gender ${genderClass(material.gender)}`}>
|
||||||
|
{genderIcon(material.gender)}
|
||||||
|
{genderLabel(material.gender)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default CardHeader
|
||||||
+21
@@ -0,0 +1,21 @@
|
|||||||
|
import React from "react"
|
||||||
|
import { formatDuration, formatFileSize, formatDate } from "../../utils/format"
|
||||||
|
|
||||||
|
interface CardMetaProps {
|
||||||
|
duration: number
|
||||||
|
fileSize: number
|
||||||
|
createdAt: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 元信息:时长 / 文件大小 / 创建日期 */
|
||||||
|
const CardMeta: React.FC<CardMetaProps> = ({ duration, fileSize, createdAt }) => {
|
||||||
|
return (
|
||||||
|
<div className="vmat-card-meta">
|
||||||
|
<span>{formatDuration(duration)}</span>
|
||||||
|
<span>{formatFileSize(fileSize)}</span>
|
||||||
|
<span>{formatDate(createdAt)}</span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default CardMeta
|
||||||
+110
@@ -0,0 +1,110 @@
|
|||||||
|
import React, { useRef } from "react"
|
||||||
|
import {
|
||||||
|
PlayCircleOutlined,
|
||||||
|
PauseCircleOutlined,
|
||||||
|
SoundOutlined,
|
||||||
|
MutedOutlined,
|
||||||
|
} from "@ant-design/icons"
|
||||||
|
import { formatDuration } from "../../utils/format"
|
||||||
|
|
||||||
|
interface CardPlayerProps {
|
||||||
|
isPlaying: boolean
|
||||||
|
currentTime: number
|
||||||
|
duration: number
|
||||||
|
volume: number
|
||||||
|
fileUrl?: string
|
||||||
|
onPlay: () => void
|
||||||
|
onPause: () => void
|
||||||
|
onSeek: (time: number) => void
|
||||||
|
onVolumeChange: (e: React.ChangeEvent<HTMLInputElement>) => void
|
||||||
|
onToggleMute: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 播放控制区:播放按钮 + 进度条 + 时间 + 音量 */
|
||||||
|
const CardPlayer: React.FC<CardPlayerProps> = ({
|
||||||
|
isPlaying,
|
||||||
|
currentTime,
|
||||||
|
duration,
|
||||||
|
volume,
|
||||||
|
fileUrl,
|
||||||
|
onPlay,
|
||||||
|
onPause,
|
||||||
|
onSeek,
|
||||||
|
onVolumeChange,
|
||||||
|
onToggleMute,
|
||||||
|
}) => {
|
||||||
|
const progressRef = useRef<HTMLDivElement>(null)
|
||||||
|
|
||||||
|
const handleProgressMouseDown = (e: React.MouseEvent<HTMLDivElement>) => {
|
||||||
|
if (!progressRef.current) return
|
||||||
|
e.preventDefault()
|
||||||
|
const doSeek = (ev: MouseEvent) => {
|
||||||
|
if (!progressRef.current) return
|
||||||
|
const rect = progressRef.current.getBoundingClientRect()
|
||||||
|
const percent = Math.max(0, Math.min(1, (ev.clientX - rect.left) / rect.width))
|
||||||
|
onSeek(percent * duration)
|
||||||
|
}
|
||||||
|
doSeek(e.nativeEvent)
|
||||||
|
const handleMove = (ev: MouseEvent) => doSeek(ev)
|
||||||
|
const handleUp = () => {
|
||||||
|
document.removeEventListener("mousemove", handleMove)
|
||||||
|
document.removeEventListener("mouseup", handleUp)
|
||||||
|
}
|
||||||
|
document.addEventListener("mousemove", handleMove)
|
||||||
|
document.addEventListener("mouseup", handleUp)
|
||||||
|
}
|
||||||
|
|
||||||
|
const progress = duration > 0 ? (currentTime / duration) * 100 : 0
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="vmat-card-player">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="vmat-play-btn"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
isPlaying ? onPause() : onPlay()
|
||||||
|
}}
|
||||||
|
disabled={!fileUrl}
|
||||||
|
>
|
||||||
|
{isPlaying ? <PauseCircleOutlined /> : <PlayCircleOutlined />}
|
||||||
|
</button>
|
||||||
|
<div ref={progressRef} className="vmat-progress" onMouseDown={handleProgressMouseDown}>
|
||||||
|
<div className="vmat-progress-bar" style={{ width: `${progress}%` }} />
|
||||||
|
{isPlaying && <div className="vmat-progress-thumb" style={{ left: `${progress}%` }} />}
|
||||||
|
</div>
|
||||||
|
<span className="vmat-time">
|
||||||
|
{isPlaying ? formatDuration(currentTime) : formatDuration(duration)}
|
||||||
|
</span>
|
||||||
|
{/* 音量控制 */}
|
||||||
|
<div className="vmat-volume">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="vmat-volume-btn"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
onToggleMute()
|
||||||
|
}}
|
||||||
|
title={volume === 0 ? "取消静音" : "静音"}
|
||||||
|
>
|
||||||
|
{volume === 0 ? <MutedOutlined /> : <SoundOutlined />}
|
||||||
|
</button>
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
className="vmat-volume-slider"
|
||||||
|
min={0}
|
||||||
|
max={1}
|
||||||
|
step={0.05}
|
||||||
|
value={volume}
|
||||||
|
onChange={(e) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
onVolumeChange(e)
|
||||||
|
}}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default CardPlayer
|
||||||
+52
@@ -0,0 +1,52 @@
|
|||||||
|
import React from "react"
|
||||||
|
import { Tooltip } from "antd"
|
||||||
|
import { Tag } from "@/components/ui"
|
||||||
|
import { type TagItem } from "@/api/tags"
|
||||||
|
import { MAX_CARD_TAGS, TAG_VARIANTS } from "../../constants"
|
||||||
|
|
||||||
|
interface CardTagsProps {
|
||||||
|
tagIds: string[]
|
||||||
|
tagMap: Map<string, TagItem>
|
||||||
|
onEdit: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 标签展示区 */
|
||||||
|
const CardTags: React.FC<CardTagsProps> = ({ tagIds, tagMap, onEdit }) => {
|
||||||
|
if (tagIds.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className="vmat-card-tags">
|
||||||
|
<span
|
||||||
|
className="vmat-tag-empty"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
onEdit()
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
添加标签
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="vmat-card-tags">
|
||||||
|
{tagIds.slice(0, MAX_CARD_TAGS).map((tagId, i) => (
|
||||||
|
<Tag key={tagId} variant={TAG_VARIANTS[i % TAG_VARIANTS.length]}>
|
||||||
|
{tagMap.get(tagId)?.name ?? tagId}
|
||||||
|
</Tag>
|
||||||
|
))}
|
||||||
|
{tagIds.length > MAX_CARD_TAGS && (
|
||||||
|
<Tooltip
|
||||||
|
title={tagIds
|
||||||
|
.slice(MAX_CARD_TAGS)
|
||||||
|
.map((id) => tagMap.get(id)?.name ?? id)
|
||||||
|
.join("、")}
|
||||||
|
>
|
||||||
|
<Tag className="vmat-tag-overflow">+{tagIds.length - MAX_CARD_TAGS}</Tag>
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default CardTags
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
export { default } from "../VoiceMaterialCard"
|
||||||
|
export * from "./types"
|
||||||
|
export { default as CardHeader } from "./CardHeader"
|
||||||
|
export { default as CardTags } from "./CardTags"
|
||||||
|
export { default as CardMeta } from "./CardMeta"
|
||||||
|
export { default as CardPlayer } from "./CardPlayer"
|
||||||
|
export { default as CardActions } from "./CardActions"
|
||||||
|
export { default as BatchCheckbox } from "./BatchCheckbox"
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import React from "react"
|
||||||
|
import { type VoiceMaterial } from "../../types"
|
||||||
|
import { type TagItem } from "@/api/tags"
|
||||||
|
|
||||||
|
export interface VoiceCardProps {
|
||||||
|
material: VoiceMaterial
|
||||||
|
isPlaying: boolean
|
||||||
|
currentTime: number
|
||||||
|
isSelected: boolean
|
||||||
|
batchMode: boolean
|
||||||
|
volume: number
|
||||||
|
tagMap: Map<string, TagItem>
|
||||||
|
onPlay: () => void
|
||||||
|
onPause: () => void
|
||||||
|
onSeek: (time: number) => void
|
||||||
|
onEdit: () => void
|
||||||
|
onDelete: () => void
|
||||||
|
onToggleSelect: (id: string) => void
|
||||||
|
onVolumeChange: (e: React.ChangeEvent<HTMLInputElement>) => void
|
||||||
|
onToggleMute: () => void
|
||||||
|
}
|
||||||
+41
@@ -0,0 +1,41 @@
|
|||||||
|
import React from "react"
|
||||||
|
import { Tooltip } from "antd"
|
||||||
|
import { Tag } from "@/components/ui"
|
||||||
|
import { type TagItem } from "@/api/tags"
|
||||||
|
import { MAX_ROW_TAGS, TAG_VARIANTS } from "../../constants"
|
||||||
|
|
||||||
|
interface TagDisplayProps {
|
||||||
|
tagIds: string[]
|
||||||
|
tagMap: Map<string, TagItem>
|
||||||
|
onAddTag?: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
const TagDisplay: React.FC<TagDisplayProps> = ({ tagIds, tagMap, onAddTag }) => {
|
||||||
|
if (tagIds.length === 0) {
|
||||||
|
return (
|
||||||
|
<span className="vmat-tag-empty" onClick={onAddTag}>
|
||||||
|
添加标签
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const visible = tagIds.slice(0, MAX_ROW_TAGS)
|
||||||
|
const overflow = tagIds.slice(MAX_ROW_TAGS)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{visible.map((tagId, i) => (
|
||||||
|
<Tag key={tagId} variant={TAG_VARIANTS[i % TAG_VARIANTS.length]}>
|
||||||
|
{tagMap.get(tagId)?.name ?? tagId}
|
||||||
|
</Tag>
|
||||||
|
))}
|
||||||
|
{overflow.length > 0 && (
|
||||||
|
<Tooltip title={overflow.map((id) => tagMap.get(id)?.name ?? id).join("、")}>
|
||||||
|
<Tag className="vmat-tag-overflow">+{overflow.length}</Tag>
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default TagDisplay
|
||||||
+38
@@ -0,0 +1,38 @@
|
|||||||
|
import { useRef, useCallback } from "react"
|
||||||
|
|
||||||
|
interface UseRowProgressOptions {
|
||||||
|
duration: number
|
||||||
|
onSeek: (time: number) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useRowProgress({ duration, onSeek }: UseRowProgressOptions) {
|
||||||
|
const progressRef = useRef<HTMLDivElement>(null)
|
||||||
|
|
||||||
|
const handleMouseDown = useCallback(
|
||||||
|
(e: React.MouseEvent<HTMLDivElement>) => {
|
||||||
|
if (!progressRef.current) return
|
||||||
|
e.preventDefault()
|
||||||
|
|
||||||
|
const doSeek = (ev: MouseEvent) => {
|
||||||
|
if (!progressRef.current) return
|
||||||
|
const rect = progressRef.current.getBoundingClientRect()
|
||||||
|
const percent = Math.max(0, Math.min(1, (ev.clientX - rect.left) / rect.width))
|
||||||
|
onSeek(percent * duration)
|
||||||
|
}
|
||||||
|
|
||||||
|
doSeek(e.nativeEvent)
|
||||||
|
|
||||||
|
const handleMove = (ev: MouseEvent) => doSeek(ev)
|
||||||
|
const handleUp = () => {
|
||||||
|
document.removeEventListener("mousemove", handleMove)
|
||||||
|
document.removeEventListener("mouseup", handleUp)
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener("mousemove", handleMove)
|
||||||
|
document.addEventListener("mouseup", handleUp)
|
||||||
|
},
|
||||||
|
[duration, onSeek],
|
||||||
|
)
|
||||||
|
|
||||||
|
return { progressRef, handleMouseDown }
|
||||||
|
}
|
||||||
+38
@@ -0,0 +1,38 @@
|
|||||||
|
import { useCallback } from "react"
|
||||||
|
import { useMutation, useQueryClient } from "@tanstack/react-query"
|
||||||
|
import { deleteAsset } from "@/api/assets"
|
||||||
|
import { type VoiceMaterial } from "../../../types"
|
||||||
|
|
||||||
|
interface UseVoiceDeleteOptions {
|
||||||
|
materials: VoiceMaterial[]
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 配音素材删除 Hook
|
||||||
|
*/
|
||||||
|
export function useVoiceDelete({ materials }: UseVoiceDeleteOptions) {
|
||||||
|
const queryClient = useQueryClient()
|
||||||
|
|
||||||
|
const deleteMutation = useMutation({
|
||||||
|
mutationFn: (assetId: string) => deleteAsset(assetId),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["assets", "voice"] })
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const handleDelete = useCallback(
|
||||||
|
(id: string, onBeforeDelete?: () => void) => {
|
||||||
|
const material = materials.find((m) => m.id === id)
|
||||||
|
if (!material) return
|
||||||
|
if (onBeforeDelete) onBeforeDelete()
|
||||||
|
deleteMutation.mutate(id)
|
||||||
|
},
|
||||||
|
[materials, deleteMutation],
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
isDeleting: deleteMutation.isPending,
|
||||||
|
deleteMutation,
|
||||||
|
handleDelete,
|
||||||
|
}
|
||||||
|
}
|
||||||
+78
@@ -0,0 +1,78 @@
|
|||||||
|
import { useCallback } from "react"
|
||||||
|
import { useMutation, useQueryClient } from "@tanstack/react-query"
|
||||||
|
import { updateAsset } from "@/api/assets"
|
||||||
|
import { tagAsset, untagAsset } from "@/api/tags"
|
||||||
|
import { type VoiceGender, type VoiceMaterial, buildMetadata } from "../../../types"
|
||||||
|
|
||||||
|
interface UseVoiceEditOptions {
|
||||||
|
materials: VoiceMaterial[]
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 配音素材编辑 Hook
|
||||||
|
* 封装编辑流程:更新基础信息 + 同步标签差异
|
||||||
|
*/
|
||||||
|
export function useVoiceEdit({ materials }: UseVoiceEditOptions) {
|
||||||
|
const queryClient = useQueryClient()
|
||||||
|
|
||||||
|
const editMutation = useMutation({
|
||||||
|
mutationFn: async (data: {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
gender: VoiceGender
|
||||||
|
description: string
|
||||||
|
tagIds: string[]
|
||||||
|
}) => {
|
||||||
|
// 1. 更新基础信息
|
||||||
|
await updateAsset(data.id, {
|
||||||
|
name: data.name,
|
||||||
|
metadata: buildMetadata({
|
||||||
|
gender: data.gender,
|
||||||
|
description: data.description,
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
|
||||||
|
// 2. 对比标签差异,调用 tag/untag API
|
||||||
|
const currentAsset = materials.find((m) => m.id === data.id)
|
||||||
|
const oldTagIds = currentAsset?.tagIds ?? []
|
||||||
|
const newTagIds = data.tagIds
|
||||||
|
|
||||||
|
const toAdd = newTagIds.filter((id) => !oldTagIds.includes(id))
|
||||||
|
const toRemove = oldTagIds.filter((id) => !newTagIds.includes(id))
|
||||||
|
|
||||||
|
if (toAdd.length > 0) {
|
||||||
|
await tagAsset(data.id, toAdd)
|
||||||
|
}
|
||||||
|
for (const tagId of toRemove) {
|
||||||
|
await untagAsset(data.id, tagId)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["assets", "voice"] })
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["tags"] })
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const handleEdit = useCallback(
|
||||||
|
(
|
||||||
|
editingMaterial: VoiceMaterial | null,
|
||||||
|
data: Omit<VoiceMaterial, "id" | "createdAt"> & { file?: File },
|
||||||
|
) => {
|
||||||
|
if (!editingMaterial) return
|
||||||
|
editMutation.mutate({
|
||||||
|
id: editingMaterial.id,
|
||||||
|
name: data.name,
|
||||||
|
gender: data.gender,
|
||||||
|
description: data.description,
|
||||||
|
tagIds: data.tagIds,
|
||||||
|
})
|
||||||
|
},
|
||||||
|
[editMutation],
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
isEditing: editMutation.isPending,
|
||||||
|
editMutation,
|
||||||
|
handleEdit,
|
||||||
|
}
|
||||||
|
}
|
||||||
+111
@@ -0,0 +1,111 @@
|
|||||||
|
import { useState, useCallback } from "react"
|
||||||
|
import { useMutation, useQueryClient } from "@tanstack/react-query"
|
||||||
|
import { message } from "antd"
|
||||||
|
import {
|
||||||
|
createAsset,
|
||||||
|
uploadAssetDirect,
|
||||||
|
getAssetLibraries,
|
||||||
|
type AssetLibraryItem,
|
||||||
|
} from "@/api/assets"
|
||||||
|
import { tagAsset } from "@/api/tags"
|
||||||
|
import { type VoiceGender, type VoiceMaterial, buildMetadata } from "../../../types"
|
||||||
|
import { getAudioDuration } from "../../../utils/audio"
|
||||||
|
|
||||||
|
interface UseVoiceUploadOptions {
|
||||||
|
voiceLibrary?: { id: string; kind: string }
|
||||||
|
createLibMutation: { mutateAsync: () => Promise<AssetLibraryItem>; isPending: boolean }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 配音素材上传 Hook
|
||||||
|
* 封装上传流程:获取库 → 上传文件 → 获取时长 → 创建记录 → 打标签
|
||||||
|
*/
|
||||||
|
export function useVoiceUpload({ voiceLibrary, createLibMutation }: UseVoiceUploadOptions) {
|
||||||
|
const queryClient = useQueryClient()
|
||||||
|
const [uploadProgress, setUploadProgress] = useState<number | null>(null)
|
||||||
|
|
||||||
|
const uploadMutation = useMutation({
|
||||||
|
mutationFn: async (data: {
|
||||||
|
file: File
|
||||||
|
name: string
|
||||||
|
gender: VoiceGender
|
||||||
|
description: string
|
||||||
|
tagIds: string[]
|
||||||
|
}) => {
|
||||||
|
setUploadProgress(0)
|
||||||
|
try {
|
||||||
|
// 1. 获取或等待 voice library
|
||||||
|
let lib = voiceLibrary
|
||||||
|
if (!lib) {
|
||||||
|
if (createLibMutation.isPending) {
|
||||||
|
await createLibMutation.mutateAsync()
|
||||||
|
}
|
||||||
|
const libs = await queryClient.fetchQuery({
|
||||||
|
queryKey: ["asset-libraries"],
|
||||||
|
queryFn: getAssetLibraries,
|
||||||
|
})
|
||||||
|
lib = libs.find((l: AssetLibraryItem) => l.kind === "voice")
|
||||||
|
if (!lib) throw new Error("无法创建配音库")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. 上传文件(带进度)
|
||||||
|
const { storage_key } = await uploadAssetDirect({
|
||||||
|
file: data.file,
|
||||||
|
library_id: lib.id,
|
||||||
|
onProgress: (p) => setUploadProgress(p),
|
||||||
|
})
|
||||||
|
|
||||||
|
// 3. 获取音频时长
|
||||||
|
const duration = await getAudioDuration(data.file)
|
||||||
|
|
||||||
|
// 4. 创建素材记录
|
||||||
|
const asset = await createAsset({
|
||||||
|
library_id: lib.id,
|
||||||
|
name: data.name,
|
||||||
|
storage_key,
|
||||||
|
mime_type: data.file.type || "audio/mpeg",
|
||||||
|
metadata: buildMetadata({
|
||||||
|
gender: data.gender,
|
||||||
|
description: data.description,
|
||||||
|
duration,
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
|
||||||
|
// 5. 打标签(标签走独立 API)
|
||||||
|
if (data.tagIds.length > 0) {
|
||||||
|
await tagAsset(asset.id, data.tagIds)
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setUploadProgress(null)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["assets", "voice"] })
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["tags"] })
|
||||||
|
},
|
||||||
|
onError: (err: Error) => {
|
||||||
|
message.error(err.message || "上传失败,请重试")
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const handleUpload = useCallback(
|
||||||
|
(data: Omit<VoiceMaterial, "id" | "createdAt"> & { file?: File }) => {
|
||||||
|
if (!data.file) return
|
||||||
|
uploadMutation.mutate({
|
||||||
|
file: data.file,
|
||||||
|
name: data.name,
|
||||||
|
gender: data.gender,
|
||||||
|
description: data.description,
|
||||||
|
tagIds: data.tagIds,
|
||||||
|
})
|
||||||
|
},
|
||||||
|
[uploadMutation],
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
uploadProgress,
|
||||||
|
isUploading: uploadMutation.isPending,
|
||||||
|
uploadMutation,
|
||||||
|
handleUpload,
|
||||||
|
}
|
||||||
|
}
|
||||||
Regular → Executable
+38
-174
@@ -1,17 +1,9 @@
|
|||||||
import { useState, useCallback } from "react"
|
import { useState } from "react"
|
||||||
import { useMutation, useQueryClient } from "@tanstack/react-query"
|
import { type VoiceMaterial } from "../../types"
|
||||||
import { message } from "antd"
|
import { type AssetLibraryItem } from "@/api/assets"
|
||||||
import {
|
import { useVoiceUpload } from "./actions/useVoiceUpload"
|
||||||
createAsset,
|
import { useVoiceEdit } from "./actions/useVoiceEdit"
|
||||||
updateAsset,
|
import { useVoiceDelete } from "./actions/useVoiceDelete"
|
||||||
deleteAsset,
|
|
||||||
uploadAssetDirect,
|
|
||||||
getAssetLibraries,
|
|
||||||
type AssetLibraryItem,
|
|
||||||
} from "@/api/assets"
|
|
||||||
import { tagAsset, untagAsset } from "@/api/tags"
|
|
||||||
import { type VoiceGender, type VoiceMaterial, buildMetadata } from "../../types"
|
|
||||||
import { getAudioDuration } from "../../utils/audio"
|
|
||||||
|
|
||||||
interface UseVoiceMaterialActionsOptions {
|
interface UseVoiceMaterialActionsOptions {
|
||||||
voiceLibrary?: { id: string; kind: string }
|
voiceLibrary?: { id: string; kind: string }
|
||||||
@@ -21,184 +13,56 @@ interface UseVoiceMaterialActionsOptions {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* 配音素材操作 Hook
|
* 配音素材操作 Hook
|
||||||
* 封装上传、编辑、删除等变更操作及相关 UI 状态
|
* 组合上传、编辑、删除三个子 Hook,统一管理弹窗状态
|
||||||
*/
|
*/
|
||||||
export function useVoiceMaterialActions({
|
export function useVoiceMaterialActions({
|
||||||
voiceLibrary,
|
voiceLibrary,
|
||||||
materials,
|
materials,
|
||||||
createLibMutation,
|
createLibMutation,
|
||||||
}: UseVoiceMaterialActionsOptions) {
|
}: UseVoiceMaterialActionsOptions) {
|
||||||
const queryClient = useQueryClient()
|
// ── 弹窗状态 ──
|
||||||
|
|
||||||
// ── 弹窗状态 ──────────────────────────────────────────────
|
|
||||||
const [uploadOpen, setUploadOpen] = useState(false)
|
const [uploadOpen, setUploadOpen] = useState(false)
|
||||||
const [editingMaterial, setEditingMaterial] = useState<VoiceMaterial | null>(null)
|
const [editingMaterial, setEditingMaterial] = useState<VoiceMaterial | null>(null)
|
||||||
|
|
||||||
// ── 上传进度 ──────────────────────────────────────────────
|
// ── 子领域 Hooks ──
|
||||||
const [uploadProgress, setUploadProgress] = useState<number | null>(null)
|
const { uploadProgress, uploadMutation } = useVoiceUpload({ voiceLibrary, createLibMutation })
|
||||||
|
const { editMutation } = useVoiceEdit({ materials })
|
||||||
|
const { handleDelete } = useVoiceDelete({ materials })
|
||||||
|
|
||||||
// ── 上传 mutation ─────────────────────────────────────────
|
/* ── 操作 handlers(关联弹窗状态) ── */
|
||||||
const uploadMutation = useMutation({
|
|
||||||
mutationFn: async (data: {
|
|
||||||
file: File
|
|
||||||
name: string
|
|
||||||
gender: VoiceGender
|
|
||||||
description: string
|
|
||||||
tagIds: string[]
|
|
||||||
}) => {
|
|
||||||
setUploadProgress(0)
|
|
||||||
try {
|
|
||||||
// 1. 获取或等待 voice library
|
|
||||||
let lib = voiceLibrary
|
|
||||||
if (!lib) {
|
|
||||||
if (createLibMutation.isPending) {
|
|
||||||
await createLibMutation.mutateAsync()
|
|
||||||
}
|
|
||||||
const libs = await queryClient.fetchQuery({
|
|
||||||
queryKey: ["asset-libraries"],
|
|
||||||
queryFn: getAssetLibraries,
|
|
||||||
})
|
|
||||||
lib = libs.find((l: AssetLibraryItem) => l.kind === "voice")
|
|
||||||
if (!lib) throw new Error("无法创建配音库")
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2. 上传文件(带进度)
|
const handleUpload = (data: Omit<VoiceMaterial, "id" | "createdAt"> & { file?: File }) => {
|
||||||
const { storage_key } = await uploadAssetDirect({
|
if (!data.file) return
|
||||||
file: data.file,
|
uploadMutation.mutate(
|
||||||
library_id: lib.id,
|
{
|
||||||
onProgress: (p) => setUploadProgress(p),
|
file: data.file,
|
||||||
})
|
|
||||||
|
|
||||||
// 3. 获取音频时长
|
|
||||||
const duration = await getAudioDuration(data.file)
|
|
||||||
|
|
||||||
// 4. 创建素材记录
|
|
||||||
const asset = await createAsset({
|
|
||||||
library_id: lib.id,
|
|
||||||
name: data.name,
|
|
||||||
storage_key,
|
|
||||||
mime_type: data.file.type || "audio/mpeg",
|
|
||||||
metadata: buildMetadata({
|
|
||||||
gender: data.gender,
|
|
||||||
description: data.description,
|
|
||||||
duration,
|
|
||||||
}),
|
|
||||||
})
|
|
||||||
|
|
||||||
// 5. 打标签(标签走独立 API)
|
|
||||||
if (data.tagIds.length > 0) {
|
|
||||||
await tagAsset(asset.id, data.tagIds)
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
setUploadProgress(null)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
onSuccess: () => {
|
|
||||||
queryClient.invalidateQueries({ queryKey: ["assets", "voice"] })
|
|
||||||
queryClient.invalidateQueries({ queryKey: ["tags"] })
|
|
||||||
},
|
|
||||||
onError: (err: Error) => {
|
|
||||||
message.error(err.message || "上传失败,请重试")
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
// ── 编辑 mutation ─────────────────────────────────────────
|
|
||||||
const editMutation = useMutation({
|
|
||||||
mutationFn: async (data: {
|
|
||||||
id: string
|
|
||||||
name: string
|
|
||||||
gender: VoiceGender
|
|
||||||
description: string
|
|
||||||
tagIds: string[]
|
|
||||||
}) => {
|
|
||||||
// 1. 更新基础信息
|
|
||||||
await updateAsset(data.id, {
|
|
||||||
name: data.name,
|
|
||||||
metadata: buildMetadata({
|
|
||||||
gender: data.gender,
|
|
||||||
description: data.description,
|
|
||||||
}),
|
|
||||||
})
|
|
||||||
|
|
||||||
// 2. 对比标签差异,调用 tag/untag API
|
|
||||||
const currentAsset = materials.find((m) => m.id === data.id)
|
|
||||||
const oldTagIds = currentAsset?.tagIds ?? []
|
|
||||||
const newTagIds = data.tagIds
|
|
||||||
|
|
||||||
const toAdd = newTagIds.filter((id) => !oldTagIds.includes(id))
|
|
||||||
const toRemove = oldTagIds.filter((id) => !newTagIds.includes(id))
|
|
||||||
|
|
||||||
if (toAdd.length > 0) {
|
|
||||||
await tagAsset(data.id, toAdd)
|
|
||||||
}
|
|
||||||
for (const tagId of toRemove) {
|
|
||||||
await untagAsset(data.id, tagId)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
onSuccess: () => {
|
|
||||||
queryClient.invalidateQueries({ queryKey: ["assets", "voice"] })
|
|
||||||
queryClient.invalidateQueries({ queryKey: ["tags"] })
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
// ── 删除 mutation ─────────────────────────────────────────
|
|
||||||
const deleteMutation = useMutation({
|
|
||||||
mutationFn: (assetId: string) => deleteAsset(assetId),
|
|
||||||
onSuccess: () => {
|
|
||||||
queryClient.invalidateQueries({ queryKey: ["assets", "voice"] })
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
/* ── 数据操作 handlers ──────────────────────────────────── */
|
|
||||||
|
|
||||||
const handleUpload = useCallback(
|
|
||||||
(data: Omit<VoiceMaterial, "id" | "createdAt"> & { file?: File }) => {
|
|
||||||
if (!data.file) return
|
|
||||||
uploadMutation.mutate(
|
|
||||||
{
|
|
||||||
file: data.file,
|
|
||||||
name: data.name,
|
|
||||||
gender: data.gender,
|
|
||||||
description: data.description,
|
|
||||||
tagIds: data.tagIds,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
onSuccess: () => {
|
|
||||||
setUploadOpen(false)
|
|
||||||
},
|
|
||||||
},
|
|
||||||
)
|
|
||||||
},
|
|
||||||
[uploadMutation],
|
|
||||||
)
|
|
||||||
|
|
||||||
const handleEdit = useCallback(
|
|
||||||
(data: Omit<VoiceMaterial, "id" | "createdAt"> & { file?: File }) => {
|
|
||||||
if (!editingMaterial) return
|
|
||||||
editMutation.mutate({
|
|
||||||
id: editingMaterial.id,
|
|
||||||
name: data.name,
|
name: data.name,
|
||||||
gender: data.gender,
|
gender: data.gender,
|
||||||
description: data.description,
|
description: data.description,
|
||||||
tagIds: data.tagIds,
|
tagIds: data.tagIds,
|
||||||
})
|
},
|
||||||
setEditingMaterial(null)
|
{
|
||||||
},
|
onSuccess: () => {
|
||||||
[editingMaterial, editMutation],
|
setUploadOpen(false)
|
||||||
)
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
const handleDelete = useCallback(
|
const handleEdit = (data: Omit<VoiceMaterial, "id" | "createdAt"> & { file?: File }) => {
|
||||||
(id: string, onBeforeDelete?: () => void) => {
|
if (!editingMaterial) return
|
||||||
const material = materials.find((m) => m.id === id)
|
editMutation.mutate({
|
||||||
if (!material) return
|
id: editingMaterial.id,
|
||||||
if (onBeforeDelete) onBeforeDelete()
|
name: data.name,
|
||||||
deleteMutation.mutate(id)
|
gender: data.gender,
|
||||||
},
|
description: data.description,
|
||||||
[materials, deleteMutation],
|
tagIds: data.tagIds,
|
||||||
)
|
})
|
||||||
|
setEditingMaterial(null)
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
// 上传 & 编辑状态
|
// 上传 & 编辑 loading 状态
|
||||||
uploadProgress,
|
uploadProgress,
|
||||||
isUploading: uploadMutation.isPending,
|
isUploading: uploadMutation.isPending,
|
||||||
isEditing: editMutation.isPending,
|
isEditing: editMutation.isPending,
|
||||||
|
|||||||
@@ -1,16 +1,9 @@
|
|||||||
import React from "react"
|
import React from "react"
|
||||||
import {
|
import { DeleteOutlined, ReloadOutlined, CloseCircleOutlined } from "@ant-design/icons"
|
||||||
SoundOutlined,
|
|
||||||
PlayCircleOutlined,
|
|
||||||
PauseCircleOutlined,
|
|
||||||
DeleteOutlined,
|
|
||||||
ReloadOutlined,
|
|
||||||
CloseCircleOutlined,
|
|
||||||
UserOutlined,
|
|
||||||
} from "@ant-design/icons"
|
|
||||||
import { Tooltip } from "antd"
|
import { Tooltip } from "antd"
|
||||||
import { type ClonedVoiceDisplay } from "@/pages/voices/types"
|
import { type ClonedVoiceDisplay } from "@/pages/voices/types"
|
||||||
import { CLONE_STATUS_CONFIG } from "@/pages/voices/constants"
|
import CardHeader from "./clone-voice-card/CardHeader"
|
||||||
|
import CardFooter from "./clone-voice-card/CardFooter"
|
||||||
|
|
||||||
export interface CloneVoiceCardProps {
|
export interface CloneVoiceCardProps {
|
||||||
voice: ClonedVoiceDisplay
|
voice: ClonedVoiceDisplay
|
||||||
@@ -28,26 +21,18 @@ export interface CloneVoiceCardProps {
|
|||||||
const CloneVoiceCard: React.FC<CloneVoiceCardProps> = ({
|
const CloneVoiceCard: React.FC<CloneVoiceCardProps> = ({
|
||||||
voice,
|
voice,
|
||||||
isPlaying,
|
isPlaying,
|
||||||
currentTime,
|
|
||||||
onPlay,
|
|
||||||
onPause,
|
|
||||||
onUse,
|
|
||||||
onDelete,
|
onDelete,
|
||||||
onRetry,
|
onRetry,
|
||||||
onShowDetail,
|
onShowDetail,
|
||||||
|
...footerProps
|
||||||
}) => {
|
}) => {
|
||||||
const statusCfg = CLONE_STATUS_CONFIG[voice.status]
|
|
||||||
const isFailed = voice.status === "failed"
|
const isFailed = voice.status === "failed"
|
||||||
const isProcessing = voice.status === "processing"
|
|
||||||
const genderText =
|
|
||||||
voice.gender === "male" ? "男声" : voice.gender === "female" ? "女声" : voice.gender
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={`xx-clone-card${isPlaying ? " playing" : ""}${isFailed ? " failed" : ""}`}
|
className={`xx-clone-card${isPlaying ? " playing" : ""}${isFailed ? " failed" : ""}`}
|
||||||
onClick={isFailed ? undefined : onShowDetail}
|
onClick={isFailed ? undefined : onShowDetail}
|
||||||
>
|
>
|
||||||
{/* 右上角操作按钮 */}
|
|
||||||
<div className="xx-clone-card-actions">
|
<div className="xx-clone-card-actions">
|
||||||
<Tooltip title="删除">
|
<Tooltip title="删除">
|
||||||
<button
|
<button
|
||||||
@@ -77,38 +62,8 @@ const CloneVoiceCard: React.FC<CloneVoiceCardProps> = ({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 头部:头像 + 名称 + 状态 */}
|
<CardHeader voice={voice} />
|
||||||
<div className="xx-clone-card-header">
|
|
||||||
<div className={`xx-clone-avatar${isProcessing ? " xx-clone-avatar--processing" : ""}`}>
|
|
||||||
<SoundOutlined />
|
|
||||||
</div>
|
|
||||||
<div className="xx-clone-header-info">
|
|
||||||
<h4 className="xx-clone-name" title={voice.name}>
|
|
||||||
{voice.name}
|
|
||||||
</h4>
|
|
||||||
<span className={`xx-clone-status ${statusCfg.className}`}>
|
|
||||||
<span className="xx-clone-status-dot" />
|
|
||||||
{statusCfg.label}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 描述 */}
|
|
||||||
{voice.description && <p className="xx-clone-desc">{voice.description}</p>}
|
|
||||||
|
|
||||||
{/* 元信息 */}
|
|
||||||
<div className="xx-clone-meta">
|
|
||||||
{(voice.gender || voice.language) && (
|
|
||||||
<span className="xx-clone-meta-item">
|
|
||||||
<UserOutlined />
|
|
||||||
{genderText}
|
|
||||||
{voice.language ? ` · ${voice.language}` : ""}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
<span className="xx-clone-meta-item">{voice.createdAt}</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 错误信息 */}
|
|
||||||
{isFailed && voice.errorMessage && (
|
{isFailed && voice.errorMessage && (
|
||||||
<div className="xx-clone-error">
|
<div className="xx-clone-error">
|
||||||
<CloseCircleOutlined />
|
<CloseCircleOutlined />
|
||||||
@@ -116,63 +71,7 @@ const CloneVoiceCard: React.FC<CloneVoiceCardProps> = ({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* 底部操作区 */}
|
<CardFooter voice={voice} isPlaying={isPlaying} onRetry={onRetry} {...footerProps} />
|
||||||
<div className="xx-clone-footer">
|
|
||||||
{voice.status === "ready" && (
|
|
||||||
<>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="xx-clone-play-btn"
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation()
|
|
||||||
isPlaying ? onPause() : onPlay()
|
|
||||||
}}
|
|
||||||
title={isPlaying ? "暂停" : "试听"}
|
|
||||||
>
|
|
||||||
{isPlaying ? <PauseCircleOutlined /> : <PlayCircleOutlined />}
|
|
||||||
</button>
|
|
||||||
<div className="xx-clone-progress">
|
|
||||||
<div
|
|
||||||
className="xx-clone-progress-bar"
|
|
||||||
style={{
|
|
||||||
width: isPlaying
|
|
||||||
? `${Math.min((currentTime / Math.max(voice.duration, 1)) * 100, 100)}%`
|
|
||||||
: "0%",
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="xx-clone-use-btn"
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation()
|
|
||||||
onUse()
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
使用
|
|
||||||
</button>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
{isProcessing && (
|
|
||||||
<div className="xx-clone-processing-hint">
|
|
||||||
<ReloadOutlined spin />
|
|
||||||
克隆处理中,请稍候...
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{isFailed && (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="xx-clone-retry-btn"
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation()
|
|
||||||
onRetry()
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<ReloadOutlined />
|
|
||||||
重试克隆
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,87 @@
|
|||||||
|
import React from "react"
|
||||||
|
import { PlayCircleOutlined, PauseCircleOutlined, ReloadOutlined } from "@ant-design/icons"
|
||||||
|
import { type ClonedVoiceDisplay } from "@/pages/voices/types"
|
||||||
|
|
||||||
|
interface CardFooterProps {
|
||||||
|
voice: ClonedVoiceDisplay
|
||||||
|
isPlaying: boolean
|
||||||
|
currentTime: number
|
||||||
|
onPlay: () => void
|
||||||
|
onPause: () => void
|
||||||
|
onUse: () => void
|
||||||
|
onRetry: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
const CardFooter: React.FC<CardFooterProps> = ({
|
||||||
|
voice,
|
||||||
|
isPlaying,
|
||||||
|
currentTime,
|
||||||
|
onPlay,
|
||||||
|
onPause,
|
||||||
|
onUse,
|
||||||
|
onRetry,
|
||||||
|
}) => {
|
||||||
|
const isFailed = voice.status === "failed"
|
||||||
|
const isProcessing = voice.status === "processing"
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="xx-clone-footer">
|
||||||
|
{voice.status === "ready" && (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="xx-clone-play-btn"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
isPlaying ? onPause() : onPlay()
|
||||||
|
}}
|
||||||
|
title={isPlaying ? "暂停" : "试听"}
|
||||||
|
>
|
||||||
|
{isPlaying ? <PauseCircleOutlined /> : <PlayCircleOutlined />}
|
||||||
|
</button>
|
||||||
|
<div className="xx-clone-progress">
|
||||||
|
<div
|
||||||
|
className="xx-clone-progress-bar"
|
||||||
|
style={{
|
||||||
|
width: isPlaying
|
||||||
|
? `${Math.min((currentTime / Math.max(voice.duration, 1)) * 100, 100)}%`
|
||||||
|
: "0%",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="xx-clone-use-btn"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
onUse()
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
使用
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{isProcessing && (
|
||||||
|
<div className="xx-clone-processing-hint">
|
||||||
|
<ReloadOutlined spin />
|
||||||
|
克隆处理中,请稍候...
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{isFailed && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="xx-clone-retry-btn"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
onRetry()
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<ReloadOutlined />
|
||||||
|
重试克隆
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default CardFooter
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
import React from "react"
|
||||||
|
import { SoundOutlined, UserOutlined } from "@ant-design/icons"
|
||||||
|
import { type ClonedVoiceDisplay } from "@/pages/voices/types"
|
||||||
|
import { CLONE_STATUS_CONFIG } from "@/pages/voices/constants"
|
||||||
|
|
||||||
|
interface CardHeaderProps {
|
||||||
|
voice: ClonedVoiceDisplay
|
||||||
|
}
|
||||||
|
|
||||||
|
const genderTextOf = (gender: string) =>
|
||||||
|
gender === "male" ? "男声" : gender === "female" ? "女声" : gender
|
||||||
|
|
||||||
|
const CardHeader: React.FC<CardHeaderProps> = ({ voice }) => {
|
||||||
|
const statusCfg = CLONE_STATUS_CONFIG[voice.status]
|
||||||
|
const isProcessing = voice.status === "processing"
|
||||||
|
const genderText = genderTextOf(voice.gender ?? "")
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="xx-clone-card-header">
|
||||||
|
<div className={`xx-clone-avatar${isProcessing ? " xx-clone-avatar--processing" : ""}`}>
|
||||||
|
<SoundOutlined />
|
||||||
|
</div>
|
||||||
|
<div className="xx-clone-header-info">
|
||||||
|
<h4 className="xx-clone-name" title={voice.name}>
|
||||||
|
{voice.name}
|
||||||
|
</h4>
|
||||||
|
<span className={`xx-clone-status ${statusCfg.className}`}>
|
||||||
|
<span className="xx-clone-status-dot" />
|
||||||
|
{statusCfg.label}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{voice.description && <p className="xx-clone-desc">{voice.description}</p>}
|
||||||
|
|
||||||
|
<div className="xx-clone-meta">
|
||||||
|
{(voice.gender || voice.language) && (
|
||||||
|
<span className="xx-clone-meta-item">
|
||||||
|
<UserOutlined />
|
||||||
|
{genderText}
|
||||||
|
{voice.language ? ` · ${voice.language}` : ""}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<span className="xx-clone-meta-item">{voice.createdAt}</span>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default CardHeader
|
||||||
Regular → Executable
+1
@@ -1,3 +1,4 @@
|
|||||||
|
// 重构:DuplicationUpload 页面已拆分为子组件(UploadZone/InfoSidebar/UploadProgress等)
|
||||||
import { describe, expect, it, vi, beforeEach } from "vitest"
|
import { describe, expect, it, vi, beforeEach } from "vitest"
|
||||||
import {
|
import {
|
||||||
uploadForDuplication,
|
uploadForDuplication,
|
||||||
|
|||||||
Regular → Executable
+1
@@ -1,4 +1,5 @@
|
|||||||
import React from "react"
|
import React from "react"
|
||||||
|
// 重构:useCloneModal Hook 已拆分为 useCloneFormState + useCloneSubmit 子 Hook
|
||||||
import { describe, expect, it, vi } from "vitest"
|
import { describe, expect, it, vi } from "vitest"
|
||||||
import { render } from "@testing-library/react"
|
import { render } from "@testing-library/react"
|
||||||
import CloneModal from "@/components/voice/CloneModal"
|
import CloneModal from "@/components/voice/CloneModal"
|
||||||
|
|||||||
Regular → Executable
+5
@@ -122,6 +122,11 @@ import "@/pages/templates/hooks/useTemplateLibrary"
|
|||||||
import "@/pages/templates/hooks/useTemplateDetail"
|
import "@/pages/templates/hooks/useTemplateDetail"
|
||||||
import "@/pages/templates/components/template-library/TemplateCard"
|
import "@/pages/templates/components/template-library/TemplateCard"
|
||||||
import "@/pages/templates/components/template-library/TemplateDetailModal"
|
import "@/pages/templates/components/template-library/TemplateDetailModal"
|
||||||
|
import "@/pages/templates/components/template-library/template-detail-modal/PreviewArea"
|
||||||
|
import "@/pages/templates/components/template-library/template-detail-modal/SegmentList"
|
||||||
|
import "@/pages/templates/components/template-library/template-detail-modal/StyleConfig"
|
||||||
|
import "@/pages/templates/components/template-library/template-detail-modal/DetailFooter"
|
||||||
|
import "@/pages/templates/components/template-library/template-detail-modal/types"
|
||||||
import "@/pages/templates/components/template-library/TemplateHeader"
|
import "@/pages/templates/components/template-library/TemplateHeader"
|
||||||
import "@/pages/templates/components/template-library/TemplateToolbar"
|
import "@/pages/templates/components/template-library/TemplateToolbar"
|
||||||
import "@/pages/templates/components/template-library/TemplateGrid"
|
import "@/pages/templates/components/template-library/TemplateGrid"
|
||||||
|
|||||||
@@ -2,6 +2,9 @@
|
|||||||
* GeneratePage 模块 smoke test
|
* GeneratePage 模块 smoke test
|
||||||
* 建立完整依赖链,确保 vitest related 模式能匹配到
|
* 建立完整依赖链,确保 vitest related 模式能匹配到
|
||||||
* generate 目录下所有文件的改动(包括 Phase 3 子组件)
|
* generate 目录下所有文件的改动(包括 Phase 3 子组件)
|
||||||
|
*
|
||||||
|
* 重构记录:
|
||||||
|
* - useStep2Materials 拆分为 useMaterialLibrary + useSmartMatch 子 Hook
|
||||||
*/
|
*/
|
||||||
import { describe, it, expect } from "vitest"
|
import { describe, it, expect } from "vitest"
|
||||||
|
|
||||||
|
|||||||
Regular → Executable
+10
@@ -12,6 +12,13 @@ import "@/pages/voice-materials/VoiceMaterialLibrary"
|
|||||||
import "@/pages/voice-materials/components/TagSelector"
|
import "@/pages/voice-materials/components/TagSelector"
|
||||||
import "@/pages/voice-materials/components/MaterialForm"
|
import "@/pages/voice-materials/components/MaterialForm"
|
||||||
import "@/pages/voice-materials/components/VoiceMaterialCard"
|
import "@/pages/voice-materials/components/VoiceMaterialCard"
|
||||||
|
import "@/pages/voice-materials/components/voice-material-card/CardHeader"
|
||||||
|
import "@/pages/voice-materials/components/voice-material-card/CardTags"
|
||||||
|
import "@/pages/voice-materials/components/voice-material-card/CardMeta"
|
||||||
|
import "@/pages/voice-materials/components/voice-material-card/CardPlayer"
|
||||||
|
import "@/pages/voice-materials/components/voice-material-card/CardActions"
|
||||||
|
import "@/pages/voice-materials/components/voice-material-card/BatchCheckbox"
|
||||||
|
import "@/pages/voice-materials/components/voice-material-card/types"
|
||||||
import "@/pages/voice-materials/components/VoiceMaterialRow"
|
import "@/pages/voice-materials/components/VoiceMaterialRow"
|
||||||
import "@/pages/voice-materials/components/Toolbar"
|
import "@/pages/voice-materials/components/Toolbar"
|
||||||
import "@/pages/voice-materials/components/TagFilterBar"
|
import "@/pages/voice-materials/components/TagFilterBar"
|
||||||
@@ -33,6 +40,9 @@ describe("VoiceMaterialLibrary module smoke test", () => {
|
|||||||
// Hooks
|
// Hooks
|
||||||
import "@/pages/voice-materials/hooks/useVoiceMaterials"
|
import "@/pages/voice-materials/hooks/useVoiceMaterials"
|
||||||
import "@/pages/voice-materials/hooks/useVoiceMaterials/useVoiceMaterialActions"
|
import "@/pages/voice-materials/hooks/useVoiceMaterials/useVoiceMaterialActions"
|
||||||
|
import "@/pages/voice-materials/hooks/useVoiceMaterials/actions/useVoiceUpload"
|
||||||
|
import "@/pages/voice-materials/hooks/useVoiceMaterials/actions/useVoiceEdit"
|
||||||
|
import "@/pages/voice-materials/hooks/useVoiceMaterials/actions/useVoiceDelete"
|
||||||
import "@/pages/voice-materials/hooks/useTtsSynthesize"
|
import "@/pages/voice-materials/hooks/useTtsSynthesize"
|
||||||
import "@/pages/voice-materials/hooks/useAudioPlayer"
|
import "@/pages/voice-materials/hooks/useAudioPlayer"
|
||||||
import "@/pages/voice-materials/hooks/useBatchOperations"
|
import "@/pages/voice-materials/hooks/useBatchOperations"
|
||||||
|
|||||||
@@ -6,8 +6,6 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from typing import Any, Optional
|
|
||||||
|
|
||||||
# ── 单轨时间计算 ──────────────────────────────────────────────────────────────
|
# ── 单轨时间计算 ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
@@ -261,7 +259,6 @@ def db_to_linear(db: float) -> float:
|
|||||||
Returns:
|
Returns:
|
||||||
线性音量值
|
线性音量值
|
||||||
"""
|
"""
|
||||||
import math
|
|
||||||
|
|
||||||
return 10 ** (db / 20.0)
|
return 10 ** (db / 20.0)
|
||||||
|
|
||||||
|
|||||||
+485
@@ -0,0 +1,485 @@
|
|||||||
|
"""画中画(PiP)引擎纯逻辑模块.
|
||||||
|
|
||||||
|
从 pip_engine.py 抽离的纯函数,0 FFmpeg 依赖,可完全单测。
|
||||||
|
原模块 pip_engine.py 保持不变,向后兼容。
|
||||||
|
|
||||||
|
抽离范围:
|
||||||
|
- 滤镜链构建(scale / 圆角 / 边框 / 透明度 / 动画 / overlay)
|
||||||
|
- 位置与尺寸计算辅助(封装 domain 层调用)
|
||||||
|
- 完整 PiP 滤镜链编排
|
||||||
|
- 配置验证与降级策略判断
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from packages.domain.pip_config import (
|
||||||
|
ANIMATION_FADE,
|
||||||
|
ANIMATION_SLIDE_BOTTOM,
|
||||||
|
ANIMATION_SLIDE_LEFT,
|
||||||
|
ANIMATION_SLIDE_RIGHT,
|
||||||
|
ANIMATION_SLIDE_TOP,
|
||||||
|
PiPLayerConfig,
|
||||||
|
calculate_pip_position,
|
||||||
|
parse_size_value,
|
||||||
|
)
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
# ── 尺寸与位置 ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def compute_pip_size(
|
||||||
|
layer: PiPLayerConfig,
|
||||||
|
output_width: int,
|
||||||
|
output_height: int,
|
||||||
|
) -> tuple[int, int]:
|
||||||
|
"""计算画中画图层的实际像素尺寸.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
layer: 图层配置
|
||||||
|
output_width: 输出视频宽度
|
||||||
|
output_height: 输出视频高度
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
(width, height) 像素值
|
||||||
|
"""
|
||||||
|
pip_w = parse_size_value(layer.width, output_width)
|
||||||
|
if layer.height:
|
||||||
|
pip_h = parse_size_value(layer.height, output_height)
|
||||||
|
else:
|
||||||
|
# 按宽度等比例(默认 16:9)
|
||||||
|
pip_h = int(pip_w * 9 / 16)
|
||||||
|
|
||||||
|
# 钳制到输出尺寸内
|
||||||
|
pip_w = max(1, min(pip_w, output_width))
|
||||||
|
pip_h = max(1, min(pip_h, output_height))
|
||||||
|
return pip_w, pip_h
|
||||||
|
|
||||||
|
|
||||||
|
def compute_pip_position(
|
||||||
|
layer: PiPLayerConfig,
|
||||||
|
pip_width: int,
|
||||||
|
pip_height: int,
|
||||||
|
output_width: int,
|
||||||
|
output_height: int,
|
||||||
|
) -> tuple[int, int]:
|
||||||
|
"""计算画中画的实际位置 (x, y).
|
||||||
|
|
||||||
|
封装 domain 层的 calculate_pip_position,
|
||||||
|
提供默认值并做边界钳制。
|
||||||
|
"""
|
||||||
|
x, y = calculate_pip_position(
|
||||||
|
position=layer.position,
|
||||||
|
output_width=output_width,
|
||||||
|
output_height=output_height,
|
||||||
|
pip_width=pip_width,
|
||||||
|
pip_height=pip_height,
|
||||||
|
margin=layer.margin,
|
||||||
|
custom_x=layer.x,
|
||||||
|
custom_y=layer.y,
|
||||||
|
)
|
||||||
|
|
||||||
|
# 边界钳制:确保不超出画面
|
||||||
|
x = max(0, min(x, output_width - pip_width))
|
||||||
|
y = max(0, min(y, output_height - pip_height))
|
||||||
|
return x, y
|
||||||
|
|
||||||
|
|
||||||
|
# ── 预处理滤镜 ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def build_pip_pre_filter(
|
||||||
|
input_label: str,
|
||||||
|
layer: PiPLayerConfig,
|
||||||
|
pip_width: int,
|
||||||
|
pip_height: int,
|
||||||
|
output_label: str,
|
||||||
|
) -> str:
|
||||||
|
"""构建单个 PiP 图层的预处理滤镜链.
|
||||||
|
|
||||||
|
处理顺序:scale → 圆角裁剪(可选)→ 边框(可选)→ 透明度 → 动画(可选)
|
||||||
|
|
||||||
|
Args:
|
||||||
|
input_label: 输入标签(带方括号,如 "[1:v]")
|
||||||
|
layer: 图层配置
|
||||||
|
pip_width: 缩放后的宽度(像素)
|
||||||
|
pip_height: 缩放后的高度(像素)
|
||||||
|
output_label: 输出标签(不带方括号)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
filter_complex 片段,如 "[1:v]scale=...,setsar=1[pip_pre_0]"
|
||||||
|
"""
|
||||||
|
filters: list[str] = []
|
||||||
|
|
||||||
|
# Step 1: scale + SAR
|
||||||
|
filters.append(f"scale={pip_width}:{pip_height}")
|
||||||
|
filters.append("setsar=1")
|
||||||
|
|
||||||
|
# Step 2: 圆角裁剪
|
||||||
|
if layer.corner_radius > 0:
|
||||||
|
r = min(layer.corner_radius, pip_width // 2, pip_height // 2)
|
||||||
|
# 用 geq + 圆形遮罩实现四角圆角
|
||||||
|
filters.append(
|
||||||
|
"format=yuva420p,"
|
||||||
|
"geq="
|
||||||
|
"lum='lum(X,Y)':"
|
||||||
|
"cb='cb(X,Y)':"
|
||||||
|
"cr='cr(X,Y)':"
|
||||||
|
f"a='if(lt(X,{r})*lt(Y,{r}),"
|
||||||
|
f"gt(hypot({r}-X,{r}-Y),{r})*0+1,"
|
||||||
|
f"if(gt(X,W-{r})*lt(Y,{r}),"
|
||||||
|
f"gt(hypot(X-(W-{r}),{r}-Y),{r})*0+1,"
|
||||||
|
f"if(lt(X,{r})*gt(Y,H-{r}),"
|
||||||
|
f"gt(hypot({r}-X,Y-(H-{r})),{r})*0+1,"
|
||||||
|
f"if(gt(X,W-{r})*gt(Y,H-{r}),"
|
||||||
|
f"gt(hypot(X-(W-{r}),Y-(H-{r})),{r})*0+1,1))))'"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Step 3: 边框
|
||||||
|
if layer.border_width > 0:
|
||||||
|
bw = layer.border_width
|
||||||
|
color = layer.border_color
|
||||||
|
filters.append(f"pad={pip_width + 2 * bw}:{pip_height + 2 * bw}:{bw}:{bw}:{color}")
|
||||||
|
|
||||||
|
# Step 4: 透明度
|
||||||
|
if layer.opacity < 1.0:
|
||||||
|
alpha = max(0.0, min(1.0, layer.opacity))
|
||||||
|
filters.append(f"format=yuva420p,colorchannelmixer=aa={alpha}")
|
||||||
|
|
||||||
|
# Step 5: 入场出场动画(fade 类直接在预处理中加)
|
||||||
|
anim_filters = build_animation_filters(layer, pip_width, pip_height)
|
||||||
|
if anim_filters:
|
||||||
|
filters.extend(anim_filters)
|
||||||
|
|
||||||
|
return f"{input_label}{','.join(filters)}[{output_label}]"
|
||||||
|
|
||||||
|
|
||||||
|
def build_animation_filters(
|
||||||
|
layer: PiPLayerConfig,
|
||||||
|
pip_width: int,
|
||||||
|
pip_height: int,
|
||||||
|
) -> list[str]:
|
||||||
|
"""构建 fade 类入场出场动画滤镜.
|
||||||
|
|
||||||
|
注意:slide 类动画由 overlay 表达式处理,不在此函数内。
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
滤镜字符串列表(每项是一个完整 filter,可直接用逗号连接)
|
||||||
|
"""
|
||||||
|
filters: list[str] = []
|
||||||
|
anim_dur = max(0.0, layer.animation_duration)
|
||||||
|
|
||||||
|
# 入场动画
|
||||||
|
if layer.animation_in == ANIMATION_FADE and anim_dur > 0:
|
||||||
|
filters.append(f"fade=t=in:st=0:d={anim_dur}:alpha=1")
|
||||||
|
|
||||||
|
# 出场动画(需要总时长)
|
||||||
|
if layer.animation_out == ANIMATION_FADE and anim_dur > 0 and layer.duration is not None and layer.duration > 0:
|
||||||
|
start_fade = max(0.0, layer.duration - anim_dur)
|
||||||
|
filters.append(f"fade=t=out:st={start_fade}:d={anim_dur}:alpha=1")
|
||||||
|
|
||||||
|
return filters
|
||||||
|
|
||||||
|
|
||||||
|
# ── Overlay 表达式 ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def build_overlay_expr(
|
||||||
|
layer: PiPLayerConfig,
|
||||||
|
base_x: int,
|
||||||
|
base_y: int,
|
||||||
|
pip_width: int,
|
||||||
|
pip_height: int,
|
||||||
|
output_width: int,
|
||||||
|
output_height: int,
|
||||||
|
) -> tuple[str, str]:
|
||||||
|
"""构建 overlay 滤镜的 x/y 表达式(支持滑动动画).
|
||||||
|
|
||||||
|
Args:
|
||||||
|
layer: 图层配置
|
||||||
|
base_x: 基础 x 坐标(无动画时的最终位置)
|
||||||
|
base_y: 基础 y 坐标
|
||||||
|
pip_width: PiP 图层宽度
|
||||||
|
pip_height: PiP 图层高度
|
||||||
|
output_width: 输出视频宽度
|
||||||
|
output_height: 输出视频高度
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
(x_expr, y_expr) — 可直接传入 overlay= 的参数字符串
|
||||||
|
无动画时返回纯数字字符串,有动画时返回带引号的表达式
|
||||||
|
"""
|
||||||
|
anim_dur = max(0.0, layer.animation_duration)
|
||||||
|
|
||||||
|
x_expr = str(base_x)
|
||||||
|
y_expr = str(base_y)
|
||||||
|
|
||||||
|
# ── 入场滑入动画 ──
|
||||||
|
if anim_dur > 0:
|
||||||
|
if layer.animation_in == ANIMATION_SLIDE_LEFT:
|
||||||
|
# 从左侧滑入:x 从 -pip_width 变化到 base_x
|
||||||
|
x_expr = (
|
||||||
|
f"'{base_x}+if(lt(t,{anim_dur})," f"{-pip_width}+t/{anim_dur}*({base_x + pip_width})," f"{base_x})'"
|
||||||
|
)
|
||||||
|
elif layer.animation_in == ANIMATION_SLIDE_RIGHT:
|
||||||
|
# 从右侧滑入:x 从 output_width 变化到 base_x
|
||||||
|
x_expr = (
|
||||||
|
f"'{base_x}+if(lt(t,{anim_dur}),"
|
||||||
|
f"{output_width}-t/{anim_dur}*({output_width - base_x}),"
|
||||||
|
f"{base_x})'"
|
||||||
|
)
|
||||||
|
elif layer.animation_in == ANIMATION_SLIDE_TOP:
|
||||||
|
# 从顶部滑入
|
||||||
|
y_expr = (
|
||||||
|
f"'{base_y}+if(lt(t,{anim_dur})," f"{-pip_height}+t/{anim_dur}*({base_y + pip_height})," f"{base_y})'"
|
||||||
|
)
|
||||||
|
elif layer.animation_in == ANIMATION_SLIDE_BOTTOM:
|
||||||
|
# 从底部滑入
|
||||||
|
y_expr = (
|
||||||
|
f"'{base_y}+if(lt(t,{anim_dur}),"
|
||||||
|
f"{output_height}-t/{anim_dur}*({output_height - base_y}),"
|
||||||
|
f"{base_y})'"
|
||||||
|
)
|
||||||
|
|
||||||
|
# ── 出场滑出动画(需要总时长) ──
|
||||||
|
if layer.duration is not None and layer.duration > 0 and anim_dur > 0:
|
||||||
|
out_start = layer.duration - anim_dur
|
||||||
|
if out_start < 0:
|
||||||
|
out_start = 0
|
||||||
|
|
||||||
|
if layer.animation_out == ANIMATION_SLIDE_LEFT:
|
||||||
|
# 向左滑出
|
||||||
|
x_expr = (
|
||||||
|
f"'{base_x}+if(gt(t,{out_start}),"
|
||||||
|
f"{base_x}-(t-{out_start})/{anim_dur}*({base_x + pip_width}),"
|
||||||
|
f"{base_x})'"
|
||||||
|
)
|
||||||
|
elif layer.animation_out == ANIMATION_SLIDE_RIGHT:
|
||||||
|
# 向右滑出
|
||||||
|
x_expr = (
|
||||||
|
f"'{base_x}+if(gt(t,{out_start}),"
|
||||||
|
f"{base_x}+(t-{out_start})/{anim_dur}*({output_width - base_x + pip_width}),"
|
||||||
|
f"{base_x})'"
|
||||||
|
)
|
||||||
|
elif layer.animation_out == ANIMATION_SLIDE_TOP:
|
||||||
|
# 向上滑出
|
||||||
|
y_expr = (
|
||||||
|
f"'{base_y}+if(gt(t,{out_start}),"
|
||||||
|
f"{base_y}-(t-{out_start})/{anim_dur}*({base_y + pip_height}),"
|
||||||
|
f"{base_y})'"
|
||||||
|
)
|
||||||
|
elif layer.animation_out == ANIMATION_SLIDE_BOTTOM:
|
||||||
|
# 向下滑出
|
||||||
|
y_expr = (
|
||||||
|
f"'{base_y}+if(gt(t,{out_start}),"
|
||||||
|
f"{base_y}+(t-{out_start})/{anim_dur}*({output_height - base_y + pip_height}),"
|
||||||
|
f"{base_y})'"
|
||||||
|
)
|
||||||
|
|
||||||
|
return x_expr, y_expr
|
||||||
|
|
||||||
|
|
||||||
|
def build_enable_expr(
|
||||||
|
layer: PiPLayerConfig,
|
||||||
|
) -> str:
|
||||||
|
"""构建 overlay 的 enable 时间控制表达式.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
enable 表达式片段,如 ":enable='between(t,1,5)'"
|
||||||
|
无时间限制时返回空字符串
|
||||||
|
"""
|
||||||
|
start = max(0.0, layer.start_time)
|
||||||
|
duration = layer.duration
|
||||||
|
|
||||||
|
if start <= 0 and (duration is None or duration <= 0):
|
||||||
|
return ""
|
||||||
|
|
||||||
|
if duration and duration > 0:
|
||||||
|
end = start + duration
|
||||||
|
return f":enable='between(t,{start},{end})'"
|
||||||
|
else:
|
||||||
|
return f":enable='gte(t,{start})'"
|
||||||
|
|
||||||
|
|
||||||
|
# ── 完整滤镜链 ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def build_pip_filters(
|
||||||
|
base_label: str,
|
||||||
|
layers: list[PiPLayerConfig],
|
||||||
|
source_paths: list[Path | str],
|
||||||
|
*,
|
||||||
|
output_width: int,
|
||||||
|
output_height: int,
|
||||||
|
base_input_idx: int = 0,
|
||||||
|
) -> tuple[list[str], list[str], str]:
|
||||||
|
"""构建完整的画中画滤镜链和输入参数(纯函数版).
|
||||||
|
|
||||||
|
与 PiPEngine.build_pip_filters 对应,但不依赖类实例,
|
||||||
|
所有参数显式传入,方便测试。
|
||||||
|
|
||||||
|
Args:
|
||||||
|
base_label: 底层视频标签(不带方括号)
|
||||||
|
layers: 图层配置列表
|
||||||
|
source_paths: 对应每个图层的源文件路径列表
|
||||||
|
output_width: 输出视频宽度
|
||||||
|
output_height: 输出视频高度
|
||||||
|
base_input_idx: PiP 素材的起始输入索引
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
(filter_parts, input_args, final_label)
|
||||||
|
- filter_parts: 滤镜片段列表(用 ; 连接成 filter_complex)
|
||||||
|
- input_args: 输入参数列表 ["-i", path, "-i", path, ...]
|
||||||
|
- final_label: 最终输出标签(不带方括号)
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ValueError: layers 和 source_paths 长度不一致
|
||||||
|
"""
|
||||||
|
if len(layers) != len(source_paths):
|
||||||
|
raise ValueError(f"layers ({len(layers)}) 和 source_paths ({len(source_paths)}) 长度不一致")
|
||||||
|
|
||||||
|
if not layers:
|
||||||
|
return [], [], base_label
|
||||||
|
|
||||||
|
filter_parts: list[str] = []
|
||||||
|
input_args: list[str] = []
|
||||||
|
current_label = base_label
|
||||||
|
|
||||||
|
for i, (layer, path) in enumerate(zip(layers, source_paths, strict=False)):
|
||||||
|
# 计算实际大小
|
||||||
|
pip_w, pip_h = compute_pip_size(layer, output_width, output_height)
|
||||||
|
|
||||||
|
# 添加输入
|
||||||
|
input_args.extend(["-i", str(path)])
|
||||||
|
|
||||||
|
# 实际输入索引
|
||||||
|
actual_input_idx = base_input_idx + i
|
||||||
|
|
||||||
|
# 预处理标签
|
||||||
|
pre_label = f"pip_pre_{i}"
|
||||||
|
|
||||||
|
# 构建预处理滤镜
|
||||||
|
pre_filter = build_pip_pre_filter(
|
||||||
|
input_label=f"[{actual_input_idx}:v]",
|
||||||
|
layer=layer,
|
||||||
|
pip_width=pip_w,
|
||||||
|
pip_height=pip_h,
|
||||||
|
output_label=pre_label,
|
||||||
|
)
|
||||||
|
filter_parts.append(pre_filter)
|
||||||
|
|
||||||
|
# 计算位置
|
||||||
|
base_x, base_y = compute_pip_position(layer, pip_w, pip_h, output_width, output_height)
|
||||||
|
|
||||||
|
# 构建 overlay 表达式
|
||||||
|
x_expr, y_expr = build_overlay_expr(layer, base_x, base_y, pip_w, pip_h, output_width, output_height)
|
||||||
|
|
||||||
|
# 时间控制
|
||||||
|
enable_expr = build_enable_expr(layer)
|
||||||
|
|
||||||
|
# 合成标签
|
||||||
|
combined_label = f"pip_combined_{i}"
|
||||||
|
|
||||||
|
# overlay 滤镜
|
||||||
|
overlay_filter = (
|
||||||
|
f"[{current_label}][{pre_label}]" f"overlay={x_expr}:{y_expr}{enable_expr}" f"[{combined_label}]"
|
||||||
|
)
|
||||||
|
filter_parts.append(overlay_filter)
|
||||||
|
|
||||||
|
current_label = combined_label
|
||||||
|
|
||||||
|
return filter_parts, input_args, current_label
|
||||||
|
|
||||||
|
|
||||||
|
# ── 配置验证 ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def validate_pip_layer(layer: PiPLayerConfig) -> tuple[bool, str]:
|
||||||
|
"""验证单个 PiP 图层配置是否合法.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
(is_valid, error_message) — 合法时 error_message 为空
|
||||||
|
"""
|
||||||
|
errors: list[str] = []
|
||||||
|
|
||||||
|
# 源类型检查
|
||||||
|
if not layer.source_type:
|
||||||
|
errors.append("source_type 不能为空")
|
||||||
|
elif layer.source_type not in ("local_path", "asset_id", "url"):
|
||||||
|
errors.append(f"不支持的 source_type: {layer.source_type}")
|
||||||
|
|
||||||
|
if not layer.source:
|
||||||
|
errors.append("source 不能为空")
|
||||||
|
|
||||||
|
# 尺寸检查
|
||||||
|
if layer.width is None or layer.width == "":
|
||||||
|
errors.append("width 不能为空")
|
||||||
|
|
||||||
|
# 位置检查
|
||||||
|
valid_positions = {
|
||||||
|
"top_left",
|
||||||
|
"top_center",
|
||||||
|
"top_right",
|
||||||
|
"center_left",
|
||||||
|
"center",
|
||||||
|
"center_right",
|
||||||
|
"bottom_left",
|
||||||
|
"bottom_center",
|
||||||
|
"bottom_right",
|
||||||
|
"custom",
|
||||||
|
}
|
||||||
|
if layer.position not in valid_positions:
|
||||||
|
errors.append(f"不支持的 position: {layer.position}")
|
||||||
|
|
||||||
|
# 数值范围检查
|
||||||
|
if layer.opacity < 0.0 or layer.opacity > 1.0:
|
||||||
|
errors.append(f"opacity 必须在 0-1 之间: {layer.opacity}")
|
||||||
|
|
||||||
|
if layer.corner_radius < 0:
|
||||||
|
errors.append(f"corner_radius 不能为负: {layer.corner_radius}")
|
||||||
|
|
||||||
|
if layer.border_width < 0:
|
||||||
|
errors.append(f"border_width 不能为负: {layer.border_width}")
|
||||||
|
|
||||||
|
if layer.animation_duration < 0:
|
||||||
|
errors.append(f"animation_duration 不能为负: {layer.animation_duration}")
|
||||||
|
|
||||||
|
if layer.start_time < 0:
|
||||||
|
errors.append(f"start_time 不能为负: {layer.start_time}")
|
||||||
|
|
||||||
|
if layer.duration is not None and layer.duration < 0:
|
||||||
|
errors.append(f"duration 不能为负: {layer.duration}")
|
||||||
|
|
||||||
|
# 动画类型检查
|
||||||
|
valid_anims = {
|
||||||
|
"",
|
||||||
|
None,
|
||||||
|
ANIMATION_FADE,
|
||||||
|
ANIMATION_SLIDE_LEFT,
|
||||||
|
ANIMATION_SLIDE_RIGHT,
|
||||||
|
ANIMATION_SLIDE_TOP,
|
||||||
|
ANIMATION_SLIDE_BOTTOM,
|
||||||
|
}
|
||||||
|
if layer.animation_in and layer.animation_in not in valid_anims:
|
||||||
|
errors.append(f"不支持的 animation_in: {layer.animation_in}")
|
||||||
|
if layer.animation_out and layer.animation_out not in valid_anims:
|
||||||
|
errors.append(f"不支持的 animation_out: {layer.animation_out}")
|
||||||
|
|
||||||
|
return (len(errors) == 0, "; ".join(errors))
|
||||||
|
|
||||||
|
|
||||||
|
def count_visible_layers(layers: list[PiPLayerConfig]) -> int:
|
||||||
|
"""统计可见图层数量(排除完全透明的)."""
|
||||||
|
count = 0
|
||||||
|
for layer in layers:
|
||||||
|
if layer.opacity > 0:
|
||||||
|
count += 1
|
||||||
|
return count
|
||||||
|
|
||||||
|
|
||||||
|
def sort_layers_by_z_index(layers: list[PiPLayerConfig]) -> list[PiPLayerConfig]:
|
||||||
|
"""按 z_index 从小到大排序图层(z_index 小的先画,在底层)."""
|
||||||
|
return sorted(layers, key=lambda layer: layer.z_index)
|
||||||
@@ -13,8 +13,6 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from typing import Optional
|
|
||||||
|
|
||||||
from packages.domain.speed_config import (
|
from packages.domain.speed_config import (
|
||||||
DEFAULT_SPEED,
|
DEFAULT_SPEED,
|
||||||
MAX_SPEED,
|
MAX_SPEED,
|
||||||
|
|||||||
@@ -4,12 +4,14 @@
|
|||||||
所有符号均从该模块重新导出,请新代码直接 import packages.shared.url_security。
|
所有符号均从该模块重新导出,请新代码直接 import packages.shared.url_security。
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
from packages.domain.url_security import ( # noqa: F401
|
||||||
|
ALLOWED_VIDEO_MIME_TYPES,
|
||||||
|
)
|
||||||
from packages.shared.url_security import ( # noqa: F401
|
from packages.shared.url_security import ( # noqa: F401
|
||||||
ALLOWED_AUDIO_MIME_TYPES,
|
ALLOWED_AUDIO_MIME_TYPES,
|
||||||
ALLOWED_IMAGE_MIME_TYPES,
|
ALLOWED_IMAGE_MIME_TYPES,
|
||||||
ALLOWED_PORTS,
|
ALLOWED_PORTS,
|
||||||
ALLOWED_SCHEMES,
|
ALLOWED_SCHEMES,
|
||||||
ALLOWED_VIDEO_MIME_TYPES,
|
|
||||||
DEFAULT_MAX_DOWNLOAD_SIZE,
|
DEFAULT_MAX_DOWNLOAD_SIZE,
|
||||||
MAX_URL_LENGTH,
|
MAX_URL_LENGTH,
|
||||||
TRUSTED_DOMAINS,
|
TRUSTED_DOMAINS,
|
||||||
|
|||||||
@@ -8,9 +8,10 @@ from packages.adapters.sqlalchemy_impl import (
|
|||||||
from packages.adapters.sqlalchemy_impl.schema_guard import assert_auto_create_schema_allowed
|
from packages.adapters.sqlalchemy_impl.schema_guard import assert_auto_create_schema_allowed
|
||||||
|
|
||||||
settings = get_settings()
|
settings = get_settings()
|
||||||
ensure_database_exists(settings.database_url)
|
_db_url = settings.effective_database_url
|
||||||
|
ensure_database_exists(_db_url)
|
||||||
engine, SessionLocal = build_session_factory(
|
engine, SessionLocal = build_session_factory(
|
||||||
settings.database_url,
|
_db_url,
|
||||||
pool_size=settings.database_pool_size,
|
pool_size=settings.database_pool_size,
|
||||||
max_overflow=settings.database_max_overflow,
|
max_overflow=settings.database_max_overflow,
|
||||||
pool_timeout=settings.database_pool_timeout,
|
pool_timeout=settings.database_pool_timeout,
|
||||||
|
|||||||
@@ -48,12 +48,20 @@ def build_session_factory(
|
|||||||
return engine, session_factory
|
return engine, session_factory
|
||||||
|
|
||||||
|
|
||||||
|
def _is_sqlite(database_url: str) -> bool:
|
||||||
|
"""检测是否为 SQLite 数据库 URL."""
|
||||||
|
return database_url.startswith("sqlite")
|
||||||
|
|
||||||
|
|
||||||
def _build_admin_url(database_url: str) -> URL:
|
def _build_admin_url(database_url: str) -> URL:
|
||||||
url = make_url(database_url)
|
url = make_url(database_url)
|
||||||
return url.set(database="postgres")
|
return url.set(database="postgres")
|
||||||
|
|
||||||
|
|
||||||
def ensure_database_exists(database_url: str) -> None:
|
def ensure_database_exists(database_url: str) -> None:
|
||||||
|
"""确保数据库存在(仅 PostgreSQL 需要,SQLite 自动创建)."""
|
||||||
|
if _is_sqlite(database_url):
|
||||||
|
return
|
||||||
target_url = make_url(database_url)
|
target_url = make_url(database_url)
|
||||||
admin_engine = create_engine(_build_admin_url(database_url), isolation_level="AUTOCOMMIT")
|
admin_engine = create_engine(_build_admin_url(database_url), isolation_level="AUTOCOMMIT")
|
||||||
try:
|
try:
|
||||||
@@ -70,6 +78,14 @@ def ensure_database_exists(database_url: str) -> None:
|
|||||||
|
|
||||||
|
|
||||||
def initialize_database(engine) -> None:
|
def initialize_database(engine) -> None:
|
||||||
|
"""初始化数据库 schema。
|
||||||
|
|
||||||
|
PostgreSQL 使用 advisory lock 防止并发初始化冲突;
|
||||||
|
SQLite 直接 create_all(单文件,无并发风险)。
|
||||||
|
"""
|
||||||
|
if _is_sqlite(str(engine.url)):
|
||||||
|
Base.metadata.create_all(bind=engine)
|
||||||
|
return
|
||||||
with engine.connect() as connection:
|
with engine.connect() as connection:
|
||||||
connection.execute(text("SELECT pg_advisory_lock(:lock_id)"), {"lock_id": SCHEMA_INIT_LOCK_ID})
|
connection.execute(text("SELECT pg_advisory_lock(:lock_id)"), {"lock_id": SCHEMA_INIT_LOCK_ID})
|
||||||
try:
|
try:
|
||||||
|
|||||||
@@ -34,6 +34,9 @@ class SharedSettings(BaseSettings):
|
|||||||
database_pool_timeout: int = 30
|
database_pool_timeout: int = 30
|
||||||
database_pool_recycle: int = 3600
|
database_pool_recycle: int = 3600
|
||||||
|
|
||||||
|
# 测试用:使用 SQLite 内存数据库(CI 环境无需 PostgreSQL)
|
||||||
|
use_in_memory_db: bool = False
|
||||||
|
|
||||||
# ── Redis ────────────────────────────────────────────────────────────
|
# ── Redis ────────────────────────────────────────────────────────────
|
||||||
redis_url: str = "redis://localhost:6379/0"
|
redis_url: str = "redis://localhost:6379/0"
|
||||||
|
|
||||||
@@ -66,6 +69,16 @@ class SharedSettings(BaseSettings):
|
|||||||
doubao_timeout: int = 30
|
doubao_timeout: int = 30
|
||||||
doubao_max_retries: int = 2
|
doubao_max_retries: int = 2
|
||||||
|
|
||||||
|
@property
|
||||||
|
def effective_database_url(self) -> str:
|
||||||
|
"""返回实际使用的数据库 URL。
|
||||||
|
|
||||||
|
当 USE_IN_MEMORY_DB=True 时返回 SQLite 内存 URL,否则返回 database_url。
|
||||||
|
"""
|
||||||
|
if self.use_in_memory_db:
|
||||||
|
return "sqlite:///./test.db"
|
||||||
|
return self.database_url
|
||||||
|
|
||||||
model_config = SettingsConfigDict(
|
model_config = SettingsConfigDict(
|
||||||
env_file=".env",
|
env_file=".env",
|
||||||
env_file_encoding="utf-8",
|
env_file_encoding="utf-8",
|
||||||
|
|||||||
@@ -22,17 +22,11 @@ import urllib.error
|
|||||||
import urllib.request
|
import urllib.request
|
||||||
from urllib.parse import urljoin
|
from urllib.parse import urljoin
|
||||||
|
|
||||||
from packages.domain.url_security import (
|
from packages.domain.url_security import ALLOWED_AUDIO_MIME_TYPES as _allowed_audio_base
|
||||||
ALLOWED_AUDIO_MIME_TYPES,
|
from packages.domain.url_security import ALLOWED_IMAGE_MIME_TYPES as _allowed_image_base
|
||||||
ALLOWED_IMAGE_MIME_TYPES,
|
|
||||||
)
|
|
||||||
from packages.domain.url_security import ALLOWED_PORTS as _allowed_ports_base
|
from packages.domain.url_security import ALLOWED_PORTS as _allowed_ports_base
|
||||||
from packages.domain.url_security import ALLOWED_SCHEMES as _allowed_schemes_base
|
from packages.domain.url_security import ALLOWED_SCHEMES as _allowed_schemes_base
|
||||||
from packages.domain.url_security import (
|
from packages.domain.url_security import MAX_URL_LENGTH as _max_url_length_base
|
||||||
ALLOWED_VIDEO_MIME_TYPES,
|
|
||||||
MAGIC_NUMBERS,
|
|
||||||
MAX_URL_LENGTH,
|
|
||||||
)
|
|
||||||
from packages.domain.url_security import UrlSecurityError as _UrlSecurityError_base
|
from packages.domain.url_security import UrlSecurityError as _UrlSecurityError_base
|
||||||
from packages.domain.url_security import check_internal_hostname as _check_internal_hostname_base
|
from packages.domain.url_security import check_internal_hostname as _check_internal_hostname_base
|
||||||
from packages.domain.url_security import check_ssrf_ip as _check_ssrf_ip_base
|
from packages.domain.url_security import check_ssrf_ip as _check_ssrf_ip_base
|
||||||
@@ -46,8 +40,21 @@ logger = logging.getLogger(__name__)
|
|||||||
# ── 兼容导出(保持原有变量名供外部引用) ──────────────────────────────────
|
# ── 兼容导出(保持原有变量名供外部引用) ──────────────────────────────────
|
||||||
ALLOWED_SCHEMES = set(_allowed_schemes_base)
|
ALLOWED_SCHEMES = set(_allowed_schemes_base)
|
||||||
ALLOWED_PORTS = set(_allowed_ports_base)
|
ALLOWED_PORTS = set(_allowed_ports_base)
|
||||||
|
ALLOWED_AUDIO_MIME_TYPES = set(_allowed_audio_base)
|
||||||
|
ALLOWED_IMAGE_MIME_TYPES = set(_allowed_image_base)
|
||||||
|
MAX_URL_LENGTH = _max_url_length_base
|
||||||
UrlSecurityError = _UrlSecurityError_base
|
UrlSecurityError = _UrlSecurityError_base
|
||||||
|
|
||||||
|
# 私有别名(供测试和内部引用)
|
||||||
|
_check_internal_hostnames = _check_internal_hostname_base
|
||||||
|
_check_ssrf_ip = _check_ssrf_ip_base
|
||||||
|
|
||||||
|
|
||||||
|
def _is_trusted_domain(hostname: str) -> bool:
|
||||||
|
"""便捷包装:使用模块级 TRUSTED_DOMAINS 做可信域名检查."""
|
||||||
|
return _is_trusted_domain_base(hostname, TRUSTED_DOMAINS)
|
||||||
|
|
||||||
|
|
||||||
# 可信域名白名单(从环境变量读取)
|
# 可信域名白名单(从环境变量读取)
|
||||||
TRUSTED_DOMAINS: set[str] = set()
|
TRUSTED_DOMAINS: set[str] = set()
|
||||||
_env_trusted = os.environ.get("URL_SECURITY_TRUSTED_DOMAINS", "")
|
_env_trusted = os.environ.get("URL_SECURITY_TRUSTED_DOMAINS", "")
|
||||||
@@ -139,7 +146,11 @@ def validate_url_safety(url: str, *, purpose: str = "download") -> str:
|
|||||||
|
|
||||||
parsed = urlparse(url)
|
parsed = urlparse(url)
|
||||||
hostname = parsed.hostname
|
hostname = parsed.hostname
|
||||||
if hostname and not _is_ip_address_base(hostname):
|
if hostname and _is_ip_address_base(hostname):
|
||||||
|
# IP 直接访问:通过本地别名调用以便 mock
|
||||||
|
if ALLOW_DIRECT_IP:
|
||||||
|
_check_ssrf_ip(hostname)
|
||||||
|
elif hostname:
|
||||||
try:
|
try:
|
||||||
_check_ssrf_domain(hostname)
|
_check_ssrf_domain(hostname)
|
||||||
except UrlSecurityError:
|
except UrlSecurityError:
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
[pytest]
|
[pytest]
|
||||||
pythonpath = . apps/api apps/worker packages
|
pythonpath = . apps/api apps/worker packages
|
||||||
testpaths = tests
|
testpaths = tests
|
||||||
|
# importlib 模式避免同名测试文件的模块名冲突
|
||||||
|
addopts = --import-mode=importlib
|
||||||
|
|
||||||
# ===== 覆盖率配置 =====
|
# ===== 覆盖率配置 =====
|
||||||
# 覆盖率统计范围(供 --cov 使用时的默认源)
|
# 覆盖率统计范围(供 --cov 使用时的默认源)
|
||||||
|
|||||||
Regular → Executable
+85
-74
@@ -1,12 +1,13 @@
|
|||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
# 自动合并:CI全绿+已审批后自动squash merge PR到develop
|
# 自动合并:CI全绿+已审批后自动squash merge PR到develop
|
||||||
|
# 短作业模式:只检查一次,不满足条件就退出,由pr-auto-scan定时兜底
|
||||||
# 环境变量:GITHUB_TOKEN, MERGE_TOKEN, PR_NUMBER, PR_HEAD_SHA, BASE_REF, GITHUB_API_URL, GITHUB_REPOSITORY
|
# 环境变量:GITHUB_TOKEN, MERGE_TOKEN, PR_NUMBER, PR_HEAD_SHA, BASE_REF, GITHUB_API_URL, GITHUB_REPOSITORY
|
||||||
set -eu
|
set -eu
|
||||||
|
|
||||||
set -eu
|
|
||||||
|
|
||||||
echo "PR #${PR_NUMBER} - 检查CI状态+审批并自动合并到${BASE_REF}"
|
echo "PR #${PR_NUMBER} - 检查CI状态+审批并自动合并到${BASE_REF}"
|
||||||
echo
|
echo
|
||||||
|
echo "模式: 短作业(只检查一次,不满足则退出,由pr-auto-scan定时兜底)"
|
||||||
|
echo
|
||||||
|
|
||||||
# 只合develop分支
|
# 只合develop分支
|
||||||
if [ "$BASE_REF" != "develop" ]; then
|
if [ "$BASE_REF" != "develop" ]; then
|
||||||
@@ -22,6 +23,7 @@ TOTAL=$(echo "$FILES" | grep -cv '^$' || true)
|
|||||||
FRONTEND_COUNT=$(echo "$FILES" | grep -c '^apps/web/' || true)
|
FRONTEND_COUNT=$(echo "$FILES" | grep -c '^apps/web/' || true)
|
||||||
BACKEND_COUNT=$((TOTAL - FRONTEND_COUNT))
|
BACKEND_COUNT=$((TOTAL - FRONTEND_COUNT))
|
||||||
echo "变更文件: ${TOTAL} 个 (前端: ${FRONTEND_COUNT}, 后端/公共: ${BACKEND_COUNT})"
|
echo "变更文件: ${TOTAL} 个 (前端: ${FRONTEND_COUNT}, 后端/公共: ${BACKEND_COUNT})"
|
||||||
|
echo
|
||||||
|
|
||||||
# 使用统一的CI Gate门禁(单一检查点,自动处理前端/后端/全栈跳过逻辑)
|
# 使用统一的CI Gate门禁(单一检查点,自动处理前端/后端/全栈跳过逻辑)
|
||||||
CONTEXTS=(
|
CONTEXTS=(
|
||||||
@@ -30,21 +32,20 @@ CONTEXTS=(
|
|||||||
echo "检查CI Gate统一门禁"
|
echo "检查CI Gate统一门禁"
|
||||||
echo
|
echo
|
||||||
|
|
||||||
# 初始等待30秒,给CI启动写status的时间
|
# 等待60秒,给CI启动写status的时间
|
||||||
echo "等待30秒让CI启动..."
|
echo "等待60秒让CI启动..."
|
||||||
sleep 30
|
sleep 60
|
||||||
|
|
||||||
# 405连续计数器
|
# 405计数器(单次运行内重试)
|
||||||
MERGE_405_COUNT=0
|
MERGE_405_COUNT=0
|
||||||
MAX_405_RETRIES=10
|
MAX_405_RETRIES=3
|
||||||
|
|
||||||
# 轮询等待,最多30分钟(180次x10秒)
|
check_and_merge() {
|
||||||
for attempt in $(seq 1 90); do # 最多等45分钟(90次x30秒),确保等得到Worker构建完成
|
|
||||||
ALL_SUCCESS=true
|
ALL_SUCCESS=true
|
||||||
ANY_FAILED=false
|
ANY_FAILED=false
|
||||||
ANY_PENDING=false
|
ANY_PENDING=false
|
||||||
|
|
||||||
echo "--- 第${attempt}次检查 ($(date '+%H:%M:%S')) ---"
|
echo "--- 检查CI状态 ($(date '+%H:%M:%S')) ---"
|
||||||
|
|
||||||
# 检查CI状态
|
# 检查CI状态
|
||||||
for ctx in "${CONTEXTS[@]}"; do
|
for ctx in "${CONTEXTS[@]}"; do
|
||||||
@@ -61,77 +62,87 @@ for attempt in $(seq 1 90); do # 最多等45分钟(90次x30秒),确保等得
|
|||||||
fi
|
fi
|
||||||
done
|
done
|
||||||
|
|
||||||
|
# CI有失败 → 不合并,直接退出
|
||||||
# CI全绿 → 合并
|
|
||||||
if [ "$ALL_SUCCESS" = "true" ]; then
|
|
||||||
echo
|
|
||||||
echo "CI全绿,执行自动合并"
|
|
||||||
echo "等待60秒冷却,给Gitea内部状态同步时间..."
|
|
||||||
sleep 60
|
|
||||||
|
|
||||||
# 幂等检查:PR是否还是open
|
|
||||||
PR_STATE=$(curl -s -H "Authorization: token ${MERGE_TOKEN}" \
|
|
||||||
"${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/pulls/${PR_NUMBER}" \
|
|
||||||
| python3 -c "import sys,json; print(json.load(sys.stdin).get('state',''))")
|
|
||||||
|
|
||||||
if [ "$PR_STATE" != "open" ]; then
|
|
||||||
echo "PR状态为 ${PR_STATE},无需合并"
|
|
||||||
exit 0
|
|
||||||
fi
|
|
||||||
|
|
||||||
# 执行squash merge
|
|
||||||
HTTP_CODE=$(curl -s -o /tmp/merge_resp.json -w "%{http_code}" \
|
|
||||||
-X POST \
|
|
||||||
-H "Authorization: token ${MERGE_TOKEN}" \
|
|
||||||
-H "Content-Type: application/json" \
|
|
||||||
-d '{"do":"squash","merge_title_field":"","merge_message_field":"","delete_branch_after_merge":true,"force_merge":false}' \
|
|
||||||
"${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/pulls/${PR_NUMBER}/merge")
|
|
||||||
|
|
||||||
echo "合并API HTTP状态: $HTTP_CODE"
|
|
||||||
|
|
||||||
if [ "$HTTP_CODE" = "200" ]; then
|
|
||||||
echo "自动合并成功"
|
|
||||||
exit 0
|
|
||||||
elif [ "$HTTP_CODE" = "405" ]; then
|
|
||||||
MERGE_405_COUNT=$((MERGE_405_COUNT + 1))
|
|
||||||
echo "⚠️ 合并返回405(第${MERGE_405_COUNT}次),可能CI状态尚未同步或有未解决的门禁,继续等待重试..."
|
|
||||||
cat /tmp/merge_resp.json 2>/dev/null || true
|
|
||||||
echo
|
|
||||||
if [ "$MERGE_405_COUNT" -ge "$MAX_405_RETRIES" ]; then
|
|
||||||
echo "⚠️ 连续${MAX_405_RETRIES}次合并返回405,放弃自动合并(需人工确认,非代码问题)"
|
|
||||||
curl -s -X POST \
|
|
||||||
-H "Authorization: token ${MERGE_TOKEN}" \
|
|
||||||
-H "Content-Type: application/json" \
|
|
||||||
-d '{"body": "Auto merge skipped after multiple 405 errors: PR may have conflicts or unresolved checks. Please review manually. This is not a CI failure."}' \
|
|
||||||
"${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/issues/${PR_NUMBER}/comments" > /dev/null 2>&1 || true
|
|
||||||
exit 0
|
|
||||||
fi
|
|
||||||
sleep 30
|
|
||||||
continue
|
|
||||||
else
|
|
||||||
echo "自动合并失败 (HTTP $HTTP_CODE)"
|
|
||||||
cat /tmp/merge_resp.json 2>/dev/null || true
|
|
||||||
curl -s -X POST \
|
|
||||||
-H "Authorization: token ${MERGE_TOKEN}" \
|
|
||||||
-H "Content-Type: application/json" \
|
|
||||||
-d "{\"body\": \"Auto merge failed (HTTP ${HTTP_CODE}), please check manually.\"}" \
|
|
||||||
"${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/issues/${PR_NUMBER}/comments" > /dev/null 2>&1 || true
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
else
|
|
||||||
# 本轮不满足合并条件,重置405计数器
|
|
||||||
MERGE_405_COUNT=0
|
|
||||||
fi
|
|
||||||
|
|
||||||
if [ "$ANY_FAILED" = "true" ]; then
|
if [ "$ANY_FAILED" = "true" ]; then
|
||||||
echo
|
echo
|
||||||
echo "CI有失败项,不自动合并"
|
echo "❌ CI有失败项,不自动合并"
|
||||||
exit 0
|
exit 0
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
# CI未全绿(pending中)→ 退出,等下次触发
|
||||||
|
if [ "$ALL_SUCCESS" != "true" ]; then
|
||||||
|
echo
|
||||||
|
echo "⏳ CI尚未全绿(仍有pending),退出等待下次触发"
|
||||||
|
echo " (pr-auto-scan每5分钟扫描一次,CI通过后会自动合并)"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
# CI全绿 → 合并
|
||||||
|
echo
|
||||||
|
echo "✅ CI全绿,执行自动合并"
|
||||||
|
echo "等待30秒冷却,给Gitea内部状态同步时间..."
|
||||||
sleep 30
|
sleep 30
|
||||||
|
|
||||||
|
# 幂等检查:PR是否还是open
|
||||||
|
PR_STATE=$(curl -s -H "Authorization: token ${MERGE_TOKEN}" \
|
||||||
|
"${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/pulls/${PR_NUMBER}" \
|
||||||
|
| python3 -c "import sys,json; print(json.load(sys.stdin).get('state',''))")
|
||||||
|
|
||||||
|
if [ "$PR_STATE" != "open" ]; then
|
||||||
|
echo "PR状态为 ${PR_STATE},无需合并"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 执行squash merge
|
||||||
|
HTTP_CODE=$(curl -s -o /tmp/merge_resp.json -w "%{http_code}" \
|
||||||
|
-X POST \
|
||||||
|
-H "Authorization: token ${MERGE_TOKEN}" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"do":"squash","merge_title_field":"","merge_message_field":"","delete_branch_after_merge":true,"force_merge":false}' \
|
||||||
|
"${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/pulls/${PR_NUMBER}/merge")
|
||||||
|
|
||||||
|
echo "合并API HTTP状态: $HTTP_CODE"
|
||||||
|
|
||||||
|
if [ "$HTTP_CODE" = "200" ]; then
|
||||||
|
echo "✅ 自动合并成功"
|
||||||
|
exit 0
|
||||||
|
elif [ "$HTTP_CODE" = "405" ]; then
|
||||||
|
MERGE_405_COUNT=$((MERGE_405_COUNT + 1))
|
||||||
|
echo "⚠️ 合并返回405(第${MERGE_405_COUNT}次),可能CI状态尚未同步或有未解决的门禁"
|
||||||
|
cat /tmp/merge_resp.json 2>/dev/null || true
|
||||||
|
echo
|
||||||
|
if [ "$MERGE_405_COUNT" -ge "$MAX_405_RETRIES" ]; then
|
||||||
|
echo "⚠️ 连续${MAX_405_RETRIES}次合并返回405,放弃本次自动合并"
|
||||||
|
echo " (pr-auto-scan会继续尝试,需人工确认是否有冲突或门禁问题)"
|
||||||
|
curl -s -X POST \
|
||||||
|
-H "Authorization: token ${MERGE_TOKEN}" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"body": "Auto merge skipped after multiple 405 errors: PR may have conflicts or unresolved checks. Please review manually. This is not a CI failure."}' \
|
||||||
|
"${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/issues/${PR_NUMBER}/comments" > /dev/null 2>&1 || true
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
echo "30秒后重试..."
|
||||||
|
sleep 30
|
||||||
|
return 1 # 重试
|
||||||
|
else
|
||||||
|
echo "❌ 自动合并失败 (HTTP $HTTP_CODE)"
|
||||||
|
cat /tmp/merge_resp.json 2>/dev/null || true
|
||||||
|
curl -s -X POST \
|
||||||
|
-H "Authorization: token ${MERGE_TOKEN}" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d "{\"body\": \"Auto merge failed (HTTP ${HTTP_CODE}), please check manually.\"}" \
|
||||||
|
"${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/issues/${PR_NUMBER}/comments" > /dev/null 2>&1 || true
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
# 最多重试3次(用于405重试,非CI轮询)
|
||||||
|
for i in 1 2 3; do
|
||||||
|
if check_and_merge; then
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
done
|
done
|
||||||
|
|
||||||
echo
|
echo
|
||||||
echo "快速检查超时(3分钟),CI尚未全绿或无审批,退出等待下次触发"
|
echo "本次检查未满足合并条件,退出。pr-auto-scan每5分钟会继续扫描。"
|
||||||
exit 0
|
exit 0
|
||||||
|
|||||||
@@ -0,0 +1,531 @@
|
|||||||
|
"""ass_subtitle_builder 单元测试 - wave169
|
||||||
|
|
||||||
|
覆盖:
|
||||||
|
- hex_to_ass_color 颜色转换
|
||||||
|
- position_to_ass_alignment 位置对齐映射
|
||||||
|
- build_ass_style Style行构建
|
||||||
|
- escape_ass_text 文本转义
|
||||||
|
- format_ass_time 时间格式化
|
||||||
|
- build_ass_content 完整ASS内容生成
|
||||||
|
"""
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from packages.domain.ass_subtitle_builder import (
|
||||||
|
TITLE_MARGIN_BOTTOM,
|
||||||
|
TITLE_MARGIN_SIDE,
|
||||||
|
TITLE_MARGIN_TOP,
|
||||||
|
build_ass_content,
|
||||||
|
build_ass_style,
|
||||||
|
escape_ass_text,
|
||||||
|
format_ass_time,
|
||||||
|
hex_to_ass_color,
|
||||||
|
position_to_ass_alignment,
|
||||||
|
)
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# hex_to_ass_color
|
||||||
|
# ============================================================
|
||||||
|
|
||||||
|
|
||||||
|
class TestHexToAssColor:
|
||||||
|
def test_red(self):
|
||||||
|
# #FF0000 → &H0000FF (BBGGRR)
|
||||||
|
assert hex_to_ass_color("#FF0000") == "&H0000FF"
|
||||||
|
|
||||||
|
def test_blue(self):
|
||||||
|
# #0000FF → &HFF0000
|
||||||
|
assert hex_to_ass_color("#0000FF") == "&HFF0000"
|
||||||
|
|
||||||
|
def test_green(self):
|
||||||
|
# #00FF00 → &H00FF00
|
||||||
|
assert hex_to_ass_color("#00FF00") == "&H00FF00"
|
||||||
|
|
||||||
|
def test_white(self):
|
||||||
|
assert hex_to_ass_color("#FFFFFF") == "&HFFFFFF"
|
||||||
|
|
||||||
|
def test_black(self):
|
||||||
|
assert hex_to_ass_color("#000000") == "&H000000"
|
||||||
|
|
||||||
|
def test_without_hash_prefix(self):
|
||||||
|
assert hex_to_ass_color("FF0000") == "&H0000FF"
|
||||||
|
|
||||||
|
def test_mixed_case(self):
|
||||||
|
assert hex_to_ass_color("#aBcDeF") == "&HEFCDAB"
|
||||||
|
|
||||||
|
def test_invalid_length_short(self):
|
||||||
|
assert hex_to_ass_color("#FFF") == "&H000000"
|
||||||
|
|
||||||
|
def test_invalid_length_long(self):
|
||||||
|
assert hex_to_ass_color("#FF0000FF") == "&H000000"
|
||||||
|
|
||||||
|
def test_empty_string(self):
|
||||||
|
assert hex_to_ass_color("") == "&H000000"
|
||||||
|
|
||||||
|
def test_uppercase_output(self):
|
||||||
|
result = hex_to_ass_color("#abcdef")
|
||||||
|
assert result == result.upper()
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# position_to_ass_alignment
|
||||||
|
# ============================================================
|
||||||
|
|
||||||
|
|
||||||
|
class TestPositionToAssAlignment:
|
||||||
|
def test_top(self):
|
||||||
|
assert position_to_ass_alignment("top") == 8
|
||||||
|
|
||||||
|
def test_center(self):
|
||||||
|
assert position_to_ass_alignment("center") == 5
|
||||||
|
|
||||||
|
def test_bottom(self):
|
||||||
|
assert position_to_ass_alignment("bottom") == 2
|
||||||
|
|
||||||
|
def test_unknown_defaults_top(self):
|
||||||
|
assert position_to_ass_alignment("unknown") == 8
|
||||||
|
|
||||||
|
def test_empty_defaults_top(self):
|
||||||
|
assert position_to_ass_alignment("") == 8
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# build_ass_style
|
||||||
|
# ============================================================
|
||||||
|
|
||||||
|
|
||||||
|
class TestBuildAssStyle:
|
||||||
|
def test_minimal_style(self):
|
||||||
|
result = build_ass_style("Default")
|
||||||
|
assert result.startswith("Style: Default,")
|
||||||
|
|
||||||
|
def test_contains_font_name(self):
|
||||||
|
result = build_ass_style("S1", font_name="Arial")
|
||||||
|
assert "Arial" in result
|
||||||
|
|
||||||
|
def test_contains_font_size(self):
|
||||||
|
result = build_ass_style("S1", font_size=36)
|
||||||
|
# Style行格式:Name, Fontname, Fontsize, ...
|
||||||
|
parts = result.split(",")
|
||||||
|
assert parts[2] == "36"
|
||||||
|
|
||||||
|
def test_bold_true(self):
|
||||||
|
result = build_ass_style("S1", bold=True)
|
||||||
|
parts = result.split(",")
|
||||||
|
# Bold 是第7个字段(索引7)
|
||||||
|
assert parts[7] == "-1"
|
||||||
|
|
||||||
|
def test_bold_false(self):
|
||||||
|
result = build_ass_style("S1", bold=False)
|
||||||
|
parts = result.split(",")
|
||||||
|
assert parts[7] == "0"
|
||||||
|
|
||||||
|
def test_italic_true(self):
|
||||||
|
result = build_ass_style("S1", italic=True)
|
||||||
|
parts = result.split(",")
|
||||||
|
# Italic 是第8个字段(索引8)
|
||||||
|
assert parts[8] == "-1"
|
||||||
|
|
||||||
|
def test_italic_false(self):
|
||||||
|
result = build_ass_style("S1", italic=False)
|
||||||
|
parts = result.split(",")
|
||||||
|
assert parts[8] == "0"
|
||||||
|
|
||||||
|
def test_alignment(self):
|
||||||
|
result = build_ass_style("S1", alignment=5)
|
||||||
|
parts = result.split(",")
|
||||||
|
# Alignment 是第18个字段(索引18)
|
||||||
|
assert parts[18] == "5"
|
||||||
|
|
||||||
|
def test_outline_width(self):
|
||||||
|
result = build_ass_style("S1", outline_width=3.0)
|
||||||
|
parts = result.split(",")
|
||||||
|
# Outline 是第16个字段(索引16)
|
||||||
|
assert parts[16] == "3.0"
|
||||||
|
|
||||||
|
def test_margins(self):
|
||||||
|
result = build_ass_style("S1", margin_l=10, margin_r=20, margin_v=30)
|
||||||
|
parts = result.split(",")
|
||||||
|
assert parts[19] == "10" # MarginL
|
||||||
|
assert parts[20] == "20" # MarginR
|
||||||
|
assert parts[21] == "30" # MarginV
|
||||||
|
|
||||||
|
def test_shadow_with_blur(self):
|
||||||
|
result = build_ass_style("S1", shadow_blur=2.0, shadow_offset=(3, 5))
|
||||||
|
parts = result.split(",")
|
||||||
|
# Shadow 深度 = shadow_offset[1] when blur > 0
|
||||||
|
assert parts[17] == "5"
|
||||||
|
|
||||||
|
def test_shadow_without_blur(self):
|
||||||
|
result = build_ass_style("S1", shadow_blur=0.0, shadow_offset=(3, 5))
|
||||||
|
parts = result.split(",")
|
||||||
|
assert parts[17] == "0"
|
||||||
|
|
||||||
|
def test_primary_color(self):
|
||||||
|
result = build_ass_style("S1", primary_color="&H00FFFFFF")
|
||||||
|
parts = result.split(",")
|
||||||
|
assert parts[3] == "&H00FFFFFF"
|
||||||
|
|
||||||
|
def test_outline_color(self):
|
||||||
|
result = build_ass_style("S1", outline_color="&H000000FF")
|
||||||
|
parts = result.split(",")
|
||||||
|
assert parts[5] == "&H000000FF"
|
||||||
|
|
||||||
|
def test_22_fields(self):
|
||||||
|
# ASS Style 行应有23个字段(Style: 前缀 + 22个逗号分隔字段)
|
||||||
|
result = build_ass_style("Default")
|
||||||
|
parts = result.split(",")
|
||||||
|
assert len(parts) >= 22 # 至少22个字段
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# escape_ass_text
|
||||||
|
# ============================================================
|
||||||
|
|
||||||
|
|
||||||
|
class TestEscapeAssText:
|
||||||
|
def test_plain_text_unchanged(self):
|
||||||
|
assert escape_ass_text("Hello World") == "Hello World"
|
||||||
|
|
||||||
|
def test_newline_converted(self):
|
||||||
|
assert escape_ass_text("line1\nline2") == "line1\\Nline2"
|
||||||
|
|
||||||
|
def test_crlf_converted(self):
|
||||||
|
assert escape_ass_text("line1\r\nline2") == "line1\\Nline2"
|
||||||
|
|
||||||
|
def test_carriage_return_converted(self):
|
||||||
|
assert escape_ass_text("line1\rline2") == "line1\\Nline2"
|
||||||
|
|
||||||
|
def test_curly_braces_escaped(self):
|
||||||
|
assert escape_ass_text("{text}") == "(text)"
|
||||||
|
|
||||||
|
def test_opening_brace_escaped(self):
|
||||||
|
assert escape_ass_text("{hello") == "(hello"
|
||||||
|
|
||||||
|
def test_closing_brace_escaped(self):
|
||||||
|
assert escape_ass_text("hello}") == "hello)"
|
||||||
|
|
||||||
|
def test_multiple_braces(self):
|
||||||
|
assert escape_ass_text("{a}{b}") == "(a)(b)"
|
||||||
|
|
||||||
|
def test_mixed_newlines_and_braces(self):
|
||||||
|
result = escape_ass_text("line1\n{tag}line2")
|
||||||
|
assert result == "line1\\N(tag)line2"
|
||||||
|
|
||||||
|
def test_empty_string(self):
|
||||||
|
assert escape_ass_text("") == ""
|
||||||
|
|
||||||
|
def test_chinese_text(self):
|
||||||
|
assert escape_ass_text("你好世界") == "你好世界"
|
||||||
|
|
||||||
|
def test_backslash_n_in_input(self):
|
||||||
|
# 文本里本身有 \n 字符串(不是换行符)
|
||||||
|
result = escape_ass_text("\\n")
|
||||||
|
assert result == "\\n" # 不变,因为不是实际换行符
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# format_ass_time
|
||||||
|
# ============================================================
|
||||||
|
|
||||||
|
|
||||||
|
class TestFormatAssTime:
|
||||||
|
def test_zero(self):
|
||||||
|
assert format_ass_time(0) == "0:00:00.00"
|
||||||
|
|
||||||
|
def test_seconds_only(self):
|
||||||
|
assert format_ass_time(5.5) == "0:00:05.50"
|
||||||
|
|
||||||
|
def test_minutes(self):
|
||||||
|
assert format_ass_time(90.0) == "0:01:30.00"
|
||||||
|
|
||||||
|
def test_hours(self):
|
||||||
|
assert format_ass_time(3661.5) == "1:01:01.50"
|
||||||
|
|
||||||
|
def test_multi_hours(self):
|
||||||
|
assert format_ass_time(7384.25) == "2:03:04.25"
|
||||||
|
|
||||||
|
def test_two_decimal_places(self):
|
||||||
|
result = format_ass_time(1.234)
|
||||||
|
# 两位小数
|
||||||
|
assert result.endswith(".23") or result.endswith(".24")
|
||||||
|
|
||||||
|
def test_minutes_two_digits(self):
|
||||||
|
result = format_ass_time(65.0)
|
||||||
|
parts = result.split(":")
|
||||||
|
assert len(parts[1]) == 2
|
||||||
|
assert parts[1] == "01"
|
||||||
|
|
||||||
|
def test_seconds_two_digits_before_decimal(self):
|
||||||
|
result = format_ass_time(5.0)
|
||||||
|
parts = result.split(":")
|
||||||
|
sec_part = parts[2]
|
||||||
|
assert sec_part.startswith("05")
|
||||||
|
|
||||||
|
def test_float_input(self):
|
||||||
|
assert format_ass_time(123.45) == "0:02:03.45"
|
||||||
|
|
||||||
|
def test_exactly_one_hour(self):
|
||||||
|
assert format_ass_time(3600.0) == "1:00:00.00"
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# build_ass_content
|
||||||
|
# ============================================================
|
||||||
|
|
||||||
|
|
||||||
|
class TestBuildAssContent:
|
||||||
|
def test_no_subtitles_returns_empty(self):
|
||||||
|
result = build_ass_content(video_width=1920, video_height=1080, video_duration=10.0)
|
||||||
|
assert result == ""
|
||||||
|
|
||||||
|
def test_title_only(self):
|
||||||
|
result = build_ass_content(
|
||||||
|
video_width=1920,
|
||||||
|
video_height=1080,
|
||||||
|
video_duration=5.0,
|
||||||
|
title_text="Test Title",
|
||||||
|
)
|
||||||
|
assert result != ""
|
||||||
|
assert "[Script Info]" in result
|
||||||
|
assert "PlayResX: 1920" in result
|
||||||
|
assert "PlayResY: 1080" in result
|
||||||
|
assert "[V4+ Styles]" in result
|
||||||
|
assert "[Events]" in result
|
||||||
|
assert "TitleStyle" in result
|
||||||
|
assert "Test Title" in result
|
||||||
|
|
||||||
|
def test_subtitle_only(self):
|
||||||
|
result = build_ass_content(
|
||||||
|
video_width=1920,
|
||||||
|
video_height=1080,
|
||||||
|
video_duration=5.0,
|
||||||
|
subtitle_text="Hello Subtitle",
|
||||||
|
)
|
||||||
|
assert result != ""
|
||||||
|
assert "SubtitleStyle" in result
|
||||||
|
assert "Hello Subtitle" in result
|
||||||
|
|
||||||
|
def test_both_title_and_subtitle(self):
|
||||||
|
result = build_ass_content(
|
||||||
|
video_width=1920,
|
||||||
|
video_height=1080,
|
||||||
|
video_duration=5.0,
|
||||||
|
title_text="Title",
|
||||||
|
subtitle_text="Subtitle",
|
||||||
|
)
|
||||||
|
assert "TitleStyle" in result
|
||||||
|
assert "SubtitleStyle" in result
|
||||||
|
assert "Title" in result
|
||||||
|
assert "Subtitle" in result
|
||||||
|
|
||||||
|
def test_whitespace_title_returns_empty(self):
|
||||||
|
result = build_ass_content(
|
||||||
|
video_width=1920,
|
||||||
|
video_height=1080,
|
||||||
|
video_duration=5.0,
|
||||||
|
title_text=" ",
|
||||||
|
)
|
||||||
|
assert result == ""
|
||||||
|
|
||||||
|
def test_whitespace_subtitle_returns_empty(self):
|
||||||
|
result = build_ass_content(
|
||||||
|
video_width=1920,
|
||||||
|
video_height=1080,
|
||||||
|
video_duration=5.0,
|
||||||
|
subtitle_text=" \n ",
|
||||||
|
)
|
||||||
|
assert result == ""
|
||||||
|
|
||||||
|
def test_title_disabled(self):
|
||||||
|
result = build_ass_content(
|
||||||
|
video_width=1920,
|
||||||
|
video_height=1080,
|
||||||
|
video_duration=5.0,
|
||||||
|
title_text="Title",
|
||||||
|
title_config={"enabled": False},
|
||||||
|
)
|
||||||
|
assert result == ""
|
||||||
|
|
||||||
|
def test_subtitle_disabled(self):
|
||||||
|
result = build_ass_content(
|
||||||
|
video_width=1920,
|
||||||
|
video_height=1080,
|
||||||
|
video_duration=5.0,
|
||||||
|
subtitle_text="Sub",
|
||||||
|
subtitle_config={"enabled": False},
|
||||||
|
)
|
||||||
|
assert result == ""
|
||||||
|
|
||||||
|
def test_title_color(self):
|
||||||
|
result = build_ass_content(
|
||||||
|
video_width=1920,
|
||||||
|
video_height=1080,
|
||||||
|
video_duration=5.0,
|
||||||
|
title_text="T",
|
||||||
|
title_config={"color": "#FF0000"},
|
||||||
|
)
|
||||||
|
# 红色 → &H0000FF
|
||||||
|
assert "&H0000FF" in result
|
||||||
|
|
||||||
|
def test_title_position_top(self):
|
||||||
|
result = build_ass_content(
|
||||||
|
video_width=1920,
|
||||||
|
video_height=1080,
|
||||||
|
video_duration=5.0,
|
||||||
|
title_text="T",
|
||||||
|
title_config={"position": "top"},
|
||||||
|
)
|
||||||
|
# top alignment = 8
|
||||||
|
assert "TitleStyle" in result
|
||||||
|
|
||||||
|
def test_title_position_bottom(self):
|
||||||
|
result = build_ass_content(
|
||||||
|
video_width=1920,
|
||||||
|
video_height=1080,
|
||||||
|
video_duration=5.0,
|
||||||
|
title_text="T",
|
||||||
|
title_config={"position": "bottom"},
|
||||||
|
)
|
||||||
|
# bottom=2, 检查Style行里有2
|
||||||
|
assert "TitleStyle" in result
|
||||||
|
|
||||||
|
def test_subtitle_position_bottom(self):
|
||||||
|
result = build_ass_content(
|
||||||
|
video_width=1920,
|
||||||
|
video_height=1080,
|
||||||
|
video_duration=5.0,
|
||||||
|
subtitle_text="S",
|
||||||
|
subtitle_config={"position": "bottom"},
|
||||||
|
)
|
||||||
|
assert "SubtitleStyle" in result
|
||||||
|
|
||||||
|
def test_title_font_size(self):
|
||||||
|
result = build_ass_content(
|
||||||
|
video_width=1920,
|
||||||
|
video_height=1080,
|
||||||
|
video_duration=5.0,
|
||||||
|
title_text="T",
|
||||||
|
title_config={"size": 72},
|
||||||
|
)
|
||||||
|
# 在TitleStyle行里查找字体大小
|
||||||
|
for line in result.split("\n"):
|
||||||
|
if line.startswith("Style: TitleStyle"):
|
||||||
|
parts = line.split(",")
|
||||||
|
assert parts[2] == "72"
|
||||||
|
break
|
||||||
|
|
||||||
|
def test_title_bold(self):
|
||||||
|
result = build_ass_content(
|
||||||
|
video_width=1920,
|
||||||
|
video_height=1080,
|
||||||
|
video_duration=5.0,
|
||||||
|
title_text="T",
|
||||||
|
title_config={"bold": True},
|
||||||
|
)
|
||||||
|
for line in result.split("\n"):
|
||||||
|
if line.startswith("Style: TitleStyle"):
|
||||||
|
parts = line.split(",")
|
||||||
|
assert parts[7] == "-1"
|
||||||
|
break
|
||||||
|
|
||||||
|
def test_title_stroke_enabled(self):
|
||||||
|
result = build_ass_content(
|
||||||
|
video_width=1920,
|
||||||
|
video_height=1080,
|
||||||
|
video_duration=5.0,
|
||||||
|
title_text="T",
|
||||||
|
title_config={"stroke": {"enabled": True, "width": 3, "color": "#000000"}},
|
||||||
|
)
|
||||||
|
for line in result.split("\n"):
|
||||||
|
if line.startswith("Style: TitleStyle"):
|
||||||
|
parts = line.split(",")
|
||||||
|
assert parts[16] == "3.0"
|
||||||
|
break
|
||||||
|
|
||||||
|
def test_title_stroke_disabled(self):
|
||||||
|
result = build_ass_content(
|
||||||
|
video_width=1920,
|
||||||
|
video_height=1080,
|
||||||
|
video_duration=5.0,
|
||||||
|
title_text="T",
|
||||||
|
title_config={"stroke": {"enabled": False, "width": 3}},
|
||||||
|
)
|
||||||
|
for line in result.split("\n"):
|
||||||
|
if line.startswith("Style: TitleStyle"):
|
||||||
|
parts = line.split(",")
|
||||||
|
assert parts[16] == "0.0"
|
||||||
|
break
|
||||||
|
|
||||||
|
def test_title_shadow_enabled(self):
|
||||||
|
result = build_ass_content(
|
||||||
|
video_width=1920,
|
||||||
|
video_height=1080,
|
||||||
|
video_duration=5.0,
|
||||||
|
title_text="T",
|
||||||
|
title_config={"shadow": {"enabled": True, "blur": 2, "offset_x": 2, "offset_y": 4}},
|
||||||
|
)
|
||||||
|
for line in result.split("\n"):
|
||||||
|
if line.startswith("Style: TitleStyle"):
|
||||||
|
parts = line.split(",")
|
||||||
|
assert parts[17] == "4" # Shadow = offset_y
|
||||||
|
break
|
||||||
|
|
||||||
|
def test_dialogue_has_correct_timing(self):
|
||||||
|
result = build_ass_content(
|
||||||
|
video_width=1920,
|
||||||
|
video_height=1080,
|
||||||
|
video_duration=65.5,
|
||||||
|
title_text="T",
|
||||||
|
)
|
||||||
|
# 结束时间应该是 0:01:05.50
|
||||||
|
assert "0:01:05.50" in result
|
||||||
|
|
||||||
|
def test_dialogue_starts_at_zero(self):
|
||||||
|
result = build_ass_content(
|
||||||
|
video_width=1920,
|
||||||
|
video_height=1080,
|
||||||
|
video_duration=10.0,
|
||||||
|
subtitle_text="S",
|
||||||
|
)
|
||||||
|
assert "0:00:00.00" in result
|
||||||
|
|
||||||
|
def test_contains_script_info_header(self):
|
||||||
|
result = build_ass_content(
|
||||||
|
video_width=1920,
|
||||||
|
video_height=1080,
|
||||||
|
video_duration=10.0,
|
||||||
|
title_text="T",
|
||||||
|
)
|
||||||
|
assert "[Script Info]" in result
|
||||||
|
assert "ScriptType: v4.00+" in result
|
||||||
|
assert "ScaledBorderAndShadow: yes" in result
|
||||||
|
|
||||||
|
def test_escaped_text_in_dialogue(self):
|
||||||
|
result = build_ass_content(
|
||||||
|
video_width=1920,
|
||||||
|
video_height=1080,
|
||||||
|
video_duration=10.0,
|
||||||
|
title_text="line1\nline2",
|
||||||
|
)
|
||||||
|
# 换行符应被转义为 \N
|
||||||
|
assert "\\N" in result
|
||||||
|
assert "line1" in result
|
||||||
|
assert "line2" in result
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# 常量验证
|
||||||
|
# ============================================================
|
||||||
|
|
||||||
|
|
||||||
|
class TestConstants:
|
||||||
|
def test_margin_values(self):
|
||||||
|
assert TITLE_MARGIN_TOP > 0
|
||||||
|
assert TITLE_MARGIN_BOTTOM > 0
|
||||||
|
assert TITLE_MARGIN_SIDE > 0
|
||||||
|
|
||||||
|
def test_margins_are_integers(self):
|
||||||
|
assert isinstance(TITLE_MARGIN_TOP, int)
|
||||||
|
assert isinstance(TITLE_MARGIN_BOTTOM, int)
|
||||||
|
assert isinstance(TITLE_MARGIN_SIDE, int)
|
||||||
Executable
+530
@@ -0,0 +1,530 @@
|
|||||||
|
"""asset_scoring 单元测试 - wave162
|
||||||
|
|
||||||
|
覆盖:
|
||||||
|
- 分辨率评分 score_resolution
|
||||||
|
- 时长评分 score_duration
|
||||||
|
- 码率评分 score_bitrate
|
||||||
|
- 加权总分 calculate_total_score
|
||||||
|
- 单个素材评分 score_asset_detail
|
||||||
|
- 时长分桶 _bucket_by_duration
|
||||||
|
- 多样性选择 diverse_selection
|
||||||
|
- 候选过滤 filter_candidates
|
||||||
|
"""
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from packages.domain.asset_scoring import (
|
||||||
|
AssetScoreDetail,
|
||||||
|
SmartSelectResult,
|
||||||
|
_bucket_by_duration,
|
||||||
|
calculate_total_score,
|
||||||
|
diverse_selection,
|
||||||
|
filter_candidates,
|
||||||
|
score_asset_detail,
|
||||||
|
score_bitrate,
|
||||||
|
score_duration,
|
||||||
|
score_resolution,
|
||||||
|
)
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# score_resolution
|
||||||
|
# ============================================================
|
||||||
|
|
||||||
|
|
||||||
|
class TestScoreResolution:
|
||||||
|
def test_none_width_returns_mid(self):
|
||||||
|
assert score_resolution(None, 1080) == 0.5
|
||||||
|
|
||||||
|
def test_none_height_returns_mid(self):
|
||||||
|
assert score_resolution(1920, None) == 0.5
|
||||||
|
|
||||||
|
def test_zero_dimension_returns_mid(self):
|
||||||
|
assert score_resolution(0, 1080) == 0.5
|
||||||
|
assert score_resolution(1920, 0) == 0.5
|
||||||
|
assert score_resolution(-1, 1080) == 0.5
|
||||||
|
|
||||||
|
def test_exact_target_returns_1(self):
|
||||||
|
assert score_resolution(1920, 1080) == 1.0
|
||||||
|
|
||||||
|
def test_higher_than_target_returns_1(self):
|
||||||
|
assert score_resolution(3840, 2160) == 1.0 # 4K
|
||||||
|
assert score_resolution(2560, 1440) == 1.0 # 2K
|
||||||
|
|
||||||
|
def test_lower_than_target_linear_decay(self):
|
||||||
|
# 720p = 1280*720 / 1920*1080 = 0.444 ratio
|
||||||
|
# score = 0.3 + 0.7 * 0.444 = 0.611
|
||||||
|
score = score_resolution(1280, 720)
|
||||||
|
assert 0.55 < score < 0.7
|
||||||
|
|
||||||
|
def test_very_low_has_floor(self):
|
||||||
|
# 最低不低于 0.1
|
||||||
|
score = score_resolution(100, 100)
|
||||||
|
assert score >= 0.1
|
||||||
|
|
||||||
|
def test_480p_still_reasonable(self):
|
||||||
|
score = score_resolution(640, 480)
|
||||||
|
assert 0.3 < score < 0.5
|
||||||
|
|
||||||
|
def test_custom_target(self):
|
||||||
|
score = score_resolution(1280, 720, target_width=1280, target_height=720)
|
||||||
|
assert score == 1.0
|
||||||
|
|
||||||
|
def test_between_0_and_1(self):
|
||||||
|
for w, h in [(1920, 1080), (1280, 720), (640, 480), (3840, 2160)]:
|
||||||
|
s = score_resolution(w, h)
|
||||||
|
assert 0.0 <= s <= 1.0
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# score_duration
|
||||||
|
# ============================================================
|
||||||
|
|
||||||
|
|
||||||
|
class TestScoreDuration:
|
||||||
|
def test_none_returns_mid(self):
|
||||||
|
assert score_duration(None) == 0.5
|
||||||
|
|
||||||
|
def test_zero_or_negative_returns_mid(self):
|
||||||
|
assert score_duration(0) == 0.5
|
||||||
|
assert score_duration(-1) == 0.5
|
||||||
|
|
||||||
|
def test_optimal_range_returns_1(self):
|
||||||
|
assert score_duration(3.0) == 1.0
|
||||||
|
assert score_duration(10.0) == 1.0
|
||||||
|
assert score_duration(30.0) == 1.0
|
||||||
|
assert score_duration(15.0) == 1.0
|
||||||
|
|
||||||
|
def test_short_duration_linear_decay(self):
|
||||||
|
# 1.5s: ratio = 1.5/3 = 0.5, score = 0.3 + 0.7*0.5 = 0.65
|
||||||
|
score = score_duration(1.5)
|
||||||
|
assert score == pytest.approx(0.65)
|
||||||
|
|
||||||
|
def test_very_short_above_floor(self):
|
||||||
|
score = score_duration(0.1)
|
||||||
|
assert 0.3 <= score < 0.5
|
||||||
|
|
||||||
|
def test_long_duration_penalty(self):
|
||||||
|
# 40s: excess=10, penalty=10/10*0.1=0.1, score=0.9
|
||||||
|
score = score_duration(40.0)
|
||||||
|
assert score == pytest.approx(0.9)
|
||||||
|
|
||||||
|
def test_very_long_minimum_floor(self):
|
||||||
|
# 超过很多,最低 0.2
|
||||||
|
score = score_duration(1000.0)
|
||||||
|
assert score >= 0.2
|
||||||
|
assert score < 0.5
|
||||||
|
|
||||||
|
def test_just_below_optimal(self):
|
||||||
|
score = score_duration(2.9)
|
||||||
|
assert 0.9 < score < 1.0
|
||||||
|
|
||||||
|
def test_just_above_optimal(self):
|
||||||
|
score = score_duration(30.1)
|
||||||
|
assert 0.9 < score < 1.0
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# score_bitrate
|
||||||
|
# ============================================================
|
||||||
|
|
||||||
|
|
||||||
|
class TestScoreBitrate:
|
||||||
|
def test_no_file_size_returns_mid(self):
|
||||||
|
assert score_bitrate(0, 10.0) == 0.5
|
||||||
|
|
||||||
|
def test_no_duration_returns_mid(self):
|
||||||
|
assert score_bitrate(1000000, None) == 0.5
|
||||||
|
assert score_bitrate(1000000, 0) == 0.5
|
||||||
|
assert score_bitrate(1000000, -1) == 0.5
|
||||||
|
|
||||||
|
def test_optimal_range_returns_1(self):
|
||||||
|
# 5 Mbps for 10s = 5*10^6 * 10 / 8 = 6,250,000 bytes
|
||||||
|
size_5mbps_10s = int(5_000_000 * 10 / 8)
|
||||||
|
assert score_bitrate(size_5mbps_10s, 10.0) == 1.0
|
||||||
|
|
||||||
|
def test_low_bitrate_decay(self):
|
||||||
|
# 500 Kbps for 10s
|
||||||
|
size_500kbps = int(500_000 * 10 / 8)
|
||||||
|
score = score_bitrate(size_500kbps, 10.0)
|
||||||
|
assert 0.3 < score < 0.7
|
||||||
|
|
||||||
|
def test_high_bitrate_moderate_penalty(self):
|
||||||
|
# 16 Mbps (2x optimal high), excess=1.0, penalty=min(0.5, 1.0*0.2)=0.2
|
||||||
|
# score = 0.8
|
||||||
|
size_16mbps = int(16_000_000 * 10 / 8)
|
||||||
|
score = score_bitrate(size_16mbps, 10.0)
|
||||||
|
assert 0.7 < score < 0.9
|
||||||
|
|
||||||
|
def test_very_high_bitrate_floor(self):
|
||||||
|
# 极高码率,最低 0.5
|
||||||
|
huge_size = 10**9 # 1GB for 1s = 8Gbps
|
||||||
|
score = score_bitrate(huge_size, 1.0)
|
||||||
|
assert score >= 0.5
|
||||||
|
|
||||||
|
def test_between_0_and_1(self):
|
||||||
|
for size, dur in [(1000, 1), (1000000, 10), (100000000, 5)]:
|
||||||
|
s = score_bitrate(size, dur)
|
||||||
|
assert 0.0 <= s <= 1.0
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# calculate_total_score
|
||||||
|
# ============================================================
|
||||||
|
|
||||||
|
|
||||||
|
class TestCalculateTotalScore:
|
||||||
|
def test_all_perfect_equals_1(self):
|
||||||
|
assert calculate_total_score(1.0, 1.0, 1.0, 1.0) == 1.0
|
||||||
|
|
||||||
|
def test_all_zero_equals_0(self):
|
||||||
|
assert calculate_total_score(0.0, 0.0, 0.0, 0.0) == 0.0
|
||||||
|
|
||||||
|
def test_weighted_sum(self):
|
||||||
|
# 0.5*0.5 + 0.2*0.5 + 0.2*0.5 + 0.1*0.5 = 0.25+0.1+0.1+0.05 = 0.5
|
||||||
|
assert calculate_total_score(0.5, 0.5, 0.5, 0.5) == pytest.approx(0.5)
|
||||||
|
|
||||||
|
def test_quality_has_highest_weight(self):
|
||||||
|
# 只提高质量分,对比只提高其他
|
||||||
|
q_high = calculate_total_score(1.0, 0.0, 0.0, 0.0)
|
||||||
|
r_high = calculate_total_score(0.0, 1.0, 0.0, 0.0)
|
||||||
|
assert q_high > r_high # 0.5 > 0.2
|
||||||
|
|
||||||
|
def test_bitrate_has_lowest_weight(self):
|
||||||
|
b_high = calculate_total_score(0.0, 0.0, 0.0, 1.0)
|
||||||
|
q_high = calculate_total_score(1.0, 0.0, 0.0, 0.0)
|
||||||
|
assert b_high < q_high # 0.1 < 0.5
|
||||||
|
|
||||||
|
def test_rounded_to_4_decimals(self):
|
||||||
|
result = calculate_total_score(0.3333, 0.3333, 0.3333, 0.3333)
|
||||||
|
assert round(result, 4) == result
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# score_asset_detail
|
||||||
|
# ============================================================
|
||||||
|
|
||||||
|
|
||||||
|
class TestScoreAssetDetail:
|
||||||
|
def test_returns_detail_object(self):
|
||||||
|
detail = score_asset_detail(
|
||||||
|
asset_id="a1",
|
||||||
|
quality=80.0,
|
||||||
|
width=1920,
|
||||||
|
height=1080,
|
||||||
|
duration=10.0,
|
||||||
|
file_size=5_000_000,
|
||||||
|
)
|
||||||
|
assert isinstance(detail, AssetScoreDetail)
|
||||||
|
assert detail.asset_id == "a1"
|
||||||
|
assert 0.0 <= detail.total_score <= 1.0
|
||||||
|
|
||||||
|
def test_perfect_asset_high_score(self):
|
||||||
|
detail = score_asset_detail(
|
||||||
|
asset_id="perfect",
|
||||||
|
quality=100.0,
|
||||||
|
width=1920,
|
||||||
|
height=1080,
|
||||||
|
duration=10.0,
|
||||||
|
file_size=6_250_000, # 5Mbps for 10s
|
||||||
|
)
|
||||||
|
assert detail.total_score > 0.9
|
||||||
|
|
||||||
|
def test_quality_none_defaults_mid(self):
|
||||||
|
detail = score_asset_detail(
|
||||||
|
asset_id="a1",
|
||||||
|
quality=None,
|
||||||
|
width=1920,
|
||||||
|
height=1080,
|
||||||
|
duration=10.0,
|
||||||
|
file_size=5_000_000,
|
||||||
|
)
|
||||||
|
assert detail.quality_score == 0.5
|
||||||
|
|
||||||
|
def test_quality_normalized(self):
|
||||||
|
detail = score_asset_detail(
|
||||||
|
asset_id="a1",
|
||||||
|
quality=50.0,
|
||||||
|
width=1920,
|
||||||
|
height=1080,
|
||||||
|
duration=10.0,
|
||||||
|
file_size=5_000_000,
|
||||||
|
)
|
||||||
|
assert detail.quality_score == pytest.approx(0.5)
|
||||||
|
|
||||||
|
def test_custom_target_resolution(self):
|
||||||
|
detail = score_asset_detail(
|
||||||
|
asset_id="a1",
|
||||||
|
quality=100.0,
|
||||||
|
width=1280,
|
||||||
|
height=720,
|
||||||
|
duration=10.0,
|
||||||
|
file_size=5_000_000,
|
||||||
|
target_width=1280,
|
||||||
|
target_height=720,
|
||||||
|
)
|
||||||
|
assert detail.resolution_score == 1.0
|
||||||
|
|
||||||
|
def test_total_score_matches_components(self):
|
||||||
|
detail = score_asset_detail(
|
||||||
|
asset_id="a1",
|
||||||
|
quality=80.0,
|
||||||
|
width=1920,
|
||||||
|
height=1080,
|
||||||
|
duration=10.0,
|
||||||
|
file_size=5_000_000,
|
||||||
|
)
|
||||||
|
expected = calculate_total_score(
|
||||||
|
detail.quality_score,
|
||||||
|
detail.resolution_score,
|
||||||
|
detail.duration_score,
|
||||||
|
detail.bitrate_score,
|
||||||
|
)
|
||||||
|
assert detail.total_score == pytest.approx(expected, abs=0.001)
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# _bucket_by_duration
|
||||||
|
# ============================================================
|
||||||
|
|
||||||
|
|
||||||
|
class TestBucketByDuration:
|
||||||
|
def test_none_is_unknown(self):
|
||||||
|
item = AssetScoreDetail("a", 0.5, 0.5, 0.5, 0.5, 0.5, None)
|
||||||
|
assert _bucket_by_duration(item) == "unknown"
|
||||||
|
|
||||||
|
def test_short(self):
|
||||||
|
item = AssetScoreDetail("a", 0.5, 0.5, 0.5, 0.5, 0.5, 3.0)
|
||||||
|
assert _bucket_by_duration(item) == "short"
|
||||||
|
|
||||||
|
def test_short_boundary(self):
|
||||||
|
item = AssetScoreDetail("a", 0.5, 0.5, 0.5, 0.5, 0.5, 4.9)
|
||||||
|
assert _bucket_by_duration(item) == "short"
|
||||||
|
|
||||||
|
def test_medium(self):
|
||||||
|
item = AssetScoreDetail("a", 0.5, 0.5, 0.5, 0.5, 0.5, 10.0)
|
||||||
|
assert _bucket_by_duration(item) == "medium"
|
||||||
|
|
||||||
|
def test_medium_boundary(self):
|
||||||
|
item = AssetScoreDetail("a", 0.5, 0.5, 0.5, 0.5, 0.5, 5.0)
|
||||||
|
assert _bucket_by_duration(item) == "medium"
|
||||||
|
|
||||||
|
def test_medium_upper_boundary(self):
|
||||||
|
item = AssetScoreDetail("a", 0.5, 0.5, 0.5, 0.5, 0.5, 14.9)
|
||||||
|
assert _bucket_by_duration(item) == "medium"
|
||||||
|
|
||||||
|
def test_long(self):
|
||||||
|
item = AssetScoreDetail("a", 0.5, 0.5, 0.5, 0.5, 0.5, 20.0)
|
||||||
|
assert _bucket_by_duration(item) == "long"
|
||||||
|
|
||||||
|
def test_long_boundary(self):
|
||||||
|
item = AssetScoreDetail("a", 0.5, 0.5, 0.5, 0.5, 0.5, 15.0)
|
||||||
|
assert _bucket_by_duration(item) == "long"
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# diverse_selection
|
||||||
|
# ============================================================
|
||||||
|
|
||||||
|
|
||||||
|
def _make_detail(asset_id: str, score: float, duration: float) -> AssetScoreDetail:
|
||||||
|
return AssetScoreDetail(
|
||||||
|
asset_id=asset_id,
|
||||||
|
total_score=score,
|
||||||
|
quality_score=score,
|
||||||
|
resolution_score=score,
|
||||||
|
duration_score=score,
|
||||||
|
bitrate_score=score,
|
||||||
|
duration=duration,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TestDiverseSelection:
|
||||||
|
def test_empty_input_returns_empty(self):
|
||||||
|
assert diverse_selection([], 5) == []
|
||||||
|
|
||||||
|
def test_zero_count_returns_empty(self):
|
||||||
|
items = [_make_detail("a1", 0.9, 10.0)]
|
||||||
|
assert diverse_selection(items, 0) == []
|
||||||
|
|
||||||
|
def test_negative_count_returns_empty(self):
|
||||||
|
items = [_make_detail("a1", 0.9, 10.0)]
|
||||||
|
assert diverse_selection(items, -1) == []
|
||||||
|
|
||||||
|
def test_fewer_items_than_count(self):
|
||||||
|
items = [_make_detail("a1", 0.9, 10.0), _make_detail("a2", 0.8, 3.0)]
|
||||||
|
result = diverse_selection(items, 10)
|
||||||
|
assert len(result) == 2
|
||||||
|
|
||||||
|
def test_picks_top_from_each_bucket(self):
|
||||||
|
# 3个桶各有3个素材,选3个
|
||||||
|
items = [
|
||||||
|
_make_detail("s1", 0.95, 2.0),
|
||||||
|
_make_detail("m1", 0.9, 10.0),
|
||||||
|
_make_detail("l1", 0.85, 20.0),
|
||||||
|
_make_detail("s2", 0.8, 3.0),
|
||||||
|
_make_detail("m2", 0.75, 8.0),
|
||||||
|
_make_detail("l2", 0.7, 25.0),
|
||||||
|
]
|
||||||
|
result = diverse_selection(items, 3)
|
||||||
|
assert len(result) == 3
|
||||||
|
ids = [d.asset_id for d in result]
|
||||||
|
assert "s1" in ids
|
||||||
|
assert "m1" in ids
|
||||||
|
assert "l1" in ids
|
||||||
|
|
||||||
|
def test_base_quota_when_count_large(self):
|
||||||
|
# count=6, base_quota=max(1, 6//3)=2
|
||||||
|
items = [
|
||||||
|
_make_detail("s1", 1.0, 2.0),
|
||||||
|
_make_detail("s2", 0.9, 3.0),
|
||||||
|
_make_detail("s3", 0.8, 4.0),
|
||||||
|
_make_detail("m1", 0.95, 10.0),
|
||||||
|
_make_detail("m2", 0.85, 12.0),
|
||||||
|
_make_detail("l1", 0.92, 20.0),
|
||||||
|
_make_detail("l2", 0.82, 30.0),
|
||||||
|
]
|
||||||
|
result = diverse_selection(items, 6)
|
||||||
|
assert len(result) == 6
|
||||||
|
ids = [d.asset_id for d in result]
|
||||||
|
# 每桶至少2个
|
||||||
|
short_count = sum(1 for d in result if d.duration and d.duration < 5)
|
||||||
|
assert short_count >= 2
|
||||||
|
|
||||||
|
def test_remaining_filled_by_global_score(self):
|
||||||
|
# 只有2个桶有内容,count=5,配额用完后剩余从全局取
|
||||||
|
items = [
|
||||||
|
_make_detail("s1", 1.0, 2.0),
|
||||||
|
_make_detail("s2", 0.9, 3.0),
|
||||||
|
_make_detail("m1", 0.95, 10.0),
|
||||||
|
_make_detail("m2", 0.8, 12.0),
|
||||||
|
_make_detail("s3", 0.7, 4.0),
|
||||||
|
_make_detail("s4", 0.6, 1.0),
|
||||||
|
_make_detail("m3", 0.5, 8.0),
|
||||||
|
]
|
||||||
|
result = diverse_selection(items, 5)
|
||||||
|
assert len(result) == 5
|
||||||
|
# 最高分的都应该在
|
||||||
|
ids = [d.asset_id for d in result]
|
||||||
|
assert "s1" in ids
|
||||||
|
assert "m1" in ids
|
||||||
|
|
||||||
|
def test_single_bucket(self):
|
||||||
|
items = [
|
||||||
|
_make_detail("s1", 1.0, 2.0),
|
||||||
|
_make_detail("s2", 0.9, 3.0),
|
||||||
|
_make_detail("s3", 0.8, 4.0),
|
||||||
|
]
|
||||||
|
result = diverse_selection(items, 2)
|
||||||
|
assert len(result) == 2
|
||||||
|
assert result[0].asset_id == "s1"
|
||||||
|
assert result[1].asset_id == "s2"
|
||||||
|
|
||||||
|
def test_unknown_duration_fallback(self):
|
||||||
|
# 已知素材不够时用未知时长的补充
|
||||||
|
items = [
|
||||||
|
_make_detail("s1", 1.0, 2.0),
|
||||||
|
_make_detail("u1", 0.95, None),
|
||||||
|
_make_detail("u2", 0.9, None),
|
||||||
|
]
|
||||||
|
result = diverse_selection(items, 3)
|
||||||
|
assert len(result) == 3
|
||||||
|
ids = [d.asset_id for d in result]
|
||||||
|
assert "s1" in ids
|
||||||
|
assert "u1" in ids
|
||||||
|
|
||||||
|
def test_no_duplicates(self):
|
||||||
|
items = [
|
||||||
|
_make_detail("s1", 1.0, 2.0),
|
||||||
|
_make_detail("m1", 0.9, 10.0),
|
||||||
|
]
|
||||||
|
result = diverse_selection(items, 5)
|
||||||
|
ids = [d.asset_id for d in result]
|
||||||
|
assert len(ids) == len(set(ids))
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# filter_candidates
|
||||||
|
# ============================================================
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class FakeAsset:
|
||||||
|
status: str = "ready"
|
||||||
|
mime_type: str = "video/mp4"
|
||||||
|
quality_score: float | None = 50.0
|
||||||
|
|
||||||
|
|
||||||
|
class TestFilterCandidates:
|
||||||
|
def test_ready_video_passes(self):
|
||||||
|
assets = [FakeAsset()]
|
||||||
|
candidates, filtered = filter_candidates(assets)
|
||||||
|
assert len(candidates) == 1
|
||||||
|
assert filtered == 0
|
||||||
|
|
||||||
|
def test_non_ready_filtered(self):
|
||||||
|
assets = [FakeAsset(status="uploading"), FakeAsset(status="processing")]
|
||||||
|
candidates, filtered = filter_candidates(assets)
|
||||||
|
assert len(candidates) == 0
|
||||||
|
assert filtered == 0 # 被状态过滤的不计入质量门槛
|
||||||
|
|
||||||
|
def test_non_video_filtered(self):
|
||||||
|
assets = [FakeAsset(mime_type="image/jpeg"), FakeAsset(mime_type="audio/mp3")]
|
||||||
|
candidates, filtered = filter_candidates(assets)
|
||||||
|
assert len(candidates) == 0
|
||||||
|
|
||||||
|
def test_low_quality_filtered(self):
|
||||||
|
assets = [FakeAsset(quality_score=10.0), FakeAsset(quality_score=80.0)]
|
||||||
|
candidates, filtered = filter_candidates(assets, min_quality_score=30.0)
|
||||||
|
assert len(candidates) == 1
|
||||||
|
assert filtered == 1
|
||||||
|
|
||||||
|
def test_quality_none_passes(self):
|
||||||
|
assets = [FakeAsset(quality_score=None)]
|
||||||
|
candidates, filtered = filter_candidates(assets)
|
||||||
|
assert len(candidates) == 1
|
||||||
|
assert filtered == 0
|
||||||
|
|
||||||
|
def test_exactly_min_quality_passes(self):
|
||||||
|
assets = [FakeAsset(quality_score=30.0)]
|
||||||
|
candidates, filtered = filter_candidates(assets, min_quality_score=30.0)
|
||||||
|
assert len(candidates) == 1
|
||||||
|
|
||||||
|
def test_custom_min_quality(self):
|
||||||
|
assets = [
|
||||||
|
FakeAsset(quality_score=40.0),
|
||||||
|
FakeAsset(quality_score=60.0),
|
||||||
|
FakeAsset(quality_score=80.0),
|
||||||
|
]
|
||||||
|
candidates, filtered = filter_candidates(assets, min_quality_score=50.0)
|
||||||
|
assert len(candidates) == 2
|
||||||
|
assert filtered == 1
|
||||||
|
|
||||||
|
def test_empty_input(self):
|
||||||
|
candidates, filtered = filter_candidates([])
|
||||||
|
assert candidates == []
|
||||||
|
assert filtered == 0
|
||||||
|
|
||||||
|
def test_mime_type_none(self):
|
||||||
|
# None 的 mime_type 也应该被过滤掉(不是video开头)
|
||||||
|
asset = FakeAsset(mime_type="")
|
||||||
|
candidates, _ = filter_candidates([asset])
|
||||||
|
assert len(candidates) == 0
|
||||||
|
|
||||||
|
def test_with_enum_status(self):
|
||||||
|
from enum import Enum
|
||||||
|
|
||||||
|
class StatusEnum(Enum):
|
||||||
|
READY = "ready"
|
||||||
|
UPLOADING = "uploading"
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class EnumAsset:
|
||||||
|
status: StatusEnum = StatusEnum.READY
|
||||||
|
mime_type: str = "video/mp4"
|
||||||
|
quality_score: float = 50.0
|
||||||
|
|
||||||
|
assets = [EnumAsset()]
|
||||||
|
candidates, filtered = filter_candidates(assets)
|
||||||
|
assert len(candidates) == 1
|
||||||
Executable
+470
@@ -0,0 +1,470 @@
|
|||||||
|
"""ChromaKeyConfig 绿幕抠像配置单测.
|
||||||
|
|
||||||
|
纯逻辑模块,覆盖:数据类、from_dict解析、from_preset预设、has_effect、
|
||||||
|
validate校验、normalize_color颜色归一化、colorkey滤镜构建、
|
||||||
|
chromakey滤镜构建、apply_chroma_key_if_needed便捷函数、get_preset_names。
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from packages.domain.chroma_key_config import (
|
||||||
|
CHROMA_KEY_PRESETS,
|
||||||
|
MAX_BLEND,
|
||||||
|
MAX_SIMILARITY,
|
||||||
|
MAX_SPILL_SUPPRESS,
|
||||||
|
MIN_BLEND,
|
||||||
|
MIN_SIMILARITY,
|
||||||
|
MIN_SPILL_SUPPRESS,
|
||||||
|
VALID_PRESETS,
|
||||||
|
ChromaKeyConfig,
|
||||||
|
apply_chroma_key_if_needed,
|
||||||
|
build_chromakey_filter,
|
||||||
|
build_colorkey_filter,
|
||||||
|
get_preset_names,
|
||||||
|
normalize_color,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TestChromaKeyConfigDefaults:
|
||||||
|
def test_default_disabled(self):
|
||||||
|
config = ChromaKeyConfig()
|
||||||
|
assert config.enabled is False
|
||||||
|
assert config.key_color == "#00FF00"
|
||||||
|
assert config.similarity == 0.3
|
||||||
|
assert config.blend == 0.1
|
||||||
|
assert config.spill_suppress == 0.0
|
||||||
|
|
||||||
|
def test_default_has_no_effect(self):
|
||||||
|
config = ChromaKeyConfig()
|
||||||
|
assert config.has_effect() is False
|
||||||
|
|
||||||
|
def test_default_validate_passes(self):
|
||||||
|
config = ChromaKeyConfig()
|
||||||
|
ok, msg = config.validate()
|
||||||
|
assert ok is True
|
||||||
|
assert msg == ""
|
||||||
|
|
||||||
|
|
||||||
|
class TestFromDict:
|
||||||
|
def test_none_returns_disabled(self):
|
||||||
|
config = ChromaKeyConfig.from_dict(None)
|
||||||
|
assert config.enabled is False
|
||||||
|
|
||||||
|
def test_empty_dict_returns_disabled(self):
|
||||||
|
config = ChromaKeyConfig.from_dict({})
|
||||||
|
assert config.enabled is False
|
||||||
|
|
||||||
|
def test_enabled_false_returns_disabled(self):
|
||||||
|
config = ChromaKeyConfig.from_dict({"enabled": False})
|
||||||
|
assert config.enabled is False
|
||||||
|
|
||||||
|
def test_basic_enabled(self):
|
||||||
|
config = ChromaKeyConfig.from_dict({"enabled": True, "key_color": "#FF0000"})
|
||||||
|
assert config.enabled is True
|
||||||
|
assert config.key_color == "#FF0000"
|
||||||
|
|
||||||
|
def test_default_values_when_enabled(self):
|
||||||
|
config = ChromaKeyConfig.from_dict({"enabled": True})
|
||||||
|
assert config.enabled is True
|
||||||
|
assert config.key_color == "#00FF00"
|
||||||
|
assert config.similarity == 0.3
|
||||||
|
assert config.blend == 0.1
|
||||||
|
assert config.spill_suppress == 0.0
|
||||||
|
|
||||||
|
def test_custom_values(self):
|
||||||
|
config = ChromaKeyConfig.from_dict(
|
||||||
|
{
|
||||||
|
"enabled": True,
|
||||||
|
"key_color": "#0000FF",
|
||||||
|
"similarity": 0.5,
|
||||||
|
"blend": 0.2,
|
||||||
|
"spill_suppress": 0.3,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
assert config.key_color == "#0000FF"
|
||||||
|
assert config.similarity == 0.5
|
||||||
|
assert config.blend == 0.2
|
||||||
|
assert config.spill_suppress == 0.3
|
||||||
|
|
||||||
|
def test_similarity_clamped_below_min(self):
|
||||||
|
config = ChromaKeyConfig.from_dict({"enabled": True, "similarity": 0.0})
|
||||||
|
assert config.similarity == MIN_SIMILARITY
|
||||||
|
|
||||||
|
def test_similarity_clamped_above_max(self):
|
||||||
|
config = ChromaKeyConfig.from_dict({"enabled": True, "similarity": 2.0})
|
||||||
|
assert config.similarity == MAX_SIMILARITY
|
||||||
|
|
||||||
|
def test_blend_clamped_below_min(self):
|
||||||
|
config = ChromaKeyConfig.from_dict({"enabled": True, "blend": -1.0})
|
||||||
|
assert config.blend == MIN_BLEND
|
||||||
|
|
||||||
|
def test_blend_clamped_above_max(self):
|
||||||
|
config = ChromaKeyConfig.from_dict({"enabled": True, "blend": 2.0})
|
||||||
|
assert config.blend == MAX_BLEND
|
||||||
|
|
||||||
|
def test_spill_suppress_clamped_below_min(self):
|
||||||
|
config = ChromaKeyConfig.from_dict({"enabled": True, "spill_suppress": -0.5})
|
||||||
|
assert config.spill_suppress == MIN_SPILL_SUPPRESS
|
||||||
|
|
||||||
|
def test_spill_suppress_clamped_above_max(self):
|
||||||
|
config = ChromaKeyConfig.from_dict({"enabled": True, "spill_suppress": 2.0})
|
||||||
|
assert config.spill_suppress == MAX_SPILL_SUPPRESS
|
||||||
|
|
||||||
|
def test_invalid_float_values_use_default(self):
|
||||||
|
config = ChromaKeyConfig.from_dict(
|
||||||
|
{
|
||||||
|
"enabled": True,
|
||||||
|
"similarity": "not_a_number",
|
||||||
|
"blend": None,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
assert config.similarity == 0.3
|
||||||
|
assert config.blend == 0.1
|
||||||
|
|
||||||
|
def test_key_color_stripped(self):
|
||||||
|
config = ChromaKeyConfig.from_dict({"enabled": True, "key_color": " #FF0000 "})
|
||||||
|
assert config.key_color == "#FF0000"
|
||||||
|
|
||||||
|
|
||||||
|
class TestFromPreset:
|
||||||
|
def test_green_screen_preset(self):
|
||||||
|
config = ChromaKeyConfig.from_preset("green_screen")
|
||||||
|
assert config is not None
|
||||||
|
assert config.enabled is True
|
||||||
|
assert config.key_color == "#00FF00"
|
||||||
|
assert config.similarity == 0.3
|
||||||
|
assert config.blend == 0.1
|
||||||
|
assert config.spill_suppress == 0.5
|
||||||
|
|
||||||
|
def test_blue_screen_preset(self):
|
||||||
|
config = ChromaKeyConfig.from_preset("blue_screen")
|
||||||
|
assert config is not None
|
||||||
|
assert config.key_color == "#0000FF"
|
||||||
|
|
||||||
|
def test_red_screen_preset(self):
|
||||||
|
config = ChromaKeyConfig.from_preset("red_screen")
|
||||||
|
assert config is not None
|
||||||
|
assert config.key_color == "#FF0000"
|
||||||
|
assert config.spill_suppress == 0.0
|
||||||
|
|
||||||
|
def test_invalid_preset_returns_none(self):
|
||||||
|
config = ChromaKeyConfig.from_preset("nonexistent")
|
||||||
|
assert config is None
|
||||||
|
|
||||||
|
def test_all_presets_are_valid(self):
|
||||||
|
for name in CHROMA_KEY_PRESETS:
|
||||||
|
config = ChromaKeyConfig.from_preset(name)
|
||||||
|
assert config is not None
|
||||||
|
assert config.enabled is True
|
||||||
|
assert config.has_effect() is True
|
||||||
|
ok, _ = config.validate()
|
||||||
|
assert ok is True
|
||||||
|
|
||||||
|
|
||||||
|
class TestHasEffect:
|
||||||
|
def test_disabled_no_effect(self):
|
||||||
|
config = ChromaKeyConfig(enabled=False)
|
||||||
|
assert config.has_effect() is False
|
||||||
|
|
||||||
|
def test_enabled_but_zero_similarity(self):
|
||||||
|
config = ChromaKeyConfig(enabled=True, similarity=0.0)
|
||||||
|
assert config.has_effect() is False
|
||||||
|
|
||||||
|
def test_enabled_with_similarity_has_effect(self):
|
||||||
|
config = ChromaKeyConfig(enabled=True, similarity=0.01)
|
||||||
|
assert config.has_effect() is True
|
||||||
|
|
||||||
|
def test_full_config_has_effect(self):
|
||||||
|
config = ChromaKeyConfig(
|
||||||
|
enabled=True,
|
||||||
|
key_color="#00FF00",
|
||||||
|
similarity=0.3,
|
||||||
|
blend=0.1,
|
||||||
|
spill_suppress=0.5,
|
||||||
|
)
|
||||||
|
assert config.has_effect() is True
|
||||||
|
|
||||||
|
|
||||||
|
class TestValidate:
|
||||||
|
def test_disabled_valid(self):
|
||||||
|
config = ChromaKeyConfig(enabled=False)
|
||||||
|
ok, msg = config.validate()
|
||||||
|
assert ok is True
|
||||||
|
assert msg == ""
|
||||||
|
|
||||||
|
def test_valid_config(self):
|
||||||
|
config = ChromaKeyConfig(
|
||||||
|
enabled=True,
|
||||||
|
key_color="#00FF00",
|
||||||
|
similarity=0.3,
|
||||||
|
blend=0.1,
|
||||||
|
spill_suppress=0.5,
|
||||||
|
)
|
||||||
|
ok, msg = config.validate()
|
||||||
|
assert ok is True
|
||||||
|
assert msg == ""
|
||||||
|
|
||||||
|
def test_empty_key_color_invalid(self):
|
||||||
|
config = ChromaKeyConfig(enabled=True, key_color="")
|
||||||
|
ok, msg = config.validate()
|
||||||
|
assert ok is False
|
||||||
|
assert "key_color" in msg
|
||||||
|
|
||||||
|
def test_similarity_below_min_invalid(self):
|
||||||
|
config = ChromaKeyConfig(enabled=True, similarity=0.001)
|
||||||
|
ok, msg = config.validate()
|
||||||
|
assert ok is False
|
||||||
|
assert "similarity" in msg
|
||||||
|
|
||||||
|
def test_similarity_above_max_invalid(self):
|
||||||
|
config = ChromaKeyConfig(enabled=True, similarity=1.5)
|
||||||
|
ok, msg = config.validate()
|
||||||
|
assert ok is False
|
||||||
|
assert "similarity" in msg
|
||||||
|
|
||||||
|
def test_blend_below_min_invalid(self):
|
||||||
|
config = ChromaKeyConfig(enabled=True, blend=-0.1)
|
||||||
|
ok, msg = config.validate()
|
||||||
|
assert ok is False
|
||||||
|
assert "blend" in msg
|
||||||
|
|
||||||
|
def test_blend_above_max_invalid(self):
|
||||||
|
config = ChromaKeyConfig(enabled=True, blend=1.5)
|
||||||
|
ok, msg = config.validate()
|
||||||
|
assert ok is False
|
||||||
|
assert "blend" in msg
|
||||||
|
|
||||||
|
def test_spill_suppress_below_min_invalid(self):
|
||||||
|
config = ChromaKeyConfig(enabled=True, spill_suppress=-0.1)
|
||||||
|
ok, msg = config.validate()
|
||||||
|
assert ok is False
|
||||||
|
assert "spill_suppress" in msg
|
||||||
|
|
||||||
|
def test_spill_suppress_above_max_invalid(self):
|
||||||
|
config = ChromaKeyConfig(enabled=True, spill_suppress=1.5)
|
||||||
|
ok, msg = config.validate()
|
||||||
|
assert ok is False
|
||||||
|
assert "spill_suppress" in msg
|
||||||
|
|
||||||
|
def test_boundary_values_valid(self):
|
||||||
|
config = ChromaKeyConfig(
|
||||||
|
enabled=True,
|
||||||
|
key_color="#000",
|
||||||
|
similarity=MIN_SIMILARITY,
|
||||||
|
blend=MIN_BLEND,
|
||||||
|
spill_suppress=MIN_SPILL_SUPPRESS,
|
||||||
|
)
|
||||||
|
ok, _ = config.validate()
|
||||||
|
assert ok is True
|
||||||
|
|
||||||
|
config2 = ChromaKeyConfig(
|
||||||
|
enabled=True,
|
||||||
|
key_color="#fff",
|
||||||
|
similarity=MAX_SIMILARITY,
|
||||||
|
blend=MAX_BLEND,
|
||||||
|
spill_suppress=MAX_SPILL_SUPPRESS,
|
||||||
|
)
|
||||||
|
ok2, _ = config2.validate()
|
||||||
|
assert ok2 is True
|
||||||
|
|
||||||
|
|
||||||
|
class TestNormalizeColor:
|
||||||
|
def test_hex_with_hash(self):
|
||||||
|
assert normalize_color("#FF0000") == "0xFF0000"
|
||||||
|
|
||||||
|
def test_hex_without_hash(self):
|
||||||
|
assert normalize_color("FF0000") == "0xFF0000"
|
||||||
|
|
||||||
|
def test_hex_lowercase(self):
|
||||||
|
assert normalize_color("#ff0000") == "0xFF0000"
|
||||||
|
|
||||||
|
def test_hex_mixed_case(self):
|
||||||
|
assert normalize_color("#aBcDeF") == "0xABCDEF"
|
||||||
|
|
||||||
|
def test_hex_with_alpha(self):
|
||||||
|
assert normalize_color("#FF000080") == "0xFF0000"
|
||||||
|
|
||||||
|
def test_0x_format_passthrough(self):
|
||||||
|
assert normalize_color("0xFF0000") == "0XFF0000"
|
||||||
|
|
||||||
|
def test_0x_lowercase(self):
|
||||||
|
assert normalize_color("0xff0000") == "0XFF0000"
|
||||||
|
|
||||||
|
def test_color_name_passthrough(self):
|
||||||
|
assert normalize_color("green") == "green"
|
||||||
|
|
||||||
|
def test_color_name_blue(self):
|
||||||
|
assert normalize_color("blue") == "blue"
|
||||||
|
|
||||||
|
def test_whitespace_stripped(self):
|
||||||
|
assert normalize_color(" #FF0000 ") == "0xFF0000"
|
||||||
|
|
||||||
|
def test_black_color(self):
|
||||||
|
assert normalize_color("#000000") == "0x000000"
|
||||||
|
|
||||||
|
def test_white_color(self):
|
||||||
|
assert normalize_color("#FFFFFF") == "0xFFFFFF"
|
||||||
|
|
||||||
|
|
||||||
|
class TestBuildColorkeyFilter:
|
||||||
|
def test_disabled_returns_copy(self):
|
||||||
|
config = ChromaKeyConfig(enabled=False)
|
||||||
|
result = build_colorkey_filter(config, "[0:v]", "[out]")
|
||||||
|
assert result == "[0:v]copy[out]"
|
||||||
|
|
||||||
|
def test_zero_similarity_returns_copy(self):
|
||||||
|
config = ChromaKeyConfig(enabled=True, similarity=0.0)
|
||||||
|
result = build_colorkey_filter(config, "[v0]", "[ck]")
|
||||||
|
assert result == "[v0]copy[ck]"
|
||||||
|
|
||||||
|
def test_basic_colorkey(self):
|
||||||
|
config = ChromaKeyConfig(enabled=True, key_color="#00FF00", similarity=0.3, blend=0.1)
|
||||||
|
result = build_colorkey_filter(config, "[0:v]", "[ck]")
|
||||||
|
assert "colorkey=color=0x00FF00:similarity=0.3:blend=0.1" in result
|
||||||
|
assert result.startswith("[0:v]")
|
||||||
|
assert result.endswith("[ck]")
|
||||||
|
|
||||||
|
def test_with_spill_suppress(self):
|
||||||
|
config = ChromaKeyConfig(
|
||||||
|
enabled=True,
|
||||||
|
key_color="#00FF00",
|
||||||
|
similarity=0.3,
|
||||||
|
blend=0.1,
|
||||||
|
spill_suppress=0.5,
|
||||||
|
)
|
||||||
|
result = build_colorkey_filter(config, "[0:v]", "[ck]")
|
||||||
|
assert "colorkey=" in result
|
||||||
|
assert "colorchannelmixer=" in result
|
||||||
|
# Spill suppress reduces green gain
|
||||||
|
assert "gg=" in result
|
||||||
|
assert "rr=" in result
|
||||||
|
assert "bb=" in result
|
||||||
|
|
||||||
|
def test_no_spill_suppress_no_colorchannelmixer(self):
|
||||||
|
config = ChromaKeyConfig(
|
||||||
|
enabled=True,
|
||||||
|
key_color="#00FF00",
|
||||||
|
similarity=0.3,
|
||||||
|
blend=0.1,
|
||||||
|
spill_suppress=0.0,
|
||||||
|
)
|
||||||
|
result = build_colorkey_filter(config, "[0:v]", "[ck]")
|
||||||
|
assert "colorchannelmixer" not in result
|
||||||
|
|
||||||
|
def test_different_labels(self):
|
||||||
|
config = ChromaKeyConfig(enabled=True, key_color="#FF0000", similarity=0.5)
|
||||||
|
result = build_colorkey_filter(config, "[v_in]", "[v_out]")
|
||||||
|
assert result.startswith("[v_in]")
|
||||||
|
assert result.endswith("[v_out]")
|
||||||
|
|
||||||
|
def test_spill_suppress_gain_values(self):
|
||||||
|
config = ChromaKeyConfig(
|
||||||
|
enabled=True,
|
||||||
|
key_color="#00FF00",
|
||||||
|
similarity=0.3,
|
||||||
|
blend=0.1,
|
||||||
|
spill_suppress=1.0,
|
||||||
|
)
|
||||||
|
result = build_colorkey_filter(config, "[0:v]", "[ck]")
|
||||||
|
# At spill=1.0: g_gain = max(0.3, 1.0 - 1.0*0.7) = max(0.3, 0.3) = 0.3
|
||||||
|
assert "gg=0.3" in result
|
||||||
|
|
||||||
|
|
||||||
|
class TestBuildChromakeyFilter:
|
||||||
|
def test_disabled_returns_copy(self):
|
||||||
|
config = ChromaKeyConfig(enabled=False)
|
||||||
|
result = build_chromakey_filter(config, "[0:v]", "[out]")
|
||||||
|
assert result == "[0:v]copy[out]"
|
||||||
|
|
||||||
|
def test_basic_chromakey(self):
|
||||||
|
config = ChromaKeyConfig(enabled=True, key_color="#0000FF", similarity=0.4, blend=0.2)
|
||||||
|
result = build_chromakey_filter(config, "[0:v]", "[ck]")
|
||||||
|
assert "chromakey=color=0x0000FF:similarity=0.4:blend=0.2" in result
|
||||||
|
assert result.startswith("[0:v]")
|
||||||
|
assert result.endswith("[ck]")
|
||||||
|
|
||||||
|
def test_spill_suppress_not_included(self):
|
||||||
|
"""chromakey滤镜不包含spill_suppress(只有colorkey有)."""
|
||||||
|
config = ChromaKeyConfig(
|
||||||
|
enabled=True,
|
||||||
|
key_color="#00FF00",
|
||||||
|
similarity=0.3,
|
||||||
|
blend=0.1,
|
||||||
|
spill_suppress=0.5,
|
||||||
|
)
|
||||||
|
result = build_chromakey_filter(config, "[0:v]", "[ck]")
|
||||||
|
assert "colorchannelmixer" not in result
|
||||||
|
assert "chromakey=" in result
|
||||||
|
|
||||||
|
|
||||||
|
class TestApplyChromaKeyIfNeeded:
|
||||||
|
def test_none_clip_config_returns_none(self):
|
||||||
|
result = apply_chroma_key_if_needed(None, "[0:v]", "[ck]")
|
||||||
|
assert result is None
|
||||||
|
|
||||||
|
def test_no_chroma_key_returns_none(self):
|
||||||
|
result = apply_chroma_key_if_needed({"other": "data"}, "[0:v]", "[ck]")
|
||||||
|
assert result is None
|
||||||
|
|
||||||
|
def test_chroma_key_disabled_returns_none(self):
|
||||||
|
config = {"chroma_key": {"enabled": False}}
|
||||||
|
result = apply_chroma_key_if_needed(config, "[0:v]", "[ck]")
|
||||||
|
assert result is None
|
||||||
|
|
||||||
|
def test_chroma_key_enabled_returns_filter(self):
|
||||||
|
config = {
|
||||||
|
"chroma_key": {
|
||||||
|
"enabled": True,
|
||||||
|
"key_color": "#00FF00",
|
||||||
|
"similarity": 0.3,
|
||||||
|
"blend": 0.1,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
result = apply_chroma_key_if_needed(config, "[0:v]", "[ck]")
|
||||||
|
assert result is not None
|
||||||
|
assert "colorkey=" in result
|
||||||
|
assert result.startswith("[0:v]")
|
||||||
|
assert result.endswith("[ck]")
|
||||||
|
|
||||||
|
def test_invalid_config_returns_none(self):
|
||||||
|
"""配置异常时应该返回None而不是抛异常."""
|
||||||
|
config = {"chroma_key": "invalid_data"}
|
||||||
|
result = apply_chroma_key_if_needed(config, "[0:v]", "[ck]")
|
||||||
|
assert result is None
|
||||||
|
|
||||||
|
|
||||||
|
class TestGetPresetNames:
|
||||||
|
def test_returns_sorted_list(self):
|
||||||
|
names = get_preset_names()
|
||||||
|
assert isinstance(names, list)
|
||||||
|
assert names == sorted(names)
|
||||||
|
|
||||||
|
def test_contains_known_presets(self):
|
||||||
|
names = get_preset_names()
|
||||||
|
assert "green_screen" in names
|
||||||
|
assert "blue_screen" in names
|
||||||
|
assert "red_screen" in names
|
||||||
|
|
||||||
|
def test_count_matches_presets_dict(self):
|
||||||
|
names = get_preset_names()
|
||||||
|
assert len(names) == len(CHROMA_KEY_PRESETS)
|
||||||
|
|
||||||
|
|
||||||
|
class TestPresetsAndConstants:
|
||||||
|
def test_valid_presets_equals_preset_keys(self):
|
||||||
|
assert VALID_PRESETS == set(CHROMA_KEY_PRESETS.keys())
|
||||||
|
|
||||||
|
def test_each_preset_has_required_keys(self):
|
||||||
|
for name, preset in CHROMA_KEY_PRESETS.items():
|
||||||
|
assert "key_color" in preset, f"{name} missing key_color"
|
||||||
|
assert "similarity" in preset, f"{name} missing similarity"
|
||||||
|
assert "blend" in preset, f"{name} missing blend"
|
||||||
|
assert "spill_suppress" in preset, f"{name} missing spill_suppress"
|
||||||
|
|
||||||
|
def test_min_less_than_max(self):
|
||||||
|
assert MIN_SIMILARITY < MAX_SIMILARITY
|
||||||
|
assert MIN_BLEND <= MAX_BLEND
|
||||||
|
assert MIN_SPILL_SUPPRESS <= MAX_SPILL_SUPPRESS
|
||||||
|
|
||||||
|
def test_min_similarity_positive(self):
|
||||||
|
assert MIN_SIMILARITY > 0
|
||||||
@@ -0,0 +1,184 @@
|
|||||||
|
"""classification 单测.
|
||||||
|
|
||||||
|
domain 层素材分类模块纯逻辑,0 外部依赖。
|
||||||
|
覆盖:4个枚举 + ClassificationJob 工厂/校验。
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from packages.domain.classification import (
|
||||||
|
AssetClassification,
|
||||||
|
AssetLibraryKind,
|
||||||
|
ClassificationJob,
|
||||||
|
ClassificationJobStatus,
|
||||||
|
IngestJobStatus,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TestAssetLibraryKind:
|
||||||
|
"""AssetLibraryKind 枚举测试."""
|
||||||
|
|
||||||
|
def test_three_values(self):
|
||||||
|
"""视频/配音/图片三类."""
|
||||||
|
assert len(AssetLibraryKind) == 3
|
||||||
|
|
||||||
|
def test_video(self):
|
||||||
|
assert AssetLibraryKind.VIDEO == "video"
|
||||||
|
|
||||||
|
def test_voice(self):
|
||||||
|
assert AssetLibraryKind.VOICE == "voice"
|
||||||
|
|
||||||
|
def test_image(self):
|
||||||
|
assert AssetLibraryKind.IMAGE == "image"
|
||||||
|
|
||||||
|
def test_str_compatible(self):
|
||||||
|
"""StrEnum 字符串兼容."""
|
||||||
|
assert AssetLibraryKind.VIDEO == "video"
|
||||||
|
|
||||||
|
|
||||||
|
class TestIngestJobStatus:
|
||||||
|
"""IngestJobStatus 枚举测试."""
|
||||||
|
|
||||||
|
def test_four_statuses(self):
|
||||||
|
assert len(IngestJobStatus) == 4
|
||||||
|
|
||||||
|
def test_pending(self):
|
||||||
|
assert IngestJobStatus.PENDING == "pending"
|
||||||
|
|
||||||
|
def test_processing(self):
|
||||||
|
assert IngestJobStatus.PROCESSING == "processing"
|
||||||
|
|
||||||
|
def test_completed(self):
|
||||||
|
assert IngestJobStatus.COMPLETED == "completed"
|
||||||
|
|
||||||
|
def test_failed(self):
|
||||||
|
assert IngestJobStatus.FAILED == "failed"
|
||||||
|
|
||||||
|
|
||||||
|
class TestClassificationJobStatus:
|
||||||
|
"""ClassificationJobStatus 枚举测试."""
|
||||||
|
|
||||||
|
def test_four_statuses(self):
|
||||||
|
assert len(ClassificationJobStatus) == 4
|
||||||
|
|
||||||
|
def test_pending(self):
|
||||||
|
assert ClassificationJobStatus.PENDING == "pending"
|
||||||
|
|
||||||
|
def test_processing(self):
|
||||||
|
assert ClassificationJobStatus.PROCESSING == "processing"
|
||||||
|
|
||||||
|
def test_completed(self):
|
||||||
|
assert ClassificationJobStatus.COMPLETED == "completed"
|
||||||
|
|
||||||
|
def test_failed(self):
|
||||||
|
assert ClassificationJobStatus.FAILED == "failed"
|
||||||
|
|
||||||
|
def test_same_values_as_ingest(self):
|
||||||
|
"""两种任务状态值相同."""
|
||||||
|
assert set(ClassificationJobStatus) == set(IngestJobStatus)
|
||||||
|
|
||||||
|
|
||||||
|
class TestAssetClassification:
|
||||||
|
"""AssetClassification 枚举测试."""
|
||||||
|
|
||||||
|
def test_nine_categories(self):
|
||||||
|
"""9个分类."""
|
||||||
|
assert len(AssetClassification) == 9
|
||||||
|
|
||||||
|
def test_scenic(self):
|
||||||
|
assert AssetClassification.SCENIC == "scenic"
|
||||||
|
|
||||||
|
def test_product(self):
|
||||||
|
assert AssetClassification.PRODUCT == "product"
|
||||||
|
|
||||||
|
def test_person(self):
|
||||||
|
assert AssetClassification.PERSON == "person"
|
||||||
|
|
||||||
|
def test_animal(self):
|
||||||
|
assert AssetClassification.ANIMAL == "animal"
|
||||||
|
|
||||||
|
def test_food(self):
|
||||||
|
assert AssetClassification.FOOD == "food"
|
||||||
|
|
||||||
|
def test_tech(self):
|
||||||
|
assert AssetClassification.TECH == "tech"
|
||||||
|
|
||||||
|
def test_sport(self):
|
||||||
|
assert AssetClassification.SPORT == "sport"
|
||||||
|
|
||||||
|
def test_music(self):
|
||||||
|
assert AssetClassification.MUSIC == "music"
|
||||||
|
|
||||||
|
def test_other(self):
|
||||||
|
assert AssetClassification.OTHER == "other"
|
||||||
|
|
||||||
|
def test_all_values_unique(self):
|
||||||
|
"""所有分类值唯一."""
|
||||||
|
values = [c.value for c in AssetClassification]
|
||||||
|
assert len(values) == len(set(values))
|
||||||
|
|
||||||
|
|
||||||
|
class TestClassificationJobCreate:
|
||||||
|
"""ClassificationJob.create 测试."""
|
||||||
|
|
||||||
|
def test_create_valid(self):
|
||||||
|
"""正常创建."""
|
||||||
|
job = ClassificationJob.create(project_id="proj1", asset_id="asset1")
|
||||||
|
assert job.project_id == "proj1"
|
||||||
|
assert job.asset_id == "asset1"
|
||||||
|
assert job.status == ClassificationJobStatus.PENDING
|
||||||
|
assert job.classification == ""
|
||||||
|
assert job.confidence == 0.0
|
||||||
|
assert job.error_message == ""
|
||||||
|
assert isinstance(job.id, str)
|
||||||
|
assert len(job.id) > 0
|
||||||
|
|
||||||
|
def test_create_strips(self):
|
||||||
|
"""project_id 和 asset_id 会 strip."""
|
||||||
|
job = ClassificationJob.create(project_id=" proj1 ", asset_id=" asset1 ")
|
||||||
|
assert job.project_id == "proj1"
|
||||||
|
assert job.asset_id == "asset1"
|
||||||
|
|
||||||
|
def test_create_empty_project_id(self):
|
||||||
|
"""空 project_id 无效."""
|
||||||
|
try:
|
||||||
|
ClassificationJob.create(project_id="", asset_id="a1")
|
||||||
|
assert False
|
||||||
|
except ValueError as e:
|
||||||
|
assert "project_id" in str(e)
|
||||||
|
|
||||||
|
def test_create_whitespace_project_id(self):
|
||||||
|
"""纯空白 project_id 无效."""
|
||||||
|
try:
|
||||||
|
ClassificationJob.create(project_id=" ", asset_id="a1")
|
||||||
|
assert False
|
||||||
|
except ValueError as e:
|
||||||
|
assert "project_id" in str(e)
|
||||||
|
|
||||||
|
def test_create_empty_asset_id(self):
|
||||||
|
"""空 asset_id 无效."""
|
||||||
|
try:
|
||||||
|
ClassificationJob.create(project_id="p1", asset_id="")
|
||||||
|
assert False
|
||||||
|
except ValueError as e:
|
||||||
|
assert "asset_id" in str(e)
|
||||||
|
|
||||||
|
def test_create_whitespace_asset_id(self):
|
||||||
|
"""纯空白 asset_id 无效."""
|
||||||
|
try:
|
||||||
|
ClassificationJob.create(project_id="p1", asset_id=" ")
|
||||||
|
assert False
|
||||||
|
except ValueError as e:
|
||||||
|
assert "asset_id" in str(e)
|
||||||
|
|
||||||
|
def test_create_unique_id(self):
|
||||||
|
"""不同 job id 不同."""
|
||||||
|
j1 = ClassificationJob.create("p", "a")
|
||||||
|
j2 = ClassificationJob.create("p", "a")
|
||||||
|
assert j1.id != j2.id
|
||||||
|
|
||||||
|
def test_create_has_timestamps(self):
|
||||||
|
"""有创建和更新时间."""
|
||||||
|
job = ClassificationJob.create("p", "a")
|
||||||
|
assert job.created_at is not None
|
||||||
|
assert job.updated_at is not None
|
||||||
Executable
+474
@@ -0,0 +1,474 @@
|
|||||||
|
"""ColorGradeConfig 色彩调色配置单测.
|
||||||
|
|
||||||
|
纯逻辑模块,覆盖:数据类、resolve_params参数解析、has_effect效果判断、
|
||||||
|
from_dict字典解析、validate校验、预设查询函数、clamp_param钳制。
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from packages.domain.color_grade_config import (
|
||||||
|
ALL_PARAM_KEYS,
|
||||||
|
DEFAULT_PARAMS,
|
||||||
|
PARAM_RANGES,
|
||||||
|
PRESET_DISPLAY_NAMES,
|
||||||
|
PRESET_PARAMS,
|
||||||
|
VALID_PRESETS,
|
||||||
|
ColorGradeConfig,
|
||||||
|
clamp_param,
|
||||||
|
get_preset_names,
|
||||||
|
get_preset_params,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TestColorGradeConfigDefaults:
|
||||||
|
def test_default_disabled(self):
|
||||||
|
config = ColorGradeConfig()
|
||||||
|
assert config.enabled is False
|
||||||
|
assert config.preset == ""
|
||||||
|
assert config.brightness is None
|
||||||
|
assert config.contrast is None
|
||||||
|
assert config.saturation is None
|
||||||
|
assert config.temperature is None
|
||||||
|
assert config.hue is None
|
||||||
|
|
||||||
|
def test_default_resolve_returns_defaults(self):
|
||||||
|
config = ColorGradeConfig()
|
||||||
|
params = config.resolve_params()
|
||||||
|
for key in ALL_PARAM_KEYS:
|
||||||
|
assert params[key] == DEFAULT_PARAMS[key]
|
||||||
|
|
||||||
|
def test_default_has_no_effect(self):
|
||||||
|
config = ColorGradeConfig()
|
||||||
|
assert config.has_effect() is False
|
||||||
|
|
||||||
|
def test_default_validate_passes(self):
|
||||||
|
config = ColorGradeConfig()
|
||||||
|
ok, msg = config.validate()
|
||||||
|
assert ok is True
|
||||||
|
assert msg == ""
|
||||||
|
|
||||||
|
|
||||||
|
class TestResolveParams:
|
||||||
|
def test_disabled_still_resolves(self):
|
||||||
|
"""禁用状态下仍能解析参数."""
|
||||||
|
config = ColorGradeConfig(enabled=False, brightness=50.0)
|
||||||
|
params = config.resolve_params()
|
||||||
|
assert params["brightness"] == 50.0
|
||||||
|
|
||||||
|
def test_preset_fresh(self):
|
||||||
|
config = ColorGradeConfig(enabled=True, preset="fresh")
|
||||||
|
params = config.resolve_params()
|
||||||
|
assert params["brightness"] == 8
|
||||||
|
assert params["contrast"] == 10
|
||||||
|
assert params["saturation"] == 120
|
||||||
|
assert params["temperature"] == -8
|
||||||
|
assert params["hue"] == 5
|
||||||
|
|
||||||
|
def test_preset_black_white(self):
|
||||||
|
config = ColorGradeConfig(enabled=True, preset="black_white")
|
||||||
|
params = config.resolve_params()
|
||||||
|
assert params["saturation"] == 0
|
||||||
|
assert params["contrast"] == 15
|
||||||
|
|
||||||
|
def test_preset_warm(self):
|
||||||
|
config = ColorGradeConfig(enabled=True, preset="warm")
|
||||||
|
params = config.resolve_params()
|
||||||
|
assert params["temperature"] == 30
|
||||||
|
|
||||||
|
def test_preset_cool(self):
|
||||||
|
config = ColorGradeConfig(enabled=True, preset="cool")
|
||||||
|
params = config.resolve_params()
|
||||||
|
assert params["temperature"] == -25
|
||||||
|
|
||||||
|
def test_invalid_preset_uses_defaults(self):
|
||||||
|
config = ColorGradeConfig(enabled=True, preset="nonexistent")
|
||||||
|
params = config.resolve_params()
|
||||||
|
for key in ALL_PARAM_KEYS:
|
||||||
|
assert params[key] == DEFAULT_PARAMS[key]
|
||||||
|
|
||||||
|
def test_custom_override_preset(self):
|
||||||
|
config = ColorGradeConfig(
|
||||||
|
enabled=True,
|
||||||
|
preset="fresh",
|
||||||
|
brightness=50.0,
|
||||||
|
)
|
||||||
|
params = config.resolve_params()
|
||||||
|
# custom覆盖了预设
|
||||||
|
assert params["brightness"] == 50.0
|
||||||
|
# 其他参数仍用预设值
|
||||||
|
assert params["contrast"] == 10
|
||||||
|
assert params["saturation"] == 120
|
||||||
|
|
||||||
|
def test_multiple_custom_overrides(self):
|
||||||
|
config = ColorGradeConfig(
|
||||||
|
enabled=True,
|
||||||
|
preset="vintage",
|
||||||
|
brightness=20.0,
|
||||||
|
saturation=150.0,
|
||||||
|
hue=10.0,
|
||||||
|
)
|
||||||
|
params = config.resolve_params()
|
||||||
|
assert params["brightness"] == 20.0
|
||||||
|
assert params["saturation"] == 150.0
|
||||||
|
assert params["hue"] == 10.0
|
||||||
|
# 未覆盖的保留预设值
|
||||||
|
assert params["contrast"] == 5
|
||||||
|
assert params["temperature"] == 25
|
||||||
|
|
||||||
|
def test_custom_without_preset(self):
|
||||||
|
config = ColorGradeConfig(
|
||||||
|
enabled=True,
|
||||||
|
preset="",
|
||||||
|
brightness=30.0,
|
||||||
|
contrast=-20.0,
|
||||||
|
)
|
||||||
|
params = config.resolve_params()
|
||||||
|
assert params["brightness"] == 30.0
|
||||||
|
assert params["contrast"] == -20.0
|
||||||
|
# 未设置的用默认值
|
||||||
|
assert params["saturation"] == 100.0
|
||||||
|
assert params["temperature"] == 0.0
|
||||||
|
assert params["hue"] == 0.0
|
||||||
|
|
||||||
|
def test_clamping_brightness_above_max(self):
|
||||||
|
config = ColorGradeConfig(enabled=True, brightness=200.0)
|
||||||
|
params = config.resolve_params()
|
||||||
|
assert params["brightness"] == 100.0
|
||||||
|
|
||||||
|
def test_clamping_brightness_below_min(self):
|
||||||
|
config = ColorGradeConfig(enabled=True, brightness=-200.0)
|
||||||
|
params = config.resolve_params()
|
||||||
|
assert params["brightness"] == -100.0
|
||||||
|
|
||||||
|
def test_clamping_saturation_below_zero(self):
|
||||||
|
config = ColorGradeConfig(enabled=True, saturation=-10.0)
|
||||||
|
params = config.resolve_params()
|
||||||
|
assert params["saturation"] == 0.0
|
||||||
|
|
||||||
|
def test_clamping_saturation_above_max(self):
|
||||||
|
config = ColorGradeConfig(enabled=True, saturation=300.0)
|
||||||
|
params = config.resolve_params()
|
||||||
|
assert params["saturation"] == 200.0
|
||||||
|
|
||||||
|
def test_clamping_hue_above_max(self):
|
||||||
|
config = ColorGradeConfig(enabled=True, hue=200.0)
|
||||||
|
params = config.resolve_params()
|
||||||
|
assert params["hue"] == 180.0
|
||||||
|
|
||||||
|
def test_clamping_hue_below_min(self):
|
||||||
|
config = ColorGradeConfig(enabled=True, hue=-200.0)
|
||||||
|
params = config.resolve_params()
|
||||||
|
assert params["hue"] == -180.0
|
||||||
|
|
||||||
|
def test_clamping_preset_plus_custom(self):
|
||||||
|
"""预设值+自定义值超出范围时仍会钳制."""
|
||||||
|
config = ColorGradeConfig(
|
||||||
|
enabled=True,
|
||||||
|
preset="fresh",
|
||||||
|
saturation=250.0, # 超出200上限
|
||||||
|
)
|
||||||
|
params = config.resolve_params()
|
||||||
|
assert params["saturation"] == 200.0
|
||||||
|
|
||||||
|
def test_returns_new_dict_each_time(self):
|
||||||
|
config = ColorGradeConfig(enabled=True, brightness=10.0)
|
||||||
|
p1 = config.resolve_params()
|
||||||
|
p2 = config.resolve_params()
|
||||||
|
assert p1 is not p2
|
||||||
|
p1["brightness"] = 999
|
||||||
|
assert p2["brightness"] == 10.0
|
||||||
|
|
||||||
|
|
||||||
|
class TestHasEffect:
|
||||||
|
def test_default_no_effect(self):
|
||||||
|
config = ColorGradeConfig()
|
||||||
|
assert config.has_effect() is False
|
||||||
|
|
||||||
|
def test_enabled_but_all_defaults(self):
|
||||||
|
config = ColorGradeConfig(
|
||||||
|
enabled=True,
|
||||||
|
brightness=0.0,
|
||||||
|
contrast=0.0,
|
||||||
|
saturation=100.0,
|
||||||
|
temperature=0.0,
|
||||||
|
hue=0.0,
|
||||||
|
)
|
||||||
|
assert config.has_effect() is False
|
||||||
|
|
||||||
|
def test_brightness_change_has_effect(self):
|
||||||
|
config = ColorGradeConfig(enabled=True, brightness=1.0)
|
||||||
|
assert config.has_effect() is True
|
||||||
|
|
||||||
|
def test_contrast_change_has_effect(self):
|
||||||
|
config = ColorGradeConfig(enabled=True, contrast=1.0)
|
||||||
|
assert config.has_effect() is True
|
||||||
|
|
||||||
|
def test_saturation_change_has_effect(self):
|
||||||
|
config = ColorGradeConfig(enabled=True, saturation=99.0)
|
||||||
|
assert config.has_effect() is True
|
||||||
|
|
||||||
|
def test_temperature_change_has_effect(self):
|
||||||
|
config = ColorGradeConfig(enabled=True, temperature=1.0)
|
||||||
|
assert config.has_effect() is True
|
||||||
|
|
||||||
|
def test_hue_change_has_effect(self):
|
||||||
|
config = ColorGradeConfig(enabled=True, hue=1.0)
|
||||||
|
assert config.has_effect() is True
|
||||||
|
|
||||||
|
def test_all_presets_have_effect(self):
|
||||||
|
for preset in VALID_PRESETS:
|
||||||
|
config = ColorGradeConfig(enabled=True, preset=preset)
|
||||||
|
assert config.has_effect() is True, f"preset {preset} should have effect"
|
||||||
|
|
||||||
|
def test_very_small_change_no_effect(self):
|
||||||
|
"""小于0.001的浮点误差视为无效果."""
|
||||||
|
config = ColorGradeConfig(enabled=True, brightness=0.0001)
|
||||||
|
assert config.has_effect() is False
|
||||||
|
|
||||||
|
|
||||||
|
class TestFromDict:
|
||||||
|
def test_none_returns_disabled(self):
|
||||||
|
config = ColorGradeConfig.from_dict(None)
|
||||||
|
assert config.enabled is False
|
||||||
|
|
||||||
|
def test_empty_dict_returns_disabled(self):
|
||||||
|
config = ColorGradeConfig.from_dict({})
|
||||||
|
assert config.enabled is False
|
||||||
|
|
||||||
|
def test_enabled_false_returns_disabled(self):
|
||||||
|
config = ColorGradeConfig.from_dict({"enabled": False})
|
||||||
|
assert config.enabled is False
|
||||||
|
|
||||||
|
def test_basic_enabled(self):
|
||||||
|
config = ColorGradeConfig.from_dict({"enabled": True})
|
||||||
|
assert config.enabled is True
|
||||||
|
assert config.preset == ""
|
||||||
|
assert config.brightness is None
|
||||||
|
|
||||||
|
def test_with_preset(self):
|
||||||
|
config = ColorGradeConfig.from_dict({"enabled": True, "preset": "warm"})
|
||||||
|
assert config.enabled is True
|
||||||
|
assert config.preset == "warm"
|
||||||
|
|
||||||
|
def test_invalid_preset_ignored(self):
|
||||||
|
config = ColorGradeConfig.from_dict({"enabled": True, "preset": "invalid"})
|
||||||
|
assert config.enabled is True
|
||||||
|
assert config.preset == ""
|
||||||
|
|
||||||
|
def test_with_custom_params(self):
|
||||||
|
config = ColorGradeConfig.from_dict(
|
||||||
|
{
|
||||||
|
"enabled": True,
|
||||||
|
"brightness": 50,
|
||||||
|
"contrast": -10.5,
|
||||||
|
"saturation": 150.0,
|
||||||
|
"temperature": 20,
|
||||||
|
"hue": -15.5,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
assert config.brightness == 50.0
|
||||||
|
assert config.contrast == -10.5
|
||||||
|
assert config.saturation == 150.0
|
||||||
|
assert config.temperature == 20.0
|
||||||
|
assert config.hue == -15.5
|
||||||
|
|
||||||
|
def test_invalid_float_values_return_none(self):
|
||||||
|
config = ColorGradeConfig.from_dict(
|
||||||
|
{
|
||||||
|
"enabled": True,
|
||||||
|
"brightness": "not_a_number",
|
||||||
|
"contrast": None,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
assert config.brightness is None
|
||||||
|
assert config.contrast is None
|
||||||
|
|
||||||
|
def test_int_values_work(self):
|
||||||
|
config = ColorGradeConfig.from_dict({"enabled": True, "brightness": 10})
|
||||||
|
assert config.brightness == 10.0
|
||||||
|
|
||||||
|
def test_string_float_values_work(self):
|
||||||
|
config = ColorGradeConfig.from_dict({"enabled": True, "brightness": "15.5"})
|
||||||
|
assert config.brightness == 15.5
|
||||||
|
|
||||||
|
def test_partial_custom_params(self):
|
||||||
|
config = ColorGradeConfig.from_dict(
|
||||||
|
{
|
||||||
|
"enabled": True,
|
||||||
|
"preset": "cinema",
|
||||||
|
"brightness": 5.0,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
assert config.preset == "cinema"
|
||||||
|
assert config.brightness == 5.0
|
||||||
|
assert config.contrast is None
|
||||||
|
|
||||||
|
|
||||||
|
class TestValidate:
|
||||||
|
def test_disabled_valid(self):
|
||||||
|
config = ColorGradeConfig(enabled=False)
|
||||||
|
ok, msg = config.validate()
|
||||||
|
assert ok is True
|
||||||
|
assert msg == ""
|
||||||
|
|
||||||
|
def test_valid_config(self):
|
||||||
|
config = ColorGradeConfig(
|
||||||
|
enabled=True,
|
||||||
|
preset="fresh",
|
||||||
|
brightness=50.0,
|
||||||
|
)
|
||||||
|
ok, msg = config.validate()
|
||||||
|
assert ok is True
|
||||||
|
|
||||||
|
def test_invalid_preset(self):
|
||||||
|
config = ColorGradeConfig(enabled=True, preset="invalid")
|
||||||
|
ok, msg = config.validate()
|
||||||
|
assert ok is False
|
||||||
|
assert "预设" in msg
|
||||||
|
|
||||||
|
def test_brightness_above_max_invalid(self):
|
||||||
|
config = ColorGradeConfig(enabled=True, brightness=150.0)
|
||||||
|
ok, msg = config.validate()
|
||||||
|
assert ok is False
|
||||||
|
assert "brightness" in msg
|
||||||
|
|
||||||
|
def test_brightness_below_min_invalid(self):
|
||||||
|
config = ColorGradeConfig(enabled=True, brightness=-150.0)
|
||||||
|
ok, msg = config.validate()
|
||||||
|
assert ok is False
|
||||||
|
assert "brightness" in msg
|
||||||
|
|
||||||
|
def test_saturation_below_zero_invalid(self):
|
||||||
|
config = ColorGradeConfig(enabled=True, saturation=-10.0)
|
||||||
|
ok, msg = config.validate()
|
||||||
|
assert ok is False
|
||||||
|
assert "saturation" in msg
|
||||||
|
|
||||||
|
def test_saturation_above_max_invalid(self):
|
||||||
|
config = ColorGradeConfig(enabled=True, saturation=250.0)
|
||||||
|
ok, msg = config.validate()
|
||||||
|
assert ok is False
|
||||||
|
assert "saturation" in msg
|
||||||
|
|
||||||
|
def test_hue_above_max_invalid(self):
|
||||||
|
config = ColorGradeConfig(enabled=True, hue=200.0)
|
||||||
|
ok, msg = config.validate()
|
||||||
|
assert ok is False
|
||||||
|
assert "hue" in msg
|
||||||
|
|
||||||
|
def test_hue_below_min_invalid(self):
|
||||||
|
config = ColorGradeConfig(enabled=True, hue=-200.0)
|
||||||
|
ok, msg = config.validate()
|
||||||
|
assert ok is False
|
||||||
|
assert "hue" in msg
|
||||||
|
|
||||||
|
def test_boundary_values_valid(self):
|
||||||
|
config = ColorGradeConfig(
|
||||||
|
enabled=True,
|
||||||
|
brightness=-100.0,
|
||||||
|
contrast=100.0,
|
||||||
|
saturation=0.0,
|
||||||
|
temperature=-100.0,
|
||||||
|
hue=-180.0,
|
||||||
|
)
|
||||||
|
ok, _ = config.validate()
|
||||||
|
assert ok is True
|
||||||
|
|
||||||
|
def test_none_values_valid(self):
|
||||||
|
"""None值不参与校验(视为未设置)."""
|
||||||
|
config = ColorGradeConfig(enabled=True)
|
||||||
|
ok, _ = config.validate()
|
||||||
|
assert ok is True
|
||||||
|
|
||||||
|
|
||||||
|
class TestGetPresetNames:
|
||||||
|
def test_returns_sorted_list(self):
|
||||||
|
names = get_preset_names()
|
||||||
|
assert isinstance(names, list)
|
||||||
|
preset_keys = [n[0] for n in names]
|
||||||
|
assert preset_keys == sorted(preset_keys)
|
||||||
|
|
||||||
|
def test_count_matches_valid_presets(self):
|
||||||
|
names = get_preset_names()
|
||||||
|
assert len(names) == len(VALID_PRESETS)
|
||||||
|
|
||||||
|
def test_each_entry_has_name_and_display(self):
|
||||||
|
names = get_preset_names()
|
||||||
|
for name, display in names:
|
||||||
|
assert name in VALID_PRESETS
|
||||||
|
assert display == PRESET_DISPLAY_NAMES[name]
|
||||||
|
assert len(display) > 0
|
||||||
|
|
||||||
|
|
||||||
|
class TestGetPresetParams:
|
||||||
|
def test_existing_preset(self):
|
||||||
|
params = get_preset_params("fresh")
|
||||||
|
assert params is not None
|
||||||
|
assert params["brightness"] == 8
|
||||||
|
|
||||||
|
def test_nonexistent_preset(self):
|
||||||
|
params = get_preset_params("nonexistent")
|
||||||
|
assert params is None
|
||||||
|
|
||||||
|
def test_all_presets_have_all_params(self):
|
||||||
|
for preset in VALID_PRESETS:
|
||||||
|
params = get_preset_params(preset)
|
||||||
|
assert params is not None
|
||||||
|
for key in ALL_PARAM_KEYS:
|
||||||
|
assert key in params
|
||||||
|
|
||||||
|
def test_returns_dict_with_correct_values(self):
|
||||||
|
"""返回的字典包含所有预期参数."""
|
||||||
|
params = get_preset_params("warm")
|
||||||
|
assert params["brightness"] == 5
|
||||||
|
assert params["temperature"] == 30
|
||||||
|
assert len(params) == 5
|
||||||
|
|
||||||
|
|
||||||
|
class TestClampParam:
|
||||||
|
def test_brightness_within_range(self):
|
||||||
|
assert clamp_param("brightness", 50.0) == 50.0
|
||||||
|
|
||||||
|
def test_brightness_above_max(self):
|
||||||
|
assert clamp_param("brightness", 200.0) == 100.0
|
||||||
|
|
||||||
|
def test_brightness_below_min(self):
|
||||||
|
assert clamp_param("brightness", -200.0) == -100.0
|
||||||
|
|
||||||
|
def test_saturation_within_range(self):
|
||||||
|
assert clamp_param("saturation", 100.0) == 100.0
|
||||||
|
|
||||||
|
def test_saturation_at_boundary(self):
|
||||||
|
assert clamp_param("saturation", 0.0) == 0.0
|
||||||
|
assert clamp_param("saturation", 200.0) == 200.0
|
||||||
|
|
||||||
|
def test_hue_within_range(self):
|
||||||
|
assert clamp_param("hue", 90.0) == 90.0
|
||||||
|
|
||||||
|
def test_hue_above_max(self):
|
||||||
|
assert clamp_param("hue", 200.0) == 180.0
|
||||||
|
|
||||||
|
def test_unknown_param_passthrough(self):
|
||||||
|
assert clamp_param("unknown", 999.0) == 999.0
|
||||||
|
|
||||||
|
|
||||||
|
class TestConstants:
|
||||||
|
def test_all_presets_have_params(self):
|
||||||
|
for preset in VALID_PRESETS:
|
||||||
|
assert preset in PRESET_PARAMS
|
||||||
|
|
||||||
|
def test_all_presets_have_display_names(self):
|
||||||
|
for preset in VALID_PRESETS:
|
||||||
|
assert preset in PRESET_DISPLAY_NAMES
|
||||||
|
|
||||||
|
def test_param_ranges_has_all_keys(self):
|
||||||
|
for key in ALL_PARAM_KEYS:
|
||||||
|
assert key in PARAM_RANGES
|
||||||
|
|
||||||
|
def test_default_params_has_all_keys(self):
|
||||||
|
for key in ALL_PARAM_KEYS:
|
||||||
|
assert key in DEFAULT_PARAMS
|
||||||
|
|
||||||
|
def test_param_ranges_min_less_than_max(self):
|
||||||
|
for key, (min_val, max_val) in PARAM_RANGES.items():
|
||||||
|
assert min_val < max_val, f"{key}: min ({min_val}) should be < max ({max_val})"
|
||||||
@@ -0,0 +1,421 @@
|
|||||||
|
"""domain层小模块批量单测.
|
||||||
|
|
||||||
|
覆盖:recipe / tag / editing_mode / voice_library / title_library / template / edit_template
|
||||||
|
共 7 个模块,纯逻辑 0 外部依赖。
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from packages.domain.edit_template import EditTemplate, EditTemplateStatus
|
||||||
|
from packages.domain.editing_mode import EditingMode
|
||||||
|
from packages.domain.recipe import Recipe, RecipeItem
|
||||||
|
from packages.domain.tag import Tag
|
||||||
|
from packages.domain.template import Template, TemplateCategory, TemplateSegment
|
||||||
|
from packages.domain.title_library import TitleLibraryItem
|
||||||
|
from packages.domain.voice_library import VoiceLibraryItem
|
||||||
|
|
||||||
|
|
||||||
|
class TestEditingMode:
|
||||||
|
"""EditingMode 枚举测试."""
|
||||||
|
|
||||||
|
def test_four_modes(self):
|
||||||
|
"""四种剪辑模式."""
|
||||||
|
assert len(EditingMode) == 4
|
||||||
|
|
||||||
|
def test_one_take(self):
|
||||||
|
assert EditingMode.ONE_TAKE == "one_take"
|
||||||
|
|
||||||
|
def test_pip(self):
|
||||||
|
assert EditingMode.PIP == "pip"
|
||||||
|
|
||||||
|
def test_voice_over(self):
|
||||||
|
assert EditingMode.VOICE_OVER == "voice_over"
|
||||||
|
|
||||||
|
def test_voice_pip(self):
|
||||||
|
assert EditingMode.VOICE_PIP == "voice_pip"
|
||||||
|
|
||||||
|
def test_all_values_unique(self):
|
||||||
|
values = [m.value for m in EditingMode]
|
||||||
|
assert len(values) == len(set(values))
|
||||||
|
|
||||||
|
|
||||||
|
class TestTag:
|
||||||
|
"""Tag 测试."""
|
||||||
|
|
||||||
|
def test_create_valid(self):
|
||||||
|
"""正常创建."""
|
||||||
|
tag = Tag.create(user_id="u1", name=" 搞笑 ")
|
||||||
|
assert tag.user_id == "u1"
|
||||||
|
assert tag.name == "搞笑"
|
||||||
|
assert isinstance(tag.id, str)
|
||||||
|
assert len(tag.id) > 0
|
||||||
|
assert tag.created_at is not None
|
||||||
|
|
||||||
|
def test_create_empty_name(self):
|
||||||
|
"""空名称无效."""
|
||||||
|
try:
|
||||||
|
Tag.create("u1", "")
|
||||||
|
assert False
|
||||||
|
except ValueError as e:
|
||||||
|
assert "不能为空" in str(e)
|
||||||
|
|
||||||
|
def test_create_whitespace_name(self):
|
||||||
|
"""纯空白名称无效."""
|
||||||
|
try:
|
||||||
|
Tag.create("u1", " ")
|
||||||
|
assert False
|
||||||
|
except ValueError as e:
|
||||||
|
assert "不能为空" in str(e)
|
||||||
|
|
||||||
|
def test_create_unique_id(self):
|
||||||
|
t1 = Tag.create("u", "t1")
|
||||||
|
t2 = Tag.create("u", "t2")
|
||||||
|
assert t1.id != t2.id
|
||||||
|
|
||||||
|
|
||||||
|
class TestRecipeItem:
|
||||||
|
"""RecipeItem 测试."""
|
||||||
|
|
||||||
|
def test_create_asset_item(self):
|
||||||
|
"""素材配方项."""
|
||||||
|
item = RecipeItem(
|
||||||
|
id="item1",
|
||||||
|
recipe_id="r1",
|
||||||
|
item_type="asset",
|
||||||
|
item_id="a1",
|
||||||
|
position=0,
|
||||||
|
)
|
||||||
|
assert item.item_type == "asset"
|
||||||
|
assert item.item_id == "a1"
|
||||||
|
assert item.position == 0
|
||||||
|
|
||||||
|
def test_create_title_item(self):
|
||||||
|
"""标题配方项."""
|
||||||
|
item = RecipeItem(id="i2", recipe_id="r1", item_type="title", item_id="t1", position=1)
|
||||||
|
assert item.item_type == "title"
|
||||||
|
assert item.position == 1
|
||||||
|
|
||||||
|
def test_default_metadata(self):
|
||||||
|
"""默认 metadata 为空 dict."""
|
||||||
|
item = RecipeItem(id="i1", recipe_id="r1", item_type="voice", item_id="v1")
|
||||||
|
assert item.metadata_ == {}
|
||||||
|
|
||||||
|
|
||||||
|
class TestRecipe:
|
||||||
|
"""Recipe 测试."""
|
||||||
|
|
||||||
|
def test_create_minimal(self):
|
||||||
|
"""最简配方."""
|
||||||
|
recipe = Recipe(id="r1", user_id="u1", name="我的配方")
|
||||||
|
assert recipe.name == "我的配方"
|
||||||
|
assert recipe.description == ""
|
||||||
|
assert recipe.template_id == ""
|
||||||
|
assert recipe.generation_params == {}
|
||||||
|
assert recipe.items == []
|
||||||
|
assert recipe.is_active is True
|
||||||
|
assert recipe.created_at is not None
|
||||||
|
assert recipe.updated_at is not None
|
||||||
|
|
||||||
|
def test_create_with_items(self):
|
||||||
|
"""带配方项."""
|
||||||
|
items = [
|
||||||
|
RecipeItem(id="i1", recipe_id="r1", item_type="asset", item_id="a1", position=0),
|
||||||
|
RecipeItem(id="i2", recipe_id="r1", item_type="title", item_id="t1", position=1),
|
||||||
|
]
|
||||||
|
recipe = Recipe(id="r1", user_id="u1", name="配方", items=items)
|
||||||
|
assert len(recipe.items) == 2
|
||||||
|
assert recipe.items[0].item_type == "asset"
|
||||||
|
assert recipe.items[1].position == 1
|
||||||
|
|
||||||
|
def test_default_items_empty_list(self):
|
||||||
|
"""默认 items 为空列表."""
|
||||||
|
r1 = Recipe(id="r1", user_id="u1", name="r1")
|
||||||
|
r2 = Recipe(id="r2", user_id="u2", name="r2")
|
||||||
|
r1.items.append("fake")
|
||||||
|
assert r2.items == []
|
||||||
|
|
||||||
|
|
||||||
|
class TestVoiceLibraryItem:
|
||||||
|
"""VoiceLibraryItem 测试."""
|
||||||
|
|
||||||
|
def test_create_minimal(self):
|
||||||
|
"""最简创建."""
|
||||||
|
item = VoiceLibraryItem(id="v1", user_id="u1", name="我的配音")
|
||||||
|
assert item.name == "我的配音"
|
||||||
|
assert item.text == ""
|
||||||
|
assert item.voice_provider == ""
|
||||||
|
assert item.voice_id == ""
|
||||||
|
assert item.voice_name == ""
|
||||||
|
assert item.audio_url == ""
|
||||||
|
assert item.duration == 0
|
||||||
|
assert item.file_size == 0
|
||||||
|
assert item.status == "completed"
|
||||||
|
assert item.project_id is None
|
||||||
|
assert item.tags == []
|
||||||
|
assert item.metadata_ == {}
|
||||||
|
|
||||||
|
def test_create_full(self):
|
||||||
|
"""带全部字段."""
|
||||||
|
item = VoiceLibraryItem(
|
||||||
|
id="v1",
|
||||||
|
user_id="u1",
|
||||||
|
name="旁白",
|
||||||
|
text="大家好",
|
||||||
|
voice_provider="xf",
|
||||||
|
voice_id="v1",
|
||||||
|
voice_name="小云",
|
||||||
|
audio_url="http://x/a.mp3",
|
||||||
|
duration=10.5,
|
||||||
|
file_size=102400,
|
||||||
|
status="processing",
|
||||||
|
project_id="p1",
|
||||||
|
tags=["旁白", "正式"],
|
||||||
|
)
|
||||||
|
assert item.text == "大家好"
|
||||||
|
assert item.duration == 10.5
|
||||||
|
assert item.file_size == 102400
|
||||||
|
assert item.project_id == "p1"
|
||||||
|
assert item.tags == ["旁白", "正式"]
|
||||||
|
|
||||||
|
def test_tags_independent(self):
|
||||||
|
"""不同实例的 tags 独立."""
|
||||||
|
i1 = VoiceLibraryItem(id="v1", user_id="u", name="n1")
|
||||||
|
i2 = VoiceLibraryItem(id="v2", user_id="u", name="n2")
|
||||||
|
i1.tags.append("x")
|
||||||
|
assert i2.tags == []
|
||||||
|
|
||||||
|
|
||||||
|
class TestTitleLibraryItem:
|
||||||
|
"""TitleLibraryItem 测试."""
|
||||||
|
|
||||||
|
def test_create_required(self):
|
||||||
|
"""必填字段."""
|
||||||
|
item = TitleLibraryItem(id="t1", user_id="u1", name="爆款标题", text="这也太牛了")
|
||||||
|
assert item.name == "爆款标题"
|
||||||
|
assert item.text == "这也太牛了"
|
||||||
|
assert item.category == "default"
|
||||||
|
assert item.description == ""
|
||||||
|
assert item.tags == []
|
||||||
|
assert item.usage_count == 0
|
||||||
|
assert item.is_active is True
|
||||||
|
assert item.metadata_ == {}
|
||||||
|
|
||||||
|
def test_create_full(self):
|
||||||
|
"""带全部字段."""
|
||||||
|
item = TitleLibraryItem(
|
||||||
|
id="t1",
|
||||||
|
user_id="u1",
|
||||||
|
name="科技标题",
|
||||||
|
text="震惊!",
|
||||||
|
category="tech",
|
||||||
|
description="科技类标题",
|
||||||
|
tags=["科技", "爆款"],
|
||||||
|
usage_count=100,
|
||||||
|
is_active=False,
|
||||||
|
)
|
||||||
|
assert item.category == "tech"
|
||||||
|
assert item.usage_count == 100
|
||||||
|
assert item.is_active is False
|
||||||
|
assert item.tags == ["科技", "爆款"]
|
||||||
|
|
||||||
|
def test_tags_independent(self):
|
||||||
|
i1 = TitleLibraryItem(id="t1", user_id="u", name="n", text="t")
|
||||||
|
i2 = TitleLibraryItem(id="t2", user_id="u", name="n", text="t")
|
||||||
|
i1.tags.append("x")
|
||||||
|
assert i2.tags == []
|
||||||
|
|
||||||
|
|
||||||
|
class TestTemplateSegment:
|
||||||
|
"""TemplateSegment 测试."""
|
||||||
|
|
||||||
|
def test_create(self):
|
||||||
|
"""正常创建."""
|
||||||
|
seg = TemplateSegment(
|
||||||
|
id="s1",
|
||||||
|
template_id="t1",
|
||||||
|
segment_order=0,
|
||||||
|
duration_min=2.0,
|
||||||
|
duration_max=5.0,
|
||||||
|
)
|
||||||
|
assert seg.segment_order == 0
|
||||||
|
assert seg.duration_min == 2.0
|
||||||
|
assert seg.duration_max == 5.0
|
||||||
|
assert seg.material_type is None
|
||||||
|
|
||||||
|
def test_with_material_type(self):
|
||||||
|
"""带素材类型(voice_over模式)."""
|
||||||
|
seg = TemplateSegment(
|
||||||
|
id="s1",
|
||||||
|
template_id="t1",
|
||||||
|
segment_order=0,
|
||||||
|
duration_min=3.0,
|
||||||
|
duration_max=8.0,
|
||||||
|
material_type="person",
|
||||||
|
)
|
||||||
|
assert seg.material_type == "person"
|
||||||
|
|
||||||
|
def test_has_timestamps(self):
|
||||||
|
seg = TemplateSegment(id="s1", template_id="t1", segment_order=0, duration_min=1, duration_max=2)
|
||||||
|
assert seg.created_at is not None
|
||||||
|
assert seg.updated_at is not None
|
||||||
|
|
||||||
|
|
||||||
|
class TestTemplate:
|
||||||
|
"""Template 测试."""
|
||||||
|
|
||||||
|
def test_create_minimal(self):
|
||||||
|
"""最简模板."""
|
||||||
|
tpl = Template(id="t1", user_id="u1", name="通用模板", mode="one_take")
|
||||||
|
assert tpl.name == "通用模板"
|
||||||
|
assert tpl.mode == "one_take"
|
||||||
|
assert tpl.category == ""
|
||||||
|
assert tpl.tags == []
|
||||||
|
assert tpl.title_config == {}
|
||||||
|
assert tpl.subtitle_config == {}
|
||||||
|
assert tpl.bgm_config == {}
|
||||||
|
assert tpl.estimated_duration == 0.0
|
||||||
|
assert tpl.segments == []
|
||||||
|
assert tpl.is_active is True
|
||||||
|
|
||||||
|
def test_create_with_segments(self):
|
||||||
|
"""带片段."""
|
||||||
|
segs = [
|
||||||
|
TemplateSegment(id="s1", template_id="t1", segment_order=0, duration_min=2, duration_max=5),
|
||||||
|
TemplateSegment(id="s2", template_id="t1", segment_order=1, duration_min=3, duration_max=7),
|
||||||
|
]
|
||||||
|
tpl = Template(id="t1", user_id="u1", name="模板", mode="pip", segments=segs)
|
||||||
|
assert len(tpl.segments) == 2
|
||||||
|
assert tpl.segments[0].segment_order == 0
|
||||||
|
|
||||||
|
def test_segments_independent(self):
|
||||||
|
t1 = Template(id="t1", user_id="u", name="n1", mode="one_take")
|
||||||
|
t2 = Template(id="t2", user_id="u", name="n2", mode="pip")
|
||||||
|
t1.segments.append("fake")
|
||||||
|
assert t2.segments == []
|
||||||
|
|
||||||
|
|
||||||
|
class TestTemplateCategory:
|
||||||
|
"""TemplateCategory 测试."""
|
||||||
|
|
||||||
|
def test_create(self):
|
||||||
|
cat = TemplateCategory(id="c1", user_id="u1", name="科技")
|
||||||
|
assert cat.name == "科技"
|
||||||
|
assert cat.created_at is not None
|
||||||
|
|
||||||
|
|
||||||
|
class TestEditTemplateStatus:
|
||||||
|
"""EditTemplateStatus 枚举测试."""
|
||||||
|
|
||||||
|
def test_two_statuses(self):
|
||||||
|
assert len(EditTemplateStatus) == 2
|
||||||
|
|
||||||
|
def test_active(self):
|
||||||
|
assert EditTemplateStatus.ACTIVE == "active"
|
||||||
|
|
||||||
|
def test_inactive(self):
|
||||||
|
assert EditTemplateStatus.INACTIVE == "inactive"
|
||||||
|
|
||||||
|
|
||||||
|
class TestEditTemplate:
|
||||||
|
"""EditTemplate 测试."""
|
||||||
|
|
||||||
|
def test_create_minimal(self):
|
||||||
|
"""最简创建."""
|
||||||
|
tpl = EditTemplate.create(name=" 通用模板 ")
|
||||||
|
assert tpl.name == "通用模板"
|
||||||
|
assert tpl.description == ""
|
||||||
|
assert tpl.template_type == "default"
|
||||||
|
assert tpl.config == {}
|
||||||
|
assert tpl.preview_url == ""
|
||||||
|
assert tpl.sort_weight == 0
|
||||||
|
assert tpl.status == EditTemplateStatus.ACTIVE
|
||||||
|
assert isinstance(tpl.id, str)
|
||||||
|
assert len(tpl.id) > 0
|
||||||
|
|
||||||
|
def test_create_full(self):
|
||||||
|
"""带全部字段."""
|
||||||
|
tpl = EditTemplate.create(
|
||||||
|
name="口播模板",
|
||||||
|
description="口播类模板",
|
||||||
|
template_type="voice_over",
|
||||||
|
config={"style": "formal"},
|
||||||
|
preview_url="https://x/preview.mp4",
|
||||||
|
sort_weight=100,
|
||||||
|
status=EditTemplateStatus.INACTIVE,
|
||||||
|
)
|
||||||
|
assert tpl.name == "口播模板"
|
||||||
|
assert tpl.description == "口播类模板"
|
||||||
|
assert tpl.template_type == "voice_over"
|
||||||
|
assert tpl.config == {"style": "formal"}
|
||||||
|
assert tpl.preview_url == "https://x/preview.mp4"
|
||||||
|
assert tpl.sort_weight == 100
|
||||||
|
assert tpl.status == EditTemplateStatus.INACTIVE
|
||||||
|
|
||||||
|
def test_create_empty_name(self):
|
||||||
|
"""空名称无效."""
|
||||||
|
try:
|
||||||
|
EditTemplate.create(name="")
|
||||||
|
assert False
|
||||||
|
except ValueError as e:
|
||||||
|
assert "不能为空" in str(e)
|
||||||
|
|
||||||
|
def test_create_whitespace_name(self):
|
||||||
|
"""空白名称无效."""
|
||||||
|
try:
|
||||||
|
EditTemplate.create(name=" ")
|
||||||
|
assert False
|
||||||
|
except ValueError as e:
|
||||||
|
assert "不能为空" in str(e)
|
||||||
|
|
||||||
|
def test_empty_template_type_defaults(self):
|
||||||
|
"""空 template_type 默认 default."""
|
||||||
|
tpl = EditTemplate.create(name="t", template_type="")
|
||||||
|
assert tpl.template_type == "default"
|
||||||
|
|
||||||
|
def test_whitespace_template_type_defaults(self):
|
||||||
|
"""空白 template_type 默认 default."""
|
||||||
|
tpl = EditTemplate.create(name="t", template_type=" ")
|
||||||
|
assert tpl.template_type == "default"
|
||||||
|
|
||||||
|
def test_config_none_defaults_empty(self):
|
||||||
|
"""config=None 默认为空 dict."""
|
||||||
|
tpl = EditTemplate.create(name="t", config=None)
|
||||||
|
assert tpl.config == {}
|
||||||
|
|
||||||
|
def test_activate(self):
|
||||||
|
"""激活."""
|
||||||
|
tpl = EditTemplate.create(name="t", status=EditTemplateStatus.INACTIVE)
|
||||||
|
old = tpl.updated_at
|
||||||
|
tpl.activate()
|
||||||
|
assert tpl.is_active is True
|
||||||
|
assert tpl.status == EditTemplateStatus.ACTIVE
|
||||||
|
assert tpl.updated_at >= old
|
||||||
|
|
||||||
|
def test_deactivate(self):
|
||||||
|
"""停用."""
|
||||||
|
tpl = EditTemplate.create(name="t")
|
||||||
|
old = tpl.updated_at
|
||||||
|
tpl.deactivate()
|
||||||
|
assert tpl.is_active is False
|
||||||
|
assert tpl.status == EditTemplateStatus.INACTIVE
|
||||||
|
assert tpl.updated_at >= old
|
||||||
|
|
||||||
|
def test_is_active_property(self):
|
||||||
|
"""is_active 属性."""
|
||||||
|
tpl = EditTemplate.create(name="t")
|
||||||
|
assert tpl.is_active is True
|
||||||
|
tpl.deactivate()
|
||||||
|
assert tpl.is_active is False
|
||||||
|
tpl.activate()
|
||||||
|
assert tpl.is_active is True
|
||||||
|
|
||||||
|
def test_unique_id(self):
|
||||||
|
t1 = EditTemplate.create(name="t1")
|
||||||
|
t2 = EditTemplate.create(name="t2")
|
||||||
|
assert t1.id != t2.id
|
||||||
|
|
||||||
|
def test_config_independent(self):
|
||||||
|
t1 = EditTemplate.create(name="t1")
|
||||||
|
t2 = EditTemplate.create(name="t2")
|
||||||
|
t1.config["k"] = "v"
|
||||||
|
assert "k" not in t2.config
|
||||||
@@ -0,0 +1,376 @@
|
|||||||
|
"""duplication 单测.
|
||||||
|
|
||||||
|
domain 层查重记录纯逻辑模块,0 外部依赖。
|
||||||
|
覆盖:DuplicateSegment 工厂/校验、DuplicationRecord 创建/状态流转/重试。
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from packages.domain.duplication import DuplicateSegment, DuplicationRecord
|
||||||
|
|
||||||
|
|
||||||
|
class TestDuplicateSegmentCreate:
|
||||||
|
"""DuplicateSegment.create 工厂方法测试."""
|
||||||
|
|
||||||
|
def test_create_valid(self):
|
||||||
|
"""正常创建."""
|
||||||
|
seg = DuplicateSegment.create(
|
||||||
|
source_start=1.0,
|
||||||
|
source_end=5.0,
|
||||||
|
matched_video_id="vid123",
|
||||||
|
matched_video_name="测试视频",
|
||||||
|
matched_start=10.0,
|
||||||
|
matched_end=14.0,
|
||||||
|
similarity=85.5,
|
||||||
|
)
|
||||||
|
assert seg.source_start == 1.0
|
||||||
|
assert seg.source_end == 5.0
|
||||||
|
assert seg.matched_video_id == "vid123"
|
||||||
|
assert seg.matched_video_name == "测试视频"
|
||||||
|
assert seg.matched_start == 10.0
|
||||||
|
assert seg.matched_end == 14.0
|
||||||
|
assert seg.similarity == 85.5
|
||||||
|
assert isinstance(seg.id, str)
|
||||||
|
assert len(seg.id) > 0
|
||||||
|
|
||||||
|
def test_create_generates_unique_id(self):
|
||||||
|
"""每次创建生成不同的 id."""
|
||||||
|
seg1 = DuplicateSegment.create(0, 1, "v", "n", 0, 1, 50.0)
|
||||||
|
seg2 = DuplicateSegment.create(0, 1, "v", "n", 0, 1, 50.0)
|
||||||
|
assert seg1.id != seg2.id
|
||||||
|
|
||||||
|
def test_create_negative_source_start(self):
|
||||||
|
"""source_start 为负抛出 ValueError."""
|
||||||
|
try:
|
||||||
|
DuplicateSegment.create(-1, 5, "v", "n", 0, 1, 50.0)
|
||||||
|
assert False, "应该抛出 ValueError"
|
||||||
|
except ValueError as e:
|
||||||
|
assert "source" in str(e).lower()
|
||||||
|
|
||||||
|
def test_create_source_end_equals_start(self):
|
||||||
|
"""source_end 等于 source_start 无效."""
|
||||||
|
try:
|
||||||
|
DuplicateSegment.create(5, 5, "v", "n", 0, 1, 50.0)
|
||||||
|
assert False
|
||||||
|
except ValueError as e:
|
||||||
|
assert "source" in str(e).lower()
|
||||||
|
|
||||||
|
def test_create_source_end_less_than_start(self):
|
||||||
|
"""source_end 小于 source_start 无效."""
|
||||||
|
try:
|
||||||
|
DuplicateSegment.create(5, 3, "v", "n", 0, 1, 50.0)
|
||||||
|
assert False
|
||||||
|
except ValueError as e:
|
||||||
|
assert "source" in str(e).lower()
|
||||||
|
|
||||||
|
def test_create_negative_matched_start(self):
|
||||||
|
"""matched_start 为负无效."""
|
||||||
|
try:
|
||||||
|
DuplicateSegment.create(0, 5, "v", "n", -1, 1, 50.0)
|
||||||
|
assert False
|
||||||
|
except ValueError as e:
|
||||||
|
assert "matched" in str(e).lower()
|
||||||
|
|
||||||
|
def test_create_matched_end_invalid(self):
|
||||||
|
"""matched_end <= matched_start 无效."""
|
||||||
|
try:
|
||||||
|
DuplicateSegment.create(0, 5, "v", "n", 5, 5, 50.0)
|
||||||
|
assert False
|
||||||
|
except ValueError as e:
|
||||||
|
assert "matched" in str(e).lower()
|
||||||
|
|
||||||
|
def test_create_similarity_zero(self):
|
||||||
|
"""similarity = 0 是合法的."""
|
||||||
|
seg = DuplicateSegment.create(0, 1, "v", "n", 0, 1, 0.0)
|
||||||
|
assert seg.similarity == 0.0
|
||||||
|
|
||||||
|
def test_create_similarity_100(self):
|
||||||
|
"""similarity = 100 是合法的."""
|
||||||
|
seg = DuplicateSegment.create(0, 1, "v", "n", 0, 1, 100.0)
|
||||||
|
assert seg.similarity == 100.0
|
||||||
|
|
||||||
|
def test_create_similarity_negative(self):
|
||||||
|
"""similarity < 0 无效."""
|
||||||
|
try:
|
||||||
|
DuplicateSegment.create(0, 1, "v", "n", 0, 1, -1.0)
|
||||||
|
assert False
|
||||||
|
except ValueError as e:
|
||||||
|
assert "similarity" in str(e).lower()
|
||||||
|
|
||||||
|
def test_create_similarity_over_100(self):
|
||||||
|
"""similarity > 100 无效."""
|
||||||
|
try:
|
||||||
|
DuplicateSegment.create(0, 1, "v", "n", 0, 1, 101.0)
|
||||||
|
assert False
|
||||||
|
except ValueError as e:
|
||||||
|
assert "similarity" in str(e).lower()
|
||||||
|
|
||||||
|
|
||||||
|
class TestDuplicationRecordCreate:
|
||||||
|
"""DuplicationRecord.create 工厂方法测试."""
|
||||||
|
|
||||||
|
def test_create_minimal(self):
|
||||||
|
"""最简创建."""
|
||||||
|
rec = DuplicationRecord.create(
|
||||||
|
user_id="user1",
|
||||||
|
filename="test.mp4",
|
||||||
|
file_size=1024,
|
||||||
|
storage_key="oss://bucket/test.mp4",
|
||||||
|
)
|
||||||
|
assert rec.user_id == "user1"
|
||||||
|
assert rec.filename == "test.mp4"
|
||||||
|
assert rec.file_size == 1024
|
||||||
|
assert rec.storage_key == "oss://bucket/test.mp4"
|
||||||
|
assert rec.duration_seconds == 0.0
|
||||||
|
assert rec.status == "pending"
|
||||||
|
assert rec.duplicate_rate is None
|
||||||
|
assert rec.duplicate_count == 0
|
||||||
|
assert rec.segments == []
|
||||||
|
assert rec.error_message == ""
|
||||||
|
assert isinstance(rec.id, str)
|
||||||
|
assert len(rec.id) > 0
|
||||||
|
|
||||||
|
def test_create_with_duration(self):
|
||||||
|
"""带时长创建."""
|
||||||
|
rec = DuplicationRecord.create(
|
||||||
|
user_id="user1",
|
||||||
|
filename="test.mp4",
|
||||||
|
file_size=1024,
|
||||||
|
storage_key="oss://key",
|
||||||
|
duration_seconds=120.5,
|
||||||
|
)
|
||||||
|
assert rec.duration_seconds == 120.5
|
||||||
|
|
||||||
|
def test_create_strips_whitespace(self):
|
||||||
|
"""user_id 和 filename 会 strip."""
|
||||||
|
rec = DuplicationRecord.create(
|
||||||
|
user_id=" user1 ",
|
||||||
|
filename=" test.mp4 ",
|
||||||
|
file_size=1024,
|
||||||
|
storage_key="oss://key",
|
||||||
|
)
|
||||||
|
assert rec.user_id == "user1"
|
||||||
|
assert rec.filename == "test.mp4"
|
||||||
|
|
||||||
|
def test_create_empty_user_id(self):
|
||||||
|
"""空 user_id 无效."""
|
||||||
|
try:
|
||||||
|
DuplicationRecord.create("", "test.mp4", 1024, "oss://key")
|
||||||
|
assert False
|
||||||
|
except ValueError as e:
|
||||||
|
assert "user_id" in str(e)
|
||||||
|
|
||||||
|
def test_create_whitespace_user_id(self):
|
||||||
|
"""纯空白 user_id 无效."""
|
||||||
|
try:
|
||||||
|
DuplicationRecord.create(" ", "test.mp4", 1024, "oss://key")
|
||||||
|
assert False
|
||||||
|
except ValueError as e:
|
||||||
|
assert "user_id" in str(e)
|
||||||
|
|
||||||
|
def test_create_empty_filename(self):
|
||||||
|
"""空 filename 无效."""
|
||||||
|
try:
|
||||||
|
DuplicationRecord.create("user1", "", 1024, "oss://key")
|
||||||
|
assert False
|
||||||
|
except ValueError as e:
|
||||||
|
assert "filename" in str(e)
|
||||||
|
|
||||||
|
def test_create_whitespace_filename(self):
|
||||||
|
"""纯空白 filename 无效."""
|
||||||
|
try:
|
||||||
|
DuplicationRecord.create("user1", " ", 1024, "oss://key")
|
||||||
|
assert False
|
||||||
|
except ValueError as e:
|
||||||
|
assert "filename" in str(e)
|
||||||
|
|
||||||
|
def test_create_zero_file_size(self):
|
||||||
|
"""file_size = 0 无效."""
|
||||||
|
try:
|
||||||
|
DuplicationRecord.create("user1", "test.mp4", 0, "oss://key")
|
||||||
|
assert False
|
||||||
|
except ValueError as e:
|
||||||
|
assert "file_size" in str(e)
|
||||||
|
|
||||||
|
def test_create_negative_file_size(self):
|
||||||
|
"""file_size 为负无效."""
|
||||||
|
try:
|
||||||
|
DuplicationRecord.create("user1", "test.mp4", -1, "oss://key")
|
||||||
|
assert False
|
||||||
|
except ValueError as e:
|
||||||
|
assert "file_size" in str(e)
|
||||||
|
|
||||||
|
def test_create_unique_id(self):
|
||||||
|
"""不同记录 id 不同."""
|
||||||
|
r1 = DuplicationRecord.create("u", "f", 1, "k")
|
||||||
|
r2 = DuplicationRecord.create("u", "f", 1, "k")
|
||||||
|
assert r1.id != r2.id
|
||||||
|
|
||||||
|
def test_create_has_timestamps(self):
|
||||||
|
"""有创建和更新时间."""
|
||||||
|
rec = DuplicationRecord.create("u", "f", 1, "k")
|
||||||
|
assert rec.created_at is not None
|
||||||
|
assert rec.updated_at is not None
|
||||||
|
# 两者应该很接近(都是 now)
|
||||||
|
delta = (rec.updated_at - rec.created_at).total_seconds()
|
||||||
|
assert abs(delta) < 1.0
|
||||||
|
|
||||||
|
|
||||||
|
class TestDuplicationRecordStatusFlow:
|
||||||
|
"""状态流转测试."""
|
||||||
|
|
||||||
|
def _make_record(self):
|
||||||
|
return DuplicationRecord.create("user1", "test.mp4", 1024, "oss://key")
|
||||||
|
|
||||||
|
def test_initial_status_pending(self):
|
||||||
|
"""初始状态 pending."""
|
||||||
|
rec = self._make_record()
|
||||||
|
assert rec.status == "pending"
|
||||||
|
|
||||||
|
def test_mark_processing(self):
|
||||||
|
"""标记为处理中."""
|
||||||
|
rec = self._make_record()
|
||||||
|
old_updated = rec.updated_at
|
||||||
|
rec.mark_processing()
|
||||||
|
assert rec.status == "processing"
|
||||||
|
assert rec.updated_at >= old_updated
|
||||||
|
|
||||||
|
def test_mark_completed(self):
|
||||||
|
"""标记为完成."""
|
||||||
|
rec = self._make_record()
|
||||||
|
seg = DuplicateSegment.create(0, 1, "v", "n", 0, 1, 80.0)
|
||||||
|
rec.mark_completed(duplicate_rate=45.5, duplicate_count=3, segments=[seg])
|
||||||
|
assert rec.status == "completed"
|
||||||
|
assert rec.duplicate_rate == 45.5
|
||||||
|
assert rec.duplicate_count == 3
|
||||||
|
assert len(rec.segments) == 1
|
||||||
|
assert rec.segments[0].similarity == 80.0
|
||||||
|
|
||||||
|
def test_mark_completed_zero_rate(self):
|
||||||
|
"""重复率为 0 合法."""
|
||||||
|
rec = self._make_record()
|
||||||
|
rec.mark_completed(0.0, 0, [])
|
||||||
|
assert rec.status == "completed"
|
||||||
|
assert rec.duplicate_rate == 0.0
|
||||||
|
assert rec.duplicate_count == 0
|
||||||
|
assert rec.segments == []
|
||||||
|
|
||||||
|
def test_mark_completed_full_rate(self):
|
||||||
|
"""重复率 100 合法."""
|
||||||
|
rec = self._make_record()
|
||||||
|
rec.mark_completed(100.0, 1, [])
|
||||||
|
assert rec.duplicate_rate == 100.0
|
||||||
|
|
||||||
|
def test_mark_completed_negative_rate(self):
|
||||||
|
"""重复率为负无效."""
|
||||||
|
rec = self._make_record()
|
||||||
|
try:
|
||||||
|
rec.mark_completed(-1, 0, [])
|
||||||
|
assert False
|
||||||
|
except ValueError as e:
|
||||||
|
assert "duplicate_rate" in str(e)
|
||||||
|
|
||||||
|
def test_mark_completed_over_100(self):
|
||||||
|
"""重复率超过 100 无效."""
|
||||||
|
rec = self._make_record()
|
||||||
|
try:
|
||||||
|
rec.mark_completed(101, 0, [])
|
||||||
|
assert False
|
||||||
|
except ValueError as e:
|
||||||
|
assert "duplicate_rate" in str(e)
|
||||||
|
|
||||||
|
def test_mark_failed(self):
|
||||||
|
"""标记为失败."""
|
||||||
|
rec = self._make_record()
|
||||||
|
rec.mark_failed("网络超时")
|
||||||
|
assert rec.status == "failed"
|
||||||
|
assert rec.error_message == "网络超时"
|
||||||
|
|
||||||
|
def test_mark_failed_empty_message(self):
|
||||||
|
"""失败信息可以为空字符串."""
|
||||||
|
rec = self._make_record()
|
||||||
|
rec.mark_failed("")
|
||||||
|
assert rec.status == "failed"
|
||||||
|
assert rec.error_message == ""
|
||||||
|
|
||||||
|
def test_can_retry_failed(self):
|
||||||
|
"""failed 状态可以重试."""
|
||||||
|
rec = self._make_record()
|
||||||
|
rec.mark_failed("error")
|
||||||
|
assert rec.can_retry() is True
|
||||||
|
|
||||||
|
def test_cannot_retry_pending(self):
|
||||||
|
"""pending 状态不可重试."""
|
||||||
|
rec = self._make_record()
|
||||||
|
assert rec.can_retry() is False
|
||||||
|
|
||||||
|
def test_cannot_retry_processing(self):
|
||||||
|
"""processing 状态不可重试."""
|
||||||
|
rec = self._make_record()
|
||||||
|
rec.mark_processing()
|
||||||
|
assert rec.can_retry() is False
|
||||||
|
|
||||||
|
def test_cannot_retry_completed(self):
|
||||||
|
"""completed 状态不可重试."""
|
||||||
|
rec = self._make_record()
|
||||||
|
rec.mark_completed(50, 1, [])
|
||||||
|
assert rec.can_retry() is False
|
||||||
|
|
||||||
|
def test_reset_for_retry(self):
|
||||||
|
"""重置回 pending."""
|
||||||
|
rec = self._make_record()
|
||||||
|
rec.mark_failed("error")
|
||||||
|
seg = DuplicateSegment.create(0, 1, "v", "n", 0, 1, 50.0)
|
||||||
|
rec.segments = [seg]
|
||||||
|
rec.video_fingerprint = {"hash": "abc"}
|
||||||
|
rec.duplicate_rate = 50.0
|
||||||
|
rec.duplicate_count = 5
|
||||||
|
|
||||||
|
rec.reset_for_retry()
|
||||||
|
assert rec.status == "pending"
|
||||||
|
assert rec.duplicate_rate is None
|
||||||
|
assert rec.duplicate_count == 0
|
||||||
|
assert rec.error_message == ""
|
||||||
|
assert rec.segments == []
|
||||||
|
assert rec.video_fingerprint is None
|
||||||
|
|
||||||
|
def test_reset_updates_timestamp(self):
|
||||||
|
"""重置更新 updated_at."""
|
||||||
|
rec = self._make_record()
|
||||||
|
rec.mark_failed("error")
|
||||||
|
old_updated = rec.updated_at
|
||||||
|
rec.reset_for_retry()
|
||||||
|
assert rec.updated_at >= old_updated
|
||||||
|
|
||||||
|
|
||||||
|
class TestDuplicationRecordSegments:
|
||||||
|
"""segments 列表相关测试."""
|
||||||
|
|
||||||
|
def _make_record(self):
|
||||||
|
return DuplicationRecord.create("user1", "test.mp4", 1024, "oss://key")
|
||||||
|
|
||||||
|
def test_segments_default_empty(self):
|
||||||
|
"""初始 segments 为空列表."""
|
||||||
|
rec = self._make_record()
|
||||||
|
assert rec.segments == []
|
||||||
|
|
||||||
|
def test_segments_independent_list(self):
|
||||||
|
"""不同记录的 segments 是独立列表."""
|
||||||
|
r1 = self._make_record()
|
||||||
|
r2 = self._make_record()
|
||||||
|
r1.segments.append("fake")
|
||||||
|
assert len(r2.segments) == 0
|
||||||
|
|
||||||
|
def test_completed_with_multiple_segments(self):
|
||||||
|
"""完成时带多个片段."""
|
||||||
|
rec = self._make_record()
|
||||||
|
segs = [
|
||||||
|
DuplicateSegment.create(0, 1, "v1", "n1", 0, 1, 90.0),
|
||||||
|
DuplicateSegment.create(2, 3, "v2", "n2", 5, 6, 70.0),
|
||||||
|
DuplicateSegment.create(4, 5, "v3", "n3", 10, 11, 85.0),
|
||||||
|
]
|
||||||
|
rec.mark_completed(60.0, 3, segs)
|
||||||
|
assert len(rec.segments) == 3
|
||||||
|
assert rec.segments[0].similarity == 90.0
|
||||||
|
assert rec.segments[1].matched_video_id == "v2"
|
||||||
|
assert rec.segments[2].matched_video_name == "n3"
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user