Render clip gaps as solid black

This commit is contained in:
webadderall
2026-09-14 08:30:28 +10:00
parent ad155d486e
commit cd2405f2b2
5 changed files with 42 additions and 42 deletions
+13 -4
View File
@@ -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<VideoPlaybackRef, VideoPlaybackProps>(
style={{
width: "100%",
aspectRatio: formatAspectRatioForCSS(aspectRatio, nativeAspectRatio),
backgroundColor: "#000000",
borderRadius: 0,
clipPath: "none",
}}
@@ -2489,6 +2492,7 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
loop
playsInline
style={{
visibility: isGap ? "hidden" : "visible",
filter:
sceneEffects.backgroundBlurPx > 0
? `blur(${sceneEffects.backgroundBlurPx}px)`
@@ -2503,6 +2507,7 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
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<VideoPlaybackRef, VideoPlaybackProps>(
visibility: isGap ? "hidden" : "visible",
}}
/>
{hasRendererFallback && (
{hasRendererFallback && !isGap && (
<div className="absolute inset-0 z-10 flex items-center justify-center bg-black/60 p-2 text-center">
<div className="rounded-md bg-black/70 px-3 py-1.5 text-xs text-white">
{`Pixi renderer unavailable on this environment (${pixiRendererBackend ?? "unknown"}).`}
@@ -2534,7 +2539,10 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
<div
ref={overlayRef}
className="absolute inset-0 select-none"
style={{ pointerEvents: "none" }}
style={{
pointerEvents: "none",
visibility: isGap ? "hidden" : "visible",
}}
onPointerDown={handleOverlayPointerDown}
onPointerMove={handleOverlayPointerMove}
onPointerUp={handleOverlayPointerUp}
@@ -2916,6 +2924,7 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
ref={attachVideo}
src={videoPath}
className={fallbackVideoClassName}
style={{ visibility: isGap ? "hidden" : "visible" }}
preload="metadata"
playsInline
aria-hidden="true"
+10 -5
View File
@@ -112,6 +112,7 @@ type MockContext = {
scale: MockFunction;
clearRect: MockFunction;
filter: string;
fillStyle: string;
};
type MockCanvas = ReturnType<typeof createMockCanvas>;
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<typeof createMockCanvas>[] = [];
+3 -18
View File
@@ -1410,30 +1410,15 @@ export class FrameRenderer {
frameDurationUs?: number,
backgroundTimelineTimestamp = timestamp,
): Promise<void> {
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;
}
+11 -8
View File
@@ -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", () => {
+5 -7
View File
@@ -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;