Files
webadderall eaf87be95b Recordly 2.0 local review stack 2/6
Dependency-ordered review preparation. Not pushed or remotely reviewed.
2026-11-06 05:24:21 +11:00

215 lines
6.5 KiB
TypeScript

import {
getShadowFilterPadding,
getVideoShadowStrength,
VIDEO_SHADOW_LAYER_PROFILES,
VIDEO_SHADOW_REFERENCE_WIDTH,
} from "../../../src/lib/exporter/shadowProfile";
import { formatFfmpegSeconds } from "./audioFilters";
import {
FFMPEG_AUTO_TO_FULL_RANGE_FILTER,
FFMPEG_BT709_VIDEO_COLOR_ARGS,
FFMPEG_FULL_TO_VIDEO_RANGE_FILTER,
} from "./constants";
import {
clampUnitInterval,
formatFfmpegColor,
formatFfmpegNumber,
getBitrateArgs,
getNvencStaticLayoutModeArgs,
pushFfmpegTimeSliceArgs,
} from "./encoding";
import { type NativeStaticLayoutChunk, type NativeStaticLayoutExportArgsConfig } from "./types";
export function buildNativeStaticBackgroundRenderArgs(
config: NativeStaticLayoutExportArgsConfig,
): string[] {
const backgroundColor = formatFfmpegColor(config.backgroundColor);
const backgroundBlurPx = Math.max(0, config.backgroundBlurPx ?? 0);
const args = ["-y", "-hide_banner", "-loglevel", "error"];
if (config.backgroundImagePath) {
args.push("-i", config.backgroundImagePath);
} else {
args.push(
"-f",
"lavfi",
"-i",
`color=c=${backgroundColor}:s=${config.width}x${config.height}:d=1`,
);
}
const shadowStrength = getVideoShadowStrength(config.shadowIntensity ?? 0);
const shadowScale = Math.max(1, config.width) / VIDEO_SHADOW_REFERENCE_WIDTH;
const shadowLayers =
shadowStrength > 0 && config.maskPath
? VIDEO_SHADOW_LAYER_PROFILES.map((layer) => ({
offsetY: layer.offsetScale * shadowStrength * shadowScale,
alpha: clampUnitInterval(layer.alphaScale * shadowStrength),
blur: Math.max(0, layer.blurScale * shadowStrength * shadowScale),
})).filter((layer) => layer.alpha > 0)
: [];
if (shadowLayers.length > 0 && config.maskPath) {
args.push("-i", config.maskPath);
}
const filterParts = [
config.backgroundImagePath
? `[0:v]scale=w=${config.width}:h=${config.height}:force_original_aspect_ratio=increase,crop=w=${config.width}:h=${config.height},setsar=1,format=rgba[bg0]`
: "[0:v]format=rgba[bg0]",
];
let currentBackgroundLabel = "bg0";
if (backgroundBlurPx > 0 && config.backgroundImagePath) {
filterParts.push(
`[${currentBackgroundLabel}]gblur=sigma=${formatFfmpegNumber(
Math.min(96, backgroundBlurPx),
)}:steps=2[bg_blur]`,
);
currentBackgroundLabel = "bg_blur";
}
if (shadowLayers.length > 0) {
filterParts.push(
`[1:v]format=gray,split=${shadowLayers.length}${shadowLayers
.map((_, index) => `[shadow_mask_source_${index}]`)
.join("")}`,
);
}
shadowLayers.forEach((layer, index) => {
const padding = getShadowFilterPadding(layer.blur, layer.offsetY);
const paddedWidth = config.contentWidth + padding * 2;
const paddedHeight = config.contentHeight + padding * 2;
const positionedMaskLabel = `shadow_mask_positioned_${index}`;
const shadowMaskLabel = `shadow_mask_${index}`;
const shadowColorLabel = `shadow_color_${index}`;
const shadowLabel = `shadow_${index}`;
const nextBackgroundLabel = `bg${index + 1}`;
const blurFilter =
layer.blur > 0 ? `,gblur=sigma=${formatFfmpegNumber(layer.blur)}:steps=2` : "";
filterParts.push(
`[shadow_mask_source_${index}]lut=y=val*${formatFfmpegNumber(
layer.alpha,
)},pad=w=${paddedWidth}:h=${paddedHeight}:x=${padding}:y=${Math.round(
padding + layer.offsetY,
)}:color=black${blurFilter}[${positionedMaskLabel}]`,
`[${positionedMaskLabel}]format=gray[${shadowMaskLabel}]`,
`color=c=black:s=${paddedWidth}x${paddedHeight}:d=1,format=rgba[${shadowColorLabel}]`,
`[${shadowColorLabel}][${shadowMaskLabel}]alphamerge[${shadowLabel}]`,
`[${currentBackgroundLabel}][${shadowLabel}]overlay=x=${
config.offsetX - padding
}:y=${config.offsetY - padding}:format=auto[${nextBackgroundLabel}]`,
);
currentBackgroundLabel = nextBackgroundLabel;
});
filterParts.push(`[${currentBackgroundLabel}]format=rgba[out]`);
args.push(
"-filter_complex",
filterParts.join(";"),
"-map",
"[out]",
"-frames:v",
"1",
config.outputPath,
);
return args;
}
export function buildNativePrecompositedStaticLayoutArgs(
config: NativeStaticLayoutExportArgsConfig,
): string[] {
if (!config.staticBackgroundPath) {
throw new Error("Native precomposited static layout requires a static background path");
}
const durationSec = formatFfmpegSeconds(Math.max(0.001, config.durationSec ?? 1) * 1000);
const useMask = Boolean(config.maskPath && (config.borderRadius ?? 0) > 0.5);
const args = ["-y", "-hide_banner", "-loglevel", "error"];
pushFfmpegTimeSliceArgs(args, config.startSec, config.durationSec);
args.push(
"-hwaccel",
"cuda",
"-hwaccel_output_format",
"cuda",
"-i",
config.inputPath,
"-loop",
"1",
"-framerate",
String(config.frameRate),
"-t",
durationSec,
"-i",
config.staticBackgroundPath,
);
if (useMask && config.maskPath) {
args.push(
"-loop",
"1",
"-framerate",
String(config.frameRate),
"-t",
durationSec,
"-i",
config.maskPath,
);
}
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,${FFMPEG_FULL_TO_VIDEO_RANGE_FILTER},format=yuv420p[out]`;
args.push(
"-filter_complex",
filterComplex,
"-map",
"[out]",
"-an",
"-r",
String(config.frameRate),
"-c:v",
"h264_nvenc",
...getNvencStaticLayoutModeArgs(config.encodingMode),
...getBitrateArgs(config.bitrate),
"-pix_fmt",
"yuv420p",
...FFMPEG_BT709_VIDEO_COLOR_ARGS,
"-movflags",
"+faststart",
config.outputPath,
);
return args;
}
export function buildNativeStaticLayoutChunks(
durationSec: number,
chunkDurationSec: number,
): NativeStaticLayoutChunk[] {
if (!Number.isFinite(durationSec) || durationSec <= 0) {
return [];
}
const safeChunkDuration = Math.max(1, Math.min(300, Math.floor(chunkDurationSec)));
const chunks: NativeStaticLayoutChunk[] = [];
for (
let startSec = 0, index = 0;
startSec < durationSec;
startSec += safeChunkDuration, index++
) {
chunks.push({
index,
startSec,
durationSec: Math.min(safeChunkDuration, durationSec - startSec),
});
}
return chunks;
}
export function isNativeCudaOutOfMemory(stderr: string): boolean {
return /CUDA_ERROR_OUT_OF_MEMORY|cuMemAlloc.+out of memory/i.test(stderr);
}