Parcourir la source

feat: 视频抽帧

shuisheng il y a 1 an
Parent
commit
b54b958c54

+ 2 - 0
package.json

@@ -35,6 +35,8 @@
     }
   },
   "dependencies": {
+    "@ffmpeg/ffmpeg": "^0.12.10",
+    "@ffmpeg/util": "^0.12.1",
     "@vicons/ionicons5": "^0.12.0",
     "@webav/av-recorder": "^0.3.3",
     "axios": "^1.2.1",

+ 23 - 0
pnpm-lock.yaml

@@ -5,6 +5,12 @@ settings:
   excludeLinksFromLockfile: false
 
 dependencies:
+  '@ffmpeg/ffmpeg':
+    specifier: ^0.12.10
+    version: 0.12.10
+  '@ffmpeg/util':
+    specifier: ^0.12.1
+    version: 0.12.1
   '@vicons/ionicons5':
     specifier: ^0.12.0
     version: 0.12.0
@@ -2241,6 +2247,23 @@ packages:
     engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0}
     dev: true
 
+  /@ffmpeg/ffmpeg@0.12.10:
+    resolution: {integrity: sha512-lVtk8PW8e+NUzGZhPTWj2P1J4/NyuCrbDD3O9IGpSeLYtUZKBqZO8CNj1WYGghep/MXoM8e1qVY1GztTkf8YYQ==}
+    engines: {node: '>=18.x'}
+    dependencies:
+      '@ffmpeg/types': 0.12.2
+    dev: false
+
+  /@ffmpeg/types@0.12.2:
+    resolution: {integrity: sha512-NJtxwPoLb60/z1Klv0ueshguWQ/7mNm106qdHkB4HL49LXszjhjCCiL+ldHJGQ9ai2Igx0s4F24ghigy//ERdA==}
+    engines: {node: '>=16.x'}
+    dev: false
+
+  /@ffmpeg/util@0.12.1:
+    resolution: {integrity: sha512-10jjfAKWaDyb8+nAkijcsi9wgz/y26LOc1NKJradNMyCIl6usQcBbhkjX5qhALrSBcOy6TOeksunTYa+a03qNQ==}
+    engines: {node: '>=18.x'}
+    dev: false
+
   /@humanwhocodes/config-array@0.11.8:
     resolution: {integrity: sha512-UybHIJzJnR5Qc/MsD9Kr+RpO2h+/P1GhOwdiLPXK5TWk5sgTdu88bTD9UP+CKbPPh5Rni1u0GjAdYQLemG8g+g==}
     engines: {node: '>=10.10.0'}

+ 4 - 4
src/components/VideoControls/index.vue

@@ -52,15 +52,15 @@
     <div class="right">
       <div
         class="item fps"
-        v-if="props.control?.fps && appStore.videoFps"
+        v-if="props.control?.fps && appStore.videoControlsValue.fps"
       >
-        {{ appStore.videoFps }}帧
+        {{ appStore.videoControlsValue.fps }}帧
       </div>
       <div
         class="item kbs"
-        v-if="props.control?.kbs && appStore.videoKBs"
+        v-if="props.control?.kbs && appStore.videoControlsValue.kbs"
       >
-        {{ appStore.videoKBs }}KB/s
+        {{ appStore.videoControlsValue.kbs }}KB/s
       </div>
       <div
         class="item resolution"

+ 49 - 37
src/hooks/use-play.ts

@@ -93,11 +93,11 @@ export function useFullScreen(video) {
 }
 
 export function useFlvPlay() {
+  const cacheStore = usePiniaCacheStore();
+  const appStore = useAppStore();
   // const flvPlayer = ref<flvJs.Player>();
   const flvPlayer = ref<mpegts.Player>();
   const flvVideoEl = ref<HTMLVideoElement>();
-  const cacheStore = usePiniaCacheStore();
-  const appStore = useAppStore();
   const initRetryMax = 120;
   const retryMax = ref(initRetryMax);
   const retry = ref(0);
@@ -105,8 +105,6 @@ export function useFlvPlay() {
   const retrying = ref(false);
   const flvIsPlaying = ref(false);
 
-  onMounted(() => {});
-
   onUnmounted(() => {
     destroyFlv();
   });
@@ -116,6 +114,10 @@ export function useFlvPlay() {
       flvPlayer.value.destroy();
       flvPlayer.value = undefined;
     }
+    appStore.videoControlsValue.kbs = undefined;
+    appStore.videoControlsValue.fps = undefined;
+    flvIsPlaying.value = false;
+    appStore.playing = false;
     flvVideoEl.value?.remove();
     flvVideoEl.value = undefined;
     clearInterval(retryTimer.value);
@@ -138,16 +140,24 @@ export function useFlvPlay() {
       flvPlayer.value.volume = val / 100;
     }
   }
-  function setPlay(val: boolean) {
-    if (val) {
+  function setPlay() {
+    try {
+      console.log(`开始播放flv,muted:${cacheStore.muted}`);
       flvVideoEl.value?.play();
       flvPlayer.value?.play();
-    } else {
-      flvVideoEl.value?.pause();
-      flvPlayer.value?.pause();
+    } catch (error) {
+      console.error('flv播放失败');
+      console.log(error);
     }
   }
 
+  watch(
+    () => flvIsPlaying.value,
+    (newVal) => {
+      appStore.playing = newVal;
+    }
+  );
+
   watch(
     () => cacheStore.muted,
     (newVal) => {
@@ -165,12 +175,13 @@ export function useFlvPlay() {
   watch(
     () => appStore.playing,
     (newVal) => {
-      setPlay(newVal);
+      if (newVal) {
+        setPlay();
+      }
     }
   );
 
   function startFlvPlay(data: { flvurl: string }) {
-    console.log('startFlvPlay', data.flvurl);
     return new Promise((resolve) => {
       function main() {
         destroyFlv();
@@ -192,7 +203,6 @@ export function useFlvPlay() {
           videoEl.addEventListener('playing', () => {
             console.log('flv-playing');
             flvIsPlaying.value = true;
-            appStore.playing = true;
             retry.value = 0;
             setMuted(cacheStore.muted);
             setVolume(cacheStore.volume);
@@ -222,18 +232,12 @@ export function useFlvPlay() {
           });
           flvPlayer.value.on(mpegts.Events.MEDIA_INFO, (data) => {
             console.log('mpegts.Events.MEDIA_INFO', data);
-            // appStore.videoFps = data?.fps?.toFixed(2);
+            appStore.videoControlsValue.fps = data?.fps?.toFixed(2);
           });
           flvPlayer.value.on(mpegts.Events.STATISTICS_INFO, (data) => {
-            appStore.videoKBs = data?.speed?.toFixed(2);
+            appStore.videoControlsValue.kbs = data?.speed?.toFixed(2);
           });
-          try {
-            console.log(`开始播放flv,muted:${cacheStore.muted}`);
-            flvPlayer.value.play();
-          } catch (error) {
-            console.error('flv播放失败');
-            console.log(error);
-          }
+          setPlay();
         } else {
           console.error('不支持flv');
         }
@@ -246,10 +250,10 @@ export function useFlvPlay() {
 }
 
 export function useHlsPlay() {
-  const hlsPlayer = ref<Player>();
-  const hlsVideoEl = ref<HTMLVideoElement>();
   const cacheStore = usePiniaCacheStore();
   const appStore = useAppStore();
+  const hlsPlayer = ref<Player>();
+  const hlsVideoEl = ref<HTMLVideoElement>();
   const initRetryMax = 120;
   const retryMax = ref(initRetryMax);
   const retry = ref(0);
@@ -268,6 +272,10 @@ export function useHlsPlay() {
       hlsPlayer.value.dispose();
       hlsPlayer.value = undefined;
     }
+    appStore.videoControlsValue.kbs = undefined;
+    appStore.videoControlsValue.fps = undefined;
+    hlsIsPlaying.value = false;
+    appStore.playing = false;
     hlsVideoEl.value?.remove();
     hlsVideoEl.value = undefined;
     clearInterval(retryTimer.value);
@@ -290,16 +298,25 @@ export function useHlsPlay() {
       hlsPlayer.value.volume(val / 100);
     }
   }
-  function setPlay(val: boolean) {
-    if (val) {
+
+  function setPlay() {
+    try {
+      console.log(`开始播放hls,muted:${cacheStore.muted}`);
       hlsVideoEl.value?.play();
       hlsPlayer.value?.play();
-    } else {
-      hlsVideoEl.value?.pause();
-      hlsPlayer.value?.pause();
+    } catch (error) {
+      console.error('hls播放失败');
+      console.log(error);
     }
   }
 
+  watch(
+    () => hlsIsPlaying.value,
+    (newVal) => {
+      appStore.playing = newVal;
+    }
+  );
+
   watch(
     () => cacheStore.muted,
     (newVal) => {
@@ -317,7 +334,9 @@ export function useHlsPlay() {
   watch(
     () => appStore.playing,
     (newVal) => {
-      setPlay(newVal);
+      if (newVal) {
+        setPlay();
+      }
     }
   );
 
@@ -341,13 +360,7 @@ export function useHlsPlay() {
             ],
           },
           function () {
-            try {
-              // console.log(`开始播放hls,muted:${cacheStore.muted}`);
-              hlsPlayer.value?.play();
-            } catch (error) {
-              console.error('hls播放失败');
-              console.log(error);
-            }
+            setPlay();
           }
         );
         hlsPlayer.value?.on('error', () => {
@@ -372,7 +385,6 @@ export function useHlsPlay() {
         hlsPlayer.value?.on('playing', () => {
           console.log('hls-playing');
           hlsIsPlaying.value = true;
-          appStore.playing = true;
           setMuted(cacheStore.muted);
           setVolume(cacheStore.volume);
           retry.value = 0;

+ 0 - 1
src/hooks/use-pull.ts

@@ -220,7 +220,6 @@ export function usePull(roomId: string) {
 
   function handlePlay(data: ILiveRoom) {
     roomLiving.value = true;
-    appStore.playing = false;
     flvurl.value = data.flv_url!;
     hlsurl.value = data.hls_url!;
     function play() {

+ 50 - 35
src/layout/pc/head/index.vue

@@ -42,6 +42,7 @@
             class="item"
             :href="COMMON_URL.admin"
             @click.prevent="openToTarget(COMMON_URL.admin)"
+            v-if="!isMobile()"
           >
             {{ t('layout.liveAdmin') }}
           </a>
@@ -55,20 +56,14 @@
               <div class="txt">new</div>
             </div>
           </a>
-          <!-- <a
-          class="item"
-          :class="{
-            active: router.currentRoute.value.name === routerName.ad,
-          }"
-          href="/ad"
-          @click.prevent="router.push({ name: routerName.ad })"
-        >
-          广告
-        </a> -->
         </div>
       </div>
+
       <div class="right">
-        <Dropdown class="doc">
+        <Dropdown
+          class="doc"
+          v-if="!isMobile()"
+        >
           <template #btn>
             <div class="btn">
               <span>{{ t('layout.doc') }}</span>
@@ -113,7 +108,10 @@
           </template>
         </Dropdown>
 
-        <Dropdown class="ecosystem">
+        <Dropdown
+          class="ecosystem"
+          v-if="!isMobile()"
+        >
           <template #btn>
             <div class="btn">
               <span>{{ t('layout.ecosystem') }}</span>
@@ -155,7 +153,10 @@
           </template>
         </Dropdown>
 
-        <Dropdown class="about">
+        <Dropdown
+          class="about"
+          v-if="!isMobile()"
+        >
           <template #btn>
             <div class="btn">
               <span>{{ t('layout.about') }}</span>
@@ -185,19 +186,10 @@
           </template>
         </Dropdown>
 
-        <!-- <a
-          class="sponsors"
-          :class="{
-            active: router.currentRoute.value.name === routerName.sponsors,
-          }"
-          href="/sponsors"
-          @click.prevent="router.push({ name: routerName.sponsors })"
-        >
-          {{ t('layout.sponsor') }}
-        </a> -->
         <a
           class="signin"
           @click="handleSignin"
+          v-if="!isMobile()"
         >
           {{ t('layout.signin') }}
           <div
@@ -205,6 +197,7 @@
             v-if="appStore.showSigninRedDot"
           ></div>
         </a>
+
         <a
           class="privatizationDeployment"
           :class="{
@@ -216,24 +209,35 @@
           @click.prevent="
             router.push({ name: routerName.privatizationDeployment })
           "
+          v-if="!isMobile()"
         >
           {{ t('layout.deploy') }}
           <div class="badge">
-            <div class="txt">new</div>
+            <div class="txt">hot</div>
           </div>
         </a>
 
+        <!-- <a
+          class="videoTools"
+          :class="{
+            active: router.currentRoute.value.name === routerName.videoTools,
+          }"
+          href="/videoTools"
+          @click.prevent="router.push({ name: routerName.videoTools })"
+          v-if="!isMobile()"
+        > -->
         <a
-          class="wasm"
+          class="videoTools"
           :class="{
-            active: router.currentRoute.value.name === routerName.wasm,
+            active: router.currentRoute.value.name === routerName.videoTools,
           }"
-          href="/wasm"
-          @click.prevent="handleTip"
+          href="/videoTools"
+          @click.prevent
+          v-if="!isMobile()"
         >
-          {{ t('layout.wasm') }}
+          {{ t('layout.videoTools') }}
           <div class="badge">
-            <div class="txt">wasm</div>
+            <div class="txt">beta</div>
           </div>
         </a>
 
@@ -319,6 +323,7 @@
         >
           <div class="btn">{{ t('layout.login') }}</div>
         </div>
+
         <Dropdown
           v-else
           class="qqlogin"
@@ -354,7 +359,10 @@
           </template>
         </Dropdown>
 
-        <Dropdown class="switch-lang">
+        <Dropdown
+          class="switch-lang"
+          v-if="!isMobile()"
+        >
           <template #btn>
             <div class="btn">
               {{ localeMap[locale] }}
@@ -381,7 +389,7 @@
 </template>
 
 <script lang="ts" setup>
-import { openToTarget, windowReload } from 'billd-utils';
+import { isMobile, openToTarget, windowReload } from 'billd-utils';
 import { onMounted, ref, watch } from 'vue';
 import { useI18n } from 'vue-i18n';
 import { useRouter } from 'vue-router';
@@ -436,6 +444,7 @@ const about = ref([
     url: '',
   },
 ]);
+
 const resource = ref([
   {
     label: 'billd-live',
@@ -462,6 +471,7 @@ const resource = ref([
     url: 'https://github.com/galaxy-s10/billd-live-react-native',
   },
 ]);
+
 const plugins = ref([
   {
     label: 'billd-ui',
@@ -578,7 +588,7 @@ function handleWebsiteJump() {
   left: 0;
   z-index: 50;
   box-sizing: border-box;
-  min-width: $w-1100;
+  // min-width: $w-1100;
   width: 100%;
   background-color: #fff;
 
@@ -676,6 +686,10 @@ function handleWebsiteJump() {
       align-items: center;
       height: 100%;
 
+      & > :last-child {
+        margin-right: 0 !important;
+      }
+
       .doc,
       .about,
       .ecosystem {
@@ -742,7 +756,7 @@ function handleWebsiteJump() {
       .github,
       .sponsors,
       .privatizationDeployment,
-      .wasm,
+      .videoTools,
       .signin {
         display: flex;
         align-items: center;
@@ -754,7 +768,8 @@ function handleWebsiteJump() {
           color: $theme-color-gold;
         }
       }
-      .wasm,
+
+      .videoTools,
       .privatizationDeployment,
       .signin {
         position: relative;

+ 1 - 1
src/locales/en/layout.ts

@@ -12,7 +12,7 @@ export default nameSpaceWrap('layout', {
   sponsor: 'Sponsor',
   signin: 'Signin',
   deploy: 'Private Deploy',
-  wasm: 'Video Tool',
+  videoTools: 'Video Tool',
   startLive: 'Start Live',
   login: 'Login',
   logout: 'Logout',

+ 1 - 1
src/locales/zh/layout.ts

@@ -12,7 +12,7 @@ export default nameSpaceWrap('layout', {
   sponsor: '赞助',
   signin: '签到',
   deploy: '私有化部署',
-  wasm: '视频工具',
+  videoTools: '视频工具',
   startLive: '我要开播',
   login: '登录',
   logout: '退出',

+ 6 - 4
src/router/index.ts

@@ -28,7 +28,7 @@ export const routerName = {
   rank: 'rank',
   sponsors: 'sponsors',
   privatizationDeployment: 'privatizationDeployment',
-  wasm: 'wasm',
+  videoTools: 'videoTools',
   support: 'support',
   order: 'order',
   wallet: 'wallet',
@@ -65,7 +65,6 @@ export const defaultRoutes: RouteRecordRaw[] = [
         path: '/',
         component: () => import('@/views/home/index.vue'),
       },
-
       {
         name: routerName.about,
         path: '/about',
@@ -151,18 +150,21 @@ export const defaultRoutes: RouteRecordRaw[] = [
         path: '/wallet',
         component: () => import('@/views/wallet/index.vue'),
       },
+      {
+        name: routerName.videoTools,
+        path: '/videoTools',
+        component: () => import('@/views/videoTools/index.vue'),
+      },
       {
         name: routerName.ad,
         path: '/ad',
         component: () => import('@/views/doc/ad/index.vue'),
       },
-
       {
         name: routerName.pull,
         path: '/pull/:roomId',
         component: () => import('@/views/pull/index.vue'),
       },
-
       {
         name: routerName.push,
         path: '/push',

+ 2 - 4
src/store/app/index.ts

@@ -7,8 +7,6 @@ import { ILiveRoom } from '@/types/ILiveRoom';
 
 export type AppRootState = {
   playing: boolean;
-  videoKBs?: string;
-  videoFps?: number;
   videoRatio: number;
   normalVolume: number;
   navList: { routeName: string; name: string }[];
@@ -54,6 +52,8 @@ export type AppRootState = {
   videoControlsValue: {
     pipMode?: boolean;
     pageFullMode?: boolean;
+    kbs?: string;
+    fps?: number;
   };
   liveLine: LiveLineEnum;
   liveRoomInfo?: ILiveRoom;
@@ -66,8 +66,6 @@ export const useAppStore = defineStore('app', {
   state: (): AppRootState => {
     return {
       playing: false,
-      videoKBs: undefined,
-      videoFps: undefined,
       videoRatio: 16 / 9,
       videoControls: {
         renderMode: LiveRenderEnum.video,

+ 14 - 1
src/views/h5/room/index.vue

@@ -57,9 +57,11 @@
         :resolution="videoResolution"
         @refresh="handleRefresh"
         @full-screen="handleFullScreen"
+        @picture-in-picture="hanldePictureInPicture"
         :control="{
           line: true,
           fullMode: true,
+          pipMode: true,
         }"
       ></VideoControls>
     </div>
@@ -204,7 +206,7 @@ import { useRoute } from 'vue-router';
 import { fetchFindLiveRoom } from '@/api/liveRoom';
 import { THEME_COLOR } from '@/constant';
 import { emojiArray } from '@/emoji';
-import { useFullScreen } from '@/hooks/use-play';
+import { useFullScreen, usePictureInPicture } from '@/hooks/use-play';
 import { usePull } from '@/hooks/use-pull';
 import { DanmuMsgTypeEnum, WsMessageMsgIsFileEnum } from '@/interface';
 import router, { mobileRouterName } from '@/router';
@@ -284,6 +286,17 @@ watch(
   }
 );
 
+async function hanldePictureInPicture() {
+  if (appStore.videoControlsValue.pipMode) {
+    document.exitPictureInPicture();
+  } else {
+    const el = remoteVideoRef.value?.childNodes[0];
+    if (el && remoteVideoRef.value) {
+      await usePictureInPicture(el, remoteVideoRef.value);
+    }
+  }
+}
+
 function handleScrollTop() {
   if (danmuListRef.value) {
     danmuListRef.value.scrollTop = danmuListRef.value.scrollHeight + 10000;

+ 241 - 0
src/views/videoTools/index.vue

@@ -0,0 +1,241 @@
+<template>
+  total:{{ total }}, videoFrameTotal:{{ videoFrameTotal }}
+  <canvas ref="canvasRef"></canvas>
+  <div
+    class="frane-list"
+    ref="wrapRef"
+  ></div>
+  <div
+    class="ico img"
+    title="图片"
+    @click.stop="handleVideoFrameByCanvas"
+  >
+    handleVideoFrameByCanvas
+    <input
+      ref="uploadRef"
+      type="file"
+      class="input-upload"
+      @change="uploadChange"
+    />
+  </div>
+</template>
+
+<script lang="ts" setup>
+import MP4Box from 'mp4box';
+import { onMounted, ref } from 'vue';
+
+import { createVideo } from '@/utils';
+
+const canvasRef = ref<HTMLCanvasElement>();
+const uploadRef = ref<HTMLInputElement>();
+const wrapRef = ref<HTMLDivElement>();
+const total = ref(0);
+const videoFrameTotal = ref(0);
+
+function handleVideoFrameByWebcodec() {
+  // const mp4url = 'mini-video.mp4';
+  // const mp4url = '2024-02-25-10s.mp4';
+  const mp4url = 'ddd.mp4';
+  const mp4box = MP4Box.createFile();
+  console.log(mp4box);
+  // 这个是额外的处理方法,不需要关心里面的细节
+  const getExtradata = () => {
+    // 生成VideoDecoder.configure需要的description信息
+    const entry = mp4box.moov.traks[0].mdia.minf.stbl.stsd.entries[0];
+    console.log('mp4box', mp4box);
+    console.log('entry', entry);
+    const box = entry.avcC ?? entry.hvcC ?? entry.vpcC;
+    console.log('box', box);
+    if (box != null) {
+      const stream = new MP4Box.DataStream(
+        undefined,
+        0,
+        MP4Box.DataStream.BIG_ENDIAN
+      );
+      box.write(stream);
+      // slice()方法的作用是移除moov box的header信息
+      return new Uint8Array(stream.buffer.slice(8));
+    }
+  };
+
+  // 视频轨道,解码用
+  let videoTrack = null;
+  let videoDecoder: VideoDecoder = null;
+  // 这个就是最终解码出来的视频画面序列文件
+  const videoFrames = [];
+
+  let nbSampleTotal = 0;
+  let countSample = 0;
+
+  mp4box.onReady = function (info) {
+    // 记住视频轨道信息,onSamples匹配的时候需要
+    videoTrack = info.videoTracks[0];
+
+    if (videoTrack != null) {
+      mp4box.setExtractionOptions(videoTrack.id, 'video', {
+        nbSamples: 100,
+      });
+    }
+
+    // 视频的宽度和高度
+    const videoW = videoTrack.track_width;
+    const videoH = videoTrack.track_height;
+
+    const ctx = canvasRef.value!.getContext('2d')!;
+    let flag = false;
+    // 设置视频解码器
+    videoDecoder = new VideoDecoder({
+      output: (videoFrame) => {
+        createImageBitmap(videoFrame).then((img) => {
+          console.log(img, 22);
+          if (!flag) {
+            flag = true;
+            canvasRef.value!.style.width = `${img.width / 3}px`;
+            // canvasRef.value!.style.height = `${img.height}px`;
+            // console.log(img.width, canvasRef.value!.style);
+          }
+          // ctx.drawImage(img, 0, 0);
+          ctx.drawImage(img, 0, 0, img.width, img.height);
+          videoFrames.push({
+            img,
+            duration: videoFrame.duration,
+            timestamp: videoFrame.timestamp,
+          });
+          videoFrame.close();
+        });
+      },
+      error: (err) => {
+        console.error('videoDecoder错误:', err);
+      },
+    });
+
+    nbSampleTotal = videoTrack.nb_samples;
+    console.log(videoTrack, 22);
+    videoDecoder.configure({
+      codec: videoTrack.codec,
+      codedWidth: videoW,
+      codedHeight: videoH,
+      description: getExtradata(),
+    });
+
+    mp4box.start();
+  };
+
+  mp4box.onSamples = function (trackId, ref, samples) {
+    // samples其实就是采用数据了
+    if (videoTrack.id === trackId) {
+      mp4box.stop();
+
+      countSample += samples.length;
+
+      Object.keys(samples).forEach((key) => {
+        const sample = samples[key];
+        const type = sample.is_sync ? 'key' : 'delta';
+        const chunk = new EncodedVideoChunk({
+          type,
+          timestamp: sample.cts,
+          duration: sample.duration,
+          data: sample.data,
+        });
+        videoDecoder.decode(chunk);
+      });
+
+      if (countSample === nbSampleTotal) {
+        videoDecoder.flush();
+      }
+    }
+  };
+
+  // 获取视频的arraybuffer数据
+  fetch(mp4url)
+    .then((res) => res.arrayBuffer())
+    .then((buffer) => {
+      // 因为文件较小,所以直接一次性写入
+      // 如果文件较大,则需要res.body.getReader()创建reader对象,每次读取一部分数据
+      // reader.read().then(({ done, value })
+      // @ts-ignore
+      buffer.fileStart = 0;
+      mp4box.appendBuffer(buffer);
+      mp4box.flush();
+      console.log('buffer', buffer);
+      setTimeout(() => {
+        console.log('videoFrames', videoFrames.length, videoFrames);
+      }, 1000);
+    });
+}
+
+function uploadChange() {
+  const file = uploadRef.value?.files?.[0];
+
+  if (!file) return;
+  console.log(file);
+  const url = URL.createObjectURL(file);
+  const videoEl = createVideo({
+    appendChild: false,
+  });
+  videoEl.src = url;
+
+  let videoWidth = 0;
+  let videoHeight = 0;
+  let done = false;
+  let duration = 0;
+  let currentTime = 0;
+
+  function captureFrame() {
+    if (videoEl.readyState >= 2) {
+      // 确保视频已足够加载以获取当前帧
+      const canvas = document.createElement('canvas');
+      const ctx = canvas.getContext('2d')!;
+      canvas.width = videoWidth;
+      canvas.height = videoHeight;
+      ctx.drawImage(videoEl, 0, 0, videoWidth, videoHeight);
+      wrapRef.value?.appendChild(canvas);
+      total.value = total.value + 1;
+      if (duration > currentTime) {
+        // 移动到下一帧
+        currentTime += 1;
+        videoEl.currentTime += 1 / 20; // 假设 video.frameRate 是您视频的帧率
+      } else {
+        done = true;
+      }
+    }
+  }
+
+  videoEl.onloadeddata = () => {
+    videoWidth = videoEl.videoWidth;
+    videoHeight = videoEl.videoHeight;
+    duration = videoEl.duration;
+    currentTime = videoEl.currentTime;
+    videoFrameTotal.value = duration;
+    captureFrame();
+  };
+
+  videoEl.onseeked = () => {
+    if (currentTime < duration && !done) {
+      captureFrame();
+    } else {
+      // 视频结束
+      console.log('视频结束');
+    }
+  };
+}
+
+function handleVideoFrameByCanvas() {
+  uploadRef.value?.click();
+}
+
+onMounted(() => {});
+</script>
+
+<style lang="scss" scoped>
+.input-upload {
+  width: 0;
+  height: 0;
+  opacity: 0;
+}
+.frane-list {
+  // display: flex;
+  // align-items: center;
+  // overflow: scroll;
+}
+</style>

+ 67 - 0
test/App.vue

@@ -0,0 +1,67 @@
+<template>
+  <div>
+    <video
+      ref="videoRef"
+      controls
+    ></video>
+    <button @click="load">load</button>
+  </div>
+</template>
+
+<script lang="ts" setup>
+import { FFmpeg } from '@ffmpeg/ffmpeg';
+import { fetchFile, toBlobURL } from '@ffmpeg/util';
+import { ref } from 'vue';
+
+const ffmpegRef = new FFmpeg();
+const videoRef = ref();
+
+async function load() {
+  const baseURL = 'https://unpkg.com/@ffmpeg/core@0.12.6/dist/umd';
+  const ffmpeg = ffmpegRef;
+  ffmpeg.on('log', ({ message }) => {
+    console.log(message);
+  });
+  // toBlobURL is used to bypass CORS issue, urls with the same
+  // domain can be used directly.
+  await ffmpeg.load({
+    coreURL: await toBlobURL(`${baseURL}/ffmpeg-core.js`, 'text/javascript'),
+    wasmURL: await toBlobURL(`${baseURL}/ffmpeg-core.wasm`, 'application/wasm'),
+    workerURL: await toBlobURL(
+      `${baseURL}/ffmpeg-core.worker.js`,
+      'text/javascript'
+    ),
+  });
+  transcode();
+}
+
+async function transcode() {
+  await ffmpegRef.writeFile(
+    'input.mp4',
+    // await fetchFile('/mini-video.mp4')
+    // await fetchFile('/2024-02-25.mp4')
+    await fetchFile('/2024-02-25-10s.mp4')
+    // await fetchFile(
+    //   'https://raw.githubusercontent.com/ffmpegwasm/testdata/master/Big_Buck_Bunny_180_10s.webm'
+    // )
+  );
+  await ffmpegRef.exec([
+    '-i',
+    'input.mp4',
+    '-vf',
+    'scale=-1:1280',
+    '-r',
+    '20',
+    '-crf',
+    '23',
+    'output.mp4',
+  ]);
+  const data = await ffmpegRef.readFile('output.mp4');
+  console.log(data, 332322);
+  videoRef.value.src = URL.createObjectURL(
+    new Blob([data.buffer], { type: 'video/mp4' })
+  );
+}
+</script>
+
+<style lang="scss" scoped></style>