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 + * + * @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 @@

{{ name || 'Unknown' }}

- + >
{{ status.state.text }}
@@ -48,7 +49,8 @@