Files
Recordly/electron/ipc/export/native-video.test.ts
T

253 lines
7.4 KiB
TypeScript

import { describe, expect, it, vi } from "vitest";
vi.mock("electron", () => ({
app: {
getAppPath: () => process.cwd(),
getPath: () => process.env.TEMP ?? process.cwd(),
isPackaged: false,
},
}));
vi.mock("../ffmpeg/binary", () => ({
getFfmpegBinaryPath: () => "ffmpeg",
}));
import {
buildNativeVideoAudioMuxArgs,
normalizeNativeStaticLayoutBackground,
parseFfmpegDurationSeconds,
parseFfmpegFrameRate,
parseNativeVideoMetadataProbeOutput,
parseNvidiaCudaExportSummary,
parseWindowsGpuExportProgressLine,
parseWindowsGpuExportSummary,
} from "./native-video";
describe("normalizeNativeStaticLayoutBackground", () => {
it("falls back to a solid background when the configured image file is missing", async () => {
const normalized = await normalizeNativeStaticLayoutBackground({
inputPath: "input.mp4",
width: 1920,
height: 1080,
frameRate: 30,
bitrate: 8_000_000,
encodingMode: "quality",
durationSec: 10,
contentWidth: 1600,
contentHeight: 900,
offsetX: 160,
offsetY: 90,
backgroundColor: "#101010",
backgroundImagePath: "Z:\\recordly-missing-wallpaper\\midnight-8.jpg",
});
expect(normalized.backgroundImagePath).toBeNull();
expect(normalized.backgroundColor).toBe("#ffffff");
});
});
describe("buildNativeVideoAudioMuxArgs", () => {
it("stream-copies source audio and preserves the requested video duration", () => {
const args = buildNativeVideoAudioMuxArgs("video.mp4", "source.mp4", "out.mp4", {
audioMode: "copy-source",
outputDurationSec: 60,
});
expect(args).toEqual(
expect.arrayContaining([
"-map",
"0:v:0",
"-map",
"1:a:0",
"-c:v",
"copy",
"-c:a",
"copy",
"-t",
"60.000",
]),
);
expect(args).not.toContain("-shortest");
});
it("does not shorten copy-source muxes when no explicit duration is available", () => {
const args = buildNativeVideoAudioMuxArgs("video.mp4", "source.mp4", "out.mp4", {
audioMode: "copy-source",
});
expect(args).toEqual(expect.arrayContaining(["-c:a", "copy"]));
expect(args).not.toContain("-shortest");
});
it("keeps filtered audio on the AAC encode path", () => {
const args = buildNativeVideoAudioMuxArgs("video.mp4", "source.mp4", "out.mp4", {
audioMode: "trim-source",
trimSegments: [{ startMs: 0, endMs: 1_000 }],
outputDurationSec: 1,
});
expect(args).toEqual(expect.arrayContaining(["-filter_complex"]));
expect(args).toEqual(expect.arrayContaining(["-c:a", "aac", "-b:a", "192k"]));
});
});
describe("parseWindowsGpuExportSummary", () => {
it("returns the last JSON summary from helper stdout", () => {
const summary = parseWindowsGpuExportSummary(
[
"initializing",
'{"success":true,"frames":30,"totalMs":1000,"realtimeMultiplier":2}',
"cleanup",
'{"success":true,"frames":60,"surfacePoolSize":12,"readMs":12.5,"videoProcessMs":30,"writeSampleMs":40,"finalizeMs":5,"realtimeMultiplier":4}',
].join("\n"),
);
expect(summary).toEqual({
success: true,
frames: 60,
surfacePoolSize: 12,
readMs: 12.5,
videoProcessMs: 30,
writeSampleMs: 40,
finalizeMs: 5,
realtimeMultiplier: 4,
});
});
it("returns null when helper stdout has no valid JSON summary", () => {
expect(parseWindowsGpuExportSummary("initializing\nnot-json")).toBeNull();
expect(parseWindowsGpuExportSummary("")).toBeNull();
});
});
describe("parseNvidiaCudaExportSummary", () => {
it("parses the pretty JSON summary emitted by the CUDA lab wrapper", () => {
const summary = parseNvidiaCudaExportSummary(
[
"preflight",
JSON.stringify(
{
success: true,
fps: 30,
durationSec: 10,
targetFrames: 300,
timingsMs: { nativeEncode: 920, mux: 45, endToEnd: 1400 },
nativeSummary: { success: true, frames: 300, fps: 326.1 },
},
null,
2,
),
].join("\n"),
);
expect(summary?.success).toBe(true);
expect(summary?.targetFrames).toBe(300);
expect(summary?.timingsMs?.nativeEncode).toBe(920);
expect(summary?.nativeSummary?.fps).toBe(326.1);
});
it("returns null when the wrapper output has no JSON object", () => {
expect(parseNvidiaCudaExportSummary("native probe failed before summary")).toBeNull();
});
});
describe("parseWindowsGpuExportProgressLine", () => {
it("parses bounded helper progress lines", () => {
expect(
parseWindowsGpuExportProgressLine(
'PROGRESS {"currentFrame":30,"totalFrames":60,"percentage":50,"averageFps":240.5,"instantFps":180.25,"intervalMs":166.4,"intervalFrames":30,"intervalEncodeMs":120.2,"intervalPipelineWaitMs":46.2,"intervalMonolithicCompositeFrames":0}',
),
).toEqual({
currentFrame: 30,
totalFrames: 60,
percentage: 50,
averageFps: 240.5,
instantFps: 180.25,
intervalMs: 166.4,
intervalFrames: 30,
intervalEncodeMs: 120.2,
intervalPipelineWaitMs: 46.2,
intervalMonolithicCompositeFrames: 0,
});
});
it("ignores non-progress or malformed helper stderr", () => {
expect(parseWindowsGpuExportProgressLine("warning: encoder selected")).toBeNull();
expect(parseWindowsGpuExportProgressLine("PROGRESS not-json")).toBeNull();
expect(
parseWindowsGpuExportProgressLine(
'PROGRESS {"currentFrame":1,"totalFrames":0,"percentage":999}',
),
).toBeNull();
});
});
describe("parseNativeVideoMetadataProbeOutput", () => {
it("parses FFmpeg input metadata with video and audio streams", () => {
const metadata = parseNativeVideoMetadataProbeOutput(`
Input #0, mov,mp4,m4a,3gp,3g2,mj2, from 'recording.mp4':
Metadata:
major_brand : isom
Duration: 00:06:04.25, start: 0.000000, bitrate: 3938 kb/s
Stream #0:0[0x1](und): Video: h264 (High) (avc1 / 0x31637661), yuv420p(progressive), 1920x1080, 3720 kb/s, 46.05 fps, 60 tbr, 90k tbn (default)
Stream #0:1[0x2](und): Audio: aac (LC) (mp4a / 0x6134706D), 48000 Hz, stereo, fltp, 192 kb/s (default)
`);
expect(metadata).toEqual({
width: 1920,
height: 1080,
duration: 364.25,
mediaStartTime: 0,
streamStartTime: 0,
streamDuration: 364.25,
frameRate: 46.05,
codec: "h264 (High) (avc1 / 0x31637661)",
hasAudio: true,
audioCodec: "aac (LC) (mp4a / 0x6134706D)",
audioSampleRate: 48000,
});
});
it("parses video-only metadata and falls back to tbr when fps is absent", () => {
const metadata = parseNativeVideoMetadataProbeOutput(`
Input #0, matroska,webm, from 'recording.webm':
Duration: 00:00:10.50, start: 0.023000, bitrate: 1000 kb/s
Stream #0:0: Video: vp9, yuv420p, 1280x720, 30 tbr, 1k tbn
`);
expect(metadata).toEqual({
width: 1280,
height: 720,
duration: 10.5,
mediaStartTime: 0.023,
streamStartTime: 0.023,
streamDuration: 10.5,
frameRate: 30,
codec: "vp9",
hasAudio: false,
audioCodec: undefined,
audioSampleRate: undefined,
});
});
it("rejects output without usable video metadata", () => {
expect(parseNativeVideoMetadataProbeOutput("Duration: N/A")).toBeNull();
expect(parseNativeVideoMetadataProbeOutput("not a media file")).toBeNull();
});
});
describe("parseFfmpegDurationSeconds", () => {
it("parses HH:MM:SS timestamps", () => {
expect(parseFfmpegDurationSeconds("01:02:03.5")).toBe(3723.5);
expect(parseFfmpegDurationSeconds("bad")).toBeNull();
});
});
describe("parseFfmpegFrameRate", () => {
it("prefers fps and falls back to tbr", () => {
expect(parseFfmpegFrameRate("Video: h264, 1920x1080, 59.94 fps, 60 tbr")).toBe(59.94);
expect(parseFfmpegFrameRate("Video: h264, 1920x1080, 30 tbr")).toBe(30);
expect(parseFfmpegFrameRate("Video: h264")).toBeNull();
});
});