diff --git a/package-lock.json b/package-lock.json
index 6f89ffd..2c6afb1 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -9,6 +9,7 @@
"version": "0.0.0",
"dependencies": {
"bootstrap": "^5.1.3",
+ "hls.js": "^1.0.12",
"pretty-ms": "^7.0.1",
"vue": "^3.2.0"
},
@@ -981,6 +982,11 @@
"url": "https://github.com/sponsors/ljharb"
}
},
+ "node_modules/hls.js": {
+ "version": "1.0.12",
+ "resolved": "https://registry.npmjs.org/hls.js/-/hls.js-1.0.12.tgz",
+ "integrity": "sha512-YnyujGMZAofraBzl5PjPI6kw7HMt/Dhpnr+PrTtmoIApKUPFmeKWqvdIGhdmxQcrSHoCHDEqSX79m+Zpu924Kg=="
+ },
"node_modules/htmlparser2": {
"version": "6.1.0",
"resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-6.1.0.tgz",
@@ -2620,6 +2626,11 @@
"has-symbols": "^1.0.2"
}
},
+ "hls.js": {
+ "version": "1.0.12",
+ "resolved": "https://registry.npmjs.org/hls.js/-/hls.js-1.0.12.tgz",
+ "integrity": "sha512-YnyujGMZAofraBzl5PjPI6kw7HMt/Dhpnr+PrTtmoIApKUPFmeKWqvdIGhdmxQcrSHoCHDEqSX79m+Zpu924Kg=="
+ },
"htmlparser2": {
"version": "6.1.0",
"resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-6.1.0.tgz",
diff --git a/package.json b/package.json
index 428e9e0..c44db54 100644
--- a/package.json
+++ b/package.json
@@ -11,6 +11,7 @@
},
"dependencies": {
"bootstrap": "^5.1.3",
+ "hls.js": "^1.0.12",
"pretty-ms": "^7.0.1",
"vue": "^3.2.0"
},
diff --git a/server/package-lock.json b/server/package-lock.json
index 0272ac2..962b727 100644
--- a/server/package-lock.json
+++ b/server/package-lock.json
@@ -7,14 +7,16 @@
"dependencies": {
"express": "^4.17.1",
"express-ws": "^5.0.2",
+ "fluent-ffmpeg": "^2.1.2",
"js-yaml": "^4.1.0",
- "mjpeg-proxy": "^0.3.0",
+ "mp4frag": "^0.4.2",
"node-fetch": "^2.6.5",
"ws": "^8.2.3"
},
"devDependencies": {
"@types/express": "^4.17.8",
"@types/express-ws": "^3.0.0",
+ "@types/fluent-ffmpeg": "^2.1.19",
"@types/js-yaml": "^4.0.3",
"@types/node": "^16.10.3",
"@types/node-fetch": "^2.5.12",
@@ -121,6 +123,15 @@
"@types/ws": "*"
}
},
+ "node_modules/@types/fluent-ffmpeg": {
+ "version": "2.1.19",
+ "resolved": "https://registry.npmjs.org/@types/fluent-ffmpeg/-/fluent-ffmpeg-2.1.19.tgz",
+ "integrity": "sha512-Zn20PcPe0HbsUM7i0qNpGrZWXLCfh9xI4LJ3ovtiao4RDKmmosFmHeyxozpeqLYyZeRkZIHeb5IqrK3bld2Yjw==",
+ "dev": true,
+ "dependencies": {
+ "@types/node": "*"
+ }
+ },
"node_modules/@types/js-yaml": {
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/@types/js-yaml/-/js-yaml-4.0.3.tgz",
@@ -229,6 +240,11 @@
"resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz",
"integrity": "sha1-ml9pkFGx5wczKPKgCJaLZOopVdI="
},
+ "node_modules/async": {
+ "version": "3.2.2",
+ "resolved": "https://registry.npmjs.org/async/-/async-3.2.2.tgz",
+ "integrity": "sha512-H0E+qZaDEfx/FY4t7iLRv1W2fFI6+pyCeTw1uN20AQPiwqwM6ojPxHxdLv4z8hi2DtnW9BOckSspLucW7pIE5g=="
+ },
"node_modules/asynckit": {
"version": "0.4.0",
"resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",
@@ -469,6 +485,18 @@
"node": ">= 0.8"
}
},
+ "node_modules/fluent-ffmpeg": {
+ "version": "2.1.2",
+ "resolved": "https://registry.npmjs.org/fluent-ffmpeg/-/fluent-ffmpeg-2.1.2.tgz",
+ "integrity": "sha1-yVLeIkD4EuvaCqgAbXd27irPfXQ=",
+ "dependencies": {
+ "async": ">=0.2.9",
+ "which": "^1.1.1"
+ },
+ "engines": {
+ "node": ">=0.8.0"
+ }
+ },
"node_modules/form-data": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/form-data/-/form-data-3.0.1.tgz",
@@ -538,6 +566,11 @@
"node": ">= 0.10"
}
},
+ "node_modules/isexe": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz",
+ "integrity": "sha1-6PvzdNxVb/iUehDcsFctYz8s+hA="
+ },
"node_modules/js-yaml": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz",
@@ -606,13 +639,10 @@
"node": ">= 0.6"
}
},
- "node_modules/mjpeg-proxy": {
- "version": "0.3.0",
- "resolved": "https://registry.npmjs.org/mjpeg-proxy/-/mjpeg-proxy-0.3.0.tgz",
- "integrity": "sha512-XpdOIPIw/as8djKkJyiT1YaxlM10vqA/KJmymnVl+bZcZYdLrNPYJjkJt+x+5I77vLxyD8QmDbmJrpb63YyRJw==",
- "engines": {
- "node": ">=10"
- }
+ "node_modules/mp4frag": {
+ "version": "0.4.2",
+ "resolved": "https://registry.npmjs.org/mp4frag/-/mp4frag-0.4.2.tgz",
+ "integrity": "sha512-7fxdk04N9JIBim496YsqbKAmwdYKXHq6BwqylUFlWW9+SMQeapeJy2o8VacOMRRHH1aDc3u+AXx/Vfz4j/r7Vg=="
},
"node_modules/ms": {
"version": "2.0.0",
@@ -886,6 +916,17 @@
"webidl-conversions": "^3.0.0"
}
},
+ "node_modules/which": {
+ "version": "1.3.1",
+ "resolved": "https://registry.npmjs.org/which/-/which-1.3.1.tgz",
+ "integrity": "sha512-HxJdYWq1MTIQbJ3nw0cqssHoTNU267KlrDuGZ1WYlxDStUtKUhOaJmh112/TZmHxxUfuJqPXSOm7tDyas0OSIQ==",
+ "dependencies": {
+ "isexe": "^2.0.0"
+ },
+ "bin": {
+ "which": "bin/which"
+ }
+ },
"node_modules/ws": {
"version": "8.2.3",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.2.3.tgz",
@@ -1009,6 +1050,15 @@
"@types/ws": "*"
}
},
+ "@types/fluent-ffmpeg": {
+ "version": "2.1.19",
+ "resolved": "https://registry.npmjs.org/@types/fluent-ffmpeg/-/fluent-ffmpeg-2.1.19.tgz",
+ "integrity": "sha512-Zn20PcPe0HbsUM7i0qNpGrZWXLCfh9xI4LJ3ovtiao4RDKmmosFmHeyxozpeqLYyZeRkZIHeb5IqrK3bld2Yjw==",
+ "dev": true,
+ "requires": {
+ "@types/node": "*"
+ }
+ },
"@types/js-yaml": {
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/@types/js-yaml/-/js-yaml-4.0.3.tgz",
@@ -1105,6 +1155,11 @@
"resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz",
"integrity": "sha1-ml9pkFGx5wczKPKgCJaLZOopVdI="
},
+ "async": {
+ "version": "3.2.2",
+ "resolved": "https://registry.npmjs.org/async/-/async-3.2.2.tgz",
+ "integrity": "sha512-H0E+qZaDEfx/FY4t7iLRv1W2fFI6+pyCeTw1uN20AQPiwqwM6ojPxHxdLv4z8hi2DtnW9BOckSspLucW7pIE5g=="
+ },
"asynckit": {
"version": "0.4.0",
"resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",
@@ -1288,6 +1343,15 @@
"unpipe": "~1.0.0"
}
},
+ "fluent-ffmpeg": {
+ "version": "2.1.2",
+ "resolved": "https://registry.npmjs.org/fluent-ffmpeg/-/fluent-ffmpeg-2.1.2.tgz",
+ "integrity": "sha1-yVLeIkD4EuvaCqgAbXd27irPfXQ=",
+ "requires": {
+ "async": ">=0.2.9",
+ "which": "^1.1.1"
+ }
+ },
"form-data": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/form-data/-/form-data-3.0.1.tgz",
@@ -1339,6 +1403,11 @@
"resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
"integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g=="
},
+ "isexe": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz",
+ "integrity": "sha1-6PvzdNxVb/iUehDcsFctYz8s+hA="
+ },
"js-yaml": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz",
@@ -1386,10 +1455,10 @@
"mime-db": "1.50.0"
}
},
- "mjpeg-proxy": {
- "version": "0.3.0",
- "resolved": "https://registry.npmjs.org/mjpeg-proxy/-/mjpeg-proxy-0.3.0.tgz",
- "integrity": "sha512-XpdOIPIw/as8djKkJyiT1YaxlM10vqA/KJmymnVl+bZcZYdLrNPYJjkJt+x+5I77vLxyD8QmDbmJrpb63YyRJw=="
+ "mp4frag": {
+ "version": "0.4.2",
+ "resolved": "https://registry.npmjs.org/mp4frag/-/mp4frag-0.4.2.tgz",
+ "integrity": "sha512-7fxdk04N9JIBim496YsqbKAmwdYKXHq6BwqylUFlWW9+SMQeapeJy2o8VacOMRRHH1aDc3u+AXx/Vfz4j/r7Vg=="
},
"ms": {
"version": "2.0.0",
@@ -1589,6 +1658,14 @@
"webidl-conversions": "^3.0.0"
}
},
+ "which": {
+ "version": "1.3.1",
+ "resolved": "https://registry.npmjs.org/which/-/which-1.3.1.tgz",
+ "integrity": "sha512-HxJdYWq1MTIQbJ3nw0cqssHoTNU267KlrDuGZ1WYlxDStUtKUhOaJmh112/TZmHxxUfuJqPXSOm7tDyas0OSIQ==",
+ "requires": {
+ "isexe": "^2.0.0"
+ }
+ },
"ws": {
"version": "8.2.3",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.2.3.tgz",
diff --git a/server/package.json b/server/package.json
index 7d4a80b..973c594 100644
--- a/server/package.json
+++ b/server/package.json
@@ -2,6 +2,7 @@
"devDependencies": {
"@types/express": "^4.17.8",
"@types/express-ws": "^3.0.0",
+ "@types/fluent-ffmpeg": "^2.1.19",
"@types/js-yaml": "^4.0.3",
"@types/node": "^16.10.3",
"@types/node-fetch": "^2.5.12",
@@ -12,8 +13,9 @@
"dependencies": {
"express": "^4.17.1",
"express-ws": "^5.0.2",
+ "fluent-ffmpeg": "^2.1.2",
"js-yaml": "^4.1.0",
- "mjpeg-proxy": "^0.3.0",
+ "mp4frag": "^0.4.2",
"node-fetch": "^2.6.5",
"ws": "^8.2.3"
},
diff --git a/server/src/OctoPrintConnection.ts b/server/src/OctoPrintConnection.ts
index abf9618..38c7b08 100644
--- a/server/src/OctoPrintConnection.ts
+++ b/server/src/OctoPrintConnection.ts
@@ -1,8 +1,8 @@
import * as WebSocket from 'ws';
import fetch from 'node-fetch';
-///
-import { MjpegProxy } from 'mjpeg-proxy';
+import * as Mp4Frag from 'mp4frag';
+import {make_mp4frag} from './camera-stream';
import {Message, StatusMessage, SettingsMessage} from '../../types/messages';
import * as octoprint from '../../types/octoprint';
@@ -12,7 +12,7 @@ type Timeout = ReturnType;
export default class OctoprintConnection {
public name?: string;
- public webcamProxy?: MjpegProxy;
+ public webcamStream?: Mp4Frag;
protected lastStatus?: StatusMessage;
protected settingsMessage?: SettingsMessage;
@@ -39,8 +39,8 @@ export default class OctoprintConnection {
const settings = await this.api_get('settings');
const webcamURL = new URL(settings.webcam.streamUrl, this.address);
// TODO: handle recreating proxy on URL change
- if (this.webcamProxy === undefined) {
- this.webcamProxy = new MjpegProxy(webcamURL.toString());
+ if (this.webcamStream === undefined) {
+ this.webcamStream = make_mp4frag(this.slug, webcamURL);
}
this.settingsMessage = {
kind: "settings",
diff --git a/server/src/camera-stream.ts b/server/src/camera-stream.ts
new file mode 100644
index 0000000..ba7385b
--- /dev/null
+++ b/server/src/camera-stream.ts
@@ -0,0 +1,40 @@
+import * as ffmpeg from 'fluent-ffmpeg';
+import * as Mp4Frag from 'mp4frag';
+
+export function make_mp4frag(slug: string, url: URL | string): Mp4Frag {
+ const command = ffmpeg(url.toString())
+ .nativeFramerate()
+ .inputOptions([
+ '-probesize 1048576',
+ '-analyzeduration 10000000',
+ '-use_wallclock_as_timestamps 1',
+ ])
+ .noAudio()
+ .videoCodec('libx264')
+ .size('640x480')
+ .videoFilter('hqdn3d')
+ .format('mp4')
+ .outputOptions([
+ '-tune zerolatency',
+ '-min_frag_duration 6000000',
+ '-frag_duration 6000000',
+ '-crf 36',
+ '-preset veryfast',
+ '-profile:v baseline',
+ '-level:v 3.1',
+ '-pix_fmt yuv420p',
+ '-movflags +dash+negative_cts_offsets',
+ ])
+
+ .on('error', function (err) {
+ console.log('ffmpeg error occurred: ' + err.message);
+ });
+
+ const mp4frag = new Mp4Frag({
+ hlsPlaylistBase: slug,
+ });
+
+ command.pipe(mp4frag);
+
+ return mp4frag;
+}
diff --git a/server/src/mjpeg-proxy.d.ts b/server/src/mjpeg-proxy.d.ts
deleted file mode 100644
index 5b447ce..0000000
--- a/server/src/mjpeg-proxy.d.ts
+++ /dev/null
@@ -1,12 +0,0 @@
-declare module 'mjpeg-proxy' {
- import {Request, Response} from 'express';
- export class MjpegProxy {
- mjpegOptions: URL;
-
- constructor(mjpegUrl: string | URL);
- proxyRequest(
- req: Request,
- res: Response
- ): void;
- }
-}
diff --git a/server/src/mp4frag.d.ts b/server/src/mp4frag.d.ts
new file mode 100644
index 0000000..9567670
--- /dev/null
+++ b/server/src/mp4frag.d.ts
@@ -0,0 +1,217 @@
+declare module "mp4frag" {
+ import { Transform } from "stream";
+
+ namespace Mp4Frag {
+ interface SegmentObject {
+ segment: Buffer;
+ sequence: number;
+ duration: number;
+ timestamp: number;
+ keyframe: number;
+ }
+ }
+
+ export = Mp4Frag;
+
+ /**
+ * @fileOverview
+ *
+ * - Creates a stream transform for piping a fmp4 (fragmented mp4) from ffmpeg.
+ * - Can be used to generate a fmp4 m3u8 HLS playlist and compatible file fragments.
+ * - Can be used for storing past segments of the mp4 video in a buffer for later access.
+ * - Must use the following ffmpeg args -movflags +frag_keyframe+empty_moov+default_base_moof to generate
+ * a valid fmp4 with a compatible file structure : ftyp+moov -> moof+mdat -> moof+mdat -> moof+mdat ...
+ *
+ * @requires stream.Transform
+ */
+ class Mp4Frag extends Transform {
+ /**
+ * @constructor
+ * @param {Object} [options] - Configuration options.
+ * @param {String} [options.hlsPlaylistBase] - Base name of files in m3u8 playlist. Affects the generated m3u8 playlist by naming file fragments. Must be set to generate m3u8 playlist. e.g. 'front_door'
+ * @param {Number} [options.hlsPlaylistSize = 4] - Number of segments to use in m3u8 playlist. Must be an integer ranging from 2 to 20.
+ * @param {Number} [options.hlsPlaylistExtra = 0] - Number of extra segments to keep in memory. Must be an integer ranging from 0 to 10.
+ * @param {Boolean} [options.hlsPlaylistInit = true] - Indicates that m3u8 playlist should be generated after [initialization]{@link Mp4Frag#initialization} is created and before media segments are created.
+ * @param {Number} [options.segmentCount = 2] - Number of segments to keep in memory. Has no effect if using options.hlsPlaylistBase. Must be an integer ranging from 2 to 30.
+ * @returns {Mp4Frag} this - Returns reference to new instance of Mp4Frag for chaining event listeners.
+ * @throws Will throw an error if options.hlsPlaylistBase contains characters other than letters(a-zA-Z) and underscores(_).
+ */
+ constructor(options?: {
+ hlsPlaylistBase?: string;
+ hlsPlaylistSize?: number;
+ hlsPlaylistExtra?: number;
+ hlsPlaylistInit?: boolean;
+ segmentCount?: number;
+ });
+ /**
+ * - Returns the audio codec information as a String.
+ *
+ * - Returns Null if requested before [initialized event]{@link Mp4Frag#event:initialized}.
+ */
+ get audioCodec(): string | null;
+ /**
+ * - Returns the video codec information as a String.
+ *
+ * - Returns Null if requested before [initialized event]{@link Mp4Frag#event:initialized}.
+ */
+ get videoCodec(): string | null;
+ /**
+ * - Returns the mime type information as a String.
+ *
+ * - Returns Null if requested before [initialized event]{@link Mp4Frag#event:initialized}.
+ */
+ get mime(): string | null;
+ /**
+ * - Returns the Mp4 initialization fragment as a Buffer.
+ *
+ * - Returns Null if requested before [initialized event]{@link Mp4Frag#event:initialized}.
+ */
+ get initialization(): Buffer | null;
+ /**
+ * - Returns the latest Mp4 segment as a Buffer.
+ *
+ * - Returns Null if requested before first [segment event]{@link Mp4Frag#event:segment}.
+ */
+ get segment(): Buffer | null;
+ /**
+ * - Returns the latest Mp4 segment as an Object.
+ *
+ * - {segment, sequence, duration, timestamp, keyframe}
+ *
+ * - Returns {segment: null, sequence: -1, duration: -1; timestamp: -1, keyframe: -1} if requested before first [segment event]{@link Mp4Frag#event:segment}.
+ */
+ get segmentObject(): Mp4Frag.SegmentObject;
+ /**
+ * - Returns the timestamp of the latest Mp4 segment as an Integer(milliseconds).
+ *
+ * - Returns -1 if requested before first [segment event]{@link Mp4Frag#event:segment}.
+ */
+ get timestamp(): number;
+ /**
+ * - Returns the duration of latest Mp4 segment as a Float(seconds).
+ *
+ * - Returns -1 if requested before first [segment event]{@link Mp4Frag#event:segment}.
+ */
+ get duration(): number;
+ /**
+ * - Returns the fmp4 HLS m3u8 playlist as a String.
+ *
+ * - Returns Null if requested before [initialized event]{@link Mp4Frag#event:initialized}.
+ */
+ get m3u8(): string | null;
+ /**
+ * - Returns the sequence of the latest Mp4 segment as an Integer.
+ *
+ * - Returns -1 if requested before first [segment event]{@link Mp4Frag#event:segment}.
+ */
+ get sequence(): number;
+ /**
+ * - Returns the nal keyframe index of the latest Mp4 segment as an Integer.
+ *
+ * - Returns -1 if segment contains no keyframe nal.
+ */
+ get keyframe(): number;
+ /**
+ * - Returns the Mp4 segments as an Array of Objects
+ *
+ * - [{segment, sequence, duration, timestamp, keyframe},...]
+ *
+ * - Returns Null if requested before first [segment event]{@link Mp4Frag#event:segment}.
+ */
+ get segmentObjectList(): Mp4Frag.SegmentObject[] | null;
+ /**
+ * @param {Number} [startIndex = -1] - positive or negative starting index for segment search
+ * @param {Boolean} [isKeyframe = true] - indicate if segment should contain keyframe
+ * @param {Number} [count = 1] - stop searching when count is reached
+ * - Returns the Mp4 segments as an Array of Objects
+ *
+ * - Returns Null if requested before first [segment event]{@link Mp4Frag#event:segment}.
+ *
+ * - Returns Null if no segment found when filtered with startIndex and isKeyframe.
+ */
+ getSegmentObjectList(
+ startIndex?: number,
+ isKeyframe?: boolean,
+ count?: number
+ ): Mp4Frag.SegmentObject[] | null;
+ /**
+ * - Returns the Mp4 segments concatenated as a single Buffer.
+ *
+ * - Returns Null if requested before first [segment event]{@link Mp4Frag#event:segment}.
+ */
+ get segmentList(): Buffer | null;
+ /**
+ * @param {Number} [startIndex = -1] - positive or negative starting index for segment search
+ * @param {Boolean} [isKeyframe = true] - indicate if segment should contain keyframe
+ * @param {Number} [count = 1] - stop searching when count is reached
+ * - Returns the Mp4 segments concatenated as a single Buffer.
+ *
+ * - Returns Null if requested before first [segment event]{@link Mp4Frag#event:segment}.
+ *
+ * - Returns Null if no segment found when filtered with startIndex and isKeyframe.
+ */
+ getSegmentList(
+ startIndex?: number,
+ isKeyframe?: boolean,
+ count?: number
+ ): any | null;
+ /**
+ * - Returns the [initialization]{@link Mp4Frag#initialization} and [segmentList]{@link Mp4Frag#segmentList} concatenated as a single Buffer.
+ *
+ * - Returns Null if requested before first [segment event]{@link Mp4Frag#event:segment}.
+ */
+ get buffer(): Buffer | null;
+ /**
+ * @param {Number} [startIndex = -1] - positive or negative starting index for segment search
+ * @param {Boolean} [isKeyframe = true] - indicate if segment should contain keyframe
+ * @param {Number} [count = 1] - stop searching when count is reached
+ * - Returns the [initialization]{@link Mp4Frag#initialization} and [segmentList]{@link Mp4Frag#segmentList} concatenated as a single Buffer.
+ *
+ * - Returns Null if requested before first [segment event]{@link Mp4Frag#event:segment}.
+ *
+ * - Returns Null if no segment found when filtered with startIndex and isKeyframe.
+ */
+ getBuffer(
+ startIndex?: number,
+ isKeyframe?: boolean,
+ count?: number
+ ): Buffer | null;
+ /**
+ * @param {Number|String} sequence - sequence number
+ * - Returns the Mp4 segment that corresponds to the numbered sequence as a Buffer.
+ *
+ * - Returns Null if there is no segment that corresponds to sequence number.
+ */
+ getSegment(sequence: number | string): Buffer | null;
+ /**
+ * @param {Number|String} sequence - sequence number
+ * - Returns the Mp4 segment that corresponds to the numbered sequence as an Object.
+ *
+ * - {segment, sequence, duration, timestamp, keyframe}
+ *
+ * - Returns Null if there is no segment that corresponds to sequence number.
+ */
+ getSegmentObject(sequence: number | string): Mp4Frag.SegmentObject | null;
+ /**
+ * Clear cached values
+ */
+ resetCache(): void;
+
+ // TODO: should be able to override event emitter types
+
+ // on(event: string | symbol, listener: (...args: any[]) => void): this;
+ // on(
+ // event: "initialized",
+ // listener: (init: {
+ // mime: String;
+ // initialization: Buffer;
+ // m3u8: String;
+ // }) => void
+ // ): this;
+ // on(event: "reset", listener: () => void): this;
+ // on(
+ // event: "segment",
+ // listener: (segmentObject: Mp4Frag.SegmentObject) => void
+ // ): this;
+ }
+}
diff --git a/server/src/server.ts b/server/src/server.ts
index de9ef55..e3072f5 100644
--- a/server/src/server.ts
+++ b/server/src/server.ts
@@ -42,12 +42,51 @@ app.ws('/ws', function (ws, req) {
octoprintConnections.forEach((op: OctoPrintConnection) => op.send_init(ws));
});
-app.get('/webcam/:printer', (req, res) => {
- let printer: OctoPrintConnection | undefined = octoprintConnections.find(
+app.get('/webcam/:printer.m3u8', (req, res) => {
+ const printer: OctoPrintConnection | undefined = octoprintConnections.find(
(op) => op.slug === req.params.printer
);
- if (printer?.webcamProxy) {
- return printer.webcamProxy.proxyRequest(req, res);
+
+ if (printer?.webcamStream) {
+ if (printer.webcamStream.m3u8) {
+ res.writeHead(200, { 'Content-Type': 'application/vnd.apple.mpegurl' });
+ res.end(printer.webcamStream.m3u8.replace(/(.*\.m4s)/g, '/webcam/$1'));
+ } else {
+ res.set('Retry-After', '1.0');
+ res.status(503).send('m3u8 not ready');
+ }
+ } else res.status(404).send('Not Found: Printer not known or has no webcam.');
+});
+
+app.get('/webcam/init-:printer.mp4', (req, res) => {
+ const printer: OctoPrintConnection | undefined = octoprintConnections.find(
+ (op) => op.slug === req.params.printer
+ );
+
+ if (printer?.webcamStream) {
+ if (printer.webcamStream.initialization) {
+ res.writeHead(200, { 'Content-Type': 'video/mp4' });
+ res.end(printer.webcamStream.initialization);
+ } else {
+ res.set('Retry-After', '4.0');
+ res.status(503).send('initialization not ready');
+ }
+ } else res.status(404).send('Not Found: Printer not known or has no webcam.');
+});
+
+app.get('/webcam/:printer([^\\d]+):id(\\d+).m4s', (req, res) => {
+ const printer: OctoPrintConnection | undefined = octoprintConnections.find(
+ (op) => op.slug === req.params.printer
+ );
+
+ if (printer?.webcamStream) {
+ const segment = printer.webcamStream.getSegment(req.params.id);
+ if (segment) {
+ res.writeHead(200, { 'Content-Type': 'video/mp4' });
+ res.end(segment);
+ } else {
+ res.sendStatus(503);
+ }
} else res.status(404).send('Not Found: Printer not known or has no webcam.');
});
diff --git a/src/PrinterCard.vue b/src/PrinterCard.vue
index 45abd08..3ba189e 100644
--- a/src/PrinterCard.vue
+++ b/src/PrinterCard.vue
@@ -3,11 +3,12 @@
-
+ >
{{ status.state.text }}
@@ -48,7 +49,8 @@