@uploadista/flow-videos-av-node 0.0.13 → 0.0.14
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/.turbo/turbo-build.log +8 -8
- package/README.md +1 -1
- package/dist/index.cjs +2 -2
- package/dist/index.d.cts.map +1 -1
- package/dist/index.d.mts.map +1 -1
- package/dist/index.mjs +2 -2
- package/dist/index.mjs.map +1 -1
- package/package.json +5 -6
- package/src/utils/av-check.ts +1 -1
- package/src/utils/memory-io.ts +54 -0
- package/src/video-plugin.ts +304 -349
- package/tsdown.config.ts +6 -0
- package/src/utils/temp-file-manager.ts +0 -43
package/src/video-plugin.ts
CHANGED
|
@@ -1,25 +1,17 @@
|
|
|
1
|
-
import { randomUUID } from "node:crypto";
|
|
2
|
-
import { promises as fs } from "node:fs";
|
|
3
|
-
import { tmpdir } from "node:os";
|
|
4
|
-
import { join } from "node:path";
|
|
5
1
|
import { UploadistaError } from "@uploadista/core/errors";
|
|
6
2
|
import type {
|
|
7
3
|
DescribeVideoMetadata,
|
|
8
4
|
VideoPluginShape,
|
|
9
5
|
} from "@uploadista/core/flow";
|
|
10
6
|
import { Effect } from "effect";
|
|
11
|
-
import { Decoder,
|
|
7
|
+
import { Decoder, Demuxer, Encoder, Muxer } from "node-av/api";
|
|
12
8
|
import type { Packet } from "node-av/lib";
|
|
13
9
|
import {
|
|
14
10
|
audioCodecToAVName,
|
|
15
11
|
codecToAVName,
|
|
16
12
|
imageFormatToEncoder,
|
|
17
13
|
} from "./utils/format-mappings";
|
|
18
|
-
import {
|
|
19
|
-
bytesToTempFile,
|
|
20
|
-
cleanup,
|
|
21
|
-
tempFileToBytes,
|
|
22
|
-
} from "./utils/temp-file-manager";
|
|
14
|
+
import { createMemoryOutput } from "./utils/memory-io";
|
|
23
15
|
|
|
24
16
|
/**
|
|
25
17
|
* Creates a node-av based video processing plugin
|
|
@@ -29,60 +21,53 @@ export function createAVNodeVideoPlugin(): VideoPluginShape {
|
|
|
29
21
|
describe: (input) =>
|
|
30
22
|
Effect.tryPromise({
|
|
31
23
|
try: async () => {
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
await using mediaInput = await MediaInput.open(inputPath);
|
|
24
|
+
// Convert Uint8Array to Buffer for node-av
|
|
25
|
+
const buffer = Buffer.from(input);
|
|
26
|
+
await using mediaInput = await Demuxer.open(buffer);
|
|
36
27
|
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
if (!videoStream) {
|
|
41
|
-
throw new Error("No video stream found");
|
|
42
|
-
}
|
|
28
|
+
const videoStream = mediaInput.video();
|
|
29
|
+
const audioStream = mediaInput.audio();
|
|
43
30
|
|
|
44
|
-
|
|
31
|
+
if (!videoStream) {
|
|
32
|
+
throw new Error("No video stream found");
|
|
33
|
+
}
|
|
45
34
|
|
|
46
|
-
|
|
47
|
-
let frameRate = 0;
|
|
48
|
-
if (videoStream.rFrameRate) {
|
|
49
|
-
const { num, den } = videoStream.rFrameRate;
|
|
50
|
-
frameRate = den ? num / den : num;
|
|
51
|
-
}
|
|
35
|
+
const videoCodecParams = videoStream.codecpar;
|
|
52
36
|
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
37
|
+
// Calculate frame rate from rational number
|
|
38
|
+
let frameRate = 0;
|
|
39
|
+
if (videoStream.rFrameRate) {
|
|
40
|
+
const { num, den } = videoStream.rFrameRate;
|
|
41
|
+
frameRate = den ? num / den : num;
|
|
42
|
+
}
|
|
59
43
|
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
const
|
|
64
|
-
|
|
65
|
-
width: videoCodecParams.width || 0,
|
|
66
|
-
height: videoCodecParams.height || 0,
|
|
67
|
-
codec: String(videoCodecParams.codecId) || "unknown",
|
|
68
|
-
format: mediaInput.formatName || "unknown",
|
|
69
|
-
bitrate: mediaInput.bitRate || 0,
|
|
70
|
-
frameRate,
|
|
71
|
-
aspectRatio,
|
|
72
|
-
hasAudio: !!audioStream,
|
|
73
|
-
audioCodec: audioStream?.codecpar.codecId
|
|
74
|
-
? String(audioStream.codecpar.codecId)
|
|
75
|
-
: undefined,
|
|
76
|
-
audioBitrate: audioStream?.codecpar.bitRate
|
|
77
|
-
? Number(audioStream.codecpar.bitRate)
|
|
78
|
-
: undefined,
|
|
79
|
-
size: stats.size,
|
|
80
|
-
};
|
|
81
|
-
|
|
82
|
-
return metadata;
|
|
83
|
-
} finally {
|
|
84
|
-
await cleanup([inputPath]);
|
|
44
|
+
// Get aspect ratio
|
|
45
|
+
let aspectRatio = "unknown";
|
|
46
|
+
if (videoStream.sampleAspectRatio) {
|
|
47
|
+
const { num, den } = videoStream.sampleAspectRatio;
|
|
48
|
+
aspectRatio = `${num}:${den}`;
|
|
85
49
|
}
|
|
50
|
+
|
|
51
|
+
const metadata: DescribeVideoMetadata = {
|
|
52
|
+
duration: mediaInput.duration || 0,
|
|
53
|
+
width: videoCodecParams.width || 0,
|
|
54
|
+
height: videoCodecParams.height || 0,
|
|
55
|
+
codec: String(videoCodecParams.codecId) || "unknown",
|
|
56
|
+
format: mediaInput.formatName || "unknown",
|
|
57
|
+
bitrate: mediaInput.bitRate || 0,
|
|
58
|
+
frameRate,
|
|
59
|
+
aspectRatio,
|
|
60
|
+
hasAudio: !!audioStream,
|
|
61
|
+
audioCodec: audioStream?.codecpar.codecId
|
|
62
|
+
? String(audioStream.codecpar.codecId)
|
|
63
|
+
: undefined,
|
|
64
|
+
audioBitrate: audioStream?.codecpar.bitRate
|
|
65
|
+
? Number(audioStream.codecpar.bitRate)
|
|
66
|
+
: undefined,
|
|
67
|
+
size: input.byteLength,
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
return metadata;
|
|
86
71
|
},
|
|
87
72
|
catch: (error) =>
|
|
88
73
|
UploadistaError.fromCode("VIDEO_METADATA_EXTRACTION_FAILED", {
|
|
@@ -94,101 +79,93 @@ export function createAVNodeVideoPlugin(): VideoPluginShape {
|
|
|
94
79
|
transcode: (input, options) =>
|
|
95
80
|
Effect.tryPromise({
|
|
96
81
|
try: async () => {
|
|
97
|
-
|
|
98
|
-
const
|
|
99
|
-
tmpdir(),
|
|
100
|
-
`uploadista-${randomUUID()}.${options.format}`,
|
|
101
|
-
);
|
|
102
|
-
|
|
103
|
-
try {
|
|
104
|
-
await using mediaInput = await MediaInput.open(inputPath);
|
|
105
|
-
await using mediaOutput = await MediaOutput.open(outputPath);
|
|
106
|
-
|
|
107
|
-
const videoStream = mediaInput.video();
|
|
108
|
-
if (!videoStream) {
|
|
109
|
-
throw new Error("No video stream found");
|
|
110
|
-
}
|
|
82
|
+
// Convert Uint8Array to Buffer for input
|
|
83
|
+
const inputBuffer = Buffer.from(input);
|
|
111
84
|
|
|
112
|
-
|
|
85
|
+
// Create in-memory output
|
|
86
|
+
const { callbacks, getOutput } = createMemoryOutput();
|
|
113
87
|
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
88
|
+
await using mediaInput = await Demuxer.open(inputBuffer);
|
|
89
|
+
await using mediaOutput = await Muxer.open(callbacks, {
|
|
90
|
+
format: options.format,
|
|
91
|
+
});
|
|
118
92
|
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
93
|
+
const videoStream = mediaInput.video();
|
|
94
|
+
if (!videoStream) {
|
|
95
|
+
throw new Error("No video stream found");
|
|
96
|
+
}
|
|
123
97
|
|
|
124
|
-
|
|
98
|
+
using videoDecoder = await Decoder.create(videoStream);
|
|
125
99
|
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
const packet = await videoEncoder.encode(frame);
|
|
131
|
-
if (packet) {
|
|
132
|
-
await mediaOutput.writePacket(packet, videoOutputIndex);
|
|
133
|
-
packet.free();
|
|
134
|
-
}
|
|
135
|
-
}
|
|
100
|
+
// Determine encoder codec
|
|
101
|
+
const encoderCodec = options.codec
|
|
102
|
+
? codecToAVName[options.codec]
|
|
103
|
+
: codecToAVName.h264;
|
|
136
104
|
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
105
|
+
using videoEncoder = await Encoder.create(encoderCodec, {
|
|
106
|
+
...(options.videoBitrate && { bitrate: options.videoBitrate }),
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
const videoOutputIndex = mediaOutput.addStream(videoEncoder);
|
|
110
|
+
|
|
111
|
+
// Process video frames
|
|
112
|
+
for await (using frame of videoDecoder.frames(
|
|
113
|
+
mediaInput.packets(videoStream.index),
|
|
114
|
+
)) {
|
|
115
|
+
const packet = await videoEncoder.encode(frame);
|
|
116
|
+
if (packet) {
|
|
117
|
+
await mediaOutput.writePacket(packet, videoOutputIndex);
|
|
118
|
+
packet.free();
|
|
144
119
|
}
|
|
120
|
+
}
|
|
145
121
|
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
122
|
+
// Flush remaining packets
|
|
123
|
+
await videoEncoder.flush();
|
|
124
|
+
let transcodeVPacket: Packet | null = await videoEncoder.receive();
|
|
125
|
+
while (transcodeVPacket !== null) {
|
|
126
|
+
await mediaOutput.writePacket(transcodeVPacket, videoOutputIndex);
|
|
127
|
+
transcodeVPacket.free();
|
|
128
|
+
transcodeVPacket = await videoEncoder.receive();
|
|
129
|
+
}
|
|
150
130
|
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
131
|
+
// Handle audio stream if present
|
|
132
|
+
const audioStream = mediaInput.audio();
|
|
133
|
+
if (audioStream) {
|
|
134
|
+
using audioDecoder = await Decoder.create(audioStream);
|
|
154
135
|
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
});
|
|
136
|
+
const audioEncoderCodec = options.audioCodec
|
|
137
|
+
? audioCodecToAVName[options.audioCodec]
|
|
138
|
+
: audioCodecToAVName.aac;
|
|
159
139
|
|
|
160
|
-
|
|
140
|
+
using audioEncoder = await Encoder.create(audioEncoderCodec, {
|
|
141
|
+
...(options.audioBitrate && { bitrate: options.audioBitrate }),
|
|
142
|
+
});
|
|
161
143
|
|
|
162
|
-
|
|
163
|
-
for await (using frame of audioDecoder.frames(
|
|
164
|
-
mediaInput.packets(audioStream.index),
|
|
165
|
-
)) {
|
|
166
|
-
const packet = await audioEncoder.encode(frame);
|
|
167
|
-
if (packet) {
|
|
168
|
-
await mediaOutput.writePacket(packet, audioOutputIndex);
|
|
169
|
-
packet.free();
|
|
170
|
-
}
|
|
171
|
-
}
|
|
144
|
+
const audioOutputIndex = mediaOutput.addStream(audioEncoder);
|
|
172
145
|
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
);
|
|
182
|
-
transcodeAPacket.free();
|
|
183
|
-
transcodeAPacket = await audioEncoder.receive();
|
|
146
|
+
// Process audio frames
|
|
147
|
+
for await (using frame of audioDecoder.frames(
|
|
148
|
+
mediaInput.packets(audioStream.index),
|
|
149
|
+
)) {
|
|
150
|
+
const packet = await audioEncoder.encode(frame);
|
|
151
|
+
if (packet) {
|
|
152
|
+
await mediaOutput.writePacket(packet, audioOutputIndex);
|
|
153
|
+
packet.free();
|
|
184
154
|
}
|
|
185
155
|
}
|
|
186
156
|
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
157
|
+
// Flush remaining packets
|
|
158
|
+
await audioEncoder.flush();
|
|
159
|
+
let transcodeAPacket: Packet | null = await audioEncoder.receive();
|
|
160
|
+
while (transcodeAPacket !== null) {
|
|
161
|
+
await mediaOutput.writePacket(transcodeAPacket, audioOutputIndex);
|
|
162
|
+
transcodeAPacket.free();
|
|
163
|
+
transcodeAPacket = await audioEncoder.receive();
|
|
164
|
+
}
|
|
191
165
|
}
|
|
166
|
+
|
|
167
|
+
// Return accumulated output
|
|
168
|
+
return getOutput();
|
|
192
169
|
},
|
|
193
170
|
catch: (error) =>
|
|
194
171
|
UploadistaError.fromCode("VIDEO_PROCESSING_FAILED", {
|
|
@@ -200,100 +177,94 @@ export function createAVNodeVideoPlugin(): VideoPluginShape {
|
|
|
200
177
|
resize: (input, options) =>
|
|
201
178
|
Effect.tryPromise({
|
|
202
179
|
try: async () => {
|
|
203
|
-
|
|
204
|
-
const
|
|
180
|
+
// Convert Uint8Array to Buffer for input
|
|
181
|
+
const inputBuffer = Buffer.from(input);
|
|
205
182
|
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
await using mediaOutput = await MediaOutput.open(outputPath);
|
|
183
|
+
// Create in-memory output
|
|
184
|
+
const { callbacks, getOutput } = createMemoryOutput();
|
|
209
185
|
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
186
|
+
await using mediaInput = await Demuxer.open(inputBuffer);
|
|
187
|
+
await using mediaOutput = await Muxer.open(callbacks, {
|
|
188
|
+
format: "mp4",
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
const videoStream = mediaInput.video();
|
|
192
|
+
if (!videoStream) {
|
|
193
|
+
throw new Error("No video stream found");
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
using videoDecoder = await Decoder.create(videoStream);
|
|
214
197
|
|
|
215
|
-
|
|
198
|
+
// TODO: Implement proper resizing with FilterAPI
|
|
199
|
+
// Currently, resize functionality is limited because node-av's Encoder
|
|
200
|
+
// auto-initializes from the first frame it receives. To implement proper
|
|
201
|
+
// resizing, we would need to:
|
|
202
|
+
// 1. Use FilterAPI.create('scale', { width, height }) to create a scale filter
|
|
203
|
+
// 2. Pass decoded frames through the filter before encoding
|
|
204
|
+
// For now, this function will pass through frames without resizing.
|
|
216
205
|
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
// 1. Use FilterAPI.create('scale', { width, height }) to create a scale filter
|
|
222
|
-
// 2. Pass decoded frames through the filter before encoding
|
|
223
|
-
// For now, this function will pass through frames without resizing.
|
|
206
|
+
// Validate that resize parameters are provided
|
|
207
|
+
if (!options.width && !options.height) {
|
|
208
|
+
throw new Error("Either width or height must be specified");
|
|
209
|
+
}
|
|
224
210
|
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
211
|
+
using videoEncoder = await Encoder.create(codecToAVName.h264);
|
|
212
|
+
|
|
213
|
+
const videoOutputIndex = mediaOutput.addStream(videoEncoder);
|
|
214
|
+
|
|
215
|
+
// Process video frames with resizing
|
|
216
|
+
// Note: For production use, consider using FilterAPI for better quality scaling
|
|
217
|
+
for await (using frame of videoDecoder.frames(
|
|
218
|
+
mediaInput.packets(videoStream.index),
|
|
219
|
+
)) {
|
|
220
|
+
// TODO: Apply scale filter here for better quality
|
|
221
|
+
// For now, encoder will handle basic resizing
|
|
222
|
+
const packet = await videoEncoder.encode(frame);
|
|
223
|
+
if (packet) {
|
|
224
|
+
await mediaOutput.writePacket(packet, videoOutputIndex);
|
|
225
|
+
packet.free();
|
|
228
226
|
}
|
|
227
|
+
}
|
|
229
228
|
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
229
|
+
// Flush remaining packets
|
|
230
|
+
await videoEncoder.flush();
|
|
231
|
+
let vPacket: Packet | null = await videoEncoder.receive();
|
|
232
|
+
while (vPacket !== null) {
|
|
233
|
+
await mediaOutput.writePacket(vPacket, videoOutputIndex);
|
|
234
|
+
vPacket.free();
|
|
235
|
+
vPacket = await videoEncoder.receive();
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
// Copy audio stream if present
|
|
239
|
+
const audioStream = mediaInput.audio();
|
|
240
|
+
if (audioStream) {
|
|
241
|
+
using audioDecoder = await Decoder.create(audioStream);
|
|
242
|
+
using audioEncoder = await Encoder.create(audioCodecToAVName.aac);
|
|
233
243
|
|
|
234
|
-
const
|
|
244
|
+
const audioOutputIndex = mediaOutput.addStream(audioEncoder);
|
|
235
245
|
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
for await (using frame of videoDecoder.frames(
|
|
239
|
-
mediaInput.packets(videoStream.index),
|
|
246
|
+
for await (using frame of audioDecoder.frames(
|
|
247
|
+
mediaInput.packets(audioStream.index),
|
|
240
248
|
)) {
|
|
241
|
-
|
|
242
|
-
// For now, encoder will handle basic resizing
|
|
243
|
-
const packet = await videoEncoder.encode(frame);
|
|
249
|
+
const packet = await audioEncoder.encode(frame);
|
|
244
250
|
if (packet) {
|
|
245
|
-
await mediaOutput.writePacket(packet,
|
|
251
|
+
await mediaOutput.writePacket(packet, audioOutputIndex);
|
|
246
252
|
packet.free();
|
|
247
253
|
}
|
|
248
254
|
}
|
|
249
255
|
|
|
250
256
|
// Flush remaining packets
|
|
251
|
-
await
|
|
252
|
-
let
|
|
253
|
-
while (
|
|
254
|
-
await mediaOutput.writePacket(
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
}
|
|
258
|
-
|
|
259
|
-
// Copy audio stream if present
|
|
260
|
-
const audioStream = mediaInput.audio();
|
|
261
|
-
if (audioStream) {
|
|
262
|
-
using audioDecoder = await Decoder.create(audioStream);
|
|
263
|
-
using audioEncoder = await Encoder.create(
|
|
264
|
-
audioCodecToAVName.aac,
|
|
265
|
-
{
|
|
266
|
-
timeBase: audioStream.timeBase,
|
|
267
|
-
},
|
|
268
|
-
);
|
|
269
|
-
|
|
270
|
-
const audioOutputIndex = mediaOutput.addStream(audioEncoder);
|
|
271
|
-
|
|
272
|
-
for await (using frame of audioDecoder.frames(
|
|
273
|
-
mediaInput.packets(audioStream.index),
|
|
274
|
-
)) {
|
|
275
|
-
const packet = await audioEncoder.encode(frame);
|
|
276
|
-
if (packet) {
|
|
277
|
-
await mediaOutput.writePacket(packet, audioOutputIndex);
|
|
278
|
-
packet.free();
|
|
279
|
-
}
|
|
280
|
-
}
|
|
281
|
-
|
|
282
|
-
// Flush remaining packets
|
|
283
|
-
await audioEncoder.flush();
|
|
284
|
-
let resizeAPacket: Packet | null = await audioEncoder.receive();
|
|
285
|
-
while (resizeAPacket !== null) {
|
|
286
|
-
await mediaOutput.writePacket(resizeAPacket, audioOutputIndex);
|
|
287
|
-
resizeAPacket.free();
|
|
288
|
-
resizeAPacket = await audioEncoder.receive();
|
|
289
|
-
}
|
|
257
|
+
await audioEncoder.flush();
|
|
258
|
+
let resizeAPacket: Packet | null = await audioEncoder.receive();
|
|
259
|
+
while (resizeAPacket !== null) {
|
|
260
|
+
await mediaOutput.writePacket(resizeAPacket, audioOutputIndex);
|
|
261
|
+
resizeAPacket.free();
|
|
262
|
+
resizeAPacket = await audioEncoder.receive();
|
|
290
263
|
}
|
|
291
|
-
|
|
292
|
-
const output = await tempFileToBytes(outputPath);
|
|
293
|
-
return output;
|
|
294
|
-
} finally {
|
|
295
|
-
await cleanup([inputPath, outputPath]);
|
|
296
264
|
}
|
|
265
|
+
|
|
266
|
+
// Return accumulated output
|
|
267
|
+
return getOutput();
|
|
297
268
|
},
|
|
298
269
|
catch: (error) =>
|
|
299
270
|
UploadistaError.fromCode("VIDEO_PROCESSING_FAILED", {
|
|
@@ -305,50 +276,89 @@ export function createAVNodeVideoPlugin(): VideoPluginShape {
|
|
|
305
276
|
trim: (input, options) =>
|
|
306
277
|
Effect.tryPromise({
|
|
307
278
|
try: async () => {
|
|
308
|
-
|
|
309
|
-
const
|
|
279
|
+
// Convert Uint8Array to Buffer for input
|
|
280
|
+
const inputBuffer = Buffer.from(input);
|
|
310
281
|
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
await using mediaOutput = await MediaOutput.open(outputPath);
|
|
282
|
+
// Create in-memory output
|
|
283
|
+
const { callbacks, getOutput } = createMemoryOutput();
|
|
314
284
|
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
285
|
+
await using mediaInput = await Demuxer.open(inputBuffer);
|
|
286
|
+
await using mediaOutput = await Muxer.open(callbacks, {
|
|
287
|
+
format: "mp4",
|
|
288
|
+
});
|
|
289
|
+
|
|
290
|
+
const videoStream = mediaInput.video();
|
|
291
|
+
if (!videoStream) {
|
|
292
|
+
throw new Error("No video stream found");
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
// Calculate end time
|
|
296
|
+
let endTime: number;
|
|
297
|
+
if (options.duration !== undefined) {
|
|
298
|
+
endTime = options.startTime + options.duration;
|
|
299
|
+
} else if (options.endTime !== undefined) {
|
|
300
|
+
endTime = options.endTime;
|
|
301
|
+
} else {
|
|
302
|
+
endTime = mediaInput.duration || Number.POSITIVE_INFINITY;
|
|
303
|
+
}
|
|
319
304
|
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
305
|
+
using videoDecoder = await Decoder.create(videoStream);
|
|
306
|
+
using videoEncoder = await Encoder.create(codecToAVName.h264);
|
|
307
|
+
|
|
308
|
+
const videoOutputIndex = mediaOutput.addStream(videoEncoder);
|
|
309
|
+
|
|
310
|
+
// Process video frames within time range
|
|
311
|
+
for await (using frame of videoDecoder.frames(
|
|
312
|
+
mediaInput.packets(videoStream.index),
|
|
313
|
+
)) {
|
|
314
|
+
// Calculate frame timestamp
|
|
315
|
+
const pts = frame?.pts || 0n;
|
|
316
|
+
const timeBase = videoStream.timeBase
|
|
317
|
+
? videoStream.timeBase.num / videoStream.timeBase.den
|
|
318
|
+
: 1;
|
|
319
|
+
const timestamp = Number(pts) * timeBase;
|
|
320
|
+
|
|
321
|
+
if (timestamp >= options.startTime && timestamp < endTime) {
|
|
322
|
+
const packet = await videoEncoder.encode(frame);
|
|
323
|
+
if (packet) {
|
|
324
|
+
await mediaOutput.writePacket(packet, videoOutputIndex);
|
|
325
|
+
packet.free();
|
|
326
|
+
}
|
|
328
327
|
}
|
|
329
328
|
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
329
|
+
if (timestamp >= endTime) break;
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
// Flush remaining packets
|
|
333
|
+
await videoEncoder.flush();
|
|
334
|
+
let trimVPacket: Packet | null = await videoEncoder.receive();
|
|
335
|
+
while (trimVPacket !== null) {
|
|
336
|
+
await mediaOutput.writePacket(trimVPacket, videoOutputIndex);
|
|
337
|
+
trimVPacket.free();
|
|
338
|
+
trimVPacket = await videoEncoder.receive();
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
// Handle audio stream if present
|
|
342
|
+
const audioStream = mediaInput.audio();
|
|
343
|
+
if (audioStream) {
|
|
344
|
+
using audioDecoder = await Decoder.create(audioStream);
|
|
345
|
+
using audioEncoder = await Encoder.create(audioCodecToAVName.aac);
|
|
334
346
|
|
|
335
|
-
const
|
|
347
|
+
const audioOutputIndex = mediaOutput.addStream(audioEncoder);
|
|
336
348
|
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
mediaInput.packets(videoStream.index),
|
|
349
|
+
for await (using frame of audioDecoder.frames(
|
|
350
|
+
mediaInput.packets(audioStream.index),
|
|
340
351
|
)) {
|
|
341
|
-
|
|
342
|
-
const
|
|
343
|
-
|
|
344
|
-
? videoStream.timeBase.num / videoStream.timeBase.den
|
|
352
|
+
const pts = frame?.pts || 0n;
|
|
353
|
+
const timeBase = audioStream.timeBase
|
|
354
|
+
? audioStream.timeBase.num / audioStream.timeBase.den
|
|
345
355
|
: 1;
|
|
346
356
|
const timestamp = Number(pts) * timeBase;
|
|
347
357
|
|
|
348
358
|
if (timestamp >= options.startTime && timestamp < endTime) {
|
|
349
|
-
const packet = await
|
|
359
|
+
const packet = await audioEncoder.encode(frame);
|
|
350
360
|
if (packet) {
|
|
351
|
-
await mediaOutput.writePacket(packet,
|
|
361
|
+
await mediaOutput.writePacket(packet, audioOutputIndex);
|
|
352
362
|
packet.free();
|
|
353
363
|
}
|
|
354
364
|
}
|
|
@@ -357,62 +367,17 @@ export function createAVNodeVideoPlugin(): VideoPluginShape {
|
|
|
357
367
|
}
|
|
358
368
|
|
|
359
369
|
// Flush remaining packets
|
|
360
|
-
await
|
|
361
|
-
let
|
|
362
|
-
while (
|
|
363
|
-
await mediaOutput.writePacket(
|
|
364
|
-
|
|
365
|
-
|
|
370
|
+
await audioEncoder.flush();
|
|
371
|
+
let trimAPacket: Packet | null = await audioEncoder.receive();
|
|
372
|
+
while (trimAPacket !== null) {
|
|
373
|
+
await mediaOutput.writePacket(trimAPacket, audioOutputIndex);
|
|
374
|
+
trimAPacket.free();
|
|
375
|
+
trimAPacket = await audioEncoder.receive();
|
|
366
376
|
}
|
|
367
|
-
|
|
368
|
-
// Handle audio stream if present
|
|
369
|
-
const audioStream = mediaInput.audio();
|
|
370
|
-
if (audioStream) {
|
|
371
|
-
using audioDecoder = await Decoder.create(audioStream);
|
|
372
|
-
using audioEncoder = await Encoder.create(
|
|
373
|
-
audioCodecToAVName.aac,
|
|
374
|
-
{
|
|
375
|
-
timeBase: audioStream.timeBase,
|
|
376
|
-
},
|
|
377
|
-
);
|
|
378
|
-
|
|
379
|
-
const audioOutputIndex = mediaOutput.addStream(audioEncoder);
|
|
380
|
-
|
|
381
|
-
for await (using frame of audioDecoder.frames(
|
|
382
|
-
mediaInput.packets(audioStream.index),
|
|
383
|
-
)) {
|
|
384
|
-
const pts = frame.pts || 0n;
|
|
385
|
-
const timeBase = audioStream.timeBase
|
|
386
|
-
? audioStream.timeBase.num / audioStream.timeBase.den
|
|
387
|
-
: 1;
|
|
388
|
-
const timestamp = Number(pts) * timeBase;
|
|
389
|
-
|
|
390
|
-
if (timestamp >= options.startTime && timestamp < endTime) {
|
|
391
|
-
const packet = await audioEncoder.encode(frame);
|
|
392
|
-
if (packet) {
|
|
393
|
-
await mediaOutput.writePacket(packet, audioOutputIndex);
|
|
394
|
-
packet.free();
|
|
395
|
-
}
|
|
396
|
-
}
|
|
397
|
-
|
|
398
|
-
if (timestamp >= endTime) break;
|
|
399
|
-
}
|
|
400
|
-
|
|
401
|
-
// Flush remaining packets
|
|
402
|
-
await audioEncoder.flush();
|
|
403
|
-
let trimAPacket: Packet | null = await audioEncoder.receive();
|
|
404
|
-
while (trimAPacket !== null) {
|
|
405
|
-
await mediaOutput.writePacket(trimAPacket, audioOutputIndex);
|
|
406
|
-
trimAPacket.free();
|
|
407
|
-
trimAPacket = await audioEncoder.receive();
|
|
408
|
-
}
|
|
409
|
-
}
|
|
410
|
-
|
|
411
|
-
const output = await tempFileToBytes(outputPath);
|
|
412
|
-
return output;
|
|
413
|
-
} finally {
|
|
414
|
-
await cleanup([inputPath, outputPath]);
|
|
415
377
|
}
|
|
378
|
+
|
|
379
|
+
// Return accumulated output
|
|
380
|
+
return getOutput();
|
|
416
381
|
},
|
|
417
382
|
catch: (error) =>
|
|
418
383
|
UploadistaError.fromCode("VIDEO_PROCESSING_FAILED", {
|
|
@@ -424,66 +389,56 @@ export function createAVNodeVideoPlugin(): VideoPluginShape {
|
|
|
424
389
|
extractFrame: (input, options) =>
|
|
425
390
|
Effect.tryPromise({
|
|
426
391
|
try: async () => {
|
|
427
|
-
|
|
392
|
+
// Convert Uint8Array to Buffer for input
|
|
393
|
+
const inputBuffer = Buffer.from(input);
|
|
428
394
|
const format = options.format || "jpeg";
|
|
429
|
-
const outputPath = join(
|
|
430
|
-
tmpdir(),
|
|
431
|
-
`uploadista-${randomUUID()}.${format}`,
|
|
432
|
-
);
|
|
433
|
-
|
|
434
|
-
try {
|
|
435
|
-
await using mediaInput = await MediaInput.open(inputPath);
|
|
436
|
-
|
|
437
|
-
const videoStream = mediaInput.video();
|
|
438
|
-
if (!videoStream) {
|
|
439
|
-
throw new Error("No video stream found");
|
|
440
|
-
}
|
|
441
|
-
|
|
442
|
-
using decoder = await Decoder.create(videoStream);
|
|
443
395
|
|
|
444
|
-
|
|
445
|
-
const targetTimestamp = options.timestamp;
|
|
396
|
+
await using mediaInput = await Demuxer.open(inputBuffer);
|
|
446
397
|
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
)
|
|
450
|
-
|
|
451
|
-
const pts = frame.pts || 0n;
|
|
452
|
-
const timeBase = videoStream.timeBase
|
|
453
|
-
? videoStream.timeBase.num / videoStream.timeBase.den
|
|
454
|
-
: 1;
|
|
455
|
-
const timestamp = Number(pts) * timeBase;
|
|
398
|
+
const videoStream = mediaInput.video();
|
|
399
|
+
if (!videoStream) {
|
|
400
|
+
throw new Error("No video stream found");
|
|
401
|
+
}
|
|
456
402
|
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
403
|
+
using decoder = await Decoder.create(videoStream);
|
|
404
|
+
|
|
405
|
+
let frameData: Uint8Array | null = null;
|
|
406
|
+
const targetTimestamp = options.timestamp;
|
|
407
|
+
|
|
408
|
+
for await (using frame of decoder.frames(
|
|
409
|
+
mediaInput.packets(videoStream.index),
|
|
410
|
+
)) {
|
|
411
|
+
// Calculate frame timestamp
|
|
412
|
+
const pts = frame?.pts || 0n;
|
|
413
|
+
const timeBase = videoStream.timeBase
|
|
414
|
+
? videoStream.timeBase.num / videoStream.timeBase.den
|
|
415
|
+
: 1;
|
|
416
|
+
const timestamp = Number(pts) * timeBase;
|
|
417
|
+
|
|
418
|
+
// Look for frame at or after target timestamp
|
|
419
|
+
if (timestamp >= targetTimestamp) {
|
|
420
|
+
// Use an image encoder to save the frame
|
|
421
|
+
const encoderCodec =
|
|
422
|
+
imageFormatToEncoder[format] || imageFormatToEncoder.jpeg;
|
|
423
|
+
using imageEncoder = await Encoder.create(encoderCodec);
|
|
424
|
+
|
|
425
|
+
// Encode the frame as image
|
|
426
|
+
// The encoder will initialize from the first frame's properties
|
|
427
|
+
const packet = await imageEncoder.encode(frame);
|
|
428
|
+
if (packet?.data) {
|
|
429
|
+
// Convert Buffer to Uint8Array
|
|
430
|
+
frameData = new Uint8Array(packet.data);
|
|
431
|
+
packet.free();
|
|
432
|
+
break;
|
|
475
433
|
}
|
|
476
434
|
}
|
|
435
|
+
}
|
|
477
436
|
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
}
|
|
481
|
-
|
|
482
|
-
const output = await tempFileToBytes(outputPath);
|
|
483
|
-
return output;
|
|
484
|
-
} finally {
|
|
485
|
-
await cleanup([inputPath, outputPath]);
|
|
437
|
+
if (!frameData) {
|
|
438
|
+
throw new Error(`No frame found at timestamp ${targetTimestamp}`);
|
|
486
439
|
}
|
|
440
|
+
|
|
441
|
+
return frameData;
|
|
487
442
|
},
|
|
488
443
|
catch: (error) =>
|
|
489
444
|
UploadistaError.fromCode("VIDEO_PROCESSING_FAILED", {
|