RE: AI 이미지 EXIF 뷰어

AI 이미지 메타데이터 보기

// ==UserScript==
// @name        RE: AI 이미지 EXIF 뷰어
// @namespace   https://github.com/panta5/AI-Image-EXIF-Viewer
// @match       https://www.pixiv.net/*
// @match       https://arca.live/b/aiart*
// @match       https://arca.live/b/hypernetworks*
// @match       https://arca.live/b/aiartreal*
// @match       https://arca.live/b/aireal*
// @match       https://arca.live/b/characterai*
// @version     2.1.1+1.0
// @author      PantaFive
// @homepageURL https://github.com/panta5/AI-Image-EXIF-Viewer
// @require     https://cdnjs.cloudflare.com/ajax/libs/pako/2.1.0/pako.min.js
// @require     https://greasyfork.org/scripts/452821-upng-js/code/UPNGjs.js?version=1103227
// @require     https://cdn.jsdelivr.net/npm/casestry-exif-library@2.0.3/dist/exif-library.min.js
// @require     https://cdn.jsdelivr.net/npm/sweetalert2@11
// @require     https://cdn.jsdelivr.net/npm/clipboard@2.0.10/dist/clipboard.min.js
// @require     https://cdnjs.cloudflare.com/ajax/libs/arrive/2.4.1/arrive.min.js
// @require     https://greasyfork.org/scripts/421384-gm-fetch/code/GM_fetch.js
// @grant       GM_xmlhttpRequest
// @grant       GM_registerMenuCommand
// @grant       GM_setValue
// @grant       GM_getValue
// @grant       GM_addStyle
// @grant       GM_download
// @grant       GM_info

// @description AI 이미지 메타데이터 보기
// @license MIT
// ==/UserScript==

//this URL must be changed manually to be linked properly
// const scriptGreasyforkURL = '#';
//toast timer in ms
const toastTimer = 3000;
const colorOption1 = '#5cc964';
const colorOption2 = '#ff9d0b';
const colorClose = '#b41b29';

const footerString = `<div class="version">v${GM_info.script.version}  -  <a href="${GM_info.script.namespace}" target="_blank">Forked Repository</a>  -  <a href="https://github.com/nyqui/AI-Image-EXIF-Viewer" target="_blank">Original GitHub Repository</a></div>`;

(async function () {
    'use strict';

    const modalCSS = /* css */ `
  font-family: -apple-system, BlinkMacSystemFont, NanumBarunGothic, NanumGothic, system-ui, sans-serif;
  .swal2-popup {
    font-size: 15px;
  }
  .swal2-actions {
    margin: .4em auto 0;
  }
  .swal2-footer{
    margin: 1em 1.6em .3em;
    padding: 1em 0 0;
    overflow: auto;
    font-size: 1.125em;
  }
  #dropzone {
    z-index: 100000000;
    display: none;
    position: fixed;
    width: 100%;
    height: 100%;
    left: 0;
    top: 0;
  }

  .md-grid {
    display: grid;
    grid-template-rows: repeat(3, auto);
    text-align: left;
  }

  .md-grid-item {
    border-bottom: 1px solid #b3b3b3;
    padding: .6em;
  }

  .md-grid-item:last-child {
    border-bottom: 0px;
  }

  .md-nested-grid {
    display: grid;
    grid-template-columns: repeat(3, 1fr);
    grid-template-rows: repeat(4, auto);
    gap: .5em;
  }

  .md-title {
    line-height: 1em;
    font-weight: bold;
    font-size: .9em;
    padding-bottom: .2em;
    display: flex;
    color: #1A1A1A;
  }

  .md-info {
    line-height: 1.5em;
    font-size: .8em;
    word-break: break-word;
    color: #444444;
  }

  .md-hidden {
    overflow: hidden;
    position: relative;
    max-height: 5em;
  }

  .md-hidden:after {
    content: "";
    position: absolute;
    bottom: 0;
    left: 0;
    width: 100%;
    height: 2em;
    background: linear-gradient(to bottom, rgba(255, 255, 255, 0.4) 0%, white 100%);
  }
  .md-info > a{
    text-decoration: none;
  }
  .md-info > a:hover{
    text-decoration: underline !important;
  }
  pre.md-show-and-hide{
    font-family: monospace;
    margin: 0px;
    white-space: pre-line;
  }

  .md-visible {
    height: auto;
    overflow: auto;
  }

  .md-model {
    grid-column-start: 1;
    grid-column-end: 3;
  }

  .md-show-more {
    text-align: center;
    cursor: pointer;
  }

  #md-tags {
    width: 100%;
    height: 20em;
    padding-top: .5em;
    text-align: left;
    font-size: 0.9em;
  }
  span.md-button {
    margin-left: .15em;
    cursor: pointer;
  }

  span.md-copy {
    content: url("data:image/svg+xml,%3Csvg width='16' height='16' viewBox='0 0 16 16' xmlns='http://www.w3.org/2000/svg' xmlns:xlink='http://www.w3.org/1999/xlink'%3E%3Crect width='16' height='16' stroke='none' fill='%23000000' opacity='0'/%3E%3Cg transform='matrix(0.6 0 0 0.6 8 8)' %3E%3Cpath style='stroke: none; stroke-width: 1; stroke-dasharray: none; stroke-linecap: butt; stroke-dashoffset: 0; stroke-linejoin: miter; stroke-miterlimit: 4; fill: rgb(0,0,0); fill-rule: nonzero; opacity: 1;' transform=' translate(-12, -12)' d='M 4 2 C 2.895 2 2 2.895 2 4 L 2 18 L 4 18 L 4 4 L 18 4 L 18 2 L 4 2 z M 8 6 C 6.895 6 6 6.895 6 8 L 6 20 C 6 21.105 6.895 22 8 22 L 20 22 C 21.105 22 22 21.105 22 20 L 22 8 C 22 6.895 21.105 6 20 6 L 8 6 z M 8 8 L 20 8 L 20 20 L 8 20 L 8 8 z' stroke-linecap='round' /%3E%3C/g%3E%3C/svg%3E");
  }

  span.md-civitai {
    content: url("data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAMAAAAoLQ9TAAAAllBMVEVHcEwORtARe/UMPdYPY+gOUuAKKc0RdvENT94LKc0LJMcLMM8LMc8SgPUSffIRePERcu4MN9EQbewQauoLKswLKswUIo0PZ+gWDmT///8LLtMPLKwQcvESA00LF4UiGWgKB2wNSuAMNM8LOtgPRb2XlLxDQY7Fw9kOW+cMO9NBNHG7utXR0OLv7vTc2+fe3utNSIs7N4PzUc8zAAAAFnRSTlMABvvJy9Kz18fsI9hVphtuSzKwKn11Da4jUwAAALdJREFUeNpNz8eWwjAMBVDZCQk9VMndTiGFPv//c3NimIG3ku5CRw8gZr1areETVuSc84L97ZvccCJu8k1c9ztjqHGuIWN2ewBIiRoX2ja4hiiNoAOepDxh0JQygLRHcZWXi3x47CcMYNqjv3Uh/LTiDeUIQrRPgeUbxFV2nbx7FyEp0frxqLdYJiMMgxb2fLZCD/UI26yqK7TWVXWVbOOvy4VSWiu1WP6XO86Umh3YV995ls1f0y8RAhFlMPQmQwAAAABJRU5ErkJggg==");
  }

  .version {
    margin: 1px;
    text-align: right;
    font-size: .5em;
    font-style: italic;
  }
  `;

    const toastmix = Swal.mixin({
        toast: true,
        position: 'bottom',
        showConfirmButton: false,
        timer: `${toastTimer}`,
        timerProgressBar: true,
    });

    function registerMenu() {
        try {
            if (typeof GM_registerMenuCommand == undefined) {
                return;
            } else {
                GM_registerMenuCommand('(로그인 필수) Pixiv 뷰어 사용 토글', () => {
                    if (GM_getValue('usePixiv', false)) {
                        GM_setValue('usePixiv', false);
                        toastmix.fire({
                            icon: 'error',
                            title: `Pixiv 비활성화
                      창이 닫힌 후 새로고침 됩니다`,
                            didDestroy: () => {
                                location.reload();
                            },
                        });
                    } else {
                        GM_setValue('usePixiv', true);
                        toastmix.fire({
                            icon: 'success',
                            title: `Pixiv 활성화
                      창이 닫힌 후 새로고침 됩니다`,
                            didDestroy: () => {
                                location.reload();
                            },
                        });
                    }
                });
                GM_registerMenuCommand('아카라이브 EXIF 보존 토글', () => {
                    if (GM_getValue('saveExifDefault', true)) {
                        GM_setValue('saveExifDefault', false);
                        toastmix.fire({
                            icon: 'error',
                            title: `아카라이브 EXIF 보존 비활성화
                      다음번 작성시부터 버려집니다`,
                        });
                    } else {
                        GM_setValue('saveExifDefault', true);
                        toastmix.fire({
                            icon: 'success',
                            title: `아카라이브 EXIF 보존 활성화
                      다음번 작성시부터 보존됩니다`,
                        });
                    }
                });
                GM_registerMenuCommand('아카라이브 글쓰기 창 스크립트 토글', () => {
                    if (GM_getValue('useDragdropUpload', true)) {
                        GM_setValue('useDragdropUpload', false);
                        toastmix.fire({
                            icon: 'error',
                            title: `아카 글쓰기 창 스크립트 비활성화
                      다음번 작성시부터 적용됩니다`,
                        });
                    } else {
                        GM_setValue('useDragdropUpload', true);
                        toastmix.fire({
                            icon: 'success',
                            title: `아카 글쓰기 창 스크립트 활성화
                      다음번 작성시부터 적용됩니다`,
                        });
                    }
                });
            }
        } catch (err) {
            console.log(err);
        }
    }

    class DropZone {
        constructor() {
            const dropZone = document.createElement('div');
            dropZone.setAttribute('id', 'dropzone');
            document.body.appendChild(dropZone);
            this.dropZone = document.getElementById('dropzone');
            this.setupEventListeners();
        }

        showDropZone() {
            this.dropZone.style.display = 'block';
        }

        hideDropZone() {
            this.dropZone.style.display = 'none';
        }

        allowDrag(e) {
            e.preventDefault();
        }

        async handleDrop(e) {
            e.preventDefault();
            this.hideDropZone();

            const file = e.dataTransfer.files[0];
            if (!file) return;

            const blob = await fileToBlob(file);
            const type = blob.type;
            if (isArcaEditor) {
                const uploadableType = handleUploadable(type);
                let editor = document.querySelector('.write-body .fr-element');
                let saveEXIF = GM_getValue('saveExifDefault', true);
                if (uploadableType == 'image') {
                    try {
                        saveEXIF = document.getElementById('saveExif').checked;
                    } catch {}
                    uploadArca(blob, uploadableType, saveEXIF).then((url) => {
                        editor.innerHTML =
                            editor.innerHTML + `<p><img src="${url}" class="fr-fic fr-dii"></p><p><br></p>`;
                        Swal.close();
                    });
                } else if (uploadableType == 'video') {
                    uploadArca(blob, uploadableType, false).then((url) => {
                        editor.innerHTML =
                            editor.innerHTML +
                            `<p><span class="fr-video fr-dvi fr-draggable"><video class="fr-draggable" controls="" loop="" muted="" playsinline="" src="${url}">귀하의 브라우저는 html5 video를 지원하지 않습니다.</video></span></p><p><br></p>`;
                        Swal.close();
                    });
                } else {
                    Swal.close();
                    toastmix.fire({
                        icon: 'error',
                        title: `업로드 오류:
                    업로드 할 수 있는 포맷이 아닙니다.`,
                    });
                }
            } else {
                if (!isSupportedImageFormat(blob.type)) {
                    notSupportedFormat();
                    return;
                }
                const metadata = await extractImageMetadata(blob, type);
                metadata ? showMetadataModal(metadata) : showTagExtractionModal(null, blob);
            }
        }

        setupEventListeners() {
            window.addEventListener('dragenter', () => this.showDropZone());
            this.dropZone.addEventListener('dragenter', (e) => this.allowDrag(e));
            this.dropZone.addEventListener('dragover', (e) => this.allowDrag(e));
            this.dropZone.addEventListener('dragleave', () => this.hideDropZone());
            this.dropZone.addEventListener('drop', (e) => this.handleDrop(e));
        }
    }

    function getMetadataPNGChunk(chunk) {
        const isValidPNG = chunk.slice(0, 8).every((byte, index) => [137, 80, 78, 71, 13, 10, 26, 10][index] === byte);
        if (!isValidPNG) {
            console.error('Invalid PNG');
            return null;
        }

        const textDecoder = new TextDecoder('utf-8');
        let metadata = {};

        function checkForChunks() {
            let position = 8;
            while (true) {
                const chunkLength = getUint32(position);

                if (chunk.byteLength < position + chunkLength + 12) {
                    return;
                }
                const name = String.fromCharCode(...chunk.subarray(position + 4, position + 8));
                const data = chunk.subarray(position + 8, position + chunkLength + 8);
                const dataString = textDecoder.decode(data);

                if (name === 'tEXt') {
                    const [key, value] = dataString.split('\0');
                    metadata[key] = value;
                } else if (name === 'iTXt') {
                    const [key, value] = dataString.split('\0\0\0\0\0');
                    metadata[key] = value;
                } else if (name === 'IDAT') {
                    metadata[name] = true;
                    return;
                }
                position += chunkLength + 12;
            }
        }

        function getUint32(offset) {
            return (chunk[offset] << 24) | (chunk[offset + 1] << 16) | (chunk[offset + 2] << 8) | chunk[offset + 3];
        }
        checkForChunks();
        return metadata;
    }

    function getMetadataJPEGChunk(chunk) {
        if (chunk[0] !== 255 || chunk[1] !== 216) {
            // 0xFF 0xD8
            console.error('Invalid JPEG');
            return null;
        }
        const textDecoder = new TextDecoder();
        let offset = 2;
        if (chunk[offset] === 0xff) {
            switch (chunk[offset + 1]) {
                case 0xe0: {
                    offset += ((chunk[offset + 2] << 8) | chunk[offset + 3]) + 2;
                }
                case 0xe1: {
                    const length = (chunk[offset + 2] << 8) | chunk[offset + 3];
                    const data = chunk.subarray(offset + 4, offset + 2 + length);
                    if (
                        data[0] === 69 && //0x45 E
                        data[1] === 120 && //0x78 x
                        data[2] === 105 && //0x69 i
                        data[3] === 102 && //0x66 f
                        data[4] === 0 && // null
                        data[5] === 0 // null
                    ) {
                        const userCommentData = data.subarray(46, offset + 2 + length);
                        const parameters = textDecoder
                            .decode(userCommentData)
                            .replace('UNICODE', '')
                            .replaceAll('\u0000', '');
                        return {
                            parameters,
                        };
                    }
                }
                default:
                    return null;
            }
        }
        return null;
    }

    function getFileName(url) {
        if (url === '/') return;
        const fileName = url.split('?')[0];
        return fileName;
    }

    function parseMetadata(exif) {
        try {
            let metadata = {};
            if (exif.parameters) {
                let parameters = exif.parameters.replaceAll('<', '&lt;').replaceAll('>', '&gt;');
                metadata.rawMetadata = parameters;

                if (!parameters.includes('Negative prompt')) {
                    parameters = parameters.replace('Steps', '\nNegative prompt: 정보 없음\nSteps');
                }

                parameters = parameters.split('Steps: ');
                parameters = `${parameters[0]
                    .replaceAll(': ', ':')
                    .replace('Negative prompt:', 'Negative prompt: ')}Steps: ${parameters[1]}`;

                const metadataStr = parameters.substring(parameters.indexOf('Steps'), parameters.length);
                const keyValuePairs = metadataStr.split(', ');

                for (const pair of keyValuePairs) {
                    const [key, value] = pair.split(': ');
                    metadata[key] = value;
                }

                metadata.prompt =
                    parameters.indexOf('Negative prompt') === 0
                        ? '정보 없음'
                        : parameters.substring(0, parameters.indexOf('Negative prompt:'));
                metadata.negativePrompt = parameters.includes('Negative prompt:')
                    ? parameters
                          .substring(parameters.indexOf('Negative prompt:'), parameters.indexOf('Steps:'))
                          .replace('Negative prompt:', '')
                    : null;

                return metadata;
            } else if (exif.Description) {
                metadata.rawMetadata = `${exif.Description}\n${exif.Comment}`;
                const comment = JSON.parse(exif.Comment);

                metadata.prompt = exif.Description;
                metadata.negativePrompt = comment.uc;
                metadata['Steps'] = comment.steps;
                metadata['Sampler'] = comment.sampler;
                metadata['CFG scale'] = comment.scale;
                metadata['Seed'] = comment.seed;
                metadata['Software'] = 'NovelAI';

                return metadata;
            } else if (exif['sd-metadata']) {
                metadata.rawMetadata = exif['sd-metadata'];
                const parameters = JSON.parse(exif['sd-metadata']);
                const rowPrompt = parameters.image.prompt[0].prompt;
                const PromptRegex = /[^[\]]+(?=\[|$)/g;
                const negativePromptRegex = /\[.*?\]/g;
                const promptArray = rowPrompt.match(PromptRegex);
                const negativePromptArray = rowPrompt.match(negativePromptRegex);
                const prompt = promptArray.map((prompt) => prompt.replace(/^\,|\,$/g, ''));
                const negativePrompt = negativePromptArray.map((prompt) =>
                    prompt.replace(/^\[|\]$/g, '').replace(/^\,|\,$/g, ''),
                );

                metadata.prompt = prompt.join(', ');
                metadata.negativePrompt = negativePrompt.join(', ');
                metadata['Steps'] = parameters?.image.steps;
                metadata['Model'] = parameters?.model;
                metadata['Model hash'] = parameters?.model_hash;
                metadata['Sampler'] = parameters?.image.sampler;
                metadata['CFG scale'] = parameters?.image.cfg_scale;
                metadata['Seed'] = parameters?.image.seed;
                metadata['Size'] = `${parameters?.image.width}x${parameters?.image.height}`;
                metadata['Software'] = 'InvokeAI';

                return metadata;
            }
        } catch (error) {
            console.log(error);
            Swal.fire({
                icon: 'error',
                confirmButtonColor: `${colorClose}`,
                confirmButtonText: '닫기',
                title: '분석 오류',
                html: `
        ${error}<br>
        오류내용과 이미지를 댓글로 알려주세요`,
            });
        }
    }

    function infer(metadata) {
        if (metadata?.Software) return [metadata.Software];
        const inferList = [];
        const denoising = metadata?.['Denoising strength'];
        const hires = metadata?.['Hires upscaler'];

        inferList.push('T2I');
        if (denoising && !hires) {
            inferList[0] = 'I2I';
        } else if (hires) {
            inferList.push('Hires. fix');
        }
        (metadata?.['AddNet Enabled'] ||
            metadata?.prompt?.includes('lora:') ||
            metadata?.negativePrompt?.includes('lora:')) &&
            inferList.push('LoRa');
        (metadata?.prompt?.includes('lyco:') || metadata?.negativePrompt?.includes('lyco:')) &&
            inferList.push('LyCORIS');
        (metadata?.['Hypernet'] ||
            metadata?.prompt?.includes('hypernet:') ||
            metadata?.negativePrompt?.includes('hypernet:')) &&
            inferList.push('Hypernet');

        const controlNetRegex = /(ControlNet)/;
        for (const key in metadata) {
            if (controlNetRegex.test(key)) {
                inferList.push('ControlNet');
                break;
            }
        }
        metadata?.['SD upscale upscaler'] && inferList.push('SD upscale');
        metadata?.['Ultimate SD upscale upscaler'] && inferList.push('Ultimate SD upscale');
        metadata?.['Latent Couple'] && inferList.push('Latent Couple');
        metadata?.['Dynamic thresholding enabled'] && inferList.push('Dynamic thresholding');
        metadata?.['LLuL Enabled'] && inferList.push('LLuL');
        metadata?.['Cutoff enabled'] && inferList.push('Cutoff');
        metadata?.['Tiled Diffusion'] && inferList.push('Tiled Diffusion');
        metadata?.['DDetailer model a'] && inferList.push('DDetailer');
        metadata?.['ADetailer version'] && inferList.push('ADetailer'); // DDetailer/ADetailer를 DINO 하나로 묶는 게 나을까?

        return inferList;
    }

    function showAndHide(elementSelector) {
        const contentEls = document.querySelectorAll(elementSelector);

        contentEls.forEach((contentEl) => {
            const containerEl = contentEl.parentElement;
            const showMoreEl = containerEl.nextElementSibling;

            if (contentEl.offsetHeight > containerEl.offsetHeight) {
                showMoreEl.style.display = 'block';
                containerEl.classList.add('md-hidden');
            } else {
                showMoreEl.style.display = 'none';
                containerEl.classList.remove('md-hidden');
                containerEl.classList.add('md-visible');
            }

            showMoreEl.addEventListener('click', () => {
                const isMore = showMoreEl.textContent === '더 보기';
                showMoreEl.textContent = isMore ? '숨기기' : '더 보기';
                containerEl.classList.toggle('md-hidden', !isMore);
                containerEl.classList.toggle('md-visible', isMore);
            });
        });
    }

    function showMetadataModal(metadata, url) {
        metadata = parseMetadata(metadata);
        const inferList = infer(metadata);
        const showMeta = Swal.mixin({
            title: '메타데이터 요약',
            html: /*html*/ `
    <div class="md-grid">
      <div class="md-grid-item">
        <div class="md-title">Prompt <span class="md-copy md-button" data-clipboard-target="#prompt"></span></div>
        <div class="md-info" id="prompt">
          ${metadata.prompt ?? '정보 없음'}
        </div>
      </div>
      <div class="md-grid-item">
        <div class="md-title">Negative Prompt
          <span class="md-copy md-button" data-clipboard-target="#negative-prompt"></span>
        </div>
        <div class="md-info">
          <div class="md-hidden">
            <div class="md-show-and-hide" id="negative-prompt">
              ${metadata.negativePrompt ?? '정보 없음'}
            </div>
          </div>
          <div class="md-show-more">더 보기</div>
        </div>
      </div>
      <div class="md-grid-item">
        <div class="md-nested-grid">
          <div>
            <div class="md-title">Sampler <span class="md-copy md-button" data-clipboard-target="#sampler"></span></div>
            <div class="md-info" id="sampler">${metadata['Sampler'] ?? '정보 없음'}</div>
          </div>
          <div>
            <div class="md-title">Seed <span class="md-copy md-button" data-clipboard-target="#seed"></span></div>
            <div class="md-info" id="seed">${metadata['Seed'] ?? '정보 없음'}</div>
          </div>
          <div>
            <div class="md-title">Steps <span class="md-copy md-button" data-clipboard-target="#steps"></span></div>
            <div class="md-info" id="steps">${metadata['Steps'] ?? '정보 없음'}</div>
          </div>
          <div>
            <div class="md-title">Size <span class="md-copy md-button" data-clipboard-target="#size"></span></div>
            <div class="md-info" id="size">${metadata['Size'] ?? '정보 없음'}</div>
          </div>
          <div>
            <div class="md-title">CFG scale <span class="md-copy md-button" data-clipboard-target="#cfg-scale"></span></div>
            <div class="md-info" id="cfg-scale">${metadata['CFG scale'] ?? '정보 없음'}</div>
          </div>
          <div>
            <div class="md-title">Denoising strength <span class="md-copy md-button" data-clipboard-target="#denoising-strength"></span></div>
            <div class="md-info" id="denoising-strength">${metadata['Denoising strength'] ?? '정보 없음'}</div>
          </div>
          <div class="md-model">
            <div class="md-title">Model
              <span class="md-copy md-button" data-clipboard-target="#model"></span>
              <a href='https://civitai.com/?query=${
                  metadata['Model hash']
              }' target='_blank'><span class="md-civitai md-button"></span></a>
            </div>
            <div class="md-info" id="model">${
                metadata['Model']
                    ? `${metadata['Model']} [${metadata['Model hash']}]`
                    : metadata['Model hash'] ?? '정보 없음'
            }</div>
          </div>
          <div>
            <div class="md-title">Infer...</div>
            <div class="md-info">${inferList.join(', ')}</div>
          </div>
        </div>
      </div>
      `,
            footer: /*html*/ `
      <div class="md-grid-item">
      <div class="md-title">Raw Metadata <span class="md-copy md-button" data-clipboard-target="#raw-metadata"></span>
      </div>
      <div class="md-info">
        <div class="md-hidden">
          <pre class="md-show-and-hide" id="raw-metadata">
          ${metadata.rawMetadata ?? '정보 없음'}
        </pre>
        </div>
        <div class="md-show-more">더 보기</div>
      </div>
      ${footerString}
      </div>
      `,
            width: '50em',
            showDenyButton: true,
            showCancelButton: true,
            focusCancel: true,
            confirmButtonColor: `${colorOption1}`,
            denyButtonColor: `${colorOption2}`,
            cancelButtonColor: `${colorClose}`,
            confirmButtonText: '이미지 열기',
            denyButtonText: '이미지 저장',
            cancelButtonText: '닫기',
        });

        // if image has URL, options are available to open in new tab or download
        if (url != null) {
            showMeta.fire().then((result) => {
                if (result.isConfirmed) {
                    window.open(url, '_blank');
                } else if (result.isDenied) {
                    GM_download(url, getFileName(url));
                }
            });
        } else {
            // if image has no URL, then it must have been dragged and dropped, hence no open in new tab or download options
            showMeta.fire({
                showDenyButton: false,
                showCancelButton: false,
                focusCancel: false,
                focusConfirm: true,
                confirmButtonColor: `${colorClose}`,
                confirmButtonText: '닫기',
            });
        }
        showAndHide('.md-show-and-hide');
    }

    function showTagExtractionModal(url, blob) {
        let noMeta = Swal.mixin({
            footer: `
      <div style="width: 100%;">
        <div class="md-info" style="text-align: center;">
          <a href="${url}" target="_blank">Open image...</a>
        </div>
        ${footerString}
      </div>
      `,
        });
        if (url == null) {
            noMeta = Swal.mixin({
                footer: `
        <div style="width: 100%;">
          ${footerString}
        </div>
        `,
            });
        }

        function getOptimizedImageURL(url) {
            if (isArca) {
                return url.replace('ac.namu.la', 'ac-o.namu.la').replace('&type=orig', '');
            }
            if (isPixiv) {
                const extension = url.substring(url.lastIndexOf('.') + 1);
                return url
                    .replace('/img-original/', '/c/600x1200_90_webp/img-master/')
                    .replace(`.${extension}`, '_master1200.jpg');
            }
        }
        noMeta
            .fire({
                icon: 'error',
                title: '메타데이터 없음!',
                text: '찾아볼까요?',
                showCancelButton: true,
                showDenyButton: true,
                confirmButtonText: 'Danbooru Autotagger',
                denyButtonText: 'WD 1.4 Tagger',
                cancelButtonText: '아니오',
                showLoaderOnConfirm: true,
                showLoaderOnDeny: true,
                focusCancel: true,
                confirmButtonColor: `${colorOption1}`,
                denyButtonColor: `${colorOption2}`,
                cancelButtonColor: `${colorClose}`,
                backdrop: true,
                preConfirm: async () => {
                    if (url != null) {
                        const res = await GM_fetch(getOptimizedImageURL(url), {
                            headers: {
                                Referer: `${location.protocol}//${location.hostname}`,
                            },
                        });
                        blob = await res.blob();
                    }
                    let formData = new FormData();
                    formData.append('threshold', '0.4');
                    formData.append('format', 'json');
                    formData.append('file', blob);

                    return GM_fetch('https://autotagger.donmai.us/evaluate', {
                        method: 'POST',
                        body: formData,
                    })
                        .then((res) => {
                            if (!res.status === 200) {
                                Swal.showValidationMessage(`https://autotagger.donmai.us 접속되는지 확인!`);
                            }
                            return res.json();
                        })
                        .catch((error) => {
                            console.log(error);
                            Swal.showValidationMessage(`https://autotagger.donmai.us 접속되는지 확인!`);
                        });
                },
                preDeny: async () => {
                    if (url != null) {
                        const res = await GM_fetch(getOptimizedImageURL(url), {
                            headers: {
                                Referer: `${location.protocol}//${location.hostname}`,
                            },
                        });
                        blob = await res.blob();
                    }
                    const optimizedBase64 = await blobToBase64(blob);

                    return fetch('https://smilingwolf-wd-v1-4-tags.hf.space/run/predict', {
                        method: 'POST',
                        headers: {
                            'Content-Type': 'application/json',
                        },
                        body: JSON.stringify({
                            data: [optimizedBase64, 'SwinV2', 0.35, 0.85],
                        }),
                    })
                        .then((res) => res.json())
                        .catch((error) => {
                            Swal.showValidationMessage(error);
                        });
                },
                allowOutsideClick: () => !Swal.isLoading(),
            })
            .then((result) => {
                if (result.isDismissed) return;
                let tags;
                if (result.isConfirmed) {
                    tags = Object.keys(result.value[0].tags).join(', ').replaceAll('_', ' ');
                } else if (result.isDenied) {
                    tags = result.value.data[3]?.label
                        ? `${result.value.data[3]?.label}, ${result.value.data[0]}`
                        : result.value.data[0];
                }

                Swal.fire({
                    confirmButtonColor: `${colorClose}`,
                    confirmButtonText: '닫기',
                    html: /*html*/ `
            <div class="md-title">Output
              <span class="md-copy md-button" data-clipboard-target="#md-tags"></span>
            </div>
            <div class="md-info" id="md-tags">${tags}</div>
            `,
                });
            });
    }

    function fileToBlob(file) {
        return new Promise((resolve) => {
            const reader = new FileReader();
            reader.onload = () =>
                resolve(
                    new Blob([reader.result], {
                        type: file.type,
                    }),
                );
            reader.readAsArrayBuffer(file);
        });
    }

    function blobToBase64(blob) {
        return new Promise((resolve) => {
            const reader = new FileReader();
            reader.onloadend = () => resolve(reader.result);
            reader.readAsDataURL(blob);
        });
    }

    function notSupportedFormat() {
        toastmix.fire({
            position: 'top-end',
            icon: 'error',
            title: '지원하지 않는 파일 형식입니다.',
        });
    }

    function isSupportedImageFormat(url) {
        const supportedExtensions = /\.(png|jpe?g|webp)|image\/(jpeg|webp|png)/;
        return supportedExtensions.test(url);
    }

    function handleUploadable(MIME) {
        const uploadableSubtypes = /(jpe?g|jfif|pjp|png|gif|web[pm]|mov|mp4|m4[ab])/;
        const [type, subtype] = MIME.split('/');
        if (uploadableSubtypes.test(subtype)) {
            return type;
        } else {
            return null;
        }
    }

    async function getStealthExif(src) {
        let canvas = document.createElement('canvas');
        let ctx = canvas.getContext('2d', { willReadFrequently: true, alpha: true });
        let img = new Image();
        img.src = src;
        img.crossOrigin = 'Anonymous';

        await img.decode();

        canvas.width = img.width;
        canvas.height = img.height;
        ctx.drawImage(img, 0, 0);

        let binary = '';
        const signature = 'stealth_pngcomp';
        let index = 0;
        let reading = 'signature';
        let length = 0;

        for (let x = 0; x < img.width; x++) {
            for (let y = 0; y < img.height; y++) {
                let data = ctx.getImageData(x, y, 1, 1).data;
                let a = data[3];
                binary += String(a & 1);
                index++;

                if (reading == 'signature') {
                    if (index == signature.length * 8) {
                        let str = '';
                        for (let i = 0; i < binary.length / 8; i++) {
                            str += String.fromCharCode(parseInt(binary.substring(i * 8, i * 8 + 8), 2));
                        }

                        if (str == signature) {
                            reading = 'length';
                            binary = '';
                            index = 0;
                        } else {
                            return null;
                        }
                    }
                } else if (reading == 'length') {
                    if (index == 32) {
                        length = parseInt(binary, 2);
                        reading = 'data';
                        binary = '';
                        index = 0;
                    }
                } else if (reading == 'data') {
                    if (index == length) {
                        let array = new Uint8Array(length);
                        for (let i = 0; i < binary.length / 8; i++) {
                            array[i] = parseInt(binary.substring(i * 8, i * 8 + 8), 2);
                        }

                        let temp = pako.ungzip(array);
                        let prompt = new TextDecoder('utf-8').decode(temp);
                        return JSON.parse(prompt);
                    }
                }
            }
        }

        return null;
    }

    async function extractImageMetadata(blob, type) {
        try {
            switch (type) {
                case 'image/jpeg':
                case 'image/webp': {
                    const exif = exifLib.load(await blobToBase64(blob));
                    const parameters = exif.Exif[37510].replace('UNICODE', '').replaceAll('\u0000', '');
                    return {
                        parameters,
                    };
                }
                case 'image/png': {
                    const chunks = UPNG.decode(await blob.arrayBuffer());
                    let parameters = chunks.tabs.tEXt?.parameters || chunks.tabs.iTXt?.parameters;
                    const description = chunks.tabs.tEXt?.Description || chunks.tabs.iTXt?.Description;
                    if (parameters) {
                        return {
                            parameters,
                        };
                    } else if (description) {
                        return chunks.tabs?.tEXt || chunks.tabs?.iTXt;
                    } else {
                        return null;
                    }
                }
            }
        } catch (error) {
            console.log(error);
            return null;
        }
    }

    async function fetchAndDecode(url) {
        try {
            let response, contentType, reader;
            const Referer = `${location.protocol}//${location.hostname}`;
            if (isArca) {
                response = await fetch(url.replace('ac.namu.la', 'ac-o.namu.la'));
                contentType = response.headers.get('content-type');
                reader = response.body.getReader();
            } else if (useTampermonkey) {
                response = await new Promise((resolve) => {
                    GM_xmlhttpRequest({
                        url,
                        responseType: 'stream',
                        headers: {
                            Referer,
                        },
                        onreadystatechange: (data) => {
                            resolve(data);
                        },
                    });
                });
                const headers = Object.fromEntries(
                    response.responseHeaders.split('\n').map((line) => {
                        const [key, value] = line.split(':').map((part) => part.trim());
                        return [key, value];
                    }),
                );
                contentType = headers['content-type'];
                reader = response.response.getReader();
            } else {
                response = await GM_fetch(url, {
                    headers: {
                        Referer,
                    },
                });
                contentType = response.headers.get('content-type');
                reader = response.body.getReader();
            }
            if (
                (isPixiv && !url.includes('.jpg') && contentType === 'text/html') ||
                (isPixiv && url.includes('.jpg'))
            ) {
                url = url.replace('.png', '.jpg');
                showTagExtractionModal(url);
                return;
            }

            let metadata;
            let chunks = [];
            while (true) {
                const { done, value } = await reader.read();
                if (done || metadata || metadata === null) {
                    reader.cancel();
                    break;
                }
                switch (contentType) {
                    case 'image/jpeg':
                        metadata = getMetadataJPEGChunk(value);
                        break;
                    case 'image/png':
                        metadata = getMetadataPNGChunk(value);
                        metadata?.IDAT && reader.cancel();
                        break;
                    case 'image/webp':
                        chunks.push(value);
                        break;
                    default:
                        notSupportedFormat();
                        reader.cancel();
                        break;
                }
            }
            if (contentType === 'image/webp') {
                const blob = new Blob(chunks, {
                    type: 'image/webp',
                });
                const base64 = await blobToBase64(blob);
                const exif = exifLib.load(base64);
                const parameters = exif.Exif[37510].replace('UNICODE', '').replaceAll('\u0000', '');
                metadata = {
                    parameters,
                };
            }
            const stealthData = await getStealthExif(url);
            if ((await stealthData) != null) {
                console.log('stealth data:', await stealthData);
                return await stealthData;
            }
            console.log('EXIF data:', metadata);
            return metadata;
        } catch (error) {
            console.log(error);
            return null;
        }
    }

    async function extract(url) {
        if (!isSupportedImageFormat(url)) {
            notSupportedFormat();
            return;
        }

        Swal.fire({
            title: '로드 중!',
            width: '15rem',
            didOpen: () => {
                Swal.showLoading();
            },
        });

        console.time('modal open');
        console.time('fetch');
        const metadata = await fetchAndDecode(url);
        console.timeEnd('fetch');
        console.log(metadata);

        if (metadata?.Description || metadata?.parameters || metadata?.['sd-metadata']) {
            showMetadataModal(metadata, url);
        } else {
            showTagExtractionModal(url);
        }
        console.timeEnd('modal open');
    }

    function getCSRFToken() {
        return new Promise((resolve) => {
            const csrf = document.querySelector('input[name=_csrf]');
            const token = document.querySelector('input[name=token]');
            if (csrf && token) {
                resolve([csrf.value, token.value]);
            }
        });
    }

    function uploadArca(blob, type, saveEXIF = true, token = null) {
        return new Promise(async (resolve, reject) => {
            let swalText = '비디오는 EXIF 보존 설정에 영향을 받지 않습니다.';
            if (type == 'image') {
                swalText = 'EXIF 보존: ' + saveEXIF;
            }
            let xhr = new XMLHttpRequest();
            xhr.upload.addEventListener('progress', null, false);
            let formData = new FormData();
            if (!document.querySelector('#article_write_form > input[name=token]')) {
                await getCSRFToken().then((tokenList) => {
                    token = tokenList[1];
                });
            }

            formData.append('upload', blob);
            formData.append('token', token || document.querySelector('#article_write_form > input[name=token]').value);
            formData.append('saveExif', saveEXIF);
            formData.append('saveFilename', false);

            xhr.onload = function () {
                let response = JSON.parse(xhr.responseText);
                if (response.uploaded === true) {
                    resolve(response.url);
                } else {
                    Swal.close();
                    console.error(xhr.responseText);
                    toastmix.fire({
                        icon: 'error',
                        title: `업로드 오류`,
                    });
                }
            };
            xhr.open('POST', 'https://arca.live/b/upload');
            xhr.send(formData);
            Swal.fire({
                title: '파일 업로드중',
                text: swalText,
                showConfirmButton: false,
                allowOutsideClick: false,
                didOpen: () => {
                    Swal.showLoading();
                },
            });
        });
    }

    const { hostname, href, pathname } = location;
    const isMobile = /iPhone|iPad|iPod|Android/i.test(navigator.userAgent);
    const isPixiv = hostname === 'www.pixiv.net';
    const isArca = hostname === 'arca.live';
    const isArcaViewer = /(arca.live)(\/)(b\/.*)(\/)(\d*)/.test(href);
    const isArcaEditor = /(arca.live\/b\/.*\/)(edit|write)/.test(href);
    const useTampermonkey = GM_xmlhttpRequest?.RESPONSE_TYPE_STREAM && true;
    const isPixivDragUpload = pathname === '/illustration/create' || pathname === '/upload.php';

    if (GM_getValue('usePixiv', false) && isPixiv) {
        function getOriginalUrl(url) {
            const extension = url.substring(url.lastIndexOf('.') + 1);
            const originalUrl = url
                .replace('/c/600x1200_90_webp/img-master/', '/img-original/')
                .replace('/c/100x100/img-master/', '/img-original/')
                .replace('_master1200', '')
                .replace(`.${extension}`, '.png');
            return originalUrl;
        }

        let isAi = false;
        if (!isMobile) {
            document.arrive('footer > ul > li > span > a', function () {
                if (this.href === 'https://www.pixiv.help/hc/articles/11866167926809') isAi = true;
            });
            document.arrive('div[role=presentation]:last-child > div > div', function () {
                isAi && this.click();
            });
        } else {
            document.arrive('a.ai-generated', () => {
                isAi = true;
            });
            document.arrive('button.nav-back', function () {
                isAi && this.click();
            });
        }

        document.arrive('a > img', function () {
            if (this.alt === 'pixiv') return;

            if (isAi) {
                let src;
                if (!isMobile) {
                    src = this.parentNode.href;
                } else {
                    src = getOriginalUrl(this.src);
                }

                this.onclick = function () {
                    extract(src);
                };
            }
        });
    }

    if (isArcaViewer) {
        document.arrive(
            'a[href$="type=orig"] > img',
            {
                existing: true,
            },
            function () {
                if (this.classList.contains('channel-icon')) return;

                this.parentNode.onclick = (event) => {
                    if (event.button === 0) {
                        event.preventDefault();
                    }
                };
                this.onclick = function () {
                    const src = `${this.src}&type=orig`;
                    extract(src);
                };
            },
        );
    }

    let ArcaDragUpload = true;
    if (isArcaEditor) {
        if (GM_getValue('saveExifDefault', true)) {
            document.arrive(
                '.images-multi-upload',
                {
                    onceOnly: true,
                },
                () => {
                    document.getElementById('saveExif').checked = true;
                },
            );
        }
        if (!GM_getValue('useDragdropUpload', true)) ArcaDragUpload = false;
    }

    !isMobile && !isPixivDragUpload && ArcaDragUpload && new DropZone();
    GM_addStyle(modalCSS);
    new ClipboardJS('.md-copy');
    registerMenu();
})();