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 |
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 |
--------------------------------------------------------------------------------
/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