RE: AI 이미지 EXIF 뷰어

AI 이미지 메타데이터 보기

You will need to install an extension such as Tampermonkey, Greasemonkey or Violentmonkey to install this script.

You will need to install an extension such as Tampermonkey or Violentmonkey to install this script.

You will need to install an extension such as Tampermonkey or Violentmonkey to install this script.

You will need to install an extension such as Tampermonkey or Userscripts to install this script.

You will need to install an extension such as Tampermonkey to install this script.

You will need to install a user script manager extension to install this script.

(I already have a user script manager, let me install it!)

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

(I already have a user style manager, let me install it!)

// ==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.3
// @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/[email protected]/dist/exif-library.min.js
// @require     https://cdn.jsdelivr.net/npm/sweetalert2@11
// @require     https://cdn.jsdelivr.net/npm/[email protected]/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 보존 활성화
                      다음번 작성시부터 보존됩니다`,
                        });
                    }
                });
                // https://arca.live/b/aiart/121872652 ::::: 수정 250211
                // todo: 일단 주석처리해보고 문제 없으면 그대로 두기
                // 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';
                metadata['Model'] = exif.Source; // Model (v1.1에서 추가)
                const model = exif.Source.split(' '); // Model hash (v1.1에서 추가)
                metadata['Model hash'] = model[model.length - 1]; // Model hash (v1.1에서 추가)
                metadata['Size'] = `${comment.width}x${comment.height}`; // Size (v1.1에서 추가)

                if (comment?.strength) {
                    metadata['Denoising strength'] = comment.strength; // Denoising strength (v1.1에서 추가)
                }

                // v4 prompt/negative prompt https://arca.live/b/aiart/124370349
                if (comment?.v4_prompt || comment?.v4_negative_prompt) {
                    console.log('v4 감지');
                    function extractAndCleanCaptions(data, id) {
                        function recursiveExtract(obj) {
                            let captions = [];
                            if (typeof obj === 'object' && obj !== null) {
                                for (const key in obj) {
                                    if (
                                        key.includes('caption') &&
                                        typeof obj[key] === 'string'
                                    ) {
                                        captions.push(obj[key]);
                                    } else {
                                        captions = captions.concat(
                                            recursiveExtract(obj[key])
                                        );
                                    }
                                }
                            } else if (Array.isArray(obj)) {
                                obj.forEach((item) => {
                                    captions = captions.concat(
                                        recursiveExtract(item)
                                    );
                                });
                            }
                            return captions;
                        }

                        const captions = recursiveExtract(data[id]);
                        let concatenated = captions.join(', ');
                        concatenated = concatenated
                            .replace(/,\s*,/g, ',')
                            .replace(/\s\s+/g, ' ')
                            .replace(/,\s*$/, '');
                        return concatenated;
                    }
                    metadata.prompt = extractAndCleanCaptions(
                        comment,
                        'v4_prompt'
                    );
                    metadata.negativePrompt = extractAndCleanCaptions(
                        comment,
                        'v4_negative_prompt'
                    );
                    console.log(comment.v4_prompt);
                    console.log(comment.v4_negative_prompt);
                }

                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);
                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.replace(/ac.*\.namu\.la/g, 'ac-p3.namu.la')
            url.replace(/ac.*\.namu\.la/g, 'ac-o.namu.la')
        );
        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();
})();