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