|
<!DOCTYPE html> |
|
<html> |
|
<head> |
|
<meta charset="utf-8" /> |
|
<title>Image/Manga Translator</title> |
|
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@unocss/reset/tailwind.min.css" /> |
|
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/petite-vue.iife.js"></script> |
|
<script src="https://cdn.jsdelivr.net/npm/@unocss/[email protected]/uno.global.js"></script> |
|
<script src="https://cdn.jsdelivr.net/npm/@iconify/[email protected]/dist/iconify.min.js"></script> |
|
<style> |
|
[v-cloak], |
|
[un-cloak] { |
|
display: none; |
|
} |
|
</style> |
|
</head> |
|
<body> |
|
<form |
|
action="#" |
|
class="flex py-8 w-full min-h-100vh justify-center items-center" |
|
@submit.prevent="onsubmit" |
|
@vue:mounted="onmounted" |
|
v-scope |
|
v-cloak |
|
un-cloak |
|
> |
|
<div class="flex flex-col w-85ch h-full justify-center gap-2"> |
|
<h1 class="text-center text-lg font-light">Image/Manga Translator</h1> |
|
<div class="flex mx-4 justify-start items-end"> |
|
<div class="flex gap-4"> |
|
<div class="flex items-center" title="Detection resolution"> |
|
<i class="iconify" data-icon="carbon:fit-to-screen"></i> |
|
<div class="relative"> |
|
<select class="w-9ch appearance-none bg-transparent border-b border-gray-300" v-model="detectionResolution"> |
|
<option value="S">1024px</option> |
|
<option value="M">1536px</option> |
|
<option value="L">2048px</option> |
|
<option value="X">2560px</option> |
|
</select> |
|
<i class="iconify absolute top-1.5 right-1 pointer-events-none" data-icon="carbon:chevron-down"></i> |
|
</div> |
|
</div> |
|
<div class="flex items-center gap-1" title="Text detector"> |
|
<i class="iconify" data-icon="carbon:search-locate"></i> |
|
<div class="relative"> |
|
<select class="w-9ch appearance-none bg-transparent border-b border-gray-300" v-model="textDetector"> |
|
<option value="auto">Default</option> |
|
<option value="ctd">CTD</option> |
|
</select> |
|
<i class="iconify absolute top-1.5 right-1 pointer-events-none" data-icon="carbon:chevron-down"></i> |
|
</div> |
|
</div> |
|
<div class="flex items-center gap-1" title="Render text orientation"> |
|
<i class="iconify" data-icon="carbon:text-align-left"></i> |
|
<div class="relative"> |
|
<select class="w-12ch appearance-none bg-transparent border-b border-gray-300" v-model="renderTextDirection"> |
|
<option value="auto">Auto</option> |
|
<option value="h">Horizontal</option> |
|
<option value="v">Vertical</option> |
|
</select> |
|
<i class="iconify absolute top-1.5 right-1 pointer-events-none" data-icon="carbon:chevron-down"></i> |
|
</div> |
|
</div> |
|
<div class="flex items-center gap-1" title="Translator"> |
|
<i class="iconify" data-icon="carbon:operations-record"></i> |
|
<div class="relative"> |
|
<select class="w-9ch appearance-none bg-transparent border-b border-gray-300" v-model="translator"> |
|
<option v-for="key in validTranslators" :value="key">{{getTranslatorName(key)}}</option> |
|
</select> |
|
<i class="iconify absolute top-1.5 right-1 pointer-events-none" data-icon="carbon:chevron-down"></i> |
|
</div> |
|
</div> |
|
<div class="flex items-center gap-1" title="Target language"> |
|
<i class="iconify" data-icon="carbon:language"></i> |
|
<div class="relative"> |
|
<select class="w-15ch appearance-none bg-transparent border-b border-gray-300" v-model="targetLanguage"> |
|
<option value="CHS">简体中文</option> |
|
<option value="CHT">繁體中文</option> |
|
<option value="JPN">日本語</option> |
|
<option value="ENG">English</option> |
|
<option value="KOR">한국어</option> |
|
<option value="VIN">Tiếng Việt</option> |
|
<option value="CSY">čeština</option> |
|
<option value="NLD">Nederlands</option> |
|
<option value="FRA">français</option> |
|
<option value="DEU">Deutsch</option> |
|
<option value="HUN">magyar nyelv</option> |
|
<option value="ITA">italiano</option> |
|
<option value="PLK">polski</option> |
|
<option value="PTB">português</option> |
|
<option value="ROM">limba română</option> |
|
<option value="RUS">русский язык</option> |
|
<option value="ESP">español</option> |
|
<option value="TRK">Türk dili</option> |
|
<option value="IND">Indonesia</option> |
|
</select> |
|
<i class="iconify absolute top-1.5 right-1 pointer-events-none" data-icon="carbon:chevron-down"></i> |
|
</div> |
|
</div> |
|
</div> |
|
</div> |
|
<div v-if="result" class="flex flex-col items-center"> |
|
<img class="my-2" :src="resultUri" /> |
|
<button class="px-2 py-1 text-center rounded-md text-blue-800 border-2 border-blue-300" @click="clear">Upload another</button> |
|
</div> |
|
<div v-else-if="status" class="grid w-full h-116 place-content-center rounded-2xl border-2 border-dashed border-gray-600"> |
|
<div v-if="error" class="flex flex-col items-center gap-2"> |
|
<div style="color: crimson">{{ statusText }}</div> |
|
<button class="px-2 py-1 text-center rounded-md text-blue-800 border-2 border-blue-300" @click="clear">Upload another</button> |
|
</div> |
|
<div v-else class="flex flex-col items-center gap-2"> |
|
<i class="iconify w-8 h-8 text-gray-500 animate-spin" data-icon="carbon:progress-bar-round"></i> |
|
<div>{{ statusText }}</div> |
|
</div> |
|
</div> |
|
<label |
|
v-else |
|
class="grid w-full h-116 place-content-center rounded-2xl border-2 border-dashed border-gray-600 cursor-pointer" |
|
for="file" |
|
@dragenter.prevent |
|
@dragover.prevent |
|
@dragleave.prevent |
|
@drop.prevent="ondrop" |
|
> |
|
<div v-if="file" class="flex flex-col items-center gap-2"> |
|
<div><span class="iconify-inline inline-block mr-2 scale-125" data-icon="carbon:image-search"></span>File Preview</div> |
|
<img class="max-w-72 max-h-72" :src="fileUri" /> |
|
<button type="submit" class="px-2 py-1 rounded-md text-blue-800 border-2 border-blue-300">Translate</button> |
|
<div class="text-sm text-gray-600">Click the empty space or paste/drag a new one to replace</div> |
|
</div> |
|
<div v-else class="flex flex-col items-center gap-2"> |
|
<i class="iconify w-8 h-8 text-gray-500" data-icon="carbon:cloud-upload"></i> |
|
<div>Paste an image, click to select one or drag and drop here</div> |
|
</div> |
|
<input id="file" type="file" accept="image/png,image/jpeg,image/bmp,image/webp" class="hidden" @change="onfilechange" /> |
|
</label> |
|
<div class="flex justify-center gap-2"> |
|
<div> |
|
Please consider supporting us by |
|
<a class="underline underline-blue-400" href="https://ko-fi.com/voilelabs" target="_blank" rel="noopener noreferrer">Ko-fi</a> |
|
or |
|
<a class="underline underline-blue-400" href="https://www.patreon.com/voilelabs" target="_blank" rel="noopener noreferrer" |
|
>Patreon</a |
|
>! |
|
</div> |
|
<a |
|
class="underline underline-blue-400" |
|
href="https://github.com/zyddnys/manga-image-translator" |
|
target="_blank" |
|
rel="noopener noreferrer" |
|
>Source Code</a |
|
> |
|
</div> |
|
</div> |
|
</form> |
|
<script> |
|
const BASE_URI = '/' |
|
const acceptTypes = ['image/png', 'image/jpeg', 'image/bmp', 'image/webp'] |
|
|
|
function formatSize(bytes) { |
|
const k = 1024 |
|
const sizes = ['B', 'KB', 'MB', 'GB', 'TB'] |
|
if (bytes === 0) return '0B' |
|
const i = Math.floor(Math.log(bytes) / Math.log(k)) |
|
return `${(bytes / k ** i).toFixed(2)}${sizes[i]}` |
|
} |
|
function formatProgress(loaded, total) { |
|
return `${formatSize(loaded)}/${formatSize(total)}` |
|
} |
|
|
|
PetiteVue.createApp({ |
|
onmounted() { |
|
window.addEventListener('paste', this.onpaste) |
|
}, |
|
|
|
file: null, |
|
get fileUri() { |
|
return this.file ? URL.createObjectURL(this.file) : null |
|
}, |
|
detectionResolution: 'M', |
|
textDetector: 'auto', |
|
renderTextDirection: 'auto', |
|
translator: 'youdao', |
|
validTranslators: ['youdao', 'baidu', 'google', 'deepl', 'papago', 'caiyun', 'offline', 'gpt3.5', 'none'], |
|
getTranslatorName(key) { |
|
if (key == 'none') |
|
return "No Text" |
|
return key ? key[0].toUpperCase() + key.slice(1) : ""; |
|
}, |
|
targetLanguage: 'CHS', |
|
ondrop(e) { |
|
const file = e.dataTransfer?.files?.[0] |
|
if (file && acceptTypes.includes(file.type)) { |
|
this.file = file |
|
} |
|
}, |
|
onfilechange(e) { |
|
const file = e.target.files?.[0] |
|
if (file && acceptTypes.includes(file.type)) { |
|
this.file = file |
|
} |
|
}, |
|
onpaste(e) { |
|
const items = (e.clipboardData || e.originalEvent.clipboardData).items |
|
for (const item of items) { |
|
if (item.kind === 'file') { |
|
const file = item.getAsFile() |
|
if (!file || !acceptTypes.includes(file.type)) continue |
|
this.file = file |
|
} |
|
} |
|
}, |
|
|
|
progress: null, |
|
status: null, |
|
queuePos: null, |
|
cachedStatusText: '', |
|
get statusText() { |
|
var newStatusText = this._statusText |
|
if (newStatusText != null && newStatusText != this.cachedStatusText) { |
|
this.cachedStatusText = newStatusText |
|
} |
|
return this.cachedStatusText |
|
}, |
|
get _statusText() { |
|
switch (this.status) { |
|
case 'upload': { |
|
if (this.progress) { |
|
return `Uploading (${this.progress})` |
|
} else { |
|
return 'Uploading' |
|
} |
|
} |
|
case 'download': |
|
if (this.progress) { |
|
return `Downloading (${this.progress})` |
|
} else { |
|
return 'Downloading' |
|
} |
|
|
|
case 'pending': |
|
if (this.queuePos) { |
|
return `Queuing, your position is ${this.queuePos}` |
|
} else { |
|
return 'Processing' |
|
} |
|
case 'detection': |
|
return 'Detecting texts' |
|
case 'ocr': |
|
return 'Running OCR' |
|
case 'mask-generation': |
|
return 'Generating text mask' |
|
case 'inpainting': |
|
return 'Running inpainting' |
|
case 'upscaling': |
|
return 'Running upscaling' |
|
case 'translating': |
|
return 'Translating' |
|
case 'rendering': |
|
return 'Rendering translated texts' |
|
case 'error': |
|
return 'Something went wrong, please try again' |
|
case 'error-upload': |
|
return 'Upload failed, please try again' |
|
case 'error-lang': |
|
return 'Your target language is not supported by the chosen translator' |
|
case 'error-translating': |
|
return 'Did not get any text back from the text translation service' |
|
case 'error-too-large': |
|
return 'Image size too large (greater than 8000x8000 px)' |
|
case 'error-disconnect': |
|
return 'Lost connection to server' |
|
} |
|
}, |
|
get error() { |
|
return /^error/.test(this.status) |
|
}, |
|
result: null, |
|
get resultUri() { |
|
return this.result ? URL.createObjectURL(this.result) : null |
|
}, |
|
onsubmit(e) { |
|
if (!this.file) return |
|
|
|
this.progress = null |
|
this.queuePos = null |
|
this.status = 'upload' |
|
|
|
const formData = new FormData() |
|
formData.append('file', this.file) |
|
formData.append('size', this.detectionResolution) |
|
formData.append('detector', this.textDetector) |
|
formData.append('direction', this.renderTextDirection) |
|
formData.append('translator', this.translator) |
|
formData.append('tgt_lang', this.targetLanguage) |
|
|
|
const xhr = new XMLHttpRequest() |
|
xhr.open('POST', `${BASE_URI}submit`, true) |
|
xhr.onerror = (e) => { |
|
this.status = 'error-disconnect' |
|
} |
|
xhr.upload.onprogress = (e) => { |
|
if (e.lengthComputable) this.progress = formatProgress(e.loaded, e.total) |
|
} |
|
xhr.onload = async () => { |
|
if (xhr.status !== 200) { |
|
this.status = 'error-upload' |
|
return |
|
} |
|
|
|
response = JSON.parse(xhr.responseText) |
|
const task_id = response['task_id'] |
|
this.status = response['status'] |
|
if (this.error) |
|
return |
|
|
|
this.status = 'pending' |
|
|
|
async function tryFetchTaskState() { |
|
try { |
|
return await (await fetch(`${BASE_URI}task-state?taskid=${task_id}`)).json() |
|
} |
|
catch { |
|
return null |
|
} |
|
} |
|
|
|
for (;;) { |
|
const timer = new Promise((resolve) => setTimeout(resolve, 500)) |
|
const res = await tryFetchTaskState() |
|
if (res == null) { |
|
this.status = 'error-disconnect' |
|
break |
|
} |
|
const { state, finished, waiting } = res |
|
|
|
|
|
if (finished && !state.startsWith('error')) { |
|
this.progress = null |
|
this.status = 'download' |
|
|
|
const xhrDownload = new XMLHttpRequest() |
|
xhrDownload.open('GET', `${BASE_URI}result/${task_id}`, true) |
|
xhrDownload.responseType = 'blob' |
|
xhrDownload.onprogress = (e) => { |
|
if (e.lengthComputable) this.progress = formatProgress(e.loaded, e.total) |
|
} |
|
xhrDownload.onload = () => { |
|
this.result = xhrDownload.response |
|
this.status = null |
|
} |
|
xhrDownload.send() |
|
|
|
break |
|
} |
|
|
|
this.status = state |
|
this.queuePos = waiting |
|
|
|
if (/^error/.test(state)) { |
|
break |
|
} |
|
|
|
await timer |
|
} |
|
} |
|
xhr.send(formData) |
|
}, |
|
clear() { |
|
this.file = null |
|
this.result = null |
|
this.status = null |
|
}, |
|
}).mount() |
|
</script> |
|
</body> |
|
</html> |
|
|