
All checks were successful
Test and Publish Templates / test-and-publish (push) Successful in 48s
529 lines
21 KiB
Python
Executable File
529 lines
21 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
"""
|
|
Squash Thumbnail Maker - Generates thumbnails for MP4 videos
|
|
Uses smart detection to find best frame with players visible
|
|
Independent standalone version based on squashkiwi approach
|
|
"""
|
|
|
|
import os
|
|
import sys
|
|
import json
|
|
import logging
|
|
import subprocess
|
|
import asyncio
|
|
from pathlib import Path
|
|
from typing import Optional, Tuple
|
|
from datetime import datetime
|
|
|
|
# Set process to low priority if configured
|
|
try:
|
|
nice_level = int(os.environ.get('NICE_LEVEL', '19'))
|
|
os.nice(nice_level)
|
|
except:
|
|
pass
|
|
|
|
logging.basicConfig(
|
|
level=logging.INFO,
|
|
format='%(levelname)s - %(message)s'
|
|
)
|
|
logger = logging.getLogger('thumbnailmaker')
|
|
|
|
class ThumbnailGenerator:
|
|
"""Generates thumbnails for videos using sophisticated AI detection"""
|
|
|
|
def __init__(self):
|
|
self.has_yolo = self.check_yolo_available()
|
|
self.enable_smart = os.environ.get('ENABLE_SMART_DETECTION', 'true').lower() == 'true'
|
|
self.fallback_time = int(os.environ.get('FALLBACK_TIME', '10'))
|
|
self.thumbnail_width = int(os.environ.get('THUMBNAIL_WIDTH', '640'))
|
|
self.thumbnail_quality = int(os.environ.get('THUMBNAIL_QUALITY', '95'))
|
|
self.max_sample_time = int(os.environ.get('MAX_SAMPLE_TIME', '180'))
|
|
self.sample_interval = int(os.environ.get('SAMPLE_INTERVAL', '1'))
|
|
|
|
def check_yolo_available(self) -> bool:
|
|
"""Check if YOLOv8 is available for smart detection"""
|
|
try:
|
|
from ultralytics import YOLO
|
|
return True
|
|
except ImportError:
|
|
logger.debug("YOLOv8 not available, will use simple thumbnail generation")
|
|
return False
|
|
|
|
async def process_video_file(self, video_file: Path) -> bool:
|
|
"""Process a single video file"""
|
|
if not video_file.exists():
|
|
logger.error(f"Video file not found: {video_file}")
|
|
return False
|
|
|
|
if video_file.suffix.lower() not in ['.mp4', '.mov', '.avi', '.mkv']:
|
|
logger.warning(f"Skipping non-video file: {video_file}")
|
|
return False
|
|
|
|
return await self.generate_thumbnail(video_file)
|
|
|
|
async def process_directory(self, directory: Path) -> int:
|
|
"""Process all video files in a directory"""
|
|
if not directory.exists() or not directory.is_dir():
|
|
logger.error(f"Directory not found: {directory}")
|
|
return 0
|
|
|
|
video_extensions = ['.mp4', '.mov', '.avi', '.mkv']
|
|
video_files = []
|
|
|
|
for ext in video_extensions:
|
|
video_files.extend(directory.glob(f'*{ext}'))
|
|
video_files.extend(directory.glob(f'*{ext.upper()}'))
|
|
|
|
if not video_files:
|
|
logger.warning(f"No video files found in {directory}")
|
|
return 0
|
|
|
|
logger.info(f"Found {len(video_files)} video files to process")
|
|
|
|
success_count = 0
|
|
for video_file in video_files:
|
|
if await self.process_video_file(video_file):
|
|
success_count += 1
|
|
|
|
return success_count
|
|
|
|
async def generate_thumbnail(self, video_file: Path) -> bool:
|
|
"""Main method to generate thumbnail - tries smart detection first, falls back to simple"""
|
|
thumbnail_file = video_file.with_suffix('.jpg')
|
|
|
|
# Skip if thumbnail already exists
|
|
if thumbnail_file.exists():
|
|
logger.info(f"Thumbnail already exists: {thumbnail_file.name}")
|
|
return True
|
|
|
|
try:
|
|
# Try smart thumbnail generation first if enabled
|
|
if self.enable_smart and self.has_yolo:
|
|
try:
|
|
success = await self.generate_smart_thumbnail(video_file)
|
|
if success:
|
|
return True
|
|
except ImportError as e:
|
|
logger.debug(f"YOLOv8 not available, using simple thumbnail: {e}")
|
|
except Exception as e:
|
|
logger.warning(f"Smart thumbnail failed, using fallback: {e}")
|
|
|
|
# Fallback to simple thumbnail at configured time
|
|
success = await self.generate_simple_thumbnail(video_file, seek_time=self.fallback_time)
|
|
if success:
|
|
return True
|
|
|
|
# Last resort - try to get frame at 1 second
|
|
success = await self.generate_simple_thumbnail(video_file, seek_time=1)
|
|
if not success:
|
|
logger.error(f"All thumbnail generation methods failed for {video_file.name}")
|
|
return False
|
|
|
|
except Exception as e:
|
|
logger.error(f"Critical error generating thumbnail: {e}")
|
|
return False
|
|
|
|
async def generate_simple_thumbnail(self, video_file: Path, seek_time: int = 10) -> bool:
|
|
"""Generate a simple thumbnail at specified time"""
|
|
try:
|
|
thumbnail_file = video_file.with_suffix('.jpg')
|
|
logger.debug(f"Attempting simple thumbnail generation for {video_file.name} at {seek_time}s")
|
|
|
|
if not video_file.exists():
|
|
logger.error(f"Video file does not exist: {video_file}")
|
|
return False
|
|
|
|
video_size = video_file.stat().st_size
|
|
logger.debug(f"Video file size: {video_size / (1024*1024):.1f} MB")
|
|
|
|
cmd = [
|
|
'ffmpeg',
|
|
'-i', str(video_file),
|
|
'-ss', str(seek_time),
|
|
'-vframes', '1',
|
|
'-vf', f'scale={self.thumbnail_width}:-1',
|
|
'-q:v', str(100 - self.thumbnail_quality + 1), # Convert quality to ffmpeg scale
|
|
'-y',
|
|
str(thumbnail_file)
|
|
]
|
|
|
|
logger.debug(f"Running FFmpeg command: {' '.join(cmd[:6])}")
|
|
|
|
process = await asyncio.create_subprocess_exec(
|
|
*cmd,
|
|
stdout=asyncio.subprocess.PIPE,
|
|
stderr=asyncio.subprocess.PIPE
|
|
)
|
|
|
|
stdout, stderr = await process.communicate()
|
|
|
|
if process.returncode == 0:
|
|
if thumbnail_file.exists():
|
|
thumb_size = thumbnail_file.stat().st_size
|
|
logger.info(f"Generated simple thumbnail: {thumbnail_file.name} at {seek_time}s ({thumb_size / 1024:.1f} KB)")
|
|
return True
|
|
else:
|
|
logger.error(f"FFmpeg succeeded but thumbnail file not created: {thumbnail_file}")
|
|
return False
|
|
else:
|
|
logger.error(f"FFmpeg failed with code {process.returncode} for {video_file.name}")
|
|
if stderr:
|
|
stderr_text = stderr.decode('utf-8', errors='ignore')[-500:]
|
|
logger.error(f"FFmpeg stderr: {stderr_text}")
|
|
return False
|
|
|
|
except Exception as e:
|
|
logger.error(f"Exception generating simple thumbnail for {video_file.name}: {e}")
|
|
return False
|
|
|
|
async def generate_smart_thumbnail(self, video_file: Path) -> bool:
|
|
"""Generate thumbnail using YOLOv8 pose model to find best frame with both players"""
|
|
try:
|
|
logger.info(f"Starting smart thumbnail generation for {video_file.name}")
|
|
|
|
# Import here to avoid loading if not needed
|
|
from ultralytics import YOLO
|
|
import cv2
|
|
import numpy as np
|
|
|
|
thumbnail_file = video_file.with_suffix('.jpg')
|
|
|
|
# Load YOLO pose model for better player detection
|
|
logger.debug("Loading YOLOv8 pose model...")
|
|
model = YOLO('yolov8n-pose.pt')
|
|
logger.debug("YOLOv8 model loaded successfully")
|
|
|
|
# Sample frames from video
|
|
sample_times = list(range(1, min(self.max_sample_time + 1, 181), self.sample_interval))
|
|
logger.debug(f"Will sample {len(sample_times)} frames from video")
|
|
|
|
# Get video dimensions first
|
|
probe_cmd = [
|
|
'ffprobe', '-v', 'error',
|
|
'-select_streams', 'v:0',
|
|
'-show_entries', 'stream=width,height',
|
|
'-of', 'csv=s=x:p=0',
|
|
str(video_file)
|
|
]
|
|
|
|
probe_process = await asyncio.create_subprocess_exec(
|
|
*probe_cmd,
|
|
stdout=asyncio.subprocess.PIPE,
|
|
stderr=asyncio.subprocess.DEVNULL
|
|
)
|
|
|
|
dimensions, _ = await probe_process.communicate()
|
|
|
|
if not dimensions:
|
|
logger.error("Failed to get video dimensions")
|
|
return False
|
|
|
|
width, height = map(int, dimensions.decode().strip().split('x'))
|
|
logger.debug(f"Video dimensions: {width}x{height}")
|
|
|
|
best_frame = None
|
|
best_score = 0
|
|
best_time = self.fallback_time
|
|
frames_processed = 0
|
|
|
|
# First pass: sample frames
|
|
for seek_time in sample_times:
|
|
logger.debug(f"Extracting frame at {seek_time}s...")
|
|
|
|
cmd = [
|
|
'ffmpeg',
|
|
'-ss', str(seek_time),
|
|
'-i', str(video_file),
|
|
'-vframes', '1',
|
|
'-f', 'image2pipe',
|
|
'-pix_fmt', 'rgb24',
|
|
'-vcodec', 'rawvideo',
|
|
'-'
|
|
]
|
|
|
|
process = await asyncio.create_subprocess_exec(
|
|
*cmd,
|
|
stdout=asyncio.subprocess.PIPE,
|
|
stderr=asyncio.subprocess.DEVNULL
|
|
)
|
|
|
|
stdout, _ = await process.communicate()
|
|
|
|
if process.returncode != 0 or not stdout:
|
|
logger.debug(f"Failed to extract frame at {seek_time}s")
|
|
continue
|
|
|
|
# Convert raw video to numpy array
|
|
frame = np.frombuffer(stdout, dtype=np.uint8)
|
|
frame = frame.reshape((height, width, 3))
|
|
frames_processed += 1
|
|
|
|
# Run YOLO detection
|
|
logger.debug(f"Running YOLO detection on frame at {seek_time}s...")
|
|
results = model(frame, verbose=False, conf=0.3)
|
|
|
|
# Calculate score for this frame
|
|
score = self.calculate_frame_score(results, width, height)
|
|
logger.debug(f"Frame at {seek_time}s scored: {score}")
|
|
|
|
if score > best_score:
|
|
best_score = score
|
|
best_frame = frame
|
|
best_time = seek_time
|
|
logger.info(f"New best frame found at {seek_time}s with score {score}")
|
|
|
|
# If we find an excellent frame, stop searching
|
|
if score >= 180:
|
|
logger.info(f"Excellent frame found at {seek_time}s! Stopping search.")
|
|
break
|
|
|
|
# Second pass: fine-grained search around the best frame
|
|
if best_frame is not None and best_score > 50:
|
|
logger.info(f"Found initial best frame at {best_time}s with score {best_score}")
|
|
logger.info(f"Performing fine-tuning search ±0.9s around best frame...")
|
|
|
|
fine_sample_times = []
|
|
for offset in range(-9, 10):
|
|
if offset == 0:
|
|
continue
|
|
fine_time = best_time + (offset * 0.1)
|
|
if fine_time > 0:
|
|
fine_sample_times.append(fine_time)
|
|
|
|
logger.info(f"Fine-tuning: checking {len(fine_sample_times)} frames around {best_time}s")
|
|
|
|
for seek_time in fine_sample_times:
|
|
logger.debug(f"Fine-tuning: checking frame at {seek_time:.1f}s...")
|
|
|
|
cmd = [
|
|
'ffmpeg',
|
|
'-ss', str(seek_time),
|
|
'-i', str(video_file),
|
|
'-vframes', '1',
|
|
'-f', 'image2pipe',
|
|
'-pix_fmt', 'rgb24',
|
|
'-vcodec', 'rawvideo',
|
|
'-'
|
|
]
|
|
|
|
process = await asyncio.create_subprocess_exec(
|
|
*cmd,
|
|
stdout=asyncio.subprocess.PIPE,
|
|
stderr=asyncio.subprocess.DEVNULL
|
|
)
|
|
|
|
stdout, _ = await process.communicate()
|
|
|
|
if process.returncode != 0 or not stdout:
|
|
logger.debug(f"Failed to extract frame at {seek_time:.1f}s")
|
|
continue
|
|
|
|
frame = np.frombuffer(stdout, dtype=np.uint8)
|
|
frame = frame.reshape((height, width, 3))
|
|
frames_processed += 1
|
|
|
|
results = model(frame, verbose=False, conf=0.3)
|
|
score = self.calculate_frame_score(results, width, height)
|
|
logger.debug(f"Fine-tuning: frame at {seek_time:.1f}s scored: {score}")
|
|
|
|
if score > best_score:
|
|
logger.info(f"Fine-tuning: found better frame at {seek_time:.1f}s with score {score}")
|
|
best_score = score
|
|
best_frame = frame
|
|
best_time = seek_time
|
|
|
|
if score >= 180:
|
|
logger.info(f"Fine-tuning: excellent frame found at {seek_time:.1f}s!")
|
|
break
|
|
|
|
logger.info(f"Fine-tuning complete. Final best frame at {best_time}s with score {best_score}")
|
|
|
|
# Save the best frame if we found one
|
|
logger.info(f"Processed {frames_processed} frames total")
|
|
if best_frame is not None and best_score > 0:
|
|
logger.info(f"Saving final best frame (score {best_score}) from {best_time}s as thumbnail")
|
|
|
|
# Convert RGB to BGR for OpenCV
|
|
best_frame_bgr = cv2.cvtColor(best_frame, cv2.COLOR_RGB2BGR)
|
|
|
|
# Resize to configured width
|
|
height, width = best_frame_bgr.shape[:2]
|
|
new_width = self.thumbnail_width
|
|
new_height = int(height * (new_width / width))
|
|
resized = cv2.resize(best_frame_bgr, (new_width, new_height), interpolation=cv2.INTER_AREA)
|
|
logger.debug(f"Resized thumbnail to {new_width}x{new_height}")
|
|
|
|
# Save as JPEG with configured quality
|
|
cv2.imwrite(str(thumbnail_file), resized, [cv2.IMWRITE_JPEG_QUALITY, self.thumbnail_quality])
|
|
|
|
if thumbnail_file.exists():
|
|
thumb_size = thumbnail_file.stat().st_size
|
|
logger.info(f"Smart thumbnail saved: {thumbnail_file.name} ({thumb_size / 1024:.1f} KB) from {best_time}s with score {best_score}")
|
|
return True
|
|
else:
|
|
logger.error(f"Failed to save smart thumbnail to {thumbnail_file}")
|
|
return False
|
|
else:
|
|
logger.warning(f"No suitable frame found for smart thumbnail (best score was {best_score})")
|
|
return False
|
|
|
|
except ImportError as e:
|
|
logger.debug(f"YOLOv8 not installed: {e}")
|
|
return False
|
|
except Exception as e:
|
|
logger.error(f"Smart thumbnail generation error: {e}", exc_info=True)
|
|
return False
|
|
|
|
def calculate_frame_score(self, results, frame_width, frame_height) -> int:
|
|
"""Calculate score for a frame based on player detection and pose"""
|
|
import numpy as np
|
|
|
|
score = 0
|
|
|
|
if not results or len(results) == 0:
|
|
logger.debug("No detection results")
|
|
return 0
|
|
|
|
result = results[0]
|
|
|
|
# Get person detections
|
|
if result.boxes is None:
|
|
logger.debug("No boxes detected")
|
|
return 0
|
|
|
|
persons = []
|
|
for box in result.boxes:
|
|
if box.cls == 0: # Class 0 is person in COCO
|
|
persons.append(box)
|
|
|
|
logger.debug(f"Detected {len(persons)} person(s) in frame")
|
|
|
|
# Score based on number of people
|
|
if len(persons) == 2:
|
|
score = 30 # Base score for having both players
|
|
logger.debug("Both players detected: +30 points")
|
|
elif len(persons) == 1:
|
|
score = 5 # Low score for single player
|
|
logger.debug("Single player detected: +5 points")
|
|
else:
|
|
logger.debug(f"No valid player count ({len(persons)} detected)")
|
|
return 0
|
|
|
|
# Check poses if available for face visibility
|
|
if hasattr(result, 'keypoints') and result.keypoints is not None:
|
|
keypoints = result.keypoints.xy.cpu().numpy() if hasattr(result.keypoints.xy, 'cpu') else result.keypoints.xy
|
|
|
|
faces_visible = 0
|
|
for person_kpts in keypoints[:2]: # Check first 2 people
|
|
if len(person_kpts) > 0:
|
|
# Check if face keypoints are visible (indices 0-4 are face in COCO pose)
|
|
face_points = person_kpts[:5]
|
|
face_visible = np.sum(face_points[:, 0] > 0) >= 3 # At least 3 face points visible
|
|
|
|
if face_visible:
|
|
faces_visible += 1
|
|
|
|
# Bonus points for faces visible
|
|
if faces_visible == 2:
|
|
score += 100 # Both faces clearly visible
|
|
logger.debug("Both faces visible: +100 points")
|
|
elif faces_visible == 1:
|
|
score += 20 # One face visible
|
|
logger.debug("One face visible: +20 points")
|
|
|
|
# Additional scoring based on player size and positioning
|
|
if len(persons) == 2:
|
|
center_players = 0
|
|
total_player_area = 0
|
|
min_player_area = float('inf')
|
|
|
|
for box in persons[:2]:
|
|
x1, y1, x2, y2 = box.xyxy[0].tolist()
|
|
center_x = (x1 + x2) / 2
|
|
|
|
# Calculate player bounding box area as percentage of frame
|
|
box_width = x2 - x1
|
|
box_height = y2 - y1
|
|
box_area = (box_width * box_height) / (frame_width * frame_height)
|
|
total_player_area += box_area
|
|
min_player_area = min(min_player_area, box_area)
|
|
|
|
# Check if player is in central 60% of frame
|
|
if 0.2 * frame_width < center_x < 0.8 * frame_width:
|
|
center_players += 1
|
|
|
|
# Score based on player size
|
|
avg_player_area = total_player_area / 2
|
|
|
|
if avg_player_area > 0.08:
|
|
score += 40
|
|
logger.debug(f"Players are large ({avg_player_area*100:.1f}% avg): +40 points")
|
|
elif avg_player_area > 0.05:
|
|
score += 25
|
|
logger.debug(f"Players are good size ({avg_player_area*100:.1f}% avg): +25 points")
|
|
elif avg_player_area > 0.03:
|
|
score += 10
|
|
logger.debug(f"Players are small ({avg_player_area*100:.1f}% avg): +10 points")
|
|
|
|
# Both players should be similar size
|
|
if min_player_area > 0:
|
|
size_ratio = min_player_area / (total_player_area - min_player_area)
|
|
if size_ratio > 0.5:
|
|
score += 10
|
|
logger.debug(f"Players are similar size (ratio {size_ratio:.2f}): +10 points")
|
|
|
|
# Bonus for both players being centered
|
|
if center_players == 2:
|
|
score += 15
|
|
logger.debug("Both players centered: +15 points")
|
|
elif center_players == 1:
|
|
score += 5
|
|
logger.debug("One player centered: +5 points")
|
|
|
|
logger.debug(f"Final frame score: {score}")
|
|
return score
|
|
|
|
|
|
async def main():
|
|
"""Main entry point"""
|
|
if len(sys.argv) != 2:
|
|
logger.error("Usage: generate_thumbnail.py <video_file_or_directory>")
|
|
sys.exit(1)
|
|
|
|
input_path = Path(sys.argv[1])
|
|
|
|
# Report nice level
|
|
try:
|
|
nice_level = os.nice(0) # Get current nice level
|
|
logger.info(f"Running at nice level: {nice_level} (low priority)")
|
|
except:
|
|
pass
|
|
|
|
generator = ThumbnailGenerator()
|
|
|
|
try:
|
|
if input_path.is_file():
|
|
# Process single file
|
|
if await generator.process_video_file(input_path):
|
|
logger.info(f"Thumbnail generation successful for {input_path.name}")
|
|
sys.exit(0)
|
|
else:
|
|
logger.error(f"Thumbnail generation failed for {input_path.name}")
|
|
sys.exit(1)
|
|
elif input_path.is_dir():
|
|
# Process directory
|
|
success_count = await generator.process_directory(input_path)
|
|
total_count = len(list(input_path.glob('*.mp4')) + list(input_path.glob('*.MP4')))
|
|
logger.info(f"Processed {success_count}/{total_count} videos successfully")
|
|
sys.exit(0 if success_count > 0 else 1)
|
|
else:
|
|
logger.error(f"Invalid input: {input_path} is neither a file nor a directory")
|
|
sys.exit(1)
|
|
|
|
except Exception as e:
|
|
logger.error(f"Fatal error: {e}")
|
|
sys.exit(1)
|
|
|
|
|
|
if __name__ == '__main__':
|
|
asyncio.run(main()) |