playsvideo 0.3.0 → 0.4.1
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 +5 -3
- package/dist/engine.d.ts +25 -1
- package/dist/engine.d.ts.map +1 -1
- package/dist/engine.js +221 -24
- package/dist/engine.js.map +1 -1
- package/dist/index.d.ts +2 -0
- 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/mkv-keyframe-index.d.ts +15 -0
- package/dist/pipeline/mkv-keyframe-index.d.ts.map +1 -0
- package/dist/pipeline/mkv-keyframe-index.js +324 -0
- package/dist/pipeline/mkv-keyframe-index.js.map +1 -0
- 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/worker.js +30 -2
- package/dist/worker.js.map +1 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -18,7 +18,7 @@ Many video files won't play in a browser — not because the browser can't *deco
|
|
|
18
18
|
|---|---|---|
|
|
19
19
|
| **Containers** | MKV, MP4, AVI, TS, WebM | Demuxed and remuxed to fMP4 |
|
|
20
20
|
| **Video** | H.264, H.265 (HEVC), VP9, AV1 | Passthrough — plays ~99% of files (~90% on Firefox; HEVC transcode planned) |
|
|
21
|
-
| **Audio** | AAC, MP3, AC-3, E-AC-3, DTS, FLAC, Opus |
|
|
21
|
+
| **Audio** | AAC, MP3, AC-3, E-AC-3, DTS, FLAC, Opus | Transcoded to AAC when the active playback path cannot use them safely |
|
|
22
22
|
| **Subtitles** | SRT, ASS/SSA | Extracted and displayed as WebVTT |
|
|
23
23
|
|
|
24
24
|
See [supported media](docs/supported-media.md) for the full codec matrix, browser compatibility, and transcode details.
|
|
@@ -30,13 +30,15 @@ Video file (MKV, MP4, AVI, …)
|
|
|
30
30
|
→ mediabunny demux (streaming, any file size)
|
|
31
31
|
→ keyframe-aligned segment plan
|
|
32
32
|
→ per segment:
|
|
33
|
-
video passed through
|
|
34
|
-
audio transcoded only if needed (AC-3/DTS/MP3/FLAC/Opus → AAC)
|
|
33
|
+
video remuxed / passed through
|
|
34
|
+
audio transcoded only if needed (AC-3/E-AC-3/DTS/MP3/FLAC/Opus → AAC)
|
|
35
35
|
muxed to fMP4
|
|
36
36
|
→ hls.js plays segments on demand
|
|
37
37
|
→ subtitles extracted to WebVTT
|
|
38
38
|
```
|
|
39
39
|
|
|
40
|
+
Note: passthrough/native playback and remuxed HLS/MSE playback are evaluated separately. A source file can play fine via direct passthrough in both Chrome and Safari on macOS, yet still be unsafe once routed through the remuxed HLS/fMP4 pipeline. That is what we observed with AC-3: the original file played directly, but Safari produced audible stalls after remuxing to HLS/fMP4, while Chrome continued to play correctly. The remux pipeline therefore treats AC-3/E-AC-3 as unsafe there and transcodes them to AAC, without affecting native passthrough decisions.
|
|
41
|
+
|
|
40
42
|
Video transcode is almost never needed — browsers natively decode the vast majority of video codecs. When audio transcode is needed, a lightweight 1.8 MB ffmpeg.wasm build is lazy-loaded entirely in-browser. No SharedArrayBuffer required — works on any host without special CORS headers.
|
|
41
43
|
|
|
42
44
|
### Under the hood
|
package/dist/engine.d.ts
CHANGED
|
@@ -7,6 +7,7 @@ export interface ReadyDetail {
|
|
|
7
7
|
durationSec: number;
|
|
8
8
|
subtitleTracks: SubtitleTrackInfo[];
|
|
9
9
|
passthrough?: boolean;
|
|
10
|
+
codecPath: CodecPath;
|
|
10
11
|
}
|
|
11
12
|
export interface ErrorDetail {
|
|
12
13
|
message: string;
|
|
@@ -15,6 +16,9 @@ export interface LoadingDetail {
|
|
|
15
16
|
file?: File;
|
|
16
17
|
url?: string;
|
|
17
18
|
}
|
|
19
|
+
export interface SubtitleStatusDetail {
|
|
20
|
+
message: string;
|
|
21
|
+
}
|
|
18
22
|
export interface WasmWorkerState extends TranscodeWorkerSnapshot {
|
|
19
23
|
id: number;
|
|
20
24
|
}
|
|
@@ -53,6 +57,17 @@ export interface ExternalSubtitleOptions {
|
|
|
53
57
|
language?: string;
|
|
54
58
|
kind?: 'subtitles' | 'captions';
|
|
55
59
|
}
|
|
60
|
+
export interface CodecDescriptor {
|
|
61
|
+
short: string | null;
|
|
62
|
+
full: string | null;
|
|
63
|
+
}
|
|
64
|
+
export interface CodecPath {
|
|
65
|
+
mode: 'passthrough' | 'pipeline';
|
|
66
|
+
sourceVideo: CodecDescriptor;
|
|
67
|
+
sourceAudio: CodecDescriptor;
|
|
68
|
+
outputVideo: CodecDescriptor;
|
|
69
|
+
outputAudio: CodecDescriptor;
|
|
70
|
+
}
|
|
56
71
|
interface EngineEventMap {
|
|
57
72
|
ready: CustomEvent<ReadyDetail>;
|
|
58
73
|
error: CustomEvent<ErrorDetail>;
|
|
@@ -82,6 +97,7 @@ export declare class PlaysVideoEngine extends EventTarget {
|
|
|
82
97
|
private _passthrough;
|
|
83
98
|
private _blobUrl;
|
|
84
99
|
private _pendingFileType;
|
|
100
|
+
private _codecPath;
|
|
85
101
|
private _keyframeIndex;
|
|
86
102
|
private _source;
|
|
87
103
|
private _sourceDemux;
|
|
@@ -98,6 +114,7 @@ export declare class PlaysVideoEngine extends EventTarget {
|
|
|
98
114
|
get durationSec(): number;
|
|
99
115
|
get subtitleTracks(): SubtitleTrackInfo[];
|
|
100
116
|
get passthrough(): boolean;
|
|
117
|
+
get codecPath(): CodecPath;
|
|
101
118
|
get transcodeWorkerStates(): WasmWorkerState[];
|
|
102
119
|
get segmentStates(): SegmentState[];
|
|
103
120
|
constructor(video: HTMLVideoElement, options?: EngineOptions);
|
|
@@ -137,7 +154,13 @@ export declare class PlaysVideoEngine extends EventTarget {
|
|
|
137
154
|
private updateTranscodeWorkerState;
|
|
138
155
|
private noteSegmentState;
|
|
139
156
|
private handleWorkerSegmentState;
|
|
140
|
-
private
|
|
157
|
+
private createPlaybackCapabilities;
|
|
158
|
+
private evaluateInitialPlayback;
|
|
159
|
+
private evaluateHlsPlayback;
|
|
160
|
+
private logPlaybackDiagnostics;
|
|
161
|
+
private throwPlaybackSelectionError;
|
|
162
|
+
private failPlaybackSelection;
|
|
163
|
+
private makeCodecPathFromSource;
|
|
141
164
|
private startPassthrough;
|
|
142
165
|
private handleWorkerMessage;
|
|
143
166
|
private requestSegment;
|
|
@@ -149,6 +172,7 @@ export declare class PlaysVideoEngine extends EventTarget {
|
|
|
149
172
|
private addSubtitleTrack;
|
|
150
173
|
private removeSubtitleTracks;
|
|
151
174
|
private showTextTrack;
|
|
175
|
+
private dispatchSubtitleStatus;
|
|
152
176
|
private restoreDefaultTextTrack;
|
|
153
177
|
}
|
|
154
178
|
export {};
|
package/dist/engine.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"engine.d.ts","sourceRoot":"","sources":["../src/engine.ts"],"names":[],"mappings":"AASA,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,YAAY,CAAC;
|
|
1
|
+
{"version":3,"file":"engine.d.ts","sourceRoot":"","sources":["../src/engine.ts"],"names":[],"mappings":"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"}
|
package/dist/engine.js
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
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
|
+
import { buildMkvKeyframeIndexFromSource } from './pipeline/mkv-keyframe-index.js';
|
|
6
7
|
import { generateVodPlaylist } from './pipeline/playlist.js';
|
|
7
8
|
import { buildSegmentPlan } from './pipeline/segment-plan.js';
|
|
8
9
|
import { parseSubtitleFile, subtitleDataToWebVTT } from './pipeline/subtitle.js';
|
|
@@ -41,6 +42,13 @@ export class PlaysVideoEngine extends EventTarget {
|
|
|
41
42
|
_passthrough = false;
|
|
42
43
|
_blobUrl = null;
|
|
43
44
|
_pendingFileType = null;
|
|
45
|
+
_codecPath = {
|
|
46
|
+
mode: 'pipeline',
|
|
47
|
+
sourceVideo: { short: null, full: null },
|
|
48
|
+
sourceAudio: { short: null, full: null },
|
|
49
|
+
outputVideo: { short: null, full: null },
|
|
50
|
+
outputAudio: { short: null, full: null },
|
|
51
|
+
};
|
|
44
52
|
// Pre-built keyframe index (e.g. from MKV cues) to skip mediabunny scan
|
|
45
53
|
_keyframeIndex = null;
|
|
46
54
|
// Main-thread pipeline state (used by loadSource)
|
|
@@ -71,6 +79,15 @@ export class PlaysVideoEngine extends EventTarget {
|
|
|
71
79
|
get passthrough() {
|
|
72
80
|
return this._passthrough;
|
|
73
81
|
}
|
|
82
|
+
get codecPath() {
|
|
83
|
+
return {
|
|
84
|
+
mode: this._codecPath.mode,
|
|
85
|
+
sourceVideo: { ...this._codecPath.sourceVideo },
|
|
86
|
+
sourceAudio: { ...this._codecPath.sourceAudio },
|
|
87
|
+
outputVideo: { ...this._codecPath.outputVideo },
|
|
88
|
+
outputAudio: { ...this._codecPath.outputAudio },
|
|
89
|
+
};
|
|
90
|
+
}
|
|
74
91
|
get transcodeWorkerStates() {
|
|
75
92
|
return this._transcodeWorkerStates.map((worker) => ({ ...worker }));
|
|
76
93
|
}
|
|
@@ -182,6 +199,13 @@ export class PlaysVideoEngine extends EventTarget {
|
|
|
182
199
|
this._passthrough = false;
|
|
183
200
|
this._pendingFileType = null;
|
|
184
201
|
this._keyframeIndex = null;
|
|
202
|
+
this._codecPath = {
|
|
203
|
+
mode: 'pipeline',
|
|
204
|
+
sourceVideo: { short: null, full: null },
|
|
205
|
+
sourceAudio: { short: null, full: null },
|
|
206
|
+
outputVideo: { short: null, full: null },
|
|
207
|
+
outputAudio: { short: null, full: null },
|
|
208
|
+
};
|
|
185
209
|
// Source pipeline cleanup
|
|
186
210
|
if (this._sourceSegmentAbort) {
|
|
187
211
|
this._sourceSegmentAbort.abort();
|
|
@@ -364,17 +388,79 @@ export class PlaysVideoEngine extends EventTarget {
|
|
|
364
388
|
message: msg.message,
|
|
365
389
|
});
|
|
366
390
|
}
|
|
367
|
-
|
|
368
|
-
const
|
|
369
|
-
if (
|
|
370
|
-
return
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
391
|
+
createPlaybackCapabilities() {
|
|
392
|
+
const capabilities = createBrowserPlaybackCapabilities(this.video);
|
|
393
|
+
if (FORCE_REMUX) {
|
|
394
|
+
return {
|
|
395
|
+
...capabilities,
|
|
396
|
+
canPlayType: () => '',
|
|
397
|
+
};
|
|
398
|
+
}
|
|
399
|
+
return capabilities;
|
|
400
|
+
}
|
|
401
|
+
evaluateInitialPlayback(media) {
|
|
402
|
+
const options = this._blobUrl
|
|
403
|
+
? [
|
|
404
|
+
{ mode: 'direct-bytes', mimeType: this._pendingFileType, url: this._blobUrl },
|
|
405
|
+
{ mode: 'hls' },
|
|
406
|
+
]
|
|
407
|
+
: [{ mode: 'hls' }];
|
|
408
|
+
return evaluatePlaybackOptions({
|
|
409
|
+
options: [...options],
|
|
410
|
+
media,
|
|
411
|
+
capabilities: this.createPlaybackCapabilities(),
|
|
412
|
+
});
|
|
413
|
+
}
|
|
414
|
+
evaluateHlsPlayback(media) {
|
|
415
|
+
return evaluatePlaybackOptions({
|
|
416
|
+
options: [{ mode: 'hls' }],
|
|
417
|
+
media,
|
|
418
|
+
capabilities: this.createPlaybackCapabilities(),
|
|
419
|
+
}).evaluations[0];
|
|
420
|
+
}
|
|
421
|
+
logPlaybackDiagnostics(context, evaluation) {
|
|
422
|
+
for (const entry of evaluation.evaluations) {
|
|
423
|
+
for (const diagnostic of entry.diagnostics) {
|
|
424
|
+
mlog(`${context}: mode=${entry.option.mode} ${diagnostic.code} ${diagnostic.message}`);
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
if (!evaluation.recommended) {
|
|
428
|
+
mlog(`${context}: no-supported-option`);
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
throwPlaybackSelectionError(context, diagnostics) {
|
|
432
|
+
const detail = diagnostics.length > 0
|
|
433
|
+
? diagnostics.map((diagnostic) => diagnostic.message).join(' ')
|
|
434
|
+
: 'No supported playback option.';
|
|
435
|
+
throw new Error(`${context}: ${detail}`);
|
|
436
|
+
}
|
|
437
|
+
failPlaybackSelection(context, diagnostics) {
|
|
438
|
+
const detail = diagnostics.length > 0
|
|
439
|
+
? diagnostics.map((diagnostic) => diagnostic.message).join(' ')
|
|
440
|
+
: 'No supported playback option.';
|
|
441
|
+
this._phase = 'error';
|
|
442
|
+
this.dispatchEvent(new CustomEvent('error', { detail: { message: `${context}: ${detail}` } }));
|
|
443
|
+
}
|
|
444
|
+
makeCodecPathFromSource(media, mode, outputAudio = {
|
|
445
|
+
short: media.sourceAudioCodec,
|
|
446
|
+
full: media.audioCodec,
|
|
447
|
+
}) {
|
|
448
|
+
return {
|
|
449
|
+
mode,
|
|
450
|
+
sourceVideo: {
|
|
451
|
+
short: media.sourceVideoCodec,
|
|
452
|
+
full: media.videoCodec,
|
|
453
|
+
},
|
|
454
|
+
sourceAudio: {
|
|
455
|
+
short: media.sourceAudioCodec,
|
|
456
|
+
full: media.audioCodec,
|
|
457
|
+
},
|
|
458
|
+
outputVideo: {
|
|
459
|
+
short: media.sourceVideoCodec,
|
|
460
|
+
full: media.videoCodec,
|
|
461
|
+
},
|
|
462
|
+
outputAudio,
|
|
463
|
+
};
|
|
378
464
|
}
|
|
379
465
|
startPassthrough(src) {
|
|
380
466
|
this._passthrough = true;
|
|
@@ -393,6 +479,7 @@ export class PlaysVideoEngine extends EventTarget {
|
|
|
393
479
|
durationSec: this._durationSec,
|
|
394
480
|
subtitleTracks: [],
|
|
395
481
|
passthrough: true,
|
|
482
|
+
codecPath: this.codecPath,
|
|
396
483
|
},
|
|
397
484
|
}));
|
|
398
485
|
};
|
|
@@ -407,23 +494,44 @@ export class PlaysVideoEngine extends EventTarget {
|
|
|
407
494
|
const msg = event.data;
|
|
408
495
|
if (msg.type === 'probed') {
|
|
409
496
|
// Worker finished demux — decide passthrough vs pipeline
|
|
410
|
-
const
|
|
497
|
+
const media = {
|
|
498
|
+
sourceVideoCodec: msg.sourceVideoCodec ?? null,
|
|
499
|
+
sourceAudioCodec: msg.sourceAudioCodec ?? null,
|
|
500
|
+
videoCodec: msg.videoCodec ?? null,
|
|
501
|
+
audioCodec: msg.audioCodec ?? null,
|
|
502
|
+
};
|
|
503
|
+
const evaluation = this.evaluateInitialPlayback(media);
|
|
504
|
+
this.logPlaybackDiagnostics('playback selection', evaluation);
|
|
411
505
|
this._subtitleTracks = msg.subtitleTracks ?? [];
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
506
|
+
const blobUrl = this._blobUrl;
|
|
507
|
+
const usePassthrough = evaluation.recommended?.option.mode === 'direct-bytes' && blobUrl !== null;
|
|
508
|
+
this._codecPath = this.makeCodecPathFromSource(media, usePassthrough ? 'passthrough' : 'pipeline');
|
|
509
|
+
if (usePassthrough && blobUrl) {
|
|
510
|
+
mlog(`passthrough: selected direct playback codecs=${msg.videoCodec}/${msg.audioCodec}`);
|
|
511
|
+
this.startPassthrough(blobUrl);
|
|
415
512
|
this.worker.postMessage({ type: 'passthrough-pipeline' });
|
|
513
|
+
if (this._subtitleTracks.length > 0) {
|
|
514
|
+
this.dispatchSubtitleStatus(`Extracting ${this._subtitleTracks.length} subtitle track(s)...`);
|
|
515
|
+
}
|
|
516
|
+
else {
|
|
517
|
+
this.dispatchSubtitleStatus('No embedded subtitles');
|
|
518
|
+
}
|
|
416
519
|
for (const track of this._subtitleTracks) {
|
|
417
520
|
mlog(`requesting subtitle track=${track.index} lang=${track.language} codec=${track.codec}`);
|
|
418
521
|
this.worker.postMessage({ type: 'subtitle', trackIndex: track.index });
|
|
419
522
|
}
|
|
420
523
|
}
|
|
421
524
|
else {
|
|
525
|
+
const hlsEvaluation = evaluation.evaluations.find((entry) => entry.option.mode === 'hls');
|
|
526
|
+
if (evaluation.recommended?.option.mode !== 'hls') {
|
|
527
|
+
this.failPlaybackSelection('Playback selection failed', hlsEvaluation?.diagnostics ?? []);
|
|
528
|
+
return;
|
|
529
|
+
}
|
|
422
530
|
if (this._blobUrl) {
|
|
423
531
|
URL.revokeObjectURL(this._blobUrl);
|
|
424
532
|
this._blobUrl = null;
|
|
425
533
|
}
|
|
426
|
-
mlog(
|
|
534
|
+
mlog('pipeline: selected remux/HLS playback');
|
|
427
535
|
this.ensureTranscodeWorkers();
|
|
428
536
|
const remuxMsg = { type: 'remux-pipeline' };
|
|
429
537
|
if (this._keyframeIndex)
|
|
@@ -438,6 +546,25 @@ export class PlaysVideoEngine extends EventTarget {
|
|
|
438
546
|
this._durationSec = msg.durationSec;
|
|
439
547
|
this._subtitleTracks = msg.subtitleTracks ?? [];
|
|
440
548
|
this._phase = 'ready';
|
|
549
|
+
this._codecPath = {
|
|
550
|
+
mode: 'pipeline',
|
|
551
|
+
sourceVideo: {
|
|
552
|
+
short: msg.sourceVideoCodec ?? this._codecPath.sourceVideo.short,
|
|
553
|
+
full: msg.sourceVideoCodecFull ?? this._codecPath.sourceVideo.full,
|
|
554
|
+
},
|
|
555
|
+
sourceAudio: {
|
|
556
|
+
short: msg.sourceAudioCodec ?? this._codecPath.sourceAudio.short,
|
|
557
|
+
full: msg.sourceAudioCodecFull ?? this._codecPath.sourceAudio.full,
|
|
558
|
+
},
|
|
559
|
+
outputVideo: {
|
|
560
|
+
short: msg.outputVideoCodec ?? this._codecPath.outputVideo.short,
|
|
561
|
+
full: msg.outputVideoCodecFull ?? this._codecPath.outputVideo.full,
|
|
562
|
+
},
|
|
563
|
+
outputAudio: {
|
|
564
|
+
short: msg.outputAudioCodec ?? this._codecPath.outputAudio.short,
|
|
565
|
+
full: msg.outputAudioCodecFull ?? this._codecPath.outputAudio.full,
|
|
566
|
+
},
|
|
567
|
+
};
|
|
441
568
|
mlog(`ready segments=${msg.totalSegments} dur=${msg.durationSec.toFixed(1)}s`);
|
|
442
569
|
// Resolve any pending requests
|
|
443
570
|
if (this.pendingPlaylist) {
|
|
@@ -449,6 +576,12 @@ export class PlaysVideoEngine extends EventTarget {
|
|
|
449
576
|
this.pendingInit = null;
|
|
450
577
|
}
|
|
451
578
|
// Request subtitle extraction for all embedded tracks
|
|
579
|
+
if (this._subtitleTracks.length > 0) {
|
|
580
|
+
this.dispatchSubtitleStatus(`Extracting ${this._subtitleTracks.length} subtitle track(s)...`);
|
|
581
|
+
}
|
|
582
|
+
else {
|
|
583
|
+
this.dispatchSubtitleStatus('No embedded subtitles');
|
|
584
|
+
}
|
|
452
585
|
for (const track of this._subtitleTracks) {
|
|
453
586
|
mlog(`requesting subtitle track=${track.index} lang=${track.language} codec=${track.codec}`);
|
|
454
587
|
this.worker.postMessage({ type: 'subtitle', trackIndex: track.index });
|
|
@@ -458,17 +591,24 @@ export class PlaysVideoEngine extends EventTarget {
|
|
|
458
591
|
totalSegments: this._totalSegments,
|
|
459
592
|
durationSec: this._durationSec,
|
|
460
593
|
subtitleTracks: this._subtitleTracks,
|
|
594
|
+
codecPath: this.codecPath,
|
|
461
595
|
},
|
|
462
596
|
}));
|
|
463
597
|
this.startHls();
|
|
464
598
|
}
|
|
465
599
|
else if (msg.type === 'subtitle') {
|
|
466
600
|
mlog(`subtitle arrived track=${msg.trackIndex} codec=${msg.codec} len=${msg.webvtt?.length}`);
|
|
601
|
+
const info = this._subtitleTracks.find((t) => t.index === msg.trackIndex);
|
|
602
|
+
const lang = info?.language ?? '?';
|
|
603
|
+
const cueMatch = msg.webvtt?.match(/\d\d:\d\d/g);
|
|
604
|
+
const cueCount = cueMatch ? Math.floor(cueMatch.length / 2) : 0;
|
|
605
|
+
this.dispatchSubtitleStatus(`Subtitle track ${msg.trackIndex}: ${lang} ${msg.codec} ${cueCount} cues, ${msg.webvtt?.length ?? 0} bytes`);
|
|
467
606
|
this.addSubtitleTrack({
|
|
468
607
|
webvtt: msg.webvtt,
|
|
469
608
|
source: 'embedded',
|
|
470
609
|
trackIndex: msg.trackIndex,
|
|
471
610
|
defaultTrack: msg.trackIndex === 0,
|
|
611
|
+
selectTrack: msg.trackIndex === 0,
|
|
472
612
|
});
|
|
473
613
|
}
|
|
474
614
|
else if (msg.type === 'segment-state') {
|
|
@@ -551,8 +691,15 @@ export class PlaysVideoEngine extends EventTarget {
|
|
|
551
691
|
mlog(`source pipeline: pre-built keyframes=${index.keyframes.length}`);
|
|
552
692
|
}
|
|
553
693
|
else {
|
|
554
|
-
|
|
555
|
-
|
|
694
|
+
const mkvIndex = await buildMkvKeyframeIndexFromSource(source);
|
|
695
|
+
if (mkvIndex) {
|
|
696
|
+
index = mkvIndex;
|
|
697
|
+
mlog(`source pipeline: mkv-cues keyframes=${index.keyframes.length}`);
|
|
698
|
+
}
|
|
699
|
+
else {
|
|
700
|
+
index = await getKeyframeIndex(demux.videoSink, demux.duration);
|
|
701
|
+
mlog(`source pipeline: keyframe-index keyframes=${index.keyframes.length}`);
|
|
702
|
+
}
|
|
556
703
|
}
|
|
557
704
|
// Build segment plan
|
|
558
705
|
this._sourcePlan = buildSegmentPlan({
|
|
@@ -560,14 +707,36 @@ export class PlaysVideoEngine extends EventTarget {
|
|
|
560
707
|
durationSec: index.duration,
|
|
561
708
|
targetSegmentDurationSec: this._sourceTargetSegDuration,
|
|
562
709
|
});
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
demux.
|
|
567
|
-
|
|
710
|
+
const media = {
|
|
711
|
+
sourceVideoCodec: demux.videoCodec,
|
|
712
|
+
sourceAudioCodec: demux.audioCodec,
|
|
713
|
+
videoCodec: demux.videoDecoderConfig.codec,
|
|
714
|
+
audioCodec: demux.audioDecoderConfig?.codec ?? null,
|
|
715
|
+
};
|
|
716
|
+
const hlsEvaluation = this.evaluateHlsPlayback(media);
|
|
717
|
+
this.logPlaybackDiagnostics('source playback selection', {
|
|
718
|
+
recommended: hlsEvaluation.status === 'supported'
|
|
719
|
+
? {
|
|
720
|
+
option: hlsEvaluation.option,
|
|
721
|
+
reason: hlsEvaluation.diagnostics[hlsEvaluation.diagnostics.length - 1] ?? {
|
|
722
|
+
code: 'selected-hls',
|
|
723
|
+
message: 'Recommended HLS playback.',
|
|
724
|
+
},
|
|
725
|
+
}
|
|
726
|
+
: null,
|
|
727
|
+
evaluations: [hlsEvaluation],
|
|
728
|
+
});
|
|
729
|
+
if (hlsEvaluation.status !== 'supported') {
|
|
730
|
+
this.throwPlaybackSelectionError('Source playback selection failed', hlsEvaluation.diagnostics);
|
|
731
|
+
}
|
|
732
|
+
this._sourceDoTranscode = hlsEvaluation.pipelineAudioRequiresTranscode === true;
|
|
568
733
|
this._sourceAudioDecoderConfig = this._sourceDoTranscode
|
|
569
734
|
? makeAacDecoderConfig(demux.audioDecoderConfig)
|
|
570
735
|
: demux.audioDecoderConfig;
|
|
736
|
+
this._codecPath = this.makeCodecPathFromSource(media, 'pipeline', {
|
|
737
|
+
short: this._sourceDoTranscode ? 'aac' : demux.audioCodec,
|
|
738
|
+
full: this._sourceAudioDecoderConfig?.codec ?? null,
|
|
739
|
+
});
|
|
571
740
|
// Pre-process segment 0
|
|
572
741
|
const seg0Result = await processSegmentWithAbort(this.makeSourceProcessorConfig(), 0);
|
|
573
742
|
if (seg0Result.initSegment) {
|
|
@@ -596,6 +765,7 @@ export class PlaysVideoEngine extends EventTarget {
|
|
|
596
765
|
totalSegments: this._totalSegments,
|
|
597
766
|
durationSec: this._durationSec,
|
|
598
767
|
subtitleTracks: this._subtitleTracks,
|
|
768
|
+
codecPath: this.codecPath,
|
|
599
769
|
},
|
|
600
770
|
}));
|
|
601
771
|
this.startHls();
|
|
@@ -684,8 +854,12 @@ export class PlaysVideoEngine extends EventTarget {
|
|
|
684
854
|
context = null;
|
|
685
855
|
stats = makeStats();
|
|
686
856
|
currentSegmentIndex = null;
|
|
857
|
+
callbacks = null;
|
|
858
|
+
aborted = false;
|
|
687
859
|
load(context, _config, callbacks) {
|
|
688
860
|
this.context = context;
|
|
861
|
+
this.callbacks = callbacks;
|
|
862
|
+
this.aborted = false;
|
|
689
863
|
const url = context.url;
|
|
690
864
|
if (url.includes('init.mp4')) {
|
|
691
865
|
this.loadInit(context, callbacks);
|
|
@@ -729,22 +903,36 @@ export class PlaysVideoEngine extends EventTarget {
|
|
|
729
903
|
: engine.requestSegment(index);
|
|
730
904
|
segmentPromise
|
|
731
905
|
.then((data) => {
|
|
906
|
+
if (this.aborted) {
|
|
907
|
+
return;
|
|
908
|
+
}
|
|
732
909
|
this.currentSegmentIndex = null;
|
|
733
910
|
this.stats.loaded = data.byteLength;
|
|
734
911
|
this.stats.loading.end = performance.now();
|
|
735
912
|
callbacks.onSuccess({ url: context.url, data }, this.stats, context, null);
|
|
736
913
|
})
|
|
737
914
|
.catch((err) => {
|
|
915
|
+
if (this.aborted) {
|
|
916
|
+
return;
|
|
917
|
+
}
|
|
738
918
|
this.currentSegmentIndex = null;
|
|
739
919
|
if (err instanceof DOMException && err.name === 'AbortError') {
|
|
740
920
|
this.stats.aborted = true;
|
|
921
|
+
callbacks.onAbort?.(this.stats, context, null);
|
|
741
922
|
return;
|
|
742
923
|
}
|
|
743
924
|
callbacks.onError({ code: 0, text: err.message }, context, null, this.stats);
|
|
744
925
|
});
|
|
745
926
|
}
|
|
746
927
|
abort() {
|
|
928
|
+
if (this.aborted) {
|
|
929
|
+
return;
|
|
930
|
+
}
|
|
931
|
+
this.aborted = true;
|
|
932
|
+
this.stats.aborted = true;
|
|
933
|
+
let abortedActiveSegment = false;
|
|
747
934
|
if (this.currentSegmentIndex !== null) {
|
|
935
|
+
abortedActiveSegment = true;
|
|
748
936
|
if (engine._source) {
|
|
749
937
|
// Source mode: abort the in-flight main-thread processing
|
|
750
938
|
engine._sourceSegmentAbort?.abort();
|
|
@@ -755,9 +943,14 @@ export class PlaysVideoEngine extends EventTarget {
|
|
|
755
943
|
}
|
|
756
944
|
this.currentSegmentIndex = null;
|
|
757
945
|
}
|
|
946
|
+
if (abortedActiveSegment && this.callbacks && this.context) {
|
|
947
|
+
this.callbacks.onAbort?.(this.stats, this.context, null);
|
|
948
|
+
}
|
|
758
949
|
}
|
|
759
950
|
destroy() {
|
|
760
951
|
this.abort();
|
|
952
|
+
this.callbacks = null;
|
|
953
|
+
this.context = null;
|
|
761
954
|
}
|
|
762
955
|
}
|
|
763
956
|
this.hls = new Hls({
|
|
@@ -839,6 +1032,10 @@ export class PlaysVideoEngine extends EventTarget {
|
|
|
839
1032
|
}
|
|
840
1033
|
track.track.mode = 'showing';
|
|
841
1034
|
}
|
|
1035
|
+
dispatchSubtitleStatus(message) {
|
|
1036
|
+
mlog(`subtitle-status: ${message}`);
|
|
1037
|
+
this.dispatchEvent(new CustomEvent('subtitle-status', { detail: { message } }));
|
|
1038
|
+
}
|
|
842
1039
|
restoreDefaultTextTrack() {
|
|
843
1040
|
const preferred = this.attachedSubtitleTracks.find((attached) => attached.element.default) ??
|
|
844
1041
|
this.attachedSubtitleTracks[0];
|