画像リンクのメタデータを解析し、Prompt/UCの抽出表示とRawデータの切り替え、コピー機能を提供します
当前为
// ==UserScript== // @name AIMG Auto Image Metadata Scanner // @name:ja AIMG 自動メタデータスキャナー // @namespace https://nijiurachan.net/pc/catalog.php // @version 1.0 // @description 画像リンクのメタデータを解析し、Prompt/UCの抽出表示とRawデータの切り替え、コピー機能を提供します // @author doridoridoridorin // @match https://nijiurachan.net/pc/thread.php?id=* // @require https://cdnjs.cloudflare.com/ajax/libs/pako/2.1.0/pako.min.js // @require https://cdn.jsdelivr.net/npm/[email protected]/dist/exif-reader.min.js // @grant GM_xmlhttpRequest // @grant GM_setClipboard // @license MIT // ==/UserScript== (function() { 'use strict'; // ========================================== // 設定・定数定義 // ========================================== // NovelAIのステルス情報に含まれる識別子(マジックナンバー) const NOVELAI_MAGIC = "stealth_pngcomp"; // 解析対象とする画像の拡張子(リンクのhref末尾で判定) // PNG, WebP, JPEGに対応 const TARGET_EXTENSIONS = /\.(png|webp|jpe?g)$/i; // 同時に実行するHTTPリクエストの最大数 // ブラウザの負荷軽減とサーバーへの配慮のため制限を設ける const MAX_CONCURRENT_REQUESTS = 3; // ========================================== // リクエストキュー管理システム // ========================================== // ページ内に大量の画像リンクがある場合、一斉に通信すると詰まるため // 順番に処理を行うための仕組み const requestQueue = []; // 待機中のタスクリスト let activeRequests = 0; // 現在実行中のリクエスト数 /** * キューから次のタスクを取り出して実行する関数 */ function processQueue() { // 最大同時接続数に達しているか、キューが空なら何もしない if (activeRequests >= MAX_CONCURRENT_REQUESTS || requestQueue.length === 0) return; const task = requestQueue.shift(); // 先頭のタスクを取得 activeRequests++; task.onStart(); // UIを「解析中」に変更 // GM_xmlhttpRequestを使用してクロスオリジン制限を回避してバイナリデータを取得 GM_xmlhttpRequest({ method: "GET", url: task.url, responseType: "arraybuffer", // バイナリとして取得 onload: async (response) => { try { // 取得したデータを解析 const results = await analyzeImage(response.response); task.onSuccess(results); } catch (e) { task.onError(e); } finally { activeRequests--; processQueue(); // 次のタスクへ } }, onerror: (err) => { task.onError(err); activeRequests--; processQueue(); // エラーでも次へ進む } }); } /** * 新しい解析タスクをキューに追加する関数 * @param {string} url - 画像のURL * @param {object} callbacks - UI更新用のコールバック関数群 */ function addToQueue(url, callbacks) { requestQueue.push({ url, ...callbacks }); processQueue(); } // ========================================== // LSB (Least Significant Bit) 解析クラス // ========================================== // 画像のピクセルデータ(RGBA)のアルファチャンネルの最下位ビットに // 隠されたデータを抽出するためのクラス class LSBExtractor { /** * @param {Uint8ClampedArray} pixels - Canvasから取得したピクセルデータ * @param {number} width - 画像の幅 * @param {number} height - 画像の高さ */ constructor(pixels, width, height) { this.pixels = pixels; this.width = width; this.height = height; // 読み込み状態の管理 this.bitsRead = 0; // 現在のバイト内で読んだビット数 this.currentByte = 0; // 構築中のバイトデータ // 現在の読み取り位置(列優先探索用) this.row = 0; this.col = 0; } /** * 次の1ビットを読み取る * NovelAIは「列優先(Column-Major)」順序で埋め込んでいるため、 * 上から下へ、端まで行ったら右の列へ移動する動きをする */ getNextBit() { if (this.col >= this.width) return null; // 画像範囲外 // 現在のピクセルのインデックス計算 (RGBAなので4倍) const pixelIndex = (this.row * this.width + this.col) * 4; // Alphaチャンネル(index+3)の値を取得し、最下位ビット(& 1)を取り出す const bit = this.pixels[pixelIndex + 3] & 1; // 次の位置へ移動 (列優先) this.row++; if (this.row >= this.height) { this.row = 0; this.col++; } return bit; } /** * 8ビット読み込んで1バイト(整数)を返す */ getOneByte() { this.bitsRead = 0; this.currentByte = 0; while (this.bitsRead < 8) { const bit = this.getNextBit(); if (bit === null) return null; // データ終端 // 左シフトして新しいビットを末尾に追加 this.currentByte = (this.currentByte << 1) | bit; this.bitsRead++; } return this.currentByte; } /** * 指定したバイト数だけ読み込んでUint8Arrayで返す */ getNextNBytes(n) { const bytes = new Uint8Array(n); for (let i = 0; i < n; i++) { const byte = this.getOneByte(); if (byte === null) throw new Error("LSB: End of data"); bytes[i] = byte; } return bytes; } /** * 4バイト読み込んで32ビット整数(ビッグエンディアン)として解釈 * データ長情報の取得などに使用 */ readUint32BE() { const bytes = this.getNextNBytes(4); const view = new DataView(bytes.buffer); return view.getUint32(0, false); } } // ========================================== // 画像処理・解析ユーティリティ // ========================================== // ファイルシグネチャ定義 const PNG_SIGNATURE = [137, 80, 78, 71, 13, 10, 26, 10]; const WEBP_SIGNATURE = [82, 73, 70, 70]; // "RIFF" (WebPはRIFFコンテナ) /** * バイナリデータの先頭が指定のシグネチャと一致するか確認 */ function checkSignature(data, signature) { if (data.length < signature.length) return false; for (let i = 0; i < signature.length; i++) { if (data[i] !== signature[i]) return false; } return true; } /** * BlobデータをCanvasに描画してピクセルデータを取得する * ブラウザの画像デコード機能を利用するため、Blob経由でImageオブジェクトを作る */ async function getPixelsFromBlob(blob) { return new Promise((resolve, reject) => { const url = URL.createObjectURL(blob); const img = new Image(); // Canvas汚染(Tainted Canvas)を防ぐために匿名モードを設定 // ※GM_xmlhttpRequest経由のデータはBlob化されるため基本安全だが念のため img.crossOrigin = "Anonymous"; img.onload = () => { const canvas = document.createElement('canvas'); canvas.width = img.width; canvas.height = img.height; const ctx = canvas.getContext('2d'); if (!ctx) return reject(new Error('Canvas Context Error')); ctx.drawImage(img, 0, 0); URL.revokeObjectURL(url); // メモリ解放 try { // ピクセルデータを取得 (RGBA配列) const imageData = ctx.getImageData(0, 0, img.width, img.height); resolve({ pixels: imageData.data, width: img.width, height: img.height }); } catch (e) { reject(new Error("CORS or Canvas Error")); } }; img.onerror = () => { URL.revokeObjectURL(url); reject(new Error('Image Load Error')); }; img.src = url; }); } // ========================================== // メイン解析ロジック // ========================================== /** * 画像バイナリデータを受け取り、メタデータを解析して配列で返す */ async function analyzeImage(arrayBuffer) { const results = []; const uint8Data = new Uint8Array(arrayBuffer); // 1. ファイルタイプ判定 // WebPの場合、Blob作成時にMIMEタイプを指定しないとImage.srcで読み込めない場合があるため判定 let mimeType = ''; if (checkSignature(uint8Data, PNG_SIGNATURE)) { mimeType = 'image/png'; } else if (checkSignature(uint8Data, WEBP_SIGNATURE)) { mimeType = 'image/webp'; } // Blobを作成 (MIMEタイプがあれば指定) const blob = mimeType ? new Blob([uint8Data], { type: mimeType }) : new Blob([uint8Data]); // 2. 一般的なメタデータ解析 (ExifReader ライブラリ使用) // ここではA1111形式のPNGInfoや、標準的なExif/WebPメタデータを取得 try { // @ts-ignore (ライブラリの型定義回避) const tags = window.ExifReader.load(arrayBuffer); const generalData = {}; // Stable Diffusion (Automatic1111) 形式のパラメータ if (tags.parameters?.description) { generalData['Stable Diffusion'] = tags.parameters.description; } // NovelAI (V3以前など) のExif Comment if (tags.Comment?.description) { try { generalData['NovelAI (Exif)'] = JSON.parse(tags.Comment.description); } catch (e) { generalData['Comment'] = tags.Comment.description; } } // NovelAI (WebP形式) のUserComment // 一部のツールではバイナリヘッダがついているため除去処理を行う if (tags.UserComment?.description) { let cleanText = tags.UserComment.description.replace(/^UNICODE\x00/, '').replace(/^\x00+/, ''); try { // NovelAIの署名が含まれているか確認 if (cleanText.includes('"signed_hash":')) { const s = cleanText.indexOf('{'); const e = cleanText.lastIndexOf('}'); if (s !== -1 && e !== -1) { // JSON部分だけ切り出してパース generalData['NovelAI (WebP)'] = JSON.parse(cleanText.substring(s, e + 1)); } } else { generalData['UserComment'] = cleanText; } } catch (e) { generalData['UserComment'] = cleanText; } } // 使用ソフトウェア情報 if (tags.Software) generalData['Software'] = tags.Software.description; // データが見つかれば結果に追加 if (Object.keys(generalData).length > 0) { results.push({ type: 'Standard Metadata', content: generalData }); } } catch (e) { /* 解析エラーは無視して次へ */ } // 3. LSB解析 (NovelAI Stealth) // PNGまたはWebPと判定された場合のみ実行 if (mimeType) { try { // 画像をCanvasに描画してピクセルデータを取得 const { pixels, width, height } = await getPixelsFromBlob(blob); const extractor = new LSBExtractor(pixels, width, height); // マジックナンバーをチェックしてステルス情報の有無を確認 const magicBytes = extractor.getNextNBytes(NOVELAI_MAGIC.length); const magicString = new TextDecoder().decode(magicBytes); if (magicString === NOVELAI_MAGIC) { // データ長を取得 const dataLength = extractor.readUint32BE() / 8; // 圧縮されたデータを取得 const compressedData = extractor.getNextNBytes(dataLength); // gzip解凍 (pakoライブラリ使用) // @ts-ignore const decompressedData = window.pako.inflate(compressedData); // 文字列化してJSONパース const jsonString = new TextDecoder().decode(decompressedData); results.push({ type: 'NovelAI Stealth', content: JSON.parse(jsonString) }); } } catch (e) { // LSBデータがない、または破損している場合はここで終了 } } return results; } // ========================================== // プロンプト情報抽出・整形ロジック // ========================================== /** * 解析結果からプロンプト、ネガティブプロンプト、キャラクタープロンプトを抽出する * 優先順位: LSB > Exif > WebP > Stable Diffusion */ function extractPrompts(results) { let prompt = ""; let uc = ""; let charPrompt = []; let charUc = []; let found = false; // 各種ソースからのデータを検索 const lsbData = results.find(r => r.type === 'NovelAI Stealth'); const standardData = results.find(r => r.type === 'Standard Metadata'); /** * NovelAI形式のJSONからプロンプトを抜き出すヘルパー関数 * V3形式とV4形式(captionオブジェクト)の両方に対応 */ const parseNaiJson = (json) => { let content = json; // Exifなどの場合、JSONが文字列として格納されている場合があるためパースを試みる if (json.Comment && typeof json.Comment === 'string') { try { content = JSON.parse(json.Comment); } catch (e) {} } else if (json.Comment && typeof json.Comment === 'object') { content = json.Comment; } let p = content.prompt || ""; let u = content.uc || ""; // V4形式 (caption.base_caption) の対応 if (!p && content.v4_prompt?.caption?.base_caption) p = content.v4_prompt.caption.base_caption; if (!u && content.v4_negative_prompt?.caption?.base_caption) u = content.v4_negative_prompt.caption.base_caption; // キャラクター別プロンプトの取得 let cp = content.v4_prompt?.caption?.char_captions || []; let cu = content.v4_negative_prompt?.caption?.char_captions || []; return { p, u, cp, cu }; }; // 抽出処理実行 if (lsbData && lsbData.content) { // LSBデータが最優先 const extracted = parseNaiJson(lsbData.content); prompt = extracted.p; uc = extracted.u; charPrompt = extracted.cp; charUc = extracted.cu; found = true; } else if (standardData && standardData.content) { const content = standardData.content; if (content['NovelAI (Exif)']) { const extracted = parseNaiJson(content['NovelAI (Exif)']); prompt = extracted.p; uc = extracted.u; charPrompt = extracted.cp; charUc = extracted.cu; found = true; } else if (content['NovelAI (WebP)']) { const extracted = parseNaiJson(content['NovelAI (WebP)']); prompt = extracted.p; uc = extracted.u; charPrompt = extracted.cp; charUc = extracted.cu; found = true; } else if (content['Stable Diffusion']) { // A1111形式のテキストパース const raw = content['Stable Diffusion']; const negSplit = raw.split(/Negative prompt:/i); prompt = negSplit[0].trim(); if (negSplit[1]) { const stepSplit = negSplit[1].split(/Steps:/i); uc = stepSplit[0].trim(); } found = true; } } return { prompt, uc, found, charPrompt, charUc }; } // ========================================== // UI コンポーネント生成 // ========================================== // コピーアイコン(SVG)の生成 function createCopyIcon() { const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg"); svg.setAttribute("viewBox", "0 0 24 24"); svg.setAttribute("width", "14"); svg.setAttribute("height", "14"); svg.style.fill = "currentColor"; const path = document.createElementNS("http://www.w3.org/2000/svg", "path"); path.setAttribute("d", "M16 1H4C2.9 1 2 1.9 2 3V17H4V3H16V1ZM19 5H8C6.9 5 6 5.9 6 7V21C6 22.1 6.9 23 8 23H19C20.1 23 21 22.1 21 21V7C21 5.9 20.1 5 19 5ZM19 21H8V7H19V21Z"); svg.appendChild(path); return svg; } // 詳細表示ボックス(タブ付き)の生成 function createResultDetailBox(results) { const container = document.createElement('div'); Object.assign(container.style, { backgroundColor: 'rgba(20, 20, 20, 0.95)', color: '#eee', padding: '10px', fontSize: '12px', marginTop: '4px', border: '1px solid #444', borderRadius: '4px', maxWidth: '100%', overflowX: 'auto', textAlign: 'left', display: 'none', boxShadow: '0 4px 15px rgba(0,0,0,0.5)' }); const { prompt, uc, found, charPrompt, charUc } = extractPrompts(results); // --- タブヘッダー作成 --- const tabHeader = document.createElement('div'); tabHeader.style.display = 'flex'; tabHeader.style.borderBottom = '1px solid #555'; tabHeader.style.marginBottom = '10px'; const createTab = (text, isActive) => { const t = document.createElement('div'); t.textContent = text; Object.assign(t.style, { padding: '5px 10px', cursor: 'pointer', fontWeight: 'bold', borderBottom: isActive ? '2px solid #4CAF50' : '2px solid transparent', color: isActive ? '#fff' : '#888' }); return t; }; const tabPrompt = createTab('📝 Prompt', true); const tabRaw = createTab('📄 Raw Data', false); tabHeader.appendChild(tabPrompt); tabHeader.appendChild(tabRaw); container.appendChild(tabHeader); // --- View 1: Prompt & UC 表示エリア --- const viewPrompt = document.createElement('div'); // コピー可能なセクションを作成するヘルパー const createCopyableSection = (label, text) => { const wrapper = document.createElement('div'); wrapper.style.marginBottom = '10px'; const header = document.createElement('div'); header.style.display = 'flex'; header.style.alignItems = 'center'; header.style.marginBottom = '4px'; const title = document.createElement('span'); title.textContent = label; title.style.fontWeight = 'bold'; title.style.color = '#81C784'; title.style.marginRight = '8px'; const copyBtn = document.createElement('button'); copyBtn.appendChild(createCopyIcon()); Object.assign(copyBtn.style, { background: 'transparent', border: '1px solid #666', borderRadius: '3px', color: '#ccc', cursor: 'pointer', padding: '2px 6px', display: 'flex', alignItems: 'center' }); // クリックでクリップボードにコピー copyBtn.onclick = (e) => { e.preventDefault(); GM_setClipboard(text); // コピー成功時の視覚フィードバック(一時的に緑色にする) const originalColor = copyBtn.style.color; copyBtn.style.color = '#4CAF50'; copyBtn.style.borderColor = '#4CAF50'; setTimeout(() => { copyBtn.style.color = originalColor; copyBtn.style.borderColor = '#666'; }, 1000); }; header.appendChild(title); if (text) header.appendChild(copyBtn); const content = document.createElement('div'); content.textContent = text || "(None)"; Object.assign(content.style, { whiteSpace: 'pre-wrap', wordBreak: 'break-word', padding: '6px', backgroundColor: '#000', borderRadius: '3px', border: '1px solid #333', color: text ? '#ddd' : '#666', fontFamily: 'Consolas, monospace', fontSize: '11px', maxHeight: '150px', overflowY: 'auto' }); wrapper.appendChild(header); wrapper.appendChild(content); return wrapper; }; if (found) { viewPrompt.appendChild(createCopyableSection("Prompt", prompt)); viewPrompt.appendChild(createCopyableSection("Negative (UC)", uc)); // キャラクタープロンプトがあれば表示 for(let i = 0; charPrompt.length > i; i++) { viewPrompt.appendChild(createCopyableSection(`CharacterPrompt${i+1}`, charPrompt[i].char_caption || "")); viewPrompt.appendChild(createCopyableSection(`CharacterNegative${i+1}`, charUc[i].char_caption || "")); } } else { const noData = document.createElement('div'); noData.textContent = "プロンプト情報を自動抽出できませんでした。Raw Dataを確認してください。"; noData.style.color = '#aaa'; noData.style.padding = '10px'; viewPrompt.appendChild(noData); } container.appendChild(viewPrompt); // --- View 2: Raw Data 表示エリア --- const viewRaw = document.createElement('div'); viewRaw.style.display = 'none'; results.forEach(res => { const title = document.createElement('div'); title.textContent = `■ ${res.type}`; title.style.color = '#64B5F6'; title.style.fontWeight = 'bold'; title.style.marginTop = '10px'; title.style.borderBottom = '1px solid #444'; const contentPre = document.createElement('pre'); contentPre.style.whiteSpace = 'pre-wrap'; contentPre.style.wordBreak = 'break-all'; contentPre.style.margin = '5px 0 0 0'; contentPre.style.fontFamily = 'Consolas, monospace'; if (typeof res.content === 'object') { contentPre.textContent = JSON.stringify(res.content, null, 2); } else { contentPre.textContent = res.content; } viewRaw.appendChild(title); viewRaw.appendChild(contentPre); }); container.appendChild(viewRaw); // --- タブ切り替えロジック --- tabPrompt.onclick = (e) => { e.preventDefault(); e.stopPropagation(); viewPrompt.style.display = 'block'; viewRaw.style.display = 'none'; tabPrompt.style.borderBottomColor = '#4CAF50'; tabPrompt.style.color = '#fff'; tabRaw.style.borderBottomColor = 'transparent'; tabRaw.style.color = '#888'; }; tabRaw.onclick = (e) => { e.preventDefault(); e.stopPropagation(); viewPrompt.style.display = 'none'; viewRaw.style.display = 'block'; tabRaw.style.borderBottomColor = '#2196F3'; tabRaw.style.color = '#fff'; tabPrompt.style.borderBottomColor = 'transparent'; tabPrompt.style.color = '#888'; }; return container; } // ========================================== // UI アタッチメント(リンクへの機能追加) // ========================================== function attachScanner(anchor) { if (anchor.dataset.metaScannerAttached) return; // 既に処理済みならスキップ const href = anchor.href; const childImg = anchor.querySelector('img'); // 条件: hrefが画像ファイルであり、かつ画像タグを含んでいる if (!href || !TARGET_EXTENSIONS.test(href) || !childImg) return; anchor.dataset.metaScannerAttached = "true"; // ボタンなどを格納するコンテナ const uiContainer = document.createElement('div'); uiContainer.style.display = 'block'; uiContainer.style.marginTop = '2px'; uiContainer.style.textAlign = 'left'; uiContainer.style.lineHeight = '1'; // ステータス表示ボタン const btn = document.createElement('button'); btn.textContent = '⌛ ...'; Object.assign(btn.style, { fontSize: '11px', padding: '3px 6px', border: 'none', borderRadius: '3px', backgroundColor: '#ccc', color: '#333', cursor: 'wait' }); btn.disabled = true; uiContainer.appendChild(btn); // リンクの直後にUIを挿入 if (anchor.nextSibling) anchor.parentNode.insertBefore(uiContainer, anchor.nextSibling); else anchor.parentNode.appendChild(uiContainer); let detailBox = null; // 解析キューに追加 addToQueue(href, { onStart: () => { btn.textContent = '🔄 解析中...'; btn.style.backgroundColor = '#FFEB3B'; }, onSuccess: (results) => { if (results.length > 0) { // 解析成功・データあり btn.textContent = '✅ メタデータ'; btn.style.backgroundColor = '#4CAF50'; btn.style.color = 'white'; btn.style.cursor = 'pointer'; btn.disabled = false; detailBox = createResultDetailBox(results); uiContainer.appendChild(detailBox); // 開閉トグル処理 btn.onclick = (e) => { e.preventDefault(); e.stopPropagation(); if (detailBox.style.display === 'none') { detailBox.style.display = 'block'; btn.textContent = '🔼 閉じる'; } else { detailBox.style.display = 'none'; btn.textContent = '✅ メタデータ'; } }; } else { // データなし btn.textContent = '❌ 取得できませんでした'; btn.style.backgroundColor = 'transparent'; btn.style.color = '#999'; btn.style.opacity = '0.5'; btn.style.cursor = 'default'; } }, onError: (err) => { console.error(err); btn.textContent = '⚠️ エラー'; btn.style.backgroundColor = '#FFCDD2'; btn.style.color = '#D32F2F'; } }); } // ========================================== // 初期化・監視処理 // ========================================== function init() { // 既存のリンクに対して処理実行 const anchors = document.querySelectorAll('a'); anchors.forEach(attachScanner); // 動的に追加される要素(無限スクロールなど)を監視 const observer = new MutationObserver((mutations) => { mutations.forEach((mutation) => { mutation.addedNodes.forEach((node) => { if (node.nodeType === 1) { // 要素ノードの場合 if (node.tagName === 'A') attachScanner(node); else node.querySelectorAll('a').forEach(attachScanner); } }); }); }); observer.observe(document.body, { childList: true, subtree: true }); } // ページの読み込み完了を待って開始 if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init); else init(); })();