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
from io import StringIO
from aiohttp.web import HTTPFound
from aiohttp.web import HTTPFound, HTTPBadRequest
from typing import Optional, Union
from homekit.config import config, AppConfigUnit, is_development_mode, Translation
from homekit.camera import IpcamConfig
@ -30,6 +30,7 @@ class WebKbnConfig(AppConfigUnit):
'listen_addr': cls._addr_schema(required=True),
'assets_public_path': {'type': 'string'},
'pump_addr': cls._addr_schema(required=True),
'cam_hls_host': cls._addr_schema(required=True, only_ip=True),
'inverter_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}">'
def get_head_static() -> str:
def get_head_static(files=None) -> str:
buf = StringIO()
for file in STATIC_FILES:
if files is None:
files = []
for file in STATIC_FILES+files:
v = 2
try:
q_ind = file.index('?')
@ -214,12 +217,13 @@ class WebSite(http.HTTPServer):
req: http.Request,
template_name: str,
title: Optional[str] = None,
context: Optional[dict] = None):
context: Optional[dict] = None,
assets: Optional[list] = None):
if context is None:
context = {}
context = {
**context,
'head_static': get_head_static()
'head_static': get_head_static(assets)
}
if title is not None:
context['title'] = title
@ -363,7 +367,53 @@ class WebSite(http.HTTPServer):
context=dict(status=status))
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__':

View File

@ -138,6 +138,9 @@ class IpcamConfig(ConfigUnit):
def has_camera(self, camera: int) -> bool:
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:
return self.get_camera_type(camera).get_container()

View File

@ -121,6 +121,8 @@ def json_serial(obj):
return obj.value
if isinstance(obj, KeysView):
return list(obj)
if isinstance(obj, Addr):
return str(obj)
raise TypeError("Type %s not serializable" % type(obj))

View File

@ -122,9 +122,8 @@ function indexInit() {
}
}
window.Cameras = {
var Cameras = {
hlsOptions: null,
h265webjsOptions: null,
host: null,
proto: null,
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) {
this.proto = opts.proto;
this.host = opts.host;
this.hlsOptions = opts.hlsConfig;
this.h265webjsOptions = opts.h265webjsConfig;
var useHls;
if (opts.hlsConfig !== undefined) {
@ -315,33 +198,12 @@ window.Cameras = {
}
}
for (var camId in opts.camsByType) {
var name = camId + '';
if (opts.isLow)
name += '-low';
var type = opts.camsByType[camId];
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;
}
for (var i = 0; i < opts.cams.length; i++) {
var camId = opts.cams[i]+'-low';
var video = document.createElement('video');
video.setAttribute('id', 'video-'+camId);
document.getElementById('videos').appendChild(video);
this.setupHls(video, camId, useHls);
}
},

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