Prompt Extractor

이미지로부터 Exif 정보를 추출해 사용자에게 보여줍니다

  1. // ==UserScript==
  2. // @name Prompt Extractor
  3. // @namespace https://github.com/toriato/userscripts/prompt-extractor.user.js
  4. // @version 0.1.3
  5. // @description 이미지로부터 Exif 정보를 추출해 사용자에게 보여줍니다
  6. // @author Sangha Lee <totoriato@gmail.com>
  7. // @license MIT
  8. // @match https://arca.live/b/*
  9. // @run-at document-start
  10. // @grant GM_xmlhttpRequest
  11. // @grant GM_addStyle
  12. // @require https://unpkg.com/exifreader/dist/exif-reader.js
  13. // ==/UserScript==
  14.  
  15. GM_addStyle(/*css*/`
  16. @keyframes spin {
  17. from { transform:rotate(0deg) }
  18. to { transform:rotate(360deg) }
  19. }
  20.  
  21. figure.params {
  22. margin: 0;
  23. position: relative;
  24. display: table;
  25. }
  26. figure.params img {
  27. max-width: 100%;
  28. }
  29.  
  30. /* 우측 상단 상태 아이콘 */
  31. figure.params:not([data-params=""])::after {
  32. position: absolute;
  33. right: 0;
  34. top: 0;
  35. margin: .5em;
  36. font-size: 2rem;
  37. text-shadow: 0 0 4px black;
  38. content: '❤️'
  39. }
  40. figure.params.loading::after {
  41. animation: spin 1s infinite linear;
  42. content: '🌀'
  43. }
  44. figure.params:not(.loading):not([data-params=""]):hover::after {
  45. display: none;
  46. }
  47.  
  48. figure.params figcaption {
  49. transition: transform .25s, opacity .25s;
  50. transform: scaleY(0);
  51. transform-origin: top;
  52. position: absolute;
  53. left: 0;
  54. top: 0;
  55. overflow-y: auto;
  56. max-height: 50%;
  57. padding: .5em;
  58. opacity: 0;
  59. background-color: rgba(0, 0, 0, 0.5);
  60. text-align: left;
  61. pointer-events: none;
  62. }
  63. figure.params:not(.loading):not([data-params=""]):hover figcaption {
  64. transform: scaleY(1);
  65. opacity: 1;
  66. pointer-events: inherit;
  67. }
  68. `)
  69.  
  70. /**
  71. * UPNG.js - JS PNG Decoder/Encoder
  72. * https://github.com/photopea/UPNG.js
  73. * MIT License
  74. */
  75. class UPNG {
  76. static bin = {
  77. nextZero: (data, p) => {
  78. while (data[p] != 0) p++
  79. return p
  80. },
  81. readUshort: (buff, p) =>
  82. (buff[p] << 8) | buff[p + 1],
  83. writeUshort: (buff, p, n) => {
  84. buff[p] = (n >> 8) & 255
  85. buff[p + 1] = n & 255
  86. },
  87. readUint: (buff, p) =>
  88. (buff[p] * (256 * 256 * 256)) + ((buff[p + 1] << 16) | (buff[p + 2] << 8) | buff[p + 3]),
  89. writeUint: (buff, p, n) => {
  90. buff[p] = (n >> 24) & 255
  91. buff[p + 1] = (n >> 16) & 255
  92. buff[p + 2] = (n >> 8) & 255
  93. buff[p + 3] = n & 255
  94. },
  95. readASCII: (buff, p, l) => {
  96. let s = ''
  97. for (let i = 0; i < l; i++)
  98. s += String.fromCharCode(buff[p + i])
  99. return s
  100. },
  101. writeASCII: (data, p, s) => {
  102. for (let i = 0; i < s.length; i++)
  103. data[p + i] = s.charCodeAt(i)
  104. },
  105. readBytes: (buff, p, l) => {
  106. const arr = []
  107. for (let i = 0; i < l; i++)
  108. arr.push(buff[p + i])
  109. return arr
  110. },
  111. pad: (n) =>
  112. n.length < 2 ? '0' + n : n,
  113. readUTF8: function (buff, p, l) {
  114. let s = ''
  115. let ns
  116. for (var i = 0; i < l; i++) s += '%' + UPNB.bin.pad(buff[p + i].toString(16))
  117. try { ns = decodeURIComponent(s) }
  118. catch (e) { return UPNG.bin.readASCII(buff, p, l) }
  119. return ns
  120. }
  121. }
  122.  
  123. static magicNumbers = [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]
  124.  
  125. static decode(buff) {
  126. const bin = UPNG.bin
  127. const data = new Uint8Array(buff)
  128. const texts = {}
  129.  
  130. for (let i = 0; i < 8; i++) {
  131. if (data[i] !== UPNG.magicNumbers[i]) {
  132. throw Error('Input file is not a PNG')
  133. }
  134. }
  135.  
  136. let offset = 8
  137. while (offset < data.length) {
  138. const len = bin.readUint(data, offset); offset += 4;
  139. const type = bin.readASCII(data, offset, 4); offset += 4;
  140.  
  141. // 스펙 상 tEXt 청크는 순서 관계 없으나 프롬프트는 상단에 위치하므로
  142. // 빠른 처리를 위해 데이터가 시작되면 중단함
  143. // https://www.w3.org/TR/2003/REC-PNG-20031110/#5ChunkOrdering
  144. if (type === 'IDAT') {
  145. break
  146. }
  147.  
  148. if (type === 'tEXt') {
  149. const nz = bin.nextZero(data, offset);
  150. const keyword = bin.readASCII(data, offset, nz - offset);
  151. const textLen = offset + len - nz - 1;
  152. texts[keyword] = bin.readASCII(data, nz + 1, textLen)
  153. }
  154.  
  155. offset += len + 4;
  156. }
  157.  
  158. return texts
  159. }
  160. }
  161.  
  162. /**
  163. * 파라미터 문자열 파싱에 사용되는 정규표현식 패턴
  164. *
  165. * https://github.com/AUTOMATIC1111/stable-diffusion-webui/blob/0cc0ee1bcb4c24a8c9715f66cede06601bfc00c8/modules/generation_parameters_copypaste.py#L15
  166. */
  167. const paramsPattern = /\s*([\w ]+):\s*("(?:\\"[^,]|\\"|\\|[^\"])+"|[^,]*)(?:,|$)/g
  168.  
  169. /**
  170. * 생성에 사용된 파라미터 문자열을 키, 값 형식의 Object 로 파싱합니다.
  171. *
  172. * https://github.com/AUTOMATIC1111/stable-diffusion-webui/blob/0cc0ee1bcb4c24a8c9715f66cede06601bfc00c8/modules/generation_parameters_copypaste.py#LL226C13-L226C13
  173. * @param {string} str
  174. * @returns {Object.<string, string>}
  175. */
  176. function parseGenerationParams(str) {
  177. const lines = str.trim().split('\n')
  178. const res = Object.fromEntries(
  179. // 반환 값: [ 전체 문자열, 키, 값 ]
  180. [...lines.pop().matchAll(paramsPattern)]
  181. // 첫번째 값은 일치한 전체 문자열이므로 필요 없음
  182. .map(v => v.slice(1))
  183. )
  184.  
  185. // 프롬프트와 부정 프롬프트는 둘 다 여러 줄일 수 있기 때문에 반복문으로 확인해야 함
  186. let key = 'Prompt'
  187.  
  188. for (let line of lines) {
  189. // 맨 앞 문자열이 일치하면 그 때부터 네거티브 프롬프트로 처리하는데...
  190. // 일반 프롬프트에 동일한 문자열이 존재하면 오작동하지 않을까?
  191. // 근데 자동좌 레포지토리에서도 이렇게 처리하니까 아무튼 내 잘못 아님
  192. // https://github.com/AUTOMATIC1111/stable-diffusion-webui/blob/0cc0ee1bcb4c24a8c9715f66cede06601bfc00c8/modules/generation_parameters_copypaste.py#L251
  193. if (line.startsWith('Negative prompt:')) {
  194. key = 'Negative Prompt'
  195. line = line.slice(16).trim()
  196. }
  197.  
  198. // 없으면 새 문자열 만들고 있으면 새 줄 넣고 추가하기
  199. if (key in res) {
  200. res[key] += '\n' + line
  201. } else {
  202. res[key] = line
  203. }
  204. }
  205.  
  206. return res
  207. }
  208.  
  209. /**
  210. * 이미지가 모두 불러와졌을 때 실행되는 이벤트 함수입니다.
  211. *
  212. * @param {UIEvent} event
  213. */
  214. function onLoad(event) {
  215. /** @type {HTMLImageElement} */
  216. const node = event.target
  217.  
  218. // 작은 이미지는 메타데이터 확인하지 않기
  219. const rect = node.getBoundingClientRect()
  220. if (rect.width < 128 || rect.height < 128) {
  221. return
  222. }
  223.  
  224. let src = new URL(node.src)
  225.  
  226. // 아카라이브에선 원본 이미지에만 Exif 데이터가 존재함
  227. if (src.host.endsWith('namu.la')) {
  228. src.searchParams.set('type', 'orig')
  229. }
  230.  
  231. // 기존 이미지 요소 위에 파라미터를 표시하기 위해 figure 요소로 감싸기
  232. const $figure = document.createElement('figure')
  233. $figure.classList.add('params', 'loading')
  234. $figure.innerHTML = /*html*/`
  235. ${node.closest('p').innerHTML}
  236. <figcaption></figcaption>
  237. `
  238.  
  239. node.closest('p').replaceWith($figure)
  240.  
  241. // Exif 로부터 파라미터 문자열 가져오기
  242. let params = ''
  243.  
  244. // 이미 불러온 이미지로는 데이터를 가져올 수 없기 때문에 새 요청을 만들 필요가 있음
  245. // 브라우저가 캐시해줄테니 속도에 큰 지장을 주진 않을거임... 아마도...?
  246. new Promise((resolve, reject) => {
  247. GM_xmlhttpRequest({
  248. url: src.toString(),
  249. responseType: 'arraybuffer',
  250. onload: resolve,
  251. onerror: reject
  252. })
  253. })
  254. .then(res => {
  255. const headers = Object.fromEntries(
  256. res.responseHeaders
  257. .split(/\r?\n/)
  258. .map(v => {
  259. const [key, value] = v.split(':', 2).map(v => v.trim())
  260. return [key.toLowerCase(), value]
  261. })
  262. )
  263.  
  264. const contentType = headers['content-type']
  265. switch (contentType) {
  266. // PNG 는 Exif 가 아닌 tEXt 키워드를 통해 파라미터가 저장되기 때문에
  267. // UPNG 라이브러리를 통해 파라미터 문자열을 가져올 수 있음
  268. case 'image/png':
  269. const texts = UPNG.decode(res.response)
  270. params = texts['parameters'] ?? ''
  271. break
  272.  
  273. // ExifReader 라이브러리를 사용해 Exif 중 UserComment 로부터 파라미터 가져오기
  274. // https://github.com/mattiasw/ExifReader
  275. default:
  276. // 반환 받은 파일이 이미지가 아니라면 무시하기
  277. if (!contentType.startsWith('image/')) {
  278. return
  279. }
  280.  
  281. try {
  282. const tags = ExifReader.load(res.response)
  283. if (tags?.UserComment?.value) {
  284. params = String.fromCharCode(
  285. // 첫 8바이트는 인코딩 타입이므로 디코딩 할 필요 없음
  286. // https://www.awaresystems.be/imaging/tiff/tifftags/privateifd/exif/usercomment.html
  287. ...tags.UserComment.value.slice(8).filter(v => v !== 0)
  288. )
  289. }
  290.  
  291. } catch (err) {
  292. // 메타데이터가 존재하지 않는 이미지라면 무시하기
  293. if (err.name !== 'MetadataMissingError') {
  294. throw err
  295. }
  296. }
  297. }
  298. })
  299.  
  300. // TODO: 깔끔한 오류 핸들링
  301. // .catch(err => ...)
  302.  
  303. // figcaption 요소를 통해 파라미터 정보 표시하기
  304. .finally(() => {
  305. $figure.classList.remove('loading')
  306. $figure.dataset.params = params
  307.  
  308. // 파라미터 값이 존재하지 않는다면 하위 요소 생성하지 않기
  309. if (!params) {
  310. return
  311. }
  312.  
  313. // TODO: 파싱한 파라미터 표로 보여주고 복사하는 기능 만들기
  314. // const parsedParams = parseGenerationParams(params)
  315. // console.log(parsedParams)
  316.  
  317. $figure.querySelector('figcaption').innerHTML = params
  318. })
  319. }
  320.  
  321. // 새로 추가되는 이미지 요소에 load 이벤트 등록하기
  322. new MutationObserver(mutations => {
  323. for (let mutation of mutations) {
  324. for (let node of mutation.addedNodes) {
  325. // 노드가 이미지 태그가 아니라면 무시하기
  326. if (!(node instanceof HTMLImageElement)) {
  327. continue
  328. }
  329.  
  330. node.addEventListener('load', onLoad)
  331. }
  332. }
  333. }).observe(
  334. document,
  335. {
  336. attributes: true,
  337. childList: true,
  338. subtree: true
  339. }
  340. )