@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 +46 -14
- package/index.js +26 -2
- package/package.json +7 -4
- package/src/core.js +91 -2
- package/.claude/settings.local.json +0 -8
- package/.eslintrc.json +0 -11
- package/tests/cli-direct.spec.js +0 -113
- package/tests/core.spec.js +0 -201
- package/tests/ffmpeg-check.spec.js +0 -63
- package/tests/index.spec.js +0 -250
- package/vitest.config.js +0 -13
package/README.md
CHANGED
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
# Video Cutter
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
[](https://github.com/shaztechio/video-cutter/actions/workflows/ci.yml)
|
|
4
|
+
[](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
|
|
34
|
-
-i, --input <path>
|
|
35
|
-
-n, --segments <number>
|
|
36
|
-
-d, --duration <seconds>
|
|
37
|
-
-
|
|
38
|
-
|
|
39
|
-
--
|
|
40
|
-
|
|
41
|
-
--
|
|
42
|
-
|
|
43
|
-
-
|
|
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
|
|
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
|
-
|
|
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.
|
|
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": "^
|
|
39
|
-
"eslint-plugin-promise": "^
|
|
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/
|
|
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
|
-
|
|
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
|
}
|
package/.eslintrc.json
DELETED
package/tests/cli-direct.spec.js
DELETED
|
@@ -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
|
-
})
|
package/tests/core.spec.js
DELETED
|
@@ -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
|
-
})
|
package/tests/index.spec.js
DELETED
|
@@ -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
|
-
})
|