mirror of
https://github.com/ad1217/PrinterStatus
synced 2024-11-11 02:55:09 -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:
parent
abf2144ca7
commit
cc0f6d90d0
11
package-lock.json
generated
11
package-lock.json
generated
@ -9,6 +9,7 @@
|
|||||||
"version": "0.0.0",
|
"version": "0.0.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"bootstrap": "^5.1.3",
|
"bootstrap": "^5.1.3",
|
||||||
|
"hls.js": "^1.0.12",
|
||||||
"pretty-ms": "^7.0.1",
|
"pretty-ms": "^7.0.1",
|
||||||
"vue": "^3.2.0"
|
"vue": "^3.2.0"
|
||||||
},
|
},
|
||||||
@ -981,6 +982,11 @@
|
|||||||
"url": "https://github.com/sponsors/ljharb"
|
"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": {
|
"node_modules/htmlparser2": {
|
||||||
"version": "6.1.0",
|
"version": "6.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-6.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-6.1.0.tgz",
|
||||||
@ -2620,6 +2626,11 @@
|
|||||||
"has-symbols": "^1.0.2"
|
"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": {
|
"htmlparser2": {
|
||||||
"version": "6.1.0",
|
"version": "6.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-6.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-6.1.0.tgz",
|
||||||
|
@ -11,6 +11,7 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"bootstrap": "^5.1.3",
|
"bootstrap": "^5.1.3",
|
||||||
|
"hls.js": "^1.0.12",
|
||||||
"pretty-ms": "^7.0.1",
|
"pretty-ms": "^7.0.1",
|
||||||
"vue": "^3.2.0"
|
"vue": "^3.2.0"
|
||||||
},
|
},
|
||||||
|
101
server/package-lock.json
generated
101
server/package-lock.json
generated
@ -7,14 +7,16 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"express": "^4.17.1",
|
"express": "^4.17.1",
|
||||||
"express-ws": "^5.0.2",
|
"express-ws": "^5.0.2",
|
||||||
|
"fluent-ffmpeg": "^2.1.2",
|
||||||
"js-yaml": "^4.1.0",
|
"js-yaml": "^4.1.0",
|
||||||
"mjpeg-proxy": "^0.3.0",
|
"mp4frag": "^0.4.2",
|
||||||
"node-fetch": "^2.6.5",
|
"node-fetch": "^2.6.5",
|
||||||
"ws": "^8.2.3"
|
"ws": "^8.2.3"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/express": "^4.17.8",
|
"@types/express": "^4.17.8",
|
||||||
"@types/express-ws": "^3.0.0",
|
"@types/express-ws": "^3.0.0",
|
||||||
|
"@types/fluent-ffmpeg": "^2.1.19",
|
||||||
"@types/js-yaml": "^4.0.3",
|
"@types/js-yaml": "^4.0.3",
|
||||||
"@types/node": "^16.10.3",
|
"@types/node": "^16.10.3",
|
||||||
"@types/node-fetch": "^2.5.12",
|
"@types/node-fetch": "^2.5.12",
|
||||||
@ -121,6 +123,15 @@
|
|||||||
"@types/ws": "*"
|
"@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": {
|
"node_modules/@types/js-yaml": {
|
||||||
"version": "4.0.3",
|
"version": "4.0.3",
|
||||||
"resolved": "https://registry.npmjs.org/@types/js-yaml/-/js-yaml-4.0.3.tgz",
|
"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",
|
"resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz",
|
||||||
"integrity": "sha1-ml9pkFGx5wczKPKgCJaLZOopVdI="
|
"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": {
|
"node_modules/asynckit": {
|
||||||
"version": "0.4.0",
|
"version": "0.4.0",
|
||||||
"resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",
|
"resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",
|
||||||
@ -469,6 +485,18 @@
|
|||||||
"node": ">= 0.8"
|
"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": {
|
"node_modules/form-data": {
|
||||||
"version": "3.0.1",
|
"version": "3.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/form-data/-/form-data-3.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/form-data/-/form-data-3.0.1.tgz",
|
||||||
@ -538,6 +566,11 @@
|
|||||||
"node": ">= 0.10"
|
"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": {
|
"node_modules/js-yaml": {
|
||||||
"version": "4.1.0",
|
"version": "4.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz",
|
||||||
@ -606,13 +639,10 @@
|
|||||||
"node": ">= 0.6"
|
"node": ">= 0.6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/mjpeg-proxy": {
|
"node_modules/mp4frag": {
|
||||||
"version": "0.3.0",
|
"version": "0.4.2",
|
||||||
"resolved": "https://registry.npmjs.org/mjpeg-proxy/-/mjpeg-proxy-0.3.0.tgz",
|
"resolved": "https://registry.npmjs.org/mp4frag/-/mp4frag-0.4.2.tgz",
|
||||||
"integrity": "sha512-XpdOIPIw/as8djKkJyiT1YaxlM10vqA/KJmymnVl+bZcZYdLrNPYJjkJt+x+5I77vLxyD8QmDbmJrpb63YyRJw==",
|
"integrity": "sha512-7fxdk04N9JIBim496YsqbKAmwdYKXHq6BwqylUFlWW9+SMQeapeJy2o8VacOMRRHH1aDc3u+AXx/Vfz4j/r7Vg=="
|
||||||
"engines": {
|
|
||||||
"node": ">=10"
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
"node_modules/ms": {
|
"node_modules/ms": {
|
||||||
"version": "2.0.0",
|
"version": "2.0.0",
|
||||||
@ -886,6 +916,17 @@
|
|||||||
"webidl-conversions": "^3.0.0"
|
"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": {
|
"node_modules/ws": {
|
||||||
"version": "8.2.3",
|
"version": "8.2.3",
|
||||||
"resolved": "https://registry.npmjs.org/ws/-/ws-8.2.3.tgz",
|
"resolved": "https://registry.npmjs.org/ws/-/ws-8.2.3.tgz",
|
||||||
@ -1009,6 +1050,15 @@
|
|||||||
"@types/ws": "*"
|
"@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": {
|
"@types/js-yaml": {
|
||||||
"version": "4.0.3",
|
"version": "4.0.3",
|
||||||
"resolved": "https://registry.npmjs.org/@types/js-yaml/-/js-yaml-4.0.3.tgz",
|
"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",
|
"resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz",
|
||||||
"integrity": "sha1-ml9pkFGx5wczKPKgCJaLZOopVdI="
|
"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": {
|
"asynckit": {
|
||||||
"version": "0.4.0",
|
"version": "0.4.0",
|
||||||
"resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",
|
"resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",
|
||||||
@ -1288,6 +1343,15 @@
|
|||||||
"unpipe": "~1.0.0"
|
"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": {
|
"form-data": {
|
||||||
"version": "3.0.1",
|
"version": "3.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/form-data/-/form-data-3.0.1.tgz",
|
"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",
|
"resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
|
||||||
"integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g=="
|
"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": {
|
"js-yaml": {
|
||||||
"version": "4.1.0",
|
"version": "4.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz",
|
||||||
@ -1386,10 +1455,10 @@
|
|||||||
"mime-db": "1.50.0"
|
"mime-db": "1.50.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"mjpeg-proxy": {
|
"mp4frag": {
|
||||||
"version": "0.3.0",
|
"version": "0.4.2",
|
||||||
"resolved": "https://registry.npmjs.org/mjpeg-proxy/-/mjpeg-proxy-0.3.0.tgz",
|
"resolved": "https://registry.npmjs.org/mp4frag/-/mp4frag-0.4.2.tgz",
|
||||||
"integrity": "sha512-XpdOIPIw/as8djKkJyiT1YaxlM10vqA/KJmymnVl+bZcZYdLrNPYJjkJt+x+5I77vLxyD8QmDbmJrpb63YyRJw=="
|
"integrity": "sha512-7fxdk04N9JIBim496YsqbKAmwdYKXHq6BwqylUFlWW9+SMQeapeJy2o8VacOMRRHH1aDc3u+AXx/Vfz4j/r7Vg=="
|
||||||
},
|
},
|
||||||
"ms": {
|
"ms": {
|
||||||
"version": "2.0.0",
|
"version": "2.0.0",
|
||||||
@ -1589,6 +1658,14 @@
|
|||||||
"webidl-conversions": "^3.0.0"
|
"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": {
|
"ws": {
|
||||||
"version": "8.2.3",
|
"version": "8.2.3",
|
||||||
"resolved": "https://registry.npmjs.org/ws/-/ws-8.2.3.tgz",
|
"resolved": "https://registry.npmjs.org/ws/-/ws-8.2.3.tgz",
|
||||||
|
@ -2,6 +2,7 @@
|
|||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/express": "^4.17.8",
|
"@types/express": "^4.17.8",
|
||||||
"@types/express-ws": "^3.0.0",
|
"@types/express-ws": "^3.0.0",
|
||||||
|
"@types/fluent-ffmpeg": "^2.1.19",
|
||||||
"@types/js-yaml": "^4.0.3",
|
"@types/js-yaml": "^4.0.3",
|
||||||
"@types/node": "^16.10.3",
|
"@types/node": "^16.10.3",
|
||||||
"@types/node-fetch": "^2.5.12",
|
"@types/node-fetch": "^2.5.12",
|
||||||
@ -12,8 +13,9 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"express": "^4.17.1",
|
"express": "^4.17.1",
|
||||||
"express-ws": "^5.0.2",
|
"express-ws": "^5.0.2",
|
||||||
|
"fluent-ffmpeg": "^2.1.2",
|
||||||
"js-yaml": "^4.1.0",
|
"js-yaml": "^4.1.0",
|
||||||
"mjpeg-proxy": "^0.3.0",
|
"mp4frag": "^0.4.2",
|
||||||
"node-fetch": "^2.6.5",
|
"node-fetch": "^2.6.5",
|
||||||
"ws": "^8.2.3"
|
"ws": "^8.2.3"
|
||||||
},
|
},
|
||||||
|
@ -1,8 +1,8 @@
|
|||||||
import * as WebSocket from 'ws';
|
import * as WebSocket from 'ws';
|
||||||
import fetch from 'node-fetch';
|
import fetch from 'node-fetch';
|
||||||
/// <reference path="mjpeg-proxy.d.ts"/>
|
import * as Mp4Frag from 'mp4frag';
|
||||||
import { MjpegProxy } from 'mjpeg-proxy';
|
|
||||||
|
|
||||||
|
import {make_mp4frag} from './camera-stream';
|
||||||
import {Message, StatusMessage, SettingsMessage} from '../../types/messages';
|
import {Message, StatusMessage, SettingsMessage} from '../../types/messages';
|
||||||
import * as octoprint from '../../types/octoprint';
|
import * as octoprint from '../../types/octoprint';
|
||||||
|
|
||||||
@ -12,7 +12,7 @@ type Timeout = ReturnType<typeof setTimeout>;
|
|||||||
|
|
||||||
export default class OctoprintConnection {
|
export default class OctoprintConnection {
|
||||||
public name?: string;
|
public name?: string;
|
||||||
public webcamProxy?: MjpegProxy;
|
public webcamStream?: Mp4Frag;
|
||||||
protected lastStatus?: StatusMessage;
|
protected lastStatus?: StatusMessage;
|
||||||
protected settingsMessage?: SettingsMessage;
|
protected settingsMessage?: SettingsMessage;
|
||||||
|
|
||||||
@ -39,8 +39,8 @@ export default class OctoprintConnection {
|
|||||||
const settings = await this.api_get('settings');
|
const settings = await this.api_get('settings');
|
||||||
const webcamURL = new URL(settings.webcam.streamUrl, this.address);
|
const webcamURL = new URL(settings.webcam.streamUrl, this.address);
|
||||||
// TODO: handle recreating proxy on URL change
|
// TODO: handle recreating proxy on URL change
|
||||||
if (this.webcamProxy === undefined) {
|
if (this.webcamStream === undefined) {
|
||||||
this.webcamProxy = new MjpegProxy(webcamURL.toString());
|
this.webcamStream = make_mp4frag(this.slug, webcamURL);
|
||||||
}
|
}
|
||||||
this.settingsMessage = {
|
this.settingsMessage = {
|
||||||
kind: "settings",
|
kind: "settings",
|
||||||
|
40
server/src/camera-stream.ts
Normal file
40
server/src/camera-stream.ts
Normal 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;
|
||||||
|
}
|
12
server/src/mjpeg-proxy.d.ts
vendored
12
server/src/mjpeg-proxy.d.ts
vendored
@ -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
217
server/src/mp4frag.d.ts
vendored
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
@ -42,12 +42,51 @@ app.ws('/ws', function (ws, req) {
|
|||||||
octoprintConnections.forEach((op: OctoPrintConnection) => op.send_init(ws));
|
octoprintConnections.forEach((op: OctoPrintConnection) => op.send_init(ws));
|
||||||
});
|
});
|
||||||
|
|
||||||
app.get('/webcam/:printer', (req, res) => {
|
app.get('/webcam/:printer.m3u8', (req, res) => {
|
||||||
let printer: OctoPrintConnection | undefined = octoprintConnections.find(
|
const printer: OctoPrintConnection | undefined = octoprintConnections.find(
|
||||||
(op) => op.slug === req.params.printer
|
(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.');
|
} else res.status(404).send('Not Found: Printer not known or has no webcam.');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -3,11 +3,12 @@
|
|||||||
<h3 class="card-header" :data-color="color">
|
<h3 class="card-header" :data-color="color">
|
||||||
{{ name || 'Unknown' }}
|
{{ name || 'Unknown' }}
|
||||||
</h3>
|
</h3>
|
||||||
<img
|
<video
|
||||||
|
muted
|
||||||
class="card-img webcam"
|
class="card-img webcam"
|
||||||
|
ref="video"
|
||||||
:style="webcamTransform"
|
:style="webcamTransform"
|
||||||
:src="'/webcam/' + slug"
|
></video>
|
||||||
/>
|
|
||||||
<div class="card-body" v-if="status">
|
<div class="card-body" v-if="status">
|
||||||
<div>{{ status.state.text }}</div>
|
<div>{{ status.state.text }}</div>
|
||||||
<div>
|
<div>
|
||||||
@ -48,7 +49,8 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<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 prettyMilliseconds from 'pretty-ms';
|
||||||
|
|
||||||
import { CurrentOrHistoryPayload } from '../types/octoprint';
|
import { CurrentOrHistoryPayload } from '../types/octoprint';
|
||||||
@ -67,6 +69,33 @@ interface Props {
|
|||||||
export type PrinterInfo = Omit<Props, 'slug' | 'now'>;
|
export type PrinterInfo = Omit<Props, 'slug' | 'now'>;
|
||||||
|
|
||||||
const props = defineProps<Props>();
|
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 {
|
function formatDuration(seconds: number): string {
|
||||||
return prettyMilliseconds(seconds * 1000);
|
return prettyMilliseconds(seconds * 1000);
|
||||||
@ -104,6 +133,15 @@ const webcamTransform = computed(() => {
|
|||||||
return {};
|
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>
|
</script>
|
||||||
|
|
||||||
<style lang="scss">
|
<style lang="scss">
|
||||||
|
Loading…
Reference in New Issue
Block a user