From 704103c20063f3db15f222b3fece92bef124b03b Mon Sep 17 00:00:00 2001 From: young Date: Fri, 4 Sep 2026 20:19:06 +1000 Subject: [PATCH] Keep export masks stable during zoom transforms --- src/lib/exporter/frameRenderer.test.ts | 17 +++++++++++++++++ src/lib/exporter/frameRenderer.ts | 8 ++++++-- 2 files changed, 23 insertions(+), 2 deletions(-) diff --git a/src/lib/exporter/frameRenderer.test.ts b/src/lib/exporter/frameRenderer.test.ts index 32af67a9..56d5344e 100644 --- a/src/lib/exporter/frameRenderer.test.ts +++ b/src/lib/exporter/frameRenderer.test.ts @@ -1,3 +1,5 @@ +import { readFileSync } from "node:fs"; +import { fileURLToPath } from "node:url"; import { beforeEach, describe, expect, it, vi } from "vitest"; import { DEFAULT_WEBCAM_OVERLAY } from "../../components/video-editor/types"; @@ -90,6 +92,11 @@ vi.mock("./localMediaSource", () => ({ import { FrameRenderer } from "./frameRenderer"; +const rendererSource = readFileSync( + fileURLToPath(new URL("./frameRenderer.ts", import.meta.url)), + "utf8", +); + type MockFunction = ReturnType; type MockContext = { beginPath: MockFunction; @@ -123,6 +130,16 @@ type FrameRendererTestAccess = { ) => void; }; +describe("FrameRenderer mask hierarchy", () => { + it("keeps the mask in the video wrapper when camera transforms change", () => { + expect(rendererSource).toContain( + "this.cameraContainer.addChild(this.videoEffectsContainer)", + ); + expect(rendererSource).toContain("this.videoEffectsContainer.addChild(this.maskGraphics)"); + expect(rendererSource).not.toContain("this.cameraContainer.addChild(this.maskGraphics)"); + }); +}); + type Listener = { callback: () => void; once: boolean; diff --git a/src/lib/exporter/frameRenderer.ts b/src/lib/exporter/frameRenderer.ts index c0b3c2d5..0bc5abef 100644 --- a/src/lib/exporter/frameRenderer.ts +++ b/src/lib/exporter/frameRenderer.ts @@ -233,6 +233,7 @@ function configureHighQuality2DContext( export class FrameRenderer { private app: Application | null = null; private cameraContainer: Container | null = null; + private videoEffectsContainer: Container | null = null; private videoContainer: Container | null = null; private cursorContainer: Container | null = null; private videoSprite: Sprite | null = null; @@ -399,11 +400,13 @@ export class FrameRenderer { // Setup containers this.cameraContainer = new Container(); + this.videoEffectsContainer = new Container(); this.videoContainer = new Container(); this.cursorContainer = new Container(); this.app.stage.addChild(this.cameraContainer); - this.cameraContainer.addChild(this.videoContainer); + this.cameraContainer.addChild(this.videoEffectsContainer); this.cameraContainer.addChild(this.cursorContainer); + this.videoEffectsContainer.addChild(this.videoContainer); if (cursorOverlayEnabled) { this.cursorOverlay = new PixiCursorOverlay({ @@ -499,7 +502,7 @@ export class FrameRenderer { // Setup mask this.maskGraphics = new Graphics(); - this.cameraContainer.addChild(this.maskGraphics); + this.videoEffectsContainer.addChild(this.maskGraphics); this.videoContainer.mask = this.maskGraphics; if (this.cursorOverlay) { this.cursorContainer.addChild(this.cursorOverlay.container); @@ -2208,6 +2211,7 @@ export class FrameRenderer { this.zoomBlurFilter?.destroy(); this.motionBlurFilter?.destroy(); this.cameraContainer = null; + this.videoEffectsContainer = null; this.videoContainer = null; this.maskGraphics = null; this.zoomBlurFilter = null;