@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 +9 -7
- package/package.json +4 -4
- package/.claude/settings.local.json +0 -8
- package/.eslintrc.json +0 -11
- package/.github/workflows/ci.yml +0 -19
- package/.github/workflows/publish.yml +0 -21
- package/tests/cli-direct.spec.js +0 -117
- package/tests/core.spec.js +0 -334
- package/tests/ffmpeg-check.spec.js +0 -65
- package/tests/index.spec.js +0 -331
- package/vitest.config.js +0 -13
package/README.md
CHANGED
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
# Video Cutter
|
|
2
2
|
|
|
3
|
+
[](https://github.com/shaztechio/video-cutter/actions/workflows/ci.yml)
|
|
4
|
+
[](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
|
|
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
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
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
|
-
|
|
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.
|
|
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": "^
|
|
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
46
|
},
|
|
47
47
|
"publishConfig": {
|
|
48
48
|
"access": "public"
|
package/.eslintrc.json
DELETED
package/.github/workflows/ci.yml
DELETED
|
@@ -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 }}
|
package/tests/cli-direct.spec.js
DELETED
|
@@ -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
|
-
})
|
package/tests/core.spec.js
DELETED
|
@@ -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
|
-
})
|
package/tests/index.spec.js
DELETED
|
@@ -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
|
-
})
|