Files
order_site/apps/frontend/src/utils/video-compress.ts
T

307 lines
9.8 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 验收视频:浏览器内探测 + 重编码(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 {
void 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 })
})
}