playsvideo 0.2.1 → 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.
- package/README.md +8 -3
- package/dist/engine.d.ts +74 -2
- package/dist/engine.d.ts.map +1 -1
- package/dist/engine.js +430 -47
- package/dist/engine.js.map +1 -1
- package/dist/external-subtitle-picker.d.ts +13 -0
- package/dist/external-subtitle-picker.d.ts.map +1 -0
- package/dist/external-subtitle-picker.js +41 -0
- package/dist/external-subtitle-picker.js.map +1 -0
- package/dist/index.d.ts +3 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/dist/index.js.map +1 -1
- package/dist/pipeline/codec-probe.d.ts.map +1 -1
- package/dist/pipeline/codec-probe.js +8 -0
- package/dist/pipeline/codec-probe.js.map +1 -1
- package/dist/pipeline/segment-plan.d.ts.map +1 -1
- package/dist/pipeline/segment-plan.js +25 -11
- package/dist/pipeline/segment-plan.js.map +1 -1
- package/dist/playback-selection.d.ts +82 -0
- package/dist/playback-selection.d.ts.map +1 -0
- package/dist/playback-selection.js +194 -0
- package/dist/playback-selection.js.map +1 -0
- package/dist/transcode-protocol.d.ts +16 -0
- package/dist/transcode-protocol.d.ts.map +1 -1
- package/dist/transcode-worker.js +54 -0
- package/dist/transcode-worker.js.map +1 -1
- package/dist/worker-protocol.d.ts +9 -0
- package/dist/worker-protocol.d.ts.map +1 -0
- package/dist/worker-protocol.js +2 -0
- package/dist/worker-protocol.js.map +1 -0
- package/dist/worker.js +29 -0
- package/dist/worker.js.map +1 -1
- package/package.json +2 -1
package/dist/engine.js
CHANGED
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
import Hls from 'hls.js';
|
|
2
2
|
import { WasmFfmpegRunner } from './adapters/wasm-ffmpeg.js';
|
|
3
|
+
import { createBrowserPlaybackCapabilities, evaluatePlaybackOptions, } from './playback-selection.js';
|
|
3
4
|
import { createLocalAudioTranscoder, makeAacDecoderConfig } from './pipeline/audio-transcode.js';
|
|
4
|
-
import { audioNeedsTranscode, createBrowserProber } from './pipeline/codec-probe.js';
|
|
5
5
|
import { demuxSource, getKeyframeIndex } from './pipeline/demux.js';
|
|
6
6
|
import { generateVodPlaylist } from './pipeline/playlist.js';
|
|
7
7
|
import { buildSegmentPlan } from './pipeline/segment-plan.js';
|
|
8
|
+
import { parseSubtitleFile, subtitleDataToWebVTT } from './pipeline/subtitle.js';
|
|
8
9
|
import { processSegmentWithAbort } from './pipeline/segment-processor.js';
|
|
9
10
|
import { isAbortableSource } from './pipeline/source-signal.js';
|
|
10
11
|
function defaultTranscodeWorkerCount() {
|
|
@@ -18,6 +19,8 @@ export class PlaysVideoEngine extends EventTarget {
|
|
|
18
19
|
options;
|
|
19
20
|
worker = null;
|
|
20
21
|
transcodeWorkers = [];
|
|
22
|
+
_transcodeWorkerStates = [];
|
|
23
|
+
_segmentStates = new Map();
|
|
21
24
|
hls = null;
|
|
22
25
|
// Pending segment requests from hls.js custom loader
|
|
23
26
|
pendingSegments = new Map();
|
|
@@ -28,7 +31,7 @@ export class PlaysVideoEngine extends EventTarget {
|
|
|
28
31
|
pendingPlaylist = null;
|
|
29
32
|
segmentRequestTimes = new Map();
|
|
30
33
|
// Subtitle state
|
|
31
|
-
|
|
34
|
+
attachedSubtitleTracks = [];
|
|
32
35
|
_subtitleTracks = [];
|
|
33
36
|
// Public read-only state
|
|
34
37
|
_phase = 'idle';
|
|
@@ -38,6 +41,13 @@ export class PlaysVideoEngine extends EventTarget {
|
|
|
38
41
|
_passthrough = false;
|
|
39
42
|
_blobUrl = null;
|
|
40
43
|
_pendingFileType = null;
|
|
44
|
+
_codecPath = {
|
|
45
|
+
mode: 'pipeline',
|
|
46
|
+
sourceVideo: { short: null, full: null },
|
|
47
|
+
sourceAudio: { short: null, full: null },
|
|
48
|
+
outputVideo: { short: null, full: null },
|
|
49
|
+
outputAudio: { short: null, full: null },
|
|
50
|
+
};
|
|
41
51
|
// Pre-built keyframe index (e.g. from MKV cues) to skip mediabunny scan
|
|
42
52
|
_keyframeIndex = null;
|
|
43
53
|
// Main-thread pipeline state (used by loadSource)
|
|
@@ -68,6 +78,26 @@ export class PlaysVideoEngine extends EventTarget {
|
|
|
68
78
|
get passthrough() {
|
|
69
79
|
return this._passthrough;
|
|
70
80
|
}
|
|
81
|
+
get codecPath() {
|
|
82
|
+
return {
|
|
83
|
+
mode: this._codecPath.mode,
|
|
84
|
+
sourceVideo: { ...this._codecPath.sourceVideo },
|
|
85
|
+
sourceAudio: { ...this._codecPath.sourceAudio },
|
|
86
|
+
outputVideo: { ...this._codecPath.outputVideo },
|
|
87
|
+
outputAudio: { ...this._codecPath.outputAudio },
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
get transcodeWorkerStates() {
|
|
91
|
+
return this._transcodeWorkerStates.map((worker) => ({ ...worker }));
|
|
92
|
+
}
|
|
93
|
+
get segmentStates() {
|
|
94
|
+
return Array.from(this._segmentStates.values())
|
|
95
|
+
.sort((a, b) => a.index - b.index)
|
|
96
|
+
.map((segment) => ({
|
|
97
|
+
...segment,
|
|
98
|
+
events: segment.events.map((event) => ({ ...event })),
|
|
99
|
+
}));
|
|
100
|
+
}
|
|
71
101
|
constructor(video, options = {}) {
|
|
72
102
|
super();
|
|
73
103
|
this.video = video;
|
|
@@ -91,6 +121,31 @@ export class PlaysVideoEngine extends EventTarget {
|
|
|
91
121
|
this.worker.postMessage({ type: 'open-url', url });
|
|
92
122
|
mlog(`open url=${url}`);
|
|
93
123
|
}
|
|
124
|
+
async loadExternalSubtitle(file, options = {}) {
|
|
125
|
+
if (this._phase !== 'ready') {
|
|
126
|
+
throw new Error('Load a video before adding an external subtitle file');
|
|
127
|
+
}
|
|
128
|
+
const text = await file.text();
|
|
129
|
+
const data = parseSubtitleFile(text, file.name);
|
|
130
|
+
if (data.codec === 'ass' || data.codec === 'ssa') {
|
|
131
|
+
throw new Error('External .ass/.ssa subtitles are not supported yet');
|
|
132
|
+
}
|
|
133
|
+
const webvtt = subtitleDataToWebVTT(data);
|
|
134
|
+
this.clearExternalSubtitles();
|
|
135
|
+
this.addSubtitleTrack({
|
|
136
|
+
webvtt,
|
|
137
|
+
source: 'external',
|
|
138
|
+
label: options.label ?? file.name.replace(/\.[^.]+$/, ''),
|
|
139
|
+
language: options.language ?? 'und',
|
|
140
|
+
kind: options.kind ?? 'subtitles',
|
|
141
|
+
defaultTrack: true,
|
|
142
|
+
selectTrack: true,
|
|
143
|
+
});
|
|
144
|
+
}
|
|
145
|
+
clearExternalSubtitles() {
|
|
146
|
+
this.removeSubtitleTracks('external');
|
|
147
|
+
this.restoreDefaultTextTrack();
|
|
148
|
+
}
|
|
94
149
|
/**
|
|
95
150
|
* Load from an external Source (e.g. TorrentSource).
|
|
96
151
|
*
|
|
@@ -135,9 +190,6 @@ export class PlaysVideoEngine extends EventTarget {
|
|
|
135
190
|
this.initData = null;
|
|
136
191
|
this.pendingSegments.clear();
|
|
137
192
|
this.segmentRequestTimes.clear();
|
|
138
|
-
for (const url of this.subtitleBlobUrls)
|
|
139
|
-
URL.revokeObjectURL(url);
|
|
140
|
-
this.subtitleBlobUrls = [];
|
|
141
193
|
this.removeSubtitleTracks();
|
|
142
194
|
this._phase = 'demuxing';
|
|
143
195
|
this._totalSegments = 0;
|
|
@@ -146,6 +198,13 @@ export class PlaysVideoEngine extends EventTarget {
|
|
|
146
198
|
this._passthrough = false;
|
|
147
199
|
this._pendingFileType = null;
|
|
148
200
|
this._keyframeIndex = null;
|
|
201
|
+
this._codecPath = {
|
|
202
|
+
mode: 'pipeline',
|
|
203
|
+
sourceVideo: { short: null, full: null },
|
|
204
|
+
sourceAudio: { short: null, full: null },
|
|
205
|
+
outputVideo: { short: null, full: null },
|
|
206
|
+
outputAudio: { short: null, full: null },
|
|
207
|
+
};
|
|
149
208
|
// Source pipeline cleanup
|
|
150
209
|
if (this._sourceSegmentAbort) {
|
|
151
210
|
this._sourceSegmentAbort.abort();
|
|
@@ -162,7 +221,9 @@ export class PlaysVideoEngine extends EventTarget {
|
|
|
162
221
|
this._sourceAudioDecoderConfig = null;
|
|
163
222
|
this._sourceInitSegment = null;
|
|
164
223
|
this._sourceFfmpeg = null;
|
|
224
|
+
this._segmentStates.clear();
|
|
165
225
|
this.dispatchEvent(new CustomEvent('loading', { detail }));
|
|
226
|
+
this.dispatchSegmentStateChange();
|
|
166
227
|
}
|
|
167
228
|
createWorker() {
|
|
168
229
|
this.worker = new Worker(new URL('./worker.js', import.meta.url), { type: 'module' });
|
|
@@ -180,17 +241,40 @@ export class PlaysVideoEngine extends EventTarget {
|
|
|
180
241
|
const worker = new Worker(new URL('./transcode-worker.js', import.meta.url), {
|
|
181
242
|
type: 'module',
|
|
182
243
|
});
|
|
244
|
+
worker.onmessage = (event) => this.handleTranscodeWorkerMessage(i, event);
|
|
245
|
+
worker.onerror = (event) => {
|
|
246
|
+
this.updateTranscodeWorkerState(i, {
|
|
247
|
+
phase: 'error',
|
|
248
|
+
jobId: null,
|
|
249
|
+
lastError: event.message || 'Transcode worker crashed',
|
|
250
|
+
});
|
|
251
|
+
};
|
|
183
252
|
const channel = new MessageChannel();
|
|
184
253
|
worker.postMessage({ type: 'connect' }, [channel.port2]);
|
|
185
254
|
this.worker.postMessage({ type: 'transcode-port', id: i }, [channel.port1]);
|
|
186
255
|
this.transcodeWorkers.push({ worker });
|
|
256
|
+
this._transcodeWorkerStates.push({
|
|
257
|
+
id: i,
|
|
258
|
+
phase: 'starting',
|
|
259
|
+
sourceCodec: null,
|
|
260
|
+
jobId: null,
|
|
261
|
+
inputBytes: null,
|
|
262
|
+
outputBytes: null,
|
|
263
|
+
totalMs: null,
|
|
264
|
+
ffmpegMs: null,
|
|
265
|
+
jobsCompleted: 0,
|
|
266
|
+
lastError: null,
|
|
267
|
+
});
|
|
187
268
|
}
|
|
269
|
+
this.dispatchWorkerStateChange();
|
|
188
270
|
}
|
|
189
271
|
destroyTranscodeWorkers() {
|
|
190
272
|
for (const handle of this.transcodeWorkers) {
|
|
191
273
|
handle.worker.terminate();
|
|
192
274
|
}
|
|
193
275
|
this.transcodeWorkers = [];
|
|
276
|
+
this._transcodeWorkerStates = [];
|
|
277
|
+
this.dispatchWorkerStateChange();
|
|
194
278
|
}
|
|
195
279
|
destroy() {
|
|
196
280
|
if (this.hls) {
|
|
@@ -210,29 +294,172 @@ export class PlaysVideoEngine extends EventTarget {
|
|
|
210
294
|
this.video.removeAttribute('src');
|
|
211
295
|
this.video.load();
|
|
212
296
|
}
|
|
213
|
-
for (const url of this.subtitleBlobUrls)
|
|
214
|
-
URL.revokeObjectURL(url);
|
|
215
|
-
this.subtitleBlobUrls = [];
|
|
216
297
|
this.removeSubtitleTracks();
|
|
217
298
|
this.pendingSegments.clear();
|
|
218
299
|
this.segmentRequestTimes.clear();
|
|
219
300
|
this._phase = 'idle';
|
|
220
301
|
this._passthrough = false;
|
|
302
|
+
this._segmentStates.clear();
|
|
303
|
+
this.dispatchSegmentStateChange();
|
|
221
304
|
}
|
|
222
305
|
addEventListener(type, listener, options) {
|
|
223
306
|
super.addEventListener(type, listener, options);
|
|
224
307
|
}
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
308
|
+
dispatchWorkerStateChange() {
|
|
309
|
+
this.dispatchEvent(new CustomEvent('workerstatechange', {
|
|
310
|
+
detail: {
|
|
311
|
+
workers: this.transcodeWorkerStates,
|
|
312
|
+
},
|
|
313
|
+
}));
|
|
314
|
+
}
|
|
315
|
+
dispatchSegmentStateChange() {
|
|
316
|
+
this.dispatchEvent(new CustomEvent('segmentstatechange', {
|
|
317
|
+
detail: {
|
|
318
|
+
segments: this.segmentStates,
|
|
319
|
+
},
|
|
320
|
+
}));
|
|
321
|
+
}
|
|
322
|
+
handleTranscodeWorkerMessage(id, event) {
|
|
323
|
+
const msg = event.data;
|
|
324
|
+
if (!msg || msg.type !== 'worker-state') {
|
|
325
|
+
return;
|
|
326
|
+
}
|
|
327
|
+
this.updateTranscodeWorkerState(id, msg.state);
|
|
328
|
+
}
|
|
329
|
+
updateTranscodeWorkerState(id, patch) {
|
|
330
|
+
const index = this._transcodeWorkerStates.findIndex((worker) => worker.id === id);
|
|
331
|
+
if (index === -1) {
|
|
332
|
+
return;
|
|
333
|
+
}
|
|
334
|
+
this._transcodeWorkerStates[index] = {
|
|
335
|
+
...this._transcodeWorkerStates[index],
|
|
336
|
+
...patch,
|
|
337
|
+
id,
|
|
338
|
+
};
|
|
339
|
+
this.dispatchWorkerStateChange();
|
|
340
|
+
}
|
|
341
|
+
noteSegmentState(index, phase, opts = {}) {
|
|
342
|
+
const existing = this._segmentStates.get(index);
|
|
343
|
+
const next = existing
|
|
344
|
+
? {
|
|
345
|
+
...existing,
|
|
346
|
+
events: [...existing.events],
|
|
347
|
+
}
|
|
348
|
+
: {
|
|
349
|
+
index,
|
|
350
|
+
phase,
|
|
351
|
+
requestCount: 0,
|
|
352
|
+
sizeBytes: null,
|
|
353
|
+
latencyMs: null,
|
|
354
|
+
error: null,
|
|
355
|
+
prefetched: false,
|
|
356
|
+
events: [],
|
|
357
|
+
};
|
|
358
|
+
next.phase = phase;
|
|
359
|
+
if (opts.incrementRequestCount) {
|
|
360
|
+
next.requestCount += 1;
|
|
361
|
+
}
|
|
362
|
+
if (opts.prefetched !== undefined) {
|
|
363
|
+
next.prefetched = opts.prefetched;
|
|
364
|
+
}
|
|
365
|
+
else if (phase === 'prefetching') {
|
|
366
|
+
next.prefetched = true;
|
|
367
|
+
}
|
|
368
|
+
if (opts.sizeBytes !== undefined) {
|
|
369
|
+
next.sizeBytes = opts.sizeBytes;
|
|
370
|
+
}
|
|
371
|
+
if (opts.latencyMs !== undefined) {
|
|
372
|
+
next.latencyMs = opts.latencyMs;
|
|
373
|
+
}
|
|
374
|
+
next.error = phase === 'error' ? (opts.message ?? next.error) : null;
|
|
375
|
+
next.events.push({
|
|
376
|
+
phase,
|
|
377
|
+
atMs: performance.now(),
|
|
378
|
+
sizeBytes: opts.sizeBytes ?? null,
|
|
379
|
+
message: opts.message ?? null,
|
|
380
|
+
});
|
|
381
|
+
this._segmentStates.set(index, next);
|
|
382
|
+
this.dispatchSegmentStateChange();
|
|
383
|
+
}
|
|
384
|
+
handleWorkerSegmentState(msg) {
|
|
385
|
+
this.noteSegmentState(msg.index, msg.phase, {
|
|
386
|
+
sizeBytes: msg.sizeBytes,
|
|
387
|
+
message: msg.message,
|
|
388
|
+
});
|
|
389
|
+
}
|
|
390
|
+
createPlaybackCapabilities() {
|
|
391
|
+
const capabilities = createBrowserPlaybackCapabilities(this.video);
|
|
392
|
+
if (FORCE_REMUX) {
|
|
393
|
+
return {
|
|
394
|
+
...capabilities,
|
|
395
|
+
canPlayType: () => '',
|
|
396
|
+
};
|
|
397
|
+
}
|
|
398
|
+
return capabilities;
|
|
399
|
+
}
|
|
400
|
+
evaluateInitialPlayback(media) {
|
|
401
|
+
const options = this._blobUrl
|
|
402
|
+
? [
|
|
403
|
+
{ mode: 'direct-bytes', mimeType: this._pendingFileType, url: this._blobUrl },
|
|
404
|
+
{ mode: 'hls' },
|
|
405
|
+
]
|
|
406
|
+
: [{ mode: 'hls' }];
|
|
407
|
+
return evaluatePlaybackOptions({
|
|
408
|
+
options: [...options],
|
|
409
|
+
media,
|
|
410
|
+
capabilities: this.createPlaybackCapabilities(),
|
|
411
|
+
});
|
|
412
|
+
}
|
|
413
|
+
evaluateHlsPlayback(media) {
|
|
414
|
+
return evaluatePlaybackOptions({
|
|
415
|
+
options: [{ mode: 'hls' }],
|
|
416
|
+
media,
|
|
417
|
+
capabilities: this.createPlaybackCapabilities(),
|
|
418
|
+
}).evaluations[0];
|
|
419
|
+
}
|
|
420
|
+
logPlaybackDiagnostics(context, evaluation) {
|
|
421
|
+
for (const entry of evaluation.evaluations) {
|
|
422
|
+
for (const diagnostic of entry.diagnostics) {
|
|
423
|
+
mlog(`${context}: mode=${entry.option.mode} ${diagnostic.code} ${diagnostic.message}`);
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
if (!evaluation.recommended) {
|
|
427
|
+
mlog(`${context}: no-supported-option`);
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
throwPlaybackSelectionError(context, diagnostics) {
|
|
431
|
+
const detail = diagnostics.length > 0
|
|
432
|
+
? diagnostics.map((diagnostic) => diagnostic.message).join(' ')
|
|
433
|
+
: 'No supported playback option.';
|
|
434
|
+
throw new Error(`${context}: ${detail}`);
|
|
435
|
+
}
|
|
436
|
+
failPlaybackSelection(context, diagnostics) {
|
|
437
|
+
const detail = diagnostics.length > 0
|
|
438
|
+
? diagnostics.map((diagnostic) => diagnostic.message).join(' ')
|
|
439
|
+
: 'No supported playback option.';
|
|
440
|
+
this._phase = 'error';
|
|
441
|
+
this.dispatchEvent(new CustomEvent('error', { detail: { message: `${context}: ${detail}` } }));
|
|
442
|
+
}
|
|
443
|
+
makeCodecPathFromSource(media, mode, outputAudio = {
|
|
444
|
+
short: media.sourceAudioCodec,
|
|
445
|
+
full: media.audioCodec,
|
|
446
|
+
}) {
|
|
447
|
+
return {
|
|
448
|
+
mode,
|
|
449
|
+
sourceVideo: {
|
|
450
|
+
short: media.sourceVideoCodec,
|
|
451
|
+
full: media.videoCodec,
|
|
452
|
+
},
|
|
453
|
+
sourceAudio: {
|
|
454
|
+
short: media.sourceAudioCodec,
|
|
455
|
+
full: media.audioCodec,
|
|
456
|
+
},
|
|
457
|
+
outputVideo: {
|
|
458
|
+
short: media.sourceVideoCodec,
|
|
459
|
+
full: media.videoCodec,
|
|
460
|
+
},
|
|
461
|
+
outputAudio,
|
|
462
|
+
};
|
|
236
463
|
}
|
|
237
464
|
startPassthrough(src) {
|
|
238
465
|
this._passthrough = true;
|
|
@@ -251,6 +478,7 @@ export class PlaysVideoEngine extends EventTarget {
|
|
|
251
478
|
durationSec: this._durationSec,
|
|
252
479
|
subtitleTracks: [],
|
|
253
480
|
passthrough: true,
|
|
481
|
+
codecPath: this.codecPath,
|
|
254
482
|
},
|
|
255
483
|
}));
|
|
256
484
|
};
|
|
@@ -265,23 +493,44 @@ export class PlaysVideoEngine extends EventTarget {
|
|
|
265
493
|
const msg = event.data;
|
|
266
494
|
if (msg.type === 'probed') {
|
|
267
495
|
// Worker finished demux — decide passthrough vs pipeline
|
|
268
|
-
const
|
|
496
|
+
const media = {
|
|
497
|
+
sourceVideoCodec: msg.sourceVideoCodec ?? null,
|
|
498
|
+
sourceAudioCodec: msg.sourceAudioCodec ?? null,
|
|
499
|
+
videoCodec: msg.videoCodec ?? null,
|
|
500
|
+
audioCodec: msg.audioCodec ?? null,
|
|
501
|
+
};
|
|
502
|
+
const evaluation = this.evaluateInitialPlayback(media);
|
|
503
|
+
this.logPlaybackDiagnostics('playback selection', evaluation);
|
|
269
504
|
this._subtitleTracks = msg.subtitleTracks ?? [];
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
505
|
+
const blobUrl = this._blobUrl;
|
|
506
|
+
const usePassthrough = evaluation.recommended?.option.mode === 'direct-bytes' && blobUrl !== null;
|
|
507
|
+
this._codecPath = this.makeCodecPathFromSource(media, usePassthrough ? 'passthrough' : 'pipeline');
|
|
508
|
+
if (usePassthrough && blobUrl) {
|
|
509
|
+
mlog(`passthrough: selected direct playback codecs=${msg.videoCodec}/${msg.audioCodec}`);
|
|
510
|
+
this.startPassthrough(blobUrl);
|
|
273
511
|
this.worker.postMessage({ type: 'passthrough-pipeline' });
|
|
512
|
+
if (this._subtitleTracks.length > 0) {
|
|
513
|
+
this.dispatchSubtitleStatus(`Extracting ${this._subtitleTracks.length} subtitle track(s)...`);
|
|
514
|
+
}
|
|
515
|
+
else {
|
|
516
|
+
this.dispatchSubtitleStatus('No embedded subtitles');
|
|
517
|
+
}
|
|
274
518
|
for (const track of this._subtitleTracks) {
|
|
275
519
|
mlog(`requesting subtitle track=${track.index} lang=${track.language} codec=${track.codec}`);
|
|
276
520
|
this.worker.postMessage({ type: 'subtitle', trackIndex: track.index });
|
|
277
521
|
}
|
|
278
522
|
}
|
|
279
523
|
else {
|
|
524
|
+
const hlsEvaluation = evaluation.evaluations.find((entry) => entry.option.mode === 'hls');
|
|
525
|
+
if (evaluation.recommended?.option.mode !== 'hls') {
|
|
526
|
+
this.failPlaybackSelection('Playback selection failed', hlsEvaluation?.diagnostics ?? []);
|
|
527
|
+
return;
|
|
528
|
+
}
|
|
280
529
|
if (this._blobUrl) {
|
|
281
530
|
URL.revokeObjectURL(this._blobUrl);
|
|
282
531
|
this._blobUrl = null;
|
|
283
532
|
}
|
|
284
|
-
mlog(
|
|
533
|
+
mlog('pipeline: selected remux/HLS playback');
|
|
285
534
|
this.ensureTranscodeWorkers();
|
|
286
535
|
const remuxMsg = { type: 'remux-pipeline' };
|
|
287
536
|
if (this._keyframeIndex)
|
|
@@ -296,6 +545,25 @@ export class PlaysVideoEngine extends EventTarget {
|
|
|
296
545
|
this._durationSec = msg.durationSec;
|
|
297
546
|
this._subtitleTracks = msg.subtitleTracks ?? [];
|
|
298
547
|
this._phase = 'ready';
|
|
548
|
+
this._codecPath = {
|
|
549
|
+
mode: 'pipeline',
|
|
550
|
+
sourceVideo: {
|
|
551
|
+
short: msg.sourceVideoCodec ?? this._codecPath.sourceVideo.short,
|
|
552
|
+
full: msg.sourceVideoCodecFull ?? this._codecPath.sourceVideo.full,
|
|
553
|
+
},
|
|
554
|
+
sourceAudio: {
|
|
555
|
+
short: msg.sourceAudioCodec ?? this._codecPath.sourceAudio.short,
|
|
556
|
+
full: msg.sourceAudioCodecFull ?? this._codecPath.sourceAudio.full,
|
|
557
|
+
},
|
|
558
|
+
outputVideo: {
|
|
559
|
+
short: msg.outputVideoCodec ?? this._codecPath.outputVideo.short,
|
|
560
|
+
full: msg.outputVideoCodecFull ?? this._codecPath.outputVideo.full,
|
|
561
|
+
},
|
|
562
|
+
outputAudio: {
|
|
563
|
+
short: msg.outputAudioCodec ?? this._codecPath.outputAudio.short,
|
|
564
|
+
full: msg.outputAudioCodecFull ?? this._codecPath.outputAudio.full,
|
|
565
|
+
},
|
|
566
|
+
};
|
|
299
567
|
mlog(`ready segments=${msg.totalSegments} dur=${msg.durationSec.toFixed(1)}s`);
|
|
300
568
|
// Resolve any pending requests
|
|
301
569
|
if (this.pendingPlaylist) {
|
|
@@ -307,6 +575,12 @@ export class PlaysVideoEngine extends EventTarget {
|
|
|
307
575
|
this.pendingInit = null;
|
|
308
576
|
}
|
|
309
577
|
// Request subtitle extraction for all embedded tracks
|
|
578
|
+
if (this._subtitleTracks.length > 0) {
|
|
579
|
+
this.dispatchSubtitleStatus(`Extracting ${this._subtitleTracks.length} subtitle track(s)...`);
|
|
580
|
+
}
|
|
581
|
+
else {
|
|
582
|
+
this.dispatchSubtitleStatus('No embedded subtitles');
|
|
583
|
+
}
|
|
310
584
|
for (const track of this._subtitleTracks) {
|
|
311
585
|
mlog(`requesting subtitle track=${track.index} lang=${track.language} codec=${track.codec}`);
|
|
312
586
|
this.worker.postMessage({ type: 'subtitle', trackIndex: track.index });
|
|
@@ -316,24 +590,44 @@ export class PlaysVideoEngine extends EventTarget {
|
|
|
316
590
|
totalSegments: this._totalSegments,
|
|
317
591
|
durationSec: this._durationSec,
|
|
318
592
|
subtitleTracks: this._subtitleTracks,
|
|
593
|
+
codecPath: this.codecPath,
|
|
319
594
|
},
|
|
320
595
|
}));
|
|
321
596
|
this.startHls();
|
|
322
597
|
}
|
|
323
598
|
else if (msg.type === 'subtitle') {
|
|
324
599
|
mlog(`subtitle arrived track=${msg.trackIndex} codec=${msg.codec} len=${msg.webvtt?.length}`);
|
|
325
|
-
this.
|
|
600
|
+
const info = this._subtitleTracks.find((t) => t.index === msg.trackIndex);
|
|
601
|
+
const lang = info?.language ?? '?';
|
|
602
|
+
const cueMatch = msg.webvtt?.match(/\d\d:\d\d/g);
|
|
603
|
+
const cueCount = cueMatch ? Math.floor(cueMatch.length / 2) : 0;
|
|
604
|
+
this.dispatchSubtitleStatus(`Subtitle track ${msg.trackIndex}: ${lang} ${msg.codec} ${cueCount} cues, ${msg.webvtt?.length ?? 0} bytes`);
|
|
605
|
+
this.addSubtitleTrack({
|
|
606
|
+
webvtt: msg.webvtt,
|
|
607
|
+
source: 'embedded',
|
|
608
|
+
trackIndex: msg.trackIndex,
|
|
609
|
+
defaultTrack: msg.trackIndex === 0,
|
|
610
|
+
selectTrack: msg.trackIndex === 0,
|
|
611
|
+
});
|
|
612
|
+
}
|
|
613
|
+
else if (msg.type === 'segment-state') {
|
|
614
|
+
this.handleWorkerSegmentState(msg);
|
|
326
615
|
}
|
|
327
616
|
else if (msg.type === 'segment') {
|
|
328
617
|
const pending = this.pendingSegments.get(msg.index);
|
|
329
618
|
const reqTime = this.segmentRequestTimes.get(msg.index);
|
|
330
|
-
const
|
|
619
|
+
const latencyMs = reqTime ? performance.now() - reqTime : null;
|
|
620
|
+
const latency = latencyMs !== null ? latencyMs.toFixed(1) : '?';
|
|
331
621
|
const size = msg.data?.byteLength ?? 0;
|
|
332
622
|
this.segmentRequestTimes.delete(msg.index);
|
|
333
623
|
if (pending) {
|
|
334
624
|
pending.resolve(msg.data);
|
|
335
625
|
this.pendingSegments.delete(msg.index);
|
|
336
626
|
}
|
|
627
|
+
this.noteSegmentState(msg.index, 'delivered', {
|
|
628
|
+
sizeBytes: size,
|
|
629
|
+
latencyMs: latencyMs ?? undefined,
|
|
630
|
+
});
|
|
337
631
|
mlog(`seg ${msg.index} arrived latency=${latency}ms size=${size} pending=${this.pendingSegments.size}`);
|
|
338
632
|
}
|
|
339
633
|
else if (msg.type === 'error') {
|
|
@@ -341,7 +635,8 @@ export class PlaysVideoEngine extends EventTarget {
|
|
|
341
635
|
this._phase = 'error';
|
|
342
636
|
this.dispatchEvent(new CustomEvent('error', { detail: { message: msg.message } }));
|
|
343
637
|
// Reject all pending requests
|
|
344
|
-
for (const [, p] of this.pendingSegments) {
|
|
638
|
+
for (const [index, p] of this.pendingSegments) {
|
|
639
|
+
this.noteSegmentState(index, 'error', { message: msg.message });
|
|
345
640
|
p.reject(new Error(msg.message));
|
|
346
641
|
}
|
|
347
642
|
this.pendingSegments.clear();
|
|
@@ -366,6 +661,7 @@ export class PlaysVideoEngine extends EventTarget {
|
|
|
366
661
|
}
|
|
367
662
|
mlog(`req seg ${index} pending=${pendingCount}`);
|
|
368
663
|
this.segmentRequestTimes.set(index, performance.now());
|
|
664
|
+
this.noteSegmentState(index, 'requested', { incrementRequestCount: true });
|
|
369
665
|
return new Promise((resolve, reject) => {
|
|
370
666
|
this.pendingSegments.set(index, { resolve, reject });
|
|
371
667
|
this.worker.postMessage({ type: 'segment', index });
|
|
@@ -375,6 +671,7 @@ export class PlaysVideoEngine extends EventTarget {
|
|
|
375
671
|
const pending = this.pendingSegments.get(index);
|
|
376
672
|
if (pending) {
|
|
377
673
|
mlog(`cancel seg ${index}`);
|
|
674
|
+
this.noteSegmentState(index, 'canceled');
|
|
378
675
|
pending.reject(new DOMException('Segment aborted', 'AbortError'));
|
|
379
676
|
this.pendingSegments.delete(index);
|
|
380
677
|
this.segmentRequestTimes.delete(index);
|
|
@@ -402,14 +699,36 @@ export class PlaysVideoEngine extends EventTarget {
|
|
|
402
699
|
durationSec: index.duration,
|
|
403
700
|
targetSegmentDurationSec: this._sourceTargetSegDuration,
|
|
404
701
|
});
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
demux.
|
|
409
|
-
|
|
702
|
+
const media = {
|
|
703
|
+
sourceVideoCodec: demux.videoCodec,
|
|
704
|
+
sourceAudioCodec: demux.audioCodec,
|
|
705
|
+
videoCodec: demux.videoDecoderConfig.codec,
|
|
706
|
+
audioCodec: demux.audioDecoderConfig?.codec ?? null,
|
|
707
|
+
};
|
|
708
|
+
const hlsEvaluation = this.evaluateHlsPlayback(media);
|
|
709
|
+
this.logPlaybackDiagnostics('source playback selection', {
|
|
710
|
+
recommended: hlsEvaluation.status === 'supported'
|
|
711
|
+
? {
|
|
712
|
+
option: hlsEvaluation.option,
|
|
713
|
+
reason: hlsEvaluation.diagnostics[hlsEvaluation.diagnostics.length - 1] ?? {
|
|
714
|
+
code: 'selected-hls',
|
|
715
|
+
message: 'Recommended HLS playback.',
|
|
716
|
+
},
|
|
717
|
+
}
|
|
718
|
+
: null,
|
|
719
|
+
evaluations: [hlsEvaluation],
|
|
720
|
+
});
|
|
721
|
+
if (hlsEvaluation.status !== 'supported') {
|
|
722
|
+
this.throwPlaybackSelectionError('Source playback selection failed', hlsEvaluation.diagnostics);
|
|
723
|
+
}
|
|
724
|
+
this._sourceDoTranscode = hlsEvaluation.pipelineAudioRequiresTranscode === true;
|
|
410
725
|
this._sourceAudioDecoderConfig = this._sourceDoTranscode
|
|
411
726
|
? makeAacDecoderConfig(demux.audioDecoderConfig)
|
|
412
727
|
: demux.audioDecoderConfig;
|
|
728
|
+
this._codecPath = this.makeCodecPathFromSource(media, 'pipeline', {
|
|
729
|
+
short: this._sourceDoTranscode ? 'aac' : demux.audioCodec,
|
|
730
|
+
full: this._sourceAudioDecoderConfig?.codec ?? null,
|
|
731
|
+
});
|
|
413
732
|
// Pre-process segment 0
|
|
414
733
|
const seg0Result = await processSegmentWithAbort(this.makeSourceProcessorConfig(), 0);
|
|
415
734
|
if (seg0Result.initSegment) {
|
|
@@ -438,6 +757,7 @@ export class PlaysVideoEngine extends EventTarget {
|
|
|
438
757
|
totalSegments: this._totalSegments,
|
|
439
758
|
durationSec: this._durationSec,
|
|
440
759
|
subtitleTracks: this._subtitleTracks,
|
|
760
|
+
codecPath: this.codecPath,
|
|
441
761
|
},
|
|
442
762
|
}));
|
|
443
763
|
this.startHls();
|
|
@@ -526,8 +846,12 @@ export class PlaysVideoEngine extends EventTarget {
|
|
|
526
846
|
context = null;
|
|
527
847
|
stats = makeStats();
|
|
528
848
|
currentSegmentIndex = null;
|
|
849
|
+
callbacks = null;
|
|
850
|
+
aborted = false;
|
|
529
851
|
load(context, _config, callbacks) {
|
|
530
852
|
this.context = context;
|
|
853
|
+
this.callbacks = callbacks;
|
|
854
|
+
this.aborted = false;
|
|
531
855
|
const url = context.url;
|
|
532
856
|
if (url.includes('init.mp4')) {
|
|
533
857
|
this.loadInit(context, callbacks);
|
|
@@ -571,22 +895,36 @@ export class PlaysVideoEngine extends EventTarget {
|
|
|
571
895
|
: engine.requestSegment(index);
|
|
572
896
|
segmentPromise
|
|
573
897
|
.then((data) => {
|
|
898
|
+
if (this.aborted) {
|
|
899
|
+
return;
|
|
900
|
+
}
|
|
574
901
|
this.currentSegmentIndex = null;
|
|
575
902
|
this.stats.loaded = data.byteLength;
|
|
576
903
|
this.stats.loading.end = performance.now();
|
|
577
904
|
callbacks.onSuccess({ url: context.url, data }, this.stats, context, null);
|
|
578
905
|
})
|
|
579
906
|
.catch((err) => {
|
|
907
|
+
if (this.aborted) {
|
|
908
|
+
return;
|
|
909
|
+
}
|
|
580
910
|
this.currentSegmentIndex = null;
|
|
581
911
|
if (err instanceof DOMException && err.name === 'AbortError') {
|
|
582
912
|
this.stats.aborted = true;
|
|
913
|
+
callbacks.onAbort?.(this.stats, context, null);
|
|
583
914
|
return;
|
|
584
915
|
}
|
|
585
916
|
callbacks.onError({ code: 0, text: err.message }, context, null, this.stats);
|
|
586
917
|
});
|
|
587
918
|
}
|
|
588
919
|
abort() {
|
|
920
|
+
if (this.aborted) {
|
|
921
|
+
return;
|
|
922
|
+
}
|
|
923
|
+
this.aborted = true;
|
|
924
|
+
this.stats.aborted = true;
|
|
925
|
+
let abortedActiveSegment = false;
|
|
589
926
|
if (this.currentSegmentIndex !== null) {
|
|
927
|
+
abortedActiveSegment = true;
|
|
590
928
|
if (engine._source) {
|
|
591
929
|
// Source mode: abort the in-flight main-thread processing
|
|
592
930
|
engine._sourceSegmentAbort?.abort();
|
|
@@ -597,9 +935,14 @@ export class PlaysVideoEngine extends EventTarget {
|
|
|
597
935
|
}
|
|
598
936
|
this.currentSegmentIndex = null;
|
|
599
937
|
}
|
|
938
|
+
if (abortedActiveSegment && this.callbacks && this.context) {
|
|
939
|
+
this.callbacks.onAbort?.(this.stats, this.context, null);
|
|
940
|
+
}
|
|
600
941
|
}
|
|
601
942
|
destroy() {
|
|
602
943
|
this.abort();
|
|
944
|
+
this.callbacks = null;
|
|
945
|
+
this.context = null;
|
|
603
946
|
}
|
|
604
947
|
}
|
|
605
948
|
this.hls = new Hls({
|
|
@@ -626,36 +969,71 @@ export class PlaysVideoEngine extends EventTarget {
|
|
|
626
969
|
mlog(`hls BUFFER_APPENDING type=${data.type}`);
|
|
627
970
|
});
|
|
628
971
|
this.hls.on(Hls.Events.ERROR, (_evt, data) => {
|
|
629
|
-
|
|
972
|
+
const underlyingMessage = data.error?.message ?? data.reason ?? data.response?.text ?? data.err?.message ?? null;
|
|
973
|
+
mlog(`hls ERROR fatal=${data.fatal} type=${data.type} details=${data.details}${underlyingMessage ? ` message=${underlyingMessage}` : ''}`);
|
|
630
974
|
if (data.fatal) {
|
|
631
975
|
console.error('hls.js fatal error:', data);
|
|
632
976
|
this._phase = 'error';
|
|
633
|
-
|
|
977
|
+
const message = underlyingMessage
|
|
978
|
+
? `Playback error: ${data.details} (${underlyingMessage})`
|
|
979
|
+
: `Playback error: ${data.details}`;
|
|
980
|
+
this.dispatchEvent(new CustomEvent('error', { detail: { message } }));
|
|
634
981
|
}
|
|
635
982
|
});
|
|
636
983
|
this.hls.loadSource('/virtual/playlist.m3u8');
|
|
637
984
|
this.hls.attachMedia(this.video);
|
|
638
985
|
}
|
|
639
|
-
addSubtitleTrack(webvtt, trackIndex) {
|
|
986
|
+
addSubtitleTrack({ webvtt, source, trackIndex, label, language, kind, defaultTrack = false, selectTrack = false, }) {
|
|
640
987
|
const blob = new Blob([webvtt], { type: 'text/vtt' });
|
|
641
988
|
const url = URL.createObjectURL(blob);
|
|
642
|
-
|
|
643
|
-
|
|
989
|
+
const info = trackIndex === undefined
|
|
990
|
+
? undefined
|
|
991
|
+
: this._subtitleTracks.find((t) => t.index === trackIndex);
|
|
644
992
|
const track = document.createElement('track');
|
|
645
|
-
track.kind = info?.disposition.hearingImpaired ? 'captions' : 'subtitles';
|
|
993
|
+
track.kind = kind ?? (info?.disposition.hearingImpaired ? 'captions' : 'subtitles');
|
|
646
994
|
track.src = url;
|
|
647
|
-
track.srclang =
|
|
648
|
-
track.label =
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
995
|
+
track.srclang = normalizeSubtitleLanguageCode(language ?? info?.language ?? 'und');
|
|
996
|
+
track.label =
|
|
997
|
+
label ??
|
|
998
|
+
info?.name ??
|
|
999
|
+
languageLabel(info?.language ?? 'und', trackIndex ?? this.video.querySelectorAll('track').length);
|
|
1000
|
+
track.default = defaultTrack;
|
|
652
1001
|
this.video.appendChild(track);
|
|
653
|
-
|
|
1002
|
+
this.attachedSubtitleTracks.push({ element: track, url, source });
|
|
1003
|
+
if (selectTrack) {
|
|
1004
|
+
track.addEventListener('load', () => this.showTextTrack(track), { once: true });
|
|
1005
|
+
queueMicrotask(() => this.showTextTrack(track));
|
|
1006
|
+
}
|
|
1007
|
+
mlog(`subtitle track ${trackIndex ?? 'external'} attached as <track kind=${track.kind} lang=${track.srclang}>`);
|
|
1008
|
+
}
|
|
1009
|
+
removeSubtitleTracks(source) {
|
|
1010
|
+
const keep = [];
|
|
1011
|
+
for (const attached of this.attachedSubtitleTracks) {
|
|
1012
|
+
if (source && attached.source !== source) {
|
|
1013
|
+
keep.push(attached);
|
|
1014
|
+
continue;
|
|
1015
|
+
}
|
|
1016
|
+
attached.element.remove();
|
|
1017
|
+
URL.revokeObjectURL(attached.url);
|
|
1018
|
+
}
|
|
1019
|
+
this.attachedSubtitleTracks = keep;
|
|
654
1020
|
}
|
|
655
|
-
|
|
656
|
-
for (
|
|
657
|
-
|
|
1021
|
+
showTextTrack(track) {
|
|
1022
|
+
for (let i = 0; i < this.video.textTracks.length; i++) {
|
|
1023
|
+
this.video.textTracks[i].mode = 'disabled';
|
|
658
1024
|
}
|
|
1025
|
+
track.track.mode = 'showing';
|
|
1026
|
+
}
|
|
1027
|
+
dispatchSubtitleStatus(message) {
|
|
1028
|
+
mlog(`subtitle-status: ${message}`);
|
|
1029
|
+
this.dispatchEvent(new CustomEvent('subtitle-status', { detail: { message } }));
|
|
1030
|
+
}
|
|
1031
|
+
restoreDefaultTextTrack() {
|
|
1032
|
+
const preferred = this.attachedSubtitleTracks.find((attached) => attached.element.default) ??
|
|
1033
|
+
this.attachedSubtitleTracks[0];
|
|
1034
|
+
if (!preferred)
|
|
1035
|
+
return;
|
|
1036
|
+
queueMicrotask(() => this.showTextTrack(preferred.element));
|
|
659
1037
|
}
|
|
660
1038
|
}
|
|
661
1039
|
/** Set to true to bypass native playback and force the remux pipeline (for testing). */
|
|
@@ -701,6 +1079,11 @@ function iso639_2to1(code) {
|
|
|
701
1079
|
};
|
|
702
1080
|
return map[code] ?? code;
|
|
703
1081
|
}
|
|
1082
|
+
function normalizeSubtitleLanguageCode(code) {
|
|
1083
|
+
if (code.length === 2)
|
|
1084
|
+
return code;
|
|
1085
|
+
return iso639_2to1(code);
|
|
1086
|
+
}
|
|
704
1087
|
function languageLabel(langCode, trackIndex) {
|
|
705
1088
|
const names = {
|
|
706
1089
|
eng: 'English',
|