Files
2026-08-04 00:20:42 +01:00

2854 lines
123 KiB
JavaScript

/**
* script.js with Exif reader for PNG, JPEG and WebP, with Civitai integration for metadata extraction.
* (c) otacoo / otakudude / doublerunes, GPLv3
*/
// --- DOM element references ---
const uploadArea = document.getElementById('upload-area');
const fileInput = document.getElementById('image-input');
const imagePreview = document.getElementById('image-preview');
const metadataList = document.getElementById('metadata-list');
const positivePrompt = document.getElementById('positive-prompt');
const negativePrompt = document.getElementById('negative-prompt');
const promptInfoList = document.getElementById('prompt-info-list');
const modelInfoList = document.getElementById('model-info-list');
const warningSpan = document.getElementById('warning');
// When false, skip Civitai API lookups
var civitaiCheckingEnabled = true;
// --- Decode byte array as UTF-8 (Latin-1 fallback) ---
function decodeBytesToUtf8String(bytes) {
if (!bytes || !bytes.length) return '';
try {
if (typeof TextDecoder !== 'undefined') {
return new TextDecoder('utf-8', { fatal: false }).decode(bytes instanceof Uint8Array ? bytes : new Uint8Array(bytes));
}
} catch (e) { /* fall through */ }
var s = '';
for (var i = 0; i < bytes.length; i++) s += String.fromCharCode(bytes[i] & 0xff);
return s;
}
// --- PNG keywords to look for in tEXt/zTXt/iTXt chunks ---
function getPngKeywordsFromFormats() {
return [
'parameters',
'davant__batch_parameters',
'description',
'creation time',
'author',
'usercomment',
'creatortool',
'fooocus_scheme',
'invokeai_metadata',
'invokeai_graph',
'comment',
'title',
'software',
'source',
'result',
'prompt',
'workflow',
'generation_data',
'generation_time',
'camera_manufacturer',
'image_description'
];
}
// --- Get format field names ---
function getFormatFieldNames(formatName) {
var map = {
CivitAI: ['parameters'],
ComfyUI: ['prompt', 'workflow', 'generation_data'],
InvokeAI: ['invokeai_metadata', 'invokeai_graph'],
Midjourney: ['description']
};
return map[formatName] || [];
}
// --- Format field display label ---
function formatFieldDisplayLabel(fieldKey) {
var known = { prompt: 'Prompt', workflow: 'Workflow', generation_data: 'Generation data', invokeai_graph: 'InvokeAI graph', invokeai_metadata: 'InvokeAI metadata', parameters: 'Parameters', user_comment: 'User comment', comment: 'Comment', description: 'Description', title: 'Title', software: 'Software', source: 'Source', fooocus_scheme: 'Fooocus scheme', davant__batch_parameters: 'DAVANT batch parameters', creatortool: 'Creator tool', camera_manufacturer: 'Camera manufacturer', image_description: 'Image description' };
var lower = (fieldKey || '').toString().toLowerCase();
return known[lower] || (fieldKey + '').replace(/_/g, ' ').replace(/^\w/, function (c) { return c.toUpperCase(); });
}
// --- Helper: Set textarea value and auto-resize ---
function setAndResize(textarea, value) {
if (textarea) {
textarea.value = value;
autoResizeTextarea(textarea);
}
}
// --- Helper: Strip "Prompt:" or "Workflow:" prefix ---
function stripPromptPrefix(str) {
if (typeof str !== 'string') return str;
return str.replace(/^(prompt:|workflow:)/i, '').trim();
}
// --- Helper: Determine if a key should go to model-info-list, skip ComfyUI noise ---
function isModelInfoKey(key, value) {
if (!key) return false;
// Skip if value is missing, empty, or 'none'
if (
value === undefined ||
value === null ||
(typeof value === 'string' && value.trim().toLowerCase() === 'none') ||
(typeof value === 'string' && value.trim() === '')
) {
return false;
}
const normalized = key.trim().toLowerCase().replace(/\s+/g, '');
const nWithSpaces = key.trim().toLowerCase();
// Exclude ComfyUI / ComfyUI-like noise (weights, count, strength, etc.)
if (
/^lora(wt|weight|weights?|count|modelstrength|strength|clipstrength)/i.test(normalized) ||
/^lora\s*(wt|weight|count|model\s*strength|strength)/i.test(nWithSpaces) ||
/strength_model|strength_clip|loracount|lorawt/i.test(normalized)
) {
return false;
}
// Only allow: lora name, lora hashes, model/checkpoint name, model/ckpt hash, vae name, vae hash, unet
const modelKeys = [
'ckpt', 'ckpt_name', 'checkpoint', 'model', 'modelname', 'lora', 'lora_name', 'loraname',
'lorahashes', 'modelhash', 'model_hash', 'ckpt_hash', 'hash', 'vae', 'vae_name', 'vaename', 'vae_hash', 'vaehash',
'unet', 'unet_name', 'base_ckpt_name'
];
return (
modelKeys.includes(normalized) ||
/^model[_]?name$/.test(normalized) ||
/^vae[_]?name$/.test(normalized) ||
/^vae[_]?hash$/.test(normalized) ||
// lora_name or lora-name (but not lora_wt, lora_count, etc.)
/^lora[_\-]name$/i.test(normalized)
);
}
// --- CivitAI metadata: parameters chunk with extraMetadata (ComfyUI Civitai format) ---
function extractCivitAIMetadata(parsed) {
try {
const em = parsed.extraMetadata;
if (em == null) return null;
const md = typeof em === 'string' ? safeJsonParse(em) : em;
if (!md || typeof md !== 'object') return null;
const prompt = md.prompt != null ? String(md.prompt) : '';
const negative = md.negativePrompt != null ? String(md.negativePrompt) : '';
const extra = [];
if (md.steps != null) extra.push('Steps: ' + md.steps);
if (md.sampler) extra.push('Sampler: ' + md.sampler);
if (md.cfgScale != null) extra.push('CFG scale: ' + md.cfgScale);
if (md.seed != null) extra.push('Seed: ' + md.seed);
if (md.resources) extra.push('Civitai resources: ' + JSON.stringify(md.resources));
return { prompt, negative, extra: extra.join(', ') };
} catch (e) {
return null;
}
}
// --- ComfyUI: true if parsed has generation_data or a prompt graph (nodes with class_type) ---
function isComfyUIGraph(parsed) {
if (!parsed || typeof parsed !== 'object') return false;
if (parsed.generation_data) return true;
function hasClassTypeNodes(obj) {
return !!(obj && typeof obj === 'object' && Object.keys(obj).some(function (k) {
var n = obj[k];
return n && typeof n === 'object' && n.class_type;
}));
}
return hasClassTypeNodes(parsed.prompt) || hasClassTypeNodes(parsed);
}
// --- ComfyUI: prompt graph (class_type nodes) or generation_data ---
function extractComfyUIMetadata(parsed) {
const negativeKeywords = /bad quality|worst quality|low quality|bad anatomy|lowres/i;
const positiveKeywords = /masterpiece|absurdres|best quality|very aesthetic|1girl|2girls|3girls/i;
function getNodesValues(graph, classTypeRegex, fields, excludeNegative) {
if (!graph || typeof graph !== 'object') return [];
const out = [];
for (const key in graph) {
if (!graph.hasOwnProperty(key)) continue;
const node = graph[key];
if (!node || !classTypeRegex.test((node.class_type || '').toLowerCase())) continue;
const inputs = node.inputs;
if (!inputs) continue;
const vals = fields.map(function (f) { return inputs[f]; }).filter(function (v) { return typeof v === 'string' || typeof v === 'number'; });
vals.forEach(function (val) {
const str = String(val).replace(/_/g, ' ');
if (excludeNegative && negativeKeywords.test(str)) return;
if (!excludeNegative) {
if (!negativeKeywords.test(str)) return;
if (positiveKeywords.test(str)) return;
}
out.push(typeof val === 'number' ? String(val) : val);
});
}
return out;
}
function getFirst(graph, classTypeRegex, fields) {
const arr = getNodesValues(graph, classTypeRegex, fields, true);
return arr.length ? arr[0] : undefined;
}
try {
if (parsed.generation_data && typeof parsed.generation_data === 'string') {
const s = parsed.generation_data;
const end = s.lastIndexOf('}') + 1;
const md = end > 0 ? safeJsonParse(s.slice(0, end)) : null;
if (md && typeof md === 'object') {
const prompt = md.prompt != null ? String(md.prompt) : '';
const negative = (md.negativePrompt != null ? String(md.negativePrompt) : '') || (md.negative ? String(md.negative) : '');
const extra = [];
if (md.steps != null) extra.push('Steps: ' + md.steps);
if (md.samplerName) extra.push('Sampler: ' + md.samplerName);
if (md.cfgScale != null) extra.push('CFG scale: ' + md.cfgScale);
if (md.seed != null) extra.push('Seed: ' + md.seed);
if (md.width != null && md.height != null) extra.push('Size: ' + md.width + 'x' + md.height);
if (md.baseModel && (md.baseModel.modelFileName || md.baseModel.hash)) {
if (md.baseModel.hash) extra.push('Model hash: ' + md.baseModel.hash);
if (md.baseModel.modelFileName) extra.push('Model: ' + md.baseModel.modelFileName);
}
return { prompt, negative, extra: extra.join(', ') };
}
}
const p = parsed.prompt;
var graph = null;
if (p && typeof p === 'object') {
var ctc = Object.keys(p).some(function (k) {
var n = p[k];
return n && typeof n === 'object' && n.class_type;
});
if (ctc) graph = p;
}
if (!graph && typeof parsed === 'object') {
var ctc2 = Object.keys(parsed).some(function (k) {
var n = parsed[k];
return n && typeof n === 'object' && n.class_type;
});
if (ctc2) graph = parsed;
}
if (graph) {
const re = /cliptextencode|wildcard|textboxmira|eff\. loader|ttn text/i;
const posFields = ['text', 'positive', 'wildcard_text', 'clip_l', 't5xxl', 'string_a', 'string_b'];
const negFields = ['text', 'negative', 'wildcard_text'];
const positiveArr = getNodesValues(graph, re, posFields, true);
const negativeArr = getNodesValues(graph, re, negFields, false);
const prompt = positiveArr.join('\n');
const negative = negativeArr.join('\n');
const extra = [];
const steps = getFirst(graph, /scheduler|sampler/i, ['steps']);
const sampler = getFirst(graph, /scheduler|sampler/i, ['sampler_name']);
const cfg = getFirst(graph, /guidance|sampler|cliptextencode/i, ['guidance', 'cfg']);
const seed = getFirst(graph, /randomnoise|sampler|seed/i, ['noise_seed', 'seed']);
const w = getFirst(graph, /latentimage|loader/i, ['width', 'empty_latent_width']);
const h = getFirst(graph, /latentimage|loader/i, ['height', 'empty_latent_height']);
const model = getFirst(graph, /checkpoint|loader/i, ['ckpt_name', 'base_ckpt_name', 'unet_name']);
if (steps != null) extra.push('Steps: ' + steps);
if (sampler) extra.push('Sampler: ' + sampler);
if (cfg != null) extra.push('CFG scale: ' + cfg);
if (seed != null) extra.push('Seed: ' + seed);
if (w != null && h != null) extra.push('Size: ' + w + 'x' + h);
if (model) extra.push('Model: ' + model);
return { prompt, negative, extra: extra.join(', ') };
}
} catch (e) {
console.warn('ComfyUI extraction failed:', e);
}
return null;
}
// --- InvokeAI: keyword "invokeai_metadata" or "invokeai_graph" ---
function isInvokeAIKeyword(tag) {
if (tag == null || typeof tag !== 'string') return false;
var n = tag.toLowerCase().replace(/-/g, '_').trim();
return n === 'invokeai_metadata' || n === 'invokeai_graph';
}
// --- Midjourney: keyword "Description" with plain text only ---
function isMidjourneyKeyword(tag) {
if (tag == null || typeof tag !== 'string') return false;
return tag.trim().toLowerCase() === 'description';
}
// --- NovelAI: signed_hash+sampler or Software contains "NovelAI" ---
function isNovelAIMetadata(parsed) {
return parsed && typeof parsed === 'object' && parsed.hasOwnProperty('signed_hash') && parsed.hasOwnProperty('sampler');
}
function isNovelAISoftware(parsed) {
if (!parsed || typeof parsed !== 'object') return false;
var sw = parsed.software || parsed.Software;
return typeof sw === 'string' && sw.toLowerCase().indexOf('novelai') !== -1;
}
function isNovelAI(parsed) {
return isNovelAIMetadata(parsed) || isNovelAISoftware(parsed);
}
// --- Centralized prompt data distribution ---
function distributePromptData(parsed, comment, sourceTag) {
let found = false;
// Midjourney: keyword "Description" with plain text.
// NovelAI uses Description/Comment as JSON or has Software: "NovelAI".
if (isMidjourneyKeyword(sourceTag) && !isNovelAI(parsed)) {
var descText = (comment && typeof comment === 'string') ? comment : (parsed && typeof parsed === 'string') ? parsed : '';
setAndResize(positivePrompt, unescapePromptString(descText));
setAndResize(negativePrompt, '');
var additionalPromptsTextarea = document.getElementById('additional-prompts');
if (additionalPromptsTextarea) setAndResize(additionalPromptsTextarea, '');
if (promptInfoList) promptInfoList.innerHTML = '';
if (modelInfoList) modelInfoList.innerHTML = '';
if (descText.trim()) addMetadataItem('Description', descText, promptInfoList);
if (parsed && typeof parsed === 'object' && Object.keys(parsed).length > 0) {
addMetadataAsJsonBlock(parsed, promptInfoList, 'Midjourney', sourceTag);
}
setGenerationMetadataType('Midjourney');
clearWarning();
found = true;
}
if (!found && parsed && typeof parsed === 'object') {
// --- ComfyUI (prompt/graph or generation_data) ---
if (isComfyUIGraph(parsed)) {
found = true;
var comfy = extractComfyUIMetadata(parsed);
if (comfy) {
setAndResize(positivePrompt, unescapePromptString(comfy.prompt));
setAndResize(negativePrompt, unescapePromptString(comfy.negative));
var additionalPromptsTextarea = document.getElementById('additional-prompts');
if (additionalPromptsTextarea) setAndResize(additionalPromptsTextarea, '');
if (promptInfoList) promptInfoList.innerHTML = '';
if (modelInfoList) modelInfoList.innerHTML = '';
if (comfy.extra) addMetadataItem('Parameters', comfy.extra, promptInfoList);
addMetadataAsJsonBlock(parsed, promptInfoList, 'ComfyUI', sourceTag);
walkForModelInfo(parsed, function (k, v) { addModelInfoItem(k, v); });
var extraMetadataResources = [];
collectExtraMetadataResources(parsed, extraMetadataResources);
addCivitaiResourcesToModelInfo(extraMetadataResources);
} else {
addMetadataAsJsonBlock(parsed, promptInfoList, 'ComfyUI', sourceTag);
walkForModelInfo(parsed, function (k, v) { addModelInfoItem(k, v); });
var extraMetadataResources2 = [];
collectExtraMetadataResources(parsed, extraMetadataResources2);
addCivitaiResourcesToModelInfo(extraMetadataResources2);
}
setGenerationMetadataType('ComfyUI');
clearWarning();
}
// --- NovelAI / InvokeAI / Generic handlers ---
if (!found) {
if (isNovelAI(parsed)) {
// NovelAI alpha channel embeds data under a "Comment" key
var naiData = parsed;
if (parsed.Comment && typeof parsed.Comment === 'object' && isNovelAIMetadata(parsed.Comment)) {
naiData = parsed.Comment;
}
const positiveTexts = [];
if (naiData.prompt && typeof naiData.prompt === 'string') {
positiveTexts.push(naiData.prompt);
} else if (parsed.Description && typeof parsed.Description === 'string') {
positiveTexts.push(parsed.Description);
}
if (naiData.v4_prompt && naiData.v4_prompt.caption && Array.isArray(naiData.v4_prompt.caption.char_captions)) {
naiData.v4_prompt.caption.char_captions.forEach(charCap => {
if (charCap.char_caption && charCap.char_caption.trim()) {
positiveTexts.push(charCap.char_caption);
}
});
}
let negativeText = '';
if (naiData.uc && typeof naiData.uc === 'string') {
negativeText = naiData.uc;
} else if (naiData.v4_negative_prompt && naiData.v4_negative_prompt.caption && naiData.v4_negative_prompt.caption.base_caption) {
negativeText = naiData.v4_negative_prompt.caption.base_caption;
}
setAndResize(positivePrompt, positiveTexts.map(unescapePromptString).join('\n\n'));
setAndResize(negativePrompt, unescapePromptString(negativeText));
const additionalPromptsTextarea = document.getElementById('additional-prompts');
if (additionalPromptsTextarea) {
setAndResize(additionalPromptsTextarea, '');
}
if (promptInfoList) promptInfoList.innerHTML = '';
if (modelInfoList) modelInfoList.innerHTML = '';
const infoData = { ...parsed };
delete infoData.prompt;
delete infoData.uc;
delete infoData.v4_prompt;
delete infoData.v4_negative_prompt;
collectPromptInfo(infoData, function (key, value) {
if (!isModelInfoKey(key, value)) addMetadataItem(key, value, promptInfoList);
}, function (key, value) {
if (isModelInfoKey(key, value)) addModelInfoItem(key, value);
});
walkForModelInfo(parsed, addModelInfoItem);
setGenerationMetadataType('NovelAI');
clearWarning();
found = true;
} else if (isInvokeAIKeyword(sourceTag)) {
// --- InvokeAI (detected by PNG/WebP chunk keyword invokeai_metadata or invokeai_graph) ---
var meta = getValueIgnoreCase(parsed, 'invokeai_metadata');
if (meta == null || typeof meta !== 'object') meta = parsed;
var positiveParts = [];
if (typeof meta.positive_prompt === 'string' && meta.positive_prompt.trim()) {
positiveParts.push(meta.positive_prompt);
}
var valueStr = meta.value !== undefined ? meta.value : meta.Value;
if (typeof valueStr === 'string' && valueStr.trim()) {
positiveParts.push(valueStr);
}
var positiveText = positiveParts.map(unescapePromptString).join('\n\n');
var negativeText = typeof meta.negative_prompt === 'string' ? meta.negative_prompt : '';
setAndResize(positivePrompt, positiveText);
setAndResize(negativePrompt, unescapePromptString(negativeText));
var additionalPromptsTextarea = document.getElementById('additional-prompts');
if (additionalPromptsTextarea) setAndResize(additionalPromptsTextarea, '');
if (promptInfoList) promptInfoList.innerHTML = '';
if (modelInfoList) modelInfoList.innerHTML = '';
addMetadataAsJsonBlock(parsed, promptInfoList, 'InvokeAI', sourceTag);
walkForModelInfo(meta, function (k, v) { addModelInfoItem(k, v); });
setGenerationMetadataType('InvokeAI');
clearWarning();
found = true;
} else {
// --- ComfyUI (generic JSON handler) ---
// 1. Collect all "text" values (recursively) for positive and negative prompts
const positiveTexts = [];
const negativeTexts = [];
collectTextValuesWithNegatives(parsed, positiveTexts, negativeTexts);
// 2. Find wildcard_text, extraMetadata (prompt only), and extraMetadata.resources for Models
const wildcardTexts = [];
collectAllKeyValues(parsed, 'wildcard_text', wildcardTexts);
const extraMetadataPrompts = [];
const extraMetadataResources = [];
collectExtraMetadataPromptOnly(parsed, extraMetadataPrompts);
collectExtraMetadataResources(parsed, extraMetadataResources);
// Process extraMetadataPrompts: negative -> negativeTexts, prompt -> positive prompt (not additional)
for (let i = 0; i < extraMetadataPrompts.length; i++) {
const prompt = extraMetadataPrompts[i];
if (typeof prompt === 'string' && prompt.startsWith('__NEGATIVE__')) {
negativeTexts.push(prompt.substring('__NEGATIVE__'.length));
extraMetadataPrompts.splice(i, 1);
i--;
}
}
// extraMetadata "prompt" goes to positive prompt (e.g. JPEG UserComment / CivitAI)
extraMetadataPrompts.forEach(function (p) {
if (typeof p === 'string' && p.trim()) positiveTexts.push(p);
});
setAndResize(positivePrompt, positiveTexts.map(unescapePromptString).join('\n'));
setAndResize(negativePrompt, negativeTexts.map(unescapePromptString).join('\n'));
const additionalPromptsTextarea = document.getElementById('additional-prompts');
if (additionalPromptsTextarea) {
setAndResize(additionalPromptsTextarea, wildcardTexts.join('\n'));
}
// 3. Additional Info as JSON block; Models from walkForModelInfo + extraMetadata.resources (Civitai version IDs)
if (promptInfoList) promptInfoList.innerHTML = '';
if (modelInfoList) modelInfoList.innerHTML = '';
addMetadataAsJsonBlock(parsed, promptInfoList, 'ComfyUI', sourceTag);
walkForModelInfo(parsed, function (k, v) { addModelInfoItem(k, v); });
addCivitaiResourcesToModelInfo(extraMetadataResources);
setGenerationMetadataType('ComfyUI');
clearWarning();
found = true;
}
}
}
if (!found && comment && comment.trim()) {
clearWarning();
setGenerationMetadataType('A1111');
parseAndDisplayUserComment(comment);
} else if (!found) {
showWarning('❌ No metadata found');
}
}
// --- EXIF parser ---
function getExifData(file, callback) {
const reader = new FileReader();
reader.onload = function (e) {
const view = new DataView(e.target.result);
let offset = 2; // Skip SOI marker
let exifData = null;
let found = false;
try {
while (offset + 4 < view.byteLength) { // Ensure we can read marker + length
if (view.getUint8(offset) !== 0xFF) break;
const marker = view.getUint8(offset + 1);
// Make sure we can read the length
if (offset + 4 > view.byteLength) break;
const length = view.getUint16(offset + 2, false);
// Validate length to avoid invalid offsets
if (length < 2 || offset + 2 + length > view.byteLength) {
offset += 2;
continue;
}
if (marker === 0xE1) {
// Make sure we have enough bytes to check for "Exif" header
if (offset + 10 > view.byteLength) break;
// Check for "Exif" header
if (
view.getUint8(offset + 4) === 0x45 && // 'E'
view.getUint8(offset + 5) === 0x78 && // 'x'
view.getUint8(offset + 6) === 0x69 && // 'i'
view.getUint8(offset + 7) === 0x66 && // 'f'
view.getUint8(offset + 8) === 0x00 &&
view.getUint8(offset + 9) === 0x00
) {
// Try TIFF at offset 0 (standard), else scan for TIFF marker
let exifStart = offset + 10;
let exifLength = length - 8;
// Validate exifLength to avoid creating an invalid DataView
if (exifLength <= 0 || exifStart + exifLength > view.byteLength) {
offset += 2 + length;
continue;
}
let viewExif = new DataView(view.buffer, exifStart, exifLength);
let tiffOffset = 0;
// Make sure we can read the TIFF marker
if (viewExif.byteLength < 2) {
offset += 2 + length;
continue;
}
let tiffMarker = viewExif.getUint16(0, false);
if (tiffMarker !== 0x4949 && tiffMarker !== 0x4D4D) {
// Scan for TIFF marker in EXIF segment
tiffOffset = findTiffHeader(viewExif);
if (tiffOffset === -1) {
offset += 2 + length;
continue;
}
}
try {
exifData = parseExif(viewExif, tiffOffset);
found = true;
break;
} catch (parseErr) {
console.warn('Error parsing EXIF data:', parseErr);
}
}
}
offset += 2 + length;
}
} catch (err) {
console.error('Error reading EXIF data:', err);
}
callback(exifData || {});
};
reader.readAsArrayBuffer(file);
}
// Scan for TIFF header (0x4949 or 0x4d4d)
function findTiffHeader(view) {
for (let i = 0; i < view.byteLength - 1; i++) {
const marker = view.getUint16(i, false);
if (marker === 0x4949 || marker === 0x4d4d) {
return i;
}
}
return -1;
}
// Parse EXIF data from DataView starting at offset
function parseExif(view, start) {
const tiffOffset = start;
const tiffMarker = view.getUint16(tiffOffset, false);
console.log('TIFF marker at offset', tiffOffset, ':', tiffMarker.toString(16));
const littleEndian = tiffMarker === 0x4949;
const getUint16 = (o) => view.getUint16(o, littleEndian);
const getUint32 = (o) => view.getUint32(o, littleEndian);
if (tiffMarker !== 0x4949 && tiffMarker !== 0x4D4D) return {};
const firstIFDOffset = getUint32(tiffOffset + 4);
let tags = {};
readIFD(tiffOffset + firstIFDOffset, tags);
if (tags[0x8769]) {
readIFD(tiffOffset + tags[0x8769], tags);
}
const tagNames = {
0x9286: "UserComment",
0x010E: "ImageDescription",
0x010F: "Make",
0x271: "Prompt",
0x270: "Workflow"
};
let namedTags = {};
for (const tag in tags) {
const name = tagNames[tag] || tag;
namedTags[name] = tags[tag];
}
return namedTags;
function readIFD(dirStart, outTags) {
const entries = getUint16(dirStart);
for (let i = 0; i < entries; i++) {
const entryOffset = dirStart + 2 + i * 12;
const tag = getUint16(entryOffset);
const type = getUint16(entryOffset + 2);
const numValues = getUint32(entryOffset + 4);
let valueOffset = entryOffset + 8;
let value;
if (type === 2) { // ASCII / often UTF-8 in practice (e.g. ImageDescription with CJK)
const offset = numValues > 4 ? getUint32(valueOffset) + tiffOffset : valueOffset;
const arr = new Uint8Array(numValues - 1);
for (let n = 0; n < numValues - 1; n++) arr[n] = view.getUint8(offset + n);
value = decodeBytesToUtf8String(arr);
} else if (type === 7) { // UNDEFINED (UserComment) — plain text, often JSON-like
const offset = numValues > 4 ? getUint32(valueOffset) + tiffOffset : valueOffset;
const payloadLen = Math.max(0, numValues - 8);
const payload = new Uint8Array(payloadLen);
for (let n = 0; n < payloadLen; n++) payload[n] = view.getUint8(offset + 8 + n);
// Many writers store JSON as UTF-8 regardless of prefix; try UTF-8 first when it looks like JSON
const utf8Text = decodeBytesToUtf8String(payload).replace(/\0+$/, '').replace(/^\uFEFF/, '');
if ((utf8Text.trim().startsWith('{') || utf8Text.trim().startsWith('[')) && utf8Text.trim().length > 1) {
value = utf8Text;
} else {
const prefixBytes = [];
for (let n = 0; n < 8 && n < numValues; n++) {
prefixBytes.push(view.getUint8(offset + n));
}
const prefix = String.fromCharCode.apply(null, prefixBytes);
let text = '';
if (prefix.startsWith('ASCII')) {
for (let n = 8; n < numValues; n++) {
text += String.fromCharCode(view.getUint8(offset + n));
}
} else if (prefix.startsWith('UNICODE')) {
for (let n = 8; n + 1 < numValues; n += 2) {
const code = (view.getUint8(offset + n) << 8) | view.getUint8(offset + n + 1);
text += String.fromCharCode(code);
}
} else if (prefix.startsWith('UTF-8') || prefix.startsWith('UTF8')) {
text = decodeBytesToUtf8String(payload);
} else {
text = decodeBytesToUtf8String(payload);
}
value = text.replace(/\0+$/, '').replace(/^\uFEFF/, '');
}
} else {
value = getUint32(valueOffset);
}
outTags[tag] = value;
}
}
}
// Minimal getTag: get a tag by name from the exifData object
function getExifTag(exifData, tagName) {
return exifData[tagName] || null;
}
// --- EXIF parser END ---
// --- WEBP Parser ---
function getWebpExifData(file, callback) {
const reader = new FileReader();
reader.onload = function (e) {
const bytes = new Uint8Array(e.target.result);
// Check RIFF header
if (bytes[0] !== 0x52 || bytes[1] !== 0x49 || bytes[2] !== 0x46 || bytes[3] !== 0x46) {
callback(null); return;
}
let offset = 12; // Skip RIFF header and "WEBP"
while (offset + 8 < bytes.length) {
const chunkType = String.fromCharCode(
bytes[offset], bytes[offset + 1], bytes[offset + 2], bytes[offset + 3]
);
const chunkSize = bytes[offset + 4] | (bytes[offset + 5] << 8) | (bytes[offset + 6] << 16) | (bytes[offset + 7] << 24);
console.log('Found chunk:', chunkType, 'at offset', offset, 'size', chunkSize);
if (chunkType === "EXIF") {
const exifStart = offset + 8;
// Always create a DataView for the EXIF chunk (do NOT require "Exif\0\0")
const view = new DataView(bytes.buffer, exifStart, chunkSize);
console.log('EXIF chunk found at', exifStart, 'first 16 bytes:', Array.from({ length: 16 }, (_, i) => bytes[exifStart + i]));
console.log('First 16 bytes of EXIF chunk (DataView):', Array.from({ length: 16 }, (_, i) => view.getUint8(i)));
// Scan for TIFF header (0x4949 or 0x4d4d)
let tiffOffset = findTiffHeader(view);
if (tiffOffset === -1) {
console.log('TIFF header not found in EXIF chunk');
callback(null);
return;
}
const exifData = parseExif(view, tiffOffset);
console.log('TIFF header found at offset', tiffOffset);
callback(exifData || {});
return;
}
offset += 8 + chunkSize + (chunkSize % 2); // Chunks are padded to even sizes
}
callback(null);
};
reader.readAsArrayBuffer(file);
}
function getWebpExifTag(exifData, tagName) {
return exifData && exifData[tagName] ? exifData[tagName] : null;
}
// --- WEBP Parser END ---
// --- PNGMetadata class for extracting tEXt/iTXt/zTXt chunks from PNGs ---
class PNGMetadata {
constructor(data, mode = 'byte') {
// Accepts either a binary string or Uint8Array
if (mode === 'byte' && typeof data === 'string') {
this.bytes = new Uint8Array(data.length);
for (let i = 0; i < data.length; i++) {
this.bytes[i] = data.charCodeAt(i) & 0xff;
}
} else if (data instanceof Uint8Array) {
this.bytes = data;
} else {
throw new Error('PNGMetadata: data must be a binary string or Uint8Array');
}
this.chunks = this._parseChunks();
}
_parseChunks() {
const bytes = this.bytes;
let offset = 8;
const chunks = {};
while (offset < bytes.length) {
if (offset + 8 > bytes.length) break;
const length = this._readUint32(bytes, offset);
const type = this._readString(bytes, offset + 4, 4);
const dataStart = offset + 8;
const dataEnd = dataStart + length;
if (dataEnd > bytes.length) break;
const chunkData = bytes.slice(dataStart, dataEnd);
if (!chunks[type]) {
chunks[type] = { data_raw: [] };
}
// For tEXt/iTXt, decode as UTF-8 so CJK and other Unicode in metadata parse correctly; zTXt stores compressed data (decompressed in extractPngMetadata)
if (['tEXt', 'iTXt'].includes(type)) {
chunks[type].data_raw.push(decodeBytesToUtf8String(chunkData));
} else if (type === 'zTXt') {
chunks[type].data_raw.push(chunkData);
} else if (type === 'eXIf') {
// Store raw EXIF bytes for eXIf chunk
chunks[type].data_raw.push(chunkData);
} else {
chunks[type].data_raw.push(chunkData);
}
offset = dataEnd + 4; // skip CRC
}
return chunks;
}
_readUint32(bytes, offset) {
return (
(bytes[offset] << 24) |
(bytes[offset + 1] << 16) |
(bytes[offset + 2] << 8) |
bytes[offset + 3]
) >>> 0;
}
_readString(bytes, offset, length) {
let s = '';
for (let i = 0; i < length; i++) {
s += String.fromCharCode(bytes[offset + i]);
}
return s;
}
getChunks() {
return this.chunks;
}
}
// --- Page events and UI logic ---
document.addEventListener('DOMContentLoaded', () => {
clearPromptFields();
clearWarning();
// --- DOM element references ---
const toggleText = document.getElementById('additional-info-toggle-text');
const chevrons = document.querySelectorAll('.expand-toggle-row .chevron');
const additionalContainer = document.querySelector('.additional-container');
const toggleRow = document.getElementById('additional-info-toggle-row');
const fileInput = document.getElementById('image-input');
const stripMetadataBtn = document.getElementById('strip-metadata-btn');
const themeToggle = document.getElementById('theme-toggle');
const sunIcon = document.querySelector('.sun-icon');
const moonIcon = document.querySelector('.moon-icon');
// --- Theme toggle functionality ---
if (themeToggle) {
// Check for saved theme preference or use default
const savedTheme = localStorage.getItem('theme');
if (savedTheme === 'light') {
document.body.classList.add('light-mode');
if (sunIcon) sunIcon.style.display = 'none';
if (moonIcon) moonIcon.style.display = 'block';
}
// Toggle theme when button is clicked
themeToggle.addEventListener('click', function () {
document.body.classList.toggle('light-mode');
// Toggle icons
if (document.body.classList.contains('light-mode')) {
if (sunIcon) sunIcon.style.display = 'none';
if (moonIcon) moonIcon.style.display = 'block';
localStorage.setItem('theme', 'light');
} else {
if (sunIcon) sunIcon.style.display = 'block';
if (moonIcon) moonIcon.style.display = 'none';
localStorage.setItem('theme', 'dark');
}
});
}
// --- Civitai toggle: enable/disable model lookup and linkification ---
const civitaiToggle = document.getElementById('civitai-toggle');
if (civitaiToggle) {
const civitaiIconOn = civitaiToggle.querySelector('.civitai-icon-on');
const civitaiIconOff = civitaiToggle.querySelector('.civitai-icon-off');
var saved = localStorage.getItem('civitaiCheckingEnabled');
if (saved === 'false') civitaiCheckingEnabled = false;
civitaiToggle.setAttribute('aria-pressed', civitaiCheckingEnabled ? 'true' : 'false');
if (civitaiIconOn) civitaiIconOn.style.display = civitaiCheckingEnabled ? '' : 'none';
if (civitaiIconOff) civitaiIconOff.style.display = civitaiCheckingEnabled ? 'none' : 'block';
civitaiToggle.addEventListener('click', function () {
civitaiCheckingEnabled = !civitaiCheckingEnabled;
localStorage.setItem('civitaiCheckingEnabled', civitaiCheckingEnabled ? 'true' : 'false');
civitaiToggle.setAttribute('aria-pressed', civitaiCheckingEnabled ? 'true' : 'false');
if (civitaiIconOn) civitaiIconOn.style.display = civitaiCheckingEnabled ? '' : 'none';
if (civitaiIconOff) civitaiIconOff.style.display = civitaiCheckingEnabled ? 'none' : 'block';
if (fileInput && fileInput.files && fileInput.files[0]) {
fileInput.dispatchEvent(new Event('change', { bubbles: true }));
}
});
}
let expanded = false;
// --- Helper: Show/hide the toggle row and strip metadata button ---
function setToggleRowVisible(visible) {
if (toggleRow) toggleRow.style.display = visible ? 'flex' : 'none';
}
function setStripMetadataBtnVisible(visible) {
if (stripMetadataBtn) stripMetadataBtn.style.display = visible ? 'block' : 'none';
}
// --- Helper: Set expanded/collapsed state for additional info & code blocks ---
function setExpanded(state) {
expanded = !!state;
if (additionalContainer) {
additionalContainer.classList.toggle('expanded', expanded);
additionalContainer.classList.toggle('collapsed', !expanded);
}
if (toggleText) {
toggleText.textContent = expanded ? 'Hide additional info' : 'Show additional info';
toggleText.setAttribute('aria-expanded', expanded ? 'true' : 'false');
}
chevrons.forEach(chev => chev.classList.toggle('rotate', expanded));
}
// --- Expose for programmatic use if needed ---
window.showExpandedInfo = function () { setExpanded(true); };
window.hideExpandedInfo = function () { setExpanded(false); };
// --- Expose for prompt metadata control ---
window.setPromptInfoAvailable = function (hasPromptInfo) {
setToggleRowVisible(!!hasPromptInfo);
setStripMetadataBtnVisible(!!hasPromptInfo);
if (!hasPromptInfo) setExpanded(false);
};
setExpanded(false);
setToggleRowVisible(false); // Hide toggle row on page load
setStripMetadataBtnVisible(false); // Hide strip metadata button on page load
// --- Toggle expand/collapse on click or keyboard ---
if (toggleRow) {
toggleRow.addEventListener('click', (e) => {
if (
e.target === toggleText ||
e.target.classList.contains('chevron')
) {
setExpanded(!expanded);
}
});
if (toggleText) {
toggleText.addEventListener('keydown', (e) => {
if (e.key === 'Enter' || e.key === ' ') {
setExpanded(!expanded);
e.preventDefault();
}
});
}
}
// --- Hide both toggle row and strip metadata button on file input change ---
if (fileInput) {
fileInput.addEventListener('change', function () {
// --- Clear prompt fields and warning on new image selection ---
clearPromptFields();
clearWarning();
// --- Hide both until prompt metadata is found ---
setToggleRowVisible(false);
setStripMetadataBtnVisible(false);
setExpanded(false); // Optionally collapse when new image is selected
});
}
// --- Strip Metadata Button Logic ---
if (stripMetadataBtn) {
stripMetadataBtn.addEventListener('click', async function () {
clearWarning();
if (!fileInput.files || !fileInput.files[0]) {
showWarning('No image loaded.');
return;
}
const file = fileInput.files[0];
const confirmed = window.confirm('Are you sure you want to strip all metadata from this image? This will create a new copy without metadata.');
if (!confirmed) return;
try {
const cleanBlob = await stripImageMetadata(file);
if (!cleanBlob) {
showWarning('Failed to strip metadata.');
return;
}
// Offer download of the clean image
const url = URL.createObjectURL(cleanBlob);
const a = document.createElement('a');
a.href = url;
a.download = getStrippedFilename(file.name);
a.style.display = 'none';
document.body.appendChild(a);
a.click();
setTimeout(() => {
URL.revokeObjectURL(url);
a.remove();
}, 1000);
} catch (err) {
showWarning('Error stripping metadata: ' + err.message);
}
});
}
});
uploadArea.addEventListener('click', () => fileInput.click());
uploadArea.addEventListener('keydown', (e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
fileInput.click();
}
});
// Drag and drop events
// Highlight uploadArea when dragging files over the page
['dragenter', 'dragover'].forEach(event => {
document.addEventListener(event, (e) => {
e.preventDefault();
e.stopPropagation();
uploadArea.classList.add('dragover');
});
});
// Remove highlight when leaving the page or dropping
['dragleave', 'drop'].forEach(event => {
document.addEventListener(event, (e) => {
e.preventDefault();
e.stopPropagation();
uploadArea.classList.remove('dragover');
});
});
// Handle file drop anywhere on the page
document.addEventListener('drop', (e) => {
e.preventDefault();
e.stopPropagation();
// Only process if files are present
const files = e.dataTransfer.files;
if (files && files[0]) {
fileInput.files = files;
fileInput.dispatchEvent(new Event('change'));
}
});
fileInput.addEventListener('change', (e) => {
clearPromptFields(); // Clear before processing new image
clearWarning();
const file = fileInput.files[0];
if (file && isSupportedImage(file)) {
displayImagePreview(file);
extractAndDisplayMetadata(file);
if (file.type === 'image/jpeg' || file.type === 'image/jpg') {
extractUserCommentFromJPEG(file);
} else if (file.type === 'image/png') {
extractPngMetadata(file);
} else if (file.type === 'image/webp') {
extractUserCommentFromWebp(file);
} else {
showWarning('❌ No metadata found');
}
} else {
clearPreviewAndMetadata();
showWarning('❌ No metadata found');
alert('Please select a JPG, PNG or WEBP image.');
}
});
// --- UI clearing ---
function clearPromptFields() {
setAndResize(positivePrompt, '');
setAndResize(negativePrompt, '');
if (promptInfoList) promptInfoList.innerHTML = '';
if (modelInfoList) modelInfoList.innerHTML = '';
const additionalPromptsTextarea = document.getElementById('additional-prompts');
setAndResize(additionalPromptsTextarea, '');
}
function isSupportedImage(file) {
return (
file.type === 'image/png' ||
file.type === 'image/jpeg' ||
file.type === 'image/jpg' ||
file.type === 'image/webp'
);
}
function displayImagePreview(file) {
const reader = new FileReader();
reader.onload = function (e) {
imagePreview.src = e.target.result;
imagePreview.style.display = 'block';
};
reader.readAsDataURL(file);
}
function clearPreviewAndMetadata() {
imagePreview.src = '';
imagePreview.style.display = 'none';
metadataList.innerHTML = '';
}
function extractAndDisplayMetadata(file) {
// Clear previous metadata
metadataList.innerHTML = '';
// Basic metadata
const basicMetadata = [
{ label: 'File Name', value: file.name },
{ label: 'File Type', value: file.type },
{ label: 'File Size', value: formatBytes(file.size) }
];
// Get image dimensions
const reader = new FileReader();
reader.onload = function (e) {
const img = new Image();
img.onload = function () {
basicMetadata.push({ label: 'Width', value: img.width + ' px' });
basicMetadata.push({ label: 'Height', value: img.height + ' px' });
// Display basic metadata
for (const item of basicMetadata) {
addMetadataItem(item.label, item.value);
}
};
img.src = e.target.result;
};
reader.readAsDataURL(file);
}
function formatBytes(bytes) {
if (bytes === 0) return '0 Bytes';
const k = 1024;
const sizes = ['Bytes', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
}
// --- "Strip Metadata" Functionality ---
function getStrippedFilename(originalName) {
const dotIdx = originalName.lastIndexOf('.');
if (dotIdx === -1) return originalName + '_stripped';
return originalName.slice(0, dotIdx) + '_stripped' + originalName.slice(dotIdx);
}
async function stripImageMetadata(file) {
return new Promise((resolve, reject) => {
const img = new Image();
img.onload = function () {
try {
const canvas = document.createElement('canvas');
canvas.width = img.width;
canvas.height = img.height;
const ctx = canvas.getContext('2d');
ctx.drawImage(img, 0, 0);
let mimeType = file.type;
let quality = 0.92;
if (mimeType === 'image/jpeg' || mimeType === 'image/jpg') {
canvas.toBlob(resolve, 'image/jpeg', quality);
} else if (mimeType === 'image/png') {
canvas.toBlob(resolve, 'image/png');
} else if (mimeType === 'image/webp' && canvas.toBlob) {
canvas.toBlob(resolve, 'image/webp', quality);
} else {
canvas.toBlob(resolve, 'image/png');
}
} catch (err) {
reject(err);
}
};
img.onerror = function () {
reject(new Error('Failed to load image for metadata stripping.'));
};
const reader = new FileReader();
reader.onload = function (e) {
img.src = e.target.result;
};
reader.onerror = function () {
reject(new Error('Failed to read image file.'));
};
reader.readAsDataURL(file);
});
}
/////////////////////////////////////////////////////////////////////////////////
// --- Helper: Escape control characters inside JSON string literals (so JSON.parse can succeed) ---
function escapeControlCharsInJsonString(str) {
if (typeof str !== 'string') return str;
let result = '';
let inString = false;
let escapeNext = false;
let i = 0;
while (i < str.length) {
const c = str[i];
const code = c.charCodeAt(0);
if (escapeNext) {
result += c;
escapeNext = false;
i++;
continue;
}
if (inString) {
if (c === '\\') {
result += c;
escapeNext = true;
i++;
continue;
}
if (c === '"') {
result += c;
inString = false;
i++;
continue;
}
if (code < 32 || code === 127) {
if (code === 9) result += '\\t';
else if (code === 10) result += '\\n';
else if (code === 13) result += '\\r';
else result += '\\u' + ('0000' + code.toString(16)).slice(-4);
i++;
continue;
}
result += c;
i++;
continue;
}
if (c === '"') {
result += c;
inString = true;
i++;
continue;
}
result += c;
i++;
}
return result;
}
// --- Helper: JSON-like string parsers ---
function safeJsonParse(str) {
if (!str || typeof str !== 'string') {
console.warn('safeJsonParse: Input is not a string', str);
return null;
}
// Trim and strip BOM / zero-width chars (can cause "expected property name or '}'" at column 2)
let fixed = str.trim().replace(/[\u200B-\u200D\u2060\uFEFF]/g, '');
// Strip U+0000: EXIF UserComment is often UTF-16LE; if decoded as UTF-8 we get char+NUL between every char
fixed = fixed.replace(/\u0000/g, '');
// Strip control chars immediately after { or [ (e.g. from EXIF encoding)
fixed = fixed.replace(/^(\{|\[)[\x00-\x1f]+/g, '$1');
// Only try to parse if it looks like JSON
if (!(fixed.startsWith('{') || fixed.startsWith('['))) {
return null;
}
// Unescape double-backslash newlines
fixed = fixed.replace(/\\\\n/g, "\\n");
// Replace NaN with null (JSON does not support NaN)
fixed = fixed.replace(/\bNaN\b/g, 'null');
// Remove trailing commas before } or ]
fixed = fixed.replace(/,\s*([}\]])/g, '$1');
// Escape control chars inside string literals (e.g. in nested extraMetadata values)
fixed = escapeControlCharsInJsonString(fixed);
try {
return JSON.parse(fixed);
} catch (err) {
// Try one more approach - replace single quotes with double quotes
try {
const singleQuotesFixed = fixed.replace(/'/g, '"');
return JSON.parse(singleQuotesFixed);
} catch (err2) {
// Only log if both attempts failed
console.warn('safeJsonParse failed:', err2, fixed);
try {
// Last resort: try eval (with safety precautions)
// This is risky but might work for some non-standard JSON
if (fixed.match(/^[\s\n]*[\[\{].*[\}\]][\s\n]*$/)) {
const result = (new Function('return ' + fixed))();
if (typeof result === 'object' && result !== null) {
console.warn('Used Function constructor as fallback for JSON parsing');
return result;
}
}
} catch (err3) {
// Cut my life into pieces, this is my last resort
console.warn('All JSON parse attempts failed:', err3, fixed);
}
return null;
}
}
}
// Replace double-backslash n with real newline
function unescapePromptString(str) {
if (typeof str !== 'string') return '';
// Replace double-backslash n (\\n) and single-backslash n (\n) with real newline
return str
.replace(/\\\\n/g, "\n") // double-backslash n
.replace(/\\n/g, "\n") // single-backslash n
.replace(/\\\\/g, "\\"); // unescape any remaining double backslashes
}
// --- JPEG and WEBP metadata extraction with prompt distribution ---
function extractUserCommentFromJPEG(file) {
getExifData(file, function (exifData) {
console.log('EXIF DATA:', exifData);
let userComment = getExifTag(exifData, "UserComment");
let modelComment = getExifTag(exifData, "Model") || exifData[272];
let makeComment = getExifTag(exifData, "Make");
let imageDescription = getExifTag(exifData, "ImageDescription") || exifData[0x010E] || exifData[270];
let comment = null;
let sourceTag = null;
if (userComment && typeof userComment === 'string' && userComment.trim() !== '') {
comment = userComment;
sourceTag = 'UserComment';
} else if (modelComment && typeof modelComment === 'string' && modelComment.trim() !== '') {
comment = modelComment;
sourceTag = 'Model';
} else if (makeComment && typeof makeComment === 'string' && makeComment.trim() !== '') {
comment = makeComment;
sourceTag = 'Make';
} else if (imageDescription && typeof imageDescription === 'string' && imageDescription.trim() !== '') {
comment = imageDescription;
sourceTag = 'ImageDescription';
}
console.log('Selected comment source:', sourceTag, 'Value:', comment);
if (
comment &&
typeof comment === 'string' &&
comment.trim().startsWith('UNICODE')
) {
setExpanded(true);
}
if (comment) {
window.setPromptInfoAvailable(true);
let jsonStr = stripPromptPrefix(comment.trim());
let parsed = safeJsonParse(jsonStr);
distributePromptData(parsed, comment, sourceTag);
// Auto-expand the additional info section for JPEGs with metadata
if (typeof window.showExpandedInfo === 'function') {
window.showExpandedInfo();
} else if (typeof setExpanded === 'function') {
setExpanded(true);
}
} else {
showWarning('❌ No metadata found');
window.setPromptInfoAvailable(false);
}
});
}
// --- Read WEBP metadata for UserComment with parsing and negative prompt detection ---
function extractUserCommentFromWebp(file) {
getWebpExifData(file, function (exifData) {
console.log('WEBP EXIF DATA:', exifData);
let candidate = null;
let sourceTag = null;
if (exifData) {
let userComment = getWebpExifTag(exifData, "UserComment") || exifData[37510];
let modelComment = getWebpExifTag(exifData, "Model") || exifData[272];
let makeComment = getWebpExifTag(exifData, "Make") || exifData[271];
let imageDesc = getWebpExifTag(exifData, "ImageDescription") || exifData[270];
if (userComment && typeof userComment === 'string' && userComment.trim() !== '') {
candidate = userComment;
sourceTag = 'UserComment';
} else if (modelComment && typeof modelComment === 'string' && modelComment.trim() !== '') {
candidate = modelComment;
sourceTag = 'Model';
} else if (makeComment && typeof makeComment === 'string' && makeComment.trim() !== '') {
candidate = makeComment;
sourceTag = 'Make';
} else if (imageDesc && typeof imageDesc === 'string' && imageDesc.trim() !== '') {
candidate = imageDesc;
sourceTag = 'ImageDescription';
}
window.setPromptInfoAvailable(true);
}
console.log('Selected WebP comment source:', sourceTag, 'Value:', candidate);
if (candidate) {
let jsonStr = stripPromptPrefix(candidate.trim());
let parsed = safeJsonParse(jsonStr);
distributePromptData(parsed, candidate, sourceTag);
} else {
let comment = getWebpExifTag(exifData, "UserComment");
if (comment && comment.trim()) {
console.log('Using WebP UserComment fallback:', comment);
clearWarning();
// Try JSON first (e.g. InvokeAI metadata in WebP UserComment)
let jsonStr = stripPromptPrefix(comment.trim());
let parsed = safeJsonParse(jsonStr);
if (parsed && typeof parsed === 'object') {
distributePromptData(parsed, comment, 'UserComment');
} else {
parseAndDisplayUserComment(comment);
}
} else {
window.setPromptInfoAvailable(false);
showWarning('❌ No metadata found');
}
}
});
}
// --- NovelAI Alpha Channel Metadata Extraction ---
function hasImageAlpha(imageData) {
const data = imageData.data;
for (let i = 3; i < data.length; i += 4) {
if (data[i] < 255) return true;
}
return false;
}
function bitsToText(bits) {
const bytes = new Uint8Array(bits.match(/\d{8}/g).map(function(b) { return parseInt(b, 2); }));
return new TextDecoder().decode(bytes);
}
async function decompressZlibData(uint8data) {
if (typeof DecompressionStream === 'undefined') {
throw new Error('DecompressionStream not available');
}
// Stealth metadata uses gzip compression (Python's gzip.compress)
var options = [
{ data: uint8data, format: 'gzip' },
{ data: uint8data, format: 'deflate' }
];
for (var o = 0; o < options.length; o++) {
try {
var stream = new Blob([options[o].data]).stream().pipeThrough(new DecompressionStream(options[o].format));
var reader = stream.getReader();
const chunks = [];
let total = 0;
while (true) {
const chunk = await reader.read();
if (chunk.done) break;
chunks.push(chunk.value);
total += chunk.value.byteLength;
}
if (total === 0) continue;
const result = new Uint8Array(total);
let off = 0;
for (var i = 0; i < chunks.length; i++) {
result.set(chunks[i], off);
off += chunks[i].byteLength;
}
return decodeBytesToUtf8String(result);
} catch (e) {
console.warn('Decompression failed with format ' + options[o].format + ':', e.message);
}
}
throw new Error('All decompression methods failed');
}
function extractNovelAIAlphaMetadata(file, callback) {
const reader = new FileReader();
reader.onload = function(e) {
const img = new Image();
img.onload = async function() {
try {
const canvas = document.createElement('canvas');
canvas.width = img.width;
canvas.height = img.height;
const ctx = canvas.getContext('2d');
ctx.drawImage(img, 0, 0);
const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);
// 1. Try stealth pnginfo (signature-based protocol with optional compression)
var stealthResult = await readStealthPngInfo(imageData);
if (stealthResult) {
console.log('Stealth metadata found:', stealthResult);
var result = stealthResult;
try {
var parsed = JSON.parse(stealthResult);
if (parsed.Comment) {
try {
parsed.Comment = JSON.parse(parsed.Comment);
result = JSON.stringify(parsed, null, 2);
} catch (e2) { /* ignore */ }
}
} catch (e1) { /* ignore */ }
callback(result);
return;
}
// 2. Fall back to NovelAI-style: raw JSON in alpha channel LSB
var novelaiResult = readNovelAIAlphaFromPixels(imageData.data);
if (novelaiResult) {
console.log('NovelAI alpha channel metadata found:', novelaiResult);
callback(novelaiResult);
return;
}
callback(null);
} catch (err) {
console.error('Error extracting alpha channel metadata:', err);
callback(null);
}
};
img.onerror = function() {
callback(null);
};
img.src = e.target.result;
};
reader.readAsDataURL(file);
}
/**
* Convert binary string to text, stopping at null terminator or invalid data
*/
function readNovelAIAlphaFromPixels(pixels) {
var binaryString = '';
for (var i = 3; i < pixels.length; i += 4) {
binaryString += (pixels[i] & 1).toString();
}
return binaryStringToText(binaryString);
}
function binaryStringToText(binaryString) {
var text = '';
var foundStart = false;
for (var i = 0; i < binaryString.length - 7; i += 8) {
var byte = binaryString.substr(i, 8);
var charCode = parseInt(byte, 2);
if (charCode === 0) {
if (foundStart) break;
continue;
}
if (charCode === 123) {
foundStart = true;
}
if (foundStart) {
text += String.fromCharCode(charCode);
}
if (foundStart && charCode === 125) {
try {
JSON.parse(text);
break; // Valid JSON found, stop here
} catch (e) {
// Continue, might be nested JSON
}
}
}
// Validate that we have JSON-like content
if (text.trim().startsWith('{') && text.includes('"')) {
return text.trim();
}
return null;
}
async function readStealthPngInfo(imageData) {
const width = imageData.width;
const height = imageData.height;
const data = imageData.data;
const hasAlpha = hasImageAlpha(imageData);
const SIG_LEN_BITS = 'stealth_pnginfo'.length * 8;
var mode = null;
var compressed = false;
var binaryData = '';
var bufferA = '';
var bufferRGB = '';
var indexA = 0;
var indexRGB = 0;
var sigConfirmed = false;
var confirmingSignature = true;
var readingParamLen = false;
var readingParam = false;
var readEnd = false;
var paramLen = 0;
for (var x = 0; x < width && !readEnd; x++) {
for (var y = 0; y < height && !readEnd; y++) {
var i = (y * width + x) * 4;
if (hasAlpha) {
bufferA += (data[i + 3] & 1).toString();
indexA++;
}
bufferRGB += (data[i] & 1).toString();
bufferRGB += (data[i + 1] & 1).toString();
bufferRGB += (data[i + 2] & 1).toString();
indexRGB += 3;
if (confirmingSignature) {
if (hasAlpha && indexA === SIG_LEN_BITS) {
var decodedSigA = bitsToText(bufferA);
if (decodedSigA === 'stealth_pnginfo' || decodedSigA === 'stealth_pngcomp') {
confirmingSignature = false;
sigConfirmed = true;
readingParamLen = true;
mode = 'alpha';
if (decodedSigA === 'stealth_pngcomp') compressed = true;
bufferA = '';
indexA = 0;
} else {
readEnd = true;
break;
}
} else if (indexRGB === SIG_LEN_BITS) {
var decodedSigRGB = bitsToText(bufferRGB);
if (decodedSigRGB === 'stealth_rgbinfo' || decodedSigRGB === 'stealth_rgbcomp') {
confirmingSignature = false;
sigConfirmed = true;
readingParamLen = true;
mode = 'rgb';
if (decodedSigRGB === 'stealth_rgbcomp') compressed = true;
bufferRGB = '';
indexRGB = 0;
}
}
} else if (readingParamLen) {
if (mode === 'alpha' && indexA === 32) {
paramLen = parseInt(bufferA, 2);
readingParamLen = false;
readingParam = true;
bufferA = '';
indexA = 0;
} else if (mode === 'rgb' && indexRGB === 33) {
paramLen = parseInt(bufferRGB.slice(0, -1), 2);
readingParamLen = false;
readingParam = true;
bufferRGB = bufferRGB.slice(-1);
indexRGB = 1;
}
} else if (readingParam) {
if (mode === 'alpha' && indexA === paramLen) {
binaryData = bufferA;
readEnd = true;
break;
} else if (mode === 'rgb' && indexRGB >= paramLen) {
var diff = paramLen - indexRGB;
if (diff < 0) {
bufferRGB = bufferRGB.slice(0, diff);
}
binaryData = bufferRGB;
readEnd = true;
break;
}
} else {
readEnd = true;
break;
}
}
}
if (sigConfirmed && binaryData) {
var byteData = new Uint8Array(binaryData.match(/\d{8}/g).map(function(b) { return parseInt(b, 2); }));
var decoded;
if (compressed) {
try {
decoded = await decompressZlibData(byteData);
} catch (e) {
console.error('Stealth metadata decompression failed:', e);
decoded = decodeBytesToUtf8String(byteData);
}
} else {
decoded = new TextDecoder().decode(byteData);
}
console.log('Decoded stealth metadata:\n\n', decoded);
return decoded;
}
return null;
}
// --- Decompress zTXt chunk (zlib); same approach as ai-image-metadata-editor: Blob stream + 'deflate' ---
function decompressZlibToStr(zlibBytes) {
if (!zlibBytes.length || typeof DecompressionStream === 'undefined') return Promise.resolve(null);
const stream = new Blob([zlibBytes]).stream().pipeThrough(new DecompressionStream('deflate'));
const reader = stream.getReader();
const chunks = [];
let total = 0;
return (function read() {
return reader.read().then(function (result) {
if (result.done) {
const dec = new Uint8Array(total);
let off = 0;
for (let c = 0; c < chunks.length; c++) {
dec.set(chunks[c], off);
off += chunks[c].length;
}
return decodeBytesToUtf8String(dec);
}
if (result.value) {
chunks.push(result.value);
total += result.value.byteLength;
}
return read();
});
})();
}
function decompressZtxt(rawBytes) {
if (!(rawBytes instanceof Uint8Array) || rawBytes.length < 3) {
return Promise.resolve({ text: '', fallback: '' });
}
const sepIdx = rawBytes.indexOf(0);
if (sepIdx === -1) return Promise.resolve({ text: '', fallback: '' });
const zlibStream = rawBytes.slice(sepIdx + 2);
if (zlibStream.length < 6) return Promise.resolve({ text: '', fallback: '' });
// Fallback: decode raw bytes as UTF-8 so CJK in metadata is preserved
let fallbackStr = decodeBytesToUtf8String(rawBytes);
if (typeof DecompressionStream === 'undefined') {
return Promise.resolve({ text: fallbackStr, fallback: fallbackStr });
}
return decompressZlibToStr(zlibStream)
.then(function (text) {
return text ? { text: text, fallback: fallbackStr } : { text: '', fallback: fallbackStr };
})
.catch(function (err) {
console.warn('zTXt decompression failed:', err);
return { text: '', fallback: fallbackStr };
});
}
// --- PNG metadata extraction with prompt distribution ---
function extractPngMetadata(file) {
const reader = new FileReader();
reader.onload = async function (e) {
const arrayBuffer = e.target.result;
const bytes = new Uint8Array(arrayBuffer);
try {
const pngMeta = new PNGMetadata(bytes, 'byte');
const chunks = pngMeta.getChunks();
var found = false;
var pngKeywords = getPngKeywordsFromFormats();
var collectedByKeyword = {};
// 1. Collect all tEXt/zTXt/iTXt chunks with relevant keywords
for (var chunkTypeIdx = 0; chunkTypeIdx < 3; chunkTypeIdx++) {
var chunkType = ['tEXt', 'zTXt', 'iTXt'][chunkTypeIdx];
if (!chunks[chunkType] || chunks[chunkType].data_raw.length === 0) continue;
for (var i = 0; i < chunks[chunkType].data_raw.length; i++) {
var raw = chunks[chunkType].data_raw[i];
var keyword = '';
var text = '';
if (chunkType === 'zTXt' && raw instanceof Uint8Array) {
var nul = raw.indexOf(0);
if (nul <= 0) continue;
keyword = decodeBytesToUtf8String(raw.subarray(0, nul)).replace(/\0/g, '');
var decompressed = await decompressZtxt(raw);
var rawStr = (decompressed && decompressed.text) ? decompressed.text : (decompressed && decompressed.fallback) ? decompressed.fallback : '';
if (!rawStr) continue;
var sepIdx = rawStr.indexOf('\0');
if (sepIdx >= 0) {
keyword = rawStr.substring(0, sepIdx);
text = rawStr.substring(sepIdx + 1);
} else {
text = rawStr;
}
} else {
var sepIdx = raw.indexOf('\0');
if (sepIdx === -1) continue;
keyword = raw.substring(0, sepIdx);
text = raw.substring(sepIdx + 1);
}
if (!keyword) continue;
var keyLower = keyword.toLowerCase();
if (pngKeywords.indexOf(keyLower) === -1) continue;
window.setPromptInfoAvailable(true);
var parsed = null;
if (keyLower === 'parameters') {
// Always treat parameters as plain text (A1111)
} else {
var textToParse = text;
if (text.includes('"sampler"') && text.includes('"signed_hash"')) {
var jsonStartIndex = text.indexOf('{');
if (jsonStartIndex > -1) textToParse = text.substring(jsonStartIndex);
}
parsed = safeJsonParse(textToParse);
if (keyLower === 'prompt' && (parsed === null || parsed === undefined)) {
var startObj = text.indexOf('{');
var startArr = text.indexOf('[');
var start = (startObj >= 0 && (startArr < 0 || startObj < startArr)) ? startObj : startArr;
if (start >= 0) {
var fromStart = text.substring(start);
var retry = safeJsonParse(fromStart);
if (retry !== null && typeof retry === 'object') parsed = retry;
}
}
}
if (!collectedByKeyword[keyLower]) collectedByKeyword[keyLower] = [];
collectedByKeyword[keyLower].push({ keyword: keyword, text: text, parsed: parsed });
}
}
// 2a. InvokeAI: if invokeai_metadata and/or invokeai_graph chunks exist, merge and distribute once (before ComfyUI so it takes precedence)
var metaEntries = null;
var graphEntries = null;
for (var kw in collectedByKeyword) {
var norm = kw.toLowerCase().replace(/-/g, '_').trim();
if (norm === 'invokeai_metadata') metaEntries = collectedByKeyword[kw];
if (norm === 'invokeai_graph') graphEntries = collectedByKeyword[kw];
}
if ((metaEntries && metaEntries.length > 0) || (graphEntries && graphEntries.length > 0)) {
var invokeMerged = {};
if (metaEntries && metaEntries.length > 0) {
var metaEntry = metaEntries[0];
invokeMerged.invokeai_metadata = (metaEntry.parsed !== undefined && metaEntry.parsed !== null && typeof metaEntry.parsed === 'object') ? metaEntry.parsed : metaEntry.text;
}
if (graphEntries && graphEntries.length > 0) {
var graphEntry = graphEntries[0];
invokeMerged.invokeai_graph = (graphEntry.parsed !== undefined && graphEntry.parsed !== null && typeof graphEntry.parsed === 'object') ? graphEntry.parsed : graphEntry.text;
}
var firstEntry = (metaEntries && metaEntries[0]) || graphEntries[0];
distributePromptData(invokeMerged, firstEntry.text, firstEntry.keyword);
found = true;
}
// 2a.5 NovelAI: if any chunk has Software "NovelAI" or signed_hash+sampler, use it and add Title/Source/Generation time to Image Info
if (!found) {
var novelAIEntry = null;
for (var nKw in collectedByKeyword) {
var entries = collectedByKeyword[nKw];
for (var ne = 0; ne < entries.length; ne++) {
if (isNovelAI(entries[ne].parsed)) {
novelAIEntry = entries[ne];
break;
}
}
if (novelAIEntry) break;
}
if (novelAIEntry) {
distributePromptData(novelAIEntry.parsed, novelAIEntry.text, novelAIEntry.keyword);
found = true;
if (collectedByKeyword['title'] && collectedByKeyword['title'].length > 0) {
addMetadataItem('Title', collectedByKeyword['title'][0].text, metadataList);
}
if (collectedByKeyword['source'] && collectedByKeyword['source'].length > 0) {
addMetadataItem('Source', collectedByKeyword['source'][0].text, metadataList);
}
if (collectedByKeyword['generation_time'] && collectedByKeyword['generation_time'].length > 0) {
addMetadataItem('Generation Time', collectedByKeyword['generation_time'][0].text, metadataList);
}
}
}
// 2b. Midjourney: "Description" chunk with plain text only (NovelAI uses Description/Comment as JSON or Software key)
if (!found && collectedByKeyword['description'] && collectedByKeyword['description'].length > 0) {
var descEntry = collectedByKeyword['description'][0];
if (!isNovelAI(descEntry.parsed)) {
distributePromptData(descEntry.parsed, descEntry.text, descEntry.keyword);
found = true;
// Add Creation Time and Author to Image Info when present
if (collectedByKeyword['creation time'] && collectedByKeyword['creation time'].length > 0) {
addMetadataItem('Creation Time', collectedByKeyword['creation time'][0].text, metadataList);
}
if (collectedByKeyword['author'] && collectedByKeyword['author'].length > 0) {
addMetadataItem('Author', collectedByKeyword['author'][0].text, metadataList);
}
}
}
// 2c. PNG "parameters" = always plain text → parse as A1111. Optionally add workflow block.
if (!found && collectedByKeyword['parameters'] && collectedByKeyword['parameters'].length > 0) {
var paramsEntry = collectedByKeyword['parameters'][0];
var paramsText = typeof paramsEntry.text === 'string' ? stripSurroundingQuotes(paramsEntry.text.trim()) : '';
if (paramsText) {
parseAndDisplayUserComment(paramsText);
setGenerationMetadataType('A1111');
var workflowEntries = collectedByKeyword['workflow'];
if (workflowEntries && workflowEntries.length > 0 && workflowEntries[0].parsed != null && typeof workflowEntries[0].parsed === 'object') {
addMetadataAsJsonBlock({ workflow: workflowEntries[0].parsed }, promptInfoList, 'ComfyUI', 'workflow');
}
found = true;
}
}
// 2c continued. ComfyUI JSON "prompt" chunk + workflow — merge and distribute.
if (!found && collectedByKeyword['prompt'] && collectedByKeyword['prompt'].length > 0 && collectedByKeyword['workflow'] && collectedByKeyword['workflow'].length > 0) {
var promptEntry = collectedByKeyword['prompt'][0];
var workflowEntry = collectedByKeyword['workflow'][0];
var promptObj = promptEntry.parsed;
var workflowObj = workflowEntry.parsed;
if (promptObj && typeof promptObj === 'object' && workflowObj != null && typeof workflowObj === 'object') {
distributePromptData({ prompt: promptObj, workflow: workflowObj }, promptEntry.text, promptEntry.keyword);
found = true;
}
}
if (!found) {
for (var kw in collectedByKeyword) {
var normKw = kw.toLowerCase().replace(/-/g, '_').trim();
if (normKw === 'invokeai_metadata' || normKw === 'invokeai_graph') continue;
var entries = collectedByKeyword[kw];
for (var e = 0; e < entries.length; e++) {
distributePromptData(entries[e].parsed, entries[e].text, entries[e].keyword);
found = true;
}
}
}
if (found && (typeof window.showExpandedInfo === 'function' || typeof setExpanded === 'function')) {
if (typeof window.showExpandedInfo === 'function') window.showExpandedInfo();
else if (typeof setExpanded === 'function') setExpanded(true);
}
// 2. If not found, try eXIf chunk
if (!found && chunks['eXIf'] && chunks['eXIf'].data_raw.length > 0) {
console.log('PNG: No metadata in text chunks, trying eXIf chunk');
let exifBytes = [];
for (const part of chunks['eXIf'].data_raw) {
exifBytes = exifBytes.concat(Array.from(part));
}
const exifArray = new Uint8Array(exifBytes);
const exifView = new DataView(exifArray.buffer);
// Find TIFF header
let tiffOffset = 0;
let tiffMarker = exifView.getUint16(0, false);
if (tiffMarker !== 0x4949 && tiffMarker !== 0x4D4D) {
console.log('PNG eXIf: TIFF marker not at offset 0, scanning...');
tiffOffset = findTiffHeader(exifView);
if (tiffOffset === -1) {
console.warn('PNG eXIf: No TIFF header found');
showWarning('❌ No EXIF TIFF header found in PNG eXIf chunk');
return;
}
console.log('PNG eXIf: TIFF header found at offset', tiffOffset);
}
const exifData = parseExif(exifView, tiffOffset);
console.log('Parsed EXIF from PNG eXIf:', exifData);
// Try to extract UserComment or other prompt data from exifData
let userComment = exifData["UserComment"] || exifData[0x9286];
let makeComment = exifData["Make"];
let imageDescription = exifData["ImageDescription"] || exifData[0x010E] || exifData[270];
let comment = null;
let sourceTag = null;
// Try to decode UserComment if it's not a string (e.g., array of char codes)
if (userComment && typeof userComment !== 'string' && Array.isArray(userComment)) {
userComment = String.fromCharCode.apply(null, userComment);
}
if (userComment && typeof userComment === 'string' && userComment.trim() !== '') {
comment = userComment;
sourceTag = 'UserComment';
} else if (makeComment && typeof makeComment === 'string' && makeComment.trim() !== '') {
comment = makeComment;
sourceTag = 'Make';
} else if (imageDescription && typeof imageDescription === 'string' && imageDescription.trim() !== '') {
comment = imageDescription;
sourceTag = 'ImageDescription';
}
console.log('Selected comment source from PNG EXIF:', sourceTag, 'Value:', comment);
if (comment) {
let jsonStr = stripPromptPrefix(comment.trim());
let parsed = safeJsonParse(jsonStr);
distributePromptData(parsed, comment, sourceTag);
found = true;
// --- Auto-expand the additional info section for PNGs with metadata
if (typeof window.showExpandedInfo === 'function') {
window.showExpandedInfo();
} else if (typeof setExpanded === 'function') {
setExpanded(true);
}
}
}
// 3. If still not found, try NovelAI alpha channel extraction
if (!found) {
console.log('PNG: No metadata in text/EXIF chunks, trying NovelAI alpha channel extraction');
extractNovelAIAlphaMetadata(file, function(alphaMetadata) {
if (alphaMetadata) {
console.log('NovelAI alpha metadata extracted:', alphaMetadata);
window.setPromptInfoAvailable(true);
let parsed = safeJsonParse(alphaMetadata);
distributePromptData(parsed, alphaMetadata, 'NovelAI Alpha Channel');
// Auto-expand the additional info section
if (typeof window.showExpandedInfo === 'function') {
window.showExpandedInfo();
} else if (typeof setExpanded === 'function') {
setExpanded(true);
}
} else {
console.warn('No prompt metadata found in PNG (including alpha channel)');
window.setPromptInfoAvailable(false);
showWarning('❌ No prompt metadata found');
}
});
return; // Exit early since alpha extraction is async
}
} catch (err) {
console.error('Failed to parse PNG metadata:', err);
showWarning('❌ Failed to parse PNG metadata');
}
};
reader.readAsArrayBuffer(file);
}
// --- PNG Metadata extractor END ---
// --- Extractor Helpers ----
// Helper: Find the first occurrence of a key in a nested object
function findFirstKeyValue(obj, targetKey) {
if (typeof obj !== 'object' || obj === null) return null;
for (const key in obj) {
if (!obj.hasOwnProperty(key)) continue;
if (key === targetKey && typeof obj[key] === 'string') {
return obj[key];
} else if (typeof obj[key] === 'object') {
const result = findFirstKeyValue(obj[key], targetKey);
if (result !== null) return result;
}
}
return null;
}
// --- Helper: Collect all values for a key in a nested object ---
function collectAllKeyValues(obj, targetKey, resultArr) {
if (typeof obj !== 'object' || obj === null) return;
if (Array.isArray(obj)) {
for (const item of obj) {
collectAllKeyValues(item, targetKey, resultArr);
}
return;
}
for (const key in obj) {
if (!obj.hasOwnProperty(key)) continue;
if (key === targetKey && typeof obj[key] === 'string') {
resultArr.push(obj[key]);
} else if (typeof obj[key] === 'object') {
collectAllKeyValues(obj[key], targetKey, resultArr);
}
}
}
// --- Helper: Collect "resources" (modelVersionId + strength) from any "extraMetadata" in a nested structure ---
function collectExtraMetadataResources(obj, resultArr) {
if (typeof obj !== 'object' || obj === null) return;
if (Array.isArray(obj)) {
obj.forEach(item => collectExtraMetadataResources(item, resultArr));
return;
}
for (const key in obj) {
if (!obj.hasOwnProperty(key)) continue;
if (key === 'extraMetadata') {
var em = obj[key];
var parsed = null;
if (typeof em === 'string') {
try {
var sanitized = escapeControlCharsInJsonString(em);
parsed = JSON.parse(sanitized);
} catch (e) { /* ignore */ }
} else if (typeof em === 'object' && em !== null) {
parsed = em;
}
if (parsed && Array.isArray(parsed.resources)) {
parsed.resources.forEach(function (r) {
if (r && (r.modelVersionId != null || r.id != null)) {
resultArr.push({
modelVersionId: r.modelVersionId != null ? r.modelVersionId : r.id,
strength: r.strength != null ? r.strength : r.weight
});
}
});
}
} else if (typeof obj[key] === 'object') {
collectExtraMetadataResources(obj[key], resultArr);
}
}
}
// --- Helper: Collect only the "prompt" property from any "extraMetadata" object in a nested structure ---
function collectExtraMetadataPromptOnly(obj, resultArr) {
if (typeof obj !== 'object' || obj === null) return;
if (Array.isArray(obj)) {
obj.forEach(item => collectExtraMetadataPromptOnly(item, resultArr));
return;
}
for (const key in obj) {
if (!obj.hasOwnProperty(key)) continue;
if (key === 'extraMetadata') {
// Handle both string and object formats
if (typeof obj[key] === 'string') {
try {
// Sanitize: escape control chars inside string literals so JSON.parse succeeds
const sanitized = escapeControlCharsInJsonString(obj[key]);
const extraMetaObj = JSON.parse(sanitized);
if (typeof extraMetaObj.prompt === 'string') {
resultArr.push(extraMetaObj.prompt);
}
// Also check for negativePrompt
if (typeof extraMetaObj.negativePrompt === 'string') {
// Store negative prompts with a special marker
resultArr.push('__NEGATIVE__' + extraMetaObj.negativePrompt);
}
} catch (e) {
console.warn('Failed to parse extraMetadata string:', e);
}
} else if (typeof obj[key] === 'object' && obj[key] !== null) {
if (typeof obj[key].prompt === 'string') {
resultArr.push(obj[key].prompt);
}
// Also check for negativePrompt
if (typeof obj[key].negativePrompt === 'string') {
// Store negative prompts with a special marker
resultArr.push('__NEGATIVE__' + obj[key].negativePrompt);
}
}
} else if (typeof obj[key] === 'object') {
collectExtraMetadataPromptOnly(obj[key], resultArr);
}
}
}
// --- Helper: Pretty-print as JSON for display ---
function safeStringifyForDisplay(obj) {
const seen = new WeakSet();
try {
return JSON.stringify(obj, function (key, value) {
if (typeof value === 'object' && value !== null) {
if (seen.has(value)) return '[Circular]';
seen.add(value);
}
return value;
}, 2);
} catch (e) {
return String(obj);
}
}
var preBlockStyle = 'margin:0.5em 0;white-space:pre-wrap;font-size:0.9em;max-height:30em;overflow:auto;';
function addCollapsibleCodeBlock(label, value, listElement) {
if (!listElement) return;
var template = document.getElementById('metadata-code-block-template');
if (!template || !template.content) {
return;
}
var jsonStr = safeStringifyForDisplay(value);
var li = template.content.cloneNode(true);
var labelTextEl = li.querySelector('.metadata-code-block-label-text');
var toggle = li.querySelector('.metadata-code-block-toggle');
var preEl = li.querySelector('.metadata-code-block-content pre');
if (labelTextEl) labelTextEl.textContent = label;
if (preEl) {
preEl.setAttribute('style', preBlockStyle);
preEl.textContent = jsonStr;
}
toggle.addEventListener('click', function (e) {
var row = e.currentTarget;
var contentBlock = row.nextElementSibling;
var chevronEl = row.querySelector('.metadata-code-block-label .chevron');
if (contentBlock) contentBlock.classList.toggle('collapsed');
var isNowCollapsed = contentBlock && contentBlock.classList.contains('collapsed');
if (chevronEl) chevronEl.classList.toggle('rotate', !isNowCollapsed);
row.setAttribute('aria-expanded', !isNowCollapsed ? 'true' : 'false');
});
toggle.addEventListener('keydown', function (e) {
if (e.key === 'Enter' || e.key === ' ') {
toggle.click();
e.preventDefault();
}
});
listElement.appendChild(li);
}
function addJsonBlockWithLabel(label, value, listElement) {
addCollapsibleCodeBlock(label, value, listElement);
}
// --- Helper: Get object property by key (case-insensitive) ---
function getValueIgnoreCase(obj, key) {
if (!obj || typeof obj !== 'object') return undefined;
var k = (key || '').toString().toLowerCase();
for (var name in obj) {
if (Object.prototype.hasOwnProperty.call(obj, name) && name.toLowerCase() === k) return obj[name];
}
return undefined;
}
// --- Additional Info: one code block per format field present ---
function addMetadataAsJsonBlock(parsed, listElement, formatName, sourceLabel) {
if (!listElement || !parsed) return;
var fieldNames = formatName ? getFormatFieldNames(formatName) : [];
var added = 0;
if (fieldNames.length > 0 && typeof parsed === 'object' && !Array.isArray(parsed) && parsed !== null) {
for (var i = 0; i < fieldNames.length; i++) {
var val = getValueIgnoreCase(parsed, fieldNames[i]);
if (val !== undefined && val !== null) {
if (typeof val === 'string' && (fieldNames[i] === 'prompt' || fieldNames[i] === 'parameters')) {
val = stripSurroundingQuotes(val.trim());
}
addJsonBlockWithLabel(formatFieldDisplayLabel(fieldNames[i]), val, listElement);
added++;
}
}
}
if (added === 0) {
addCollapsibleCodeBlock(sourceLabel || 'Metadata', parsed, listElement);
}
}
// --- Helper: Collect prompt info for prompt-info-list and model-info-list ---
function collectPromptInfo(obj, cbPrompt, cbModel) {
if (typeof obj !== 'object' || obj === null) return;
if (Array.isArray(obj)) {
obj.forEach(item => collectPromptInfo(item, cbPrompt, cbModel));
return;
}
for (const key in obj) {
if (!obj.hasOwnProperty(key)) continue;
const value = obj[key];
if (isModelInfoKey(key, value)) {
// If value is primitive, add directly
if (typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') {
cbModel(key, value);
}
// If value is an array of objects, display as a sub-list
else if (Array.isArray(value) && value.length > 0 && value.every(v => typeof v === 'object')) {
// Build a sub-list as HTML
let html = '<ul style="margin:0 0 0 1.5em;padding:0;">';
value.forEach(item => {
if (typeof item === 'object' && item !== null) {
html += '<li style="margin-bottom:0.5em;"><ul style="margin:0 0 0 1.5em;padding:0;">';
for (const subKey in item) {
if (!item.hasOwnProperty(subKey)) continue;
html += `<li><strong>${subKey}:</strong> ${item[subKey]}</li>`;
}
html += '</ul></li>';
} else {
html += `<li>${item}</li>`;
}
});
html += '</ul>';
cbModel(key, html);
}
// If value is an object, pretty-print as key-value pairs
else if (typeof value === 'object' && value !== null) {
let html = '<ul style="margin:0 0 0 1.5em;padding:0;">';
for (const subKey in value) {
if (!value.hasOwnProperty(subKey)) continue;
html += `<li><strong>${subKey}:</strong> ${value[subKey]}</li>`;
}
html += '</ul>';
cbModel(key, html);
}
} else if (typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') {
cbPrompt(key, value);
} else if (typeof value === 'object' && value !== null) {
collectPromptInfo(value, cbPrompt, cbModel);
}
}
}
// --- Helper: Collect all "text" values and separate negative and positives ---
function collectTextValuesWithNegatives(obj, positiveArr, negativeArr) {
if (typeof obj !== 'object' || obj === null) return;
if (Array.isArray(obj)) {
obj.forEach(item => collectTextValuesWithNegatives(item, positiveArr, negativeArr));
return;
}
// Define keys to check for prompt text (case-insensitive, ignore trailing colon/whitespace)
const promptKeys = ['text', 'text_l', 'text_a', 'text_b', 'negative', 'positive', 'result', 'tags', 'string', 'string_field', 'string_a', 'string_b', 'prompt', 'populated_text', 'value'];
// Negative/positive regex
const negativeRegex = /low quality|censored|lowres|watermark|jpeg artifacts|worst quality|bad quality/i;
const positiveRegex = /masterpiece|absurdres|best quality|very aesthetic|1girl|2girls|3girls/i;
for (const key in obj) {
if (!obj.hasOwnProperty(key)) continue;
const value = obj[key];
if (typeof value === 'string') {
// Normalize key: lowercase, remove trailing colon/whitespace
const normKey = key.trim().replace(/:$/, '').toLowerCase();
if (normKey === 'value' || normKey === 'string_a' || normKey === 'string_b') {
// "value", "string_a", "string_b" treated as positive prompt (InvokeAI, ComfyUI)
positiveArr.push(value);
} else if (promptKeys.includes(normKey)) {
// Text containing positive indicators (e.g. "masterpiece") must never go to negative prompt
if (positiveRegex.test(value)) {
positiveArr.push(value);
} else if (negativeRegex.test(value)) {
negativeArr.push(value);
}
// If it doesn't match either, don't add it
}
} else if (typeof value === 'object') {
collectTextValuesWithNegatives(value, positiveArr, negativeArr);
}
}
}
// --- Helper: Recursively walk object/array for model info keys and call cbModel for each found. ---
function walkForModelInfo(obj, cbModel) {
if (Array.isArray(obj)) {
obj.forEach(item => walkForModelInfo(item, cbModel));
} else if (typeof obj === 'object' && obj !== null) {
for (const k in obj) {
if (!obj.hasOwnProperty(k)) continue;
const v = obj[k];
if (isModelInfoKey(k, v) && (typeof v === 'string' || typeof v === 'number' || typeof v === 'boolean')) {
cbModel(k, v);
}
if (typeof v === 'object' && v !== null) {
walkForModelInfo(v, cbModel);
}
}
}
}
// --- Helper: Extract JSON value from a string using bracket matching ---
function extractJsonValue(str, startIdx) {
// str: the full string
// startIdx: index of the first '[' or '{'
let open = str[startIdx];
let close = open === '[' ? ']' : '}';
let depth = 0;
for (let i = startIdx; i < str.length; i++) {
if (str[i] === open) depth++;
if (str[i] === close) depth--;
if (depth === 0) {
return str.slice(startIdx, i + 1);
}
}
return null; // Not found
}
/**
* Strip leading/trailing double quotes (ASCII or Unicode), single quotes, and unescape \" inside.
* Strips even if only one end has a quote.
* Handles multiple consecutive quotes at start/end.
*/
function stripSurroundingQuotes(str) {
if (typeof str !== 'string' || str.length === 0) return str;
var isQuote = function (c) {
return c === '"' || c === "'" || c === '\u201C' || c === '\u201D' || c === '\u201E' || c === '\u2018' || c === '\u2019';
};
while (str.length > 0 && isQuote(str.charAt(0))) str = str.slice(1);
while (str.length > 0 && isQuote(str.charAt(str.length - 1))) str = str.slice(0, -1);
str = str.replace(/\\"/g, '"');
return str;
}
/**
* Parse the UserComment string and distribute to UI elements.
* - All text until "Negative prompt:" (excluded) -> #positive-prompt
* - "Negative prompt:" until "Steps:" (excluded) -> #negative-prompt
* - "Steps:" and everything after -> #prompt-info-list (as <li>s)
* - Remove initial "UNICODE" if present
* - Remove Template: "..." and any text within the double quotes (including the quotes)
* - Enhanced: Recursively extract model info keys from JSON-like values in additional info.
*/
function parseAndDisplayUserComment(comment) {
window.setPromptInfoAvailable(true);
comment = comment.trim();
comment = stripSurroundingQuotes(comment);
comment = unescapePromptString(comment);
if (comment.startsWith('UNICODE')) {
comment = comment.substring('UNICODE'.length).trim();
}
// Find "Negative prompt:" and "Steps:" (case-insensitive, allow optional spaces)
const negPromptIdx = comment.search(/\bnegative\s+prompt\s*:/i);
const stepsIdx = comment.search(/\bsteps\s*:/i);
let positive = '', negative = '', additional = '';
if (negPromptIdx !== -1) {
positive = comment.substring(0, negPromptIdx).trim();
var negColonIdx = comment.indexOf(':', negPromptIdx);
var negEnd = (stepsIdx !== -1 && stepsIdx > negPromptIdx) ? stepsIdx : comment.length;
negative = negColonIdx !== -1 ? comment.substring(negColonIdx + 1, negEnd).trim() : '';
if (stepsIdx !== -1 && stepsIdx > negPromptIdx) additional = comment.substring(stepsIdx).trim();
} else {
if (stepsIdx !== -1) {
positive = comment.substring(0, stepsIdx).trim();
additional = comment.substring(stepsIdx).trim();
} else {
positive = comment.trim();
}
}
setAndResize(positivePrompt, positive);
setAndResize(negativePrompt, negative);
// --- Split additional into items, route model keys to model-info-list ---
if (promptInfoList) promptInfoList.innerHTML = '';
if (modelInfoList) modelInfoList.innerHTML = '';
if (additional) {
const items = splitPromptInfo(additional);
for (const item of items) {
const colonIdx = item.indexOf(':');
if (colonIdx !== -1) {
const label = item.slice(0, colonIdx).trim();
let value = item.slice(colonIdx + 1).trim();
// If value starts with [ or { but does not end with ] or }, try to extract the full JSON value from the original string
if ((value.startsWith('[') && !value.endsWith(']')) || (value.startsWith('{') && !value.endsWith('}'))) {
// Find the position of value in the original string
let startIdx = additional.indexOf(value);
let fullJson = extractJsonValue(additional, startIdx);
if (fullJson) value = fullJson;
}
let parsedValue = value;
let parsedSuccessfully = false;
if (typeof value === 'string' && (value.trim().startsWith('[') || value.trim().startsWith('{'))) {
try {
console.log('Trying to parse as JSON:', value);
parsedValue = JSON.parse(value);
parsedSuccessfully = true;
console.log('Parsed JSON:', parsedValue);
} catch (e) {
console.warn('Failed to parse JSON:', value, e);
}
}
if (parsedSuccessfully && (Array.isArray(parsedValue) || typeof parsedValue === 'object')) {
walkForModelInfo(parsedValue, (k, v) => addMetadataItem(k, v, modelInfoList));
} else if (isModelInfoKey(label, value)) {
addMetadataItem(label, value, modelInfoList);
} else {
addMetadataItem(label, value, promptInfoList);
}
}
}
}
}
// Split additional prompt info into items, respecting commas inside parentheses/brackets/braces and double-quoted strings.
function splitPromptInfo(str) {
const result = [];
let current = '';
let parenDepth = 0, bracketDepth = 0, braceDepth = 0;
let inDoubleQuote = false;
for (let i = 0; i < str.length; ++i) {
const c = str[i];
if (c === '"') {
inDoubleQuote = !inDoubleQuote;
current += c;
continue;
}
if (c === '(' && !inDoubleQuote) parenDepth++;
if (c === ')' && !inDoubleQuote) parenDepth--;
if (c === '[' && !inDoubleQuote) bracketDepth++;
if (c === ']' && !inDoubleQuote) bracketDepth--;
if (c === '{' && !inDoubleQuote) braceDepth++;
if (c === '}' && !inDoubleQuote) braceDepth--;
if (c === ',' && !inDoubleQuote && parenDepth === 0 && bracketDepth === 0 && braceDepth === 0) {
result.push(current);
current = '';
// Skip the space after comma if present
if (str[i + 1] === ' ') i++;
} else {
current += c;
}
}
if (current.trim().length > 0) result.push(current);
return result;
}
// --- Civitai: resolve hash or modelVersionId to model page URL ---
var CIVITAI_API_BASE = 'https://civitai.com/api/v1';
function fetchCivitaiModelByVersionId(modelVersionId, callback) {
var id = modelVersionId != null ? Number(modelVersionId) : NaN;
if (id <= 0 || !isFinite(id)) {
callback(new Error('Invalid modelVersionId'));
return;
}
var xhr = new XMLHttpRequest();
xhr.open('GET', CIVITAI_API_BASE + '/model-versions/' + id, true);
xhr.onload = function () {
if (xhr.status !== 200) {
callback(new Error('Civitai API returned ' + xhr.status));
return;
}
try {
var data = JSON.parse(xhr.responseText);
var modelId = data.modelId || (data.model && data.model.id);
var versionId = data.id;
var modelName = (data.model && data.model.name) ? data.model.name : '';
var versionName = data.name || '';
var displayName = modelName || versionName || ('v' + versionId);
if (modelId != null && versionId != null) {
callback(null, { modelId: modelId, modelVersionId: versionId, modelName: modelName, versionName: versionName, displayName: displayName });
} else {
callback(new Error('Missing modelId or version id'));
}
} catch (e) {
callback(e);
}
};
xhr.onerror = function () { callback(new Error('Network error')); };
xhr.send();
}
function fetchCivitaiModelByHash(hash, callback) {
if (!hash || typeof hash !== 'string') {
callback(new Error('Invalid hash'));
return;
}
hash = hash.trim();
if (!hash) {
callback(new Error('Empty hash'));
return;
}
var xhr = new XMLHttpRequest();
xhr.open('GET', CIVITAI_API_BASE + '/model-versions/by-hash/' + encodeURIComponent(hash), true);
xhr.onload = function () {
if (xhr.status !== 200) {
callback(new Error('Civitai API returned ' + xhr.status));
return;
}
try {
var data = JSON.parse(xhr.responseText);
var modelId = data.modelId || (data.model && data.model.id);
var modelVersionId = data.id;
if (modelId != null && modelVersionId != null) {
callback(null, { modelId: modelId, modelVersionId: modelVersionId });
} else {
callback(new Error('Missing modelId or version id'));
}
} catch (e) {
callback(e);
}
};
xhr.onerror = function () { callback(new Error('Network error')); };
xhr.send();
}
function escapeHtml(str) {
if (str == null) return '';
var div = document.createElement('div');
div.textContent = String(str);
return div.innerHTML;
}
/** Strip surrounding double quotes and trim. */
function stripQuotes(s) {
if (s == null) return '';
s = String(s).trim();
if (s.length >= 2 && s.charAt(0) === '"' && s.charAt(s.length - 1) === '"') {
s = s.slice(1, -1).trim();
}
return s;
}
/** Parse "Model hash" / "Lora hashes" value into list of { name, hash } for linkification. */
function parseHashesForCivitai(label, value) {
var out = [];
if (value == null) return out;
var str = typeof value === 'string' ? value.trim() : (typeof value === 'object' ? JSON.stringify(value) : String(value));
str = stripQuotes(str);
if (!str) return out;
// JSON object like {"lora1":"hash1","lora2":"hash2"}
if (str.startsWith('{')) {
try {
var obj = JSON.parse(str);
for (var k in obj) { if (Object.prototype.hasOwnProperty.call(obj, k)) { out.push({ name: stripQuotes(k), hash: stripQuotes(String(obj[k])) }); } }
return out;
} catch (e) { /* fall through */ }
}
// "name: hash" or "name1: hash1, name2: hash2"
var parts = str.split(',').map(function (p) { return p.trim(); });
parts.forEach(function (part) {
part = stripQuotes(part);
var colon = part.indexOf(':');
if (colon !== -1) {
var name = stripQuotes(part.slice(0, colon).trim());
var hash = stripQuotes(part.slice(colon + 1).trim());
if (hash) out.push({ name: name, hash: hash });
} else if (/^[a-zA-Z0-9]{6,64}$/i.test(part)) {
out.push({ name: '', hash: part });
}
});
// Single hash only (e.g. Model hash)
if (out.length === 0 && /^[a-zA-Z0-9]{6,64}$/i.test(str)) {
out.push({ name: '', hash: str });
}
return out;
}
// Parse value for Civitai URNs (e.g. urn:air:sdxl:checkpoint:civitai:1689192@1911770)
// Model version ID is the number after @. Returns [{ versionId, strength }]
function parseCivitaiUrnsInValue(value) {
var out = [];
if (value == null) return out;
var str = typeof value === 'string' ? value.trim() : String(value);
if (!str) return out;
var re = /civitai:(\d+)(?:@(\d+))?/g;
var m;
while ((m = re.exec(str)) !== null) {
var versionId = m[2] != null ? parseInt(m[2], 10) : parseInt(m[1], 10);
if (!isNaN(versionId)) out.push({ versionId: versionId, strength: null });
}
return out;
}
// Normalize model-related keys to consistent display labels for the Models section
function normalizeModelLabel(key) {
if (!key || typeof key !== 'string') return key;
var k = key.trim().toLowerCase().replace(/\s+/g, ' ');
if (/^ckpt_name$|^checkpoint$|^model$|^ckpt$|^modelname$|^model_name$/.test(k)) return 'Model';
if (/^base_ckpt_name$|^base model$/.test(k)) return 'Base model';
if (/^model_hash$|^ckpt_hash$|^hash$/.test(k)) return 'Model hash';
if (k === 'lora' || k === 'lora_name') return 'Lora';
if (/^lora hashes$|^lorahashes$/.test(k.replace(/\s/g, ''))) return 'Lora hashes';
if (/^lora[_\-]?\d*$/.test(k)) {
var num = k.replace(/^lora[_\-]?/, '');
return num ? 'Lora ' + num : 'Lora';
}
return key.replace(/_/g, ' ').replace(/^\w/, function (c) { return c.toUpperCase(); });
}
// Add a model info row with normalized label to model-info-list.
function addModelInfoItem(key, value) {
if (!modelInfoList) return;
var label = normalizeModelLabel(key);
if (civitaiCheckingEnabled && (label === 'Model hash' || label === 'Lora hashes')) {
var entries = parseHashesForCivitai(label, value);
if (entries.length === 0) {
addMetadataItem(label, value, modelInfoList);
return;
}
var li = document.createElement('li');
li.innerHTML = '<strong class="unselectable-label">' + escapeHtml(label) + ':</strong> ';
var valueContainer = document.createElement('span');
valueContainer.className = 'civitai-hash-links';
if (entries.length === 1) {
var span = document.createElement('span');
span.textContent = entries[0].name ? entries[0].name + ': ' + entries[0].hash : entries[0].hash;
span.setAttribute('data-hash', entries[0].hash);
valueContainer.appendChild(span);
resolveCivitaiHashSpan(span, entries[0].hash, entries[0].name);
} else {
var ul = document.createElement('ul');
ul.style.cssText = 'margin:0 0 0 1em;padding:0;list-style:none;';
entries.forEach(function (entry) {
var itemLi = document.createElement('li');
var span = document.createElement('span');
span.textContent = entry.name ? entry.name + ': ' + entry.hash : entry.hash;
span.setAttribute('data-hash', entry.hash);
itemLi.appendChild(span);
ul.appendChild(itemLi);
resolveCivitaiHashSpan(span, entry.hash, entry.name);
});
valueContainer.appendChild(ul);
}
li.appendChild(valueContainer);
modelInfoList.appendChild(li);
return;
}
if (civitaiCheckingEnabled) {
var civitaiEntries = parseCivitaiUrnsInValue(value);
if (civitaiEntries.length > 0) {
var li = document.createElement('li');
li.innerHTML = '<strong class="unselectable-label">' + escapeHtml(label) + ':</strong> ';
var valueContainer = document.createElement('span');
valueContainer.className = 'civitai-hash-links';
civitaiEntries.forEach(function (entry, idx) {
if (idx > 0) valueContainer.appendChild(document.createTextNode(', '));
var span = document.createElement('span');
span.setAttribute('data-version-id', String(entry.versionId));
valueContainer.appendChild(span);
resolveCivitaiVersionSpan(span, entry.versionId, entry.strength);
});
li.appendChild(valueContainer);
modelInfoList.appendChild(li);
return;
}
}
addMetadataItem(label, value, modelInfoList);
}
function resolveCivitaiHashSpan(span, hash, name) {
fetchCivitaiModelByHash(hash, function (err, result) {
if (err || !result) {
return;
}
var url = 'https://civitai.com/models/' + result.modelId + '?modelVersionId=' + result.modelVersionId;
var display = name ? name + ': ' + hash : hash;
var a = document.createElement('a');
a.href = url;
a.target = '_blank';
a.rel = 'noopener noreferrer';
a.textContent = display;
span.textContent = '';
span.appendChild(a);
});
}
function resolveCivitaiVersionSpan(span, modelVersionId, strength) {
span.textContent = '…';
fetchCivitaiModelByVersionId(modelVersionId, function (err, result) {
if (err || !result) {
span.textContent = 'v' + modelVersionId + (strength != null ? ' (' + strength + ')' : '');
return;
}
var url = 'https://civitai.com/models/' + result.modelId + '?modelVersionId=' + result.modelVersionId;
var display = result.displayName + (strength != null && strength !== '' ? ' (' + strength + ')' : '');
var a = document.createElement('a');
a.href = url;
a.target = '_blank';
a.rel = 'noopener noreferrer';
a.textContent = display;
span.textContent = '';
span.appendChild(a);
});
}
function addCivitaiResourcesToModelInfo(resources) {
if (!modelInfoList || !resources || resources.length === 0 || !civitaiCheckingEnabled) return;
var li = document.createElement('li');
li.innerHTML = '<strong class="unselectable-label">Civitai resources:</strong> ';
var ul = document.createElement('ul');
ul.style.cssText = 'margin:0 0 0 1em;padding:0;list-style:none;';
resources.forEach(function (r) {
var strength = r.strength != null ? r.strength : r.weight;
var vid = r.modelVersionId != null ? r.modelVersionId : r.id;
if (vid == null) return;
var itemLi = document.createElement('li');
var span = document.createElement('span');
span.className = 'civitai-hash-links';
span.setAttribute('data-version-id', String(vid));
itemLi.appendChild(span);
ul.appendChild(itemLi);
resolveCivitaiVersionSpan(span, vid, strength);
});
li.appendChild(ul);
modelInfoList.appendChild(li);
}
/**
* Add a metadata item to a given list element, formatting with <strong> for label.
* If no list element is provided, defaults to metadataList.
* When listElement is modelInfoList and label is "Model hash" or "Lora hashes", value is linkified to Civitai.
*/
function addMetadataItem(label, value, listElement) {
var targetList = listElement || metadataList;
if (targetList === modelInfoList && civitaiCheckingEnabled && (label === 'Model hash' || label === 'Lora hashes')) {
var entries = parseHashesForCivitai(label, value);
if (entries.length > 0) {
var li = document.createElement('li');
li.innerHTML = '<strong class="unselectable-label">' + escapeHtml(label) + ':</strong> ';
var valueContainer = document.createElement('span');
valueContainer.className = 'civitai-hash-links';
if (entries.length === 1) {
var span = document.createElement('span');
span.textContent = entries[0].name ? entries[0].name + ': ' + entries[0].hash : entries[0].hash;
span.setAttribute('data-hash', entries[0].hash);
valueContainer.appendChild(span);
resolveCivitaiHashSpan(span, entries[0].hash, entries[0].name);
} else {
var ul = document.createElement('ul');
ul.style.cssText = 'margin:0 0 0 1em;padding:0;list-style:none;';
entries.forEach(function (entry) {
var itemLi = document.createElement('li');
var span = document.createElement('span');
span.textContent = entry.name ? entry.name + ': ' + entry.hash : entry.hash;
span.setAttribute('data-hash', entry.hash);
itemLi.appendChild(span);
ul.appendChild(itemLi);
resolveCivitaiHashSpan(span, entry.hash, entry.name);
});
valueContainer.appendChild(ul);
}
li.appendChild(valueContainer);
targetList.appendChild(li);
return;
}
}
if (targetList === modelInfoList && civitaiCheckingEnabled && value != null) {
var civitaiEntries = parseCivitaiUrnsInValue(value);
if (civitaiEntries.length > 0) {
var li = document.createElement('li');
li.innerHTML = '<strong class="unselectable-label">' + escapeHtml(label) + ':</strong> ';
var valueContainer = document.createElement('span');
valueContainer.className = 'civitai-hash-links';
civitaiEntries.forEach(function (entry, idx) {
if (idx > 0) valueContainer.appendChild(document.createTextNode(', '));
var span = document.createElement('span');
span.setAttribute('data-version-id', String(entry.versionId));
valueContainer.appendChild(span);
resolveCivitaiVersionSpan(span, entry.versionId, entry.strength);
});
li.appendChild(valueContainer);
targetList.appendChild(li);
return;
}
}
var li = document.createElement('li');
if (label) {
li.innerHTML = '<strong class="unselectable-label">' + escapeHtml(label) + ':</strong> ' + escapeHtml(value);
} else {
li.textContent = value;
}
targetList.appendChild(li);
}
// Show the detected generation metadata type in Image Info
function setGenerationMetadataType(type) {
if (!metadataList || !type) return;
var children = metadataList.children;
for (var i = children.length - 1; i >= 0; i--) {
var li = children[i];
if (li.querySelector && li.querySelector('strong') && li.querySelector('strong').textContent === 'Metadata type:') {
metadataList.removeChild(li);
break;
}
}
addMetadataItem('Metadata type', type);
}
// --- UI clearing ---
function clearPromptFields() {
setAndResize(positivePrompt, '');
setAndResize(negativePrompt, '');
if (promptInfoList) promptInfoList.innerHTML = '';
if (modelInfoList) modelInfoList.innerHTML = '';
const additionalPromptsTextarea = document.getElementById('additional-prompts');
setAndResize(additionalPromptsTextarea, '');
}
// --- Show/clear warning ---
function showWarning(msg) {
if (warningSpan) {
warningSpan.textContent = msg;
warningSpan.style.display = 'inline';
}
}
//Clear the warning message in #warning span.
function clearWarning() {
if (warningSpan) {
warningSpan.textContent = '';
warningSpan.style.display = 'none';
}
}
// --- Auto-resize a textarea to fit its content. ---
function autoResizeTextarea(textarea) {
if (!textarea) return;
textarea.style.height = 'auto';
textarea.style.height = (textarea.scrollHeight + 2) + 'px';
}
// --- Copy textarea text using the modern clipboard API ---
document.querySelectorAll('.copy-btn').forEach(btn => {
btn.addEventListener('click', async function () {
const targetId = btn.getAttribute('data-target');
const textarea = document.getElementById(targetId);
const label = btn.parentElement.querySelector('.btn-label');
if (textarea) {
if (!textarea.value.trim()) {
// If textarea is empty, show 'Nothing to copy...'
if (label) {
label.textContent = 'Nothing to copy...';
label.style.opacity = '1';
label.style.transition = 'opacity 0.5s';
void label.offsetWidth;
setTimeout(() => {
label.style.opacity = '0';
setTimeout(() => {
label.textContent = '';
}, 500);
}, 2000);
}
return;
}
try {
await navigator.clipboard.writeText(textarea.value);
if (label) {
label.textContent = 'Copied!';
label.style.opacity = '1';
label.style.transition = 'opacity 0.5s';
void label.offsetWidth;
setTimeout(() => {
label.style.opacity = '0';
setTimeout(() => {
label.textContent = '';
}, 500);
}, 2000);
}
} catch {
if (label) {
label.textContent = 'Copy failed';
label.style.opacity = '1';
setTimeout(() => {
label.style.opacity = '0';
setTimeout(() => { label.textContent = ''; }, 500);
}, 2000);
}
}
}
});
});
// --- Scroll-to-top arrow ---
(function scrollArrow() {
if (document.getElementById("scroll-to-top")) return;
const upBtn = document.createElement("button");
upBtn.id = "scroll-to-top";
upBtn.className = "scroll-arrow-btn";
upBtn.title = "Scroll to top";
upBtn.innerHTML = "▲";
upBtn.style.display = "none"; // Hide initially
upBtn.addEventListener("click", () => {
window.scrollTo({ top: 0, behavior: "smooth" });
});
document.body.appendChild(upBtn);
// Show/hide on scroll
window.addEventListener("scroll", function () {
if (window.scrollY > 500) {
upBtn.style.display = "block";
upBtn.classList.add("show");
} else {
upBtn.classList.remove("show");
// Wait for transition before hiding
setTimeout(() => {
if (!upBtn.classList.contains("show")) {
upBtn.style.display = "none";
}
}, 300);
}
});
})();