307 lines
9.8 KiB
TypeScript
307 lines
9.8 KiB
TypeScript
/**
|
||
* 验收视频:浏览器内探测 + 重编码(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 })
|
||
})
|
||
}
|