diff --git a/FRIGATE_RECORD_CONFIG.md b/FRIGATE_RECORD_CONFIG.md new file mode 100644 index 0000000..7ca288c --- /dev/null +++ b/FRIGATE_RECORD_CONFIG.md @@ -0,0 +1,155 @@ +# Frigate Configuration - Запись по движению с детекцией объектов + +Конфигурация для Frigate с записью при обнаружении движения и детекцией объектов (person, car, cat, dog). + +## Dual-Stream конфиг (Main + Sub) - РЕКОМЕНДУЕТСЯ + +Используется sub stream для детекции (экономия CPU), main stream для записи (качество). + +```yaml +mqtt: + enabled: false + +# Глобальные настройки записи +record: + enabled: true + retain: + days: 7 + mode: motion # Записывать только при движении + +# Go2RTC Configuration (Frigate built-in) +go2rtc: + streams: + '10_0_20_112_main': + - rtsp://admin:password@10.0.20.112/live/main + + '10_0_20_112_sub': + - rtsp://admin:password@10.0.20.112/live/sub + +# Frigate Camera Configuration +cameras: + camera_10_0_20_112: + ffmpeg: + inputs: + - path: rtsp://127.0.0.1:8554/10_0_20_112_sub + input_args: preset-rtsp-restream + roles: + - detect + - path: rtsp://127.0.0.1:8554/10_0_20_112_main + input_args: preset-rtsp-restream + roles: + - record + live: + streams: + Main Stream: 10_0_20_112_main # HD для просмотра + Sub Stream: 10_0_20_112_sub # Низкое разрешение (опционально) + objects: + track: + - person + - car + - cat + - dog + record: + enabled: true + +version: 0.16-0 +``` + +## Single-Stream конфиг (Main только) + +Когда нет sub stream - используется main для детекции и записи. + +```yaml +mqtt: + enabled: false + +# Глобальные настройки записи +record: + enabled: true + retain: + days: 7 + mode: motion # Записывать только при движении + +# Go2RTC Configuration (Frigate built-in) +go2rtc: + streams: + '10_0_20_112_main': + - rtsp://admin:password@10.0.20.112/stream1 + +# Frigate Camera Configuration +cameras: + camera_10_0_20_112: + ffmpeg: + inputs: + - path: rtsp://127.0.0.1:8554/10_0_20_112_main + input_args: preset-rtsp-restream + roles: + - detect + - record + objects: + track: + - person + - car + - cat + - dog + record: + enabled: true + +version: 0.16-0 +``` + +## Режимы записи + +### `mode: motion` (рекомендуется) +Записывает видео при обнаружении движения. Экономит место на диске. + +### `mode: active_objects` +Записывает только когда обнаружены объекты (person, car, etc). Еще больше экономия. + +### `mode: all` +Записывает постоянно 24/7. Требует много места на диске. + +## Преимущества Dual-Stream подхода + +✅ **Низкая нагрузка на CPU** - детекция на sub stream (обычно 352x288 или 640x480) +✅ **Качественная запись** - запись на main stream в полном разрешении (HD/4K) +✅ **Быстрая детекция** - меньше пикселей = быстрее обработка +✅ **Авто-определение разрешения** - Frigate сам определяет параметры потока +✅ **Одно подключение к камере** - Go2RTC мультиплексирует потоки + +## Что делает этот конфиг + +✅ **Детекция** - работает постоянно, ищет объекты +✅ **Запись** - начинается при движении +✅ **Объекты** - распознает person, car, cat, dog +✅ **Хранение** - 7 дней записи +✅ **Snapshots** - сохраняются автоматически при детекции + +## Добавление других объектов + +Чтобы добавить больше объектов для детекции, измените секцию `objects.track`: + +```yaml +objects: + track: + - person + - car + - cat + - dog + - motorcycle # Мотоциклы + - bicycle # Велосипеды + - truck # Грузовики + - bus # Автобусы +``` + +Полный список доступных объектов: https://docs.frigate.video/configuration/objects/ + +## Примечания + +- Dual-stream экономит CPU, используйте когда камера поддерживает sub stream +- Single-stream проще, но требует больше CPU для детекции (особенно на 4K) +- Frigate автоматически определяет разрешение потоков, блок `detect` не нужен +- Запись по движению экономит место, но может пропустить начало события +- Для непрерывной записи используйте `mode: all` +- Frigate автоматически управляет удалением старых записей +- Main stream поддерживает любое разрешение: HD (1920x1080), 4K (3840x2160) и выше diff --git a/webui/CONFIG_GENERATORS.md b/webui/CONFIG_GENERATORS.md new file mode 100644 index 0000000..b0350cb --- /dev/null +++ b/webui/CONFIG_GENERATORS.md @@ -0,0 +1,279 @@ +# Configuration Generators Documentation + +This document describes how Strix generates configurations for go2rtc and Frigate with support for main and sub streams. + +## Go2RTC Generator (`webui/web/js/config-generators/go2rtc/index.js`) + +### Purpose +Generates YAML configurations for go2rtc based on discovered camera streams. Supports both single stream and dual-stream (main + sub) configurations. + +### Stream Naming Convention + +**Format:** `_` + +**Examples:** +- Main: `192.168.1.100` → `192_168_1_100_main` +- Sub: `192.168.1.100` → `192_168_1_100_sub` +- Main: `10.0.20.112` → `10_0_20_112_main` +- Sub: `10.0.20.112` → `10_0_20_112_sub` + +### Single Stream Configuration + +When only a main stream is selected: + +```yaml +streams: + '192_168_1_100_main': + - rtsp://admin:password@192.168.1.100/stream1 +``` + +### Dual Stream Configuration (Main + Sub) + +When both main and sub streams are selected: + +```yaml +streams: + '192_168_1_100_main': + - rtsp://admin:password@192.168.1.100/live/main + + '192_168_1_100_sub': + - rtsp://admin:password@192.168.1.100/live/sub +``` + +### Logic by Stream Type + +#### 1. **JPEG Snapshots** (Special Case) +Static JPEG images require conversion to video stream using FFmpeg. + +**Generated Config:** +```yaml +streams: + '10_0_20_112_main': + - exec:ffmpeg -loglevel quiet -f image2 -loop 1 -framerate 10 -i http://admin:pass@10.0.20.112/snapshot.jpg -c:v libx264 -preset ultrafast -tune zerolatency -g 20 -f rtsp {output} +``` + +**Parameters:** +- `-f image2 -loop 1`: Loop single image +- `-framerate 10`: 10 fps output +- `-c:v libx264`: H264 encoding +- `-preset ultrafast -tune zerolatency`: Low latency +- `-g 20`: GOP size for keyframes +- `-f rtsp {output}`: Output to RTSP (go2rtc internal) + +#### 2. **All Other Formats** (Direct Pass-through) +For RTSP, MJPEG, HLS, HTTP-FLV, HTTP-TS, RTMP - use direct URL. +go2rtc has native support for these formats. + +**Supported Formats:** +- **RTSP** (`rtsp://`) - Direct support +- **RTMP** (`rtmp://`) - Direct support +- **MJPEG** (`http://...mjpeg`) - Direct support +- **HLS** (`http://...m3u8`) - Direct support +- **HTTP-FLV** (`http://...flv`) - Direct support +- **HTTP-TS** (`http://...ts`) - Direct support + +#### 3. **ONVIF Device Endpoints** +ONVIF URLs are converted to `onvif://` format: + +```yaml +streams: + '192_168_1_100_main': + - onvif://admin:password@192.168.1.100:80 +``` + +## Frigate Generator (`webui/web/js/config-generators/frigate/index.js`) + +### Purpose +Generates unified Frigate + Go2RTC YAML configurations with intelligent stream routing for optimal performance. + +### Key Features + +- **Motion-based recording**: Records only when motion is detected +- **Object detection**: Tracks person, car, cat, dog +- **Smart stream routing**: + - If sub stream exists → detect on sub (low CPU), record on main (quality) + - If no sub stream → detect and record on main + +### Benefits of Dual-Stream Setup + +✅ **Lower CPU usage**: Detection runs on lower resolution sub stream +✅ **Better quality**: Recording uses high resolution main stream +✅ **Single connection per camera**: Go2RTC multiplexes streams +✅ **Optimal performance**: Each task uses appropriate stream quality + +### Single Stream Configuration (Main Only) + +When only main stream is selected, it handles both detection and recording: + +```yaml +mqtt: + enabled: false + +# Global Recording Settings +record: + enabled: true + retain: + days: 7 + mode: motion # Record only on motion detection + +# Go2RTC Configuration (Frigate built-in) +go2rtc: + streams: + '192_168_1_100_main': + - rtsp://admin:password@192.168.1.100/stream1 + +# Frigate Camera Configuration +cameras: + camera_192_168_1_100: + ffmpeg: + inputs: + - path: rtsp://127.0.0.1:8554/192_168_1_100_main + input_args: preset-rtsp-restream + roles: + - detect + - record + objects: + track: + - person + - car + - cat + - dog + record: + enabled: true + +version: 0.16-0 +``` + +### Dual Stream Configuration (Main + Sub) + +When both streams are selected, detection uses sub stream and recording uses main stream: + +```yaml +mqtt: + enabled: false + +# Global Recording Settings +record: + enabled: true + retain: + days: 7 + mode: motion # Record only on motion detection + +# Go2RTC Configuration (Frigate built-in) +go2rtc: + streams: + '192_168_1_100_main': + - rtsp://admin:password@192.168.1.100/live/main + + '192_168_1_100_sub': + - rtsp://admin:password@192.168.1.100/live/sub + +# Frigate Camera Configuration +cameras: + camera_192_168_1_100: + ffmpeg: + inputs: + - path: rtsp://127.0.0.1:8554/192_168_1_100_sub + input_args: preset-rtsp-restream + roles: + - detect + - path: rtsp://127.0.0.1:8554/192_168_1_100_main + input_args: preset-rtsp-restream + roles: + - record + live: + streams: + Main Stream: 192_168_1_100_main # HD для просмотра + Sub Stream: 192_168_1_100_sub # Низкое разрешение (опционально) + objects: + track: + - person + - car + - cat + - dog + record: + enabled: true + +version: 0.16-0 +``` + +### Why Sub Stream for Detection? + +✅ **CPU Efficiency**: Processing lower resolution (typically 352x288 or 640x480) instead of HD/4K +✅ **Faster Inference**: ML model runs faster on smaller resolution +✅ **Sufficient Accuracy**: Object detection doesn't need Full HD or 4K +✅ **Quality Recording**: Main stream at full resolution (HD/4K) saved to disk +✅ **Auto-detection**: Frigate automatically detects stream resolution + +### Camera Naming Convention + +**Format:** `camera_` + +**Examples:** +- `192.168.1.100` → `camera_192_168_1_100` +- `10.0.20.112` → `camera_10_0_20_112` +- `camera.local` → `camera_camera_local` + +### Object Detection + +The generator includes basic object detection for common use cases: + +- **person** - Human detection +- **car** - Vehicle detection +- **cat** - Cat detection +- **dog** - Dog detection + +To add more objects, edit the generated config and add items from [Frigate's object list](https://docs.frigate.video/configuration/objects/). + +### Recording Mode + +**Mode: `motion`** (Default) +- Records only when motion is detected +- Saves disk space +- May miss the start of an event + +**To enable 24/7 recording**, change to: +```yaml +record: + enabled: true + retain: + days: 7 + mode: all # Continuous recording +``` + +## Workflow + +``` +Stream Discovery + ↓ +User selects Main Stream + ↓ +Config generated (main only) + ↓ +┌─────────────────────┐ +│ User clicks │ +│ "Add Sub Stream" │ +└──────────┬──────────┘ + ↓ +User selects Sub Stream from existing results + ↓ +Config regenerated (main + sub with optimized routing) +``` + +## Key Principles + +1. **No additional scanning**: Sub stream is selected from already discovered streams +2. **Intelligent routing**: Sub for detect, main for record (when both available) +3. **Simplicity first**: Use direct URLs whenever possible +4. **Native support**: Leverage go2rtc's built-in format support +5. **Special cases only**: Only use exec:ffmpeg for JPEG snapshots +6. **Motion-based recording**: Save disk space by default + +## Benefits of This Approach + +✅ **Better performance**: Optimal stream selection for each task +✅ **Lower CPU usage**: Detection on lower resolution when sub stream available +✅ **Quality recordings**: Full resolution saved to disk +✅ **User flexibility**: Optional sub stream - not required +✅ **No re-scanning**: Reuses already discovered streams +✅ **Disk space efficiency**: Motion-based recording by default diff --git a/webui/web/css/main.css b/webui/web/css/main.css index 21ca2e4..81e9374 100644 --- a/webui/web/css/main.css +++ b/webui/web/css/main.css @@ -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; diff --git a/webui/web/index.html b/webui/web/index.html index d3d87d1..0da824b 100644 --- a/webui/web/index.html +++ b/webui/web/index.html @@ -217,14 +217,16 @@ diff --git a/webui/web/js/config-generators/frigate/index.js b/webui/web/js/config-generators/frigate/index.js index ad02346..5682861 100644 --- a/webui/web/js/config-generators/frigate/index.js +++ b/webui/web/js/config-generators/frigate/index.js @@ -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}`; + } + } } diff --git a/webui/web/js/config-generators/go2rtc/index.js b/webui/web/js/config-generators/go2rtc/index.js index c8a2dee..ce963ac 100644 --- a/webui/web/js/config-generators/go2rtc/index.js +++ b/webui/web/js/config-generators/go2rtc/index.js @@ -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; + } } } diff --git a/webui/web/js/main.js b/webui/web/js/main.js index 86d0717..fd788df 100644 --- a/webui/web/js/main.js +++ b/webui/web/js/main.js @@ -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 = '
Searching...
'; - 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 = '
Searching...
'; + } } 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 = ''; diff --git a/webui/web/js/ui/config-panel.js b/webui/web/js/ui/config-panel.js index 55c43b0..ae044e9 100644 --- a/webui/web/js/ui/config-panel.js +++ b/webui/web/js/ui/config-panel.js @@ -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);