Bläddra i källkod

feat: 视频截帧

shuisheng 1 år sedan
förälder
incheckning
269d6fd834

+ 3 - 2
package.json

@@ -47,12 +47,13 @@
     "fabric": "^5.3.0",
     "flv.js": "^1.6.2",
     "js-cookie": "^3.0.5",
+    "jszip": "^3.10.1",
     "mp4box": "^0.5.2",
     "mpegts.js": "^1.7.3",
     "naive-ui": "^2.34.4",
     "pinia": "^2.0.33",
     "pinia-plugin-persistedstate": "^3.2.0",
-    "qiniu": "^7.11.0",
+    "qiniu-js": "^3.4.2",
     "qrcode": "^1.5.3",
     "socket.io-client": "^4.7.2",
     "socket.io-msgpack-parser": "^3.0.2",
@@ -132,4 +133,4 @@
     "webpackbar": "^5.0.2",
     "windicss-webpack-plugin": "^1.7.7"
   }
-}
+}

+ 65 - 3
pnpm-lock.yaml

@@ -41,6 +41,9 @@ dependencies:
   js-cookie:
     specifier: ^3.0.5
     version: 3.0.5
+  jszip:
+    specifier: ^3.10.1
+    version: 3.10.1
   mp4box:
     specifier: ^0.5.2
     version: 0.5.2
@@ -56,9 +59,9 @@ dependencies:
   pinia-plugin-persistedstate:
     specifier: ^3.2.0
     version: 3.2.0(pinia@2.0.33)
-  qiniu:
-    specifier: ^7.11.0
-    version: 7.11.0
+  qiniu-js:
+    specifier: ^3.4.2
+    version: 3.4.2
   qrcode:
     specifier: ^1.5.3
     version: 1.5.3
@@ -1325,6 +1328,14 @@ packages:
   /@babel/regjsgen@0.8.0:
     resolution: {integrity: sha512-x/rqGMdzj+fWZvCOYForTghzbtqPDZ5gPwaoNGHdgDfF2QA/XZbCBp4Moo5scrkAMPhB7z26XM/AaHuIJdgauA==}
 
+  /@babel/runtime-corejs2@7.23.9:
+    resolution: {integrity: sha512-lwwDy5QfMkO2rmSz9AvLj6j2kWt5a4ulMi1t21vWQEO0kNCFslHoszat8reU/uigIQSUDF31zraZG/qMkcqAlw==}
+    engines: {node: '>=6.9.0'}
+    dependencies:
+      core-js: 2.6.12
+      regenerator-runtime: 0.14.1
+    dev: false
+
   /@babel/runtime-corejs3@7.21.0:
     resolution: {integrity: sha512-TDD4UJzos3JJtM+tHX+w2Uc+KWj7GV+VKKFdMVd2Rx8sdA19hcc3P3AHFYd5LVOw+pYuSd5lICC3gm52B6Rwxw==}
     engines: {node: '>=6.9.0'}
@@ -4729,6 +4740,12 @@ packages:
     requiresBuild: true
     dev: false
 
+  /core-js@2.6.12:
+    resolution: {integrity: sha512-Kb2wC0fvsWfQrgk8HU5lW6U/Lcs8+9aaYcy4ZFc6DDlo4nZ7n70dEgE5rtR0oG6ufKDUnrwfWL1mXR5ljDatrQ==}
+    deprecated: core-js@<3.23.3 is no longer maintained and not recommended for usage due to the number of issues. Because of the V8 engine whims, feature detection in old core-js versions could cause a slowdown up to 100x even if nothing is polyfilled. Some versions have web compatibility issues. Please, upgrade your dependencies to the actual version of core-js.
+    requiresBuild: true
+    dev: false
+
   /core-js@3.29.1:
     resolution: {integrity: sha512-+jwgnhg6cQxKYIIjGtAHq2nwUOolo9eoFZ4sHfUH09BLXBgxnH4gA0zEd+t+BO2cNB8idaBtZFcFTRjQJRJmAw==}
     requiresBuild: true
@@ -7313,6 +7330,10 @@ packages:
     engines: {node: '>= 4'}
     dev: true
 
+  /immediate@3.0.6:
+    resolution: {integrity: sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==}
+    dev: false
+
   /immutable@4.3.0:
     resolution: {integrity: sha512-0AOCmOip+xgJwEVTQj1EfiDDOkPmuyllDuTuEX+DDXUgapLAsBIfkg3sxCYyCEA8mQqZrrxPUGjcOQ2JS3WLkg==}
 
@@ -7907,6 +7928,15 @@ packages:
     resolution: {integrity: sha512-OYWlK0j+roh+eyaMROlNbS5cd5R25Y+IUpdl7cNdB8HNrkgwQzIS7L9MegxOiWNBj9dQhA/yAxiMwCC5mwNoBw==}
     dev: false
 
+  /jszip@3.10.1:
+    resolution: {integrity: sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g==}
+    dependencies:
+      lie: 3.3.0
+      pako: 1.0.11
+      readable-stream: 2.3.8
+      setimmediate: 1.0.5
+    dev: false
+
   /keycode@2.2.0:
     resolution: {integrity: sha512-ps3I9jAdNtRpJrbBvQjpzyFbss/skHqzS+eu4RxKLaEAtFqkjZaB6TZMSivPbLxf4K7VI4SjR0P5mRCX5+Q25A==}
     dev: false
@@ -7934,6 +7964,12 @@ packages:
       type-check: 0.4.0
     dev: true
 
+  /lie@3.3.0:
+    resolution: {integrity: sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==}
+    dependencies:
+      immediate: 3.0.6
+    dev: false
+
   /lilconfig@2.0.5:
     resolution: {integrity: sha512-xaYmXZtTHPAw5m+xLN8ab9C+3a8YmV3asNSPOATITbtwrfbwaLJj8h66H1WMIpALCkqsIzK3h7oQ+PdX+LQ9Eg==}
     engines: {node: '>=10'}
@@ -8944,6 +8980,10 @@ packages:
     resolution: {integrity: sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==}
     engines: {node: '>=6'}
 
+  /pako@1.0.11:
+    resolution: {integrity: sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==}
+    dev: false
+
   /param-case@3.0.4:
     resolution: {integrity: sha512-RXlj7zCYokReqWpOPH9oYivUzLYZ5vAPIfEmCTNViosC78F8F0H9y7T7gG2M39ymgutxF5gcFEsyZQSph9Bp3A==}
     dependencies:
@@ -9920,6 +9960,14 @@ packages:
     engines: {node: '>=0.6.0', teleport: '>=0.2.0'}
     dev: true
 
+  /qiniu-js@3.4.2:
+    resolution: {integrity: sha512-Gu94/4adN2FnM9VpTgLsgvS3KN+2ZV9gCxlmrKICMI7VqcAwTsy3+9eBLLk8WueMYwniyg8rELjdxNf0wABUHg==}
+    dependencies:
+      '@babel/runtime-corejs2': 7.23.9
+      querystring: 0.2.1
+      spark-md5: 3.0.2
+    dev: false
+
   /qiniu@7.11.0:
     resolution: {integrity: sha512-Pdux9AxQR5V8IrlkSWDBUIrBRoxyK98sfmdGm19R0jZyxBMM2+KMwB0zhjAJhb6+lxEzjyHO3EfsVRz0JeTj7A==}
     engines: {node: '>= 6'}
@@ -9962,6 +10010,12 @@ packages:
     engines: {node: '>=0.6'}
     dev: false
 
+  /querystring@0.2.1:
+    resolution: {integrity: sha512-wkvS7mL/JMugcup3/rMitHmd9ecIGd2lhFhK9N3UUQ450h66d1r3Y9nvXzQAW1Lq+wyx61k/1pfKS5KuKiyEbg==}
+    engines: {node: '>=0.4.x'}
+    deprecated: The querystring API is considered Legacy. new code should use the URLSearchParams API instead.
+    dev: false
+
   /querystringify@2.2.0:
     resolution: {integrity: sha512-FIqgj2EUvTa7R50u0rGsyTftzjYmv/a3hO345bZNrqabNqjtgiDMgmo4mkUjd+nzU5oF3dClKqFIPUKybUyqoQ==}
     requiresBuild: true
@@ -10082,6 +10136,10 @@ packages:
   /regenerator-runtime@0.13.11:
     resolution: {integrity: sha512-kY1AZVr2Ra+t+piVaJ4gxaFaReZVH40AKNo7UCX6W+dEwBo/2oZJzqfuN1qLq1oL45o56cPaTXELwrTh8Fpggg==}
 
+  /regenerator-runtime@0.14.1:
+    resolution: {integrity: sha512-dYnhHh0nJoMfnkZs6GmmhFknAGRrLznOu5nc9ML+EJxGvrx6H7teuevqVqCuPcPK//3eDrrjQhehXVx9cnkGdw==}
+    dev: false
+
   /regenerator-transform@0.15.1:
     resolution: {integrity: sha512-knzmNAcuyxV+gQCufkYcvOqX/qIIfHLv0u5x79kRxuGojfYVky1f15TzZEu2Avte8QGepvUNTnLskf8E6X6Vyg==}
     dependencies:
@@ -10603,6 +10661,10 @@ packages:
     resolution: {integrity: sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==}
     dev: false
 
+  /setimmediate@1.0.5:
+    resolution: {integrity: sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==}
+    dev: false
+
   /setprototypeof@1.1.0:
     resolution: {integrity: sha512-BvE/TwpZX4FXExxOxZyRGQQv651MSwmWKZGqvmPcRIjDqWub67kTKuIMx43cZZrS/cBBzwBcNDWoFxt2XEFIpQ==}
 

+ 4 - 0
src/api/qiniuData.ts

@@ -7,6 +7,10 @@ export interface IQiniuKey {
   ext: string;
 }
 
+export function fetchQiniuUploadToken(params: IQiniuKey) {
+  return request.get('/qiniu_data/get_token', { params });
+}
+
 export function fetchQiniuDataList(params) {
   return request.get('/qiniu_data/list', {
     params,

+ 0 - 1
src/components/VideoControls/index.vue

@@ -236,7 +236,6 @@ onUnmounted(() => {
 
 function handleKeydown(e) {
   if (e.key === 'Escape') {
-    console.log('esc');
     if (appStore.videoControlsValue.pageFullMode) {
       window.$message.info('已退出网页全屏');
       appStore.videoControlsValue.pageFullMode = false;

+ 1 - 0
src/constant.ts

@@ -50,6 +50,7 @@ export const QINIU_RESOURCE = {
   prefix: {
     'billd-live/image/': 'billd-live/image/',
     'billd-live/msg-image/': 'billd-live/msg-image/',
+    'billd-live/live-preview/': 'billd-live/live-preview/',
   },
 };
 

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

@@ -236,7 +236,6 @@ export function usePush() {
       frameRate: currentMaxFramerate.value,
     });
     handleStartLive({
-      coverImg: lastCoverImg.value,
       name: roomName.value,
       type,
       msrDelay,

+ 45 - 0
src/hooks/use-upload.ts

@@ -1,11 +1,14 @@
+import { upload } from 'qiniu-js';
 import { ref } from 'vue';
 
 import {
+  fetchQiniuUploadToken,
   fetchUpload,
   fetchUploadChunk,
   fetchUploadMergeChunk,
   fetchUploadProgress,
 } from '@/api/qiniuData';
+import { QINIU_RESOURCE } from '@/constant';
 import { getHash, splitFile } from '@/utils';
 
 export async function useUpload({
@@ -114,3 +117,45 @@ export async function useUpload({
     clearInterval(timer.value);
   }
 }
+
+export async function useQiniuJsUpload({
+  prefix,
+  file,
+}: {
+  prefix: string;
+  file: File;
+}) {
+  const { hash, ext } = await getHash(file);
+  const res = await fetchQiniuUploadToken({ ext, hash, prefix });
+  if (res.code === 200) {
+    return new Promise<{ flag: boolean; err?: any; resultUrl?: string }>(
+      (resolve) => {
+        const key = `${prefix + hash}.${ext}`;
+        const observable = upload(file, key, res.data);
+        observable.subscribe({
+          next(res) {
+            console.log('next', res);
+          },
+          error(err) {
+            console.log('error', err);
+            resolve({
+              flag: false,
+              err,
+            });
+          },
+          complete(res) {
+            console.log('complete', res);
+            resolve({
+              flag: true,
+              resultUrl: `${QINIU_RESOURCE.url}/${res.key as string}`,
+            });
+          },
+        });
+      }
+    );
+  } else {
+    return Promise.resolve<{ flag: boolean; err?: any; resultUrl?: string }>({
+      flag: false,
+    });
+  }
+}

+ 0 - 3
src/hooks/use-websocket.ts

@@ -140,13 +140,11 @@ export const useWebsocket = () => {
   }
 
   function handleStartLive({
-    coverImg,
     name,
     type,
     msrDelay,
     msrMaxDelay,
   }: {
-    coverImg?: string;
     name?: string;
     type: LiveRoomTypeEnum;
     videoEl?: HTMLVideoElement;
@@ -160,7 +158,6 @@ export const useWebsocket = () => {
       requestId: getRandomString(8),
       msgType: WsMsgTypeEnum.startLive,
       data: {
-        cover_img: coverImg!,
         name: name!,
         type,
         msrDelay,

+ 1 - 10
src/layout/pc/head/index.vue

@@ -217,22 +217,13 @@
           </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="videoTools"
           :class="{
             active: router.currentRoute.value.name === routerName.videoTools,
           }"
           href="/videoTools"
-          @click.prevent
+          @click.prevent="router.push({ name: routerName.videoTools })"
           v-if="!isMobile()"
         >
           {{ t('layout.videoTools') }}

+ 1 - 1
src/layout/pc/index.vue

@@ -1,6 +1,6 @@
 <template>
   <div class="layout">
-    <div class="fixed-mask"></div>
+    <!-- <div class="fixed-mask"></div> -->
     <HeadCpt></HeadCpt>
     <router-view v-slot="{ Component }">
       <component :is="Component"></component>

+ 14 - 0
src/router/index.ts

@@ -29,6 +29,8 @@ export const routerName = {
   sponsors: 'sponsors',
   privatizationDeployment: 'privatizationDeployment',
   videoTools: 'videoTools',
+  frameScreenshotByCanvas: 'frameScreenshotByCanvas',
+  frameScreenshotByWebcodec: 'frameScreenshotByWebcodec',
   support: 'support',
   order: 'order',
   wallet: 'wallet',
@@ -155,6 +157,18 @@ export const defaultRoutes: RouteRecordRaw[] = [
         path: '/videoTools',
         component: () => import('@/views/videoTools/index.vue'),
       },
+      {
+        name: routerName.frameScreenshotByCanvas,
+        path: '/videoTools/frameScreenshotByCanvas',
+        component: () =>
+          import('@/views/videoTools/frameScreenshot/canvas/index.vue'),
+      },
+      {
+        name: routerName.frameScreenshotByWebcodec,
+        path: '/videoTools/frameScreenshotByWebcodec',
+        component: () =>
+          import('@/views/videoTools/frameScreenshot/webcodec/index.vue'),
+      },
       {
         name: routerName.ad,
         path: '/ad',

+ 7 - 1
src/types/websocket.ts

@@ -48,6 +48,8 @@ export enum WsMsgTypeEnum {
   getLiveUser = 'getLiveUser',
   /** 更新加入信息 */
   updateJoinInfo = 'updateJoinInfo',
+  /** 更新直播间预览图 */
+  updateLiveRoomCoverImg = 'updateLiveRoomCoverImg',
   /** 心跳 */
   heartbeat = 'heartbeat',
   /** 开始直播 */
@@ -174,7 +176,6 @@ export type WsOtherJoinType = IWsFormat<{
 
 /** 开始直播 */
 export type WsStartLiveType = IWsFormat<{
-  cover_img: string;
   name: string;
   type: LiveRoomTypeEnum;
   /** 单位:毫秒 */
@@ -183,6 +184,11 @@ export type WsStartLiveType = IWsFormat<{
   msrMaxDelay: number;
 }>;
 
+/** 更新直播间预览图 */
+export type WsUpdateLiveRoomCoverImg = IWsFormat<{
+  cover_img: string;
+}>;
+
 /** 用户加入直播间 */
 export type WsJoinType = IWsFormat<{
   socket_id: string;

+ 43 - 8
src/utils/index.ts

@@ -2,6 +2,27 @@
 import { computeBox, getRangeRandom } from 'billd-utils';
 import sparkMD5 from 'spark-md5';
 
+/**
+ * 将base64转换为file
+ */
+export function base64ToFile(base64: string, fileName: string) {
+  // 解析Base64编码的字符串,分离数据头和编码数据
+  const splitDataURI = base64.split(',');
+  const byteString = atob(splitDataURI[1]);
+  const mimeString = splitDataURI[0].split(':')[1].split(';')[0];
+
+  // 构建Uint8Array类型的数组,用数组来创建Blob对象
+  const arrayBuffer = new ArrayBuffer(byteString.length);
+  const intArray = new Uint8Array(arrayBuffer);
+
+  for (let i = 0; i < byteString.length; i += 1) {
+    intArray[i] = byteString.charCodeAt(i);
+  }
+  const imageBlob = new Blob([intArray], { type: mimeString });
+  const file = new File([imageBlob], fileName, { type: mimeString });
+  return file;
+}
+
 export function stringToArrayBuffer(str: string) {
   const encoder = new TextEncoder(); // 默认是'utf-8'编码
   const uint8Array = encoder.encode(str);
@@ -363,9 +384,27 @@ export function readFile(fileName: string) {
 export function generateBase64(dom: CanvasImageSource) {
   const canvas = document.createElement('canvas');
   // @ts-ignore
-  const { width, height } = dom.getBoundingClientRect();
+  const res = dom.getBoundingClientRect();
+  let width = res.width;
+  let height = res.height;
+  if (dom instanceof HTMLVideoElement) {
+    if (dom.videoWidth) {
+      width = dom.videoWidth;
+    }
+    if (dom.videoHeight) {
+      height = dom.videoHeight;
+    }
+  }
+  if (dom instanceof HTMLCanvasElement) {
+    if (dom.width) {
+      width = dom.width;
+    }
+    if (dom.height) {
+      height = dom.height;
+    }
+  }
   const rate = width / height;
-  let ratio = 0.5;
+  const ratio = 1;
   function geturl() {
     const coverWidth = width * ratio;
     const coverHeight = coverWidth / rate;
@@ -373,13 +412,9 @@ export function generateBase64(dom: CanvasImageSource) {
     canvas.height = coverHeight;
     canvas.getContext('2d')!.drawImage(dom, 0, 0, coverWidth, coverHeight);
     // webp比png的体积小非常多!因此coverWidth就可以不用压缩太夸张
-    return canvas.toDataURL('image/webp');
-  }
-  let dataURL = geturl();
-  while (dataURL.length > 1000 * 20) {
-    ratio = ratio * 0.8;
-    dataURL = geturl();
+    return canvas.toDataURL('image/webp', 1);
   }
+  const dataURL = geturl();
   return dataURL;
 }
 

+ 2 - 2
src/views/pull/index.vue

@@ -417,7 +417,7 @@ import { emojiArray } from '@/emoji';
 import { commentAuthTip, loginTip } from '@/hooks/use-login';
 import { useFullScreen, usePictureInPicture } from '@/hooks/use-play';
 import { usePull } from '@/hooks/use-pull';
-import { useUpload } from '@/hooks/use-upload';
+import { useQiniuJsUpload } from '@/hooks/use-upload';
 import {
   DanmuMsgTypeEnum,
   GiftRecordStatusEnum,
@@ -731,7 +731,7 @@ async function uploadChange() {
     try {
       msgLoading.value = true;
       msgIsFile.value = WsMessageMsgIsFileEnum.yes;
-      const res = await useUpload({
+      const res = await useQiniuJsUpload({
         prefix: QINIU_RESOURCE.prefix['billd-live/msg-image/'],
         file: fileList[0],
       });

+ 26 - 4
src/views/push/index.vue

@@ -414,6 +414,7 @@ import {
   VolumeMuteOutline,
 } from '@vicons/ionicons5';
 import { AVRecorder } from '@webav/av-recorder';
+import { getRandomString } from 'billd-utils';
 import { fabric } from 'fabric';
 import {
   Raw,
@@ -436,7 +437,7 @@ import { emojiArray } from '@/emoji';
 import { commentAuthTip, loginTip } from '@/hooks/use-login';
 import { usePush } from '@/hooks/use-push';
 import { useRTCParams } from '@/hooks/use-rtcParams';
-import { useUpload } from '@/hooks/use-upload';
+import { useQiniuJsUpload } from '@/hooks/use-upload';
 import {
   DanmuMsgTypeEnum,
   MediaTypeEnum,
@@ -449,7 +450,9 @@ import { usePiniaCacheStore } from '@/store/cache';
 import { useNetworkStore } from '@/store/network';
 import { useUserStore } from '@/store/user';
 import { LiveRoomTypeEnum } from '@/types/ILiveRoom';
+import { WsMsgTypeEnum, WsUpdateLiveRoomCoverImg } from '@/types/websocket';
 import {
+  base64ToFile,
   createVideo,
   formatDownTime2,
   generateBase64,
@@ -484,7 +487,6 @@ const {
   roomId,
   msgIsFile,
   mySocketId,
-  lastCoverImg,
   canvasVideoStream,
   roomLiving,
   currentResolutionRatio,
@@ -661,7 +663,7 @@ async function uploadChange() {
     try {
       msgLoading.value = true;
       msgIsFile.value = WsMessageMsgIsFileEnum.yes;
-      const res = await useUpload({
+      const res = await useQiniuJsUpload({
         prefix: QINIU_RESOURCE.prefix['billd-live/msg-image/'],
         file: fileList[0],
       });
@@ -1047,13 +1049,33 @@ function initAudio() {
   handleMixedAudio();
 }
 
+async function uploadLivePreview() {
+  const base64 = generateBase64(pushCanvasRef.value!);
+  const file = base64ToFile(base64, `tmp.webp`);
+  const uploadRes = await useQiniuJsUpload({
+    prefix: QINIU_RESOURCE.prefix['billd-live/live-preview/'],
+    file,
+  });
+  if (uploadRes.flag && uploadRes.resultUrl) {
+    networkStore.wsMap
+      .get(roomId.value)
+      ?.send<WsUpdateLiveRoomCoverImg['data']>({
+        requestId: getRandomString(8),
+        msgType: WsMsgTypeEnum.updateLiveRoomCoverImg,
+        data: {
+          cover_img: uploadRes.resultUrl,
+        },
+      });
+  }
+}
+
 function handleStartLive() {
   if (!appStore.allTrack.length) {
     window.$message.warning('至少选择一个素材');
     return;
   }
+  uploadLivePreview();
   initAudio();
-  lastCoverImg.value = generateBase64(pushCanvasRef.value!);
   startLive({
     type: liveType,
     msrDelay: msrDelay.value,

+ 215 - 0
src/views/videoTools/frameScreenshot/canvas/index.vue

@@ -0,0 +1,215 @@
+<template>
+  <div class="wrap">
+    <h1>视频帧截图(canvas)</h1>
+    <n-button
+      :loading="loading"
+      type="primary"
+      @click.stop="handleVideoFrame"
+    >
+      选择视频
+      <input
+        ref="uploadRef"
+        type="file"
+        class="input-upload"
+        @change="uploadChange"
+      />
+    </n-button>
+    <span>
+      进度:{{
+        currentDuation ? ((currentDuation / videoDuration) * 100).toFixed() : 0
+      }}%
+    </span>
+    <n-button
+      v-if="currentDuation && currentDuation - videoDuration === 0"
+      type="success"
+      @click="handleDownload"
+    >
+      下载
+    </n-button>
+    <div
+      ref="listRef"
+      class="frame-list"
+      :style="{ height: height + 'px' }"
+    >
+      <div
+        v-for="(item, index) in imgList"
+        :key="index"
+        class="item"
+      >
+        <img ref="imgListRef" />
+        <div class="time">{{ item }}</div>
+      </div>
+    </div>
+  </div>
+</template>
+
+<script lang="ts" setup>
+import JSZip from 'jszip';
+import { nextTick, onMounted, ref } from 'vue';
+
+import { createVideo, formatDownTime2, generateBase64 } from '@/utils';
+
+const uploadRef = ref<HTMLInputElement>();
+const loading = ref(false);
+const currentDuation = ref(0);
+const videoDuration = ref(0);
+const height = ref(0);
+const fileList = ref<{ name: string; data: string }[]>([]);
+const imgList = ref<any[]>([]);
+const imgListRef = ref<HTMLImageElement[]>([]);
+const listRef = ref<HTMLDivElement>();
+
+function handleDownload() {
+  // 初始化一个zip打包对象
+  const zip = new JSZip();
+  // 创建一个被用来打包的名为Hello.txt的文件
+  fileList.value.forEach((file) => {
+    zip.file(file.name, file.data, { base64: true });
+  });
+  // 把打包内容异步转成blob二进制格式
+  zip.generateAsync({ type: 'blob' }).then(function (content) {
+    // 创建隐藏的可下载链接
+    const eleLink = document.createElement('a');
+    eleLink.download = '视频帧截图.zip';
+    eleLink.style.display = 'none';
+    // 下载内容转变成blob地址
+    eleLink.href = URL.createObjectURL(content);
+    // 触发点击
+    document.body.appendChild(eleLink);
+    eleLink.click();
+    // 然后移除
+    document.body.removeChild(eleLink);
+  });
+}
+
+function uploadChange() {
+  if (loading.value) return;
+  loading.value = true;
+  imgList.value = [];
+  currentDuation.value = 0;
+  videoDuration.value = 0;
+  nextTick(() => {
+    const file = uploadRef.value?.files?.[0];
+
+    if (!file) return;
+    const url = URL.createObjectURL(file);
+    const videoEl = createVideo({
+      appendChild: false,
+    });
+    videoEl.src = url;
+
+    let currentTime = 0;
+
+    function captureFrame() {
+      const res = formatDownTime2({
+        startTime: +new Date(),
+        endTime: +new Date() + (currentTime + 1) * 1000,
+        addZero: true,
+      });
+      let time = '';
+      if (res.d) {
+        time = `${res.d}天${res.h}:${res.m}:${res.s}`;
+      } else {
+        time = `${res.h}:${res.m}:${res.s}`;
+      }
+      imgList.value.push(time);
+      nextTick(() => {
+        // 确保视频已足够加载以获取当前帧
+        const img = imgListRef.value[imgListRef.value.length - 1];
+        if (img) {
+          const str = generateBase64(videoEl);
+          img.src = str;
+          fileList.value.push({
+            name: `${currentTime}.webp`,
+            data: str.split(';base64,')[1],
+          });
+          currentDuation.value = currentDuation.value + 1;
+          if (videoDuration.value > currentTime) {
+            // 移动到下一帧
+            videoEl.currentTime += 1;
+            currentTime += 1;
+          }
+        }
+      });
+    }
+
+    videoEl.onloadeddata = () => {
+      currentTime = videoEl.currentTime;
+      videoDuration.value = Math.ceil(videoEl.duration);
+      captureFrame();
+    };
+
+    videoEl.onseeked = () => {
+      if (currentTime < videoDuration.value) {
+        captureFrame();
+      } else {
+        loading.value = false;
+        if (uploadRef.value) {
+          uploadRef.value.value = '';
+        }
+      }
+    };
+  });
+}
+
+function handleVideoFrame() {
+  uploadRef.value?.click();
+}
+
+function getHeight() {
+  const h =
+    document.documentElement.clientHeight -
+    (listRef.value?.getBoundingClientRect().top || 0);
+  height.value = h;
+}
+
+onMounted(() => {
+  getHeight();
+});
+</script>
+
+<style lang="scss" scoped>
+.wrap {
+  padding-top: 10px;
+  padding-left: 30px;
+  .input-upload {
+    width: 0;
+    height: 0;
+    opacity: 0;
+  }
+  .frame-list {
+    display: flex;
+    overflow: scroll;
+    align-content: baseline;
+    flex-wrap: wrap;
+    margin-top: 10px;
+
+    @extend %customScrollbar;
+
+    .item {
+      position: relative;
+      margin-right: 10px;
+      margin-bottom: 10px;
+      padding: 3px;
+      width: 200px;
+      height: fit-content;
+      border: 1px solid black;
+      border-radius: 5px;
+      .time {
+        position: absolute;
+        right: 3px;
+        bottom: 3px;
+        padding: 3px 4px;
+        border-radius: 3px;
+        background-color: rgba($color: #000000, $alpha: 0.5);
+        color: white;
+        font-size: 13px;
+      }
+      img {
+        width: 100%;
+        height: 100%;
+      }
+    }
+  }
+}
+</style>

+ 384 - 0
src/views/videoTools/frameScreenshot/webcodec/index.vue

@@ -0,0 +1,384 @@
+<template>
+  <div class="wrap">
+    <h1>视频帧截图(webcodec)</h1>
+    <n-button
+      :loading="loading"
+      type="primary"
+      @click.stop="handleVideoFrame"
+    >
+      选择视频
+      <input
+        ref="uploadRef"
+        type="file"
+        class="input-upload"
+        @change="uploadChange"
+      />
+    </n-button>
+    <span>
+      进度:{{
+        currentDuation ? ((currentDuation / videoDuration) * 100).toFixed() : 0
+      }}%
+    </span>
+    <n-button
+      v-if="currentDuation && currentDuation - videoDuration === 0"
+      type="success"
+      @click="handleDownload"
+    >
+      下载
+    </n-button>
+    <div
+      ref="listRef"
+      class="frame-list"
+      :style="{ height: height + 'px' }"
+    >
+      <div
+        v-for="(item, index) in imgList"
+        :key="index"
+        class="item"
+      >
+        <img ref="imgListRef" />
+        <div class="time">{{ item }}</div>
+      </div>
+    </div>
+  </div>
+</template>
+
+<script lang="ts" setup>
+import JSZip from 'jszip';
+import MP4Box from 'mp4box';
+import { nextTick, onMounted, ref } from 'vue';
+
+import { createVideo, formatDownTime2, generateBase64 } from '@/utils';
+
+const uploadRef = ref<HTMLInputElement>();
+const loading = ref(false);
+const currentDuation = ref(0);
+const videoDuration = ref(0);
+const height = ref(0);
+const fileList = ref<{ name: string; data: string }[]>([]);
+const imgList = ref<any[]>([]);
+const imgListRef = ref<HTMLImageElement[]>([]);
+const listRef = ref<HTMLDivElement>();
+
+const mp4box = MP4Box.createFile();
+
+// 这个是额外的处理方法,不需要关心里面的细节
+const getExtradata = () => {
+  // 生成VideoDecoder.configure需要的description信息
+  const entry = mp4box.moov.traks[0].mdia.minf.stbl.stsd.entries[0];
+
+  const box = entry.avcC ?? entry.hvcC ?? entry.vpcC;
+  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: any = null;
+let videoDecoder: any = null;
+// 这个就是最终解码出来的视频画面序列文件
+const videoFrames: any[] = [];
+
+let nbSampleTotal = 0;
+let countSample = 0;
+
+mp4box.onReady = function (info) {
+  console.log('onReady', 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;
+  let num = 0;
+  // 设置视频解码器
+  videoDecoder = new VideoDecoder({
+    output: (videoFrame: VideoFrame) => {
+      num += 1;
+      if (num % 10 !== 0) return;
+      const res = formatDownTime2({
+        startTime: +new Date(),
+        endTime: +new Date() + num * 100,
+        addZero: true,
+      });
+      let time = '';
+      if (res.d) {
+        time = `${res.d}天${res.h}:${res.m}:${res.s}`;
+      } else {
+        time = `${res.h}:${res.m}:${res.s}`;
+      }
+      imgList.value.push(time);
+      createImageBitmap(videoFrame).then((img) => {
+        // 在画布上显示解码后的帧
+        // const canvas = canvasRef.value!;
+        const canvas = document.createElement('canvas');
+        const ctx = canvas.getContext('2d')!;
+        canvas.width = img.width;
+        canvas.height = img.height;
+        ctx.drawImage(img, 0, 0);
+        const imgEl = imgListRef.value[imgListRef.value.length - 1];
+        if (imgEl) {
+          const str = generateBase64(canvas);
+          imgEl.src = str;
+          fileList.value.push({
+            name: `${num}.webp`,
+            data: str.split(';base64,')[1],
+          });
+        }
+        videoFrame.close();
+      });
+    },
+    error: (err) => {
+      console.error('videoDecoder错误:', err);
+    },
+  });
+
+  nbSampleTotal = videoTrack.nb_samples;
+
+  videoDecoder.configure({
+    codec: videoTrack.codec,
+    codedWidth: videoW,
+    codedHeight: videoH,
+    description: getExtradata(),
+  });
+
+  mp4box.start();
+};
+
+mp4box.onSamples = function (trackId, ref, samples) {
+  console.log('onSamples', trackId, ref, samples);
+  // samples其实就是采用数据了
+  if (videoTrack.id === trackId) {
+    mp4box.stop();
+
+    countSample += samples.length;
+
+    // eslint-disable-next-line
+    for (const sample of samples) {
+      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();
+    }
+  }
+};
+
+function handleDownload() {
+  // 初始化一个zip打包对象
+  const zip = new JSZip();
+  // 创建一个被用来打包的名为Hello.txt的文件
+  fileList.value.forEach((file) => {
+    zip.file(file.name, file.data, { base64: true });
+  });
+  // 把打包内容异步转成blob二进制格式
+  zip.generateAsync({ type: 'blob' }).then(function (content) {
+    // 创建隐藏的可下载链接
+    const eleLink = document.createElement('a');
+    eleLink.download = '视频帧截图.zip';
+    eleLink.style.display = 'none';
+    // 下载内容转变成blob地址
+    eleLink.href = URL.createObjectURL(content);
+    // 触发点击
+    document.body.appendChild(eleLink);
+    eleLink.click();
+    // 然后移除
+    document.body.removeChild(eleLink);
+  });
+}
+
+async function playHEVCStream() {
+  if (!window.VideoDecoder) {
+    console.error('不支持Webcodecs');
+    return;
+  }
+  // 获取视频源
+  const stream = await navigator.mediaDevices.getUserMedia({ video: true });
+  const videoTrack = stream.getVideoTracks()[0];
+  const videoStream = new MediaStream([videoTrack]);
+
+  // 创建WebCodecs编解码器
+  const codec = new VideoDecoder({
+    output: (frame) => {
+      // 在画布上显示解码后的帧
+      // const canvas = canvasRef.value!;
+      // const ctx = canvas.getContext('2d')!;
+      // canvas.width = frame.displayWidth;
+      // canvas.height = frame.displayHeight;
+      // // 创建ImageBitmap
+      // const imageBitmap = await createImageBitmap(frame);
+      // ctx.drawImage(imageBitmap, 0, 0);
+      console.log(frame);
+      frame.close();
+    },
+    error() {},
+  });
+
+  codec.configure({ codec: 'hevc' });
+  // 构造一个输入数据示例
+  const encodedVideoChunk = new EncodedVideoChunk({
+    type: 'key', // 或者 'delta',取决于帧类型
+    timestamp: performance.now(), // 提供一个时间戳
+    data: new Uint8Array(), // 这是编码视频帧的数据
+  });
+  codec.decode(encodedVideoChunk);
+}
+
+function uploadChange() {
+  if (loading.value) return;
+  loading.value = true;
+  imgList.value = [];
+  currentDuation.value = 0;
+  videoDuration.value = 0;
+  nextTick(async () => {
+    const file = uploadRef.value?.files?.[0];
+
+    if (!file) return;
+    const buffer = await file.arrayBuffer();
+    // @ts-ignore
+    buffer.fileStart = 0;
+    mp4box.appendBuffer(buffer);
+    mp4box.flush();
+    return;
+    const url = URL.createObjectURL(file);
+    const videoEl = createVideo({
+      appendChild: false,
+    });
+    videoEl.src = url;
+
+    let currentTime = 0;
+
+    function captureFrame() {
+      const res = formatDownTime2({
+        startTime: +new Date(),
+        endTime: +new Date() + (currentTime + 1) * 1000,
+        addZero: true,
+      });
+      let time = '';
+      if (res.d) {
+        time = `${res.d}天${res.h}:${res.m}:${res.s}`;
+      } else {
+        time = `${res.h}:${res.m}:${res.s}`;
+      }
+      imgList.value.push(time);
+      nextTick(() => {
+        // 确保视频已足够加载以获取当前帧
+        const img = imgListRef.value[imgListRef.value.length - 1];
+        if (img) {
+          const str = generateBase64(videoEl);
+          img.src = str;
+          fileList.value.push({
+            name: `${currentTime}.webp`,
+            data: str.split(';base64,')[1],
+          });
+          currentDuation.value = currentDuation.value + 1;
+          if (videoDuration.value > currentTime) {
+            // 移动到下一帧
+            videoEl.currentTime += 1;
+            currentTime += 1;
+          }
+        }
+      });
+    }
+
+    videoEl.onloadeddata = () => {
+      currentTime = videoEl.currentTime;
+      videoDuration.value = Math.ceil(videoEl.duration);
+      captureFrame();
+    };
+
+    videoEl.onseeked = () => {
+      if (currentTime < videoDuration.value) {
+        captureFrame();
+      } else {
+        loading.value = false;
+        if (uploadRef.value) {
+          uploadRef.value.value = '';
+        }
+      }
+    };
+  });
+}
+
+function handleVideoFrame() {
+  uploadRef.value?.click();
+}
+
+function getHeight() {
+  const h =
+    document.documentElement.clientHeight -
+    (listRef.value?.getBoundingClientRect().top || 0);
+  height.value = h;
+}
+
+onMounted(() => {
+  getHeight();
+});
+</script>
+
+<style lang="scss" scoped>
+.wrap {
+  padding-top: 10px;
+  padding-left: 30px;
+  .input-upload {
+    width: 0;
+    height: 0;
+    opacity: 0;
+  }
+  .frame-list {
+    display: flex;
+    overflow: scroll;
+    align-content: baseline;
+    flex-wrap: wrap;
+    margin-top: 10px;
+
+    @extend %customScrollbar;
+
+    .item {
+      position: relative;
+      margin-right: 10px;
+      margin-bottom: 10px;
+      padding: 3px;
+      width: 200px;
+      height: fit-content;
+      border: 1px solid black;
+      border-radius: 5px;
+      .time {
+        position: absolute;
+        right: 3px;
+        bottom: 3px;
+        padding: 3px 4px;
+        border-radius: 3px;
+        background-color: rgba($color: #000000, $alpha: 0.5);
+        color: white;
+        font-size: 13px;
+      }
+      img {
+        width: 100%;
+        height: 100%;
+      }
+    }
+  }
+}
+</style>

+ 36 - 229
src/views/videoTools/index.vue

@@ -1,241 +1,48 @@
 <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 class="list">
+    <div
+      class="item"
+      @click.prevent="router.push({ name: routerName.frameScreenshotByCanvas })"
+    >
+      视频帧截图(canvas)
+    </div>
+    <div
+      class="item"
+      @click.prevent="
+        router.push({ name: routerName.frameScreenshotByWebcodec })
+      "
+    >
+      视频帧截图(webcodec)
+    </div>
   </div>
 </template>
 
 <script lang="ts" setup>
-import MP4Box from 'mp4box';
-import { onMounted, ref } from 'vue';
+import { useRouter } from 'vue-router';
 
-import { createVideo } from '@/utils';
+import { routerName } from '@/router';
 
-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(() => {});
+const router = useRouter();
 </script>
 
 <style lang="scss" scoped>
-.input-upload {
-  width: 0;
-  height: 0;
-  opacity: 0;
-}
-.frane-list {
-  // display: flex;
-  // align-items: center;
-  // overflow: scroll;
+.list {
+  display: flex;
+  align-items: center;
+  padding-top: 10px;
+  padding-left: 30px;
+  .item {
+    display: flex;
+    align-items: center;
+    justify-content: center;
+    margin-right: 15px;
+    margin-bottom: 15px;
+    width: 200px;
+    height: 100px;
+    border-radius: 10px;
+    background-color: rgba($color: $theme-color-gold, $alpha: 0.8);
+    font-size: 18px;
+    cursor: pointer;
+  }
 }
 </style>

+ 305 - 0
test/index.vue

@@ -0,0 +1,305 @@
+<template>
+  <div class="wrap">
+    <n-button
+      type="success"
+      @click.stop="handleVideoFrameByCanvas"
+    >
+      选择视频
+      <input
+        ref="uploadRef"
+        type="file"
+        class="input-upload"
+        @change="uploadChange"
+      />
+    </n-button>
+    <span>
+      进度:{{ total ? ((total / videoDuration) * 100).toFixed() : 0 }}%
+    </span>
+    <div
+      ref="listRef"
+      class="frame-list"
+      :style="{ height: height + 'px' }"
+    >
+      <div
+        v-for="(item, index) in canvasList"
+        :key="index"
+        class="item"
+      >
+        <canvas ref="canvasListRef"></canvas>
+        <div class="time">{{ item }}</div>
+      </div>
+    </div>
+  </div>
+</template>
+
+<script lang="ts" setup>
+import { createVideo, formatDownTime2 } from '@/utils';
+import MP4Box from 'mp4box';
+import { nextTick, onMounted, ref } from 'vue';
+
+const uploadRef = ref<HTMLInputElement>();
+const total = ref(0);
+const videoDuration = ref(0);
+const height = ref(0);
+const canvasList = ref<any[]>([]);
+const canvasListRef = ref<HTMLCanvasElement[]>([]);
+const listRef = ref<HTMLDivElement>();
+
+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: any = null;
+  let videoDecoder: VideoDecoder | null = 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() {
+  canvasList.value = [];
+  total.value = 0;
+  videoDuration.value = 0;
+  nextTick(() => {
+    const file = uploadRef.value?.files?.[0];
+
+    if (!file) return;
+    const url = URL.createObjectURL(file);
+    const videoEl = createVideo({
+      appendChild: false,
+    });
+    videoEl.src = url;
+
+    let videoWidth = 0;
+    let videoHeight = 0;
+    let currentTime = 0;
+
+    function captureFrame() {
+      const res = formatDownTime2({
+        startTime: +new Date(),
+        endTime: +new Date() + (currentTime + 1) * 1000,
+        addZero: true,
+      });
+      let time = '';
+      if (res.d) {
+        time = `${res.d}天${res.h}:${res.m}:${res.s}`;
+      } else {
+        time = `${res.h}:${res.m}:${res.s}`;
+      }
+      canvasList.value.push(time);
+      nextTick(() => {
+        // 确保视频已足够加载以获取当前帧
+        const canvas = canvasListRef.value[canvasListRef.value.length - 1];
+        if (canvas) {
+          const ctx = canvas.getContext('2d')!;
+          canvas.width = videoWidth;
+          canvas.height = videoHeight;
+          ctx.drawImage(videoEl, 0, 0, videoWidth, videoHeight);
+          total.value = total.value + 1;
+          if (videoDuration.value > currentTime) {
+            // 移动到下一帧
+            videoEl.currentTime += 1;
+            currentTime += 1;
+          }
+        }
+      });
+    }
+
+    videoEl.onloadeddata = () => {
+      videoWidth = videoEl.videoWidth;
+      videoHeight = videoEl.videoHeight;
+      currentTime = videoEl.currentTime;
+      videoDuration.value = Math.ceil(videoEl.duration);
+      captureFrame();
+    };
+
+    videoEl.onseeked = () => {
+      if (currentTime < videoDuration.value) {
+        captureFrame();
+      }
+    };
+  });
+}
+
+function handleVideoFrameByCanvas() {
+  uploadRef.value?.click();
+}
+
+function getHeight() {
+  const h =
+    document.documentElement.clientHeight -
+    (listRef.value?.getBoundingClientRect().top || 0);
+  height.value = h;
+}
+
+onMounted(() => {
+  getHeight();
+});
+</script>
+
+<style lang="scss" scoped>
+.wrap {
+  padding-top: 10px;
+  padding-left: 30px;
+  .input-upload {
+    width: 0;
+    height: 0;
+    opacity: 0;
+  }
+  .frame-list {
+    display: flex;
+    overflow: scroll;
+    align-content: baseline;
+    flex-wrap: wrap;
+    margin-top: 10px;
+
+    @extend %customScrollbar;
+
+    .item {
+      position: relative;
+      margin-right: 10px;
+      margin-bottom: 10px;
+      padding: 3px;
+      width: 200px;
+      height: fit-content;
+      border: 1px solid black;
+      border-radius: 5px;
+      .time {
+        position: absolute;
+        right: 3px;
+        bottom: 3px;
+        padding: 3px 4px;
+        border-radius: 3px;
+        background-color: rgba($color: #000000, $alpha: 0.5);
+        color: white;
+        font-size: 13px;
+      }
+      canvas {
+        width: 100%;
+        height: 100%;
+      }
+    }
+  }
+}
+</style>