From d740141462ccd70a75f15f4068189ce88e76f35b Mon Sep 17 00:00:00 2001 From: dazzatronus Date: Mon, 19 Oct 2026 22:00:12 +1100 Subject: [PATCH 01/16] fix(export): recognise video players by type, not class name --- src/core/export/video-frame-processor.ts | 9 +++----- tests/export-coordinator.test.ts | 7 +++++- tests/export-preflight.test.ts | 10 ++++++-- tests/export-video-frames.test.ts | 29 ++++++++++++++++++++++++ 4 files changed, 46 insertions(+), 9 deletions(-) create mode 100644 tests/export-video-frames.test.ts diff --git a/src/core/export/video-frame-processor.ts b/src/core/export/video-frame-processor.ts index ec196917..af4d1670 100644 --- a/src/core/export/video-frame-processor.ts +++ b/src/core/export/video-frame-processor.ts @@ -1,3 +1,4 @@ +import { PlayerType } from "@canvas/players/player"; import { Input, UrlSource, MP4, WEBM, CanvasSink, type InputVideoTrack } from "mediabunny"; import * as pixi from "pixi.js"; @@ -15,13 +16,9 @@ export interface VideoPlayerExtended { getLength?(): number; } +// By type, not class name: minification renames classes, and a luma matte can hold a video texture too. export function isVideoPlayer(player: unknown): player is VideoPlayerExtended { - if (!player || typeof player !== "object") return false; - const p = player as Record; - const name = p.constructor?.name; - if (name === "RichTextPlayer" || name === "RichCaptionPlayer") return false; - const texture = p["texture"] as { source?: { resource?: unknown } } | undefined; - return name === "VideoPlayer" || texture?.source?.resource instanceof HTMLVideoElement; + return typeof player === "object" && player !== null && "playerType" in player && player.playerType === PlayerType.Video; } interface ClipDecoder { diff --git a/tests/export-coordinator.test.ts b/tests/export-coordinator.test.ts index 796b77ad..88c29b50 100644 --- a/tests/export-coordinator.test.ts +++ b/tests/export-coordinator.test.ts @@ -44,7 +44,10 @@ jest.mock("mediabunny", () => ({ WEBM: {} })); +// Player modules extend pixi filters at load. jest.mock("pixi.js", () => ({ + // eslint-disable-next-line global-require, @typescript-eslint/no-require-imports -- jest.mock factories can't use imports + ...require("./helpers/pixi-mock-filters").pixiFilterStubs, Rectangle: jest.fn().mockImplementation((x: number, y: number, width: number, height: number) => ({ x, y, width, height })), Texture: { from: jest.fn(() => ({ source: { update: jest.fn() }, destroy: jest.fn() })) } })); @@ -337,8 +340,10 @@ describe("off-screen export", () => { describe("speed", () => { it("decodes each video frame at the player's own source time for that frame", async () => { const { exporter, edit } = createHarness(); - // isVideoPlayer recognises a player by its class name; getSourceTime stands in for the preview's speed mapping. + // getSourceTime stands in for the preview's speed mapping. class VideoPlayer { + playerType = "video"; + clipId = "fast-clip"; needsResolution = false; diff --git a/tests/export-preflight.test.ts b/tests/export-preflight.test.ts index 167bf236..e3f3ad59 100644 --- a/tests/export-preflight.test.ts +++ b/tests/export-preflight.test.ts @@ -16,7 +16,12 @@ jest.mock("mediabunny", () => ({ WEBM: {} })); -jest.mock("pixi.js", () => ({ Texture: { from: jest.fn() } })); +// Player modules extend pixi filters at load. +jest.mock("pixi.js", () => ({ + // eslint-disable-next-line global-require, @typescript-eslint/no-require-imports -- jest.mock factories can't use imports + ...require("./helpers/pixi-mock-filters").pixiFilterStubs, + Texture: { from: jest.fn() } +})); const customFonts: Record = { "Brand Sans|400": "https://example.com/brand-sans.ttf", @@ -140,8 +145,9 @@ describe("findBrowserIssue", () => { describe("VideoFrameProcessor.initialize", () => { it("reports a video the browser can't decode as video-source", async () => { - // isVideoPlayer recognises a player by its class name. class VideoPlayer { + playerType = "video"; + clipId = "mov-clip"; clipConfiguration = { asset: { type: "video", src: "https://example.com/clip.mov" } }; diff --git a/tests/export-video-frames.test.ts b/tests/export-video-frames.test.ts new file mode 100644 index 00000000..48bb429f --- /dev/null +++ b/tests/export-video-frames.test.ts @@ -0,0 +1,29 @@ +/** @jest-environment jsdom */ + +import { isVideoPlayer } from "@core/export/video-frame-processor"; + +jest.mock("mediabunny", () => ({ Input: jest.fn(), UrlSource: jest.fn(), CanvasSink: jest.fn(), MP4: {}, WEBM: {} })); + +// Player modules extend pixi filters at load; the frame processor only builds textures. +jest.mock("pixi.js", () => ({ + // eslint-disable-next-line global-require, @typescript-eslint/no-require-imports -- jest.mock factories can't use imports + ...require("./helpers/pixi-mock-filters").pixiFilterStubs, + Texture: { from: jest.fn(() => ({ source: { update: jest.fn() }, destroy: jest.fn() })) } +})); + +describe("isVideoPlayer", () => { + it("recognises a video player by its type, whatever name the bundler gives its class", () => { + // The published build renames VideoPlayer to something like this. + class UK { + playerType = "video"; + } + + expect(isVideoPlayer(new UK())).toBe(true); + }); + + it("leaves out a luma matte drawn from a video", () => { + const luma = { playerType: "luma", texture: { source: { resource: document.createElement("video") } } }; + + expect(isVideoPlayer(luma)).toBe(false); + }); +}); From 36d992e5f02cd5bff6bee688d09af8a6a81e426a Mon Sep 17 00:00:00 2001 From: dazzatronus Date: Mon, 19 Oct 2026 22:01:02 +1100 Subject: [PATCH 02/16] fix(export): release inputs without a video track and unfreeze every paused video --- src/core/export/video-frame-processor.ts | 16 ++++++++---- tests/export-video-frames.test.ts | 33 +++++++++++++++++++++++- 2 files changed, 43 insertions(+), 6 deletions(-) diff --git a/src/core/export/video-frame-processor.ts b/src/core/export/video-frame-processor.ts index af4d1670..cf7e232f 100644 --- a/src/core/export/video-frame-processor.ts +++ b/src/core/export/video-frame-processor.ts @@ -36,10 +36,11 @@ interface ClipDecoder { */ export class VideoFrameProcessor { private decoders = new Map(); + private paused = new Set(); /** Open a decoder per video clip; a source the browser can't decode comes back as an issue. */ async initialize(clips: ReadonlyArray): Promise { - const players = clips.filter(isVideoPlayer) as VideoPlayerExtended[]; + const players = clips.filter(isVideoPlayer); const issues: ExportIssue[] = []; await Promise.all( @@ -47,6 +48,7 @@ export class VideoFrameProcessor { const src = this.getVideoKey(player); if (!src) return; const input = new Input({ source: new UrlSource(src), formats: [MP4, WEBM] }); + let kept = false; try { const track = await input.getPrimaryVideoTrack(); if (!track) return; @@ -57,10 +59,12 @@ export class VideoFrameProcessor { sink: new CanvasSink(track, { poolSize: 2 }), originalTexture: player.texture }); + kept = true; } catch (error) { - input.dispose(); console.warn("Export: can't decode video", src, error); issues.push({ code: "video-source", message: "This video's format can't be decoded in the browser.", clipId: player.clipId ?? undefined }); + } finally { + if (!kept) input.dispose(); } }) ); @@ -99,13 +103,14 @@ export class VideoFrameProcessor { /** Pause the live