@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,444 @@
1
+ "use client";
2
+
3
+ import { useRef, useState, useEffect, useCallback } from "react";
4
+ import type { RefObject } from "react";
5
+
6
+ /** A single HLS quality level. */
7
+ export interface QualityLevel {
8
+ index: number;
9
+ width: number;
10
+ height: number;
11
+ bitrate: number;
12
+ label: string;
13
+ }
14
+
15
+ /** Sprite position for scrub preview. */
16
+ export interface SpritePosition {
17
+ url: string;
18
+ x: number;
19
+ y: number;
20
+ width: number;
21
+ height: number;
22
+ }
23
+
24
+ /** Options for the useVideoPlayer hook. */
25
+ export interface UseVideoPlayerOptions {
26
+ /** Auto-play the video when ready. @default false */
27
+ autoPlay?: boolean;
28
+ /** Initial volume (0–1). @default 1 */
29
+ initialVolume?: number;
30
+ /** URL to the WebVTT sprite sheet file. */
31
+ spriteVttUrl?: string;
32
+ }
33
+
34
+ /** Return value of the useVideoPlayer hook. */
35
+ export interface VideoPlayerState {
36
+ /** Ref to attach to the <video> element. */
37
+ videoRef: RefObject<HTMLVideoElement | null>;
38
+ /** Whether the video is currently playing. */
39
+ isPlaying: boolean;
40
+ /** Current playback position in seconds. */
41
+ currentTime: number;
42
+ /** Total duration in seconds. */
43
+ duration: number;
44
+ /** Current volume (0–1). */
45
+ volume: number;
46
+ /** Whether audio is muted. */
47
+ isMuted: boolean;
48
+ /** Current playback rate. */
49
+ playbackRate: number;
50
+ /** Available quality levels. */
51
+ qualities: QualityLevel[];
52
+ /** Index of the current quality level (-1 for auto). */
53
+ currentQuality: number;
54
+ /** Whether adaptive quality selection is active. */
55
+ isAutoQuality: boolean;
56
+ /** Whether fullscreen is active. */
57
+ isFullscreen: boolean;
58
+ /** Whether the video is buffering. */
59
+ isBuffering: boolean;
60
+ /** Start playback. */
61
+ play: () => void;
62
+ /** Pause playback. */
63
+ pause: () => void;
64
+ /** Toggle play/pause. */
65
+ toggle: () => void;
66
+ /** Seek to a position in seconds. */
67
+ seek: (time: number) => void;
68
+ /** Set volume (0–1). */
69
+ setVolume: (vol: number) => void;
70
+ /** Toggle mute/unmute. */
71
+ toggleMute: () => void;
72
+ /** Set playback rate. */
73
+ setPlaybackRate: (rate: number) => void;
74
+ /** Set quality level by index. Pass -1 for auto. */
75
+ setQuality: (index: number) => void;
76
+ /** Enter fullscreen. */
77
+ enterFullscreen: () => void;
78
+ /** Exit fullscreen. */
79
+ exitFullscreen: () => void;
80
+ /** Toggle fullscreen. */
81
+ toggleFullscreen: () => void;
82
+ /** Get sprite position for a given time (for scrub preview). */
83
+ getSpriteForTime: (time: number) => SpritePosition | null;
84
+ }
85
+
86
+ interface VttCue {
87
+ startTime: number;
88
+ endTime: number;
89
+ url: string;
90
+ x: number;
91
+ y: number;
92
+ width: number;
93
+ height: number;
94
+ }
95
+
96
+ /**
97
+ * Headless React hook for HLS video playback.
98
+ *
99
+ * Provides playback controls, quality switching, fullscreen, and sprite preview
100
+ * without any UI — you build your own player controls.
101
+ *
102
+ * Uses `hls.js` for adaptive streaming with a fallback to native `<video>` HLS
103
+ * support (Safari).
104
+ *
105
+ * @param src - URL to the HLS master playlist (.m3u8) or a regular video file.
106
+ * @param options - Configuration options.
107
+ *
108
+ * @example
109
+ * ```tsx
110
+ * import { useVideoPlayer } from "@uploadbox/video/react";
111
+ *
112
+ * function Player({ url }: { url: string }) {
113
+ * const player = useVideoPlayer(url);
114
+ *
115
+ * return (
116
+ * <div>
117
+ * <video ref={player.videoRef} />
118
+ * <button onClick={player.toggle}>
119
+ * {player.isPlaying ? "Pause" : "Play"}
120
+ * </button>
121
+ * <span>{player.currentTime} / {player.duration}</span>
122
+ * </div>
123
+ * );
124
+ * }
125
+ * ```
126
+ */
127
+ export function useVideoPlayer(
128
+ src: string,
129
+ options: UseVideoPlayerOptions = {}
130
+ ): VideoPlayerState {
131
+ const { autoPlay = false, initialVolume = 1, spriteVttUrl } = options;
132
+
133
+ const videoRef = useRef<HTMLVideoElement | null>(null);
134
+ const hlsRef = useRef<any>(null);
135
+
136
+ const [isPlaying, setIsPlaying] = useState(false);
137
+ const [currentTime, setCurrentTime] = useState(0);
138
+ const [duration, setDuration] = useState(0);
139
+ const [volume, setVolumeState] = useState(initialVolume);
140
+ const [isMuted, setIsMuted] = useState(false);
141
+ const [playbackRate, setPlaybackRateState] = useState(1);
142
+ const [qualities, setQualities] = useState<QualityLevel[]>([]);
143
+ const [currentQuality, setCurrentQualityState] = useState(-1);
144
+ const [isFullscreen, setIsFullscreen] = useState(false);
145
+ const [isBuffering, setIsBuffering] = useState(false);
146
+ const [spriteCues, setSpriteCues] = useState<VttCue[]>([]);
147
+
148
+ // Parse sprite VTT
149
+ useEffect(() => {
150
+ if (!spriteVttUrl) return;
151
+
152
+ fetch(spriteVttUrl)
153
+ .then((res) => res.text())
154
+ .then((text) => {
155
+ const cues = parseVtt(text, spriteVttUrl);
156
+ setSpriteCues(cues);
157
+ })
158
+ .catch(() => {});
159
+ }, [spriteVttUrl]);
160
+
161
+ // Setup HLS or native playback
162
+ useEffect(() => {
163
+ const video = videoRef.current;
164
+ if (!video || !src) return;
165
+
166
+ let hls: any = null;
167
+
168
+ async function setup() {
169
+ const isHls = src.endsWith(".m3u8");
170
+
171
+ if (isHls && !video!.canPlayType("application/vnd.apple.mpegurl")) {
172
+ // Use hls.js
173
+ try {
174
+ const HlsModule = await (Function('return import("hls.js")')() as Promise<any>);
175
+ const Hls = HlsModule.default ?? HlsModule;
176
+
177
+ if (!Hls.isSupported()) {
178
+ console.warn("[uploadbox/video] HLS.js is not supported in this browser");
179
+ video!.src = src;
180
+ return;
181
+ }
182
+
183
+ hls = new Hls({ enableWorker: true });
184
+ hlsRef.current = hls;
185
+
186
+ hls.loadSource(src);
187
+ hls.attachMedia(video!);
188
+
189
+ hls.on(Hls.Events.MANIFEST_PARSED, () => {
190
+ const levels = hls.levels.map((level: any, index: number) => ({
191
+ index,
192
+ width: level.width,
193
+ height: level.height,
194
+ bitrate: level.bitrate,
195
+ label: `${level.height}p`,
196
+ }));
197
+ setQualities(levels);
198
+
199
+ if (autoPlay) video!.play().catch(() => {});
200
+ });
201
+
202
+ hls.on(Hls.Events.LEVEL_SWITCHED, (_: any, data: any) => {
203
+ setCurrentQualityState(data.level);
204
+ });
205
+ } catch {
206
+ // hls.js not available, try native
207
+ video!.src = src;
208
+ }
209
+ } else {
210
+ // Native HLS (Safari) or regular video
211
+ video!.src = src;
212
+ if (autoPlay) video!.play().catch(() => {});
213
+ }
214
+ }
215
+
216
+ setup();
217
+
218
+ return () => {
219
+ if (hls) {
220
+ hls.destroy();
221
+ hlsRef.current = null;
222
+ }
223
+ };
224
+ }, [src, autoPlay]);
225
+
226
+ // Video event listeners
227
+ useEffect(() => {
228
+ const video = videoRef.current;
229
+ if (!video) return;
230
+
231
+ const onPlay = () => setIsPlaying(true);
232
+ const onPause = () => setIsPlaying(false);
233
+ const onTimeUpdate = () => setCurrentTime(video.currentTime);
234
+ const onDurationChange = () => setDuration(video.duration || 0);
235
+ const onVolumeChange = () => {
236
+ setVolumeState(video.volume);
237
+ setIsMuted(video.muted);
238
+ };
239
+ const onWaiting = () => setIsBuffering(true);
240
+ const onPlaying = () => setIsBuffering(false);
241
+ const onCanPlay = () => setIsBuffering(false);
242
+
243
+ video.addEventListener("play", onPlay);
244
+ video.addEventListener("pause", onPause);
245
+ video.addEventListener("timeupdate", onTimeUpdate);
246
+ video.addEventListener("durationchange", onDurationChange);
247
+ video.addEventListener("volumechange", onVolumeChange);
248
+ video.addEventListener("waiting", onWaiting);
249
+ video.addEventListener("playing", onPlaying);
250
+ video.addEventListener("canplay", onCanPlay);
251
+
252
+ // Set initial volume
253
+ video.volume = initialVolume;
254
+
255
+ return () => {
256
+ video.removeEventListener("play", onPlay);
257
+ video.removeEventListener("pause", onPause);
258
+ video.removeEventListener("timeupdate", onTimeUpdate);
259
+ video.removeEventListener("durationchange", onDurationChange);
260
+ video.removeEventListener("volumechange", onVolumeChange);
261
+ video.removeEventListener("waiting", onWaiting);
262
+ video.removeEventListener("playing", onPlaying);
263
+ video.removeEventListener("canplay", onCanPlay);
264
+ };
265
+ }, [initialVolume]);
266
+
267
+ // Fullscreen change listener
268
+ useEffect(() => {
269
+ const onFullscreenChange = () => {
270
+ setIsFullscreen(!!document.fullscreenElement);
271
+ };
272
+ document.addEventListener("fullscreenchange", onFullscreenChange);
273
+ return () => document.removeEventListener("fullscreenchange", onFullscreenChange);
274
+ }, []);
275
+
276
+ const play = useCallback(() => {
277
+ videoRef.current?.play().catch(() => {});
278
+ }, []);
279
+
280
+ const pause = useCallback(() => {
281
+ videoRef.current?.pause();
282
+ }, []);
283
+
284
+ const toggle = useCallback(() => {
285
+ const video = videoRef.current;
286
+ if (!video) return;
287
+ if (video.paused) video.play().catch(() => {});
288
+ else video.pause();
289
+ }, []);
290
+
291
+ const seek = useCallback((time: number) => {
292
+ const video = videoRef.current;
293
+ if (video) video.currentTime = time;
294
+ }, []);
295
+
296
+ const setVolume = useCallback((vol: number) => {
297
+ const video = videoRef.current;
298
+ if (video) {
299
+ video.volume = Math.max(0, Math.min(1, vol));
300
+ if (vol > 0) video.muted = false;
301
+ }
302
+ }, []);
303
+
304
+ const toggleMute = useCallback(() => {
305
+ const video = videoRef.current;
306
+ if (video) video.muted = !video.muted;
307
+ }, []);
308
+
309
+ const setPlaybackRate = useCallback((rate: number) => {
310
+ const video = videoRef.current;
311
+ if (video) {
312
+ video.playbackRate = rate;
313
+ setPlaybackRateState(rate);
314
+ }
315
+ }, []);
316
+
317
+ const setQuality = useCallback((index: number) => {
318
+ const hls = hlsRef.current;
319
+ if (hls) {
320
+ hls.currentLevel = index; // -1 = auto
321
+ setCurrentQualityState(index);
322
+ }
323
+ }, []);
324
+
325
+ const enterFullscreen = useCallback(() => {
326
+ const video = videoRef.current;
327
+ const container = video?.parentElement;
328
+ if (container?.requestFullscreen) {
329
+ container.requestFullscreen().catch(() => {});
330
+ }
331
+ }, []);
332
+
333
+ const exitFullscreen = useCallback(() => {
334
+ if (document.fullscreenElement) {
335
+ document.exitFullscreen().catch(() => {});
336
+ }
337
+ }, []);
338
+
339
+ const toggleFullscreen = useCallback(() => {
340
+ if (document.fullscreenElement) exitFullscreen();
341
+ else enterFullscreen();
342
+ }, [enterFullscreen, exitFullscreen]);
343
+
344
+ const getSpriteForTime = useCallback(
345
+ (time: number): SpritePosition | null => {
346
+ const cue = spriteCues.find((c) => time >= c.startTime && time < c.endTime);
347
+ if (!cue) return null;
348
+ return {
349
+ url: cue.url,
350
+ x: cue.x,
351
+ y: cue.y,
352
+ width: cue.width,
353
+ height: cue.height,
354
+ };
355
+ },
356
+ [spriteCues]
357
+ );
358
+
359
+ return {
360
+ videoRef,
361
+ isPlaying,
362
+ currentTime,
363
+ duration,
364
+ volume,
365
+ isMuted,
366
+ playbackRate,
367
+ qualities,
368
+ currentQuality,
369
+ isAutoQuality: currentQuality === -1,
370
+ isFullscreen,
371
+ isBuffering,
372
+ play,
373
+ pause,
374
+ toggle,
375
+ seek,
376
+ setVolume,
377
+ toggleMute,
378
+ setPlaybackRate,
379
+ setQuality,
380
+ enterFullscreen,
381
+ exitFullscreen,
382
+ toggleFullscreen,
383
+ getSpriteForTime,
384
+ };
385
+ }
386
+
387
+ /** Parse a WebVTT sprite sheet file. */
388
+ function parseVtt(text: string, baseUrl: string): VttCue[] {
389
+ const cues: VttCue[] = [];
390
+ const lines = text.split("\n");
391
+ const baseDir = baseUrl.substring(0, baseUrl.lastIndexOf("/") + 1);
392
+
393
+ let i = 0;
394
+ // Skip header
395
+ while (i < lines.length && !lines[i]!.includes("-->")) i++;
396
+
397
+ while (i < lines.length) {
398
+ const line = lines[i]!.trim();
399
+
400
+ if (line.includes("-->")) {
401
+ const [startStr, endStr] = line.split("-->").map((s) => s.trim());
402
+ const startTime = parseVttTimestamp(startStr!);
403
+ const endTime = parseVttTimestamp(endStr!);
404
+
405
+ i++;
406
+ const dataLine = lines[i]?.trim() ?? "";
407
+
408
+ // Parse: sprite-sheet.jpg#xywh=0,0,160,90
409
+ const hashIndex = dataLine.indexOf("#xywh=");
410
+ if (hashIndex !== -1) {
411
+ const file = dataLine.substring(0, hashIndex);
412
+ const coords = dataLine.substring(hashIndex + 6).split(",").map(Number);
413
+
414
+ if (coords.length === 4) {
415
+ cues.push({
416
+ startTime,
417
+ endTime,
418
+ url: file.startsWith("http") ? file : `${baseDir}${file}`,
419
+ x: coords[0]!,
420
+ y: coords[1]!,
421
+ width: coords[2]!,
422
+ height: coords[3]!,
423
+ });
424
+ }
425
+ }
426
+ }
427
+ i++;
428
+ }
429
+
430
+ return cues;
431
+ }
432
+
433
+ function parseVttTimestamp(ts: string): number {
434
+ const parts = ts.split(":");
435
+ if (parts.length === 3) {
436
+ const [h, m, s] = parts;
437
+ return parseInt(h!) * 3600 + parseInt(m!) * 60 + parseFloat(s!);
438
+ }
439
+ if (parts.length === 2) {
440
+ const [m, s] = parts;
441
+ return parseInt(m!) * 60 + parseFloat(s!);
442
+ }
443
+ return parseFloat(ts);
444
+ }
package/src/types.ts ADDED
@@ -0,0 +1,130 @@
1
+ /** Extracted metadata from a video file. */
2
+ export interface VideoMetadata {
3
+ /** Video width in pixels. */
4
+ width: number;
5
+ /** Video height in pixels. */
6
+ height: number;
7
+ /** Duration in seconds. */
8
+ durationSeconds: number;
9
+ /** Video codec (e.g. "h264", "vp9"). */
10
+ codec: string;
11
+ /** Frame rate (e.g. 30, 60). */
12
+ frameRate: number;
13
+ /** Video bitrate in bits per second. */
14
+ bitrate: number;
15
+ /** Audio stream information, if present. */
16
+ audio?: {
17
+ codec: string;
18
+ sampleRate: number;
19
+ channels: number;
20
+ bitrate: number;
21
+ };
22
+ }
23
+
24
+ /** Status of a transcoding job. */
25
+ export type TranscodingStatus = "pending" | "processing" | "completed" | "failed";
26
+
27
+ /** A quality preset for HLS adaptive bitrate streaming. */
28
+ export interface QualityPreset {
29
+ /** Human-readable label (e.g. "720p"). */
30
+ label: string;
31
+ /** Output width in pixels. */
32
+ width: number;
33
+ /** Output height in pixels. */
34
+ height: number;
35
+ /** Target video bitrate in bits per second. */
36
+ videoBitrate: number;
37
+ /** Target audio bitrate in bits per second. */
38
+ audioBitrate: number;
39
+ /** Maximum frame rate. Source frame rate is used if lower. */
40
+ maxFrameRate: number;
41
+ }
42
+
43
+ /** Built-in quality presets for HLS transcoding. */
44
+ export const QUALITY_PRESETS: Record<string, QualityPreset> = {
45
+ "360p": {
46
+ label: "360p",
47
+ width: 640,
48
+ height: 360,
49
+ videoBitrate: 600_000,
50
+ audioBitrate: 64_000,
51
+ maxFrameRate: 30,
52
+ },
53
+ "480p": {
54
+ label: "480p",
55
+ width: 854,
56
+ height: 480,
57
+ videoBitrate: 1_500_000,
58
+ audioBitrate: 128_000,
59
+ maxFrameRate: 30,
60
+ },
61
+ "720p": {
62
+ label: "720p",
63
+ width: 1280,
64
+ height: 720,
65
+ videoBitrate: 3_000_000,
66
+ audioBitrate: 128_000,
67
+ maxFrameRate: 60,
68
+ },
69
+ "1080p": {
70
+ label: "1080p",
71
+ width: 1920,
72
+ height: 1080,
73
+ videoBitrate: 5_000_000,
74
+ audioBitrate: 192_000,
75
+ maxFrameRate: 60,
76
+ },
77
+ };
78
+
79
+ /** State of a transcoding job. */
80
+ export interface TranscodingJob {
81
+ /** Unique identifier for this job. */
82
+ jobId: string;
83
+ /** Current status. */
84
+ status: TranscodingStatus;
85
+ /** Progress percentage (0–100). */
86
+ progress: number;
87
+ /** S3 keys for generated outputs (playlists, segments, thumbnails). */
88
+ outputKeys: string[];
89
+ /** Error message if status is "failed". */
90
+ error?: string;
91
+ }
92
+
93
+ /** Options controlling the transcoding output. */
94
+ export interface TranscodingOptions {
95
+ /** Quality presets to generate. Defaults to all QUALITY_PRESETS. */
96
+ qualities?: QualityPreset[];
97
+ /** HLS segment duration in seconds. @default 6 */
98
+ segmentDuration?: number;
99
+ /** Whether to generate a poster thumbnail. @default true */
100
+ generateThumbnail?: boolean;
101
+ /** Whether to generate a sprite sheet with WebVTT. @default true */
102
+ generateSpriteSheet?: boolean;
103
+ /** S3 key prefix for output files. */
104
+ s3OutputPrefix: string;
105
+ }
106
+
107
+ /** Progress callback payload. */
108
+ export interface TranscodingProgress {
109
+ /** Job identifier. */
110
+ jobId: string;
111
+ /** Overall progress percentage (0–100). */
112
+ percent: number;
113
+ /** Quality preset currently being transcoded. */
114
+ currentQuality?: string;
115
+ /** Human-readable status message. */
116
+ message?: string;
117
+ }
118
+
119
+ /** Callback invoked during transcoding to report progress. */
120
+ export type TranscodingProgressCallback = (progress: TranscodingProgress) => void;
121
+
122
+ /** CDN cache header presets for HLS content. */
123
+ export const CDN_CACHE_HEADERS = {
124
+ /** HLS segments (.ts): immutable, cache for 1 year. */
125
+ segment: "public, max-age=31536000, immutable",
126
+ /** HLS playlists (.m3u8): short TTL for live-like updates. */
127
+ playlist: "public, max-age=60",
128
+ /** Thumbnails and sprite sheets: cache for 1 day. */
129
+ thumbnail: "public, max-age=86400",
130
+ } as const;