// ==UserScript==
// @name Jp Manga Translator
// @author moony
// @license MIT
// @version 1.1
// @description Translate manga from Japanese/Chinese to English using Gemini, or optionally with a Python server (YOLO+MangaOCR+mBART+Colorization)/in-browser RT-DETR/Google.
// @match *.mangadex.org/*
// @match *.mangapark.net/*
// @match *.weebcentral.com/*
// @match *.mangahere.cc/*
// @match *.mangafox.fun/*
// @match *.fanfox.net/*
// @match *.webtoons.com/*
// @match *.bato.to/*
// @match *.asuracomic.net/*
// @match *.tapas.io/*
// @match *.dynasty-scans.com/*
// @match *.comic-walker.com/*
// @match *.pixiv.net/*/artworks/*
// @match *.8muses.com/*
// @match *.hentai2read.com/*
// @match *.e-hentai.org/*
// @match *.exhentai.*
// @match *.nhentai.net/*
// @match *.hitomi.la/*
// @exclude *.mangaplus.shueisha.co.jp/*
// @exclude *.battwo.com/*
// @exclude *.natomanga.com/*
// @exclude *.mangakakalot.gg/*
// @icon https://i.imgur.com/9Oym4Cp.png
// @grant GM_xmlhttpRequest
// @grant GM_registerMenuCommand
// @grant GM_getValue
// @grant GM_setValue
// @connect localhost
// @connect ocrt.iwanhae.kr
// @connect generativelanguage.googleapis.com
// @connect translate.googleapis.com
// @connect api.mymemory.translated.net
// @connect huggingface.co
// @connect *
// @require https://cdn.jsdelivr.net/npm/[email protected]/dist/ort.min.js
// @run-at document-idle
// @namespace Manga Translator
// ==/UserScript==
(function () {
'use strict';
const log = (...a) => console.log('[MT]', ...a);
const API_BASE = GM_getValue('server_url', 'http://localhost:8000');
const DETECT_MIN_IMAGE_SIZE = 600;
const RTDETR_RESIZE = 640, RTDETR_CONFIDENCE = 0.25;
if (!GM_getValue('_init')) { // Defaults in storage
GM_setValue('mode', 'external'); // 'gemini' | 'external' | 'server' | 'browser'
GM_setValue('aio', true); // All-In-One pipeline
GM_setValue('colorize', true); // server colorize
GM_setValue('detector', 'none'); // 'yolo' | 'rtdetr' | 'none'
GM_setValue('ocr', 'none'); // 'server' | 'manga2025' | 'none'
GM_setValue('translator', 'none'); // 'mbart' | 'google' | 'mymemory' | 'none'
GM_setValue('target_lang', 'English'); // Gemini target language
GM_setValue('grouping_box', false);
GM_setValue('_init', 1);
}
log(`Generic detection: scans for images ≥${DETECT_MIN_IMAGE_SIZE}px`);
// Presets only
GM_registerMenuCommand('Preset: Gemini API (All-in-One)', async () => {
let key = GM_getValue('gemini_key');
if (!key) { key = prompt('Enter GEMINI_API_KEY from https://aistudio.google.com/app/apikey:'); if (key) GM_setValue('gemini_key', key); }
setPreset('Gemini', { mode: 'gemini', aio: true, detector: 'none', ocr: 'none', translator: 'none', colorize: false });
});
GM_registerMenuCommand('Preset: iwanhae.kr (All-in-One, free)', () => setPreset('iwanhae', { mode: 'external', aio: true, detector: 'none', ocr: 'none', translator: 'none', colorize: false }));
GM_registerMenuCommand('Preset: Server (YOLO+MangaOCR+mBART+Colorize)', () => setPreset('Server', { mode: 'server', aio: false, detector: 'yolo', ocr: 'server', translator: 'mbart', colorize: true }));
GM_registerMenuCommand('Preset: Browser (RT-DETR+MangaOCR-2025+Google)', () => setPreset('Browser', { mode: 'browser', aio: false, detector: 'rtdetr', ocr: 'manga2025', translator: 'google', colorize: false }));
GM_registerMenuCommand('Process all images', processAllImages);
function setPreset(name, flags) { Object.entries(flags).forEach(([k, v]) => GM_setValue(k, v)); log('✓ Preset:', name); }
function findMangaImages() { // Image selection
const out = [];
document.querySelectorAll('img, canvas').forEach(img => {
if ((img.offsetWidth >= DETECT_MIN_IMAGE_SIZE || img.offsetHeight >= DETECT_MIN_IMAGE_SIZE) &&
(img.naturalWidth || img.width) > 0 && (img.naturalHeight || img.height) > 0 && (!img.complete || img.complete)) {
const u = resolveUrl(img);
if (u && (!u.startsWith('data:') || img.getContext)) out.push(img);
}
});
return out;
}
function getVisibleImage() {
const imgs = findMangaImages();
const vis = imgs.filter(img => {
const r = img.getBoundingClientRect();
return r.top < window.innerHeight && r.bottom > 0;
});
return vis[0] || imgs[0] || null;
}
function resolveUrl(img) {
if (!img) return null;
if (img.toDataURL) return img.toDataURL('image/png');
let u = img.currentSrc || img.src || img.getAttribute('data-src') || img.getAttribute('data-original');
if (!u) return null;
if (u.startsWith('//')) u = location.protocol + u;
return u;
}
function gmFetch(url, opts = {}) { // Network helpers
return new Promise((ok, err) => GM_xmlhttpRequest({
method: opts.method || 'GET', url, headers: opts.headers || {}, data: opts.body,
responseType: opts.responseType || 'json', timeout: opts.timeout || 120000,
onload: r => (r.status >= 200 && r.status < 300) ? ok(r.response) : err(new Error(`HTTP ${r.status}`)),
onerror: () => err(new Error('Network error')), ontimeout: () => err(new Error('Timeout'))
}));
}
function fetchBlob(url) {
if (url.startsWith('data:')) return fetch(url).then(r => r.blob());
return new Promise((ok, err) => GM_xmlhttpRequest({
method: 'GET', url, responseType: 'blob', headers: { 'Referer': window.location.origin }, timeout: 60000,
onload: r => ok(r.response), onerror: () => err(new Error('Blob error')), ontimeout: () => err(new Error('Blob timeout'))
}));
}
function fetchBase64(url) {
return new Promise((ok, err) => GM_xmlhttpRequest({
method: 'GET', url, responseType: 'blob', headers: { 'Referer': window.location.origin }, timeout: 60000,
onload: r => { const rd = new FileReader(); rd.onloadend = () => ok(rd.result.split(',')[1]); rd.onerror = () => err(new Error('FileReader failed')); rd.readAsDataURL(r.response); },
onerror: () => err(new Error('Image error')), ontimeout: () => err(new Error('Image timeout'))
}));
}
function sortReadingOrder(boxes, rtl = true) {
const items = boxes.map(b => ({ b, cy: (b[1] + b[3]) / 2, h: (b[3] - b[1]) })).sort((a, b) => a.cy - b.cy);
const rows = [];
for (const it of items) {
const r = rows.find(x => Math.abs(x.cy - it.cy) < Math.min(x.h, it.h) * 0.6);
if (r) { r.items.push(it); r.cy = (r.cy * r.items.length + it.cy) / (r.items.length + 1); r.h = (r.h + it.h) / 2; }
else rows.push({ cy: it.cy, h: it.h, items: [it] });
}
rows.forEach(r => r.items.sort((a, b) => rtl ? b.b[0] - a.b[0] : a.b[0] - b.b[0]));
return rows.flatMap(r => r.items.map(i => i.b));
}
function mergeRegionsStrong(boxes) {
const n = boxes.length; if (!n) return [];
const parent = Array.from({ length: n }, (_, i) => i);
const find = x => parent[x] === x ? x : (parent[x] = find(parent[x]));
const unite = (a, b) => { a = find(a); b = find(b); if (a !== b) parent[a] = b; };
const w = b => b[2] - b[0], h = b => b[3] - b[1];
const cx = b => (b[0] + b[2]) / 2, cy = b => (b[1] + b[3]) / 2;
const iou = (a, b) => {
const xx1 = Math.max(a[0], b[0]), yy1 = Math.max(a[1], b[1]);
const xx2 = Math.min(a[2], b[2]), yy2 = Math.min(a[3], b[3]);
const inter = Math.max(0, xx2 - xx1) * Math.max(0, yy2 - yy1);
const areaA = w(a) * h(a), areaB = w(b) * h(b);
return inter / Math.max(areaA + areaB - inter, 1);
};
const xOverlapRatio = (a, b) => Math.max(0, Math.min(a[2], b[2]) - Math.max(a[0], b[0])) / Math.max(Math.min(w(a), w(b)), 1);
const yOverlapRatio = (a, b) => Math.max(0, Math.min(a[3], b[3]) - Math.max(a[1], b[1])) / Math.max(Math.min(h(a), h(b)), 1);
const shouldLink = (a, b) => {
const d = Math.hypot(cx(a) - cx(b), cy(a) - cy(b));
const fmax = Math.max(h(a), h(b)), fmin = Math.max(Math.min(h(a), h(b)), 1);
if (d < fmax * 1.5 && fmax / fmin < 2.0) return true;
const xOR = xOverlapRatio(a, b), yOR = yOverlapRatio(a, b);
const gapY = Math.max(0, Math.max(a[1], b[1]) - Math.min(a[3], b[3]));
const gapX = Math.max(0, Math.max(a[0], b[0]) - Math.min(a[2], b[2]));
const maxH = Math.max(h(a), h(b)), maxW = Math.max(w(a), w(b));
if (xOR >= 0.4 && gapY < 1.4 * maxH) return true;
if (yOR >= 0.4 && gapX < 1.4 * maxW) return true;
if (iou(a, b) >= 0.05) return true;
const alignedX = Math.abs(cx(a) - cx(b)) < 0.35 * Math.min(w(a), w(b));
if (alignedX && gapY < 2.2 * maxH && (xOR >= 0.25 || gapX < 0.15 * maxW)) return true;
return false;
};
for (let i = 0; i < n; i++) for (let j = i + 1; j < n; j++) if (shouldLink(boxes[i], boxes[j])) unite(i, j);
const groups = new Map();
for (let i = 0; i < n; i++) { const p = find(i); if (!groups.has(p)) groups.set(p, []); groups.get(p).push(boxes[i]); }
const merged = [];
for (const arr of groups.values()) merged.push([ Math.min(...arr.map(b => b[0])), Math.min(...arr.map(b => b[1])), Math.max(...arr.map(b => b[2])), Math.max(...arr.map(b => b[3])) ]);
return merged;
}
async function createCanvasFromBlob(blob, width, height) {
return new Promise((ok, err) => {
const url = URL.createObjectURL(blob); const img = new Image();
img.onload = () => { const c = document.createElement('canvas'); c.width = width; c.height = height; c.getContext('2d').drawImage(img, 0, 0); URL.revokeObjectURL(url); ok(c); };
img.onerror = () => { URL.revokeObjectURL(url); err(new Error('img load')); };
img.src = url;
});
}
function drawTextOnCanvas(canvas, region) {
const ctx = canvas.getContext('2d'); const [x1, y1, x2, y2] = region.box;
const rw = x2 - x1, rh = y2 - y1;
ctx.fillStyle = '#fff'; ctx.fillRect(x1, y1, rw, rh);
const fs = Math.min(28, Math.max(12, rh * 0.35));
ctx.font = `${fs}px Arial, sans-serif`; ctx.fillStyle = '#000';
ctx.textAlign = 'center'; ctx.textBaseline = 'middle';
const words = (region.text || '').split(' '); const lines = []; let line = '';
for (const w of words) { const t = line + (line ? ' ' : '') + w; if (ctx.measureText(t).width > rw - 12 && line) { lines.push(line); line = w; } else line = t; }
if (line) lines.push(line);
const lh = fs * 1.2, total = lines.length * lh, startY = y1 + (rh - total) / 2 + lh / 2;
for (let i = 0; i < lines.length; i++) ctx.fillText(lines[i], x1 + rw / 2, startY + i * lh);
}
function deviceId() {
let id = GM_getValue('device_id');
if (!id) { id = 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, c => ((c === 'x' ? Math.random() * 16 : (Math.random() * 16 & 3) | 8) | 0).toString(16)); GM_setValue('device_id', id); }
return id;
}
async function externalAPI(img, lang = 'English') {
const blob = await fetchBlob(resolveUrl(img)); const resp = await new Promise((ok, err) => GM_xmlhttpRequest({
method: 'POST', url: `https://ocrt.iwanhae.kr/ocrv1?lang=${lang}`,
headers: { 'X-DEVICE-ID': deviceId(), 'X-REFERER': location.hostname, 'X-REFERER-PATH': location.pathname, 'Content-Type': blob.type || 'image/webp' },
data: blob, timeout: 120000, onload: r => ok(r.responseText || ''), onerror: () => err(new Error('external net')), ontimeout: () => err(new Error('external timeout'))
}));
const segments = [];
for (const line of resp.split(/\r?\n/)) {
const match = line.match(/^data:\s*(\{.*\})\s*$/); if (!match) continue;
try { const obj = JSON.parse(match[1]); if (obj?.type === 'segment' && obj.segment?.box_2d) segments.push({ text: obj.segment.text || '', translation: obj.segment.translation || '', box: obj.segment.box_2d }); } catch {}
} return segments;
}
async function geminiAPI(img) {
const key = GM_getValue('gemini_key'); if (!key) throw new Error('Set Gemini key via preset');
const targetLang = GM_getValue('target_lang', 'English');
const b64 = await fetchBase64(resolveUrl(img));
const body = { contents: [{ parts: [ // prompt complexity: percentages > pixel
{ text: `Detect ALL text in manga image (include partial edges). Extract original text and translate to ${targetLang}. Return JSON:
{"regions":[{"text":"original text","translation":"translated text","position":{"x":20.5,"y":30.2,"width":15.3,"height":10.1}}]}
Position values are percentages (0-100).` },
{ inline_data: { mime_type: 'image/jpeg', data: b64 } }
]}]};
const text = await new Promise((ok, err) => GM_xmlhttpRequest({
method: 'POST',
url: `https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash-lite-preview-09-2025:generateContent?key=${key}`,
headers: { 'Content-Type': 'application/json' }, data: JSON.stringify(body), timeout: 60000,
onload: r => {
if (r.status >= 200 && r.status < 300) { try { const j = JSON.parse(r.responseText); ok(j.candidates?.[0]?.content?.parts?.[0]?.text || ''); } catch { err(new Error('Gemini parse')); } }
else err(new Error('Gemini HTTP ' + r.status));
},
onerror: () => err(new Error('Gemini net')), ontimeout: () => err(new Error('Gemini timeout'))
}));
const m = text.match(/\{[\s\S]*\}/); if (!m) throw new Error('Gemini JSON missing');
const res = JSON.parse(m[0]), nat = { w: img.naturalWidth || img.width, h: img.naturalHeight || img.height }, out = [];
for (const r of (res?.regions || [])) {
const p = r.position || { x: 0, y: 0, width: 0, height: 0 };
const x1 = Math.round(p.x / 100 * nat.w), y1 = Math.round(p.y / 100 * nat.h);
const x2 = Math.round(x1 + p.width / 100 * nat.w), y2 = Math.round(y1 + p.height / 100 * nat.h);
out.push({ text: r.text || '', translation: r.translation || '', box: [x1, y1, x2, y2] });
} return out;
}
async function serverDetect(img) {
const b64 = await fetchBase64(resolveUrl(img));
const r = await gmFetch(`${API_BASE}/detect`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ image: b64 }) });
if (r?.status !== 'ok') throw new Error('detect failed');
log(`🧭 detect boxes=${(r.boxes || []).length} in ${Number(r.time || 0).toFixed(3)}s`);
return r.boxes || [];
}
async function serverOCR(img, boxes) {
const b64 = await fetchBase64(resolveUrl(img));
const r = await gmFetch(`${API_BASE}/ocr`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ image: b64, boxes }) });
if (r?.status !== 'ok') throw new Error('ocr failed');
return r.texts || [];
}
async function serverTranslateMBART(texts) {
const r = await gmFetch(`${API_BASE}/translate_mbart`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ texts }) });
if (r?.status !== 'ok') throw new Error('mbart failed');
return r.translations;
}
async function serverColorize(img, sigma = 15) {
const b64 = await fetchBase64(resolveUrl(img));
const r = await gmFetch(`${API_BASE}/colorize`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ image: b64, denoise_sigma: sigma }), timeout: 180000 });
if (r?.status !== 'ok') throw new Error('colorize failed');
return 'data:image/png;base64,' + r.image;
}
// Browser detectors + OCR (WASM backend CPU)
const RTDETR_URL = 'https://huggingface.co/ogkalu/comic-text-and-bubble-detector/resolve/main/detector.onnx';
if (typeof ort !== 'undefined' && ort.env?.wasm) {
ort.env.wasm.wasmPaths = {
'ort-wasm.wasm': 'https://cdn.jsdelivr.net/npm/[email protected]/dist/ort-wasm.wasm',
'ort-wasm-simd.wasm': 'https://cdn.jsdelivr.net/npm/[email protected]/dist/ort-wasm-simd.wasm',
'ort-wasm-threaded.wasm': 'https://cdn.jsdelivr.net/npm/[email protected]/dist/ort-wasm-threaded.wasm',
'ort-wasm-simd-threaded.wasm': 'https://cdn.jsdelivr.net/npm/[email protected]/dist/ort-wasm-simd-threaded.wasm'
};
ort.env.wasm.numThreads = 1; ort.env.wasm.simd = false;
}
let rtdetrSession = null;
async function ensureRTDETR() {
if (rtdetrSession) return;
const bytes = await gmFetch(RTDETR_URL, { responseType: 'arraybuffer' });
rtdetrSession = await ort.InferenceSession.create(bytes, { executionProviders: ['wasm'], graphOptimizationLevel: 'all' });
}
async function toCHW(url, size) {
const bl = await fetchBlob(url); const bm = await createImageBitmap(bl);
const c = document.createElement('canvas'); c.width = size; c.height = size; const ctx = c.getContext('2d');
ctx.drawImage(bm, 0, 0, size, size); const data = ctx.getImageData(0, 0, size, size).data;
const plane = size * size; const out = new Float32Array(3 * plane);
for (let i = 0; i < plane; i++) { out[i] = data[i * 4] / 255; out[plane + i] = data[i * 4 + 1] / 255; out[2 * plane + i] = data[i * 4 + 2] / 255; }
bm.close && bm.close(); return out;
}
async function detectRTDETR(img) {
const u = resolveUrl(img), W = img.naturalWidth || img.width, H = img.naturalHeight || img.height;
await ensureRTDETR(); const chw = await toCHW(u, RTDETR_RESIZE);
const out = await rtdetrSession.run({
images: new ort.Tensor('float32', chw, [1, 3, RTDETR_RESIZE, RTDETR_RESIZE]),
orig_target_sizes: new ort.Tensor('int64', new BigInt64Array([BigInt(W), BigInt(H)]), [1, 2])
});
const keys = Object.keys(out), boxes = out[keys[1]].data, scores = out[keys[2]].data;
const n = scores.length, det = [];
for (let i = 0; i < n; i++) {
if (scores[i] < RTDETR_CONFIDENCE) continue;
const x1 = Math.round(boxes[i * 4 + 0]), y1 = Math.round(boxes[i * 4 + 1]);
const x2 = Math.round(boxes[i * 4 + 2]), y2 = Math.round(boxes[i * 4 + 3]);
if ((x2 - x1) > 14 && (y2 - y1) > 10) det.push([x1, y1, x2, y2]);
} return det;
}
const MANGA_OCR = {
encoder: 'https://huggingface.co/l0wgear/manga-ocr-2025-onnx/resolve/main/encoder_model.onnx',
decoder: 'https://huggingface.co/l0wgear/manga-ocr-2025-onnx/resolve/main/decoder_model.onnx',
tokenizer: 'https://huggingface.co/l0wgear/manga-ocr-2025-onnx/resolve/main/tokenizer.json'
};
let mangaOCR = null;
class MangaOCRWeb {
constructor() { this.encoder = null; this.decoder = null; this.vocab = []; this.V = 0; this.maxLen = 50; }
async init() {
const tok = await gmFetch(MANGA_OCR.tokenizer, { responseType: 'json' });
if (!tok.model?.vocab) throw new Error('tokenizer');
const arr = Object.entries(tok.model.vocab).sort((a, b) => a[1] - b[1]); this.vocab = arr.map(([t]) => t); this.V = this.vocab.length;
const enc = await gmFetch(MANGA_OCR.encoder, { responseType: 'arraybuffer' });
this.encoder = await ort.InferenceSession.create(enc, { executionProviders: ['wasm'], graphOptimizationLevel: 'all' });
const dec = await gmFetch(MANGA_OCR.decoder, { responseType: 'arraybuffer' });
this.decoder = await ort.InferenceSession.create(dec, { executionProviders: ['wasm'] });
}
async ocrCrop(u, b) {
const [x1, y1, x2, y2] = b, bl = await fetchBlob(u), bm = await createImageBitmap(bl);
const c = document.createElement('canvas'); c.width = 256; c.height = 256; const x = c.getContext('2d');
x.drawImage(bm, x1, y1, x2 - x1, y2 - y1, 0, 0, 256, 256); const data = x.getImageData(16, 16, 224, 224).data; bm.close && bm.close();
const N = 224 * 224; const R = new Float32Array(N), G = new Float32Array(N), B = new Float32Array(N);
for (let i = 0, j = 0; i < data.length; i += 4, j++) { R[j] = data[i] / 255 * 2 - 1; G[j] = data[i + 1] / 255 * 2 - 1; B[j] = data[i + 2] / 255 * 2 - 1; }
const bat = new Float32Array([...R, ...G, ...B]);
const encOut = await this.encoder.run({ pixel_values: new ort.Tensor('float32', bat, [1, 3, 224, 224]) });
return this.decode(encOut.last_hidden_state);
}
async decode(hs) {
let ids = [2n]; const seq = hs.dims[1], hid = hs.dims[2];
while (ids.length < this.maxLen) {
const feed = { input_ids: new ort.Tensor('int64', new BigInt64Array(ids), [1, ids.length]), encoder_hidden_states: new ort.Tensor('float32', hs.data, [1, seq, hid]) };
const out = await this.decoder.run(feed), logits = out.logits.data;
let mx = -1e9, idx = 0; for (let j = 0; j < this.V; j++) { const v = logits[(ids.length - 1) * this.V + j]; if (v > mx) { mx = v; idx = j; } }
ids.push(BigInt(idx)); if (idx === 3) break;
}
return ids.filter(n => n > 14n).map(i => this.vocab[Number(i)]).join('');
}
}
async function ensureMangaOCR() { if (mangaOCR) return mangaOCR; mangaOCR = new MangaOCRWeb(); await mangaOCR.init(); return mangaOCR; }
async function translateGoogle(texts) {
if (!texts.length) return [];
const SEP = '\u241E', esc = s => s.replaceAll(SEP, ' ');
const q = texts.map(esc).join(SEP);
const p = new URLSearchParams({ client: 'gtx', sl: 'ja', tl: 'en', dt: 't', q });
const r = await gmFetch(`https://translate.googleapis.com/translate_a/single?${p}`, { timeout: 15000, responseType: 'json' });
let out = ''; if (Array.isArray(r?.[0])) for (const seg of r[0]) if (seg?.[0]) out += seg[0];
const arr = out.split(SEP); while (arr.length < texts.length) arr.push('');
return arr.slice(0, texts.length);
}
async function translateMyMemory(texts) {
if (!texts.length) return [];
const url = 'https://api.mymemory.translated.net/get';
const params = new URLSearchParams({ q: texts.join('\n'), langpair: 'ja|en', de: '[email protected]' });
try {
const r = await gmFetch(`${url}?${params}`, { timeout: 15000, responseType: 'json' });
if (r?.responseStatus === 200) {
const translated = r.responseData?.translatedText || '';
if (!translated || translated.includes('MetaMask')) return translateGoogle(texts);
const arr = translated.split('\n'); while (arr.length < texts.length) arr.push('');
return arr.slice(0, texts.length);
} else return translateGoogle(texts);
} catch { return translateGoogle(texts); }
}
async function getSegmentsFromAIO(img) {
const mode = GM_getValue('mode'), override = GM_getValue('translator', 'none');
let segs = (mode === 'gemini') ? await geminiAPI(img) : await externalAPI(img);
if (override !== 'none') {
const src = segs.map(s => s.text || '');
const tr = override === 'mbart' ? await serverTranslateMBART(src)
: override === 'google' ? await translateGoogle(src)
: override === 'mymemory' ? await translateMyMemory(src) : src;
segs = segs.map((s, i) => ({ ...s, translation: tr[i] || s.translation || s.text || '' }));
}
return segs;
}
async function detectBoxes(img) {
const d = GM_getValue('detector'); let raw = [];
if (d === 'yolo') raw = await serverDetect(img);
else if (d === 'rtdetr') raw = await detectRTDETR(img);
const grouped = GM_getValue('grouping_box', true) ? mergeRegionsStrong(raw) : raw;
return sortReadingOrder(grouped, true);
}
async function ocrTexts(img, boxes) {
const o = GM_getValue('ocr');
if (o === 'server') return await serverOCR(img, boxes);
if (o === 'manga2025') {
const m = await ensureMangaOCR(), u = resolveUrl(img), arr = [];
for (const b of boxes) arr.push(await m.ocrCrop(u, b));
return arr;
}
return [];
}
async function translateTexts(texts) {
const t = GM_getValue('translator');
if (t === 'mbart') return await serverTranslateMBART(texts);
if (t === 'google') return await translateGoogle(texts);
if (t === 'mymemory') return await translateMyMemory(texts);
return texts;
}
async function run(img) { // Universal pipeline for modularity.
const t0 = performance.now();
if (!img.getContext && !img.complete && img.addEventListener) { img.addEventListener('load', () => run(img), { once: true }); return; }
if (!img.dataset.originalSrc) img.dataset.originalSrc = resolveUrl(img);
const nat = { w: img.naturalWidth || img.width, h: img.naturalHeight || img.height };
try {
let base = await fetchBlob(resolveUrl(img));
if (GM_getValue('mode') === 'server' && GM_getValue('colorize', true)) {
try { const b64 = await serverColorize(img); base = await fetch(b64).then(r => r.blob()); } catch (e) { log('⚠️ Colorize:', e.message); }
}
const canvas = await createCanvasFromBlob(base, nat.w, nat.h);
if (GM_getValue('aio', true)) {
const segs = await getSegmentsFromAIO(img);
for (const s of segs) drawTextOnCanvas(canvas, { box: s.box, text: s.translation || s.text });
} else {
const boxes = await detectBoxes(img);
if (boxes.length) {
const texts = await ocrTexts(img, boxes);
const trans = await translateTexts(texts);
for (let i = 0; i < boxes.length; i++) drawTextOnCanvas(canvas, { box: boxes[i], text: trans[i] || texts[i] });
} else log('No boxes');
}
if (img.getContext) { const x = img.getContext('2d'); x.clearRect(0, 0, img.width, img.height); x.drawImage(canvas, 0, 0); }
else img.src = canvas.toDataURL('image/png');
log(`✓ Done in ${((performance.now() - t0) / 1000).toFixed(2)}s`);
} catch (e) { log('ERR:', e.message || e); }
}
async function processAllImages() { // Trigger button: Process all images
const imgs = findMangaImages();
if (!imgs.length) { log(`No manga images found (min size: ${DETECT_MIN_IMAGE_SIZE}px)`); return; }
for (const img of imgs) await run(img);
}
document.addEventListener('keydown', e => { // Trigger Arrow keys: process visible image
if ((e.key === 'ArrowLeft' || e.key === 'ArrowRight') && !e.ctrlKey && !e.altKey) {
const a = document.activeElement;
if (a && (a.tagName === 'INPUT' || a.tagName === 'TEXTAREA' || a.isContentEditable)) return;
e.preventDefault();
const img = getVisibleImage();
if (!img) { log(`No manga images found (min size: ${DETECT_MIN_IMAGE_SIZE}px)`); return; }
run(img);
}
});
setTimeout(() => { const imgs = findMangaImages(); log(`Found ${imgs.length} manga images (≥${DETECT_MIN_IMAGE_SIZE}px)`); }, 1000);
})();