VR漫游视频完整版

javascript 复制代码
async function autoRecordRealseeVR({
  // "9:16" 竖屏;"16:9" 横屏
  aspectRatio = "9:16",

  portraitWidth = 540,
  portraitHeight = 960,
  landscapeWidth = 1280,
  landscapeHeight = 720,

  // 三维模型录制前的放大参数
  modelZoomTimes = 3,
  modelZoomDelta = -480,
  modelZoomIntervalMs = 450,
  modelZoomWaitMs = 1200,

  // 三维模型和房间旋转时长
  modelRotateSeconds = 25,
  roomRotateSeconds = 40,

  modelLoadSeconds = 3,
  roomLoadSeconds = 5,
  staySeconds = 1.2,

  // 旋转方向相反时改成 1
  direction = -1,

  rotateFPS = 30,
  recordingFPS = 30,
  videoBitsPerSecond = 14_000_000,

  hideBottomUI = true,
  hideTitle = false,
  hideMiniMap = false,

  excludeRooms = [],
  maxRooms = 30
} = {}) {
  if (window.REALSEE_AUTO_RECORDER?.running) {
    console.warn(
      "脚本正在运行。停止请输入:window.REALSEE_AUTO_RECORDER.stop()"
    );
    return;
  }

  const sleep = ms =>
    new Promise(resolve => setTimeout(resolve, ms));

  const normalizeText = value =>
    String(value || "")
      .replace(/\s+/g, "")
      .trim();

  const safeFilename = value =>
    normalizeText(value)
      .replace(/[\\/:*?"<>|]/g, "_") ||
    "未命名";

  const selectedRatio =
    String(aspectRatio)
      .replace(":", ":")
      .replace(/\s+/g, "") === "16:9"
      ? "16:9"
      : "9:16";

  const targetSize =
    selectedRatio === "16:9"
      ? {
          width: landscapeWidth,
          height: landscapeHeight
        }
      : {
          width: portraitWidth,
          height: portraitHeight
        };

  let stopped = false;
  let stream = null;
  let activeRecorder = null;
  let pageRestored = false;

  const completedRooms = new Set();
  const failedRooms = new Set();

  console.log(
    `[如视录制] 启动,比例:${selectedRatio},` +
    `尺寸:${targetSize.width}×${targetSize.height}`
  );

  /* =====================================================
     保存网页原始样式
  ===================================================== */

  const originalApp = document.getElementById("app");
  const originalVapor = document.getElementById("vapor");

  const originalStyles = {
    html:
      document.documentElement.getAttribute("style"),

    body:
      document.body.getAttribute("style"),

    app:
      originalApp?.getAttribute("style") ?? null,

    vapor:
      originalVapor?.getAttribute("style") ?? null
  };

  /* =====================================================
     清理旧脚本元素
  ===================================================== */

  [
    "realsee-clean-recording-style",
    "realsee-v3-panel",
    "realsee-batch-panel",
    "realsee-auto-record-panel",
    "realsee-video-status",
    "realsee-room-record-panel",
    "realsee-click-record-panel",
    "realsee-record-panel"
  ].forEach(id => {
    document.getElementById(id)?.remove();
  });

  /* =====================================================
     隐藏无关网页 UI
  ===================================================== */

  const cleanStyle =
    document.createElement("style");

  cleanStyle.id =
    "realsee-clean-recording-style";

  cleanStyle.textContent = `
    html,
    body {
      margin: 0 !important;
      padding: 0 !important;
      overflow: hidden !important;
      background: #000 !important;
    }

    body {
      display: flex !important;
      align-items: center !important;
      justify-content: center !important;
    }

    #app,
    #vapor {
      overflow: hidden !important;
      background: #000 !important;
    }

    #realsee-v3-panel,
    #realsee-batch-panel,
    #realsee-auto-record-panel,
    #realsee-video-status,
    #realsee-room-record-panel,
    #realsee-click-record-panel,
    #realsee-record-panel {
      display: none !important;
      visibility: hidden !important;
      opacity: 0 !important;
      pointer-events: none !important;
    }

    ${
      hideBottomUI
        ? `
      .vapor-bottom-bar,
      .vapor-single-bottom-bar-theme-arc_beauty-addition-side,
      .vapor-single-bottom-bar-theme-arc_beauty-front,
      .vapor-single-bottom-bar-theme-arc_beauty-front-content,
      .vapor-avatar-horizontal,
      .vapor-overlay
      .vapor-h-stack.vapor-h-stack-h-align-start.vapor-h-stack-v-align-center {
        display: none !important;
        visibility: hidden !important;
        opacity: 0 !important;
        pointer-events: none !important;
      }
    `
        : ""
    }

    ${
      hideTitle
        ? `
      .header_header__3KWjy
      > .vapor-v-stack-wrap
      > .vapor-v-stack
      > .vapor-block-wrap
      .vapor-h-stack-h-align-start
      .vapor-text-warp {
        visibility: hidden !important;
      }

      .header_modeTabs__rvuNw,
      .header_modeTabs__rvuNw * {
        visibility: visible !important;
      }
    `
        : ""
    }

    ${
      hideMiniMap
        ? `
      .minimap_miniViewport__ZZPy2,
      [class*="minimap_miniViewport"] {
        display: none !important;
      }
    `
        : ""
    }
  `;

  document.head.appendChild(cleanStyle);

  /* =====================================================
     设置 9:16 / 16:9 页面区域
  ===================================================== */

  const applyAspectRatio = () => {
    const app = document.getElementById("app");
    const vapor = document.getElementById("vapor");

    if (!app || !vapor) {
      console.warn("[如视录制] 没有找到 #app 或 #vapor");
      return;
    }

    const targetRatio =
      targetSize.width / targetSize.height;

    const availableWidth =
      window.innerWidth;

    const availableHeight =
      window.innerHeight;

    let displayWidth =
      availableWidth;

    let displayHeight =
      displayWidth / targetRatio;

    if (displayHeight > availableHeight) {
      displayHeight =
        availableHeight;

      displayWidth =
        displayHeight * targetRatio;
    }

    Object.assign(app.style, {
      width: `${displayWidth}px`,
      height: `${displayHeight}px`,
      position: "relative",
      overflow: "hidden",
      flex: "0 0 auto"
    });

    Object.assign(vapor.style, {
      width: `${displayWidth}px`,
      height: `${displayHeight}px`,
      maxWidth: "none",
      maxHeight: "none",
      overflow: "hidden"
    });

    window.dispatchEvent(
      new Event("resize")
    );

    console.log(
      `[如视录制] 页面区域:` +
      `${Math.round(displayWidth)}×` +
      `${Math.round(displayHeight)}`
    );
  };

  try {
    const browserChromeWidth =
      window.outerWidth -
      window.innerWidth;

    const browserChromeHeight =
      window.outerHeight -
      window.innerHeight;

    window.resizeTo(
      targetSize.width +
      browserChromeWidth,

      targetSize.height +
      browserChromeHeight
    );
  } catch {}

  applyAspectRatio();

  const resizeHandler = () => {
    applyAspectRatio();
  };

  window.addEventListener(
    "resize",
    resizeHandler
  );

  /* =====================================================
     恢复网页
  ===================================================== */

  const restoreAttribute = (
    element,
    attribute,
    value
  ) => {
    if (!element) return;

    if (value === null) {
      element.removeAttribute(attribute);
    } else {
      element.setAttribute(
        attribute,
        value
      );
    }
  };

  const restorePage = () => {
    if (pageRestored) return;

    pageRestored = true;

    window.removeEventListener(
      "resize",
      resizeHandler
    );

    document
      .getElementById(
        "realsee-clean-recording-style"
      )
      ?.remove();

    restoreAttribute(
      document.documentElement,
      "style",
      originalStyles.html
    );

    restoreAttribute(
      document.body,
      "style",
      originalStyles.body
    );

    restoreAttribute(
      document.getElementById("app"),
      "style",
      originalStyles.app
    );

    restoreAttribute(
      document.getElementById("vapor"),
      "style",
      originalStyles.vapor
    );

    window.dispatchEvent(
      new Event("resize")
    );

    console.log("[如视录制] 网页布局已恢复");
  };

  /* =====================================================
     顶部菜单
  ===================================================== */

  const findMenuButton = menuName =>
    [...document.querySelectorAll("button")]
      .find(button =>
        normalizeText(
          button.textContent
        ) === menuName
      );

  const clickMenu = async menuName => {
    for (
      let attempt = 0;
      attempt < 30;
      attempt++
    ) {
      const button =
        findMenuButton(menuName);

      if (button) {
        button.click();
        await sleep(800);
        return true;
      }

      await sleep(200);
    }

    throw new Error(
      `没有找到菜单:${menuName}`
    );
  };

  const isMenuActive = menuName => {
    const button =
      findMenuButton(menuName);

    if (!button) return false;

    return Boolean(
      button.closest(
        "[class*='active']"
      ) ||
      button.parentElement?.closest(
        "[class*='active']"
      )
    );
  };

  /* =====================================================
     查找主 Canvas
  ===================================================== */

  const isVisible = element => {
    if (!(element instanceof HTMLElement)) {
      return false;
    }

    const style =
      getComputedStyle(element);

    const rect =
      element.getBoundingClientRect();

    return (
      style.display !== "none" &&
      style.visibility !== "hidden" &&
      rect.width > 20 &&
      rect.height > 20
    );
  };

  const getMainCanvas = () =>
    [
      ...document.querySelectorAll(
        "#viewports canvas"
      )
    ]
      .filter(isVisible)
      .sort((a, b) => {
        const rectA =
          a.getBoundingClientRect();

        const rectB =
          b.getBoundingClientRect();

        return (
          rectB.width *
          rectB.height -
          rectA.width *
          rectA.height
        );
      })[0] || null;

  /* =====================================================
     鼠标和滚轮事件
  ===================================================== */

  const dispatchPointer = (
    target,
    type,
    x,
    y,
    buttons,
    pointerId = 51
  ) => {
    const options = {
      bubbles: true,
      cancelable: true,
      composed: true,
      clientX: x,
      clientY: y,
      screenX: x,
      screenY: y,
      button: 0,
      buttons,
      pointerId,
      pointerType: "mouse",
      isPrimary: true
    };

    try {
      target.dispatchEvent(
        new PointerEvent(
          type,
          options
        )
      );
    } catch {}

    const mouseType = {
      pointerdown: "mousedown",
      pointermove: "mousemove",
      pointerup: "mouseup"
    }[type];

    if (mouseType) {
      try {
        target.dispatchEvent(
          new MouseEvent(
            mouseType,
            options
          )
        );
      } catch {}
    }
  };

  const realClick = element => {
    if (!element) return false;

    const rect =
      element.getBoundingClientRect();

    const x =
      rect.left +
      rect.width / 2;

    const y =
      rect.top +
      rect.height / 2;

    const common = {
      bubbles: true,
      cancelable: true,
      composed: true,
      clientX: x,
      clientY: y,
      screenX: x,
      screenY: y,
      button: 0
    };

    try {
      element.dispatchEvent(
        new PointerEvent(
          "pointerdown",
          {
            ...common,
            buttons: 1,
            pointerId: 88,
            pointerType: "mouse",
            isPrimary: true
          }
        )
      );

      element.dispatchEvent(
        new PointerEvent(
          "pointerup",
          {
            ...common,
            buttons: 0,
            pointerId: 88,
            pointerType: "mouse",
            isPrimary: true
          }
        )
      );
    } catch {}

    try {
      element.dispatchEvent(
        new MouseEvent(
          "mousedown",
          {
            ...common,
            buttons: 1
          }
        )
      );

      element.dispatchEvent(
        new MouseEvent(
          "mouseup",
          {
            ...common,
            buttons: 0
          }
        )
      );

      element.dispatchEvent(
        new MouseEvent(
          "click",
          {
            ...common,
            buttons: 0
          }
        )
      );
    } catch {}

    try {
      element.click();
    } catch {}

    return true;
  };

  const realClickAt = (
    target,
    x,
    y
  ) => {
    const common = {
      bubbles: true,
      cancelable: true,
      composed: true,
      clientX: x,
      clientY: y,
      screenX: x,
      screenY: y,
      button: 0
    };

    try {
      target.dispatchEvent(
        new PointerEvent(
          "pointerdown",
          {
            ...common,
            buttons: 1,
            pointerId: 99,
            pointerType: "mouse",
            isPrimary: true
          }
        )
      );

      target.dispatchEvent(
        new PointerEvent(
          "pointerup",
          {
            ...common,
            buttons: 0,
            pointerId: 99,
            pointerType: "mouse",
            isPrimary: true
          }
        )
      );

      target.dispatchEvent(
        new MouseEvent(
          "click",
          {
            ...common,
            buttons: 0
          }
        )
      );
    } catch {}
  };

  /* =====================================================
     三维模型滚轮放大
  ===================================================== */

  const zoomModelByWheel = async ({
    times,
    delta,
    intervalMs,
    waitMs
  }) => {
    const canvas =
      getMainCanvas();

    if (!canvas) {
      throw new Error(
        "没有找到三维模型 Canvas"
      );
    }

    const rect =
      canvas.getBoundingClientRect();

    const x =
      rect.left +
      rect.width * 0.5;

    const y =
      rect.top +
      rect.height * 0.52;

    for (
      let index = 0;
      index < times &&
      !stopped;
      index++
    ) {
      try {
        canvas.dispatchEvent(
          new WheelEvent(
            "wheel",
            {
              bubbles: true,
              cancelable: true,
              composed: true,
              clientX: x,
              clientY: y,
              screenX: x,
              screenY: y,
              deltaX: 0,
              deltaY: delta,
              deltaMode: 0
            }
          )
        );
      } catch (error) {
        console.warn(
          "[如视录制] 模型放大失败:",
          error
        );
      }

      console.log(
        `[如视录制] 三维模型放大:` +
        `${index + 1}/${times}`
      );

      await sleep(intervalMs);
    }

    await sleep(waitMs);
  };

  /* =====================================================
     缓慢旋转 360°
  ===================================================== */

  const rotateCanvas360 = async ({
    seconds,
    title
  }) => {
    const canvas =
      getMainCanvas();

    if (!canvas) {
      throw new Error(
        "没有找到 VR 主画布"
      );
    }

    const segmentCount =
      Math.max(
        6,
        Math.round(
          seconds / 5
        )
      );

    const segmentDuration =
      seconds *
      1000 /
      segmentCount;

    for (
      let segment = 0;
      segment <
        segmentCount &&
      !stopped;
      segment++
    ) {
      const rect =
        canvas.getBoundingClientRect();

      const y =
        rect.top +
        rect.height * 0.52;

      const startX =
        direction < 0
          ? rect.left +
            rect.width * 0.74
          : rect.left +
            rect.width * 0.26;

      const endX =
        direction < 0
          ? rect.left +
            rect.width * 0.26
          : rect.left +
            rect.width * 0.74;

      const frames =
        Math.max(
          1,
          Math.round(
            segmentDuration /
            1000 *
            rotateFPS
          )
        );

      const pointerId =
        100 + segment;

      dispatchPointer(
        canvas,
        "pointerdown",
        startX,
        y,
        1,
        pointerId
      );

      const startedAt =
        performance.now();

      for (
        let frame = 0;
        frame <= frames &&
        !stopped;
        frame++
      ) {
        const progress =
          frame / frames;

        const smooth =
          progress *
          progress *
          (3 - 2 * progress);

        const x =
          startX +
          (
            endX -
            startX
          ) *
          smooth;

        dispatchPointer(
          canvas,
          "pointermove",
          x,
          y,
          1,
          pointerId
        );

        const totalProgress =
          (
            segment +
            progress
          ) /
          segmentCount;

        console.log(
          `[如视录制] ${title}:` +
          `${Math.round(
            totalProgress * 100
          )}%`
        );

        const targetTime =
          startedAt +
          (frame + 1) *
          (
            segmentDuration /
            frames
          );

        await sleep(
          Math.max(
            1,
            targetTime -
            performance.now()
          )
        );
      }

      dispatchPointer(
        canvas,
        "pointerup",
        endX,
        y,
        0,
        pointerId
      );

      await sleep(70);
    }

    await sleep(
      staySeconds * 1000
    );
  };

  /* =====================================================
     房间名称识别
  ===================================================== */

  const roomNamePattern =
    /客餐厅|客厅|餐厅|主卧|次卧|卧室|儿童房|老人房|书房|厨房|卫生间|洗手间|浴室|阳台|玄关|走廊|过道|衣帽间|储藏室|储物间|保姆间|设备间|茶室|多功能房/;

  const isRoomName = name =>
    Boolean(
      name &&
      name.length <= 20 &&
      roomNamePattern.test(name) &&
      !excludeRooms.some(
        excluded =>
          name.includes(
            excluded
          )
      )
    );

  const collectAllRoomNames = () => {
    const names =
      new Set();

    document
      .querySelectorAll(
        ".plugin-DoorLabelPlugin-txt"
      )
      .forEach(element => {
        const name =
          normalizeText(
            element.textContent
          );

        if (isRoomName(name)) {
          names.add(name);
        }
      });

    document
      .querySelectorAll(
        [
          ".model-room-label-plugin-container *",
          "#viewports span",
          "#viewports div",
          "[class*='room-label']",
          "[class*='roomLabel']",
          "[class*='RoomLabel']",
          "[class*='model-room']"
        ].join(",")
      )
      .forEach(element => {
        const text =
          normalizeText(
            element.textContent
          );

        if (isRoomName(text)) {
          names.add(text);
        }
      });

    return [...names]
      .slice(0, maxRooms);
  };

  /* =====================================================
     三维模型房间标签
  ===================================================== */

  const getClickableParent =
    element => {
      if (!element) {
        return null;
      }

      return (
        element.closest(
          [
            "button",
            "[role='button']",
            "[onclick]",
            "[style*='pointer-events: auto']",
            "[class*='room']",
            "[class*='Room']",
            "[class*='label']",
            "[class*='Label']",
            "[class*='item']",
            "[class*='Item']"
          ].join(",")
        ) ||
        element.parentElement ||
        element
      );
    };

  const findModelRoomCandidates =
    roomName => {
      const candidates = [];

      const elements = [
        ...document.querySelectorAll(
          [
            ".model-room-label-plugin-container *",
            "#viewports span",
            "#viewports div",
            "[class*='room-label']",
            "[class*='roomLabel']",
            "[class*='RoomLabel']",
            "[class*='model-room']"
          ].join(",")
        )
      ];

      for (
        const element of
        elements
      ) {
        const text =
          normalizeText(
            element.textContent
          );

        if (text !== roomName) {
          continue;
        }

        const clickable =
          getClickableParent(
            element
          );

        const rect =
          clickable
            .getBoundingClientRect();

        const style =
          getComputedStyle(
            clickable
          );

        candidates.push({
          element: clickable,
          rect,
          score:
            (
              rect.width > 0
                ? 10
                : 0
            ) +
            (
              rect.height > 0
                ? 10
                : 0
            ) +
            (
              style.pointerEvents !==
              "none"
                ? 20
                : 0
            ) +
            (
              Number(
                style.opacity
              ) > 0.4
                ? 20
                : 0
            ) +
            (
              rect.left >= 0 &&
              rect.top >= 0 &&
              rect.right <=
                innerWidth &&
              rect.bottom <=
                innerHeight
                ? 30
                : 0
            )
        });
      }

      return candidates.sort(
        (a, b) =>
          b.score -
          a.score
      );
    };

  const clickModelRoom =
    async roomName => {
      for (
        let attempt = 1;
        attempt <= 8;
        attempt++
      ) {
        if (
          !isMenuActive(
            "三维模型"
          )
        ) {
          await clickMenu(
            "三维模型"
          );

          await sleep(
            modelLoadSeconds *
            1000
          );
        }

        const candidates =
          findModelRoomCandidates(
            roomName
          );

        console.log(
          `[如视录制] ${roomName} 候选:`,
          candidates
        );

        for (
          const candidate of
          candidates
        ) {
          console.log(
            `[如视录制] 进入 ${roomName},` +
            `尝试 ${attempt}/8`
          );

          realClick(
            candidate.element
          );

          await sleep(900);

          if (
            isMenuActive("漫游")
          ) {
            await sleep(
              roomLoadSeconds *
              1000
            );

            return true;
          }

          if (
            candidate.rect.width >
              0 &&
            candidate.rect.height >
              0
          ) {
            const canvas =
              getMainCanvas();

            if (canvas) {
              const x =
                candidate.rect.left +
                candidate.rect.width /
                2;

              const y =
                candidate.rect.top +
                candidate.rect.height /
                2;

              realClickAt(
                canvas,
                x,
                y
              );

              await sleep(800);
            }
          }

          if (
            isMenuActive("漫游")
          ) {
            await sleep(
              roomLoadSeconds *
              1000
            );

            return true;
          }
        }

        if (candidates.length) {
          await clickMenu(
            "漫游"
          );

          await sleep(
            roomLoadSeconds *
            1000
          );

          if (
            isMenuActive(
              "漫游"
            )
          ) {
            return true;
          }
        }

        await sleep(500);
      }

      return false;
    };

  /* =====================================================
     MP4 检测
  ===================================================== */

  const mp4Types = [
    'video/mp4;codecs="avc1.42E01E"',
    'video/mp4;codecs="avc1.42001E"',
    "video/mp4;codecs=h264",
    "video/mp4"
  ];

  const mimeType =
    mp4Types.find(type =>
      MediaRecorder
        .isTypeSupported(type)
    );

  if (!mimeType) {
    restorePage();

    throw new Error(
      "浏览器不支持直接录制 MP4," +
      "请使用最新版 Edge 或 Chrome"
    );
  }

  /* =====================================================
     请求一次标签页共享
  ===================================================== */

  console.log(
    "[如视录制] 请选择当前 VR 标签页"
  );

  stream =
    await navigator
      .mediaDevices
      .getDisplayMedia({
        video: {
          frameRate: {
            ideal:
              recordingFPS,
            max:
              recordingFPS
          },

          width: {
            ideal:
              targetSize.width
          },

          height: {
            ideal:
              targetSize.height
          }
        },

        audio: false,

        preferCurrentTab: true,
        selfBrowserSurface:
          "include",
        surfaceSwitching:
          "exclude"
      });

  /* =====================================================
     单独录制一个文件
  ===================================================== */

  const recordOneFile =
    async (
      fileName,
      action
    ) => {
      if (stopped) {
        return false;
      }

      const chunks = [];

      const recorder =
        new MediaRecorder(
          stream,
          {
            mimeType,
            videoBitsPerSecond
          }
        );

      activeRecorder =
        recorder;

      recorder.addEventListener(
        "dataavailable",
        event => {
          if (
            event.data?.size >
            0
          ) {
            chunks.push(
              event.data
            );
          }
        }
      );

      const savePromise =
        new Promise(
          (resolve, reject) => {
            recorder.addEventListener(
              "stop",
              () => {
                try {
                  const blob =
                    new Blob(
                      chunks,
                      {
                        type:
                          mimeType
                      }
                    );

                  if (!blob.size) {
                    reject(
                      new Error(
                        `${fileName}.mp4 录像为空`
                      )
                    );

                    return;
                  }

                  const url =
                    URL.createObjectURL(
                      blob
                    );

                  const link =
                    document.createElement(
                      "a"
                    );

                  link.href =
                    url;

                  link.download =
                    `${safeFilename(
                      fileName
                    )}.mp4`;

                  link.style.display =
                    "none";

                  document.body
                    .appendChild(
                      link
                    );

                  link.click();
                  link.remove();

                  setTimeout(() => {
                    URL.revokeObjectURL(
                      url
                    );
                  }, 120000);

                  console.log(
                    `[如视录制] 已保存:` +
                    `${fileName}.mp4,` +
                    `${(
                      blob.size /
                      1024 /
                      1024
                    ).toFixed(1)} MB`
                  );

                  resolve(true);
                } catch (error) {
                  reject(error);
                }
              },
              {
                once: true
              }
            );
          }
        );

      recorder.start(1000);

      try {
        await action();
      } finally {
        if (
          recorder.state !==
          "inactive"
        ) {
          try {
            recorder.requestData();
          } catch {}

          recorder.stop();
        }
      }

      await savePromise;

      activeRecorder = null;

      await sleep(1200);

      return true;
    };

  /* =====================================================
     停止函数
  ===================================================== */

  const stop = () => {
    if (stopped) return;

    stopped = true;

    console.log(
      "[如视录制] 正在停止并保存当前视频"
    );

    try {
      if (
        activeRecorder &&
        activeRecorder.state !==
          "inactive"
      ) {
        activeRecorder
          .requestData();

        activeRecorder.stop();
      }
    } catch {}

    setTimeout(() => {
      stream
        ?.getTracks()
        .forEach(track => {
          try {
            track.stop();
          } catch {}
        });

      restorePage();
    }, 800);

    if (
      window
        .REALSEE_AUTO_RECORDER
    ) {
      window
        .REALSEE_AUTO_RECORDER
        .running = false;
    }
  };

  window.REALSEE_AUTO_RECORDER = {
    running: true,
    stop,
    completedRooms,
    failedRooms,
    collectAllRoomNames,
    findModelRoomCandidates,
    aspectRatio:
      selectedRatio,
    targetSize
  };

  stream
    .getVideoTracks()[0]
    ?.addEventListener(
      "ended",
      () => {
        stopped = true;

        restorePage();

        if (
          window
            .REALSEE_AUTO_RECORDER
        ) {
          window
            .REALSEE_AUTO_RECORDER
            .running = false;
        }
      }
    );

  try {
    /* ===================================================
       打开三维模型
    =================================================== */

    console.log(
      "[如视录制] 打开三维模型"
    );

    await clickMenu(
      "三维模型"
    );

    await sleep(
      modelLoadSeconds *
      1000
    );

    let roomNames =
      collectAllRoomNames();

    if (
      roomNames.length <
      2
    ) {
      await sleep(2500);

      roomNames =
        collectAllRoomNames();
    }

    roomNames = [
      ...new Set(
        roomNames
      )
    ].slice(
      0,
      maxRooms
    );

    console.log(
      "[如视录制] 房间列表:",
      roomNames
    );

    console.table(
      roomNames.map(
        (name, index) => ({
          顺序:
            index + 1,
          房间:
            name
        })
      )
    );

    if (!roomNames.length) {
      throw new Error(
        "没有检测到房间名称"
      );
    }

    /* ===================================================
       先放大三维模型,不录制放大过程
    =================================================== */

    console.log(
      `[如视录制] 三维模型放大 ${modelZoomTimes} 次`
    );

    await zoomModelByWheel({
      times:
        modelZoomTimes,

      delta:
        modelZoomDelta,

      intervalMs:
        modelZoomIntervalMs,

      waitMs:
        modelZoomWaitMs
    });

    /* ===================================================
       放大后录制三维模型
    =================================================== */

    await recordOneFile(
      "三维模型",
      async () => {
        await rotateCanvas360({
          seconds:
            modelRotateSeconds,

          title:
            "三维模型"
        });
      }
    );

    /* ===================================================
       逐房间录制
    =================================================== */

    for (
      let index = 0;
      index <
        roomNames.length &&
      !stopped;
      index++
    ) {
      const roomName =
        roomNames[index];

      if (
        completedRooms.has(
          roomName
        )
      ) {
        continue;
      }

      console.log(
        `[如视录制] 准备录制:` +
        `${roomName}.mp4,` +
        `${index + 1}/` +
        `${roomNames.length}`
      );

      await clickMenu(
        "三维模型"
      );

      await sleep(
        modelLoadSeconds *
        1000
      );

      const entered =
        await clickModelRoom(
          roomName
        );

      if (!entered) {
        console.warn(
          `[如视录制] 无法进入:` +
          roomName
        );

        failedRooms.add(
          roomName
        );

        continue;
      }

      if (
        !isMenuActive(
          "漫游"
        )
      ) {
        await clickMenu(
          "漫游"
        );

        await sleep(
          roomLoadSeconds *
          1000
        );
      }

      await recordOneFile(
        roomName,
        async () => {
          await rotateCanvas360({
            seconds:
              roomRotateSeconds,

            title:
              `${roomName} ` +
              `${index + 1}/` +
              `${roomNames.length}`
          });
        }
      );

      completedRooms.add(
        roomName
      );

      console.log(
        `[如视录制] 完成:` +
        `${roomName}.mp4`
      );
    }

    /* ===================================================
       重试失败房间
    =================================================== */

    if (
      failedRooms.size &&
      !stopped
    ) {
      const retryRooms =
        [...failedRooms];

      for (
        let index = 0;
        index <
          retryRooms.length &&
        !stopped;
        index++
      ) {
        const roomName =
          retryRooms[index];

        console.log(
          `[如视录制] 重试:` +
          roomName
        );

        await clickMenu(
          "三维模型"
        );

        await sleep(
          modelLoadSeconds *
          1000 +
          1500
        );

        const entered =
          await clickModelRoom(
            roomName
          );

        if (!entered) {
          continue;
        }

        if (
          !isMenuActive(
            "漫游"
          )
        ) {
          await clickMenu(
            "漫游"
          );

          await sleep(
            roomLoadSeconds *
            1000
          );
        }

        await recordOneFile(
          roomName,
          async () => {
            await rotateCanvas360({
              seconds:
                roomRotateSeconds,

              title:
                `重试 ${roomName}`
            });
          }
        );

        completedRooms.add(
          roomName
        );

        failedRooms.delete(
          roomName
        );
      }
    }

    /* ===================================================
       全部结束
    =================================================== */

    stream
      .getTracks()
      .forEach(track => {
        try {
          track.stop();
        } catch {}
      });

    window
      .REALSEE_AUTO_RECORDER
      .running = false;

    restorePage();

    console.log(
      "[如视录制] 全部录制完成"
    );

    console.log(
      "成功房间:",
      [...completedRooms]
    );

    console.log(
      "失败房间:",
      [...failedRooms]
    );

    console.log(
      `共保存 ${
        completedRooms.size + 1
      } 个 MP4 视频`
    );
  } catch (error) {
    console.error(
      "[如视录制] 执行错误:",
      error
    );

    stop();
  }
}


/* =====================================================
   最底部直接调用
===================================================== */

autoRecordRealseeVR({
  // 默认竖屏;横屏改成 "16:9"
  aspectRatio: "9:16",

  portraitWidth: 540,
  portraitHeight: 960,

  landscapeWidth: 1280,
  landscapeHeight: 720,

  // 三维模型录制前放大三次
  modelZoomTimes: 3,
  modelZoomDelta: -480,
  modelZoomIntervalMs: 450,
  modelZoomWaitMs: 1200,

  modelRotateSeconds: 25,
  roomRotateSeconds: 40,

  modelLoadSeconds: 3,
  roomLoadSeconds: 5,
  staySeconds: 1.2,

  direction: -1,

  hideBottomUI: true,
  hideTitle: false,
  hideMiniMap: false,

  excludeRooms: [],
  maxRooms: 30
});
相关推荐
小程故事多_802 小时前
拆解SOP+ReAct对话Agent,告别硬编码,解锁智能对话的柔性迭代能力
前端·人工智能·react.js·前端框架
lzjava20242 小时前
LangChain Prompt提示词工程
前端·langchain·prompt
nicole bai2 小时前
vue 自定义模块内容
前端·javascript·vue.js
音视频牛哥2 小时前
从RFC到工程落地:SmartMediaKit 低延迟RTSP播放器技术深度解析
音视频·rtsp播放器·rtsp播放器安卓端·rtspplayer·鸿蒙next rtsp播放器·rtsp播放器linux端·rtsp播放器windows端
西安老张(AIGC&ComfyUI)2 小时前
第026章:从坑里上来了!ComfyUI文生音频IndexTTS2+Qwen3-TTS本地部署!
音视频
清水白石0082 小时前
Python 类定义阶段自动注册子类:从 `__init_subclass__` 到插件系统实战
linux·前端·python
西西学代码3 小时前
Flutter---StreamBuilder案例
javascript·windows·flutter
用户69371750013843 小时前
Kimi K3 综合能力处于全球第一梯队
android·前端·后端
anno3 小时前
Agent 新手 Skill 优先级指南:从第一套必装工作流,到专业能力补全
前端·后端