// ==UserScript== // @name あいもげ 原寸表示&メタデータドック // @namespace https://nijiurachan.net/ // @version 0.2.2 // @description 画像拡大・縮小・メタデータ表示機能 // @author 「」 // @match https://nijiurachan.net/b/*/ // @match https://nijiurachan.net/b/*/thread/* // @run-at document-start // @grant GM_xmlhttpRequest // @grant GM_setValue // @grant GM_getValue // @grant GM_registerMenuCommand // @grant GM_setClipboard // @grant unsafeWindow // @connect nijiurachan.net // @connect api.nijiurachan.net // @connect * // @license MIT // ==/UserScript== (() => { 'use strict'; const SCRIPT_ID = 'aimg-image-tools'; const SETTINGS_KEY = `${SCRIPT_ID}:settings`; const DOCK_KEY = `${SCRIPT_ID}:dock`; const DEFAULTS = { enabled: true, metadataEnabled: true, minImageSide: 140, expandedViewportWidthRatio: 0.8, expandedViewportHeightRatio: 0.7, debug: false, }; const settings = Object.assign({}, DEFAULTS, GM_getValue(SETTINGS_KEY, {})); const dockState = Object.assign( { left: null, top: 72, width: 560, height: 690 }, GM_getValue(DOCK_KEY, {}), ); const apiImageUrls = new Set(); const imageStates = new WeakMap(); const metadataButtons = new WeakMap(); const watchedImages = new WeakSet(); let currentImage = null; let toolbar = null; let dock = null; let hideTimer = 0; let scanTimer = 0; let saveDockTimer = 0; let routeActive = false; let lastPathname = location.pathname; let mutationObserver = null; let dockResizeObserver = null; const log = (...args) => { if (settings.debug) console.debug('[AIMG Image Tools]', ...args); }; function saveSettings() { GM_setValue(SETTINGS_KEY, settings); } function isThreadRoute() { return /^\/b\/[^/]+\/thread\/[^/?#]+\/?$/.test(location.pathname); } function registerMenus() { GM_registerMenuCommand( `画像ツール: ${settings.enabled ? 'ON' : 'OFF'}`, () => { settings.enabled = !settings.enabled; saveSettings(); location.reload(); }, ); GM_registerMenuCommand( `メタデータボタン: ${settings.metadataEnabled ? 'ON' : 'OFF'}`, () => { settings.metadataEnabled = !settings.metadataEnabled; saveSettings(); location.reload(); }, ); GM_registerMenuCommand(`拡大表示の横幅上限: ${Math.round(settings.expandedViewportWidthRatio * 100)}%`, () => { const value = prompt('拡大表示の横幅上限を、画面幅に対する割合(%)で入力してください。', String(Math.round(settings.expandedViewportWidthRatio * 100))); if (value === null) return; const next = Number.parseFloat(value); if (!Number.isFinite(next) || next < 20 || next > 100) { alert('20〜100 の数値を入力してください。'); return; } settings.expandedViewportWidthRatio = next / 100; saveSettings(); alert(`横幅上限を ${next}% に保存しました。再読込後に反映されます。`); }); GM_registerMenuCommand(`拡大表示の高さ上限: ${Math.round(settings.expandedViewportHeightRatio * 100)}%`, () => { const value = prompt('拡大表示の高さ上限を、画面高さに対する割合(%)で入力してください。', String(Math.round(settings.expandedViewportHeightRatio * 100))); if (value === null) return; const next = Number.parseFloat(value); if (!Number.isFinite(next) || next < 20 || next > 100) { alert('20〜100 の数値を入力してください。'); return; } settings.expandedViewportHeightRatio = next / 100; saveSettings(); alert(`高さ上限を ${next}% に保存しました。再読込後に反映されます。`); }); GM_registerMenuCommand('メタデータドック位置をリセット', () => { GM_setValue(DOCK_KEY, { left: null, top: 72, width: 560, height: 690 }); if (dock) { dock.style.left = ''; dock.style.right = '18px'; dock.style.top = '72px'; dock.style.width = '560px'; dock.style.height = '690px'; clampDockToViewport(); } }); GM_registerMenuCommand('検出した原寸候補URLをコンソール表示', () => { console.table([...apiImageUrls].map((url) => ({ url }))); alert(`${apiImageUrls.size}件を開発者コンソールへ出力しました。`); }); GM_registerMenuCommand(`デバッグログ: ${settings.debug ? 'ON' : 'OFF'}`, () => { settings.debug = !settings.debug; saveSettings(); location.reload(); }); } registerMenus(); if (!settings.enabled) return; installFetchTap(); function installFetchTap() { try { const pageWindow = typeof unsafeWindow !== 'undefined' ? unsafeWindow : window; const originalFetch = pageWindow.fetch; if (typeof originalFetch !== 'function' || originalFetch.__aimgTapped) return; const wrappedFetch = async function (...args) { const response = await originalFetch.apply(this, args); try { const requestUrl = String(args[0]?.url || args[0] || ''); const contentType = response.headers?.get?.('content-type') || ''; if ( requestUrl.includes('/api/threads/') && (contentType.includes('json') || requestUrl.includes('/state')) ) { response .clone() .json() .then((json) => collectImageUrls(json, requestUrl)) .catch(() => {}); } } catch (error) { log('fetch tap error', error); } return response; }; wrappedFetch.__aimgTapped = true; wrappedFetch.__aimgOriginal = originalFetch; pageWindow.fetch = wrappedFetch; } catch (error) { log('fetch tap installation failed', error); } } function collectImageUrls(value, baseUrl = location.href, depth = 0) { if (depth > 16 || value == null) return; if (typeof value === 'string') { const text = value.trim(); if (!text) return; const looksLikeImage = /\.(?:png|jpe?g|webp|gif|avif)(?:[?#].*)?$/i.test(text); const looksLikeMediaPath = /(?:image|media|upload|attachment|original|file)/i.test(text); if (looksLikeImage || (looksLikeMediaPath && /^(?:https?:|\/|\.\/|\.\.\/)/i.test(text))) { try { apiImageUrls.add(new URL(text, baseUrl).href); } catch {} } return; } if (Array.isArray(value)) { for (const item of value) collectImageUrls(item, baseUrl, depth + 1); return; } if (typeof value === 'object') { for (const child of Object.values(value)) collectImageUrls(child, baseUrl, depth + 1); } } function addStyle() { if (document.getElementById(`${SCRIPT_ID}-style`)) return; const style = document.createElement('style'); style.id = `${SCRIPT_ID}-style`; style.textContent = ` #${SCRIPT_ID}-toolbar { position: fixed; z-index: 2147483600; display: none; align-items: center; gap: 3px; padding: 3px; border: 1px solid rgba(255,255,255,.20); border-radius: 6px; background: rgba(20,24,32,.62); box-shadow: 0 2px 7px rgba(0,0,0,.24); backdrop-filter: blur(4px); -webkit-backdrop-filter: blur(4px); opacity: .72; transition: opacity .12s ease; user-select: none; } #${SCRIPT_ID}-toolbar:hover { opacity: 1; } #${SCRIPT_ID}-toolbar button { box-sizing: border-box; height: 23px; margin: 0; padding: 0 7px; border: 0; border-radius: 4px; color: #fff; background: rgba(255,255,255,.10); font: 600 11px/23px system-ui, sans-serif; white-space: nowrap; cursor: pointer; } #${SCRIPT_ID}-toolbar button:hover { background: rgba(255,255,255,.22); } #${SCRIPT_ID}-toolbar button:disabled { opacity: .55; cursor: progress; } #${SCRIPT_ID}-toolbar button[hidden] { display: none; } .${SCRIPT_ID}-meta-button { display: inline-flex !important; align-items: center; justify-content: center; box-sizing: border-box; min-width: 20px; height: 20px; margin: 0 0 0 5px !important; padding: 0 5px !important; border: 1px solid color-mix(in srgb, currentColor 28%, transparent) !important; border-radius: 5px !important; color: inherit !important; background: color-mix(in srgb, currentColor 7%, transparent) !important; font: 700 12px/18px system-ui, sans-serif !important; vertical-align: middle; opacity: .72; cursor: pointer; } .${SCRIPT_ID}-meta-button:hover { opacity: 1; background: color-mix(in srgb, currentColor 14%, transparent) !important; } #${SCRIPT_ID}-dock { position: fixed; z-index: 2147483610; display: flex; flex-direction: column; box-sizing: border-box; min-width: 320px; min-height: 280px; max-width: calc(100vw - 8px); max-height: calc(100vh - 8px); overflow: hidden; resize: both; border: 1px solid rgba(255,255,255,.20); border-radius: 10px; color: #edf2f7; background: #18202c; box-shadow: 0 10px 34px rgba(0,0,0,.46); font: 13px/1.5 system-ui, sans-serif; } #${SCRIPT_ID}-dock[hidden] { display: none; } #${SCRIPT_ID}-dock .aimg-dock-head { display: flex; align-items: center; gap: 8px; flex: 0 0 auto; padding: 8px 9px 8px 11px; border-bottom: 1px solid rgba(255,255,255,.12); background: rgba(255,255,255,.055); cursor: move; touch-action: none; user-select: none; } #${SCRIPT_ID}-dock .aimg-dock-title { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-weight: 700; } #${SCRIPT_ID}-dock button { border: 1px solid rgba(255,255,255,.17); border-radius: 6px; padding: 5px 8px; color: inherit; background: rgba(255,255,255,.08); font: inherit; cursor: pointer; } #${SCRIPT_ID}-dock button:hover { background: rgba(255,255,255,.16); } #${SCRIPT_ID}-dock .aimg-close { padding: 2px 8px; font-size: 16px; line-height: 20px; } #${SCRIPT_ID}-dock .aimg-dock-body { flex: 1 1 auto; min-height: 0; overflow: auto; padding: 10px; } #${SCRIPT_ID}-dock .aimg-field { margin: 0 0 10px; } #${SCRIPT_ID}-dock .aimg-label-row { display: flex; align-items: center; gap: 8px; margin: 0 0 4px; } #${SCRIPT_ID}-dock .aimg-label { flex: 1; min-width: 0; margin: 0; font-weight: 700; color: #dce8f5; } #${SCRIPT_ID}-dock .aimg-copy-inline { padding: 2px 7px !important; font-size: 11px !important; line-height: 18px !important; } #${SCRIPT_ID}-dock .aimg-value { box-sizing: border-box; min-height: 42px; margin: 0; padding: 8px 9px; white-space: pre-wrap; overflow-wrap: anywhere; border: 1px solid rgba(255,255,255,.10); border-radius: 7px; background: rgba(255,255,255,.045); font: 12px/1.55 ui-monospace, SFMono-Regular, Consolas, monospace; user-select: text; } #${SCRIPT_ID}-dock details { margin: 9px 0 0; border: 1px solid rgba(255,255,255,.11); border-radius: 7px; background: rgba(255,255,255,.035); } #${SCRIPT_ID}-dock summary { padding: 7px 9px; font-weight: 700; cursor: pointer; user-select: none; } #${SCRIPT_ID}-dock details pre { margin: 0; padding: 9px; white-space: pre-wrap; overflow-wrap: anywhere; border-top: 1px solid rgba(255,255,255,.09); font: 12px/1.5 ui-monospace, SFMono-Regular, Consolas, monospace; user-select: text; } #${SCRIPT_ID}-dock details textarea { box-sizing: border-box; width: calc(100% - 16px); min-height: 240px; margin: 0 8px 8px; resize: vertical; border: 1px solid rgba(255,255,255,.13); border-radius: 6px; padding: 8px; color: #e8edf2; background: #10161f; font: 11px/1.5 ui-monospace, SFMono-Regular, Consolas, monospace; } #${SCRIPT_ID}-dock .aimg-dock-foot { display: flex; align-items: center; gap: 7px; flex: 0 0 auto; padding: 7px 9px; border-top: 1px solid rgba(255,255,255,.12); background: rgba(255,255,255,.045); } #${SCRIPT_ID}-dock .aimg-status { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; opacity: .8; font-size: 11px; } #${SCRIPT_ID}-toast { position: fixed; z-index: 2147483620; left: 50%; bottom: 22px; transform: translateX(-50%); max-width: min(680px, 92vw); padding: 8px 12px; border-radius: 7px; color: #fff; background: rgba(24,32,44,.94); box-shadow: 0 5px 20px rgba(0,0,0,.35); font: 12px/1.45 system-ui, sans-serif; white-space: pre-wrap; pointer-events: none; } @media (max-width: 700px) { #${SCRIPT_ID}-toolbar { opacity: .9; } #${SCRIPT_ID}-dock { min-width: 280px; } } `; document.documentElement.appendChild(style); } function createToolbar() { toolbar = document.createElement('div'); toolbar.id = `${SCRIPT_ID}-toolbar`; toolbar.innerHTML = ` `; toolbar.addEventListener('pointerenter', cancelHide); toolbar.addEventListener('pointerleave', scheduleHide); toolbar.addEventListener('click', onToolbarClick, true); document.body.appendChild(toolbar); } function createDock() { dock = document.createElement('section'); dock.id = `${SCRIPT_ID}-dock`; dock.hidden = true; dock.innerHTML = `
画像メタデータ
prompt:
Negative prompt:
character prompt:
設定
生データ
`; applySavedDockGeometry(); dock.addEventListener('pointerdown', () => bringDockToFront(), true); dock.addEventListener('click', (event) => { const action = event.target.closest('button[data-action]')?.dataset.action; if (action === 'close' || action === 'close-bottom') closeDock(); if (action === 'copy-prompt') return copyDockField('prompt', 'promptをコピーしました。'); if (action === 'copy-negative') return copyDockField('negative', 'Negative promptをコピーしました。'); if (action === 'copy-character') return copyDockField('character', 'character promptをコピーしました。'); if (action === 'open') { const url = dock.dataset.imageUrl; if (url) window.open(url, '_blank', 'noopener,noreferrer'); } }); installDockDragging(); document.body.appendChild(dock); if (typeof ResizeObserver === 'function') { dockResizeObserver = new ResizeObserver(() => { if (dock.hidden) return; scheduleDockGeometrySave(); }); dockResizeObserver.observe(dock); } } function applySavedDockGeometry() { const width = clamp(Number(dockState.width) || 560, 320, Math.max(320, innerWidth - 8)); const height = clamp(Number(dockState.height) || 690, 280, Math.max(280, innerHeight - 8)); dock.style.width = `${width}px`; dock.style.height = `${height}px`; dock.style.top = `${clamp(Number(dockState.top) || 72, 4, Math.max(4, innerHeight - 80))}px`; if (Number.isFinite(Number(dockState.left))) { dock.style.left = `${clamp(Number(dockState.left), 4, Math.max(4, innerWidth - 80))}px`; dock.style.right = 'auto'; } else { dock.style.left = 'auto'; dock.style.right = '18px'; } } function installDockDragging() { const head = dock.querySelector('.aimg-dock-head'); let drag = null; head.addEventListener('pointerdown', (event) => { if (event.button !== 0 || event.target.closest('button')) return; const rect = dock.getBoundingClientRect(); drag = { pointerId: event.pointerId, startX: event.clientX, startY: event.clientY, left: rect.left, top: rect.top, }; dock.style.left = `${rect.left}px`; dock.style.top = `${rect.top}px`; dock.style.right = 'auto'; head.setPointerCapture?.(event.pointerId); event.preventDefault(); }); head.addEventListener('pointermove', (event) => { if (!drag || drag.pointerId !== event.pointerId) return; const rect = dock.getBoundingClientRect(); const nextLeft = clamp(drag.left + event.clientX - drag.startX, 4 - rect.width + 80, innerWidth - 80); const nextTop = clamp(drag.top + event.clientY - drag.startY, 4, innerHeight - 38); dock.style.left = `${Math.round(nextLeft)}px`; dock.style.top = `${Math.round(nextTop)}px`; }); const endDrag = (event) => { if (!drag || drag.pointerId !== event.pointerId) return; drag = null; scheduleDockGeometrySave(true); }; head.addEventListener('pointerup', endDrag); head.addEventListener('pointercancel', endDrag); } function scheduleDockGeometrySave(immediate = false) { clearTimeout(saveDockTimer); const save = () => { if (!dock || dock.hidden) return; const rect = dock.getBoundingClientRect(); GM_setValue(DOCK_KEY, { left: Math.round(rect.left), top: Math.round(rect.top), width: Math.round(rect.width), height: Math.round(rect.height), }); }; if (immediate) save(); else saveDockTimer = window.setTimeout(save, 250); } function clampDockToViewport() { if (!dock || dock.hidden) return; const rect = dock.getBoundingClientRect(); const width = Math.min(rect.width, Math.max(320, innerWidth - 8)); const height = Math.min(rect.height, Math.max(280, innerHeight - 8)); const left = clamp(rect.left, 4 - width + 80, innerWidth - 80); const top = clamp(rect.top, 4, innerHeight - 38); dock.style.width = `${Math.round(width)}px`; dock.style.height = `${Math.round(height)}px`; dock.style.left = `${Math.round(left)}px`; dock.style.top = `${Math.round(top)}px`; dock.style.right = 'auto'; } function bringDockToFront() { if (!dock) return; dock.style.zIndex = '2147483615'; requestAnimationFrame(() => { if (dock) dock.style.zIndex = '2147483610'; }); } function init() { addStyle(); createToolbar(); createDock(); document.addEventListener('pointerover', onPointerOver, true); document.addEventListener('pointerout', onPointerOut, true); document.addEventListener('keydown', (event) => { if (event.key === 'Escape' && dock && !dock.hidden) closeDock(); }); window.addEventListener('scroll', updateToolbarPosition, true); window.addEventListener('resize', () => { updateToolbarPosition(); clampDockToViewport(); }, { passive: true }); mutationObserver = new MutationObserver(() => { if (routeActive) scheduleThreadScan(); }); mutationObserver.observe(document.body, { childList: true, subtree: true }); window.addEventListener('popstate', checkRoute); window.setInterval(checkRoute, 450); checkRoute(true); } function checkRoute(force = false) { if (!force && location.pathname === lastPathname) return; lastPathname = location.pathname; const shouldActivate = isThreadRoute(); if (shouldActivate === routeActive && !force) return; routeActive = shouldActivate; if (routeActive) { scheduleThreadScan(true); } else { deactivateOnCatalog(); } } function deactivateOnCatalog() { cancelHide(); currentImage = null; if (toolbar) toolbar.style.display = 'none'; if (dock) dock.hidden = true; document.querySelectorAll(`.${SCRIPT_ID}-meta-button`).forEach((button) => button.remove()); } function scheduleThreadScan(immediate = false) { if (!routeActive) return; clearTimeout(scanTimer); if (immediate) scanThreadImages(); else scanTimer = window.setTimeout(scanThreadImages, 90); } function scanThreadImages() { if (!routeActive || !isThreadRoute()) return; for (const image of document.querySelectorAll('img')) { if (!watchedImages.has(image)) { watchedImages.add(image); if (!image.complete) image.addEventListener('load', () => scheduleThreadScan(true), { once: true }); } if (!isTargetImage(image)) continue; if (settings.metadataEnabled) ensureMetadataButton(image); } } function onPointerOver(event) { if (!routeActive || !isThreadRoute()) return; const image = event.target instanceof Element ? event.target.closest('img') : null; if (!isTargetImage(image)) return; showToolbar(image); } function onPointerOut(event) { if (!currentImage) return; const related = event.relatedTarget; if (related && (related === toolbar || toolbar.contains(related))) return; if (event.target === currentImage || currentImage.contains?.(event.target)) scheduleHide(); } function isTargetImage(image) { if (!(image instanceof HTMLImageElement)) return false; if (!routeActive || !isThreadRoute()) return false; if (image.closest(`#${SCRIPT_ID}-dock, #${SCRIPT_ID}-toolbar`)) return false; const src = image.currentSrc || image.src || ''; if (!src || /^data:image\/(?:svg|gif)/i.test(src)) return false; const hint = `${image.className || ''} ${image.alt || ''} ${image.closest('[class]')?.className || ''}`; if (/(?:avatar|icon|emoji|stamp|favicon|logo|reaction|profile)/i.test(hint)) return false; const rect = image.getBoundingClientRect(); const width = image.naturalWidth || image.width || rect.width; const height = image.naturalHeight || image.height || rect.height; return Math.max(width, height) >= settings.minImageSide && Math.min(width, height) >= 80; } function showToolbar(image) { cancelHide(); currentImage = image; toolbar.style.display = 'flex'; updateToolbarState(); updateToolbarPosition(); } function scheduleHide() { cancelHide(); hideTimer = window.setTimeout(() => { toolbar.style.display = 'none'; currentImage = null; }, 220); } function cancelHide() { if (hideTimer) clearTimeout(hideTimer); hideTimer = 0; } function updateToolbarPosition() { if (!routeActive || !currentImage || !currentImage.isConnected || toolbar.style.display === 'none') return; const rect = currentImage.getBoundingClientRect(); if (rect.bottom < 0 || rect.top > innerHeight || rect.right < 0 || rect.left > innerWidth) { toolbar.style.display = 'none'; return; } const toolbarRect = toolbar.getBoundingClientRect(); const top = Math.min(Math.max(rect.top + 6, 4), innerHeight - toolbarRect.height - 4); const left = Math.min(Math.max(rect.left + 6, 4), innerWidth - toolbarRect.width - 4); toolbar.style.top = `${Math.round(top)}px`; toolbar.style.left = `${Math.round(left)}px`; } function updateToolbarState() { if (!toolbar) return; const state = currentImage ? imageStates.get(currentImage) : null; const expand = toolbar.querySelector('[data-action="expand"]'); const restore = toolbar.querySelector('[data-action="restore"]'); const expanded = Boolean(state?.expanded); const loading = Boolean(state?.loading); expand.hidden = expanded; restore.hidden = !expanded; expand.disabled = loading; restore.disabled = loading; expand.textContent = loading ? '読込中…' : '拡大'; } async function onToolbarClick(event) { const button = event.target.closest('button[data-action]'); if (!button || !currentImage) return; const image = currentImage; event.preventDefault(); event.stopPropagation(); event.stopImmediatePropagation(); if (button.dataset.action === 'expand') await expandToOriginal(image); if (button.dataset.action === 'restore') restoreThumbnail(image); } function ensureImageState(image) { let state = imageStates.get(image); if (state) return state; const wrapper = image.closest('a') || image.parentElement; state = { expanded: false, loading: false, fetched: null, blobUrl: '', wrapper, original: { src: image.getAttribute('src'), srcset: image.getAttribute('srcset'), sizes: image.getAttribute('sizes'), style: image.getAttribute('style'), width: image.getAttribute('width'), height: image.getAttribute('height'), wrapperStyle: wrapper?.getAttribute('style') ?? null, }, }; imageStates.set(image, state); return state; } async function expandToOriginal(image) { const state = ensureImageState(image); if (state.loading || state.expanded) return; state.loading = true; updateToolbarState(); try { const fetched = await getOriginalImageData(image, state); const mime = normalizeImageMime(fetched.mime, fetched.buffer, fetched.url); if (state.blobUrl) URL.revokeObjectURL(state.blobUrl); state.blobUrl = URL.createObjectURL(new Blob([fetched.buffer], { type: mime })); image.removeAttribute('srcset'); image.removeAttribute('sizes'); image.src = state.blobUrl; await waitForImageLoad(image); const width = fetched.dimensions?.width || image.naturalWidth; const height = fetched.dimensions?.height || image.naturalHeight; const expandedSize = calculateExpandedDisplaySize(width, height); image.style.setProperty('width', expandedSize.width ? `${expandedSize.width}px` : 'auto', 'important'); image.style.setProperty('height', expandedSize.height ? `${expandedSize.height}px` : 'auto', 'important'); image.style.setProperty('max-width', `${expandedSize.width}px`, 'important'); image.style.setProperty('max-height', `${expandedSize.height}px`, 'important'); image.style.setProperty('object-fit', 'contain', 'important'); image.style.setProperty('display', 'block', 'important'); if (state.wrapper) { state.wrapper.style.setProperty('max-width', `${expandedSize.width}px`, 'important'); state.wrapper.style.setProperty('max-height', `${expandedSize.height}px`, 'important'); state.wrapper.style.setProperty('width', expandedSize.width ? `${expandedSize.width}px` : 'max-content', 'important'); state.wrapper.style.setProperty('height', expandedSize.height ? `${expandedSize.height}px` : 'auto', 'important'); state.wrapper.style.setProperty('overflow', 'visible', 'important'); state.wrapper.style.setProperty('display', 'inline-block', 'important'); } state.expanded = true; showToast(`原画像を画面内サイズで表示しました${width && height ? `(元画像 ${width} × ${height} / 表示 ${expandedSize.width} × ${expandedSize.height})` : ''}`); } catch (error) { console.error('[AIMG Image Tools] expand error', error); showToast(`原寸画像を取得できませんでした。\n${error?.message || error}`); } finally { state.loading = false; updateToolbarState(); requestAnimationFrame(updateToolbarPosition); } } function restoreThumbnail(image) { const state = imageStates.get(image); if (!state || !state.expanded) return; restoreAttribute(image, 'src', state.original.src); restoreAttribute(image, 'srcset', state.original.srcset); restoreAttribute(image, 'sizes', state.original.sizes); restoreAttribute(image, 'style', state.original.style); restoreAttribute(image, 'width', state.original.width); restoreAttribute(image, 'height', state.original.height); if (state.wrapper) restoreAttribute(state.wrapper, 'style', state.original.wrapperStyle); if (state.blobUrl) { URL.revokeObjectURL(state.blobUrl); state.blobUrl = ''; } state.expanded = false; updateToolbarState(); requestAnimationFrame(updateToolbarPosition); } function restoreAttribute(element, name, value) { if (value === null || value === undefined) element.removeAttribute(name); else element.setAttribute(name, value); } function calculateExpandedDisplaySize(width, height) { const rawWidth = Number(width) || 0; const rawHeight = Number(height) || 0; const viewportWidthLimit = Math.max(220, Math.floor(innerWidth * clamp(settings.expandedViewportWidthRatio || 0.8, 0.2, 1))); const viewportHeightLimit = Math.max(220, Math.floor(innerHeight * clamp(settings.expandedViewportHeightRatio || 0.7, 0.2, 1))); if (!rawWidth || !rawHeight) { return { width: viewportWidthLimit, height: viewportHeightLimit, }; } const ratio = Math.min(viewportWidthLimit / rawWidth, viewportHeightLimit / rawHeight, 1); return { width: Math.max(1, Math.round(rawWidth * ratio)), height: Math.max(1, Math.round(rawHeight * ratio)), }; } function copyDockField(fieldName, successMessage) { const text = dock?.querySelector(`[data-field="${fieldName}"]`)?.textContent?.trim() || ''; if (!text || text === '—' || text === '読み込み中…') { setDockStatus('コピーできる内容がありません。'); return; } GM_setClipboard(text, 'text'); setDockStatus(successMessage); } function waitForImageLoad(image) { if (image.complete && image.naturalWidth > 0) return Promise.resolve(); return new Promise((resolve, reject) => { const timer = setTimeout(() => cleanup(new Error('画像の表示がタイムアウトしました。')), 20000); const onLoad = () => cleanup(); const onError = () => cleanup(new Error('取得画像を表示できませんでした。')); const cleanup = (error) => { clearTimeout(timer); image.removeEventListener('load', onLoad); image.removeEventListener('error', onError); if (error) reject(error); else resolve(); }; image.addEventListener('load', onLoad, { once: true }); image.addEventListener('error', onError, { once: true }); }); } function ensureMetadataButton(image) { const oldButton = metadataButtons.get(image); if (oldButton?.isConnected) return; const placement = findMetadataPlacement(image); if (!placement) return; const button = document.createElement('button'); button.type = 'button'; button.className = `${SCRIPT_ID}-meta-button`; button.textContent = 'ⓘ'; button.title = '画像メタデータを表示'; button.__aimgImage = image; button.addEventListener('click', (event) => { event.preventDefault(); event.stopPropagation(); void showMetadata(button.__aimgImage); }); if (placement.after) placement.after.insertAdjacentElement('afterend', button); else placement.container.appendChild(button); metadataButtons.set(image, button); } function findMetadataPlacement(image) { let container = image.parentElement; for (let depth = 0; container && depth < 9; depth += 1, container = container.parentElement) { if (container === document.body) break; const placement = findPlacementInside(container, image); if (placement) return placement; } return null; } function findPlacementInside(container, image) { if (container.querySelectorAll('img').length > 12) return null; const elements = [...container.querySelectorAll('span,small,p,div,figcaption,a,button')]; const infoLines = elements .filter((element) => { if (element.closest(`#${SCRIPT_ID}-dock, #${SCRIPT_ID}-toolbar`)) return false; const text = normalizeText(element.textContent); if (!text || text.length > 320) return false; return /画像ファイル名|ファイル名[::]/.test(text); }) .sort((a, b) => normalizeText(a.textContent).length - normalizeText(b.textContent).length); if (infoLines.length) { const line = chooseIndexedCompanion(container, image, infoLines); const saves = [...line.querySelectorAll('a,button')].filter((el) => /保存|download/i.test(normalizeText(el.textContent) || el.getAttribute('download') || '')); return saves.length ? { after: saves[saves.length - 1], container: line } : { after: null, container: line }; } const saveElements = [...container.querySelectorAll('a,button')].filter((element) => { const text = normalizeText(element.textContent); const href = element.getAttribute('href') || ''; return /保存|download/i.test(text) || element.hasAttribute('download') || /\.(?:png|jpe?g|webp)(?:[?#]|$)/i.test(href) && /保存/.test(text); }); if (saveElements.length) { const save = chooseIndexedCompanion(container, image, saveElements); return { after: save, container: save.parentElement || container }; } return null; } function chooseIndexedCompanion(container, image, candidates) { if (candidates.length === 1) return candidates[0]; const images = [...container.querySelectorAll('img')].filter((item) => isTargetImage(item)); const index = Math.max(0, images.indexOf(image)); return candidates[Math.min(index, candidates.length - 1)] || candidates[0]; } function normalizeText(value) { return String(value || '').replace(/\s+/g, ' ').trim(); } async function showMetadata(image) { if (!(image instanceof HTMLImageElement) || !image.isConnected) return; openDock(); setDockLoading(image); try { const state = ensureImageState(image); const fetched = await getOriginalImageData(image, state); dock.dataset.imageUrl = fetched.url; setDockStatus('メタデータを解析しています…'); const parsed = await parseImageMetadata(fetched.buffer, fetched.mime, fetched.url); parsed.file.width = fetched.dimensions?.width || parsed.file.width || null; parsed.file.height = fetched.dimensions?.height || parsed.file.height || null; parsed.file.filename = filenameFromUrl(fetched.url); const enriched = enrichMetadata(parsed); populateDock(enriched); setDockStatus( `${formatBytes(fetched.buffer.byteLength)} / ${enriched.file?.format || fetched.mime || '不明'} / ${safeHostname(fetched.url)}`, ); } catch (error) { console.error('[AIMG Image Tools] metadata error', error); setDockError(error); } } function openDock() { dock.hidden = false; clampDockToViewport(); bringDockToFront(); } function closeDock() { if (!dock) return; scheduleDockGeometrySave(true); dock.hidden = true; dock.dataset.imageUrl = ''; } function setDockLoading(image) { dock.querySelector('.aimg-dock-title').textContent = `画像メタデータ — ${guessImageFilename(image) || '読み込み中'}`; setDockField('prompt', '読み込み中…'); setDockField('negative', '読み込み中…'); setDockField('character', '読み込み中…'); dock.querySelector('[data-field="settings"]').textContent = '読み込み中…'; dock.querySelector('[data-field="raw"]').value = ''; dock.querySelector('.aimg-settings').open = false; dock.querySelector('.aimg-raw').open = false; setDockStatus('原画像を取得しています…'); } function populateDock(result) { const extracted = result.extracted || {}; const settingsView = buildSettingsView(result); const rawText = JSON.stringify(result, null, 2); dock.querySelector('.aimg-dock-title').textContent = `画像メタデータ — ${result.file?.filename || '画像'}`; setDockField('prompt', formatPromptValue(extracted.prompt)); setDockField('negative', formatPromptValue(extracted.negativePrompt)); setDockField('character', formatCharacterPrompts(extracted.characterPrompt)); dock.querySelector('[data-field="settings"]').textContent = objectToReadableLines(settingsView) || '—'; dock.querySelector('[data-field="raw"]').value = rawText; dock.querySelector('.aimg-settings').open = false; dock.querySelector('.aimg-raw').open = false; } function setDockError(error) { const message = `取得または解析に失敗しました。\n${error?.message || error}`; setDockField('prompt', message); setDockField('negative', '—'); setDockField('character', '—'); dock.querySelector('[data-field="settings"]').textContent = '—'; dock.querySelector('[data-field="raw"]').value = ''; setDockStatus('失敗'); } function setDockField(name, value) { dock.querySelector(`[data-field="${name}"]`).textContent = value || '—'; } function setDockStatus(text) { dock.querySelector('.aimg-status').textContent = text; } function formatPromptValue(value) { if (value === undefined || value === null || value === '') return '—'; if (typeof value === 'string') return value; return JSON.stringify(value, null, 2); } function formatCharacterPrompts(value) { if (value === undefined || value === null || value === '') return '—'; const items = Array.isArray(value) ? value : [value]; return items .map((item, index) => { const text = typeof item === 'string' ? item : JSON.stringify(item, null, 2); return items.length > 1 ? `[Character ${index + 1}]\n${text}` : text; }) .join('\n\n'); } function buildSettingsView(result) { const e = result.extracted || {}; const file = result.file || {}; const view = { 形式: file.format || file.declaredMime || '不明', サイズ: file.width && file.height ? `${file.width} × ${file.height}` : undefined, ファイル容量: Number.isFinite(file.byteLength) ? formatBytes(file.byteLength) : undefined, モデル: e.model, Seed: e.seed, Steps: e.steps, Sampler: e.sampler, Scale: e.scale, CFGリスケール: e.cfgRescale, Strength: e.strength, Noise: e.noise, Software: e.software, ファイル名: file.filename, 取得元: file.url, }; return Object.fromEntries(Object.entries(view).filter(([, value]) => value !== undefined && value !== null && value !== '')); } function objectToReadableLines(object) { return Object.entries(object) .map(([key, value]) => `${key}: ${typeof value === 'string' ? value : JSON.stringify(value)}`) .join('\n'); } function buildImageCandidates(image) { const list = []; const add = (value, score, source) => { if (!value || typeof value !== 'string') return; const pieces = value.includes(',') && /\s\d+(?:w|x)(?:,|$)/.test(value) ? value.split(',').map((part) => part.trim().split(/\s+/)[0]) : [value]; for (const piece of pieces) { try { const url = new URL(piece, location.href).href; if (!/^(?:https?:|blob:|data:)/i.test(url)) continue; list.push({ url, score, source }); } catch {} } }; const postContainer = findLikelyPostContainer(image); if (postContainer) { const imageTokens = tokenizeUrl(image.currentSrc || image.src || ''); for (const element of postContainer.querySelectorAll('a[href]')) { const text = normalizeText(element.textContent); const href = element.href; const linkTokens = tokenizeUrl(href); const common = [...imageTokens].filter((token) => linkTokens.has(token)).length; const tokenBoost = Math.min(42, common * 14); if (element.hasAttribute('download') || /保存|download/i.test(text)) add(href, 112 + tokenBoost, '保存リンク'); else if (/\.(?:png|jpe?g|webp|gif|avif)(?:[?#]|$)/i.test(href)) add(href, 96 + tokenBoost, 'レス内画像リンク'); } } const anchor = image.closest('a[href]'); if (anchor) add(anchor.href, 118, '画像リンク先'); const dataKeys = [ 'original', 'originalSrc', 'full', 'fullSrc', 'fullsize', 'large', 'largeSrc', 'download', 'file', 'image', 'src', 'lazySrc', 'url', ]; for (const key of dataKeys) add(image.dataset?.[key], 112, `data-${key}`); for (const attr of image.attributes) { if (/(?:original|full|large|download|file|image|src|url)/i.test(attr.name)) { add(attr.value, 92, attr.name); } } const picture = image.closest('picture'); if (picture) { for (const source of picture.querySelectorAll('source[srcset]')) add(largestSrcsetUrl(source.srcset), 102, 'picture srcset'); } add(largestSrcsetUrl(image.srcset), 98, 'img srcset'); add(image.currentSrc, 72, 'currentSrc'); add(image.src, 68, 'src'); const pageTokens = tokenizeUrl(image.currentSrc || image.src || ''); for (const apiUrl of apiImageUrls) { let score = 55; const apiTokens = tokenizeUrl(apiUrl); const common = [...pageTokens].filter((token) => apiTokens.has(token)); score += Math.min(54, common.length * 13); if (/(?:original|full|download|raw)/i.test(apiUrl)) score += 14; add(apiUrl, score, 'API応答'); } for (const item of list) { if (/\.(?:png|jpe?g|webp|avif)(?:[?#]|$)/i.test(item.url)) item.score += 8; if (/(?:original|full|raw|download)/i.test(item.url)) item.score += 13; if (/(?:thumb|thumbnail|small|preview|resize)/i.test(item.url)) item.score -= 24; } const dedup = new Map(); for (const item of list) { const old = dedup.get(item.url); if (!old || item.score > old.score) dedup.set(item.url, item); } return [...dedup.values()].sort((a, b) => b.score - a.score); } function findLikelyPostContainer(image) { const direct = image.closest('[data-reply-id], [data-post-id], [data-response-id], [data-res-no], article, li, tr, .reply, .post, .response'); if (direct) return direct; let node = image.parentElement; for (let i = 0; node && i < 7; i += 1, node = node.parentElement) { const text = normalizeText(node.textContent); if (/画像ファイル名|ファイル名[::]|保存/.test(text) && node.querySelectorAll('img').length <= 8) return node; } return image.parentElement; } function largestSrcsetUrl(srcset) { if (!srcset) return ''; let best = null; for (const part of srcset.split(',')) { const [url, descriptor = '1x'] = part.trim().split(/\s+/); const value = Number.parseFloat(descriptor) || 1; if (!best || value > best.value) best = { url, value }; } return best?.url || ''; } function tokenizeUrl(value) { try { const url = new URL(value, location.href); return new Set( decodeURIComponent(`${url.pathname} ${url.search}`) .toLowerCase() .split(/[^a-z0-9_-]+/) .filter((token) => token.length >= 6 && !/^(?:thumbnail|original|image|media)$/.test(token)), ); } catch { return new Set(); } } async function getOriginalImageData(image, state = ensureImageState(image)) { if (state.fetched) return state.fetched; const candidates = buildImageCandidates(image); log('image candidates', candidates); state.fetched = await fetchBestImage(candidates, image); return state.fetched; } async function fetchBestImage(candidates, image) { if (!candidates.length) throw new Error('画像URL候補を取得できませんでした。'); const failures = []; const currentArea = Math.max(1, (image.naturalWidth || image.width || 1) * (image.naturalHeight || image.height || 1)); let best = null; let fetchedCount = 0; for (const candidate of candidates.slice(0, 14)) { if (fetchedCount >= 7 && best) break; try { const result = await fetchArrayBuffer(candidate.url); fetchedCount += 1; if (!isLikelyImage(result.buffer, result.mime)) { failures.push(`${candidate.source}: 画像ではない応答`); continue; } const dimensions = await getImageDimensions(result.buffer, result.mime); const area = Math.max(1, (dimensions?.width || 1) * (dimensions?.height || 1)); let score = candidate.score; if (area > currentArea) score += Math.min(60, Math.log2(area / currentArea) * 18); if (candidate.url === image.currentSrc || candidate.url === image.src) score -= 12; const item = { ...result, url: candidate.url, source: candidate.source, dimensions, score, area }; if (!best || item.score > best.score || (item.score === best.score && item.area > best.area)) best = item; const strongOriginal = candidate.score >= 120 && area >= currentArea; const clearlyLarger = area >= currentArea * 2.25; if (strongOriginal || clearlyLarger) return item; } catch (error) { failures.push(`${candidate.source}: ${error?.message || error}`); } } if (best) return best; throw new Error(`原寸画像を取得できませんでした。\n${failures.slice(0, 6).join('\n')}`); } async function getImageDimensions(buffer, mime) { const blob = new Blob([buffer], { type: normalizeImageMime(mime, buffer, '') }); if (typeof createImageBitmap === 'function') { try { const bitmap = await createImageBitmap(blob); const dimensions = { width: bitmap.width, height: bitmap.height }; bitmap.close?.(); return dimensions; } catch {} } return new Promise((resolve) => { const url = URL.createObjectURL(blob); const image = new Image(); const done = (value) => { URL.revokeObjectURL(url); resolve(value); }; image.onload = () => done({ width: image.naturalWidth, height: image.naturalHeight }); image.onerror = () => done(null); image.src = url; }); } function normalizeImageMime(mime, buffer, url) { const normalized = String(mime || '').split(';')[0].trim().toLowerCase(); if (normalized.startsWith('image/')) return normalized; const bytes = new Uint8Array(buffer, 0, Math.min(buffer.byteLength, 16)); if (matches(bytes, [0x89, 0x50, 0x4e, 0x47])) return 'image/png'; if (matches(bytes, [0xff, 0xd8, 0xff])) return 'image/jpeg'; if (ascii(bytes, 0, 4) === 'RIFF' && ascii(bytes, 8, 12) === 'WEBP') return 'image/webp'; const extension = /\.([a-z0-9]+)(?:[?#]|$)/i.exec(url || '')?.[1]?.toLowerCase(); return ({ png: 'image/png', jpg: 'image/jpeg', jpeg: 'image/jpeg', webp: 'image/webp', gif: 'image/gif', avif: 'image/avif' })[extension] || 'application/octet-stream'; } function filenameFromUrl(url) { try { const name = decodeURIComponent(new URL(url, location.href).pathname.split('/').pop() || ''); return name || 'image'; } catch { return 'image'; } } function guessImageFilename(image) { const candidates = buildImageCandidates(image); return filenameFromUrl(candidates[0]?.url || image.currentSrc || image.src || ''); } function safeHostname(url) { try { return new URL(url).hostname; } catch { return ''; } } function showToast(message) { document.getElementById(`${SCRIPT_ID}-toast`)?.remove(); const toast = document.createElement('div'); toast.id = `${SCRIPT_ID}-toast`; toast.textContent = String(message); document.body.appendChild(toast); setTimeout(() => toast.remove(), 3600); } function clamp(value, min, max) { return Math.min(max, Math.max(min, value)); } function fetchArrayBuffer(url) { if (/^(?:blob:|data:)/i.test(url)) { return fetch(url).then(async (response) => { if (!response.ok) throw new Error(`HTTP ${response.status}`); return { buffer: await response.arrayBuffer(), mime: response.headers.get('content-type') || '', }; }); } return new Promise((resolve, reject) => { GM_xmlhttpRequest({ method: 'GET', url, responseType: 'arraybuffer', timeout: 30000, anonymous: false, onload: (response) => { if (response.status < 200 || response.status >= 400) { reject(new Error(`HTTP ${response.status}`)); return; } const mime = /content-type:\s*([^;\r\n]+)/i.exec(response.responseHeaders || '')?.[1] || ''; resolve({ buffer: response.response, mime }); }, onerror: () => reject(new Error('ネットワークエラー')), ontimeout: () => reject(new Error('タイムアウト')), }); }); } function isLikelyImage(buffer, mime = '') { if (mime.toLowerCase().startsWith('image/')) return true; const bytes = new Uint8Array(buffer, 0, Math.min(buffer.byteLength, 16)); return ( matches(bytes, [0x89, 0x50, 0x4e, 0x47]) || matches(bytes, [0xff, 0xd8, 0xff]) || ascii(bytes, 0, 4) === 'RIFF' || ascii(bytes, 4, 8) === 'ftyp' ); } async function parseImageMetadata(buffer, declaredMime, url) { const bytes = new Uint8Array(buffer); const metadata = { file: { url, declaredMime: declaredMime || null, byteLength: buffer.byteLength, format: 'unknown', }, metadata: {}, }; if (matches(bytes, [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])) { metadata.file.format = 'PNG'; metadata.metadata = await parsePng(bytes); return metadata; } if (matches(bytes, [0xff, 0xd8, 0xff])) { metadata.file.format = 'JPEG'; metadata.metadata = parseJpeg(bytes); return metadata; } if (ascii(bytes, 0, 4) === 'RIFF' && ascii(bytes, 8, 12) === 'WEBP') { metadata.file.format = 'WebP'; metadata.metadata = parseWebP(bytes); return metadata; } if (ascii(bytes, 4, 8) === 'ftyp') { metadata.file.format = 'AVIF/HEIF'; metadata.metadata.note = 'AVIF/HEIFの埋め込みメタデータ解析は未対応です。'; return metadata; } metadata.metadata.note = '未対応の画像形式です。'; return metadata; } async function parsePng(bytes) { const result = { text: {}, chunks: [] }; let offset = 8; while (offset + 12 <= bytes.length) { const length = readU32BE(bytes, offset); const type = ascii(bytes, offset + 4, offset + 8); const start = offset + 8; const end = start + length; if (end + 4 > bytes.length) break; const data = bytes.slice(start, end); result.chunks.push({ type, length }); try { if (type === 'IHDR' && data.length >= 13) { result.width = readU32BE(data, 0); result.height = readU32BE(data, 4); result.bitDepth = data[8]; result.colorType = data[9]; } else if (type === 'tEXt') { const zero = data.indexOf(0); if (zero >= 0) { const key = decodeLatin1(data.slice(0, zero)); const value = decodeLatin1(data.slice(zero + 1)); assignRepeated(result.text, key, smartParseString(value)); } } else if (type === 'zTXt') { const zero = data.indexOf(0); if (zero >= 0 && data.length > zero + 2) { const key = decodeLatin1(data.slice(0, zero)); const compressed = data.slice(zero + 2); const inflated = await inflateBytes(compressed); assignRepeated(result.text, key, smartParseString(decodeLatin1(inflated))); } } else if (type === 'iTXt') { const parsed = await parseInternationalText(data); if (parsed) assignRepeated(result.text, parsed.key, smartParseString(parsed.value)); } else if (type === 'eXIf') { result.exif = parseExifPayload(data); } } catch (error) { result[`${type}_error`] = String(error?.message || error); } offset = end + 4; if (type === 'IEND') break; } return result; } async function parseInternationalText(data) { let pos = data.indexOf(0); if (pos < 0 || pos + 2 >= data.length) return null; const key = decodeUtf8(data.slice(0, pos)); const compressionFlag = data[pos + 1]; pos += 3; // NUL + flag + method const langEnd = data.indexOf(0, pos); if (langEnd < 0) return null; pos = langEnd + 1; const translatedEnd = data.indexOf(0, pos); if (translatedEnd < 0) return null; pos = translatedEnd + 1; let payload = data.slice(pos); if (compressionFlag === 1) payload = await inflateBytes(payload); return { key, value: decodeUtf8(payload) }; } async function inflateBytes(bytes) { if (typeof DecompressionStream !== 'function') { throw new Error('このブラウザは圧縮テキスト展開に未対応です。'); } const stream = new Blob([bytes]).stream().pipeThrough(new DecompressionStream('deflate')); return new Uint8Array(await new Response(stream).arrayBuffer()); } function parseJpeg(bytes) { const result = { comments: [], appSegments: [] }; let offset = 2; while (offset + 4 <= bytes.length) { if (bytes[offset] !== 0xff) { offset += 1; continue; } while (bytes[offset] === 0xff) offset += 1; const marker = bytes[offset++]; if (marker === 0xd9 || marker === 0xda) break; if (marker >= 0xd0 && marker <= 0xd7) continue; const length = readU16BE(bytes, offset); if (length < 2 || offset + length > bytes.length) break; const data = bytes.slice(offset + 2, offset + length); if (marker === 0xfe) result.comments.push(smartParseString(decodeUtf8(data))); if (marker === 0xe1) { if (ascii(data, 0, 6) === 'Exif\0\0') { result.exif = parseExifPayload(data.slice(6)); } else if (decodeLatin1(data.slice(0, 40)).includes('http://ns.adobe.com/xap/1.0/')) { const zero = data.indexOf(0); result.xmp = decodeUtf8(data.slice(zero + 1)); } } if ((marker >= 0xe0 && marker <= 0xef) || marker === 0xfe) { result.appSegments.push({ marker: `0x${marker.toString(16)}`, length: data.length }); } offset += length; } return result; } function parseWebP(bytes) { const result = { chunks: [] }; let offset = 12; while (offset + 8 <= bytes.length) { const type = ascii(bytes, offset, offset + 4); const length = readU32LE(bytes, offset + 4); const start = offset + 8; const end = start + length; if (end > bytes.length) break; const data = bytes.slice(start, end); result.chunks.push({ type, length }); if (type === 'EXIF') { result.exif = parseExifPayload(ascii(data, 0, 6) === 'Exif\0\0' ? data.slice(6) : data); } else if (type === 'XMP ') { result.xmp = decodeUtf8(data).replace(/\0+$/g, ''); } else if (type === 'VP8X' && data.length >= 10) { result.width = 1 + readU24LE(data, 4); result.height = 1 + readU24LE(data, 7); } offset = end + (length % 2); } return result; } function parseExifPayload(data) { try { if (data.length < 8) return { error: 'EXIFデータが短すぎます。' }; const little = ascii(data, 0, 2) === 'II'; if (!little && ascii(data, 0, 2) !== 'MM') return { error: 'TIFFヘッダーを認識できません。' }; const view = new DataView(data.buffer, data.byteOffset, data.byteLength); const u16 = (offset) => view.getUint16(offset, little); const u32 = (offset) => view.getUint32(offset, little); const firstIfd = u32(4); const output = {}; parseIfd(firstIfd, 'IFD0', output, view, little, data); const exifOffset = output._ExifIFDPointer; if (Number.isInteger(exifOffset)) parseIfd(exifOffset, 'ExifIFD', output, view, little, data); delete output._ExifIFDPointer; return output; } catch (error) { return { error: String(error?.message || error) }; } } const EXIF_TAGS = { 0x010e: 'ImageDescription', 0x010f: 'Make', 0x0110: 'Model', 0x0112: 'Orientation', 0x0131: 'Software', 0x0132: 'DateTime', 0x8298: 'Copyright', 0x8769: '_ExifIFDPointer', 0x9003: 'DateTimeOriginal', 0x9004: 'DateTimeDigitized', 0x9286: 'UserComment', 0xa002: 'PixelXDimension', 0xa003: 'PixelYDimension', 0x9c9b: 'XPTitle', 0x9c9c: 'XPComment', 0x9c9d: 'XPAuthor', 0x9c9e: 'XPKeywords', 0x9c9f: 'XPSubject', }; const TYPE_SIZES = { 1: 1, 2: 1, 3: 2, 4: 4, 5: 8, 7: 1, 9: 4, 10: 8 }; function parseIfd(offset, section, output, view, little, bytes) { if (offset < 0 || offset + 2 > view.byteLength) return; const count = view.getUint16(offset, little); output[section] ||= {}; for (let i = 0; i < count; i++) { const entry = offset + 2 + i * 12; if (entry + 12 > view.byteLength) break; const tag = view.getUint16(entry, little); const type = view.getUint16(entry + 2, little); const amount = view.getUint32(entry + 4, little); const size = (TYPE_SIZES[type] || 1) * amount; const valueOffset = size <= 4 ? entry + 8 : view.getUint32(entry + 8, little); if (valueOffset < 0 || valueOffset + size > view.byteLength) continue; const name = EXIF_TAGS[tag] || `Tag_0x${tag.toString(16).padStart(4, '0')}`; const value = readExifValue(view, bytes, valueOffset, type, amount, little, tag); if (name === '_ExifIFDPointer') output._ExifIFDPointer = Number(value); else output[section][name] = smartParseString(value); } if (!Object.keys(output[section]).length) delete output[section]; } function readExifValue(view, bytes, offset, type, count, little, tag) { if (type === 2) return decodeLatin1(bytes.slice(offset, offset + count)).replace(/\0+$/g, ''); if (type === 3) { const values = Array.from({ length: count }, (_, i) => view.getUint16(offset + i * 2, little)); return count === 1 ? values[0] : values; } if (type === 4) { const values = Array.from({ length: count }, (_, i) => view.getUint32(offset + i * 4, little)); return count === 1 ? values[0] : values; } if (type === 5) { const values = Array.from({ length: count }, (_, i) => { const a = view.getUint32(offset + i * 8, little); const b = view.getUint32(offset + i * 8 + 4, little); return b ? a / b : null; }); return count === 1 ? values[0] : values; } if (type === 7 || type === 1) { const raw = bytes.slice(offset, offset + count); if (tag >= 0x9c9b && tag <= 0x9c9f) return decodeUtf16LE(raw).replace(/\0+$/g, ''); if (tag === 0x9286) { const prefix = decodeLatin1(raw.slice(0, 8)); const body = raw.slice(8); if (prefix.startsWith('ASCII')) return decodeLatin1(body).replace(/\0+$/g, ''); if (prefix.startsWith('UNICODE')) return decodeUtf16BE(body).replace(/\0+$/g, ''); return decodeUtf8(raw).replace(/\0+$/g, ''); } const text = decodeUtf8(raw).replace(/\0+$/g, ''); return isMostlyPrintable(text) ? text : `[${raw.length} bytes]`; } if (type === 9) { const values = Array.from({ length: count }, (_, i) => view.getInt32(offset + i * 4, little)); return count === 1 ? values[0] : values; } return `[type=${type}, count=${count}]`; } function enrichMetadata(parsed) { const copy = structuredCloneSafe(parsed); copy.extracted = extractKnownFields(copy.metadata); return copy; } function extractKnownFields(root) { const scalarNodes = []; const characterPrompts = []; const walk = (value, path = [], depth = 0) => { if (depth > 24 || value == null) return; if (Array.isArray(value)) { value.forEach((item, index) => walk(item, [...path, String(index)], depth + 1)); return; } if (typeof value !== 'object') return; for (const [key, child] of Object.entries(value)) { const nextPath = [...path, key]; const keyLower = key.toLowerCase(); const pathLower = nextPath.join('.').toLowerCase(); if (isUsefulScalar(child)) { scalarNodes.push({ key: keyLower, path: pathLower, value: child, depth: nextPath.length }); const isCharacterKey = /^(?:character_?prompt|char_?prompt|char_?caption|character_?caption|characterprompt|charcaption)$/.test(keyLower); const promptInsideCharacter = /^(?:prompt|caption|positive|text)$/.test(keyLower) && /(?:character|characters|char_captions|charprompt)/.test(pathLower); const isNegativePath = /(?:negative|undesired|v4_negative|\.uc(?:\.|$))/.test(pathLower); if ((isCharacterKey || promptInsideCharacter) && !isNegativePath) characterPrompts.push(child); } walk(child, nextPath, depth + 1); } }; walk(root); const pick = (rules) => { let best = null; for (const node of scalarNodes) { let score = rules.keys[node.key] ?? -Infinity; if (!Number.isFinite(score)) continue; if (rules.requirePath && !rules.requirePath.test(node.path)) continue; if (rules.excludePath?.test(node.path)) score -= 160; if (rules.preferPath?.test(node.path)) score += 35; score -= node.depth * 2; if (!best || score > best.score) best = { value: node.value, score }; } return best?.value; }; const prompt = pick({ keys: { prompt: 130, positive_prompt: 125, positiveprompt: 125, base_caption: 92, description: 45 }, excludePath: /(?:negative|undesired|v4_negative|character|char_captions|\.uc(?:\.|$))/, preferPath: /(?:^|\.)(?:prompt|v4_prompt\.caption\.base_caption)$/, }); const negativePrompt = pick({ keys: { uc: 145, negative_prompt: 140, negativeprompt: 140, undesired_content: 135, base_caption: 90 }, preferPath: /(?:negative|undesired|v4_negative|\.uc(?:\.|$))/, }); const fieldRules = { seed: { seed: 130 }, steps: { steps: 130 }, sampler: { sampler: 130, sampler_name: 125 }, scale: { scale: 130, cfg_scale: 128, prompt_guidance: 125 }, cfgRescale: { cfg_rescale: 130, cfgrescale: 125 }, model: { model: 130, model_name: 128, source: 75 }, software: { software: 130 }, width: { width: 125, pixelxdimension: 120 }, height: { height: 125, pixelydimension: 120 }, strength: { strength: 130 }, noise: { noise: 130 }, }; const found = { prompt, negativePrompt }; for (const [canonical, keys] of Object.entries(fieldRules)) { const value = pick({ keys, excludePath: canonical !== 'model' ? /(?:prompt|character|char_captions)/ : null }); if (value !== undefined) found[canonical] = value; } const dedupedCharacters = []; const seen = new Set(); for (const value of characterPrompts) { const key = typeof value === 'string' ? value.trim() : JSON.stringify(value); if (!key || seen.has(key) || key === String(prompt || '').trim()) continue; seen.add(key); dedupedCharacters.push(value); } if (dedupedCharacters.length === 1) found.characterPrompt = dedupedCharacters[0]; else if (dedupedCharacters.length > 1) found.characterPrompt = dedupedCharacters; return Object.fromEntries(Object.entries(found).filter(([, value]) => value !== undefined)); } function smartParseString(value) { if (typeof value !== 'string') return value; const trimmed = value.trim().replace(/^\uFEFF/, ''); if (!trimmed) return ''; if ((trimmed.startsWith('{') && trimmed.endsWith('}')) || (trimmed.startsWith('[') && trimmed.endsWith(']'))) { try { return deepParseStrings(JSON.parse(trimmed)); } catch {} } return value; } function deepParseStrings(value, depth = 0) { if (depth > 12) return value; if (typeof value === 'string') return smartParseString(value); if (Array.isArray(value)) return value.map((item) => deepParseStrings(item, depth + 1)); if (value && typeof value === 'object') { for (const key of Object.keys(value)) value[key] = deepParseStrings(value[key], depth + 1); } return value; } function assignRepeated(object, key, value) { if (!(key in object)) object[key] = value; else if (Array.isArray(object[key])) object[key].push(value); else object[key] = [object[key], value]; } function structuredCloneSafe(value) { try { return structuredClone(value); } catch { return JSON.parse(JSON.stringify(value)); } } function isUsefulScalar(value) { return ['string', 'number', 'boolean'].includes(typeof value) && String(value).length > 0; } function stringifyInline(value) { return typeof value === 'string' ? value : JSON.stringify(value); } function formatBytes(bytes) { if (bytes < 1024) return `${bytes} B`; if (bytes < 1024 ** 2) return `${(bytes / 1024).toFixed(1)} KiB`; return `${(bytes / 1024 ** 2).toFixed(2)} MiB`; } function matches(bytes, signature) { return signature.every((value, index) => bytes[index] === value); } function ascii(bytes, start, end) { return String.fromCharCode(...bytes.slice(start, end)); } function decodeLatin1(bytes) { return new TextDecoder('latin1').decode(bytes); } function decodeUtf8(bytes) { return new TextDecoder('utf-8', { fatal: false }).decode(bytes); } function decodeUtf16LE(bytes) { return new TextDecoder('utf-16le', { fatal: false }).decode(bytes); } function decodeUtf16BE(bytes) { const swapped = new Uint8Array(bytes.length); for (let i = 0; i + 1 < bytes.length; i += 2) { swapped[i] = bytes[i + 1]; swapped[i + 1] = bytes[i]; } return decodeUtf16LE(swapped); } function isMostlyPrintable(text) { if (!text) return false; let printable = 0; for (const char of text) { const code = char.codePointAt(0); if (code === 9 || code === 10 || code === 13 || code >= 32) printable += 1; } return printable / text.length > 0.8; } function readU16BE(bytes, offset) { return (bytes[offset] << 8) | bytes[offset + 1]; } function readU32BE(bytes, offset) { return ( bytes[offset] * 0x1000000 + (bytes[offset + 1] << 16) + (bytes[offset + 2] << 8) + bytes[offset + 3] ) >>> 0; } function readU32LE(bytes, offset) { return ( bytes[offset] + bytes[offset + 1] * 0x100 + bytes[offset + 2] * 0x10000 + bytes[offset + 3] * 0x1000000 ) >>> 0; } function readU24LE(bytes, offset) { return bytes[offset] | (bytes[offset + 1] << 8) | (bytes[offset + 2] << 16); } if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', init, { once: true }); } else { init(); } })();