web_kbn: basic support of cams hls streaming
This commit is contained in:
parent
4215537047
commit
f14bdc6752
@ -10,7 +10,7 @@ import time
|
|||||||
import __py_include
|
import __py_include
|
||||||
|
|
||||||
from io import StringIO
|
from io import StringIO
|
||||||
from aiohttp.web import HTTPFound
|
from aiohttp.web import HTTPFound, HTTPBadRequest
|
||||||
from typing import Optional, Union
|
from typing import Optional, Union
|
||||||
from homekit.config import config, AppConfigUnit, is_development_mode, Translation
|
from homekit.config import config, AppConfigUnit, is_development_mode, Translation
|
||||||
from homekit.camera import IpcamConfig
|
from homekit.camera import IpcamConfig
|
||||||
@ -30,6 +30,7 @@ class WebKbnConfig(AppConfigUnit):
|
|||||||
'listen_addr': cls._addr_schema(required=True),
|
'listen_addr': cls._addr_schema(required=True),
|
||||||
'assets_public_path': {'type': 'string'},
|
'assets_public_path': {'type': 'string'},
|
||||||
'pump_addr': cls._addr_schema(required=True),
|
'pump_addr': cls._addr_schema(required=True),
|
||||||
|
'cam_hls_host': cls._addr_schema(required=True, only_ip=True),
|
||||||
'inverter_grafana_url': {'type': 'string'},
|
'inverter_grafana_url': {'type': 'string'},
|
||||||
'sensors_grafana_url': {'type': 'string'},
|
'sensors_grafana_url': {'type': 'string'},
|
||||||
}
|
}
|
||||||
@ -60,9 +61,11 @@ def get_css_link(file, version) -> str:
|
|||||||
return f'<link rel="stylesheet" type="text/css" href="{config.app_config["assets_public_path"]}/{file}">'
|
return f'<link rel="stylesheet" type="text/css" href="{config.app_config["assets_public_path"]}/{file}">'
|
||||||
|
|
||||||
|
|
||||||
def get_head_static() -> str:
|
def get_head_static(files=None) -> str:
|
||||||
buf = StringIO()
|
buf = StringIO()
|
||||||
for file in STATIC_FILES:
|
if files is None:
|
||||||
|
files = []
|
||||||
|
for file in STATIC_FILES+files:
|
||||||
v = 2
|
v = 2
|
||||||
try:
|
try:
|
||||||
q_ind = file.index('?')
|
q_ind = file.index('?')
|
||||||
@ -214,12 +217,13 @@ class WebSite(http.HTTPServer):
|
|||||||
req: http.Request,
|
req: http.Request,
|
||||||
template_name: str,
|
template_name: str,
|
||||||
title: Optional[str] = None,
|
title: Optional[str] = None,
|
||||||
context: Optional[dict] = None):
|
context: Optional[dict] = None,
|
||||||
|
assets: Optional[list] = None):
|
||||||
if context is None:
|
if context is None:
|
||||||
context = {}
|
context = {}
|
||||||
context = {
|
context = {
|
||||||
**context,
|
**context,
|
||||||
'head_static': get_head_static()
|
'head_static': get_head_static(assets)
|
||||||
}
|
}
|
||||||
if title is not None:
|
if title is not None:
|
||||||
context['title'] = title
|
context['title'] = title
|
||||||
@ -363,7 +367,53 @@ class WebSite(http.HTTPServer):
|
|||||||
context=dict(status=status))
|
context=dict(status=status))
|
||||||
|
|
||||||
async def cams(self, req: http.Request):
|
async def cams(self, req: http.Request):
|
||||||
pass
|
cc = IpcamConfig()
|
||||||
|
|
||||||
|
cam = req.query.get('id', None)
|
||||||
|
zone = req.query.get('zone', None)
|
||||||
|
debug_hls = bool(req.query.get('debug_hls', False))
|
||||||
|
debug_video_events = bool(req.query.get('debug_video_events', False))
|
||||||
|
|
||||||
|
if cam is not None:
|
||||||
|
if not cc.has_camera(int(cam)):
|
||||||
|
raise ValueError('invalid camera id')
|
||||||
|
cams = [int(cam)]
|
||||||
|
mode = {'type': 'single', 'cam': cam}
|
||||||
|
|
||||||
|
elif zone is not None:
|
||||||
|
if not cc.has_zone(zone):
|
||||||
|
raise ValueError('invalid zone')
|
||||||
|
cams = cc['zones'][zone]
|
||||||
|
mode = {'type': 'zone', 'zone': zone}
|
||||||
|
|
||||||
|
else:
|
||||||
|
raise HTTPBadRequest(text='no camera id or zone found')
|
||||||
|
|
||||||
|
js_config = {
|
||||||
|
'host': config.app_config['cam_hls_host'],
|
||||||
|
'proto': 'http',
|
||||||
|
'cams': cams,
|
||||||
|
'hlsConfig': {
|
||||||
|
'opts': {
|
||||||
|
'startPosition': -1,
|
||||||
|
# https://github.com/video-dev/hls.js/issues/3884#issuecomment-842380784
|
||||||
|
'liveSyncDuration': 2,
|
||||||
|
'liveMaxLatencyDuration': 3,
|
||||||
|
'maxLiveSyncPlaybackRate': 2,
|
||||||
|
'liveDurationInfinity': True
|
||||||
|
},
|
||||||
|
'debugVideoEvents': debug_video_events,
|
||||||
|
'debug': debug_hls
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return await self.render_page(req, 'cams',
|
||||||
|
title='Камеры',
|
||||||
|
assets=['hls.js'],
|
||||||
|
context=dict(
|
||||||
|
mode=mode,
|
||||||
|
js_config=js_config,
|
||||||
|
))
|
||||||
|
|
||||||
|
|
||||||
if __name__ == '__main__':
|
if __name__ == '__main__':
|
||||||
|
@ -138,6 +138,9 @@ class IpcamConfig(ConfigUnit):
|
|||||||
def has_camera(self, camera: int) -> bool:
|
def has_camera(self, camera: int) -> bool:
|
||||||
return camera in tuple(self['cameras'].keys())
|
return camera in tuple(self['cameras'].keys())
|
||||||
|
|
||||||
|
def has_zone(self, zone: str) -> bool:
|
||||||
|
return zone in tuple(self['zones'].keys())
|
||||||
|
|
||||||
def get_camera_container(self, camera: int) -> VideoContainerType:
|
def get_camera_container(self, camera: int) -> VideoContainerType:
|
||||||
return self.get_camera_type(camera).get_container()
|
return self.get_camera_type(camera).get_container()
|
||||||
|
|
||||||
|
@ -121,6 +121,8 @@ def json_serial(obj):
|
|||||||
return obj.value
|
return obj.value
|
||||||
if isinstance(obj, KeysView):
|
if isinstance(obj, KeysView):
|
||||||
return list(obj)
|
return list(obj)
|
||||||
|
if isinstance(obj, Addr):
|
||||||
|
return str(obj)
|
||||||
raise TypeError("Type %s not serializable" % type(obj))
|
raise TypeError("Type %s not serializable" % type(obj))
|
||||||
|
|
||||||
|
|
||||||
|
@ -122,9 +122,8 @@ function indexInit() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
window.Cameras = {
|
var Cameras = {
|
||||||
hlsOptions: null,
|
hlsOptions: null,
|
||||||
h265webjsOptions: null,
|
|
||||||
host: null,
|
host: null,
|
||||||
proto: null,
|
proto: null,
|
||||||
hlsDebugVideoEvents: false,
|
hlsDebugVideoEvents: false,
|
||||||
@ -185,126 +184,10 @@ window.Cameras = {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
setupH265WebJS: function(videoContainer, name) {
|
|
||||||
var containerHeightFixed = false;
|
|
||||||
var config = {
|
|
||||||
player: 'video-'+name,
|
|
||||||
width: videoContainer.offsetWidth,
|
|
||||||
height: parseInt(videoContainer.offsetWidth * 9 / 16, 10),
|
|
||||||
accurateSeek: true,
|
|
||||||
token: this.h265webjsOptions.token,
|
|
||||||
extInfo: {
|
|
||||||
moovStartFlag: true,
|
|
||||||
readyShow: true,
|
|
||||||
autoPlay: true,
|
|
||||||
rawFps: 15,
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
var mediaInfo;
|
|
||||||
var player = window.new265webjs(this.getUrl(name), config);
|
|
||||||
|
|
||||||
player.onSeekStart = (pts) => {
|
|
||||||
console.log(name + ": onSeekStart:" + pts);
|
|
||||||
};
|
|
||||||
|
|
||||||
player.onSeekFinish = () => {
|
|
||||||
console.log(name + ": onSeekFinish");
|
|
||||||
};
|
|
||||||
|
|
||||||
player.onPlayFinish = () => {
|
|
||||||
console.log(name + ": onPlayFinish");
|
|
||||||
};
|
|
||||||
|
|
||||||
player.onRender = (width, height, imageBufferY, imageBufferB, imageBufferR) => {
|
|
||||||
// console.log(name + ": onRender");
|
|
||||||
if (!containerHeightFixed) {
|
|
||||||
var ratio = height / width;
|
|
||||||
videoContainer.style.width = parseInt(videoContainer.offsetWidth * ratio, 10)+'px';
|
|
||||||
containerHeightFixed = true;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
player.onOpenFullScreen = () => {
|
|
||||||
console.log(name + ": onOpenFullScreen");
|
|
||||||
};
|
|
||||||
|
|
||||||
player.onCloseFullScreen = () => {
|
|
||||||
console.log(name + ": onCloseFullScreen");
|
|
||||||
};
|
|
||||||
|
|
||||||
player.onSeekFinish = () => {
|
|
||||||
console.log(name + ": onSeekFinish");
|
|
||||||
};
|
|
||||||
|
|
||||||
player.onLoadCache = () => {
|
|
||||||
console.log(name + ": onLoadCache");
|
|
||||||
};
|
|
||||||
|
|
||||||
player.onLoadCacheFinshed = () => {
|
|
||||||
console.log(name + ": onLoadCacheFinshed");
|
|
||||||
};
|
|
||||||
|
|
||||||
player.onReadyShowDone = () => {
|
|
||||||
// console.log(name + ": onReadyShowDone:【You can play now】");
|
|
||||||
player.play()
|
|
||||||
};
|
|
||||||
|
|
||||||
player.onLoadFinish = () => {
|
|
||||||
console.log(name + ": onLoadFinish");
|
|
||||||
|
|
||||||
player.setVoice(1.0);
|
|
||||||
|
|
||||||
mediaInfo = player.mediaInfo();
|
|
||||||
console.log("onLoadFinish mediaInfo===========>", mediaInfo);
|
|
||||||
|
|
||||||
var codecName = "h265";
|
|
||||||
if (mediaInfo.meta.isHEVC === false) {
|
|
||||||
console.log(name + ": onLoadFinish is Not HEVC/H.265");
|
|
||||||
codecName = "h264";
|
|
||||||
} else {
|
|
||||||
console.log(name + ": onLoadFinish is HEVC/H.265");
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log(name + ": onLoadFinish media Codec:" + codecName);
|
|
||||||
console.log(name + ": onLoadFinish media FPS:" + mediaInfo.meta.fps);
|
|
||||||
console.log(name + ": onLoadFinish media size:" + mediaInfo.meta.size.width + "x" + mediaInfo.meta.size.height);
|
|
||||||
|
|
||||||
if (mediaInfo.meta.audioNone) {
|
|
||||||
console.log(name + ": onLoadFinish media no Audio");
|
|
||||||
} else {
|
|
||||||
console.log(name + ": onLoadFinish media sampleRate:" + mediaInfo.meta.sampleRate);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (mediaInfo.videoType == "vod") {
|
|
||||||
console.log(name + ": onLoadFinish media is VOD");
|
|
||||||
console.log(name + ": onLoadFinish media dur:" + Math.ceil(mediaInfo.meta.durationMs) / 1000.0);
|
|
||||||
} else {
|
|
||||||
console.log(name + ": onLoadFinish media is LIVE");
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
player.onCacheProcess = (cPts) => {
|
|
||||||
console.log(name + ": onCacheProcess:" + cPts);
|
|
||||||
};
|
|
||||||
|
|
||||||
player.onPlayTime = (videoPTS) => {
|
|
||||||
if (mediaInfo.videoType == "vod") {
|
|
||||||
console.log(name + ": onPlayTime:" + videoPTS);
|
|
||||||
} else {
|
|
||||||
// LIVE
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
player.do();
|
|
||||||
// console.log('setupH265WebJS: video: ', video.offsetWidth, video.offsetHeight)
|
|
||||||
},
|
|
||||||
|
|
||||||
init: function(opts) {
|
init: function(opts) {
|
||||||
this.proto = opts.proto;
|
this.proto = opts.proto;
|
||||||
this.host = opts.host;
|
this.host = opts.host;
|
||||||
this.hlsOptions = opts.hlsConfig;
|
this.hlsOptions = opts.hlsConfig;
|
||||||
this.h265webjsOptions = opts.h265webjsConfig;
|
|
||||||
|
|
||||||
var useHls;
|
var useHls;
|
||||||
if (opts.hlsConfig !== undefined) {
|
if (opts.hlsConfig !== undefined) {
|
||||||
@ -315,33 +198,12 @@ window.Cameras = {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
for (var camId in opts.camsByType) {
|
for (var i = 0; i < opts.cams.length; i++) {
|
||||||
var name = camId + '';
|
var camId = opts.cams[i]+'-low';
|
||||||
if (opts.isLow)
|
var video = document.createElement('video');
|
||||||
name += '-low';
|
video.setAttribute('id', 'video-'+camId);
|
||||||
var type = opts.camsByType[camId];
|
document.getElementById('videos').appendChild(video);
|
||||||
|
this.setupHls(video, camId, useHls);
|
||||||
switch (type) {
|
|
||||||
case 'h265':
|
|
||||||
var videoContainer = document.createElement('div');
|
|
||||||
videoContainer.setAttribute('id', 'video-'+name);
|
|
||||||
videoContainer.setAttribute('style', 'position: relative'); // a hack to fix an error in h265webjs lib
|
|
||||||
videoContainer.className = 'video-container';
|
|
||||||
document.getElementById('videos').appendChild(videoContainer);
|
|
||||||
try {
|
|
||||||
this.setupH265WebJS(videoContainer, name);
|
|
||||||
} catch (e) {
|
|
||||||
console.error('cam'+camId+': error', e)
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
|
|
||||||
case 'h264':
|
|
||||||
var video = document.createElement('video');
|
|
||||||
video.setAttribute('id', 'video-'+name);
|
|
||||||
document.getElementById('videos').appendChild(video);
|
|
||||||
this.setupHls(video, name, useHls);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
21
web/kbn_templates/cams.j2
Normal file
21
web/kbn_templates/cams.j2
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
{% extends "base.j2" %}
|
||||||
|
{% block content %}
|
||||||
|
{{ breadcrumbs([{'text': 'Камеры'}]) }}
|
||||||
|
|
||||||
|
{#<nav>#}
|
||||||
|
{# <div class="nav nav-tabs" id="nav-tab">#}
|
||||||
|
{# <a href="/cams/{{ camera_param ? camera_param~"/" : "" }}" class="text-decoration-none"><button class="nav-link{% if tab == 'low' %} active{% endif %}" type="button">Low-res</button></a>#}
|
||||||
|
{# <a href="/cams/{{ camera_param ? camera_param~"/" : "" }}?high=1" class="text-decoration-none"><button class="nav-link{% if tab == 'high' %} active{% endif %}" type="button">High-res</button></a>#}
|
||||||
|
{# </div>#}
|
||||||
|
{#</nav>#}
|
||||||
|
|
||||||
|
<div id="videos" class="camfeeds"></div>
|
||||||
|
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block js %}
|
||||||
|
if (isTouchDevice()) {
|
||||||
|
addClass(ge('videos'), 'is_mobile');
|
||||||
|
}
|
||||||
|
Cameras.init({{ js_config|tojson }});
|
||||||
|
{% endblock %}
|
Loading…
x
Reference in New Issue
Block a user