diff --git a/package-lock.json b/package-lock.json index 75e67e5..695771b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -15,6 +15,9 @@ "@emotion/server": "^11.11.0", "@emotion/styled": "^11.11.0", "@excalidraw/excalidraw": "^0.17.2", + "@ffmpeg/ffmpeg": "^0.12.6", + "@ffmpeg/types": "^0.12.2", + "@ffmpeg/util": "^0.12.1", "@homegrown/word-counter": "^0.1.7", "@leenguyen/react-flip-clock-countdown": "^1.5.0", "@mui/icons-material": "^5.11.16", @@ -38,11 +41,13 @@ "dompurify": "^3.0.6", "echarts": "^5.4.3", "eslint-config-next": "^14.0.4", + "file-saver": "^2.0.5", "html-entities": "^2.4.0", "ip": "^1.1.8", "js-yaml": "^4.1.0", "jsfuck": "^0.4.0", "json-2-csv": "^5.0.1", + "jszip": "^3.10.1", "less": "^4.2.0", "marked": "^11.1.1", "next": "^14.0.4", @@ -89,6 +94,7 @@ "@types/big-integer": "^0.0.31", "@types/crypto-js": "^4.2.0", "@types/dompurify": "^3.0.5", + "@types/file-saver": "^2.0.7", "@types/ip": "^1.1.3", "@types/less": "^3.0.6", "@types/node": "20.1.5", @@ -1417,6 +1423,33 @@ "react-dom": "^17.0.2 || ^18.2.0" } }, + "node_modules/@ffmpeg/ffmpeg": { + "version": "0.12.6", + "resolved": "https://registry.npmmirror.com/@ffmpeg/ffmpeg/-/ffmpeg-0.12.6.tgz", + "integrity": "sha512-4CuXDaqrCga5qBwVtiDDR45y65OGPYZd7VzwGCGz3QLdrQH7xaLYEjU19XL4DTCL0WnTSH8752b8Atyb1SiiLw==", + "dependencies": { + "@ffmpeg/types": "^0.12.0" + }, + "engines": { + "node": ">=18.x" + } + }, + "node_modules/@ffmpeg/types": { + "version": "0.12.2", + "resolved": "https://registry.npmmirror.com/@ffmpeg/types/-/types-0.12.2.tgz", + "integrity": "sha512-NJtxwPoLb60/z1Klv0ueshguWQ/7mNm106qdHkB4HL49LXszjhjCCiL+ldHJGQ9ai2Igx0s4F24ghigy//ERdA==", + "engines": { + "node": ">=16.x" + } + }, + "node_modules/@ffmpeg/util": { + "version": "0.12.1", + "resolved": "https://registry.npmmirror.com/@ffmpeg/util/-/util-0.12.1.tgz", + "integrity": "sha512-10jjfAKWaDyb8+nAkijcsi9wgz/y26LOc1NKJradNMyCIl6usQcBbhkjX5qhALrSBcOy6TOeksunTYa+a03qNQ==", + "engines": { + "node": ">=18.x" + } + }, "node_modules/@floating-ui/core": { "version": "1.5.2", "resolved": "https://registry.npmmirror.com/@floating-ui/core/-/core-1.5.2.tgz", @@ -2135,6 +2168,12 @@ "resolved": "https://registry.npmmirror.com/@types/extend/-/extend-3.0.4.tgz", "integrity": "sha512-ArMouDUTJEz1SQRpFsT2rIw7DeqICFv5aaVzLSIYMYQSLcwcGOfT3VyglQs/p7K3F7fT4zxr0NWxYZIdifD6dA==" }, + "node_modules/@types/file-saver": { + "version": "2.0.7", + "resolved": "https://registry.npmmirror.com/@types/file-saver/-/file-saver-2.0.7.tgz", + "integrity": "sha512-dNKVfHd/jk0SkR/exKGj2ggkB45MAkzvWCaqLUUgkyjITkGNzH8H+yUwr+BLJUBjZOe9w8X3wgmXhZDRg1ED6A==", + "dev": true + }, "node_modules/@types/hast": { "version": "2.3.8", "resolved": "https://registry.npmmirror.com/@types/hast/-/hast-2.3.8.tgz", @@ -4409,6 +4448,11 @@ "node": "^10.12.0 || >=12.0.0" } }, + "node_modules/file-saver": { + "version": "2.0.5", + "resolved": "https://registry.npmmirror.com/file-saver/-/file-saver-2.0.5.tgz", + "integrity": "sha512-P9bmyZ3h/PRG+Nzga+rbdI4OEpNDzAVyy74uVO9ATgzLK6VtAsYybF/+TOCvrc0MO793d6+42lLyZTw7/ArVzA==" + }, "node_modules/fill-range": { "version": "7.0.1", "resolved": "https://registry.npmmirror.com/fill-range/-/fill-range-7.0.1.tgz", @@ -5119,6 +5163,11 @@ "node": ">=0.10.0" } }, + "node_modules/immediate": { + "version": "3.0.6", + "resolved": "https://registry.npmmirror.com/immediate/-/immediate-3.0.6.tgz", + "integrity": "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==" + }, "node_modules/import-fresh": { "version": "3.3.0", "resolved": "https://registry.npmmirror.com/import-fresh/-/import-fresh-3.3.0.tgz", @@ -5757,6 +5806,49 @@ "node": ">=4.0" } }, + "node_modules/jszip": { + "version": "3.10.1", + "resolved": "https://registry.npmmirror.com/jszip/-/jszip-3.10.1.tgz", + "integrity": "sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g==", + "dependencies": { + "lie": "~3.3.0", + "pako": "~1.0.2", + "readable-stream": "~2.3.6", + "setimmediate": "^1.0.5" + } + }, + "node_modules/jszip/node_modules/isarray": { + "version": "1.0.0", + "resolved": "https://registry.npmmirror.com/isarray/-/isarray-1.0.0.tgz", + "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==" + }, + "node_modules/jszip/node_modules/pako": { + "version": "1.0.11", + "resolved": "https://registry.npmmirror.com/pako/-/pako-1.0.11.tgz", + "integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==" + }, + "node_modules/jszip/node_modules/readable-stream": { + "version": "2.3.8", + "resolved": "https://registry.npmmirror.com/readable-stream/-/readable-stream-2.3.8.tgz", + "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", + "dependencies": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + }, + "node_modules/jszip/node_modules/string_decoder": { + "version": "1.1.1", + "resolved": "https://registry.npmmirror.com/string_decoder/-/string_decoder-1.1.1.tgz", + "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "dependencies": { + "safe-buffer": "~5.1.0" + } + }, "node_modules/keyv": { "version": "4.5.4", "resolved": "https://registry.npmmirror.com/keyv/-/keyv-4.5.4.tgz", @@ -5844,6 +5936,14 @@ "node": ">= 0.8.0" } }, + "node_modules/lie": { + "version": "3.3.0", + "resolved": "https://registry.npmmirror.com/lie/-/lie-3.3.0.tgz", + "integrity": "sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==", + "dependencies": { + "immediate": "~3.0.5" + } + }, "node_modules/lilconfig": { "version": "3.0.0", "resolved": "https://registry.npmmirror.com/lilconfig/-/lilconfig-3.0.0.tgz", @@ -8567,6 +8667,11 @@ "node": ">= 0.4" } }, + "node_modules/setimmediate": { + "version": "1.0.5", + "resolved": "https://registry.npmmirror.com/setimmediate/-/setimmediate-1.0.5.tgz", + "integrity": "sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==" + }, "node_modules/shebang-command": { "version": "2.0.0", "resolved": "https://registry.npmmirror.com/shebang-command/-/shebang-command-2.0.0.tgz", diff --git a/package.json b/package.json index e823abe..34de2c1 100644 --- a/package.json +++ b/package.json @@ -27,6 +27,9 @@ "@emotion/server": "^11.11.0", "@emotion/styled": "^11.11.0", "@excalidraw/excalidraw": "^0.17.2", + "@ffmpeg/ffmpeg": "^0.12.6", + "@ffmpeg/types": "^0.12.2", + "@ffmpeg/util": "^0.12.1", "@homegrown/word-counter": "^0.1.7", "@leenguyen/react-flip-clock-countdown": "^1.5.0", "@mui/icons-material": "^5.11.16", @@ -50,11 +53,13 @@ "dompurify": "^3.0.6", "echarts": "^5.4.3", "eslint-config-next": "^14.0.4", + "file-saver": "^2.0.5", "html-entities": "^2.4.0", "ip": "^1.1.8", "js-yaml": "^4.1.0", "jsfuck": "^0.4.0", "json-2-csv": "^5.0.1", + "jszip": "^3.10.1", "less": "^4.2.0", "marked": "^11.1.1", "next": "^14.0.4", @@ -101,6 +106,7 @@ "@types/big-integer": "^0.0.31", "@types/crypto-js": "^4.2.0", "@types/dompurify": "^3.0.5", + "@types/file-saver": "^2.0.7", "@types/ip": "^1.1.3", "@types/less": "^3.0.6", "@types/node": "20.1.5", diff --git a/src/components/Frame/index.tsx b/src/components/Frame/index.tsx new file mode 100644 index 0000000..85e17a6 --- /dev/null +++ b/src/components/Frame/index.tsx @@ -0,0 +1,244 @@ +import { FFmpeg } from '@ffmpeg/ffmpeg'; +import { fetchFile, toBlobURL } from '@ffmpeg/util'; +import { Box, Button, Modal, OutlinedInput, Stack } from '@mui/material'; +import { saveAs } from 'file-saver'; +import JSZip from 'jszip'; +import { useEffect, useRef, useState } from 'react'; + +const VideoFrame = () => { + const [video, setVideo] = useState(); + const [interval, setInterval] = useState(3); + const [duration, setDuration] = useState(0); + const [imgs, setImgs] = useState([]); + const [curImg, setCurImg] = useState(''); + const [files, setFiles] = useState([]); + const [loadConfig, setLoadingConfig] = useState(false); + const [fileDown, setFileDown] = useState(false); + const [loadFrame, setLoadFrame] = useState(false); + + const ffmpegRef = useRef(new FFmpeg()); + const messageRef = useRef(null); + + const upload = () => { + const videoInput = document.getElementById('videoframe'); + videoInput?.click(); + }; + + const extract = async () => { + try { + setLoadFrame(true); + const videoEl = document.getElementById('videoEl') as HTMLVideoElement; + const duration = videoEl?.duration || 0; + + setImgs([]); + setFiles([]); + + // 提取视频帧截图 + const ffmpeg = ffmpegRef.current; + await ffmpeg.writeFile('input.mp4', await fetchFile(video)); + await ffmpeg.exec([ + '-i', + 'input.mp4', + '-vf', + `fps=${interval}`, + '-f', + 'image2', + `output_%01d.png`, + ]); + + const images = []; + const files = []; + + for (let i = 0; i < Math.floor(duration * interval); i++) { + const data = await ffmpeg.readFile(`output_${i + 1}.png`); + const url = URL.createObjectURL( + new Blob([data], { type: 'image/png' }) + ); + const file = new File([data], `frame${i + 1}.png`, { + type: 'image/png', + }); + files.push(file); + images.push(url); + } + + setFiles(files); + setImgs(images); + setLoadFrame(false); + if (messageRef.current) messageRef.current.innerHTML = '截图提取完成'; + } catch (error) { + if (messageRef.current) + messageRef.current.innerHTML = `
${error}
`; + setLoadFrame(false); + } + }; + + const loadPlugin = async () => { + setLoadingConfig(true); + const baseURL = 'https://unpkg.com/@ffmpeg/core@0.12.6/dist/umd'; + const ffmpeg = ffmpegRef.current; + ffmpeg.on('log', ({ message }) => { + if (messageRef.current) messageRef.current.innerHTML = message; + }); + await ffmpeg.load({ + coreURL: await toBlobURL(`${baseURL}/ffmpeg-core.js`, 'text/javascript'), + wasmURL: await toBlobURL( + `${baseURL}/ffmpeg-core.wasm`, + 'application/wasm' + ), + }); + setLoadingConfig(false); + }; + + const download = () => { + setFileDown(true); + const zip = new JSZip(); + files.forEach((file) => { + zip.file(file.name, file); + }); + zip + .generateAsync({ type: 'blob' }) + .then((content) => { + saveAs(content, 'frames.zip'); + }) + .finally(() => { + setFileDown(false); + }); + }; + + useEffect(() => { + const videoEl = document.getElementById('videoEl') as HTMLVideoElement; + videoEl?.addEventListener('loadeddata', () => { + const duration = videoEl?.duration || 0; + setDuration(duration); + }); + }, [video]); + + useEffect(() => { + loadPlugin(); + }, []); + + return ( + <> + + + + {!!video && {video.name}} + + {!!video && ( + <> + + + 视频帧设置 + + + 视频帧采样间隔 + { + setInterval(Number(e.target.value) * 1000); + }} + /> + 视频帧数 + { + const v = Number(e.target.value); + setInterval(v >= 0 ? v : interval); + }} + /> + 预计截取 {Math.floor(duration * interval)} 张 + + + + + + {imgs.map((it) => ( + setCurImg(it)} + component='img' + width={409} + src={it} + key={it} + sx={{ cursor: 'pointer' }} + > + ))} + + + + + )} + { + if (e.target.files) { + const file = e.target.files[0]; + setVideo(file); + } + }} + /> + + setCurImg('')}> + setCurImg('')} + src={curImg} + /> + + + ); +}; + +export default VideoFrame; diff --git a/src/pages/videoframe.tsx b/src/pages/videoframe.tsx new file mode 100644 index 0000000..f2946a1 --- /dev/null +++ b/src/pages/videoframe.tsx @@ -0,0 +1,15 @@ +import MainContent from '@/components/MainContent'; +import dynamic from 'next/dynamic'; + +const VideoFrame = () => { + const Frame = dynamic(() => import('@/components/Frame'), { + ssr: false, + }); + return ( + + + + ); +}; + +export default VideoFrame; diff --git a/src/utils/tools.ts b/src/utils/tools.ts index 965a166..e72bac7 100644 --- a/src/utils/tools.ts +++ b/src/utils/tools.ts @@ -416,6 +416,13 @@ export const allTools: Tool[] = [ key: [], subTitle: '视频提取音频小工具', }, + { + label: '视频帧截图', + tags: [Tags.MEDIA], + path: '/videoframe', + key: [], + subTitle: '视频帧截图小工具', + }, { label: 'HTML 格式化', tags: [Tags.FORMAT],