bare-media 1.7.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,74 +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
49
43
 
50
- ## API
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 |
51
48
 
52
- > See [schema.js](shared/spec/schema.js) for the complete reference of parameters
49
+ ### encode()
53
50
 
54
- ### createPreview()
51
+ Encodes an image to a specific format
55
52
 
56
- Create a preview from a media file
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 |
57
59
 
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 |
60
+ ### crop()
70
61
 
71
- ### decodeImage()
62
+ Crop an image
72
63
 
73
- Decode an image to RGBA
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 |
71
+
72
+ ### resize()
73
+
74
+ Resize an image
75
+
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 |
81
+
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`:
74
124
 
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 |
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.
81
130
 
82
131
  ## License
83
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,22 +1,31 @@
1
1
  {
2
2
  "name": "bare-media",
3
- "version": "1.7.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
- "lint": "lunte",
11
- "test": "npm run lint && npm run format:check && brittle-bare test/index.js"
18
+ "lint": "npm run format:check && lunte",
19
+ "test": "brittle-bare test/index.js"
12
20
  },
13
21
  "keywords": [],
14
22
  "author": "Holepunch Inc",
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 }
@@ -1,10 +1,8 @@
1
1
  import getMimeType from 'get-mime-type'
2
2
  import getFileFormat from 'get-file-format'
3
3
 
4
- export const log = (...args) => console.log('[bare-media]', ...args)
5
-
6
- export function detectMimeType(buffer, path) {
7
- return getMimeType(getFileFormat(buffer)) || getMimeType(path)
4
+ export function detectMimeType(buffer) {
5
+ return getMimeType(getFileFormat(buffer))
8
6
  }
9
7
 
10
8
  export function calculateFitDimensions(width, height, maxWidth, maxHeight) {
@@ -21,3 +19,12 @@ export function calculateFitDimensions(width, height, maxWidth, maxHeight) {
21
19
  height: Math.round(height * ratio)
22
20
  }
23
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 }