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); | |
| })(); |
评论
?
参与讨论