@banou/media-player 0.10.21 → 0.10.22
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 +6 -1
- package/dist/audio/loudness-analysis.d.ts +9 -1
- package/dist/audio/playback-tap.d.ts +11 -0
- package/dist/engine/index.d.ts +1 -1
- package/dist/engine/index.js +1 -1
- package/dist/engine/playback.d.ts +21 -0
- package/dist/{engine-C4Gj_4zs.js → engine-Cr_7OImR.js} +120 -103
- package/dist/index.d.ts +1 -1
- package/dist/index.js +511 -442
- package/dist/react/hooks/use-playback.d.ts +2 -1
- package/dist/react/hooks/use-volume-normalizer.d.ts +6 -2
- package/dist/react/video-player.d.ts +10 -2
- package/dist/utils/source.d.ts +6 -1
- package/dist/volume-normalizer-worklet.js +14 -4
- package/package.json +3 -3
- package/src/lib/audio/loudness-analysis.ts +50 -17
- package/src/lib/audio/playback-tap.ts +37 -0
- package/src/lib/audio/volume-normalizer-worklet.js +14 -4
- package/src/lib/engine/index.ts +1 -1
- package/src/lib/engine/playback.ts +21 -1
- package/src/lib/index.tsx +1 -0
- package/src/lib/react/hooks/use-playback.ts +5 -1
- package/src/lib/react/hooks/use-volume-normalizer.ts +7 -4
- package/src/lib/react/video-player.tsx +26 -14
- package/src/lib/utils/source.ts +6 -2
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { PlaybackOutput } from '../../engine';
|
|
1
2
|
import type { MediaPlayerLocalOptions } from '../video-player';
|
|
2
3
|
/**
|
|
3
4
|
* Owns the engine for the life of a source: start, teardown, and every piece of state the pipeline
|
|
@@ -5,4 +6,4 @@ import type { MediaPlayerLocalOptions } from '../video-player';
|
|
|
5
6
|
*/
|
|
6
7
|
export declare const usePlayback: (video: HTMLVideoElement | null, subtitles: HTMLElement | null,
|
|
7
8
|
/** null when the media is remote: there are no bytes, so there is no pipeline to run. */
|
|
8
|
-
options: MediaPlayerLocalOptions | null) => void;
|
|
9
|
+
options: MediaPlayerLocalOptions | null, onOutput?: (output: PlaybackOutput) => void) => void;
|
|
@@ -1,12 +1,16 @@
|
|
|
1
1
|
import type { LoudnessSource } from '../../audio/loudness-analysis';
|
|
2
|
+
import type { PlaybackTap } from '../../audio/playback-tap';
|
|
2
3
|
/**
|
|
3
4
|
* Normalizes the element's volume while `enabled`. Nothing is built until it first is, and turning it off
|
|
4
5
|
* afterwards bypasses rather than unroutes, since an element cannot be handed back. `refused` is true once the
|
|
5
6
|
* engine turned the element down, which leaves it playing exactly as before.
|
|
6
7
|
*
|
|
7
8
|
* With a `source`, the file is also analysed ahead of the playhead while it is on, reading only the byte `ranges`
|
|
8
|
-
* given (all of them when undefined).
|
|
9
|
+
* given (all of them when undefined), or with a `tap`, from the audio playback remuxed.
|
|
9
10
|
*/
|
|
10
|
-
export declare const useVolumeNormalizer: (video: HTMLVideoElement | null, workletUrl: string | undefined, enabled: boolean, source?: LoudnessSource
|
|
11
|
+
export declare const useVolumeNormalizer: (video: HTMLVideoElement | null, workletUrl: string | undefined, enabled: boolean, source?: LoudnessSource | {
|
|
12
|
+
tap: PlaybackTap;
|
|
13
|
+
audioStreamIndex: number;
|
|
14
|
+
}, ranges?: [number, number][]) => {
|
|
11
15
|
refused: boolean;
|
|
12
16
|
};
|
|
@@ -6,13 +6,19 @@ import type { ExposePlayerOptions } from '../remote';
|
|
|
6
6
|
/**
|
|
7
7
|
* The source, read a range at a time: the player never downloads the whole file. Both fields travel
|
|
8
8
|
* together, so passing one without the other is a type error.
|
|
9
|
+
*
|
|
10
|
+
* `readsDownload` marks a source each read of which is a download, a file over HTTP, so the volume normalizer
|
|
11
|
+
* analyses the audio playback fetched instead of reading the file again. Without it, and with no
|
|
12
|
+
* `downloadedRanges`, every byte is taken to be readable for free, as a local file's are.
|
|
9
13
|
*/
|
|
10
14
|
export type MediaPlayerSource = {
|
|
11
15
|
read: (offset: number, size: number) => Promise<ArrayBuffer>;
|
|
12
16
|
size: number;
|
|
17
|
+
readsDownload?: boolean;
|
|
13
18
|
} | {
|
|
14
19
|
read?: undefined;
|
|
15
20
|
size?: undefined;
|
|
21
|
+
readsDownload?: undefined;
|
|
16
22
|
};
|
|
17
23
|
/** Shared by both arms: nothing here depends on who owns the media. */
|
|
18
24
|
type CommonOptions = {
|
|
@@ -139,7 +145,8 @@ export type MediaPlayerLocalOptions = CommonOptions & MediaPlayerSource & {
|
|
|
139
145
|
* They are the same by default so generation shares playback's fetch order. A consumer whose
|
|
140
146
|
* reads are not free wants them apart: a torrent hands this a non-prioritising, fail-fast reader
|
|
141
147
|
* so generating previews cannot steal download order from the bytes playback is blocked on. The
|
|
142
|
-
* analysis asks it only for bytes inside `downloadedRanges`,
|
|
148
|
+
* analysis asks it only for bytes inside `downloadedRanges`, with none given for the whole file, and for
|
|
149
|
+
* nothing when the source `readsDownload`.
|
|
143
150
|
*/
|
|
144
151
|
thumbnailRead?: (offset: number, size: number) => Promise<ArrayBuffer>;
|
|
145
152
|
/** Off entirely. A second wasm worker during pipeline boot is worth avoiding on a slow source. */
|
|
@@ -160,7 +167,8 @@ export type MediaPlayerLocalOptions = CommonOptions & MediaPlayerSource & {
|
|
|
160
167
|
* `onNormalizeVolumeChange` reports and passes it back.
|
|
161
168
|
*
|
|
162
169
|
* While it is on the file is read a second time, in a worker, as far as `downloadedRanges` allows, to find the
|
|
163
|
-
* loud parts ahead of the playhead
|
|
170
|
+
* loud parts ahead of the playhead; a source that `readsDownload` is not read again, and its loud parts are found
|
|
171
|
+
* in what playback fetched. The sound plays 10 ms behind its input, on or off, once routed.
|
|
164
172
|
*
|
|
165
173
|
* The first time it is on, the element is routed through Web Audio for the rest of its life (off then
|
|
166
174
|
* bypasses). If autoplay rules hold the audio back, that waits for the page's next click, tap or key press, and
|
package/dist/utils/source.d.ts
CHANGED
|
@@ -15,12 +15,17 @@ declare const fromUrl: ({ url, length: _length, name, credentials }: {
|
|
|
15
15
|
length: number;
|
|
16
16
|
name: string | undefined;
|
|
17
17
|
read: (offset: number, size: number) => Promise<ArrayBuffer>;
|
|
18
|
+
readsDownload: boolean;
|
|
18
19
|
}>;
|
|
19
|
-
/**
|
|
20
|
+
/**
|
|
21
|
+
* A source resolved down to what the remuxer needs: a total length and a byte-range reader, and whether each read
|
|
22
|
+
* is a download, which the player's `readsDownload` takes.
|
|
23
|
+
*/
|
|
20
24
|
export type RemuxerInput = {
|
|
21
25
|
length: number;
|
|
22
26
|
name?: string;
|
|
23
27
|
read: (offset: number, size: number) => Promise<ArrayBuffer>;
|
|
28
|
+
readsDownload?: boolean;
|
|
24
29
|
};
|
|
25
30
|
export declare const inputToRemuxerInput: (params: Parameters<typeof fromBlob | typeof fromUrl>[0] | {
|
|
26
31
|
length: number;
|
|
@@ -629,12 +629,15 @@ export const createAnalysis = ({ toWorklet, toPage = () => {} }) => {
|
|
|
629
629
|
}
|
|
630
630
|
|
|
631
631
|
/*
|
|
632
|
-
* The worker's side: fragments from a second libav remuxer reading the same file, decoded with
|
|
632
|
+
* The worker's side: fragments from a second libav remuxer reading the same file, or from playback's own, decoded with
|
|
633
|
+
* WebCodecs.
|
|
633
634
|
*
|
|
634
635
|
* { type: 'port', port } the worklet's end of a MessageChannel, for the blocks
|
|
635
636
|
* { type: 'init', data, codec } the init segment and the codec string MediaSource was given
|
|
636
637
|
* { type: 'run', run, pts, start } a run of fragments starts, `pts` added to their times as the SourceBuffer's
|
|
637
|
-
* timestampOffset adds it for playback; `start` for the one from the start
|
|
638
|
+
* timestampOffset adds it for playback; `start` for the one from the start,
|
|
639
|
+
* and `join: true` for one whose first fragments went by unseen, which starts
|
|
640
|
+
* at its first sample
|
|
638
641
|
* { type: 'fragment', run, data } answered { type: 'ack', run, spans, end, normal } once its audio is queued
|
|
639
642
|
* for decoding: the analysed seconds, the file's end once known, and the
|
|
640
643
|
* level learned so far in LUFS
|
|
@@ -651,6 +654,7 @@ const serve = (scope) => {
|
|
|
651
654
|
let decoder
|
|
652
655
|
let runId = -1
|
|
653
656
|
let pts = 0
|
|
657
|
+
let joining = false
|
|
654
658
|
const analysis = createAnalysis({
|
|
655
659
|
toWorklet: (message, transfer) => port?.postMessage(message, transfer),
|
|
656
660
|
toPage: (message) => scope.postMessage(message),
|
|
@@ -707,10 +711,16 @@ const serve = (scope) => {
|
|
|
707
711
|
runId = data.run
|
|
708
712
|
pts = data.pts
|
|
709
713
|
open(runId)
|
|
710
|
-
|
|
714
|
+
joining = !!data.join
|
|
715
|
+
if (!joining) analysis.startRun(runId, pts, data.start)
|
|
711
716
|
} else if (data.type === 'fragment') {
|
|
712
717
|
if (data.run !== runId || decoder.state !== 'configured') return
|
|
713
|
-
|
|
718
|
+
const samples = audioSamplesOf(data.data, track)
|
|
719
|
+
if (joining && samples.length) {
|
|
720
|
+
joining = false
|
|
721
|
+
analysis.startRun(runId, pts + samples[0].time / track.timescale)
|
|
722
|
+
}
|
|
723
|
+
for (const sample of samples) {
|
|
714
724
|
const time = pts + sample.time / track.timescale
|
|
715
725
|
stamps.push(time)
|
|
716
726
|
decoder.decode(new EncodedAudioChunk({
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@banou/media-player",
|
|
3
|
-
"version": "0.10.
|
|
3
|
+
"version": "0.10.22",
|
|
4
4
|
"description": "A video player for containers and codecs the browser cannot play natively, remuxed on the fly",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "dist/index.js",
|
|
@@ -34,12 +34,12 @@
|
|
|
34
34
|
"types": "tsc -p tsconfig.lib.json",
|
|
35
35
|
"copy-assets": "shx mkdir -p public && npm run copy-libav",
|
|
36
36
|
"copy-libav": "shx cp node_modules/libav-wasm/build/worker.js public/libav-worker.js && shx cp node_modules/libav-wasm/build/libav.wasm public/libav.wasm && shx cp node_modules/libav-wasm/build/libav-jspi.wasm public/libav-jspi.wasm",
|
|
37
|
-
"test": "npm run fixtures && vp test run",
|
|
37
|
+
"test": "npm run copy-assets && npm run fixtures && vp test run",
|
|
38
38
|
"fixtures": "node scripts/fixture.mjs"
|
|
39
39
|
},
|
|
40
40
|
"author": "Banou26",
|
|
41
41
|
"dependencies": {
|
|
42
|
-
"@banou/ponyfill": "^0.0.
|
|
42
|
+
"@banou/ponyfill": "^0.0.12",
|
|
43
43
|
"@videojs/core": "10.0.0-beta.26",
|
|
44
44
|
"@videojs/react": "10.0.0-beta.26",
|
|
45
45
|
"ass-compiler": "^0.1.16",
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import type { PlaybackTap } from './playback-tap'
|
|
2
|
+
|
|
1
3
|
import { makeRemuxer } from 'libav-wasm'
|
|
2
4
|
|
|
3
5
|
import { terminateRemuxer } from '../engine/playback'
|
|
@@ -37,12 +39,16 @@ const CLOCK_MS = 50
|
|
|
37
39
|
* nearest the playhead first, then the rest of the file, and drops nothing it learned on a seek. Until a stretch is
|
|
38
40
|
* analysed, and for the whole file where this browser's WebCodecs cannot decode the track, the worklet's live stage
|
|
39
41
|
* plays.
|
|
42
|
+
*
|
|
43
|
+
* Given a `tap` instead, it reads nothing and analyses the audio playback remuxed for `audioStreamIndex`, which runs
|
|
44
|
+
* as far ahead of the playhead as playback buffers. It starts at the next fragment playback fetches, so what was
|
|
45
|
+
* buffered before it attached is not analysed.
|
|
40
46
|
*/
|
|
41
47
|
export const startLoudnessAnalysis = ({ node, element, scriptUrl, source }: {
|
|
42
48
|
node: AudioWorkletNode
|
|
43
49
|
element: HTMLMediaElement
|
|
44
50
|
scriptUrl: string
|
|
45
|
-
source: LoudnessSource
|
|
51
|
+
source: LoudnessSource | { tap: PlaybackTap, audioStreamIndex: number }
|
|
46
52
|
}): LoudnessAnalysis => {
|
|
47
53
|
let stopped = false
|
|
48
54
|
let ranges: [number, number][] | undefined
|
|
@@ -54,14 +60,13 @@ export const startLoudnessAnalysis = ({ node, element, scriptUrl, source }: {
|
|
|
54
60
|
}
|
|
55
61
|
const changed = () => new Promise<void>((resolve) => wakers.add(resolve))
|
|
56
62
|
|
|
57
|
-
const
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
while (!readable(offset, size)) {
|
|
63
|
+
const read = async (reader: LoudnessSource, offset: number, size: number) => {
|
|
64
|
+
const end = Math.min(offset + size, reader.length)
|
|
65
|
+
while (ranges && !ranges.some(([from, to]) => from <= offset && end <= to)) {
|
|
61
66
|
// given up once the task waiting on it has been aborted, or the analysis stopped
|
|
62
67
|
if (stopped || await new Promise<boolean>((resolve) => waiting.add(resolve))) throw new Error('Cancelled')
|
|
63
68
|
}
|
|
64
|
-
return
|
|
69
|
+
return reader.read(offset, size)
|
|
65
70
|
}
|
|
66
71
|
const release = (abandon: boolean) => {
|
|
67
72
|
for (const resolve of [...waiting]) resolve(abandon)
|
|
@@ -180,14 +185,14 @@ export const startLoudnessAnalysis = ({ node, element, scriptUrl, source }: {
|
|
|
180
185
|
await acked
|
|
181
186
|
}
|
|
182
187
|
|
|
183
|
-
const loop = async () => {
|
|
188
|
+
const loop = async (reader: LoudnessSource) => {
|
|
184
189
|
remuxer = await makeRemuxer({
|
|
185
|
-
publicPath:
|
|
186
|
-
workerUrl:
|
|
190
|
+
publicPath: reader.publicPath,
|
|
191
|
+
workerUrl: reader.libavWorkerUrl,
|
|
187
192
|
workerOptions: { type: 'module' },
|
|
188
|
-
length:
|
|
189
|
-
read,
|
|
190
|
-
audioStreamIndex:
|
|
193
|
+
length: reader.length,
|
|
194
|
+
read: (offset, size) => read(reader, offset, size),
|
|
195
|
+
audioStreamIndex: reader.audioStreamIndex,
|
|
191
196
|
})
|
|
192
197
|
// stopped while it was being made, which stop() could not end
|
|
193
198
|
if (stopped) return terminateRemuxer(remuxer)
|
|
@@ -240,6 +245,30 @@ export const startLoudnessAnalysis = ({ node, element, scriptUrl, source }: {
|
|
|
240
245
|
remuxer = undefined
|
|
241
246
|
}
|
|
242
247
|
|
|
248
|
+
let detach = () => {}
|
|
249
|
+
const follow = (tap: PlaybackTap, audioStreamIndex: number) => {
|
|
250
|
+
let open = false
|
|
251
|
+
const end = (finished: boolean) => {
|
|
252
|
+
if (open) worker.postMessage({ type: 'end', run, finished })
|
|
253
|
+
open = false
|
|
254
|
+
}
|
|
255
|
+
detach = tap.attach(audioStreamIndex, (output, join) => {
|
|
256
|
+
if (output.type === 'init') {
|
|
257
|
+
end(false)
|
|
258
|
+
const data = output.data.slice(0)
|
|
259
|
+
worker.postMessage({ type: 'init', data, codec: output.audioMimeType }, [data])
|
|
260
|
+
} else if (output.type === 'run') {
|
|
261
|
+
end(false)
|
|
262
|
+
open = true
|
|
263
|
+
worker.postMessage({ type: 'run', run: ++run, pts: output.pts, start: !join && output.pts <= 0, join })
|
|
264
|
+
} else if (output.type === 'end') end(true)
|
|
265
|
+
else if (open) {
|
|
266
|
+
const data = output.data.slice(0)
|
|
267
|
+
worker.postMessage({ type: 'fragment', run, data }, [data])
|
|
268
|
+
}
|
|
269
|
+
})
|
|
270
|
+
}
|
|
271
|
+
|
|
243
272
|
const stop = () => {
|
|
244
273
|
if (stopped) return
|
|
245
274
|
stopped = true
|
|
@@ -250,6 +279,7 @@ export const startLoudnessAnalysis = ({ node, element, scriptUrl, source }: {
|
|
|
250
279
|
element.removeEventListener('seeking', onSeeking)
|
|
251
280
|
element.removeEventListener('seeked', onTime)
|
|
252
281
|
element.removeEventListener('timeupdate', onTime)
|
|
282
|
+
detach()
|
|
253
283
|
if (remuxer) terminateRemuxer(remuxer)
|
|
254
284
|
worker.terminate()
|
|
255
285
|
node.port.postMessage({ analysis: null })
|
|
@@ -257,11 +287,14 @@ export const startLoudnessAnalysis = ({ node, element, scriptUrl, source }: {
|
|
|
257
287
|
replies.clear()
|
|
258
288
|
}
|
|
259
289
|
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
290
|
+
if ('tap' in source) follow(source.tap, source.audioStreamIndex)
|
|
291
|
+
else {
|
|
292
|
+
loop(source).catch((error) => {
|
|
293
|
+
if (stopped) return
|
|
294
|
+
console.warn('[media-player] the volume normalizer cannot analyse ahead:', error)
|
|
295
|
+
stop()
|
|
296
|
+
})
|
|
297
|
+
}
|
|
265
298
|
playhead()
|
|
266
299
|
|
|
267
300
|
return {
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import type { PlaybackOutput } from '../engine/playback'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Playback's output, for an analysis that reads nothing of its own: `push` takes what playback's remuxer produced,
|
|
5
|
+
* and `attach` hands a listener what follows for one input audio stream, starting with the last init segment and the
|
|
6
|
+
* run in progress. `join` marks a run some of whose fragments went by before the listener was attached.
|
|
7
|
+
*/
|
|
8
|
+
export type PlaybackTap = {
|
|
9
|
+
push: (output: PlaybackOutput) => void
|
|
10
|
+
attach: (audioStreamIndex: number, listener: (output: PlaybackOutput, join?: boolean) => void) => () => void
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export const createPlaybackTap = (): PlaybackTap => {
|
|
14
|
+
let init: Extract<PlaybackOutput, { type: 'init' }> | undefined
|
|
15
|
+
let run: { pts: number, begun: boolean } | undefined
|
|
16
|
+
let listener: { audioStreamIndex: number, take: (output: PlaybackOutput, join?: boolean) => void } | undefined
|
|
17
|
+
const live = () => listener && init?.audioStreamIndex === listener.audioStreamIndex ? listener : undefined
|
|
18
|
+
return {
|
|
19
|
+
push: (output) => {
|
|
20
|
+
if (output.type === 'init') {
|
|
21
|
+
init = output
|
|
22
|
+
run = undefined
|
|
23
|
+
} else if (output.type === 'run') run = { pts: output.pts, begun: false }
|
|
24
|
+
else if (output.type === 'fragment' && run) run.begun = true
|
|
25
|
+
live()?.take(output)
|
|
26
|
+
},
|
|
27
|
+
attach: (audioStreamIndex, take) => {
|
|
28
|
+
const mine = { audioStreamIndex, take }
|
|
29
|
+
listener = mine
|
|
30
|
+
if (live() && init) {
|
|
31
|
+
take(init)
|
|
32
|
+
if (run) take({ type: 'run', pts: run.pts }, run.begun)
|
|
33
|
+
}
|
|
34
|
+
return () => { if (listener === mine) listener = undefined }
|
|
35
|
+
},
|
|
36
|
+
}
|
|
37
|
+
}
|
|
@@ -629,12 +629,15 @@ export const createAnalysis = ({ toWorklet, toPage = () => {} }) => {
|
|
|
629
629
|
}
|
|
630
630
|
|
|
631
631
|
/*
|
|
632
|
-
* The worker's side: fragments from a second libav remuxer reading the same file, decoded with
|
|
632
|
+
* The worker's side: fragments from a second libav remuxer reading the same file, or from playback's own, decoded with
|
|
633
|
+
* WebCodecs.
|
|
633
634
|
*
|
|
634
635
|
* { type: 'port', port } the worklet's end of a MessageChannel, for the blocks
|
|
635
636
|
* { type: 'init', data, codec } the init segment and the codec string MediaSource was given
|
|
636
637
|
* { type: 'run', run, pts, start } a run of fragments starts, `pts` added to their times as the SourceBuffer's
|
|
637
|
-
* timestampOffset adds it for playback; `start` for the one from the start
|
|
638
|
+
* timestampOffset adds it for playback; `start` for the one from the start,
|
|
639
|
+
* and `join: true` for one whose first fragments went by unseen, which starts
|
|
640
|
+
* at its first sample
|
|
638
641
|
* { type: 'fragment', run, data } answered { type: 'ack', run, spans, end, normal } once its audio is queued
|
|
639
642
|
* for decoding: the analysed seconds, the file's end once known, and the
|
|
640
643
|
* level learned so far in LUFS
|
|
@@ -651,6 +654,7 @@ const serve = (scope) => {
|
|
|
651
654
|
let decoder
|
|
652
655
|
let runId = -1
|
|
653
656
|
let pts = 0
|
|
657
|
+
let joining = false
|
|
654
658
|
const analysis = createAnalysis({
|
|
655
659
|
toWorklet: (message, transfer) => port?.postMessage(message, transfer),
|
|
656
660
|
toPage: (message) => scope.postMessage(message),
|
|
@@ -707,10 +711,16 @@ const serve = (scope) => {
|
|
|
707
711
|
runId = data.run
|
|
708
712
|
pts = data.pts
|
|
709
713
|
open(runId)
|
|
710
|
-
|
|
714
|
+
joining = !!data.join
|
|
715
|
+
if (!joining) analysis.startRun(runId, pts, data.start)
|
|
711
716
|
} else if (data.type === 'fragment') {
|
|
712
717
|
if (data.run !== runId || decoder.state !== 'configured') return
|
|
713
|
-
|
|
718
|
+
const samples = audioSamplesOf(data.data, track)
|
|
719
|
+
if (joining && samples.length) {
|
|
720
|
+
joining = false
|
|
721
|
+
analysis.startRun(runId, pts + samples[0].time / track.timescale)
|
|
722
|
+
}
|
|
723
|
+
for (const sample of samples) {
|
|
714
724
|
const time = pts + sample.time / track.timescale
|
|
715
725
|
stamps.push(time)
|
|
716
726
|
decoder.decode(new EncodedAudioChunk({
|
package/src/lib/engine/index.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
export { startPlayback, terminateRemuxer, MediaElementError, isMediaElementError, DEFAULT_BUFFER_SIZE } from './playback'
|
|
2
|
-
export type { PlaybackOptions, PlaybackController, MediaIndex, MediaChapter, AudioStream } from './playback'
|
|
2
|
+
export type { PlaybackOptions, PlaybackController, PlaybackOutput, MediaIndex, MediaChapter, AudioStream } from './playback'
|
|
3
3
|
|
|
4
4
|
export { createSubtitleRenderer, SUBTITLES_OFF } from './subtitles'
|
|
5
5
|
export type { SubtitleRenderer, SubtitleRendererOptions, SubtitleStream } from './subtitles'
|
|
@@ -23,6 +23,18 @@ export type MediaIndex = { pos: number, timestamp: number }
|
|
|
23
23
|
*/
|
|
24
24
|
export type MediaChapter = { start: number, end: number, title: string }
|
|
25
25
|
|
|
26
|
+
/**
|
|
27
|
+
* What the remuxer produced, in the order it was appended: the init segment and the input audio stream it carries,
|
|
28
|
+
* a run at load and at every remuxer seek (`pts` is the timestampOffset its fragments are appended at), each
|
|
29
|
+
* fragment, and the end of the file once read. The buffers are playback's own and may be appended again, so a
|
|
30
|
+
* consumer copies what it keeps.
|
|
31
|
+
*/
|
|
32
|
+
export type PlaybackOutput =
|
|
33
|
+
| { type: 'init', data: ArrayBuffer, audioMimeType: string, audioStreamIndex: number }
|
|
34
|
+
| { type: 'run', pts: number }
|
|
35
|
+
| { type: 'fragment', data: ArrayBuffer }
|
|
36
|
+
| { type: 'end' }
|
|
37
|
+
|
|
26
38
|
export type PlaybackOptions = {
|
|
27
39
|
videoElement: HTMLVideoElement
|
|
28
40
|
/**
|
|
@@ -51,6 +63,7 @@ export type PlaybackOptions = {
|
|
|
51
63
|
onSeek?: (fraction: number) => void
|
|
52
64
|
onSubtitleStreams?: (streams: SubtitleStream[]) => void
|
|
53
65
|
onAudioStreams?: (streams: AudioStream[], selected: number) => void
|
|
66
|
+
onOutput?: (output: PlaybackOutput) => void
|
|
54
67
|
}
|
|
55
68
|
|
|
56
69
|
export type PlaybackController = {
|
|
@@ -146,7 +159,7 @@ export const startPlayback = async (options: PlaybackOptions): Promise<PlaybackC
|
|
|
146
159
|
videoElement, subtitleContainer, read, length, publicPath, libavWorkerUrl,
|
|
147
160
|
jassubWorkerUrl, jassubWasmUrl, jassubLegacyWasmUrl, defaultFontUrl, bufferSize = DEFAULT_BUFFER_SIZE,
|
|
148
161
|
audioStreamIndex, onReady, onError, onRecovered, onSeek, onSubtitleStreams,
|
|
149
|
-
onAudioStreams,
|
|
162
|
+
onAudioStreams, onOutput,
|
|
150
163
|
} = options
|
|
151
164
|
|
|
152
165
|
const remuxer = await makeRemuxer({
|
|
@@ -253,6 +266,8 @@ export const startPlayback = async (options: PlaybackOptions): Promise<PlaybackC
|
|
|
253
266
|
|
|
254
267
|
const { appendBuffer, unbufferRange, updateTimestampOffset, endOfStream } = updateSourceBuffer(sourceBuffer, mediaSource)
|
|
255
268
|
await appendBuffer(metadata.data)
|
|
269
|
+
onOutput?.({ type: 'init', data: metadata.data, audioMimeType: metadata.info.output.audioMimeType, audioStreamIndex: selectedAudio })
|
|
270
|
+
onOutput?.({ type: 'run', pts: 0 })
|
|
256
271
|
if (metadata.subtitles?.length) subtitles.pushFragments(metadata.subtitles)
|
|
257
272
|
onReady?.()
|
|
258
273
|
|
|
@@ -400,6 +415,9 @@ export const startPlayback = async (options: PlaybackOptions): Promise<PlaybackC
|
|
|
400
415
|
if (fragments?.length) subtitles.pushFragments(fragments)
|
|
401
416
|
await updateTimestampOffset(pts)
|
|
402
417
|
if (destroyed || generation !== seekGeneration) return
|
|
418
|
+
onOutput?.({ type: 'run', pts })
|
|
419
|
+
if (data.byteLength) onOutput?.({ type: 'fragment', data })
|
|
420
|
+
if (done) onOutput?.({ type: 'end' })
|
|
403
421
|
if (data.byteLength) {
|
|
404
422
|
await appendBuffer(data)
|
|
405
423
|
trackAppendedEnd()
|
|
@@ -458,6 +476,8 @@ export const startPlayback = async (options: PlaybackOptions): Promise<PlaybackC
|
|
|
458
476
|
if (stale()) return
|
|
459
477
|
if (done) finished = true
|
|
460
478
|
if (fragments?.length) subtitles.pushFragments(fragments)
|
|
479
|
+
if (data.byteLength) onOutput?.({ type: 'fragment', data })
|
|
480
|
+
if (done) onOutput?.({ type: 'end' })
|
|
461
481
|
if (!data.byteLength) return
|
|
462
482
|
pending = data
|
|
463
483
|
pendingAttempts = 0
|
package/src/lib/index.tsx
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { PlaybackController } from '../../engine'
|
|
1
|
+
import type { PlaybackController, PlaybackOutput } from '../../engine'
|
|
2
2
|
import type { PlaybackErrorEntry } from '../source-feature'
|
|
3
3
|
import type { MediaPlayerLocalOptions } from '../video-player'
|
|
4
4
|
|
|
@@ -74,6 +74,7 @@ export const usePlayback = (
|
|
|
74
74
|
subtitles: HTMLElement | null,
|
|
75
75
|
/** null when the media is remote: there are no bytes, so there is no pipeline to run. */
|
|
76
76
|
options: MediaPlayerLocalOptions | null,
|
|
77
|
+
onOutput?: (output: PlaybackOutput) => void,
|
|
77
78
|
) => {
|
|
78
79
|
const player = usePlayer()
|
|
79
80
|
// The four asset urls are required on the local arm, so a default only ever applies when options is
|
|
@@ -147,6 +148,8 @@ export const usePlayback = (
|
|
|
147
148
|
onSeekRef.current = options?.onSeek
|
|
148
149
|
const onPlaybackErrorRef = useRef(options?.onPlaybackError)
|
|
149
150
|
onPlaybackErrorRef.current = options?.onPlaybackError
|
|
151
|
+
const onOutputRef = useRef(onOutput)
|
|
152
|
+
onOutputRef.current = onOutput
|
|
150
153
|
|
|
151
154
|
// The store's ids are opaque, but everything this hook writes into it came from libav, so every id
|
|
152
155
|
// that comes back is one of its own stream indices.
|
|
@@ -355,6 +358,7 @@ export const usePlayback = (
|
|
|
355
358
|
onError: fail,
|
|
356
359
|
onRecovered: () => { if (!cancelled) player.setSourceState({ playbackError: null }) },
|
|
357
360
|
onSeek: (fraction) => onSeekRef.current?.(fraction),
|
|
361
|
+
onOutput: (output) => onOutputRef.current?.(output),
|
|
358
362
|
onSubtitleStreams: (streams) => {
|
|
359
363
|
if (cancelled) return
|
|
360
364
|
player.setSourceState({
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { VolumeNormalizer } from '../../audio/volume-normalizer'
|
|
2
2
|
import type { LoudnessSource } from '../../audio/loudness-analysis'
|
|
3
|
+
import type { PlaybackTap } from '../../audio/playback-tap'
|
|
3
4
|
|
|
4
5
|
import { useEffect, useRef, useState } from 'react'
|
|
5
6
|
|
|
@@ -12,13 +13,13 @@ import { startLoudnessAnalysis } from '../../audio/loudness-analysis'
|
|
|
12
13
|
* engine turned the element down, which leaves it playing exactly as before.
|
|
13
14
|
*
|
|
14
15
|
* With a `source`, the file is also analysed ahead of the playhead while it is on, reading only the byte `ranges`
|
|
15
|
-
* given (all of them when undefined).
|
|
16
|
+
* given (all of them when undefined), or with a `tap`, from the audio playback remuxed.
|
|
16
17
|
*/
|
|
17
18
|
export const useVolumeNormalizer = (
|
|
18
19
|
video: HTMLVideoElement | null,
|
|
19
20
|
workletUrl: string | undefined,
|
|
20
21
|
enabled: boolean,
|
|
21
|
-
source?: LoudnessSource,
|
|
22
|
+
source?: LoudnessSource | { tap: PlaybackTap, audioStreamIndex: number },
|
|
22
23
|
ranges?: [number, number][],
|
|
23
24
|
) => {
|
|
24
25
|
const handle = useRef<VolumeNormalizer | null>(null)
|
|
@@ -53,7 +54,9 @@ export const useVolumeNormalizer = (
|
|
|
53
54
|
const analysis = useRef<ReturnType<typeof startLoudnessAnalysis> | null>(null)
|
|
54
55
|
const sourceRef = useRef(source)
|
|
55
56
|
sourceRef.current = source
|
|
56
|
-
const { length, publicPath, libavWorkerUrl
|
|
57
|
+
const { length, publicPath, libavWorkerUrl } = source && 'read' in source ? source : {}
|
|
58
|
+
const tap = source && 'tap' in source ? source.tap : undefined
|
|
59
|
+
const audioStreamIndex = source?.audioStreamIndex
|
|
57
60
|
useEffect(() => {
|
|
58
61
|
const current = sourceRef.current
|
|
59
62
|
if (!node || !video || !workletUrl || !enabled || !current) return
|
|
@@ -64,7 +67,7 @@ export const useVolumeNormalizer = (
|
|
|
64
67
|
started.stop()
|
|
65
68
|
analysis.current = null
|
|
66
69
|
}
|
|
67
|
-
}, [node, video, workletUrl, enabled, length, publicPath, libavWorkerUrl, audioStreamIndex])
|
|
70
|
+
}, [node, video, workletUrl, enabled, length, publicPath, libavWorkerUrl, audioStreamIndex, tap])
|
|
68
71
|
|
|
69
72
|
useEffect(() => { analysis.current?.update(ranges) }, [ranges])
|
|
70
73
|
|
|
@@ -15,15 +15,20 @@ import { useSeekThumbnails } from './hooks/use-thumbnails'
|
|
|
15
15
|
import { usePictureInPicture } from './hooks/use-picture-in-picture'
|
|
16
16
|
import { usePassThroughPictureInPicture } from './hooks/use-pass-through-picture-in-picture'
|
|
17
17
|
import { useVolumeNormalizer } from './hooks/use-volume-normalizer'
|
|
18
|
+
import { createPlaybackTap } from '../audio/playback-tap'
|
|
18
19
|
import Chrome from './components/chrome'
|
|
19
20
|
|
|
20
21
|
/**
|
|
21
22
|
* The source, read a range at a time: the player never downloads the whole file. Both fields travel
|
|
22
23
|
* together, so passing one without the other is a type error.
|
|
24
|
+
*
|
|
25
|
+
* `readsDownload` marks a source each read of which is a download, a file over HTTP, so the volume normalizer
|
|
26
|
+
* analyses the audio playback fetched instead of reading the file again. Without it, and with no
|
|
27
|
+
* `downloadedRanges`, every byte is taken to be readable for free, as a local file's are.
|
|
23
28
|
*/
|
|
24
29
|
export type MediaPlayerSource =
|
|
25
|
-
| { read: (offset: number, size: number) => Promise<ArrayBuffer>, size: number }
|
|
26
|
-
| { read?: undefined, size?: undefined }
|
|
30
|
+
| { read: (offset: number, size: number) => Promise<ArrayBuffer>, size: number, readsDownload?: boolean }
|
|
31
|
+
| { read?: undefined, size?: undefined, readsDownload?: undefined }
|
|
27
32
|
|
|
28
33
|
/** Shared by both arms: nothing here depends on who owns the media. */
|
|
29
34
|
type CommonOptions = {
|
|
@@ -161,7 +166,8 @@ export type MediaPlayerLocalOptions =
|
|
|
161
166
|
* They are the same by default so generation shares playback's fetch order. A consumer whose
|
|
162
167
|
* reads are not free wants them apart: a torrent hands this a non-prioritising, fail-fast reader
|
|
163
168
|
* so generating previews cannot steal download order from the bytes playback is blocked on. The
|
|
164
|
-
* analysis asks it only for bytes inside `downloadedRanges`,
|
|
169
|
+
* analysis asks it only for bytes inside `downloadedRanges`, with none given for the whole file, and for
|
|
170
|
+
* nothing when the source `readsDownload`.
|
|
165
171
|
*/
|
|
166
172
|
thumbnailRead?: (offset: number, size: number) => Promise<ArrayBuffer>
|
|
167
173
|
/** Off entirely. A second wasm worker during pipeline boot is worth avoiding on a slow source. */
|
|
@@ -183,7 +189,8 @@ export type MediaPlayerLocalOptions =
|
|
|
183
189
|
* `onNormalizeVolumeChange` reports and passes it back.
|
|
184
190
|
*
|
|
185
191
|
* While it is on the file is read a second time, in a worker, as far as `downloadedRanges` allows, to find the
|
|
186
|
-
* loud parts ahead of the playhead
|
|
192
|
+
* loud parts ahead of the playhead; a source that `readsDownload` is not read again, and its loud parts are found
|
|
193
|
+
* in what playback fetched. The sound plays 10 ms behind its input, on or off, once routed.
|
|
187
194
|
*
|
|
188
195
|
* The first time it is on, the element is routed through Web Audio for the rest of its life (off then
|
|
189
196
|
* bypasses). If autoplay rules hold the audio back, that waits for the page's next click, tap or key press, and
|
|
@@ -250,7 +257,7 @@ const PlayerRoot = ({ options, children }: { options: MediaPlayerOptions, childr
|
|
|
250
257
|
// local arm and is absent when the media is remote.
|
|
251
258
|
const { title, chapters } = options
|
|
252
259
|
const {
|
|
253
|
-
size, downloadedRanges, publicPath, libavWorkerUrl, read, thumbnailRead, thumbnailsEnabled,
|
|
260
|
+
size, downloadedRanges, publicPath, libavWorkerUrl, read, thumbnailRead, thumbnailsEnabled, readsDownload,
|
|
254
261
|
} = local ?? ({} as Partial<MediaPlayerLocalOptions>)
|
|
255
262
|
|
|
256
263
|
const setMedia = useMediaAttach()
|
|
@@ -298,9 +305,12 @@ const PlayerRoot = ({ options, children }: { options: MediaPlayerOptions, childr
|
|
|
298
305
|
return () => { dropped = true; stop?.() }
|
|
299
306
|
}, [media, !!expose, exposeId, exposeOrigin, exposeChannel])
|
|
300
307
|
|
|
308
|
+
// one per file, as `size` is the source's identity everywhere here
|
|
309
|
+
const tap = useMemo(() => createPlaybackTap(), [size])
|
|
310
|
+
|
|
301
311
|
// Each of these no-ops on null inputs, which is what a remote arm supplies: it renders no <video>,
|
|
302
312
|
// so there is nothing for them to attach to and nothing to guard at the call site.
|
|
303
|
-
usePlayback(video, subtitleLayer, local)
|
|
313
|
+
usePlayback(video, subtitleLayer, local, readsDownload ? tap.push : undefined)
|
|
304
314
|
|
|
305
315
|
const normalizeVolumeProp = local?.normalizeVolume ?? true
|
|
306
316
|
const [normalizeVolume, setNormalizeVolume] = useState(normalizeVolumeProp)
|
|
@@ -315,15 +325,17 @@ const PlayerRoot = ({ options, children }: { options: MediaPlayerOptions, childr
|
|
|
315
325
|
const hasRead = !!read
|
|
316
326
|
const normalizerSource = useMemo(
|
|
317
327
|
() => hasRead && size && publicPath && libavWorkerUrl && typeof playingAudio === 'number' && playingAudio >= 0
|
|
318
|
-
?
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
328
|
+
? readsDownload
|
|
329
|
+
? { tap, audioStreamIndex: playingAudio }
|
|
330
|
+
: {
|
|
331
|
+
read: (offset: number, length: number) => analysisRead.current!(offset, length),
|
|
332
|
+
length: size,
|
|
333
|
+
publicPath,
|
|
334
|
+
libavWorkerUrl,
|
|
335
|
+
audioStreamIndex: playingAudio,
|
|
336
|
+
}
|
|
325
337
|
: undefined,
|
|
326
|
-
[hasRead, size, publicPath, libavWorkerUrl, playingAudio],
|
|
338
|
+
[hasRead, size, publicPath, libavWorkerUrl, playingAudio, readsDownload, tap],
|
|
327
339
|
)
|
|
328
340
|
const normalizerRanges = useMemo(
|
|
329
341
|
() => downloadedRanges?.map(({ startByteOffset, endByteOffset }) => [startByteOffset, endByteOffset] as [number, number]),
|
package/src/lib/utils/source.ts
CHANGED
|
@@ -42,14 +42,18 @@ const fromUrl = async (
|
|
|
42
42
|
if (!response.ok) throw new Error(`The source could not be read: HTTP ${response.status}`)
|
|
43
43
|
return response.arrayBuffer()
|
|
44
44
|
}
|
|
45
|
-
return { length, name, read }
|
|
45
|
+
return { length, name, read, readsDownload: true }
|
|
46
46
|
}
|
|
47
47
|
|
|
48
|
-
/**
|
|
48
|
+
/**
|
|
49
|
+
* A source resolved down to what the remuxer needs: a total length and a byte-range reader, and whether each read
|
|
50
|
+
* is a download, which the player's `readsDownload` takes.
|
|
51
|
+
*/
|
|
49
52
|
export type RemuxerInput = {
|
|
50
53
|
length: number
|
|
51
54
|
name?: string
|
|
52
55
|
read: (offset: number, size: number) => Promise<ArrayBuffer>
|
|
56
|
+
readsDownload?: boolean
|
|
53
57
|
}
|
|
54
58
|
|
|
55
59
|
export const inputToRemuxerInput = async (
|