add:新增验证视频可选上传功能

This commit is contained in:
2026-10-24 16:12:56 +08:00
parent 25756901cc
commit 0ee5554f57
28 changed files with 1588 additions and 51 deletions
+306
View File
@@ -0,0 +1,306 @@
/**
* 验收视频:浏览器内探测 + 重编码(Canvas + MediaRecorder)。
*
* 为什么不用 ffmpeg.wasm:核心包 31MB、需要 COOP/COEP,iOS 内存易崩;
* 这里只是把「游戏中录屏的 30 秒片段」压到 720p/1.2Mbps,WebM(VP8) 足够,
* 且零依赖、不上传原片(打手多为手机流量)。
*
* 时长策略(产品已确认):**超过上限直接拒绝,不自动裁剪**。
*/
export type AcceptanceVideoConfig = {
enabled: boolean
maxDurationSeconds: number
maxSizeMb: number
targetHeight: number
bitrateKbps: number
audioBitrateKbps: number
fps: number
}
export type VideoProbeResult = {
durationSeconds: number
width: number
height: number
}
export type CompressedVideo = {
blob: Blob
durationSeconds: number
width: number
height: number
contentType: string
extension: 'webm' | 'mp4'
}
/** 按兼容性从高到低尝试;实际可用性由 MediaRecorder.isTypeSupported 决定。 */
const MIME_CANDIDATES = [
'video/webm;codecs=vp8,opus',
'video/webm;codecs=vp9,opus',
'video/webm',
'video/mp4;codecs=avc1.42E01E,mp4a.40.2',
'video/mp4',
]
export function pickSupportedVideoMimeType(
isSupported: (mime: string) => boolean = defaultMimeSupport,
): string {
for (const candidate of MIME_CANDIDATES) {
if (isSupported(candidate)) return candidate
}
return ''
}
/** 时长是否超出配置上限;返回空串表示通过。 */
export function resolveVideoDurationLimitError(
durationSeconds: number,
maxDurationSeconds: number,
): string {
if (!Number.isFinite(durationSeconds) || durationSeconds <= 0)
return '无法读取视频时长,请更换视频'
if (durationSeconds > maxDurationSeconds + 0.5) {
return `视频时长不能超过 ${maxDurationSeconds} 秒,请先裁剪后再上传`
}
return ''
}
/** 按目标高度等比缩放(取偶数值,编码器要求宽高为偶数)。 */
export function resolveVideoTargetSize(
width: number,
height: number,
targetHeight: number,
): { width: number; height: number } {
const safeWidth = Math.max(2, Math.round(width || 0))
const safeHeight = Math.max(2, Math.round(height || 0))
const limit = Math.max(2, Math.round(targetHeight || 720))
const scale = Math.min(1, limit / safeHeight)
return {
width: ensureEven(safeWidth * scale),
height: ensureEven(safeHeight * scale),
}
}
export function isVideoCompressionSupported(): boolean {
if (typeof window === 'undefined' || typeof document === 'undefined') return false
if (typeof MediaRecorder === 'undefined') return false
if (typeof HTMLCanvasElement === 'undefined') return false
if (typeof HTMLCanvasElement.prototype.captureStream !== 'function') return false
return Boolean(pickSupportedVideoMimeType())
}
/** 读取视频时长/分辨率;部分 WebM 的 duration 为 Infinity,这里做一次定位探测。 */
export function probeVideoFile(file: File): Promise<VideoProbeResult> {
return new Promise((resolve, reject) => {
const url = URL.createObjectURL(file)
const video = document.createElement('video')
let settled = false
const cleanup = () => {
URL.revokeObjectURL(url)
video.removeAttribute('src')
video.load()
}
const finish = () => {
if (settled) return
settled = true
const durationSeconds = Number.isFinite(video.duration) ? video.duration : 0
const width = video.videoWidth
const height = video.videoHeight
cleanup()
if (!durationSeconds || durationSeconds <= 0 || !width || !height) {
reject(new Error('无法读取视频信息,请更换视频或先裁剪'))
return
}
resolve({ durationSeconds, width, height })
}
video.preload = 'metadata'
video.muted = true
video.src = url
video.onloadedmetadata = () => {
if (Number.isFinite(video.duration) && video.duration > 0) {
finish()
return
}
// WebM 常见:duration = Infinity。跳到极远处逼浏览器计算真实时长。
video.currentTime = 1e101
const timer = setTimeout(finish, 1500)
video.ondurationchange = () => {
clearTimeout(timer)
finish()
}
}
video.onerror = () => {
if (settled) return
settled = true
cleanup()
reject(new Error('无法读取视频信息,请更换视频或先裁剪'))
}
})
}
/**
* 压缩到配置参数并返回新的 Blob;超过时长上限直接抛错(不裁剪)。
* onProgress 回调 0~1,用于展示"压缩中"进度。
*/
export async function compressVideoForUpload(
file: File,
config: AcceptanceVideoConfig,
onProgress?: (ratio: number) => void,
): Promise<CompressedVideo> {
if (!isVideoCompressionSupported()) {
throw new Error('当前浏览器不支持视频压缩,请使用 Chrome / Edge,或先裁剪视频后再上传')
}
const mimeType = pickSupportedVideoMimeType()
const probe = await probeVideoFile(file)
const limitError = resolveVideoDurationLimitError(
probe.durationSeconds,
config.maxDurationSeconds,
)
if (limitError) throw new Error(limitError)
const target = resolveVideoTargetSize(probe.width, probe.height, config.targetHeight)
const url = URL.createObjectURL(file)
const video = document.createElement('video')
const canvas = document.createElement('canvas')
let audioContext: AudioContext | null = null
let stopped = false
let frameHandle = 0
const stopRecording = () => {
if (stopped) return
stopped = true
try {
video.pause()
} catch {
// ignore
}
}
try {
video.src = url
video.muted = true
video.playsInline = true
video.preload = 'auto'
await waitForEvent(video, 'loadeddata')
canvas.width = target.width
canvas.height = target.height
const context = canvas.getContext('2d')
if (!context) throw new Error('当前浏览器不支持视频压缩,请更换浏览器')
const stream = canvas.captureStream(Math.max(15, Math.min(30, config.fps || 30)))
try {
const AudioContextCtor =
window.AudioContext ||
(window as unknown as { webkitAudioContext?: typeof AudioContext }).webkitAudioContext
if (AudioContextCtor) {
audioContext = new AudioContextCtor()
const source = audioContext.createMediaElementSource(video)
const destination = audioContext.createMediaStreamDestination()
source.connect(destination)
for (const track of destination.stream.getAudioTracks()) stream.addTrack(track)
}
} catch {
// 视频无音轨或浏览器限制:退化为无声视频,不阻断压缩。
}
const recorder = new MediaRecorder(stream, {
mimeType,
videoBitsPerSecond: Math.max(300, Number(config.bitrateKbps) || 1200) * 1000,
audioBitsPerSecond: Math.max(32, Number(config.audioBitrateKbps) || 64) * 1000,
})
const chunks: Blob[] = []
recorder.ondataavailable = (event) => {
if (event.data && event.data.size > 0) chunks.push(event.data)
}
const recorderStopped = new Promise<void>((resolve) => {
recorder.onstop = () => resolve()
})
const drawFrame = () => {
if (stopped) return
if (video.readyState >= 2) {
context.drawImage(video, 0, 0, target.width, target.height)
}
onProgress?.(Math.max(0, Math.min(1, video.currentTime / probe.durationSeconds)))
if (video.ended || video.currentTime >= config.maxDurationSeconds) {
stopRecording()
return
}
frameHandle = requestAnimationFrame(drawFrame)
}
recorder.start(1000)
await video.play()
frameHandle = requestAnimationFrame(drawFrame)
video.addEventListener('ended', stopRecording, { once: true })
// 防呆:视频卡住时最多等「时长 + 10 秒」,避免压缩界面永久转圈。
const timeoutHandle = setTimeout(stopRecording, (probe.durationSeconds + 10) * 1000)
await new Promise<void>((resolve) => {
const interval = setInterval(() => {
if (stopped) {
clearInterval(interval)
resolve()
}
}, 200)
})
clearTimeout(timeoutHandle)
if (recorder.state !== 'inactive') recorder.stop()
await recorderStopped
if (frameHandle) cancelAnimationFrame(frameHandle)
const blob = new Blob(chunks, { type: mimeType.split(';')[0] })
if (!blob.size) throw new Error('视频压缩失败,请重试或更换视频')
const contentType = blob.type || 'video/webm'
onProgress?.(1)
return {
blob,
durationSeconds: Math.min(probe.durationSeconds, config.maxDurationSeconds),
width: target.width,
height: target.height,
contentType,
extension: contentType.includes('mp4') ? 'mp4' : 'webm',
}
} finally {
if (frameHandle) cancelAnimationFrame(frameHandle)
try {
audioContext?.close()
} catch {
// ignore
}
URL.revokeObjectURL(url)
video.removeAttribute('src')
video.load()
}
}
function defaultMimeSupport(mime: string): boolean {
if (typeof MediaRecorder === 'undefined') return false
return typeof MediaRecorder.isTypeSupported === 'function'
? MediaRecorder.isTypeSupported(mime)
: false
}
function ensureEven(value: number): number {
const rounded = Math.max(2, Math.round(value))
return rounded % 2 === 0 ? rounded : rounded - 1
}
function waitForEvent(target: HTMLMediaElement, eventName: string): Promise<void> {
return new Promise((resolve, reject) => {
const onEvent = () => {
cleanup()
resolve()
}
const onError = () => {
cleanup()
reject(new Error('视频读取失败,请更换文件'))
}
const cleanup = () => {
target.removeEventListener(eventName, onEvent)
target.removeEventListener('error', onError)
}
target.addEventListener(eventName, onEvent, { once: true })
target.addEventListener('error', onError, { once: true })
})
}