Last active
September 30, 2025 23:21
-
-
Save JimChengLin/8c79561b68664492f7a69169af530832 to your computer and use it in GitHub Desktop.
Smart HDR Compressor (Detect then Process)
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 Smart HDR Compressor (Detect then Process) | |
| // @name:zh-CN 智能HDR压缩器 (先检测后处理) | |
| // @namespace http://tampermonkey.net/ | |
| // @version 4.1 | |
| // @description Only processes images detected as HDR. First, checks if the display is in HDR mode. Then, does a quick scan for bright pixels. If found, performs a high-quality, color-preserving highlight compression in a web worker. | |
| // @description:zh-CN 仅处理检测到的HDR图片。首先检查显示器是否为HDR模式,然后快速扫描图片是否存在高光像素。如果存在,则在后台使用Web Worker进行高质量、无色差的高光压缩。 | |
| // @author Gemini | |
| // @match *://*/* | |
| // @grant none | |
| // @run-at document-start | |
| // ==/UserScript== | |
| (function() { | |
| 'use strict'; | |
| // 使用一个立即执行函数表达式 (IIFE) 来包裹整个脚本, | |
| // 这样可以避免污染全局命名空间,确保脚本内部变量不会与网页自身的脚本冲突。 | |
| // ================================================================================= | |
| // STAGE 1: 全局环境检测 (Global Environment Check) | |
| // 这是脚本的第一道,也是最重要的一道性能关卡。 | |
| // ================================================================================= | |
| // 使用 window.matchMedia API 来检查 CSS 媒体查询 '(dynamic-range: high)' 是否匹配。 | |
| // 这能判断当前显示器和浏览器环境是否处于HDR(高动态范围)模式。 | |
| if (!window.matchMedia || !window.matchMedia('(dynamic-range: high)').matches) { | |
| // 如果不匹配,说明当前不是HDR环境,那么HDR图片过曝的问题也就不存在。 | |
| console.log("HDR 压缩器: 当前显示环境非HDR模式,脚本将不会运行。"); | |
| // 直接 return,终止脚本的后续所有操作,实现零资源消耗。 | |
| return; | |
| } | |
| console.log("HDR 压缩器: 检测到HDR显示环境,脚本已激活。"); | |
| // ================================================================================= | |
| // CONFIGURATION: 用户可配置参数 | |
| // 你可以在这里微调脚本的行为,以适应你的个人偏好和显示器特性。 | |
| // ================================================================================= | |
| // 亮度阈值 (0-255)。一个像素的亮度高于此值,就会被认为是“高光像素”。 | |
| // 这个值是触发后续压缩处理的“扳机”。 | |
| // - 调低此值 (如 235): 会让脚本更敏感,更多的高光会被处理。 | |
| // - 调高此值 (如 245): 只有最极端、最刺眼的亮部才会被处理。 | |
| const BRIGHTNESS_THRESHOLD = 240; | |
| // 压缩系数 (0.0 - 1.0)。它决定了对高光部分的压缩强度。 | |
| // 值为 0.5 意味着,超过阈值的亮度部分将被“削减一半”。 | |
| // 例如: 阈值是240,一个像素亮度是250,超出的部分是10。压缩后,这部分变为 10 * 0.5 = 5。最终亮度为 240 + 5 = 245。 | |
| // - 调高此值 (如 0.7): 压缩更“狠”,高光部分会变得更暗。 | |
| // - 调低此值 (如 0.3): 压缩更“温柔”,效果更细微。 | |
| const COMPRESSION_FACTOR = 0.5; | |
| // ================================================================================= | |
| // WEB WORKER: 后台像素处理线程 | |
| // 这是脚本的核心计算部分,被封装在一个Web Worker里。 | |
| // ================================================================================= | |
| // 将Worker的代码定义为一个字符串。这是在油猴脚本中内嵌Worker的常用技巧,无需一个单独的.js文件。 | |
| const workerCode = ` | |
| // Worker 线程的入口点。通过 self.onmessage 接收来自主线程的消息。 | |
| self.onmessage = function(event) { | |
| // 从主线程传来的数据中解构出需要用到的变量。 | |
| const { imageData, threshold, compressionFactor } = event.data; | |
| const data = imageData.data; // 这是一个包含所有像素RGBA值的一维数组。 | |
| const threshold_normalized = threshold / 255; // 将阈值归一化到 0.0-1.0 范围,便于计算。 | |
| // 遍历像素数据数组。步长为4,因为每个像素由R, G, B, A四个值组成。 | |
| for (let i = 0; i < data.length; i += 4) { | |
| // 将当前像素的R,G,B值也归一化到 0.0-1.0 范围。 | |
| const r = data[i] / 255; | |
| const g = data[i + 1] / 255; | |
| const b = data[i + 2] / 255; | |
| // 使用标准的亮度计算公式 (Luminance),它更符合人眼的感知。 | |
| const luminance = 0.2126 * r + 0.7152 * g + 0.0722 * b; | |
| // 如果当前像素的亮度超过了我们设定的阈值... | |
| if (luminance > threshold_normalized) { | |
| // ...那么它就是一个需要被压缩的高光像素。 | |
| const excess = luminance - threshold_normalized; // 计算超出阈值的部分。 | |
| const new_luminance = threshold_normalized + (excess * compressionFactor); // 根据压缩系数,计算新的亮度值。 | |
| // 计算一个缩放比例。这是保证色彩(色相/饱和度)不变的关键! | |
| const scale = new_luminance / luminance; | |
| // 只有当缩放比例小于1时(即确实是降低亮度),才进行操作。 | |
| if (scale < 1) { | |
| // 将R, G, B三个通道等比例缩放,这样就不会产生色差。 | |
| data[i] *= scale; | |
| data[i + 1] *= scale; | |
| data[i + 2] *= scale; | |
| } | |
| } | |
| } | |
| // 处理完成后,通过 self.postMessage 将修改后的像素数据传回主线程。 | |
| self.postMessage(imageData); | |
| }; | |
| `; | |
| // 将字符串代码转换成一个Blob对象。 | |
| const workerBlob = new Blob([workerCode], { type: 'application/javascript' }); | |
| // 为这个Blob对象创建一个URL,以便之后可以创建Worker实例。 | |
| const workerUrl = URL.createObjectURL(workerBlob); | |
| // ================================================================================= | |
| // STAGE 2: 快速高光检测函数 (Quick Highlight Detection) | |
| // 这是脚本的第二道性能关卡,用于快速判断一张图片是否“值得”进行昂贵的处理。 | |
| // ================================================================================= | |
| function containsHighlights(imageData, threshold) { | |
| const data = imageData.data; | |
| // 遍历像素数据。 | |
| for (let i = 0; i < data.length; i += 4) { | |
| // 为了极致的速度,这里的检测使用一个简化的亮度算法 (R,G,B平均值)。 | |
| // 因为它仅用于“检测”而非“精确计算”,所以这点精度损失是值得的。 | |
| const brightness = (data[i] + data[i+1] + data[i+2]) / 3; | |
| if (brightness > threshold) { | |
| // 一旦找到任何一个过亮的像素,就立即返回 true,停止继续扫描。 | |
| // 这极大地提升了处理SDR图片时的效率。 | |
| return true; | |
| } | |
| } | |
| // 如果遍历完所有像素都没找到高光,说明是SDR图片,返回 false。 | |
| return false; | |
| } | |
| // ================================================================================= | |
| // STAGE 3: 图像处理总调度函数 (Image Processing Orchestrator) | |
| // 这个函数负责调度以上所有功能,处理单张图片。 | |
| // ================================================================================= | |
| function processImage(img) { | |
| // 通过 `dataset` 属性检查图片的处理状态,防止重复处理。 | |
| // 同时忽略没有 src 或者 src 是 data: URL 的图片。 | |
| if (img.dataset.hdrProcessed || !img.src || img.src.startsWith('data:')) { | |
| return; | |
| } | |
| // 标记为“处理中”,避免被再次触发。 | |
| img.dataset.hdrProcessed = 'pending'; | |
| // 创建一个临时的Image对象来加载图片。 | |
| // 这样做是为了能设置 crossOrigin 属性,以解决跨域(CORS)问题。 | |
| const tempImg = new Image(); | |
| tempImg.crossOrigin = "anonymous"; // 尝试以匿名模式请求图片,让服务器返回CORS头部。 | |
| tempImg.src = img.src; | |
| // 当图片加载成功后执行... | |
| tempImg.onload = () => { | |
| // 创建一个内存中的Canvas。 | |
| const canvas = document.createElement('canvas'); | |
| // `willReadFrequently: true` 是一个给浏览器的性能提示,告诉它我们接下来会频繁读取此Canvas的数据。 | |
| const ctx = canvas.getContext('2d', { willReadFrequently: true }); | |
| canvas.width = tempImg.naturalWidth; | |
| canvas.height = tempImg.naturalHeight; | |
| try { | |
| // 将图片绘制到Canvas上。 | |
| ctx.drawImage(tempImg, 0, 0); | |
| // 从Canvas获取原始像素数据。如果图片跨域且服务器不允许,这一步会抛出安全错误。 | |
| const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height); | |
| // --- 这是整个脚本最核心的决策点 --- | |
| // 调用快速检测函数,判断图片是否包含高光。 | |
| if (containsHighlights(imageData, BRIGHTNESS_THRESHOLD)) { | |
| // 如果包含高光,我们认为它是HDR图片,并进行后续处理。 | |
| console.log("HDR 压缩器: 检测到HDR图片,开始处理...", img.src); | |
| img.dataset.hdrProcessed = 'processing'; | |
| // 创建后台Worker实例。 | |
| const worker = new Worker(workerUrl); | |
| // 设置回调,当Worker处理完成并返回数据时执行。 | |
| worker.onmessage = (event) => { | |
| // 将处理后的像素数据写回Canvas。 | |
| ctx.putImageData(event.data, 0, 0); | |
| // 将Canvas的内容转换成一个 base64 的 data: URL。 | |
| // 然后替换掉原始图片的 src,用户就能看到处理后的效果了。 | |
| img.src = canvas.toDataURL(); | |
| img.dataset.hdrProcessed = 'done'; | |
| // 任务完成,终止Worker以释放内存。 | |
| worker.terminate(); | |
| }; | |
| worker.onerror = (err) => { | |
| console.error("HDR 压缩器 Worker 发生错误:", err); | |
| worker.terminate(); | |
| }; | |
| // 启动Worker,并将像素数据和配置参数发送给它。 | |
| worker.postMessage({ imageData, threshold: BRIGHTNESS_THRESHOLD, compressionFactor: COMPRESSION_FACTOR }); | |
| } else { | |
| // 如果不包含高光,我们认为它是SDR图片,直接跳过。 | |
| console.log("HDR 压缩器: 检测到SDR图片,跳过处理。", img.src); | |
| img.dataset.hdrProcessed = 'sdr_skipped'; | |
| } | |
| } catch (e) { | |
| // 捕获上面 getImageData 可能抛出的CORS错误。 | |
| console.warn(`HDR 压缩器: 无法处理图片 ${img.src}。可能受到了跨域保护(CORS)。`, e); | |
| img.dataset.hdrProcessed = 'failed'; | |
| } | |
| }; | |
| // 如果图片加载失败... | |
| tempImg.onerror = () => { | |
| console.warn(`HDR 压缩器: 无法加载图片 ${img.src}。`); | |
| img.dataset.hdrProcessed = 'failed'; | |
| }; | |
| } | |
| // ================================================================================= | |
| // OBSERVERS: 页面监听器 | |
| // 使用现代的API来高效地监听页面上的图片。 | |
| // ================================================================================= | |
| // 1. IntersectionObserver (交叉观察器) | |
| // 它的作用是“懒加载”处理。只有当图片滚动到屏幕视窗内时,才触发处理函数。 | |
| // 这极大地节省了资源,避免了一次性处理页面上所有(包括屏幕外)的图片。 | |
| const intersectionObserver = new IntersectionObserver((entries) => { | |
| entries.forEach(entry => { | |
| if (entry.isIntersecting) { | |
| processImage(entry.target); | |
| // 一旦触发,就停止对该元素的观察,避免重复触发。 | |
| intersectionObserver.unobserve(entry.target); | |
| } | |
| }); | |
| }); | |
| // 2. MutationObserver (变动观察器) | |
| // 它的作用是监听整个DOM树的变化。当有新的节点(比如图片)被添加到页面上时 | |
| // (例如在无限滚动、SPA页面切换等场景下),它能捕捉到这些新图片。 | |
| const mutationObserver = new MutationObserver((mutations) => { | |
| mutations.forEach((mutation) => { | |
| mutation.addedNodes.forEach((node) => { | |
| // 如果新添加的节点本身就是一张图片... | |
| if (node.nodeName === 'IMG') { | |
| // ...就让 IntersectionObserver 开始观察它。 | |
| intersectionObserver.observe(node); | |
| // 如果新节点不是图片,但可能包含图片... | |
| } else if (node.querySelectorAll) { | |
| // ...就查找它内部的所有图片,并让 IntersectionObserver 观察它们。 | |
| node.querySelectorAll('img').forEach(img => intersectionObserver.observe(img)); | |
| } | |
| }); | |
| }); | |
| }); | |
| // 脚本启动时,开始用 MutationObserver 观察整个文档的变化。 | |
| mutationObserver.observe(document.documentElement, { | |
| childList: true, // 观察子节点的添加或删除。 | |
| subtree: true // 观察所有后代节点。 | |
| }); | |
| // 对于脚本运行时页面上已经存在的图片,也需要让 IntersectionObserver 开始观察它们。 | |
| document.querySelectorAll('img').forEach(img => intersectionObserver.observe(img)); | |
| })(); |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment