fix(video): develop
This commit is contained in:
@@ -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}
|
||||
|
||||
@@ -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)}`,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user