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