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..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.0.17", + "@shotstack/schemas": "1.9.0", + "@shotstack/shotstack-canvas": "^2.1.5", "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 6950187..1ed6e69 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, @@ -381,9 +382,9 @@ 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, + height }; const optionalFields: Record = { @@ -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)) { @@ -406,7 +407,7 @@ export class RichCaptionPlayer extends Player { } if (customFonts.length > 0) { - payload['customFonts'] = customFonts; + payload["customFonts"] = customFonts; } return payload; @@ -414,24 +415,43 @@ 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 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: frameWidth * 0.9, - maxLines: 2, - verticalAlign: align?.vertical ?? "bottom", + availableWidth, + maxLines, + verticalAlign: align?.vertical ?? "middle", horizontalAlign: align?.horizontal ?? "center", - paddingLeft: padding, - fontSize: font?.size ?? 24, + padding, + 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: asset.pauseThreshold ?? 500 + pauseThreshold: this.resolvedPauseThreshold }; } @@ -530,20 +550,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; + 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) { @@ -552,11 +608,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); } diff --git a/tests/rich-caption-player.test.ts b/tests/rich-caption-player.test.ts index 3947c31..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 () => { @@ -436,12 +435,12 @@ 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); }); - }); - describe("Rendering", () => { + describe("Rendering", () => { it("renders first frame during load", async () => { const edit = createMockEdit(); const player = new RichCaptionPlayer(edit, createClip(createAsset())); @@ -533,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())); @@ -663,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, @@ -741,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); }); @@ -786,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({ @@ -847,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); @@ -874,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 @@ -908,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();