@uploadbox/video 0.4.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.
Files changed (56) hide show
  1. package/dist/ffutils.d.ts +39 -0
  2. package/dist/ffutils.d.ts.map +1 -0
  3. package/dist/ffutils.js +82 -0
  4. package/dist/ffutils.js.map +1 -0
  5. package/dist/hooks/video-processing.d.ts +47 -0
  6. package/dist/hooks/video-processing.d.ts.map +1 -0
  7. package/dist/hooks/video-processing.js +115 -0
  8. package/dist/hooks/video-processing.js.map +1 -0
  9. package/dist/index.d.ts +5 -0
  10. package/dist/index.d.ts.map +1 -0
  11. package/dist/index.js +3 -0
  12. package/dist/index.js.map +1 -0
  13. package/dist/metadata.d.ts +19 -0
  14. package/dist/metadata.d.ts.map +1 -0
  15. package/dist/metadata.js +50 -0
  16. package/dist/metadata.js.map +1 -0
  17. package/dist/provider.d.ts +44 -0
  18. package/dist/provider.d.ts.map +1 -0
  19. package/dist/provider.js +2 -0
  20. package/dist/provider.js.map +1 -0
  21. package/dist/providers/external.d.ts +40 -0
  22. package/dist/providers/external.d.ts.map +1 -0
  23. package/dist/providers/external.js +94 -0
  24. package/dist/providers/external.js.map +1 -0
  25. package/dist/providers/ffmpeg.d.ts +27 -0
  26. package/dist/providers/ffmpeg.d.ts.map +1 -0
  27. package/dist/providers/ffmpeg.js +282 -0
  28. package/dist/providers/ffmpeg.js.map +1 -0
  29. package/dist/providers/lambda.d.ts +49 -0
  30. package/dist/providers/lambda.d.ts.map +1 -0
  31. package/dist/providers/lambda.js +80 -0
  32. package/dist/providers/lambda.js.map +1 -0
  33. package/dist/react/index.d.ts +3 -0
  34. package/dist/react/index.d.ts.map +1 -0
  35. package/dist/react/index.js +2 -0
  36. package/dist/react/index.js.map +1 -0
  37. package/dist/react/use-video-player.d.ts +110 -0
  38. package/dist/react/use-video-player.d.ts.map +1 -0
  39. package/dist/react/use-video-player.js +319 -0
  40. package/dist/react/use-video-player.js.map +1 -0
  41. package/dist/types.d.ts +90 -0
  42. package/dist/types.d.ts.map +1 -0
  43. package/dist/types.js +45 -0
  44. package/dist/types.js.map +1 -0
  45. package/package.json +83 -0
  46. package/src/ffutils.ts +128 -0
  47. package/src/hooks/video-processing.ts +160 -0
  48. package/src/index.ts +18 -0
  49. package/src/metadata.ts +57 -0
  50. package/src/provider.ts +46 -0
  51. package/src/providers/external.ts +122 -0
  52. package/src/providers/ffmpeg.ts +365 -0
  53. package/src/providers/lambda.ts +112 -0
  54. package/src/react/index.ts +7 -0
  55. package/src/react/use-video-player.ts +444 -0
  56. package/src/types.ts +130 -0
@@ -0,0 +1,365 @@
1
+ import type { TranscodingProvider, TranscodingSubmitInput } from "../provider.js";
2
+ import type { TranscodingJob, QualityPreset } from "../types.js";
3
+ import { QUALITY_PRESETS } from "../types.js";
4
+ import { createS3Client } from "@uploadbox/core";
5
+ import { ffprobe, runFFmpeg } from "../ffutils.js";
6
+ import crypto from "crypto";
7
+ import fs from "fs/promises";
8
+ import path from "path";
9
+ import os from "os";
10
+
11
+ interface FFmpegProviderOptions {
12
+ /** Path to ffmpeg binary. @default "ffmpeg" */
13
+ ffmpegPath?: string;
14
+ /** Path to ffprobe binary. @default "ffprobe" */
15
+ ffprobePath?: string;
16
+ /** Temporary directory for intermediate files. @default os.tmpdir() */
17
+ tmpDir?: string;
18
+ }
19
+
20
+ interface JobState {
21
+ status: TranscodingJob["status"];
22
+ progress: number;
23
+ outputKeys: string[];
24
+ error?: string;
25
+ abortController?: AbortController;
26
+ }
27
+
28
+ /**
29
+ * Local FFmpeg transcoding provider.
30
+ *
31
+ * Downloads the source video from S3, transcodes it using a local FFmpeg binary,
32
+ * and uploads HLS segments, playlists, thumbnails, and sprites back to S3.
33
+ *
34
+ * Best for development and small-scale self-hosted deployments.
35
+ *
36
+ * @example
37
+ * ```ts
38
+ * import { createFFmpegProvider } from "@uploadbox/video/providers/ffmpeg";
39
+ *
40
+ * const provider = createFFmpegProvider({ ffmpegPath: "/usr/bin/ffmpeg" });
41
+ * ```
42
+ */
43
+ export function createFFmpegProvider(
44
+ options: FFmpegProviderOptions = {}
45
+ ): TranscodingProvider {
46
+ const {
47
+ ffmpegPath = "ffmpeg",
48
+ ffprobePath = "ffprobe",
49
+ tmpDir = os.tmpdir(),
50
+ } = options;
51
+
52
+ const jobs = new Map<string, JobState>();
53
+
54
+ return {
55
+ name: "ffmpeg-local",
56
+
57
+ async submit(input: TranscodingSubmitInput): Promise<{ jobId: string }> {
58
+ const jobId = crypto.randomUUID();
59
+ const abortController = new AbortController();
60
+
61
+ const jobState: JobState = {
62
+ status: "pending",
63
+ progress: 0,
64
+ outputKeys: [],
65
+ abortController,
66
+ };
67
+ jobs.set(jobId, jobState);
68
+
69
+ // Run transcoding in background (don't await)
70
+ runTranscoding(jobId, input, jobState, abortController.signal).catch(
71
+ (err) => {
72
+ jobState.status = "failed";
73
+ jobState.error = (err as Error).message;
74
+ }
75
+ );
76
+
77
+ return { jobId };
78
+ },
79
+
80
+ async getJobStatus(jobId: string): Promise<TranscodingJob> {
81
+ const state = jobs.get(jobId);
82
+ if (!state) {
83
+ return {
84
+ jobId,
85
+ status: "failed",
86
+ progress: 0,
87
+ outputKeys: [],
88
+ error: "Job not found",
89
+ };
90
+ }
91
+ return {
92
+ jobId,
93
+ status: state.status,
94
+ progress: state.progress,
95
+ outputKeys: state.outputKeys,
96
+ error: state.error,
97
+ };
98
+ },
99
+
100
+ async cancel(jobId: string): Promise<void> {
101
+ const state = jobs.get(jobId);
102
+ if (state?.abortController) {
103
+ state.abortController.abort();
104
+ state.status = "failed";
105
+ state.error = "Cancelled";
106
+ }
107
+ },
108
+ };
109
+
110
+ async function runTranscoding(
111
+ jobId: string,
112
+ input: TranscodingSubmitInput,
113
+ state: JobState,
114
+ signal: AbortSignal
115
+ ): Promise<void> {
116
+ const workDir = path.join(tmpDir, `uploadbox-video-${jobId}`);
117
+ await fs.mkdir(workDir, { recursive: true });
118
+
119
+ const { GetObjectCommand, PutObjectCommand } = await import("@aws-sdk/client-s3");
120
+
121
+ const s3Client = createS3Client({
122
+ region: input.s3Config.region ?? "us-east-1",
123
+ bucket: input.sourceBucket,
124
+ accessKeyId: input.s3Config.accessKeyId,
125
+ secretAccessKey: input.s3Config.secretAccessKey,
126
+ endpoint: input.s3Config.endpoint,
127
+ forcePathStyle: input.s3Config.forcePathStyle,
128
+ });
129
+
130
+ try {
131
+ state.status = "processing";
132
+ state.progress = 0;
133
+ input.onProgress?.({ jobId, percent: 0, message: "Downloading source video" });
134
+
135
+ // Download source from S3
136
+ const sourcePath = path.join(workDir, "source");
137
+ const getResponse = await s3Client.send(
138
+ new GetObjectCommand({ Bucket: input.sourceBucket, Key: input.sourceKey })
139
+ );
140
+ const body = await getResponse.Body?.transformToByteArray();
141
+ if (!body) throw new Error("Failed to download source video from S3");
142
+ await fs.writeFile(sourcePath, body);
143
+
144
+ if (signal.aborted) throw new Error("Cancelled");
145
+
146
+ // Determine qualities to transcode
147
+ const qualities = input.options.qualities ?? Object.values(QUALITY_PRESETS);
148
+ const segmentDuration = input.options.segmentDuration ?? 6;
149
+ const outputPrefix = input.options.s3OutputPrefix;
150
+ const totalSteps = qualities.length + 2; // +1 thumbnail, +1 sprite
151
+ let completedSteps = 0;
152
+
153
+ // Transcode each quality
154
+ const qualityPlaylists: { preset: QualityPreset; playlistKey: string }[] = [];
155
+
156
+ for (const preset of qualities) {
157
+ if (signal.aborted) throw new Error("Cancelled");
158
+
159
+ const qualityDir = path.join(workDir, preset.label);
160
+ await fs.mkdir(qualityDir, { recursive: true });
161
+
162
+ input.onProgress?.({
163
+ jobId,
164
+ percent: Math.round((completedSteps / totalSteps) * 100),
165
+ currentQuality: preset.label,
166
+ message: `Transcoding ${preset.label}`,
167
+ });
168
+
169
+ const segmentPattern = path.join(qualityDir, "segment-%03d.ts");
170
+ const playlistOutput = path.join(qualityDir, "playlist.m3u8");
171
+
172
+ await runFFmpeg([
173
+ "-i", sourcePath,
174
+ "-vf", `scale=${preset.width}:${preset.height}:force_original_aspect_ratio=decrease,pad=${preset.width}:${preset.height}:(ow-iw)/2:(oh-ih)/2`,
175
+ "-c:v", "libx264",
176
+ "-b:v", `${preset.videoBitrate}`,
177
+ "-maxrate", `${Math.round(preset.videoBitrate * 1.2)}`,
178
+ "-bufsize", `${Math.round(preset.videoBitrate * 2)}`,
179
+ "-r", `${Math.min(preset.maxFrameRate, 60)}`,
180
+ "-c:a", "aac",
181
+ "-b:a", `${preset.audioBitrate}`,
182
+ "-ac", "2",
183
+ "-hls_time", `${segmentDuration}`,
184
+ "-hls_playlist_type", "vod",
185
+ "-hls_segment_filename", segmentPattern,
186
+ "-f", "hls",
187
+ playlistOutput,
188
+ ], { ffmpegPath, signal });
189
+
190
+ // Upload segments and playlist to S3
191
+ const segmentFiles = await fs.readdir(qualityDir);
192
+ for (const file of segmentFiles) {
193
+ const filePath = path.join(qualityDir, file);
194
+ const fileContent = await fs.readFile(filePath);
195
+ const s3Key = `${outputPrefix}/${preset.label}/${file}`;
196
+ const contentType = file.endsWith(".m3u8")
197
+ ? "application/vnd.apple.mpegurl"
198
+ : "video/MP2T";
199
+
200
+ await s3Client.send(
201
+ new PutObjectCommand({
202
+ Bucket: input.outputBucket,
203
+ Key: s3Key,
204
+ Body: fileContent,
205
+ ContentType: contentType,
206
+ })
207
+ );
208
+ state.outputKeys.push(s3Key);
209
+ }
210
+
211
+ qualityPlaylists.push({
212
+ preset,
213
+ playlistKey: `${outputPrefix}/${preset.label}/playlist.m3u8`,
214
+ });
215
+
216
+ completedSteps++;
217
+ state.progress = Math.round((completedSteps / totalSteps) * 100);
218
+ }
219
+
220
+ if (signal.aborted) throw new Error("Cancelled");
221
+
222
+ // Generate master playlist
223
+ let masterPlaylist = "#EXTM3U\n#EXT-X-VERSION:3\n";
224
+ for (const { preset } of qualityPlaylists) {
225
+ const bandwidth = preset.videoBitrate + preset.audioBitrate;
226
+ masterPlaylist += `#EXT-X-STREAM-INF:BANDWIDTH=${bandwidth},RESOLUTION=${preset.width}x${preset.height}\n`;
227
+ masterPlaylist += `${preset.label}/playlist.m3u8\n`;
228
+ }
229
+
230
+ const masterKey = `${outputPrefix}/master.m3u8`;
231
+ await s3Client.send(
232
+ new PutObjectCommand({
233
+ Bucket: input.outputBucket,
234
+ Key: masterKey,
235
+ Body: Buffer.from(masterPlaylist),
236
+ ContentType: "application/vnd.apple.mpegurl",
237
+ })
238
+ );
239
+ state.outputKeys.push(masterKey);
240
+
241
+ // Generate poster thumbnail at ~10% duration
242
+ if (input.options.generateThumbnail !== false) {
243
+ input.onProgress?.({
244
+ jobId,
245
+ percent: Math.round((completedSteps / totalSteps) * 100),
246
+ message: "Generating thumbnail",
247
+ });
248
+
249
+ const thumbnailPath = path.join(workDir, "thumbnail.jpg");
250
+
251
+ // Get duration for timestamp calculation
252
+ const probeData = await ffprobe(sourcePath, { ffprobePath });
253
+ const duration = parseFloat(probeData.format?.duration ?? "10");
254
+ const thumbnailTime = Math.max(0.1, duration * 0.1);
255
+
256
+ await runFFmpeg([
257
+ "-ss", `${thumbnailTime}`,
258
+ "-i", sourcePath,
259
+ "-vframes", "1",
260
+ "-q:v", "2",
261
+ thumbnailPath,
262
+ ], { ffmpegPath, signal });
263
+
264
+ const thumbContent = await fs.readFile(thumbnailPath);
265
+ const thumbKey = `${outputPrefix}/thumbnail.jpg`;
266
+ await s3Client.send(
267
+ new PutObjectCommand({
268
+ Bucket: input.outputBucket,
269
+ Key: thumbKey,
270
+ Body: thumbContent,
271
+ ContentType: "image/jpeg",
272
+ })
273
+ );
274
+ state.outputKeys.push(thumbKey);
275
+ completedSteps++;
276
+ }
277
+
278
+ // Generate sprite sheet + WebVTT
279
+ if (input.options.generateSpriteSheet !== false) {
280
+ input.onProgress?.({
281
+ jobId,
282
+ percent: Math.round((completedSteps / totalSteps) * 100),
283
+ message: "Generating sprite sheet",
284
+ });
285
+
286
+ const spritesDir = path.join(workDir, "sprites");
287
+ await fs.mkdir(spritesDir, { recursive: true });
288
+
289
+ // Extract frames every 10 seconds
290
+ const probeData = await ffprobe(sourcePath, { ffprobePath });
291
+ const duration = parseFloat(probeData.format?.duration ?? "10");
292
+
293
+ const interval = 10;
294
+ const thumbWidth = 160;
295
+ const thumbHeight = 90;
296
+ const cols = 10;
297
+ const frameCount = Math.ceil(duration / interval);
298
+ const rows = Math.ceil(frameCount / cols);
299
+
300
+ // Generate sprite sheet using a single FFmpeg command
301
+ await runFFmpeg([
302
+ "-i", sourcePath,
303
+ "-vf", `fps=1/${interval},scale=${thumbWidth}:${thumbHeight},tile=${cols}x${rows}`,
304
+ "-frames:v", "1",
305
+ "-q:v", "5",
306
+ path.join(spritesDir, "sprite-sheet.jpg"),
307
+ ], { ffmpegPath, signal });
308
+
309
+ // Upload sprite sheet
310
+ const spriteContent = await fs.readFile(path.join(spritesDir, "sprite-sheet.jpg"));
311
+ const spriteKey = `${outputPrefix}/sprites/sprite-sheet.jpg`;
312
+ await s3Client.send(
313
+ new PutObjectCommand({
314
+ Bucket: input.outputBucket,
315
+ Key: spriteKey,
316
+ Body: spriteContent,
317
+ ContentType: "image/jpeg",
318
+ })
319
+ );
320
+ state.outputKeys.push(spriteKey);
321
+
322
+ // Generate WebVTT for sprite positions
323
+ let vtt = "WEBVTT\n\n";
324
+ for (let i = 0; i < frameCount; i++) {
325
+ const startTime = i * interval;
326
+ const endTime = Math.min((i + 1) * interval, duration);
327
+ const col = i % cols;
328
+ const row = Math.floor(i / cols);
329
+ const x = col * thumbWidth;
330
+ const y = row * thumbHeight;
331
+
332
+ vtt += `${formatVttTime(startTime)} --> ${formatVttTime(endTime)}\n`;
333
+ vtt += `sprite-sheet.jpg#xywh=${x},${y},${thumbWidth},${thumbHeight}\n\n`;
334
+ }
335
+
336
+ const vttKey = `${outputPrefix}/sprites/sprites.vtt`;
337
+ await s3Client.send(
338
+ new PutObjectCommand({
339
+ Bucket: input.outputBucket,
340
+ Key: vttKey,
341
+ Body: Buffer.from(vtt),
342
+ ContentType: "text/vtt",
343
+ })
344
+ );
345
+ state.outputKeys.push(vttKey);
346
+ completedSteps++;
347
+ }
348
+
349
+ state.status = "completed";
350
+ state.progress = 100;
351
+ input.onProgress?.({ jobId, percent: 100, message: "Transcoding complete" });
352
+ } finally {
353
+ // Cleanup temp directory
354
+ await fs.rm(workDir, { recursive: true, force: true }).catch(() => {});
355
+ }
356
+ }
357
+ }
358
+
359
+ function formatVttTime(seconds: number): string {
360
+ const h = Math.floor(seconds / 3600);
361
+ const m = Math.floor((seconds % 3600) / 60);
362
+ const s = Math.floor(seconds % 60);
363
+ const ms = Math.round((seconds % 1) * 1000);
364
+ return `${String(h).padStart(2, "0")}:${String(m).padStart(2, "0")}:${String(s).padStart(2, "0")}.${String(ms).padStart(3, "0")}`;
365
+ }
@@ -0,0 +1,112 @@
1
+ import type { TranscodingProvider, TranscodingSubmitInput } from "../provider.js";
2
+ import type { TranscodingJob } from "../types.js";
3
+ import crypto from "crypto";
4
+
5
+ interface LambdaProviderOptions {
6
+ /** AWS region where the Lambda function is deployed. */
7
+ region: string;
8
+ /** ARN of the Lambda function that performs transcoding. */
9
+ functionArn: string;
10
+ /**
11
+ * Function to poll job status. The Lambda function should store status
12
+ * in DynamoDB or a similar store that this function can query.
13
+ */
14
+ getStatus: (jobId: string) => Promise<TranscodingJob>;
15
+ /**
16
+ * Optional function to cancel a job.
17
+ */
18
+ cancelJob?: (jobId: string) => Promise<void>;
19
+ }
20
+
21
+ /**
22
+ * AWS Lambda transcoding provider.
23
+ *
24
+ * Invokes a user-provided Lambda function asynchronously for transcoding.
25
+ * The Lambda function is NOT included — users must implement and deploy it themselves.
26
+ *
27
+ * The Lambda function receives the same input payload and should:
28
+ * 1. Download the source video from S3
29
+ * 2. Transcode to HLS using FFmpeg (via a Lambda layer)
30
+ * 3. Upload outputs back to S3
31
+ * 4. Store job status in DynamoDB for polling
32
+ *
33
+ * Requires `@aws-sdk/client-lambda` to be installed by the user.
34
+ *
35
+ * @example
36
+ * ```ts
37
+ * import { createLambdaProvider } from "@uploadbox/video/providers/lambda";
38
+ *
39
+ * const provider = createLambdaProvider({
40
+ * region: "us-east-1",
41
+ * functionArn: "arn:aws:lambda:us-east-1:123456789:function:video-transcode",
42
+ * getStatus: async (jobId) => {
43
+ * // Query DynamoDB for job status
44
+ * const item = await dynamodb.get({ TableName: "jobs", Key: { jobId } });
45
+ * return item;
46
+ * },
47
+ * });
48
+ * ```
49
+ */
50
+ export function createLambdaProvider(
51
+ options: LambdaProviderOptions
52
+ ): TranscodingProvider {
53
+ const { region, functionArn, getStatus, cancelJob } = options;
54
+
55
+ return {
56
+ name: "aws-lambda",
57
+
58
+ async submit(input: TranscodingSubmitInput): Promise<{ jobId: string }> {
59
+ // Dynamic import to keep @aws-sdk/client-lambda optional
60
+ let LambdaClient: any;
61
+ let InvokeCommand: any;
62
+ try {
63
+ const mod = await (Function('return import("@aws-sdk/client-lambda")')() as Promise<any>);
64
+ LambdaClient = mod.LambdaClient;
65
+ InvokeCommand = mod.InvokeCommand;
66
+ } catch {
67
+ throw new Error(
68
+ "@aws-sdk/client-lambda is not installed. Run: npm install @aws-sdk/client-lambda"
69
+ );
70
+ }
71
+
72
+ const lambda = new LambdaClient({
73
+ region,
74
+ credentials: {
75
+ accessKeyId: input.s3Config.accessKeyId,
76
+ secretAccessKey: input.s3Config.secretAccessKey,
77
+ },
78
+ });
79
+
80
+ const jobId = crypto.randomUUID();
81
+
82
+ const payload = {
83
+ jobId,
84
+ sourceKey: input.sourceKey,
85
+ sourceBucket: input.sourceBucket,
86
+ outputBucket: input.outputBucket,
87
+ options: input.options,
88
+ s3Config: input.s3Config,
89
+ };
90
+
91
+ await lambda.send(
92
+ new InvokeCommand({
93
+ FunctionName: functionArn,
94
+ InvocationType: "Event",
95
+ Payload: Buffer.from(JSON.stringify(payload)),
96
+ })
97
+ );
98
+
99
+ return { jobId };
100
+ },
101
+
102
+ async getJobStatus(jobId: string): Promise<TranscodingJob> {
103
+ return getStatus(jobId);
104
+ },
105
+
106
+ async cancel(jobId: string): Promise<void> {
107
+ if (cancelJob) {
108
+ await cancelJob(jobId);
109
+ }
110
+ },
111
+ };
112
+ }
@@ -0,0 +1,7 @@
1
+ export { useVideoPlayer } from "./use-video-player.js";
2
+ export type {
3
+ UseVideoPlayerOptions,
4
+ VideoPlayerState,
5
+ QualityLevel,
6
+ SpritePosition,
7
+ } from "./use-video-player.js";