Greasy Fork is available in English.

Masked Watch

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

// ==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*
// @exclude     *://www.nicovideo.jp/robots.txt*
// @version     0.3.2
// @grant       none
// @author      名無しさん
// @license     public domain
// ==/UserScript==
/* eslint-disable */

// chrome://flags/#enable-experimental-web-platform-features


/**
 * @typedf BoundingBox
 * @property {number} x
 * @property {number} y
 * @property {number} width
 * @property {number} height
 * @property {'face'|'text'} type
 */


(() => {
  const PRODUCT = 'MaskedWatch';

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

    let ZenzaWatch = null;

    const DEFAULT_CONFIG = {
      interval: 300,
      enabled: true,
      debug: false,
      faceDetection: true,
      textDetection: !navigator.userAgent.toLowerCase().includes('windows'),
      fastMode: true,
      tmpWidth: 854,
      tmpHeight: 480,
    };
    const config = new class extends Function {
      toString() {
        return `
*** CONFIG MENU (設定はサービスごとに保存) ***
enabled: ${config.enabled},       // 有効/無効
debug: ${config.debug},        // デバッグON/OFF
faceDetection: ${config.faceDetection}, // 顔検出ON/OFF
textDetection: ${config.textDetection}, // テキスト検出ON/OFF
fastMode: ${config.fastMode},     // false 精度重視 true 速度重視
tmpWidth: ${config.tmpWidth},      // 検出処理用キャンバスの横解像度
tmpHeight: ${config.tmpHeight}        // 検出処理用キャンバスの縦解像度
interval: ${config.interval}        // マスクの更新間隔
`;
      }
    }, 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 bounce = {
	origin: Symbol('origin'),
	idle(func, time) {
		let reqId = null;
		let lastArgs = null;
		let promise = new PromiseHandler();
		const [caller, canceller] =
			(time === undefined && self.requestIdleCallback) ?
				[self.requestIdleCallback, self.cancelIdleCallback] : [self.setTimeout, self.clearTimeout];
		const callback = () => {
			const lastResult = func(...lastArgs);
			promise.resolve({lastResult, lastArgs});
			reqId = lastArgs = null;
			promise = new PromiseHandler();
		};
		const result = (...args) => {
			if (reqId) {
				reqId = canceller(reqId);
			}
			lastArgs = args;
			reqId = caller(callback, time);
			return promise;
		};
		result[this.origin] = func;
		return result;
	},
	time(func, time = 0) {
		return this.idle(func, time);
	}
};
const throttle = (func, interval) => {
	let lastTime = 0;
	let timer;
	let promise = new PromiseHandler();
	const result = (...args) => {
		if (timer) {
			return promise;
		}
		const now = performance.now();
		const timeDiff = now - lastTime;
		timer = setTimeout(() => {
			lastTime = performance.now();
			timer = null;
			const lastResult = func(...args);
			promise.resolve({lastResult, lastArgs: args});
			promise = new PromiseHandler();
		}, Math.max(interval - timeDiff, 0));
		return promise;
	};
	result.cancel = () => {
		if (timer) {
			timer = clearTimeout(timer);
		}
		promise.resolve({lastResult: null, lastArgs: null});
		promise = new PromiseHandler();
	};
	return result;
};
throttle.time = (func, interval = 0) => throttle(func, interval);
throttle.raf = function(func) {
	// let promise;// = new PromiseHandler();
	let promise;
	let cancelled = false;
	const result = (...args) => {
		if (promise) {
			return promise;
		}
		if (!this.req) {
			this.req = new Promise(res => requestAnimationFrame(res)).then(() => {
				this.req = null;
			});
		}
		promise = this.req.then(() => {
			if (cancelled) {
				cancelled = false;
				return;
			}
			try { func(...args); } catch (e) { console.warn(e); }
			promise = null;
		});
		return promise;
	};
	result.cancel = () => {
		cancelled = true;
		promise = null;
	};
	return result;
}.bind({req: null, count: 0, id: 0});
throttle.idle = func => {
	let id;
	const request = (self.requestIdleCallback || self.setTimeout);
	const cancel = (self.cancelIdleCallback || self.clearTimeout);
	const result = (...args) => {
		if (id) {
			return;
		}
		id = request(() => {
			id = null;
			func(...args);
		}, 0);
	};
	result.cancel = () => {
		if (id) {
			id = cancel(id);
		}
	};
	return result;
};
const css = (() => {
	const setPropsTask = [];
	const applySetProps = throttle.raf(
		() => {
		const tasks = setPropsTask.concat();
		setPropsTask.length = 0;
		for (const [element, prop, value] of tasks) {
			try {
				element.style.setProperty(prop, value);
			} catch (error) {
				console.warn('element.style.setProperty fail', {element, prop, value, error});
			}
		}
	});
	const css = {
		addStyle: (styles, option, document = window.document) => {
			const elm = Object.assign(document.createElement('style'), {
				type: 'text/css'
			}, typeof option === 'string' ? {id: option} : (option || {}));
			if (typeof option === 'string') {
				elm.id = option;
			} else if (option) {
				Object.assign(elm, option);
			}
			elm.classList.add(global.PRODUCT);
			elm.append(styles.toString());
			(document.head || document.body || document.documentElement).append(elm);
			elm.disabled = option && option.disabled;
			elm.dataset.switch = elm.disabled ? 'off' : 'on';
			return elm;
		},
		registerProps(...args) {
			if (!CSS || !('registerProperty' in CSS)) {
				return;
			}
			for (const definition of args) {
				try {
					(definition.window || window).CSS.registerProperty(definition);
				} catch (err) { console.warn('CSS.registerProperty fail', definition, err); }
			}
		},
		setProps(...tasks) {
			setPropsTask.push(...tasks);
			return setPropsTask.length ? applySetProps() : Promise.resolve();
		},
		addModule: async function(func, options = {}) {
			if (!CSS || !('paintWorklet' in CSS) || this.set.has(func)) {
				return;
			}
			this.set.add(func);
			const src =
			`(${func.toString()})(
				this,
				registerPaint,
				${JSON.stringify(options.config || {}, null, 2)}
				);`;
			const blob = new Blob([src], {type: 'text/javascript'});
			const url = URL.createObjectURL(blob);
			await CSS.paintWorklet.addModule(url).then(() => URL.revokeObjectURL(url));
			return true;
		}.bind({set: new WeakSet}),
		escape:  value => CSS.escape  ? CSS.escape(value) : value.replace(/([\.#()[\]])/g, '\\$1'),
		number:  value => CSS.number  ? CSS.number(value) : value,
		s:       value => CSS.s       ? CSS.s(value) :  `${value}s`,
		ms:      value => CSS.ms      ? CSS.ms(value) : `${value}ms`,
		pt:      value => CSS.pt      ? CSS.pt(value) : `${value}pt`,
		px:      value => CSS.px      ? CSS.px(value) : `${value}px`,
		percent: value => CSS.percent ? CSS.percent(value) : `${value}%`,
		vh:      value => CSS.vh      ? CSS.vh(value) : `${value}vh`,
		vw:      value => CSS.vw      ? CSS.vw(value) : `${value}vw`,
		trans:   value => self.CSSStyleValue ? CSSStyleValue.parse('transform', value) : value,
		word:    value => self.CSSKeywordValue ? new CSSKeywordValue(value) : value,
		image:   value => self.CSSStyleValue ? CSSStyleValue.parse('background-image', value) : value,
	};
	return css;
})();
const cssUtil = css;

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

      const updateConfig = ({config}) => {
        ({fastMode, faceDetection, textDetection, debug, enabled} = config);
        faceDetector = new (self || window).FaceDetector({fastMode});
        textDetector = new (self || window).TextDetector();
      };

      let faceDetector;
      let textDetector;
      const detect = async ({bitmap}) => {
        // debug && console.time('detect');
        const tasks = [];
        faceDetection && (tasks.push(faceDetector.detect(bitmap).catch(() => [])));
        textDetection && (tasks.push(textDetector.detect(bitmap).catch(() => [])));
        const detected = (await Promise.all(tasks)).flat();

        const boxes = detected.map(d => {
          const {x, y , width, height} = d.boundingBox;
          return {x, y , width, height, type: d.landmarks ? 'face' : 'text'};
        });
        // debug && console.timeEnd('detect');
        return {boxes};
      };

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

    const 下請 = function(self, registerPaint, config) {
      registerPaint('塗装', class {
        static get inputProperties() {
          return ['--json-args', '--config'];
        }
        paint(ctx, {width, height}, props) {
          const args   = JSON.parse(props.get('--json-args').toString() || '{}');
          const config = JSON.parse(props.get('--config').toString() || '{}');

          ctx.beginPath();
          ctx.fillStyle = 'rgba(255, 255, 255, 1)';
          ctx.fillRect(0, 0, width, height);
          if (!args.history || !config.enabled) {
            return;
          }

          const ratio = Math.min(width / config.tmpWidth, height / config.tmpHeight);
          const transX = (width  - (config.tmpWidth  * ratio)) / 2;
          const transY = (height - (config.tmpHeight * ratio)) / 2;
          const tmpArea = (config.tmpWidth  * ratio) * (config.tmpHeight * ratio);


          /** @type {(BoundingBox[])[]} */
          const history = args.history;
          for (const boxes of history) {
            ctx.fillStyle = 'rgba(255, 255, 255, 0.2)';
            ctx.fillRect(0, 0, width, height);

            for (const box of boxes) {
              let {x, y , width, height, type} = box;
              const area = width * height;
              const opacity = area / tmpArea * 0.3;
              ctx.fillStyle = `rgba(255, 255, 255, ${opacity})`;

              x = x * ratio + transX;
              y = y * ratio + transY;
              width  *= ratio;
              height *= ratio;
              if (type === 'face') {
                const mx = 16 * ratio, my = 24 * ratio; // margin
                ctx.clearRect(x - mx, y  - my, width + mx * 2, height + my * 2);
                ctx.fillRect (x - mx, y  - my, width + mx * 2, height + my * 2);
              } else {
                const mx = 16 * ratio, my = 16 * ratio; // margin
                ctx.clearRect(x - mx, y  - my, width + mx * 2, height + my * 2);
                ctx.fillRect (x - mx, y  - my, width + mx * 2, height + my * 2);
              }
            }
          }
        }
      });
    };

    const createDetector = async ({video, layer, interval, type}) => {
      const worker = createWorker(業務, {name: 'Facelook'});
      await css.addModule(下請, {config: {...config}});
      const transferCanvas = new OffscreenCanvas(config.tmpWidth, config.tmpHeight);
      const ctx = transferCanvas.getContext('2d', {alpha: false, desynchronized: true});
      const debugLayer = document.createElement('div');
      [layer, debugLayer].forEach(layer => {
        layer.style.setProperty('--config', JSON.stringify({...config}));
        layer.style.setProperty('--json-args', '{}');
      });
      'maskImage' in layer.style ?
        (layer.style.maskImage = 'paint(塗装)') :
        (layer.style.webkitMaskImage = 'paint(塗装)');

      // for debug
      Object.assign(debugLayer.style, {
        border: '1px solid #888',
        left: 0,
        bottom: '48px',
        position: 'fixed',
        zIndex: '100000',
        width: '160px',
        height: '90px',
        opacity: 0.5,
        background: '#333',
        pointerEvents: 'none',
        userSelect: 'none'
      });
      debugLayer.classList.add('zen-family');
      debugLayer.dataset.type = type;
      debugLayer.style.backgroundImage = 'paint(塗装)';
      config.debug && document.body.append(debugLayer);
      worker.postMessage({body: {command: 'init', params: {config: {...config}}}});

      let isBusy = true, currentTime = video.currentTime, boxHistory = [];
      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; }
            /** @type {BoundingBox[]} */
            const boxes = params.boxes;
            boxHistory.push(boxes);
            while (boxHistory.length > 5) { boxHistory.shift(); }
            const arg = JSON.stringify({
              tmpWidth: config.tmpWidth, tmpHeight: config.tmpHeight,
              history: boxHistory
            });
            layer.style.setProperty('--json-args', arg);
            config.debug && debugLayer.style.setProperty('--json-args', arg);
          }
          break;
        }
      });

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

        currentTime = video.currentTime;
        const vw = video.videoWidth, vh = video.videoHeight;
        const tmpWidth = config.tmpWidth, tmpHeight = config.tmpHeight;
        const ratio = Math.min(tmpWidth / vw, tmpHeight / vh);
        const dw = vw * ratio, dh = vh * ratio;
        ctx.beginPath();
        ctx.drawImage(
          video,
          0, 0, vw, vh,
          (tmpWidth - dw) / 2, (tmpHeight - 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);

      window.addEventListener(`${PRODUCT}-config.update`, e => {
        worker.postMessage({body: {command: 'config', params: {config: {...config}}}});
        const {key, value} = e.detail;
        layer.style.setProperty('--config', JSON.stringify({...config}));
        debugLayer.style.setProperty('--config', JSON.stringify({...config}));
        switch (key) {
          case 'enabled':
            value ? start() : stop();
            break;
          case 'debug':
            value ? document.body.append(debugLayer) : debugLayer.remove();
            break;
          case 'tmpWidth':
            transferCanvas.width = value;
            break;
          case 'tmpHeight':
            transferCanvas.height = value;
            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: 240px;
                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: 180px;
                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">テキストの検出<br>
                <span class="name" style="font-size: 80%;">windowsで動かないっぽい?</span>
              </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 = () => {
      css.registerProps(
        {name: '--json-args', syntax: '*', initialValue: '{}', inherits: false },
        {name: '--config',    syntax: '*', initialValue: '{}', inherits: false }
      );
      timer = setInterval(watch, 1000);

      document.body.append(dialog);

      window.setTimeout(() => {
        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);
      }, document.querySelector('#siteHeaderRightMenuContainer') ? 1000 : 15000);

      ZenzaDetector.detect().then(zen => {
        console.log('ZenzaWatch found ver.%s', zen.version);
        ZenzaWatch = zen;
        ZenzaWatch.emitter.promise('videoControBar.addonMenuReady').then(({container}) => {
          container.append(createToggleButton(config, dialog));
        });
        ZenzaWatch.emitter.promise('videoContextMenu.addonMenuReady.list').then(({container}) => {
          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);
          });

          container.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();
})();