feat: client-side video optimization - remove server transcoding for instant loading and zero CPU
This commit is contained in:
parent
13841f479e
commit
aad352a80f
3 changed files with 91 additions and 79 deletions
|
|
@ -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,
|
||||
|
|
|
|||
14
frontend/package-lock.json
generated
14
frontend/package-lock.json
generated
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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<VideoPlayerProps> = ({
|
|||
const [localMuted, setLocalMuted] = useState(true);
|
||||
const isMuted = externalMuted !== undefined ? externalMuted : localMuted;
|
||||
const [hearts, setHearts] = useState<HeartParticle[]>([]);
|
||||
<<<<<<< HEAD
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [cachedUrl, setCachedUrl] = useState<string | null>(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<number>(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<VideoPlayerProps> = ({
|
|||
// 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<VideoPlayerProps> = ({
|
|||
};
|
||||
|
||||
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<VideoPlayerProps> = ({
|
|||
};
|
||||
|
||||
// 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<VideoPlayerProps> = ({
|
|||
onClick={handleVideoClick}
|
||||
onTouchStart={handleTouchStart}
|
||||
>
|
||||
{/* Video Element */}
|
||||
{/* Video Element - preload="metadata" for instant player readiness */}
|
||||
<video
|
||||
ref={videoRef}
|
||||
src={proxyUrl}
|
||||
loop
|
||||
playsInline
|
||||
preload="auto"
|
||||
preload="metadata"
|
||||
muted={isMuted}
|
||||
className="w-full h-full"
|
||||
style={{ objectFit }}
|
||||
|
|
@ -345,7 +378,7 @@ export const VideoPlayer: React.FC<VideoPlayerProps> = ({
|
|||
/>
|
||||
|
||||
{/* Loading Overlay - Subtle pulsing logo */}
|
||||
{isLoading && (
|
||||
{isLoading && !codecError && (
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-black/40 z-20">
|
||||
<div className="w-16 h-16 bg-gradient-to-r from-cyan-400/80 to-pink-500/80 rounded-2xl flex items-center justify-center animate-pulse">
|
||||
<svg className="w-8 h-8 text-white" viewBox="0 0 24 24" fill="currentColor">
|
||||
|
|
@ -355,6 +388,25 @@ export const VideoPlayer: React.FC<VideoPlayerProps> = ({
|
|||
</div>
|
||||
)}
|
||||
|
||||
{/* Codec Error Fallback - Graceful UI for unsupported video codecs */}
|
||||
{codecError && (
|
||||
<div className="absolute inset-0 flex flex-col items-center justify-center bg-black/80 z-20 p-6 text-center">
|
||||
<AlertCircle className="w-12 h-12 text-amber-400 mb-3" />
|
||||
<h3 className="text-white font-semibold text-lg mb-2">Video Format Not Supported</h3>
|
||||
<p className="text-white/60 text-sm mb-4 max-w-xs">
|
||||
This video uses HEVC codec. Try Safari, Chrome 107+, or download to watch.
|
||||
</p>
|
||||
<a
|
||||
href={downloadUrl}
|
||||
download
|
||||
className="px-4 py-2 bg-gradient-to-r from-cyan-500 to-pink-500 text-white text-sm font-medium rounded-full hover:opacity-90 transition-opacity"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
Download Video
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Heart Animation Particles */}
|
||||
{hearts.map(heart => (
|
||||
<div
|
||||
|
|
|
|||
Loading…
Reference in a new issue