@shaztech/video-cutter 1.1.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,5 +1,8 @@
1
1
  # Video Cutter
2
2
 
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
+
3
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
@@ -79,15 +82,14 @@ This detects hard cuts between scenes and creates one segment per scene, named `
79
82
  video-cutter -i my_video.mp4 --scene-detect 20
80
83
  ```
81
84
 
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:
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.
83
86
 
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 |
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).
89
91
 
90
- The default is **10**. Raise it if you are getting too many segments; lower it if real cuts are being missed.
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.
91
93
 
92
94
  **Note:** File paths containing `:` or `,` may cause scene detection to fail due to limitations in the ffmpeg lavfi filter string format.
93
95
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@shaztech/video-cutter",
3
- "version": "1.1.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,14 +35,14 @@
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
46
  },
47
47
  "publishConfig": {
48
48
  "access": "public"
@@ -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,19 +0,0 @@
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
@@ -1,21 +0,0 @@
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 }}
@@ -1,117 +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
- detectSceneChanges: vi.fn(),
37
- createSceneSegments: vi.fn()
38
- }))
39
- vi.doMock('commander', () => {
40
- const mockProgram = {
41
- name: vi.fn().mockReturnThis(),
42
- description: vi.fn().mockReturnThis(),
43
- version: vi.fn().mockReturnThis(),
44
- requiredOption: vi.fn().mockReturnThis(),
45
- addOption: vi.fn().mockReturnThis(),
46
- option: vi.fn().mockReturnThis(),
47
- action: vi.fn().mockReturnThis(),
48
- parse: parseMock
49
- }
50
- return {
51
- // eslint-disable-next-line prefer-arrow-callback
52
- Command: vi.fn(function MockCommand () { return mockProgram }),
53
- // eslint-disable-next-line prefer-arrow-callback
54
- Option: vi.fn(function MockOption () { return { conflicts: vi.fn().mockReturnThis() } })
55
- }
56
- })
57
-
58
- // Simulate running `node index.js` by setting argv[1] to the index.js path
59
- const indexPath = path.resolve(fileURLToPath(new URL('../index.js', import.meta.url)))
60
- const originalArgv1 = process.argv[1]
61
- process.argv[1] = indexPath
62
-
63
- try {
64
- await import('../index.js')
65
- } finally {
66
- process.argv[1] = originalArgv1
67
- }
68
-
69
- expect(parseMock).toHaveBeenCalled()
70
- })
71
-
72
- it('does not call setupCli when process.argv[1] is empty (covers falsy argv branch)', async () => {
73
- vi.doMock('child_process', () => ({
74
- execSync: vi.fn(),
75
- spawn: vi.fn()
76
- }))
77
- vi.doMock('../src/core.js', () => ({
78
- getVideoDuration: vi.fn(),
79
- createCountSegments: vi.fn(),
80
- createTimeSegments: vi.fn(),
81
- detectSceneChanges: vi.fn(),
82
- createSceneSegments: vi.fn()
83
- }))
84
- const parseMock = vi.fn()
85
- vi.doMock('commander', () => {
86
- const mockProgram = {
87
- name: vi.fn().mockReturnThis(),
88
- description: vi.fn().mockReturnThis(),
89
- version: vi.fn().mockReturnThis(),
90
- requiredOption: vi.fn().mockReturnThis(),
91
- addOption: vi.fn().mockReturnThis(),
92
- option: vi.fn().mockReturnThis(),
93
- action: vi.fn().mockReturnThis(),
94
- parse: parseMock
95
- }
96
- return {
97
- // eslint-disable-next-line prefer-arrow-callback
98
- Command: vi.fn(function MockCommand () { return mockProgram }),
99
- // eslint-disable-next-line prefer-arrow-callback
100
- Option: vi.fn(function MockOption () { return { conflicts: vi.fn().mockReturnThis() } })
101
- }
102
- })
103
-
104
- // Empty argv[1] triggers the `? ... : ''` false branch in index.js
105
- const originalArgv1 = process.argv[1]
106
- process.argv[1] = ''
107
-
108
- try {
109
- await import('../index.js')
110
- } finally {
111
- process.argv[1] = originalArgv1
112
- }
113
-
114
- // setupCli is not called because mainModule ('') !== path to index.js
115
- expect(parseMock).not.toHaveBeenCalled()
116
- })
117
- })
@@ -1,334 +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, detectSceneChanges, createSceneSegments } 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
- })
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
- })
@@ -1,65 +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
- detectSceneChanges: vi.fn(),
32
- createSceneSegments: vi.fn()
33
- }))
34
- vi.doMock('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
- return {
46
- // eslint-disable-next-line prefer-arrow-callback
47
- Command: vi.fn(function MockCommand () { return mockProgram }),
48
- // eslint-disable-next-line prefer-arrow-callback
49
- Option: vi.fn(function MockOption () { return { conflicts: vi.fn().mockReturnThis() } })
50
- }
51
- })
52
-
53
- const exitSpy = vi.spyOn(process, 'exit').mockImplementation(() => { throw new Error('exit') })
54
- vi.spyOn(console, 'error').mockImplementation(() => {})
55
-
56
- try {
57
- await import('../index.js')
58
- } catch (e) {
59
- // expected: process.exit() throws in test environment
60
- }
61
-
62
- expect(exitSpy).toHaveBeenCalledWith(1)
63
- vi.restoreAllMocks()
64
- })
65
- })
@@ -1,331 +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, detectSceneChanges, createSceneSegments } 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
- detectSceneChanges: vi.fn(),
33
- createSceneSegments: vi.fn()
34
- }))
35
-
36
- vi.mock('commander', () => {
37
- const mockProgram = {
38
- name: vi.fn().mockReturnThis(),
39
- description: vi.fn().mockReturnThis(),
40
- version: vi.fn().mockReturnThis(),
41
- requiredOption: vi.fn().mockReturnThis(),
42
- addOption: vi.fn().mockReturnThis(),
43
- option: vi.fn().mockReturnThis(),
44
- action: vi.fn().mockReturnThis(),
45
- parse: vi.fn()
46
- }
47
- // eslint-disable-next-line prefer-arrow-callback
48
- const MockOption = vi.fn(function MockOption () { return { conflicts: vi.fn().mockReturnThis() } })
49
- // eslint-disable-next-line prefer-arrow-callback
50
- return { Command: vi.fn(function MockCommand () { return mockProgram }), Option: MockOption }
51
- })
52
-
53
- describe('processVideo', () => {
54
- beforeEach(() => {
55
- vi.clearAllMocks()
56
- vi.spyOn(console, 'log').mockImplementation(() => {})
57
- vi.spyOn(console, 'warn').mockImplementation(() => {})
58
- vi.spyOn(console, 'error').mockImplementation(() => {})
59
- })
60
- afterEach(() => vi.restoreAllMocks())
61
-
62
- it('exits when input file does not exist', async () => {
63
- vi.spyOn(fs, 'existsSync').mockReturnValueOnce(false)
64
- const exitSpy = vi.spyOn(process, 'exit').mockImplementation(() => { throw new Error('exit') })
65
- await expect(processVideo({ input: 'missing.mp4', segments: 2, output: '/out' })).rejects.toThrow('exit')
66
- expect(exitSpy).toHaveBeenCalledWith(1)
67
- })
68
-
69
- it('creates default output directory path when none provided', async () => {
70
- vi.spyOn(fs, 'existsSync')
71
- .mockReturnValueOnce(true) // input file exists
72
- .mockReturnValueOnce(false) // output dir does not exist
73
- vi.spyOn(fs, 'mkdirSync').mockImplementation(() => {})
74
- vi.mocked(getVideoDuration).mockResolvedValue(120)
75
- vi.mocked(createCountSegments).mockResolvedValue()
76
-
77
- await processVideo({ input: 'video.mp4', segments: 2 })
78
-
79
- expect(fs.mkdirSync).toHaveBeenCalledWith(expect.stringContaining('output'), { recursive: true })
80
- })
81
-
82
- it('creates output directory when it does not exist', async () => {
83
- vi.spyOn(fs, 'existsSync')
84
- .mockReturnValueOnce(true) // input file exists
85
- .mockReturnValueOnce(false) // output dir does not exist
86
- vi.spyOn(fs, 'mkdirSync').mockImplementation(() => {})
87
- vi.mocked(getVideoDuration).mockResolvedValue(120)
88
- vi.mocked(createCountSegments).mockResolvedValue()
89
-
90
- await processVideo({ input: 'video.mp4', segments: 2, output: '/out' })
91
-
92
- expect(fs.mkdirSync).toHaveBeenCalledWith('/out', { recursive: true })
93
- })
94
-
95
- it('exits when video duration cannot be determined (falsy)', async () => {
96
- vi.spyOn(fs, 'existsSync').mockReturnValue(true)
97
- vi.mocked(getVideoDuration).mockResolvedValue(0)
98
- const exitSpy = vi.spyOn(process, 'exit').mockImplementation(() => { throw new Error('exit') })
99
- await expect(processVideo({ input: 'video.mp4', segments: 2, output: '/out' })).rejects.toThrow('exit')
100
- expect(exitSpy).toHaveBeenCalledWith(1)
101
- })
102
-
103
- it('exits when getVideoDuration throws', async () => {
104
- vi.spyOn(fs, 'existsSync').mockReturnValue(true)
105
- vi.mocked(getVideoDuration).mockRejectedValue(new Error('ffprobe failed'))
106
- const exitSpy = vi.spyOn(process, 'exit').mockImplementation(() => { throw new Error('exit') })
107
- await expect(processVideo({ input: 'video.mp4', segments: 2, output: '/out' })).rejects.toThrow('exit')
108
- expect(exitSpy).toHaveBeenCalledWith(1)
109
- })
110
-
111
- describe('time-based segmentation (--duration)', () => {
112
- it('warns about stream copy mode when not re-encoding', async () => {
113
- vi.spyOn(fs, 'existsSync').mockReturnValue(true)
114
- vi.mocked(getVideoDuration).mockResolvedValue(120)
115
- vi.mocked(createTimeSegments).mockResolvedValue()
116
-
117
- await processVideo({ input: 'video.mp4', duration: 60, output: '/out' })
118
-
119
- expect(console.warn).toHaveBeenCalledWith(expect.stringContaining('stream copy mode'))
120
- expect(createTimeSegments).toHaveBeenCalledWith('video.mp4', 120, 60, '/out', false, false)
121
- })
122
-
123
- it('does not warn when re-encoding', async () => {
124
- vi.spyOn(fs, 'existsSync').mockReturnValue(true)
125
- vi.mocked(getVideoDuration).mockResolvedValue(120)
126
- vi.mocked(createTimeSegments).mockResolvedValue()
127
-
128
- await processVideo({ input: 'video.mp4', duration: 60, output: '/out', reEncode: true })
129
-
130
- expect(console.warn).not.toHaveBeenCalled()
131
- expect(createTimeSegments).toHaveBeenCalledWith('video.mp4', 120, 60, '/out', false, true)
132
- })
133
-
134
- it('prompts user for short segments (<30s) and proceeds when confirmed', async () => {
135
- vi.spyOn(fs, 'existsSync').mockReturnValue(true)
136
- vi.mocked(getVideoDuration).mockResolvedValue(60)
137
- vi.mocked(createTimeSegments).mockResolvedValue()
138
- vi.spyOn(inquirer, 'prompt').mockResolvedValue({ confirm: true })
139
-
140
- await processVideo({ input: 'video.mp4', duration: 10, output: '/out' })
141
-
142
- expect(inquirer.prompt).toHaveBeenCalled()
143
- expect(createTimeSegments).toHaveBeenCalled()
144
- })
145
-
146
- it('exits when user cancels short segment prompt', async () => {
147
- vi.spyOn(fs, 'existsSync').mockReturnValue(true)
148
- vi.mocked(getVideoDuration).mockResolvedValue(60)
149
- vi.spyOn(inquirer, 'prompt').mockResolvedValue({ confirm: false })
150
- const exitSpy = vi.spyOn(process, 'exit').mockImplementation(() => { throw new Error('exit') })
151
-
152
- await expect(processVideo({ input: 'video.mp4', duration: 10, output: '/out' })).rejects.toThrow('exit')
153
- expect(exitSpy).toHaveBeenCalledWith(0)
154
- })
155
- })
156
-
157
- describe('count-based segmentation (--segments)', () => {
158
- it('warns about stream copy mode when segments >= 30s', async () => {
159
- vi.spyOn(fs, 'existsSync').mockReturnValue(true)
160
- vi.mocked(getVideoDuration).mockResolvedValue(120)
161
- vi.mocked(createCountSegments).mockResolvedValue()
162
-
163
- await processVideo({ input: 'video.mp4', segments: 2, output: '/out' })
164
-
165
- expect(console.warn).toHaveBeenCalledWith(expect.stringContaining('stream copy mode'))
166
- expect(createCountSegments).toHaveBeenCalledWith('video.mp4', 120, 2, '/out', false, false)
167
- })
168
-
169
- it('does not warn when re-encoding with segments >= 30s', async () => {
170
- vi.spyOn(fs, 'existsSync').mockReturnValue(true)
171
- vi.mocked(getVideoDuration).mockResolvedValue(120)
172
- vi.mocked(createCountSegments).mockResolvedValue()
173
-
174
- await processVideo({ input: 'video.mp4', segments: 2, output: '/out', reEncode: true })
175
-
176
- expect(console.warn).not.toHaveBeenCalled()
177
- })
178
-
179
- it('warns about stream copy mode when segments < 30s and not re-encoding', async () => {
180
- vi.spyOn(fs, 'existsSync').mockReturnValue(true)
181
- vi.mocked(getVideoDuration).mockResolvedValue(60)
182
- vi.mocked(createCountSegments).mockResolvedValue()
183
- vi.spyOn(inquirer, 'prompt').mockResolvedValue({ confirm: true })
184
-
185
- await processVideo({ input: 'video.mp4', segments: 10, output: '/out' })
186
-
187
- expect(console.warn).toHaveBeenCalledWith(expect.stringContaining('stream copy mode'))
188
- })
189
-
190
- it('does not warn when re-encoding with short count segments', async () => {
191
- vi.spyOn(fs, 'existsSync').mockReturnValue(true)
192
- vi.mocked(getVideoDuration).mockResolvedValue(60)
193
- vi.mocked(createCountSegments).mockResolvedValue()
194
- vi.spyOn(inquirer, 'prompt').mockResolvedValue({ confirm: true })
195
-
196
- await processVideo({ input: 'video.mp4', segments: 10, output: '/out', reEncode: true })
197
-
198
- expect(console.warn).not.toHaveBeenCalled()
199
- expect(createCountSegments).toHaveBeenCalled()
200
- })
201
-
202
- it('prompts user for short segments and proceeds when confirmed', async () => {
203
- vi.spyOn(fs, 'existsSync').mockReturnValue(true)
204
- vi.mocked(getVideoDuration).mockResolvedValue(60)
205
- vi.mocked(createCountSegments).mockResolvedValue()
206
- vi.spyOn(inquirer, 'prompt').mockResolvedValue({ confirm: true })
207
-
208
- await processVideo({ input: 'video.mp4', segments: 10, output: '/out' })
209
-
210
- expect(inquirer.prompt).toHaveBeenCalled()
211
- expect(createCountSegments).toHaveBeenCalled()
212
- })
213
-
214
- it('exits when user cancels short segment prompt', async () => {
215
- vi.spyOn(fs, 'existsSync').mockReturnValue(true)
216
- vi.mocked(getVideoDuration).mockResolvedValue(60)
217
- vi.spyOn(inquirer, 'prompt').mockResolvedValue({ confirm: false })
218
- const exitSpy = vi.spyOn(process, 'exit').mockImplementation(() => { throw new Error('exit') })
219
-
220
- await expect(processVideo({ input: 'video.mp4', segments: 10, output: '/out' })).rejects.toThrow('exit')
221
- expect(exitSpy).toHaveBeenCalledWith(0)
222
- })
223
-
224
- it('passes verify and reEncode flags correctly', async () => {
225
- vi.spyOn(fs, 'existsSync').mockReturnValue(true)
226
- vi.mocked(getVideoDuration).mockResolvedValue(120)
227
- vi.mocked(createCountSegments).mockResolvedValue()
228
-
229
- await processVideo({ input: 'video.mp4', segments: 2, output: '/out', verify: true, reEncode: true })
230
-
231
- expect(createCountSegments).toHaveBeenCalledWith('video.mp4', 120, 2, '/out', true, true)
232
- })
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
- })
313
- })
314
-
315
- describe('setupCli', () => {
316
- beforeEach(() => {
317
- vi.clearAllMocks()
318
- vi.spyOn(console, 'log').mockImplementation(() => {})
319
- })
320
- afterEach(() => vi.restoreAllMocks())
321
-
322
- it('configures CLI with correct name, version, and options then calls parse', () => {
323
- setupCli()
324
- const program = vi.mocked(Command).mock.results[0].value
325
- expect(program.name).toHaveBeenCalledWith('video-cutter')
326
- expect(program.version).toHaveBeenCalledWith('1.0.0')
327
- expect(program.requiredOption).toHaveBeenCalledWith('-i, --input <path>', expect.any(String))
328
- expect(program.action).toHaveBeenCalledWith(processVideo)
329
- expect(program.parse).toHaveBeenCalled()
330
- })
331
- })
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
- })