Last active
September 13, 2026 05:40
-
-
Save fabien-github/60d7005a0d54c76ac39bd208fb966038 to your computer and use it in GitHub Desktop.
Invidious Vertical Video Filter (Remove shorts)
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| // ==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 <img> | |
| * 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); | |
| })(); |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment