Invidious Vertical Video Filter (Remove shorts)

// ==UserScript==
// @name Invidious Vertical Video Filter
// @namespace invidious-vertical-video-filter
// @version 4.1
// @description Hide vertical videos.
// @author fabien-github (https://gist.github.com/fabien-github/60d7005a0d54c76ac39bd208fb966038)
// @match https://inv.nadeko.net/*
// @grant none
// @run-at document-idle
// @noframes
// ==/UserScript==
/*
* A vertical video shown in a 16:9 thumbnail is pillarboxed: the real picture
* occupies a ~101px strip in the middle of a 320x180 frame, and the margins
* either side are filler - flat black, or a blurred copy of the picture.
*
* Two independent signals identify that shape, and either one is enough:
*
* BORDERS A vertical edge at x=109 and x=209 that runs the height of the
* frame and is stronger than the pixels beside it.
*
* MARGINS Outer thirds far quieter than the middle, with the quiet/busy
* transition landing on 109 and 209.
*
* Neither alone is sufficient. Blurred filler produces a border too soft for
* the edge test, so the margin test carries those. Ordinary thumbnails that
* happen to contain two strong vertical lines 100px apart - a slide frame, a
* centred figure - pass the edge test, so the margin test vetoes those.
*
* Thresholds were fitted to a labelled sample set with vvd-tuner.html under a
* hard no-false-positive constraint. Refit there rather than by hand.
*/
(function () {
'use strict';
/* ====================================================================
* 1. CONFIGURATION
* ================================================================== */
/* What to do with a detected thumbnail.
* 'hide' - remove it from the page
* 'outline' - draw a red box round it and leave it in place (for testing)
*/
const MODE = 'hide';
/* Log every thumbnail's measurements to the console, and stamp each one
* with a label showing them. Slows things down; off for normal use. */
const DEBUG = false;
/* Fitted thresholds. The v3.6 values are noted where they differ, in case
* you want to compare. */
const T = {
/* --- borders --- */
/* fraction of rows where each border must read as an edge (v3.6: 0.45) */
minContinuity: 0.49,
/* how much stronger a border must be than its surroundings (v3.6: 1.70) */
minAverageRatio: 2.02,
/* accepted spacing between the two borders, in pixels */
minLineDistance: 99,
maxLineDistance: 101,
/* 98px is allowed too, but only for an unusually clean pair */
specialDistance: 98,
specialMinContinuity: 0.90,
specialMinAverageRatio: 5.0,
/* --- margins --- */
/* set false to ignore the margins and judge on borders alone */
useMargins: true,
/* margins must be at least this quiet, relative to the middle, before
* border evidence is accepted at all. Low, because the border test
* already rejects most things; this exists for the thumbnails it
* doesn't, which have busier margins than middles. */
minDetailRatio: 0.8,
/* margins this much quieter than the middle detect on their own */
soloDetailRatio: 3.5,
/* ...provided the quiet/busy transition is within this many pixels
* of where pillarboxing would put it */
maxBoundaryError: 47
};
/* Re-test an image when its src changes. Sites that recycle
* elements while scrolling will otherwise keep a stale verdict. */
const RECHECK_ON_SRC_CHANGE = true;
/* Analyse in idle batches rather than blocking on every mutation. */
const BATCH_SIZE = 12;
/* ====================================================================
* 2. FRAME GEOMETRY
* ================================================================== */
/* every thumbnail is normalised to this size before analysis */
const W = 320;
const H = 180;
/* where pillarbox borders fall in a normalised frame */
const LEFT_X = 109;
const RIGHT_X = 209;
/* borders are searched +/- this far from those positions */
const TOLERANCE = 1;
/* only the middle 80% of rows is examined, to skip overlaid captions */
const FIRST_ROW = Math.floor(H * 0.10); /* 18 */
const LAST_ROW = Math.floor(H * 0.90); /* 162 */
const ROW_COUNT = LAST_ROW - FIRST_ROW + 1; /* 145 */
/* a border must be at least this strong to count as an edge */
const MIN_EDGE_STRENGTH = 10;
/* columns compared against, to check a border stands out locally */
const NEAR = 5;
const FAR = 8;
const EDGE_MARGIN = 1.25;
/* rows sampled for the margin profile; every third is plenty */
const MARGIN_ROW_STEP = 3;
/* columns ignored at the frame edge, and either side of each border */
const FRAME_INSET = 4;
const BORDER_GAP = 7;
/* stops a perfectly flat margin dividing by zero */
const DETAIL_FLOOR = 0.15;
/* consecutive columns above the midpoint needed to call the margin over */
const BUSY_RUN = 4;
/* thumbnails outside these bounds are not 16:9 video thumbnails */
const MIN_SOURCE_WIDTH = 200;
const MIN_SOURCE_HEIGHT = 100;
const MIN_ASPECT = 1.60;
const MAX_ASPECT = 1.95;
/* pixels arrive as one RGBA band: full width, rows FIRST_ROW..LAST_ROW */
const at = (x, y) => ((y - FIRST_ROW) * W + x) * 4;
const CANDIDATES = TOLERANCE * 2 + 1;
const GRADIENTS = (FAR + TOLERANCE) * 2 + 2;
/* ====================================================================
* 3. BORDER TEST
*
* For each of the three candidate positions per side, measure how often
* a vertical edge appears and how far it outranks the columns beside it.
*
* The edge at column x is the larger of the two colour steps across it,
* so every step is computed once per row and shared by all candidates
* rather than recomputed for each.
* ================================================================== */
const gradient = new Float64Array(GRADIENTS);
const hits = new Int32Array(CANDIDATES);
const edgeSum = new Float64Array(CANDIDATES);
const localSum = new Float64Array(CANDIDATES);
/* strength of the edge at one column: the larger of the two steps across it */
const peak = (k) => gradient[k] > gradient[k + 1] ? gradient[k] : gradient[k + 1];
function scoreBorder(pixels, expectedX) {
const originX = expectedX - (FAR + TOLERANCE);
hits.fill(0);
edgeSum.fill(0);
localSum.fill(0);
for (let y = FIRST_ROW; y <= LAST_ROW; y++) {
/* colour distance between each adjacent column pair in range */
let i = at(originX - 1, y);
let pr = pixels[i], pg = pixels[i + 1], pb = pixels[i + 2];
for (let g = 0; g < GRADIENTS; g++) {
i += 4;
const r = pixels[i], gr = pixels[i + 1], b = pixels[i + 2];
const dr = pr - r, dg = pg - gr, db = pb - b;
gradient[g] = Math.sqrt(dr * dr + dg * dg + db * db);
pr = r; pg = gr; pb = b;
}
for (let c = 0; c < CANDIDATES; c++) {
const k = c + FAR; /* this candidate's column */
const edge = peak(k);
const local = (peak(k - NEAR) + peak(k + NEAR) +
peak(k - FAR) + peak(k + FAR)) / 4;
edgeSum[c] += edge;
localSum[c] += local;
if (edge >= MIN_EDGE_STRENGTH &&
(local === 0 || edge >= local * EDGE_MARGIN)) {
hits[c]++;
}
}
}
/* keep the candidate that read as an edge most often; on a tie the
* leftmost wins, which is what the fitted thresholds were tuned on */
let best = 0;
for (let c = 1; c < CANDIDATES; c++) {
if (hits[c] > hits[best]) best = c;
}
const meanEdge = edgeSum[best] / ROW_COUNT;
const meanLocal = localSum[best] / ROW_COUNT;
return {
x: expectedX - TOLERANCE + best,
continuity: hits[best] / ROW_COUNT,
ratio: meanLocal > 0 ? meanEdge / meanLocal : 999
};
}
/* ====================================================================
* 4. MARGIN TEST
*
* How much fine detail each column carries, as the mean luma step from
* the column before it. Filler margins - flat or blurred - carry almost
* none; real picture carries as much at the edges as in the middle.
*
* Luma and absolute differences only, so no square roots: this is the
* cheaper of the two tests despite reading the whole width.
* ================================================================== */
const profile = new Float64Array(W);
const sorting = new Float64Array(W);
function detailProfile(pixels) {
profile.fill(0);
let rows = 0;
for (let y = FIRST_ROW; y <= LAST_ROW; y += MARGIN_ROW_STEP) {
let i = at(0, y);
let prev = 0.299 * pixels[i] + 0.587 * pixels[i + 1] + 0.114 * pixels[i + 2];
for (let x = 1; x < W; x++) {
i += 4;
const luma = 0.299 * pixels[i] + 0.587 * pixels[i + 1] + 0.114 * pixels[i + 2];
const step = luma - prev;
profile[x] += step < 0 ? -step : step;
prev = luma;
}
rows++;
}
for (let x = 1; x < W; x++) profile[x] /= rows;
profile[0] = profile[1];
return profile;
}
/* median of up to two column ranges; robust to a logo or caption sitting
* in one part of a margin, which a mean would not be */
function median(from, to, from2, to2) {
let n = 0;
for (let x = from; x < to; x++) sorting[n++] = profile[x];
for (let x = from2; x < to2; x++) sorting[n++] = profile[x];
if (n === 0) return 0;
const s = sorting.subarray(0, n);
s.sort();
return (n & 1) ? s[(n - 1) >> 1] : (s[(n >> 1) - 1] + s[n >> 1]) / 2;
}
function scoreMargins(pixels) {
detailProfile(pixels);
const margin = median(FRAME_INSET, LEFT_X - BORDER_GAP,
RIGHT_X + BORDER_GAP, W - FRAME_INSET);
const middle = median(LEFT_X + BORDER_GAP, RIGHT_X - BORDER_GAP, 0, 0);
/* where does the quiet part actually stop? sweep in from both sides
* for the first sustained run above the quiet/busy midpoint */
const busy = (margin + middle) / 2;
let left = -1;
let right = -1;
outerLeft:
for (let x = FRAME_INSET; x < W - BUSY_RUN - FRAME_INSET; x++) {
for (let k = 0; k < BUSY_RUN; k++) {
if (profile[x + k] <= busy) continue outerLeft;
}
left = x;
break;
}
outerRight:
for (let x = W - FRAME_INSET - 1; x >= BUSY_RUN + FRAME_INSET - 1; x--) {
for (let k = 0; k < BUSY_RUN; k++) {
if (profile[x - k] <= busy) continue outerRight;
}
right = x + 1;
break;
}
return {
detailRatio: middle / Math.max(margin, DETAIL_FLOOR),
boundaryError: (left < 0 || right < 0)
? 99
: Math.max(Math.abs(left - LEFT_X), Math.abs(right - RIGHT_X))
};
}
/* ====================================================================
* 5. VERDICT
* ================================================================== */
function classify(pixels) {
const margins = T.useMargins
? scoreMargins(pixels)
: { detailRatio: Infinity, boundaryError: 0 };
/* Margins quiet enough and the right width detect on their own, so
* that path still needs the border positions for their spacing. */
const soloPossible = T.useMargins &&
margins.detailRatio >= T.soloDetailRatio &&
margins.boundaryError <= T.maxBoundaryError;
/* Otherwise the borders must carry it, and they cannot if the margins
* are too busy - so there is nothing left to measure. */
if (!soloPossible && T.useMargins && margins.detailRatio < T.minDetailRatio) {
return { detected: false, margins };
}
const left = scoreBorder(pixels, LEFT_X);
/* the border path needs both sides, so a failed left side ends it */
if (!soloPossible && left.continuity < T.minContinuity) {
return { detected: false, margins, left };
}
const right = scoreBorder(pixels, RIGHT_X);
const averageRatio = (left.ratio + right.ratio) / 2;
const distance = right.x - left.x;
const spacingOk =
(distance >= T.minLineDistance && distance <= T.maxLineDistance) ||
(distance === T.specialDistance &&
left.continuity >= T.specialMinContinuity &&
right.continuity >= T.specialMinContinuity &&
averageRatio >= T.specialMinAverageRatio);
const borderPath =
left.continuity >= T.minContinuity &&
right.continuity >= T.minContinuity &&
averageRatio >= T.minAverageRatio &&
spacingOk &&
(!T.useMargins || margins.detailRatio >= T.minDetailRatio);
const marginPath = soloPossible && spacingOk;
return {
detected: borderPath || marginPath,
byMarginsAlone: marginPath && !borderPath,
margins, left, right, averageRatio, distance
};
}
/* ====================================================================
* 6. READING THUMBNAILS
*
* One canvas serves the whole page. A single cross-origin image without
* CORS headers taints it permanently, so a failed read discards it and
* the next thumbnail starts on a clean one.
* ================================================================== */
let context = null;
function canvasContext() {
if (context) return context;
const canvas = document.createElement('canvas');
canvas.width = W;
canvas.height = H;
context = canvas.getContext('2d', { willReadFrequently: true });
/* replace rather than blend, so a thumbnail with transparency cannot
* show the previous one through it */
if (context) context.globalCompositeOperation = 'copy';
return context;
}
function analyse(img) {
if (!img.complete || !img.naturalWidth || !img.naturalHeight) return null;
if (img.naturalWidth < MIN_SOURCE_WIDTH ||
img.naturalHeight < MIN_SOURCE_HEIGHT) return null;
const aspect = img.naturalWidth / img.naturalHeight;
if (aspect < MIN_ASPECT || aspect > MAX_ASPECT) return null;
const ctx = canvasContext();
if (!ctx) return null;
try {
ctx.drawImage(img, 0, 0, W, H);
return classify(ctx.getImageData(0, FIRST_ROW, W, ROW_COUNT).data);
} catch (error) {
if (DEBUG) console.log('[VVD] cannot read', img.currentSrc || img.src, error);
context = null;
return null;
}
}
/* ====================================================================
* 7. ACTING ON THE PAGE
* ================================================================== */
/* climb to the largest ancestor that is still thumbnail-sized, which is
* the card the person actually sees */
function findCard(img) {
let card = img;
for (let i = 0; i < 7; i++) {
const parent = card.parentElement;
if (!parent ||
parent === document.body ||
parent === document.documentElement) break;
const box = parent.getBoundingClientRect();
if (box.width > 700 || box.height > 500) break;
card = parent;
}
return card;
}
const appliedTo = new WeakMap();
const labelFor = new WeakMap();
function revert(img) {
const card = appliedTo.get(img);
if (card) {
card.style.removeProperty('display');
card.style.removeProperty('outline');
card.style.removeProperty('outline-offset');
appliedTo.delete(img);
}
labelFor.get(img)?.remove();
labelFor.delete(img);
}
function stamp(img, result) {
let label = labelFor.get(img);
if (!label) {
const parent = img.parentElement;
if (!parent) return;
if (getComputedStyle(parent).position === 'static') {
parent.style.position = 'relative';
}
label = document.createElement('div');
label.style.cssText =
'position:absolute;top:4px;left:4px;z-index:2147483647;' +
'padding:3px 6px;border-radius:3px;pointer-events:none;' +
'white-space:nowrap;color:#fff;font:bold 12px Arial,sans-serif';
parent.appendChild(label);
labelFor.set(img, label);
}
const { margins: m, left, right } = result;
label.style.background = result.detected
? 'rgba(200,0,0,.9)'
: 'rgba(0,90,0,.85)';
label.textContent =
(result.detected ? 'VERTICAL' : 'normal') +
(left ? ` ${Math.round(left.continuity * 100)}/` +
`${right ? Math.round(right.continuity * 100) : '-'}%` : '') +
(result.averageRatio ? ` r${result.averageRatio.toFixed(2)}` : '') +
` d${m.detailRatio.toFixed(1)} e${m.boundaryError}` +
(result.byMarginsAlone ? ' [margins]' : '');
}
function apply(img, result) {
if (DEBUG) {
stamp(img, result);
console.log('[VVD]', {
detected: result.detected,
byMarginsAlone: !!result.byMarginsAlone,
detailRatio: +result.margins.detailRatio.toFixed(2),
boundaryError: result.margins.boundaryError,
leftContinuity: result.left && +result.left.continuity.toFixed(2),
rightContinuity: result.right && +result.right.continuity.toFixed(2),
averageRatio: result.averageRatio && +result.averageRatio.toFixed(2),
distance: result.distance,
src: img.currentSrc || img.src
});
}
if (!result.detected && MODE === 'hide') return;
const card = findCard(img);
if (!card) return;
appliedTo.set(img, card);
if (MODE === 'hide') {
card.style.setProperty('display', 'none', 'important');
} else if (result.detected) {
card.style.setProperty('outline', '4px solid red', 'important');
card.style.setProperty('outline-offset', '-4px', 'important');
} else {
card.style.setProperty('outline', '2px solid rgba(0,255,0,.4)', 'important');
}
}
/* ====================================================================
* 8. SCHEDULING
* ================================================================== */
const stats = { seen: 0, hidden: 0, byMarginsAlone: 0, unreadable: 0 };
function check(img) {
if (!img.isConnected) return;
const src = img.currentSrc || img.src;
if (!src) return;
/* already judged this exact picture */
if (img.dataset.vvd === src) return;
if (!img.complete) {
if (!img.dataset.vvdWaiting) {
img.dataset.vvdWaiting = '1';
const again = () => {
delete img.dataset.vvdWaiting;
queue(img);
};
img.addEventListener('load', again, { once: true });
img.addEventListener('error', again, { once: true });
}
return;
}
/* the element is being reused for a different video */
if (img.dataset.vvd) revert(img);
img.dataset.vvd = src;
const result = analyse(img);
if (!result) {
stats.unreadable++;
return;
}
stats.seen++;
if (result.detected) {
stats.hidden++;
if (result.byMarginsAlone) stats.byMarginsAlone++;
}
apply(img, result);
}
const pending = new Set();
let scheduled = false;
const whenIdle = window.requestIdleCallback
? window.requestIdleCallback.bind(window)
: (fn) => setTimeout(() => fn(null), 0);
function queue(img) {
if (img instanceof HTMLImageElement) {
pending.add(img);
schedule();
}
}
function schedule() {
if (scheduled || pending.size === 0) return;
scheduled = true;
whenIdle(drain, { timeout: 300 });
}
function drain(deadline) {
scheduled = false;
let done = 0;
for (const img of pending) {
pending.delete(img);
check(img);
done++;
const timeLeft = deadline?.timeRemaining ? deadline.timeRemaining() > 3 : false;
if (!timeLeft && done >= BATCH_SIZE) break;
}
schedule();
}
/* ====================================================================
* 9. START
* ================================================================== */
function scanAll(root) {
if (root instanceof HTMLImageElement) queue(root);
root.querySelectorAll?.('img').forEach(queue);
}
const observer = new MutationObserver((mutations) => {
for (const mutation of mutations) {
if (mutation.type === 'attributes') {
queue(mutation.target);
} else {
for (const node of mutation.addedNodes) {
if (node.nodeType === Node.ELEMENT_NODE) scanAll(node);
}
}
}
});
scanAll(document);
observer.observe(document.body, RECHECK_ON_SRC_CHANGE
? { childList: true, subtree: true, attributes: true, attributeFilter: ['src', 'srcset'] }
: { childList: true, subtree: true });
/* console helpers: __VVD.stats, __VVD.rescan() */
window.__VVD = {
stats,
thresholds: T,
rescan() {
document.querySelectorAll('img').forEach((img) => {
delete img.dataset.vvd;
revert(img);
});
scanAll(document);
}
};
if (DEBUG) console.log('[VVD] active, mode:', MODE);
})();
添加评论
点赞收藏
点踩分享查看原文
评论
?
参与讨论