Skip to content

Commit 64e6969

Browse files
committed
feat: streaming MP4 over HTTP
In order to stream an MP4 file via HTTP, streaming fetch can be used in combination with MSE. - HTTP source component via streaming fetch - MP4 parser (generates ISOM boxes from data stream) - MSE adjusted to be SDP-agnostic - MP4 muxer stores track info on first ISOM instead of SDP - example streaming MP4
1 parent b4c92eb commit 64e6969

20 files changed

+678
-66
lines changed
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
const { pipelines } = window.mediaStreamLibrary
2+
3+
// force auth
4+
const authorize = async (host) => {
5+
// Force a login by fetching usergroup
6+
const fetchOptions = {
7+
credentials: 'include',
8+
headers: {
9+
'Axis-Orig-Sw': true,
10+
'X-Requested-With': 'XMLHttpRequest',
11+
},
12+
mode: 'no-cors',
13+
}
14+
try {
15+
await window.fetch(`http://${host}/axis-cgi/usergroup.cgi`, fetchOptions)
16+
} catch (err) {
17+
console.error(err)
18+
}
19+
}
20+
21+
let pipeline
22+
const play = (host) => {
23+
// Grab a reference to the video element
24+
const mediaElement = document.querySelector('video')
25+
26+
// Setup a new pipeline
27+
pipeline = new pipelines.HttpMsePipeline({
28+
http: {
29+
uri: `http://${host}/axis-cgi/media.cgi?videocodec=h264&container=mp4`,
30+
},
31+
mediaElement,
32+
})
33+
pipeline.http.play()
34+
}
35+
36+
// Each time a device ip is entered, authorize and then play
37+
const playButton = document.querySelector('#play')
38+
playButton.addEventListener('click', async (e) => {
39+
pipeline && pipeline.close()
40+
41+
const host = window.location.host
42+
43+
await authorize(host)
44+
45+
pipeline = play(host)
46+
})
+26
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
<!DOCTYPE html>
2+
<html>
3+
<head>
4+
<title>Example streaming MP4 video from Axis camera</title>
5+
</head>
6+
7+
<body>
8+
<div>
9+
<p>
10+
To play from a camera, make sure the camera is proxied in
11+
order to avoid CORS errors. The easiest way is to export an
12+
environment variable MSL_EXAMPLE_CAMERA='http://ip'.
13+
</p>
14+
<button id="play">Play</button>
15+
</div>
16+
<div style="position: fixed; width: 1280px; height: 720px">
17+
<video
18+
style="position: absolute; width: 100%; height: 100%;"
19+
autoplay
20+
controls
21+
></video>
22+
</div>
23+
<script src="../media-stream-library.min.js"></script>
24+
<script src="simple-mp4-player.js"></script>
25+
</body>
26+
</html>

examples/browser/test/bbb.mp4

2.32 MB
Binary file not shown.

examples/browser/test/mp4.html

+17
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
<!DOCTYPE html>
2+
<html>
3+
<head>
4+
<title>Example MP4 video streaming via HTML5 src attribute</title>
5+
</head>
6+
7+
<body>
8+
<div style="position: fixed; width: 960px; height: 540px">
9+
<video
10+
src="bbb.mp4"
11+
style="position: absolute; width: 100%; height: 100%;"
12+
autoplay
13+
controls
14+
></video>
15+
</div>
16+
</body>
17+
</html>
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
const { pipelines } = window.mediaStreamLibrary
2+
3+
const play = (host) => {
4+
// Grab a reference to the video element
5+
const mediaElement = document.querySelector('video')
6+
7+
// Setup a new pipeline
8+
const pipeline = new pipelines.HttpMsePipeline({
9+
http: { uri: `http://${host}/test/bbb.mp4` },
10+
mediaElement,
11+
})
12+
pipeline.http.play()
13+
}
14+
15+
play(window.location.host)
+19
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
<!DOCTYPE html>
2+
<html>
3+
<head>
4+
<title>Example HTTP MP4 video streaming</title>
5+
</head>
6+
7+
<body>
8+
<div style="position: fixed; width: 960px; height: 540px">
9+
<video
10+
style="position: absolute; width: 100%; height: 100%;"
11+
autoplay
12+
muted
13+
controls
14+
></video>
15+
</div>
16+
<script src="../media-stream-library.min.js"></script>
17+
<script src="streaming-mp4-player.js"></script>
18+
</body>
19+
</html>

lib/components/http-source/index.ts

+115
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
1+
import registerDebug from 'debug'
2+
import { Source } from '../component'
3+
import { Readable } from 'stream'
4+
import { MessageType } from '../message'
5+
6+
const debug = registerDebug('msl:http-source')
7+
8+
interface Headers {
9+
[key: string]: string
10+
}
11+
12+
export interface HttpConfig {
13+
uri: string
14+
headers?: Headers
15+
}
16+
17+
export class HttpSource extends Source {
18+
public uri: string
19+
public headers?: Headers
20+
public length?: number
21+
22+
private _reader?: ReadableStreamDefaultReader<Uint8Array>
23+
24+
/**
25+
* Create an HTTP component.
26+
*
27+
* The constructor sets a single readable stream from a fetch.
28+
*/
29+
constructor(config: HttpConfig) {
30+
const { uri, headers } = config
31+
32+
/**
33+
* Set up an incoming stream and attach it to the socket.
34+
*/
35+
const incoming = new Readable({
36+
objectMode: true,
37+
read: function () {
38+
//
39+
},
40+
})
41+
42+
// When an error is sent on the incoming stream, close the socket.
43+
incoming.on('error', (e) => {
44+
console.warn('closing socket due to incoming error', e)
45+
this._reader && this._reader.cancel()
46+
})
47+
48+
/**
49+
* initialize the component.
50+
*/
51+
super(incoming)
52+
53+
// When a read is requested, continue to pull data
54+
incoming._read = () => {
55+
this._pull()
56+
}
57+
58+
this.uri = uri
59+
this.headers = headers
60+
}
61+
62+
play(): void {
63+
if (this.uri === undefined) {
64+
throw new Error('cannot start playing when there is no URI')
65+
}
66+
67+
this.length = 0
68+
fetch(this.uri, { headers: this.headers })
69+
.then((rsp) => {
70+
if (rsp.body === null) {
71+
throw new Error('empty response body')
72+
}
73+
74+
this._reader = rsp.body.getReader()
75+
this._pull()
76+
})
77+
.catch((err) => {
78+
throw new Error(err)
79+
})
80+
}
81+
82+
_pull(): void {
83+
if (this._reader === undefined) {
84+
return
85+
}
86+
87+
this._reader.read().then(({ done, value }) => {
88+
if (done) {
89+
debug('fetch completed, total downloaded: ', this.length, ' bytes')
90+
this.incoming.push(null)
91+
return
92+
}
93+
if (value === undefined) {
94+
throw new Error('expected value to be defined')
95+
}
96+
if (this.length === undefined) {
97+
throw new Error('expected length to be defined')
98+
}
99+
this.length += value.length
100+
const buffer = Buffer.from(value)
101+
if (!this.incoming.push({ data: buffer, type: MessageType.RAW })) {
102+
// Something happened down stream that it is no longer processing the
103+
// incoming data, and the stream buffer got full.
104+
// This could be because we are downloading too much data at once,
105+
// or because the downstream is frozen. The latter is most likely
106+
// when dealing with a live stream (as in that case we would expect
107+
// downstream to be able to handle the data).
108+
debug('downstream back pressure: pausing read')
109+
} else {
110+
// It's ok to read more data
111+
this._pull()
112+
}
113+
})
114+
}
115+
}

lib/components/index.browser.ts

+1
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ export * from './aacdepay'
44
export * from './basicdepay'
55
export * from './canvas'
66
export * from './h264depay'
7+
export * from './http-source'
78
export * from './inspector'
89
export * from './jpegdepay'
910
export * from './mp4capture'

lib/components/message.ts

+2
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { MediaTrack } from '../utils/protocols/isom'
12
import { Sdp } from '../utils/protocols/sdp'
23

34
export interface GenericMessage {
@@ -63,6 +64,7 @@ export interface H264Message extends GenericMessage {
6364
export interface IsomMessage extends GenericMessage {
6465
readonly type: MessageType.ISOM
6566
readonly checkpointTime?: number // presentation time of last I-frame (s)
67+
readonly tracks?: MediaTrack[]
6668
}
6769

6870
export interface XmlMessage extends GenericMessage {

lib/components/mp4-parser/index.ts

+33
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
import { Tube } from '../component'
2+
import { Transform } from 'stream'
3+
import { Message, MessageType } from '../message'
4+
import { Parser } from './parser'
5+
6+
/**
7+
* A component that converts raw binary MP4 data into ISOM boxes.
8+
* @extends {Component}
9+
*/
10+
export class Mp4Parser extends Tube {
11+
/**
12+
* Create a new RTSP parser component.
13+
*/
14+
constructor() {
15+
const parser = new Parser()
16+
17+
// Incoming stream
18+
const incoming = new Transform({
19+
objectMode: true,
20+
transform: function (msg: Message, _, callback) {
21+
if (msg.type === MessageType.RAW) {
22+
parser.parse(msg.data).forEach((message) => incoming.push(message))
23+
callback()
24+
} else {
25+
// Not a message we should handle
26+
callback(undefined, msg)
27+
}
28+
},
29+
})
30+
31+
super(incoming)
32+
}
33+
}

0 commit comments

Comments
 (0)