fix(video): develop

This commit is contained in:
zhangheng
2026-02-12 17:30:29 +08:00
parent 311a92f3fd
commit 6a5dbd776f
25 changed files with 229 additions and 54 deletions

View File

@@ -6,6 +6,7 @@ import {
Card,
Container,
FileInput,
Flex,
Group,
Image,
Loader,
@@ -25,6 +26,7 @@ import {
import { useEffect, useRef, useState } from "react"
import { appendCapped } from "./cap"
import type { WorkerResponse } from "./ffmpeg.worker"
import { getServerImage } from "@/components/label/api/label"
export default function VideoFrameExtractor() {
const [loaded, setLoaded] = useState(false)
@@ -155,6 +157,69 @@ export default function VideoFrameExtractor() {
}
}, [])
/**
* 将视频 Base64 转换为 File 对象
* @param {string} base64String - 视频 Base64 字符串
* @param {string} fileName - 文件名 (例如 'video.mp4')
* @returns {File}
*/
function videoBase64ToFile(base64String: string, fileName: string) {
// 1. 提取 MIME 类型和数据
const parts = base64String.split(",")
const mime = parts[0].match(/:(.*?);/)?.[1] // 可能是 video/mp4, video/webm 等
// 2. 解码
const bstr = atob(parts[1])
let n = bstr.length
const u8arr = new Uint8Array(n)
// 3. 填充字节数组
while (n--) {
u8arr[n] = bstr.charCodeAt(n)
}
// 4. 返回 File 对象
return new File([u8arr], fileName, { type: mime })
}
const getServerVideo = async () => {
const params = {
data_names: ["6874b86e341d1d0e4c75d0d5.h264"],
data_type: 0,
project_id: 9,
}
let text
const response = await getServerImage(params)
text = `data:video/h264;base64,${response}`
const fileRes = videoBase64ToFile(text, "6874b86e341d1d0e4c75d0d5.h264")
// const videoFile = new File([response], "6874b86e341d1d0e4c75d0d5.264", {
// type: "video/264",
// })
setFile(fileRes)
}
function downloadFile(file: File | null) {
if (!file) return
// 创建一个临时的 DOM URL 指向内存中的文件
const url = URL.createObjectURL(file)
// 创建一个隐藏的 a 标签
const a = document.createElement("a")
a.style.display = "none"
a.href = url
a.download = file.name // 设置下载后的文件名
// 将标签添加到文档中并触发点击
document.body.appendChild(a)
a.click()
// 清理工作:移除标签并释放内存 URL
document.body.removeChild(a)
window.URL.revokeObjectURL(url)
}
const processVideo = () => {
if (!file || !loaded || !workerRef.current) return
@@ -215,11 +280,21 @@ export default function VideoFrameExtractor() {
)}
<Card withBorder shadow="sm" p="lg" radius="md">
<Flex gap={"md"}>
<Button onClick={getServerVideo}>getServerVideo</Button>
<Button
onClick={() => {
downloadFile(file)
}}>
downloadFile
</Button>
</Flex>
<Stack gap="md">
<FileInput
label="Select Video File"
placeholder="Click to select MP4, WebM..."
accept="video/*"
// accept="video/*"
leftSection={<IconMovie size={16} />}
value={file}
onChange={setFile}

View File

@@ -61,7 +61,7 @@ ctx.onmessage = async (event: MessageEvent<WorkerMessage>) => {
})
const mountDir = "/input_mount"
const inputPath = `${mountDir}/${file.name}`
let inputPath = `${mountDir}/${file.name}`
const mountInput = async () => {
await ffmpeg.createDir(mountDir)
@@ -69,12 +69,64 @@ ctx.onmessage = async (event: MessageEvent<WorkerMessage>) => {
}
const unmountInput = async () => {
await ffmpeg.unmount(mountDir).catch(() => {})
await ffmpeg.deleteDir(mountDir).catch(() => {})
await ffmpeg.unmount(mountDir).catch((err) => {
console.log("unmounterr", err)
})
await ffmpeg.deleteDir(mountDir).catch((err) => {
console.log("deleteDir", err)
})
}
await mountInput()
const isRawH264 =
file.name.toLowerCase().endsWith(".264") ||
file.name.toLowerCase().endsWith(".h264")
if (isRawH264) {
ctx.postMessage({
type: "LOG",
message: "[fix] 检测到 H.264 裸流,正在封装为 MP4 以修复索引...",
})
const remuxedPath = "fixed_container.mp4"
try {
// -f h264: 强制指定输入格式为 h264 裸流
// -i ... : 输入
// -c copy: 直接复制流,不转码(速度极快)
// -f mp4 : 输出为 MP4 容器
const ret = await ffmpeg.exec([
"-f",
"h264",
"-i",
inputPath,
"-c",
"copy",
remuxedPath,
])
if (ret === 0) {
ctx.postMessage({
type: "LOG",
message: "[fix] 封装成功,切换输入源",
})
// 关键:将后续操作的输入路径指向新生成的 MP4
inputPath = remuxedPath
} else {
ctx.postMessage({
type: "LOG",
message: "[fix] 封装失败,尝试继续使用原始文件...",
})
}
} catch (e) {
ctx.postMessage({
type: "LOG",
message: `[fix] 预处理出错: ${String(e)}`,
})
}
}
try {
const normalizeError = (e: unknown) => {
if (typeof e === "string") return e
@@ -145,7 +197,9 @@ ctx.onmessage = async (event: MessageEvent<WorkerMessage>) => {
message: `[probe] ffprobe failed: ${normalizeError(e)}`,
})
} finally {
await ffmpeg.deleteFile(durationProbePath).catch(() => {})
await ffmpeg.deleteFile(durationProbePath).catch((err) => {
console.log("deleteFile error", err)
})
}
if (!durationSec) {
@@ -388,7 +442,9 @@ ctx.onmessage = async (event: MessageEvent<WorkerMessage>) => {
)}`,
})
}
await ffmpeg.deleteFile(outputName).catch(() => {})
await ffmpeg.deleteFile(outputName).catch((err) => {
console.log("deleteFile error", err)
})
index += 1
continue
}
@@ -397,7 +453,9 @@ ctx.onmessage = async (event: MessageEvent<WorkerMessage>) => {
const raw = (await ffmpeg.readFile(outputName)) as Uint8Array
const copy = raw.slice()
sentBytes += copy.byteLength
await ffmpeg.deleteFile(outputName).catch(() => {})
await ffmpeg.deleteFile(outputName).catch((err) => {
console.log("deleteFile error", err)
})
ctx.postMessage(
{
@@ -484,13 +542,10 @@ ctx.onmessage = async (event: MessageEvent<WorkerMessage>) => {
})
}
} catch (e2) {
let flag = typeof e2 === "string"
ctx.postMessage({
type: "LOG",
message: `[fatal] reload failed: ${
typeof e2 === "string"
? e2
: (e2 as any)?.message || String(e2)
}`,
message: `[fatal] reload failed: ${flag ? e2 : (e2 as any)?.message || String(e2)}`,
})
}

View File

@@ -1,5 +1,9 @@
// src/workers/ffmpeg.worker.ts
import type { LibAVWrapper, WorkerMessage, WorkerResponse } from "./types/libav"
// app/component/label/video/libav.worker.ts
import type {
LibAVWrapper,
WorkerMessage,
WorkerResponse,
} from "@/app/component/label/video/types/libav"
const ctx: Worker = self as any
let libav: any = null // Use any to access full API

View File

@@ -1,7 +1,10 @@
"use client"
import { ChangeEvent, useEffect, useRef, useState } from "react"
import type { WorkerMessage, WorkerResponse } from "./types/libav"
import type {
WorkerMessage,
WorkerResponse,
} from "@/app/component/label/video/types/libav"
export default function Home() {
const workerRef = useRef<Worker | null>(null)

View File

@@ -500,7 +500,7 @@ export default function ProjectAuditPage() {
onChange={(e) =>
setFilters((s) => ({
...s,
create_at_start: e.currentTarget.value,
create_at_start: e.target.value,
}))
}
/>
@@ -510,12 +510,12 @@ export default function ProjectAuditPage() {
radius="xs"
type="date"
value={filters.create_at_end}
onChange={(e) =>
onChange={(e) => {
setFilters((s) => ({
...s,
create_at_end: e.currentTarget.value,
create_at_end: e.target.value,
}))
}
}}
/>
</SimpleGrid>
</Stack>