Masked Watch

動画上のテキストや顔を検出してコメントを透過する

当前为 2019-08-26 提交的版本,查看 最新版本

// ==UserScript==
// @name        Masked Watch
// @namespace   https://github.com/segabito/
// @description 動画上のテキストや顔を検出してコメントを透過する
// @match       *://www.nicovideo.jp/*
// @match       *://live.nicovideo.jp/*
// @match       *://anime.nicovideo.jp/*
// @match       *://embed.nicovideo.jp/watch/*
// @match       *://sp.nicovideo.jp/watch/*
// @exclude     *://ads*.nicovideo.jp/*
// @exclude     *://www.nicovideo.jp/favicon.ico*
// @version     0.2.1
// @grant       none
// @author      名無しさん
// @license     public domain
// ==/UserScript==
/* eslint-disable */


// chrome://flags/#enable-experimental-web-platform-features
(() => {
  const PRODUCT = 'MaskedWatch';

  const monkey = (PRODUCT) => {
    'use strict';
    var VER = '0.2.1';
    const ENV = 'STABLE';

    let ZenzaWatch = null;

    const DEFAULT_CONFIG = {
      interval: 30,
      enabled: true,
      debug: false,
      faceDetection: true,
      textDetection: true,
      fastMode: true,
      width: 160,
      height: 90
    };
    const config = new class extends Function {
      toString() {
        return `
*** CONFIG MENU (設定はサービスごとに保存) ***
enabled: true,       // 有効/無効
debug: false,        // デバッグON/OFF
faceDetection: true, // 顔検出ON/OFF
textDetection: true, // テキスト検出ON/OFF
fastMode: false,     // false 精度重視 true 速度重視
width: 160,          // マスク用キャンバスの横解像度
height: 90           // マスク用キャンバスの縦解像度
`;
      }
    }, def = {};
    Object.keys(DEFAULT_CONFIG).sort().forEach(key => {
      const storageKey = `${PRODUCT}_${key}`;
      def[key] = {
        enumerable: true,
        get() {
          return localStorage.hasOwnProperty(storageKey) ?
            JSON.parse(localStorage[storageKey]) : DEFAULT_CONFIG[key];
        },
        set(value) {
          const currentValue = this[key];
          if (value === currentValue) {
            return;
          }
          if (value === DEFAULT_CONFIG[key]) {
            localStorage.removeItem(storageKey);
          } else {
            localStorage[storageKey] = JSON.stringify(value);
          }
          document.body.dispatchEvent(
            new CustomEvent(`${PRODUCT}-config.update`,
            {detail: {key, value, lastValue: currentValue}, bubbles: true, composed: true}
          ));
        }
      };
    });
    Object.defineProperties(config, def);

    const MaskedWatch = window.MaskedWatch = { config };

    const createWorker = (func, options = {}) => {
      const src = `(${func.toString()})(self);`;
      const blob = new Blob([src], {type: 'text/javascript'});
      const url = URL.createObjectURL(blob);
      return new Worker(url, options);
    };

    const 業務 = function(self) {
      let canvas, ctx, fastMode, faceDetection, textDetection, debug, enabled;
      const init = params => {
        ({canvas} = params);
        ctx = canvas.getContext('2d');
        updateConfig({config: params.config});
      };

      const updateConfig = ({config}) => {
        ({fastMode, faceDetection, textDetection, debug, enabled} = config);
        canvas.width = config.width;
        canvas.height = config.height;
        ctx.fillStyle = 'rgba(255, 255, 255, 1)';
        ctx.fillRect(0, 0, canvas.width, canvas.height);
        faceDetector = new (self || window).FaceDetector({fastMode});
        textDetector = new (self || window).TextDetector({fastMode});
      };

      let faceDetector;
      let textDetector;
      const detect = async ({bitmap}) => {
        const bitmapArea = bitmap.width * bitmap.height;
        const r = bitmap.width / canvas.width;

        // debug && console.time('detect');
        const tasks = [];
        faceDetection && (tasks.push(faceDetector.detect(bitmap)));
        textDetection && (tasks.push(textDetector.detect(bitmap)));
        const detected = (await Promise.all(tasks)).flat();
        // debug && console.timeLog('detect', 'detector.detect');

        ctx.beginPath();
        ctx.fillStyle = 'rgba(255, 255, 255, 0.3)';
        ctx.fillRect(0, 0, canvas.width, canvas.height);
        for (const d of detected) {
          let {x, y , width, height} = d.boundingBox;
          const area = width * height;
          const opacity = area / bitmapArea * 0.75;
          ctx.fillStyle = `rgba(255, 255, 255, ${opacity})`;

          x /= r; y /= r; width /= r; height /= r;
          if (d.landmarks) { // face
            ctx.clearRect(x - 5, y  - 8, width + 10, height + 16);
            ctx.fillRect (x - 5, y  - 8, width + 10, height + 16);
          } else {           // text
            ctx.clearRect(x - 5, y  - 2, width + 10, height +  4);
            ctx.fillRect (x - 5, y  - 2, width + 10, height +  4);
          }
          debug && d.rawValue && console.log('text: ', d.rawValue);
        }
        // debug && console.timeLog('detect', 'draw');

        const dataURL = await toDataURL(canvas);
        // debug && console.timeEnd('detect');
        return dataURL;
      };

      const reader = new FileReader();
      const toDataURL = async (canvas, type = 'image/png') => {
        const blob = await canvas.convertToBlob({type});
        return new Promise((ok, ng) => {
          reader.onload = () => { ok(reader.result); };
          reader.onerror = ng;
          reader.readAsDataURL(blob);
        });
      };

      self.onmessage = async e => {
        const {command, params} = e.data.body;
        switch (command) {
          case 'init':
            init(params);
            self.postMessage({body: {command: 'init', params: {}, status: 'ok'}});
            break;
          case 'config':
            updateConfig(params);
            break;
          case 'detect': {
            const dataURL = await detect(params);
            self.postMessage({body: {command: 'data', params: {dataURL}, status: 'ok'}});
          }
            break;
        }
      };
    };

    const createDetector = ({video, layer, interval, type}) => {
      const worker = createWorker(業務, {name: 'Facelook'});
      const width = 640, height = 360;
      const transferCanvas = new OffscreenCanvas(640, 360);
      const ctx = transferCanvas.getContext('2d', {alpha: false});

      const workCanvas = document.createElement('canvas');
      // for debug
      Object.assign(workCanvas.style, {
        border: '1px solid #888',
        left: 0,
        bottom: '48px',
        position: 'fixed',
        zIndex: '100000',
        width: `${config.width}px`,
        height: `${config.height}px`,
        opacity: 0.5,
        background: '#333',
        pointerEvents: 'none',
        userSelect: 'none'
      });
      workCanvas.classList.add('zen-family');
      workCanvas.dataset.type = type;
      config.debug && document.body.append(workCanvas);
      const offscreenCanvas = workCanvas.transferControlToOffscreen();
      worker.postMessage({body:
        {command: 'init', params: {canvas: offscreenCanvas, config: {...config}}}
      }, [offscreenCanvas]);
      let currentTime = video.currentTime;

      let isBusy = true;
      worker.addEventListener('message', e => {
        const {command, params} = e.data.body;
        switch (command) {
          case 'init':
            console.log('initialized');
            isBusy = false;
            break;
          case 'data': {
            isBusy = false;
            if (!config.enabled) { return; }
            const url = `url('${params.dataURL}')`;
            layer.style.maskImage = url;
            layer.style.webkitMaskImage = url;
          }
          break;
        }
      });

      const onTimer = () => {
        if (isBusy ||
            currentTime === video.currentTime ||
            document.visibilityState !== 'visible') {
          return;
        }

        currentTime = video.currentTime;
        const vw = video.videoWidth, vh = video.videoHeight;
        const ratio = Math.min(width / vw, height / vh);
        const dw = vw * ratio, dh = vh * ratio;

        ctx.drawImage(video, (width - dw) / 2, (height - dh) / 2, dw, dh);
        const bitmap = transferCanvas.transferToImageBitmap();
        isBusy = true;
        worker.postMessage({body: {command: 'detect', params: {bitmap}}}, [bitmap]);
      };
      let timer = setInterval(onTimer, interval);

      const start = () => timer = setInterval(onTimer, interval);
      const stop = () => {
        timer = clearInterval(timer);
        layer.style.maskImage = '';
        layer.style.webkitMaskImage = '';
      };

      window.addEventListener(`${PRODUCT}-config.update`, e => {
        worker.postMessage({body: {command: 'config', params: {config: {...config}}}});
        const {key, value} = e.detail;
        switch (key) {
          case 'enabled':
            value ? start() : stop();
            break;
          case 'debug':
            value ? document.body.append(workCanvas) : workCanvas.remove();
            break;
        }
      }, {passive: true});
      return { start, stop };
    };

    const dialog = ((config) => {
      class MaskedWatchDialog extends HTMLElement {
        init() {
          if (this.shadow) { return; }
          this.shadow = this.attachShadow({mode: 'open'});
          this.shadow.innerHTML = this.getTemplate(config);
          this.root = this.shadow.querySelector('#root');
          this.shadow.querySelector('.close-button').addEventListener('click', e => {
            this.close(); e.stopPropagation(); e.preventDefault();
          });
          this.root.addEventListener('click', e => {
            if (e.target === this.root) { this.close(); }
            e.stopPropagation();
          });
          this.classList.add('zen-family');
          this.root.classList.add('zen-family');
          this.update();

          this.root.addEventListener('change', e => {
            const input = e.target;
            const name = input.name;
            const value = JSON.parse(input.value);
            config.debug && console.log('update config', {name, value});
            config[name] = value;
          });
        }
        getTemplate(config) {
          return `
          <dialog id="root" class="root">
            <div>
            <style>
              .root {
                position: fixed;
                z-index: 10000;
                left: 0;
                top: 50%;
                transform: translate(0, -50%);
                background: rgba(240, 240, 240, 0.95);
                color: #222;
                padding: 16px 24px 8px;
                border: 0;
                user-select: none;
                box-shadow: 0px 0px 4px rgba(0, 0, 0, 0.8);
                text-shadow: 1px 1px 0 #fff;
                border-radius: 4px;
              }
              .title {
                margin: 0;
                padding: 0 0 16px;
                font-size: 20px;
                text-align: center;
              }
              .config {
                padding: 0 0 16px;
                line-height: 20px;
              }
              .name {
                display: inline-block;
                min-width: 200px;
                white-space: nowrap;
                margin: 0;
              }
              label {
                display: inline-block;
                padding: 8px;
                line-height: 20px;
                min-width: 100px;
                border: 1px groove silver;
                border-radius: 4px;
                cursor: pointer;
              }
              label + label {
                margin-left: 8px;
              }
              label:hover {
                background: rgba(255, 255, 255, 1);
              }
              input[type=radio] {
                transform: scale(1.5);
                margin-right: 12px;
              }
              .close-button {
                display: block;
                margin: 8px auto 0;
                min-width: 160px;
                padding: 8px;
                font-size: 16px;
                border-radius: 4px;
                text-align: center;
                cursor: pointer;
                outline: none;
              }
            </style>
            <h1 class="title">††† Masked Watch 設定 †††</h1>
            <div class="config">
              <h3 class="name">顔の検出</h3>
              <label><input type="radio" name="faceDetection" value="true">ON</label>
              <label><input type="radio" name="faceDetection" value="false">OFF</label>
            </div>

            <div class="config">
              <h3 class="name">テキストの検出</h3>
              <label><input type="radio" name="textDetection" value="true">ON</label>
              <label><input type="radio" name="textDetection" value="false">OFF</label>
            </div>

            <div class="config">
              <h3 class="name">動作モード</h3>
              <label><input type="radio" name="fastMode" value="true">速度重視</label>
              <label><input type="radio" name="fastMode" value="false">精度重視</label>
            </div>

            <div class="config">
              <h3 class="name">デバッグ</h3>
              <label><input type="radio" name="debug" value="true">ON</label>
              <label><input type="radio" name="debug" value="false">OFF</label>
            </div>

            <div class="config">
              <h3 class="name">MaskedWatch有効/無効</h3>
              <label><input type="radio" name="enabled" value="true">有効</label>
              <label><input type="radio" name="enabled" value="false">無効</label>
            </div>
            <div class="config">
              <button class="close-button">閉じる</button>
            </div>
            </div>
          </dialog>
          `;
        }

        update() {
          this.init();
          [...this.shadow.querySelectorAll('input')].forEach(input => {
            const name = input.name, value = JSON.parse(input.value);
            input.checked = config[name] === value;
          });
        }

        get isOpen() {
          return !!this.root && !!this.root.open;
        }

        open() {
          this.update();
          this.root.showModal();
        }

        close() {
          this.root && this.root.close();
        }

        toggle() {
          this.init();
          if (this.isOpen) {
            this.root.close();
          } else {
            this.open();
          }
        }
      }
      window.customElements.define(`${PRODUCT.toLowerCase()}-dialog`, MaskedWatchDialog);
      return document.createElement(`${PRODUCT.toLowerCase()}-dialog`);
    })(config);
    MaskedWatch.dialog = dialog;

    const createToggleButton = (config, dialog) => {
      class ToggleButton extends HTMLElement {
        constructor() {
          super();
          this.init();
        }
        init() {
          if (this.shadow) { return; }
          this.shadow = this.attachShadow({mode: 'open'});
          this.shadow.innerHTML = this.getTemplate(config);
          this.root = this.shadow.querySelector('#root');
          this.root.addEventListener('click', e => {
            dialog.toggle(); e.stopPropagation(); e.preventDefault();
          });
        }
        getTemplate() {
          return `
          <style>
          .controlButton {
            position: relative;
            display: inline-block;
            transition: opacity 0.4s ease, margin-left 0.2s ease, margin-top 0.2s ease;
            box-sizing: border-box;
            text-align: center;
            cursor: pointer;
            color: #fff;
            opacity: 0.8;
            vertical-align: middle;
          }
          .controlButton:hover {
            cursor: pointer;
            opacity: 1;
          }
          .controlButton .controlButtonInner {
            filter: grayscale(100%);
          }
          .switch {
            font-size: 16px;
            width: 32px;
            height: 32px;
            line-height: 30px;
            cursor: pointer;
          }
          .is-Enabled .controlButtonInner {
            color: #aef;
            filter: none;
          }

          .controlButton .tooltip {
            display: none;
            pointer-events: none;
            position: absolute;
            left: 16px;
            top: -30px;
            transform:  translate(-50%, 0);
            font-size: 12px;
            line-height: 16px;
            padding: 2px 4px;
            border: 1px solid !000;
            background: #ffc;
            color: #000;
            text-shadow: none;
            white-space: nowrap;
            z-index: 100;
            opacity: 0.8;
          }

          .controlButton:hover {
            background: #222;
          }

          .controlButton:hover .tooltip {
            display: block;
            opacity: 1;
          }

        </style>
        <div id="root" class="switch controlButton root">
          <div class="controlButtonInner" title="MaskedWatch">&#9787;</div>
          <div class="tooltip">Masked Watch</div>
        </div>
            `;
        }
      }
      window.customElements.define(`${PRODUCT.toLowerCase()}-toggle-button`, ToggleButton);
      return document.createElement(`${PRODUCT.toLowerCase()}-toggle-button`);
    };

    const ZenzaDetector = (() => {
      const promise =
        (window.ZenzaWatch && window.ZenzaWatch.ready) ?
          Promise.resolve(window.ZenzaWatch) :
          new Promise(resolve => {
            [window, (document.body || document.documentElement)]
              .forEach(e => e.addEventListener('ZenzaWatchInitialize', () => {
                resolve(window.ZenzaWatch);
              }, {once: true}));
          });
      return {detect: () => promise};
    })();

    const vmap = new WeakMap();
    let timer;
    const watch = () => {
      if (!config.enabled || document.visibilityState !== 'visible') { return; }
      [...document.querySelectorAll('video, zenza-video')]
        .filter(video => !video.paused && !vmap.has(video))
        .forEach(video => {
          // 対応プレイヤー増やすならココ
          let layer, type = 'UNKNOWN';
          if (video.closest('#MainVideoPlayer')) {
            layer = document.querySelector('.CommentRenderer');
            type = 'NICO VIDEO';
          } else if (video.closest('#rootElementId')) {
            layer = document.querySelector('#comment canvas');
            type = 'NICO EMBED';
          } else if (video.closest('#watchVideoContainer')) {
            layer = document.querySelector('#jsPlayerCanvasComment canvas');
            type = 'NICO SP';
          } else if (video.closest('.zenzaPlayerContainer')) {
            layer = document.querySelector('.commentLayerFrame');
            type = 'ZenzaWatch';
          } else if (video.closest('[class*="__leo"]')) {
            layer = document.querySelector('#comment-layer-container canvas');
            type = 'NICO LIVE';
          } else if (video.closest('#bilibiliPlayer')) {
            layer = document.querySelector('.bilibili-player-video-danmaku').parentElement;
            type = 'BILI BILI [´ω`]';
          } else if (video.id === 'js-video') {
            layer = document.querySelector('#cmCanvas');
            type = 'HIMAWARI';
          }

          console.log('%ctype: "%s"', 'font-weight: bold', layer ? type : 'UNKNOWN???');
          layer && Object.assign(layer.style, {
            backgroundSize:     'contain',
            maskSize:           'contain',
            webkitMaskSize:     'contain',
            maskRepeat:         'no-repeat',
            webkitMaskRepeat:   'no-repeat',
            maskPosition:       'center center',
            webkitMaskPosition: 'center center'
          });
          layer && video.dispatchEvent(
            new CustomEvent(`${PRODUCT}-start`,
            {detail: {type, video, layer}, bubbles: true, composed: true}
          ));

          vmap.set(video,
            layer ?
            createDetector({video: video.drawableElement || video, layer, interval: config.interval, type}) :
            type
          );
          layer && !location.href.startsWith('https://www.nicovideo.jp/watch/') && clearInterval(timer);
        });
    };

    const init = () => {
      timer = setInterval(watch, 1000);

      document.body.append(dialog);

      const li = document.createElement('li');
      li.innerHTML = `<a href="javascript:;">${PRODUCT}設定</a>`;
      li.style.whiteSpace = 'nowrap';
      li.addEventListener('click', () => dialog.toggle());
      document.querySelector('#siteHeaderRightMenuContainer').append(li);

      ZenzaDetector.detect().then(zen => {
        console.log('ZenzaWatch found ver.%s', zen.version);
        ZenzaWatch = zen;
        ZenzaWatch.emitter.on('videoControBar.addonMenuReady', (container, handler) => {
          container.append(createToggleButton(config, dialog));
        });
        ZenzaWatch.emitter.on('videoContextMenu.addonMenuReady.list', (menuContainer) => {
          const faceMenu = document.createElement('li');
          faceMenu.className = 'command';
          faceMenu.dataset.command = 'nop';
          faceMenu.textContent = '顔の検出';
          faceMenu.classList.toggle('selected', config.faceDetection);
          faceMenu.addEventListener('click', () => {
            config.faceDetection = !config.faceDetection;
          });
          const textMenu = document.createElement('li');
          textMenu.className = 'command';
          textMenu.dataset.command = 'nop';
          textMenu.textContent = 'テキストの検出';
          textMenu.classList.toggle('selected', config.textDetection);
          textMenu.addEventListener('click', () => {
            config.textDetection = !config.textDetection;
          });
          ZenzaWatch.emitter.on('showMenu', () => {
            faceMenu.classList.toggle('selected', config.faceDetection);
            textMenu.classList.toggle('selected', config.textDetection);
          });

          menuContainer.append(faceMenu, textMenu);
        });

      });
    };
    init();

    // eslint-disable-next-line no-undef
    console.log('%cMasked Watch', 'font-size: 200%;', `ver ${VER}`, '\nconfig: ', JSON.stringify({...config}));
  };

  const loadGm = () => {
    const script = document.createElement('script');
    script.id = `${PRODUCT}Loader`;
    script.setAttribute('type', 'text/javascript');
    script.setAttribute('charset', 'UTF-8');
    script.append(`
    (() => {
      (${monkey.toString()})("${PRODUCT}");
    })();`);
    (document.head || document.documentElement).append(script);
  };

  loadGm();
})();

QingJ © 2025

镜像随时可能失效,请加Q群300939539或关注我们的公众号极客氢云获取最新地址