358 lines
12 KiB
Python
358 lines
12 KiB
Python
"""
|
|
Ray Serve application: Video Embedding → Multi-Decoder.
|
|
|
|
Processes entire videos by chunking into segments.
|
|
Videos are downloaded from S3 to temp file, then processed locally (faster than streaming).
|
|
|
|
Encoder refs are passed directly to decoder; Ray Serve resolves dependencies automatically.
|
|
|
|
Usage:
|
|
serve run app:app
|
|
|
|
# With custom bucket:
|
|
S3_BUCKET=my-bucket serve run app:app
|
|
"""
|
|
|
|
import logging
|
|
import os
|
|
import tempfile
|
|
import time
|
|
from collections import defaultdict
|
|
from pathlib import Path
|
|
from urllib.parse import urlparse
|
|
|
|
import aioboto3
|
|
import numpy as np
|
|
from fastapi import FastAPI, HTTPException
|
|
from pydantic import BaseModel
|
|
from ray import serve
|
|
from ray.serve.handle import DeploymentResponse
|
|
|
|
from deployments.encoder import VideoEncoder
|
|
from deployments.decoder import MultiDecoder
|
|
from utils.video import chunk_video_async
|
|
from constants import DEFAULT_NUM_FRAMES, DEFAULT_CHUNK_DURATION, FFMPEG_THREADS, NUM_WORKERS
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
def parse_s3_uri(s3_uri: str) -> tuple[str, str]:
|
|
"""Parse s3://bucket/key into (bucket, key)."""
|
|
parsed = urlparse(s3_uri)
|
|
if parsed.scheme != "s3":
|
|
raise ValueError(f"Invalid S3 URI: {s3_uri}")
|
|
bucket = parsed.netloc
|
|
key = parsed.path.lstrip("/")
|
|
return bucket, key
|
|
|
|
|
|
class AnalyzeRequest(BaseModel):
|
|
"""Request schema for /analyze endpoint."""
|
|
stream_id: str
|
|
video_path: str # S3 URI: s3://bucket/key
|
|
num_frames: int = DEFAULT_NUM_FRAMES
|
|
chunk_duration: float = DEFAULT_CHUNK_DURATION
|
|
use_batching: bool = False # Set False to compare unbatched performance
|
|
|
|
|
|
class TagResult(BaseModel):
|
|
text: str
|
|
score: float
|
|
|
|
|
|
class CaptionResult(BaseModel):
|
|
text: str
|
|
score: float
|
|
|
|
|
|
class TimingResult(BaseModel):
|
|
s3_download_ms: float
|
|
decode_video_ms: float
|
|
encode_ms: float
|
|
decode_ms: float
|
|
total_ms: float
|
|
|
|
|
|
class SceneChange(BaseModel):
|
|
"""Detected scene change event."""
|
|
timestamp: float # Seconds from video start
|
|
score: float # Scene change score (higher = bigger change)
|
|
chunk_index: int
|
|
frame_index: int # Frame index within chunk
|
|
|
|
|
|
class ChunkResult(BaseModel):
|
|
"""Result for a single chunk."""
|
|
chunk_index: int
|
|
start_time: float
|
|
duration: float
|
|
tags: list[TagResult]
|
|
retrieval_caption: CaptionResult
|
|
# Detected scene changes in this chunk
|
|
scene_changes: list[SceneChange]
|
|
|
|
|
|
class AnalyzeResponse(BaseModel):
|
|
"""Response schema for /analyze endpoint."""
|
|
stream_id: str
|
|
# Aggregated results (across all chunks)
|
|
tags: list[TagResult]
|
|
retrieval_caption: CaptionResult
|
|
# Scene change detection
|
|
scene_changes: list[SceneChange] # All detected scene changes
|
|
num_scene_changes: int
|
|
# Per-chunk results
|
|
chunks: list[ChunkResult]
|
|
num_chunks: int
|
|
video_duration: float
|
|
timing_ms: TimingResult
|
|
|
|
|
|
# FastAPI app
|
|
fastapi_app = FastAPI(
|
|
title="Video Embedding API",
|
|
description="GPU encoder → CPU multi-decoder using SigLIP embeddings",
|
|
)
|
|
|
|
|
|
@serve.deployment(
|
|
# setting this to twice that of the encoder. So that requests can complete the
|
|
# upfront CPU work and be queued for GPU processing.
|
|
num_replicas="auto",
|
|
ray_actor_options={"num_cpus": FFMPEG_THREADS},
|
|
max_ongoing_requests=4,
|
|
autoscaling_config={
|
|
"min_replicas": 2,
|
|
"max_replicas": 20,
|
|
"target_num_ongoing_requests": 2,
|
|
},
|
|
)
|
|
@serve.ingress(fastapi_app)
|
|
class VideoAnalyzer:
|
|
"""
|
|
Main ingress deployment that orchestrates VideoEncoder and MultiDecoder.
|
|
|
|
Encoder refs are passed directly to decoder; Ray Serve resolves dependencies.
|
|
Downloads video from S3 to temp file for fast local processing.
|
|
"""
|
|
|
|
def __init__(self, encoder: VideoEncoder, decoder: MultiDecoder):
|
|
self.encoder = encoder
|
|
self.decoder = decoder
|
|
self._s3_session = aioboto3.Session()
|
|
self._s3_client = None # Cached client for reuse across requests
|
|
logger.info("VideoAnalyzer ready")
|
|
|
|
async def _get_s3_client(self):
|
|
"""Get or create a reusable S3 client."""
|
|
if self._s3_client is None:
|
|
self._s3_client = await self._s3_session.client("s3").__aenter__()
|
|
return self._s3_client
|
|
|
|
async def _download_video(self, s3_uri: str) -> Path:
|
|
"""Download video from S3 to temp file. Returns local path."""
|
|
bucket, key = parse_s3_uri(s3_uri)
|
|
|
|
# Create temp file with video extension
|
|
suffix = Path(key).suffix or ".mp4"
|
|
temp_file = tempfile.NamedTemporaryFile(suffix=suffix, delete=False)
|
|
temp_path = Path(temp_file.name)
|
|
temp_file.close()
|
|
|
|
try:
|
|
s3 = await self._get_s3_client()
|
|
await s3.download_file(bucket, key, str(temp_path))
|
|
except Exception:
|
|
# Clean up temp file if download fails
|
|
temp_path.unlink(missing_ok=True)
|
|
raise
|
|
|
|
return temp_path
|
|
|
|
def _aggregate_results(
|
|
self,
|
|
chunk_results: list[dict],
|
|
top_k_tags: int = 5,
|
|
) -> dict:
|
|
"""
|
|
Aggregate results from multiple chunks.
|
|
|
|
Strategy:
|
|
- Tags: Average scores across chunks, return top-k
|
|
- Caption: Return the one with highest score across all chunks
|
|
"""
|
|
# Aggregate tag scores
|
|
tag_scores = defaultdict(list)
|
|
for result in chunk_results:
|
|
for tag in result["tags"]:
|
|
tag_scores[tag["text"]].append(tag["score"])
|
|
|
|
# Average tag scores and sort
|
|
aggregated_tags = [
|
|
{"text": text, "score": np.mean(scores)}
|
|
for text, scores in tag_scores.items()
|
|
]
|
|
aggregated_tags.sort(key=lambda x: x["score"], reverse=True)
|
|
top_tags = aggregated_tags[:top_k_tags]
|
|
|
|
# Best caption across all chunks
|
|
best_caption = max(
|
|
(r["retrieval_caption"] for r in chunk_results),
|
|
key=lambda x: x["score"],
|
|
)
|
|
|
|
return {
|
|
"tags": top_tags,
|
|
"retrieval_caption": best_caption,
|
|
}
|
|
|
|
def _encode_chunk(self, frames: np.ndarray, use_batching: bool = False) -> DeploymentResponse:
|
|
"""Encode a single chunk's frames to embeddings. Returns DeploymentResponse ref."""
|
|
return self.encoder.remote(frames, use_batching=use_batching)
|
|
|
|
async def _decode_chunk(
|
|
self,
|
|
encoder_output: dict,
|
|
chunk_index: int,
|
|
chunk_start_time: float,
|
|
chunk_duration: float,
|
|
ema_state=None,
|
|
) -> dict:
|
|
"""Decode embeddings to tags, caption, scene changes."""
|
|
return await self.decoder.remote(
|
|
encoder_output=encoder_output,
|
|
chunk_index=chunk_index,
|
|
chunk_start_time=chunk_start_time,
|
|
chunk_duration=chunk_duration,
|
|
ema_state=ema_state,
|
|
)
|
|
|
|
@fastapi_app.post("/analyze", response_model=AnalyzeResponse)
|
|
async def analyze(self, request: AnalyzeRequest) -> AnalyzeResponse:
|
|
"""
|
|
Analyze a video from S3 and return tags, caption, and scene changes.
|
|
|
|
Downloads video to temp file for fast local processing.
|
|
Chunks the entire video and aggregates results.
|
|
Encoder refs are passed directly to decoder for dependency resolution.
|
|
"""
|
|
total_start = time.perf_counter()
|
|
temp_path = None
|
|
|
|
try:
|
|
# Download video from S3 to temp file
|
|
download_start = time.perf_counter()
|
|
try:
|
|
temp_path = await self._download_video(request.video_path)
|
|
except Exception as e:
|
|
raise HTTPException(status_code=400, detail=f"Cannot download S3 video: {e}")
|
|
s3_download_ms = (time.perf_counter() - download_start) * 1000
|
|
|
|
# Chunk video with PARALLEL frame extraction from local file
|
|
decode_start = time.perf_counter()
|
|
try:
|
|
chunks = await chunk_video_async(
|
|
str(temp_path),
|
|
chunk_duration=request.chunk_duration,
|
|
num_frames_per_chunk=request.num_frames,
|
|
ffmpeg_threads=FFMPEG_THREADS,
|
|
use_single_ffmpeg=True,
|
|
)
|
|
except Exception as e:
|
|
raise HTTPException(status_code=400, detail=f"Cannot process video: {e}")
|
|
|
|
decode_video_ms = (time.perf_counter() - decode_start) * 1000
|
|
|
|
if not chunks:
|
|
raise HTTPException(status_code=400, detail="No chunks extracted from video")
|
|
|
|
# Calculate video duration from chunks
|
|
video_duration = chunks[-1].start_time + chunks[-1].duration
|
|
|
|
# Fire off all encoder calls (returns refs, not awaited)
|
|
encode_start = time.perf_counter()
|
|
encode_refs = [
|
|
self._encode_chunk(chunk.frames, use_batching=request.use_batching)
|
|
for chunk in chunks
|
|
]
|
|
encode_ms = (time.perf_counter() - encode_start) * 1000
|
|
|
|
# Decode chunks SERIALLY, passing encoder refs directly.
|
|
# Ray Serve resolves the encoder result when decoder needs it.
|
|
# EMA state is tracked here (not in decoder) to ensure continuity
|
|
# even when autoscaling routes requests to different replicas.
|
|
decode_start = time.perf_counter()
|
|
decode_results = []
|
|
ema_state = None # Will be initialized from first chunk's first frame
|
|
for chunk, enc_ref in zip(chunks, encode_refs):
|
|
dec_result = await self._decode_chunk(
|
|
encoder_output=enc_ref,
|
|
chunk_index=chunk.index,
|
|
chunk_start_time=chunk.start_time,
|
|
chunk_duration=chunk.duration,
|
|
ema_state=ema_state,
|
|
)
|
|
decode_results.append(dec_result)
|
|
ema_state = dec_result["ema_state"] # Carry forward for next chunk
|
|
decode_ms = (time.perf_counter() - decode_start) * 1000
|
|
|
|
# Collect results
|
|
chunk_results = []
|
|
per_chunk_results = []
|
|
all_scene_changes = []
|
|
|
|
for chunk, decoder_result in zip(chunks, decode_results):
|
|
chunk_results.append(decoder_result)
|
|
|
|
# Scene changes come directly from decoder
|
|
chunk_scene_changes = [
|
|
SceneChange(**sc) for sc in decoder_result["scene_changes"]
|
|
]
|
|
all_scene_changes.extend(chunk_scene_changes)
|
|
|
|
per_chunk_results.append(ChunkResult(
|
|
chunk_index=chunk.index,
|
|
start_time=chunk.start_time,
|
|
duration=chunk.duration,
|
|
tags=[TagResult(**t) for t in decoder_result["tags"]],
|
|
retrieval_caption=CaptionResult(**decoder_result["retrieval_caption"]),
|
|
scene_changes=chunk_scene_changes,
|
|
))
|
|
|
|
# Aggregate results
|
|
aggregated = self._aggregate_results(chunk_results)
|
|
|
|
total_ms = (time.perf_counter() - total_start) * 1000
|
|
|
|
return AnalyzeResponse(
|
|
stream_id=request.stream_id,
|
|
tags=[TagResult(**t) for t in aggregated["tags"]],
|
|
retrieval_caption=CaptionResult(**aggregated["retrieval_caption"]),
|
|
scene_changes=all_scene_changes,
|
|
num_scene_changes=len(all_scene_changes),
|
|
chunks=per_chunk_results,
|
|
num_chunks=len(chunks),
|
|
video_duration=video_duration,
|
|
timing_ms=TimingResult(
|
|
s3_download_ms=round(s3_download_ms, 2),
|
|
decode_video_ms=round(decode_video_ms, 2),
|
|
encode_ms=round(encode_ms, 2),
|
|
decode_ms=round(decode_ms, 2),
|
|
total_ms=round(total_ms, 2),
|
|
),
|
|
)
|
|
finally:
|
|
# Clean up temp file
|
|
if temp_path and temp_path.exists():
|
|
temp_path.unlink(missing_ok=True)
|
|
|
|
@fastapi_app.get("/health")
|
|
async def health(self):
|
|
"""Health check endpoint."""
|
|
return {"status": "healthy"}
|
|
|
|
|
|
encoder = VideoEncoder.bind()
|
|
decoder = MultiDecoder.bind(bucket=os.environ.get("S3_BUCKET"))
|
|
app = VideoAnalyzer.bind(encoder=encoder, decoder=decoder)
|