web_kbn: basic support of cams hls streaming

This commit is contained in:
Evgeny Zinoviev 2024-02-18 02:19:27 +03:00
parent 4215537047
commit f14bdc6752
5 changed files with 89 additions and 151 deletions

View File

@ -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__':

View File

@ -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()

View File

@ -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))

View File

@ -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
View 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 %}