add:新增验证视频可选上传功能
This commit is contained in:
@@ -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 })
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user