├── .deepsource.toml ├── .eslintignore ├── .eslintrc ├── .github └── workflows │ ├── deploy-docs.yml │ ├── lint-and-test.yml │ └── release-please.yml ├── .gitignore ├── .prettierignore ├── .prettierrc ├── .vscode └── settings.json ├── .whitesource ├── CHANGELOG.md ├── LICENSE.txt ├── README.md ├── app.js ├── bin └── subsrt.js ├── jest.config.cjs ├── lib ├── format │ ├── ass.ts │ ├── index.ts │ ├── json.ts │ ├── lrc.ts │ ├── sbv.ts │ ├── smi.ts │ ├── srt.ts │ ├── ssa.ts │ ├── sub.ts │ ├── types │ │ ├── smi.ts │ │ └── sub.ts │ └── vtt.ts ├── handler.ts ├── subsrt.ts └── types │ ├── handler.ts │ └── subsrt.ts ├── package.json ├── pnpm-lock.yaml ├── renovate.json ├── subsrt.bat ├── test ├── build.test.ts ├── convert.test.ts ├── customFormat.test.ts ├── detect.test.ts ├── fixtures │ ├── sample.ass │ ├── sample.json │ ├── sample.lrc │ ├── sample.sbv │ ├── sample.smi │ ├── sample.srt │ ├── sample.ssa │ ├── sample.sub │ └── sample.vtt ├── parse.test.ts ├── resync.test.ts └── time.test.ts ├── tsconfig.eslint.json └── tsconfig.json /.deepsource.toml: -------------------------------------------------------------------------------- 1 | version = 1 2 | 3 | [[analyzers]] 4 | name = "test-coverage" 5 | 6 | [[analyzers]] 7 | name = "secrets" 8 | 9 | [[analyzers]] 10 | name = "javascript" 11 | 12 | [analyzers.meta] 13 | environment = ["nodejs"] 14 | 15 | [[transformers]] 16 | name = "prettier" 17 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | node_modules 3 | /dist 4 | pnpm-lock.yaml 5 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "env": { 3 | "es2017": true, 4 | "es2020": true, 5 | "es6": true, 6 | "node": true 7 | }, 8 | "extends": ["eslint:recommended", "plugin:import/recommended", "plugin:regexp/recommended", "prettier"], 9 | "overrides": [ 10 | { 11 | "env": { 12 | "jest/globals": true 13 | }, 14 | "extends": ["plugin:jest/recommended", "plugin:jest/style"], 15 | "files": ["test/*.test.ts"], 16 | "plugins": ["jest"] 17 | }, 18 | { 19 | "extends": [ 20 | "plugin:@typescript-eslint/recommended-type-checked", 21 | "plugin:@typescript-eslint/stylistic-type-checked", 22 | "plugin:import/typescript" 23 | ], 24 | "files": ["**/*.ts"] 25 | } 26 | ], 27 | "parser": "@typescript-eslint/parser", 28 | "parserOptions": { 29 | "ecmaVersion": "latest", 30 | "project": "./tsconfig.eslint.json", 31 | "sourceType": "module" 32 | }, 33 | "plugins": ["@typescript-eslint", "regexp", "eslint-plugin-tsdoc"], 34 | "rules": { 35 | "@typescript-eslint/no-unused-vars": [ 36 | "error", 37 | { 38 | "argsIgnorePattern": "^_", 39 | "varsIgnorePattern": "^_" 40 | } 41 | ], 42 | "curly": "error", 43 | "dot-notation": "error", 44 | "eqeqeq": "error", 45 | "import/order": [ 46 | "error", 47 | { 48 | "alphabetize": { 49 | "caseInsensitive": true, 50 | "order": "asc" 51 | }, 52 | "groups": ["builtin", "external", "internal", "parent", "sibling", "index"], 53 | "newlines-between": "always" 54 | } 55 | ], 56 | "no-else-return": "error", 57 | "no-empty": [ 58 | "error", 59 | { 60 | "allowEmptyCatch": true 61 | } 62 | ], 63 | "no-extra-bind": "error", 64 | "no-labels": "error", 65 | "no-lone-blocks": "error", 66 | "no-loop-func": "error", 67 | "no-new-func": "error", 68 | "no-new-object": "error", 69 | "no-new-wrappers": "error", 70 | "no-param-reassign": "error", 71 | "no-redeclare": "error", 72 | "no-template-curly-in-string": "error", 73 | "no-unreachable": "error", 74 | "no-useless-constructor": "error", 75 | "prefer-arrow-callback": "error", 76 | "prefer-exponentiation-operator": "error", 77 | "prefer-template": "error", 78 | "quotes": [ 79 | "error", 80 | "double", 81 | { 82 | "avoidEscape": true 83 | } 84 | ], 85 | "regexp/no-super-linear-backtracking": "warn", 86 | "require-atomic-updates": "error", 87 | "require-await": "error", 88 | "sort-imports": [ 89 | "error", 90 | { 91 | "ignoreCase": true, 92 | "ignoreDeclarationSort": true 93 | } 94 | ], 95 | "tsdoc/syntax": "warn" 96 | }, 97 | "settings": { 98 | "import/parsers": { 99 | "@typescript-eslint/parser": [".ts", ".tsx"] 100 | }, 101 | "import/resolver": { 102 | "typescript": { 103 | "alwaysTryTypes": true 104 | } 105 | } 106 | } 107 | } 108 | -------------------------------------------------------------------------------- /.github/workflows/deploy-docs.yml: -------------------------------------------------------------------------------- 1 | name: Build and deploy docs 2 | on: 3 | push: 4 | branches: 5 | - main 6 | paths: 7 | - "**.js" 8 | - "**.ts" 9 | 10 | permissions: 11 | pages: write 12 | id-token: write 13 | 14 | concurrency: 15 | group: "pages" 16 | cancel-in-progress: false 17 | 18 | jobs: 19 | build: 20 | name: Build docs 21 | runs-on: ubuntu-latest 22 | steps: 23 | - name: Check out Git repository 24 | uses: actions/checkout@v4 25 | 26 | - name: Set up pnpm 27 | uses: pnpm/action-setup@v2 28 | with: 29 | version: latest 30 | 31 | - name: Set up Node.js 32 | uses: actions/setup-node@v4 33 | with: 34 | node-version: latest 35 | cache: "pnpm" 36 | 37 | - name: Setup Pages 38 | uses: actions/configure-pages@v5 39 | 40 | - name: Install Node.js dependencies 41 | run: pnpm install --frozen-lockfile 42 | 43 | - name: Build docs 44 | run: pnpm run docs 45 | 46 | - name: Upload artifact 47 | uses: actions/upload-pages-artifact@v3 48 | with: 49 | path: docs/ 50 | 51 | deploy: 52 | name: Deploy docs 53 | environment: 54 | name: github-pages 55 | url: ${{ steps.deployment.outputs.page_url }} 56 | runs-on: ubuntu-latest 57 | needs: build 58 | steps: 59 | - name: Deploy to GitHub Pages 60 | id: deployment 61 | uses: actions/deploy-pages@v4 62 | -------------------------------------------------------------------------------- /.github/workflows/lint-and-test.yml: -------------------------------------------------------------------------------- 1 | name: Lint and test 2 | 3 | on: 4 | push: 5 | pull_request: 6 | 7 | jobs: 8 | run-linters-and-tests: 9 | name: Run linters and tests 10 | runs-on: ubuntu-latest 11 | 12 | steps: 13 | - name: Check out Git repository 14 | uses: actions/checkout@v4 15 | 16 | - name: Set up pnpm 17 | uses: pnpm/action-setup@v2 18 | with: 19 | version: latest 20 | 21 | - name: Set up Node.js 22 | uses: actions/setup-node@v4 23 | with: 24 | node-version: latest 25 | cache: "pnpm" 26 | 27 | - name: Install Node.js dependencies 28 | run: pnpm install --frozen-lockfile 29 | 30 | - name: Run linters 31 | uses: wearerequired/lint-action@v2 32 | with: 33 | continue_on_error: false 34 | eslint: true 35 | eslint_extensions: js,ts 36 | prettier: true 37 | 38 | - name: Run tests 39 | run: pnpm run testCoverage 40 | 41 | - name: Report test coverage to DeepSource 42 | run: | 43 | curl https://deepsource.io/cli | sh 44 | 45 | ./bin/deepsource report --analyzer test-coverage --key javascript --value-file ./coverage/cobertura-coverage.xml 46 | env: 47 | DEEPSOURCE_DSN: ${{ secrets.DEEPSOURCE_DSN }} 48 | -------------------------------------------------------------------------------- /.github/workflows/release-please.yml: -------------------------------------------------------------------------------- 1 | on: 2 | push: 3 | branches: 4 | - main 5 | 6 | permissions: 7 | contents: write 8 | pull-requests: write 9 | 10 | name: Release Please 11 | 12 | jobs: 13 | release-please: 14 | name: Release Please 15 | runs-on: ubuntu-latest 16 | steps: 17 | - name: Run Release Please 18 | id: release 19 | uses: google-github-actions/release-please-action@v3 20 | with: 21 | release-type: node 22 | package-name: subsrt-ts 23 | 24 | # The logic below handles the npm publication: 25 | - name: Check out Git repository 26 | uses: actions/checkout@v4 27 | # these if statements ensure that a publication only occurs when 28 | # a new release is created: 29 | if: ${{ steps.release.outputs.release_created }} 30 | 31 | - name: Set up pnpm 32 | uses: pnpm/action-setup@v2 33 | with: 34 | version: latest 35 | 36 | - name: Set up Node.js 37 | uses: actions/setup-node@v4 38 | with: 39 | node-version: latest 40 | registry-url: "https://registry.npmjs.org" 41 | cache: "pnpm" 42 | if: ${{ steps.release.outputs.release_created }} 43 | 44 | - name: Install Node.js dependencies 45 | run: pnpm install --frozen-lockfile 46 | if: ${{ steps.release.outputs.release_created }} 47 | 48 | - name: Publish to npm 49 | run: pnpm publish 50 | env: 51 | NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} 52 | if: ${{ steps.release.outputs.release_created }} 53 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # File created using '.gitignore Generator' for Visual Studio Code: https://bit.ly/vscode-gig 2 | # Created by https://www.toptal.com/developers/gitignore/api/visualstudiocode,macos,node 3 | # Edit at https://www.toptal.com/developers/gitignore?templates=visualstudiocode,macos,node 4 | 5 | ### macOS ### 6 | # General 7 | .DS_Store 8 | .AppleDouble 9 | .LSOverride 10 | 11 | # Icon must end with two \r 12 | Icon 13 | 14 | 15 | # Thumbnails 16 | ._* 17 | 18 | # Files that might appear in the root of a volume 19 | .DocumentRevisions-V100 20 | .fseventsd 21 | .Spotlight-V100 22 | .TemporaryItems 23 | .Trashes 24 | .VolumeIcon.icns 25 | .com.apple.timemachine.donotpresent 26 | 27 | # Directories potentially created on remote AFP share 28 | .AppleDB 29 | .AppleDesktop 30 | Network Trash Folder 31 | Temporary Items 32 | .apdisk 33 | 34 | ### macOS Patch ### 35 | # iCloud generated files 36 | *.icloud 37 | 38 | ### Node ### 39 | # Logs 40 | logs 41 | *.log 42 | npm-debug.log* 43 | yarn-debug.log* 44 | yarn-error.log* 45 | lerna-debug.log* 46 | .pnpm-debug.log* 47 | 48 | # Diagnostic reports (https://nodejs.org/api/report.html) 49 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json 50 | 51 | # Runtime data 52 | pids 53 | *.pid 54 | *.seed 55 | *.pid.lock 56 | 57 | # Directory for instrumented libs generated by jscoverage/JSCover 58 | lib-cov 59 | 60 | # Coverage directory used by tools like istanbul 61 | coverage 62 | *.lcov 63 | 64 | # nyc test coverage 65 | .nyc_output 66 | 67 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 68 | .grunt 69 | 70 | # Bower dependency directory (https://bower.io/) 71 | bower_components 72 | 73 | # node-waf configuration 74 | .lock-wscript 75 | 76 | # Compiled binary addons (https://nodejs.org/api/addons.html) 77 | build/Release 78 | 79 | # Dependency directories 80 | node_modules/ 81 | jspm_packages/ 82 | 83 | # Snowpack dependency directory (https://snowpack.dev/) 84 | web_modules/ 85 | 86 | # TypeScript cache 87 | *.tsbuildinfo 88 | 89 | # Optional npm cache directory 90 | .npm 91 | 92 | # Optional eslint cache 93 | .eslintcache 94 | 95 | # Optional stylelint cache 96 | .stylelintcache 97 | 98 | # Microbundle cache 99 | .rpt2_cache/ 100 | .rts2_cache_cjs/ 101 | .rts2_cache_es/ 102 | .rts2_cache_umd/ 103 | 104 | # Optional REPL history 105 | .node_repl_history 106 | 107 | # Output of 'npm pack' 108 | *.tgz 109 | 110 | # Yarn Integrity file 111 | .yarn-integrity 112 | 113 | # dotenv environment variable files 114 | .env 115 | .env.development.local 116 | .env.test.local 117 | .env.production.local 118 | .env.local 119 | 120 | # parcel-bundler cache (https://parceljs.org/) 121 | .cache 122 | .parcel-cache 123 | 124 | # Next.js build output 125 | .next 126 | out 127 | 128 | # Nuxt.js build / generate output 129 | .nuxt 130 | dist 131 | 132 | # Gatsby files 133 | .cache/ 134 | # Comment in the public line in if your project uses Gatsby and not Next.js 135 | # https://nextjs.org/blog/next-9-1#public-directory-support 136 | # public 137 | 138 | # vuepress build output 139 | .vuepress/dist 140 | 141 | # vuepress v2.x temp and cache directory 142 | .temp 143 | 144 | # Docusaurus cache and generated files 145 | .docusaurus 146 | 147 | # Serverless directories 148 | .serverless/ 149 | 150 | # FuseBox cache 151 | .fusebox/ 152 | 153 | # DynamoDB Local files 154 | .dynamodb/ 155 | 156 | # TernJS port file 157 | .tern-port 158 | 159 | # Stores VSCode versions used for testing VSCode extensions 160 | .vscode-test 161 | 162 | # yarn v2 163 | .yarn/cache 164 | .yarn/unplugged 165 | .yarn/build-state.yml 166 | .yarn/install-state.gz 167 | .pnp.* 168 | 169 | ### Node Patch ### 170 | # Serverless Webpack directories 171 | .webpack/ 172 | 173 | # Optional stylelint cache 174 | 175 | # SvelteKit build / generate output 176 | .svelte-kit 177 | 178 | ### VisualStudioCode ### 179 | .vscode/* 180 | !.vscode/settings.json 181 | !.vscode/tasks.json 182 | !.vscode/launch.json 183 | !.vscode/extensions.json 184 | !.vscode/*.code-snippets 185 | 186 | # Local History for Visual Studio Code 187 | .history/ 188 | 189 | # Built Visual Studio Code Extensions 190 | *.vsix 191 | 192 | ### VisualStudioCode Patch ### 193 | # Ignore all local history of files 194 | .history 195 | .ionide 196 | 197 | # Support for Project snippet scope 198 | .vscode/*.code-snippets 199 | 200 | # Ignore code-workspaces 201 | *.code-workspace 202 | 203 | # End of https://www.toptal.com/developers/gitignore/api/visualstudiocode,macos,node 204 | 205 | # Custom rules (everything added below won't be overriden by 'Generate .gitignore File' if you use 'Update' option) 206 | 207 | # Logs 208 | logs 209 | *.log 210 | npm-debug.log* 211 | 212 | # Runtime data 213 | pids 214 | *.pid 215 | *.seed 216 | 217 | # Directory for instrumented libs generated by jscoverage/JSCover 218 | lib-cov 219 | 220 | # Coverage directory used by tools like istanbul 221 | coverage 222 | 223 | # nyc test coverage 224 | .nyc_output 225 | 226 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 227 | .grunt 228 | 229 | # node-waf configuration 230 | .lock-wscript 231 | 232 | # Compiled binary addons (http://nodejs.org/api/addons.html) 233 | build/Release 234 | 235 | # Dependency directories 236 | node_modules 237 | jspm_packages 238 | 239 | # npm 240 | package-lock.json 241 | 242 | # Optional npm cache directory 243 | .npm 244 | 245 | # Optional REPL history 246 | .node_repl_history 247 | 248 | # Notes 249 | .docs 250 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | node_modules 3 | /dist 4 | pnpm-lock.yaml 5 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "printWidth": 150, 3 | "tabWidth": 4, 4 | "trailingComma": "all", 5 | "overrides": [ 6 | { 7 | "files": ".*rc", 8 | "options": { 9 | "trailingComma": "none" 10 | } 11 | } 12 | ] 13 | } 14 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "[json]": { 3 | "editor.defaultFormatter": "esbenp.prettier-vscode" 4 | }, 5 | "[jsonc]": { 6 | "editor.defaultFormatter": "esbenp.prettier-vscode" 7 | }, 8 | "[markdown]": { 9 | "editor.defaultFormatter": "esbenp.prettier-vscode" 10 | }, 11 | "editor.codeActionsOnSave": ["source.fixAll.eslint"], 12 | "editor.defaultFormatter": "esbenp.prettier-vscode", 13 | "editor.formatOnSave": true, 14 | "markdownlint.config": { 15 | "MD007": { 16 | "indent": 4 17 | }, 18 | "MD030": { 19 | "ul_multi": 3, 20 | "ul_single": 3 21 | } 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /.whitesource: -------------------------------------------------------------------------------- 1 | { 2 | "scanSettings": { 3 | "baseBranches": [] 4 | }, 5 | "checkRunSettings": { 6 | "vulnerableCheckRunConclusionLevel": "failure", 7 | "displayMode": "diff", 8 | "useMendCheckNames": true 9 | }, 10 | "issueSettings": { 11 | "minSeverityLevel": "LOW", 12 | "issueType": "DEPENDENCY" 13 | } 14 | } -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | ## [2.1.2](https://github.com/rakuzen25/subsrt-ts/compare/v2.1.1...v2.1.2) (2023-10-17) 4 | 5 | ### Bug Fixes 6 | 7 | - apply new eslint recommendations ([3b2fb33](https://github.com/rakuzen25/subsrt-ts/commit/3b2fb338d8d59652702721305bd4aa2ca97bdca9)) 8 | 9 | ## [2.1.1](https://github.com/rakuzen25/subsrt-ts/compare/2.1.0...v2.1.1) (2023-04-27) 10 | 11 | ### Bug Fixes 12 | 13 | - **vtt:** multiline support ([#3](https://github.com/rakuzen25/subsrt-ts/issues/3)) ([702c8a4](https://github.com/rakuzen25/subsrt-ts/commit/702c8a4c9284bf8d5a6d30f926858948a80d5ae4)) 14 | - **vtt:** remove replacement of new lines in caption content ([#5](https://github.com/rakuzen25/subsrt-ts/issues/5)) ([7cdf968](https://github.com/rakuzen25/subsrt-ts/commit/7cdf968b28f0c6f72e296c48b579e1906b8f8ad8)) 15 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2017 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | # subsrt-ts 4 | 5 | 6 | 7 | [![npm](https://img.shields.io/npm/v/subsrt-ts?logo=npm)](https://www.npmjs.com/package/subsrt-ts) 8 | [![npm](https://img.shields.io/npm/dt/subsrt-ts?logo=npm)](https://www.npmjs.com/package/subsrt-ts) 9 | [![Linting](https://img.shields.io/github/actions/workflow/status/rakuzen25/subsrt-ts/lint-and-test.yml?label=Lint%20and%20test&logo=github)](https://github.com/rakuzen25/subsrt-ts/actions/workflows/lint-and-test.yml) 10 | [![DeepSource](https://app.deepsource.com/gh/rakuzen25/subsrt-ts.svg/?label=active+issues&show_trend=true&token=VSYfXgJAchNJCwJhEqwVhMlh)](https://app.deepsource.com/gh/rakuzen25/subsrt-ts/?ref=repository-badge) 11 | 12 |

13 | Docs 14 | · 15 | npm package 16 |

17 | 18 | Subtitle JavaScript library and command line tool with no dependencies. 19 | 20 | This is a rewrite of the original [subsrt](https://www.npmjs.com/package/subsrt) package in TypeScript and using ESM syntax. 21 | 22 |
23 | Table of Contents 24 |
    25 |
  1. Getting started
  2. 26 |
  3. Supported subtitle formats
  4. 27 |
  5. Command line arguments
  6. 28 |
  7. 29 | Using in JavaScript 30 | 40 |
  8. 41 |
  9. Source code
  10. 42 |
  11. License
  12. 43 |
44 |
45 | 46 | ## Getting started 47 | 48 | Install the module 49 | 50 | ```console 51 | npm install -g subsrt 52 | ``` 53 | 54 | Command line 55 | 56 | ```console 57 | subsrt --help 58 | subsrt convert sample.sub sample.srt 59 | ``` 60 | 61 | Using as Node.js library 62 | 63 | ```javascript 64 | import subsrt from "subsrt"; 65 | 66 | // MicroDVD (.sub) content 67 | const sub = "{14975}{104000}Hi, my name is..."; 68 | 69 | // Convert to SubRip (.srt) content 70 | const srt = subsrt.convert(sub, { format: "srt", fps: 25 }); 71 | ``` 72 | 73 |

(back to top)

74 | 75 | ## Supported subtitle formats 76 | 77 | - [MicroDVD SUB](https://en.wikipedia.org/wiki/MicroDVD) (.sub) 78 | - [SubRip](https://en.wikipedia.org/wiki/SubRip) (.srt) 79 | - [SubViewer](https://en.wikipedia.org/wiki/SubViewer) (.sbv) 80 | - [WebVTT](https://w3c.github.io/webvtt/) (.vtt) 81 | - [SubStation Alpha](https://en.wikipedia.org/wiki/SubStation_Alpha) (.ssa and .ass) 82 | - [SAMI](https://en.wikipedia.org/wiki/SAMI) (.smi) aka Synchronized Accessible Media Interchange 83 | - [LRC](https://en.wikipedia.org/wiki/LRC_%28file_format%29) (.lrc) aka LyRiCs 84 | - JSON (.json) 85 | 86 |

(back to top)

87 | 88 | ## Command line arguments 89 | 90 | ```text 91 | Usage: 92 | subsrt [command] [options] 93 | 94 | Commands: 95 | list List supported formats 96 | parse [src] [json] Parse a subtitle file 97 | build [json] [dst] Create a subtitle file from captions 98 | detect [src] Detect subtitle file format, if supported 99 | resync [src] [dst] Resync FPS or shift time (+/- offset) 100 | convert [src] [dst] Converts a subtitle format 101 | 102 | Options: 103 | --help Print this message 104 | --eol [chars] End of line chars, e.g. \r\n 105 | --fps [fps] Frames per second for .sub format 106 | --offset [time] Resync time shift offset in ms 107 | --format [ext] Subtitle format to convert/build/parse 108 | --verbose Enable detailed logging 109 | --version Print version number 110 | 111 | Examples: 112 | subsrt parse sample.sbv 113 | subsrt parse sample.srt output.json 114 | subsrt parse sample.sub --fps 30 115 | subsrt build input.json output.vtt 116 | subsrt build input.json --format sbv 117 | subsrt detect unknown.txt 118 | subsrt convert sample.srt sample.vtt 119 | subsrt convert --offset -250 sample.srt sample.ssa 120 | subsrt resync --offset +3000 input.srt output.srt 121 | subsrt resync --fps 25-30 input.sub output.sub 122 | ``` 123 | 124 |

(back to top)

125 | 126 | ## Using in JavaScript 127 | 128 | The Node.js library supports converting, parsing and building subtitle file formats. 129 | Subtitles can also be resynced by shifting time offset, extending the duration or changing FPS. 130 | 131 | ### List supported formats 132 | 133 | ```javascript 134 | import subsrt from "subsrt"; 135 | 136 | const list = subsrt.list(); 137 | 138 | console.log(list.join(", ")); 139 | // vtt, lrc, smi, ssa, ass, sub, srt, sbv, json 140 | ``` 141 | 142 | Format name is used in conversion options, e.g. `{ format: "srt" }` 143 | 144 | Use `subsrt.format.name` to access functions directly 145 | 146 | ```javascript 147 | import subsrt from "subsrt"; 148 | const handler = subsrt.format.srt; 149 | // handler = { name: 'srt', helper: [object], parse: [function], build: [function] } 150 | ``` 151 | 152 | To implement a new subtitle format handler do the following 153 | 154 | ```javascript 155 | import subsrt from "subsrt"; 156 | subsrt.format.my = { 157 | // "my" is the format name 158 | name: "my", 159 | parse: (content, options) => { 160 | const captions = []; 161 | // ... 162 | return captions; 163 | }, 164 | build: (captions, options) => { 165 | const content = ""; 166 | // ... 167 | return content; 168 | }, 169 | detect: (content) => { 170 | if (content.indexOf("my") > 0) { 171 | return true; // Recognized 172 | } 173 | }, 174 | }; 175 | ``` 176 | 177 |

(back to top)

178 | 179 | ### Detect 180 | 181 | Recognizes format by content 182 | 183 | ```javascript 184 | import subsrt from "subsrt"; 185 | 186 | let content = ""; 187 | content += "5" + "\r\n"; 188 | content += "00:00:16,700 --> 00:00:21,480" + "\r\n"; 189 | content += "Okay, so we have all the ingredients laid out here" + "\r\n"; 190 | 191 | const format = subsrt.detect(content); 192 | // format = "srt" 193 | ``` 194 | 195 |

(back to top)

196 | 197 | ### Parse 198 | 199 | Parse a subtitle file 200 | 201 | ```javascript 202 | import { readFileSync } from "fs"; 203 | 204 | import subsrt from "subsrt"; 205 | 206 | // Read a .srt file 207 | const content = readFileSync("sample.srt", "utf8"); 208 | 209 | // Parse the content 210 | const options = { verbose: true }; 211 | const captions = subsrt.parse(content, options); 212 | 213 | // Output to console 214 | console.log(captions); 215 | ``` 216 | 217 | Example of output 218 | 219 | ```json 220 | [ 221 | { 222 | "type": "caption", // "caption" or "meta" 223 | "index": 1, // Caption id, usually a sequential number 224 | "start": 599, // Time to show caption in milliseconds 225 | "end": 4160, // Time to hide caption in milliseconds 226 | "duration": 3561, // Calculated caption duration 227 | "content": ">> ALICE: Hi, my name is Alice Miller and this is John Brown", // Formatted content 228 | "text": "Hi, my name is Alice Miller and this is John Brown" // Plain text content 229 | }, 230 | { 231 | "type": "caption", 232 | "index": 2, 233 | "start": 4160, 234 | "end": 6770, 235 | "duration": 2610, 236 | "content": ">> JOHN: and we're the owners of Miller Bakery.", 237 | "text": "and we're the owners of Miller Bakery." 238 | } 239 | // ... 240 | ] 241 | ``` 242 | 243 | List of options 244 | 245 | - `format`: explicitly select a parser, values: `sub`, `srt`, `sbv`, `vtt`, `lrc`, `smi`, `ssa`, `ass`, `json`, default is undefined to auto detect 246 | - `verbose`: set to true for extra messages, console only, default: `false` 247 | - `eol`: end of line character(s), default: `\r\n` 248 | - `fps`: frames per second, `sub` format only 249 | - `preserveSpaces`: keep white space lines, `smi` format only 250 | 251 |

(back to top)

252 | 253 | ### Build 254 | 255 | Build a subtitle file 256 | 257 | ```javascript 258 | import { writeFileSync } from "fs"; 259 | 260 | import subsrt from "subsrt"; 261 | 262 | // Sample captions 263 | const captions = [ 264 | { 265 | start: 599, // Time to show caption in milliseconds 266 | end: 4160, // Time to hide caption in milliseconds 267 | text: "Hi, my name is Alice Miller and this is John Brown", // Plain text content 268 | }, 269 | { 270 | start: 4160, 271 | end: 6770, 272 | text: "and we're the owners of Miller Bakery.", 273 | }, 274 | ]; 275 | 276 | // Build the WebVTT content 277 | const options = { format: "vtt" }; 278 | const content = subsrt.build(captions, options); 279 | 280 | // Write content to .vtt file 281 | writeFileSync("generated.vtt", content); 282 | ``` 283 | 284 | List of options 285 | 286 | - `format`: required, output subtitle format, values: `sub`, `srt`, `sbv`, `vtt`, `lrc`, `smi`, `ssa`, `ass`, `json`, default: `srt` 287 | - `verbose`: set to true for extra messages, console only, default: `false` 288 | - `fps`: frames per second, `sub` format only 289 | - `closeTags`: set to true to close tags, `smi` format only 290 | 291 |

(back to top)

292 | 293 | ### Convert 294 | 295 | Using a single action to convert from one to another subtitle format 296 | 297 | ```javascript 298 | import { readFileSync, writeFileSync } from "fs"; 299 | 300 | import subsrt from "subsrt"; 301 | 302 | // Read a .srt file 303 | const srt = readFileSync("sample.srt", "utf8"); 304 | 305 | // Convert .srt to .sbv 306 | const sbv = subsrt.convert(srt, { format: "sbv" }); 307 | 308 | // Write content to .sbv file 309 | writeFileSync("converted.sbv", sbv); 310 | ``` 311 | 312 | List of options 313 | 314 | - `format`: required, output subtitle format, values: `sub`, `srt`, `sbv`, `vtt`, `lrc`, `smi`, `ssa`, `ass`, `json`, default: `srt` 315 | - `verbose`: set to true for extra messages, console only, default: `false` 316 | - `eol`: end of line character(s), default: `\r\n` 317 | - `fps`: frames per second, `sub` format only 318 | - `resync`: resync options, see below 319 | 320 |

(back to top)

321 | 322 | ### Timeshift (+/- offset) 323 | 324 | An example to make an extra 3-second delay 325 | 326 | ```javascript 327 | import { readFileSync } from "fs"; 328 | 329 | import subsrt from "subsrt"; 330 | 331 | // Read a .srt file 332 | const content = readFileSync("sample.srt", "utf8"); 333 | const captions = subsrt.parse(content); 334 | 335 | // Returns updated captions 336 | const resynced = subsrt.resync(captions, { offset: 3000 }); 337 | ``` 338 | 339 | Use minus sign to display captions earlier 340 | 341 | ```javascript 342 | const resynced = subsrt.resync(captions, { offset: -3000 }); 343 | ``` 344 | 345 |

(back to top)

346 | 347 | ### Change FPS 348 | 349 | The .sub format has captions saved in frame units. To shift from 25 FPS to 30 FPS do the following 350 | 351 | ```javascript 352 | import { readFileSync } from "fs"; 353 | 354 | import subsrt from "subsrt"; 355 | 356 | // Read a .sub file 357 | const content = readFileSync("sample.sub", "utf8"); 358 | const captions = subsrt.parse(content, { fps: 25 }); // The .sub file content is saved in 25 FPS units 359 | 360 | // Convert to 30 FPS, make sure to set 'frame' to true to convert frames instead of time 361 | const resynced = subsrt.resync(content, { ratio: 30 / 25, frame: true }); 362 | ``` 363 | 364 |

(back to top)

365 | 366 | ### Advanced resync options 367 | 368 | Extend caption duration by 500 ms 369 | 370 | ```javascript 371 | // Argument 'a' is an array with two elements: [ start, end ] 372 | // Return shifted [ start, end ] values 373 | const resynced = subsrt.resync(content, (a) => [a[0], a[1] + 500]); 374 | ``` 375 | 376 |

(back to top)

377 | 378 | ## Source code 379 | 380 | Download the source code from the GitHub repository. 381 | 382 | Install required packages if any 383 | 384 | ```console 385 | pnpm install 386 | ``` 387 | 388 | Run the unit tests 389 | 390 | ```console 391 | pnpm test 392 | ``` 393 | 394 |

(back to top)

395 | 396 | ## License 397 | 398 | Distributed under the MIT License. See [LICENSE.txt](LICENSE.txt) for more information. 399 | 400 |

(back to top)

401 | -------------------------------------------------------------------------------- /app.js: -------------------------------------------------------------------------------- 1 | import { readFileSync, writeFileSync } from "fs"; 2 | import { extname } from "path"; 3 | 4 | // eslint-disable-next-line import/no-unresolved 5 | import { build as _build, convert as _convert, detect as _detect, list as _list, parse as _parse } from "./dist/subsrt.js"; 6 | 7 | const config = { 8 | verbose: process.env.NODE_VERBOSE === "true" || process.env.NODE_VERBOSE === "1", 9 | }; 10 | 11 | // Prints help message 12 | const help = () => { 13 | console.log("Usage:"); 14 | console.log(" subsrt [command] [options]"); 15 | console.log(""); 16 | console.log("Commands:"); 17 | console.log(" list List supported formats"); 18 | console.log(" parse [src] [json] Parse a subtitle file"); 19 | console.log(" build [json] [dst] Create a subtitle file from captions"); 20 | console.log(" detect [src] Detect subtitle file format, if supported"); 21 | console.log(" resync [src] [dst] Resync FPS or shift time (+/- offset)"); 22 | console.log(" convert [src] [dst] Converts a subtitle format"); 23 | console.log(""); 24 | console.log("Options:"); 25 | console.log(" --help Print this message"); 26 | console.log(" --eol [chars] End of line chars, e.g. \\r\\n"); 27 | console.log(" --fps [fps] Frames per second for .sub format"); 28 | console.log(" --offset [time] Resync time shift offset in ms"); 29 | console.log(" --format [ext] Subtitle format to convert/build/parse"); 30 | console.log(" --verbose Enable detailed logging"); 31 | console.log(" --version Print version number"); 32 | console.log(""); 33 | console.log("Examples:"); 34 | console.log(" subsrt parse sample.sbv"); 35 | console.log(" subsrt parse sample.srt output.json"); 36 | console.log(" subsrt parse sample.sub --fps 30"); 37 | console.log(" subsrt build input.json output.vtt"); 38 | console.log(" subsrt build input.json --format sbv"); 39 | console.log(" subsrt detect unknown.txt"); 40 | console.log(" subsrt convert sample.srt sample.vtt"); 41 | console.log(" subsrt convert --offset -250 sample.srt sample.ssa"); 42 | console.log(" subsrt resync --offset +3000 input.srt output.srt"); 43 | console.log(" subsrt resync --fps 25-30 input.sub output.sub"); 44 | }; 45 | 46 | // Command line arguments 47 | const args = process.argv.slice(2); 48 | for (let i = 0; i < args.length; i++) { 49 | switch (args[i]) { 50 | case "list": 51 | case "parse": 52 | case "build": 53 | case "detect": 54 | case "resync": 55 | case "convert": 56 | if (config.command) { 57 | throw new Error(`Cannot run more than one command: ${args[i]}`); 58 | } 59 | config.command = args[i]; 60 | break; 61 | 62 | case "--eol": 63 | config.eol = args[++i]; 64 | if (config.eol) { 65 | config.eol = config.eol.replace(/\\r/g, "\r").replace(/\\n/g, "\n"); 66 | } 67 | break; 68 | 69 | case "--fps": { 70 | let fps = args[++i]; 71 | if (fps.indexOf("-") > 0) { 72 | fps = fps.split("-"); 73 | config.fpsFrom = parseFloat(fps[0]); 74 | config.fpsTo = parseFloat(fps[1]); 75 | } else { 76 | config.fps = parseFloat(fps); 77 | } 78 | break; 79 | } 80 | 81 | case "--offset": 82 | config.offset = parseInt(args[++i], 10); 83 | break; 84 | 85 | case "--format": 86 | config.format = args[++i]; 87 | break; 88 | 89 | case "--help": 90 | help(); 91 | config.exit = true; 92 | break; 93 | 94 | case "--verbose": 95 | config.verbose = true; 96 | break; 97 | 98 | case "--version": 99 | console.log((await import("./package.json")).version); 100 | config.exit = true; 101 | break; 102 | 103 | default: 104 | if (!config.src) { 105 | config.src = args[i]; 106 | continue; 107 | } 108 | if (!config.dst) { 109 | config.dst = args[i]; 110 | continue; 111 | } 112 | throw new Error(`Unknown command line argument: ${args[i]}`); 113 | } 114 | } 115 | 116 | const commands = { 117 | list: () => { 118 | console.log(_list().join(", ")); 119 | }, 120 | parse: () => { 121 | const content = readFileSync(config.src, "utf8"); 122 | 123 | const options = { 124 | verbose: config.verbose, 125 | }; 126 | if (config.fps) { 127 | options.fps = config.fps; 128 | } 129 | 130 | const captions = _parse(content, options); 131 | const json = JSON.stringify(captions, " ", 2); 132 | if (config.dst) { 133 | writeFileSync(config.dst, json); 134 | } else { 135 | console.log(json); 136 | } 137 | }, 138 | build: () => { 139 | const json = readFileSync(config.src, "utf8"); 140 | const captions = JSON.parse(json); 141 | if (!config.format && config.dst) { 142 | const ext = extname(config.dst); 143 | config.format = ext.replace(/\./, "").toLowerCase(); 144 | } 145 | 146 | const options = { 147 | verbose: config.verbose, 148 | format: config.format, 149 | }; 150 | if (config.fps) { 151 | options.fps = config.fps; 152 | } 153 | if (config.eol) { 154 | options.eol = config.eol; 155 | } 156 | 157 | const content = _build(captions, options); 158 | if (config.dst) { 159 | writeFileSync(config.dst, content); 160 | } else { 161 | console.log(content); 162 | } 163 | }, 164 | detect: () => { 165 | const content = readFileSync(config.src, "utf8"); 166 | const format = _detect(content); 167 | console.log(format ?? "unknown"); 168 | }, 169 | resync: () => { 170 | const options = {}; 171 | if (config.offset) { 172 | options.offset = config.offset; 173 | } 174 | if (config.fpsFrom && config.fpsTo) { 175 | options.ratio = config.fpsTo / config.fpsFrom; 176 | options.frame = true; 177 | } 178 | if (config.fps) { 179 | options.fps = config.fps; 180 | } 181 | if (config.fpsFrom) { 182 | options.fps = config.fpsFrom; 183 | options.frame = true; 184 | } 185 | config.resync = options; 186 | commands.convert(); 187 | }, 188 | convert: () => { 189 | const content = readFileSync(config.src, "utf8"); 190 | if (!config.format && config.dst) { 191 | const ext = extname(config.dst); 192 | config.format = ext.replace(/\./, "").toLowerCase(); 193 | } 194 | 195 | const options = { 196 | verbose: config.verbose, 197 | format: config.format, 198 | }; 199 | if (config.fps) { 200 | options.fps = config.fps; 201 | } 202 | if (config.eol) { 203 | options.eol = config.eol; 204 | } 205 | if (config.resync) { 206 | options.resync = config.resync; 207 | } 208 | 209 | const converted = _convert(content, options); 210 | if (config.dst) { 211 | writeFileSync(config.dst, converted); 212 | } else { 213 | console.log(converted); 214 | } 215 | }, 216 | }; 217 | 218 | if (!config.exit) { 219 | const func = commands[config.command]; 220 | if (typeof func === "function") { 221 | func(); 222 | } else { 223 | help(); 224 | } 225 | } 226 | -------------------------------------------------------------------------------- /bin/subsrt.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | import "../app.js"; 3 | -------------------------------------------------------------------------------- /jest.config.cjs: -------------------------------------------------------------------------------- 1 | /** @type {import("ts-jest").JestConfigWithTsJest} */ 2 | module.exports = { 3 | // [...] 4 | preset: "ts-jest/presets/default-esm", // or other ESM presets 5 | moduleNameMapper: { 6 | "^(\\.{1,2}/.*)\\.js$": "$1", 7 | }, 8 | transform: { 9 | // '^.+\\.[tj]sx?$' to process js/ts with `ts-jest` 10 | // '^.+\\.m?[tj]sx?$' to process js/ts/mjs/mts with `ts-jest` 11 | "^.+\\.tsx?$": [ 12 | "ts-jest", 13 | { 14 | useESM: true, 15 | }, 16 | ], 17 | }, 18 | }; 19 | -------------------------------------------------------------------------------- /lib/format/ass.ts: -------------------------------------------------------------------------------- 1 | import { buildHandler } from "../handler.js"; 2 | 3 | const FORMAT_NAME = "ass"; 4 | 5 | // Compatible format 6 | import { build, detect, helper, parse } from "./ssa.js"; 7 | 8 | export default buildHandler({ name: FORMAT_NAME, build, detect, helper, parse }); 9 | export { FORMAT_NAME as name, build, detect, helper, parse }; 10 | -------------------------------------------------------------------------------- /lib/format/index.ts: -------------------------------------------------------------------------------- 1 | import { SubsrtFormats } from "../types/subsrt.js"; 2 | 3 | import ass from "./ass.js"; 4 | import json from "./json.js"; 5 | import lrc from "./lrc.js"; 6 | import sbv from "./sbv.js"; 7 | import smi from "./smi.js"; 8 | import srt from "./srt.js"; 9 | import ssa from "./ssa.js"; 10 | import sub from "./sub.js"; 11 | import vtt from "./vtt.js"; 12 | 13 | const formats = { 14 | vtt, 15 | lrc, 16 | smi, 17 | ssa, 18 | ass, 19 | sub, 20 | srt, 21 | sbv, 22 | json, 23 | } as SubsrtFormats; 24 | 25 | export default formats; 26 | -------------------------------------------------------------------------------- /lib/format/json.ts: -------------------------------------------------------------------------------- 1 | import { buildHandler } from "../handler.js"; 2 | import { BuildOptions, Caption, ParseOptions } from "../types/handler.js"; 3 | 4 | const FORMAT_NAME = "json"; 5 | 6 | /** 7 | * Parses captions in JSON format. 8 | * @param content - The subtitle content 9 | * @param _options - Parse options 10 | * @returns Parsed captions 11 | */ 12 | const parse = (content: string, _options: ParseOptions) => JSON.parse(content) as Caption[]; 13 | 14 | /** 15 | * Builds captions in JSON format. 16 | * @param captions - The captions to build 17 | * @param _options - Build options 18 | * @returns The built captions string in JSON format 19 | */ 20 | const build = (captions: Caption[], _options: BuildOptions) => JSON.stringify(captions, undefined, 2); 21 | 22 | /** 23 | * Detects whether the content is in JSON format. 24 | * @param content - The subtitle content 25 | * @returns Whether the content is in JSON format 26 | */ 27 | const detect = (content: string) => { 28 | /* 29 | [ 30 | { ... } 31 | ] 32 | */ 33 | // return /^\[[\s\r\n]*\{[\s\S]*\}[\s\r\n]*\]$/g.test(content); 34 | try { 35 | const res = JSON.parse(content) as unknown; 36 | return Array.isArray(res) && res.length > 0 && typeof res[0] === "object"; 37 | } catch (e) { 38 | return false; 39 | } 40 | }; 41 | 42 | export default buildHandler({ name: FORMAT_NAME, build, detect, parse }); 43 | export { FORMAT_NAME as name, build, detect, parse }; 44 | -------------------------------------------------------------------------------- /lib/format/lrc.ts: -------------------------------------------------------------------------------- 1 | import { buildHandler } from "../handler.js"; 2 | import { BuildOptions, Caption, ContentCaption, MetaCaption, ParseOptions } from "../types/handler.js"; 3 | 4 | const FORMAT_NAME = "lrc"; 5 | 6 | const helper = { 7 | /** 8 | * Converts a time string in format of mm:ss.ff or mm:ss,ff to milliseconds. 9 | * @param s - The time string to convert 10 | * @throws TypeError If the time string is invalid 11 | * @returns Milliseconds 12 | */ 13 | toMilliseconds: (s: string) => { 14 | const match = /^\s*(\d+):(\d{1,2})(?:[.,](\d{1,3}))?\s*$/.exec(s); 15 | if (!match) { 16 | throw new TypeError(`Invalid time format: ${s}`); 17 | } 18 | const mm = parseInt(match[1], 10); 19 | const ss = parseInt(match[2], 10); 20 | const ff = match[3] ? parseInt(match[3], 10) : 0; 21 | const ms = mm * 60 * 1000 + ss * 1000 + ff * 10; 22 | return ms; 23 | }, 24 | /** 25 | * Converts milliseconds to a time string in format of mm:ss.ff. 26 | * @param ms - Milliseconds 27 | * @returns Time string in format of mm:ss.ff 28 | */ 29 | toTimeString: (ms: number) => { 30 | const mm = Math.floor(ms / 1000 / 60); 31 | const ss = Math.floor((ms / 1000) % 60); 32 | const ff = Math.floor(ms % 1000); 33 | const time = `${(mm < 10 ? "0" : "") + mm}:${ss < 10 ? "0" : ""}${ss}.${ff < 100 ? "0" : ""}${ff < 10 ? "0" : Math.floor(ff / 10)}`; 34 | return time; 35 | }, 36 | }; 37 | 38 | /** 39 | * Parses captions in LRC format. 40 | * @param content - The subtitle content 41 | * @param options - Parse options 42 | * @returns Parsed captions 43 | * @see https://en.wikipedia.org/wiki/LRC_%28file_format%29 44 | */ 45 | const parse = (content: string, options: ParseOptions) => { 46 | let prev = null; 47 | const captions = []; 48 | // const eol = options.eol || "\r\n"; 49 | const parts = content.split(/\r?\n/); 50 | for (const part of parts) { 51 | if (!part || part.trim().length === 0) { 52 | continue; 53 | } 54 | 55 | // LRC content 56 | const regex = /^\[(\d{1,2}:\d{1,2}(?:[.,]\d{1,3})?)\](.*)(?:\r?\n)*$/; 57 | const match = regex.exec(part); 58 | if (match) { 59 | const caption = {} as ContentCaption; 60 | caption.type = "caption"; 61 | caption.start = helper.toMilliseconds(match[1]); 62 | caption.end = caption.start + 2000; 63 | caption.duration = caption.end - caption.start; 64 | caption.content = match[2]; 65 | caption.text = caption.content; 66 | captions.push(caption); 67 | 68 | // Update previous 69 | if (prev) { 70 | prev.end = caption.start; 71 | prev.duration = prev.end - prev.start; 72 | } 73 | prev = caption; 74 | continue; 75 | } 76 | 77 | // LRC meta 78 | const meta = /^\[(\w+):([^\]]*)\](?:\r?\n)*$/.exec(part); 79 | if (meta) { 80 | const caption = {} as MetaCaption; 81 | caption.type = "meta"; 82 | caption.tag = meta[1]; 83 | if (meta[2]) { 84 | caption.data = meta[2]; 85 | } 86 | captions.push(caption); 87 | continue; 88 | } 89 | 90 | if (options.verbose) { 91 | console.warn("Unknown part", part); 92 | } 93 | } 94 | return captions; 95 | }; 96 | 97 | /** 98 | * Builds captions in LRC format. 99 | * @param captions - The captions to build 100 | * @param options - Build options 101 | * @returns The built captions string in LRC format 102 | * @see https://en.wikipedia.org/wiki/LRC_%28file_format%29 103 | */ 104 | const build = (captions: Caption[], options: BuildOptions) => { 105 | let content = ""; 106 | let lyrics = false; 107 | const eol = options.eol ?? "\r\n"; 108 | for (const caption of captions) { 109 | if (caption.type === "meta") { 110 | if (caption.tag && caption.data && typeof caption.data === "string") { 111 | content += `[${caption.tag}:${caption.data.replace(/[\r\n]+/g, " ")}]${eol}`; 112 | } 113 | continue; 114 | } 115 | 116 | if (!caption.type || caption.type === "caption") { 117 | if (!lyrics) { 118 | content += eol; //New line when lyrics start 119 | lyrics = true; 120 | } 121 | content += `[${helper.toTimeString(caption.start)}]${caption.text}${eol}`; 122 | continue; 123 | } 124 | 125 | if (options.verbose) { 126 | console.log("SKIP:", caption); 127 | } 128 | } 129 | 130 | return content; 131 | }; 132 | 133 | /** 134 | * Detects whether the content is in LRC format. 135 | * @param content - The subtitle content 136 | * @returns Format name if detected, or null if not detected 137 | */ 138 | const detect = (content: string) => { 139 | /* 140 | [04:48.28]Sister, perfume? 141 | */ 142 | return /\r?\n\[\d+:\d{1,2}(?:[.,]\d{1,3})?\].*\r?\n/.test(content); 143 | }; 144 | 145 | export default buildHandler({ name: FORMAT_NAME, build, detect, helper, parse }); 146 | export { FORMAT_NAME as name, build, detect, helper, parse }; 147 | -------------------------------------------------------------------------------- /lib/format/sbv.ts: -------------------------------------------------------------------------------- 1 | import { buildHandler } from "../handler.js"; 2 | import { BuildOptions, Caption, ContentCaption, ParseOptions } from "../types/handler.js"; 3 | 4 | const FORMAT_NAME = "sbv"; 5 | 6 | const helper = { 7 | /** 8 | * Converts a time string in format of hh:mm:ss.sss or hh:mm:ss,sss to milliseconds. 9 | * @param s - The time string to convert 10 | * @throws TypeError If the time string is invalid 11 | * @returns Milliseconds 12 | */ 13 | toMilliseconds: (s: string) => { 14 | const match = /^\s*(\d{1,2}):(\d{1,2}):(\d{1,2})(?:[.,](\d{1,3}))?\s*$/.exec(s); 15 | if (!match) { 16 | throw new TypeError(`Invalid time format: ${s}`); 17 | } 18 | const hh = parseInt(match[1], 10); 19 | const mm = parseInt(match[2], 10); 20 | const ss = parseInt(match[3], 10); 21 | const ff = match[4] ? parseInt(match[4], 10) : 0; 22 | const ms = hh * 3600 * 1000 + mm * 60 * 1000 + ss * 1000 + ff; 23 | return ms; 24 | }, 25 | 26 | /** 27 | * Converts milliseconds to a time string in format of hh:mm:ss.sss. 28 | * @param ms - Milliseconds 29 | * @returns Time string in format of hh:mm:ss.sss 30 | */ 31 | toTimeString: (ms: number) => { 32 | const hh = Math.floor(ms / 1000 / 3600); 33 | const mm = Math.floor((ms / 1000 / 60) % 60); 34 | const ss = Math.floor((ms / 1000) % 60); 35 | const ff = Math.floor(ms % 1000); 36 | const time = `${(hh < 10 ? "0" : "") + hh}:${mm < 10 ? "0" : ""}${mm}:${ss < 10 ? "0" : ""}${ss}.${ff < 100 ? "0" : ""}${ 37 | ff < 10 ? "0" : "" 38 | }${ff}`; 39 | return time; 40 | }, 41 | }; 42 | 43 | /** 44 | * Parses captions in SubViewer format (.sbv). 45 | * @param content - The subtitle content 46 | * @param options - Parse options 47 | * @returns Parsed captions 48 | */ 49 | const parse = (content: string, options: ParseOptions) => { 50 | const captions = []; 51 | const eol = options.eol ?? "\r\n"; 52 | const parts = content.split(/\r?\n\s*\n/); 53 | for (const part of parts) { 54 | const regex = /^(\d{1,2}:\d{1,2}:\d{1,2}(?:[.,]\d{1,3})?)\s*[,;]\s*(\d{1,2}:\d{1,2}:\d{1,2}(?:[.,]\d{1,3})?)\r?\n([\s\S]*)$/; 55 | const match = regex.exec(part); 56 | if (match) { 57 | const caption = {} as ContentCaption; 58 | caption.type = "caption"; 59 | caption.start = helper.toMilliseconds(match[1]); 60 | caption.end = helper.toMilliseconds(match[2]); 61 | caption.duration = caption.end - caption.start; 62 | const lines = match[3].split(/\[br\]|\r?\n/i); 63 | caption.content = lines.join(eol); 64 | caption.text = caption.content.replace(/>>[^:]+:\s*/g, ""); // >> SPEAKER NAME: 65 | captions.push(caption); 66 | continue; 67 | } 68 | 69 | if (options.verbose) { 70 | console.warn("Unknown part", part); 71 | } 72 | } 73 | return captions; 74 | }; 75 | 76 | /** 77 | * Builds captions in SubViewer format (.sbv). 78 | * @param captions - The captions to build 79 | * @param options - Build options 80 | * @returns The built captions string in SubViewer format 81 | */ 82 | const build = (captions: Caption[], options: BuildOptions) => { 83 | let content = ""; 84 | const eol = options.eol ?? "\r\n"; 85 | for (const caption of captions) { 86 | if (!caption.type || caption.type === "caption") { 87 | content += `${helper.toTimeString(caption.start)},${helper.toTimeString(caption.end)}${eol}`; 88 | content += caption.text + eol; 89 | content += eol; 90 | continue; 91 | } 92 | if (options.verbose) { 93 | console.log("SKIP:", caption); 94 | } 95 | } 96 | 97 | return content; 98 | }; 99 | 100 | /** 101 | * Detects whether the content is in SubViewer format. 102 | * @param content - The subtitle content 103 | * @returns Whether the subtitle format is SubViewer 104 | */ 105 | const detect = (content: string) => { 106 | /* 107 | 00:04:48.280,00:04:50.510 108 | Sister, perfume? 109 | */ 110 | return /\d{1,2}:\d{1,2}:\d{1,2}(?:[.,]\d{1,3})?\s*[,;]\s*\d{1,2}:\d{1,2}:\d{1,2}(?:[.,]\d{1,3})?/.test(content); 111 | }; 112 | 113 | export default buildHandler({ name: FORMAT_NAME, build, detect, helper, parse }); 114 | export { FORMAT_NAME as name, build, detect, helper, parse }; 115 | -------------------------------------------------------------------------------- /lib/format/smi.ts: -------------------------------------------------------------------------------- 1 | import { buildHandler } from "../handler.js"; 2 | import { Caption, ContentCaption, MetaCaption } from "../types/handler.js"; 3 | 4 | import { SMIBuildOptions, SMIParseOptions } from "./types/smi.js"; 5 | 6 | const FORMAT_NAME = "smi"; 7 | 8 | const helper = { 9 | /** 10 | * Encodes a string to be used in XML. 11 | * @param text - The text to be encoded 12 | * @returns The HTML-encoded string 13 | */ 14 | htmlEncode: (text: string) => 15 | text 16 | .replace(/&/g, "&") 17 | .replace(/"/g, """) 18 | .replace(/'/g, "'") 19 | .replace(//g, ">") 21 | //.replace(/\s/g, ' ') 22 | .replace(/\r?\n/g, "
"), 23 | /** 24 | * Decodes a string that has been HTML-encoded. 25 | * @param html - The HTML-encoded string to decode 26 | * @param eol - The end-of-line character to use 27 | * @returns The decoded string 28 | */ 29 | htmlDecode: (html: string, eol: string) => 30 | html 31 | .replace(//gi, eol ?? "\r\n") 32 | .replace(/ /g, " ") 33 | .replace(/"/g, '"') 34 | .replace(/'/g, "'") 35 | .replace(/</g, "<") 36 | .replace(/>/g, ">") 37 | .replace(/&/g, "&"), 38 | }; 39 | 40 | /** 41 | * Parses captions in SAMI format (.smi). 42 | * @param content - The subtitle content 43 | * @param options - Parse options 44 | * @throws TypeError When the format is not supported 45 | * @returns Parsed captions 46 | */ 47 | const parse = (content: string, options: SMIParseOptions) => { 48 | const captions = []; 49 | const eol = options.eol ?? "\r\n"; 50 | 51 | const title = /]*>([\s\S]*)<\/TITLE>/i.exec(content); 52 | if (title) { 53 | const caption = {} as MetaCaption; 54 | caption.type = "meta"; 55 | caption.name = "title"; 56 | caption.data = title[1].replace(/^\s*/g, "").replace(/\s*$/g, ""); 57 | captions.push(caption); 58 | } 59 | 60 | const style = /]*>([\s\S]*)<\/STYLE>/i.exec(content); 61 | if (style) { 62 | const caption = {} as MetaCaption; 63 | caption.type = "meta"; 64 | caption.name = "style"; 65 | caption.data = style[1]; 66 | captions.push(caption); 67 | } 68 | 69 | const sami = content 70 | .replace(/^[\s\S]*]*>/gi, "") // Remove content before body 71 | .replace(/<\/BODY[^>]*>[\s\S]*$/gi, ""); // Remove content after body 72 | 73 | let prev = null; 74 | const parts = sami.split(/ 83 | const match = /^]+Start\s*=\s*["']?(\d+)[^\d>]*>([\s\S]*)/i.exec(part); 84 | if (match) { 85 | const caption = {} as ContentCaption; 86 | caption.type = "caption"; 87 | caption.start = parseInt(match[1], 10); 88 | caption.end = caption.start + 2000; 89 | caption.duration = caption.end - caption.start; 90 | caption.content = match[2].replace(/^<\/SYNC[^>]*>/gi, ""); 91 | 92 | let blank = true; 93 | const pMatch = /^([\s\S]*)/i.exec(caption.content) ?? /^]*)>([\s\S]*)/i.exec(caption.content); 94 | if (pMatch) { 95 | let html = pMatch[2].replace(/ tag 96 | html = html 97 | .replace(/\s+/gi, eol) 98 | .replace(//gi, eol) 99 | .replace(/<[^>]+>/g, ""); // Remove all tags 100 | html = html.replace(/^\s+/g, "").replace(/\s+$/g, ""); // Trim new lines and spaces 101 | blank = html.replace(/ /gi, " ").replace(/\s+/g, "").length === 0; 102 | caption.text = helper.htmlDecode(html, eol); 103 | } 104 | 105 | if (!options.preserveSpaces && blank) { 106 | if (options.verbose) { 107 | console.log(`INFO: Skipping white space caption at ${caption.start}`); 108 | } 109 | } else { 110 | captions.push(caption); 111 | } 112 | 113 | // Update previous 114 | if (prev) { 115 | prev.end = caption.start; 116 | prev.duration = prev.end - prev.start; 117 | } 118 | prev = caption; 119 | continue; 120 | } 121 | 122 | if (options.verbose) { 123 | console.warn("Unknown part", _part); 124 | } 125 | } 126 | 127 | return captions; 128 | }; 129 | 130 | /** 131 | * Builds captions in SAMI format (.smi). 132 | * @param captions - The captions to build 133 | * @param options - Build options 134 | * @returns The built captions string in SAMI format 135 | */ 136 | const build = (captions: Caption[], options: SMIBuildOptions) => { 137 | const eol = options.eol ?? "\r\n"; 138 | 139 | let content = ""; 140 | content += `${eol}`; 141 | content += `${eol}`; 142 | content += `${options.title ?? ""}${eol}`; 143 | content += `${eol}`; 149 | content += `${eol}`; 150 | content += `${eol}`; 151 | 152 | for (const caption of captions) { 153 | if (caption.type === "meta") { 154 | continue; 155 | } 156 | 157 | if (!caption.type || caption.type === "caption") { 158 | // Start of caption 159 | content += `${eol}`; 160 | content += `

${helper.htmlEncode(caption.text ?? "")}${options.closeTags ? "

" : ""}${eol}`; 161 | if (options.closeTags) { 162 | content += `
${eol}`; 163 | } 164 | 165 | // Blank line indicates the end of caption 166 | content += `${eol}`; 167 | content += `

 ${options.closeTags ? "

" : ""}${eol}`; 168 | if (options.closeTags) { 169 | content += `
${eol}`; 170 | } 171 | 172 | continue; 173 | } 174 | 175 | if (options.verbose) { 176 | console.log("SKIP:", caption); 177 | } 178 | } 179 | 180 | content += `${eol}`; 181 | content += `
${eol}`; 182 | 183 | return content; 184 | }; 185 | 186 | /** 187 | * Detects whether the content is in SAMI format. 188 | * @param content - The content to be detected 189 | * @returns Whether the subtitle format is SAMI 190 | */ 191 | const detect = (content: string) => { 192 | /* 193 | 194 | 195 | 198 | 199 | */ 200 | return /]*>[\s\S]*]*>/.test(content); 201 | }; 202 | 203 | export default buildHandler({ name: FORMAT_NAME, build, detect, helper, parse }); 204 | export { FORMAT_NAME as name, build, detect, helper, parse }; 205 | -------------------------------------------------------------------------------- /lib/format/srt.ts: -------------------------------------------------------------------------------- 1 | import { buildHandler } from "../handler.js"; 2 | import { BuildOptions, Caption, ContentCaption, ParseOptions } from "../types/handler.js"; 3 | 4 | const FORMAT_NAME = "srt"; 5 | 6 | const helper = { 7 | /** 8 | * Converts a time string in format of hh:mm:ss, hh:mm:ss.sss or hh:mm:ss,sss to milliseconds. 9 | * @param s - The time string to convert 10 | * @throws TypeError If the time string is invalid 11 | * @returns Milliseconds 12 | */ 13 | toMilliseconds: (s: string) => { 14 | const match = /^\s*(\d{1,2}):(\d{1,2}):(\d{1,2})(?:[.,](\d{1,3}))?\s*$/.exec(s); 15 | if (!match) { 16 | throw new TypeError(`Invalid time format: ${s}`); 17 | } 18 | const hh = parseInt(match[1], 10); 19 | const mm = parseInt(match[2], 10); 20 | const ss = parseInt(match[3], 10); 21 | const ff = match[4] ? parseInt(match[4], 10) : 0; 22 | const ms = hh * 3600 * 1000 + mm * 60 * 1000 + ss * 1000 + ff; 23 | return ms; 24 | }, 25 | /** 26 | * Converts milliseconds to a time string in format of hh:mm:ss,sss. 27 | * @param ms - Milliseconds 28 | * @returns Time string in format of hh:mm:ss,sss 29 | */ 30 | toTimeString: (ms: number) => { 31 | const hh = Math.floor(ms / 1000 / 3600); 32 | const mm = Math.floor((ms / 1000 / 60) % 60); 33 | const ss = Math.floor((ms / 1000) % 60); 34 | const ff = Math.floor(ms % 1000); 35 | const time = `${(hh < 10 ? "0" : "") + hh}:${mm < 10 ? "0" : ""}${mm}:${ss < 10 ? "0" : ""}${ss},${ff < 100 ? "0" : ""}${ 36 | ff < 10 ? "0" : "" 37 | }${ff}`; 38 | return time; 39 | }, 40 | }; 41 | 42 | /** 43 | * Parses captions in SubRip format (.srt). 44 | * @param content - The subtitle content 45 | * @param options - Parse options 46 | * @returns Parsed captions 47 | */ 48 | const parse = (content: string, options: ParseOptions) => { 49 | const captions = []; 50 | const eol = options.eol ?? "\r\n"; 51 | const parts = content.split(/\r?\n\s*\n/); 52 | for (const part of parts) { 53 | const regex = /^(\d+)\r?\n(\d{1,2}:\d{1,2}:\d{1,2}(?:[.,]\d{1,3})?)\s*-->\s*(\d{1,2}:\d{1,2}:\d{1,2}(?:[.,]\d{1,3})?)\r?\n([\s\S]*)$/; 54 | const match = regex.exec(part); 55 | if (match) { 56 | const caption = {} as ContentCaption; 57 | caption.type = "caption"; 58 | caption.index = parseInt(match[1], 10); 59 | caption.start = helper.toMilliseconds(match[2]); 60 | caption.end = helper.toMilliseconds(match[3]); 61 | caption.duration = caption.end - caption.start; 62 | const lines = match[4].split(/\r?\n/); 63 | caption.content = lines.join(eol); 64 | caption.text = caption.content 65 | .replace(/<[^>]+>/g, "") // bold or italic 66 | .replace(/\{[^}]+\}/g, "") // {b}bold{/b} or {i}italic{/i} 67 | .replace(/>>[^:]*:\s*/g, ""); // >> SPEAKER NAME: 68 | captions.push(caption); 69 | continue; 70 | } 71 | 72 | if (options.verbose) { 73 | console.warn("Unknown part", part); 74 | } 75 | } 76 | return captions; 77 | }; 78 | 79 | /** 80 | * Builds captions in SubRip format (.srt). 81 | * @param captions - The captions to build 82 | * @param options - Build options 83 | * @returns The built captions string in SubRip format 84 | */ 85 | const build = (captions: Caption[], options: BuildOptions) => { 86 | let srt = ""; 87 | const eol = options.eol ?? "\r\n"; 88 | for (let i = 0; i < captions.length; i++) { 89 | const caption = captions[i]; 90 | if (!caption.type || caption.type === "caption") { 91 | srt += (i + 1).toString() + eol; 92 | srt += `${helper.toTimeString(caption.start)} --> ${helper.toTimeString(caption.end)}${eol}`; 93 | srt += caption.text + eol; 94 | srt += eol; 95 | continue; 96 | } 97 | if (options.verbose) { 98 | console.log("SKIP:", caption); 99 | } 100 | } 101 | 102 | return srt; 103 | }; 104 | 105 | /** 106 | * Detects whether the content is in SubRip format. 107 | * @param content - The subtitle content 108 | * @returns Whether the content is in SubRip format 109 | */ 110 | const detect = (content: string) => { 111 | /* 112 | 3 113 | 00:04:48,280 --> 00:04:50,510 114 | Sister, perfume? 115 | */ 116 | return /\d+\r?\n\d{1,2}:\d{1,2}:\d{1,2}(?:[.,]\d{1,3})?\s*-->\s*\d{1,2}:\d{1,2}:\d{1,2}(?:[.,]\d{1,3})?/.test(content); 117 | }; 118 | 119 | export default buildHandler({ name: FORMAT_NAME, build, detect, helper, parse }); 120 | export { FORMAT_NAME as name, build, detect, helper, parse }; 121 | -------------------------------------------------------------------------------- /lib/format/ssa.ts: -------------------------------------------------------------------------------- 1 | import { buildHandler } from "../handler.js"; 2 | import { BuildOptions, Caption, ContentCaption, MetaCaption, ParseOptions, StyleCaption } from "../types/handler.js"; 3 | 4 | const FORMAT_NAME = "ssa"; 5 | 6 | const helper = { 7 | /** 8 | * Converts a time string in format of hh:mm:ss.fff or hh:mm:ss,fff to milliseconds. 9 | * @param s - The time string to convert 10 | * @throws TypeError If the time string is invalid 11 | * @returns Milliseconds 12 | */ 13 | toMilliseconds: (s: string) => { 14 | const match = /^\s*(\d+:)?(\d{1,2}):(\d{1,2})(?:[.,](\d{1,3}))?\s*$/.exec(s); 15 | if (!match) { 16 | throw new TypeError(`Invalid time format: ${s}`); 17 | } 18 | const hh = match[1] ? parseInt(match[1].replace(":", "")) : 0; 19 | const mm = parseInt(match[2], 10); 20 | const ss = parseInt(match[3], 10); 21 | const ff = match[4] ? parseInt(match[4], 10) : 0; 22 | const ms = hh * 3600 * 1000 + mm * 60 * 1000 + ss * 1000 + ff * 10; 23 | return ms; 24 | }, 25 | /** 26 | * Converts milliseconds to a time string in format of hh:mm:ss.fff. 27 | * @param ms - Milliseconds 28 | * @returns Time string in format of hh:mm:ss.fff 29 | */ 30 | toTimeString: (ms: number) => { 31 | const hh = Math.floor(ms / 1000 / 3600); 32 | const mm = Math.floor((ms / 1000 / 60) % 60); 33 | const ss = Math.floor((ms / 1000) % 60); 34 | const ff = Math.floor((ms % 1000) / 10); // 2 digits 35 | const time = `${hh}:${mm < 10 ? "0" : ""}${mm}:${ss < 10 ? "0" : ""}${ss}.${ff < 10 ? "0" : ""}${ff}`; 36 | return time; 37 | }, 38 | }; 39 | 40 | /** 41 | * Internal helper function for building caption data. 42 | * @param columns - Columns 43 | * @param values - Values 44 | * @returns Caption data 45 | * @internal 46 | */ 47 | const _buildCaptionData = (columns: string[], values: string[]) => { 48 | const data: Record = {}; 49 | for (let c = 0; c < columns.length && c < values.length; c++) { 50 | data[columns[c]] = values[c]; 51 | } 52 | return data; 53 | }; 54 | 55 | /** 56 | * Parses captions in SubStation Alpha format (.ssa). 57 | * @param content - The subtitle content 58 | * @param options - Parse options 59 | * @throws TypeError If the meta data is in invalid format 60 | * @returns Parsed captions 61 | */ 62 | const parse = (content: string, options: ParseOptions) => { 63 | let meta; 64 | let columns = null; 65 | const captions = []; 66 | const eol = options.eol ?? "\r\n"; 67 | const parts = content.split(/\r?\n\s*\n/); 68 | for (const part of parts) { 69 | const regex = /^\s*\[([^\]]+)\]\r?\n([\s\S]*)$/; 70 | const match = regex.exec(part); 71 | if (!match) { 72 | if (options.verbose) { 73 | console.warn("Unknown part", part); 74 | } 75 | continue; 76 | } 77 | 78 | const tag = match[1]; 79 | const lines = match[2].split(/\r?\n/); 80 | for (const line of lines) { 81 | if (/^\s*;/.test(line)) { 82 | continue; // Skip comment 83 | } 84 | // FIXME: prevent backtracking 85 | // eslint-disable-next-line regexp/no-super-linear-backtracking 86 | const lineMatch = /^\s*([^\s:]+):\s*(.*)$/.exec(line); 87 | if (!lineMatch) { 88 | continue; 89 | } 90 | if (tag === "Script Info") { 91 | if (!meta) { 92 | meta = {} as MetaCaption; 93 | meta.type = "meta"; 94 | meta.data = {}; 95 | captions.push(meta); 96 | } 97 | if (typeof meta.data === "object") { 98 | const name = lineMatch[1].trim(); 99 | const value = lineMatch[2].trim(); 100 | meta.data[name] = value; 101 | } 102 | } else if (tag === "V4 Styles" || tag === "V4+ Styles") { 103 | const name = lineMatch[1].trim(); 104 | const value = lineMatch[2].trim(); 105 | if (name === "Format") { 106 | columns = value.split(/\s*,\s*/); 107 | } else if (name === "Style" && columns) { 108 | const values = value.split(/\s*,\s*/); 109 | const caption = {} as StyleCaption; 110 | caption.type = "style"; 111 | caption.data = _buildCaptionData(columns, values); 112 | captions.push(caption); 113 | } 114 | } else if (tag === "Events") { 115 | const name = lineMatch[1].trim(); 116 | const value = lineMatch[2].trim(); 117 | if (name === "Format") { 118 | columns = value.split(/\s*,\s*/); 119 | } else if (name === "Dialogue" && columns) { 120 | const values = value.split(/\s*,\s*/); 121 | const caption = {} as ContentCaption; 122 | caption.type = "caption"; 123 | caption.data = _buildCaptionData(columns, values); 124 | caption.start = helper.toMilliseconds(caption.data.Start); 125 | caption.end = helper.toMilliseconds(caption.data.End); 126 | caption.duration = caption.end - caption.start; 127 | caption.content = caption.data.Text; 128 | 129 | // Work-around for missing text (when the text contains ',' char) 130 | const indexOfText = value.split(",", columns.length - 1).join(",").length + 1 + 1; 131 | caption.content = value.substring(indexOfText); 132 | caption.data.Text = caption.content; 133 | 134 | caption.text = caption.content 135 | .replace(/\\N/g, eol) // "\N" for new line 136 | .replace(/\{[^}]+\}/g, ""); // {\pos(400,570)} 137 | captions.push(caption); 138 | } 139 | } 140 | } 141 | } 142 | return captions; 143 | }; 144 | 145 | /** 146 | * Builds captions in SubStation Alpha format (.ssa). 147 | * @param captions - The captions to build 148 | * @param options - Build options 149 | * @returns The built captions string in SubStation Alpha format 150 | */ 151 | const build = (captions: Caption[], options: BuildOptions) => { 152 | const eol = options.eol ?? "\r\n"; 153 | const ass = options.format === "ass"; 154 | 155 | let content = ""; 156 | content += `[Script Info]${eol}`; 157 | content += `; Script generated by subsrt ${eol}`; 158 | content += `ScriptType: v4.00${ass ? "+" : ""}${eol}`; 159 | content += `Collisions: Normal${eol}`; 160 | content += eol; 161 | if (ass) { 162 | content += `[V4+ Styles]${eol}`; 163 | content += `Format: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding${eol}`; 164 | content += `Style: DefaultVCD, Arial,28,&H00B4FCFC,&H00B4FCFC,&H00000008,&H80000008,-1,0,0,0,100,100,0.00,0.00,1,1.00,2.00,2,30,30,30,0${eol}`; 165 | } else { 166 | content += `[V4 Styles]${eol}`; 167 | content += `Format: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, TertiaryColour, BackColour, Bold, Italic, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, AlphaLevel, Encoding${eol}`; 168 | content += `Style: DefaultVCD, Arial,28,11861244,11861244,11861244,-2147483640,-1,0,1,1,2,2,30,30,30,0,0${eol}`; 169 | } 170 | content += eol; 171 | content += `[Events]${eol}`; 172 | content += `Format: ${ass ? "Layer" : "Marked"}, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text${eol}`; 173 | 174 | for (const caption of captions) { 175 | if (caption.type === "meta") { 176 | continue; 177 | } 178 | 179 | if (!caption.type || caption.type === "caption") { 180 | content += `Dialogue: ${ass ? "0" : "Marked=0"},${helper.toTimeString(caption.start)},${helper.toTimeString( 181 | caption.end, 182 | )},DefaultVCD, NTP,0000,0000,0000,,${caption.text.replace(/\r?\n/g, "\\N")}${eol}`; 183 | continue; 184 | } 185 | 186 | if (options.verbose) { 187 | console.log("SKIP:", caption); 188 | } 189 | } 190 | 191 | return content; 192 | }; 193 | 194 | /** 195 | * Detects whether the content is in ASS or SSA format. 196 | * @param content - The subtitle content 197 | * @returns Whether the content is in "ass", "ssa" or neither 198 | */ 199 | const detect = (content: string) => { 200 | if (/^\s*\[Script Info\]\r?\n/.test(content) && /\s*\[Events\]\r?\n/.test(content)) { 201 | /* 202 | [Script Info] 203 | ... 204 | [Events] 205 | */ 206 | // Advanced (V4+) styles for ASS format 207 | return content.indexOf("[V4+ Styles]") > 0 ? "ass" : "ssa"; 208 | } 209 | return false; 210 | }; 211 | 212 | export default buildHandler({ name: FORMAT_NAME, build, detect, helper, parse }); 213 | export { FORMAT_NAME as name, build, detect, helper, parse }; 214 | -------------------------------------------------------------------------------- /lib/format/sub.ts: -------------------------------------------------------------------------------- 1 | import { buildHandler } from "../handler.js"; 2 | import { Caption, ContentCaption } from "../types/handler.js"; 3 | 4 | import { SUBBuildOptions, SUBParseOptions } from "./types/sub.js"; 5 | 6 | const FORMAT_NAME = "sub"; 7 | const DEFAULT_FPS = 25; 8 | 9 | /** 10 | * Parses captions in MicroDVD format. 11 | * @param content - The subtitle content 12 | * @param options - Parse options 13 | * @returns Parsed captions 14 | * @see https://en.wikipedia.org/wiki/MicroDVD 15 | */ 16 | const parse = (content: string, options: SUBParseOptions) => { 17 | options.fps ||= DEFAULT_FPS; 18 | const fps = options.fps > 0 ? options.fps : DEFAULT_FPS; 19 | const captions = []; 20 | const eol = options.eol ?? "\r\n"; 21 | const parts = content.split(/\r?\n/); 22 | for (let i = 0; i < parts.length; i++) { 23 | const regex = /^\{(\d+)\}\{(\d+)\}(.*)$/; 24 | const match = regex.exec(parts[i]); 25 | if (match) { 26 | const caption = {} as ContentCaption; 27 | caption.type = "caption"; 28 | caption.index = i + 1; 29 | caption.frame = { 30 | start: parseInt(match[1], 10), 31 | end: parseInt(match[2], 10), 32 | count: parseInt(match[2]) - parseInt(match[1], 10), 33 | }; 34 | caption.start = Math.round(caption.frame.start / fps); 35 | caption.end = Math.round(caption.frame.end / fps); 36 | caption.duration = caption.end - caption.start; 37 | const lines = match[3].split(/\|/); 38 | caption.content = lines.join(eol); 39 | caption.text = caption.content.replace(/\{[^}]+\}/g, ""); // {0}{25}{c:$0000ff}{y:b,u}{f:DeJaVuSans}{s:12}Hello! 40 | captions.push(caption); 41 | continue; 42 | } 43 | 44 | if (options.verbose) { 45 | console.warn("Unknown part", parts[i]); 46 | } 47 | } 48 | return captions; 49 | }; 50 | 51 | /** 52 | * Builds captions in MicroDVD format. 53 | * @param captions - The captions to build 54 | * @param options - Build options 55 | * @returns The built captions string in MicroDVD format 56 | * @see https://en.wikipedia.org/wiki/MicroDVD 57 | */ 58 | const build = (captions: Caption[], options: SUBBuildOptions) => { 59 | const fps = options.fps && options.fps > 0 ? options.fps : DEFAULT_FPS; 60 | 61 | let sub = ""; 62 | const eol = options.eol ?? "\r\n"; 63 | for (const caption of captions) { 64 | if (!caption.type || caption.type === "caption") { 65 | const startFrame = typeof caption.frame === "object" && caption.frame.start >= 0 ? caption.frame.start : caption.start * fps; 66 | const endFrame = typeof caption.frame === "object" && caption.frame.end >= 0 ? caption.frame.end : caption.end * fps; 67 | const text = caption.text.replace(/\r?\n/, "|"); 68 | sub += `{${startFrame}}{${endFrame}}${text}${eol}`; 69 | continue; 70 | } 71 | 72 | if (options.verbose) { 73 | console.log("SKIP:", caption); 74 | } 75 | } 76 | 77 | return sub; 78 | }; 79 | 80 | /** 81 | * Detects whether the content is in MicroDVD format. 82 | * @param content - The subtitle content 83 | * @returns Whether it's MicroDVD format 84 | */ 85 | const detect = (content: string) => { 86 | /* 87 | {7207}{7262}Sister, perfume? 88 | */ 89 | return /^\{\d+\}\{\d+\}.*/.test(content); 90 | }; 91 | 92 | export default buildHandler({ name: FORMAT_NAME, build, detect, parse }); 93 | export { FORMAT_NAME as name, build, detect, parse }; 94 | -------------------------------------------------------------------------------- /lib/format/types/smi.ts: -------------------------------------------------------------------------------- 1 | import { BuildOptions, ParseOptions } from "../../types/handler.js"; 2 | 3 | export interface SMIBuildOptions extends BuildOptions { 4 | title?: string; 5 | langName?: string; 6 | langCode?: string; 7 | closeTags?: boolean; 8 | } 9 | 10 | export interface SMIParseOptions extends ParseOptions { 11 | preserveSpaces?: boolean; 12 | } 13 | -------------------------------------------------------------------------------- /lib/format/types/sub.ts: -------------------------------------------------------------------------------- 1 | import { BuildOptions, ParseOptions } from "../../types/handler.js"; 2 | 3 | export interface SUBBuildOptions extends BuildOptions { 4 | fps?: number; 5 | } 6 | 7 | export interface SUBParseOptions extends ParseOptions { 8 | fps?: number; 9 | } 10 | -------------------------------------------------------------------------------- /lib/format/vtt.ts: -------------------------------------------------------------------------------- 1 | import { buildHandler } from "../handler.js"; 2 | import { BuildOptions, Caption, ContentCaption, MetaCaption, ParseOptions } from "../types/handler.js"; 3 | 4 | const FORMAT_NAME = "vtt"; 5 | 6 | const helper = { 7 | /** 8 | * Converts a time string in format of hh:mm:ss.fff or hh:mm:ss,fff to milliseconds. 9 | * @param s - The time string to convert 10 | * @throws TypeError If the time string is invalid 11 | * @returns Milliseconds 12 | */ 13 | toMilliseconds: (s: string) => { 14 | const match = /^\s*(\d{1,2}:)?(\d{1,2}):(\d{1,2})(?:[.,](\d{1,3}))?\s*$/.exec(s); 15 | if (!match) { 16 | throw new TypeError(`Invalid time format: ${s}`); 17 | } 18 | const hh = match[1] ? parseInt(match[1].replace(":", "")) : 0; 19 | const mm = parseInt(match[2], 10); 20 | const ss = parseInt(match[3], 10); 21 | const ff = match[4] ? parseInt(match[4], 10) : 0; 22 | const ms = hh * 3600 * 1000 + mm * 60 * 1000 + ss * 1000 + ff; 23 | return ms; 24 | }, 25 | /** 26 | * Converts milliseconds to a time string in format of hh:mm:ss.fff. 27 | * @param ms - Milliseconds 28 | * @returns Time string in format of hh:mm:ss.fff 29 | */ 30 | toTimeString: (ms: number) => { 31 | const hh = Math.floor(ms / 1000 / 3600); 32 | const mm = Math.floor((ms / 1000 / 60) % 60); 33 | const ss = Math.floor((ms / 1000) % 60); 34 | const ff = Math.floor(ms % 1000); 35 | const time = `${(hh < 10 ? "0" : "") + hh}:${mm < 10 ? "0" : ""}${mm}:${ss < 10 ? "0" : ""}${ss}.${ff < 100 ? "0" : ""}${ 36 | ff < 10 ? "0" : "" 37 | }${ff}`; 38 | return time; 39 | }, 40 | }; 41 | 42 | /** 43 | * Parses captions in WebVTT format (Web Video Text Tracks Format). 44 | * @param content - The subtitle content 45 | * @param options - Parse options 46 | * @returns Parsed captions 47 | */ 48 | const parse = (content: string, options: ParseOptions) => { 49 | let index = 1; 50 | const captions: Caption[] = []; 51 | const parts = content.split(/\r?\n\s*\n/); 52 | for (const part of parts) { 53 | // WebVTT data 54 | const regex = 55 | /^([^\r\n]+\r?\n)?((?:\d{1,2}:)?\d{1,2}:\d{1,2}(?:[.,]\d{1,3})?)\s*-->\s*((?:\d{1,2}:)?\d{1,2}:\d{1,2}(?:[.,]\d{1,3})?)[^\S\r\n]?.*\r?\n([\s\S]*)$/; 56 | const match = regex.exec(part); 57 | if (match) { 58 | const caption = {} as ContentCaption; 59 | caption.type = "caption"; 60 | caption.index = index++; 61 | if (match[1]) { 62 | caption.cue = match[1].replace(/[\r\n]*/g, ""); 63 | } 64 | caption.start = helper.toMilliseconds(match[2]); 65 | caption.end = helper.toMilliseconds(match[3]); 66 | caption.duration = caption.end - caption.start; 67 | caption.content = match[4]; 68 | caption.text = caption.content 69 | .replace(/<[^>]+>/g, "") // bold or italic 70 | .replace(/\{[^}]+\}/g, ""); // {b}bold{/b} or {i}italic{/i} 71 | captions.push(caption); 72 | continue; 73 | } 74 | 75 | // WebVTT meta 76 | // FIXME: prevent backtracking 77 | // eslint-disable-next-line regexp/no-super-linear-backtracking 78 | const meta = /^([A-Z]+)(\r?\n([\s\S]*))?$/.exec(part) ?? /^([A-Z]+)\s+([^\r\n]*)$/.exec(part); 79 | if (meta) { 80 | const caption = {} as MetaCaption; 81 | caption.type = "meta"; 82 | caption.name = meta[1]; 83 | if (meta[3]) { 84 | caption.data = meta[3]; 85 | } 86 | captions.push(caption); 87 | continue; 88 | } 89 | 90 | if (options.verbose) { 91 | console.warn("Unknown part", part); 92 | } 93 | } 94 | return captions; 95 | }; 96 | 97 | /** 98 | * Builds captions in WebVTT format (Web Video Text Tracks Format). 99 | * @param captions - The captions to build 100 | * @param options - Build options 101 | * @returns The built captions string in WebVTT format 102 | */ 103 | const build = (captions: Caption[], options: BuildOptions) => { 104 | const eol = options.eol ?? "\r\n"; 105 | let content = `WEBVTT${eol}${eol}`; 106 | for (let i = 0; i < captions.length; i++) { 107 | const caption = captions[i]; 108 | if (caption.type === "meta") { 109 | if (caption.name === "WEBVTT") { 110 | continue; 111 | } 112 | content += caption.name + eol; 113 | content += typeof caption.data === "string" ? caption.data + eol : ""; 114 | content += eol; 115 | continue; 116 | } 117 | 118 | if (!caption.type || caption.type === "caption") { 119 | content += (i + 1).toString() + eol; 120 | content += `${helper.toTimeString(caption.start)} --> ${helper.toTimeString(caption.end)}${eol}`; 121 | content += caption.text + eol; 122 | content += eol; 123 | continue; 124 | } 125 | 126 | if (options.verbose) { 127 | console.log("SKIP:", caption); 128 | } 129 | } 130 | 131 | return content; 132 | }; 133 | 134 | /** 135 | * Detects whether the content is in WebVTT format. 136 | * @param content - The subtitle content 137 | * @returns Whether the content is in WebVTT format 138 | */ 139 | const detect = (content: string) => { 140 | /* 141 | WEBVTT 142 | ... 143 | */ 144 | return /^\s*WEBVTT\r?\n/.test(content); 145 | }; 146 | 147 | export default buildHandler({ name: FORMAT_NAME, build, detect, helper, parse }); 148 | export { FORMAT_NAME as name, build, detect, helper, parse }; 149 | -------------------------------------------------------------------------------- /lib/handler.ts: -------------------------------------------------------------------------------- 1 | import { BaseHandler, BuildFunction, DetectFunction, Helper, ParseFunction, ParseOptions } from "./types/handler.js"; 2 | 3 | /** 4 | * Handler class. 5 | */ 6 | export class Handler implements BaseHandler { 7 | name: string; 8 | helper?: Helper; 9 | build: BuildFunction; 10 | detect: DetectFunction; 11 | parse: ParseFunction; 12 | 13 | /** 14 | * Creates a new handler. 15 | * @param args - The handler properties (`name`, `build`, `detect`, `helper` and `parse`) 16 | * @see 17 | * - {@link BaseHandler} 18 | * - {@link BuildFunction} 19 | * - {@link DetectFunction} 20 | * - {@link Helper} 21 | * - {@link ParseFunction} 22 | * - {@link ParseOptions} 23 | * @example 24 | * ```ts 25 | * const handler = new Handler({ 26 | * name: "ext", 27 | * build: (captions: Caption[], options: BuildOptions): string => { 28 | * // ... 29 | * }, 30 | * detect: (content: string): boolean | string => { 31 | * // ... 32 | * }, 33 | * parse: (content: string, options: ParseOptions): Caption[] => { 34 | * // ... 35 | * }, 36 | * }); 37 | * ``` 38 | */ 39 | constructor({ name, build, detect, helper, parse }: BaseHandler) { 40 | this.name = name; 41 | this.helper = helper; 42 | this.build = build; 43 | this.detect = (content: string) => { 44 | if (typeof content !== "string") { 45 | throw new TypeError(`Expected string, got ${typeof content}!`); 46 | } 47 | 48 | return detect(content); 49 | }; 50 | this.parse = (content: string, _options: ParseOptions) => { 51 | if (typeof content !== "string") { 52 | throw new TypeError(`Expected string, got ${typeof content}!`); 53 | } 54 | 55 | return parse(content, _options); 56 | }; 57 | } 58 | } 59 | 60 | /** 61 | * Build a handler. 62 | * @param args - The handler properties 63 | * @returns The handler 64 | * @see {@link Handler} 65 | */ 66 | export const buildHandler = (args: BaseHandler) => { 67 | return new Handler(args); 68 | }; 69 | -------------------------------------------------------------------------------- /lib/subsrt.ts: -------------------------------------------------------------------------------- 1 | import formats from "./format/index.js"; 2 | import { BuildOptions, Caption, ConvertOptions, ParseOptions, ResyncOptions } from "./types/handler.js"; 3 | import { ResyncFunction, SubsrtInterface } from "./types/subsrt.js"; 4 | 5 | /** 6 | * Clones an object. 7 | * @param obj - The object to clone 8 | * @returns The cloned object 9 | */ 10 | const clone = (obj: T) => JSON.parse(JSON.stringify(obj)) as T; 11 | 12 | /** 13 | * Main subsrt class. 14 | */ 15 | class Subsrt implements SubsrtInterface { 16 | format = formats; 17 | 18 | /** 19 | * Gets a list of supported subtitle formats. 20 | * @returns The list of supported subtitle formats 21 | */ 22 | list = () => Object.keys(this.format); 23 | 24 | /** 25 | * Detects a subtitle format from the content. 26 | * @param content - The subtitle content 27 | * @returns The detected format 28 | */ 29 | detect = (content: string) => { 30 | const formats = this.list(); 31 | for (const format of formats) { 32 | const handler = this.format[format]; 33 | if (typeof handler === "undefined") { 34 | continue; 35 | } 36 | if (typeof handler.detect !== "function") { 37 | continue; 38 | } 39 | // Function 'detect' can return true or format name 40 | const detected = handler.detect(content); 41 | if (detected === true || detected === format) { 42 | return format; 43 | } 44 | } 45 | return ""; 46 | }; 47 | 48 | /** 49 | * Parses a subtitle content. 50 | * @param content - The subtitle content 51 | * @param options - The parsing options 52 | * @throws TypeError If the format cannot be determined 53 | * @throws TypeError If the format is not supported 54 | * @throws TypeError If the handler does not support 'parse' op 55 | * @returns The parsed captions 56 | */ 57 | parse = (content: string, options = {} as ParseOptions) => { 58 | const format = options.format ?? this.detect(content); 59 | if (!format || format.trim().length === 0) { 60 | throw new TypeError("Cannot determine subtitle format"); 61 | } 62 | 63 | const handler = this.format[format]; 64 | if (typeof handler === "undefined") { 65 | throw new TypeError(`Unsupported subtitle format: ${format}`); 66 | } 67 | 68 | const func = handler.parse; 69 | if (typeof func !== "function") { 70 | throw new TypeError(`Subtitle format does not support 'parse' op: ${format}`); 71 | } 72 | 73 | return func(content, options); 74 | }; 75 | 76 | /** 77 | * Builds a subtitle content. 78 | * @param captions - The captions to build 79 | * @param options - The building options 80 | * @throws TypeError If the format cannot be determined 81 | * @throws TypeError If the format is not supported 82 | * @throws TypeError If the handler does not support 'build' op 83 | * @returns The built subtitle content 84 | */ 85 | build = (captions: Caption[], options = {} as BuildOptions) => { 86 | const format = options.format ?? "srt"; 87 | if (!format || format.trim().length === 0) { 88 | throw new TypeError("Cannot determine subtitle format"); 89 | } 90 | 91 | const handler = this.format[format]; 92 | if (typeof handler === "undefined") { 93 | throw new TypeError(`Unsupported subtitle format: ${format}`); 94 | } 95 | 96 | const func = handler.build; 97 | if (typeof func !== "function") { 98 | throw new TypeError(`Subtitle format does not support 'build' op: ${format}`); 99 | } 100 | 101 | return func(captions, options); 102 | }; 103 | 104 | /** 105 | * Converts subtitle format. 106 | * @param content - The subtitle content 107 | * @param options - The conversion options 108 | * @returns The converted subtitle content 109 | */ 110 | convert = (content: string, _options: ConvertOptions | string = {} as ConvertOptions) => { 111 | let options = {} as ConvertOptions; 112 | if (typeof _options === "string") { 113 | options.to = _options; 114 | } else { 115 | options = _options; 116 | } 117 | 118 | const parseOptions = { 119 | format: options.from ?? undefined, 120 | verbose: options.verbose, 121 | eol: options.eol, 122 | } as ParseOptions; 123 | let captions = this.parse(content, parseOptions); 124 | 125 | if (options.resync) { 126 | captions = this.resync(captions, options.resync); 127 | } 128 | 129 | const buildOptions = { 130 | format: options.to || options.format, 131 | verbose: options.verbose, 132 | eol: options.eol, 133 | } as BuildOptions; 134 | const result = this.build(captions, buildOptions); 135 | 136 | return result; 137 | }; 138 | 139 | /** 140 | * Shifts the time of the captions. 141 | * @param captions - The captions to resync 142 | * @param options - The resync options 143 | * @throws TypeError If the 'options' argument is not defined 144 | * @returns The resynced captions 145 | */ 146 | // skipcq: JS-0105 147 | resync = (captions: Caption[], options: ResyncFunction | number | ResyncOptions = {} as ResyncOptions) => { 148 | let func: ResyncFunction, 149 | ratio: number, 150 | frame = false, 151 | offset: number; 152 | if (typeof options === "function") { 153 | func = options; // User's function to handle time shift 154 | } else if (typeof options === "number") { 155 | offset = options; // Time shift (+/- offset) 156 | func = (a) => [a[0] + offset, a[1] + offset]; 157 | } else { 158 | offset = (options.offset ?? 0) * (options.frame ? (options.fps ?? 25) : 1); 159 | ratio = options.ratio ?? 1.0; 160 | frame = options.frame ?? false; 161 | func = (a) => [Math.round(a[0] * ratio + offset), Math.round(a[1] * ratio + offset)]; 162 | } 163 | 164 | const resynced: Caption[] = []; 165 | for (const _caption of captions) { 166 | const caption = clone(_caption); 167 | if (!caption.type || caption.type === "caption") { 168 | if (frame && caption.frame) { 169 | const shift = func([caption.frame.start, caption.frame.end]); 170 | if (shift && shift.length === 2) { 171 | caption.frame.start = shift[0]; 172 | caption.frame.end = shift[1]; 173 | caption.frame.count = caption.frame.end - caption.frame.start; 174 | } 175 | } else { 176 | const shift = func([caption.start, caption.end]); 177 | if (shift && shift.length === 2) { 178 | caption.start = shift[0]; 179 | caption.end = shift[1]; 180 | caption.duration = caption.end - caption.start; 181 | } 182 | } 183 | } 184 | resynced.push(caption); 185 | } 186 | 187 | return resynced; 188 | }; 189 | } 190 | 191 | const subsrt = new Subsrt(); 192 | export default subsrt; 193 | export const { format, list, detect, parse, build, convert, resync } = subsrt; 194 | -------------------------------------------------------------------------------- /lib/types/handler.ts: -------------------------------------------------------------------------------- 1 | export interface ContentCaption { 2 | type: "caption"; 3 | index: number; 4 | start: number; 5 | end: number; 6 | duration: number; 7 | cue?: string; 8 | content: string; 9 | text: string; 10 | frame?: { 11 | start: number; 12 | end: number; 13 | count: number; 14 | }; 15 | data?: Record; 16 | } 17 | 18 | export interface MetaCaption { 19 | type: "meta"; 20 | name: string; 21 | data: string | Record; 22 | tag?: string; 23 | } 24 | 25 | export interface StyleCaption { 26 | type: "style"; 27 | data: Record; 28 | } 29 | 30 | export type Caption = ContentCaption | MetaCaption | StyleCaption; 31 | 32 | export interface ParseOptions { 33 | format?: string; 34 | verbose?: boolean; 35 | eol?: string; 36 | // Only for smi 37 | // preserveSpaces?: boolean; 38 | } 39 | 40 | export interface BuildOptions extends ParseOptions { 41 | format: string; 42 | } 43 | 44 | export type ConvertOptions = ParseOptions & { 45 | to: string; 46 | resync?: ResyncOptions; 47 | } & ( 48 | | { 49 | format: string; 50 | from?: never; 51 | } 52 | | { 53 | format?: never; 54 | from: string; 55 | } 56 | ); 57 | 58 | export interface ResyncOptions { 59 | offset?: number; 60 | ratio?: number; 61 | frame?: boolean; 62 | fps?: number; 63 | } 64 | 65 | export interface Helper { 66 | toMilliseconds?: (time: string) => number; 67 | toTimeString?: (ms: number) => string; 68 | htmlEncode?: (text: string) => string; 69 | htmlDecode?: (text: string, eol: string) => string; 70 | } 71 | export type BuildFunction = (captions: Caption[], options: BuildOptions) => string; 72 | export type DetectFunction = (content: string) => boolean | string; 73 | export type ParseFunction = (content: string, options: ParseOptions) => Caption[]; 74 | 75 | export interface BaseHandler { 76 | name: string; 77 | helper?: Helper; 78 | build: BuildFunction; 79 | detect: DetectFunction; 80 | parse: ParseFunction; 81 | } 82 | -------------------------------------------------------------------------------- /lib/types/subsrt.ts: -------------------------------------------------------------------------------- 1 | import { Handler } from "../handler.js"; 2 | 3 | import { BaseHandler, Caption, ConvertOptions, ResyncOptions } from "./handler"; 4 | 5 | export type ResyncFunction = (a: number[]) => number[]; 6 | 7 | export type SubsrtFormats = Record; 8 | 9 | export interface SubsrtInterface extends Omit { 10 | format: SubsrtFormats; 11 | list: () => string[]; 12 | convert: (content: string, options?: ConvertOptions | string) => string; 13 | resync: (captions: Caption[], options?: ResyncFunction | number | ResyncOptions) => Caption[]; 14 | } 15 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "subsrt-ts", 3 | "description": "Subtitle JavaScript library and command line tool with no dependencies.", 4 | "keywords": [ 5 | "subtitle", 6 | "captions", 7 | "srt", 8 | "sub", 9 | "sbv", 10 | "vtt", 11 | "ssa", 12 | "ass", 13 | "smi", 14 | "sami", 15 | "subrip", 16 | "lrc", 17 | "lyrics", 18 | "json" 19 | ], 20 | "version": "2.1.2", 21 | "author": "Rakuzen25", 22 | "contributors": [ 23 | "Papn Kukn" 24 | ], 25 | "license": "MIT", 26 | "type": "module", 27 | "main": "dist/subsrt.js", 28 | "bin": "bin/subsrt.js", 29 | "types": "dist/subsrt.d.ts", 30 | "files": [ 31 | "dist", 32 | "bin", 33 | "app.js" 34 | ], 35 | "scripts": { 36 | "format": "prettier --write . && eslint --fix . && tsc --noEmit", 37 | "lint": "prettier --check . && eslint . && tsc --noEmit", 38 | "build": "rm -rf dist && tsc --noEmitOnError", 39 | "test": "NODE_OPTIONS=--experimental-vm-modules pnpm jest", 40 | "testCoverage": "NODE_OPTIONS=--experimental-vm-modules pnpm jest --coverage=true --coverageReporters=cobertura", 41 | "docs": "pnpm typedoc --plugin typedoc-plugin-missing-exports lib/**.ts", 42 | "preversion": "pnpm run format && pnpm run lint && pnpm run build && pnpm run test", 43 | "prepublishOnly": "pnpm run test && pnpm run lint" 44 | }, 45 | "repository": { 46 | "type": "git", 47 | "url": "https://github.com/rakuzen25/subsrt-ts" 48 | }, 49 | "bugs": { 50 | "url": "https://github.com/rakuzen25/subsrt-ts/issues" 51 | }, 52 | "devDependencies": { 53 | "@types/jest": "^29.5.12", 54 | "@types/node": "^20.11.16", 55 | "@typescript-eslint/eslint-plugin": "^7.0.0", 56 | "@typescript-eslint/parser": "^7.0.0", 57 | "eslint": "^8.56.0", 58 | "eslint-config-prettier": "^9.1.0", 59 | "eslint-import-resolver-typescript": "^3.6.1", 60 | "eslint-plugin-import": "^2.29.1", 61 | "eslint-plugin-jest": "^28.0.0", 62 | "eslint-plugin-regexp": "^2.2.0", 63 | "eslint-plugin-tsdoc": "^0.4.0", 64 | "jest": "^29.7.0", 65 | "prettier": "^3.2.5", 66 | "ts-jest": "^29.1.2", 67 | "typedoc": "^0.26.0", 68 | "typedoc-plugin-missing-exports": "^3.0.0", 69 | "typescript": "^5.3.3" 70 | }, 71 | "packageManager": "pnpm@9.14.2" 72 | } 73 | -------------------------------------------------------------------------------- /renovate.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://docs.renovatebot.com/renovate-schema.json", 3 | "extends": ["config:base", ":automergeMinor", "group:allNonMajor"] 4 | } 5 | -------------------------------------------------------------------------------- /subsrt.bat: -------------------------------------------------------------------------------- 1 | @echo off 2 | node app %* 3 | -------------------------------------------------------------------------------- /test/build.test.ts: -------------------------------------------------------------------------------- 1 | import { readFileSync } from "fs"; 2 | 3 | import { build, list, parse } from "../lib/subsrt"; 4 | import { BuildOptions, Caption } from "../lib/types/handler"; 5 | 6 | describe("Build", () => { 7 | const formats = list(); 8 | test.each(formats)("should build a subtitle file", (ext) => { 9 | console.log(`Build .${ext}`); 10 | const json = readFileSync("./test/fixtures/sample.json", "utf8"); 11 | const captions = JSON.parse(json) as Caption[]; 12 | const content = build(captions, { 13 | format: ext, 14 | closeTags: ext === "smi" ? true : undefined, 15 | } as BuildOptions); 16 | expect(content.length).toBeGreaterThan(0); 17 | }); 18 | 19 | test("should build LRC tags", () => { 20 | const captions = [ 21 | { type: "meta", tag: "ar", data: "Artist" }, 22 | { type: "meta", tag: "ti", data: "Title" }, 23 | { type: "meta", tag: "au", data: "Author" }, 24 | { type: "meta", tag: "al", data: "Album" }, 25 | { type: "meta", tag: "by", data: "Creator" }, 26 | { type: "meta", tag: "offset", data: "100" }, 27 | { type: "caption", start: 0, text: "Lyrics" }, 28 | ] as Caption[]; 29 | const content = build(captions, { format: "lrc" }); 30 | expect(content).toMatch(/\[ar:.*\]/); 31 | expect(content).toMatch(/\[ti:.*\]/); 32 | expect(content).toMatch(/\[au:.*\]/); 33 | expect(content).toMatch(/\[al:.*\]/); 34 | expect(content).toMatch(/\[by:.*\]/); 35 | expect(content).toMatch(/\[offset:.*\]/); 36 | }); 37 | 38 | test("should not have duplicate WEBVTT header", () => { 39 | const vtt = readFileSync("./test/fixtures/sample.vtt", "utf8"); 40 | const captions = parse(vtt, { format: "vtt" }); 41 | const content = build(captions, { format: "vtt" }); 42 | expect(content).not.toMatch(/WEBVTT\r?\nWEBVTT/); 43 | }); 44 | }); 45 | -------------------------------------------------------------------------------- /test/convert.test.ts: -------------------------------------------------------------------------------- 1 | import { readFileSync } from "fs"; 2 | 3 | import { convert, detect, list, parse } from "../lib/subsrt"; 4 | import { ContentCaption } from "../lib/types/handler"; 5 | 6 | describe("Convert", () => { 7 | const extensions = list(); 8 | const cases = extensions.map((ext1) => extensions.map((ext2) => [ext1, ext2])).flat(); 9 | test.each(cases)("should convert a subtitle file", (ext1, ext2) => { 10 | if (ext1 === ext2) { 11 | return; 12 | } 13 | 14 | console.log(`Convert .${ext1} to .${ext2}`); 15 | 16 | const content1 = readFileSync(`./test/fixtures/sample.${ext1}`, "utf8"); 17 | const content2 = convert(content1, { from: ext1, to: ext2 }); 18 | 19 | expect(typeof content2).toBe("string"); 20 | expect(content2.length).toBeGreaterThan(0); 21 | 22 | const format = detect(content2); 23 | expect(format).toBe(ext2); 24 | 25 | const captions = parse(content2, { format: ext2 }); 26 | expect(typeof captions).not.toBe("undefined"); 27 | expect(captions.length).toBeGreaterThan(0); 28 | 29 | // String "to" format as second argument 30 | const content3 = convert(content1, ext2); 31 | expect(content3).toBe(content2); 32 | }); 33 | 34 | test("should resync +3000 ms after conversion", () => { 35 | const srt = readFileSync("./test/fixtures/sample.srt", "utf8"); 36 | const captions = parse(srt, { format: "srt" }); 37 | const convertedAndResynced = convert(srt, { 38 | from: "srt", 39 | to: "vtt", 40 | resync: { 41 | offset: 3000, 42 | }, 43 | }); 44 | 45 | expect(typeof convertedAndResynced).toBe("string"); 46 | expect(convertedAndResynced.length).toBeGreaterThan(0); 47 | 48 | const format = detect(convertedAndResynced); 49 | expect(format).toBe("vtt"); 50 | 51 | const resynced = parse(convertedAndResynced, { format }); 52 | expect(typeof resynced).toBe("object"); 53 | expect(resynced.length).toBeGreaterThan(0); 54 | expect(resynced).toHaveLength(captions.length + 1); // Extra WEBVTT header 55 | 56 | expect((resynced[1] as ContentCaption).start).toBe((captions[0] as ContentCaption).start + 3000); 57 | expect((resynced[1] as ContentCaption).end).toBe((captions[0] as ContentCaption).end + 3000); 58 | }); 59 | }); 60 | -------------------------------------------------------------------------------- /test/customFormat.test.ts: -------------------------------------------------------------------------------- 1 | import { build, convert, detect, format, parse, resync } from "../lib/subsrt"; 2 | import { ContentCaption } from "../lib/types/handler"; 3 | 4 | describe("Custom format", () => { 5 | beforeAll(() => { 6 | format.line = { 7 | name: "line", 8 | parse: (content, _options) => 9 | content.split("\n").map((text, index) => ({ 10 | type: "caption", 11 | index, 12 | start: index * 10, 13 | end: (index + 1) * 10 - 1, 14 | duration: 10, 15 | content: text, 16 | text, 17 | })), 18 | build: (captions, options) => captions.map((caption) => (caption as ContentCaption).text).join(options.eol ?? "\n"), 19 | detect: (content) => content.includes("\n"), 20 | }; 21 | }); 22 | 23 | test("should parse a custom format", () => { 24 | const content = "Hello\nWorld"; 25 | const captions = parse(content, { format: "line" }) as ContentCaption[]; 26 | 27 | expect(typeof captions).toBe("object"); 28 | expect(captions.length).toBeGreaterThan(0); 29 | expect(captions).toHaveLength(2); 30 | expect(captions[0].start).toBe(0); 31 | expect(captions[0].end).toBe(9); 32 | expect(captions[1].start).toBe(10); 33 | expect(captions[1].end).toBe(19); 34 | }); 35 | 36 | test("should build a custom format", () => { 37 | const captions = [ 38 | { 39 | type: "caption", 40 | index: 0, 41 | start: 0, 42 | end: 9, 43 | duration: 10, 44 | content: "Hello", 45 | text: "Hello", 46 | }, 47 | { 48 | type: "caption", 49 | index: 1, 50 | start: 10, 51 | end: 19, 52 | duration: 10, 53 | content: "World", 54 | text: "World", 55 | }, 56 | ] as ContentCaption[]; 57 | const content = build(captions, { format: "line" }); 58 | 59 | expect(typeof content).toBe("string"); 60 | expect(content.length).toBeGreaterThan(0); 61 | expect(content).toBe("Hello\nWorld"); 62 | }); 63 | 64 | test("should detect a custom format", () => { 65 | const content = "Hello\nWorld"; 66 | const detected = detect(content); 67 | 68 | expect(typeof detected).toBe("string"); 69 | expect(detected.length).toBeGreaterThan(0); 70 | expect(detected).toBe("line"); 71 | }); 72 | 73 | test("should convert a custom format", () => { 74 | const content = "Hello\nWorld"; 75 | const converted = convert(content, { from: "line", to: "srt" }); 76 | 77 | expect(typeof converted).toBe("string"); 78 | expect(converted.length).toBeGreaterThan(0); 79 | 80 | const convertedAndResynced = convert(content, { 81 | from: "line", 82 | to: "srt", 83 | resync: { 84 | offset: 3000, 85 | }, 86 | }); 87 | expect(typeof convertedAndResynced).toBe("string"); 88 | expect(convertedAndResynced.length).toBeGreaterThan(0); 89 | expect(convertedAndResynced).toHaveLength(converted.length); 90 | 91 | const format = detect(convertedAndResynced); 92 | expect(format).toBe("srt"); 93 | 94 | const resynced = parse(convertedAndResynced, { format }); 95 | expect(typeof resynced).toBe("object"); 96 | expect(resynced.length).toBeGreaterThan(0); 97 | expect(resynced).toHaveLength(2); 98 | expect((resynced[0] as ContentCaption).start).toBe(3000); 99 | expect((resynced[0] as ContentCaption).end).toBe(3009); 100 | }); 101 | 102 | test("should resync a custom format", () => { 103 | const content = "Hello\nWorld"; 104 | const captions = parse(content, { format: "line" }) as ContentCaption[]; 105 | const resynced = resync(captions, +3000); 106 | 107 | expect(typeof resynced).toBe("object"); 108 | expect(resynced.length).toBeGreaterThan(0); 109 | expect(resynced).toHaveLength(captions.length); 110 | expect((resynced[0] as ContentCaption).start).toBe(captions[0].start + 3000); 111 | expect((resynced[0] as ContentCaption).end).toBe(captions[0].end + 3000); 112 | 113 | const content2 = build(resynced, { format: "line" }); 114 | expect(typeof content2).toBe("string"); 115 | expect(content2.length).toBeGreaterThan(0); 116 | expect(content2).toBe("Hello\nWorld"); 117 | }); 118 | 119 | test("should throw an error or skip when the format does not have corresponding methods", () => { 120 | // @ts-expect-error For testing purposes 121 | format.line.build = undefined; 122 | expect(() => { 123 | build([], { format: "line" }); 124 | }).toThrow(TypeError); 125 | 126 | // @ts-expect-error For testing purposes 127 | format.line.parse = undefined; 128 | expect(() => { 129 | parse("Hello\nWorld", { format: "line" }); 130 | }).toThrow(TypeError); 131 | 132 | // @ts-expect-error For testing purposes 133 | format.line.detect = undefined; 134 | expect(detect("Hello\nWorld")).toBe(""); 135 | }); 136 | }); 137 | -------------------------------------------------------------------------------- /test/detect.test.ts: -------------------------------------------------------------------------------- 1 | import { readFileSync } from "fs"; 2 | 3 | import { detect, list } from "../lib/subsrt"; 4 | 5 | describe("Detect", () => { 6 | const formats = list(); 7 | test.each(formats)("should detect a subtitle file", (ext) => { 8 | console.log(`Detect .${ext}`); 9 | const content = readFileSync(`./test/fixtures/sample.${ext}`, "utf8"); 10 | 11 | const expected = ext; 12 | const actual = detect(content); 13 | 14 | expect(actual).toBe(expected); 15 | }); 16 | 17 | test("should return an empty string when the format is not supported", () => { 18 | expect(detect("Hello\nWorld")).toBe(""); 19 | }); 20 | 21 | test("should throw an error when the input is not a string", () => { 22 | // @ts-expect-error For testing purposes 23 | expect(() => detect(1)).toThrow(TypeError); 24 | }); 25 | }); 26 | -------------------------------------------------------------------------------- /test/fixtures/sample.ass: -------------------------------------------------------------------------------- 1 | [Script Info] 2 | ; Script generated by Aegisub 3 | ; http://www.aegisub.net 4 | Title: Neon Genesis Evangelion - Episode 26 (neutral Spanish) 5 | Original Script: RoRo 6 | Script Updated By: version 2.8.01 7 | ScriptType: v4.00+ 8 | Collisions: Normal 9 | PlayResY: 600 10 | PlayDepth: 0 11 | Timer: 100,0000 12 | Video Aspect Ratio: 0 13 | Video Zoom: 6 14 | Video Position: 0 15 | 16 | [V4+ Styles] 17 | Format: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding 18 | Style: DefaultVCD, Arial,28,&H00B4FCFC,&H00B4FCFC,&H00000008,&H80000008,-1,0,0,0,100,100,0.00,0.00,1,1.00,2.00,2,30,30,30,0 19 | 20 | [Events] 21 | Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text 22 | Dialogue: 0,0:00:01.18,0:00:06.85,DefaultVCD, NTP,0000,0000,0000,,{\pos(400,570)}Like an Angel with pity on nobody\NThe second line in subtitle 23 | -------------------------------------------------------------------------------- /test/fixtures/sample.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "type": "caption", 4 | "index": 1, 5 | "start": 599, 6 | "end": 4160, 7 | "duration": 3561, 8 | "content": ">> ALICE: Hi, my name is Alice Miller and this is John Brown", 9 | "text": "Hi, my name is Alice Miller and this is John Brown" 10 | }, 11 | { 12 | "type": "caption", 13 | "index": 2, 14 | "start": 4160, 15 | "end": 6770, 16 | "duration": 2610, 17 | "content": ">> JOHN: and we're the owners of Miller Bakery.", 18 | "text": "and we're the owners of Miller Bakery." 19 | }, 20 | { 21 | "type": "caption", 22 | "index": 3, 23 | "start": 6770, 24 | "end": 10880, 25 | "duration": 4110, 26 | "content": ">> ALICE: Today we'll be teaching you how to make\r\nour famous chocolate chip cookies!", 27 | "text": "Today we'll be teaching you how to make\r\nour famous chocolate chip cookies!" 28 | }, 29 | { 30 | "type": "caption", 31 | "index": 4, 32 | "start": 10880, 33 | "end": 16700, 34 | "duration": 5820, 35 | "content": "[intro music]", 36 | "text": "[intro music]" 37 | }, 38 | { 39 | "type": "caption", 40 | "index": 5, 41 | "start": 16700, 42 | "end": 21480, 43 | "duration": 4780, 44 | "content": "Okay, so we have all the ingredients laid out here", 45 | "text": "Okay, so we have all the ingredients laid out here" 46 | } 47 | ] 48 | -------------------------------------------------------------------------------- /test/fixtures/sample.lrc: -------------------------------------------------------------------------------- 1 | [ar:Lyrics artist] 2 | [al:Album where the song is from] 3 | [ti:Lyrics (song) title] 4 | [au:Creator of the Songtext] 5 | [length:How long the song is] 6 | [by:Creator of the LRC file] 7 | [offset:+/- Overall timestamp adjustment in milliseconds, + shifts time up, - shifts down] 8 | 9 | [re:The player or editor that created the LRC file] 10 | [ve:version of program] 11 | 12 | [00:12.00]Line 1 lyrics 13 | [00:17.20]F: Line 2 lyrics 14 | [00:21.10]M: Line 3 lyrics 15 | [00:24.00]Line 4 lyrics 16 | [00:28.25]D: Line 5 lyrics 17 | [00:29.02]Line 6 lyrics 18 | -------------------------------------------------------------------------------- /test/fixtures/sample.sbv: -------------------------------------------------------------------------------- 1 | 0:00:00.599,0:00:04.160 2 | >> ALICE: Hi, my name is Alice Miller[br]and this is John Brown 3 | 4 | 0:00:04.160,0:00:06.770 5 | >> JOHN: and we're the owners of Miller Bakery. 6 | 7 | 0:00:06.770,0:00:10.880 8 | >> ALICE: Today we'll be teaching you how to make 9 | our famous chocolate chip cookies! 10 | 11 | 0:00:10.880,0:00:16.700 12 | [intro music] 13 | 14 | 0:00:16.700,0:00:21.480 15 | Okay, so we have all the ingredients laid out here 16 | -------------------------------------------------------------------------------- /test/fixtures/sample.smi: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 13 | 14 | 15 |

16 | 500년 전, 17 |

  18 |

19 | 살려서는 안 될 인간의 목숨을 구했다 20 |

  21 |

22 | 이 때문에, 그녀는 얼음연못에 감금되었다 23 |

24 | 밤낮으로 극심한 추위가 고통이었다 25 |

  26 |

27 | 매일 연못 위를 맴돌았다 28 |

  29 |

30 | 빨리! 빨리! 31 |

  32 | 33 | 34 | -------------------------------------------------------------------------------- /test/fixtures/sample.srt: -------------------------------------------------------------------------------- 1 | 1 2 | 00:00:00,599 --> 00:00:04,160 3 | >> ALICE: Hi, my name is Alice Miller and this is John Brown 4 | 5 | 2 6 | 00:00:04,160 --> 00:00:06,770 7 | >> JOHN: and we're the owners of Miller Bakery. 8 | 9 | 3 10 | 00:00:06,770 --> 00:00:10,880 11 | >> ALICE: Today we'll be teaching you how to make 12 | our famous chocolate chip cookies! 13 | 14 | 4 15 | 00:00:10,880 --> 00:00:16,700 16 | [intro music] 17 | 18 | 5 19 | 00:00:16,700 --> 00:00:21,480 20 | Okay, so we have all the ingredients laid out here 21 | -------------------------------------------------------------------------------- /test/fixtures/sample.ssa: -------------------------------------------------------------------------------- 1 | [Script Info] 2 | ; This is a Sub Station Alpha v4 script. 3 | ; For Sub Station Alpha info and downloads, 4 | ; go to http://www.eswat.demon.co.uk/ 5 | Title: Neon Genesis Evangelion - Episode 26 (neutral Spanish) 6 | Original Script: RoRo 7 | Script Updated By: version 2.8.01 8 | ScriptType: v4.00 9 | Collisions: Normal 10 | PlayResY: 600 11 | PlayDepth: 0 12 | Timer: 100,0000 13 | 14 | [V4 Styles] 15 | Format: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, TertiaryColour, BackColour, Bold, Italic, BorderStyle, Outline, Shadow, 16 | Alignment, MarginL, MarginR, MarginV, AlphaLevel, Encoding 17 | Style: DefaultVCD, Arial,28,11861244,11861244,11861244,-2147483640,-1,0,1,1,2,2,30,30,30,0,0 18 | 19 | [Events] 20 | Format: Marked, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text 21 | Dialogue: Marked=0,0:00:01.18,0:00:06.85,DefaultVCD, NTP,0000,0000,0000,,{\pos(400,570)}Like an angel with pity on nobody 22 | -------------------------------------------------------------------------------- /test/fixtures/sample.sub: -------------------------------------------------------------------------------- 1 | {14975}{104000}Hi, my name is Alice Miller and this is John Brown 2 | {104000}{169250}and we're the owners of Miller Bakery. 3 | {169250}{272000}Today we'll be teaching you how to make|our famous chocolate chip cookies! 4 | {272000}{417500}[intro music] 5 | {417500}{537000}Okay, so we have all the ingredients laid out here 6 | -------------------------------------------------------------------------------- /test/fixtures/sample.vtt: -------------------------------------------------------------------------------- 1 | WEBVTT 2 | 3 | NOTE 4 | This file was written by Jill. I hope 5 | you enjoy reading it. Some things to 6 | bear in mind: 7 | - I was lip-reading, so the cues may 8 | not be 100% accurate 9 | - I didn’t pay too close attention to 10 | when the cues should start or end. 11 | 12 | STYLE 13 | ::cue { 14 | background-image: linear-gradient(to bottom, dimgray, lightgray); 15 | color: papayawhip; 16 | } 17 | /* Style blocks cannot use blank lines nor "dash dash greater than" */ 18 | 19 | 00:01.000 --> 00:04.000 20 | — Hi, my name is Alice Miller and this is John Brown. 21 | — And we're the owners of Miller Bakery. 22 | 23 | NOTE check next cue 24 | 25 | 00:05.000 --> 00:09.000 26 | Today we'll be teaching you how to make 27 | our famous chocolate chip cookies! 28 | 29 | test 30 | 00:20.000 --> 00:22.000 31 | This is a test. 32 | 33 | 123 34 | 00:22.000 --> 00:24.000 35 | That’s an, an, that’s an L! 36 | 37 | crédit de transcription 38 | 00:24.000 --> 00:30.000 39 | Okay, so we have all the ingredients laid out here 40 | 41 | NOTE end of file 42 | -------------------------------------------------------------------------------- /test/parse.test.ts: -------------------------------------------------------------------------------- 1 | import { readFileSync } from "fs"; 2 | 3 | import { list, parse } from "../lib/subsrt"; 4 | 5 | describe("Parse", () => { 6 | const formats = list(); 7 | test.each(formats)("should parse a subtitle file", (ext) => { 8 | console.log(`Parse .${ext}`); 9 | const content = readFileSync(`./test/fixtures/sample.${ext}`, "utf8"); 10 | const captions = parse(content, { format: ext }); 11 | expect(captions.length).toBeGreaterThan(1); 12 | }); 13 | 14 | test("should throw an error when the format is not supported", () => { 15 | expect(() => parse("Hello\nWorld")).toThrow(TypeError); 16 | expect(() => { 17 | parse("Hello\nWorld", { format: "unsupported" }); 18 | }).toThrow(TypeError); 19 | }); 20 | 21 | test("should throw an error when the input is not a string", () => { 22 | // @ts-expect-error For testing purposes 23 | expect(() => parse(1)).toThrow(TypeError); 24 | }); 25 | }); 26 | -------------------------------------------------------------------------------- /test/resync.test.ts: -------------------------------------------------------------------------------- 1 | import { readFileSync } from "fs"; 2 | 3 | import { SUBParseOptions } from "../lib/format/types/sub"; 4 | import { parse, resync } from "../lib/subsrt"; 5 | import { ContentCaption } from "../lib/types/handler"; 6 | 7 | describe("Resync", () => { 8 | test("should resync +3000 ms", () => { 9 | const srt = readFileSync("./test/fixtures/sample.srt", "utf8"); 10 | const captions = parse(srt); 11 | const resynced = resync(captions, +3000); 12 | 13 | expect(typeof resynced).toBe("object"); 14 | expect(resynced.length).toBeGreaterThan(0); 15 | expect(resynced).toHaveLength(captions.length); 16 | 17 | expect((resynced[0] as ContentCaption).start).toBe((captions[0] as ContentCaption).start + 3000); 18 | expect((resynced[0] as ContentCaption).end).toBe((captions[0] as ContentCaption).end + 3000); 19 | }); 20 | 21 | test("should resync -250 ms", () => { 22 | const sbv = readFileSync("./test/fixtures/sample.sbv", "utf8"); 23 | const captions = parse(sbv); 24 | const resynced = resync(captions, -250); 25 | 26 | expect(typeof resynced).toBe("object"); 27 | expect(resynced.length).toBeGreaterThan(0); 28 | expect(resynced).toHaveLength(captions.length); 29 | 30 | expect((resynced[3] as ContentCaption).start).toBe((captions[3] as ContentCaption).start - 250); 31 | expect((resynced[3] as ContentCaption).end).toBe((captions[3] as ContentCaption).end - 250); 32 | }); 33 | 34 | test("should resync 25 to 30 FPS", () => { 35 | const sub = readFileSync("./test/fixtures/sample.sub", "utf8"); 36 | const captions = parse(sub, { fps: 25 } as SUBParseOptions); 37 | const resynced = resync(captions, { ratio: 30 / 25, frame: true }); 38 | 39 | expect(typeof resynced).toBe("object"); 40 | expect(resynced.length).toBeGreaterThan(0); 41 | expect(resynced).toHaveLength(captions.length); 42 | 43 | expect((resynced[3] as ContentCaption).frame).toBeDefined(); 44 | expect((resynced[3] as ContentCaption).frame?.start).toBe((((captions[3] as ContentCaption)?.frame?.start ?? 0) * 30) / 25); 45 | expect((resynced[3] as ContentCaption).frame?.end).toBe((((captions[3] as ContentCaption)?.frame?.end ?? 0) * 30) / 25); 46 | expect((resynced[3] as ContentCaption).frame?.count).toBe((((captions[3] as ContentCaption)?.frame?.count ?? 0) * 30) / 25); 47 | }); 48 | 49 | test("should resync with non-linear function", () => { 50 | const vtt = readFileSync("./test/fixtures/sample.vtt", "utf8"); 51 | const captions = parse(vtt); 52 | const resynced = resync(captions, (a) => [ 53 | a[0] + 0, // Keep the start time 54 | a[1] + 500, // Extend each end time by 500 ms 55 | ]); 56 | 57 | expect(typeof resynced).toBe("object"); 58 | expect(resynced.length).toBeGreaterThan(0); 59 | expect(resynced).toHaveLength(captions.length); 60 | 61 | expect((resynced[3] as ContentCaption).start).toBe((captions[3] as ContentCaption).start); 62 | expect((resynced[3] as ContentCaption).end).toBe((captions[3] as ContentCaption).end + 500); 63 | }); 64 | }); 65 | -------------------------------------------------------------------------------- /test/time.test.ts: -------------------------------------------------------------------------------- 1 | import { format, list } from "../lib/subsrt"; 2 | 3 | describe("Time", () => { 4 | const formats = list(); 5 | const fixtures = [0, 90, 1000, 60000, 3600000, 7236250]; 6 | test.each(formats)("should convert time to milliseconds", (ext) => { 7 | console.log(`Time .${ext}`); 8 | const handler = format[ext]; 9 | if (!handler.helper) { 10 | console.log(`Time .${ext} skipped`); 11 | return; 12 | } 13 | const toMilliseconds = handler.helper.toMilliseconds, 14 | toTimeString = handler.helper.toTimeString; 15 | if (typeof toMilliseconds !== "function" || typeof toTimeString !== "function") { 16 | console.log(`Time .${ext} skipped`); 17 | return; 18 | } 19 | for (const value of fixtures) { 20 | const str = toTimeString(value); 21 | const ms = toMilliseconds(str); 22 | expect(ms).toBe(value); 23 | } 24 | 25 | // Test for invalid time 26 | expect(() => { 27 | toMilliseconds("Hello"); 28 | }).toThrow(TypeError); 29 | }); 30 | }); 31 | -------------------------------------------------------------------------------- /tsconfig.eslint.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "noEmit": true, 4 | "types": ["@types/node", "jest"] 5 | }, 6 | "exclude": ["dist"], 7 | "extends": "./tsconfig.json" 8 | } 9 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "allowSyntheticDefaultImports": true, 4 | "declaration": true, 5 | "lib": ["es2022", "dom"], 6 | "module": "es2022", 7 | "moduleResolution": "Node", 8 | "outDir": "dist", 9 | "rootDir": "lib", 10 | "strict": true, 11 | "target": "es6" 12 | }, 13 | "exclude": ["test", "dist"] 14 | } 15 | --------------------------------------------------------------------------------