Spaces:
Sleeping
Sleeping
| import pygame | |
| import numpy as np | |
| from flask import Flask, Response, render_template_string, request, jsonify | |
| import time | |
| import os | |
| import cv2 | |
| import subprocess | |
| import threading | |
| import queue | |
| import uuid | |
| # ============ CONFIGURATION ============ | |
| VIDEO_WIDTH = 1280 | |
| VIDEO_HEIGHT = 720 | |
| VIDEO_FPS = 30 | |
| JPEG_QUALITY = 70 | |
| STREAM_PORT = 7860 | |
| # Colors (RGB) | |
| COLOR_TOP = (255, 100, 100) | |
| COLOR_BOTTOM = (100, 255, 100) | |
| COLOR_BG = (20, 20, 30) | |
| COLOR_GRID = (50, 50, 70) | |
| COLOR_FPS = (255, 255, 0) | |
| COLOR_CLOCK = (0, 255, 255) | |
| # Button colors | |
| COLOR_BUTTON_NORMAL = (80, 80, 80) | |
| COLOR_BUTTON_HOVER = (100, 100, 200) | |
| COLOR_BUTTON_CLICKED = (0, 200, 0) | |
| COLOR_BUTTON_BORDER = (200, 200, 200) | |
| # Sound source colors | |
| COLOR_SOUND_NONE = (255, 100, 100) | |
| COLOR_SOUND_PYGAME = (100, 255, 100) | |
| COLOR_SOUND_BROWSER = (100, 100, 255) | |
| # ======================================= | |
| # Initialize Pygame headlessly | |
| os.environ['SDL_VIDEODRIVER'] = 'dummy' | |
| pygame.init() | |
| # Try to initialize mixer, but don't crash if it fails | |
| try: | |
| pygame.mixer.init(frequency=44100, size=-16, channels=2) | |
| print("✅ Audio mixer initialized") | |
| except Exception as e: | |
| print(f"⚠️ Audio mixer not available: {e}") | |
| print(" Continuing without sound...") | |
| app = Flask(__name__) | |
| class ShaderRenderer: | |
| def __init__(self, width=VIDEO_WIDTH, height=VIDEO_HEIGHT): | |
| self.width = width | |
| self.height = height | |
| self.mouse_x = width // 2 | |
| self.mouse_y = height // 2 | |
| self.start_time = time.time() | |
| self.surface = pygame.Surface((width, height)) | |
| self.frame_count = 0 | |
| self.last_frame_time = time.time() | |
| self.fps = 0 | |
| self.button_clicked = False | |
| # Sound sources | |
| self.sound_source = 'none' | |
| self.pygame_sound = None | |
| self.pygame_playing = False | |
| self.sound_amp = 0.0 | |
| # Load pygame sound if available | |
| if os.path.exists('sound.mp3'): | |
| try: | |
| self.pygame_sound = pygame.mixer.Sound('sound.mp3') | |
| print("✅ Pygame sound loaded") | |
| except: | |
| print("⚠️ Could not load sound.mp3") | |
| def set_mouse(self, x, y): | |
| self.mouse_x = max(0, min(self.width, x)) | |
| self.mouse_y = max(0, min(self.height, y)) | |
| def set_sound_source(self, source): | |
| """Change sound source: none, pygame, browser""" | |
| self.sound_source = source | |
| # Handle pygame sound | |
| if source == 'pygame': | |
| if self.pygame_sound and not self.pygame_playing: | |
| self.pygame_sound.play(loops=-1) | |
| self.pygame_playing = True | |
| self.sound_amp = 0.5 | |
| else: | |
| if self.pygame_playing: | |
| pygame.mixer.stop() | |
| self.pygame_playing = False | |
| self.sound_amp = 0.0 | |
| def handle_click(self, x, y): | |
| """Handle mouse clicks on Pygame surface""" | |
| button_rect = pygame.Rect(self.width-250, 120, 220, 50) | |
| if button_rect.collidepoint(x, y): | |
| self.button_clicked = not self.button_clicked | |
| print(f"🎯 Button {'clicked!' if self.button_clicked else 'unclicked!'}") | |
| return True | |
| return False | |
| def render_frame(self): | |
| """Render the pygame frame""" | |
| t = time.time() - self.start_time | |
| # Calculate FPS | |
| self.frame_count += 1 | |
| if time.time() - self.last_frame_time > 1.0: | |
| self.fps = self.frame_count | |
| self.frame_count = 0 | |
| self.last_frame_time = time.time() | |
| # Clear | |
| self.surface.fill(COLOR_BG) | |
| # Use larger font for 720p | |
| font = pygame.font.Font(None, 36) | |
| small_font = pygame.font.Font(None, 24) | |
| # Draw TOP marker | |
| pygame.draw.rect(self.surface, COLOR_TOP, (10, 10, 150, 40)) | |
| text = font.render("TOP", True, (255, 255, 255)) | |
| self.surface.blit(text, (30, 15)) | |
| # Draw BOTTOM marker | |
| pygame.draw.rect(self.surface, COLOR_BOTTOM, (10, self.height-50, 150, 40)) | |
| text = font.render("BOTTOM", True, (0, 0, 0)) | |
| self.surface.blit(text, (20, self.height-45)) | |
| # ===== CLOCK DISPLAY ===== | |
| current_time = time.time() | |
| seconds = int(current_time) % 60 | |
| hundredths = int((current_time * 100) % 100) | |
| # Clock background | |
| pygame.draw.rect(self.surface, (40, 40, 50), (self.width-250, 70, 220, 50)) | |
| pygame.draw.rect(self.surface, (100, 100, 150), (self.width-250, 70, 220, 50), 2) | |
| # Clock text | |
| time_str = f"{seconds:02d}.{hundredths:02d}s" | |
| clock_text = font.render(time_str, True, COLOR_CLOCK) | |
| self.surface.blit(clock_text, (self.width-230, 80)) | |
| # ===== CLICKABLE BUTTON ===== | |
| button_rect = pygame.Rect(self.width-250, 140, 220, 50) | |
| # Check if mouse is over button | |
| mouse_over = button_rect.collidepoint(self.mouse_x, self.mouse_y) | |
| # Button color based on state and hover | |
| if self.button_clicked: | |
| button_color = COLOR_BUTTON_CLICKED | |
| elif mouse_over: | |
| button_color = COLOR_BUTTON_HOVER | |
| else: | |
| button_color = COLOR_BUTTON_NORMAL | |
| # Draw button | |
| pygame.draw.rect(self.surface, button_color, button_rect) | |
| pygame.draw.rect(self.surface, COLOR_BUTTON_BORDER, button_rect, 3) | |
| # Button text | |
| if self.button_clicked: | |
| btn_text = "✅ CLICKED!" | |
| else: | |
| btn_text = "🔘 CLICK ME" | |
| text_surf = font.render(btn_text, True, (255, 255, 255)) | |
| text_rect = text_surf.get_rect(center=button_rect.center) | |
| self.surface.blit(text_surf, text_rect) | |
| # ===== MOVING CIRCLE ===== | |
| circle_size = 40 + int(30 * np.sin(t * 2)) | |
| # Color code based on sound source | |
| if self.sound_source == 'pygame': | |
| color = COLOR_SOUND_PYGAME | |
| elif self.sound_source == 'browser': | |
| color = COLOR_SOUND_BROWSER | |
| else: | |
| color = COLOR_SOUND_NONE | |
| pygame.draw.circle(self.surface, color, | |
| (self.mouse_x, self.mouse_y), circle_size) | |
| # ===== GRID ===== | |
| for x in range(0, self.width, 70): | |
| alpha = int(40 + 20 * np.sin(x * 0.1 + t)) | |
| pygame.draw.line(self.surface, (alpha, alpha, 50), | |
| (x, 0), (x, self.height)) | |
| for y in range(0, self.height, 70): | |
| alpha = int(40 + 20 * np.cos(y * 0.1 + t)) | |
| pygame.draw.line(self.surface, (alpha, alpha, 50), | |
| (0, y), (self.width, y)) | |
| # ===== SOUND METER ===== | |
| meter_width = int(250 * self.sound_amp) | |
| pygame.draw.rect(self.surface, (60, 60, 60), (self.width-270, 210, 250, 25)) | |
| pygame.draw.rect(self.surface, (100, 255, 100), | |
| (self.width-270, 210, meter_width, 25)) | |
| # ===== FPS COUNTER ===== | |
| fps_text = small_font.render(f"FPS: {self.fps}", True, COLOR_FPS) | |
| self.surface.blit(fps_text, (self.width-200, self.height-80)) | |
| return pygame.image.tostring(self.surface, 'RGB') | |
| def get_frame(self): | |
| return self.render_frame() | |
| def get_frame_jpeg(self, quality=JPEG_QUALITY): | |
| """Return frame as JPEG""" | |
| frame = self.get_frame() | |
| # Convert to numpy array for OpenCV | |
| img = np.frombuffer(frame, dtype=np.uint8).reshape((self.height, self.width, 3)) | |
| # Convert RGB to BGR for OpenCV | |
| img = cv2.cvtColor(img, cv2.COLOR_RGB2BGR) | |
| # Encode as JPEG | |
| _, jpeg = cv2.imencode('.jpg', img, [cv2.IMWRITE_JPEG_QUALITY, quality]) | |
| return jpeg.tobytes() | |
| renderer = ShaderRenderer() | |
| # ============ STREAMING ENDPOINTS ============ | |
| def video_mjpeg(): | |
| """MJPEG streaming endpoint""" | |
| def generate(): | |
| while True: | |
| frame = renderer.get_frame_jpeg() | |
| yield (b'--frame\r\n' | |
| b'Content-Type: image/jpeg\r\n\r\n' + frame + b'\r\n') | |
| time.sleep(1/VIDEO_FPS) | |
| return Response( | |
| generate(), | |
| mimetype='multipart/x-mixed-replace; boundary=frame' | |
| ) | |
| def video_webm(): | |
| """WebM streaming endpoint""" | |
| cmd = [ | |
| 'ffmpeg', | |
| '-f', 'rawvideo', | |
| '-pix_fmt', 'rgb24', | |
| '-s', f'{VIDEO_WIDTH}x{VIDEO_HEIGHT}', | |
| '-r', str(VIDEO_FPS), | |
| '-i', '-', | |
| '-c:v', 'libvpx-vp9', | |
| '-b:v', '2M', | |
| '-cpu-used', '4', | |
| '-deadline', 'realtime', | |
| '-f', 'webm', | |
| '-' | |
| ] | |
| process = subprocess.Popen( | |
| cmd, | |
| stdin=subprocess.PIPE, | |
| stdout=subprocess.PIPE, | |
| stderr=subprocess.DEVNULL, | |
| bufsize=0 | |
| ) | |
| def generate(): | |
| def push_frames(): | |
| while True: | |
| try: | |
| frame = renderer.get_frame() | |
| process.stdin.write(frame) | |
| except: | |
| break | |
| threading.Thread(target=push_frames, daemon=True).start() | |
| while True: | |
| data = process.stdout.read(4096) | |
| if not data: | |
| break | |
| yield data | |
| return Response( | |
| generate(), | |
| mimetype='video/webm', | |
| headers={'Cache-Control': 'no-cache', 'Transfer-Encoding': 'chunked'} | |
| ) | |
| def video_mp4(): | |
| """GPU-accelerated MP4 streaming using NVENC""" | |
| cmd = [ | |
| 'ffmpeg', | |
| '-hwaccel', 'cuda', # Use GPU for decoding | |
| '-hwaccel_output_format', 'cuda', # Keep data on GPU | |
| '-f', 'rawvideo', | |
| '-pix_fmt', 'rgb24', | |
| '-s', f'{VIDEO_WIDTH}x{VIDEO_HEIGHT}', | |
| '-r', '30', | |
| '-i', '-', | |
| '-c:v', 'h264_nvenc', # NVIDIA's GPU encoder | |
| '-preset', 'p4', # p1-p7, p4 is good balance | |
| '-tune', 'll', # Low latency tuning | |
| '-b:v', '2M', | |
| '-bufsize', '4M', | |
| '-f', 'mp4', | |
| '-movflags', 'frag_keyframe+empty_moov', | |
| '-' | |
| ] | |
| process = subprocess.Popen( | |
| cmd, | |
| stdin=subprocess.PIPE, | |
| stdout=subprocess.PIPE, | |
| stderr=subprocess.DEVNULL, | |
| bufsize=0 | |
| ) | |
| def generate(): | |
| def push_frames(): | |
| while True: | |
| try: | |
| frame = renderer.get_frame() | |
| process.stdin.write(frame) | |
| except: | |
| break | |
| threading.Thread(target=push_frames, daemon=True).start() | |
| while True: | |
| data = process.stdout.read(4096) | |
| if not data: | |
| break | |
| yield data | |
| return Response( | |
| generate(), | |
| mimetype='video/mp4', | |
| headers={'Cache-Control': 'no-cache', 'Transfer-Encoding': 'chunked'} | |
| ) | |
| # ============ INTERACTIVITY ENDPOINTS ============ | |
| def mouse(): | |
| data = request.json | |
| renderer.set_mouse(data['x'], data['y']) | |
| return 'OK' | |
| def click(): | |
| data = request.json | |
| renderer.handle_click(data['x'], data['y']) | |
| return 'OK' | |
| def sound_source(): | |
| data = request.json | |
| renderer.set_sound_source(data['source']) | |
| return 'OK' | |
| def sound_amp(): | |
| return {'amp': renderer.sound_amp} | |
| def serve_sound(): | |
| if os.path.exists('sound.mp3'): | |
| with open('sound.mp3', 'rb') as f: | |
| return Response(f.read(), mimetype='audio/mpeg') | |
| return 'Sound not found', 404 | |
| # ============ HTML PAGE ============ | |
| def index(): | |
| return render_template_string(f''' | |
| <!DOCTYPE html> | |
| <html> | |
| <head> | |
| <title>🎮 Pygame 720p Streaming</title> | |
| <style> | |
| body {{ | |
| margin: 0; | |
| background: #0a0a0a; | |
| color: white; | |
| font-family: 'Segoe UI', Arial, sans-serif; | |
| display: flex; | |
| justify-content: center; | |
| align-items: center; | |
| min-height: 100vh; | |
| }} | |
| .container {{ | |
| max-width: 1400px; | |
| padding: 20px; | |
| text-align: center; | |
| }} | |
| h1 {{ | |
| color: #4CAF50; | |
| margin-bottom: 20px; | |
| }} | |
| .video-container {{ | |
| background: #000; | |
| border-radius: 12px; | |
| padding: 5px; | |
| margin: 20px 0; | |
| box-shadow: 0 0 30px rgba(76, 175, 80, 0.2); | |
| position: relative; | |
| }} | |
| #videoPlayer, #mjpegImg {{ | |
| width: 100%; | |
| max-width: {VIDEO_WIDTH}px; | |
| height: auto; | |
| border-radius: 8px; | |
| display: block; | |
| margin: 0 auto; | |
| background: #111; | |
| cursor: crosshair; | |
| }} | |
| #mjpegImg {{ | |
| display: none; | |
| }} | |
| .mouse-coords {{ | |
| position: absolute; | |
| bottom: 10px; | |
| left: 10px; | |
| background: rgba(0,0,0,0.7); | |
| color: #4CAF50; | |
| padding: 5px 10px; | |
| border-radius: 20px; | |
| font-family: monospace; | |
| font-size: 14px; | |
| pointer-events: none; | |
| }} | |
| .controls {{ | |
| background: #1a1a1a; | |
| border-radius: 12px; | |
| padding: 20px; | |
| margin-top: 20px; | |
| }} | |
| .format-buttons, .sound-controls {{ | |
| display: flex; | |
| gap: 10px; | |
| justify-content: center; | |
| margin: 20px 0; | |
| flex-wrap: wrap; | |
| }} | |
| button {{ | |
| background: #333; | |
| color: white; | |
| border: none; | |
| padding: 12px 24px; | |
| font-size: 16px; | |
| border-radius: 8px; | |
| cursor: pointer; | |
| transition: all 0.3s; | |
| min-width: 120px; | |
| font-weight: bold; | |
| border: 1px solid #444; | |
| }} | |
| button:hover {{ | |
| transform: translateY(-2px); | |
| box-shadow: 0 5px 15px rgba(0,0,0,0.3); | |
| }} | |
| button.active {{ | |
| background: #4CAF50; | |
| border-color: #4CAF50; | |
| box-shadow: 0 0 20px #4CAF50; | |
| }} | |
| .status-panel {{ | |
| background: #222; | |
| border-radius: 8px; | |
| padding: 15px; | |
| margin-top: 20px; | |
| display: flex; | |
| justify-content: space-around; | |
| flex-wrap: wrap; | |
| gap: 15px; | |
| }} | |
| .status-item {{ | |
| display: flex; | |
| align-items: center; | |
| gap: 10px; | |
| }} | |
| .status-label {{ | |
| color: #888; | |
| font-size: 14px; | |
| }} | |
| .status-value {{ | |
| background: #333; | |
| padding: 5px 12px; | |
| border-radius: 20px; | |
| font-size: 14px; | |
| font-weight: bold; | |
| }} | |
| .meter {{ | |
| width: 100%; | |
| height: 25px; | |
| background: #333; | |
| border-radius: 12px; | |
| overflow: hidden; | |
| margin: 10px 0; | |
| }} | |
| .meter-fill {{ | |
| height: 100%; | |
| width: 0%; | |
| background: linear-gradient(90deg, #4CAF50, #2196F3); | |
| transition: width 0.05s; | |
| }} | |
| </style> | |
| </head> | |
| <body> | |
| <div class="container"> | |
| <h1>🎮 Pygame 720p Streaming</h1> | |
| <div class="video-container"> | |
| <video id="videoPlayer" autoplay controls muted></video> | |
| <img id="mjpegImg" crossorigin="anonymous"> | |
| <div id="mouseCoords" class="mouse-coords">X: 320, Y: 240</div> | |
| </div> | |
| <div class="controls"> | |
| <div class="format-buttons"> | |
| <button id="btnMjpeg" onclick="setFormat('mjpeg')" class="active">📸 MJPEG</button> | |
| <button id="btnWebm" onclick="setFormat('webm')">🎥 WebM</button> | |
| <button id="btnMp4" onclick="setFormat('mp4')">🎬 MP4</button> | |
| </div> | |
| <div class="sound-controls"> | |
| <button id="btnNone" onclick="setSound('none')" class="active">🔇 None</button> | |
| <button id="btnPygame" onclick="setSound('pygame')">🎮 Pygame</button> | |
| <button id="btnBrowser" onclick="setSound('browser')">🌐 Browser</button> | |
| </div> | |
| <div class="meter"> | |
| <div id="soundMeter" class="meter-fill"></div> | |
| </div> | |
| <div class="status-panel"> | |
| <div class="status-item"> | |
| <span class="status-label">Format:</span> | |
| <span id="currentFormat" class="status-value">MJPEG</span> | |
| </div> | |
| <div class="status-item"> | |
| <span class="status-label">Sound:</span> | |
| <span id="currentSource" class="status-value">None</span> | |
| </div> | |
| <div class="status-item"> | |
| <span class="status-label">Resolution:</span> | |
| <span class="status-value">{VIDEO_WIDTH}x{VIDEO_HEIGHT}</span> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| <audio id="browserAudio" loop style="display:none;"> | |
| <source src="/static/sound.mp3" type="audio/mpeg"> | |
| </audio> | |
| <script> | |
| const videoPlayer = document.getElementById('videoPlayer'); | |
| const mjpegImg = document.getElementById('mjpegImg'); | |
| const browserAudio = document.getElementById('browserAudio'); | |
| let currentFormat = 'mjpeg'; | |
| let currentSource = 'none'; | |
| let lastMouseSend = 0; | |
| // Mouse tracking | |
| function handleMouseMove(e) {{ | |
| const rect = (currentFormat === 'mjpeg' ? mjpegImg : videoPlayer).getBoundingClientRect(); | |
| const x = Math.round((e.clientX - rect.left) * {VIDEO_WIDTH} / rect.width); | |
| const y = Math.round((e.clientY - rect.top) * {VIDEO_HEIGHT} / rect.height); | |
| document.getElementById('mouseCoords').innerHTML = `X: ${{x}}, Y: ${{y}}`; | |
| const now = Date.now(); | |
| if (now - lastMouseSend > 33) {{ | |
| fetch('/mouse', {{ | |
| method: 'POST', | |
| headers: {{'Content-Type': 'application/json'}}, | |
| body: JSON.stringify({{x: x, y: y}}) | |
| }}); | |
| lastMouseSend = now; | |
| }} | |
| }} | |
| // Click handling for button | |
| function handleClick(e) {{ | |
| const rect = (currentFormat === 'mjpeg' ? mjpegImg : videoPlayer).getBoundingClientRect(); | |
| const x = Math.round((e.clientX - rect.left) * {VIDEO_WIDTH} / rect.width); | |
| const y = Math.round((e.clientY - rect.top) * {VIDEO_HEIGHT} / rect.height); | |
| fetch('/click', {{ | |
| method: 'POST', | |
| headers: {{'Content-Type': 'application/json'}}, | |
| body: JSON.stringify({{x: x, y: y}}) | |
| }}); | |
| }} | |
| videoPlayer.addEventListener('mousemove', handleMouseMove); | |
| mjpegImg.addEventListener('mousemove', handleMouseMove); | |
| videoPlayer.addEventListener('click', handleClick); | |
| mjpegImg.addEventListener('click', handleClick); | |
| // Sound handling | |
| function setSound(source) {{ | |
| currentSource = source; | |
| document.getElementById('btnNone').className = source === 'none' ? 'active' : ''; | |
| document.getElementById('btnPygame').className = source === 'pygame' ? 'active' : ''; | |
| document.getElementById('btnBrowser').className = source === 'browser' ? 'active' : ''; | |
| document.getElementById('currentSource').innerHTML = | |
| source.charAt(0).toUpperCase() + source.slice(1); | |
| if (source === 'browser') {{ | |
| browserAudio.play().catch(e => console.log('Audio error:', e)); | |
| }} else {{ | |
| browserAudio.pause(); | |
| browserAudio.currentTime = 0; | |
| }} | |
| fetch('/sound/source', {{ | |
| method: 'POST', | |
| headers: {{'Content-Type': 'application/json'}}, | |
| body: JSON.stringify({{source: source}}) | |
| }}); | |
| }} | |
| // Sound meter | |
| function updateSoundMeter() {{ | |
| fetch('/sound/amp') | |
| .then(res => res.json()) | |
| .then(data => {{ | |
| document.getElementById('soundMeter').style.width = (data.amp * 100) + '%'; | |
| }}); | |
| setTimeout(updateSoundMeter, 100); | |
| }} | |
| updateSoundMeter(); | |
| // Format switching | |
| function setFormat(format) {{ | |
| currentFormat = format; | |
| document.getElementById('btnMjpeg').className = format === 'mjpeg' ? 'active' : ''; | |
| document.getElementById('btnWebm').className = format === 'webm' ? 'active' : ''; | |
| document.getElementById('btnMp4').className = format === 'mp4' ? 'active' : ''; | |
| document.getElementById('currentFormat').innerHTML = format.toUpperCase(); | |
| videoPlayer.style.display = 'none'; | |
| mjpegImg.style.display = 'none'; | |
| if (format === 'mjpeg') {{ | |
| mjpegImg.style.display = 'block'; | |
| mjpegImg.src = '/video/mjpeg?' + Date.now(); | |
| }} else {{ | |
| videoPlayer.style.display = 'block'; | |
| videoPlayer.src = `/video/${{format}}?` + Date.now(); | |
| videoPlayer.play().catch(e => console.log('Playback error:', e)); | |
| }} | |
| }} | |
| // Initialize | |
| setFormat('mjpeg'); | |
| setSound('none'); | |
| </script> | |
| </body> | |
| </html> | |
| ''') | |
| if __name__ == '__main__': | |
| print("\n" + "="*70) | |
| print("🎮 Pygame 720p Streaming App") | |
| print("="*70) | |
| print(f"📡 Resolution: {VIDEO_WIDTH}x{VIDEO_HEIGHT} @ {VIDEO_FPS}fps") | |
| print("📡 Streaming endpoints:") | |
| print(" • /video/mjpeg - MJPEG stream") | |
| print(" • /video/webm - WebM stream") | |
| print(" • /video/mp4 - MP4 stream") | |
| print("🖱️ Interactive: mouse + clickable button") | |
| print(f"\n🌐 Main page: /") | |
| print("="*70 + "\n") | |
| port = int(os.environ.get('PORT', STREAM_PORT)) | |
| app.run(host='0.0.0.0', port=port, debug=False, threaded=True) |