mirror of
https://github.com/webadderallorg/Recordly.git
synced 2026-09-24 06:46:09 +00:00
fix(export): support native cropped layouts
This commit is contained in:
Vendored
+5
@@ -336,6 +336,10 @@ interface Window {
|
||||
contentHeight: number;
|
||||
offsetX: number;
|
||||
offsetY: number;
|
||||
sourceCropX?: number;
|
||||
sourceCropY?: number;
|
||||
sourceCropWidth?: number;
|
||||
sourceCropHeight?: number;
|
||||
backgroundColor: string;
|
||||
backgroundImagePath?: string | null;
|
||||
backgroundBlurPx?: number;
|
||||
@@ -355,6 +359,7 @@ interface Window {
|
||||
cy: number;
|
||||
cursorTypeIndex?: number;
|
||||
bounceScale?: number;
|
||||
visible?: boolean;
|
||||
}>;
|
||||
cursorSize?: number;
|
||||
cursorAtlasPngDataUrl?: string | null;
|
||||
|
||||
@@ -646,6 +646,32 @@ describe("buildExperimentalNvidiaCudaStaticLayoutArgs", () => {
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it("passes source crop coordinates to the CUDA wrapper", () => {
|
||||
const args = buildExperimentalNvidiaCudaStaticLayoutArgs(
|
||||
createNvidiaCudaSkipOptions({
|
||||
sourceCropX: 192,
|
||||
sourceCropY: 108,
|
||||
sourceCropWidth: 1536,
|
||||
sourceCropHeight: 864,
|
||||
}),
|
||||
"output.mp4",
|
||||
"work",
|
||||
);
|
||||
|
||||
expect(args).toEqual(
|
||||
expect.arrayContaining([
|
||||
"--source-crop-x",
|
||||
"192",
|
||||
"--source-crop-y",
|
||||
"108",
|
||||
"--source-crop-width",
|
||||
"1536",
|
||||
"--source-crop-height",
|
||||
"864",
|
||||
]),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildExperimentalWindowsGpuStaticLayoutArgs", () => {
|
||||
@@ -660,6 +686,31 @@ describe("buildExperimentalWindowsGpuStaticLayoutArgs", () => {
|
||||
|
||||
expect(args).toEqual(expect.arrayContaining(["--background-blur", "36"]));
|
||||
});
|
||||
|
||||
it("passes source crop coordinates to the D3D11 compositor", () => {
|
||||
const args = buildExperimentalWindowsGpuStaticLayoutArgs(
|
||||
createNvidiaCudaSkipOptions({
|
||||
sourceCropX: 192,
|
||||
sourceCropY: 108,
|
||||
sourceCropWidth: 1536,
|
||||
sourceCropHeight: 864,
|
||||
}),
|
||||
"output.mp4",
|
||||
);
|
||||
|
||||
expect(args).toEqual(
|
||||
expect.arrayContaining([
|
||||
"--source-crop-x",
|
||||
"192",
|
||||
"--source-crop-y",
|
||||
"108",
|
||||
"--source-crop-width",
|
||||
"1536",
|
||||
"--source-crop-height",
|
||||
"864",
|
||||
]),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildNativeStaticLayoutTimelineSegments", () => {
|
||||
|
||||
@@ -105,6 +105,10 @@ export interface NativeStaticLayoutExportOptions {
|
||||
contentHeight: number;
|
||||
offsetX: number;
|
||||
offsetY: number;
|
||||
sourceCropX?: number;
|
||||
sourceCropY?: number;
|
||||
sourceCropWidth?: number;
|
||||
sourceCropHeight?: number;
|
||||
backgroundColor: string;
|
||||
backgroundImagePath?: string | null;
|
||||
backgroundBlurPx?: number;
|
||||
@@ -124,6 +128,7 @@ export interface NativeStaticLayoutExportOptions {
|
||||
cy: number;
|
||||
cursorTypeIndex?: number;
|
||||
bounceScale?: number;
|
||||
visible?: boolean;
|
||||
}>;
|
||||
cursorTelemetryPath?: string | null;
|
||||
cursorSize?: number;
|
||||
@@ -1655,6 +1660,15 @@ function hasNativeStaticLayoutTimeline(options: NativeStaticLayoutExportOptions)
|
||||
return (options.timelineSegments?.length ?? 0) > 0;
|
||||
}
|
||||
|
||||
function hasNativeStaticLayoutSourceCrop(options: NativeStaticLayoutExportOptions) {
|
||||
return Boolean(
|
||||
Number.isFinite(options.sourceCropWidth) &&
|
||||
Number.isFinite(options.sourceCropHeight) &&
|
||||
(options.sourceCropWidth ?? 0) >= 2 &&
|
||||
(options.sourceCropHeight ?? 0) >= 2,
|
||||
);
|
||||
}
|
||||
|
||||
export function buildNativeStaticLayoutTimelineSegments(
|
||||
segments: NativeVideoExportEditedTrackSegment[],
|
||||
): NativeStaticLayoutTimelineSegment[] {
|
||||
@@ -2149,6 +2163,25 @@ export function buildExperimentalWindowsGpuStaticLayoutArgs(
|
||||
if (options.backgroundImagePath) {
|
||||
args.push("--background-image", options.backgroundImagePath);
|
||||
}
|
||||
if (
|
||||
Number.isFinite(options.sourceCropX) &&
|
||||
Number.isFinite(options.sourceCropY) &&
|
||||
Number.isFinite(options.sourceCropWidth) &&
|
||||
Number.isFinite(options.sourceCropHeight) &&
|
||||
(options.sourceCropWidth ?? 0) >= 2 &&
|
||||
(options.sourceCropHeight ?? 0) >= 2
|
||||
) {
|
||||
args.push(
|
||||
"--source-crop-x",
|
||||
String(Math.round(options.sourceCropX ?? 0)),
|
||||
"--source-crop-y",
|
||||
String(Math.round(options.sourceCropY ?? 0)),
|
||||
"--source-crop-width",
|
||||
String(Math.round(options.sourceCropWidth ?? 0)),
|
||||
"--source-crop-height",
|
||||
String(Math.round(options.sourceCropHeight ?? 0)),
|
||||
);
|
||||
}
|
||||
if (backgroundBlurPx > 0) {
|
||||
args.push("--background-blur", formatCliNumber(backgroundBlurPx));
|
||||
}
|
||||
@@ -2226,12 +2259,14 @@ async function prepareWindowsGpuCursorTelemetry(
|
||||
Math.min(8, Math.round(sample.cursorTypeIndex ?? 0)),
|
||||
);
|
||||
const bounceScale = Math.min(2, Math.max(0.1, sample.bounceScale ?? 1));
|
||||
const visible = sample.visible !== false ? 1 : 0;
|
||||
return [
|
||||
formatCliNumber(timeMs),
|
||||
formatCliNumber(cx),
|
||||
formatCliNumber(cy),
|
||||
String(cursorTypeIndex),
|
||||
formatCliNumber(bounceScale),
|
||||
String(visible),
|
||||
].join(",");
|
||||
});
|
||||
|
||||
@@ -2318,6 +2353,7 @@ async function prepareNvidiaCudaCursorTelemetry(
|
||||
cy: Math.min(1, Math.max(0, sample.cy)),
|
||||
cursorTypeIndex: Math.max(0, Math.min(8, Math.round(sample.cursorTypeIndex ?? 0))),
|
||||
bounceScale: Math.min(2, Math.max(0.1, sample.bounceScale ?? 1)),
|
||||
visible: sample.visible !== false,
|
||||
}));
|
||||
if (samples.length === 0) {
|
||||
return null;
|
||||
@@ -2587,6 +2623,25 @@ export function buildExperimentalNvidiaCudaStaticLayoutArgs(
|
||||
if (options.backgroundImagePath) {
|
||||
args.push("--background-image", options.backgroundImagePath);
|
||||
}
|
||||
if (
|
||||
Number.isFinite(options.sourceCropX) &&
|
||||
Number.isFinite(options.sourceCropY) &&
|
||||
Number.isFinite(options.sourceCropWidth) &&
|
||||
Number.isFinite(options.sourceCropHeight) &&
|
||||
(options.sourceCropWidth ?? 0) >= 2 &&
|
||||
(options.sourceCropHeight ?? 0) >= 2
|
||||
) {
|
||||
args.push(
|
||||
"--source-crop-x",
|
||||
String(Math.round(options.sourceCropX ?? 0)),
|
||||
"--source-crop-y",
|
||||
String(Math.round(options.sourceCropY ?? 0)),
|
||||
"--source-crop-width",
|
||||
String(Math.round(options.sourceCropWidth ?? 0)),
|
||||
"--source-crop-height",
|
||||
String(Math.round(options.sourceCropHeight ?? 0)),
|
||||
);
|
||||
}
|
||||
if (backgroundBlurPx > 0) {
|
||||
args.push("--background-blur", formatCliNumber(backgroundBlurPx));
|
||||
}
|
||||
@@ -3158,6 +3213,10 @@ export async function exportNativeStaticLayoutVideo(
|
||||
contentHeight: options.contentHeight,
|
||||
offsetX: options.offsetX,
|
||||
offsetY: options.offsetY,
|
||||
sourceCropX: options.sourceCropX,
|
||||
sourceCropY: options.sourceCropY,
|
||||
sourceCropWidth: options.sourceCropWidth,
|
||||
sourceCropHeight: options.sourceCropHeight,
|
||||
backgroundColor: options.backgroundColor,
|
||||
backgroundImagePath: options.backgroundImagePath,
|
||||
backgroundBlurPx: options.backgroundBlurPx,
|
||||
@@ -3463,6 +3522,9 @@ export async function exportNativeStaticLayoutVideo(
|
||||
if (!didRenderVideo && hasNativeStaticLayoutTimeline(options)) {
|
||||
throw new Error("Native timeline-map export requires a GPU compositor backend");
|
||||
}
|
||||
if (!didRenderVideo && hasNativeStaticLayoutSourceCrop(options)) {
|
||||
throw new Error("Native crop export requires a GPU compositor backend");
|
||||
}
|
||||
|
||||
if (!didRenderVideo && usePrecompositedLayout) {
|
||||
const maskPath = path.join(chunkDirectory, "layout-mask.pgm");
|
||||
|
||||
@@ -76,6 +76,10 @@ export interface NativeStaticLayoutExportArgsConfig {
|
||||
contentHeight: number;
|
||||
offsetX: number;
|
||||
offsetY: number;
|
||||
sourceCropX?: number;
|
||||
sourceCropY?: number;
|
||||
sourceCropWidth?: number;
|
||||
sourceCropHeight?: number;
|
||||
backgroundColor: string;
|
||||
backgroundImagePath?: string | null;
|
||||
backgroundBlurPx?: number;
|
||||
|
||||
@@ -19,17 +19,17 @@
|
||||
},
|
||||
"recordly-gpu-export": {
|
||||
"binaryName": "recordly-gpu-export.exe",
|
||||
"binarySha256": "f147901f01dd5b410f67a5c0feae5064ea7556a878b333aa493e799498abb5e5",
|
||||
"binarySha256": "4cb3a293fd36f718af55906820d9b3fd78babc855888c2e248f0b918ec1aff3c",
|
||||
"sourceDir": "electron/native/gpu-export-probe",
|
||||
"sourceFingerprint": "056a4c41113c471379d0ec6095249c46a79b05fac5fc0e52ce0bfdf6de4d91e1",
|
||||
"updatedAt": "2026-05-07T15:21:50.702Z"
|
||||
"sourceFingerprint": "743b386a5f1bbcc99cec5465c3de228d2b045061dead31dfcbf25cf6a1e61de5",
|
||||
"updatedAt": "2026-05-07T20:13:48.585Z"
|
||||
},
|
||||
"recordly-nvidia-cuda-compositor": {
|
||||
"binaryName": "recordly-nvidia-cuda-compositor.exe",
|
||||
"binarySha256": "c02a3326b1f840df788a206d288164d428156177ce64aa1331d87e7913826b77",
|
||||
"binarySha256": "a787531c07142de7c292d1726e0339c97dbce5073d9a0853d539a725265fd945",
|
||||
"sourceDir": "electron/native/nvidia-cuda-compositor",
|
||||
"sourceFingerprint": "5388602559cbfb98ab18dd3ad96ce55897c0a548febaeb9304d26a0201b296b0",
|
||||
"updatedAt": "2026-05-07T15:22:12.014Z"
|
||||
"sourceFingerprint": "528b599e9d576d81ec087d0d4dc93a79af1bbf30fdb969f44f773bef90146135",
|
||||
"updatedAt": "2026-05-07T20:14:13.794Z"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Binary file not shown.
Binary file not shown.
@@ -57,6 +57,10 @@ struct Options {
|
||||
LONG contentTop = -1;
|
||||
LONG contentWidth = 0;
|
||||
LONG contentHeight = 0;
|
||||
LONG sourceCropLeft = 0;
|
||||
LONG sourceCropTop = 0;
|
||||
LONG sourceCropWidth = 0;
|
||||
LONG sourceCropHeight = 0;
|
||||
float backgroundR = 0.035f;
|
||||
float backgroundG = 0.035f;
|
||||
float backgroundB = 0.045f;
|
||||
@@ -140,6 +144,7 @@ struct CursorSample {
|
||||
float cy = 0.0f;
|
||||
int cursorTypeIndex = 0;
|
||||
float bounceScale = 1.0f;
|
||||
bool visible = true;
|
||||
};
|
||||
|
||||
struct CursorAtlasEntry {
|
||||
@@ -388,6 +393,10 @@ Options parseOptions(int argc, wchar_t** argv) {
|
||||
options.contentTop = parseLongArg(args, L"--content-top", options.contentTop);
|
||||
options.contentWidth = parseLongArg(args, L"--content-width", options.contentWidth);
|
||||
options.contentHeight = parseLongArg(args, L"--content-height", options.contentHeight);
|
||||
options.sourceCropLeft = parseLongArg(args, L"--source-crop-x", options.sourceCropLeft);
|
||||
options.sourceCropTop = parseLongArg(args, L"--source-crop-y", options.sourceCropTop);
|
||||
options.sourceCropWidth = parseLongArg(args, L"--source-crop-width", options.sourceCropWidth);
|
||||
options.sourceCropHeight = parseLongArg(args, L"--source-crop-height", options.sourceCropHeight);
|
||||
const auto backgroundColor = getArgValue(args, L"--background-color");
|
||||
if (!backgroundColor.empty()) {
|
||||
parseHexColor(
|
||||
@@ -875,12 +884,7 @@ private:
|
||||
return false;
|
||||
}
|
||||
|
||||
RECT rect = {
|
||||
0,
|
||||
0,
|
||||
static_cast<LONG>(sourceWidth_),
|
||||
static_cast<LONG>(sourceHeight_),
|
||||
};
|
||||
RECT rect = getSourceCropRect();
|
||||
RECT outputRect = {
|
||||
0,
|
||||
0,
|
||||
@@ -2024,14 +2028,16 @@ float4 main(PSIn input) : SV_Target {
|
||||
float cy = 0.0f;
|
||||
int cursorTypeIndex = 0;
|
||||
float bounceScale = 1.0f;
|
||||
int visible = 1;
|
||||
const int parsed = sscanf_s(
|
||||
line,
|
||||
"%lf,%f,%f,%d,%f",
|
||||
"%lf,%f,%f,%d,%f,%d",
|
||||
&timeMs,
|
||||
&cx,
|
||||
&cy,
|
||||
&cursorTypeIndex,
|
||||
&bounceScale);
|
||||
&bounceScale,
|
||||
&visible);
|
||||
if (parsed < 3) {
|
||||
continue;
|
||||
}
|
||||
@@ -2046,6 +2052,7 @@ float4 main(PSIn input) : SV_Target {
|
||||
std::isfinite(bounceScale)
|
||||
? std::min(2.0f, std::max(0.1f, bounceScale))
|
||||
: 1.0f,
|
||||
parsed >= 6 ? visible != 0 : true,
|
||||
});
|
||||
}
|
||||
std::fclose(file);
|
||||
@@ -2083,6 +2090,7 @@ float4 main(PSIn input) : SV_Target {
|
||||
a.cy + (b.cy - a.cy) * t,
|
||||
a.cursorTypeIndex,
|
||||
a.bounceScale + (b.bounceScale - a.bounceScale) * t,
|
||||
a.visible && b.visible,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -2188,6 +2196,41 @@ float4 main(PSIn input) : SV_Target {
|
||||
return {left, top, left + safeSize, top + safeSize};
|
||||
}
|
||||
|
||||
RECT getSourceCropRect() const {
|
||||
if (
|
||||
options_.sourceCropWidth >= 2 &&
|
||||
options_.sourceCropHeight >= 2 &&
|
||||
sourceWidth_ >= 2 &&
|
||||
sourceHeight_ >= 2
|
||||
) {
|
||||
const LONG left = std::min<LONG>(
|
||||
std::max<LONG>(0, options_.sourceCropLeft),
|
||||
static_cast<LONG>(sourceWidth_) - 2);
|
||||
const LONG top = std::min<LONG>(
|
||||
std::max<LONG>(0, options_.sourceCropTop),
|
||||
static_cast<LONG>(sourceHeight_) - 2);
|
||||
const LONG width = (std::min<LONG>(
|
||||
options_.sourceCropWidth & ~1L,
|
||||
static_cast<LONG>(sourceWidth_) - left)) & ~1L;
|
||||
const LONG height = (std::min<LONG>(
|
||||
options_.sourceCropHeight & ~1L,
|
||||
static_cast<LONG>(sourceHeight_) - top)) & ~1L;
|
||||
return {
|
||||
left,
|
||||
top,
|
||||
left + std::max<LONG>(2, width),
|
||||
top + std::max<LONG>(2, height),
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
0,
|
||||
0,
|
||||
static_cast<LONG>(sourceWidth_),
|
||||
static_cast<LONG>(sourceHeight_),
|
||||
};
|
||||
}
|
||||
|
||||
RECT getContentRect() const {
|
||||
if (
|
||||
options_.contentLeft >= 0 &&
|
||||
@@ -2730,15 +2773,16 @@ float4 main(PSIn input) : SV_Target {
|
||||
const CursorSample cursor = cursorEnabled
|
||||
? getCursorSampleAt(static_cast<double>(outputTimestamp) / 10'000.0)
|
||||
: CursorSample{};
|
||||
const CursorAtlasEntry* cursorAtlasEntry = cursorEnabled
|
||||
const bool cursorVisible = cursorEnabled && cursor.visible;
|
||||
const CursorAtlasEntry* cursorAtlasEntry = cursorVisible
|
||||
? getCursorAtlasEntry(cursor.cursorTypeIndex)
|
||||
: nullptr;
|
||||
const bool cursorAtlasEnabled = cursorAtlasEntry != nullptr;
|
||||
const float cursorX = cursorEnabled
|
||||
const float cursorX = cursorVisible
|
||||
? static_cast<float>(contentRect.left) +
|
||||
cursor.cx * static_cast<float>(contentRect.right - contentRect.left)
|
||||
: 0.0f;
|
||||
const float cursorY = cursorEnabled
|
||||
const float cursorY = cursorVisible
|
||||
? static_cast<float>(contentRect.top) +
|
||||
cursor.cy * static_cast<float>(contentRect.bottom - contentRect.top)
|
||||
: 0.0f;
|
||||
@@ -2773,7 +2817,7 @@ float4 main(PSIn input) : SV_Target {
|
||||
webcamEnabled ? options_.webcamShadow : 0.0f,
|
||||
webcamEnabled ? 0.42f : 0.0f,
|
||||
options_.webcamMirror ? 1.0f : 0.0f,
|
||||
cursorEnabled ? 1.0f : 0.0f,
|
||||
cursorVisible ? 1.0f : 0.0f,
|
||||
cursorX,
|
||||
cursorY,
|
||||
options_.cursorSize,
|
||||
|
||||
@@ -505,6 +505,7 @@ function writeCursorSamples(cursorPayload, outputPath) {
|
||||
? Math.max(0, Math.min(8, Math.round(sample.cursorTypeIndex)))
|
||||
: 0),
|
||||
Number(bounceScale.toFixed(4)),
|
||||
sample.visible === false ? 0 : 1,
|
||||
].join("\t");
|
||||
})
|
||||
.filter(Boolean)
|
||||
@@ -951,6 +952,10 @@ const contentX = Math.round(getNonNegativeNumberArg("--content-x", 0));
|
||||
const contentY = Math.round(getNonNegativeNumberArg("--content-y", 0));
|
||||
const contentWidth = Math.round(getNumberArg("--content-width", 0));
|
||||
const contentHeight = Math.round(getNumberArg("--content-height", 0));
|
||||
const sourceCropX = Math.round(getNonNegativeNumberArg("--source-crop-x", 0));
|
||||
const sourceCropY = Math.round(getNonNegativeNumberArg("--source-crop-y", 0));
|
||||
const sourceCropWidth = Math.round(getNumberArg("--source-crop-width", 0));
|
||||
const sourceCropHeight = Math.round(getNumberArg("--source-crop-height", 0));
|
||||
const radius = Math.round(getNonNegativeNumberArg("--radius", 0));
|
||||
const backgroundY = Math.round(getNonNegativeNumberArg("--background-y", 16));
|
||||
const backgroundU = Math.round(getNonNegativeNumberArg("--background-u", 128));
|
||||
@@ -1322,6 +1327,18 @@ if (contentWidth > 0 && contentHeight > 0) {
|
||||
if (backgroundNv12Path) {
|
||||
encodeArgs.push("--background-nv12", backgroundNv12Path);
|
||||
}
|
||||
if (sourceCropWidth >= 2 && sourceCropHeight >= 2) {
|
||||
encodeArgs.push(
|
||||
"--source-crop-x",
|
||||
String(sourceCropX),
|
||||
"--source-crop-y",
|
||||
String(sourceCropY),
|
||||
"--source-crop-width",
|
||||
String(sourceCropWidth),
|
||||
"--source-crop-height",
|
||||
String(sourceCropHeight),
|
||||
);
|
||||
}
|
||||
if (!shouldBakeStaticShadow && shadowOffsetY > 0 && shadowIntensityPct > 0) {
|
||||
encodeArgs.push(
|
||||
"--shadow-offset-y",
|
||||
|
||||
@@ -54,6 +54,10 @@ struct Options {
|
||||
int contentY = 0;
|
||||
int contentWidth = 0;
|
||||
int contentHeight = 0;
|
||||
int sourceCropX = 0;
|
||||
int sourceCropY = 0;
|
||||
int sourceCropWidth = 0;
|
||||
int sourceCropHeight = 0;
|
||||
int radius = 0;
|
||||
int backgroundY = 16;
|
||||
int backgroundU = 128;
|
||||
@@ -201,6 +205,14 @@ Options parseOptions(int argc, char** argv) {
|
||||
options.contentWidth = parsePositiveInt(requireValue("--content-width"), "--content-width");
|
||||
} else if (arg == "--content-height") {
|
||||
options.contentHeight = parsePositiveInt(requireValue("--content-height"), "--content-height");
|
||||
} else if (arg == "--source-crop-x") {
|
||||
options.sourceCropX = parseNonNegativeInt(requireValue("--source-crop-x"), "--source-crop-x");
|
||||
} else if (arg == "--source-crop-y") {
|
||||
options.sourceCropY = parseNonNegativeInt(requireValue("--source-crop-y"), "--source-crop-y");
|
||||
} else if (arg == "--source-crop-width") {
|
||||
options.sourceCropWidth = parsePositiveInt(requireValue("--source-crop-width"), "--source-crop-width");
|
||||
} else if (arg == "--source-crop-height") {
|
||||
options.sourceCropHeight = parsePositiveInt(requireValue("--source-crop-height"), "--source-crop-height");
|
||||
} else if (arg == "--radius") {
|
||||
options.radius = parseNonNegativeInt(requireValue("--radius"), "--radius");
|
||||
} else if (arg == "--background-y") {
|
||||
@@ -623,6 +635,7 @@ struct CursorSample {
|
||||
double cy = 0.0;
|
||||
int typeIndex = 0;
|
||||
double bounceScale = 1.0;
|
||||
bool visible = true;
|
||||
};
|
||||
|
||||
struct CursorPosition {
|
||||
@@ -641,10 +654,22 @@ struct CursorTrack {
|
||||
return {};
|
||||
}
|
||||
if (timeMs <= samples.front().timeMs) {
|
||||
return {true, samples.front().cx, samples.front().cy, samples.front().typeIndex, samples.front().bounceScale};
|
||||
return {
|
||||
samples.front().visible,
|
||||
samples.front().cx,
|
||||
samples.front().cy,
|
||||
samples.front().typeIndex,
|
||||
samples.front().bounceScale,
|
||||
};
|
||||
}
|
||||
if (timeMs >= samples.back().timeMs) {
|
||||
return {true, samples.back().cx, samples.back().cy, samples.back().typeIndex, samples.back().bounceScale};
|
||||
return {
|
||||
samples.back().visible,
|
||||
samples.back().cx,
|
||||
samples.back().cy,
|
||||
samples.back().typeIndex,
|
||||
samples.back().bounceScale,
|
||||
};
|
||||
}
|
||||
|
||||
int low = 0;
|
||||
@@ -662,12 +687,12 @@ struct CursorTrack {
|
||||
const CursorSample& right = samples[high];
|
||||
const double span = right.timeMs - left.timeMs;
|
||||
if (span <= 0.0) {
|
||||
return {true, left.cx, left.cy, left.typeIndex, left.bounceScale};
|
||||
return {left.visible, left.cx, left.cy, left.typeIndex, left.bounceScale};
|
||||
}
|
||||
|
||||
const double t = (timeMs - left.timeMs) / span;
|
||||
return {
|
||||
true,
|
||||
left.visible && right.visible,
|
||||
left.cx + (right.cx - left.cx) * t,
|
||||
left.cy + (right.cy - left.cy) * t,
|
||||
t < 0.5 ? left.typeIndex : right.typeIndex,
|
||||
@@ -706,6 +731,10 @@ std::unique_ptr<CursorTrack> loadCursorTrack(const Options& options) {
|
||||
if (!(row >> sample.bounceScale)) {
|
||||
sample.bounceScale = 1.0;
|
||||
}
|
||||
int visible = 1;
|
||||
if (row >> visible) {
|
||||
sample.visible = visible != 0;
|
||||
}
|
||||
if (sample.cx < -1.0 || sample.cx > 2.0 || sample.cy < -1.0 || sample.cy > 2.0) {
|
||||
continue;
|
||||
}
|
||||
@@ -1046,7 +1075,11 @@ __global__ void overlayContentRectNv12Kernel(
|
||||
int contentX,
|
||||
int contentY,
|
||||
int contentWidth,
|
||||
int contentHeight) {
|
||||
int contentHeight,
|
||||
int sourceCropX,
|
||||
int sourceCropY,
|
||||
int sourceCropWidth,
|
||||
int sourceCropHeight) {
|
||||
const int localX = blockIdx.x * blockDim.x + threadIdx.x;
|
||||
const int localY = blockIdx.y * blockDim.y + threadIdx.y;
|
||||
if (localX >= contentWidth || localY >= contentHeight) {
|
||||
@@ -1059,15 +1092,19 @@ __global__ void overlayContentRectNv12Kernel(
|
||||
return;
|
||||
}
|
||||
|
||||
const int srcX = min(srcWidth - 1, (localX * srcWidth) / contentWidth);
|
||||
const int srcY = min(srcHeight - 1, (localY * srcHeight) / contentHeight);
|
||||
const int cropWidth = max(1, min(sourceCropWidth > 0 ? sourceCropWidth : srcWidth, srcWidth - sourceCropX));
|
||||
const int cropHeight = max(1, min(sourceCropHeight > 0 ? sourceCropHeight : srcHeight, srcHeight - sourceCropY));
|
||||
const int cropX = max(0, min(sourceCropX, srcWidth - 1));
|
||||
const int cropY = max(0, min(sourceCropY, srcHeight - 1));
|
||||
const int srcX = min(srcWidth - 1, cropX + (localX * cropWidth) / contentWidth);
|
||||
const int srcY = min(srcHeight - 1, cropY + (localY * cropHeight) / contentHeight);
|
||||
dst[y * dstPitch + x] = src[srcY * srcPitch + srcX];
|
||||
|
||||
if ((x % 2) == 0 && (y % 2) == 0) {
|
||||
const int localUvX = max(0, min(contentWidth - 1, localX + 1));
|
||||
const int localUvY = max(0, min(contentHeight - 1, localY + 1));
|
||||
const int srcUvX = min(srcWidth - 2, ((localUvX * srcWidth) / contentWidth) & ~1);
|
||||
const int srcUvY = min((srcHeight / 2) - 1, ((localUvY * srcHeight) / contentHeight) / 2);
|
||||
const int srcUvX = min(srcWidth - 2, (cropX + ((localUvX * cropWidth) / contentWidth)) & ~1);
|
||||
const int srcUvY = min((srcHeight / 2) - 1, (cropY + ((localUvY * cropHeight) / contentHeight)) / 2);
|
||||
const unsigned char* srcUv = src + srcPitch * srcSurfaceHeight + srcUvY * srcPitch + srcUvX;
|
||||
unsigned char* dstUv = dst + dstChromaOffset + (y / 2) * dstPitch + x;
|
||||
dstUv[0] = srcUv[0];
|
||||
@@ -1099,6 +1136,8 @@ __global__ void overlayContentTransformNv12Kernel(
|
||||
float invZoomScale,
|
||||
float srcScaleX,
|
||||
float srcScaleY,
|
||||
int sourceCropX,
|
||||
int sourceCropY,
|
||||
float zoomX,
|
||||
float zoomY) {
|
||||
const int localX = blockIdx.x * blockDim.x + threadIdx.x;
|
||||
@@ -1125,8 +1164,10 @@ __global__ void overlayContentTransformNv12Kernel(
|
||||
fminf(static_cast<float>(contentWidth - 1), fmaxf(0.0f, layoutXf - contentX));
|
||||
const float localContentY =
|
||||
fminf(static_cast<float>(contentHeight - 1), fmaxf(0.0f, layoutYf - contentY));
|
||||
const int sx = min(srcWidth - 1, __float2int_rd(localContentX * srcScaleX));
|
||||
const int sy = min(srcHeight - 1, __float2int_rd(localContentY * srcScaleY));
|
||||
const int cropX = max(0, min(sourceCropX, srcWidth - 1));
|
||||
const int cropY = max(0, min(sourceCropY, srcHeight - 1));
|
||||
const int sx = min(srcWidth - 1, cropX + __float2int_rd(localContentX * srcScaleX));
|
||||
const int sy = min(srcHeight - 1, cropY + __float2int_rd(localContentY * srcScaleY));
|
||||
dst[y * dstPitch + x] = src[sy * srcPitch + sx];
|
||||
|
||||
if ((x % 2) == 0 && (y % 2) == 0 && x + 1 < dstWidth && y + 1 < dstHeight) {
|
||||
@@ -1147,9 +1188,9 @@ __global__ void overlayContentTransformNv12Kernel(
|
||||
const float uvLocalContentY =
|
||||
fminf(static_cast<float>(contentHeight - 1), fmaxf(0.0f, uvLayoutYf - contentY));
|
||||
const int suvX =
|
||||
min(srcWidth - 2, __float2int_rd(uvLocalContentX * srcScaleX) & ~1);
|
||||
min(srcWidth - 2, (cropX + __float2int_rd(uvLocalContentX * srcScaleX)) & ~1);
|
||||
const int suvY =
|
||||
min((srcHeight / 2) - 1, __float2int_rd(uvLocalContentY * srcScaleY) / 2);
|
||||
min((srcHeight / 2) - 1, (cropY + __float2int_rd(uvLocalContentY * srcScaleY)) / 2);
|
||||
const unsigned char* srcUv = src + srcPitch * srcSurfaceHeight + suvY * srcPitch + suvX;
|
||||
unsigned char* dstUv = dst + dstChromaOffset + (y / 2) * dstPitch + x;
|
||||
dstUv[0] = srcUv[0];
|
||||
@@ -1438,6 +1479,10 @@ __global__ void compositeStaticNv12Kernel(
|
||||
int contentY,
|
||||
int contentWidth,
|
||||
int contentHeight,
|
||||
int sourceCropX,
|
||||
int sourceCropY,
|
||||
int sourceCropWidth,
|
||||
int sourceCropHeight,
|
||||
int radius,
|
||||
unsigned char backgroundY,
|
||||
unsigned char backgroundU,
|
||||
@@ -1482,13 +1527,17 @@ __global__ void compositeStaticNv12Kernel(
|
||||
const int layoutX = static_cast<int>(floorf(layoutXf));
|
||||
const int layoutY = static_cast<int>(floorf(layoutYf));
|
||||
|
||||
const int cropX = max(0, min(sourceCropX, srcWidth - 1));
|
||||
const int cropY = max(0, min(sourceCropY, srcHeight - 1));
|
||||
const int cropWidth = max(1, min(sourceCropWidth > 0 ? sourceCropWidth : srcWidth, srcWidth - cropX));
|
||||
const int cropHeight = max(1, min(sourceCropHeight > 0 ? sourceCropHeight : srcHeight, srcHeight - cropY));
|
||||
const bool inside = isInsideRoundedRect(layoutX, layoutY, contentX, contentY, contentWidth, contentHeight, radius);
|
||||
unsigned char outY = background ? background[y * dstWidth + x] : backgroundY;
|
||||
if (inside) {
|
||||
const float localX = fminf(static_cast<float>(contentWidth - 1), fmaxf(0.0f, layoutXf - contentX));
|
||||
const float localY = fminf(static_cast<float>(contentHeight - 1), fmaxf(0.0f, layoutYf - contentY));
|
||||
const int sx = min(srcWidth - 1, static_cast<int>((localX * srcWidth) / contentWidth));
|
||||
const int sy = min(srcHeight - 1, static_cast<int>((localY * srcHeight) / contentHeight));
|
||||
const int sx = min(srcWidth - 1, cropX + static_cast<int>((localX * cropWidth) / contentWidth));
|
||||
const int sy = min(srcHeight - 1, cropY + static_cast<int>((localY * cropHeight) / contentHeight));
|
||||
outY = src[sy * srcPitch + sx];
|
||||
} else {
|
||||
const bool shadowInside =
|
||||
@@ -1590,8 +1639,10 @@ __global__ void compositeStaticNv12Kernel(
|
||||
if (uvInside) {
|
||||
const float localX = fminf(static_cast<float>(contentWidth - 1), fmaxf(0.0f, uvLayoutXf - contentX));
|
||||
const float localY = fminf(static_cast<float>(contentHeight - 1), fmaxf(0.0f, uvLayoutYf - contentY));
|
||||
const int suvX = min(srcWidth - 2, (static_cast<int>((localX * srcWidth) / contentWidth)) & ~1);
|
||||
const int suvY = min((srcHeight / 2) - 1, static_cast<int>(localY * srcHeight / contentHeight) / 2);
|
||||
const int suvX =
|
||||
min(srcWidth - 2, (cropX + static_cast<int>((localX * cropWidth) / contentWidth)) & ~1);
|
||||
const int suvY =
|
||||
min((srcHeight / 2) - 1, (cropY + static_cast<int>(localY * cropHeight / contentHeight)) / 2);
|
||||
const unsigned char* srcUv = src + srcPitch * srcSurfaceHeight + suvY * srcPitch + suvX;
|
||||
dstUv[0] = srcUv[0];
|
||||
dstUv[1] = srcUv[1];
|
||||
@@ -2050,6 +2101,21 @@ public:
|
||||
const int cursorY = cursorPosition.visible
|
||||
? static_cast<int>(std::round(cursorHotspotOutputY)) - cursorHotspotY
|
||||
: 0;
|
||||
const bool hasSourceCrop =
|
||||
layoutOptions_.sourceCropWidth >= 2 &&
|
||||
layoutOptions_.sourceCropHeight >= 2;
|
||||
const int sourceCropX = hasSourceCrop
|
||||
? std::max(0, std::min(layoutOptions_.sourceCropX, srcWidth - 2)) & ~1
|
||||
: 0;
|
||||
const int sourceCropY = hasSourceCrop
|
||||
? std::max(0, std::min(layoutOptions_.sourceCropY, srcHeight - 2)) & ~1
|
||||
: 0;
|
||||
const int sourceCropWidth = hasSourceCrop
|
||||
? std::max(2, std::min(layoutOptions_.sourceCropWidth, srcWidth - sourceCropX)) & ~1
|
||||
: srcWidth;
|
||||
const int sourceCropHeight = hasSourceCrop
|
||||
? std::max(2, std::min(layoutOptions_.sourceCropHeight, srcHeight - sourceCropY)) & ~1
|
||||
: srcHeight;
|
||||
const bool zoomChangesLayout =
|
||||
zoomTrack_ &&
|
||||
(std::abs(zoomSample.scale - 1.0) > 0.001 ||
|
||||
@@ -2190,9 +2256,9 @@ public:
|
||||
const float safeZoomScale = std::max(0.01f, static_cast<float>(zoomSample.scale));
|
||||
const float invZoomScale = 1.0f / safeZoomScale;
|
||||
const float srcScaleX =
|
||||
static_cast<float>(srcWidth) / static_cast<float>(std::max(1, layoutOptions_.contentWidth));
|
||||
static_cast<float>(sourceCropWidth) / static_cast<float>(std::max(1, layoutOptions_.contentWidth));
|
||||
const float srcScaleY =
|
||||
static_cast<float>(srcHeight) / static_cast<float>(std::max(1, layoutOptions_.contentHeight));
|
||||
static_cast<float>(sourceCropHeight) / static_cast<float>(std::max(1, layoutOptions_.contentHeight));
|
||||
const int transformedContentX = zoomChangesLayout
|
||||
? static_cast<int>(std::floor(layoutOptions_.contentX * safeZoomScale + zoomSample.x))
|
||||
: layoutOptions_.contentX;
|
||||
@@ -2248,6 +2314,8 @@ public:
|
||||
invZoomScale,
|
||||
srcScaleX,
|
||||
srcScaleY,
|
||||
sourceCropX,
|
||||
sourceCropY,
|
||||
static_cast<float>(zoomSample.x),
|
||||
static_cast<float>(zoomSample.y));
|
||||
checkCuda(cudaGetLastError(), "overlayContentTransformNv12Kernel");
|
||||
@@ -2266,7 +2334,11 @@ public:
|
||||
layoutOptions_.contentX,
|
||||
layoutOptions_.contentY,
|
||||
layoutOptions_.contentWidth,
|
||||
layoutOptions_.contentHeight);
|
||||
layoutOptions_.contentHeight,
|
||||
sourceCropX,
|
||||
sourceCropY,
|
||||
sourceCropWidth,
|
||||
sourceCropHeight);
|
||||
checkCuda(cudaGetLastError(), "overlayContentRectNv12Kernel");
|
||||
|
||||
const int cornerRadius = std::min(
|
||||
@@ -2388,6 +2460,10 @@ public:
|
||||
layoutOptions_.contentY,
|
||||
layoutOptions_.contentWidth,
|
||||
layoutOptions_.contentHeight,
|
||||
sourceCropX,
|
||||
sourceCropY,
|
||||
sourceCropWidth,
|
||||
sourceCropHeight,
|
||||
layoutOptions_.radius,
|
||||
clampByte(layoutOptions_.backgroundY),
|
||||
clampByte(layoutOptions_.backgroundU),
|
||||
@@ -2518,6 +2594,8 @@ private:
|
||||
layoutOptions_.radius == 0 &&
|
||||
layoutOptions_.shadowIntensityPct == 0 &&
|
||||
backgroundDevice_ == nullptr &&
|
||||
layoutOptions_.sourceCropWidth <= 0 &&
|
||||
layoutOptions_.sourceCropHeight <= 0 &&
|
||||
!zoomChangesLayout;
|
||||
}
|
||||
|
||||
|
||||
@@ -213,6 +213,10 @@ contextBridge.exposeInMainWorld("electronAPI", {
|
||||
contentHeight: number;
|
||||
offsetX: number;
|
||||
offsetY: number;
|
||||
sourceCropX?: number;
|
||||
sourceCropY?: number;
|
||||
sourceCropWidth?: number;
|
||||
sourceCropHeight?: number;
|
||||
backgroundColor: string;
|
||||
backgroundImagePath?: string | null;
|
||||
backgroundBlurPx?: number;
|
||||
@@ -232,6 +236,7 @@ contextBridge.exposeInMainWorld("electronAPI", {
|
||||
cy: number;
|
||||
cursorTypeIndex?: number;
|
||||
bounceScale?: number;
|
||||
visible?: boolean;
|
||||
}>;
|
||||
cursorSize?: number;
|
||||
cursorAtlasPngDataUrl?: string | null;
|
||||
|
||||
@@ -58,6 +58,12 @@ function createExporter(overrides: Record<string, unknown> = {}) {
|
||||
videoInfo: DecodedVideoInfo,
|
||||
effectiveDurationSec: number,
|
||||
) => string[];
|
||||
getNativeStaticLayoutSourceCrop: (videoInfo: DecodedVideoInfo) => {
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
};
|
||||
resolveNativeStaticLayoutBackground: () => Promise<unknown>;
|
||||
createNativeStaticLayoutGradient: (
|
||||
ctx: CanvasRenderingContext2D,
|
||||
@@ -243,6 +249,29 @@ describe("ModernVideoExporter native static-layout eligibility", () => {
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it("allows non-default crop when native source crop coordinates are valid", () => {
|
||||
const exporter = createExporter({
|
||||
cropRegion: { x: 0.1, y: 0.1, width: 0.8, height: 0.8 },
|
||||
});
|
||||
|
||||
expect(
|
||||
exporter.getNativeStaticLayoutSkipReason(
|
||||
{
|
||||
audioMode: "copy-source",
|
||||
audioSourcePath: "recording.mp4",
|
||||
},
|
||||
videoInfo,
|
||||
60,
|
||||
),
|
||||
).toBeNull();
|
||||
expect(exporter.getNativeStaticLayoutSourceCrop(videoInfo)).toEqual({
|
||||
x: 192,
|
||||
y: 108,
|
||||
width: 1536,
|
||||
height: 864,
|
||||
});
|
||||
});
|
||||
|
||||
it("uses the default wallpaper for native static-layout when the project has no wallpaper", async () => {
|
||||
const exporter = createExporter({ wallpaper: "" });
|
||||
const electronAPI = window.electronAPI as typeof window.electronAPI & {
|
||||
@@ -288,7 +317,7 @@ describe("ModernVideoExporter native static-layout eligibility", () => {
|
||||
autoCaptions: [{ id: "caption-1", text: "hello", startMs: 0, endMs: 1_000 }],
|
||||
webcam: { enabled: true },
|
||||
frame: "macbook",
|
||||
cropRegion: { top: 0.1, bottom: 0, left: 0, right: 0 },
|
||||
cropRegion: { x: 0.1, y: 0, width: 0.9, height: 1 },
|
||||
});
|
||||
|
||||
expect(
|
||||
@@ -307,10 +336,19 @@ describe("ModernVideoExporter native static-layout eligibility", () => {
|
||||
"unsupported-caption-overlay",
|
||||
"unsupported-webcam-source",
|
||||
"unsupported-frame-overlay",
|
||||
"non-default-crop",
|
||||
]);
|
||||
});
|
||||
|
||||
it("reports invalid crop geometry instead of passing native export bad coordinates", () => {
|
||||
const exporter = createExporter({
|
||||
cropRegion: { x: 0, y: 0, width: 0, height: 1 },
|
||||
});
|
||||
|
||||
expect(exporter.getNativeStaticLayoutSkipReason({ audioMode: "none" }, videoInfo, 60)).toBe(
|
||||
"invalid-crop-region",
|
||||
);
|
||||
});
|
||||
|
||||
it("materializes uploaded data-url image backgrounds for native static-layout", async () => {
|
||||
const jpegBytes = new Uint8Array([0xff, 0xd8, 0xff, 0xd9]);
|
||||
const dataUrl = `data:image/jpeg;base64,${Buffer.from(jpegBytes).toString("base64")}`;
|
||||
|
||||
@@ -1237,6 +1237,33 @@ export class ModernVideoExporter {
|
||||
);
|
||||
}
|
||||
|
||||
private getNativeStaticLayoutSourceCrop(videoInfo: DecodedVideoInfo) {
|
||||
const crop = this.config.cropRegion;
|
||||
const sourceWidth = Math.max(2, Math.round(videoInfo.width));
|
||||
const sourceHeight = Math.max(2, Math.round(videoInfo.height));
|
||||
const cropX = Math.min(1, Math.max(0, crop.x));
|
||||
const cropY = Math.min(1, Math.max(0, crop.y));
|
||||
const cropRight = Math.min(1, Math.max(cropX, crop.x + crop.width));
|
||||
const cropBottom = Math.min(1, Math.max(cropY, crop.y + crop.height));
|
||||
|
||||
const left = Math.min(sourceWidth - 2, Math.max(0, Math.floor(cropX * sourceWidth))) & ~1;
|
||||
const top = Math.min(sourceHeight - 2, Math.max(0, Math.floor(cropY * sourceHeight))) & ~1;
|
||||
const right = Math.min(sourceWidth, Math.max(left + 2, Math.ceil(cropRight * sourceWidth)));
|
||||
const bottom = Math.min(
|
||||
sourceHeight,
|
||||
Math.max(top + 2, Math.ceil(cropBottom * sourceHeight)),
|
||||
);
|
||||
const width = Math.max(2, right - left) & ~1;
|
||||
const height = Math.max(2, bottom - top) & ~1;
|
||||
|
||||
return {
|
||||
x: left,
|
||||
y: top,
|
||||
width: Math.min(width, sourceWidth - left),
|
||||
height: Math.min(height, sourceHeight - top),
|
||||
};
|
||||
}
|
||||
|
||||
private canUseNativeStaticTailTrim(
|
||||
videoInfo: DecodedVideoInfo,
|
||||
effectiveDurationSec: number,
|
||||
@@ -1343,8 +1370,16 @@ export class ModernVideoExporter {
|
||||
reasons.push("unsupported-frame-overlay");
|
||||
}
|
||||
|
||||
if (!this.isDefaultCropRegion()) {
|
||||
reasons.push("non-default-crop");
|
||||
const crop = this.config.cropRegion;
|
||||
if (
|
||||
!Number.isFinite(crop.x) ||
|
||||
!Number.isFinite(crop.y) ||
|
||||
!Number.isFinite(crop.width) ||
|
||||
!Number.isFinite(crop.height) ||
|
||||
crop.width <= 0 ||
|
||||
crop.height <= 0
|
||||
) {
|
||||
reasons.push("invalid-crop-region");
|
||||
}
|
||||
|
||||
return reasons;
|
||||
@@ -1836,6 +1871,7 @@ export class ModernVideoExporter {
|
||||
durationSec: this.effectiveDurationSec || 0,
|
||||
clickBounce: this.config.cursorClickBounce,
|
||||
clickBounceDurationMs: this.config.cursorClickBounceDuration,
|
||||
sourceCrop: this.config.cropRegion,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -2079,8 +2115,8 @@ export class ModernVideoExporter {
|
||||
videoHeight: videoInfo.height,
|
||||
});
|
||||
const contentSize = roundNativeStaticLayoutContentSize({
|
||||
width: layout.fullVideoDisplayWidth,
|
||||
height: layout.fullVideoDisplayHeight,
|
||||
width: layout.croppedDisplayWidth,
|
||||
height: layout.croppedDisplayHeight,
|
||||
});
|
||||
const contentWidth = contentSize.width;
|
||||
const contentHeight = contentSize.height;
|
||||
@@ -2096,12 +2132,11 @@ export class ModernVideoExporter {
|
||||
return null;
|
||||
}
|
||||
|
||||
const offsetX = Math.round(
|
||||
layout.centerOffsetX + layout.croppedDisplayWidth / 2 - contentWidth / 2,
|
||||
);
|
||||
const offsetY = Math.round(
|
||||
layout.centerOffsetY + layout.croppedDisplayHeight / 2 - contentHeight / 2,
|
||||
);
|
||||
const offsetX = Math.round(layout.centerOffsetX);
|
||||
const offsetY = Math.round(layout.centerOffsetY);
|
||||
const sourceCrop = this.isDefaultCropRegion()
|
||||
? null
|
||||
: this.getNativeStaticLayoutSourceCrop(videoInfo);
|
||||
const previewWidth = this.config.previewWidth || 1920;
|
||||
const previewHeight = this.config.previewHeight || 1080;
|
||||
const canvasScaleFactor = Math.min(
|
||||
@@ -2274,6 +2309,10 @@ export class ModernVideoExporter {
|
||||
contentHeight,
|
||||
offsetX,
|
||||
offsetY,
|
||||
sourceCropX: sourceCrop?.x,
|
||||
sourceCropY: sourceCrop?.y,
|
||||
sourceCropWidth: sourceCrop?.width,
|
||||
sourceCropHeight: sourceCrop?.height,
|
||||
backgroundColor: background.backgroundColor,
|
||||
backgroundImagePath: background.backgroundImagePath ?? null,
|
||||
backgroundBlurPx: Math.max(0, (this.config.backgroundBlur ?? 0) * 3),
|
||||
|
||||
@@ -109,4 +109,40 @@ describe("buildNativeStaticLayoutCursorTelemetry", () => {
|
||||
expect(resampled?.map((sample) => sample.cursorTypeIndex)).toContain(1);
|
||||
expect(resampled?.some((sample) => (sample.bounceScale ?? 1) < 1)).toBe(true);
|
||||
});
|
||||
|
||||
it("projects cursor samples into the cropped viewport and marks out-of-crop samples hidden", () => {
|
||||
const resampled = buildNativeStaticLayoutCursorTelemetry(
|
||||
[
|
||||
{ timeMs: 0, cx: 0.25, cy: 0.5 },
|
||||
{ timeMs: 1000, cx: 0.75, cy: 0.5 },
|
||||
],
|
||||
{
|
||||
frameRate: 1,
|
||||
durationSec: 1,
|
||||
sourceCrop: { x: 0.25, y: 0.25, width: 0.5, height: 0.5 },
|
||||
},
|
||||
);
|
||||
|
||||
expect(resampled?.[0]).toMatchObject({ timeMs: 0, cx: 0, cy: 0.5, visible: true });
|
||||
expect(resampled?.[1]).toMatchObject({ timeMs: 1000, cx: 1, cy: 0.5, visible: true });
|
||||
|
||||
const hidden = buildNativeStaticLayoutCursorTelemetry(
|
||||
[
|
||||
{ timeMs: 0, cx: 0.1, cy: 0.5 },
|
||||
{ timeMs: 1000, cx: 0.1, cy: 0.5 },
|
||||
],
|
||||
{
|
||||
frameRate: 1,
|
||||
durationSec: 1,
|
||||
sourceCrop: { x: 0.25, y: 0.25, width: 0.5, height: 0.5 },
|
||||
},
|
||||
);
|
||||
|
||||
expect(hidden).toEqual([
|
||||
expect.objectContaining({
|
||||
timeMs: 1000,
|
||||
visible: false,
|
||||
}),
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { CursorTelemetryPoint } from "@/components/video-editor/types";
|
||||
import type { CropRegion, CursorTelemetryPoint } from "@/components/video-editor/types";
|
||||
import { projectCursorPositionToViewport } from "@/components/video-editor/videoPlayback/cursorViewport";
|
||||
|
||||
export type NativeStaticLayoutCursorTelemetrySample = {
|
||||
timeMs: number;
|
||||
@@ -8,6 +9,7 @@ export type NativeStaticLayoutCursorTelemetrySample = {
|
||||
interactionType?: string;
|
||||
cursorTypeIndex?: number;
|
||||
bounceScale?: number;
|
||||
visible?: boolean;
|
||||
};
|
||||
|
||||
export type NativeStaticLayoutCursorTelemetryOptions = {
|
||||
@@ -15,6 +17,7 @@ export type NativeStaticLayoutCursorTelemetryOptions = {
|
||||
durationSec: number;
|
||||
clickBounce?: number;
|
||||
clickBounceDurationMs?: number;
|
||||
sourceCrop?: CropRegion;
|
||||
};
|
||||
|
||||
const CURSOR_POSITION_EPSILON = 0.00001;
|
||||
@@ -186,12 +189,16 @@ function buildCursorRenderSample(
|
||||
): NativeStaticLayoutCursorTelemetrySample {
|
||||
const position = interpolateCursorSample(samples, timeMs);
|
||||
const cursorType = findLatestStableCursorType(samples, timeMs);
|
||||
const projectedPosition = projectCursorPositionToViewport(position, options.sourceCrop);
|
||||
|
||||
return {
|
||||
...position,
|
||||
cx: projectedPosition.cx,
|
||||
cy: projectedPosition.cy,
|
||||
cursorType,
|
||||
cursorTypeIndex: getCursorTypeIndex(cursorType),
|
||||
bounceScale: getCursorBounceScale(samples, timeMs, options),
|
||||
...(options.sourceCrop ? { visible: projectedPosition.visible } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -205,7 +212,9 @@ function pushCursorSample(
|
||||
Math.abs(previous.cx - sample.cx) <= CURSOR_POSITION_EPSILON &&
|
||||
Math.abs(previous.cy - sample.cy) <= CURSOR_POSITION_EPSILON &&
|
||||
previous.cursorTypeIndex === sample.cursorTypeIndex &&
|
||||
Math.abs((previous.bounceScale ?? 1) - (sample.bounceScale ?? 1)) <= CURSOR_BOUNCE_EPSILON
|
||||
Math.abs((previous.bounceScale ?? 1) - (sample.bounceScale ?? 1)) <=
|
||||
CURSOR_BOUNCE_EPSILON &&
|
||||
(previous.visible ?? true) === (sample.visible ?? true)
|
||||
) {
|
||||
previous.timeMs = sample.timeMs;
|
||||
return;
|
||||
|
||||
Reference in New Issue
Block a user