diff --git a/package-lock.json b/package-lock.json index fb06c39..5c75d0b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -14,6 +14,7 @@ "@fortawesome/free-regular-svg-icons": "^5.11.2", "@fortawesome/free-solid-svg-icons": "^5.11.2", "@fortawesome/react-fontawesome": "^0.1.7", + "@jokester/ts-commonutil": "^0.5.0", "@reduxjs/toolkit": "^1.2.5", "@zip.js/zip.js": "^2.7.20", "antd": "^4.15.1", @@ -1250,6 +1251,22 @@ "integrity": "sha512-6EwiSjwWYP7pTckG6I5eyFANjPhmPjUX9JRLUSfNPC7FX7zK9gyZAfUEaECL6ALTpGX5AjnBq3C9XmVWPitNpw==", "dev": true }, + "node_modules/@jokester/ts-commonutil": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/@jokester/ts-commonutil/-/ts-commonutil-0.5.0.tgz", + "integrity": "sha512-SpSXRg5VQ8ezZ7EhGeBMkcFsng+6di857SqTQHg5u2yNppm5g92lZsZ2piUAV+B/OeZsmOmDbdoeuvQw+8tAew==", + "dependencies": { + "tslib": "^2.6" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/@jokester/ts-commonutil/node_modules/tslib": { + "version": "2.6.2", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.6.2.tgz", + "integrity": "sha512-AEYxH93jGFPn/a2iVAwW87VuUIkR1FVUKB77NwMF7nBTDkDrrT/Hpt/IrCJ0QXhW27jTBDcf5ZY7w6RiqTMw2Q==" + }, "node_modules/@jridgewell/gen-mapping": { "version": "0.3.3", "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.3.tgz", diff --git a/package.json b/package.json index 03d5817..d8a06b2 100644 --- a/package.json +++ b/package.json @@ -8,6 +8,7 @@ "@fortawesome/free-regular-svg-icons": "^5.11.2", "@fortawesome/free-solid-svg-icons": "^5.11.2", "@fortawesome/react-fontawesome": "^0.1.7", + "@jokester/ts-commonutil": "^0.5.0", "@reduxjs/toolkit": "^1.2.5", "@zip.js/zip.js": "^2.7.20", "antd": "^4.15.1", diff --git a/src/App.tsx b/src/App.tsx index 7fe9eec..43d5b9c 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -12,6 +12,7 @@ import ResetPassword from './pages/ResetPassword'; import { NotFoundPage } from './pages/404'; import { AppState } from './store'; import style from './style'; +import { MitPreprocessDemo } from './pages/mit/MitPreprocessDemo'; import { routes } from './pages/routes'; // 公共的页面 @@ -20,7 +21,7 @@ const publicPaths = [ routes.login, routes.signUp, routes.resetPassword, - // routes.mit.preprocessDemo, + routes.mit.preprocessDemo, ] as readonly string[]; const App: React.FC = () => { @@ -168,6 +169,9 @@ const App: React.FC = () => { + + + {userIsAdmin && ( diff --git a/src/apis/_request.ts b/src/apis/_request.ts new file mode 100644 index 0000000..e1086ea --- /dev/null +++ b/src/apis/_request.ts @@ -0,0 +1,16 @@ +import { BasicSuccessResult, request } from '.'; +import { AxiosRequestConfig } from 'axios'; + +export async function uploadRequest( + data: FormData, + configs: AxiosRequestConfig, +): Promise> { + return request({ + data, + ...configs, + headers: { + ...configs.headers, + 'Content-Type': 'multipart/form-data', + }, + }); +} diff --git a/src/apis/index.ts b/src/apis/index.ts index 7333c4c..6d9b233 100644 --- a/src/apis/index.ts +++ b/src/apis/index.ts @@ -29,6 +29,7 @@ import user from './user'; import group from './group'; import insight from './insight'; import siteSetting from './siteSetting'; +import { mitPreprocess } from './mit_preprocess'; // TODO: move instance/request to a peer file, to prevent circular imports // TODO: can we hide this from API callsites? @@ -247,6 +248,34 @@ const defaultNetworkFailure = () => { }); }; +export const api = { + // TODO switch to this nested / drop use of default export + application, + auth, + file, + group, + insight, + instance, + invitation, + language, + // mitPreprocess, + me, + member, + output, + project, + projectSet, + siteSetting, + source, + target, + team, + tip, + translation, + type, + user, +} as const; +/** + * @deprecated use named import + */ export default { instance, ...auth, diff --git a/src/apis/mit_preprocess.ts b/src/apis/mit_preprocess.ts new file mode 100644 index 0000000..351470b --- /dev/null +++ b/src/apis/mit_preprocess.ts @@ -0,0 +1,109 @@ +import { request } from '.'; +import { uploadRequest } from './_request'; +import { wait } from '@jokester/ts-commonutil/lib/concurrency/timing'; + +const mitApiPrefix = `/v1/mit`; + +interface MitPreprocessResponse { + id: string; + result?: MitPreprocessResult; + status: 'success' | 'pending' | 'fail'; + message?: string; +} + +export interface MitPreprocessResult { + target_lang: string; + text_quads: TextQuad[]; +} + +type CoordTuple = [number, number]; // x, y in non-normalized pixels +export type BBox = [CoordTuple, CoordTuple, CoordTuple, CoordTuple]; // left-top, right-top, right-bottom, left-bottom + +export interface TextQuad { + pts: BBox; + raw_text: string; + translated: string; +} + +async function uploadImg(file: File) { + const formData = new FormData(); + formData.append('file', file); + + return uploadRequest<{ filename: string }>(formData, { + method: 'POST', + url: `${mitApiPrefix}/images`, + }); +} + +async function createImgTask( + filename: string, + taskName: 'mit_ocr' | 'mit_detect_text', + payload: object, +) { + return request<{ task_id: string }>({ + method: 'POST', + url: `${mitApiPrefix}/image-tasks`, + data: { + task_name: taskName, + filename, + ...payload, + }, + }); +} + +interface TaskState { + task_id: string; + status: 'success' | 'pending' | 'fail'; + result?: Result; + message?: string; +} + +async function waitImgTask(taskId: string) { + while (true) { + const r = await request>({ + method: 'GET', + url: `${mitApiPrefix}/image-tasks/${taskId}`, + }); + if (r.data.status === 'success') { + return r.data.result!; + } else if (r.data.status === 'pending') { + await wait(2e3); + } else { + throw new Error(`task failed: ${r.data.message ?? 'unknown'}`); + } + } +} + +async function createTranslateTask(payload: object) { + return request<{ task_id: string }>({ + method: 'POST', + url: `${mitApiPrefix}/translate-tasks`, + data: { + ...payload, + }, + }); +} + +async function waitTranslateTask(taskId: string) { + while (true) { + const r = await request>({ + method: 'GET', + url: `${mitApiPrefix}/translate-tasks/${taskId}`, + }); + if (r.data.status === 'success') { + return r.data.result!; + } else if (r.data.status === 'pending') { + await wait(1e3); + } else { + throw new Error(`task failed: ${r.data.message ?? 'unknown'}`); + } + } +} + +export const mitPreprocess = { + uploadImg, + createImgTask, + waitImgTask, + createTranslateTask, + waitTranslateTask, +} as const; diff --git a/src/components/Header.tsx b/src/components/Header.tsx index fe812de..093ca84 100644 --- a/src/components/Header.tsx +++ b/src/components/Header.tsx @@ -18,6 +18,7 @@ import { configs } from '../configs'; interface HeaderProps { className?: string; } +const showMitExperimentLink = configs.mitUiEnabled; /** * 头部 */ @@ -71,6 +72,11 @@ export const Header: FC = ({ className }) => { ); + const mitLink = showMitExperimentLink && ( + + {formatMessage({ id: 'mit.title' })} + + ); return (
= ({ className }) => { .register { color: #7f7a7a; font-size: 20px; - line-height: 25x; + line-height: 25px; padding: 7px 12px; border-radius: ${style.borderRadiusBase}; ${clickEffect()}; @@ -134,6 +140,7 @@ export const Header: FC = ({ className }) => {
{currentUser.token ? (
+ {mitLink} = ({ className }) => {
) : ( )} diff --git a/src/components/MitPreprocess/TranslateCompanion.tsx b/src/components/MitPreprocess/TranslateCompanion.tsx new file mode 100644 index 0000000..6cf2e3b --- /dev/null +++ b/src/components/MitPreprocess/TranslateCompanion.tsx @@ -0,0 +1,320 @@ +import { FC } from '../../interfaces'; +import { RefObject, useRef, useState } from 'react'; +import { FilePond } from 'react-filepond'; +import { css } from '@emotion/core'; +import { Button } from '../Button'; +import { createMoeflowProjectZip, LPFile } from './moeflow-packager'; +import { FailureResults } from '../../apis'; +import { measureImgSize } from '@jokester/ts-commonutil/lib/frontend/measure-img'; +import { clamp } from 'lodash-es'; +import { BBox, mitPreprocess, TextQuad } from '../../apis/mit_preprocess'; +import { ResourcePool } from '@jokester/ts-commonutil/lib/concurrency/resource-pool'; + +const MAX_FILE_COUNT = 30; + +function getQuadCenter(q: TextQuad) { + const xs = q.pts.flatMap((pt) => pt.map((p) => p[0])); + const ys = q.pts.flatMap((pt) => pt.map((p) => p[1])); + const minX = Math.min(...xs); + const maxX = Math.max(...xs); + const minY = Math.min(...ys); + const maxY = Math.max(...ys); + return { + x: (minX + maxX) / 2, + y: (minY + maxY) / 2, + }; +} + +function buildLpFile( + img: File, + size: { width: number; height: number }, + textQuads: TextQuad[], +): LPFile { + const labels = textQuads + .sort((a, b) => { + // sort : top=>bottom , right=>left + const ca = getQuadCenter(a); + const cb = getQuadCenter(b); + return Math.sign(ca.y - cb.y) || Math.sign(cb.x - ca.x); + }) + .map((q) => { + const { x, y } = getQuadCenter(q); + return { + x: clamp(x / size.width, 0, 1), + y: clamp(y / size.height, 0, 1), + position_type: 1, + translation: `${q.raw_text}\n${q.translated}`, + }; + }); + console.debug('labels', labels); + return { + file_name: img.name, + labels, + }; +} + +async function translateWithTask( + text: string, + targetLang = 'CHT', +): Promise { + const task = await mitPreprocess.createTranslateTask({ + query: text, + target_lang: targetLang, + translator: 'gpt4', + }); + const result = await mitPreprocess.waitTranslateTask(task.data.task_id); + return result[0] || ''; +} + +async function* startTranslateFile( + image: File, + running: RefObject, +): AsyncGenerator<{ + progress?: string; + failed?: FailureResults; + detectTextResult?: unknown; + ocrResult?: unknown; + translateResult?: unknown; + result?: LPFile; +}> { + let uploaded; + yield { progress: 'uploading' }; + try { + uploaded = await mitPreprocess.uploadImg(image); + } catch (e: unknown) { + yield { + failed: e as FailureResults, + }; + return; + } + yield { progress: 'extracting text lines' }; + const { filename } = uploaded.data; + + let detectTextResult; + try { + const task = await mitPreprocess.createImgTask( + filename, + 'mit_detect_text', + {}, + ); + detectTextResult = await mitPreprocess.waitImgTask<{ + textlines: { + prob: number; + pts: number[][]; + text: string; + // textlines: any[]; // FIXME why did server return this? + }[]; + }>(task.data.task_id); + } catch (e: unknown) { + yield { + failed: e as FailureResults, + }; + return; + } + + yield { progress: 'recognizing text lines' }; + let ocrResult; + try { + const created = await mitPreprocess.createImgTask(filename, 'mit_ocr', { + regions: detectTextResult.textlines, + }); + ocrResult = await mitPreprocess.waitImgTask< + { + pts: BBox; + text: string; + textlines: Array; + }[] + >(created.data.task_id); + console.debug('ocrResult', ocrResult); + } catch (e: unknown) { + yield { + failed: e as FailureResults, + }; + return; + } + + yield { progress: 'translating' }; + let translateResult: string[]; + try { + const limiter = ResourcePool.multiple([1, 2, 3, 4]); + translateResult = await Promise.all( + ocrResult.map((textBlock) => + limiter.use(() => translateWithTask(textBlock.text)), + ), + ); + } catch (e: unknown) { + yield { + failed: e as FailureResults, + }; + return; + } + + const textQuads: TextQuad[] = ocrResult.map((textBlock, i) => ({ + pts: textBlock.pts, + raw_text: textBlock.text, + translated: translateResult[i] ?? '', + })); + + const lpFile = buildLpFile(image, await measureImgSize(image), textQuads); + + yield { + result: lpFile, + }; +} + +async function translateFile(image: File, imageIndex: number): Promise { + try { + for await (const fileProgress of startTranslateFile(image, { + current: true, + })) { + console.debug( + `translating file #${imageIndex} / ${image.name}`, + 'step', + fileProgress, + ); + if (fileProgress.result) { + return fileProgress.result; + } else if (fileProgress.failed) { + throw fileProgress.failed; + } // else: continue + } + } catch (e) { + console.error(`failed translating file #${imageIndex} / ${image.name}`, e); + return { + file_name: image.name, + labels: [], + }; + } + throw new Error(`should not be here`); +} + +async function startOcr( + files: File[], + onProgress?: (finished: number, total: number) => void, +): Promise { + const limiter = ResourcePool.multiple([1, 2]); + + const translations = await Promise.all( + files.map((f, i) => + limiter.use(async () => { + const lpFile = await translateFile(f, i); + onProgress?.(i + 1, files.length); + return lpFile; + }), + ), + ); + const zipBlob = await createMoeflowProjectZip( + { + name: `${files[0]!.name}`, + intro: `这是由<萌翻+Mit demo>生成的项目. https://moeflow-mit-poc.voxscape.io/temp/mit-preprocess`, + default_role: 'supporter', + allow_apply_type: 3, + application_check_type: 1, + is_need_check_application: true, + source_language: 'ja', + output_language: 'zh-TW', + }, + translations.map((lp, i) => ({ lp, image: files[i] })), + ); + return new File( + [zipBlob], + `moeflow-project-${Date.now()}-${files[0]!.name}.zip`, + ); +} + +interface DemoWorkingState { + nonce: string; + numPages: number; + finished: number; +} + +export const DemoOcrFiles: FC<{}> = (props) => { + const [working, setWorking] = useState(null); + const [origFiles, setOrigFiles] = useState(() => []); + const [error, setError] = useState(null); + const [translated, setTranslated] = useState(null); + const filePondRef = useRef(null); + + const onStartOcr = async (files: File[]) => { + try { + const initState = { + nonce: `${Math.random()}`, + numPages: files.length, + finished: 0, + }; + setWorking(initState); + setTranslated( + await startOcr(files, (finished, total) => + setWorking((s) => + s?.nonce === initState.nonce + ? { + ...s, + finished: Math.max(s.finished, finished), + numPages: total, + } + : s, + ), + ), + ); + } catch (e: any) { + alert(e?.message || 'error'); + console.error(e); + } finally { + setWorking(null); + } + }; + return ( +
+ 0} + ref={(value) => (filePondRef.current = value)} + css={css` + display: none; + `} + allowMultiple + acceptedFileTypes={['image/*', '.png', '.jpg']} + onupdatefiles={(_files) => { + const files = _files.map((f) => f.file) as File[]; + console.debug('onaddfile', files); + if (!(files.length > 0 && files.length <= MAX_FILE_COUNT)) { + setError(`一次最多只能上传${MAX_FILE_COUNT}张图片`); + setOrigFiles([]); + filePondRef.current!.removeFiles(); + } else { + setOrigFiles(files); + setError(null); + } + }} + /> + + + +
+ ); +}; diff --git a/src/components/MitPreprocess/moeflow-packager.ts b/src/components/MitPreprocess/moeflow-packager.ts new file mode 100644 index 0000000..5da4389 --- /dev/null +++ b/src/components/MitPreprocess/moeflow-packager.ts @@ -0,0 +1,80 @@ +import * as zip from '@zip.js/zip.js'; + +export interface LPLabel { + x: number; // normalized + y: number; // normalized + position_type: number; // int , always 1 ? + translation: string; // singleline +} + +export interface LPFile { + file_name: string; // img filename (basename) + labels: LPLabel[]; +} + +function serializeIntoLabelplusFormat(files: LPFile[]): string[] { + return files.flatMap((file) => [ + `>>>>[${file.file_name}]<<<<`, + ...file.labels.flatMap((l, labelIndex) => [ + `----[${labelIndex}]----[${l.x},${l.y},${l.position_type}]`, + l.translation, + ]), + ]); +} + +type LANG_CODE = 'ja' | 'en' | 'zh-CN' | 'zh-TW'; + +interface MoeflowProjectMeta { + name: string; + intro: string; + default_role: 'supporter'; + allow_apply_type: 3; + application_check_type: 1; + is_need_check_application: boolean; + // create_time: string; + // edit_time: string; + source_language: 'ja'; + // target_languages: LANG_CODE[]; + // output_id: string; + output_language: LANG_CODE; +} + +export interface MoeflowImageFile { + lp: LPFile; + image: Blob; +} + +/** + * see moeflow-backend "TeamProjectImportAPI" + * @return a zip file for importing into moeflow-backend + */ +export async function createMoeflowProjectZip( + meta: MoeflowProjectMeta, + files: MoeflowImageFile[], +): Promise { + const zipWriter = new zip.ZipWriter(new zip.BlobWriter('application/zip'), { + bufferedWrite: true, + level: 9, + }); + + { + const translationsTxt = + serializeIntoLabelplusFormat(files.map((f) => f.lp)).join('\n') + '\n'; + const blob = new Blob([translationsTxt], { type: 'text/plain' }); + await zipWriter.add('translations.txt', new zip.BlobReader(blob)); + } + + for (const f of files) { + await zipWriter.add( + `images/${f.lp.file_name}`, + new zip.BlobReader(f.image), + ); + } + + await zipWriter.add( + 'project.json', + new zip.TextReader(JSON.stringify(meta, null, 2)), + ); + + return zipWriter.close(); +} diff --git a/src/configs.tsx b/src/configs.tsx index 3f3c14d..da86138 100644 --- a/src/configs.tsx +++ b/src/configs.tsx @@ -3,6 +3,11 @@ const configs = { // 所有API请求的baseURL。 // 如本地开发时backend URL不匹配,可在vite server中配置反向代理 baseURL: process.env.REACT_APP_BASE_URL, + /** + * TODO think how to deal with manga-image-translator backend + */ + mitBackendURL: process.env.MIT_BACKEND_URL, + mitUiEnabled: !!process.env.MIT_BACKEND_URL, /** 默认值 */ default: { team: { diff --git a/src/locales/en-us.json b/src/locales/en-us.json index eb68960..234984a 100644 --- a/src/locales/en-us.json +++ b/src/locales/en-us.json @@ -6,5 +6,12 @@ "imageTranslator.imageViewerZoomPanel.fixWidth": "Fix Width", "imageTranslator.imageViewerZoomPanel.originSize": "Origin Size", "imageTranslator.imageViewerZoomPanel.zoomIn": "Zoom In", - "imageTranslator.imageViewerZoomPanel.zoomOut": "Zoom Out" + "imageTranslator.imageViewerZoomPanel.zoomOut": "Zoom Out", + "mit.title": "mit assistant", + "mit.desc": "Auto translate based on manga-image-translator. Translated file can be imported as Moeflow project.", + "mit.step1PickImageFiles": "Please pick up to 30 image files", + "mit.startPreprocess": "Start", + "mit.preprocessStepLines": "Locating characters", + "mit.preprocessStepOcr": "Recognizing text", + "mit.preprocessStepTranslate": "Translating" } diff --git a/src/locales/zh-cn.json b/src/locales/zh-cn.json index bce7e93..ad3eec0 100644 --- a/src/locales/zh-cn.json +++ b/src/locales/zh-cn.json @@ -364,5 +364,12 @@ "translation.copyToMyTranslation": "复制到我的翻译", "translation.copyToMyTranslationNotEmptyTip": "您的翻译已有内容,您确定要覆盖吗?", "translation.best": "最佳翻译", - "imageTranslator.proofreaderShowSource": "显示原文" + "imageTranslator.proofreaderShowSource": "显示原文", + "mit.title": "mit翻译辅助", + "mit.desc": "基于manga-image-translator的自动翻译。请选择图上文字并机翻,", + "mit.step1PickImageFiles": "请选择图片文件 (最多30个)", + "mit.startPreprocess": "开始自动翻译", + "mit.preprocessStepLines": "定位文字", + "mit.preprocessStepOcr": "识别文字", + "mit.preprocessStepTranslate": "机翻" } diff --git a/src/pages/mit/MitPreprocessDemo.tsx b/src/pages/mit/MitPreprocessDemo.tsx new file mode 100644 index 0000000..839efeb --- /dev/null +++ b/src/pages/mit/MitPreprocessDemo.tsx @@ -0,0 +1,24 @@ +import { css } from '@emotion/core'; +import React from 'react'; +import { useIntl } from 'react-intl'; +import { useHistory } from 'react-router-dom'; +import { useTitle } from '../../hooks'; +import { FC } from '../../interfaces'; +import { DemoOcrFiles } from '../../components/MitPreprocess/TranslateCompanion'; + +/** 模板的属性接口 */ +interface TmpProps {} +/** + * 模板 + */ +export const MitPreprocessDemo: FC = () => { + const history = useHistory(); + const { formatMessage } = useIntl(); + useTitle(); + + return ( +
+ +
+ ); +}; diff --git a/vite.config.mts b/vite.config.mts index 3ff4060..396d3f8 100644 --- a/vite.config.mts +++ b/vite.config.mts @@ -37,6 +37,10 @@ export default defineConfig({ 'process.env.REACT_APP_BASE_URL': JSON.stringify( process.env.REACT_APP_BASE_URL ?? '/api/', ), + // works as feature flag + 'process.env.MIT_BACKEND_URL': JSON.stringify( + process.env.MIT_BACKEND_URL ?? null, + ), }, resolve: { alias: {},