Greasy Fork is available in English.
Show Stable Diffusion generated image's metadata
// ==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); } })();