mirror of
https://github.com/webadderallorg/Recordly.git
synced 2026-11-09 15:33:31 +00:00
215 lines
6.5 KiB
TypeScript
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);
|
|
}
|