mirror of
https://github.com/Afilmory/afilmory
synced 2026-04-24 23:05:05 +00:00
feat: integrate mp4-muxer for video conversion and enhance ExifPanel layout
- Added mp4-muxer dependency to facilitate MP4 video conversion. - Updated video conversion logic to utilize WebCodecs API for improved performance and reliability. - Enhanced ExifPanel layout by adjusting grid spacing and item alignment for better visual consistency. Signed-off-by: Innei <tukon479@gmail.com>
This commit is contained in:
@@ -49,6 +49,7 @@
|
||||
"jotai": "2.12.5",
|
||||
"masonic": "4.1.0",
|
||||
"motion": "12.17.0",
|
||||
"mp4-muxer": "5.2.1",
|
||||
"ofetch": "1.4.1",
|
||||
"react": "19.1.0",
|
||||
"react-blurhash": "0.3.0",
|
||||
|
||||
@@ -204,9 +204,9 @@ export const ExifPanel: FC<{
|
||||
<h4 className="my-2 text-sm font-medium text-white/80">
|
||||
{t('exif.capture.parameters')}
|
||||
</h4>
|
||||
<div className={`grid grid-cols-2 gap-3`}>
|
||||
<div className={`grid grid-cols-2 gap-2`}>
|
||||
{formattedExifData.focalLength35mm && (
|
||||
<div className="flex items-center gap-2 rounded-md bg-white/10 px-2 py-1">
|
||||
<div className="flex h-6 items-center gap-2 rounded-md bg-white/10 px-2">
|
||||
<StreamlineImageAccessoriesLensesPhotosCameraShutterPicturePhotographyPicturesPhotoLens className="text-sm text-white/70" />
|
||||
<span className="text-xs">
|
||||
{formattedExifData.focalLength35mm}mm
|
||||
@@ -215,7 +215,7 @@ export const ExifPanel: FC<{
|
||||
)}
|
||||
|
||||
{formattedExifData.aperture && (
|
||||
<div className="flex items-center gap-2 rounded-md bg-white/10 px-2 py-1">
|
||||
<div className="flex h-6 items-center gap-2 rounded-md bg-white/10 px-2">
|
||||
<TablerAperture className="text-sm text-white/70" />
|
||||
<span className="text-xs">
|
||||
{formattedExifData.aperture}
|
||||
@@ -224,7 +224,7 @@ export const ExifPanel: FC<{
|
||||
)}
|
||||
|
||||
{formattedExifData.shutterSpeed && (
|
||||
<div className="flex items-center gap-2 rounded-md bg-white/10 px-2 py-1">
|
||||
<div className="flex h-6 items-center gap-2 rounded-md bg-white/10 px-2">
|
||||
<MaterialSymbolsShutterSpeed className="text-sm text-white/70" />
|
||||
<span className="text-xs">
|
||||
{formattedExifData.shutterSpeed}
|
||||
@@ -233,7 +233,7 @@ export const ExifPanel: FC<{
|
||||
)}
|
||||
|
||||
{formattedExifData.iso && (
|
||||
<div className="flex items-center gap-2 rounded-md bg-white/10 px-2 py-1">
|
||||
<div className="flex h-6 items-center gap-2 rounded-md bg-white/10 px-2">
|
||||
<CarbonIsoOutline className="text-sm text-white/70" />
|
||||
<span className="text-xs">
|
||||
ISO {formattedExifData.iso}
|
||||
@@ -242,7 +242,7 @@ export const ExifPanel: FC<{
|
||||
)}
|
||||
|
||||
{formattedExifData.exposureBias && (
|
||||
<div className="flex items-center gap-2 rounded-md bg-white/10 px-2 py-1">
|
||||
<div className="flex h-6 items-center gap-2 rounded-md bg-white/10 px-2">
|
||||
<MaterialSymbolsExposure className="text-sm text-white/70" />
|
||||
<span className="text-xs">
|
||||
{formattedExifData.exposureBias}
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { ArrayBufferTarget, Muxer } from 'mp4-muxer'
|
||||
|
||||
import { isSafari } from './device-viewport'
|
||||
import { LRUCache } from './lru-cache'
|
||||
|
||||
@@ -54,18 +56,27 @@ export function isVideoConversionSupported(): boolean {
|
||||
function convertVideoWithWebCodecs(
|
||||
videoUrl: string,
|
||||
onProgress?: (progress: ConversionProgress) => void,
|
||||
preferMp4 = true, // 新增参数:是否优先选择MP4格式
|
||||
preferMp4 = true,
|
||||
): Promise<ConversionResult> {
|
||||
return new Promise((resolve) => {
|
||||
const composeVideo = async () => {
|
||||
let muxer: Muxer<ArrayBufferTarget> | null = null
|
||||
let encoder: VideoEncoder | null = null
|
||||
let conversionHasFailed = false
|
||||
|
||||
const cleanup = () => {
|
||||
if (encoder?.state !== 'closed') encoder?.close()
|
||||
muxer = null
|
||||
encoder = null
|
||||
}
|
||||
|
||||
const startConversion = async () => {
|
||||
try {
|
||||
onProgress?.({
|
||||
isConverting: true,
|
||||
progress: 0,
|
||||
message: '正在初始化视频转换器...',
|
||||
message: 'Initializing video converter...',
|
||||
})
|
||||
|
||||
// 创建视频元素来读取源视频
|
||||
const video = document.createElement('video')
|
||||
video.crossOrigin = 'anonymous'
|
||||
video.muted = true
|
||||
@@ -74,267 +85,170 @@ function convertVideoWithWebCodecs(
|
||||
onProgress?.({
|
||||
isConverting: true,
|
||||
progress: 10,
|
||||
message: '正在加载视频文件...',
|
||||
message: 'Loading video file...',
|
||||
})
|
||||
|
||||
// 等待视频加载
|
||||
await new Promise<void>((videoResolve, videoReject) => {
|
||||
video.onloadedmetadata = () => videoResolve()
|
||||
video.onerror = () => videoReject(new Error('Failed to load video'))
|
||||
video.onerror = (e) =>
|
||||
videoReject(new Error(`Failed to load video metadata: ${e}`))
|
||||
video.src = videoUrl
|
||||
})
|
||||
|
||||
const { videoWidth, videoHeight, duration } = video
|
||||
const selectedFrameRate = 30 // 固定使用30fps
|
||||
if (!duration || !Number.isFinite(duration)) {
|
||||
throw new Error(
|
||||
'Could not determine video duration or duration is not finite.',
|
||||
)
|
||||
}
|
||||
const frameRate = 30 // Desired frame rate
|
||||
|
||||
console.info(
|
||||
`Original video: ${videoWidth}x${videoHeight}, duration: ${duration}s`,
|
||||
`Original video: ${videoWidth}x${videoHeight}, duration: ${duration.toFixed(
|
||||
2,
|
||||
)}s`,
|
||||
)
|
||||
|
||||
let mimeType = 'video/webm; codecs=vp9'
|
||||
let codec = 'vp09.00.10.08' // VP9, profile 0, level 1.0, 8-bit
|
||||
let outputFormat = 'WebM'
|
||||
|
||||
if (preferMp4) {
|
||||
const avcConfigs = [
|
||||
// From highest quality/level to lowest
|
||||
{ codec: 'avc1.640033', name: 'H.264 High @L5.1' }, // 4K+
|
||||
{ codec: 'avc1.64002A', name: 'H.264 High @L4.2' }, // 1080p
|
||||
{ codec: 'avc1.4D4029', name: 'H.264 Main @L4.1' }, // 1080p
|
||||
{ codec: 'avc1.42E01F', name: 'H.264 Baseline @L3.1' }, // 720p
|
||||
]
|
||||
|
||||
for (const config of avcConfigs) {
|
||||
if (
|
||||
await VideoEncoder.isConfigSupported({
|
||||
codec: config.codec,
|
||||
width: videoWidth,
|
||||
height: videoHeight,
|
||||
})
|
||||
) {
|
||||
mimeType = `video/mp4; codecs=${config.codec}`
|
||||
codec = config.codec
|
||||
outputFormat = 'MP4'
|
||||
console.info(
|
||||
`Using supported codec: ${config.name} (${config.codec})`,
|
||||
)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (outputFormat === 'WebM' && preferMp4) {
|
||||
console.warn(
|
||||
'Could not find a supported MP4 codec for this resolution. Falling back to WebM.',
|
||||
)
|
||||
}
|
||||
|
||||
console.info(`Target format: ${outputFormat} (${codec})`)
|
||||
|
||||
muxer = new Muxer({
|
||||
target: new ArrayBufferTarget(),
|
||||
video: {
|
||||
codec: outputFormat === 'MP4' ? 'avc' : 'vp9',
|
||||
width: videoWidth,
|
||||
height: videoHeight,
|
||||
frameRate,
|
||||
},
|
||||
fastStart: 'fragmented',
|
||||
firstTimestampBehavior: 'offset',
|
||||
})
|
||||
|
||||
encoder = new VideoEncoder({
|
||||
output: (chunk, meta) => {
|
||||
if (conversionHasFailed) return
|
||||
muxer!.addVideoChunk(chunk, meta)
|
||||
},
|
||||
error: (e) => {
|
||||
if (conversionHasFailed) return
|
||||
conversionHasFailed = true
|
||||
console.error('VideoEncoder error:', e)
|
||||
resolve({ success: false, error: e.message })
|
||||
},
|
||||
})
|
||||
encoder.configure({
|
||||
codec,
|
||||
width: videoWidth,
|
||||
height: videoHeight,
|
||||
bitrate: 5_000_000, // 5 Mbps
|
||||
framerate: frameRate,
|
||||
})
|
||||
|
||||
const totalFrames = Math.floor(duration * frameRate)
|
||||
|
||||
onProgress?.({
|
||||
isConverting: true,
|
||||
progress: 20,
|
||||
message: '正在提取视频帧...',
|
||||
message: 'Starting conversion...',
|
||||
})
|
||||
|
||||
// 创建Canvas用于录制
|
||||
const canvas = document.createElement('canvas')
|
||||
canvas.width = videoWidth
|
||||
canvas.height = videoHeight
|
||||
const ctx = canvas.getContext('2d')
|
||||
|
||||
if (!ctx) {
|
||||
throw new Error('无法创建Canvas上下文')
|
||||
}
|
||||
|
||||
// 提取帧 - 按固定帧率提取视频的每一帧
|
||||
const totalFrames = Math.floor(duration * selectedFrameRate)
|
||||
const frameInterval = 1 / selectedFrameRate // 每帧的时间间隔(秒)
|
||||
|
||||
interface Frame {
|
||||
timestamp: number
|
||||
canvas: HTMLCanvasElement
|
||||
}
|
||||
|
||||
const frames: Frame[] = []
|
||||
|
||||
const frameInterval = 1 / frameRate
|
||||
for (let i = 0; i < totalFrames; i++) {
|
||||
const timestamp = i * frameInterval
|
||||
|
||||
// 确保不超过视频总时长
|
||||
if (timestamp >= duration) break
|
||||
|
||||
video.currentTime = timestamp
|
||||
|
||||
// 等待视频跳转到指定时间
|
||||
await new Promise<void>((frameResolve) => {
|
||||
const onSeeked = () => {
|
||||
video.removeEventListener('seeked', onSeeked)
|
||||
frameResolve()
|
||||
}
|
||||
|
||||
const onTimeUpdate = () => {
|
||||
if (Math.abs(video.currentTime - timestamp) < 0.1) {
|
||||
video.removeEventListener('timeupdate', onTimeUpdate)
|
||||
frameResolve()
|
||||
}
|
||||
}
|
||||
|
||||
video.addEventListener('seeked', onSeeked)
|
||||
video.addEventListener('timeupdate', onTimeUpdate)
|
||||
|
||||
// 超时保护
|
||||
setTimeout(() => {
|
||||
video.removeEventListener('seeked', onSeeked)
|
||||
video.removeEventListener('timeupdate', onTimeUpdate)
|
||||
frameResolve()
|
||||
}, 1000)
|
||||
})
|
||||
|
||||
// 绘制当前帧到Canvas
|
||||
ctx.drawImage(video, 0, 0, videoWidth, videoHeight)
|
||||
|
||||
// 创建帧的Canvas副本
|
||||
const frameCanvas = document.createElement('canvas')
|
||||
frameCanvas.width = videoWidth
|
||||
frameCanvas.height = videoHeight
|
||||
const frameCtx = frameCanvas.getContext('2d')
|
||||
|
||||
if (frameCtx) {
|
||||
frameCtx.drawImage(canvas, 0, 0)
|
||||
|
||||
frames.push({
|
||||
timestamp: timestamp * 1000000, // 转换为微秒
|
||||
canvas: frameCanvas,
|
||||
})
|
||||
if (conversionHasFailed) {
|
||||
console.warn('Aborting conversion due to encoder error.')
|
||||
return
|
||||
}
|
||||
|
||||
// 更新提取进度
|
||||
const extractProgress = 20 + ((i + 1) / totalFrames) * 30
|
||||
const time = i * frameInterval
|
||||
video.currentTime = time
|
||||
await new Promise((r) => (video.onseeked = r))
|
||||
|
||||
const frame = new VideoFrame(video, {
|
||||
timestamp: time * 1_000_000,
|
||||
duration: frameInterval * 1_000_000,
|
||||
})
|
||||
|
||||
await encoder.encode(frame)
|
||||
frame.close()
|
||||
|
||||
const progress = 20 + ((i + 1) / totalFrames) * 70
|
||||
onProgress?.({
|
||||
isConverting: true,
|
||||
progress: extractProgress,
|
||||
message: `正在提取视频帧... ${i + 1}/${totalFrames}`,
|
||||
progress,
|
||||
message: `Converting... ${i + 1}/${totalFrames} frames`,
|
||||
})
|
||||
}
|
||||
|
||||
if (frames.length === 0) {
|
||||
throw new Error('没有可用的帧来合成视频')
|
||||
}
|
||||
if (conversionHasFailed) return
|
||||
|
||||
await encoder.flush()
|
||||
muxer.finalize()
|
||||
|
||||
const { buffer } = muxer.target
|
||||
const blob = new Blob([buffer], { type: mimeType })
|
||||
const url = URL.createObjectURL(blob)
|
||||
|
||||
onProgress?.({
|
||||
isConverting: true,
|
||||
progress: 50,
|
||||
message: '正在检测编码器支持...',
|
||||
isConverting: false,
|
||||
progress: 100,
|
||||
message: 'Conversion complete',
|
||||
})
|
||||
|
||||
// 检查浏览器支持的MIME类型,优先选择用户偏好
|
||||
let mimeType = 'video/webm;codecs=vp9'
|
||||
let outputFormat = 'WebM'
|
||||
|
||||
if (preferMp4) {
|
||||
// 尝试MP4格式
|
||||
const mp4Types = [
|
||||
'video/mp4;codecs=avc1.64002A', // H.264 High Profile
|
||||
'video/mp4;codecs=avc1.4D4029', // H.264 Main Profile
|
||||
'video/mp4;codecs=avc1.42E01E', // H.264 Baseline
|
||||
'video/mp4',
|
||||
]
|
||||
|
||||
for (const type of mp4Types) {
|
||||
if (MediaRecorder.isTypeSupported(type)) {
|
||||
mimeType = type
|
||||
outputFormat = 'MP4'
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 如果MP4不支持或不偏好MP4,使用WebM
|
||||
if (outputFormat !== 'MP4') {
|
||||
const webmTypes = [
|
||||
'video/webm;codecs=vp9',
|
||||
'video/webm;codecs=vp8',
|
||||
'video/webm',
|
||||
]
|
||||
|
||||
for (const type of webmTypes) {
|
||||
if (MediaRecorder.isTypeSupported(type)) {
|
||||
mimeType = type
|
||||
outputFormat = 'WebM'
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
console.info(`Using MediaRecorder with mimeType: ${mimeType}`)
|
||||
console.info(`Output format: ${outputFormat}`)
|
||||
|
||||
onProgress?.({
|
||||
isConverting: true,
|
||||
progress: 60,
|
||||
message: `正在使用 ${outputFormat} 编码器合成视频...`,
|
||||
})
|
||||
|
||||
// 设置MediaRecorder
|
||||
const stream = canvas.captureStream(selectedFrameRate)
|
||||
const mediaRecorder = new MediaRecorder(stream, {
|
||||
mimeType,
|
||||
videoBitsPerSecond: 5000000, // 5Mbps for good quality
|
||||
})
|
||||
|
||||
const chunks: Blob[] = []
|
||||
|
||||
return new Promise<void>((composeResolve, composeReject) => {
|
||||
mediaRecorder.ondataavailable = (event) => {
|
||||
if (event.data.size > 0) {
|
||||
chunks.push(event.data)
|
||||
}
|
||||
}
|
||||
|
||||
mediaRecorder.onstop = () => {
|
||||
const blob = new Blob(chunks, { type: mimeType })
|
||||
const url = URL.createObjectURL(blob)
|
||||
|
||||
onProgress?.({
|
||||
isConverting: false,
|
||||
progress: 100,
|
||||
message: '转换完成',
|
||||
})
|
||||
|
||||
resolve({
|
||||
success: true,
|
||||
videoUrl: url,
|
||||
convertedSize: blob.size,
|
||||
method: 'webcodecs',
|
||||
})
|
||||
composeResolve()
|
||||
}
|
||||
|
||||
mediaRecorder.onerror = (event) => {
|
||||
console.error('MediaRecorder error:', event)
|
||||
resolve({
|
||||
success: false,
|
||||
error: '录制过程中发生错误',
|
||||
})
|
||||
composeReject(new Error('录制过程中发生错误'))
|
||||
}
|
||||
|
||||
// 开始录制
|
||||
mediaRecorder.start(100) // 每100ms收集一次数据
|
||||
|
||||
let frameIndex = 0
|
||||
const frameDuration = 1000 / selectedFrameRate // 毫秒
|
||||
|
||||
const renderFrame = () => {
|
||||
if (frameIndex >= frames.length) {
|
||||
// 录制完成
|
||||
mediaRecorder.stop()
|
||||
return
|
||||
}
|
||||
|
||||
const frame = frames[frameIndex]
|
||||
|
||||
// 绘制帧到Canvas
|
||||
ctx.clearRect(0, 0, canvas.width, canvas.height)
|
||||
ctx.drawImage(frame.canvas, 0, 0)
|
||||
|
||||
// 更新进度
|
||||
const progress = 60 + ((frameIndex + 1) / frames.length) * 30
|
||||
onProgress?.({
|
||||
isConverting: true,
|
||||
progress,
|
||||
message: `正在合成视频... ${frameIndex + 1}/${frames.length}`,
|
||||
})
|
||||
|
||||
frameIndex++
|
||||
|
||||
// 使用requestAnimationFrame和setTimeout来控制帧率
|
||||
if (frameIndex < frames.length) {
|
||||
setTimeout(() => {
|
||||
requestAnimationFrame(renderFrame)
|
||||
}, frameDuration)
|
||||
} else {
|
||||
// 最后一帧,停止录制
|
||||
setTimeout(() => {
|
||||
mediaRecorder.stop()
|
||||
}, frameDuration)
|
||||
}
|
||||
}
|
||||
|
||||
// 开始渲染第一帧
|
||||
requestAnimationFrame(renderFrame)
|
||||
resolve({
|
||||
success: true,
|
||||
videoUrl: url,
|
||||
convertedSize: blob.size,
|
||||
method: 'webcodecs',
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Video conversion failed:', error)
|
||||
resolve({
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : '视频转换失败',
|
||||
error:
|
||||
error instanceof Error ? error.message : 'Video conversion failed',
|
||||
})
|
||||
} finally {
|
||||
cleanup()
|
||||
}
|
||||
}
|
||||
|
||||
composeVideo()
|
||||
startConversion()
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
8117
pnpm-lock.yaml
generated
8117
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user