这次使用 ffmpeg 创建视频的宫格预览图。
主要使用 ffmpeg 的 -ss 参数,获取指定秒数的帧图片。
在网上看到一个使用 bash 来创建的脚本(链接:ffmpeg快速生成预览贴图)。
由于 node:alpine 不支持 bash 只有 sh,当使用了 bash 的一些语法时,sh 会报错。要支持 bash 需要 apk install bash,会稍微加大 docker 的镜像体积。参考链接:Docker: How to use bash with an Alpine based docker image?
所以将 bash 脚本使用 js 来实现一遍。在原脚本基础上加入了一些自己的逻辑。
node:alpine /bin 目录 grep
/ $ ls /bin | grep sh
ash
fdflush
sh
将 bash 脚本转译成 js 脚本
// https://fooy.github.io/posts/ffmpeg-fast-mosaic-preview/
import { exec, execSync } from 'child_process'
export const createVideoPreview = (INFILE = '', OUTFILE = '') => {
const FFMPEG = 'ffmpeg -nostdin -loglevel error -nostats'
if (!INFILE) {
console.error('Input file not provided')
process.exit(1)
}
if (!OUTFILE) {
OUTFILE = `${INFILE}.webp`
}
const DURATION = execSync(`ffprobe -loglevel error -show_entries format=duration -of default=nw=1:nk=1 "${INFILE}"`)
.toString()
.trim()
const DURATION_SECONDS = Math.floor(parseFloat(DURATION))
if (isNaN(DURATION_SECONDS)) {
console.error('Invalid duration')
process.exit(1)
}
console.log(`Duration is ${DURATION_SECONDS} s`)
const COL = 4
let N_SHOT = COL * 7
let SEG = Math.floor(DURATION_SECONDS / N_SHOT)
if (SEG < 5) {
SEG = 5
N_SHOT = DURATION_SECONDS / 5
}
let SHOTS = Array.from({ length: N_SHOT + 1 }, (_, i) => i * SEG)
if (DURATION_SECONDS >= N_SHOT) {
SHOTS.shift()
if (SEG >= 300) {
SHOTS.unshift(5, 30, 90, 180)
} else if (SEG >= 150) {
SHOTS.unshift(5, 30, 60, 100)
} else if (SEG >= 60) {
SHOTS.unshift(5, 10, 20, 40)
}
}
console.log(`SEG: ${SEG}, Screenshots: ${SHOTS.join(' ')}`)
const SCALE = `${1920 * 2}:-1`
const PADS = Array.from({ length: 26 }, (_, i) => String.fromCharCode(i + 97)).concat(
Array.from({ length: 26 }, (_, i) => String.fromCharCode(i + 65)),
)
const N_FRAME = SHOTS.length
let OPT_SEEK = ''
let OPT_FILTER = ''
let i = 0
for (const t of SHOTS) {
OPT_SEEK += ` -ss ${t} -i '${INFILE}'`
OPT_FILTER += `[${i}:v]trim=start_frame=1:end_frame=2[${PADS[i]}];`
i++
}
OPT_FILTER += `${PADS.slice(0, i)
.map((item) => `[${item}]`)
.join(' ')}concat=n=${i},tile=${COL}x${Math.ceil(N_FRAME / COL)},scale=${SCALE}`
return exec(`${FFMPEG} ${OPT_SEEK} -filter_complex '${OPT_FILTER}' -y '${OUTFILE}'`)
}
开发
explorer-manage
调用 ffmpeg 生成预览图的脚步写好之后就是常规操作。
为 explorer-manage/src/ffmpeg/main.mjs 新增一个方法。
由于 createVideoPreview 调用时是返回一个可读流。需要监听 on('data') 获取数据, on('end') 执行结束。
这里使用 Promise 封装一个调用 createVideoPreview 返回数据流的 getVideoPreviewImg 方法。默认预览图存放位置与当前视频路径相同。
import { createVideoPreview } from "./create-video-preview.mjs"
/**
*
* @param {string} video_path
* @returns {Promise<string>}
*/
export const createVideoPreviewImg = (video_path) => {
const join_video_path = formatPath(video_path)
const stream = createVideoPreview(join_video_path)
return new Promise((res, rej) => {
stream.stdout.on('data', (data) => console.log(data.toString()))
stream.stdout.on('end', () => {
const done_path = `${resetPath(join_video_path)}.webp`
console.log('create preview done', done_path)
res({ video_preview_path: done_path })
})
stream.stdout.on('error', rej)
})
}
explorer
依旧使用 server action 调用 getVideoPreviewImg 方法。
server action 文件添加方法
import { createVideoPreviewImg } from '@/explorer-manager/src/ffmpeg/main.mjs'
export const createVideoPreviewAction: typeof createVideoPreviewImg = async (path) => {
return await createVideoPreviewImg(path)
}
在 video-info-modal 抽屉弹窗的 extra 位置插入一个 CreatePreviewBtn 的按钮组件。当点击按钮时创建该弹出窗视频的预览图。
CreatePreviewBtn 按钮组件
这里使用了 react 提供的 useFormStatus hooks(需要内嵌在一个 form 标签内,所以外部的 Drawer 的 extra 使用一个 form 标签进行封装,action 属性为 createVideoPreviewAction 方法)。pending 属性为当前 form 是否完成请求操作。为 Button 添加等待创建图片的 loading 状态。
const CreatePreviewBtn: React.FC = () => {
const { pending } = useFormStatus()
const { update } = useUpdateReaddirList()
useUpdateEffect(() => {
if (!pending) {
setTimeout(() => {
update()
}, 1000)
}
}, [pending])
return (
<Button loading={pending} htmlType="submit">
创建预览图
</Button>
)
}
video-info-modal 弹窗 Drawer extra 部分
...
<Drawer
title="视频信息"
...
extra={
<form action={() => createVideoPreview(video_path)}>
<CreatePreviewBtn />
</form>
}
>
<VideoInfoItem />
</Drawer>
...
**粗体** _斜体_ [链接](http://example.com) `代码` - 列表 > 引用
。你还可以使用@
来通知其他用户。