您需要先安装一个扩展,例如 篡改猴、Greasemonkey 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 Userscripts ,之后才能安装此脚本。
您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey,才能安装此脚本。
您需要先安装用户脚本管理器扩展后才能安装此脚本。
Adds a number of tools, including an import mask button, export mask button, invert mask button, and layers.
// ==UserScript== // @name NovelAI Inpainting Tools // @version 0.8.2 // @description Adds a number of tools, including an import mask button, export mask button, invert mask button, and layers. // @author IAintTellinYouNothin // @match https://novelai.net/* // @run-at document-idle // @license MIT // @grant none // @namespace https://greasyfork.org/users/1465742 // ==/UserScript== (() => { 'use strict'; // Constants let cachedCtl = null; // Track last pasted or dropped image let lastImg = null; window.addEventListener('paste', e => { for (const item of e.clipboardData?.items || []) { if (!item.type.startsWith('image/')) continue; const img = new Image(); img.onload = () => {lastImg = img}; img.src = URL.createObjectURL(item.getAsFile()); break; } }); //** Helper Functions **// function querySelectorIncludesText (selector, text){ return Array.from(document.querySelectorAll(selector)) .find(el => el.textContent.includes(text)); } // Canvas Controller const getCanvasController = () => { if (cachedCtl?.displayCanvas?.isConnected) return cachedCtl; cachedCtl = findCanvasControllerRaw(); return cachedCtl; }; // Wait For Function function waitFor(selector, timeout = 10000) { return new Promise((resolve, reject) => { const interval = 200; let elapsed = 0; const handle = setInterval(() => { const el = document.querySelector(selector); if (el) { clearInterval(handle); resolve(el); } else if ((elapsed += interval) >= timeout) { clearInterval(handle); reject(`Timed out waiting for ${selector}`); } }, interval); }); } // Drag and drop listeners document.addEventListener('dragover', e => e.preventDefault()); document.addEventListener('drop', e => { e.preventDefault(); const file = e.dataTransfer?.files[0]; if (!file?.type.startsWith('image/')) return; const img = new Image(); img.onload = () => {lastImg = img}; img.src = URL.createObjectURL(file); }); // Hidden file input for manual uploads const fileInput = Object.assign(document.createElement('input'), { type: 'file', accept: 'image/png,image/jpeg', style: 'display:none' }); fileInput.onchange = () => { if (!fileInput.files.length) return; const reader = new FileReader(); reader.onload = ev => { const img = new Image(); img.onload = () => applyMask(img); img.src = ev.target.result; }; reader.readAsDataURL(fileInput.files[0]); fileInput.value = ''; }; document.body.appendChild(fileInput); // Check if element is visible const isVisible = el => { if (!el) return false; const style = getComputedStyle(el); if (style.display === 'none' || style.visibility === 'hidden' || +style.opacity === 0) return false; const r = el.getBoundingClientRect(); return r.width > 0 && r.height > 0 && r.bottom > 0 && r.top < window.innerHeight; }; // Find the React canvas controller const findCanvasControllerRaw = () => { const disp = document.getElementById('canvas'); if (!disp) return null; const key = Object.keys(disp).find(k => k.startsWith('__reactFiber$')); if (!key) return null; const queue = [disp[key]]; const seen = new Set(); while (queue.length) { const f = queue.shift(); if (!f || seen.has(f)) continue; seen.add(f); for (let h = f.memoizedState; h; h = h.next) { const ref = h.memoizedState; if (ref?.current?.addLayer && Array.isArray(ref.current.layers)) return ref.current; } queue.push(f.child, f.sibling, f.return); } return null; }; // Import mask image onto current layer (white = mask) const applyMask = img => { const disp = document.getElementById('canvas'); if (!disp) return; const ctl = findCanvasControllerRaw(); if (!ctl) return; const { canvas } = ctl.currentLayer; const { width, height } = canvas; const ctx = canvas.getContext('2d'); const off = document.createElement('canvas'); off.width = width; off.height = height; const octx = off.getContext('2d'); // Scale input to mask layer resolution const scale = (width / disp.width !== 1/8) ? 1/8 : 1; octx.drawImage(img, 0, 0, disp.width, disp.height, 0, 0, width * scale, height * scale); const data = octx.getImageData(0, 0, width * scale, height * scale).data; for (let i = 0; i < data.length; i += 4) { const [r,g,b] = [data[i], data[i+1], data[i+2]]; if (r > 250 && g > 250 && b > 250) { data[i+3] = 255; } else { data[i+3] = 0; } } ctx.clearRect(0, 0, width * scale, height * scale); ctx.putImageData(new ImageData(data, width * scale, height * scale), 0, 0); ctl.saveState?.(); ctl.toolState?.changeToReload?.(true); }; // Invert current mask layer const invertCurrentMask = () => { const disp = document.getElementById('canvas'); if (!disp) return; const ctl = findCanvasControllerRaw(); if (!ctl) return; const { canvas } = ctl.currentLayer; const ctx = canvas.getContext('2d'); const img = ctx.getImageData(0, 0, canvas.width, canvas.height); for (let i = 0; i < img.data.length; i += 4) { img.data[i+3] = img.data[i+3] > 0 ? 0 : 255; if (img.data[i+3] === 255) { img.data[i] = img.data[i+1] = img.data[i+2] = 255; } } ctx.clearRect(0, 0, canvas.width, canvas.height); ctx.putImageData(img, 0, 0); ctl.saveState?.(); ctl.toolState?.changeToReload?.(true); }; // Export mask as PNG const exportMask = () => { const disp = document.getElementById('canvas'); if (!disp) return; const ctl = findCanvasControllerRaw(); if (!ctl) return; const src = ctl.currentLayer.canvas; const buf = document.createElement('canvas'); buf.width = src.width; buf.height = src.height; const bctx = buf.getContext('2d'); bctx.drawImage(src, 0, 0); // Clip zoom region if active if (ctl.toolState.maskZoomRegion && ctl.mode === 1) { const { from, to } = ctl.toolState.maskZoomRegion; const pad = ctl.toolState.safeAreaSize / (ctl.currentLayer.scaleFactor || 1); const clip = { x: Math.min(from.x,to.x)+pad, y: Math.min(from.y,to.y)+pad, width: Math.abs(to.x-from.x)-2*pad, height: Math.abs(to.y-from.y)-2*pad }; if (clip.width>0 && clip.height>0) { bctx.globalCompositeOperation = 'destination-in'; bctx.fillStyle = '#000'; bctx.fillRect(clip.x, clip.y, clip.width, clip.height); bctx.globalCompositeOperation = 'source-over'; } else { bctx.clearRect(0,0,buf.width,buf.height); } } // Upscale if needed const scaleUp = (src.width/disp.width === 1/8) ? 8 : 1; const out = document.createElement('canvas'); out.width = src.width * scaleUp; out.height = src.height * scaleUp; const octx = out.getContext('2d'); octx.imageSmoothingEnabled = false; octx.drawImage(buf, 0, 0, out.width, out.height); const a = document.createElement('a'); a.download = 'mask.png'; a.href = out.toDataURL('image/png'); a.click(); }; // Add UI // Build layer UI row const createRow = () => { const saveBtn = document.querySelector('button.sc-4f026a5f-0.sc-4f026a5f-1.sc-4f026a5f-5'); const wrap = saveBtn?.closest('div[style*="flex-wrap: wrap-reverse"]'); if (!wrap) return null; const row = document.createElement('div'); row.id = 'nai-layer-ui-row'; Object.assign(row.style, { display: 'flex', alignItems: 'center', gap: '10px', width: '100%', marginTop: '10px', flexWrap: 'wrap' }); wrap.parentNode.insertBefore(row, wrap.nextSibling); return row; }; const cloneButton = (label, id) => { const proto = document.querySelector('button.sc-4f026a5f-2.iaNkyw') || document.querySelector('button.sc-4f026a5f-2'); const btn = proto ? proto.cloneNode(true) : document.createElement('button'); btn.id = id; btn.querySelector('div')?.remove(); btn.textContent = label; Object.assign(btn.style, { position: 'static', margin: '0 4px' }); return btn; }; const makeDeleteBtn = cb => { let b = document.createElement('button'); let trashIcon = document.createElement('svg'); Object.assign(trashIcon.style, { width: '16px', height: '16px', top: '0', bottom: '0', right: '0', left: '0', position: 'absolute', margin: 'auto', background: 'rgb(255,255,255)', 'mask-image': 'url(/_next/static/media/trash.72ef2ba9.svg)' }); b.className = 'sc-4f026a5f-2 iaNkyw'; b.onclick = cb; Object.assign(b.style, { position: 'absolute', top: '0.25em', right: '0.25em', width: '16px', height: '16px', padding: 0, lineHeight: 0, background: 'rgba(1,1,1,0.6)', }); b.appendChild(trashIcon) return b; }; // Render layer thumbnails const renderThumbnails = () => { const ctl = getCanvasController(); const strip = document.getElementById('nai-layer-strip'); if (!ctl || !strip) return; const previewW = 60; const layers = ctl.layers; // 1) Make sure strip has exactly one holder per layer // Add new holders if layers grew, or remove extras if they shrank while (strip.children.length < layers.length) { const holder = document.createElement('div'); holder.className = 'strip-holder'; strip.appendChild(holder); } while (strip.children.length > layers.length) { strip.removeChild(strip.lastChild); } // 2) Update each holder/button in-place layers.forEach((layer, idx) => { const holder = strip.children[idx]; // size the holder const h = Math.round(previewW * layer.canvas.height / layer.canvas.width); Object.assign(holder.style, { width: `${previewW}px`, height: `${h}px`, position: 'relative', flex: '0 0 auto', }); // find or create the thumbnail button let btn = holder.querySelector('button'); if (!btn) { btn = document.createElement('button'); holder.appendChild(btn); } // apply classes & size btn.className = 'sc-4f026a5f-2 iaNkyw' + (idx === ctl.selectedLayer ? ' selected' : ''); Object.assign(btn.style, { width: `${previewW}px`, height: `${h}px`, padding: 0, border: idx === ctl.selectedLayer ? '3px solid var(--textMain,#fff)' : '3px solid var(--bg2,#3a3a3a)', }); // redraw the thumbnail into a tiny offscreen canvas try { const c = document.createElement('canvas'); c.width = previewW; c.height = h; const tx = c.getContext('2d'); tx.imageSmoothingEnabled = true; tx.drawImage( layer.canvas, 0, 0, layer.canvas.width, layer.canvas.height, 0, 0, previewW, h ); btn.style.backgroundImage = `url(${c.toDataURL()})`; btn.style.backgroundSize = 'cover'; btn.style.backgroundPosition = 'center'; } catch (e) { // drawing failed; just leave existing background } // update click handler btn.onclick = () => { ctl.switchLayer(idx); renderThumbnails(); }; // handle delete‐button let del = holder.querySelector('.delete-layer-btn'); if (ctl.layers.length > 1) { if (!del) { del = makeDeleteBtn(ev => { ev.stopPropagation(); ctl.removeLayer(idx, true); ctl.switchLayer(Math.min(idx, ctl.layers.length - 1)); renderThumbnails(); }); del.classList.add('delete-layer-btn'); holder.appendChild(del); } } else if (del) { // no longer needed holder.removeChild(del); } }); }; // Render Toolbar const refreshToolbar = () => { ['#nai-mask-import-btn', '#nai-mask-export-btn', '#nai-invert-btn'].forEach(sel => { document.querySelectorAll(sel).forEach(btn => { const wrap = btn.closest('.image-gen-canvas'); if (!wrap || !isVisible(wrap)) btn.remove(); }); }); document.querySelectorAll('.image-gen-canvas').forEach(container => { if (!isVisible(container)) return; const tpl = querySelectorIncludesText('span', 'Draw Mask').parentNode if (document.querySelector('#nai-mask-import-btn')) return; const makeBtn = (id, text, cb) => { const btn = tpl.cloneNode(true); btn.id = id; btn.style.cursor = 'pointer'; btn.querySelector('div')?.replaceChildren(); btn.querySelector('span').textContent = text; btn.addEventListener('click', cb); return btn; }; const importBtn = makeBtn('nai-mask-import-btn','Mask Import',() => { const img = lastImg; lastImg = null; img ? applyMask(img) : fileInput.click(); }); const exportBtn = makeBtn('nai-mask-export-btn','Mask Export', exportMask); const invertBtn = makeBtn('nai-invert-btn','Invert Mask', invertCurrentMask); const bar = tpl.parentElement; const sep = Array.from(bar.childNodes).find(div => window.getComputedStyle(div).display !== 'flex'); if (sep) sep.parentNode.insertBefore(importBtn, sep), sep.parentNode.insertBefore(exportBtn, sep), sep.parentNode.insertBefore(invertBtn, sep); else bar.append(importBtn, exportBtn, invertBtn); }); const ctl = getCanvasController(); if (!ctl || ctl.mode !== 1) return; let row = document.getElementById('nai-layer-ui-row'); if (!row) row = createRow(); if (!row || row.dataset.ready) return; row.style.position = 'relative'; const newBtn = cloneButton('New Layer','nai-new-mask-layer'); const toggleBtn = cloneButton('Toggle Layer','nai-toggle-mask-layer'); row.append(newBtn, toggleBtn); const strip = document.createElement('div'); strip.id = 'nai-layer-strip'; Object.assign(strip.style, { position: 'absolute', top:'100%', left: '3', display: 'flex', alignItems: 'left', flexDirection: 'column', gap: '6px', padding: '0.2em 0.5em', zIndex: '999' }); row.appendChild(strip); row.dataset.ready = '1'; newBtn.onclick = () => { ctl.addLayer(true); const layer = ctl.currentLayer; layer.scaleFactor = 8; layer.canvas.width = ctl.displayCanvas.width / 8; layer.canvas.height = ctl.displayCanvas.height / 8; ctl.switchLayer(ctl.layers.length - 1); renderThumbnails(); }; toggleBtn.onclick = () => { const layer = ctl.layers[ctl.selectedLayer]; layer.opacity = layer.opacity > 0 ? 0 : 0.5; ctl.toolState.changeToReload?.(true); renderThumbnails(); }; if (!window._naiLayerHotkeys) { window._naiLayerHotkeys = true; window.addEventListener('keydown', ev => { if (['INPUT','TEXTAREA'].includes(document.activeElement.tagName)) return; if (!ctl || ctl.mode !== 1) return; if (ev.key === '[') { ctl.switchLayer((ctl.selectedLayer-1+ctl.layers.length)%ctl.layers.length); renderThumbnails(); } if (ev.key === ']') { ctl.switchLayer((ctl.selectedLayer+1)%ctl.layers.length); renderThumbnails(); } }); renderThumbnails(); } }; waitFor('.image-gen-body').then(() => { const imageGenBody = document.querySelector('.image-gen-body') new MutationObserver(refreshToolbar).observe(imageGenBody, {childList: true, subtree: true, attributes: true}); refreshToolbar(); }); setInterval(renderThumbnails, 100); // Add mask-upload button to preview modal const modalSelector = 'div[data-projection-id]'; const addButton = modal => { if (modal.querySelector('#nai-mask-btn-modal')) return; const orig = querySelectorIncludesText('span', 'Image2Image').parentNode; if (!orig) return; const row = document.createElement('div'); Object.assign(row.style, { display: 'flex', justifyContent: 'center', flexWrap: 'wrap', gap: '20px', width: '100%', marginTop: '20px' }); orig.parentElement.after(row); const btn = orig.cloneNode(true); btn.id = 'nai-mask-btn-modal'; btn.querySelector('div')?.replaceChildren(); btn.querySelector('span').textContent = 'Mask Upload'; btn.onclick = ev => { ev.preventDefault(); const preview = modal.querySelector('div[style*="background-image"]'); const bg = preview ? (preview.style.backgroundImage || getComputedStyle(preview).backgroundImage) : ''; const m = bg.match(/url\(["']?(data:image\/[^"')]+)["']?\)/); if (m) { const img = new Image(); img.onload = () => applyMask(img); img.src = m[1]; } else if (lastImg) { applyMask(lastImg); } else { fileInput.click(); } modal.querySelector('button.modal-close')?.click(); }; row.appendChild(btn); }; new MutationObserver(muts => { muts.forEach(m => { m.addedNodes.forEach(n => { if (!(n instanceof Element)) return; if (n.matches(modalSelector)) addButton(n); }); }); }).observe(document.body, {childList: true, subtree: true}); })(); // Save and restore mask layers (() => { 'use strict'; const STORAGE_KEY = 'nai-mask-layers'; const findCanvasController = () => { const disp = document.getElementById('canvas'); if (!disp) return null; const key = Object.keys(disp).find(k => k.startsWith('__reactFiber$')); if (!key) return null; const queue = [disp[key]]; const visited = new Set(); while (queue.length) { const f = queue.shift(); if (!f || visited.has(f)) continue; visited.add(f); for (let h = f.memoizedState; h; h = h.next) { const ref = h.memoizedState; if (ref?.current?.layers && ref.current.getImage) return ref.current; } queue.push(f.child, f.sibling, f.return); } return null; }; const isMaskMode = ctl => ctl?.mode === 1; // Patch getImage to exclude hidden layers const patchGetImage = () => { const ctl = findCanvasController(); if (!isMaskMode(ctl) || ctl._naiPatchedGetImage) return; ctl._naiPatchedGetImage = true; const orig = ctl.getImage.bind(ctl); ctl.getImage = function(...args) { const layers = this.layers; this.layers = layers.filter(l => l.opacity > 0); const result = orig(...args); this.layers = layers; return result; }; console.log('[NAI helper] getImage() patched'); }; // Save all mask layers to localStorage const saveMaskLayers = () => { const ctl = findCanvasController(); if (!isMaskMode(ctl)) return; const snapshot = ctl.layers.map(l => ({ data: l.canvas.toDataURL('image/png'), opacity: l.opacity, scale: l.scaleFactor || 1 })); try { localStorage.setItem(STORAGE_KEY, JSON.stringify(snapshot)); ctl._naiLayersRestored = false; } catch { console.warn('[NAI helper] could not save layers'); } }; // Load saved mask layers from localStorage const loadMaskLayers = async () => { const ctl = findCanvasController(); if (!isMaskMode(ctl)) return; const raw = localStorage.getItem(STORAGE_KEY); if (!raw) return; let saved; try { saved = JSON.parse(raw); } catch { return; } // Remove existing extra layers for (let i = ctl.layers.length - 1; i >= 1; --i) { ctl.removeLayer(i, false); } for (let i = 0; i < saved.length; ++i) { const { data, opacity, scale } = saved[i]; if (i > 0) { ctl.addLayer(true); ctl.switchLayer(ctl.layers.length - 1); } const layer = ctl.layers[i]; if (scale !== 1) { layer.scaleFactor = scale; layer.canvas.width = ctl.displayCanvas.width / scale; layer.canvas.height = ctl.displayCanvas.height / scale; } layer.opacity = opacity; await new Promise(resolve => { const img = new Image(); img.onload = () => { const ctx = layer.canvas.getContext('2d'); ctx.clearRect(0,0,layer.canvas.width,layer.canvas.height); ctx.drawImage(img, 0,0,layer.canvas.width,layer.canvas.height); resolve(); }; img.src = data; }); } ctl.switchLayer(0); ctl.toolState?.changeToReload?.(true); }; // Hook the “Save & Close” button const hookSaveButton = () => { const btn = Array.from(document.querySelectorAll('button')) .find(b => /save\s*&\s*close/i.test(b.textContent)); if (!btn || btn._naiPersistHook) return; btn._naiPersistHook = true; btn.addEventListener('click', saveMaskLayers, true); }; const activatePersistence = () => { const ctl = findCanvasController(); if (!isMaskMode(ctl) || ctl._naiLayersRestored) return; ctl._naiLayersRestored = true; patchGetImage(); loadMaskLayers().then(() => { // reuse renderThumbnails from earlier scripts if (typeof renderThumbnails === 'function') renderThumbnails(); }); hookSaveButton(); }; const observer = new MutationObserver(activatePersistence); observer.observe(document.body, { childList: true, subtree: true }); activatePersistence(); })();