diff --git a/package.json b/package.json index 78df304..8899154 100644 --- a/package.json +++ b/package.json @@ -31,7 +31,7 @@ "jest": "^29.4.3", "jest-environment-jsdom": "^29.4.3", "lerna": "^6.6.1", - "prettier": "^2.8.4", - "typescript": "^4.9.5" + "prettier": "^3.2.5", + "typescript": "^5.4.5" } } diff --git a/packages/captions-renderer/package.json b/packages/captions-renderer/package.json index c524850..7b329ec 100644 --- a/packages/captions-renderer/package.json +++ b/packages/captions-renderer/package.json @@ -6,7 +6,7 @@ "module": "lib/index.js", "type": "module", "peerDependencies": { - "@sub37/server": "^1.0.0" + "@sub37/server": "workspace:^" }, "scripts": { "build": "rm -rf lib && npx tsc -p tsconfig.build.json", diff --git a/packages/captions-renderer/specs/renderer.spec.pw.ts b/packages/captions-renderer/specs/renderer.spec.pw.ts index 290db8a..136def1 100644 --- a/packages/captions-renderer/specs/renderer.spec.pw.ts +++ b/packages/captions-renderer/specs/renderer.spec.pw.ts @@ -143,10 +143,10 @@ STYLE const [bgColor1, bgColor2] = await Promise.all([ regionsLocator - .locator('span[voice="Fred"]') + .locator('span[voice="Fred"] > span') .evaluate((element) => element.style.backgroundColor), regionsLocator - .locator('span[voice="Bill"]') + .locator('span[voice="Bill"] > span') .evaluate((element) => element.style.backgroundColor), ]); @@ -154,25 +154,53 @@ STYLE expect(bgColor2).toBe("blue"); }); -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 ({ +test("An entity wrapping part of a word, should be rendered as such", async ({ page, waitForEvent, seekToSecond, pauseServing, }) => { + /** + * @typedef {import("../../sample/src/customElements/fake-video")} FakeHTMLVideoElement + */ + const TEST_WEBVTT_TRACK = ` WEBVTT -REGION -id:fred -width:40% -lines:3 -regionanchor:0%,100% -viewportanchor:10%,90% -scroll:up +00:00:00.000 --> 00:00:20.000 +I am Fred-ish +`; -00:00:00.000 --> 00:00:20.000 region:fred align:left -Hi, my name is Fred + await Promise.all([ + waitForEvent("playing"), + page.getByRole("textbox", { name: "WEBVTT..." }).fill(TEST_WEBVTT_TRACK), + ]); + + await pauseServing(); + await seekToSecond(3); + + const regionsLocator = page.locator("captions-renderer > main > .region span"); + const evaluation = await regionsLocator.evaluate((element) => + Array.prototype.map.call(element.childNodes, (e: HTMLElement) => e.textContent), + ); + + expect(evaluation[3]).toBe(" -ish"); +}); + +test("A global-style should get applied to all the cues", async ({ + page, + waitForEvent, + seekToSecond, + pauseServing, +}) => { + const peachpuff = `rgb(255, 218, 185)`; + const TEST_WEBVTT_TRACK = ` +WEBVTT + +STYLE +::cue { + color: peachpuff; +} 00:00:02.500 --> 00:00:22.500 align:right Hi, I’m Bill @@ -194,6 +222,59 @@ scroll:up 00:00:12.500 --> 00:00:32.500 region:fred align:left OK, let’s go. +`; + + await Promise.all([ + waitForEvent("playing"), + page.getByRole("textbox", { name: "WEBVTT..." }).fill(TEST_WEBVTT_TRACK), + ]); + + await pauseServing(); + await seekToSecond(3); + + const regionsLocator = page.locator("captions-renderer > main > .region span"); + + const fredLocator = regionsLocator.locator('span[voice="Fred"]'); + const billLocator = regionsLocator.locator('span[voice="Bill"]'); + + expect(fredLocator.isVisible()).toBeTruthy(); + expect(billLocator.isVisible()).toBeTruthy(); + + const [textColorFred, textColorBill] = await Promise.all([ + fredLocator.evaluate((element) => + getComputedStyle(element.children[0]).getPropertyValue("color"), + ), + billLocator.evaluate((element) => + getComputedStyle(element.children[0]).getPropertyValue("color"), + ), + ]); + + expect(textColorFred).toBe(peachpuff); + expect(textColorBill).toBe(peachpuff); +}); + +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 ({ + page, + waitForEvent, + seekToSecond, + pauseServing, +}) => { + const TEST_WEBVTT_TRACK = ` +WEBVTT + +REGION +id:fred +width:40% +lines:3 +regionanchor:0%,100% +viewportanchor:10%,90% +scroll:up + +00:00:00.000 --> 00:00:20.000 region:fred align:left +Hi, my name is Fred + +00:00:02.500 --> 00:00:22.500 region:bill align:right +Hi, I’m Bill `; /** diff --git a/packages/captions-renderer/src/TreeOrchestrator.ts b/packages/captions-renderer/src/TreeOrchestrator.ts index 4cb33f9..ae3565c 100644 --- a/packages/captions-renderer/src/TreeOrchestrator.ts +++ b/packages/captions-renderer/src/TreeOrchestrator.ts @@ -1,5 +1,5 @@ import type { CueNode, Region, RenderingModifiers } from "@sub37/server"; -import { IntervalBinaryTree, Entities } from "@sub37/server"; +import { Entities } from "@sub37/server"; import { CSSVAR_TEXT_COLOR } from "./constants"; const rootElementSymbol = Symbol("to.root.element"); @@ -40,7 +40,7 @@ export interface OrchestratorSettings { const LINES_TRANSITION_TIME_MS = 250; const ROOT_CLASS_NAME = "region"; -const UNIT_REGEX = /\d+\.?\d+?[a-zA-Z%]+$/; +const UNIT_REGEX = /\d+(?:\.\d+)?[a-zA-Z%]+$/; export default class TreeOrchestrator { private static DEFAULT_SETTINGS: OrchestratorSettings = { @@ -150,13 +150,7 @@ export default class TreeOrchestrator { continue; } - const entitiesTree = new IntervalBinaryTree(); - - for (let i = 0; i < cueNode.entities.length; i++) { - entitiesTree.addNode(cueNode.entities[i]); - } - - cues.push(...splitCueNodeByBreakpoints(cueNode, entitiesTree)); + cues.push(...splitCueNodeByBreakpoints(cueNode)); } let latestCueId = ""; @@ -273,112 +267,65 @@ export default class TreeOrchestrator { } } -function splitCueNodeByBreakpoints( - cueNode: CueNode, - entitiesTree: IntervalBinaryTree, -): CueNode[] { +function splitCueNodeByBreakpoints(cueNode: CueNode): CueNode[] { let idVariations = 0; let previousContentBreakIndex: number = 0; const cues: CueNode[] = []; for (let i = 0; i < cueNode.content.length; i++) { - /** - * Reordering because IBT serves nodes from left to right, - * but left nodes are the smallest. In case of a global entity, - * it is inserted as the first node. Hence, it will will result - * as the last entity here. If so, we render wrong elements. - * - * Getting all the current entities and next entities so we can - * check if this is the last character before an entity begin - * (i.e. we have to break). - */ - - const entitiesAtCoordinates = (entitiesTree.getCurrentNodes([i, i + 1]) ?? []).sort( - reorderEntitiesComparisonFn, - ); + if (!shouldCueNodeBreak(cueNode.content, i)) { + continue; + } - if (shouldCueNodeBreak(cueNode.content, entitiesAtCoordinates, i)) { - const content = cueNode.content.slice(previousContentBreakIndex, i + 1).trim(); + const content = cueNode.content.substring(previousContentBreakIndex, i + 1).trim(); - const cue = Object.create(cueNode, { - content: { - value: content, - }, - entities: { - value: entitiesAtCoordinates.filter((entity) => entity.offset <= i), - }, - }); + const cue = Object.create(cueNode, { + content: { + value: content, + }, + }); - if (idVariations > 0) { - cue.id = `${cue.id}/${idVariations}`; - } + if (idVariations > 0) { + cue.id = `${cue.id}/${idVariations}`; + } - /** - * If we detect a new line, we want to force the creation - * of a new line on the next content. So we increase the variation - * so that rendering will break line on a different cue id. - */ + /** + * If we detect a new line, we want to force the creation + * of a new line on the next content. So we increase the variation + * so that rendering will break line on a different cue id. + */ - if (cueNode.content[i] === "\x0A") { - idVariations++; - } + if (cueNode.content[i] === "\x0A") { + idVariations++; + } - cues.push(cue); + cues.push(cue); - previousContentBreakIndex = i + 1; - } + previousContentBreakIndex = i + 1; } return cues; } -function shouldCueNodeBreak( - cueNodeContent: string, - entitiesAtCoordinates: Entities.GenericEntity[], - currentIndex: number, -): boolean { +function shouldCueNodeBreak(cueNodeContent: string, currentIndex: number): boolean { const char = cueNodeContent[currentIndex]; - return ( - isCharacterWhitespace(char) || - indexMatchesEntityBegin(currentIndex, entitiesAtCoordinates) || - indexMatchesEntityEnd(currentIndex, entitiesAtCoordinates) || - isCueContentEnd(cueNodeContent, currentIndex) - ); + return isCharacterWhitespace(char) || isCueContentEnd(cueNodeContent, currentIndex); } function isCharacterWhitespace(char: string): boolean { return char === "\x20" || char === "\x09" || char === "\x0C" || char === "\x0A"; } -function indexMatchesEntityBegin(index: number, entities: Entities.GenericEntity[]): boolean { - if (!entities.length) { - return false; - } - - for (const entity of entities) { - if (index + 1 === entity.offset) { - return true; - } - } - - return false; -} - -function indexMatchesEntityEnd(index: number, entities: Entities.GenericEntity[]): boolean { - if (!entities.length) { - return false; - } - - const lastEntity = entities[entities.length - 1]; - return lastEntity.offset + lastEntity.length === index; -} - function isCueContentEnd(cueNodeContent: string, index: number): boolean { return cueNodeContent.length - 1 === index; } -function commitDOMTree(rootNode: Node, cueSubTreeRoot: Node, diffDepth: number): HTMLElement { +function commitDOMTree( + rootNode: Node | undefined, + cueSubTreeRoot: Node, + diffDepth: number, +): HTMLElement { const root = rootNode || createLine(); addNode(getNodeAtDepth(diffDepth, root.lastChild), cueSubTreeRoot); return root as HTMLElement; @@ -410,43 +357,50 @@ function getNodeAtDepth(index: number, node: Node) { return latestNodePointer; } -function entitiesToDOM(rootNode: Node, ...entities: Entities.GenericEntity[]): Node { +function entitiesToDOM(rootNode: Node, ...entities: Entities.AllEntities[]): Node { const subRoot = new DocumentFragment(); let latestNode: Node = rootNode; - for (let i = entities.length - 1; i >= 0; i--) { - const entity = entities[i]; - - if (entity instanceof Entities.Tag) { - const node = getHTMLElementByEntity(entity); - - if (entity.styles) { - for (const [key, value] of Object.entries(entity.styles) as [string, string][]) { - switch (key) { - case "color": { - /** Otherwise user cannot override the default style and track style */ - node.style.cssText += `${key}:var(${CSSVAR_TEXT_COLOR}, ${value});`; - break; - } - - default: { - node.style.cssText += `${key}:${value};`; - } - } - } + const styleEntities = entities + .filter(Entities.isStyleEntity) + .flatMap((entity) => Object.entries(entity.styles)); + const tagEntities = entities.filter(Entities.isTagEntity); + + if (styleEntities.length) { + const styleNode = document.createElement("span"); + + for (const [key, styleValue] of styleEntities) { + let value: string = styleValue; + + if (key === "color") { + /** Otherwise user cannot override the default style and track style */ + value = `var(${CSSVAR_TEXT_COLOR}, ${styleValue});`; } - node.appendChild(latestNode); - latestNode = node; + styleNode.style.cssText += `${key}:${value}`; } + + styleNode.appendChild(latestNode); + latestNode = styleNode; + } + + for (const entity of tagEntities) { + const node = getHTMLElementByEntity(entity); + + if (!node) { + continue; + } + + node.appendChild(latestNode); + latestNode = node; } subRoot.appendChild(latestNode); return subRoot; } -function getHTMLElementByEntity(entity: Entities.Tag): HTMLElement { - const element: HTMLElement = (() => { +function getHTMLElementByEntity(entity: Entities.TagEntity): HTMLElement | undefined { + const element: HTMLElement | undefined = (() => { switch (entity.tagType) { case Entities.TagType.BOLD: { return document.createElement("b"); @@ -463,22 +417,23 @@ function getHTMLElementByEntity(entity: Entities.Tag): HTMLElement { case Entities.TagType.RUBY: { return document.createElement("ruby"); } - case Entities.TagType.LANG: - case Entities.TagType.VOICE: { - const node = document.createElement("span"); - - for (let [key, value] of entity.attributes) { - node.setAttribute(key, value ? value : ""); - } - - return node; + case Entities.TagType.SPAN: { + return document.createElement("span"); } default: { - return document.createElement("span"); + return undefined; } } })(); + if (!element) { + return undefined; + } + + for (let [key, value] of entity.attributes) { + element.setAttribute(key, value ? value : ""); + } + for (const className of entity.classes) { element.classList.add(className); } @@ -491,6 +446,15 @@ function addNode(node: Node, content: Node): Node { return node; } +/** + * Compares two cues to check where the first not-shared + * entity should be placed in the DOM tree. + * + * @param currentCue + * @param previousCue + * @returns + */ + function getSubtreeFromCueNodes( currentCue: CueNode, previousCue?: CueNode, @@ -503,7 +467,7 @@ function getSubtreeFromCueNodes( return [fragment, 0, textNode]; } - if (!previousCue?.entities.length) { + if (!previousCue?.entities.length || previousCue.id !== currentCue.id) { return [entitiesToDOM(textNode, ...currentCue.entities), currentCue.entities.length, textNode]; } @@ -523,52 +487,20 @@ function getSubtreeFromCueNodes( break; } - const currentCueEntity = currentCue.entities[i]; - const previousCueEntity = previousCue.entities[i]; + const currentCueEntity = currentCue.entities[i] as Entities.TagEntity; + const previousCueEntity = previousCue.entities[i] as Entities.TagEntity; if ( - currentCueEntity.length !== previousCueEntity.length || - currentCueEntity.offset !== previousCueEntity.offset + currentCueEntity.type !== previousCueEntity.type || + currentCueEntity.tagType !== previousCueEntity.tagType ) { break; } } - if (firstDifferentEntityIndex >= currentCue.entities.length) { - /** We already reached that depth */ - const fragment = new DocumentFragment(); - fragment.appendChild(textNode); - return [fragment, firstDifferentEntityIndex, textNode]; - } - return [ entitiesToDOM(textNode, ...currentCue.entities.slice(firstDifferentEntityIndex)), firstDifferentEntityIndex, textNode, ]; } - -function reorderEntitiesComparisonFn(e1: Entities.GenericEntity, e2: Entities.GenericEntity) { - if (e1.offset < e2.offset) { - /** e1 starts before e2 */ - return -1; - } - - /** - * The condition `e1.offset > e2.offset` is not possible. - * Otherwise there would be an issue with parser. Tags open - * and close like onions. Hence, here we have `e1.offset == e2.offset` - */ - - if (e1.length < e2.length) { - /** e1 ends before e2, so it must be set last */ - return 1; - } - - if (e1.length > e2.length) { - /** e2 ends before e1, so it must be set first */ - return -1; - } - - return 0; -} diff --git a/packages/sample/package.json b/packages/sample/package.json index 5916b2d..95dad0c 100644 --- a/packages/sample/package.json +++ b/packages/sample/package.json @@ -8,7 +8,7 @@ "build": "pnpm vite build" }, "devDependencies": { - "typescript": "^5.3.3", + "typescript": "^5.4.5", "vite": "^5.1.1", "vite-plugin-checker": "^0.6.4", "vite-tsconfig-paths": "^4.3.1" diff --git a/packages/sample/pages/sub37-example/index.html b/packages/sample/pages/sub37-example/index.html index ef3867c..8240131 100644 --- a/packages/sample/pages/sub37-example/index.html +++ b/packages/sample/pages/sub37-example/index.html @@ -16,12 +16,16 @@ -
+

Content type:

+
+ + +
diff --git a/packages/sample/pages/sub37-example/script.mjs b/packages/sample/pages/sub37-example/script.mjs index dc01962..22aa88c 100644 --- a/packages/sample/pages/sub37-example/script.mjs +++ b/packages/sample/pages/sub37-example/script.mjs @@ -1,10 +1,12 @@ import "@sub37/captions-renderer"; import { Server } from "@sub37/server"; import { WebVTTAdapter } from "@sub37/webvtt-adapter"; +import { TTMLAdapter } from "@sub37/ttml-adapter"; import longTextTrackVTTPath from "../../src/longtexttrack.vtt"; import longTextTrackVTTPathChunk from "../../src/longtexttrack-chunk1.vtt"; import "../../src/components/customElements/scheduled-textarea"; import "../../src/components/customElements/fake-video"; +import "../../src/components/customElements/processorSelector"; /** * @typedef {import("../../src/components/customElements/fake-video").FakeHTMLVideoElement} FakeHTMLVideoElement @@ -22,7 +24,7 @@ const defaultTrackLoadBtn = document.getElementById("load-default-track"); * @type {Server} */ -const server = new Server(WebVTTAdapter); +const server = new Server(WebVTTAdapter, TTMLAdapter); /** * Instance to let tests access to the server instance @@ -91,7 +93,7 @@ defaultTrackLoadBtn.addEventListener("click", async () => { // 00:00:04.000 --> 00:10:00.000 region:fred align:left // Hello world, bibi - document.querySelector('input[name="caption-type"][id="webvtt"]').setAttribute("checked", true); + document.forms["content-type"].elements.webvtt.checked = true; defaultTrackLoadBtn.disabled = true; const [vttTrack, vttChunk] = await Promise.all([ @@ -147,7 +149,7 @@ videoTag.addEventListener("pause", () => { } }); -scheduledTextArea.addEventListener("commit", async ({ detail: vttTrack }) => { +scheduledTextArea.addEventListener("commit", async ({ detail: track }) => { const contentMimeType = document.forms["content-type"].elements["caption-type"].value; const timeStart = performance.now(); @@ -162,17 +164,17 @@ scheduledTextArea.addEventListener("commit", async ({ detail: vttTrack }) => { await Promise.resolve(); - server.createSession( - [ - { - lang: "any", - content: vttTrack, - mimeType: "text/vtt", - active: true, - }, - ], - contentMimeType, - ); + const isWebVTTTrackSelected = contentMimeType === "text/vtt"; + const isTTMLTrackSelected = contentMimeType === "application/ttml+xml"; + + server.createSession([ + { + lang: "any", + content: track, + mimeType: contentMimeType, + active: true, + }, + ]); console.info( `%c[DEBUG] Track parsing took: ${performance.now() - timeStart}ms`, "background-color: #af0000; color: #FFF; padding: 5px; margin: 5px", diff --git a/packages/sample/src/components/customElements/index.ts b/packages/sample/src/components/customElements/index.ts index f1d024d..e249d82 100644 --- a/packages/sample/src/components/customElements/index.ts +++ b/packages/sample/src/components/customElements/index.ts @@ -1,2 +1,3 @@ export * from "./fake-video/index.js"; export * from "./scheduled-textarea/index.js"; +export * from "./processorSelector/index.js"; diff --git a/packages/sample/src/components/customElements/processorSelector/index.ts b/packages/sample/src/components/customElements/processorSelector/index.ts new file mode 100644 index 0000000..4b74661 --- /dev/null +++ b/packages/sample/src/components/customElements/processorSelector/index.ts @@ -0,0 +1,33 @@ +export class ProcessorSelectorElement extends HTMLFormElement { + private preferenceStorer = createPreferenceStorer("processor"); + + constructor() { + super(); + + this.addEventListener("change", (e) => { + if ((e.target as HTMLInputElement).getAttribute("type") === "radio") { + this.preferenceStorer.save((e.target as HTMLElement).id); + } + }); + + const childrenInputs = Array.from(this.querySelectorAll("input")) as HTMLInputElement[]; + + if (childrenInputs.length) { + const currentSelectedElement = this.preferenceStorer.get(); + childrenInputs.find((e) => e.id === currentSelectedElement)?.setAttribute("checked", "true"); + } + } +} + +function createPreferenceStorer(key: string) { + return { + save(value: string) { + window.localStorage.setItem(key, value); + }, + get() { + return window.localStorage.getItem(key); + }, + }; +} + +window.customElements.define("processor-selector", ProcessorSelectorElement, { extends: "form" }); diff --git a/packages/server/package.json b/packages/server/package.json index 55850a5..8b030b1 100644 --- a/packages/server/package.json +++ b/packages/server/package.json @@ -5,7 +5,7 @@ "main": "lib/index.js", "type": "module", "devDependencies": { - "typescript": "^5.3.3" + "typescript": "^5.4.5" }, "scripts": { "build": "rm -rf lib && pnpm tsc -p tsconfig.build.json", diff --git a/packages/server/specs/Entities.spec.mjs b/packages/server/specs/Entities.spec.mjs index 2efa5cd..4b51839 100644 --- a/packages/server/specs/Entities.spec.mjs +++ b/packages/server/specs/Entities.spec.mjs @@ -3,28 +3,12 @@ import { describe, it, expect } from "@jest/globals"; import { Entities } from "../lib/index.js"; describe("Tag entities", () => { - describe("Setting styles", () => { - it("should return empty object if not a string or an object", () => { - const entity = new Entities.Tag({ - attributes: new Map(), - classes: [], - length: 0, - offset: 0, - tagType: Entities.TagType.BOLD, - }); + it("Building a tag entity, should not alter the properties", () => { + const entity = Entities.createTagEntity(Entities.TagType.BOLD, new Map()); - // @ts-expect-error - entity.setStyles(); - - entity.setStyles(undefined); - - // @ts-expect-error - entity.setStyles(null); - - // @ts-expect-error - entity.setStyles(0); - - expect(entity.styles).toEqual({}); - }); + expect(entity.attributes).toEqual(new Map()); + expect(entity.classes).toEqual([]); + expect(entity.tagType).toBe(Entities.TagType.BOLD); + expect(entity.type).toBe(1); }); }); diff --git a/packages/server/specs/IntervalBinaryTree.spec.mjs b/packages/server/specs/IntervalBinaryTree.spec.mjs index ca49e5f..bbe39c4 100644 --- a/packages/server/specs/IntervalBinaryTree.spec.mjs +++ b/packages/server/specs/IntervalBinaryTree.spec.mjs @@ -202,7 +202,7 @@ describe("IntervalBinaryTree", () => { cueNodeToTreeLeaf( new CueNode({ id: "any", - content: "A test content", + content: "A test content 1", startTime: 11000, endTime: 12000, }), @@ -214,7 +214,7 @@ describe("IntervalBinaryTree", () => { cueNodeToTreeLeaf( new CueNode({ id: "any", - content: "A test content", + content: "A test content 2", startTime: 3000, endTime: 10000, }), @@ -226,7 +226,7 @@ describe("IntervalBinaryTree", () => { cueNodeToTreeLeaf( new CueNode({ id: "any", - content: "A test content", + content: "A test content 3", startTime: 12000, endTime: 15000, }), @@ -238,7 +238,7 @@ describe("IntervalBinaryTree", () => { cueNodeToTreeLeaf( new CueNode({ id: "any", - content: "A test content", + content: "A test content 4", startTime: 0, endTime: 5000, }), @@ -250,7 +250,7 @@ describe("IntervalBinaryTree", () => { cueNodeToTreeLeaf( new CueNode({ id: "any", - content: "A test content", + content: "A test content 5", startTime: 5000, endTime: 9000, }), @@ -262,7 +262,7 @@ describe("IntervalBinaryTree", () => { cueNodeToTreeLeaf( new CueNode({ id: "any", - content: "A test content", + content: "A test content 6", startTime: 12000, endTime: 13000, }), @@ -274,7 +274,7 @@ describe("IntervalBinaryTree", () => { cueNodeToTreeLeaf( new CueNode({ id: "any", - content: "A test content", + content: "A test content 7", startTime: 13000, endTime: 15000, }), @@ -285,53 +285,117 @@ describe("IntervalBinaryTree", () => { expect(query.length).toBe(7); - expect(query).toEqual([ - // left - new CueNode({ - id: "any", - content: "A test content", - startTime: 0, - endTime: 5000, - }), - new CueNode({ - id: "any", - content: "A test content", - startTime: 3000, - endTime: 10000, - }), - new CueNode({ - id: "any", - content: "A test content", - startTime: 5000, - endTime: 9000, - }), - // Root - new CueNode({ - id: "any", - content: "A test content", - startTime: 11000, - endTime: 12000, - }), - // right - new CueNode({ - id: "any", - content: "A test content", - startTime: 12000, - endTime: 13000, - }), - new CueNode({ - id: "any", - content: "A test content", - startTime: 12000, - endTime: 15000, - }), - new CueNode({ - id: "any", - content: "A test content", - startTime: 13000, - endTime: 15000, - }), - ]); + /** LEFT NODES */ + + expect(query[0]).toMatchObject({ + content: "A test content 4", + startTime: 0, + endTime: 5000, + }); + + expect(query[1]).toMatchObject({ + content: "A test content 2", + startTime: 3000, + endTime: 10000, + }); + + expect(query[2]).toMatchObject({ + content: "A test content 5", + startTime: 5000, + endTime: 9000, + }); + + /** ROOT NODE */ + + expect(query[3]).toMatchObject({ + content: "A test content 1", + startTime: 11000, + endTime: 12000, + }); + + /** RIGHT NODES */ + + expect(query[4]).toMatchObject({ + content: "A test content 3", + startTime: 12000, + endTime: 15000, + }); + + expect(query[5]).toMatchObject({ + content: "A test content 6", + startTime: 12000, + endTime: 13000, + }); + + expect(query[6]).toMatchObject({ + content: "A test content 7", + startTime: 13000, + endTime: 15000, + }); + }); + + it("should always return a node that has Infinity as high, once it entered", () => { + /** Root */ + tree.addNode( + cueNodeToTreeLeaf( + new CueNode({ + id: "any", + content: "A test content", + startTime: 0, + endTime: 1000, + }), + ), + ); + + /** Adding on left */ + tree.addNode( + cueNodeToTreeLeaf( + new CueNode({ + id: "any", + content: "A test content", + startTime: 500, + endTime: Infinity, + }), + ), + ); + + /** Adding on right */ + tree.addNode( + cueNodeToTreeLeaf( + new CueNode({ + id: "any", + content: "A test content", + startTime: 1000, + endTime: 3000, + }), + ), + ); + + /** Adding on left's left */ + tree.addNode( + cueNodeToTreeLeaf( + new CueNode({ + id: "any", + content: "A test content", + startTime: 2500, + endTime: 5000, + }), + ), + ); + + const query1 = tree.getCurrentNodes(0); + const query2 = tree.getCurrentNodes(500); + const query3 = tree.getCurrentNodes(1000); + const query4 = tree.getCurrentNodes(2500); + const query5 = tree.getCurrentNodes(5000); + const query6 = tree.getCurrentNodes(6000); + + expect(query1?.length).toBe(1); + expect(query2?.length).toBe(2); + expect(query3?.length).toBe(3); + expect(query4?.length).toBe(3); + expect(query5?.length).toBe(2); + expect(query6?.length).toBe(1); }); }); diff --git a/packages/server/src/CueNode.ts b/packages/server/src/CueNode.ts index aa049fb..1a03a0c 100644 --- a/packages/server/src/CueNode.ts +++ b/packages/server/src/CueNode.ts @@ -12,7 +12,7 @@ interface CueProps { endTime: number; content: string; renderingModifiers?: RenderingModifiers; - entities?: Entities.GenericEntity[]; + entities?: Entities.AllEntities[]; region?: Region; } @@ -43,7 +43,7 @@ export class CueNode implements CueProps, Leafable { public renderingModifiers?: RenderingModifiers; private [regionSymbol]?: Region; - private [entitiesSymbol]: Entities.GenericEntity[] = []; + private [entitiesSymbol]: Entities.AllEntities[] = []; constructor(data: CueProps) { this.id = data.id; @@ -58,17 +58,17 @@ export class CueNode implements CueProps, Leafable { } } - public get entities(): Entities.GenericEntity[] { + public get entities(): Entities.AllEntities[] { return this[entitiesSymbol]; } - public set entities(value: Entities.GenericEntity[]) { + public set entities(value: Entities.AllEntities[]) { /** * Reordering cues entities for a later reconciliation * in captions renderer */ - this[entitiesSymbol] = value.sort(reorderEntitiesComparisonFn); + this[entitiesSymbol] = value; } public set region(value: Region) { @@ -101,7 +101,3 @@ export class CueNode implements CueProps, Leafable { }; } } - -function reorderEntitiesComparisonFn(e1: Entities.GenericEntity, e2: Entities.GenericEntity) { - return e1.offset <= e2.offset ? -1 : 1; -} diff --git a/packages/server/src/Entities/Generic.ts b/packages/server/src/Entities/Generic.ts deleted file mode 100644 index 7887038..0000000 --- a/packages/server/src/Entities/Generic.ts +++ /dev/null @@ -1,33 +0,0 @@ -import type { IntervalBinaryLeaf, Leafable } from "../IntervalBinaryTree"; - -export const enum Type { - STYLE, - TAG, -} - -export class GenericEntity implements Leafable { - public offset: number; - public length: number; - public type: Type; - - public constructor(type: Type, offset: number, length: number) { - this.offset = offset; - this.length = length; - this.type = type; - } - - public toLeaf(): IntervalBinaryLeaf { - return { - left: null, - right: null, - node: this, - max: this.offset + this.length, - get high() { - return this.node.offset + this.node.length; - }, - get low() { - return this.node.offset; - }, - }; - } -} diff --git a/packages/server/src/Entities/Style.ts b/packages/server/src/Entities/Style.ts new file mode 100644 index 0000000..ca1a1d8 --- /dev/null +++ b/packages/server/src/Entities/Style.ts @@ -0,0 +1,45 @@ +import { type EntityProtocol, Type } from "./index.js"; + +export interface StyleEntity extends EntityProtocol { + readonly type: Type.STYLE; + readonly styles: Record; +} + +export function createStyleEntity(stylesSource: string | Record): StyleEntity { + const styles = getKeyValueFromCSSRawDeclarations(stylesSource); + + return { + type: Type.STYLE, + styles, + }; +} + +function getKeyValueFromCSSRawDeclarations( + declarationsRaw: string | Record, +): Record { + if (typeof declarationsRaw !== "string" && typeof declarationsRaw !== "object") { + return {}; + } + + if (typeof declarationsRaw === "object") { + return declarationsRaw; + } + + const stylesObject: { [key: string]: string } = {}; + const declarations = declarationsRaw.split(/\s*;\s*/); + + for (const declaration of declarations) { + if (!declaration.length) { + continue; + } + + const [key, value] = declaration.split(/\s*:\s*/); + stylesObject[key] = value; + } + + return stylesObject; +} + +export function isStyleEntity(entity: EntityProtocol): entity is StyleEntity { + return entity.type === Type.STYLE; +} diff --git a/packages/server/src/Entities/Tag.ts b/packages/server/src/Entities/Tag.ts index c13408f..a305d03 100644 --- a/packages/server/src/Entities/Tag.ts +++ b/packages/server/src/Entities/Tag.ts @@ -1,71 +1,41 @@ -import { GenericEntity, Type } from "./Generic.js"; +import { EntityProtocol, Type } from "./index.js"; /** - * TagType is an enum containing - * recognized types in adapters - * like vtt + * TagType includes only the common tag types that + * could or will show in adapters. + * + * If missing, "span" should be used. */ export enum TagType { - SPAN /********/ = 0b00000000, - VOICE /*******/ = 0b00000001, - LANG /********/ = 0b00000010, - RUBY /********/ = 0b00000100, - RT /**********/ = 0b00001000, - CLASS /*******/ = 0b00010000, - BOLD /********/ = 0b00100000, - ITALIC /******/ = 0b01000000, - UNDERLINE /***/ = 0b10000000, + SPAN /********/ = "span", + RUBY /********/ = "ruby", + RT /**********/ = "rt", + BOLD /********/ = "b", + ITALIC /******/ = "i", + UNDERLINE /***/ = "u", } -export class Tag extends GenericEntity { - public tagType: TagType; - public attributes: Map; - public classes: string[]; - public styles?: { [key: string]: string }; - - public constructor(params: { - offset: number; - length: number; - tagType: TagType; - attributes: Map; - styles?: Tag["styles"]; - classes: Tag["classes"]; - }) { - super(Type.TAG, params.offset, params.length); - - this.tagType = params.tagType; - this.attributes = params.attributes; - this.styles = params.styles || {}; - this.classes = params.classes || []; - } - - public setStyles(styles: string | Tag["styles"]): void { - const declarations = getKeyValueFromCSSRawDeclarations(styles); - Object.assign(this.styles, declarations); - } +export interface TagEntity extends EntityProtocol { + type: Type.TAG; + tagType: TagType; + attributes: Map; + classes: string[]; } -function getKeyValueFromCSSRawDeclarations(declarationsRaw: string | object): object { - if (typeof declarationsRaw !== "string" && typeof declarationsRaw !== "object") { - return {}; - } - - if (typeof declarationsRaw === "object") { - return declarationsRaw; - } - - const stylesObject: { [key: string]: string } = {}; - const declarations = declarationsRaw.split(/\s*;\s*/); - - for (const declaration of declarations) { - if (!declaration.length) { - continue; - } - - const [key, value] = declaration.split(/\s*:\s*/); - stylesObject[key] = value; - } +export function createTagEntity( + tagType: TagType, + attributes: Map, + classes: string[] = [], +): TagEntity { + return { + type: Type.TAG, + tagType, + attributes, + classes, + }; +} - return stylesObject; +export function isTagEntity(entity: EntityProtocol): entity is TagEntity { + return entity.type === Type.TAG; } diff --git a/packages/server/src/Entities/index.ts b/packages/server/src/Entities/index.ts index 217edac..f1809ea 100644 --- a/packages/server/src/Entities/index.ts +++ b/packages/server/src/Entities/index.ts @@ -1,2 +1,16 @@ +import { type StyleEntity } from "./Style.js"; +import { type TagEntity } from "./Tag.js"; + export * from "./Tag.js"; -export * from "./Generic.js"; +export * from "./Style.js"; + +export const enum Type { + STYLE, + TAG, +} + +export interface EntityProtocol { + type: Type; +} + +export type AllEntities = StyleEntity | TagEntity; diff --git a/packages/server/src/IntervalBinaryTree.ts b/packages/server/src/IntervalBinaryTree.ts index 3b76094..70bbb3b 100644 --- a/packages/server/src/IntervalBinaryTree.ts +++ b/packages/server/src/IntervalBinaryTree.ts @@ -72,7 +72,7 @@ function insert( return node; } - if (node.low <= root.low) { + if (node.low < root.low) { root.left = insert(root.left, node); } else { root.right = insert(root.right, node); diff --git a/packages/ttml-adapter/package.json b/packages/ttml-adapter/package.json new file mode 100644 index 0000000..24b81cf --- /dev/null +++ b/packages/ttml-adapter/package.json @@ -0,0 +1,28 @@ +{ + "name": "@sub37/ttml-adapter", + "version": "1.0.0", + "description": "TTML Adapter for sub37 subtitles system", + "main": "lib/index.js", + "type": "module", + "peerDependencies": { + "@sub37/server": "workspace:^" + }, + "scripts": { + "build": "rm -rf lib && npx tsc -p tsconfig.build.json", + "test": "npm run build && npm --prefix ../.. run test", + "prepublishOnly": "npm run build" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/alexandercerutti/sub37.git" + }, + "author": "Alexander P. Cerutti ", + "license": "MIT", + "bugs": { + "url": "https://github.com/alexandercerutti/sub37/issues" + }, + "homepage": "https://github.com/alexandercerutti/sub37#readme", + "files": [ + "lib/**/*.+(js|d.ts)!(*.map)" + ] +} diff --git a/packages/ttml-adapter/specs/Adapter.spec.mjs b/packages/ttml-adapter/specs/Adapter.spec.mjs new file mode 100644 index 0000000..6f7deb9 --- /dev/null +++ b/packages/ttml-adapter/specs/Adapter.spec.mjs @@ -0,0 +1,487 @@ +import { describe, it, expect } from "@jest/globals"; +import TTMLAdapter from "../lib/Adapter.js"; + +/** + * - A document without tt, is not considered a valid document + * + * - An XML that does not contain a element, should throw an error + * + * - A style tag gets correctly inherited by another style tag + * - A style tag gets correctly inherited by a style tag inside a region + * - A style tag places inside a region should not get inherited + * - A style tag with an already available id should get a new one (todo: test tree equals --1, --2, --3) + * + * - A region tag should be parsed correctly only in layout/head and if it both a self-closing tag or not. + * - A style tag should be parsed correctly only in stylings and if it both a self-closing tag or not. + * + * - Timings: + * - On an element specifying both "dur" and "end", should win the + * Math.min(dur, end - begin). Test both cases. + * - `referenceBegin` on `media` with par and seq + */ + +// https://developer.mozilla.org/en-US/docs/Related/IMSC/Subtitle_placement + +describe("Adapter", () => { + it("should throw if the track does not start with a element", () => { + const adapter = new TTMLAdapter(); + expect(() => { + adapter.parse(` + + + + +
+

Some Content that won't be used

+
+ + `); + }).toThrowError(); + }); + + describe("Cues", () => { + describe("Timing semantics", () => { + describe("Region timing inheritance", () => { + it("should let cue inherit them from an out-of-line region", () => { + const adapter = new TTMLAdapter(); + const { data: cues } = adapter.parse(` + + + + + + + + + +
+

+ + Test cue r1 +

+

+ + Test cue r2 +

+

+ + Test cue r3 +

+
+ +
+ `); + + expect(cues.length).toBe(3); + expect(cues[0]).toMatchObject({ + startTime: 3000, + endTime: 5000, + }); + expect(cues[1]).toMatchObject({ + startTime: 3000, + endTime: 8000, + }); + expect(cues[2]).toMatchObject({ + startTime: 0, + endTime: 5000, + }); + }); + + it("should not let cue inherit them from an inline region", () => { + const adapter = new TTMLAdapter(); + + const { data: cues } = adapter.parse(` + + +
+ +

+ + Paragraph flowed inside r1 +

+
+ +
+ `); + + expect(cues.length).toBe(1); + expect(cues[0]).toMatchObject({ + content: "Paragraph flowed inside r1", + startTime: 0, + endTime: Infinity, + }); + }); + }); + + describe("ttp:timeBase 'media'", () => { + it("should emit a cue from anonymous span with indefinite active duration when sequential parent doesn't specify a duration", () => { + const adapter = new TTMLAdapter(); + const { data: cues } = adapter.parse(` + + +
+

Test cue

+
+ +
+ `); + + expect(cues.length).toBe(1); + expect(cues[0]).toMatchObject({ + startTime: 0, + endTime: Infinity, + }); + }); + + it("should emit a cue from anonymous span with active duration of 0s when parent is sequential", () => { + const adapter = new TTMLAdapter(); + const { data: cues } = adapter.parse(` + + +
+

+ Test cue 1 +

+
+ +
+ `); + + expect(cues.length).toBe(1); + expect(cues[0]).toMatchObject({ + startTime: 0, + endTime: 0, + }); + }); + + it("should emit a cue from anonymous span with active duration of 3s when parent is sequential", () => { + const adapter = new TTMLAdapter(); + const { data: cues } = adapter.parse(` + + +
+

Test cue 1

+
+ +
+ `); + + expect(cues.length).toBe(1); + expect(cues[0]).toMatchObject({ + startTime: 0, + endTime: 3000, + }); + }); + }); + }); + + describe("Region attribute chain", () => { + it("should emit an element nested inside a parent when both have the same region attribute", () => {}); + + it("should not emit an element containing an inline region, nested inside a parent with a region attribute", () => { + { + /** + * Testing flowed inside region r1 and + *

flowed inside inline region + */ + + const adapter = new TTMLAdapter(); + + const { data: cues } = adapter.parse( + ` + + + + + + + +

+

+ + Unemitted cue +

+
+
+

+ +