2
0
mirror of https://github.com/ad1217/PrinterStatus synced 2024-12-25 11:05:41 -05:00

Re-stream webcams with ffmpeg and stream them via hls.js

This should result in a significant decrease in bandwidth
requirements, as well as providing the ability to reconnect to streams
This commit is contained in:
Adam Goldsmith 2021-11-08 20:17:42 -05:00
parent abf2144ca7
commit cc0f6d90d0
10 changed files with 451 additions and 38 deletions

11
package-lock.json generated
View File

@ -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",

View File

@ -11,6 +11,7 @@
},
"dependencies": {
"bootstrap": "^5.1.3",
"hls.js": "^1.0.12",
"pretty-ms": "^7.0.1",
"vue": "^3.2.0"
},

101
server/package-lock.json generated
View File

@ -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",

View File

@ -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"
},

View File

@ -1,8 +1,8 @@
import * as WebSocket from 'ws';
import fetch from 'node-fetch';
/// <reference path="mjpeg-proxy.d.ts"/>
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<typeof setTimeout>;
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",

View File

@ -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;
}

View File

@ -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;
}
}

217
server/src/mp4frag.d.ts vendored Normal file
View File

@ -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
* <ul>
* <li>Creates a stream transform for piping a fmp4 (fragmented mp4) from ffmpeg.</li>
* <li>Can be used to generate a fmp4 m3u8 HLS playlist and compatible file fragments.</li>
* <li>Can be used for storing past segments of the mp4 video in a buffer for later access.</li>
* <li>Must use the following ffmpeg args <b><i>-movflags +frag_keyframe+empty_moov+default_base_moof</i></b> to generate
* a valid fmp4 with a compatible file structure : ftyp+moov -> moof+mdat -> moof+mdat -> moof+mdat ...</li>
* </ul>
* @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 <b>String</b>.
*
* - Returns <b>Null</b> if requested before [initialized event]{@link Mp4Frag#event:initialized}.
*/
get audioCodec(): string | null;
/**
* - Returns the video codec information as a <b>String</b>.
*
* - Returns <b>Null</b> if requested before [initialized event]{@link Mp4Frag#event:initialized}.
*/
get videoCodec(): string | null;
/**
* - Returns the mime type information as a <b>String</b>.
*
* - Returns <b>Null</b> if requested before [initialized event]{@link Mp4Frag#event:initialized}.
*/
get mime(): string | null;
/**
* - Returns the Mp4 initialization fragment as a <b>Buffer</b>.
*
* - Returns <b>Null</b> if requested before [initialized event]{@link Mp4Frag#event:initialized}.
*/
get initialization(): Buffer | null;
/**
* - Returns the latest Mp4 segment as a <b>Buffer</b>.
*
* - Returns <b>Null</b> if requested before first [segment event]{@link Mp4Frag#event:segment}.
*/
get segment(): Buffer | null;
/**
* - Returns the latest Mp4 segment as an <b>Object</b>.
*
* - <b><code>{segment, sequence, duration, timestamp, keyframe}</code></b>
*
* - Returns <b>{segment: null, sequence: -1, duration: -1; timestamp: -1, keyframe: -1}</b> if requested before first [segment event]{@link Mp4Frag#event:segment}.
*/
get segmentObject(): Mp4Frag.SegmentObject;
/**
* - Returns the timestamp of the latest Mp4 segment as an <b>Integer</b>(<i>milliseconds</i>).
*
* - Returns <b>-1</b> if requested before first [segment event]{@link Mp4Frag#event:segment}.
*/
get timestamp(): number;
/**
* - Returns the duration of latest Mp4 segment as a <b>Float</b>(<i>seconds</i>).
*
* - Returns <b>-1</b> if requested before first [segment event]{@link Mp4Frag#event:segment}.
*/
get duration(): number;
/**
* - Returns the fmp4 HLS m3u8 playlist as a <b>String</b>.
*
* - Returns <b>Null</b> if requested before [initialized event]{@link Mp4Frag#event:initialized}.
*/
get m3u8(): string | null;
/**
* - Returns the sequence of the latest Mp4 segment as an <b>Integer</b>.
*
* - Returns <b>-1</b> 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 <b>Integer</b>.
*
* - Returns <b>-1</b> if segment contains no keyframe nal.
*/
get keyframe(): number;
/**
* - Returns the Mp4 segments as an <b>Array</b> of <b>Objects</b>
*
* - <b><code>[{segment, sequence, duration, timestamp, keyframe},...]</code></b>
*
* - Returns <b>Null</b> 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 <b>Array</b> of <b>Objects</b>
*
* - Returns <b>Null</b> if requested before first [segment event]{@link Mp4Frag#event:segment}.
*
* - Returns <b>Null</b> 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 <b>Buffer</b>.
*
* - Returns <b>Null</b> 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 <b>Buffer</b>.
*
* - Returns <b>Null</b> if requested before first [segment event]{@link Mp4Frag#event:segment}.
*
* - Returns <b>Null</b> 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 <b>Buffer</b>.
*
* - Returns <b>Null</b> 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 <b>Buffer</b>.
*
* - Returns <b>Null</b> if requested before first [segment event]{@link Mp4Frag#event:segment}.
*
* - Returns <b>Null</b> 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 <b>Buffer</b>.
*
* - Returns <b>Null</b> 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 <b>Object</b>.
*
* - <b><code>{segment, sequence, duration, timestamp, keyframe}</code></b>
*
* - Returns <b>Null</b> 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;
}
}

View File

@ -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.');
});

View File

@ -3,11 +3,12 @@
<h3 class="card-header" :data-color="color">
{{ name || 'Unknown' }}
</h3>
<img
<video
muted
class="card-img webcam"
ref="video"
:style="webcamTransform"
:src="'/webcam/' + slug"
/>
></video>
<div class="card-body" v-if="status">
<div>{{ status.state.text }}</div>
<div>
@ -48,7 +49,8 @@
</template>
<script setup lang="ts">
import { computed } from 'vue';
import Hls from 'hls.js';
import { computed, onMounted, Ref, ref, watchEffect } from 'vue';
import prettyMilliseconds from 'pretty-ms';
import { CurrentOrHistoryPayload } from '../types/octoprint';
@ -67,6 +69,33 @@ interface Props {
export type PrinterInfo = Omit<Props, 'slug' | 'now'>;
const props = defineProps<Props>();
const video: Ref<HTMLMediaElement | null> = ref(null);
const hls: Ref<Hls | null> = ref(null);
if (Hls.isSupported()) {
hls.value = new Hls({
liveDurationInfinity: true,
backBufferLength: 30,
manifestLoadingTimeOut: 1000,
manifestLoadingMaxRetry: 30,
manifestLoadingRetryDelay: 500,
//debug: true,
});
hls.value.on(Hls.Events.MEDIA_ATTACHED, () => {
hls.value!.loadSource(`/webcam/${props.slug}.m3u8`);
hls.value!.on(Hls.Events.MANIFEST_PARSED, (event, data) => {
video.value?.play();
console.log(
'manifest loaded, found ' + data.levels.length + ' quality level'
);
});
});
hls.value.on(Hls.Events.ERROR, (event, data) => {
console.log(data);
});
}
function formatDuration(seconds: number): string {
return prettyMilliseconds(seconds * 1000);
@ -104,6 +133,15 @@ const webcamTransform = computed(() => {
return {};
}
});
watchEffect(() => {
console.log(video.value, hls.value);
if (hls.value && video.value) {
// if hls and video element are valid, bind them together
hls.value.attachMedia(video.value);
console.log('video and hls.js are now bound together !');
}
});
</script>
<style lang="scss">