luci-app-ustreamer: complete rewrite
authorGeorgi Valkov <redacted>
Sat, 14 Feb 2026 13:09:12 +0000 (15:09 +0200)
committerPaul Donald <redacted>
Thu, 5 Mar 2026 00:24:16 +0000 (01:24 +0100)
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 <redacted>
Closes #8324
Link: https://github.com/openwrt/luci/pull/8324/
Signed-off-by: Paul Donald <redacted>
applications/luci-app-ustreamer/Makefile
applications/luci-app-ustreamer/htdocs/luci-static/resources/view/ustreamer/ustreamer.js
applications/luci-app-ustreamer/root/usr/share/luci/menu.d/luci-app-ustreamer.json

index b161b277019faefd9efa8a8ab6b85a33ce7d0089..30ab6cb6f7f6a7c50a6560e8506e390aba37fafb 100644 (file)
@@ -1,18 +1,16 @@
 #
-# Copyright (C) 2008-2014 The LuCI Team <luci@lists.subsignal.org>
+# Copyright (C) 2008-2026 The LuCI Team <luci@lists.subsignal.org>
 #
 # 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 <jo@mein.io>
-
-PROVIDES:=luci-app-mjpeg-streamer
+PKG_MAINTAINER:=Georgi Valkov <gvalkov@gmail.com>
 
 include ../../luci.mk
 
index cc98bed3fd96293643d57d933ad2168493ad0e95..4381826d19fdbee8865c6876a00b7dab1e28accc 100644 (file)
 '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')
+               );
+
+
+               // preview
+
+               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');
 
-               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.'));
+                       if (port == undefined) {
+                               port = '8080';
+                       }
+
+                       if (user != undefined) {
+                               if (pass == undefined) {
+                                       login = user + '@';
+                               }
+                               else {
+                                       login = user + ':' + pass + '@';
+                               }
+                       }
 
-               //General settings
+                       return 'http://' + login + location.hostname + ':' + port + '/';
+               }
+
+               const url = stream_url();
 
-               const section_gen = m.section(form.TypedSection, 'ustreamer', _('General'));
-               section_gen.addremove = false;
-               section_gen.anonymous = true;
+               const stream_link = E('a', {
+                               'id': 'stream_link',
+                               'target': '_blank',
+                               'href': url,
+                       },
+                       _('Preview'),
+               );
 
-               const enabled = section_gen.option(form.Flag, 'enabled', _('Enabled'));
+               const s_preview = m.section(form.TypedSection, 'ustreamer', stream_link);
+               s_preview.addremove = false;
+               s_preview.anonymous = true;
 
-               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'));
+               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();
+               }
 
-               //Plugin settings
+               function start_stream() {
+                       if (stream.timer) {
+                               return;
+                       }
 
-               s = m.section(form.TypedSection, 'ustreamer', _('Plugin settings'));
-               s.addremove = true;
+                       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
+
+               let this_tab = 'general';
+
+               const enabled = s.taboption(
+                       this_tab, form.Flag, 'enabled', _('Enabled'), _('Enable µStreamer')
+               );
+
+               const device = s.taboption(
+                       this_tab, form.Value, 'device', _('Device'),
+                       _('Path to V4L2 device. Default: /dev/video0'));
+
+               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'));
 
-               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;
+               device_timeout.datatype = 'and(uinteger, range(0, 60))';
+               device_timeout.placeholder = '5';
+               device_timeout.optional = true;
 
-               const dtimeout = s.taboption(this_tab, form.Value, 'timeout', _('Timeout'), _('units: seconds'));
-               dtimeout.placeholder = '5';
-               dtimeout.datatype = 'uinteger';
+               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 input = s.taboption(this_tab, form.Flag, 'input', _('Input'));
-               input.default = input.disabled;
+               const resolution = s.taboption(
+                       this_tab, form.Value, 'resolution', _('Resolution'),
+                       _('Initial image resolution. Default: 640x480'));
 
-               const resolution = s.taboption(this_tab, form.Value, 'resolution', _('Resolution'));
-               resolution.placeholder = '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 encoder = s.taboption(this_tab, form.Value, 'encoder', _('Encoder'));
-               encoder.value('CPU');
-               encoder.value('HW');
+               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.') + '<br />' +
+                       _('Useful to reduce CPU consumption.') + ' ' + _('Default: disabled'));
 
-               const quality = s.taboption(
-                       this_tab,
-                       form.Value,
-                       'quality',
-                       _('Quality'),
-                       _('Set the quality in percent.'),
+               const format = s.taboption(
+                       this_tab, form.Value, 'format', _('Image format'), _('Default: YUYV')
                );
-               quality.datatype = 'range(0, 100)';
 
+               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') + '<br />' +
+                       '<li><kbd>CPU  ──────── </kbd>' + _('Software MJPEG encoding (default)') + '</li>' +
+                       '<li><kbd>HW  ───────── </kbd>' + _('Use pre-encoded MJPEG frames directly from camera hardware') + '</li>' +
+                       '<li><kbd>M2M-VIDEO  ── </kbd>' + _('GPU-accelerated MJPEG encoding using V4L2 M2M video interface') + '</li>' +
+                       '<li><kbd>M2M-IMAGE  ── </kbd>' + _('GPU-accelerated JPEG encoding using V4L2 M2M image interface') + '</li>'
+               );
 
-               const allow_truncated_frames = s.taboption(this_tab, form.Flag, 'allow_truncated_frames', _('Allow truncated frames'));
-               allow_truncated_frames.default = allow_truncated_frames.disabled;
+               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 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;
+               const quality = s.taboption(
+                       this_tab, form.Value, 'quality', _('Quality'),
+                       _('Set the quality of JPEG encoding: 1 to 100 (best). Default: 80') + '<br />' +
+                       _("If HW encoding is used (JPEG source format), attempts to configure the camera or capture device hardware's internal encoder.") + '<br />' +
+                       _('MJPEG will not be recoded to MJPEG to change the quality'));
 
-               const persistent = s.taboption(this_tab, form.Flag, 'persistent', _('Persistent'),
-                       _("Don't re-initialize device on timeout. Default: disabled."));
-               persistent.default = persistent.disabled;
+               quality.datatype = 'and(uinteger, range(0, 100))';
+               quality.placeholder = '0';
+               quality.optional = true;
 
-               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 host = s.taboption(
+                       this_tab, form.Value, 'host', _('Host'),
+                       _('Listen on Hostname or IP. Default: 127.0.0.1'));
 
-               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');
+               host.datatype = 'or(ip4addr, ip6addr, host)';
+               host.placeholder = '::';
+               host.optional = true;
 
-               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 port = s.taboption(
+                       this_tab, form.Value, 'port', _('Port'),
+                       _('Bind to this TCP port.') + ' ' + _(' Default: 8080'));
 
-               const buffers = s.taboption(this_tab, form.Value, 'buffers', _('Buffers'),
-                       _('The number of buffers to receive data from the device.') + '<br/>' +
-                       _('Each buffer may processed using an independent thread.') + '<br/>' +
-                       _('Default: 3 (the number of CPU cores (but not more than 4) + 1).'));
-               buffers.datatype = 'and(uinteger, min(1))';
+               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;
+
+
+               // capture
+
+               this_tab = 'capture';
+
+               const allow_truncated_frames = s.taboption(
+                       this_tab, form.Flag, 'allow_truncated_frames',
+                       _('Allow truncated frames'),
+                       _('Allows to handle truncated frames.') + ' ' + _('Default: disabled') + '<br />' +
+                       _('Useful if the device produces incorrect but still acceptable frames'));
+
+               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 persistent = s.taboption(
+                       this_tab, form.Flag, 'persistent', _('Persistent'),
+                       _("Don't re-initialize device on timeout.") + ' ' + _('Default: disabled'));
+
+               const dv_timings = s.taboption(
+                       this_tab, form.Flag, 'dv_timings', _('DV Timings'),
+                       _('Enable DV Timings querying and events processing to automatic resolution change') + '<br />' +
+                       _('Default: disabled'));
+
+               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.') + '<br />' +
+                       _('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.') + '<br />' +
+                       _('Each buffer may be processed using an independent thread.') + '<br />' +
+                       _('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.') + '<br/>' +
-                       _('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.') + '<br />' +
+                       _('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';
+                       this_tab, form.Value, 'min_frame_size', _('Min frame size'),
+                       _('Drop frames smaller than this limit. Useful if the device') + '<br />' +
+                       _('produces small-sized garbage frames.') + ' ' + _('Default: 128 bytes'));
+
+               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'));
-               device_error_delay.datatype = 'and(uinteger, min(1))';
+               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).') + '<br />' +
+                       _('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 enable_auth = s.taboption(this_tab, form.Flag, 'enable_auth', _('Authentication required'), _('Ask for username and password on connect'));
-               enable_auth.default = 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') + '<br />' + 
+                       _('Default: disabled'));
 
-               const username = s.taboption(this_tab, form.Value, 'user', _('Username'));
-               username.depends('enable_auth', '1');
-               username.optional = false;
+               const www = s.taboption(
+                       this_tab, form.Value, 'static', _('WWW folder'),
+                       _('Path to dir with static files instead of embedded root index page.') + '<br />' +
+                       _('Symlinks are not supported for security reasons.') + ' ' + _('Default: disabled'));
 
-               const password = s.taboption(this_tab, form.Value, 'pass', _('Password'));
-               password.depends('enable_auth', '1');
-               password.password = true;
-               password.optional = false;
-               password.default = false;
+               www.datatype = 'directory';
+               www.placeholder = '/www/webcam';
+               www.optional = true;
 
-               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', _('UNIX socket'),
+                       _('Bind to UNIX domain socket.') + ' ' + _('Default: disabled'));
 
-               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';
-               unix_mode.placeholder = '660';
-
-               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 fake_resolution = s.taboption(this_tab, form.Value, 'fake_resolution', _('Fake resolution'));
-               fake_resolution.placeholder = '640x480';
-               fake_resolution.keylist = resolution.keylist;
-               fake_resolution.vallist = resolution.vallist;
-
-               const allow_origin = s.taboption(this_tab, form.Value, 'allow_origin', _('Allow origin'));
-               allow_origin.values = resolution.values;
-
-               const instance_id = s.taboption(this_tab, form.Value, 'instance_id', _('Instance ID'));
-
-               const server_timeout = s.taboption(this_tab, form.Value, 'server_timeout', _('Server timeout'));
-               server_timeout.datatype = 'uinteger';
-               server_timeout.placeholder = '10';
+               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 init_stream() {
-                       console.debug('init_stream');
-                       start_stream();
+               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');
                }
 
-               function _start_stream() {
-                       console.debug('_start_stream');
-
-                       const port = uci.get('ustreamer', 'core', 'port');
-                       let login;
+               const unix_mode = s.taboption(
+                       this_tab, form.Value, 'unix_mode', _('UNIX socket permissions'),
+                       _('Set UNIX socket file permissions (like 777).') + ' ' + _('Default: disabled'));
 
-                       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 = '';
-                       }
+               unix_mode.validate = validate_file_mode;
+               unix_mode.placeholder = '660';
+               unix_mode.optional = true;
 
-                       const img = document.getElementById('video_preview') || video_preview;
-                       img.src = 'http://' + login + location.hostname + ':' + port + '/?action=snapshot' + '&t=' + new Date().getTime();
-               }
+               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.") + '<br />' +
+                       _('It can significantly reduce the outgoing traffic, but will increase the CPU load.') + '<br />' +
+                       _("Don't use this option with analog signal sources or webcams, it's useless.") + '<br />' +
+                       _('Default: disabled'));
 
-               function start_stream() {
-                       console.debug('start_stream');
+               drop_same_frames.datatype = 'and(uinteger, min(0))';
+               drop_same_frames.placeholder = '0';
+               drop_same_frames.optional = true;
 
-                       setTimeout(function () {
-                               _start_stream();
-                       }, 5000);
-               }
+               const fake_resolution = s.taboption(
+                       this_tab, form.Value, 'fake_resolution', _('Fake resolution'),
+                       _('Override image resolution for the /state.') + ' ' + _('Default: disabled'));
 
-               function on_error() {
-                       console.warn('on_error');
+               fake_resolution.default = '';
+               fake_resolution.keylist = resolution.keylist;
+               fake_resolution.vallist = resolution.vallist;
+               fake_resolution.optional = true;
 
-                       const img = video_preview;
-                       img.style.display = 'none';
+               const allow_origin = s.taboption(
+                       this_tab, form.Value, 'allow_origin', _('Allow origin'),
+                       _('Set Access-Control-Allow-Origin header.') + ' ' + _('Default: disabled'));
 
-                       const stream_stat = document.getElementById('stream_status') || stream_status;
-                       stream_stat.style.display = 'block';
+               allow_origin.datatype = 'string';
+               allow_origin.optional = true;
 
-                       // start_stream();
-               }
+               const instance_id = s.taboption(
+                       this_tab, form.Value, 'instance_id', _('Instance ID'),
+                       _('A short string identifier to be displayed in the /state handle.') + '<br />' +
+                       _('It must satisfy regexp') + ' <code>^[a-zA-Z0-9./+_-]*$.</code>' + ' ' + _('Default: an empty string'));
 
-               function on_load() {
-                       console.debug('on_load');
+               instance_id.datatype = 'string';
+               instance_id.optional = true;
 
-                       const img = video_preview;
-                       img.style.display = 'block';
+               const server_timeout = s.taboption(
+                       this_tab, form.Value, 'server_timeout', _('Server timeout'),
+                       _('Timeout for client connections. Default: 10 seconds'));
 
-                       const stream_stat = stream_status;
-                       stream_stat.style.display = 'none';
-               }
+               server_timeout.datatype = 'and(uinteger, range(0, 60))';
+               server_timeout.placeholder = '10';
+               server_timeout.optional = true;
 
-               //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'),
-               );
+               // JPEG sink
 
+               this_tab = 'sink_jpeg';
 
-               init_stream();
+               const jpeg_sink = s.taboption(
+                       this_tab, form.Value, 'jpeg_sink', _('JPEG sink'),
+                       _('Use the shared memory to sink JPEG frames.') + '<br />' +
+                       _('The name should end with a suffix .jpg or .jpeg') + ' ' + _('Default: disabled'));
 
-               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.') + '<br/>' +
-                       _('The name should end with a suffix ".jpeg".') + '<br/>' +
-                       _('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
 
-               // RAW sink settings
+               this_tab = 'sink_raw';
 
-               this_tab = 'raw_sink';
+               const raw_sink = s.taboption(
+                       this_tab, form.Value, 'raw_sink', _('RAW sink'),
+                       _('Use the shared memory to sink RAW frames.') + '<br />' +
+                       _('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. Default: disabled.') + '<br/>' +
-                       _('The name should end with a suffix ".raw".') + '<br/>' +
-                       _('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
 
-               // H264 sink settings
+               this_tab = 'sink_h264';
 
-               this_tab = 'h264_sink';
+               const h264_sink = s.taboption(
+                       this_tab, form.Value, 'h264_sink', _('H264 sink'),
+                       _('Use the shared memory to sink H264 frames.') + '<br />' +
+                       _('The name should end with a suffix .h264') + ' ' + _('Default: disabled'));
 
-               const h264_sink = s.taboption(this_tab, form.Value, 'h264_sink', _('H264 sink'),
-                       _('Use the shared memory to sink H264 frames. Default: disabled.') + '<br/>' +
-                       _('The name should end with a suffix ".h264"') + '<br/>' +
-                       _('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)') + '<br />' +
+                       _('Enabling debugging messages can slow down the program') + '<br />' +
+                       _('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') + '<br />' +
+                       _('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'));
+
+               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'));
 
-               const brightness = s.taboption(this_tab, form.Value, 'brightness', _('Brightness'));
-               brightness.placeholder = '128 | auto';
+               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';
+               const gain = s.taboption(
+                       this_tab, form.Value, 'gain', _('Gain'),
+                       _('number | default | auto. Blank: no change'));
+
+               gain.validate = validate_int_default_auto;
+               gain.placeholder = '0 | default | auto';
+               gain.optional = true;
+
+               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 backlight_compensation = s.taboption(this_tab, form.Value, 'backlight_compensation', _('Backlight compensation'));
-               backlight_compensation.placeholder = '128';
-               backlight_compensation.optional = true;
+               const color_effect = s.taboption(
+                       this_tab, form.Value, 'color_effect', _('Colour effect'),
+                       _('number | default. Blank: no change'));
 
-               const white_balance = s.taboption(this_tab, form.Value, 'white_balance', _('White balance'));
-               white_balance.placeholder = '128 | auto';
+               color_effect.validate = validate_int_default;
+               color_effect.placeholder = 'default';
+               color_effect.optional = true;
+
+               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 gain = s.taboption(this_tab, form.Value, 'gain', _('Gain'));
-               gain.placeholder = '128 | auto';
-               gain.optional = true;
+               const backlight_compensation = s.taboption(
+                       this_tab, form.Value, 'backlight_compensation',
+                       _('Backlight compensation'),
+                       _('number | default. Blank: no change'));
 
-               const color_effect = s.taboption(this_tab, form.Value, 'color_effect', _('Color effect'));
-               color_effect.placeholder = '128';
-               color_effect.optional = true;
+               backlight_compensation.validate = validate_int_default;
+               backlight_compensation.placeholder = '0 | default';
+               backlight_compensation.optional = true;
 
-               const rotate = s.taboption(this_tab, form.Value, 'rotate', _('Rotate'));
-               rotate.datatype = 'uinteger';
-               rotate.optional = true;
+               const flip_horizontal = s.taboption(
+                       this_tab, form.Value, 'flip_horizontal', _('Flip horizontal'),
+                       _('number | default. Blank: no change'));
 
-               const flip_horizontal = s.taboption(this_tab, form.Flag, 'flip_horizontal', _('Flip horizontally'));
-               flip_horizontal.default = flip_horizontal.disabled;
+               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_vertical = s.taboption(this_tab, form.Flag, 'flip_vertical', _('Flip vertically'));
-               flip_vertical.default = flip_vertical.disabled;
 
                return m.render();
        },
index ad68670f68c21b27b17d2b2b9965fcd208be3f75..a9d40c9737dbb82b96fe31087a214d4f65c37f7d 100644 (file)
@@ -1,6 +1,6 @@
 {
        "admin/services/ustreamer": {
-               "title": "ustreamer",
+               "title": "µStreamer",
                "action": {
                        "type": "view",
                        "path": "ustreamer/ustreamer"
git clone https://git.99rst.org/PROJECT