@shaztech/video-cutter 1.0.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/src/core.js ADDED
@@ -0,0 +1,251 @@
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
+ // Core video cutting logic functions
16
+ import { spawn } from 'child_process'
17
+ import path from 'path'
18
+ import colors from 'colors'
19
+
20
+ /**
21
+ * Get video duration using ffprobe
22
+ *
23
+ * @param {string} inputFile - Path to the input video file
24
+ * @returns {Promise<number>} - Video duration in seconds
25
+ */
26
+ async function getVideoDuration (inputFile) {
27
+ return new Promise((resolve, reject) => {
28
+ const ffprobe = spawn('ffprobe', [
29
+ '-v', 'quiet',
30
+ '-show_entries', 'format=duration',
31
+ '-of', 'csv=p=0',
32
+ inputFile
33
+ ])
34
+
35
+ let output = ''
36
+ ffprobe.stdout.on('data', (data) => {
37
+ output += data.toString()
38
+ })
39
+
40
+ ffprobe.stderr.on('data', (data) => {
41
+ // Ignore stderr for this operation
42
+ })
43
+
44
+ ffprobe.on('close', (code) => {
45
+ if (code !== 0) {
46
+ reject(new Error(`ffprobe exited with code ${code}`))
47
+ return
48
+ }
49
+
50
+ const duration = parseFloat(output.trim())
51
+ if (isNaN(duration)) {
52
+ reject(new Error('Could not parse duration'))
53
+ return
54
+ }
55
+
56
+ resolve(duration)
57
+ })
58
+ })
59
+ }
60
+
61
+ /**
62
+ * Create a video segment using ffmpeg
63
+ *
64
+ * @param {string} inputFile - Path to the input video file
65
+ * @param {number} startTime - Start time of the segment in seconds
66
+ * @param {number} endTime - End time of the segment in seconds
67
+ * @param {string} outputFile - Path to save the output segment
68
+ * @param {boolean} reEncode - Whether to re-encode for exact duration
69
+ * @returns {Promise<void>}
70
+ */
71
+ function createSegment (inputFile, startTime, endTime, outputFile, reEncode = false) {
72
+ return new Promise((resolve, reject) => {
73
+ console.log(`Creating segment: ${outputFile}`)
74
+
75
+ const args = [
76
+ '-ss', startTime.toString(),
77
+ '-t', (endTime - startTime).toString(),
78
+ '-i', inputFile
79
+ ]
80
+
81
+ if (reEncode) {
82
+ args.push(
83
+ '-c:v', 'libx264', // Re-encode to ensure exact duration
84
+ '-c:a', 'aac', // Re-encode audio
85
+ '-avoid_negative_ts', 'make_zero',
86
+ '-reset_timestamps', '1'
87
+ )
88
+ } else {
89
+ args.push(
90
+ '-c', 'copy', // Copy streams without re-encoding for speed
91
+ '-avoid_negative_ts', 'make_zero',
92
+ '-reset_timestamps', '1'
93
+ )
94
+ }
95
+
96
+ args.push(outputFile)
97
+
98
+ const ffmpeg = spawn('ffmpeg', args)
99
+
100
+ ffmpeg.stdout.on('data', (data) => {
101
+ // Log progress if needed
102
+ })
103
+
104
+ ffmpeg.stderr.on('data', (data) => {
105
+ // Handle errors
106
+ })
107
+
108
+ ffmpeg.on('close', (code) => {
109
+ if (code !== 0) {
110
+ reject(new Error(`ffmpeg exited with code ${code}`))
111
+ } else {
112
+ resolve()
113
+ }
114
+ })
115
+ })
116
+ }
117
+
118
+ /**
119
+ * Create video segments based on count
120
+ *
121
+ * @param {string} inputFile - Path to the input video file
122
+ * @param {number} duration - Total duration of the video
123
+ * @param {number} segmentCount - Number of segments to create
124
+ * @param {string} outputPath - Directory to save the segments
125
+ * @param {boolean} verifySegments - Whether to verify segment durations
126
+ * @param {boolean} reEncode - Whether to re-encode for exact duration
127
+ * @returns {Promise<void>}
128
+ */
129
+ async function createCountSegments (inputFile, duration, segmentCount, outputPath, verifySegments = false, reEncode = false) {
130
+ // Calculate segment duration
131
+ const segmentDuration = duration / segmentCount
132
+
133
+ // Generate segments
134
+ const promises = []
135
+ for (let i = 0; i < segmentCount; i++) {
136
+ const startTime = i * segmentDuration
137
+ const endTime = Math.min((i + 1) * segmentDuration, duration)
138
+
139
+ const segmentFile = path.join(outputPath, `segment_${String(i + 1).padStart(3, '0')}.mp4`)
140
+
141
+ promises.push(createSegment(inputFile, startTime, endTime, segmentFile, reEncode))
142
+ }
143
+
144
+ return Promise.all(promises)
145
+ .then(async () => {
146
+ console.log('All segments created successfully!')
147
+
148
+ // Verify segments if requested
149
+ if (verifySegments) {
150
+ console.log('Verifying segment durations...')
151
+ for (let i = 0; i < segmentCount; i++) {
152
+ const startTime = i * segmentDuration
153
+ const endTime = Math.min((i + 1) * segmentDuration, duration)
154
+ const expectedDuration = endTime - startTime
155
+ const segmentFile = path.join(outputPath, `segment_${String(i + 1).padStart(3, '0')}.mp4`)
156
+
157
+ const actualDuration = await getVideoDuration(segmentFile)
158
+ const difference = Math.abs(actualDuration - expectedDuration)
159
+ const tolerance = reEncode ? 0.1 : 1.0 // Stricter tolerance when re-encoding
160
+ const isValid = difference <= tolerance
161
+ if (!isValid) {
162
+ console.warn(`Warning: Segment ${segmentFile} duration is ${actualDuration.toFixed(2)} seconds, expected ${expectedDuration.toFixed(2)} seconds`)
163
+ } else {
164
+ console.log(colors.green(`Verified: ${segmentFile} duration is correct`))
165
+ }
166
+ }
167
+ }
168
+ })
169
+ .catch(err => {
170
+ console.error('Error creating segments:', err)
171
+ process.exit(1)
172
+ })
173
+ }
174
+
175
+ /**
176
+ * Create video segments based on duration
177
+ *
178
+ * @param {string} inputFile - Path to the input video file
179
+ * @param {number} videoDuration - Total duration of the video
180
+ * @param {number} segmentDuration - Duration of each segment in seconds
181
+ * @param {string} outputPath - Directory to save the segments
182
+ * @param {boolean} verifySegments - Whether to verify segment durations
183
+ * @param {boolean} reEncode - Whether to re-encode for exact duration
184
+ * @returns {Promise<void>}
185
+ */
186
+ async function createTimeSegments (inputFile, videoDuration, segmentDuration, outputPath, verifySegments = false, reEncode = false) {
187
+ // Calculate number of segments needed
188
+ const segmentCount = Math.ceil(videoDuration / segmentDuration)
189
+
190
+ // Generate segments
191
+ const promises = []
192
+ for (let i = 0; i < segmentCount; i++) {
193
+ const startTime = i * segmentDuration
194
+ const endTime = Math.min((i + 1) * segmentDuration, videoDuration)
195
+
196
+ // Convert start time to HH-MM-SS format for filename
197
+ const hours = Math.floor(startTime / 3600)
198
+ const minutes = Math.floor((startTime % 3600) / 60)
199
+ const seconds = Math.floor(startTime % 60)
200
+
201
+ const padTime = (num) => String(num).padStart(2, '0')
202
+ const timeStr = `${padTime(hours)}-${padTime(minutes)}-${padTime(seconds)}`
203
+ const segmentFile = path.join(outputPath, `seg_${padTime(i + 1)}_${timeStr}.mp4`)
204
+
205
+ promises.push(createSegment(inputFile, startTime, endTime, segmentFile, reEncode))
206
+ }
207
+
208
+ return Promise.all(promises)
209
+ .then(async () => {
210
+ console.log('All segments created successfully!')
211
+
212
+ // Verify segments if requested
213
+ if (verifySegments) {
214
+ console.log('Verifying segment durations...')
215
+ for (let i = 0; i < segmentCount; i++) {
216
+ const startTime = i * segmentDuration
217
+ const endTime = Math.min((i + 1) * segmentDuration, videoDuration)
218
+ const expectedDuration = endTime - startTime
219
+
220
+ // Recalculate the time string for verification
221
+ const hours = Math.floor(startTime / 3600)
222
+ const minutes = Math.floor((startTime % 3600) / 60)
223
+ const seconds = Math.floor(startTime % 60)
224
+ const padTime = (num) => String(num).padStart(2, '0')
225
+ const timeStr = `${padTime(hours)}-${padTime(minutes)}-${padTime(seconds)}`
226
+ const segmentFile = path.join(outputPath, `seg_${padTime(i + 1)}_${timeStr}.mp4`)
227
+
228
+ const actualDuration = await getVideoDuration(segmentFile)
229
+ const difference = Math.abs(actualDuration - expectedDuration)
230
+ const tolerance = reEncode ? 0.1 : 1.0 // Stricter tolerance when re-encoding
231
+ const isValid = difference <= tolerance
232
+ if (!isValid) {
233
+ console.warn(`Warning: Segment ${segmentFile} duration is ${actualDuration.toFixed(2)} seconds, expected ${expectedDuration.toFixed(2)} seconds`)
234
+ } else {
235
+ console.log(colors.green(`Verified: ${segmentFile} duration is correct`))
236
+ }
237
+ }
238
+ }
239
+ })
240
+ .catch(err => {
241
+ console.error('Error creating segments:', err)
242
+ process.exit(1)
243
+ })
244
+ }
245
+
246
+ export {
247
+ getVideoDuration,
248
+ createSegment,
249
+ createCountSegments,
250
+ createTimeSegments
251
+ }
@@ -0,0 +1,113 @@
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
+ })
@@ -0,0 +1,201 @@
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
+ })
@@ -0,0 +1,63 @@
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
+ })