From aad352a80fc84fe76bb8070bf6865919b5aed42b Mon Sep 17 00:00:00 2001 From: Khoa Vo Date: Sat, 20 Dec 2025 14:57:01 +0700 Subject: [PATCH] feat: client-side video optimization - remove server transcoding for instant loading and zero CPU --- backend/api/routes/feed.py | 94 ++++++------------------- frontend/package-lock.json | 14 ++++ frontend/src/components/VideoPlayer.tsx | 62 ++++++++++++++-- 3 files changed, 91 insertions(+), 79 deletions(-) diff --git a/backend/api/routes/feed.py b/backend/api/routes/feed.py index 04ca4d8..752b91f 100644 --- a/backend/api/routes/feed.py +++ b/backend/api/routes/feed.py @@ -190,18 +190,21 @@ async def proxy_video( ): """ Proxy video with LRU caching for mobile optimization. - Prefers H.264 codec for browser compatibility, re-encodes HEVC if needed. + OPTIMIZED: No server-side transcoding - client handles decoding. + This reduces server CPU to ~0% during video playback. """ import yt_dlp import re - import subprocess # Check cache first cached_path = get_cached_path(url) if cached_path: print(f"CACHE HIT: {url[:50]}...") - response_headers = {} + response_headers = { + "Accept-Ranges": "bytes", + "Cache-Control": "public, max-age=3600", + } if download: video_id_match = re.search(r'/video/(\d+)', url) video_id = video_id_match.group(1) if video_id_match else "tiktok_video" @@ -232,10 +235,10 @@ async def proxy_video( cookie_file.close() cookie_file_path = cookie_file.name - # Prefer H.264 (avc1) over HEVC (hvc1/hev1) for browser compatibility - # Format selection: prefer mp4 with h264, fallback to best mp4 + # Download best quality - NO TRANSCODING (let client decode) + # Prefer H.264 when available, but accept any codec ydl_opts = { - 'format': 'best[ext=mp4][vcodec^=avc]/best[ext=mp4]/best', # Prefer H.264 + 'format': 'best[ext=mp4][vcodec^=avc]/best[ext=mp4]/best', 'outtmpl': output_template, 'quiet': True, 'no_warnings': True, @@ -249,7 +252,7 @@ async def proxy_video( ydl_opts['cookiefile'] = cookie_file_path video_path = None - video_codec = None + video_codec = "unknown" try: loop = asyncio.get_event_loop() @@ -258,8 +261,7 @@ async def proxy_video( with yt_dlp.YoutubeDL(ydl_opts) as ydl: info = ydl.extract_info(url, download=True) ext = info.get('ext', 'mp4') - # Get codec info - vcodec = info.get('vcodec', '') or '' + vcodec = info.get('vcodec', 'unknown') or 'unknown' return os.path.join(temp_dir, f"video.{ext}"), vcodec video_path, video_codec = await loop.run_in_executor(None, download_video) @@ -267,70 +269,9 @@ async def proxy_video( if not os.path.exists(video_path): raise Exception("Video file not created") - print(f"Downloaded codec: {video_codec}") + print(f"Downloaded codec: {video_codec} (no transcoding - client will decode)") - # Check if we need to re-encode HEVC to H.264 - # If codec unknown, probe the actual file - is_hevc = False - if video_codec: - is_hevc = any(x in video_codec.lower() for x in ['hevc', 'hev1', 'hvc1', 'h265', 'h.265']) - else: - # Try probing with yt-dlp's ffprobe - try: - probe_cmd = ['ffprobe', '-v', 'error', '-select_streams', 'v:0', - '-show_entries', 'stream=codec_name', '-of', 'csv=p=0', video_path] - probe_result = subprocess.run(probe_cmd, capture_output=True, timeout=10) - detected_codec = probe_result.stdout.decode().strip().lower() - is_hevc = 'hevc' in detected_codec or 'h265' in detected_codec - print(f"Probed codec: {detected_codec}") - except Exception as probe_err: - print(f"Probe failed: {probe_err}") - - if is_hevc: - print(f"HEVC detected, re-encoding to H.264 for browser compatibility...") - h264_path = os.path.join(temp_dir, "video_h264.mp4") - - def reencode_to_h264(): - # Use ffmpeg to re-encode from HEVC to H.264 - cmd = [ - 'ffmpeg', '-y', - '-i', video_path, - '-c:v', 'libx264', # H.264 codec - '-preset', 'fast', # Balance speed and quality - '-crf', '23', # Quality (lower = better, 18-28 is good) - '-c:a', 'aac', # AAC audio - '-movflags', '+faststart', # Web optimization - h264_path - ] - try: - result = subprocess.run(cmd, capture_output=True, timeout=120) - if result.returncode != 0: - print(f"FFmpeg error: {result.stderr.decode()[:200]}") - return None - return h264_path - except FileNotFoundError: - print("FFmpeg not installed, trying yt-dlp postprocessor...") - # Fallback: re-download with recode postprocessor - recode_opts = ydl_opts.copy() - recode_opts['postprocessors'] = [{'key': 'FFmpegVideoConvertor', 'preferedformat': 'mp4'}] - recode_opts['postprocessor_args'] = ['-c:v', 'libx264', '-preset', 'fast', '-crf', '23'] - recode_opts['outtmpl'] = h264_path.replace('.mp4', '.%(ext)s') - try: - with yt_dlp.YoutubeDL(recode_opts) as ydl: - ydl.download([url]) - return h264_path if os.path.exists(h264_path) else None - except: - return None - - reencoded_path = await loop.run_in_executor(None, reencode_to_h264) - - if reencoded_path and os.path.exists(reencoded_path): - video_path = reencoded_path - print("Re-encoding successful!") - else: - print("Re-encoding failed, using original video") - - # Save to cache for future requests + # Save to cache directly - NO TRANSCODING cached_path = save_to_cache(url, video_path) stats = get_cache_stats() print(f"CACHED: {url[:50]}... ({stats['files']} files, {stats['size_mb']}MB total)") @@ -349,8 +290,12 @@ async def proxy_video( os.unlink(cookie_file_path) shutil.rmtree(temp_dir, ignore_errors=True) - # Return from cache - response_headers = {} + # Return from cache with codec info header + response_headers = { + "Accept-Ranges": "bytes", + "Cache-Control": "public, max-age=3600", + "X-Video-Codec": video_codec, # Let client know the codec + } if download: video_id_match = re.search(r'/video/(\d+)', url) video_id = video_id_match.group(1) if video_id_match else "tiktok_video" @@ -363,6 +308,7 @@ async def proxy_video( ) + @router.get("/thin-proxy") async def thin_proxy_video( request: Request, diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 3f81f03..63aa46f 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -81,6 +81,7 @@ "integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.5", @@ -1409,6 +1410,7 @@ "integrity": "sha512-N2clP5pJhB2YnZJ3PIHFk5RkygRX5WO/5f0WC08tp0wd+sv0rsJk3MqWn3CbNmT2J505a5336jaQj4ph1AdMug==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "undici-types": "~6.21.0" } @@ -1426,6 +1428,7 @@ "integrity": "sha512-cisd7gxkzjBKU2GgdYrTdtQx1SORymWyaAFhaxQPK9bYO9ot3Y5OikQRvY0VYQtvwjeQnizCINJAenh/V7MK2w==", "devOptional": true, "license": "MIT", + "peer": true, "dependencies": { "@types/prop-types": "*", "csstype": "^3.2.2" @@ -1486,6 +1489,7 @@ "integrity": "sha512-6/cmF2piao+f6wSxUsJLZjck7OQsYyRtcOZS02k7XINSNlz93v6emM8WutDQSXnroG2xwYlEVHJI+cPA7CPM3Q==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.50.0", "@typescript-eslint/types": "8.50.0", @@ -1737,6 +1741,7 @@ "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "dev": true, "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -1959,6 +1964,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", @@ -2364,6 +2370,7 @@ "integrity": "sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -3007,6 +3014,7 @@ "integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==", "dev": true, "license": "MIT", + "peer": true, "bin": { "jiti": "bin/jiti.js" } @@ -3507,6 +3515,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", @@ -3702,6 +3711,7 @@ "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", "license": "MIT", + "peer": true, "dependencies": { "loose-envify": "^1.1.0" }, @@ -3714,6 +3724,7 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", "license": "MIT", + "peer": true, "dependencies": { "loose-envify": "^1.1.0", "scheduler": "^0.23.2" @@ -4121,6 +4132,7 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -4186,6 +4198,7 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -4279,6 +4292,7 @@ "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "esbuild": "^0.21.3", "postcss": "^8.4.43", diff --git a/frontend/src/components/VideoPlayer.tsx b/frontend/src/components/VideoPlayer.tsx index cafa9f8..f3329bf 100644 --- a/frontend/src/components/VideoPlayer.tsx +++ b/frontend/src/components/VideoPlayer.tsx @@ -1,9 +1,16 @@ import React, { useRef, useState, useEffect } from 'react'; -import { Download, UserPlus, Check, Volume2, VolumeX } from 'lucide-react'; +import { Download, UserPlus, Check, Volume2, VolumeX, AlertCircle } from 'lucide-react'; import type { Video } from '../types'; import { API_BASE_URL } from '../config'; import { videoCache } from '../utils/videoCache'; +// Check if browser supports HEVC codec (Safari, Chrome 107+, Edge) +const supportsHEVC = (): boolean => { + if (typeof MediaSource === 'undefined') return false; + return MediaSource.isTypeSupported('video/mp4; codecs="hvc1"') || + MediaSource.isTypeSupported('video/mp4; codecs="hev1"'); +}; + interface HeartParticle { id: number; x: number; @@ -42,9 +49,15 @@ export const VideoPlayer: React.FC = ({ const [localMuted, setLocalMuted] = useState(true); const isMuted = externalMuted !== undefined ? externalMuted : localMuted; const [hearts, setHearts] = useState([]); +<<<<<<< HEAD const [isLoading, setIsLoading] = useState(true); const [cachedUrl, setCachedUrl] = useState(null); +======= + const [isLoading, setIsLoading] = useState(true); // Show loading spinner until video is ready + const [codecError, setCodecError] = useState(false); // True if video codec not supported +>>>>>>> 6153739 (feat: client-side video optimization - remove server transcoding for instant loading and zero CPU) const lastTapRef = useRef(0); + const browserSupportsHEVC = useRef(supportsHEVC()); const fullProxyUrl = `${API_BASE_URL}/feed/proxy?url=${encodeURIComponent(video.url)}`; const thinProxyUrl = video.cdn_url ? `${API_BASE_URL}/feed/thin-proxy?cdn_url=${encodeURIComponent(video.cdn_url)}` : null; @@ -106,6 +119,7 @@ export const VideoPlayer: React.FC = ({ // Reset fallback and loading state when video changes useEffect(() => { setUseFallback(false); +<<<<<<< HEAD setIsLoading(true); setCachedUrl(null); @@ -118,6 +132,10 @@ export const VideoPlayer: React.FC = ({ }; checkCache(); +======= + setIsLoading(true); // Show loading for new video + setCodecError(false); // Reset codec error for new video +>>>>>>> 6153739 (feat: client-side video optimization - remove server transcoding for instant loading and zero CPU) }, [video.id]); // Progress tracking @@ -134,7 +152,22 @@ export const VideoPlayer: React.FC = ({ }; // Fallback on error - if thin proxy fails, switch to full proxy - const handleError = () => { + // Also detect codec errors for graceful fallback UI + const handleError = (e: Event) => { + const videoEl = e.target as HTMLVideoElement; + const error = videoEl?.error; + + // Check if this is a codec/decode error (MEDIA_ERR_DECODE = 3) + if (error?.code === 3 || error?.code === 4) { + console.log(`Codec error detected (code ${error.code}):`, error.message); + // Only show codec error if browser doesn't support HEVC + if (!browserSupportsHEVC.current) { + setCodecError(true); + setIsLoading(false); + return; + } + } + if (thinProxyUrl && !useFallback) { console.log('Thin proxy failed, falling back to full proxy...'); setUseFallback(true); @@ -329,13 +362,13 @@ export const VideoPlayer: React.FC = ({ onClick={handleVideoClick} onTouchStart={handleTouchStart} > - {/* Video Element */} + {/* Video Element - preload="metadata" for instant player readiness */}