X Media

X Media:X 媒体助手 —— 一键下载帖子媒体(单图/整帖/媒体页全部图片视频)、10秒内短视频自动转GIF保存、恢复旧版个人主页媒体网格、X 界面美化。

Aby zainstalować ten skrypt, wymagana jest instalacje jednego z następujących rozszerzeń: Tampermonkey, Greasemonkey lub Violentmonkey.

You will need to install an extension such as Tampermonkey or Violentmonkey to install this script.

Aby zainstalować ten skrypt, wymagana jest instalacje jednego z następujących rozszerzeń: Tampermonkey, Violentmonkey.

Aby zainstalować ten skrypt, wymagana będzie instalacja rozszerzenia Tampermonkey lub Userscripts.

You will need to install an extension such as Tampermonkey to install this script.

Aby zainstalować ten skrypt, musisz zainstalować rozszerzenie menedżera skryptów użytkownika.

(Mam już menedżera skryptów użytkownika, pozwól mi to zainstalować!)

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

Będziesz musiał zainstalować rozszerzenie menedżera stylów użytkownika, aby zainstalować ten styl.

Będziesz musiał zainstalować rozszerzenie menedżera stylów użytkownika, aby zainstalować ten styl.

Musisz zainstalować rozszerzenie menedżera stylów użytkownika, aby zainstalować ten styl.

(Mam już menedżera stylów użytkownika, pozwól mi to zainstalować!)

// ==UserScript==
// @name         X Media
// @namespace    http://tampermonkey.net/
// @version      1.16
// @author       Ksanadu
// @match        https://twitter.com/*
// @match        https://x.com/*
// @grant        GM_download
// @grant        GM_xmlhttpRequest
// @grant        GM_addStyle
// @grant        GM_registerMenuCommand
// @grant        unsafeWindow
// @connect      cdn.jsdelivr.net
// @connect      unpkg.com
// @connect      cdnjs.cloudflare.com
// @run-at       document-start
// @noframes
// @license      MIT
// @description  X Media:X 媒体助手 —— 一键下载帖子媒体(单图/整帖/媒体页全部图片视频)、10秒内短视频自动转GIF保存、恢复旧版个人主页媒体网格、X 界面美化。
// ==/UserScript==

// =====================================================================
// X Media —— X 媒体助手
//   1. 媒体下载:单图按钮只下该图;帖子左下角按钮下整帖;
//                媒体页瓦片按钮下该帖全部图片/视频(API 缓存补全)
//   2. 媒体网格:恢复旧版个人主页媒体网格/时间线
//   3. 界面优化:恢复小鸟图标、绝对时间、侧边栏时钟/日期、
//                默认视频播放器、引用推文入口、隐藏推广等(Ctrl+Alt+O 设置面板)
// =====================================================================

(function() {
    'use strict';

    // =====================================================================
    // 模块1:恢复旧版媒体网格
    // 在 document-start 用 unsafeWindow 补丁真实页面的 __INITIAL_STATE__,
    // 把媒体轮播/新版个人主页特性开关设为关闭。必须抢在 X 内联 bootstrap
    // 脚本之前安装(X 读取后立即 delete 该属性,错过窗口即失效)。
    // =====================================================================
    const GRID_FLAG_NAMES = [
        'responsive_web_profile_redesign_enabled',
        'rweb_media_carousel_enabled'
    ];

    function disableMediaRedesign(state) {
        const featureSwitch = state && state.featureSwitch;
        const configs = [
            featureSwitch && featureSwitch.defaultConfig,
            featureSwitch && featureSwitch.user && featureSwitch.user.config
        ];
        for (const config of configs) {
            if (!config) continue;
            for (const flagName of GRID_FLAG_NAMES) {
                const flag = config[flagName];
                if (flag && typeof flag === 'object') {
                    flag.value = false;
                }
            }
        }
        return state;
    }

    function initGridRestore() {
        try {
            // 沙箱内必须补丁真实页面的 window(unsafeWindow)
            const target = (typeof unsafeWindow !== 'undefined' && unsafeWindow) ? unsafeWindow : window;
            const existingDescriptor = Object.getOwnPropertyDescriptor(target, '__INITIAL_STATE__');
            let initialState;
            if (existingDescriptor && 'value' in existingDescriptor) {
                initialState = disableMediaRedesign(existingDescriptor.value);
            }
            Object.defineProperty(target, '__INITIAL_STATE__', {
                configurable: true,
                enumerable: true,
                get() { return initialState; },
                set(value) { initialState = disableMediaRedesign(value); }
            });
        } catch (err) {
            console.warn('[X Media] 网格恢复补丁失败:', err);
        }
    }
    initGridRestore();

    // =====================================================================
    // 模块1.5:媒体列表缓存(从 GraphQL 响应中抓取完整 extended_entities)
    // 媒体Grid页面的瓦片 fiber/DOM 只含封面图,但时间线 API 返回完整媒体列表,
    // 这里缓存 tweetId -> 完整媒体数组,供下载时补全"该帖子的所有图片/视频"。
    // =====================================================================
    const mediaCache = new Map();
    const MEDIA_CACHE_MAX = 500;

    function cacheTweetsFromJson(obj) {
        if (!obj || typeof obj !== 'object') return;
        if (Array.isArray(obj)) {
            for (const x of obj) cacheTweetsFromJson(x);
            return;
        }
        const media = obj.extended_entities && obj.extended_entities.media;
        const id = obj.id_str || obj.rest_id;
        if (media && id && !mediaCache.has(String(id))) {
            if (mediaCache.size >= MEDIA_CACHE_MAX) {
                mediaCache.delete(mediaCache.keys().next().value);
            }
            mediaCache.set(String(id), media);
        }
        for (const k in obj) {
            const v = obj[k];
            if (v && typeof v === 'object') cacheTweetsFromJson(v);
        }
    }

    function hookMediaCache() {
        try {
            const handleText = (text) => {
                if (!text || !text.includes('"extended_entities"')) return;
                try {
                    cacheTweetsFromJson(JSON.parse(text));
                } catch (e) { /* 忽略解析失败 */ }
            };

            // fetch
            const origFetch = window.fetch;
            if (origFetch) {
                window.fetch = async function (...args) {
                    const res = await origFetch.apply(this, args);
                    try {
                        const url = String(args[0] && (args[0].url || args[0]));
                        const ct = res.headers.get('content-type') || '';
                        if (url.includes('/i/api/graphql/') && ct.includes('json')) {
                            const clone = res.clone();
                            clone.text().then(handleText).catch(() => {});
                        }
                    } catch (e) {}
                    return res;
                };
            }

            // XMLHttpRequest
            const origOpen = XMLHttpRequest.prototype.open;
            const origSend = XMLHttpRequest.prototype.send;
            XMLHttpRequest.prototype.open = function (m, u) {
                this.__mediaCacheUrl = u;
                return origOpen.apply(this, arguments);
            };
            XMLHttpRequest.prototype.send = function (...args) {
                const self = this;
                this.addEventListener('load', () => {
                    try {
                        const url = String(self.__mediaCacheUrl || '');
                        const ct = self.getResponseHeader('content-type') || '';
                        if (url.includes('/i/api/graphql/') && ct.includes('json') && self.responseText) {
                            handleText(self.responseText);
                        }
                    } catch (e) {}
                });
                return origSend.apply(this, args);
            };
        } catch (err) {
            console.warn('[X Media] media cache hook failed:', err);
        }
    }
    hookMediaCache();

    // =====================================================================
    // 模块2:界面美化
    // 恢复小鸟图标、绝对时间、侧边栏时钟/日期、默认视频播放器、
    // 引用推文入口、界面整理(去边框/加宽时间线/隐藏推广)等。
    // =====================================================================

    // document-start 时 document.head 可能尚不存在,安全追加样式
    function appendStyleOnce(styleEl) {
        if (document.head) {
            document.head.appendChild(styleEl);
        } else {
            document.addEventListener('DOMContentLoaded', () => {
                if (document.head && !styleEl.isConnected) document.head.appendChild(styleEl);
            });
        }
    }

    try {
        GM_addStyle(`
        /* -----------------------------------------------------------------------------------
        去除基础边框
        ----------------------------------------------------------------------------------- */
        /* light */
        .r-jxzhtn /* basic */,
        .r-1igl3o0, /* tl */
        /* gray */
        .r-18bvks7 /* basic */,
        .r-1ila09b /* tl */,
        /* dark */
        .r-1kqtdi0 /* basic */,
        .r-j5o65s /* tl */ {
          border: none !important;
        }

        /* -----------------------------------------------------------------------------------
        去除推文下方的分隔边框
        ----------------------------------------------------------------------------------- */
        .r-109y4c4 {
          height: 0 !important;
        }

        /* -----------------------------------------------------------------------------------
        时间线加宽(600→700px)、右侧栏收窄(350→250px)、图片放大时回复栏加宽(350→550px)
        ----------------------------------------------------------------------------------- */
        /* 时间线 */
        .r-1ye8kvj {
          max-width: 700px !important;
        }
        /* 侧边栏 */
        .r-1hycxz {
          width: 250px !important;
        }
        /* 图片放大时的回复栏 */
        .css-175oi2r.r-kemksi.r-1kqtdi0.r-th6na.r-1phboty.r-1dqxon3.r-1hycxz {
          width: 550px !important;
        }

        /* -----------------------------------------------------------------------------------
        隐藏头部滚动条
        ----------------------------------------------------------------------------------- */
        .css-175oi2r.r-1pi2tsx.r-1wtj0ep.r-1rnoaur.r-o96wvk.r-is05cd {
          overflow-y: scroll !important;
          -ms-overflow-style: none !important;
          scrollbar-width: none !important;
        }
        .css-175oi2r.r-1pi2tsx.r-1wtj0ep.r-1rnoaur.r-o96wvk.r-is05cd::-webkit-scrollbar {
          display: none !important;
        }

        /* -----------------------------------------------------------------------------------
        隐藏侧边栏的 Premium 推广
        ----------------------------------------------------------------------------------- */
        .css-175oi2r.r-1habvwh.r-eqz5dr.r-uaa2di.r-1mmae3n.r-3pj75a.r-bnwqim {
          display: none !important;
        }

        /* -----------------------------------------------------------------------------------
        隐藏侧边栏的"谁关注"
        ----------------------------------------------------------------------------------- */
        .css-175oi2r.r-1bro5k0 {
          display: none !important;
        }

        /* -----------------------------------------------------------------------------------
        隐藏时间线上的用户名/日期冗余行
        ----------------------------------------------------------------------------------- */
        div[data-testid="User-Name"] > div:nth-child(2) > div > div:nth-child(1),
        div[data-testid="User-Name"] > div:nth-child(2) > div > div:nth-child(2) {
          display: none !important;
        }

        /* -----------------------------------------------------------------------------------
        时间线上的账户名与日期改为纵向排列
        ----------------------------------------------------------------------------------- */
        div[data-testid="User-Name"] {
          align-items: initial !important;
          flex-direction: column !important;
        }
        div[data-testid="User-Name"] > div:last-child {
          margin-left: 0 !important;
        }

        /* -----------------------------------------------------------------------------------
        时钟/日期字体颜色适配三种主题(light / gray / dark)
        ----------------------------------------------------------------------------------- */
        /* light */
        html[style*="color-scheme: light;"] #date__container__text,
        html[style*="color-scheme: light;"] #time__container__text,
        body[style*="background-color: rgb(255, 255, 255);"] #date__container__text,
        body[style*="background-color: rgb(255, 255, 255);"] #time__container__text {
          color: #0f1419;
        }
        /* gray */
        body[style*="background-color: rgb(21, 32, 43);"] #date__container__text,
        body[style*="background-color: rgb(21, 32, 43);"] #time__container__text {
          color: #f7f9f9;
        }
        /* dark */
        html[style*="color-scheme: dark;"] #date__container__text,
        html[style*="color-scheme: dark;"] #time__container__text,
        body[style*="background-color: rgb(0, 0, 0);"] #date__container__text,
        body[style*="background-color: rgb(0, 0, 0);"] #time__container__text {
          color: #e7e9ea;
        }
    `);
    } catch (err) {
        console.warn('[X Media] 界面样式注入失败:', err);
    }

    // 从本地存储读取设置
    function loadConfig() {
        const savedConfig = localStorage.getItem("xMediaConfig");
        if (savedConfig) {
            Object.assign(config, JSON.parse(savedConfig));
        }
    }

    // 保存设置到本地存储
    function saveConfig() {
        localStorage.setItem("xMediaConfig", JSON.stringify(config));
    }

    // -----------------------------------------------------------------------------------
    // 工具函数与常量
    // -----------------------------------------------------------------------------------
    const Utils = {
        debounce: (func, wait) => {
            let timeout;
            return (...args) => {
                clearTimeout(timeout);
                timeout = setTimeout(() => func(...args), wait);
            };
        },

        pad: (num) => num.toString().padStart(2, "0"),

        createElement: (tag, options = {}) => {
            const element = document.createElement(tag);
            if (options.id) element.id = options.id;
            options.classList?.forEach((cls) => element.classList.add(cls));
            Object.entries(options.attributes || {}).forEach(([attr, value]) =>
                element.setAttribute(attr, value)
            );
            Object.entries(options.styles || {}).forEach(([key, value]) => {
                element.style[key] = value;
            });
            if (options.innerHTML) element.innerHTML = options.innerHTML;
            if (options.textContent) element.textContent = options.textContent;
            return element;
        },

        observeDOM: (
            targetNode,
            callback,
            config = { childList: true, subtree: true }
        ) => {
            const observer = new MutationObserver(callback);
            observer.observe(targetNode, config);
            return observer;
        },
    };

    // 多言語定義
    const TRANSLATIONS = {
        en: {
            panel: {
                replaceIcons: "Reclaim Twitter (restore icon)",
                useAbsoluteTime: "Change TL time from relative to absolute time",
                showTimeAndDateSidebar: "Display time and date in sidebar",
                useDefaultVideoPlayer: "Revert video player to default",
                enhanceTweetEngagements: "Easy access to quoted tweets",
                shortVideoToGif: "Save videos shorter than 10s as GIF",
            },
            weeks: ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"],
        },
        ja: {
            panel: {
                replaceIcons: "Twitterを取り戻す (アイコンを元に戻す)",
                useAbsoluteTime: "TLの時間を相対時間から絶対時間に変更",
                showTimeAndDateSidebar: "サイドバーに時間、日付を表示",
                useDefaultVideoPlayer: "動画プレイヤーをデフォルトに戻す",
                enhanceTweetEngagements: "引用ツイートへのアクセスを簡単に",
                shortVideoToGif: "10秒未満の動画をGIFで保存",
            },
            weeks: ["日", "月", "火", "水", "木", "金", "土"],
        },
        zh: {
            panel: {
                replaceIcons: "替换 Twitter 图标",
                useAbsoluteTime: "使用绝对时间",
                showTimeAndDateSidebar: "显示时间和日期侧边栏",
                useDefaultVideoPlayer: "使用默认视频播放器",
                enhanceTweetEngagements: "增强推文互动",
                shortVideoToGif: "10秒内的视频保存为GIF",
            },
            weeks: ["周日", "周一", "周二", "周三", "周四", "周五", "周六"],
        },
        ko: {
            panel: {
                replaceIcons: "Twitter 아이콘 교체",
                useAbsoluteTime: "절대 시간 사용",
                showTimeAndDateSidebar: "시간 및 날짜 사이드바 표시",
                useDefaultVideoPlayer: "기본 비디오 플레이어 사용",
                enhanceTweetEngagements: "트윗 참여 향상",
                shortVideoToGif: "10초 미만 동영상을 GIF로 저장",
            },
            weeks: ["일", "월", "화", "수", "목", "금", "토"],
        },
        ru: {
            panel: {
                replaceIcons: "Заменить иконки Twitter",
                useAbsoluteTime: "Использовать абсолютное время",
                showTimeAndDateSidebar: "Показать боковую панель времени и даты",
                useDefaultVideoPlayer: "Использовать стандартный видеоплеер",
                enhanceTweetEngagements: "Улучшить взаимодействие с твитами",
                shortVideoToGif: "Видео короче 10 сек. сохранять как GIF",
            },
            weeks: ["Вс", "Пн", "Вт", "Ср", "Чт", "Пт", "Сб"],
        },
        de: {
            panel: {
                replaceIcons: "Twitter-Icons ersetzen",
                useAbsoluteTime: "Absolute Zeit verwenden",
                showTimeAndDateSidebar: "Zeit- und Datums-Sidebar anzeigen",
                useDefaultVideoPlayer: "Standard-Video-Player verwenden",
                enhanceTweetEngagements: "Tweet-Interaktionen verbessern",
                shortVideoToGif: "Videos unter 10 Sek. als GIF speichern",
            },
            weeks: ["So", "Mo", "Di", "Mi", "Do", "Fr", "Sa"],
        },
    };

    const LANG = navigator.language.split("-")[0];
    const CURRENT_LANG = TRANSLATIONS[LANG] || TRANSLATIONS.en;

    const PANEL_LANG = CURRENT_LANG.panel;
    const WEEKS_LANG = CURRENT_LANG.weeks;

    // -----------------------------------------------------------------------------------
    // 设置面板
    // -----------------------------------------------------------------------------------
    const config = {
        replaceIcons: true,
        useAbsoluteTime: true,
        showTimeAndDateSidebar: true,
        useDefaultVideoPlayer: true,
        enhanceTweetEngagements: true,
        gifShortVideos: true,
    };

    const SettingsModule = {
        createSettingsUI: function () {
            const settingsDiv = Utils.createElement("div", {
                id: "x-media-panel",
                classList: ["x-media-panel"],
            });

            // 面板内联样式
            Object.assign(settingsDiv.style, {
                position: "fixed",
                top: "10px",
                right: "10px",
                zIndex: "9999",
                background: "#f9f9f9",
                padding: "15px",
                border: "1px solid #ccc",
                borderRadius: "10px",
                boxShadow: "0 4px 12px rgba(0, 0, 0, 0.1)",
                color: "#333",
                fontFamily: "Arial, sans-serif",
                width: "300px",
                maxWidth: "100%",
                display: "none",
                transition: "transform 0.3s ease, opacity 0.3s ease",
            });

            const title = Utils.createElement("h3", {
                textContent: "X Media Settings",
            });
            title.style.fontSize = "18px";
            title.style.margin = "10px";
            title.style.color = "#333";
            settingsDiv.appendChild(title);

            const features = [
                { key: "replaceIcons", label: PANEL_LANG.replaceIcons },
                { key: "useAbsoluteTime", label: PANEL_LANG.useAbsoluteTime },
                {
                    key: "showTimeAndDateSidebar",
                    label: PANEL_LANG.showTimeAndDateSidebar,
                },
                {
                    key: "useDefaultVideoPlayer",
                    label: PANEL_LANG.useDefaultVideoPlayer,
                },
                {
                    key: "enhanceTweetEngagements",
                    label: PANEL_LANG.enhanceTweetEngagements,
                },
                {
                    key: "gifShortVideos",
                    label: PANEL_LANG.shortVideoToGif,
                },
            ];

            features.forEach(({ key, label }) => {
                const checkbox = Utils.createElement("input", {
                    attributes: { type: "checkbox", id: key },
                });
                checkbox.checked = config[key];
                checkbox.addEventListener("change", () => {
                    config[key] = checkbox.checked;
                    saveConfig();
                    location.reload();
                });

                const labelElement = Utils.createElement("label", {
                    attributes: { for: key },
                    textContent: label,
                });
                labelElement.style.marginLeft = "8px";
                labelElement.style.fontSize = "14px";
                labelElement.style.color = "#555";

                settingsDiv.appendChild(checkbox);
                settingsDiv.appendChild(labelElement);
                settingsDiv.appendChild(Utils.createElement("br"));
            });

            document.body.appendChild(settingsDiv);
        },

        toggleSettingsPanel: function () {
            const panel = document.getElementById("x-media-panel");
            if (panel) {
                if (panel.style.display === "none") {
                    panel.style.display = "block";
                    panel.style.transform = "scale(1)";
                    panel.style.opacity = "1";
                } else {
                    panel.style.transform = "scale(0.9)";
                    panel.style.opacity = "0";
                    setTimeout(() => {
                        panel.style.display = "none";
                    }, 300);
                }
            }
        },
    };

    // 快捷键(Ctrl+Alt+O 切换设置面板)
    function setupKeyboardShortcut() {
        document.addEventListener("keydown", function (e) {
            if (e.ctrlKey && e.altKey && e.key === "o") {
                SettingsModule.toggleSettingsPanel();
            }
        });
    }

    // 注册 Tampermonkey 菜单命令
    function setupMenuCommand() {
        GM_registerMenuCommand("Toggle X Media Settings", () => {
            SettingsModule.toggleSettingsPanel();
        });
    }

    // -----------------------------------------------------------------------------------
    // 恢复 Twitter 小鸟图标
    // -----------------------------------------------------------------------------------

    function replaceTwitterIcons() {
        if (!config.replaceIcons) return;

        const paths = {
            bird: "M23.643 4.937c-.835.37-1.732.62-2.675.733.962-.576 1.7-1.49 2.048-2.578-.9.534-1.897.922-2.958 1.13-.85-.904-2.06-1.47-3.4-1.47-2.572 0-4.658 2.086-4.658 4.66 0 .364.042.718.12 1.06-3.873-.195-7.304-2.05-9.602-4.868-.4.69-.63 1.49-.63 2.342 0 1.616.823 3.043 2.072 3.878-.764-.025-1.482-.234-2.11-.583v.06c0 2.257 1.605 4.14 3.737 4.568-.392.106-.803.162-1.227.162-.3 0-.593-.028-.877-.082.593 1.85 2.313 3.198 4.352 3.234-1.595 1.25-3.604 1.995-5.786 1.995-.376 0-.747-.022-1.112-.065 2.062 1.323 4.51 2.093 7.14 2.093 8.57 0 13.255-7.098 13.255-13.254 0-.2-.005-.402-.014-.602.91-.658 1.7-1.477 2.323-2.41z",
            premium:
                "M 8.52 3.59 c 0.8 -1.1 2.04 -1.84 3.48 -1.84 s 2.68 0.74 3.49 1.84 c 1.34 -0.21 2.74 0.14 3.76 1.16 s 1.37 2.42 1.16 3.77 c 1.1 0.8 1.84 2.04 1.84 3.48 s -0.74 2.68 -1.84 3.48 c 0.21 1.34 -0.14 2.75 -1.16 3.77 s -2.42 1.37 -3.76 1.16 c -0.8 1.1 -2.05 1.84 -3.49 1.84 s -2.68 -0.74 -3.48 -1.84 c -1.34 0.21 -2.75 -0.14 -3.77 -1.16 c -1.01 -1.02 -1.37 -2.42 -1.16 -3.77 c -1.09 -0.8 -1.84 -2.04 -1.84 -3.48 s 0.75 -2.68 1.84 -3.48 c -0.21 -1.35 0.14 -2.75 1.16 -3.77 s 2.43 -1.37 3.77 -1.16 Z m 3.48 0.16 c -0.85 0 -1.66 0.53 -2.12 1.43 l -0.38 0.77 l -0.82 -0.27 c -0.96 -0.32 -1.91 -0.12 -2.51 0.49 c -0.6 0.6 -0.8 1.54 -0.49 2.51 l 0.27 0.81 l -0.77 0.39 c -0.9 0.46 -1.43 1.27 -1.43 2.12 s 0.53 1.66 1.43 2.12 l 0.77 0.39 l -0.27 0.81 c -0.31 0.97 -0.11 1.91 0.49 2.51 c 0.6 0.61 1.55 0.81 2.51 0.49 l 0.82 -0.27 l 0.38 0.77 c 0.46 0.9 1.27 1.43 2.12 1.43 s 1.66 -0.53 2.12 -1.43 l 0.39 -0.77 l 0.82 0.27 c 0.96 0.32 1.9 0.12 2.51 -0.49 c 0.6 -0.6 0.8 -1.55 0.48 -2.51 l -0.26 -0.81 l 0.76 -0.39 c 0.91 -0.46 1.43 -1.27 1.43 -2.12 s -0.52 -1.66 -1.43 -2.12 l -0.77 -0.39 l 0.27 -0.81 c 0.32 -0.97 0.12 -1.91 -0.48 -2.51 c -0.61 -0.61 -1.55 -0.81 -2.51 -0.49 l -0.82 0.27 l -0.39 -0.77 c -0.46 -0.9 -1.27 -1.43 -2.12 -1.43 Z m 4.74 5.68 l -6.2 6.77 l -3.74 -3.74 l 1.41 -1.42 l 2.26 2.26 l 4.8 -5.23 l 1.47 1.36 Z",
            defaultHomeActive:
                "M21.591 7.146L12.52 1.157c-.316-.21-.724-.21-1.04 0l-9.071 5.99c-.26.173-.409.456-.409.757v13.183c0 .502.418.913.929.913H9.14c.51 0 .929-.41.929-.913v-7.075h3.909v7.075c0 .502.417.913.928.913h6.165c.511 0 .929-.41.929-.913V7.904c0-.301-.158-.584-.408-.758z",
            twitterHome:
                "M12 9c-2.209 0-4 1.791-4 4s1.791 4 4 4 4-1.791 4-4-1.791-4-4-4zm0 6c-1.105 0-2-.895-2-2s.895-2 2-2 2 .895 2 2-.895 2-2 2zm0-13.304L.622 8.807l1.06 1.696L3 9.679V19.5C3 20.881 4.119 22 5.5 22h13c1.381 0 2.5-1.119 2.5-2.5V9.679l1.318.824 1.06-1.696L12 1.696zM19 19.5c0 .276-.224.5-.5.5h-13c-.276 0-.5-.224-.5-.5V8.429l7-4.375 7 4.375V19.5z",
            twitterHomeActive:
                "M12 1.696L.622 8.807l1.06 1.696L3 9.679V19.5C3 20.881 4.119 22 5.5 22h13c1.381 0 2.5-1.119 2.5-2.5V9.679l1.318.824 1.06-1.696L12 1.696zM12 16.5c-1.933 0-3.5-1.567-3.5-3.5s1.567-3.5 3.5-3.5 3.5 1.567 3.5 3.5-1.567 3.5-3.5 3.5z",
        };

        GM_addStyle(`
          /* bird */
          .r-64el8z[href="/home"] > div > svg > g > path, /* main */
          .r-1blnp2b > g > path /* splash */ {
            d: path("${paths.bird}") !important;
          }

          /* premium */
          .r-eqz5dr[href="/i/premium_sign_up"] > div > div > svg > g > path {
            d: path("${paths.premium}") !important;
          }

          /* 通过 :not() 同时适配首页图标的激活/未激活两种状态 */

          /* home active */
          .r-eqz5dr[href="/home"] > div > div > svg > g > path:not(path[d="${paths.twitterHome}"]) {
            d:path("${paths.twitterHomeActive}");
          }

          /* home not active */
          .r-eqz5dr[href="/home"] > div > div > svg > g > path:not(path[d="${paths.defaultHomeActive}"]) {
            d:path("${paths.twitterHome}");
          }
        `);
    }

    // -----------------------------------------------------------------------------------
    // 时间线相对时间改为绝对时间(HH:MM:SS・月/日/年, 星期)
    // -----------------------------------------------------------------------------------
    // 时间戳模块
    const TimestampModule = {
        toFormattedDateString: function (date) {
            const YEAR = date.getFullYear().toString().slice(-2);
            const TIME = `${Utils.pad(date.getHours())}:${Utils.pad(date.getMinutes())}:${Utils.pad(date.getSeconds())}`;
            const DATE = `${Utils.pad(date.getMonth() + 1)}/${Utils.pad(date.getDate())}/${YEAR}, ${WEEKS_LANG[date.getDay()]}`;
            return `${TIME}・${DATE}`;
        },
        // 更新时间戳
        updateTimestamps: function () {
            if (!config.useAbsoluteTime) return;

            /*
              1. 常规时间元素
              2. 引用推文的时间元素
            */
            const timeSelectors =
                'a[href*="/status/"] > time, div.css-146c3p1.r-bcqeeo.r-1ttztb7.r-qvutc0.r-1qd0xha.r-a023e6.r-rjixqe.r-16dba41.r-xoduu5.r-1q142lx.r-1w6e6rj.r-9aw3ui.r-3s2u2q > time';

            document.querySelectorAll(timeSelectors).forEach((timeElement) => {
                const parent = timeElement.parentNode;
                const span = Utils.createElement("span", {
                    textContent: this.toFormattedDateString(
                        new Date(timeElement.getAttribute("datetime"))
                    ),
                });
                span.style.pointerEvents = "none";
                parent.appendChild(span);
                parent.removeChild(timeElement);
            });
        },
    };

    // -----------------------------------------------------------------------------------
    // 侧边栏显示时间与日期(HH:MM:SS・月/日/年, 星期)
    // -----------------------------------------------------------------------------------
    const SidebarModule = {
        createInfoElement: function (type) {
            if (!config.showTimeAndDateSidebar) return;

            // [适配] 左导航选择器:旧版 class 组合已失效,回退到包含 /home 链接的 nav
            const nav =
                document.querySelector('div[class="css-175oi2r r-vacyoi r-ttdzmv"]') ||
                [...document.querySelectorAll('nav[role="navigation"]')].find(n => n.querySelector('a[href="/home"]')) ||
                document.querySelector('nav[role="navigation"]');
            if (!nav || document.getElementById(type)) return;

            // [适配] 去掉 font-awesome 依赖,改用内联 SVG 图标
            const iconHTML =
                type === "time"
                    ? '<svg viewBox="0 0 24 24" aria-hidden="true" style="width: 26.25px; height: 26.25px; fill: currentColor;"><path d="M11.99 2C6.47 2 2 6.48 2 12s4.47 10 9.99 10C17.52 22 22 17.52 22 12S17.52 2 11.99 2zM12 20c-4.42 0-8-3.58-8-8s3.58-8 8-8 8 3.58 8 8-3.58 8-8 8zm.5-13H11v6l5.25 3.15.75-1.23-4.5-2.67V7z"/></svg>'
                    : '<svg viewBox="0 0 24 24" aria-hidden="true" style="width: 26.25px; height: 26.25px; fill: currentColor;"><path d="M17 12h-5v5h5v-5zM16 1v2H8V1H6v2H5c-1.11 0-1.99.9-1.99 2L3 19c0 1.1.89 2 2 2h14c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2h-1V1h-2zm3 18H5V8h14v11z"/></svg>';

            const textContentFunc = () => {
                const date = new Date();
                const YEAR = date.getFullYear().toString().slice(-2);
                const TIME = `${Utils.pad(date.getHours())}:${Utils.pad(date.getMinutes())}:${Utils.pad(date.getSeconds())}`;
                const DATE = `${Utils.pad(date.getMonth() + 1)}/${Utils.pad(date.getDate())}/${YEAR}, ${WEEKS_LANG[date.getDay()]}`;

                return type === "time" ? `${TIME}` : `${DATE}`;
            };

            const infoElement = Utils.createElement("div", {
                id: type,
                classList: [
                    "css-g5y9jx",
                    "r-6koalj",
                    "r-eqz5dr",
                    "r-16y2uox",
                    "r-1habvwh",
                    "r-cnw61z",
                    "r-13qz1uu",
                    "r-1loqt21",
                    "r-1ny4l3l",
                ],
            });

            const container = Utils.createElement("div", {
                id: `${type}__container`,
                classList: [
                    "css-g5y9jx",
                    "r-sdzlij",
                    "r-dnmrzs",
                    "r-1awozwy",
                    "r-18u37iz",
                    "r-1777fci",
                    "r-xyw6el",
                    "r-o7ynqc",
                    "r-6416eg",
                ],
            });

            const icon = Utils.createElement("div", {
                id: `${type}__container__icon`,
                classList: ["css-g5y9jx"],
                innerHTML: iconHTML,
            });

            const text = Utils.createElement("div", {
                id: `${type}__container__text`,
                classList: [
                    "css-146c3p1",
                    "r-dnmrzs",
                    "r-1udh08x",
                    "r-3s2u2q",
                    "r-bcqeeo",
                    "r-1ttztb7",
                    "r-qvutc0",
                    "r-1qd0xha",
                    "r-adyw6z",
                    "r-135wba7",
                    "r-16dba41",
                    "r-dlybji",
                    "r-nazi8o",
                ],
            });

            const textContent = Utils.createElement("span", {
                id: `${type}__text__content`,
                classList: ["1jxf684", "r-bcqeeo", "r-1ttztb7", "r-qvutc0", "r-poiln3"],
                textContent: textContentFunc(),
            });

            text.appendChild(textContent);
            container.appendChild(icon);
            container.appendChild(text);
            infoElement.appendChild(container);
            nav.appendChild(infoElement);

            if (type === "time") {
                setInterval(() => {
                    textContent.textContent = textContentFunc();
                }, 1000);
            }
        },

        init: function () {
            this.createInfoElement("time");
            this.createInfoElement("date");

            const observer = new MutationObserver(() => {
                this.createInfoElement("time");
                this.createInfoElement("date");
            });

            observer.observe(document.body, { childList: true, subtree: true });
        },
    };

    // -----------------------------------------------------------------------------------
    // 视频播放器恢复默认样式
    // -----------------------------------------------------------------------------------
    const VideoModule = {
        setupDefaultVideoPlayer: function (container) {
            if (!config.useDefaultVideoPlayer) return;

            const video = container.querySelector("div:first-child > div > video");
            if (!video) return;

            video.controls = true;
            video.removeAttribute("disablepictureinpicture");
            video.muted = false;

            const onClick = (e) => {
                e.preventDefault();
                video
                    .play()
                    .then(() => {
                        video.muted = false;
                    })
                    .catch((error) => console.error("Video playback error:", error));

                const onVolumeChange = (e) => {
                    if (e.target.muted) {
                        e.target.muted = false;
                    }
                    e.target.removeEventListener("volumechange", onVolumeChange);
                };

                e.target.addEventListener("volumechange", onVolumeChange);
                video.removeEventListener("click", onClick);
            };

            video.addEventListener("click", onClick);

            container.parentElement.appendChild(video);
            container.remove();
        },

        observeVideos: function () {
            const observer = new MutationObserver(() => {
                const videoContainer = document.body.querySelector(
                    'div[data-testid="videoComponent"]:not(.enhanced-video)'
                );
                if (videoContainer) {
                    videoContainer.classList.add("enhanced-video");
                    setTimeout(() => this.setupDefaultVideoPlayer(videoContainer), 100);
                }
            });

            observer.observe(document.body, { subtree: true, childList: true });
        },
    };

    // -----------------------------------------------------------------------------------
    // 操作栏新增"引用推文"快捷入口
    // -----------------------------------------------------------------------------------
    const TweetEngagementModule = {
        createQuoteButton: function (tweetId) {
            if (!config.enhanceTweetEngagements) return;

            // 创建按钮
            const tweetEngagementButton = Utils.createElement("a", {
                attributes: {
                    href: `https://x.com${tweetId}/quotes`,
                    "data-testid": "tweetEngagements",
                    target: "_blank",
                    rel: "noopener",
                },
                classList: [
                    "css-175oi2r",
                    "r-1777fci",
                    "r-bt1l66",
                    "r-bztko3",
                    "r-lrvibr",
                    "r-1loqt21",
                    "r-1ny4l3l",
                    "r-1wron08",
                ],
            });

            // 点击后在新标签页打开引用推文列表
            tweetEngagementButton.addEventListener("click", (event) => {
                const tweetEngagementHref = event.currentTarget.getAttribute("href");
                window.open(tweetEngagementHref, "_blank");
            });

            // 图标元素
            const tweetEngagementIconDiv = Utils.createElement("div", {
                attributes: { dir: "ltr" },
                classList: [
                    "css-146c3p1",
                    "r-bcqeeo",
                    "r-1ttztb7",
                    "r-qvutc0",
                    "r-1qd0xha",
                    "r-a023e6",
                    "r-rjixqe",
                    "r-16dba41",
                    "r-1awozwy",
                    "r-6koalj",
                    "r-1h0z5md",
                    "r-o7ynqc",
                    "r-clp7b1",
                    "r-3s2u2q",
                ],
            });

            // 向上查找按钮所在的操作栏(需包含 4 个指定类,否则返回 null)
            const tweetEngagementParent = () => {
                let parent = tweetEngagementButton.parentElement;
                while (parent) {
                    if (
                        parent.classList.contains("css-175oi2r") &&
                        parent.classList.contains("r-1kbdv8c") &&
                        parent.classList.contains("r-18u37iz") &&
                        parent.classList.contains("r-1wtj0ep")
                    ) {
                        return parent;
                    }
                    parent = parent.parentElement;
                }
                return null;
            };

            const tweetEngagementIcon = () => {
                const parent = tweetEngagementParent();
                const tweetEngagementIconBaseClass =
                    "r-4qtqp9 r-yyyyoo r-dnmrzs r-bnwqim r-lrvibr r-m6rgpd";

                // 未找到操作栏时使用基础图标类
                if (!parent) return tweetEngagementIconBaseClass;

                // 主贴(详情页)时图标稍大(1.5rem)
                if (
                    parent.classList.contains("r-1oszu61") &&
                    parent.classList.contains("r-3qxfft") &&
                    parent.classList.contains("r-n7gxbd") &&
                    parent.classList.contains("r-2sztyj") &&
                    parent.classList.contains("r-1efd50x") &&
                    parent.classList.contains("r-5kkj8d") &&
                    parent.classList.contains("r-h3s6tt") &&
                    parent.classList.contains("r-1igl3o0") &&
                    parent.classList.contains("r-rull8r") &&
                    parent.classList.contains("r-qklmqi")
                ) {
                    return `${tweetEngagementIconBaseClass} r-50lct3 r-1srniu`;
                }
                // 时间线/回复场景图标较小(1.25rem)
                return `${tweetEngagementIconBaseClass} r-1xvli5t r-1hdv0qi`;
            };

            // SVG 图标元素
            const tweetEngagementIconElement = document.createElementNS(
                "http://www.w3.org/2000/svg",
                "svg"
            );
            tweetEngagementIconElement.setAttribute("viewBox", "0 0 24 24");
            tweetEngagementIconElement.setAttribute("aria-hidden", "true");

            setTimeout(() => {
                tweetEngagementIconElement.setAttribute("class", tweetEngagementIcon());
            }, 0);

            tweetEngagementIconElement.innerHTML = `
                <g>
                  <path d="M8.75 21V3h2v18h-2zM18 21V8.5h2V21h-2zM4 21l.004-10h2L6 21H4zm9.248 0v-7h2v7h-2z" transform="scale(0.75) translate(4, 0)" />
                  <path d="M1.751 10c0-4.42 3.584-8 8.005-8h4.366c4.49 0 8.129 3.64 8.129 8.13 0 2.96-1.607 5.68-4.196 7.11l-8.054 4.46v-3.69h-.067c-4.49.1-8.183-3.51-8.183-8.01zm8.005-6c-3.317 0-6.005 2.69-6.005 6 0 3.37 2.77 6.08 6.138 6.01l.351-.01h1.761v2.3l5.087-2.81c1.951-1.08 3.163-3.13 3.163-5.36 0-3.39-2.744-6.13-6.129-6.13H9.756z" />
                </g>
            `;

            // 图标背景元素
            const tweetEngagementBgDiv = document.createElement("div");
            tweetEngagementBgDiv.className =
                "css-175oi2r r-xoduu5 r-1p0dtai r-1d2f490 r-u8s1d r-zchlnj r-ipm5af r-1niwhzg r-sdzlij r-xf4iuw r-o7ynqc r-6416eg r-1ny4l3l";

            tweetEngagementIconDiv.appendChild(tweetEngagementBgDiv);
            tweetEngagementIconDiv.appendChild(tweetEngagementIconElement);

            // 悬停效果
            tweetEngagementIconDiv.style.textOverflow = "unset";
            tweetEngagementIconDiv.style.color = "rgb(113, 118, 123)";

            const tweetEngagementIconBgDiv =
                tweetEngagementIconDiv.querySelector("div");

            // 悬停时变为金色
            tweetEngagementButton.addEventListener("mouseenter", () => {
                tweetEngagementIconDiv.style.color = "rgb(238 201 104)";
                if (tweetEngagementIconBgDiv) {
                    tweetEngagementIconBgDiv.style.backgroundColor =
                        "rgba(238, 201, 104, 0.1)";
                }
            });

            tweetEngagementButton.addEventListener("mouseleave", () => {
                tweetEngagementIconDiv.style.color = "rgb(113, 118, 123)";
                if (tweetEngagementIconBgDiv) {
                    tweetEngagementIconBgDiv.style.backgroundColor = "";
                }
            });

            tweetEngagementButton.appendChild(tweetEngagementIconDiv);

            return tweetEngagementButton;
        },

        addQuoteElement: function () {
            const tweetEngagementTargetDivs = document.querySelectorAll(
                'div[role="group"][id^="id__"]'
            );
            tweetEngagementTargetDivs.forEach((targetDiv) => {
                if (targetDiv.querySelector('[data-testid="tweetEngagements"]')) {
                    return;
                }

                // 获取推文 ID
                let article = targetDiv.closest("article");
                let tweetId = null;

                if (article) {
                    const tweetEngagementStatusLink = article.querySelector(
                        'a[href*="/status/"]'
                    );
                    if (tweetEngagementStatusLink) {
                        let tweetEngagementHref =
                            tweetEngagementStatusLink.getAttribute("href");

                        if (tweetEngagementHref) {
                            // 去掉 /photo/1 后缀
                            tweetEngagementHref = tweetEngagementHref.replace("/photo/1", "");

                            tweetId = tweetEngagementHref;
                        }
                    }
                }

                const tweetEngagementQuoteButton = this.createQuoteButton(tweetId);
                targetDiv.insertBefore(
                    tweetEngagementQuoteButton,
                    targetDiv.children[4]
                );
                // [修复] 图标类选择器可能失效导致按钮 0 宽度,立即应用内联尺寸兜底
                this.fixQuoteIconSize(tweetEngagementQuoteButton);
            });
        },

        fixQuoteIconSize: function (btn) {
            try {
                const svg = btn.querySelector('svg');
                if (!svg) return;
                let px = 20; // 时间线/回复:1.25rem
                let n = btn.parentElement;
                while (n) {
                    if (n.classList.contains("r-1oszu61") && n.classList.contains("r-1igl3o0")) {
                        px = 24; // 详情页主贴:1.5rem
                        break;
                    }
                    n = n.parentElement;
                }
                svg.style.width = px + 'px';
                svg.style.height = px + 'px';
                // 图标容器垂直居中(基础类可能失效导致图标贴顶)
                const iconDiv = svg.parentElement;
                if (iconDiv) {
                    iconDiv.style.display = 'flex';
                    iconDiv.style.alignItems = 'center';
                    iconDiv.style.justifyContent = 'center';
                    iconDiv.style.height = '100%';
                }
                if (!svg.getAttribute("class") || !svg.getAttribute("class").includes("r-1xvli5t")) {
                    svg.setAttribute("class", "r-4qtqp9 r-yyyyoo r-dnmrzs r-bnwqim r-lrvibr r-m6rgpd r-1xvli5t r-1hdv0qi");
                }
            } catch (e) { /* 忽略 */ }
        },

        init: function () {
            const debouncedAddQuoteElement = Utils.debounce(
                () => this.addQuoteElement(),
                250
            );

            // 监听 URL 变化(SPA 路由切换时重新注入)
            let lastUrl = location.href;
            const urlObserver = new MutationObserver(() => {
                const url = location.href;
                if (url !== lastUrl) {
                    lastUrl = url;
                    debouncedAddQuoteElement();
                }
            });

            urlObserver.observe(document, { subtree: true, childList: true });

            const timeline = document.querySelector(
                "div[data-testid='primaryColumn']"
            );
            if (timeline) {
                new MutationObserver(() => {
                    debouncedAddQuoteElement();
                }).observe(timeline, { childList: true, subtree: true });
            }

            const retryInterval = setInterval(debouncedAddQuoteElement, 1000);

            window.addEventListener("popstate", debouncedAddQuoteElement);

            history.pushState = ((origPushState) => {
                return function (state, title, url) {
                    origPushState.apply(this, arguments);
                    debouncedAddQuoteElement();
                };
            })(history.pushState);

            history.replaceState = ((origReplaceState) => {
                return function (state, title, url) {
                    origReplaceState.apply(this, arguments);
                    debouncedAddQuoteElement();
                };
            })(history.replaceState);

            // DOMContentLoaded 时初始化
            document.addEventListener("DOMContentLoaded", () => {
                debouncedAddQuoteElement();
                clearInterval(retryInterval);
            });

            debouncedAddQuoteElement();
        },
    };

    // -----------------------------------------------------------------------------------
    // 界面美化主流程
    // -----------------------------------------------------------------------------------
    function interfaceMain() {
        loadConfig();
        SettingsModule.createSettingsUI();
        setupKeyboardShortcut();
        setupMenuCommand();

        // 定时更新时间戳
        setInterval(() => TimestampModule.updateTimestamps(), 1000);

        // 初始化图标替换
        replaceTwitterIcons();

        // 初始化侧边栏时钟/日期
        SidebarModule.init();

        // 监听视频播放器
        VideoModule.observeVideos();

        // 初始化引用推文入口
        TweetEngagementModule.init();
    }

    // 页面加载完成后执行界面美化主流程
    window.addEventListener("load", interfaceMain);

    // =====================================================================
    // 模块3:媒体下载
    // =====================================================================

    const CLASS_NAME = 'x-batch-downloader';
    const BAR_CLASS = 'x-post-downloader';
    // 单图按钮图标:描边风格(原始样式)
    const SVG_ICON = `
        <svg viewBox="0 0 24 24" style="width: 100%; height: 100%; display: block;">
            <path d="M3,14 v5 q0,2 2,2 h14 q2,0 2,-2 v-5 M7,10 l4,4 q1,1 2,0 l4,-4 M12,3 v11"
                  fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" />
        </svg>
    `;
    // 整帖按钮图标:填充风格(与 X 原生图标一致)
    const BAR_SVG_ICON = `
        <svg viewBox="0 0 24 24" style="width: 100%; height: 100%; display: block;">
            <path d="M19 9h-4V3H9v6H5l7 7 7-7zM5 18v2h14v-2H5z" fill="currentColor" />
        </svg>
    `;

    const style = document.createElement('style');
    style.innerHTML = `
        .${CLASS_NAME} {
            position: absolute !important;
            top: 6px !important;
            left: 6px !important;
            z-index: 2147483647 !important;

            display: flex !important;
            align-items: center !important;
            justify-content: center !important;
            width: 28px !important;
            height: 28px !important;
            padding: 5px !important;
            box-sizing: border-box !important;
            border-radius: 4px !important;

            background-color: rgba(0, 0, 0, 0.6) !important;
            border: 1px solid rgba(255, 255, 255, 0.3) !important;
            color: #ffffff !important;

            cursor: pointer !important;
            pointer-events: auto !important;
            transition: transform 0.2s !important;
        }

        /* [修改] 单图按钮固定位于图片左上角 */

        .${CLASS_NAME}:hover {
            background-color: rgba(29, 161, 242, 0.9) !important;
            transform: scale(1.1);
        }

        /* [修改] 帖子"下载全部媒体"按钮:位于转发栏(操作栏)内,
           样式与栏内元素一致:无边框、无底色、灰色图标、悬停变蓝圆形高亮。
           align-self: center 保证中心与左侧图标对齐;尺寸由 matchBarButtonSize 适配 */
        .${BAR_CLASS} {
            display: flex !important;
            align-items: center !important;
            justify-content: center !important;
            align-self: center !important;
            width: 36px;
            height: 36px;
            box-sizing: border-box !important;
            border-radius: 9999px !important;
            color: rgb(113, 118, 123) !important;
            cursor: pointer !important;
            transition: background-color 0.2s, color 0.2s !important;
        }
        .${BAR_CLASS}:hover {
            background-color: rgba(29, 161, 242, 0.1) !important;
            color: rgb(29, 161, 242) !important;
        }

        .x-batch-loading {
            opacity: 0.7;
            animation: x-spin 1s linear infinite;
        }
        @keyframes x-spin { 100% { transform: rotate(360deg); } }
    `;
    appendStyleOnce(style);

    let scanTimer = null;
    function scheduleScan() {
        if (scanTimer) return;
        scanTimer = setTimeout(() => { scanTimer = null; globalScan(); }, 300);
    }

    function globalScan() {
        document.querySelectorAll('video').forEach(video => {
            const container = video.closest('div[data-testid="videoComponent"]') ||
                              video.closest('div[data-testid="videoPlayer"]') ||
                              video.parentNode;
            injectButton(container);
        });

        document.querySelectorAll('img[src*="format"]').forEach(img => {
            if (img.src.includes('/profile_images/') || img.src.includes('emoji')) return;

            let container = img.closest('div[data-testid="tweetPhoto"]');

            if (!container) {
                const link = img.closest('a[href*="/status/"]');
                if (link) container = img.parentNode;
            }

            if (!container && img.naturalWidth > 50) container = img.parentNode;

            if (container) injectButton(container);
        });

        // [新增] 在每个帖子的操作栏(转发栏)注入"下载全部媒体"按钮
        document.querySelectorAll('article').forEach(article => {
            injectBarButton(article);
        });
    }

    function injectButton(container) {
        if (!container || container.querySelector(`.${CLASS_NAME}`)) return;

        const rect = container.getBoundingClientRect();
        if (rect.width < 50 || rect.height < 50) return;

        const computedStyle = window.getComputedStyle(container);
        if (computedStyle.position === 'static') container.style.position = 'relative';

        const btn = document.createElement('div');
        btn.className = CLASS_NAME;
        btn.innerHTML = SVG_ICON;
        btn.title = 'Download This Media';

        btn.onclick = (e) => {
            e.preventDefault();
            e.stopPropagation();
            startDownload(btn, container);
        };

        container.appendChild(btn);
    }

    // 判断元素是否位于被引用推文(quote tweet)内
    function isQuoteMedia(el) {
        return !!(el && el.closest && el.closest('[data-testid="tweetQuote"], [data-testid="quoteTweet"]'));
    }

    // [修改] 帖子自身是否包含媒体(图片/视频,排除头像、表情、引用推文里的媒体)
    function hasOwnMedia(article) {
        if (!article) return false;
        const hasVideo = [...article.querySelectorAll('video')].some(v => !isQuoteMedia(v));
        if (hasVideo) return true;
        return [...article.querySelectorAll('img[src*="format"]')].some(im => {
            if (isQuoteMedia(im)) return false;
            if (im.src.includes('/profile_images/') || im.src.includes('emoji')) return false;
            try { return new URL(im.src).pathname.includes('/media/'); } catch { return false; }
        });
    }

    // [修改] 让整帖按钮与转发栏原生元素尺寸一致,且中心与左侧图标精确对齐
    // 参照"原生圆形高亮区"(图标背景圆,正方形):时间线 35px、详情页主贴 39px,
    // 图标按原生 svg 尺寸(19px / 23px)。垂直位置直接以原生图标中心为基准计算。
    function matchBarButtonSize(btn, bar) {
        try {
            const ref = bar.querySelector('[data-testid="reply"], [data-testid="like"]');
            if (!ref) return;
            // 原生圆形高亮区:按钮 > 行容器(div[dir]) > 图标包裹 > 背景圆
            const row = ref.querySelector('div[dir="ltr"]');
            const iconWrap = row && row.children[0];
            const circleDiv = iconWrap && iconWrap.children[0];
            let d = 0;
            if (circleDiv) {
                const c = circleDiv.getBoundingClientRect();
                d = Math.round(Math.max(c.width, c.height));
            }
            const refSvg = ref.querySelector('svg');
            let iconSize = 0;
            if (refSvg) {
                const s = refSvg.getBoundingClientRect();
                iconSize = Math.round(Math.max(s.width, s.height));
            }
            // 渐进渲染时可能测到 0 尺寸,此时用兜底值,避免注入过小/异常按钮
            if (d < 20) d = iconSize > 0 ? iconSize + 16 : 36;
            if (iconSize < 8) iconSize = 20;
            // [修改] 图标在原生尺寸基础上放大 1px,视觉更均衡
            iconSize += 1;

            btn.style.width = d + 'px';
            btn.style.height = d + 'px';
            // [修改] 按钮整体左移 1px
            btn.style.transform = 'translateX(-1px)';
            const svg = btn.querySelector('svg');
            if (svg) {
                svg.style.width = iconSize + 'px';
                svg.style.height = iconSize + 'px';
                // [修改] 图标下移 1px,视觉更均衡
                svg.style.transform = 'translateY(1px)';
            }
            // 垂直居中由 CSS 的 align-self: center 保证(与原生图标同处一行中心)
        } catch (e) { /* 测量失败则使用 CSS 兜底尺寸 */ }
    }

    // [修改] 帖子"下载全部媒体"按钮:位于转发栏(操作栏)内,样式与栏内元素一致
    // 仅当帖子自身有媒体时才注入;主页面直接替换"分析"按钮,其他页面追加到栏尾
    function injectBarButton(article) {
        if (!article || article.querySelector(`.${BAR_CLASS}`)) return;

        if (!hasOwnMedia(article)) return;

        // 操作栏锚点:转发/回复/点赞任一存在即可(兼容不同页面/布局变体)
        // 注意:data-testid 挂在 <button> 上(不能限定 div)
        const barAnchor = article.querySelector('[data-testid="retweet"]') ||
                          article.querySelector('[data-testid="reply"]') ||
                          article.querySelector('[data-testid="like"]');
        if (!barAnchor) return;

        const bar = barAnchor.closest('[role="group"]') || barAnchor.parentElement;
        if (!bar) return;

        const btn = document.createElement('div');
        btn.className = BAR_CLASS;
        btn.innerHTML = BAR_SVG_ICON;
        btn.title = 'Download All Media in Post';
        matchBarButtonSize(btn, bar);

        btn.onclick = (e) => {
            e.preventDefault();
            e.stopPropagation();
            const svg = btn.querySelector('svg');
            if (svg) svg.classList.add('x-batch-loading');
            startDownloadAll(article).finally(() => {
                if (svg) svg.classList.remove('x-batch-loading');
            });
        };

        // [新增] 主页面(首页时间线):用下载按钮替换操作栏里的"分析"按钮(views/analytics 链接)
        const mainPage = window.location.pathname === '/home' || window.location.pathname === '/';
        if (mainPage) {
            const analytics = article.querySelector('a[href*="/analytics"]');
            if (analytics) {
                analytics.replaceWith(btn);
                return;
            }
        }

        bar.appendChild(btn);
    }

    function isMediaPage() {
        const path = window.location.pathname;
        return path.endsWith('/media') || path.includes('/media/');
    }

    setInterval(globalScan, 1500);

    // [新增] SPA 路由切换后立即重新扫描(如进入 status 详情页),
    // 不依赖 MutationObserver 的触发时机,保证按钮尽快注入
    let lastHref = location.href;
    setInterval(() => {
        if (location.href !== lastHref) {
            lastHref = location.href;
            globalScan();
        }
    }, 800);
    const observer = new MutationObserver(() => scheduleScan());
    if (document.body) observer.observe(document.body, { childList: true, subtree: true });

    // [修改] 提取帖子的身份信息(推文ID、用户名),单图和整帖下载共用
    function getPostContext(container) {
        let statusId = 'unknown';
        let userName = 'twitter';

        const article = container.closest('article');
        const link = container.closest('a[href*="/status/"]');

        if (article) {
            // 优先取属于本推文的媒体链接(/photo/ 或 /video/),避免取到"正在回复"的父推文链接
            const statusLinks = article.querySelectorAll('a[href*="/status/"]');
            let idLink = null;
            for (const a of statusLinks) {
                const h = a.getAttribute('href') || '';
                if (h.includes('/photo/') || h.includes('/video/')) { idLink = a; break; }
            }
            if (!idLink && statusLinks.length) idLink = statusLinks[0];
            if (idLink) statusId = idLink.href.split('/status/').pop().split('/')[0];

            const userEl = article.querySelector('div[data-testid="User-Name"] a');
            if (userEl) userName = userEl.getAttribute('href').replace('/', '');
        } else if (link) {
            const parts = link.href.split('/');
            const statusIndex = parts.indexOf('status');
            if (statusIndex > -1) {
                statusId = parts[statusIndex + 1];
                userName = parts[statusIndex - 1];
            }
        }

        // URL 兜底
        if (statusId === 'unknown' || userName === 'twitter') {
            const path = window.location.pathname;
            const parts = path.split('/');
            const statusIndex = parts.indexOf('status');
            if (statusIndex > -1 && parts[statusIndex + 1]) {
                userName = parts[statusIndex - 1];
                statusId = parts[statusIndex + 1];
            }
        }

        return { statusId, userName, article, link };
    }

    async function startDownload(btn, container) {
        const svg = btn.querySelector('svg');
        svg.classList.add('x-batch-loading');

        try {
            const { statusId, userName, article, link } = getPostContext(container);

            // [修改] 与整帖按钮保持一致:API 缓存是权威数据源(媒体类型/时长完整),
            // 优先使用;其次按 article → container → link 走 fiber,最后 DOM 兜底
            let mediaList = null;
            let mediaSource = 'none';
            if (mediaCache.has(String(statusId))) {
                const cached = parseMedia(mediaCache.get(String(statusId)));
                if (cached && cached.length > 0) {
                    mediaList = cached;
                    mediaSource = 'cache';
                }
            }
            if (!mediaList || mediaList.length === 0) {
                mediaList = getFullTweetMedia(article, false) ||
                            getFullTweetMedia(container, true) ||
                            getFullTweetMedia(link, true);
                if (mediaList && mediaList.length) mediaSource = 'fiber';
            }
            if (!mediaList || mediaList.length === 0) {
                mediaList = tryExtractFromDOM(container);
                if (mediaList && mediaList.length) mediaSource = 'dom';
            }
            console.info('[X Media] 单媒体下载: 数据源=' + mediaSource + ', statusId=' + statusId +
                ', 条目=' + JSON.stringify((mediaList || []).map(m => ({ ext: m.ext, duration: m.duration, mediaType: m.mediaType }))));

            if (mediaList && mediaList.length > 0) {
                if (isMediaPage()) {
                    // [修改] 媒体Grid页面:网格瓦片没有操作栏,点按钮 -> 下载该帖子的所有图片/视频
                    // 若 fiber/DOM 只拿到瓦片封面单图,用 API 缓存补全整帖媒体
                    if (mediaCache.has(String(statusId))) {
                        const cached = parseMedia(mediaCache.get(String(statusId)));
                        if (cached && cached.length >= mediaList.length) {
                            mediaList = cached;
                        }
                    }
                    const uniqueList = mediaList.filter((v, i, a) => a.findIndex(t => (t.url === v.url)) === i);
                    await downloadBatchFallback(uniqueList, statusId, userName);
                } else {
                    // 其他页面:点击单张图片/视频的按钮 -> 仅下载该媒体
                    const targetMedia = filterMediaForContainer(mediaList, container);

                    if (targetMedia) {
                        let finalIndex = mediaList.indexOf(targetMedia) + 1;
                        if (finalIndex === 0) finalIndex = 1;

                        // Photo Viewer 索引修复
                        if (window.location.pathname.includes('/photo/')) {
                            const pathParts = window.location.pathname.split('/');
                            const photoIdx = pathParts.indexOf('photo');
                            if (photoIdx > -1 && pathParts[photoIdx + 1]) {
                                const urlIndex = parseInt(pathParts[photoIdx + 1], 10);
                                if (!isNaN(urlIndex)) finalIndex = urlIndex;
                            }
                        }

                        const fileName = `twitter_${userName}_${statusId}_${finalIndex}.${targetMedia.ext}`;
                        console.info('[X Media] 单媒体下载目标: ext=' + targetMedia.ext +
                            ', duration=' + (targetMedia.duration || 0) + 'ms, url=' + targetMedia.url);
                        await downloadMedia(targetMedia, fileName);
                    } else {
                        // 匹配失败兜底:下载该帖子的全部媒体
                        const uniqueList = mediaList.filter((v, i, a) => a.findIndex(t => (t.url === v.url)) === i);
                        await downloadBatchFallback(uniqueList, statusId, userName);
                    }
                }
            } else {
                alert('No media found.');
            }

        } catch (err) {
            console.error(err);
            alert('Error: ' + err.message);
        } finally {
            svg.classList.remove('x-batch-loading');
        }
    }

    // [新增] 操作栏按钮:下载该帖子的所有图片/视频
    async function startDownloadAll(article) {
        try {
            const { statusId, userName } = getPostContext(article);

            let mediaList = getFullTweetMedia(article, false);

            if (!mediaList || mediaList.length === 0) {
                mediaList = tryExtractFromDOM(article);
            }

            if (mediaList && mediaList.length > 0) {
                // 若 fiber/DOM 不完整,用 API 缓存补全
                if (mediaCache.has(String(statusId))) {
                    const cached = parseMedia(mediaCache.get(String(statusId)));
                    if (cached && cached.length >= mediaList.length) {
                        mediaList = cached;
                    }
                }
                const uniqueList = mediaList.filter((v, i, a) => a.findIndex(t => (t.url === v.url)) === i);
                await downloadBatchFallback(uniqueList, statusId, userName);
            } else {
                alert('No media found.');
            }
        } catch (err) {
            console.error(err);
            alert('Error: ' + err.message);
        }
    }

    function filterMediaForContainer(mediaList, container) {
        const img = container.querySelector('img[src*="format"]');
        const video = container.querySelector('video');

        let targetId = null;

        if (img) {
            const src = img.src;
            const parts = src.split('/media/');
            if (parts.length > 1) {
                targetId = parts[1].split('?')[0].split('.')[0];
            } else if (src.includes('/tweet_video_thumb/')) {
                // [新增] GIF/视频封面图路径
                targetId = src.split('/tweet_video_thumb/')[1].split('?')[0].split('.')[0];
            }
        }
        if (!targetId && video) {
            const poster = video.poster || '';
            if (poster.includes('/media/')) {
                targetId = poster.split('/media/')[1].split('.')[0];
            } else if (poster.includes('/tweet_video_thumb/')) {
                targetId = poster.split('/tweet_video_thumb/')[1].split('.')[0];
            } else {
                // [新增] 用视频地址本身匹配(GIF 视频形如 .../tweet_video/<id>.mp4)
                const src = video.currentSrc || video.src || '';
                if (src.startsWith('http')) {
                    const mm = src.match(/tweet_video\/([^/?]+)/);
                    if (mm) targetId = mm[1];
                }
            }
        }

        if (targetId) {
            return mediaList.find(m => m.url.includes(targetId) || (m.poster && m.poster.includes(targetId)));
        }
        return null;
    }

    // [修改] 增加向下遍历:从 article 出发时,tweet 数据可能在子 fiber 上
    function getFullTweetMedia(domNode, allowSingle) {
        if (!domNode) return null;
        const key = Object.keys(domNode).find(k => k.startsWith('__reactFiber$'));
        if (!key) return null;

        let fiber = domNode[key];
        let attempts = 0;
        let foundMedia = null;

        while (fiber && attempts < 40) {
            const full = findFullMedia(fiber.memoizedProps);
            if (full) return full;

            // 单图模式兜底:只取当前媒体自身(allowSingle=false 时避免误抓其他帖子的媒体)
            if (allowSingle && !foundMedia && fiber.memoizedProps?.media?.media_url_https) {
                foundMedia = parseMedia([fiber.memoizedProps.media]);
            }

            fiber = fiber.return;
            attempts++;
        }

        const down = findFullMediaInChildren(domNode[key], 20, 500);
        if (down) return down;

        return foundMedia;
    }

    function findFullMedia(props) {
        if (!props) return null;
        if (props.tweet?.extended_entities?.media) return parseMedia(props.tweet.extended_entities.media);
        if (props.data?.tweet?.extended_entities?.media) return parseMedia(props.data.tweet.extended_entities.media);
        if (props.item?.content?.tweet?.extended_entities?.media) return parseMedia(props.item.content.tweet.extended_entities.media);
        if (props.source?.tweet?.extended_entities?.media) return parseMedia(props.source.tweet.extended_entities.media);
        // [新增] 新版 React 树形结构:entry.content.itemContent.tweet_results.result.legacy
        const entryResult = props.entry?.content?.itemContent?.tweet_results?.result;
        if (entryResult?.legacy?.extended_entities?.media) return parseMedia(entryResult.legacy.extended_entities.media);
        const itemResult = props.item?.content?.tweet_results?.result;
        if (itemResult?.legacy?.extended_entities?.media) return parseMedia(itemResult.legacy.extended_entities.media);
        return null;
    }

    function findFullMediaInChildren(root, maxDepth, budget) {
        let count = 0;
        const stack = [{ f: root, d: 0 }];
        while (stack.length && count < budget) {
            const { f, d } = stack.pop();
            if (!f || d > maxDepth) continue;
            count++;
            const full = findFullMedia(f.memoizedProps);
            if (full) return full;
            if (d < maxDepth) {
                if (f.sibling) stack.push({ f: f.sibling, d });
                if (f.child) stack.push({ f: f.child, d: d + 1 });
            }
        }
        return null;
    }

    function parseMedia(mediaArray) {
        return mediaArray.map(media => {
            if (media.type === 'photo') {
                return {
                    url: media.media_url_https + ':orig',
                    ext: 'jpg',
                    poster: media.media_url_https
                };
            } else if (media.type === 'video' || media.type === 'animated_gif') {
                const variants = media.video_info.variants
                    .filter(n => n.content_type === 'video/mp4')
                    .sort((a, b) => (b.bitrate || 0) - (a.bitrate || 0));

                if (variants.length > 0) {
                    const duration = (media.video_info && media.video_info.duration_millis) || 0;
                    // [修改] animated_gif 本身就是 GIF(X 会转成 mp4 存储,且 API 不返回时长字段),
                    // 一律恢复为 GIF;普通视频按"时长 < 10 秒"判定(设置面板可关闭)
                    const asGif = config.gifShortVideos && (
                        media.type === 'animated_gif' ? true : (duration > 0 && duration < 10000)
                    );
                    return {
                        url: variants[0].url,
                        ext: asGif ? 'gif' : 'mp4',
                        duration,
                        mediaType: media.type,
                        poster: media.media_url_https
                    };
                }
            }
            return null;
        }).filter(Boolean);
    }

    function tryExtractFromDOM(container) {
        const results = [];
        const seen = new Set();

        // 扩大扫描范围到整个帖子(时间线上的多图拼贴包含该帖子的全部图片)
        const root = container.closest('article') || container;

        const push = (url, ext, duration) => {
            if (seen.has(url)) return;
            seen.add(url);
            results.push({ url, ext, duration: duration || 0 });
        };

        root.querySelectorAll('img[src*="format"]').forEach(img => {
            if (img.src.includes('/profile_images/') || img.src.includes('emoji')) return;
            // 跳过被引用推文(quote tweet)里的媒体
            if (img.closest('[data-testid="tweetQuote"], [data-testid="quoteTweet"]')) return;

            const u = new URL(img.src);
            if (u.pathname.includes('/media/')) {
                const format = u.searchParams.get('format') || 'jpg';
                push(`${u.origin}${u.pathname}?format=${format}&name=orig`, format);
            }
        });
        root.querySelectorAll('video').forEach(v => {
            // [修改] 优先 currentSrc(播放中 src 可能被替换为 blob:),并读取页面
            // <video> 已加载的 duration,让 DOM 兜底路径也能按 <10s 判定转 GIF
            const src = (v.currentSrc || v.src || '');
            if (!src.startsWith('http')) return;
            const duration = (isFinite(v.duration) && v.duration > 0) ? Math.round(v.duration * 1000) : 0;
            const asGif = config.gifShortVideos && duration > 0 && duration < 10000;
            push(src, asGif ? 'gif' : 'mp4', duration);
        });
        return results;
    }

    async function downloadBatchFallback(list, id, user) {
        for (let i = 0; i < list.length; i++) {
            const item = list[i];
            const name = `twitter_${user}_${id}_${i+1}.${item.ext}`;
            await downloadMedia(item, name);
            // 浏览器对连续多次下载有限制,间隔一下避免被拦截
            if (i < list.length - 1) await new Promise(r => setTimeout(r, 300));
        }
    }

    function saveBlob(blob, filename) {
        return new Promise((resolve, reject) => {
            try {
                const u = URL.createObjectURL(blob);
                const a = document.createElement('a');
                a.href = u; a.download = filename;
                document.body.appendChild(a); a.click(); document.body.removeChild(a);
                setTimeout(() => URL.revokeObjectURL(u), 1000);
                resolve();
            } catch (err) { reject(err); }
        });
    }

    function gmGetBlob(url) {
        return new Promise((resolve, reject) => {
            GM_xmlhttpRequest({
                method: "GET", url, responseType: "blob", timeout: 300000,
                onload: res => {
                    if (res.status >= 200 && res.status < 300) resolve(res.response);
                    else reject(new Error('HTTP ' + res.status));
                },
                onerror: () => reject(new Error('Network error')),
                ontimeout: () => reject(new Error('Download timeout'))
            });
        });
    }

    function downloadAsBlob(url, filename) {
        return gmGetBlob(url).then(blob => saveBlob(blob, filename));
    }

    // =====================================================================
    // 模块3.5:短视频(<10 秒)转 GIF
    // 视频先以 blob 下载,再经 <video> + <canvas> 抽帧,最后用 gif.js
    // (从 CDN 动态加载)编码为 GIF。优先使用 Worker 后台编码;
    // 若页面 CSP 禁止 blob Worker,则回退到主线程"假 Worker"。
    // 任何一步失败都会回退为保存原始 MP4。
    // =====================================================================
    const GIF_MAX_WIDTH = 320;    // GIF 最长边(px)
    const GIF_TARGET_FRAMES = 90; // 目标总帧数
    const GIF_MIN_FPS = 4;
    const GIF_MAX_FPS = 12;
    const GIF_JS_CDNS = [
        'https://cdn.jsdelivr.net/npm/[email protected]/dist/',
        'https://unpkg.com/[email protected]/dist/',
        'https://cdnjs.cloudflare.com/ajax/libs/gif.js/0.2.0/'
    ];

    const SANDBOX_GLOBAL = (typeof globalThis !== 'undefined') ? globalThis : (function(){ return this; })();

    // ---- 轻量进度提示条 ----
    let gifToastEl = null;
    function showGifToast(msg) {
        if (!gifToastEl) {
            gifToastEl = document.createElement('div');
            Object.assign(gifToastEl.style, {
                position: 'fixed', left: '50%', bottom: '24px',
                transform: 'translateX(-50%)', zIndex: '2147483647',
                background: 'rgba(15, 20, 25, 0.92)', color: '#ffffff',
                padding: '10px 16px', borderRadius: '8px',
                font: '13px/1.4 Arial, sans-serif', pointerEvents: 'none',
                maxWidth: '80vw', boxShadow: '0 4px 12px rgba(0,0,0,0.3)',
                display: 'none'
            });
            document.body.appendChild(gifToastEl);
        }
        gifToastEl.textContent = msg;
        gifToastEl.style.display = 'block';
    }
    function hideGifToast() {
        if (gifToastEl) gifToastEl.style.display = 'none';
    }

    // ---- GM_xmlhttpRequest 文本请求(加载 gif.js 源码) ----
    function gmGetText(url) {
        return new Promise((resolve, reject) => {
            GM_xmlhttpRequest({
                method: 'GET', url, responseType: 'text', timeout: 30000,
                onload: res => {
                    if (res.status >= 200 && res.status < 300) resolve(res.responseText);
                    else reject(new Error('HTTP ' + res.status));
                },
                onerror: () => reject(new Error('Network error: ' + url)),
                ontimeout: () => reject(new Error('Timeout: ' + url))
            });
        });
    }

    // =====================================================================
    // gif.js 主库与编码器模块(内嵌源码,规避页面 CSP 禁止 eval 的问题)
    // 所有内嵌代码都在非严格 IIFE 中运行,与原库行为一致。
    // =====================================================================

    // 通过局部 module/exports 让 UMD 走 CommonJS 分支,确定性捕获导出
    var XMEDIA_GIF_CLASS = (function () {
        var XMEDIA_MODULE = { exports: null };
        var exports = XMEDIA_MODULE.exports;
        var module = XMEDIA_MODULE;
        var define;
        /* ===== gif.js 0.2.0 (dist/gif.js) 内嵌开始 ===== */
// gif.js 0.2.0 - https://github.com/jnordberg/gif.js
(function(f){if(typeof exports==="object"&&typeof module!=="undefined"){module.exports=f()}else if(typeof define==="function"&&define.amd){define([],f)}else{var g;if(typeof window!=="undefined"){g=window}else if(typeof global!=="undefined"){g=global}else if(typeof self!=="undefined"){g=self}else{g=this}g.GIF=f()}})(function(){var define,module,exports;return function e(t,n,r){function s(o,u){if(!n[o]){if(!t[o]){var a=typeof require=="function"&&require;if(!u&&a)return a(o,!0);if(i)return i(o,!0);var f=new Error("Cannot find module '"+o+"'");throw f.code="MODULE_NOT_FOUND",f}var l=n[o]={exports:{}};t[o][0].call(l.exports,function(e){var n=t[o][1][e];return s(n?n:e)},l,l.exports,e,t,n,r)}return n[o].exports}var i=typeof require=="function"&&require;for(var o=0;o<r.length;o++)s(r[o]);return s}({1:[function(require,module,exports){function EventEmitter(){this._events=this._events||{};this._maxListeners=this._maxListeners||undefined}module.exports=EventEmitter;EventEmitter.EventEmitter=EventEmitter;EventEmitter.prototype._events=undefined;EventEmitter.prototype._maxListeners=undefined;EventEmitter.defaultMaxListeners=10;EventEmitter.prototype.setMaxListeners=function(n){if(!isNumber(n)||n<0||isNaN(n))throw TypeError("n must be a positive number");this._maxListeners=n;return this};EventEmitter.prototype.emit=function(type){var er,handler,len,args,i,listeners;if(!this._events)this._events={};if(type==="error"){if(!this._events.error||isObject(this._events.error)&&!this._events.error.length){er=arguments[1];if(er instanceof Error){throw er}else{var err=new Error('Uncaught, unspecified "error" event. ('+er+")");err.context=er;throw err}}}handler=this._events[type];if(isUndefined(handler))return false;if(isFunction(handler)){switch(arguments.length){case 1:handler.call(this);break;case 2:handler.call(this,arguments[1]);break;case 3:handler.call(this,arguments[1],arguments[2]);break;default:args=Array.prototype.slice.call(arguments,1);handler.apply(this,args)}}else if(isObject(handler)){args=Array.prototype.slice.call(arguments,1);listeners=handler.slice();len=listeners.length;for(i=0;i<len;i++)listeners[i].apply(this,args)}return true};EventEmitter.prototype.addListener=function(type,listener){var m;if(!isFunction(listener))throw TypeError("listener must be a function");if(!this._events)this._events={};if(this._events.newListener)this.emit("newListener",type,isFunction(listener.listener)?listener.listener:listener);if(!this._events[type])this._events[type]=listener;else if(isObject(this._events[type]))this._events[type].push(listener);else this._events[type]=[this._events[type],listener];if(isObject(this._events[type])&&!this._events[type].warned){if(!isUndefined(this._maxListeners)){m=this._maxListeners}else{m=EventEmitter.defaultMaxListeners}if(m&&m>0&&this._events[type].length>m){this._events[type].warned=true;console.error("(node) warning: possible EventEmitter memory "+"leak detected. %d listeners added. "+"Use emitter.setMaxListeners() to increase limit.",this._events[type].length);if(typeof console.trace==="function"){console.trace()}}}return this};EventEmitter.prototype.on=EventEmitter.prototype.addListener;EventEmitter.prototype.once=function(type,listener){if(!isFunction(listener))throw TypeError("listener must be a function");var fired=false;function g(){this.removeListener(type,g);if(!fired){fired=true;listener.apply(this,arguments)}}g.listener=listener;this.on(type,g);return this};EventEmitter.prototype.removeListener=function(type,listener){var list,position,length,i;if(!isFunction(listener))throw TypeError("listener must be a function");if(!this._events||!this._events[type])return this;list=this._events[type];length=list.length;position=-1;if(list===listener||isFunction(list.listener)&&list.listener===listener){delete this._events[type];if(this._events.removeListener)this.emit("removeListener",type,listener)}else if(isObject(list)){for(i=length;i-- >0;){if(list[i]===listener||list[i].listener&&list[i].listener===listener){position=i;break}}if(position<0)return this;if(list.length===1){list.length=0;delete this._events[type]}else{list.splice(position,1)}if(this._events.removeListener)this.emit("removeListener",type,listener)}return this};EventEmitter.prototype.removeAllListeners=function(type){var key,listeners;if(!this._events)return this;if(!this._events.removeListener){if(arguments.length===0)this._events={};else if(this._events[type])delete this._events[type];return this}if(arguments.length===0){for(key in this._events){if(key==="removeListener")continue;this.removeAllListeners(key)}this.removeAllListeners("removeListener");this._events={};return this}listeners=this._events[type];if(isFunction(listeners)){this.removeListener(type,listeners)}else if(listeners){while(listeners.length)this.removeListener(type,listeners[listeners.length-1])}delete this._events[type];return this};EventEmitter.prototype.listeners=function(type){var ret;if(!this._events||!this._events[type])ret=[];else if(isFunction(this._events[type]))ret=[this._events[type]];else ret=this._events[type].slice();return ret};EventEmitter.prototype.listenerCount=function(type){if(this._events){var evlistener=this._events[type];if(isFunction(evlistener))return 1;else if(evlistener)return evlistener.length}return 0};EventEmitter.listenerCount=function(emitter,type){return emitter.listenerCount(type)};function isFunction(arg){return typeof arg==="function"}function isNumber(arg){return typeof arg==="number"}function isObject(arg){return typeof arg==="object"&&arg!==null}function isUndefined(arg){return arg===void 0}},{}],2:[function(require,module,exports){var UA,browser,mode,platform,ua;ua=navigator.userAgent.toLowerCase();platform=navigator.platform.toLowerCase();UA=ua.match(/(opera|ie|firefox|chrome|version)[\s\/:]([\w\d\.]+)?.*?(safari|version[\s\/:]([\w\d\.]+)|$)/)||[null,"unknown",0];mode=UA[1]==="ie"&&document.documentMode;browser={name:UA[1]==="version"?UA[3]:UA[1],version:mode||parseFloat(UA[1]==="opera"&&UA[4]?UA[4]:UA[2]),platform:{name:ua.match(/ip(?:ad|od|hone)/)?"ios":(ua.match(/(?:webos|android)/)||platform.match(/mac|win|linux/)||["other"])[0]}};browser[browser.name]=true;browser[browser.name+parseInt(browser.version,10)]=true;browser.platform[browser.platform.name]=true;module.exports=browser},{}],3:[function(require,module,exports){var EventEmitter,GIF,browser,extend=function(child,parent){for(var key in parent){if(hasProp.call(parent,key))child[key]=parent[key]}function ctor(){this.constructor=child}ctor.prototype=parent.prototype;child.prototype=new ctor;child.__super__=parent.prototype;return child},hasProp={}.hasOwnProperty,indexOf=[].indexOf||function(item){for(var i=0,l=this.length;i<l;i++){if(i in this&&this[i]===item)return i}return-1},slice=[].slice;EventEmitter=require("events").EventEmitter;browser=require("./browser.coffee");GIF=function(superClass){var defaults,frameDefaults;extend(GIF,superClass);defaults={workerScript:"gif.worker.js",workers:2,repeat:0,background:"#fff",quality:10,width:null,height:null,transparent:null,debug:false,dither:false};frameDefaults={delay:500,copy:false};function GIF(options){var base,key,value;this.running=false;this.options={};this.frames=[];this.freeWorkers=[];this.activeWorkers=[];this.setOptions(options);for(key in defaults){value=defaults[key];if((base=this.options)[key]==null){base[key]=value}}}GIF.prototype.setOption=function(key,value){this.options[key]=value;if(this._canvas!=null&&(key==="width"||key==="height")){return this._canvas[key]=value}};GIF.prototype.setOptions=function(options){var key,results,value;results=[];for(key in options){if(!hasProp.call(options,key))continue;value=options[key];results.push(this.setOption(key,value))}return results};GIF.prototype.addFrame=function(image,options){var frame,key;if(options==null){options={}}frame={};frame.transparent=this.options.transparent;for(key in frameDefaults){frame[key]=options[key]||frameDefaults[key]}if(this.options.width==null){this.setOption("width",image.width)}if(this.options.height==null){this.setOption("height",image.height)}if(typeof ImageData!=="undefined"&&ImageData!==null&&image instanceof ImageData){frame.data=image.data}else if(typeof CanvasRenderingContext2D!=="undefined"&&CanvasRenderingContext2D!==null&&image instanceof CanvasRenderingContext2D||typeof WebGLRenderingContext!=="undefined"&&WebGLRenderingContext!==null&&image instanceof WebGLRenderingContext){if(options.copy){frame.data=this.getContextData(image)}else{frame.context=image}}else if(image.childNodes!=null){if(options.copy){frame.data=this.getImageData(image)}else{frame.image=image}}else{throw new Error("Invalid image")}return this.frames.push(frame)};GIF.prototype.render=function(){var i,j,numWorkers,ref;if(this.running){throw new Error("Already running")}if(this.options.width==null||this.options.height==null){throw new Error("Width and height must be set prior to rendering")}this.running=true;this.nextFrame=0;this.finishedFrames=0;this.imageParts=function(){var j,ref,results;results=[];for(i=j=0,ref=this.frames.length;0<=ref?j<ref:j>ref;i=0<=ref?++j:--j){results.push(null)}return results}.call(this);numWorkers=this.spawnWorkers();if(this.options.globalPalette===true){this.renderNextFrame()}else{for(i=j=0,ref=numWorkers;0<=ref?j<ref:j>ref;i=0<=ref?++j:--j){this.renderNextFrame()}}this.emit("start");return this.emit("progress",0)};GIF.prototype.abort=function(){var worker;while(true){worker=this.activeWorkers.shift();if(worker==null){break}this.log("killing active worker");worker.terminate()}this.running=false;return this.emit("abort")};GIF.prototype.spawnWorkers=function(){var j,numWorkers,ref,results;numWorkers=Math.min(this.options.workers,this.frames.length);(function(){results=[];for(var j=ref=this.freeWorkers.length;ref<=numWorkers?j<numWorkers:j>numWorkers;ref<=numWorkers?j++:j--){results.push(j)}return results}).apply(this).forEach(function(_this){return function(i){var worker;_this.log("spawning worker "+i);worker=new Worker(_this.options.workerScript);worker.onmessage=function(event){_this.activeWorkers.splice(_this.activeWorkers.indexOf(worker),1);_this.freeWorkers.push(worker);return _this.frameFinished(event.data)};return _this.freeWorkers.push(worker)}}(this));return numWorkers};GIF.prototype.frameFinished=function(frame){var i,j,ref;this.log("frame "+frame.index+" finished - "+this.activeWorkers.length+" active");this.finishedFrames++;this.emit("progress",this.finishedFrames/this.frames.length);this.imageParts[frame.index]=frame;if(this.options.globalPalette===true){this.options.globalPalette=frame.globalPalette;this.log("global palette analyzed");if(this.frames.length>2){for(i=j=1,ref=this.freeWorkers.length;1<=ref?j<ref:j>ref;i=1<=ref?++j:--j){this.renderNextFrame()}}}if(indexOf.call(this.imageParts,null)>=0){return this.renderNextFrame()}else{return this.finishRendering()}};GIF.prototype.finishRendering=function(){var data,frame,i,image,j,k,l,len,len1,len2,len3,offset,page,ref,ref1,ref2;len=0;ref=this.imageParts;for(j=0,len1=ref.length;j<len1;j++){frame=ref[j];len+=(frame.data.length-1)*frame.pageSize+frame.cursor}len+=frame.pageSize-frame.cursor;this.log("rendering finished - filesize "+Math.round(len/1e3)+"kb");data=new Uint8Array(len);offset=0;ref1=this.imageParts;for(k=0,len2=ref1.length;k<len2;k++){frame=ref1[k];ref2=frame.data;for(i=l=0,len3=ref2.length;l<len3;i=++l){page=ref2[i];data.set(page,offset);if(i===frame.data.length-1){offset+=frame.cursor}else{offset+=frame.pageSize}}}image=new Blob([data],{type:"image/gif"});return this.emit("finished",image,data)};GIF.prototype.renderNextFrame=function(){var frame,task,worker;if(this.freeWorkers.length===0){throw new Error("No free workers")}if(this.nextFrame>=this.frames.length){return}frame=this.frames[this.nextFrame++];worker=this.freeWorkers.shift();task=this.getTask(frame);this.log("starting frame "+(task.index+1)+" of "+this.frames.length);this.activeWorkers.push(worker);return worker.postMessage(task)};GIF.prototype.getContextData=function(ctx){return ctx.getImageData(0,0,this.options.width,this.options.height).data};GIF.prototype.getImageData=function(image){var ctx;if(this._canvas==null){this._canvas=document.createElement("canvas");this._canvas.width=this.options.width;this._canvas.height=this.options.height}ctx=this._canvas.getContext("2d");ctx.setFill=this.options.background;ctx.fillRect(0,0,this.options.width,this.options.height);ctx.drawImage(image,0,0);return this.getContextData(ctx)};GIF.prototype.getTask=function(frame){var index,task;index=this.frames.indexOf(frame);task={index:index,last:index===this.frames.length-1,delay:frame.delay,transparent:frame.transparent,width:this.options.width,height:this.options.height,quality:this.options.quality,dither:this.options.dither,globalPalette:this.options.globalPalette,repeat:this.options.repeat,canTransfer:browser.name==="chrome"};if(frame.data!=null){task.data=frame.data}else if(frame.context!=null){task.data=this.getContextData(frame.context)}else if(frame.image!=null){task.data=this.getImageData(frame.image)}else{throw new Error("Invalid frame")}return task};GIF.prototype.log=function(){var args;args=1<=arguments.length?slice.call(arguments,0):[];if(!this.options.debug){return}return console.log.apply(console,args)};return GIF}(EventEmitter);module.exports=GIF},{"./browser.coffee":2,events:1}]},{},[3])(3)});
//# sourceMappingURL=gif.js.map

        /* ===== gif.js 0.2.0 内嵌结束 ===== */
        return XMEDIA_MODULE.exports;
    })();
    XMEDIA_GIF_CLASS = XMEDIA_GIF_CLASS || (typeof window !== 'undefined' && window.GIF) || SANDBOX_GLOBAL.GIF || null;

    // 编码器模块(src/GIFEncoder.js 等,去除 require/module.exports 后内嵌)
    var XMediaEncoder = (function () {
        /* ===== TypedNeuQuant.js ===== */
/* NeuQuant Neural-Net Quantization Algorithm
 * ------------------------------------------
 *
 * Copyright (c) 1994 Anthony Dekker
 *
 * NEUQUANT Neural-Net quantization algorithm by Anthony Dekker, 1994.
 * See "Kohonen neural networks for optimal colour quantization"
 * in "Network: Computation in Neural Systems" Vol. 5 (1994) pp 351-367.
 * for a discussion of the algorithm.
 * See also  http://members.ozemail.com.au/~dekker/NEUQUANT.HTML
 *
 * Any party obtaining a copy of these files from the author, directly or
 * indirectly, is granted, free of charge, a full and unrestricted irrevocable,
 * world-wide, paid up, royalty-free, nonexclusive right and license to deal
 * in this software and documentation files (the "Software"), including without
 * limitation the rights to use, copy, modify, merge, publish, distribute, sublicense,
 * and/or sell copies of the Software, and to permit persons who receive
 * copies from any such party to do so, with the only requirement being
 * that this copyright notice remain intact.
 *
 * (JavaScript port 2012 by Johan Nordberg)
 */

var ncycles = 100; // number of learning cycles
var netsize = 256; // number of colors used
var maxnetpos = netsize - 1;

// defs for freq and bias
var netbiasshift = 4; // bias for colour values
var intbiasshift = 16; // bias for fractions
var intbias = (1 << intbiasshift);
var gammashift = 10;
var gamma = (1 << gammashift);
var betashift = 10;
var beta = (intbias >> betashift); /* beta = 1/1024 */
var betagamma = (intbias << (gammashift - betashift));

// defs for decreasing radius factor
var initrad = (netsize >> 3); // for 256 cols, radius starts
var radiusbiasshift = 6; // at 32.0 biased by 6 bits
var radiusbias = (1 << radiusbiasshift);
var initradius = (initrad * radiusbias); //and decreases by a
var radiusdec = 30; // factor of 1/30 each cycle

// defs for decreasing alpha factor
var alphabiasshift = 10; // alpha starts at 1.0
var initalpha = (1 << alphabiasshift);
var alphadec; // biased by 10 bits

/* radbias and alpharadbias used for radpower calculation */
var radbiasshift = 8;
var radbias = (1 << radbiasshift);
var alpharadbshift = (alphabiasshift + radbiasshift);
var alpharadbias = (1 << alpharadbshift);

// four primes near 500 - assume no image has a length so large that it is
// divisible by all four primes
var prime1 = 499;
var prime2 = 491;
var prime3 = 487;
var prime4 = 503;
var minpicturebytes = (3 * prime4);

/*
  Constructor: NeuQuant

  Arguments:

  pixels - array of pixels in RGB format
  samplefac - sampling factor 1 to 30 where lower is better quality

  >
  > pixels = [r, g, b, r, g, b, r, g, b, ..]
  >
*/
function NeuQuant(pixels, samplefac) {
  var network; // int[netsize][4]
  var netindex; // for network lookup - really 256

  // bias and freq arrays for learning
  var bias;
  var freq;
  var radpower;

  /*
    Private Method: init

    sets up arrays
  */
  function init() {
    network = [];
    netindex = new Int32Array(256);
    bias = new Int32Array(netsize);
    freq = new Int32Array(netsize);
    radpower = new Int32Array(netsize >> 3);

    var i, v;
    for (i = 0; i < netsize; i++) {
      v = (i << (netbiasshift + 8)) / netsize;
      network[i] = new Float64Array([v, v, v, 0]);
      //network[i] = [v, v, v, 0]
      freq[i] = intbias / netsize;
      bias[i] = 0;
    }
  }

  /*
    Private Method: unbiasnet

    unbiases network to give byte values 0..255 and record position i to prepare for sort
  */
  function unbiasnet() {
    for (var i = 0; i < netsize; i++) {
      network[i][0] >>= netbiasshift;
      network[i][1] >>= netbiasshift;
      network[i][2] >>= netbiasshift;
      network[i][3] = i; // record color number
    }
  }

  /*
    Private Method: altersingle

    moves neuron *i* towards biased (b,g,r) by factor *alpha*
  */
  function altersingle(alpha, i, b, g, r) {
    network[i][0] -= (alpha * (network[i][0] - b)) / initalpha;
    network[i][1] -= (alpha * (network[i][1] - g)) / initalpha;
    network[i][2] -= (alpha * (network[i][2] - r)) / initalpha;
  }

  /*
    Private Method: alterneigh

    moves neurons in *radius* around index *i* towards biased (b,g,r) by factor *alpha*
  */
  function alterneigh(radius, i, b, g, r) {
    var lo = Math.abs(i - radius);
    var hi = Math.min(i + radius, netsize);

    var j = i + 1;
    var k = i - 1;
    var m = 1;

    var p, a;
    while ((j < hi) || (k > lo)) {
      a = radpower[m++];

      if (j < hi) {
        p = network[j++];
        p[0] -= (a * (p[0] - b)) / alpharadbias;
        p[1] -= (a * (p[1] - g)) / alpharadbias;
        p[2] -= (a * (p[2] - r)) / alpharadbias;
      }

      if (k > lo) {
        p = network[k--];
        p[0] -= (a * (p[0] - b)) / alpharadbias;
        p[1] -= (a * (p[1] - g)) / alpharadbias;
        p[2] -= (a * (p[2] - r)) / alpharadbias;
      }
    }
  }

  /*
    Private Method: contest

    searches for biased BGR values
  */
  function contest(b, g, r) {
    /*
      finds closest neuron (min dist) and updates freq
      finds best neuron (min dist-bias) and returns position
      for frequently chosen neurons, freq[i] is high and bias[i] is negative
      bias[i] = gamma * ((1 / netsize) - freq[i])
    */

    var bestd = ~(1 << 31);
    var bestbiasd = bestd;
    var bestpos = -1;
    var bestbiaspos = bestpos;

    var i, n, dist, biasdist, betafreq;
    for (i = 0; i < netsize; i++) {
      n = network[i];

      dist = Math.abs(n[0] - b) + Math.abs(n[1] - g) + Math.abs(n[2] - r);
      if (dist < bestd) {
        bestd = dist;
        bestpos = i;
      }

      biasdist = dist - ((bias[i]) >> (intbiasshift - netbiasshift));
      if (biasdist < bestbiasd) {
        bestbiasd = biasdist;
        bestbiaspos = i;
      }

      betafreq = (freq[i] >> betashift);
      freq[i] -= betafreq;
      bias[i] += (betafreq << gammashift);
    }

    freq[bestpos] += beta;
    bias[bestpos] -= betagamma;

    return bestbiaspos;
  }

  /*
    Private Method: inxbuild

    sorts network and builds netindex[0..255]
  */
  function inxbuild() {
    var i, j, p, q, smallpos, smallval, previouscol = 0, startpos = 0;
    for (i = 0; i < netsize; i++) {
      p = network[i];
      smallpos = i;
      smallval = p[1]; // index on g
      // find smallest in i..netsize-1
      for (j = i + 1; j < netsize; j++) {
        q = network[j];
        if (q[1] < smallval) { // index on g
          smallpos = j;
          smallval = q[1]; // index on g
        }
      }
      q = network[smallpos];
      // swap p (i) and q (smallpos) entries
      if (i != smallpos) {
        j = q[0];   q[0] = p[0];   p[0] = j;
        j = q[1];   q[1] = p[1];   p[1] = j;
        j = q[2];   q[2] = p[2];   p[2] = j;
        j = q[3];   q[3] = p[3];   p[3] = j;
      }
      // smallval entry is now in position i

      if (smallval != previouscol) {
        netindex[previouscol] = (startpos + i) >> 1;
        for (j = previouscol + 1; j < smallval; j++)
          netindex[j] = i;
        previouscol = smallval;
        startpos = i;
      }
    }
    netindex[previouscol] = (startpos + maxnetpos) >> 1;
    for (j = previouscol + 1; j < 256; j++)
      netindex[j] = maxnetpos; // really 256
  }

  /*
    Private Method: inxsearch

    searches for BGR values 0..255 and returns a color index
  */
  function inxsearch(b, g, r) {
    var a, p, dist;

    var bestd = 1000; // biggest possible dist is 256*3
    var best = -1;

    var i = netindex[g]; // index on g
    var j = i - 1; // start at netindex[g] and work outwards

    while ((i < netsize) || (j >= 0)) {
      if (i < netsize) {
        p = network[i];
        dist = p[1] - g; // inx key
        if (dist >= bestd) i = netsize; // stop iter
        else {
          i++;
          if (dist < 0) dist = -dist;
          a = p[0] - b; if (a < 0) a = -a;
          dist += a;
          if (dist < bestd) {
            a = p[2] - r; if (a < 0) a = -a;
            dist += a;
            if (dist < bestd) {
              bestd = dist;
              best = p[3];
            }
          }
        }
      }
      if (j >= 0) {
        p = network[j];
        dist = g - p[1]; // inx key - reverse dif
        if (dist >= bestd) j = -1; // stop iter
        else {
          j--;
          if (dist < 0) dist = -dist;
          a = p[0] - b; if (a < 0) a = -a;
          dist += a;
          if (dist < bestd) {
            a = p[2] - r; if (a < 0) a = -a;
            dist += a;
            if (dist < bestd) {
              bestd = dist;
              best = p[3];
            }
          }
        }
      }
    }

    return best;
  }

  /*
    Private Method: learn

    "Main Learning Loop"
  */
  function learn() {
    var i;

    var lengthcount = pixels.length;
    var alphadec = 30 + ((samplefac - 1) / 3);
    var samplepixels = lengthcount / (3 * samplefac);
    var delta = ~~(samplepixels / ncycles);
    var alpha = initalpha;
    var radius = initradius;

    var rad = radius >> radiusbiasshift;

    if (rad <= 1) rad = 0;
    for (i = 0; i < rad; i++)
      radpower[i] = alpha * (((rad * rad - i * i) * radbias) / (rad * rad));

    var step;
    if (lengthcount < minpicturebytes) {
      samplefac = 1;
      step = 3;
    } else if ((lengthcount % prime1) !== 0) {
      step = 3 * prime1;
    } else if ((lengthcount % prime2) !== 0) {
      step = 3 * prime2;
    } else if ((lengthcount % prime3) !== 0)  {
      step = 3 * prime3;
    } else {
      step = 3 * prime4;
    }

    var b, g, r, j;
    var pix = 0; // current pixel

    i = 0;
    while (i < samplepixels) {
      b = (pixels[pix] & 0xff) << netbiasshift;
      g = (pixels[pix + 1] & 0xff) << netbiasshift;
      r = (pixels[pix + 2] & 0xff) << netbiasshift;

      j = contest(b, g, r);

      altersingle(alpha, j, b, g, r);
      if (rad !== 0) alterneigh(rad, j, b, g, r); // alter neighbours

      pix += step;
      if (pix >= lengthcount) pix -= lengthcount;

      i++;

      if (delta === 0) delta = 1;
      if (i % delta === 0) {
        alpha -= alpha / alphadec;
        radius -= radius / radiusdec;
        rad = radius >> radiusbiasshift;

        if (rad <= 1) rad = 0;
        for (j = 0; j < rad; j++)
          radpower[j] = alpha * (((rad * rad - j * j) * radbias) / (rad * rad));
      }
    }
  }

  /*
    Method: buildColormap

    1. initializes network
    2. trains it
    3. removes misconceptions
    4. builds colorindex
  */
  function buildColormap() {
    init();
    learn();
    unbiasnet();
    inxbuild();
  }
  this.buildColormap = buildColormap;

  /*
    Method: getColormap

    builds colormap from the index

    returns array in the format:

    >
    > [r, g, b, r, g, b, r, g, b, ..]
    >
  */
  function getColormap() {
    var map = [];
    var index = [];

    for (var i = 0; i < netsize; i++)
      index[network[i][3]] = i;

    var k = 0;
    for (var l = 0; l < netsize; l++) {
      var j = index[l];
      map[k++] = (network[j][0]);
      map[k++] = (network[j][1]);
      map[k++] = (network[j][2]);
    }
    return map;
  }
  this.getColormap = getColormap;

  /*
    Method: lookupRGB

    looks for the closest *r*, *g*, *b* color in the map and
    returns its index
  */
  this.lookupRGB = inxsearch;
}

        /* ===== LZWEncoder.js ===== */
/*
  LZWEncoder.js

  Authors
  Kevin Weiner (original Java version - [email protected])
  Thibault Imbert (AS3 version - bytearray.org)
  Johan Nordberg (JS version - [email protected])

  Acknowledgements
  GIFCOMPR.C - GIF Image compression routines
  Lempel-Ziv compression based on 'compress'. GIF modifications by
  David Rowley ([email protected])
  GIF Image compression - modified 'compress'
  Based on: compress.c - File compression ala IEEE Computer, June 1984.
  By Authors: Spencer W. Thomas (decvax!harpo!utah-cs!utah-gr!thomas)
  Jim McKie (decvax!mcvax!jim)
  Steve Davies (decvax!vax135!petsd!peora!srd)
  Ken Turkowski (decvax!decwrl!turtlevax!ken)
  James A. Woods (decvax!ihnp4!ames!jaw)
  Joe Orost (decvax!vax135!petsd!joe)
*/

var EOF = -1;
var BITS = 12;
var HSIZE = 5003; // 80% occupancy
var masks = [0x0000, 0x0001, 0x0003, 0x0007, 0x000F, 0x001F,
             0x003F, 0x007F, 0x00FF, 0x01FF, 0x03FF, 0x07FF,
             0x0FFF, 0x1FFF, 0x3FFF, 0x7FFF, 0xFFFF];

function LZWEncoder(width, height, pixels, colorDepth) {
  var initCodeSize = Math.max(2, colorDepth);

  var accum = new Uint8Array(256);
  var htab = new Int32Array(HSIZE);
  var codetab = new Int32Array(HSIZE);

  var cur_accum, cur_bits = 0;
  var a_count;
  var free_ent = 0; // first unused entry
  var maxcode;

  // block compression parameters -- after all codes are used up,
  // and compression rate changes, start over.
  var clear_flg = false;

  // Algorithm: use open addressing double hashing (no chaining) on the
  // prefix code / next character combination. We do a variant of Knuth's
  // algorithm D (vol. 3, sec. 6.4) along with G. Knott's relatively-prime
  // secondary probe. Here, the modular division first probe is gives way
  // to a faster exclusive-or manipulation. Also do block compression with
  // an adaptive reset, whereby the code table is cleared when the compression
  // ratio decreases, but after the table fills. The variable-length output
  // codes are re-sized at this point, and a special CLEAR code is generated
  // for the decompressor. Late addition: construct the table according to
  // file size for noticeable speed improvement on small files. Please direct
  // questions about this implementation to ames!jaw.
  var g_init_bits, ClearCode, EOFCode;

  // Add a character to the end of the current packet, and if it is 254
  // characters, flush the packet to disk.
  function char_out(c, outs) {
    accum[a_count++] = c;
    if (a_count >= 254) flush_char(outs);
  }

  // Clear out the hash table
  // table clear for block compress
  function cl_block(outs) {
    cl_hash(HSIZE);
    free_ent = ClearCode + 2;
    clear_flg = true;
    output(ClearCode, outs);
  }

  // Reset code table
  function cl_hash(hsize) {
    for (var i = 0; i < hsize; ++i) htab[i] = -1;
  }

  function compress(init_bits, outs) {
    var fcode, c, i, ent, disp, hsize_reg, hshift;

    // Set up the globals: g_init_bits - initial number of bits
    g_init_bits = init_bits;

    // Set up the necessary values
    clear_flg = false;
    n_bits = g_init_bits;
    maxcode = MAXCODE(n_bits);

    ClearCode = 1 << (init_bits - 1);
    EOFCode = ClearCode + 1;
    free_ent = ClearCode + 2;

    a_count = 0; // clear packet

    ent = nextPixel();

    hshift = 0;
    for (fcode = HSIZE; fcode < 65536; fcode *= 2) ++hshift;
    hshift = 8 - hshift; // set hash code range bound
    hsize_reg = HSIZE;
    cl_hash(hsize_reg); // clear hash table

    output(ClearCode, outs);

    outer_loop: while ((c = nextPixel()) != EOF) {
      fcode = (c << BITS) + ent;
      i = (c << hshift) ^ ent; // xor hashing
      if (htab[i] === fcode) {
        ent = codetab[i];
        continue;
      } else if (htab[i] >= 0) { // non-empty slot
        disp = hsize_reg - i; // secondary hash (after G. Knott)
        if (i === 0) disp = 1;
        do {
          if ((i -= disp) < 0) i += hsize_reg;
          if (htab[i] === fcode) {
            ent = codetab[i];
            continue outer_loop;
          }
        } while (htab[i] >= 0);
      }
      output(ent, outs);
      ent = c;
      if (free_ent < 1 << BITS) {
        codetab[i] = free_ent++; // code -> hashtable
        htab[i] = fcode;
      } else {
        cl_block(outs);
      }
    }

    // Put out the final code.
    output(ent, outs);
    output(EOFCode, outs);
  }

  function encode(outs) {
    outs.writeByte(initCodeSize); // write "initial code size" byte
    remaining = width * height; // reset navigation variables
    curPixel = 0;
    compress(initCodeSize + 1, outs); // compress and write the pixel data
    outs.writeByte(0); // write block terminator
  }

  // Flush the packet to disk, and reset the accumulator
  function flush_char(outs) {
    if (a_count > 0) {
      outs.writeByte(a_count);
      outs.writeBytes(accum, 0, a_count);
      a_count = 0;
    }
  }

  function MAXCODE(n_bits) {
    return (1 << n_bits) - 1;
  }

  // Return the next pixel from the image
  function nextPixel() {
    if (remaining === 0) return EOF;
    --remaining;
    var pix = pixels[curPixel++];
    return pix & 0xff;
  }

  function output(code, outs) {
    cur_accum &= masks[cur_bits];

    if (cur_bits > 0) cur_accum |= (code << cur_bits);
    else cur_accum = code;

    cur_bits += n_bits;

    while (cur_bits >= 8) {
      char_out((cur_accum & 0xff), outs);
      cur_accum >>= 8;
      cur_bits -= 8;
    }

    // If the next entry is going to be too big for the code size,
    // then increase it, if possible.
    if (free_ent > maxcode || clear_flg) {
      if (clear_flg) {
        maxcode = MAXCODE(n_bits = g_init_bits);
        clear_flg = false;
      } else {
        ++n_bits;
        if (n_bits == BITS) maxcode = 1 << BITS;
        else maxcode = MAXCODE(n_bits);
      }
    }

    if (code == EOFCode) {
      // At EOF, write the rest of the buffer.
      while (cur_bits > 0) {
        char_out((cur_accum & 0xff), outs);
        cur_accum >>= 8;
        cur_bits -= 8;
      }
      flush_char(outs);
    }
  }

  this.encode = encode;
}

        /* ===== GIFEncoder.js ===== */
/*
  GIFEncoder.js

  Authors
  Kevin Weiner (original Java version - [email protected])
  Thibault Imbert (AS3 version - bytearray.org)
  Johan Nordberg (JS version - [email protected])
*/

function ByteArray() {
  this.page = -1;
  this.pages = [];
  this.newPage();
}

ByteArray.pageSize = 4096;
ByteArray.charMap = {};

for (var i = 0; i < 256; i++)
  ByteArray.charMap[i] = String.fromCharCode(i);

ByteArray.prototype.newPage = function() {
  this.pages[++this.page] = new Uint8Array(ByteArray.pageSize);
  this.cursor = 0;
};

ByteArray.prototype.getData = function() {
  var rv = '';
  for (var p = 0; p < this.pages.length; p++) {
    for (var i = 0; i < ByteArray.pageSize; i++) {
      rv += ByteArray.charMap[this.pages[p][i]];
    }
  }
  return rv;
};

ByteArray.prototype.writeByte = function(val) {
  if (this.cursor >= ByteArray.pageSize) this.newPage();
  this.pages[this.page][this.cursor++] = val;
};

ByteArray.prototype.writeUTFBytes = function(string) {
  for (var l = string.length, i = 0; i < l; i++)
    this.writeByte(string.charCodeAt(i));
};

ByteArray.prototype.writeBytes = function(array, offset, length) {
  for (var l = length || array.length, i = offset || 0; i < l; i++)
    this.writeByte(array[i]);
};

function GIFEncoder(width, height) {
  // image size
  this.width = ~~width;
  this.height = ~~height;

  // transparent color if given
  this.transparent = null;

  // transparent index in color table
  this.transIndex = 0;

  // -1 = no repeat, 0 = forever. anything else is repeat count
  this.repeat = -1;

  // frame delay (hundredths)
  this.delay = 0;

  this.image = null; // current frame
  this.pixels = null; // BGR byte array from frame
  this.indexedPixels = null; // converted frame indexed to palette
  this.colorDepth = null; // number of bit planes
  this.colorTab = null; // RGB palette
  this.neuQuant = null; // NeuQuant instance that was used to generate this.colorTab.
  this.usedEntry = new Array(); // active palette entries
  this.palSize = 7; // color table size (bits-1)
  this.dispose = -1; // disposal code (-1 = use default)
  this.firstFrame = true;
  this.sample = 10; // default sample interval for quantizer
  this.dither = false; // default dithering
  this.globalPalette = false;

  this.out = new ByteArray();
}

/*
  Sets the delay time between each frame, or changes it for subsequent frames
  (applies to last frame added)
*/
GIFEncoder.prototype.setDelay = function(milliseconds) {
  this.delay = Math.round(milliseconds / 10);
};

/*
  Sets frame rate in frames per second.
*/
GIFEncoder.prototype.setFrameRate = function(fps) {
  this.delay = Math.round(100 / fps);
};

/*
  Sets the GIF frame disposal code for the last added frame and any
  subsequent frames.

  Default is 0 if no transparent color has been set, otherwise 2.
*/
GIFEncoder.prototype.setDispose = function(disposalCode) {
  if (disposalCode >= 0) this.dispose = disposalCode;
};

/*
  Sets the number of times the set of GIF frames should be played.

  -1 = play once
  0 = repeat indefinitely

  Default is -1

  Must be invoked before the first image is added
*/

GIFEncoder.prototype.setRepeat = function(repeat) {
  this.repeat = repeat;
};

/*
  Sets the transparent color for the last added frame and any subsequent
  frames. Since all colors are subject to modification in the quantization
  process, the color in the final palette for each frame closest to the given
  color becomes the transparent color for that frame. May be set to null to
  indicate no transparent color.
*/
GIFEncoder.prototype.setTransparent = function(color) {
  this.transparent = color;
};

/*
  Adds next GIF frame. The frame is not written immediately, but is
  actually deferred until the next frame is received so that timing
  data can be inserted.  Invoking finish() flushes all frames.
*/
GIFEncoder.prototype.addFrame = function(imageData) {
  this.image = imageData;

  this.colorTab = this.globalPalette && this.globalPalette.slice ? this.globalPalette : null;

  this.getImagePixels(); // convert to correct format if necessary
  this.analyzePixels(); // build color table & map pixels

  if (this.globalPalette === true) this.globalPalette = this.colorTab;

  if (this.firstFrame) {
    this.writeLSD(); // logical screen descriptior
    this.writePalette(); // global color table
    if (this.repeat >= 0) {
      // use NS app extension to indicate reps
      this.writeNetscapeExt();
    }
  }

  this.writeGraphicCtrlExt(); // write graphic control extension
  this.writeImageDesc(); // image descriptor
  if (!this.firstFrame && !this.globalPalette) this.writePalette(); // local color table
  this.writePixels(); // encode and write pixel data

  this.firstFrame = false;
};

/*
  Adds final trailer to the GIF stream, if you don't call the finish method
  the GIF stream will not be valid.
*/
GIFEncoder.prototype.finish = function() {
  this.out.writeByte(0x3b); // gif trailer
};

/*
  Sets quality of color quantization (conversion of images to the maximum 256
  colors allowed by the GIF specification). Lower values (minimum = 1)
  produce better colors, but slow processing significantly. 10 is the
  default, and produces good color mapping at reasonable speeds. Values
  greater than 20 do not yield significant improvements in speed.
*/
GIFEncoder.prototype.setQuality = function(quality) {
  if (quality < 1) quality = 1;
  this.sample = quality;
};

/*
  Sets dithering method. Available are:
  - FALSE no dithering
  - TRUE or FloydSteinberg
  - FalseFloydSteinberg
  - Stucki
  - Atkinson
  You can add '-serpentine' to use serpentine scanning
*/
GIFEncoder.prototype.setDither = function(dither) {
  if (dither === true) dither = 'FloydSteinberg';
  this.dither = dither;
};

/*
  Sets global palette for all frames.
  You can provide TRUE to create global palette from first picture.
  Or an array of r,g,b,r,g,b,...
*/
GIFEncoder.prototype.setGlobalPalette = function(palette) {
  this.globalPalette = palette;
};

/*
  Returns global palette used for all frames.
  If setGlobalPalette(true) was used, then this function will return
  calculated palette after the first frame is added.
*/
GIFEncoder.prototype.getGlobalPalette = function() {
  return (this.globalPalette && this.globalPalette.slice && this.globalPalette.slice(0)) || this.globalPalette;
};

/*
  Writes GIF file header
*/
GIFEncoder.prototype.writeHeader = function() {
  this.out.writeUTFBytes("GIF89a");
};

/*
  Analyzes current frame colors and creates color map.
*/
GIFEncoder.prototype.analyzePixels = function() {
  if (!this.colorTab) {
    this.neuQuant = new NeuQuant(this.pixels, this.sample);
    this.neuQuant.buildColormap(); // create reduced palette
    this.colorTab = this.neuQuant.getColormap();
  }

  // map image pixels to new palette
  if (this.dither) {
    this.ditherPixels(this.dither.replace('-serpentine', ''), this.dither.match(/-serpentine/) !== null);
  } else {
    this.indexPixels();
  }

  this.pixels = null;
  this.colorDepth = 8;
  this.palSize = 7;

  // get closest match to transparent color if specified
  if (this.transparent !== null) {
    this.transIndex = this.findClosest(this.transparent, true);
  }
};

/*
  Index pixels, without dithering
*/
GIFEncoder.prototype.indexPixels = function(imgq) {
  var nPix = this.pixels.length / 3;
  this.indexedPixels = new Uint8Array(nPix);
  var k = 0;
  for (var j = 0; j < nPix; j++) {
    var index = this.findClosestRGB(
      this.pixels[k++] & 0xff,
      this.pixels[k++] & 0xff,
      this.pixels[k++] & 0xff
    );
    this.usedEntry[index] = true;
    this.indexedPixels[j] = index;
  }
};

/*
  Taken from http://jsbin.com/iXofIji/2/edit by PAEz
*/
GIFEncoder.prototype.ditherPixels = function(kernel, serpentine) {
  var kernels = {
    FalseFloydSteinberg: [
      [3 / 8, 1, 0],
      [3 / 8, 0, 1],
      [2 / 8, 1, 1]
    ],
    FloydSteinberg: [
      [7 / 16, 1, 0],
      [3 / 16, -1, 1],
      [5 / 16, 0, 1],
      [1 / 16, 1, 1]
    ],
    Stucki: [
      [8 / 42, 1, 0],
      [4 / 42, 2, 0],
      [2 / 42, -2, 1],
      [4 / 42, -1, 1],
      [8 / 42, 0, 1],
      [4 / 42, 1, 1],
      [2 / 42, 2, 1],
      [1 / 42, -2, 2],
      [2 / 42, -1, 2],
      [4 / 42, 0, 2],
      [2 / 42, 1, 2],
      [1 / 42, 2, 2]
    ],
    Atkinson: [
      [1 / 8, 1, 0],
      [1 / 8, 2, 0],
      [1 / 8, -1, 1],
      [1 / 8, 0, 1],
      [1 / 8, 1, 1],
      [1 / 8, 0, 2]
    ]
  };

  if (!kernel || !kernels[kernel]) {
    throw 'Unknown dithering kernel: ' + kernel;
  }

  var ds = kernels[kernel];
  var index = 0,
    height = this.height,
    width = this.width,
    data = this.pixels;
  var direction = serpentine ? -1 : 1;

  this.indexedPixels = new Uint8Array(this.pixels.length / 3);

  for (var y = 0; y < height; y++) {

    if (serpentine) direction = direction * -1;

    for (var x = (direction == 1 ? 0 : width - 1), xend = (direction == 1 ? width : 0); x !== xend; x += direction) {

      index = (y * width) + x;
      // Get original colour
      var idx = index * 3;
      var r1 = data[idx];
      var g1 = data[idx + 1];
      var b1 = data[idx + 2];

      // Get converted colour
      idx = this.findClosestRGB(r1, g1, b1);
      this.usedEntry[idx] = true;
      this.indexedPixels[index] = idx;
      idx *= 3;
      var r2 = this.colorTab[idx];
      var g2 = this.colorTab[idx + 1];
      var b2 = this.colorTab[idx + 2];

      var er = r1 - r2;
      var eg = g1 - g2;
      var eb = b1 - b2;

      for (var i = (direction == 1 ? 0: ds.length - 1), end = (direction == 1 ? ds.length : 0); i !== end; i += direction) {
        var x1 = ds[i][1]; // *direction;  //  Should this by timesd by direction?..to make the kernel go in the opposite direction....got no idea....
        var y1 = ds[i][2];
        if (x1 + x >= 0 && x1 + x < width && y1 + y >= 0 && y1 + y < height) {
          var d = ds[i][0];
          idx = index + x1 + (y1 * width);
          idx *= 3;

          data[idx] = Math.max(0, Math.min(255, data[idx] + er * d));
          data[idx + 1] = Math.max(0, Math.min(255, data[idx + 1] + eg * d));
          data[idx + 2] = Math.max(0, Math.min(255, data[idx + 2] + eb * d));
        }
      }
    }
  }
};

/*
  Returns index of palette color closest to c
*/
GIFEncoder.prototype.findClosest = function(c, used) {
  return this.findClosestRGB((c & 0xFF0000) >> 16, (c & 0x00FF00) >> 8, (c & 0x0000FF), used);
};

GIFEncoder.prototype.findClosestRGB = function(r, g, b, used) {
  if (this.colorTab === null) return -1;

  if (this.neuQuant && !used) {
    return this.neuQuant.lookupRGB(r, g, b);
  }

  var c = b | (g << 8) | (r << 16);

  var minpos = 0;
  var dmin = 256 * 256 * 256;
  var len = this.colorTab.length;

  for (var i = 0, index = 0; i < len; index++) {
    var dr = r - (this.colorTab[i++] & 0xff);
    var dg = g - (this.colorTab[i++] & 0xff);
    var db = b - (this.colorTab[i++] & 0xff);
    var d = dr * dr + dg * dg + db * db;
    if ((!used || this.usedEntry[index]) && (d < dmin)) {
      dmin = d;
      minpos = index;
    }
  }

  return minpos;
};

/*
  Extracts image pixels into byte array pixels
  (removes alphachannel from canvas imagedata)
*/
GIFEncoder.prototype.getImagePixels = function() {
  var w = this.width;
  var h = this.height;
  this.pixels = new Uint8Array(w * h * 3);

  var data = this.image;
  var srcPos = 0;
  var count = 0;

  for (var i = 0; i < h; i++) {
    for (var j = 0; j < w; j++) {
      this.pixels[count++] = data[srcPos++];
      this.pixels[count++] = data[srcPos++];
      this.pixels[count++] = data[srcPos++];
      srcPos++;
    }
  }
};

/*
  Writes Graphic Control Extension
*/
GIFEncoder.prototype.writeGraphicCtrlExt = function() {
  this.out.writeByte(0x21); // extension introducer
  this.out.writeByte(0xf9); // GCE label
  this.out.writeByte(4); // data block size

  var transp, disp;
  if (this.transparent === null) {
    transp = 0;
    disp = 0; // dispose = no action
  } else {
    transp = 1;
    disp = 2; // force clear if using transparent color
  }

  if (this.dispose >= 0) {
    disp = dispose & 7; // user override
  }
  disp <<= 2;

  // packed fields
  this.out.writeByte(
    0 | // 1:3 reserved
    disp | // 4:6 disposal
    0 | // 7 user input - 0 = none
    transp // 8 transparency flag
  );

  this.writeShort(this.delay); // delay x 1/100 sec
  this.out.writeByte(this.transIndex); // transparent color index
  this.out.writeByte(0); // block terminator
};

/*
  Writes Image Descriptor
*/
GIFEncoder.prototype.writeImageDesc = function() {
  this.out.writeByte(0x2c); // image separator
  this.writeShort(0); // image position x,y = 0,0
  this.writeShort(0);
  this.writeShort(this.width); // image size
  this.writeShort(this.height);

  // packed fields
  if (this.firstFrame || this.globalPalette) {
    // no LCT - GCT is used for first (or only) frame
    this.out.writeByte(0);
  } else {
    // specify normal LCT
    this.out.writeByte(
      0x80 | // 1 local color table 1=yes
      0 | // 2 interlace - 0=no
      0 | // 3 sorted - 0=no
      0 | // 4-5 reserved
      this.palSize // 6-8 size of color table
    );
  }
};

/*
  Writes Logical Screen Descriptor
*/
GIFEncoder.prototype.writeLSD = function() {
  // logical screen size
  this.writeShort(this.width);
  this.writeShort(this.height);

  // packed fields
  this.out.writeByte(
    0x80 | // 1 : global color table flag = 1 (gct used)
    0x70 | // 2-4 : color resolution = 7
    0x00 | // 5 : gct sort flag = 0
    this.palSize // 6-8 : gct size
  );

  this.out.writeByte(0); // background color index
  this.out.writeByte(0); // pixel aspect ratio - assume 1:1
};

/*
  Writes Netscape application extension to define repeat count.
*/
GIFEncoder.prototype.writeNetscapeExt = function() {
  this.out.writeByte(0x21); // extension introducer
  this.out.writeByte(0xff); // app extension label
  this.out.writeByte(11); // block size
  this.out.writeUTFBytes('NETSCAPE2.0'); // app id + auth code
  this.out.writeByte(3); // sub-block size
  this.out.writeByte(1); // loop sub-block id
  this.writeShort(this.repeat); // loop count (extra iterations, 0=repeat forever)
  this.out.writeByte(0); // block terminator
};

/*
  Writes color table
*/
GIFEncoder.prototype.writePalette = function() {
  this.out.writeBytes(this.colorTab);
  var n = (3 * 256) - this.colorTab.length;
  for (var i = 0; i < n; i++)
    this.out.writeByte(0);
};

GIFEncoder.prototype.writeShort = function(pValue) {
  this.out.writeByte(pValue & 0xFF);
  this.out.writeByte((pValue >> 8) & 0xFF);
};

/*
  Encodes and writes pixel data
*/
GIFEncoder.prototype.writePixels = function() {
  var enc = new LZWEncoder(this.width, this.height, this.indexedPixels, this.colorDepth);
  enc.encode(this.out);
};

/*
  Retrieves the GIF stream
*/
GIFEncoder.prototype.stream = function() {
  return this.out;
};

        // 主线程版 renderFrame(与 gif.worker.js 的 renderFrame 逻辑一致)
        function renderFrameInMain(frame) {
            const encoder = new GIFEncoder(frame.width, frame.height);
            if (frame.index === 0) encoder.writeHeader();
            else encoder.firstFrame = false;
            encoder.setTransparent(frame.transparent);
            encoder.setRepeat(frame.repeat);
            encoder.setDelay(frame.delay);
            encoder.setQuality(frame.quality);
            encoder.setDither(frame.dither);
            encoder.setGlobalPalette(frame.globalPalette);
            encoder.addFrame(frame.data);
            if (frame.last) encoder.finish();
            if (frame.globalPalette === true) frame.globalPalette = encoder.getGlobalPalette();
            const stream = encoder.stream();
            frame.data = stream.pages;
            frame.cursor = stream.cursor;
            frame.pageSize = stream.constructor.pageSize;
            return frame;
        }
        return { GIFEncoder: GIFEncoder, renderFrameInMain: renderFrameInMain };
    })();

    // ---- gif.js 环境初始化(无 eval) ----
    let gifJsEnv = null; // { GIF, workerUrl, useRealWorker }
    async function ensureGifJs() {
        if (gifJsEnv) return gifJsEnv;

        const GIFClass = XMEDIA_GIF_CLASS;
        if (!GIFClass) throw new Error('gif.js 初始化失败');

        // gif.js 内部以裸标识符引用这些全局,沙箱缺失时从 window 补齐
        ['Worker', 'Blob', 'ImageData', 'CanvasRenderingContext2D', 'URL'].forEach(name => {
            if (typeof SANDBOX_GLOBAL[name] === 'undefined' && typeof window !== 'undefined' && window[name]) {
                SANDBOX_GLOBAL[name] = window[name];
            }
        });

        // 真实 Worker 编码器源码从 CDN 获取(仅作 Worker 脚本用,不经 eval);
        // 获取失败或页面 CSP 禁止 blob Worker 时,回退主线程"假 Worker"
        let workerScript = null;
        for (const base of GIF_JS_CDNS) {
            try { workerScript = await gmGetText(base + 'gif.worker.js'); break; }
            catch (e) { console.warn('[X Media] gif.worker.js 加载失败', base, e); }
        }
        let workerUrl = null, useRealWorker = false;
        if (workerScript) {
            workerUrl = URL.createObjectURL(new Blob([workerScript], { type: 'text/javascript' }));
            useRealWorker = await blobWorkerSupported();
        }
        if (!useRealWorker) {
            console.info('[X Media] blob Worker 不可用,改用主线程编码(编码期间页面会短暂卡顿)');
        }
        gifJsEnv = { GIF: GIFClass, workerUrl, useRealWorker };
        return gifJsEnv;
    }

    function blobWorkerSupported() {
        return new Promise((resolve) => {
            let done = false, w = null, testUrl = null;
            const finish = (ok) => {
                if (done) return;
                done = true;
                try { if (w) w.terminate(); } catch (e) {}
                try { if (testUrl) URL.revokeObjectURL(testUrl); } catch (e) {}
                resolve(ok);
            };
            try {
                testUrl = URL.createObjectURL(new Blob(['self.postMessage(1)'], { type: 'text/javascript' }));
                w = new Worker(testUrl);
                w.onmessage = () => finish(true);
                w.onerror = () => finish(false);
                setTimeout(() => finish(false), 2000);
            } catch (e) {
                finish(false);
            }
        });
    }

    // ---- 主线程"假 Worker":直接调用内嵌编码器(无 eval) ----
    function createFakeGifWorker() {
        const fake = {
            onmessage: null,
            _terminated: false,
            postMessage: function (task) {
                if (fake._terminated) return;
                setTimeout(() => {
                    if (fake._terminated) return;
                    try {
                        const result = XMediaEncoder.renderFrameInMain(task);
                        if (typeof fake.onmessage === 'function') fake.onmessage({ data: result });
                    } catch (e) {
                        console.warn('[X Media] 主线程编码帧失败:', e);
                    }
                }, 0);
            },
            terminate: function () { fake._terminated = true; }
        };
        return fake;
    }
    function encodeGif(env, frames, width, height, fps, onProgress) {
        const { GIF: GIFClass, workerUrl, useRealWorker } = env;
        return new Promise((resolve, reject) => {
            const RealWorker = SANDBOX_GLOBAL.Worker;
            let gif = null;
            const safetyTimer = setTimeout(() => {
                try { if (gif) gif.abort(); } catch (e) {}
                reject(new Error('GIF 编码超时'));
            }, 300000);
            try {
                if (!useRealWorker) {
                    // 仅替换沙箱域内的全局 Worker(不影响真实页面),render() 同步完成 spawn 后还原
                    SANDBOX_GLOBAL.Worker = function (url) {
                        return createFakeGifWorker();
                    };
                }
                gif = new GIFClass({
                    workers: 2,
                    quality: 10,
                    width: width,
                    height: height,
                    repeat: 0,
                    dither: false,
                    workerScript: workerUrl
                });
                for (const frame of frames) {
                    gif.addFrame(frame, { copy: true, delay: Math.round(1000 / fps) });
                }
                gif.on('progress', (p) => { if (onProgress) onProgress(p); });
                gif.on('finished', (blob) => { clearTimeout(safetyTimer); resolve(blob); });
                gif.render();
            } catch (err) {
                clearTimeout(safetyTimer);
                reject(err);
            } finally {
                if (!useRealWorker) {
                    try { SANDBOX_GLOBAL.Worker = RealWorker; } catch (e) {}
                }
            }
        });
    }

    // ---- 视频抽帧 ----
    function createGifVideo() {
        const video = document.createElement('video');
        video.muted = true;
        video.playsInline = true;
        video.preload = 'auto';
        return video;
    }

    function waitForVideoMeta(video) {
        return new Promise((resolve, reject) => {
            let settled = false;
            const onOk = () => { if (!settled) { settled = true; cleanup(); resolve(); } };
            const onErr = () => { if (!settled) { settled = true; cleanup(); reject(new Error('视频加载失败')); } };
            const cleanup = () => {
                video.removeEventListener('loadedmetadata', onOk);
                video.removeEventListener('error', onErr);
                clearTimeout(timer);
            };
            const timer = setTimeout(onErr, 30000);
            video.addEventListener('loadedmetadata', onOk);
            video.addEventListener('error', onErr);
        });
    }

    function seekVideo(video, t) {
        return new Promise((resolve) => {
            let settled = false;
            const done = () => {
                if (settled) return;
                settled = true;
                video.removeEventListener('seeked', onSeeked);
                clearTimeout(timer);
                resolve();
            };
            const onSeeked = () => done();
            const timer = setTimeout(done, 4000);
            video.addEventListener('seeked', onSeeked);
            try { video.currentTime = t; } catch (e) { done(); }
        });
    }

    async function extractVideoFrames(video, width, height, fps, maxFrames) {
        const duration = video.duration;
        const canvas = document.createElement('canvas');
        canvas.width = width;
        canvas.height = height;
        const ctx = canvas.getContext('2d', { willReadFrequently: true });
        const drawFrame = () => {
            ctx.drawImage(video, 0, 0, width, height);
            return ctx.getImageData(0, 0, width, height);
        };

        // 首选 requestVideoFrameCallback:边播放边按视频帧率捕获(Chrome/Edge/Firefox 新版)
        if (typeof video.requestVideoFrameCallback === 'function') {
            const captured = [];
            let lastIdx = -1;
            await new Promise((resolve) => {
                const finish = () => {
                    try { video.pause(); } catch (e) {}
                    resolve();
                };
                const onFrame = (now, meta) => {
                    try {
                        const t = meta && typeof meta.mediaTime === 'number' ? meta.mediaTime : video.currentTime;
                        const idx = Math.floor(t * fps + 1e-6);
                        if (idx > lastIdx && captured.length < maxFrames && t < duration - 0.02) {
                            lastIdx = idx;
                            captured.push(drawFrame());
                        }
                        if (captured.length >= maxFrames || video.ended) {
                            finish();
                            return;
                        }
                        video.requestVideoFrameCallback(onFrame);
                    } catch (e) {
                        finish();
                    }
                };
                video.addEventListener('ended', finish, { once: true });
                video.requestVideoFrameCallback(onFrame);
                video.play().catch(() => finish());
                setTimeout(finish, Math.max(5000, duration * 1000 + 5000));
            });
            return captured;
        }

        // 回退:seek 逐帧捕获(Safari 等无 rVFC 的环境)
        const captured = [];
        for (let i = 0; i < maxFrames; i++) {
            const t = Math.min(i / fps, Math.max(0, duration - 0.05));
            await seekVideo(video, t);
            captured.push(drawFrame());
        }
        return captured;
    }

    function calcGifSize(video, useRealWorker) {
        const maxSide = useRealWorker ? GIF_MAX_WIDTH : 260;
        const scale = maxSide / Math.max(video.videoWidth, video.videoHeight);
        const width = Math.max(2, Math.round(video.videoWidth * scale));
        const height = Math.max(2, Math.round(video.videoHeight * scale));
        let fps = Math.max(GIF_MIN_FPS, Math.min(GIF_MAX_FPS, GIF_TARGET_FRAMES / video.duration));
        if (!useRealWorker) fps = Math.min(fps, 8); // 主线程编码时降低负载
        const maxFrames = Math.max(2, Math.min(Math.round(video.duration * fps), GIF_TARGET_FRAMES));
        return { width, height, fps, maxFrames };
    }

    function cleanupGifVideo(video, videoUrl) {
        if (!video) return;
        try { video.pause(); video.removeAttribute('src'); video.load(); } catch (e) {}
        if (videoUrl && videoUrl.startsWith('blob:')) {
            try { URL.revokeObjectURL(videoUrl); } catch (e) {}
        }
    }

    async function loadAndExtract(videoSrc, isBlob, useRealWorker) {
        const video = createGifVideo();
        if (!isBlob) video.crossOrigin = 'anonymous';
        try {
            const metaPromise = waitForVideoMeta(video);
            video.src = videoSrc;
            await metaPromise;
            const { width, height, fps, maxFrames } = calcGifSize(video, useRealWorker);
            const frames = await extractVideoFrames(video, width, height, fps, maxFrames);
            return { video, videoUrl: videoSrc, width, height, fps, frames };
        } catch (err) {
            cleanupGifVideo(video, isBlob ? videoSrc : null);
            throw err;
        }
    }

    async function downloadAsGif(url, filename, existingBlob) {
        showGifToast('正在下载视频…');
        const blob = existingBlob || await gmGetBlob(url);
        const env = await ensureGifJs();

        let video = null, videoUrl = null, width = 0, height = 0, fps = 0, frames = null;

        // 尝试1:blob URL 加载(同源,canvas 不污染)
        try {
            videoUrl = URL.createObjectURL(blob);
            ({ video, width, height, fps, frames } = await loadAndExtract(videoUrl, true, env.useRealWorker));
        } catch (err) {
            console.warn('[X Media] blob 方式抽帧失败,尝试直连:', err);
        }

        // 尝试2:crossorigin 直连(视频 CDN 一般带 CORS 头)
        if (!frames || frames.length === 0) {
            cleanupGifVideo(video, videoUrl);
            video = null; videoUrl = null;
            try {
                ({ video, videoUrl, width, height, fps, frames } = await loadAndExtract(url, false, env.useRealWorker));
            } catch (err) {
                console.warn('[X Media] 直连抽帧失败:', err);
            }
        }

        if (!frames || frames.length === 0) {
            cleanupGifVideo(video, videoUrl);
            throw new Error('无法提取视频帧');
        }

        showGifToast('正在生成 GIF… 0%');
        const gifBlob = await encodeGif(env, frames, width, height, fps, (p) => {
            showGifToast('正在生成 GIF… ' + Math.round(p * 100) + '%');
        });

        cleanupGifVideo(video, videoUrl);
        await saveBlob(gifBlob, filename);
        hideGifToast();
    }

    // 下载分发:GIF 条目转 GIF(失败回退 MP4);时长未知的 MP4 先探测真实时长再决定
    async function downloadMedia(item, filename) {
        if (item.ext === 'gif' && config.gifShortVideos) {
            try {
                await downloadAsGif(item.url, filename);
                return;
            } catch (err) {
                console.warn('[X Media] GIF 转换失败,改存 MP4:', err);
                hideGifToast();
                await downloadAsBlob(item.url, filename.replace(/\.gif$/i, '.mp4'));
                return;
            }
        }
        // [修改] mp4 条目统一用真实文件时长兜底判定(API/fiber/DOM 的元数据可能缺失或
        // 不可靠):下载成 blob 后探测实际时长,<10s 转 GIF,否则照常保存 MP4
        if (item.ext === 'mp4' && config.gifShortVideos) {
            try {
                const blob = await gmGetBlob(item.url);
                const realDurMs = await probeBlobDuration(blob);
                if (realDurMs > 0 && realDurMs < 10000) {
                    try {
                        await downloadAsGif(item.url, filename.replace(/\.mp4$/i, '.gif'), blob);
                        return;
                    } catch (err) {
                        console.warn('[X Media] GIF 转换失败,改存 MP4:', err);
                        hideGifToast();
                    }
                }
                await saveBlob(blob, filename);
                return;
            } catch (err) {
                console.warn('[X Media] 时长探测失败,直接保存 MP4:', err);
            }
        }
        await downloadAsBlob(item.url, filename);
    }

    // 探测 blob 视频的真实时长(毫秒);失败返回 0
    function probeBlobDuration(blob) {
        return new Promise((resolve) => {
            let video = null, url = null, settled = false;
            const finish = (ms) => {
                if (settled) return;
                settled = true;
                clearTimeout(timer);
                try { if (video) { video.pause(); video.removeAttribute('src'); video.load(); } } catch (e) {}
                try { if (url) URL.revokeObjectURL(url); } catch (e) {}
                resolve(ms);
            };
            const timer = setTimeout(() => finish(0), 15000);
            try {
                video = document.createElement('video');
                video.muted = true;
                video.preload = 'metadata';
                video.addEventListener('loadedmetadata', () => {
                    finish((isFinite(video.duration) && video.duration > 0) ? Math.round(video.duration * 1000) : 0);
                });
                video.addEventListener('error', () => finish(0));
                url = URL.createObjectURL(blob);
                video.src = url;
            } catch (e) {
                finish(0);
            }
        });
    }

})();