@shaztech/video-cutter 1.0.0 → 1.1.1

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.
package/README.md CHANGED
@@ -1,6 +1,9 @@
1
1
  # Video Cutter
2
2
 
3
- A simple CLI tool to cut a video file into equal-length segments using ffmpeg.
3
+ [![CI](https://github.com/shaztechio/video-cutter/actions/workflows/ci.yml/badge.svg)](https://github.com/shaztechio/video-cutter/actions/workflows/ci.yml)
4
+ [![npm version](https://img.shields.io/npm/v/@shaztech/video-cutter.svg)](https://www.npmjs.com/package/@shaztech/video-cutter)
5
+
6
+ A CLI tool to cut a video file into segments using ffmpeg — by equal count, fixed duration, or automatic scene change detection.
4
7
 
5
8
  ## Prerequisites
6
9
 
@@ -30,17 +33,19 @@ video-cutter [options]
30
33
 
31
34
  Options:
32
35
  ```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
36
+ -V, --version output the version number
37
+ -i, --input <path> input video file path
38
+ -n, --segments <number> number of segments to create
39
+ -d, --duration <seconds> duration of each segment in seconds
40
+ --scene-detect [threshold] cut at scene change boundaries (threshold
41
+ 0–100, default: 10)
42
+ -o, --output <path> output directory for segments (default:
43
+ ./output/YYYY-MM-DD_HH-MM-SS/)
44
+ --verify verify that each segment matches its intended
45
+ duration
46
+ --re-encode re-encode segments for exact duration (slower
47
+ but more accurate)
48
+ -h, --help display help for command
44
49
  ```
45
50
 
46
51
  ## Examples
@@ -63,6 +68,31 @@ This will create files named `seg_01_00-00-00.mp4`, `seg_02_00-00-30.mp4`, etc.
63
68
 
64
69
  **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
70
 
71
+ ### Cut a video at scene changes (automatic detection):
72
+
73
+ ```bash
74
+ video-cutter -i my_video.mp4 --scene-detect
75
+ ```
76
+
77
+ 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.
78
+
79
+ ### Cut at scene changes with a custom threshold:
80
+
81
+ ```bash
82
+ video-cutter -i my_video.mp4 --scene-detect 20
83
+ ```
84
+
85
+ The `scdet` filter computes a per-frame difference score (0–100) representing how different each frame is from the previous one — essentially the percentage of maximum possible pixel change across the frame.
86
+
87
+ - **Low threshold (e.g. 2–5):** Very sensitive — detects subtle transitions, fades, and gradual lighting changes. Produces many cuts, including false positives.
88
+ - **Default threshold (10):** Catches hard cuts reliably with few false positives. ffmpeg's own docs suggest the sweet spot is 8–14.
89
+ - **High threshold (e.g. 30–50):** Only detects dramatic, obvious scene changes. May miss some real cuts.
90
+ - **100:** Would never trigger (nothing is ever 100% different).
91
+
92
+ So for a cartoon with sharp hard cuts between scenes, the default of 10 should work well. If you're getting too many segments (spurious cuts on action frames), raise it to 20–30. If you're getting too few (missing real scene changes), lower it to 5–8.
93
+
94
+ **Note:** File paths containing `:` or `,` may cause scene detection to fail due to limitations in the ffmpeg lavfi filter string format.
95
+
66
96
  ### Specify custom output directory:
67
97
 
68
98
  ```bash
@@ -77,8 +107,10 @@ video-cutter -i my_video.mp4 -n 10 -o ./my_segments/
77
107
  - When creating segments shorter than 30 seconds, you'll be prompted for confirmation
78
108
  - Segment numbers are zero-padded to 3 digits (001, 002, etc.) for count-based segments
79
109
  - 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
110
+ - The `--segments`, `--duration`, and `--scene-detect` options are mutually exclusive
81
111
  - By default, the tool uses stream copying for fast segment creation, which may result in slight duration inaccuracies due to keyframe alignment
82
112
  - Use the `--re-encode` flag to re-encode segments for precise duration control (slower but exact)
83
113
  - 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
114
+ - When using both `--re-encode` and `--verify`, segments will have exact durations as specified
115
+ - Scene-detect segments are named `scene_001.mp4`, `scene_002.mp4`, etc.
116
+ - 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.1",
4
4
  "type": "module",
5
5
  "description": "A CLI tool to cut videos into segments using ffmpeg",
6
6
  "main": "index.js",
@@ -35,13 +35,16 @@
35
35
  "eslint": "^8.57.1",
36
36
  "eslint-config-standard": "^17.1.0",
37
37
  "eslint-plugin-import": "^2.32.0",
38
- "eslint-plugin-n": "^17.24.0",
39
- "eslint-plugin-promise": "^7.2.1",
38
+ "eslint-plugin-n": "^16.6.2",
39
+ "eslint-plugin-promise": "^6.6.0",
40
40
  "jsdom": "^28.1.0",
41
41
  "vitest": "^4.1.0"
42
42
  },
43
43
  "repository": {
44
44
  "type": "git",
45
- "url": "git+https://github.com/shazron/video-cutter.git"
45
+ "url": "git+https://github.com/shaztechio/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
  }
@@ -1,8 +0,0 @@
1
- {
2
- "permissions": {
3
- "allow": [
4
- "Bash(npm run:*)",
5
- "Bash(npx vitest:*)"
6
- ]
7
- }
8
- }
package/.eslintrc.json DELETED
@@ -1,11 +0,0 @@
1
- {
2
- "extends": "standard",
3
- "env": {
4
- "node": true,
5
- "es6": true
6
- },
7
- "rules": {
8
- "semi": ["error", "never"],
9
- "quotes": ["error", "single"]
10
- }
11
- }
@@ -1,113 +0,0 @@
1
- /*
2
- * Licensed under the Apache License, Version 2.0 (the "License");
3
- * you may not use this file except in compliance with the License.
4
- * You may obtain a copy of the License at
5
- *
6
- * http://www.apache.org/licenses/LICENSE-2.0
7
- *
8
- * Unless required by applicable law or agreed to in writing, software
9
- * distributed under the License is distributed on an "AS IS" BASIS,
10
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
11
- * See the License for the specific language governing permissions and
12
- * limitations under the License.
13
- */
14
-
15
- // Tests that index.js calls setupCli() when invoked as the main entry point.
16
- // This file has its own module registry so vi.doMock works correctly.
17
-
18
- import { describe, it, expect, vi, afterEach } from 'vitest'
19
- import path from 'path'
20
- import { fileURLToPath } from 'url'
21
-
22
- describe('index.js invoked directly as main module', () => {
23
- afterEach(() => vi.resetModules())
24
-
25
- it('calls program.parse() when process.argv[1] matches the module path', async () => {
26
- const parseMock = vi.fn()
27
-
28
- vi.doMock('child_process', () => ({
29
- execSync: vi.fn(), // does not throw → ffmpeg available
30
- spawn: vi.fn()
31
- }))
32
- vi.doMock('../src/core.js', () => ({
33
- getVideoDuration: vi.fn(),
34
- createCountSegments: vi.fn(),
35
- createTimeSegments: vi.fn()
36
- }))
37
- vi.doMock('commander', () => {
38
- const mockProgram = {
39
- name: vi.fn().mockReturnThis(),
40
- description: vi.fn().mockReturnThis(),
41
- version: vi.fn().mockReturnThis(),
42
- requiredOption: vi.fn().mockReturnThis(),
43
- addOption: vi.fn().mockReturnThis(),
44
- option: vi.fn().mockReturnThis(),
45
- action: vi.fn().mockReturnThis(),
46
- parse: parseMock
47
- }
48
- return {
49
- // eslint-disable-next-line prefer-arrow-callback
50
- Command: vi.fn(function MockCommand () { return mockProgram }),
51
- // eslint-disable-next-line prefer-arrow-callback
52
- Option: vi.fn(function MockOption () { return { conflicts: vi.fn().mockReturnThis() } })
53
- }
54
- })
55
-
56
- // Simulate running `node index.js` by setting argv[1] to the index.js path
57
- const indexPath = path.resolve(fileURLToPath(new URL('../index.js', import.meta.url)))
58
- const originalArgv1 = process.argv[1]
59
- process.argv[1] = indexPath
60
-
61
- try {
62
- await import('../index.js')
63
- } finally {
64
- process.argv[1] = originalArgv1
65
- }
66
-
67
- expect(parseMock).toHaveBeenCalled()
68
- })
69
-
70
- it('does not call setupCli when process.argv[1] is empty (covers falsy argv branch)', async () => {
71
- vi.doMock('child_process', () => ({
72
- execSync: vi.fn(),
73
- spawn: vi.fn()
74
- }))
75
- vi.doMock('../src/core.js', () => ({
76
- getVideoDuration: vi.fn(),
77
- createCountSegments: vi.fn(),
78
- createTimeSegments: vi.fn()
79
- }))
80
- const parseMock = vi.fn()
81
- vi.doMock('commander', () => {
82
- const mockProgram = {
83
- name: vi.fn().mockReturnThis(),
84
- description: vi.fn().mockReturnThis(),
85
- version: vi.fn().mockReturnThis(),
86
- requiredOption: vi.fn().mockReturnThis(),
87
- addOption: vi.fn().mockReturnThis(),
88
- option: vi.fn().mockReturnThis(),
89
- action: vi.fn().mockReturnThis(),
90
- parse: parseMock
91
- }
92
- return {
93
- // eslint-disable-next-line prefer-arrow-callback
94
- Command: vi.fn(function MockCommand () { return mockProgram }),
95
- // eslint-disable-next-line prefer-arrow-callback
96
- Option: vi.fn(function MockOption () { return { conflicts: vi.fn().mockReturnThis() } })
97
- }
98
- })
99
-
100
- // Empty argv[1] triggers the `? ... : ''` false branch in index.js
101
- const originalArgv1 = process.argv[1]
102
- process.argv[1] = ''
103
-
104
- try {
105
- await import('../index.js')
106
- } finally {
107
- process.argv[1] = originalArgv1
108
- }
109
-
110
- // setupCli is not called because mainModule ('') !== path to index.js
111
- expect(parseMock).not.toHaveBeenCalled()
112
- })
113
- })
@@ -1,201 +0,0 @@
1
- /*
2
- * Licensed under the Apache License, Version 2.0 (the "License");
3
- * you may not use this file except in compliance with the License.
4
- * You may obtain a copy of the License at
5
- *
6
- * http://www.apache.org/licenses/LICENSE-2.0
7
- *
8
- * Unless required by applicable law or agreed to in writing, software
9
- * distributed under the License is distributed on an "AS IS" BASIS,
10
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
11
- * See the License for the specific language governing permissions and
12
- * limitations under the License.
13
- */
14
-
15
- import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
16
- import { EventEmitter } from 'events'
17
-
18
- import { spawn } from 'child_process'
19
- import { getVideoDuration, createSegment, createCountSegments, createTimeSegments } from '../src/core.js'
20
-
21
- vi.mock('child_process', () => ({
22
- spawn: vi.fn()
23
- }))
24
-
25
- function createMockProcess ({ stdoutData = null, stderrData = null, exitCode = 0 } = {}) {
26
- const proc = new EventEmitter()
27
- proc.stdout = new EventEmitter()
28
- proc.stderr = new EventEmitter()
29
- setTimeout(() => {
30
- if (stdoutData !== null) proc.stdout.emit('data', Buffer.from(stdoutData))
31
- if (stderrData !== null) proc.stderr.emit('data', Buffer.from(stderrData))
32
- proc.emit('close', exitCode)
33
- }, 0)
34
- return proc
35
- }
36
-
37
- describe('getVideoDuration', () => {
38
- beforeEach(() => vi.clearAllMocks())
39
- afterEach(() => vi.restoreAllMocks())
40
-
41
- it('resolves with parsed duration on success', async () => {
42
- vi.mocked(spawn).mockReturnValueOnce(createMockProcess({ stdoutData: '120.5\n' }))
43
- const duration = await getVideoDuration('video.mp4')
44
- expect(duration).toBe(120.5)
45
- })
46
-
47
- it('handles stderr data without error', async () => {
48
- vi.mocked(spawn).mockReturnValueOnce(createMockProcess({ stdoutData: '90.0\n', stderrData: 'some warning' }))
49
- const duration = await getVideoDuration('video.mp4')
50
- expect(duration).toBe(90.0)
51
- })
52
-
53
- it('rejects on non-zero exit code', async () => {
54
- vi.mocked(spawn).mockReturnValueOnce(createMockProcess({ exitCode: 1 }))
55
- await expect(getVideoDuration('video.mp4')).rejects.toThrow('ffprobe exited with code 1')
56
- })
57
-
58
- it('rejects when output cannot be parsed as number', async () => {
59
- vi.mocked(spawn).mockReturnValueOnce(createMockProcess({ stdoutData: 'invalid_output', exitCode: 0 }))
60
- await expect(getVideoDuration('video.mp4')).rejects.toThrow('Could not parse duration')
61
- })
62
- })
63
-
64
- describe('createSegment', () => {
65
- beforeEach(() => vi.clearAllMocks())
66
- afterEach(() => vi.restoreAllMocks())
67
-
68
- it('creates segment using stream copy by default (reEncode=false)', async () => {
69
- vi.mocked(spawn).mockReturnValueOnce(createMockProcess())
70
- await expect(createSegment('video.mp4', 0, 60, 'out/seg.mp4', false)).resolves.toBeUndefined()
71
- expect(spawn).toHaveBeenCalledWith('ffmpeg', expect.arrayContaining(['-c', 'copy']))
72
- })
73
-
74
- it('creates segment with re-encoding when reEncode=true', async () => {
75
- vi.mocked(spawn).mockReturnValueOnce(createMockProcess())
76
- await expect(createSegment('video.mp4', 0, 60, 'out/seg.mp4', true)).resolves.toBeUndefined()
77
- expect(spawn).toHaveBeenCalledWith('ffmpeg', expect.arrayContaining(['-c:v', 'libx264']))
78
- })
79
-
80
- it('handles stdout and stderr data', async () => {
81
- vi.mocked(spawn).mockReturnValueOnce(createMockProcess({ stdoutData: 'progress', stderrData: 'info' }))
82
- await expect(createSegment('video.mp4', 0, 60, 'out/seg.mp4')).resolves.toBeUndefined()
83
- })
84
-
85
- it('rejects on non-zero exit code', async () => {
86
- vi.mocked(spawn).mockReturnValueOnce(createMockProcess({ exitCode: 1 }))
87
- await expect(createSegment('video.mp4', 0, 60, 'out/seg.mp4')).rejects.toThrow('ffmpeg exited with code 1')
88
- })
89
- })
90
-
91
- describe('createCountSegments', () => {
92
- beforeEach(() => {
93
- vi.clearAllMocks()
94
- vi.spyOn(console, 'log').mockImplementation(() => {})
95
- vi.spyOn(console, 'warn').mockImplementation(() => {})
96
- vi.spyOn(console, 'error').mockImplementation(() => {})
97
- })
98
- afterEach(() => vi.restoreAllMocks())
99
-
100
- it('creates segments without verification', async () => {
101
- vi.mocked(spawn)
102
- .mockReturnValueOnce(createMockProcess())
103
- .mockReturnValueOnce(createMockProcess())
104
- await expect(createCountSegments('video.mp4', 120, 2, '/output')).resolves.toBeUndefined()
105
- expect(spawn).toHaveBeenCalledTimes(2)
106
- })
107
-
108
- it('verifies valid segment durations within stream copy tolerance (1s)', async () => {
109
- vi.mocked(spawn)
110
- .mockReturnValueOnce(createMockProcess())
111
- .mockReturnValueOnce(createMockProcess({ stdoutData: '120.0\n' }))
112
- await expect(createCountSegments('video.mp4', 120, 1, '/output', true)).resolves.toBeUndefined()
113
- })
114
-
115
- it('warns when verified segment duration is outside tolerance', async () => {
116
- vi.mocked(spawn)
117
- .mockReturnValueOnce(createMockProcess())
118
- .mockReturnValueOnce(createMockProcess({ stdoutData: '117.0\n' })) // 3s diff > 1s tolerance
119
- await createCountSegments('video.mp4', 120, 1, '/output', true)
120
- expect(console.warn).toHaveBeenCalled()
121
- })
122
-
123
- it('uses 0.1s tolerance when re-encoding and warns on invalid segments', async () => {
124
- vi.mocked(spawn)
125
- .mockReturnValueOnce(createMockProcess())
126
- .mockReturnValueOnce(createMockProcess({ stdoutData: '119.5\n' })) // 0.5s diff > 0.1s tolerance
127
- await createCountSegments('video.mp4', 120, 1, '/output', true, true)
128
- expect(console.warn).toHaveBeenCalled()
129
- })
130
-
131
- it('logs success when verified segment is within re-encode tolerance', async () => {
132
- vi.mocked(spawn)
133
- .mockReturnValueOnce(createMockProcess())
134
- .mockReturnValueOnce(createMockProcess({ stdoutData: '120.05\n' })) // 0.05s diff <= 0.1s tolerance
135
- await createCountSegments('video.mp4', 120, 1, '/output', true, true)
136
- expect(console.log).toHaveBeenCalledWith(expect.stringContaining('Verified'))
137
- })
138
-
139
- it('calls process.exit(1) when segment creation fails', async () => {
140
- const exitSpy = vi.spyOn(process, 'exit').mockImplementation(() => { throw new Error('exit') })
141
- vi.mocked(spawn).mockReturnValueOnce(createMockProcess({ exitCode: 1 }))
142
- await expect(createCountSegments('video.mp4', 120, 1, '/output')).rejects.toThrow('exit')
143
- expect(exitSpy).toHaveBeenCalledWith(1)
144
- })
145
- })
146
-
147
- describe('createTimeSegments', () => {
148
- beforeEach(() => {
149
- vi.clearAllMocks()
150
- vi.spyOn(console, 'log').mockImplementation(() => {})
151
- vi.spyOn(console, 'warn').mockImplementation(() => {})
152
- vi.spyOn(console, 'error').mockImplementation(() => {})
153
- })
154
- afterEach(() => vi.restoreAllMocks())
155
-
156
- it('creates segments without verification', async () => {
157
- vi.mocked(spawn)
158
- .mockReturnValueOnce(createMockProcess())
159
- .mockReturnValueOnce(createMockProcess())
160
- await expect(createTimeSegments('video.mp4', 120, 60, '/output')).resolves.toBeUndefined()
161
- expect(spawn).toHaveBeenCalledTimes(2)
162
- })
163
-
164
- it('verifies valid segment durations', async () => {
165
- vi.mocked(spawn)
166
- .mockReturnValueOnce(createMockProcess())
167
- .mockReturnValueOnce(createMockProcess({ stdoutData: '60.0\n' }))
168
- await expect(createTimeSegments('video.mp4', 60, 60, '/output', true)).resolves.toBeUndefined()
169
- })
170
-
171
- it('warns when verified segment duration is outside tolerance', async () => {
172
- vi.mocked(spawn)
173
- .mockReturnValueOnce(createMockProcess())
174
- .mockReturnValueOnce(createMockProcess({ stdoutData: '57.0\n' })) // 3s diff > 1s tolerance
175
- await createTimeSegments('video.mp4', 60, 60, '/output', true)
176
- expect(console.warn).toHaveBeenCalled()
177
- })
178
-
179
- it('uses 0.1s tolerance when re-encoding and warns on invalid segments', async () => {
180
- vi.mocked(spawn)
181
- .mockReturnValueOnce(createMockProcess())
182
- .mockReturnValueOnce(createMockProcess({ stdoutData: '59.5\n' })) // 0.5s diff > 0.1s tolerance
183
- await createTimeSegments('video.mp4', 60, 60, '/output', true, true)
184
- expect(console.warn).toHaveBeenCalled()
185
- })
186
-
187
- it('logs success when verified segment is within re-encode tolerance', async () => {
188
- vi.mocked(spawn)
189
- .mockReturnValueOnce(createMockProcess())
190
- .mockReturnValueOnce(createMockProcess({ stdoutData: '60.05\n' })) // 0.05s diff <= 0.1s tolerance
191
- await createTimeSegments('video.mp4', 60, 60, '/output', true, true)
192
- expect(console.log).toHaveBeenCalledWith(expect.stringContaining('Verified'))
193
- })
194
-
195
- it('calls process.exit(1) when segment creation fails', async () => {
196
- const exitSpy = vi.spyOn(process, 'exit').mockImplementation(() => { throw new Error('exit') })
197
- vi.mocked(spawn).mockReturnValueOnce(createMockProcess({ exitCode: 1 }))
198
- await expect(createTimeSegments('video.mp4', 60, 60, '/output')).rejects.toThrow('exit')
199
- expect(exitSpy).toHaveBeenCalledWith(1)
200
- })
201
- })
@@ -1,63 +0,0 @@
1
- /*
2
- * Licensed under the Apache License, Version 2.0 (the "License");
3
- * you may not use this file except in compliance with the License.
4
- * You may obtain a copy of the License at
5
- *
6
- * http://www.apache.org/licenses/LICENSE-2.0
7
- *
8
- * Unless required by applicable law or agreed to in writing, software
9
- * distributed under the License is distributed on an "AS IS" BASIS,
10
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
11
- * See the License for the specific language governing permissions and
12
- * limitations under the License.
13
- */
14
-
15
- // This file tests the module-level ffmpeg/ffprobe availability check in index.js.
16
- // It uses vi.doMock (not hoisted) so that the mock is applied before the dynamic import.
17
-
18
- import { describe, it, expect, vi } from 'vitest'
19
-
20
- describe('module-level ffmpeg/ffprobe check', () => {
21
- it('exits with code 1 when ffmpeg is not installed', async () => {
22
- vi.doMock('child_process', () => ({
23
- // eslint-disable-next-line prefer-arrow-callback
24
- execSync: vi.fn(function execSync () { throw new Error('command not found') }),
25
- spawn: vi.fn()
26
- }))
27
- vi.doMock('../src/core.js', () => ({
28
- getVideoDuration: vi.fn(),
29
- createCountSegments: vi.fn(),
30
- createTimeSegments: vi.fn()
31
- }))
32
- vi.doMock('commander', () => {
33
- const mockProgram = {
34
- name: vi.fn().mockReturnThis(),
35
- description: vi.fn().mockReturnThis(),
36
- version: vi.fn().mockReturnThis(),
37
- requiredOption: vi.fn().mockReturnThis(),
38
- addOption: vi.fn().mockReturnThis(),
39
- option: vi.fn().mockReturnThis(),
40
- action: vi.fn().mockReturnThis(),
41
- parse: vi.fn()
42
- }
43
- return {
44
- // eslint-disable-next-line prefer-arrow-callback
45
- Command: vi.fn(function MockCommand () { return mockProgram }),
46
- // eslint-disable-next-line prefer-arrow-callback
47
- Option: vi.fn(function MockOption () { return { conflicts: vi.fn().mockReturnThis() } })
48
- }
49
- })
50
-
51
- const exitSpy = vi.spyOn(process, 'exit').mockImplementation(() => { throw new Error('exit') })
52
- vi.spyOn(console, 'error').mockImplementation(() => {})
53
-
54
- try {
55
- await import('../index.js')
56
- } catch (e) {
57
- // expected: process.exit() throws in test environment
58
- }
59
-
60
- expect(exitSpy).toHaveBeenCalledWith(1)
61
- vi.restoreAllMocks()
62
- })
63
- })
@@ -1,250 +0,0 @@
1
- /*
2
- * Licensed under the Apache License, Version 2.0 (the "License");
3
- * you may not use this file except in compliance with the License.
4
- * You may obtain a copy of the License at
5
- *
6
- * http://www.apache.org/licenses/LICENSE-2.0
7
- *
8
- * Unless required by applicable law or agreed to in writing, software
9
- * distributed under the License is distributed on an "AS IS" BASIS,
10
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
11
- * See the License for the specific language governing permissions and
12
- * limitations under the License.
13
- */
14
-
15
- import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
16
-
17
- import fs from 'fs'
18
- import inquirer from 'inquirer'
19
- import { Command } from 'commander'
20
- import { getVideoDuration, createCountSegments, createTimeSegments } from '../src/core.js'
21
- import { processVideo, setupCli } from '../index.js'
22
-
23
- vi.mock('child_process', () => ({
24
- execSync: vi.fn(), // doesn't throw = ffmpeg/ffprobe available
25
- spawn: vi.fn()
26
- }))
27
-
28
- vi.mock('../src/core.js', () => ({
29
- getVideoDuration: vi.fn(),
30
- createCountSegments: vi.fn(),
31
- createTimeSegments: vi.fn()
32
- }))
33
-
34
- vi.mock('commander', () => {
35
- const mockProgram = {
36
- name: vi.fn().mockReturnThis(),
37
- description: vi.fn().mockReturnThis(),
38
- version: vi.fn().mockReturnThis(),
39
- requiredOption: vi.fn().mockReturnThis(),
40
- addOption: vi.fn().mockReturnThis(),
41
- option: vi.fn().mockReturnThis(),
42
- action: vi.fn().mockReturnThis(),
43
- parse: vi.fn()
44
- }
45
- // eslint-disable-next-line prefer-arrow-callback
46
- const MockOption = vi.fn(function MockOption () { return { conflicts: vi.fn().mockReturnThis() } })
47
- // eslint-disable-next-line prefer-arrow-callback
48
- return { Command: vi.fn(function MockCommand () { return mockProgram }), Option: MockOption }
49
- })
50
-
51
- describe('processVideo', () => {
52
- beforeEach(() => {
53
- vi.clearAllMocks()
54
- vi.spyOn(console, 'log').mockImplementation(() => {})
55
- vi.spyOn(console, 'warn').mockImplementation(() => {})
56
- vi.spyOn(console, 'error').mockImplementation(() => {})
57
- })
58
- afterEach(() => vi.restoreAllMocks())
59
-
60
- it('exits when input file does not exist', async () => {
61
- vi.spyOn(fs, 'existsSync').mockReturnValueOnce(false)
62
- const exitSpy = vi.spyOn(process, 'exit').mockImplementation(() => { throw new Error('exit') })
63
- await expect(processVideo({ input: 'missing.mp4', segments: 2, output: '/out' })).rejects.toThrow('exit')
64
- expect(exitSpy).toHaveBeenCalledWith(1)
65
- })
66
-
67
- it('creates default output directory path when none provided', async () => {
68
- vi.spyOn(fs, 'existsSync')
69
- .mockReturnValueOnce(true) // input file exists
70
- .mockReturnValueOnce(false) // output dir does not exist
71
- vi.spyOn(fs, 'mkdirSync').mockImplementation(() => {})
72
- vi.mocked(getVideoDuration).mockResolvedValue(120)
73
- vi.mocked(createCountSegments).mockResolvedValue()
74
-
75
- await processVideo({ input: 'video.mp4', segments: 2 })
76
-
77
- expect(fs.mkdirSync).toHaveBeenCalledWith(expect.stringContaining('output'), { recursive: true })
78
- })
79
-
80
- it('creates output directory when it does not exist', async () => {
81
- vi.spyOn(fs, 'existsSync')
82
- .mockReturnValueOnce(true) // input file exists
83
- .mockReturnValueOnce(false) // output dir does not exist
84
- vi.spyOn(fs, 'mkdirSync').mockImplementation(() => {})
85
- vi.mocked(getVideoDuration).mockResolvedValue(120)
86
- vi.mocked(createCountSegments).mockResolvedValue()
87
-
88
- await processVideo({ input: 'video.mp4', segments: 2, output: '/out' })
89
-
90
- expect(fs.mkdirSync).toHaveBeenCalledWith('/out', { recursive: true })
91
- })
92
-
93
- it('exits when video duration cannot be determined (falsy)', async () => {
94
- vi.spyOn(fs, 'existsSync').mockReturnValue(true)
95
- vi.mocked(getVideoDuration).mockResolvedValue(0)
96
- const exitSpy = vi.spyOn(process, 'exit').mockImplementation(() => { throw new Error('exit') })
97
- await expect(processVideo({ input: 'video.mp4', segments: 2, output: '/out' })).rejects.toThrow('exit')
98
- expect(exitSpy).toHaveBeenCalledWith(1)
99
- })
100
-
101
- it('exits when getVideoDuration throws', async () => {
102
- vi.spyOn(fs, 'existsSync').mockReturnValue(true)
103
- vi.mocked(getVideoDuration).mockRejectedValue(new Error('ffprobe failed'))
104
- const exitSpy = vi.spyOn(process, 'exit').mockImplementation(() => { throw new Error('exit') })
105
- await expect(processVideo({ input: 'video.mp4', segments: 2, output: '/out' })).rejects.toThrow('exit')
106
- expect(exitSpy).toHaveBeenCalledWith(1)
107
- })
108
-
109
- describe('time-based segmentation (--duration)', () => {
110
- it('warns about stream copy mode when not re-encoding', async () => {
111
- vi.spyOn(fs, 'existsSync').mockReturnValue(true)
112
- vi.mocked(getVideoDuration).mockResolvedValue(120)
113
- vi.mocked(createTimeSegments).mockResolvedValue()
114
-
115
- await processVideo({ input: 'video.mp4', duration: 60, output: '/out' })
116
-
117
- expect(console.warn).toHaveBeenCalledWith(expect.stringContaining('stream copy mode'))
118
- expect(createTimeSegments).toHaveBeenCalledWith('video.mp4', 120, 60, '/out', false, false)
119
- })
120
-
121
- it('does not warn when re-encoding', async () => {
122
- vi.spyOn(fs, 'existsSync').mockReturnValue(true)
123
- vi.mocked(getVideoDuration).mockResolvedValue(120)
124
- vi.mocked(createTimeSegments).mockResolvedValue()
125
-
126
- await processVideo({ input: 'video.mp4', duration: 60, output: '/out', reEncode: true })
127
-
128
- expect(console.warn).not.toHaveBeenCalled()
129
- expect(createTimeSegments).toHaveBeenCalledWith('video.mp4', 120, 60, '/out', false, true)
130
- })
131
-
132
- it('prompts user for short segments (<30s) and proceeds when confirmed', async () => {
133
- vi.spyOn(fs, 'existsSync').mockReturnValue(true)
134
- vi.mocked(getVideoDuration).mockResolvedValue(60)
135
- vi.mocked(createTimeSegments).mockResolvedValue()
136
- vi.spyOn(inquirer, 'prompt').mockResolvedValue({ confirm: true })
137
-
138
- await processVideo({ input: 'video.mp4', duration: 10, output: '/out' })
139
-
140
- expect(inquirer.prompt).toHaveBeenCalled()
141
- expect(createTimeSegments).toHaveBeenCalled()
142
- })
143
-
144
- it('exits when user cancels short segment prompt', async () => {
145
- vi.spyOn(fs, 'existsSync').mockReturnValue(true)
146
- vi.mocked(getVideoDuration).mockResolvedValue(60)
147
- vi.spyOn(inquirer, 'prompt').mockResolvedValue({ confirm: false })
148
- const exitSpy = vi.spyOn(process, 'exit').mockImplementation(() => { throw new Error('exit') })
149
-
150
- await expect(processVideo({ input: 'video.mp4', duration: 10, output: '/out' })).rejects.toThrow('exit')
151
- expect(exitSpy).toHaveBeenCalledWith(0)
152
- })
153
- })
154
-
155
- describe('count-based segmentation (--segments)', () => {
156
- it('warns about stream copy mode when segments >= 30s', async () => {
157
- vi.spyOn(fs, 'existsSync').mockReturnValue(true)
158
- vi.mocked(getVideoDuration).mockResolvedValue(120)
159
- vi.mocked(createCountSegments).mockResolvedValue()
160
-
161
- await processVideo({ input: 'video.mp4', segments: 2, output: '/out' })
162
-
163
- expect(console.warn).toHaveBeenCalledWith(expect.stringContaining('stream copy mode'))
164
- expect(createCountSegments).toHaveBeenCalledWith('video.mp4', 120, 2, '/out', false, false)
165
- })
166
-
167
- it('does not warn when re-encoding with segments >= 30s', async () => {
168
- vi.spyOn(fs, 'existsSync').mockReturnValue(true)
169
- vi.mocked(getVideoDuration).mockResolvedValue(120)
170
- vi.mocked(createCountSegments).mockResolvedValue()
171
-
172
- await processVideo({ input: 'video.mp4', segments: 2, output: '/out', reEncode: true })
173
-
174
- expect(console.warn).not.toHaveBeenCalled()
175
- })
176
-
177
- it('warns about stream copy mode when segments < 30s and not re-encoding', async () => {
178
- vi.spyOn(fs, 'existsSync').mockReturnValue(true)
179
- vi.mocked(getVideoDuration).mockResolvedValue(60)
180
- vi.mocked(createCountSegments).mockResolvedValue()
181
- vi.spyOn(inquirer, 'prompt').mockResolvedValue({ confirm: true })
182
-
183
- await processVideo({ input: 'video.mp4', segments: 10, output: '/out' })
184
-
185
- expect(console.warn).toHaveBeenCalledWith(expect.stringContaining('stream copy mode'))
186
- })
187
-
188
- it('does not warn when re-encoding with short count segments', async () => {
189
- vi.spyOn(fs, 'existsSync').mockReturnValue(true)
190
- vi.mocked(getVideoDuration).mockResolvedValue(60)
191
- vi.mocked(createCountSegments).mockResolvedValue()
192
- vi.spyOn(inquirer, 'prompt').mockResolvedValue({ confirm: true })
193
-
194
- await processVideo({ input: 'video.mp4', segments: 10, output: '/out', reEncode: true })
195
-
196
- expect(console.warn).not.toHaveBeenCalled()
197
- expect(createCountSegments).toHaveBeenCalled()
198
- })
199
-
200
- it('prompts user for short segments and proceeds when confirmed', async () => {
201
- vi.spyOn(fs, 'existsSync').mockReturnValue(true)
202
- vi.mocked(getVideoDuration).mockResolvedValue(60)
203
- vi.mocked(createCountSegments).mockResolvedValue()
204
- vi.spyOn(inquirer, 'prompt').mockResolvedValue({ confirm: true })
205
-
206
- await processVideo({ input: 'video.mp4', segments: 10, output: '/out' })
207
-
208
- expect(inquirer.prompt).toHaveBeenCalled()
209
- expect(createCountSegments).toHaveBeenCalled()
210
- })
211
-
212
- it('exits when user cancels short segment prompt', async () => {
213
- vi.spyOn(fs, 'existsSync').mockReturnValue(true)
214
- vi.mocked(getVideoDuration).mockResolvedValue(60)
215
- vi.spyOn(inquirer, 'prompt').mockResolvedValue({ confirm: false })
216
- const exitSpy = vi.spyOn(process, 'exit').mockImplementation(() => { throw new Error('exit') })
217
-
218
- await expect(processVideo({ input: 'video.mp4', segments: 10, output: '/out' })).rejects.toThrow('exit')
219
- expect(exitSpy).toHaveBeenCalledWith(0)
220
- })
221
-
222
- it('passes verify and reEncode flags correctly', async () => {
223
- vi.spyOn(fs, 'existsSync').mockReturnValue(true)
224
- vi.mocked(getVideoDuration).mockResolvedValue(120)
225
- vi.mocked(createCountSegments).mockResolvedValue()
226
-
227
- await processVideo({ input: 'video.mp4', segments: 2, output: '/out', verify: true, reEncode: true })
228
-
229
- expect(createCountSegments).toHaveBeenCalledWith('video.mp4', 120, 2, '/out', true, true)
230
- })
231
- })
232
- })
233
-
234
- describe('setupCli', () => {
235
- beforeEach(() => {
236
- vi.clearAllMocks()
237
- vi.spyOn(console, 'log').mockImplementation(() => {})
238
- })
239
- afterEach(() => vi.restoreAllMocks())
240
-
241
- it('configures CLI with correct name, version, and options then calls parse', () => {
242
- setupCli()
243
- const program = vi.mocked(Command).mock.results[0].value
244
- expect(program.name).toHaveBeenCalledWith('video-cutter')
245
- expect(program.version).toHaveBeenCalledWith('1.0.0')
246
- expect(program.requiredOption).toHaveBeenCalledWith('-i, --input <path>', expect.any(String))
247
- expect(program.action).toHaveBeenCalledWith(processVideo)
248
- expect(program.parse).toHaveBeenCalled()
249
- })
250
- })
package/vitest.config.js DELETED
@@ -1,13 +0,0 @@
1
- import { defineConfig } from 'vitest/config'
2
-
3
- export default defineConfig({
4
- test: {
5
- globals: true,
6
- environment: 'node',
7
- coverage: {
8
- provider: 'v8',
9
- reporter: ['text', 'html', 'lcov'],
10
- exclude: ['**/node_modules/**', '**/dist/**', '**/*.spec.js', 'vitest.config.js']
11
- }
12
- }
13
- })