使用 requestAnimationFrame 将视频绘制到 Canvas 时发生内存泄漏

Memory Leak when Drawing Video to Canvas using requestAnimationFrame

我正在尝试通过在 Canvas 中组合 screen1 和 screen2 来进行双显示器屏幕录制。我正在使用 vue 和 electron 来做到这一点。但是在我对代码进行故障排除并缩小问题范围后,我总是会发生内存泄漏。我发现这个简单的代码会导致内存泄漏,但直到现在我都无法找出为什么在 canvas 内绘制会导致内存泄漏。我也尝试在绘制之前清理 canvas 但仍然出现内存泄漏。我的完整代码在这里:

<!--Electron版本選擇共享畫面-->
<template>
  <div v-show="false" class="modal-container">
    <section class="modal">
      <div class="modalTitleName">ScreenRecord Result</div>
      <div class="modalTitleBarRightBtn">
        <button class="closeButton" v-on:click="close">
          <span class="icon"
            ><img src="images/icon_black_close.svg" style="width: 20px"
          /></span>
        </button>
      </div>
      <div class="screenshoot-result">
        <canvas style="display: none" id="canvasRecord"></canvas>
        <img id="my-preview" />
        <video style="display: none" id="video1" autoplay="autoplay" />
        <video style="display: none" id="video2" autoplay="autoplay" />
        <!-- <video id="video3" autoplay="autoplay" /> -->
      </div>
      <div class="modalSaveButton">
        <button class="saveButton">
          <span class="icon">Save Screenshoot</span>
        </button>
      </div>
    </section>
  </div>
</template>

<script>
import { langKey } from "@/scripts/starise-lang.js";
import Logger from "@/scripts/starise-logger";

const logger = Logger("ScreenRecordModule");

export default {
  name: "ScreenRecordModule",
  components: {},
  props: {},
  data: () => {
    return {
      langKey: langKey,
      show: "screen",
      screenSources: [],
      imageFormat: "image/jpeg",
      img: null,
      width: [],
      height: [],
      readyToShow: false,
      imageSave: null,
      videoStream: null,
      mediaRecorder: null,
      soundRecorder: null,
      chuncks: [],
      videoURL: null,
      streamWrite: null,
      recorderStream: null,
      canvas: null,
      video1: null,
      video2: null,
      ctx: null,
    };
  },
  created() {
    // init(window);
  },
  mounted() {
    logger.debug("mounted");
    if (window.electron) {
      // logger.debug("PATH:%o", window.electron);
    }

    this.init();
  },
  methods: {
    init() {
      logger.debug("init");

      let _inst = this;
      this.screenSources = [];

      if (window.electron) {
        // 取得現有視窗
        window.electron.desktopCapturer
          .getSources({
            types: ["screen"],
          })
          .then(async (sources) => {
            for (const source of sources) {
              if (source.id.includes("screen:")) {
                const stream = await navigator.mediaDevices.getUserMedia({
                  audio:
                    process.platform === "win32"
                      ? {
                          mandatory: {
                            chromeMediaSource: "desktop",
                          },
                        }
                      : false,
                  video: {
                    mandatory: {
                      chromeMediaSource: "desktop",
                      chromeMediaSourceId: source.id,
                      // maxWidth: 4000,
                      // maxHeight: 4000,
                    },
                  },
                });
                let stream_settings = stream.getVideoTracks()[0].getSettings();
                logger.debug("Stream setting:%o", stream_settings);

                // actual width & height of the camera video
                let stream_width = stream_settings.width;
                let stream_height = stream_settings.height;

                logger.debug("Width: " + stream_width + "px");
                logger.debug("Height: " + stream_height + "px");
                _inst.screenSources.push(stream);
              }
            }

            try {
              this.handleStream(_inst.screenSources);
            } catch (error) {
              logger.debug("THIS IS SCREENSOURCES ERROR: %o", error);
            }
          });
      }
    },

    async handleStream(screenSources) {
      // Create hidden video tag
      let video = [
        document.getElementById("video1"),
        document.getElementById("video2"),
      ];
      for (let i = 0; i < screenSources.length; i++) {
        video[i].srcObject = screenSources[i];
        video[i].onloadedmetadata = function () {
          video[i].play();
        };
      }

      this.readyToShow = true;
      logger.debug("Num of Screen: %o", this.screenSources.length);

      this.video1 = document.getElementById("video1");
      this.video2 = document.getElementById("video2");
      this.canvas = document.getElementById("canvasRecord");
      this.ctx = this.canvas.getContext("2d");
      this.canvas.height = 1080;
      this.canvas.width = 1920 * this.screenSources.length;
      /* Add audio in and audio in desktop */

      const speakerStream = await navigator.mediaDevices.getUserMedia({
        audio: true,
        video: false,
      });
      const audioDesktop = await navigator.mediaDevices.getUserMedia({
        audio: {
          mandatory: {
            chromeMediaSource: "desktop",
          },
        },

        video: {
          mandatory: {
            chromeMediaSource: "desktop",
          },
        },
      });

      //Mix the track
      this.recorderStream = this.mixer(audioDesktop, speakerStream);
      //Add audio track to canvas stream
      const canvasStream = this.canvas.captureStream();
      canvasStream.addTrack(this.recorderStream.getAudioTracks()[0]);

      this.mediaRecorder = new MediaRecorder(canvasStream);

      let chunks = [];
      this.mediaRecorder.ondataavailable = function (e) {
        if (e.data.size > 0) {
          chunks.push(e.data);
        }
      };

      this.mediaRecorder.onstop = function (e) {
        let blob = new Blob(chunks, { type: "video/mp4" }); // other types are available such as 'video/webm' for instance, see the doc for more info
        chunks = [];
        this.videoURL = URL.createObjectURL(blob);
        let a = document.createElement("a");
        document.body.appendChild(a);
        a.style = "display: none";
        a.href = this.videoURL;
        a.download = Date.now() + ".mp4";
        a.click();
        window.URL.revokeObjectURL(this.videoURL);
        // video3.src = this.videoURL;
        this.mediaRecorder = null;
      };

      this.mediaRecorder.start(3000);

      // if (this.screenSources.length > 1) {
      //   window.requestAnimationFrame(this.drawFirstVideo);
      //   window.requestAnimationFrame(this.drawSecondVideo);
      // } else {
      //   window.requestAnimationFrame(this.drawFirstVideo);
      // }
      this.testDraw();
    },

    testDraw() {
      // this.ctx.clearRect(0,0,this.canvas.width, this.canvas.height)
      this.ctx.fillStyle = "#FF0000";
      this.ctx.fillRect(0, 0, 150, 75);
      requestAnimationFrame(this.testDraw);
    },

    drawFirstVideo() {
      this.ctx.drawImage(this.video1, 0, 0);
      requestAnimationFrame(this.drawFirstVideo);
    },

    drawSecondVideo() {
      this.ctx.drawImage(this.video2, 1920, 0);
      window.requestAnimationFrame(this.drawSecondVideo);
    },

    //Mixing Desktop Audio
    mixer(windowSource, speakerSource) {
      const audioContext = new AudioContext();
      const mediaStreamDestination =
        audioContext.createMediaStreamDestination();
      if (
        windowSource &&
        !!windowSource &&
        windowSource.getAudioTracks().length > 0
      ) {
        logger.debug("windowSource");
        audioContext
          .createMediaStreamSource(windowSource)
          .connect(mediaStreamDestination);
      }
      if (
        speakerSource &&
        !!speakerSource &&
        speakerSource.getAudioTracks().length > 0
      ) {
        audioContext
          .createMediaStreamSource(speakerSource)
          .connect(mediaStreamDestination);
      }

      return new MediaStream(
        mediaStreamDestination.stream
          .getTracks()
          .concat(windowSource.getVideoTracks())
      );
    },

    showContext(type) {
      this.show = type;
    },

    close() {
      this.$store.commit("starv/show", { showScreenRecordModule: false });
    },

    picked(id, type) {
      window.setDesktopShareSourceId(id, type);
      this.$store.commit("starv/show", { showScreenshotModule: false });
    },
  },
  watch: {
    "$store.state.starv.show.startScreenRecord": function (isRecord) {
      if (!isRecord) {
        this.mediaRecorder.stop();
        this.close();
      }
    },
  },
};
</script>

<style scoped src="@/styles/ShareScreenPicker.css" />

缩小我的问题后,我知道我的这部分代码导致内存泄漏:

 this.testDraw();
},

testDraw() {
  // this.ctx.clearRect(0,0,this.canvas.width, this.canvas.height)
  this.ctx.fillStyle = "#FF0000";
  this.ctx.fillRect(0, 0, 150, 75);
  requestAnimationFrame(this.testDraw);
},

有没有人遇到过同样的问题?谢谢

我发现问题出在 this.canvas.captureStream()。我想并排拼贴 1920x1080 以创建 dual-monitor 屏幕录像机,因此我需要一个宽度为 3840x1080 的大 canvas。我认为Javascript没有足够的时间来做垃圾收集器,当我尝试做单记录时,分辨率为1920*1080,一切正常。

在这里,如果我们要在大 canvas 上执行 captureStream,我们应该选择牺牲以下两件事之一:

  • 将 canvas 捕获流设置为更低的 fps,例如 15 或 10 fps,例如, this.captureStream(10) 10 帧/秒

  • 第二个选项是在不牺牲 fps 的情况下重新缩放到更小的 canvas 大小。