+ (streamSelected)="onStreamSelected($event)" + (streamStopped)="onStreamStopped()">
diff --git a/media-analyzer/frontend/src/app/app.component.scss b/media-analyzer/frontend/src/app/app.component.scss index 3d90864..dda4489 100644 --- a/media-analyzer/frontend/src/app/app.component.scss +++ b/media-analyzer/frontend/src/app/app.component.scss @@ -14,26 +14,44 @@ .main-content { display: grid; - grid-template-columns: 1fr 2fr; + grid-template-columns: 350px 1fr; + grid-template-rows: auto auto; + grid-template-areas: + "controls viewer" + "controls analysis"; gap: 2rem; max-width: 1400px; margin: 0 auto; @media (max-width: 768px) { grid-template-columns: 1fr; + grid-template-rows: auto auto auto; + grid-template-areas: + "controls" + "viewer" + "analysis"; gap: 1rem; } } .controls-section { + grid-area: controls; background: #f8f9fa; padding: 1.5rem; border-radius: 8px; } .viewer-section { + grid-area: viewer; background: #000; padding: 1rem; border-radius: 8px; } + + .analysis-section { + grid-area: analysis; + background: #f8f9fa; + padding: 1rem; + border-radius: 8px; + } } \ No newline at end of file diff --git a/media-analyzer/frontend/src/app/app.component.ts b/media-analyzer/frontend/src/app/app.component.ts index b68cea5..8d3061a 100644 --- a/media-analyzer/frontend/src/app/app.component.ts +++ b/media-analyzer/frontend/src/app/app.component.ts @@ -1,12 +1,12 @@ -import { Component, OnInit, OnDestroy } from '@angular/core'; +import { Component, OnInit, OnDestroy, ViewChild } from '@angular/core'; import { RouterOutlet } from '@angular/router'; import { HttpClientModule } from '@angular/common/http'; import { StreamControlComponent } from './components/stream-control/stream-control.component'; import { StreamViewerComponent } from './components/stream-viewer/stream-viewer.component'; import { AnalysisPanelComponent } from './components/analysis-panel/analysis-panel.component'; import { AnalysisService } from './services/analysis.service'; +import { StreamService } from './services/stream.service'; import { DetectionResult, VisualAnalysis, Analysis } from './models/analysis'; -import { environment } from '../environments/environment'; @Component({ selector: 'app-root', @@ -16,6 +16,8 @@ import { environment } from '../environments/environment'; styleUrl: './app.component.scss' }) export class AppComponent implements OnInit, OnDestroy { + @ViewChild(StreamViewerComponent) streamViewer!: StreamViewerComponent; + title = 'Media Analyzer'; selectedStreamUrl: string = ''; currentStreamId: string = ''; @@ -23,7 +25,10 @@ export class AppComponent implements OnInit, OnDestroy { currentVisual?: VisualAnalysis; recentAnalyses: Analysis[] = []; - constructor(private analysisService: AnalysisService) {} + constructor( + private analysisService: AnalysisService, + private streamService: StreamService + ) {} ngOnInit() { // Subscribe to analysis updates @@ -47,20 +52,63 @@ export class AppComponent implements OnInit, OnDestroy { onStreamSelected(streamUrl: string) { console.log('App received stream URL:', streamUrl); - // Convert backend URL to browser-accessible URL using environment config - const browserUrl = streamUrl.replace(/^http:\/\/[^\/]+/, environment.hlsBaseUrl); - this.selectedStreamUrl = browserUrl; - console.log('Converted to browser URL:', browserUrl); + // Extract filename from backend URL, then construct a browser-resolvable URL + const filename = streamUrl.split('/').pop() || ''; + this.selectedStreamUrl = `/streaming/${filename}`; + console.log('Using HLS URL:', this.selectedStreamUrl); - // Extract stream ID from filename: 476c0bd7-d037-4b6c-a29d-0773c19a76c5.m3u8 - const streamIdMatch = streamUrl.match(/([0-9a-f-]+)\.m3u8/); + // Retry function to get active stream (with small delays to allow DB update) + const getActiveStreamWithRetry = (attempt = 1, maxAttempts = 3) => { + this.streamService.getStreams().subscribe({ + next: (response) => { + const activeStream = response.streams.find(stream => stream.status === 'active'); + if (activeStream) { + this.currentStreamId = activeStream.stream_key; + console.log('Found active stream with key:', this.currentStreamId); + // Connect to WebSocket for this stream + this.analysisService.connectToStream(this.currentStreamId); + } else if (attempt < maxAttempts) { + console.log(`No active stream found (attempt ${attempt}/${maxAttempts}), retrying in 1s...`); + setTimeout(() => getActiveStreamWithRetry(attempt + 1, maxAttempts), 1000); + } else { + console.log('No active stream found after retries, falling back to filename parsing'); + this.fallbackToFilenameExtraction(filename); + } + }, + error: (error) => { + console.error('Failed to get streams from API:', error); + this.fallbackToFilenameExtraction(filename); + } + }); + }; + + // Start the retry process + getActiveStreamWithRetry(); + } + + private fallbackToFilenameExtraction(filename: string) { + const streamIdMatch = filename.match(/^([a-zA-Z0-9-]+)\.m3u8$/); if (streamIdMatch) { this.currentStreamId = streamIdMatch[1]; - console.log('Extracted stream ID:', this.currentStreamId); - // Connect to WebSocket for this stream + console.log('Fallback: Extracted stream ID from filename:', this.currentStreamId); this.analysisService.connectToStream(this.currentStreamId); } else { - console.error('Could not extract stream ID from URL:', streamUrl); + console.error('Could not extract stream ID from filename:', filename); } } + + onStreamStopped() { + console.log('Stream stopped - clearing player'); + // Clear the stream from player + if (this.streamViewer) { + this.streamViewer.clearStream(); + } + // Clear app state + this.selectedStreamUrl = ''; + this.currentStreamId = ''; + this.currentDetections = []; + this.currentVisual = undefined; + // Disconnect from WebSocket + this.analysisService.disconnect(); + } } diff --git a/media-analyzer/frontend/src/app/app.config.ts b/media-analyzer/frontend/src/app/app.config.ts index 6c6ef60..1009fc3 100644 --- a/media-analyzer/frontend/src/app/app.config.ts +++ b/media-analyzer/frontend/src/app/app.config.ts @@ -1,8 +1,9 @@ import { ApplicationConfig } from '@angular/core'; import { provideRouter } from '@angular/router'; +import { provideHttpClient } from '@angular/common/http'; import { routes } from './app.routes'; export const appConfig: ApplicationConfig = { - providers: [provideRouter(routes)] + providers: [provideRouter(routes), provideHttpClient()] }; diff --git a/media-analyzer/frontend/src/app/components/stream-control/stream-control.component.html b/media-analyzer/frontend/src/app/components/stream-control/stream-control.component.html index e65bd23..7bb37b4 100644 --- a/media-analyzer/frontend/src/app/components/stream-control/stream-control.component.html +++ b/media-analyzer/frontend/src/app/components/stream-control/stream-control.component.html @@ -1,38 +1,94 @@

Stream Control

- -
-

Create Stream

- - + +
+ +
- -
-

Streams

-
-
-

{{ stream.name }}

-

Status: {{ stream.status }}

-

RTMP URL: {{ stream.rtmp_ingest_url }}

-

Stream Key: {{ stream.stream_key }}

+ +
+
+

Webcam Stream

+

Start your webcam for real-time logo detection

+ + + + +
+

Active Webcam Stream

+
+
+

{{ stream.name }}

+

Status: {{ stream.status }}

+

Stream Key: {{ stream.stream_key }}

+
+ +
+ + + +
+
+
+
+
+ + +
+
+ +
+

Create RTMP Stream

+

Create a stream for OBS or other RTMP sources

+ +
-
- - - + +
+

RTMP Streams

+
+
+

{{ stream.name }}

+

Status: {{ stream.status }}

+

RTMP URL: {{ stream.rtmp_ingest_url }}

+

Stream Key: {{ stream.stream_key }}

+
+ +
+ + + +
+
diff --git a/media-analyzer/frontend/src/app/components/stream-control/stream-control.component.scss b/media-analyzer/frontend/src/app/components/stream-control/stream-control.component.scss index e69de29..7d671a4 100644 --- a/media-analyzer/frontend/src/app/components/stream-control/stream-control.component.scss +++ b/media-analyzer/frontend/src/app/components/stream-control/stream-control.component.scss @@ -0,0 +1,134 @@ +.stream-control { + padding: 20px; +} + +.tabs { + display: flex; + margin-bottom: 20px; + border-bottom: 2px solid #ddd; +} + +.tab { + padding: 12px 24px; + border: none; + background: transparent; + cursor: pointer; + font-size: 16px; + font-weight: 500; + border-bottom: 3px solid transparent; + margin-right: 8px; + transition: all 0.2s; +} + +.tab:hover { + background: #f8f9fa; +} + +.tab.active { + color: #007bff; + border-bottom-color: #007bff; + background: #f8f9fa; +} + +.tab-content { + margin-top: 20px; +} + +.webcam-section, .rtmp-section { + min-height: 300px; +} + +.description { + color: #666; + margin-bottom: 15px; + font-style: italic; +} + +.btn-webcam { + background: #4CAF50; + color: white; + border: none; + padding: 12px 24px; + border-radius: 8px; + cursor: pointer; + font-size: 16px; + font-weight: 500; +} + +.btn-webcam:hover { + background: #45a049; +} + +.create-stream { + margin-bottom: 20px; + padding: 15px; + border: 1px solid #ddd; + border-radius: 8px; +} + +.input { + padding: 8px; + margin-right: 10px; + border: 1px solid #ddd; + border-radius: 4px; + min-width: 200px; +} + +.btn { + padding: 8px 16px; + border: none; + border-radius: 4px; + cursor: pointer; + margin-right: 8px; +} + +.btn-primary { background: #007bff; color: white; } +.btn-success { background: #28a745; color: white; } +.btn-danger { background: #dc3545; color: white; } +.btn-info { background: #17a2b8; color: white; } + +.btn:disabled { + background: #6c757d !important; + cursor: not-allowed; +} + +.streams-list { + border: 1px solid #ddd; + border-radius: 8px; + padding: 15px; +} + +.stream-item { + display: flex; + justify-content: space-between; + align-items: center; + padding: 15px; + border-bottom: 1px solid #eee; +} + +.stream-item:last-child { + border-bottom: none; +} + +.stream-info h4 { + margin: 0 0 8px 0; +} + +.source-type { + font-weight: normal; + font-size: 0.8em; + color: #666; +} + +.status-active { color: #28a745; font-weight: bold; } +.status-inactive { color: #6c757d; } +.status-starting { color: #ffc107; } +.status-stopping { color: #fd7e14; } +.status-error { color: #dc3545; font-weight: bold; } + +code { + background: #f8f9fa; + padding: 2px 4px; + border-radius: 3px; + font-size: 0.9em; +} \ No newline at end of file diff --git a/media-analyzer/frontend/src/app/components/stream-control/stream-control.component.ts b/media-analyzer/frontend/src/app/components/stream-control/stream-control.component.ts index 9f9bb79..43e8b7e 100644 --- a/media-analyzer/frontend/src/app/components/stream-control/stream-control.component.ts +++ b/media-analyzer/frontend/src/app/components/stream-control/stream-control.component.ts @@ -1,20 +1,8 @@ import { Component, EventEmitter, Output } from '@angular/core'; -import { HttpClient } from '@angular/common/http'; import { CommonModule } from '@angular/common'; import { FormsModule } from '@angular/forms'; -import { environment } from '../../../environments/environment'; - -interface Stream { - id: number; - name: string; - source_type: string; - processing_mode: string; - status: string; - stream_key: string; - hls_playlist_url: string | null; - rtmp_ingest_url: string; - created_at: string; -} +import { StreamService } from '../../services/stream.service'; +import { Stream } from '../../models/stream'; @Component({ selector: 'app-stream-control', @@ -25,17 +13,19 @@ interface Stream { }) export class StreamControlComponent { @Output() streamSelected = new EventEmitter(); + @Output() streamStopped = new EventEmitter(); streams: Stream[] = []; newStreamName = ''; selectedStream: Stream | null = null; + activeTab: 'rtmp' | 'webcam' = 'webcam'; - constructor(private http: HttpClient) { + constructor(private streamService: StreamService) { this.loadStreams(); } loadStreams() { - this.http.get<{streams: Stream[]}>(`${environment.apiUrl}/streams/`).subscribe({ + this.streamService.getStreams().subscribe({ next: (response) => { this.streams = response.streams; }, @@ -46,7 +36,7 @@ export class StreamControlComponent { createStream() { if (!this.newStreamName) return; - this.http.post(`${environment.apiUrl}/streams/create/`, { + this.streamService.createStream({ name: this.newStreamName, source_type: 'rtmp', processing_mode: 'live' @@ -60,8 +50,12 @@ export class StreamControlComponent { } startStream(stream: Stream) { - this.http.post(`${environment.apiUrl}/streams/${stream.id}/start/`, {}).subscribe({ - next: () => { + this.streamService.startStream(stream.stream_key).subscribe({ + next: (response) => { + console.log('Stream started, HLS URL:', response.hls_playlist_url); + // Emit the stream selection immediately with the HLS URL from response + this.streamSelected.emit(response.hls_playlist_url); + // Then reload streams to get updated status this.loadStreams(); }, error: (error) => console.error('Error starting stream:', error) @@ -69,14 +63,56 @@ export class StreamControlComponent { } stopStream(stream: Stream) { - this.http.post(`${environment.apiUrl}/streams/${stream.id}/stop/`, {}).subscribe({ + this.streamService.stopStream(stream.stream_key).subscribe({ next: () => { this.loadStreams(); + // Emit event to clear the player + this.streamStopped.emit(); }, error: (error) => console.error('Error stopping stream:', error) }); } + startWebcam() { + this.streamService.startWebcamStream().subscribe({ + next: (stream) => { + this.loadStreams(); + // Backend now waits for HLS to be ready, so we can directly select + this.selectStream(stream); + }, + error: (error) => { + console.error('Error starting webcam:', error); + if (error.status === 409) { + const activeStreamKey = error.error.active_stream_key; + if (activeStreamKey) { + console.log(`Stopping active stream ${activeStreamKey} before retrying webcam`); + this.streamService.stopStream(activeStreamKey).subscribe({ + next: () => this.startWebcam(), + error: (stopError) => { + console.error('Error stopping active stream:', stopError); + alert(`Cannot start webcam: ${error.error.error}`); + } + }); + } else { + alert(`Cannot start webcam: ${error.error.error}`); + } + } + } + }); + } + + switchTab(tab: 'rtmp' | 'webcam') { + this.activeTab = tab; + } + + get rtmpStreams() { + return this.streams.filter(stream => stream.source_type === 'rtmp'); + } + + get webcamStreams() { + return this.streams.filter(stream => stream.source_type === 'webcam'); + } + selectStream(stream: Stream) { this.selectedStream = stream; if (stream.hls_playlist_url) { diff --git a/media-analyzer/frontend/src/app/components/stream-viewer/stream-viewer.component.ts b/media-analyzer/frontend/src/app/components/stream-viewer/stream-viewer.component.ts index 5d287d2..717101a 100644 --- a/media-analyzer/frontend/src/app/components/stream-viewer/stream-viewer.component.ts +++ b/media-analyzer/frontend/src/app/components/stream-viewer/stream-viewer.component.ts @@ -188,4 +188,24 @@ export class StreamViewerComponent implements AfterViewInit, OnDestroy, OnChange const canvas = this.overlayElement.nativeElement; this.ctx.clearRect(0, 0, canvas.width, canvas.height); } + + clearStream() { + const video = this.videoElement.nativeElement; + + // Stop and clear HLS + if (this.hls) { + this.hls.destroy(); + this.hls = undefined; + } + + // Clear video source and stop playback + video.src = ''; + video.srcObject = null; + video.load(); // Reset video element + + // Clear overlay + this.clearOverlay(); + + console.log('Stream cleared'); + } } diff --git a/media-analyzer/frontend/src/app/models/stream.ts b/media-analyzer/frontend/src/app/models/stream.ts index 40ef812..45c4a69 100644 --- a/media-analyzer/frontend/src/app/models/stream.ts +++ b/media-analyzer/frontend/src/app/models/stream.ts @@ -1,2 +1,11 @@ export interface Stream { + id: number; + name: string; + source_type: 'rtmp' | 'webcam' | 'file' | 'url'; + processing_mode: 'live' | 'batch'; + status: 'inactive' | 'starting' | 'active' | 'stopping' | 'error'; + stream_key: string; + hls_playlist_url?: string; + rtmp_ingest_url?: string; + created_at: string; } diff --git a/media-analyzer/frontend/src/app/services/analysis.service.ts b/media-analyzer/frontend/src/app/services/analysis.service.ts index 8e70987..e14689f 100644 --- a/media-analyzer/frontend/src/app/services/analysis.service.ts +++ b/media-analyzer/frontend/src/app/services/analysis.service.ts @@ -10,6 +10,7 @@ export class AnalysisService { private currentDetections = new BehaviorSubject([]); private currentVisual = new BehaviorSubject(null); private recentAnalyses = new BehaviorSubject([]); + private streamStartTime: Date | null = null; public detections$ = this.currentDetections.asObservable(); public visual$ = this.currentVisual.asObservable(); @@ -23,16 +24,34 @@ export class AnalysisService { } connectToStream(streamId: string) { - this.websocketService.connect(streamId); + this.streamStartTime = new Date(); + this.websocketService.subscribe(streamId); } disconnect() { + this.websocketService.unsubscribe(); this.websocketService.disconnect(); this.currentDetections.next([]); this.currentVisual.next(null); + this.streamStartTime = null; } private handleAnalysisUpdate(analysis: Analysis) { + console.log('Received analysis update:', analysis); + + // Filter out analysis from before stream started (with 30 second buffer for recent analysis) + if (this.streamStartTime && analysis.timestamp) { + const analysisTime = new Date(analysis.timestamp); + const bufferTime = new Date(this.streamStartTime.getTime() - 30000); // 30 seconds before stream start + if (analysisTime < bufferTime) { + console.log('Ignoring old analysis from before stream start:', { + analysisTime: analysisTime.toISOString(), + streamStart: this.streamStartTime.toISOString() + }); + return; + } + } + // Update recent analyses list const current = this.recentAnalyses.value; const updated = [analysis, ...current.slice(0, 9)]; // Keep last 10 diff --git a/media-analyzer/frontend/src/app/services/stream.service.ts b/media-analyzer/frontend/src/app/services/stream.service.ts index e316571..741b292 100644 --- a/media-analyzer/frontend/src/app/services/stream.service.ts +++ b/media-analyzer/frontend/src/app/services/stream.service.ts @@ -1,9 +1,34 @@ import { Injectable } from '@angular/core'; +import { HttpClient } from '@angular/common/http'; +import { Observable } from 'rxjs'; +import { Stream } from '../models/stream'; +import { environment } from '../../environments/environment'; @Injectable({ providedIn: 'root' }) export class StreamService { + private apiUrl = `${environment.apiUrl}/streaming`; - constructor() { } + constructor(private http: HttpClient) { } + + getStreams(): Observable<{streams: Stream[]}> { + return this.http.get<{streams: Stream[]}>(`${this.apiUrl}/streams/`); + } + + createStream(streamData: any): Observable { + return this.http.post(`${this.apiUrl}/streams/create/`, streamData); + } + + startWebcamStream(): Observable { + return this.http.post(`${this.apiUrl}/streams/webcam/start/`, {}); + } + + startStream(streamKey: string): Observable<{message: string, hls_playlist_url: string}> { + return this.http.post<{message: string, hls_playlist_url: string}>(`${this.apiUrl}/streams/${streamKey}/start/`, {}); + } + + stopStream(streamKey: string): Observable<{message: string}> { + return this.http.post<{message: string}>(`${this.apiUrl}/streams/${streamKey}/stop/`, {}); + } } diff --git a/media-analyzer/frontend/src/app/services/websocket.service.ts b/media-analyzer/frontend/src/app/services/websocket.service.ts index d75ee2d..11d1036 100644 --- a/media-analyzer/frontend/src/app/services/websocket.service.ts +++ b/media-analyzer/frontend/src/app/services/websocket.service.ts @@ -7,6 +7,7 @@ import { Analysis } from '../models/analysis'; }) export class WebsocketService { private socket?: WebSocket; + private currentStreamId?: string; private analysisSubject = new Subject(); private connectionStatus = new BehaviorSubject(false); @@ -15,13 +16,13 @@ export class WebsocketService { constructor() { } - connect(streamId: string) { + connect() { if (this.socket?.readyState === WebSocket.OPEN) { return; } const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; - const wsUrl = `${protocol}//${window.location.host}/ws/stream/${streamId}/`; + const wsUrl = `${protocol}//${window.location.host}/ws/stream/`; console.log('Connecting to WebSocket:', wsUrl); this.socket = new WebSocket(wsUrl); @@ -63,6 +64,7 @@ export class WebsocketService { if (this.socket) { this.socket.close(); this.socket = undefined; + this.currentStreamId = undefined; this.connectionStatus.next(false); } } @@ -79,4 +81,17 @@ export class WebsocketService { timestamp: Date.now() }); } + + subscribe(streamId: string) { + this.currentStreamId = streamId; + this.connect(); + this.send({ type: 'subscribe', stream_id: streamId }); + } + + unsubscribe() { + if (this.currentStreamId) { + this.send({ type: 'unsubscribe', stream_id: this.currentStreamId }); + this.currentStreamId = undefined; + } + } } diff --git a/thevideo.MD b/thevideo.MD index 79cd3b1..c3fb523 100644 --- a/thevideo.MD +++ b/thevideo.MD @@ -18,19 +18,20 @@ video structure keyboards (early days) music vs coding (the gap gets wider) recurrent back to basics + behind the scenes + the setup + deskmeter + timelapses/ffmpeg demo phase 1 phase 2 phase 3 extras - behind the scenes - the setup - deskmeter - timelapses/ffmpeg - make your own path + opinions bootcamps pimp-up-your-profile new trend - for seenka + show the current state of my use of AI tools + motivations im not in it (just) for the money video processing is my passion (?