@invintusmedia/tomp4 1.1.1 → 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.1
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.1';
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.1",
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",
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.1';
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
+ }