@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/.claude/settings.local.json +8 -0
- package/.eslintrc.json +11 -0
- package/LICENSE +200 -0
- package/README.md +84 -0
- package/index.js +203 -0
- package/package.json +47 -0
- package/src/core.js +251 -0
- package/tests/cli-direct.spec.js +113 -0
- package/tests/core.spec.js +201 -0
- package/tests/ffmpeg-check.spec.js +63 -0
- package/tests/index.spec.js +250 -0
- package/vitest.config.js +13 -0
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
|
+
})
|