Fortnite Image Replacer

Adds a panel to replace item previews with their full-size 'featured' images or a high-resolution frame from the item's video. Caches results in IndexedDB

当前为 2025-10-04 提交的版本,查看 最新版本

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Greasemonkey 油猴子Violentmonkey 暴力猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Violentmonkey 暴力猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Userscripts ,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展后才能安装此脚本。

(我已经安装了用户脚本管理器,让我安装!)

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

(我已经安装了用户样式管理器,让我安装!)

// ==UserScript==
// @name         Fortnite Image Replacer
// @version      1.13
// @description  Adds a panel to replace item previews with their full-size 'featured' images or a high-resolution frame from the item's video. Caches results in IndexedDB
// @match        https://fortnite.gg/*
// @grant        none
// @namespace https://greasyfork.org/users/789838
// ==/UserScript==

(function() {
    'use strict';

    // Запасное изображение (прозрачный пиксель), если замена не удалась.
    const TRANSPARENT_PIXEL = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII=';

    // --- Модуль для работы с IndexedDB ---
    // Обеспечивает надёжное и объёмное кеширование данных в браузере.
    const idb = {
        db: null, // Хранит активное соединение с базой данных.
        DB_NAME: 'FortniteImageCacheDB', // Имя базы данных.
        STORE_NAME: 'ImageStore', // Имя "таблицы" (хранилища) для кеша.

        // Инициализация и открытие соединения с базой данных.
        async init() {
            if (this.db) return; // Если соединение уже есть, ничего не делаем.
            return new Promise((resolve, reject) => {
                const request = indexedDB.open(this.DB_NAME, 1);
                request.onerror = () => reject('IndexedDB error: ' + request.error);
                request.onsuccess = () => { this.db = request.result; resolve(); };
                // Этот обработчик срабатывает один раз при создании БД или обновлении её версии.
                request.onupgradeneeded = (event) => {
                    const db = event.target.result;
                    if (!db.objectStoreNames.contains(this.STORE_NAME)) {
                        db.createObjectStore(this.STORE_NAME);
                    }
                };
            });
        },

        // Получение значения из кеша по ключу.
        async get(key) {
            await this.init();
            return new Promise((resolve) => {
                const transaction = this.db.transaction(this.STORE_NAME, 'readonly');
                const store = transaction.objectStore(this.STORE_NAME);
                const request = store.get(key);
                request.onerror = () => resolve(undefined); // В случае ошибки просто считаем, что в кеше ничего нет.
                request.onsuccess = () => resolve(request.result);
            });
        },

        // Запись значения в кеш по ключу.
        async set(key, value) {
            await this.init();
            return new Promise((resolve) => {
                const transaction = this.db.transaction(this.STORE_NAME, 'readwrite');
                const store = transaction.objectStore(this.STORE_NAME);
                const request = store.put(value, key);
                request.onerror = () => resolve(); // В случае ошибки просто завершаем, не прерывая работу.
                request.onsuccess = () => resolve();
            });
        },

        // Полная очистка кеша.
        async clear() {
            await this.init();
            return new Promise((resolve) => {
                const transaction = this.db.transaction(this.STORE_NAME, 'readwrite');
                const store = transaction.objectStore(this.STORE_NAME);
                const request = store.clear();
                request.onerror = () => resolve();
                request.onsuccess = () => resolve();
            });
        }
    };

    // --- Асинхронные функции для работы с изображениями ---

    // Проверяет существование изображения по URL, используя кеш.
    async function checkImage(url) {
        const cacheKey = 'status_' + url.split('?')[0];
        const cachedStatus = await idb.get(cacheKey);
        if (cachedStatus !== undefined) {
            return cachedStatus === 'exists';
        }

        return new Promise(resolve => {
            const img = new Image();
            img.onload = async () => { await idb.set(cacheKey, 'exists'); resolve(true); };
            img.onerror = async () => { await idb.set(cacheKey, 'missing'); resolve(false); };
            img.src = url + '?t=' + Date.now();
        });
    }

    // Захватывает кадр из видео на 2-й секунде, используя кеш.
    async function captureVideoFrame(videoUrl) {
        const cacheKey = 'frame_' + videoUrl.split('?')[0];
        const cachedFrame = await idb.get(cacheKey);
        if (cachedFrame) {
            return cachedFrame === 'failed' ? null : cachedFrame;
        }

        return new Promise(resolve => {
            const video = document.createElement('video');
            video.crossOrigin = 'anonymous';
            let attempts = 0;

            const cleanup = () => video.remove(); // Функция для удаления видеоэлемента.

            // Обработчик события загрузки видео.
            const onLoaded = () => {
                video.currentTime = (video.duration < 2) ? 0.01 : 2;
            };

            // Обработчик события перемотки видео.
            const onSeeked = async () => {
                try {
                    const canvas = document.createElement('canvas');
                    canvas.width = video.videoWidth || 800;
                    canvas.height = video.videoHeight || 450;
                    canvas.getContext('2d').drawImage(video, 0, 0, canvas.width, canvas.height);
                    const frameUrl = canvas.toDataURL(); // Преобразование кадра в изображение.
                    await idb.set(cacheKey, frameUrl);
                    resolve(frameUrl);
                } catch (e) {
                    await handleVideoError(e);
                } finally {
                    cleanup();
                }
            };

            // Обработчик ошибок.
            const handleVideoError = async () => {
                if (attempts++ < 2) {
                    video.currentTime = 0.01; // Повторная попытка с самого начала.
                    return;
                }
                await idb.set(cacheKey, 'failed');
                resolve(null);
                cleanup();
            };

            // Назначение обработчиков.
            video.addEventListener('loadeddata', onLoaded, { once: true });
            video.addEventListener('seeked', onSeeked, { once: true });
            video.addEventListener('error', handleVideoError, { once: true });
            video.src = videoUrl;
        });
    }

    // --- Основная логика ---
    // Функция, запускаемая по клику на кнопку "Replace Images".
    function replaceImages() {
        // Ищем только те картинки, которые ещё не были обработаны.
        // :not([data-processed-by-script]) — это ключевая оптимизация для повторных запусков.
        const imagesToProcess = document.querySelectorAll('.img:not([data-processed-by-script]), img[src*="/img/items/"]:not([data-processed-by-script])');

        imagesToProcess.forEach(async (image) => {
            // Сразу помечаем картинку, чтобы она не попала в следующую выборку.
            image.dataset.processedByScript = 'true';

            const originalSrc = image.src || image.getAttribute('data-src');
            if (!originalSrc) return;

            // Извлекаем ID предмета из URL иконки.
            const iconMatch = originalSrc.match(/\/img\/items\/(\d+)\/icon\.(png|jpg)(\?.+)?/);
            if (iconMatch) {
                const itemId = iconMatch[1];
                const featuredUrl = `https:\/\/fortnite.gg\/img\/items\/${itemId}\/featured.png${iconMatch[3] || ''}`;

                // Проверяем, есть ли 'featured' изображение.
                const exists = await checkImage(featuredUrl);
                if (exists) {
                    // Если есть, используем его.
                    image.src = featuredUrl;
                } else {
                    // Если нет, генерируем кадр из видео.
                    const videoUrl = `https:\/\/fnggcdn.com\/items\/${itemId}\/video.mp4`;
                    const frameUrl = await captureVideoFrame(videoUrl);
                    image.src = frameUrl || TRANSPARENT_PIXEL;
                    if (!frameUrl) image.style.opacity = '0'; // Скрываем, если и кадр не удалось получить.
                }
            }
        });
    }

    // --- Панель управления ---
    // Создаёт и добавляет на страницу панель с кнопками.
    function addControlPanel() {
        const panel = document.createElement('div');
        panel.style.cssText = 'position: fixed; top: 20px; right: 20px; background: rgba(0,0,0,0.8); padding: 15px; border-radius: 8px; z-index: 9999;';
        const buttonStyle = 'display: block; width: 100%; min-width: 150px; margin: 5px 0; padding: 10px; background: #5865F2; color: white; border: none; border-radius: 4px; cursor: pointer;';

        // Кнопка для запуска замены изображений.
        const replaceBtn = document.createElement('button');
        replaceBtn.textContent = '🖼️ Replace Images';
        replaceBtn.style.cssText = buttonStyle;
        replaceBtn.onclick = replaceImages;

        // Кнопка для очистки базы данных кеша.
        const clearBtn = document.createElement('button');
        clearBtn.textContent = '🧹 Clear DB Cache';
        clearBtn.style.cssText = buttonStyle;
        clearBtn.onclick = async () => {
            if (confirm('Очистить ВЕСЬ кеш в IndexedDB и перезагрузить?')) {
                replaceBtn.disabled = true;
                clearBtn.disabled = true;
                clearBtn.textContent = 'Clearing...';
                await idb.clear();
                window.location.reload();
            }
        };

        panel.appendChild(replaceBtn);
        panel.appendChild(clearBtn);
        document.body.appendChild(panel);
    }

    // Запускаем создание панели после полной загрузки страницы.
    window.addEventListener('load', addControlPanel);
})();