// ==UserScript==
// @name Masked Watch
// @namespace https://github.com/segabito/
// @description ビリビリのアレをリアルタイムでやってみる
// @match *://www.nicovideo.jp/*
// @match *://live.nicovideo.jp/*
// @match *://anime.nicovideo.jp/*
// @exclude *://ads*.nicovideo.jp/*
// @exclude *://www.upload.nicovideo.jp/*
// @exclude *://www.nicovideo.jp/watch/*?edit=*
// @exclude *://ch.nicovideo.jp/tool/*
// @exclude *://flapi.nicovideo.jp/*
// @exclude *://dic.nicovideo.jp/p/*
// @exclude *://ext.nicovideo.jp/thumb/*
// @exclude *://ext.nicovideo.jp/thumb_channel/*
// @version 0.0.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';
const INTERVAL = 30;
var VER = '0.0.1';
//@version
const ENV = 'STABLE';
//@environment
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) {
const FASTMODE = false; // true 精度重視 false 速度重視
let canvas, ctx;
const init = params => {
({canvas} = params);
const {width, height} = params;
ctx = canvas.getContext('2d');
ctx.fillStyle = 'rgba(255, 255, 255, 0.5)';
canvas.width = width || 320;
canvas.height = height || 180;
};
const detector = new (self || window).FaceDetector({fastMode: FASTMODE});
const detect = async ({bitmap}) => {
// console.time('detect');
const faces = await detector.detect(bitmap);
// console.timeLog('detect', 'detector.detect');
ctx.beginPath();
ctx.fillRect(0, 0, canvas.width, canvas.height);
for (const face of faces) {
const {x, y , width, height} = face.boundingBox;
ctx.clearRect(x / 2 - 10, y / 2 -16 , width / 2 + 20, height / 2 + 32);
}
// console.timeLog('detect', 'draw');
const dataURL = await toDataURL(canvas);
// 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 'detect': {
const dataURL = await detect(params);
self.postMessage({body: {command: 'data', params: {dataURL}, status: 'ok'}});
}
break;
}
};
};
const createFaceDetector = ({video, layer, interval}) => {
const worker = createWorker(業務, {name: 'Facelook'});
const width = 640, height = 360;
const transferCanvas = new OffscreenCanvas(width, height);
const ctx = transferCanvas.getContext('2d', {alpha: false});
const workCanvas = document.createElement('canvas');
workCanvas.width = width;
workCanvas.height = height;
// for debug
// Object.assign(workCanvas.style, {
// border: '1px solid #888',
// left: 0,
// bottom: 0,
// position:'fixed',
// zIndex:'100000',
// width:`${width / 2}px`,
// height:`${height / 2}px`,
// opacity: 0.8,
// filter: 'drop-shadow(2px 2px 2px black)',
// pointerEvents: 'none',
// userSelect: 'none'
// });
// document.body.append(workCanvas);
const offscreenCanvas = workCanvas.transferControlToOffscreen();
worker.postMessage({body: {command: 'init', params: {canvas: offscreenCanvas, width: width / 2, height: height / 2}}}, [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': {
const url = `url('${params.dataURL}')`;
layer.style.maskImage = url;
layer.style.webkitMaskImage = url;
isBusy = false;
}
break;
}
});
interval = interval || INTERVAL;
const onTimer = () => {
if (currentTime === video.currentTime || isBusy) {
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);
return {
start: () => timer = setInterval(onTimer, interval),
stop: () => timer = clearInterval(timer),
};
};
const vmap = new WeakMap();
const watch = () => {
[...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('.zenzaPlayerContainer')) {
layer = document.querySelector('.commentLayerFrame');
type = 'ZenzaWatch';
} else if (video.closest('[class*="__leo"]')) {
layer = document.querySelector('#comment-layer-container canvas');
type = 'NICO LIVE';
}
console.log('%ctype: "%s"', 'font-weight: bold', type);
Object.assign(layer.style, {
backgroundSize: 'contain',
maskSize: 'contain',
webkitMaskSize: 'contain',
maskRepeat: 'no-repeat',
webkitMaskRepeat: 'no-repeat',
maskPosition: 'center center',
webkitMaskPosition: 'center center'
});
vmap.set(video,
layer ?
createFaceDetector({video: video.drawableElement || video, layer, interval: INTERVAL}) :
type
);
});
};
setInterval(watch, 1000);
console.log('%cMasked Watch', 'font-size: 200%;', `ver ${VER}`);
};
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();
})();