@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 +2 -2
- package/package.json +4 -2
- package/src/hls.js +37 -7
- package/src/index.js +6 -1
- package/src/thumbnail.js +269 -0
package/dist/tomp4.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* toMp4.js v1.
|
|
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.
|
|
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.
|
|
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 (!
|
|
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
|
-
|
|
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.
|
|
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,
|
package/src/thumbnail.js
ADDED
|
@@ -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
|
+
}
|