// ==UserScript== // @name A2P // @namespace https://github.com/MakotoArai-CN/A2P // @version 1.2.0 // @description Anime2Potplayer,用Potplayer打开浏览器播放的动漫,然后本地使用SVP4补帧!Potplayer需要是安装版,否则不生效。 // @author MakotoArai(https://github.com/MakotoArai-CN) // @supportURL https://blog.ciy.cool // @license GPL-v3 // @icon https://cravatar.cn/avatar/1e84fce3269537e4aa7473602516bf6d?s=256 // @match *://anich.emmmm.eu.org/* // @match *://mutedm.com/* // @match *://*.mutedm.com/* // @match *://2kdm.com/* // @match *://*.2kdm.com/* // @match *://iyinghua.com/* // @match *://*.iyinghua.com/* // @match *://5dm.link/* // @match *://*.5dm.link/* // @match *://dmd77.com/* // @match *://*.dmd77.com/* // @match *://agefans.la/* // @match *://*.agefans.la/* // @match *://43.240.156.118:8443/* // @match *://tinaacg.net/* // @match *://*.tinaacg.net/* // @match *://susudyy.com/* // @match *://*.susudyy.com/* // @match *://5o5k.com/* // @match *://*.5o5k.com/* // @match *://k6dm.com/* // @match *://*.k6dm.com/* // @match *://233dm.com/* // @match *://*.233dm.com/* // @match *://mgnacg.com/* // @match *://*.mgnacg.com/* // @match *://omofun.xyz/* // @match *://*.omofun.xyz/* // @match *://omofun2.xyz/* // @match *://*.omofun2.xyz/* // @grant GM_info // @grant GM_setValue // @grant GM_getValue // @grant unsafeWindow // @grant GM_xmlhttpRequest // @grant GM_notification // @grant GM_addStyle // @grant GM_setClipboard // @grant GM_registerMenuCommand // ==/UserScript== 'use strict'; const m3u8Urls = new Set(); let resultsShown = false; // 媒体直链匹配:m3u8(播放列表)+ mp4/mkv/flv 等常见直链格式 const mediaUrlPattern = /\.(?:m3u8|mp4|m4v|mov|webm|mkv|flv|avi|wmv|3gp|mpd)($|[?#])/i; const playlistPattern = /\.m3u8($|[?#])/i; function showResults(title, text, timeout) { // 发送桌面通知 if (typeof GM_notification !== 'undefined') { GM_notification({ title: title, text: text, timeout: timeout, //点击后触发复制 onclick: function () { GM_setClipboard(text); } }); } } /** * 统一的媒体链接记录入口。XHR / fetch / video / base64 解码都汇集到这里。 * @param {string} url 媒体链接 * @param {string} from 来源标记,仅用于日志 */ // 排除常见的广告/统计/缩略图等噪声链接 const noisePattern = /(?:doubleclick|googlesyndication|google-analytics|googletagmanager|cnzz|hm\.baidu|umeng|adservice|\/ads?\/|\/adVideo|\/analytics|\/stat|\/track|\/beacon)/i; function recordMediaUrl(url, from) { if (!url || typeof url !== 'string') return; if (!mediaUrlPattern.test(url)) return; if (noisePattern.test(url)) return; const isNew = !m3u8Urls.has(url); m3u8Urls.add(url); // Reallyurl 是 potplayer 唤起时的回填源,避免被噪声污染: // m3u8(正片播放列表)优先;mp4 等直链仅在尚未嗅到 m3u8 时才写入。 const stored = GM_getValue("Reallyurl") || ''; const isPlaylist = playlistPattern.test(url); const storedIsPlaylist = playlistPattern.test(stored); if (isPlaylist || !storedIsPlaylist) { GM_setValue("Reallyurl", url); } if (isNew) { console.log('拦截到媒体链接 (' + (from || '?') + '):', url); // 仅对 m3u8 弹通知,保持与旧版一致的提示语义 if (isPlaylist && GM_getValue("notify")) { showResults("M3U8嗅探到:", url, 5000); } } } /** * anich.emmmm.eu.org 使用混淆的 base64:将 'A0' 替换为 '0' 后才是合法 base64。 * 例如 aHRA0cHM6... -> aHR0cHM6... -> https:... * 从任意文本中尝试解出隐藏的媒体直链。 */ function extractAnichEncodedUrls(text) { if (!text || typeof text !== 'string') return; // aHR 开头是 "htt" 的 base64 前缀,借此定位编码片段 const tokens = text.match(/aHR[A-Za-z0-9+/_=-]{16,}/g); if (!tokens) return; tokens.forEach(function (token) { try { let normalized = token.replace(/-/g, '+').replace(/_/g, '/'); if (/aHRA0/.test(normalized)) normalized = normalized.replace(/A0/g, '0'); const padding = normalized.length % 4 ? '='.repeat(4 - normalized.length % 4) : ''; const decoded = atob(normalized + padding); if (/^https?:\/\//i.test(decoded)) recordMediaUrl(decoded, 'anich-b64'); } catch (error) { // 忽略无法解码的片段 } }); } // 拦截 XHR:请求 URL + 响应体(文本类)一并嗅探 if (typeof unsafeWindow.XMLHttpRequest !== 'undefined') { const originalXHR = unsafeWindow.XMLHttpRequest; unsafeWindow.XMLHttpRequest = function () { const xhr = new originalXHR(); const originalOpen = xhr.open; let requestUrl = ''; xhr.open = function (method, url) { requestUrl = String(url || ''); recordMediaUrl(requestUrl, 'XHR'); return originalOpen.apply(this, arguments); }; xhr.addEventListener('load', function () { try { const contentType = (xhr.getResponseHeader('content-type') || '').toLowerCase(); if (/json|text|javascript|xml|mpegurl/.test(contentType) && typeof xhr.responseText === 'string') { scanResponseText(xhr.responseText); } } catch (error) { // 二进制响应无法读取 responseText,属正常情况 } }); return xhr; }; } // 拦截 fetch:请求 URL + 响应体(文本类,克隆读取不影响页面) if (typeof unsafeWindow.fetch === 'function') { const nativeFetch = unsafeWindow.fetch; unsafeWindow.fetch = function (input, init) { const reqUrl = typeof input === 'string' ? input : (input && input.url) || ''; recordMediaUrl(reqUrl, 'fetch'); return nativeFetch.apply(this, arguments).then(function (response) { try { const contentType = (response.headers && response.headers.get('content-type') || '').toLowerCase(); if (/json|text|javascript|xml|mpegurl/.test(contentType)) { response.clone().text().then(scanResponseText).catch(function () {}); } } catch (error) { // 读取响应头失败时保持页面 fetch 不受影响 } return response; }); }; } /** * 扫描响应体文本,提取其中的媒体直链与 anich 混淆 base64。 */ function scanResponseText(text) { if (!text || typeof text !== 'string') return; // 限制单次扫描体积,避免大响应阻塞 const content = text.length > 1024 * 1024 ? text.slice(0, 1024 * 1024) : text; const matches = content.match(/https?:\/\/[^\s"'<>\\]+/g); if (matches) { matches.forEach(function (raw) { const url = raw.replace(/\\\//g, '/').replace(/[),.;\]}'"]+$/, ''); recordMediaUrl(url, 'body'); }); } extractAnichEncodedUrls(content); } // 监听动态创建的video元素 new MutationObserver(function (mutations) { mutations.forEach(function (mutation) { mutation.addedNodes.forEach(function (node) { if (node.nodeName === 'VIDEO') checkVideoElement(node); if (node.querySelectorAll) node.querySelectorAll('video').forEach(checkVideoElement); }); }); }).observe(document, { childList: true, subtree: true }); /** * 视频直链嗅探(m3u8 / mp4 等) * @param {*} video */ function checkVideoElement(video) { if (!video) return; // currentSrc 优先(reflects 实际选中的 source),回退到 src const src = video.currentSrc || video.src || ''; if (src && mediaUrlPattern.test(src)) { recordMediaUrl(src); } // 检查 子元素 if (video.querySelectorAll) { video.querySelectorAll('source[src]').forEach(function (source) { const ssrc = source.getAttribute('src') || ''; if (ssrc && mediaUrlPattern.test(ssrc)) recordMediaUrl(ssrc); }); } } // 初始检查 document.querySelectorAll('video').forEach(checkVideoElement); // console.log('M3U8综合嗅探已激活'); window.onload = function () { console.info("%cA2P%c%s", "color:red;font-size:40px;font-weight:bold;", "color:black;font-size:16px;font-weight:normal", "\n" + GM_info.script.version); // 定时器用于动态嗅探视频链接 const videoTimer = setInterval(findVideoUrl, 1000); console.log(window.top != window ? "嗅探当前处于iframe中" : "嗅探当前不处于iframe中"); // 域名包含 anich.emmmm.eu.org 则启用下面的逻辑 if (window.location.href.includes("anich.emmmm.eu.org")) { // 定时器检测url是否改变,如果改变则重新调用findVideoUrl setInterval(function () { if (GM_getValue("url") !== window.location.href) { // 存入url方便对比 GM_setValue("url", window.location.href); findVideoUrl(); } }, 1500); } function Launch(App, url) { try { if (window.top != window) { window.top.postMessage({ type: 'launch', app: App, url: url }, '*'); console.log("Launch to (iframe):" + `${App}://${url}`); return; } window.location.href = `${App}://${url}`; console.log(window.top != window ? "唤起当前处于iframe中" : "唤起当前不处于iframe中"); console.log("Launch to:" + `${App}://${url}`); } catch (error) { if (error.message.includes("is not installed")) { alert(`请先安装 ${App}`); } } } function findVideoUrl() { const videoElement = document.querySelector("video"); if (videoElement && videoElement.src) { clearInterval(videoTimer); preparePotplayerInteraction(videoElement, GM_getValue("check") ?? false); } } function preparePotplayerInteraction(videoElement, check = true) { let videoUrl = videoElement.src; console.log(`检测到视频链接: ${videoUrl}`); if (videoElement.src.includes("blob:")) videoUrl = GM_getValue("Reallyurl");; creatBtn(videoElement); if (check) { Launch("potplayer", videoUrl) // 检测是否播放,如果正在播放则暂停网页的播放 var pause_Flag = 0; const checkTimer = setInterval(() => { const isPlaying = !videoElement.paused && !videoElement.ended && videoElement.readyState > 2; // console.log(isPlaying ? "正在播放" : "已暂停或结束"); if (isPlaying) videoElement.pause(); if (!isPlaying || pause_Flag > 100) clearInterval(checkTimer); pause_Flag++; }, 1500); }; } function creatBtn(videoElement) { // 插入自定义CDN document.head.insertAdjacentHTML("beforeend", ` `); // 右键菜单 var menu = document.createElement("div"); document.head.insertAdjacentHTML("beforeend", ` )`); menu.innerHTML = ` `; document.body.appendChild(menu); function menuFun(GMValue, element, icon_on, icon_off, text) { const check = GM_getValue(GMValue) ?? false; if (check) { GM_setValue(GMValue, false); element.innerHTML = `开启${text}`; } else { GM_setValue(GMValue, true); element.innerHTML = `关闭${text}`; } } // 自定义鼠标右键菜单行为 (function () { let mouseX = 0; let mouseY = 0; let windowWidth = 0; let windowHeight = 0; // 获取元素 const menu = document.querySelector('.usercm'); // 鼠标移动事件 window.addEventListener('mousemove', function (e) { windowWidth = window.innerWidth; windowHeight = window.innerHeight; mouseX = e.clientX; mouseY = e.clientY; // 设置菜单位置 let left = e.pageX; let top = e.pageY; if (mouseX + menu.offsetWidth >= windowWidth) left = left - menu.offsetWidth - 5; if (mouseY + menu.offsetHeight >= windowHeight) top = top - menu.offsetHeight - 5; // 绑定右键点击事件 document.documentElement.addEventListener('contextmenu', function (event) { if (event.button === 2) { // 右键点击 event.preventDefault(); menu.style.left = `${left}px`; menu.style.top = `${top}px`; menu.style.display = 'block'; } }); // 点击隐藏菜单 document.documentElement.addEventListener('click', function () { menu.style.display = 'none'; }); }); // 禁用默认右键菜单 window.oncontextmenu = function (e) { e.preventDefault(); return false; }; // 判断是否是移动端 const userAgent = navigator.userAgent; const mobileKeywords = ['Android', 'iPhone', 'SymbianOS', 'Windows Phone', 'iPad', 'iPod']; let isMobile = false; for (let keyword of mobileKeywords) { if (userAgent.indexOf(keyword) > -1) { isMobile = true; break; } } })(); const potplayer = document.querySelector(".potplayer"); const aa2p = document.querySelector(".aa2p"); const vstats = document.querySelector(".vstats"); const notify = document.querySelector(".notify"); let videoUrl = videoElement.src; if (videoElement.src.includes("blob:")) videoUrl = GM_getValue("Reallyurl");; potplayer.addEventListener("click", function () { Launch("potplayer", videoUrl); videoElement.pause(); }) if (vstats) { vstats.addEventListener("click", function () { VideoStats.toggle(); }); } document.onkeydown = function (e) { const keyNum = window.event ? e.keyCode : e.which; if (e.altKey && Number.isInteger(keyNum)) { switch (keyNum) { case 88:// X 键--> potplayer console.log("potplayer://" + videoUrl); Launch("potplayer", videoUrl) videoElement.pause(); break; case 90:// Z 键--> 自动跳转 console.log("%cAuto jump %c%s", "", GM_getValue("check") ? "color:green;font-weight:bold;" : "color:red;font-weight:bold;", + GM_getValue("check") ? "Turn on" : "Turn off"); menuFun("check", this, "fas fa-toggle-on", "fas fa-toggle-off", "自动跳转"); break; } } }; aa2p.innerHTML = `${GM_getValue("check") ? "关闭自动跳转" : "开启自动跳转"}`; aa2p.addEventListener("click", function () { menuFun("check", this, "fas fa-toggle-on", "fas fa-toggle-off", "自动跳转"); }); notify.innerHTML = `${GM_getValue("notify") ? "关闭通知" : "开启通知"}`; notify.addEventListener("click", function () { menuFun("notify", this, "fas fa-bell", "far fa-bell-slash", "通知"); }); } } // 接收来自iframe的消息 window.addEventListener('message', function (event) { try { if (event.data.type === 'launch') { window.location.href = `${event.data.app}://${event.data.url}`; } // console.log(window.top != window ? "接收当前处于iframe中" : "接收当前不处于iframe中"); } catch (error) { try { if (window.top != window) { window.top.location.href = `${event.data.app}://${event.data.url}`; console.log("Launch to (iframe):" + `${event.data.app}://${event.data.url}`); return; } } catch (error) { if (error.message.includes("is not installed")) { alert(`请先安装 ${event.data.app}`); } } } }); /* ============================================================================ * VideoStats —— 视频信息查看器(集成自「视频信息查看器 Pro」并重写评分模型) * * 架构(关键):A2P 脚本会注入到 iframe 中,但面板只能在最外层 top 窗口渲染, * 否则每个 iframe 各弹一个面板。视频又常常在 iframe 内,top 无法跨文档读取。 * 解决方案——单一真相源 + 上报聚合: * - 每个 frame(含 top)用同一套代码采集“自己文档内最佳视频”的原始指标; * - iframe 把指标通过 postMessage 周期性上报给 top; * - 只有 top 渲染面板,并在所有上报(含自身)中挑选“最佳视频”集中评分。 * * 评分模型有据可依,四个维度的依据均在对应函数处标注: * 1) 分辨率 —— ITU-R 行业分级(SD/HD/FHD/QHD/UHD) * 2) 帧率 —— 影视/广播标准(24 电影 / 25 PAL / 30 / 50-60 高帧) * 3) 稳定性 —— ITU-T P.1203 流媒体 QoE 的渲染损伤(丢帧率) * 4) 码率充足 —— 实测码率 / 推荐码率(YouTube 上传建议 + Apple HLS * Authoring Spec 阶梯),辅以 BPP / Kush Gauge 交叉校验 * 无法测得的维度采用「权重归一化排除」而非给默认半分,避免误导性高分。 * ========================================================================== */ var VideoStats = (function () { 'use strict'; var IS_TOP = (function () { try { return window.top === window; } catch (e) { return false; } })(); var MSG = 'A2P_VSTATS'; // iframe -> top 指标上报 var MSG_CMD = 'A2P_VSTATS_CMD'; // iframe -> top 控制命令(切换显隐) // ---- 每个 frame 的本地采样状态 ---- var localTimer = null; var lastFrameCount = 0, lastFrameTime = 0, curFPS = 0, fpsHist = []; var lastDecodedBytes = 0, lastByteTime = 0, brHist = []; // ---- top 聚合状态 ---- var reports = {}; // frameId -> { metrics, ts } var selfFrameId = 'f_' + Math.random().toString(36).slice(2); var renderTimer = null; // ---- UI 状态(仅 top)---- var host = null, root = null, visible = false; /* ---------------------------------------------------------------------- * 采样:FPS * getVideoPlaybackQuality().totalVideoFrames 为累计解码帧数,对相邻采样做 * 差分 / 时间即得瞬时帧率。240 上限滤除 seek/卡顿造成的异常尖峰。 * -------------------------------------------------------------------- */ function sampleFPS(video) { if (!video || typeof video.getVideoPlaybackQuality !== 'function') return null; try { var q = video.getVideoPlaybackQuality(); var frames = q.totalVideoFrames; var now = performance.now(); var dt = (now - lastFrameTime) / 1000; if (dt >= 0.5 && lastFrameCount > 0 && !video.paused) { var fps = Math.round((frames - lastFrameCount) / dt); if (fps > 0 && fps < 360) { curFPS = fps; fpsHist.push(fps); if (fpsHist.length > 20) fpsHist.shift(); } lastFrameTime = now; lastFrameCount = frames; } else if (lastFrameCount === 0) { lastFrameCount = frames; lastFrameTime = now; } var avg = 0; if (fpsHist.length) { for (var i = 0, s = 0; i < fpsHist.length; i++) s += fpsHist[i]; avg = Math.round(s / fpsHist.length); } return { fps: curFPS, avgFPS: avg, totalFrames: q.totalVideoFrames, droppedFrames: q.droppedVideoFrames }; } catch (e) { return null; } } /* ---------------------------------------------------------------------- * 采样:码率 * 优先用 webkitVideoDecodedByteCount(Chromium 提供已解码字节累计,不受 * 跨域 Timing-Allow-Origin 限制,比 PerformanceResourceTiming.transferSize * 可靠得多——后者跨域恒为 0)。对字节差分 / 时间得瞬时码率,并维护滑动均值; * 同时给出基于 currentTime 的整段平均码率作为回退。 * -------------------------------------------------------------------- */ function sampleBitrate(video) { if (!video) return { mbps: null, method: null }; var now = performance.now(); var decoded = (typeof video.webkitVideoDecodedByteCount === 'number') ? video.webkitVideoDecodedByteCount : null; if (decoded !== null && decoded > 0) { if (lastByteTime > 0 && decoded > lastDecodedBytes) { var dt = (now - lastByteTime) / 1000; if (dt > 0.4) { var inst = (decoded - lastDecodedBytes) * 8 / dt / 1e6; if (inst > 0 && inst < 300) { brHist.push(inst); if (brHist.length > 12) brHist.shift(); } } } lastDecodedBytes = decoded; lastByteTime = now; if (brHist.length) { for (var i = 0, s = 0; i < brHist.length; i++) s += brHist[i]; return { mbps: s / brHist.length, method: 'decoded' }; } if (video.currentTime > 2) { return { mbps: decoded * 8 / video.currentTime / 1e6, method: 'decoded-avg' }; } } // 回退:PerformanceResourceTiming(仅同源/带 TAO 的资源有效) try { var res = performance.getEntriesByType('resource'); var total = 0; for (var k = 0; k < res.length; k++) { var r = res[k], n = r.name || ''; var isVid = r.initiatorType === 'video' || /\.(?:mp4|webm|m3u8|ts|m4s)(?:$|[?#])/i.test(n) || n.indexOf('videoplayback') !== -1 || n.indexOf('/video/') !== -1; if (isVid && r.transferSize) total += r.transferSize; } if (total > 0 && video.currentTime > 2) { return { mbps: total * 8 / video.currentTime / 1e6, method: 'resource' }; } } catch (e) {} return { mbps: null, method: null }; } // 采集本 frame 最佳视频的原始指标(不评分;评分集中在 top) function collectLocalMetrics() { var video = pickBestVideo(); if (!video || !video.videoWidth) return null; var fps = sampleFPS(video); var br = sampleBitrate(video); var buffered = null; try { if (video.buffered && video.buffered.length && video.duration > 0) { buffered = video.buffered.end(video.buffered.length - 1) / video.duration; } } catch (e) {} return { width: video.videoWidth, height: video.videoHeight, clientWidth: Math.round(video.clientWidth || 0), clientHeight: Math.round(video.clientHeight || 0), fps: fps ? fps.fps : 0, avgFPS: fps ? fps.avgFPS : 0, totalFrames: fps ? fps.totalFrames : 0, droppedFrames: fps ? fps.droppedFrames : 0, bitrateMbps: br.mbps, bitrateMethod: br.method, currentTime: video.currentTime || 0, duration: video.duration || 0, paused: !!video.paused, muted: !!video.muted, playbackRate: video.playbackRate || 1, buffered: buffered, area: (video.videoWidth || 0) * (video.videoHeight || 0), origin: location.host || location.href }; } function pickBestVideo() { var vids = document.querySelectorAll('video'); if (!vids.length) return null; var best = null; for (var i = 0; i < vids.length; i++) { var v = vids[i]; if (!v.paused && v.readyState >= 2 && v.videoWidth > 0) { best = v; break; } } if (!best) { var maxA = 0; for (var j = 0; j < vids.length; j++) { var a = (vids[j].videoWidth || 0) * (vids[j].videoHeight || 0); if (a > maxA) { maxA = a; best = vids[j]; } } } return best || vids[0]; } /* ====================================================================== * 评分模型(集中在 top) * 总分 100,四维度权重:分辨率 25 / 帧率 20 / 稳定性 15 / 码率充足 40。 * 测不到的维度(如码率)从总权重中剔除并对剩余维度按比例归一化,最终分 * 仍落在 0-100,且面板标注哪些维度未计入。 * ==================================================================== */ // 维度1:分辨率 —— ITU-R 行业分级(按可见高度) function scoreResolution(h) { if (h >= 2160) return { s: 1.00, label: '4K UHD' }; if (h >= 1440) return { s: 0.90, label: '1440p QHD' }; if (h >= 1080) return { s: 0.78, label: '1080p FHD' }; if (h >= 720) return { s: 0.60, label: '720p HD' }; if (h >= 576) return { s: 0.46, label: '576p' }; if (h >= 480) return { s: 0.40, label: '480p SD' }; if (h >= 360) return { s: 0.26, label: '360p' }; if (h > 0) return { s: 0.12, label: 'Low' }; return { s: 0, label: 'N/A' }; } // 维度2:帧率 —— 影视/广播标准 + 高刷内容 // 24 电影 / 25 PAL / 30 标准 / 50-60 高帧 / 90-120 高刷 / 240 极限 // 60fps 不再封顶:高刷(游戏直播、120fps 录屏、144/240Hz 内容)应高于 60fps, // 否则高帧内容被低估。满分留给 120fps+,60fps 给 0.92。 function scoreFramerate(fps) { if (fps <= 0) return { s: null, label: 'N/A' }; // 测不到则不计入 if (fps >= 230) return { s: 1.00, label: '240fps' }; if (fps >= 110) return { s: 1.00, label: '120fps' }; if (fps >= 85) return { s: 0.96, label: '90fps' }; if (fps >= 58) return { s: 0.92, label: '60fps' }; if (fps >= 48) return { s: 0.84, label: '50fps' }; if (fps >= 28) return { s: 0.70, label: '30fps' }; if (fps >= 23) return { s: 0.56, label: '24fps' }; if (fps >= 14) return { s: 0.34, label: '15fps' }; return { s: 0.15, label: 'Low' }; } // 维度3:稳定性 —— ITU-T P.1203 渲染损伤思路(丢帧率越低越好) function scoreStability(dropped, total) { if (!total || total <= 100) return { s: null, label: 'N/A' }; var rate = dropped / total * 100; if (rate < 0.1) return { s: 1.00, label: 'Excellent' }; if (rate < 0.5) return { s: 0.85, label: 'Good' }; if (rate < 1) return { s: 0.65, label: 'Fair' }; if (rate < 3) return { s: 0.40, label: 'Poor' }; return { s: 0.15, label: 'Bad' }; } /* 维度4:码率充足度 —— 实测码率 / 该“分辨率@帧率”的推荐码率。 * 基准推荐码率取自 YouTube 上传建议(SDR, H.264)与 Apple HLS Authoring * Spec 的折中常用值(以 30fps 为基准,单位 Mbps): * 2160p: 35 1440p: 16 1080p: 8 720p: 5 480p: 2.5 360p: 1.0 * 帧率缩放:码率需求随帧率上升,但因帧间冗余高、压缩更高效,呈次线性。 * 采用以 30fps 为基准的开方型缩放 factor = (fps/30)^0.7,并夹在 [0.8, 4]: * 60fps ≈ 1.62x,120fps ≈ 2.63x,与 YouTube“高帧约 1.5x”及高刷需求一致。 * ratio = 实测/推荐:>=1 视为充足;并用 BPP(Kush Gauge)做交叉合理性校验。 */ function baseRecommendedBitrate(h) { if (h >= 2160) return 35; if (h >= 1440) return 16; if (h >= 1080) return 8; if (h >= 720) return 5; if (h >= 480) return 2.5; if (h >= 360) return 1.0; return 0.6; } function framerateFactor(fps) { var f = (fps > 0 ? fps : 30) / 30; var factor = Math.pow(f, 0.7); return Math.max(0.8, Math.min(4, factor)); } function recommendedBitrate(h, fps) { return baseRecommendedBitrate(h) * framerateFactor(fps); } function scoreBitrate(mbps, w, h, fps) { if (!mbps || mbps <= 0 || !h) return { s: null, label: 'N/A', ratio: null, bpp: null }; var rec = recommendedBitrate(h, fps > 0 ? fps : 30); var ratio = mbps / rec; var s; // 充足度映射:0.5x->0.45, 1.0x->0.85, >=1.3x->1.0(过高不额外加分) if (ratio >= 1.3) s = 1.00; else if (ratio >= 1.0) s = 0.85 + (ratio - 1.0) * 0.5; else if (ratio >= 0.7) s = 0.65 + (ratio - 0.7) / 0.3 * 0.20; else if (ratio >= 0.5) s = 0.45 + (ratio - 0.5) / 0.2 * 0.20; else if (ratio >= 0.3) s = 0.25 + (ratio - 0.3) / 0.2 * 0.20; else s = Math.max(0.08, ratio / 0.3 * 0.25); var bpp = (w && h && fps > 0) ? (mbps * 1e6) / (w * h * fps) : null; var label = ratio >= 1.15 ? 'Excellent' : ratio >= 0.9 ? 'Very Good' : ratio >= 0.7 ? 'Good' : ratio >= 0.5 ? 'Fair' : ratio >= 0.3 ? 'Poor' : 'Bad'; return { s: s, label: label, ratio: ratio, bpp: bpp }; } var WEIGHTS = { resolution: 25, framerate: 20, stability: 15, bitrate: 40 }; function computeScore(m) { var r = scoreResolution(m.height); var f = scoreFramerate(m.avgFPS > 0 ? m.avgFPS : m.fps); var st = scoreStability(m.droppedFrames, m.totalFrames); var b = scoreBitrate(m.bitrateMbps, m.width, m.height, m.avgFPS > 0 ? m.avgFPS : m.fps); var dims = [ { key: 'resolution', w: WEIGHTS.resolution, s: r.s, label: r.label }, { key: 'framerate', w: WEIGHTS.framerate, s: f.s, label: f.label }, { key: 'stability', w: WEIGHTS.stability, s: st.s, label: st.label }, { key: 'bitrate', w: WEIGHTS.bitrate, s: b.s, label: b.label } ]; // 归一化:仅对“可测得”的维度计权,剔除 s===null 的维度 var totalW = 0, got = 0; for (var i = 0; i < dims.length; i++) { if (dims[i].s !== null) { totalW += dims[i].w; got += dims[i].w * dims[i].s; } } var score = totalW > 0 ? Math.round(got / totalW * 100) : 0; var grade, color; if (score >= 90) { grade = 'S'; color = '#0ff'; } else if (score >= 80) { grade = 'A'; color = '#0f0'; } else if (score >= 70) { grade = 'B'; color = '#8f0'; } else if (score >= 60) { grade = 'C'; color = '#ff0'; } else if (score >= 45) { grade = 'D'; color = '#fa0'; } else { grade = 'E'; color = '#f55'; } return { score: score, grade: grade, color: color, dims: dims, bpp: b.bpp, ratio: b.ratio, metrics: m }; } // ---- 选最佳视频(跨所有上报):优先在播放、其次面积最大 ---- function pickBestReport() { var best = null; var now = Date.now(); for (var id in reports) { if (!reports.hasOwnProperty(id)) continue; var rep = reports[id]; if (now - rep.ts > 6000) { delete reports[id]; continue; } // 过期清理 var m = rep.metrics; if (!m || !m.area) continue; if (!best) { best = m; continue; } var bp = best.paused ? 0 : 1, mp = m.paused ? 0 : 1; if (mp > bp || (mp === bp && m.area > best.area)) best = m; } return best; } /* ====================== UI(仅 top 渲染) ====================== */ var STYLE = [ ':host{all:initial}', '*{box-sizing:border-box}', '.p{position:fixed;top:10px;left:10px;width:320px;max-height:85vh;background:rgba(0,0,0,.92);color:#0f0;font-family:Consolas,Monaco,monospace;font-size:12px;border:1px solid #444;border-radius:8px;z-index:2147483647;box-shadow:0 4px 20px rgba(0,0,0,.6);display:none;flex-direction:column;overflow:hidden;line-height:1.4;cursor:move;user-select:none}', '.p.on{display:flex}', '.h{padding:10px 12px;border-bottom:1px solid #333;background:rgba(0,0,0,.3);flex-shrink:0}', '.t{font-size:13px;font-weight:bold;color:#fff;display:flex;justify-content:space-between;align-items:center}', '.x{cursor:pointer;color:#f55;font-size:18px;padding:0 5px;line-height:1}', '.x:hover{color:#f00}', '.sc{text-align:center;padding:12px;background:rgba(255,255,255,.03);border-bottom:1px solid #333;flex-shrink:0}', '.scd{display:flex;align-items:baseline;justify-content:center;gap:8px}', '.scn{font-size:32px;font-weight:bold}', '.scg{font-size:22px;font-weight:bold}', '.scl{font-size:10px;color:#666;margin-top:4px}', '.bar{height:5px;background:#222;border-radius:3px;margin-top:8px;overflow:hidden}', '.barf{height:100%;border-radius:3px;transition:width .3s,background .3s;width:0}', '.c{flex:1;overflow-y:auto;overflow-x:hidden;padding:8px 12px;min-height:100px;max-height:calc(85vh - 200px);scrollbar-width:thin;scrollbar-color:#555 #222}', '.c::-webkit-scrollbar{width:5px}.c::-webkit-scrollbar-thumb{background:#555;border-radius:3px}', '.row{display:flex;justify-content:space-between;margin:3px 0;padding:2px 0}', '.lb{color:#888;font-size:11px}', '.vl{color:#0f0;font-weight:bold;text-align:right;max-width:170px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-size:11px}', '.vl.warn{color:#fa0}.vl.err{color:#f55}.vl.exc{color:#0ff}', '.sec{margin-top:8px;padding-top:8px;border-top:1px solid #333}', '.sec:first-child{margin-top:0;padding-top:0;border-top:none}', '.st{color:#666;font-size:10px;margin-bottom:5px;text-transform:uppercase;letter-spacing:.5px}', '.ft{padding:6px 12px;border-top:1px solid #333;background:rgba(0,0,0,.3);flex-shrink:0}', '.hp{font-size:9px;color:#555;text-align:center}', '.em{padding:20px 12px;text-align:center;color:#f55;font-size:12px}' ].join(''); function ce(tag, cls, txt) { var el = document.createElement(tag); if (cls) el.className = cls; if (txt != null) el.textContent = txt; return el; } function buildUI() { if (host && document.body && document.body.contains(host)) return true; if (!document.body) return false; try { host = ce('div'); host.id = 'a2p-vstats-host'; host.style.cssText = 'position:fixed;top:0;left:0;z-index:2147483647;pointer-events:none'; root = host.attachShadow({ mode: 'closed' }); var styleEl = ce('style'); styleEl.textContent = STYLE; root.appendChild(styleEl); var wrap = ce('div'); wrap.style.pointerEvents = 'auto'; var p = ce('div', 'p'); var h = ce('div', 'h'), t = ce('div', 't'); t.appendChild(ce('span', null, 'Video Stats')); var x = ce('span', 'x', '×'); t.appendChild(x); h.appendChild(t); p.appendChild(h); var sc = ce('div', 'sc'), scd = ce('div', 'scd'); scd.appendChild(ce('span', 'scn', '--')); scd.appendChild(ce('span', 'scg', '-')); sc.appendChild(scd); sc.appendChild(ce('div', 'scl', 'Quality Score')); var bar = ce('div', 'bar'); bar.appendChild(ce('div', 'barf')); sc.appendChild(bar); p.appendChild(sc); var c = ce('div', 'c'); c.appendChild(ce('div', 'em', 'Loading...')); p.appendChild(c); var ft = ce('div', 'ft'); ft.appendChild(ce('div', 'hp', 'Alt+Q toggle | Drag to move')); p.appendChild(ft); wrap.appendChild(p); root.appendChild(wrap); document.body.appendChild(host); bindUI(p, x, c); return true; } catch (e) { return false; } } function bindUI(panel, closeBtn, content) { var drag = false, ox = 0, oy = 0; panel.addEventListener('mousedown', function (e) { if (e.target.classList.contains('x') || e.target.closest('.c')) return; drag = true; ox = e.clientX - panel.offsetLeft; oy = e.clientY - panel.offsetTop; e.preventDefault(); }); document.addEventListener('mousemove', function (e) { if (!drag) return; var x = Math.max(0, Math.min(window.innerWidth - panel.offsetWidth, e.clientX - ox)); var y = Math.max(0, Math.min(window.innerHeight - panel.offsetHeight, e.clientY - oy)); panel.style.left = x + 'px'; panel.style.top = y + 'px'; }); document.addEventListener('mouseup', function () { drag = false; }); closeBtn.addEventListener('click', function () { hide(); }); content.addEventListener('wheel', function (e) { var top = content.scrollTop === 0; var bot = content.scrollTop + content.clientHeight >= content.scrollHeight - 1; if ((e.deltaY < 0 && top) || (e.deltaY > 0 && bot)) return; e.stopPropagation(); }, { passive: false }); } function q(sel) { try { return root ? root.querySelector(sel) : null; } catch (e) { return null; } } function fmtTime(s) { if (isNaN(s) || !isFinite(s)) return null; var h = Math.floor(s / 3600), m = Math.floor((s % 3600) / 60), ss = Math.floor(s % 60); if (h > 0) return h + ':' + (m < 10 ? '0' : '') + m + ':' + (ss < 10 ? '0' : '') + ss; return m + ':' + (ss < 10 ? '0' : '') + ss; } function fmtBitrate(mbps) { if (!mbps || mbps <= 0) return null; return mbps >= 1 ? mbps.toFixed(2) + ' Mbps' : (mbps * 1000).toFixed(0) + ' Kbps'; } function addRow(sec, label, value, cls) { if (value == null || value === '') return; var row = ce('div', 'row'); row.appendChild(ce('span', 'lb', label)); row.appendChild(ce('span', 'vl' + (cls ? ' ' + cls : ''), String(value))); sec.appendChild(row); } function newSec(c, title) { var s = ce('div', 'sec'); s.appendChild(ce('div', 'st', title)); c.appendChild(s); return s; } function render() { if (!visible) return; if (!buildUI()) return; var panel = q('.p'); if (panel && !panel.classList.contains('on')) panel.classList.add('on'); var c = q('.c'), scn = q('.scn'), scg = q('.scg'), barf = q('.barf'); if (!c) return; var m = pickBestReport(); if (!m) { while (c.firstChild) c.removeChild(c.firstChild); c.appendChild(ce('div', 'em', 'No video found')); if (scn) { scn.textContent = '--'; scn.style.color = '#888'; } if (scg) { scg.textContent = '-'; scg.style.color = '#888'; } if (barf) barf.style.width = '0%'; return; } var Q = computeScore(m); if (scn) { scn.textContent = Q.score; scn.style.color = Q.color; } if (scg) { scg.textContent = Q.grade; scg.style.color = Q.color; } if (barf) { barf.style.width = Q.score + '%'; barf.style.background = Q.color; } var keep = c.scrollTop; while (c.firstChild) c.removeChild(c.firstChild); var sec; sec = newSec(c, 'Resolution'); addRow(sec, 'Video Size', m.width + ' x ' + m.height); addRow(sec, 'Megapixels', (m.area / 1e6).toFixed(2) + ' MP'); if (m.height) addRow(sec, 'Aspect', (m.width / m.height).toFixed(2) + ':1'); if (m.clientWidth && (m.clientWidth !== m.width || m.clientHeight !== m.height)) addRow(sec, 'Display', m.clientWidth + ' x ' + m.clientHeight); if (m.fps > 0 || m.totalFrames > 0) { sec = newSec(c, 'Framerate'); if (m.fps > 0) addRow(sec, 'Current FPS', m.fps + ' fps'); if (m.avgFPS > 0) addRow(sec, 'Average FPS', m.avgFPS + ' fps'); if (m.totalFrames > 0) addRow(sec, 'Total Frames', m.totalFrames.toLocaleString()); if (m.droppedFrames > 0 && m.totalFrames > 0) { var dc = m.droppedFrames > 50 ? 'err' : (m.droppedFrames > 10 ? 'warn' : ''); addRow(sec, 'Dropped', m.droppedFrames, dc); var dr = (m.droppedFrames / m.totalFrames * 100).toFixed(3); addRow(sec, 'Drop Rate', dr + '%', parseFloat(dr) > 2 ? 'err' : parseFloat(dr) > 0.5 ? 'warn' : ''); } } if (m.bitrateMbps || Q.bpp != null) { sec = newSec(c, 'Bitrate / Quality'); var fb = fmtBitrate(m.bitrateMbps); if (fb) addRow(sec, 'Est. Bitrate', fb + (m.bitrateMethod === 'decoded' ? '' : ' ~')); if (Q.ratio != null) { var rc = Q.ratio >= 0.9 ? 'exc' : Q.ratio >= 0.7 ? 'good' : Q.ratio >= 0.5 ? 'warn' : 'err'; addRow(sec, 'vs Recommended', (Q.ratio * 100).toFixed(0) + '%', rc); } if (Q.bpp != null) addRow(sec, 'BPP', Q.bpp.toFixed(4), Q.bpp >= 0.1 ? 'exc' : Q.bpp >= 0.05 ? 'good' : Q.bpp >= 0.03 ? 'warn' : 'err'); } sec = newSec(c, 'Playback'); addRow(sec, 'Status', m.paused ? 'Paused' : 'Playing', m.paused ? 'warn' : ''); var ct = fmtTime(m.currentTime), du = fmtTime(m.duration); if (ct && du) addRow(sec, 'Time', ct + ' / ' + du); if (m.playbackRate !== 1) addRow(sec, 'Speed', m.playbackRate + 'x'); if (m.muted) addRow(sec, 'Volume', 'Muted', 'warn'); if (m.buffered != null && m.buffered < 0.99) addRow(sec, 'Buffered', (m.buffered * 100).toFixed(1) + '%'); if (m.origin) addRow(sec, 'Source', m.origin); sec = newSec(c, 'Score Breakdown'); for (var i = 0; i < Q.dims.length; i++) { var d = Q.dims[i]; var pct = d.s === null ? 'N/A' : Math.round(d.s * 100) + '%'; addRow(sec, cap(d.key) + ' (w' + d.w + ')', pct + ' · ' + d.label, d.s === null ? 'warn' : ''); } c.scrollTop = keep; } function cap(s) { return s.charAt(0).toUpperCase() + s.slice(1); } /* ====================== 控制 ====================== */ function startLocalReporting() { if (localTimer) return; localTimer = setInterval(function () { var m = collectLocalMetrics(); if (!m) return; if (IS_TOP) { reports[selfFrameId] = { metrics: m, ts: Date.now() }; } else { try { window.top.postMessage({ type: MSG, frameId: selfFrameId, metrics: m }, '*'); } catch (e) {} } }, 500); } function stopLocalReporting() { if (localTimer) { clearInterval(localTimer); localTimer = null; } } function show() { if (!IS_TOP) { try { window.top.postMessage({ type: MSG_CMD, cmd: 'show' }, '*'); } catch (e) {} startLocalReporting(); return; } visible = true; if (!buildUI()) return; var p = q('.p'); if (p) p.classList.add('on'); startLocalReporting(); if (!renderTimer) renderTimer = setInterval(render, 500); render(); } function hide() { if (!IS_TOP) { try { window.top.postMessage({ type: MSG_CMD, cmd: 'hide' }, '*'); } catch (e) {} return; } visible = false; var p = q('.p'); if (p) p.classList.remove('on'); if (renderTimer) { clearInterval(renderTimer); renderTimer = null; } } function toggle() { if (!IS_TOP) { try { window.top.postMessage({ type: MSG_CMD, cmd: 'toggle' }, '*'); } catch (e) {} startLocalReporting(); return; } if (visible) hide(); else show(); } // top 接收 iframe 的上报与命令 if (IS_TOP) { window.addEventListener('message', function (e) { var d = e.data; if (!d || typeof d !== 'object') return; if (d.type === MSG && d.frameId && d.metrics) { reports[d.frameId] = { metrics: d.metrics, ts: Date.now() }; } else if (d.type === MSG_CMD) { if (d.cmd === 'show') show(); else if (d.cmd === 'hide') hide(); else if (d.cmd === 'toggle') toggle(); } }); } // 每个 frame 独立注册一次 Alt+Q(iframe 内按键也能转发给 top 切换) if (!window.__A2P_VSTATS_KEY__) { window.__A2P_VSTATS_KEY__ = true; document.addEventListener('keydown', function (e) { if (e.altKey && !e.ctrlKey && !e.shiftKey && !e.metaKey && e.key && e.key.toLowerCase() === 'q') { e.preventDefault(); e.stopPropagation(); toggle(); } }, true); } return { toggle: toggle, show: show, hide: hide }; })(); // 油猴脚本菜单命令 try { if (typeof GM_registerMenuCommand === 'function') { GM_registerMenuCommand('视频信息面板 (Alt+Q)', function () { VideoStats.toggle(); }); } } catch (e) {}