Spaces:
Sleeping
Sleeping
aladhefafalquran commited on
Commit ·
df2fada
0
Parent(s):
Initial commit
Browse files- .gitignore +27 -0
- Dockerfile +32 -0
- README.md +39 -0
- app.py +649 -0
- output/.gitkeep +0 -0
- requirements.txt +19 -0
- src/__init__.py +2 -0
- src/audio_processor.py +247 -0
- src/quran_data.py +327 -0
- src/subtitle_generator.py +196 -0
- src/verse_matcher.py +398 -0
- src/video_generator.py +263 -0
- templates/index.html +1976 -0
- uploads/.gitkeep +0 -0
.gitignore
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Byte-compiled / optimized / DLL files
|
| 2 |
+
__pycache__/
|
| 3 |
+
*.py[cod]
|
| 4 |
+
*$py.class
|
| 5 |
+
|
| 6 |
+
# Virtual environments
|
| 7 |
+
venv/
|
| 8 |
+
env/
|
| 9 |
+
.env
|
| 10 |
+
|
| 11 |
+
# Output files
|
| 12 |
+
output/*
|
| 13 |
+
!output/.gitkeep
|
| 14 |
+
uploads/*
|
| 15 |
+
!uploads/.gitkeep
|
| 16 |
+
|
| 17 |
+
# Temporary files
|
| 18 |
+
*.tmp
|
| 19 |
+
*.temp
|
| 20 |
+
|
| 21 |
+
# IDE
|
| 22 |
+
.vscode/
|
| 23 |
+
.idea/
|
| 24 |
+
|
| 25 |
+
# OS
|
| 26 |
+
.DS_Store
|
| 27 |
+
Thumbs.db
|
Dockerfile
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
FROM python:3.10-slim
|
| 2 |
+
|
| 3 |
+
# Set working directory
|
| 4 |
+
WORKDIR /app
|
| 5 |
+
|
| 6 |
+
# Install system dependencies (FFmpeg, etc.)
|
| 7 |
+
RUN apt-get update && apt-get install -y \
|
| 8 |
+
ffmpeg \
|
| 9 |
+
git \
|
| 10 |
+
&& rm -rf /var/lib/apt/lists/*
|
| 11 |
+
|
| 12 |
+
# Copy requirements first for caching
|
| 13 |
+
COPY requirements.txt .
|
| 14 |
+
|
| 15 |
+
# Install Python dependencies
|
| 16 |
+
RUN pip install --no-cache-dir -r requirements.txt
|
| 17 |
+
|
| 18 |
+
# Copy application code
|
| 19 |
+
COPY . .
|
| 20 |
+
|
| 21 |
+
# Create output directory
|
| 22 |
+
RUN mkdir -p /app/output /app/uploads
|
| 23 |
+
|
| 24 |
+
# Set environment variables
|
| 25 |
+
ENV PYTHONUNBUFFERED=1
|
| 26 |
+
ENV HF_SPACES=1
|
| 27 |
+
|
| 28 |
+
# Expose port (HF Spaces uses 7860)
|
| 29 |
+
EXPOSE 7860
|
| 30 |
+
|
| 31 |
+
# Run the application
|
| 32 |
+
CMD ["python", "app.py"]
|
README.md
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
---
|
| 2 |
+
title: Quran SRT Generator
|
| 3 |
+
emoji: 📖
|
| 4 |
+
colorFrom: green
|
| 5 |
+
colorTo: blue
|
| 6 |
+
sdk: docker
|
| 7 |
+
pinned: false
|
| 8 |
+
license: mit
|
| 9 |
+
---
|
| 10 |
+
|
| 11 |
+
# Quran SRT Generator
|
| 12 |
+
|
| 13 |
+
Generate synchronized subtitles (SRT) for Quran recitation videos with multiple language support.
|
| 14 |
+
|
| 15 |
+
## Features
|
| 16 |
+
|
| 17 |
+
- **Multiple Languages**: Arabic, English, Turkish, French, Urdu, and more
|
| 18 |
+
- **Multiple Reciters**: 20+ famous Quran reciters
|
| 19 |
+
- **Video Generation**: Create videos with black background and burned-in subtitles
|
| 20 |
+
- **Accurate Timing**: Syncs subtitles with actual audio from AlQuran API
|
| 21 |
+
- **Download Options**: SRT files, JSON timing data, and MP4 videos
|
| 22 |
+
|
| 23 |
+
## How to Use
|
| 24 |
+
|
| 25 |
+
1. Select a Surah (chapter)
|
| 26 |
+
2. Choose ayah range (start/end verses)
|
| 27 |
+
3. Select languages for subtitles
|
| 28 |
+
4. Choose a reciter
|
| 29 |
+
5. Click "Generate" and wait for processing
|
| 30 |
+
6. Download your SRT files or video
|
| 31 |
+
|
| 32 |
+
## Supported Languages
|
| 33 |
+
|
| 34 |
+
Arabic, English, Turkish, French, German, Spanish, Indonesian, Urdu, Bengali, Russian, Chinese, Japanese, Korean, Malay, Dutch, Italian
|
| 35 |
+
|
| 36 |
+
## Credits
|
| 37 |
+
|
| 38 |
+
- Quran text and audio from [AlQuran Cloud API](https://alquran.cloud/)
|
| 39 |
+
- Audio transcription powered by OpenAI Whisper
|
app.py
ADDED
|
@@ -0,0 +1,649 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Quran SRT Generator - FastAPI Backend
|
| 3 |
+
Hugging Face Spaces Version
|
| 4 |
+
"""
|
| 5 |
+
|
| 6 |
+
import os
|
| 7 |
+
import asyncio
|
| 8 |
+
import uuid
|
| 9 |
+
import json
|
| 10 |
+
from pathlib import Path
|
| 11 |
+
from typing import Optional, List
|
| 12 |
+
from dataclasses import asdict
|
| 13 |
+
|
| 14 |
+
from fastapi import FastAPI, UploadFile, File, Form, HTTPException, BackgroundTasks
|
| 15 |
+
from fastapi.staticfiles import StaticFiles
|
| 16 |
+
from fastapi.templating import Jinja2Templates
|
| 17 |
+
from fastapi.responses import FileResponse, HTMLResponse, JSONResponse
|
| 18 |
+
from fastapi.requests import Request
|
| 19 |
+
from pydantic import BaseModel
|
| 20 |
+
|
| 21 |
+
from src.quran_data import fetch_surah_list, fetch_all_translations, download_full_quran, check_cached_data, set_offline_mode, is_offline_mode
|
| 22 |
+
from src.audio_processor import get_processor, WHISPER_AVAILABLE
|
| 23 |
+
from src.verse_matcher import match_video_to_verses, MatchedVerse
|
| 24 |
+
from src.subtitle_generator import SubtitleGenerator
|
| 25 |
+
from src.video_generator import generate_quran_video, VideoGenerator
|
| 26 |
+
|
| 27 |
+
# Initialize FastAPI app
|
| 28 |
+
app = FastAPI(
|
| 29 |
+
title="Quran SRT Generator",
|
| 30 |
+
description="Generate subtitles for Quran recitation videos with Arabic, English, and Turkish translations",
|
| 31 |
+
version="1.0.0"
|
| 32 |
+
)
|
| 33 |
+
|
| 34 |
+
# Setup directories
|
| 35 |
+
BASE_DIR = Path(__file__).parent
|
| 36 |
+
UPLOAD_DIR = BASE_DIR / "uploads"
|
| 37 |
+
OUTPUT_DIR = BASE_DIR / "output"
|
| 38 |
+
STATIC_DIR = BASE_DIR / "static"
|
| 39 |
+
TEMPLATES_DIR = BASE_DIR / "templates"
|
| 40 |
+
|
| 41 |
+
UPLOAD_DIR.mkdir(exist_ok=True)
|
| 42 |
+
OUTPUT_DIR.mkdir(exist_ok=True)
|
| 43 |
+
STATIC_DIR.mkdir(exist_ok=True)
|
| 44 |
+
|
| 45 |
+
# Mount static files and templates
|
| 46 |
+
app.mount("/static", StaticFiles(directory=str(STATIC_DIR)), name="static")
|
| 47 |
+
app.mount("/output", StaticFiles(directory=str(OUTPUT_DIR)), name="output")
|
| 48 |
+
templates = Jinja2Templates(directory=str(TEMPLATES_DIR))
|
| 49 |
+
|
| 50 |
+
# Processing status tracking
|
| 51 |
+
processing_status = {}
|
| 52 |
+
|
| 53 |
+
|
| 54 |
+
class ProcessingRequest(BaseModel):
|
| 55 |
+
surah: int
|
| 56 |
+
start_ayah: int = 1
|
| 57 |
+
end_ayah: Optional[int] = None
|
| 58 |
+
separator: str = " | "
|
| 59 |
+
languages: List[str] = ["arabic", "english", "turkish"]
|
| 60 |
+
whisper_model: str = "medium"
|
| 61 |
+
|
| 62 |
+
|
| 63 |
+
class ProcessingStatus(BaseModel):
|
| 64 |
+
task_id: str
|
| 65 |
+
status: str
|
| 66 |
+
progress: int
|
| 67 |
+
message: str
|
| 68 |
+
output_files: Optional[dict] = None
|
| 69 |
+
error: Optional[str] = None
|
| 70 |
+
|
| 71 |
+
|
| 72 |
+
@app.get("/", response_class=HTMLResponse)
|
| 73 |
+
async def home(request: Request):
|
| 74 |
+
"""Serve the main web interface"""
|
| 75 |
+
return templates.TemplateResponse("index.html", {
|
| 76 |
+
"request": request,
|
| 77 |
+
"whisper_available": WHISPER_AVAILABLE
|
| 78 |
+
})
|
| 79 |
+
|
| 80 |
+
|
| 81 |
+
@app.get("/api/surahs")
|
| 82 |
+
async def get_surahs():
|
| 83 |
+
"""Get list of all surahs"""
|
| 84 |
+
try:
|
| 85 |
+
surahs = await fetch_surah_list()
|
| 86 |
+
return {"success": True, "surahs": surahs}
|
| 87 |
+
except Exception as e:
|
| 88 |
+
return {"success": False, "error": str(e)}
|
| 89 |
+
|
| 90 |
+
|
| 91 |
+
@app.get("/api/surah/{surah_number}")
|
| 92 |
+
async def get_surah(surah_number: int):
|
| 93 |
+
"""Get surah verses with all translations"""
|
| 94 |
+
try:
|
| 95 |
+
verses = await fetch_all_translations(surah_number)
|
| 96 |
+
return {
|
| 97 |
+
"success": True,
|
| 98 |
+
"surah": surah_number,
|
| 99 |
+
"verses": verses
|
| 100 |
+
}
|
| 101 |
+
except Exception as e:
|
| 102 |
+
return {"success": False, "error": str(e)}
|
| 103 |
+
|
| 104 |
+
|
| 105 |
+
@app.get("/api/reciters")
|
| 106 |
+
async def get_reciters():
|
| 107 |
+
"""Get list of available audio reciters"""
|
| 108 |
+
reciters = [
|
| 109 |
+
{"identifier": "ar.alafasy", "englishName": "Mishary Rashid Alafasy", "style": "Murattal"},
|
| 110 |
+
{"identifier": "ar.abdurrahmaansudais", "englishName": "Abdurrahmaan As-Sudais", "style": "Murattal"},
|
| 111 |
+
{"identifier": "ar.husary", "englishName": "Mahmoud Khalil Al-Husary", "style": "Murattal"},
|
| 112 |
+
{"identifier": "ar.minshawi", "englishName": "Mohamed Siddiq Al-Minshawi", "style": "Murattal"},
|
| 113 |
+
{"identifier": "ar.abdulsamad", "englishName": "Abdul Samad", "style": "Murattal"},
|
| 114 |
+
{"identifier": "ar.shaatree", "englishName": "Abu Bakr Ash-Shaatree", "style": "Murattal"},
|
| 115 |
+
{"identifier": "ar.ahmedajamy", "englishName": "Ahmed ibn Ali al-Ajamy", "style": "Murattal"},
|
| 116 |
+
{"identifier": "ar.hudhaify", "englishName": "Ali Al-Hudhaify", "style": "Murattal"},
|
| 117 |
+
{"identifier": "ar.ibrahimakhbar", "englishName": "Ibrahim Al-Akhdar", "style": "Murattal"},
|
| 118 |
+
{"identifier": "ar.mahermuaiqly", "englishName": "Maher Al-Muaiqly", "style": "Murattal"},
|
| 119 |
+
{"identifier": "ar.muhammadayyoub", "englishName": "Muhammad Ayyub", "style": "Murattal"},
|
| 120 |
+
{"identifier": "ar.muhammadjibreel", "englishName": "Muhammad Jibreel", "style": "Murattal"},
|
| 121 |
+
{"identifier": "ar.saaborinah", "englishName": "Saad Al-Ghamdi", "style": "Murattal"},
|
| 122 |
+
{"identifier": "ar.parhizgar", "englishName": "Shahriar Parhizgar", "style": "Murattal"},
|
| 123 |
+
{"identifier": "ar.aaboromali", "englishName": "Abdullah Awad al-Juhani", "style": "Murattal"},
|
| 124 |
+
{"identifier": "ar.haborouni", "englishName": "Hani Ar-Rifai", "style": "Murattal"},
|
| 125 |
+
{"identifier": "ar.abdullahbasfar", "englishName": "Abdullah Basfar", "style": "Murattal"},
|
| 126 |
+
{"identifier": "ar.ibrahimwalkil", "englishName": "Ibrahim Walk (English)", "style": "Translation"},
|
| 127 |
+
{"identifier": "ar.husarymujawwad", "englishName": "Al-Husary (Mujawwad)", "style": "Mujawwad"},
|
| 128 |
+
{"identifier": "ar.minshawimujawwad", "englishName": "Al-Minshawi (Mujawwad)", "style": "Mujawwad"},
|
| 129 |
+
]
|
| 130 |
+
return {"success": True, "reciters": reciters}
|
| 131 |
+
|
| 132 |
+
|
| 133 |
+
@app.post("/api/upload")
|
| 134 |
+
async def upload_video(
|
| 135 |
+
surah: int = Form(...),
|
| 136 |
+
start_ayah: int = Form(1),
|
| 137 |
+
end_ayah: Optional[int] = Form(None),
|
| 138 |
+
separator: str = Form(" | "),
|
| 139 |
+
languages: str = Form('["arabic", "english"]'),
|
| 140 |
+
mode: str = Form("video"),
|
| 141 |
+
model_size: str = Form("medium"),
|
| 142 |
+
reciter: str = Form("ar.alafasy"),
|
| 143 |
+
file: Optional[UploadFile] = File(None),
|
| 144 |
+
image: Optional[UploadFile] = File(None)
|
| 145 |
+
):
|
| 146 |
+
"""Upload video/image and start processing"""
|
| 147 |
+
try:
|
| 148 |
+
selected_languages = json.loads(languages)
|
| 149 |
+
except:
|
| 150 |
+
selected_languages = ["arabic", "english"]
|
| 151 |
+
|
| 152 |
+
task_id = str(uuid.uuid4())
|
| 153 |
+
upload_path = None
|
| 154 |
+
image_path = None
|
| 155 |
+
|
| 156 |
+
if mode == "video":
|
| 157 |
+
if not file or not file.filename:
|
| 158 |
+
raise HTTPException(400, "No video/audio file provided")
|
| 159 |
+
|
| 160 |
+
allowed_extensions = {".mp4", ".mkv", ".avi", ".mov", ".webm", ".mp3", ".wav", ".m4a"}
|
| 161 |
+
file_ext = Path(file.filename).suffix.lower()
|
| 162 |
+
|
| 163 |
+
if file_ext not in allowed_extensions:
|
| 164 |
+
raise HTTPException(400, f"Unsupported file format: {file_ext}")
|
| 165 |
+
|
| 166 |
+
upload_path = UPLOAD_DIR / f"{task_id}{file_ext}"
|
| 167 |
+
try:
|
| 168 |
+
with open(upload_path, "wb") as f:
|
| 169 |
+
content = await file.read()
|
| 170 |
+
f.write(content)
|
| 171 |
+
except Exception as e:
|
| 172 |
+
raise HTTPException(500, f"Failed to save file: {e}")
|
| 173 |
+
|
| 174 |
+
elif mode == "image":
|
| 175 |
+
if not image or not image.filename:
|
| 176 |
+
raise HTTPException(400, "No image file provided")
|
| 177 |
+
|
| 178 |
+
allowed_extensions = {".jpg", ".jpeg", ".png", ".webp"}
|
| 179 |
+
file_ext = Path(image.filename).suffix.lower()
|
| 180 |
+
|
| 181 |
+
if file_ext not in allowed_extensions:
|
| 182 |
+
raise HTTPException(400, f"Unsupported image format: {file_ext}")
|
| 183 |
+
|
| 184 |
+
image_path = UPLOAD_DIR / f"{task_id}_bg{file_ext}"
|
| 185 |
+
try:
|
| 186 |
+
with open(image_path, "wb") as f:
|
| 187 |
+
content = await image.read()
|
| 188 |
+
f.write(content)
|
| 189 |
+
except Exception as e:
|
| 190 |
+
raise HTTPException(500, f"Failed to save image: {e}")
|
| 191 |
+
|
| 192 |
+
processing_status[task_id] = {
|
| 193 |
+
"status": "pending",
|
| 194 |
+
"progress": 0,
|
| 195 |
+
"message": "Upload complete. Starting processing...",
|
| 196 |
+
"output_files": None,
|
| 197 |
+
"error": None,
|
| 198 |
+
"settings": {
|
| 199 |
+
"mode": mode,
|
| 200 |
+
"surah": surah,
|
| 201 |
+
"start_ayah": start_ayah,
|
| 202 |
+
"end_ayah": end_ayah,
|
| 203 |
+
"separator": separator,
|
| 204 |
+
"languages": selected_languages,
|
| 205 |
+
"model_size": model_size,
|
| 206 |
+
"reciter": reciter,
|
| 207 |
+
"file_path": str(upload_path) if upload_path else None,
|
| 208 |
+
"image_path": str(image_path) if image_path else None
|
| 209 |
+
}
|
| 210 |
+
}
|
| 211 |
+
|
| 212 |
+
return {
|
| 213 |
+
"success": True,
|
| 214 |
+
"task_id": task_id,
|
| 215 |
+
"message": "Upload successful. Processing will start shortly."
|
| 216 |
+
}
|
| 217 |
+
|
| 218 |
+
|
| 219 |
+
@app.post("/api/process/{task_id}")
|
| 220 |
+
async def start_processing(task_id: str, background_tasks: BackgroundTasks):
|
| 221 |
+
"""Start processing an uploaded video"""
|
| 222 |
+
if task_id not in processing_status:
|
| 223 |
+
raise HTTPException(404, "Task not found")
|
| 224 |
+
|
| 225 |
+
status = processing_status[task_id]
|
| 226 |
+
if status["status"] not in ["pending", "error"]:
|
| 227 |
+
return {"success": False, "message": "Task already processing or completed"}
|
| 228 |
+
|
| 229 |
+
background_tasks.add_task(process_video_task, task_id)
|
| 230 |
+
|
| 231 |
+
processing_status[task_id]["status"] = "processing"
|
| 232 |
+
processing_status[task_id]["message"] = "Processing started..."
|
| 233 |
+
|
| 234 |
+
return {"success": True, "message": "Processing started"}
|
| 235 |
+
|
| 236 |
+
|
| 237 |
+
async def process_video_task(task_id: str):
|
| 238 |
+
"""Background task to process video"""
|
| 239 |
+
status = processing_status[task_id]
|
| 240 |
+
settings = status["settings"]
|
| 241 |
+
mode = settings.get("mode", "video")
|
| 242 |
+
|
| 243 |
+
try:
|
| 244 |
+
status["progress"] = 10
|
| 245 |
+
status["message"] = "Loading Quran data..."
|
| 246 |
+
|
| 247 |
+
selected_languages = settings.get("languages", ["arabic", "english", "turkish"])
|
| 248 |
+
verses_data = await fetch_all_translations(settings["surah"], selected_languages)
|
| 249 |
+
|
| 250 |
+
if mode == "video":
|
| 251 |
+
status["progress"] = 20
|
| 252 |
+
status["message"] = "Extracting audio from video..."
|
| 253 |
+
|
| 254 |
+
processor = get_processor(settings.get("model_size", "medium"))
|
| 255 |
+
|
| 256 |
+
status["progress"] = 30
|
| 257 |
+
status["message"] = "Transcribing audio (this may take a few minutes)..."
|
| 258 |
+
|
| 259 |
+
loop = asyncio.get_event_loop()
|
| 260 |
+
transcription = await loop.run_in_executor(
|
| 261 |
+
None,
|
| 262 |
+
lambda: processor.transcribe_video(settings["file_path"])
|
| 263 |
+
)
|
| 264 |
+
|
| 265 |
+
segments = processor.get_segments_with_timing(transcription)
|
| 266 |
+
|
| 267 |
+
status["progress"] = 70
|
| 268 |
+
status["message"] = "Matching verses..."
|
| 269 |
+
|
| 270 |
+
matched_verses = await match_video_to_verses(
|
| 271 |
+
segments,
|
| 272 |
+
surah=settings["surah"],
|
| 273 |
+
start_ayah=settings["start_ayah"],
|
| 274 |
+
end_ayah=settings["end_ayah"],
|
| 275 |
+
languages=selected_languages
|
| 276 |
+
)
|
| 277 |
+
else:
|
| 278 |
+
status["progress"] = 30
|
| 279 |
+
status["message"] = "Downloading audio from reciter..."
|
| 280 |
+
|
| 281 |
+
start_ayah = settings.get("start_ayah", 1)
|
| 282 |
+
end_ayah = settings.get("end_ayah")
|
| 283 |
+
reciter = settings.get("reciter", "ar.alafasy")
|
| 284 |
+
|
| 285 |
+
video_gen = VideoGenerator(output_dir=str(OUTPUT_DIR))
|
| 286 |
+
ayah_timings = []
|
| 287 |
+
|
| 288 |
+
try:
|
| 289 |
+
audio_path, ayah_timings = await video_gen.download_surah_audio(
|
| 290 |
+
surah=settings["surah"],
|
| 291 |
+
reciter=reciter,
|
| 292 |
+
start_ayah=start_ayah,
|
| 293 |
+
end_ayah=end_ayah
|
| 294 |
+
)
|
| 295 |
+
except Exception as e:
|
| 296 |
+
print(f"Audio download failed: {e}")
|
| 297 |
+
audio_path = None
|
| 298 |
+
ayah_timings = []
|
| 299 |
+
|
| 300 |
+
status["progress"] = 50
|
| 301 |
+
status["message"] = "Generating verse timings..."
|
| 302 |
+
|
| 303 |
+
filtered_verses = {}
|
| 304 |
+
for ayah_num, verse in verses_data.items():
|
| 305 |
+
if ayah_num >= start_ayah:
|
| 306 |
+
if end_ayah is None or ayah_num <= end_ayah:
|
| 307 |
+
filtered_verses[ayah_num] = verse
|
| 308 |
+
|
| 309 |
+
timing_lookup = {t[0]: (t[1], t[2]) for t in ayah_timings}
|
| 310 |
+
|
| 311 |
+
matched_verses = []
|
| 312 |
+
current_time = 0.0
|
| 313 |
+
default_duration = 5.0
|
| 314 |
+
|
| 315 |
+
for ayah_num in sorted(filtered_verses.keys()):
|
| 316 |
+
verse = filtered_verses[ayah_num]
|
| 317 |
+
|
| 318 |
+
if ayah_num in timing_lookup:
|
| 319 |
+
start_time, end_time = timing_lookup[ayah_num]
|
| 320 |
+
else:
|
| 321 |
+
start_time = current_time
|
| 322 |
+
end_time = current_time + default_duration
|
| 323 |
+
current_time = end_time
|
| 324 |
+
|
| 325 |
+
extra_translations = {}
|
| 326 |
+
for lang in selected_languages:
|
| 327 |
+
if lang not in ["arabic", "english", "turkish"]:
|
| 328 |
+
extra_translations[lang] = verse.get(lang, "")
|
| 329 |
+
|
| 330 |
+
matched_verses.append(MatchedVerse(
|
| 331 |
+
surah=settings["surah"],
|
| 332 |
+
ayah=ayah_num,
|
| 333 |
+
arabic=verse.get("arabic", ""),
|
| 334 |
+
english=verse.get("english", ""),
|
| 335 |
+
turkish=verse.get("turkish", ""),
|
| 336 |
+
start_time=start_time,
|
| 337 |
+
end_time=end_time,
|
| 338 |
+
confidence=1.0,
|
| 339 |
+
segment_text="",
|
| 340 |
+
translations=extra_translations
|
| 341 |
+
))
|
| 342 |
+
|
| 343 |
+
status["progress"] = 60
|
| 344 |
+
status["message"] = f"Generated timing for {len(matched_verses)} verses..."
|
| 345 |
+
status["audio_path"] = audio_path
|
| 346 |
+
|
| 347 |
+
status["progress"] = 90
|
| 348 |
+
status["message"] = "Generating subtitle files..."
|
| 349 |
+
|
| 350 |
+
generator = SubtitleGenerator(
|
| 351 |
+
output_dir=str(OUTPUT_DIR),
|
| 352 |
+
separator=settings["separator"],
|
| 353 |
+
languages=settings.get("languages", ["arabic", "english", "turkish"])
|
| 354 |
+
)
|
| 355 |
+
|
| 356 |
+
base_filename = f"quran_surah{settings['surah']}_{task_id[:8]}"
|
| 357 |
+
output_files = generator.generate_all(matched_verses, base_filename)
|
| 358 |
+
|
| 359 |
+
if mode in ["image", "black"] and status.get("audio_path"):
|
| 360 |
+
status["progress"] = 95
|
| 361 |
+
status["message"] = "Generating video with burned-in subtitles..."
|
| 362 |
+
|
| 363 |
+
try:
|
| 364 |
+
video_output = str(OUTPUT_DIR / f"{base_filename}_video.mp4")
|
| 365 |
+
srt_for_video = output_files.get("srt_combined")
|
| 366 |
+
|
| 367 |
+
bg_image = settings.get("image_path") if mode == "image" else None
|
| 368 |
+
|
| 369 |
+
video_gen = VideoGenerator(output_dir=str(OUTPUT_DIR))
|
| 370 |
+
video_path = video_gen.create_video_with_subtitles(
|
| 371 |
+
audio_path=status["audio_path"],
|
| 372 |
+
srt_path=srt_for_video,
|
| 373 |
+
output_path=video_output,
|
| 374 |
+
background_image=bg_image
|
| 375 |
+
)
|
| 376 |
+
|
| 377 |
+
output_files["video"] = video_path
|
| 378 |
+
status["video_file"] = video_path
|
| 379 |
+
except Exception as e:
|
| 380 |
+
print(f"Video generation failed: {e}")
|
| 381 |
+
|
| 382 |
+
verses_data = []
|
| 383 |
+
for v in matched_verses:
|
| 384 |
+
verse_dict = {
|
| 385 |
+
"surah": v.surah,
|
| 386 |
+
"ayah": v.ayah,
|
| 387 |
+
"arabic": v.arabic,
|
| 388 |
+
"english": v.english,
|
| 389 |
+
"turkish": v.turkish,
|
| 390 |
+
"start_time": v.start_time,
|
| 391 |
+
"end_time": v.end_time,
|
| 392 |
+
"confidence": v.confidence
|
| 393 |
+
}
|
| 394 |
+
if v.translations:
|
| 395 |
+
for lang, text in v.translations.items():
|
| 396 |
+
verse_dict[lang] = text
|
| 397 |
+
verses_data.append(verse_dict)
|
| 398 |
+
|
| 399 |
+
status["progress"] = 100
|
| 400 |
+
status["status"] = "completed"
|
| 401 |
+
status["message"] = f"Processing complete! Generated {len(matched_verses)} verse subtitles."
|
| 402 |
+
status["output_files"] = output_files
|
| 403 |
+
status["verses"] = verses_data
|
| 404 |
+
status["video_file"] = settings.get("file_path")
|
| 405 |
+
|
| 406 |
+
except Exception as e:
|
| 407 |
+
status["status"] = "error"
|
| 408 |
+
status["error"] = str(e)
|
| 409 |
+
status["message"] = f"Error: {str(e)}"
|
| 410 |
+
|
| 411 |
+
|
| 412 |
+
@app.get("/api/status/{task_id}")
|
| 413 |
+
async def get_status(task_id: str):
|
| 414 |
+
"""Get processing status"""
|
| 415 |
+
if task_id not in processing_status:
|
| 416 |
+
raise HTTPException(404, "Task not found")
|
| 417 |
+
return processing_status[task_id]
|
| 418 |
+
|
| 419 |
+
|
| 420 |
+
@app.get("/api/download/{task_id}/{file_type}")
|
| 421 |
+
async def download_file(task_id: str, file_type: str):
|
| 422 |
+
"""Download generated file"""
|
| 423 |
+
if task_id not in processing_status:
|
| 424 |
+
raise HTTPException(404, "Task not found")
|
| 425 |
+
|
| 426 |
+
status = processing_status[task_id]
|
| 427 |
+
if status["status"] != "completed":
|
| 428 |
+
raise HTTPException(400, "Processing not complete")
|
| 429 |
+
|
| 430 |
+
output_files = status.get("output_files", {})
|
| 431 |
+
|
| 432 |
+
if file_type == "srt_combined":
|
| 433 |
+
file_path = output_files.get("srt_combined")
|
| 434 |
+
elif file_type == "json":
|
| 435 |
+
file_path = output_files.get("json")
|
| 436 |
+
elif file_type == "video":
|
| 437 |
+
file_path = output_files.get("video")
|
| 438 |
+
if file_path:
|
| 439 |
+
return FileResponse(
|
| 440 |
+
file_path,
|
| 441 |
+
filename=Path(file_path).name,
|
| 442 |
+
media_type="video/mp4"
|
| 443 |
+
)
|
| 444 |
+
elif file_type.startswith("srt_"):
|
| 445 |
+
lang = file_type.replace("srt_", "")
|
| 446 |
+
file_path = output_files.get("srt_separate", {}).get(lang)
|
| 447 |
+
else:
|
| 448 |
+
raise HTTPException(400, "Invalid file type")
|
| 449 |
+
|
| 450 |
+
if not file_path or not Path(file_path).exists():
|
| 451 |
+
raise HTTPException(404, "File not found")
|
| 452 |
+
|
| 453 |
+
return FileResponse(
|
| 454 |
+
file_path,
|
| 455 |
+
filename=Path(file_path).name,
|
| 456 |
+
media_type="application/octet-stream"
|
| 457 |
+
)
|
| 458 |
+
|
| 459 |
+
|
| 460 |
+
@app.post("/api/download-quran")
|
| 461 |
+
async def download_quran_data(background_tasks: BackgroundTasks):
|
| 462 |
+
"""Download full Quran data for offline use"""
|
| 463 |
+
background_tasks.add_task(download_full_quran)
|
| 464 |
+
return {"success": True, "message": "Downloading Quran data in background..."}
|
| 465 |
+
|
| 466 |
+
|
| 467 |
+
@app.get("/api/health")
|
| 468 |
+
async def health_check():
|
| 469 |
+
"""Health check endpoint"""
|
| 470 |
+
return {
|
| 471 |
+
"status": "healthy",
|
| 472 |
+
"whisper_available": WHISPER_AVAILABLE,
|
| 473 |
+
"version": "1.0.0"
|
| 474 |
+
}
|
| 475 |
+
|
| 476 |
+
|
| 477 |
+
@app.get("/api/cache-status")
|
| 478 |
+
async def get_cache_status():
|
| 479 |
+
"""Get information about cached Quran data"""
|
| 480 |
+
cache_info = check_cached_data()
|
| 481 |
+
return {
|
| 482 |
+
"success": True,
|
| 483 |
+
"offline_mode": is_offline_mode(),
|
| 484 |
+
"cache": cache_info
|
| 485 |
+
}
|
| 486 |
+
|
| 487 |
+
|
| 488 |
+
@app.post("/api/offline-mode")
|
| 489 |
+
async def toggle_offline_mode(enabled: bool = True):
|
| 490 |
+
"""Toggle offline mode on/off"""
|
| 491 |
+
set_offline_mode(enabled)
|
| 492 |
+
return {
|
| 493 |
+
"success": True,
|
| 494 |
+
"offline_mode": is_offline_mode(),
|
| 495 |
+
"message": f"Offline mode {'enabled' if enabled else 'disabled'}"
|
| 496 |
+
}
|
| 497 |
+
|
| 498 |
+
|
| 499 |
+
@app.get("/api/verses/{task_id}")
|
| 500 |
+
async def get_verses(task_id: str):
|
| 501 |
+
"""Get verses data for editing"""
|
| 502 |
+
if task_id not in processing_status:
|
| 503 |
+
raise HTTPException(404, "Task not found")
|
| 504 |
+
|
| 505 |
+
status = processing_status[task_id]
|
| 506 |
+
if status["status"] != "completed":
|
| 507 |
+
raise HTTPException(400, "Processing not complete")
|
| 508 |
+
|
| 509 |
+
return {
|
| 510 |
+
"success": True,
|
| 511 |
+
"verses": status.get("verses", []),
|
| 512 |
+
"settings": status.get("settings", {})
|
| 513 |
+
}
|
| 514 |
+
|
| 515 |
+
|
| 516 |
+
class VerseUpdate(BaseModel):
|
| 517 |
+
index: int
|
| 518 |
+
start_time: float
|
| 519 |
+
end_time: float
|
| 520 |
+
|
| 521 |
+
|
| 522 |
+
class VersesUpdateRequest(BaseModel):
|
| 523 |
+
verses: List[VerseUpdate]
|
| 524 |
+
|
| 525 |
+
|
| 526 |
+
@app.post("/api/verses/{task_id}/update")
|
| 527 |
+
async def update_verses(task_id: str, request: VersesUpdateRequest):
|
| 528 |
+
"""Update verse timings"""
|
| 529 |
+
if task_id not in processing_status:
|
| 530 |
+
raise HTTPException(404, "Task not found")
|
| 531 |
+
|
| 532 |
+
status = processing_status[task_id]
|
| 533 |
+
if status["status"] != "completed":
|
| 534 |
+
raise HTTPException(400, "Processing not complete")
|
| 535 |
+
|
| 536 |
+
verses = status.get("verses", [])
|
| 537 |
+
|
| 538 |
+
for update in request.verses:
|
| 539 |
+
if 0 <= update.index < len(verses):
|
| 540 |
+
verses[update.index]["start_time"] = update.start_time
|
| 541 |
+
verses[update.index]["end_time"] = update.end_time
|
| 542 |
+
|
| 543 |
+
status["verses"] = verses
|
| 544 |
+
|
| 545 |
+
return {"success": True, "message": "Timings updated"}
|
| 546 |
+
|
| 547 |
+
|
| 548 |
+
@app.post("/api/regenerate/{task_id}")
|
| 549 |
+
async def regenerate_subtitles(task_id: str):
|
| 550 |
+
"""Regenerate SRT/JSON files with updated timings"""
|
| 551 |
+
if task_id not in processing_status:
|
| 552 |
+
raise HTTPException(404, "Task not found")
|
| 553 |
+
|
| 554 |
+
status = processing_status[task_id]
|
| 555 |
+
if status["status"] != "completed":
|
| 556 |
+
raise HTTPException(400, "Processing not complete")
|
| 557 |
+
|
| 558 |
+
verses_data = status.get("verses", [])
|
| 559 |
+
settings = status.get("settings", {})
|
| 560 |
+
|
| 561 |
+
matched_verses = []
|
| 562 |
+
for v in verses_data:
|
| 563 |
+
extra_translations = {}
|
| 564 |
+
for key, value in v.items():
|
| 565 |
+
if key not in ["surah", "ayah", "arabic", "english", "turkish", "start_time", "end_time", "confidence"]:
|
| 566 |
+
extra_translations[key] = value
|
| 567 |
+
|
| 568 |
+
matched_verses.append(MatchedVerse(
|
| 569 |
+
surah=v["surah"],
|
| 570 |
+
ayah=v["ayah"],
|
| 571 |
+
arabic=v.get("arabic", ""),
|
| 572 |
+
english=v.get("english", ""),
|
| 573 |
+
turkish=v.get("turkish", ""),
|
| 574 |
+
start_time=v["start_time"],
|
| 575 |
+
end_time=v["end_time"],
|
| 576 |
+
confidence=v.get("confidence", 1.0),
|
| 577 |
+
segment_text="",
|
| 578 |
+
translations=extra_translations if extra_translations else None
|
| 579 |
+
))
|
| 580 |
+
|
| 581 |
+
generator = SubtitleGenerator(
|
| 582 |
+
output_dir=str(OUTPUT_DIR),
|
| 583 |
+
separator=settings.get("separator", " | "),
|
| 584 |
+
languages=settings.get("languages", ["arabic", "english", "turkish"])
|
| 585 |
+
)
|
| 586 |
+
|
| 587 |
+
base_filename = f"quran_surah{settings['surah']}_{task_id[:8]}"
|
| 588 |
+
output_files = generator.generate_all(matched_verses, base_filename)
|
| 589 |
+
|
| 590 |
+
status["output_files"] = output_files
|
| 591 |
+
|
| 592 |
+
return {
|
| 593 |
+
"success": True,
|
| 594 |
+
"message": "Subtitles regenerated with updated timings",
|
| 595 |
+
"output_files": output_files
|
| 596 |
+
}
|
| 597 |
+
|
| 598 |
+
|
| 599 |
+
@app.get("/api/video/{task_id}")
|
| 600 |
+
async def get_video(task_id: str):
|
| 601 |
+
"""Stream video file for preview"""
|
| 602 |
+
if task_id not in processing_status:
|
| 603 |
+
raise HTTPException(404, "Task not found")
|
| 604 |
+
|
| 605 |
+
status = processing_status[task_id]
|
| 606 |
+
|
| 607 |
+
video_path = status.get("video_file")
|
| 608 |
+
if not video_path or not Path(video_path).exists():
|
| 609 |
+
output_files = status.get("output_files", {})
|
| 610 |
+
video_path = output_files.get("video")
|
| 611 |
+
|
| 612 |
+
if not video_path or not Path(video_path).exists():
|
| 613 |
+
raise HTTPException(404, "Video file not found")
|
| 614 |
+
|
| 615 |
+
return FileResponse(
|
| 616 |
+
video_path,
|
| 617 |
+
media_type="video/mp4",
|
| 618 |
+
filename=Path(video_path).name
|
| 619 |
+
)
|
| 620 |
+
|
| 621 |
+
|
| 622 |
+
@app.delete("/api/task/{task_id}")
|
| 623 |
+
async def cleanup_task(task_id: str):
|
| 624 |
+
"""Clean up task and delete video file"""
|
| 625 |
+
if task_id not in processing_status:
|
| 626 |
+
raise HTTPException(404, "Task not found")
|
| 627 |
+
|
| 628 |
+
status = processing_status[task_id]
|
| 629 |
+
video_path = status.get("video_file")
|
| 630 |
+
|
| 631 |
+
if video_path and Path(video_path).exists():
|
| 632 |
+
try:
|
| 633 |
+
os.remove(video_path)
|
| 634 |
+
except:
|
| 635 |
+
pass
|
| 636 |
+
|
| 637 |
+
del processing_status[task_id]
|
| 638 |
+
|
| 639 |
+
return {"success": True, "message": "Task cleaned up"}
|
| 640 |
+
|
| 641 |
+
|
| 642 |
+
# Run with HF Spaces port (7860)
|
| 643 |
+
if __name__ == "__main__":
|
| 644 |
+
import uvicorn
|
| 645 |
+
port = int(os.environ.get("PORT", 7860))
|
| 646 |
+
print("Starting Quran SRT Generator (HF Spaces)...")
|
| 647 |
+
print(f"Whisper available: {WHISPER_AVAILABLE}")
|
| 648 |
+
print(f"\nRunning on port {port}")
|
| 649 |
+
uvicorn.run(app, host="0.0.0.0", port=port)
|
output/.gitkeep
ADDED
|
File without changes
|
requirements.txt
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Quran SRT Generator - HF Spaces Dependencies
|
| 2 |
+
|
| 3 |
+
# Web Framework
|
| 4 |
+
fastapi==0.109.0
|
| 5 |
+
uvicorn[standard]==0.27.0
|
| 6 |
+
python-multipart==0.0.6
|
| 7 |
+
jinja2==3.1.3
|
| 8 |
+
|
| 9 |
+
# HTTP Client (for Quran API)
|
| 10 |
+
httpx==0.26.0
|
| 11 |
+
|
| 12 |
+
# Audio Processing (Whisper)
|
| 13 |
+
openai-whisper==20231117
|
| 14 |
+
|
| 15 |
+
# Utilities
|
| 16 |
+
pydantic==2.5.3
|
| 17 |
+
|
| 18 |
+
# Additional for production
|
| 19 |
+
aiofiles==23.2.1
|
src/__init__.py
ADDED
|
@@ -0,0 +1,2 @@
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Quran SRT Generator
|
| 2 |
+
# Core modules for processing Quran recitation videos
|
src/audio_processor.py
ADDED
|
@@ -0,0 +1,247 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Audio Processor
|
| 3 |
+
Extracts audio from video and transcribes using Whisper
|
| 4 |
+
Optimized for Arabic Quran recitation
|
| 5 |
+
"""
|
| 6 |
+
|
| 7 |
+
import os
|
| 8 |
+
import subprocess
|
| 9 |
+
import tempfile
|
| 10 |
+
from pathlib import Path
|
| 11 |
+
from typing import Optional, List, Dict
|
| 12 |
+
import json
|
| 13 |
+
|
| 14 |
+
# Try to import whisper
|
| 15 |
+
try:
|
| 16 |
+
import whisper
|
| 17 |
+
WHISPER_AVAILABLE = True
|
| 18 |
+
except ImportError:
|
| 19 |
+
WHISPER_AVAILABLE = False
|
| 20 |
+
print("Warning: Whisper not installed. Install with: pip install openai-whisper")
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
class AudioProcessor:
|
| 24 |
+
def __init__(self, model_size: str = "medium"):
|
| 25 |
+
"""
|
| 26 |
+
Initialize the audio processor
|
| 27 |
+
|
| 28 |
+
Args:
|
| 29 |
+
model_size: Whisper model size
|
| 30 |
+
- "tiny": Fastest, least accurate
|
| 31 |
+
- "base": Fast, basic accuracy
|
| 32 |
+
- "small": Good balance
|
| 33 |
+
- "medium": Recommended for Arabic (best balance)
|
| 34 |
+
- "large": Most accurate, slowest (requires more VRAM)
|
| 35 |
+
"""
|
| 36 |
+
self.model_size = model_size
|
| 37 |
+
self.model = None
|
| 38 |
+
self.temp_dir = Path(tempfile.gettempdir()) / "quran_srt"
|
| 39 |
+
self.temp_dir.mkdir(exist_ok=True)
|
| 40 |
+
|
| 41 |
+
def load_model(self):
|
| 42 |
+
"""Load Whisper model (lazy loading)"""
|
| 43 |
+
if not WHISPER_AVAILABLE:
|
| 44 |
+
raise RuntimeError("Whisper is not installed. Run: pip install openai-whisper")
|
| 45 |
+
|
| 46 |
+
if self.model is None:
|
| 47 |
+
print(f"Loading Whisper {self.model_size} model...")
|
| 48 |
+
self.model = whisper.load_model(self.model_size)
|
| 49 |
+
print("Model loaded successfully!")
|
| 50 |
+
|
| 51 |
+
return self.model
|
| 52 |
+
|
| 53 |
+
def extract_audio(self, video_path: str, output_path: Optional[str] = None) -> str:
|
| 54 |
+
"""
|
| 55 |
+
Extract audio from video file using FFmpeg
|
| 56 |
+
|
| 57 |
+
Args:
|
| 58 |
+
video_path: Path to the video file
|
| 59 |
+
output_path: Optional output path for audio file
|
| 60 |
+
|
| 61 |
+
Returns:
|
| 62 |
+
Path to the extracted audio file
|
| 63 |
+
"""
|
| 64 |
+
video_path = Path(video_path)
|
| 65 |
+
|
| 66 |
+
if not video_path.exists():
|
| 67 |
+
raise FileNotFoundError(f"Video file not found: {video_path}")
|
| 68 |
+
|
| 69 |
+
if output_path is None:
|
| 70 |
+
output_path = self.temp_dir / f"{video_path.stem}_audio.wav"
|
| 71 |
+
else:
|
| 72 |
+
output_path = Path(output_path)
|
| 73 |
+
|
| 74 |
+
# FFmpeg command to extract audio as WAV (16kHz for Whisper)
|
| 75 |
+
cmd = [
|
| 76 |
+
"ffmpeg",
|
| 77 |
+
"-i", str(video_path),
|
| 78 |
+
"-vn", # No video
|
| 79 |
+
"-acodec", "pcm_s16le", # PCM format
|
| 80 |
+
"-ar", "16000", # 16kHz sample rate (Whisper optimal)
|
| 81 |
+
"-ac", "1", # Mono
|
| 82 |
+
"-y", # Overwrite output
|
| 83 |
+
str(output_path)
|
| 84 |
+
]
|
| 85 |
+
|
| 86 |
+
try:
|
| 87 |
+
result = subprocess.run(
|
| 88 |
+
cmd,
|
| 89 |
+
capture_output=True,
|
| 90 |
+
text=True,
|
| 91 |
+
check=True
|
| 92 |
+
)
|
| 93 |
+
print(f"Audio extracted to: {output_path}")
|
| 94 |
+
return str(output_path)
|
| 95 |
+
|
| 96 |
+
except subprocess.CalledProcessError as e:
|
| 97 |
+
raise RuntimeError(f"FFmpeg error: {e.stderr}")
|
| 98 |
+
except FileNotFoundError:
|
| 99 |
+
raise RuntimeError("FFmpeg not found. Please install FFmpeg.")
|
| 100 |
+
|
| 101 |
+
def transcribe(
|
| 102 |
+
self,
|
| 103 |
+
audio_path: str,
|
| 104 |
+
language: str = "ar",
|
| 105 |
+
task: str = "transcribe"
|
| 106 |
+
) -> Dict:
|
| 107 |
+
"""
|
| 108 |
+
Transcribe audio using Whisper
|
| 109 |
+
|
| 110 |
+
Args:
|
| 111 |
+
audio_path: Path to audio file
|
| 112 |
+
language: Language code ("ar" for Arabic)
|
| 113 |
+
task: "transcribe" for same language, "translate" for English
|
| 114 |
+
|
| 115 |
+
Returns:
|
| 116 |
+
Transcription result with segments and timestamps
|
| 117 |
+
"""
|
| 118 |
+
model = self.load_model()
|
| 119 |
+
|
| 120 |
+
print(f"Transcribing audio: {audio_path}")
|
| 121 |
+
print("This may take a few minutes depending on the video length...")
|
| 122 |
+
|
| 123 |
+
result = model.transcribe(
|
| 124 |
+
audio_path,
|
| 125 |
+
language=language,
|
| 126 |
+
task=task,
|
| 127 |
+
word_timestamps=True, # Get word-level timestamps
|
| 128 |
+
verbose=False,
|
| 129 |
+
initial_prompt="بسم الله الرحمن الرحيم", # Help with Quran context
|
| 130 |
+
)
|
| 131 |
+
|
| 132 |
+
return result
|
| 133 |
+
|
| 134 |
+
def transcribe_video(
|
| 135 |
+
self,
|
| 136 |
+
video_path: str,
|
| 137 |
+
language: str = "ar"
|
| 138 |
+
) -> Dict:
|
| 139 |
+
"""
|
| 140 |
+
Full pipeline: extract audio and transcribe
|
| 141 |
+
|
| 142 |
+
Args:
|
| 143 |
+
video_path: Path to video file
|
| 144 |
+
language: Language code
|
| 145 |
+
|
| 146 |
+
Returns:
|
| 147 |
+
Transcription result with segments
|
| 148 |
+
"""
|
| 149 |
+
# Extract audio
|
| 150 |
+
audio_path = self.extract_audio(video_path)
|
| 151 |
+
|
| 152 |
+
# Transcribe
|
| 153 |
+
result = self.transcribe(audio_path, language=language)
|
| 154 |
+
|
| 155 |
+
# Clean up temp audio file
|
| 156 |
+
try:
|
| 157 |
+
os.remove(audio_path)
|
| 158 |
+
except:
|
| 159 |
+
pass
|
| 160 |
+
|
| 161 |
+
return result
|
| 162 |
+
|
| 163 |
+
def get_segments_with_timing(self, transcription: Dict) -> List[Dict]:
|
| 164 |
+
"""
|
| 165 |
+
Extract segments with precise timing from transcription
|
| 166 |
+
|
| 167 |
+
Args:
|
| 168 |
+
transcription: Whisper transcription result
|
| 169 |
+
|
| 170 |
+
Returns:
|
| 171 |
+
List of segments with start, end, and text
|
| 172 |
+
"""
|
| 173 |
+
segments = []
|
| 174 |
+
|
| 175 |
+
for segment in transcription.get("segments", []):
|
| 176 |
+
segments.append({
|
| 177 |
+
"id": segment.get("id", len(segments)),
|
| 178 |
+
"start": segment.get("start", 0),
|
| 179 |
+
"end": segment.get("end", 0),
|
| 180 |
+
"text": segment.get("text", "").strip(),
|
| 181 |
+
"words": segment.get("words", []),
|
| 182 |
+
"confidence": segment.get("avg_logprob", 0)
|
| 183 |
+
})
|
| 184 |
+
|
| 185 |
+
return segments
|
| 186 |
+
|
| 187 |
+
|
| 188 |
+
class MockAudioProcessor:
|
| 189 |
+
"""
|
| 190 |
+
Mock processor for testing without Whisper installed
|
| 191 |
+
"""
|
| 192 |
+
|
| 193 |
+
def __init__(self, model_size: str = "medium"):
|
| 194 |
+
self.model_size = model_size
|
| 195 |
+
|
| 196 |
+
def transcribe_video(self, video_path: str, language: str = "ar") -> Dict:
|
| 197 |
+
"""Return mock transcription for testing"""
|
| 198 |
+
return {
|
| 199 |
+
"text": "بسم الله الرحمن الرحيم الحمد لله رب العالمين",
|
| 200 |
+
"segments": [
|
| 201 |
+
{
|
| 202 |
+
"id": 0,
|
| 203 |
+
"start": 0.0,
|
| 204 |
+
"end": 3.5,
|
| 205 |
+
"text": "بسم الله الرحمن الرحيم",
|
| 206 |
+
"words": []
|
| 207 |
+
},
|
| 208 |
+
{
|
| 209 |
+
"id": 1,
|
| 210 |
+
"start": 3.5,
|
| 211 |
+
"end": 6.0,
|
| 212 |
+
"text": "الحمد لله رب العالمين",
|
| 213 |
+
"words": []
|
| 214 |
+
}
|
| 215 |
+
],
|
| 216 |
+
"language": "ar"
|
| 217 |
+
}
|
| 218 |
+
|
| 219 |
+
def get_segments_with_timing(self, transcription: Dict) -> List[Dict]:
|
| 220 |
+
return transcription.get("segments", [])
|
| 221 |
+
|
| 222 |
+
|
| 223 |
+
def get_processor(model_size: str = "medium") -> AudioProcessor:
|
| 224 |
+
"""
|
| 225 |
+
Get appropriate processor based on Whisper availability
|
| 226 |
+
"""
|
| 227 |
+
if WHISPER_AVAILABLE:
|
| 228 |
+
return AudioProcessor(model_size)
|
| 229 |
+
else:
|
| 230 |
+
print("Using mock processor (Whisper not installed)")
|
| 231 |
+
return MockAudioProcessor(model_size)
|
| 232 |
+
|
| 233 |
+
|
| 234 |
+
# For testing
|
| 235 |
+
if __name__ == "__main__":
|
| 236 |
+
processor = get_processor()
|
| 237 |
+
print(f"Whisper available: {WHISPER_AVAILABLE}")
|
| 238 |
+
print(f"Processor type: {type(processor).__name__}")
|
| 239 |
+
|
| 240 |
+
# Test with mock data
|
| 241 |
+
if not WHISPER_AVAILABLE:
|
| 242 |
+
result = processor.transcribe_video("test.mp4")
|
| 243 |
+
segments = processor.get_segments_with_timing(result)
|
| 244 |
+
|
| 245 |
+
print("\nMock transcription result:")
|
| 246 |
+
for seg in segments:
|
| 247 |
+
print(f"[{seg['start']:.2f} - {seg['end']:.2f}] {seg['text']}")
|
src/quran_data.py
ADDED
|
@@ -0,0 +1,327 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Quran Data Handler
|
| 3 |
+
Fetches and caches Quran verses with translations from free APIs
|
| 4 |
+
Primary source: api.alquran.cloud (reliable, no rate limits)
|
| 5 |
+
"""
|
| 6 |
+
|
| 7 |
+
import os
|
| 8 |
+
import json
|
| 9 |
+
import httpx
|
| 10 |
+
import asyncio
|
| 11 |
+
from pathlib import Path
|
| 12 |
+
from typing import Optional
|
| 13 |
+
|
| 14 |
+
DATA_DIR = Path(__file__).parent.parent / "data"
|
| 15 |
+
DATA_DIR.mkdir(exist_ok=True)
|
| 16 |
+
|
| 17 |
+
# API Base URL
|
| 18 |
+
API_BASE = "https://api.alquran.cloud/v1"
|
| 19 |
+
|
| 20 |
+
# Global offline mode setting
|
| 21 |
+
OFFLINE_MODE = False
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
def set_offline_mode(enabled: bool):
|
| 25 |
+
"""Enable or disable offline mode"""
|
| 26 |
+
global OFFLINE_MODE
|
| 27 |
+
OFFLINE_MODE = enabled
|
| 28 |
+
|
| 29 |
+
|
| 30 |
+
def is_offline_mode() -> bool:
|
| 31 |
+
"""Check if offline mode is enabled"""
|
| 32 |
+
return OFFLINE_MODE
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
def check_cached_data() -> dict:
|
| 36 |
+
"""
|
| 37 |
+
Check what data is available offline
|
| 38 |
+
Returns dict with cache status
|
| 39 |
+
"""
|
| 40 |
+
cache_info = {
|
| 41 |
+
"surah_list": (DATA_DIR / "surah_list.json").exists(),
|
| 42 |
+
"surahs_cached": [],
|
| 43 |
+
"languages_cached": {}
|
| 44 |
+
}
|
| 45 |
+
|
| 46 |
+
# Check which surahs are cached for each language
|
| 47 |
+
for lang in EDITIONS.keys():
|
| 48 |
+
cache_info["languages_cached"][lang] = []
|
| 49 |
+
for surah_num in range(1, 115):
|
| 50 |
+
cache_file = DATA_DIR / f"surah_{surah_num}_{lang}.json"
|
| 51 |
+
if cache_file.exists():
|
| 52 |
+
cache_info["languages_cached"][lang].append(surah_num)
|
| 53 |
+
if surah_num not in cache_info["surahs_cached"]:
|
| 54 |
+
cache_info["surahs_cached"].append(surah_num)
|
| 55 |
+
|
| 56 |
+
cache_info["total_cached"] = len(cache_info["surahs_cached"])
|
| 57 |
+
cache_info["is_complete"] = cache_info["total_cached"] == 114
|
| 58 |
+
|
| 59 |
+
return cache_info
|
| 60 |
+
|
| 61 |
+
|
| 62 |
+
def is_surah_cached(surah: int, languages: list = None) -> bool:
|
| 63 |
+
"""Check if a surah is cached for the specified languages"""
|
| 64 |
+
if languages is None:
|
| 65 |
+
languages = ["arabic", "english", "turkish"]
|
| 66 |
+
|
| 67 |
+
for lang in languages:
|
| 68 |
+
cache_file = DATA_DIR / f"surah_{surah}_{lang}.json"
|
| 69 |
+
if not cache_file.exists():
|
| 70 |
+
return False
|
| 71 |
+
return True
|
| 72 |
+
|
| 73 |
+
# Translation editions (verified high-quality)
|
| 74 |
+
EDITIONS = {
|
| 75 |
+
"arabic": "quran-uthmani", # Original Arabic (Uthmani script)
|
| 76 |
+
"english": "en.sahih", # Saheeh International
|
| 77 |
+
"turkish": "tr.diyanet", # Diyanet Isleri (official Turkish)
|
| 78 |
+
"urdu": "ur.jalandhry", # Fateh Muhammad Jalandhry
|
| 79 |
+
"french": "fr.hamidullah", # Muhammad Hamidullah
|
| 80 |
+
"german": "de.aburida", # Abu Rida Muhammad
|
| 81 |
+
"indonesian": "id.indonesian", # Indonesian Ministry of Religious Affairs
|
| 82 |
+
"spanish": "es.cortes", # Julio Cortes
|
| 83 |
+
"russian": "ru.kuliev", # Elmir Kuliev
|
| 84 |
+
"bengali": "bn.bengali", # Muhiuddin Khan
|
| 85 |
+
"chinese": "zh.majian", # Ma Jian
|
| 86 |
+
"dutch": "nl.siregar", # Sofian Siregar
|
| 87 |
+
"italian": "it.piccardo", # Hamza Piccardo
|
| 88 |
+
"japanese": "ja.japanese", # Japanese
|
| 89 |
+
"korean": "ko.korean", # Korean
|
| 90 |
+
"malay": "ms.basmeih", # Abdullah Basmeih
|
| 91 |
+
"persian": "fa.ansarian", # Hussain Ansarian
|
| 92 |
+
"portuguese": "pt.elhayek", # Samir El-Hayek
|
| 93 |
+
"swedish": "sv.bernstrom", # Knut Bernstrom
|
| 94 |
+
"thai": "th.thai", # Thai
|
| 95 |
+
}
|
| 96 |
+
|
| 97 |
+
# Surah metadata cache
|
| 98 |
+
SURAH_INFO = None
|
| 99 |
+
|
| 100 |
+
|
| 101 |
+
async def fetch_surah_list() -> list:
|
| 102 |
+
"""Fetch list of all surahs with metadata"""
|
| 103 |
+
global SURAH_INFO
|
| 104 |
+
|
| 105 |
+
cache_file = DATA_DIR / "surah_list.json"
|
| 106 |
+
|
| 107 |
+
if cache_file.exists():
|
| 108 |
+
with open(cache_file, "r", encoding="utf-8") as f:
|
| 109 |
+
SURAH_INFO = json.load(f)
|
| 110 |
+
return SURAH_INFO
|
| 111 |
+
|
| 112 |
+
async with httpx.AsyncClient(timeout=30.0) as client:
|
| 113 |
+
try:
|
| 114 |
+
response = await client.get(f"{API_BASE}/surah")
|
| 115 |
+
data = response.json()
|
| 116 |
+
|
| 117 |
+
if data.get("status") == "OK":
|
| 118 |
+
SURAH_INFO = data["data"]
|
| 119 |
+
with open(cache_file, "w", encoding="utf-8") as f:
|
| 120 |
+
json.dump(SURAH_INFO, f, ensure_ascii=False, indent=2)
|
| 121 |
+
return SURAH_INFO
|
| 122 |
+
except Exception as e:
|
| 123 |
+
print(f"Error fetching surah list: {e}")
|
| 124 |
+
|
| 125 |
+
return []
|
| 126 |
+
|
| 127 |
+
|
| 128 |
+
async def fetch_surah_translation(surah: int, language: str = "arabic") -> Optional[list]:
|
| 129 |
+
"""
|
| 130 |
+
Fetch entire surah with translation
|
| 131 |
+
Returns list of verses with text
|
| 132 |
+
"""
|
| 133 |
+
edition = EDITIONS.get(language, "quran-uthmani")
|
| 134 |
+
cache_file = DATA_DIR / f"surah_{surah}_{language}.json"
|
| 135 |
+
|
| 136 |
+
# Check cache first
|
| 137 |
+
if cache_file.exists():
|
| 138 |
+
with open(cache_file, "r", encoding="utf-8") as f:
|
| 139 |
+
return json.load(f)
|
| 140 |
+
|
| 141 |
+
# If offline mode is enabled and no cache, return None
|
| 142 |
+
if OFFLINE_MODE:
|
| 143 |
+
print(f"Offline mode: Surah {surah} in {language} not cached")
|
| 144 |
+
return None
|
| 145 |
+
|
| 146 |
+
async with httpx.AsyncClient(timeout=30.0) as client:
|
| 147 |
+
try:
|
| 148 |
+
url = f"{API_BASE}/surah/{surah}/{edition}"
|
| 149 |
+
response = await client.get(url)
|
| 150 |
+
data = response.json()
|
| 151 |
+
|
| 152 |
+
if data.get("status") == "OK":
|
| 153 |
+
ayahs = data["data"]["ayahs"]
|
| 154 |
+
verses = []
|
| 155 |
+
for ayah in ayahs:
|
| 156 |
+
verses.append({
|
| 157 |
+
"ayah": ayah["numberInSurah"],
|
| 158 |
+
"text": ayah["text"]
|
| 159 |
+
})
|
| 160 |
+
|
| 161 |
+
# Cache the result
|
| 162 |
+
with open(cache_file, "w", encoding="utf-8") as f:
|
| 163 |
+
json.dump(verses, f, ensure_ascii=False, indent=2)
|
| 164 |
+
|
| 165 |
+
return verses
|
| 166 |
+
except Exception as e:
|
| 167 |
+
print(f"Error fetching surah {surah} in {language}: {e}")
|
| 168 |
+
|
| 169 |
+
return None
|
| 170 |
+
|
| 171 |
+
|
| 172 |
+
async def fetch_all_translations(surah: int, languages: list = None) -> dict:
|
| 173 |
+
"""
|
| 174 |
+
Fetch surah in specified languages (default: Arabic, English, Turkish)
|
| 175 |
+
Returns dict with verses indexed by ayah number
|
| 176 |
+
"""
|
| 177 |
+
if languages is None:
|
| 178 |
+
languages = ["arabic", "english", "turkish"]
|
| 179 |
+
|
| 180 |
+
results = {}
|
| 181 |
+
|
| 182 |
+
# Fetch all requested languages in parallel
|
| 183 |
+
tasks = {lang: fetch_surah_translation(surah, lang) for lang in languages}
|
| 184 |
+
translations = await asyncio.gather(*tasks.values())
|
| 185 |
+
|
| 186 |
+
# Map results back to language keys
|
| 187 |
+
for lang, translation in zip(tasks.keys(), translations):
|
| 188 |
+
if translation:
|
| 189 |
+
for verse in translation:
|
| 190 |
+
ayah = verse.get("ayah")
|
| 191 |
+
if ayah:
|
| 192 |
+
if ayah not in results:
|
| 193 |
+
results[ayah] = {"ayah": ayah, "surah": surah}
|
| 194 |
+
results[ayah][lang] = verse.get("text", "")
|
| 195 |
+
|
| 196 |
+
return results
|
| 197 |
+
|
| 198 |
+
|
| 199 |
+
async def download_full_quran():
|
| 200 |
+
"""
|
| 201 |
+
Download entire Quran in all languages for offline use
|
| 202 |
+
This is optional but recommended for faster processing
|
| 203 |
+
"""
|
| 204 |
+
print("Downloading Quran data for offline use...")
|
| 205 |
+
|
| 206 |
+
surah_list = await fetch_surah_list()
|
| 207 |
+
total = len(surah_list)
|
| 208 |
+
|
| 209 |
+
for i, surah in enumerate(surah_list, 1):
|
| 210 |
+
surah_num = surah["number"]
|
| 211 |
+
print(f"Downloading Surah {surah_num}/{total}: {surah['englishName']}...")
|
| 212 |
+
|
| 213 |
+
await fetch_all_translations(surah_num)
|
| 214 |
+
|
| 215 |
+
# Small delay to be respectful to the API
|
| 216 |
+
await asyncio.sleep(0.2)
|
| 217 |
+
|
| 218 |
+
print("Download complete! Quran data cached locally.")
|
| 219 |
+
|
| 220 |
+
|
| 221 |
+
def get_cached_surah(surah: int) -> Optional[dict]:
|
| 222 |
+
"""
|
| 223 |
+
Get cached surah data if available
|
| 224 |
+
Returns None if not cached
|
| 225 |
+
"""
|
| 226 |
+
result = {}
|
| 227 |
+
|
| 228 |
+
for lang in ["arabic", "english", "turkish"]:
|
| 229 |
+
cache_file = DATA_DIR / f"surah_{surah}_{lang}.json"
|
| 230 |
+
if cache_file.exists():
|
| 231 |
+
with open(cache_file, "r", encoding="utf-8") as f:
|
| 232 |
+
verses = json.load(f)
|
| 233 |
+
for verse in verses:
|
| 234 |
+
ayah = verse.get("ayah")
|
| 235 |
+
if ayah:
|
| 236 |
+
if ayah not in result:
|
| 237 |
+
result[ayah] = {"ayah": ayah, "surah": surah}
|
| 238 |
+
result[ayah][lang] = verse.get("text", "")
|
| 239 |
+
|
| 240 |
+
return result if result else None
|
| 241 |
+
|
| 242 |
+
|
| 243 |
+
def search_verse_by_text(arabic_text: str, surah: int) -> Optional[dict]:
|
| 244 |
+
"""
|
| 245 |
+
Search for a verse by its Arabic text within a surah
|
| 246 |
+
Uses fuzzy matching for Whisper transcription errors
|
| 247 |
+
"""
|
| 248 |
+
from difflib import SequenceMatcher
|
| 249 |
+
|
| 250 |
+
cached = get_cached_surah(surah)
|
| 251 |
+
if not cached:
|
| 252 |
+
return None
|
| 253 |
+
|
| 254 |
+
best_match = None
|
| 255 |
+
best_ratio = 0
|
| 256 |
+
|
| 257 |
+
# Clean the input text
|
| 258 |
+
clean_input = normalize_arabic(arabic_text)
|
| 259 |
+
|
| 260 |
+
for ayah, verse in cached.items():
|
| 261 |
+
if "arabic" in verse:
|
| 262 |
+
clean_verse = normalize_arabic(verse["arabic"])
|
| 263 |
+
|
| 264 |
+
# Calculate similarity
|
| 265 |
+
ratio = SequenceMatcher(None, clean_input, clean_verse).ratio()
|
| 266 |
+
|
| 267 |
+
if ratio > best_ratio and ratio > 0.5: # Minimum 50% match
|
| 268 |
+
best_ratio = ratio
|
| 269 |
+
best_match = verse
|
| 270 |
+
best_match["match_confidence"] = ratio
|
| 271 |
+
|
| 272 |
+
return best_match
|
| 273 |
+
|
| 274 |
+
|
| 275 |
+
def normalize_arabic(text: str) -> str:
|
| 276 |
+
"""
|
| 277 |
+
Normalize Arabic text for comparison
|
| 278 |
+
Removes diacritics and normalizes characters
|
| 279 |
+
"""
|
| 280 |
+
# Remove diacritics (tashkeel)
|
| 281 |
+
diacritics = [
|
| 282 |
+
'\u064B', '\u064C', '\u064D', '\u064E', '\u064F',
|
| 283 |
+
'\u0650', '\u0651', '\u0652', '\u0653', '\u0654',
|
| 284 |
+
'\u0655', '\u0656', '\u0657', '\u0658', '\u0659',
|
| 285 |
+
'\u065A', '\u065B', '\u065C', '\u065D', '\u065E',
|
| 286 |
+
'\u065F', '\u0670'
|
| 287 |
+
]
|
| 288 |
+
|
| 289 |
+
for d in diacritics:
|
| 290 |
+
text = text.replace(d, '')
|
| 291 |
+
|
| 292 |
+
# Normalize alef variations
|
| 293 |
+
text = text.replace('أ', 'ا')
|
| 294 |
+
text = text.replace('إ', 'ا')
|
| 295 |
+
text = text.replace('آ', 'ا')
|
| 296 |
+
text = text.replace('ٱ', 'ا')
|
| 297 |
+
|
| 298 |
+
# Normalize other characters
|
| 299 |
+
text = text.replace('ة', 'ه')
|
| 300 |
+
text = text.replace('ى', 'ي')
|
| 301 |
+
|
| 302 |
+
# Remove extra spaces
|
| 303 |
+
text = ' '.join(text.split())
|
| 304 |
+
|
| 305 |
+
return text
|
| 306 |
+
|
| 307 |
+
|
| 308 |
+
# For testing
|
| 309 |
+
if __name__ == "__main__":
|
| 310 |
+
async def test():
|
| 311 |
+
print("Testing Quran Data Handler...")
|
| 312 |
+
|
| 313 |
+
# Test fetching surah list
|
| 314 |
+
surahs = await fetch_surah_list()
|
| 315 |
+
print(f"Found {len(surahs)} surahs")
|
| 316 |
+
|
| 317 |
+
# Test fetching Al-Fatiha in all languages
|
| 318 |
+
print("\nFetching Al-Fatiha (Surah 1)...")
|
| 319 |
+
verses = await fetch_all_translations(1)
|
| 320 |
+
|
| 321 |
+
for ayah, verse in sorted(verses.items()):
|
| 322 |
+
print(f"\nAyah {ayah}:")
|
| 323 |
+
print(f" AR: {verse.get('arabic', 'N/A')}")
|
| 324 |
+
print(f" EN: {verse.get('english', 'N/A')}")
|
| 325 |
+
print(f" TR: {verse.get('turkish', 'N/A')}")
|
| 326 |
+
|
| 327 |
+
asyncio.run(test())
|
src/subtitle_generator.py
ADDED
|
@@ -0,0 +1,196 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Subtitle Generator
|
| 3 |
+
Generates SRT and JSON files from matched verses
|
| 4 |
+
Supports multiple languages on the same line with separators
|
| 5 |
+
"""
|
| 6 |
+
|
| 7 |
+
import json
|
| 8 |
+
from pathlib import Path
|
| 9 |
+
from typing import List, Optional
|
| 10 |
+
from datetime import timedelta
|
| 11 |
+
from dataclasses import asdict
|
| 12 |
+
|
| 13 |
+
from .verse_matcher import MatchedVerse
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
class SubtitleGenerator:
|
| 17 |
+
def __init__(
|
| 18 |
+
self,
|
| 19 |
+
output_dir: str = "output",
|
| 20 |
+
separator: str = " | ",
|
| 21 |
+
languages: List[str] = None
|
| 22 |
+
):
|
| 23 |
+
self.output_dir = Path(output_dir)
|
| 24 |
+
self.output_dir.mkdir(exist_ok=True)
|
| 25 |
+
self.separator = separator
|
| 26 |
+
self.languages = languages or ["arabic", "english", "turkish"]
|
| 27 |
+
|
| 28 |
+
def _format_timestamp(self, seconds: float) -> str:
|
| 29 |
+
td = timedelta(seconds=seconds)
|
| 30 |
+
total_seconds = int(td.total_seconds())
|
| 31 |
+
hours = total_seconds // 3600
|
| 32 |
+
minutes = (total_seconds % 3600) // 60
|
| 33 |
+
secs = total_seconds % 60
|
| 34 |
+
milliseconds = int((seconds - int(seconds)) * 1000)
|
| 35 |
+
return f"{hours:02d}:{minutes:02d}:{secs:02d},{milliseconds:03d}"
|
| 36 |
+
|
| 37 |
+
def _format_verse_text(self, verse: MatchedVerse) -> str:
|
| 38 |
+
parts = []
|
| 39 |
+
|
| 40 |
+
# RTL Unicode markers for Arabic text
|
| 41 |
+
RLE = '\u202B' # Right-to-Left Embedding
|
| 42 |
+
PDF = '\u202C' # Pop Directional Formatting
|
| 43 |
+
|
| 44 |
+
for lang in self.languages:
|
| 45 |
+
if hasattr(verse, 'get_translation'):
|
| 46 |
+
text = verse.get_translation(lang)
|
| 47 |
+
else:
|
| 48 |
+
text = getattr(verse, lang, "")
|
| 49 |
+
if text:
|
| 50 |
+
# Wrap Arabic text with RTL markers for proper rendering
|
| 51 |
+
if lang == "arabic":
|
| 52 |
+
text = f"{RLE}{text}{PDF}"
|
| 53 |
+
parts.append(text)
|
| 54 |
+
|
| 55 |
+
return "\n".join(parts)
|
| 56 |
+
|
| 57 |
+
def generate_srt(
|
| 58 |
+
self,
|
| 59 |
+
verses: List[MatchedVerse],
|
| 60 |
+
filename: str = "quran_subtitles.srt"
|
| 61 |
+
) -> str:
|
| 62 |
+
output_path = self.output_dir / filename
|
| 63 |
+
srt_content = []
|
| 64 |
+
|
| 65 |
+
for i, verse in enumerate(verses, 1):
|
| 66 |
+
srt_content.append(str(i))
|
| 67 |
+
start = self._format_timestamp(verse.start_time)
|
| 68 |
+
end = self._format_timestamp(verse.end_time)
|
| 69 |
+
srt_content.append(f"{start} --> {end}")
|
| 70 |
+
text = self._format_verse_text(verse)
|
| 71 |
+
srt_content.append(text)
|
| 72 |
+
srt_content.append("")
|
| 73 |
+
|
| 74 |
+
with open(output_path, "w", encoding="utf-8") as f:
|
| 75 |
+
f.write("\n".join(srt_content))
|
| 76 |
+
|
| 77 |
+
print(f"SRT file saved: {output_path}")
|
| 78 |
+
return str(output_path)
|
| 79 |
+
|
| 80 |
+
def generate_srt_separate(
|
| 81 |
+
self,
|
| 82 |
+
verses: List[MatchedVerse],
|
| 83 |
+
base_filename: str = "quran_subtitles"
|
| 84 |
+
) -> dict:
|
| 85 |
+
output_paths = {}
|
| 86 |
+
|
| 87 |
+
for lang in self.languages:
|
| 88 |
+
filename = f"{base_filename}_{lang}.srt"
|
| 89 |
+
output_path = self.output_dir / filename
|
| 90 |
+
srt_content = []
|
| 91 |
+
|
| 92 |
+
for i, verse in enumerate(verses, 1):
|
| 93 |
+
srt_content.append(str(i))
|
| 94 |
+
start = self._format_timestamp(verse.start_time)
|
| 95 |
+
end = self._format_timestamp(verse.end_time)
|
| 96 |
+
srt_content.append(f"{start} --> {end}")
|
| 97 |
+
|
| 98 |
+
if hasattr(verse, 'get_translation'):
|
| 99 |
+
text = verse.get_translation(lang)
|
| 100 |
+
else:
|
| 101 |
+
text = getattr(verse, lang, "")
|
| 102 |
+
srt_content.append(text)
|
| 103 |
+
srt_content.append("")
|
| 104 |
+
|
| 105 |
+
with open(output_path, "w", encoding="utf-8") as f:
|
| 106 |
+
f.write("\n".join(srt_content))
|
| 107 |
+
|
| 108 |
+
output_paths[lang] = str(output_path)
|
| 109 |
+
print(f"SRT file saved: {output_path}")
|
| 110 |
+
|
| 111 |
+
return output_paths
|
| 112 |
+
|
| 113 |
+
def generate_json(
|
| 114 |
+
self,
|
| 115 |
+
verses: List[MatchedVerse],
|
| 116 |
+
filename: str = "quran_timing.json",
|
| 117 |
+
include_confidence: bool = True
|
| 118 |
+
) -> str:
|
| 119 |
+
output_path = self.output_dir / filename
|
| 120 |
+
|
| 121 |
+
data = {
|
| 122 |
+
"version": "1.0",
|
| 123 |
+
"generator": "Quran SRT Generator",
|
| 124 |
+
"total_verses": len(verses),
|
| 125 |
+
"languages": self.languages,
|
| 126 |
+
"separator": self.separator,
|
| 127 |
+
"verses": []
|
| 128 |
+
}
|
| 129 |
+
|
| 130 |
+
for verse in verses:
|
| 131 |
+
verse_data = {
|
| 132 |
+
"index": verses.index(verse) + 1,
|
| 133 |
+
"surah": verse.surah,
|
| 134 |
+
"ayah": verse.ayah,
|
| 135 |
+
"timing": {
|
| 136 |
+
"start": verse.start_time,
|
| 137 |
+
"end": verse.end_time,
|
| 138 |
+
"start_formatted": self._format_timestamp(verse.start_time),
|
| 139 |
+
"end_formatted": self._format_timestamp(verse.end_time),
|
| 140 |
+
"duration": round(verse.end_time - verse.start_time, 3)
|
| 141 |
+
},
|
| 142 |
+
"text": {
|
| 143 |
+
"arabic": verse.arabic,
|
| 144 |
+
"english": verse.english,
|
| 145 |
+
"turkish": verse.turkish,
|
| 146 |
+
"combined": self._format_verse_text(verse)
|
| 147 |
+
}
|
| 148 |
+
}
|
| 149 |
+
|
| 150 |
+
if include_confidence:
|
| 151 |
+
verse_data["confidence"] = round(verse.confidence, 4)
|
| 152 |
+
|
| 153 |
+
data["verses"].append(verse_data)
|
| 154 |
+
|
| 155 |
+
if verses:
|
| 156 |
+
data["summary"] = {
|
| 157 |
+
"surah": verses[0].surah,
|
| 158 |
+
"first_ayah": min(v.ayah for v in verses),
|
| 159 |
+
"last_ayah": max(v.ayah for v in verses),
|
| 160 |
+
"total_duration": round(verses[-1].end_time - verses[0].start_time, 3),
|
| 161 |
+
"average_verse_duration": round(
|
| 162 |
+
sum(v.end_time - v.start_time for v in verses) / len(verses), 3
|
| 163 |
+
)
|
| 164 |
+
}
|
| 165 |
+
|
| 166 |
+
with open(output_path, "w", encoding="utf-8") as f:
|
| 167 |
+
json.dump(data, f, ensure_ascii=False, indent=2)
|
| 168 |
+
|
| 169 |
+
print(f"JSON file saved: {output_path}")
|
| 170 |
+
return str(output_path)
|
| 171 |
+
|
| 172 |
+
def generate_all(
|
| 173 |
+
self,
|
| 174 |
+
verses: List[MatchedVerse],
|
| 175 |
+
base_filename: str = "quran_subtitles"
|
| 176 |
+
) -> dict:
|
| 177 |
+
outputs = {}
|
| 178 |
+
outputs["srt_combined"] = self.generate_srt(verses, f"{base_filename}_combined.srt")
|
| 179 |
+
outputs["srt_separate"] = self.generate_srt_separate(verses, base_filename)
|
| 180 |
+
outputs["json"] = self.generate_json(verses, f"{base_filename}_timing.json")
|
| 181 |
+
return outputs
|
| 182 |
+
|
| 183 |
+
|
| 184 |
+
def generate_subtitles(
|
| 185 |
+
verses: List[MatchedVerse],
|
| 186 |
+
output_dir: str = "output",
|
| 187 |
+
separator: str = " | ",
|
| 188 |
+
languages: List[str] = None,
|
| 189 |
+
base_filename: str = "quran_subtitles"
|
| 190 |
+
) -> dict:
|
| 191 |
+
generator = SubtitleGenerator(
|
| 192 |
+
output_dir=output_dir,
|
| 193 |
+
separator=separator,
|
| 194 |
+
languages=languages
|
| 195 |
+
)
|
| 196 |
+
return generator.generate_all(verses, base_filename)
|
src/verse_matcher.py
ADDED
|
@@ -0,0 +1,398 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Verse Matcher
|
| 3 |
+
Matches transcribed Arabic text to Quran verses
|
| 4 |
+
Uses fuzzy matching to handle Whisper transcription variations
|
| 5 |
+
"""
|
| 6 |
+
|
| 7 |
+
import asyncio
|
| 8 |
+
import re
|
| 9 |
+
from difflib import SequenceMatcher
|
| 10 |
+
from typing import List, Dict, Optional, Tuple
|
| 11 |
+
from dataclasses import dataclass
|
| 12 |
+
|
| 13 |
+
from .quran_data import (
|
| 14 |
+
fetch_all_translations,
|
| 15 |
+
normalize_arabic,
|
| 16 |
+
get_cached_surah,
|
| 17 |
+
fetch_surah_list
|
| 18 |
+
)
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
@dataclass
|
| 22 |
+
class MatchedVerse:
|
| 23 |
+
"""Represents a matched Quran verse with timing"""
|
| 24 |
+
surah: int
|
| 25 |
+
ayah: int
|
| 26 |
+
arabic: str
|
| 27 |
+
english: str
|
| 28 |
+
turkish: str
|
| 29 |
+
start_time: float
|
| 30 |
+
end_time: float
|
| 31 |
+
confidence: float
|
| 32 |
+
segment_text: str # Original transcribed text
|
| 33 |
+
# Additional languages stored as dict
|
| 34 |
+
translations: dict = None
|
| 35 |
+
|
| 36 |
+
def __post_init__(self):
|
| 37 |
+
if self.translations is None:
|
| 38 |
+
self.translations = {}
|
| 39 |
+
|
| 40 |
+
def get_translation(self, lang: str) -> str:
|
| 41 |
+
"""Get translation for a language"""
|
| 42 |
+
if lang == "arabic":
|
| 43 |
+
return self.arabic
|
| 44 |
+
elif lang == "english":
|
| 45 |
+
return self.english
|
| 46 |
+
elif lang == "turkish":
|
| 47 |
+
return self.turkish
|
| 48 |
+
return self.translations.get(lang, "")
|
| 49 |
+
|
| 50 |
+
|
| 51 |
+
class VerseMatcher:
|
| 52 |
+
def __init__(self, surah_number: int, start_ayah: int = 1, end_ayah: Optional[int] = None):
|
| 53 |
+
"""
|
| 54 |
+
Initialize verse matcher for a specific surah range
|
| 55 |
+
|
| 56 |
+
Args:
|
| 57 |
+
surah_number: The surah to match against
|
| 58 |
+
start_ayah: Starting ayah number
|
| 59 |
+
end_ayah: Ending ayah number (None for all)
|
| 60 |
+
"""
|
| 61 |
+
self.surah = surah_number
|
| 62 |
+
self.start_ayah = start_ayah
|
| 63 |
+
self.end_ayah = end_ayah
|
| 64 |
+
self.verses = {}
|
| 65 |
+
self.verse_list = [] # Ordered list for sequential matching
|
| 66 |
+
|
| 67 |
+
async def load_verses(self, languages: List[str] = None):
|
| 68 |
+
"""Load verses from Quran API"""
|
| 69 |
+
if languages is None:
|
| 70 |
+
languages = ["arabic", "english", "turkish"]
|
| 71 |
+
|
| 72 |
+
print(f"Loading Surah {self.surah} verses...")
|
| 73 |
+
|
| 74 |
+
self.verses = await fetch_all_translations(self.surah, languages)
|
| 75 |
+
|
| 76 |
+
# Filter by ayah range
|
| 77 |
+
if self.end_ayah:
|
| 78 |
+
self.verses = {
|
| 79 |
+
k: v for k, v in self.verses.items()
|
| 80 |
+
if self.start_ayah <= k <= self.end_ayah
|
| 81 |
+
}
|
| 82 |
+
else:
|
| 83 |
+
self.verses = {
|
| 84 |
+
k: v for k, v in self.verses.items()
|
| 85 |
+
if k >= self.start_ayah
|
| 86 |
+
}
|
| 87 |
+
|
| 88 |
+
# Create ordered list
|
| 89 |
+
self.verse_list = [
|
| 90 |
+
self.verses[k] for k in sorted(self.verses.keys())
|
| 91 |
+
]
|
| 92 |
+
|
| 93 |
+
print(f"Loaded {len(self.verses)} verses")
|
| 94 |
+
return self.verses
|
| 95 |
+
|
| 96 |
+
def _calculate_similarity(self, text1: str, text2: str) -> float:
|
| 97 |
+
"""Calculate similarity ratio between two texts"""
|
| 98 |
+
norm1 = normalize_arabic(text1)
|
| 99 |
+
norm2 = normalize_arabic(text2)
|
| 100 |
+
return SequenceMatcher(None, norm1, norm2).ratio()
|
| 101 |
+
|
| 102 |
+
def _find_best_match(self, segment_text: str, search_start: int = 0) -> Tuple[Optional[Dict], float, int]:
|
| 103 |
+
"""
|
| 104 |
+
Find the best matching verse for a text segment
|
| 105 |
+
|
| 106 |
+
Args:
|
| 107 |
+
segment_text: Transcribed text to match
|
| 108 |
+
search_start: Start searching from this verse index
|
| 109 |
+
|
| 110 |
+
Returns:
|
| 111 |
+
Tuple of (matched_verse, confidence, verse_index)
|
| 112 |
+
"""
|
| 113 |
+
best_match = None
|
| 114 |
+
best_confidence = 0
|
| 115 |
+
best_index = search_start
|
| 116 |
+
|
| 117 |
+
normalized_segment = normalize_arabic(segment_text)
|
| 118 |
+
|
| 119 |
+
# Search through verses starting from search_start
|
| 120 |
+
for i, verse in enumerate(self.verse_list[search_start:], search_start):
|
| 121 |
+
if "arabic" not in verse:
|
| 122 |
+
continue
|
| 123 |
+
|
| 124 |
+
verse_text = verse["arabic"]
|
| 125 |
+
normalized_verse = normalize_arabic(verse_text)
|
| 126 |
+
|
| 127 |
+
# Calculate different types of matches
|
| 128 |
+
|
| 129 |
+
# 1. Full match
|
| 130 |
+
full_ratio = self._calculate_similarity(segment_text, verse_text)
|
| 131 |
+
|
| 132 |
+
# 2. Segment is part of verse
|
| 133 |
+
if normalized_segment in normalized_verse:
|
| 134 |
+
partial_ratio = len(normalized_segment) / len(normalized_verse)
|
| 135 |
+
full_ratio = max(full_ratio, 0.7 + (partial_ratio * 0.3))
|
| 136 |
+
|
| 137 |
+
# 3. Verse is part of segment (for combined segments)
|
| 138 |
+
if normalized_verse in normalized_segment:
|
| 139 |
+
partial_ratio = len(normalized_verse) / len(normalized_segment)
|
| 140 |
+
full_ratio = max(full_ratio, 0.6 + (partial_ratio * 0.4))
|
| 141 |
+
|
| 142 |
+
# 4. Word overlap
|
| 143 |
+
segment_words = set(normalized_segment.split())
|
| 144 |
+
verse_words = set(normalized_verse.split())
|
| 145 |
+
if segment_words and verse_words:
|
| 146 |
+
overlap = len(segment_words & verse_words)
|
| 147 |
+
word_ratio = overlap / max(len(segment_words), len(verse_words))
|
| 148 |
+
full_ratio = max(full_ratio, word_ratio * 0.8)
|
| 149 |
+
|
| 150 |
+
if full_ratio > best_confidence:
|
| 151 |
+
best_confidence = full_ratio
|
| 152 |
+
best_match = verse
|
| 153 |
+
best_index = i
|
| 154 |
+
|
| 155 |
+
return best_match, best_confidence, best_index
|
| 156 |
+
|
| 157 |
+
def match_segments(
|
| 158 |
+
self,
|
| 159 |
+
segments: List[Dict],
|
| 160 |
+
min_confidence: float = 0.3,
|
| 161 |
+
languages: List[str] = None
|
| 162 |
+
) -> List[MatchedVerse]:
|
| 163 |
+
"""
|
| 164 |
+
Match transcribed segments to Quran verses
|
| 165 |
+
|
| 166 |
+
Args:
|
| 167 |
+
segments: List of transcribed segments with timing
|
| 168 |
+
min_confidence: Minimum confidence threshold
|
| 169 |
+
languages: List of languages to include
|
| 170 |
+
|
| 171 |
+
Returns:
|
| 172 |
+
List of matched verses with timing
|
| 173 |
+
"""
|
| 174 |
+
if languages is None:
|
| 175 |
+
languages = ["arabic", "english", "turkish"]
|
| 176 |
+
|
| 177 |
+
matched = []
|
| 178 |
+
current_verse_index = 0
|
| 179 |
+
pending_segments = []
|
| 180 |
+
|
| 181 |
+
for segment in segments:
|
| 182 |
+
text = segment.get("text", "").strip()
|
| 183 |
+
if not text:
|
| 184 |
+
continue
|
| 185 |
+
|
| 186 |
+
start_time = segment.get("start", 0)
|
| 187 |
+
end_time = segment.get("end", 0)
|
| 188 |
+
|
| 189 |
+
# Try to match this segment
|
| 190 |
+
verse, confidence, verse_index = self._find_best_match(
|
| 191 |
+
text,
|
| 192 |
+
search_start=current_verse_index
|
| 193 |
+
)
|
| 194 |
+
|
| 195 |
+
if verse and confidence >= min_confidence:
|
| 196 |
+
# Build extra translations
|
| 197 |
+
extra_translations = {}
|
| 198 |
+
for lang in languages:
|
| 199 |
+
if lang not in ["arabic", "english", "turkish"]:
|
| 200 |
+
extra_translations[lang] = verse.get(lang, "")
|
| 201 |
+
|
| 202 |
+
# Good match found
|
| 203 |
+
matched_verse = MatchedVerse(
|
| 204 |
+
surah=verse.get("surah", self.surah),
|
| 205 |
+
ayah=verse.get("ayah", 0),
|
| 206 |
+
arabic=verse.get("arabic", ""),
|
| 207 |
+
english=verse.get("english", ""),
|
| 208 |
+
turkish=verse.get("turkish", ""),
|
| 209 |
+
start_time=start_time,
|
| 210 |
+
end_time=end_time,
|
| 211 |
+
confidence=confidence,
|
| 212 |
+
segment_text=text,
|
| 213 |
+
translations=extra_translations if extra_translations else None
|
| 214 |
+
)
|
| 215 |
+
matched.append(matched_verse)
|
| 216 |
+
|
| 217 |
+
# Move to next verse for sequential matching
|
| 218 |
+
current_verse_index = verse_index + 1
|
| 219 |
+
|
| 220 |
+
else:
|
| 221 |
+
# Low confidence - might be partial verse
|
| 222 |
+
# Try combining with next segment
|
| 223 |
+
pending_segments.append(segment)
|
| 224 |
+
|
| 225 |
+
# Handle any remaining pending segments
|
| 226 |
+
if pending_segments:
|
| 227 |
+
combined_text = " ".join(s.get("text", "") for s in pending_segments)
|
| 228 |
+
verse, confidence, _ = self._find_best_match(combined_text, current_verse_index)
|
| 229 |
+
|
| 230 |
+
if verse and confidence >= min_confidence:
|
| 231 |
+
# Build extra translations
|
| 232 |
+
extra_translations = {}
|
| 233 |
+
for lang in languages:
|
| 234 |
+
if lang not in ["arabic", "english", "turkish"]:
|
| 235 |
+
extra_translations[lang] = verse.get(lang, "")
|
| 236 |
+
|
| 237 |
+
matched_verse = MatchedVerse(
|
| 238 |
+
surah=verse.get("surah", self.surah),
|
| 239 |
+
ayah=verse.get("ayah", 0),
|
| 240 |
+
arabic=verse.get("arabic", ""),
|
| 241 |
+
english=verse.get("english", ""),
|
| 242 |
+
turkish=verse.get("turkish", ""),
|
| 243 |
+
start_time=pending_segments[0].get("start", 0),
|
| 244 |
+
end_time=pending_segments[-1].get("end", 0),
|
| 245 |
+
confidence=confidence,
|
| 246 |
+
segment_text=combined_text,
|
| 247 |
+
translations=extra_translations if extra_translations else None
|
| 248 |
+
)
|
| 249 |
+
matched.append(matched_verse)
|
| 250 |
+
|
| 251 |
+
return matched
|
| 252 |
+
|
| 253 |
+
def sequential_match(
|
| 254 |
+
self,
|
| 255 |
+
segments: List[Dict],
|
| 256 |
+
languages: List[str] = None,
|
| 257 |
+
bismillah_included: bool = True
|
| 258 |
+
) -> List[MatchedVerse]:
|
| 259 |
+
"""
|
| 260 |
+
Sequential matching - assumes verses are recited in order
|
| 261 |
+
This is more accurate for continuous recitation
|
| 262 |
+
|
| 263 |
+
Args:
|
| 264 |
+
segments: Transcribed segments
|
| 265 |
+
languages: List of languages to include
|
| 266 |
+
bismillah_included: Whether Bismillah is recited (not for Surah 9)
|
| 267 |
+
|
| 268 |
+
Returns:
|
| 269 |
+
List of matched verses
|
| 270 |
+
"""
|
| 271 |
+
if languages is None:
|
| 272 |
+
languages = ["arabic", "english", "turkish"]
|
| 273 |
+
|
| 274 |
+
if not self.verse_list:
|
| 275 |
+
print("No verses loaded. Call load_verses() first.")
|
| 276 |
+
return []
|
| 277 |
+
|
| 278 |
+
matched = []
|
| 279 |
+
segment_index = 0
|
| 280 |
+
verse_index = 0
|
| 281 |
+
|
| 282 |
+
# Combine all segment text for analysis
|
| 283 |
+
all_text = " ".join(s.get("text", "") for s in segments)
|
| 284 |
+
all_text_normalized = normalize_arabic(all_text)
|
| 285 |
+
|
| 286 |
+
# Process each verse in order
|
| 287 |
+
for verse in self.verse_list:
|
| 288 |
+
if "arabic" not in verse:
|
| 289 |
+
continue
|
| 290 |
+
|
| 291 |
+
verse_text = verse["arabic"]
|
| 292 |
+
verse_normalized = normalize_arabic(verse_text)
|
| 293 |
+
|
| 294 |
+
# Find where this verse appears in the transcription
|
| 295 |
+
position = all_text_normalized.find(verse_normalized[:20]) # First 20 chars
|
| 296 |
+
|
| 297 |
+
# Find corresponding segment
|
| 298 |
+
best_segment = None
|
| 299 |
+
best_overlap = 0
|
| 300 |
+
|
| 301 |
+
for i, segment in enumerate(segments[segment_index:], segment_index):
|
| 302 |
+
seg_text = normalize_arabic(segment.get("text", ""))
|
| 303 |
+
|
| 304 |
+
# Check overlap with verse
|
| 305 |
+
overlap = len(set(seg_text.split()) & set(verse_normalized.split()))
|
| 306 |
+
if overlap > best_overlap:
|
| 307 |
+
best_overlap = overlap
|
| 308 |
+
best_segment = segment
|
| 309 |
+
segment_index = i
|
| 310 |
+
|
| 311 |
+
if best_segment:
|
| 312 |
+
# Build extra translations
|
| 313 |
+
extra_translations = {}
|
| 314 |
+
for lang in languages:
|
| 315 |
+
if lang not in ["arabic", "english", "turkish"]:
|
| 316 |
+
extra_translations[lang] = verse.get(lang, "")
|
| 317 |
+
|
| 318 |
+
matched_verse = MatchedVerse(
|
| 319 |
+
surah=verse.get("surah", self.surah),
|
| 320 |
+
ayah=verse.get("ayah", 0),
|
| 321 |
+
arabic=verse_text,
|
| 322 |
+
english=verse.get("english", ""),
|
| 323 |
+
turkish=verse.get("turkish", ""),
|
| 324 |
+
start_time=best_segment.get("start", 0),
|
| 325 |
+
end_time=best_segment.get("end", 0),
|
| 326 |
+
confidence=0.8, # Assumed high for sequential
|
| 327 |
+
segment_text=best_segment.get("text", ""),
|
| 328 |
+
translations=extra_translations if extra_translations else None
|
| 329 |
+
)
|
| 330 |
+
matched.append(matched_verse)
|
| 331 |
+
|
| 332 |
+
return matched
|
| 333 |
+
|
| 334 |
+
|
| 335 |
+
async def match_video_to_verses(
|
| 336 |
+
segments: List[Dict],
|
| 337 |
+
surah: int,
|
| 338 |
+
start_ayah: int = 1,
|
| 339 |
+
end_ayah: Optional[int] = None,
|
| 340 |
+
languages: List[str] = None
|
| 341 |
+
) -> List[MatchedVerse]:
|
| 342 |
+
"""
|
| 343 |
+
Main function to match video segments to Quran verses
|
| 344 |
+
|
| 345 |
+
Args:
|
| 346 |
+
segments: Transcribed segments from Whisper
|
| 347 |
+
surah: Surah number
|
| 348 |
+
start_ayah: Starting ayah
|
| 349 |
+
end_ayah: Ending ayah (None for all)
|
| 350 |
+
languages: List of languages to include in output
|
| 351 |
+
|
| 352 |
+
Returns:
|
| 353 |
+
List of matched verses with timing
|
| 354 |
+
"""
|
| 355 |
+
if languages is None:
|
| 356 |
+
languages = ["arabic", "english", "turkish"]
|
| 357 |
+
|
| 358 |
+
matcher = VerseMatcher(surah, start_ayah, end_ayah)
|
| 359 |
+
await matcher.load_verses(languages)
|
| 360 |
+
|
| 361 |
+
# Try sequential matching first (more accurate for recitation)
|
| 362 |
+
matched = matcher.sequential_match(segments, languages)
|
| 363 |
+
|
| 364 |
+
# Fall back to fuzzy matching if sequential didn't work well
|
| 365 |
+
if len(matched) < len(matcher.verse_list) * 0.5:
|
| 366 |
+
print("Sequential matching incomplete, trying fuzzy matching...")
|
| 367 |
+
matched = matcher.match_segments(segments, languages=languages)
|
| 368 |
+
|
| 369 |
+
return matched
|
| 370 |
+
|
| 371 |
+
|
| 372 |
+
# For testing
|
| 373 |
+
if __name__ == "__main__":
|
| 374 |
+
async def test():
|
| 375 |
+
# Mock segments (simulating Whisper output)
|
| 376 |
+
mock_segments = [
|
| 377 |
+
{"id": 0, "start": 0.0, "end": 3.5, "text": "بسم الله الرحمن الرحيم"},
|
| 378 |
+
{"id": 1, "start": 3.5, "end": 6.5, "text": "الحمد لله رب العالمين"},
|
| 379 |
+
{"id": 2, "start": 6.5, "end": 9.0, "text": "الرحمن الرحيم"},
|
| 380 |
+
{"id": 3, "start": 9.0, "end": 12.0, "text": "مالك يوم الدين"},
|
| 381 |
+
{"id": 4, "start": 12.0, "end": 15.5, "text": "اياك نعبد واياك نستعين"},
|
| 382 |
+
{"id": 5, "start": 15.5, "end": 19.0, "text": "اهدنا الصراط المستقيم"},
|
| 383 |
+
{"id": 6, "start": 19.0, "end": 25.0, "text": "صراط الذين انعمت عليهم غير المغضوب عليهم ولا الضالين"},
|
| 384 |
+
]
|
| 385 |
+
|
| 386 |
+
print("Testing Verse Matcher with Al-Fatiha...")
|
| 387 |
+
matched = await match_video_to_verses(mock_segments, surah=1)
|
| 388 |
+
|
| 389 |
+
print(f"\nMatched {len(matched)} verses:\n")
|
| 390 |
+
for verse in matched:
|
| 391 |
+
print(f"[{verse.start_time:.1f}s - {verse.end_time:.1f}s] Ayah {verse.ayah}")
|
| 392 |
+
print(f" AR: {verse.arabic[:50]}...")
|
| 393 |
+
print(f" EN: {verse.english[:50]}...")
|
| 394 |
+
print(f" TR: {verse.turkish[:50]}...")
|
| 395 |
+
print(f" Confidence: {verse.confidence:.2%}")
|
| 396 |
+
print()
|
| 397 |
+
|
| 398 |
+
asyncio.run(test())
|
src/video_generator.py
ADDED
|
@@ -0,0 +1,263 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Video Generator
|
| 3 |
+
Creates videos with black screen or image background,
|
| 4 |
+
audio from reciters, and burned-in subtitles
|
| 5 |
+
"""
|
| 6 |
+
|
| 7 |
+
import os
|
| 8 |
+
import subprocess
|
| 9 |
+
import asyncio
|
| 10 |
+
import httpx
|
| 11 |
+
from pathlib import Path
|
| 12 |
+
from typing import Optional, List
|
| 13 |
+
import tempfile
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
class VideoGenerator:
|
| 17 |
+
def __init__(self, output_dir: str = "output", temp_dir: str = None):
|
| 18 |
+
self.output_dir = Path(output_dir)
|
| 19 |
+
self.output_dir.mkdir(exist_ok=True)
|
| 20 |
+
self.temp_dir = Path(temp_dir) if temp_dir else Path(tempfile.gettempdir()) / "quran_video"
|
| 21 |
+
self.temp_dir.mkdir(exist_ok=True)
|
| 22 |
+
|
| 23 |
+
async def download_surah_audio(
|
| 24 |
+
self,
|
| 25 |
+
surah: int,
|
| 26 |
+
reciter: str = "ar.alafasy",
|
| 27 |
+
start_ayah: int = 1,
|
| 28 |
+
end_ayah: Optional[int] = None
|
| 29 |
+
) -> tuple:
|
| 30 |
+
print(f"Downloading audio for Surah {surah} by {reciter}...")
|
| 31 |
+
base_url = "https://api.alquran.cloud/v1"
|
| 32 |
+
|
| 33 |
+
async with httpx.AsyncClient(timeout=60.0) as client:
|
| 34 |
+
response = await client.get(f"{base_url}/surah/{surah}")
|
| 35 |
+
data = response.json()
|
| 36 |
+
|
| 37 |
+
if data.get("status") != "OK":
|
| 38 |
+
raise Exception(f"Failed to get surah info: {data}")
|
| 39 |
+
|
| 40 |
+
total_ayahs = data["data"]["numberOfAyahs"]
|
| 41 |
+
if end_ayah is None:
|
| 42 |
+
end_ayah = total_ayahs
|
| 43 |
+
|
| 44 |
+
audio_files = []
|
| 45 |
+
ayah_durations = []
|
| 46 |
+
|
| 47 |
+
for ayah in range(start_ayah, end_ayah + 1):
|
| 48 |
+
print(f" Downloading ayah {ayah}/{end_ayah}...")
|
| 49 |
+
|
| 50 |
+
ayah_response = await client.get(
|
| 51 |
+
f"{base_url}/ayah/{surah}:{ayah}/{reciter}"
|
| 52 |
+
)
|
| 53 |
+
ayah_data = ayah_response.json()
|
| 54 |
+
|
| 55 |
+
if ayah_data.get("status") != "OK":
|
| 56 |
+
print(f" Warning: Could not get ayah {ayah}")
|
| 57 |
+
continue
|
| 58 |
+
|
| 59 |
+
audio_url = ayah_data["data"].get("audio")
|
| 60 |
+
if not audio_url:
|
| 61 |
+
print(f" Warning: No audio URL for ayah {ayah}")
|
| 62 |
+
continue
|
| 63 |
+
|
| 64 |
+
audio_response = await client.get(audio_url)
|
| 65 |
+
audio_path = self.temp_dir / f"ayah_{surah}_{ayah}.mp3"
|
| 66 |
+
|
| 67 |
+
with open(audio_path, "wb") as f:
|
| 68 |
+
f.write(audio_response.content)
|
| 69 |
+
|
| 70 |
+
duration = self.get_audio_duration(str(audio_path))
|
| 71 |
+
ayah_durations.append((ayah, duration))
|
| 72 |
+
audio_files.append(str(audio_path))
|
| 73 |
+
|
| 74 |
+
if not audio_files:
|
| 75 |
+
raise Exception("No audio files downloaded")
|
| 76 |
+
|
| 77 |
+
ayah_timings = []
|
| 78 |
+
current_time = 0.0
|
| 79 |
+
for ayah_num, duration in ayah_durations:
|
| 80 |
+
start_time = current_time
|
| 81 |
+
end_time = current_time + duration
|
| 82 |
+
ayah_timings.append((ayah_num, start_time, end_time))
|
| 83 |
+
current_time = end_time
|
| 84 |
+
|
| 85 |
+
combined_audio = self.temp_dir / f"surah_{surah}_combined.mp3"
|
| 86 |
+
|
| 87 |
+
if len(audio_files) == 1:
|
| 88 |
+
import shutil
|
| 89 |
+
shutil.copy(audio_files[0], combined_audio)
|
| 90 |
+
else:
|
| 91 |
+
concat_list = self.temp_dir / "concat_list.txt"
|
| 92 |
+
with open(concat_list, "w") as f:
|
| 93 |
+
for audio_file in audio_files:
|
| 94 |
+
f.write(f"file '{audio_file}'\n")
|
| 95 |
+
|
| 96 |
+
cmd = [
|
| 97 |
+
"ffmpeg", "-y",
|
| 98 |
+
"-f", "concat",
|
| 99 |
+
"-safe", "0",
|
| 100 |
+
"-i", str(concat_list),
|
| 101 |
+
"-c", "copy",
|
| 102 |
+
str(combined_audio)
|
| 103 |
+
]
|
| 104 |
+
|
| 105 |
+
result = subprocess.run(cmd, capture_output=True, text=True)
|
| 106 |
+
if result.returncode != 0:
|
| 107 |
+
print(f"FFmpeg error: {result.stderr}")
|
| 108 |
+
raise Exception("Failed to combine audio files")
|
| 109 |
+
|
| 110 |
+
print(f"Audio downloaded: {combined_audio}")
|
| 111 |
+
return str(combined_audio), ayah_timings
|
| 112 |
+
|
| 113 |
+
def get_audio_duration(self, audio_path: str) -> float:
|
| 114 |
+
cmd = [
|
| 115 |
+
"ffprobe",
|
| 116 |
+
"-v", "error",
|
| 117 |
+
"-show_entries", "format=duration",
|
| 118 |
+
"-of", "default=noprint_wrappers=1:nokey=1",
|
| 119 |
+
audio_path
|
| 120 |
+
]
|
| 121 |
+
result = subprocess.run(cmd, capture_output=True, text=True)
|
| 122 |
+
if result.returncode == 0:
|
| 123 |
+
return float(result.stdout.strip())
|
| 124 |
+
return 0
|
| 125 |
+
|
| 126 |
+
def create_video_with_subtitles(
|
| 127 |
+
self,
|
| 128 |
+
audio_path: str,
|
| 129 |
+
srt_path: str,
|
| 130 |
+
output_path: str,
|
| 131 |
+
background_image: Optional[str] = None,
|
| 132 |
+
resolution: str = "1920x1080",
|
| 133 |
+
font_size: int = 24,
|
| 134 |
+
font_color: str = "white",
|
| 135 |
+
bg_color: str = "black"
|
| 136 |
+
) -> str:
|
| 137 |
+
print(f"Creating video with subtitles...")
|
| 138 |
+
|
| 139 |
+
width, height = resolution.split("x")
|
| 140 |
+
duration = self.get_audio_duration(audio_path)
|
| 141 |
+
|
| 142 |
+
alignment = 8 # Top center
|
| 143 |
+
|
| 144 |
+
style = (
|
| 145 |
+
f"FontName=Arial,"
|
| 146 |
+
f"FontSize={font_size},"
|
| 147 |
+
f"PrimaryColour=&Hffffff&,"
|
| 148 |
+
f"OutlineColour=&H000000&,"
|
| 149 |
+
f"Outline=2,"
|
| 150 |
+
f"Alignment={alignment},"
|
| 151 |
+
f"MarginV=30,"
|
| 152 |
+
f"MarginL=50,"
|
| 153 |
+
f"MarginR=50,"
|
| 154 |
+
f"BorderStyle=1"
|
| 155 |
+
)
|
| 156 |
+
|
| 157 |
+
if background_image and Path(background_image).exists():
|
| 158 |
+
input_args = [
|
| 159 |
+
"-loop", "1",
|
| 160 |
+
"-i", background_image,
|
| 161 |
+
"-i", audio_path,
|
| 162 |
+
]
|
| 163 |
+
filter_complex = (
|
| 164 |
+
f"[0:v]scale={width}:{height}:force_original_aspect_ratio=decrease,"
|
| 165 |
+
f"pad={width}:{height}:(ow-iw)/2:(oh-ih)/2:color={bg_color},"
|
| 166 |
+
f"subtitles='{srt_path.replace(chr(92), '/').replace(':', chr(92)+':')}':force_style='{style}'[v]"
|
| 167 |
+
)
|
| 168 |
+
else:
|
| 169 |
+
input_args = [
|
| 170 |
+
"-f", "lavfi",
|
| 171 |
+
"-i", f"color=c={bg_color}:s={resolution}:d={duration}",
|
| 172 |
+
"-i", audio_path,
|
| 173 |
+
]
|
| 174 |
+
srt_escaped = srt_path.replace("\\", "/").replace(":", "\\:")
|
| 175 |
+
filter_complex = (
|
| 176 |
+
f"[0:v]subtitles='{srt_escaped}':force_style='{style}'[v]"
|
| 177 |
+
)
|
| 178 |
+
|
| 179 |
+
cmd = [
|
| 180 |
+
"ffmpeg", "-y",
|
| 181 |
+
*input_args,
|
| 182 |
+
"-filter_complex", filter_complex,
|
| 183 |
+
"-map", "[v]",
|
| 184 |
+
"-map", "1:a",
|
| 185 |
+
"-c:v", "libx264",
|
| 186 |
+
"-preset", "medium",
|
| 187 |
+
"-crf", "23",
|
| 188 |
+
"-c:a", "aac",
|
| 189 |
+
"-b:a", "192k",
|
| 190 |
+
"-shortest",
|
| 191 |
+
"-movflags", "+faststart",
|
| 192 |
+
output_path
|
| 193 |
+
]
|
| 194 |
+
|
| 195 |
+
print(f"Running FFmpeg: {' '.join(cmd)}")
|
| 196 |
+
|
| 197 |
+
result = subprocess.run(cmd, capture_output=True, text=True)
|
| 198 |
+
if result.returncode != 0:
|
| 199 |
+
print(f"FFmpeg error: {result.stderr}")
|
| 200 |
+
cmd_simple = [
|
| 201 |
+
"ffmpeg", "-y",
|
| 202 |
+
"-f", "lavfi",
|
| 203 |
+
"-i", f"color=c={bg_color}:s={resolution}:d={duration}",
|
| 204 |
+
"-i", audio_path,
|
| 205 |
+
"-c:v", "libx264",
|
| 206 |
+
"-preset", "medium",
|
| 207 |
+
"-crf", "23",
|
| 208 |
+
"-c:a", "aac",
|
| 209 |
+
"-b:a", "192k",
|
| 210 |
+
"-shortest",
|
| 211 |
+
"-movflags", "+faststart",
|
| 212 |
+
output_path
|
| 213 |
+
]
|
| 214 |
+
result = subprocess.run(cmd_simple, capture_output=True, text=True)
|
| 215 |
+
if result.returncode != 0:
|
| 216 |
+
raise Exception(f"Failed to create video: {result.stderr}")
|
| 217 |
+
print("Warning: Created video without burned-in subtitles")
|
| 218 |
+
|
| 219 |
+
print(f"Video created: {output_path}")
|
| 220 |
+
return output_path
|
| 221 |
+
|
| 222 |
+
def cleanup_temp_files(self, surah: int = None):
|
| 223 |
+
try:
|
| 224 |
+
for f in self.temp_dir.glob("*.mp3"):
|
| 225 |
+
if surah is None or f"surah_{surah}" in f.name or f"ayah_{surah}_" in f.name:
|
| 226 |
+
f.unlink()
|
| 227 |
+
for f in self.temp_dir.glob("*.txt"):
|
| 228 |
+
f.unlink()
|
| 229 |
+
except Exception as e:
|
| 230 |
+
print(f"Cleanup error: {e}")
|
| 231 |
+
|
| 232 |
+
|
| 233 |
+
async def generate_quran_video(
|
| 234 |
+
surah: int,
|
| 235 |
+
reciter: str,
|
| 236 |
+
srt_path: str,
|
| 237 |
+
output_path: str,
|
| 238 |
+
start_ayah: int = 1,
|
| 239 |
+
end_ayah: Optional[int] = None,
|
| 240 |
+
background_image: Optional[str] = None,
|
| 241 |
+
output_dir: str = "output"
|
| 242 |
+
) -> str:
|
| 243 |
+
generator = VideoGenerator(output_dir=output_dir)
|
| 244 |
+
|
| 245 |
+
try:
|
| 246 |
+
audio_path = await generator.download_surah_audio(
|
| 247 |
+
surah=surah,
|
| 248 |
+
reciter=reciter,
|
| 249 |
+
start_ayah=start_ayah,
|
| 250 |
+
end_ayah=end_ayah
|
| 251 |
+
)
|
| 252 |
+
|
| 253 |
+
video_path = generator.create_video_with_subtitles(
|
| 254 |
+
audio_path=audio_path,
|
| 255 |
+
srt_path=srt_path,
|
| 256 |
+
output_path=output_path,
|
| 257 |
+
background_image=background_image
|
| 258 |
+
)
|
| 259 |
+
|
| 260 |
+
return video_path
|
| 261 |
+
|
| 262 |
+
finally:
|
| 263 |
+
generator.cleanup_temp_files(surah)
|
templates/index.html
ADDED
|
@@ -0,0 +1,1976 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<!DOCTYPE html>
|
| 2 |
+
<html lang="en" dir="ltr">
|
| 3 |
+
<head>
|
| 4 |
+
<meta charset="UTF-8">
|
| 5 |
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
| 6 |
+
<title>Quran SRT Generator</title>
|
| 7 |
+
<style>
|
| 8 |
+
* {
|
| 9 |
+
margin: 0;
|
| 10 |
+
padding: 0;
|
| 11 |
+
box-sizing: border-box;
|
| 12 |
+
}
|
| 13 |
+
|
| 14 |
+
:root {
|
| 15 |
+
--primary: #1a5f4a;
|
| 16 |
+
--primary-light: #2d8a6e;
|
| 17 |
+
--secondary: #c9a227;
|
| 18 |
+
--bg-dark: #0f1419;
|
| 19 |
+
--bg-card: #1a1f26;
|
| 20 |
+
--bg-input: #0d1117;
|
| 21 |
+
--text: #e8e8e8;
|
| 22 |
+
--text-muted: #8b949e;
|
| 23 |
+
--border: #30363d;
|
| 24 |
+
--success: #2ea043;
|
| 25 |
+
--error: #f85149;
|
| 26 |
+
--warning: #d29922;
|
| 27 |
+
}
|
| 28 |
+
|
| 29 |
+
body {
|
| 30 |
+
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
|
| 31 |
+
background: var(--bg-dark);
|
| 32 |
+
color: var(--text);
|
| 33 |
+
min-height: 100vh;
|
| 34 |
+
line-height: 1.6;
|
| 35 |
+
}
|
| 36 |
+
|
| 37 |
+
.container {
|
| 38 |
+
max-width: 1200px;
|
| 39 |
+
margin: 0 auto;
|
| 40 |
+
padding: 2rem;
|
| 41 |
+
}
|
| 42 |
+
|
| 43 |
+
header {
|
| 44 |
+
text-align: center;
|
| 45 |
+
margin-bottom: 2rem;
|
| 46 |
+
padding: 2rem;
|
| 47 |
+
background: linear-gradient(135deg, var(--primary) 0%, var(--primary-light) 100%);
|
| 48 |
+
border-radius: 16px;
|
| 49 |
+
}
|
| 50 |
+
|
| 51 |
+
header h1 {
|
| 52 |
+
font-size: 2.5rem;
|
| 53 |
+
margin-bottom: 0.5rem;
|
| 54 |
+
color: white;
|
| 55 |
+
}
|
| 56 |
+
|
| 57 |
+
header p {
|
| 58 |
+
color: rgba(255, 255, 255, 0.85);
|
| 59 |
+
font-size: 1.1rem;
|
| 60 |
+
}
|
| 61 |
+
|
| 62 |
+
.arabic-text {
|
| 63 |
+
font-family: 'Traditional Arabic', 'Scheherazade', serif;
|
| 64 |
+
font-size: 1.8rem;
|
| 65 |
+
margin-bottom: 1rem;
|
| 66 |
+
color: var(--secondary);
|
| 67 |
+
}
|
| 68 |
+
|
| 69 |
+
.card {
|
| 70 |
+
background: var(--bg-card);
|
| 71 |
+
border: 1px solid var(--border);
|
| 72 |
+
border-radius: 12px;
|
| 73 |
+
padding: 1.5rem;
|
| 74 |
+
margin-bottom: 1.5rem;
|
| 75 |
+
}
|
| 76 |
+
|
| 77 |
+
.card h2 {
|
| 78 |
+
color: var(--primary-light);
|
| 79 |
+
margin-bottom: 1rem;
|
| 80 |
+
font-size: 1.3rem;
|
| 81 |
+
}
|
| 82 |
+
|
| 83 |
+
.form-group {
|
| 84 |
+
margin-bottom: 1.25rem;
|
| 85 |
+
}
|
| 86 |
+
|
| 87 |
+
label {
|
| 88 |
+
display: block;
|
| 89 |
+
margin-bottom: 0.5rem;
|
| 90 |
+
color: var(--text);
|
| 91 |
+
font-weight: 500;
|
| 92 |
+
}
|
| 93 |
+
|
| 94 |
+
select, input[type="number"], input[type="text"] {
|
| 95 |
+
width: 100%;
|
| 96 |
+
padding: 0.75rem 1rem;
|
| 97 |
+
background: var(--bg-input);
|
| 98 |
+
border: 1px solid var(--border);
|
| 99 |
+
border-radius: 8px;
|
| 100 |
+
color: var(--text);
|
| 101 |
+
font-size: 1rem;
|
| 102 |
+
transition: border-color 0.2s;
|
| 103 |
+
}
|
| 104 |
+
|
| 105 |
+
select:focus, input:focus {
|
| 106 |
+
outline: none;
|
| 107 |
+
border-color: var(--primary-light);
|
| 108 |
+
}
|
| 109 |
+
|
| 110 |
+
.row {
|
| 111 |
+
display: grid;
|
| 112 |
+
grid-template-columns: 1fr 1fr;
|
| 113 |
+
gap: 1rem;
|
| 114 |
+
}
|
| 115 |
+
|
| 116 |
+
.row-3 {
|
| 117 |
+
grid-template-columns: 1fr 1fr 1fr;
|
| 118 |
+
}
|
| 119 |
+
|
| 120 |
+
.upload-area {
|
| 121 |
+
border: 2px dashed var(--border);
|
| 122 |
+
border-radius: 12px;
|
| 123 |
+
padding: 3rem 2rem;
|
| 124 |
+
text-align: center;
|
| 125 |
+
cursor: pointer;
|
| 126 |
+
transition: all 0.3s;
|
| 127 |
+
background: var(--bg-input);
|
| 128 |
+
}
|
| 129 |
+
|
| 130 |
+
.upload-area:hover, .upload-area.dragover {
|
| 131 |
+
border-color: var(--primary-light);
|
| 132 |
+
background: rgba(45, 138, 110, 0.1);
|
| 133 |
+
}
|
| 134 |
+
|
| 135 |
+
.upload-area.has-file {
|
| 136 |
+
border-color: var(--success);
|
| 137 |
+
border-style: solid;
|
| 138 |
+
}
|
| 139 |
+
|
| 140 |
+
.upload-icon {
|
| 141 |
+
font-size: 3rem;
|
| 142 |
+
margin-bottom: 1rem;
|
| 143 |
+
}
|
| 144 |
+
|
| 145 |
+
.upload-area p {
|
| 146 |
+
color: var(--text-muted);
|
| 147 |
+
}
|
| 148 |
+
|
| 149 |
+
.file-name {
|
| 150 |
+
color: var(--success);
|
| 151 |
+
font-weight: 500;
|
| 152 |
+
margin-top: 0.5rem;
|
| 153 |
+
}
|
| 154 |
+
|
| 155 |
+
input[type="file"] {
|
| 156 |
+
display: none;
|
| 157 |
+
}
|
| 158 |
+
|
| 159 |
+
.btn {
|
| 160 |
+
display: inline-block;
|
| 161 |
+
padding: 0.875rem 2rem;
|
| 162 |
+
background: var(--primary);
|
| 163 |
+
color: white;
|
| 164 |
+
border: none;
|
| 165 |
+
border-radius: 8px;
|
| 166 |
+
font-size: 1rem;
|
| 167 |
+
font-weight: 600;
|
| 168 |
+
cursor: pointer;
|
| 169 |
+
transition: all 0.2s;
|
| 170 |
+
text-decoration: none;
|
| 171 |
+
}
|
| 172 |
+
|
| 173 |
+
.btn:hover {
|
| 174 |
+
background: var(--primary-light);
|
| 175 |
+
transform: translateY(-1px);
|
| 176 |
+
}
|
| 177 |
+
|
| 178 |
+
.btn:disabled {
|
| 179 |
+
background: var(--border);
|
| 180 |
+
cursor: not-allowed;
|
| 181 |
+
transform: none;
|
| 182 |
+
}
|
| 183 |
+
|
| 184 |
+
.btn-block {
|
| 185 |
+
display: block;
|
| 186 |
+
width: 100%;
|
| 187 |
+
text-align: center;
|
| 188 |
+
}
|
| 189 |
+
|
| 190 |
+
.btn-secondary {
|
| 191 |
+
background: transparent;
|
| 192 |
+
border: 2px solid var(--primary);
|
| 193 |
+
color: var(--primary-light);
|
| 194 |
+
}
|
| 195 |
+
|
| 196 |
+
.btn-secondary:hover {
|
| 197 |
+
background: var(--primary);
|
| 198 |
+
color: white;
|
| 199 |
+
}
|
| 200 |
+
|
| 201 |
+
.btn-sm {
|
| 202 |
+
padding: 0.5rem 1rem;
|
| 203 |
+
font-size: 0.875rem;
|
| 204 |
+
}
|
| 205 |
+
|
| 206 |
+
.btn-warning {
|
| 207 |
+
background: var(--warning);
|
| 208 |
+
}
|
| 209 |
+
|
| 210 |
+
.btn-warning:hover {
|
| 211 |
+
background: #e5a922;
|
| 212 |
+
}
|
| 213 |
+
|
| 214 |
+
.progress-container {
|
| 215 |
+
display: none;
|
| 216 |
+
margin-top: 1.5rem;
|
| 217 |
+
}
|
| 218 |
+
|
| 219 |
+
.progress-container.show {
|
| 220 |
+
display: block;
|
| 221 |
+
}
|
| 222 |
+
|
| 223 |
+
.progress-bar {
|
| 224 |
+
height: 8px;
|
| 225 |
+
background: var(--border);
|
| 226 |
+
border-radius: 4px;
|
| 227 |
+
overflow: hidden;
|
| 228 |
+
margin-bottom: 0.75rem;
|
| 229 |
+
}
|
| 230 |
+
|
| 231 |
+
.progress-fill {
|
| 232 |
+
height: 100%;
|
| 233 |
+
background: linear-gradient(90deg, var(--primary), var(--primary-light));
|
| 234 |
+
border-radius: 4px;
|
| 235 |
+
transition: width 0.3s ease;
|
| 236 |
+
width: 0%;
|
| 237 |
+
}
|
| 238 |
+
|
| 239 |
+
.progress-text {
|
| 240 |
+
color: var(--text-muted);
|
| 241 |
+
font-size: 0.9rem;
|
| 242 |
+
}
|
| 243 |
+
|
| 244 |
+
.alert {
|
| 245 |
+
padding: 1rem 1.25rem;
|
| 246 |
+
border-radius: 8px;
|
| 247 |
+
margin-bottom: 1rem;
|
| 248 |
+
}
|
| 249 |
+
|
| 250 |
+
.alert-warning {
|
| 251 |
+
background: rgba(201, 162, 39, 0.15);
|
| 252 |
+
border: 1px solid var(--secondary);
|
| 253 |
+
color: var(--secondary);
|
| 254 |
+
}
|
| 255 |
+
|
| 256 |
+
.alert-error {
|
| 257 |
+
background: rgba(248, 81, 73, 0.15);
|
| 258 |
+
border: 1px solid var(--error);
|
| 259 |
+
color: var(--error);
|
| 260 |
+
}
|
| 261 |
+
|
| 262 |
+
.alert-success {
|
| 263 |
+
background: rgba(46, 160, 67, 0.15);
|
| 264 |
+
border: 1px solid var(--success);
|
| 265 |
+
color: var(--success);
|
| 266 |
+
}
|
| 267 |
+
|
| 268 |
+
/* ================================
|
| 269 |
+
MODE SELECTOR
|
| 270 |
+
================================ */
|
| 271 |
+
|
| 272 |
+
.mode-selector {
|
| 273 |
+
display: grid;
|
| 274 |
+
grid-template-columns: repeat(3, 1fr);
|
| 275 |
+
gap: 1rem;
|
| 276 |
+
margin-bottom: 1rem;
|
| 277 |
+
}
|
| 278 |
+
|
| 279 |
+
.mode-option input {
|
| 280 |
+
display: none;
|
| 281 |
+
}
|
| 282 |
+
|
| 283 |
+
.mode-card {
|
| 284 |
+
display: flex;
|
| 285 |
+
flex-direction: column;
|
| 286 |
+
align-items: center;
|
| 287 |
+
padding: 1.25rem;
|
| 288 |
+
background: var(--bg-input);
|
| 289 |
+
border: 2px solid var(--border);
|
| 290 |
+
border-radius: 12px;
|
| 291 |
+
cursor: pointer;
|
| 292 |
+
transition: all 0.2s;
|
| 293 |
+
}
|
| 294 |
+
|
| 295 |
+
.mode-card:hover {
|
| 296 |
+
border-color: var(--primary);
|
| 297 |
+
}
|
| 298 |
+
|
| 299 |
+
.mode-option input:checked + .mode-card {
|
| 300 |
+
border-color: var(--primary-light);
|
| 301 |
+
background: rgba(45, 138, 110, 0.15);
|
| 302 |
+
}
|
| 303 |
+
|
| 304 |
+
.mode-icon {
|
| 305 |
+
font-size: 2rem;
|
| 306 |
+
margin-bottom: 0.5rem;
|
| 307 |
+
}
|
| 308 |
+
|
| 309 |
+
.mode-label {
|
| 310 |
+
font-weight: 600;
|
| 311 |
+
color: var(--text);
|
| 312 |
+
}
|
| 313 |
+
|
| 314 |
+
.mode-desc {
|
| 315 |
+
font-size: 0.75rem;
|
| 316 |
+
color: var(--text-muted);
|
| 317 |
+
margin-top: 0.25rem;
|
| 318 |
+
}
|
| 319 |
+
|
| 320 |
+
.upload-section {
|
| 321 |
+
margin-top: 1rem;
|
| 322 |
+
}
|
| 323 |
+
|
| 324 |
+
.info-box {
|
| 325 |
+
display: flex;
|
| 326 |
+
align-items: center;
|
| 327 |
+
gap: 1rem;
|
| 328 |
+
padding: 1.5rem;
|
| 329 |
+
background: var(--bg-input);
|
| 330 |
+
border: 1px solid var(--border);
|
| 331 |
+
border-radius: 12px;
|
| 332 |
+
}
|
| 333 |
+
|
| 334 |
+
.info-icon {
|
| 335 |
+
font-size: 2.5rem;
|
| 336 |
+
}
|
| 337 |
+
|
| 338 |
+
.info-box p {
|
| 339 |
+
color: var(--text-muted);
|
| 340 |
+
}
|
| 341 |
+
|
| 342 |
+
/* ================================
|
| 343 |
+
CHECKBOX GROUP
|
| 344 |
+
================================ */
|
| 345 |
+
|
| 346 |
+
.checkbox-group {
|
| 347 |
+
display: flex;
|
| 348 |
+
flex-wrap: wrap;
|
| 349 |
+
gap: 1rem;
|
| 350 |
+
}
|
| 351 |
+
|
| 352 |
+
.checkbox-option {
|
| 353 |
+
display: flex;
|
| 354 |
+
align-items: center;
|
| 355 |
+
gap: 0.5rem;
|
| 356 |
+
padding: 0.75rem 1rem;
|
| 357 |
+
background: var(--bg-input);
|
| 358 |
+
border: 1px solid var(--border);
|
| 359 |
+
border-radius: 8px;
|
| 360 |
+
cursor: pointer;
|
| 361 |
+
transition: all 0.2s;
|
| 362 |
+
}
|
| 363 |
+
|
| 364 |
+
.checkbox-option:hover {
|
| 365 |
+
border-color: var(--primary);
|
| 366 |
+
}
|
| 367 |
+
|
| 368 |
+
.checkbox-option input {
|
| 369 |
+
width: 18px;
|
| 370 |
+
height: 18px;
|
| 371 |
+
accent-color: var(--primary-light);
|
| 372 |
+
}
|
| 373 |
+
|
| 374 |
+
.checkbox-option input:checked + .checkbox-label {
|
| 375 |
+
color: var(--primary-light);
|
| 376 |
+
}
|
| 377 |
+
|
| 378 |
+
.checkbox-label {
|
| 379 |
+
font-weight: 500;
|
| 380 |
+
}
|
| 381 |
+
|
| 382 |
+
@media (max-width: 768px) {
|
| 383 |
+
.mode-selector {
|
| 384 |
+
grid-template-columns: 1fr;
|
| 385 |
+
}
|
| 386 |
+
|
| 387 |
+
.checkbox-group {
|
| 388 |
+
flex-direction: column;
|
| 389 |
+
}
|
| 390 |
+
}
|
| 391 |
+
|
| 392 |
+
/* ================================
|
| 393 |
+
EDITOR SECTION
|
| 394 |
+
================================ */
|
| 395 |
+
|
| 396 |
+
.editor-section {
|
| 397 |
+
display: none;
|
| 398 |
+
}
|
| 399 |
+
|
| 400 |
+
.editor-section.show {
|
| 401 |
+
display: block;
|
| 402 |
+
}
|
| 403 |
+
|
| 404 |
+
.editor-layout {
|
| 405 |
+
display: grid;
|
| 406 |
+
grid-template-columns: 1fr 1fr;
|
| 407 |
+
gap: 1.5rem;
|
| 408 |
+
}
|
| 409 |
+
|
| 410 |
+
@media (max-width: 1024px) {
|
| 411 |
+
.editor-layout {
|
| 412 |
+
grid-template-columns: 1fr;
|
| 413 |
+
}
|
| 414 |
+
}
|
| 415 |
+
|
| 416 |
+
/* Video Preview */
|
| 417 |
+
.video-preview {
|
| 418 |
+
position: sticky;
|
| 419 |
+
top: 1rem;
|
| 420 |
+
}
|
| 421 |
+
|
| 422 |
+
.video-container {
|
| 423 |
+
position: relative;
|
| 424 |
+
background: #000;
|
| 425 |
+
border-radius: 12px;
|
| 426 |
+
overflow: hidden;
|
| 427 |
+
}
|
| 428 |
+
|
| 429 |
+
.video-container video {
|
| 430 |
+
width: 100%;
|
| 431 |
+
display: block;
|
| 432 |
+
}
|
| 433 |
+
|
| 434 |
+
.subtitle-overlay {
|
| 435 |
+
position: absolute;
|
| 436 |
+
bottom: 60px;
|
| 437 |
+
left: 50%;
|
| 438 |
+
transform: translateX(-50%);
|
| 439 |
+
width: 90%;
|
| 440 |
+
text-align: center;
|
| 441 |
+
padding: 0.75rem 1rem;
|
| 442 |
+
background: rgba(0, 0, 0, 0.8);
|
| 443 |
+
border-radius: 8px;
|
| 444 |
+
font-size: 1rem;
|
| 445 |
+
line-height: 1.5;
|
| 446 |
+
pointer-events: none;
|
| 447 |
+
}
|
| 448 |
+
|
| 449 |
+
.subtitle-overlay .arabic {
|
| 450 |
+
font-family: 'Traditional Arabic', 'Scheherazade', serif;
|
| 451 |
+
font-size: 1.4rem;
|
| 452 |
+
color: var(--secondary);
|
| 453 |
+
margin-bottom: 0.25rem;
|
| 454 |
+
}
|
| 455 |
+
|
| 456 |
+
.subtitle-overlay .english {
|
| 457 |
+
color: #fff;
|
| 458 |
+
font-size: 0.95rem;
|
| 459 |
+
}
|
| 460 |
+
|
| 461 |
+
.subtitle-overlay .turkish {
|
| 462 |
+
color: #aaa;
|
| 463 |
+
font-size: 0.9rem;
|
| 464 |
+
}
|
| 465 |
+
|
| 466 |
+
.video-controls {
|
| 467 |
+
display: flex;
|
| 468 |
+
gap: 0.5rem;
|
| 469 |
+
margin-top: 1rem;
|
| 470 |
+
flex-wrap: wrap;
|
| 471 |
+
}
|
| 472 |
+
|
| 473 |
+
.video-controls .btn {
|
| 474 |
+
flex: 1;
|
| 475 |
+
min-width: 80px;
|
| 476 |
+
}
|
| 477 |
+
|
| 478 |
+
.current-time {
|
| 479 |
+
text-align: center;
|
| 480 |
+
padding: 0.5rem;
|
| 481 |
+
background: var(--bg-input);
|
| 482 |
+
border-radius: 8px;
|
| 483 |
+
font-family: monospace;
|
| 484 |
+
font-size: 1.2rem;
|
| 485 |
+
margin-top: 0.5rem;
|
| 486 |
+
}
|
| 487 |
+
|
| 488 |
+
/* Timing Editor */
|
| 489 |
+
.verse-list {
|
| 490 |
+
max-height: 600px;
|
| 491 |
+
overflow-y: auto;
|
| 492 |
+
}
|
| 493 |
+
|
| 494 |
+
.verse-item {
|
| 495 |
+
background: var(--bg-input);
|
| 496 |
+
border: 1px solid var(--border);
|
| 497 |
+
border-radius: 8px;
|
| 498 |
+
padding: 1rem;
|
| 499 |
+
margin-bottom: 0.75rem;
|
| 500 |
+
transition: all 0.2s;
|
| 501 |
+
}
|
| 502 |
+
|
| 503 |
+
.verse-item:hover {
|
| 504 |
+
border-color: var(--primary);
|
| 505 |
+
}
|
| 506 |
+
|
| 507 |
+
.verse-item.active {
|
| 508 |
+
border-color: var(--secondary);
|
| 509 |
+
background: rgba(201, 162, 39, 0.1);
|
| 510 |
+
}
|
| 511 |
+
|
| 512 |
+
.verse-item.editing {
|
| 513 |
+
border-color: var(--primary-light);
|
| 514 |
+
box-shadow: 0 0 0 2px rgba(45, 138, 110, 0.3);
|
| 515 |
+
}
|
| 516 |
+
|
| 517 |
+
.verse-header {
|
| 518 |
+
display: flex;
|
| 519 |
+
justify-content: space-between;
|
| 520 |
+
align-items: center;
|
| 521 |
+
margin-bottom: 0.5rem;
|
| 522 |
+
}
|
| 523 |
+
|
| 524 |
+
.verse-number {
|
| 525 |
+
background: var(--primary);
|
| 526 |
+
color: white;
|
| 527 |
+
padding: 0.25rem 0.75rem;
|
| 528 |
+
border-radius: 20px;
|
| 529 |
+
font-size: 0.875rem;
|
| 530 |
+
font-weight: 600;
|
| 531 |
+
}
|
| 532 |
+
|
| 533 |
+
.verse-confidence {
|
| 534 |
+
font-size: 0.75rem;
|
| 535 |
+
color: var(--text-muted);
|
| 536 |
+
}
|
| 537 |
+
|
| 538 |
+
.verse-text {
|
| 539 |
+
font-family: 'Traditional Arabic', 'Scheherazade', serif;
|
| 540 |
+
font-size: 1.2rem;
|
| 541 |
+
color: var(--secondary);
|
| 542 |
+
margin-bottom: 0.5rem;
|
| 543 |
+
direction: rtl;
|
| 544 |
+
text-align: right;
|
| 545 |
+
}
|
| 546 |
+
|
| 547 |
+
.verse-translation {
|
| 548 |
+
font-size: 0.85rem;
|
| 549 |
+
color: var(--text-muted);
|
| 550 |
+
margin-bottom: 0.75rem;
|
| 551 |
+
}
|
| 552 |
+
|
| 553 |
+
.timing-inputs {
|
| 554 |
+
display: grid;
|
| 555 |
+
grid-template-columns: 1fr 1fr auto;
|
| 556 |
+
gap: 0.5rem;
|
| 557 |
+
align-items: end;
|
| 558 |
+
}
|
| 559 |
+
|
| 560 |
+
.timing-inputs label {
|
| 561 |
+
font-size: 0.75rem;
|
| 562 |
+
color: var(--text-muted);
|
| 563 |
+
margin-bottom: 0.25rem;
|
| 564 |
+
}
|
| 565 |
+
|
| 566 |
+
.timing-inputs input {
|
| 567 |
+
padding: 0.5rem;
|
| 568 |
+
font-family: monospace;
|
| 569 |
+
font-size: 0.9rem;
|
| 570 |
+
}
|
| 571 |
+
|
| 572 |
+
.timing-inputs .btn {
|
| 573 |
+
padding: 0.5rem 0.75rem;
|
| 574 |
+
}
|
| 575 |
+
|
| 576 |
+
/* Download Section */
|
| 577 |
+
.download-section {
|
| 578 |
+
margin-top: 1.5rem;
|
| 579 |
+
}
|
| 580 |
+
|
| 581 |
+
.download-grid {
|
| 582 |
+
display: grid;
|
| 583 |
+
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
|
| 584 |
+
gap: 1rem;
|
| 585 |
+
margin-top: 1rem;
|
| 586 |
+
}
|
| 587 |
+
|
| 588 |
+
.download-btn {
|
| 589 |
+
display: flex;
|
| 590 |
+
align-items: center;
|
| 591 |
+
justify-content: center;
|
| 592 |
+
gap: 0.5rem;
|
| 593 |
+
padding: 1rem;
|
| 594 |
+
background: var(--bg-input);
|
| 595 |
+
border: 1px solid var(--border);
|
| 596 |
+
border-radius: 8px;
|
| 597 |
+
color: var(--text);
|
| 598 |
+
text-decoration: none;
|
| 599 |
+
transition: all 0.2s;
|
| 600 |
+
}
|
| 601 |
+
|
| 602 |
+
.download-btn:hover {
|
| 603 |
+
border-color: var(--primary-light);
|
| 604 |
+
background: rgba(45, 138, 110, 0.1);
|
| 605 |
+
}
|
| 606 |
+
|
| 607 |
+
.download-btn .icon {
|
| 608 |
+
font-size: 1.5rem;
|
| 609 |
+
}
|
| 610 |
+
|
| 611 |
+
/* Features */
|
| 612 |
+
.features {
|
| 613 |
+
display: grid;
|
| 614 |
+
grid-template-columns: repeat(3, 1fr);
|
| 615 |
+
gap: 1rem;
|
| 616 |
+
margin-top: 2rem;
|
| 617 |
+
}
|
| 618 |
+
|
| 619 |
+
.feature {
|
| 620 |
+
text-align: center;
|
| 621 |
+
padding: 1.5rem;
|
| 622 |
+
background: var(--bg-card);
|
| 623 |
+
border-radius: 12px;
|
| 624 |
+
border: 1px solid var(--border);
|
| 625 |
+
}
|
| 626 |
+
|
| 627 |
+
.feature-icon {
|
| 628 |
+
font-size: 2rem;
|
| 629 |
+
margin-bottom: 0.75rem;
|
| 630 |
+
}
|
| 631 |
+
|
| 632 |
+
.feature h3 {
|
| 633 |
+
color: var(--primary-light);
|
| 634 |
+
margin-bottom: 0.5rem;
|
| 635 |
+
}
|
| 636 |
+
|
| 637 |
+
.feature p {
|
| 638 |
+
color: var(--text-muted);
|
| 639 |
+
font-size: 0.9rem;
|
| 640 |
+
}
|
| 641 |
+
|
| 642 |
+
footer {
|
| 643 |
+
text-align: center;
|
| 644 |
+
padding: 2rem;
|
| 645 |
+
color: var(--text-muted);
|
| 646 |
+
font-size: 0.9rem;
|
| 647 |
+
}
|
| 648 |
+
|
| 649 |
+
/* Tabs */
|
| 650 |
+
.tabs {
|
| 651 |
+
display: flex;
|
| 652 |
+
gap: 0.5rem;
|
| 653 |
+
margin-bottom: 1rem;
|
| 654 |
+
border-bottom: 1px solid var(--border);
|
| 655 |
+
padding-bottom: 0.5rem;
|
| 656 |
+
}
|
| 657 |
+
|
| 658 |
+
.tab {
|
| 659 |
+
padding: 0.5rem 1rem;
|
| 660 |
+
background: transparent;
|
| 661 |
+
border: none;
|
| 662 |
+
color: var(--text-muted);
|
| 663 |
+
cursor: pointer;
|
| 664 |
+
border-radius: 8px 8px 0 0;
|
| 665 |
+
transition: all 0.2s;
|
| 666 |
+
}
|
| 667 |
+
|
| 668 |
+
.tab:hover {
|
| 669 |
+
color: var(--text);
|
| 670 |
+
background: var(--bg-input);
|
| 671 |
+
}
|
| 672 |
+
|
| 673 |
+
.tab.active {
|
| 674 |
+
color: var(--primary-light);
|
| 675 |
+
background: var(--bg-input);
|
| 676 |
+
border-bottom: 2px solid var(--primary-light);
|
| 677 |
+
}
|
| 678 |
+
|
| 679 |
+
.hidden {
|
| 680 |
+
display: none !important;
|
| 681 |
+
}
|
| 682 |
+
|
| 683 |
+
@media (max-width: 768px) {
|
| 684 |
+
.container {
|
| 685 |
+
padding: 1rem;
|
| 686 |
+
}
|
| 687 |
+
|
| 688 |
+
header h1 {
|
| 689 |
+
font-size: 1.8rem;
|
| 690 |
+
}
|
| 691 |
+
|
| 692 |
+
.row, .row-3 {
|
| 693 |
+
grid-template-columns: 1fr;
|
| 694 |
+
}
|
| 695 |
+
|
| 696 |
+
.features {
|
| 697 |
+
grid-template-columns: 1fr;
|
| 698 |
+
}
|
| 699 |
+
|
| 700 |
+
.timing-inputs {
|
| 701 |
+
grid-template-columns: 1fr 1fr;
|
| 702 |
+
}
|
| 703 |
+
|
| 704 |
+
.timing-inputs .btn {
|
| 705 |
+
grid-column: span 2;
|
| 706 |
+
}
|
| 707 |
+
}
|
| 708 |
+
</style>
|
| 709 |
+
</head>
|
| 710 |
+
<body>
|
| 711 |
+
<div class="container">
|
| 712 |
+
<header>
|
| 713 |
+
<div class="arabic-text">بِسْمِ اللَّهِ الرَّحْمَٰنِ الرَّحِيمِ</div>
|
| 714 |
+
<h1>Quran SRT Generator</h1>
|
| 715 |
+
<p>Generate multilingual subtitles for Quran recitation videos</p>
|
| 716 |
+
</header>
|
| 717 |
+
|
| 718 |
+
{% if not whisper_available %}
|
| 719 |
+
<div class="alert alert-warning">
|
| 720 |
+
<strong>Note:</strong> Whisper is not installed. The app will work in demo mode.
|
| 721 |
+
Install with: <code>pip install openai-whisper</code>
|
| 722 |
+
</div>
|
| 723 |
+
{% endif %}
|
| 724 |
+
|
| 725 |
+
<!-- UPLOAD SECTION -->
|
| 726 |
+
<div id="uploadSection">
|
| 727 |
+
<form id="uploadForm">
|
| 728 |
+
<div class="card">
|
| 729 |
+
<h2>1. Select Surah & Range</h2>
|
| 730 |
+
|
| 731 |
+
<div class="form-group">
|
| 732 |
+
<label for="surah">Surah</label>
|
| 733 |
+
<select id="surah" name="surah" required>
|
| 734 |
+
<option value="">Loading surahs...</option>
|
| 735 |
+
</select>
|
| 736 |
+
</div>
|
| 737 |
+
|
| 738 |
+
<div class="row">
|
| 739 |
+
<div class="form-group">
|
| 740 |
+
<label for="startAyah">Start Ayah</label>
|
| 741 |
+
<input type="number" id="startAyah" name="start_ayah" value="1" min="1">
|
| 742 |
+
</div>
|
| 743 |
+
<div class="form-group">
|
| 744 |
+
<label for="endAyah">End Ayah (leave empty for all)</label>
|
| 745 |
+
<input type="number" id="endAyah" name="end_ayah" min="1">
|
| 746 |
+
</div>
|
| 747 |
+
</div>
|
| 748 |
+
</div>
|
| 749 |
+
|
| 750 |
+
<div class="card">
|
| 751 |
+
<h2>2. Input Mode</h2>
|
| 752 |
+
|
| 753 |
+
<div class="mode-selector">
|
| 754 |
+
<label class="mode-option">
|
| 755 |
+
<input type="radio" name="inputMode" value="video" checked>
|
| 756 |
+
<div class="mode-card">
|
| 757 |
+
<span class="mode-icon">🎬</span>
|
| 758 |
+
<span class="mode-label">Video/Audio</span>
|
| 759 |
+
<span class="mode-desc">Upload existing recitation</span>
|
| 760 |
+
</div>
|
| 761 |
+
</label>
|
| 762 |
+
<label class="mode-option">
|
| 763 |
+
<input type="radio" name="inputMode" value="image">
|
| 764 |
+
<div class="mode-card">
|
| 765 |
+
<span class="mode-icon">🖼️</span>
|
| 766 |
+
<span class="mode-label">Image</span>
|
| 767 |
+
<span class="mode-desc">Upload background image</span>
|
| 768 |
+
</div>
|
| 769 |
+
</label>
|
| 770 |
+
<label class="mode-option">
|
| 771 |
+
<input type="radio" name="inputMode" value="black">
|
| 772 |
+
<div class="mode-card">
|
| 773 |
+
<span class="mode-icon">⬛</span>
|
| 774 |
+
<span class="mode-label">Black Screen</span>
|
| 775 |
+
<span class="mode-desc">Plain black background</span>
|
| 776 |
+
</div>
|
| 777 |
+
</label>
|
| 778 |
+
</div>
|
| 779 |
+
|
| 780 |
+
<!-- Video/Audio Upload -->
|
| 781 |
+
<div id="videoUploadArea" class="upload-section">
|
| 782 |
+
<div class="upload-area" id="uploadArea">
|
| 783 |
+
<div class="upload-icon">📁</div>
|
| 784 |
+
<p>Drag & drop your video/audio file here</p>
|
| 785 |
+
<p>or click to browse</p>
|
| 786 |
+
<p class="file-name" id="fileName"></p>
|
| 787 |
+
<input type="file" id="fileInput" name="file" accept=".mp4,.mkv,.avi,.mov,.webm,.mp3,.wav,.m4a">
|
| 788 |
+
</div>
|
| 789 |
+
</div>
|
| 790 |
+
|
| 791 |
+
<!-- Image Upload -->
|
| 792 |
+
<div id="imageUploadArea" class="upload-section hidden">
|
| 793 |
+
<div class="upload-area" id="imageUpload">
|
| 794 |
+
<div class="upload-icon">🖼️</div>
|
| 795 |
+
<p>Drag & drop background image</p>
|
| 796 |
+
<p>or click to browse</p>
|
| 797 |
+
<p class="file-name" id="imageName"></p>
|
| 798 |
+
<input type="file" id="imageInput" name="image" accept=".jpg,.jpeg,.png,.webp">
|
| 799 |
+
</div>
|
| 800 |
+
</div>
|
| 801 |
+
|
| 802 |
+
<!-- Black Screen Info -->
|
| 803 |
+
<div id="blackScreenInfo" class="upload-section hidden">
|
| 804 |
+
<div class="info-box">
|
| 805 |
+
<span class="info-icon">⬛</span>
|
| 806 |
+
<p>Video will be generated with a black background</p>
|
| 807 |
+
</div>
|
| 808 |
+
</div>
|
| 809 |
+
|
| 810 |
+
<!-- Audio Reciter (for image/black modes) -->
|
| 811 |
+
<div id="reciterSection" class="hidden" style="margin-top: 1rem;">
|
| 812 |
+
<div class="form-group">
|
| 813 |
+
<label for="reciter">Audio Reciter</label>
|
| 814 |
+
<select id="reciter" name="reciter">
|
| 815 |
+
<option value="">Loading reciters...</option>
|
| 816 |
+
</select>
|
| 817 |
+
</div>
|
| 818 |
+
</div>
|
| 819 |
+
</div>
|
| 820 |
+
|
| 821 |
+
<div class="card">
|
| 822 |
+
<h2>3. Languages</h2>
|
| 823 |
+
<p style="color: var(--text-muted); font-size: 0.9rem; margin-bottom: 1rem;">Select which languages to include in subtitles</p>
|
| 824 |
+
|
| 825 |
+
<div class="checkbox-group" style="display: grid; grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); gap: 0.75rem;">
|
| 826 |
+
<label class="checkbox-option">
|
| 827 |
+
<input type="checkbox" name="languages" value="arabic" checked>
|
| 828 |
+
<span class="checkbox-label">Arabic (Original)</span>
|
| 829 |
+
</label>
|
| 830 |
+
<label class="checkbox-option">
|
| 831 |
+
<input type="checkbox" name="languages" value="english" checked>
|
| 832 |
+
<span class="checkbox-label">English (Saheeh)</span>
|
| 833 |
+
</label>
|
| 834 |
+
<label class="checkbox-option">
|
| 835 |
+
<input type="checkbox" name="languages" value="turkish">
|
| 836 |
+
<span class="checkbox-label">Turkish (Diyanet)</span>
|
| 837 |
+
</label>
|
| 838 |
+
<label class="checkbox-option">
|
| 839 |
+
<input type="checkbox" name="languages" value="urdu">
|
| 840 |
+
<span class="checkbox-label">Urdu</span>
|
| 841 |
+
</label>
|
| 842 |
+
<label class="checkbox-option">
|
| 843 |
+
<input type="checkbox" name="languages" value="french">
|
| 844 |
+
<span class="checkbox-label">French</span>
|
| 845 |
+
</label>
|
| 846 |
+
<label class="checkbox-option">
|
| 847 |
+
<input type="checkbox" name="languages" value="german">
|
| 848 |
+
<span class="checkbox-label">German</span>
|
| 849 |
+
</label>
|
| 850 |
+
<label class="checkbox-option">
|
| 851 |
+
<input type="checkbox" name="languages" value="indonesian">
|
| 852 |
+
<span class="checkbox-label">Indonesian</span>
|
| 853 |
+
</label>
|
| 854 |
+
<label class="checkbox-option">
|
| 855 |
+
<input type="checkbox" name="languages" value="spanish">
|
| 856 |
+
<span class="checkbox-label">Spanish</span>
|
| 857 |
+
</label>
|
| 858 |
+
<label class="checkbox-option">
|
| 859 |
+
<input type="checkbox" name="languages" value="russian">
|
| 860 |
+
<span class="checkbox-label">Russian</span>
|
| 861 |
+
</label>
|
| 862 |
+
<label class="checkbox-option">
|
| 863 |
+
<input type="checkbox" name="languages" value="bengali">
|
| 864 |
+
<span class="checkbox-label">Bengali</span>
|
| 865 |
+
</label>
|
| 866 |
+
<label class="checkbox-option">
|
| 867 |
+
<input type="checkbox" name="languages" value="chinese">
|
| 868 |
+
<span class="checkbox-label">Chinese</span>
|
| 869 |
+
</label>
|
| 870 |
+
<label class="checkbox-option">
|
| 871 |
+
<input type="checkbox" name="languages" value="malay">
|
| 872 |
+
<span class="checkbox-label">Malay</span>
|
| 873 |
+
</label>
|
| 874 |
+
<label class="checkbox-option">
|
| 875 |
+
<input type="checkbox" name="languages" value="persian">
|
| 876 |
+
<span class="checkbox-label">Persian/Farsi</span>
|
| 877 |
+
</label>
|
| 878 |
+
<label class="checkbox-option">
|
| 879 |
+
<input type="checkbox" name="languages" value="dutch">
|
| 880 |
+
<span class="checkbox-label">Dutch</span>
|
| 881 |
+
</label>
|
| 882 |
+
<label class="checkbox-option">
|
| 883 |
+
<input type="checkbox" name="languages" value="italian">
|
| 884 |
+
<span class="checkbox-label">Italian</span>
|
| 885 |
+
</label>
|
| 886 |
+
<label class="checkbox-option">
|
| 887 |
+
<input type="checkbox" name="languages" value="portuguese">
|
| 888 |
+
<span class="checkbox-label">Portuguese</span>
|
| 889 |
+
</label>
|
| 890 |
+
</div>
|
| 891 |
+
</div>
|
| 892 |
+
|
| 893 |
+
<div class="card">
|
| 894 |
+
<h2>4. Options</h2>
|
| 895 |
+
|
| 896 |
+
<div class="row">
|
| 897 |
+
<div class="form-group">
|
| 898 |
+
<label for="separator">Language Separator</label>
|
| 899 |
+
<input type="text" id="separator" name="separator" value=" | ">
|
| 900 |
+
</div>
|
| 901 |
+
<div class="form-group" id="whisperModelGroup">
|
| 902 |
+
<label for="modelSize">Whisper Model</label>
|
| 903 |
+
<select id="modelSize" name="model_size">
|
| 904 |
+
<option value="tiny">Tiny (Fastest)</option>
|
| 905 |
+
<option value="base">Base (Fast)</option>
|
| 906 |
+
<option value="small">Small (Balanced)</option>
|
| 907 |
+
<option value="medium" selected>Medium (Recommended)</option>
|
| 908 |
+
<option value="large">Large (Most Accurate)</option>
|
| 909 |
+
</select>
|
| 910 |
+
</div>
|
| 911 |
+
</div>
|
| 912 |
+
</div>
|
| 913 |
+
|
| 914 |
+
<!-- Offline Mode -->
|
| 915 |
+
<div class="card">
|
| 916 |
+
<div style="display: flex; justify-content: space-between; align-items: center; flex-wrap: wrap; gap: 1rem;">
|
| 917 |
+
<div>
|
| 918 |
+
<h2 style="margin: 0;">Offline Mode</h2>
|
| 919 |
+
<p style="color: var(--text-muted); font-size: 0.9rem; margin-top: 0.25rem;">
|
| 920 |
+
Download Quran data (~5-10 MB) for faster processing
|
| 921 |
+
</p>
|
| 922 |
+
</div>
|
| 923 |
+
<div style="display: flex; gap: 0.75rem; align-items: center;">
|
| 924 |
+
<label class="checkbox-option" style="margin: 0;">
|
| 925 |
+
<input type="checkbox" id="offlineModeToggle">
|
| 926 |
+
<span class="checkbox-label">Use Offline Only</span>
|
| 927 |
+
</label>
|
| 928 |
+
<button type="button" class="btn btn-secondary" id="downloadQuranBtn">
|
| 929 |
+
Download Quran Data
|
| 930 |
+
</button>
|
| 931 |
+
</div>
|
| 932 |
+
</div>
|
| 933 |
+
<div id="cacheStatus" style="margin-top: 1rem; padding: 0.75rem; background: var(--bg-input); border-radius: 8px; display: none;">
|
| 934 |
+
<p style="color: var(--text-muted); font-size: 0.9rem;">
|
| 935 |
+
<span id="cacheStatusText">Checking cache...</span>
|
| 936 |
+
</p>
|
| 937 |
+
</div>
|
| 938 |
+
<div id="downloadProgress" class="hidden" style="margin-top: 1rem;">
|
| 939 |
+
<div class="progress-bar">
|
| 940 |
+
<div class="progress-fill" id="quranProgressFill"></div>
|
| 941 |
+
</div>
|
| 942 |
+
<p class="progress-text" id="quranProgressText">Downloading...</p>
|
| 943 |
+
</div>
|
| 944 |
+
</div>
|
| 945 |
+
|
| 946 |
+
<button type="submit" class="btn btn-block" id="submitBtn">
|
| 947 |
+
Generate Subtitles
|
| 948 |
+
</button>
|
| 949 |
+
|
| 950 |
+
<div class="progress-container" id="progressContainer">
|
| 951 |
+
<div class="progress-bar">
|
| 952 |
+
<div class="progress-fill" id="progressFill"></div>
|
| 953 |
+
</div>
|
| 954 |
+
<p class="progress-text" id="progressText">Uploading...</p>
|
| 955 |
+
</div>
|
| 956 |
+
</form>
|
| 957 |
+
</div>
|
| 958 |
+
|
| 959 |
+
<!-- EDITOR SECTION -->
|
| 960 |
+
<div class="editor-section" id="editorSection">
|
| 961 |
+
<div class="card">
|
| 962 |
+
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 1rem;">
|
| 963 |
+
<h2 style="margin: 0;">Edit Timing & Preview</h2>
|
| 964 |
+
<button class="btn btn-secondary btn-sm" id="backBtn">Back to Upload</button>
|
| 965 |
+
</div>
|
| 966 |
+
|
| 967 |
+
<div class="alert alert-success" id="editorMessage"></div>
|
| 968 |
+
|
| 969 |
+
<div class="editor-layout">
|
| 970 |
+
<!-- Video Preview -->
|
| 971 |
+
<div class="video-preview">
|
| 972 |
+
<div class="video-container">
|
| 973 |
+
<video id="videoPlayer" controls>
|
| 974 |
+
Your browser does not support the video tag.
|
| 975 |
+
</video>
|
| 976 |
+
<div class="subtitle-overlay" id="subtitleOverlay">
|
| 977 |
+
<div class="arabic"></div>
|
| 978 |
+
<div class="english"></div>
|
| 979 |
+
<div class="turkish"></div>
|
| 980 |
+
</div>
|
| 981 |
+
</div>
|
| 982 |
+
<div class="current-time" id="currentTime">00:00:00.000</div>
|
| 983 |
+
<div class="video-controls">
|
| 984 |
+
<button class="btn btn-sm" id="seekBack5">-5s</button>
|
| 985 |
+
<button class="btn btn-sm" id="seekBack1">-1s</button>
|
| 986 |
+
<button class="btn btn-sm btn-secondary" id="playPause">Play</button>
|
| 987 |
+
<button class="btn btn-sm" id="seekForward1">+1s</button>
|
| 988 |
+
<button class="btn btn-sm" id="seekForward5">+5s</button>
|
| 989 |
+
</div>
|
| 990 |
+
<div class="video-controls" style="margin-top: 0.5rem;">
|
| 991 |
+
<button class="btn btn-sm btn-warning" id="setStartTime">Set Start</button>
|
| 992 |
+
<button class="btn btn-sm btn-warning" id="setEndTime">Set End</button>
|
| 993 |
+
</div>
|
| 994 |
+
</div>
|
| 995 |
+
|
| 996 |
+
<!-- Verse List -->
|
| 997 |
+
<div class="timing-editor">
|
| 998 |
+
<h3 style="margin-bottom: 1rem;">Verses (click to edit)</h3>
|
| 999 |
+
<div class="verse-list" id="verseList">
|
| 1000 |
+
<!-- Verses will be inserted here -->
|
| 1001 |
+
</div>
|
| 1002 |
+
</div>
|
| 1003 |
+
</div>
|
| 1004 |
+
</div>
|
| 1005 |
+
|
| 1006 |
+
<!-- Download Section -->
|
| 1007 |
+
<div class="card download-section">
|
| 1008 |
+
<div style="display: flex; justify-content: space-between; align-items: center;">
|
| 1009 |
+
<h2 style="margin: 0;">Download Subtitles</h2>
|
| 1010 |
+
<button class="btn" id="regenerateBtn">Regenerate with New Timings</button>
|
| 1011 |
+
</div>
|
| 1012 |
+
|
| 1013 |
+
<div class="download-grid" id="downloadGrid">
|
| 1014 |
+
<!-- Downloads will be inserted here -->
|
| 1015 |
+
</div>
|
| 1016 |
+
</div>
|
| 1017 |
+
</div>
|
| 1018 |
+
|
| 1019 |
+
<div class="features" id="featuresSection">
|
| 1020 |
+
<div class="feature">
|
| 1021 |
+
<div class="feature-icon">🌍</div>
|
| 1022 |
+
<h3>3 Languages</h3>
|
| 1023 |
+
<p>Arabic, English & Turkish on one line</p>
|
| 1024 |
+
</div>
|
| 1025 |
+
<div class="feature">
|
| 1026 |
+
<div class="feature-icon">⚡</div>
|
| 1027 |
+
<h3>AI-Powered</h3>
|
| 1028 |
+
<p>OpenAI Whisper for accurate transcription</p>
|
| 1029 |
+
</div>
|
| 1030 |
+
<div class="feature">
|
| 1031 |
+
<div class="feature-icon">📋</div>
|
| 1032 |
+
<h3>JSON Export</h3>
|
| 1033 |
+
<p>Precise timing for video editors</p>
|
| 1034 |
+
</div>
|
| 1035 |
+
</div>
|
| 1036 |
+
|
| 1037 |
+
<footer>
|
| 1038 |
+
<p>Quran SRT Generator - 100% Free & Open Source</p>
|
| 1039 |
+
<p>Translations from trusted sources (Saheeh International, Diyanet)</p>
|
| 1040 |
+
</footer>
|
| 1041 |
+
</div>
|
| 1042 |
+
|
| 1043 |
+
<script>
|
| 1044 |
+
// ================================
|
| 1045 |
+
// STATE
|
| 1046 |
+
// ================================
|
| 1047 |
+
let selectedFile = null;
|
| 1048 |
+
let selectedImage = null;
|
| 1049 |
+
let currentTaskId = null;
|
| 1050 |
+
let statusPollInterval = null;
|
| 1051 |
+
let verses = [];
|
| 1052 |
+
let selectedVerseIndex = null;
|
| 1053 |
+
let currentMode = 'video'; // 'video', 'image', 'black'
|
| 1054 |
+
let surahData = {}; // Store surah info including ayah count
|
| 1055 |
+
|
| 1056 |
+
// ================================
|
| 1057 |
+
// DOM ELEMENTS
|
| 1058 |
+
// ================================
|
| 1059 |
+
const uploadForm = document.getElementById('uploadForm');
|
| 1060 |
+
const uploadArea = document.getElementById('uploadArea');
|
| 1061 |
+
const fileInput = document.getElementById('fileInput');
|
| 1062 |
+
const fileName = document.getElementById('fileName');
|
| 1063 |
+
const surahSelect = document.getElementById('surah');
|
| 1064 |
+
const submitBtn = document.getElementById('submitBtn');
|
| 1065 |
+
const progressContainer = document.getElementById('progressContainer');
|
| 1066 |
+
const progressFill = document.getElementById('progressFill');
|
| 1067 |
+
const progressText = document.getElementById('progressText');
|
| 1068 |
+
|
| 1069 |
+
const uploadSection = document.getElementById('uploadSection');
|
| 1070 |
+
const editorSection = document.getElementById('editorSection');
|
| 1071 |
+
const featuresSection = document.getElementById('featuresSection');
|
| 1072 |
+
|
| 1073 |
+
const videoPlayer = document.getElementById('videoPlayer');
|
| 1074 |
+
const subtitleOverlay = document.getElementById('subtitleOverlay');
|
| 1075 |
+
const currentTimeDisplay = document.getElementById('currentTime');
|
| 1076 |
+
const verseList = document.getElementById('verseList');
|
| 1077 |
+
const downloadGrid = document.getElementById('downloadGrid');
|
| 1078 |
+
const editorMessage = document.getElementById('editorMessage');
|
| 1079 |
+
|
| 1080 |
+
// Mode elements
|
| 1081 |
+
const videoUploadArea = document.getElementById('videoUploadArea');
|
| 1082 |
+
const imageUploadArea = document.getElementById('imageUploadArea');
|
| 1083 |
+
const blackScreenInfo = document.getElementById('blackScreenInfo');
|
| 1084 |
+
const reciterSection = document.getElementById('reciterSection');
|
| 1085 |
+
const whisperModelGroup = document.getElementById('whisperModelGroup');
|
| 1086 |
+
const reciterSelect = document.getElementById('reciter');
|
| 1087 |
+
const imageUpload = document.getElementById('imageUpload');
|
| 1088 |
+
const imageInput = document.getElementById('imageInput');
|
| 1089 |
+
const imageName = document.getElementById('imageName');
|
| 1090 |
+
|
| 1091 |
+
// ================================
|
| 1092 |
+
// INITIALIZATION
|
| 1093 |
+
// ================================
|
| 1094 |
+
async function loadSurahs() {
|
| 1095 |
+
try {
|
| 1096 |
+
const response = await fetch('/api/surahs');
|
| 1097 |
+
const data = await response.json();
|
| 1098 |
+
|
| 1099 |
+
if (data.success) {
|
| 1100 |
+
surahSelect.innerHTML = '<option value="">Select a Surah</option>';
|
| 1101 |
+
data.surahs.forEach(surah => {
|
| 1102 |
+
// Store surah data for ayah validation
|
| 1103 |
+
surahData[surah.number] = {
|
| 1104 |
+
name: surah.name,
|
| 1105 |
+
englishName: surah.englishName,
|
| 1106 |
+
numberOfAyahs: surah.numberOfAyahs
|
| 1107 |
+
};
|
| 1108 |
+
|
| 1109 |
+
const option = document.createElement('option');
|
| 1110 |
+
option.value = surah.number;
|
| 1111 |
+
option.textContent = `${surah.number}. ${surah.englishName} (${surah.name}) - ${surah.numberOfAyahs} ayahs`;
|
| 1112 |
+
surahSelect.appendChild(option);
|
| 1113 |
+
});
|
| 1114 |
+
}
|
| 1115 |
+
} catch (error) {
|
| 1116 |
+
console.error('Failed to load surahs:', error);
|
| 1117 |
+
surahSelect.innerHTML = '<option value="">Failed to load surahs</option>';
|
| 1118 |
+
}
|
| 1119 |
+
}
|
| 1120 |
+
|
| 1121 |
+
// Update ayah range inputs when surah is selected
|
| 1122 |
+
surahSelect.addEventListener('change', () => {
|
| 1123 |
+
const surahNum = parseInt(surahSelect.value);
|
| 1124 |
+
const startAyah = document.getElementById('startAyah');
|
| 1125 |
+
const endAyah = document.getElementById('endAyah');
|
| 1126 |
+
|
| 1127 |
+
if (surahNum && surahData[surahNum]) {
|
| 1128 |
+
const maxAyahs = surahData[surahNum].numberOfAyahs;
|
| 1129 |
+
|
| 1130 |
+
// Set max values
|
| 1131 |
+
startAyah.max = maxAyahs;
|
| 1132 |
+
endAyah.max = maxAyahs;
|
| 1133 |
+
|
| 1134 |
+
// Set placeholder with max info
|
| 1135 |
+
endAyah.placeholder = `Max: ${maxAyahs}`;
|
| 1136 |
+
|
| 1137 |
+
// Reset values if they exceed max
|
| 1138 |
+
if (parseInt(startAyah.value) > maxAyahs) {
|
| 1139 |
+
startAyah.value = 1;
|
| 1140 |
+
}
|
| 1141 |
+
if (parseInt(endAyah.value) > maxAyahs) {
|
| 1142 |
+
endAyah.value = '';
|
| 1143 |
+
}
|
| 1144 |
+
|
| 1145 |
+
// Validate start ayah
|
| 1146 |
+
startAyah.value = Math.max(1, Math.min(parseInt(startAyah.value) || 1, maxAyahs));
|
| 1147 |
+
} else {
|
| 1148 |
+
startAyah.max = '';
|
| 1149 |
+
endAyah.max = '';
|
| 1150 |
+
endAyah.placeholder = '';
|
| 1151 |
+
}
|
| 1152 |
+
});
|
| 1153 |
+
|
| 1154 |
+
// Validate ayah inputs on change
|
| 1155 |
+
document.getElementById('startAyah').addEventListener('change', (e) => {
|
| 1156 |
+
const surahNum = parseInt(surahSelect.value);
|
| 1157 |
+
if (surahNum && surahData[surahNum]) {
|
| 1158 |
+
const max = surahData[surahNum].numberOfAyahs;
|
| 1159 |
+
let val = parseInt(e.target.value) || 1;
|
| 1160 |
+
e.target.value = Math.max(1, Math.min(val, max));
|
| 1161 |
+
}
|
| 1162 |
+
});
|
| 1163 |
+
|
| 1164 |
+
document.getElementById('endAyah').addEventListener('change', (e) => {
|
| 1165 |
+
const surahNum = parseInt(surahSelect.value);
|
| 1166 |
+
if (surahNum && surahData[surahNum]) {
|
| 1167 |
+
const max = surahData[surahNum].numberOfAyahs;
|
| 1168 |
+
const startVal = parseInt(document.getElementById('startAyah').value) || 1;
|
| 1169 |
+
let val = parseInt(e.target.value);
|
| 1170 |
+
if (val) {
|
| 1171 |
+
e.target.value = Math.max(startVal, Math.min(val, max));
|
| 1172 |
+
}
|
| 1173 |
+
}
|
| 1174 |
+
});
|
| 1175 |
+
|
| 1176 |
+
async function loadReciters() {
|
| 1177 |
+
try {
|
| 1178 |
+
const response = await fetch('/api/reciters');
|
| 1179 |
+
const data = await response.json();
|
| 1180 |
+
|
| 1181 |
+
if (data.success) {
|
| 1182 |
+
reciterSelect.innerHTML = '';
|
| 1183 |
+
data.reciters.forEach(reciter => {
|
| 1184 |
+
const option = document.createElement('option');
|
| 1185 |
+
option.value = reciter.identifier;
|
| 1186 |
+
option.textContent = reciter.style
|
| 1187 |
+
? `${reciter.englishName} (${reciter.style})`
|
| 1188 |
+
: reciter.englishName;
|
| 1189 |
+
reciterSelect.appendChild(option);
|
| 1190 |
+
});
|
| 1191 |
+
}
|
| 1192 |
+
} catch (error) {
|
| 1193 |
+
console.error('Failed to load reciters:', error);
|
| 1194 |
+
reciterSelect.innerHTML = '<option value="ar.alafasy">Mishary Alafasy</option>';
|
| 1195 |
+
}
|
| 1196 |
+
}
|
| 1197 |
+
|
| 1198 |
+
// ================================
|
| 1199 |
+
// MODE SWITCHING
|
| 1200 |
+
// ================================
|
| 1201 |
+
document.querySelectorAll('input[name="inputMode"]').forEach(radio => {
|
| 1202 |
+
radio.addEventListener('change', (e) => {
|
| 1203 |
+
currentMode = e.target.value;
|
| 1204 |
+
updateModeUI();
|
| 1205 |
+
});
|
| 1206 |
+
});
|
| 1207 |
+
|
| 1208 |
+
function updateModeUI() {
|
| 1209 |
+
// Hide all upload areas
|
| 1210 |
+
videoUploadArea.classList.add('hidden');
|
| 1211 |
+
imageUploadArea.classList.add('hidden');
|
| 1212 |
+
blackScreenInfo.classList.add('hidden');
|
| 1213 |
+
reciterSection.classList.add('hidden');
|
| 1214 |
+
whisperModelGroup.classList.remove('hidden');
|
| 1215 |
+
|
| 1216 |
+
// Show relevant area based on mode
|
| 1217 |
+
if (currentMode === 'video') {
|
| 1218 |
+
videoUploadArea.classList.remove('hidden');
|
| 1219 |
+
} else if (currentMode === 'image') {
|
| 1220 |
+
imageUploadArea.classList.remove('hidden');
|
| 1221 |
+
reciterSection.classList.remove('hidden');
|
| 1222 |
+
whisperModelGroup.classList.add('hidden');
|
| 1223 |
+
} else if (currentMode === 'black') {
|
| 1224 |
+
blackScreenInfo.classList.remove('hidden');
|
| 1225 |
+
reciterSection.classList.remove('hidden');
|
| 1226 |
+
whisperModelGroup.classList.add('hidden');
|
| 1227 |
+
}
|
| 1228 |
+
}
|
| 1229 |
+
|
| 1230 |
+
// ================================
|
| 1231 |
+
// FILE UPLOAD (Video/Audio)
|
| 1232 |
+
// ================================
|
| 1233 |
+
uploadArea.addEventListener('click', () => fileInput.click());
|
| 1234 |
+
|
| 1235 |
+
uploadArea.addEventListener('dragover', (e) => {
|
| 1236 |
+
e.preventDefault();
|
| 1237 |
+
uploadArea.classList.add('dragover');
|
| 1238 |
+
});
|
| 1239 |
+
|
| 1240 |
+
uploadArea.addEventListener('dragleave', () => {
|
| 1241 |
+
uploadArea.classList.remove('dragover');
|
| 1242 |
+
});
|
| 1243 |
+
|
| 1244 |
+
uploadArea.addEventListener('drop', (e) => {
|
| 1245 |
+
e.preventDefault();
|
| 1246 |
+
uploadArea.classList.remove('dragover');
|
| 1247 |
+
const files = e.dataTransfer.files;
|
| 1248 |
+
if (files.length > 0) {
|
| 1249 |
+
handleFile(files[0]);
|
| 1250 |
+
}
|
| 1251 |
+
});
|
| 1252 |
+
|
| 1253 |
+
fileInput.addEventListener('change', (e) => {
|
| 1254 |
+
if (e.target.files.length > 0) {
|
| 1255 |
+
handleFile(e.target.files[0]);
|
| 1256 |
+
}
|
| 1257 |
+
});
|
| 1258 |
+
|
| 1259 |
+
function handleFile(file) {
|
| 1260 |
+
selectedFile = file;
|
| 1261 |
+
fileName.textContent = file.name;
|
| 1262 |
+
uploadArea.classList.add('has-file');
|
| 1263 |
+
}
|
| 1264 |
+
|
| 1265 |
+
// ================================
|
| 1266 |
+
// IMAGE UPLOAD
|
| 1267 |
+
// ================================
|
| 1268 |
+
imageUpload.addEventListener('click', () => imageInput.click());
|
| 1269 |
+
|
| 1270 |
+
imageUpload.addEventListener('dragover', (e) => {
|
| 1271 |
+
e.preventDefault();
|
| 1272 |
+
imageUpload.classList.add('dragover');
|
| 1273 |
+
});
|
| 1274 |
+
|
| 1275 |
+
imageUpload.addEventListener('dragleave', () => {
|
| 1276 |
+
imageUpload.classList.remove('dragover');
|
| 1277 |
+
});
|
| 1278 |
+
|
| 1279 |
+
imageUpload.addEventListener('drop', (e) => {
|
| 1280 |
+
e.preventDefault();
|
| 1281 |
+
imageUpload.classList.remove('dragover');
|
| 1282 |
+
const files = e.dataTransfer.files;
|
| 1283 |
+
if (files.length > 0) {
|
| 1284 |
+
handleImage(files[0]);
|
| 1285 |
+
}
|
| 1286 |
+
});
|
| 1287 |
+
|
| 1288 |
+
imageInput.addEventListener('change', (e) => {
|
| 1289 |
+
if (e.target.files.length > 0) {
|
| 1290 |
+
handleImage(e.target.files[0]);
|
| 1291 |
+
}
|
| 1292 |
+
});
|
| 1293 |
+
|
| 1294 |
+
function handleImage(file) {
|
| 1295 |
+
selectedImage = file;
|
| 1296 |
+
imageName.textContent = file.name;
|
| 1297 |
+
imageUpload.classList.add('has-file');
|
| 1298 |
+
}
|
| 1299 |
+
|
| 1300 |
+
// ================================
|
| 1301 |
+
// LANGUAGE SELECTION
|
| 1302 |
+
// ================================
|
| 1303 |
+
function getSelectedLanguages() {
|
| 1304 |
+
const languages = [];
|
| 1305 |
+
document.querySelectorAll('input[name="languages"]:checked').forEach(cb => {
|
| 1306 |
+
languages.push(cb.value);
|
| 1307 |
+
});
|
| 1308 |
+
return languages;
|
| 1309 |
+
}
|
| 1310 |
+
|
| 1311 |
+
// ================================
|
| 1312 |
+
// OFFLINE MODE & CACHE
|
| 1313 |
+
// ================================
|
| 1314 |
+
async function checkCacheStatus() {
|
| 1315 |
+
try {
|
| 1316 |
+
const response = await fetch('/api/cache-status');
|
| 1317 |
+
const data = await response.json();
|
| 1318 |
+
|
| 1319 |
+
if (data.success) {
|
| 1320 |
+
const cacheDiv = document.getElementById('cacheStatus');
|
| 1321 |
+
const cacheText = document.getElementById('cacheStatusText');
|
| 1322 |
+
const offlineToggle = document.getElementById('offlineModeToggle');
|
| 1323 |
+
|
| 1324 |
+
cacheDiv.style.display = 'block';
|
| 1325 |
+
offlineToggle.checked = data.offline_mode;
|
| 1326 |
+
|
| 1327 |
+
if (data.cache.is_complete) {
|
| 1328 |
+
cacheText.innerHTML = `<span style="color: var(--success);">All 114 surahs cached.</span> Ready for offline use.`;
|
| 1329 |
+
} else if (data.cache.total_cached > 0) {
|
| 1330 |
+
cacheText.innerHTML = `${data.cache.total_cached}/114 surahs cached. <a href="#" onclick="event.preventDefault(); document.getElementById('downloadQuranBtn').click();">Download remaining</a>`;
|
| 1331 |
+
} else {
|
| 1332 |
+
cacheText.innerHTML = `No data cached. <a href="#" onclick="event.preventDefault(); document.getElementById('downloadQuranBtn').click();">Download for offline use</a>`;
|
| 1333 |
+
}
|
| 1334 |
+
}
|
| 1335 |
+
} catch (error) {
|
| 1336 |
+
console.error('Failed to check cache status:', error);
|
| 1337 |
+
}
|
| 1338 |
+
}
|
| 1339 |
+
|
| 1340 |
+
// Toggle offline mode
|
| 1341 |
+
document.getElementById('offlineModeToggle').addEventListener('change', async (e) => {
|
| 1342 |
+
const enabled = e.target.checked;
|
| 1343 |
+
try {
|
| 1344 |
+
const response = await fetch(`/api/offline-mode?enabled=${enabled}`, { method: 'POST' });
|
| 1345 |
+
const data = await response.json();
|
| 1346 |
+
|
| 1347 |
+
if (!data.success) {
|
| 1348 |
+
e.target.checked = !enabled; // Revert
|
| 1349 |
+
alert('Failed to toggle offline mode');
|
| 1350 |
+
}
|
| 1351 |
+
} catch (error) {
|
| 1352 |
+
e.target.checked = !enabled; // Revert
|
| 1353 |
+
console.error('Failed to toggle offline mode:', error);
|
| 1354 |
+
}
|
| 1355 |
+
});
|
| 1356 |
+
|
| 1357 |
+
document.getElementById('downloadQuranBtn').addEventListener('click', async () => {
|
| 1358 |
+
const btn = document.getElementById('downloadQuranBtn');
|
| 1359 |
+
const progress = document.getElementById('downloadProgress');
|
| 1360 |
+
const progressFill = document.getElementById('quranProgressFill');
|
| 1361 |
+
const progressText = document.getElementById('quranProgressText');
|
| 1362 |
+
|
| 1363 |
+
btn.disabled = true;
|
| 1364 |
+
btn.textContent = 'Downloading...';
|
| 1365 |
+
progress.classList.remove('hidden');
|
| 1366 |
+
|
| 1367 |
+
try {
|
| 1368 |
+
const response = await fetch('/api/download-quran', { method: 'POST' });
|
| 1369 |
+
const data = await response.json();
|
| 1370 |
+
|
| 1371 |
+
if (data.success) {
|
| 1372 |
+
// Poll for progress
|
| 1373 |
+
let percent = 0;
|
| 1374 |
+
const interval = setInterval(() => {
|
| 1375 |
+
percent += 2;
|
| 1376 |
+
if (percent >= 100) {
|
| 1377 |
+
clearInterval(interval);
|
| 1378 |
+
progressFill.style.width = '100%';
|
| 1379 |
+
progressText.textContent = 'Download complete!';
|
| 1380 |
+
btn.textContent = 'Downloaded';
|
| 1381 |
+
// Refresh cache status
|
| 1382 |
+
setTimeout(checkCacheStatus, 1000);
|
| 1383 |
+
} else {
|
| 1384 |
+
progressFill.style.width = percent + '%';
|
| 1385 |
+
progressText.textContent = `Downloading... ${percent}%`;
|
| 1386 |
+
}
|
| 1387 |
+
}, 500);
|
| 1388 |
+
}
|
| 1389 |
+
} catch (error) {
|
| 1390 |
+
progressText.textContent = 'Error: ' + error.message;
|
| 1391 |
+
btn.disabled = false;
|
| 1392 |
+
btn.textContent = 'Retry Download';
|
| 1393 |
+
}
|
| 1394 |
+
});
|
| 1395 |
+
|
| 1396 |
+
// ================================
|
| 1397 |
+
// FORM SUBMISSION
|
| 1398 |
+
// ================================
|
| 1399 |
+
uploadForm.addEventListener('submit', async (e) => {
|
| 1400 |
+
e.preventDefault();
|
| 1401 |
+
|
| 1402 |
+
const languages = getSelectedLanguages();
|
| 1403 |
+
if (languages.length === 0) {
|
| 1404 |
+
alert('Please select at least one language');
|
| 1405 |
+
return;
|
| 1406 |
+
}
|
| 1407 |
+
|
| 1408 |
+
// Validate based on mode
|
| 1409 |
+
if (currentMode === 'video' && !selectedFile) {
|
| 1410 |
+
alert('Please select a video or audio file');
|
| 1411 |
+
return;
|
| 1412 |
+
}
|
| 1413 |
+
if (currentMode === 'image' && !selectedImage) {
|
| 1414 |
+
alert('Please select a background image');
|
| 1415 |
+
return;
|
| 1416 |
+
}
|
| 1417 |
+
|
| 1418 |
+
if (!surahSelect.value) {
|
| 1419 |
+
alert('Please select a surah');
|
| 1420 |
+
return;
|
| 1421 |
+
}
|
| 1422 |
+
|
| 1423 |
+
const formData = new FormData();
|
| 1424 |
+
formData.append('mode', currentMode);
|
| 1425 |
+
formData.append('surah', surahSelect.value);
|
| 1426 |
+
formData.append('start_ayah', document.getElementById('startAyah').value || 1);
|
| 1427 |
+
formData.append('end_ayah', document.getElementById('endAyah').value || '');
|
| 1428 |
+
formData.append('separator', document.getElementById('separator').value);
|
| 1429 |
+
formData.append('languages', JSON.stringify(languages));
|
| 1430 |
+
|
| 1431 |
+
if (currentMode === 'video') {
|
| 1432 |
+
formData.append('file', selectedFile);
|
| 1433 |
+
formData.append('model_size', document.getElementById('modelSize').value);
|
| 1434 |
+
} else if (currentMode === 'image') {
|
| 1435 |
+
formData.append('image', selectedImage);
|
| 1436 |
+
formData.append('reciter', reciterSelect.value);
|
| 1437 |
+
} else if (currentMode === 'black') {
|
| 1438 |
+
formData.append('reciter', reciterSelect.value);
|
| 1439 |
+
}
|
| 1440 |
+
|
| 1441 |
+
submitBtn.disabled = true;
|
| 1442 |
+
progressContainer.classList.add('show');
|
| 1443 |
+
updateProgress(0, currentMode === 'video' ? 'Uploading file...' : 'Starting...');
|
| 1444 |
+
|
| 1445 |
+
try {
|
| 1446 |
+
const uploadResponse = await fetch('/api/upload', {
|
| 1447 |
+
method: 'POST',
|
| 1448 |
+
body: formData
|
| 1449 |
+
});
|
| 1450 |
+
const uploadData = await uploadResponse.json();
|
| 1451 |
+
|
| 1452 |
+
if (!uploadData.success) {
|
| 1453 |
+
throw new Error(uploadData.message || 'Upload failed');
|
| 1454 |
+
}
|
| 1455 |
+
|
| 1456 |
+
currentTaskId = uploadData.task_id;
|
| 1457 |
+
updateProgress(5, 'File uploaded. Starting processing...');
|
| 1458 |
+
|
| 1459 |
+
const processResponse = await fetch(`/api/process/${currentTaskId}`, {
|
| 1460 |
+
method: 'POST'
|
| 1461 |
+
});
|
| 1462 |
+
const processData = await processResponse.json();
|
| 1463 |
+
|
| 1464 |
+
if (!processData.success) {
|
| 1465 |
+
throw new Error(processData.message || 'Failed to start processing');
|
| 1466 |
+
}
|
| 1467 |
+
|
| 1468 |
+
startStatusPolling();
|
| 1469 |
+
|
| 1470 |
+
} catch (error) {
|
| 1471 |
+
console.error('Error:', error);
|
| 1472 |
+
updateProgress(0, `Error: ${error.message}`);
|
| 1473 |
+
submitBtn.disabled = false;
|
| 1474 |
+
}
|
| 1475 |
+
});
|
| 1476 |
+
|
| 1477 |
+
function startStatusPolling() {
|
| 1478 |
+
statusPollInterval = setInterval(async () => {
|
| 1479 |
+
try {
|
| 1480 |
+
const response = await fetch(`/api/status/${currentTaskId}`);
|
| 1481 |
+
const status = await response.json();
|
| 1482 |
+
|
| 1483 |
+
updateProgress(status.progress, status.message);
|
| 1484 |
+
|
| 1485 |
+
if (status.status === 'completed') {
|
| 1486 |
+
clearInterval(statusPollInterval);
|
| 1487 |
+
showEditor(status);
|
| 1488 |
+
} else if (status.status === 'error') {
|
| 1489 |
+
clearInterval(statusPollInterval);
|
| 1490 |
+
updateProgress(0, `Error: ${status.error}`);
|
| 1491 |
+
submitBtn.disabled = false;
|
| 1492 |
+
}
|
| 1493 |
+
} catch (error) {
|
| 1494 |
+
console.error('Status poll error:', error);
|
| 1495 |
+
}
|
| 1496 |
+
}, 1000);
|
| 1497 |
+
}
|
| 1498 |
+
|
| 1499 |
+
function updateProgress(percent, message) {
|
| 1500 |
+
progressFill.style.width = `${percent}%`;
|
| 1501 |
+
progressText.textContent = message;
|
| 1502 |
+
}
|
| 1503 |
+
|
| 1504 |
+
// ================================
|
| 1505 |
+
// EDITOR
|
| 1506 |
+
// ================================
|
| 1507 |
+
function showEditor(status) {
|
| 1508 |
+
uploadSection.classList.add('hidden');
|
| 1509 |
+
featuresSection.classList.add('hidden');
|
| 1510 |
+
editorSection.classList.add('show');
|
| 1511 |
+
progressContainer.classList.remove('show');
|
| 1512 |
+
submitBtn.disabled = false;
|
| 1513 |
+
|
| 1514 |
+
editorMessage.textContent = status.message;
|
| 1515 |
+
verses = status.verses || [];
|
| 1516 |
+
|
| 1517 |
+
// Check if video is available
|
| 1518 |
+
const settings = status.settings || {};
|
| 1519 |
+
const videoPreview = document.querySelector('.video-preview');
|
| 1520 |
+
const hasVideo = status.video_file || (settings.mode === 'video' && settings.file_path);
|
| 1521 |
+
const hasGeneratedVideo = status.output_files && status.output_files.video;
|
| 1522 |
+
|
| 1523 |
+
// Reset video preview HTML
|
| 1524 |
+
videoPreview.innerHTML = `
|
| 1525 |
+
<div class="video-container">
|
| 1526 |
+
<video id="videoPlayer" controls>
|
| 1527 |
+
Your browser does not support the video tag.
|
| 1528 |
+
</video>
|
| 1529 |
+
<div class="subtitle-overlay" id="subtitleOverlay">
|
| 1530 |
+
<div class="arabic"></div>
|
| 1531 |
+
<div class="english"></div>
|
| 1532 |
+
<div class="turkish"></div>
|
| 1533 |
+
</div>
|
| 1534 |
+
</div>
|
| 1535 |
+
<div class="current-time" id="currentTime">00:00:00.000</div>
|
| 1536 |
+
<div class="video-controls">
|
| 1537 |
+
<button class="btn btn-sm" id="seekBack5">-5s</button>
|
| 1538 |
+
<button class="btn btn-sm" id="seekBack1">-1s</button>
|
| 1539 |
+
<button class="btn btn-sm btn-secondary" id="playPause">Play</button>
|
| 1540 |
+
<button class="btn btn-sm" id="seekForward1">+1s</button>
|
| 1541 |
+
<button class="btn btn-sm" id="seekForward5">+5s</button>
|
| 1542 |
+
</div>
|
| 1543 |
+
<div class="video-controls" style="margin-top: 0.5rem;">
|
| 1544 |
+
<button class="btn btn-sm btn-warning" id="setStartTime">Set Start</button>
|
| 1545 |
+
<button class="btn btn-sm btn-warning" id="setEndTime">Set End</button>
|
| 1546 |
+
</div>
|
| 1547 |
+
`;
|
| 1548 |
+
|
| 1549 |
+
// Re-bind video player reference
|
| 1550 |
+
const newVideoPlayer = document.getElementById('videoPlayer');
|
| 1551 |
+
|
| 1552 |
+
if (hasVideo || hasGeneratedVideo) {
|
| 1553 |
+
// Load video
|
| 1554 |
+
newVideoPlayer.src = `/api/video/${currentTaskId}`;
|
| 1555 |
+
videoPreview.style.display = 'block';
|
| 1556 |
+
|
| 1557 |
+
// Re-setup video event listeners
|
| 1558 |
+
setupVideoControls(newVideoPlayer);
|
| 1559 |
+
} else {
|
| 1560 |
+
// No video available
|
| 1561 |
+
videoPreview.innerHTML = `
|
| 1562 |
+
<div style="background: var(--bg-input); border-radius: 12px; padding: 3rem; text-align: center;">
|
| 1563 |
+
<div style="font-size: 3rem; margin-bottom: 1rem;">📋</div>
|
| 1564 |
+
<p style="color: var(--text-muted);">Video is being generated...</p>
|
| 1565 |
+
<p style="color: var(--text-muted); font-size: 0.9rem; margin-top: 0.5rem;">If audio download failed, edit timing manually.</p>
|
| 1566 |
+
</div>
|
| 1567 |
+
`;
|
| 1568 |
+
}
|
| 1569 |
+
|
| 1570 |
+
// Render verse list
|
| 1571 |
+
renderVerseList();
|
| 1572 |
+
|
| 1573 |
+
// Setup download buttons based on selected languages and video availability
|
| 1574 |
+
setupDownloadButtons(settings.languages || ['arabic', 'english', 'turkish'], hasGeneratedVideo);
|
| 1575 |
+
}
|
| 1576 |
+
|
| 1577 |
+
function setupVideoControls(player) {
|
| 1578 |
+
// Time update
|
| 1579 |
+
player.addEventListener('timeupdate', () => {
|
| 1580 |
+
const time = player.currentTime;
|
| 1581 |
+
const hours = Math.floor(time / 3600);
|
| 1582 |
+
const minutes = Math.floor((time % 3600) / 60);
|
| 1583 |
+
const seconds = Math.floor(time % 60);
|
| 1584 |
+
const ms = Math.floor((time % 1) * 1000);
|
| 1585 |
+
document.getElementById('currentTime').textContent =
|
| 1586 |
+
`${hours.toString().padStart(2, '0')}:${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}.${ms.toString().padStart(3, '0')}`;
|
| 1587 |
+
|
| 1588 |
+
updateSubtitleOverlay(time);
|
| 1589 |
+
highlightActiveVerse(time);
|
| 1590 |
+
});
|
| 1591 |
+
|
| 1592 |
+
// Control buttons
|
| 1593 |
+
document.getElementById('playPause').onclick = () => {
|
| 1594 |
+
if (player.paused) {
|
| 1595 |
+
player.play();
|
| 1596 |
+
document.getElementById('playPause').textContent = 'Pause';
|
| 1597 |
+
} else {
|
| 1598 |
+
player.pause();
|
| 1599 |
+
document.getElementById('playPause').textContent = 'Play';
|
| 1600 |
+
}
|
| 1601 |
+
};
|
| 1602 |
+
|
| 1603 |
+
document.getElementById('seekBack5').onclick = () => {
|
| 1604 |
+
player.currentTime = Math.max(0, player.currentTime - 5);
|
| 1605 |
+
};
|
| 1606 |
+
document.getElementById('seekBack1').onclick = () => {
|
| 1607 |
+
player.currentTime = Math.max(0, player.currentTime - 1);
|
| 1608 |
+
};
|
| 1609 |
+
document.getElementById('seekForward1').onclick = () => {
|
| 1610 |
+
player.currentTime = Math.min(player.duration, player.currentTime + 1);
|
| 1611 |
+
};
|
| 1612 |
+
document.getElementById('seekForward5').onclick = () => {
|
| 1613 |
+
player.currentTime = Math.min(player.duration, player.currentTime + 5);
|
| 1614 |
+
};
|
| 1615 |
+
|
| 1616 |
+
document.getElementById('setStartTime').onclick = () => {
|
| 1617 |
+
if (selectedVerseIndex !== null) {
|
| 1618 |
+
verses[selectedVerseIndex].start_time = player.currentTime;
|
| 1619 |
+
const input = document.querySelector(`input[data-index="${selectedVerseIndex}"][data-field="start_time"]`);
|
| 1620 |
+
if (input) input.value = player.currentTime.toFixed(3);
|
| 1621 |
+
} else {
|
| 1622 |
+
alert('Please select a verse first');
|
| 1623 |
+
}
|
| 1624 |
+
};
|
| 1625 |
+
|
| 1626 |
+
document.getElementById('setEndTime').onclick = () => {
|
| 1627 |
+
if (selectedVerseIndex !== null) {
|
| 1628 |
+
verses[selectedVerseIndex].end_time = player.currentTime;
|
| 1629 |
+
const input = document.querySelector(`input[data-index="${selectedVerseIndex}"][data-field="end_time"]`);
|
| 1630 |
+
if (input) input.value = player.currentTime.toFixed(3);
|
| 1631 |
+
} else {
|
| 1632 |
+
alert('Please select a verse first');
|
| 1633 |
+
}
|
| 1634 |
+
};
|
| 1635 |
+
}
|
| 1636 |
+
|
| 1637 |
+
function renderVerseList() {
|
| 1638 |
+
verseList.innerHTML = '';
|
| 1639 |
+
|
| 1640 |
+
verses.forEach((verse, index) => {
|
| 1641 |
+
const item = document.createElement('div');
|
| 1642 |
+
item.className = 'verse-item';
|
| 1643 |
+
item.dataset.index = index;
|
| 1644 |
+
|
| 1645 |
+
item.innerHTML = `
|
| 1646 |
+
<div class="verse-header">
|
| 1647 |
+
<span class="verse-number">Ayah ${verse.ayah}</span>
|
| 1648 |
+
<span class="verse-confidence">${(verse.confidence * 100).toFixed(0)}% match</span>
|
| 1649 |
+
</div>
|
| 1650 |
+
<div class="verse-text">${verse.arabic}</div>
|
| 1651 |
+
<div class="verse-translation">${verse.english}</div>
|
| 1652 |
+
<div class="timing-inputs">
|
| 1653 |
+
<div>
|
| 1654 |
+
<label>Start (seconds)</label>
|
| 1655 |
+
<input type="number" step="0.001" value="${verse.start_time.toFixed(3)}"
|
| 1656 |
+
data-field="start_time" data-index="${index}">
|
| 1657 |
+
</div>
|
| 1658 |
+
<div>
|
| 1659 |
+
<label>End (seconds)</label>
|
| 1660 |
+
<input type="number" step="0.001" value="${verse.end_time.toFixed(3)}"
|
| 1661 |
+
data-field="end_time" data-index="${index}">
|
| 1662 |
+
</div>
|
| 1663 |
+
<button class="btn btn-sm" onclick="jumpToVerse(${index})">Jump</button>
|
| 1664 |
+
</div>
|
| 1665 |
+
`;
|
| 1666 |
+
|
| 1667 |
+
item.addEventListener('click', (e) => {
|
| 1668 |
+
if (e.target.tagName !== 'INPUT' && e.target.tagName !== 'BUTTON') {
|
| 1669 |
+
selectVerse(index);
|
| 1670 |
+
}
|
| 1671 |
+
});
|
| 1672 |
+
|
| 1673 |
+
// Input change handlers
|
| 1674 |
+
item.querySelectorAll('input').forEach(input => {
|
| 1675 |
+
input.addEventListener('change', (e) => {
|
| 1676 |
+
const idx = parseInt(e.target.dataset.index);
|
| 1677 |
+
const field = e.target.dataset.field;
|
| 1678 |
+
verses[idx][field] = parseFloat(e.target.value);
|
| 1679 |
+
});
|
| 1680 |
+
});
|
| 1681 |
+
|
| 1682 |
+
verseList.appendChild(item);
|
| 1683 |
+
});
|
| 1684 |
+
}
|
| 1685 |
+
|
| 1686 |
+
function selectVerse(index) {
|
| 1687 |
+
selectedVerseIndex = index;
|
| 1688 |
+
|
| 1689 |
+
// Update UI
|
| 1690 |
+
document.querySelectorAll('.verse-item').forEach((item, i) => {
|
| 1691 |
+
item.classList.toggle('editing', i === index);
|
| 1692 |
+
});
|
| 1693 |
+
|
| 1694 |
+
// Jump to verse
|
| 1695 |
+
jumpToVerse(index);
|
| 1696 |
+
}
|
| 1697 |
+
|
| 1698 |
+
function jumpToVerse(index) {
|
| 1699 |
+
if (verses[index]) {
|
| 1700 |
+
videoPlayer.currentTime = verses[index].start_time;
|
| 1701 |
+
}
|
| 1702 |
+
}
|
| 1703 |
+
|
| 1704 |
+
// ================================
|
| 1705 |
+
// VIDEO PLAYER
|
| 1706 |
+
// ================================
|
| 1707 |
+
videoPlayer.addEventListener('timeupdate', () => {
|
| 1708 |
+
const time = videoPlayer.currentTime;
|
| 1709 |
+
|
| 1710 |
+
// Update time display
|
| 1711 |
+
const hours = Math.floor(time / 3600);
|
| 1712 |
+
const minutes = Math.floor((time % 3600) / 60);
|
| 1713 |
+
const seconds = Math.floor(time % 60);
|
| 1714 |
+
const ms = Math.floor((time % 1) * 1000);
|
| 1715 |
+
currentTimeDisplay.textContent =
|
| 1716 |
+
`${hours.toString().padStart(2, '0')}:${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}.${ms.toString().padStart(3, '0')}`;
|
| 1717 |
+
|
| 1718 |
+
// Update subtitle overlay
|
| 1719 |
+
updateSubtitleOverlay(time);
|
| 1720 |
+
|
| 1721 |
+
// Highlight active verse
|
| 1722 |
+
highlightActiveVerse(time);
|
| 1723 |
+
});
|
| 1724 |
+
|
| 1725 |
+
function updateSubtitleOverlay(time) {
|
| 1726 |
+
const activeVerse = verses.find(v => time >= v.start_time && time <= v.end_time);
|
| 1727 |
+
|
| 1728 |
+
if (activeVerse) {
|
| 1729 |
+
subtitleOverlay.style.display = 'block';
|
| 1730 |
+
subtitleOverlay.querySelector('.arabic').textContent = activeVerse.arabic;
|
| 1731 |
+
subtitleOverlay.querySelector('.english').textContent = activeVerse.english;
|
| 1732 |
+
subtitleOverlay.querySelector('.turkish').textContent = activeVerse.turkish;
|
| 1733 |
+
} else {
|
| 1734 |
+
subtitleOverlay.style.display = 'none';
|
| 1735 |
+
}
|
| 1736 |
+
}
|
| 1737 |
+
|
| 1738 |
+
function highlightActiveVerse(time) {
|
| 1739 |
+
document.querySelectorAll('.verse-item').forEach((item, index) => {
|
| 1740 |
+
const verse = verses[index];
|
| 1741 |
+
const isActive = time >= verse.start_time && time <= verse.end_time;
|
| 1742 |
+
item.classList.toggle('active', isActive);
|
| 1743 |
+
});
|
| 1744 |
+
}
|
| 1745 |
+
|
| 1746 |
+
// Video controls
|
| 1747 |
+
document.getElementById('playPause').addEventListener('click', () => {
|
| 1748 |
+
if (videoPlayer.paused) {
|
| 1749 |
+
videoPlayer.play();
|
| 1750 |
+
document.getElementById('playPause').textContent = 'Pause';
|
| 1751 |
+
} else {
|
| 1752 |
+
videoPlayer.pause();
|
| 1753 |
+
document.getElementById('playPause').textContent = 'Play';
|
| 1754 |
+
}
|
| 1755 |
+
});
|
| 1756 |
+
|
| 1757 |
+
document.getElementById('seekBack5').addEventListener('click', () => {
|
| 1758 |
+
videoPlayer.currentTime = Math.max(0, videoPlayer.currentTime - 5);
|
| 1759 |
+
});
|
| 1760 |
+
|
| 1761 |
+
document.getElementById('seekBack1').addEventListener('click', () => {
|
| 1762 |
+
videoPlayer.currentTime = Math.max(0, videoPlayer.currentTime - 1);
|
| 1763 |
+
});
|
| 1764 |
+
|
| 1765 |
+
document.getElementById('seekForward1').addEventListener('click', () => {
|
| 1766 |
+
videoPlayer.currentTime = Math.min(videoPlayer.duration, videoPlayer.currentTime + 1);
|
| 1767 |
+
});
|
| 1768 |
+
|
| 1769 |
+
document.getElementById('seekForward5').addEventListener('click', () => {
|
| 1770 |
+
videoPlayer.currentTime = Math.min(videoPlayer.duration, videoPlayer.currentTime + 5);
|
| 1771 |
+
});
|
| 1772 |
+
|
| 1773 |
+
document.getElementById('setStartTime').addEventListener('click', () => {
|
| 1774 |
+
if (selectedVerseIndex !== null) {
|
| 1775 |
+
verses[selectedVerseIndex].start_time = videoPlayer.currentTime;
|
| 1776 |
+
const input = document.querySelector(`input[data-index="${selectedVerseIndex}"][data-field="start_time"]`);
|
| 1777 |
+
if (input) input.value = videoPlayer.currentTime.toFixed(3);
|
| 1778 |
+
} else {
|
| 1779 |
+
alert('Please select a verse first');
|
| 1780 |
+
}
|
| 1781 |
+
});
|
| 1782 |
+
|
| 1783 |
+
document.getElementById('setEndTime').addEventListener('click', () => {
|
| 1784 |
+
if (selectedVerseIndex !== null) {
|
| 1785 |
+
verses[selectedVerseIndex].end_time = videoPlayer.currentTime;
|
| 1786 |
+
const input = document.querySelector(`input[data-index="${selectedVerseIndex}"][data-field="end_time"]`);
|
| 1787 |
+
if (input) input.value = videoPlayer.currentTime.toFixed(3);
|
| 1788 |
+
} else {
|
| 1789 |
+
alert('Please select a verse first');
|
| 1790 |
+
}
|
| 1791 |
+
});
|
| 1792 |
+
|
| 1793 |
+
// ================================
|
| 1794 |
+
// REGENERATE & DOWNLOAD
|
| 1795 |
+
// ================================
|
| 1796 |
+
document.getElementById('regenerateBtn').addEventListener('click', async () => {
|
| 1797 |
+
const btn = document.getElementById('regenerateBtn');
|
| 1798 |
+
btn.disabled = true;
|
| 1799 |
+
btn.textContent = 'Regenerating...';
|
| 1800 |
+
|
| 1801 |
+
try {
|
| 1802 |
+
// Update verses on server
|
| 1803 |
+
const updates = verses.map((v, i) => ({
|
| 1804 |
+
index: i,
|
| 1805 |
+
start_time: v.start_time,
|
| 1806 |
+
end_time: v.end_time
|
| 1807 |
+
}));
|
| 1808 |
+
|
| 1809 |
+
await fetch(`/api/verses/${currentTaskId}/update`, {
|
| 1810 |
+
method: 'POST',
|
| 1811 |
+
headers: { 'Content-Type': 'application/json' },
|
| 1812 |
+
body: JSON.stringify({ verses: updates })
|
| 1813 |
+
});
|
| 1814 |
+
|
| 1815 |
+
// Regenerate files
|
| 1816 |
+
const response = await fetch(`/api/regenerate/${currentTaskId}`, {
|
| 1817 |
+
method: 'POST'
|
| 1818 |
+
});
|
| 1819 |
+
const data = await response.json();
|
| 1820 |
+
|
| 1821 |
+
if (data.success) {
|
| 1822 |
+
editorMessage.textContent = 'Subtitles regenerated with updated timings!';
|
| 1823 |
+
editorMessage.className = 'alert alert-success';
|
| 1824 |
+
} else {
|
| 1825 |
+
throw new Error(data.message || 'Regeneration failed');
|
| 1826 |
+
}
|
| 1827 |
+
|
| 1828 |
+
} catch (error) {
|
| 1829 |
+
editorMessage.textContent = `Error: ${error.message}`;
|
| 1830 |
+
editorMessage.className = 'alert alert-error';
|
| 1831 |
+
} finally {
|
| 1832 |
+
btn.disabled = false;
|
| 1833 |
+
btn.textContent = 'Regenerate with New Timings';
|
| 1834 |
+
}
|
| 1835 |
+
});
|
| 1836 |
+
|
| 1837 |
+
function setupDownloadButtons(languages, hasVideo = false) {
|
| 1838 |
+
downloadGrid.innerHTML = '';
|
| 1839 |
+
|
| 1840 |
+
// Language display names and icons
|
| 1841 |
+
const langInfo = {
|
| 1842 |
+
'arabic': { label: 'Arabic', icon: 'AR' },
|
| 1843 |
+
'english': { label: 'English', icon: 'EN' },
|
| 1844 |
+
'turkish': { label: 'Turkish', icon: 'TR' },
|
| 1845 |
+
'urdu': { label: 'Urdu', icon: 'UR' },
|
| 1846 |
+
'french': { label: 'French', icon: 'FR' },
|
| 1847 |
+
'german': { label: 'German', icon: 'DE' },
|
| 1848 |
+
'indonesian': { label: 'Indonesian', icon: 'ID' },
|
| 1849 |
+
'spanish': { label: 'Spanish', icon: 'ES' },
|
| 1850 |
+
'russian': { label: 'Russian', icon: 'RU' },
|
| 1851 |
+
'bengali': { label: 'Bengali', icon: 'BN' },
|
| 1852 |
+
'chinese': { label: 'Chinese', icon: 'ZH' },
|
| 1853 |
+
'malay': { label: 'Malay', icon: 'MS' },
|
| 1854 |
+
'persian': { label: 'Persian', icon: 'FA' },
|
| 1855 |
+
'dutch': { label: 'Dutch', icon: 'NL' },
|
| 1856 |
+
'italian': { label: 'Italian', icon: 'IT' },
|
| 1857 |
+
'portuguese': { label: 'Portuguese', icon: 'PT' }
|
| 1858 |
+
};
|
| 1859 |
+
|
| 1860 |
+
const files = [];
|
| 1861 |
+
|
| 1862 |
+
// Add video download if available
|
| 1863 |
+
if (hasVideo) {
|
| 1864 |
+
files.push({
|
| 1865 |
+
id: 'video',
|
| 1866 |
+
label: 'Download Video',
|
| 1867 |
+
icon: '🎬',
|
| 1868 |
+
desc: 'MP4 with subtitles',
|
| 1869 |
+
highlight: true
|
| 1870 |
+
});
|
| 1871 |
+
}
|
| 1872 |
+
|
| 1873 |
+
files.push({ id: 'srt_combined', label: 'Combined SRT', icon: '📄', desc: `All ${languages.length} languages` });
|
| 1874 |
+
|
| 1875 |
+
// Add individual language SRT downloads
|
| 1876 |
+
languages.forEach(lang => {
|
| 1877 |
+
const info = langInfo[lang] || { label: lang, icon: lang.substring(0, 2).toUpperCase() };
|
| 1878 |
+
files.push({
|
| 1879 |
+
id: `srt_${lang}`,
|
| 1880 |
+
label: `${info.label} SRT`,
|
| 1881 |
+
icon: info.icon,
|
| 1882 |
+
desc: `${info.label} only`
|
| 1883 |
+
});
|
| 1884 |
+
});
|
| 1885 |
+
|
| 1886 |
+
// Add JSON timing file
|
| 1887 |
+
files.push({ id: 'json', label: 'JSON Timing', icon: '⏱️', desc: 'For video editors' });
|
| 1888 |
+
|
| 1889 |
+
files.forEach(file => {
|
| 1890 |
+
const btn = document.createElement('a');
|
| 1891 |
+
btn.href = `/api/download/${currentTaskId}/${file.id}`;
|
| 1892 |
+
btn.className = 'download-btn' + (file.highlight ? ' highlight' : '');
|
| 1893 |
+
btn.innerHTML = `
|
| 1894 |
+
<span class="icon">${file.icon}</span>
|
| 1895 |
+
<div>
|
| 1896 |
+
<strong>${file.label}</strong>
|
| 1897 |
+
<br><small>${file.desc}</small>
|
| 1898 |
+
</div>
|
| 1899 |
+
`;
|
| 1900 |
+
if (file.highlight) {
|
| 1901 |
+
btn.style.background = 'linear-gradient(135deg, var(--primary), var(--primary-dark))';
|
| 1902 |
+
btn.style.color = 'white';
|
| 1903 |
+
}
|
| 1904 |
+
downloadGrid.appendChild(btn);
|
| 1905 |
+
});
|
| 1906 |
+
}
|
| 1907 |
+
|
| 1908 |
+
// ================================
|
| 1909 |
+
// BACK BUTTON
|
| 1910 |
+
// ================================
|
| 1911 |
+
document.getElementById('backBtn').addEventListener('click', () => {
|
| 1912 |
+
if (confirm('Are you sure? Your edited timings will be lost.')) {
|
| 1913 |
+
editorSection.classList.remove('show');
|
| 1914 |
+
uploadSection.classList.remove('hidden');
|
| 1915 |
+
featuresSection.classList.remove('hidden');
|
| 1916 |
+
|
| 1917 |
+
// Cleanup
|
| 1918 |
+
fetch(`/api/task/${currentTaskId}`, { method: 'DELETE' });
|
| 1919 |
+
currentTaskId = null;
|
| 1920 |
+
verses = [];
|
| 1921 |
+
selectedVerseIndex = null;
|
| 1922 |
+
videoPlayer.src = '';
|
| 1923 |
+
}
|
| 1924 |
+
});
|
| 1925 |
+
|
| 1926 |
+
// ================================
|
| 1927 |
+
// KEYBOARD SHORTCUTS
|
| 1928 |
+
// ================================
|
| 1929 |
+
document.addEventListener('keydown', (e) => {
|
| 1930 |
+
if (editorSection.classList.contains('show') && !e.target.matches('input')) {
|
| 1931 |
+
switch(e.key) {
|
| 1932 |
+
case ' ':
|
| 1933 |
+
e.preventDefault();
|
| 1934 |
+
document.getElementById('playPause').click();
|
| 1935 |
+
break;
|
| 1936 |
+
case 'ArrowLeft':
|
| 1937 |
+
e.preventDefault();
|
| 1938 |
+
videoPlayer.currentTime -= e.shiftKey ? 5 : 1;
|
| 1939 |
+
break;
|
| 1940 |
+
case 'ArrowRight':
|
| 1941 |
+
e.preventDefault();
|
| 1942 |
+
videoPlayer.currentTime += e.shiftKey ? 5 : 1;
|
| 1943 |
+
break;
|
| 1944 |
+
case 'ArrowUp':
|
| 1945 |
+
e.preventDefault();
|
| 1946 |
+
if (selectedVerseIndex > 0) selectVerse(selectedVerseIndex - 1);
|
| 1947 |
+
break;
|
| 1948 |
+
case 'ArrowDown':
|
| 1949 |
+
e.preventDefault();
|
| 1950 |
+
if (selectedVerseIndex < verses.length - 1) selectVerse(selectedVerseIndex + 1);
|
| 1951 |
+
break;
|
| 1952 |
+
case 's':
|
| 1953 |
+
if (e.ctrlKey || e.metaKey) {
|
| 1954 |
+
e.preventDefault();
|
| 1955 |
+
document.getElementById('regenerateBtn').click();
|
| 1956 |
+
} else {
|
| 1957 |
+
document.getElementById('setStartTime').click();
|
| 1958 |
+
}
|
| 1959 |
+
break;
|
| 1960 |
+
case 'e':
|
| 1961 |
+
document.getElementById('setEndTime').click();
|
| 1962 |
+
break;
|
| 1963 |
+
}
|
| 1964 |
+
}
|
| 1965 |
+
});
|
| 1966 |
+
|
| 1967 |
+
// ================================
|
| 1968 |
+
// INITIALIZE
|
| 1969 |
+
// ================================
|
| 1970 |
+
loadSurahs();
|
| 1971 |
+
loadReciters();
|
| 1972 |
+
updateModeUI();
|
| 1973 |
+
checkCacheStatus();
|
| 1974 |
+
</script>
|
| 1975 |
+
</body>
|
| 1976 |
+
</html>
|
uploads/.gitkeep
ADDED
|
File without changes
|