@shaztech/video-cutter 1.0.0 → 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,19 @@
1
+ name: CI
2
+
3
+ on:
4
+ push:
5
+ branches:
6
+ - main
7
+ pull_request:
8
+
9
+ jobs:
10
+ test:
11
+ runs-on: ubuntu-latest
12
+ steps:
13
+ - uses: actions/checkout@v4
14
+ - uses: actions/setup-node@v4
15
+ with:
16
+ node-version: '20'
17
+ cache: 'npm'
18
+ - run: npm ci
19
+ - run: npm test
@@ -0,0 +1,21 @@
1
+ name: Publish to npm
2
+
3
+ on:
4
+ release:
5
+ types: [published]
6
+
7
+ jobs:
8
+ publish:
9
+ runs-on: ubuntu-latest
10
+ steps:
11
+ - uses: actions/checkout@v4
12
+ - uses: actions/setup-node@v4
13
+ with:
14
+ node-version: '20'
15
+ cache: 'npm'
16
+ registry-url: 'https://registry.npmjs.org'
17
+ - run: npm ci
18
+ - run: npm test
19
+ - run: npm publish
20
+ env:
21
+ NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # Video Cutter
2
2
 
3
- A simple CLI tool to cut a video file into equal-length segments using ffmpeg.
3
+ A CLI tool to cut a video file into segments using ffmpeg — by equal count, fixed duration, or automatic scene change detection.
4
4
 
5
5
  ## Prerequisites
6
6
 
@@ -30,17 +30,19 @@ video-cutter [options]
30
30
 
31
31
  Options:
32
32
  ```text
33
- -V, --version output the version number
34
- -i, --input <path> input video file path
35
- -n, --segments <number> number of segments to create
36
- -d, --duration <seconds> duration of each segment in seconds
37
- -o, --output <path> output directory for segments (default:
38
- ./output/YYYY-MM-DD_HH-MM-SS/)
39
- --verify verify that each segment matches its intended
40
- duration
41
- --re-encode re-encode segments for exact duration (slower but
42
- more accurate)
43
- -h, --help display help for command
33
+ -V, --version output the version number
34
+ -i, --input <path> input video file path
35
+ -n, --segments <number> number of segments to create
36
+ -d, --duration <seconds> duration of each segment in seconds
37
+ --scene-detect [threshold] cut at scene change boundaries (threshold
38
+ 0–100, default: 10)
39
+ -o, --output <path> output directory for segments (default:
40
+ ./output/YYYY-MM-DD_HH-MM-SS/)
41
+ --verify verify that each segment matches its intended
42
+ duration
43
+ --re-encode re-encode segments for exact duration (slower
44
+ but more accurate)
45
+ -h, --help display help for command
44
46
  ```
45
47
 
46
48
  ## Examples
@@ -63,6 +65,32 @@ This will create files named `seg_01_00-00-00.mp4`, `seg_02_00-00-30.mp4`, etc.
63
65
 
64
66
  **Note:** When using the `-d` flag without `--re-encode`, segment durations may not be exactly as specified due to keyframe alignment when stream copying. For exact durations, use the `--re-encode` flag.
65
67
 
68
+ ### Cut a video at scene changes (automatic detection):
69
+
70
+ ```bash
71
+ video-cutter -i my_video.mp4 --scene-detect
72
+ ```
73
+
74
+ This detects hard cuts between scenes and creates one segment per scene, named `scene_001.mp4`, `scene_002.mp4`, etc. The number of segments is content-driven — it depends entirely on what's in the video.
75
+
76
+ ### Cut at scene changes with a custom threshold:
77
+
78
+ ```bash
79
+ video-cutter -i my_video.mp4 --scene-detect 20
80
+ ```
81
+
82
+ The threshold (0–100) controls sensitivity to scene changes. It represents the minimum per-frame difference score required to count as a scene cut:
83
+
84
+ | Threshold | Behaviour |
85
+ |-----------|-----------|
86
+ | 2–5 | Very sensitive — catches subtle transitions and fades; may produce false positives |
87
+ | **8–14** | **Recommended range** — reliably detects hard cuts with few false positives |
88
+ | 20–30 | Only dramatic, obvious cuts; may miss some real scene changes |
89
+
90
+ The default is **10**. Raise it if you are getting too many segments; lower it if real cuts are being missed.
91
+
92
+ **Note:** File paths containing `:` or `,` may cause scene detection to fail due to limitations in the ffmpeg lavfi filter string format.
93
+
66
94
  ### Specify custom output directory:
67
95
 
68
96
  ```bash
@@ -77,8 +105,10 @@ video-cutter -i my_video.mp4 -n 10 -o ./my_segments/
77
105
  - When creating segments shorter than 30 seconds, you'll be prompted for confirmation
78
106
  - Segment numbers are zero-padded to 3 digits (001, 002, etc.) for count-based segments
79
107
  - Time-based segments are named with both sequence number and start time (e.g., seg_01_00-00-00.mp4)
80
- - The `--segments` and `--duration` options are mutually exclusive
108
+ - The `--segments`, `--duration`, and `--scene-detect` options are mutually exclusive
81
109
  - By default, the tool uses stream copying for fast segment creation, which may result in slight duration inaccuracies due to keyframe alignment
82
110
  - Use the `--re-encode` flag to re-encode segments for precise duration control (slower but exact)
83
111
  - Use the `--verify` flag to check that each segment matches its intended duration after creation
84
- - When using both `--re-encode` and `--verify`, segments will have exact durations as specified
112
+ - When using both `--re-encode` and `--verify`, segments will have exact durations as specified
113
+ - Scene-detect segments are named `scene_001.mp4`, `scene_002.mp4`, etc.
114
+ - The `--scene-detect` threshold is a value from 0–100 representing the minimum frame difference score to trigger a cut; the recommended range is 8–14 (default: 10)
package/index.js CHANGED
@@ -23,7 +23,9 @@ import colors from 'colors'
23
23
  import {
24
24
  getVideoDuration,
25
25
  createCountSegments,
26
- createTimeSegments
26
+ createTimeSegments,
27
+ detectSceneChanges,
28
+ createSceneSegments
27
29
  } from './src/core.js'
28
30
 
29
31
  // Colored console output helpers
@@ -95,7 +97,24 @@ async function processVideo (options) {
95
97
 
96
98
  console.log(`Video duration: ${duration} seconds`)
97
99
 
98
- if (segmentDuration) {
100
+ const sceneDetect = options.sceneDetect ?? null
101
+ const threshold = sceneDetect === true ? 10 : (sceneDetect ? parseInt(sceneDetect) : null)
102
+
103
+ if (sceneDetect !== null) {
104
+ console.log(`Detecting scene changes with threshold: ${threshold}...`)
105
+ const timestamps = await detectSceneChanges(inputFile, threshold)
106
+ if (timestamps.length <= 1) {
107
+ console.warn('No scene changes detected. Cannot create segments.')
108
+ process.exit(0)
109
+ }
110
+ const boundaries = [...timestamps, duration]
111
+ console.log('boundaries:', boundaries)
112
+ console.log(`${boundaries.length - 1} scene segment(s) will be created.`)
113
+ if (!reEncode) {
114
+ console.warn('Using stream copy mode. Durations may vary slightly. Use --re-encode for exact cuts.')
115
+ }
116
+ return createSceneSegments(inputFile, outputPath, boundaries, verifySegments, reEncode)
117
+ } else if (segmentDuration) {
99
118
  // Time-based segmentation
100
119
  const calculatedSegmentCount = Math.ceil(duration / segmentDuration)
101
120
  console.log(`${calculatedSegmentCount} segments will be created.`)
@@ -183,6 +202,11 @@ function setupCli () {
183
202
  .requiredOption('-i, --input <path>', 'input video file path')
184
203
  .addOption(new Option('-n, --segments <number>', 'number of segments to create').conflicts('duration'))
185
204
  .addOption(new Option('-d, --duration <seconds>', 'duration of each segment in seconds').conflicts('segments'))
205
+ .addOption(
206
+ new Option('--scene-detect [threshold]', 'cut at scene change boundaries (threshold 0–100, default: 10)')
207
+ .conflicts('segments')
208
+ .conflicts('duration')
209
+ )
186
210
  .option('-o, --output <path>', 'output directory for segments (default: ./output/YYYY-MM-DD_HH-MM-SS/)')
187
211
  .option('--verify', 'verify that each segment matches its intended duration')
188
212
  .option('--re-encode', 're-encode segments for exact duration (slower but more accurate)')
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@shaztech/video-cutter",
3
- "version": "1.0.0",
3
+ "version": "1.1.0",
4
4
  "type": "module",
5
5
  "description": "A CLI tool to cut videos into segments using ffmpeg",
6
6
  "main": "index.js",
@@ -43,5 +43,8 @@
43
43
  "repository": {
44
44
  "type": "git",
45
45
  "url": "git+https://github.com/shazron/video-cutter.git"
46
+ },
47
+ "publishConfig": {
48
+ "access": "public"
46
49
  }
47
50
  }
package/src/core.js CHANGED
@@ -101,12 +101,14 @@ function createSegment (inputFile, startTime, endTime, outputFile, reEncode = fa
101
101
  // Log progress if needed
102
102
  })
103
103
 
104
+ let stderrOutput = ''
104
105
  ffmpeg.stderr.on('data', (data) => {
105
- // Handle errors
106
+ stderrOutput += data.toString()
106
107
  })
107
108
 
108
109
  ffmpeg.on('close', (code) => {
109
110
  if (code !== 0) {
111
+ if (stderrOutput) console.error(stderrOutput)
110
112
  reject(new Error(`ffmpeg exited with code ${code}`))
111
113
  } else {
112
114
  resolve()
@@ -243,9 +245,96 @@ async function createTimeSegments (inputFile, videoDuration, segmentDuration, ou
243
245
  })
244
246
  }
245
247
 
248
+ /**
249
+ * Detect scene changes using ffprobe's scdet filter
250
+ *
251
+ * Note: file paths containing `:` or `,` may break the lavfi filter string.
252
+ *
253
+ * @param {string} inputFile - Path to the input video file
254
+ * @param {number} threshold - Scene change sensitivity (0–100, default: 10)
255
+ * @returns {Promise<number[]>} - Array of timestamps (always starts with 0)
256
+ */
257
+ async function detectSceneChanges (inputFile, threshold = 10) {
258
+ return new Promise((resolve, reject) => {
259
+ const args = [
260
+ '-v', 'quiet',
261
+ '-f', 'lavfi',
262
+ '-i', `movie=${inputFile},scdet=threshold=${threshold}:sc_pass=1`,
263
+ '-show_frames', '-select_streams', 'v:0',
264
+ '-of', 'json'
265
+ ]
266
+ const proc = spawn('ffprobe', args)
267
+ let output = ''
268
+ proc.stdout.on('data', chunk => { output += chunk })
269
+ proc.on('close', code => {
270
+ if (code !== 0) return reject(new Error(`ffprobe exited with code ${code}`))
271
+ let parsed
272
+ try { parsed = JSON.parse(output) } catch {
273
+ return reject(new Error('Could not parse scene detection output'))
274
+ }
275
+ const frames = parsed.frames ?? []
276
+ const timestamps = frames.map(f => parseFloat(f.pts_time ?? f.pkt_pts_time))
277
+ const result = timestamps.length > 0 && timestamps[0] === 0
278
+ ? timestamps
279
+ : [0, ...timestamps]
280
+ resolve(result)
281
+ })
282
+ })
283
+ }
284
+
285
+ /**
286
+ * Create video segments at scene change boundaries
287
+ *
288
+ * @param {string} inputFile - Path to the input video file
289
+ * @param {string} outputDir - Directory to save the segments
290
+ * @param {number[]} boundaries - Array of timestamps including 0 at start and total duration at end
291
+ * @param {boolean} verifySegments - Whether to verify segment durations
292
+ * @param {boolean} reEncode - Whether to re-encode for exact duration
293
+ * @returns {Promise<void>}
294
+ */
295
+ async function createSceneSegments (inputFile, outputDir, boundaries, verifySegments = false, reEncode = false) {
296
+ const segmentCount = boundaries.length - 1
297
+ const promises = []
298
+
299
+ for (let i = 0; i < segmentCount; i++) {
300
+ const startTime = boundaries[i]
301
+ const endTime = boundaries[i + 1]
302
+ const segmentFile = path.join(outputDir, `scene_${String(i + 1).padStart(3, '0')}.mp4`)
303
+ promises.push(createSegment(inputFile, startTime, endTime, segmentFile, reEncode))
304
+ }
305
+
306
+ return Promise.all(promises)
307
+ .then(async () => {
308
+ console.log('All segments created successfully!')
309
+
310
+ if (verifySegments) {
311
+ console.log('Verifying segment durations...')
312
+ for (let i = 0; i < segmentCount; i++) {
313
+ const expectedDuration = boundaries[i + 1] - boundaries[i]
314
+ const segmentFile = path.join(outputDir, `scene_${String(i + 1).padStart(3, '0')}.mp4`)
315
+
316
+ const actualDuration = await getVideoDuration(segmentFile)
317
+ const difference = Math.abs(actualDuration - expectedDuration)
318
+ const tolerance = reEncode ? 0.1 : 1.0
319
+ if (difference > tolerance) {
320
+ console.warn(`Warning: Segment ${segmentFile} duration is ${actualDuration.toFixed(2)} seconds, expected ${expectedDuration.toFixed(2)} seconds`)
321
+ } else {
322
+ console.log(colors.green(`Verified: ${segmentFile} duration is correct`))
323
+ }
324
+ }
325
+ }
326
+ })
327
+ .catch(err => {
328
+ console.error('Error creating segments:', err)
329
+ process.exit(1)
330
+ })
331
+ }
332
+
246
333
  export {
247
334
  getVideoDuration,
248
335
  createSegment,
249
336
  createCountSegments,
250
- createTimeSegments
337
+ createTimeSegments,
338
+ detectSceneChanges,
339
+ createSceneSegments
251
340
  }
@@ -32,7 +32,9 @@ describe('index.js invoked directly as main module', () => {
32
32
  vi.doMock('../src/core.js', () => ({
33
33
  getVideoDuration: vi.fn(),
34
34
  createCountSegments: vi.fn(),
35
- createTimeSegments: vi.fn()
35
+ createTimeSegments: vi.fn(),
36
+ detectSceneChanges: vi.fn(),
37
+ createSceneSegments: vi.fn()
36
38
  }))
37
39
  vi.doMock('commander', () => {
38
40
  const mockProgram = {
@@ -75,7 +77,9 @@ describe('index.js invoked directly as main module', () => {
75
77
  vi.doMock('../src/core.js', () => ({
76
78
  getVideoDuration: vi.fn(),
77
79
  createCountSegments: vi.fn(),
78
- createTimeSegments: vi.fn()
80
+ createTimeSegments: vi.fn(),
81
+ detectSceneChanges: vi.fn(),
82
+ createSceneSegments: vi.fn()
79
83
  }))
80
84
  const parseMock = vi.fn()
81
85
  vi.doMock('commander', () => {
@@ -16,7 +16,7 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
16
16
  import { EventEmitter } from 'events'
17
17
 
18
18
  import { spawn } from 'child_process'
19
- import { getVideoDuration, createSegment, createCountSegments, createTimeSegments } from '../src/core.js'
19
+ import { getVideoDuration, createSegment, createCountSegments, createTimeSegments, detectSceneChanges, createSceneSegments } from '../src/core.js'
20
20
 
21
21
  vi.mock('child_process', () => ({
22
22
  spawn: vi.fn()
@@ -199,3 +199,136 @@ describe('createTimeSegments', () => {
199
199
  expect(exitSpy).toHaveBeenCalledWith(1)
200
200
  })
201
201
  })
202
+
203
+ describe('detectSceneChanges', () => {
204
+ beforeEach(() => vi.clearAllMocks())
205
+ afterEach(() => vi.restoreAllMocks())
206
+
207
+ it('resolves with timestamps when frames are returned using pts_time (ffmpeg 7+)', async () => {
208
+ const framesJson = JSON.stringify({
209
+ frames: [
210
+ { pts_time: '5.2' },
211
+ { pts_time: '12.8' }
212
+ ]
213
+ })
214
+ vi.mocked(spawn).mockReturnValueOnce(createMockProcess({ stdoutData: framesJson }))
215
+ const result = await detectSceneChanges('video.mp4')
216
+ expect(result).toEqual([0, 5.2, 12.8])
217
+ })
218
+
219
+ it('resolves with timestamps when frames are returned using pkt_pts_time fallback (ffmpeg <7)', async () => {
220
+ const framesJson = JSON.stringify({
221
+ frames: [
222
+ { pkt_pts_time: '5.2' },
223
+ { pkt_pts_time: '12.8' }
224
+ ]
225
+ })
226
+ vi.mocked(spawn).mockReturnValueOnce(createMockProcess({ stdoutData: framesJson }))
227
+ const result = await detectSceneChanges('video.mp4')
228
+ expect(result).toEqual([0, 5.2, 12.8])
229
+ })
230
+
231
+ it('resolves with [0] when frames array is empty', async () => {
232
+ const framesJson = JSON.stringify({ frames: [] })
233
+ vi.mocked(spawn).mockReturnValueOnce(createMockProcess({ stdoutData: framesJson }))
234
+ const result = await detectSceneChanges('video.mp4')
235
+ expect(result).toEqual([0])
236
+ })
237
+
238
+ it('resolves with [0] when frames key is missing (malformed JSON object)', async () => {
239
+ const framesJson = JSON.stringify({})
240
+ vi.mocked(spawn).mockReturnValueOnce(createMockProcess({ stdoutData: framesJson }))
241
+ const result = await detectSceneChanges('video.mp4')
242
+ expect(result).toEqual([0])
243
+ })
244
+
245
+ it('rejects on non-zero exit code', async () => {
246
+ vi.mocked(spawn).mockReturnValueOnce(createMockProcess({ exitCode: 1 }))
247
+ await expect(detectSceneChanges('video.mp4')).rejects.toThrow('ffprobe exited with code 1')
248
+ })
249
+
250
+ it('rejects when stdout is not valid JSON', async () => {
251
+ vi.mocked(spawn).mockReturnValueOnce(createMockProcess({ stdoutData: 'not-json' }))
252
+ await expect(detectSceneChanges('video.mp4')).rejects.toThrow('Could not parse scene detection output')
253
+ })
254
+
255
+ it('uses default threshold 10 in spawn args', async () => {
256
+ const framesJson = JSON.stringify({ frames: [] })
257
+ vi.mocked(spawn).mockReturnValueOnce(createMockProcess({ stdoutData: framesJson }))
258
+ await detectSceneChanges('video.mp4')
259
+ expect(spawn).toHaveBeenCalledWith('ffprobe', expect.arrayContaining([
260
+ expect.stringContaining('threshold=10')
261
+ ]))
262
+ })
263
+
264
+ it('uses provided threshold in spawn args', async () => {
265
+ const framesJson = JSON.stringify({ frames: [] })
266
+ vi.mocked(spawn).mockReturnValueOnce(createMockProcess({ stdoutData: framesJson }))
267
+ await detectSceneChanges('video.mp4', 25)
268
+ expect(spawn).toHaveBeenCalledWith('ffprobe', expect.arrayContaining([
269
+ expect.stringContaining('threshold=25')
270
+ ]))
271
+ })
272
+
273
+ it('does not duplicate 0 if first frame timestamp is already 0', async () => {
274
+ const framesJson = JSON.stringify({
275
+ frames: [
276
+ { pts_time: '0' },
277
+ { pts_time: '8.5' }
278
+ ]
279
+ })
280
+ vi.mocked(spawn).mockReturnValueOnce(createMockProcess({ stdoutData: framesJson }))
281
+ const result = await detectSceneChanges('video.mp4')
282
+ expect(result[0]).toBe(0)
283
+ expect(result.filter(t => t === 0)).toHaveLength(1)
284
+ })
285
+ })
286
+
287
+ describe('createSceneSegments', () => {
288
+ beforeEach(() => {
289
+ vi.clearAllMocks()
290
+ vi.spyOn(console, 'log').mockImplementation(() => {})
291
+ vi.spyOn(console, 'warn').mockImplementation(() => {})
292
+ vi.spyOn(console, 'error').mockImplementation(() => {})
293
+ })
294
+ afterEach(() => vi.restoreAllMocks())
295
+
296
+ it('creates N-1 segments from N boundaries with scene_NNN.mp4 filenames', async () => {
297
+ vi.mocked(spawn)
298
+ .mockReturnValueOnce(createMockProcess())
299
+ .mockReturnValueOnce(createMockProcess())
300
+ await expect(createSceneSegments('video.mp4', '/output', [0, 5.2, 30])).resolves.toBeUndefined()
301
+ expect(spawn).toHaveBeenCalledTimes(2)
302
+ expect(spawn).toHaveBeenCalledWith('ffmpeg', expect.arrayContaining([expect.stringContaining('scene_001.mp4')]))
303
+ expect(spawn).toHaveBeenCalledWith('ffmpeg', expect.arrayContaining([expect.stringContaining('scene_002.mp4')]))
304
+ })
305
+
306
+ it('passes reEncode=true through to spawn args (-c:v libx264)', async () => {
307
+ vi.mocked(spawn).mockReturnValueOnce(createMockProcess())
308
+ await createSceneSegments('video.mp4', '/output', [0, 30], false, true)
309
+ expect(spawn).toHaveBeenCalledWith('ffmpeg', expect.arrayContaining(['-c:v', 'libx264']))
310
+ })
311
+
312
+ it('verifies durations when verifySegments=true', async () => {
313
+ vi.mocked(spawn)
314
+ .mockReturnValueOnce(createMockProcess())
315
+ .mockReturnValueOnce(createMockProcess({ stdoutData: '30.0\n' }))
316
+ await createSceneSegments('video.mp4', '/output', [0, 30], true)
317
+ expect(console.log).toHaveBeenCalledWith(expect.stringContaining('Verified'))
318
+ })
319
+
320
+ it('warns on duration mismatch during verification', async () => {
321
+ vi.mocked(spawn)
322
+ .mockReturnValueOnce(createMockProcess())
323
+ .mockReturnValueOnce(createMockProcess({ stdoutData: '1.0\n' })) // expected 30s
324
+ await createSceneSegments('video.mp4', '/output', [0, 30], true)
325
+ expect(console.warn).toHaveBeenCalled()
326
+ })
327
+
328
+ it('calls process.exit(1) on segment creation failure', async () => {
329
+ const exitSpy = vi.spyOn(process, 'exit').mockImplementation(() => { throw new Error('exit') })
330
+ vi.mocked(spawn).mockReturnValueOnce(createMockProcess({ exitCode: 1 }))
331
+ await expect(createSceneSegments('video.mp4', '/output', [0, 5.2, 30])).rejects.toThrow('exit')
332
+ expect(exitSpy).toHaveBeenCalledWith(1)
333
+ })
334
+ })
@@ -27,7 +27,9 @@ describe('module-level ffmpeg/ffprobe check', () => {
27
27
  vi.doMock('../src/core.js', () => ({
28
28
  getVideoDuration: vi.fn(),
29
29
  createCountSegments: vi.fn(),
30
- createTimeSegments: vi.fn()
30
+ createTimeSegments: vi.fn(),
31
+ detectSceneChanges: vi.fn(),
32
+ createSceneSegments: vi.fn()
31
33
  }))
32
34
  vi.doMock('commander', () => {
33
35
  const mockProgram = {
@@ -17,7 +17,7 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
17
17
  import fs from 'fs'
18
18
  import inquirer from 'inquirer'
19
19
  import { Command } from 'commander'
20
- import { getVideoDuration, createCountSegments, createTimeSegments } from '../src/core.js'
20
+ import { getVideoDuration, createCountSegments, createTimeSegments, detectSceneChanges, createSceneSegments } from '../src/core.js'
21
21
  import { processVideo, setupCli } from '../index.js'
22
22
 
23
23
  vi.mock('child_process', () => ({
@@ -28,7 +28,9 @@ vi.mock('child_process', () => ({
28
28
  vi.mock('../src/core.js', () => ({
29
29
  getVideoDuration: vi.fn(),
30
30
  createCountSegments: vi.fn(),
31
- createTimeSegments: vi.fn()
31
+ createTimeSegments: vi.fn(),
32
+ detectSceneChanges: vi.fn(),
33
+ createSceneSegments: vi.fn()
32
34
  }))
33
35
 
34
36
  vi.mock('commander', () => {
@@ -229,6 +231,85 @@ describe('processVideo', () => {
229
231
  expect(createCountSegments).toHaveBeenCalledWith('video.mp4', 120, 2, '/out', true, true)
230
232
  })
231
233
  })
234
+
235
+ describe('scene-detect segmentation (--scene-detect)', () => {
236
+ it('calls detectSceneChanges with threshold 10 when sceneDetect is true', async () => {
237
+ vi.spyOn(fs, 'existsSync').mockReturnValue(true)
238
+ vi.mocked(getVideoDuration).mockResolvedValue(120)
239
+ vi.mocked(detectSceneChanges).mockResolvedValue([0, 5.2, 12.8])
240
+ vi.mocked(createSceneSegments).mockResolvedValue()
241
+
242
+ await processVideo({ input: 'video.mp4', output: '/out', sceneDetect: true })
243
+
244
+ expect(detectSceneChanges).toHaveBeenCalledWith('video.mp4', 10)
245
+ })
246
+
247
+ it('calls detectSceneChanges with parsed threshold when sceneDetect is a string', async () => {
248
+ vi.spyOn(fs, 'existsSync').mockReturnValue(true)
249
+ vi.mocked(getVideoDuration).mockResolvedValue(120)
250
+ vi.mocked(detectSceneChanges).mockResolvedValue([0, 5.2, 12.8])
251
+ vi.mocked(createSceneSegments).mockResolvedValue()
252
+
253
+ await processVideo({ input: 'video.mp4', output: '/out', sceneDetect: '25' })
254
+
255
+ expect(detectSceneChanges).toHaveBeenCalledWith('video.mp4', 25)
256
+ })
257
+
258
+ it('appends duration to boundaries before calling createSceneSegments', async () => {
259
+ vi.spyOn(fs, 'existsSync').mockReturnValue(true)
260
+ vi.mocked(getVideoDuration).mockResolvedValue(120)
261
+ vi.mocked(detectSceneChanges).mockResolvedValue([0, 5.2, 12.8])
262
+ vi.mocked(createSceneSegments).mockResolvedValue()
263
+
264
+ await processVideo({ input: 'video.mp4', output: '/out', sceneDetect: true })
265
+
266
+ expect(createSceneSegments).toHaveBeenCalledWith('video.mp4', '/out', [0, 5.2, 12.8, 120], false, false)
267
+ })
268
+
269
+ it('exits with code 0 and warns when only [0] is returned (no scenes)', async () => {
270
+ vi.spyOn(fs, 'existsSync').mockReturnValue(true)
271
+ vi.mocked(getVideoDuration).mockResolvedValue(120)
272
+ vi.mocked(detectSceneChanges).mockResolvedValue([0])
273
+ const exitSpy = vi.spyOn(process, 'exit').mockImplementation(() => { throw new Error('exit') })
274
+
275
+ await expect(processVideo({ input: 'video.mp4', output: '/out', sceneDetect: true })).rejects.toThrow('exit')
276
+ expect(exitSpy).toHaveBeenCalledWith(0)
277
+ expect(console.warn).toHaveBeenCalledWith(expect.stringContaining('No scene changes detected'))
278
+ })
279
+
280
+ it('warns about stream copy mode when not re-encoding', async () => {
281
+ vi.spyOn(fs, 'existsSync').mockReturnValue(true)
282
+ vi.mocked(getVideoDuration).mockResolvedValue(120)
283
+ vi.mocked(detectSceneChanges).mockResolvedValue([0, 5.2, 12.8])
284
+ vi.mocked(createSceneSegments).mockResolvedValue()
285
+
286
+ await processVideo({ input: 'video.mp4', output: '/out', sceneDetect: true })
287
+
288
+ expect(console.warn).toHaveBeenCalledWith(expect.stringContaining('stream copy mode'))
289
+ })
290
+
291
+ it('does not warn about stream copy when reEncode is true', async () => {
292
+ vi.spyOn(fs, 'existsSync').mockReturnValue(true)
293
+ vi.mocked(getVideoDuration).mockResolvedValue(120)
294
+ vi.mocked(detectSceneChanges).mockResolvedValue([0, 5.2, 12.8])
295
+ vi.mocked(createSceneSegments).mockResolvedValue()
296
+
297
+ await processVideo({ input: 'video.mp4', output: '/out', sceneDetect: true, reEncode: true })
298
+
299
+ expect(console.warn).not.toHaveBeenCalled()
300
+ })
301
+
302
+ it('passes verify and reEncode flags correctly to createSceneSegments', async () => {
303
+ vi.spyOn(fs, 'existsSync').mockReturnValue(true)
304
+ vi.mocked(getVideoDuration).mockResolvedValue(120)
305
+ vi.mocked(detectSceneChanges).mockResolvedValue([0, 5.2, 12.8])
306
+ vi.mocked(createSceneSegments).mockResolvedValue()
307
+
308
+ await processVideo({ input: 'video.mp4', output: '/out', sceneDetect: true, verify: true, reEncode: true })
309
+
310
+ expect(createSceneSegments).toHaveBeenCalledWith('video.mp4', '/out', [0, 5.2, 12.8, 120], true, true)
311
+ })
312
+ })
232
313
  })
233
314
 
234
315
  describe('setupCli', () => {