From 1c35eec004975b0ee838193407c2c94d327348aa Mon Sep 17 00:00:00 2001 From: Georgi Valkov Date: Sat, 14 Feb 2026 15:09:12 +0200 Subject: [PATCH] luci-app-ustreamer: complete rewrite New features: - Implement all supported ustreamer features - Detailed UI text and description based on the program help - Input validation for all parameters - Stream preview with link to the stream page - Dark theme colours for the stream preview - Bulgarian translation (complete) Bug fixes: - Use of poll.add inside the render function results in a fork bomb - Repeated use if setTimeout results in a fork bomb when the stream is not available (old bug from luci-app-mjpg-streamer) Merge: - I tried to keep existing translations as much as possible - All existing features, except [video_devs] Removed: - [video_devs] parameters, this or a similar feature will be implemented once I fully test it, and choose an optimal strategy, with support for multiple video input devices. In order to comlpete work on this feature, I need programatic access to the configuration name for each instance: config ustreamer 'configuration_name' Formatting: - Format code for readability and to fit 80 column where possible Notes: The values for image control varies between camera models, therefore the range is unrestricted. Due to a race condition, two instances of the package got created. I put a lot of effort and testing in every single detail, and the other implementation got merged first. All features and translations are merged here, except for [video_devs], which will be reworked later. Signed-off-by: Georgi Valkov Closes #8324 Link: https://github.com/openwrt/luci/pull/8324/ Signed-off-by: Paul Donald --- applications/luci-app-ustreamer/Makefile | 8 +- .../resources/view/ustreamer/ustreamer.js | 963 ++++++++++++------ .../share/luci/menu.d/luci-app-ustreamer.json | 2 +- 3 files changed, 629 insertions(+), 344 deletions(-) diff --git a/applications/luci-app-ustreamer/Makefile b/applications/luci-app-ustreamer/Makefile index b161b27701..30ab6cb6f7 100644 --- a/applications/luci-app-ustreamer/Makefile +++ b/applications/luci-app-ustreamer/Makefile @@ -1,18 +1,16 @@ # -# Copyright (C) 2008-2014 The LuCI Team +# Copyright (C) 2008-2026 The LuCI Team # # This is free software, licensed under the Apache License, Version 2.0 . # include $(TOPDIR)/rules.mk -LUCI_TITLE:=ustreamer service configuration module +LUCI_TITLE:=uStreamer service configuration module LUCI_DEPENDS:=+luci-base +ustreamer PKG_LICENSE:=Apache-2.0 -PKG_MAINTAINER:=Jo-Philipp Wich - -PROVIDES:=luci-app-mjpeg-streamer +PKG_MAINTAINER:=Georgi Valkov include ../../luci.mk diff --git a/applications/luci-app-ustreamer/htdocs/luci-static/resources/view/ustreamer/ustreamer.js b/applications/luci-app-ustreamer/htdocs/luci-static/resources/view/ustreamer/ustreamer.js index cc98bed3fd..4381826d19 100644 --- a/applications/luci-app-ustreamer/htdocs/luci-static/resources/view/ustreamer/ustreamer.js +++ b/applications/luci-app-ustreamer/htdocs/luci-static/resources/view/ustreamer/ustreamer.js @@ -1,89 +1,215 @@ 'use strict'; 'require form'; -'require fs'; 'require poll'; 'require uci'; 'require ui'; 'require view'; -/* Licensed to the public under the Apache License 2.0. */ + +/* Licensed to the public under the Apache License 2.0. */ return view.extend({ load() { + let self = this; + self.stream = { ts: new Date().getTime(), timer: 0 }; + + poll.add(function () { + self.render(); + }, 5); + document .querySelector('head') .appendChild( E('style', { type: 'text/css' }, [ '.img-preview {display: inline-block !important;height: auto;width: 640px;padding: 4px;line-height: 1.428571429;background-color: #fff;border: 1px solid #ddd;border-radius: 4px;-webkit-transition: all .2s ease-in-out;transition: all .2s ease-in-out;margin-bottom: 5px;display: none;}', + '@media (prefers-color-scheme: dark){ .img-preview {background-color: #222;border-color: #333;} }', ]), ); return Promise.all([ - L.resolveDefault(fs.list('/dev/'), []).then(entries => entries.filter(e => /^video.*$/.test(e.name)) ), uci.load('ustreamer'), ]); }, - render([video_devs]) { + + render() { let m, s, o; + let stream = this.stream; - let self = this; - poll.add(() => { - self.load().then(([video_devs]) => { - self.render([video_devs]); - }); - }, 5); + m = new form.Map( + 'ustreamer', 'µStreamer', _('Lightweight and fast MJPEG-HTTP streamer') + ); - m = new form.Map('ustreamer', 'ustreamer', - _('µStreamer is a lightweight and very quick server to stream MJPEG video from any V4L2 device to the net.')); - //General settings + // preview - const section_gen = m.section(form.TypedSection, 'ustreamer', _('General')); - section_gen.addremove = false; - section_gen.anonymous = true; + function stream_url() { + let login = ''; + let user = uci.get('ustreamer', 'video0', 'user'); + let pass = uci.get('ustreamer', 'video0', 'pass'); + let port = uci.get('ustreamer', 'video0', 'port'); - const enabled = section_gen.option(form.Flag, 'enabled', _('Enabled')); + if (port == undefined) { + port = '8080'; + } - const log_level = section_gen.option(form.Value, 'log_level', _('Log level')); - log_level.placeholder = _('info'); - log_level.value('0', _('info')); - log_level.value('1', _('performance')); - log_level.value('2', _('verbose')); - log_level.value('3', _('debug')); + if (user != undefined) { + if (pass == undefined) { + login = user + '@'; + } + else { + login = user + ':' + pass + '@'; + } + } - //Plugin settings + return 'http://' + login + location.hostname + ':' + port + '/'; + } - s = m.section(form.TypedSection, 'ustreamer', _('Plugin settings')); - s.addremove = true; + const url = stream_url(); + + const stream_link = E('a', { + 'id': 'stream_link', + 'target': '_blank', + 'href': url, + }, + _('Preview'), + ); + + const s_preview = m.section(form.TypedSection, 'ustreamer', stream_link); + s_preview.addremove = false; + s_preview.anonymous = true; + + function _start_stream() { + let ts = new Date().getTime(); + let dt = ts - stream.ts; + stream.ts = ts; + stream.timer = 0; + console.log('_start_stream ' + dt); + + let img = document.getElementById('video_preview') || video_preview; + img.src = url + 'snapshot' + '?t=' + new Date().getTime(); + } + + function start_stream() { + if (stream.timer) { + return; + } + + stream.timer = setTimeout(function () { + _start_stream(); + }, 500); + } + + function on_error() { + console.log('on_error'); + + let img = document.getElementById('video_preview') || video_preview; + img.style.display = 'none'; + + let status = document.getElementById('stream_status') || stream_status; + status.style.display = 'block'; + + let enabled = uci.get('ustreamer', 'video0', 'enabled'); + + if (enabled) { + start_stream(); + } + } + + function on_load() { + console.log('on_load'); + + let img = document.getElementById('video_preview') || video_preview; + img.style.display = 'block'; + + let status = document.getElementById('stream_status') || stream_status; + status.style.display = 'none'; + } + + // HTTP preview + const video_preview = E('img', { + 'id': 'video_preview', + 'class': 'img-preview', + 'error': on_error, + 'load': on_load, + } + ); + + const stream_status = E('p', { + 'id': 'stream_status', + 'style': 'text-align: center; color: orange; font-weight: bold;', + }, + _('Stream unavailable'), + ); + + start_stream(); + + const preview = s_preview.option(form.DummyValue, '_dummy'); + + preview.render = L.bind(function (view, section_id) { + return E([], [ + video_preview, + stream_status + ]); + }, preview, this); + + preview.depends('enabled', '1'); + + + // settings + + s = m.section(form.TypedSection, 'ustreamer', _('Settings')); + s.addremove = false; s.anonymous = true; - s.tab('h264_sink', _('H264 sink')); - s.tab('output_http', _('HTTP output')); + s.tab('general', _('General')); + s.tab('capture', _('Capture')); + s.tab('server_http', _('HTTP server')); + s.tab('sink_jpeg', _('JPEG sink')); + s.tab('sink_raw', _('RAW sink')); + s.tab('sink_h264', _('H264 sink')); + s.tab('logging', _('Logging')); s.tab('image_control', _('Image control')); - s.tab('jpeg_sink', _('JPEG sink')); - s.tab('raw_sink', _('RAW sink')); - s.tab('input_uvc', _('UVC input')); - // Input UVC settings - let this_tab = 'input_uvc'; + // general - const device = s.taboption(this_tab, form.Value, 'device', _('Device')); - device.placeholder = '/dev/video0'; - for (const dev of video_devs) - device.value(`/dev/${dev.name}`); - device.optional = false; - device.rmempty = false; + let this_tab = 'general'; - const dtimeout = s.taboption(this_tab, form.Value, 'timeout', _('Timeout'), _('units: seconds')); - dtimeout.placeholder = '5'; - dtimeout.datatype = 'uinteger'; + const enabled = s.taboption( + this_tab, form.Flag, 'enabled', _('Enabled'), _('Enable µStreamer') + ); - const input = s.taboption(this_tab, form.Flag, 'input', _('Input')); - input.default = input.disabled; + const device = s.taboption( + this_tab, form.Value, 'device', _('Device'), + _('Path to V4L2 device. Default: /dev/video0')); - const resolution = s.taboption(this_tab, form.Value, 'resolution', _('Resolution')); - resolution.placeholder = '640x480'; + device.default = '/dev/video0'; + device.value('/dev/video0', '/dev/video0'); + device.value('/dev/video1', '/dev/video1'); + device.value('/dev/video2', '/dev/video2'); + device.optional = true; + + const device_timeout = s.taboption( + this_tab, form.Value, 'device_timeout', _('Device timeout'), + _('Timeout for device querying. Default: 1 second')); + + device_timeout.datatype = 'and(uinteger, range(0, 60))'; + device_timeout.placeholder = '5'; + device_timeout.optional = true; + + const input = s.taboption( + this_tab, form.Value, 'input', _('Input'), _('Input channel. Default: 0') + ); + + input.datatype = 'and(uinteger, range(0, 128))'; + input.placeholder = '0'; + input.optional = true; + + const resolution = s.taboption( + this_tab, form.Value, 'resolution', _('Resolution'), + _('Initial image resolution. Default: 640x480')); + + resolution.default = '640x480'; resolution.value('320x240', '320x240'); resolution.value('640x480', '640x480'); resolution.value('800x600', '800x600'); @@ -95,433 +221,594 @@ return view.extend({ resolution.value('1920x1080', '1920x1080'); resolution.optional = true; - const fps = s.taboption(this_tab, form.Value, 'desired_fps', _('Frames per second'), - _('Default: maximum possible.')); - fps.datatype = 'and(uinteger, min(1))'; - fps.placeholder = '5'; + const fps = s.taboption( + this_tab, form.Value, 'desired_fps', _('Frames per second'), + _('Desired FPS. Default: maximum possible')); + + fps.datatype = 'and(uinteger, min(0))'; + fps.placeholder = '0'; fps.optional = true; - const format = s.taboption(this_tab, form.Value, 'format', _('Format')); - format.placeholder = 'YUYV'; - format.value('BGR24'); - format.value('GREY'); - format.value('JPEG'); - format.value('MJPEG'); - format.value('RGB24'); - format.value('RGB565'); - format.value('UYVY'); - format.value('YUV420'); - format.value('YUYV'); - format.value('YVU420'); - format.value('YVYU'); + const slowdown = s.taboption( + this_tab, form.Flag, 'slowdown', _('Slowdown'), + _('Slowdown capturing to 1 FPS or less when no stream or sink clients are connected.') + '
' + + _('Useful to reduce CPU consumption.') + ' ' + _('Default: disabled')); - const encoder = s.taboption(this_tab, form.Value, 'encoder', _('Encoder')); - encoder.value('CPU'); - encoder.value('HW'); + const format = s.taboption( + this_tab, form.Value, 'format', _('Image format'), _('Default: YUYV') + ); + + format.default = 'YUYV'; + format.value('YUYV', 'YUYV'); + format.value('YVYU', 'YVYU'); + format.value('UYVY', 'UYVY'); + format.value('YUV420', 'YUV420'); + format.value('YVU420', 'YVU420'); + format.value('RGB565', 'RGB565'); + format.value('RGB24', 'RGB24'); + format.value('BGR24', 'BGR24'); + format.value('GREY', 'GREY'); + format.value('MJPEG', 'MJPEG'); + format.value('JPEG', 'JPEG'); + format.optional = true; + + const encoder = s.taboption( + this_tab, form.Value, 'encoder', _('Еncoder'), + _('Use specified encoder. It may affect the number of workers') + '
' + + '
  • CPU ──────── ' + _('Software MJPEG encoding (default)') + '
  • ' + + '
  • HW ───────── ' + _('Use pre-encoded MJPEG frames directly from camera hardware') + '
  • ' + + '
  • M2M-VIDEO ── ' + _('GPU-accelerated MJPEG encoding using V4L2 M2M video interface') + '
  • ' + + '
  • M2M-IMAGE ── ' + _('GPU-accelerated JPEG encoding using V4L2 M2M image interface') + '
  • ' + ); + + encoder.default = 'CPU'; + encoder.value('CPU', 'CPU'); + encoder.value('HW', 'HW'); + encoder.value('M2M-VIDEO', 'M2M-VIDEO'); + encoder.value('M2M-IMAGE', 'M2M-IMAGE'); + encoder.optional = true; const quality = s.taboption( - this_tab, - form.Value, - 'quality', - _('Quality'), - _('Set the quality in percent.'), - ); - quality.datatype = 'range(0, 100)'; + this_tab, form.Value, 'quality', _('Quality'), + _('Set the quality of JPEG encoding: 1 to 100 (best). Default: 80') + '
    ' + + _("If HW encoding is used (JPEG source format), attempts to configure the camera or capture device hardware's internal encoder.") + '
    ' + + _('MJPEG will not be recoded to MJPEG to change the quality')); + + quality.datatype = 'and(uinteger, range(0, 100))'; + quality.placeholder = '0'; + quality.optional = true; + + const host = s.taboption( + this_tab, form.Value, 'host', _('Host'), + _('Listen on Hostname or IP. Default: 127.0.0.1')); + + host.datatype = 'or(ip4addr, ip6addr, host)'; + host.placeholder = '::'; + host.optional = true; + + const port = s.taboption( + this_tab, form.Value, 'port', _('Port'), + _('Bind to this TCP port.') + ' ' + _(' Default: 8080')); + + port.datatype = 'port'; + port.placeholder = '8080'; + port.optional = true; + + const user = s.taboption( + this_tab, form.Value, 'user', _('Username'), + _('HTTP basic auth user.') + ' ' + _('Default: disabled')); + + user.datatype = 'string'; + user.placeholder = ''; + user.optional = true; + + const pass = s.taboption( + this_tab, form.Value, 'pass', _('Password'), + _('HTTP basic auth passwd.') + ' ' + _('Default: empty')); + + pass.datatype = 'string'; + pass.placeholder = ''; + pass.password = true; + pass.optional = true; - const allow_truncated_frames = s.taboption(this_tab, form.Flag, 'allow_truncated_frames', _('Allow truncated frames')); - allow_truncated_frames.default = allow_truncated_frames.disabled; + // capture - const format_swap_rgb = s.taboption(this_tab, form.Flag, 'format_swap_rgb', _('Format: Swap RGB'), - _('Enable R-G-B order swapping: RGB to BGR and vice versa.')); - format_swap_rgb.default = format_swap_rgb.disabled; + this_tab = 'capture'; - const persistent = s.taboption(this_tab, form.Flag, 'persistent', _('Persistent'), - _("Don't re-initialize device on timeout. Default: disabled.")); - persistent.default = persistent.disabled; + const allow_truncated_frames = s.taboption( + this_tab, form.Flag, 'allow_truncated_frames', + _('Allow truncated frames'), + _('Allows to handle truncated frames.') + ' ' + _('Default: disabled') + '
    ' + + _('Useful if the device produces incorrect but still acceptable frames')); - const dv_timings = s.taboption(this_tab, form.Flag, 'dv_timings', _('DV Timings'), - _("Enable DV-timings querying and events processing to automatic resolution change. Default: disabled.")); - dv_timings.default = dv_timings.disabled; + const format_swap_rgb = s.taboption( + this_tab, form.Flag, 'format_swap_rgb', _('R-G-B order swap'), + _('RGB to BGR and vice versa.') + ' ' + _('Default: disabled')); - const tv_standard = s.taboption(this_tab, form.Value, 'tv_standard', _('TV standard')); - tv_standard.placeholder = ''; - tv_standard.value('PAL'); - tv_standard.value('NTSC'); - tv_standard.value('SECAM'); + const persistent = s.taboption( + this_tab, form.Flag, 'persistent', _('Persistent'), + _("Don't re-initialize device on timeout.") + ' ' + _('Default: disabled')); - const io_method = s.taboption(this_tab, form.Value, 'io_method', _('IO method')); - io_method.placeholder = 'MMAP'; - io_method.value('MMAP'); - io_method.value('USERPTR'); + const dv_timings = s.taboption( + this_tab, form.Flag, 'dv_timings', _('DV Timings'), + _('Enable DV Timings querying and events processing to automatic resolution change') + '
    ' + + _('Default: disabled')); - const buffers = s.taboption(this_tab, form.Value, 'buffers', _('Buffers'), - _('The number of buffers to receive data from the device.') + '
    ' + - _('Each buffer may processed using an independent thread.') + '
    ' + - _('Default: 3 (the number of CPU cores (but not more than 4) + 1).')); - buffers.datatype = 'and(uinteger, min(1))'; + const tv_standard = s.taboption( + this_tab, form.ListValue, 'tv_standard', _('Force TV standard'), + _('Default: disabled')); + + tv_standard.default = ''; + tv_standard.value('', _('default')); + tv_standard.value('PAL', 'PAL'); + tv_standard.value('NTSC', 'NTSC'); + tv_standard.value('SECAM', 'SECAM'); + tv_standard.optional = true; + + const io_method = s.taboption( + this_tab, form.ListValue, 'io_method', _('V4L2 IO method'), + _('Changing this parameter may increase the performance. Or not.') + '
    ' + + _('See kernel documentation. Default: MMAP')); + + io_method.default = ''; + io_method.value('', _('default')); + io_method.value('MMAP', 'MMAP'); + io_method.value('USERPTR', 'USERPTR'); + io_method.optional = true; + + const buffers = s.taboption( + this_tab, form.Value, 'buffers', _('Buffers'), + _('The number of buffers to receive data from the device.') + '
    ' + + _('Each buffer may be processed using an independent thread.') + '
    ' + + _('Default: 3 (the number of CPU cores (but not more than 4) + 1)')); + + buffers.datatype = 'and(uinteger, range(0, 32))'; buffers.placeholder = '3'; buffers.optional = true; - const workers = s.taboption(this_tab, form.Value, 'workers', _('Workers'), - _('The number of worker threads but not more than buffers.') + '
    ' + - _('Default: 2 (the number of CPU cores (but not more than 4)).')); - workers.datatype = 'and(uinteger, min(1))'; + const workers = s.taboption( + this_tab, form.Value, 'workers', _('Workers'), + _('The number of worker threads but not more than buffers.') + '
    ' + + _('Default: 2 (the number of CPU cores (but not more than 4))')); + + workers.datatype = 'and(uinteger, range(0, 32))'; workers.placeholder = '2'; workers.optional = true; - const m2m_device = s.taboption(this_tab, form.FileUpload, 'm2m_device', _('M2M device')); + const m2m_device = s.taboption( + this_tab, form.FileUpload, 'm2m_device', _('M2M device'), + _('Path to V4L2 M2M encoder device. Default: auto select')); + m2m_device.root_directory = '/dev'; - m2m_device.show_hidden = true; m2m_device.directory_create = false; m2m_device.enable_download = false; m2m_device.enable_upload = false; m2m_device.enable_remove = false; + m2m_device.show_hidden = true; m2m_device.optional = true; m2m_device.datatype = 'file'; const min_frame_size = s.taboption( - this_tab, - form.Value, - 'min_frame_size', - _('Drop frames smaller than this limit'), - _('Set the minimum size if the webcam produces small-sized garbage frames. May happen under low light conditions'), - ); - min_frame_size.datatype = 'uinteger'; - min_frame_size.placeholder = '128'; + this_tab, form.Value, 'min_frame_size', _('Min frame size'), + _('Drop frames smaller than this limit. Useful if the device') + '
    ' + + _('produces small-sized garbage frames.') + ' ' + _('Default: 128 bytes')); - const device_error_delay = s.taboption(this_tab, form.Value, 'device_error_delay', _('Device error delay')); - device_error_delay.datatype = 'and(uinteger, min(1))'; + min_frame_size.datatype = 'and(uinteger, range(0, 8192))'; + min_frame_size.placeholder = '128'; + min_frame_size.optional = true; + + const device_error_delay = s.taboption( + this_tab, form.Value, 'device_error_delay', _('Device error delay'), _( + 'Delay before trying to connect to the device again after an error (timeout for example).') + '
    ' + + _('Default: 1 second')); + + device_error_delay.datatype = 'and(uinteger, range(0, 60))'; device_error_delay.placeholder = '1'; device_error_delay.optional = true; - // Output HTTP settings - this_tab = 'output_http'; + // HTTP server - const host = s.taboption(this_tab, form.Value, 'host', _('Host'), _('TCP host for this HTTP server')); - host.datatype = 'host'; - host.placeholder = '::'; - host.datatype = 'or(hostname,ipaddr)'; - host.optional = false; + this_tab = 'server_http'; - const port = s.taboption(this_tab, form.Value, 'port', _('Port'), _('TCP port for this HTTP server')); - port.datatype = 'port'; - port.placeholder = '8080'; - port.optional = false; + const tcp_nodelay = s.taboption( + this_tab, form.Flag, 'tcp_nodelay', _('TCP no delay'), + _('Set TCP_NODELAY flag to the client /stream socket. Only for TCP socket') + '
    ' + + _('Default: disabled')); - const enable_auth = s.taboption(this_tab, form.Flag, 'enable_auth', _('Authentication required'), _('Ask for username and password on connect')); - enable_auth.default = false; + const www = s.taboption( + this_tab, form.Value, 'static', _('WWW folder'), + _('Path to dir with static files instead of embedded root index page.') + '
    ' + + _('Symlinks are not supported for security reasons.') + ' ' + _('Default: disabled')); - const username = s.taboption(this_tab, form.Value, 'user', _('Username')); - username.depends('enable_auth', '1'); - username.optional = false; + www.datatype = 'directory'; + www.placeholder = '/www/webcam'; + www.optional = true; - const password = s.taboption(this_tab, form.Value, 'pass', _('Password')); - password.depends('enable_auth', '1'); - password.password = true; - password.optional = false; - password.default = false; + const unix = s.taboption( + this_tab, form.Value, 'unix', _('UNIX socket'), + _('Bind to UNIX domain socket.') + ' ' + _('Default: disabled')); - const staticres = s.taboption(this_tab, form.Value, 'static', _('WWW folder'), _('Folder that contains webpages')); - staticres.datatype = 'directory'; - staticres.placeholder = '/www/webcam/'; - staticres.optional = false; - - const unix = s.taboption(this_tab, form.Value, 'unix', _('Socket'), _('Folder that contains the socket')); unix.datatype = 'file'; unix.placeholder = '/path/to/socket'; + unix.optional = true; - const unix_mode = s.taboption(this_tab, form.Value, 'unix_mode', _('Socket Permissions')); - unix_mode.datatype = 'string'; + const unix_rm = s.taboption( + this_tab, form.Flag, 'unix_rm', _('UNIX socket remove old'), + _('Try to remove old UNIX socket file before binding.') + ' ' + _('Default: disabled')); + + function validate_file_mode (section_id, value) { + if (!value || /^[0-7]{3,4}$/.test(value)) return true; + return _('Expecting: file mode, e.g. 640 or 0640'); + } + + const unix_mode = s.taboption( + this_tab, form.Value, 'unix_mode', _('UNIX socket permissions'), + _('Set UNIX socket file permissions (like 777).') + ' ' + _('Default: disabled')); + + unix_mode.validate = validate_file_mode; unix_mode.placeholder = '660'; + unix_mode.optional = true; - const drop_same_frames = s.taboption(this_tab, form.Flag, 'drop_same_frames', _('Drop same frames')); - drop_same_frames.default = drop_same_frames.disabled; + const drop_same_frames = s.taboption( + this_tab, form.Value, 'drop_same_frames', _('Drop same frames'), + _("Don't send identical frames to clients, but no more than specified number.") + '
    ' + + _('It can significantly reduce the outgoing traffic, but will increase the CPU load.') + '
    ' + + _("Don't use this option with analog signal sources or webcams, it's useless.") + '
    ' + + _('Default: disabled')); - const fake_resolution = s.taboption(this_tab, form.Value, 'fake_resolution', _('Fake resolution')); - fake_resolution.placeholder = '640x480'; + drop_same_frames.datatype = 'and(uinteger, min(0))'; + drop_same_frames.placeholder = '0'; + drop_same_frames.optional = true; + + const fake_resolution = s.taboption( + this_tab, form.Value, 'fake_resolution', _('Fake resolution'), + _('Override image resolution for the /state.') + ' ' + _('Default: disabled')); + + fake_resolution.default = ''; fake_resolution.keylist = resolution.keylist; fake_resolution.vallist = resolution.vallist; + fake_resolution.optional = true; - const allow_origin = s.taboption(this_tab, form.Value, 'allow_origin', _('Allow origin')); - allow_origin.values = resolution.values; + const allow_origin = s.taboption( + this_tab, form.Value, 'allow_origin', _('Allow origin'), + _('Set Access-Control-Allow-Origin header.') + ' ' + _('Default: disabled')); - const instance_id = s.taboption(this_tab, form.Value, 'instance_id', _('Instance ID')); + allow_origin.datatype = 'string'; + allow_origin.optional = true; - const server_timeout = s.taboption(this_tab, form.Value, 'server_timeout', _('Server timeout')); - server_timeout.datatype = 'uinteger'; + const instance_id = s.taboption( + this_tab, form.Value, 'instance_id', _('Instance ID'), + _('A short string identifier to be displayed in the /state handle.') + '
    ' + + _('It must satisfy regexp') + ' ^[a-zA-Z0-9./+_-]*$.' + ' ' + _('Default: an empty string')); + + instance_id.datatype = 'string'; + instance_id.optional = true; + + const server_timeout = s.taboption( + this_tab, form.Value, 'server_timeout', _('Server timeout'), + _('Timeout for client connections. Default: 10 seconds')); + + server_timeout.datatype = 'and(uinteger, range(0, 60))'; server_timeout.placeholder = '10'; + server_timeout.optional = true; + // JPEG sink - function init_stream() { - console.debug('init_stream'); - start_stream(); - } + this_tab = 'sink_jpeg'; - function _start_stream() { - console.debug('_start_stream'); + const jpeg_sink = s.taboption( + this_tab, form.Value, 'jpeg_sink', _('JPEG sink'), + _('Use the shared memory to sink JPEG frames.') + '
    ' + + _('The name should end with a suffix .jpg or .jpeg') + ' ' + _('Default: disabled')); - const port = uci.get('ustreamer', 'core', 'port'); - let login; - - if (uci.get('ustreamer', 'core', 'enable_auth') == '1') { - const user = uci.get('ustreamer', 'core', 'username'); - const pass = uci.get('ustreamer', 'core', 'password'); - login = `${user}:${pass}@`; - } else { - login = ''; - } - - const img = document.getElementById('video_preview') || video_preview; - img.src = 'http://' + login + location.hostname + ':' + port + '/?action=snapshot' + '&t=' + new Date().getTime(); - } - - function start_stream() { - console.debug('start_stream'); - - setTimeout(function () { - _start_stream(); - }, 5000); - } - - function on_error() { - console.warn('on_error'); - - const img = video_preview; - img.style.display = 'none'; - - const stream_stat = document.getElementById('stream_status') || stream_status; - stream_stat.style.display = 'block'; - - // start_stream(); - } - - function on_load() { - console.debug('on_load'); - - const img = video_preview; - img.style.display = 'block'; - - const stream_stat = stream_status; - stream_stat.style.display = 'none'; - } - - //HTTP preview - const video_preview = E('img', { - 'id': 'video_preview', - 'class': 'img-preview', - 'error': on_error, - 'load': on_load, - }); - - const stream_status = E('p', { - 'id': 'stream_status', - 'style': 'text-align: center; color: orange; font-weight: bold;', - }, - _('Stream unavailable'), - ); - - - init_stream(); - - const preview = s.taboption(this_tab, form.DummyValue, '_dummy'); - preview.render = L.bind(function (view, section_id) { - return E([], [ - video_preview, - stream_status - ]); - }, preview, this); - preview.depends('output', 'http'); - - // JPEG sink settings - - this_tab = 'jpeg_sink'; - - const jpeg_sink = s.taboption(this_tab, form.Value, 'jpeg_sink', _('JPEG sink'), - _('Use the shared memory to sink JPEG frames. Default: disabled.') + '
    ' + - _('The name should end with a suffix ".jpeg".') + '
    ' + - _('Default: disabled.')); + jpeg_sink.datatype = 'file'; jpeg_sink.placeholder = 'name.jpeg'; jpeg_sink.optional = true; - const jpeg_sink_mode = s.taboption(this_tab, form.Value, 'jpeg_sink_mode', _('JPEG sink mode'), - _('Set JPEG sink permissions (like 777). Default: 660.')); - jpeg_sink_mode.datatype = 'string'; + const jpeg_sink_mode = s.taboption( + this_tab, form.Value, 'jpeg_sink_mode', _('Sink permissions'), + _('Set sink file permissions.') + ' ' + _('Default: 660')); + + jpeg_sink_mode.validate = validate_file_mode; jpeg_sink_mode.placeholder = '660'; jpeg_sink_mode.optional = true; - const jpeg_sink_client_ttl = s.taboption(this_tab, form.Value, 'jpeg_sink_client_ttl', _('Client TTL'), - _('Default: 10.')); - jpeg_sink_client_ttl.datatype = 'uinteger'; + const jpeg_sink_client_ttl = s.taboption( + this_tab, form.Value, 'jpeg_sink_client_ttl', _('Client TTL'), + _('Default: 10 seconds')); + + jpeg_sink_client_ttl.datatype = 'and(uinteger, range(0, 60))'; jpeg_sink_client_ttl.placeholder = '10'; jpeg_sink_client_ttl.optional = true; - const jpeg_sink_timeout = s.taboption(this_tab, form.Value, 'jpeg_sink_timeout', _('JPEG sink timeout'), - _('Timeout for lock. Default: 1.')); - jpeg_sink_timeout.datatype = 'uinteger'; + const jpeg_sink_timeout = s.taboption( + this_tab, form.Value, 'jpeg_sink_timeout', _('Timeout for lock'), + _('Default: 1 second')); + + jpeg_sink_timeout.datatype = 'and(uinteger, range(0, 60))'; jpeg_sink_timeout.placeholder = '1'; jpeg_sink_timeout.optional = true; - const jpeg_sink_rm = s.taboption(this_tab, form.Flag, 'jpeg_sink_rm', _('Remove JPEG sink'), - _('Remove JPEG sink file on exit')); - jpeg_sink_rm.default = jpeg_sink_rm.disabled; + const jpeg_sink_rm = s.taboption( + this_tab, form.Flag, 'jpeg_sink_rm', _('Remove on stop'), + _('Remove shared memory on stop.') + ' ' + _('Default: disabled')); - // RAW sink settings + // RAW sink - this_tab = 'raw_sink'; + this_tab = 'sink_raw'; - const raw_sink = s.taboption(this_tab, form.Value, 'raw_sink', _('RAW sink'), - _('Use the shared memory to sink RAW frames. Default: disabled.') + '
    ' + - _('The name should end with a suffix ".raw".') + '
    ' + - _('Default: disabled.')); + const raw_sink = s.taboption( + this_tab, form.Value, 'raw_sink', _('RAW sink'), + _('Use the shared memory to sink RAW frames.') + '
    ' + + _('The name should end with a suffix .raw') + ' ' + _('Default: disabled')); + + raw_sink.datatype = 'file'; raw_sink.placeholder = 'name.raw'; raw_sink.optional = true; - const raw_sink_mode = s.taboption(this_tab, form.Value, 'raw_sink_mode', _('RAW sink mode'), - _('Set RAW sink permissions (like 777). Default: 660.')); - raw_sink_mode.datatype = 'string'; + const raw_sink_mode = s.taboption( + this_tab, form.Value, 'raw_sink_mode', _('Sink permissions'), + _('Set sink file permissions.') + ' ' + _('Default: 660')); + + raw_sink_mode.validate = validate_file_mode; raw_sink_mode.placeholder = '660'; raw_sink_mode.optional = true; - const raw_sink_client_ttl = s.taboption(this_tab, form.Value, 'raw_sink_client_ttl', _('RAW sink client TTL'), - _('Client TTL. Default: 10.')); - raw_sink_client_ttl.datatype = 'uinteger'; + const raw_sink_client_ttl = s.taboption( + this_tab, form.Value, 'raw_sink_client_ttl', _('Client TTL'), + _('Default: 10 seconds')); + + raw_sink_client_ttl.datatype = 'and(uinteger, range(0, 60))'; raw_sink_client_ttl.placeholder = '10'; raw_sink_client_ttl.optional = true; - const raw_sink_timeout = s.taboption(this_tab, form.Value, 'raw_sink_timeout', _('RAW sink timeout'), - _('Timeout for lock. Default: 1.')); - raw_sink_timeout.datatype = 'uinteger'; + const raw_sink_timeout = s.taboption( + this_tab, form.Value, 'raw_sink_timeout', _('Timeout for lock'), + _('Default: 1 second')); + + raw_sink_timeout.datatype = 'and(uinteger, range(0, 60))'; raw_sink_timeout.placeholder = '1'; raw_sink_timeout.optional = true; - const raw_sink_rm = s.taboption(this_tab, form.Flag, 'raw_sink_rm', _('Remove RAW sink'), - _('Remove shared memory on stop. Default: disabled.')); - raw_sink_rm.default = raw_sink_rm.disabled; + const raw_sink_rm = s.taboption( + this_tab, form.Flag, 'raw_sink_rm', _('Remove on stop'), + _('Remove shared memory on stop. Default: disabled')); - // H264 sink settings - this_tab = 'h264_sink'; + // H264 sink - const h264_sink = s.taboption(this_tab, form.Value, 'h264_sink', _('H264 sink'), - _('Use the shared memory to sink H264 frames. Default: disabled.') + '
    ' + - _('The name should end with a suffix ".h264"') + '
    ' + - _('Default: disabled.')); + this_tab = 'sink_h264'; + + const h264_sink = s.taboption( + this_tab, form.Value, 'h264_sink', _('H264 sink'), + _('Use the shared memory to sink H264 frames.') + '
    ' + + _('The name should end with a suffix .h264') + ' ' + _('Default: disabled')); + + h264_sink.datatype = 'file'; h264_sink.placeholder = 'name.h264'; h264_sink.optional = true; - const h264_sink_mode = s.taboption(this_tab, form.Value, 'h264_sink_mode', _('H264 sink mode'), - _('Set H264 sink permissions (like 777). Default: 660.')); - h264_sink_mode.datatype = 'string'; + const h264_sink_mode = s.taboption( + this_tab, form.Value, 'h264_sink_mode', _('Sink permissions'), + _('Set sink file permissions.') + ' ' + _('Default: 660')); + + h264_sink_mode.validate = validate_file_mode; h264_sink_mode.placeholder = '660'; h264_sink_mode.optional = true; - const h264_sink_rm = s.taboption(this_tab, form.Flag, 'h264_sink_rm', _('Remove'), - _('Remove shared memory on stop. Default: disabled.')); - h264_sink_rm.default = h264_sink_rm.disabled; + const h264_sink_client_ttl = s.taboption( + this_tab, form.Value, 'h264_sink_client_ttl', _('Client TTL'), + _('Default: 10 seconds')); - const h264_sink_client_ttl = s.taboption(this_tab, form.Value, 'h264_sink_client_ttl', _('Sink client TTL'), - _('Client TTL. Default: 10.')); - h264_sink_client_ttl.datatype = 'uinteger'; + h264_sink_client_ttl.datatype = 'and(uinteger, range(0, 60))'; h264_sink_client_ttl.placeholder = '10'; h264_sink_client_ttl.optional = true; - const h264_sink_timeout = s.taboption(this_tab, form.Value, 'h264_sink_timeout', _('Sink timeout'), - _('Timeout for lock. Default: 1.')); - h264_sink_timeout.datatype = 'uinteger'; + const h264_sink_timeout = s.taboption( + this_tab, form.Value, 'h264_sink_timeout', _('Timeout for lock'), + _('Default: 1 second')); + + h264_sink_timeout.datatype = 'and(uinteger, range(0, 60))'; h264_sink_timeout.placeholder = '1'; h264_sink_timeout.optional = true; - const h264_bitrate = s.taboption(this_tab, form.Value, 'h264_bitrate', _('Bitrate'), - _('H264 bitrate in Kbps. Default: 5000.')); - h264_bitrate.datatype = 'uinteger'; + const h264_sink_rm = s.taboption( + this_tab, form.Flag, 'h264_sink_rm', _('Remove on stop'), + _('Remove shared memory on stop.') + ' ' + _('Default: disabled')); + + const h264_boost = s.taboption( + this_tab, form.Flag, 'h264_boost', _('H264 boost'), + _('Increase encoder performance on PiKVM V4.') + ' ' + _('Default: disabled')); + + const h264_bitrate = s.taboption( + this_tab, form.Value, 'h264_bitrate', _('Bitrate (kbps)'), + _('Default: 5000 kbps')); + + h264_bitrate.datatype = 'and(uinteger, range(25, 20000))'; h264_bitrate.placeholder = '5000'; h264_bitrate.optional = true; - const h264_gop = s.taboption(this_tab, form.Value, 'h264_gop', _('H264 GOP'), - _('Interval between keyframes. Default: 30.')); - h264_gop.datatype = 'uinteger'; + const h264_gop = s.taboption( + this_tab, form.Value, 'h264_gop', _('Keyframe interval'), + _('Default: 30')); + + h264_gop.datatype = 'and(uinteger, range(0, 60))'; h264_gop.placeholder = '30'; h264_gop.optional = true; - const h264_m2m_device = s.taboption(this_tab, form.FileUpload, 'h264_m2m_device', _('H264 M2M device'), - _('Path to V4L2 M2M encoder device. Default: auto select.')); + const h264_m2m_device = s.taboption( + this_tab, form.FileUpload, 'h264_m2m_device', _('M2M device'), + _('Path to V4L2 M2M encoder device. Default: auto select')); + h264_m2m_device.root_directory = '/dev'; - h264_m2m_device.show_hidden = true; h264_m2m_device.directory_create = false; h264_m2m_device.enable_download = false; h264_m2m_device.enable_upload = false; h264_m2m_device.enable_remove = false; + h264_m2m_device.show_hidden = true; h264_m2m_device.optional = true; h264_m2m_device.datatype = 'file'; - const h264_boost = s.taboption(this_tab, form.Flag, 'h264_boost', _('H264 boost'), - _('Increase encoder performance on PiKVM V4. Default: disabled.')); - h264_boost.default = h264_boost.disabled; - const exit_on_no_clients = s.taboption(this_tab, form.Flag, 'exit_on_no_clients', _('Exit on no clients'), - _('Exit the program if there have been no stream or sink clients ') + - _('or any HTTP requests in the last N seconds. Default: 0 (disabled).')); - exit_on_no_clients.default = exit_on_no_clients.disabled; + // logging - // Image control settings + this_tab = 'logging'; + + const log_level = s.taboption( + this_tab, form.ListValue, 'log_level', _('Log level'), + _('Verbosity level of messages from 0 (info) to 3 (debug)') + '
    ' + + _('Enabling debugging messages can slow down the program') + '
    ' + + _('Default: 0 (info)')); + + log_level.default = ''; + log_level.datatype = 'and(uinteger, range(0, 3))'; + log_level.value('', _('default')); + log_level.value('0', '0 ' + _('Info')); + log_level.value('1', '1 ' + _('Performance')); + log_level.value('2', '2 ' + _('Verbose')); + log_level.value('3', '3 ' + _('Debug')); + log_level.optional = true; + + const exit_on_no_clients = s.taboption( + this_tab, form.Value, 'exit_on_no_clients', _('Exit on no clients'), + _('Exit the program if there have been no stream or sink clients') + '
    ' + + _('or any HTTP requests in the last N seconds.') + ' ' + _('Default: 0 (disabled)')); + + exit_on_no_clients.datatype = 'and(uinteger, range(0, 86400))'; + exit_on_no_clients.placeholder = '0'; + exit_on_no_clients.optional = true; + + + // image control this_tab = 'image_control'; - const image_default = s.taboption(this_tab, form.Flag, 'image_default', _('Use device defaults')); - image_default.default = image_default.disabled; + const image_default = s.taboption( + this_tab, form.Flag, 'image_default', _('Image default'), + _('Reset all image settings below to default.') + ' ' + _('Unchecked: no change')); - const brightness = s.taboption(this_tab, form.Value, 'brightness', _('Brightness')); - brightness.placeholder = '128 | auto'; + function validate_int_default (section_id, value) { + if (!value || (value == 'default')) return true; + value = parseInt(value); + if (!isNaN(value)) return true; + return _('Expecting: number | default'); + } + + function validate_int_default_auto (section_id, value) { + if (!value || (value == 'default') || (value == 'auto')) return true; + value = parseInt(value); + if (!isNaN(value)) return true; + return _('Expecting: number | default | auto'); + } + + const brightness = s.taboption( + this_tab, form.Value, 'brightness', _('Brightness'), + _('number | default | auto. Blank: no change')); + + brightness.validate = validate_int_default_auto; + brightness.placeholder = '128 | default | auto'; brightness.optional = true; - const contrast = s.taboption(this_tab, form.Value, 'contrast', _('Contrast')); - contrast.placeholder = '128'; + const contrast = s.taboption( + this_tab, form.Value, 'contrast', _('Contrast'), + _('number | default. Blank: no change')); + + contrast.validate = validate_int_default; + contrast.placeholder = '128 | default'; contrast.optional = true; - const saturation = s.taboption(this_tab, form.Value, 'saturation', _('Saturation')); - saturation.placeholder = '128'; + const saturation = s.taboption( + this_tab, form.Value, 'saturation', _('Saturation'), + _('number | default. Blank: no change')); + + saturation.validate = validate_int_default; + saturation.placeholder = '128 | default'; saturation.optional = true; - const hue = s.taboption(this_tab, form.Value, 'hue', _('Hue')); - hue.placeholder = '128 | auto'; - hue.optional = true; + const gamma = s.taboption( + this_tab, form.Value, 'gamma', _('Gamma'), + _('number | default. Blank: no change')); - const gamma = s.taboption(this_tab, form.Value, 'gamma', _('Gamma')); - gamma.placeholder = '128'; + gamma.validate = validate_int_default; + gamma.placeholder = 'default'; gamma.optional = true; - const sharpness = s.taboption(this_tab, form.Value, 'sharpness', _('Sharpness')); - sharpness.placeholder = '128'; - sharpness.optional = true; + const gain = s.taboption( + this_tab, form.Value, 'gain', _('Gain'), + _('number | default | auto. Blank: no change')); - const backlight_compensation = s.taboption(this_tab, form.Value, 'backlight_compensation', _('Backlight compensation')); - backlight_compensation.placeholder = '128'; - backlight_compensation.optional = true; - - const white_balance = s.taboption(this_tab, form.Value, 'white_balance', _('White balance')); - white_balance.placeholder = '128 | auto'; - white_balance.optional = true; - - const gain = s.taboption(this_tab, form.Value, 'gain', _('Gain')); - gain.placeholder = '128 | auto'; + gain.validate = validate_int_default_auto; + gain.placeholder = '0 | default | auto'; gain.optional = true; - const color_effect = s.taboption(this_tab, form.Value, 'color_effect', _('Color effect')); - color_effect.placeholder = '128'; + const hue = s.taboption( + this_tab, form.Value, 'hue', _('Hue'), + _('number | default | auto. Blank: no change')); + + hue.validate = validate_int_default_auto; + hue.placeholder = 'number | default | auto'; + hue.optional = true; + + const sharpness = s.taboption( + this_tab, form.Value, 'sharpness', _('Sharpness'), + _('number | default. Blank: no change')); + + sharpness.validate = validate_int_default; + sharpness.placeholder = '128 | default'; + sharpness.optional = true; + + const color_effect = s.taboption( + this_tab, form.Value, 'color_effect', _('Colour effect'), + _('number | default. Blank: no change')); + + color_effect.validate = validate_int_default; + color_effect.placeholder = 'default'; color_effect.optional = true; - const rotate = s.taboption(this_tab, form.Value, 'rotate', _('Rotate')); - rotate.datatype = 'uinteger'; + const white_balance = s.taboption( + this_tab, form.Value, 'white_balance', _('White balance'), + _('temperature | default | auto. Blank: no change')); + + white_balance.validate = validate_int_default_auto; + white_balance.placeholder = '4000 | default | auto'; + white_balance.optional = true; + + const backlight_compensation = s.taboption( + this_tab, form.Value, 'backlight_compensation', + _('Backlight compensation'), + _('number | default. Blank: no change')); + + backlight_compensation.validate = validate_int_default; + backlight_compensation.placeholder = '0 | default'; + backlight_compensation.optional = true; + + const flip_horizontal = s.taboption( + this_tab, form.Value, 'flip_horizontal', _('Flip horizontal'), + _('number | default. Blank: no change')); + + flip_horizontal.validate = validate_int_default; + flip_horizontal.placeholder = '0 | default'; + flip_horizontal.optional = true; + + const flip_vertical = s.taboption( + this_tab, form.Value, 'flip_vertical', _('Flip vertical'), + _('number | default. Blank: no change')); + + flip_vertical.validate = validate_int_default; + flip_vertical.placeholder = '0 | default'; + flip_vertical.optional = true; + + const rotate = s.taboption( + this_tab, form.Value, 'rotate', _('Rotate'), + _('number | default. Blank: no change')); + + rotate.validate = validate_int_default; + rotate.placeholder = '0 | default'; rotate.optional = true; - const flip_horizontal = s.taboption(this_tab, form.Flag, 'flip_horizontal', _('Flip horizontally')); - flip_horizontal.default = flip_horizontal.disabled; - - const flip_vertical = s.taboption(this_tab, form.Flag, 'flip_vertical', _('Flip vertically')); - flip_vertical.default = flip_vertical.disabled; return m.render(); }, diff --git a/applications/luci-app-ustreamer/root/usr/share/luci/menu.d/luci-app-ustreamer.json b/applications/luci-app-ustreamer/root/usr/share/luci/menu.d/luci-app-ustreamer.json index ad68670f68..a9d40c9737 100644 --- a/applications/luci-app-ustreamer/root/usr/share/luci/menu.d/luci-app-ustreamer.json +++ b/applications/luci-app-ustreamer/root/usr/share/luci/menu.d/luci-app-ustreamer.json @@ -1,6 +1,6 @@ { "admin/services/ustreamer": { - "title": "ustreamer", + "title": "µStreamer", "action": { "type": "view", "path": "ustreamer/ustreamer"