|
| 1 | +/** |
| 2 | + * @module core/video/__tests__/SceneDetector.test |
| 3 | + * |
| 4 | + * Unit tests for the {@link SceneDetector} class. |
| 5 | + * |
| 6 | + * Tests use synthetic 4x4 RGB frames with known solid colours to verify |
| 7 | + * scene boundary detection, histogram computation, and configuration |
| 8 | + * behaviour without requiring actual video data or image processing |
| 9 | + * libraries. |
| 10 | + * |
| 11 | + * ## Test categories |
| 12 | + * |
| 13 | + * 1. **Hard cut detection** — large colour difference triggers scene change |
| 14 | + * 2. **Identical frames** — no scene change when frames are the same |
| 15 | + * 3. **Streaming detection** — multiple scene boundaries from async frame stream |
| 16 | + * 4. **Histogram identity** — histogramDiff returns 0 for identical buffers |
| 17 | + * 5. **Histogram divergence** — histogramDiff returns high score for opposite colours |
| 18 | + * 6. **Min scene duration** — rapid changes suppressed by minSceneDurationSec |
| 19 | + */ |
| 20 | + |
| 21 | +import { describe, it, expect } from 'vitest'; |
| 22 | +import { SceneDetector } from '../../vision/SceneDetector.js'; |
| 23 | +import type { Frame } from '../../vision/types.js'; |
| 24 | + |
| 25 | +// --------------------------------------------------------------------------- |
| 26 | +// Test helper — create a 4x4 solid-colour RGB frame |
| 27 | +// --------------------------------------------------------------------------- |
| 28 | + |
| 29 | +/** |
| 30 | + * Create a 4x4 pixel RGB frame filled with a single colour. |
| 31 | + * |
| 32 | + * The buffer contains 48 bytes (4 * 4 * 3 = 48) of raw RGB data |
| 33 | + * where every pixel has the specified (r, g, b) values. |
| 34 | + * |
| 35 | + * @param r - Red channel value (0-255). |
| 36 | + * @param g - Green channel value (0-255). |
| 37 | + * @param b - Blue channel value (0-255). |
| 38 | + * @param timestampSec - Timestamp of this frame in seconds. |
| 39 | + * @param index - 0-based frame index. |
| 40 | + * @returns A Frame object with the specified colour and metadata. |
| 41 | + */ |
| 42 | +function makeFrame(r: number, g: number, b: number, timestampSec: number, index: number): Frame { |
| 43 | + // 4x4 pixels, 3 bytes per pixel = 48 bytes |
| 44 | + const buf = Buffer.alloc(4 * 4 * 3); |
| 45 | + for (let i = 0; i < 16; i++) { |
| 46 | + buf[i * 3] = r; |
| 47 | + buf[i * 3 + 1] = g; |
| 48 | + buf[i * 3 + 2] = b; |
| 49 | + } |
| 50 | + return { buffer: buf, timestampSec, index }; |
| 51 | +} |
| 52 | + |
| 53 | +/** |
| 54 | + * Helper to convert an array of frames into an async iterable. |
| 55 | + */ |
| 56 | +async function* toAsyncIterable(frames: Frame[]): AsyncGenerator<Frame> { |
| 57 | + for (const frame of frames) { |
| 58 | + yield frame; |
| 59 | + } |
| 60 | +} |
| 61 | + |
| 62 | +/** |
| 63 | + * Helper to collect all values from an async generator into an array. |
| 64 | + */ |
| 65 | +async function collect<T>(gen: AsyncGenerator<T>): Promise<T[]> { |
| 66 | + const results: T[] = []; |
| 67 | + for await (const item of gen) { |
| 68 | + results.push(item); |
| 69 | + } |
| 70 | + return results; |
| 71 | +} |
| 72 | + |
| 73 | +// --------------------------------------------------------------------------- |
| 74 | +// Tests |
| 75 | +// --------------------------------------------------------------------------- |
| 76 | + |
| 77 | +describe('SceneDetector', () => { |
| 78 | + // ----------------------------------------------------------------------- |
| 79 | + // Test 1: detects hard cut between very different frames |
| 80 | + // ----------------------------------------------------------------------- |
| 81 | + |
| 82 | + it('detects hard cut between very different frames (red vs blue)', () => { |
| 83 | + const detector = new SceneDetector({ hardCutThreshold: 0.3 }); |
| 84 | + |
| 85 | + const redFrame = makeFrame(255, 0, 0, 0, 0); |
| 86 | + const blueFrame = makeFrame(0, 0, 255, 1, 1); |
| 87 | + |
| 88 | + const result = detector.hasSceneChanged(redFrame.buffer, blueFrame.buffer); |
| 89 | + |
| 90 | + expect(result.changed).toBe(true); |
| 91 | + expect(result.score).toBeGreaterThan(0.3); |
| 92 | + expect(result.type).toBe('hard-cut'); |
| 93 | + }); |
| 94 | + |
| 95 | + // ----------------------------------------------------------------------- |
| 96 | + // Test 2: no change between identical frames |
| 97 | + // ----------------------------------------------------------------------- |
| 98 | + |
| 99 | + it('no change between identical frames', () => { |
| 100 | + const detector = new SceneDetector(); |
| 101 | + |
| 102 | + const frame = makeFrame(128, 128, 128, 0, 0); |
| 103 | + |
| 104 | + const result = detector.hasSceneChanged(frame.buffer, frame.buffer); |
| 105 | + |
| 106 | + expect(result.changed).toBe(false); |
| 107 | + expect(result.score).toBe(0); |
| 108 | + expect(result.type).toBeUndefined(); |
| 109 | + }); |
| 110 | + |
| 111 | + // ----------------------------------------------------------------------- |
| 112 | + // Test 3: detects scenes from async frame stream |
| 113 | + // ----------------------------------------------------------------------- |
| 114 | + |
| 115 | + it('detects scenes from async frame stream (3 colour groups -> 2+ scene boundaries)', async () => { |
| 116 | + const detector = new SceneDetector({ |
| 117 | + hardCutThreshold: 0.3, |
| 118 | + gradualThreshold: 0.15, |
| 119 | + minSceneDurationSec: 0.5, |
| 120 | + }); |
| 121 | + |
| 122 | + // Create a sequence: 3 red frames, 3 green frames, 3 blue frames |
| 123 | + // Each frame is 1 second apart, so scene boundaries should be detected |
| 124 | + // at the colour transitions (frame 3 and frame 6). |
| 125 | + const frames: Frame[] = [ |
| 126 | + // Scene 0: red |
| 127 | + makeFrame(255, 0, 0, 0, 0), |
| 128 | + makeFrame(255, 0, 0, 1, 1), |
| 129 | + makeFrame(255, 0, 0, 2, 2), |
| 130 | + // Scene 1: green |
| 131 | + makeFrame(0, 255, 0, 3, 3), |
| 132 | + makeFrame(0, 255, 0, 4, 4), |
| 133 | + makeFrame(0, 255, 0, 5, 5), |
| 134 | + // Scene 2: blue |
| 135 | + makeFrame(0, 0, 255, 6, 6), |
| 136 | + makeFrame(0, 0, 255, 7, 7), |
| 137 | + makeFrame(0, 0, 255, 8, 8), |
| 138 | + ]; |
| 139 | + |
| 140 | + const boundaries = await collect(detector.detectScenes(toAsyncIterable(frames))); |
| 141 | + |
| 142 | + // Should have at least 3 boundaries: |
| 143 | + // scene 0 (red), scene 1 (green), scene 2 (blue) + final boundary |
| 144 | + expect(boundaries.length).toBeGreaterThanOrEqual(3); |
| 145 | + |
| 146 | + // First boundary should be the red scene |
| 147 | + expect(boundaries[0].startFrame).toBe(0); |
| 148 | + expect(boundaries[0].startTimeSec).toBe(0); |
| 149 | + |
| 150 | + // Verify we detected the colour transitions |
| 151 | + const hardCuts = boundaries.filter((b) => b.cutType === 'hard-cut'); |
| 152 | + expect(hardCuts.length).toBeGreaterThanOrEqual(1); |
| 153 | + }); |
| 154 | + |
| 155 | + // ----------------------------------------------------------------------- |
| 156 | + // Test 4: histogramDiff returns 0 for identical buffers |
| 157 | + // ----------------------------------------------------------------------- |
| 158 | + |
| 159 | + it('histogramDiff returns 0 for identical buffers', () => { |
| 160 | + const detector = new SceneDetector(); |
| 161 | + |
| 162 | + const frame = makeFrame(100, 150, 200, 0, 0); |
| 163 | + |
| 164 | + const diff = detector.histogramDiff(frame.buffer, frame.buffer); |
| 165 | + |
| 166 | + expect(diff).toBe(0); |
| 167 | + }); |
| 168 | + |
| 169 | + // ----------------------------------------------------------------------- |
| 170 | + // Test 5: histogramDiff returns high score for opposite colours |
| 171 | + // ----------------------------------------------------------------------- |
| 172 | + |
| 173 | + it('histogramDiff returns high score for opposite colours', () => { |
| 174 | + const detector = new SceneDetector(); |
| 175 | + |
| 176 | + const blackFrame = makeFrame(0, 0, 0, 0, 0); |
| 177 | + const whiteFrame = makeFrame(255, 255, 255, 1, 1); |
| 178 | + |
| 179 | + const diff = detector.histogramDiff(blackFrame.buffer, whiteFrame.buffer); |
| 180 | + |
| 181 | + // Black (all pixels at bin 0) vs white (all pixels at bin 255) should |
| 182 | + // produce a very high chi-squared distance since the histograms are |
| 183 | + // completely disjoint across all channels. |
| 184 | + expect(diff).toBeGreaterThan(0.5); |
| 185 | + }); |
| 186 | + |
| 187 | + // ----------------------------------------------------------------------- |
| 188 | + // Test 6: respects minSceneDurationSec |
| 189 | + // ----------------------------------------------------------------------- |
| 190 | + |
| 191 | + it('respects minSceneDurationSec (rapid 1s changes suppressed with 5s min)', async () => { |
| 192 | + const detector = new SceneDetector({ |
| 193 | + hardCutThreshold: 0.3, |
| 194 | + gradualThreshold: 0.15, |
| 195 | + minSceneDurationSec: 5.0, // Require at least 5 seconds between scenes |
| 196 | + }); |
| 197 | + |
| 198 | + // Create rapid colour changes every 1 second — all should be suppressed |
| 199 | + // because they don't meet the 5-second minimum scene duration. |
| 200 | + const frames: Frame[] = [ |
| 201 | + makeFrame(255, 0, 0, 0, 0), // red |
| 202 | + makeFrame(0, 255, 0, 1, 1), // green (1s later — too soon) |
| 203 | + makeFrame(0, 0, 255, 2, 2), // blue (2s later — too soon) |
| 204 | + makeFrame(255, 255, 0, 3, 3), // yellow (3s later — too soon) |
| 205 | + makeFrame(0, 255, 255, 4, 4), // cyan (4s later — too soon) |
| 206 | + ]; |
| 207 | + |
| 208 | + const boundaries = await collect(detector.detectScenes(toAsyncIterable(frames))); |
| 209 | + |
| 210 | + // With 5s minSceneDurationSec and frames spanning only 0-4s, |
| 211 | + // no intermediate scene changes should be detected. |
| 212 | + // We should only get the final boundary (closing the single scene). |
| 213 | + expect(boundaries.length).toBe(1); |
| 214 | + expect(boundaries[0].startFrame).toBe(0); |
| 215 | + expect(boundaries[0].endFrame).toBe(4); |
| 216 | + }); |
| 217 | +}); |
0 commit comments