├── .nvmrc ├── .gitattributes ├── packages ├── sample │ ├── src │ │ ├── index.d.ts │ │ ├── components │ │ │ ├── index.ts │ │ │ ├── customElements │ │ │ │ ├── index.ts │ │ │ │ ├── scheduled-textarea │ │ │ │ │ └── index.ts │ │ │ │ └── fake-video │ │ │ │ │ ├── index.ts │ │ │ │ │ └── controls.ts │ │ │ └── TrackScheduler │ │ │ │ ├── DebouncedOperation.ts │ │ │ │ └── index.ts │ │ └── longtexttrack-chunk1.vtt │ ├── CHANGELOG.md │ ├── public │ │ └── bigbuckbunny.webm │ ├── tsconfig.json │ ├── package.json │ ├── index.html │ ├── README.md │ ├── vite.config.mjs │ └── pages │ │ ├── sub37-example │ │ ├── style.css │ │ ├── index.html │ │ └── script.mjs │ │ └── native-video │ │ ├── script.mjs │ │ └── index.html ├── webvtt-adapter │ ├── src │ │ ├── index.ts │ │ ├── Parser │ │ │ ├── index.ts │ │ │ ├── Tags │ │ │ │ ├── index.ts │ │ │ │ ├── Node.ts │ │ │ │ ├── tokenEntities.ts │ │ │ │ ├── NodeQueue.ts │ │ │ │ └── utils.ts │ │ │ ├── Timestamps.utils.ts │ │ │ ├── parseRegion.ts │ │ │ ├── parseCue.ts │ │ │ ├── parseStyle.ts │ │ │ └── RenderingModifiers.ts │ │ ├── MissingContentError.ts │ │ ├── EmptyStyleDeclarationError.ts │ │ ├── InvalidStyleDeclarationError.ts │ │ ├── MalformedStyleBlockError.ts │ │ ├── InvalidFormatError.ts │ │ ├── Token.ts │ │ └── Tokenizer.ts │ ├── tsconfig.json │ ├── tsconfig.build.json │ ├── package.json │ ├── CHANGELOG.md │ ├── README.md │ └── specs │ │ └── Token.spec.mjs ├── server │ ├── src │ │ ├── Entities │ │ │ ├── index.ts │ │ │ ├── Generic.ts │ │ │ └── Tag.ts │ │ ├── Track │ │ │ ├── index.ts │ │ │ ├── TrackRecord.ts │ │ │ ├── Track.ts │ │ │ └── appendChunkToTrack.ts │ │ ├── Errors │ │ │ ├── ServerAlreadyRunningError.ts │ │ │ ├── ServerNotRunningError.ts │ │ │ ├── ActiveTrackMissingError.ts │ │ │ ├── SessionNotStartedError.ts │ │ │ ├── SessionNotInitializedError.ts │ │ │ ├── UnexpectedDataFormatError.ts │ │ │ ├── AdaptersMissingError.ts │ │ │ ├── ParsingError.ts │ │ │ ├── AdapterNotExtendingPrototypeError.ts │ │ │ ├── UnexpectedParsingOutputFormatError.ts │ │ │ ├── utils.ts │ │ │ ├── AdapterNotOverridingSupportedTypesError.ts │ │ │ ├── AdapterNotOverridingToStringError.ts │ │ │ ├── UnparsableContentError.ts │ │ │ ├── NoAdaptersFoundError.ts │ │ │ ├── UnsupportedContentError.ts │ │ │ ├── OutOfRangeFrequencyError.ts │ │ │ ├── UncaughtParsingExceptionError.ts │ │ │ └── index.ts │ │ ├── RenderingModifiers.ts │ │ ├── index.ts │ │ ├── Region.ts │ │ ├── SuspendableTimer.ts │ │ ├── DistributionSession.ts │ │ ├── BaseAdapter │ │ │ └── index.ts │ │ ├── CueNode.ts │ │ └── IntervalBinaryTree.ts │ ├── tsconfig.json │ ├── tsconfig.build.json │ ├── CHANGELOG.md │ ├── specs │ │ ├── Entities.spec.mjs │ │ ├── DistributionSession.spec.mjs │ │ └── IntervalBinaryTree.spec.mjs │ └── package.json └── captions-renderer │ ├── tsconfig.json │ ├── tsconfig.build.json │ ├── src │ ├── constants.ts │ └── index.ts │ ├── package.json │ ├── CHANGELOG.md │ ├── specs │ ├── RendererFixture.ts │ └── renderer.spec.pw.ts │ ├── playwright.config.js │ └── README.md ├── assets ├── logo.afdesign ├── social.afdesign └── wiki │ ├── Entities.afdesign │ ├── timeline.afdesign │ ├── Subtitles vs captions.png │ ├── architecture-schema.afdesign │ ├── Subtitles vs captions.afdesign │ ├── timeline.svg │ ├── Entities.svg │ └── timeline-ibt.svg ├── .vscode ├── extensions.json ├── settings.json └── launch.json ├── pnpm-workspace.yaml ├── tsconfig.json ├── .prettierrc ├── lerna.json ├── .gitignore ├── tsconfig.build.json ├── .github └── workflows │ └── run-tests.yml ├── package.json ├── README.md └── jest.config.mjs /.nvmrc: -------------------------------------------------------------------------------- 1 | 22 -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | *.webm filter=lfs diff=lfs merge=lfs -text 2 | -------------------------------------------------------------------------------- /packages/sample/src/index.d.ts: -------------------------------------------------------------------------------- 1 | declare module "*.vtt" {} 2 | -------------------------------------------------------------------------------- /assets/logo.afdesign: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/alexandercerutti/sub37/HEAD/assets/logo.afdesign -------------------------------------------------------------------------------- /packages/webvtt-adapter/src/index.ts: -------------------------------------------------------------------------------- 1 | export { default as WebVTTAdapter } from "./Adapter.js"; 2 | -------------------------------------------------------------------------------- /assets/social.afdesign: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/alexandercerutti/sub37/HEAD/assets/social.afdesign -------------------------------------------------------------------------------- /packages/server/src/Entities/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./Tag.js"; 2 | export * from "./Generic.js"; 3 | -------------------------------------------------------------------------------- /.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | "recommendations": ["Orta.vscode-jest", "esbenp.prettier-vscode"] 3 | } 4 | -------------------------------------------------------------------------------- /packages/sample/CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # @sub37/sample-app 2 | 3 | ## 1.0.0 4 | 5 | - First version released 6 | -------------------------------------------------------------------------------- /pnpm-workspace.yaml: -------------------------------------------------------------------------------- 1 | packages: 2 | - packages/* 3 | 4 | onlyBuiltDependencies: 5 | - esbuild 6 | - nx 7 | -------------------------------------------------------------------------------- /assets/wiki/Entities.afdesign: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/alexandercerutti/sub37/HEAD/assets/wiki/Entities.afdesign -------------------------------------------------------------------------------- /assets/wiki/timeline.afdesign: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/alexandercerutti/sub37/HEAD/assets/wiki/timeline.afdesign -------------------------------------------------------------------------------- /packages/sample/src/components/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./TrackScheduler/index.js"; 2 | export * from "./customElements"; 3 | -------------------------------------------------------------------------------- /assets/wiki/Subtitles vs captions.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/alexandercerutti/sub37/HEAD/assets/wiki/Subtitles vs captions.png -------------------------------------------------------------------------------- /assets/wiki/architecture-schema.afdesign: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/alexandercerutti/sub37/HEAD/assets/wiki/architecture-schema.afdesign -------------------------------------------------------------------------------- /assets/wiki/Subtitles vs captions.afdesign: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/alexandercerutti/sub37/HEAD/assets/wiki/Subtitles vs captions.afdesign -------------------------------------------------------------------------------- /packages/sample/src/components/customElements/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./fake-video/index.js"; 2 | export * from "./scheduled-textarea/index.js"; 3 | -------------------------------------------------------------------------------- /packages/webvtt-adapter/src/Parser/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./parseCue.js"; 2 | export * from "./parseRegion.js"; 3 | export * from "./parseStyle.js"; 4 | -------------------------------------------------------------------------------- /packages/server/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig.json", 3 | "compilerOptions": { 4 | "outDir": "lib" 5 | }, 6 | "include": ["src/**/*"] 7 | } 8 | -------------------------------------------------------------------------------- /packages/webvtt-adapter/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig.json", 3 | "compilerOptions": { 4 | "outDir": "lib" 5 | }, 6 | "include": ["src/**/*"] 7 | } 8 | -------------------------------------------------------------------------------- /packages/captions-renderer/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig.json", 3 | "compilerOptions": { 4 | "outDir": "lib" 5 | }, 6 | "include": ["src/**/*"] 7 | } 8 | -------------------------------------------------------------------------------- /packages/server/tsconfig.build.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig.build.json", 3 | "compilerOptions": { 4 | "outDir": "lib" 5 | }, 6 | "include": ["src/**/*"] 7 | } 8 | -------------------------------------------------------------------------------- /packages/captions-renderer/tsconfig.build.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig.build.json", 3 | "compilerOptions": { 4 | "outDir": "lib" 5 | }, 6 | "include": ["src/**/*"] 7 | } 8 | -------------------------------------------------------------------------------- /packages/sample/public/bigbuckbunny.webm: -------------------------------------------------------------------------------- 1 | version https://git-lfs.github.com/spec/v1 2 | oid sha256:133cefac7d54f968c42da403bc7e95ef9fcb3293d12de81ffaccb97730da9f22 3 | size 144434285 4 | -------------------------------------------------------------------------------- /packages/webvtt-adapter/src/Parser/Tags/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./utils.js"; 2 | export { default as Node } from "./Node.js"; 3 | export { default as NodeQueue } from "./NodeQueue.js"; 4 | -------------------------------------------------------------------------------- /packages/webvtt-adapter/tsconfig.build.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig.build.json", 3 | "compilerOptions": { 4 | "outDir": "lib" 5 | }, 6 | "include": ["src/**/*"] 7 | } 8 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.build.json", 3 | "compilerOptions": { 4 | "noEmit": true, 5 | "baseUrl": ".", 6 | "paths": { 7 | "@sub37/*": ["packages/*/src"] 8 | } 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /packages/server/src/Track/index.ts: -------------------------------------------------------------------------------- 1 | export { default as Track } from "./Track.js"; 2 | export { appendChunkToTrack as appendChunk } from "./appendChunkToTrack.js"; 3 | export type { TrackRecord } from "./TrackRecord"; 4 | -------------------------------------------------------------------------------- /packages/webvtt-adapter/src/MissingContentError.ts: -------------------------------------------------------------------------------- 1 | export class MissingContentError extends Error { 2 | constructor() { 3 | super(); 4 | this.name = "MissingContentError"; 5 | this.message = "Cannot parse content. Empty content received."; 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "editor.tabSize": 2, 3 | "editor.trimAutoWhitespace": true, 4 | "editor.detectIndentation": true, 5 | 6 | "jest.jestCommandLine": "NODE_OPTIONS=\"--experimental-vm-modules --no-warnings\" pnpm jest -c jest.config.mjs --runInBand" 7 | } 8 | -------------------------------------------------------------------------------- /packages/sample/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig.json", 3 | "compilerOptions": { 4 | "target": "ESNext", 5 | "useDefineForClassFields": true, 6 | "resolveJsonModule": true, 7 | "isolatedModules": true, 8 | "noEmit": true 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /packages/server/CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # @sub37/server 2 | 3 | ## **1.1.0** 4 | 5 | - Added support to a new property `height` to the `Region` protocol in order to let adapters to specify an height; 6 | 7 | --- 8 | 9 | ## **1.0.0** 10 | 11 | - First version released 12 | -------------------------------------------------------------------------------- /packages/webvtt-adapter/src/EmptyStyleDeclarationError.ts: -------------------------------------------------------------------------------- 1 | export class EmptyStyleDeclarationError extends Error { 2 | constructor() { 3 | super(); 4 | this.name = "EmptyStyleDeclarationError"; 5 | this.message = `The provided style block resulted in an empty css ruleset.`; 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "trailingComma": "all", 3 | "tabWidth": 2, 4 | "semi": true, 5 | "singleQuote": false, 6 | "endOfLine": "lf", 7 | "arrowParens": "always", 8 | "useTabs": true, 9 | "printWidth": 100, 10 | "jsxSingleQuote": false, 11 | "htmlWhitespaceSensitivity": "css" 12 | } 13 | -------------------------------------------------------------------------------- /lerna.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "node_modules/lerna/schemas/lerna-schema.json", 3 | "workspaces": ["packages/*"], 4 | "version": "independent", 5 | "npmClient": "pnpm", 6 | "includeMergedTags": true, 7 | "command": { 8 | "version": { 9 | "allowBranch": "master" 10 | } 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /packages/server/src/Track/TrackRecord.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Represents the track data that developers 3 | * will have to use to add a track 4 | */ 5 | 6 | export interface TrackRecord { 7 | lang: string; 8 | content: unknown; 9 | mimeType: `${string}/${string}`; 10 | active?: boolean; 11 | } 12 | -------------------------------------------------------------------------------- /packages/webvtt-adapter/src/Parser/Tags/Node.ts: -------------------------------------------------------------------------------- 1 | import { Token } from "../../Token"; 2 | 3 | export default class Node { 4 | public parent: Node = null; 5 | 6 | constructor( 7 | /** Zero-based position of cue (or timestamp section) content */ 8 | public index: number, 9 | public token: Token, 10 | ) {} 11 | } 12 | -------------------------------------------------------------------------------- /packages/server/src/Errors/ServerAlreadyRunningError.ts: -------------------------------------------------------------------------------- 1 | export class ServerAlreadyRunningError extends Error { 2 | constructor() { 3 | super(); 4 | 5 | const message = `Server is already running. Cannot perform operation.`; 6 | 7 | this.name = "ServerAlreadyRunningError"; 8 | this.message = message; 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /packages/server/src/Errors/ServerNotRunningError.ts: -------------------------------------------------------------------------------- 1 | export class ServerNotRunningError extends Error { 2 | constructor() { 3 | super(); 4 | 5 | const message = `Server has been started but is not running. Cannot perform operation.`; 6 | 7 | this.name = "ServerNotRunningError"; 8 | this.message = message; 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /packages/server/src/Errors/ActiveTrackMissingError.ts: -------------------------------------------------------------------------------- 1 | export class ActiveTrackMissingError extends Error { 2 | constructor() { 3 | super(); 4 | 5 | const message = ` 6 | No active track has been set. Cannot retrieve active cues. 7 | `; 8 | 9 | this.name = "ActiveTrackMissingError"; 10 | this.message = message; 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /packages/server/src/Errors/SessionNotStartedError.ts: -------------------------------------------------------------------------------- 1 | export class SessionNotStartedError extends Error { 2 | constructor() { 3 | super(); 4 | 5 | const message = `Session has been created but not been started yet. Cannot perform any operation.`; 6 | 7 | this.name = "SessionNotStartedError"; 8 | this.message = message; 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /packages/server/src/Errors/SessionNotInitializedError.ts: -------------------------------------------------------------------------------- 1 | export class SessionNotInitializedError extends Error { 2 | constructor() { 3 | super(); 4 | 5 | const message = `No session initialized. Cannot start or perform other session operations.`; 6 | 7 | this.name = "SessionNotInitializedError"; 8 | this.message = message; 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /packages/server/src/Errors/UnexpectedDataFormatError.ts: -------------------------------------------------------------------------------- 1 | export class UnexpectedDataFormatError extends Error { 2 | public constructor(adapterName: string) { 3 | super(); 4 | 5 | this.name = "UnexpectedDataFormatError"; 6 | this.message = `${adapterName} returned an object that has a format different from the expected CueNode. This "cue" has been ignored.`; 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /packages/server/src/Errors/AdaptersMissingError.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * When Server will be instantiated without adapters, this will be the resulting error 3 | */ 4 | 5 | export class AdaptersMissingError extends Error { 6 | constructor() { 7 | super("Server is expected to be initialized with adapters. Received none."); 8 | this.name = "AdaptersMissingError"; 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /packages/webvtt-adapter/src/InvalidStyleDeclarationError.ts: -------------------------------------------------------------------------------- 1 | export class InvalidStyleDeclarationError extends Error { 2 | constructor() { 3 | super(); 4 | this.name = "InvalidStyleDeclarationError"; 5 | this.message = `The provided style block is invalid and will be ignored. The block must not contain empty lines and must have either a valid or no selector.`; 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /packages/webvtt-adapter/src/MalformedStyleBlockError.ts: -------------------------------------------------------------------------------- 1 | export class MalformedStyleBlockError extends Error { 2 | constructor() { 3 | super(); 4 | this.name = "MalformedStyleBlockError"; 5 | this.message = `The provided style block is malformed. Will be ignored. STYLE declaration must be immediately followed by the '::cue' declaration (no empty lines allowed).`; 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /packages/webvtt-adapter/src/Parser/Tags/tokenEntities.ts: -------------------------------------------------------------------------------- 1 | import { Entities } from "@sub37/server"; 2 | 3 | export const EntitiesTokenMap: { [key: string]: Entities.TagType } = { 4 | b: Entities.TagType.BOLD, 5 | c: Entities.TagType.CLASS, 6 | i: Entities.TagType.ITALIC, 7 | lang: Entities.TagType.LANG, 8 | rt: Entities.TagType.RT, 9 | ruby: Entities.TagType.RUBY, 10 | u: Entities.TagType.UNDERLINE, 11 | v: Entities.TagType.VOICE, 12 | }; 13 | -------------------------------------------------------------------------------- /packages/server/src/Errors/ParsingError.ts: -------------------------------------------------------------------------------- 1 | import { formatError } from "./utils.js"; 2 | 3 | export class ParsingError extends Error { 4 | constructor(originalError: unknown) { 5 | super(); 6 | 7 | const message = `Unable to create parsing session: critical issues prevented content parsing. 8 | More details below. 9 | 10 | ${formatError(originalError)} 11 | `; 12 | 13 | this.name = "ParsingError"; 14 | this.message = message; 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /packages/server/src/Errors/AdapterNotExtendingPrototypeError.ts: -------------------------------------------------------------------------------- 1 | export class AdapterNotExtendingPrototypeError extends Error { 2 | constructor(adapterName: string) { 3 | super(); 4 | 5 | this.name = "AdapterNotExtendingPrototypeError"; 6 | this.message = `${adapterName} does not extend BaseAdapter. 7 | 8 | If you are the adapter developer, please ensure yourself that your adapter is correctly extending BaseAdapter from 9 | the main package. 10 | `; 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /packages/sample/src/components/TrackScheduler/DebouncedOperation.ts: -------------------------------------------------------------------------------- 1 | export class DebouncedOperation { 2 | private timer: number; 3 | 4 | private constructor(timer: number) { 5 | this.timer = timer; 6 | } 7 | 8 | public static clear(operation: DebouncedOperation) { 9 | if (operation) { 10 | window.clearTimeout(operation.timer); 11 | } 12 | } 13 | 14 | public static create(fn: Function) { 15 | return new DebouncedOperation(window.setTimeout(fn, 1500)); 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /packages/server/src/Errors/UnexpectedParsingOutputFormatError.ts: -------------------------------------------------------------------------------- 1 | export class UnexpectedParsingOutputFormatError extends Error { 2 | public constructor(adapterName: string, lang: string, output: unknown) { 3 | super(); 4 | 5 | this.name = "UnexpectedParsingOutputFormatError"; 6 | this.message = `${adapterName} output for track in lang ${lang} doesn't seems to respect the required output format. 7 | Received: 8 | 9 | ${JSON.stringify(output)} 10 | `; 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | packages/**/lib 3 | **/spec/build 4 | .DS_Store 5 | 6 | 7 | 8 | # VITE 9 | 10 | # Logs 11 | logs 12 | *.log 13 | npm-debug.log* 14 | yarn-debug.log* 15 | yarn-error.log* 16 | pnpm-debug.log* 17 | lerna-debug.log* 18 | 19 | dist 20 | dist-ssr 21 | *.local 22 | 23 | # Editor directories and files 24 | .vscode/* 25 | !.vscode/extensions.json 26 | .idea 27 | *.suo 28 | *.ntvs* 29 | *.njsproj 30 | *.sln 31 | *.sw? 32 | test-results/ 33 | playwright-report/ 34 | playwright/.cache/ 35 | -------------------------------------------------------------------------------- /packages/server/src/Errors/utils.ts: -------------------------------------------------------------------------------- 1 | const INDENT_REGEX = /\n/g; 2 | 3 | function convertError(error: unknown): Error { 4 | if (error instanceof Error) { 5 | return error; 6 | } 7 | 8 | if (typeof error === "string") { 9 | return new Error(error); 10 | } 11 | 12 | return new Error(JSON.stringify(error)); 13 | } 14 | 15 | export function formatError(error: unknown): string { 16 | const wrapperError = convertError(error); 17 | return `\t${wrapperError.toString().replace(INDENT_REGEX, "\n\t")}`; 18 | } 19 | -------------------------------------------------------------------------------- /packages/server/src/Errors/AdapterNotOverridingSupportedTypesError.ts: -------------------------------------------------------------------------------- 1 | export class AdapterNotOverridingSupportedTypesError extends Error { 2 | constructor(adapterName: string) { 3 | super(); 4 | 5 | this.name = "AdapterNotOverridingSupportedTypesError.ts"; 6 | this.message = `${adapterName} does not override static property method 'supportedTypes' to provide supported mime-types subtitles formats. 7 | 8 | If you are a adapter developer, ensure yourself for your adapter to override all the expected properties. 9 | `; 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /packages/server/src/Errors/AdapterNotOverridingToStringError.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @deprecated 3 | */ 4 | 5 | export class AdapterNotOverridingToStringError extends Error { 6 | constructor() { 7 | super(); 8 | 9 | this.name = "AdapterNotOverridingToStringError"; 10 | this.message = `A adapter you have provided does not override static (and instance) method 'toString' to provide a human-readable name. 11 | 12 | If you are a adapter developer, ensure yourself for your adapter to override all the expected properties. 13 | `; 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /packages/server/src/Errors/UnparsableContentError.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * When content fails fatally 3 | */ 4 | 5 | import { formatError } from "./utils.js"; 6 | 7 | export class UnparsableContentError extends Error { 8 | constructor(adapterName: string, error: unknown) { 9 | super(); 10 | 11 | const message = `${adapterName} failed on every section of the provided content or critically failed on a point. 12 | 13 | ${formatError(error)} 14 | `; 15 | 16 | this.name = "UnparsableContentError"; 17 | this.message = message; 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /packages/sample/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@sub37/sample-app", 3 | "private": true, 4 | "version": "1.0.4", 5 | "type": "module", 6 | "scripts": { 7 | "dev": "pnpm vite", 8 | "build": "pnpm vite build" 9 | }, 10 | "devDependencies": { 11 | "typescript": "^5.9.2", 12 | "vite": "^5.4.20", 13 | "vite-plugin-checker": "^0.6.4", 14 | "vite-tsconfig-paths": "^4.3.1" 15 | }, 16 | "dependencies": { 17 | "@sub37/captions-renderer": "workspace:^", 18 | "@sub37/server": "workspace:^", 19 | "@sub37/webvtt-adapter": "workspace:^" 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /packages/server/src/Errors/NoAdaptersFoundError.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * When Server will be instantiated without adapters, this will be the resulting error 3 | */ 4 | 5 | export class NoAdaptersFoundError extends Error { 6 | constructor() { 7 | super(); 8 | 9 | const message = `Server didn't find any valid adapter. 10 | 11 | If you are a adapter developer, please ensure yourself that your adapter satisfies all the API requirements. See documentation for more details. 12 | `; 13 | 14 | this.name = "NoAdaptersFoundError"; 15 | this.message = message; 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /packages/sample/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Sample index 8 | 9 | 10 |
11 |

Sample pages:

12 | 16 |
17 | 18 | 19 | -------------------------------------------------------------------------------- /packages/server/src/Errors/UnsupportedContentError.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * When session is provided with a content not supported 3 | * by the provided adapters, this will error will be emitted 4 | */ 5 | 6 | export class UnsupportedContentError extends Error { 7 | constructor(expectedMimeType: string) { 8 | super(); 9 | 10 | const message = `None of the provided adapters seems to support this content type ("${expectedMimeType}"). Matching against 'supportedType' failed. Engine halted.`; 11 | 12 | this.name = "UnsupportedContentError"; 13 | this.message = message; 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /packages/server/src/Errors/OutOfRangeFrequencyError.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * When Server will be instantiated without adapters, this will be the resulting error 3 | */ 4 | 5 | export class OutOfRangeFrequencyError extends Error { 6 | constructor(frequency: number) { 7 | super(); 8 | 9 | const message = `Cannot start subtitles server. 10 | 11 | Custom frequency requires to be a positive numeric value higher than 0ms. 12 | If not provided, it is automatically set to 250ms. Received: ${frequency} (ms). 13 | `; 14 | 15 | this.name = "OutOfRangeFrequencyError"; 16 | this.message = message; 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /packages/server/src/RenderingModifiers.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * BRING YOUR OWN RENDERING MODIFIER. 3 | * Each adapter should be able to define the properties 4 | * in the structure, but letting us to use them 5 | * through a common interface. 6 | */ 7 | 8 | export interface RenderingModifiers { 9 | /** 10 | * A unique id that uses the required props 11 | * to allow us comparing two RenderingModifiers 12 | * with some common properties, e.g. regionIdentifier 13 | */ 14 | id: number; 15 | 16 | width: number; 17 | 18 | leftOffset: number; 19 | 20 | textAlignment: "start" | "left" | "center" | "right" | "end"; 21 | 22 | regionIdentifier?: string; 23 | } 24 | -------------------------------------------------------------------------------- /packages/server/src/index.ts: -------------------------------------------------------------------------------- 1 | export { Server, Events } from "./Server.js"; 2 | export type { EventsPayloadMap } from "./Server.js"; 3 | export { BaseAdapter } from "./BaseAdapter/index.js"; 4 | export type { BaseAdapterConstructor } from "./BaseAdapter/index.js"; 5 | export type { Region } from "./Region"; 6 | export type { RenderingModifiers } from "./RenderingModifiers"; 7 | export { CueNode } from "./CueNode.js"; 8 | export * as Entities from "./Entities/index.js"; 9 | export * as Errors from "./Errors/index.js"; 10 | 11 | export { IntervalBinaryTree } from "./IntervalBinaryTree.js"; 12 | export type { IntervalBinaryLeaf } from "./IntervalBinaryTree.js"; 13 | 14 | export type { TrackRecord } from "./Track"; 15 | -------------------------------------------------------------------------------- /packages/server/specs/Entities.spec.mjs: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | import { describe, it, expect } from "@jest/globals"; 3 | import { Entities } from "../lib/index.js"; 4 | 5 | describe("Tag entities", () => { 6 | describe("Setting styles", () => { 7 | it("should return empty object if not a string or an object", () => { 8 | const entity = new Entities.Tag({ 9 | attributes: new Map(), 10 | classes: [], 11 | length: 0, 12 | offset: 0, 13 | tagType: Entities.TagType.BOLD, 14 | }); 15 | 16 | // @ts-expect-error 17 | entity.setStyles(); 18 | 19 | entity.setStyles(undefined); 20 | 21 | // @ts-expect-error 22 | entity.setStyles(null); 23 | 24 | // @ts-expect-error 25 | entity.setStyles(0); 26 | 27 | expect(entity.styles).toEqual({}); 28 | }); 29 | }); 30 | }); 31 | -------------------------------------------------------------------------------- /packages/server/src/Entities/Generic.ts: -------------------------------------------------------------------------------- 1 | import type { IntervalBinaryLeaf, Leafable } from "../IntervalBinaryTree"; 2 | 3 | export const enum Type { 4 | STYLE, 5 | TAG, 6 | } 7 | 8 | export class GenericEntity implements Leafable { 9 | public offset: number; 10 | public length: number; 11 | public type: Type; 12 | 13 | public constructor(type: Type, offset: number, length: number) { 14 | this.offset = offset; 15 | this.length = length; 16 | this.type = type; 17 | } 18 | 19 | public toLeaf(): IntervalBinaryLeaf { 20 | return { 21 | left: null, 22 | right: null, 23 | node: this, 24 | max: this.offset + this.length, 25 | get high() { 26 | return this.node.offset + this.node.length; 27 | }, 28 | get low() { 29 | return this.node.offset; 30 | }, 31 | }; 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /packages/webvtt-adapter/src/InvalidFormatError.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * When VTT file doesn't start with WEBVTT format 3 | */ 4 | 5 | type Reason = "WEBVTT_HEADER_MISSING" | "UNKNOWN_BLOCK_ENTITY" | "INVALID_CUE_FORMAT"; 6 | 7 | export class InvalidFormatError extends Error { 8 | constructor(reason: Reason, dataBlock: string) { 9 | super(); 10 | 11 | this.name = "InvalidFormatError"; 12 | 13 | if (reason === "WEBVTT_HEADER_MISSING") { 14 | this.message = `Content provided to WebVTTAdapter cannot be parsed. 15 | 16 | Reason code: ${reason} 17 | `; 18 | } else { 19 | this.message = `Content provided to WebVTTAdapter cannot be parsed. 20 | 21 | Reason code: ${reason} 22 | 23 | This block seems to be invalid: 24 | 25 | ============= 26 | ${dataBlock.replace(/\n/g, "\n\t")} 27 | ============= 28 | `; 29 | } 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /packages/server/src/Errors/UncaughtParsingExceptionError.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * When session is provided with a content not supported 3 | * by the provided adapters, this will error will be emitted 4 | */ 5 | 6 | import { formatError } from "./utils.js"; 7 | 8 | export class UncaughtParsingExceptionError extends Error { 9 | constructor(adapterName: string, error: unknown) { 10 | super(); 11 | 12 | const message = `Oh no! Parsing through ${adapterName} failed for some uncaught reason. 13 | 14 | If you are using a custom adapter (out of the provided ones), check your adapter first and the content that caused the issue. 15 | Otherwise, please report it us with a repro case (code + content). Thank you! 16 | 17 | Here below what happened: 18 | 19 | ${formatError(error)} 20 | `; 21 | 22 | this.name = "UncaughtParsingExceptionError"; 23 | this.message = message; 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /tsconfig.build.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "esnext", 4 | "module": "es2022", 5 | "declaration": true, 6 | "esModuleInterop": true, 7 | "sourceMap": true, 8 | "forceConsistentCasingInFileNames": true, 9 | "noImplicitAny": true, 10 | "noImplicitThis": true, 11 | "useUnknownInCatchVariables": true, 12 | "noUnusedLocals": true, 13 | "noFallthroughCasesInSwitch": true, 14 | "noUncheckedIndexedAccess": true, 15 | "noPropertyAccessFromIndexSignature": true, 16 | "noImplicitOverride": true, 17 | "moduleResolution": "node", 18 | "noImplicitReturns": true, 19 | "lib": ["DOM", "ES2018", "ES2020", "ES2022"] 20 | }, 21 | /** 22 | * Just for the sample, as it seems to include the specs files. 23 | * This is valid as long as we keep the tests in TS, as we plan 24 | * to move them to JS + TSDoc 25 | */ 26 | "exclude": ["packages/**/specs/**/*"] 27 | } 28 | -------------------------------------------------------------------------------- /packages/webvtt-adapter/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@sub37/webvtt-adapter", 3 | "version": "1.1.1", 4 | "description": "A subtitles adapter for WebVTT subtitles", 5 | "main": "lib/index.js", 6 | "type": "module", 7 | "peerDependencies": { 8 | "@sub37/server": "^1.0.0" 9 | }, 10 | "scripts": { 11 | "build": "rm -rf lib && pnpm tsc -p tsconfig.build.json", 12 | "test": "pnpm build && pnpm --prefix ../.. run test", 13 | "prepublishOnly": "pnpm build" 14 | }, 15 | "repository": { 16 | "type": "git", 17 | "url": "git+https://github.com/alexandercerutti/sub37.git" 18 | }, 19 | "author": "Alexander P. Cerutti ", 20 | "license": "MIT", 21 | "bugs": { 22 | "url": "https://github.com/alexandercerutti/sub37/issues" 23 | }, 24 | "homepage": "https://github.com/alexandercerutti/sub37#readme", 25 | "files": [ 26 | "lib/**/*.+(js|d.ts)!(*.map)" 27 | ] 28 | } 29 | -------------------------------------------------------------------------------- /packages/webvtt-adapter/src/Parser/Timestamps.utils.ts: -------------------------------------------------------------------------------- 1 | const TIME_REGEX = 2 | /(?(\d{2})?):?(?(\d{2})):(?(\d{2}))(?:\.(?(\d{0,3})))?/; 3 | 4 | export function parseMs(timestring: string): number { 5 | const timeMatch = timestring.match(TIME_REGEX); 6 | 7 | if (!timeMatch) { 8 | throw new Error("Time format is not valid. Ignoring cue."); 9 | } 10 | 11 | const { 12 | groups: { hours, minutes, seconds, milliseconds }, 13 | } = timeMatch; 14 | 15 | const hoursInSeconds = zeroFallback(parseInt(hours)) * 60 * 60; 16 | const minutesInSeconds = zeroFallback(parseInt(minutes)) * 60; 17 | const parsedSeconds = zeroFallback(parseInt(seconds)); 18 | const parsedMs = zeroFallback(parseInt(milliseconds)) / 1000; 19 | 20 | return (hoursInSeconds + minutesInSeconds + parsedSeconds + parsedMs) * 1000; 21 | } 22 | 23 | function zeroFallback(value: number): number { 24 | return value || 0; 25 | } 26 | -------------------------------------------------------------------------------- /packages/server/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@sub37/server", 3 | "version": "1.1.0", 4 | "description": "Server component for subtitles", 5 | "main": "lib/index.js", 6 | "type": "module", 7 | "devDependencies": { 8 | "typescript": "^5.9.2" 9 | }, 10 | "scripts": { 11 | "build": "rm -rf lib && pnpm tsc -p tsconfig.build.json", 12 | "test": "pnpm build && pnpm --prefix ../.. test", 13 | "prepublishOnly": "pnpm build" 14 | }, 15 | "repository": { 16 | "type": "git", 17 | "url": "git+https://github.com/alexandercerutti/sub37.git" 18 | }, 19 | "keywords": [ 20 | "vtt", 21 | "subtitles", 22 | "captions" 23 | ], 24 | "author": "Alexander P. Cerutti ", 25 | "license": "MIT", 26 | "bugs": { 27 | "url": "https://github.com/alexandercerutti/sub37/issues" 28 | }, 29 | "homepage": "https://github.com/alexandercerutti/sub37#readme", 30 | "files": [ 31 | "lib/**/*.+(js|d.ts)!(*.map)" 32 | ] 33 | } 34 | -------------------------------------------------------------------------------- /.github/workflows/run-tests.yml: -------------------------------------------------------------------------------- 1 | on: 2 | push: 3 | branches: 4 | - master 5 | - develop 6 | 7 | pull_request: 8 | types: [opened, edited] 9 | branches: 10 | - master 11 | 12 | workflow_dispatch: 13 | 14 | jobs: 15 | test-on-ubuntu: 16 | name: Testing Workflow Linux 17 | runs-on: ubuntu-latest 18 | steps: 19 | - uses: pnpm/action-setup@v3 20 | with: 21 | version: 8 22 | - uses: actions/checkout@v3 23 | - uses: actions/setup-node@v3 24 | with: 25 | node-version-file: .nvmrc 26 | check-latest: true 27 | cache: "pnpm" 28 | - name: Install dependencies 29 | run: | 30 | pnpm install 31 | pnpm dlx playwright install --with-deps 32 | - name: Building and running tests 33 | run: | 34 | pnpm build 35 | pnpm test 36 | cd packages/captions-renderer 37 | pnpm test:e2e 38 | -------------------------------------------------------------------------------- /packages/server/src/Errors/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./AdaptersMissingError.js"; 2 | export * from "./NoAdaptersFoundError.js"; 3 | export * from "./UnsupportedContentError.js"; 4 | export * from "./OutOfRangeFrequencyError.js"; 5 | export * from "./UnexpectedParsingOutputFormatError.js"; 6 | export * from "./UncaughtParsingExceptionError.js"; 7 | export * from "./UnexpectedDataFormatError.js"; 8 | export * from "./ParsingError.js"; 9 | export * from "./UnparsableContentError.js"; 10 | export * from "./ActiveTrackMissingError.js"; 11 | export * from "./AdapterNotExtendingPrototypeError.js"; 12 | export * from "./AdapterNotOverridingSupportedTypesError.js"; 13 | export * from "./SessionNotStartedError.js"; 14 | export * from "./SessionNotInitializedError.js"; 15 | export * from "./ServerAlreadyRunningError.js"; 16 | export * from "./ServerNotRunningError.js"; 17 | 18 | /** 19 | * @deprecated 20 | */ 21 | 22 | export * from "./AdapterNotOverridingToStringError.js"; 23 | -------------------------------------------------------------------------------- /packages/sample/README.md: -------------------------------------------------------------------------------- 1 | # @sub37's sample 2 | 3 | This is a sample Vite project that has the objective to show and test how the engine is integrated. 4 | 5 | It offers two pages: 6 | 7 | - [Native Video](http://localhost:3000/pages/native-video/index.html) page, which sets up a video tag showing the famous Big Buck Bunny video and some custom native subtitles; 8 | - [Sub37 Example](http://localhost:3000/pages/sub37-example/index.html) page, which sets up a fake HTMLVideoElement that has seeking, playing and pausing capabilities and shows custom subtitles through the usage of `sub37` libraries. This is also used by `@sub37/captions-renderer`'s integration tests to verify everything is fine; 9 | 10 | ## Starting the server 11 | 12 | If you are placed in this project's folder, you can run: 13 | 14 | ```sh 15 | $ npm run dev 16 | ``` 17 | 18 | It is also possible to start the project from the monorepo root by running: 19 | 20 | ```sh 21 | $ npm run sample:serve 22 | ``` 23 | -------------------------------------------------------------------------------- /packages/captions-renderer/src/constants.ts: -------------------------------------------------------------------------------- 1 | export const CSSVAR_TEXT_COLOR = "--sub37-text-color" as const; 2 | export const CSSVAR_TEXT_BG_COLOR = "--sub37-text-bg-color" as const; 3 | 4 | /** 5 | * The background of a region is the amount of 6 | * lines that are shown in a specific moment. 7 | * 8 | * Maybe the name is not the best of all. In fact, 9 | * we might decide to give it to the variable below 10 | * and rename this. But this would be a breaking change. 11 | */ 12 | 13 | export const CSSVAR_REGION_BG_COLOR = "--sub37-region-bg-color" as const; 14 | 15 | /** 16 | * The area of the region is composed of its full height, 17 | * which, if not specified by the renderer, fallbacks to the 18 | * max amount of lines that should be shown. 19 | */ 20 | 21 | export const CSSVAR_REGION_AREA_BG_COLOR = "--sub37-region-area-bg-color" as const; 22 | export const CSSVAR_BOTTOM_SPACING = "--sub37-bottom-spacing" as const; 23 | export const CSSVAR_BOTTOM_TRANSITION = "--sub37-bottom-transition" as const; 24 | -------------------------------------------------------------------------------- /packages/sample/src/components/TrackScheduler/index.ts: -------------------------------------------------------------------------------- 1 | import { DebouncedOperation } from "./DebouncedOperation"; 2 | 3 | const LOCAL_STORAGE_KEY = "latest-track-text"; 4 | const schedulerOperation = Symbol("schedulerOperation"); 5 | 6 | export class TrackScheduler { 7 | private [schedulerOperation]: DebouncedOperation; 8 | 9 | constructor(private onCommit: (text: string) => void) { 10 | const latestTrack = localStorage.getItem(LOCAL_STORAGE_KEY); 11 | 12 | if (latestTrack) { 13 | this.commit(latestTrack); 14 | } 15 | } 16 | 17 | private set operation(fn: Function) { 18 | DebouncedOperation.clear(this[schedulerOperation]); 19 | this[schedulerOperation] = DebouncedOperation.create(fn); 20 | } 21 | 22 | public schedule(text: string) { 23 | this.operation = () => this.commit(text); 24 | } 25 | 26 | private commit(text: string): void { 27 | if (!text.length) { 28 | return; 29 | } 30 | 31 | this.onCommit(text); 32 | localStorage.setItem(LOCAL_STORAGE_KEY, text); 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /packages/sample/vite.config.mjs: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | 3 | import { defineConfig } from "vite"; 4 | import tsconfigPaths from "vite-tsconfig-paths"; 5 | import checker from "vite-plugin-checker"; 6 | import path from "node:path"; 7 | 8 | /** 9 | * This file was a .ts file but vite-plugin-checker somehow 10 | * checks also for what there's in this file and prints out 11 | * errors 12 | */ 13 | 14 | export default defineConfig({ 15 | plugins: [ 16 | tsconfigPaths({ 17 | loose: true, 18 | root: "../..", 19 | }), 20 | checker({ 21 | typescript: { 22 | root: "../..", 23 | }, 24 | }), 25 | ], 26 | server: { 27 | host: "0.0.0.0", 28 | port: 3000, 29 | strictPort: true, 30 | }, 31 | build: { 32 | rollupOptions: { 33 | input: { 34 | index: path.resolve(__dirname, "index.html"), 35 | "native-video": path.resolve(__dirname, "pages/native-video/index.html"), 36 | "sub37-example": path.resolve(__dirname, "pages/sub37-example/index.html"), 37 | }, 38 | }, 39 | }, 40 | assetsInclude: ["**/*.vtt"], 41 | }); 42 | -------------------------------------------------------------------------------- /packages/webvtt-adapter/src/Parser/Tags/NodeQueue.ts: -------------------------------------------------------------------------------- 1 | import type Node from "./Node"; 2 | 3 | /** 4 | * LIFO queue, where root is always the first element, 5 | * so we can easily pop out and not drill. 6 | */ 7 | 8 | export default class NodeQueue { 9 | private root: Node = null; 10 | 11 | public get current() { 12 | return this.root; 13 | } 14 | 15 | public get length(): number { 16 | if (!this.root) { 17 | return 0; 18 | } 19 | 20 | let thisNode: Node = this.root; 21 | let length = 1; 22 | 23 | while (thisNode.parent !== null) { 24 | length++; 25 | thisNode = thisNode.parent; 26 | } 27 | 28 | return length; 29 | } 30 | 31 | public push(node: Node): void { 32 | if (!this.root) { 33 | this.root = node; 34 | return; 35 | } 36 | 37 | node.parent = this.root; 38 | this.root = node; 39 | } 40 | 41 | public pop(): Node | undefined { 42 | if (!this.root) { 43 | return undefined; 44 | } 45 | 46 | const out = this.root; 47 | this.root = this.root.parent; 48 | return out; 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "sub37", 3 | "description": "", 4 | "private": true, 5 | "workspaces": [ 6 | "packages/*" 7 | ], 8 | "repository": { 9 | "type": "git", 10 | "url": "git+https://github.com/alexandercerutti/sub37.git" 11 | }, 12 | "keywords": [ 13 | "vtt", 14 | "subtitles", 15 | "srt" 16 | ], 17 | "scripts": { 18 | "build": "pnpm lerna run build", 19 | "test": "NODE_OPTIONS=\"--experimental-vm-modules --no-warnings\" pnpm jest -c jest.config.mjs --silent", 20 | "sample:serve": "pnpm --prefix packages/sample run dev" 21 | }, 22 | "author": "Alexander P. Cerutti ", 23 | "license": "MIT", 24 | "bugs": { 25 | "url": "https://github.com/alexandercerutti/sub37/issues" 26 | }, 27 | "homepage": "https://github.com/alexandercerutti/sub37#readme", 28 | "devDependencies": { 29 | "@jest/globals": "^29.7.0", 30 | "@types/jest": "^29.5.14", 31 | "jest": "^29.4.3", 32 | "jest-environment-jsdom": "^29.7.0", 33 | "lerna": "^8.2.3", 34 | "prettier": "^3.6.2", 35 | "typescript": "^5.9.2" 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /packages/webvtt-adapter/CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # @sub37/webvtt-adapter 2 | 3 | ## **1.1.1** (12 Feb 2025) 4 | 5 | **Bug fix**: 6 | 7 | - Fixed subtle broken styles not being reported and making crash everything without a clear information. Now more cases are handled and, in case of style failure, a message is reported as warning. Crashing styles will be ignored in that case; 8 | 9 | --- 10 | 11 | ## **1.1.0** (10 Feb 2025) 12 | 13 | **Changes**: 14 | 15 | - Added missing exclusion of cues with the same ids, when available, with error emission; 16 | 17 | --- 18 | 19 | ## **1.0.4** (08 Feb 2024) 20 | 21 | **Changes**: 22 | 23 | - Generic improvements; 24 | 25 | --- 26 | 27 | ## **1.0.3** (17 Feb 2024) 28 | 29 | **Changes**: 30 | 31 | - Improved Region's `regionanchor` and `viewportanchor` parsing and forced them to be provided as percentages, as specified by the standard; 32 | 33 | **Bug fix**: 34 | 35 | - Fixed wrong styles being mistakenly assigned when a wrong CSS selector was specified (#12); 36 | 37 | --- 38 | 39 | ## **1.0.0** 40 | 41 | - First version released 42 | -------------------------------------------------------------------------------- /packages/captions-renderer/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@sub37/captions-renderer", 3 | "version": "1.1.1", 4 | "description": "A caption renderer written with Web Components", 5 | "main": "lib/index.js", 6 | "module": "lib/index.js", 7 | "type": "module", 8 | "peerDependencies": { 9 | "@sub37/server": "^1.0.0" 10 | }, 11 | "scripts": { 12 | "build": "rm -rf lib && pnpm tsc -p tsconfig.build.json", 13 | "test": "pnpm build && pnpm test:e2e", 14 | "test:e2e": "pnpm playwright test -c \"playwright.config.js\"", 15 | "prepublishOnly": "pnpm run build" 16 | }, 17 | "repository": { 18 | "type": "git", 19 | "url": "git+https://github.com/alexandercerutti/sub37.git" 20 | }, 21 | "author": "Alexander P. Cerutti ", 22 | "license": "MIT", 23 | "bugs": { 24 | "url": "https://github.com/alexandercerutti/sub37/issues" 25 | }, 26 | "homepage": "https://github.com/alexandercerutti/sub37#readme", 27 | "devDependencies": { 28 | "@playwright/test": "^1.50.1" 29 | }, 30 | "files": [ 31 | "lib/**/*.+(js|d.ts)!(*.map)" 32 | ] 33 | } 34 | -------------------------------------------------------------------------------- /packages/sample/pages/sub37-example/style.css: -------------------------------------------------------------------------------- 1 | * { 2 | box-sizing: border-box; 3 | font-family: "Roboto", sans-serif; 4 | } 5 | 6 | main { 7 | margin: 20px; 8 | display: grid; 9 | grid-template-columns: 1fr 2fr; 10 | justify-items: center; 11 | column-gap: 20px; 12 | } 13 | 14 | main > * { 15 | width: 100%; 16 | } 17 | 18 | main div { 19 | border-radius: 3px; 20 | } 21 | 22 | main div > h3 { 23 | text-transform: uppercase; 24 | color: rgb(197, 66, 6); 25 | font-weight: 400; 26 | font-size: 1.8rem; 27 | } 28 | 29 | main div > p#firefox-not-showing-warning { 30 | font-size: 1.8rem; 31 | font-weight: 300; 32 | border-radius: 3px; 33 | border: 2px solid #ffdeb4; 34 | background-color: orange; 35 | padding: 10px; 36 | } 37 | 38 | button#load-default-track { 39 | padding: 5px 10px; 40 | outline: none; 41 | box-shadow: none; 42 | border-radius: 3px; 43 | } 44 | 45 | #video-container { 46 | position: relative; 47 | height: 400px; 48 | border: 1px solid #000; 49 | display: flex; 50 | flex-direction: column; 51 | aspect-ratio: 16 / 9; 52 | resize: both; 53 | overflow: hidden; 54 | } 55 | -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "0.2.0", 3 | "configurations": [ 4 | { 5 | "type": "node", 6 | "request": "launch", 7 | "name": "Debug Jest (Manual Chrome)", 8 | "port": 9229, 9 | "runtimeArgs": [ 10 | "--inspect-brk", 11 | "--experimental-vm-modules", 12 | "${workspaceRoot}/node_modules/.bin/jest", 13 | "--runInBand", 14 | "-c", 15 | "${workspaceRoot}/jest.config.mjs" 16 | ], 17 | "console": "internalConsole", 18 | "internalConsoleOptions": "neverOpen" 19 | }, 20 | { 21 | "type": "node", 22 | "name": "vscode-jest-tests.v2", 23 | "request": "launch", 24 | "console": "integratedTerminal", 25 | "internalConsoleOptions": "neverOpen", 26 | "env": { 27 | "NODE_OPTIONS": "--experimental-vm-modules --no-warnings" 28 | }, 29 | "runtimeExecutable": "pnpm", 30 | "cwd": "${workspaceFolder}", 31 | "args": [ 32 | "jest", 33 | "-c", 34 | "${workspaceRoot}/jest.config.mjs", 35 | "--runInBand", 36 | "--watchAll=false", 37 | "--testNamePattern", 38 | "${jest.testNamePattern}", 39 | "--runTestsByPath", 40 | "${jest.testFile}" 41 | ] 42 | } 43 | ] 44 | } 45 | -------------------------------------------------------------------------------- /packages/server/src/Region.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * BRING YOUR OWN REGION. 3 | * Each adapter should be able to define the properties 4 | * in the structure, but letting us to use them 5 | * through a common interface. 6 | */ 7 | 8 | export interface Region { 9 | id: string; 10 | 11 | /** 12 | * Expressed in percentage 13 | */ 14 | 15 | width: number; 16 | 17 | /** 18 | * When not specified, region's height 19 | * equals to the max visible amount of 20 | * lines, specified through the property 21 | * 'lines' below. 22 | * 23 | * Expressed in `em`s 24 | */ 25 | 26 | height?: number; 27 | 28 | lines: number; 29 | 30 | /** 31 | * Allows each parser how to express 32 | * the position of the region. 33 | * 34 | * @returns {[x: string | number, y: string | number]} coordinates with measure unit 35 | */ 36 | 37 | getOrigin(): [x: number | string, y: number | string]; 38 | 39 | /** 40 | * Allows each parser how to express 41 | * the position of the region based on runtime data 42 | * 43 | * @param viewportWidth 44 | * @param viewportHeight 45 | */ 46 | 47 | getOrigin( 48 | viewportWidth: number, 49 | viewportHeight: number, 50 | ): [x: number | string, y: number | string]; 51 | } 52 | -------------------------------------------------------------------------------- /packages/sample/pages/sub37-example/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Test page 8 | 9 | 10 | 11 |
12 | 13 |
14 | 15 | 16 |
17 | 18 | 19 |
20 |

Content type:

21 |
22 | 23 | 24 |
25 |
26 | 28 |
29 |
30 |
31 | 32 |
33 |
34 | 35 | 36 | 37 | -------------------------------------------------------------------------------- /packages/captions-renderer/CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # @sub37/captions-renderer 2 | 3 | ## **1.1.1** (08 Feb 2025) 4 | 5 | **Changes**: 6 | 7 | - Refactored entity difference calculation; 8 | - Refactored entity conversion to DOM elements; 9 | - Refactored line creation and entities association; 10 | - 11 | 12 | **Bug fix**: 13 | 14 | - Fixed [Issue #11](https://github.com/alexandercerutti/sub37/issues/11); 15 | 16 | --- 17 | 18 | ## **1.1.0** 19 | 20 | **Changes**: 21 | 22 | - Changed fallback values for `getOrigin` invokation to be percentages strings; 23 | - Added fallbacks for `getOrigin`'s `originX` and `originY` to be percentages if no unit is specified; 24 | - Changed region `height` to respect the adapter region implementation will with the new `height` property in the Region protocol, when available, and to fallback to the `lines` property; 25 | - Added new Renderer boolean property `roundRegionHeightLineFit` to let `captions-renderer` to slightly override the adapter `height` property, in order to show the next full line, if cut; 26 | - Added new css style variable **--sub37-region-area-bg-color**, to change color to the area covered by `height`. It defaults to `transparent`; 27 | - **Typescript**: exported type `CaptionsRenderer` to reference the component; 28 | - **Tests**: Improved tests structure through fixture; 29 | 30 | --- 31 | 32 | ## **1.0.0** 33 | 34 | - First version released 35 | -------------------------------------------------------------------------------- /packages/server/src/SuspendableTimer.ts: -------------------------------------------------------------------------------- 1 | interface Ticker { 2 | run(currentTime?: number): void; 3 | } 4 | 5 | function createTicker(tickCallback: (currentTime?: number) => void): Ticker { 6 | return { 7 | run(currentTime?: number) { 8 | tickCallback(currentTime); 9 | }, 10 | }; 11 | } 12 | 13 | export class SuspendableTimer { 14 | private interval: number | undefined = undefined; 15 | private ticker: Ticker; 16 | 17 | constructor(private frequency: number, tickCallback: (currentTime?: number) => void) { 18 | this.ticker = createTicker(tickCallback); 19 | } 20 | 21 | public start(): void { 22 | if (this.isRunning) { 23 | return; 24 | } 25 | 26 | this.interval = window.setInterval(this.ticker.run, this.frequency || 0); 27 | } 28 | 29 | public stop(): void { 30 | if (!this.isRunning) { 31 | return; 32 | } 33 | 34 | window.clearInterval(this.interval); 35 | this.interval = undefined; 36 | } 37 | 38 | public get isRunning(): boolean { 39 | return Boolean(this.interval); 40 | } 41 | 42 | /** 43 | * Allows executing a function call of the timer 44 | * (tick) without waiting for the timer. 45 | * 46 | * Most useful when the timer is suspended and the 47 | * function is run "manually". 48 | * 49 | * @param currentTime 50 | */ 51 | 52 | public runTick(currentTime?: number): void { 53 | this.ticker.run(currentTime); 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /packages/server/src/Track/Track.ts: -------------------------------------------------------------------------------- 1 | import type { BaseAdapter } from "../BaseAdapter"; 2 | import type { CueNode } from "../CueNode"; 3 | import type { SessionTrack } from "../DistributionSession"; 4 | import type { TrackRecord } from "./TrackRecord"; 5 | import { appendChunkToTrack } from "./appendChunkToTrack"; 6 | import { IntervalBinaryTree } from "../IntervalBinaryTree"; 7 | 8 | export const addCuesSymbol = Symbol("track.addcues"); 9 | 10 | export default class Track implements Omit { 11 | private readonly timeline: IntervalBinaryTree; 12 | private readonly onSafeFailure: (error: Error) => void; 13 | public readonly adapter: BaseAdapter; 14 | public readonly lang: string; 15 | public readonly mimeType: `${string}/${string}`; 16 | 17 | public active: boolean = false; 18 | 19 | public constructor( 20 | lang: string, 21 | mimeType: SessionTrack["mimeType"], 22 | adapter: BaseAdapter, 23 | onSafeFailure?: (error: Error) => void, 24 | ) { 25 | this.adapter = adapter; 26 | this.timeline = new IntervalBinaryTree(); 27 | this.lang = lang; 28 | this.mimeType = mimeType; 29 | this.onSafeFailure = onSafeFailure; 30 | } 31 | 32 | public getActiveCues(time: number): CueNode[] { 33 | return this.timeline.getCurrentNodes(time); 34 | } 35 | 36 | public [addCuesSymbol](...cues: CueNode[]): void { 37 | for (const cue of cues) { 38 | this.timeline.addNode(cue); 39 | } 40 | } 41 | 42 | public addChunk(content: unknown): void { 43 | appendChunkToTrack(this, content, this.onSafeFailure); 44 | } 45 | 46 | public get cues(): CueNode[] { 47 | return this.timeline.getAll(); 48 | } 49 | 50 | public getAdapterName(): string { 51 | return this.adapter.toString(); 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /packages/sample/pages/native-video/script.mjs: -------------------------------------------------------------------------------- 1 | import "../../src/components/customElements/scheduled-textarea"; 2 | 3 | /** 4 | * @type {string} text 5 | * @returns {string} 6 | */ 7 | 8 | function createTrackURL(text) { 9 | const blob = new Blob([text], { type: "text/vtt" }); 10 | return URL.createObjectURL(blob); 11 | } 12 | 13 | /** 14 | * @type {string} trackUrl 15 | */ 16 | 17 | function disposeTrackURL(trackUrl) { 18 | URL.revokeObjectURL(trackUrl); 19 | } 20 | 21 | const videoContainer = document.getElementById("video-container"); 22 | const scheduledTextArea = document.getElementsByTagName("scheduled-textarea")?.[0]; 23 | 24 | scheduledTextArea.addEventListener("commit", ({ detail: text }) => { 25 | const currentVideo = videoContainer.querySelector("video"); 26 | const currentTrack = Array.prototype.find.call( 27 | currentVideo.childNodes, 28 | (child) => child.nodeName === "TRACK", 29 | ); 30 | 31 | if (currentTrack?.src) { 32 | disposeTrackURL(currentTrack.src); 33 | } 34 | 35 | const newTrackURL = createTrackURL(text); 36 | 37 | /** 38 | * Creating again the video tag due to a bug in Chrome 39 | * for which removing a textTrack element and adding a new one 40 | * lefts the UI dirty 41 | */ 42 | 43 | const videoElement = Object.assign(document.createElement("video"), { 44 | controls: true, 45 | muted: true, 46 | src: currentVideo.src, 47 | autoplay: true, 48 | }); 49 | 50 | const track = Object.assign(document.createElement("track"), { 51 | src: newTrackURL, 52 | mode: "showing", 53 | default: true, 54 | label: "Test track", 55 | }); 56 | 57 | videoElement.appendChild(track); 58 | 59 | videoContainer.querySelector("video").remove(); 60 | videoContainer.appendChild(videoElement); 61 | }); 62 | -------------------------------------------------------------------------------- /packages/webvtt-adapter/README.md: -------------------------------------------------------------------------------- 1 | # @sub37/webvtt-adapter 2 | 3 | As its name says, this adapter handles whatever concerns the parsing, the tokenization and hence the conversion of a WebVTT text track so that it can be used by `@sub37/*`. 4 | 5 | It tries to adhere as much as possible to the standard, leaving out or manipulating some concepts regarding the rendering of cues (which must be accomplished along with `@sub37/captions-renderer`). 6 | 7 | ### Supported features 8 | 9 | Here below a list of features that other platforms do not or partially support: 10 | 11 | - **Timestamps** to show timed text within the same cue (right now this is not supported by browsers even if part of the standard); 12 | - **Regions** (not very well supported by Firefox, but supported in Chromium); 13 | - **Positioning** attributes (like `position: 30%,line-left`, supported by Firefox but not supported by Chromium); 14 | 15 | ### Manipulated concepts or missing features 16 | 17 | - `lines`: as one of the core principles of `@sub37/captions-renderer` is to collect everything into regions, the line amount is to be intended of how many lines the region will show before hiding older lines; 18 | - `snapToLines`: this is not supported, cause of `lines` 19 | - `::past / ::future`: not yet supported. Might require deep changes, but they haven't been evaluated yet; 20 | - `::cue-region`: as above; 21 | - Vertical text support is missing yet. Will be introduced soon, as it requires changes also into `@sub37/captions-renderer`. 22 | - [Default CSS Properties](https://www.w3.org/TR/webvtt1/#applying-css-properties) are not supported as they are matter of `@sub37/captions-renderer`; 23 | - [Time-aligned metadata](https://www.w3.org/TR/webvtt1/#introduction-metadata) cues are not supported yet. 24 | -------------------------------------------------------------------------------- /packages/webvtt-adapter/src/Token.ts: -------------------------------------------------------------------------------- 1 | export enum TokenType { 2 | STRING, 3 | START_TAG, 4 | END_TAG, 5 | TIMESTAMP, 6 | } 7 | 8 | type Boundaries = { start: number; end: number }; 9 | 10 | export class Token { 11 | public annotations: string[]; 12 | public classes: string[]; 13 | public offset: number; 14 | public length: number; 15 | 16 | public readonly type: TokenType; 17 | public readonly content: string; 18 | 19 | private constructor(type: TokenType, content: string) { 20 | this.type = type; 21 | this.content = content; 22 | } 23 | 24 | public static String(content: string, boundaries: Boundaries): Token { 25 | const token = new Token(TokenType.STRING, content); 26 | 27 | token.length = boundaries.end - boundaries.start; 28 | token.offset = boundaries.start; 29 | 30 | return token; 31 | } 32 | 33 | public static StartTag( 34 | tagName: string, 35 | boundaries: Boundaries, 36 | classes: string[] = [], 37 | annotations: string[] = [], 38 | ): Token { 39 | const token = new Token(TokenType.START_TAG, tagName); 40 | 41 | token.length = boundaries.end - boundaries.start; 42 | token.offset = boundaries.start; 43 | token.classes = classes; 44 | token.annotations = annotations; 45 | 46 | return token; 47 | } 48 | 49 | public static EndTag(tagName: string, boundaries: Boundaries): Token { 50 | const token = new Token(TokenType.END_TAG, tagName); 51 | 52 | token.length = boundaries.end - boundaries.start; 53 | token.offset = boundaries.start; 54 | 55 | return token; 56 | } 57 | 58 | public static TimestampTag(timestampRaw: string, boundaries: Boundaries): Token { 59 | const token = new Token(TokenType.TIMESTAMP, timestampRaw); 60 | 61 | token.length = boundaries.end - boundaries.start; 62 | token.offset = boundaries.start; 63 | 64 | return token; 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /packages/sample/src/components/customElements/scheduled-textarea/index.ts: -------------------------------------------------------------------------------- 1 | import { TrackScheduler } from "../../TrackScheduler"; 2 | 3 | export class ScheduledTextArea extends HTMLElement { 4 | private scheduler: TrackScheduler; 5 | 6 | constructor() { 7 | super(); 8 | this.attachShadow({ mode: "open" }); 9 | 10 | const style = document.createElement("style"); 11 | style.textContent = ` 12 | textarea { 13 | padding: 10px; 14 | width: 100%; 15 | outline-color: rgb(197, 66, 6); 16 | height: 500px; 17 | font-size: inherit; 18 | resize: none; 19 | font-weight: 300; 20 | box-sizing: border-box; 21 | } 22 | `; 23 | 24 | this.shadowRoot.appendChild(style); 25 | 26 | const textArea = Object.assign(document.createElement("textarea"), { 27 | placeholder: this.getAttribute("placeholder"), 28 | }); 29 | 30 | textArea.addEventListener("input", ({ target }) => { 31 | this.scheduler.schedule((target as HTMLInputElement).value); 32 | }); 33 | 34 | /** 35 | * We want to wait for listeners to setup outside before creating 36 | * the scheduler. 37 | */ 38 | 39 | window.setTimeout(() => { 40 | this.scheduler = new TrackScheduler((text) => { 41 | /** Keep em sync, especially on first commit */ 42 | textArea.value = text; 43 | const event = new CustomEvent("commit", { detail: text }); 44 | this.dispatchEvent(event); 45 | }); 46 | }, 0); 47 | 48 | this.shadowRoot.appendChild(textArea); 49 | } 50 | 51 | public set value(value: string) { 52 | (this.shadowRoot.querySelector("textarea") as HTMLTextAreaElement).value = value; 53 | this.scheduler.schedule(value); 54 | } 55 | 56 | public get value(): string { 57 | return (this.shadowRoot.querySelector("textarea") as HTMLTextAreaElement).value; 58 | } 59 | } 60 | 61 | window.customElements.define("scheduled-textarea", ScheduledTextArea); 62 | -------------------------------------------------------------------------------- /packages/captions-renderer/specs/RendererFixture.ts: -------------------------------------------------------------------------------- 1 | import { type Locator, test as base } from "@playwright/test"; 2 | import { FakeHTMLVideoElement } from "../../sample/src/components/customElements/fake-video"; 3 | 4 | interface RendererFixture { 5 | getFakeVideo(): Locator; 6 | pauseServing(): Promise; 7 | seekToSecond(atSecond: number): Promise; 8 | waitForEvent(event: "playing"): Promise; 9 | } 10 | 11 | const SUB37_SAMPLE_PAGE_PATH = "./pages/sub37-example/index.html"; 12 | 13 | export const RendererFixture = base.extend({ 14 | async page({ page }, use) { 15 | if (!page.url().includes(SUB37_SAMPLE_PAGE_PATH)) { 16 | await page.goto(SUB37_SAMPLE_PAGE_PATH); 17 | } 18 | 19 | return use(page); 20 | }, 21 | getFakeVideo({ page }, use) { 22 | return use(() => page.locator("fake-video")); 23 | }, 24 | waitForEvent({ getFakeVideo }, use) { 25 | return use(async (eventName) => { 26 | const videoElement = getFakeVideo(); 27 | 28 | return videoElement.evaluate( 29 | (element, { eventName }) => { 30 | return new Promise((resolve) => { 31 | element.addEventListener(eventName, () => resolve()); 32 | }); 33 | }, 34 | { eventName }, 35 | ); 36 | }); 37 | }, 38 | pauseServing({ getFakeVideo }, use) { 39 | return use(async () => { 40 | const videoElement = getFakeVideo(); 41 | await videoElement.evaluate((element) => { 42 | element.pause(); 43 | }, undefined); 44 | }); 45 | }, 46 | seekToSecond({ getFakeVideo }, use) { 47 | return use(async (atSecond) => { 48 | const videoElement = getFakeVideo(); 49 | await videoElement.evaluate( 50 | (element, { atSecond }) => { 51 | element.currentTime = atSecond; 52 | }, 53 | { atSecond }, 54 | ); 55 | }); 56 | }, 57 | }); 58 | -------------------------------------------------------------------------------- /packages/server/src/DistributionSession.ts: -------------------------------------------------------------------------------- 1 | import type { TrackRecord } from "./Track"; 2 | import { CueNode } from "./CueNode.js"; 3 | import { BaseAdapterConstructor } from "./BaseAdapter/index.js"; 4 | import { ActiveTrackMissingError } from "./Errors/index.js"; 5 | import { Track } from "./Track"; 6 | 7 | export interface SessionTrack extends TrackRecord { 8 | adapter: InstanceType; 9 | } 10 | 11 | export class DistributionSession { 12 | private tracks: Track[] = []; 13 | private onSafeFailure: (error: Error) => void; 14 | 15 | constructor(tracks: SessionTrack[], onSafeFailure: DistributionSession["onSafeFailure"]) { 16 | this.onSafeFailure = onSafeFailure; 17 | 18 | for (const sessionTrack of tracks) { 19 | this.addChunkToTrack(sessionTrack); 20 | } 21 | } 22 | 23 | public getAll(): CueNode[] { 24 | const nodes: CueNode[] = []; 25 | 26 | for (const track of this.tracks) { 27 | if (track.active) { 28 | nodes.push(...track.cues); 29 | } 30 | } 31 | 32 | return nodes; 33 | } 34 | 35 | public get availableTracks(): Track[] { 36 | return this.tracks; 37 | } 38 | 39 | public get activeTracks(): Track[] { 40 | return this.tracks.filter((track) => track.active); 41 | } 42 | 43 | public getActiveCues(time: number): CueNode[] { 44 | if (!this.activeTracks.length) { 45 | throw new ActiveTrackMissingError(); 46 | } 47 | 48 | return this.activeTracks.flatMap((track) => track.getActiveCues(time)); 49 | } 50 | 51 | /** 52 | * Allows adding a chunks to be processed by an adapter 53 | * and get inserted into track's timeline. 54 | * 55 | * @param sessionTrack 56 | */ 57 | 58 | private addChunkToTrack(sessionTrack: SessionTrack) { 59 | const { lang, content, mimeType, adapter, active = false } = sessionTrack; 60 | const track = new Track(lang, mimeType, adapter, this.onSafeFailure); 61 | track.active = active; 62 | 63 | track.addChunk(content); 64 | 65 | if (track.cues.length) { 66 | this.tracks.push(track); 67 | } 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /packages/server/src/Entities/Tag.ts: -------------------------------------------------------------------------------- 1 | import { GenericEntity, Type } from "./Generic.js"; 2 | 3 | /** 4 | * TagType is an enum containing 5 | * recognized types in adapters 6 | * like vtt 7 | */ 8 | 9 | export enum TagType { 10 | SPAN /********/ = 0b00000000, 11 | VOICE /*******/ = 0b00000001, 12 | LANG /********/ = 0b00000010, 13 | RUBY /********/ = 0b00000100, 14 | RT /**********/ = 0b00001000, 15 | CLASS /*******/ = 0b00010000, 16 | BOLD /********/ = 0b00100000, 17 | ITALIC /******/ = 0b01000000, 18 | UNDERLINE /***/ = 0b10000000, 19 | } 20 | 21 | export class Tag extends GenericEntity { 22 | public tagType: TagType; 23 | public attributes: Map; 24 | public classes: string[]; 25 | public styles?: { [key: string]: string }; 26 | 27 | public constructor(params: { 28 | offset: number; 29 | length: number; 30 | tagType: TagType; 31 | attributes: Map; 32 | styles?: Tag["styles"]; 33 | classes: Tag["classes"]; 34 | }) { 35 | super(Type.TAG, params.offset, params.length); 36 | 37 | this.tagType = params.tagType; 38 | this.attributes = params.attributes; 39 | this.styles = params.styles || {}; 40 | this.classes = params.classes || []; 41 | } 42 | 43 | public setStyles(styles: string | Tag["styles"]): void { 44 | const declarations = getKeyValueFromCSSRawDeclarations(styles); 45 | Object.assign(this.styles, declarations); 46 | } 47 | } 48 | 49 | function getKeyValueFromCSSRawDeclarations(declarationsRaw: string | object): object { 50 | if (typeof declarationsRaw !== "string" && typeof declarationsRaw !== "object") { 51 | return {}; 52 | } 53 | 54 | if (typeof declarationsRaw === "object") { 55 | return declarationsRaw; 56 | } 57 | 58 | const stylesObject: { [key: string]: string } = {}; 59 | const declarations = declarationsRaw.split(/\s*;\s*/); 60 | 61 | for (const declaration of declarations) { 62 | if (!declaration.length) { 63 | continue; 64 | } 65 | 66 | const [key, value] = declaration.split(/\s*:\s*/); 67 | stylesObject[key] = value; 68 | } 69 | 70 | return stylesObject; 71 | } 72 | -------------------------------------------------------------------------------- /packages/server/src/Track/appendChunkToTrack.ts: -------------------------------------------------------------------------------- 1 | import { ParseResult } from "../BaseAdapter"; 2 | import { CueNode } from "../CueNode"; 3 | import { 4 | UncaughtParsingExceptionError, 5 | UnexpectedDataFormatError, 6 | UnexpectedParsingOutputFormatError, 7 | UnparsableContentError, 8 | } from "../Errors"; 9 | import Track, { addCuesSymbol } from "./Track"; 10 | 11 | /** 12 | * 13 | * @param {Track} track the track object to which data will be parsed and added to; 14 | * @param {unknown} content the content to be parsed. It must be of a type that can be 15 | * understood by the adapter assigned to the track; 16 | * @param {Function} onSafeFailure A function that will be invoked whenever there's a 17 | * non-critical failure during parsing. The function accepts a parameter 18 | * which will be the Error object 19 | */ 20 | 21 | export function appendChunkToTrack( 22 | track: Track, 23 | content: unknown, 24 | onSafeFailure?: (error: Error) => void, 25 | ): void { 26 | const { adapter, lang } = track; 27 | 28 | try { 29 | const parseResult = adapter.parse(content); 30 | 31 | if (!(parseResult instanceof ParseResult)) { 32 | /** If parser fails once for this reason, it is worth to stop the whole ride. */ 33 | throw new UnexpectedParsingOutputFormatError(adapter.toString(), lang, parseResult); 34 | } 35 | 36 | if (parseResult.data.length) { 37 | for (const cue of parseResult.data) { 38 | if (!(cue instanceof CueNode)) { 39 | parseResult.errors.push({ 40 | error: new UnexpectedDataFormatError(adapter.toString()), 41 | failedChunk: cue, 42 | isCritical: false, 43 | }); 44 | 45 | continue; 46 | } 47 | 48 | track[addCuesSymbol](cue); 49 | } 50 | } else if (parseResult.errors.length >= 1) { 51 | throw new UnparsableContentError(adapter.toString(), parseResult.errors[0]); 52 | } 53 | 54 | if (typeof onSafeFailure === "function") { 55 | for (const parseResultError of parseResult.errors) { 56 | onSafeFailure(parseResultError.error); 57 | } 58 | } 59 | } catch (err: unknown) { 60 | if ( 61 | err instanceof UnexpectedParsingOutputFormatError || 62 | err instanceof UnparsableContentError 63 | ) { 64 | throw err; 65 | } 66 | 67 | throw new UncaughtParsingExceptionError(adapter.toString(), err); 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /packages/sample/pages/native-video/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | WebVTT Custom Viewer 8 | 9 | 10 | 14 | 70 | 71 | 72 |
73 |
74 |

WebVTT Text Track

75 | 76 |
77 |
78 |

Example Video

79 |

80 | Please note that Firefox is glitched and might decide not render anymore subtitles for 81 | unknown reasons, even if reloading, when using regions. 82 |

83 | 84 |
85 |
86 | 87 | 88 | 89 | -------------------------------------------------------------------------------- /packages/webvtt-adapter/src/Parser/Tags/utils.ts: -------------------------------------------------------------------------------- 1 | import { Entities } from "@sub37/server"; 2 | import { EntitiesTokenMap } from "./tokenEntities.js"; 3 | import type { CueParsedData } from "../parseCue.js"; 4 | import type Node from "./Node.js"; 5 | import type NodeQueue from "./NodeQueue.js"; 6 | 7 | export function isSupported(content: string): boolean { 8 | return EntitiesTokenMap.hasOwnProperty(content); 9 | } 10 | 11 | /** 12 | * Creates entities from tree entities that have not been popped 13 | * out yet, without removing them from the tree 14 | * 15 | * @param openTagsQueue 16 | * @param currentCue 17 | * @returns 18 | */ 19 | 20 | export function createTagEntitiesFromUnpaired( 21 | openTagsQueue: NodeQueue, 22 | currentCue: CueParsedData, 23 | ): Entities.Tag[] { 24 | let nodeCursor: Node = openTagsQueue.current; 25 | 26 | if (!nodeCursor) { 27 | return []; 28 | } 29 | 30 | const entities: Entities.Tag[] = []; 31 | 32 | while (nodeCursor !== null) { 33 | if (currentCue.text.length - nodeCursor.index !== 0) { 34 | /** 35 | * If an entity startTag is placed between two timestamps 36 | * the closing timestamp should not have the new tag associated. 37 | * tag.index is zero-based. 38 | */ 39 | 40 | entities.push(createTagEntity(currentCue, nodeCursor)); 41 | } 42 | 43 | nodeCursor = nodeCursor.parent; 44 | } 45 | 46 | return entities; 47 | } 48 | 49 | export function createTagEntity(currentCue: CueParsedData, tagStart: Node): Entities.Tag { 50 | /** 51 | * If length is negative, that means that the tag was opened before 52 | * the beginning of the current Cue. Therefore, offset should represent 53 | * the beginning of the **current cue** and the length should be set to 54 | * current cue content. 55 | */ 56 | 57 | const tagOpenedInCurrentCue = currentCue.text.length - tagStart.index > 0; 58 | 59 | const attributes = new Map( 60 | tagStart.token.annotations?.map((annotation) => { 61 | if (tagStart.token.content === "lang") { 62 | return ["lang", annotation]; 63 | } 64 | 65 | if (tagStart.token.content === "v") { 66 | return ["voice", annotation]; 67 | } 68 | 69 | const attribute = annotation.split("="); 70 | return [attribute[0], attribute[1]?.replace(/["']/g, "")]; 71 | }), 72 | ); 73 | 74 | return new Entities.Tag({ 75 | tagType: EntitiesTokenMap[tagStart.token.content], 76 | offset: tagOpenedInCurrentCue ? tagStart.index : 0, 77 | length: tagOpenedInCurrentCue 78 | ? currentCue.text.length - tagStart.index 79 | : currentCue.text.length, 80 | attributes, 81 | classes: tagStart.token.classes, 82 | }); 83 | } 84 | -------------------------------------------------------------------------------- /packages/server/src/BaseAdapter/index.ts: -------------------------------------------------------------------------------- 1 | import type { CueNode } from "../CueNode.js"; 2 | import { AdapterNotOverridingSupportedTypesError } from "../Errors/index.js"; 3 | 4 | export interface BaseAdapterConstructor { 5 | supportedType: string; 6 | 7 | ParseResult(data: CueNode[], errors: BaseAdapter.ParseError[]): ParseResult; 8 | 9 | new (): BaseAdapter; 10 | } 11 | 12 | export interface BaseAdapter { 13 | parse(rawContent: unknown): ParseResult; 14 | } 15 | 16 | export declare namespace BaseAdapter { 17 | type ParseResult = InstanceType; 18 | type ParseError = InstanceType; 19 | } 20 | 21 | /** By doing this way, we also have static props type-checking */ 22 | export const BaseAdapter: BaseAdapterConstructor = class BaseAdapter implements BaseAdapter { 23 | /** 24 | * Static property that instructs for which type of subtitles 25 | * this adapter should be used. Must be overridden by Adapters 26 | */ 27 | 28 | public static get supportedType(): string { 29 | throw new AdapterNotOverridingSupportedTypesError(this.toString()); 30 | } 31 | 32 | /** 33 | * The result of any operation performed by any adapter that 34 | * extend BaseAdapter 35 | * 36 | * @param data 37 | * @param errors 38 | * @returns 39 | */ 40 | 41 | public static ParseResult( 42 | data: CueNode[] = [], 43 | errors: BaseAdapter.ParseError[] = [], 44 | ): ParseResult { 45 | return new ParseResult(data, errors); 46 | } 47 | 48 | /** 49 | * Returns the human name for the adapter. 50 | * 51 | * @returns 52 | */ 53 | 54 | public static toString(): string { 55 | return this.name ?? "Anonymous adapter"; 56 | } 57 | 58 | /** 59 | * Returns a human name for the adapter. 60 | * 61 | * @returns 62 | */ 63 | 64 | public toString(): string { 65 | return this.constructor.name ?? "Anonymous adapter"; 66 | } 67 | 68 | /** 69 | * Parses the content of the type specified by supportedType. 70 | * It will be called by Server and **must** be overridden by 71 | * any Adapter passed to server. 72 | * 73 | * @param rawContent 74 | */ 75 | 76 | public parse(rawContent: unknown): ParseResult { 77 | throw new Error( 78 | "Adapter doesn't override parse method. Don't know how to parse the content. Content will be ignored.", 79 | ); 80 | } 81 | }; 82 | 83 | export class ParseResult { 84 | public constructor(public data: CueNode[] = [], public errors: BaseAdapter.ParseError[] = []) {} 85 | } 86 | 87 | export class ParseError { 88 | public constructor( 89 | public error: Error, 90 | public isCritical: boolean, 91 | public failedChunk: unknown, 92 | ) {} 93 | } 94 | -------------------------------------------------------------------------------- /packages/captions-renderer/playwright.config.js: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | import { devices } from "@playwright/test"; 3 | 4 | /** 5 | * See https://playwright.dev/docs/test-configuration. 6 | * 7 | * @type {import('@playwright/test').PlaywrightTestConfig} 8 | */ 9 | 10 | export default { 11 | testDir: "./specs", 12 | testMatch: /.*spec\.pw\.(js|ts|mjs)/, 13 | /* Maximum time one test can run for. */ 14 | timeout: 30 * 1000, 15 | expect: { 16 | /** 17 | * Maximum time expect() should wait for the condition to be met. 18 | * For example in `await expect(locator).toHaveText();` 19 | */ 20 | timeout: 5000, 21 | }, 22 | /* Run tests in files in parallel */ 23 | fullyParallel: true, 24 | /* Fail the build on CI if you accidentally left test.only in the source code. */ 25 | forbidOnly: !!process.env.CI, 26 | /* Retry on CI only */ 27 | retries: process.env.CI ? 2 : 0, 28 | /* Opt out of parallel tests on CI. */ 29 | workers: process.env.CI ? 1 : undefined, 30 | /* Reporter to use. See https://playwright.dev/docs/test-reporters */ 31 | reporter: "html", 32 | /* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */ 33 | use: { 34 | /* Maximum time each action such as `click()` can take. Defaults to 0 (no limit). */ 35 | actionTimeout: 0, 36 | /* Base URL to use in actions like `await page.goto('/')`. */ 37 | // baseURL: 'http://localhost:3000', 38 | 39 | /* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */ 40 | trace: "on-first-retry", 41 | }, 42 | 43 | /* Configure projects for major browsers */ 44 | projects: [ 45 | { 46 | name: "chromium", 47 | use: { 48 | ...devices["Desktop Chrome"], 49 | headless: true, 50 | }, 51 | }, 52 | 53 | { 54 | name: "firefox", 55 | use: { 56 | ...devices["Desktop Firefox"], 57 | headless: true, 58 | }, 59 | }, 60 | 61 | { 62 | name: "webkit", 63 | use: { 64 | ...devices["Desktop Safari"], 65 | headless: true, 66 | }, 67 | }, 68 | 69 | /* Test against mobile viewports. */ 70 | // { 71 | // name: 'Mobile Chrome', 72 | // use: { 73 | // ...devices['Pixel 5'], 74 | // }, 75 | // }, 76 | // { 77 | // name: 'Mobile Safari', 78 | // use: { 79 | // ...devices['iPhone 12'], 80 | // }, 81 | // }, 82 | 83 | /* Test against branded browsers. */ 84 | // { 85 | // name: 'Microsoft Edge', 86 | // use: { 87 | // channel: 'msedge', 88 | // }, 89 | // }, 90 | // { 91 | // name: 'Google Chrome', 92 | // use: { 93 | // channel: 'chrome', 94 | // }, 95 | // }, 96 | ], 97 | 98 | webServer: { 99 | command: "pnpm --dir ../sample dev", 100 | /** Sample serves on this port */ 101 | port: 3000, 102 | reuseExistingServer: true, 103 | gracefulShutdown: { 104 | signal: "SIGTERM", 105 | timeout: 5000, 106 | }, 107 | }, 108 | }; 109 | -------------------------------------------------------------------------------- /packages/server/src/CueNode.ts: -------------------------------------------------------------------------------- 1 | import type * as Entities from "./Entities"; 2 | import type { IntervalBinaryLeaf, Leafable } from "./IntervalBinaryTree"; 3 | import type { Region } from "./Region"; 4 | import type { RenderingModifiers } from "./RenderingModifiers"; 5 | 6 | const entitiesSymbol = Symbol("sub37.entities"); 7 | const regionSymbol = Symbol("sub37.region"); 8 | 9 | interface CueProps { 10 | id: string; 11 | startTime: number; 12 | endTime: number; 13 | content: string; 14 | renderingModifiers?: RenderingModifiers; 15 | entities?: Entities.GenericEntity[]; 16 | region?: Region; 17 | } 18 | 19 | export class CueNode implements CueProps, Leafable { 20 | static from(cueNode: CueNode, data: CueProps): CueNode { 21 | if (!cueNode) { 22 | return new CueNode(data); 23 | } 24 | 25 | const descriptors: PropertyDescriptorMap = {}; 26 | const dataMap = Object.entries(data) as [keyof CueProps, CueProps[keyof CueProps]][]; 27 | 28 | for (const [key, value] of dataMap) { 29 | if (cueNode[key] !== value) { 30 | descriptors[key] = { 31 | value, 32 | }; 33 | } 34 | } 35 | 36 | return Object.create(cueNode, descriptors); 37 | } 38 | 39 | public startTime: number; 40 | public endTime: number; 41 | public id: string; 42 | public content: string; 43 | public renderingModifiers?: RenderingModifiers; 44 | 45 | private [regionSymbol]?: Region; 46 | private [entitiesSymbol]: Entities.GenericEntity[] = []; 47 | 48 | constructor(data: CueProps) { 49 | this.id = data.id; 50 | this.startTime = data.startTime; 51 | this.endTime = data.endTime; 52 | this.content = data.content; 53 | this.renderingModifiers = data.renderingModifiers; 54 | this.region = data.region; 55 | 56 | if (data.entities?.length) { 57 | this.entities = data.entities; 58 | } 59 | } 60 | 61 | public get entities(): Entities.GenericEntity[] { 62 | return this[entitiesSymbol]; 63 | } 64 | 65 | public set entities(value: Entities.GenericEntity[]) { 66 | /** 67 | * Reordering cues entities for a later reconciliation 68 | * in captions renderer 69 | */ 70 | 71 | this[entitiesSymbol] = value.sort(reorderEntitiesComparisonFn); 72 | } 73 | 74 | public set region(value: Region) { 75 | if (value) { 76 | this[regionSymbol] = value; 77 | } 78 | } 79 | 80 | public get region(): Region | undefined { 81 | return this[regionSymbol]; 82 | } 83 | 84 | /** 85 | * Method to convert it to an IntervalBinaryTree 86 | * @returns 87 | */ 88 | 89 | public toLeaf(): IntervalBinaryLeaf { 90 | return { 91 | left: null, 92 | right: null, 93 | node: this, 94 | max: this.endTime, 95 | get low() { 96 | return this.node.startTime; 97 | }, 98 | get high() { 99 | return this.node.endTime; 100 | }, 101 | }; 102 | } 103 | } 104 | 105 | function reorderEntitiesComparisonFn(e1: Entities.GenericEntity, e2: Entities.GenericEntity) { 106 | return e1.offset <= e2.offset ? -1 : 1; 107 | } 108 | -------------------------------------------------------------------------------- /packages/webvtt-adapter/specs/Token.spec.mjs: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | import { describe, beforeEach, it, expect } from "@jest/globals"; 3 | import { Token, TokenType } from "../lib/Token.js"; 4 | 5 | describe("Token", () => { 6 | /** @type {Token} */ 7 | let token; 8 | 9 | describe("::String", () => { 10 | beforeEach(() => { 11 | token = Token.String("test content", { start: 10, end: 15 }); 12 | }); 13 | 14 | it("should own a length and an offset", () => { 15 | expect(token.type).toBe(TokenType.STRING); 16 | expect(token.length).toBe(5); 17 | expect(token.offset).toBe(10); 18 | }); 19 | 20 | it("should not have classes", () => { 21 | expect(token.classes).toBeUndefined(); 22 | }); 23 | 24 | it("should not have annotations", () => { 25 | expect(token.annotations).toBeUndefined(); 26 | }); 27 | 28 | it("should bring the content as-is", () => { 29 | expect(token.content).toBe("test content"); 30 | }); 31 | }); 32 | 33 | describe("::StartTag", () => { 34 | beforeEach(() => { 35 | token = Token.StartTag("b", { start: 10, end: 15 }); 36 | }); 37 | 38 | it("should own a length and an offset", () => { 39 | expect(token.type).toBe(TokenType.START_TAG); 40 | expect(token.length).toBe(5); 41 | expect(token.offset).toBe(10); 42 | }); 43 | 44 | it("should retain classes", () => { 45 | const token = Token.StartTag("b", { start: 10, end: 15 }, ["className"]); 46 | expect(token.classes).toEqual(["className"]); 47 | }); 48 | 49 | it("should retain annotations", () => { 50 | const token = Token.StartTag("b", { start: 10, end: 15 }, undefined, ["Fred"]); 51 | expect(token.annotations).toEqual(["Fred"]); 52 | }); 53 | 54 | it("should set classes to empty array if none is available", () => { 55 | expect(token.classes).toEqual([]); 56 | }); 57 | 58 | it("should set annotations to empty array if none is available", () => { 59 | expect(token.annotations).toEqual([]); 60 | }); 61 | 62 | it("should bring the content as-is", () => { 63 | expect(token.content).toBe("b"); 64 | }); 65 | }); 66 | 67 | describe("::EndTag", () => { 68 | beforeEach(() => { 69 | token = Token.EndTag("b", { start: 10, end: 15 }); 70 | }); 71 | 72 | it("should own a length and an offset", () => { 73 | expect(token.type).toBe(TokenType.END_TAG); 74 | expect(token.length).toBe(5); 75 | expect(token.offset).toBe(10); 76 | }); 77 | 78 | it("should not have classes", () => { 79 | expect(token.classes).toBeUndefined(); 80 | }); 81 | 82 | it("should not have annotations", () => { 83 | expect(token.annotations).toBeUndefined(); 84 | }); 85 | 86 | it("should bring the content as-is", () => { 87 | expect(token.content).toBe("b"); 88 | }); 89 | }); 90 | 91 | describe("::TimestampTag", () => { 92 | beforeEach(() => { 93 | token = Token.TimestampTag("00.02.22:000", { start: 10, end: 15 }); 94 | }); 95 | 96 | it("should own a length and an offset", () => { 97 | expect(token.type).toBe(TokenType.TIMESTAMP); 98 | expect(token.length).toBe(5); 99 | expect(token.offset).toBe(10); 100 | }); 101 | 102 | it("should not have classes", () => { 103 | expect(token.classes).toBeUndefined(); 104 | }); 105 | 106 | it("should not have annotations", () => { 107 | expect(token.annotations).toBeUndefined(); 108 | }); 109 | 110 | it("should bring the content as-is", () => { 111 | expect(token.content).toBe("00.02.22:000"); 112 | }); 113 | }); 114 | }); 115 | -------------------------------------------------------------------------------- /packages/webvtt-adapter/src/Parser/parseRegion.ts: -------------------------------------------------------------------------------- 1 | import type { Region } from "@sub37/server"; 2 | 3 | /** 4 | * @param rawRegionData 5 | */ 6 | 7 | export function parseRegion(rawRegionData: string): Region { 8 | const region = new WebVTTRegion(); 9 | const attributes = rawRegionData.split(/[\n\t\s]+/); 10 | 11 | for (let i = 0; i < attributes.length; i++) { 12 | const [key, value] = attributes[i].split(":") as [keyof WebVTTRegion, string]; 13 | 14 | if (!value || !key) { 15 | continue; 16 | } 17 | 18 | switch (key) { 19 | case "regionanchor": 20 | case "viewportanchor": { 21 | const [x = "0%", y = "0%"] = value.split(","); 22 | 23 | if (!x.endsWith("%") || !y.endsWith("%")) { 24 | break; 25 | } 26 | 27 | const xInteger = parseInt(x); 28 | const yInteger = parseInt(y); 29 | 30 | if (Number.isNaN(xInteger) || Number.isNaN(yInteger)) { 31 | break; 32 | } 33 | 34 | const clampedX = Math.max(0, Math.min(xInteger, 100)); 35 | const clampedY = Math.max(0, Math.min(yInteger, 100)); 36 | 37 | region[key] = [clampedX, clampedY]; 38 | break; 39 | } 40 | 41 | case "scroll": { 42 | if (value !== "up" && value !== "none") { 43 | break; 44 | } 45 | 46 | region[key] = value; 47 | break; 48 | } 49 | 50 | case "id": { 51 | region[key] = value; 52 | break; 53 | } 54 | 55 | case "lines": 56 | case "width": { 57 | region[key] = parseInt(value); 58 | break; 59 | } 60 | 61 | default: 62 | break; 63 | } 64 | } 65 | 66 | if (!region.id) { 67 | return undefined; 68 | } 69 | 70 | return region; 71 | } 72 | 73 | /** 74 | * One line's height in VH units. 75 | * This probably assumes that each line in renderer is 76 | * of the same height. So this might lead to some issues 77 | * in the future. 78 | * 79 | * I still don't have clear why Chrome does have this 80 | * constant while all the standard version of VTT standard 81 | * says "6vh". 82 | * 83 | * @see https://github.com/chromium/chromium/blob/c4d3c31083a2e1481253ff2d24298a1dfe19c754/third_party/blink/renderer/core/html/track/vtt/vtt_region.cc#L70 84 | * @see https://www.w3.org/TR/webvtt1/#processing-model 85 | */ 86 | 87 | const VH_LINE_HEIGHT = 5.33; 88 | 89 | class WebVTTRegion implements Region { 90 | public id: string; 91 | /** 92 | * Region width expressed in percentage 93 | */ 94 | public width: number = 100; 95 | public lines: number = 3; 96 | public scroll?: "up" | "none"; 97 | 98 | /** 99 | * Position of region based on video region. 100 | * Couple of numbers expressed in percentage 101 | */ 102 | public viewportanchor?: [number, number]; 103 | 104 | /** 105 | * Position of region based on viewportAnchor 106 | * Couple of numbers expressed in percentage 107 | */ 108 | public regionanchor?: [number, number]; 109 | 110 | public getOrigin(): [x: string, y: string] { 111 | const height = VH_LINE_HEIGHT * this.lines; 112 | 113 | const [regionAnchorWidth = 0, regionAnchorHeight = 0] = this.regionanchor || []; 114 | const [viewportAnchorWidth = 0, viewportAnchorHeight = 0] = this.viewportanchor || []; 115 | 116 | /** 117 | * It is still not very clear to me why we base on current width and height, but 118 | * a thing that I know is that we need low numbers. 119 | */ 120 | 121 | const leftOffset = (regionAnchorWidth * this.width) / 100; 122 | const topOffset = (regionAnchorHeight * height) / 100; 123 | 124 | const originX = `${viewportAnchorWidth - leftOffset}%`; 125 | const originY = `${viewportAnchorHeight - topOffset}%`; 126 | 127 | return [originX, originY]; 128 | } 129 | } 130 | -------------------------------------------------------------------------------- /assets/wiki/timeline.svg: -------------------------------------------------------------------------------- 1 | Time00:37:04.000 -------------------------------------------------------------------------------- /packages/sample/src/components/customElements/fake-video/index.ts: -------------------------------------------------------------------------------- 1 | import "./controls"; 2 | import { Controls } from "./controls"; 3 | 4 | const currentTimeSymbol = Symbol("currentTime"); 5 | const durationSymbol = Symbol("duration"); 6 | 7 | export class FakeHTMLVideoElement extends HTMLElement { 8 | static get observedAttributes() { 9 | return ["controls"]; 10 | } 11 | 12 | private playheadInterval: number | undefined; 13 | private [currentTimeSymbol]: number = 0; 14 | private [durationSymbol]: number = 0; 15 | 16 | public constructor() { 17 | super(); 18 | 19 | this.attachShadow({ mode: "open" }); 20 | 21 | const style = document.createElement("style"); 22 | style.textContent = ` 23 | :host { 24 | height: 100%; 25 | width: 100%; 26 | position: absolute; 27 | z-index: 10; 28 | } 29 | `; 30 | 31 | console.log("controls:", this.getAttribute("controls")); 32 | this.shadowRoot.append(style); 33 | 34 | this.updateControlsView(this.hasAttribute("controls")); 35 | } 36 | 37 | public attributeChangedCallback(name: string, oldValue: string, newValue: string) { 38 | if (name === "controls") { 39 | this.updateControlsView(typeof newValue === "string"); 40 | return; 41 | } 42 | } 43 | 44 | private updateControlsView(viewWillBecomeVisible: boolean) { 45 | let controlsView = this.shadowRoot.querySelector("controls-skin") as Controls; 46 | 47 | if (viewWillBecomeVisible) { 48 | if (controlsView) { 49 | return; 50 | } 51 | 52 | controlsView = document.createElement("controls-skin") as Controls; 53 | this.shadowRoot.appendChild(controlsView); 54 | 55 | controlsView.controllers = { 56 | onPlaybackStatusChange: (status) => { 57 | if (status === "PLAY") { 58 | this.play(); 59 | } else { 60 | this.pause(); 61 | } 62 | }, 63 | onSeek: (currentTime) => { 64 | this.currentTime = currentTime; 65 | this.dispatchEvent(new Event("seeked")); 66 | }, 67 | }; 68 | 69 | if (this.playheadInterval) { 70 | controlsView.play(); 71 | } else { 72 | controlsView.pause(); 73 | } 74 | 75 | return; 76 | } 77 | 78 | controlsView = this.shadowRoot.querySelector("controls-skin") as Controls; 79 | controlsView?.remove(); 80 | } 81 | 82 | public get currentTime(): number { 83 | return this[currentTimeSymbol]; 84 | } 85 | 86 | public set currentTime(value: number) { 87 | const controlsView = this.shadowRoot.querySelector("controls-skin") as Controls; 88 | 89 | this[currentTimeSymbol] = Math.min(Math.max(0, value), this[durationSymbol]); 90 | const events: Event[] = [new Event("seeked"), new Event("timeupdate")]; 91 | 92 | if (controlsView) { 93 | controlsView.currentTime = this[currentTimeSymbol]; 94 | } 95 | 96 | for (const event of events) { 97 | this.dispatchEvent(event); 98 | } 99 | } 100 | 101 | public get duration(): number { 102 | return this[durationSymbol]; 103 | } 104 | 105 | public set duration(value: number) { 106 | this[durationSymbol] = value; 107 | } 108 | 109 | public get paused() { 110 | return this.playheadInterval === undefined; 111 | } 112 | 113 | public play() { 114 | if (this.playheadInterval) { 115 | window.clearInterval(this.playheadInterval); 116 | this.playheadInterval = undefined; 117 | } 118 | 119 | this.playheadInterval = window.setInterval(() => { 120 | const event = new Event("timeupdate"); 121 | this.dispatchEvent(event); 122 | this.currentTime += 0.25; 123 | 124 | if (this.currentTime >= this.duration) { 125 | this.pause(); 126 | } 127 | }, 250); 128 | 129 | const controlsView = this.shadowRoot.querySelector("controls-skin") as Controls; 130 | controlsView?.play(); 131 | 132 | const event = new Event("playing"); 133 | this.dispatchEvent(event); 134 | // this.emitEvent("playing"); 135 | } 136 | 137 | public pause() { 138 | if (this.paused) { 139 | return; 140 | } 141 | 142 | const controlsView = this.shadowRoot.querySelector("controls-skin") as Controls; 143 | controlsView.pause(); 144 | 145 | window.clearInterval(this.playheadInterval); 146 | this.playheadInterval = undefined; 147 | 148 | const event = new Event("pause"); 149 | this.dispatchEvent(event); 150 | } 151 | } 152 | 153 | window.customElements.define("fake-video", FakeHTMLVideoElement); 154 | -------------------------------------------------------------------------------- /packages/sample/src/components/customElements/fake-video/controls.ts: -------------------------------------------------------------------------------- 1 | export interface ControlDelegates { 2 | onPlaybackStatusChange?(status: "PLAY" | "PAUSE"): void; 3 | onSeek?(newTime: number): void; 4 | } 5 | 6 | const controllersSymbol = Symbol("controllers"); 7 | 8 | export class Controls extends HTMLElement { 9 | private [controllersSymbol]: ControlDelegates = {}; 10 | 11 | public constructor() { 12 | super(); 13 | 14 | this.onPlaybackStatusChange = this.onPlaybackStatusChange.bind(this); 15 | 16 | const shadowRoot = this.attachShadow({ mode: "open" }); 17 | const style = document.createElement("style"); 18 | 19 | style.id = "host-styles"; 20 | style.textContent = ` 21 | :host { 22 | width: 100%; 23 | height: 100%; 24 | display: block; 25 | } 26 | 27 | :host #ranger { 28 | width: 100%; 29 | display: flex; 30 | justify-content: space-evenly; 31 | gap: 20px; 32 | margin-bottom: 10px; 33 | position: absolute; 34 | bottom: 0; 35 | box-sizing: border-box; 36 | padding: 0 15px; 37 | } 38 | 39 | :host #ranger input { 40 | flex-grow: 1; 41 | } 42 | 43 | :host #ranger img { 44 | width: 1.2em; 45 | } 46 | 47 | :host #ranger span { 48 | width: 50px; 49 | } 50 | `; 51 | 52 | shadowRoot.appendChild(style); 53 | 54 | const ranger = Object.assign(document.createElement("div"), { 55 | id: "ranger", 56 | }); 57 | 58 | const timeRange = Object.assign(document.createElement("input"), { 59 | type: "range", 60 | min: 0, 61 | max: 7646, 62 | value: 0, 63 | step: 0.25, 64 | id: "time-range", 65 | }); 66 | 67 | timeRange.addEventListener("input", () => { 68 | const time = parseFloat(timeRange.value); 69 | this[controllersSymbol]?.onSeek?.(time); 70 | timeLabel.textContent = String(time); 71 | }); 72 | 73 | const timeLabel = Object.assign(document.createElement("span"), { 74 | id: "currentTime", 75 | textContent: "0", 76 | style: { 77 | width: "50px", 78 | }, 79 | }); 80 | 81 | const durationLabel = Object.assign(document.createElement("span"), { 82 | id: "durationTime", 83 | textContent: "7646", 84 | }); 85 | 86 | const playbackButton = Object.assign(document.createElement("img"), { 87 | src: "../../../pause-icon.svg", 88 | id: "playback-btn", 89 | style: { 90 | cursor: "click", 91 | }, 92 | }); 93 | playbackButton.dataset["playback"] = "playing"; 94 | playbackButton.addEventListener("click", this.onPlaybackStatusChange); 95 | 96 | ranger.append(playbackButton, timeLabel, timeRange, durationLabel); 97 | shadowRoot.appendChild(ranger); 98 | } 99 | 100 | public set duration(value: number) { 101 | const valueString = String(value); 102 | const [input, timeLabel] = this.shadowRoot.querySelectorAll( 103 | "input, #durationTime", 104 | ) as unknown as [HTMLInputElement, HTMLSpanElement]; 105 | 106 | timeLabel.textContent = valueString; 107 | input.max = valueString; 108 | } 109 | 110 | public set currentTime(value: number) { 111 | const valueString = String(value); 112 | const [timeLabel, input] = this.shadowRoot.querySelectorAll( 113 | "input, #currentTime", 114 | ) as unknown as [HTMLSpanElement, HTMLInputElement]; 115 | 116 | timeLabel.textContent = valueString; 117 | input.value = valueString; 118 | } 119 | 120 | public set controllers(value: ControlDelegates) { 121 | this[controllersSymbol] = value; 122 | } 123 | 124 | public play( 125 | playbackButton: HTMLImageElement = this.shadowRoot.getElementById( 126 | "playback-btn", 127 | ) as HTMLImageElement, 128 | ) { 129 | playbackButton.src = "../../../pause-icon.svg"; 130 | playbackButton.dataset["playback"] = "playing"; 131 | } 132 | 133 | public pause( 134 | playbackButton: HTMLImageElement = this.shadowRoot.getElementById( 135 | "playback-btn", 136 | ) as HTMLImageElement, 137 | ) { 138 | playbackButton.src = "../../../play-icon.svg"; 139 | playbackButton.dataset["playback"] = "paused"; 140 | } 141 | 142 | private onPlaybackStatusChange() { 143 | const playbackButton: HTMLImageElement = this.shadowRoot.getElementById( 144 | "playback-btn", 145 | ) as HTMLImageElement; 146 | 147 | if (playbackButton.dataset["playback"] === "playing") { 148 | this.pause(playbackButton); 149 | this[controllersSymbol]?.onPlaybackStatusChange?.("PAUSE"); 150 | } else { 151 | this.play(playbackButton); 152 | this[controllersSymbol]?.onPlaybackStatusChange?.("PLAY"); 153 | } 154 | } 155 | } 156 | 157 | window.customElements.define("controls-skin", Controls); 158 | -------------------------------------------------------------------------------- /packages/sample/src/longtexttrack-chunk1.vtt: -------------------------------------------------------------------------------- 1 | WEBVTT 2 | 3 | 1192 4 | 01:35:20.440 --> 01:35:23.040 5 | <i>La scoperta è stata fatta</i> 6 | <i>dalla Swat dell'FBI</i> 7 | 8 | 1193 9 | 01:35:23.120 --> 01:35:24.600 10 | <i>che ha fatto irruzione nel complesso.</i> 11 | 12 | 1194 13 | 01:35:24.680 --> 01:35:27.240 14 | <i>Il dispositivo che inizialmente</i> 15 | <i>si credeva fosse una bomba,</i> 16 | 17 | 1195 18 | 01:35:27.320 --> 01:35:30.760 19 | <i>si è rivelato essere un server esca,</i> 20 | <i>messo lì dai colpevoli,</i> 21 | 22 | 1196 23 | 01:35:30.840 --> 01:35:33.040 24 | <i>solo per depistare le autorità.</i> 25 | 26 | 1197 27 | 01:35:33.120 --> 01:35:36.400 28 | <i>Al momento, non ci sono ancora</i> 29 | <i>sospettati per questo caso</i>. 30 | 31 | 1198 32 | 01:35:36.480 --> 01:35:38.200 33 | <i>Bentornati al Chuck Torn Show.</i> 34 | 35 | 1199 36 | 01:35:38.280 --> 01:35:40.920 37 | <i>Ho parlato con il gigante della tecnologia</i> 38 | <i>Nero Alexander,</i> 39 | 40 | 1200 41 | 01:35:41.000 --> 01:35:42.560 42 | <i>proprietario di più di 70 aziende,</i> 43 | 44 | 1201 45 | 01:35:42.640 --> 01:35:46.120 46 | <i>che spaziano dalla bio-costruzione,</i> 47 | <i>ai carburanti, alla farmaceutica,</i> 48 | 49 | 1202 50 | 01:35:46.200 --> 01:35:48.680 51 | <i>alle intelligenze artificiali</i> 52 | <i>e ora anche alla nanotecnologia.</i> 53 | 54 | 1203 55 | 01:35:48.760 --> 01:35:51.880 56 | <i>Nero, hai realizzato</i> 57 | <i>quello che pochi altri hanno raggiunto,</i> 58 | 59 | 1204 60 | 01:35:51.960 --> 01:35:54.880 61 | <i>hai costruito un impero</i> 62 | <i>senza l'aiuto di nessuno.</i> 63 | 64 | 1205 65 | 01:35:54.960 --> 01:35:57.920 66 | <i>Non hai ereditato alcuna ricchezza,</i> 67 | <i>eppure, a un certo punto,</i> 68 | 69 | 1206 70 | 01:35:58.000 --> 01:36:02.080 71 | sei diventato il più giovane miliardario 72 | del pianeta. Non è così? 73 | 74 | 1207 75 | 01:36:02.160 --> 01:36:06.200 76 | Sono quasi in pensione adesso, 77 | mi voglio solo divertire. 78 | 79 | 1208 80 | 01:36:06.880 --> 01:36:08.680 81 | A tutti gli imprenditori in erba 82 | 83 | 1209 84 | 01:36:08.760 --> 01:36:12.920 85 | che ti considerano un esempio di quello 86 | che è possibile ottenere, che cosa dici? 87 | 88 | 1210 89 | 01:36:13.000 --> 01:36:17.080 90 | So soltanto che la gran parte dei giovani 91 | di oggi vuole tutto, senza fare niente. 92 | 93 | 1211 94 | 01:36:17.160 --> 01:36:20.120 95 | Vuole fama e fortuna con 96 | il minimo sforzo possibile. 97 | 98 | 1212 99 | 01:36:21.000 --> 01:36:26.200 100 | È lo scienziato il modello da seguire, 101 | il fisico, l'ingegnere, l'inventore. 102 | 103 | 1213 104 | 01:36:26.880 --> 01:36:30.360 105 | Non qualcuno il cui unico contributo 106 | all'umanità è un video porno 107 | 108 | 1214 109 | 01:36:31.040 --> 01:36:35.760 110 | o uno stupido reality show 111 | o milioni di followers sui social. 112 | 113 | 1215 114 | 01:36:35.840 --> 01:36:37.720 115 | Insomma, è una vera follia! 116 | 117 | 1216 118 | 01:36:40.320 --> 01:36:45.360 119 | Quindi credo di dover dire: fate qualcosa 120 | che dia un contributo alla società, 121 | 122 | 1217 123 | 01:36:45.440 --> 01:36:46.920 124 | io so di averlo fatto. 125 | 126 | 1218 127 | 01:37:24.720 --> 01:37:26.240 128 | Cat Zim. 129 | 130 | 1219 131 | 01:37:28.520 --> 01:37:32.080 132 | Sei una donna piena di sorprese, 133 | non è vero? 134 | 135 | 1220 136 | 01:37:49.200 --> 01:37:51.920 137 | Allora, ti sei divertita? 138 | 139 | 1221 140 | 01:37:54.480 --> 01:37:55.480 141 | Sì. 142 | 143 | 1222 144 | 01:37:56.320 --> 01:37:57.520 145 | Ma la prossima volta... 146 | 147 | 1223 148 | 01:37:58.720 --> 01:38:00.560 149 | non ti lascerò vincere a scacchi. 150 | 151 | 1224 152 | 01:41:12.280 --> 01:41:13.680 153 | Si, è Gary che parla. 154 | 155 | 1225 156 | 01:41:14.840 --> 01:41:18.600 157 | Nevin? No, sta partecipando 158 | a uno show, <i>Funhouse</i>, mi sembra. 159 | 160 | 1226 161 | 01:41:19.560 --> 01:41:20.560 162 | Cosa? 163 | 164 | 1227 165 | 01:41:21.280 --> 01:41:22.280 166 | È morto? 167 | 168 | 1228 169 | 01:41:23.720 --> 01:41:25.080 170 | Pignatta umana? 171 | 172 | 1229 173 | 01:41:25.960 --> 01:41:27.160 174 | Che cazzata è questa? 175 | 176 | 1230 177 | 01:41:30.000 --> 01:41:31.880 178 | Abbiamo avuto lo stesso i 100.000? 179 | 180 | 1231 181 | 01:41:34.800 --> 01:41:37.960 182 | Che mi prenda un colpo. 183 | Posso avere un altro Mai Tai? -------------------------------------------------------------------------------- /packages/server/src/IntervalBinaryTree.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Implementation of an Interval Tree or Binary Search Three without nodes 3 | * deletion feature. 4 | * 5 | * This solves the issue of "How can we serve several overlapping cues 6 | * at the same time? 7 | */ 8 | 9 | export interface IntervalBinaryLeaf { 10 | left: IntervalBinaryLeaf | null; 11 | right: IntervalBinaryLeaf | null; 12 | node: LeafShape; 13 | max: number; 14 | get low(): number; 15 | get high(): number; 16 | } 17 | 18 | export interface Leafable { 19 | toLeaf(): IntervalBinaryLeaf; 20 | } 21 | 22 | export class IntervalBinaryTree { 23 | private root: IntervalBinaryLeaf | null = null; 24 | 25 | public addNode(newNode: Leafable | IntervalBinaryLeaf): void { 26 | const nextTreeNode = isLeafable(newNode) ? newNode.toLeaf() : newNode; 27 | 28 | if (!this.root) { 29 | this.root = nextTreeNode; 30 | return; 31 | } 32 | 33 | insert(this.root, nextTreeNode); 34 | } 35 | 36 | /** 37 | * Retrieves nodes which startTime and endTime are inside 38 | * 39 | * @param positionOrRange 40 | * @returns 41 | */ 42 | 43 | public getCurrentNodes( 44 | positionOrRange: number | [start: number, end: number], 45 | ): null | IntervalBinaryLeaf["node"][] { 46 | let range: [number, number]; 47 | 48 | if (positionOrRange instanceof Array) { 49 | range = positionOrRange; 50 | } else { 51 | range = [positionOrRange, positionOrRange]; 52 | } 53 | 54 | return accumulateMatchingNodes(this.root, ...range); 55 | } 56 | 57 | /** 58 | * Retrieves all the nodes in order 59 | * @returns 60 | */ 61 | 62 | public getAll(): IntervalBinaryLeaf["node"][] { 63 | return findAllInSubtree(this.root); 64 | } 65 | } 66 | 67 | function insert( 68 | root: IntervalBinaryLeaf | null, 69 | node: IntervalBinaryLeaf, 70 | ) { 71 | if (!root) { 72 | return node; 73 | } 74 | 75 | if (node.low <= root.low) { 76 | root.left = insert(root.left, node); 77 | } else { 78 | root.right = insert(root.right, node); 79 | } 80 | 81 | if (root.max < node.high) { 82 | root.max = node.high; 83 | } 84 | 85 | return root; 86 | } 87 | 88 | /** 89 | * Handles exploration of the tree starting from a specific node 90 | * and checking if every queried node's startTime and endTime are 91 | * an interval containing time parameter 92 | * 93 | * @param treeNode 94 | * @param low 95 | * @param high 96 | * @returns 97 | */ 98 | 99 | function accumulateMatchingNodes( 100 | treeNode: IntervalBinaryLeaf | null, 101 | low: number, 102 | high: number, 103 | ): IntervalBinaryLeaf["node"][] { 104 | if (!treeNode) { 105 | return []; 106 | } 107 | 108 | const matchingNodes: IntervalBinaryLeaf["node"][] = []; 109 | 110 | /** 111 | * If current node has not yet ended, we might have nodes 112 | * on left that might overlap 113 | */ 114 | 115 | if (treeNode.left && treeNode.left.max >= low) { 116 | matchingNodes.push(...accumulateMatchingNodes(treeNode.left, low, high)); 117 | } 118 | 119 | /** 120 | * After having processed all the left nodes we can 121 | * proceed checking the current one, so we are sure 122 | * even unordered nodes will be pushed in the 123 | * correct sequence. 124 | */ 125 | 126 | if ( 127 | (low >= treeNode.low && treeNode.high >= low) || 128 | (high >= treeNode.low && treeNode.high >= high) 129 | ) { 130 | matchingNodes.push(treeNode.node); 131 | } 132 | 133 | if (treeNode.right) { 134 | /** 135 | * If current node has started already started, we might have 136 | * some nodes that are overlapping or this is just not the node 137 | * we are looking for. We don't care if the current 138 | * node has finished or not here. Right nodes will be for sure bigger. 139 | */ 140 | 141 | matchingNodes.push(...accumulateMatchingNodes(treeNode.right, low, high)); 142 | } 143 | 144 | return matchingNodes; 145 | } 146 | 147 | /** 148 | * Recursively scans and accumulate the nodes in the subtree 149 | * starting from an arbitrary root node 150 | * 151 | * @param root 152 | * @returns 153 | */ 154 | 155 | function findAllInSubtree( 156 | root: IntervalBinaryLeaf | null, 157 | ): IntervalBinaryLeaf["node"][] { 158 | if (!root) { 159 | return []; 160 | } 161 | 162 | return [...findAllInSubtree(root.left), root.node, ...findAllInSubtree(root.right)]; 163 | } 164 | 165 | function isLeafable(node: unknown): node is Leafable { 166 | return typeof (node as Leafable).toLeaf === "function"; 167 | } 168 | -------------------------------------------------------------------------------- /packages/webvtt-adapter/src/Parser/parseCue.ts: -------------------------------------------------------------------------------- 1 | import type { Entities, RenderingModifiers } from "@sub37/server"; 2 | import type { Token } from "../Token.js"; 3 | import { Tokenizer } from "../Tokenizer.js"; 4 | import { TokenType } from "../Token.js"; 5 | import * as Tags from "./Tags/index.js"; 6 | import * as Timestamps from "./Timestamps.utils.js"; 7 | import { WebVTTRenderingModifiers } from "./RenderingModifiers.js"; 8 | 9 | /** This structure is compliant with the resulting one from Regex groups property */ 10 | export interface CueRawData { 11 | cueid: string; 12 | starttime: string; 13 | endtime: string; 14 | attributes: string; 15 | text: string; 16 | } 17 | 18 | export interface CueParsedData { 19 | id?: string; 20 | startTime: number; 21 | endTime: number; 22 | regionName?: string; 23 | tags: Entities.Tag[]; 24 | text: string; 25 | renderingModifiers: RenderingModifiers; 26 | 27 | /** 28 | * Grouping identifier allows us to skip 29 | * uniqueness checks for ids for cues nodes 30 | * coming from the same source cues 31 | */ 32 | groupingIdentifier?: string; 33 | } 34 | 35 | export function parseCue(data: CueRawData): CueParsedData[] { 36 | const { starttime, endtime, text } = data; 37 | 38 | const hsCues: CueParsedData[] = []; 39 | const tokenizer = new Tokenizer(text); 40 | 41 | let token: Token = null; 42 | let currentCue = createCue( 43 | Timestamps.parseMs(starttime), 44 | Timestamps.parseMs(endtime), 45 | data.cueid, 46 | WebVTTRenderingModifiers.fromString(data.attributes), 47 | ); 48 | 49 | const openTagsQueue = new Tags.NodeQueue(); 50 | 51 | while ((token = tokenizer.nextToken())) { 52 | switch (token.type) { 53 | case TokenType.START_TAG: { 54 | if (Tags.isSupported(token.content)) { 55 | openTagsQueue.push(new Tags.Node(currentCue.text.length, token)); 56 | } 57 | 58 | break; 59 | } 60 | 61 | case TokenType.END_TAG: { 62 | if (Tags.isSupported(token.content) && openTagsQueue.length) { 63 | if (!openTagsQueue.current) { 64 | break; 65 | } 66 | 67 | /** 68 | * is expected to contain nothing but text and . 69 | * Can we be safe about popping twice, one for rt and one for ruby later? 70 | */ 71 | 72 | if (token.content === "ruby" && openTagsQueue.current.token.content === "rt") { 73 | const out = openTagsQueue.pop(); 74 | addCueEntities(currentCue, [Tags.createTagEntity(currentCue, out)]); 75 | } 76 | 77 | if (openTagsQueue.current.token.content === token.content) { 78 | const out = openTagsQueue.pop(); 79 | addCueEntities(currentCue, [Tags.createTagEntity(currentCue, out)]); 80 | } 81 | } 82 | 83 | break; 84 | } 85 | 86 | case TokenType.STRING: { 87 | currentCue.text += token.content; 88 | break; 89 | } 90 | 91 | case TokenType.TIMESTAMP: { 92 | /** 93 | * If current cue has no content, we can safely ignore it. 94 | * Next cues will be the timestamped ones. 95 | */ 96 | 97 | if (currentCue.text.length) { 98 | /** 99 | * Closing the current entities for the previous cue, 100 | * still without resetting open tags, because timestamps 101 | * actually belong to the same "logic" cue, so we might 102 | * have some tags still open 103 | */ 104 | 105 | addCueEntities(currentCue, Tags.createTagEntitiesFromUnpaired(openTagsQueue, currentCue)); 106 | currentCue.groupingIdentifier = currentCue.id || "timestamp-group"; 107 | hsCues.push(currentCue); 108 | } 109 | 110 | currentCue = createCue( 111 | Timestamps.parseMs(token.content), 112 | currentCue.endTime, 113 | currentCue.id, 114 | currentCue.renderingModifiers, 115 | currentCue.groupingIdentifier, 116 | ); 117 | 118 | break; 119 | } 120 | 121 | default: 122 | break; 123 | } 124 | 125 | // Resetting the token for the next one 126 | token = null; 127 | } 128 | 129 | /** 130 | * For the last token... hip hip, hooray! 131 | * Jk, we need to close the yet-opened 132 | * tags and create entities for them. 133 | */ 134 | 135 | addCueEntities(currentCue, Tags.createTagEntitiesFromUnpaired(openTagsQueue, currentCue)); 136 | 137 | if (currentCue.text.length) { 138 | hsCues.push(currentCue); 139 | } 140 | 141 | return hsCues; 142 | } 143 | 144 | function addCueEntities(cue: CueParsedData, entities: Entities.Tag[]) { 145 | for (const entity of entities) { 146 | cue.tags.push(entity); 147 | } 148 | } 149 | 150 | function createCue( 151 | startTime: number, 152 | endTime: number, 153 | id?: string, 154 | renderingModifiers?: RenderingModifiers, 155 | groupingIdentifier?: string, 156 | ): CueParsedData { 157 | return { 158 | startTime, 159 | endTime, 160 | text: "", 161 | tags: [], 162 | id, 163 | renderingModifiers, 164 | groupingIdentifier, 165 | }; 166 | } 167 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |
2 |
3 |
4 | 5 | 8 | sub37 logo for light mode 12 | 13 |
14 |
15 |
16 | 17 | ## Introduction 18 | 19 | sub37 is set of client-side _dependency-free_ libraries that aims to provide a consistent and customizable media contents captions experience across browsers by leaving developers, at the same time, the freedom to choose which caption format to support (like WebVTT, TTML, ...) and how to style them. 20 | 21 | ## Architecture overview 22 | 23 | sub37 architecture is made of three components: 24 | 25 | - A [Renderer](packages/captions-renderer/) (`@sub37/captions-renderer`), a WebComponent that handles all the aspects of captions rendering (styles, colors, positioning, etc.); 26 | - A [Server](packages/server/) (`@sub37/server`), a client library that handles timed serving of cues and centralizes all the shared aspects (formats, communication); 27 | - An [Adapter](packages/webvtt-adapter/) (e.g. `@sub37/webvtt-adapter`), a library that knows how to parse a specific captions format and knows how to convert it to the core format provided by the server; 28 | 29 | Of the three components, both Renderer and Adapter are replaceable: they only need to provide the correct support to the interface that server expects to use to communicate with them (see the wiki). 30 | 31 | > At this early stage, sub37 provides only the webvtt-adapter to be used. Further formats will be implemented and evaluated over the time. 32 | 33 | Here a schema of how the flow works: 34 | 35 |
36 | architecture 37 |
38 | 39 | ## Installation 40 | 41 | Once chosen the adapters suitable for your use case, proceed to install all the packages needed. 42 | 43 | ```sh 44 | $ npm install @sub37/server @sub37/captions-renderer @sub37/webvtt-adapter 45 | ``` 46 | 47 | ## API Documentation Reference 48 | 49 | Each component has its own README and Wiki Page that provides more details on how they should be used and which features they support. 50 | 51 | More details about usage and architecture can be found in the wiki (_coming soon_). 52 | 53 | ## Usage example 54 | 55 | In your HTML, include the `captions-renderer`. This element is studied to be put over `HTMLVideoElement`s. 56 | 57 | ```html 58 |
59 | 60 |
62 | ``` 63 | 64 | In your Javascript, load the caption-renderer by importing it as a side-effect. This will load it into your window's customElements Registry. 65 | 66 | Create an instance of the server and pass it the Renderers you want to import. The renderers will be available as long as you keep your Server instance. 67 | 68 | Attach `caption-renderer` to server instance event system. Then create a session and pass it a set of tracks. Each track represents a content to be associated and parsed through your adapters. 69 | As parsing is a synchronous activity, once it is completed, you'll be able to call `.start` to start serving the content. 70 | 71 | ```javascript 72 | import "@sub37/captions-renderer"; 73 | import { Server, Events } from "@sub37/server"; 74 | import { WebVTTAdapter } from "@sub37/webvtt-adapter"; 75 | 76 | const videoElement = document.getElementsByTagName("video")[0]; 77 | const rendererElement = document.getElementsByTagName("captions-renderer")[0]; 78 | 79 | /** 80 | * Create the server instance. 81 | * One for your whole runtime is fine. 82 | * Renderers will be maintained across sessions. 83 | * You'll need at least an Adapter per server... 84 | */ 85 | const captionServer = new Server(WebVTTAdapter); 86 | /** 87 | * ... or you may pass multiple adapters to a single server instance, 88 | * as many as formats you plan to support, custom renderers included 89 | */ 90 | const captionServer = new Server(WebVTTAdapter, MyCustomAdapter, ...); 91 | 92 | 93 | 94 | captionServer.addEventListener(Events.CUE_START, rendererElement.setCue); 95 | captionServer.addEventListener(Events.CUE_STOP, rendererElement.setCue); 96 | captionServer.addEventListener(Events.CUE_ERROR, (/** @type {Error} */ error) => { 97 | console.error(error); 98 | }); 99 | 100 | /** 101 | * Create the session. A new one for each video content. 102 | */ 103 | captionServer.createSession([ 104 | { 105 | lang: "ita", 106 | content: "WEBVTT ...", 107 | mimeType: `text/vtt`, 108 | active: true, 109 | }, 110 | ]); 111 | 112 | captionServer.start(() => { 113 | return videoElement.currentTime; 114 | }); 115 | 116 | videoElement.play(); 117 | 118 | videoElement.addEventListener("seeking", () => { 119 | /** 120 | * Seeking on native controls, might fire this event 121 | * a lot of times. We want to keep our subtitles updated 122 | * based on position if we are in pause. Otherwise 123 | * the server will automatically update them. 124 | */ 125 | 126 | if (videoElement.paused && !captionServer.isRunning) { 127 | captionServer.updateTime(videoElement.currentTime); 128 | } 129 | }); 130 | 131 | videoElement.addEventListener("pause", () => { 132 | /** 133 | * It might be useless to keep having an interval 134 | * running if our content is paused. We can safely 135 | * update the captions when seeking. 136 | */ 137 | 138 | captionServer.suspend(); 139 | }); 140 | 141 | videoElement.addEventListener("playing", () => { 142 | if (!captionServer.isRunning) { 143 | captionServer.resume(); 144 | } 145 | }); 146 | ``` 147 | 148 | ## Other 149 | 150 | This project was born by a personal need while working on the on-demand video player of a big Italian 🇮🇹 television broadcaster. It took over a year to become ready. 151 | 152 | Its name is a reference to an Italian television teletext service, called "televideo". It was common to hear, before the beginning of programs, a voice telling "subtitles available at 777 of televideo". From there, `sub37`. 153 | -------------------------------------------------------------------------------- /packages/captions-renderer/README.md: -------------------------------------------------------------------------------- 1 | # @sub37/captions-renderer 2 | 3 | This is the main element that handles captions rendering and allows styiling them with custom CSS properties (described below). 4 | All the areas in which subtitles get rendered are considered as regions to achieve a uniformed behavior. 5 | 6 | ## Importing 7 | 8 | The minimum requirement to make `@sub37/captions-renderer` usable is to import it as a side-effect. Importing it like this, will make `captions-renderer` to be registered onto window's custom elements registry. 9 | 10 | ```typescript 11 | import "@sub37/captions-renderer`; 12 | ``` 13 | 14 | It also exposes some properties that might require you to import them as classicly. For example: 15 | 16 | ```typescript 17 | import type { OrchestratorSettings } from "@sub37/captions-renderer"; 18 | import { CSSVAR_TEXT_COLOR, ... } from "@sub37/captions-renderer"; 19 | ``` 20 | 21 | ## Rendering behaviors 22 | 23 | Captions are known to have three types of behaviors when they get rendered. This is regardless of using `@sub27/captions-renderer`. 24 | 25 | - `Pop-on` 26 | - `Roll-up` 27 | - `Paint-on` 28 | 29 | Each of these is supported by `captions-renderer`, but it is not the only responsible for them to happen. Continue reading to understand how they can be obtained. 30 | 31 | ### Pop-on 32 | 33 | **Pop-on** mode is the most classic rendering mode: captions gets rendered and remain visible for the track's pre-established amount of time. Then they get removed to leave place to other cues. 34 | 35 | This behavior is automatically supported by `captions-renderer`. 36 | 37 | ### Roll-up 38 | 39 | **Roll-up** mode was popular in old television subtitles and it is deployed today in captions like Youtube's. 40 | It consists into painting the whole line and, once the available space ends, push the whole line up to leave space to another line. Once the available region space is reached (tipically 2 or 3 lines), older lines gets hidden. 41 | 42 | Cues following roll-up, could be splitted into different regions that proceed distinctly. This might be useful to represent some dialogs. 43 | 44 | `captions-renderer` supports this behavior by default. Cues are splitted by words to allow it to determine how many lines should be occupied by each cue. 45 | 46 | ### Paint-on 47 | 48 | **Paint-on** is one of the most interesting rendering mode, as it goes hand in hand with the previous two modes, especially **roll-up**. It consists into rendering each word with a different time from the previous or the next one. 49 | 50 | This might be useful to obtain a speaker-like effect. 51 | 52 | `captions-renderer` supports this by default, but to achieve the timing, tracks, adapters implementation and subtitle format **must support this**. 53 | 54 | For example, WebVTT standard supports [`timestamps`](https://www.w3.org/TR/webvtt1/#webvtt-cue-timestamp), which allow words to be splitted inside the tracks. Then, `@sub37/webvtt-adapter` supports parsing and normalization of these timestamps. 55 | 56 | --- 57 | 58 | ## Properties 59 | 60 | Some properties are available to customize the rendering experience. These properties should be considered as defaults replacements, but might be ignored if provided tracks have such properties. 61 | 62 | These properties can be set like follows: 63 | 64 | ```javascript 65 | const renderer = document.getElementsByTagName("captions-renderer")[0]; 66 | 67 | renderer.setRegionProperties({ ... }); 68 | ``` 69 | 70 | These properties are described into an exposed typescript interface: 71 | 72 | ```typescript 73 | import type { OrchestratorSettings } from "@sub37/captions-renderer"; 74 | ``` 75 | 76 | ### Supported Properties 77 | 78 | | Property name | Default | Description | 79 | | -------------------- | :-----: | -------------------------------------------------------------------------------------------------------------------------------------------------------- | 80 | | `lines` | `2` | The maximum amount of lines that should be rendered by this element before hiding the previous lines. Is overridden by tracks' regions `lines` property. | 81 | | `shiftDownFirstLine` | `false` | Allows obtaining a Youtube-like effect, where the first line is shifted down if no other lines are available to be showed. | 82 | 83 | ## Custom CSS variables 84 | 85 | ```typescript 86 | import { CSSVAR_TEXT_COLOR, ... } from "@sub37/captions-renderer"; 87 | ``` 88 | 89 | | CSS Variable Name | Javascript Constant Name | Default | Description | 90 | | :-------------------------- | :------------------------: | :---------------: | :----------------------------------------------------------------------------------------------------------------------------------------------- | 91 | | `--sub37-text-color` | `CSSVAR_TEXT_COLOR` | `#FFF` | Allows changing the color of text. This text overrides the color harcoded into provided tracks | 92 | | `--sub37-text-bg-color` | `CSSVAR_TEXT_BG_COLOR` | `rgba(0,0,0,0.7)` | This is the background color. Set to `transparent` to remove it. | 93 | | `--sub37-region-bg-color` | `CSSVAR_REGION_BG_COLOR` | `rgba(0,0,0,0.4)` | This is the background color of the regions. Set to `transparent` to remove it. | 94 | | `--sub37-bottom-spacing` | `CSSVAR_BOTTOM_SPACING` | `0px` | This is the amount of space that regions should leave from bottom. This might be useful to make the regions move, for example, to show controls. | 95 | | `--sub37-bottom-transition` | `CSSVAR_BOTTOM_TRANSITION` | `0s linear` | This is the `transition` proprieties that can be applied to regions when `--sub37-bottom-spacing` gets changed. Make it smoooooth, baby! | 96 | 97 | ## Testing 98 | 99 | This package uses the sub37 sample page (`packages/sample/pages/sub37-example`) to run the tests. 100 | Playwright automatically navigates starts the server and navigates there. 101 | 102 | Assuming that dependencies have been already installed, to run tests, run the following command: 103 | 104 | ```sh 105 | $ npm test 106 | ``` 107 | -------------------------------------------------------------------------------- /packages/sample/pages/sub37-example/script.mjs: -------------------------------------------------------------------------------- 1 | import "@sub37/captions-renderer"; 2 | import { Server } from "@sub37/server"; 3 | import { WebVTTAdapter } from "@sub37/webvtt-adapter"; 4 | import longTextTrackVTTPath from "../../src/longtexttrack.vtt"; 5 | import longTextTrackVTTPathChunk from "../../src/longtexttrack-chunk1.vtt"; 6 | import "../../src/components/customElements/scheduled-textarea"; 7 | import "../../src/components/customElements/fake-video"; 8 | 9 | /** 10 | * @typedef {import("../../src/components/customElements/fake-video").FakeHTMLVideoElement} FakeHTMLVideoElement 11 | * @typedef {import("../../src/components/customElements/scheduled-textarea").ScheduledTextArea} ScheduledTextArea 12 | * @typedef {import("@sub37/captions-renderer").CaptionsRenderer} CaptionsRenderer 13 | */ 14 | 15 | /** 16 | * @type {HTMLButtonElement} 17 | */ 18 | 19 | const defaultTrackLoadBtn = document.getElementById("load-default-track"); 20 | 21 | /** 22 | * @type {Server} 23 | */ 24 | 25 | const server = new Server(WebVTTAdapter); 26 | 27 | /** 28 | * Instance to let tests access to the server instance 29 | */ 30 | 31 | window.captionsServer = server; 32 | 33 | /** 34 | * @type {FakeHTMLVideoElement} 35 | */ 36 | 37 | const videoTag = document.getElementsByTagName("fake-video")[0]; 38 | videoTag.duration = 7646; 39 | 40 | /** 41 | * @type {ScheduledTextArea} 42 | */ 43 | 44 | const scheduledTextArea = document.getElementsByTagName("scheduled-textarea")?.[0]; 45 | 46 | /** 47 | * @type {CaptionsRenderer} 48 | */ 49 | 50 | const presenter = document.getElementById("presenter"); 51 | 52 | /** 53 | * @param {FakeHTMLVideoElement} videoElement 54 | */ 55 | 56 | function togglePlayback(videoElement) { 57 | if (videoElement.paused) { 58 | videoElement.play(); 59 | } else { 60 | videoElement.pause(); 61 | } 62 | } 63 | 64 | defaultTrackLoadBtn.addEventListener("click", async () => { 65 | // WEBVTT 66 | 67 | // REGION 68 | // id:fred 69 | // width:40% 70 | // lines:3 71 | // align:center 72 | // regionanchor:0%,100% 73 | // viewportanchor:10%,90% 74 | // scroll:up 75 | 76 | // REGION 77 | // id:bill 78 | // align:right 79 | // width:40% 80 | // lines:3 81 | // regionanchor:0%,100% 82 | // viewportanchor:10%,90% 83 | // scroll:up 84 | 85 | // 00:00:00.000 --> 00:10:00.000 region:fred align:left 86 | // Hello world. 87 | 88 | // 00:00:03.000 --> 00:10:00.000 region:bill align:right 89 | // Hello milady ;) 90 | 91 | // 00:00:04.000 --> 00:10:00.000 region:fred align:left 92 | // Hello world, bibi 93 | 94 | document.querySelector('input[name="caption-type"][id="webvtt"]').setAttribute("checked", true); 95 | defaultTrackLoadBtn.disabled = true; 96 | 97 | const [vttTrack, vttChunk] = await Promise.all([ 98 | fetch(longTextTrackVTTPath).then((e) => e.text()), 99 | fetch(longTextTrackVTTPathChunk).then((e) => e.text()), 100 | ]); 101 | 102 | setTimeout(() => { 103 | server.tracks[0].addChunk(vttChunk); 104 | }, 3000); 105 | 106 | scheduledTextArea.value = vttTrack; 107 | defaultTrackLoadBtn.disabled = false; 108 | }); 109 | 110 | document.addEventListener("keydown", ({ code }) => { 111 | switch (code) { 112 | case "ArrowLeft": { 113 | videoTag.currentTime = videoTag.currentTime - 10; 114 | break; 115 | } 116 | case "ArrowRight": { 117 | videoTag.currentTime = videoTag.currentTime + 10; 118 | break; 119 | } 120 | case "Space": { 121 | togglePlayback(videoTag); 122 | break; 123 | } 124 | } 125 | }); 126 | 127 | videoTag.addEventListener("seeked", () => { 128 | if (videoTag.paused) { 129 | server.updateTime(videoTag.currentTime * 1000); 130 | } 131 | }); 132 | 133 | videoTag.addEventListener("playing", () => { 134 | if (server.isRunning) { 135 | server.resume(); 136 | return; 137 | } 138 | 139 | server.start(() => { 140 | return parseFloat(videoTag.currentTime) * 1000; 141 | }); 142 | }); 143 | 144 | videoTag.addEventListener("pause", () => { 145 | if (server.isRunning) { 146 | server.suspend(); 147 | } 148 | }); 149 | 150 | scheduledTextArea.addEventListener("commit", async ({ detail: vttTrack }) => { 151 | const contentMimeType = document.forms["content-type"].elements["caption-type"].value; 152 | 153 | const timeStart = performance.now(); 154 | 155 | try { 156 | /** 157 | * Just a trick to not let the browser complaining 158 | * about the commit timeout taking too long to complete 159 | * and defer the parsing. 160 | * (the default track should take like 160ms to parse) 161 | */ 162 | 163 | await Promise.resolve(); 164 | 165 | server.createSession( 166 | [ 167 | { 168 | lang: "any", 169 | content: vttTrack, 170 | mimeType: "text/vtt", 171 | active: true, 172 | }, 173 | ], 174 | contentMimeType, 175 | ); 176 | console.info( 177 | `%c[DEBUG] Track parsing took: ${performance.now() - timeStart}ms`, 178 | "background-color: #af0000; color: #FFF; padding: 5px; margin: 5px", 179 | ); 180 | } catch (err) { 181 | console.error(err); 182 | } 183 | 184 | videoTag.play(); 185 | videoTag.currentTime = 0; 186 | }); 187 | 188 | // server.createSession( 189 | // [ 190 | // { 191 | // lang: "it", 192 | // content: ` 193 | // WEBVTT 194 | 195 | // 00:00:00.000 --> 00:00:02.000 region:fred align:left 196 | // Hi, my name is Fred 197 | 198 | // 00:00:02.500 --> 00:00:04.500 region:bill align:right 199 | // Hi, I’m Bill 200 | 201 | // 00:00:05.000 --> 00:00:06.000 region:fred align:left 202 | // Would you like to get a coffee? 203 | 204 | // 00:00:07.500 --> 00:00:09.500 region:bill align:right 205 | // Sure! I’ve only had one today. 206 | 207 | // 00:00:10.000 --> 00:00:11.000 region:fred align:left 208 | // This is my fourth! 209 | 210 | // 00:00:12.500 --> 00:00:13.500 region:fred align:left 211 | // OK, let’s go. 212 | // `, 213 | // }, 214 | // ], 215 | // "text/vtt", 216 | // ); 217 | 218 | server.addEventListener("cueerror", (error) => { 219 | console.warn(error); 220 | }); 221 | 222 | server.addEventListener("cuestart", (cues) => { 223 | const timeStart = performance.now(); 224 | // console.log("CUE START:", cues); 225 | presenter.setCue(cues); 226 | console.info( 227 | `%c[DEBUG] Cue rendering took: ${performance.now() - timeStart}ms`, 228 | "background-color: #7900ff; color: #FFF; padding: 5px; margin: 5px", 229 | cues, 230 | ); 231 | }); 232 | 233 | server.addEventListener("cuestop", () => { 234 | console.log("CUES STOP"); 235 | presenter.setCue(); 236 | }); 237 | 238 | /** 239 | * @type {CaptionsRenderer} 240 | */ 241 | 242 | const rendererElement = document.getElementsByTagName("captions-renderer")[0]; 243 | rendererElement.setRegionProperties({ 244 | shiftDownFirstLine: false, 245 | roundRegionHeightToNearest: true, 246 | }); 247 | -------------------------------------------------------------------------------- /packages/captions-renderer/src/index.ts: -------------------------------------------------------------------------------- 1 | import type { CueNode, RenderingModifiers } from "@sub37/server"; 2 | import { 3 | CSSVAR_BOTTOM_SPACING, 4 | CSSVAR_BOTTOM_TRANSITION, 5 | CSSVAR_REGION_AREA_BG_COLOR, 6 | CSSVAR_REGION_BG_COLOR, 7 | CSSVAR_TEXT_BG_COLOR, 8 | CSSVAR_TEXT_COLOR, 9 | } from "./constants.js"; 10 | import TreeOrchestrator, { OrchestratorSettings } from "./TreeOrchestrator.js"; 11 | 12 | export * from "./constants.js"; 13 | export type { OrchestratorSettings }; 14 | 15 | class Renderer extends HTMLElement { 16 | private container = Object.assign(document.createElement("main"), { 17 | id: "caption-window", 18 | className: "hidden", 19 | }); 20 | 21 | /** 22 | * Properties to be applied to all regions. 23 | * Some properties might get overridden by regions 24 | * or rendering modifiers. 25 | */ 26 | 27 | private regionsProperties: Partial = {}; 28 | 29 | /** 30 | * Active regions are needed to have a state in case 31 | * of animations. For example, in case of Youtube simulation 32 | * translateY might go to 0ems, but we were at 1.5em. 33 | * 34 | * Creating the element again will reset the translation to 35 | * 0 and will cause no animation. 36 | */ 37 | 38 | private activeRegions: { [region: string]: TreeOrchestrator } = {}; 39 | 40 | public constructor() { 41 | super(); 42 | 43 | const shadowRoot = this.attachShadow({ mode: "open" }); 44 | const style = document.createElement("style"); 45 | 46 | style.id = "host-styles"; 47 | style.textContent = ` 48 | :host { 49 | /** 50 | * This component is meant to be set inside a container 51 | * along with video tag sibling 52 | */ 53 | 54 | width: 100%; 55 | height: 100%; 56 | } 57 | 58 | main#caption-window { 59 | position: relative; 60 | width: 100%; 61 | /** 62 | * Positive calculations because people might want 63 | * to pull up the rendering area and not push it down 64 | */ 65 | height: calc(100% + var(${CSSVAR_BOTTOM_SPACING}, 0px)); 66 | transition: height var(${CSSVAR_BOTTOM_TRANSITION}, 0s linear); 67 | overflow: hidden; 68 | } 69 | 70 | main#caption-window.hidden { 71 | display: none; 72 | } 73 | 74 | main#caption-window.active { 75 | display: block; 76 | } 77 | 78 | div.region { 79 | position: absolute; 80 | overflow-y: hidden; 81 | min-height: 1.5em; 82 | color: var(${CSSVAR_TEXT_COLOR}, #FFF); 83 | background-color: var(${CSSVAR_REGION_AREA_BG_COLOR}, transparent); 84 | } 85 | 86 | div.region > div { 87 | background-color: var(${CSSVAR_REGION_BG_COLOR}, rgba(0,0,0,0.4)); 88 | scroll-behavior: smooth; 89 | } 90 | 91 | div.region div > p { 92 | margin: 0; 93 | box-sizing: border-box; 94 | } 95 | 96 | div.region div > p > span { 97 | color: var(${CSSVAR_TEXT_COLOR}, #FFF); 98 | background-color: var(${CSSVAR_TEXT_BG_COLOR}, rgba(0,0,0,0.7)); 99 | padding: 0px 15px; 100 | line-height: 1.5em; 101 | word-wrap: break-word; 102 | /** 103 | * Change this to display:block for pop-on captions 104 | * and whole background 105 | */ 106 | display: inline-block; 107 | } 108 | `; 109 | 110 | shadowRoot.appendChild(style); 111 | shadowRoot.appendChild(this.container); 112 | } 113 | 114 | /** 115 | * Allows setting some properties that regions should use when rendered. 116 | * Not every property might get used: tt stands to each own property to 117 | * handle the priority over some defaults (e.g. track regions' properties 118 | * might have an higher priority). 119 | * 120 | * @param props 121 | */ 122 | 123 | public setRegionProperties(props: Partial): void { 124 | this.regionsProperties = props; 125 | } 126 | 127 | /** 128 | * Sets the cues to be rendered. Pass and empty array or nothing to 129 | * removed all the cues and regions. 130 | * 131 | * @param cueData 132 | * @returns 133 | */ 134 | 135 | public setCue(cueData?: CueNode[]): void { 136 | this.wipeContainer(); 137 | 138 | if (!cueData?.length) { 139 | this.container.classList.remove("active"); 140 | this.container.classList.add("hidden"); 141 | this.activeRegions = {}; 142 | 143 | return; 144 | } 145 | 146 | /** 147 | * Classes must be toggled before rendering, 148 | * otherwise height won't be calculated. 149 | */ 150 | 151 | this.container.classList.add("active"); 152 | this.container.classList.remove("hidden"); 153 | 154 | const cueGroupsByRegion: { [key: string]: CueNode[] } = {}; 155 | const nextActiveRegions: Renderer["activeRegions"] = {}; 156 | 157 | for (let i = 0; i < cueData.length; i++) { 158 | const cue = cueData[i]; 159 | const prevCue = cueData[i - 1]; 160 | 161 | const modifierId = 162 | getRegionModifierId(cue.renderingModifiers, prevCue?.renderingModifiers) || i; 163 | 164 | const regionIdentifier = cue.region?.id || "default"; 165 | const region = `${regionIdentifier}-${modifierId}`; 166 | 167 | if (!cueGroupsByRegion[region]) { 168 | cueGroupsByRegion[region] = []; 169 | } 170 | 171 | cueGroupsByRegion[region].push(cue); 172 | } 173 | 174 | for (const [regionId, cues] of Object.entries(cueGroupsByRegion)) { 175 | let tree: TreeOrchestrator; 176 | 177 | if (this.activeRegions[regionId]) { 178 | tree = this.activeRegions[regionId]; 179 | } else { 180 | tree = new TreeOrchestrator( 181 | this.container, 182 | cues[0].region, 183 | cues[0].renderingModifiers, 184 | this.regionsProperties, 185 | ); 186 | } 187 | 188 | /** 189 | * Appending is required to happen before wiping 190 | * so that re-used tree containers will render 191 | * correctly and won't show previous elements. 192 | */ 193 | 194 | this.appendTree(tree); 195 | tree.renderCuesToHTML(cues); 196 | 197 | nextActiveRegions[regionId] = tree; 198 | } 199 | 200 | this.activeRegions = nextActiveRegions; 201 | } 202 | 203 | private wipeContainer(): void { 204 | for (const tree of Object.values(this.activeRegions)) { 205 | tree.wipeTree(); 206 | tree.remove(); 207 | } 208 | } 209 | 210 | private appendTree(tree: TreeOrchestrator): void { 211 | this.container.appendChild(tree.root); 212 | } 213 | } 214 | 215 | /** 216 | * A region is created by looking at the region object itself 217 | * and by looking at the RenderingModifier's id 218 | * 219 | * @param r1 220 | * @param r2 221 | * @param cueIndex 222 | * @returns 223 | */ 224 | 225 | function getRegionModifierId(r1: RenderingModifiers, r2: RenderingModifiers): number { 226 | if (!r1 && !r2) { 227 | return undefined; 228 | } 229 | 230 | if (!r1 && r2) { 231 | return r2.id; 232 | } 233 | 234 | if (r1 && !r2) { 235 | return r1.id; 236 | } 237 | 238 | return r1.id; 239 | } 240 | 241 | customElements.define("captions-renderer", Renderer); 242 | export type CaptionsRenderer = typeof Renderer; 243 | -------------------------------------------------------------------------------- /packages/webvtt-adapter/src/Parser/parseStyle.ts: -------------------------------------------------------------------------------- 1 | import type { Entities } from "@sub37/server"; 2 | import { EntitiesTokenMap } from "./Tags/tokenEntities.js"; 3 | import { MalformedStyleBlockError } from "../MalformedStyleBlockError.js"; 4 | import { InvalidStyleDeclarationError } from "../InvalidStyleDeclarationError.js"; 5 | 6 | const CSS_RULESET_REGEX = /::cue(?:\(([^.]*?)(?:\.(.+))*\))?\s*\{\s*([\s\S]+)\}/; 7 | 8 | /** 9 | * Matching of `lang[voice="Esme"]` both 'lang' or 'voice' + 'Esme' 10 | */ 11 | 12 | const CSS_SELECTOR_ATTRIBUTES_REGEX = /([^\[\]]+)|\[(.+?)(?:="(.+?)?")?\]/g; 13 | 14 | const CODEPOINT_ESCAPE_REPLACE_REGEX = /\\3(\d)\s+(\d+)/; 15 | 16 | export const enum StyleDomain { 17 | GLOBAL, 18 | ID, 19 | TAG, 20 | } 21 | 22 | type SelectorTarget = 23 | | { type: StyleDomain.GLOBAL } 24 | | { type: StyleDomain.ID; selector: string } 25 | | { 26 | type: StyleDomain.TAG; 27 | tagName: Entities.TagType; 28 | classes: string[]; 29 | attributes: Map; 30 | }; 31 | 32 | export type Style = SelectorTarget & { 33 | styleString: string; 34 | }; 35 | 36 | /** 37 | * @see https://www.w3.org/TR/webvtt1/#the-cue-pseudo-element 38 | */ 39 | 40 | const WEBVTT_CSS_SUPPORTED_PROPERTIES = [ 41 | "color", 42 | "opacity", 43 | "visibility", 44 | "text-shadow", 45 | "white-space", 46 | "text-combine-upright", 47 | "ruby-position", 48 | 49 | "text-decoration", 50 | "text-decoration-color", 51 | "text-decoration-line", 52 | "text-decoration-style", 53 | "text-decoration-thickness", 54 | 55 | "background", 56 | "background-color", 57 | "background-image", 58 | 59 | "outline", 60 | "outline-color", 61 | "outline-style", 62 | "outline-width", 63 | 64 | "font-family", 65 | "font-size", 66 | "font-stretch", 67 | "font-style", 68 | "font-variant", 69 | "font-weight", 70 | /** 71 | * Line-height have been excluded because it might cause issues with 72 | * the renderer as it has its own line-height property 73 | */ 74 | // "line-height", 75 | ]; 76 | 77 | export function parseStyle(rawStyleData: string): Style | undefined { 78 | if (!rawStyleData) { 79 | throw new MalformedStyleBlockError(); 80 | } 81 | 82 | const styleBlockComponents = rawStyleData.match(CSS_RULESET_REGEX); 83 | 84 | if (!styleBlockComponents) { 85 | throw new InvalidStyleDeclarationError(); 86 | } 87 | 88 | const [, selector, classesChain = "", cssData] = styleBlockComponents; 89 | 90 | const normalizedCssData = normalizeCssString(cssData.trim()); 91 | 92 | if (!normalizedCssData.length) { 93 | return undefined; 94 | } 95 | 96 | const styleString = filterUnsupportedStandardProperties(normalizedCssData); 97 | 98 | if (!styleString.length) { 99 | return undefined; 100 | } 101 | 102 | const parsedSelector = getParsedSelector(selector, classesChain); 103 | 104 | if (!parsedSelector) { 105 | return undefined; 106 | } 107 | 108 | return { 109 | ...parsedSelector, 110 | styleString, 111 | }; 112 | } 113 | 114 | function getParsedSelector(selector: string, classesChain: string): SelectorTarget | undefined { 115 | if (!selector && !classesChain.length) { 116 | return { 117 | type: StyleDomain.GLOBAL, 118 | }; 119 | } 120 | 121 | if (selector.startsWith("#")) { 122 | return { 123 | type: StyleDomain.ID, 124 | selector: stripEscapedCodePoint(selector.slice(1)).replace("\\", ""), 125 | }; 126 | } 127 | 128 | const selectorComponents = getSelectorComponents(selector); 129 | 130 | if (!selectorComponents.tagName && !selectorComponents.attributes.size && !classesChain.length) { 131 | /** Invalid */ 132 | return undefined; 133 | } 134 | 135 | return { 136 | type: StyleDomain.TAG, 137 | classes: (classesChain.length && classesChain.split(".")) || [], 138 | ...selectorComponents, 139 | }; 140 | } 141 | 142 | function getSelectorComponents( 143 | rawSelector: string, 144 | ): Omit { 145 | let selector: string = undefined; 146 | const attributes: [string, string][] = []; 147 | 148 | /** 149 | * This is too recent and will likely require a polyfill from 150 | * users. But it fulfill a requirement when matching things with regex. 151 | */ 152 | 153 | const matchIterator = rawSelector.matchAll(CSS_SELECTOR_ATTRIBUTES_REGEX); 154 | 155 | for (const [, tag = selector, attribute, value] of matchIterator) { 156 | selector = tag; 157 | 158 | if (attribute) { 159 | attributes.push([attribute, value || "*"]); 160 | } 161 | } 162 | 163 | return { 164 | tagName: EntitiesTokenMap[selector], 165 | attributes: new Map(attributes), 166 | }; 167 | } 168 | 169 | function normalizeCssString(cssData: string = "") { 170 | return cssData 171 | .replace(/\/\*.+\*\//, "") /** CSS inline Comments */ 172 | .replace(/\s+/g, "\x20") /** Multiple whitespaces */ 173 | .trim(); 174 | } 175 | 176 | /** 177 | * Recomposes the style string by filtering out the unsupported 178 | * WebVTT properties 179 | * 180 | * @param styleString 181 | * @returns 182 | */ 183 | 184 | function filterUnsupportedStandardProperties(styleString: string) { 185 | let finalStyleString = ""; 186 | let startCursor = 0; 187 | let endCursor = 0; 188 | 189 | while (endCursor <= styleString.length) { 190 | if (styleString[endCursor] === ";" || endCursor === styleString.length) { 191 | const parsed = styleString.slice(startCursor, endCursor + 1).split(/\s*:\s*/); 192 | const property = parsed[0].trim(); 193 | const value = parsed[1].trim(); 194 | 195 | if (property.length && value.length && WEBVTT_CSS_SUPPORTED_PROPERTIES.includes(property)) { 196 | finalStyleString += `${property}:${value}`; 197 | } 198 | 199 | /** Clearning up an restarting */ 200 | startCursor = endCursor + 1; 201 | endCursor++; 202 | } 203 | 204 | endCursor++; 205 | } 206 | 207 | return finalStyleString; 208 | } 209 | 210 | /** 211 | * CSS specs require selectors to start with a character in 212 | * ['ASCII upper alpha' - 'ASCII lower alpha'] code points range or an 213 | * escaped character. 214 | * 215 | * If we have a CSS ID which is '123', it is written like "\31 23", 216 | * where the space is needed to represent the entity and to not select 217 | * a different character (\3123 would be a different character). 218 | * 219 | * Since ASCII Digits starts go from U+0030 (0) to U+0039 (9), we can 220 | * safely strip it. 221 | * 222 | * Right now we are supporting only numbers but other character might 223 | * get supported in the future. 224 | * 225 | * 226 | * @param string 227 | * @see https://w3c.github.io/webvtt/#introduction-other-features 228 | * @see https://infra.spec.whatwg.org/#code-points 229 | * @see https://www.w3.org/International/questions/qa-escapes#css_identifiers 230 | * @see https://github.com/chromium/chromium/blob/924ec189cdfd33c8cee15d918f927afcb88d06db/third_party/blink/renderer/core/css/parser/css_parser_idioms.cc#L24-L52 231 | */ 232 | 233 | function stripEscapedCodePoint(string: string) { 234 | return string.replace(CODEPOINT_ESCAPE_REPLACE_REGEX, "$1$2"); 235 | } 236 | -------------------------------------------------------------------------------- /jest.config.mjs: -------------------------------------------------------------------------------- 1 | /* 2 | * For a detailed explanation regarding each configuration property, visit: 3 | * https://jestjs.io/docs/configuration 4 | */ 5 | 6 | export default { 7 | // Indicates which provider should be used to instrument code for coverage 8 | // coverageProvider: "v8", 9 | // An array of file extensions your modules use 10 | moduleFileExtensions: ["js", "mjs"], 11 | // The test environment that will be used for testing 12 | testEnvironment: "jsdom", 13 | // The glob patterns Jest uses to detect test files 14 | testMatch: ["**/specs/**/*.spec.mjs"], 15 | 16 | // A map from regular expressions to paths to transformers 17 | // transform: {}, 18 | 19 | // A map from regular expressions to module names or to arrays of module names that allow to stub out resources with a single module 20 | // moduleNameMapper: {}, 21 | 22 | // All imported modules in your tests should be mocked automatically 23 | // automock: false, 24 | 25 | // Stop running tests after `n` failures 26 | // bail: 0, 27 | 28 | // The directory where Jest should store its cached dependency information 29 | // cacheDirectory: "/private/var/folders/6v/sjx0fx392614828_p1163n600000gn/T/jest_dx", 30 | 31 | // Automatically clear mock calls, instances and results before every test 32 | // clearMocks: false, 33 | 34 | // Indicates whether the coverage information should be collected while executing the test 35 | // collectCoverage: false, 36 | 37 | // An array of glob patterns indicating a set of files for which coverage information should be collected 38 | // collectCoverageFrom: undefined, 39 | 40 | // The directory where Jest should output its coverage files 41 | // coverageDirectory: undefined, 42 | 43 | // An array of regexp pattern strings used to skip coverage collection 44 | // coveragePathIgnorePatterns: [ 45 | // "/node_modules/" 46 | // ], 47 | 48 | // A list of reporter names that Jest uses when writing coverage reports 49 | // coverageReporters: [ 50 | // "json", 51 | // "text", 52 | // "lcov", 53 | // "clover" 54 | // ], 55 | 56 | // An object that configures minimum threshold enforcement for coverage results 57 | // coverageThreshold: undefined, 58 | 59 | // A path to a custom dependency extractor 60 | // dependencyExtractor: undefined, 61 | 62 | // Make calling deprecated APIs throw helpful error messages 63 | // errorOnDeprecated: false, 64 | 65 | // Force coverage collection from ignored files using an array of glob patterns 66 | // forceCoverageMatch: [], 67 | 68 | // A path to a module which exports an async function that is triggered once before all test suites 69 | // globalSetup: undefined, 70 | 71 | // A path to a module which exports an async function that is triggered once after all test suites 72 | // globalTeardown: undefined, 73 | 74 | // A set of global variables that need to be available in all test environments 75 | // globals: {}, 76 | 77 | // The maximum amount of workers used to run your tests. Can be specified as % or a number. E.g. maxWorkers: 10% will use 10% of your CPU amount + 1 as the maximum worker number. maxWorkers: 2 will use a maximum of 2 workers. 78 | // maxWorkers: "50%", 79 | 80 | // An array of directory names to be searched recursively up from the requiring module's location 81 | // moduleDirectories: [ 82 | // "node_modules" 83 | // ], 84 | 85 | // An array of regexp pattern strings, matched against all module paths before considered 'visible' to the module loader 86 | // modulePathIgnorePatterns: [], 87 | 88 | // Activates notifications for test results 89 | // notify: false, 90 | 91 | // An enum that specifies notification mode. Requires { notify: true } 92 | // notifyMode: "failure-change", 93 | 94 | // A preset that is used as a base for Jest's configuration 95 | // preset: undefined, 96 | 97 | // Run tests from one or more projects 98 | // projects: undefined, 99 | 100 | // Use this configuration option to add custom reporters to Jest 101 | // reporters: undefined, 102 | 103 | // Automatically reset mock state before every test 104 | // resetMocks: false, 105 | 106 | // Reset the module registry before running each individual test 107 | // resetModules: false, 108 | 109 | // A path to a custom resolver 110 | // resolver: undefined, 111 | 112 | // Automatically restore mock state and implementation before every test 113 | // restoreMocks: false, 114 | 115 | // The root directory that Jest should scan for tests and modules within 116 | // rootDir: "specs", 117 | 118 | // A list of paths to directories that Jest should use to search for files in 119 | // roots: [ 120 | // "" 121 | // ], 122 | 123 | // Allows you to use a custom runner instead of Jest's default test runner 124 | // runner: "jest-runner", 125 | 126 | // The paths to modules that run some code to configure or set up the testing environment before each test 127 | // setupFiles: [], 128 | 129 | // A list of paths to modules that run some code to configure or set up the testing framework before each test 130 | // setupFilesAfterEnv: [], 131 | 132 | // The number of seconds after which a test is considered as slow and reported as such in the results. 133 | // slowTestThreshold: 5, 134 | 135 | // A list of paths to snapshot serializer modules Jest should use for snapshot testing 136 | // snapshotSerializers: [], 137 | 138 | // Options that will be passed to the testEnvironment 139 | // testEnvironmentOptions: {}, 140 | 141 | // Adds a location field to test results 142 | // testLocationInResults: false, 143 | 144 | // An array of regexp pattern strings that are matched against all test paths, matched tests are skipped 145 | // testPathIgnorePatterns: [ 146 | // "/node_modules/" 147 | // ], 148 | 149 | // The regexp pattern or array of patterns that Jest uses to detect test files 150 | // testRegex: [], 151 | 152 | // This option allows the use of a custom results processor 153 | // testResultsProcessor: undefined, 154 | 155 | // This option allows use of a custom test runner 156 | // testRunner: "jest-circus/runner", 157 | 158 | // This option sets the URL for the jsdom environment. It is reflected in properties such as location.href 159 | // testURL: "http://localhost", 160 | 161 | // Setting this value to "fake" allows the use of fake timers for functions such as "setTimeout" 162 | // timers: "real", 163 | 164 | // An array of regexp pattern strings that are matched against all source file paths, matched files will skip transformation 165 | // transformIgnorePatterns: ["\\.pnp\\.[^\\/]+$"], 166 | 167 | // An array of regexp pattern strings that are matched against all modules before the module loader will automatically return a mock for them 168 | // unmockedModulePathPatterns: undefined, 169 | 170 | // Indicates whether each individual test should be reported during the run 171 | // verbose: false, 172 | 173 | // An array of regexp patterns that are matched against all source file paths before re-running tests in watch mode 174 | // watchPathIgnorePatterns: [], 175 | 176 | // Whether to use watchman for file crawling 177 | // watchman: true, 178 | }; 179 | -------------------------------------------------------------------------------- /assets/wiki/Entities.svg: -------------------------------------------------------------------------------- 1 | 00:00:12.500 --> 00:00:32.500 region:fred<v Fred>OK, <b>let’s <i>go</i></b>.00:00:10.000 --> 00:00:30.000 region:fred<v Fred>This is my fourth!00:00:12.500 --> 00:00:32.500 region:fred<v Fred>OK, <b>let’s <i>go</i></b>.00:00:10.000 --> 00:00:30.000 region:fred<v Fred>This is my fourth!This is my fourth!OK,let’sgo. -------------------------------------------------------------------------------- /packages/captions-renderer/specs/renderer.spec.pw.ts: -------------------------------------------------------------------------------- 1 | import { expect } from "@playwright/test"; 2 | import { RendererFixture as test } from "./RendererFixture.js"; 3 | import type { Server } from "@sub37/server"; 4 | import type { CaptionsRenderer } from "../lib/index.js"; 5 | import type { Region } from "@sub37/server"; 6 | import type { CueNode } from "@sub37/server"; 7 | 8 | declare global { 9 | /** 10 | * Window is the interface for each browser 11 | * in this case 12 | */ 13 | interface Window { 14 | captionsServer: Server; 15 | } 16 | } 17 | 18 | test("Renderer should render two regions if the tracks owns two regions", async ({ 19 | page, 20 | waitForEvent, 21 | pauseServing, 22 | seekToSecond, 23 | }) => { 24 | const TEST_WEBVTT_TRACK = ` 25 | WEBVTT 26 | 27 | REGION 28 | id:fred 29 | width:40% 30 | lines:3 31 | regionanchor:0%,100% 32 | viewportanchor:10%,90% 33 | scroll:up 34 | 35 | REGION 36 | id:bill 37 | width:40% 38 | lines:3 39 | regionanchor:100%,100% 40 | viewportanchor:90%,90% 41 | scroll:up 42 | 43 | 00:00:00.000 --> 00:00:20.000 region:fred align:left 44 | Hi, my name is Fred 45 | 46 | 00:00:02.500 --> 00:00:22.500 region:bill align:right 47 | Hi, I’m Bill 48 | `; 49 | 50 | await Promise.all([ 51 | waitForEvent("playing"), 52 | page.getByRole("textbox", { name: "WEBVTT..." }).fill(TEST_WEBVTT_TRACK), 53 | ]); 54 | 55 | await pauseServing(); 56 | await seekToSecond(3); 57 | 58 | expect((await page.$$("captions-renderer > main > div")).length).toBe(2); 59 | }); 60 | 61 | test("Renderer should render two regions, one of them is the default one", async ({ 62 | page, 63 | waitForEvent, 64 | seekToSecond, 65 | pauseServing, 66 | }) => { 67 | const TEST_WEBVTT_TRACK = ` 68 | WEBVTT 69 | 70 | REGION 71 | id:fred 72 | width:40% 73 | lines:3 74 | regionanchor:0%,100% 75 | viewportanchor:10%,90% 76 | scroll:up 77 | 78 | 00:00:00.000 --> 00:00:20.000 region:fred align:left 79 | Hi, my name is Fred 80 | 81 | 00:00:02.500 --> 00:00:22.500 align:right 82 | Hi, I’m Bill 83 | `; 84 | 85 | await Promise.all([ 86 | waitForEvent("playing"), 87 | page.getByRole("textbox", { name: "WEBVTT..." }).fill(TEST_WEBVTT_TRACK), 88 | ]); 89 | 90 | await pauseServing(); 91 | await seekToSecond(3); 92 | 93 | expect((await page.$$("captions-renderer > main > div")).length).toBe(2); 94 | }); 95 | 96 | test("Renderer should render 'Fred' region with a red background color and a 'Bill' region with a blue background color", async ({ 97 | page, 98 | waitForEvent, 99 | seekToSecond, 100 | pauseServing, 101 | }) => { 102 | /** 103 | * @typedef {import("../../sample/src/customElements/fake-video")} FakeHTMLVideoElement 104 | */ 105 | 106 | const TEST_WEBVTT_TRACK = ` 107 | WEBVTT 108 | 109 | REGION 110 | id:fred 111 | width:40% 112 | lines:3 113 | regionanchor:0%,100% 114 | viewportanchor:10%,90% 115 | scroll:up 116 | 117 | STYLE 118 | ::cue(v[voice="Fred"]) { 119 | background-color: red; 120 | } 121 | 122 | STYLE 123 | ::cue([voice="Bill"]) { 124 | background-color: blue; 125 | } 126 | 127 | 00:00:00.000 --> 00:00:20.000 region:fred 128 | Hi, my name is Fred 129 | 130 | 00:00:02.500 --> 00:00:22.500 131 | Hi, I’m Bill 132 | `; 133 | 134 | await Promise.all([ 135 | waitForEvent("playing"), 136 | page.getByRole("textbox", { name: "WEBVTT..." }).fill(TEST_WEBVTT_TRACK), 137 | ]); 138 | 139 | await pauseServing(); 140 | await seekToSecond(3); 141 | 142 | const regionsLocator = page.locator("captions-renderer > main > .region"); 143 | 144 | const [bgColor1, bgColor2] = await Promise.all([ 145 | regionsLocator 146 | .locator('span[voice="Fred"]') 147 | .evaluate((element) => element.style.backgroundColor), 148 | regionsLocator 149 | .locator('span[voice="Bill"]') 150 | .evaluate((element) => element.style.backgroundColor), 151 | ]); 152 | 153 | expect(bgColor1).toBe("red"); 154 | expect(bgColor2).toBe("blue"); 155 | }); 156 | 157 | test("Renderer with a region of 3.2em height should be rounded to 4.5 to fit the whole next line if the line height is 1.5em and roundRegionHeightLineFit is set", async ({ 158 | page, 159 | waitForEvent, 160 | seekToSecond, 161 | pauseServing, 162 | }) => { 163 | const TEST_WEBVTT_TRACK = ` 164 | WEBVTT 165 | 166 | REGION 167 | id:fred 168 | width:40% 169 | lines:3 170 | regionanchor:0%,100% 171 | viewportanchor:10%,90% 172 | scroll:up 173 | 174 | 00:00:00.000 --> 00:00:20.000 region:fred align:left 175 | Hi, my name is Fred 176 | 177 | 00:00:02.500 --> 00:00:22.500 align:right 178 | Hi, I’m Bill 179 | 180 | 00:00:03.000 --> 00:00:25.000 region:fred align:left 181 | Would 182 | <00:00:05.250>you 183 | <00:00:05.500>like 184 | <00:00:05.750>to 185 | <00:00:06.000>get 186 | <00:00:06.250>a 187 | <00:00:06.500>coffee? 188 | 189 | 00:00:07.500 --> 00:00:27.500 align:right 190 | Sure! I’ve only had one today. 191 | 192 | 00:00:10.000 --> 00:00:30.000 region:fred align:left 193 | This is my fourth! 194 | 195 | 00:00:12.500 --> 00:00:32.500 region:fred align:left 196 | OK, let’s go. 197 | `; 198 | 199 | /** 200 | * Injecting a listener to rewrite the first 201 | * and injecting renderer properties 202 | */ 203 | 204 | await page.evaluate(() => { 205 | function isRendererElement(element: Element | null): element is InstanceType { 206 | return typeof (element as InstanceType)?.setRegionProperties === "function"; 207 | } 208 | 209 | const rendererElement = document.querySelector("captions-renderer"); 210 | 211 | if (!isRendererElement(rendererElement)) { 212 | throw new Error("No renderer element found."); 213 | } 214 | 215 | rendererElement.setRegionProperties({ 216 | roundRegionHeightLineFit: true, 217 | }); 218 | 219 | const regionInstance = new (class implements Region { 220 | public height = 3.2; 221 | public width: number = 100; 222 | public lines: number = 3; 223 | public scroll?: "up" | "none" = "none"; 224 | public id = "testRegionCustom"; 225 | 226 | getOrigin(): [x: string, y: string] { 227 | return ["0%", "0%"]; 228 | } 229 | })(); 230 | 231 | window.captionsServer.addEventListener("cuestart", (cues: CueNode[]) => { 232 | for (const cue of cues) { 233 | if (cue.region?.id === "fred") { 234 | cue.region = regionInstance; 235 | } 236 | } 237 | }); 238 | }); 239 | 240 | await Promise.all([ 241 | waitForEvent("playing"), 242 | page.getByRole("textbox", { name: "WEBVTT..." }).fill(TEST_WEBVTT_TRACK), 243 | ]); 244 | 245 | await pauseServing(); 246 | await seekToSecond(10); 247 | 248 | let fredRegionHeight = await page 249 | .locator("captions-renderer > main > .region:first-child") 250 | .evaluate((element) => element.style.height); 251 | 252 | expect(fredRegionHeight).toBe("4.5em"); 253 | 254 | /** 255 | * Now disabling the setting and seek to rerender the cues. 256 | */ 257 | 258 | await page.evaluate(() => { 259 | function isRendererElement(element: Element | null): element is InstanceType { 260 | return typeof (element as InstanceType)?.setRegionProperties === "function"; 261 | } 262 | 263 | const rendererElement = document.querySelector("captions-renderer"); 264 | 265 | if (!isRendererElement(rendererElement)) { 266 | throw new Error("No renderer element found."); 267 | } 268 | 269 | rendererElement.setRegionProperties({ 270 | roundRegionHeightLineFit: false, 271 | }); 272 | }); 273 | 274 | await seekToSecond(9.5); 275 | await seekToSecond(10); 276 | 277 | fredRegionHeight = await page 278 | .locator("captions-renderer > main > .region:first-child") 279 | .evaluate((element) => element.style.height); 280 | 281 | expect(fredRegionHeight).toBe("3.2em"); 282 | }); 283 | -------------------------------------------------------------------------------- /packages/server/specs/DistributionSession.spec.mjs: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | import { describe, it, expect, beforeEach, jest } from "@jest/globals"; 3 | import { DistributionSession } from "../lib/DistributionSession.js"; 4 | import { IntervalBinaryTree } from "../lib/IntervalBinaryTree.js"; 5 | import { BaseAdapter, ParseResult } from "../lib/BaseAdapter"; 6 | import { CueNode } from "../lib/CueNode.js"; 7 | import { 8 | UnexpectedParsingOutputFormatError, 9 | UncaughtParsingExceptionError, 10 | } from "../lib/Errors/index.js"; 11 | 12 | class MockedAdapter extends BaseAdapter { 13 | static toString() { 14 | return "Mocked Adapter"; 15 | } 16 | 17 | toString() { 18 | return "Mocked Adapter"; 19 | } 20 | 21 | /** 22 | * 23 | * @param {*} content 24 | * @returns {ParseResult} 25 | */ 26 | parse(content) { 27 | return BaseAdapter.ParseResult([], []); 28 | } 29 | } 30 | 31 | class MockedAdapterWithParseResultError { 32 | static toString() { 33 | return "Mocked Adapter With Parse Result Error"; 34 | } 35 | 36 | toString() { 37 | return "Mocked Adapter With Parse Result Error"; 38 | } 39 | 40 | /** 41 | * 42 | * @param {string} content 43 | * @returns {ParseResult} 44 | */ 45 | parse(content) { 46 | return BaseAdapter.ParseResult( 47 | [ 48 | new CueNode({ 49 | content, 50 | endTime: 0, 51 | startTime: 0, 52 | id: "mocked", 53 | }), 54 | ], 55 | [ 56 | { 57 | error: new Error("mocked adapter error"), 58 | failedChunk: "", 59 | isCritical: false, 60 | }, 61 | ], 62 | ); 63 | } 64 | } 65 | 66 | const originalParseMethod = MockedAdapter.prototype.parse; 67 | 68 | describe("DistributionSession", () => { 69 | /** @type {import("../lib/Track").TrackRecord[]} */ 70 | const mockedTracks = [ 71 | { 72 | lang: "ita", 73 | content: "WEBVTT", 74 | mimeType: "text/vtt", 75 | }, 76 | { 77 | lang: "eng", 78 | content: "WEBVTT", 79 | mimeType: "text/vtt", 80 | }, 81 | ]; 82 | 83 | /** @type {import("../lib/Track").TrackRecord[]} */ 84 | const mockedEmptyTracks = [ 85 | { 86 | lang: "ita", 87 | content: "", 88 | mimeType: "text/vtt", 89 | }, 90 | { 91 | lang: "eng", 92 | content: "", 93 | mimeType: "text/vtt", 94 | }, 95 | ]; 96 | 97 | beforeEach(() => { 98 | MockedAdapter.prototype.parse = originalParseMethod; 99 | }); 100 | 101 | it("Should throw if adapter doesn't return expected data structure", () => { 102 | // ********************* // 103 | // *** MOCKING START *** // 104 | // ********************* // 105 | 106 | /** 107 | * @param {CueNode[]} content 108 | * @returns 109 | */ 110 | 111 | // @ts-expect-error 112 | MockedAdapter.prototype.parse = function (content) { 113 | return content; 114 | }; 115 | 116 | // ******************* // 117 | // *** MOCKING END *** // 118 | // ******************* // 119 | 120 | expect(() => { 121 | new DistributionSession( 122 | [ 123 | { 124 | content: "This content format is not actually important. adapter is mocked", 125 | lang: "eng", 126 | mimeType: "text/vtt", 127 | adapter: new MockedAdapter(), 128 | }, 129 | ], 130 | () => {}, 131 | ); 132 | }).toThrow(UnexpectedParsingOutputFormatError); 133 | 134 | MockedAdapter.prototype.parse = originalParseMethod; 135 | }); 136 | 137 | it("Should throw if adapter crashes", () => { 138 | // ********************* // 139 | // *** MOCKING START *** // 140 | // ********************* // 141 | 142 | /** 143 | * @param {CueNode[]} content 144 | * @returns 145 | */ 146 | 147 | MockedAdapter.prototype.parse = function (content) { 148 | throw new Error("Mocked Error"); 149 | }; 150 | 151 | // ******************* // 152 | // *** MOCKING END *** // 153 | // ******************* // 154 | 155 | expect(() => { 156 | new DistributionSession( 157 | [ 158 | { 159 | content: "This content format is not actually important. adapter is mocked", 160 | lang: "eng", 161 | mimeType: "text/vtt", 162 | adapter: new MockedAdapter(), 163 | }, 164 | ], 165 | () => {}, 166 | ); 167 | }).toThrowError(UncaughtParsingExceptionError); 168 | 169 | MockedAdapter.prototype.parse = originalParseMethod; 170 | }); 171 | 172 | it("should create a track for each provided session track that has content and didn't throw", () => { 173 | // *************** // 174 | // *** MOCKING *** // 175 | // *************** // 176 | 177 | MockedAdapter.prototype.parse = function () { 178 | return BaseAdapter.ParseResult( 179 | [ 180 | new CueNode({ 181 | id: "any", 182 | startTime: 0, 183 | endTime: 2000, 184 | content: "Whatever is your content, it will be displayed here", 185 | }), 186 | ], 187 | [], 188 | ); 189 | }; 190 | 191 | // ******************* // 192 | // *** MOCKING END *** // 193 | // ******************* // 194 | 195 | /** 196 | * @type {import("../lib/DistributionSession").SessionTrack[]} 197 | */ 198 | const trackRecords = mockedTracks.map((record) => ({ 199 | ...record, 200 | adapter: new MockedAdapter(), 201 | })); 202 | 203 | const session = new DistributionSession(trackRecords, () => {}); 204 | 205 | expect(session.availableTracks.length).toBe(trackRecords.length); 206 | }); 207 | 208 | it("should ignore tracks that have no output", () => { 209 | /** 210 | * @type {import("../lib/DistributionSession").SessionTrack[]} 211 | */ 212 | const trackRecords = mockedEmptyTracks.map((record) => ({ 213 | ...record, 214 | adapter: new MockedAdapter(), 215 | })); 216 | 217 | const session = new DistributionSession(trackRecords, () => {}); 218 | expect(session.availableTracks.length).toBe(0); 219 | }); 220 | 221 | it("should honor session tracks active attribute", () => { 222 | // *************** // 223 | // *** MOCKING *** // 224 | // *************** // 225 | 226 | MockedAdapter.prototype.parse = function () { 227 | return BaseAdapter.ParseResult( 228 | [ 229 | new CueNode({ 230 | id: "any", 231 | startTime: 0, 232 | endTime: 2000, 233 | content: "Whatever is your content, it will be displayed here", 234 | }), 235 | ], 236 | [], 237 | ); 238 | }; 239 | 240 | // ******************* // 241 | // *** MOCKING END *** // 242 | // ******************* // 243 | 244 | /** 245 | * @type {import("../lib/DistributionSession").SessionTrack[]} 246 | */ 247 | const trackRecords = mockedEmptyTracks.map((record) => ({ 248 | ...record, 249 | adapter: new MockedAdapter(), 250 | })); 251 | 252 | trackRecords[0].active = true; 253 | trackRecords[1].active = true; 254 | 255 | const session = new DistributionSession(trackRecords, () => {}); 256 | 257 | expect(session.availableTracks.length).toBe(trackRecords.length); 258 | expect(session.activeTracks.length).toBe(2); 259 | }); 260 | 261 | it("should call safeFailure callback when adapter goes bad", () => { 262 | /** 263 | * @param {Error} error 264 | */ 265 | 266 | function onSafeFailureCb(error) {} 267 | 268 | /** 269 | * Jest is not happy if we don't do this. Meh 270 | */ 271 | 272 | const mockObject = { onSafeFailureCb }; 273 | 274 | const spy = jest.spyOn(mockObject, "onSafeFailureCb"); 275 | const mockedError = new Error("mocked adapter error"); 276 | 277 | /** 278 | * @type {import("../lib/DistributionSession").SessionTrack[]} 279 | */ 280 | const trackRecords = mockedEmptyTracks.map((record) => ({ 281 | ...record, 282 | adapter: new MockedAdapterWithParseResultError(), 283 | })); 284 | 285 | new DistributionSession(trackRecords, mockObject.onSafeFailureCb); 286 | 287 | expect(spy).toHaveBeenCalledTimes(2); /** One error per track */ 288 | expect(spy).toHaveBeenCalledWith(mockedError); 289 | }); 290 | }); 291 | -------------------------------------------------------------------------------- /packages/server/specs/IntervalBinaryTree.spec.mjs: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | import { describe, it, expect, beforeEach } from "@jest/globals"; 3 | import { IntervalBinaryTree } from "../lib/IntervalBinaryTree.js"; 4 | import { CueNode } from "../lib/CueNode.js"; 5 | 6 | describe("IntervalBinaryTree", () => { 7 | /** @type {IntervalBinaryTree} */ 8 | let tree; 9 | 10 | beforeEach(() => { 11 | tree = new IntervalBinaryTree(); 12 | }); 13 | 14 | it("should assign nodes to the correct timeframe", () => { 15 | tree.addNode( 16 | cueNodeToTreeLeaf( 17 | new CueNode({ 18 | id: "any", 19 | content: "A test content", 20 | startTime: 11000, 21 | endTime: 12000, 22 | }), 23 | ), 24 | ); 25 | 26 | tree.addNode( 27 | cueNodeToTreeLeaf( 28 | new CueNode({ 29 | id: "any", 30 | content: "A test content", 31 | startTime: 0, 32 | endTime: 10000, 33 | }), 34 | ), 35 | ); 36 | 37 | const query1 = tree.getCurrentNodes(0); 38 | 39 | expect(query1?.length).toBe(1); 40 | expect(query1?.[0]).toMatchObject({ 41 | content: "A test content", 42 | startTime: 0, 43 | endTime: 10000, 44 | }); 45 | 46 | const query2 = tree.getCurrentNodes(11500); 47 | 48 | expect(query2?.length).toBe(1); 49 | expect(query2?.[0]).toMatchObject({ 50 | content: "A test content", 51 | startTime: 11000, 52 | endTime: 12000, 53 | }); 54 | }); 55 | 56 | it("should return all the overlapping nodes for the selected time moment", () => { 57 | /** 58 | * Test: the second node ends at the same moment of the "parent". 59 | * For example, VTT Timestamps 60 | */ 61 | tree.addNode( 62 | cueNodeToTreeLeaf( 63 | new CueNode({ 64 | id: "any", 65 | content: "A test master-content", 66 | startTime: 0, 67 | endTime: 15000, 68 | }), 69 | ), 70 | ); 71 | 72 | tree.addNode( 73 | cueNodeToTreeLeaf( 74 | new CueNode({ 75 | id: "any", 76 | content: "A test sub-content", 77 | startTime: 3000, 78 | endTime: 15000, 79 | }), 80 | ), 81 | ); 82 | 83 | tree.addNode( 84 | cueNodeToTreeLeaf( 85 | new CueNode({ 86 | id: "any", 87 | content: "A completely different and single node", 88 | startTime: 16000, 89 | endTime: 17000, 90 | }), 91 | ), 92 | ); 93 | 94 | /** 95 | * Test: the second node ends before "parent". 96 | */ 97 | 98 | tree.addNode( 99 | cueNodeToTreeLeaf( 100 | new CueNode({ 101 | id: "any", 102 | content: "A test master-content", 103 | startTime: 18000, 104 | endTime: 30000, 105 | }), 106 | ), 107 | ); 108 | 109 | tree.addNode( 110 | cueNodeToTreeLeaf( 111 | new CueNode({ 112 | id: "any", 113 | content: "A test sub-content", 114 | startTime: 20000, 115 | endTime: 23000, 116 | }), 117 | ), 118 | ); 119 | 120 | /** 121 | * Test: first node ends after second node. 122 | * If should be fetched in the correct time 123 | * order 124 | */ 125 | 126 | tree.addNode( 127 | cueNodeToTreeLeaf( 128 | new CueNode({ 129 | id: "any", 130 | content: "A test sub-content", 131 | startTime: 36000, 132 | endTime: 38000, 133 | }), 134 | ), 135 | ); 136 | 137 | tree.addNode( 138 | cueNodeToTreeLeaf( 139 | new CueNode({ 140 | id: "any", 141 | content: "A test master-content", 142 | startTime: 33500, 143 | endTime: 38000, 144 | }), 145 | ), 146 | ); 147 | 148 | const query1 = tree.getCurrentNodes(7000); 149 | 150 | expect(query1?.length).toBe(2); 151 | expect(query1).toMatchObject([ 152 | { 153 | id: "any", 154 | content: "A test master-content", 155 | startTime: 0, 156 | endTime: 15000, 157 | }, 158 | { 159 | id: "any", 160 | content: "A test sub-content", 161 | startTime: 3000, 162 | endTime: 15000, 163 | }, 164 | ]); 165 | 166 | const query2 = tree.getCurrentNodes(22500); 167 | 168 | expect(query2?.length).toBe(2); 169 | expect(query2).toMatchObject([ 170 | { 171 | content: "A test master-content", 172 | startTime: 18000, 173 | endTime: 30000, 174 | }, 175 | { 176 | content: "A test sub-content", 177 | startTime: 20000, 178 | endTime: 23000, 179 | }, 180 | ]); 181 | 182 | const query3 = tree.getCurrentNodes(37000); 183 | 184 | expect(query3?.length).toBe(2); 185 | expect(query3).toMatchObject([ 186 | { 187 | content: "A test master-content", 188 | startTime: 33500, 189 | endTime: 38000, 190 | }, 191 | { 192 | content: "A test sub-content", 193 | startTime: 36000, 194 | endTime: 38000, 195 | }, 196 | ]); 197 | }); 198 | 199 | it("should return all the nodes in the correct order", () => { 200 | /** Root */ 201 | tree.addNode( 202 | cueNodeToTreeLeaf( 203 | new CueNode({ 204 | id: "any", 205 | content: "A test content", 206 | startTime: 11000, 207 | endTime: 12000, 208 | }), 209 | ), 210 | ); 211 | 212 | /** Adding on left */ 213 | tree.addNode( 214 | cueNodeToTreeLeaf( 215 | new CueNode({ 216 | id: "any", 217 | content: "A test content", 218 | startTime: 3000, 219 | endTime: 10000, 220 | }), 221 | ), 222 | ); 223 | 224 | /** Adding on right */ 225 | tree.addNode( 226 | cueNodeToTreeLeaf( 227 | new CueNode({ 228 | id: "any", 229 | content: "A test content", 230 | startTime: 12000, 231 | endTime: 15000, 232 | }), 233 | ), 234 | ); 235 | 236 | /** Adding on left's left */ 237 | tree.addNode( 238 | cueNodeToTreeLeaf( 239 | new CueNode({ 240 | id: "any", 241 | content: "A test content", 242 | startTime: 0, 243 | endTime: 5000, 244 | }), 245 | ), 246 | ); 247 | 248 | /** Adding on left's right */ 249 | tree.addNode( 250 | cueNodeToTreeLeaf( 251 | new CueNode({ 252 | id: "any", 253 | content: "A test content", 254 | startTime: 5000, 255 | endTime: 9000, 256 | }), 257 | ), 258 | ); 259 | 260 | /** Adding on right's left */ 261 | tree.addNode( 262 | cueNodeToTreeLeaf( 263 | new CueNode({ 264 | id: "any", 265 | content: "A test content", 266 | startTime: 12000, 267 | endTime: 13000, 268 | }), 269 | ), 270 | ); 271 | 272 | /** Adding on right's right */ 273 | tree.addNode( 274 | cueNodeToTreeLeaf( 275 | new CueNode({ 276 | id: "any", 277 | content: "A test content", 278 | startTime: 13000, 279 | endTime: 15000, 280 | }), 281 | ), 282 | ); 283 | 284 | const query = tree.getAll(); 285 | 286 | expect(query.length).toBe(7); 287 | 288 | expect(query).toEqual([ 289 | // left 290 | new CueNode({ 291 | id: "any", 292 | content: "A test content", 293 | startTime: 0, 294 | endTime: 5000, 295 | }), 296 | new CueNode({ 297 | id: "any", 298 | content: "A test content", 299 | startTime: 3000, 300 | endTime: 10000, 301 | }), 302 | new CueNode({ 303 | id: "any", 304 | content: "A test content", 305 | startTime: 5000, 306 | endTime: 9000, 307 | }), 308 | // Root 309 | new CueNode({ 310 | id: "any", 311 | content: "A test content", 312 | startTime: 11000, 313 | endTime: 12000, 314 | }), 315 | // right 316 | new CueNode({ 317 | id: "any", 318 | content: "A test content", 319 | startTime: 12000, 320 | endTime: 13000, 321 | }), 322 | new CueNode({ 323 | id: "any", 324 | content: "A test content", 325 | startTime: 12000, 326 | endTime: 15000, 327 | }), 328 | new CueNode({ 329 | id: "any", 330 | content: "A test content", 331 | startTime: 13000, 332 | endTime: 15000, 333 | }), 334 | ]); 335 | }); 336 | }); 337 | 338 | /** 339 | * 340 | * @param {CueNode} cueNode 341 | * @returns 342 | */ 343 | 344 | function cueNodeToTreeLeaf(cueNode) { 345 | return { 346 | left: null, 347 | right: null, 348 | node: cueNode, 349 | max: cueNode.endTime, 350 | get low() { 351 | return this.node.startTime; 352 | }, 353 | get high() { 354 | return this.node.endTime; 355 | }, 356 | }; 357 | } 358 | -------------------------------------------------------------------------------- /packages/webvtt-adapter/src/Tokenizer.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @see https://www.w3.org/TR/2019/CR-webvtt1-20190404/#webvtt-data-state 3 | * 4 | * An "annotation" is: 5 | * (1) the content between the tag name and the ">"; 6 | * (2) the content between ">" and "<"; 7 | * 8 | * As (1), some disallow them (c, i, u, b, ruby, rt). All but "ruby" (which take its from "rt") require one as (2) 9 | * As (1), some requires them (v, lang). 10 | * 11 | * Therefore the annotation state is after the "<" and the tag name 12 | * 13 | * @example 14 | * 15 | * ```vtt 16 | * (Fred) 17 | * (en-US) 18 | * ``` 19 | */ 20 | 21 | import { Token } from "./Token.js"; 22 | 23 | enum TokenizerState { 24 | DATA, 25 | HTML_CHARACTER_REFERENCE, 26 | TAG, 27 | START_TAG, 28 | START_TAG_CLASS, 29 | START_TAG_ANNOTATION /** Content in tag */, 30 | HTML_CHARACTER_REFERENCE_ANNOTATION /** HTML Entity in annotation */, 31 | END_TAG, 32 | TIMESTAMP_TAG, 33 | } 34 | 35 | const SHARED_DOM_PARSER = new DOMParser(); 36 | 37 | export class Tokenizer { 38 | /** Character index in the content */ 39 | private cursor = 0; 40 | /** Next token index in the content */ 41 | private startPoint = 0; 42 | 43 | constructor(private rawContent: string) {} 44 | 45 | static isWhitespace(character: string) { 46 | return character == "\x20" || character == "\x09" || character == "\x0C"; 47 | } 48 | 49 | static isNewLine(character: string) { 50 | return character == "\x0A"; 51 | } 52 | 53 | /** 54 | * Attempts to convert a set of character (a supposed HTML Entity) 55 | * to the correct character, by accumulating its char code; 56 | * 57 | * @param source 58 | * @param currentCursor 59 | * @param additionalAllowedCharacters 60 | * @returns 61 | */ 62 | 63 | static parseHTMLEntity( 64 | source: string, 65 | currentCursor: number, 66 | additionalAllowedCharacters: string[] = [], 67 | ): [content: string, cursor: number] { 68 | if (!source?.length) { 69 | return ["", currentCursor]; 70 | } 71 | 72 | let cursor = currentCursor; 73 | let result = ""; 74 | 75 | /** 76 | * This partial implementation, compared to Chromium's implementation 77 | * is due to my lack of understanding and laziness of everything 78 | * that concerns Unicode characters conversion. I mean, wth is this? 79 | * It is okay for me until it is just matter of following parsing 80 | * and states, but when it comes to decimals and unicodes... I'm out. 81 | * It is better to use a DOMParser, even if it might be a little bit 82 | * slower than native implementation (or... maybe not?). 83 | * 84 | * Maybe one day I'll try to understand this whole world. 85 | * Right now, I'm going to integrate only the basic logic. 86 | * 87 | * @see https://github.com/chromium/chromium/blob/c4d3c31083a2e1481253ff2d24298a1dfe19c754/third_party/blink/renderer/core/html/parser/html_entity_parser.cc#L107 88 | */ 89 | 90 | while (cursor < source.length) { 91 | const char = source[cursor] as string; 92 | const maybeHTMLEntity = "&" + result + char; 93 | 94 | if ( 95 | Tokenizer.isWhitespace(char) || 96 | Tokenizer.isNewLine(char) || 97 | char === "<" || 98 | char === "&" || 99 | additionalAllowedCharacters.includes(char) 100 | ) { 101 | /** 102 | * Not a valid HTMLEntity. Returning what we 103 | * discovered, so it can be appended to the result 104 | */ 105 | return [maybeHTMLEntity, cursor]; 106 | } 107 | 108 | if (char === ";") { 109 | return [ 110 | SHARED_DOM_PARSER.parseFromString(maybeHTMLEntity, "text/html").documentElement 111 | .textContent || maybeHTMLEntity, 112 | cursor, 113 | ]; 114 | } 115 | 116 | result += char; 117 | cursor++; 118 | } 119 | 120 | return ["&" + result, cursor]; 121 | } 122 | 123 | // ************************ // 124 | // *** INSTANCE METHODS *** // 125 | // ************************ // 126 | 127 | public nextToken(): Token | null { 128 | if (this.cursor >= this.rawContent.length) { 129 | return null; 130 | } 131 | 132 | /** Our token starts at this index of the raw content */ 133 | this.startPoint = this.cursor; 134 | 135 | let state: TokenizerState = TokenizerState.DATA; 136 | let result = ""; 137 | 138 | /** 139 | * Buffer is an additional container for data 140 | * that should be associated to result but should 141 | * not belong to the same content 142 | */ 143 | let buffer = ""; 144 | 145 | const classes: string[] = []; 146 | 147 | while (this.cursor <= this.rawContent.length) { 148 | const char = this.rawContent[this.cursor] as string; 149 | 150 | switch (state) { 151 | case TokenizerState.DATA: { 152 | if (char === "&") { 153 | state = TokenizerState.HTML_CHARACTER_REFERENCE; 154 | break; 155 | } 156 | 157 | if (char === "<") { 158 | if (!result.length) { 159 | state = TokenizerState.TAG; 160 | } else { 161 | return Token.String(result, { start: this.startPoint, end: this.cursor }); 162 | } 163 | 164 | break; 165 | } 166 | 167 | if (this.cursor === this.rawContent.length) { 168 | return Token.String(result, { start: this.startPoint, end: this.cursor }); 169 | } 170 | 171 | result += char; 172 | break; 173 | } 174 | 175 | case TokenizerState.HTML_CHARACTER_REFERENCE: { 176 | const [content, nextCursor] = Tokenizer.parseHTMLEntity(this.rawContent, this.cursor); 177 | 178 | result += content; 179 | this.cursor = nextCursor; 180 | 181 | state = TokenizerState.DATA; 182 | break; 183 | } 184 | 185 | case TokenizerState.TAG: { 186 | if (Tokenizer.isWhitespace(char) || Tokenizer.isNewLine(char)) { 187 | state = TokenizerState.START_TAG_ANNOTATION; 188 | break; 189 | } 190 | 191 | if (char === ".") { 192 | state = TokenizerState.START_TAG_CLASS; 193 | break; 194 | } 195 | 196 | if (char === "/") { 197 | state = TokenizerState.END_TAG; 198 | break; 199 | } 200 | 201 | if (!Number.isNaN(parseInt(char))) { 202 | state = TokenizerState.TIMESTAMP_TAG; 203 | result += char; 204 | break; 205 | } 206 | 207 | if (this.cursor === this.rawContent.length) { 208 | this.cursor++; 209 | return Token.StartTag(result, { start: this.startPoint, end: this.cursor }, classes); 210 | } 211 | 212 | state = TokenizerState.START_TAG; 213 | result += char; 214 | break; 215 | } 216 | 217 | case TokenizerState.START_TAG: { 218 | if (Tokenizer.isWhitespace(char)) { 219 | state = TokenizerState.START_TAG_ANNOTATION; 220 | break; 221 | } 222 | 223 | if (Tokenizer.isNewLine(char)) { 224 | buffer += char; 225 | state = TokenizerState.START_TAG_ANNOTATION; 226 | break; 227 | } 228 | 229 | if (char === ".") { 230 | state = TokenizerState.START_TAG_CLASS; 231 | break; 232 | } 233 | 234 | if (char === ">" || this.cursor === this.rawContent.length) { 235 | this.cursor++; 236 | return Token.StartTag(result, { start: this.startPoint, end: this.cursor }, classes); 237 | } 238 | 239 | result += char; 240 | break; 241 | } 242 | 243 | case TokenizerState.START_TAG_CLASS: { 244 | if (Tokenizer.isWhitespace(char)) { 245 | classes.push(buffer); 246 | buffer = ""; 247 | state = TokenizerState.START_TAG_ANNOTATION; 248 | break; 249 | } 250 | 251 | if (Tokenizer.isNewLine(char)) { 252 | buffer += char; 253 | state = TokenizerState.START_TAG_ANNOTATION; 254 | break; 255 | } 256 | 257 | if (char === ".") { 258 | classes.push(buffer); 259 | buffer = ""; 260 | break; 261 | } 262 | 263 | if (char === ">" || this.cursor === this.rawContent.length) { 264 | this.cursor++; 265 | classes.push(buffer); 266 | return Token.StartTag(result, { start: this.startPoint, end: this.cursor }, classes); 267 | } 268 | 269 | buffer += char; 270 | break; 271 | } 272 | 273 | case TokenizerState.START_TAG_ANNOTATION: { 274 | if (char === "&") { 275 | state = TokenizerState.HTML_CHARACTER_REFERENCE_ANNOTATION; 276 | break; 277 | } 278 | 279 | if (char === ">" || this.cursor === this.rawContent.length) { 280 | this.cursor++; 281 | 282 | return Token.StartTag( 283 | result, 284 | { start: this.startPoint, end: this.cursor }, 285 | classes, 286 | /** \x20 is classic space (U+0020 SPACE character) */ 287 | buffer.trim().replace(/\s+/g, "\x20").split("\x20"), 288 | ); 289 | } 290 | 291 | buffer += char; 292 | break; 293 | } 294 | 295 | case TokenizerState.HTML_CHARACTER_REFERENCE_ANNOTATION: { 296 | const [content, nextCursor] = Tokenizer.parseHTMLEntity(this.rawContent, this.cursor, [ 297 | ">", 298 | ]); 299 | 300 | buffer += content; 301 | this.cursor = nextCursor; 302 | 303 | state = TokenizerState.START_TAG_ANNOTATION; 304 | break; 305 | } 306 | 307 | case TokenizerState.END_TAG: { 308 | /** 309 | * Reminder: this will be accessed only if we have found a 310 | * "/" first in a Tag state 311 | */ 312 | 313 | if (char === ">" || this.cursor === this.rawContent.length) { 314 | this.cursor++; 315 | return Token.EndTag(result, { start: this.startPoint, end: this.cursor }); 316 | } 317 | 318 | result += char; 319 | break; 320 | } 321 | 322 | case TokenizerState.TIMESTAMP_TAG: { 323 | if (char === ">" || this.cursor === this.rawContent.length) { 324 | this.cursor++; 325 | return Token.TimestampTag(result, { start: this.startPoint, end: this.cursor }); 326 | } 327 | 328 | if (Tokenizer.isWhitespace(char)) { 329 | /** 330 | * Timestamp is incomplete and not a timestamp tag. 331 | * TIMESTAMP_TAG can be accessed only from TAG and, before, 332 | * from DATA. 333 | */ 334 | state = TokenizerState.DATA; 335 | } 336 | 337 | result += char; 338 | break; 339 | } 340 | } 341 | 342 | this.cursor++; 343 | } 344 | 345 | return null; 346 | } 347 | } 348 | -------------------------------------------------------------------------------- /assets/wiki/timeline-ibt.svg: -------------------------------------------------------------------------------- 1 | Time(s)Max = 30[11, 22][7, 15]Max = 15Max = 10[0, 10]Max = 30[16, 25]Max = 30[25, 30][27, 30]Max = 30071011151622252730 -------------------------------------------------------------------------------- /packages/webvtt-adapter/src/Parser/RenderingModifiers.ts: -------------------------------------------------------------------------------- 1 | import type { RenderingModifiers } from "@sub37/server"; 2 | 3 | // const GROWING_LEFT = "rl"; 4 | // type GROWING_LEFT = typeof GROWING_LEFT; 5 | 6 | // const GROWING_RIGHT = "lr"; 7 | // type GROWING_RIGHT = typeof GROWING_RIGHT; 8 | 9 | // const HORIZONTAL = ""; 10 | // type HORIZONTAL = typeof HORIZONTAL; 11 | 12 | const ALIGNMENT_NUMBER_IDENTIFIERS = { 13 | start: 1, 14 | left: 2, 15 | center: 3, 16 | right: 4, 17 | end: 5, 18 | } as const; 19 | 20 | export class WebVTTRenderingModifiers implements RenderingModifiers { 21 | public static fromString(source: string | undefined): WebVTTRenderingModifiers { 22 | const modifier = new WebVTTRenderingModifiers(); 23 | 24 | if (!source?.length) { 25 | return modifier; 26 | } 27 | 28 | const properties: Record = source.split(" ").reduce((acc, curr) => { 29 | const [key, value] = curr.split(":"); 30 | return (key && value && { ...acc, [key]: value }) || acc; 31 | }, {}); 32 | 33 | let position: number | "auto" = "auto"; 34 | let positionAlignment: WebVTTRenderingModifiers["positionAlignment"] | "auto" = "auto"; 35 | 36 | for (const [key, value] of Object.entries(properties)) { 37 | switch (key) { 38 | case "position": { 39 | /** e.g. position:30%,line-left */ 40 | const [pos, posAlignment] = value.split(","); 41 | 42 | if (isPositionAlignmentStandard(posAlignment)) { 43 | positionAlignment = posAlignment; 44 | } 45 | 46 | if (pos !== "auto") { 47 | const integerPosition = (pos.endsWith("%") && parseInt(pos)) || NaN; 48 | 49 | if (!Number.isNaN(integerPosition)) { 50 | position = Math.min(Math.max(0, integerPosition), 100); 51 | } 52 | } 53 | 54 | break; 55 | } 56 | 57 | /** e.g. align:center */ 58 | case "align": { 59 | if (isTextAlignmentStandard(value)) { 60 | modifier.align = value; 61 | } 62 | 63 | break; 64 | } 65 | 66 | /** e.g. region:fred */ 67 | case "region": { 68 | modifier.region = value; 69 | break; 70 | } 71 | 72 | /** e.g. size:80% */ 73 | case "size": { 74 | const integerSize = (value.endsWith("%") && parseInt(value)) || NaN; 75 | 76 | if (!Number.isNaN(integerSize)) { 77 | modifier.size = Math.min(Math.max(0, integerSize), 100); 78 | } 79 | 80 | break; 81 | } 82 | 83 | // case "vertical": { 84 | // if (isVerticalStandard(value)) { 85 | // modifier.vertical = value; 86 | // } 87 | 88 | // break; 89 | // } 90 | } 91 | } 92 | 93 | // **************************************************** // 94 | // ***************** DEPENDENCY PHASE ***************** // 95 | // *** Some properties final value depend on others *** // 96 | // **************************************************** // 97 | 98 | /** 99 | * @see https://www.w3.org/TR/webvtt1/#webvtt-cue-position 100 | */ 101 | 102 | if (position === "auto") { 103 | switch (modifier.align) { 104 | case "left": { 105 | modifier.position = 0; 106 | break; 107 | } 108 | 109 | case "right": { 110 | modifier.position = 100; 111 | break; 112 | } 113 | 114 | default: { 115 | modifier.position = 50; 116 | } 117 | } 118 | } else { 119 | modifier.position = position; 120 | } 121 | 122 | if (positionAlignment === "auto") { 123 | switch (modifier.align) { 124 | case "left": { 125 | modifier.positionAlignment = "line-left"; 126 | break; 127 | } 128 | 129 | case "right": { 130 | modifier.positionAlignment = "line-right"; 131 | break; 132 | } 133 | 134 | case "start": 135 | case "end": { 136 | /** 137 | * @TODO to implement based on base direction 138 | * base direction is detected with 139 | * 140 | * U+200E LEFT-TO-RIGHT MARK ---> start: "line-left", end: "line-right" 141 | * U+200F RIGHT-TO-LEFT MARK ---> start: "line-right", end: "line-left" 142 | */ 143 | 144 | break; 145 | } 146 | 147 | default: { 148 | modifier.positionAlignment = "center"; 149 | } 150 | } 151 | } else { 152 | modifier.positionAlignment = positionAlignment; 153 | } 154 | 155 | return modifier; 156 | } 157 | 158 | private position?: number = 50; 159 | private positionAlignment?: "line-left" | "center" | "line-right" = "center"; 160 | private align?: "start" | "left" | "center" | "right" | "end" = "center"; 161 | private region?: string; 162 | 163 | /** 164 | * @TODO support vertical in renderer 165 | * along with a new property "writing mode" 166 | */ 167 | // private vertical?: HORIZONTAL | GROWING_LEFT | GROWING_RIGHT = HORIZONTAL; 168 | 169 | private size: number = 100; 170 | 171 | public get id(): number { 172 | return Math.abs(this.width + this.leftOffset - ALIGNMENT_NUMBER_IDENTIFIERS[this.align]); 173 | } 174 | 175 | public get width(): number { 176 | /** 177 | * Width, and hence cuebox left offset, calculation is 178 | * highly influenced by the alignment. 179 | * 180 | * In fact, we need to apply different formulas based on 181 | * the point we start and the direction we want to proceed. 182 | * 183 | * In the same way, also leftOffset is influenced by alignment 184 | * and highly tied to width. 185 | */ 186 | 187 | switch (this.positionAlignment) { 188 | case "line-left": { 189 | /** 190 | * Cuebox's left edge matches at position 191 | * point and ends at 100%. 192 | * 193 | * @example scheme, 60% 194 | * Calculation starts from right edge 195 | * 196 | * 0% 10% 20% 30% 40% 50% 60% 70% 80% 90% 100% 197 | * |----|----|----|----|----|----|----|----|----|----| 198 | * | | | 199 | * | |----|----|----|----| 200 | * | Left Offset | |--te|xt--| | 201 | * | |----|----|----|----| 202 | * | | | 203 | * |----|----|----|----|----|----|----|----|----|----| 204 | */ 205 | 206 | return Math.min(this.size, 100 - this.position); 207 | } 208 | 209 | case "center": { 210 | /** 211 | * Cuebox center matches the position point 212 | * and spans in both direction. 213 | * 214 | * Based on the position point, we need to change 215 | * the formula to begin calculating the width 216 | * starting from one edge or the other. 217 | * 218 | * @example scheme, point < 50% 219 | * Calculation start from left edge 220 | * 221 | * 0% 10% 20% 30% 40% 50% 60% 70% 80% 90% 100% 222 | * |----|----|----|----|----|----|----|----|----|----| 223 | * | | | 224 | * |----|----|----|----|----|----| | 225 | * | |--te|xt--| | | 226 | * |----|----|----|----|----|----| | 227 | * | | | 228 | * |----|----|----|----|----|----|----|----|----|----| 229 | */ 230 | 231 | if (this.position <= 50) { 232 | return Math.min(this.size, this.position * 2); 233 | } 234 | 235 | /** 236 | * @example scheme, point > 50% 237 | * Calculation starts from right edge 238 | * 239 | * 0% 10% 20% 30% 40% 50% 60% 70% 80% 90% 100% 240 | * |----|----|----|----|----|----|----|----|----|----| 241 | * | | | 242 | * | Left |----|----|----|----|----|----|----|----| 243 | * | - | |--te|xt--| | 244 | * | Offset |----|----|----|----|----|----|----|----| 245 | * | | | 246 | * |----|----|----|----|----|----|----|----|----|----| 247 | */ 248 | 249 | return Math.min(this.size, (100 - this.position) * 2); 250 | } 251 | 252 | case "line-right": { 253 | /** 254 | * Cuebox's right edge matches the position point 255 | * and spans the available space on the left 256 | * (to 0%) 257 | * 258 | * @example scheme, 60% 259 | * Calculation starts from left edge 260 | * 261 | * 0% 10% 20% 30% 40% 50% 60% 70% 80% 90% 100% 262 | * |----|----|----|----|----|----|----|----|----|----| 263 | * | | | 264 | * |----|----|----|----|----|----| | 265 | * | |--te|xt--| | | 266 | * |----|----|----|----|----|----| | 267 | * | | | 268 | * |----|----|----|----|----|----|----|----|----|----| 269 | */ 270 | 271 | return Math.min(this.size, this.position); 272 | } 273 | } 274 | } 275 | 276 | public get leftOffset(): number { 277 | /** 278 | * Width, and hence cuebox left offset, calculation is 279 | * highly influenced by the alignment. 280 | * In fact, we need to apply different formulas based on 281 | * the point we start and the direction we want to proceed. 282 | * 283 | * In the same way, also leftOffset is influenced by alignment 284 | * and highly tied to width. 285 | */ 286 | 287 | switch (this.positionAlignment) { 288 | case "line-left": { 289 | /** 290 | * Cuebox's left edge matches at position 291 | * point and ends at 100%. 292 | * 293 | * For schemas, refer to width on "line-left" case. 294 | */ 295 | 296 | return this.position; 297 | } 298 | 299 | case "center": { 300 | /** 301 | * Cuebox center matches the position point 302 | * and spans in both direction. 303 | * 304 | * Based on the position point, we need to change 305 | * the formula to begin calculating the width, 306 | * and hence the leftOffset, starting from one 307 | * edge or the other. 308 | * 309 | * For schemas, refer to width on "center" case. 310 | */ 311 | 312 | if (this.position <= 50) { 313 | return 0; 314 | } 315 | 316 | const width = (100 - this.position) * 2; 317 | return 100 - width; 318 | } 319 | 320 | case "line-right": { 321 | /** 322 | * Cuebox's right edge matches the position point 323 | * and spans the available space on the left 324 | * (to 0%) 325 | * 326 | * For schemas, refer to width on "line-right" case. 327 | */ 328 | 329 | return 0; 330 | } 331 | } 332 | } 333 | 334 | public get textAlignment(): typeof this.align { 335 | return this.align; 336 | } 337 | 338 | public get regionIdentifier(): string { 339 | return this.region; 340 | } 341 | } 342 | 343 | function isPositionAlignmentStandard( 344 | alignment: string, 345 | ): alignment is "line-left" | "center" | "line-right" { 346 | return ["line-left", "center", "line-right"].includes(alignment); 347 | } 348 | 349 | function isTextAlignmentStandard( 350 | alignment: string, 351 | ): alignment is "start" | "left" | "center" | "right" | "end" { 352 | return ["start", "left", "center", "right", "end"].includes(alignment); 353 | } 354 | 355 | // function isVerticalStandard( 356 | // vertical: string, 357 | // ): vertical is HORIZONTAL | GROWING_LEFT | GROWING_RIGHT { 358 | // return [HORIZONTAL, GROWING_LEFT, GROWING_RIGHT].includes(vertical); 359 | // } 360 | --------------------------------------------------------------------------------