diff --git a/electron/ipc/handlers/__tests__/demo.handlers.test.ts b/electron/ipc/handlers/__tests__/demo.handlers.test.ts index 31b98f7c9..af81cf5ca 100644 --- a/electron/ipc/handlers/__tests__/demo.handlers.test.ts +++ b/electron/ipc/handlers/__tests__/demo.handlers.test.ts @@ -14,21 +14,20 @@ vi.mock("crypto", () => ({ randomBytes: vi.fn(() => ({ toString: () => "test-request-id" })), })); -const fsMock = vi.hoisted(() => ({ - mkdtemp: vi.fn().mockResolvedValue("/tmp/canopy-capture-abc123"), - writeFile: vi.fn().mockResolvedValue(undefined), -})); - -vi.mock("fs/promises", () => fsMock); - vi.mock("fs", () => ({ readdirSync: vi.fn(() => ["frame-000001.png", "frame-000002.png", "frame-000003.png"]), mkdirSync: vi.fn(), })); +class MockStdin extends EventEmitter { + write = vi.fn(() => true); + end = vi.fn(); +} + let mockProc: EventEmitter & { stdout: EventEmitter; stderr: EventEmitter; + stdin: MockStdin; kill: ReturnType; }; @@ -36,10 +35,12 @@ function createMockProc() { const proc = new EventEmitter() as EventEmitter & { stdout: EventEmitter; stderr: EventEmitter; + stdin: MockStdin; kill: ReturnType; }; proc.stdout = new EventEmitter(); proc.stderr = new EventEmitter(); + proc.stdin = new MockStdin(); proc.kill = vi.fn(); return proc; } @@ -52,6 +53,21 @@ import { registerDemoHandlers } from "../demo.js"; import type { HandlerDependencies } from "../../types.js"; import type { BrowserWindow } from "electron"; +const FRAME_W = 1920; +const FRAME_H = 1080; +// Use a small buffer for tests — real BGRA would be FRAME_W * FRAME_H * 4 but that causes OOM in test workers +const BGRA_BUFFER = Buffer.alloc(16); + +function makeMockImage() { + const img = { + toPNG: () => Buffer.from([0x89, 0x50, 0x4e, 0x47]), + getSize: () => ({ width: FRAME_W, height: FRAME_H }), + toBitmap: () => BGRA_BUFFER, + resize: vi.fn().mockReturnThis(), + }; + return img; +} + function makeDeps(isDemoMode: boolean): HandlerDependencies { return { mainWindow: { @@ -59,10 +75,7 @@ function makeDeps(isDemoMode: boolean): HandlerDependencies { webContents: { isDestroyed: () => false, send: vi.fn(), - capturePage: vi.fn().mockResolvedValue({ - toPNG: () => Buffer.from([0x89, 0x50, 0x4e, 0x47]), - getSize: () => ({ width: 1920, height: 1080 }), - }), + capturePage: vi.fn().mockResolvedValue(makeMockImage()), }, } as unknown as BrowserWindow, isDemoMode, @@ -307,7 +320,7 @@ describe("registerDemoHandlers", () => { expect(event.sender.send).not.toHaveBeenCalled(); }); - it("spawns ffmpeg with the resolved binary path", async () => { + it("uses yuv444p and high444 profile for youtube presets", async () => { const { spawn: spawnMock } = await import("child_process"); const handler = getEncodeHandler(); const event = makeEvent(); @@ -321,11 +334,34 @@ describe("registerDemoHandlers", () => { mockProc.emit("close", 0); await promise; - expect(spawnMock).toHaveBeenCalledWith( - expect.any(String), - expect.arrayContaining(["-i", "/tmp/frames/frame-%06d.png", "-c:v", "libx264"]), - expect.any(Object) - ); + const args = (spawnMock as ReturnType).mock.calls[0][1] as string[]; + expect(args).toContain("yuv444p"); + expect(args).toContain("high444"); + expect(args).not.toContain("yuv420p"); + }); + + it("uses yuv444p for web-webm preset with row-mt", async () => { + const { spawn: spawnMock } = await import("child_process"); + // Need a fresh mockProc since the encode handler uses the current one + mockProc = createMockProc(); + const handler = getEncodeHandler(); + const event = makeEvent(); + + const promise = handler(event, { + framesDir: "/tmp/frames", + outputPath: "/tmp/out.webm", + preset: "web-webm", + }); + + mockProc.emit("close", 0); + await promise; + + const lastCall = (spawnMock as ReturnType).mock.calls.at(-1)!; + const args = lastCall[1] as string[]; + expect(args).toContain("yuv444p"); + expect(args).toContain("1"); + const rowMtIdx = args.indexOf("-row-mt"); + expect(rowMtIdx).toBeGreaterThan(-1); }); }); @@ -336,7 +372,6 @@ describe("registerDemoHandlers", () => { const [, handler] = ipcMainMock.handle.mock.calls.find(([ch]: unknown[]) => ch === "demo:move-to") ?? []; - // Simulate renderer responding to the command with matching requestId ipcMainMock.on.mockImplementation((channel: string, listener: (...args: unknown[]) => void) => { if (channel === "demo:command-done") { setTimeout(() => { @@ -420,30 +455,84 @@ describe("frame capture pipeline", () => { vi.useRealTimers(); }); - it("startCapture creates a temp directory and returns outputDir", async () => { + const defaultPayload = { + fps: 30, + outputPath: "/tmp/capture/out.mp4", + preset: "youtube-1080p" as const, + }; + + it("startCapture spawns ffmpeg with rawvideo stdin args and returns outputPath", async () => { + const { spawn: spawnMock } = await import("child_process"); const deps = makeDeps(true); const cleanup = registerDemoHandlers(deps); const handler = getHandler("demo:start-capture"); - const result = (await handler({}, { fps: 30 })) as { outputDir: string }; + const result = (await handler({}, defaultPayload)) as { outputPath: string }; + + expect(result.outputPath).toBe("/tmp/capture/out.mp4"); + + const args = (spawnMock as ReturnType).mock.calls[0][1] as string[]; + expect(args).toContain("-f"); + expect(args).toContain("rawvideo"); + expect(args).toContain("-pix_fmt"); + expect(args[args.indexOf("-pix_fmt") + 1]).toBe("bgra"); + expect(args).toContain("-video_size"); + expect(args[args.indexOf("-video_size") + 1]).toBe("1920x1080"); + expect(args).toContain("-framerate"); + expect(args[args.indexOf("-framerate") + 1]).toBe("30"); + expect(args).toContain("-i"); + expect(args[args.indexOf("-i") + 1]).toBe("pipe:0"); + expect(args).toContain("-fps_mode"); + expect(args).toContain("cfr"); - expect(fsMock.mkdtemp).toHaveBeenCalledWith(expect.stringContaining("canopy-capture-")); - expect(result.outputDir).toBe("/tmp/canopy-capture-abc123"); + cleanup(); + }); + + it("creates output directory before spawning ffmpeg", async () => { + const fsMod = await import("fs"); + const deps = makeDeps(true); + const cleanup = registerDemoHandlers(deps); + + const handler = getHandler("demo:start-capture"); + await handler({}, defaultPayload); + + expect(fsMod.mkdirSync).toHaveBeenCalledWith("/tmp/capture", { recursive: true }); cleanup(); }); - it("startCapture uses explicit outputDir when provided", async () => { + it("captures first frame and calls resize for HiDPI normalization", async () => { const deps = makeDeps(true); const cleanup = registerDemoHandlers(deps); const handler = getHandler("demo:start-capture"); - const result = (await handler({}, { fps: 30, outputDir: "/custom/dir" })) as { - outputDir: string; - }; + await handler({}, defaultPayload); - expect(fsMock.mkdtemp).not.toHaveBeenCalled(); - expect(result.outputDir).toBe("/custom/dir"); + const capturePage = deps.mainWindow!.webContents.capturePage as ReturnType; + expect(capturePage).toHaveBeenCalled(); + + // The mock image's resize should have been called with logical dims + const mockImage = await capturePage.mock.results[0].value; + expect(mockImage.resize).toHaveBeenCalledWith({ + width: 1920, + height: 1080, + quality: "best", + }); + + cleanup(); + }); + + it("ticker writes BGRA buffer to ffmpeg stdin", async () => { + const deps = makeDeps(true); + const cleanup = registerDemoHandlers(deps); + + const handler = getHandler("demo:start-capture"); + await handler({}, defaultPayload); + + // Advance timer to trigger the ticker + await vi.advanceTimersByTimeAsync(34); + + expect(mockProc.stdin.write).toHaveBeenCalledWith(BGRA_BUFFER); cleanup(); }); @@ -456,61 +545,83 @@ describe("frame capture pipeline", () => { const status = (await handler({})) as { active: boolean; frameCount: number; - outputDir: string | null; + outputPath: string | null; }; expect(status.active).toBe(false); expect(status.frameCount).toBe(0); - expect(status.outputDir).toBeNull(); + expect(status.outputPath).toBeNull(); cleanup(); }); - it("captures a frame with zero-padded filename", async () => { + it("getCaptureStatus reports active while capturing", async () => { const deps = makeDeps(true); const cleanup = registerDemoHandlers(deps); const startHandler = getHandler("demo:start-capture"); - await startHandler({}, { fps: 30 }); + await startHandler({}, defaultPayload); - // Advance timer to trigger the first capture + // Write one frame via ticker await vi.advanceTimersByTimeAsync(34); - expect(fsMock.writeFile).toHaveBeenCalledWith( - "/tmp/canopy-capture-abc123/frame-000001.png", - expect.any(Buffer) - ); + const statusHandler = getHandler("demo:get-capture-status"); + const status = (await statusHandler({})) as { + active: boolean; + frameCount: number; + outputPath: string | null; + }; + + expect(status.active).toBe(true); + expect(status.frameCount).toBe(1); + expect(status.outputPath).toBe("/tmp/capture/out.mp4"); cleanup(); }); - it("stopCapture returns outputDir and frameCount", async () => { + it("stopCapture calls stdin.end and resolves with outputPath and frameCount", async () => { const deps = makeDeps(true); const cleanup = registerDemoHandlers(deps); const startHandler = getHandler("demo:start-capture"); - await startHandler({}, { fps: 30 }); + await startHandler({}, defaultPayload); - // Capture one frame + // Write one frame await vi.advanceTimersByTimeAsync(34); const stopHandler = getHandler("demo:stop-capture"); - const result = (await stopHandler({})) as { outputDir: string; frameCount: number }; + const stopPromise = stopHandler({}) as Promise<{ outputPath: string; frameCount: number }>; - expect(result.outputDir).toBe("/tmp/canopy-capture-abc123"); + expect(mockProc.stdin.end).toHaveBeenCalled(); + + // Simulate ffmpeg closing successfully + mockProc.emit("close", 0); + + const result = await stopPromise; + expect(result.outputPath).toBe("/tmp/capture/out.mp4"); expect(result.frameCount).toBe(1); cleanup(); }); + it("stopCapture rejects when no capture in progress", async () => { + const deps = makeDeps(true); + const cleanup = registerDemoHandlers(deps); + + const stopHandler = getHandler("demo:stop-capture"); + await expect(stopHandler({})).rejects.toThrow("No capture in progress"); + + cleanup(); + }); + it("rejects startCapture when already active", async () => { const deps = makeDeps(true); const cleanup = registerDemoHandlers(deps); const handler = getHandler("demo:start-capture"); - await handler({}, { fps: 30 }); + await handler({}, defaultPayload); - await expect(handler({}, { fps: 30 })).rejects.toThrow("Capture already in progress"); + await expect(handler({}, defaultPayload)).rejects.toThrow("Capture already in progress"); cleanup(); }); @@ -520,120 +631,178 @@ describe("frame capture pipeline", () => { const cleanup = registerDemoHandlers(deps); const startHandler = getHandler("demo:start-capture"); - await startHandler({}, { fps: 30, maxFrames: 2 }); + await startHandler({}, { ...defaultPayload, maxFrames: 2 }); - // Capture first frame + // Write first frame await vi.advanceTimersByTimeAsync(34); - // Capture second frame (should auto-stop) + expect(mockProc.stdin.write).toHaveBeenCalledTimes(1); + + // Write second frame — should trigger auto-stop await vi.advanceTimersByTimeAsync(34); + expect(mockProc.stdin.write).toHaveBeenCalledTimes(2); + expect(mockProc.stdin.end).toHaveBeenCalled(); - const statusHandler = getHandler("demo:get-capture-status"); - const status = (await statusHandler({})) as { active: boolean; frameCount: number }; + cleanup(); + }); - expect(status.active).toBe(false); - expect(status.frameCount).toBe(2); + it("handles backpressure by pausing ticker and resuming on drain", async () => { + const deps = makeDeps(true); + const cleanup = registerDemoHandlers(deps); + + // First write returns false (backpressure) + mockProc.stdin.write.mockReturnValueOnce(false); + + const startHandler = getHandler("demo:start-capture"); + await startHandler({}, defaultPayload); + + // First tick — write returns false + await vi.advanceTimersByTimeAsync(34); + expect(mockProc.stdin.write).toHaveBeenCalledTimes(1); + + // More ticks should NOT produce writes (ticker paused) + await vi.advanceTimersByTimeAsync(34); + expect(mockProc.stdin.write).toHaveBeenCalledTimes(1); + + // Emit drain — should resume ticker + mockProc.stdin.emit("drain"); + + // Next tick after drain should write again + await vi.advanceTimersByTimeAsync(34); + expect(mockProc.stdin.write).toHaveBeenCalledTimes(2); cleanup(); }); - it("cleanup stops an active capture", async () => { + it("cleanup stops capture and kills ffmpeg process", async () => { const deps = makeDeps(true); const cleanup = registerDemoHandlers(deps); const startHandler = getHandler("demo:start-capture"); - await startHandler({}, { fps: 30 }); + await startHandler({}, defaultPayload); cleanup(); - // After cleanup, no more frames should be written - fsMock.writeFile.mockClear(); - await vi.advanceTimersByTimeAsync(100); - expect(fsMock.writeFile).not.toHaveBeenCalled(); + expect(mockProc.stdin.end).toHaveBeenCalled(); + expect(mockProc.kill).toHaveBeenCalledWith("SIGKILL"); }); - it("getCaptureStatus reports active while capturing", async () => { + it("rejects finalize promise when ffmpeg exits with non-zero code", async () => { const deps = makeDeps(true); const cleanup = registerDemoHandlers(deps); const startHandler = getHandler("demo:start-capture"); - await startHandler({}, { fps: 30 }); + await startHandler({}, defaultPayload); - await vi.advanceTimersByTimeAsync(34); + const stopHandler = getHandler("demo:stop-capture"); + const stopPromise = stopHandler({}) as Promise; - const statusHandler = getHandler("demo:get-capture-status"); - const status = (await statusHandler({})) as { - active: boolean; - frameCount: number; - outputDir: string | null; - }; + mockProc.emit("close", 1); - expect(status.active).toBe(true); - expect(status.frameCount).toBe(1); - expect(status.outputDir).toBe("/tmp/canopy-capture-abc123"); + await expect(stopPromise).rejects.toThrow("ffmpeg exited with code 1"); cleanup(); }); - it("continues capturing after capturePage error", async () => { + it("rejects finalize promise on ffmpeg spawn error", async () => { const deps = makeDeps(true); - const capturePage = deps.mainWindow!.webContents.capturePage as ReturnType; - capturePage.mockRejectedValueOnce(new Error("GPU error")).mockResolvedValue({ - toPNG: () => Buffer.from([0x89, 0x50, 0x4e, 0x47]), - getSize: () => ({ width: 1920, height: 1080 }), - }); - const cleanup = registerDemoHandlers(deps); const startHandler = getHandler("demo:start-capture"); - await startHandler({}, { fps: 30 }); + await startHandler({}, defaultPayload); - // First tick — capturePage throws - await vi.advanceTimersByTimeAsync(34); - expect(fsMock.writeFile).not.toHaveBeenCalled(); + // Stop first to get the finalize promise, then emit error + const stopHandler = getHandler("demo:stop-capture"); + const stopPromise = stopHandler({}) as Promise; - // Second tick — should recover and write a frame - await vi.advanceTimersByTimeAsync(34); - expect(fsMock.writeFile).toHaveBeenCalledTimes(1); + mockProc.emit("error", new Error("spawn ENOENT")); - const statusHandler = getHandler("demo:get-capture-status"); - const status = (await statusHandler({})) as { active: boolean; frameCount: number }; - expect(status.active).toBe(true); - expect(status.frameCount).toBe(1); + await expect(stopPromise).rejects.toThrow("Capture encode failed: spawn ENOENT"); cleanup(); }); - it("supports start/stop/restart cycle with fresh state", async () => { + it("uses capture preset options including yuv444p for youtube-1080p", async () => { + const { spawn: spawnMock } = await import("child_process"); const deps = makeDeps(true); const cleanup = registerDemoHandlers(deps); - fsMock.mkdtemp - .mockResolvedValueOnce("/tmp/canopy-capture-session1") - .mockResolvedValueOnce("/tmp/canopy-capture-session2"); + const handler = getHandler("demo:start-capture"); + await handler({}, defaultPayload); + + const args = (spawnMock as ReturnType).mock.calls[0][1] as string[]; + expect(args).toContain("yuv444p"); + expect(args).toContain("high444"); + expect(args).toContain("libx264"); + expect(args).not.toContain("yuv420p"); + + cleanup(); + }); + + it("uses web-webm capture preset with VP9 and yuv444p", async () => { + const { spawn: spawnMock } = await import("child_process"); + mockProc = createMockProc(); + const deps = makeDeps(true); + const cleanup = registerDemoHandlers(deps); + + const handler = getHandler("demo:start-capture"); + await handler({}, { ...defaultPayload, preset: "web-webm", outputPath: "/tmp/out.webm" }); + + const args = (spawnMock as ReturnType).mock.calls[0][1] as string[]; + expect(args).toContain("libvpx-vp9"); + expect(args).toContain("yuv444p"); + expect(args).toContain("-row-mt"); + + cleanup(); + }); + + it("supports start/stop/restart cycle with fresh state", async () => { + const deps = makeDeps(true); + const cleanup = registerDemoHandlers(deps); const startHandler = getHandler("demo:start-capture"); const stopHandler = getHandler("demo:stop-capture"); // First session - await startHandler({}, { fps: 30 }); + await startHandler({}, defaultPayload); await vi.advanceTimersByTimeAsync(34); - const result1 = (await stopHandler({})) as { outputDir: string; frameCount: number }; - expect(result1.outputDir).toBe("/tmp/canopy-capture-session1"); + const stopPromise1 = stopHandler({}) as Promise<{ outputPath: string; frameCount: number }>; + mockProc.emit("close", 0); + const result1 = await stopPromise1; + expect(result1.outputPath).toBe("/tmp/capture/out.mp4"); expect(result1.frameCount).toBe(1); - // Second session — should have fresh frame count - await startHandler({}, { fps: 30 }); + // Second session — need fresh mockProc + mockProc = createMockProc(); + const { spawn: spawnMock } = await import("child_process"); + (spawnMock as ReturnType).mockReturnValue(mockProc); + + await startHandler({}, { ...defaultPayload, outputPath: "/tmp/capture/out2.mp4" }); await vi.advanceTimersByTimeAsync(34); await vi.advanceTimersByTimeAsync(34); - const result2 = (await stopHandler({})) as { outputDir: string; frameCount: number }; - expect(result2.outputDir).toBe("/tmp/canopy-capture-session2"); + const stopPromise2 = stopHandler({}) as Promise<{ outputPath: string; frameCount: number }>; + mockProc.emit("close", 0); + const result2 = await stopPromise2; + expect(result2.outputPath).toBe("/tmp/capture/out2.mp4"); expect(result2.frameCount).toBe(2); - // Filenames should restart from 000001 in second session - expect(fsMock.writeFile).toHaveBeenCalledWith( - "/tmp/canopy-capture-session2/frame-000001.png", - expect.any(Buffer) - ); + cleanup(); + }); + + it("rejects startCapture when first capturePage fails", async () => { + const deps = makeDeps(true); + const capturePage = deps.mainWindow!.webContents.capturePage as ReturnType; + capturePage.mockRejectedValueOnce(new Error("GPU context lost")); + + const cleanup = registerDemoHandlers(deps); + + const { spawn: spawnMock } = await import("child_process"); + const spawnCallsBefore = (spawnMock as ReturnType).mock.calls.length; + + const handler = getHandler("demo:start-capture"); + await expect(handler({}, defaultPayload)).rejects.toThrow("GPU context lost"); + + // ffmpeg should not have been spawned + expect((spawnMock as ReturnType).mock.calls.length).toBe(spawnCallsBefore); cleanup(); }); diff --git a/electron/ipc/handlers/demo.ts b/electron/ipc/handlers/demo.ts index 2dfe3541a..348e46d8e 100644 --- a/electron/ipc/handlers/demo.ts +++ b/electron/ipc/handlers/demo.ts @@ -1,8 +1,5 @@ import { ipcMain } from "electron"; import { randomBytes } from "crypto"; -import { mkdtemp, writeFile } from "fs/promises"; -import { join } from "path"; -import { tmpdir } from "os"; import * as fs from "fs"; import * as path from "path"; import { spawn, type ChildProcess } from "child_process"; @@ -24,6 +21,7 @@ import type { DemoEncodePayload, DemoEncodeProgressEvent, DemoEncodeResult, + DemoEncodePreset, } from "../../../shared/types/ipc/demo.js"; export function resolveFfmpegPath(): string { @@ -144,110 +142,314 @@ export function registerDemoHandlers(deps: HandlerDependencies): () => void { }; // --- Frame capture state --- - let captureActive = false; - let captureBusy = false; - let captureFrameCount = 0; - let captureSessionDir: string | null = null; - let captureTimer: ReturnType | null = null; - let captureToken = 0; - let captureMaxFrames = 9000; - - function stopCapture(): DemoStopCaptureResult { - captureActive = false; - captureToken++; - if (captureTimer !== null) { - clearTimeout(captureTimer); - captureTimer = null; + interface CaptureSession { + ffmpegProc: ChildProcess; + ticker: ReturnType | null; + captureToken: number; + lastFrameBuffer: Buffer | null; + frameWidth: number; + frameHeight: number; + frameCount: number; + maxFrames: number; + outputPath: string; + draining: boolean; + stopping: boolean; + fps: number; + finalizePromise: Promise; + resolveFinalizeWith: (result: DemoStopCaptureResult) => void; + rejectFinalizeWith: (err: Error) => void; + } + + let captureSession: CaptureSession | null = null; + let captureTokenCounter = 0; + + function writeFrameToStdin(session: CaptureSession): void { + if (!session.lastFrameBuffer || session.stopping) return; + const ok = session.ffmpegProc.stdin!.write(session.lastFrameBuffer); + session.frameCount++; + if (session.frameCount >= session.maxFrames) { + stopCaptureSession(); + return; + } + if (!ok) { + session.draining = true; + if (session.ticker !== null) { + clearInterval(session.ticker); + session.ticker = null; + } + session.ffmpegProc.stdin!.once("drain", () => { + if (session !== captureSession || session.stopping) return; + session.draining = false; + session.ticker = setInterval( + () => writeFrameToStdin(session), + Math.round(1000 / session.fps) + ); + }); } - return { outputDir: captureSessionDir ?? "", frameCount: captureFrameCount }; + } + + function stopCaptureSession(): Promise | null { + const session = captureSession; + if (!session || session.stopping) return session?.finalizePromise ?? null; + session.stopping = true; + captureTokenCounter++; + if (session.ticker !== null) { + clearInterval(session.ticker); + session.ticker = null; + } + session.ffmpegProc.stdin!.end(); + return session.finalizePromise; + } + + function startCaptureLoop(session: CaptureSession, token: number): void { + const win = getMainWindow(); + if (!win || win.isDestroyed()) return; + const wc = getAppWebContents(win); + + void (async () => { + while (captureSession === session && session.captureToken === token && !session.stopping) { + try { + const image = await wc.capturePage(); + if (captureSession !== session || session.captureToken !== token || session.stopping) + break; + const resized = image.resize({ + width: session.frameWidth, + height: session.frameHeight, + quality: "best", + }); + session.lastFrameBuffer = resized.toBitmap(); + } catch { + // Keep lastFrameBuffer unchanged — ticker will duplicate + } + // Yield to event loop between captures to avoid starving the ticker + await new Promise((resolve) => setTimeout(resolve, 0)); + } + })(); } const handleStartCapture = async ( _event: Electron.IpcMainInvokeEvent, payload: DemoStartCapturePayload ): Promise => { - if (captureActive) { + if (captureSession) { throw new Error("Capture already in progress"); } - captureActive = true; const fps = payload.fps ?? 30; - captureMaxFrames = payload.maxFrames ?? 9000; - const intervalMs = Math.round(1000 / fps); - - try { - captureSessionDir = payload.outputDir ?? (await mkdtemp(join(tmpdir(), "canopy-capture-"))); - } catch (err) { - captureActive = false; - throw err; + const maxFrames = payload.maxFrames ?? 9000; + const { outputPath, preset } = payload; + const presetConfig = CAPTURE_PRESETS[preset]; + if (!presetConfig) { + throw new Error(`Unknown capture preset: ${preset}`); } - captureFrameCount = 0; - captureBusy = false; - const token = ++captureToken; - - function scheduleNext(): void { - captureTimer = setTimeout(async () => { - if (!captureActive || token !== captureToken) return; - if (captureBusy) { - scheduleNext(); - return; - } - captureBusy = true; - try { - const captureWin = getMainWindow(); - if (!captureWin || captureWin.isDestroyed()) return; - const image = await getAppWebContents(captureWin).capturePage(); - if (!captureActive || token !== captureToken) return; - const filename = `frame-${String(captureFrameCount + 1).padStart(6, "0")}.png`; - await writeFile(`${captureSessionDir}/${filename}`, image.toPNG()); - if (!captureActive || token !== captureToken) return; - captureFrameCount++; - if (captureFrameCount >= captureMaxFrames) { - stopCapture(); - } else { - scheduleNext(); - } - } catch { - if (captureActive && token === captureToken) { - scheduleNext(); - } - } finally { - captureBusy = false; - } - }, intervalMs); + + // Capture first frame to determine dimensions + const win = getMainWindow(); + if (!win || win.isDestroyed()) { + throw new Error("No window available for capture"); } + const firstImage = await getAppWebContents(win).capturePage(); + const logicalSize = firstImage.getSize(); + const frameWidth = logicalSize.width; + const frameHeight = logicalSize.height; + const resizedFirst = firstImage.resize({ + width: frameWidth, + height: frameHeight, + quality: "best", + }); + const firstBitmap = resizedFirst.toBitmap(); + + fs.mkdirSync(path.dirname(outputPath), { recursive: true }); - scheduleNext(); - return { outputDir: captureSessionDir }; + const ffmpegBin = resolveFfmpegPath(); + const args = [ + "-y", + "-f", + "rawvideo", + "-pix_fmt", + "bgra", + "-video_size", + `${frameWidth}x${frameHeight}`, + "-framerate", + String(fps), + "-i", + "pipe:0", + ...presetConfig.outputOptions, + "-fps_mode", + "cfr", + outputPath, + ]; + + const ffmpegProc = spawn(ffmpegBin, args, { stdio: ["pipe", "pipe", "pipe"] }); + + const token = ++captureTokenCounter; + + let resolveFinalizeWith!: (result: DemoStopCaptureResult) => void; + let rejectFinalizeWith!: (err: Error) => void; + const finalizePromise = new Promise((resolve, reject) => { + resolveFinalizeWith = resolve; + rejectFinalizeWith = reject; + }); + + const session: CaptureSession = { + ffmpegProc, + ticker: null, + captureToken: token, + lastFrameBuffer: firstBitmap, + frameWidth, + frameHeight, + frameCount: 0, + maxFrames, + outputPath, + draining: false, + stopping: false, + fps, + finalizePromise, + resolveFinalizeWith, + rejectFinalizeWith, + }; + + captureSession = session; + + ffmpegProc.on("error", (err: Error) => { + if (captureSession === session) { + session.stopping = true; + if (session.ticker !== null) { + clearInterval(session.ticker); + session.ticker = null; + } + captureSession = null; + session.rejectFinalizeWith(new Error(`Capture encode failed: ${err.message}`)); + } + }); + + ffmpegProc.on("close", (code) => { + session.stopping = true; + if (session.ticker !== null) { + clearInterval(session.ticker); + session.ticker = null; + } + if (captureSession === session) { + captureSession = null; + } + if (code === 0) { + session.resolveFinalizeWith({ + outputPath: session.outputPath, + frameCount: session.frameCount, + }); + } else { + session.rejectFinalizeWith(new Error(`ffmpeg exited with code ${code}`)); + } + }); + + // Start the ticker and capture loop + session.ticker = setInterval(() => writeFrameToStdin(session), Math.round(1000 / fps)); + startCaptureLoop(session, token); + + return { outputPath }; }; const handleStopCapture = async (): Promise => { - return stopCapture(); + const promise = stopCaptureSession(); + if (!promise) { + throw new Error("No capture in progress"); + } + return promise; }; const handleGetCaptureStatus = async (): Promise => { return { - active: captureActive, - frameCount: captureFrameCount, - outputDir: captureSessionDir, + active: captureSession !== null && !captureSession.stopping, + frameCount: captureSession?.frameCount ?? 0, + outputPath: captureSession?.outputPath ?? null, }; }; - // --- Video encoding --- + // --- Encode presets for live capture (raw BGRA stdin → output file) --- + + const CAPTURE_PRESETS: Record = { + "youtube-4k": { + outputOptions: [ + "-vf", + "scale=3840:2160:flags=lanczos", + "-c:v", + "libx264", + "-profile:v", + "high444", + "-crf", + "18", + "-pix_fmt", + "yuv444p", + "-preset", + "slow", + "-g", + "15", + "-bf", + "2", + "-movflags", + "+faststart", + "-an", + ], + }, + "youtube-1080p": { + outputOptions: [ + "-vf", + "scale=1920:1080:flags=lanczos", + "-c:v", + "libx264", + "-profile:v", + "high444", + "-crf", + "18", + "-pix_fmt", + "yuv444p", + "-preset", + "slow", + "-g", + "15", + "-bf", + "2", + "-movflags", + "+faststart", + "-an", + ], + }, + "web-webm": { + outputOptions: [ + "-c:v", + "libvpx-vp9", + "-crf", + "20", + "-b:v", + "0", + "-deadline", + "good", + "-cpu-used", + "1", + "-row-mt", + "1", + "-pix_fmt", + "yuv444p", + "-an", + ], + }, + }; + + // --- Encode presets for offline re-encode (PNG files from disk) --- const ENCODE_PRESETS = { "youtube-4k": { outputOptions: [ - "-s", - "3840x2160", + "-vf", + "scale=3840:2160:flags=lanczos", "-c:v", "libx264", "-profile:v", - "high", + "high444", "-crf", "18", "-pix_fmt", - "yuv420p", + "yuv444p", "-preset", "slow", "-g", @@ -261,16 +463,16 @@ export function registerDemoHandlers(deps: HandlerDependencies): () => void { }, "youtube-1080p": { outputOptions: [ - "-s", - "1920x1080", + "-vf", + "scale=1920:1080:flags=lanczos", "-c:v", "libx264", "-profile:v", - "high", + "high444", "-crf", "18", "-pix_fmt", - "yuv420p", + "yuv444p", "-preset", "slow", "-g", @@ -287,15 +489,17 @@ export function registerDemoHandlers(deps: HandlerDependencies): () => void { "-c:v", "libvpx-vp9", "-crf", - "30", + "20", "-b:v", "0", "-deadline", "good", "-cpu-used", "1", + "-row-mt", + "1", "-pix_fmt", - "yuv420p", + "yuv444p", "-an", ], }, @@ -426,7 +630,10 @@ export function registerDemoHandlers(deps: HandlerDependencies): () => void { ipcMain.handle(CHANNELS.DEMO_ENCODE, handleEncode); return () => { - stopCapture(); + if (captureSession) { + stopCaptureSession(); + captureSession?.ffmpegProc.kill("SIGKILL"); + } ipcMain.removeHandler(CHANNELS.DEMO_MOVE_TO); ipcMain.removeHandler(CHANNELS.DEMO_MOVE_TO_SELECTOR); ipcMain.removeHandler(CHANNELS.DEMO_CLICK); diff --git a/electron/preload.cts b/electron/preload.cts index 77429df2c..85583f733 100644 --- a/electron/preload.cts +++ b/electron/preload.cts @@ -2644,8 +2644,12 @@ const api: ElectronAPI = { pause: () => _unwrappingInvoke(CHANNELS.DEMO_PAUSE), resume: () => _unwrappingInvoke(CHANNELS.DEMO_RESUME), sleep: (durationMs: number) => _unwrappingInvoke(CHANNELS.DEMO_SLEEP, { durationMs }), - startCapture: (payload: { fps?: number; maxFrames?: number; outputDir?: string }) => - _unwrappingInvoke(CHANNELS.DEMO_START_CAPTURE, payload), + startCapture: (payload: { + fps?: number; + maxFrames?: number; + outputPath: string; + preset: import("../shared/types/ipc/demo.js").DemoEncodePreset; + }) => _unwrappingInvoke(CHANNELS.DEMO_START_CAPTURE, payload), stopCapture: () => _unwrappingInvoke(CHANNELS.DEMO_STOP_CAPTURE), getCaptureStatus: () => _unwrappingInvoke(CHANNELS.DEMO_GET_CAPTURE_STATUS), encode: (payload: import("../shared/types/ipc/demo.js").DemoEncodePayload) => diff --git a/shared/types/ipc/demo.ts b/shared/types/ipc/demo.ts index 0fcfa45f9..74a707737 100644 --- a/shared/types/ipc/demo.ts +++ b/shared/types/ipc/demo.ts @@ -40,22 +40,23 @@ export interface DemoScreenshotResult { export interface DemoStartCapturePayload { fps?: number; maxFrames?: number; - outputDir?: string; + outputPath: string; + preset: DemoEncodePreset; } export interface DemoStartCaptureResult { - outputDir: string; + outputPath: string; } export interface DemoStopCaptureResult { - outputDir: string; + outputPath: string; frameCount: number; } export interface DemoCaptureStatus { active: boolean; frameCount: number; - outputDir: string | null; + outputPath: string | null; } export type DemoEncodePreset = "youtube-4k" | "youtube-1080p" | "web-webm";