Jp Manga Translator

Translate manga from Japanese/Chinese to English using Gemini, or optionally with a Python server (YOLO+MangaOCR+mBART+Colorization)/in-browser RT-DETR/Google.

// ==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);
})();