@invintusmedia/tomp4 1.1.0 → 1.2.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/dist/tomp4.js CHANGED
@@ -1,5 +1,5 @@
1
1
  /**
2
- * toMp4.js v1.1.0
2
+ * toMp4.js v1.2.0
3
3
  * Convert MPEG-TS and fMP4 to standard MP4
4
4
  * https://github.com/TVWIT/toMp4.js
5
5
  * MIT License
@@ -705,7 +705,7 @@
705
705
  toMp4.isMpegTs = isMpegTs;
706
706
  toMp4.isFmp4 = isFmp4;
707
707
  toMp4.isStandardMp4 = isStandardMp4;
708
- toMp4.version = '1.1.0';
708
+ toMp4.version = '1.2.0';
709
709
 
710
710
  return toMp4;
711
711
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@invintusmedia/tomp4",
3
- "version": "1.1.0",
3
+ "version": "1.2.0",
4
4
  "description": "Convert MPEG-TS, fMP4, and HLS streams to MP4 with clipping support - pure JavaScript, zero dependencies",
5
5
  "main": "src/index.js",
6
6
  "module": "src/index.js",
@@ -20,7 +20,9 @@
20
20
  "scripts": {
21
21
  "build": "node build.js",
22
22
  "dev": "npx serve . -p 3000",
23
- "test": "npm run test:clip && npm run test:mp4",
23
+ "test": "npm run test:hls-map && npm run test:thumbnail && npm run test:clip && npm run test:mp4",
24
+ "test:hls-map": "node tests/hls-map.test.js",
25
+ "test:thumbnail": "node tests/thumbnail.node.test.js",
24
26
  "test:clip": "node tests/clip.test.js",
25
27
  "test:mp4": "node tests/mp4-parser.test.js",
26
28
  "test:all": "npm run test",
@@ -47,12 +47,17 @@ function createBox(type, ...payloads) {
47
47
  // Fragment Parsing
48
48
  // ============================================
49
49
 
50
- function parseTfhd(tfhdData) {
50
+ function parseTfhd(tfhdData, trexDefaults = {}) {
51
51
  const view = new DataView(tfhdData.buffer, tfhdData.byteOffset, tfhdData.byteLength);
52
52
  const flags = (tfhdData[9] << 16) | (tfhdData[10] << 8) | tfhdData[11];
53
53
  const trackId = view.getUint32(12);
54
54
  let offset = 16;
55
- let baseDataOffset = 0, defaultSampleDuration = 0, defaultSampleSize = 0, defaultSampleFlags = 0;
55
+
56
+ // Start with trex defaults, override with tfhd values if present
57
+ let baseDataOffset = 0;
58
+ let defaultSampleDuration = trexDefaults.defaultSampleDuration || 0;
59
+ let defaultSampleSize = trexDefaults.defaultSampleSize || 0;
60
+ let defaultSampleFlags = trexDefaults.defaultSampleFlags || 0;
56
61
 
57
62
  if (flags & 0x1) { baseDataOffset = Number(view.getBigUint64(offset)); offset += 8; }
58
63
  if (flags & 0x2) offset += 4; // sample description index
@@ -63,6 +68,43 @@ function parseTfhd(tfhdData) {
63
68
  return { trackId, flags, baseDataOffset, defaultSampleDuration, defaultSampleSize, defaultSampleFlags };
64
69
  }
65
70
 
71
+ /**
72
+ * Parse trex (Track Extends) box from mvex
73
+ * This contains default sample values for all fragments
74
+ */
75
+ function parseTrex(trexData) {
76
+ const view = new DataView(trexData.buffer, trexData.byteOffset, trexData.byteLength);
77
+ // Skip box header (8) + version/flags (4)
78
+ const trackId = view.getUint32(12);
79
+ const defaultSampleDescriptionIndex = view.getUint32(16);
80
+ const defaultSampleDuration = view.getUint32(20);
81
+ const defaultSampleSize = view.getUint32(24);
82
+ const defaultSampleFlags = view.getUint32(28);
83
+
84
+ return { trackId, defaultSampleDescriptionIndex, defaultSampleDuration, defaultSampleSize, defaultSampleFlags };
85
+ }
86
+
87
+ /**
88
+ * Extract trex defaults from moov's mvex box
89
+ */
90
+ function extractTrexDefaults(moovBox) {
91
+ const defaults = new Map(); // trackId -> { defaultSampleDuration, ... }
92
+ const moovChildren = parseChildBoxes(moovBox);
93
+ const mvex = findBox(moovChildren, 'mvex');
94
+
95
+ if (mvex) {
96
+ const mvexChildren = parseChildBoxes(mvex);
97
+ for (const child of mvexChildren) {
98
+ if (child.type === 'trex') {
99
+ const trex = parseTrex(child.data);
100
+ defaults.set(trex.trackId, trex);
101
+ }
102
+ }
103
+ }
104
+
105
+ return defaults;
106
+ }
107
+
66
108
  function parseTfdt(tfdtData) {
67
109
  const view = new DataView(tfdtData.buffer, tfdtData.byteOffset, tfdtData.byteLength);
68
110
  const version = tfdtData[8];
@@ -433,6 +475,7 @@ export function stitchFmp4(segments, options = {}) {
433
475
  let ftyp = null;
434
476
  let moov = null;
435
477
  let originalTrackIds = [];
478
+ let trexDefaults = new Map(); // trackId -> default sample values from trex
436
479
 
437
480
  // Process init segment if provided separately
438
481
  if (initData) {
@@ -443,6 +486,7 @@ export function stitchFmp4(segments, options = {}) {
443
486
  throw new Error('stitchFmp4: Init segment missing ftyp or moov');
444
487
  }
445
488
  originalTrackIds = extractTrackIds(moov);
489
+ trexDefaults = extractTrexDefaults(moov);
446
490
  }
447
491
 
448
492
  // Process each segment
@@ -461,6 +505,7 @@ export function stitchFmp4(segments, options = {}) {
461
505
  if (!moov && segMoov) {
462
506
  moov = segMoov;
463
507
  originalTrackIds = extractTrackIds(moov);
508
+ trexDefaults = extractTrexDefaults(moov);
464
509
  }
465
510
 
466
511
  // Process fragment boxes (moof + mdat pairs)
@@ -492,7 +537,12 @@ export function stitchFmp4(segments, options = {}) {
492
537
  const tfdtBox = findBox(trafChildren, 'tfdt');
493
538
 
494
539
  if (tfhdBox && trunBox) {
495
- const tfhd = parseTfhd(tfhdBox.data);
540
+ // Get trackId first to look up trex defaults
541
+ const tfhdView = new DataView(tfhdBox.data.buffer, tfhdBox.data.byteOffset, tfhdBox.data.byteLength);
542
+ const trackId = tfhdView.getUint32(12);
543
+ const trackTrexDefaults = trexDefaults.get(trackId) || {};
544
+
545
+ const tfhd = parseTfhd(tfhdBox.data, trackTrexDefaults);
496
546
  const { samples, dataOffset } = parseTrun(trunBox.data, tfhd);
497
547
 
498
548
  if (!tracks.has(tfhd.trackId)) {
package/src/hls.js CHANGED
@@ -126,8 +126,10 @@ function parsePlaylistText(text, baseUrl) {
126
126
  const lines = text.split('\n').map(l => l.trim());
127
127
  const variants = [];
128
128
  const segments = [];
129
+ let initSegmentUrl = null;
129
130
  let currentDuration = 0;
130
131
  let runningTime = 0;
132
+ const isMaster = lines.some(l => l.startsWith('#EXT-X-STREAM-INF'));
131
133
 
132
134
  for (let i = 0; i < lines.length; i++) {
133
135
  const line = lines[i];
@@ -151,6 +153,15 @@ function parsePlaylistText(text, baseUrl) {
151
153
  }
152
154
  }
153
155
 
156
+ // Parse CMAF init segment for fMP4 playlists
157
+ // Example: #EXT-X-MAP:URI="init.m4s"
158
+ if (line.startsWith('#EXT-X-MAP:')) {
159
+ const match = line.match(/URI="([^"]+)"/);
160
+ if (match?.[1]) {
161
+ initSegmentUrl = toAbsoluteUrl(match[1], baseUrl);
162
+ }
163
+ }
164
+
154
165
  // Parse segment duration
155
166
  if (line.startsWith('#EXTINF:')) {
156
167
  const match = line.match(/#EXTINF:([\d.]+)/);
@@ -160,7 +171,7 @@ function parsePlaylistText(text, baseUrl) {
160
171
  // Parse media playlist segments
161
172
  if (line && !line.startsWith('#')) {
162
173
  // It's a segment URL
163
- if (!lines.some(l => l.startsWith('#EXT-X-STREAM-INF'))) {
174
+ if (!isMaster) {
164
175
  segments.push(new HlsSegment(
165
176
  toAbsoluteUrl(line, baseUrl),
166
177
  currentDuration,
@@ -172,7 +183,7 @@ function parsePlaylistText(text, baseUrl) {
172
183
  }
173
184
  }
174
185
 
175
- return { variants, segments };
186
+ return { variants, segments, initSegmentUrl };
176
187
  }
177
188
 
178
189
  /**
@@ -194,7 +205,7 @@ async function parseHls(url, options = {}) {
194
205
  }
195
206
 
196
207
  const text = await response.text();
197
- const { variants, segments } = parsePlaylistText(text, url);
208
+ const { variants, segments, initSegmentUrl } = parsePlaylistText(text, url);
198
209
 
199
210
  if (variants.length > 0) {
200
211
  // Master playlist
@@ -203,7 +214,9 @@ async function parseHls(url, options = {}) {
203
214
  } else if (segments.length > 0) {
204
215
  // Media playlist (no variants)
205
216
  log(`Found ${segments.length} segments`);
206
- return new HlsStream(url, [], segments);
217
+ const stream = new HlsStream(url, [], segments);
218
+ stream.initSegmentUrl = initSegmentUrl;
219
+ return stream;
207
220
  } else {
208
221
  throw new Error('Invalid HLS playlist: no variants or segments found');
209
222
  }
@@ -237,6 +250,7 @@ async function downloadHls(source, options = {}) {
237
250
 
238
251
  // Get segments
239
252
  let segments = stream.segments;
253
+ let initSegmentUrl = stream.initSegmentUrl || null;
240
254
 
241
255
  // If master playlist, fetch the selected variant's media playlist
242
256
  if (stream.isMaster && stream.selected) {
@@ -249,8 +263,9 @@ async function downloadHls(source, options = {}) {
249
263
  }
250
264
 
251
265
  const mediaText = await mediaResponse.text();
252
- const { segments: mediaSegments } = parsePlaylistText(mediaText, variant.url);
266
+ const { segments: mediaSegments, initSegmentUrl: mediaInit } = parsePlaylistText(mediaText, variant.url);
253
267
  segments = mediaSegments;
268
+ initSegmentUrl = mediaInit || null;
254
269
  }
255
270
 
256
271
  if (!segments || segments.length === 0) {
@@ -300,10 +315,26 @@ async function downloadHls(source, options = {}) {
300
315
  })
301
316
  );
302
317
 
318
+ // If CMAF/fMP4 playlist includes an init segment, prepend it so the combined
319
+ // buffer starts with ftyp+moov and conversion can succeed.
320
+ let initBytes = null;
321
+ if (initSegmentUrl) {
322
+ log('Downloading init segment...', { phase: 'download', percent: 0 });
323
+ const initResp = await fetch(initSegmentUrl);
324
+ if (!initResp.ok) {
325
+ throw new Error(`Init segment failed: ${initResp.status}`);
326
+ }
327
+ initBytes = new Uint8Array(await initResp.arrayBuffer());
328
+ }
329
+
303
330
  // Combine into single buffer
304
- const totalSize = buffers.reduce((sum, buf) => sum + buf.length, 0);
331
+ const totalSize = buffers.reduce((sum, buf) => sum + buf.length, 0) + (initBytes ? initBytes.length : 0);
305
332
  const combined = new Uint8Array(totalSize);
306
333
  let offset = 0;
334
+ if (initBytes) {
335
+ combined.set(initBytes, offset);
336
+ offset += initBytes.length;
337
+ }
307
338
  for (const buf of buffers) {
308
339
  combined.set(buf, offset);
309
340
  offset += buf.length;
@@ -343,4 +374,3 @@ export {
343
374
  parsePlaylistText,
344
375
  toAbsoluteUrl
345
376
  };
346
-
package/src/index.js CHANGED
@@ -40,6 +40,7 @@ import { MP4Muxer } from './muxers/mp4.js';
40
40
  import { TSParser } from './parsers/mpegts.js';
41
41
  import { MP4Parser } from './parsers/mp4.js';
42
42
  import { RemoteMp4 } from './remote/index.js';
43
+ import { thumbnail, ImageResult } from './thumbnail.js';
43
44
 
44
45
  /**
45
46
  * Result object returned by toMp4()
@@ -320,6 +321,7 @@ toMp4.analyze = analyzeTsData;
320
321
  // Transcoding (browser-only, uses WebCodecs)
321
322
  toMp4.transcode = transcode;
322
323
  toMp4.isWebCodecsSupported = isWebCodecsSupported;
324
+ toMp4.thumbnail = thumbnail;
323
325
 
324
326
  // Parsers
325
327
  toMp4.MP4Parser = MP4Parser;
@@ -329,7 +331,7 @@ toMp4.TSParser = TSParser;
329
331
  toMp4.RemoteMp4 = RemoteMp4;
330
332
 
331
333
  // Version (injected at build time for dist, read from package.json for ESM)
332
- toMp4.version = '1.1.0';
334
+ toMp4.version = '1.2.0';
333
335
 
334
336
  // Export
335
337
  export {
@@ -353,6 +355,9 @@ export {
353
355
  // Transcoding (browser-only)
354
356
  transcode,
355
357
  isWebCodecsSupported,
358
+ // Thumbnails (browser-only)
359
+ thumbnail,
360
+ ImageResult,
356
361
  // Muxers
357
362
  TSMuxer,
358
363
  MP4Muxer,
@@ -0,0 +1,269 @@
1
+ /**
2
+ * Thumbnail extraction (browser-only)
3
+ *
4
+ * This is intentionally implemented using `<video>` + `<canvas>` instead of
5
+ * container-level parsing, because decoding frames is the hard part:
6
+ * browsers already have hardware decoders and seeking.
7
+ *
8
+ * For HLS inputs, we rely on toMp4() to remux a tiny time range into an MP4 clip
9
+ * (downloads minimal segments). This also handles fMP4 EXT-X-MAP init segments
10
+ * now that HLS parsing supports it.
11
+ *
12
+ * Limitations:
13
+ * - Requires browser DOM APIs (document, HTMLVideoElement, canvas).
14
+ * - For cross-origin URLs, the server must send `Access-Control-Allow-Origin`
15
+ * or the canvas will be tainted and pixel extraction will fail.
16
+ */
17
+
18
+ import toMp4 from './index.js';
19
+
20
+ export class ImageResult {
21
+ constructor(blob, filename = 'thumbnail.jpg') {
22
+ this.blob = blob;
23
+ this.filename = filename;
24
+ this._url = null;
25
+ }
26
+
27
+ toBlob() {
28
+ return this.blob;
29
+ }
30
+
31
+ toURL() {
32
+ if (!this._url) this._url = URL.createObjectURL(this.blob);
33
+ return this._url;
34
+ }
35
+
36
+ revokeURL() {
37
+ if (this._url) URL.revokeObjectURL(this._url);
38
+ this._url = null;
39
+ }
40
+
41
+ download(filename) {
42
+ const a = document.createElement('a');
43
+ a.href = this.toURL();
44
+ a.download = filename || this.filename;
45
+ a.click();
46
+ }
47
+ }
48
+
49
+ function isBrowser() {
50
+ return typeof document !== 'undefined' && typeof window !== 'undefined';
51
+ }
52
+
53
+ function requireBrowser() {
54
+ if (!isBrowser()) {
55
+ throw new Error('toMp4.thumbnail() is browser-only (requires document/canvas).');
56
+ }
57
+ }
58
+
59
+ function withTimeout(promise, timeoutMs, message) {
60
+ return new Promise((resolve, reject) => {
61
+ const t = setTimeout(() => reject(new Error(message)), timeoutMs);
62
+ promise
63
+ .then(resolve)
64
+ .catch(reject)
65
+ .finally(() => clearTimeout(t));
66
+ });
67
+ }
68
+
69
+ async function waitOnce(target, eventName) {
70
+ return new Promise((resolve, reject) => {
71
+ const onOk = () => {
72
+ cleanup();
73
+ resolve();
74
+ };
75
+ const onErr = () => {
76
+ cleanup();
77
+ reject(new Error('Video failed to load'));
78
+ };
79
+ const cleanup = () => {
80
+ target.removeEventListener(eventName, onOk);
81
+ target.removeEventListener('error', onErr);
82
+ };
83
+ target.addEventListener(eventName, onOk, { once: true });
84
+ target.addEventListener('error', onErr, { once: true });
85
+ });
86
+ }
87
+
88
+ async function seek(video, timeSeconds) {
89
+ return new Promise((resolve) => {
90
+ const onSeeked = () => resolve();
91
+ video.addEventListener('seeked', onSeeked, { once: true });
92
+ try {
93
+ video.currentTime = Math.max(0, timeSeconds);
94
+ } catch {
95
+ resolve();
96
+ }
97
+ });
98
+ }
99
+
100
+ /**
101
+ * Extract a single frame as an image.
102
+ *
103
+ * @param {string|Uint8Array|ArrayBuffer|Blob|import('./hls.js').HlsStream|{init?:Uint8Array|ArrayBuffer, segments:(Uint8Array|ArrayBuffer)[]}} input
104
+ * @param {object} [options]
105
+ * @param {number} [options.time] - Time in seconds for the capture (default 0.15)
106
+ * @param {number} [options.maxWidth] - Resize output to this width (preserve aspect)
107
+ * @param {string} [options.mimeType] - 'image/jpeg' | 'image/webp' | 'image/png'
108
+ * @param {number} [options.quality] - 0..1 (jpeg/webp)
109
+ * @param {number} [options.timeoutMs] - Overall timeout (default 15000)
110
+ * @param {string} [options.hlsQuality] - 'lowest' | 'highest' (default 'lowest')
111
+ * @returns {Promise<ImageResult>}
112
+ */
113
+ export async function thumbnail(input, options = {}) {
114
+ requireBrowser();
115
+
116
+ const {
117
+ time = 0.15,
118
+ maxWidth = 480,
119
+ mimeType = 'image/jpeg',
120
+ quality = 0.82,
121
+ timeoutMs = 15000,
122
+ hlsQuality = 'lowest',
123
+ } = options;
124
+
125
+ // If input is fragmented-only fMP4, allow caller to provide init+segments.
126
+ // This addresses the "no ftyp/moov" case: fragments alone are not decodable
127
+ // without codec init data.
128
+ let mp4Cleanup = null;
129
+ let mediaUrl = null;
130
+ let localSeek = time;
131
+
132
+ const isHlsLike =
133
+ typeof input === 'string' ? toMp4.isHlsUrl(input) : false;
134
+
135
+ const buildMp4Clip = async () => {
136
+ const clipEnd = time + 1.5;
137
+ const mp4 = await toMp4(input, {
138
+ startTime: time,
139
+ endTime: clipEnd,
140
+ quality: hlsQuality,
141
+ onProgress: () => {},
142
+ });
143
+ mp4Cleanup = () => mp4.revokeURL();
144
+ mediaUrl = mp4.toURL();
145
+ // The clip is normalized to start at 0
146
+ localSeek = 0.1;
147
+ };
148
+
149
+ const isSegmentsObject =
150
+ input &&
151
+ typeof input === 'object' &&
152
+ !ArrayBuffer.isView(input) &&
153
+ !(input instanceof ArrayBuffer) &&
154
+ !(input instanceof Blob) &&
155
+ !('masterUrl' in input) && // HlsStream
156
+ Array.isArray(input.segments);
157
+
158
+ if (isSegmentsObject) {
159
+ const init = input.init
160
+ ? (input.init instanceof ArrayBuffer ? new Uint8Array(input.init) : input.init)
161
+ : null;
162
+ const segs = input.segments.map((s) =>
163
+ s instanceof ArrayBuffer ? new Uint8Array(s) : s,
164
+ );
165
+ if (!init) {
166
+ // fMP4 fragments (moof/mdat) are not decodable without the init segment (ftyp/moov).
167
+ // This commonly comes from HLS playlists via EXT-X-MAP.
168
+ throw new Error('Fragments-only input requires an init segment (ftyp/moov). Provide `init` (e.g. EXT-X-MAP) along with `segments`.');
169
+ }
170
+ const mp4 = toMp4.stitchFmp4(segs, init ? { init } : undefined);
171
+ mp4Cleanup = () => mp4.revokeURL();
172
+ mediaUrl = mp4.toURL();
173
+ localSeek = time;
174
+ } else if (isHlsLike || (input && typeof input === 'object' && input.masterUrl && input.variants)) {
175
+ // HLS URL or HlsStream: make a tiny MP4 clip around the time.
176
+ await buildMp4Clip();
177
+ } else if (typeof input === 'string') {
178
+ // Direct MP4 URL
179
+ mediaUrl = input;
180
+ localSeek = time;
181
+ } else {
182
+ // Raw bytes/blob: remux to an MP4 clip and capture from that.
183
+ await buildMp4Clip();
184
+ }
185
+
186
+ const run = async () => {
187
+ const video = document.createElement('video');
188
+ video.muted = true;
189
+ video.playsInline = true;
190
+ video.preload = 'auto';
191
+ video.crossOrigin = 'anonymous';
192
+
193
+ const host = document.createElement('div');
194
+ host.style.position = 'fixed';
195
+ host.style.left = '-99999px';
196
+ host.style.top = '0';
197
+ host.style.width = '1px';
198
+ host.style.height = '1px';
199
+ host.style.overflow = 'hidden';
200
+ host.appendChild(video);
201
+ document.body.appendChild(host);
202
+
203
+ try {
204
+ video.src = mediaUrl;
205
+ await waitOnce(video, 'loadeddata');
206
+
207
+ try {
208
+ await video.play();
209
+ video.pause();
210
+ } catch {
211
+ // ignore autoplay restrictions; muted should pass in most browsers
212
+ }
213
+
214
+ const dur = Number.isFinite(video.duration) ? video.duration : 0;
215
+ const safeTime = dur > 0 ? Math.min(localSeek, Math.max(0, dur - 0.05)) : localSeek;
216
+ await seek(video, safeTime);
217
+
218
+ const vw = video.videoWidth || 0;
219
+ const vh = video.videoHeight || 0;
220
+ if (!vw || !vh) throw new Error('No video frame available');
221
+
222
+ const scale = Math.min(1, maxWidth / vw);
223
+ const outW = Math.max(1, Math.round(vw * scale));
224
+ const outH = Math.max(1, Math.round(vh * scale));
225
+
226
+ const canvas = document.createElement('canvas');
227
+ canvas.width = outW;
228
+ canvas.height = outH;
229
+ const ctx = canvas.getContext('2d');
230
+ if (!ctx) throw new Error('Canvas not supported');
231
+
232
+ ctx.drawImage(video, 0, 0, outW, outH);
233
+
234
+ const blob = await new Promise((resolve, reject) => {
235
+ try {
236
+ canvas.toBlob(
237
+ (b) => (b ? resolve(b) : reject(new Error('Failed to encode thumbnail'))),
238
+ mimeType,
239
+ quality,
240
+ );
241
+ } catch (err) {
242
+ reject(err);
243
+ }
244
+ });
245
+
246
+ return new ImageResult(blob, mimeType === 'image/png' ? 'thumbnail.png' : 'thumbnail.jpg');
247
+ } catch (err) {
248
+ const msg = err && err.message ? err.message : String(err);
249
+ if (msg.toLowerCase().includes('taint') || msg.toLowerCase().includes('security')) {
250
+ throw new Error('Canvas is tainted by cross-origin media. Ensure the server sets CORS headers (Access-Control-Allow-Origin).');
251
+ }
252
+ throw err;
253
+ } finally {
254
+ try {
255
+ video.pause();
256
+ video.removeAttribute('src');
257
+ video.load();
258
+ } catch {}
259
+ try {
260
+ host.remove();
261
+ } catch {}
262
+ try {
263
+ mp4Cleanup?.();
264
+ } catch {}
265
+ }
266
+ };
267
+
268
+ return await withTimeout(run(), timeoutMs, 'Thumbnail generation timed out');
269
+ }