From 1e27be645c8c17f60781d6ffbe63d3d2a003a1dc Mon Sep 17 00:00:00 2001 From: Kratos2k7 Date: Wed, 11 Mar 2026 14:31:52 +0500 Subject: [PATCH 01/25] =?UTF-8?q?fix:=20The=20pauseThreshold=20was=20being?= =?UTF-8?q?=20mutated=20directly=20onto=20the=20asset=20object=20(richCapt?= =?UTF-8?q?ionAsset['pauseThreshold']=20=3D=205),=20but=20the=20SDK's=20?= =?UTF-8?q?=20=20=20=20=20=20ResolvedClipSchema=20uses=20@shotstack/schema?= =?UTF-8?q?s=20which=20has=20.strict()=20mode=20=E2=80=94=20rejecting=20un?= =?UTF-8?q?known=20keys.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/canvas/players/rich-caption-player.ts | 7 ++++--- tests/rich-caption-player.test.ts | 3 ++- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/src/components/canvas/players/rich-caption-player.ts b/src/components/canvas/players/rich-caption-player.ts index 6950187..00d37d7 100644 --- a/src/components/canvas/players/rich-caption-player.ts +++ b/src/components/canvas/players/rich-caption-player.ts @@ -42,6 +42,7 @@ export class RichCaptionPlayer extends Player { private readonly fontRegistrationCache = new Map>(); private lastRegisteredFontKey: string = ""; private pendingLayoutId: number = 0; + private resolvedPauseThreshold: number = 500; constructor(edit: Edit, clipConfiguration: ResolvedClip) { const { fit, ...configWithoutFit } = clipConfiguration; @@ -63,7 +64,7 @@ export class RichCaptionPlayer extends Player { let words: WordTiming[]; if (richCaptionAsset.src) { words = await this.fetchAndParseSubtitle(richCaptionAsset.src); - (richCaptionAsset as Record)['pauseThreshold'] = 5; + this.resolvedPauseThreshold = 5; } else { words = ((richCaptionAsset as RichCaptionAsset & { words?: WordTiming[] }).words ?? []).map((w: WordTiming) => ({ text: w.text, @@ -396,7 +397,7 @@ export class RichCaptionPlayer extends Player { style: asset.style, wordAnimation: asset.wordAnimation, align: asset.align, - pauseThreshold: (asset as Record)['pauseThreshold'], + pauseThreshold: this.resolvedPauseThreshold, }; for (const [key, value] of Object.entries(optionalFields)) { @@ -431,7 +432,7 @@ export class RichCaptionPlayer extends Player { wordSpacing: typeof style?.wordSpacing === "number" ? style.wordSpacing : 0, lineHeight: style?.lineHeight ?? 1.2, textTransform: (style?.textTransform as CaptionLayoutConfig["textTransform"]) ?? "none", - pauseThreshold: asset.pauseThreshold ?? 500 + pauseThreshold: this.resolvedPauseThreshold }; } diff --git a/tests/rich-caption-player.test.ts b/tests/rich-caption-player.test.ts index 3947c31..cdf3a5a 100644 --- a/tests/rich-caption-player.test.ts +++ b/tests/rich-caption-player.test.ts @@ -436,7 +436,8 @@ describe("RichCaptionPlayer", () => { const player = new RichCaptionPlayer(edit, clip); await player.load(); - expect((clip.asset as Record)['pauseThreshold']).toBe(5); + // @ts-expect-error accessing private property for test verification + expect(player.resolvedPauseThreshold).toBe(5); }); }); From c11658a9ba1cde5db208b7f469dbee20ac9abed7 Mon Sep 17 00:00:00 2001 From: Kratos2k7 Date: Thu, 12 Mar 2026 06:38:49 +0500 Subject: [PATCH 02/25] fix: The throttled notifyDimensionsChanged in selection-handles.ts during drag still fires every 100ms, so the caption visually re-flows live as you drag the edge handle. --- .../canvas/players/rich-caption-player.ts | 63 +++++++++++++++---- src/core/ui/selection-handles.ts | 15 +++++ 2 files changed, 66 insertions(+), 12 deletions(-) diff --git a/src/components/canvas/players/rich-caption-player.ts b/src/components/canvas/players/rich-caption-player.ts index 00d37d7..0a4fe37 100644 --- a/src/components/canvas/players/rich-caption-player.ts +++ b/src/components/canvas/players/rich-caption-player.ts @@ -384,7 +384,7 @@ export class RichCaptionPlayer extends Player { words: words.map(w => ({ text: w.text, start: w.start, end: w.end, confidence: w.confidence })), font: { family: resolvedFamily, ...asset.font }, width, - height, + height }; const optionalFields: Record = { @@ -397,7 +397,7 @@ export class RichCaptionPlayer extends Player { style: asset.style, wordAnimation: asset.wordAnimation, align: asset.align, - pauseThreshold: this.resolvedPauseThreshold, + pauseThreshold: this.resolvedPauseThreshold }; for (const [key, value] of Object.entries(optionalFields)) { @@ -407,7 +407,7 @@ export class RichCaptionPlayer extends Player { } if (customFonts.length > 0) { - payload['customFonts'] = customFonts; + payload["customFonts"] = customFonts; } return payload; @@ -531,20 +531,56 @@ export class RichCaptionPlayer extends Player { } protected override onDimensionsChanged(): void { - if (!this.layoutEngine || !this.validatedAsset || !this.canvas || !this.painter) return; + if (this.words.length === 0) return; - const { width, height } = this.getSize(); + this.rebuildForCurrentSize(); + } - this.canvas.width = width; - this.canvas.height = height; + private async rebuildForCurrentSize(): Promise { + const currentTimeMs = this.getPlaybackTime() * 1000; if (this.texture) { this.texture.destroy(); this.texture = null; } + if (this.sprite) { + this.contentContainer.removeChild(this.sprite); + this.sprite.destroy(); + this.sprite = null; + } + if (this.contentContainer.mask) { + const mask = this.contentContainer.mask; + this.contentContainer.mask = null; + if (mask instanceof pixi.Graphics) { + mask.destroy(); + } + } + + this.captionLayout = null; + this.validatedAsset = null; + this.generatorConfig = null; + this.canvas = null; + this.painter = null; + + const { width, height } = this.getSize(); + const asset = this.clipConfiguration.asset as RichCaptionAsset; + + const canvasPayload = this.buildCanvasPayload(asset, this.words); + const canvasValidation = CanvasRichCaptionAssetSchema.safeParse(canvasPayload); + if (!canvasValidation.success) { + return; + } + this.validatedAsset = canvasValidation.data; this.generatorConfig = createDefaultGeneratorConfig(width, height, 1); + this.canvas = document.createElement("canvas"); + this.canvas.width = width; + this.canvas.height = height; + this.painter = createWebPainter(this.canvas); + + if (!this.layoutEngine) return; + const layoutConfig = this.buildLayoutConfig(this.validatedAsset, width, height); const canvasTextMeasurer = this.createCanvasTextMeasurer(); if (canvasTextMeasurer) { @@ -553,11 +589,14 @@ export class RichCaptionPlayer extends Player { this.pendingLayoutId += 1; const layoutId = this.pendingLayoutId; - this.layoutEngine.layoutCaption(this.words, layoutConfig).then(layout => { - if (layoutId !== this.pendingLayoutId) return; - this.captionLayout = layout; - this.renderFrameSync(this.getPlaybackTime() * 1000); - }); + + const layout = await this.layoutEngine.layoutCaption(this.words, layoutConfig); + + if (layoutId !== this.pendingLayoutId) return; + + this.captionLayout = layout; + + this.renderFrameSync(currentTimeMs); } public override supportsEdgeResize(): boolean { diff --git a/src/core/ui/selection-handles.ts b/src/core/ui/selection-handles.ts index d2ec80d..2eb2b53 100644 --- a/src/core/ui/selection-handles.ts +++ b/src/core/ui/selection-handles.ts @@ -79,6 +79,9 @@ export class SelectionHandles implements CanvasOverlayRegistration { transform?: { rotate?: { angle: number } }; } | null = null; + private edgeResizeNotifyTimer: ReturnType | null = null; + private static readonly EDGE_RESIZE_NOTIFY_INTERVAL_MS = 100; + // Bound event handlers for cleanup private onClipSelectedBound: (payload: { trackIndex: number; clipIndex: number }) => void; private onSelectionClearedBound: () => void; @@ -469,6 +472,11 @@ export class SelectionHandles implements CanvasOverlayRegistration { // Commit with explicit final state (adds to history, doesn't execute) this.edit.commitClipUpdate(this.selectedClipId, this.initialClipConfiguration, finalClip); + if (this.edgeResizeNotifyTimer) { + clearTimeout(this.edgeResizeNotifyTimer); + this.edgeResizeNotifyTimer = null; + } + // Notify player if dimensions changed (corner or edge resize) if ((this.scaleDirection || this.edgeDragDirection) && this.selectedPlayer) { this.selectedPlayer.notifyDimensionsChanged(); @@ -644,6 +652,13 @@ export class SelectionHandles implements CanvasOverlayRegistration { }); this.edit.resolveClip(this.selectedClipId); + if (this.selectedPlayer.supportsEdgeResize() && !this.edgeResizeNotifyTimer) { + this.edgeResizeNotifyTimer = setTimeout(() => { + this.edgeResizeNotifyTimer = null; + this.selectedPlayer?.notifyDimensionsChanged(); + }, SelectionHandles.EDGE_RESIZE_NOTIFY_INTERVAL_MS); + } + this.showDimensionLabel(rounded.width, rounded.height); } From bdf7f244f77de99b3eff8ea4d8e2c15a0182473e Mon Sep 17 00:00:00 2001 From: Kratos2k7 Date: Thu, 12 Mar 2026 06:45:22 +0500 Subject: [PATCH 03/25] fixed lint issues --- .../canvas/players/rich-caption-player.ts | 2 +- tests/rich-caption-player.test.ts | 113 +++++++++++++----- 2 files changed, 84 insertions(+), 31 deletions(-) diff --git a/src/components/canvas/players/rich-caption-player.ts b/src/components/canvas/players/rich-caption-player.ts index 0a4fe37..42652cd 100644 --- a/src/components/canvas/players/rich-caption-player.ts +++ b/src/components/canvas/players/rich-caption-player.ts @@ -549,7 +549,7 @@ export class RichCaptionPlayer extends Player { this.sprite = null; } if (this.contentContainer.mask) { - const mask = this.contentContainer.mask; + const { mask } = this.contentContainer; this.contentContainer.mask = null; if (mask instanceof pixi.Graphics) { mask.destroy(); diff --git a/tests/rich-caption-player.test.ts b/tests/rich-caption-player.test.ts index cdf3a5a..a989b90 100644 --- a/tests/rich-caption-player.test.ts +++ b/tests/rich-caption-player.test.ts @@ -134,12 +134,14 @@ jest.mock("@shotstack/shotstack-canvas", () => { yPositions: [540, 540, 540], widths: [120, 130, 100] }, - groups: [{ - wordIndices: [0, 1, 2], - startTime: 0, - endTime: 1400, - lines: [{ wordIndices: [0, 1, 2], x: 100, y: 540, width: 400, height: 48 }] - }], + groups: [ + { + wordIndices: [0, 1, 2], + startTime: 0, + endTime: 1400, + lines: [{ wordIndices: [0, 1, 2], x: 100, y: 540, width: 400, height: 48 }] + } + ], shapedWords: [ { text: "Hello", width: 120, glyphs: [], isRTL: false }, { text: "World", width: 130, glyphs: [], isRTL: false }, @@ -150,9 +152,9 @@ jest.mock("@shotstack/shotstack-canvas", () => { { wordIndex: 0, text: "Hello", x: 100, y: 540, width: 120, startTime: 0, endTime: 400, isRTL: false }, { wordIndex: 1, text: "World", x: 300, y: 540, width: 130, startTime: 500, endTime: 900, isRTL: false } ]); - mockGetActiveWordAtTime = jest.fn().mockReturnValue( - { wordIndex: 0, text: "Hello", x: 100, y: 540, width: 120, startTime: 0, endTime: 400, isRTL: false } - ); + mockGetActiveWordAtTime = jest + .fn() + .mockReturnValue({ wordIndex: 0, text: "Hello", x: 100, y: 540, width: 120, startTime: 0, endTime: 400, isRTL: false }); mockGenerateRichCaptionFrame = jest.fn().mockReturnValue({ ops: [{ op: "DrawCaptionWord", text: "Hello" }], visibleWordCount: 1, @@ -289,7 +291,7 @@ describe("RichCaptionPlayer", () => { it("falls back to placeholder on invalid asset", async () => { const { RichCaptionAssetSchema } = jest.requireMock("@schemas") as { - RichCaptionAssetSchema: { safeParse: jest.Mock } + RichCaptionAssetSchema: { safeParse: jest.Mock }; }; RichCaptionAssetSchema.safeParse.mockReturnValueOnce({ success: false, error: new Error("invalid") }); @@ -368,10 +370,7 @@ describe("RichCaptionPlayer", () => { await player.load(); expect(mockRegisterFromBytes).toHaveBeenCalledTimes(1); - expect(mockRegisterFromBytes).toHaveBeenCalledWith( - expect.any(ArrayBuffer), - expect.objectContaining({ family: "Roboto" }) - ); + expect(mockRegisterFromBytes).toHaveBeenCalledWith(expect.any(ArrayBuffer), expect.objectContaining({ family: "Roboto" })); }); it("handles font registration failure gracefully", async () => { @@ -439,10 +438,9 @@ describe("RichCaptionPlayer", () => { // @ts-expect-error accessing private property for test verification expect(player.resolvedPauseThreshold).toBe(5); }); - }); - describe("Rendering", () => { + describe("Rendering", () => { it("renders first frame during load", async () => { const edit = createMockEdit(); const player = new RichCaptionPlayer(edit, createClip(createAsset())); @@ -534,7 +532,7 @@ describe("RichCaptionPlayer", () => { }); }); - describe("Lifecycle", () => { + describe("Lifecycle", () => { it("releases FontRegistry reference on dispose", async () => { const edit = createMockEdit(); const player = new RichCaptionPlayer(edit, createClip(createAsset())); @@ -664,7 +662,7 @@ describe("RichCaptionPlayer", () => { it("handles canvas validation failure", async () => { const { CanvasRichCaptionAssetSchema } = jest.requireMock("@shotstack/shotstack-canvas") as { - CanvasRichCaptionAssetSchema: { safeParse: jest.Mock } + CanvasRichCaptionAssetSchema: { safeParse: jest.Mock }; }; CanvasRichCaptionAssetSchema.safeParse.mockReturnValueOnce({ success: false, @@ -742,7 +740,6 @@ describe("RichCaptionPlayer", () => { (edit as unknown as Record)["playbackTime"] = 0.4; player.update(0.016, 0.4); - expect(pixi.Texture.from.mock.calls.length).toBe(fromCallCount); }); @@ -787,16 +784,76 @@ describe("RichCaptionPlayer", () => { player.onDimensionsChanged(); // Wait for async layout - await new Promise(resolve => { setTimeout(resolve, 10); }); + await new Promise(resolve => { + setTimeout(resolve, 10); + }); expect(mockLayoutCaption.mock.calls.length).toBe(layoutCallsBefore + 1); }); + + it("fully rebuilds caption on dimensions changed", async () => { + const { CanvasRichCaptionAssetSchema } = jest.requireMock("@shotstack/shotstack-canvas") as { + CanvasRichCaptionAssetSchema: { safeParse: jest.Mock }; + }; + + const edit = createMockEdit(); + const clip = createClip(createAsset(), { width: 800, height: 200 }); + const player = new RichCaptionPlayer(edit, clip); + await player.load(); + + // Change clip dimensions to simulate resize + (player as unknown as { clipConfiguration: ResolvedClip }).clipConfiguration.width = 400; + (player as unknown as { clipConfiguration: ResolvedClip }).clipConfiguration.height = 100; + + CanvasRichCaptionAssetSchema.safeParse.mockClear(); + mockLayoutCaption.mockClear(); + mockCreateWebPainter.mockClear(); + + // @ts-expect-error accessing protected method + player.onDimensionsChanged(); + + // Wait for async rebuild + await new Promise(resolve => { + setTimeout(resolve, 10); + }); + + // Should have rebuilt validatedAsset with new dimensions + expect(CanvasRichCaptionAssetSchema.safeParse).toHaveBeenCalledTimes(1); + const payload = CanvasRichCaptionAssetSchema.safeParse.mock.calls[0]?.[0]; + expect(payload.width).toBe(400); + expect(payload.height).toBe(100); + + // Should have created a new canvas/painter and re-laid-out + expect(mockCreateWebPainter).toHaveBeenCalledTimes(1); + expect(mockLayoutCaption).toHaveBeenCalledTimes(1); + }); + + it("applyFixedDimensions is a no-op for caption player", async () => { + const edit = createMockEdit(); + const player = new RichCaptionPlayer(edit, createClip(createAsset())); + await player.load(); + + // @ts-expect-error accessing private property + const spriteBefore = player.sprite; + + // @ts-expect-error accessing protected method + player.applyFixedDimensions(); + + // @ts-expect-error accessing private property + const spriteAfter = player.sprite; + + // Sprite should remain untouched — no anchor/scale/position changes + expect(spriteAfter).toBe(spriteBefore); + if (spriteAfter) { + expect(spriteAfter.anchor.set).not.toHaveBeenCalled(); + } + }); }); describe("Google Font Resolution", () => { it("resolves Google Font hash via getFontDisplayName", async () => { const { parseFontFamily: mockParseFontFamily } = jest.requireMock("@core/fonts/font-config") as { - parseFontFamily: jest.Mock + parseFontFamily: jest.Mock; }; const asset = createAsset({ @@ -848,18 +905,14 @@ describe("RichCaptionPlayer", () => { const player = new RichCaptionPlayer(edit, createClip(createAsset())); await player.load(); - expect(MockFontFace).toHaveBeenCalledWith( - "Roboto", - expect.any(ArrayBuffer), - expect.objectContaining({ weight: "400" }) - ); + expect(MockFontFace).toHaveBeenCalledWith("Roboto", expect.any(ArrayBuffer), expect.objectContaining({ weight: "400" })); }); }); describe("buildCanvasPayload Field Stripping", () => { it("always includes font with resolved family even when asset.font is undefined", async () => { const { CanvasRichCaptionAssetSchema } = jest.requireMock("@shotstack/shotstack-canvas") as { - CanvasRichCaptionAssetSchema: { safeParse: jest.Mock } + CanvasRichCaptionAssetSchema: { safeParse: jest.Mock }; }; const asset = createAsset({ font: undefined } as Partial); @@ -875,14 +928,14 @@ describe("RichCaptionPlayer", () => { it("includes only allowlisted fields in canvas payload", async () => { const { CanvasRichCaptionAssetSchema } = jest.requireMock("@shotstack/shotstack-canvas") as { - CanvasRichCaptionAssetSchema: { safeParse: jest.Mock } + CanvasRichCaptionAssetSchema: { safeParse: jest.Mock }; }; const asset = createAsset({ font: { family: "Roboto", size: 48, color: "#ffffff" }, stroke: { width: 2, color: "#000000" }, shadow: { offsetX: 2, offsetY: 2, blur: 4, color: "#000000" }, - background: { color: "#333333" }, + background: { color: "#333333" } } as Partial); // Add non-allowlisted fields that should be stripped @@ -909,7 +962,7 @@ describe("RichCaptionPlayer", () => { it("excludes undefined optional fields from canvas payload", async () => { const { CanvasRichCaptionAssetSchema } = jest.requireMock("@shotstack/shotstack-canvas") as { - CanvasRichCaptionAssetSchema: { safeParse: jest.Mock } + CanvasRichCaptionAssetSchema: { safeParse: jest.Mock }; }; const asset = createAsset(); From a820206771ebe1d6e0172d315ca53ce35139f9e0 Mon Sep 17 00:00:00 2001 From: Kratos2k7 Date: Thu, 12 Mar 2026 08:03:31 +0500 Subject: [PATCH 04/25] fix: The default verticalAlign was hardcoded to "bottom" in the SDK but the canvas schema defaults to "middle". --- src/components/canvas/players/rich-caption-player.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/canvas/players/rich-caption-player.ts b/src/components/canvas/players/rich-caption-player.ts index 42652cd..ee16c9a 100644 --- a/src/components/canvas/players/rich-caption-player.ts +++ b/src/components/canvas/players/rich-caption-player.ts @@ -422,7 +422,7 @@ export class RichCaptionPlayer extends Player { frameHeight, availableWidth: frameWidth * 0.9, maxLines: 2, - verticalAlign: align?.vertical ?? "bottom", + verticalAlign: align?.vertical ?? "middle", horizontalAlign: align?.horizontal ?? "center", paddingLeft: padding, fontSize: font?.size ?? 24, From 31eaad219ab7a230cd852c440966f91032adda38 Mon Sep 17 00:00:00 2001 From: Kratos2k7 Date: Thu, 12 Mar 2026 08:10:03 +0500 Subject: [PATCH 05/25] fixed padding for width and height --- .../canvas/players/rich-caption-player.ts | 23 ++++++++++++++++--- 1 file changed, 20 insertions(+), 3 deletions(-) diff --git a/src/components/canvas/players/rich-caption-player.ts b/src/components/canvas/players/rich-caption-player.ts index ee16c9a..5d43e24 100644 --- a/src/components/canvas/players/rich-caption-player.ts +++ b/src/components/canvas/players/rich-caption-player.ts @@ -415,16 +415,33 @@ export class RichCaptionPlayer extends Player { private buildLayoutConfig(asset: CanvasRichCaptionAsset, frameWidth: number, frameHeight: number): CaptionLayoutConfig { const { font, style, align, padding: rawPadding } = asset; - const padding = typeof rawPadding === "number" ? rawPadding : (rawPadding?.left ?? 0); + + let padLeft: number; + let padRight: number; + if (typeof rawPadding === "number") { + padLeft = rawPadding; + padRight = rawPadding; + } else if (rawPadding) { + padLeft = (rawPadding as { left?: number }).left ?? 0; + padRight = (rawPadding as { right?: number }).right ?? 0; + } else { + padLeft = 0; + padRight = 0; + } + + const totalHorizontalPadding = padLeft + padRight; + const availableWidth = totalHorizontalPadding > 0 + ? frameWidth - totalHorizontalPadding + : frameWidth * 0.9; return { frameWidth, frameHeight, - availableWidth: frameWidth * 0.9, + availableWidth, maxLines: 2, verticalAlign: align?.vertical ?? "middle", horizontalAlign: align?.horizontal ?? "center", - paddingLeft: padding, + paddingLeft: padLeft, fontSize: font?.size ?? 24, fontFamily: font?.family ?? "Roboto", fontWeight: String(font?.weight ?? "400"), From 6506260f1babb258858ccc26bcd1302bc209a8d6 Mon Sep 17 00:00:00 2001 From: Kratos2k7 Date: Thu, 12 Mar 2026 08:20:14 +0500 Subject: [PATCH 06/25] fix: fixed custom font issue --- .../canvas/players/rich-caption-player.ts | 26 ++++++++++++++----- 1 file changed, 20 insertions(+), 6 deletions(-) diff --git a/src/components/canvas/players/rich-caption-player.ts b/src/components/canvas/players/rich-caption-player.ts index 5d43e24..3bb9a98 100644 --- a/src/components/canvas/players/rich-caption-player.ts +++ b/src/components/canvas/players/rich-caption-player.ts @@ -382,7 +382,7 @@ export class RichCaptionPlayer extends Player { const payload: Record = { type: asset.type, words: words.map(w => ({ text: w.text, start: w.start, end: w.end, confidence: w.confidence })), - font: { family: resolvedFamily, ...asset.font }, + font: { ...asset.font, family: resolvedFamily }, width, height }; @@ -418,15 +418,24 @@ export class RichCaptionPlayer extends Player { let padLeft: number; let padRight: number; + let padTop: number; + let padBottom: number; if (typeof rawPadding === "number") { padLeft = rawPadding; padRight = rawPadding; + padTop = rawPadding; + padBottom = rawPadding; } else if (rawPadding) { - padLeft = (rawPadding as { left?: number }).left ?? 0; - padRight = (rawPadding as { right?: number }).right ?? 0; + const p = rawPadding as { top?: number; right?: number; bottom?: number; left?: number }; + padLeft = p.left ?? 0; + padRight = p.right ?? 0; + padTop = p.top ?? 0; + padBottom = p.bottom ?? 0; } else { padLeft = 0; padRight = 0; + padTop = 0; + padBottom = 0; } const totalHorizontalPadding = padLeft + padRight; @@ -434,20 +443,25 @@ export class RichCaptionPlayer extends Player { ? frameWidth - totalHorizontalPadding : frameWidth * 0.9; + const fontSize = font?.size ?? 24; + const lineHeight = style?.lineHeight ?? 1.2; + const availableHeight = frameHeight - padTop - padBottom; + const maxLines = Math.max(1, Math.min(10, Math.floor(availableHeight / (fontSize * lineHeight)))); + return { frameWidth, frameHeight, availableWidth, - maxLines: 2, + maxLines, verticalAlign: align?.vertical ?? "middle", horizontalAlign: align?.horizontal ?? "center", paddingLeft: padLeft, - fontSize: font?.size ?? 24, + fontSize, fontFamily: font?.family ?? "Roboto", fontWeight: String(font?.weight ?? "400"), letterSpacing: style?.letterSpacing ?? 0, wordSpacing: typeof style?.wordSpacing === "number" ? style.wordSpacing : 0, - lineHeight: style?.lineHeight ?? 1.2, + lineHeight, textTransform: (style?.textTransform as CaptionLayoutConfig["textTransform"]) ?? "none", pauseThreshold: this.resolvedPauseThreshold }; From 4611504e97f97f9140b9028329bbbe20dd8017f0 Mon Sep 17 00:00:00 2001 From: Kratos2k7 Date: Thu, 12 Mar 2026 09:25:33 +0500 Subject: [PATCH 07/25] fix: padding css model pattern added and updated canvas vesion along with branch prefix for conventional commits --- .releaserc.json | 71 +++++++++++++------ package.json | 2 +- .../canvas/players/rich-caption-player.ts | 26 ++----- 3 files changed, 58 insertions(+), 41 deletions(-) diff --git a/.releaserc.json b/.releaserc.json index 6cdc8f5..6bd6a4e 100644 --- a/.releaserc.json +++ b/.releaserc.json @@ -1,23 +1,52 @@ { - "branches": ["main"], - "plugins": [ - ["@semantic-release/commit-analyzer", { - "preset": "angular", - "releaseRules": [ - { "message": "*fix/*", "release": "patch" }, - { "message": "*hotfix/*", "release": "patch" }, - { "message": "*feat/*", "release": "minor" }, - { "message": "*release/*", "release": "major" } - ] - }], - "@semantic-release/release-notes-generator", - ["@semantic-release/npm", { - "pkgRoot": "." - }], - ["@semantic-release/git", { - "assets": ["package.json"], - "message": "chore(release): ${nextRelease.version} [skip ci]\n\n${nextRelease.notes}" - }], - "@semantic-release/github" - ] + "branches": ["main"], + "plugins": [ + [ + "@semantic-release/commit-analyzer", + { + "preset": "angular", + "releaseRules": [ + { + "message": "*/fix/*", + "release": "patch" + }, + { + "message": "*/hotfix/*", + "release": "patch" + }, + { + "message": "*/feat/*", + "release": "minor" + }, + { + "message": "*/release/*", + "release": "major" + }, + { + "type": "fix", + "release": "patch" + }, + { + "type": "feat", + "release": "minor" + } + ] + } + ], + "@semantic-release/release-notes-generator", + [ + "@semantic-release/npm", + { + "pkgRoot": "." + } + ], + [ + "@semantic-release/git", + { + "assets": ["package.json"], + "message": "chore(release): ${nextRelease.version} [skip ci]\n\n${nextRelease.notes}" + } + ], + "@semantic-release/github" + ] } diff --git a/package.json b/package.json index d75cb24..f56499f 100644 --- a/package.json +++ b/package.json @@ -97,7 +97,7 @@ }, "dependencies": { "@shotstack/schemas": "1.8.7", - "@shotstack/shotstack-canvas": "^2.0.17", + "@shotstack/shotstack-canvas": "^2.1.1", "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 3bb9a98..1ed6e69 100644 --- a/src/components/canvas/players/rich-caption-player.ts +++ b/src/components/canvas/players/rich-caption-player.ts @@ -416,36 +416,24 @@ export class RichCaptionPlayer extends Player { private buildLayoutConfig(asset: CanvasRichCaptionAsset, frameWidth: number, frameHeight: number): CaptionLayoutConfig { const { font, style, align, padding: rawPadding } = asset; - let padLeft: number; - let padRight: number; - let padTop: number; - let padBottom: number; + let padding: { top: number; right: number; bottom: number; left: number }; if (typeof rawPadding === "number") { - padLeft = rawPadding; - padRight = rawPadding; - padTop = rawPadding; - padBottom = rawPadding; + padding = { top: rawPadding, right: rawPadding, bottom: rawPadding, left: rawPadding }; } else if (rawPadding) { const p = rawPadding as { top?: number; right?: number; bottom?: number; left?: number }; - padLeft = p.left ?? 0; - padRight = p.right ?? 0; - padTop = p.top ?? 0; - padBottom = p.bottom ?? 0; + padding = { top: p.top ?? 0, right: p.right ?? 0, bottom: p.bottom ?? 0, left: p.left ?? 0 }; } else { - padLeft = 0; - padRight = 0; - padTop = 0; - padBottom = 0; + padding = { top: 0, right: 0, bottom: 0, left: 0 }; } - const totalHorizontalPadding = padLeft + padRight; + 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 - padTop - padBottom; + const availableHeight = frameHeight - padding.top - padding.bottom; const maxLines = Math.max(1, Math.min(10, Math.floor(availableHeight / (fontSize * lineHeight)))); return { @@ -455,7 +443,7 @@ export class RichCaptionPlayer extends Player { maxLines, verticalAlign: align?.vertical ?? "middle", horizontalAlign: align?.horizontal ?? "center", - paddingLeft: padLeft, + padding, fontSize, fontFamily: font?.family ?? "Roboto", fontWeight: String(font?.weight ?? "400"), From c1659a1b5042c1e394133ab6b1f0ecf7df06401e Mon Sep 17 00:00:00 2001 From: Kratos2k7 Date: Thu, 12 Mar 2026 11:32:41 +0500 Subject: [PATCH 08/25] fix:package update --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index f56499f..4382ad6 100644 --- a/package.json +++ b/package.json @@ -97,7 +97,7 @@ }, "dependencies": { "@shotstack/schemas": "1.8.7", - "@shotstack/shotstack-canvas": "^2.1.1", + "@shotstack/shotstack-canvas": "^2.1.3", "howler": "^2.2.4", "mediabunny": "^1.11.2", "opentype.js": "^1.3.4", From 510e41a006b3f7ceef994d3a5bda2359b758ed1a Mon Sep 17 00:00:00 2001 From: Kratos2k7 Date: Thu, 12 Mar 2026 13:06:44 +0500 Subject: [PATCH 09/25] fix: The "no-active" variants verify that all words render the same color when no active property is set. The "with-active" variants verify only the active word changes color/scale. --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 4382ad6..27d7514 100644 --- a/package.json +++ b/package.json @@ -97,7 +97,7 @@ }, "dependencies": { "@shotstack/schemas": "1.8.7", - "@shotstack/shotstack-canvas": "^2.1.3", + "@shotstack/shotstack-canvas": "^2.1.4", "howler": "^2.2.4", "mediabunny": "^1.11.2", "opentype.js": "^1.3.4", From 8c3e9d43986b262c681719fb0e8eb0e8b927f202 Mon Sep 17 00:00:00 2001 From: Kratos2k7 Date: Fri, 13 Mar 2026 03:29:53 +0500 Subject: [PATCH 10/25] chore: bump canvas and schema version --- package.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/package.json b/package.json index 27d7514..91ba670 100644 --- a/package.json +++ b/package.json @@ -96,8 +96,8 @@ "vite-plugin-dts": "^4.5.4" }, "dependencies": { - "@shotstack/schemas": "1.8.7", - "@shotstack/shotstack-canvas": "^2.1.4", + "@shotstack/schemas": "1.9.0", + "@shotstack/shotstack-canvas": "^2.1.5", "howler": "^2.2.4", "mediabunny": "^1.11.2", "opentype.js": "^1.3.4", From 8ee0dc5c6c233129e635a389e4a8e16def36d1db Mon Sep 17 00:00:00 2001 From: dazzatronus Date: Fri, 13 Mar 2026 12:52:59 +1100 Subject: [PATCH 11/25] feat: filter custom fonts by search query instead of hiding section --- src/core/ui/font-picker.ts | 17 +++++++++++++---- src/core/ui/virtual-font-list.ts | 7 +++++++ 2 files changed, 20 insertions(+), 4 deletions(-) diff --git a/src/core/ui/font-picker.ts b/src/core/ui/font-picker.ts index 768139b..020221e 100644 --- a/src/core/ui/font-picker.ts +++ b/src/core/ui/font-picker.ts @@ -241,9 +241,9 @@ export class FontPicker { // Hide sections when searching if (this.searchQuery) { this.recentSection.style.display = "none"; - this.customSection.style.display = "none"; (this.element.querySelector(".ss-font-picker-divider") as HTMLElement).style.display = "none"; - this.customDivider.style.display = "none"; + // Filter custom fonts by search query instead of hiding + this.updateCustomSection(this.searchQuery); } else { this.recentSection.style.display = ""; (this.element.querySelector(".ss-font-picker-divider") as HTMLElement).style.display = ""; @@ -378,9 +378,18 @@ export class FontPicker { * Update the custom fonts section. * Shows non-Google fonts from timeline.fonts. */ - private updateCustomSection(): void { + private updateCustomSection(filterQuery?: string): void { // Get custom fonts from timeline (non-Google, non-built-in) - const customFonts = this.timelineFonts.filter(font => isCustomFont(font.src)); + let customFonts = this.timelineFonts.filter(font => isCustomFont(font.src)); + + // Filter by search query + if (filterQuery) { + const lowerQuery = filterQuery.toLowerCase().trim(); + customFonts = customFonts.filter(font => { + const displayName = this.resolveCustomFontName(font.src); + return displayName.toLowerCase().includes(lowerQuery); + }); + } // Hide section if no custom fonts if (customFonts.length === 0) { diff --git a/src/core/ui/virtual-font-list.ts b/src/core/ui/virtual-font-list.ts index a7dc07e..652eb14 100644 --- a/src/core/ui/virtual-font-list.ts +++ b/src/core/ui/virtual-font-list.ts @@ -104,6 +104,13 @@ export class VirtualFontList { return matchesQuery && matchesCategory; }); + // Clear cached items — index→font mapping is now stale + for (const [, element] of this.items) { + this.fontLoader.unobserve(element); + element.remove(); + } + this.items.clear(); + // Reset scroll position when filter changes this.viewport.scrollTop = 0; this.scrollTop = 0; From dba6218b4a0efa6d3cb937b9dc539d518c78ae8b Mon Sep 17 00:00:00 2001 From: dazzatronus Date: Fri, 13 Mar 2026 12:53:17 +1100 Subject: [PATCH 12/25] feat: add alias caption placeholder with reload support --- src/components/canvas/players/player.ts | 3 + .../canvas/players/rich-caption-player.ts | 135 +++++++++---- src/core/player-reconciler.ts | 10 + src/core/timing-manager.ts | 16 +- tests/rich-caption-player.test.ts | 191 ++++++++++++++++++ 5 files changed, 316 insertions(+), 39 deletions(-) diff --git a/src/components/canvas/players/player.ts b/src/components/canvas/players/player.ts index bc1c63d..cf6851a 100644 --- a/src/components/canvas/players/player.ts +++ b/src/components/canvas/players/player.ts @@ -70,6 +70,9 @@ export abstract class Player extends Entity { */ public clipId: string | null = null; + /** True when the player's asset needs external resolution (e.g. alias caption awaiting transcription). */ + public needsResolution = false; + protected edit: Edit; public clipConfiguration: ResolvedClip; diff --git a/src/components/canvas/players/rich-caption-player.ts b/src/components/canvas/players/rich-caption-player.ts index 1ed6e69..d2ecd77 100644 --- a/src/components/canvas/players/rich-caption-player.ts +++ b/src/components/canvas/players/rich-caption-player.ts @@ -2,6 +2,7 @@ import { Player, PlayerType } from "@canvas/players/player"; import { Edit } from "@core/edit-session"; import { parseFontFamily, resolveFontPath, getFontDisplayName } from "@core/fonts/font-config"; import { extractFontNames, isGoogleFontUrl } from "@core/fonts/font-utils"; +import { isAliasReference } from "@core/timing/types"; import { type Size, type Vector } from "@layouts/geometry"; import { RichCaptionAssetSchema, type RichCaptionAsset, type ResolvedClip } from "@schemas"; import { @@ -38,6 +39,7 @@ export class RichCaptionPlayer extends Player { private words: WordTiming[] = []; private loadComplete: boolean = false; + private isPlaceholder = false; private readonly fontRegistrationCache = new Map>(); private lastRegisteredFontKey: string = ""; @@ -49,6 +51,17 @@ export class RichCaptionPlayer extends Player { super(edit, configWithoutFit, PlayerType.RichCaption); } + private static createPlaceholderWords(clipLengthMs: number): WordTiming[] { + const words = ["Your", "captions", "will", "appear", "here"]; + const wordDuration = clipLengthMs / words.length; + return words.map((text, i) => ({ + text, + start: Math.round(i * wordDuration), + end: Math.round((i + 1) * wordDuration), + confidence: 1 + })); + } + public override async load(): Promise { await super.load(); @@ -63,8 +76,14 @@ export class RichCaptionPlayer extends Player { let words: WordTiming[]; if (richCaptionAsset.src) { - words = await this.fetchAndParseSubtitle(richCaptionAsset.src); - this.resolvedPauseThreshold = 5; + if (isAliasReference(richCaptionAsset.src)) { + words = RichCaptionPlayer.createPlaceholderWords(this.getLength() * 1000); + this.isPlaceholder = true; + this.needsResolution = true; + } else { + words = await this.fetchAndParseSubtitle(richCaptionAsset.src); + this.resolvedPauseThreshold = 5; + } } else { words = ((richCaptionAsset as RichCaptionAsset & { words?: WordTiming[] }).words ?? []).map((w: WordTiming) => ({ text: w.text, @@ -87,42 +106,7 @@ export class RichCaptionPlayer extends Player { console.warn(`RichCaptionPlayer: ${words.length} words exceeds soft limit of ${SOFT_WORD_LIMIT}. Performance may degrade.`); } - const canvasPayload = this.buildCanvasPayload(richCaptionAsset, words); - const canvasValidation = CanvasRichCaptionAssetSchema.safeParse(canvasPayload); - if (!canvasValidation.success) { - console.error("Canvas caption validation failed:", canvasValidation.error?.issues ?? canvasValidation.error); - this.createFallbackGraphic("Caption validation failed"); - return; - } - this.validatedAsset = canvasValidation.data; - this.words = words; - - this.fontRegistry = await FontRegistry.getSharedInstance(); - await this.registerFonts(richCaptionAsset); - this.lastRegisteredFontKey = `${richCaptionAsset.font?.family ?? "Roboto"}|${richCaptionAsset.font?.weight ?? 400}`; - - this.layoutEngine = new CaptionLayoutEngine(this.fontRegistry); - - const { width, height } = this.getSize(); - const layoutConfig = this.buildLayoutConfig(this.validatedAsset, width, height); - - const canvasTextMeasurer = this.createCanvasTextMeasurer(); - if (canvasTextMeasurer) { - layoutConfig.measureTextWidth = canvasTextMeasurer; - } - - this.captionLayout = await this.layoutEngine.layoutCaption(words, layoutConfig); - - this.generatorConfig = createDefaultGeneratorConfig(width, height, 1); - - this.canvas = document.createElement("canvas"); - this.canvas.width = width; - this.canvas.height = height; - this.painter = createWebPainter(this.canvas); - - this.renderFrameSync(0); - this.configureKeyframes(); - this.loadComplete = true; + await this.buildRenderPipeline(richCaptionAsset, words); } catch (error) { console.error("RichCaptionPlayer load failed:", error); this.cleanupResources(); @@ -141,6 +125,37 @@ export class RichCaptionPlayer extends Player { this.renderFrameSync(currentTimeMs); } + public override async reloadAsset(): Promise { + this.loadComplete = false; + + if (this.texture) { this.texture.destroy(); this.texture = null; } + if (this.sprite) { this.sprite.destroy(); this.sprite = null; } + this.captionLayout = null; + this.validatedAsset = null; + this.generatorConfig = null; + this.canvas = null; + this.painter = null; + + const asset = this.clipConfiguration.asset as RichCaptionAsset; + + if (!asset.src || isAliasReference(asset.src)) { + return; + } + + this.isPlaceholder = false; + this.needsResolution = false; + + const words = await this.fetchAndParseSubtitle(asset.src); + this.resolvedPauseThreshold = 5; + + if (words.length === 0) { + this.createFallbackGraphic("No caption words found"); + return; + } + + await this.buildRenderPipeline(asset, words); + } + public override reconfigureAfterRestore(): void { super.reconfigureAfterRestore(); this.reconfigure(); @@ -154,6 +169,11 @@ export class RichCaptionPlayer extends Player { try { const asset = this.clipConfiguration.asset as RichCaptionAsset; + // Regenerate placeholder words when clip length changes (e.g. "end" re-resolved after video probed) + if (this.isPlaceholder) { + this.words = RichCaptionPlayer.createPlaceholderWords(this.getLength() * 1000); + } + const fontKey = `${asset.font?.family ?? "Roboto"}|${asset.font?.weight ?? 400}`; if (fontKey !== this.lastRegisteredFontKey) { await this.registerFonts(asset); @@ -184,6 +204,45 @@ export class RichCaptionPlayer extends Player { } } + private async buildRenderPipeline(asset: RichCaptionAsset, words: WordTiming[]): Promise { + const canvasPayload = this.buildCanvasPayload(asset, words); + const canvasValidation = CanvasRichCaptionAssetSchema.safeParse(canvasPayload); + if (!canvasValidation.success) { + console.error("Canvas caption validation failed:", canvasValidation.error?.issues ?? canvasValidation.error); + this.createFallbackGraphic("Caption validation failed"); + return; + } + this.validatedAsset = canvasValidation.data; + this.words = words; + + this.fontRegistry = await FontRegistry.getSharedInstance(); + await this.registerFonts(asset); + this.lastRegisteredFontKey = `${asset.font?.family ?? "Roboto"}|${asset.font?.weight ?? 400}`; + + this.layoutEngine = new CaptionLayoutEngine(this.fontRegistry); + + const { width, height } = this.getSize(); + const layoutConfig = this.buildLayoutConfig(this.validatedAsset, width, height); + + const canvasTextMeasurer = this.createCanvasTextMeasurer(); + if (canvasTextMeasurer) { + layoutConfig.measureTextWidth = canvasTextMeasurer; + } + + this.captionLayout = await this.layoutEngine.layoutCaption(words, layoutConfig); + + this.generatorConfig = createDefaultGeneratorConfig(width, height, 1); + + this.canvas = document.createElement("canvas"); + this.canvas.width = width; + this.canvas.height = height; + this.painter = createWebPainter(this.canvas); + + this.renderFrameSync(0); + this.configureKeyframes(); + this.loadComplete = true; + } + private renderFrameSync(timeMs: number): void { if (!this.layoutEngine || !this.captionLayout || !this.canvas || !this.painter || !this.validatedAsset || !this.generatorConfig) { return; diff --git a/src/core/player-reconciler.ts b/src/core/player-reconciler.ts index d9271f4..17bf1fc 100644 --- a/src/core/player-reconciler.ts +++ b/src/core/player-reconciler.ts @@ -212,6 +212,16 @@ export class PlayerReconciler { clipId }); } + + // Emit ClipUnresolved for players that need external resolution (e.g. alias captions) + if (player.needsResolution) { + this.edit.getInternalEvents().emit(EditEvent.ClipUnresolved, { + trackIndex, + clipIndex, + assetType, + clipId + }); + } }) .catch(error => { const errorMessage = error instanceof Error ? error.message : String(error); diff --git a/src/core/timing-manager.ts b/src/core/timing-manager.ts index 6223117..cb3a4c2 100644 --- a/src/core/timing-manager.ts +++ b/src/core/timing-manager.ts @@ -65,6 +65,10 @@ export class TimingManager { } player.setResolvedTiming({ start: resolvedStart, length: resolvedLength }); + + // Sync resolved edit cache so timeline UI sees actual timing + resolvedClip.start = resolvedStart; + resolvedClip.length = resolvedLength; } } } @@ -78,10 +82,20 @@ export class TimingManager { const endLengthClips = this.getEndLengthClips(); for (const clip of endLengthClips) { const currentTiming = clip.getResolvedTiming(); + const endLength = resolveEndLength(currentTiming.start, timelineEnd); clip.setResolvedTiming({ start: currentTiming.start, - length: resolveEndLength(currentTiming.start, timelineEnd) + length: endLength }); + + // Sync resolved edit cache for "end" clips + const trackIdx = clip.layer - 1; + const clipIdx = tracks[trackIdx]?.indexOf(clip) ?? -1; + const endResolvedClip = resolved.timeline.tracks[trackIdx]?.clips[clipIdx]; + if (endResolvedClip) { + endResolvedClip.start = currentTiming.start; + endResolvedClip.length = endLength; + } } // Reconfigure "end" clips to rebuild keyframes diff --git a/tests/rich-caption-player.test.ts b/tests/rich-caption-player.test.ts index a989b90..2306310 100644 --- a/tests/rich-caption-player.test.ts +++ b/tests/rich-caption-player.test.ts @@ -1016,4 +1016,195 @@ describe("RichCaptionPlayer", () => { expect(player.pendingLayoutId).toBe(initialLayoutId + 2); }); }); + + describe("Alias Placeholder", () => { + it("detects alias reference and sets placeholder flags", async () => { + const asset = createAsset({ src: "alias://VIDEO", words: undefined } as Partial); + const edit = createMockEdit(); + const player = new RichCaptionPlayer(edit, createClip(asset)); + await player.load(); + + // @ts-expect-error accessing private property + expect(player.isPlaceholder).toBe(true); + expect(player.needsResolution).toBe(true); + // @ts-expect-error accessing private property + expect(player.loadComplete).toBe(true); + }); + + it("does NOT call fetch for alias:// URLs", async () => { + const asset = createAsset({ src: "alias://VIDEO", words: undefined } as Partial); + const edit = createMockEdit(); + const player = new RichCaptionPlayer(edit, createClip(asset)); + await player.load(); + + // fetch should only be called for font registration, not for the alias URL + const fetchCalls = mockFetch.mock.calls.map((c: unknown[]) => c[0]); + expect(fetchCalls).not.toContain("alias://VIDEO"); + }); + + it("renders placeholder through full canvas pipeline", async () => { + const asset = createAsset({ src: "alias://VIDEO", words: undefined } as Partial); + const edit = createMockEdit(); + const player = new RichCaptionPlayer(edit, createClip(asset)); + await player.load(); + + expect(mockLayoutCaption).toHaveBeenCalledTimes(1); + expect(mockGenerateRichCaptionFrame).toHaveBeenCalledTimes(1); + }); + + it("placeholder respects caption styling", async () => { + const { CanvasRichCaptionAssetSchema } = jest.requireMock("@shotstack/shotstack-canvas") as { + CanvasRichCaptionAssetSchema: { safeParse: jest.Mock }; + }; + + const asset = createAsset({ + src: "alias://VIDEO", + words: undefined, + font: { family: "Inter", size: 36, color: "#ff0000" }, + active: { color: "#00ff00" }, + stroke: { width: 2, color: "#000000" } + } as Partial); + const edit = createMockEdit(); + const player = new RichCaptionPlayer(edit, createClip(asset)); + await player.load(); + + const payload = CanvasRichCaptionAssetSchema.safeParse.mock.calls[0]?.[0]; + expect(payload.font.family).toBe("Inter"); + expect(payload.active).toBeDefined(); + expect(payload.stroke).toBeDefined(); + expect(payload.words).toHaveLength(5); // placeholder words + }); + + it("distributes placeholder words across the full clip length", async () => { + const { CanvasRichCaptionAssetSchema } = jest.requireMock("@shotstack/shotstack-canvas") as { + CanvasRichCaptionAssetSchema: { safeParse: jest.Mock }; + }; + + const clipLength = 10; // 10 seconds + const asset = createAsset({ src: "alias://VIDEO", words: undefined } as Partial); + const edit = createMockEdit(); + const player = new RichCaptionPlayer(edit, createClip(asset, { length: clipLength })); + await player.load(); + + const payload = CanvasRichCaptionAssetSchema.safeParse.mock.calls[0]?.[0]; + const words = payload.words as Array<{ start: number; end: number }>; + expect(words[0].start).toBe(0); + expect(words[words.length - 1].end).toBe(clipLength * 1000); // spans full clip in ms + }); + + it("reconfigure works on placeholder state", async () => { + const asset = createAsset({ src: "alias://VIDEO", words: undefined } as Partial); + const edit = createMockEdit(); + const player = new RichCaptionPlayer(edit, createClip(asset)); + await player.load(); + + mockLayoutCaption.mockClear(); + + // @ts-expect-error accessing private method + await player.reconfigure(); + + expect(mockLayoutCaption).toHaveBeenCalledTimes(1); + }); + + it("regenerates placeholder words when clip length changes (e.g. 'end' re-resolved)", async () => { + const { CanvasRichCaptionAssetSchema } = jest.requireMock("@shotstack/shotstack-canvas") as { + CanvasRichCaptionAssetSchema: { safeParse: jest.Mock }; + }; + + const asset = createAsset({ src: "alias://VIDEO", words: undefined } as Partial); + const edit = createMockEdit(); + const clip = createClip(asset, { length: 3 }); // initial "end" resolved to 3s + const player = new RichCaptionPlayer(edit, clip); + await player.load(); + + // Verify initial placeholder spans 3s + let payload = CanvasRichCaptionAssetSchema.safeParse.mock.calls[0]?.[0]; + const initialWords = payload.words as Array<{ end: number }>; + expect(initialWords[initialWords.length - 1].end).toBe(3000); + + // Simulate resolveAllTiming updating the length after video probing + player.setResolvedTiming({ start: 0, length: 20 } as { start: number; length: number }); + CanvasRichCaptionAssetSchema.safeParse.mockClear(); + + player.reconfigureAfterRestore(); + await new Promise(resolve => { setTimeout(resolve, 10); }); + + // After reconfigure, placeholder should span the new 20s length + payload = CanvasRichCaptionAssetSchema.safeParse.mock.calls[0]?.[0]; + const updatedWords = payload.words as Array<{ end: number }>; + expect(updatedWords[updatedWords.length - 1].end).toBe(20000); + }); + }); + + describe("reloadAsset", () => { + it("transitions from placeholder to real subtitles when src changes", async () => { + const asset = createAsset({ src: "alias://VIDEO", words: undefined } as Partial); + const edit = createMockEdit(); + const clip = createClip(asset); + const player = new RichCaptionPlayer(edit, clip); + await player.load(); + + // @ts-expect-error accessing private property + expect(player.isPlaceholder).toBe(true); + expect(player.needsResolution).toBe(true); + + // Simulate reconciler updating src to a real URL + (player as unknown as { clipConfiguration: ResolvedClip }).clipConfiguration.asset = { + ...asset, + src: "https://cdn.test/real-captions.srt" + }; + + mockLayoutCaption.mockClear(); + mockGenerateRichCaptionFrame.mockClear(); + + await player.reloadAsset(); + + // @ts-expect-error accessing private property + expect(player.isPlaceholder).toBe(false); + expect(player.needsResolution).toBe(false); + // @ts-expect-error accessing private property + expect(player.loadComplete).toBe(true); + expect(mockParseSubtitleToWords).toHaveBeenCalled(); + expect(mockLayoutCaption).toHaveBeenCalledTimes(1); + }); + + it("stays in current state if src is still an alias", async () => { + const asset = createAsset({ src: "alias://VIDEO", words: undefined } as Partial); + const edit = createMockEdit(); + const player = new RichCaptionPlayer(edit, createClip(asset)); + await player.load(); + + mockLayoutCaption.mockClear(); + + await player.reloadAsset(); + + // Should not attempt to rebuild — stays as placeholder + // @ts-expect-error accessing private property + expect(player.loadComplete).toBe(false); // was reset, stays false since no rebuild + expect(mockLayoutCaption).not.toHaveBeenCalled(); + }); + + it("clears rendering state before reload", async () => { + const asset = createAsset({ src: "alias://VIDEO", words: undefined } as Partial); + const edit = createMockEdit(); + const player = new RichCaptionPlayer(edit, createClip(asset)); + await player.load(); + + // Verify rendering state exists + // @ts-expect-error accessing private property + expect(player.canvas).not.toBeNull(); + + // Simulate src change to real URL + (player as unknown as { clipConfiguration: ResolvedClip }).clipConfiguration.asset = { + ...asset, + src: "https://cdn.test/captions.srt" + }; + + await player.reloadAsset(); + + // After reload, pipeline is rebuilt + // @ts-expect-error accessing private property + expect(player.loadComplete).toBe(true); + }); + }); }); From 85a3d0ab5785925542878fa962e82b7ebacf4a41 Mon Sep 17 00:00:00 2001 From: dazzatronus Date: Fri, 13 Mar 2026 14:38:34 +1100 Subject: [PATCH 13/25] fix: early return in reloadAsset when asset source is unresolved --- .../canvas/players/rich-caption-player.ts | 13 +++++++------ tests/rich-caption-player.test.ts | 4 ++-- 2 files changed, 9 insertions(+), 8 deletions(-) diff --git a/src/components/canvas/players/rich-caption-player.ts b/src/components/canvas/players/rich-caption-player.ts index d2ecd77..9958bac 100644 --- a/src/components/canvas/players/rich-caption-player.ts +++ b/src/components/canvas/players/rich-caption-player.ts @@ -126,6 +126,13 @@ export class RichCaptionPlayer extends Player { } public override async reloadAsset(): Promise { + const asset = this.clipConfiguration.asset as RichCaptionAsset; + + // Bail out before destroying anything if src is still unresolved + if (!asset.src || isAliasReference(asset.src)) { + return; + } + this.loadComplete = false; if (this.texture) { this.texture.destroy(); this.texture = null; } @@ -136,12 +143,6 @@ export class RichCaptionPlayer extends Player { this.canvas = null; this.painter = null; - const asset = this.clipConfiguration.asset as RichCaptionAsset; - - if (!asset.src || isAliasReference(asset.src)) { - return; - } - this.isPlaceholder = false; this.needsResolution = false; diff --git a/tests/rich-caption-player.test.ts b/tests/rich-caption-player.test.ts index 2306310..3894e50 100644 --- a/tests/rich-caption-player.test.ts +++ b/tests/rich-caption-player.test.ts @@ -1178,9 +1178,9 @@ describe("RichCaptionPlayer", () => { await player.reloadAsset(); - // Should not attempt to rebuild — stays as placeholder + // Should not attempt to rebuild — preserves existing placeholder state // @ts-expect-error accessing private property - expect(player.loadComplete).toBe(false); // was reset, stays false since no rebuild + expect(player.loadComplete).toBe(true); // untouched — early return before destruction expect(mockLayoutCaption).not.toHaveBeenCalled(); }); From 1ac925c4a0d03d4b945217d7fa52f22cc9ff8c9a Mon Sep 17 00:00:00 2001 From: dazzatronus Date: Fri, 13 Mar 2026 14:43:46 +1100 Subject: [PATCH 14/25] test: add RichCaptionPlayer asset reload and layout edge case tests --- tests/rich-caption-player.test.ts | 237 ++++++++++++++++++++++++++++++ 1 file changed, 237 insertions(+) diff --git a/tests/rich-caption-player.test.ts b/tests/rich-caption-player.test.ts index 3894e50..19e5356 100644 --- a/tests/rich-caption-player.test.ts +++ b/tests/rich-caption-player.test.ts @@ -1206,5 +1206,242 @@ describe("RichCaptionPlayer", () => { // @ts-expect-error accessing private property expect(player.loadComplete).toBe(true); }); + it("preserves canvas and sprite when src stays alias", async () => { + const asset = createAsset({ src: "alias://VIDEO", words: undefined } as Partial); + const edit = createMockEdit(); + const player = new RichCaptionPlayer(edit, createClip(asset)); + await player.load(); + + // @ts-expect-error accessing private property + expect(player.canvas).not.toBeNull(); + // @ts-expect-error accessing private property + expect(player.sprite).not.toBeNull(); + + await player.reloadAsset(); + + // Early return should NOT destroy canvas/sprite + // @ts-expect-error accessing private property + expect(player.canvas).not.toBeNull(); + // @ts-expect-error accessing private property + expect(player.sprite).not.toBeNull(); + }); + + it("handles fetch failure during reload without unhandled rejection", async () => { + const errorSpy = jest.spyOn(console, "error").mockImplementation(); + + const asset = createAsset({ src: "alias://VIDEO", words: undefined } as Partial); + const edit = createMockEdit(); + const player = new RichCaptionPlayer(edit, createClip(asset)); + await player.load(); + + // Transition src from alias to real URL + (player as unknown as { clipConfiguration: ResolvedClip }).clipConfiguration.asset = { + ...asset, + src: "https://cdn.test/captions.srt" + }; + + // Make fetch reject during reload + mockFetch.mockRejectedValueOnce(new Error("Network error")); + + // Should not throw an unhandled rejection + await expect(player.reloadAsset()).rejects.toThrow("Network error"); + + // loadComplete should stay false since pipeline was torn down before the fetch + // @ts-expect-error accessing private property + expect(player.loadComplete).toBe(false); + errorSpy.mockRestore(); + }); + + it("creates fallback graphic when reload yields zero words", async () => { + const asset = createAsset({ src: "alias://VIDEO", words: undefined } as Partial); + const edit = createMockEdit(); + const player = new RichCaptionPlayer(edit, createClip(asset)); + await player.load(); + + // Transition src to real URL + (player as unknown as { clipConfiguration: ResolvedClip }).clipConfiguration.asset = { + ...asset, + src: "https://cdn.test/empty.srt" + }; + + // Return empty words from parser + mockParseSubtitleToWords.mockReturnValueOnce([]); + + await player.reloadAsset(); + + // @ts-expect-error accessing private property + expect(player.loadComplete).toBe(false); + // Fallback text should have been added to contentContainer + const pixi = jest.requireMock("pixi.js") as { Text: jest.Mock }; + expect(pixi.Text).toHaveBeenCalledWith("No caption words found", expect.anything()); + }); + }); + + describe("buildLayoutConfig padding branches", () => { + it("computes availableWidth from object padding {top, right, bottom, left}", async () => { + const asset = createAsset({ + padding: { top: 25, right: 10, bottom: 15, left: 10 } + } as Partial); + const edit = createMockEdit(); + const player = new RichCaptionPlayer(edit, createClip(asset, { width: 1920, height: 1080 })); + await player.load(); + + const layoutConfig = mockLayoutCaption.mock.calls[0]?.[1]; + expect(layoutConfig.availableWidth).toBe(1920 - 20); // left + right + expect(layoutConfig.padding).toEqual({ top: 25, right: 10, bottom: 15, left: 10 }); + }); + + it("defaults to 90% width when padding is undefined", async () => { + const asset = createAsset({ padding: undefined } as Partial); + const edit = createMockEdit(); + const player = new RichCaptionPlayer(edit, createClip(asset, { width: 1920, height: 1080 })); + await player.load(); + + const layoutConfig = mockLayoutCaption.mock.calls[0]?.[1]; + expect(layoutConfig.availableWidth).toBe(1920 * 0.9); + expect(layoutConfig.padding).toEqual({ top: 0, right: 0, bottom: 0, left: 0 }); + }); + + it("defaults missing sides to 0 in partial padding object", async () => { + const asset = createAsset({ + padding: { left: 20 } + } as Partial); + const edit = createMockEdit(); + const player = new RichCaptionPlayer(edit, createClip(asset, { width: 1920, height: 1080 })); + await player.load(); + + const layoutConfig = mockLayoutCaption.mock.calls[0]?.[1]; + // right defaults to 0, so availableWidth = 1920 - (20 + 0) = 1900 + expect(layoutConfig.availableWidth).toBe(1920 - 20); + expect(layoutConfig.padding.left).toBe(20); + expect(layoutConfig.padding.right).toBe(0); + expect(layoutConfig.padding.top).toBe(0); + expect(layoutConfig.padding.bottom).toBe(0); + }); + + it("computes maxLines from available height minus vertical padding", async () => { + const asset = createAsset({ + padding: { top: 100, right: 0, bottom: 100, left: 0 }, + font: { family: "Roboto", size: 48, color: "#ffffff" } + } as Partial); + const edit = createMockEdit(); + const player = new RichCaptionPlayer(edit, createClip(asset, { width: 1920, height: 1080 })); + await player.load(); + + const layoutConfig = mockLayoutCaption.mock.calls[0]?.[1]; + // availableHeight = 1080 - 100 - 100 = 880, fontSize = 48, lineHeight = 1.2 + // maxLines = floor(880 / (48 * 1.2)) = floor(880 / 57.6) = 15, clamped to 10 + expect(layoutConfig.maxLines).toBe(10); + }); + }); + + describe("rebuildForCurrentSize edge cases", () => { + it("discards stale layout when dimensions change rapidly (pendingLayoutId race)", async () => { + const edit = createMockEdit(); + const player = new RichCaptionPlayer(edit, createClip(createAsset())); + await player.load(); + + // Track layout calls and frame renders separately + mockLayoutCaption.mockClear(); + mockGenerateRichCaptionFrame.mockClear(); + + // Make layoutCaption resolve asynchronously so we can race two calls + let resolveFirst!: (value: unknown) => void; + let resolveSecond!: (value: unknown) => void; + const firstLayout = new Promise(r => { resolveFirst = r; }); + const secondLayout = new Promise(r => { resolveSecond = r; }); + + const layoutResult = { + store: { + length: 3, + words: ["Hello", "World", "Test"], + startTimes: [0, 500, 1000], + endTimes: [400, 900, 1400], + xPositions: [100, 300, 500], + yPositions: [540, 540, 540], + widths: [120, 130, 100] + }, + groups: [{ + wordIndices: [0, 1, 2], + startTime: 0, + endTime: 1400, + lines: [{ wordIndices: [0, 1, 2], x: 100, y: 540, width: 400, height: 48 }] + }], + shapedWords: [ + { text: "Hello", width: 120, glyphs: [], isRTL: false }, + { text: "World", width: 130, glyphs: [], isRTL: false }, + { text: "Test", width: 100, glyphs: [], isRTL: false } + ] + }; + + mockLayoutCaption + .mockReturnValueOnce(firstLayout) + .mockReturnValueOnce(secondLayout); + + // Trigger two rapid dimension changes without awaiting + // @ts-expect-error accessing protected method + player.onDimensionsChanged(); + // @ts-expect-error accessing protected method + player.onDimensionsChanged(); + + // Resolve the second (latest) first, then the first (stale) + resolveSecond(layoutResult); + await secondLayout; + resolveFirst(layoutResult); + await firstLayout; + + // Allow microtasks to settle + await new Promise(resolve => { setTimeout(resolve, 10); }); + + // layoutCaption was called twice + expect(mockLayoutCaption).toHaveBeenCalledTimes(2); + // But only the second layout should have rendered (first was discarded by stale-ID guard) + expect(mockGenerateRichCaptionFrame).toHaveBeenCalledTimes(1); + }); + + it("handles canvas validation failure during resize without crash", async () => { + const { CanvasRichCaptionAssetSchema } = jest.requireMock("@shotstack/shotstack-canvas") as { + CanvasRichCaptionAssetSchema: { safeParse: jest.Mock }; + }; + + const edit = createMockEdit(); + const player = new RichCaptionPlayer(edit, createClip(createAsset())); + await player.load(); + + // Make canvas validation fail on next call (during resize) + CanvasRichCaptionAssetSchema.safeParse.mockReturnValueOnce({ success: false }); + + mockLayoutCaption.mockClear(); + + // @ts-expect-error accessing protected method + player.onDimensionsChanged(); + + await new Promise(resolve => { setTimeout(resolve, 10); }); + + // Layout should NOT be called since validation failed early + expect(mockLayoutCaption).not.toHaveBeenCalled(); + // @ts-expect-error accessing private property + expect(player.captionLayout).toBeNull(); + }); + + it("skips rebuild when words array is empty", async () => { + const edit = createMockEdit(); + const player = new RichCaptionPlayer(edit, createClip(createAsset())); + await player.load(); + + // Clear words to trigger early return in onDimensionsChanged + // @ts-expect-error accessing private property + player.words = []; + + mockLayoutCaption.mockClear(); + + // @ts-expect-error accessing protected method + player.onDimensionsChanged(); + + await new Promise(resolve => { setTimeout(resolve, 10); }); + + // Should NOT call layoutCaption because of the early return guard + expect(mockLayoutCaption).not.toHaveBeenCalled(); + }); }); }); From 690ec455b4cb17fee50143cb98d70ec62a3ae44f Mon Sep 17 00:00:00 2001 From: Kratos2k7 Date: Fri, 13 Mar 2026 20:30:48 +0500 Subject: [PATCH 15/25] chore: bump canvas and schema version --- package.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/package.json b/package.json index 91ba670..2a3f699 100644 --- a/package.json +++ b/package.json @@ -96,8 +96,8 @@ "vite-plugin-dts": "^4.5.4" }, "dependencies": { - "@shotstack/schemas": "1.9.0", - "@shotstack/shotstack-canvas": "^2.1.5", + "@shotstack/schemas": "1.9.1", + "@shotstack/shotstack-canvas": "^2.1.6", "howler": "^2.2.4", "mediabunny": "^1.11.2", "opentype.js": "^1.3.4", From 50da3153a09e151e9fb9d442d901d48ffc699a73 Mon Sep 17 00:00:00 2001 From: dazzatronus Date: Sat, 14 Mar 2026 07:25:42 +1100 Subject: [PATCH 16/25] feat: add tabbed active word styling with shadow effects --- src/core/ui/font-color-picker.ts | 77 +++- src/core/ui/rich-caption-toolbar.ts | 606 ++++++++++++++++++++-------- src/core/ui/rich-text-toolbar.ts | 14 +- src/styles/ui/rich-text-toolbar.css | 6 + tests/rich-caption-player.test.ts | 7 +- tests/rich-caption-toolbar.test.ts | 81 +--- 6 files changed, 512 insertions(+), 279 deletions(-) diff --git a/src/core/ui/font-color-picker.ts b/src/core/ui/font-color-picker.ts index 65792e9..7988bf2 100644 --- a/src/core/ui/font-color-picker.ts +++ b/src/core/ui/font-color-picker.ts @@ -314,12 +314,17 @@ type ColorMode = "color" | "gradient"; type FontColorChangeCallback = (updates: { color?: string; opacity?: number; - background?: string; + background?: string | undefined; gradient?: { type: "linear" | "radial"; angle: number; stops: Array<{ offset: number; color: string }> }; }) => void; +export interface FontColorPickerOptions { + hideGradient?: boolean; +} + export class FontColorPicker { private container: HTMLDivElement | null = null; + private hideGradient: boolean; // Tab buttons private colorTab: HTMLButtonElement | null = null; @@ -336,6 +341,7 @@ export class FontColorPicker { // Highlight elements (in color tab) private highlightColorInput: HTMLInputElement | null = null; + private highlightToggle: HTMLInputElement | null = null; private onColorChange: FontColorChangeCallback | null = null; @@ -360,6 +366,19 @@ export class FontColorPicker { this.highlightThrottle.call(); }; + private handleHighlightToggle = (): void => { + const enabled = this.highlightToggle?.checked ?? false; + if (this.highlightColorInput) { + this.highlightColorInput.disabled = !enabled; + this.highlightColorInput.style.opacity = enabled ? "1" : "0.3"; + } + if (enabled) { + this.highlightThrottle.call(); + } else if (this.onColorChange) { + this.onColorChange({ background: undefined }); + } + }; + // Flush throttle on slider release (change event) to ensure final value is applied private handleColorOpacityChange = (): void => { this.colorThrottle.flush(); @@ -369,7 +388,8 @@ export class FontColorPicker { this.setMode(mode); }; - constructor() { + constructor(options?: FontColorPickerOptions) { + this.hideGradient = options?.hideGradient ?? false; injectShotstackStyles(); } @@ -377,11 +397,21 @@ export class FontColorPicker { this.container = document.createElement("div"); this.container.className = "ss-font-color-picker"; - this.container.innerHTML = ` -
+ const tabsHTML = this.hideGradient + ? "" + : `
-
+
`; + + const gradientHTML = this.hideGradient + ? "" + : `
+ ${this.buildGradientHTML()} +
`; + + this.container.innerHTML = ` + ${tabsHTML}
@@ -397,13 +427,14 @@ export class FontColorPicker {
Highlight
- +
+ + +
-
- ${this.buildGradientHTML()} -
+ ${gradientHTML} `; parent.appendChild(this.container); @@ -423,6 +454,7 @@ export class FontColorPicker { // Query highlight elements this.highlightColorInput = this.container.querySelector("[data-highlight-color]"); + this.highlightToggle = this.container.querySelector("[data-highlight-toggle]"); // Setup event listeners using arrow function handlers for proper cleanup this.colorTab?.addEventListener("click", () => this.handleTabClick("color")); @@ -435,16 +467,19 @@ export class FontColorPicker { this.colorOpacitySlider?.addEventListener("input", this.handleColorOpacityInput); this.colorOpacitySlider?.addEventListener("change", this.handleColorOpacityChange); - // Highlight color: throttle on input + // Highlight toggle + color: throttle on input + this.highlightToggle?.addEventListener("change", this.handleHighlightToggle); this.highlightColorInput?.addEventListener("input", this.handleHighlightInputChange); // Setup gradient swatch click handlers - this.container.querySelectorAll("[data-cat]").forEach(btn => { - btn.addEventListener("click", e => { - const el = e.currentTarget as HTMLButtonElement; - this.handleGradientClick(parseInt(el.dataset["cat"] || "0", 10), parseInt(el.dataset["idx"] || "0", 10)); + if (!this.hideGradient) { + this.container.querySelectorAll("[data-cat]").forEach(btn => { + btn.addEventListener("click", e => { + const el = e.currentTarget as HTMLButtonElement; + this.handleGradientClick(parseInt(el.dataset["cat"] || "0", 10), parseInt(el.dataset["idx"] || "0", 10)); + }); }); - }); + } } private emitColorChange(): void { @@ -516,9 +551,15 @@ export class FontColorPicker { } } - setHighlight(color: string): void { + setHighlight(color: string | undefined): void { + const hasHighlight = color !== undefined; + if (this.highlightToggle) { + this.highlightToggle.checked = hasHighlight; + } if (this.highlightColorInput) { - this.highlightColorInput.value = color.toUpperCase(); + this.highlightColorInput.value = (color ?? "#FFFF00").toUpperCase(); + this.highlightColorInput.disabled = !hasHighlight; + this.highlightColorInput.style.opacity = hasHighlight ? "1" : "0.3"; } } @@ -535,6 +576,7 @@ export class FontColorPicker { this.colorInput?.removeEventListener("input", this.handleColorInputChange); this.colorOpacitySlider?.removeEventListener("input", this.handleColorOpacityInput); this.colorOpacitySlider?.removeEventListener("change", this.handleColorOpacityChange); + this.highlightToggle?.removeEventListener("change", this.handleHighlightToggle); this.highlightColorInput?.removeEventListener("input", this.handleHighlightInputChange); this.container?.remove(); @@ -547,6 +589,7 @@ export class FontColorPicker { this.colorOpacitySlider = null; this.colorOpacityValue = null; this.highlightColorInput = null; + this.highlightToggle = null; this.onColorChange = null; } } diff --git a/src/core/ui/rich-caption-toolbar.ts b/src/core/ui/rich-caption-toolbar.ts index 318d4cd..4d59c7f 100644 --- a/src/core/ui/rich-caption-toolbar.ts +++ b/src/core/ui/rich-caption-toolbar.ts @@ -1,6 +1,7 @@ -import type { RichCaptionAsset, ResolvedClip } from "@schemas"; +import type { RichCaptionAsset } from "@schemas"; import { StylePanel } from "./composites/StylePanel"; +import { FontColorPicker } from "./font-color-picker"; import { RichTextToolbar } from "./rich-text-toolbar"; /** @@ -14,35 +15,52 @@ export class RichCaptionToolbar extends RichTextToolbar { private wordAnimPopup: HTMLDivElement | null = null; private activeWordPopup: HTMLDivElement | null = null; - // Word Animation slider refs - private wordAnimSpeedSlider: HTMLInputElement | null = null; - private wordAnimSpeedValue: HTMLSpanElement | null = null; + // Word Animation refs private wordAnimDirectionSection: HTMLDivElement | null = null; - // Active-mode scale control - private scaleSlider: HTMLInputElement | null = null; - private scaleValue: HTMLSpanElement | null = null; - - // Active-word dedicated controls + // Active-word font controls + private activeColorToggle: HTMLInputElement | null = null; private activeColorInput: HTMLInputElement | null = null; private activeOpacitySlider: HTMLInputElement | null = null; private activeOpacityValue: HTMLSpanElement | null = null; + private activeHighlightToggle: HTMLInputElement | null = null; private activeHighlightInput: HTMLInputElement | null = null; + // Active-word stroke controls + private activeStrokeToggle: HTMLInputElement | null = null; private activeStrokeWidthSlider: HTMLInputElement | null = null; private activeStrokeWidthValue: HTMLSpanElement | null = null; private activeStrokeColorInput: HTMLInputElement | null = null; private activeStrokeOpacitySlider: HTMLInputElement | null = null; private activeStrokeOpacityValue: HTMLSpanElement | null = null; - // Current slider values during drag (for final commit) - private currentWordAnimSpeed = 1; - private currentActiveScale = 1; + // Active-word shadow controls + private activeShadowToggle: HTMLInputElement | null = null; + private activeShadowOffsetXSlider: HTMLInputElement | null = null; + private activeShadowOffsetXValue: HTMLSpanElement | null = null; + private activeShadowOffsetYSlider: HTMLInputElement | null = null; + private activeShadowOffsetYValue: HTMLSpanElement | null = null; + private activeShadowColorInput: HTMLInputElement | null = null; + private activeShadowOpacitySlider: HTMLInputElement | null = null; + private activeShadowOpacityValue: HTMLSpanElement | null = null; + + // Active-word text decoration buttons + private activeTextDecorationBtns: NodeListOf | null = null; + + // Active-word tab state + private activeWordTabs: NodeListOf | null = null; + private activeWordPanels: NodeListOf | null = null; + + protected override createStylePanel(): StylePanel { return new StylePanel({}); } + protected override createFontColorPicker(): FontColorPicker { + return new FontColorPicker({ hideGradient: true }); + } + // ─── Lifecycle ───────────────────────────────────────────────────── override mount(parent: HTMLElement): void { @@ -64,20 +82,30 @@ export class RichCaptionToolbar extends RichTextToolbar { super.dispose(); this.wordAnimPopup = null; this.activeWordPopup = null; - this.wordAnimSpeedSlider = null; - this.wordAnimSpeedValue = null; this.wordAnimDirectionSection = null; - this.scaleSlider = null; - this.scaleValue = null; + this.activeColorToggle = null; this.activeColorInput = null; this.activeOpacitySlider = null; this.activeOpacityValue = null; + this.activeHighlightToggle = null; this.activeHighlightInput = null; + this.activeStrokeToggle = null; this.activeStrokeWidthSlider = null; this.activeStrokeWidthValue = null; this.activeStrokeColorInput = null; this.activeStrokeOpacitySlider = null; this.activeStrokeOpacityValue = null; + this.activeShadowToggle = null; + this.activeShadowOffsetXSlider = null; + this.activeShadowOffsetXValue = null; + this.activeShadowOffsetYSlider = null; + this.activeShadowOffsetYValue = null; + this.activeShadowColorInput = null; + this.activeShadowOpacitySlider = null; + this.activeShadowOpacityValue = null; + this.activeTextDecorationBtns = null; + this.activeWordTabs = null; + this.activeWordPanels = null; } // ─── Overrides ───────────────────────────────────────────────────── @@ -120,14 +148,6 @@ export class RichCaptionToolbar extends RichTextToolbar { this.setButtonActive(btn, btn.dataset["captionWordStyle"] === animStyle); }); - const speed = wordAnim?.speed ?? 1; - if (this.wordAnimSpeedSlider) this.wordAnimSpeedSlider.value = String(speed); - if (this.wordAnimSpeedValue) this.wordAnimSpeedValue.textContent = `${speed.toFixed(1)}x`; - - // Hide speed when "none" (nothing to animate) - const speedSection = this.container?.querySelector("[data-caption-word-speed-section]") as HTMLElement | null; - if (speedSection) speedSection.style.display = animStyle === "none" ? "none" : ""; - if (this.wordAnimDirectionSection) { this.wordAnimDirectionSection.style.display = animStyle === "slide" ? "" : "none"; } @@ -139,28 +159,87 @@ export class RichCaptionToolbar extends RichTextToolbar { // ─── Active Word Controls ─────────────────────────── const activeData = asset.active; + const baseFont = asset.font; + const baseStroke = asset.stroke; + + // Text decoration + const textDecoration = (activeData?.font as Record | undefined)?.["textDecoration"] as string | undefined; + this.activeTextDecorationBtns?.forEach(btn => { + this.setButtonActive(btn, btn.dataset["activeTextDecoration"] === (textDecoration ?? "none")); + }); - if (this.activeColorInput) this.activeColorInput.value = activeData?.font?.color ?? "#ffff00"; - const opacity = Math.round((activeData?.font?.opacity ?? 1) * 100); - if (this.activeOpacitySlider) this.activeOpacitySlider.value = String(opacity); + // ─── Font tab ────────────────────────────────────── + const hasActiveColor = activeData?.font?.color !== undefined; + if (this.activeColorToggle) this.activeColorToggle.checked = hasActiveColor; + if (this.activeColorInput) { + this.activeColorInput.value = hasActiveColor ? (activeData!.font!.color ?? "#ffff00") : (baseFont?.color ?? "#ffffff"); + this.activeColorInput.disabled = !hasActiveColor; + this.activeColorInput.style.opacity = hasActiveColor ? "1" : "0.3"; + } + const opacity = Math.round((hasActiveColor ? (activeData?.font?.opacity ?? 1) : (baseFont?.opacity ?? 1)) * 100); + if (this.activeOpacitySlider) { + this.activeOpacitySlider.value = String(opacity); + this.activeOpacitySlider.disabled = !hasActiveColor; + } if (this.activeOpacityValue) this.activeOpacityValue.textContent = `${opacity}%`; - if (this.activeHighlightInput) - this.activeHighlightInput.value = ((activeData?.font as Record | undefined)?.["background"] as string) ?? "#000000"; - - if (this.activeStrokeWidthSlider) this.activeStrokeWidthSlider.value = String(activeData?.stroke?.width ?? 0); - if (this.activeStrokeWidthValue) this.activeStrokeWidthValue.textContent = String(activeData?.stroke?.width ?? 0); - if (this.activeStrokeColorInput) this.activeStrokeColorInput.value = activeData?.stroke?.color ?? "#000000"; - const strokeOpacity = Math.round((activeData?.stroke?.opacity ?? 1) * 100); - if (this.activeStrokeOpacitySlider) this.activeStrokeOpacitySlider.value = String(strokeOpacity); - if (this.activeStrokeOpacityValue) this.activeStrokeOpacityValue.textContent = `${strokeOpacity}%`; - const scale = activeData?.scale ?? 1; - if (this.scaleSlider) this.scaleSlider.value = String(scale); - if (this.scaleValue) this.scaleValue.textContent = `${scale.toFixed(1)}x`; + const activeBackground = (activeData?.font as Record | undefined)?.["background"] as string | undefined; + const baseBackground = (baseFont as Record | undefined)?.["background"] as string | undefined; + const hasActiveHighlight = activeBackground !== undefined; + if (this.activeHighlightToggle) this.activeHighlightToggle.checked = hasActiveHighlight; + if (this.activeHighlightInput) { + this.activeHighlightInput.value = hasActiveHighlight ? activeBackground : (baseBackground ?? "#000000"); + this.activeHighlightInput.disabled = !hasActiveHighlight; + this.activeHighlightInput.style.opacity = hasActiveHighlight ? "1" : "0.3"; + } + + // ─── Stroke tab ──────────────────────────────────── + const activeStroke = activeData?.stroke; + const hasActiveStroke = typeof activeStroke === "object"; + if (this.activeStrokeToggle) this.activeStrokeToggle.checked = hasActiveStroke; + const strokeWidth = hasActiveStroke ? (activeStroke.width ?? 0) : (baseStroke?.width ?? 0); + if (this.activeStrokeWidthSlider) { + this.activeStrokeWidthSlider.value = String(strokeWidth); + this.activeStrokeWidthSlider.disabled = !hasActiveStroke; + } + if (this.activeStrokeWidthValue) this.activeStrokeWidthValue.textContent = String(strokeWidth); + if (this.activeStrokeColorInput) { + this.activeStrokeColorInput.value = hasActiveStroke ? (activeStroke.color ?? "#000000") : (baseStroke?.color ?? "#000000"); + this.activeStrokeColorInput.disabled = !hasActiveStroke; + this.activeStrokeColorInput.style.opacity = hasActiveStroke ? "1" : "0.3"; + } + const strokeOpacity = Math.round((hasActiveStroke ? (activeStroke.opacity ?? 1) : (baseStroke?.opacity ?? 1)) * 100); + if (this.activeStrokeOpacitySlider) { + this.activeStrokeOpacitySlider.value = String(strokeOpacity); + this.activeStrokeOpacitySlider.disabled = !hasActiveStroke; + } + if (this.activeStrokeOpacityValue) this.activeStrokeOpacityValue.textContent = `${strokeOpacity}%`; - // Show scale section only when word animation is "pop" - const scaleSection = this.container?.querySelector("[data-caption-scale-section]") as HTMLElement | null; - if (scaleSection) scaleSection.style.display = animStyle === "pop" ? "" : "none"; + // ─── Shadow tab ──────────────────────────────────── + const activeShadow = activeData?.shadow; + const hasShadow = typeof activeShadow === "object"; + if (this.activeShadowToggle) this.activeShadowToggle.checked = hasShadow; + if (this.activeShadowOffsetXSlider) { + this.activeShadowOffsetXSlider.value = String(hasShadow ? (activeShadow.offsetX ?? 2) : 2); + this.activeShadowOffsetXSlider.disabled = !hasShadow; + } + if (this.activeShadowOffsetXValue) this.activeShadowOffsetXValue.textContent = String(hasShadow ? (activeShadow.offsetX ?? 2) : 2); + if (this.activeShadowOffsetYSlider) { + this.activeShadowOffsetYSlider.value = String(hasShadow ? (activeShadow.offsetY ?? 2) : 2); + this.activeShadowOffsetYSlider.disabled = !hasShadow; + } + if (this.activeShadowOffsetYValue) this.activeShadowOffsetYValue.textContent = String(hasShadow ? (activeShadow.offsetY ?? 2) : 2); + if (this.activeShadowColorInput) { + this.activeShadowColorInput.value = hasShadow ? (activeShadow.color ?? "#000000") : "#000000"; + this.activeShadowColorInput.disabled = !hasShadow; + this.activeShadowColorInput.style.opacity = hasShadow ? "1" : "0.3"; + } + const shadowOpacity = Math.round((hasShadow ? (activeShadow.opacity ?? 0.5) : 0.5) * 100); + if (this.activeShadowOpacitySlider) { + this.activeShadowOpacitySlider.value = String(shadowOpacity); + this.activeShadowOpacitySlider.disabled = !hasShadow; + } + if (this.activeShadowOpacityValue) this.activeShadowOpacityValue.textContent = `${shadowOpacity}%`; } // ─── Caption Asset Helper ────────────────────────────────────────── @@ -196,13 +275,6 @@ export class RichCaptionToolbar extends RichTextToolbar { -
-
Speed
-
- - 1.0x -
-