| 1 | import type { NativeImage } from 'electron' |
| 2 | import { describe, expect, it, vi } from 'vitest' |
| 3 | |
| 4 | vi.mock('electron', () => ({ |
| 5 | BrowserWindow: class BrowserWindow {} |
| 6 | })) |
| 7 | |
| 8 | vi.mock('@electron-toolkit/utils', () => ({ |
| 9 | is: { dev: true } |
| 10 | })) |
| 11 | |
| 12 | vi.mock('electron-log/main.js', () => ({ |
| 13 | default: { |
| 14 | info: vi.fn(), |
| 15 | warn: vi.fn(), |
| 16 | error: vi.fn() |
| 17 | } |
| 18 | })) |
| 19 | |
| 20 | import { |
| 21 | VIDEO_EXPORT_FRAME_SIZE, |
| 22 | VIDEO_EXPORT_EVEN_DIMENSIONS_FILTER, |
| 23 | buildVideoExportFfmpegArgs, |
| 24 | normalizeCapturedVideoFrameImage, |
| 25 | normalizeVideoExportCaptureFps, |
| 26 | normalizeVideoExportFps, |
| 27 | normalizeVideoExportSecondsPerPage, |
| 28 | resolveVideoExportFrameLayout |
| 29 | } from '../../../src/main/io/html-video/exporter' |
| 30 | |
| 31 | describe('html video exporter', () => { |
| 32 | it('normalizes captured frames back to the export canvas size', () => { |
| 33 | const normalizedImage = {} |
| 34 | const image = { |
| 35 | resize: vi.fn(() => normalizedImage) |
| 36 | } |
| 37 | |
| 38 | expect(normalizeCapturedVideoFrameImage(image as unknown as NativeImage)).toBe(normalizedImage) |
| 39 | expect(image.resize).toHaveBeenCalledWith({ |
| 40 | width: VIDEO_EXPORT_FRAME_SIZE.width, |
| 41 | height: VIDEO_EXPORT_FRAME_SIZE.height, |
| 42 | quality: 'best' |
| 43 | }) |
| 44 | }) |
| 45 | |
| 46 | it('scales ffmpeg output to even dimensions before libx264 encoding', () => { |
| 47 | const args = buildVideoExportFfmpegArgs({ |
| 48 | concatPath: '/tmp/video-export/concat.txt', |
| 49 | outputPath: '/tmp/video-export/out.mp4', |
| 50 | fps: 30 |
| 51 | }) |
| 52 | |
| 53 | const filterIndex = args.indexOf('-vf') |
| 54 | const codecIndex = args.indexOf('-c:v') |
| 55 | |
| 56 | expect(filterIndex).toBeGreaterThan(-1) |
| 57 | expect(codecIndex).toBeGreaterThan(filterIndex) |
| 58 | expect(args[filterIndex + 1]).toBe(VIDEO_EXPORT_EVEN_DIMENSIONS_FILTER) |
| 59 | expect(VIDEO_EXPORT_EVEN_DIMENSIONS_FILTER).toContain('scale=') |
| 60 | expect(VIDEO_EXPORT_EVEN_DIMENSIONS_FILTER).toContain('ceil(iw/2)*2') |
| 61 | expect(VIDEO_EXPORT_EVEN_DIMENSIONS_FILTER).toContain('ceil(ih/2)*2') |
| 62 | expect(args).toContain('libx264') |
| 63 | expect(args).toContain('yuv420p') |
| 64 | }) |
| 65 | |
| 66 | it('keeps video timing options within supported bounds', () => { |
| 67 | expect(normalizeVideoExportFps(120)).toBe(60) |
| 68 | expect(normalizeVideoExportFps(5)).toBe(12) |
| 69 | expect(normalizeVideoExportCaptureFps(30, 24)).toBe(24) |
| 70 | expect(normalizeVideoExportSecondsPerPage(0)).toBe(1) |
| 71 | }) |
| 72 | |
| 73 | it('uses a 2K video frame and centers non-16:9 slide canvases inside it', () => { |
| 74 | expect(VIDEO_EXPORT_FRAME_SIZE).toEqual({ width: 2560, height: 1440 }) |
| 75 | |
| 76 | const portrait = resolveVideoExportFrameLayout({ |
| 77 | frameWidth: 2560, |
| 78 | frameHeight: 1440, |
| 79 | slideWidth: 900, |
| 80 | slideHeight: 1600 |
| 81 | }) |
| 82 | expect(portrait.scale).toBeCloseTo(1440 / 1600) |
| 83 | expect(portrait.top).toBeCloseTo(0) |
| 84 | expect(portrait.left).toBeCloseTo((2560 - 900 * (1440 / 1600)) / 2) |
| 85 | |
| 86 | const square = resolveVideoExportFrameLayout({ |
| 87 | frameWidth: 2560, |
| 88 | frameHeight: 1440, |
| 89 | slideWidth: 1200, |
| 90 | slideHeight: 1200 |
| 91 | }) |
| 92 | expect(square.scale).toBeCloseTo(1440 / 1200) |
| 93 | expect(square.top).toBeCloseTo(0) |
| 94 | expect(square.left).toBeCloseTo(560) |
| 95 | }) |
| 96 | }) |
| 97 |