bare-media 1.8.0 → 2.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 CHANGED
@@ -1,6 +1,6 @@
1
1
  # bare-media
2
2
 
3
- A set of media apis for Bare
3
+ A set of media APIs for Bare
4
4
 
5
5
  ## Install
6
6
 
@@ -10,90 +10,123 @@ npm i bare-media
10
10
 
11
11
  ## Usage
12
12
 
13
- From a single worker:
13
+ Image:
14
14
 
15
15
  ```js
16
- import worker from 'bare-media'
16
+ import { image } from 'bare-media'
17
17
 
18
- const data = await worker.createPreview({ path, maxWidth, maxHeight })
18
+ const preview = await image(path)
19
+ .decode({ maxFrames })
20
+ .resize({ maxWidth, maxHeight })
21
+ .encode({ mimetype: 'image/webp' })
19
22
  ```
20
23
 
21
- Manually instantiate one or multiple workers:
24
+ Video:
22
25
 
23
26
  ```js
24
- import { WorkerClient } from 'bare-media/client'
27
+ import { video } from 'bare-media'
25
28
 
26
- const worker = new WorkerClient()
27
- const data = await worker.createPreview({ path, maxWidth, maxHeight })
29
+ const frames = video(path).extractFrames({ frameIndex })
28
30
  ```
29
31
 
30
- > NOTE: A worker spawns when an operation is requested and it stays running until the parent process is killed.
31
-
32
- Terminate the worker:
32
+ Each method can also be used independently:
33
33
 
34
34
  ```js
35
- worker.close()
36
-
37
- worker.onClose = () => {
38
- // worker terminated
39
- }
35
+ const rgba = await image.decode(buffer, { maxFrames })
40
36
  ```
41
37
 
42
- Call the methods directly without a worker:
38
+ ## Image API
43
39
 
44
- ```js
45
- import { createPreview } from 'bare-media/worker/media.js'
40
+ ### decode()
46
41
 
47
- const data = await createPreview({ path, maxWidth, maxHeight })
48
- ```
42
+ Decode an image to RGBA
43
+
44
+ | Parameter | Type | Description |
45
+ | ---------------- | ------ | --------------------------------------------------------- |
46
+ | `buffer` | object | The encoded image |
47
+ | `opts.maxFrames` | number | Max number of frames to decode in case of animated images |
49
48
 
50
- ## API
49
+ ### encode()
51
50
 
52
- > See [schema.js](shared/spec/schema.js) for the complete reference of parameters
51
+ Encodes an image to a specific format
53
52
 
54
- ### createPreview()
53
+ | Parameter | Type | Description |
54
+ | --------------- | ------ | ----------------------------------------------------------------------------------- |
55
+ | `buffer` | object | The rgba image |
56
+ | `opts.mimetype` | string | The mimetype of the output image |
57
+ | `opts.maxBytes` | number | Max bytes for the encoded image (reduces quality or fps in case of animated images) |
58
+ | `opts...` | any | Additional encoder-specific options |
55
59
 
56
- Create a preview from a media file
60
+ ### crop()
57
61
 
58
- | Property | Type | Description |
59
- | ----------- | ------ | ------------------------------------------------------------------------- |
60
- | `path` | string | Path to the input file. Either `path`, `httpLink` or `buffer` is required |
61
- | `httpLink` | string | Http link to the input file |
62
- | `buffer` | object | Bytes of the input file |
63
- | `mimetype` | string | Media type of the input file. If not provided it will be detected |
64
- | `maxWidth` | number | Max width for the generated preview |
65
- | `maxHeight` | number | Max height for the generated preview |
66
- | `maxFrames` | number | Max frames for the generated preview in case the file is animated |
67
- | `maxBytes` | number | Max bytes for the generated preview |
68
- | `format` | string | Media type for the generated preview. Default `image/webp` |
69
- | `encoding` | string | `base64` or nothing for buffer |
62
+ Crop an image
70
63
 
71
- ### decodeImage()
64
+ | Parameter | Type | Description |
65
+ | ------------- | ------ | ---------------------------- |
66
+ | `buffer` | object | The rgba image |
67
+ | `opts.left` | number | Offset from left edge |
68
+ | `opts.top` | number | Offset from top edge |
69
+ | `opts.width` | number | Width of the region to crop |
70
+ | `opts.height` | number | Height of the region to crop |
72
71
 
73
- Decode an image to RGBA
72
+ ### resize()
74
73
 
75
- | Property | Type | Description |
76
- | ---------- | ------ | ------------------------------------------------------------------------- |
77
- | `path` | string | Path to the input file. Either `path`, `httpLink` or `buffer` is required |
78
- | `httpLink` | string | Http link to the input file |
79
- | `buffer` | object | Bytes of the input file |
80
- | `mimetype` | string | Media type of the input file. If not provided it will be detected |
74
+ Resize an image
81
75
 
82
- ### cropImage()
76
+ | Parameter | Type | Description |
77
+ | ---------------- | ------ | ----------------------------- |
78
+ | `buffer` | object | The rgba image |
79
+ | `opts.maxWidth` | number | Max width of the output rgba |
80
+ | `opts.maxHeight` | number | Max height of the output rgba |
83
81
 
84
- Crop an image
82
+ ### slice()
83
+
84
+ Limits an animated image to a subset of frames. If the image is not animated, it returns the same rgba.
85
+
86
+ | Parameter | Type | Description |
87
+ | ------------ | ------ | ------------------------------------------------------------------------ |
88
+ | `buffer` | object | The rgba image |
89
+ | `opts.start` | number | Frame index at which to start extraction. Default 0. |
90
+ | `opts.end` | number | Frame index at which to end extraction. Defaults to end of the animation |
91
+
92
+ ### read()
93
+
94
+ Read an image from a file path, URL, or buffer.
95
+
96
+ | Parameter | Type | Description |
97
+ | --------- | ------ | ------------------------------------------- |
98
+ | `input` | object | File path, http(s) URL, or raw image buffer |
99
+
100
+ ### save()
101
+
102
+ Write an encoded image buffer to a file.
103
+
104
+ | Parameter | Type | Description |
105
+ | ---------- | ------ | ---------------------------------------- |
106
+ | `filename` | string | Destination file path |
107
+ | `buffer` | object | Encoded image buffer |
108
+ | `opts` | object | Options passed through to `fs.writeFile` |
109
+
110
+ ## Video API
111
+
112
+ ### extractFrames()
113
+
114
+ Extracts frames from a video in RGBA
115
+
116
+ | Parameter | Type | Description |
117
+ | ----------------- | ------ | ------------------------------ |
118
+ | `fd` | number | File descriptor |
119
+ | `opts.frameIndex` | number | Number of the frame to extract |
120
+
121
+ ## Supported Types
122
+
123
+ Helpers to check supported media types are exposed in `bare-media/types`:
85
124
 
86
- | Property | Type | Description |
87
- | ---------- | ------ | ------------------------------------------------------------------------- |
88
- | `path` | string | Path to the input file. Either `path`, `httpLink` or `buffer` is required |
89
- | `httpLink` | string | Http link to the input file |
90
- | `buffer` | object | Bytes of the input file |
91
- | `mimetype` | string | Media type of the input file. If not provided it will be detected |
92
- | `left` | number | Offset from left edge |
93
- | `top` | number | Offset from top edge |
94
- | `width` | number | Width of the region to crop |
95
- | `height` | number | Height of the region to crop |
96
- | `format` | string | Media type for the cropped image. Default same as the input image |
125
+ - `supportedImageMimetypes`: list of supported image mimetypes.
126
+ - `supportedVideoMimetypes`: list of supported video mimetypes.
127
+ - `isImageSupported(mimetype)`: returns `true` if the mimetype is a supported image format.
128
+ - `isVideoSupported(mimetype)`: returns `true` if the mimetype is a supported video format.
129
+ - `isMediaSupported(mimetype)`: returns `true` if the mimetype is either a supported image or video format.
97
130
 
98
131
  ## License
99
132
 
package/index.js CHANGED
@@ -1,5 +1,3 @@
1
- import { WorkerClient } from './client'
2
-
3
- const worker = new WorkerClient()
4
-
5
- export default worker
1
+ export * from './src/codecs'
2
+ export * from './src/image'
3
+ export * from './src/video'
package/package.json CHANGED
@@ -1,10 +1,18 @@
1
1
  {
2
2
  "name": "bare-media",
3
- "version": "1.8.0",
3
+ "version": "2.0.0",
4
4
  "main": "index.js",
5
5
  "type": "module",
6
+ "exports": {
7
+ ".": "./index.js",
8
+ "./types": "./types.js"
9
+ },
10
+ "files": [
11
+ "src",
12
+ "index.js",
13
+ "types.js"
14
+ ],
6
15
  "scripts": {
7
- "build:rpc": "cd shared/spec && bare ./build.js",
8
16
  "format": "prettier --write .",
9
17
  "format:check": "prettier --check .",
10
18
  "lint": "npm run format:check && lunte",
@@ -15,8 +23,9 @@
15
23
  "license": "Apache-2.0",
16
24
  "description": "",
17
25
  "dependencies": {
18
- "b4a": "^1.6.7",
26
+ "bare-bmp": "^1.0.0",
19
27
  "bare-fetch": "^2.4.1",
28
+ "bare-ffmpeg": "^1.0.0",
20
29
  "bare-fs": "^4.1.5",
21
30
  "bare-gif": "^1.1.2",
22
31
  "bare-heif": "^1.0.5",
@@ -25,28 +34,19 @@
25
34
  "bare-png": "^1.0.2",
26
35
  "bare-tiff": "^1.0.1",
27
36
  "bare-webp": "^1.0.3",
28
- "cross-worker": "^1.1.0",
29
37
  "get-file-format": "^1.0.1",
30
- "get-mime-type": "^2.0.1",
31
- "hrpc": "^4.0.0",
32
- "hyperschema": "^1.12.3",
33
- "ready-resource": "^1.2.0",
34
- "uncaughts": "^1.1.1"
38
+ "get-mime-type": "^2.0.1"
35
39
  },
36
40
  "devDependencies": {
41
+ "b4a": "^1.7.3",
42
+ "bare-os": "^3.6.2",
37
43
  "brittle": "^3.16.3",
38
44
  "corestore": "^7.4.5",
39
45
  "hyperblobs": "^2.8.0",
40
46
  "hypercore-blob-server": "^1.11.0",
41
- "lunte": "^1.3.0",
42
- "prettier": "^3.6.2",
43
- "prettier-config-holepunch": "^1.0.0",
47
+ "lunte": "^1.4.0",
48
+ "prettier": "^3.7.4",
49
+ "prettier-config-holepunch": "^2.0.0",
44
50
  "test-tmp": "^1.4.0"
45
- },
46
- "files": [
47
- "shared",
48
- "worker",
49
- "client.js",
50
- "index.js"
51
- ]
51
+ }
52
52
  }
package/src/codecs.js ADDED
@@ -0,0 +1,26 @@
1
+ import { IMAGE } from '../types'
2
+
3
+ export const codecs = {
4
+ [IMAGE.AVIF]: () => import('bare-heif'),
5
+ [IMAGE.GIF]: () => import('bare-gif'),
6
+ [IMAGE.BMP]: () => import('bare-bmp'),
7
+ [IMAGE.HEIC]: () => import('bare-heif'),
8
+ [IMAGE.HEIF]: () => import('bare-heif'),
9
+ [IMAGE.JPEG]: () => import('bare-jpeg'),
10
+ [IMAGE.JPG]: () => import('bare-jpeg'),
11
+ [IMAGE.PNG]: () => import('bare-png'),
12
+ [IMAGE.TIF]: () => import('bare-tiff'),
13
+ [IMAGE.TIFF]: () => import('bare-tiff'),
14
+ [IMAGE.WEBP]: () => import('bare-webp'),
15
+ [IMAGE.X_MS_BMP]: () => import('bare-bmp')
16
+ }
17
+
18
+ export async function importCodec(mimetype) {
19
+ const codecImport = codecs[mimetype]
20
+ if (!codecImport) throw new Error(`Unsupported file type: No codec available for ${mimetype}`)
21
+ return await codecImport()
22
+ }
23
+
24
+ export function supportsQuality(mimetype) {
25
+ return { 'image/webp': true, 'image/jpeg': true }[mimetype] || false
26
+ }
package/src/image.js ADDED
@@ -0,0 +1,262 @@
1
+ import fs from 'bare-fs'
2
+ import fetch from 'bare-fetch'
3
+
4
+ import { importCodec, supportsQuality } from './codecs.js'
5
+ import { isHttpUrl, detectMimeType, calculateFitDimensions } from './util'
6
+
7
+ const animatableMimetypes = ['image/gif', 'image/webp']
8
+
9
+ async function read(input) {
10
+ let buffer
11
+
12
+ if (typeof input === 'string') {
13
+ if (isHttpUrl(input)) {
14
+ const response = await fetch(input)
15
+ buffer = await response.buffer()
16
+ } else {
17
+ buffer = await fs.readFile(input)
18
+ }
19
+ } else {
20
+ buffer = input
21
+ }
22
+
23
+ return buffer
24
+ }
25
+
26
+ async function save(filename, buffer, opts) {
27
+ return fs.writeFile(filename, buffer, opts)
28
+ }
29
+
30
+ async function decode(buffer, opts = {}) {
31
+ const { maxFrames = 0 } = opts
32
+
33
+ let rgba
34
+
35
+ const mimetype = detectMimeType(buffer)
36
+ const codec = await importCodec(mimetype)
37
+
38
+ if (animatableMimetypes.includes(mimetype)) {
39
+ const { width, height, loops, frames } = codec.decodeAnimated(buffer)
40
+ const data = []
41
+ for (const frame of frames) {
42
+ if (maxFrames > 0 && data.length >= maxFrames) break
43
+ data.push(frame)
44
+ }
45
+ rgba = { width, height, loops, frames: data }
46
+ } else {
47
+ rgba = codec.decode(buffer)
48
+ }
49
+
50
+ return rgba
51
+ }
52
+
53
+ async function encode(rgba, opts = {}) {
54
+ const { mimetype, maxBytes, ...codecOpts } = opts
55
+
56
+ let encoded = await _encodeRGBA(rgba, mimetype, codecOpts)
57
+
58
+ if (maxBytes) {
59
+ if (encoded.byteLength > maxBytes && supportsQuality(mimetype)) {
60
+ const MIN_QUALITY = 50
61
+ for (let quality = 80; quality >= MIN_QUALITY; quality -= 15) {
62
+ encoded = await _encodeRGBA(rgba, mimetype, { quality })
63
+ if (encoded.byteLength <= maxBytes) {
64
+ break
65
+ }
66
+ }
67
+ }
68
+
69
+ if (encoded.byteLength > maxBytes && rgba.frames?.length > 1) {
70
+ const quality = 75
71
+
72
+ for (const dropEvery of [4, 3, 2]) {
73
+ const frames = rgba.frames.filter((frame, index) => index % dropEvery !== 0)
74
+ const filtered = { ...rgba, frames }
75
+ encoded = await _encodeRGBA(filtered, mimetype, { quality })
76
+ if (encoded.byteLength <= maxBytes) {
77
+ break
78
+ }
79
+ }
80
+
81
+ if (encoded.byteLength > maxBytes) {
82
+ const frames = rgba.frames.slice(0, 50).filter((frame, index) => index % 2 === 0)
83
+ const capped = { ...rgba, frames }
84
+ encoded = await _encodeRGBA(capped, mimetype, { quality })
85
+ }
86
+
87
+ if (encoded.byteLength > maxBytes) {
88
+ const oneFrame = { ...rgba, frames: rgba.frames.slice(0, 1) }
89
+ encoded = await _encodeRGBA(oneFrame, mimetype)
90
+ }
91
+ }
92
+
93
+ if (encoded.byteLength > maxBytes) {
94
+ throw new Error(
95
+ `Could not create preview under maxBytes, reached ${encoded.byteLength} bytes`
96
+ )
97
+ }
98
+ }
99
+
100
+ return encoded
101
+ }
102
+
103
+ async function crop(rgba, opts = {}) {
104
+ const { left, top, width, height } = opts
105
+
106
+ if (
107
+ left < 0 ||
108
+ top < 0 ||
109
+ width <= 0 ||
110
+ height <= 0 ||
111
+ left + width > rgba.width ||
112
+ top + height > rgba.height
113
+ ) {
114
+ throw new Error('Crop rectangle out of bounds')
115
+ }
116
+
117
+ const data = Buffer.alloc(width * height * 4)
118
+
119
+ for (let y = 0; y < height; y++) {
120
+ for (let x = 0; x < width; x++) {
121
+ const srcIndex = ((y + top) * rgba.width + (x + left)) * 4
122
+ const dstIndex = (y * width + x) * 4
123
+
124
+ data[dstIndex] = rgba.data[srcIndex]
125
+ data[dstIndex + 1] = rgba.data[srcIndex + 1]
126
+ data[dstIndex + 2] = rgba.data[srcIndex + 2]
127
+ data[dstIndex + 3] = rgba.data[srcIndex + 3]
128
+ }
129
+ }
130
+
131
+ return { width, height, data }
132
+ }
133
+
134
+ async function resize(rgba, opts = {}) {
135
+ const { maxWidth, maxHeight } = opts
136
+ const { width, height } = rgba
137
+
138
+ let maybeResizedRGBA
139
+
140
+ if (maxWidth && maxHeight && (width > maxWidth || height > maxHeight)) {
141
+ const { resize } = await import('bare-image-resample')
142
+ const dimensions = calculateFitDimensions(width, height, maxWidth, maxHeight)
143
+ if (Array.isArray(rgba.frames)) {
144
+ const frames = []
145
+ for (const frame of rgba.frames) {
146
+ const resized = resize(frame, dimensions.width, dimensions.height)
147
+ frames.push({ ...resized, timestamp: frame.timestamp })
148
+ }
149
+ maybeResizedRGBA = {
150
+ width: frames[0].width,
151
+ height: frames[0].height,
152
+ loops: rgba.loops,
153
+ frames
154
+ }
155
+ } else {
156
+ maybeResizedRGBA = resize(rgba, dimensions.width, dimensions.height)
157
+ }
158
+ } else {
159
+ maybeResizedRGBA = rgba
160
+ }
161
+
162
+ return maybeResizedRGBA
163
+ }
164
+
165
+ function slice(rgba, opts = {}) {
166
+ if (Array.isArray(rgba.frames)) {
167
+ let { start = 0, end = rgba.frames.length } = opts
168
+
169
+ start = Math.max(0, start)
170
+ end = Math.max(0, end)
171
+
172
+ if (start >= end) {
173
+ throw new Error('slice(): "start" must be less than "end"')
174
+ }
175
+
176
+ return {
177
+ ...rgba,
178
+ frames: rgba.frames.slice(start, end)
179
+ }
180
+ }
181
+
182
+ return rgba
183
+ }
184
+
185
+ async function _encodeRGBA(rgba, mimetype, opts) {
186
+ const codec = await importCodec(mimetype)
187
+
188
+ let encoded
189
+ if (Array.isArray(rgba.frames)) {
190
+ encoded = codec.encodeAnimated(rgba, opts)
191
+ } else {
192
+ encoded = codec.encode(rgba, opts)
193
+ }
194
+
195
+ return encoded
196
+ }
197
+
198
+ class ImagePipeline {
199
+ constructor(input) {
200
+ this.input = input
201
+ this.steps = []
202
+
203
+ const methods = ['decode', 'resize', 'crop', 'slice', 'encode']
204
+ for (let method of methods) {
205
+ this[method] = (opts) => {
206
+ this.steps.push({ op: method, opts })
207
+ return this
208
+ }
209
+ }
210
+ }
211
+
212
+ async then(resolve, reject) {
213
+ try {
214
+ let buffer = await read(this.input)
215
+
216
+ for (const step of this.steps) {
217
+ if (step.op === 'decode') {
218
+ buffer = await decode(buffer, step.opts)
219
+ }
220
+
221
+ if (step.op === 'resize') {
222
+ buffer = await resize(buffer, step.opts)
223
+ }
224
+
225
+ if (step.op === 'crop') {
226
+ buffer = await crop(buffer, step.opts)
227
+ }
228
+
229
+ if (step.op === 'slice') {
230
+ buffer = slice(buffer, step.opts)
231
+ }
232
+
233
+ if (step.op === 'encode') {
234
+ buffer = await encode(buffer, step.opts)
235
+ }
236
+ }
237
+
238
+ resolve(buffer)
239
+ } catch (err) {
240
+ reject(err)
241
+ }
242
+ }
243
+
244
+ async save(filename, opts) {
245
+ const buffer = await this
246
+ return save(filename, buffer, opts)
247
+ }
248
+ }
249
+
250
+ function image(input) {
251
+ return new ImagePipeline(input)
252
+ }
253
+
254
+ image.read = read
255
+ image.save = save
256
+ image.decode = decode
257
+ image.resize = resize
258
+ image.crop = crop
259
+ image.slice = slice
260
+ image.encode = encode
261
+
262
+ export { image }
package/src/util.js ADDED
@@ -0,0 +1,30 @@
1
+ import getMimeType from 'get-mime-type'
2
+ import getFileFormat from 'get-file-format'
3
+
4
+ export function detectMimeType(buffer) {
5
+ return getMimeType(getFileFormat(buffer))
6
+ }
7
+
8
+ export function calculateFitDimensions(width, height, maxWidth, maxHeight) {
9
+ if (width <= maxWidth && height <= maxHeight) {
10
+ return { width, height }
11
+ }
12
+
13
+ const widthRatio = maxWidth / width
14
+ const heightRatio = maxHeight / height
15
+ const ratio = Math.min(widthRatio, heightRatio)
16
+
17
+ return {
18
+ width: Math.round(width * ratio),
19
+ height: Math.round(height * ratio)
20
+ }
21
+ }
22
+
23
+ export function isHttpUrl(value) {
24
+ try {
25
+ const url = new URL(value)
26
+ return url.protocol === 'http:' || url.protocol === 'https:'
27
+ } catch {
28
+ return false
29
+ }
30
+ }
package/src/video.js ADDED
@@ -0,0 +1,112 @@
1
+ import fs from 'bare-fs'
2
+ import ffmpeg from 'bare-ffmpeg'
3
+
4
+ function extractFrames(fd, opts = {}) {
5
+ const { frameIndex } = opts
6
+
7
+ const fileSize = fs.fstatSync(fd).size
8
+ let offset = 0
9
+
10
+ const io = new ffmpeg.IOContext(4096, {
11
+ onread: (buffer, requested) => {
12
+ const read = fs.readSync(fd, buffer, 0, requested, offset)
13
+ if (read === 0) return 0
14
+ offset += read
15
+ return read
16
+ },
17
+ onseek: (o, whence) => {
18
+ if (whence === ffmpeg.constants.seek.SIZE) return fileSize
19
+ if (whence === ffmpeg.constants.seek.SET) offset = o
20
+ else if (whence === ffmpeg.constants.seek.CUR) offset += o
21
+ else if (whence === ffmpeg.constants.seek.END) offset = fileSize + o
22
+ else return -1
23
+ return offset
24
+ }
25
+ })
26
+
27
+ using inputFormat = new ffmpeg.InputFormatContext(io)
28
+ const stream = inputFormat.getBestStream(ffmpeg.constants.mediaTypes.VIDEO)
29
+ const decoder = stream.decoder()
30
+ decoder.open()
31
+
32
+ using packet = new ffmpeg.Packet()
33
+ using frame = new ffmpeg.Frame()
34
+
35
+ let currentFrame = 0
36
+ let result = null
37
+
38
+ while (inputFormat.readFrame(packet)) {
39
+ if (packet.streamIndex === stream.index) {
40
+ if (decoder.sendPacket(packet)) {
41
+ while (decoder.receiveFrame(frame)) {
42
+ if (currentFrame === frameIndex) {
43
+ // Convert to RGBA
44
+ using scaler = new ffmpeg.Scaler(
45
+ frame.format,
46
+ frame.width,
47
+ frame.height,
48
+ ffmpeg.constants.pixelFormats.RGBA,
49
+ frame.width,
50
+ frame.height
51
+ )
52
+
53
+ using rgbaFrame = new ffmpeg.Frame()
54
+ rgbaFrame.width = frame.width
55
+ rgbaFrame.height = frame.height
56
+ rgbaFrame.format = ffmpeg.constants.pixelFormats.RGBA
57
+ rgbaFrame.alloc()
58
+
59
+ scaler.scale(frame, rgbaFrame)
60
+
61
+ const image = new ffmpeg.Image(
62
+ ffmpeg.constants.pixelFormats.RGBA,
63
+ rgbaFrame.width,
64
+ rgbaFrame.height
65
+ )
66
+ image.read(rgbaFrame)
67
+
68
+ result = {
69
+ width: rgbaFrame.width,
70
+ height: rgbaFrame.height,
71
+ data: image.data
72
+ }
73
+ break
74
+ }
75
+ currentFrame++
76
+ }
77
+ }
78
+ }
79
+ packet.unref()
80
+ if (result) break
81
+ }
82
+
83
+ decoder.destroy()
84
+
85
+ if (!result) {
86
+ throw new Error(`Frame ${frameIndex} not found (video only has ${currentFrame} frames)`)
87
+ }
88
+
89
+ return result
90
+ }
91
+
92
+ class VideoPipeline {
93
+ constructor(input) {
94
+ this.input = input
95
+ this.steps = []
96
+ }
97
+
98
+ extractFrames(opts) {
99
+ const fd = fs.openSync(this.input, 'r')
100
+ const result = extractFrames(fd, opts)
101
+ fs.closeSync(fd)
102
+ return result
103
+ }
104
+ }
105
+
106
+ function video(input) {
107
+ return new VideoPipeline(input)
108
+ }
109
+
110
+ video.extractFrames = extractFrames
111
+
112
+ export { video }