mirror of
https://github.com/webadderallorg/Recordly.git
synced 2026-09-26 07:45:34 +00:00
Merge pull request #834 from webadderallorg/codex/macos-recording-resume-fix
Improve app presence and preview/export fidelity
This commit is contained in:
@@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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
@@ -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
@@ -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()) {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -43,7 +43,7 @@
|
||||
"amongus": "Among Us",
|
||||
"turtle": "Черепаха"
|
||||
},
|
||||
"backgroundBlur": "Размытие фона",
|
||||
"backgroundBlur": "Размытие",
|
||||
"zoomMotionBlur": "Размытие при зуме",
|
||||
"temporalZoomMotionBlur": "Временное размытие зума",
|
||||
"temporalZoomMotionBlurDescription": "Настройка плавности зума по времени и кадрам.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
@@ -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: {
|
||||
|
||||
Reference in New Issue
Block a user