Stable Diffusion image metadata viewer

Show Stable Diffusion generated image's metadata

Tendrás que instalar una extensión para tu navegador como Tampermonkey, Greasemonkey o Violentmonkey si quieres utilizar este script.

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

Tendrás que instalar una extensión como Tampermonkey o Violentmonkey para instalar este script.

Necesitarás instalar una extensión como Tampermonkey o Userscripts para instalar este script.

Tendrás que instalar una extensión como Tampermonkey antes de poder instalar este script.

Necesitarás instalar una extensión para administrar scripts de usuario si quieres instalar este script.

(Ya tengo un administrador de scripts de usuario, déjame instalarlo)

Tendrás que instalar una extensión como Stylus antes de poder instalar este script.

Tendrás que instalar una extensión como Stylus antes de poder instalar este script.

Tendrás que instalar una extensión como Stylus antes de poder instalar este script.

Para poder instalar esto tendrás que instalar primero una extensión de estilos de usuario.

Para poder instalar esto tendrás que instalar primero una extensión de estilos de usuario.

Para poder instalar esto tendrás que instalar primero una extensión de estilos de usuario.

(Ya tengo un administrador de estilos de usuario, déjame instalarlo)

// ==UserScript==
// @name         Stable Diffusion image metadata viewer
// @namespace    https://github.com/himuro-majika
// @version      0.4.1
// @description  Show Stable Diffusion generated image's metadata
// @author       himuro_majika
// @match        http://*/*.png*
// @match        http://*/*.jpg*
// @match        http://*/*.jpeg*
// @match        http://*/*.webp*
// @match        https://*/*.png*
// @match        https://*/*.jpg*
// @match        https://*/*.jpeg*
// @match        https://*/*.webp*
// @match        file:///*.png*
// @match        file:///*.jpg*
// @match        file:///*.jpeg*
// @match        file:///*.webp*
// @require      https://cdn.jsdelivr.net/npm/[email protected]/dist/exif-reader.min.js
// @license      MIT
// @grant        GM_xmlhttpRequest
// @grant        GM_addElement
// ==/UserScript==

(function() {
    'use strict';

    const DEBUG = false;

    // Some sites show a direct PNG URL but the DOM image may be missing, lazy-loaded, or have a blob: src.
    // Prefer an <img> that actually points at the current URL; otherwise fall back to location.href.
    let img = pickBestImage();
    let url = getImageUrl(img);

    // Status (badge disabled by default; we keep setStatus as a no-op unless enabled).
    const badge = null;
    // setStatus('ready');

    if (DEBUG) console.log('[SIMV] start', { href: location.href, pickedImg: !!img, imgSrc: img?.currentSrc || img?.src || null, url });

    if (url) {
        readExif(url);
    } else {
        // Wait briefly for the page to insert an <img> (some viewers do this async even on direct URLs).
        waitForImageAndRun();
    }

    function pickBestImage() {
        const imgs = Array.from(document.images || []).filter(i => i && isVisible(i));
        if (!imgs.length) return null;

        // Prefer an img whose src/currentSrc matches the current URL (common on direct image pages).
        const hrefNoHash = (location.href || '').split('#')[0];
        const exact = imgs.find(i => (i.currentSrc || i.src || '') === hrefNoHash);
        if (exact) return exact;

        // Otherwise pick the largest visible image (common on gallery/viewer pages).
        let best = null;
        let bestScore = -1;
        for (const i of imgs) {
            const w = i.naturalWidth || i.width || 0;
            const h = i.naturalHeight || i.height || 0;
            const score = w * h;
            if (score > bestScore) {
                bestScore = score;
                best = i;
            }
        }
        return best;
    }

    function isVisible(el) {
        try {
            const r = el.getBoundingClientRect();
            const style = getComputedStyle(el);
            if (style.display === 'none' || style.visibility === 'hidden' || Number(style.opacity) === 0) return false;
            return r.width > 1 && r.height > 1;
        } catch {
            return true;
        }
    }

    function getImageUrl(imageEl) {
        // If we have an <img>, prefer currentSrc/src, but ignore blob: URLs (they usually won't contain metadata or are cross-origin).
        const src = imageEl?.currentSrc || imageEl?.src || '';
        if (src && !src.startsWith('blob:')) return src;

        // For direct-image pages, location.href points at the actual file (including ?ex=...).
        const href = location.href || '';
        if (href && /\.(png|jpe?g|webp)(\?|#|$)/i.test(href)) return href;

        return '';
    }

    function getMimeFromUrl(url) {
        const u = (url || '').toLowerCase();
        if (u.includes('.png')) return 'image/png';
        if (u.includes('.webp')) return 'image/webp';
        if (u.includes('.jpg') || u.includes('.jpeg')) return 'image/jpeg';
        return 'image/png';
    }

    // Badge removed (user preference). Leave helper stubbed in case you want to re-enable later.
    function makeStatusBadge() { return null; }

    function setStatus(text) {
        if (!badge) return;
        badge.textContent = `SIMV: ${text}`;
    }

    function ensureOpenButton() {
        if (document.getElementById('_gm_simv_open_button')) return;
        makeButton();
    }

    // No placeholder modal: only create UI if real metadata is detected.
    function ensureEmptyModal() { /* intentionally disabled */ }

    function waitForImageAndRun() {
        let done = false;
        const run = () => {
            if (done) return;
            img = pickBestImage();
            url = getImageUrl(img);
            if (!url) return;
            done = true;
            if (DEBUG) console.log('[SIMV] late image found', { imgSrc: img?.currentSrc || img?.src || null, url });
            readExif(url);
        };

        // Try immediately and then observe DOM changes for a short time.
        run();
        const obs = new MutationObserver(() => run());
        obs.observe(document.documentElement || document.body, { childList: true, subtree: true, attributes: true });
        setTimeout(() => {
            obs.disconnect();
        }, 5000);
    }

    function readExif(url) {
        setStatus('loading');
        // Don't show UI until we actually detect metadata.
        fetch(url).then((response) => response.arrayBuffer())
        .then((fileBuffer) => loadTags(fileBuffer, url))
        .catch(() => {
            GM_xmlhttpRequest({
                method: "GET",
                url: url,
                responseType: "arraybuffer",
                onload: (res) => {
                    loadTags(res.response, url);
                },
                onerror: (e) => {
                    console.log(e);
                    setStatus('failed to load');
                    return;
                }
            });
        });
    }

    async function loadTags(fileBuffer, sourceUrl) {
        if (!fileBuffer) return;
        try {
            const tags = ExifReader.load(fileBuffer, {expanded: true});
            const prompt = getPrompt(tags);

            // Stealth (LSB/steganographic) metadata fallback (commonly used by NovelAI and others).
            try {
                const stealth = await readInfoFromImageStealth(fileBuffer, getMimeFromUrl(sourceUrl));
                if (stealth) {
                    prompt.stealth = stealth;
                    if (!prompt.comfy) prompt.comfy = parseComfyUIMetadata(stealth.text);
                    if (!prompt.swarm) prompt.swarm = parseSwarmUIMetadata(stealth.text);
                    if (!prompt.novelai) prompt.novelai = parseNovelAIMetadataFromString(stealth.text);
                    if (!prompt.positive && prompt.novelai?.prompt) prompt.positive = prompt.novelai.prompt;
                    if (!prompt.negative && prompt.novelai?.negative) prompt.negative = prompt.novelai.negative;
                    if (!prompt.others && stealth.text) prompt.others = stealth.text;
                }
            } catch (e) {
                if (DEBUG) console.log('[SIMV] stealth decode failed', e);
            }

            const hasAny = !!(prompt.positive || prompt.negative || prompt.others || prompt.comfy || prompt.swarm || prompt.novelai || prompt.stealth);
            setStatus(hasAny ? 'metadata found' : 'no metadata');

            // Only create UI if we actually found something to show.
            if (!hasAny) {
                // If we previously showed UI (e.g., navigating within a SPA), remove it.
                document.getElementById('_gm_simv_container')?.remove();
                document.getElementById('_gm_simv_open_button')?.remove();
                return;
            }

            // Replace any existing modal with the real one.
            document.getElementById('_gm_simv_container')?.remove();
            makeData(prompt);
        } catch(e) {
            console.log(e);
            setStatus('error');
        }
    }

    function getPrompt(tags) {
        // console.dir(JSON.parse(JSON.stringify(tags)));

        let com = "";
        let prompt = {
            positive: "",
            negative: "",
            others: "",
            comfy: null,
            swarm: null,
            novelai: null,
            stealth: null
        }

        function tryParseExtrasFromString(text) {
            if (!text) return;
            if (!prompt.comfy) {
                const comfy = parseComfyUIMetadata(text);
                if (comfy) prompt.comfy = comfy;
            }
            if (!prompt.swarm) {
                const swarm = parseSwarmUIMetadata(text);
                if (swarm) prompt.swarm = swarm;
            }
            if (!prompt.novelai) {
                const nai = parseNovelAIMetadataFromString(text);
                if (nai) prompt.novelai = nai;
            }
        }

        // Exif
        if (tags.exif && tags.exif.UserComment) {
            com = decodeUnicode(tags.exif.UserComment.value);
            tryParseExtrasFromString(com);
            try {
                prompt.positive = com.match(/([^]+)Negative prompt: /)[1];
                prompt.negative = com.match(/Negative prompt: ([^]+)Steps: /)[1];
                prompt.others = com.match(/(Steps: [^]+)/)[1];
            } catch (e) {
                console.log(com);
                prompt.others = com;
            }
        }

        // Scan common namespaces for SwarmUI/ComfyUI/NovelAI JSON even when pngText is missing.
        tryParseExtrasFromString(concatAllTagDescriptions(tags));

        // iTXt
        if (tags.pngText) {
            // NovelAI: detect from tags before other branches so we don't miss it.
            if (!prompt.novelai) {
                const nai = parseNovelAIMetadataFromTags(tags);
                if (nai) prompt.novelai = nai;
            }

            // If we detected NovelAI, ensure we populate at least something so the button appears.
            if (prompt.novelai) {
                if (!prompt.positive && prompt.novelai.prompt) prompt.positive = prompt.novelai.prompt;
                if (!prompt.negative && prompt.novelai.negative) prompt.negative = prompt.novelai.negative;
                if (!prompt.others) {
                    const nai = prompt.novelai;
                    const parts = [];
                    if (nai.software) parts.push(`Software: ${nai.software}`);
                    if (nai.source) parts.push(`Source: ${nai.source}`);
                    if (nai.generationTime != null) parts.push(`Generation time: ${nai.generationTime}`);
                    prompt.others = parts.join("\r\n");
                }
                // Important: do not return here; we still want A1111 prompt/etc if present too.
            }

            // A1111
            if (tags.pngText.parameters) {
                com = tags.pngText.parameters.description;
                tryParseExtrasFromString(com);
                try {
                    prompt.positive = com.match(/([^]+)Negative prompt: /)[1];
                    prompt.negative = com.match(/Negative prompt: ([^]+)Steps: /)[1];
                    prompt.others = com.match(/(Steps: [^]+)/)[1];
                } catch (e) {
                    console.log(com);
                    prompt.others = com;
                }
                return prompt;
            }
            // NMKD
            if (tags.pngText.Dream) {
                com = tags.pngText.Dream.description;
                com += tags.pngText["sd-metadata"] ? "\r\n" + tags.pngText["sd-metadata"].description : "";
                tryParseExtrasFromString(com);
                try {
                    prompt.positive = com.match(/([^]+?)\[[^[]+\]/)[1];
                    prompt.negative = com.match(/\[([^[]+?)(\]|Steps: )/)[1];
                    prompt.others = com.match(/\]([^]+)/)[1];
                } catch (e) {
                    console.log(com);
                    prompt.others = com;
                }
                return prompt;
            }

            // NAI (also used by NovelAI): show raw comment summary if present
            if (tags.pngText.Comment) {
                const comment = tags.pngText.Comment.description.replaceAll(/\\u00a0/g, " ");
                const positive = tags.pngText.Description ? tags.pngText.Description.description : safeJsonParse(comment)?.prompt;
                const negative = safeJsonParse(comment)?.uc;
                let others = comment + "\r\n";
                others += tags.pngText.Software ? tags.pngText.Software.description + "\r\n" : "";
                others += tags.pngText.Title ? tags.pngText.Title.description + "\r\n" : "";
                others += tags.pngText.Source ? tags.pngText.Source.description : "";
                others += tags.pngText["Generation time"] ? "\r\nGeneration time: " + tags.pngText["Generation time"].description : "";

                if (!prompt.positive) prompt.positive = positive || '';
                if (!prompt.negative) prompt.negative = negative || '';
                if (!prompt.others) prompt.others = others;

                // Try parsing NovelAI again using the now-known Comment (in case tag parsing missed).
                if (!prompt.novelai) {
                    const nai = parseNovelAIMetadataFromTags(tags);
                    if (nai) prompt.novelai = nai;
                }

                tryParseExtrasFromString(others);
                return prompt;
            }

            // Fallback: concatenate all pngText descriptions
            Object.keys(tags.pngText).forEach(tag => {
                com += tags.pngText[tag].description;
            });
            tryParseExtrasFromString(com);
            if (!prompt.others) prompt.others = com;
            return prompt;
        }

        return prompt;
    }

    function safeJsonParse(text) {
        if (!text || typeof text !== 'string') return null;
        try { return JSON.parse(text); } catch { return null; }
    }

    function getNovelTextFromCommentObject(commentObj, type) {
        // type: 'positive' | 'negative'
        if (!commentObj || typeof commentObj !== 'object') return '';
        if (type === 'positive') {
            // Newer NovelAI shape: prompt + v4_prompt.caption.base_caption
            const v4 = commentObj.v4_prompt?.caption?.base_caption;
            return (commentObj.prompt || v4 || '');
        }
        // negative: uc + v4_negative_prompt.caption.base_caption
        const v4n = commentObj.v4_negative_prompt?.caption?.base_caption;
        return (commentObj.uc || v4n || commentObj.negative_prompt || '');
    }

    function parseNovelAIMetadataFromTags(tags) {
        // NovelAI commonly writes to pngText: Description/Software/Source/Generation time/Comment(JSON)
        if (!tags || !tags.pngText) return null;
        const pt = tags.pngText;

        // Note: ExifReader v4 gives us objects whose .description is the text.
        const software = pt.Software?.description || '';
        const source = pt.Source?.description || '';
        const genTime = pt["Generation time"]?.description;
        const description = pt.Description?.description || '';

        const commentRaw = pt.Comment?.description || '';
        const comment = safeJsonParse(commentRaw);

        // If the comment isn't JSON, bail early (this function is for tag-based NovelAI).
        if (!comment || typeof comment !== 'object') {
            const explicit = /novelai/i.test(software) || /novelai/i.test(source);
            // Some NovelAI exports may still have a plain Description even if Comment isn't JSON.
            if (!explicit || !description) return null;
        }

        const promptText = getNovelTextFromCommentObject(comment, 'positive') || description || '';
        const negativeText = getNovelTextFromCommentObject(comment, 'negative') || '';

        const nai = {
            kind: 'novelai',
            software,
            source,
            generationTime: genTime,
            prompt: promptText,
            negative: negativeText,
            steps: comment?.steps,
            seed: comment?.seed,
            sampler: comment?.sampler || comment?.v4_prompt?.sampler,
            width: comment?.width,
            height: comment?.height,
            scale: comment?.scale,
            uncondScale: comment?.uncond_scale,
            cfgRescale: comment?.cfg_rescale,
            noiseSchedule: comment?.noise_schedule,
            strength: comment?.strength,
            requestType: comment?.request_type,
            model: source || software,
            rawJson: commentRaw
        };

        // Confidence: accept either explicit NovelAI marker or a strong param shape.
        const explicit = /novelai/i.test(software) || /novelai/i.test(source);
        const hasPromptish = !!(nai.prompt || nai.negative);
        const hasParamish = [nai.steps, nai.seed, nai.sampler, nai.width, nai.height, nai.scale, nai.cfgRescale, nai.noiseSchedule, nai.requestType]
            .filter(v => v !== undefined && v !== null && v !== '').length >= 3;
        const looksLikeNovel = !!(comment && (comment.v4_prompt || comment.v4_negative_prompt || comment.signed_hash || comment.noise_schedule));

        if (!(hasPromptish && (explicit || looksLikeNovel || hasParamish))) return null;
        return nai;
    }

    function parseNovelAIMetadataFromString(text) {
        // Handles cases where the NovelAI Comment JSON is embedded in a larger string.
        if (!text || typeof text !== 'string') return null;
        if (!/novelai/i.test(text) && !/"uc"\s*:/i.test(text) && !/"steps"\s*:/i.test(text) && !/"signed_hash"\s*:/i.test(text)) return null;

        const json = extractFirstJsonObjectFromAnywhere(text);
        if (!json) return null;
        const obj = safeJsonParse(json);
        if (!obj || typeof obj !== 'object') return null;

        // NovelAI exports often wrap the actual generation params in a `Comment` field.
        // That field can be an object OR a JSON string.
        let commentObj = obj;
        if (obj.Comment !== undefined) {
            if (typeof obj.Comment === 'string') {
                commentObj = safeJsonParse(obj.Comment) || obj;
            } else if (obj.Comment && typeof obj.Comment === 'object') {
                commentObj = obj.Comment;
            }
        }

        const promptText = getNovelTextFromCommentObject(commentObj, 'positive');
        const negativeText = getNovelTextFromCommentObject(commentObj, 'negative');
        if (!promptText && !negativeText) return null;

        const nai = {
            kind: 'novelai',
            software: '',
            source: '',
            generationTime: undefined,
            prompt: promptText || '',
            negative: negativeText || '',
            steps: commentObj.steps,
            seed: commentObj.seed,
            sampler: commentObj.sampler,
            width: commentObj.width,
            height: commentObj.height,
            scale: commentObj.scale,
            cfgRescale: commentObj.cfg_rescale,
            noiseSchedule: commentObj.noise_schedule,
            strength: commentObj.strength,
            requestType: commentObj.request_type,
            rawJson: json
        };

        const confidence = [nai.prompt, nai.negative, nai.steps, nai.seed, nai.sampler, nai.width, nai.height, nai.noiseSchedule, nai.requestType]
            .filter(v => v !== undefined && v !== null && v !== '').length;
        if (confidence < 3) return null;

        return nai;
    }

    function extractFirstJsonObjectFromAnywhere(text) {
        const start = text.indexOf('{');
        if (start === -1) return null;
        return extractFirstJsonObject(text.slice(start));
    }

    function parseComfyUIMetadata(text) {
        if (!text || typeof text !== 'string') return null;

        const jsonStrings = extractLeadingJsonObjects(text);
        if (!jsonStrings.length) return null;

        for (const js of jsonStrings) {
            try {
                const obj = JSON.parse(js);
                const comfy = parseComfyGraphObject(obj);
                if (comfy) {
                    comfy.rawJson = js;
                    return comfy;
                }
            } catch {
                // ignore
            }
        }

        return null;
    }

    function parseSwarmUIMetadata(text) {
        if (!text || typeof text !== 'string') return null;
        const idx = text.indexOf('sui_image_params');
        if (idx === -1) return null;

        // Anchor the JSON extraction near the signature (don't assume first '{' in the whole string is SwarmUI).
        const braceStart = text.lastIndexOf('{', idx);
        const slice = braceStart !== -1 ? text.slice(braceStart) : text;
        const json = extractFirstJsonObject(slice);
        if (!json) return null;

        try {
            const obj = JSON.parse(json);
            if (!obj || typeof obj !== 'object') return null;
            if (!obj.sui_image_params) return null;

            const p = obj.sui_image_params;
            const modelEntry = Array.isArray(obj.sui_models) ? obj.sui_models.find(m => m && m.param === 'model') : null;
            const swarm = {
                kind: 'swarmui',
                model: modelEntry?.name || p.model || '',
                modelHash: modelEntry?.hash || '',
                seed: p.seed,
                steps: p.steps,
                cfg: p.cfgscale,
                sampler: p.sampler,
                scheduler: p.scheduler,
                width: p.width,
                height: p.height,
                aspect: p.aspectratio,
                prompt: p.prompt || '',
                negative: p.negativeprompt || '',
                refinerMethod: p.refinermethod,
                refinerSteps: p.refinersteps,
                refinerControl: p.refinercontrolpercentage,
                refinerUpscale: p.refinerupscale,
                refinerUpscaleMethod: p.refinerupscalemethod,
                version: p.swarm_version
            };

            const confidence = [swarm.model, swarm.steps, swarm.sampler, swarm.prompt]
                .filter(v => v !== undefined && v !== null && v !== '').length;
            if (confidence < 2) return null;

            swarm.rawJson = json;
            return swarm;
        } catch {
            return null;
        }
    }

    function concatAllTagDescriptions(tags) {
        const parts = [];
        const seen = new Set();

        function walk(node) {
            if (!node || typeof node !== 'object') return;

            if (Array.isArray(node)) {
                node.forEach(walk);
                return;
            }

            if (typeof node.description === 'string') {
                const s = node.description;
                if (s && !seen.has(s)) {
                    seen.add(s);
                    parts.push(s);
                }
            }

            for (const k of Object.keys(node)) {
                if (k === 'value' || k === 'description') continue;
                walk(node[k]);
            }
        }

        walk(tags);
        return parts.join("\n");
    }

    function extractLeadingJsonObjects(text) {
        const out = [];
        const start = text.indexOf('{');
        if (start === -1) return out;

        let i = start;
        for (let attempt = 0; attempt < 2 && i < text.length; attempt++) {
            const slice = text.slice(i);
            const json = extractFirstJsonObject(slice);
            if (!json) break;
            out.push(json);
            i += json.length;
            while (i < text.length && /\s/.test(text[i])) i++;
            if (text[i] !== '{') break;
        }
        return out;
    }

    function extractFirstJsonObject(text) {
        let depth = 0;
        let inString = false;
        let escape = false;
        let started = false;
        for (let i = 0; i < text.length; i++) {
            const c = text[i];
            if (!started) {
                if (c === '{') {
                    started = true;
                    depth = 1;
                }
                continue;
            }

            if (inString) {
                if (escape) {
                    escape = false;
                } else if (c === '\\') {
                    escape = true;
                } else if (c === '"') {
                    inString = false;
                }
                continue;
            }

            if (c === '"') {
                inString = true;
                continue;
            }
            if (c === '{') depth++;
            if (c === '}') depth--;
            if (depth === 0) {
                return text.slice(0, i + 1);
            }
        }
        return null;
    }

    function parseComfyGraphObject(obj) {
        if (!obj || typeof obj !== 'object') return null;

        const keys = Object.keys(obj);
        if (!keys.length) return null;
        const numericish = keys.filter(k => /^\d+$/.test(k));
        if (numericish.length < Math.max(1, Math.floor(keys.length / 2))) return null;

        const nodes = numericish.map(k => obj[k]).filter(v => v && typeof v === 'object' && v.class_type);
        if (!nodes.length) return null;

        const ks = nodes.find(n => n.class_type === 'KSampler' || n.class_type?.includes('KSampler'));
        const pos = nodes.find(n => n.class_type === 'CLIPTextEncode' && n._meta?.title?.includes('(Prompt)'));
        const neg = nodes.find(n => n.class_type === 'CLIPTextEncode' && n._meta?.title?.toLowerCase().includes('negative'));
        const unet = nodes.find(n => n.class_type === 'UNETLoader' || n.class_type === 'CheckpointLoaderSimple');
        const clip = nodes.find(n => n.class_type === 'CLIPLoader');
        const vae = nodes.find(n => n.class_type === 'VAELoader');

        const comfy = {
            kind: 'comfyui',
            checkpoint: unet?.inputs?.unet_name || unet?.inputs?.ckpt_name || unet?.inputs?.checkpoint || '',
            clip: clip?.inputs?.clip_name || '',
            vae: vae?.inputs?.vae_name || '',
            seed: getFirstDefined(ks?.inputs?.seed, nodes.find(n => n.class_type?.toLowerCase().includes('seed'))?.inputs?.seed),
            steps: ks?.inputs?.steps,
            cfg: ks?.inputs?.cfg,
            sampler: ks?.inputs?.sampler_name,
            scheduler: ks?.inputs?.scheduler,
            denoise: ks?.inputs?.denoise,
            positive: pos?.inputs?.text || '',
            negative: neg?.inputs?.text || ''
        };

        if (!comfy.positive || !comfy.negative) {
            const clipEncodes = nodes.filter(n => n.class_type === 'CLIPTextEncode' && typeof n.inputs?.text === 'string');
            if (!comfy.positive && clipEncodes[0]) comfy.positive = clipEncodes[0].inputs.text;
            if (!comfy.negative && clipEncodes[1]) comfy.negative = clipEncodes[1].inputs.text;
        }

        const confidence = [comfy.steps, comfy.cfg, comfy.sampler, comfy.positive, comfy.negative, comfy.checkpoint]
            .filter(v => v !== undefined && v !== null && v !== '').length;
        if (confidence < 2) return null;

        return comfy;
    }

    function getFirstDefined(...values) {
        for (const v of values) {
            if (v === undefined || v === null) continue;
            if (Array.isArray(v) && v.length) return v[0];
            return v;
        }
        return undefined;
    }

    function decodeUnicode(array) {
        const plain = array.map(t => t.toString(16).padStart(2, "0")).join("");
        if (!plain.match(/^554e49434f44450/)) {
            return;
        }
        const hex = plain.replace(/^554e49434f44450[0-9]/, "").replace(/[0-9a-f]{4}/g, ",0x$&").replace(/^,/, "");
        const arhex = hex.split(",");
        let decode = "";
        arhex.forEach(v => {
            decode += String.fromCodePoint(v);
        })
        return decode;
    }

    async function readInfoFromImageStealth(fileBuffer, mimeType) {
        // NovelAI reference implementation stores metadata in the ALPHA channel LSBs.
        // Format:
        //   magic (ASCII) = "stealth_pngcomp" or "stealth_pnginfo"
        //   lenBits (uint32 BE)
        //   payload bytes (lenBits/8)
        //      - gzip-compressed JSON for stealth_pngcomp
        //      - plain UTF-8 text for stealth_pnginfo
        const u8 = new Uint8Array(fileBuffer);
        const blob = new Blob([u8], { type: mimeType || 'image/png' });
        const bmp = await createImageBitmap(blob);
        const canvas = document.createElement('canvas');
        canvas.width = bmp.width;
        canvas.height = bmp.height;
        const ctx = canvas.getContext('2d', { willReadFrequently: true });
        if (!ctx) return null;
        ctx.drawImage(bmp, 0, 0);
        const imgData = ctx.getImageData(0, 0, canvas.width, canvas.height);
        const data = imgData.data; // Uint8ClampedArray RGBA

        // NovelAI's Python reference uses alpha.T.reshape(-1) (i.e., transposed alpha plane)
        // before reading bits. Canvas ImageData is row-major. We'll try BOTH orders.
        const alphaStreams = [
            { name: 'direct', bytes: extractAlphaRowMajor(data) },
            { name: 'transpose', bytes: extractAlphaTransposed(data, canvas.width, canvas.height) },
        ];

        // Only alpha-channel variants are used by NovelAI.
        const variants = [
            { sig: 'stealth_pngcomp', compressed: true },
            { sig: 'stealth_pnginfo', compressed: false },
        ];

        for (const alpha of alphaStreams) {
            for (const v of variants) {
                const res = await tryDecodeNovelAIAlphaStealth(alpha.bytes, v);
                if (res) {
                    if (DEBUG) console.log('[SIMV] stealth found', { order: alpha.name, variant: v.sig, compressed: v.compressed, length: res.length });
                    return res;
                }
            }
        }

        return null;
    }

    function extractAlphaRowMajor(rgba) {
        const totalPixels = Math.floor(rgba.length / 4);
        const a = new Uint8Array(totalPixels);
        for (let i = 0; i < totalPixels; i++) a[i] = rgba[i * 4 + 3];
        return a;
    }

    function extractAlphaTransposed(rgba, width, height) {
        // rgba is row-major. We extract alpha as HxW then transpose to WxH and flatten.
        const totalPixels = Math.floor(rgba.length / 4);
        const out = new Uint8Array(totalPixels);
        let idx = 0;
        for (let x = 0; x < width; x++) {
            for (let y = 0; y < height; y++) {
                const i = y * width + x;
                out[idx++] = rgba[i * 4 + 3];
            }
        }
        return out;
    }

    async function tryDecodeNovelAIAlphaStealth(alphaBytes, variant) {
        // Read bits from alpha channel LSB only.
        // We expect alphaBytes to already be in the correct traversal order.
        let cursor = 0;

        function readBit() {
            if (cursor >= alphaBytes.length) return null;
            const bit = alphaBytes[cursor] & 1;
            cursor++;
            return bit;
        }

        function readByteBE() {
            // Pack bits MSB->LSB (numpy packbits default bitorder='big').
            let b = 0;
            for (let k = 0; k < 8; k++) {
                const bit = readBit();
                if (bit == null) return null;
                b = (b << 1) | bit;
            }
            return b;
        }

        function readBytes(n) {
            const out = new Uint8Array(n);
            for (let i = 0; i < n; i++) {
                const b = readByteBE();
                if (b == null) return null;
                out[i] = b;
            }
            return out;
        }

        const magicBytes = readBytes(variant.sig.length);
        if (!magicBytes) return null;
        const magic = bytesToUtf8(magicBytes);
        if (magic !== variant.sig) return null;

        // lenBits is uint32 BE
        const lenBytes = readBytes(4);
        if (!lenBytes) return null;
        const lenBits = (lenBytes[0] << 24) | (lenBytes[1] << 16) | (lenBytes[2] << 8) | (lenBytes[3]);
        if (!Number.isFinite(lenBits) || lenBits <= 0) return null;
        if (lenBits % 8 !== 0) return null;
        const len = lenBits / 8;
        if (len > 40_000_000) return null;

        const payload = readBytes(len);
        if (!payload) return null;

        let decoded = payload;
        if (variant.compressed) {
            decoded = await gunzipMaybe(payload);
            if (!decoded) return null;
        }
        const text = bytesToUtf8(decoded).replace(/^\uFEFF/, '');
        if (!text) return null;
        return { kind: 'stealth', variant: variant.sig, compressed: variant.compressed, length: len, text };
    }

    function bytesToAscii(bytes) {
        return String.fromCharCode(...bytes.map(b => b & 0xFF));
    }

    function bytesToUtf8(bytes) {
        try {
            return new TextDecoder('utf-8', { fatal: false }).decode(bytes);
        } catch {
            let s = '';
            for (const b of bytes) s += String.fromCharCode(b);
            return s;
        }
    }

    async function gunzipMaybe(bytes) {
        try {
            if (typeof DecompressionStream === 'function') {
                const ds = new DecompressionStream('gzip');
                const stream = new Blob([bytes]).stream().pipeThrough(ds);
                const ab = await new Response(stream).arrayBuffer();
                return new Uint8Array(ab);
            }
        } catch {
            // ignore
        }
        return null;
    }

    function makeButton() {
        addStyle();
        if (document.getElementById('_gm_simv_open_button')) return;
        const button = document.createElement("button");
        button.id = "_gm_simv_open_button";
        button.innerHTML = "Show SD metadata";
        button.addEventListener("click", showModal);
        document.body.appendChild(button);
    }

    function makeSectionHtml({ title, accent, copybutton, rows, value, area }) {
        const gridArea = area ? ` style="--_gm_accent:${accent}; grid-area:${area}"` : ` style="--_gm_accent:${accent}"`;
        return `
<section class="_gm_simv_card"${gridArea}>
  <header class="_gm_simv_card_header">
    <div class="_gm_simv_card_title">${title}</div>
    ${copybutton}
  </header>
  <textarea class="_gm_simv_textarea" rows="${rows}">${value || ''}</textarea>
</section>`;
    }

    function makeData(prompt) {
        const positive = prompt.positive;
        const negative = prompt.negative;
        const others = prompt.others;
        const comfy = prompt.comfy;
        const swarm = prompt.swarm;
        const novel = prompt.novelai;
        const stealth = prompt.stealth;
    if (!positive && !negative && !others && !comfy && !swarm && !novel && !stealth) return;

        makeButton();

        const container = document.createElement("div");
        container.id ="_gm_simv_container";

        const copybutton = location.protocol == "http:" ? "" : `<button class="_gm_simv_copybutton" type="button">Copy</button>`;

        const cards = [];
        if (comfy) {
            cards.push(makeSectionHtml({ title: 'ComfyUI', accent: '#7c3aed', copybutton, rows: 18, value: formatComfySummary(comfy), area: 'details' }));
        } else if (swarm) {
            cards.push(makeSectionHtml({ title: 'SwarmUI', accent: '#f59e0b', copybutton, rows: 18, value: formatSwarmSummary(swarm), area: 'details' }));
        } else if (novel) {
            cards.push(makeSectionHtml({ title: 'NovelAI', accent: '#06b6d4', copybutton, rows: 18, value: formatNovelAISummary(novel), area: 'details' }));
        } else {
            cards.push(makeSectionHtml({ title: 'Details', accent: '#94a3b8', copybutton, rows: 18, value: (others || ''), area: 'details' }));
        }
        cards.push(makeSectionHtml({ title: 'Prompt', accent: '#22c55e', copybutton, rows: 6, value: (novel?.prompt || positive), area: 'prompt' }));
        cards.push(makeSectionHtml({ title: 'Negative Prompt', accent: '#ef4444', copybutton, rows: 6, value: (novel?.negative || negative), area: 'negative' }));
        cards.push(makeSectionHtml({ title: 'Other info', accent: '#38bdf8', copybutton, rows: 6, value: others, area: 'other' }));

        container.innerHTML = `
<div class="_gm_simv_backdrop" role="dialog" aria-modal="true">
  <div class="_gm_simv_modal">
    <div class="_gm_simv_modal_title">
      <div>
        <div class="_gm_simv_modal_heading">Stable Diffusion image metadata</div>
        <div class="_gm_simv_modal_subheading">Extracted from embedded image metadata</div>
      </div>
      <button id="_gm_simv_closebutton" class="_gm_simv_icon_button" type="button" aria-label="Close">✕</button>
    </div>
    <div class="_gm_simv_modal_body">
      ${cards.join('')}
    </div>
  </div>
</div>`;

        document.body.appendChild(container);
        document.getElementById("_gm_simv_closebutton").addEventListener("click", closeModal);
        document.querySelectorAll("._gm_simv_copybutton").forEach(item => item.addEventListener("click", copyText));
    }

    function formatNovelAISummary(nai) {
        const lines = [];
        lines.push(`Software: ${nai.software || '-'}`);
        if (nai.source) lines.push(`Source: ${nai.source}`);
        if (nai.generationTime != null) lines.push(`Generation time: ${nai.generationTime}`);
        lines.push(`Seed: ${nai.seed ?? '-'}`);
        lines.push(`Steps: ${nai.steps ?? '-'}`);
        if (nai.width && nai.height) lines.push(`Size: ${nai.width} x ${nai.height}`);
        if (nai.sampler) lines.push(`Sampler: ${nai.sampler}`);
        if (nai.scale != null) lines.push(`Scale: ${nai.scale}`);
        if (nai.uncondScale != null) lines.push(`Uncond scale: ${nai.uncondScale}`);
        if (nai.cfgRescale != null) lines.push(`CFG rescale: ${nai.cfgRescale}`);
        if (nai.noiseSchedule) lines.push(`Noise schedule: ${nai.noiseSchedule}`);
        if (nai.requestType) lines.push(`Request type: ${nai.requestType}`);
        if (nai.strength != null) lines.push(`Strength: ${nai.strength}`);
        lines.push('');
        lines.push('Prompt:');
        lines.push(nai.prompt || '');
        lines.push('');
        lines.push('Negative:');
        lines.push(nai.negative || '');
        return lines.join("\n");
    }

    function formatComfySummary(comfy) {
        const lines = [];
        lines.push(`Checkpoint/UNet: ${comfy.checkpoint || '-'}`);
        if (comfy.clip) lines.push(`CLIP: ${comfy.clip}`);
        if (comfy.vae) lines.push(`VAE: ${comfy.vae}`);
        lines.push(`Seed: ${comfy.seed ?? '-'}`);
        lines.push(`Steps: ${comfy.steps ?? '-'}`);
        lines.push(`CFG: ${comfy.cfg ?? '-'}`);
        lines.push(`Sampler: ${comfy.sampler || '-'}`);
        lines.push(`Scheduler: ${comfy.scheduler || '-'}`);
        lines.push(`Denoise: ${comfy.denoise ?? '-'}`);
        lines.push('');
        lines.push('Positive:');
        lines.push(comfy.positive || '');
        lines.push('');
        lines.push('Negative:');
        lines.push(comfy.negative || '');
        return lines.join("\n");
    }

    function formatSwarmSummary(swarm) {
        const lines = [];
        lines.push(`Model: ${swarm.model || '-'}`);
        if (swarm.modelHash) lines.push(`Model hash: ${swarm.modelHash}`);
        lines.push(`Seed: ${swarm.seed ?? '-'}`);
        lines.push(`Steps: ${swarm.steps ?? '-'}`);
        lines.push(`CFG scale: ${swarm.cfg ?? '-'}`);
        lines.push(`Sampler: ${swarm.sampler || '-'}`);
        lines.push(`Scheduler: ${swarm.scheduler || '-'}`);
        if (swarm.width && swarm.height) lines.push(`Size: ${swarm.width} x ${swarm.height}`);
        if (swarm.aspect) lines.push(`Aspect: ${swarm.aspect}`);
        lines.push('');
        lines.push('Prompt:');
        lines.push(swarm.prompt || '');
        lines.push('');
        lines.push('Negative:');
        lines.push(swarm.negative || '');
        return lines.join("\n");
    }

    function addStyle() {
        GM_addElement("style", { textContent: `
#_gm_simv_container, #_gm_simv_container * { box-sizing: border-box; }
img { display: block; margin: auto; }
#_gm_simv_open_button {
    position: fixed; top: 12px; left: 12px; z-index: 2147483646;
    padding: 10px 12px; border-radius: 10px;
    border: 1px solid rgba(255,255,255,.22);
    background: rgba(17, 24, 39, .72); color: #f8fafc;
    font: 600 13px/1.1 ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, Arial;
    backdrop-filter: blur(10px); -webkit-backdrop-filter: blur(10px); cursor: pointer;
}
#_gm_simv_open_button:hover { background: rgba(17, 24, 39, .86); }
#_gm_simv_container { display: none; width: 100%; }
._gm_simv_backdrop {
    position: fixed; inset: 0; z-index: 2147483647;
    background: radial-gradient(1200px 700px at 20% 10%, rgba(99,102,241,.18), transparent 50%),
                radial-gradient(900px 600px at 80% 40%, rgba(34,197,94,.12), transparent 55%),
                rgba(0,0,0,.55);
    display: grid; place-items: center; padding: 16px;
}
._gm_simv_modal {
    width: min(1060px, 96vw); max-height: min(86vh, 980px);
    border-radius: 14px; border: 1px solid rgba(255,255,255,.14);
    background: rgba(17, 24, 39, .82);
    box-shadow: 0 24px 70px rgba(0,0,0,.60), 0 0 0 1px rgba(0,0,0,.25) inset;
    backdrop-filter: blur(18px); -webkit-backdrop-filter: blur(18px);
    overflow: hidden; color: #e5e7eb;
    font: 14px/1.35 ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, Arial;
}
._gm_simv_modal_title {
    display: flex; justify-content: space-between; align-items: start; gap: 12px;
    padding: 14px 14px 10px; border-bottom: 1px solid rgba(255,255,255,.10);
}
._gm_simv_modal_heading { font-weight: 700; font-size: 14px; letter-spacing: .2px; color: #f8fafc; }
._gm_simv_modal_subheading { margin-top: 2px; font-size: 12px; color: rgba(226,232,240,.78); }
._gm_simv_icon_button {
    cursor: pointer; border: 1px solid rgba(255,255,255,.14); background: rgba(2, 6, 23, .35);
    color: rgba(248,250,252,.92); width: 36px; height: 36px; border-radius: 10px;
    display: grid; place-items: center;
}
._gm_simv_icon_button:hover { background: rgba(2, 6, 23, .55); }
._gm_simv_modal_body {
    padding: 12px; display: grid; grid-template-columns: 1fr; gap: 12px;
    max-height: calc(min(86vh, 980px) - 64px); overflow: auto;
}
._gm_simv_card { background: rgba(2, 6, 23, .38); border: 1px solid rgba(255,255,255,.10); border-radius: 12px; overflow: hidden; min-width: 0; }
._gm_simv_card_header { display:flex; align-items:center; justify-content:space-between; gap:10px; padding:10px 10px; border-bottom: 1px solid rgba(255,255,255,.08); }
._gm_simv_card_title { font-weight: 700; color:#f8fafc; padding-left:10px; position:relative; }
._gm_simv_card_title::before { content:""; position:absolute; left:0; top:2px; bottom:2px; width:4px; border-radius:99px; background: var(--_gm_accent, #38bdf8); box-shadow: 0 0 0 1px rgba(0,0,0,.25) inset; }
._gm_simv_textarea {
    width:100%; display:block; resize:vertical; padding:10px;
    color: rgba(226,232,240,.95); background: rgba(148,163,184,.14); border:0; outline:none;
    font: 12.5px/1.35 ui-monospace, SFMono-Regular, Menlo, Consolas, "Liberation Mono", monospace;
    min-height:84px; max-height:60vh; overflow:auto;
}
._gm_simv_textarea:focus { background: rgba(148,163,184,.18); }
._gm_simv_copybutton {
    cursor:pointer; border:1px solid rgba(255,255,255,.14); background: rgba(2, 6, 23, .35);
    color: rgba(248,250,252,.92); padding:7px 10px; border-radius:10px; font-weight:600; font-size:12px;
}
._gm_simv_copybutton:hover { background: rgba(2, 6, 23, .55); }
._gm_simv_copybutton:active { transform: translateY(1px); }
@media (min-width: 880px) {
    ._gm_simv_modal_body {
        grid-template-columns: 1fr 1.1fr;
        grid-template-areas: "prompt details" "negative details" "other details" "stealth details";
        align-items: stretch;
    }
    ._gm_simv_modal_body > ._gm_simv_card[style*="grid-area:details"] { align-self: stretch; }
    ._gm_simv_modal_body > ._gm_simv_card[style*="grid-area:details"] ._gm_simv_textarea { height: calc(100% - 44px); max-height: none; resize: none; }
}
@media (max-width: 420px) {
    ._gm_simv_backdrop { padding: 10px; }
    ._gm_simv_modal_body { padding: 10px; }
    #_gm_simv_open_button { left: 10px; top: 10px; }
}
`});
    }

    function showModal() {
        const el = document.getElementById('_gm_simv_container');
        if (el) el.style.display = 'block';
    }
    function closeModal() { document.getElementById("_gm_simv_container").style.display = "none"; }
    function copyText() {
        const value = this.closest('section').querySelector("textarea").value;
        navigator.clipboard.writeText(value);
    }

})();