Add dual-stream support for Frigate with optional sub-stream selection

Features:
- Optional sub-stream selection from already discovered streams
- No additional scanning required - reuse existing results
- UI: "Add Sub Stream" button to select secondary stream
- UI: "Remove Sub Stream" button to clear selection
- Smart stream routing in Frigate configs
- Go2RTC: generates _main and _sub stream names
- Frigate: detect on sub (CPU efficient), record on main (quality)
- Frigate: auto-detection of stream resolution
- Object detection: person, car, cat, dog
- Motion-based recording by default
- Live view streams configuration
- Support for any resolution: HD, 4K, 8K+
- Comprehensive documentation with examples
This commit is contained in:
eduard256
2025-11-06 22:53:50 +03:00
parent 74fe12bcf1
commit 7fd1d78ffa
8 changed files with 874 additions and 129 deletions
+75 -15
View File
@@ -556,12 +556,19 @@ body {
}
/* ===== CAROUSEL ===== */
.carousel {
.carousel-wrapper {
position: relative;
overflow: hidden;
display: flex;
align-items: center;
gap: var(--space-4);
margin-bottom: var(--space-4);
}
.carousel {
flex: 1;
overflow: hidden;
}
.carousel-track {
display: flex;
transition: transform var(--transition-slow);
@@ -621,9 +628,7 @@ body {
}
.carousel-arrow {
position: absolute;
top: 50%;
transform: translateY(-50%);
flex-shrink: 0;
width: 48px;
height: 48px;
background: var(--bg-elevated);
@@ -634,7 +639,6 @@ body {
justify-content: center;
cursor: pointer;
transition: all var(--transition-fast);
z-index: 10;
color: var(--text-secondary);
}
@@ -650,15 +654,12 @@ body {
cursor: not-allowed;
}
.carousel-arrow-left {
left: -24px;
}
.carousel-arrow-right {
right: -24px;
}
@media (max-width: 767px) {
.carousel-wrapper {
flex-direction: column;
gap: var(--space-3);
}
.carousel-arrow {
display: none;
}
@@ -699,14 +700,32 @@ body {
}
/* ===== SELECTED STREAM INFO ===== */
.stream-selection-container {
margin-bottom: var(--space-6);
}
.selected-stream-info {
padding: var(--space-6);
background: var(--bg-secondary);
border: 1px solid var(--border-color);
border-radius: 8px;
margin-bottom: var(--space-4);
position: relative;
}
.selected-stream-info:last-child {
margin-bottom: var(--space-6);
}
.stream-label {
font-size: var(--text-xs);
font-weight: 600;
color: var(--text-tertiary);
text-transform: uppercase;
letter-spacing: 0.05em;
margin-bottom: var(--space-3);
}
.selected-type {
font-size: var(--text-sm);
font-weight: 600;
@@ -723,6 +742,28 @@ body {
word-break: break-all;
}
.sub-stream {
border-color: rgba(139, 92, 246, 0.3);
}
.btn-remove-sub {
margin-top: var(--space-4);
padding: var(--space-2) var(--space-4);
background: transparent;
border: 1px solid var(--error);
border-radius: 6px;
color: var(--error);
font-size: var(--text-sm);
font-weight: 500;
cursor: pointer;
transition: all var(--transition-fast);
}
.btn-remove-sub:hover {
background: var(--error);
color: white;
}
/* ===== TABS ===== */
.tabs {
margin-bottom: var(--space-6);
@@ -796,13 +837,32 @@ body {
.actions {
display: flex;
gap: var(--space-3);
margin-bottom: var(--space-6);
margin-top: 10px;
margin-bottom: var(--space-4);
}
.actions .btn {
flex: 1;
}
.secondary-actions {
display: flex;
gap: var(--space-3);
margin-bottom: var(--space-6);
}
.secondary-actions .btn {
flex: 1;
}
.secondary-actions .btn-primary {
flex: 1.2;
}
.secondary-actions .btn-outline {
flex: 0.8;
}
/* ===== TOAST ===== */
.toast {
position: fixed;
+28 -8
View File
@@ -217,14 +217,16 @@
<div id="streams-section" class="streams-section hidden">
<h3 class="section-title">Found Connections</h3>
<div class="carousel">
<div class="carousel-wrapper">
<button id="carousel-prev" class="carousel-arrow carousel-arrow-left" aria-label="Previous stream">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none">
<path d="M15 18l-6-6 6-6" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
</svg>
</button>
<div id="carousel-track" class="carousel-track"></div>
<div class="carousel">
<div id="carousel-track" class="carousel-track"></div>
</div>
<button id="carousel-next" class="carousel-arrow carousel-arrow-right" aria-label="Next stream">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none">
@@ -253,9 +255,19 @@
<h2 class="screen-title">Stream Configuration</h2>
<div class="selected-stream-info">
<p id="selected-stream-type" class="selected-type"></p>
<p id="selected-stream-url" class="selected-url"></p>
<div class="stream-selection-container">
<div class="selected-stream-info">
<p class="stream-label">Main Stream</p>
<p id="selected-main-type" class="selected-type"></p>
<p id="selected-main-url" class="selected-url"></p>
</div>
<div id="sub-stream-info" class="selected-stream-info sub-stream hidden">
<p class="stream-label">Sub Stream</p>
<p id="selected-sub-type" class="selected-type"></p>
<p id="selected-sub-url" class="selected-url"></p>
<button id="btn-remove-sub" class="btn-remove-sub">Remove Sub Stream</button>
</div>
</div>
<div class="tabs">
@@ -294,9 +306,17 @@
</button>
</div>
<button id="btn-new-search" class="btn btn-outline">
Add Another Camera
</button>
<div class="secondary-actions">
<button id="btn-add-sub-stream" class="btn btn-primary">
<svg width="20" height="20" viewBox="0 0 20 20" fill="none">
<path d="M10 4v12M4 10h12" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
</svg>
Add Sub Stream
</button>
<button id="btn-new-search" class="btn btn-outline">
Add Another Camera
</button>
</div>
</div>
</div>
</div>
+160 -29
View File
@@ -1,41 +1,158 @@
/**
* Frigate NVR Configuration Generator
* Generates unified Frigate + Go2RTC YAML configs
* All cameras are routed through Frigate's built-in go2rtc for optimal performance
*/
export class FrigateGenerator {
static generate(stream) {
// For non-RTSP streams, suggest using Go2RTC
if (stream.type !== 'FFMPEG' || stream.protocol !== 'rtsp') {
return `# This stream type requires Go2RTC proxy\n\n` +
`# This ${stream.type} stream is not natively supported by Frigate.\n` +
`# Please use Go2RTC to convert it to RTSP first.\n\n` +
`# Steps:\n` +
`# 1. Add this stream to your Go2RTC configuration\n` +
`# 2. Use the Go2RTC RTSP endpoint in Frigate\n` +
`# 3. Example: rtsp://localhost:8554/camera_stream_0`;
}
// Generate RTSP config for Frigate
const cameraName = this.generateCameraName(stream);
/**
* Generate complete Frigate config with embedded Go2RTC
* @param {Object} mainStream - Main stream object (used for recording)
* @param {Object} subStream - Optional sub stream object (used for detection if provided)
* @returns {string} YAML configuration string
*/
static generate(mainStream, subStream = null) {
const cameraName = this.generateCameraName(mainStream);
const config = [];
config.push(`cameras:`);
config.push(` ${cameraName}:`);
config.push(` ffmpeg:`);
config.push(` inputs:`);
config.push(` - path: ${stream.url}`);
config.push(` roles:`);
config.push(` - detect`);
config.push(` - record`);
// MQTT Configuration
config.push('mqtt:');
config.push(' enabled: false');
config.push('');
if (stream.resolution) {
config.push(` detect:`);
const [width, height] = stream.resolution.split('x').map(Number);
if (width && height) {
config.push(` width: ${width}`);
config.push(` height: ${height}`);
}
// Global Record Configuration
config.push('# Global Recording Settings');
config.push('record:');
config.push(' enabled: true');
config.push(' retain:');
config.push(' days: 7');
config.push(' mode: motion # Record only on motion detection');
config.push('');
// Generate Go2RTC section
config.push('# Go2RTC Configuration (Frigate built-in)');
config.push('go2rtc:');
config.push(' streams:');
// Main stream configuration
const mainStreamName = this.generateStreamName(mainStream, 'main');
const mainSource = this.generateGo2RTCSource(mainStream);
config.push(` '${mainStreamName}':`);
config.push(` - ${mainSource}`);
// Sub stream configuration if provided
if (subStream) {
config.push('');
const subStreamName = this.generateStreamName(subStream, 'sub');
const subSource = this.generateGo2RTCSource(subStream);
config.push(` '${subStreamName}':`);
config.push(` - ${subSource}`);
}
config.push('');
// Generate Frigate cameras section
config.push('# Frigate Camera Configuration');
config.push('cameras:');
config.push(` ${cameraName}:`);
config.push(' ffmpeg:');
config.push(' inputs:');
if (subStream) {
// If sub stream exists: use it for detection, main for recording
const subStreamName = this.generateStreamName(subStream, 'sub');
config.push(` - path: rtsp://127.0.0.1:8554/${subStreamName}`);
config.push(' input_args: preset-rtsp-restream');
config.push(' roles:');
config.push(' - detect');
config.push(` - path: rtsp://127.0.0.1:8554/${mainStreamName}`);
config.push(' input_args: preset-rtsp-restream');
config.push(' roles:');
config.push(' - record');
} else {
// No sub stream: use main for both detection and recording
config.push(` - path: rtsp://127.0.0.1:8554/${mainStreamName}`);
config.push(' input_args: preset-rtsp-restream');
config.push(' roles:');
config.push(' - detect');
config.push(' - record');
}
// Live view configuration
if (subStream) {
config.push(' live:');
config.push(' streams:');
config.push(` Main Stream: ${mainStreamName} # HD для просмотра`);
config.push(` Sub Stream: ${this.generateStreamName(subStream, 'sub')} # Низкое разрешение (опционально)`);
}
// Object detection configuration
config.push(' objects:');
config.push(' track:');
config.push(' - person');
config.push(' - car');
config.push(' - cat');
config.push(' - dog');
// Recording configuration
config.push(' record:');
config.push(' enabled: true');
config.push('');
config.push('version: 0.16-0');
return config.join('\n');
}
/**
* Generate Go2RTC source configuration based on stream type
* Returns the source string for go2rtc streams section
*/
static generateGo2RTCSource(stream) {
// Handle JPEG snapshots with exec:ffmpeg conversion
// Uses full path to ffmpeg and {{output}} for Frigate template escaping
if (stream.type === 'JPEG') {
return [
'exec:/usr/lib/ffmpeg/7.0/bin/ffmpeg',
'-loglevel quiet',
'-f image2',
'-loop 1',
'-framerate 10',
`-i ${stream.url}`,
'-c:v libx264',
'-preset ultrafast',
'-tune zerolatency',
'-g 20',
'-f rtsp {{output}}' // Double braces for Frigate template escaping
].join(' ');
}
// Handle ONVIF - convert to onvif:// format if needed
if (stream.type === 'ONVIF') {
try {
const urlObj = new URL(stream.url);
// Extract credentials and host from HTTP URL
const username = urlObj.username || 'admin';
const password = urlObj.password || '';
const host = urlObj.hostname;
const port = urlObj.port || '80';
// Generate onvif:// URL
return `onvif://${username}:${password}@${host}:${port}`;
} catch (e) {
// If URL parsing fails, return as-is
return stream.url;
}
}
// For all other types (RTSP, MJPEG, HLS, HTTP-FLV, RTMP, etc.): use direct URL
// Go2RTC handles these formats natively
return stream.url;
}
/**
* Generate camera name from IP address
* Format: "camera_192_168_1_100"
*/
static generateCameraName(stream) {
try {
const urlObj = new URL(stream.url);
@@ -45,4 +162,18 @@ export class FrigateGenerator {
return 'camera';
}
}
/**
* Generate stream name for Go2RTC reference
* Format: "192_168_1_100_main" or "192_168_1_100_sub"
*/
static generateStreamName(stream, suffix) {
try {
const urlObj = new URL(stream.url);
const ip = urlObj.hostname.replace(/\./g, '_').replace(/:/g, '_');
return `${ip}_${suffix}`;
} catch (e) {
return `camera_stream_${suffix}`;
}
}
}
+74 -58
View File
@@ -1,50 +1,80 @@
/**
* Go2RTC Configuration Generator
* Generates proper go2rtc YAML configs based on stream type
* Following go2rtc documentation and best practices
*/
export class Go2RTCGenerator {
static generate(stream) {
const streamName = this.generateStreamName(stream);
/**
* Generate go2rtc config for streams (main + optional sub)
* @param {Object} mainStream - Main stream object with type, protocol, and url
* @param {Object} subStream - Optional sub stream object
* @returns {string} YAML configuration string
*/
static generate(mainStream, subStream = null) {
const configs = [];
configs.push('streams:');
switch (stream.type) {
case 'FFMPEG':
if (stream.protocol === 'rtsp') {
return this.generateRTSP(streamName, stream);
}
break;
case 'JPEG':
return this.generateJPEG(streamName, stream);
case 'MJPEG':
return this.generateMJPEG(streamName, stream);
case 'HTTP_VIDEO':
return this.generateHTTPVideo(streamName, stream);
case 'HLS':
return this.generateHLS(streamName, stream);
case 'ONVIF':
return `# ONVIF Device Service\n# This is a device management endpoint, not a stream\n# URL: ${stream.url}`;
default:
return this.generateRTSP(streamName, stream);
// Generate main stream config
const mainStreamName = this.generateStreamName(mainStream, 'main');
const mainSource = this.generateSource(mainStream);
configs.push(` '${mainStreamName}':`);
configs.push(` - ${mainSource}`);
// Generate sub stream config if provided
if (subStream) {
configs.push('');
const subStreamName = this.generateStreamName(subStream, 'sub');
const subSource = this.generateSource(subStream);
configs.push(` '${subStreamName}':`);
configs.push(` - ${subSource}`);
}
return configs.join('\n');
}
static generateStreamName(stream) {
/**
* Generate stream name from IP address with suffix
* Format: "192_168_1_100_main" or "192_168_1_100_sub"
*/
static generateStreamName(stream, suffix) {
try {
const urlObj = new URL(stream.url);
const ip = urlObj.hostname.replace(/\./g, '_').replace(/:/g, '_');
return `${ip}_0`;
return `${ip}_${suffix}`;
} catch (e) {
return 'camera_stream_0';
return `camera_stream_${suffix}`;
}
}
static generateRTSP(streamName, stream) {
return `streams:\n '${streamName}':\n - ${stream.url}`;
/**
* Generate source configuration based on stream type
*/
static generateSource(stream) {
// Handle JPEG snapshots with special exec:ffmpeg conversion
if (stream.type === 'JPEG') {
return this.generateJPEGSource(stream);
}
// Handle ONVIF
if (stream.type === 'ONVIF') {
return this.generateONVIFSource(stream);
}
// For all other types: use direct URL
return stream.url;
}
static generateJPEG(streamName, stream) {
const framerate = 10;
const ffmpegCmd = [
/**
* Generate JPEG snapshot conversion using exec:ffmpeg
* Converts static JPEG to RTSP stream with H264 encoding
*/
static generateJPEGSource(stream) {
return [
'exec:ffmpeg',
'-loglevel quiet',
'-f image2',
'-loop 1',
`-framerate ${framerate}`,
'-framerate 10',
`-i ${stream.url}`,
'-c:v libx264',
'-preset ultrafast',
@@ -52,36 +82,22 @@ export class Go2RTCGenerator {
'-g 20',
'-f rtsp {output}'
].join(' ');
return `streams:\n '${streamName}':\n - ${ffmpegCmd}`;
}
static generateMJPEG(streamName, stream) {
const ffmpegCmd = [
'exec:ffmpeg',
'-loglevel quiet',
`-i ${stream.url}`,
'-c:v copy',
'-f rtsp {output}'
].join(' ');
return `streams:\n '${streamName}':\n - ${ffmpegCmd}`;
}
static generateHTTPVideo(streamName, stream) {
const ffmpegCmd = [
'exec:ffmpeg',
'-loglevel quiet',
`-i ${stream.url}`,
'-c:v copy',
'-c:a copy',
'-f rtsp {output}'
].join(' ');
return `streams:\n '${streamName}':\n - ${ffmpegCmd}`;
}
static generateHLS(streamName, stream) {
return `streams:\n '${streamName}':\n - ${stream.url}`;
/**
* Generate ONVIF source
* Converts HTTP device service endpoint to onvif:// format
*/
static generateONVIFSource(stream) {
try {
const urlObj = new URL(stream.url);
const username = urlObj.username || 'admin';
const password = urlObj.password || '';
const host = urlObj.hostname;
const port = urlObj.port || '80';
return `onvif://${username}:${password}@${host}:${port}`;
} catch (e) {
return stream.url;
}
}
}
+79 -10
View File
@@ -16,7 +16,9 @@ class StrixApp {
this.currentAddress = '';
this.currentStreams = [];
this.currentStream = null;
this.selectedMainStream = null;
this.selectedSubStream = null;
this.isSelectingSubStream = false;
this.init();
}
@@ -94,11 +96,16 @@ class StrixApp {
// Screen 4: Configuration output
document.getElementById('btn-back-to-streams').addEventListener('click', () => {
this.isSelectingSubStream = false;
this.showScreen('discovery');
});
document.getElementById('btn-copy-config').addEventListener('click', () => this.copyConfig());
document.getElementById('btn-download-config').addEventListener('click', () => this.downloadConfig());
document.getElementById('btn-add-sub-stream').addEventListener('click', () => this.addSubStream());
document.getElementById('btn-remove-sub').addEventListener('click', () => this.removeSubStream());
document.getElementById('btn-new-search').addEventListener('click', () => {
this.reset();
this.showScreen('address');
@@ -171,9 +178,16 @@ class StrixApp {
async searchCameraModels(query, limit = 10, append = false) {
const dropdown = document.getElementById('autocomplete-dropdown');
// Keep dropdown open and show loading state smoothly
if (!append) {
dropdown.innerHTML = '<div class="autocomplete-loading">Searching...</div>';
dropdown.classList.remove('hidden');
const isOpen = !dropdown.classList.contains('hidden');
if (!isOpen) {
dropdown.classList.remove('hidden');
}
// Show loading only if dropdown was empty or closed
if (!isOpen || dropdown.children.length === 0) {
dropdown.innerHTML = '<div class="autocomplete-loading">Searching...</div>';
}
}
try {
@@ -316,9 +330,52 @@ class StrixApp {
}
selectStream(stream, index) {
this.currentStream = stream;
this.configPanel.render(stream);
this.showScreen('output');
if (!this.isSelectingSubStream) {
// Selecting main stream
this.selectedMainStream = stream;
this.selectedSubStream = null;
this.configPanel.render(this.selectedMainStream, this.selectedSubStream);
this.updateSubStreamUI();
this.showScreen('output');
} else {
// Selecting sub stream
this.selectedSubStream = stream;
this.isSelectingSubStream = false;
this.configPanel.render(this.selectedMainStream, this.selectedSubStream);
this.updateSubStreamUI();
this.showScreen('output');
}
}
addSubStream() {
if (this.currentStreams.length === 0) {
showToast('No streams available to select');
return;
}
this.isSelectingSubStream = true;
showToast('Select a sub stream from available streams');
this.showScreen('discovery');
}
removeSubStream() {
this.selectedSubStream = null;
this.configPanel.render(this.selectedMainStream, this.selectedSubStream);
this.updateSubStreamUI();
showToast('Sub stream removed');
}
updateSubStreamUI() {
const subStreamInfo = document.getElementById('sub-stream-info');
const addSubStreamBtn = document.getElementById('btn-add-sub-stream');
if (this.selectedSubStream) {
subStreamInfo.classList.remove('hidden');
addSubStreamBtn.style.display = 'none';
} else {
subStreamInfo.classList.add('hidden');
addSubStreamBtn.style.display = 'inline-flex';
}
}
switchTab(tabName) {
@@ -336,12 +393,22 @@ class StrixApp {
const configElement = document.getElementById(`config-${activeTab}`);
const text = configElement.textContent;
navigator.clipboard.writeText(text).then(() => {
const textarea = document.createElement('textarea');
textarea.value = text;
textarea.style.position = 'fixed';
textarea.style.left = '-9999px';
document.body.appendChild(textarea);
textarea.select();
try {
document.execCommand('copy');
showToast('Copied to clipboard!');
}).catch(err => {
} catch (err) {
showToast('Failed to copy');
console.error('Copy error:', err);
});
} finally {
document.body.removeChild(textarea);
}
}
downloadConfig() {
@@ -364,7 +431,9 @@ class StrixApp {
reset() {
this.currentAddress = '';
this.currentStreams = [];
this.currentStream = null;
this.selectedMainStream = null;
this.selectedSubStream = null;
this.isSelectingSubStream = false;
document.getElementById('network-address').value = '';
document.getElementById('camera-model').value = '';
+24 -9
View File
@@ -3,20 +3,28 @@ import { FrigateGenerator } from '../config-generators/frigate/index.js';
export class ConfigPanel {
constructor() {
this.stream = null;
this.mainStream = null;
this.subStream = null;
}
render(stream) {
this.stream = stream;
render(mainStream, subStream = null) {
this.mainStream = mainStream;
this.subStream = subStream;
// Update selected stream info
document.getElementById('selected-stream-type').textContent = stream.type;
document.getElementById('selected-stream-url').textContent = this.maskCredentials(stream.url);
// Update main stream info
document.getElementById('selected-main-type').textContent = mainStream.type;
document.getElementById('selected-main-url').textContent = this.maskCredentials(mainStream.url);
// Update sub stream info if provided
if (subStream) {
document.getElementById('selected-sub-type').textContent = subStream.type;
document.getElementById('selected-sub-url').textContent = this.maskCredentials(subStream.url);
}
// Generate configs
const urlConfig = stream.url;
const go2rtcConfig = Go2RTCGenerator.generate(stream);
const frigateConfig = FrigateGenerator.generate(stream);
const urlConfig = this.generateURLConfig();
const go2rtcConfig = Go2RTCGenerator.generate(mainStream, subStream);
const frigateConfig = FrigateGenerator.generate(mainStream, subStream);
// Update config displays
document.getElementById('config-url').textContent = urlConfig;
@@ -24,6 +32,13 @@ export class ConfigPanel {
document.getElementById('config-frigate').textContent = frigateConfig;
}
generateURLConfig() {
if (this.subStream) {
return `Main Stream:\n${this.mainStream.url}\n\nSub Stream:\n${this.subStream.url}`;
}
return this.mainStream.url;
}
maskCredentials(url) {
try {
const urlObj = new URL(url);