Stable Diffusion image metadata viewer

Show Stable Diffusion generated image's metadata

이 스크립트를 설치하려면 Tampermonkey, Greasemonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey와 같은 확장 프로그램을 설치해야 합니다.

이 스크립트를 설치하려면 Tampermonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey 또는 Userscripts와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 유저 스크립트 관리자 확장 프로그램이 필요합니다.

(이미 유저 스크립트 관리자가 설치되어 있습니다. 설치를 진행합니다!)

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

(이미 유저 스타일 관리자가 설치되어 있습니다. 설치를 진행합니다!)

// ==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);
    }

})();