| 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 |
|
|
| |
| VIDEO_WIDTH = 1280 |
| VIDEO_HEIGHT = 720 |
| VIDEO_FPS = 30 |
| JPEG_QUALITY = 70 |
| STREAM_PORT = 7860 |
|
|
| |
| 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) |
|
|
| |
| COLOR_BUTTON_NORMAL = (80, 80, 80) |
| COLOR_BUTTON_HOVER = (100, 100, 200) |
| COLOR_BUTTON_CLICKED = (0, 200, 0) |
| COLOR_BUTTON_BORDER = (200, 200, 200) |
|
|
| |
| COLOR_SOUND_NONE = (255, 100, 100) |
| COLOR_SOUND_PYGAME = (100, 255, 100) |
| COLOR_SOUND_BROWSER = (100, 100, 255) |
| |
|
|
| |
| os.environ['SDL_VIDEODRIVER'] = 'dummy' |
| pygame.init() |
|
|
| |
| 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 |
| |
| |
| self.sound_source = 'none' |
| self.pygame_sound = None |
| self.pygame_playing = False |
| self.sound_amp = 0.0 |
| |
| |
| 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 |
| |
| |
| 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 |
| |
| |
| 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() |
| |
| |
| self.surface.fill(COLOR_BG) |
| |
| |
| font = pygame.font.Font(None, 36) |
| small_font = pygame.font.Font(None, 24) |
| |
| |
| 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)) |
| |
| |
| 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)) |
| |
| |
| current_time = time.time() |
| seconds = int(current_time) % 60 |
| hundredths = int((current_time * 100) % 100) |
| |
| |
| 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) |
| |
| |
| 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)) |
| |
| |
| button_rect = pygame.Rect(self.width-250, 140, 220, 50) |
| |
| |
| mouse_over = button_rect.collidepoint(self.mouse_x, self.mouse_y) |
| |
| |
| if self.button_clicked: |
| button_color = COLOR_BUTTON_CLICKED |
| elif mouse_over: |
| button_color = COLOR_BUTTON_HOVER |
| else: |
| button_color = COLOR_BUTTON_NORMAL |
| |
| |
| pygame.draw.rect(self.surface, button_color, button_rect) |
| pygame.draw.rect(self.surface, COLOR_BUTTON_BORDER, button_rect, 3) |
| |
| |
| 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) |
| |
| |
| circle_size = 40 + int(30 * np.sin(t * 2)) |
| |
| |
| 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) |
| |
| |
| 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)) |
| |
| |
| 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_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() |
| |
| img = np.frombuffer(frame, dtype=np.uint8).reshape((self.height, self.width, 3)) |
| |
| img = cv2.cvtColor(img, cv2.COLOR_RGB2BGR) |
| |
| _, jpeg = cv2.imencode('.jpg', img, [cv2.IMWRITE_JPEG_QUALITY, quality]) |
| return jpeg.tobytes() |
|
|
| renderer = ShaderRenderer() |
|
|
| |
|
|
| @app.route('/video/mjpeg') |
| 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' |
| ) |
|
|
| @app.route('/video/webm') |
| 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'} |
| ) |
|
|
| @app.route('/video/mp4') |
| def video_mp4(): |
| """GPU-accelerated MP4 streaming using NVENC""" |
| cmd = [ |
| 'ffmpeg', |
| '-hwaccel', 'cuda', |
| '-hwaccel_output_format', 'cuda', |
| '-f', 'rawvideo', |
| '-pix_fmt', 'rgb24', |
| '-s', f'{VIDEO_WIDTH}x{VIDEO_HEIGHT}', |
| '-r', '30', |
| '-i', '-', |
| '-c:v', 'h264_nvenc', |
| '-preset', 'p4', |
| '-tune', 'll', |
| '-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'} |
| ) |
|
|
| |
|
|
| @app.route('/mouse', methods=['POST']) |
| def mouse(): |
| data = request.json |
| renderer.set_mouse(data['x'], data['y']) |
| return 'OK' |
|
|
| @app.route('/click', methods=['POST']) |
| def click(): |
| data = request.json |
| renderer.handle_click(data['x'], data['y']) |
| return 'OK' |
|
|
| @app.route('/sound/source', methods=['POST']) |
| def sound_source(): |
| data = request.json |
| renderer.set_sound_source(data['source']) |
| return 'OK' |
|
|
| @app.route('/sound/amp') |
| def sound_amp(): |
| return {'amp': renderer.sound_amp} |
|
|
| @app.route('/static/sound.mp3') |
| 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 |
|
|
| |
|
|
| @app.route('/') |
| 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) |