48d1b4dd33
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 1m4s
CI/CD Pipeline / Validate - Type Check (mypy) (push) Successful in 1m46s
CI/CD Pipeline / Validate - Migration (alembic) (push) Successful in 1m53s
CI/CD Pipeline / Build Staging API Image (push) Successful in 3m1s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 4m55s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 5m16s
CI/CD Pipeline / Validate - Code Quality (push) Successful in 5m53s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 40s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 43s
CI/CD Pipeline / Staging E2E Tests (push) Successful in 48s
CI/CD Pipeline / Integration Tests (push) Successful in 2m18s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 3m6s
CI/CD Pipeline / Unit Tests (push) Successful in 10m49s
CI/CD Pipeline / Production Browser E2E (push) Failing after 673h58m42s
CI/CD Pipeline / CI Gate (push) Failing after 673h58m44s
CI/CD Pipeline / Canary Release to Production (push) Failing after 673h58m42s
CI/CD Pipeline / Deploy Production (push) Failing after 673h58m43s
CI/CD Pipeline / Build Production Web Image (push) Failing after 673h58m44s
CI/CD Pipeline / Build Production API Image (push) Failing after 673h58m44s
CI/CD Pipeline / PR Build Worker Image (push) Failing after 674h9m24s
CI/CD Pipeline / PR Build Web Image (push) Failing after 674h9m26s
CI/CD Pipeline / Frontend Lint (push) Failing after 674h9m35s
CI/CD Pipeline / Check if frontend-only change (push) Failing after 674h9m38s
CI/CD Pipeline / Build Production Worker Image (push) Failing after 674h32m28s
CI/CD Pipeline / PR Build API Image (push) Failing after 674h43m12s
- useCanvasPlayer renderFrame 删除 frame.close(),帧释放由 FrameQueue 统一管理 - 新增 calculateTotalVideoDuration 共享工具函数 - GeneratePage: 素材为空时用模板 duration_max 之和估算总时长 - Step5VoiceSelect: Number() 类型转换 + debug log
334 lines
10 KiB
TypeScript
334 lines
10 KiB
TypeScript
/**
|
|
* Step 5 配音选择组件
|
|
* 展示用户已上传的配音素材,支持选中、预览播放
|
|
*/
|
|
import React, { useState, useRef, useCallback } from "react"
|
|
import { useNavigate } from "react-router-dom"
|
|
import { useQuery } from "@tanstack/react-query"
|
|
import { AudioOutlined, SoundOutlined, WarningOutlined } from "@ant-design/icons"
|
|
import { Modal } from "antd"
|
|
import { getAssetsByKind } from "@/api/assets"
|
|
import type { AssetItem } from "@/api/assets"
|
|
|
|
interface Step5VoiceSelectProps {
|
|
selectedVoice: string
|
|
onSelectedVoiceChange: (id: string) => void
|
|
totalVideoDuration?: number
|
|
}
|
|
|
|
/** 格式化时长 mm:ss */
|
|
const formatDuration = (seconds?: number): string => {
|
|
if (!seconds || seconds <= 0) return "00:00"
|
|
const m = Math.floor(seconds / 60)
|
|
const s = Math.floor(seconds % 60)
|
|
return `${String(m).padStart(2, "0")}:${String(s).padStart(2, "0")}`
|
|
}
|
|
|
|
/** 格式化文件大小 */
|
|
const formatFileSize = (bytes?: number): string => {
|
|
if (!bytes || bytes <= 0) return "未知"
|
|
if (bytes < 1024) return `${bytes} B`
|
|
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`
|
|
if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)} MB`
|
|
return `${(bytes / (1024 * 1024 * 1024)).toFixed(1)} GB`
|
|
}
|
|
|
|
const Step5VoiceSelect: React.FC<Step5VoiceSelectProps> = ({
|
|
selectedVoice,
|
|
onSelectedVoiceChange,
|
|
totalVideoDuration = 0,
|
|
}) => {
|
|
const navigate = useNavigate()
|
|
const [playingId, setPlayingId] = useState<string | null>(null)
|
|
const audioRef = useRef<HTMLAudioElement | null>(null)
|
|
const [durationWarningOpen, setDurationWarningOpen] = useState(false)
|
|
const [pendingVoiceId, setPendingVoiceId] = useState<string | null>(null)
|
|
|
|
// 获取用户上传的配音素材
|
|
const { data: materials = [], isLoading } = useQuery({
|
|
queryKey: ["assets", "voice"],
|
|
queryFn: () => getAssetsByKind("voice", { limit: 50 }),
|
|
})
|
|
|
|
/** 切换播放/暂停 */
|
|
const togglePlay = useCallback(
|
|
(material: AssetItem) => {
|
|
// 如果当前正在播放同一个素材,则暂停
|
|
if (playingId === material.id && audioRef.current) {
|
|
audioRef.current.pause()
|
|
setPlayingId(null)
|
|
return
|
|
}
|
|
|
|
// 停止之前的播放
|
|
if (audioRef.current) {
|
|
audioRef.current.pause()
|
|
}
|
|
|
|
// 创建新的 audio 元素播放
|
|
const audio = new Audio(material.file_url)
|
|
audioRef.current = audio
|
|
setPlayingId(material.id)
|
|
|
|
audio.onended = () => {
|
|
setPlayingId(null)
|
|
audioRef.current = null
|
|
}
|
|
|
|
audio.play().catch(() => {
|
|
setPlayingId(null)
|
|
audioRef.current = null
|
|
})
|
|
},
|
|
[playingId],
|
|
)
|
|
|
|
/** 选中素材(含时长校验) */
|
|
const handleSelect = useCallback(
|
|
(id: string) => {
|
|
// 如果启用了时长校验,且配音时长不足
|
|
if (totalVideoDuration > 0) {
|
|
const material = materials.find((m) => m.id === id)
|
|
if (material && (material.duration || 0) < totalVideoDuration) {
|
|
setPendingVoiceId(id)
|
|
setDurationWarningOpen(true)
|
|
return
|
|
}
|
|
}
|
|
onSelectedVoiceChange(id)
|
|
},
|
|
[onSelectedVoiceChange, totalVideoDuration, materials],
|
|
)
|
|
|
|
/** 确认使用时长不足的配音 */
|
|
const handleConfirmUseAnyway = useCallback(() => {
|
|
if (pendingVoiceId) {
|
|
onSelectedVoiceChange(pendingVoiceId)
|
|
}
|
|
setDurationWarningOpen(false)
|
|
setPendingVoiceId(null)
|
|
}, [pendingVoiceId, onSelectedVoiceChange])
|
|
|
|
/** 取消选择 */
|
|
const handleCancelSelection = useCallback(() => {
|
|
setDurationWarningOpen(false)
|
|
setPendingVoiceId(null)
|
|
}, [])
|
|
|
|
/** 跳转到配音库上传 */
|
|
const handleGoToUpload = useCallback(() => {
|
|
navigate("/app/voices")
|
|
}, [navigate])
|
|
|
|
// 加载中状态
|
|
if (isLoading) {
|
|
return (
|
|
<div className="xx-form-section">
|
|
<h3>🎙️ 选择配音</h3>
|
|
<div style={{ textAlign: "center", padding: "40px 0", color: "#999" }}>
|
|
加载配音素材中...
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
// 空状态
|
|
if (materials.length === 0) {
|
|
return (
|
|
<div className="xx-form-section">
|
|
<h3>🎙️ 选择配音</h3>
|
|
<div
|
|
style={{
|
|
textAlign: "center",
|
|
padding: "60px 0",
|
|
color: "#999",
|
|
}}
|
|
>
|
|
<AudioOutlined style={{ fontSize: 48, color: "#d9d9d9", marginBottom: 16 }} />
|
|
<p style={{ fontSize: 16, marginBottom: 16 }}>暂无配音素材</p>
|
|
<button
|
|
type="button"
|
|
onClick={handleGoToUpload}
|
|
style={{
|
|
padding: "8px 24px",
|
|
background: "#1677ff",
|
|
color: "#fff",
|
|
border: "none",
|
|
borderRadius: 6,
|
|
cursor: "pointer",
|
|
fontSize: 14,
|
|
}}
|
|
>
|
|
去配音库上传
|
|
</button>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
return (
|
|
<div className="xx-form-section">
|
|
<h3>🎙️ 选择配音</h3>
|
|
<p style={{ color: "#666", marginBottom: 16, fontSize: 14 }}>
|
|
从配音库中选择已上传的素材,点击卡片可预览播放
|
|
</p>
|
|
<div
|
|
style={{
|
|
display: "grid",
|
|
gridTemplateColumns: "repeat(auto-fill, minmax(220px, 1fr))",
|
|
gap: 12,
|
|
}}
|
|
>
|
|
{materials.map((item) => {
|
|
const isSelected = selectedVoice === item.id
|
|
const isPlaying = playingId === item.id
|
|
|
|
return (
|
|
<div
|
|
key={item.id}
|
|
onClick={() => handleSelect(item.id)}
|
|
style={{
|
|
padding: 16,
|
|
borderRadius: 8,
|
|
border: isSelected ? "2px solid #1677ff" : "1px solid #e8e8e8",
|
|
background: isSelected ? "#e6f4ff" : "#fff",
|
|
cursor: "pointer",
|
|
transition: "all 0.2s",
|
|
display: "flex",
|
|
flexDirection: "column",
|
|
gap: 8,
|
|
}}
|
|
>
|
|
{/* 顶部:图标 + 播放按钮 */}
|
|
<div
|
|
style={{ display: "flex", alignItems: "center", justifyContent: "space-between" }}
|
|
>
|
|
<div
|
|
style={{
|
|
width: 40,
|
|
height: 40,
|
|
borderRadius: 8,
|
|
background: isSelected
|
|
? "linear-gradient(135deg, #1677ff, #4096ff)"
|
|
: "linear-gradient(135deg, #f0f0f0, #e8e8e8)",
|
|
display: "flex",
|
|
alignItems: "center",
|
|
justifyContent: "center",
|
|
}}
|
|
>
|
|
<AudioOutlined style={{ fontSize: 20, color: isSelected ? "#fff" : "#666" }} />
|
|
</div>
|
|
{item.file_url && (
|
|
<button
|
|
type="button"
|
|
onClick={(e) => {
|
|
e.stopPropagation()
|
|
togglePlay(item)
|
|
}}
|
|
style={{
|
|
width: 32,
|
|
height: 32,
|
|
borderRadius: "50%",
|
|
border: "none",
|
|
background: isPlaying ? "#ff4d4f" : "#1677ff",
|
|
color: "#fff",
|
|
cursor: "pointer",
|
|
display: "flex",
|
|
alignItems: "center",
|
|
justifyContent: "center",
|
|
fontSize: 14,
|
|
}}
|
|
title={isPlaying ? "暂停" : "播放"}
|
|
>
|
|
<SoundOutlined />
|
|
</button>
|
|
)}
|
|
</div>
|
|
|
|
{/* 名称 */}
|
|
<div
|
|
style={{
|
|
fontSize: 14,
|
|
fontWeight: 500,
|
|
color: "#333",
|
|
overflow: "hidden",
|
|
textOverflow: "ellipsis",
|
|
whiteSpace: "nowrap",
|
|
}}
|
|
title={item.name}
|
|
>
|
|
{item.name}
|
|
</div>
|
|
|
|
{/* 时长 + 大小 */}
|
|
<div
|
|
style={{
|
|
display: "flex",
|
|
justifyContent: "space-between",
|
|
fontSize: 12,
|
|
color: "#999",
|
|
alignItems: "center",
|
|
}}
|
|
>
|
|
<span style={{ display: "flex", alignItems: "center", gap: 4 }}>
|
|
{formatDuration(item.duration)}
|
|
{totalVideoDuration > 0 &&
|
|
(Number(item.duration) || 0) < Number(totalVideoDuration) && (
|
|
<span
|
|
style={{
|
|
color: "#ff4d4f",
|
|
fontSize: 11,
|
|
fontWeight: 500,
|
|
display: "inline-flex",
|
|
alignItems: "center",
|
|
gap: 2,
|
|
}}
|
|
>
|
|
<WarningOutlined />
|
|
时长不足
|
|
</span>
|
|
)}
|
|
</span>
|
|
<span>{formatFileSize(item.file_size)}</span>
|
|
</div>
|
|
</div>
|
|
)
|
|
})}
|
|
</div>
|
|
|
|
{/* 时长不足警告弹窗 */}
|
|
<Modal
|
|
title={
|
|
<span style={{ display: "flex", alignItems: "center", gap: 8 }}>
|
|
<WarningOutlined style={{ color: "#faad14" }} />
|
|
配音时长不足
|
|
</span>
|
|
}
|
|
open={durationWarningOpen}
|
|
onOk={handleConfirmUseAnyway}
|
|
onCancel={handleCancelSelection}
|
|
okText="仍要使用"
|
|
cancelText="重新选择"
|
|
okButtonProps={{ danger: true }}
|
|
>
|
|
{(() => {
|
|
const pendingMaterial = pendingVoiceId
|
|
? materials.find((m) => m.id === pendingVoiceId)
|
|
: null
|
|
return (
|
|
<p>
|
|
该配音时长(
|
|
<strong>{pendingMaterial ? formatDuration(pendingMaterial.duration) : "--"}</strong>
|
|
)短于视频总时长(
|
|
<strong>{formatDuration(totalVideoDuration)}</strong>
|
|
),播放时配音可能提前结束,建议选择更长的配音素材。
|
|
</p>
|
|
)
|
|
})()}
|
|
</Modal>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
export default Step5VoiceSelect
|