这次使用 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>
...

效果

git-repo

yangWs29/share-explorer


寒露
18 声望0 粉丝