|
// ==UserScript== |
|
// @name 4chan /hdg/ catbox.moe userscript |
|
// @namespace 4chanhdgcatbox |
|
// @match https://boards.4chan.org/*/thread/* |
|
// @match https://archiveofsins.com/* |
|
// @match https://desuarchive.org/* |
|
// @grant GM.xmlHttpRequest |
|
// @grant GM_xmlhttpRequest |
|
// @version 3.7.3 |
|
// @author Anonymous |
|
// @description Upload image directly to catbox.moe from 4chan |
|
// @updateURL https://gist.github.com/raw/ca46eb79ce55e3216aecab49d5c7a3fb/catbox.user.js |
|
// @downloadURL https://gist.github.com/raw/ca46eb79ce55e3216aecab49d5c7a3fb/catbox.user.js |
|
// @require https://cdn.jsdelivr.net/npm/exif-js |
|
// @require https://cdn.jsdelivr.net/npm/exifreader@4.21.0/dist/exif-reader.min.js |
|
// @require https://cdn.jsdelivr.net/npm/pako@2.1.0/dist/pako.min.js |
|
// ==/UserScript== |
|
|
|
(async function () { |
|
const STRICT_CHECK = false || JSON.parse(localStorage['CATBOX_HDG_STRICT_CHECK'] || false); |
|
const TITLE_CHECK = [ |
|
'/aids/', '/asdg/', '/ddg/', '/hdg/', '/sdg/', '/swarm/', '/vtai/', |
|
'/aicg/', |
|
'>nai leak speedrun', 'otaku ai art thread', |
|
['/jp/', ' ai thread '], ['/jp/', ' ai art '], ['/pw/', 'waifu wrestling alliance'], ['/bant/', 'ai waifus general'] |
|
]; |
|
const OP_CHECK = [ |
|
'waifus.nemusona.com', 'rentry.org/voldy', 'rentry.co/voldy', 'github.com/AUTOMATIC1111/stable-diffusion-webui' |
|
]; |
|
const ARCHIVE_CHECK = [ |
|
'archiveofsins.com', 'desuarchive.org' |
|
]; |
|
const CATBOX_BUTTON_ID = 'qr-catbox-button_userscript'; |
|
const RE_CATBOX_URL = /^https?:\/\/files\.catbox\.moe\/([a-z0-9]{6}\.(?:png|jpg|webp))$/i; |
|
const RE_CATBOX_FILENAME = /^catbox_[a-z0-9]{6}\.(?:png|jpg|webp)$/i; |
|
const RE_NAI_FILENAME = /^.+\ss\-\d+\s*(?:\(\d+\))?(\.png)+\s*$/; |
|
|
|
let loaded = false; |
|
let thread_match = false; |
|
let qr_updated = false; |
|
|
|
const posts = new Set(); |
|
|
|
function log(msg) { |
|
console.log(`[4chan /sdg/ catbox.moe userscript] ${msg}`); |
|
} |
|
|
|
function getXmlHttpRequest() { |
|
return (typeof GM !== "undefined" && GM !== null ? GM.xmlHttpRequest : GM_xmlhttpRequest); |
|
} |
|
|
|
function get(url) { |
|
return new Promise((resolve, reject) => { |
|
getXmlHttpRequest()({ |
|
method: 'GET', |
|
url: url, |
|
timeout: 15000, |
|
responseType: 'blob', |
|
onload: function(response) { |
|
resolve(response); |
|
}, |
|
onerror: function(error) { |
|
reject(error); |
|
}, |
|
ontimeout: function(error) { |
|
reject(error); |
|
} |
|
}); |
|
}); |
|
} |
|
|
|
function post(url, data) { |
|
return new Promise((resolve, reject) => { |
|
getXmlHttpRequest()({ |
|
method: 'POST', |
|
url: url, |
|
data: data, |
|
timeout: 15000, |
|
onload: function(response) { |
|
resolve(response); |
|
}, |
|
onerror: function(error) { |
|
reject(error); |
|
}, |
|
ontimeout: function(error) { |
|
reject(error); |
|
} |
|
}); |
|
}); |
|
} |
|
|
|
function setFontColor(elm, color) { |
|
elm.setAttribute('style', elm.getAttribute('style').replace(/(.+color: )[a-z]+( !.+)/i, `$1${color}$2`)); |
|
} |
|
|
|
function setCatboxAuth() { |
|
const userhash = window.prompt('Catbox auth hotkey triggered. Please enter your userhash to store your login. Input nothing to remove it.'); |
|
localStorage['CATBOX_USERHASH'] = JSON.stringify(userhash || ''); |
|
} |
|
|
|
function getCatboxAuth() { |
|
const res = JSON.parse(localStorage['CATBOX_USERHASH'] || '{}'); |
|
return (typeof res === 'object' ? '' : res || ''); |
|
} |
|
|
|
async function uploadToCatbox(file) { |
|
const catboxButton = document.querySelector(`#${CATBOX_BUTTON_ID}`); |
|
const submitButton = document.querySelector('#file-n-submit [type=submit][value=Submit]'); |
|
|
|
const formData = new FormData(); |
|
formData.append('reqtype', 'fileupload'); |
|
formData.append('fileToUpload', file, file.name); |
|
const userhash = getCatboxAuth(); |
|
if (userhash) { |
|
formData.append('userhash', userhash); |
|
} |
|
|
|
catboxButton.value = 'Uploading ...'; |
|
setFontColor(catboxButton, 'yellow'); |
|
|
|
// Selector doesn't seem to grab the submit button all the time for some reason |
|
if (submitButton) { |
|
submitButton.disabled = true; |
|
submitButton.style.pointerEvents = 'none'; |
|
submitButton.style.opacity = 0.25; |
|
} |
|
|
|
try { |
|
log('attempting catbox upload'); |
|
const response = await post('https://catbox.moe/user/api.php', formData); |
|
|
|
if (response.status === 200 && response.responseText.match(RE_CATBOX_URL)) { |
|
const filenameMatch = response.responseText.match(RE_CATBOX_URL); |
|
|
|
log('uploaded'); |
|
catboxButton.value = 'uploaded'; |
|
setFontColor(catboxButton, 'limegreen'); |
|
|
|
const filenameInput = document.querySelector('#qr-filename'); |
|
const fileEvent = new CustomEvent('QRSetFile', { |
|
detail: { |
|
file: file |
|
} |
|
}); |
|
|
|
document.dispatchEvent(fileEvent); |
|
|
|
filenameInput.value = `catbox_${filenameMatch[1]}`; |
|
filenameInput.dispatchEvent(new Event('input', {bubbles:true})); |
|
} else { |
|
log('upload error'); |
|
console.error(response); |
|
|
|
catboxButton.value = 'upload error'; |
|
setFontColor(catboxButton, 'red'); |
|
} |
|
} catch(err) { |
|
log('upload failed'); |
|
console.error(err); |
|
|
|
catboxButton.value = 'upload failed'; |
|
setFontColor(catboxButton, 'red'); |
|
} |
|
|
|
if (submitButton) { |
|
submitButton.disabled = false; |
|
submitButton.style.pointerEvents = ''; |
|
submitButton.style.opacity = 1; |
|
} |
|
|
|
setTimeout(() => { |
|
catboxButton.value = 'catbox'; |
|
setFontColor(catboxButton, 'inherit'); |
|
}, 2000); |
|
} |
|
|
|
const loadImage = (blob) => { |
|
return new Promise((resolve, reject) => { |
|
const image = new Image(); |
|
image.onload = () => resolve(image); |
|
image.onerror = reject; |
|
image.src = URL.createObjectURL(blob); |
|
}); |
|
} |
|
|
|
function parseNaiMetadata(metadata) { |
|
metadata = JSON.parse(metadata); |
|
return JSON.stringify(metadata, null, '\t'); |
|
} |
|
|
|
async function handleMetadataReq(evt, el, href) { |
|
evt.preventDefault(); |
|
|
|
const box = insertPromptBox(el); |
|
|
|
let metadata, chunks; |
|
const hostedOn4chan = el.href.includes('4cdn.org'); |
|
|
|
if (hostedOn4chan) { |
|
const res = await get(href); |
|
if (res.status != 200) { |
|
return; |
|
} |
|
|
|
const image = await loadImage(res.response); |
|
|
|
metadata = readInfoFromImageStealth(image); |
|
if (metadata) { |
|
try { |
|
let stealthMetadata = JSON.parse(metadata); |
|
if (stealthMetadata?.Comment) { |
|
metadata = parseNaiMetadata(stealthMetadata['Comment']); |
|
} |
|
else if (stealthMetadata?.prompt || stealthMetadata?.workflow) { |
|
metadata = `prompt\n \n${stealthMetadata?.prompt}\n \nworkflow\n \n${stealthMetadata?.workflow}`; |
|
} |
|
} catch { |
|
; |
|
} |
|
} |
|
} else { |
|
const res = await fetch(href); |
|
if (!res.ok || !res.body) { |
|
return; |
|
} |
|
|
|
const reader = res.body.getReader(); |
|
|
|
chunks = []; |
|
let iterCount = 0; |
|
|
|
while (true) { |
|
const {done, value} = await reader.read(); |
|
if (done || iterCount > 10) { |
|
break; |
|
} |
|
chunks.push(value); |
|
iterCount++; |
|
} |
|
|
|
if (href.endsWith('.jpg')) { |
|
let rawJpegMetdata = chunksToArray(chunks); |
|
rawJpegMetdata = EXIF.readFromBinaryFile(rawJpegMetdata.buffer.slice(rawJpegMetdata.byteOffset, rawJpegMetdata.byteLength + rawJpegMetdata.byteOffset)); |
|
if (Object.keys(rawJpegMetdata).includes('UserComment')) { |
|
const jpegMetadata = String.fromCharCode(...rawJpegMetdata.UserComment.slice(9).filter((value) => value !== 0)); |
|
updatePromptBox(box, jpegMetadata); |
|
} |
|
|
|
return; |
|
} |
|
|
|
if (href.endsWith('.webp')) { |
|
let rawWebpMetadata = chunksToArray(chunks)?.buffer; |
|
rawWebpMetadata = ExifReader.load(rawWebpMetadata); |
|
if (Object.keys(rawWebpMetadata).includes('UserComment')) { |
|
const webpMetadata = String.fromCharCode(...rawWebpMetadata.UserComment.value.slice(9).filter((value) => value !== 0)); |
|
updatePromptBox(box, webpMetadata); |
|
} |
|
|
|
return; |
|
} |
|
|
|
metadata = await getMetaData(chunks[0]); |
|
} |
|
|
|
if (metadata) { |
|
updatePromptBox(box, metadata, hostedOn4chan || metadata.includes('"prompt": "')); |
|
return; |
|
} else if (!hostedOn4chan) { |
|
const fallbackMetadata = await getMetaData(chunksToArray(chunks)); |
|
if (fallbackMetadata) { |
|
updatePromptBox(box, fallbackMetadata); |
|
return; |
|
} |
|
} |
|
|
|
updatePromptBox(box, 'No metadata found.'); |
|
} |
|
|
|
function updateLinkHover(link) { |
|
link.setAttribute('style', 'color: limegreen !important;'); |
|
link.addEventListener('mouseenter', (evt) => {evt.target.style.filter = 'brightness(250%)'}); |
|
link.addEventListener('mouseleave', (evt) => {evt.target.style.filter = 'none'}); |
|
} |
|
|
|
function updateFilenameLink(link) { |
|
const name = link.getAttribute('download')?.split('_')[1] || link.getAttribute('title')?.split('_')[1]; |
|
if (!name) { |
|
log('Failed to parse catbox link filename'); |
|
return; |
|
} |
|
const href = `https://files.catbox.moe/${name}`; |
|
log(`parsed catbox link: ${href}`); |
|
link.href = href; |
|
updateLinkHover(link); |
|
} |
|
|
|
async function updateDownloadLinks(root=null, limit=100000) { |
|
root = root !== null ? root : document; |
|
const skipExisting = (root == document); |
|
|
|
function getPostId(el) { |
|
return el.closest('[data-full-i-d]').getAttribute('data-full-i-d').replace(/\D/g, ''); |
|
} |
|
|
|
const downloadLinks = Array.from(root.querySelectorAll('.file-info a[href*="4cdn.org"][download^="catbox_" i]')).slice(0, limit); |
|
const naiLinks = Array.from(root.querySelectorAll('a:is([download*="s-1"],[download*="s-2"],[download*="s-3"],[download*="s-4"],[download*="s-5"],[download*="s-6"],[download*="s-7"],[download*="s-8"],[download*="s-9"])[download$="png"]')).slice(0, limit); |
|
const fileDownloadLinks = Array.from(root.querySelectorAll('.file-info a.download-button[href]:not([href*=".catbox.moe/"]):not([download^="catbox_" i])')).slice(0, limit); |
|
|
|
for (const link of [...downloadLinks, ...naiLinks]) { |
|
if (skipExisting && posts.has(getPostId(link))) { |
|
continue; |
|
} |
|
|
|
if (link.getAttribute('download').match(RE_CATBOX_FILENAME)) { |
|
updateFilenameLink(link); |
|
continue; |
|
} |
|
|
|
if (link.getAttribute('download').match(RE_NAI_FILENAME)) { |
|
updateLinkHover(link); |
|
} |
|
} |
|
|
|
const catboxLinks = Array.from(root.querySelectorAll('a[href*=".catbox.moe/"]:is([href$=".png" i], [href$=".jpg" i], [href$=".webp" i])')).slice(0, limit); |
|
|
|
for (const link of [...catboxLinks, ...fileDownloadLinks]) { |
|
if (skipExisting && posts.has(getPostId(link))) { |
|
continue; |
|
} |
|
|
|
log(`found existing link: ${link.href}`); |
|
link.style.cursor = 'help'; |
|
link.title = 'Right click to attempt to load & show/hide metadata'; |
|
link.addEventListener('contextmenu', async (evt) => {await handleMetadataReq(evt, link, link.href)}); |
|
} |
|
|
|
if (skipExisting) { |
|
for (const link of [...catboxLinks, ...fileDownloadLinks]) { |
|
posts.add(getPostId(link)); |
|
} |
|
} |
|
} |
|
|
|
function createMetadataBoxButton(name, desc, rgb, idx=0) { |
|
const btn = document.createElement('div'); |
|
btn.title = desc; |
|
btn.classList.add(`catbox-prompt-${name}_userscript`); |
|
btn.style.position = 'absolute'; |
|
btn.style.width = '16px'; |
|
btn.style.height = '16px'; |
|
btn.style.backgroundColor = `rgba(${rgb[0]},${rgb[1]},${rgb[2]},0.5)`; |
|
btn.style.lineHeight = '16px'; |
|
btn.style.textAlign = 'center'; |
|
btn.style.marginTop = '-8px'; |
|
btn.style.marginLeft = '-8px'; |
|
btn.style.textIndent = '0px'; |
|
btn.style.userSelect = 'none'; |
|
btn.style.cursor = 'pointer'; |
|
btn.style.color = 'white'; |
|
btn.style.left = `${((idx + 1) * 16) + (2 * (idx > 0 ? 1 : 0))}px`; |
|
btn.style.top = '16px'; |
|
btn.innerText = name.charAt(0).toUpperCase(); |
|
btn.addEventListener('mouseenter', (evt) => {evt.target.style.backgroundColor = `rgba(${rgb[0]},${rgb[1]},${rgb[2]},0.8)`}); |
|
btn.addEventListener('mouseleave', (evt) => {evt.target.style.backgroundColor = `rgba(${rgb[0]},${rgb[1]},${rgb[2]},0.5)`}); |
|
|
|
return btn; |
|
} |
|
|
|
function insertPromptBox(el) { |
|
const boxClass = 'catbox-prompt_userscript'; |
|
// const blockquote = document.querySelector(`#m${el.closest('.postContainer[data-full-i-d]').getAttribute('data-full-i-d').split('h.')[1]}`); |
|
const blockquote = el.closest(':is(.reply, .op)').querySelector('blockquote'); |
|
|
|
const exists = blockquote.querySelector(`.${boxClass}`); |
|
if (exists) { |
|
exists.remove(); |
|
return; |
|
} |
|
|
|
const box = document.createElement('div'); |
|
box.classList.add('catbox-prompt_userscript'); |
|
box.style.display = 'grid'; |
|
box.style.position = 'relative'; |
|
box.style.color = 'white'; |
|
box.style.backgroundColor = 'rgba(0,0,255,0.2)'; |
|
box.style.border = '2px solid rgba(255,255,255,0.2)'; |
|
box.style.borderStyle = 'dashed'; |
|
box.style.padding = '8px'; |
|
box.style.paddingTop = '16px;' |
|
box.style.marginBottom = '16px'; |
|
box.style.whiteSpace = 'pre-wrap'; |
|
box.style.textIndent = '48px'; |
|
box.style.maxHeight = '480px'; |
|
box.style.overflowY = 'auto'; |
|
|
|
box.innerText = 'Attempting to load metadata...'; |
|
|
|
blockquote.prepend(box); |
|
|
|
return box; |
|
} |
|
|
|
function updatePromptBox(box, metadata, jsonDisplay = false) { |
|
if (jsonDisplay) { |
|
box.style.fontFamily = 'monospace'; |
|
} |
|
|
|
box.innerText = metadata; |
|
|
|
const closeBtn = createMetadataBoxButton('x', 'Hide', [255,0,0], 0); |
|
closeBtn.addEventListener('click', () => {box.remove()}); |
|
|
|
const copyBtn = createMetadataBoxButton('copy', 'Copy', [0,127,255], 1); |
|
copyBtn.addEventListener('click', async (evt) => { |
|
await navigator.clipboard.writeText(metadata); |
|
copyBtn.style.backgroundColor = 'rgba(0,255,0,0.5)'; |
|
}); |
|
|
|
box.appendChild(closeBtn); |
|
box.appendChild(copyBtn); |
|
} |
|
|
|
async function updateQrWindow() { |
|
if (qr_updated) { |
|
log('qr window already updated'); |
|
return; |
|
} |
|
|
|
const fileButton = document.querySelector('#qr-file-button'); |
|
|
|
const catboxButton = fileButton.cloneNode(); |
|
catboxButton.id = CATBOX_BUTTON_ID; |
|
catboxButton.value = 'Catbox'; |
|
catboxButton.setAttribute('style', 'text-transform: uppercase; font-size: 10px !important; border-radius: 2px !important; color: inherit !important;'); |
|
|
|
fileButton.parentNode.insertBefore(catboxButton, fileButton.nextSibling); |
|
|
|
catboxButton.addEventListener('click', async (evt) => { |
|
const input = document.createElement('input'); |
|
|
|
input.setAttribute('type', 'file'); |
|
input.setAttribute('style', 'display: none !important;'); |
|
input.addEventListener('change', async () => { |
|
if (input.files.length > 0) { |
|
await uploadToCatbox(input.files[0]); |
|
} |
|
input.remove(); |
|
}); |
|
|
|
document.body.appendChild(input); |
|
input.click(); |
|
}); |
|
|
|
qr_updated = true; |
|
} |
|
|
|
function textCheck(inp, arr) { |
|
for (let title of arr) { |
|
if (Array.isArray(title)) { |
|
if (title.every(substr => inp.includes(substr.toLowerCase()))) { |
|
return true; |
|
} |
|
} else if (inp.indexOf(title.toLowerCase()) != -1) { |
|
return true; |
|
} |
|
} |
|
|
|
return false; |
|
} |
|
|
|
async function init() { |
|
if (loaded) { |
|
return; |
|
} |
|
|
|
loaded = true; |
|
log('initialized userscript'); |
|
|
|
const pageTitle = document.title.toLowerCase(); |
|
const opText = document.querySelector('.post.op .postMessage')?.innerText.toLowerCase() || ''; |
|
thread_match = !STRICT_CHECK || textCheck(pageTitle, TITLE_CHECK) || textCheck(opText, OP_CHECK); |
|
if (!thread_match) { |
|
log('Thread does not match criteria, short-circuiting'); |
|
return; |
|
} |
|
|
|
document.addEventListener('keydown', (evt) => { |
|
if (evt.key == "x" && evt.ctrlKey && evt.altKey) { |
|
setCatboxAuth(); |
|
} |
|
}); |
|
|
|
if (document.querySelector('#qr') && !qr_updated) { |
|
log('qr window already exists -- updating qr window'); |
|
await updateQrWindow(); |
|
} |
|
|
|
window.addEventListener('drop', async (evt) => { |
|
log('drop event'); |
|
|
|
if (!evt.dataTransfer.files.length || !evt.ctrlKey) { |
|
return; |
|
} |
|
|
|
evt.preventDefault(); |
|
uploadToCatbox(evt.dataTransfer.files[0]); |
|
}); |
|
|
|
document.addEventListener('QRDialogCreation', async () => { |
|
log('QR dialog creation event -- attempting to update qr window'); |
|
|
|
await updateQrWindow(); |
|
}); |
|
|
|
await updateDownloadLinks(); |
|
document.addEventListener('PostsInserted', async (evt) => { |
|
if (evt.target.hasAttribute('data-full-i-d')) { |
|
await updateDownloadLinks(evt.target); |
|
} else { |
|
await updateDownloadLinks(); |
|
} |
|
}); |
|
} |
|
|
|
document.addEventListener('4chanXInitFinished', async () => { |
|
log('4chanX init finished'); |
|
await init(); |
|
}); |
|
|
|
// 4chanXInitFinished event doesn't seem to fire all the time |
|
window.addEventListener('load', async () => { |
|
if (ARCHIVE_CHECK.includes(window.location.hostname)) { |
|
return; |
|
} else { |
|
setTimeout(async () => { |
|
if (!loaded) { |
|
log('4chanX init never received, using fallback'); |
|
await init(); |
|
} |
|
}, 5000) |
|
} |
|
}); |
|
|
|
function fillArchiveWithLinks() { |
|
if (ARCHIVE_CHECK.includes(window.location.hostname)) { |
|
log('Archive website detected'); |
|
const catboxLinks = document.querySelectorAll('a.btnr[download^="catbox_" i]'); |
|
for (const link of catboxLinks) { |
|
updateFilenameLink(link); |
|
} |
|
} |
|
} |
|
|
|
if (document.readyState !== 'loading') { |
|
fillArchiveWithLinks(); |
|
} else { |
|
document.addEventListener('DOMContentLoaded', async () => { fillArchiveWithLinks(); }) |
|
} |
|
|
|
function chunksToArray(inp) { |
|
|
|
let data = inp; |
|
|
|
if (Array.isArray(inp)) { |
|
let length = 0; |
|
inp.forEach(item => { |
|
length += item.length; |
|
}); |
|
|
|
data = new Uint8Array(length); |
|
let offset = 0; |
|
inp.forEach(item => { |
|
data.set(item, offset); |
|
offset += item.length; |
|
}); |
|
|
|
return data; |
|
|
|
} else {return inp} |
|
} |
|
|
|
/* ----------------------------------------------------------------- |
|
/* https://github.com/moonshinegloss/stable-diffusion-discord-prompts |
|
/* ----------------------------------------------------------------- */ |
|
|
|
// Used for fast-ish conversion between uint8s and uint32s/int32s. |
|
// Also required in order to remain agnostic for both Node Buffers and |
|
// Uint8Arrays. |
|
let uint8 = new Uint8Array(4) |
|
let int32 = new Int32Array(uint8.buffer) |
|
let uint32 = new Uint32Array(uint8.buffer) |
|
|
|
const RESOLUTION_UNITS = {UNDEFINED: 0, METERS: 1, INCHES: 2}; |
|
|
|
/** |
|
* https://github.com/hughsk/png-chunk-text |
|
* Reads a Uint8Array or Node.js Buffer instance containing a tEXt PNG chunk's data and returns its keyword/text: |
|
* @param data |
|
* @returns {{text: string, keyword: string}} |
|
*/ |
|
function textDecode (data) { |
|
if (data.data && data.name) { |
|
data = data.data |
|
} |
|
|
|
let naming = true |
|
let text = '' |
|
let name = '' |
|
|
|
for (let i = 0; i < data.length; i++) { |
|
let code = data[i] |
|
|
|
if (naming) { |
|
if (code) { |
|
name += String.fromCharCode(code) |
|
} else { |
|
naming = false |
|
} |
|
} else { |
|
if (code) { |
|
text += String.fromCharCode(code) |
|
} |
|
} |
|
} |
|
|
|
return { |
|
keyword: name, |
|
text: text |
|
} |
|
} |
|
|
|
/** |
|
* https://github.com/hughsk/png-chunks-extract |
|
* Extract the data chunks from a PNG file. |
|
* Useful for reading the metadata of a PNG image, or as the base of a more complete PNG parser. |
|
* Takes the raw image file data as a Uint8Array or Node.js Buffer, and returns an array of chunks. Each chunk has a name and data buffer: |
|
* @param data {Uint8Array} |
|
* @returns {[{name: String, data: Uint8Array}]} |
|
*/ |
|
function extractChunks (data) { |
|
|
|
if (data[0] !== 0x89) throw new Error('Invalid .png file header') |
|
if (data[1] !== 0x50) throw new Error('Invalid .png file header') |
|
if (data[2] !== 0x4E) throw new Error('Invalid .png file header') |
|
if (data[3] !== 0x47) throw new Error('Invalid .png file header') |
|
if (data[4] !== 0x0D) throw new Error('Invalid .png file header: possibly caused by DOS-Unix line ending conversion?') |
|
if (data[5] !== 0x0A) throw new Error('Invalid .png file header: possibly caused by DOS-Unix line ending conversion?') |
|
if (data[6] !== 0x1A) throw new Error('Invalid .png file header') |
|
if (data[7] !== 0x0A) throw new Error('Invalid .png file header: possibly caused by DOS-Unix line ending conversion?') |
|
|
|
let ended = false |
|
let chunks = [] |
|
let idx = 8 |
|
|
|
while (idx < data.length) { |
|
// Read the length of the current chunk, |
|
// which is stored as a Uint32. |
|
uint8[3] = data[idx++] |
|
uint8[2] = data[idx++] |
|
uint8[1] = data[idx++] |
|
uint8[0] = data[idx++] |
|
|
|
// Chunk includes name/type for CRC check (see below). |
|
let length = uint32[0] + 4 |
|
let chunk = new Uint8Array(length) |
|
chunk[0] = data[idx++] |
|
chunk[1] = data[idx++] |
|
chunk[2] = data[idx++] |
|
chunk[3] = data[idx++] |
|
|
|
// Get the name in ASCII for identification. |
|
let name = ( |
|
String.fromCharCode(chunk[0]) + |
|
String.fromCharCode(chunk[1]) + |
|
String.fromCharCode(chunk[2]) + |
|
String.fromCharCode(chunk[3]) |
|
) |
|
|
|
// The IHDR header MUST come first. |
|
if (!chunks.length && name !== 'IHDR') { |
|
throw new Error('IHDR header missing') |
|
} |
|
|
|
// The IEND header marks the end of the file, |
|
// so on discovering it break out of the loop. |
|
if (name === 'IEND') { |
|
ended = true |
|
chunks.push({ |
|
name: name, |
|
data: new Uint8Array(0) |
|
}) |
|
|
|
break |
|
} |
|
|
|
// Read the contents of the chunk out of the main buffer. |
|
for (let i = 4; i < length; i++) { |
|
chunk[i] = data[idx++] |
|
} |
|
|
|
// Read out the CRC value for comparison. |
|
// It's stored as an Int32. |
|
uint8[3] = data[idx++] |
|
uint8[2] = data[idx++] |
|
uint8[1] = data[idx++] |
|
uint8[0] = data[idx++] |
|
|
|
// The chunk data is now copied to remove the 4 preceding |
|
// bytes used for the chunk name/type. |
|
let chunkData = new Uint8Array(chunk.buffer.slice(4)) |
|
|
|
chunks.push({ |
|
name: name, |
|
data: chunkData |
|
}) |
|
} |
|
|
|
return chunks |
|
} |
|
|
|
/** |
|
* read 4 bytes number from UInt8Array. |
|
* @param uint8array |
|
* @param offset |
|
* @returns {number} |
|
*/ |
|
function readUint32 (uint8array,offset) { |
|
let byte1, byte2, byte3, byte4; |
|
byte1 = uint8array[offset++]; |
|
byte2 = uint8array[offset++]; |
|
byte3 = uint8array[offset++]; |
|
byte4 = uint8array[offset]; |
|
return 0 | (byte1 << 24) | (byte2 << 16) | (byte3 << 8) | byte4; |
|
} |
|
|
|
/** |
|
* Get object with PNG metadata. only tEXt and pHYs chunks are parsed |
|
* @param buffer {Buffer} |
|
* @returns {{tEXt: {keyword: value}, pHYs: {x: number, y: number, units: RESOLUTION_UNITS}, [string]: true}} |
|
*/ |
|
function readMetadata(buffer){ |
|
let result = {}; |
|
const chunks = extractChunks(buffer); |
|
chunks.forEach( chunk => { |
|
switch(chunk.name){ |
|
case 'tEXt': |
|
if (!result.tEXt) { |
|
result.tEXt = {}; |
|
} |
|
let textChunk = textDecode(chunk.data); |
|
result.tEXt[textChunk.keyword] = textChunk.text; |
|
break |
|
case 'pHYs': |
|
result.pHYs = { |
|
// Pixels per unit, X axis: 4 bytes (unsigned integer) |
|
"x": readUint32(chunk.data, 0), |
|
// Pixels per unit, Y axis: 4 bytes (unsigned integer) |
|
"y": readUint32(chunk.data, 4), |
|
"unit": chunk.data[8], |
|
} |
|
break |
|
case 'gAMA': |
|
case 'cHRM': |
|
case 'sRGB': |
|
case 'IHDR': |
|
case 'iCCP': |
|
default: |
|
result[chunk.name] = true; |
|
} |
|
}) |
|
return result; |
|
} |
|
|
|
function largeuint8ArrToString(uint8arr) { |
|
return new Promise((resolve) => { |
|
const f = new FileReader(); |
|
f.onload = function(e) { |
|
resolve(e.target.result); |
|
} |
|
f.readAsText(new Blob([uint8arr])); |
|
}) |
|
} |
|
|
|
function imageHasAlpha (context, canvas) { |
|
var data = context.getImageData(0, 0, canvas.width, canvas.height).data, |
|
hasAlphaPixels = false; |
|
for (var i = 3, n = data.length; i < n; i+=4) { |
|
if (data[i] < 255) { |
|
hasAlphaPixels = true; |
|
break; |
|
} |
|
} |
|
return hasAlphaPixels; |
|
} |
|
|
|
function readInfoFromImageStealth(image) { |
|
|
|
let geninfo, items, paramLen; |
|
let r, g, b, a; |
|
|
|
const canvas = document.createElement('canvas'); |
|
|
|
// trying to read stealth pnginfo |
|
const [width, height] = [image.width, image.height]; |
|
const context = canvas.getContext('2d'); |
|
canvas.width = image.width; |
|
canvas.height = image.height; |
|
context.drawImage(image, 0, 0); |
|
|
|
const imageData = context.getImageData(0, 0, width, height); |
|
const data = imageData.data; |
|
|
|
let hasAlpha = imageHasAlpha(context, canvas); |
|
let mode = null; |
|
let compressed = false; |
|
let binaryData = ''; |
|
let bufferA = ''; |
|
let bufferRGB = ''; |
|
let indexA = 0; |
|
let indexRGB = 0; |
|
let sigConfirmed = false; |
|
let confirmingSignature = true; |
|
let readingParamLen = false; |
|
let readingParam = false; |
|
let readEnd = false; |
|
|
|
for (let x = 0; x < width; x++) { |
|
for (let y = 0; y < height; y++) { |
|
|
|
let i = (y * width + x) * 4; |
|
|
|
if (hasAlpha) { |
|
[r, g, b, a] = data.slice(i, i+4); |
|
bufferA += (a & 1).toString(); |
|
indexA++; |
|
} else { |
|
[r, g, b] = data.slice(i, i+3); |
|
} |
|
bufferRGB += (r & 1).toString(); |
|
bufferRGB += (g & 1).toString(); |
|
bufferRGB += (b & 1).toString(); |
|
indexRGB += 3; |
|
|
|
if (confirmingSignature) { |
|
if (indexA === 'stealth_pnginfo'.length * 8) { |
|
const decodedSig = new TextDecoder().decode(new Uint8Array(bufferA.match(/\d{8}/g).map(b => parseInt(b, 2)))); |
|
if (decodedSig === 'stealth_pnginfo' || decodedSig === 'stealth_pngcomp') { |
|
confirmingSignature = false; |
|
sigConfirmed = true; |
|
readingParamLen = true; |
|
mode = 'alpha'; |
|
if (decodedSig === 'stealth_pngcomp') { |
|
compressed = true; |
|
} |
|
bufferA = ''; |
|
indexA = 0; |
|
} else { |
|
readEnd = true; |
|
break; |
|
} |
|
} else if (indexRGB === 'stealth_pnginfo'.length * 8) { |
|
const decodedSig = new TextDecoder().decode(new Uint8Array(bufferRGB.match(/\d{8}/g).map(b => parseInt(b, 2)))); |
|
if (decodedSig === 'stealth_rgbinfo' || decodedSig === 'stealth_rgbcomp') { |
|
confirmingSignature = false; |
|
sigConfirmed = true; |
|
readingParamLen = true; |
|
mode = 'rgb'; |
|
if (decodedSig === '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 != 'alpha' && 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 != 'alpha' && indexRGB >= paramLen) { |
|
const diff = paramLen - indexRGB; |
|
if (diff < 0) { |
|
bufferRGB = bufferRGB.slice(0, diff); |
|
} |
|
binaryData = bufferRGB; |
|
readEnd = true; |
|
break; |
|
} |
|
} else { |
|
// Impossible |
|
readEnd = true; |
|
break; |
|
} |
|
} |
|
|
|
if (readEnd) { |
|
break; |
|
} |
|
} |
|
|
|
if (sigConfirmed && binaryData) { |
|
// Convert binary string to UTF-8 encoded text |
|
const byteData = new Uint8Array(binaryData.match(/\d{8}/g).map(b => parseInt(b, 2))); |
|
let decodedData; |
|
if (compressed) { |
|
decodedData = pako.inflate(byteData, {to: 'string'}); |
|
} else { |
|
decodedData = new TextDecoder().decode(byteData); |
|
} |
|
geninfo = decodedData; |
|
} |
|
|
|
return geninfo; |
|
} |
|
|
|
async function getMetaData(chunks) { |
|
let meta |
|
try{ |
|
meta = readMetadata(chunks) |
|
}catch(_){} |
|
|
|
if (meta?.tEXt?.Comment && meta?.tEXt?.Description && meta?.tEXt?.Software) { |
|
return parseNaiMetadata(meta.tEXt.Comment); |
|
} |
|
if(meta?.tEXt?.Dream) { |
|
return `${meta?.tEXt?.Dream} ${meta?.tEXt?.['sd-metadata'] || ''}` |
|
}else if(meta?.tEXt?.parameters) { |
|
return meta?.tEXt?.parameters |
|
}else if(meta?.tEXt?.prompt || meta?.tEXt?.workflow) { |
|
return `prompt\n \n${meta?.tEXt?.prompt}\n \nworkflow\n \n${meta?.tEXt?.workflow}`; |
|
} else if(meta?.tEXt?.chara) { |
|
let charaDef = atob(meta?.tEXt?.chara); |
|
let charaDefJson = JSON.parse(charaDef); |
|
if (charaDefJson && ['name', 'description', 'mes_example', 'first_mes'].every(val => Object.keys(charaDefJson).includes(val))) { |
|
return `Name: ${charaDefJson['name']}\n \nDescription: ${charaDefJson['description']}\n \nMessage example: ${charaDefJson['mes_example']}\n \nFirst message: ${charaDefJson['first_mes']}`; |
|
} |
|
} |
|
|
|
// fallback to simple text extraction |
|
const textData = await largeuint8ArrToString(chunks) |
|
const textTypes = ["Dream","parameters"] |
|
|
|
if(textData.includes("IDAT") && textTypes.some(x => textData.includes(x))) { |
|
const result = textData.split("IDAT")[0] |
|
.replace(new RegExp(`[\\s\\S]+Xt(${textTypes.join('|')})`),"") |
|
.replace(/[^\x00-\x7F]/g,"") |
|
|
|
if(result.length > 50) return result |
|
} |
|
|
|
return false; |
|
} |
|
})(); |