From cd2405f2b2a8438072d474197e1acdd80879ceb4 Mon Sep 17 00:00:00 2001 From: webadderall <131426131+webadderall@users.noreply.github.com> Date: Mon, 14 Sep 2026 08:30:28 +1000 Subject: [PATCH] Render clip gaps as solid black --- src/components/video-editor/VideoPlayback.tsx | 17 +++++++++++---- src/lib/exporter/frameRenderer.test.ts | 15 ++++++++----- src/lib/exporter/frameRenderer.ts | 21 +++---------------- src/lib/exporter/modernFrameRenderer.test.ts | 19 ++++++++++------- src/lib/exporter/modernFrameRenderer.ts | 12 +++++------ 5 files changed, 42 insertions(+), 42 deletions(-) diff --git a/src/components/video-editor/VideoPlayback.tsx b/src/components/video-editor/VideoPlayback.tsx index 3c722e06..33b1c679 100644 --- a/src/components/video-editor/VideoPlayback.tsx +++ b/src/components/video-editor/VideoPlayback.tsx @@ -46,6 +46,7 @@ import { type AnnotationRegion, type AutoCaptionSettings, type CaptionCue, + type ClipRegion, type CursorClickEffectStyle, type CursorStyle, DEFAULT_CONNECTED_ZOOM_DURATION_MS, @@ -75,7 +76,9 @@ import { DEFAULT_ZOOM_MOTION_BLUR_TUNING, DEFAULT_ZOOM_OUT_DURATION_MS, DEFAULT_ZOOM_OUT_EASING, + findClipAtTimelineTime, getDefaultCaptionFontFamily, + mapTimelineTimeToSourceTime, type Padding, type WebcamOverlaySettings, type ZoomDepth, @@ -88,6 +91,7 @@ import { isAnnotationActiveAtTime, shouldClearSelectedAnnotation, } from "./videoPlayback/annotationVisibility"; +import { createClipPlayback } from "./videoPlayback/clipPlayback"; import { DEFAULT_FOCUS } from "./videoPlayback/constants"; import { type CursorFollowCameraState, @@ -116,8 +120,6 @@ import { resolveSceneZoomTarget, shouldComposePreviewFrame, } from "./videoPlayback/sceneMotion"; -import { createClipPlayback } from "./videoPlayback/clipPlayback"; -import { type ClipRegion, findClipAtTimelineTime, mapTimelineTimeToSourceTime } from "./types"; import { getWebcamMediaTargetTimeSeconds, isWebcamMediaSynchronized, @@ -2474,6 +2476,7 @@ const VideoPlayback = forwardRef( style={{ width: "100%", aspectRatio: formatAspectRatioForCSS(aspectRatio, nativeAspectRatio), + backgroundColor: "#000000", borderRadius: 0, clipPath: "none", }} @@ -2489,6 +2492,7 @@ const VideoPlayback = forwardRef( loop playsInline style={{ + visibility: isGap ? "hidden" : "visible", filter: sceneEffects.backgroundBlurPx > 0 ? `blur(${sceneEffects.backgroundBlurPx}px)` @@ -2503,6 +2507,7 @@ const VideoPlayback = forwardRef( className="absolute inset-0 bg-cover bg-center" style={{ ...backgroundStyle, + visibility: isGap ? "hidden" : "visible", filter: sceneEffects.backgroundBlurPx > 0 ? `blur(${sceneEffects.backgroundBlurPx}px)` @@ -2519,7 +2524,7 @@ const VideoPlayback = forwardRef( visibility: isGap ? "hidden" : "visible", }} /> - {hasRendererFallback && ( + {hasRendererFallback && !isGap && (
{`Pixi renderer unavailable on this environment (${pixiRendererBackend ?? "unknown"}).`} @@ -2534,7 +2539,10 @@ const VideoPlayback = forwardRef(
( ref={attachVideo} src={videoPath} className={fallbackVideoClassName} + style={{ visibility: isGap ? "hidden" : "visible" }} preload="metadata" playsInline aria-hidden="true" diff --git a/src/lib/exporter/frameRenderer.test.ts b/src/lib/exporter/frameRenderer.test.ts index 2ad9f704..ba1701e5 100644 --- a/src/lib/exporter/frameRenderer.test.ts +++ b/src/lib/exporter/frameRenderer.test.ts @@ -112,6 +112,7 @@ type MockContext = { scale: MockFunction; clearRect: MockFunction; filter: string; + fillStyle: string; }; type MockCanvas = ReturnType; type FrameRendererTestAccess = { @@ -251,6 +252,7 @@ function createMockContext() { scale: vi.fn(), clearRect: vi.fn(), filter: "", + fillStyle: "", }; } @@ -287,19 +289,22 @@ function createRenderer() { } describe("FrameRenderer webcam export path", () => { - it("draws the background without the screen or webcam during a timeline gap", async () => { + it("renders a timeline gap as solid black", async () => { const renderer = createRenderer(); const camera = { visible: true }; - const composite = vi.fn(); + const context = createMockContext(); + const app = { stage: {}, renderer: { render: vi.fn() } }; Object.assign(renderer, { - app: { stage: {}, renderer: { render: vi.fn() } }, + app, cameraContainer: camera, videoContainer: {}, - compositeWithShadows: composite, + compositeCtx: context, }); await renderer.renderFrame(null, 0, 0, 33333, 1500000); expect(camera.visible).toBe(false); - expect(composite).toHaveBeenCalledWith(false); + expect(context.fillStyle).toBe("#000000"); + expect(context.fillRect).toHaveBeenCalledWith(0, 0, 1920, 1080); + expect(app.renderer.render).not.toHaveBeenCalled(); }); const createdCanvases: ReturnType[] = []; diff --git a/src/lib/exporter/frameRenderer.ts b/src/lib/exporter/frameRenderer.ts index 6d44f694..41cc9d62 100644 --- a/src/lib/exporter/frameRenderer.ts +++ b/src/lib/exporter/frameRenderer.ts @@ -1410,30 +1410,15 @@ export class FrameRenderer { frameDurationUs?: number, backgroundTimelineTimestamp = timestamp, ): Promise { - if (!this.app || !this.videoContainer || !this.cameraContainer) { + if (!this.app || !this.videoContainer || !this.cameraContainer || !this.compositeCtx) { throw new Error("Renderer not initialized"); } this.currentVideoTime = timestamp / 1000000; this.cameraContainer.visible = videoFrame !== null; if (!videoFrame) { - if (this.backgroundForwardFrameSource || this.backgroundVideoElement) { - await this.syncBackgroundFrame(backgroundTimelineTimestamp / 1_000_000); - } - this.app.renderer.render(this.app.stage); - this.compositeWithShadows(false); - if (this.compositeCtx && this.config.annotationRegions) { - await renderAnnotations( - this.compositeCtx, - this.config.annotationRegions, - this.config.width, - this.config.height, - backgroundTimelineTimestamp / 1000, - (this.config.width / BASE_PREVIEW_WIDTH + - this.config.height / BASE_PREVIEW_HEIGHT) / - 2, - ); - } + this.compositeCtx.fillStyle = "#000000"; + this.compositeCtx.fillRect(0, 0, this.config.width, this.config.height); return; } diff --git a/src/lib/exporter/modernFrameRenderer.test.ts b/src/lib/exporter/modernFrameRenderer.test.ts index ce6aa9cc..456519fb 100644 --- a/src/lib/exporter/modernFrameRenderer.test.ts +++ b/src/lib/exporter/modernFrameRenderer.test.ts @@ -202,11 +202,12 @@ function createRenderer() { }); } -it("hides source layers in gaps while rendering timeline annotations at the output time", async () => { +it("renders a timeline gap as solid black", async () => { const renderer = createRenderer(); const camera = { visible: true }; - const webcam = { visible: true }; - const captions = { visible: true }; + const output = createMockCanvas(); + output.width = 1920; + output.height = 1080; const annotations = vi.fn(); const renderOutput = vi.fn(async () => {}); Object.assign(renderer, { @@ -214,15 +215,17 @@ it("hides source layers in gaps while rendering timeline annotations at the outp videoContainer: {}, videoMaskGraphics: {}, cameraContainer: camera, - webcamRootContainer: webcam, - captionContainer: captions, + ensureExportCompositeCanvas: () => ({ canvas: output, context: output.context }), updateAnnotationLayer: annotations, renderOutput, }); await renderer.renderFrame(null, 0, 0, 33333, 1500000); - for (const layer of [camera, webcam, captions]) expect(layer.visible).toBe(false); - expect(annotations).toHaveBeenCalledWith(1500); - expect(renderOutput).toHaveBeenCalledWith(1500); + expect(camera.visible).toBe(false); + expect(output.context.fillStyle).toBe("#000000"); + expect(output.context.fillRect).toHaveBeenCalledWith(0, 0, 1920, 1080); + expect(annotations).not.toHaveBeenCalled(); + expect(renderOutput).not.toHaveBeenCalled(); + expect(renderer.getCanvas()).toBe(output); }); describe("ModernFrameRenderer Pixi lifecycle", () => { diff --git a/src/lib/exporter/modernFrameRenderer.ts b/src/lib/exporter/modernFrameRenderer.ts index 9dee1898..24a6f86d 100644 --- a/src/lib/exporter/modernFrameRenderer.ts +++ b/src/lib/exporter/modernFrameRenderer.ts @@ -3118,13 +3118,11 @@ export class FrameRenderer { this.currentVideoTime = timestamp / 1_000_000; this.cameraContainer.visible = videoFrame !== null; if (!videoFrame) { - if (this.backgroundForwardFrameSource || this.backgroundVideoElement) { - await this.syncBackgroundFrame(backgroundTimelineTimestamp / 1_000_000); - } - if (this.webcamRootContainer) this.webcamRootContainer.visible = false; - if (this.captionContainer) this.captionContainer.visible = false; - this.updateAnnotationLayer(backgroundTimelineTimestamp / 1000); - await this.renderOutput(backgroundTimelineTimestamp / 1000); + const output = this.ensureExportCompositeCanvas(); + if (!output) throw new Error("Failed to create gap frame canvas"); + output.context.fillStyle = "#000000"; + output.context.fillRect(0, 0, output.canvas.width, output.canvas.height); + this.outputCanvasOverride = output.canvas; return; } if (this.captionContainer) this.captionContainer.visible = true;