Merge pull request #834 from webadderallorg/codex/macos-recording-resume-fix

Improve app presence and preview/export fidelity
This commit is contained in:
webadderall
2026-08-25 21:15:27 +10:00
committed by GitHub
28 changed files with 260 additions and 133 deletions
+21
View File
@@ -0,0 +1,21 @@
import { describe, expect, it } from "vitest";
import { getHudOverlayTaskbarOptions } from "./hudOverlayWindowOptions";
describe("getHudOverlayTaskbarOptions", () => {
it("keeps a focusable HUD in the Windows taskbar", () => {
expect(getHudOverlayTaskbarOptions("win32")).toEqual({
skipTaskbar: false,
focusable: true,
});
});
it.each([
"darwin",
"linux",
] as const)("keeps the HUD non-focusable and out of the taskbar on %s", (platform) => {
expect(getHudOverlayTaskbarOptions(platform)).toEqual({
skipTaskbar: true,
focusable: false,
});
});
});
+12
View File
@@ -0,0 +1,12 @@
export interface HudOverlayTaskbarOptions {
skipTaskbar: boolean;
focusable: boolean;
}
export function getHudOverlayTaskbarOptions(platform: NodeJS.Platform): HudOverlayTaskbarOptions {
const showInWindowsTaskbar = platform === "win32";
return {
skipTaskbar: !showInWindowsTaskbar,
focusable: showInWindowsTaskbar,
};
}
+40 -5
View File
@@ -8,8 +8,10 @@ import {
buildNativePrecompositedStaticLayoutArgs,
buildNativeStaticBackgroundRenderArgs,
buildNativeStaticLayoutChunks,
buildNativeVideoExportArgs,
buildTrimmedSourceAudioFilter,
createNativeSquircleMaskPgmBuffer,
FFMPEG_BT709_VIDEO_COLOR_ARGS,
isNativeCudaOutOfMemory,
} from "./nativeVideoExport";
@@ -150,14 +152,44 @@ describe("native static layout command builders", () => {
expect(args).toContain("-filter_complex");
expect(args).toContain(
"color=c=0x101010:s=1920x1080:r=60:d=60.000,format=nv12,hwupload_cuda[bg];" +
"[0:v]scale_cuda=w=1536:h=864:format=nv12,fps=60[fg];" +
"color=c=0x101010:s=1920x1080:r=60:d=60.000,format=nv12,setrange=limited,hwupload_cuda[bg];" +
"[0:v]scale_cuda=w=1536:h=864:format=nv12:passthrough=0,hwdownload,format=nv12,scale=in_range=auto:out_range=tv,fps=60,hwupload_cuda[fg];" +
"[bg][fg]overlay_cuda=192:108:shortest=0:repeatlast=1:eof_action=repeat,trim=duration=60.000,setpts=PTS-STARTPTS[out]",
);
expect(args).toContain("h264_nvenc");
expect(args).toContain("p1");
expect(args).not.toContain("yuv420p");
expect(args).toEqual(expect.arrayContaining(["-ss", "120.000", "-t", "60.000"]));
expect(args).toEqual(expect.arrayContaining([...FFMPEG_BT709_VIDEO_COLOR_ARGS]));
});
it("converts full-range canvas pixels and tags native H.264 as BT.709 video range", () => {
const args = buildNativeVideoExportArgs(
"h264_videotoolbox",
{
width: 1920,
height: 1080,
frameRate: 30,
bitrate: 30_000_000,
encodingMode: "quality",
},
"out.mp4",
);
expect(args).toEqual(
expect.arrayContaining([
"-vf",
"vflip,scale=in_range=full:out_range=tv",
"-colorspace",
"bt709",
"-color_primaries",
"bt709",
"-color_trc",
"bt709",
"-color_range",
"tv",
]),
);
});
it("builds the stable CUDA scale plus CPU pad fallback command", () => {
@@ -166,12 +198,13 @@ describe("native static layout command builders", () => {
expect(args).toEqual(
expect.arrayContaining([
"-vf",
"scale_cuda=w=1536:h=864:format=nv12:passthrough=0,hwdownload,format=nv12,fps=60,pad=w=1920:h=1080:x=192:y=108:color=0x101010",
"scale_cuda=w=1536:h=864:format=nv12:passthrough=0,hwdownload,format=nv12,scale=in_range=auto:out_range=tv,fps=60,pad=w=1920:h=1080:x=192:y=108:color=0x101010",
"-map",
"0:v:0",
"-an",
]),
);
expect(args).toEqual(expect.arrayContaining([...FFMPEG_BT709_VIDEO_COLOR_ARGS]));
});
it("sanitizes unsupported background colors to the safe dark fallback", () => {
@@ -181,7 +214,7 @@ describe("native static layout command builders", () => {
});
expect(args).toContain(
"scale_cuda=w=1536:h=864:format=nv12:passthrough=0,hwdownload,format=nv12,fps=60,pad=w=1920:h=1080:x=192:y=108:color=0x101010",
"scale_cuda=w=1536:h=864:format=nv12:passthrough=0,hwdownload,format=nv12,scale=in_range=auto:out_range=tv,fps=60,pad=w=1920:h=1080:x=192:y=108:color=0x101010",
);
});
@@ -229,12 +262,14 @@ describe("native static layout command builders", () => {
expect(args).toEqual(expect.arrayContaining(["-i", "background.png", "-i", "mask.pgm"]));
expect(filterComplex).toContain(
"scale_cuda=w=1536:h=864:format=nv12:passthrough=0,hwdownload,format=nv12,fps=60,format=rgba",
"scale_cuda=w=1536:h=864:format=nv12:passthrough=0,hwdownload,format=nv12,scale=in_range=auto:out_range=full,fps=60,format=rgba",
);
expect(filterComplex).toContain("[fgbase][mask]alphamerge[fg]");
expect(filterComplex).toContain("overlay=x=192:y=108:format=auto");
expect(filterComplex).toContain("scale=in_range=full:out_range=tv,format=yuv420p[out]");
expect(args).toContain("h264_nvenc");
expect(args).toEqual(expect.arrayContaining(["-pix_fmt", "yuv420p"]));
expect(args).toEqual(expect.arrayContaining([...FFMPEG_BT709_VIDEO_COLOR_ARGS]));
});
it("creates an opaque PGM mask for square video corners and a partial mask for radius", () => {
+31 -6
View File
@@ -9,6 +9,21 @@ const NATIVE_EXPORT_INPUT_BYTES_PER_PIXEL = 4;
const MIN_EDITED_TRACK_TEMPO_SPEED = 0.5;
const MAX_EDITED_TRACK_TEMPO_SPEED = 2;
export const FFMPEG_BT709_VIDEO_COLOR_ARGS = [
"-colorspace",
"bt709",
"-color_primaries",
"bt709",
"-color_trc",
"bt709",
"-color_range",
"tv",
] as const;
const FFMPEG_AUTO_TO_VIDEO_RANGE_FILTER = "scale=in_range=auto:out_range=tv";
const FFMPEG_AUTO_TO_FULL_RANGE_FILTER = "scale=in_range=auto:out_range=full";
const FFMPEG_FULL_TO_VIDEO_RANGE_FILTER = "scale=in_range=full:out_range=tv";
export type NativeExportEncodingMode = "fast" | "balanced" | "quality";
export type NativeVideoExportAudioMode = "none" | "copy-source" | "trim-source" | "edited-track";
@@ -296,7 +311,7 @@ export function buildNativeVideoExportArgs(
"-i",
"pipe:0",
"-vf",
"vflip",
"vflip,scale=in_range=full:out_range=tv",
"-an",
"-c:v",
encoder,
@@ -309,7 +324,14 @@ export function buildNativeVideoExportArgs(
args.push(...getLibx264ModeArgs(options.encodingMode));
}
args.push("-pix_fmt", "yuv420p", "-movflags", "+faststart", outputPath);
args.push(
"-pix_fmt",
"yuv420p",
...FFMPEG_BT709_VIDEO_COLOR_ARGS,
"-movflags",
"+faststart",
outputPath,
);
return args;
}
@@ -328,7 +350,7 @@ export function buildNativeCudaOverlayStaticLayoutArgs(
"-i",
config.inputPath,
"-filter_complex",
`color=c=${backgroundColor}:s=${config.width}x${config.height}:r=${config.frameRate}:d=${durationSec},format=nv12,hwupload_cuda[bg];[0:v]scale_cuda=w=${config.contentWidth}:h=${config.contentHeight}:format=nv12,fps=${config.frameRate}[fg];[bg][fg]overlay_cuda=${config.offsetX}:${config.offsetY}:shortest=0:repeatlast=1:eof_action=repeat,trim=duration=${durationSec},setpts=PTS-STARTPTS[out]`,
`color=c=${backgroundColor}:s=${config.width}x${config.height}:r=${config.frameRate}:d=${durationSec},format=nv12,setrange=limited,hwupload_cuda[bg];[0:v]scale_cuda=w=${config.contentWidth}:h=${config.contentHeight}:format=nv12:passthrough=0,hwdownload,format=nv12,${FFMPEG_AUTO_TO_VIDEO_RANGE_FILTER},fps=${config.frameRate},hwupload_cuda[fg];[bg][fg]overlay_cuda=${config.offsetX}:${config.offsetY}:shortest=0:repeatlast=1:eof_action=repeat,trim=duration=${durationSec},setpts=PTS-STARTPTS[out]`,
"-map",
"[out]",
"-an",
@@ -338,6 +360,7 @@ export function buildNativeCudaOverlayStaticLayoutArgs(
"h264_nvenc",
...getNvencStaticLayoutModeArgs(config.encodingMode),
...getBitrateArgs(config.bitrate),
...FFMPEG_BT709_VIDEO_COLOR_ARGS,
"-movflags",
"+faststart",
config.outputPath,
@@ -359,7 +382,7 @@ export function buildNativeCudaScaleCpuPadStaticLayoutArgs(
"-i",
config.inputPath,
"-vf",
`scale_cuda=w=${config.contentWidth}:h=${config.contentHeight}:format=nv12:passthrough=0,hwdownload,format=nv12,fps=${config.frameRate},pad=w=${config.width}:h=${config.height}:x=${config.offsetX}:y=${config.offsetY}:color=${backgroundColor}`,
`scale_cuda=w=${config.contentWidth}:h=${config.contentHeight}:format=nv12:passthrough=0,hwdownload,format=nv12,${FFMPEG_AUTO_TO_VIDEO_RANGE_FILTER},fps=${config.frameRate},pad=w=${config.width}:h=${config.height}:x=${config.offsetX}:y=${config.offsetY}:color=${backgroundColor}`,
"-map",
"0:v:0",
"-an",
@@ -371,6 +394,7 @@ export function buildNativeCudaScaleCpuPadStaticLayoutArgs(
...getBitrateArgs(config.bitrate),
"-pix_fmt",
"yuv420p",
...FFMPEG_BT709_VIDEO_COLOR_ARGS,
"-movflags",
"+faststart",
config.outputPath,
@@ -514,10 +538,10 @@ export function buildNativePrecompositedStaticLayoutArgs(
);
}
const foregroundFilter = `[0:v]scale_cuda=w=${config.contentWidth}:h=${config.contentHeight}:format=nv12:passthrough=0,hwdownload,format=nv12,fps=${config.frameRate},format=rgba[fgbase]`;
const foregroundFilter = `[0:v]scale_cuda=w=${config.contentWidth}:h=${config.contentHeight}:format=nv12:passthrough=0,hwdownload,format=nv12,${FFMPEG_AUTO_TO_FULL_RANGE_FILTER},fps=${config.frameRate},format=rgba[fgbase]`;
const maskFilter = useMask ? ";[2:v]format=gray[mask];[fgbase][mask]alphamerge[fg]" : "";
const foregroundLabel = useMask ? "fg" : "fgbase";
const filterComplex = `${foregroundFilter}${maskFilter};[1:v]format=rgba[bg];[bg][${foregroundLabel}]overlay=x=${config.offsetX}:y=${config.offsetY}:format=auto,trim=duration=${durationSec},setpts=PTS-STARTPTS,format=yuv420p[out]`;
const filterComplex = `${foregroundFilter}${maskFilter};[1:v]format=rgba[bg];[bg][${foregroundLabel}]overlay=x=${config.offsetX}:y=${config.offsetY}:format=auto,trim=duration=${durationSec},setpts=PTS-STARTPTS,${FFMPEG_FULL_TO_VIDEO_RANGE_FILTER},format=yuv420p[out]`;
args.push(
"-filter_complex",
@@ -533,6 +557,7 @@ export function buildNativePrecompositedStaticLayoutArgs(
...getBitrateArgs(config.bitrate),
"-pix_fmt",
"yuv420p",
...FFMPEG_BT709_VIDEO_COLOR_ARGS,
"-movflags",
"+faststart",
config.outputPath,
+17 -4
View File
@@ -528,6 +528,12 @@ function createTray() {
tray.on("double-click", () => focusOrCreateMainWindow());
}
function shouldUseTray() {
// macOS and Windows expose Recordly through their Dock/taskbar. Keep the
// tray entry only on Linux, where it remains the primary app entry point.
return process.platform === "linux";
}
function getPublicAssetPath(filename: string) {
return path.join(process.env.VITE_PUBLIC || RENDERER_DIST, filename);
}
@@ -1002,9 +1008,14 @@ app.whenReady().then(async () => {
}
}, 100);
});
if (process.platform === "darwin" && app.dock) {
await app.dock.show();
}
syncDockIcon();
createTray();
updateTrayMenu();
if (shouldUseTray()) {
createTray();
updateTrayMenu();
}
setupApplicationMenu();
// Ensure recordings directory exists
await ensureRecordingsDir();
@@ -1031,8 +1042,10 @@ app.whenReady().then(async () => {
(recording: boolean, sourceName: string) => {
selectedSourceName = sourceName;
setHudOverlayRecordingActive(recording);
if (!tray) createTray();
updateTrayMenu(recording);
if (shouldUseTray()) {
if (!tray) createTray();
updateTrayMenu(recording);
}
if (recording) {
reassertHudOverlayMouseState();
}
+23 -5
View File
@@ -10,6 +10,7 @@ import {
resizeHudOverlayFallbackBounds,
shouldExpandHudOverlayFallback,
} from "./hudOverlayBounds";
import { getHudOverlayTaskbarOptions } from "./hudOverlayWindowOptions";
import { getPackagedRendererBaseUrl } from "./rendererServer";
const electronWindowsDir = path.dirname(fileURLToPath(import.meta.url));
@@ -464,10 +465,11 @@ export function createHudOverlayWindow(): BrowserWindow {
backgroundColor: "#00000000",
resizable: false,
alwaysOnTop: true,
skipTaskbar: true,
// The HUD is Recordly's persistent top-level window, so it owns the
// Windows taskbar entry while auxiliary overlays stay hidden there.
...getHudOverlayTaskbarOptions(process.platform),
hasShadow: false,
show: false,
focusable: false,
webPreferences: {
preload: path.join(electronWindowsDir, "preload.mjs"),
nodeIntegration: false,
@@ -482,7 +484,13 @@ export function createHudOverlayWindow(): BrowserWindow {
return;
}
hasShownHudWindow = true;
win.show();
if (process.platform === "win32") {
// A focusable window is required for a Windows taskbar entry, but the
// always-on-top HUD must not steal focus when Recordly starts.
win.showInactive();
} else {
win.show();
}
win.moveTop();
if (process.platform === "win32" && isHudOverlayMousePassthroughSupported()) {
win.setIgnoreMouseEvents(false);
@@ -693,7 +701,12 @@ export function createUpdateToastWindow(): BrowserWindow {
win.setAlwaysOnTop(true, "status");
}
win.setVisibleOnAllWorkspaces(true, { visibleOnFullScreen: true });
win.setVisibleOnAllWorkspaces(true, {
visibleOnFullScreen: true,
// Keep Recordly a foreground application so macOS does not temporarily
// remove its Dock icon while showing an overlay window.
skipTransformProcessType: process.platform === "darwin",
});
updateToastWindow = win;
win.on("closed", () => {
@@ -1001,7 +1014,12 @@ export function createCountdownWindow(): BrowserWindow {
countdownWindow = win;
win.setVisibleOnAllWorkspaces(true, { visibleOnFullScreen: true });
win.setVisibleOnAllWorkspaces(true, {
visibleOnFullScreen: true,
// Keep Recordly a foreground application so macOS does not temporarily
// remove its Dock icon while showing the countdown.
skipTransformProcessType: process.platform === "darwin",
});
win.webContents.on("did-finish-load", () => {
if (!win.isDestroyed()) {
+15 -52
View File
@@ -171,9 +171,7 @@ import {
SNAP_TO_EDGES_RATIO_AUTO,
} from "./videoPlayback/cursorFollowCamera";
import { clampFocusToStage as clampFocusToStageUtil } from "./videoPlayback/focusUtils";
import {
layoutVideoContent as layoutVideoContentUtil,
} from "./videoPlayback/layoutUtils";
import { layoutVideoContent as layoutVideoContentUtil } from "./videoPlayback/layoutUtils";
import { updateOverlayIndicator } from "./videoPlayback/overlayUtils";
import { createVideoEventHandlers } from "./videoPlayback/videoEventHandlers";
import { getWebcamMediaTargetTimeSeconds, shouldSeekWebcamMedia } from "./videoPlayback/webcamSync";
@@ -1084,6 +1082,7 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
motionBlurFilter.resolution = filterResolution;
zoomBlurFilter.resolution = filterResolution;
cursorOverlayRef.current?.setFilterResolution(filterResolution);
videoEffectsContainer.filterArea = new Rectangle(0, 0, stageWidth, stageHeight);
}, []);
@@ -1908,53 +1907,6 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
});
}, [pixiReady, videoReady, layoutVideoContent, cropRegion]);
useEffect(() => {
const previewFrame = previewFrameRef.current;
if (!previewFrame) {
return;
}
let frameId: number | null = null;
const applyPreviewFrameSquircle = () => {
const width = previewFrame.offsetWidth;
const height = previewFrame.offsetHeight;
if (width <= 0 || height <= 0) {
return;
}
const squirclePath = getSquircleSvgPath({
x: 0,
y: 0,
width,
height,
radius: 12,
});
previewFrame.style.clipPath = `path('${squirclePath}')`;
previewFrame.style.setProperty("-webkit-clip-path", `path('${squirclePath}')`);
};
applyPreviewFrameSquircle();
if (typeof ResizeObserver === "undefined") {
return;
}
const observer = new ResizeObserver(() => {
if (frameId !== null) {
cancelAnimationFrame(frameId);
}
frameId = requestAnimationFrame(applyPreviewFrameSquircle);
});
observer.observe(previewFrame);
return () => {
if (frameId !== null) {
cancelAnimationFrame(frameId);
}
observer.disconnect();
};
}, []);
useEffect(() => {
if (!pixiReady || !videoReady) return;
const container = containerRef.current;
@@ -2173,6 +2125,9 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
sway: cursorSwayRef.current,
});
cursorOverlayRef.current = cursorOverlay;
cursorOverlay.setFilterResolution(
app.renderer.resolution || window.devicePixelRatio || 1,
);
cursorContainer.addChild(cursorOverlay.container);
} else {
cursorOverlayRef.current = null;
@@ -2951,6 +2906,9 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
: resolvedWallpaperKind === "video"
? {}
: { background: resolvedWallpaper || "" };
// Overscan blurred wallpaper layers so the browser never samples transparent
// pixels beyond the preview bounds, which otherwise looks like a vignette.
const backgroundBlurOverscan = backgroundBlur > 0 ? Math.ceil(backgroundBlur * 2) : 0;
const fallbackVideoClassName = pixiRendererError
? "absolute inset-0 h-full w-full object-cover"
: "pointer-events-none absolute left-0 top-0 h-px w-px opacity-0";
@@ -2981,7 +2939,8 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
style={{
width: "100%",
aspectRatio: formatAspectRatioForCSS(aspectRatio, nativeAspectRatio),
borderRadius: "12px",
borderRadius: 0,
clipPath: "none",
}}
>
{/* Background layer */}
@@ -2989,13 +2948,16 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
<video
key={resolvedWallpaper}
ref={bgVideoRef}
className="absolute inset-0 h-full w-full object-cover"
className="absolute object-cover"
src={resolvedWallpaper}
muted
loop
playsInline
style={{
filter: backgroundBlur > 0 ? `blur(${backgroundBlur}px)` : "none",
inset: -backgroundBlurOverscan,
width: `calc(100% + ${backgroundBlurOverscan * 2}px)`,
height: `calc(100% + ${backgroundBlurOverscan * 2}px)`,
}}
/>
) : (
@@ -3004,6 +2966,7 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
style={{
...backgroundStyle,
filter: backgroundBlur > 0 ? `blur(${backgroundBlur}px)` : "none",
inset: -backgroundBlurOverscan,
}}
/>
)}
@@ -16,6 +16,7 @@ import {
DEFAULT_CURSOR_STYLE,
normalizeCursorClickEffectColor,
} from "../types";
import { getCursorViewportScale } from "./cursorScale";
import { computeCursorSwayRotation } from "./cursorSway";
import { type CursorViewportRect, projectCursorPositionToViewport } from "./cursorViewport";
import {
@@ -110,8 +111,7 @@ export interface CursorRenderConfig {
style: CursorStyle;
}
const REFERENCE_WIDTH = 1920;
const MIN_CURSOR_VIEWPORT_SCALE = 0.55;
const MIN_CURSOR_VIEWPORT_SCALE = 0;
const CURSOR_MOTION_BLUR_BASE_MULTIPLIER = 0.08;
const CURSOR_TIME_DISCONTINUITY_MS = 100;
const CURSOR_SWAY_SMOOTHING_MULTIPLIER = 0.7;
@@ -807,13 +807,6 @@ function findLatestStableCursorType(samples: CursorTelemetryPoint[], timeMs: num
return findLatestSample(samples, timeMs)?.cursorType ?? "arrow";
}
function getCursorViewportScale(
viewport: CursorViewportRect,
minViewportScale = MIN_CURSOR_VIEWPORT_SCALE,
) {
return Math.max(minViewportScale, viewport.width / REFERENCE_WIDTH);
}
function getCursorSwaySpringConfig(smoothingFactor: number, springTuning: CursorSpringTuning) {
const baseConfig = getCursorSpringConfig(
Math.min(
@@ -1231,6 +1224,10 @@ export class PixiCursorOverlay {
}
}
setFilterResolution(resolution: number) {
this.cursorMotionBlurFilter.resolution = Math.max(1, resolution);
}
setClickBounce(clickBounce: number) {
this.config.clickBounce = Math.max(0, clickBounce);
}
@@ -1346,7 +1343,7 @@ export class PixiCursorOverlay {
const h =
this.config.dotRadius *
getCursorViewportScale(viewport, this.config.minViewportScale);
getCursorViewportScale(viewport.width, this.config.minViewportScale);
const { cursorType, clickSample, clickBounceProgress, clickProgress } =
getCursorVisualState(
samples,
@@ -1642,7 +1639,7 @@ export function drawCursorOnCanvas(
const px = viewport.x + smoothedState.x * viewport.width;
const py = viewport.y + smoothedState.y * viewport.height;
const h = config.dotRadius * getCursorViewportScale(viewport, config.minViewportScale);
const h = config.dotRadius * getCursorViewportScale(viewport.width, config.minViewportScale);
const { cursorType, clickSample, clickBounceProgress, clickProgress } = getCursorVisualState(
samples,
timeMs,
@@ -0,0 +1,14 @@
import { describe, expect, it } from "vitest";
import { getCursorViewportScale } from "./cursorScale";
describe("cursor preview/export scale", () => {
it("preserves the same cursor-to-video ratio at preview and export sizes", () => {
const baseCursorHeight = 28 * 2.5;
const previewWidth = 720;
const exportWidth = 2940;
const previewCursorHeight = baseCursorHeight * getCursorViewportScale(previewWidth);
const exportCursorHeight = baseCursorHeight * getCursorViewportScale(exportWidth);
expect(previewCursorHeight / previewWidth).toBeCloseTo(exportCursorHeight / exportWidth, 8);
});
});
@@ -0,0 +1,5 @@
export const CURSOR_REFERENCE_VIEWPORT_WIDTH = 1920;
export function getCursorViewportScale(viewportWidth: number, minimumScale = 0): number {
return Math.max(minimumScale, Math.max(0, viewportWidth) / CURSOR_REFERENCE_VIEWPORT_WIDTH);
}
+1 -1
View File
@@ -43,7 +43,7 @@
"amongus": "Among Us",
"turtle": "Turtle"
},
"backgroundBlur": "Hintergrundunschärfe",
"backgroundBlur": "Unschärfe",
"zoomMotionBlur": "Zoom-Bewegungsunschärfe",
"temporalZoomMotionBlur": "Zeitliche Zoom-Bewegungsunschärfe",
"temporalZoomMotionBlurDescription": "Steuert das Verschlussfenster und die Bildausschnitte, die vom neueren Zoom-Bewegungsunschärfe-Durchlauf verwendet werden.",
+1 -1
View File
@@ -43,7 +43,7 @@
"amongus": "Among Us",
"turtle": "Turtle"
},
"backgroundBlur": "Background Blur",
"backgroundBlur": "Blur",
"zoomMotionBlur": "Zoom Motion Blur",
"temporalZoomMotionBlur": "Temporal Zoom Blur",
"temporalZoomMotionBlurDescription": "Control the shutter window and frame samples used by the newer zoom blur pass.",
+1 -1
View File
@@ -43,7 +43,7 @@
"amongus": "Among Us",
"turtle": "Turtle"
},
"backgroundBlur": "Desenfoque de fondo",
"backgroundBlur": "Desenfoque",
"zoomMotionBlur": "Desenfoque de movimiento del zoom",
"temporalZoomMotionBlur": "Temporal Zoom Blur",
"temporalZoomMotionBlurDescription": "Control the shutter window and frame samples used by the newer zoom blur pass.",
+1 -1
View File
@@ -43,7 +43,7 @@
"amongus": "Among Us",
"turtle": "Tortue"
},
"backgroundBlur": "Flou d’arrière-plan",
"backgroundBlur": "Flou",
"zoomMotionBlur": "Flou de mouvement du zoom",
"temporalZoomMotionBlur": "Temporal Zoom Blur",
"temporalZoomMotionBlurDescription": "Control the shutter window and frame samples used by the newer zoom blur pass.",
+1 -1
View File
@@ -43,7 +43,7 @@
"amongus": "Among Us",
"turtle": "Tartaruga"
},
"backgroundBlur": "Sfocatura sfondo",
"backgroundBlur": "Sfocatura",
"zoomMotionBlur": "Motion blur dello zoom",
"temporalZoomMotionBlur": "Sfocatura zoom temporale",
"temporalZoomMotionBlurDescription": "Controlla la finestra dell'otturatore e i campioni di frame usati dal nuovo passaggio di sfocatura zoom.",
+1 -1
View File
@@ -43,7 +43,7 @@
"amongus": "Among Us",
"turtle": "Turtle"
},
"backgroundBlur": "배경 블러",
"backgroundBlur": "블러",
"zoomMotionBlur": "확대 모션 블러",
"temporalZoomMotionBlur": "Temporal Zoom Blur",
"temporalZoomMotionBlurDescription": "Control the shutter window and frame samples used by the newer zoom blur pass.",
+1 -1
View File
@@ -43,7 +43,7 @@
"amongus": "Among Us",
"turtle": "Schildpad"
},
"backgroundBlur": "Achtergrondvervaging",
"backgroundBlur": "Vervaging",
"zoomMotionBlur": "Zoom-bewegingsonscherpte",
"temporalZoomMotionBlur": "Temporal Zoom Blur",
"temporalZoomMotionBlurDescription": "Control the shutter window and frame samples used by the newer zoom blur pass.",
+1 -1
View File
@@ -43,7 +43,7 @@
"amongus": "Among Us",
"turtle": "Turtle"
},
"backgroundBlur": "Desfoque de fundo",
"backgroundBlur": "Desfoque",
"zoomMotionBlur": "Desfoque de movimento do zoom",
"temporalZoomMotionBlur": "Temporal Zoom Blur",
"temporalZoomMotionBlurDescription": "Control the shutter window and frame samples used by the newer zoom blur pass.",
+1 -1
View File
@@ -43,7 +43,7 @@
"amongus": "Among Us",
"turtle": "Черепаха"
},
"backgroundBlur": "Размытие фона",
"backgroundBlur": "Размытие",
"zoomMotionBlur": "Размытие при зуме",
"temporalZoomMotionBlur": "Временное размытие зума",
"temporalZoomMotionBlurDescription": "Настройка плавности зума по времени и кадрам.",
+1 -1
View File
@@ -43,7 +43,7 @@
"amongus": "Among Us",
"turtle": "Turtle"
},
"backgroundBlur": "背景模糊",
"backgroundBlur": "模糊",
"zoomMotionBlur": "缩放运动模糊",
"temporalZoomMotionBlur": "Temporal Zoom Blur",
"temporalZoomMotionBlurDescription": "Control the shutter window and frame samples used by the newer zoom blur pass.",
+1 -1
View File
@@ -43,7 +43,7 @@
"amongus": "Among Us",
"turtle": "Turtle"
},
"backgroundBlur": "背景模糊",
"backgroundBlur": "模糊",
"zoomMotionBlur": "縮放動態模糊",
"temporalZoomMotionBlur": "Temporal Zoom Blur",
"temporalZoomMotionBlurDescription": "Control the shutter window and frame samples used by the newer zoom blur pass.",
+4 -2
View File
@@ -2225,8 +2225,10 @@ export class FrameRenderer {
if (this.config.backgroundBlur > 0) {
ctx.save();
ctx.filter = `blur(${this.config.backgroundBlur * 3}px)`;
ctx.drawImage(bgCanvas, 0, 0, w, h);
const blurPx = this.config.backgroundBlur * 3;
const overscan = Math.ceil(blurPx * 2);
ctx.filter = `blur(${blurPx}px)`;
ctx.drawImage(bgCanvas, -overscan, -overscan, w + overscan * 2, h + overscan * 2);
ctx.restore();
} else {
ctx.drawImage(bgCanvas, 0, 0, w, h);
+11 -2
View File
@@ -1380,6 +1380,7 @@ export class FrameRenderer {
this.backgroundBlurFilter.blur = this.config.backgroundBlur * 3;
this.backgroundBlurFilter.quality = 4;
this.backgroundBlurFilter.resolution = this.app?.renderer.resolution ?? 1;
this.backgroundBlurFilter.repeatEdgePixels = true;
this.backgroundSprite.filters = [this.backgroundBlurFilter];
}
} else if (this.backgroundTextureSource) {
@@ -1410,8 +1411,16 @@ export class FrameRenderer {
}
blurredCtx.save();
blurredCtx.filter = `blur(${this.config.backgroundBlur * 3}px)`;
blurredCtx.drawImage(sourceCanvas, 0, 0, blurredCanvas.width, blurredCanvas.height);
const blurPx = this.config.backgroundBlur * 3;
const overscan = Math.ceil(blurPx * 2);
blurredCtx.filter = `blur(${blurPx}px)`;
blurredCtx.drawImage(
sourceCanvas,
-overscan,
-overscan,
blurredCanvas.width + overscan * 2,
blurredCanvas.height + overscan * 2,
);
blurredCtx.restore();
return blurredCanvas;
@@ -236,12 +236,12 @@ describe("ModernVideoExporter native static-layout eligibility", () => {
).toBeNull();
});
it("scales native static-layout cursor size with a minimum visible floor", () => {
it("preserves the cursor-to-video ratio at every native static-layout size", () => {
const exporter = createExporter({ cursorSize: 3, cursorStyle: "tahoe" });
expect(exporter.getNativeStaticLayoutCursorSize(1920)).toBeCloseTo(84, 6);
expect(exporter.getNativeStaticLayoutCursorSize(960)).toBeCloseTo(46.2, 6);
expect(exporter.getNativeStaticLayoutCursorSize(480)).toBeCloseTo(46.2, 6);
expect(exporter.getNativeStaticLayoutCursorSize(960)).toBeCloseTo(42, 6);
expect(exporter.getNativeStaticLayoutCursorSize(480)).toBeCloseTo(21, 6);
});
it("skips native static-layout when cursor click effects are enabled", () => {
+8 -13
View File
@@ -25,6 +25,7 @@ import {
SNAP_TO_EDGES_RATIO_AUTO,
} from "@/components/video-editor/videoPlayback/cursorFollowCamera";
import { buildNativeCursorAtlas } from "@/components/video-editor/videoPlayback/cursorRenderer";
import { getCursorViewportScale } from "@/components/video-editor/videoPlayback/cursorScale";
import {
computePaddedLayout,
scalePreviewBorderRadius,
@@ -92,6 +93,7 @@ import type {
ExportRenderBackend,
ExportResult,
} from "./types";
import { ENCODED_H264_COLOR_SPACE_FALLBACK, EXPORT_CANVAS_COLOR_SPACE } from "./videoColorSpace";
interface VideoExporterConfig extends ExportConfig {
videoUrl: string;
@@ -2089,7 +2091,7 @@ export class ModernVideoExporter {
private getNativeStaticLayoutCursorSize(contentWidth: number) {
const cursorStyle = this.config.cursorStyle ?? "tahoe";
const viewportScale = Math.max(0.55, contentWidth / 1920);
const viewportScale = getCursorViewportScale(contentWidth);
return (
28 *
(this.config.cursorSize ?? 3) *
@@ -2725,9 +2727,11 @@ export class ModernVideoExporter {
if (this.nativeEncoderError) throw this.nativeEncoderError;
}
const canvas = this.renderer!.getCanvas();
// @ts-expect-error - colorSpace is supported at runtime but missing from this DOM typing.
const frame = new VideoFrame(canvas, {
timestamp,
duration: frameDuration,
colorSpace: EXPORT_CANVAS_COLOR_SPACE,
});
this.nativeH264Encoder.encode(frame, { keyFrame: frameIndex % 300 === 0 });
frame.close();
@@ -2956,12 +2960,7 @@ export class ModernVideoExporter {
const exportFrame = new VideoFrame(canvas, {
timestamp,
duration: frameDuration,
colorSpace: {
primaries: "bt709",
transfer: "iec61966-2-1",
matrix: "rgb",
fullRange: true,
},
colorSpace: EXPORT_CANVAS_COLOR_SPACE,
});
while (
@@ -3376,12 +3375,8 @@ export class ModernVideoExporter {
try {
if (isFirstChunk && this.videoDescription) {
// Add decoder config for the first chunk
const colorSpace = this.videoColorSpace || {
primaries: "bt709",
transfer: "iec61966-2-1",
matrix: "rgb",
fullRange: true,
};
const colorSpace =
this.videoColorSpace || ENCODED_H264_COLOR_SPACE_FALLBACK;
const metadata: EncodedVideoChunkMetadata = {
decoderConfig: {
+13
View File
@@ -0,0 +1,13 @@
import { describe, expect, it } from "vitest";
import { ENCODED_H264_COLOR_SPACE_FALLBACK, EXPORT_CANVAS_COLOR_SPACE } from "./videoColorSpace";
describe("export colour metadata", () => {
it("does not confuse full-range RGB input with encoded YUV output", () => {
expect(EXPORT_CANVAS_COLOR_SPACE).toMatchObject({ matrix: "rgb", fullRange: true });
expect(ENCODED_H264_COLOR_SPACE_FALLBACK).toMatchObject({
matrix: "bt709",
transfer: "bt709",
fullRange: false,
});
});
});
+18
View File
@@ -0,0 +1,18 @@
/** The renderer composites into an sRGB canvas, whose pixels are full-range RGB. */
export const EXPORT_CANVAS_COLOR_SPACE = {
primaries: "bt709",
transfer: "iec61966-2-1",
matrix: "rgb",
fullRange: true,
} as const satisfies VideoColorSpaceInit;
/**
* H.264 encoders normally convert the canvas to video-range YUV. Use this only
* when the encoder does not report its own output colour metadata.
*/
export const ENCODED_H264_COLOR_SPACE_FALLBACK = {
primaries: "bt709",
transfer: "bt709",
matrix: "bt709",
fullRange: false,
} as const satisfies VideoColorSpaceInit;
+6 -19
View File
@@ -8,8 +8,8 @@ import type {
CursorStyle,
CursorTelemetryPoint,
Padding,
SpeedRegion,
SourceAudioTrackSettings,
SpeedRegion,
TrimRegion,
WebcamOverlaySettings,
ZoomMotionBlurTuning,
@@ -38,6 +38,7 @@ import type {
ExportProgress,
ExportResult,
} from "./types";
import { ENCODED_H264_COLOR_SPACE_FALLBACK, EXPORT_CANVAS_COLOR_SPACE } from "./videoColorSpace";
const DEFAULT_MAX_ENCODE_QUEUE = 240;
const PROGRESS_SAMPLE_WINDOW_MS = 1_000;
@@ -825,12 +826,7 @@ export class VideoExporter {
const frame = new VideoFrame(canvas, {
timestamp,
duration: frameDuration,
colorSpace: {
primaries: "bt709",
transfer: "iec61966-2-1",
matrix: "rgb",
fullRange: true,
},
colorSpace: EXPORT_CANVAS_COLOR_SPACE,
});
this.nativeH264Encoder.encode(frame, { keyFrame: frameIndex % 300 === 0 });
frame.close();
@@ -1077,12 +1073,7 @@ export class VideoExporter {
const exportFrame = new VideoFrame(canvas, {
timestamp,
duration: frameDuration,
colorSpace: {
primaries: "bt709",
transfer: "iec61966-2-1",
matrix: "rgb",
fullRange: true,
},
colorSpace: EXPORT_CANVAS_COLOR_SPACE,
});
while (
@@ -1270,12 +1261,8 @@ export class VideoExporter {
try {
if (isFirstChunk && this.videoDescription) {
// Add decoder config for the first chunk
const colorSpace = this.videoColorSpace || {
primaries: "bt709",
transfer: "iec61966-2-1",
matrix: "rgb",
fullRange: true,
};
const colorSpace =
this.videoColorSpace || ENCODED_H264_COLOR_SPACE_FALLBACK;
const metadata: EncodedVideoChunkMetadata = {
decoderConfig: {