├── .browserslistrc ├── .editorconfig ├── .eslintrc.js ├── .github └── workflows │ ├── ci.yml │ └── publish-build.yml ├── .gitignore ├── .img ├── config.jpg └── edit.jpg ├── .stylelintrc.js ├── LICENSE ├── README.md ├── README_zh-hans.md ├── babel.config.js ├── build ├── icon.png └── icon.svg ├── changelog.md ├── electron-builder.yml ├── jest.config.js ├── package.json ├── public └── index.html ├── src ├── App.vue ├── CommonIpc.ts ├── CommonStorage.ts ├── SubtitleInfo.ts ├── Utils.ts ├── VideoProperties.ts ├── assets │ ├── advanced-settings-expand.svg │ ├── logo.png │ ├── next_frame.svg │ ├── next_line.svg │ ├── open-video-icon.svg │ ├── pause.svg │ ├── play.svg │ ├── prev_frame.svg │ ├── prev_line.svg │ ├── subtitle-add.svg │ ├── subtitle-delete.svg │ ├── subtitle-locate.svg │ ├── subtitle-merge-end.svg │ ├── subtitle-merge-start.svg │ ├── titlebar-close.svg │ ├── titlebar-maximize.svg │ ├── titlebar-minimize.svg │ └── titlebar-unmaximize.svg ├── backends │ ├── ASSGenerator.ts │ ├── BMPVideoPlayer.ts │ ├── RawVideoPlayer.ts │ ├── TorchOCR.ts │ ├── TorchOCRWorker.ts │ ├── TorchOCRWorkerManager.ts │ └── VideoPlayer.ts ├── background.ts ├── components │ ├── EditableDiv.vue │ ├── SubtitleInfoTable.vue │ ├── Timeline.vue │ ├── TimelineBar.vue │ ├── TitleBar.vue │ ├── VideoBar.vue │ └── VideoPlayer.vue ├── config.ts ├── configIpc.ts ├── global.d.ts ├── interfaces.ts ├── logger.ts ├── main.ts ├── preload.js ├── router │ └── index.ts ├── shims-tsx.d.ts ├── shims-vue.d.ts ├── styles │ └── simplebar.css └── views │ ├── MainWindow.vue │ └── Start.vue ├── tests ├── ASSGenerator.unit.spec.ts ├── TorchOCR.integration.spec.ts ├── TorchOCR.time.ts ├── TorchOCR.unit.spec.ts ├── TorchOCRTaskScheduler.unit.spec.ts ├── __snapshots__ │ ├── ASSGenerator.unit.spec.ts.snap │ ├── TorchOCR.integration.spec.ts.snap │ ├── TorchOCR.unit.spec.ts.snap │ └── TorchOCRTaskScheduler.unit.spec.ts.snap └── files │ ├── sample.mp4 │ └── subtitleInfos.json ├── tsconfig.json ├── vue.config.js └── yarn.lock /.browserslistrc: -------------------------------------------------------------------------------- 1 | > 1% 2 | last 2 versions 3 | not dead 4 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | [*.{js,jsx,ts,tsx,vue}] 2 | indent_style = space 3 | indent_size = 4 4 | trim_trailing_whitespace = true 5 | insert_final_newline = true 6 | -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | root: true, 3 | env: { 4 | node: true 5 | }, 6 | extends: [ 7 | 'plugin:vue/essential', 8 | '@vue/standard', 9 | '@vue/typescript/recommended' 10 | ], 11 | parserOptions: { 12 | ecmaVersion: 2020 13 | }, 14 | rules: { 15 | 'no-console': process.env.NODE_ENV === 'production' ? 'error' : 'off', 16 | 'no-debugger': process.env.NODE_ENV === 'production' ? 'error' : 'off', 17 | 'quotes': 'off', 18 | '@typescript-eslint/quotes': ['error', 'single'], 19 | 'semi': 'off', 20 | '@typescript-eslint/semi': ['error', 'never'], 21 | 'indent': 'off', 22 | '@typescript-eslint/indent': ['error', 4], 23 | 'vue/html-indent': ['error', 4], 24 | 'vue/script-indent': ['error', 4], 25 | 'space-before-function-paren': 'off', 26 | '@typescript-eslint/space-before-function-paren': ['error', { 27 | anonymous: 'always', 28 | named: 'never', 29 | asyncArrow: 'always' 30 | }], 31 | 'quote-props': ['error', 'consistent'], 32 | 'space-infix-ops': ['error'], 33 | 'no-useless-constructor': 'off', 34 | '@typescript-eslint/no-useless-constructor': ['error'], 35 | '@typescript-eslint/array-type': ['error', { 36 | default: 'generic' 37 | }], 38 | '@typescript-eslint/class-literal-property-style': 'error', 39 | '@typescript-eslint/member-delimiter-style': ['error', { 40 | 'multiline': { 41 | 'delimiter': 'none', 42 | 'requireLast': false 43 | }, 44 | 'singleline': { 45 | 'delimiter': 'semi', 46 | 'requireLast': false 47 | } 48 | }], 49 | '@typescript-eslint/method-signature-style': ['error', 'property'], 50 | 'lines-between-class-members': 'off', 51 | '@typescript-eslint/lines-between-class-members': ['error', { 52 | 'exceptAfterSingleLine': true 53 | }], 54 | '@typescript-eslint/type-annotation-spacing': ['error'], 55 | 'no-void': 'off' 56 | }, 57 | overrides: [ 58 | { 59 | files: [ 60 | '**/__tests__/*.{j,t}s?(x)', 61 | '**/tests/**/*.spec.{j,t}s?(x)' 62 | ], 63 | env: { 64 | jest: true 65 | } 66 | }, 67 | { 68 | files: [ 69 | 'src/**/*.ts', 70 | 'src/**/*.tsx', 71 | 'src/**/*.vue', 72 | 'tests/**/*.ts', 73 | 'tests/**/*.tsx' 74 | ], 75 | extends: [ 76 | 'plugin:@typescript-eslint/recommended-requiring-type-checking' 77 | ], 78 | parser: require.resolve('vue-eslint-parser'), 79 | parserOptions: { 80 | tsconfigRootDir: __dirname, 81 | project: ['./tsconfig.json'] 82 | }, 83 | rules: { 84 | 'camelcase': 'off', 85 | '@typescript-eslint/naming-convention': [ 86 | 'error', 87 | { 88 | 'selector': 'default', 89 | 'format': ['camelCase'] 90 | }, 91 | { 92 | 'selector': 'variable', 93 | 'format': ['camelCase', 'UPPER_CASE'] 94 | }, 95 | { 96 | 'selector': 'parameter', 97 | 'format': ['camelCase'], 98 | 'leadingUnderscore': 'allow' 99 | }, 100 | { 101 | 'selector': 'memberLike', 102 | 'format': ['camelCase'], 103 | 'trailingUnderscore': 'allow' 104 | }, 105 | { 106 | 'selector': 'memberLike', 107 | 'modifiers': ['private'], 108 | 'format': ['camelCase'], 109 | 'leadingUnderscore': 'require' 110 | }, 111 | { 112 | 'selector': 'typeLike', 113 | 'format': ['PascalCase'] 114 | } 115 | ], 116 | '@typescript-eslint/no-unnecessary-condition': 'warn', 117 | '@typescript-eslint/no-unnecessary-boolean-literal-compare': 'warn' 118 | } 119 | } 120 | ] 121 | } 122 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: 5 | branches: [master] 6 | pull_request: 7 | 8 | # A workflow run is made up of one or more jobs that can run sequentially or in parallel 9 | jobs: 10 | # This workflow contains a single job called "build" 11 | build: 12 | # The type of runner that the job will run on 13 | runs-on: ${{ matrix.os }} 14 | strategy: 15 | matrix: 16 | os: [macos-latest, ubuntu-latest, windows-latest] 17 | if: "!contains(github.event.head_commit.message, 'ci skip')" 18 | 19 | # Steps represent a sequence of tasks that will be executed as part of the job 20 | steps: 21 | - name: Checkout 22 | uses: actions/checkout@v2 23 | 24 | - name: Install node 25 | uses: actions/setup-node@v1 26 | with: 27 | node-version: "14" 28 | 29 | - name: MacOS install ffmpeg 30 | if: ${{ matrix.os == 'macos-latest' }} 31 | shell: bash 32 | run: brew install ffmpeg 33 | 34 | - name: Linux install ffmpeg 35 | if: ${{ matrix.os == 'ubuntu-latest' }} 36 | shell: bash 37 | run: | 38 | sudo add-apt-repository ppa:jonathonf/ffmpeg-4 39 | sudo apt-get install libavcodec-dev libavformat-dev libavdevice-dev libavfilter-dev libavutil-dev libpostproc-dev libswresample-dev libswscale-dev 40 | 41 | - name: Install packages 42 | run: yarn 43 | 44 | - name: Windows build 45 | if: ${{ matrix.os == 'windows-latest' }} 46 | shell: powershell 47 | run: | 48 | $env:GH_TOKEN="${{ secrets.GITHUB_TOKEN }}" 49 | yarn electron:build --windows 7z 50 | 51 | - name: MacOS build 52 | if: ${{ matrix.os == 'macos-latest' }} 53 | shell: bash 54 | run: | 55 | export CSC_IDENTITY_AUTO_DISCOVERY=false 56 | export GH_TOKEN=${{ secrets.GITHUB_TOKEN }} 57 | yarn electron:build --macos 7z 58 | 59 | - name: Linux build 60 | if: ${{ matrix.os == 'ubuntu-latest' }} 61 | shell: bash 62 | run: | 63 | export GH_TOKEN=${{ secrets.GITHUB_TOKEN }} 64 | yarn electron:build --linux 7z 65 | 66 | - name: Upload artifact 67 | uses: actions/upload-artifact@v2 68 | with: 69 | name: artifact-${{ matrix.os }} 70 | path: dist_electron/*.7z 71 | -------------------------------------------------------------------------------- /.github/workflows/publish-build.yml: -------------------------------------------------------------------------------- 1 | name: publish-build 2 | 3 | on: 4 | push: 5 | tags: 6 | - "v*.*.*" 7 | 8 | # A workflow run is made up of one or more jobs that can run sequentially or in parallel 9 | jobs: 10 | # This workflow contains a single job called "build" 11 | build: 12 | # The type of runner that the job will run on 13 | runs-on: ${{ matrix.os }} 14 | strategy: 15 | fail-fast: false 16 | matrix: 17 | os: [macos-latest, ubuntu-latest, windows-latest] 18 | if: "!contains(github.event.head_commit.message, 'publish-build skip')" 19 | 20 | # Steps represent a sequence of tasks that will be executed as part of the job 21 | steps: 22 | - name: Checkout 23 | uses: actions/checkout@v2 24 | 25 | - name: Install node 26 | uses: actions/setup-node@v1 27 | with: 28 | node-version: '14' 29 | 30 | - name: MacOS install ffmpeg 31 | if: ${{ matrix.os == 'macos-latest' }} 32 | shell: bash 33 | run: brew install ffmpeg 34 | 35 | - name: Linux install ffmpeg 36 | if: ${{ matrix.os == 'ubuntu-latest' }} 37 | shell: bash 38 | run: | 39 | sudo add-apt-repository ppa:jonathonf/ffmpeg-4 40 | sudo apt-get install libavcodec-dev libavformat-dev libavdevice-dev libavfilter-dev libavutil-dev libpostproc-dev libswresample-dev libswscale-dev 41 | 42 | - name: Install packages 43 | run: yarn 44 | 45 | - name: Windows build 46 | if: ${{ matrix.os == 'windows-latest' }} 47 | shell: powershell 48 | run: | 49 | $env:GH_TOKEN="${{ secrets.GITHUB_TOKEN }}" 50 | yarn electron:build --windows 7z --publish always 51 | 52 | - name: MacOS build 53 | if: ${{ matrix.os == 'macos-latest' }} 54 | shell: bash 55 | run: | 56 | export CSC_IDENTITY_AUTO_DISCOVERY=false 57 | export GH_TOKEN=${{ secrets.GITHUB_TOKEN }} 58 | yarn electron:build --macos 7z --publish always 59 | 60 | - name: Linux build 61 | if: ${{ matrix.os == 'ubuntu-latest' }} 62 | shell: bash 63 | run: | 64 | export GH_TOKEN=${{ secrets.GITHUB_TOKEN }} 65 | yarn electron:build --linux 7z --publish always 66 | 67 | - name: Upload artifact 68 | uses: actions/upload-artifact@v2 69 | with: 70 | name: artifact-${{ matrix.os }} 71 | path: dist_electron/*.7z 72 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | node_modules 3 | /dist 4 | 5 | *.log 6 | /utils 7 | /dist_timetest 8 | /models 9 | 10 | # local env files 11 | .env.local 12 | .env.*.local 13 | 14 | # Log files 15 | npm-debug.log* 16 | yarn-debug.log* 17 | yarn-error.log* 18 | pnpm-debug.log* 19 | 20 | # Editor directories and files 21 | .idea 22 | .vscode 23 | *.suo 24 | *.ntvs* 25 | *.njsproj 26 | *.sln 27 | *.sw? 28 | 29 | #Electron-builder output 30 | /dist_electron -------------------------------------------------------------------------------- /.img/config.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/freyjaSubOCR/freyja-sub-ocr-electron/5cdbde4a8c414cc9ce88db1fef4dd951f6f823f1/.img/config.jpg -------------------------------------------------------------------------------- /.img/edit.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/freyjaSubOCR/freyja-sub-ocr-electron/5cdbde4a8c414cc9ce88db1fef4dd951f6f823f1/.img/edit.jpg -------------------------------------------------------------------------------- /.stylelintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | 'extends': 'stylelint-config-standard', 3 | 'rules': { 4 | 'indentation': 4 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Freyja 2 | 3 | [![GitHub release](https://img.shields.io/github/release/freyjaSubOCR/freyja-sub-ocr-electron)](https://GitHub.com/freyjaSubOCR/freyja-sub-ocr-electron/releases/) 4 | 5 | Nodejs + electron user interface for freyja subtitle OCR extractor. 6 | 7 | Still in beta. All functionality are useable, but you may meet bug / crash when using the app. Please report any bugs 8 | you meet with your ```log.log``` file on github issues. 9 | 10 | ![Config page screenshot](.img/config.jpg) 11 | 12 | ![Edit page screenshot](.img/edit.jpg) 13 | 14 | ## System requirements 15 | 16 | 8GB of RAM required. Having a recent Nvidia GPU is strongly recommended or the process will be extremely slow. 17 | 18 | ## Usage 19 | 20 | 1. If you are using Windows, please install [Visual C++ Redist 2019](https://aka.ms/vs/16/release/vc_redist.x64.exe). If 21 | you are using MacOS or Linux, make sure you have ```ffmpeg``` installed. 22 | 23 | 2. Download latest version of Freyja from [Releases](https://github.com/freyjaSubOCR/freyja-sub-ocr-electron/releases) 24 | page and extract it. 25 | 26 | 3. Download models from . Download all txt and 27 | torchscript files, and place these files into ```/models/``` folder. 28 | 29 | 4. Run ```freyja.exe```. Enable ```Enable CUDA``` option if you have a Nvidia GPU, otherwise disable the option. 30 | 31 | ## Known issues 32 | 33 | - Video player is laggy. 34 | 35 | Current video player implementation does not work well on real time video playback, a new implementation will be 36 | available when the app is out of beta. 37 | 38 | - MacOS and Linux versions do not work. 39 | 40 | Currently there are some issues related with the underlying ```torch-js``` package. It should be fixed in the next 41 | beta version. 42 | 43 | ## Common issues 44 | 45 | - No audio on video playback. 46 | 47 | Current video player cannot play audio. 48 | 49 | - Cannot play the video. 50 | 51 | Maybe the video is an vfr (variable frame rate) video, which is not supported on current video player 52 | implementation. You can do a fast transcoding using ffmpeg to convert the video to a constant frame rate video: 53 | ```ffmpeg -i video.mkv video_transcoded.mkv```. Remux won't work. 54 | 55 | - Cannot use GPU models. 56 | 57 | Make sure you have a recent Nvidia GPU. If you do have a Nvidia GPU, please try to update the driver. 58 | 59 | - The program says that "pyTorch backend crashed". 60 | 61 | Please check the ```log.log```. 62 | 63 | If the log says that ```CUDA out of memory```, you need to reduce the batch size. If it still not works, it means that 64 | your GPU memory is too small and you can only use the CPU models. 65 | 66 | If the log shows other errors, please try to change the crop height of the video. 67 | -------------------------------------------------------------------------------- /README_zh-hans.md: -------------------------------------------------------------------------------- 1 | # Freyja 2 | 3 | 使用 Nodejs 和 Electron 编写的 Freyja 视频硬字幕提取 App 的用户界面。 4 | 5 | 目前仍然在 beta 测试中。基本功能都是可用的,但是可能会遇到 bug 或者随机崩溃等问题。如果你碰到了问题,请带上```log.log``` 6 | 文件,然后在 Github issues 中报告。 7 | 8 | ![Config page screenshot](.img/config.jpg) 9 | 10 | ![Edit page screenshot](.img/edit.jpg) 11 | 12 | ## 系统要求 13 | 14 | Freyja 需要 8GB 的内存。强烈推荐使用带 Nvidia 显卡的电脑,否则过程会非常缓慢。 15 | 16 | ## 使用 17 | 18 | 1. 如果你使用的是 Windows,安装[Visual C++ Redist 2019](https://aka.ms/vs/16/release/vc_redist.x64.exe)。如果你使用的是 19 | MacOS 或 Linux ,确保已安装```ffmpeg```。 20 | 21 | 2. 从[Release页](https://github.com/freyjaSubOCR/freyja-sub-ocr-electron/releases)下载最新版本的Freyja并将其解压缩。 22 | 23 | 3. 从中下载模型。下载对应模型的所有txt和torchscript文 24 | 件,并将这些文件放入```/models/```文件夹。 25 | 26 | 4. 运行```freyja.exe```。如果有 Nvidia 显卡,请启用```Enable CUDA```选项,否则禁用该选项。 27 | 28 | ## 已知的问题 29 | 30 | - 视频播放很慢 31 | 32 | 目前的视频播放实现不是很可靠,会占用比较多的内存并且会有播放卡顿。在正式版推出之前会有新的视频播放实现。 33 | 34 | - 无法使用 MacOS 和 Linux 版本 35 | 36 | 当前,底层的```torch-js```包存在一些问题。下一个测试版本预计会修复这个问题。 37 | 38 | ## 常见问题 39 | 40 | - 视频播放没有声音。 41 | 42 | 目前视频播放器没有播放声音功能。 43 | 44 | - 无法播放视频。 45 | 46 | 这个视频可能是vfr(可变帧率)视频,当前视频播放器不支持播放这类视频。一个比较简单的解决方法是使用ffmpeg进行转码,来把视频转换为恒定帧率视频:```ffmpeg -i video.mkv video_transcoded.mkv```。 47 | 48 | - 无法使用GPU模型。 49 | 50 | 确认你有 Nvidia 的显卡。如果确实有 Nvidia 的显卡,请尝试更新驱动程序。 51 | 52 | - 程序提示 "pyTorch backend crashed"。 53 | 54 | 检查一下 ```log.log``` 中的最后一条错误信息。 55 | 56 | 如果log中有 ```CUDA out of memory``` 的提示,说明显存不足。可以通过降低 ```batch size``` 来减少显存使用。如果降低 57 | ```batch size``` 不起作用,说明你的显存太小,请换用CPU模型。 58 | 59 | 如果log显示其他错误,请尝试改变视频裁剪的大小然后重试。 60 | -------------------------------------------------------------------------------- /babel.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | presets: [ 3 | '@vue/cli-plugin-babel/preset' 4 | ] 5 | } 6 | -------------------------------------------------------------------------------- /build/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/freyjaSubOCR/freyja-sub-ocr-electron/5cdbde4a8c414cc9ce88db1fef4dd951f6f823f1/build/icon.png -------------------------------------------------------------------------------- /build/icon.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | -------------------------------------------------------------------------------- /changelog.md: -------------------------------------------------------------------------------- 1 | # Change Log 2 | 3 | ## V0.4.0 4 | 5 | This version is updated to use OCRV3 models. OCRV3 models are faster than the old OCR models with little accuracy loss. 6 | The new models use less GPU memory, so the default batch size has been updated from 8 to 32. On a Surface book 2 laptop 7 | (i7-8650U, GTX 1060 Max-Q), it only takes 10 minutes to process a 24 minutes video. 8 | 9 | This version does not need an object detection model, and it unifies CPU and GPU models. 10 | 11 | To archive a higher accuracy, this version requires you to select a tighter subtitle boundary. 12 | 13 | 这个版本使用了更新后的OCRV3模型。新的OCRV3模型相比旧的OCR模型来说运行速度更快,也更加准确。新模型使用的GPU内存较少,所以 14 | 默认的批次大小从8个变更为32个。在Surface book 2笔记本上(i7-8650U,GTX 1060 Max-Q),处理一段24分钟的视频只需要10分钟。 15 | 16 | 这个版本不需要以前的对象检测模型,也统一了CPU和GPU模型。 17 | 18 | 新的模型需要你框选更准确的字幕边界,否则准确度会很差。 19 | -------------------------------------------------------------------------------- /electron-builder.yml: -------------------------------------------------------------------------------- 1 | asarUnpack: 2 | - 0.worker.js 3 | - node_modules/bindings 4 | - node_modules/file-uri-to-path 5 | - node_modules/js-levenshtein 6 | - node_modules/segfault-handler 7 | appId: com.freyja.freyja-electron 8 | mac: 9 | category: public.app-category.productivity 10 | linux: 11 | category: AudioVideo -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | preset: '@vue/cli-plugin-unit-jest/presets/typescript-and-babel', 3 | runner: '@jest-runner/electron', 4 | testEnvironment: '@jest-runner/electron/environment', 5 | testMatch: [ 6 | '**/tests/**/*.spec.ts?(x)' 7 | ] 8 | } 9 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "freyja", 3 | "version": "0.4.0", 4 | "author": "arition, ReventonC", 5 | "description": "freyja subtitle OCR extractor", 6 | "license": "GPL-3.0-or-later", 7 | "repository": { 8 | "type": "git", 9 | "url": "git+https://github.com/freyjaSubOCR/freyja-sub-ocr-electron.git" 10 | }, 11 | "private": true, 12 | "scripts": { 13 | "serve": "vue-cli-service serve", 14 | "build": "vue-cli-service build", 15 | "test:unit": "vue-cli-service test:unit unit", 16 | "test:integration": "vue-cli-service test:unit integration", 17 | "test:time": "tsc --outDir dist_timetest --target es6 --module commonjs && tspath -f && cross-env ELECTRON_RUN_AS_NODE=true ./node_modules/.bin/electron dist_timetest/tests/TorchOCR.time.js", 18 | "lint": "vue-cli-service lint", 19 | "electron:build": "vue-cli-service electron:build", 20 | "electron:serve": "vue-cli-service electron:serve", 21 | "postinstall": "electron-builder install-app-deps", 22 | "postuninstall": "electron-builder install-app-deps" 23 | }, 24 | "main": "background.js", 25 | "dependencies": { 26 | "beamcoder": "https://github.com/arition/beamcoder#refs/heads/master", 27 | "core-js": "^3.8.1", 28 | "d3": "^6.3.1", 29 | "js-levenshtein": "^1.1.6", 30 | "lodash": "^4.17.20", 31 | "normalize.css": "^8.0.1", 32 | "segfault-handler": "^1.3.0", 33 | "simplebar-vue": "^1.6.0", 34 | "threads": "^1.6.3", 35 | "torch-js": "npm:@arition/torch-js@^0.11.0", 36 | "uuid": "^8.3.2", 37 | "vue": "^2.6.11", 38 | "vue-class-component": "^7.2.6", 39 | "vue-property-decorator": "^9.1.2", 40 | "vue-router": "^3.4.9", 41 | "winston": "^3.3.3" 42 | }, 43 | "devDependencies": { 44 | "@jest-runner/electron": "^3.0.0", 45 | "@types/d3": "^6.2.0", 46 | "@types/electron-devtools-installer": "^2.2.0", 47 | "@types/jest": "^26.0.19", 48 | "@types/js-levenshtein": "^1.1.0", 49 | "@types/lodash": "^4.14.167", 50 | "@types/uuid": "^8.3.0", 51 | "@typescript-eslint/eslint-plugin": "^4.11.1", 52 | "@typescript-eslint/parser": "^4.11.1", 53 | "@vue/cli-plugin-babel": "~4.5.9", 54 | "@vue/cli-plugin-eslint": "~4.5.9", 55 | "@vue/cli-plugin-router": "~4.5.9", 56 | "@vue/cli-plugin-typescript": "~4.5.9", 57 | "@vue/cli-plugin-unit-jest": "~4.5.9", 58 | "@vue/cli-service": "~4.5.9", 59 | "@vue/eslint-config-standard": "^6.0.0", 60 | "@vue/eslint-config-typescript": "^7.0.0", 61 | "@vue/test-utils": "^1.1.2", 62 | "cross-env": "^7.0.3", 63 | "electron": "11.1.1", 64 | "electron-devtools-installer": "^3.1.0", 65 | "eslint": "^7.17.0", 66 | "eslint-plugin-import": "^2.22.1", 67 | "eslint-plugin-node": "^11.1.0", 68 | "eslint-plugin-promise": "^4.2.1", 69 | "eslint-plugin-standard": "^5.0.0", 70 | "eslint-plugin-vue": "^7.4.0", 71 | "node-sass": "^5.0.0", 72 | "sass-loader": "^10.1.0", 73 | "spectron": "^13.0.0", 74 | "stylelint": "^13.8.0", 75 | "stylelint-config-standard": "^20.0.0", 76 | "stylelint-webpack-plugin": "^2.1.1", 77 | "threads-plugin": "^1.4.0", 78 | "ts-loader": "^8.0.4", 79 | "tspath": "^1.3.7", 80 | "typescript": "^4.1.2", 81 | "vue-cli-plugin-electron-builder": "^2.0.0-rc.5", 82 | "vue-template-compiler": "^2.6.11", 83 | "webpack": "^4" 84 | }, 85 | "resolutions": { 86 | "@vue/cli-plugin-typescript/ts-loader": "^8.0.4", 87 | "@vue/cli-plugin-eslint/eslint-loader": "^4.0.2" 88 | }, 89 | "cmake-js": { 90 | "runtime": "electron", 91 | "runtimeVersion": "11.1.1" 92 | } 93 | } 94 | -------------------------------------------------------------------------------- /public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | <%= htmlWebpackPlugin.options.title %> 9 | 10 | 11 | 14 |
15 | 16 | 17 | 18 | -------------------------------------------------------------------------------- /src/App.vue: -------------------------------------------------------------------------------- 1 | 7 | 8 | 21 | 22 | 156 | -------------------------------------------------------------------------------- /src/CommonIpc.ts: -------------------------------------------------------------------------------- 1 | import { dialog, BrowserWindow, ipcMain } from 'electron' 2 | import logger from '@/logger' 3 | import { ScriptModule } from 'torch-js' 4 | 5 | class CommonIpc { 6 | registerIPCListener(): void { 7 | ipcMain.handle('CommonIpc:OpenMovieDialog', async () => { 8 | try { 9 | return await this.openMovieDialog() 10 | } catch (error) { 11 | logger.error((error as Error).message) 12 | return null 13 | } 14 | }) 15 | ipcMain.handle('CommonIpc:SaveASSDialog', async () => { 16 | try { 17 | return await this.saveASSDialog() 18 | } catch (error) { 19 | logger.error((error as Error).message) 20 | return null 21 | } 22 | }) 23 | ipcMain.handle('CommonIpc:Minimize', () => { 24 | try { 25 | return this.minimize() 26 | } catch (error) { 27 | logger.error((error as Error).message) 28 | return null 29 | } 30 | }) 31 | ipcMain.handle('CommonIpc:Unmaximize', () => { 32 | try { 33 | return this.unmaximize() 34 | } catch (error) { 35 | logger.error((error as Error).message) 36 | return null 37 | } 38 | }) 39 | ipcMain.handle('CommonIpc:Maximize', () => { 40 | try { 41 | return this.maximize() 42 | } catch (error) { 43 | logger.error((error as Error).message) 44 | return null 45 | } 46 | }) 47 | ipcMain.handle('CommonIpc:IsMaximized', () => { 48 | try { 49 | return this.isMaximized() 50 | } catch (error) { 51 | logger.error((error as Error).message) 52 | return null 53 | } 54 | }) 55 | ipcMain.handle('CommonIpc:Close', () => { 56 | try { 57 | return this.close() 58 | } catch (error) { 59 | logger.error((error as Error).message) 60 | return null 61 | } 62 | }) 63 | ipcMain.handle('CommonIpc:ErrorBox', async (e, ...args) => { 64 | try { 65 | if (args.length === 1) { 66 | const window = BrowserWindow.getFocusedWindow() 67 | if (window !== null) { 68 | await dialog.showMessageBox(window, { type: 'info', title: 'Freyja', message: args[0] as string }) 69 | } else { 70 | await dialog.showMessageBox({ type: 'info', title: 'Freyja', message: args[0] as string }) 71 | } 72 | } 73 | } catch (error) { 74 | logger.error((error as Error).message) 75 | return null 76 | } 77 | }) 78 | ipcMain.handle('CommonIpc:CudaAvailable', () => { 79 | try { 80 | return ScriptModule.isCudaAvailable() 81 | } catch (error) { 82 | logger.error((error as Error).message) 83 | return null 84 | } 85 | }) 86 | } 87 | 88 | minimize(): void { 89 | const browserWindow = BrowserWindow.getFocusedWindow() 90 | if (browserWindow !== null) { 91 | browserWindow.minimize() 92 | } 93 | } 94 | 95 | unmaximize(): void { 96 | const browserWindow = BrowserWindow.getFocusedWindow() 97 | if (browserWindow !== null) { 98 | browserWindow.unmaximize() 99 | } 100 | } 101 | 102 | maximize(): void { 103 | const browserWindow = BrowserWindow.getFocusedWindow() 104 | if (browserWindow !== null) { 105 | browserWindow.maximize() 106 | } 107 | } 108 | 109 | isMaximized(): boolean { 110 | const browserWindow = BrowserWindow.getFocusedWindow() 111 | if (browserWindow !== null) { 112 | return browserWindow.isMaximized() 113 | } 114 | return false 115 | } 116 | 117 | close(): void { 118 | const browserWindow = BrowserWindow.getFocusedWindow() 119 | if (browserWindow !== null) { 120 | browserWindow.close() 121 | } 122 | } 123 | 124 | async openMovieDialog(): Promise { 125 | const dialogResult = await dialog.showOpenDialog(BrowserWindow.getFocusedWindow() as BrowserWindow, { 126 | filters: [ 127 | { name: 'Movies', extensions: ['mkv', 'mp4', 'avi', 'flv', 'm2ts'] }, 128 | { name: 'All Files', extensions: ['*'] } 129 | ], 130 | properties: ['openFile'] 131 | }) 132 | if (dialogResult.canceled) return null 133 | return dialogResult.filePaths[0] 134 | } 135 | 136 | async saveASSDialog(): Promise { 137 | const dialogResult = await dialog.showSaveDialog(BrowserWindow.getFocusedWindow() as BrowserWindow, { 138 | filters: [ 139 | { name: 'Subtitles', extensions: ['ass'] }, 140 | { name: 'All Files', extensions: ['*'] } 141 | ] 142 | }) 143 | if (dialogResult.canceled) return null 144 | return dialogResult.filePath ?? null 145 | } 146 | } 147 | 148 | export default CommonIpc 149 | -------------------------------------------------------------------------------- /src/CommonStorage.ts: -------------------------------------------------------------------------------- 1 | import { ipcMain } from 'electron' 2 | import logger from '@/logger' 3 | import { SubtitleInfo } from '@/SubtitleInfo' 4 | 5 | class CommonStorage { 6 | subtitleInfos: Array = [] 7 | 8 | registerIPCListener(): void { 9 | ipcMain.handle('CommonStorage:subtitleInfos', (e, ...args) => { 10 | try { 11 | if (args.length === 1) { 12 | this.subtitleInfos = args[0] as Array 13 | } 14 | return this.subtitleInfos 15 | } catch (error) { 16 | logger.error((error as Error).message) 17 | return null 18 | } 19 | }) 20 | } 21 | } 22 | 23 | export default CommonStorage 24 | -------------------------------------------------------------------------------- /src/SubtitleInfo.ts: -------------------------------------------------------------------------------- 1 | import { isNumber, toInteger } from 'lodash' 2 | import { v4 as uuidv4 } from 'uuid' 3 | 4 | interface ISubtitleInfo { 5 | startFrame: number 6 | endFrame: number 7 | texts?: Array 8 | startTime?: string 9 | endTime?: string 10 | box?: Int32Array 11 | } 12 | 13 | class SubtitleInfo implements ISubtitleInfo { 14 | startFrame: number 15 | endFrame: number 16 | texts: Array = [] 17 | startTime?: string 18 | endTime?: string 19 | box?: Int32Array 20 | id: string 21 | fps?: number 22 | 23 | constructor(subtitleInfo: ISubtitleInfo) 24 | 25 | constructor(startFrame: number, endFrame?: number) 26 | 27 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 28 | constructor(startFrame: number | ISubtitleInfo, endFrame?: number) { 29 | this.id = uuidv4() 30 | if (isNumber(startFrame)) { 31 | if (endFrame === undefined) { 32 | throw new Error('Cannot init class from the provided parameters') 33 | } 34 | this.startFrame = startFrame 35 | this.endFrame = endFrame 36 | } else { 37 | const subtitleInfo = startFrame 38 | this.startFrame = subtitleInfo.startFrame 39 | this.endFrame = subtitleInfo.endFrame 40 | this.texts = subtitleInfo.texts === undefined ? [] : subtitleInfo.texts 41 | this.startTime = subtitleInfo.startTime 42 | this.endTime = subtitleInfo.endTime 43 | this.box = subtitleInfo.box 44 | } 45 | } 46 | 47 | get text(): string | undefined { 48 | const freq = {} as Record 49 | let maxFreq = 0 50 | let maxFreqText = '' 51 | if (this.texts.length === 0) { 52 | return undefined 53 | } 54 | for (const text of this.texts) { 55 | freq[text] = text in freq ? freq[text] + 1 : 1 56 | if (freq[text] > maxFreq) { 57 | maxFreq = freq[text] 58 | maxFreqText = text 59 | } 60 | } 61 | return maxFreqText 62 | } 63 | 64 | set text(value: string | undefined) { 65 | if (value !== undefined) { 66 | this.texts = [value] 67 | } 68 | } 69 | 70 | generateTime(fps: number): void { 71 | this.fps = fps 72 | 73 | let timeInt = Math.floor(this.startFrame * 1000 / fps) 74 | let timeStruct = new Date(timeInt) 75 | this.startTime = `${timeStruct.getUTCHours().toString().padStart(2, '0')}:${timeStruct.getUTCMinutes().toString().padStart(2, '0')}:${timeStruct.getUTCSeconds().toString().padStart(2, '0')}.${Math.floor(timeStruct.getUTCMilliseconds() / 10).toString().padStart(2, '0')}` 76 | 77 | timeInt = Math.floor(this.endFrame * 1000 / fps) 78 | timeStruct = new Date(timeInt) 79 | this.endTime = `${timeStruct.getUTCHours().toString().padStart(2, '0')}:${timeStruct.getUTCMinutes().toString().padStart(2, '0')}:${timeStruct.getUTCSeconds().toString().padStart(2, '0')}.${Math.floor(timeStruct.getUTCMilliseconds() / 10).toString().padStart(2, '0')}` 80 | } 81 | 82 | get startTimeValidated(): string | undefined { 83 | if (this.fps !== undefined) { 84 | this.generateTime(this.fps) 85 | } 86 | return this.startTime 87 | } 88 | 89 | set startTimeValidated(value: string | undefined) { 90 | if (value === undefined) { 91 | this.startTime = undefined 92 | } else { 93 | const match = /^(\d{2}):([0-5]\d):([0-5]\d).(\d{2})$/.exec(value) 94 | if (match) { 95 | this.startTime = value 96 | if (this.fps !== undefined) { 97 | this.startFrame = Math.round(((toInteger(match[1]) * 60 + toInteger(match[2])) * 60 + toInteger(match[3]) + toInteger(match[4]) / 100) * this.fps) 98 | } 99 | } 100 | } 101 | } 102 | 103 | get endTimeValidated(): string | undefined { 104 | if (this.fps !== undefined) { 105 | this.generateTime(this.fps) 106 | } 107 | return this.endTime 108 | } 109 | 110 | set endTimeValidated(value: string | undefined) { 111 | if (value === undefined) { 112 | this.endTime = undefined 113 | } else { 114 | const match = /^(\d{2}):([0-5]\d):([0-5]\d).(\d{2})$/.exec(value) 115 | if (match) { 116 | this.endTime = value 117 | if (this.fps !== undefined) { 118 | this.endFrame = Math.round(((toInteger(match[1]) * 60 + toInteger(match[2])) * 60 + toInteger(match[3]) + toInteger(match[4]) / 100) * this.fps) 119 | } 120 | } 121 | } 122 | } 123 | } 124 | 125 | export { ISubtitleInfo, SubtitleInfo } 126 | -------------------------------------------------------------------------------- /src/Utils.ts: -------------------------------------------------------------------------------- 1 | function frameToTime(frame: number, fps: number): string { 2 | const timeInt = Math.floor(frame * 1000 / fps) 3 | const timeStruct = new Date(timeInt) 4 | return `${timeStruct.getUTCHours().toString().padStart(2, '0')}:${timeStruct.getUTCMinutes().toString().padStart(2, '0')}:${timeStruct.getUTCSeconds().toString().padStart(2, '0')}.${Math.floor(timeStruct.getUTCMilliseconds() / 10).toString().padStart(2, '0')}` 5 | } 6 | 7 | export { frameToTime } 8 | -------------------------------------------------------------------------------- /src/VideoProperties.ts: -------------------------------------------------------------------------------- 1 | import { isNumber } from 'lodash' 2 | 3 | interface IVideoProperties { 4 | duration: number 5 | timeBase: Array 6 | fps: Array 7 | width: number 8 | height: number 9 | } 10 | 11 | class VideoProperties implements IVideoProperties { 12 | duration: number 13 | timeBase: Array 14 | fps: Array 15 | width: number 16 | height: number 17 | 18 | constructor(videoProperties: IVideoProperties) 19 | 20 | constructor(duration: number, timeBase?: Array, fps?: Array, width?: number, height?: number) 21 | 22 | constructor(duration: number | IVideoProperties, timeBase?: Array, fps?: Array, width?: number, height?: number) { 23 | if (isNumber(duration)) { 24 | if (timeBase === undefined || fps === undefined || width === undefined || height === undefined) { 25 | throw new Error('Cannot init class from the provided parameters') 26 | } 27 | this.duration = duration 28 | this.timeBase = timeBase 29 | this.fps = fps 30 | this.width = width 31 | this.height = height 32 | } else { 33 | const videoProperties = duration 34 | this.duration = videoProperties.duration 35 | this.timeBase = videoProperties.timeBase 36 | this.fps = videoProperties.fps 37 | this.width = videoProperties.width 38 | this.height = videoProperties.height 39 | } 40 | } 41 | 42 | get unitFrame(): number { 43 | return this.timeBase[1] * 44 | this.fps[1] / 45 | this.timeBase[0] / 46 | this.fps[0] 47 | } 48 | 49 | get lastFrame(): number { 50 | return Math.floor(this.duration / this.unitFrame) 51 | } 52 | } 53 | 54 | export { IVideoProperties, VideoProperties } 55 | -------------------------------------------------------------------------------- /src/assets/advanced-settings-expand.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /src/assets/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/freyjaSubOCR/freyja-sub-ocr-electron/5cdbde4a8c414cc9ce88db1fef4dd951f6f823f1/src/assets/logo.png -------------------------------------------------------------------------------- /src/assets/next_frame.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /src/assets/next_line.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /src/assets/open-video-icon.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | -------------------------------------------------------------------------------- /src/assets/pause.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /src/assets/play.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /src/assets/prev_frame.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /src/assets/prev_line.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /src/assets/subtitle-add.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /src/assets/subtitle-delete.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /src/assets/subtitle-locate.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /src/assets/subtitle-merge-end.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /src/assets/subtitle-merge-start.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /src/assets/titlebar-close.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /src/assets/titlebar-maximize.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /src/assets/titlebar-minimize.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /src/assets/titlebar-unmaximize.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /src/backends/ASSGenerator.ts: -------------------------------------------------------------------------------- 1 | import { ipcMain } from 'electron' 2 | import { ASSStyle } from '@/interfaces' 3 | import { ISubtitleInfo, SubtitleInfo } from '@/SubtitleInfo' 4 | import { VideoProperties } from '@/VideoProperties' 5 | import logger from '@/logger' 6 | import fs_ from 'fs' 7 | const fs = fs_.promises 8 | 9 | class ASSGenerator { 10 | private _style = new ASSStyle() 11 | 12 | registerIPCListener(): void { 13 | ipcMain.handle('ASSGenerator:Generate', (e, ...args) => { 14 | try { 15 | const subtitleInfos = (args[0] as Array).map(t => new SubtitleInfo(t)) 16 | return this.generate(subtitleInfos, args[1]) 17 | } catch (error) { 18 | logger.error((error as Error).message) 19 | return null 20 | } 21 | }) 22 | ipcMain.handle('ASSGenerator:GenerateAndSave', async (e, ...args) => { 23 | try { 24 | const subtitleInfos = (args[0] as Array).map(t => new SubtitleInfo(t)) 25 | return await this.generateAndSave(subtitleInfos, args[1], args[2]) 26 | } catch (error) { 27 | logger.error((error as Error).message) 28 | return null 29 | } 30 | }) 31 | } 32 | 33 | generate(subtitleInfos: Array, videoProperties: VideoProperties): string { 34 | const strings: Array = [] 35 | strings.push('[Script Info]') 36 | strings.push('ScriptType: v4.00+') 37 | strings.push(`PlayResX: ${videoProperties.width}`) 38 | strings.push(`PlayResY: ${videoProperties.height}`) 39 | strings.push('[V4+ Styles]') 40 | strings.push('Format: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding') 41 | strings.push(`Style: ${this._style.name},${this._style.fontname},${this._style.fontsize},${this._style.primaryColour},${this._style.secondaryColour},${this._style.outlineColour},${this._style.backColour},${this._style.bold ? -1 : 0},${this._style.italic ? -1 : 0},${this._style.underline ? -1 : 0},${this._style.strikeOut ? -1 : 0},${this._style.scaleX},${this._style.scaleY},${this._style.spacing},${this._style.angle},${this._style.borderStyle},${this._style.outline},${this._style.shadow},${this._style.alignment},${this._style.marginL},${this._style.marginR},${this._style.marginV},${this._style.encoding}`) 42 | 43 | strings.push('[Events]') 44 | strings.push('Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text') 45 | for (const subtitleInfo of subtitleInfos) { 46 | strings.push(`Dialogue: 0,${subtitleInfo.startTime ?? ''},${subtitleInfo.endTime ?? ''},Default,,0,0,0,,${subtitleInfo.text ?? ''}`) 47 | } 48 | return strings.join('\n') 49 | } 50 | 51 | async generateAndSave(subtitleInfos: Array, videoProperties: VideoProperties, path: string): Promise { 52 | const ass = this.generate(subtitleInfos, videoProperties) 53 | await fs.writeFile(path, ass, { encoding: 'utf-8' }) 54 | } 55 | 56 | applyStyle(style: ASSStyle): void { 57 | this._style = style 58 | } 59 | } 60 | 61 | export default ASSGenerator 62 | -------------------------------------------------------------------------------- /src/backends/BMPVideoPlayer.ts: -------------------------------------------------------------------------------- 1 | import { ipcMain } from 'electron' 2 | import beamcoder from 'beamcoder' 3 | import { RectPos, RenderedVideo } from '@/interfaces' 4 | import logger from '@/logger' 5 | import VideoPlayer from './VideoPlayer' 6 | 7 | class BMPVideoPlayer extends VideoPlayer { 8 | private _decodedCache: Array = [] 9 | private _rectPositons: Array | null = null 10 | 11 | registerIPCListener(): void { 12 | ipcMain.handle('VideoPlayer:OpenVideo', async (e, ...args) => { 13 | try { 14 | logger.debug('BMPVideoPlayer:OpenVideo') 15 | return await this.openVideo(args[0]) 16 | } catch (error) { 17 | logger.error((error as Error).message) 18 | return null 19 | } 20 | }) 21 | ipcMain.handle('VideoPlayer:GetVideoProperties', () => { 22 | try { 23 | logger.debug('BMPVideoPlayer:GetVideoProperties') 24 | return this.videoProperties 25 | } catch (error) { 26 | logger.error((error as Error).message) 27 | return null 28 | } 29 | }) 30 | ipcMain.handle('VideoPlayer:Seek', async (e, ...args) => { 31 | try { 32 | logger.debug(`BMPVideoPlayer:Seek ${args[0] as number}`) 33 | return await this.seekByTimestamp(args[0]) 34 | } catch (error) { 35 | logger.error((error as Error).message) 36 | return (error as Error) 37 | } 38 | }) 39 | ipcMain.handle('VideoPlayer:GetImage', async (e, ...args) => { 40 | try { 41 | logger.debug(`BMPVideoPlayer:GetImage ${args[0] as number}`) 42 | return await this.getImage2(args[0]) 43 | } catch (error) { 44 | logger.error((error as Error).message) 45 | return (error as Error) 46 | } 47 | }) 48 | ipcMain.handle('VideoPlayer:CloseVideo', () => { 49 | try { 50 | logger.debug('BMPVideoPlayer:CloseVideo') 51 | return this.closeVideo() 52 | } catch (error) { 53 | logger.error((error as Error).message) 54 | return null 55 | } 56 | }) 57 | } 58 | 59 | async getImage2(timestamp: number): Promise { 60 | if (timestamp < this.startTimestamp) { 61 | // fake a frame 62 | return { 63 | data: Buffer.from([]), 64 | timestamp: timestamp, 65 | keyFrame: false 66 | } 67 | } 68 | if (!this._decodedCache.some(t => t.best_effort_timestamp === timestamp)) { 69 | await this.seekByTimestamp(timestamp) 70 | let decodedFrames: Array 71 | do { 72 | logger.debug(`BMPVideoPlayer: decoding for timestamp ${timestamp}`) 73 | decodedFrames = await this.decode() 74 | decodedFrames = await this.convertPixelFormat(decodedFrames) 75 | 76 | if (this._rectPositons !== null) { 77 | decodedFrames = this.drawRect(decodedFrames) 78 | } 79 | 80 | // error upstream type definitions 81 | // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition 82 | if (decodedFrames == null) { 83 | throw new Error('Unknown decode error') 84 | } 85 | 86 | // if (decodedFrames.length > 0 && decodedFrames[0].best_effort_timestamp > timestamp) { 87 | // throw new Error('Unsupported variable frame rate video. Try to transcode the video using ffmpeg.\n' + 88 | // `Requested timestamp: ${timestamp}, receive timestamp ${decodedFrames[0].best_effort_timestamp}`) 89 | // } 90 | } 91 | // eslint-disable-next-line no-unmodified-loop-condition 92 | while (!decodedFrames.some(t => t.best_effort_timestamp === timestamp)) 93 | 94 | this._decodedCache = decodedFrames 95 | } 96 | 97 | const decodedFrame = this._decodedCache.filter(t => t.best_effort_timestamp === timestamp) 98 | const renderedFrame: RenderedVideo = { 99 | data: (await this.encode(decodedFrame[0])).data, 100 | timestamp: decodedFrame[0].best_effort_timestamp, 101 | keyFrame: decodedFrame[0].key_frame 102 | } 103 | logger.debug(`BMPVideoPlayer: send frame on timestamp ${timestamp}`) 104 | return renderedFrame 105 | } 106 | 107 | drawRect(frames: Array): Array { 108 | return frames 109 | } 110 | } 111 | 112 | export default BMPVideoPlayer 113 | -------------------------------------------------------------------------------- /src/backends/RawVideoPlayer.ts: -------------------------------------------------------------------------------- 1 | import beamcoder from 'beamcoder' 2 | import { RenderedVideo } from '@/interfaces' 3 | import logger from '@/logger' 4 | import Config from '@/config' 5 | import VideoPlayer from './VideoPlayer' 6 | 7 | class RawVideoPlayer extends VideoPlayer { 8 | private _renderedCache: Array = [] 9 | 10 | async renderImage(timestamp: number): Promise { 11 | timestamp = Math.floor(timestamp) 12 | logger.debug(`RawVideoPlayer: start render frame on timestamp ${timestamp}`) 13 | if (!this._renderedCache.some(t => t.timestamp === timestamp)) { 14 | await this.seekByTimestamp(timestamp) 15 | let decodedFrames: Array 16 | do { 17 | logger.debug(`RawVideoPlayer: decoding for timestamp ${timestamp}`) 18 | decodedFrames = await this.decode() 19 | decodedFrames = await this.convertPixelFormat(decodedFrames) 20 | 21 | // error upstream type definitions 22 | // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition 23 | if (decodedFrames == null) { 24 | throw new Error('Unknown decode error') 25 | } 26 | 27 | // Cannot use promise.all since the encode operation must be sequential 28 | for (const frame of decodedFrames) { 29 | this._renderedCache.push({ 30 | data: frame.data, 31 | timestamp: frame.best_effort_timestamp, 32 | keyFrame: frame.key_frame 33 | }) 34 | } 35 | 36 | if (timestamp < this.startTimestamp) { 37 | logger.debug(`fake a frame on timestamp ${timestamp}`) 38 | this._renderedCache.push({ 39 | data: decodedFrames[0].data, 40 | timestamp: timestamp, 41 | keyFrame: false 42 | }) 43 | } else if (decodedFrames.length > 0 && decodedFrames[0].best_effort_timestamp > timestamp) { 44 | throw new Error('Unsupported variable frame rate video. Try to transcode the video using ffmpeg.') 45 | } 46 | } 47 | while (!this._renderedCache.some(t => t.timestamp === timestamp)) 48 | } 49 | 50 | const renderedFrame = this._renderedCache.filter(t => t.timestamp === timestamp) 51 | if (renderedFrame.length === 0) { 52 | throw new Error('Cannot find rendered timestamp from cache') 53 | } else if (renderedFrame.length > 1) { 54 | logger.info(`duplicate cache for timestamp ${timestamp}`) 55 | } 56 | const targetFrame = renderedFrame[0] 57 | 58 | while (this._renderedCache.length > Config.cachedFrames) { 59 | this._renderedCache.shift() 60 | } 61 | 62 | logger.debug(`RawVideoPlayer: send frame on timestamp ${timestamp}`) 63 | return targetFrame 64 | } 65 | 66 | async renderImageSeq(): Promise { 67 | logger.debug('RawVideoPlayer: start render frame') 68 | if (this._renderedCache.length === 0) { 69 | let decodedFrames: Array 70 | try { 71 | logger.debug('RawVideoPlayer: decoding for timestamp unknown') 72 | decodedFrames = await this.decode() 73 | } catch (error) { 74 | if (error instanceof Error && error.message === 'Reach end of file') { 75 | return null 76 | } 77 | throw error 78 | } 79 | decodedFrames = await this.convertPixelFormat(decodedFrames) 80 | 81 | // error upstream type definitions 82 | // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition 83 | if (decodedFrames == null) { 84 | throw new Error('Unknown decode error') 85 | } 86 | 87 | for (const frame of decodedFrames) { 88 | this._renderedCache.push({ 89 | data: frame.data, 90 | timestamp: frame.best_effort_timestamp, 91 | keyFrame: frame.key_frame 92 | }) 93 | } 94 | } 95 | 96 | const targetFrame = this._renderedCache.shift() 97 | 98 | logger.debug(`RawVideoPlayer: send frame on timestamp ${targetFrame?.timestamp ?? 'null'}`) 99 | return targetFrame ?? null 100 | } 101 | } 102 | 103 | export default RawVideoPlayer 104 | -------------------------------------------------------------------------------- /src/backends/TorchOCR.ts: -------------------------------------------------------------------------------- 1 | import { ScriptModule, ObjectTensor, Tensor } from 'torch-js' 2 | import Config from '@/config' 3 | import fs_ from 'fs' 4 | import RawVideoPlayer from './RawVideoPlayer' 5 | import { SubtitleInfo } from '@/SubtitleInfo' 6 | import { VideoProperties } from '@/VideoProperties' 7 | import lodash from 'lodash' 8 | import { RenderedVideo } from '@/interfaces' 9 | 10 | const fs = fs_.promises 11 | 12 | class TorchOCR { 13 | private _rcnnModule: ScriptModule | undefined 14 | private _ocrModule: ScriptModule | undefined 15 | private _ocrChars: string | undefined 16 | private _videoPlayer: RawVideoPlayer | undefined 17 | private _videoProperties: VideoProperties | undefined 18 | 19 | public get videoProperties(): VideoProperties | undefined { 20 | return this._videoProperties 21 | } 22 | 23 | initRCNN(path: string | null = null): void { 24 | if (path == null) { path = Config.rcnnModulePath } 25 | this._rcnnModule = new ScriptModule(path) 26 | if (Config.enableCuda && ScriptModule.isCudaAvailable()) { this._rcnnModule = this._rcnnModule.cuda() } 27 | } 28 | 29 | async initOCR(modulePath: string | null = null, charsPath: string | null = null): Promise { 30 | if (modulePath == null) { modulePath = Config.ocrModulePath } 31 | if (charsPath == null) { charsPath = Config.ocrCharsPath } 32 | this._ocrModule = new ScriptModule(modulePath) 33 | this._ocrChars = await fs.readFile(charsPath, { encoding: 'utf-8' }) 34 | if (Config.enableCuda && ScriptModule.isCudaAvailable()) { this._ocrModule = this._ocrModule.cuda() } 35 | } 36 | 37 | async initVideoPlayer(path: string): Promise { 38 | this._videoPlayer = new RawVideoPlayer() 39 | this._videoProperties = await this._videoPlayer.openVideo(path) 40 | return this.videoProperties as VideoProperties 41 | } 42 | 43 | closeVideoPlayer(): void { 44 | if (this._videoPlayer !== undefined) { 45 | this._videoPlayer.closeVideo() 46 | } 47 | this._videoPlayer = undefined 48 | this._videoProperties = undefined 49 | } 50 | 51 | async readRawFrame(frame: number | undefined): Promise { 52 | if (this._videoPlayer === undefined || this.videoProperties === undefined) { 53 | throw new Error('VideoPlayer is not initialized') 54 | } 55 | const unitFrame = this.videoProperties.timeBase[1] * 56 | this.videoProperties.fps[1] / 57 | this.videoProperties.timeBase[0] / 58 | this.videoProperties.fps[0] 59 | let rawFrame: RenderedVideo 60 | if (frame === undefined) { 61 | const rawFrameNullable = await this._videoPlayer.renderImageSeq() 62 | if (rawFrameNullable === null) { 63 | return null 64 | } else { 65 | rawFrame = rawFrameNullable 66 | } 67 | } else { 68 | const timestamp = lodash.toInteger(frame * unitFrame) 69 | rawFrame = await this._videoPlayer.renderImage(timestamp) 70 | } 71 | let rawData = rawFrame.data[0] as Buffer 72 | rawData = rawData.slice(0, 3 * this.videoProperties.height * this.videoProperties.width) 73 | return rawData 74 | } 75 | 76 | bufferToImgTensor(buffers: Array, cropTop = 0, cropBottom = 0): Tensor { 77 | if (this.videoProperties === undefined) { 78 | throw new Error('VideoPlayer is not initialized') 79 | } 80 | if (cropTop < 0) cropTop = 0 81 | cropTop = lodash.toInteger(cropTop) 82 | if (cropBottom < 0) cropBottom = 0 83 | cropBottom = lodash.toInteger(cropBottom) 84 | 85 | const oneImgLength = 3 * (this.videoProperties.height - cropTop - cropBottom) * this.videoProperties.width 86 | const imgObjTensor = { 87 | data: new Float32Array(buffers.length * oneImgLength), 88 | shape: [buffers.length, this.videoProperties.height - cropTop - cropBottom, this.videoProperties.width, 3] 89 | } as ObjectTensor 90 | 91 | for (let j = 0; j < buffers.length; j++) { 92 | const buffer = buffers[j] 93 | if (buffer.length !== 3 * this.videoProperties.height * this.videoProperties.width) { 94 | throw new Error(`Buffer length mismatch. Should be ${3 * this.videoProperties.height * this.videoProperties.width}, got ${buffer.length}`) 95 | } 96 | imgObjTensor.data.set(buffer.slice(cropTop * this.videoProperties.width * 3, buffer.length - cropBottom * this.videoProperties.width * 3), j * oneImgLength) 97 | } 98 | return Tensor.fromObject(imgObjTensor) 99 | } 100 | 101 | async rcnnForward(input: Tensor): Promise>> { 102 | if (this._rcnnModule === undefined) { 103 | throw new Error('RCNN Module is not initialized') 104 | } 105 | if (Config.enableCuda && ScriptModule.isCudaAvailable()) { 106 | const inputCUDA = input.cuda() 107 | const result = await this._rcnnModule.forward(inputCUDA) as Array> 108 | inputCUDA.free() 109 | return result 110 | } else { 111 | const result = await this._rcnnModule.forward(input) as Array> 112 | return result 113 | } 114 | } 115 | 116 | rcnnParse(rcnnResults: Array>): Array { 117 | let subtitleInfo: SubtitleInfo | undefined 118 | const subtitleInfos: Array = [] 119 | for (const i of rcnnResults.keys()) { 120 | if (rcnnResults[i].boxes.cpu().toObject().shape[0] !== 0) { 121 | const boxObjectTensor = rcnnResults[i].boxes.cpu().toObject() 122 | subtitleInfo = new SubtitleInfo(i, i + 1) 123 | subtitleInfo.box = new Int32Array(4) 124 | subtitleInfo.box[0] = lodash.toInteger(boxObjectTensor.data[0]) - 10 125 | subtitleInfo.box[1] = lodash.toInteger(boxObjectTensor.data[1]) - 10 126 | subtitleInfo.box[2] = lodash.toInteger(boxObjectTensor.data[2]) + 10 127 | subtitleInfo.box[3] = lodash.toInteger(boxObjectTensor.data[3]) + 10 128 | subtitleInfos.push(subtitleInfo) 129 | } 130 | } 131 | 132 | return subtitleInfos 133 | } 134 | 135 | subtitleInfoToTensor(subtitleInfos: Array): Tensor { 136 | const boxesObjectTensor = { data: new Int32Array(subtitleInfos.length * 5), shape: [subtitleInfos.length, 5] } 137 | for (const i of subtitleInfos.keys()) { 138 | const box = subtitleInfos[i].box 139 | if (box !== undefined) { 140 | boxesObjectTensor.data[i * 5 + 0] = box[0] 141 | boxesObjectTensor.data[i * 5 + 1] = box[1] 142 | boxesObjectTensor.data[i * 5 + 2] = box[2] 143 | boxesObjectTensor.data[i * 5 + 3] = box[3] 144 | boxesObjectTensor.data[i * 5 + 4] = subtitleInfos[i].startFrame 145 | } 146 | } 147 | return Tensor.fromObject(boxesObjectTensor) 148 | } 149 | 150 | async ocrForward(input: Tensor, boxes: Tensor): Promise>> { 151 | if (this._ocrModule === undefined) { 152 | throw new Error('OCR Module is not initialized') 153 | } 154 | 155 | if (Config.enableCuda && ScriptModule.isCudaAvailable()) { 156 | const inputCUDA = input.cuda() 157 | const result = await this._ocrModule.forward(inputCUDA, boxes) as Array> 158 | inputCUDA.free() 159 | return result 160 | } else { 161 | return await this._ocrModule.forward(input, boxes) as Array> 162 | } 163 | } 164 | 165 | async ocrV3Forward(input: Tensor): Promise>> { 166 | if (this._ocrModule === undefined) { 167 | throw new Error('OCR Module is not initialized') 168 | } 169 | 170 | if (Config.enableCuda && ScriptModule.isCudaAvailable()) { 171 | const inputCUDA = input.cuda() 172 | const result = await this._ocrModule.forward(inputCUDA) as Array> 173 | inputCUDA.free() 174 | return result 175 | } else { 176 | return await this._ocrModule.forward(input) as Array> 177 | } 178 | } 179 | 180 | ocrParse(ocrResults: Array>): Array { 181 | return ocrResults.map(t => t.map(d => { 182 | if (this._ocrChars === undefined) { 183 | throw new Error('OCR Module is not initialized') 184 | } 185 | return this._ocrChars[d] 186 | }).join('').trim()) 187 | } 188 | } 189 | 190 | export default TorchOCR 191 | -------------------------------------------------------------------------------- /src/backends/TorchOCRWorker.ts: -------------------------------------------------------------------------------- 1 | import TorchOCR from './TorchOCR' 2 | import logger from '@/logger' 3 | import { Tensor } from 'torch-js' 4 | import { IConfig, Config } from '@/config' 5 | import { SubtitleInfo } from '@/SubtitleInfo' 6 | import levenshtein from 'js-levenshtein' 7 | import { expose } from 'threads/worker' 8 | 9 | class TorchOCRWorker { 10 | private _torchOCR: TorchOCR = new TorchOCR() 11 | currentProcessingFrame = 0 12 | subtitleInfos: Array = [] 13 | 14 | async init(path: string): Promise { 15 | await this._torchOCR.initOCR() 16 | await this._torchOCR.initVideoPlayer(path) 17 | } 18 | 19 | close(): void { 20 | this._torchOCR.closeVideoPlayer() 21 | this._torchOCR = new TorchOCR() 22 | } 23 | 24 | async start(): Promise> { 25 | this.currentProcessingFrame = 0 26 | this.subtitleInfos = [] 27 | const step = Config.batchSize 28 | let tensorDataPromise = new Promise(resolve => resolve(null)) 29 | let ocrPromise = new Promise(resolve => resolve(null)) 30 | const ocrPromiseBuffer = [ocrPromise, ocrPromise, ocrPromise, ocrPromise] 31 | if (this._torchOCR.videoProperties === undefined) { 32 | throw new Error('VideoPlayer is not initialized') 33 | } 34 | 35 | for (let frame = 0; frame <= this._torchOCR.videoProperties.lastFrame; frame += step) { 36 | const currentFrame = frame 37 | let localStep = step 38 | if (this._torchOCR.videoProperties.lastFrame + 1 - frame < step) { 39 | localStep = this._torchOCR.videoProperties.lastFrame + 1 - frame 40 | } 41 | 42 | tensorDataPromise = Promise.all([tensorDataPromise, ocrPromiseBuffer[0]]).then(async () => { 43 | logger.debug(`loading tensor data on frame ${currentFrame}...`) 44 | const rawImg: Array = [] 45 | // eslint-disable-next-line @typescript-eslint/no-unused-vars 46 | for (const i of Array(localStep).keys()) { 47 | const frame = await this._torchOCR.readRawFrame(undefined) 48 | if (frame === null) { 49 | continue 50 | } 51 | rawImg.push(frame) 52 | } 53 | this.currentProcessingFrame = currentFrame 54 | if (rawImg.length === 0) return null 55 | const inputTensor = this._torchOCR.bufferToImgTensor(rawImg, Config.cropTop, Config.cropBottom) 56 | return inputTensor 57 | }) 58 | 59 | ocrPromise = Promise.all([ocrPromise, tensorDataPromise]).then(async (values) => { 60 | logger.debug(`ocr on frame ${currentFrame}...`) 61 | const inputTensor = values[1] 62 | if (inputTensor === null) return null 63 | 64 | const ocrResults = this._torchOCR.ocrParse(await this._torchOCR.ocrV3Forward(inputTensor)) 65 | 66 | for (const i of ocrResults.keys()) { 67 | const subtitleInfo = new SubtitleInfo(i + currentFrame, i + currentFrame + 1) 68 | subtitleInfo.texts = ocrResults[i] === '' ? [] : [ocrResults[i]] 69 | this.subtitleInfos.push(subtitleInfo) 70 | } 71 | 72 | inputTensor.free() 73 | }) 74 | void ocrPromiseBuffer.shift() 75 | ocrPromiseBuffer.push(ocrPromise) 76 | } 77 | await Promise.all([tensorDataPromise, ocrPromise]) 78 | logger.debug(this.subtitleInfos) 79 | return this.subtitleInfos 80 | } 81 | 82 | cleanUpSubtitleInfos(): Array { 83 | if (this._torchOCR.videoProperties === undefined) { 84 | throw new Error('VideoPlayer is not initialized') 85 | } 86 | 87 | let subtitleInfo: SubtitleInfo | undefined 88 | const subtitleInfos: Array = [] 89 | for (const i of this.subtitleInfos.keys()) { 90 | const currentSubtitleInfo = this.subtitleInfos[i] 91 | if (currentSubtitleInfo.text === undefined) { 92 | if (subtitleInfo !== undefined) { 93 | subtitleInfo.generateTime(this._torchOCR.videoProperties.fps[0] / this._torchOCR.videoProperties.fps[1]) 94 | subtitleInfos.push(subtitleInfo) // previous subtitle end, push to array 95 | subtitleInfo = undefined 96 | } 97 | } else { 98 | if (subtitleInfo?.text === undefined) { 99 | subtitleInfo = new SubtitleInfo(currentSubtitleInfo.startFrame, currentSubtitleInfo.endFrame) 100 | } else { 101 | if (levenshtein(subtitleInfo.text, currentSubtitleInfo.text) > 3) { 102 | subtitleInfo.generateTime(this._torchOCR.videoProperties.fps[0] / this._torchOCR.videoProperties.fps[1]) 103 | subtitleInfos.push(subtitleInfo) // push old subtitle 104 | subtitleInfo = new SubtitleInfo(currentSubtitleInfo.startFrame, currentSubtitleInfo.endFrame) // create new subtitle for current text 105 | } else { 106 | subtitleInfo.endFrame = currentSubtitleInfo.endFrame 107 | } 108 | } 109 | subtitleInfo.texts.push(currentSubtitleInfo.text) 110 | } 111 | } 112 | if (subtitleInfo !== undefined) { 113 | subtitleInfo.generateTime(this._torchOCR.videoProperties.fps[0] / this._torchOCR.videoProperties.fps[1]) 114 | subtitleInfos.push(subtitleInfo) // previous subtitle end, push to array 115 | subtitleInfo = undefined 116 | } 117 | 118 | this.subtitleInfos = subtitleInfos 119 | logger.debug(this.subtitleInfos) 120 | return this.subtitleInfos 121 | } 122 | } 123 | 124 | const torchOCRWorker = new TorchOCRWorker() 125 | 126 | const torchOCRWorkerThreadInterface = { 127 | importConfig(config: IConfig): void { 128 | Config.import(config) 129 | if (/\.asar[/\\]/.exec(Config.rcnnModulePath)) { 130 | Config.rcnnModulePath = Config.rcnnModulePath.replace(/\.asar([/\\])/, '.asar.unpacked$1') 131 | } 132 | if (/\.asar[/\\]/.exec(Config.ocrModulePath)) { 133 | Config.ocrModulePath = Config.ocrModulePath.replace(/\.asar([/\\])/, '.asar.unpacked$1') 134 | } 135 | if (/\.asar[/\\]/.exec(Config.ocrCharsPath)) { 136 | Config.ocrCharsPath = Config.ocrCharsPath.replace(/\.asar([/\\])/, '.asar.unpacked$1') 137 | } 138 | logger.debug(Config.export()) 139 | }, 140 | init(path: string): Promise { return torchOCRWorker.init(path) }, 141 | start(): Promise> { return torchOCRWorker.start() }, 142 | close(): void { return torchOCRWorker.close() }, 143 | cleanUpSubtitleInfos(): Array { return torchOCRWorker.cleanUpSubtitleInfos() }, 144 | currentProcessingFrame(): number { return torchOCRWorker.currentProcessingFrame } 145 | } 146 | 147 | export type TorchOCRWorkerThreadInterface = typeof torchOCRWorkerThreadInterface 148 | 149 | if (process.env.NODE_ENV !== 'test') { 150 | expose(torchOCRWorkerThreadInterface) 151 | } 152 | 153 | export default TorchOCRWorker 154 | -------------------------------------------------------------------------------- /src/backends/TorchOCRWorkerManager.ts: -------------------------------------------------------------------------------- 1 | import { ipcMain } from 'electron' 2 | import logger from '@/logger' 3 | import Config from '@/config' 4 | import { TorchOCRWorkerThreadInterface } from '@/backends/TorchOCRWorker' 5 | import { spawn, Thread, Worker } from 'threads' 6 | 7 | const wrapperSpawn = () => spawn(new Worker('@/backends/TorchOCRWorker')) 8 | type ThenArg = T extends PromiseLike ? U : T 9 | type WorkerType = ThenArg> 10 | 11 | class TorchOCRTaskSchedulerWorker { 12 | worker: WorkerType | undefined 13 | 14 | async initWorker(): Promise { 15 | if (this.worker !== undefined) return 16 | let workerPath = '0.worker.js' 17 | if (process.env.NODE_ENV !== 'production') { 18 | workerPath = '0.worker.js' 19 | } 20 | this.worker = await spawn(new Worker(workerPath)) 21 | } 22 | 23 | async terminateWorker(): Promise { 24 | if (this.worker === undefined) return 25 | await Thread.terminate(this.worker) 26 | this.worker = undefined 27 | } 28 | 29 | registerIPCListener(): void { 30 | ipcMain.handle('TorchOCRWorker:Init', async (e, ...args) => { 31 | try { 32 | if (this.worker === undefined) await this.initWorker() 33 | await this.worker?.importConfig(Config.export()) 34 | return await this.worker?.init(args[0]) 35 | } catch (error) { 36 | logger.error((error as Error).message) 37 | return null 38 | } 39 | }) 40 | ipcMain.handle('TorchOCRWorker:Start', async () => { 41 | try { 42 | if (this.worker === undefined) await this.initWorker() 43 | return await this.worker?.start() 44 | } catch (error) { 45 | logger.error((error as Error).message) 46 | return null 47 | } 48 | }) 49 | ipcMain.handle('TorchOCRWorker:CleanUpSubtitleInfos', async () => { 50 | try { 51 | if (this.worker === undefined) await this.initWorker() 52 | return await this.worker?.cleanUpSubtitleInfos() 53 | } catch (error) { 54 | logger.error((error as Error).message) 55 | return null 56 | } 57 | }) 58 | ipcMain.handle('TorchOCRWorker:currentProcessingFrame', async () => { 59 | try { 60 | if (this.worker === undefined) await this.initWorker() 61 | return await this.worker?.currentProcessingFrame() 62 | } catch (error) { 63 | logger.error((error as Error).message) 64 | return null 65 | } 66 | }) 67 | ipcMain.handle('TorchOCRWorker:Close', async () => { 68 | try { 69 | if (this.worker === undefined) await this.initWorker() 70 | return await this.worker?.close() 71 | } catch (error) { 72 | logger.error((error as Error).message) 73 | return null 74 | } 75 | }) 76 | } 77 | } 78 | 79 | export default TorchOCRTaskSchedulerWorker 80 | -------------------------------------------------------------------------------- /src/backends/VideoPlayer.ts: -------------------------------------------------------------------------------- 1 | import beamcoder from 'beamcoder' 2 | import { IVideoProperties, VideoProperties } from '@/VideoProperties' 3 | import logger from '@/logger' 4 | 5 | class VideoPlayer { 6 | private _demuxer: beamcoder.Demuxer | null = null 7 | private _decoder: beamcoder.Decoder | null = null 8 | private _encoder: beamcoder.Encoder | null = null 9 | private _formatFilter: beamcoder.Filterer | null = null 10 | protected startTimestamp = 0 11 | 12 | get videoProperties(): VideoProperties | null { 13 | if (this._demuxer == null) return null 14 | 15 | const videoProperties: IVideoProperties = { 16 | duration: this._demuxer.streams[0].duration !== null ? this._demuxer.streams[0].duration : this._demuxer.duration * this._demuxer.streams[0].time_base[1] / this._demuxer.streams[0].time_base[0] / 1000000, 17 | timeBase: this._demuxer.streams[0].time_base, 18 | fps: this._demuxer.streams[0].avg_frame_rate, 19 | width: this._demuxer.streams[0].codecpar.width, 20 | height: this._demuxer.streams[0].codecpar.height 21 | } 22 | 23 | return new VideoProperties(videoProperties) 24 | } 25 | 26 | async openVideo(path: string): Promise { 27 | this._demuxer = await beamcoder.demuxer(path) 28 | 29 | // Since we just created the demuxer, the videoProperties cannot be null 30 | // eslint-disable-next-line @typescript-eslint/no-non-null-assertion 31 | const videoProperties = this.videoProperties! 32 | logger.debug(videoProperties) 33 | 34 | // eslint-disable-next-line @typescript-eslint/naming-convention 35 | this._decoder = beamcoder.decoder({ 'demuxer': this._demuxer, 'stream_index': 0 }) 36 | this._encoder = beamcoder.encoder({ 37 | 'name': 'bmp', 38 | 'width': videoProperties.width, 39 | 'height': videoProperties.height, 40 | // eslint-disable-next-line @typescript-eslint/naming-convention 41 | 'pix_fmt': 'bgr24', 42 | // eslint-disable-next-line @typescript-eslint/naming-convention 43 | 'time_base': [1, 1] 44 | }) 45 | this._formatFilter = await beamcoder.filterer({ 46 | filterType: 'video', 47 | inputParams: [ 48 | { 49 | 'width': videoProperties.width, 50 | 'height': videoProperties.height, 51 | 'pixelFormat': this._demuxer.streams[0].codecpar.format, 52 | 'pixelAspect': this._demuxer.streams[0].codecpar.sample_aspect_ratio, 53 | 'timeBase': videoProperties.timeBase 54 | } 55 | ], 56 | outputParams: [ 57 | { 58 | pixelFormat: 'bgr24' 59 | } 60 | ], 61 | filterSpec: 'format=pix_fmts=bgr24' 62 | }) 63 | 64 | const startTimestamp = this._demuxer.streams[0].start_time 65 | if (startTimestamp === null) { 66 | this.startTimestamp = 0 67 | } else { 68 | logger.debug(`set start timestamp to ${startTimestamp}`) 69 | this.startTimestamp = startTimestamp 70 | } 71 | 72 | logger.debug(`Opened Video: ${path}`) 73 | return videoProperties 74 | } 75 | 76 | closeVideo(): void { 77 | if (this._demuxer !== null) { 78 | // upstream type def bug 79 | // eslint-disable-next-line @typescript-eslint/no-unsafe-call 80 | this._demuxer.forceClose() 81 | } 82 | this._demuxer = null 83 | this._decoder = null 84 | this._encoder = null 85 | this._formatFilter = null 86 | } 87 | 88 | async seekByTime(time: number): Promise { 89 | if (this._demuxer == null) { 90 | throw new Error('No video is opened for seek') 91 | } 92 | await this._demuxer.seek({ 'time': time }) 93 | logger.debug(`seeked to time ${time}`) 94 | } 95 | 96 | async seekByTimestamp(timestamp: number): Promise { 97 | timestamp = Math.floor(timestamp) 98 | if (this._demuxer == null) { 99 | throw new Error('No video is opened for seek') 100 | } 101 | // eslint-disable-next-line @typescript-eslint/naming-convention 102 | await this._demuxer.seek({ 'timestamp': timestamp, 'stream_index': 0 }) 103 | logger.debug(`seeked to timestamp ${timestamp}`) 104 | } 105 | 106 | protected async convertPixelFormat(decodedFrames: Array): Promise> { 107 | if (this._formatFilter == null) { 108 | throw new Error('Failed to initlize formatFilter') 109 | } 110 | const filterResult = await this._formatFilter.filter(decodedFrames) 111 | // error upstream type definitions 112 | // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition 113 | if (filterResult === undefined) { 114 | throw new Error('Unknown filter error') 115 | } 116 | decodedFrames = filterResult[0].frames 117 | return decodedFrames 118 | } 119 | 120 | protected async encode(frame: beamcoder.Frame): Promise { 121 | if (this._encoder == null) { 122 | throw new Error('Failed to initlize encoder') 123 | } 124 | let encodeResult = await this._encoder.encode(frame) 125 | if (encodeResult.packets.length === 0) { 126 | encodeResult = await this._encoder.flush() 127 | } 128 | if (encodeResult.packets.length !== 1) { 129 | throw new Error(`Unexpected encode_result, got ${encodeResult.packets.length} packets`) 130 | } 131 | return encodeResult.packets[0] 132 | } 133 | 134 | protected async decode(): Promise> { 135 | logger.debug('start decoding...') 136 | if (this._demuxer == null || this._decoder == null) { 137 | throw new Error('No video is opened for decode') 138 | } 139 | 140 | const decodedFrames: Array = [] 141 | let metKeyFrame = false 142 | 143 | while (decodedFrames.length === 0 || !metKeyFrame) { 144 | let packet = await this._demuxer.read() 145 | // error upstream type definitions 146 | // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition 147 | for (; packet != null && packet.stream_index !== 0; packet = await this._demuxer.read()); 148 | // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition 149 | if (packet == null) { 150 | decodedFrames.push(...(await this._decoder.flush()).frames) 151 | // restart a decoder for further processing 152 | // eslint-disable-next-line @typescript-eslint/naming-convention 153 | this._decoder = beamcoder.decoder({ 'demuxer': this._demuxer, 'stream_index': 0 }) 154 | if (decodedFrames.length === 0) { 155 | throw new Error('Reach end of file') 156 | } 157 | break // ignore keyframe optmization 158 | } 159 | 160 | const decodeResult = ((await this._decoder.decode(packet)).frames) 161 | metKeyFrame = decodeResult.some(t => t.key_frame) 162 | decodedFrames.push(...decodeResult) 163 | } 164 | 165 | logger.debug(`decoded frames on timestamp ${decodedFrames.map(t => t.best_effort_timestamp).join(' ')}`) 166 | return decodedFrames 167 | } 168 | } 169 | 170 | export default VideoPlayer 171 | -------------------------------------------------------------------------------- /src/background.ts: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | import { app, protocol, BrowserWindow } from 'electron' 4 | import { createProtocol } from 'vue-cli-plugin-electron-builder/lib' 5 | import installExtension, { VUEJS_DEVTOOLS } from 'electron-devtools-installer' 6 | import path from 'path' 7 | import CommonIpc from '@/CommonIpc' 8 | import CommonStorage from '@/CommonStorage' 9 | import TorchOCRWorkerManager from '@/backends/TorchOCRWorkerManager' 10 | import BMPVideoPlayer from '@/backends/BMPVideoPlayer' 11 | import ASSGenerator from '@/backends/ASSGenerator' 12 | import ConfigIpc from '@/configIpc' 13 | import SegfaultHandler from 'segfault-handler' 14 | 15 | const isDevelopment = process.env.NODE_ENV === 'development' 16 | SegfaultHandler.registerHandler('crash.log') 17 | 18 | // Keep a global reference of the window object, if you don't, the window will 19 | // be closed automatically when the JavaScript object is garbage collected. 20 | let win: BrowserWindow | null 21 | let torchOCRWorkerManager: TorchOCRWorkerManager | undefined 22 | 23 | // Scheme must be registered before the app is ready 24 | protocol.registerSchemesAsPrivileged([ 25 | { scheme: 'app', privileges: { secure: true, standard: true } } 26 | ]) 27 | 28 | function createWindow() { 29 | // Create the browser window. 30 | win = new BrowserWindow({ 31 | width: 1100, 32 | height: 680, 33 | minWidth: 1100, 34 | minHeight: 680, 35 | frame: false, 36 | webPreferences: { 37 | // Use pluginOptions.nodeIntegration, leave this alone 38 | // See nklayman.github.io/vue-cli-plugin-electron-builder/guide/security.html#node-integration for more info 39 | nodeIntegration: false, 40 | enableRemoteModule: false, 41 | preload: path.join(__dirname, 'preload.js') 42 | } 43 | }) 44 | 45 | if (process.env.WEBPACK_DEV_SERVER_URL) { 46 | // Load the url of the dev server if in development mode 47 | void win.loadURL(process.env.WEBPACK_DEV_SERVER_URL) 48 | if (isDevelopment && !process.env.IS_TEST) win.webContents.openDevTools() 49 | } else { 50 | createProtocol('app') 51 | // Load the index.html when not in development 52 | void win.loadURL('app://./index.html') 53 | } 54 | 55 | win.on('closed', () => { 56 | // ignore promise 57 | void torchOCRWorkerManager?.terminateWorker() 58 | win = null 59 | }) 60 | } 61 | 62 | // Quit when all windows are closed. 63 | app.on('window-all-closed', () => { 64 | // On macOS it is common for applications and their menu bar 65 | // to stay active until the user quits explicitly with Cmd + Q 66 | if (process.platform !== 'darwin') { 67 | app.quit() 68 | } 69 | }) 70 | 71 | app.on('activate', () => { 72 | // On macOS it's common to re-create a window in the app when the 73 | // dock icon is clicked and there are no other windows open. 74 | if (win === null) { 75 | createWindow() 76 | } 77 | }) 78 | 79 | // This method will be called when Electron has finished 80 | // initialization and is ready to create browser windows. 81 | // Some APIs can only be used after this event occurs. 82 | // eslint-disable-next-line @typescript-eslint/no-misused-promises 83 | app.on('ready', async () => { 84 | if (isDevelopment && !process.env.IS_TEST) { 85 | // Install Vue Devtools 86 | try { 87 | await installExtension(VUEJS_DEVTOOLS) 88 | } catch (e) { 89 | console.error('Vue Devtools failed to install:', (e as Error).message) 90 | } 91 | } 92 | 93 | const bmpVideoPlayer = new BMPVideoPlayer() 94 | bmpVideoPlayer.registerIPCListener() 95 | const commonIpc = new CommonIpc() 96 | commonIpc.registerIPCListener() 97 | const commonStorage = new CommonStorage() 98 | commonStorage.registerIPCListener() 99 | const assGenerator = new ASSGenerator() 100 | assGenerator.registerIPCListener() 101 | torchOCRWorkerManager = new TorchOCRWorkerManager() 102 | torchOCRWorkerManager.registerIPCListener() 103 | ConfigIpc.registerIPCListener() 104 | 105 | createWindow() 106 | }) 107 | 108 | // Exit cleanly on request from parent process in development mode. 109 | if (isDevelopment) { 110 | if (process.platform === 'win32') { 111 | process.on('message', (data) => { 112 | if (data === 'graceful-exit') { 113 | app.quit() 114 | } 115 | }) 116 | } else { 117 | process.on('SIGTERM', () => { 118 | app.quit() 119 | }) 120 | } 121 | } 122 | -------------------------------------------------------------------------------- /src/components/EditableDiv.vue: -------------------------------------------------------------------------------- 1 | 4 | 16 | -------------------------------------------------------------------------------- /src/components/SubtitleInfoTable.vue: -------------------------------------------------------------------------------- 1 | 51 | 52 | 169 | 170 | 318 | 319 | 333 | -------------------------------------------------------------------------------- /src/components/Timeline.vue: -------------------------------------------------------------------------------- 1 | 35 | 36 | 301 | 302 | 351 | 437 | -------------------------------------------------------------------------------- /src/components/TimelineBar.vue: -------------------------------------------------------------------------------- 1 | 10 | 11 | 98 | 99 | 136 | -------------------------------------------------------------------------------- /src/components/TitleBar.vue: -------------------------------------------------------------------------------- 1 | 22 | 23 | 53 | 54 | 112 | -------------------------------------------------------------------------------- /src/components/VideoBar.vue: -------------------------------------------------------------------------------- 1 | 11 | 12 | 135 | 136 | 222 | -------------------------------------------------------------------------------- /src/components/VideoPlayer.vue: -------------------------------------------------------------------------------- 1 | 38 | 39 | 187 | 188 | 272 | -------------------------------------------------------------------------------- /src/config.ts: -------------------------------------------------------------------------------- 1 | import path from 'path' 2 | import fs from 'fs' 3 | 4 | interface IConfig { 5 | cachedFrames: number 6 | rcnnModulePath: string 7 | ocrModulePath: string 8 | ocrCharsPath: string 9 | enableCuda: boolean 10 | batchSize: number 11 | cropTop: number 12 | cropBottom: number 13 | } 14 | 15 | class Config { 16 | static export(): IConfig { 17 | return { 18 | cachedFrames: Config.cachedFrames, 19 | rcnnModulePath: Config.rcnnModulePath, 20 | ocrModulePath: Config.ocrModulePath, 21 | ocrCharsPath: Config.ocrCharsPath, 22 | enableCuda: Config.enableCuda, 23 | batchSize: Config.batchSize, 24 | cropTop: Config.cropTop, 25 | cropBottom: Config.cropBottom 26 | } 27 | } 28 | 29 | static import(config: IConfig): void { 30 | Config.cachedFrames = config.cachedFrames 31 | Config.rcnnModulePath = config.rcnnModulePath 32 | Config.ocrModulePath = config.ocrModulePath 33 | Config.ocrCharsPath = config.ocrCharsPath 34 | Config.enableCuda = config.enableCuda 35 | Config.batchSize = config.batchSize 36 | Config.cropTop = config.cropTop 37 | Config.cropBottom = config.cropBottom 38 | } 39 | 40 | static checkPath(): boolean { 41 | if (/\.asar[/\\]/.exec(Config.rcnnModulePath)) { 42 | Config.rcnnModulePath = Config.rcnnModulePath.replace(/\.asar([/\\])/, '.asar.unpacked$1') 43 | } 44 | if (/\.asar[/\\]/.exec(Config.ocrModulePath)) { 45 | Config.ocrModulePath = Config.ocrModulePath.replace(/\.asar([/\\])/, '.asar.unpacked$1') 46 | } 47 | if (/\.asar[/\\]/.exec(Config.ocrCharsPath)) { 48 | Config.ocrCharsPath = Config.ocrCharsPath.replace(/\.asar([/\\])/, '.asar.unpacked$1') 49 | } 50 | // const rcnnModuleExists = fs.existsSync(Config.rcnnModulePath) 51 | const ocrModuleExists = fs.existsSync(Config.ocrModulePath) 52 | const ocrCharsExists = fs.existsSync(Config.ocrCharsPath) 53 | return ocrModuleExists && ocrCharsExists 54 | } 55 | 56 | private static _language = 'SC5000Chars' 57 | private static _font = 'yuan' 58 | static get language(): string { 59 | return Config._language 60 | } 61 | static set language(value: string) { 62 | Config._language = value 63 | Config.ocrModulePath = path.resolve('./', 'models', `ocrV3_${Config.language}_${Config.font}.torchscript`) 64 | Config.ocrCharsPath = path.resolve('./', 'models', `ocr_${Config.language}.txt`) 65 | } 66 | static get font(): string { 67 | return Config._font 68 | } 69 | static set font(value: string) { 70 | Config._font = value 71 | Config.ocrModulePath = path.resolve('./', 'models', `ocrV3_${Config.language}_${Config.font}.torchscript`) 72 | } 73 | 74 | static cachedFrames = 200 75 | static rcnnModulePath = path.resolve('./', 'models', 'object_detection.torchscript') 76 | static ocrModulePath = path.resolve('./', 'models', `ocrV3_${Config.language}_${Config.font}.torchscript`) 77 | static ocrCharsPath = path.resolve('./', 'models', `ocr_${Config.language}.txt`) 78 | static enableCuda = true 79 | static batchSize = 32 80 | static cropTop = 0 81 | static cropBottom = 0 82 | // eslint-disable-next-line @typescript-eslint/naming-convention 83 | static languages: Record = { 'Simplified Chinese': 'SC5000Chars', 'Traditional Chinese': 'TC5000Chars' } 84 | // eslint-disable-next-line @typescript-eslint/naming-convention 85 | static fonts: Record = { '圆体': 'yuan', '黑体': 'hei' } 86 | } 87 | 88 | export { Config as default, IConfig, Config } 89 | -------------------------------------------------------------------------------- /src/configIpc.ts: -------------------------------------------------------------------------------- 1 | import { ipcMain } from 'electron' 2 | import logger from '@/logger' 3 | import Config from '@/config' 4 | 5 | class ConfigIpc { 6 | static registerIPCListener(): void { 7 | ipcMain.handle('Config:cachedFrames', (e, ...args) => { 8 | try { 9 | if (args.length === 1) { 10 | Config.cachedFrames = args[0] as number 11 | } 12 | return Config.cachedFrames 13 | } catch (error) { 14 | logger.error((error as Error).message) 15 | return null 16 | } 17 | }) 18 | ipcMain.handle('Config:enableCuda', (e, ...args) => { 19 | try { 20 | if (args.length === 1) { 21 | Config.enableCuda = args[0] as boolean 22 | } 23 | return Config.enableCuda 24 | } catch (error) { 25 | logger.error((error as Error).message) 26 | return null 27 | } 28 | }) 29 | ipcMain.handle('Config:batchSize', (e, ...args) => { 30 | try { 31 | if (args.length === 1) { 32 | Config.batchSize = args[0] as number 33 | } 34 | return Config.batchSize 35 | } catch (error) { 36 | logger.error((error as Error).message) 37 | return null 38 | } 39 | }) 40 | ipcMain.handle('Config:cropTop', (e, ...args) => { 41 | try { 42 | if (args.length === 1) { 43 | Config.cropTop = args[0] as number 44 | } 45 | return Config.cropTop 46 | } catch (error) { 47 | logger.error((error as Error).message) 48 | return null 49 | } 50 | }) 51 | ipcMain.handle('Config:cropBottom', (e, ...args) => { 52 | try { 53 | if (args.length === 1) { 54 | Config.cropBottom = args[0] as number 55 | } 56 | return Config.cropBottom 57 | } catch (error) { 58 | logger.error((error as Error).message) 59 | return null 60 | } 61 | }) 62 | ipcMain.handle('Config:language', (e, ...args) => { 63 | try { 64 | if (args.length === 1) { 65 | Config.language = args[0] as string 66 | } 67 | return Config.language 68 | } catch (error) { 69 | logger.error((error as Error).message) 70 | return null 71 | } 72 | }) 73 | ipcMain.handle('Config:font', (e, ...args) => { 74 | try { 75 | if (args.length === 1) { 76 | Config.font = args[0] as string 77 | } 78 | return Config.font 79 | } catch (error) { 80 | logger.error((error as Error).message) 81 | return null 82 | } 83 | }) 84 | ipcMain.handle('Config:languages', (e, ...args) => { 85 | try { 86 | if (args.length === 1) { 87 | Config.languages = args[0] as Record 88 | } 89 | return Config.languages 90 | } catch (error) { 91 | logger.error((error as Error).message) 92 | return null 93 | } 94 | }) 95 | ipcMain.handle('Config:fonts', (e, ...args) => { 96 | try { 97 | if (args.length === 1) { 98 | Config.fonts = args[0] as Record 99 | } 100 | return Config.fonts 101 | } catch (error) { 102 | logger.error((error as Error).message) 103 | return null 104 | } 105 | }) 106 | ipcMain.handle('Config:CheckPath', () => { 107 | try { 108 | return Config.checkPath() 109 | } catch (error) { 110 | logger.error((error as Error).message) 111 | return null 112 | } 113 | }) 114 | } 115 | } 116 | 117 | export default ConfigIpc 118 | -------------------------------------------------------------------------------- /src/global.d.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-unused-vars */ 2 | /* eslint-disable @typescript-eslint/no-namespace */ 3 | import { IpcRenderer } from 'electron' 4 | 5 | declare global { 6 | namespace NodeJS { 7 | interface Global { 8 | ipcRenderer: IpcRenderer 9 | } 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /src/interfaces.ts: -------------------------------------------------------------------------------- 1 | interface RectPos { 2 | top: number 3 | left: number 4 | bottom: number 5 | right: number 6 | } 7 | 8 | interface RenderedVideo { 9 | timestamp: number 10 | keyFrame: boolean 11 | data: Buffer | Array 12 | } 13 | 14 | class ASSStyle { 15 | name = 'Default' 16 | fontname = '方正准圆_GBK' 17 | fontsize = '75' 18 | primaryColour = '&H00FFFFFF' 19 | secondaryColour = '&HF0000000' 20 | outlineColour = '&H00193768' 21 | backColour = '&HF0000000' 22 | bold = false 23 | italic = false 24 | underline = false 25 | strikeOut = false 26 | scaleX = 100 27 | scaleY = 100 28 | spacing = 0 29 | angle = 0 30 | borderStyle = 1 31 | outline = 2 32 | shadow = 0 33 | alignment = 2 34 | marginL = 10 35 | marginR = 10 36 | marginV = 15 37 | encoding = 1 38 | } 39 | 40 | export { RectPos, RenderedVideo, ASSStyle } 41 | -------------------------------------------------------------------------------- /src/logger.ts: -------------------------------------------------------------------------------- 1 | import winston, { format } from 'winston' 2 | import fs from 'fs' 3 | 4 | const debugging = fs.existsSync('debug.log') 5 | 6 | const logger = winston.createLogger({ 7 | level: debugging ? 'debug' : 'info', 8 | format: format.combine( 9 | format.timestamp(), 10 | format.prettyPrint() 11 | ), 12 | transports: [ 13 | new winston.transports.Console(), 14 | new winston.transports.File({ 'filename': debugging ? 'debug.log' : 'log.log' }) 15 | ] 16 | }) 17 | 18 | export default logger 19 | -------------------------------------------------------------------------------- /src/main.ts: -------------------------------------------------------------------------------- 1 | import Vue from 'vue' 2 | import App from './App.vue' 3 | import router from './router' 4 | 5 | Vue.config.productionTip = false 6 | 7 | new Vue({ 8 | router, 9 | render: h => h(App) 10 | }).$mount('#app') 11 | -------------------------------------------------------------------------------- /src/preload.js: -------------------------------------------------------------------------------- 1 | import { ipcRenderer } from 'electron' 2 | 3 | process.once('loaded', () => { 4 | global.ipcRenderer = ipcRenderer 5 | }) 6 | -------------------------------------------------------------------------------- /src/router/index.ts: -------------------------------------------------------------------------------- 1 | import Vue from 'vue' 2 | import VueRouter, { RouteConfig } from 'vue-router' 3 | import Start from '../views/Start.vue' 4 | import MainWindow from '../views/MainWindow.vue' 5 | 6 | Vue.use(VueRouter) 7 | 8 | const routes: Array = [ 9 | { 10 | path: '/', 11 | name: 'Start', 12 | component: Start 13 | }, 14 | { 15 | path: '/MainWindow', 16 | name: 'MainWindow', 17 | component: MainWindow 18 | } 19 | ] 20 | 21 | const router = new VueRouter({ 22 | mode: process.env.IS_ELECTRON ? 'hash' : 'history', 23 | base: process.env.BASE_URL, 24 | routes 25 | }) 26 | 27 | export default router 28 | -------------------------------------------------------------------------------- /src/shims-tsx.d.ts: -------------------------------------------------------------------------------- 1 | import Vue, { VNode } from 'vue' 2 | 3 | declare global { 4 | namespace JSX { 5 | // tslint:disable no-empty-interface 6 | interface Element extends VNode {} 7 | // tslint:disable no-empty-interface 8 | interface ElementClass extends Vue {} 9 | interface IntrinsicElements { 10 | [elem: string]: any 11 | } 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /src/shims-vue.d.ts: -------------------------------------------------------------------------------- 1 | declare module '*.vue' { 2 | import Vue from 'vue' 3 | export default Vue 4 | } 5 | -------------------------------------------------------------------------------- /src/styles/simplebar.css: -------------------------------------------------------------------------------- 1 | [data-simplebar] { 2 | position: relative; 3 | flex-direction: column; 4 | flex-wrap: wrap; 5 | justify-content: flex-start; 6 | align-content: flex-start; 7 | align-items: flex-start; 8 | } 9 | 10 | .simplebar-wrapper { 11 | overflow: hidden; 12 | width: inherit; 13 | height: inherit; 14 | max-width: inherit; 15 | max-height: inherit; 16 | } 17 | 18 | .simplebar-mask { 19 | direction: inherit; 20 | position: absolute; 21 | overflow: hidden; 22 | padding: 0; 23 | margin: 0; 24 | left: 0; 25 | top: 0; 26 | bottom: 0; 27 | right: 0; 28 | width: auto !important; 29 | height: auto !important; 30 | z-index: 0; 31 | } 32 | 33 | .simplebar-offset { 34 | direction: inherit !important; 35 | box-sizing: inherit !important; 36 | resize: none !important; 37 | position: absolute; 38 | top: 0; 39 | left: 0; 40 | bottom: 0; 41 | right: 0; 42 | padding: 0; 43 | margin: 0; 44 | -webkit-overflow-scrolling: touch; 45 | } 46 | 47 | .simplebar-content-wrapper { 48 | direction: inherit; 49 | box-sizing: border-box !important; 50 | position: relative; 51 | display: block; 52 | height: 100%; /* Required for horizontal native scrollbar to not appear if parent is taller than natural height */ 53 | width: auto; 54 | max-width: 100%; /* Not required for horizontal scroll to trigger */ 55 | max-height: 100%; /* Needed for vertical scroll to trigger */ 56 | scrollbar-width: none; 57 | -ms-overflow-style: none; 58 | } 59 | 60 | .simplebar-content::before, 61 | .simplebar-content::after { 62 | content: ' '; 63 | display: table; 64 | } 65 | 66 | .simplebar-placeholder { 67 | max-height: 100%; 68 | max-width: 100%; 69 | width: 100%; 70 | pointer-events: none; 71 | } 72 | 73 | .simplebar-height-auto-observer-wrapper { 74 | box-sizing: inherit !important; 75 | height: 100%; 76 | width: 100%; 77 | max-width: 1px; 78 | position: relative; 79 | float: left; 80 | max-height: 1px; 81 | overflow: hidden; 82 | z-index: -1; 83 | padding: 0; 84 | margin: 0; 85 | pointer-events: none; 86 | flex-grow: inherit; 87 | flex-shrink: 0; 88 | flex-basis: 0; 89 | } 90 | 91 | .simplebar-height-auto-observer { 92 | box-sizing: inherit; 93 | display: block; 94 | opacity: 0; 95 | position: absolute; 96 | top: 0; 97 | left: 0; 98 | height: 1000%; 99 | width: 1000%; 100 | min-height: 1px; 101 | min-width: 1px; 102 | overflow: hidden; 103 | pointer-events: none; 104 | z-index: -1; 105 | } 106 | 107 | .simplebar-track { 108 | z-index: 1; 109 | position: absolute; 110 | right: 0; 111 | bottom: 0; 112 | pointer-events: none; 113 | overflow: hidden; 114 | } 115 | 116 | [data-simplebar].simplebar-dragging .simplebar-content { 117 | pointer-events: none; 118 | user-select: none; 119 | -webkit-user-select: none; 120 | } 121 | 122 | [data-simplebar].simplebar-dragging .simplebar-track { 123 | pointer-events: all; 124 | } 125 | 126 | .simplebar-scrollbar { 127 | position: absolute; 128 | left: 0; 129 | right: 0; 130 | min-height: 10px; 131 | } 132 | 133 | .simplebar-scrollbar::before { 134 | position: absolute; 135 | content: ''; 136 | background: black; 137 | border-radius: 7px; 138 | left: 2px; 139 | right: 2px; 140 | opacity: 0; 141 | transition: opacity 0.2s linear; 142 | } 143 | 144 | .simplebar-scrollbar.simplebar-visible::before { 145 | /* When hovered, remove all transitions from drag handle */ 146 | opacity: 0.5; 147 | transition: opacity 0s linear; 148 | } 149 | 150 | .simplebar-track.simplebar-vertical { 151 | top: 0; 152 | width: 11px; 153 | } 154 | 155 | .simplebar-track.simplebar-vertical .simplebar-scrollbar::before { 156 | top: 2px; 157 | bottom: 2px; 158 | } 159 | 160 | .simplebar-track.simplebar-horizontal { 161 | left: 0; 162 | height: 11px; 163 | } 164 | 165 | .simplebar-track.simplebar-horizontal .simplebar-scrollbar::before { 166 | height: 100%; 167 | left: 2px; 168 | right: 2px; 169 | } 170 | 171 | .simplebar-track.simplebar-horizontal .simplebar-scrollbar { 172 | right: auto; 173 | left: 0; 174 | top: 2px; 175 | height: 7px; 176 | min-height: 0; 177 | min-width: 10px; 178 | width: auto; 179 | } 180 | 181 | /* Rtl support */ 182 | [data-simplebar-direction='rtl'] .simplebar-track.simplebar-vertical { 183 | right: auto; 184 | left: 0; 185 | } 186 | 187 | .hs-dummy-scrollbar-size { 188 | direction: rtl; 189 | position: fixed; 190 | opacity: 0; 191 | visibility: hidden; 192 | height: 500px; 193 | width: 500px; 194 | overflow-y: hidden; 195 | overflow-x: scroll; 196 | } 197 | 198 | .simplebar-hide-scrollbar { 199 | position: fixed; 200 | left: 0; 201 | visibility: hidden; 202 | overflow-y: scroll; 203 | scrollbar-width: none; 204 | -ms-overflow-style: none; 205 | } 206 | 207 | .simplebar-content-wrapper::-webkit-scrollbar, 208 | .simplebar-hide-scrollbar::-webkit-scrollbar { 209 | width: 0; 210 | height: 0; 211 | } 212 | -------------------------------------------------------------------------------- /src/views/MainWindow.vue: -------------------------------------------------------------------------------- 1 | 13 | 14 | 95 | 96 | 139 | -------------------------------------------------------------------------------- /src/views/Start.vue: -------------------------------------------------------------------------------- 1 | 75 | 76 | 378 | 379 | 605 | -------------------------------------------------------------------------------- /tests/ASSGenerator.unit.spec.ts: -------------------------------------------------------------------------------- 1 | import ASSGenerator from '@/backends/ASSGenerator' 2 | import { ASSStyle } from '@/interfaces' 3 | import { ISubtitleInfo, SubtitleInfo } from '@/SubtitleInfo' 4 | import { VideoProperties } from '@/VideoProperties' 5 | import fs_ from 'fs' 6 | const fs = fs_.promises 7 | 8 | describe('ASSGenerator.ts', () => { 9 | it('generate test', async () => { 10 | const ass = new ASSGenerator() 11 | ass.applyStyle(new ASSStyle()) 12 | const subtitleInfos = (JSON.parse(await fs.readFile('tests/files/subtitleInfos.json', { encoding: 'utf-8' })) as Array) 13 | .map((element: ISubtitleInfo) => new SubtitleInfo(element)) 14 | const assContent = ass.generate(subtitleInfos, new VideoProperties(0, [], [], 1920, 1080)) 15 | expect(assContent).toMatchSnapshot() 16 | }, 100000) 17 | }) 18 | -------------------------------------------------------------------------------- /tests/TorchOCR.integration.spec.ts: -------------------------------------------------------------------------------- 1 | import TorchOCR from '@/backends/TorchOCR' 2 | 3 | describe('TorchOCR.ts', () => { 4 | it('Intergration test', async () => { 5 | const torchOCR = new TorchOCR() 6 | await torchOCR.initOCR() 7 | await torchOCR.initVideoPlayer('tests/files/sample.mp4') 8 | const rawImg = await torchOCR.readRawFrame(10) 9 | expect(rawImg).not.toBeNull() 10 | const inputTensor = torchOCR.bufferToImgTensor([rawImg as Buffer], 970, 30) 11 | 12 | const result = torchOCR.ocrParse(await torchOCR.ocrV3Forward(inputTensor)) 13 | expect(result).toMatchSnapshot() 14 | inputTensor.free() 15 | }, 100000) 16 | }) 17 | -------------------------------------------------------------------------------- /tests/TorchOCR.time.ts: -------------------------------------------------------------------------------- 1 | import TorchOCR from '@/backends/TorchOCR' 2 | import { performance } from 'perf_hooks' 3 | import { Tensor } from 'torch-js' 4 | 5 | void (async () => { 6 | try { 7 | let tStart = performance.now() 8 | const torchOCR = new TorchOCR() 9 | await torchOCR.initOCR() 10 | await torchOCR.initVideoPlayer('D:/Projects/freyja-sub-ocr-electron/tests/files/sample.mp4') 11 | console.log(`Init torchOCR: ${(performance.now() - tStart)}ms`) 12 | 13 | const step = 20 14 | let tensorDataPromise = new Promise(resolve => resolve(null)) 15 | let ocrPromise = new Promise(resolve => resolve(null)) 16 | const ocrPromiseBuffer = [ocrPromise, ocrPromise, ocrPromise, ocrPromise] 17 | const tLoop = performance.now() 18 | for (let frame = 0; frame < 800; frame += step) { 19 | tensorDataPromise = Promise.all([tensorDataPromise, ocrPromiseBuffer[0]]).then(async () => { 20 | tStart = performance.now() 21 | const rawImg: Array = [] 22 | // eslint-disable-next-line @typescript-eslint/no-unused-vars 23 | for (const i of Array(step).keys()) { 24 | const frame = await torchOCR.readRawFrame(undefined) 25 | if (frame === null) { 26 | continue 27 | } 28 | rawImg.push(frame) 29 | } 30 | const inputTensor = torchOCR.bufferToImgTensor(rawImg, 600) 31 | console.log(`Copy Tensor data (img) ${frame}: ${(performance.now() - tStart)}ms`) 32 | return inputTensor 33 | }) 34 | 35 | ocrPromise = Promise.all([ocrPromise, tensorDataPromise]).then(async (values) => { 36 | const inputTensor = values[1] 37 | if (inputTensor === null) return null 38 | 39 | tStart = performance.now() 40 | // eslint-disable-next-line @typescript-eslint/no-unused-vars 41 | const ocrResults = torchOCR.ocrParse(await torchOCR.ocrV3Forward(inputTensor)) 42 | console.log(`Inferance OCR ${frame}: ${(performance.now() - tStart)}ms`) 43 | inputTensor.free() 44 | }) 45 | void ocrPromiseBuffer.shift() 46 | ocrPromiseBuffer.push(ocrPromise) 47 | } 48 | await Promise.all([tensorDataPromise, ocrPromise]) 49 | console.log(`\nTotal loop: ${(performance.now() - tLoop)}ms`) 50 | } catch (e) { 51 | console.log(e) 52 | } 53 | })() 54 | -------------------------------------------------------------------------------- /tests/TorchOCR.unit.spec.ts: -------------------------------------------------------------------------------- 1 | import TorchOCR from '@/backends/TorchOCR' 2 | 3 | describe('TorchOCR.ts', () => { 4 | it('Init OCR Module', async () => { 5 | const torchOCR = new TorchOCR() 6 | await torchOCR.initOCR() 7 | expect(torchOCR).toBeDefined() 8 | }) 9 | it('Init VideoPlayer', async () => { 10 | const torchOCR = new TorchOCR() 11 | const result = await torchOCR.initVideoPlayer('tests/files/sample.mp4') 12 | expect(result).toMatchSnapshot() 13 | }) 14 | it('Render raw frame', async () => { 15 | const torchOCR = new TorchOCR() 16 | await torchOCR.initVideoPlayer('tests/files/sample.mp4') 17 | const result = await torchOCR.readRawFrame(10) 18 | expect(result?.length).toMatchSnapshot() 19 | // Total samples are too large, only record first 20 value 20 | expect(result?.slice(0, 20)).toMatchSnapshot() 21 | }, 100000) 22 | }) 23 | -------------------------------------------------------------------------------- /tests/TorchOCRTaskScheduler.unit.spec.ts: -------------------------------------------------------------------------------- 1 | import TorchOCRWorker from '@/backends/TorchOCRWorker' 2 | import Config from '@/config' 3 | 4 | describe('TorchOCRTaskScheduler.ts', () => { 5 | it('task test', async () => { 6 | Config.cropTop = 970 7 | Config.cropBottom = 30 8 | 9 | const worker = new TorchOCRWorker() 10 | await worker.init('tests/files/sample.mp4') 11 | let result = await worker.start() 12 | expect(result.length).toBeGreaterThan(0) 13 | result = worker.cleanUpSubtitleInfos() 14 | expect(result.length).toBeGreaterThan(0) 15 | const noIdResult = result.map(t => { 16 | return { 17 | text: t.text, 18 | startFrame: t.startFrame, 19 | endFrame: t.endFrame 20 | } 21 | }) 22 | expect(noIdResult).toMatchSnapshot() 23 | }, 100000) 24 | }) 25 | -------------------------------------------------------------------------------- /tests/__snapshots__/ASSGenerator.unit.spec.ts.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`ASSGenerator.ts generate test 1`] = ` 4 | "[Script Info] 5 | ScriptType: v4.00+ 6 | PlayResX: 1920 7 | PlayResY: 1080 8 | [V4+ Styles] 9 | Format: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding 10 | Style: Default,方正准圆_GBK,75,&H00FFFFFF,&HF0000000,&H00193768,&HF0000000,0,0,0,0,100,100,0,0,1,2,0,2,10,10,15,1 11 | [Events] 12 | Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text 13 | Dialogue: 0,00:00:00.08,00:00:01.12,Default,,0,0,0,,我是神野惠 14 | Dialogue: 0,00:00:01.45,00:00:03.42,Default,,0,0,0,,是和泉纱雾的同班同学 15 | Dialogue: 0,00:00:03.50,00:00:05.92,Default,,0,0,0,,你为什么要让她进我们家啊 16 | Dialogue: 0,00:00:06.00,00:00:10.13,Default,,0,0,0,,跟我差不多岁数的女孩子都最喜欢鸡鸡了 17 | Dialogue: 0,00:00:10.26,00:00:11.59,Default,,0,0,0,,最…最喜欢? 18 | Dialogue: 0,00:00:11.84,00:00:15.51,Default,,0,0,0,,名字就叫「把小和泉从房间里拖出来!」同盟 19 | Dialogue: 0,00:00:15.72,00:00:16.80,Default,,0,0,0,,不出去 不出去 20 | Dialogue: 0,00:00:16.80,00:00:18.26,Default,,0,0,0,,我说不出去就是不出去 21 | Dialogue: 0,00:00:18.39,00:00:21.39,Default,,0,0,0,,哥哥想让妹妹变成什么样? 22 | Dialogue: 0,00:00:22.14,00:00:23.39,Default,,0,0,0,,和泉政宗? 23 | Dialogue: 0,00:00:23.81,00:00:25.81,Default,,0,0,0,,居然是如此年轻的女孩子? 24 | Dialogue: 0,00:00:26.19,00:00:29.98,Default,,0,0,0,,我的下一个作品要请情色漫画老师来担任插画师" 25 | `; 26 | -------------------------------------------------------------------------------- /tests/__snapshots__/TorchOCR.integration.spec.ts.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`TorchOCR.ts Intergration test 1`] = ` 4 | Array [ 5 | "我是神野惠", 6 | ] 7 | `; 8 | -------------------------------------------------------------------------------- /tests/__snapshots__/TorchOCR.unit.spec.ts.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`TorchOCR.ts Init VideoPlayer 1`] = ` 4 | VideoProperties { 5 | "duration": 864864, 6 | "fps": Array [ 7 | 24000, 8 | 1001, 9 | ], 10 | "height": 1080, 11 | "timeBase": Array [ 12 | 1, 13 | 24000, 14 | ], 15 | "width": 1920, 16 | } 17 | `; 18 | 19 | exports[`TorchOCR.ts Render raw frame 1`] = `6220800`; 20 | 21 | exports[`TorchOCR.ts Render raw frame 2`] = ` 22 | Object { 23 | "data": Array [ 24 | 207, 25 | 250, 26 | 203, 27 | 207, 28 | 250, 29 | 203, 30 | 207, 31 | 250, 32 | 203, 33 | 207, 34 | 250, 35 | 203, 36 | 207, 37 | 250, 38 | 203, 39 | 207, 40 | 250, 41 | 203, 42 | 209, 43 | 252, 44 | ], 45 | "type": "Buffer", 46 | } 47 | `; 48 | -------------------------------------------------------------------------------- /tests/__snapshots__/TorchOCRTaskScheduler.unit.spec.ts.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`TorchOCRTaskScheduler.ts task test 1`] = ` 4 | Array [ 5 | Object { 6 | "endFrame": 27, 7 | "startFrame": 2, 8 | "text": "我是神野惠", 9 | }, 10 | Object { 11 | "endFrame": 82, 12 | "startFrame": 35, 13 | "text": "是和泉纱雾的同班同学", 14 | }, 15 | Object { 16 | "endFrame": 142, 17 | "startFrame": 84, 18 | "text": "你为什么要让她进我们家啊", 19 | }, 20 | Object { 21 | "endFrame": 243, 22 | "startFrame": 144, 23 | "text": "跟我差不多岁数的女孩子都最喜欢鸡鸡了", 24 | }, 25 | Object { 26 | "endFrame": 278, 27 | "startFrame": 246, 28 | "text": "最…最喜欢?", 29 | }, 30 | Object { 31 | "endFrame": 372, 32 | "startFrame": 284, 33 | "text": "名字就叫「把小和泉从房间里拖出来!」同盟", 34 | }, 35 | Object { 36 | "endFrame": 403, 37 | "startFrame": 377, 38 | "text": "不出去 不出去", 39 | }, 40 | Object { 41 | "endFrame": 438, 42 | "startFrame": 403, 43 | "text": "我说不出去就是不出去", 44 | }, 45 | Object { 46 | "endFrame": 513, 47 | "startFrame": 441, 48 | "text": "哥哥想让妹妹变成什么样?", 49 | }, 50 | Object { 51 | "endFrame": 561, 52 | "startFrame": 531, 53 | "text": "和泉政宗?", 54 | }, 55 | Object { 56 | "endFrame": 619, 57 | "startFrame": 571, 58 | "text": "居然是如此年轻的女孩子?", 59 | }, 60 | Object { 61 | "endFrame": 719, 62 | "startFrame": 628, 63 | "text": "我的下一个作品要请情色漫画老师来担任插画师", 64 | }, 65 | ] 66 | `; 67 | -------------------------------------------------------------------------------- /tests/files/sample.mp4: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/freyjaSubOCR/freyja-sub-ocr-electron/5cdbde4a8c414cc9ce88db1fef4dd951f6f823f1/tests/files/sample.mp4 -------------------------------------------------------------------------------- /tests/files/subtitleInfos.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "endFrame": 27, 4 | "endTime": "00:00:01.12", 5 | "startFrame": 2, 6 | "startTime": "00:00:00.08", 7 | "texts": [ 8 | "我是神野惠" 9 | ] 10 | }, 11 | { 12 | "endFrame": 82, 13 | "endTime": "00:00:03.42", 14 | "startFrame": 35, 15 | "startTime": "00:00:01.45", 16 | "texts": [ 17 | "是和泉纱雾的同班同学" 18 | ] 19 | }, 20 | { 21 | "endFrame": 142, 22 | "endTime": "00:00:05.92", 23 | "startFrame": 84, 24 | "startTime": "00:00:03.50", 25 | "texts": [ 26 | "你为什么要让她进我们家啊" 27 | ] 28 | }, 29 | { 30 | "endFrame": 243, 31 | "endTime": "00:00:10.13", 32 | "startFrame": 144, 33 | "startTime": "00:00:06.00", 34 | "texts": [ 35 | "跟我差不多岁数的女孩子都最喜欢鸡鸡了" 36 | ] 37 | }, 38 | { 39 | "endFrame": 278, 40 | "endTime": "00:00:11.59", 41 | "startFrame": 246, 42 | "startTime": "00:00:10.26", 43 | "texts": [ 44 | "最…最喜欢?" 45 | ] 46 | }, 47 | { 48 | "endFrame": 372, 49 | "endTime": "00:00:15.51", 50 | "startFrame": 284, 51 | "startTime": "00:00:11.84", 52 | "texts": [ 53 | "名字就叫「把小和泉从房间里拖出来!」同盟" 54 | ] 55 | }, 56 | { 57 | "endFrame": 403, 58 | "endTime": "00:00:16.80", 59 | "startFrame": 377, 60 | "startTime": "00:00:15.72", 61 | "texts": [ 62 | "不出去 不出去" 63 | ] 64 | }, 65 | { 66 | "endFrame": 438, 67 | "endTime": "00:00:18.26", 68 | "startFrame": 403, 69 | "startTime": "00:00:16.80", 70 | "texts": [ 71 | "我说不出去就是不出去" 72 | ] 73 | }, 74 | { 75 | "endFrame": 513, 76 | "endTime": "00:00:21.39", 77 | "startFrame": 441, 78 | "startTime": "00:00:18.39", 79 | "texts": [ 80 | "哥哥想让妹妹变成什么样?" 81 | ] 82 | }, 83 | { 84 | "endFrame": 561, 85 | "endTime": "00:00:23.39", 86 | "startFrame": 531, 87 | "startTime": "00:00:22.14", 88 | "texts": [ 89 | "和泉政宗?" 90 | ] 91 | }, 92 | { 93 | "endFrame": 619, 94 | "endTime": "00:00:25.81", 95 | "startFrame": 571, 96 | "startTime": "00:00:23.81", 97 | "texts": [ 98 | "居然是如此年轻的女孩子?" 99 | ] 100 | }, 101 | { 102 | "endFrame": 719, 103 | "endTime": "00:00:29.98", 104 | "startFrame": 628, 105 | "startTime": "00:00:26.19", 106 | "texts": [ 107 | "我的下一个作品要请情色漫画老师来担任插画师" 108 | ] 109 | } 110 | ] -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es2019", 4 | "module": "esnext", 5 | "outDir" : "dist_timetest", 6 | "strict": true, 7 | "jsx": "preserve", 8 | "importHelpers": true, 9 | "moduleResolution": "node", 10 | "experimentalDecorators": true, 11 | "skipLibCheck": true, 12 | "esModuleInterop": true, 13 | "allowSyntheticDefaultImports": true, 14 | "sourceMap": true, 15 | "baseUrl": ".", 16 | "types": [ 17 | "webpack-env", 18 | "jest" 19 | ], 20 | "paths": { 21 | "@/*": [ 22 | "src/*" 23 | ] 24 | }, 25 | "lib": [ 26 | "esnext", 27 | "dom", 28 | "dom.iterable", 29 | "scripthost" 30 | ] 31 | }, 32 | "include": [ 33 | "src/**/*.ts", 34 | "src/**/*.tsx", 35 | "src/**/*.vue", 36 | "tests/**/*.ts", 37 | "tests/**/*.tsx" 38 | ], 39 | "exclude": [ 40 | "node_modules" 41 | ] 42 | } -------------------------------------------------------------------------------- /vue.config.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-var-requires */ 2 | const StyleLintPlugin = require('stylelint-webpack-plugin') 3 | const ThreadsPlugin = require('threads-plugin') 4 | const NodeTargetPlugin = require('webpack/lib/node/NodeTargetPlugin') 5 | const ExternalsPlugin = require('webpack/lib/ExternalsPlugin') 6 | /* eslint-enable @typescript-eslint/no-var-requires */ 7 | 8 | module.exports = { 9 | configureWebpack: { 10 | devtool: 'source-map', 11 | plugins: [ 12 | new StyleLintPlugin({ 13 | files: ['src/**/*.{vue,htm,html,css,sss,less,scss,sass}'] 14 | }) 15 | ] 16 | }, 17 | pluginOptions: { 18 | electronBuilder: { 19 | preload: { 'preload': 'src/preload.js' }, 20 | mainProcessWatch: ['src/backends/*.ts', 'src/*.ts', 'src/preload.js'], 21 | externals: ['segfault-handler'], 22 | chainWebpackMainProcess: (config) => { 23 | config.plugin('threads').use(ThreadsPlugin, [{ 24 | plugins: [ 25 | new NodeTargetPlugin(), 26 | new ExternalsPlugin('commonjs', ['bindings', 'beamcoder', 'torch-js']) 27 | ] 28 | }]) 29 | } 30 | } 31 | } 32 | } 33 | --------------------------------------------------------------------------------