bare-media 2.1.0 → 2.3.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/README.md +26 -1
- package/package.json +8 -4
- package/src/codecs.js +1 -0
- package/src/{video.js → video/index.js} +23 -0
- package/src/video/transcoder.js +538 -0
- package/types.js +1 -0
package/README.md
CHANGED
|
@@ -115,9 +115,34 @@ Extracts frames from a video in RGBA
|
|
|
115
115
|
|
|
116
116
|
| Parameter | Type | Description |
|
|
117
117
|
| ----------------- | ------ | ------------------------------ |
|
|
118
|
-
| `fd` | number | File descriptor |
|
|
119
118
|
| `opts.frameIndex` | number | Number of the frame to extract |
|
|
120
119
|
|
|
120
|
+
### transcode()
|
|
121
|
+
|
|
122
|
+
Transcode a media file to a different format
|
|
123
|
+
|
|
124
|
+
| Parameter | Type | Description |
|
|
125
|
+
| ------------- | ------ | ------------------------------------------------------------------- |
|
|
126
|
+
| `opts.format` | string | Output format name (e.g., `mp4`, `webm`, `matroska`). Default `mp4` |
|
|
127
|
+
| `opts.width` | number | Width of the output video |
|
|
128
|
+
| `opts.height` | number | Height of the output video |
|
|
129
|
+
|
|
130
|
+
**Supported formats**: `mp4` (VP9+Opus), `webm` (VP8+Opus), `matroska`/`mkv` (VP9+Opus)
|
|
131
|
+
|
|
132
|
+
#### Example
|
|
133
|
+
|
|
134
|
+
```javascript
|
|
135
|
+
import { video } from 'bare-media'
|
|
136
|
+
|
|
137
|
+
for await (const chunk of video('input.mkv').transcode({
|
|
138
|
+
format: 'mp4',
|
|
139
|
+
width: 1280,
|
|
140
|
+
height: 720
|
|
141
|
+
})) {
|
|
142
|
+
console.log('Received chunk:', chunk.buffer.length)
|
|
143
|
+
}
|
|
144
|
+
```
|
|
145
|
+
|
|
121
146
|
## Supported Types
|
|
122
147
|
|
|
123
148
|
Helpers to check supported media types are exposed in `bare-media/types`:
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "bare-media",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.3.0-0",
|
|
4
4
|
"description": "A set of media APIs for Bare",
|
|
5
5
|
"main": "index.js",
|
|
6
6
|
"type": "module",
|
|
@@ -17,7 +17,8 @@
|
|
|
17
17
|
"format": "prettier --write .",
|
|
18
18
|
"format:check": "prettier --check .",
|
|
19
19
|
"lint": "npm run format:check && lunte",
|
|
20
|
-
"test": "brittle-bare test/index.js"
|
|
20
|
+
"test": "brittle-bare test/index.js",
|
|
21
|
+
"example": "bare examples/index.js"
|
|
21
22
|
},
|
|
22
23
|
"keywords": [],
|
|
23
24
|
"author": "Holepunch Inc",
|
|
@@ -33,7 +34,7 @@
|
|
|
33
34
|
"dependencies": {
|
|
34
35
|
"bare-bmp": "^1.0.0",
|
|
35
36
|
"bare-fetch": "^2.4.1",
|
|
36
|
-
"bare-ffmpeg": "^1.
|
|
37
|
+
"bare-ffmpeg": "^1.1.0",
|
|
37
38
|
"bare-fs": "^4.1.5",
|
|
38
39
|
"bare-gif": "^1.1.2",
|
|
39
40
|
"bare-heif": "^1.0.5",
|
|
@@ -41,14 +42,17 @@
|
|
|
41
42
|
"bare-image-resample": "^1.0.1",
|
|
42
43
|
"bare-jpeg": "^1.0.1",
|
|
43
44
|
"bare-png": "^1.0.2",
|
|
45
|
+
"bare-svg": "^1.0.1",
|
|
44
46
|
"bare-tiff": "^1.0.1",
|
|
45
47
|
"bare-webp": "^1.0.3",
|
|
46
|
-
"get-file-format": "^1.0
|
|
48
|
+
"get-file-format": "^1.1.0",
|
|
47
49
|
"get-mime-type": "^2.0.1"
|
|
48
50
|
},
|
|
49
51
|
"devDependencies": {
|
|
50
52
|
"b4a": "^1.7.3",
|
|
51
53
|
"bare-os": "^3.6.2",
|
|
54
|
+
"bare-path": "^3.0.0",
|
|
55
|
+
"bare-process": "^4.2.2",
|
|
52
56
|
"brittle": "^3.16.3",
|
|
53
57
|
"corestore": "^7.4.5",
|
|
54
58
|
"hyperblobs": "^2.8.0",
|
package/src/codecs.js
CHANGED
|
@@ -9,6 +9,7 @@ export const codecs = {
|
|
|
9
9
|
[IMAGE.JPEG]: () => import('bare-jpeg'),
|
|
10
10
|
[IMAGE.JPG]: () => import('bare-jpeg'),
|
|
11
11
|
[IMAGE.PNG]: () => import('bare-png'),
|
|
12
|
+
[IMAGE.SVG_XML]: () => import('bare-svg'),
|
|
12
13
|
[IMAGE.TIF]: () => import('bare-tiff'),
|
|
13
14
|
[IMAGE.TIFF]: () => import('bare-tiff'),
|
|
14
15
|
[IMAGE.VND_MS_ICON]: () => import('bare-ico'),
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import fs from 'bare-fs'
|
|
2
2
|
import ffmpeg from 'bare-ffmpeg'
|
|
3
|
+
import { Transcoder } from './transcoder.js'
|
|
3
4
|
|
|
4
5
|
function extractFrames(fd, opts = {}) {
|
|
5
6
|
const { frameIndex } = opts
|
|
@@ -89,6 +90,18 @@ function extractFrames(fd, opts = {}) {
|
|
|
89
90
|
return result
|
|
90
91
|
}
|
|
91
92
|
|
|
93
|
+
async function* transcode(fd, opts = {}) {
|
|
94
|
+
const transcoder = new Transcoder(fd, {
|
|
95
|
+
outputParameters: {
|
|
96
|
+
format: opts.format,
|
|
97
|
+
width: opts.width,
|
|
98
|
+
height: opts.height
|
|
99
|
+
},
|
|
100
|
+
bufferSize: opts.bufferSize
|
|
101
|
+
})
|
|
102
|
+
yield* transcoder.transcode()
|
|
103
|
+
}
|
|
104
|
+
|
|
92
105
|
class VideoPipeline {
|
|
93
106
|
constructor(input) {
|
|
94
107
|
this.input = input
|
|
@@ -101,6 +114,15 @@ class VideoPipeline {
|
|
|
101
114
|
fs.closeSync(fd)
|
|
102
115
|
return result
|
|
103
116
|
}
|
|
117
|
+
|
|
118
|
+
async *transcode(opts) {
|
|
119
|
+
const fd = fs.openSync(this.input, 'r')
|
|
120
|
+
try {
|
|
121
|
+
yield* transcode(fd, opts)
|
|
122
|
+
} finally {
|
|
123
|
+
fs.closeSync(fd)
|
|
124
|
+
}
|
|
125
|
+
}
|
|
104
126
|
}
|
|
105
127
|
|
|
106
128
|
function video(input) {
|
|
@@ -108,5 +130,6 @@ function video(input) {
|
|
|
108
130
|
}
|
|
109
131
|
|
|
110
132
|
video.extractFrames = extractFrames
|
|
133
|
+
video.transcode = transcode
|
|
111
134
|
|
|
112
135
|
export { video }
|
|
@@ -0,0 +1,538 @@
|
|
|
1
|
+
import fs from 'bare-fs'
|
|
2
|
+
import b4a from 'b4a'
|
|
3
|
+
import ffmpeg from 'bare-ffmpeg'
|
|
4
|
+
|
|
5
|
+
const { VIDEO, AUDIO } = ffmpeg.constants.mediaTypes
|
|
6
|
+
|
|
7
|
+
class FormatRegistry {
|
|
8
|
+
#formats = new Map()
|
|
9
|
+
|
|
10
|
+
register(formatName, config) {
|
|
11
|
+
this.#formats.set(formatName, {
|
|
12
|
+
video: config.video,
|
|
13
|
+
audio: config.audio,
|
|
14
|
+
muxer: config.muxer || {}
|
|
15
|
+
})
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
getVideoConfig(formatName) {
|
|
19
|
+
const format = this.#formats.get(formatName)
|
|
20
|
+
if (!format?.video) {
|
|
21
|
+
throw new Error(`Unsupported video output format: ${formatName}`)
|
|
22
|
+
}
|
|
23
|
+
return format.video
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
getAudioConfig(formatName) {
|
|
27
|
+
const format = this.#formats.get(formatName)
|
|
28
|
+
if (!format?.audio) {
|
|
29
|
+
throw new Error(`Unsupported audio output format: ${formatName}`)
|
|
30
|
+
}
|
|
31
|
+
return format.audio
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
getMuxerOptions(formatName) {
|
|
35
|
+
const format = this.#formats.get(formatName)
|
|
36
|
+
return format?.muxer || {}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
hasFormat(formatName) {
|
|
40
|
+
return this.#formats.has(formatName)
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const formatRegistry = new FormatRegistry()
|
|
45
|
+
|
|
46
|
+
formatRegistry.register('webm', {
|
|
47
|
+
video: {
|
|
48
|
+
id: ffmpeg.constants.codecs.VP8,
|
|
49
|
+
format: ffmpeg.constants.pixelFormats.YUV420P,
|
|
50
|
+
encoder: 'libvpx'
|
|
51
|
+
},
|
|
52
|
+
audio: {
|
|
53
|
+
id: ffmpeg.constants.codecs.OPUS,
|
|
54
|
+
format: ffmpeg.constants.sampleFormats.FLTP,
|
|
55
|
+
sampleRate: 48000,
|
|
56
|
+
encoder: 'libopus'
|
|
57
|
+
},
|
|
58
|
+
muxer: { live: '1' }
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
formatRegistry.register('mp4', {
|
|
62
|
+
video: {
|
|
63
|
+
id: ffmpeg.constants.codecs.VP9,
|
|
64
|
+
format: ffmpeg.constants.pixelFormats.YUV420P,
|
|
65
|
+
encoder: 'libvpx-vp9'
|
|
66
|
+
},
|
|
67
|
+
audio: {
|
|
68
|
+
id: ffmpeg.constants.codecs.OPUS,
|
|
69
|
+
format: ffmpeg.constants.sampleFormats.FLTP,
|
|
70
|
+
sampleRate: 48000,
|
|
71
|
+
encoder: 'libopus'
|
|
72
|
+
},
|
|
73
|
+
muxer: { movflags: 'frag_keyframe+empty_moov+default_base_moof' }
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
formatRegistry.register('matroska', {
|
|
77
|
+
video: {
|
|
78
|
+
id: ffmpeg.constants.codecs.VP9,
|
|
79
|
+
format: ffmpeg.constants.pixelFormats.YUV420P,
|
|
80
|
+
encoder: 'libvpx-vp9'
|
|
81
|
+
},
|
|
82
|
+
audio: {
|
|
83
|
+
id: ffmpeg.constants.codecs.OPUS,
|
|
84
|
+
format: ffmpeg.constants.sampleFormats.FLTP,
|
|
85
|
+
sampleRate: 48000,
|
|
86
|
+
encoder: 'libopus'
|
|
87
|
+
},
|
|
88
|
+
muxer: { live: '1' }
|
|
89
|
+
})
|
|
90
|
+
|
|
91
|
+
formatRegistry.register('mkv', {
|
|
92
|
+
video: {
|
|
93
|
+
id: ffmpeg.constants.codecs.VP9,
|
|
94
|
+
format: ffmpeg.constants.pixelFormats.YUV420P,
|
|
95
|
+
encoder: 'libvpx-vp9'
|
|
96
|
+
},
|
|
97
|
+
audio: {
|
|
98
|
+
id: ffmpeg.constants.codecs.OPUS,
|
|
99
|
+
format: ffmpeg.constants.sampleFormats.FLTP,
|
|
100
|
+
sampleRate: 48000,
|
|
101
|
+
encoder: 'libopus'
|
|
102
|
+
},
|
|
103
|
+
muxer: { live: '1' }
|
|
104
|
+
})
|
|
105
|
+
|
|
106
|
+
class TranscodeStreamConfig {
|
|
107
|
+
static create(inputStream, outputFormatContext, containerFormat, outputParameters) {
|
|
108
|
+
const config = new TranscodeStreamConfig(
|
|
109
|
+
inputStream,
|
|
110
|
+
outputFormatContext,
|
|
111
|
+
containerFormat,
|
|
112
|
+
outputParameters
|
|
113
|
+
)
|
|
114
|
+
return config.#initialize() ? config : null
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
constructor(inputStream, outputFormatContext, containerFormat, outputParameters) {
|
|
118
|
+
this.inputStream = inputStream
|
|
119
|
+
this.outputFormatContext = outputFormatContext
|
|
120
|
+
this.containerFormat = containerFormat
|
|
121
|
+
this.outputParameters = outputParameters
|
|
122
|
+
this.codecType = inputStream.codecParameters.type
|
|
123
|
+
|
|
124
|
+
this.outputStream = null
|
|
125
|
+
this.decoder = null
|
|
126
|
+
this.encoder = null
|
|
127
|
+
this.rescaler = null
|
|
128
|
+
this.resampler = null
|
|
129
|
+
this.fifo = null
|
|
130
|
+
this.fifoFrame = null
|
|
131
|
+
this.samplesWritten = 0
|
|
132
|
+
this.nextVideoPts = 0
|
|
133
|
+
this.lastWidth = null
|
|
134
|
+
this.lastHeight = null
|
|
135
|
+
this.lastFormat = null
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
isVideo() {
|
|
139
|
+
return this.codecType === VIDEO
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
isAudio() {
|
|
143
|
+
return this.codecType === AUDIO
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
getConfig() {
|
|
147
|
+
return this.isVideo()
|
|
148
|
+
? formatRegistry.getVideoConfig(this.containerFormat)
|
|
149
|
+
: formatRegistry.getAudioConfig(this.containerFormat)
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
#initialize() {
|
|
153
|
+
this.decoder = this.#createDecoder()
|
|
154
|
+
if (!this.decoder) return false
|
|
155
|
+
|
|
156
|
+
this.outputStream = this.outputFormatContext.createStream()
|
|
157
|
+
this.#configureOutputStream(this.outputStream, this.decoder)
|
|
158
|
+
|
|
159
|
+
this.encoder = this.#createEncoder(this.outputStream, this.decoder)
|
|
160
|
+
this.outputStream.codecParameters.fromContext(this.encoder)
|
|
161
|
+
|
|
162
|
+
return true
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
#createDecoder() {
|
|
166
|
+
const decoderContext = this.inputStream.decoder()
|
|
167
|
+
try {
|
|
168
|
+
decoderContext.open()
|
|
169
|
+
return decoderContext
|
|
170
|
+
} catch (err) {
|
|
171
|
+
console.warn(`Failed to open decoder for stream ${this.inputStream.index}: ${err.message}`)
|
|
172
|
+
decoderContext.destroy()
|
|
173
|
+
return null
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
#configureOutputStream(outputStream, decoder) {
|
|
178
|
+
const config = this.getConfig()
|
|
179
|
+
|
|
180
|
+
outputStream.codecParameters.type = this.codecType
|
|
181
|
+
outputStream.codecParameters.id = config.id
|
|
182
|
+
outputStream.codecParameters.format = config.format
|
|
183
|
+
|
|
184
|
+
if (this.isVideo()) {
|
|
185
|
+
outputStream.codecParameters.width = this.outputParameters?.width || decoder.width
|
|
186
|
+
outputStream.codecParameters.height = this.outputParameters?.height || decoder.height
|
|
187
|
+
outputStream.timeBase = new ffmpeg.Rational(1, 90000)
|
|
188
|
+
} else {
|
|
189
|
+
outputStream.codecParameters.sampleRate = config.sampleRate
|
|
190
|
+
outputStream.codecParameters.channelLayout = decoder.channelLayout
|
|
191
|
+
outputStream.timeBase = new ffmpeg.Rational(1, config.sampleRate)
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
#createEncoder(outputStream, decoder) {
|
|
196
|
+
const config = this.getConfig()
|
|
197
|
+
const encoder = new ffmpeg.CodecContext(new ffmpeg.Encoder(config.encoder))
|
|
198
|
+
outputStream.codecParameters.toContext(encoder)
|
|
199
|
+
|
|
200
|
+
if (this.isVideo()) {
|
|
201
|
+
this.#configureVideoEncoder(encoder, outputStream, decoder)
|
|
202
|
+
} else {
|
|
203
|
+
this.#configureAudioEncoder(encoder, outputStream)
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
if (this.outputFormatContext.outputFormat.flags & ffmpeg.constants.formatFlags.GLOBALHEADER) {
|
|
207
|
+
encoder.flags |= ffmpeg.constants.codecFlags.GLOBAL_HEADER
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
const encoderOptions = this.isVideo()
|
|
211
|
+
? ffmpeg.Dictionary.from({ allow_sw: '1' })
|
|
212
|
+
: new ffmpeg.Dictionary()
|
|
213
|
+
|
|
214
|
+
encoder.open(encoderOptions)
|
|
215
|
+
return encoder
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
#configureVideoEncoder(encoder, outputStream, decoder) {
|
|
219
|
+
encoder.timeBase = outputStream.timeBase
|
|
220
|
+
encoder.width = outputStream.codecParameters.width
|
|
221
|
+
encoder.height = outputStream.codecParameters.height
|
|
222
|
+
encoder.pixelFormat = outputStream.codecParameters.format
|
|
223
|
+
|
|
224
|
+
if (decoder.frameRate && decoder.frameRate.valid) {
|
|
225
|
+
encoder.frameRate = decoder.frameRate
|
|
226
|
+
} else {
|
|
227
|
+
encoder.frameRate = new ffmpeg.Rational(30, 1)
|
|
228
|
+
}
|
|
229
|
+
encoder.gopSize = 30
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
#configureAudioEncoder(encoder, outputStream) {
|
|
233
|
+
encoder.timeBase = outputStream.timeBase
|
|
234
|
+
encoder.sampleRate = outputStream.codecParameters.sampleRate
|
|
235
|
+
encoder.channelLayout = outputStream.codecParameters.channelLayout
|
|
236
|
+
encoder.sampleFormat = outputStream.codecParameters.format
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
class VideoFrameProcessor {
|
|
241
|
+
constructor(transcoder) {
|
|
242
|
+
this.transcoder = transcoder
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
process(frame, config, packet) {
|
|
246
|
+
const { encoder, outputStream } = config
|
|
247
|
+
|
|
248
|
+
if (
|
|
249
|
+
!config.rescaler ||
|
|
250
|
+
config.lastWidth !== frame.width ||
|
|
251
|
+
config.lastHeight !== frame.height ||
|
|
252
|
+
config.lastFormat !== frame.format
|
|
253
|
+
) {
|
|
254
|
+
if (config.rescaler) config.rescaler.destroy()
|
|
255
|
+
|
|
256
|
+
config.rescaler = new ffmpeg.Scaler(
|
|
257
|
+
frame.format,
|
|
258
|
+
frame.width,
|
|
259
|
+
frame.height,
|
|
260
|
+
encoder.pixelFormat,
|
|
261
|
+
encoder.width,
|
|
262
|
+
encoder.height
|
|
263
|
+
)
|
|
264
|
+
|
|
265
|
+
config.lastWidth = frame.width
|
|
266
|
+
config.lastHeight = frame.height
|
|
267
|
+
config.lastFormat = frame.format
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
const outFrame = new ffmpeg.Frame()
|
|
271
|
+
outFrame.format = encoder.pixelFormat
|
|
272
|
+
outFrame.width = encoder.width
|
|
273
|
+
outFrame.height = encoder.height
|
|
274
|
+
outFrame.alloc()
|
|
275
|
+
outFrame.copyProperties(frame)
|
|
276
|
+
|
|
277
|
+
config.rescaler.scale(frame, outFrame)
|
|
278
|
+
|
|
279
|
+
outFrame.pts = config.nextVideoPts
|
|
280
|
+
const frameDuration =
|
|
281
|
+
(encoder.timeBase.denominator * encoder.frameRate.denominator) /
|
|
282
|
+
(encoder.timeBase.numerator * encoder.frameRate.numerator)
|
|
283
|
+
config.nextVideoPts += frameDuration
|
|
284
|
+
|
|
285
|
+
this.transcoder._encodeAndWrite(encoder, outFrame, outputStream, packet)
|
|
286
|
+
|
|
287
|
+
outFrame.destroy()
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
class AudioFrameProcessor {
|
|
292
|
+
constructor(transcoder) {
|
|
293
|
+
this.transcoder = transcoder
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
process(frame, config, packet) {
|
|
297
|
+
const { encoder, outputStream } = config
|
|
298
|
+
|
|
299
|
+
if (!config.resampler) {
|
|
300
|
+
config.resampler = new ffmpeg.Resampler(
|
|
301
|
+
frame.sampleRate,
|
|
302
|
+
frame.channelLayout,
|
|
303
|
+
frame.format,
|
|
304
|
+
encoder.sampleRate,
|
|
305
|
+
encoder.channelLayout,
|
|
306
|
+
encoder.sampleFormat
|
|
307
|
+
)
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
if (!config.fifo) {
|
|
311
|
+
config.fifo = new ffmpeg.AudioFIFO(
|
|
312
|
+
encoder.sampleFormat,
|
|
313
|
+
encoder.channelLayout.nbChannels,
|
|
314
|
+
encoder.frameSize
|
|
315
|
+
)
|
|
316
|
+
config.fifoFrame = new ffmpeg.Frame()
|
|
317
|
+
config.fifoFrame.format = encoder.sampleFormat
|
|
318
|
+
config.fifoFrame.channelLayout = encoder.channelLayout
|
|
319
|
+
config.fifoFrame.sampleRate = encoder.sampleRate
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
const outFrame = new ffmpeg.Frame()
|
|
323
|
+
outFrame.format = encoder.sampleFormat
|
|
324
|
+
outFrame.channelLayout = encoder.channelLayout
|
|
325
|
+
outFrame.sampleRate = encoder.sampleRate
|
|
326
|
+
|
|
327
|
+
const outSamples = Math.ceil((frame.nbSamples * encoder.sampleRate) / frame.sampleRate) + 32
|
|
328
|
+
outFrame.nbSamples = outSamples
|
|
329
|
+
outFrame.alloc()
|
|
330
|
+
|
|
331
|
+
const convertedSamples = config.resampler.convert(frame, outFrame)
|
|
332
|
+
outFrame.nbSamples = convertedSamples
|
|
333
|
+
|
|
334
|
+
config.fifo.write(outFrame)
|
|
335
|
+
outFrame.destroy()
|
|
336
|
+
|
|
337
|
+
const frameSize = encoder.frameSize
|
|
338
|
+
while (config.fifo.size >= frameSize) {
|
|
339
|
+
config.fifoFrame.nbSamples = frameSize
|
|
340
|
+
config.fifoFrame.alloc()
|
|
341
|
+
|
|
342
|
+
config.fifo.read(config.fifoFrame, frameSize)
|
|
343
|
+
|
|
344
|
+
config.fifoFrame.pts = config.samplesWritten
|
|
345
|
+
config.samplesWritten += config.fifoFrame.nbSamples
|
|
346
|
+
|
|
347
|
+
this.transcoder._encodeAndWrite(encoder, config.fifoFrame, outputStream, packet)
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
flush(config, packet) {
|
|
352
|
+
if (config.fifo && config.fifo.size > 0) {
|
|
353
|
+
const remaining = config.fifo.size
|
|
354
|
+
config.fifoFrame.nbSamples = remaining
|
|
355
|
+
config.fifoFrame.alloc()
|
|
356
|
+
config.fifo.read(config.fifoFrame, remaining)
|
|
357
|
+
config.fifoFrame.pts = config.samplesWritten
|
|
358
|
+
config.samplesWritten += config.fifoFrame.nbSamples
|
|
359
|
+
this.transcoder._encodeAndWrite(config.encoder, config.fifoFrame, config.outputStream, packet)
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
class Transcoder {
|
|
365
|
+
constructor(fd, opts = {}) {
|
|
366
|
+
this.fd = fd
|
|
367
|
+
this.outputParameters = opts.outputParameters || {}
|
|
368
|
+
this.bufferSize = opts.bufferSize || 32 * 1024
|
|
369
|
+
|
|
370
|
+
this.chunks = []
|
|
371
|
+
this.inputFormatContext = null
|
|
372
|
+
this.outputFormatContext = null
|
|
373
|
+
this.configs = []
|
|
374
|
+
this.containerFormat = null
|
|
375
|
+
|
|
376
|
+
this.videoProcessor = new VideoFrameProcessor(this)
|
|
377
|
+
this.audioProcessor = new AudioFrameProcessor(this)
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
async *transcode() {
|
|
381
|
+
try {
|
|
382
|
+
this.#setupIOContexts()
|
|
383
|
+
this.#discoverAndConfigureStreams()
|
|
384
|
+
this.#configureOutput()
|
|
385
|
+
this.#processFrames()
|
|
386
|
+
this.#finalize()
|
|
387
|
+
} finally {
|
|
388
|
+
this.#cleanup()
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
for (const chunk of this.chunks) {
|
|
392
|
+
yield { buffer: chunk }
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
#setupIOContexts() {
|
|
397
|
+
const fileSize = fs.fstatSync(this.fd).size
|
|
398
|
+
let offset = 0
|
|
399
|
+
|
|
400
|
+
const inIO = new ffmpeg.IOContext(4096, {
|
|
401
|
+
onread: (buffer, requested) => {
|
|
402
|
+
const read = fs.readSync(this.fd, buffer, 0, requested, offset)
|
|
403
|
+
if (read === 0) return 0
|
|
404
|
+
offset += read
|
|
405
|
+
return read
|
|
406
|
+
},
|
|
407
|
+
onseek: (o, whence) => {
|
|
408
|
+
if (whence === ffmpeg.constants.seek.SIZE) return fileSize
|
|
409
|
+
if (whence === ffmpeg.constants.seek.SET) offset = o
|
|
410
|
+
else if (whence === ffmpeg.constants.seek.CUR) offset += o
|
|
411
|
+
else if (whence === ffmpeg.constants.seek.END) offset = fileSize + o
|
|
412
|
+
else return -1
|
|
413
|
+
return offset
|
|
414
|
+
}
|
|
415
|
+
})
|
|
416
|
+
|
|
417
|
+
this.inputFormatContext = new ffmpeg.InputFormatContext(inIO)
|
|
418
|
+
|
|
419
|
+
const outIO = new ffmpeg.IOContext(this.bufferSize, {
|
|
420
|
+
onwrite: (chunk) => {
|
|
421
|
+
this.chunks.push(b4a.from(chunk))
|
|
422
|
+
return chunk.length
|
|
423
|
+
}
|
|
424
|
+
})
|
|
425
|
+
|
|
426
|
+
this.containerFormat = this.outputParameters?.format || 'mp4'
|
|
427
|
+
|
|
428
|
+
if (!formatRegistry.hasFormat(this.containerFormat)) {
|
|
429
|
+
throw new Error(`Unsupported output format: ${this.containerFormat}`)
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
this.outputFormatContext = new ffmpeg.OutputFormatContext(this.containerFormat, outIO)
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
#discoverAndConfigureStreams() {
|
|
436
|
+
for (const inputStream of this.inputFormatContext.streams) {
|
|
437
|
+
const codecType = inputStream.codecParameters.type
|
|
438
|
+
|
|
439
|
+
if (codecType !== VIDEO && codecType !== AUDIO) {
|
|
440
|
+
continue
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
const config = TranscodeStreamConfig.create(
|
|
444
|
+
inputStream,
|
|
445
|
+
this.outputFormatContext,
|
|
446
|
+
this.containerFormat,
|
|
447
|
+
this.outputParameters
|
|
448
|
+
)
|
|
449
|
+
|
|
450
|
+
if (config) {
|
|
451
|
+
this.configs[inputStream.index] = config
|
|
452
|
+
}
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
#configureOutput() {
|
|
457
|
+
const options = formatRegistry.getMuxerOptions(this.containerFormat)
|
|
458
|
+
const muxerOptions = ffmpeg.Dictionary.from(options)
|
|
459
|
+
|
|
460
|
+
this.outputFormatContext.writeHeader(muxerOptions)
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
#processFrames() {
|
|
464
|
+
const packet = new ffmpeg.Packet()
|
|
465
|
+
const frame = new ffmpeg.Frame()
|
|
466
|
+
|
|
467
|
+
try {
|
|
468
|
+
while (this.inputFormatContext.readFrame(packet)) {
|
|
469
|
+
const config = this.configs[packet.streamIndex]
|
|
470
|
+
if (!config) {
|
|
471
|
+
packet.unref()
|
|
472
|
+
continue
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
const { decoder } = config
|
|
476
|
+
|
|
477
|
+
if (decoder.sendPacket(packet)) {
|
|
478
|
+
while (decoder.receiveFrame(frame)) {
|
|
479
|
+
if (config.isVideo()) {
|
|
480
|
+
this.videoProcessor.process(frame, config, packet)
|
|
481
|
+
} else if (config.isAudio()) {
|
|
482
|
+
this.audioProcessor.process(frame, config, packet)
|
|
483
|
+
}
|
|
484
|
+
}
|
|
485
|
+
}
|
|
486
|
+
packet.unref()
|
|
487
|
+
}
|
|
488
|
+
} finally {
|
|
489
|
+
packet.destroy()
|
|
490
|
+
frame.destroy()
|
|
491
|
+
}
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
#finalize() {
|
|
495
|
+
const packet = new ffmpeg.Packet()
|
|
496
|
+
|
|
497
|
+
try {
|
|
498
|
+
for (const index in this.configs) {
|
|
499
|
+
const config = this.configs[index]
|
|
500
|
+
this.audioProcessor.flush(config, packet)
|
|
501
|
+
|
|
502
|
+
this._encodeAndWrite(config.encoder, null, config.outputStream, packet)
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
this.outputFormatContext.writeTrailer()
|
|
506
|
+
} finally {
|
|
507
|
+
packet.destroy()
|
|
508
|
+
}
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
#cleanup() {
|
|
512
|
+
for (const index in this.configs) {
|
|
513
|
+
const config = this.configs[index]
|
|
514
|
+
config.decoder.destroy()
|
|
515
|
+
config.encoder.destroy()
|
|
516
|
+
if (config.rescaler) config.rescaler.destroy()
|
|
517
|
+
if (config.resampler) config.resampler.destroy()
|
|
518
|
+
if (config.fifo) config.fifo.destroy()
|
|
519
|
+
if (config.fifoFrame) config.fifoFrame.destroy()
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
if (this.inputFormatContext) this.inputFormatContext.destroy()
|
|
523
|
+
if (this.outputFormatContext) this.outputFormatContext.destroy()
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
_encodeAndWrite(encoder, frame, outputStream, packet) {
|
|
527
|
+
if (encoder.sendFrame(frame)) {
|
|
528
|
+
while (encoder.receivePacket(packet)) {
|
|
529
|
+
packet.streamIndex = outputStream.index
|
|
530
|
+
packet.rescaleTimestamps(encoder.timeBase, outputStream.timeBase)
|
|
531
|
+
this.outputFormatContext.writeFrame(packet)
|
|
532
|
+
packet.unref()
|
|
533
|
+
}
|
|
534
|
+
}
|
|
535
|
+
}
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
export { Transcoder }
|