index.vue 8.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327
  1. <template>
  2. <div class="wrap">
  3. <h1>视频帧截图(webcodec)</h1>
  4. <n-button
  5. :loading="loading"
  6. type="primary"
  7. @click.stop="handleVideoFrame"
  8. >
  9. 选择视频
  10. <input
  11. ref="uploadRef"
  12. type="file"
  13. class="input-upload"
  14. @change="uploadChange"
  15. />
  16. </n-button>
  17. <span>
  18. 进度:{{
  19. currentDuation ? ((currentDuation / videoDuration) * 100).toFixed() : 0
  20. }}%
  21. </span>
  22. <n-button
  23. v-if="currentDuation && currentDuation - videoDuration === 0"
  24. type="success"
  25. @click="handleDownload"
  26. >
  27. 下载
  28. </n-button>
  29. <div
  30. ref="listRef"
  31. class="frame-list"
  32. :style="{ height: height + 'px' }"
  33. >
  34. <div
  35. v-for="(item, index) in imgList"
  36. :key="index"
  37. class="item"
  38. >
  39. <img ref="imgListRef" />
  40. <div class="time">{{ item }}</div>
  41. </div>
  42. </div>
  43. </div>
  44. </template>
  45. <script lang="ts" setup>
  46. import JSZip from 'jszip';
  47. import MP4Box from 'mp4box';
  48. import { nextTick, onMounted, ref } from 'vue';
  49. import { formatDownTime2, generateBase64 } from '@/utils';
  50. const uploadRef = ref<HTMLInputElement>();
  51. const loading = ref(false);
  52. const currentDuation = ref(0);
  53. const videoDuration = ref(0);
  54. const height = ref(0);
  55. const fileList = ref<{ name: string; data: string }[]>([]);
  56. const imgList = ref<any[]>([]);
  57. const imgListRef = ref<HTMLImageElement[]>([]);
  58. const listRef = ref<HTMLDivElement>();
  59. const mp4box = MP4Box.createFile();
  60. // 这个是额外的处理方法,不需要关心里面的细节
  61. const getExtradata = () => {
  62. // 生成VideoDecoder.configure需要的description信息
  63. const entry = mp4box.moov.traks[0].mdia.minf.stbl.stsd.entries[0];
  64. const box = entry.avcC ?? entry.hvcC ?? entry.vpcC;
  65. if (box != null) {
  66. const stream = new MP4Box.DataStream(
  67. undefined,
  68. 0,
  69. MP4Box.DataStream.BIG_ENDIAN
  70. );
  71. box.write(stream);
  72. // slice()方法的作用是移除moov box的header信息
  73. return new Uint8Array(stream.buffer.slice(8));
  74. }
  75. };
  76. // 视频轨道,解码用
  77. let videoTrack: any = null;
  78. let videoDecoder: any = null;
  79. // 这个就是最终解码出来的视频画面序列文件
  80. const videoFrames: any[] = [];
  81. let nbSampleTotal = 0;
  82. let countSample = 0;
  83. mp4box.onReady = function (info) {
  84. console.log('onReady', info); // 记住视频轨道信息,onSamples匹配的时候需要
  85. videoTrack = info.videoTracks[0];
  86. videoDuration.value = Math.ceil(info.duration / 1000);
  87. if (videoTrack != null) {
  88. mp4box.setExtractionOptions(videoTrack.id, 'video', {
  89. nbSamples: 100,
  90. });
  91. }
  92. // 视频的宽度和高度
  93. const videoW = videoTrack.track_width;
  94. const videoH = videoTrack.track_height;
  95. let num = 0;
  96. // 设置视频解码器
  97. videoDecoder = new VideoDecoder({
  98. output: (videoFrame: VideoFrame) => {
  99. num += 1;
  100. if (num % 10 !== 0) return;
  101. currentDuation.value += 1;
  102. const res = formatDownTime2({
  103. startTime: +new Date(),
  104. endTime: +new Date() + num * 100,
  105. addZero: true,
  106. });
  107. let time = '';
  108. if (res.d) {
  109. time = `${res.d}天${res.h}:${res.m}:${res.s}`;
  110. } else {
  111. time = `${res.h}:${res.m}:${res.s}`;
  112. }
  113. imgList.value.push(time);
  114. createImageBitmap(videoFrame).then((img) => {
  115. // 在画布上显示解码后的帧
  116. // const canvas = canvasRef.value!;
  117. const canvas = document.createElement('canvas');
  118. const ctx = canvas.getContext('2d')!;
  119. canvas.width = img.width;
  120. canvas.height = img.height;
  121. ctx.drawImage(img, 0, 0);
  122. const imgEl = imgListRef.value[imgListRef.value.length - 1];
  123. if (imgEl) {
  124. const str = generateBase64(canvas);
  125. imgEl.src = str;
  126. fileList.value.push({
  127. name: `${num}.webp`,
  128. data: str.split(';base64,')[1],
  129. });
  130. }
  131. videoFrame.close();
  132. });
  133. },
  134. error: (err) => {
  135. console.error('videoDecoder错误:', err);
  136. },
  137. });
  138. nbSampleTotal = videoTrack.nb_samples;
  139. videoDecoder.configure({
  140. codec: videoTrack.codec,
  141. codedWidth: videoW,
  142. codedHeight: videoH,
  143. description: getExtradata(),
  144. });
  145. mp4box.start();
  146. };
  147. mp4box.onSamples = function (trackId, ref, samples) {
  148. console.log('onSamples', trackId, ref, samples);
  149. // samples其实就是采用数据了
  150. if (videoTrack.id === trackId) {
  151. mp4box.stop();
  152. countSample += samples.length;
  153. // eslint-disable-next-line
  154. for (const sample of samples) {
  155. const type = sample.is_sync ? 'key' : 'delta';
  156. const chunk = new EncodedVideoChunk({
  157. type,
  158. timestamp: sample.cts,
  159. duration: sample.duration,
  160. data: sample.data,
  161. });
  162. videoDecoder.decode(chunk);
  163. }
  164. if (countSample === nbSampleTotal) {
  165. videoDecoder.flush();
  166. }
  167. }
  168. };
  169. function handleDownload() {
  170. // 初始化一个zip打包对象
  171. const zip = new JSZip();
  172. // 创建一个被用来打包的名为Hello.txt的文件
  173. fileList.value.forEach((file) => {
  174. zip.file(file.name, file.data, { base64: true });
  175. });
  176. // 把打包内容异步转成blob二进制格式
  177. zip.generateAsync({ type: 'blob' }).then(function (content) {
  178. // 创建隐藏的可下载链接
  179. const eleLink = document.createElement('a');
  180. eleLink.download = '视频帧截图.zip';
  181. eleLink.style.display = 'none';
  182. // 下载内容转变成blob地址
  183. eleLink.href = URL.createObjectURL(content);
  184. // 触发点击
  185. document.body.appendChild(eleLink);
  186. eleLink.click();
  187. // 然后移除
  188. document.body.removeChild(eleLink);
  189. });
  190. }
  191. async function playHEVCStream() {
  192. if (!window.VideoDecoder) {
  193. console.error('不支持Webcodecs');
  194. return;
  195. }
  196. // 获取视频源
  197. const stream = await navigator.mediaDevices.getUserMedia({ video: true });
  198. const videoTrack = stream.getVideoTracks()[0];
  199. const videoStream = new MediaStream([videoTrack]);
  200. // 创建WebCodecs编解码器
  201. const codec = new VideoDecoder({
  202. output: (frame) => {
  203. // 在画布上显示解码后的帧
  204. // const canvas = canvasRef.value!;
  205. // const ctx = canvas.getContext('2d')!;
  206. // canvas.width = frame.displayWidth;
  207. // canvas.height = frame.displayHeight;
  208. // // 创建ImageBitmap
  209. // const imageBitmap = await createImageBitmap(frame);
  210. // ctx.drawImage(imageBitmap, 0, 0);
  211. console.log(frame);
  212. frame.close();
  213. },
  214. error() {},
  215. });
  216. codec.configure({ codec: 'hevc' });
  217. // 构造一个输入数据示例
  218. const encodedVideoChunk = new EncodedVideoChunk({
  219. type: 'key', // 或者 'delta',取决于帧类型
  220. timestamp: performance.now(), // 提供一个时间戳
  221. data: new Uint8Array(), // 这是编码视频帧的数据
  222. });
  223. codec.decode(encodedVideoChunk);
  224. }
  225. function uploadChange() {
  226. if (loading.value) return;
  227. loading.value = true;
  228. imgList.value = [];
  229. currentDuation.value = 0;
  230. videoDuration.value = 0;
  231. nextTick(async () => {
  232. const file = uploadRef.value?.files?.[0];
  233. if (!file) return;
  234. const buffer = await file.arrayBuffer();
  235. // @ts-ignore
  236. buffer.fileStart = 0;
  237. mp4box.appendBuffer(buffer);
  238. mp4box.flush();
  239. });
  240. }
  241. function handleVideoFrame() {
  242. uploadRef.value?.click();
  243. }
  244. function getHeight() {
  245. const h =
  246. document.documentElement.clientHeight -
  247. (listRef.value?.getBoundingClientRect().top || 0);
  248. height.value = h;
  249. }
  250. onMounted(() => {
  251. getHeight();
  252. });
  253. </script>
  254. <style lang="scss" scoped>
  255. .wrap {
  256. padding-top: 10px;
  257. padding-left: 30px;
  258. .input-upload {
  259. width: 0;
  260. height: 0;
  261. opacity: 0;
  262. }
  263. .frame-list {
  264. display: flex;
  265. overflow: scroll;
  266. align-content: baseline;
  267. flex-wrap: wrap;
  268. margin-top: 10px;
  269. @extend %customScrollbar;
  270. .item {
  271. position: relative;
  272. margin-right: 10px;
  273. margin-bottom: 10px;
  274. padding: 3px;
  275. width: 200px;
  276. height: fit-content;
  277. border: 1px solid black;
  278. border-radius: 5px;
  279. .time {
  280. position: absolute;
  281. right: 3px;
  282. bottom: 3px;
  283. padding: 3px 4px;
  284. border-radius: 3px;
  285. background-color: rgba($color: #000000, $alpha: 0.5);
  286. color: white;
  287. font-size: 13px;
  288. }
  289. img {
  290. width: 100%;
  291. height: 100%;
  292. }
  293. }
  294. }
  295. }
  296. </style>