From c4d09fb507451f621c2bef09f2a1acc7b69ee695 Mon Sep 17 00:00:00 2001 From: dazzatronus Date: Mon, 8 Jun 2026 13:31:43 +1000 Subject: [PATCH 1/7] fix: delegate rich-caption font resolution to shotstack-canvas --- .../canvas/players/rich-caption-player.ts | 80 +++++---- tests/rich-caption-player.test.ts | 153 ++++++++++++------ 2 files changed, 138 insertions(+), 95 deletions(-) diff --git a/src/components/canvas/players/rich-caption-player.ts b/src/components/canvas/players/rich-caption-player.ts index 6a3bfa4..3392e06 100644 --- a/src/components/canvas/players/rich-caption-player.ts +++ b/src/components/canvas/players/rich-caption-player.ts @@ -12,6 +12,7 @@ import { createDefaultGeneratorConfig, createWebPainter, buildCaptionLayoutConfig, + resolveCaptionFonts, parseSubtitleToWords, CanvasRichCaptionAssetSchema, type CanvasRichCaptionAsset, @@ -218,11 +219,6 @@ export class RichCaptionPlayer extends Player { const { width, height } = this.getSize(); const layoutConfig = buildCaptionLayoutConfig(this.validatedAsset, width, height); - const letterSpacing = this.validatedAsset?.style?.letterSpacing; - const canvasTextMeasurer = this.createCanvasTextMeasurer(letterSpacing); - if (canvasTextMeasurer) { - layoutConfig.measureTextWidth = canvasTextMeasurer; - } this.captionLayout = await this.layoutEngine.layoutCaption(this.words, layoutConfig); this.generatorConfig = createDefaultGeneratorConfig(width, height, 1); @@ -253,12 +249,6 @@ export class RichCaptionPlayer extends Player { const { width, height } = this.getSize(); const layoutConfig = buildCaptionLayoutConfig(this.validatedAsset, width, height); - const letterSpacing = this.validatedAsset?.style?.letterSpacing; - const canvasTextMeasurer = this.createCanvasTextMeasurer(letterSpacing); - if (canvasTextMeasurer) { - layoutConfig.measureTextWidth = canvasTextMeasurer; - } - this.captionLayout = await this.layoutEngine.layoutCaption(words, layoutConfig); this.generatorConfig = createDefaultGeneratorConfig(width, height, 1); @@ -323,20 +313,46 @@ export class RichCaptionPlayer extends Player { } } + // Shared font resolution used by both registration and the render payload, so the registered + // face and the rendered family are always the same. Delegates to the canvas resolver. + private resolveFontsForAsset(asset: RichCaptionAsset) { + const family = asset.font?.family ?? "Roboto"; + const weight = asset.font?.weight ? parseInt(String(asset.font.weight), 10) || 400 : 400; + const fontNameMap = new Map(); + for (const [src, meta] of this.edit.getFontMetadata()) fontNameMap.set(src, meta.baseFamilyName); + const activeFamily = asset.active?.font?.family; + const activeWeight = asset.active?.font?.weight; + return resolveCaptionFonts({ + family, + weight, + timelineFonts: this.edit.getTimelineFonts(), + fontNameMap, + ...(activeFamily ? { activeFamily } : {}), + ...(activeWeight != null ? { activeWeight: activeWeight as string | number } : {}) + }); + } + private async registerFonts(asset: RichCaptionAsset): Promise { if (!this.fontRegistry) return; const family = asset.font?.family ?? "Roboto"; const assetWeight = asset.font?.weight ? parseInt(String(asset.font.weight), 10) || 400 : 400; + + // Registration and the render payload (buildCanvasPayload) share this one resolution, so the + // render looks up exactly the face that was registered. + const resolution = this.resolveFontsForAsset(asset); + + if (resolution.matched) { + for (const font of resolution.fonts) { + if (font.src) await this.registerFontFromUrl(font.src, font.family, parseInt(font.weight, 10) || 400); + } + return; + } + const resolved = this.resolveFontWithWeight(family, assetWeight); if (resolved) { await this.registerFontFromUrl(resolved.url, resolved.baseFontFamily, resolved.fontWeight); } - - const customFonts = this.buildCustomFontsFromTimeline(asset); - for (const customFont of customFonts) { - await this.registerFontFromUrl(customFont.src, customFont.family, parseInt(customFont.weight, 10) || 400); - } } private async registerFontFromUrl(url: string, family: string, weight: number): Promise { @@ -465,8 +481,12 @@ export class RichCaptionPlayer extends Player { private buildCanvasPayload(asset: RichCaptionAsset, words: WordTiming[]): Record { const { width, height } = this.getSize(); - const customFonts = this.buildCustomFontsFromTimeline(asset); - const resolvedFamily = getFontDisplayName(asset.font?.family ?? "Roboto"); + // Use the same resolution as registration, so the rendered family matches the registered face. + const resolution = this.resolveFontsForAsset(asset); + const resolvedFamily = resolution.matched ? resolution.resolvedFamily : getFontDisplayName(asset.font?.family ?? "Roboto"); + const customFonts = resolution.matched + ? resolution.fonts.filter(f => f.src).map(f => ({ src: f.src as string, family: f.family, weight: f.weight })) + : this.buildCustomFontsFromTimeline(asset); const payload: Record = { type: asset.type, @@ -502,25 +522,6 @@ export class RichCaptionPlayer extends Player { return payload; } - private createCanvasTextMeasurer(letterSpacing?: number): ((text: string, font: string) => number) | undefined { - try { - const measureCanvas = document.createElement("canvas"); - const ctx = measureCanvas.getContext("2d"); - if (!ctx) return undefined; - - if (letterSpacing) { - (ctx as unknown as Record)["letterSpacing"] = `${letterSpacing}px`; - } - - return (text: string, font: string): number => { - ctx.font = font; - return ctx.measureText(text).width; - }; - } catch { - return undefined; - } - } - private createFallbackGraphic(message: string): void { const { width, height } = this.getSize(); @@ -652,11 +653,6 @@ export class RichCaptionPlayer extends Player { if (!this.layoutEngine) return; const layoutConfig = buildCaptionLayoutConfig(this.validatedAsset, width, height); - const letterSpacing = this.validatedAsset?.style?.letterSpacing; - const canvasTextMeasurer = this.createCanvasTextMeasurer(letterSpacing); - if (canvasTextMeasurer) { - layoutConfig.measureTextWidth = canvasTextMeasurer; - } this.pendingLayoutId += 1; const layoutId = this.pendingLayoutId; diff --git a/tests/rich-caption-player.test.ts b/tests/rich-caption-player.test.ts index 3b5e784..cb32340 100644 --- a/tests/rich-caption-player.test.ts +++ b/tests/rich-caption-player.test.ts @@ -157,7 +157,7 @@ jest.mock("@shotstack/shotstack-canvas", () => { .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" }], + ops: [{ op: "FillPath", path: "M 0 0 L 1 0 L 1 1 Z", x: 100, y: 540, scale: 1, fill: { kind: "solid", color: "#ffffff", opacity: 1 } }], visibleWordCount: 1, activeWordIndex: 0 }); @@ -190,43 +190,52 @@ jest.mock("@shotstack/shotstack-canvas", () => { }), createWebPainter: (...args: unknown[]) => mockCreateWebPainter(...args), parseSubtitleToWords: (...args: unknown[]) => mockParseSubtitleToWords(...args), - buildCaptionLayoutConfig: (asset: Record, frameWidth: number, frameHeight: number) => { - const rawPadding = asset['padding'] as number | { top?: number; right?: number; bottom?: number; left?: number } | undefined; - let padding: { top: number; right: number; bottom: number; left: number }; - if (typeof rawPadding === "number") { - padding = { top: rawPadding, right: rawPadding, bottom: rawPadding, left: rawPadding }; - } else if (rawPadding) { - padding = { top: rawPadding.top ?? 0, right: rawPadding.right ?? 0, bottom: rawPadding.bottom ?? 0, left: rawPadding.left ?? 0 }; - } else { - padding = { top: 0, right: 0, bottom: 0, left: 0 }; - } - const font = asset['font'] as { size?: number; family?: string; weight?: number | string } | undefined; - const style = asset['style'] as { letterSpacing?: number; lineHeight?: number; textTransform?: string } | undefined; - const align = asset['align'] as { vertical?: string; horizontal?: string } | undefined; - const fontSize = font?.size ?? 24; - const lineHeight = style?.lineHeight ?? 1.2; - const availableWidth = frameWidth - padding.left - padding.right; - const availableHeight = frameHeight - padding.top - padding.bottom; - const maxLines = Math.max(1, Math.min(10, Math.floor(availableHeight / (fontSize * lineHeight)))); - const vertical = align?.vertical; - const horizontal = align?.horizontal; - return { - frameWidth, - frameHeight, - availableWidth, - maxLines, - verticalAlign: (() => { if (vertical === "top") return "top"; if (vertical === "middle") return "middle"; return "bottom"; })(), - horizontalAlign: (() => { if (horizontal === "left") return "left"; if (horizontal === "right") return "right"; return "center"; })(), - padding, - fontSize, - fontFamily: font?.family ?? "Roboto", - fontWeight: String(font?.weight ?? "400"), - letterSpacing: style?.letterSpacing ?? 0, - lineHeight, - textTransform: style?.textTransform ?? "none", - pauseThreshold: (asset['pauseThreshold'] as number) ?? 500, - }; - }, + resolveCaptionFonts: jest.fn(() => ({ fonts: [], resolvedFamily: "Roboto", matched: false })), + buildCaptionLayoutConfig: (asset: Record, frameWidth: number, frameHeight: number) => { + const rawPadding = asset["padding"] as number | { top?: number; right?: number; bottom?: number; left?: number } | undefined; + let padding: { top: number; right: number; bottom: number; left: number }; + if (typeof rawPadding === "number") { + padding = { top: rawPadding, right: rawPadding, bottom: rawPadding, left: rawPadding }; + } else if (rawPadding) { + padding = { top: rawPadding.top ?? 0, right: rawPadding.right ?? 0, bottom: rawPadding.bottom ?? 0, left: rawPadding.left ?? 0 }; + } else { + padding = { top: 0, right: 0, bottom: 0, left: 0 }; + } + const font = asset["font"] as { size?: number; family?: string; weight?: number | string } | undefined; + const style = asset["style"] as { letterSpacing?: number; lineHeight?: number; textTransform?: string } | undefined; + const align = asset["align"] as { vertical?: string; horizontal?: string } | undefined; + const fontSize = font?.size ?? 24; + const lineHeight = style?.lineHeight ?? 1.2; + const availableWidth = frameWidth - padding.left - padding.right; + const availableHeight = frameHeight - padding.top - padding.bottom; + const maxLines = Math.max(1, Math.min(10, Math.floor(availableHeight / (fontSize * lineHeight)))); + const vertical = align?.vertical; + const horizontal = align?.horizontal; + return { + frameWidth, + frameHeight, + availableWidth, + maxLines, + verticalAlign: (() => { + if (vertical === "top") return "top"; + if (vertical === "middle") return "middle"; + return "bottom"; + })(), + horizontalAlign: (() => { + if (horizontal === "left") return "left"; + if (horizontal === "right") return "right"; + return "center"; + })(), + padding, + fontSize, + fontFamily: font?.family ?? "Roboto", + fontWeight: String(font?.weight ?? "400"), + letterSpacing: style?.letterSpacing ?? 0, + lineHeight, + textTransform: style?.textTransform ?? "none", + pauseThreshold: (asset["pauseThreshold"] as number) ?? 500 + }; + }, CanvasRichCaptionAssetSchema: { safeParse: jest.fn().mockImplementation((asset: unknown) => ({ success: true, @@ -307,6 +316,32 @@ describe("RichCaptionPlayer", () => { }; }); + describe("Shared canvas resolver wiring", () => { + it("registers the resolver's fonts under the resolved family + weight when a timeline font matches", async () => { + const canvas = jest.requireMock("@shotstack/shotstack-canvas") as { resolveCaptionFonts: jest.Mock }; + canvas.resolveCaptionFonts.mockReturnValue({ + matched: true, + resolvedFamily: "Montserrat", + fonts: [{ src: "https://cdn.test/Montserrat.ttf", family: "Montserrat", weight: "700" }] + }); + try { + const edit = createMockEdit(); + const player = new RichCaptionPlayer( + edit, + createClip(createAsset({ font: { family: "JTUSjIg1_hash", weight: 700 } } as Partial)) + ); + await player.load(); + + // The declared file is fetched and registered under the resolver's family + weight — + // not via the built-in fallback (which would never hit this URL). + expect(mockFetch).toHaveBeenCalledWith("https://cdn.test/Montserrat.ttf"); + expect(mockRegisterFromBytes).toHaveBeenCalledWith(expect.any(ArrayBuffer), { family: "Montserrat", weight: "700" }); + } finally { + canvas.resolveCaptionFonts.mockReturnValue({ fonts: [], resolvedFamily: "Roboto", matched: false }); + } + }); + }); + describe("Construction & Validation", () => { it("strips fit property from clip config", () => { const edit = createMockEdit(); @@ -1166,7 +1201,9 @@ describe("RichCaptionPlayer", () => { CanvasRichCaptionAssetSchema.safeParse.mockClear(); player.reconfigureAfterRestore(); - await new Promise(resolve => { setTimeout(resolve, 10); }); + await new Promise(resolve => { + setTimeout(resolve, 10); + }); // After reconfigure, placeholder should span the new 20s length payload = CanvasRichCaptionAssetSchema.safeParse.mock.calls[0]?.[0]; @@ -1411,8 +1448,12 @@ describe("RichCaptionPlayer", () => { // 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 firstLayout = new Promise(r => { + resolveFirst = r; + }); + const secondLayout = new Promise(r => { + resolveSecond = r; + }); const layoutResult = { store: { @@ -1424,12 +1465,14 @@ describe("RichCaptionPlayer", () => { 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 }, @@ -1437,9 +1480,7 @@ describe("RichCaptionPlayer", () => { ] }; - mockLayoutCaption - .mockReturnValueOnce(firstLayout) - .mockReturnValueOnce(secondLayout); + mockLayoutCaption.mockReturnValueOnce(firstLayout).mockReturnValueOnce(secondLayout); // Trigger two rapid dimension changes without awaiting // @ts-expect-error accessing protected method @@ -1454,7 +1495,9 @@ describe("RichCaptionPlayer", () => { await firstLayout; // Allow microtasks to settle - await new Promise(resolve => { setTimeout(resolve, 10); }); + await new Promise(resolve => { + setTimeout(resolve, 10); + }); // layoutCaption was called twice expect(mockLayoutCaption).toHaveBeenCalledTimes(2); @@ -1479,7 +1522,9 @@ describe("RichCaptionPlayer", () => { // @ts-expect-error accessing protected method player.onDimensionsChanged(); - await new Promise(resolve => { setTimeout(resolve, 10); }); + await new Promise(resolve => { + setTimeout(resolve, 10); + }); // Layout should NOT be called since validation failed early expect(mockLayoutCaption).not.toHaveBeenCalled(); @@ -1501,7 +1546,9 @@ describe("RichCaptionPlayer", () => { // @ts-expect-error accessing protected method player.onDimensionsChanged(); - await new Promise(resolve => { setTimeout(resolve, 10); }); + await new Promise(resolve => { + setTimeout(resolve, 10); + }); // Should NOT call layoutCaption because of the early return guard expect(mockLayoutCaption).not.toHaveBeenCalled(); From eb56a62d28ea2bd2d3315d70633fe1c36fa53edb Mon Sep 17 00:00:00 2001 From: dazzatronus Date: Mon, 8 Jun 2026 13:31:50 +1000 Subject: [PATCH 2/7] chore(deps): require @shotstack/shotstack-canvas ^2.10.0 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 3cddac0..89d452f 100644 --- a/package.json +++ b/package.json @@ -100,7 +100,7 @@ }, "dependencies": { "@shotstack/schemas": "1.11.0", - "@shotstack/shotstack-canvas": "^2.9.0", + "@shotstack/shotstack-canvas": "^2.10.0", "howler": "^2.2.4", "mediabunny": "^1.11.2", "opentype.js": "^1.3.4", From 97ce1bbee86eb83ae7adac5074443c9e18d8ea5d Mon Sep 17 00:00:00 2001 From: dazzatronus Date: Tue, 9 Jun 2026 18:56:06 +1000 Subject: [PATCH 3/7] fix: capture the rendered output frame at a requested time --- src/components/canvas/players/html5-player.ts | 12 +--- src/components/canvas/players/player.ts | 8 +++ .../canvas/players/rich-caption-player.ts | 39 +++++++++++- .../canvas/players/rich-text-player.ts | 63 ++++++++++++++----- src/components/canvas/shotstack-canvas.ts | 48 +++++++++++++- src/core/edit-session.ts | 54 +++++++++++++--- src/core/player-reconciler.ts | 20 +++++- src/core/shared/utils.ts | 14 +++++ 8 files changed, 219 insertions(+), 39 deletions(-) diff --git a/src/components/canvas/players/html5-player.ts b/src/components/canvas/players/html5-player.ts index 2dbbbf9..bfc5bf5 100644 --- a/src/components/canvas/players/html5-player.ts +++ b/src/components/canvas/players/html5-player.ts @@ -3,6 +3,7 @@ import type { Edit } from "@core/edit-session"; import { EditEvent } from "@core/events/edit-events"; import { type Size } from "@layouts/geometry"; import type { ResolvedClip } from "@schemas"; +import { nextFrame } from "@shared/utils"; import { Html5AssetSchema, composeHtml5IframeSrcdoc, computeHtml5FrameCount, type Html5Asset } from "@shotstack/shotstack-canvas"; import * as pixi from "pixi.js"; @@ -19,13 +20,6 @@ type HarnessWindow = Window & { const SEEK_KEY = "__shotstackSeek" as const; const CAPTURE_CONCURRENCY = 4; -function yieldFrame(): Promise { - return new Promise(resolve => { - if (typeof requestAnimationFrame === "function") requestAnimationFrame(() => resolve()); - else setTimeout(() => resolve(), 0); - }); -} - function forceLayout(el: HTMLElement): void { el.getBoundingClientRect(); } @@ -313,7 +307,7 @@ export class Html5Player extends Player { } catch { /* older browsers — proceed */ } - await yieldFrame(); + await nextFrame(); if (stale()) return null; const { frameCount } = computeHtml5FrameCount({ @@ -330,7 +324,7 @@ export class Html5Player extends Player { const blobs: Blob[] = []; for (let i = 0; i < frameCount; i += CAPTURE_CONCURRENCY) { - await yieldFrame(); + await nextFrame(); if (stale()) return null; const batch: Promise[] = []; for (let j = i; j < Math.min(i + CAPTURE_CONCURRENCY, frameCount); j += 1) { diff --git a/src/components/canvas/players/player.ts b/src/components/canvas/players/player.ts index 1bd5960..b8dbd6d 100644 --- a/src/components/canvas/players/player.ts +++ b/src/components/canvas/players/player.ts @@ -234,6 +234,14 @@ export abstract class Player extends Entity { } public override update(_: number, __: number): void { + this.applyPlayheadState(); + } + + /** + * Apply all playhead-time-dependent container state (visibility, transform, opacity, wipe mask) + * for the current playback time. Called by the per-frame tick and directly by static capture. + */ + public applyPlayheadState(): void { this.getContainer().visible = this.isActive(); this.getContainer().zIndex = 100000 - this.layer * 100; if (!this.isActive()) { diff --git a/src/components/canvas/players/rich-caption-player.ts b/src/components/canvas/players/rich-caption-player.ts index 3392e06..d5ce53f 100644 --- a/src/components/canvas/players/rich-caption-player.ts +++ b/src/components/canvas/players/rich-caption-player.ts @@ -35,6 +35,7 @@ export class RichCaptionPlayer extends Player { private canvas: HTMLCanvasElement | null = null; private painter: ReturnType | null = null; + private currentRender: Promise | null = null; // serialises the async paint so captures await a finished frame private texture: pixi.Texture | null = null; private sprite: pixi.Sprite | null = null; @@ -140,6 +141,16 @@ export class RichCaptionPlayer extends Player { this.renderFrameSync(currentTimeMs); } + /** + * Render the exact playhead frame to completion for an off-playback capture. The web painter is + * async, so this awaits {@link renderFrame} (which awaits the paint) before captureFrame extracts — + * otherwise the snapshot is a half-drawn caption. Asset loading is awaited by the caller. + * @internal + */ + public override async prepareStaticRender(): Promise { + if (this.loadComplete) await this.renderFrame(this.getPlaybackTime() * 1000); + } + public override async reloadAsset(): Promise { const asset = this.clipConfiguration.asset as RichCaptionAsset; @@ -263,7 +274,31 @@ export class RichCaptionPlayer extends Player { this.loadComplete = true; } + /** + * Render the caption at `timeMs`, serialised so the shared canvas isn't cleared mid-paint. + * Awaited by {@link prepareStaticRender} for off-playback captures. + */ + private async renderFrame(timeMs: number): Promise { + while (this.currentRender) { + await this.currentRender; + } + this.currentRender = this.paintFrame(timeMs).finally(() => { + this.currentRender = null; + }); + await this.currentRender; + } + + /** Fire-and-forget render for the live tick and lifecycle hooks; the texture updates when the paint settles. */ private renderFrameSync(timeMs: number): void { + this.renderFrame(timeMs).catch(() => {}); + } + + /** + * Paint the caption frame to completion. The web painter is async (glyph fills resolve + * asynchronously), so the Pixi texture is only updated once the paint has finished — otherwise a + * snapshot captures a half-drawn canvas (a single glyph). + */ + private async paintFrame(timeMs: number): Promise { if (!this.layoutEngine || !this.captionLayout || !this.canvas || !this.painter || !this.validatedAsset || !this.generatorConfig) { return; } @@ -279,7 +314,7 @@ export class RichCaptionPlayer extends Player { const ctx = this.canvas.getContext("2d"); if (ctx) ctx.clearRect(0, 0, this.canvas.width, this.canvas.height); - this.painter.render(ops); + await this.painter.render(ops); if (!this.texture) { this.texture = pixi.Texture.from(this.canvas); @@ -504,7 +539,7 @@ export class RichCaptionPlayer extends Player { border: asset.border, padding: asset.padding, style: asset.style, - wordAnimation: asset.animation, + animation: asset.animation, align: asset.align, pauseThreshold: this.resolvedPauseThreshold }; diff --git a/src/components/canvas/players/rich-text-player.ts b/src/components/canvas/players/rich-text-player.ts index 9072614..52fb437 100644 --- a/src/components/canvas/players/rich-text-player.ts +++ b/src/components/canvas/players/rich-text-player.ts @@ -12,21 +12,25 @@ import * as pixi from "pixi.js"; // Derive TextEngine type from createTextEngine return type type TextEngine = Awaited>; - - export class RichTextPlayer extends Player { private static readonly PREVIEW_FPS = 60; /** CSS font-weight string → numeric value. Extends WEIGHT_MODIFIERS (@core/fonts/font-config.ts) with CSS aliases. */ private static readonly NAMED_WEIGHTS: Record = { - thin: 100, hairline: 100, - extralight: 200, ultralight: 200, + thin: 100, + hairline: 100, + extralight: 200, + ultralight: 200, light: 300, - normal: 400, regular: 400, + normal: 400, + regular: 400, medium: 500, - semibold: 600, demibold: 600, + semibold: 600, + demibold: 600, bold: 700, - extrabold: 800, ultrabold: 800, - black: 900, heavy: 900, + extrabold: 800, + ultrabold: 800, + black: 900, + heavy: 900 }; private static readonly fontCapabilityCache = new Map>(); private static readonly fontBytesCache = new Map>(); @@ -39,6 +43,7 @@ export class RichTextPlayer extends Player { private cachedFrames = new Map(); private isRendering: boolean = false; private pendingRenderTime: number | null = null; // Stores time requested while rendering (race condition fix) + private currentRender: Promise | null = null; // In-flight render promise, so an off-playback capture can await it private validatedAsset: CanvasRichTextAsset | null = null; private fontSupportsBold: boolean = false; private loadComplete: boolean = false; @@ -54,13 +59,15 @@ export class RichTextPlayer extends Player { const cached = RichTextPlayer.fontBytesCache.get(cacheKey); if (cached) return cached; - const fetchPromise = fetch(url).then(res => { - if (!res.ok) throw new Error(`Failed to fetch font: ${res.status}`); - return res.arrayBuffer(); - }).catch(err => { - RichTextPlayer.fontBytesCache.delete(cacheKey); - throw err; - }); + const fetchPromise = fetch(url) + .then(res => { + if (!res.ok) throw new Error(`Failed to fetch font: ${res.status}`); + return res.arrayBuffer(); + }) + .catch(err => { + RichTextPlayer.fontBytesCache.delete(cacheKey); + throw err; + }); RichTextPlayer.fontBytesCache.set(cacheKey, fetchPromise); return fetchPromise; } @@ -463,10 +470,11 @@ export class RichTextPlayer extends Player { this.isRendering = true; this.pendingRenderTime = null; - this.renderFrame(timeSeconds) + this.currentRender = this.renderFrame(timeSeconds) .catch(err => console.error("Failed to render rich text frame:", err)) .finally(() => { this.isRendering = false; + this.currentRender = null; // Check if a render was requested while we were busy if (this.pendingRenderTime !== null && this.pendingRenderTime !== timeSeconds) { @@ -506,6 +514,29 @@ export class RichTextPlayer extends Player { } } + /** + * Render the exact playhead frame to completion for an off-playback capture. The live render is + * async and fire-and-forget (see {@link renderFrameSafe}), so a snapshot taken right after a seek + * can read a stale or empty texture. This drives the render deterministically via + * {@link renderAtTime}. Asset loading is awaited by the caller (the reconciler's pending loads) + * before this runs, so the text engine is ready here. + * @internal + */ + public override async prepareStaticRender(): Promise { + if (!this.textEngine || !this.renderer || !this.loadComplete) return; + await this.renderAtTime(this.getPlaybackTime()); + } + + /** Await a completed render of `timeSeconds`, draining any in-flight tick render first. */ + private async renderAtTime(timeSeconds: number): Promise { + while (this.currentRender) { + await this.currentRender; + } + // isRendering is false after the drain, so renderFrameSafe proceeds and stores currentRender. + this.renderFrameSafe(timeSeconds); + if (this.currentRender) await this.currentRender; + } + public override dispose(): void { super.dispose(); this.loadComplete = false; diff --git a/src/components/canvas/shotstack-canvas.ts b/src/components/canvas/shotstack-canvas.ts index 1632b61..d3958c5 100644 --- a/src/components/canvas/shotstack-canvas.ts +++ b/src/components/canvas/shotstack-canvas.ts @@ -316,19 +316,63 @@ export class Canvas { } /** - * Capture the current canvas content as a base64-encoded data URL. + * Capture the rendered output frame as a base64-encoded data URL. + * + * Renders the composition full-bleed at the edit's output resolution, independent of the + * editor's current zoom and pan — the rendered frame only, without the surrounding canvas + * margin, selection handles, or alignment guides. Use {@link captureViewport} to capture + * the on-screen editor view instead. */ public async captureFrame( options: { format?: "png" | "jpeg" | "webp"; quality?: number; } = {} + ): Promise { + const container = this.getViewportContainer(); + // Neutralise the editor's zoom/pan so the extract is a 1:1 output-resolution frame. The + // ticker is paused around the swap so the on-screen view never renders in this transient + // state; position and scale (and the ticker) are restored in `finally`. + const { x: posX, y: posY } = container.position; + const { x: scaleX, y: scaleY } = container.scale; + this.pauseTicker(); + container.position.set(0, 0); + container.scale.set(1, 1); + try { + return await this.extractBase64(container, new pixi.Rectangle(0, 0, this.edit.size.width, this.edit.size.height), options); + } finally { + container.position.set(posX, posY); + container.scale.set(scaleX, scaleY); + this.resumeTicker(); + } + } + + /** + * Capture the on-screen editor viewport as a base64-encoded data URL. + * + * Reflects the editor's current zoom, pan, and surrounding canvas — what the user sees. + * Use {@link captureFrame} for the full-bleed rendered output frame. + */ + public async captureViewport( + options: { + format?: "png" | "jpeg" | "webp"; + quality?: number; + } = {} ): Promise { this.application.renderer.render(this.application.stage); + return this.extractBase64(this.application.stage, undefined, options); + } + + private extractBase64( + target: pixi.Container, + frame: pixi.Rectangle | undefined, + options: { format?: "png" | "jpeg" | "webp"; quality?: number } + ): Promise { const requested = options.format ?? "png"; const pixiFormat: "png" | "jpg" | "webp" = requested === "jpeg" ? "jpg" : requested; return this.application.renderer.extract.base64({ - target: this.application.stage, + target, + ...(frame ? { frame } : {}), format: pixiFormat, quality: options.quality ?? 0.85 }); diff --git a/src/core/edit-session.ts b/src/core/edit-session.ts index 5bad49e..314f065 100644 --- a/src/core/edit-session.ts +++ b/src/core/edit-session.ts @@ -30,7 +30,7 @@ import { MergeFieldService, type SerializedMergeField } from "@core/merge"; import { calculateSizeFromPreset, OutputSettingsManager } from "@core/output-settings-manager"; import { SelectionManager } from "@core/selection-manager"; import { findEligibleSourceClips, ensureClipAlias } from "@core/shared/source-clip-finder"; -import { deepMerge, setNestedValue } from "@core/shared/utils"; +import { deepMerge, nextFrame, setNestedValue } from "@core/shared/utils"; import { calculateTimelineEnd, resolveAutoLength, resolveAutoStart } from "@core/timing/resolver"; import { type Milliseconds, type ResolutionContext, type Seconds, sec, toSec, isAliasReference } from "@core/timing/types"; import { TimingManager } from "@core/timing-manager"; @@ -2106,7 +2106,11 @@ export class Edit { } /** - * Capture the current canvas as a base64 data URL. + * Capture the rendered output frame as a base64 data URL. + * + * Full-bleed at the edit's output resolution, independent of the editor's zoom and pan. Pass + * `time` to capture a specific playhead position. Use {@link captureViewport} for the + * on-screen editor view. */ public async captureFrame( options: { @@ -2114,18 +2118,47 @@ export class Edit { format?: "png" | "jpeg" | "webp"; quality?: number; } = {} + ): Promise { + return this.captureStatic(options, opts => this.canvas!.captureFrame(opts)); + } + + /** + * Capture the on-screen editor viewport as a base64 data URL. + * + * Reflects the editor's current zoom, pan, and surrounding canvas. Pass `time` to capture a + * specific playhead position. Use {@link captureFrame} for the full-bleed output frame. + */ + public async captureViewport( + options: { + time?: number; + format?: "png" | "jpeg" | "webp"; + quality?: number; + } = {} + ): Promise { + return this.captureStatic(options, opts => this.canvas!.captureViewport(opts)); + } + + /** + * Seek (when `time` is given), project html5 video frames into the scene, run `capture`, then + * release the static-render state. Shared by {@link captureFrame} and {@link captureViewport}. + */ + private async captureStatic( + options: { time?: number; format?: "png" | "jpeg" | "webp"; quality?: number }, + capture: (opts: { format?: "png" | "jpeg" | "webp"; quality?: number }) => Promise ): Promise { if (!this.canvas) { throw new Error("captureFrame: no Canvas is attached — Edit must be mounted to a viewport."); } if (options.time !== undefined) { this.seek(options.time); - // One rAF lets players + reconciler reflect the new playhead time. - await new Promise(resolve => { - requestAnimationFrame(() => resolve()); - }); - } - // Project html5 frames into the scene. + // Yield a frame so players reflect the new playhead before capture reads it. + await nextFrame(); + } + // Asset loads (rich-text/caption text engines, etc.) are started fire-and-forget by the + // reconciler, so wait for them before capturing — otherwise a snapshot taken right after + // loadEdit() can read an unloaded or stale frame. + await this.playerReconciler.whenSettled(); + // Project html5 frames + drive text/caption renders into the scene for the off-playback capture. const activePlayers: Player[] = []; for (const track of this.tracks) { for (const player of track) { @@ -2134,7 +2167,10 @@ export class Edit { } try { await Promise.all(activePlayers.map(player => player.prepareStaticRender())); - return await this.canvas.captureFrame({ format: options.format, quality: options.quality }); + // Apply playhead-driven transforms + wipe mask explicitly: the per-frame tick may not run + // during a static capture (e.g. a hidden document), which would leave them stale. + for (const player of activePlayers) player.applyPlayheadState(); + return await capture({ format: options.format, quality: options.quality }); } finally { for (const player of activePlayers) player.endStaticRender(); } diff --git a/src/core/player-reconciler.ts b/src/core/player-reconciler.ts index 88accb0..dee4f6e 100644 --- a/src/core/player-reconciler.ts +++ b/src/core/player-reconciler.ts @@ -38,6 +38,9 @@ export class PlayerReconciler { */ private enableCreation = true; + /** In-flight player load promises, so off-playback captures (captureFrame) can await asset readiness. */ + private readonly inFlightLoads = new Set>(); + constructor(private readonly edit: Edit) { this.edit.getInternalEvents().on(InternalEvent.Resolved, this.onResolved); } @@ -62,6 +65,18 @@ export class PlayerReconciler { return result; } + /** + * Resolve once all in-flight player loads have settled. Player loads are asynchronous and, for + * newly created players, started fire-and-forget by reconcile()/the Resolved handler — so an + * off-playback capture taken right after loadEdit() can race them. captureFrame awaits this so it + * renders a loaded asset deterministically. Loops because a load can enqueue follow-up work. + */ + public async whenSettled(): Promise { + while (this.inFlightLoads.size > 0) { + await Promise.allSettled([...this.inFlightLoads]); + } + } + /** * Reconcile Players to match the ResolvedEdit. * @@ -232,7 +247,10 @@ export class PlayerReconciler { assetType }); }); - return loadPromise; + + // Track the load so off-playback captures can await asset readiness (see whenSettled()). + this.inFlightLoads.add(loadPromise); + return loadPromise.finally(() => this.inFlightLoads.delete(loadPromise)); } /** diff --git a/src/core/shared/utils.ts b/src/core/shared/utils.ts index 2140bd8..98554fc 100644 --- a/src/core/shared/utils.ts +++ b/src/core/shared/utils.ts @@ -146,6 +146,20 @@ export function createThrottle( return { call, flush, cancel }; } +/** Resolve on the next animation frame, or a timer when rAF is paused (hidden document). @internal */ +export function nextFrame(): Promise { + return new Promise(resolve => { + let settled = false; + const finish = (): void => { + if (settled) return; + settled = true; + resolve(); + }; + setTimeout(finish, 32); + if (typeof requestAnimationFrame === "function") requestAnimationFrame(finish); + }); +} + export interface UrlValidationResult { valid: boolean; error?: string; From cda9401af553dca6bbeb35073627e732f377a817 Mon Sep 17 00:00:00 2001 From: dazzatronus Date: Tue, 9 Jun 2026 18:56:11 +1000 Subject: [PATCH 4/7] fix: evaluate eased keyframes with a true cubic-bezier timing function --- src/core/animations/curve-interpolator.ts | 37 ++++++++++++++----- .../animations/transition-preset-builder.ts | 2 +- 2 files changed, 29 insertions(+), 10 deletions(-) diff --git a/src/core/animations/curve-interpolator.ts b/src/core/animations/curve-interpolator.ts index 2b9acaf..383e630 100644 --- a/src/core/animations/curve-interpolator.ts +++ b/src/core/animations/curve-interpolator.ts @@ -1,3 +1,9 @@ +/** One axis of a cubic bezier with implicit endpoints 0 and 1: B(t) = 3(1−t)²t·a + 3(1−t)t²·b + t³. */ +function cubicBezierAxis(t: number, a: number, b: number): number { + const mt = 1 - t; + return 3 * mt * mt * t * a + 3 * mt * t * t * b + t * t * t; +} + export class CurveInterpolator { private curves: Record = {}; @@ -127,19 +133,32 @@ export class CurveInterpolator { } public getValue(from: number, to: number, progress: number, easing?: string): number { + if (progress <= 0) return from; + if (progress >= 1) return to; + const handles = this.curves[easing ?? ""] ?? this.curves["ease"]; const [[controlPoint1X, controlPoint1Y], [controlPoint2X, controlPoint2Y]] = handles; - const adjustedProgress = progress + (3 * controlPoint1X - 3 * controlPoint2X + 1) * progress * (1 - progress); + // Standard cubic-bezier timing function: solve the X-axis bezier for t at this progress, + // then read the Y-axis bezier. Control points are CSS cubic-bezier handles. + const t = this.solveForT(progress, controlPoint1X, controlPoint2X); + const eased = cubicBezierAxis(t, controlPoint1Y, controlPoint2Y); - const startValue = from; - const controlValue1 = from + (to - from) * controlPoint1Y; - const controlValue2 = from + (to - from) * controlPoint2Y; - const endValue = to; - - const t = adjustedProgress; - const oneMinusT = 1 - t; + return from + (to - from) * eased; + } - return oneMinusT ** 3 * startValue + 3 * oneMinusT ** 2 * t * controlValue1 + 3 * oneMinusT * t ** 2 * controlValue2 + t ** 3 * endValue; + /** Bisect the X-axis cubic bezier for the parameter `t` where x(t) = `x` (x1, x2 ∈ [0,1] keep x(t) monotonic). */ + private solveForT(x: number, x1: number, x2: number): number { + let low = 0; + let high = 1; + let t = x; + for (let i = 0; i < 24; i += 1) { + const error = cubicBezierAxis(t, x1, x2) - x; + if (Math.abs(error) < 1e-5) break; + if (error < 0) low = t; + else high = t; + t = (low + high) / 2; + } + return t; } } diff --git a/src/core/animations/transition-preset-builder.ts b/src/core/animations/transition-preset-builder.ts index e44109e..054c8ac 100644 --- a/src/core/animations/transition-preset-builder.ts +++ b/src/core/animations/transition-preset-builder.ts @@ -65,7 +65,7 @@ export class TransitionPresetBuilder { switch (transitionName) { case "fade": { const [from, to] = isIn ? [0, 1] : [1, 0]; - keyframes.opacityKeyframes.push({ from, to, start, length, interpolation: "bezier", easing: "ease" }); + keyframes.opacityKeyframes.push({ from, to, start, length, interpolation: "bezier", easing: "easeInOut" }); break; } case "zoom": { From 1101b4925b14d9e02a112267ebcce8b6edf69714 Mon Sep 17 00:00:00 2001 From: dazzatronus Date: Tue, 9 Jun 2026 18:56:13 +1000 Subject: [PATCH 5/7] fix: render wipeLeft and wipeRight transitions with a gradient-sweep filter --- src/components/canvas/players/player.ts | 41 ++++++- .../animations/transition-preset-builder.ts | 21 +++- src/core/animations/wipe-filter.ts | 109 ++++++++++++++++++ tests/edit-clip-operations.test.ts | 2 + tests/edit-commands.test.ts | 1 + tests/edit-load.test.ts | 2 + tests/edit-merge-fields.test.ts | 2 + tests/edit-playback.test.ts | 2 + tests/edit-timing.test.ts | 2 + tests/helpers/pixi-mock-filters.ts | 14 +++ tests/intent-stripping.test.ts | 1 + tests/media-player-fallback.test.ts | 13 ++- tests/output-resolution.test.ts | 2 + tests/player-sync.test.ts | 2 + tests/rich-caption-player.test.ts | 14 +++ tests/rich-text-player-font-caching.test.ts | 2 + tests/svg-player.test.ts | 2 + 17 files changed, 221 insertions(+), 11 deletions(-) create mode 100644 src/core/animations/wipe-filter.ts create mode 100644 tests/helpers/pixi-mock-filters.ts diff --git a/src/components/canvas/players/player.ts b/src/components/canvas/players/player.ts index b8dbd6d..f903272 100644 --- a/src/components/canvas/players/player.ts +++ b/src/components/canvas/players/player.ts @@ -2,6 +2,7 @@ import { ComposedKeyframeBuilder } from "@animations/composed-keyframe-builder"; import { EffectPresetBuilder } from "@animations/effect-preset-builder"; import { KeyframeBuilder } from "@animations/keyframe-builder"; import { TransitionPresetBuilder } from "@animations/transition-preset-builder"; +import { WipeFilter } from "@animations/wipe-filter"; import { type Edit } from "@core/edit-session"; import { InternalEvent } from "@core/events/edit-events"; import { calculateContainerScale, calculateFitScale, calculateSpriteTransform, type FitMode } from "@core/layout/fit-system"; @@ -87,6 +88,11 @@ export abstract class Player extends Entity { private skewXKeyframeBuilder?: ComposedKeyframeBuilder; private skewYKeyframeBuilder?: ComposedKeyframeBuilder; private maskXKeyframeBuilder?: KeyframeBuilder; + private wipeBrightnessBuilder?: KeyframeBuilder; + private wipeInFromRight: boolean = false; + private wipeOutFromRight: boolean = false; + private wipeOutStart: number = Number.POSITIVE_INFINITY; + private wipeFilter: WipeFilter | null = null; private wipeMask: pixi.Graphics | null; protected lumaWrapper: pixi.Container; @@ -208,11 +214,20 @@ export abstract class Player extends Entity { this.opacityKeyframeBuilder.addLayer(transitionSet.out.opacityKeyframes); this.rotationKeyframeBuilder.addLayer(transitionSet.out.rotationKeyframes); - // Mask keyframes (wipe/reveal effects) + // Mask keyframes (reveal effect) const maskXKeyframes: Keyframe[] = [...transitionSet.in.maskXKeyframes, ...transitionSet.out.maskXKeyframes]; if (maskXKeyframes.length) { this.maskXKeyframeBuilder = new KeyframeBuilder(maskXKeyframes, length); } + + // Wipe brightness sweep (wipeLeft/wipeRight) — driven through WipeFilter, not a mask. + const brightnessKeyframes: Keyframe[] = [...transitionSet.in.brightnessKeyframes, ...transitionSet.out.brightnessKeyframes]; + if (brightnessKeyframes.length) { + this.wipeBrightnessBuilder = new KeyframeBuilder(brightnessKeyframes, length); + this.wipeInFromRight = transitionSet.in.wipeFromRight; + this.wipeOutFromRight = transitionSet.out.wipeFromRight; + this.wipeOutStart = transitionSet.out.brightnessKeyframes[0]?.start ?? Number.POSITIVE_INFINITY; + } } public override async load(): Promise { @@ -270,6 +285,7 @@ export abstract class Player extends Entity { // Update wipe/reveal mask animation this.updateWipeMask(); + this.updateWipeFilter(); if (this.shouldDiscardFrame()) { this.contentContainer.alpha = 0; @@ -302,9 +318,32 @@ export abstract class Player extends Entity { this.wipeMask.fill(0xffffff); } + private updateWipeFilter(): void { + if (!this.wipeBrightnessBuilder) { + this.removeWipeFilter(); + return; + } + if (!this.wipeFilter) { + this.wipeFilter = new WipeFilter(); + this.contentContainer.filters = [this.wipeFilter]; + } + const time = this.getPlaybackTime(); + this.wipeFilter.brightness = this.wipeBrightnessBuilder.getValue(time); + this.wipeFilter.revealFromRight = time >= this.wipeOutStart ? this.wipeOutFromRight : this.wipeInFromRight; + } + + private removeWipeFilter(): void { + if (!this.wipeFilter) return; + this.contentContainer.filters = []; + this.wipeFilter.destroy(); + this.wipeFilter = null; + } + public override dispose(): void { this.wipeMask?.destroy(); this.wipeMask = null; + this.wipeFilter?.destroy(); + this.wipeFilter = null; this.contentContainer?.destroy(); this.lumaWrapper?.destroy(); diff --git a/src/core/animations/transition-preset-builder.ts b/src/core/animations/transition-preset-builder.ts index 054c8ac..cbe4bd6 100644 --- a/src/core/animations/transition-preset-builder.ts +++ b/src/core/animations/transition-preset-builder.ts @@ -7,6 +7,10 @@ export type TransitionKeyframeSet = { scaleKeyframes: Keyframe[]; rotationKeyframes: Keyframe[]; maskXKeyframes: Keyframe[]; + /** Wipe brightness sweep (+1 hidden → −1 revealed), applied through WipeFilter. */ + brightnessKeyframes: Keyframe[]; + /** Wipe reveal direction for this phase: true = from the right edge inward, false = from the left. */ + wipeFromRight: boolean; }; export type RelativeTransitionKeyframeSet = { @@ -47,7 +51,9 @@ export class TransitionPresetBuilder { opacityKeyframes: [], scaleKeyframes: [], rotationKeyframes: [], - maskXKeyframes: [] + maskXKeyframes: [], + brightnessKeyframes: [], + wipeFromRight: false }; } @@ -116,15 +122,18 @@ export class TransitionPresetBuilder { ); break; } - case "reveal": - case "wipeRight": { + case "reveal": { const [from, to] = isIn ? [0, 1] : [1, 0]; keyframes.maskXKeyframes.push({ from, to, start, length, interpolation: "bezier", easing: "ease" }); break; } - case "wipeLeft": { - const [from, to] = isIn ? [1, 0] : [0, 1]; - keyframes.maskXKeyframes.push({ from, to, start, length, interpolation: "bezier", easing: "ease" }); + case "wipeLeft": + case "wipeRight": { + // Brightness sweeps +1 (hidden) → −1 (revealed) in, and back out, applied via WipeFilter. + // wipeLeft reveals from the right edge inward; wipeRight from the left. + const [from, to] = isIn ? [1, -1] : [-1, 1]; + keyframes.brightnessKeyframes.push({ from, to, start, length, interpolation: "bezier", easing: "easeInOut" }); + keyframes.wipeFromRight = transitionName === "wipeLeft" ? isIn : !isIn; break; } default: diff --git a/src/core/animations/wipe-filter.ts b/src/core/animations/wipe-filter.ts new file mode 100644 index 0000000..fbf88f8 --- /dev/null +++ b/src/core/animations/wipe-filter.ts @@ -0,0 +1,109 @@ +import { defaultFilterVert, Filter, GlProgram, GpuProgram, UniformGroup } from "pixi.js"; + +/** + * Reveals or hides a clip by sweeping a horizontal luminosity gradient across it. + * + * Per pixel: `alpha = clamp(0.5 + K · (0.5 − m − brightness), 0, 1)`, where `m` is the gradient + * value (`1 − x` for a right-to-left wipe, `x` for left-to-right), `brightness` sweeps `+1` + * (hidden) → `−1` (revealed), and `K` sets the edge softness. + */ + +const CONTRAST_K = "1.1764706"; // edge softness factor + +const glFragment = ` +in vec2 vTextureCoord; +out vec4 finalColor; + +uniform sampler2D uTexture; +uniform float uBrightness; +uniform float uRevealFromRight; + +void main(void) +{ + float m = (uRevealFromRight > 0.5) ? (1.0 - vTextureCoord.x) : vTextureCoord.x; + float a = clamp(0.5 + ${CONTRAST_K} * (0.5 - m - uBrightness), 0.0, 1.0); + finalColor = texture(uTexture, vTextureCoord) * a; +} +`; + +const gpuSource = ` +struct GlobalFilterUniforms { + uInputSize:vec4, + uInputPixel:vec4, + uInputClamp:vec4, + uOutputFrame:vec4, + uGlobalFrame:vec4, + uOutputTexture:vec4, +}; + +struct WipeUniforms { + uBrightness:f32, + uRevealFromRight:f32, +}; + +@group(0) @binding(0) var gfu: GlobalFilterUniforms; +@group(0) @binding(1) var uTexture: texture_2d; +@group(0) @binding(2) var uSampler : sampler; +@group(1) @binding(0) var wipeUniforms : WipeUniforms; + +struct VSOutput { + @builtin(position) position: vec4, + @location(0) uv : vec2 +}; + +fn filterVertexPosition(aPosition:vec2) -> vec4 +{ + var position = aPosition * gfu.uOutputFrame.zw + gfu.uOutputFrame.xy; + position.x = position.x * (2.0 / gfu.uOutputTexture.x) - 1.0; + position.y = position.y * (2.0*gfu.uOutputTexture.z / gfu.uOutputTexture.y) - gfu.uOutputTexture.z; + return vec4(position, 0.0, 1.0); +} + +fn filterTextureCoord( aPosition:vec2 ) -> vec2 +{ + return aPosition * (gfu.uOutputFrame.zw * gfu.uInputSize.zw); +} + +@vertex +fn mainVertex(@location(0) aPosition : vec2) -> VSOutput { + return VSOutput(filterVertexPosition(aPosition), filterTextureCoord(aPosition)); +} + +@fragment +fn mainFragment(@location(0) uv: vec2, @builtin(position) position: vec4) -> @location(0) vec4 { + var m = select(uv.x, 1.0 - uv.x, wipeUniforms.uRevealFromRight > 0.5); + var a = clamp(0.5 + ${CONTRAST_K} * (0.5 - m - wipeUniforms.uBrightness), 0.0, 1.0); + return textureSample(uTexture, uSampler, uv) * a; +} +`; + +export class WipeFilter extends Filter { + private readonly uniformGroup: UniformGroup<{ + uBrightness: { value: number; type: "f32" }; + uRevealFromRight: { value: number; type: "f32" }; + }>; + + constructor() { + const glProgram = GlProgram.from({ vertex: defaultFilterVert, fragment: glFragment, name: "wipe-filter" }); + const gpuProgram = GpuProgram.from({ + vertex: { source: gpuSource, entryPoint: "mainVertex" }, + fragment: { source: gpuSource, entryPoint: "mainFragment" } + }); + const wipeUniforms = new UniformGroup({ + uBrightness: { value: 1, type: "f32" }, + uRevealFromRight: { value: 0, type: "f32" } + }); + super({ glProgram, gpuProgram, resources: { wipeUniforms }, padding: 0 }); + this.uniformGroup = wipeUniforms; + } + + /** Sweep position: `+1` fully hidden → `−1` fully revealed. */ + public set brightness(value: number) { + this.uniformGroup.uniforms.uBrightness = value; + } + + /** `true` reveals from the right edge inward (wipeLeft), `false` from the left (wipeRight). */ + public set revealFromRight(value: boolean) { + this.uniformGroup.uniforms.uRevealFromRight = value ? 1 : 0; + } +} diff --git a/tests/edit-clip-operations.test.ts b/tests/edit-clip-operations.test.ts index f382c81..2723094 100644 --- a/tests/edit-clip-operations.test.ts +++ b/tests/edit-clip-operations.test.ts @@ -88,6 +88,8 @@ jest.mock("pixi.js", () => { }); return { + // eslint-disable-next-line global-require, @typescript-eslint/no-require-imports + ...require("./helpers/pixi-mock-filters").pixiFilterStubs, Container: jest.fn().mockImplementation(createMockContainer), Graphics: jest.fn().mockImplementation(createMockGraphics), Sprite: jest.fn().mockImplementation(() => ({ diff --git a/tests/edit-commands.test.ts b/tests/edit-commands.test.ts index ec375f4..c7387fe 100644 --- a/tests/edit-commands.test.ts +++ b/tests/edit-commands.test.ts @@ -78,6 +78,7 @@ jest.mock("pixi.js", () => { }); return { + ...require("./helpers/pixi-mock-filters").pixiFilterStubs, Container: jest.fn().mockImplementation(createMockContainer), Graphics: jest.fn().mockImplementation(createMockGraphics), Sprite: jest.fn().mockImplementation(() => ({ diff --git a/tests/edit-load.test.ts b/tests/edit-load.test.ts index 4a14dc4..3b015b1 100644 --- a/tests/edit-load.test.ts +++ b/tests/edit-load.test.ts @@ -87,6 +87,8 @@ jest.mock("pixi.js", () => { }); return { + // eslint-disable-next-line global-require, @typescript-eslint/no-require-imports + ...require("./helpers/pixi-mock-filters").pixiFilterStubs, Container: jest.fn().mockImplementation(createMockContainer), Graphics: jest.fn().mockImplementation(createMockGraphics), Sprite: jest.fn().mockImplementation(() => ({ diff --git a/tests/edit-merge-fields.test.ts b/tests/edit-merge-fields.test.ts index 0092bf3..fe01a41 100644 --- a/tests/edit-merge-fields.test.ts +++ b/tests/edit-merge-fields.test.ts @@ -69,6 +69,8 @@ jest.mock("pixi.js", () => { }); return { + // eslint-disable-next-line global-require, @typescript-eslint/no-require-imports + ...require("./helpers/pixi-mock-filters").pixiFilterStubs, Container: jest.fn().mockImplementation(createMockContainer), Graphics: jest.fn().mockImplementation(createMockGraphics), Sprite: jest.fn().mockImplementation(() => ({ diff --git a/tests/edit-playback.test.ts b/tests/edit-playback.test.ts index d3390ef..485b77a 100644 --- a/tests/edit-playback.test.ts +++ b/tests/edit-playback.test.ts @@ -62,6 +62,8 @@ jest.mock("pixi.js", () => { }); return { + // eslint-disable-next-line global-require, @typescript-eslint/no-require-imports + ...require("./helpers/pixi-mock-filters").pixiFilterStubs, Container: jest.fn().mockImplementation(createMockContainer), Graphics: jest.fn().mockImplementation(createMockGraphics), Sprite: jest.fn().mockImplementation(() => ({ diff --git a/tests/edit-timing.test.ts b/tests/edit-timing.test.ts index 76ab47b..c0a0cf8 100644 --- a/tests/edit-timing.test.ts +++ b/tests/edit-timing.test.ts @@ -74,6 +74,8 @@ jest.mock("pixi.js", () => { }); return { + // eslint-disable-next-line global-require, @typescript-eslint/no-require-imports + ...require("./helpers/pixi-mock-filters").pixiFilterStubs, Container: jest.fn().mockImplementation(createMockContainer), Graphics: jest.fn().mockImplementation(createMockGraphics), Sprite: jest.fn().mockImplementation(() => ({ diff --git a/tests/helpers/pixi-mock-filters.ts b/tests/helpers/pixi-mock-filters.ts new file mode 100644 index 0000000..5fa0fe1 --- /dev/null +++ b/tests/helpers/pixi-mock-filters.ts @@ -0,0 +1,14 @@ +/** pixi.js filter/shader stubs so WipeFilter (extends pixi.Filter at module load) survives a mocked pixi. */ +/* eslint-disable max-classes-per-file, class-methods-use-this, import/prefer-default-export -- minimal pixi API stubs for jest mocks */ + +export const pixiFilterStubs = { + Filter: class { + destroy(): void {} + }, + GlProgram: { from: (): Record => ({}) }, + GpuProgram: { from: (): Record => ({}) }, + UniformGroup: class { + uniforms: Record = {}; + }, + defaultFilterVert: "" +}; diff --git a/tests/intent-stripping.test.ts b/tests/intent-stripping.test.ts index 7abf2e1..f20075c 100644 --- a/tests/intent-stripping.test.ts +++ b/tests/intent-stripping.test.ts @@ -77,6 +77,7 @@ jest.mock("pixi.js", () => { }); return { + ...require("./helpers/pixi-mock-filters").pixiFilterStubs, Container: jest.fn().mockImplementation(createMockContainer), Graphics: jest.fn().mockImplementation(createMockGraphics), Sprite: jest.fn().mockImplementation(() => ({ diff --git a/tests/media-player-fallback.test.ts b/tests/media-player-fallback.test.ts index e7b0e0f..a032463 100644 --- a/tests/media-player-fallback.test.ts +++ b/tests/media-player-fallback.test.ts @@ -96,7 +96,12 @@ jest.mock("pixi.js", () => { public height: number; public destroyed = false; - constructor({ source, frame, width = 0, height = 0 }: { source?: { width?: number; height?: number }; frame?: { width?: number; height?: number }; width?: number; height?: number } = {}) { + constructor({ + source, + frame, + width = 0, + height = 0 + }: { source?: { width?: number; height?: number }; frame?: { width?: number; height?: number }; width?: number; height?: number } = {}) { this.source = source; this.width = width || frame?.width || source?.width || 0; this.height = height || frame?.height || source?.height || 0; @@ -128,6 +133,8 @@ jest.mock("pixi.js", () => { } return { + // eslint-disable-next-line global-require, @typescript-eslint/no-require-imports + ...require("./helpers/pixi-mock-filters").pixiFilterStubs, Container: MockContainer, Graphics: MockGraphics, Sprite: MockSprite, @@ -233,9 +240,7 @@ describe("media player fallbacks", () => { it("replaces a failed video placeholder after a successful reload", async () => { const edit = createEdit(); - edit.assetLoader.loadVideoUnique - .mockResolvedValueOnce(null) - .mockResolvedValueOnce(createVideoTexture(1280, 720)); + edit.assetLoader.loadVideoUnique.mockResolvedValueOnce(null).mockResolvedValueOnce(createVideoTexture(1280, 720)); const player = new VideoPlayer(edit as never, createVideoClip()); diff --git a/tests/output-resolution.test.ts b/tests/output-resolution.test.ts index 1123de4..0774c81 100644 --- a/tests/output-resolution.test.ts +++ b/tests/output-resolution.test.ts @@ -65,6 +65,8 @@ jest.mock("pixi.js", () => { }); return { + // eslint-disable-next-line global-require, @typescript-eslint/no-require-imports + ...require("./helpers/pixi-mock-filters").pixiFilterStubs, Container: jest.fn().mockImplementation(createMockContainer), Graphics: jest.fn().mockImplementation(createMockGraphics), Sprite: jest.fn().mockImplementation(() => ({ diff --git a/tests/player-sync.test.ts b/tests/player-sync.test.ts index 53e9d90..868c43c 100644 --- a/tests/player-sync.test.ts +++ b/tests/player-sync.test.ts @@ -53,6 +53,8 @@ jest.mock("pixi.js", () => { }; return { + // eslint-disable-next-line global-require, @typescript-eslint/no-require-imports + ...require("./helpers/pixi-mock-filters").pixiFilterStubs, Container: jest.fn().mockImplementation(createMockContainer), Graphics: jest.fn().mockImplementation(() => ({ rect: jest.fn().mockReturnThis(), diff --git a/tests/rich-caption-player.test.ts b/tests/rich-caption-player.test.ts index cb32340..c8b23a5 100644 --- a/tests/rich-caption-player.test.ts +++ b/tests/rich-caption-player.test.ts @@ -53,6 +53,8 @@ jest.mock("pixi.js", () => { }; return { + // eslint-disable-next-line global-require, @typescript-eslint/no-require-imports + ...require("./helpers/pixi-mock-filters").pixiFilterStubs, Container: jest.fn().mockImplementation(createMockContainer), Texture: { from: jest.fn().mockImplementation(() => ({ @@ -550,6 +552,10 @@ describe("RichCaptionPlayer", () => { (edit as unknown as Record)["playbackTime"] = 0.6; player.update(0.016, 0.6); + await new Promise(resolve => { + setTimeout(resolve, 10); + }); + expect(mockGenerateRichCaptionFrame).toHaveBeenCalledTimes(2); }); @@ -567,6 +573,10 @@ describe("RichCaptionPlayer", () => { player.update(0.016, 0.1); player.update(0.016, 0.116); + await new Promise(resolve => { + setTimeout(resolve, 10); + }); + expect(mockGenerateRichCaptionFrame).toHaveBeenCalledTimes(2); }); @@ -587,6 +597,10 @@ describe("RichCaptionPlayer", () => { (edit as unknown as Record)["playbackTime"] = 1.5; player.update(0.016, 1.5); + await new Promise(resolve => { + setTimeout(resolve, 10); + }); + expect(mockGenerateRichCaptionFrame).toHaveBeenCalledTimes(3); expect(mockPainterRender).toHaveBeenCalledTimes(3); }); diff --git a/tests/rich-text-player-font-caching.test.ts b/tests/rich-text-player-font-caching.test.ts index 6be04aa..e4237bd 100644 --- a/tests/rich-text-player-font-caching.test.ts +++ b/tests/rich-text-player-font-caching.test.ts @@ -49,6 +49,8 @@ jest.mock("pixi.js", () => { }; return { + // eslint-disable-next-line global-require, @typescript-eslint/no-require-imports + ...require("./helpers/pixi-mock-filters").pixiFilterStubs, Container: jest.fn().mockImplementation(createMockContainer), Texture: { from: jest.fn().mockImplementation(() => ({ diff --git a/tests/svg-player.test.ts b/tests/svg-player.test.ts index 01b3142..321edcf 100644 --- a/tests/svg-player.test.ts +++ b/tests/svg-player.test.ts @@ -86,6 +86,8 @@ jest.mock("pixi.js", () => { }); return { + // eslint-disable-next-line global-require, @typescript-eslint/no-require-imports + ...require("./helpers/pixi-mock-filters").pixiFilterStubs, Container: jest.fn().mockImplementation(createMockContainer), Graphics: jest.fn().mockImplementation(() => { mockGraphicsInstance = createMockGraphics(); From 26c857af21b278efc3f32df2afa6ca55aa33c7bb Mon Sep 17 00:00:00 2001 From: dazzatronus Date: Tue, 9 Jun 2026 20:52:10 +1000 Subject: [PATCH 6/7] fix: zoom-in transition no longer accelerates too fast near the end --- src/core/animations/transition-preset-builder.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/core/animations/transition-preset-builder.ts b/src/core/animations/transition-preset-builder.ts index cbe4bd6..944bb72 100644 --- a/src/core/animations/transition-preset-builder.ts +++ b/src/core/animations/transition-preset-builder.ts @@ -78,7 +78,11 @@ export class TransitionPresetBuilder { const [scaleFrom, scaleTo] = isIn ? [10, 1] : [1, 10]; const [opacityFrom, opacityTo] = isIn ? [0, 1] : [1, 0]; const easing = isIn ? "easeIn" : "easeOut"; - keyframes.scaleKeyframes.push({ from: scaleFrom, to: scaleTo, start, length, interpolation: "bezier", easing }); + if (isIn) { + keyframes.scaleKeyframes.push({ from: scaleFrom, to: scaleTo, start, length, interpolation: "linear" }); + } else { + keyframes.scaleKeyframes.push({ from: scaleFrom, to: scaleTo, start, length, interpolation: "bezier", easing }); + } keyframes.opacityKeyframes.push({ from: opacityFrom, to: opacityTo, start, length, interpolation: "bezier", easing }); break; } From 73465e983c69401807c6bd11c46726adc71ae546 Mon Sep 17 00:00:00 2001 From: dazzatronus Date: Wed, 10 Jun 2026 10:16:19 +1000 Subject: [PATCH 7/7] fix: defer luma mask setup until the masked clip loads to prevent a black clip --- src/core/luma-mask-controller.ts | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/src/core/luma-mask-controller.ts b/src/core/luma-mask-controller.ts index da9cdcf..ec56906 100644 --- a/src/core/luma-mask-controller.ts +++ b/src/core/luma-mask-controller.ts @@ -116,8 +116,9 @@ export class LumaMaskController { private onPlayerLoaded(payload: { player: Player; trackIndex: number; clipIndex: number }): void { const { player, trackIndex } = payload; - // Only handle luma players + // Content clip loaded — set up any luma mask deferred while its size was unknown if (player.playerType !== PlayerType.Luma) { + this.rebuildLumaMasksIfNeeded(); return; } @@ -163,6 +164,11 @@ export class LumaMaskController { const { renderer } = canvas.application; const { width, height } = contentClip.getSize(); + // Defer until the content clip has loaded — a mask baked at size 0 blacks out the clip + if (width <= 0 || height <= 0) { + return; + } + const tempContainer = new pixi.Container(); const tempSprite = new pixi.Sprite(lumaTexture); tempSprite.width = width;