豆包无水印图片下载

为豆包添加无水印图片下载功能

// ==UserScript==
// @name         豆包无水印图片下载
// @namespace    http://tampermonkey.net/
// @version      1.3.0
// @description  为豆包添加无水印图片下载功能
// @author       Qalxry
// @license      GPL-3.0
// @supportURL   https://github.com/Qalxry/doubao-no-watermark
// @icon         https://lf-flow-web-cdn.doubao.com/obj/flow-doubao/doubao/web/logo-icon.png
// @match        https://*.doubao.com/*
// @grant        none
// ==/UserScript==

(function () {
  "use strict";

  let selectedImage = null;
  let selectedImageContainer = null;
  let selectedImageUrl = null;

  const toastDiv = document.createElement("div");
  toastDiv.style.cssText = `
    position: fixed;
    top: 40%;
    left: 50%;
    transform: translateX(-50%);
    background: #fff8f8ff;
    color: #ff6060;
    padding: 10px 20px;
    border: 1px solid #ff6060;
    border-radius: 10px;
    z-index: 99999;
    font-family: sans-serif;
    box-shadow: 0 2px 10px rgba(0,0,0,0.2);
    transition: opacity 0.3s ease;
    display: none;
    text-align: center;
    opacity: 0.8;
  `;
  toastDiv.innerHTML = "消息内容";
  document.body.appendChild(toastDiv);

  function showToast(message, duration = 3000) {
    toastDiv.innerHTML = message;
    if (toastDiv.style.display === "block") {
      // 如果已经显示,先清除之前的隐藏定时器
      clearTimeout(toastDiv.hideTimeout);
    }
    toastDiv.style.display = "block";
    toastDiv.style.opacity = "1";

    if (duration <= 0) return; // 不自动关闭
    // 设置一个定时器来隐藏
    toastDiv.hideTimeout = setTimeout(() => {
      toastDiv.style.opacity = "0";
      toastDiv.hideTimeout = setTimeout(() => {
        toastDiv.style.display = "none";
      }, 300); // 等待过渡效果结束后再隐藏
    }, duration);
  }

  document.addEventListener(
    "contextmenu",
    (e) => {
      if (e.target.matches?.("img")) {
        selectedImage = e.target;
        console.log("选中的图片:", selectedImage);

        // 该逻辑已废弃,因为豆包图片结构改版
        // // 向上找到第一个 class 为 "image-box-grid-item-{xxxxx} " 的 div
        // let element = e.target;
        // while (element && element !== document) {
        //     if (
        //         element.classList &&
        //         element.classList.length &&
        //         element.classList[0].startsWith("image-box-grid-item-")
        //     ) {
        //         selectedImageContainer = element;
        //         break;
        //     }
        //     element = element.parentElement;
        // }
        // if (!selectedImageContainer) {
        //     console.warn("未找到包含图片的容器,可能是页面结构发生了变化");
        //     return;
        // }
        // const pngImage = selectedImageContainer.querySelector('img[data-testid="in_painting_picture"]');
        // if (pngImage) {
        //     selectedImageUrl = pngImage.src;
        //     console.log("选中的图片 URL:", selectedImageUrl);
        // } else {
        //     console.warn("未找到完整图片,可能是页面结构发生了变化");
        //     return;
        // }
      }
    },
    true
  );
  /**
   * 合并两张图片,用第2张图片的右下部分覆盖第1张图片的右下部分
   * 如果第2张图片大小不等于第1张图片,则将第2张图片缩放至第1张图片大小
   * 并向body插入一个警告,说“该图片缓存已失效,请注意必须在图片刚生成的时候才能成功”
   * @param {Blob} image1_blob
   * @param {Blob} image2_blob
   */
  async function mergeImage(image1_blob, image2_blob) {
    return new Promise((resolve, reject) => {
      const canvas = document.createElement("canvas");
      const ctx = canvas.getContext("2d");

      const img1 = new Image();
      const img2 = new Image();

      let loadedCount = 0;
      let hasError = false;
      let needScale = false; // 标记是否需要缩放

      function checkBothLoaded() {
        loadedCount++;
        if (loadedCount === 2 && !hasError) {
          // 检查图片尺寸是否相同
          if (img1.width !== img2.width || img1.height !== img2.height) {
            needScale = true;
            // 插入警告
            console.warn("图片尺寸不一致,正在尝试缩放第二张图片");
            showToast("该图片缓存已失效,请注意必须在图片刚生成的时候才能成功");

            // 创建临时canvas缩放img2
            const scaleCanvas = document.createElement("canvas");
            scaleCanvas.width = img1.width;
            scaleCanvas.height = img1.height;
            const scaleCtx = scaleCanvas.getContext("2d");

            // 缩放绘制
            scaleCtx.drawImage(img2, 0, 0, img1.width, img1.height);

            // 将缩放后的图片转换为新的Image对象
            scaleCanvas.toBlob((blob) => {
              if (!blob) {
                reject(new Error("缩放图片失败"));
                return;
              }

              const scaledImg = new Image();
              scaledImg.onload = () => {
                // 使用缩放后的图片进行合并
                performMerge(img1, scaledImg);
              };
              scaledImg.onerror = () => {
                reject(new Error("加载缩放后的图片失败"));
              };
              scaledImg.src = URL.createObjectURL(blob);
            }, "image/png");

            return;
          }

          // 尺寸相同直接合并
          performMerge(img1, img2);
        }
      }

      // 执行实际合并操作的函数
      function performMerge(baseImg, overlayImg) {
        canvas.width = baseImg.width;
        canvas.height = baseImg.height;

        // 绘制底图
        ctx.drawImage(baseImg, 0, 0);

        // 计算覆盖区域(右下1/4)
        const copyWidth = overlayImg.width / 2;
        const copyHeight = overlayImg.height / 2;
        const sourceX = overlayImg.width / 2;
        const sourceY = overlayImg.height / 2;
        const destX = baseImg.width / 2;
        const destY = baseImg.height / 2;

        // 覆盖右下角
        ctx.drawImage(overlayImg, sourceX, sourceY, copyWidth, copyHeight, destX, destY, copyWidth, copyHeight);

        // 转换为Blob
        canvas.toBlob((blob) => {
          if (blob) {
            resolve(blob);
          } else {
            reject(new Error("无法生成图片"));
          }
        }, "image/png");
      }

      img1.onload = checkBothLoaded;
      img2.onload = checkBothLoaded;

      img1.onerror = () => {
        hasError = true;
        reject(new Error("加载第一张图片失败"));
      };

      img2.onerror = () => {
        hasError = true;
        reject(new Error("加载第二张图片失败"));
      };

      img1.src = URL.createObjectURL(image1_blob);
      img2.src = URL.createObjectURL(image2_blob);
    });
  }

  /* 轮询直到剪贴板出现图片 */
  async function waitForImageInClipboard(interval = 200, timeout = 10_000) {
    const start = Date.now();
    while (Date.now() - start < timeout) {
      try {
        const items = await navigator.clipboard.read();
        const imgItem = items.find((it) => it.types.some((t) => t.startsWith("image/")));
        if (imgItem) {
          return imgItem.getType(imgItem.types.find((t) => t.startsWith("image/")));
        }
      } catch {
        /* 忽略权限或空剪贴板错误 */
      }
      await new Promise((r) => setTimeout(r, interval));
    }
    throw new Error("未检测到剪贴板图片");
  }

  async function copyAndMerge(selectedImageUrl) {
    const copyDiv = document.querySelector('div[data-testid="right_click_copy_image"]');
    if (!copyDiv) return;

    /* 1. 同步清空剪贴板(必须在同一用户手势里) */
    // 构造一个空的 ClipboardItem,同步写进去
    const emptyItem = new ClipboardItem({ "text/plain": new Blob([""], { type: "text/plain" }) });
    await navigator.clipboard.write([emptyItem]);

    /* 2. 立即触发网页自己的复制按钮 */
    copyDiv.click();

    /* 3. 等待真正出现图片(此时一定是“新”图片) */
    const clipboardBlob = await waitForImageInClipboard(); // 轮询直到出现图片
    const netBlob = await fetch(selectedImageUrl).then((r) => r.blob());
    const mergedBlob = await mergeImage(clipboardBlob, netBlob);

    /* 4. 下载 */
    const url = URL.createObjectURL(mergedBlob);
    const a = document.createElement("a");
    a.href = url;
    a.download = `豆包无水印_${new Date().toISOString().replace(/[:.]/g, "-")}.png`;
    a.style.display = "none";
    document.body.appendChild(a);
    a.click();
    a.remove();
    URL.revokeObjectURL(url);
  }

  const mo = new MutationObserver(() => {
    const menu = document.querySelector('div.semi-dropdown-content div[data-testid="image_context_menu"]');
    if (menu && !menu.querySelector(".tm-inject-selection")) {
      // 找同级第一个有 class 的 div
      const cls = [...menu.children].find((el) => el.classList.length)?.className || "";

      const downloadDiv = document.createElement("div");
      downloadDiv.className = `${cls} tm-inject-selection`;
      downloadDiv.style.color = "#ff6060";
      downloadDiv.innerHTML = `
          <span role="img" class="semi-icon semi-icon-default">
            <svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" fill="none" viewBox="0 0 24 24">
              <path fill="currentColor" d="M19.207 12.707a1 1 0 0 0-1.414-1.414L13 16.086V2a1 1 0 1 0-2 0v14.086l-4.793-4.793a1 1 0 0 0-1.414 1.414l6.5 6.5c.195.195.45.293.706.293H5a1 1 0 1 0 0 2h14a1 1 0 1 0 0-2h-6.999a1 1 0 0 0 .706-.293z"/>
            </svg>
          </span>
          下载无水印图片(仅适用于刚刚生成的图片)
      `;

      downloadDiv.addEventListener("click", async () => {
        if (!selectedImage) {
          console.warn("未选中图片或图片 URL 无效");
          return;
        }
        selectedImage.click(); // 确保 aside 出现
        if (!document.body.querySelector('img[data-testid="in_painting_picture"]')) {
          showToast("打开侧边栏中,请稍候...");
          await new Promise((r) => setTimeout(r, 2000)); // 等待一下
        } else {
          await new Promise((r) => setTimeout(r, 1000)); // 等待一下
        }
        selectedImageUrl = document.body.querySelector('img[data-testid="in_painting_picture"]')?.src;
        if (!selectedImageUrl) {
          console.warn("未找到完整图片,可能是页面结构发生了变化");
          showToast("未找到完整图片,可能是页面结构发生了变化");
          return;
        }
        showToast("开始无水印下载,请勿进行任何操作。<br>如果出现【复制成功】的消息表明工作正常。<br>如果出现【复制失败】的消息,请在本次操作结束后再试一次。", 0);
        try {
          await copyAndMerge(selectedImageUrl);
          console.log("下载成功");
          showToast("操作完成!");
        } catch (error) {
          console.error("下载失败:", error);
          showToast("下载失败,请查看控制台日志!");
        }
      });

      menu.appendChild(downloadDiv);
    }
  });
  mo.observe(document.body, { childList: true });
})();