// ==UserScript==
// @name Prompt Extractor
// @namespace https://github.com/toriato/userscripts/prompt-extractor.user.js
// @version 0.1.3
// @description 이미지로부터 Exif 정보를 추출해 사용자에게 보여줍니다
// @author Sangha Lee <totoriato@gmail.com>
// @license MIT
// @match https://arca.live/b/*
// @run-at document-start
// @grant GM_xmlhttpRequest
// @grant GM_addStyle
// @require https://unpkg.com/exifreader/dist/exif-reader.js
// ==/UserScript==
GM_addStyle(/*css*/`
@keyframes spin {
from { transform:rotate(0deg) }
to { transform:rotate(360deg) }
}
figure.params {
margin: 0;
position: relative;
display: table;
}
figure.params img {
max-width: 100%;
}
/* 우측 상단 상태 아이콘 */
figure.params:not([data-params=""])::after {
position: absolute;
right: 0;
top: 0;
margin: .5em;
font-size: 2rem;
text-shadow: 0 0 4px black;
content: '❤️'
}
figure.params.loading::after {
animation: spin 1s infinite linear;
content: '🌀'
}
figure.params:not(.loading):not([data-params=""]):hover::after {
display: none;
}
figure.params figcaption {
transition: transform .25s, opacity .25s;
transform: scaleY(0);
transform-origin: top;
position: absolute;
left: 0;
top: 0;
overflow-y: auto;
max-height: 50%;
padding: .5em;
opacity: 0;
background-color: rgba(0, 0, 0, 0.5);
text-align: left;
pointer-events: none;
}
figure.params:not(.loading):not([data-params=""]):hover figcaption {
transform: scaleY(1);
opacity: 1;
pointer-events: inherit;
}
`)
/**
* UPNG.js - JS PNG Decoder/Encoder
* https://github.com/photopea/UPNG.js
* MIT License
*/
class UPNG {
static bin = {
nextZero: (data, p) => {
while (data[p] != 0) p++
return p
},
readUshort: (buff, p) =>
(buff[p] << 8) | buff[p + 1],
writeUshort: (buff, p, n) => {
buff[p] = (n >> 8) & 255
buff[p + 1] = n & 255
},
readUint: (buff, p) =>
(buff[p] * (256 * 256 * 256)) + ((buff[p + 1] << 16) | (buff[p + 2] << 8) | buff[p + 3]),
writeUint: (buff, p, n) => {
buff[p] = (n >> 24) & 255
buff[p + 1] = (n >> 16) & 255
buff[p + 2] = (n >> 8) & 255
buff[p + 3] = n & 255
},
readASCII: (buff, p, l) => {
let s = ''
for (let i = 0; i < l; i++)
s += String.fromCharCode(buff[p + i])
return s
},
writeASCII: (data, p, s) => {
for (let i = 0; i < s.length; i++)
data[p + i] = s.charCodeAt(i)
},
readBytes: (buff, p, l) => {
const arr = []
for (let i = 0; i < l; i++)
arr.push(buff[p + i])
return arr
},
pad: (n) =>
n.length < 2 ? '0' + n : n,
readUTF8: function (buff, p, l) {
let s = ''
let ns
for (var i = 0; i < l; i++) s += '%' + UPNB.bin.pad(buff[p + i].toString(16))
try { ns = decodeURIComponent(s) }
catch (e) { return UPNG.bin.readASCII(buff, p, l) }
return ns
}
}
static magicNumbers = [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]
static decode(buff) {
const bin = UPNG.bin
const data = new Uint8Array(buff)
const texts = {}
for (let i = 0; i < 8; i++) {
if (data[i] !== UPNG.magicNumbers[i]) {
throw Error('Input file is not a PNG')
}
}
let offset = 8
while (offset < data.length) {
const len = bin.readUint(data, offset); offset += 4;
const type = bin.readASCII(data, offset, 4); offset += 4;
// 스펙 상 tEXt 청크는 순서 관계 없으나 프롬프트는 상단에 위치하므로
// 빠른 처리를 위해 데이터가 시작되면 중단함
// https://www.w3.org/TR/2003/REC-PNG-20031110/#5ChunkOrdering
if (type === 'IDAT') {
break
}
if (type === 'tEXt') {
const nz = bin.nextZero(data, offset);
const keyword = bin.readASCII(data, offset, nz - offset);
const textLen = offset + len - nz - 1;
texts[keyword] = bin.readASCII(data, nz + 1, textLen)
}
offset += len + 4;
}
return texts
}
}
/**
* 파라미터 문자열 파싱에 사용되는 정규표현식 패턴
*
* https://github.com/AUTOMATIC1111/stable-diffusion-webui/blob/0cc0ee1bcb4c24a8c9715f66cede06601bfc00c8/modules/generation_parameters_copypaste.py#L15
*/
const paramsPattern = /\s*([\w ]+):\s*("(?:\\"[^,]|\\"|\\|[^\"])+"|[^,]*)(?:,|$)/g
/**
* 생성에 사용된 파라미터 문자열을 키, 값 형식의 Object 로 파싱합니다.
*
* https://github.com/AUTOMATIC1111/stable-diffusion-webui/blob/0cc0ee1bcb4c24a8c9715f66cede06601bfc00c8/modules/generation_parameters_copypaste.py#LL226C13-L226C13
* @param {string} str
* @returns {Object.<string, string>}
*/
function parseGenerationParams(str) {
const lines = str.trim().split('\n')
const res = Object.fromEntries(
// 반환 값: [ 전체 문자열, 키, 값 ]
[...lines.pop().matchAll(paramsPattern)]
// 첫번째 값은 일치한 전체 문자열이므로 필요 없음
.map(v => v.slice(1))
)
// 프롬프트와 부정 프롬프트는 둘 다 여러 줄일 수 있기 때문에 반복문으로 확인해야 함
let key = 'Prompt'
for (let line of lines) {
// 맨 앞 문자열이 일치하면 그 때부터 네거티브 프롬프트로 처리하는데...
// 일반 프롬프트에 동일한 문자열이 존재하면 오작동하지 않을까?
// 근데 자동좌 레포지토리에서도 이렇게 처리하니까 아무튼 내 잘못 아님
// https://github.com/AUTOMATIC1111/stable-diffusion-webui/blob/0cc0ee1bcb4c24a8c9715f66cede06601bfc00c8/modules/generation_parameters_copypaste.py#L251
if (line.startsWith('Negative prompt:')) {
key = 'Negative Prompt'
line = line.slice(16).trim()
}
// 없으면 새 문자열 만들고 있으면 새 줄 넣고 추가하기
if (key in res) {
res[key] += '\n' + line
} else {
res[key] = line
}
}
return res
}
/**
* 이미지가 모두 불러와졌을 때 실행되는 이벤트 함수입니다.
*
* @param {UIEvent} event
*/
function onLoad(event) {
/** @type {HTMLImageElement} */
const node = event.target
// 작은 이미지는 메타데이터 확인하지 않기
const rect = node.getBoundingClientRect()
if (rect.width < 128 || rect.height < 128) {
return
}
let src = new URL(node.src)
// 아카라이브에선 원본 이미지에만 Exif 데이터가 존재함
if (src.host.endsWith('namu.la')) {
src.searchParams.set('type', 'orig')
}
// 기존 이미지 요소 위에 파라미터를 표시하기 위해 figure 요소로 감싸기
const $figure = document.createElement('figure')
$figure.classList.add('params', 'loading')
$figure.innerHTML = /*html*/`
${node.closest('p').innerHTML}
<figcaption></figcaption>
`
node.closest('p').replaceWith($figure)
// Exif 로부터 파라미터 문자열 가져오기
let params = ''
// 이미 불러온 이미지로는 데이터를 가져올 수 없기 때문에 새 요청을 만들 필요가 있음
// 브라우저가 캐시해줄테니 속도에 큰 지장을 주진 않을거임... 아마도...?
new Promise((resolve, reject) => {
GM_xmlhttpRequest({
url: src.toString(),
responseType: 'arraybuffer',
onload: resolve,
onerror: reject
})
})
.then(res => {
const headers = Object.fromEntries(
res.responseHeaders
.split(/\r?\n/)
.map(v => {
const [key, value] = v.split(':', 2).map(v => v.trim())
return [key.toLowerCase(), value]
})
)
const contentType = headers['content-type']
switch (contentType) {
// PNG 는 Exif 가 아닌 tEXt 키워드를 통해 파라미터가 저장되기 때문에
// UPNG 라이브러리를 통해 파라미터 문자열을 가져올 수 있음
case 'image/png':
const texts = UPNG.decode(res.response)
params = texts['parameters'] ?? ''
break
// ExifReader 라이브러리를 사용해 Exif 중 UserComment 로부터 파라미터 가져오기
// https://github.com/mattiasw/ExifReader
default:
// 반환 받은 파일이 이미지가 아니라면 무시하기
if (!contentType.startsWith('image/')) {
return
}
try {
const tags = ExifReader.load(res.response)
if (tags?.UserComment?.value) {
params = String.fromCharCode(
// 첫 8바이트는 인코딩 타입이므로 디코딩 할 필요 없음
// https://www.awaresystems.be/imaging/tiff/tifftags/privateifd/exif/usercomment.html
...tags.UserComment.value.slice(8).filter(v => v !== 0)
)
}
} catch (err) {
// 메타데이터가 존재하지 않는 이미지라면 무시하기
if (err.name !== 'MetadataMissingError') {
throw err
}
}
}
})
// TODO: 깔끔한 오류 핸들링
// .catch(err => ...)
// figcaption 요소를 통해 파라미터 정보 표시하기
.finally(() => {
$figure.classList.remove('loading')
$figure.dataset.params = params
// 파라미터 값이 존재하지 않는다면 하위 요소 생성하지 않기
if (!params) {
return
}
// TODO: 파싱한 파라미터 표로 보여주고 복사하는 기능 만들기
// const parsedParams = parseGenerationParams(params)
// console.log(parsedParams)
$figure.querySelector('figcaption').innerHTML = params
})
}
// 새로 추가되는 이미지 요소에 load 이벤트 등록하기
new MutationObserver(mutations => {
for (let mutation of mutations) {
for (let node of mutation.addedNodes) {
// 노드가 이미지 태그가 아니라면 무시하기
if (!(node instanceof HTMLImageElement)) {
continue
}
node.addEventListener('load', onLoad)
}
}
}).observe(
document,
{
attributes: true,
childList: true,
subtree: true
}
)