acsaco's picture
download
raw
13.6 kB
// Global states
let activeProgressWidget = null;
let activeWidgetData = {
total: 0,
completed: 0,
rollingTimes: [],
startTime: 0
};
// Listen for messages from popup or background scripts
chrome.runtime.onMessage.addListener((request, sender, sendResponse) => {
if (request.action === 'translate_page') {
const images = discoverImages();
if (images.length === 0) {
sendResponse({ success: false, reason: 'No images found' });
return;
}
sendResponse({ success: true, count: images.length });
startProgressiveTranslation(images, request);
}
else if (request.action === 'translate_single_image') {
const imgEl = findImageElementByUrl(request.imageUrl);
if (imgEl) {
sendResponse({ success: true });
translateSingleImage(imgEl, request);
} else {
sendResponse({ success: false, reason: 'Image element not found in DOM' });
}
}
return true;
});
// Discover all large images on the webpage (ignores icons, logos, ads)
function discoverImages() {
const images = Array.from(document.querySelectorAll('img'));
return images.filter(img => {
// Basic heuristics for comic/manga pages
const rect = img.getBoundingClientRect();
const w = img.naturalWidth || rect.width;
const h = img.naturalHeight || rect.height;
// Ignore small icons or thin borders
if (w < 200 || h < 200) return false;
// Ignore already translated
if (img.dataset.mangaTranslated === 'true') return false;
return true;
});
}
// Find image element by source URL in DOM
function findImageElementByUrl(url) {
const images = document.querySelectorAll('img');
for (const img of images) {
if (img.src === url || img.dataset.originalSrc === url) {
return img;
}
}
return null;
}
// Create a glassmorphic floating progress dashboard widget
function createProgressWidget(totalCount) {
if (activeProgressWidget) {
activeProgressWidget.remove();
}
activeWidgetData = {
total: totalCount,
completed: 0,
rollingTimes: [],
startTime: Date.now()
};
const widget = document.createElement('div');
widget.className = 'manga-progress-widget';
widget.id = 'manga-progress-widget-root';
widget.innerHTML = `
<!-- Minimized view icon badge -->
<div class="manga-progress-minimized-badge" id="manga-widget-minimized">
<span style="font-size: 14px;">文</span>
<span id="manga-widget-min-text">0/${totalCount}</span>
</div>
<!-- Full expanded content -->
<div class="manga-progress-full-content" id="manga-widget-expanded">
<div class="manga-progress-widget-header">
<span class="manga-progress-widget-title">Traduciendo Manga</span>
<div class="manga-progress-widget-controls">
<button class="manga-progress-btn" id="manga-btn-minimize" title="Minimizar">_</button>
<button class="manga-progress-btn" id="manga-btn-close" title="Cerrar">×</button>
</div>
</div>
<div class="manga-progress-stats">
<span>Progreso:</span>
<span id="manga-txt-progress">0 de ${totalCount} páginas</span>
</div>
<div class="manga-progress-bar-bg">
<div class="manga-progress-bar-fill" id="manga-bar-fill"></div>
</div>
<div class="manga-progress-eta">
<span>Tiempo Estimado:</span>
<span class="manga-progress-eta-badge" id="manga-txt-eta">Calculando...</span>
</div>
</div>
`;
document.body.appendChild(widget);
activeProgressWidget = widget;
// Add event listeners
const btnMin = widget.querySelector('#manga-btn-minimize');
const btnClose = widget.querySelector('#manga-btn-close');
btnMin.addEventListener('click', (e) => {
e.stopPropagation();
widget.classList.add('minimized');
});
btnClose.addEventListener('click', (e) => {
e.stopPropagation();
widget.remove();
activeProgressWidget = null;
});
// Restore when clicking minimized bubble
widget.addEventListener('click', () => {
if (widget.classList.contains('minimized')) {
widget.classList.remove('minimized');
}
});
}
// Update progress stats in floating widget
function updateProgressWidget(completedIncrement, elapsedMs) {
if (!activeProgressWidget) return;
activeWidgetData.completed += completedIncrement;
if (elapsedMs > 0) {
activeWidgetData.rollingTimes.push(elapsedMs);
}
const { total, completed, rollingTimes } = activeWidgetData;
const pct = Math.min(100, Math.round((completed / total) * 100));
// Update text values
document.getElementById('manga-txt-progress').textContent = `${completed} de ${total} páginas`;
document.getElementById('manga-bar-fill').style.width = `${pct}%`;
document.getElementById('manga-widget-min-text').textContent = `${completed}/${total}`;
// Calculate ETA
const txtEta = document.getElementById('manga-txt-eta');
if (completed === total) {
txtEta.textContent = '¡Completado!';
txtEta.style.background = 'rgba(46, 213, 115, 0.2)';
txtEta.style.color = '#2ed573';
} else if (rollingTimes.length > 0) {
const sum = rollingTimes.reduce((a, b) => a + b, 0);
const avg = sum / rollingTimes.length;
const remaining = total - completed;
const etaMs = avg * remaining;
txtEta.textContent = formatTime(etaMs);
} else {
txtEta.textContent = 'Calculando...';
}
}
function formatTime(ms) {
const seconds = Math.ceil(ms / 1000);
if (seconds < 60) {
return `~${seconds}s`;
}
const minutes = Math.floor(seconds / 60);
const remSecs = seconds % 60;
return `~${minutes}m ${remSecs}s`;
}
// Overlay a loading spinner over image
function addLoaderOverlay(imgEl) {
// If already wrapped, return existing overlay
if (imgEl.parentElement && imgEl.parentElement.classList.contains('manga-translate-wrapper')) {
return imgEl.parentElement.querySelector('.manga-translate-spinner-overlay');
}
const wrapper = document.createElement('div');
wrapper.className = 'manga-translate-wrapper';
// Set wrapper styles to mirror image dimensions and layout
const rect = imgEl.getBoundingClientRect();
wrapper.style.width = (rect.width || imgEl.clientWidth || imgEl.naturalWidth || 200) + 'px';
wrapper.style.height = (rect.height || imgEl.clientHeight || imgEl.naturalHeight || 300) + 'px';
wrapper.style.display = window.getComputedStyle(imgEl).display;
imgEl.replaceWith(wrapper);
wrapper.appendChild(imgEl);
const overlay = document.createElement('div');
overlay.className = 'manga-translate-spinner-overlay';
overlay.innerHTML = `
<div class="manga-translate-spinner"></div>
<span style="font-weight: 500;">Traduciendo...</span>
`;
wrapper.appendChild(overlay);
return overlay;
}
// Remove loading spinner wrapper
function removeLoaderOverlay(imgEl) {
const wrapper = imgEl.parentElement;
if (wrapper && wrapper.classList.contains('manga-translate-wrapper')) {
const overlay = wrapper.querySelector('.manga-translate-spinner-overlay');
if (overlay) overlay.remove();
wrapper.replaceWith(imgEl);
}
}
// Extract base64 representation of image source
async function getBase64FromImage(imgEl) {
const src = imgEl.src;
// 1. Handle data URLs directly
if (src.startsWith('data:')) {
return src.split(',')[1];
}
// 2. Handle blob URLs (background scripts cannot fetch blob URLs from pages)
if (src.startsWith('blob:')) {
try {
const response = await fetch(src);
const blob = await response.blob();
return await new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onloadend = () => resolve(reader.result.split(',')[1]);
reader.onerror = reject;
reader.readAsDataURL(blob);
});
} catch (e) {
console.warn("Failed to read blob URL, falling back to canvas", e);
}
}
// 3. Ask background service worker to fetch and convert to base64 bypassing CORS
try {
return await new Promise((resolve, reject) => {
chrome.runtime.sendMessage({
action: 'fetch_image_base64',
url: src
}, (response) => {
if (chrome.runtime.lastError) {
reject(new Error(chrome.runtime.lastError.message));
} else if (response && response.success) {
resolve(response.base64);
} else {
reject(new Error(response ? response.error : 'Failed to fetch image base64 via background worker'));
}
});
});
} catch (e) {
console.warn("Background fetch failed for image, falling back to canvas", src, e);
// 4. Fallback: Draw to canvas (may throw tainted canvas error if CORS is strict)
return new Promise((resolve, reject) => {
try {
const canvas = document.createElement('canvas');
canvas.width = imgEl.naturalWidth || imgEl.width;
canvas.height = imgEl.naturalHeight || imgEl.height;
const ctx = canvas.getContext('2d');
ctx.drawImage(imgEl, 0, 0);
const dataURL = canvas.toDataURL('image/jpeg', 0.9);
resolve(dataURL.split(',')[1]);
} catch (canvasErr) {
reject(new Error(`Canvas fallback failed (likely CORS taint): ${canvasErr.message}. Original fetch error: ${e.message}`));
}
});
}
}
// Progressive image translation orchestrator
async function startProgressiveTranslation(images, config) {
createProgressWidget(images.length);
const concurrency = 2; // Run 2 parallel requests to avoid overloading local CPU/GPU/Ollama
let index = 0;
const worker = async () => {
while (index < images.length) {
const currentIndex = index++;
const imgEl = images[currentIndex];
const imgStartTime = Date.now();
addLoaderOverlay(imgEl);
try {
const base64Str = await getBase64FromImage(imgEl);
// Prepare API request payload
const payload = {
image: base64Str,
target_lang: config.targetLang,
translator: config.translator,
ollama_model: config.ollamaModel,
ollama_host: config.ollamaHost,
libretranslate_host: config.libretranslateHost,
detector: 'ctd',
ocr: 'manga_ocr',
inpainter: 'lama',
renderer: 'manga2eng'
};
// Request translation via background worker to bypass page CSP restrictions
const result = await new Promise((resolve, reject) => {
chrome.runtime.sendMessage({
action: 'translate_image',
apiUrl: config.apiUrl,
payload: payload
}, (response) => {
if (chrome.runtime.lastError) {
reject(new Error(chrome.runtime.lastError.message));
} else if (response && response.success) {
resolve(response.data);
} else {
reject(new Error(response ? response.error : 'Failed to translate image via background worker'));
}
});
});
if (result.success && result.translated_image) {
// Replace source immediately
imgEl.dataset.originalSrc = imgEl.src;
imgEl.src = `data:image/jpeg;base64,${result.translated_image}`;
imgEl.dataset.mangaTranslated = 'true';
imgEl.classList.add('manga-translated-fade-in');
} else {
throw new Error(result.error || 'API translation failed');
}
} catch (e) {
console.error(`Failed to translate image ${currentIndex}:`, e);
} finally {
removeLoaderOverlay(imgEl);
const elapsed = Date.now() - imgStartTime;
updateProgressWidget(1, elapsed);
}
}
};
// Launch workers in parallel
const workers = Array.from({ length: concurrency }, () => worker());
await Promise.all(workers);
}
// Translate a single image triggered from the context menu
async function translateSingleImage(imgEl, config) {
const imgStartTime = Date.now();
addLoaderOverlay(imgEl);
try {
const base64Str = await getBase64FromImage(imgEl);
const payload = {
image: base64Str,
target_lang: config.targetLang,
translator: config.translator,
ollama_model: config.ollamaModel,
ollama_host: config.ollamaHost,
libretranslate_host: config.libretranslateHost,
detector: 'ctd',
ocr: 'manga_ocr',
inpainter: 'lama',
renderer: 'manga2eng'
};
// Request translation via background worker to bypass page CSP restrictions
const result = await new Promise((resolve, reject) => {
chrome.runtime.sendMessage({
action: 'translate_image',
apiUrl: config.apiUrl,
payload: payload
}, (response) => {
if (chrome.runtime.lastError) {
reject(new Error(chrome.runtime.lastError.message));
} else if (response && response.success) {
resolve(response.data);
} else {
reject(new Error(response ? response.error : 'Failed to translate image via background worker'));
}
});
});
if (result.success && result.translated_image) {
imgEl.dataset.originalSrc = imgEl.src;
imgEl.src = `data:image/jpeg;base64,${result.translated_image}`;
imgEl.dataset.mangaTranslated = 'true';
imgEl.classList.add('manga-translated-fade-in');
} else {
throw new Error(result.error || 'API translation failed');
}
} catch (e) {
console.error('Single image translation failed:', e);
alert('Fallo la traducción de la imagen. Verifica que la API local esté corriendo.');
} finally {
removeLoaderOverlay(imgEl);
}
}

Xet Storage Details

Size:
13.6 kB
·
Xet hash:
34ff7a02b9e8bedc3cf9a7408bd0205908bd5d9c9a49e615494c5e17b6554276

Xet efficiently stores files, intelligently splitting them into unique chunks and accelerating uploads and downloads. More info.