diff --git a/package.json b/package.json index 91947e1..0031405 100644 --- a/package.json +++ b/package.json @@ -97,7 +97,7 @@ }, "dependencies": { "@shotstack/schemas": "1.9.3", - "@shotstack/shotstack-canvas": "^2.1.10", + "@shotstack/shotstack-canvas": "^2.1.12", "howler": "^2.2.4", "mediabunny": "^1.11.2", "opentype.js": "^1.3.4", diff --git a/src/components/canvas/players/rich-caption-player.ts b/src/components/canvas/players/rich-caption-player.ts index a0befb1..a27396b 100644 --- a/src/components/canvas/players/rich-caption-player.ts +++ b/src/components/canvas/players/rich-caption-player.ts @@ -11,11 +11,11 @@ import { generateRichCaptionFrame, createDefaultGeneratorConfig, createWebPainter, + buildCaptionLayoutConfig, parseSubtitleToWords, CanvasRichCaptionAssetSchema, type CanvasRichCaptionAsset, type CaptionLayout, - type CaptionLayoutConfig, type RichCaptionGeneratorConfig, type WordTiming } from "@shotstack/shotstack-canvas"; @@ -217,8 +217,9 @@ export class RichCaptionPlayer extends Player { this.validatedAsset = canvasValidation.data; const { width, height } = this.getSize(); - const layoutConfig = this.buildLayoutConfig(this.validatedAsset, width, height); - const canvasTextMeasurer = this.createCanvasTextMeasurer(); + const layoutConfig = buildCaptionLayoutConfig(this.validatedAsset, width, height); + const letterSpacing = this.validatedAsset?.style?.letterSpacing; + const canvasTextMeasurer = this.createCanvasTextMeasurer(letterSpacing); if (canvasTextMeasurer) { layoutConfig.measureTextWidth = canvasTextMeasurer; } @@ -250,9 +251,10 @@ export class RichCaptionPlayer extends Player { this.layoutEngine = new CaptionLayoutEngine(this.fontRegistry); const { width, height } = this.getSize(); - const layoutConfig = this.buildLayoutConfig(this.validatedAsset, width, height); + const layoutConfig = buildCaptionLayoutConfig(this.validatedAsset, width, height); - const canvasTextMeasurer = this.createCanvasTextMeasurer(); + const letterSpacing = this.validatedAsset?.style?.letterSpacing; + const canvasTextMeasurer = this.createCanvasTextMeasurer(letterSpacing); if (canvasTextMeasurer) { layoutConfig.measureTextWidth = canvasTextMeasurer; } @@ -500,51 +502,16 @@ export class RichCaptionPlayer extends Player { return payload; } - private buildLayoutConfig(asset: CanvasRichCaptionAsset, frameWidth: number, frameHeight: number): CaptionLayoutConfig { - const { font, style, align, padding: rawPadding } = asset; - - let padding: { top: number; right: number; bottom: number; left: number }; - if (typeof rawPadding === "number") { - padding = { top: rawPadding, right: rawPadding, bottom: rawPadding, left: rawPadding }; - } else if (rawPadding) { - const p = rawPadding as { top?: number; right?: number; bottom?: number; left?: number }; - padding = { top: p.top ?? 0, right: p.right ?? 0, bottom: p.bottom ?? 0, left: p.left ?? 0 }; - } else { - padding = { top: 0, right: 0, bottom: 0, left: 0 }; - } - - const totalHorizontalPadding = padding.left + padding.right; - const availableWidth = totalHorizontalPadding > 0 ? frameWidth - totalHorizontalPadding : frameWidth * 0.9; - - const fontSize = font?.size ?? 24; - const lineHeight = style?.lineHeight ?? 1.2; - const availableHeight = frameHeight - padding.top - padding.bottom; - const maxLines = Math.max(1, Math.min(10, Math.floor(availableHeight / (fontSize * lineHeight)))); - - return { - frameWidth, - frameHeight, - availableWidth, - maxLines, - verticalAlign: align?.vertical ?? "middle", - horizontalAlign: align?.horizontal ?? "center", - padding, - fontSize, - fontFamily: font?.family ?? "Roboto", - fontWeight: String(font?.weight ?? "400"), - letterSpacing: style?.letterSpacing ?? 0, - lineHeight, - textTransform: (style?.textTransform as CaptionLayoutConfig["textTransform"]) ?? "none", - pauseThreshold: this.resolvedPauseThreshold - }; - } - - private createCanvasTextMeasurer(): ((text: string, font: string) => number) | undefined { + private createCanvasTextMeasurer(letterSpacing?: number): ((text: string, font: string) => number) | undefined { try { const measureCanvas = document.createElement("canvas"); const ctx = measureCanvas.getContext("2d"); if (!ctx) return undefined; + if (letterSpacing) { + (ctx as unknown as Record)["letterSpacing"] = `${letterSpacing}px`; + } + return (text: string, font: string): number => { ctx.font = font; return ctx.measureText(text).width; @@ -684,8 +651,9 @@ export class RichCaptionPlayer extends Player { if (!this.layoutEngine) return; - const layoutConfig = this.buildLayoutConfig(this.validatedAsset, width, height); - const canvasTextMeasurer = this.createCanvasTextMeasurer(); + const layoutConfig = buildCaptionLayoutConfig(this.validatedAsset, width, height); + const letterSpacing = this.validatedAsset?.style?.letterSpacing; + const canvasTextMeasurer = this.createCanvasTextMeasurer(letterSpacing); if (canvasTextMeasurer) { layoutConfig.measureTextWidth = canvasTextMeasurer; } diff --git a/tests/rich-caption-player.test.ts b/tests/rich-caption-player.test.ts index 55564c9..fffc2b7 100644 --- a/tests/rich-caption-player.test.ts +++ b/tests/rich-caption-player.test.ts @@ -190,6 +190,43 @@ jest.mock("@shotstack/shotstack-canvas", () => { }), createWebPainter: (...args: unknown[]) => mockCreateWebPainter(...args), parseSubtitleToWords: (...args: unknown[]) => mockParseSubtitleToWords(...args), + buildCaptionLayoutConfig: (asset: Record, frameWidth: number, frameHeight: number) => { + const rawPadding = asset['padding'] as number | { top?: number; right?: number; bottom?: number; left?: number } | undefined; + let padding: { top: number; right: number; bottom: number; left: number }; + if (typeof rawPadding === "number") { + padding = { top: rawPadding, right: rawPadding, bottom: rawPadding, left: rawPadding }; + } else if (rawPadding) { + padding = { top: rawPadding.top ?? 0, right: rawPadding.right ?? 0, bottom: rawPadding.bottom ?? 0, left: rawPadding.left ?? 0 }; + } else { + padding = { top: 0, right: 0, bottom: 0, left: 0 }; + } + const font = asset['font'] as { size?: number; family?: string; weight?: number | string } | undefined; + const style = asset['style'] as { letterSpacing?: number; lineHeight?: number; textTransform?: string } | undefined; + const align = asset['align'] as { vertical?: string; horizontal?: string } | undefined; + const fontSize = font?.size ?? 24; + const lineHeight = style?.lineHeight ?? 1.2; + const availableWidth = frameWidth - padding.left - padding.right; + const availableHeight = frameHeight - padding.top - padding.bottom; + const maxLines = Math.max(1, Math.min(10, Math.floor(availableHeight / (fontSize * lineHeight)))); + const vertical = align?.vertical; + const horizontal = align?.horizontal; + return { + frameWidth, + frameHeight, + availableWidth, + maxLines, + verticalAlign: (() => { if (vertical === "top") return "top"; if (vertical === "middle") return "middle"; return "bottom"; })(), + horizontalAlign: (() => { if (horizontal === "left") return "left"; if (horizontal === "right") return "right"; return "center"; })(), + padding, + fontSize, + fontFamily: font?.family ?? "Roboto", + fontWeight: String(font?.weight ?? "400"), + letterSpacing: style?.letterSpacing ?? 0, + lineHeight, + textTransform: style?.textTransform ?? "none", + pauseThreshold: (asset['pauseThreshold'] as number) ?? 500, + }; + }, CanvasRichCaptionAssetSchema: { safeParse: jest.fn().mockImplementation((asset: unknown) => ({ success: true, @@ -1324,7 +1361,7 @@ describe("RichCaptionPlayer", () => { await player.load(); const layoutConfig = mockLayoutCaption.mock.calls[0]?.[1]; - expect(layoutConfig.availableWidth).toBe(1920 * 0.9); + expect(layoutConfig.availableWidth).toBe(1920); expect(layoutConfig.padding).toEqual({ top: 0, right: 0, bottom: 0, left: 0 }); });