This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
You are building a micro-SaaS MVP that extracts hardcoded Korean subtitles from YouTube videos using OCR and optionally translates them to English. The application must be production-ready with minimal UI, robust error handling, and comprehensive testing.
- To know about the project, always start with the
docsfolder. - Never start implementation/code changes when asked for suggestions, questions, explanations.
- Never add claude or Anthropic related texts anywhere in the project, even in commit messages
- Never install libraries globally unless absolutely necessary. Always check if the library/package is installed before trying to install it
- Always make changes with caution and if its core logic, keep it revertable preferrably as a separate commit
# Backend setup
cd backend
python -m venv venv
source venv/bin/activate # Windows: venv/Scripts/activate
pip install -r requirements.txt
# Frontend setup
cd frontend
npm install
# Environment setup
cp backend/.env.example backend/.env
cp frontend/.env.example frontend/.env# Start backend server
cd backend
uvicorn app.main:app --reload --host 0.0.0.0 --port 8000
# Start frontend dev server
cd frontend
npm run dev
# Run both concurrently (if available)
npm run dev:all# Backend tests
cd backend
pytest tests/ --cov=app --cov-report=html
pytest tests/unit/ # Unit tests only
pytest tests/integration/ # Integration tests only
# Frontend tests
cd frontend
npm run test
npm run test:watch
npm run test:coverage
# E2E tests
npm run test:e2e# Backend linting and formatting
cd backend
pylint app/
black app/
isort app/
# Frontend linting and type checking
cd frontend
npm run lint
npm run lint:fix
npm run type-check# Frontend build
cd frontend
npm run build
npm run preview # Preview production build
# Backend requirements
cd backend
pip freeze > requirements.txt
# Docker build (future)
docker build -t korean-subtitle-extractor .korean-subtitle-extractor/
├── frontend/ # React TypeScript + Vite
│ ├── src/
│ │ ├── components/ # Shadcn UI components
│ │ ├── hooks/ # useWebSocket, useProcess
│ │ ├── services/ # API client, processing
│ │ ├── types/ # TypeScript definitions
│ │ └── pages/ # Home, Metrics
│ └── tests/
├── backend/ # Python FastAPI
│ ├── app/
│ │ ├── api/v1/ # API endpoints
│ │ ├── services/ # OCR, translation, SRT generation
│ │ ├── models/ # SQLAlchemy models
│ │ ├── utils/ # Text processing, validation
│ │ └── main.py
│ └── tests/
└── shared/ # Common types/constants
- Video Download: yt-dlp extracts video info and validates duration (≤20min)
- Frame Extraction: OpenCV extracts 1 frame per second with timestamps
- OCR Processing: Google Cloud Vision API batch processes frames (10 frames/request)
- Text Deduplication: Removes duplicate text using Levenshtein distance (90% threshold)
- Translation: Google Cloud Translate API translates Korean to English (optional)
- SRT Generation: Creates properly formatted SRT files for both languages
- Frontend: React 18 + TypeScript (strict) + Vite + Shadcn/ui + React Query
- Backend: FastAPI + SQLAlchemy + Pydantic + Redis (caching)
- External APIs: Google Cloud Vision (OCR) + Google Cloud Translate
- Database: SQLite (MVP) → PostgreSQL (production)
- 1 frame per second extraction rate for accuracy
- 20-minute maximum video duration for MVP
- No authentication - free service for validation
- Both Korean and English SRT output with translation toggle
- Minimal UI using Shadcn components - no fancy animations
- Complete test suite for backend
- Strict TypeScript for frontend
Commit at main checkpoint implementations, or for every subphase/phase implementation
# Use conventional commits
feat: add YouTube URL validation
fix: resolve OCR deduplication bug
test: add unit tests for SRT generation
docs: update API documentation
refactor: optimize frame extraction logic# Always use type hints and async/await
from typing import List, Optional, Dict
from pydantic import BaseModel, Field
async def process_video(url: str) -> ProcessingResult:
"""Process video and extract subtitles."""
pass
# Use Pydantic for validation with regex
class ProcessRequest(BaseModel):
youtube_url: str = Field(..., regex=r'^https?://(www\.)?(youtube\.com/watch\?v=|youtu\.be/)[\w-]+')
translate_to_english: bool = False
# Constants in uppercase
MAX_VIDEO_DURATION = 1200 # 20 minutes
FRAME_EXTRACTION_FPS = 1
OCR_BATCH_SIZE = 10# Use background tasks for long operations
@app.post("/api/v1/process")
async def process_video(
request: ProcessRequest,
background_tasks: BackgroundTasks
):
job_id = str(uuid.uuid4())
background_tasks.add_task(process_video_task, job_id, request)
return {"job_id": job_id, "status": "processing"}
# Use dependency injection for database
async def get_db() -> AsyncGenerator[AsyncSession, None]:
async with async_session() as session:
yield session# Batch OCR processing for efficiency (10 frames per request)
async def process_ocr_batch(cropped_frames: List[bytes]) -> List[OCRResult]:
batch_requests = [
vision.AnnotateImageRequest(
image=vision.Image(content=frame_data),
features=[vision.Feature(type_=vision.Feature.Type.TEXT_DETECTION)],
image_context=vision.ImageContext(language_hints=["ko"])
) for frame_data in cropped_frames
]
responses = client.batch_annotate_images(requests=batch_requests)
return parse_ocr_responses(responses)
# CRITICAL: Bulk translation for better context preservation
async def translate_batch_bulk(texts: List[str]) -> List[str]:
"""Translate all Korean texts together for better context and meaning"""
if not texts:
return []
# Join texts with delimiter for bulk translation
combined_text = " ||DELIMITER|| ".join(texts)
translated_combined = await translate_client.translate(
combined_text,
source_language="ko",
target_language="en"
)
# Split back and map to original order
translated_texts = translated_combined.split(" ||DELIMITER|| ")
return translated_texts[:len(texts)] # Ensure same lengthfrom dataclasses import dataclass
from typing import Optional
@dataclass
class FrameOCRMapping:
"""
CRITICAL: Core data model maintaining frame -> timestamp -> OCR -> translation relationships.
This ensures we never lose the connection between original frame timing and processed text.
"""
frame_number: int # For ordering and tracking
timestamp_seconds: float # Original frame timestamp (1fps = 1 second intervals)
frame_data: bytes # Original frame data
cropped_frame_data: bytes # Cropped frame (remove channel logos)
korean_text: str = "" # OCR result (empty if no text detected)
english_text: str = "" # Translation result (empty if not translated)
confidence_score: float = 0.0 # OCR confidence
bounding_box: Optional[dict] = None # OCR bounding box
is_empty: bool = True # Track frames with no text
cropped_frame_hash: str = "" # For duplicate frame detection
def crop_frame_sides(frame_data: bytes, crop_ratio: float = 0.1) -> bytes:
"""CRITICAL: Remove YouTube channel logos from frame sides before OCR"""
image = cv2.imdecode(np.frombuffer(frame_data, np.uint8), cv2.IMREAD_COLOR)
height, width = image.shape[:2]
# Remove crop_ratio from left and right sides
crop_width = int(width * crop_ratio)
cropped_image = image[:, crop_width:width-crop_width]
# Enhance for better OCR
gray = cv2.cvtColor(cropped_image, cv2.COLOR_BGR2GRAY)
enhanced = cv2.addWeighted(gray, 1.2, gray, 0, 10)
_, buffer = cv2.imencode('.jpg', enhanced)
return buffer.tobytes()
def deduplicate_consecutive_mappings(mappings: List[FrameOCRMapping], threshold: float = 0.9) -> List[FrameOCRMapping]:
"""Remove duplicate text while preserving frame->timestamp relationships"""
if not mappings:
return []
# Only process non-empty mappings for deduplication
non_empty = [m for m in mappings if not m.is_empty]
if not non_empty:
return []
unique = [non_empty[0]]
for current in non_empty[1:]:
last = unique[-1]
similarity = SequenceMatcher(None, last.korean_text, current.korean_text).ratio()
if similarity < threshold:
unique.append(current) # Different subtitle
# Same subtitle - skip but preserve timing info for SRT generation
return unique// tsconfig.json - Use strict mode with path aliases
{
"compilerOptions": {
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"paths": { "@/*": ["./src/*"] }
}
}// types/processing.types.ts - Never use 'any', always define proper types
export interface ProcessingJob {
id: string;
youtube_url: string;
status: ProcessingStatus;
progress: number;
error?: ProcessingError;
}
export enum ProcessingStatus {
PENDING = 'pending',
PROCESSING_OCR = 'processing_ocr',
TRANSLATING = 'translating',
COMPLETED = 'completed',
FAILED = 'failed'
}
export interface ProcessingError {
code: string;
message: string;
}// Use functional components with proper typing, memo, useCallback
import { FC, useState, useCallback, memo } from 'react';
interface URLProcessorProps {
onSubmit: (url: string, translate: boolean) => Promise<void>;
isProcessing: boolean;
}
export const URLProcessor: FC<URLProcessorProps> = memo(({ onSubmit, isProcessing }) => {
const [url, setUrl] = useState<string>('');
const [error, setError] = useState<string | null>(null);
const validateYouTubeURL = useCallback((url: string): boolean => {
const regex = /^(https?:\/\/)?(www\.)?(youtube\.com\/watch\?v=|youtu\.be\/)[\w-]+/;
return regex.test(url);
}, []);
// Component implementation...
});// services/api.ts - Use axios with proper error handling
class APIClient {
private client = axios.create({
baseURL: import.meta.env.VITE_API_URL,
timeout: 30000,
});
async processVideo(url: string, translate: boolean): Promise<ProcessingJob> {
const response = await this.client.post('/api/v1/process', {
youtube_url: url,
translate_to_english: translate,
});
return response.data;
}
}// hooks/useWebSocket.ts - Handle reconnection and cleanup
export const useWebSocket = (jobId: string | null, onMessage: (msg: WebSocketMessage) => void) => {
const ws = useRef<WebSocket | null>(null);
const connect = useCallback(() => {
if (!jobId) return;
ws.current = new WebSocket(`${import.meta.env.VITE_WS_URL}/ws/progress/${jobId}`);
ws.current.onmessage = (event) => onMessage(JSON.parse(event.data));
}, [jobId, onMessage]);
useEffect(() => {
connect();
return () => ws.current?.close();
}, [connect]);
};# Backend (.env)
GOOGLE_APPLICATION_CREDENTIALS=path/to/credentials.json
GOOGLE_CLOUD_PROJECT=your-project-id
DATABASE_URL=sqlite:///./app.db
REDIS_URL=redis://localhost:6379
MAX_VIDEO_DURATION=1200
CORS_ORIGINS=["http://localhost:5173"]
GA4_MEASUREMENT_ID=G-XXXXXXXXXX
CLARITY_PROJECT_ID=your-clarity-id
FRAME_CROP_RATIO=0.1
# Frontend (.env)
VITE_API_URL=http://localhost:8000
VITE_WS_URL=ws://localhost:8000
VITE_GA4_MEASUREMENT_ID=G-XXXXXXXXXX
VITE_CLARITY_PROJECT_ID=your-clarity-id- Don't skip frame cropping - MUST remove YouTube channel logos before OCR
- Don't lose frame->timestamp->OCR->translation relationships - Use FrameOCRMapping throughout
- Don't skip OCR order preservation - Handle empty OCR results while maintaining frame order
- Don't translate texts individually - Use bulk translation for better context
- Don't process videos synchronously - Use FastAPI background tasks
- Don't use 'any' type in TypeScript - Always define proper types
- Don't make unbatched API calls - Batch OCR requests (10 frames/call)
- Don't forget to track analytics events - GA4 and Clarity for user behavior
- Don't skip WebSocket cleanup - Prevent memory leaks
- Don't store large files in database - Use filesystem for SRT files
POST /api/v1/process- Start processing jobGET /api/v1/status/{job_id}- Get job statusGET /api/v1/download/{job_id}/{language}- Download SRT filesWebSocket /ws/progress/{job_id}- Real-time progress updates