gadearjun241 commited on
Commit ·
f37003f
1
Parent(s): cd28200
optmised main and step 1 2 and 4
Browse files- main.py +1608 -29
- src/pipeline/step_001_separate.py +58 -31
- src/pipeline/step_004_transcribe.py +160 -427
main.py
CHANGED
|
@@ -1,3 +1,953 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
# uvicorn main:app --host 0.0.0.0 --port 8000
|
| 2 |
"""
|
| 3 |
FastAPI service for the 5-step subtitle pipeline.
|
|
@@ -50,16 +1000,18 @@ a queue/worker system as preferable for heavy background computation.
|
|
| 50 |
from __future__ import annotations
|
| 51 |
|
| 52 |
import asyncio
|
|
|
|
| 53 |
import json
|
| 54 |
import logging
|
| 55 |
import os
|
| 56 |
import re
|
| 57 |
import shutil
|
|
|
|
| 58 |
import tempfile
|
| 59 |
import threading
|
| 60 |
import time
|
| 61 |
from concurrent.futures import ThreadPoolExecutor
|
| 62 |
-
from datetime import datetime, timezone
|
| 63 |
from email.message import Message
|
| 64 |
from pathlib import Path
|
| 65 |
from typing import Any
|
|
@@ -68,6 +1020,7 @@ from urllib.parse import urlparse, unquote
|
|
| 68 |
import httpx
|
| 69 |
from fastapi import FastAPI, HTTPException, status
|
| 70 |
from fastapi.responses import FileResponse
|
|
|
|
| 71 |
from pydantic import BaseModel, Field, HttpUrl
|
| 72 |
|
| 73 |
from src.pipeline.step_001_separate import run_audio_separation
|
|
@@ -116,6 +1069,171 @@ MAX_STATUS_LOG_LINES = int(
|
|
| 116 |
)
|
| 117 |
)
|
| 118 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 119 |
# Only one whole pipeline job executes at a time in this process.
|
| 120 |
#
|
| 121 |
# This is deliberate. Step 2/4 are heavy CPU model workloads, and concurrent
|
|
@@ -201,6 +1319,14 @@ app = FastAPI(
|
|
| 201 |
),
|
| 202 |
)
|
| 203 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 204 |
|
| 205 |
# ============================================================================
|
| 206 |
# GLOBAL JOB EXECUTOR
|
|
@@ -239,6 +1365,18 @@ class CreateProjectRequest(BaseModel):
|
|
| 239 |
description="Public/signed HTTP(S) URL of the source media file.",
|
| 240 |
)
|
| 241 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 242 |
|
| 243 |
class ResumeProjectResponse(BaseModel):
|
| 244 |
project_id: str
|
|
@@ -251,6 +1389,79 @@ class ResumeProjectResponse(BaseModel):
|
|
| 251 |
# LOGGING / PERSISTENT STATE
|
| 252 |
# ============================================================================
|
| 253 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 254 |
class ProjectLogger:
|
| 255 |
"""
|
| 256 |
File-backed project logger.
|
|
@@ -300,16 +1511,31 @@ class ProjectLogger:
|
|
| 300 |
self.logger.propagate = False
|
| 301 |
|
| 302 |
if not self.logger.handlers:
|
| 303 |
-
|
| 304 |
self.log_path,
|
| 305 |
encoding="utf-8",
|
| 306 |
)
|
| 307 |
-
|
| 308 |
-
|
| 309 |
"%(asctime)s | %(levelname)s | %(message)s",
|
|
|
|
| 310 |
)
|
| 311 |
)
|
| 312 |
-
self.logger.addHandler(
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 313 |
|
| 314 |
def write(
|
| 315 |
self,
|
|
@@ -322,6 +1548,31 @@ class ProjectLogger:
|
|
| 322 |
message,
|
| 323 |
)
|
| 324 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 325 |
def recent_lines(
|
| 326 |
self,
|
| 327 |
limit: int = MAX_STATUS_LOG_LINES,
|
|
@@ -340,6 +1591,41 @@ class ProjectLogger:
|
|
| 340 |
|
| 341 |
return lines[-limit:]
|
| 342 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 343 |
|
| 344 |
# ============================================================================
|
| 345 |
# PROJECT STATE
|
|
@@ -375,12 +1661,14 @@ def _default_step_state() -> dict[str, Any]:
|
|
| 375 |
def _initial_state(
|
| 376 |
project_id: str,
|
| 377 |
source_url: str,
|
|
|
|
| 378 |
) -> dict[str, Any]:
|
| 379 |
return {
|
| 380 |
"schema_version": "1.0",
|
| 381 |
"project_id": project_id,
|
| 382 |
"source_url": source_url,
|
| 383 |
"source_file": None,
|
|
|
|
| 384 |
"status": "queued",
|
| 385 |
"current_step": None,
|
| 386 |
"current_step_name": None,
|
|
@@ -502,7 +1790,7 @@ def _record_event(
|
|
| 502 |
|
| 503 |
logs.append(
|
| 504 |
{
|
| 505 |
-
"
|
| 506 |
"message": message,
|
| 507 |
}
|
| 508 |
)
|
|
@@ -715,8 +2003,11 @@ def download_source_file(
|
|
| 715 |
A .part file is used until the download completes. This makes resume/retry
|
| 716 |
safe: an interrupted download is never mistaken for a usable source.
|
| 717 |
"""
|
| 718 |
-
logger.
|
| 719 |
-
|
|
|
|
|
|
|
|
|
|
| 720 |
)
|
| 721 |
|
| 722 |
with httpx.Client(
|
|
@@ -869,10 +2160,13 @@ def download_source_file(
|
|
| 869 |
"Downloaded media file is empty."
|
| 870 |
)
|
| 871 |
|
| 872 |
-
logger.
|
|
|
|
|
|
|
| 873 |
"Download complete: "
|
| 874 |
f"{destination} "
|
| 875 |
-
f"({destination.stat().st_size:,} bytes)"
|
|
|
|
| 876 |
)
|
| 877 |
|
| 878 |
return destination
|
|
@@ -947,6 +2241,181 @@ def _result_to_jsonable(
|
|
| 947 |
)
|
| 948 |
|
| 949 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 950 |
def _run_step_1(
|
| 951 |
project_id: str,
|
| 952 |
source_file: Path,
|
|
@@ -1228,7 +2697,12 @@ def _run_pipeline_job(
|
|
| 1228 |
_record_event(
|
| 1229 |
state,
|
| 1230 |
logger,
|
| 1231 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1232 |
)
|
| 1233 |
|
| 1234 |
try:
|
|
@@ -1252,7 +2726,11 @@ def _run_pipeline_job(
|
|
| 1252 |
_record_event(
|
| 1253 |
state,
|
| 1254 |
logger,
|
| 1255 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1256 |
)
|
| 1257 |
|
| 1258 |
try:
|
|
@@ -1280,13 +2758,25 @@ def _run_pipeline_job(
|
|
| 1280 |
_record_event(
|
| 1281 |
state,
|
| 1282 |
logger,
|
| 1283 |
-
(
|
| 1284 |
-
|
| 1285 |
-
|
|
|
|
|
|
|
| 1286 |
),
|
| 1287 |
level=logging.ERROR,
|
| 1288 |
)
|
| 1289 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1290 |
return
|
| 1291 |
|
| 1292 |
_mark_step_success(
|
|
@@ -1298,7 +2788,21 @@ def _run_pipeline_job(
|
|
| 1298 |
_record_event(
|
| 1299 |
state,
|
| 1300 |
logger,
|
| 1301 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1302 |
)
|
| 1303 |
|
| 1304 |
state["status"] = "completed"
|
|
@@ -1314,7 +2818,20 @@ def _run_pipeline_job(
|
|
| 1314 |
_record_event(
|
| 1315 |
state,
|
| 1316 |
logger,
|
| 1317 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1318 |
)
|
| 1319 |
|
| 1320 |
finally:
|
|
@@ -1361,6 +2878,42 @@ def _submit_pipeline(
|
|
| 1361 |
# API ROUTES
|
| 1362 |
# ============================================================================
|
| 1363 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1364 |
@app.get(
|
| 1365 |
"/health",
|
| 1366 |
)
|
|
@@ -1398,6 +2951,14 @@ async def create_project(
|
|
| 1398 |
request.file_url
|
| 1399 |
)
|
| 1400 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1401 |
paths = project_paths(
|
| 1402 |
project_id
|
| 1403 |
)
|
|
@@ -1431,14 +2992,25 @@ async def create_project(
|
|
| 1431 |
state = _initial_state(
|
| 1432 |
project_id,
|
| 1433 |
source_url,
|
|
|
|
| 1434 |
)
|
| 1435 |
|
| 1436 |
_write_state(
|
| 1437 |
state
|
| 1438 |
)
|
| 1439 |
|
| 1440 |
-
logger.
|
| 1441 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1442 |
)
|
| 1443 |
|
| 1444 |
try:
|
|
@@ -1471,9 +3043,11 @@ async def create_project(
|
|
| 1471 |
_record_event(
|
| 1472 |
state,
|
| 1473 |
logger,
|
| 1474 |
-
(
|
|
|
|
|
|
|
| 1475 |
"Source media downloaded successfully. "
|
| 1476 |
-
"Submitting pipeline."
|
| 1477 |
),
|
| 1478 |
)
|
| 1479 |
|
|
@@ -1495,9 +3069,12 @@ async def create_project(
|
|
| 1495 |
state
|
| 1496 |
)
|
| 1497 |
|
| 1498 |
-
logger.
|
| 1499 |
-
|
| 1500 |
-
|
|
|
|
|
|
|
|
|
|
| 1501 |
)
|
| 1502 |
|
| 1503 |
raise HTTPException(
|
|
@@ -1706,9 +3283,12 @@ async def resume_project(
|
|
| 1706 |
_record_event(
|
| 1707 |
state,
|
| 1708 |
logger,
|
| 1709 |
-
(
|
| 1710 |
-
|
| 1711 |
-
|
|
|
|
|
|
|
|
|
|
| 1712 |
),
|
| 1713 |
)
|
| 1714 |
|
|
@@ -1837,5 +3417,4 @@ async def get_project_subtitle(project_id: str):
|
|
| 1837 |
# PIPELINE_EXECUTOR.shutdown(
|
| 1838 |
# wait=False,
|
| 1839 |
# cancel_futures=False,
|
| 1840 |
-
# )
|
| 1841 |
-
|
|
|
|
| 1 |
+
# # uvicorn main:app --host 0.0.0.0 --port 8000
|
| 2 |
+
# """
|
| 3 |
+
# FastAPI service for the 5-step subtitle pipeline.
|
| 4 |
+
|
| 5 |
+
# Project storage contract:
|
| 6 |
+
|
| 7 |
+
# src/
|
| 8 |
+
# ├── input/
|
| 9 |
+
# │ └── <project_id>/
|
| 10 |
+
# │ └── file.<original-extension>
|
| 11 |
+
# └── output/
|
| 12 |
+
# └── <project_id>/
|
| 13 |
+
# ├── audios/
|
| 14 |
+
# ├── diarization/
|
| 15 |
+
# ├── segments/
|
| 16 |
+
# ├── transcribe/
|
| 17 |
+
# ├── subtitles/
|
| 18 |
+
# ├── service/
|
| 19 |
+
# │ ├── status.json
|
| 20 |
+
# │ └── pipeline.log
|
| 21 |
+
# └── ...
|
| 22 |
+
|
| 23 |
+
# API:
|
| 24 |
+
|
| 25 |
+
# POST /projects
|
| 26 |
+
# Start a new project from a remote HTTP/HTTPS media URL.
|
| 27 |
+
|
| 28 |
+
# GET /projects/{project_id}
|
| 29 |
+
# Return current persistent project status.
|
| 30 |
+
|
| 31 |
+
# GET /projects/{project_id}/logs
|
| 32 |
+
# Return recent project log lines.
|
| 33 |
+
|
| 34 |
+
# POST /projects/{project_id}/resume
|
| 35 |
+
# Resume the failed/interrupted project from the first incomplete step.
|
| 36 |
+
|
| 37 |
+
# GET /health
|
| 38 |
+
# Health check.
|
| 39 |
+
|
| 40 |
+
# The processing job is intentionally single-worker inside this process.
|
| 41 |
+
# That is important for your CPU-heavy ML pipeline because Step 2/4 should not
|
| 42 |
+
# be duplicated into multiple independent OS processes.
|
| 43 |
+
|
| 44 |
+
# For production across multiple machines/processes, move the job execution to
|
| 45 |
+
# a durable queue such as Celery/RQ/Arq with Redis. FastAPI's BackgroundTasks
|
| 46 |
+
# are suitable for smaller/simple background work but FastAPI itself documents
|
| 47 |
+
# a queue/worker system as preferable for heavy background computation.
|
| 48 |
+
# """
|
| 49 |
+
|
| 50 |
+
# from __future__ import annotations
|
| 51 |
+
|
| 52 |
+
# import asyncio
|
| 53 |
+
# import json
|
| 54 |
+
# import logging
|
| 55 |
+
# import os
|
| 56 |
+
# import re
|
| 57 |
+
# import shutil
|
| 58 |
+
# import tempfile
|
| 59 |
+
# import threading
|
| 60 |
+
# import time
|
| 61 |
+
# from concurrent.futures import ThreadPoolExecutor
|
| 62 |
+
# from datetime import datetime, timezone
|
| 63 |
+
# from email.message import Message
|
| 64 |
+
# from pathlib import Path
|
| 65 |
+
# from typing import Any
|
| 66 |
+
# from urllib.parse import urlparse, unquote
|
| 67 |
+
|
| 68 |
+
# import httpx
|
| 69 |
+
# from fastapi import FastAPI, HTTPException, status
|
| 70 |
+
# from fastapi.responses import FileResponse
|
| 71 |
+
# from pydantic import BaseModel, Field, HttpUrl
|
| 72 |
+
|
| 73 |
+
# from src.pipeline.step_001_separate import run_audio_separation
|
| 74 |
+
# from src.pipeline.step_002_diarize import run_diarization
|
| 75 |
+
# from src.pipeline.step_003_segment import run_segment_preparation
|
| 76 |
+
# from src.pipeline.step_004_transcribe import run_transcription
|
| 77 |
+
# from src.pipeline.step_005_original_subtitle import run_step_5
|
| 78 |
+
|
| 79 |
+
# # ============================================================================
|
| 80 |
+
# # CONSOLE & FILE LOGGING SETUP
|
| 81 |
+
# # ============================================================================
|
| 82 |
+
|
| 83 |
+
# logging.basicConfig(
|
| 84 |
+
# level=logging.INFO,
|
| 85 |
+
# format="%(asctime)s | %(levelname)s | %(name)s | %(message)s",
|
| 86 |
+
# )
|
| 87 |
+
# console_logger = logging.getLogger("subtitle-pipeline-console")
|
| 88 |
+
|
| 89 |
+
# # ============================================================================
|
| 90 |
+
# # CONFIGURATION
|
| 91 |
+
# # ============================================================================
|
| 92 |
+
|
| 93 |
+
# BASE_DIR = Path(__file__).resolve().parent
|
| 94 |
+
# SRC_DIR = BASE_DIR / "src"
|
| 95 |
+
|
| 96 |
+
# INPUT_ROOT = SRC_DIR / "input"
|
| 97 |
+
# OUTPUT_ROOT = SRC_DIR / "output"
|
| 98 |
+
|
| 99 |
+
# MAX_DOWNLOAD_BYTES = int(
|
| 100 |
+
# os.getenv(
|
| 101 |
+
# "MAX_DOWNLOAD_BYTES",
|
| 102 |
+
# str(4 * 1024 * 1024 * 1024),
|
| 103 |
+
# )
|
| 104 |
+
# )
|
| 105 |
+
|
| 106 |
+
# DOWNLOAD_CHUNK_BYTES = 1024 * 1024 # 1 MiB
|
| 107 |
+
# HTTP_TIMEOUT_SECONDS = float(
|
| 108 |
+
# os.getenv(
|
| 109 |
+
# "HTTP_TIMEOUT_SECONDS",
|
| 110 |
+
# "120",
|
| 111 |
+
# )
|
| 112 |
+
# )
|
| 113 |
+
|
| 114 |
+
# MAX_LOG_LINES = int(
|
| 115 |
+
# os.getenv(
|
| 116 |
+
# "MAX_LOG_LINES",
|
| 117 |
+
# "5000",
|
| 118 |
+
# )
|
| 119 |
+
# )
|
| 120 |
+
|
| 121 |
+
# MAX_STATUS_LOG_LINES = int(
|
| 122 |
+
# os.getenv(
|
| 123 |
+
# "MAX_STATUS_LOG_LINES",
|
| 124 |
+
# "100",
|
| 125 |
+
# )
|
| 126 |
+
# )
|
| 127 |
+
|
| 128 |
+
# MAX_CONCURRENT_PIPELINES = 1
|
| 129 |
+
|
| 130 |
+
# SUPPORTED_EXTENSIONS = {
|
| 131 |
+
# ".mp3", ".wav", ".flac", ".m4a", ".aac", ".ogg", ".opus", ".wma",
|
| 132 |
+
# ".mp4", ".mkv", ".mov", ".avi", ".webm", ".m4v", ".mpeg", ".mpg",
|
| 133 |
+
# ".ts", ".mts", ".m2ts", ".3gp",
|
| 134 |
+
# }
|
| 135 |
+
|
| 136 |
+
# EXTENSION_BY_CONTENT_TYPE = {
|
| 137 |
+
# "audio/mpeg": ".mp3",
|
| 138 |
+
# "audio/wav": ".wav",
|
| 139 |
+
# "audio/x-wav": ".wav",
|
| 140 |
+
# "audio/flac": ".flac",
|
| 141 |
+
# "audio/x-flac": ".flac",
|
| 142 |
+
# "audio/mp4": ".m4a",
|
| 143 |
+
# "audio/aac": ".aac",
|
| 144 |
+
# "audio/ogg": ".ogg",
|
| 145 |
+
# "audio/opus": ".opus",
|
| 146 |
+
# "video/mp4": ".mp4",
|
| 147 |
+
# "video/quicktime": ".mov",
|
| 148 |
+
# "video/x-matroska": ".mkv",
|
| 149 |
+
# "video/webm": ".webm",
|
| 150 |
+
# "video/mpeg": ".mpg",
|
| 151 |
+
# }
|
| 152 |
+
|
| 153 |
+
# MEDIA_MAGIC_FALLBACK_EXTENSION = ".bin"
|
| 154 |
+
|
| 155 |
+
# STEPS = [
|
| 156 |
+
# {"number": 1, "name": "audio_separation"},
|
| 157 |
+
# {"number": 2, "name": "speaker_diarization"},
|
| 158 |
+
# {"number": 3, "name": "speaker_segment_preparation"},
|
| 159 |
+
# {"number": 4, "name": "speaker_aware_transcription"},
|
| 160 |
+
# {"number": 5, "name": "original_subtitle_generation"},
|
| 161 |
+
# ]
|
| 162 |
+
|
| 163 |
+
# # ============================================================================
|
| 164 |
+
# # FASTAPI APP
|
| 165 |
+
# # ============================================================================
|
| 166 |
+
|
| 167 |
+
# app = FastAPI(
|
| 168 |
+
# title="Subtitle Pipeline Service",
|
| 169 |
+
# version="1.0.0",
|
| 170 |
+
# description=(
|
| 171 |
+
# "Project-based FastAPI orchestration for audio separation, "
|
| 172 |
+
# "speaker diarization, segmentation, transcription, and subtitles."
|
| 173 |
+
# ),
|
| 174 |
+
# )
|
| 175 |
+
|
| 176 |
+
# # ============================================================================
|
| 177 |
+
# # GLOBAL JOB EXECUTOR
|
| 178 |
+
# # ============================================================================
|
| 179 |
+
|
| 180 |
+
# PIPELINE_EXECUTOR = ThreadPoolExecutor(
|
| 181 |
+
# max_workers=MAX_CONCURRENT_PIPELINES,
|
| 182 |
+
# thread_name_prefix="subtitle-pipeline",
|
| 183 |
+
# )
|
| 184 |
+
|
| 185 |
+
# ACTIVE_PROJECTS: set[str] = set()
|
| 186 |
+
# ACTIVE_PROJECTS_LOCK = threading.Lock()
|
| 187 |
+
# PROJECT_EVENTS: dict[str, asyncio.Event] = {}
|
| 188 |
+
# PROJECT_EVENTS_LOCK = threading.Lock()
|
| 189 |
+
|
| 190 |
+
# # ============================================================================
|
| 191 |
+
# # REQUEST MODELS
|
| 192 |
+
# # ============================================================================
|
| 193 |
+
|
| 194 |
+
# class CreateProjectRequest(BaseModel):
|
| 195 |
+
# project_id: str = Field(
|
| 196 |
+
# ...,
|
| 197 |
+
# min_length=1,
|
| 198 |
+
# max_length=128,
|
| 199 |
+
# description="Directory-safe project identifier.",
|
| 200 |
+
# )
|
| 201 |
+
# file_url: HttpUrl = Field(
|
| 202 |
+
# ...,
|
| 203 |
+
# description="Public/signed HTTP(S) URL of the source media file.",
|
| 204 |
+
# )
|
| 205 |
+
|
| 206 |
+
# class ResumeProjectResponse(BaseModel):
|
| 207 |
+
# project_id: str
|
| 208 |
+
# status: str
|
| 209 |
+
# resumed_from_step: int | None
|
| 210 |
+
# message: str
|
| 211 |
+
|
| 212 |
+
# # ============================================================================
|
| 213 |
+
# # LOGGING / PERSISTENT STATE
|
| 214 |
+
# # ============================================================================
|
| 215 |
+
|
| 216 |
+
# class ProjectLogger:
|
| 217 |
+
# def __init__(self, project_id: str) -> None:
|
| 218 |
+
# self.project_id = project_id
|
| 219 |
+
# self.project_dir = OUTPUT_ROOT / project_id
|
| 220 |
+
# self.service_dir = self.project_dir / "service"
|
| 221 |
+
# self.log_path = self.service_dir / "pipeline.log"
|
| 222 |
+
# self.status_path = self.service_dir / "status.json"
|
| 223 |
+
|
| 224 |
+
# self.service_dir.mkdir(parents=True, exist_ok=True)
|
| 225 |
+
# self._lock = threading.Lock()
|
| 226 |
+
|
| 227 |
+
# self.logger = logging.getLogger(f"subtitle-project.{project_id}")
|
| 228 |
+
# self.logger.setLevel(logging.INFO)
|
| 229 |
+
# self.logger.propagate = False
|
| 230 |
+
|
| 231 |
+
# if not self.logger.handlers:
|
| 232 |
+
# handler = logging.FileHandler(self.log_path, encoding="utf-8")
|
| 233 |
+
# handler.setFormatter(
|
| 234 |
+
# logging.Formatter("%(asctime)s | %(levelname)s | %(message)s")
|
| 235 |
+
# )
|
| 236 |
+
# self.logger.addHandler(handler)
|
| 237 |
+
|
| 238 |
+
# def write(self, message: str, level: int = logging.INFO) -> None:
|
| 239 |
+
# with self._lock:
|
| 240 |
+
# self.logger.log(level, message)
|
| 241 |
+
# # Detailed Console Output with Timestamp
|
| 242 |
+
# timestamp = datetime.now(timezone.utc).isoformat()
|
| 243 |
+
# console_msg = f"[{timestamp}] [Project: {self.project_id}] {message}"
|
| 244 |
+
# if level == logging.ERROR:
|
| 245 |
+
# console_logger.error(console_msg)
|
| 246 |
+
# elif level == logging.WARNING:
|
| 247 |
+
# console_logger.warning(console_msg)
|
| 248 |
+
# else:
|
| 249 |
+
# console_logger.info(console_msg)
|
| 250 |
+
|
| 251 |
+
# def recent_lines(self, limit: int = MAX_STATUS_LOG_LINES) -> list[str]:
|
| 252 |
+
# if not self.log_path.exists():
|
| 253 |
+
# return []
|
| 254 |
+
# with self.log_path.open("r", encoding="utf-8", errors="replace") as handle:
|
| 255 |
+
# lines = handle.readlines()
|
| 256 |
+
# return lines[-limit:]
|
| 257 |
+
|
| 258 |
+
# # ============================================================================
|
| 259 |
+
# # PROJECT STATE UTILITIES
|
| 260 |
+
# # ============================================================================
|
| 261 |
+
|
| 262 |
+
# def utc_now() -> str:
|
| 263 |
+
# return datetime.now(timezone.utc).isoformat()
|
| 264 |
+
|
| 265 |
+
# def _project_state_path(project_id: str) -> Path:
|
| 266 |
+
# return OUTPUT_ROOT / project_id / "service" / "status.json"
|
| 267 |
+
|
| 268 |
+
# def _default_step_state() -> dict[str, Any]:
|
| 269 |
+
# return {
|
| 270 |
+
# "status": "pending",
|
| 271 |
+
# "started_at": None,
|
| 272 |
+
# "completed_at": None,
|
| 273 |
+
# "error": None,
|
| 274 |
+
# "result": None,
|
| 275 |
+
# }
|
| 276 |
+
|
| 277 |
+
# def _initial_state(project_id: str, source_url: str) -> dict[str, Any]:
|
| 278 |
+
# return {
|
| 279 |
+
# "schema_version": "1.0",
|
| 280 |
+
# "project_id": project_id,
|
| 281 |
+
# "source_url": source_url,
|
| 282 |
+
# "source_file": None,
|
| 283 |
+
# "status": "queued",
|
| 284 |
+
# "current_step": None,
|
| 285 |
+
# "current_step_name": None,
|
| 286 |
+
# "last_completed_step": 0,
|
| 287 |
+
# "created_at": utc_now(),
|
| 288 |
+
# "updated_at": utc_now(),
|
| 289 |
+
# "completed_at": None,
|
| 290 |
+
# "error": None,
|
| 291 |
+
# "steps": {
|
| 292 |
+
# str(step["number"]): {**step, **_default_step_state()}
|
| 293 |
+
# for step in STEPS
|
| 294 |
+
# },
|
| 295 |
+
# "logs": [],
|
| 296 |
+
# }
|
| 297 |
+
|
| 298 |
+
# def _read_state(project_id: str) -> dict[str, Any] | None:
|
| 299 |
+
# path = _project_state_path(project_id)
|
| 300 |
+
# if not path.exists():
|
| 301 |
+
# return None
|
| 302 |
+
# try:
|
| 303 |
+
# with path.open("r", encoding="utf-8") as handle:
|
| 304 |
+
# return json.load(handle)
|
| 305 |
+
# except json.JSONDecodeError:
|
| 306 |
+
# return None
|
| 307 |
+
|
| 308 |
+
# def _atomic_write_json(path: Path, payload: dict[str, Any]) -> None:
|
| 309 |
+
# path.parent.mkdir(parents=True, exist_ok=True)
|
| 310 |
+
# temp = path.with_name(f".{path.name}.tmp-{os.getpid()}")
|
| 311 |
+
# try:
|
| 312 |
+
# with temp.open("w", encoding="utf-8") as handle:
|
| 313 |
+
# json.dump(payload, handle, indent=2, ensure_ascii=False)
|
| 314 |
+
# handle.write("\n")
|
| 315 |
+
# os.replace(temp, path)
|
| 316 |
+
# finally:
|
| 317 |
+
# temp.unlink(missing_ok=True)
|
| 318 |
+
|
| 319 |
+
# def _write_state(state: dict[str, Any]) -> None:
|
| 320 |
+
# state["updated_at"] = utc_now()
|
| 321 |
+
# state["logs"] = state.get("logs", [])[-MAX_STATUS_LOG_LINES:]
|
| 322 |
+
# _atomic_write_json(_project_state_path(state["project_id"]), state)
|
| 323 |
+
|
| 324 |
+
# with PROJECT_EVENTS_LOCK:
|
| 325 |
+
# event = PROJECT_EVENTS.get(state["project_id"])
|
| 326 |
+
# if event:
|
| 327 |
+
# event.set()
|
| 328 |
+
|
| 329 |
+
# def _record_event(
|
| 330 |
+
# state: dict[str, Any],
|
| 331 |
+
# logger: ProjectLogger,
|
| 332 |
+
# message: str,
|
| 333 |
+
# *,
|
| 334 |
+
# level: int = logging.INFO,
|
| 335 |
+
# ) -> None:
|
| 336 |
+
# logger.write(message, level=level)
|
| 337 |
+
# logs = state.setdefault("logs", [])
|
| 338 |
+
# logs.append({"timestamp": utc_now(), "message": message})
|
| 339 |
+
# state["updated_at"] = utc_now()
|
| 340 |
+
# _write_state(state)
|
| 341 |
+
|
| 342 |
+
# # ============================================================================
|
| 343 |
+
# # PROJECT ID / PATH SAFETY
|
| 344 |
+
# # ============================================================================
|
| 345 |
+
|
| 346 |
+
# PROJECT_ID_PATTERN = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$")
|
| 347 |
+
|
| 348 |
+
# def validate_project_id(project_id: str) -> str:
|
| 349 |
+
# value = str(project_id).strip()
|
| 350 |
+
# if not PROJECT_ID_PATTERN.fullmatch(value):
|
| 351 |
+
# raise HTTPException(
|
| 352 |
+
# status_code=status.HTTP_400_BAD_REQUEST,
|
| 353 |
+
# detail=(
|
| 354 |
+
# "project_id must contain only letters, numbers, "
|
| 355 |
+
# "dot, underscore, and hyphen, and must not exceed 128 characters."
|
| 356 |
+
# ),
|
| 357 |
+
# )
|
| 358 |
+
# return value
|
| 359 |
+
|
| 360 |
+
# def project_paths(project_id: str) -> dict[str, Path]:
|
| 361 |
+
# project_id = validate_project_id(project_id)
|
| 362 |
+
# root = OUTPUT_ROOT / project_id
|
| 363 |
+
# return {
|
| 364 |
+
# "project_dir": root,
|
| 365 |
+
# "input_dir": INPUT_ROOT / project_id,
|
| 366 |
+
# "output_dir": root,
|
| 367 |
+
# "audios": root / "audios",
|
| 368 |
+
# "diarization": root / "diarization",
|
| 369 |
+
# "segments": root / "segments",
|
| 370 |
+
# "transcribe": root / "transcribe",
|
| 371 |
+
# "subtitles": root / "subtitles",
|
| 372 |
+
# "service": root / "service",
|
| 373 |
+
# }
|
| 374 |
+
|
| 375 |
+
# # ============================================================================
|
| 376 |
+
# # URL DOWNLOAD
|
| 377 |
+
# # ============================================================================
|
| 378 |
+
|
| 379 |
+
# def _extension_from_url(url: str) -> str | None:
|
| 380 |
+
# parsed = urlparse(url)
|
| 381 |
+
# name = unquote(Path(parsed.path).name)
|
| 382 |
+
# suffix = Path(name).suffix.lower()
|
| 383 |
+
# if suffix in SUPPORTED_EXTENSIONS:
|
| 384 |
+
# return suffix
|
| 385 |
+
# return None
|
| 386 |
+
|
| 387 |
+
# def _extension_from_content_type(content_type: str | None) -> str | None:
|
| 388 |
+
# if not content_type:
|
| 389 |
+
# return None
|
| 390 |
+
# value = content_type.split(";", 1)[0].strip().lower()
|
| 391 |
+
# return EXTENSION_BY_CONTENT_TYPE.get(value)
|
| 392 |
+
|
| 393 |
+
# def _extension_from_content_disposition(header: str | None) -> str | None:
|
| 394 |
+
# if not header:
|
| 395 |
+
# return None
|
| 396 |
+
# message = Message()
|
| 397 |
+
# message["content-disposition"] = header
|
| 398 |
+
# filename = message.get_filename()
|
| 399 |
+
# if not filename:
|
| 400 |
+
# return None
|
| 401 |
+
# suffix = Path(filename).suffix.lower()
|
| 402 |
+
# if suffix in SUPPORTED_EXTENSIONS:
|
| 403 |
+
# return suffix
|
| 404 |
+
# return None
|
| 405 |
+
|
| 406 |
+
# def _sniff_extension(first_bytes: bytes) -> str | None:
|
| 407 |
+
# if first_bytes.startswith(b"RIFF") and first_bytes[8:12] == b"WAVE":
|
| 408 |
+
# return ".wav"
|
| 409 |
+
# if first_bytes.startswith(b"ID3"):
|
| 410 |
+
# return ".mp3"
|
| 411 |
+
# if first_bytes.startswith(b"OggS"):
|
| 412 |
+
# return ".ogg"
|
| 413 |
+
# if first_bytes.startswith(b"fLaC"):
|
| 414 |
+
# return ".flac"
|
| 415 |
+
# if len(first_bytes) >= 12:
|
| 416 |
+
# if first_bytes[4:8] == b"ftyp":
|
| 417 |
+
# return ".mp4"
|
| 418 |
+
# if first_bytes.startswith(b"\x1a\x45\xdf\xa3"):
|
| 419 |
+
# return ".mkv"
|
| 420 |
+
# return None
|
| 421 |
+
|
| 422 |
+
# def _safe_download_destination(project_id: str, extension: str) -> Path:
|
| 423 |
+
# paths = project_paths(project_id)
|
| 424 |
+
# input_dir = paths["input_dir"]
|
| 425 |
+
# input_dir.mkdir(parents=True, exist_ok=True)
|
| 426 |
+
# return input_dir / f"file{extension}"
|
| 427 |
+
|
| 428 |
+
# def download_source_file(
|
| 429 |
+
# project_id: str,
|
| 430 |
+
# source_url: str,
|
| 431 |
+
# logger: ProjectLogger,
|
| 432 |
+
# ) -> Path:
|
| 433 |
+
# logger.write(f"Downloading source media: {source_url}")
|
| 434 |
+
# with httpx.Client(
|
| 435 |
+
# follow_redirects=True,
|
| 436 |
+
# timeout=httpx.Timeout(HTTP_TIMEOUT_SECONDS),
|
| 437 |
+
# headers={"User-Agent": "subtitle-pipeline/1.0 (media downloader)"},
|
| 438 |
+
# ) as client:
|
| 439 |
+
# try:
|
| 440 |
+
# with client.stream("GET", source_url) as response:
|
| 441 |
+
# response.raise_for_status()
|
| 442 |
+
# content_type = response.headers.get("content-type")
|
| 443 |
+
# content_length_header = response.headers.get("content-length")
|
| 444 |
+
|
| 445 |
+
# if content_length_header:
|
| 446 |
+
# try:
|
| 447 |
+
# content_length = int(content_length_header)
|
| 448 |
+
# except ValueError:
|
| 449 |
+
# content_length = None
|
| 450 |
+
|
| 451 |
+
# if content_length is not None and content_length > MAX_DOWNLOAD_BYTES:
|
| 452 |
+
# raise ValueError("Remote media exceeds MAX_DOWNLOAD_BYTES.")
|
| 453 |
+
|
| 454 |
+
# extension = (
|
| 455 |
+
# _extension_from_content_disposition(response.headers.get("content-disposition"))
|
| 456 |
+
# or _extension_from_url(source_url)
|
| 457 |
+
# or _extension_from_content_type(content_type)
|
| 458 |
+
# )
|
| 459 |
+
|
| 460 |
+
# if not extension:
|
| 461 |
+
# extension = MEDIA_MAGIC_FALLBACK_EXTENSION
|
| 462 |
+
|
| 463 |
+
# destination = _safe_download_destination(project_id, extension)
|
| 464 |
+
# partial = destination.with_suffix(destination.suffix + ".part")
|
| 465 |
+
# partial.unlink(missing_ok=True)
|
| 466 |
+
|
| 467 |
+
# total = 0
|
| 468 |
+
# sniff = bytearray()
|
| 469 |
+
|
| 470 |
+
# with partial.open("wb") as output:
|
| 471 |
+
# for chunk in response.iter_bytes(chunk_size=DOWNLOAD_CHUNK_BYTES):
|
| 472 |
+
# if not chunk:
|
| 473 |
+
# continue
|
| 474 |
+
# if len(sniff) < 64:
|
| 475 |
+
# needed = 64 - len(sniff)
|
| 476 |
+
# sniff.extend(chunk[:needed])
|
| 477 |
+
# total += len(chunk)
|
| 478 |
+
# if total > MAX_DOWNLOAD_BYTES:
|
| 479 |
+
# raise ValueError("Remote media exceeded MAX_DOWNLOAD_BYTES.")
|
| 480 |
+
# output.write(chunk)
|
| 481 |
+
|
| 482 |
+
# if destination.suffix == MEDIA_MAGIC_FALLBACK_EXTENSION:
|
| 483 |
+
# sniffed = _sniff_extension(bytes(sniff))
|
| 484 |
+
# if sniffed:
|
| 485 |
+
# sniffed_destination = destination.with_suffix(sniffed)
|
| 486 |
+
# sniffed_destination.unlink(missing_ok=True)
|
| 487 |
+
# os.replace(partial, sniffed_destination)
|
| 488 |
+
# destination = sniffed_destination
|
| 489 |
+
# else:
|
| 490 |
+
# os.replace(partial, destination)
|
| 491 |
+
# else:
|
| 492 |
+
# os.replace(partial, destination)
|
| 493 |
+
|
| 494 |
+
# if destination.stat().st_size <= 0:
|
| 495 |
+
# raise ValueError("Downloaded media file is empty.")
|
| 496 |
+
|
| 497 |
+
# logger.write(
|
| 498 |
+
# f"Download complete: {destination} ({destination.stat().st_size:,} bytes)"
|
| 499 |
+
# )
|
| 500 |
+
# return destination
|
| 501 |
+
|
| 502 |
+
# except Exception:
|
| 503 |
+
# try:
|
| 504 |
+
# if "partial" in locals():
|
| 505 |
+
# partial.unlink(missing_ok=True)
|
| 506 |
+
# except Exception:
|
| 507 |
+
# pass
|
| 508 |
+
# raise
|
| 509 |
+
|
| 510 |
+
# # ============================================================================
|
| 511 |
+
# # PIPELINE EXECUTION
|
| 512 |
+
# # ============================================================================
|
| 513 |
+
|
| 514 |
+
# def _result_to_jsonable(value: Any) -> Any:
|
| 515 |
+
# if value is None:
|
| 516 |
+
# return None
|
| 517 |
+
# if isinstance(value, (str, int, float, bool)):
|
| 518 |
+
# return value
|
| 519 |
+
# if isinstance(value, Path):
|
| 520 |
+
# return str(value)
|
| 521 |
+
# if isinstance(value, dict):
|
| 522 |
+
# return {str(key): _result_to_jsonable(item) for key, item in value.items()}
|
| 523 |
+
# if isinstance(value, (list, tuple)):
|
| 524 |
+
# return [_result_to_jsonable(item) for item in value]
|
| 525 |
+
# return str(value)
|
| 526 |
+
|
| 527 |
+
# def _run_step_1(project_id: str, source_file: Path) -> dict[str, Any]:
|
| 528 |
+
# paths = project_paths(project_id)
|
| 529 |
+
# vocal_path, instrument_path = run_audio_separation(
|
| 530 |
+
# input_file_path=source_file,
|
| 531 |
+
# output_dir=paths["audios"],
|
| 532 |
+
# )
|
| 533 |
+
# return {"vocal_path": str(vocal_path), "instrument_path": str(instrument_path)}
|
| 534 |
+
|
| 535 |
+
# def _run_step_2(project_id: str) -> dict[str, Any]:
|
| 536 |
+
# return run_diarization(project_id)
|
| 537 |
+
|
| 538 |
+
# def _run_step_3(project_id: str) -> dict[str, Any]:
|
| 539 |
+
# return run_segment_preparation(project_id)
|
| 540 |
+
|
| 541 |
+
# def _run_step_4(project_id: str) -> dict[str, Any]:
|
| 542 |
+
# return run_transcription(project_id)
|
| 543 |
+
|
| 544 |
+
# def _run_step_5(project_id: str) -> dict[str, Any]:
|
| 545 |
+
# return run_step_5(project_id)
|
| 546 |
+
|
| 547 |
+
# def _step_function(number: int):
|
| 548 |
+
# return {
|
| 549 |
+
# 1: _run_step_1,
|
| 550 |
+
# 2: _run_step_2,
|
| 551 |
+
# 3: _run_step_3,
|
| 552 |
+
# 4: _run_step_4,
|
| 553 |
+
# 5: _run_step_5,
|
| 554 |
+
# }[number]
|
| 555 |
+
|
| 556 |
+
# def _first_incomplete_step(state: dict[str, Any]) -> int | None:
|
| 557 |
+
# for step in STEPS:
|
| 558 |
+
# step_state = state["steps"][str(step["number"])]
|
| 559 |
+
# if step_state.get("status") != "success":
|
| 560 |
+
# return step["number"]
|
| 561 |
+
# return None
|
| 562 |
+
|
| 563 |
+
# def _mark_step_started(state: dict[str, Any], step_number: int) -> None:
|
| 564 |
+
# step_state = state["steps"][str(step_number)]
|
| 565 |
+
# step_state["status"] = "running"
|
| 566 |
+
# step_state["started_at"] = utc_now()
|
| 567 |
+
# step_state["completed_at"] = None
|
| 568 |
+
# step_state["error"] = None
|
| 569 |
+
|
| 570 |
+
# state["status"] = "running"
|
| 571 |
+
# state["current_step"] = step_number
|
| 572 |
+
# state["current_step_name"] = STEPS[step_number - 1]["name"]
|
| 573 |
+
# _write_state(state)
|
| 574 |
+
|
| 575 |
+
# def _mark_step_success(state: dict[str, Any], step_number: int, result: Any) -> None:
|
| 576 |
+
# step_state = state["steps"][str(step_number)]
|
| 577 |
+
# step_state["status"] = "success"
|
| 578 |
+
# step_state["completed_at"] = utc_now()
|
| 579 |
+
# step_state["error"] = None
|
| 580 |
+
# step_state["result"] = _result_to_jsonable(result)
|
| 581 |
+
|
| 582 |
+
# state["last_completed_step"] = max(state.get("last_completed_step", 0), step_number)
|
| 583 |
+
# state["error"] = None
|
| 584 |
+
# _write_state(state)
|
| 585 |
+
|
| 586 |
+
# def _mark_step_failed(state: dict[str, Any], step_number: int, error: Exception) -> None:
|
| 587 |
+
# step_state = state["steps"][str(step_number)]
|
| 588 |
+
# step_state["status"] = "failed"
|
| 589 |
+
# step_state["completed_at"] = utc_now()
|
| 590 |
+
# step_state["error"] = str(error)
|
| 591 |
+
|
| 592 |
+
# state["status"] = "failed"
|
| 593 |
+
# state["error"] = str(error)
|
| 594 |
+
# state["current_step"] = step_number
|
| 595 |
+
# state["current_step_name"] = STEPS[step_number - 1]["name"]
|
| 596 |
+
# _write_state(state)
|
| 597 |
+
|
| 598 |
+
# def _run_pipeline_job(project_id: str) -> None:
|
| 599 |
+
# logger = ProjectLogger(project_id)
|
| 600 |
+
# state = _read_state(project_id)
|
| 601 |
+
|
| 602 |
+
# if not state:
|
| 603 |
+
# logger.write("Cannot start pipeline: project state missing.", logging.ERROR)
|
| 604 |
+
# return
|
| 605 |
+
|
| 606 |
+
# source_file_value = state.get("source_file")
|
| 607 |
+
# if not source_file_value:
|
| 608 |
+
# state["status"] = "failed"
|
| 609 |
+
# state["error"] = "source_file missing from project state."
|
| 610 |
+
# _write_state(state)
|
| 611 |
+
# logger.write("Pipeline aborted: source_file missing.", logging.ERROR)
|
| 612 |
+
# return
|
| 613 |
+
|
| 614 |
+
# source_file = Path(source_file_value)
|
| 615 |
+
# if not source_file.exists():
|
| 616 |
+
# state["status"] = "failed"
|
| 617 |
+
# state["error"] = f"Source file not found: {source_file}"
|
| 618 |
+
# _write_state(state)
|
| 619 |
+
# logger.write(state["error"], logging.ERROR)
|
| 620 |
+
# return
|
| 621 |
+
|
| 622 |
+
# start_step = _first_incomplete_step(state)
|
| 623 |
+
# if start_step is None:
|
| 624 |
+
# state["status"] = "completed"
|
| 625 |
+
# state["current_step"] = None
|
| 626 |
+
# state["current_step_name"] = None
|
| 627 |
+
# state["completed_at"] = state.get("completed_at") or utc_now()
|
| 628 |
+
# _write_state(state)
|
| 629 |
+
# logger.write("Project already completed; nothing to do.")
|
| 630 |
+
# return
|
| 631 |
+
|
| 632 |
+
# _record_event(state, logger, f"Pipeline starting from Step {start_step}.")
|
| 633 |
+
|
| 634 |
+
# try:
|
| 635 |
+
# for number in range(start_step, 6):
|
| 636 |
+
# step = STEPS[number - 1]
|
| 637 |
+
# step_name = step["name"]
|
| 638 |
+
|
| 639 |
+
# _mark_step_started(state, number)
|
| 640 |
+
# _record_event(state, logger, f"STEP {number:03d} STARTED | {step_name}")
|
| 641 |
+
|
| 642 |
+
# try:
|
| 643 |
+
# if number == 1:
|
| 644 |
+
# result = _step_function(number)(project_id, source_file)
|
| 645 |
+
# else:
|
| 646 |
+
# result = _step_function(number)(project_id)
|
| 647 |
+
# except Exception as exc:
|
| 648 |
+
# _mark_step_failed(state, number, exc)
|
| 649 |
+
# _record_event(
|
| 650 |
+
# state,
|
| 651 |
+
# logger,
|
| 652 |
+
# f"STEP {number:03d} FAILED | {step_name} | {exc}",
|
| 653 |
+
# level=logging.ERROR,
|
| 654 |
+
# )
|
| 655 |
+
# return
|
| 656 |
+
|
| 657 |
+
# _mark_step_success(state, number, result)
|
| 658 |
+
# _record_event(state, logger, f"STEP {number:03d} COMPLETED | {step_name}")
|
| 659 |
+
|
| 660 |
+
# state["status"] = "completed"
|
| 661 |
+
# state["current_step"] = None
|
| 662 |
+
# state["current_step_name"] = None
|
| 663 |
+
# state["completed_at"] = utc_now()
|
| 664 |
+
# state["error"] = None
|
| 665 |
+
# _write_state(state)
|
| 666 |
+
# _record_event(state, logger, "PIPELINE COMPLETED SUCCESSFULLY.")
|
| 667 |
+
|
| 668 |
+
# finally:
|
| 669 |
+
# with ACTIVE_PROJECTS_LOCK:
|
| 670 |
+
# ACTIVE_PROJECTS.discard(project_id)
|
| 671 |
+
|
| 672 |
+
# def _submit_pipeline(project_id: str) -> None:
|
| 673 |
+
# with ACTIVE_PROJECTS_LOCK:
|
| 674 |
+
# if project_id in ACTIVE_PROJECTS:
|
| 675 |
+
# raise RuntimeError("Project is already running.")
|
| 676 |
+
# ACTIVE_PROJECTS.add(project_id)
|
| 677 |
+
|
| 678 |
+
# try:
|
| 679 |
+
# PIPELINE_EXECUTOR.submit(_run_pipeline_job, project_id)
|
| 680 |
+
# except Exception:
|
| 681 |
+
# with ACTIVE_PROJECTS_LOCK:
|
| 682 |
+
# ACTIVE_PROJECTS.discard(project_id)
|
| 683 |
+
# raise
|
| 684 |
+
|
| 685 |
+
# # ============================================================================
|
| 686 |
+
# # API ROUTES
|
| 687 |
+
# # ============================================================================
|
| 688 |
+
|
| 689 |
+
# @app.get("/")
|
| 690 |
+
# async def root() -> dict[str, Any]:
|
| 691 |
+
# console_logger.info(f"[{utc_now()}] Root endpoint '/' accessed.")
|
| 692 |
+
# with ACTIVE_PROJECTS_LOCK:
|
| 693 |
+
# active_count = len(ACTIVE_PROJECTS)
|
| 694 |
+
# return {
|
| 695 |
+
# "message": "Subtitle Pipeline Service is active and running.",
|
| 696 |
+
# "version": "1.0.0",
|
| 697 |
+
# "active_projects_count": active_count,
|
| 698 |
+
# "docs_url": "/docs",
|
| 699 |
+
# "health_check": "/health"
|
| 700 |
+
# }
|
| 701 |
+
|
| 702 |
+
# @app.get("/health")
|
| 703 |
+
# async def health() -> dict[str, Any]:
|
| 704 |
+
# with ACTIVE_PROJECTS_LOCK:
|
| 705 |
+
# active = sorted(ACTIVE_PROJECTS)
|
| 706 |
+
# console_logger.info(f"[{utc_now()}] Health check endpoint accessed.")
|
| 707 |
+
# return {
|
| 708 |
+
# "status": "ok",
|
| 709 |
+
# "service": "subtitle-pipeline",
|
| 710 |
+
# "active_projects": active,
|
| 711 |
+
# "max_concurrent_pipelines": MAX_CONCURRENT_PIPELINES,
|
| 712 |
+
# "output_root": str(OUTPUT_ROOT.resolve()),
|
| 713 |
+
# }
|
| 714 |
+
|
| 715 |
+
# @app.post("/projects", status_code=status.HTTP_202_ACCEPTED)
|
| 716 |
+
# async def create_project(request: CreateProjectRequest) -> dict[str, Any]:
|
| 717 |
+
# project_id = validate_project_id(request.project_id)
|
| 718 |
+
# source_url = str(request.file_url)
|
| 719 |
+
# paths = project_paths(project_id)
|
| 720 |
+
|
| 721 |
+
# paths["service"].mkdir(parents=True, exist_ok=True)
|
| 722 |
+
# existing = _read_state(project_id)
|
| 723 |
+
|
| 724 |
+
# if existing:
|
| 725 |
+
# raise HTTPException(
|
| 726 |
+
# status_code=status.HTTP_409_CONFLICT,
|
| 727 |
+
# detail=f"Project '{project_id}' already exists. Use GET for status or POST /resume.",
|
| 728 |
+
# )
|
| 729 |
+
|
| 730 |
+
# logger = ProjectLogger(project_id)
|
| 731 |
+
# state = _initial_state(project_id, source_url)
|
| 732 |
+
# _write_state(state)
|
| 733 |
+
# logger.write(f"Project created: {project_id}")
|
| 734 |
+
|
| 735 |
+
# try:
|
| 736 |
+
# source_file = await asyncio.to_thread(
|
| 737 |
+
# download_source_file,
|
| 738 |
+
# project_id,
|
| 739 |
+
# source_url,
|
| 740 |
+
# logger,
|
| 741 |
+
# )
|
| 742 |
+
|
| 743 |
+
# state = _read_state(project_id)
|
| 744 |
+
# if not state:
|
| 745 |
+
# raise RuntimeError("Project state disappeared after download.")
|
| 746 |
+
|
| 747 |
+
# state["source_file"] = str(source_file.resolve())
|
| 748 |
+
# state["status"] = "queued"
|
| 749 |
+
# state["error"] = None
|
| 750 |
+
# _write_state(state)
|
| 751 |
+
|
| 752 |
+
# _record_event(state, logger, "Source media downloaded successfully. Submitting pipeline.")
|
| 753 |
+
# _submit_pipeline(project_id)
|
| 754 |
+
|
| 755 |
+
# except Exception as exc:
|
| 756 |
+
# state = _read_state(project_id) or state
|
| 757 |
+
# state["status"] = "failed"
|
| 758 |
+
# state["error"] = str(exc)
|
| 759 |
+
# _write_state(state)
|
| 760 |
+
# logger.write(f"PROJECT CREATION FAILED | {exc}", level=logging.ERROR)
|
| 761 |
+
# raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) from exc
|
| 762 |
+
|
| 763 |
+
# return {
|
| 764 |
+
# "project_id": project_id,
|
| 765 |
+
# "status": "queued",
|
| 766 |
+
# "source_file": state["source_file"],
|
| 767 |
+
# "status_url": f"/projects/{project_id}",
|
| 768 |
+
# "logs_url": f"/projects/{project_id}/logs",
|
| 769 |
+
# "resume_url": f"/projects/{project_id}/resume",
|
| 770 |
+
# }
|
| 771 |
+
|
| 772 |
+
# @app.get("/projects/{project_id}")
|
| 773 |
+
# async def get_project(project_id: str) -> dict[str, Any]:
|
| 774 |
+
# project_id = validate_project_id(project_id)
|
| 775 |
+
# state = _read_state(project_id)
|
| 776 |
+
|
| 777 |
+
# if not state:
|
| 778 |
+
# raise HTTPException(
|
| 779 |
+
# status_code=status.HTTP_404_NOT_FOUND,
|
| 780 |
+
# detail=f"Project '{project_id}' does not exist.",
|
| 781 |
+
# )
|
| 782 |
+
|
| 783 |
+
# with ACTIVE_PROJECTS_LOCK:
|
| 784 |
+
# running = project_id in ACTIVE_PROJECTS
|
| 785 |
+
|
| 786 |
+
# state["runtime"] = {"running_in_this_process": running}
|
| 787 |
+
# return state
|
| 788 |
+
|
| 789 |
+
# @app.get("/projects/{project_id}/logs")
|
| 790 |
+
# async def get_project_logs(project_id: str, limit: int = 100) -> dict[str, Any]:
|
| 791 |
+
# project_id = validate_project_id(project_id)
|
| 792 |
+
# state = _read_state(project_id)
|
| 793 |
+
|
| 794 |
+
# if not state:
|
| 795 |
+
# raise HTTPException(
|
| 796 |
+
# status_code=status.HTTP_404_NOT_FOUND,
|
| 797 |
+
# detail=f"Project '{project_id}' does not exist.",
|
| 798 |
+
# )
|
| 799 |
+
|
| 800 |
+
# limit = max(1, min(limit, MAX_LOG_LINES))
|
| 801 |
+
# logger = ProjectLogger(project_id)
|
| 802 |
+
|
| 803 |
+
# return {
|
| 804 |
+
# "project_id": project_id,
|
| 805 |
+
# "log_file": str(logger.log_path.resolve()),
|
| 806 |
+
# "lines": logger.recent_lines(limit),
|
| 807 |
+
# }
|
| 808 |
+
|
| 809 |
+
# @app.post("/projects/{project_id}/resume", response_model=ResumeProjectResponse)
|
| 810 |
+
# async def resume_project(project_id: str) -> ResumeProjectResponse:
|
| 811 |
+
# project_id = validate_project_id(project_id)
|
| 812 |
+
# state = _read_state(project_id)
|
| 813 |
+
|
| 814 |
+
# if not state:
|
| 815 |
+
# raise HTTPException(
|
| 816 |
+
# status_code=status.HTTP_404_NOT_FOUND,
|
| 817 |
+
# detail=f"Project '{project_id}' does not exist.",
|
| 818 |
+
# )
|
| 819 |
+
|
| 820 |
+
# if not state.get("source_file"):
|
| 821 |
+
# raise HTTPException(
|
| 822 |
+
# status_code=status.HTTP_409_CONFLICT,
|
| 823 |
+
# detail="Cannot resume because the source media file is missing from project state.",
|
| 824 |
+
# )
|
| 825 |
+
|
| 826 |
+
# if not Path(state["source_file"]).exists():
|
| 827 |
+
# raise HTTPException(
|
| 828 |
+
# status_code=status.HTTP_409_CONFLICT,
|
| 829 |
+
# detail="Cannot resume because the downloaded source file no longer exists.",
|
| 830 |
+
# )
|
| 831 |
+
|
| 832 |
+
# with ACTIVE_PROJECTS_LOCK:
|
| 833 |
+
# if project_id in ACTIVE_PROJECTS:
|
| 834 |
+
# raise HTTPException(
|
| 835 |
+
# status_code=status.HTTP_409_CONFLICT,
|
| 836 |
+
# detail="Project is already running.",
|
| 837 |
+
# )
|
| 838 |
+
|
| 839 |
+
# start_step = _first_incomplete_step(state)
|
| 840 |
+
# if start_step is None:
|
| 841 |
+
# return ResumeProjectResponse(
|
| 842 |
+
# project_id=project_id,
|
| 843 |
+
# status="completed",
|
| 844 |
+
# resumed_from_step=None,
|
| 845 |
+
# message="Project is already complete.",
|
| 846 |
+
# )
|
| 847 |
+
|
| 848 |
+
# step_state = state["steps"][str(start_step)]
|
| 849 |
+
# step_state["status"] = "pending"
|
| 850 |
+
# step_state["error"] = None
|
| 851 |
+
# step_state["started_at"] = None
|
| 852 |
+
# step_state["completed_at"] = None
|
| 853 |
+
|
| 854 |
+
# state["status"] = "queued"
|
| 855 |
+
# state["error"] = None
|
| 856 |
+
# state["current_step"] = start_step
|
| 857 |
+
# state["current_step_name"] = STEPS[start_step - 1]["name"]
|
| 858 |
+
# _write_state(state)
|
| 859 |
+
|
| 860 |
+
# logger = ProjectLogger(project_id)
|
| 861 |
+
# _record_event(
|
| 862 |
+
# state,
|
| 863 |
+
# logger,
|
| 864 |
+
# f"RESUME REQUESTED | restarting from Step {start_step:03d} | {STEPS[start_step - 1]['name']}",
|
| 865 |
+
# )
|
| 866 |
+
|
| 867 |
+
# try:
|
| 868 |
+
# _submit_pipeline(project_id)
|
| 869 |
+
# except RuntimeError as exc:
|
| 870 |
+
# raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail=str(exc)) from exc
|
| 871 |
+
|
| 872 |
+
# return ResumeProjectResponse(
|
| 873 |
+
# project_id=project_id,
|
| 874 |
+
# status="queued",
|
| 875 |
+
# resumed_from_step=start_step,
|
| 876 |
+
# message=f"Project queued for resume from Step {start_step:03d}.",
|
| 877 |
+
# )
|
| 878 |
+
|
| 879 |
+
# @app.get("/projects/{project_id}/subtitle")
|
| 880 |
+
# async def get_project_subtitle(project_id: str):
|
| 881 |
+
# project_id = validate_project_id(project_id)
|
| 882 |
+
# state = _read_state(project_id)
|
| 883 |
+
|
| 884 |
+
# if not state:
|
| 885 |
+
# raise HTTPException(
|
| 886 |
+
# status_code=status.HTTP_404_NOT_FOUND,
|
| 887 |
+
# detail={
|
| 888 |
+
# "status": "not_found",
|
| 889 |
+
# "message": f"Project '{project_id}' does not exist.",
|
| 890 |
+
# },
|
| 891 |
+
# )
|
| 892 |
+
|
| 893 |
+
# step_5 = state.get("steps", {}).get("5", {})
|
| 894 |
+
# step_5_result = step_5.get("result") or {}
|
| 895 |
+
# srt_path_value = step_5_result.get("srt_path")
|
| 896 |
+
|
| 897 |
+
# if srt_path_value:
|
| 898 |
+
# srt_path = Path(srt_path_value)
|
| 899 |
+
# if srt_path.exists() and srt_path.is_file():
|
| 900 |
+
# return FileResponse(
|
| 901 |
+
# path=str(srt_path),
|
| 902 |
+
# media_type="application/x-subrip",
|
| 903 |
+
# filename=srt_path.name,
|
| 904 |
+
# headers={"Content-Disposition": f'attachment; filename="{srt_path.name}"'},
|
| 905 |
+
# )
|
| 906 |
+
|
| 907 |
+
# subtitles_dir = OUTPUT_ROOT / project_id / "subtitles"
|
| 908 |
+
# if subtitles_dir.exists():
|
| 909 |
+
# srt_files = sorted(
|
| 910 |
+
# subtitles_dir.glob("*.srt"),
|
| 911 |
+
# key=lambda path: path.stat().st_mtime,
|
| 912 |
+
# reverse=True,
|
| 913 |
+
# )
|
| 914 |
+
# if srt_files:
|
| 915 |
+
# srt_path = srt_files[0]
|
| 916 |
+
# return FileResponse(
|
| 917 |
+
# path=str(srt_path),
|
| 918 |
+
# media_type="application/x-subrip",
|
| 919 |
+
# filename=srt_path.name,
|
| 920 |
+
# headers={"Content-Disposition": f'attachment; filename="{srt_path.name}"'},
|
| 921 |
+
# )
|
| 922 |
+
|
| 923 |
+
# logger = ProjectLogger(project_id)
|
| 924 |
+
# return {
|
| 925 |
+
# "status": "not_ready",
|
| 926 |
+
# "message": "Subtitle file has not been created yet.",
|
| 927 |
+
# "project_id": project_id,
|
| 928 |
+
# "project_status": state.get("status"),
|
| 929 |
+
# "current_step": state.get("current_step"),
|
| 930 |
+
# "current_step_name": state.get("current_step_name"),
|
| 931 |
+
# "last_completed_step": state.get("last_completed_step", 0),
|
| 932 |
+
# "subtitle_exists": False,
|
| 933 |
+
# "error": state.get("error"),
|
| 934 |
+
# "logs": logger.recent_lines(20),
|
| 935 |
+
# }
|
| 936 |
+
|
| 937 |
+
# '''
|
| 938 |
+
|
| 939 |
+
|
| 940 |
+
# curl -X POST \
|
| 941 |
+
# "https://friendly-space-train-xrvvggqpg95jc57j-8000.app.github.dev/projects" \
|
| 942 |
+
# -H "Content-Type: application/json" \
|
| 943 |
+
# -d '{
|
| 944 |
+
# "project_id": "test-conversation-002",
|
| 945 |
+
# "file_url": "https://huggingface.co/datasets/zhangjinyang/TalkingHead-1KH-audio/resolve/main/val_cropped_clips/1lSejjfNHpw_0075_S1_E728_L671_T47_R1471_B847.mp4"
|
| 946 |
+
# }'
|
| 947 |
+
|
| 948 |
+
# '''
|
| 949 |
+
|
| 950 |
+
|
| 951 |
# uvicorn main:app --host 0.0.0.0 --port 8000
|
| 952 |
"""
|
| 953 |
FastAPI service for the 5-step subtitle pipeline.
|
|
|
|
| 1000 |
from __future__ import annotations
|
| 1001 |
|
| 1002 |
import asyncio
|
| 1003 |
+
import copy
|
| 1004 |
import json
|
| 1005 |
import logging
|
| 1006 |
import os
|
| 1007 |
import re
|
| 1008 |
import shutil
|
| 1009 |
+
import sys
|
| 1010 |
import tempfile
|
| 1011 |
import threading
|
| 1012 |
import time
|
| 1013 |
from concurrent.futures import ThreadPoolExecutor
|
| 1014 |
+
from datetime import datetime, timedelta, timezone
|
| 1015 |
from email.message import Message
|
| 1016 |
from pathlib import Path
|
| 1017 |
from typing import Any
|
|
|
|
| 1020 |
import httpx
|
| 1021 |
from fastapi import FastAPI, HTTPException, status
|
| 1022 |
from fastapi.responses import FileResponse
|
| 1023 |
+
from fastapi.middleware.cors import CORSMiddleware
|
| 1024 |
from pydantic import BaseModel, Field, HttpUrl
|
| 1025 |
|
| 1026 |
from src.pipeline.step_001_separate import run_audio_separation
|
|
|
|
| 1069 |
)
|
| 1070 |
)
|
| 1071 |
|
| 1072 |
+
# Webhook (optional, sent per-project) delivery timeout.
|
| 1073 |
+
WEBHOOK_TIMEOUT_SECONDS = float(
|
| 1074 |
+
os.getenv(
|
| 1075 |
+
"WEBHOOK_TIMEOUT_SECONDS",
|
| 1076 |
+
"15",
|
| 1077 |
+
)
|
| 1078 |
+
)
|
| 1079 |
+
|
| 1080 |
+
# Whether logs are also mirrored to stdout (colorized) in addition to the
|
| 1081 |
+
# per-project log file on disk.
|
| 1082 |
+
LOG_TO_CONSOLE = os.getenv(
|
| 1083 |
+
"LOG_TO_CONSOLE",
|
| 1084 |
+
"true",
|
| 1085 |
+
).strip().lower() in {
|
| 1086 |
+
"1",
|
| 1087 |
+
"true",
|
| 1088 |
+
"yes",
|
| 1089 |
+
}
|
| 1090 |
+
|
| 1091 |
+
# ============================================================================
|
| 1092 |
+
# TIMEZONE (INDIAN STANDARD TIME) — all human-facing log timestamps use IST.
|
| 1093 |
+
# ============================================================================
|
| 1094 |
+
|
| 1095 |
+
IST_TZ = timezone(
|
| 1096 |
+
timedelta(
|
| 1097 |
+
hours=5,
|
| 1098 |
+
minutes=30,
|
| 1099 |
+
),
|
| 1100 |
+
name="IST",
|
| 1101 |
+
)
|
| 1102 |
+
|
| 1103 |
+
|
| 1104 |
+
def ist_now() -> datetime:
|
| 1105 |
+
return datetime.now(
|
| 1106 |
+
IST_TZ
|
| 1107 |
+
)
|
| 1108 |
+
|
| 1109 |
+
|
| 1110 |
+
def ist_now_iso() -> str:
|
| 1111 |
+
return ist_now().isoformat()
|
| 1112 |
+
|
| 1113 |
+
|
| 1114 |
+
def ist_now_display() -> str:
|
| 1115 |
+
return ist_now().strftime(
|
| 1116 |
+
"%Y-%m-%d %H:%M:%S.%f"
|
| 1117 |
+
)[:-3] + " IST"
|
| 1118 |
+
|
| 1119 |
+
|
| 1120 |
+
# ============================================================================
|
| 1121 |
+
# ANSI COLORS — used to make per-step / per-event log lines colorful.
|
| 1122 |
+
# ============================================================================
|
| 1123 |
+
|
| 1124 |
+
class Ansi:
|
| 1125 |
+
RESET = "\033[0m"
|
| 1126 |
+
BOLD = "\033[1m"
|
| 1127 |
+
DIM = "\033[2m"
|
| 1128 |
+
|
| 1129 |
+
BLACK = "\033[30m"
|
| 1130 |
+
RED = "\033[31m"
|
| 1131 |
+
GREEN = "\033[32m"
|
| 1132 |
+
YELLOW = "\033[33m"
|
| 1133 |
+
BLUE = "\033[34m"
|
| 1134 |
+
MAGENTA = "\033[35m"
|
| 1135 |
+
CYAN = "\033[36m"
|
| 1136 |
+
WHITE = "\033[37m"
|
| 1137 |
+
|
| 1138 |
+
BRIGHT_RED = "\033[91m"
|
| 1139 |
+
BRIGHT_GREEN = "\033[92m"
|
| 1140 |
+
BRIGHT_YELLOW = "\033[93m"
|
| 1141 |
+
BRIGHT_BLUE = "\033[94m"
|
| 1142 |
+
BRIGHT_MAGENTA = "\033[95m"
|
| 1143 |
+
BRIGHT_CYAN = "\033[96m"
|
| 1144 |
+
BRIGHT_WHITE = "\033[97m"
|
| 1145 |
+
|
| 1146 |
+
|
| 1147 |
+
# Each pipeline step gets its own distinct color so its log lines are
|
| 1148 |
+
# instantly recognizable when scanning pipeline.log or the console.
|
| 1149 |
+
STEP_COLORS: dict[int, str] = {
|
| 1150 |
+
1: Ansi.BRIGHT_CYAN,
|
| 1151 |
+
2: Ansi.BRIGHT_MAGENTA,
|
| 1152 |
+
3: Ansi.BRIGHT_YELLOW,
|
| 1153 |
+
4: Ansi.BRIGHT_BLUE,
|
| 1154 |
+
5: Ansi.BRIGHT_GREEN,
|
| 1155 |
+
}
|
| 1156 |
+
|
| 1157 |
+
# Event-type colors (STARTED / COMPLETED / FAILED / etc.) layered on top of
|
| 1158 |
+
# the step color.
|
| 1159 |
+
EVENT_COLORS: dict[str, str] = {
|
| 1160 |
+
"STARTED": Ansi.BLUE,
|
| 1161 |
+
"RUNNING": Ansi.CYAN,
|
| 1162 |
+
"COMPLETED": Ansi.GREEN,
|
| 1163 |
+
"SUCCESS": Ansi.GREEN,
|
| 1164 |
+
"FAILED": Ansi.RED,
|
| 1165 |
+
"ERROR": Ansi.RED,
|
| 1166 |
+
"INFO": Ansi.WHITE,
|
| 1167 |
+
"WEBHOOK": Ansi.MAGENTA,
|
| 1168 |
+
"RESUME": Ansi.YELLOW,
|
| 1169 |
+
"PROJECT": Ansi.BRIGHT_WHITE,
|
| 1170 |
+
}
|
| 1171 |
+
|
| 1172 |
+
LEVEL_COLORS: dict[int, str] = {
|
| 1173 |
+
logging.DEBUG: Ansi.WHITE,
|
| 1174 |
+
logging.INFO: Ansi.CYAN,
|
| 1175 |
+
logging.WARNING: Ansi.YELLOW,
|
| 1176 |
+
logging.ERROR: Ansi.RED,
|
| 1177 |
+
logging.CRITICAL: Ansi.BOLD + Ansi.BRIGHT_RED,
|
| 1178 |
+
}
|
| 1179 |
+
|
| 1180 |
+
|
| 1181 |
+
def _colorize(
|
| 1182 |
+
text: str,
|
| 1183 |
+
color: str,
|
| 1184 |
+
) -> str:
|
| 1185 |
+
return f"{color}{text}{Ansi.RESET}"
|
| 1186 |
+
|
| 1187 |
+
|
| 1188 |
+
def step_log_message(
|
| 1189 |
+
step_number: int | None,
|
| 1190 |
+
event_type: str,
|
| 1191 |
+
message: str,
|
| 1192 |
+
) -> str:
|
| 1193 |
+
"""
|
| 1194 |
+
Build a colorful, per-step log message such as:
|
| 1195 |
+
|
| 1196 |
+
[STEP 002] STARTED | speaker_diarization
|
| 1197 |
+
|
| 1198 |
+
Colors are ANSI escape codes. They render nicely in any terminal that
|
| 1199 |
+
tails pipeline.log (e.g. `tail -f` / `cat`) and in the console handler.
|
| 1200 |
+
They are harmless (ignored/visible as-is) in non-ANSI viewers.
|
| 1201 |
+
"""
|
| 1202 |
+
step_color = (
|
| 1203 |
+
STEP_COLORS.get(
|
| 1204 |
+
step_number,
|
| 1205 |
+
Ansi.WHITE,
|
| 1206 |
+
)
|
| 1207 |
+
if step_number is not None
|
| 1208 |
+
else Ansi.BRIGHT_WHITE
|
| 1209 |
+
)
|
| 1210 |
+
|
| 1211 |
+
event_color = EVENT_COLORS.get(
|
| 1212 |
+
event_type,
|
| 1213 |
+
Ansi.WHITE,
|
| 1214 |
+
)
|
| 1215 |
+
|
| 1216 |
+
step_tag = (
|
| 1217 |
+
_colorize(
|
| 1218 |
+
f"[STEP {step_number:03d}]",
|
| 1219 |
+
Ansi.BOLD + step_color,
|
| 1220 |
+
)
|
| 1221 |
+
if step_number is not None
|
| 1222 |
+
else _colorize(
|
| 1223 |
+
"[PROJECT]",
|
| 1224 |
+
Ansi.BOLD + Ansi.BRIGHT_WHITE,
|
| 1225 |
+
)
|
| 1226 |
+
)
|
| 1227 |
+
|
| 1228 |
+
event_tag = _colorize(
|
| 1229 |
+
event_type,
|
| 1230 |
+
Ansi.BOLD + event_color,
|
| 1231 |
+
)
|
| 1232 |
+
|
| 1233 |
+
return (
|
| 1234 |
+
f"{step_tag} {event_tag} | {message}"
|
| 1235 |
+
)
|
| 1236 |
+
|
| 1237 |
# Only one whole pipeline job executes at a time in this process.
|
| 1238 |
#
|
| 1239 |
# This is deliberate. Step 2/4 are heavy CPU model workloads, and concurrent
|
|
|
|
| 1319 |
),
|
| 1320 |
)
|
| 1321 |
|
| 1322 |
+
# Configure CORS to allow requests from anywhere
|
| 1323 |
+
app.add_middleware(
|
| 1324 |
+
CORSMiddleware,
|
| 1325 |
+
allow_origins=["*"], # Allows requests from any origin
|
| 1326 |
+
allow_credentials=True, # Allows cookies/auth headers
|
| 1327 |
+
allow_methods=["*"], # Allows all HTTP methods (GET, POST, etc.)
|
| 1328 |
+
allow_headers=["*"], # Allows all headers
|
| 1329 |
+
)
|
| 1330 |
|
| 1331 |
# ============================================================================
|
| 1332 |
# GLOBAL JOB EXECUTOR
|
|
|
|
| 1365 |
description="Public/signed HTTP(S) URL of the source media file.",
|
| 1366 |
)
|
| 1367 |
|
| 1368 |
+
webhook_url: HttpUrl | None = Field(
|
| 1369 |
+
default=None,
|
| 1370 |
+
description=(
|
| 1371 |
+
"Optional HTTP(S) webhook URL. If present, the service will "
|
| 1372 |
+
"POST a JSON payload to it whenever a pipeline step completes "
|
| 1373 |
+
"(with that step's detailed logs plus all previous logs for "
|
| 1374 |
+
"this project), and once more when the whole pipeline "
|
| 1375 |
+
"completes (with all logs and the subtitle file path/"
|
| 1376 |
+
"download link)."
|
| 1377 |
+
),
|
| 1378 |
+
)
|
| 1379 |
+
|
| 1380 |
|
| 1381 |
class ResumeProjectResponse(BaseModel):
|
| 1382 |
project_id: str
|
|
|
|
| 1389 |
# LOGGING / PERSISTENT STATE
|
| 1390 |
# ============================================================================
|
| 1391 |
|
| 1392 |
+
class ISTColorFormatter(logging.Formatter):
|
| 1393 |
+
"""
|
| 1394 |
+
Logging formatter that:
|
| 1395 |
+
1. Renders timestamps in Indian Standard Time (IST, UTC+05:30).
|
| 1396 |
+
2. Colorizes the level name using ANSI escape codes so pipeline.log
|
| 1397 |
+
(and the console mirror) is colorful and easy to scan.
|
| 1398 |
+
|
| 1399 |
+
The per-step / per-event coloring (STARTED / COMPLETED / FAILED, etc.)
|
| 1400 |
+
is applied separately, in the message text itself, via
|
| 1401 |
+
`step_log_message()`, so a single log line ends up carrying both a
|
| 1402 |
+
level color and a step/event color.
|
| 1403 |
+
"""
|
| 1404 |
+
|
| 1405 |
+
def __init__(
|
| 1406 |
+
self,
|
| 1407 |
+
fmt: str,
|
| 1408 |
+
colorize: bool = True,
|
| 1409 |
+
) -> None:
|
| 1410 |
+
super().__init__(fmt)
|
| 1411 |
+
self.colorize = colorize
|
| 1412 |
+
|
| 1413 |
+
def formatTime(
|
| 1414 |
+
self,
|
| 1415 |
+
record: logging.LogRecord,
|
| 1416 |
+
datefmt: str | None = None,
|
| 1417 |
+
) -> str:
|
| 1418 |
+
dt = datetime.fromtimestamp(
|
| 1419 |
+
record.created,
|
| 1420 |
+
tz=IST_TZ,
|
| 1421 |
+
)
|
| 1422 |
+
|
| 1423 |
+
if datefmt:
|
| 1424 |
+
return dt.strftime(
|
| 1425 |
+
datefmt
|
| 1426 |
+
)
|
| 1427 |
+
|
| 1428 |
+
return (
|
| 1429 |
+
dt.strftime(
|
| 1430 |
+
"%Y-%m-%d %H:%M:%S.%f"
|
| 1431 |
+
)[:-3]
|
| 1432 |
+
+ " IST"
|
| 1433 |
+
)
|
| 1434 |
+
|
| 1435 |
+
def format(
|
| 1436 |
+
self,
|
| 1437 |
+
record: logging.LogRecord,
|
| 1438 |
+
) -> str:
|
| 1439 |
+
if not self.colorize:
|
| 1440 |
+
return super().format(
|
| 1441 |
+
record
|
| 1442 |
+
)
|
| 1443 |
+
|
| 1444 |
+
# Work on a shallow copy so we never mutate the shared LogRecord
|
| 1445 |
+
# (multiple handlers format the same record).
|
| 1446 |
+
colored_record = copy.copy(
|
| 1447 |
+
record
|
| 1448 |
+
)
|
| 1449 |
+
|
| 1450 |
+
level_color = LEVEL_COLORS.get(
|
| 1451 |
+
colored_record.levelno,
|
| 1452 |
+
Ansi.WHITE,
|
| 1453 |
+
)
|
| 1454 |
+
|
| 1455 |
+
colored_record.levelname = _colorize(
|
| 1456 |
+
f"{colored_record.levelname:<8}",
|
| 1457 |
+
level_color,
|
| 1458 |
+
)
|
| 1459 |
+
|
| 1460 |
+
return super().format(
|
| 1461 |
+
colored_record
|
| 1462 |
+
)
|
| 1463 |
+
|
| 1464 |
+
|
| 1465 |
class ProjectLogger:
|
| 1466 |
"""
|
| 1467 |
File-backed project logger.
|
|
|
|
| 1511 |
self.logger.propagate = False
|
| 1512 |
|
| 1513 |
if not self.logger.handlers:
|
| 1514 |
+
file_handler = logging.FileHandler(
|
| 1515 |
self.log_path,
|
| 1516 |
encoding="utf-8",
|
| 1517 |
)
|
| 1518 |
+
file_handler.setFormatter(
|
| 1519 |
+
ISTColorFormatter(
|
| 1520 |
"%(asctime)s | %(levelname)s | %(message)s",
|
| 1521 |
+
colorize=True,
|
| 1522 |
)
|
| 1523 |
)
|
| 1524 |
+
self.logger.addHandler(file_handler)
|
| 1525 |
+
|
| 1526 |
+
if LOG_TO_CONSOLE:
|
| 1527 |
+
console_handler = logging.StreamHandler(
|
| 1528 |
+
sys.stdout
|
| 1529 |
+
)
|
| 1530 |
+
console_handler.setFormatter(
|
| 1531 |
+
ISTColorFormatter(
|
| 1532 |
+
"%(asctime)s | %(levelname)s | "
|
| 1533 |
+
f"[{project_id}] "
|
| 1534 |
+
"%(message)s",
|
| 1535 |
+
colorize=True,
|
| 1536 |
+
)
|
| 1537 |
+
)
|
| 1538 |
+
self.logger.addHandler(console_handler)
|
| 1539 |
|
| 1540 |
def write(
|
| 1541 |
self,
|
|
|
|
| 1548 |
message,
|
| 1549 |
)
|
| 1550 |
|
| 1551 |
+
def write_step(
|
| 1552 |
+
self,
|
| 1553 |
+
step_number: int | None,
|
| 1554 |
+
event_type: str,
|
| 1555 |
+
message: str,
|
| 1556 |
+
level: int = logging.INFO,
|
| 1557 |
+
) -> str:
|
| 1558 |
+
"""
|
| 1559 |
+
Write a colorful, per-step log line (both to file and, if enabled,
|
| 1560 |
+
console) and return the fully-composed message text so callers can
|
| 1561 |
+
reuse the exact same text (e.g. for state["logs"] / webhooks).
|
| 1562 |
+
"""
|
| 1563 |
+
composed = step_log_message(
|
| 1564 |
+
step_number,
|
| 1565 |
+
event_type,
|
| 1566 |
+
message,
|
| 1567 |
+
)
|
| 1568 |
+
|
| 1569 |
+
self.write(
|
| 1570 |
+
composed,
|
| 1571 |
+
level=level,
|
| 1572 |
+
)
|
| 1573 |
+
|
| 1574 |
+
return composed
|
| 1575 |
+
|
| 1576 |
def recent_lines(
|
| 1577 |
self,
|
| 1578 |
limit: int = MAX_STATUS_LOG_LINES,
|
|
|
|
| 1591 |
|
| 1592 |
return lines[-limit:]
|
| 1593 |
|
| 1594 |
+
def all_lines(
|
| 1595 |
+
self,
|
| 1596 |
+
) -> list[str]:
|
| 1597 |
+
"""
|
| 1598 |
+
Full project log, unbounded. Used for the "all previous logs
|
| 1599 |
+
related to this project" payload sent to the webhook, and for the
|
| 1600 |
+
final completed-pipeline webhook event.
|
| 1601 |
+
"""
|
| 1602 |
+
if not self.log_path.exists():
|
| 1603 |
+
return []
|
| 1604 |
+
|
| 1605 |
+
with self.log_path.open(
|
| 1606 |
+
"r",
|
| 1607 |
+
encoding="utf-8",
|
| 1608 |
+
errors="replace",
|
| 1609 |
+
) as handle:
|
| 1610 |
+
return handle.readlines()
|
| 1611 |
+
|
| 1612 |
+
def lines_for_step(
|
| 1613 |
+
self,
|
| 1614 |
+
step_number: int,
|
| 1615 |
+
) -> list[str]:
|
| 1616 |
+
"""
|
| 1617 |
+
Just the lines related to a single step (STARTED/COMPLETED/FAILED
|
| 1618 |
+
and anything else logged with that step's [STEP NNN] tag).
|
| 1619 |
+
Used for the "this step's detailed logs" webhook payload.
|
| 1620 |
+
"""
|
| 1621 |
+
marker = f"STEP {step_number:03d}]"
|
| 1622 |
+
|
| 1623 |
+
return [
|
| 1624 |
+
line
|
| 1625 |
+
for line in self.all_lines()
|
| 1626 |
+
if marker in line
|
| 1627 |
+
]
|
| 1628 |
+
|
| 1629 |
|
| 1630 |
# ============================================================================
|
| 1631 |
# PROJECT STATE
|
|
|
|
| 1661 |
def _initial_state(
|
| 1662 |
project_id: str,
|
| 1663 |
source_url: str,
|
| 1664 |
+
webhook_url: str | None = None,
|
| 1665 |
) -> dict[str, Any]:
|
| 1666 |
return {
|
| 1667 |
"schema_version": "1.0",
|
| 1668 |
"project_id": project_id,
|
| 1669 |
"source_url": source_url,
|
| 1670 |
"source_file": None,
|
| 1671 |
+
"webhook_url": webhook_url,
|
| 1672 |
"status": "queued",
|
| 1673 |
"current_step": None,
|
| 1674 |
"current_step_name": None,
|
|
|
|
| 1790 |
|
| 1791 |
logs.append(
|
| 1792 |
{
|
| 1793 |
+
"timestamp_ist": ist_now_iso(),
|
| 1794 |
"message": message,
|
| 1795 |
}
|
| 1796 |
)
|
|
|
|
| 2003 |
A .part file is used until the download completes. This makes resume/retry
|
| 2004 |
safe: an interrupted download is never mistaken for a usable source.
|
| 2005 |
"""
|
| 2006 |
+
logger.write_step(
|
| 2007 |
+
None,
|
| 2008 |
+
"PROJECT",
|
| 2009 |
+
f"Downloading source media: {source_url} "
|
| 2010 |
+
f"| started_at={ist_now_display()}",
|
| 2011 |
)
|
| 2012 |
|
| 2013 |
with httpx.Client(
|
|
|
|
| 2160 |
"Downloaded media file is empty."
|
| 2161 |
)
|
| 2162 |
|
| 2163 |
+
logger.write_step(
|
| 2164 |
+
None,
|
| 2165 |
+
"PROJECT",
|
| 2166 |
"Download complete: "
|
| 2167 |
f"{destination} "
|
| 2168 |
+
f"({destination.stat().st_size:,} bytes) "
|
| 2169 |
+
f"| completed_at={ist_now_display()}",
|
| 2170 |
)
|
| 2171 |
|
| 2172 |
return destination
|
|
|
|
| 2241 |
)
|
| 2242 |
|
| 2243 |
|
| 2244 |
+
# ============================================================================
|
| 2245 |
+
# OPTIONAL PROJECT WEBHOOK
|
| 2246 |
+
# ============================================================================
|
| 2247 |
+
|
| 2248 |
+
def _subtitle_download_info(
|
| 2249 |
+
project_id: str,
|
| 2250 |
+
state: dict[str, Any],
|
| 2251 |
+
) -> dict[str, Any]:
|
| 2252 |
+
"""
|
| 2253 |
+
Resolve the generated subtitle file path (if any) plus the existing
|
| 2254 |
+
download route for it, for inclusion in webhook payloads.
|
| 2255 |
+
"""
|
| 2256 |
+
step_5 = state.get(
|
| 2257 |
+
"steps",
|
| 2258 |
+
{},
|
| 2259 |
+
).get(
|
| 2260 |
+
"5",
|
| 2261 |
+
{},
|
| 2262 |
+
)
|
| 2263 |
+
|
| 2264 |
+
step_5_result = (
|
| 2265 |
+
step_5.get("result")
|
| 2266 |
+
or {}
|
| 2267 |
+
)
|
| 2268 |
+
|
| 2269 |
+
srt_path_value = step_5_result.get(
|
| 2270 |
+
"srt_path"
|
| 2271 |
+
)
|
| 2272 |
+
|
| 2273 |
+
subtitle_path: str | None = None
|
| 2274 |
+
|
| 2275 |
+
if srt_path_value and Path(
|
| 2276 |
+
srt_path_value
|
| 2277 |
+
).exists():
|
| 2278 |
+
subtitle_path = str(
|
| 2279 |
+
Path(
|
| 2280 |
+
srt_path_value
|
| 2281 |
+
).resolve()
|
| 2282 |
+
)
|
| 2283 |
+
else:
|
| 2284 |
+
subtitles_dir = (
|
| 2285 |
+
OUTPUT_ROOT
|
| 2286 |
+
/ project_id
|
| 2287 |
+
/ "subtitles"
|
| 2288 |
+
)
|
| 2289 |
+
|
| 2290 |
+
if subtitles_dir.exists():
|
| 2291 |
+
srt_files = sorted(
|
| 2292 |
+
subtitles_dir.glob(
|
| 2293 |
+
"*.srt"
|
| 2294 |
+
),
|
| 2295 |
+
key=lambda path: path.stat().st_mtime,
|
| 2296 |
+
reverse=True,
|
| 2297 |
+
)
|
| 2298 |
+
|
| 2299 |
+
if srt_files:
|
| 2300 |
+
subtitle_path = str(
|
| 2301 |
+
srt_files[0].resolve()
|
| 2302 |
+
)
|
| 2303 |
+
|
| 2304 |
+
return {
|
| 2305 |
+
"subtitle_file_path": subtitle_path,
|
| 2306 |
+
"subtitle_ready": subtitle_path is not None,
|
| 2307 |
+
# This route already exists in this API and streams the .srt file.
|
| 2308 |
+
"subtitle_download_url": (
|
| 2309 |
+
f"/projects/{project_id}/subtitle"
|
| 2310 |
+
),
|
| 2311 |
+
}
|
| 2312 |
+
|
| 2313 |
+
|
| 2314 |
+
def _send_webhook(
|
| 2315 |
+
state: dict[str, Any],
|
| 2316 |
+
logger: ProjectLogger,
|
| 2317 |
+
event: str,
|
| 2318 |
+
*,
|
| 2319 |
+
step_number: int | None = None,
|
| 2320 |
+
step_state: dict[str, Any] | None = None,
|
| 2321 |
+
) -> None:
|
| 2322 |
+
"""
|
| 2323 |
+
Best-effort webhook delivery.
|
| 2324 |
+
|
| 2325 |
+
Fired:
|
| 2326 |
+
- after every step completes (success or failure): includes that
|
| 2327 |
+
step's own detailed logs, plus every log line for the project so
|
| 2328 |
+
far ("previous logs too, related to that project").
|
| 2329 |
+
- once more when the whole pipeline finishes: includes ALL logs for
|
| 2330 |
+
the project plus the subtitle file path / download link.
|
| 2331 |
+
|
| 2332 |
+
Failures here are logged but NEVER raised — a webhook outage must not
|
| 2333 |
+
break the pipeline.
|
| 2334 |
+
"""
|
| 2335 |
+
webhook_url = state.get(
|
| 2336 |
+
"webhook_url"
|
| 2337 |
+
)
|
| 2338 |
+
|
| 2339 |
+
if not webhook_url:
|
| 2340 |
+
return
|
| 2341 |
+
|
| 2342 |
+
project_id = state[
|
| 2343 |
+
"project_id"
|
| 2344 |
+
]
|
| 2345 |
+
|
| 2346 |
+
payload: dict[str, Any] = {
|
| 2347 |
+
"event": event,
|
| 2348 |
+
"project_id": project_id,
|
| 2349 |
+
"sent_at_ist": ist_now_iso(),
|
| 2350 |
+
"status": state.get("status"),
|
| 2351 |
+
"current_step": state.get("current_step"),
|
| 2352 |
+
"current_step_name": state.get("current_step_name"),
|
| 2353 |
+
"last_completed_step": state.get(
|
| 2354 |
+
"last_completed_step",
|
| 2355 |
+
0,
|
| 2356 |
+
),
|
| 2357 |
+
"error": state.get("error"),
|
| 2358 |
+
"logs_so_far": logger.all_lines(),
|
| 2359 |
+
}
|
| 2360 |
+
|
| 2361 |
+
if step_number is not None:
|
| 2362 |
+
payload["step"] = {
|
| 2363 |
+
"number": step_number,
|
| 2364 |
+
"name": STEPS[step_number - 1]["name"],
|
| 2365 |
+
"status": (step_state or {}).get("status"),
|
| 2366 |
+
"started_at": (step_state or {}).get("started_at"),
|
| 2367 |
+
"completed_at": (step_state or {}).get("completed_at"),
|
| 2368 |
+
"error": (step_state or {}).get("error"),
|
| 2369 |
+
"result": (step_state or {}).get("result"),
|
| 2370 |
+
}
|
| 2371 |
+
payload["step_logs"] = logger.lines_for_step(
|
| 2372 |
+
step_number
|
| 2373 |
+
)
|
| 2374 |
+
|
| 2375 |
+
if event == "pipeline_completed":
|
| 2376 |
+
payload.update(
|
| 2377 |
+
_subtitle_download_info(
|
| 2378 |
+
project_id,
|
| 2379 |
+
state,
|
| 2380 |
+
)
|
| 2381 |
+
)
|
| 2382 |
+
payload["completed_at"] = state.get(
|
| 2383 |
+
"completed_at"
|
| 2384 |
+
)
|
| 2385 |
+
|
| 2386 |
+
try:
|
| 2387 |
+
with httpx.Client(
|
| 2388 |
+
timeout=httpx.Timeout(
|
| 2389 |
+
WEBHOOK_TIMEOUT_SECONDS
|
| 2390 |
+
),
|
| 2391 |
+
) as client:
|
| 2392 |
+
response = client.post(
|
| 2393 |
+
webhook_url,
|
| 2394 |
+
json=payload,
|
| 2395 |
+
)
|
| 2396 |
+
|
| 2397 |
+
logger.write_step(
|
| 2398 |
+
step_number,
|
| 2399 |
+
"WEBHOOK",
|
| 2400 |
+
(
|
| 2401 |
+
f"Webhook delivered | event={event} | "
|
| 2402 |
+
f"url={webhook_url} | "
|
| 2403 |
+
f"response_status={response.status_code}"
|
| 2404 |
+
),
|
| 2405 |
+
)
|
| 2406 |
+
|
| 2407 |
+
except Exception as exc:
|
| 2408 |
+
logger.write_step(
|
| 2409 |
+
step_number,
|
| 2410 |
+
"WEBHOOK",
|
| 2411 |
+
(
|
| 2412 |
+
f"Webhook delivery FAILED | event={event} | "
|
| 2413 |
+
f"url={webhook_url} | error={exc}"
|
| 2414 |
+
),
|
| 2415 |
+
level=logging.WARNING,
|
| 2416 |
+
)
|
| 2417 |
+
|
| 2418 |
+
|
| 2419 |
def _run_step_1(
|
| 2420 |
project_id: str,
|
| 2421 |
source_file: Path,
|
|
|
|
| 2697 |
_record_event(
|
| 2698 |
state,
|
| 2699 |
logger,
|
| 2700 |
+
step_log_message(
|
| 2701 |
+
None,
|
| 2702 |
+
"PROJECT",
|
| 2703 |
+
f"Pipeline starting from Step {start_step:03d} "
|
| 2704 |
+
f"at {ist_now_display()}.",
|
| 2705 |
+
),
|
| 2706 |
)
|
| 2707 |
|
| 2708 |
try:
|
|
|
|
| 2726 |
_record_event(
|
| 2727 |
state,
|
| 2728 |
logger,
|
| 2729 |
+
step_log_message(
|
| 2730 |
+
number,
|
| 2731 |
+
"STARTED",
|
| 2732 |
+
f"{step_name} | started_at={ist_now_display()}",
|
| 2733 |
+
),
|
| 2734 |
)
|
| 2735 |
|
| 2736 |
try:
|
|
|
|
| 2758 |
_record_event(
|
| 2759 |
state,
|
| 2760 |
logger,
|
| 2761 |
+
step_log_message(
|
| 2762 |
+
number,
|
| 2763 |
+
"FAILED",
|
| 2764 |
+
f"{step_name} | error={exc} | "
|
| 2765 |
+
f"failed_at={ist_now_display()}",
|
| 2766 |
),
|
| 2767 |
level=logging.ERROR,
|
| 2768 |
)
|
| 2769 |
|
| 2770 |
+
# Notify the optional webhook: this step's detailed logs +
|
| 2771 |
+
# every previous log line for this project.
|
| 2772 |
+
_send_webhook(
|
| 2773 |
+
state,
|
| 2774 |
+
logger,
|
| 2775 |
+
event="step_failed",
|
| 2776 |
+
step_number=number,
|
| 2777 |
+
step_state=state["steps"][str(number)],
|
| 2778 |
+
)
|
| 2779 |
+
|
| 2780 |
return
|
| 2781 |
|
| 2782 |
_mark_step_success(
|
|
|
|
| 2788 |
_record_event(
|
| 2789 |
state,
|
| 2790 |
logger,
|
| 2791 |
+
step_log_message(
|
| 2792 |
+
number,
|
| 2793 |
+
"COMPLETED",
|
| 2794 |
+
f"{step_name} | completed_at={ist_now_display()}",
|
| 2795 |
+
),
|
| 2796 |
+
)
|
| 2797 |
+
|
| 2798 |
+
# Notify the optional webhook: this step's detailed logs +
|
| 2799 |
+
# every previous log line for this project so far.
|
| 2800 |
+
_send_webhook(
|
| 2801 |
+
state,
|
| 2802 |
+
logger,
|
| 2803 |
+
event="step_completed",
|
| 2804 |
+
step_number=number,
|
| 2805 |
+
step_state=state["steps"][str(number)],
|
| 2806 |
)
|
| 2807 |
|
| 2808 |
state["status"] = "completed"
|
|
|
|
| 2818 |
_record_event(
|
| 2819 |
state,
|
| 2820 |
logger,
|
| 2821 |
+
step_log_message(
|
| 2822 |
+
None,
|
| 2823 |
+
"SUCCESS",
|
| 2824 |
+
"PIPELINE COMPLETED SUCCESSFULLY at "
|
| 2825 |
+
f"{ist_now_display()}.",
|
| 2826 |
+
),
|
| 2827 |
+
)
|
| 2828 |
+
|
| 2829 |
+
# Final webhook: all logs for the project + subtitle file path /
|
| 2830 |
+
# download link.
|
| 2831 |
+
_send_webhook(
|
| 2832 |
+
state,
|
| 2833 |
+
logger,
|
| 2834 |
+
event="pipeline_completed",
|
| 2835 |
)
|
| 2836 |
|
| 2837 |
finally:
|
|
|
|
| 2878 |
# API ROUTES
|
| 2879 |
# ============================================================================
|
| 2880 |
|
| 2881 |
+
@app.get(
|
| 2882 |
+
"/",
|
| 2883 |
+
)
|
| 2884 |
+
async def root() -> dict[str, Any]:
|
| 2885 |
+
"""
|
| 2886 |
+
Root endpoint — simple service banner + a directory of available
|
| 2887 |
+
routes, so hitting the base URL is never a 404.
|
| 2888 |
+
"""
|
| 2889 |
+
with ACTIVE_PROJECTS_LOCK:
|
| 2890 |
+
active = sorted(
|
| 2891 |
+
ACTIVE_PROJECTS
|
| 2892 |
+
)
|
| 2893 |
+
|
| 2894 |
+
return {
|
| 2895 |
+
"service": "Subtitle Pipeline Service",
|
| 2896 |
+
"version": app.version,
|
| 2897 |
+
"status": "ok",
|
| 2898 |
+
"message": (
|
| 2899 |
+
"Subtitle Pipeline API is running. "
|
| 2900 |
+
"See /docs for interactive API documentation."
|
| 2901 |
+
),
|
| 2902 |
+
"server_time_ist": ist_now_display(),
|
| 2903 |
+
"active_projects": active,
|
| 2904 |
+
"endpoints": {
|
| 2905 |
+
"root": "GET /",
|
| 2906 |
+
"health": "GET /health",
|
| 2907 |
+
"docs": "GET /docs",
|
| 2908 |
+
"create_project": "POST /projects",
|
| 2909 |
+
"project_status": "GET /projects/{project_id}",
|
| 2910 |
+
"project_logs": "GET /projects/{project_id}/logs",
|
| 2911 |
+
"resume_project": "POST /projects/{project_id}/resume",
|
| 2912 |
+
"download_subtitle": "GET /projects/{project_id}/subtitle",
|
| 2913 |
+
},
|
| 2914 |
+
}
|
| 2915 |
+
|
| 2916 |
+
|
| 2917 |
@app.get(
|
| 2918 |
"/health",
|
| 2919 |
)
|
|
|
|
| 2951 |
request.file_url
|
| 2952 |
)
|
| 2953 |
|
| 2954 |
+
webhook_url = (
|
| 2955 |
+
str(
|
| 2956 |
+
request.webhook_url
|
| 2957 |
+
)
|
| 2958 |
+
if request.webhook_url
|
| 2959 |
+
else None
|
| 2960 |
+
)
|
| 2961 |
+
|
| 2962 |
paths = project_paths(
|
| 2963 |
project_id
|
| 2964 |
)
|
|
|
|
| 2992 |
state = _initial_state(
|
| 2993 |
project_id,
|
| 2994 |
source_url,
|
| 2995 |
+
webhook_url,
|
| 2996 |
)
|
| 2997 |
|
| 2998 |
_write_state(
|
| 2999 |
state
|
| 3000 |
)
|
| 3001 |
|
| 3002 |
+
logger.write_step(
|
| 3003 |
+
None,
|
| 3004 |
+
"PROJECT",
|
| 3005 |
+
(
|
| 3006 |
+
f"Project created: {project_id} | source_url={source_url} | "
|
| 3007 |
+
"webhook_url="
|
| 3008 |
+
+ (
|
| 3009 |
+
webhook_url
|
| 3010 |
+
if webhook_url
|
| 3011 |
+
else "(none)"
|
| 3012 |
+
)
|
| 3013 |
+
),
|
| 3014 |
)
|
| 3015 |
|
| 3016 |
try:
|
|
|
|
| 3043 |
_record_event(
|
| 3044 |
state,
|
| 3045 |
logger,
|
| 3046 |
+
step_log_message(
|
| 3047 |
+
None,
|
| 3048 |
+
"PROJECT",
|
| 3049 |
"Source media downloaded successfully. "
|
| 3050 |
+
f"Submitting pipeline at {ist_now_display()}.",
|
| 3051 |
),
|
| 3052 |
)
|
| 3053 |
|
|
|
|
| 3069 |
state
|
| 3070 |
)
|
| 3071 |
|
| 3072 |
+
logger.write_step(
|
| 3073 |
+
None,
|
| 3074 |
+
"FAILED",
|
| 3075 |
+
f"PROJECT CREATION FAILED | {exc} | "
|
| 3076 |
+
f"failed_at={ist_now_display()}",
|
| 3077 |
+
level=logging.ERROR,
|
| 3078 |
)
|
| 3079 |
|
| 3080 |
raise HTTPException(
|
|
|
|
| 3283 |
_record_event(
|
| 3284 |
state,
|
| 3285 |
logger,
|
| 3286 |
+
step_log_message(
|
| 3287 |
+
start_step,
|
| 3288 |
+
"RESUME",
|
| 3289 |
+
f"Restarting from Step {start_step:03d} | "
|
| 3290 |
+
f"{STEPS[start_step - 1]['name']} | "
|
| 3291 |
+
f"requested_at={ist_now_display()}",
|
| 3292 |
),
|
| 3293 |
)
|
| 3294 |
|
|
|
|
| 3417 |
# PIPELINE_EXECUTOR.shutdown(
|
| 3418 |
# wait=False,
|
| 3419 |
# cancel_futures=False,
|
| 3420 |
+
# )
|
|
|
src/pipeline/step_001_separate.py
CHANGED
|
@@ -20,44 +20,52 @@ from dataclasses import dataclass
|
|
| 20 |
from pathlib import Path
|
| 21 |
from typing import Final
|
| 22 |
|
| 23 |
-
|
| 24 |
|
| 25 |
|
| 26 |
# ---------------------------------------------------------------------------
|
| 27 |
-
# Configuration
|
| 28 |
# ---------------------------------------------------------------------------
|
| 29 |
|
| 30 |
-
|
| 31 |
-
|
| 32 |
-
|
| 33 |
-
|
| 34 |
-
|
| 35 |
-
|
| 36 |
-
|
|
|
|
|
|
|
| 37 |
|
| 38 |
-
|
| 39 |
-
|
|
|
|
|
|
|
| 40 |
|
| 41 |
-
|
| 42 |
|
| 43 |
|
| 44 |
def _configure_cpu_threads() -> int:
|
| 45 |
-
"""Configure PyTorch, OpenMP, and ONNX Runtime thread pools for CPU execution."""
|
| 46 |
-
|
| 47 |
-
|
| 48 |
-
|
| 49 |
-
# Force
|
| 50 |
-
os.environ["OMP_NUM_THREADS"] =
|
| 51 |
-
os.environ["MKL_NUM_THREADS"] =
|
| 52 |
-
os.environ["OPENBLAS_NUM_THREADS"] =
|
| 53 |
-
os.environ["NUMEXPR_NUM_THREADS"] =
|
| 54 |
-
os.environ["VECLIB_MAXIMUM_THREADS"] =
|
| 55 |
-
os.environ["NUMPY_NUM_THREADS"] =
|
|
|
|
|
|
|
|
|
|
|
|
|
| 56 |
|
| 57 |
try:
|
| 58 |
import torch
|
| 59 |
|
| 60 |
-
torch.set_num_threads(
|
| 61 |
try:
|
| 62 |
torch.set_num_interop_threads(1)
|
| 63 |
except RuntimeError:
|
|
@@ -65,8 +73,29 @@ def _configure_cpu_threads() -> int:
|
|
| 65 |
except Exception as exc:
|
| 66 |
LOGGER.warning("Could not configure PyTorch CPU threads: %s", exc)
|
| 67 |
|
| 68 |
-
LOGGER.info("CPU optimization:
|
| 69 |
-
return
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 70 |
|
| 71 |
|
| 72 |
@dataclass(frozen=True)
|
|
@@ -171,7 +200,7 @@ def _extract_to_wav(input_path: Path, temp_dir: Path, sample_rate: int) -> Path:
|
|
| 171 |
"-vn",
|
| 172 |
"-ac", str(DEFAULT_FFMPEG_AUDIO_CHANNELS),
|
| 173 |
"-ar", str(sample_rate),
|
| 174 |
-
"-threads",
|
| 175 |
"-c:a", "pcm_s16le",
|
| 176 |
str(output_path),
|
| 177 |
]
|
|
@@ -213,7 +242,7 @@ def _make_separator(
|
|
| 213 |
mdx_params={
|
| 214 |
"hop_length": 1024,
|
| 215 |
"segment_size": 256,
|
| 216 |
-
"overlap": 0.25, # Drastically speeds up execution
|
| 217 |
"batch_size": 1,
|
| 218 |
"enable_denoise": False,
|
| 219 |
},
|
|
@@ -334,7 +363,6 @@ def run_audio_separation(
|
|
| 334 |
input_file_path: str | os.PathLike[str],
|
| 335 |
output_dir: str | os.PathLike[str],
|
| 336 |
) -> tuple[str, str]:
|
| 337 |
-
_configure_cpu_threads()
|
| 338 |
|
| 339 |
input_path = Path(input_file_path).expanduser().resolve()
|
| 340 |
output_path = Path(output_dir).expanduser().resolve()
|
|
@@ -373,7 +401,6 @@ def run_audio_separation(
|
|
| 373 |
model_to_use = os.getenv("AUDIO_SEPARATOR_MODEL", DEFAULT_MODEL)
|
| 374 |
|
| 375 |
try:
|
| 376 |
-
# audio-separator handles the download internally if the model is missing
|
| 377 |
separator.load_model(model_filename=model_to_use)
|
| 378 |
except TypeError:
|
| 379 |
separator.load_model(model_to_use)
|
|
@@ -382,7 +409,7 @@ def run_audio_separation(
|
|
| 382 |
f"Failed to load separation model '{model_to_use}'."
|
| 383 |
) from exc
|
| 384 |
|
| 385 |
-
LOGGER.info("Separating with %s", model_to_use)
|
| 386 |
|
| 387 |
try:
|
| 388 |
try:
|
|
|
|
| 20 |
from pathlib import Path
|
| 21 |
from typing import Final
|
| 22 |
|
| 23 |
+
LOGGER = logging.getLogger(__name__)
|
| 24 |
|
| 25 |
|
| 26 |
# ---------------------------------------------------------------------------
|
| 27 |
+
# Dynamic Thread & Core Configuration (Executed BEFORE C++ Runtime Imports)
|
| 28 |
# ---------------------------------------------------------------------------
|
| 29 |
|
| 30 |
+
def _detect_physical_cores() -> int:
|
| 31 |
+
"""Dynamically detect physical CPU cores to prevent thread thrashing."""
|
| 32 |
+
try:
|
| 33 |
+
import psutil
|
| 34 |
+
physical_cores = psutil.cpu_count(logical=False)
|
| 35 |
+
if physical_cores and physical_cores > 0:
|
| 36 |
+
return physical_cores
|
| 37 |
+
except Exception:
|
| 38 |
+
pass
|
| 39 |
|
| 40 |
+
# Fallback using logical count / 2 (standard hyperthreading ratio)
|
| 41 |
+
logical_cores = os.cpu_count()
|
| 42 |
+
if logical_cores and logical_cores > 1:
|
| 43 |
+
return max(2, logical_cores // 2)
|
| 44 |
|
| 45 |
+
return 2 # Safe default core limit
|
| 46 |
|
| 47 |
|
| 48 |
def _configure_cpu_threads() -> int:
|
| 49 |
+
"""Configure PyTorch, OpenMP, HuggingFace, and ONNX Runtime thread pools for CPU execution."""
|
| 50 |
+
cores = _detect_physical_cores()
|
| 51 |
+
str_cores = str(cores)
|
| 52 |
+
|
| 53 |
+
# Force C/C++ underlying thread pools to match physical CPU core count
|
| 54 |
+
os.environ["OMP_NUM_THREADS"] = str_cores
|
| 55 |
+
os.environ["MKL_NUM_THREADS"] = str_cores
|
| 56 |
+
os.environ["OPENBLAS_NUM_THREADS"] = str_cores
|
| 57 |
+
os.environ["NUMEXPR_NUM_THREADS"] = str_cores
|
| 58 |
+
os.environ["VECLIB_MAXIMUM_THREADS"] = str_cores
|
| 59 |
+
os.environ["NUMPY_NUM_THREADS"] = str_cores
|
| 60 |
+
|
| 61 |
+
# ONNX & Hugging Face threading control
|
| 62 |
+
os.environ["OMP_WAIT_POLICY"] = "PASSIVE"
|
| 63 |
+
os.environ["TOKENIZERS_PARALLELISM"] = "false"
|
| 64 |
|
| 65 |
try:
|
| 66 |
import torch
|
| 67 |
|
| 68 |
+
torch.set_num_threads(cores)
|
| 69 |
try:
|
| 70 |
torch.set_num_interop_threads(1)
|
| 71 |
except RuntimeError:
|
|
|
|
| 73 |
except Exception as exc:
|
| 74 |
LOGGER.warning("Could not configure PyTorch CPU threads: %s", exc)
|
| 75 |
|
| 76 |
+
LOGGER.info("CPU optimization: dynamically locked execution to %d physical CPU cores", cores)
|
| 77 |
+
return cores
|
| 78 |
+
|
| 79 |
+
|
| 80 |
+
# Initialize thread locks prior to loading heavy model dependencies
|
| 81 |
+
PHYSICAL_CPU_CORES = _configure_cpu_threads()
|
| 82 |
+
|
| 83 |
+
# Lazy import after environment variables are set
|
| 84 |
+
from audio_separator.separator import Separator # noqa: E402
|
| 85 |
+
|
| 86 |
+
|
| 87 |
+
# ---------------------------------------------------------------------------
|
| 88 |
+
# Configuration
|
| 89 |
+
# ---------------------------------------------------------------------------
|
| 90 |
+
|
| 91 |
+
DEFAULT_MODEL: Final[str] = "UVR-MDX-NET-Voc_FT.onnx"
|
| 92 |
+
DEFAULT_SAMPLE_RATE: Final[int] = 44_100
|
| 93 |
+
DEFAULT_NORMALIZATION: Final[float] = 0.9
|
| 94 |
+
DEFAULT_MODEL_DIR: Final[Path] = Path("models/audio-separator")
|
| 95 |
+
DEFAULT_FFMPEG_AUDIO_CHANNELS: Final[int] = 2
|
| 96 |
+
|
| 97 |
+
VOCAL_OUTPUT_NAME: Final[str] = "vocal.wav"
|
| 98 |
+
INSTRUMENT_OUTPUT_NAME: Final[str] = "instrument.wav"
|
| 99 |
|
| 100 |
|
| 101 |
@dataclass(frozen=True)
|
|
|
|
| 200 |
"-vn",
|
| 201 |
"-ac", str(DEFAULT_FFMPEG_AUDIO_CHANNELS),
|
| 202 |
"-ar", str(sample_rate),
|
| 203 |
+
"-threads", str(PHYSICAL_CPU_CORES),
|
| 204 |
"-c:a", "pcm_s16le",
|
| 205 |
str(output_path),
|
| 206 |
]
|
|
|
|
| 242 |
mdx_params={
|
| 243 |
"hop_length": 1024,
|
| 244 |
"segment_size": 256,
|
| 245 |
+
"overlap": 0.25, # Drastically speeds up CPU execution
|
| 246 |
"batch_size": 1,
|
| 247 |
"enable_denoise": False,
|
| 248 |
},
|
|
|
|
| 363 |
input_file_path: str | os.PathLike[str],
|
| 364 |
output_dir: str | os.PathLike[str],
|
| 365 |
) -> tuple[str, str]:
|
|
|
|
| 366 |
|
| 367 |
input_path = Path(input_file_path).expanduser().resolve()
|
| 368 |
output_path = Path(output_dir).expanduser().resolve()
|
|
|
|
| 401 |
model_to_use = os.getenv("AUDIO_SEPARATOR_MODEL", DEFAULT_MODEL)
|
| 402 |
|
| 403 |
try:
|
|
|
|
| 404 |
separator.load_model(model_filename=model_to_use)
|
| 405 |
except TypeError:
|
| 406 |
separator.load_model(model_to_use)
|
|
|
|
| 409 |
f"Failed to load separation model '{model_to_use}'."
|
| 410 |
) from exc
|
| 411 |
|
| 412 |
+
LOGGER.info("Separating with %s on %d physical cores", model_to_use, PHYSICAL_CPU_CORES)
|
| 413 |
|
| 414 |
try:
|
| 415 |
try:
|
src/pipeline/step_004_transcribe.py
CHANGED
|
@@ -1,5 +1,5 @@
|
|
| 1 |
"""
|
| 2 |
-
Pipeline Step 004 - Speaker-Aware Transcription.
|
| 3 |
|
| 4 |
Project layout used by this pipeline:
|
| 5 |
|
|
@@ -27,8 +27,8 @@ Step 004:
|
|
| 27 |
2. Uses every prepared speaker WAV referenced by the manifest.
|
| 28 |
3. Uses faster-whisper + CTranslate2.
|
| 29 |
4. Uses Whisper medium.
|
| 30 |
-
5. Uses INT8 CPU inference.
|
| 31 |
-
6. Uses the available physical CPU cores by default.
|
| 32 |
7. Detects language automatically when WHISPER_LANGUAGE is unset.
|
| 33 |
8. Stores language and confidence in the output.
|
| 34 |
9. Produces word-level timestamps.
|
|
@@ -46,19 +46,6 @@ Important:
|
|
| 46 |
|
| 47 |
from __future__ import annotations
|
| 48 |
|
| 49 |
-
# ============================================================================
|
| 50 |
-
# CPU THREAD ENVIRONMENT
|
| 51 |
-
# ============================================================================
|
| 52 |
-
#
|
| 53 |
-
# CTranslate2 owns the model execution threads. Environment variables are set
|
| 54 |
-
# before importing faster-whisper so native math libraries do not silently
|
| 55 |
-
# create competing thread pools.
|
| 56 |
-
#
|
| 57 |
-
# Prefer physical cores. On virtualized environments os.cpu_count() often
|
| 58 |
-
# reports logical CPUs, while physical-core count is usually the better default
|
| 59 |
-
# for one heavy CPU inference job.
|
| 60 |
-
# ============================================================================
|
| 61 |
-
|
| 62 |
import gc
|
| 63 |
import json
|
| 64 |
import logging
|
|
@@ -66,8 +53,10 @@ import math
|
|
| 66 |
import os
|
| 67 |
import time
|
| 68 |
from collections import Counter
|
|
|
|
| 69 |
from dataclasses import dataclass
|
| 70 |
from pathlib import Path
|
|
|
|
| 71 |
from typing import Any, Final
|
| 72 |
|
| 73 |
try:
|
|
@@ -103,26 +92,21 @@ def _detect_cpu_threads() -> int:
|
|
| 103 |
|
| 104 |
CPU_THREADS: Final[int] = _detect_cpu_threads()
|
| 105 |
|
| 106 |
-
|
| 107 |
-
|
| 108 |
-
|
| 109 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 110 |
os.environ.setdefault("TOKENIZERS_PARALLELISM", "false")
|
| 111 |
|
| 112 |
# ============================================================================
|
| 113 |
# CONFIGURATION
|
| 114 |
# ============================================================================
|
| 115 |
|
| 116 |
-
# This file lives at:
|
| 117 |
-
# subtitle/src/pipeline/step_004_transcribe.py
|
| 118 |
-
#
|
| 119 |
-
# parents[1] is:
|
| 120 |
-
# subtitle/src
|
| 121 |
-
#
|
| 122 |
-
# Therefore every Step 4 project is rooted at:
|
| 123 |
-
# subtitle/src/output/<project_id>
|
| 124 |
-
#
|
| 125 |
-
# This is the path rule used by Step 3 as well.
|
| 126 |
SRC_ROOT: Final[Path] = Path(__file__).resolve().parents[1]
|
| 127 |
DEFAULT_OUTPUT_ROOT: Final[Path] = SRC_ROOT / "output"
|
| 128 |
|
|
@@ -136,9 +120,6 @@ COMPUTE_TYPE: Final[str] = os.getenv(
|
|
| 136 |
"int8",
|
| 137 |
)
|
| 138 |
|
| 139 |
-
# One model copy per process. CTranslate2 uses CPU threads inside that model.
|
| 140 |
-
NUM_WORKERS: Final[int] = 1
|
| 141 |
-
|
| 142 |
BEAM_SIZE: Final[int] = max(
|
| 143 |
1,
|
| 144 |
int(os.getenv("WHISPER_BEAM_SIZE", "5")),
|
|
@@ -148,20 +129,15 @@ WORD_TIMESTAMPS: Final[bool] = True
|
|
| 148 |
|
| 149 |
TASK: Final[str] = "transcribe"
|
| 150 |
|
| 151 |
-
# Empty => automatic language detection.
|
| 152 |
REQUESTED_LANGUAGE: Final[str | None] = (
|
| 153 |
os.getenv("WHISPER_LANGUAGE") or None
|
| 154 |
)
|
| 155 |
|
| 156 |
-
# Set to 1 only for genuinely multilingual/code-switching content.
|
| 157 |
DETECT_LANGUAGE_PER_CHUNK: Final[bool] = (
|
| 158 |
os.getenv("DETECT_LANGUAGE_PER_CHUNK", "0").lower()
|
| 159 |
in {"1", "true", "yes", "on"}
|
| 160 |
)
|
| 161 |
|
| 162 |
-
# VAD is optional. For these short speaker-isolated chunks it is useful for
|
| 163 |
-
# long internal silence and hallucination control, but it is not needed to
|
| 164 |
-
# reconstruct the original timeline because Step 003 supplies the offset.
|
| 165 |
ENABLE_VAD: Final[bool] = (
|
| 166 |
os.getenv("WHISPER_VAD", "1").lower()
|
| 167 |
in {"1", "true", "yes", "on"}
|
|
@@ -174,12 +150,10 @@ VAD_MIN_SILENCE_MS: Final[int] = int(
|
|
| 174 |
)
|
| 175 |
)
|
| 176 |
|
| 177 |
-
# Whisper/faster-whisper decoding safeguards.
|
| 178 |
NO_SPEECH_THRESHOLD: Final[float] = 0.6
|
| 179 |
LOG_PROB_THRESHOLD: Final[float] = -1.0
|
| 180 |
COMPRESSION_RATIO_THRESHOLD: Final[float] = 2.4
|
| 181 |
|
| 182 |
-
# Native faster-whisper language detection.
|
| 183 |
LANGUAGE_DETECTION_SEGMENTS: Final[int] = max(
|
| 184 |
1,
|
| 185 |
int(
|
|
@@ -197,8 +171,6 @@ LANGUAGE_DETECTION_THRESHOLD: Final[float] = float(
|
|
| 197 |
)
|
| 198 |
)
|
| 199 |
|
| 200 |
-
# If auto-detection on a chunk is below this probability, keep detecting on
|
| 201 |
-
# later chunks instead of permanently locking the project to a weak guess.
|
| 202 |
LANGUAGE_LOCK_THRESHOLD: Final[float] = float(
|
| 203 |
os.getenv(
|
| 204 |
"LANGUAGE_LOCK_THRESHOLD",
|
|
@@ -206,7 +178,6 @@ LANGUAGE_LOCK_THRESHOLD: Final[float] = float(
|
|
| 206 |
)
|
| 207 |
)
|
| 208 |
|
| 209 |
-
# Manifest is checkpointed after every processed chunk.
|
| 210 |
CHECKPOINT_EVERY_CHUNKS: Final[int] = 1
|
| 211 |
|
| 212 |
FORCE_RETRANSCRIBE: Final[bool] = (
|
|
@@ -247,7 +218,6 @@ def _validate_project_id(project_id: str) -> str:
|
|
| 247 |
if value in {".", ".."}:
|
| 248 |
raise ValueError("Invalid project_id.")
|
| 249 |
|
| 250 |
-
# A project ID is a directory name, never a nested path.
|
| 251 |
if Path(value).name != value:
|
| 252 |
raise ValueError(
|
| 253 |
"project_id must be a single directory-safe name."
|
|
@@ -494,7 +464,6 @@ def _resolve_audio_path(
|
|
| 494 |
if path.is_absolute():
|
| 495 |
return path.resolve()
|
| 496 |
|
| 497 |
-
# Step 3 stores relative audio paths relative to project_dir.
|
| 498 |
project_dir = (
|
| 499 |
manifest_path.parent.parent
|
| 500 |
)
|
|
@@ -506,7 +475,6 @@ def _resolve_audio_path(
|
|
| 506 |
if candidate.exists():
|
| 507 |
return candidate.resolve()
|
| 508 |
|
| 509 |
-
# Backward-compatible fallback.
|
| 510 |
return (
|
| 511 |
manifest_path.parent / path
|
| 512 |
).resolve()
|
|
@@ -539,7 +507,7 @@ def _load_model() -> Any:
|
|
| 539 |
"cpu_threads=%d num_workers=%d",
|
| 540 |
MODEL_NAME,
|
| 541 |
COMPUTE_TYPE,
|
| 542 |
-
|
| 543 |
NUM_WORKERS,
|
| 544 |
)
|
| 545 |
|
|
@@ -552,8 +520,8 @@ def _load_model() -> Any:
|
|
| 552 |
MODEL_NAME,
|
| 553 |
device="cpu",
|
| 554 |
compute_type=COMPUTE_TYPE,
|
| 555 |
-
cpu_threads=
|
| 556 |
-
num_workers=
|
| 557 |
)
|
| 558 |
except Exception as exc:
|
| 559 |
raise RuntimeError(
|
|
@@ -624,14 +592,6 @@ def _transcribe_one(
|
|
| 624 |
audio_path: Path,
|
| 625 |
language: str | None,
|
| 626 |
):
|
| 627 |
-
"""
|
| 628 |
-
Run one speaker chunk.
|
| 629 |
-
|
| 630 |
-
When language=None, faster-whisper performs native Whisper language
|
| 631 |
-
detection and returns info.language / info.language_probability.
|
| 632 |
-
|
| 633 |
-
Word timestamps are requested because Step 005 needs them.
|
| 634 |
-
"""
|
| 635 |
kwargs: dict[str, Any] = {
|
| 636 |
"language": language,
|
| 637 |
"task": TASK,
|
|
@@ -1002,28 +962,12 @@ def _summarize_languages(
|
|
| 1002 |
def run_transcription(
|
| 1003 |
project_id: str,
|
| 1004 |
) -> dict[str, Any]:
|
| 1005 |
-
"""
|
| 1006 |
-
Run Step 004 for one project.
|
| 1007 |
-
|
| 1008 |
-
Only project_id is required.
|
| 1009 |
-
|
| 1010 |
-
Input:
|
| 1011 |
-
src/output/<project_id>/segments/segments_manifest.json
|
| 1012 |
-
|
| 1013 |
-
Output:
|
| 1014 |
-
src/output/<project_id>/transcribe/transcription.json
|
| 1015 |
-
src/output/<project_id>/transcribe/step_004_manifest.json
|
| 1016 |
-
|
| 1017 |
-
Also updates:
|
| 1018 |
-
src/output/<project_id>/segments/segments_manifest.json
|
| 1019 |
-
"""
|
| 1020 |
started = time.perf_counter()
|
| 1021 |
|
| 1022 |
paths = _get_paths(
|
| 1023 |
project_id
|
| 1024 |
)
|
| 1025 |
|
| 1026 |
-
# Fail early and clearly before loading the model.
|
| 1027 |
_validate_file(
|
| 1028 |
paths.segments_manifest,
|
| 1029 |
"Step 003 segments_manifest.json",
|
|
@@ -1097,8 +1041,6 @@ def run_transcription(
|
|
| 1097 |
REQUESTED_LANGUAGE
|
| 1098 |
)
|
| 1099 |
|
| 1100 |
-
# When auto-detecting, do not immediately lock the entire project to a
|
| 1101 |
-
# low-confidence first chunk.
|
| 1102 |
project_language: str | None = requested_language
|
| 1103 |
project_language_probability: float | None = None
|
| 1104 |
language_votes: list[tuple[str, float]] = []
|
|
@@ -1109,92 +1051,32 @@ def run_transcription(
|
|
| 1109 |
total_words = 0
|
| 1110 |
total_inference_seconds = 0.0
|
| 1111 |
|
| 1112 |
-
|
| 1113 |
-
|
| 1114 |
-
# ========================================================================
|
| 1115 |
-
|
| 1116 |
-
for index, chunk in enumerate(
|
| 1117 |
-
chunks,
|
| 1118 |
-
start=1,
|
| 1119 |
-
):
|
| 1120 |
-
chunk_id = chunk.get(
|
| 1121 |
-
"chunk_id",
|
| 1122 |
-
index,
|
| 1123 |
-
)
|
| 1124 |
|
| 1125 |
-
|
| 1126 |
-
|
| 1127 |
-
|
| 1128 |
-
"UNKNOWN",
|
| 1129 |
-
)
|
| 1130 |
-
)
|
| 1131 |
-
|
| 1132 |
-
# ----------------------------------------------------
|
| 1133 |
-
# Resume
|
| 1134 |
-
# ----------------------------------------------------
|
| 1135 |
-
|
| 1136 |
-
if (
|
| 1137 |
-
not FORCE_RETRANSCRIBE
|
| 1138 |
-
and _chunk_successful(
|
| 1139 |
-
chunk
|
| 1140 |
-
)
|
| 1141 |
-
):
|
| 1142 |
-
skipped += 1
|
| 1143 |
-
|
| 1144 |
-
previous_language = (
|
| 1145 |
-
chunk.get(
|
| 1146 |
-
"transcription",
|
| 1147 |
-
{},
|
| 1148 |
-
).get(
|
| 1149 |
-
"language"
|
| 1150 |
-
)
|
| 1151 |
-
)
|
| 1152 |
|
|
|
|
|
|
|
| 1153 |
previous_probability = _safe_float(
|
| 1154 |
-
chunk.get(
|
| 1155 |
-
"transcription",
|
| 1156 |
-
{},
|
| 1157 |
-
).get(
|
| 1158 |
-
"language_probability",
|
| 1159 |
-
0.0,
|
| 1160 |
-
)
|
| 1161 |
-
)
|
| 1162 |
-
|
| 1163 |
-
if previous_language:
|
| 1164 |
-
language_votes.append(
|
| 1165 |
-
(
|
| 1166 |
-
str(
|
| 1167 |
-
previous_language
|
| 1168 |
-
),
|
| 1169 |
-
previous_probability,
|
| 1170 |
-
)
|
| 1171 |
-
)
|
| 1172 |
-
|
| 1173 |
-
LOGGER.info(
|
| 1174 |
-
"[%d/%d] chunk=%s speaker=%s -> SKIP",
|
| 1175 |
-
index,
|
| 1176 |
-
len(chunks),
|
| 1177 |
-
chunk_id,
|
| 1178 |
-
speaker,
|
| 1179 |
)
|
| 1180 |
-
|
| 1181 |
-
|
| 1182 |
-
|
| 1183 |
-
|
| 1184 |
-
|
| 1185 |
-
|
| 1186 |
-
|
| 1187 |
-
|
| 1188 |
-
{}
|
| 1189 |
-
)
|
| 1190 |
|
| 1191 |
-
|
| 1192 |
-
|
| 1193 |
-
)
|
| 1194 |
|
| 1195 |
if not audio_path_value:
|
| 1196 |
-
|
| 1197 |
-
|
| 1198 |
chunk["transcription"] = {
|
| 1199 |
"status": "failed",
|
| 1200 |
"completed": False,
|
|
@@ -1203,41 +1085,14 @@ def run_transcription(
|
|
| 1203 |
"language_probability": None,
|
| 1204 |
"words": [],
|
| 1205 |
"word_count": 0,
|
| 1206 |
-
"error":
|
| 1207 |
-
"Missing audio.path "
|
| 1208 |
-
"in segments_manifest.json."
|
| 1209 |
-
),
|
| 1210 |
}
|
|
|
|
| 1211 |
|
| 1212 |
-
|
| 1213 |
-
paths.segments_manifest,
|
| 1214 |
-
manifest,
|
| 1215 |
-
)
|
| 1216 |
-
continue
|
| 1217 |
-
|
| 1218 |
-
audio_path = _resolve_audio_path(
|
| 1219 |
-
paths.segments_manifest,
|
| 1220 |
-
str(
|
| 1221 |
-
audio_path_value
|
| 1222 |
-
),
|
| 1223 |
-
)
|
| 1224 |
|
| 1225 |
if not audio_path.exists():
|
| 1226 |
-
|
| 1227 |
-
|
| 1228 |
-
error = (
|
| 1229 |
-
"Chunk audio not found: "
|
| 1230 |
-
f"{audio_path}"
|
| 1231 |
-
)
|
| 1232 |
-
|
| 1233 |
-
LOGGER.error(
|
| 1234 |
-
"[%d/%d] chunk=%s -> %s",
|
| 1235 |
-
index,
|
| 1236 |
-
len(chunks),
|
| 1237 |
-
chunk_id,
|
| 1238 |
-
error,
|
| 1239 |
-
)
|
| 1240 |
-
|
| 1241 |
chunk["transcription"] = {
|
| 1242 |
"status": "failed",
|
| 1243 |
"completed": False,
|
|
@@ -1248,157 +1103,40 @@ def run_transcription(
|
|
| 1248 |
"word_count": 0,
|
| 1249 |
"error": error,
|
| 1250 |
}
|
|
|
|
| 1251 |
|
| 1252 |
-
|
| 1253 |
-
|
| 1254 |
-
|
| 1255 |
-
)
|
| 1256 |
-
continue
|
| 1257 |
-
|
| 1258 |
-
# ----------------------------------------------------
|
| 1259 |
-
# Source timeline from Step 003
|
| 1260 |
-
# ----------------------------------------------------
|
| 1261 |
-
|
| 1262 |
-
timeline = chunk.get(
|
| 1263 |
-
"source_timeline",
|
| 1264 |
-
{}
|
| 1265 |
-
)
|
| 1266 |
-
|
| 1267 |
-
source_start = max(
|
| 1268 |
-
0.0,
|
| 1269 |
-
_safe_float(
|
| 1270 |
-
timeline.get(
|
| 1271 |
-
"start",
|
| 1272 |
-
0.0,
|
| 1273 |
-
)
|
| 1274 |
-
),
|
| 1275 |
-
)
|
| 1276 |
-
|
| 1277 |
-
source_end = max(
|
| 1278 |
-
source_start,
|
| 1279 |
-
_safe_float(
|
| 1280 |
-
timeline.get(
|
| 1281 |
-
"end",
|
| 1282 |
-
source_start,
|
| 1283 |
-
)
|
| 1284 |
-
),
|
| 1285 |
-
)
|
| 1286 |
-
|
| 1287 |
-
LOGGER.info(
|
| 1288 |
-
"[%d/%d] chunk=%s speaker=%s %.3f -> %.3f",
|
| 1289 |
-
index,
|
| 1290 |
-
len(chunks),
|
| 1291 |
-
chunk_id,
|
| 1292 |
-
speaker,
|
| 1293 |
-
source_start,
|
| 1294 |
-
source_end,
|
| 1295 |
-
)
|
| 1296 |
-
|
| 1297 |
-
# ----------------------------------------------------
|
| 1298 |
-
# Choose language for this chunk
|
| 1299 |
-
# ----------------------------------------------------
|
| 1300 |
|
| 1301 |
if DETECT_LANGUAGE_PER_CHUNK:
|
| 1302 |
language_for_chunk = None
|
| 1303 |
else:
|
| 1304 |
-
language_for_chunk =
|
| 1305 |
-
|
| 1306 |
-
|
| 1307 |
-
# project language.
|
| 1308 |
-
if (
|
| 1309 |
-
requested_language is None
|
| 1310 |
-
and (
|
| 1311 |
-
project_language is None
|
| 1312 |
-
or (
|
| 1313 |
-
project_language_probability is not None
|
| 1314 |
-
and project_language_probability
|
| 1315 |
-
< LANGUAGE_LOCK_THRESHOLD
|
| 1316 |
-
)
|
| 1317 |
-
)
|
| 1318 |
-
):
|
| 1319 |
-
language_for_chunk = None
|
| 1320 |
|
| 1321 |
inference_started = time.perf_counter()
|
| 1322 |
-
|
| 1323 |
try:
|
| 1324 |
decoded_segments, info = _transcribe_one(
|
| 1325 |
model,
|
| 1326 |
audio_path,
|
| 1327 |
language_for_chunk,
|
| 1328 |
)
|
|
|
|
| 1329 |
|
| 1330 |
-
|
| 1331 |
-
|
| 1332 |
-
|
| 1333 |
-
)
|
| 1334 |
-
|
| 1335 |
-
text = _extract_text(
|
| 1336 |
-
decoded_segments
|
| 1337 |
-
)
|
| 1338 |
|
| 1339 |
-
|
| 1340 |
-
|
| 1341 |
-
info
|
| 1342 |
-
)
|
| 1343 |
-
)
|
| 1344 |
-
|
| 1345 |
-
if detected_language:
|
| 1346 |
-
language_votes.append(
|
| 1347 |
-
(
|
| 1348 |
-
detected_language,
|
| 1349 |
-
language_probability or 0.0,
|
| 1350 |
-
)
|
| 1351 |
-
)
|
| 1352 |
-
|
| 1353 |
-
# Lock the project language only after sufficient confidence.
|
| 1354 |
-
if (
|
| 1355 |
-
requested_language is None
|
| 1356 |
-
and not DETECT_LANGUAGE_PER_CHUNK
|
| 1357 |
-
and (
|
| 1358 |
-
project_language is None
|
| 1359 |
-
or (
|
| 1360 |
-
language_probability is not None
|
| 1361 |
-
and language_probability
|
| 1362 |
-
> (
|
| 1363 |
-
project_language_probability
|
| 1364 |
-
or 0.0
|
| 1365 |
-
)
|
| 1366 |
-
)
|
| 1367 |
-
)
|
| 1368 |
-
):
|
| 1369 |
-
project_language = detected_language
|
| 1370 |
-
project_language_probability = (
|
| 1371 |
-
language_probability
|
| 1372 |
-
)
|
| 1373 |
-
|
| 1374 |
-
final_chunk_language = (
|
| 1375 |
-
detected_language
|
| 1376 |
-
or project_language
|
| 1377 |
-
)
|
| 1378 |
-
|
| 1379 |
-
words = _extract_word_timestamps(
|
| 1380 |
-
decoded_segments,
|
| 1381 |
-
source_start,
|
| 1382 |
-
source_end,
|
| 1383 |
-
)
|
| 1384 |
-
|
| 1385 |
-
transcript_segments = _extract_segments(
|
| 1386 |
-
decoded_segments,
|
| 1387 |
-
source_start,
|
| 1388 |
-
source_end,
|
| 1389 |
-
)
|
| 1390 |
|
| 1391 |
word_probabilities = [
|
| 1392 |
-
float(
|
| 1393 |
-
item["probability"]
|
| 1394 |
-
)
|
| 1395 |
-
for item in words
|
| 1396 |
-
if "probability" in item
|
| 1397 |
]
|
| 1398 |
-
|
| 1399 |
average_word_probability = (
|
| 1400 |
-
sum(word_probabilities)
|
| 1401 |
-
/ len(word_probabilities)
|
| 1402 |
if word_probabilities
|
| 1403 |
else None
|
| 1404 |
)
|
|
@@ -1409,20 +1147,9 @@ def run_transcription(
|
|
| 1409 |
"text": text,
|
| 1410 |
"language": final_chunk_language,
|
| 1411 |
"language_probability": (
|
| 1412 |
-
round(
|
| 1413 |
-
language_probability,
|
| 1414 |
-
4,
|
| 1415 |
-
)
|
| 1416 |
if language_probability is not None
|
| 1417 |
-
else
|
| 1418 |
-
round(
|
| 1419 |
-
project_language_probability,
|
| 1420 |
-
4,
|
| 1421 |
-
)
|
| 1422 |
-
if project_language_probability
|
| 1423 |
-
is not None
|
| 1424 |
-
else None
|
| 1425 |
-
)
|
| 1426 |
),
|
| 1427 |
"model": MODEL_NAME,
|
| 1428 |
"engine": "faster-whisper",
|
|
@@ -1436,73 +1163,33 @@ def run_transcription(
|
|
| 1436 |
"word_count": len(words),
|
| 1437 |
"segments": transcript_segments,
|
| 1438 |
"average_word_probability": (
|
| 1439 |
-
round(
|
| 1440 |
-
|
| 1441 |
-
4,
|
| 1442 |
-
)
|
| 1443 |
-
if average_word_probability
|
| 1444 |
-
is not None
|
| 1445 |
else None
|
| 1446 |
),
|
| 1447 |
-
"inference_time_seconds": round(
|
| 1448 |
-
inference_seconds,
|
| 1449 |
-
3,
|
| 1450 |
-
),
|
| 1451 |
}
|
| 1452 |
|
| 1453 |
chunk["subtitle"] = {
|
| 1454 |
"ready": bool(words),
|
| 1455 |
-
"absolute_start": round(
|
| 1456 |
-
|
| 1457 |
-
|
| 1458 |
-
),
|
| 1459 |
-
"absolute_end": round(
|
| 1460 |
-
source_end,
|
| 1461 |
-
3,
|
| 1462 |
-
),
|
| 1463 |
-
"word_timestamps_available": bool(
|
| 1464 |
-
words
|
| 1465 |
-
),
|
| 1466 |
}
|
| 1467 |
|
| 1468 |
-
|
| 1469 |
-
|
| 1470 |
-
|
| 1471 |
-
|
| 1472 |
-
|
| 1473 |
-
|
| 1474 |
-
|
| 1475 |
-
"
|
| 1476 |
-
"
|
| 1477 |
-
|
| 1478 |
-
len(chunks),
|
| 1479 |
-
chunk_id,
|
| 1480 |
-
len(words),
|
| 1481 |
-
final_chunk_language or "unknown",
|
| 1482 |
-
language_probability
|
| 1483 |
-
if language_probability is not None
|
| 1484 |
-
else (
|
| 1485 |
-
project_language_probability
|
| 1486 |
-
or 0.0
|
| 1487 |
-
),
|
| 1488 |
-
inference_seconds,
|
| 1489 |
-
)
|
| 1490 |
|
| 1491 |
except Exception as exc:
|
| 1492 |
-
inference_seconds = (
|
| 1493 |
-
time.perf_counter()
|
| 1494 |
-
- inference_started
|
| 1495 |
-
)
|
| 1496 |
-
|
| 1497 |
-
failed += 1
|
| 1498 |
-
|
| 1499 |
-
LOGGER.exception(
|
| 1500 |
-
"[%d/%d] FAILED chunk=%s",
|
| 1501 |
-
index,
|
| 1502 |
-
len(chunks),
|
| 1503 |
-
chunk_id,
|
| 1504 |
-
)
|
| 1505 |
-
|
| 1506 |
chunk["transcription"] = {
|
| 1507 |
"status": "failed",
|
| 1508 |
"completed": False,
|
|
@@ -1517,55 +1204,102 @@ def run_transcription(
|
|
| 1517 |
"words": [],
|
| 1518 |
"word_count": 0,
|
| 1519 |
"segments": [],
|
| 1520 |
-
"inference_time_seconds": round(
|
| 1521 |
-
inference_seconds,
|
| 1522 |
-
3,
|
| 1523 |
-
),
|
| 1524 |
"error": str(exc),
|
| 1525 |
}
|
| 1526 |
-
|
| 1527 |
chunk["subtitle"] = {
|
| 1528 |
"ready": False,
|
| 1529 |
-
"absolute_start": round(
|
| 1530 |
-
|
| 1531 |
-
3,
|
| 1532 |
-
),
|
| 1533 |
-
"absolute_end": round(
|
| 1534 |
-
source_end,
|
| 1535 |
-
3,
|
| 1536 |
-
),
|
| 1537 |
"word_timestamps_available": False,
|
| 1538 |
}
|
|
|
|
| 1539 |
|
| 1540 |
-
|
| 1541 |
-
|
|
|
|
| 1542 |
|
| 1543 |
-
|
| 1544 |
-
|
| 1545 |
-
|
|
|
|
|
|
|
| 1546 |
|
| 1547 |
-
|
| 1548 |
-
|
| 1549 |
-
|
| 1550 |
-
|
| 1551 |
-
|
| 1552 |
-
|
| 1553 |
-
|
| 1554 |
-
|
| 1555 |
-
|
| 1556 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1557 |
|
| 1558 |
-
|
| 1559 |
-
|
| 1560 |
-
|
| 1561 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1562 |
|
| 1563 |
-
|
| 1564 |
-
|
| 1565 |
-
|
| 1566 |
-
|
| 1567 |
-
|
| 1568 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1569 |
|
| 1570 |
# =========================================================================
|
| 1571 |
# FINAL LANGUAGE
|
|
@@ -1664,7 +1398,6 @@ def run_transcription(
|
|
| 1664 |
),
|
| 1665 |
}
|
| 1666 |
|
| 1667 |
-
# Remove temporary progress marker after completion.
|
| 1668 |
manifest.pop(
|
| 1669 |
"step_004_progress",
|
| 1670 |
None,
|
|
@@ -1992,4 +1725,4 @@ if __name__ == "__main__":
|
|
| 1992 |
LOGGER.exception(
|
| 1993 |
"Step 004 failed."
|
| 1994 |
)
|
| 1995 |
-
raise SystemExit(1)
|
|
|
|
| 1 |
"""
|
| 2 |
+
Pipeline Step 004 - Speaker-Aware Transcription (Optimized Concurrency).
|
| 3 |
|
| 4 |
Project layout used by this pipeline:
|
| 5 |
|
|
|
|
| 27 |
2. Uses every prepared speaker WAV referenced by the manifest.
|
| 28 |
3. Uses faster-whisper + CTranslate2.
|
| 29 |
4. Uses Whisper medium.
|
| 30 |
+
5. Uses INT8 CPU inference with optimized multi-worker concurrency.
|
| 31 |
+
6. Uses the available physical CPU cores by default, split efficiently.
|
| 32 |
7. Detects language automatically when WHISPER_LANGUAGE is unset.
|
| 33 |
8. Stores language and confidence in the output.
|
| 34 |
9. Produces word-level timestamps.
|
|
|
|
| 46 |
|
| 47 |
from __future__ import annotations
|
| 48 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 49 |
import gc
|
| 50 |
import json
|
| 51 |
import logging
|
|
|
|
| 53 |
import os
|
| 54 |
import time
|
| 55 |
from collections import Counter
|
| 56 |
+
from concurrent.futures import ThreadPoolExecutor, as_completed
|
| 57 |
from dataclasses import dataclass
|
| 58 |
from pathlib import Path
|
| 59 |
+
from threading import Lock
|
| 60 |
from typing import Any, Final
|
| 61 |
|
| 62 |
try:
|
|
|
|
| 92 |
|
| 93 |
CPU_THREADS: Final[int] = _detect_cpu_threads()
|
| 94 |
|
| 95 |
+
# Optimize concurrent execution: split physical cores across parallel workers
|
| 96 |
+
# to maximize CPU throughput without thread contention.
|
| 97 |
+
NUM_WORKERS: Final[int] = max(1, min(CPU_THREADS // 4, 4))
|
| 98 |
+
THREADS_PER_WORKER: Final[int] = max(1, CPU_THREADS // NUM_WORKERS)
|
| 99 |
+
|
| 100 |
+
os.environ.setdefault("OMP_NUM_THREADS", str(THREADS_PER_WORKER))
|
| 101 |
+
os.environ.setdefault("MKL_NUM_THREADS", str(THREADS_PER_WORKER))
|
| 102 |
+
os.environ.setdefault("OPENBLAS_NUM_THREADS", str(THREADS_PER_WORKER))
|
| 103 |
+
os.environ.setdefault("NUMEXPR_NUM_THREADS", str(THREADS_PER_WORKER))
|
| 104 |
os.environ.setdefault("TOKENIZERS_PARALLELISM", "false")
|
| 105 |
|
| 106 |
# ============================================================================
|
| 107 |
# CONFIGURATION
|
| 108 |
# ============================================================================
|
| 109 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 110 |
SRC_ROOT: Final[Path] = Path(__file__).resolve().parents[1]
|
| 111 |
DEFAULT_OUTPUT_ROOT: Final[Path] = SRC_ROOT / "output"
|
| 112 |
|
|
|
|
| 120 |
"int8",
|
| 121 |
)
|
| 122 |
|
|
|
|
|
|
|
|
|
|
| 123 |
BEAM_SIZE: Final[int] = max(
|
| 124 |
1,
|
| 125 |
int(os.getenv("WHISPER_BEAM_SIZE", "5")),
|
|
|
|
| 129 |
|
| 130 |
TASK: Final[str] = "transcribe"
|
| 131 |
|
|
|
|
| 132 |
REQUESTED_LANGUAGE: Final[str | None] = (
|
| 133 |
os.getenv("WHISPER_LANGUAGE") or None
|
| 134 |
)
|
| 135 |
|
|
|
|
| 136 |
DETECT_LANGUAGE_PER_CHUNK: Final[bool] = (
|
| 137 |
os.getenv("DETECT_LANGUAGE_PER_CHUNK", "0").lower()
|
| 138 |
in {"1", "true", "yes", "on"}
|
| 139 |
)
|
| 140 |
|
|
|
|
|
|
|
|
|
|
| 141 |
ENABLE_VAD: Final[bool] = (
|
| 142 |
os.getenv("WHISPER_VAD", "1").lower()
|
| 143 |
in {"1", "true", "yes", "on"}
|
|
|
|
| 150 |
)
|
| 151 |
)
|
| 152 |
|
|
|
|
| 153 |
NO_SPEECH_THRESHOLD: Final[float] = 0.6
|
| 154 |
LOG_PROB_THRESHOLD: Final[float] = -1.0
|
| 155 |
COMPRESSION_RATIO_THRESHOLD: Final[float] = 2.4
|
| 156 |
|
|
|
|
| 157 |
LANGUAGE_DETECTION_SEGMENTS: Final[int] = max(
|
| 158 |
1,
|
| 159 |
int(
|
|
|
|
| 171 |
)
|
| 172 |
)
|
| 173 |
|
|
|
|
|
|
|
| 174 |
LANGUAGE_LOCK_THRESHOLD: Final[float] = float(
|
| 175 |
os.getenv(
|
| 176 |
"LANGUAGE_LOCK_THRESHOLD",
|
|
|
|
| 178 |
)
|
| 179 |
)
|
| 180 |
|
|
|
|
| 181 |
CHECKPOINT_EVERY_CHUNKS: Final[int] = 1
|
| 182 |
|
| 183 |
FORCE_RETRANSCRIBE: Final[bool] = (
|
|
|
|
| 218 |
if value in {".", ".."}:
|
| 219 |
raise ValueError("Invalid project_id.")
|
| 220 |
|
|
|
|
| 221 |
if Path(value).name != value:
|
| 222 |
raise ValueError(
|
| 223 |
"project_id must be a single directory-safe name."
|
|
|
|
| 464 |
if path.is_absolute():
|
| 465 |
return path.resolve()
|
| 466 |
|
|
|
|
| 467 |
project_dir = (
|
| 468 |
manifest_path.parent.parent
|
| 469 |
)
|
|
|
|
| 475 |
if candidate.exists():
|
| 476 |
return candidate.resolve()
|
| 477 |
|
|
|
|
| 478 |
return (
|
| 479 |
manifest_path.parent / path
|
| 480 |
).resolve()
|
|
|
|
| 507 |
"cpu_threads=%d num_workers=%d",
|
| 508 |
MODEL_NAME,
|
| 509 |
COMPUTE_TYPE,
|
| 510 |
+
THREADS_PER_WORKER,
|
| 511 |
NUM_WORKERS,
|
| 512 |
)
|
| 513 |
|
|
|
|
| 520 |
MODEL_NAME,
|
| 521 |
device="cpu",
|
| 522 |
compute_type=COMPUTE_TYPE,
|
| 523 |
+
cpu_threads=THREADS_PER_WORKER,
|
| 524 |
+
num_workers=1,
|
| 525 |
)
|
| 526 |
except Exception as exc:
|
| 527 |
raise RuntimeError(
|
|
|
|
| 592 |
audio_path: Path,
|
| 593 |
language: str | None,
|
| 594 |
):
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 595 |
kwargs: dict[str, Any] = {
|
| 596 |
"language": language,
|
| 597 |
"task": TASK,
|
|
|
|
| 962 |
def run_transcription(
|
| 963 |
project_id: str,
|
| 964 |
) -> dict[str, Any]:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 965 |
started = time.perf_counter()
|
| 966 |
|
| 967 |
paths = _get_paths(
|
| 968 |
project_id
|
| 969 |
)
|
| 970 |
|
|
|
|
| 971 |
_validate_file(
|
| 972 |
paths.segments_manifest,
|
| 973 |
"Step 003 segments_manifest.json",
|
|
|
|
| 1041 |
REQUESTED_LANGUAGE
|
| 1042 |
)
|
| 1043 |
|
|
|
|
|
|
|
| 1044 |
project_language: str | None = requested_language
|
| 1045 |
project_language_probability: float | None = None
|
| 1046 |
language_votes: list[tuple[str, float]] = []
|
|
|
|
| 1051 |
total_words = 0
|
| 1052 |
total_inference_seconds = 0.0
|
| 1053 |
|
| 1054 |
+
manifest_lock = Lock()
|
| 1055 |
+
completed_counter = 0
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1056 |
|
| 1057 |
+
def process_chunk(index: int, chunk: dict[str, Any]) -> dict[str, Any]:
|
| 1058 |
+
chunk_id = chunk.get("chunk_id", index)
|
| 1059 |
+
speaker = str(chunk.get("speaker", "UNKNOWN"))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1060 |
|
| 1061 |
+
if not FORCE_RETRANSCRIBE and _chunk_successful(chunk):
|
| 1062 |
+
previous_language = chunk.get("transcription", {}).get("language")
|
| 1063 |
previous_probability = _safe_float(
|
| 1064 |
+
chunk.get("transcription", {}).get("language_probability", 0.0)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1065 |
)
|
| 1066 |
+
return {
|
| 1067 |
+
"status": "skipped",
|
| 1068 |
+
"index": index,
|
| 1069 |
+
"chunk_id": chunk_id,
|
| 1070 |
+
"speaker": speaker,
|
| 1071 |
+
"language": previous_language,
|
| 1072 |
+
"probability": previous_probability,
|
| 1073 |
+
}
|
|
|
|
|
|
|
| 1074 |
|
| 1075 |
+
audio = chunk.get("audio", {})
|
| 1076 |
+
audio_path_value = audio.get("path")
|
|
|
|
| 1077 |
|
| 1078 |
if not audio_path_value:
|
| 1079 |
+
error = "Missing audio.path in segments_manifest.json."
|
|
|
|
| 1080 |
chunk["transcription"] = {
|
| 1081 |
"status": "failed",
|
| 1082 |
"completed": False,
|
|
|
|
| 1085 |
"language_probability": None,
|
| 1086 |
"words": [],
|
| 1087 |
"word_count": 0,
|
| 1088 |
+
"error": error,
|
|
|
|
|
|
|
|
|
|
| 1089 |
}
|
| 1090 |
+
return {"status": "failed", "index": index, "chunk_id": chunk_id, "error": error}
|
| 1091 |
|
| 1092 |
+
audio_path = _resolve_audio_path(paths.segments_manifest, str(audio_path_value))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1093 |
|
| 1094 |
if not audio_path.exists():
|
| 1095 |
+
error = f"Chunk audio not found: {audio_path}"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1096 |
chunk["transcription"] = {
|
| 1097 |
"status": "failed",
|
| 1098 |
"completed": False,
|
|
|
|
| 1103 |
"word_count": 0,
|
| 1104 |
"error": error,
|
| 1105 |
}
|
| 1106 |
+
return {"status": "failed", "index": index, "chunk_id": chunk_id, "error": error}
|
| 1107 |
|
| 1108 |
+
timeline = chunk.get("source_timeline", {})
|
| 1109 |
+
source_start = max(0.0, _safe_float(timeline.get("start", 0.0)))
|
| 1110 |
+
source_end = max(source_start, _safe_float(timeline.get("end", source_start)))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1111 |
|
| 1112 |
if DETECT_LANGUAGE_PER_CHUNK:
|
| 1113 |
language_for_chunk = None
|
| 1114 |
else:
|
| 1115 |
+
language_for_chunk = requested_language
|
| 1116 |
+
if requested_language is None and project_language is not None:
|
| 1117 |
+
language_for_chunk = project_language
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1118 |
|
| 1119 |
inference_started = time.perf_counter()
|
|
|
|
| 1120 |
try:
|
| 1121 |
decoded_segments, info = _transcribe_one(
|
| 1122 |
model,
|
| 1123 |
audio_path,
|
| 1124 |
language_for_chunk,
|
| 1125 |
)
|
| 1126 |
+
inference_seconds = time.perf_counter() - inference_started
|
| 1127 |
|
| 1128 |
+
text = _extract_text(decoded_segments)
|
| 1129 |
+
detected_language, language_probability = _language_from_info(info)
|
| 1130 |
+
final_chunk_language = detected_language or project_language
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1131 |
|
| 1132 |
+
words = _extract_word_timestamps(decoded_segments, source_start, source_end)
|
| 1133 |
+
transcript_segments = _extract_segments(decoded_segments, source_start, source_end)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1134 |
|
| 1135 |
word_probabilities = [
|
| 1136 |
+
float(item["probability"]) for item in words if "probability" in item
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1137 |
]
|
|
|
|
| 1138 |
average_word_probability = (
|
| 1139 |
+
sum(word_probabilities) / len(word_probabilities)
|
|
|
|
| 1140 |
if word_probabilities
|
| 1141 |
else None
|
| 1142 |
)
|
|
|
|
| 1147 |
"text": text,
|
| 1148 |
"language": final_chunk_language,
|
| 1149 |
"language_probability": (
|
| 1150 |
+
round(language_probability, 4)
|
|
|
|
|
|
|
|
|
|
| 1151 |
if language_probability is not None
|
| 1152 |
+
else None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1153 |
),
|
| 1154 |
"model": MODEL_NAME,
|
| 1155 |
"engine": "faster-whisper",
|
|
|
|
| 1163 |
"word_count": len(words),
|
| 1164 |
"segments": transcript_segments,
|
| 1165 |
"average_word_probability": (
|
| 1166 |
+
round(average_word_probability, 4)
|
| 1167 |
+
if average_word_probability is not None
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1168 |
else None
|
| 1169 |
),
|
| 1170 |
+
"inference_time_seconds": round(inference_seconds, 3),
|
|
|
|
|
|
|
|
|
|
| 1171 |
}
|
| 1172 |
|
| 1173 |
chunk["subtitle"] = {
|
| 1174 |
"ready": bool(words),
|
| 1175 |
+
"absolute_start": round(source_start, 3),
|
| 1176 |
+
"absolute_end": round(source_end, 3),
|
| 1177 |
+
"word_timestamps_available": bool(words),
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1178 |
}
|
| 1179 |
|
| 1180 |
+
return {
|
| 1181 |
+
"status": "success",
|
| 1182 |
+
"index": index,
|
| 1183 |
+
"chunk_id": chunk_id,
|
| 1184 |
+
"speaker": speaker,
|
| 1185 |
+
"words_len": len(words),
|
| 1186 |
+
"inference_seconds": inference_seconds,
|
| 1187 |
+
"language": detected_language,
|
| 1188 |
+
"probability": language_probability,
|
| 1189 |
+
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1190 |
|
| 1191 |
except Exception as exc:
|
| 1192 |
+
inference_seconds = time.perf_counter() - inference_started
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1193 |
chunk["transcription"] = {
|
| 1194 |
"status": "failed",
|
| 1195 |
"completed": False,
|
|
|
|
| 1204 |
"words": [],
|
| 1205 |
"word_count": 0,
|
| 1206 |
"segments": [],
|
| 1207 |
+
"inference_time_seconds": round(inference_seconds, 3),
|
|
|
|
|
|
|
|
|
|
| 1208 |
"error": str(exc),
|
| 1209 |
}
|
|
|
|
| 1210 |
chunk["subtitle"] = {
|
| 1211 |
"ready": False,
|
| 1212 |
+
"absolute_start": round(source_start, 3),
|
| 1213 |
+
"absolute_end": round(source_end, 3),
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1214 |
"word_timestamps_available": False,
|
| 1215 |
}
|
| 1216 |
+
return {"status": "failed_exception", "index": index, "chunk_id": chunk_id, "error": str(exc)}
|
| 1217 |
|
| 1218 |
+
# ========================================================================
|
| 1219 |
+
# CONCURRENT CHUNK EXECUTION
|
| 1220 |
+
# ========================================================================
|
| 1221 |
|
| 1222 |
+
with ThreadPoolExecutor(max_workers=NUM_WORKERS) as executor:
|
| 1223 |
+
futures = {
|
| 1224 |
+
executor.submit(process_chunk, index, chunk): (index, chunk)
|
| 1225 |
+
for index, chunk in enumerate(chunks, start=1)
|
| 1226 |
+
}
|
| 1227 |
|
| 1228 |
+
for future in as_completed(futures):
|
| 1229 |
+
res = future.result()
|
| 1230 |
+
status = res["status"]
|
| 1231 |
+
index = res["index"]
|
| 1232 |
+
chunk_id = res["chunk_id"]
|
| 1233 |
+
|
| 1234 |
+
with manifest_lock:
|
| 1235 |
+
completed_counter += 1
|
| 1236 |
+
|
| 1237 |
+
if status == "success":
|
| 1238 |
+
successful += 1
|
| 1239 |
+
total_words += res["words_len"]
|
| 1240 |
+
total_inference_seconds += res["inference_seconds"]
|
| 1241 |
+
lang = res["language"]
|
| 1242 |
+
prob = res["probability"]
|
| 1243 |
+
if lang:
|
| 1244 |
+
language_votes.append((lang, prob or 0.0))
|
| 1245 |
+
if (
|
| 1246 |
+
requested_language is None
|
| 1247 |
+
and not DETECT_LANGUAGE_PER_CHUNK
|
| 1248 |
+
and (
|
| 1249 |
+
project_language is None
|
| 1250 |
+
or (prob is not None and prob > (project_language_probability or 0.0))
|
| 1251 |
+
)
|
| 1252 |
+
):
|
| 1253 |
+
project_language = lang
|
| 1254 |
+
project_language_probability = prob
|
| 1255 |
+
|
| 1256 |
+
LOGGER.info(
|
| 1257 |
+
"[%d/%d] OK chunk=%s words=%d language=%s prob=%.3f inference=%.2fs",
|
| 1258 |
+
index,
|
| 1259 |
+
len(chunks),
|
| 1260 |
+
chunk_id,
|
| 1261 |
+
res["words_len"],
|
| 1262 |
+
res["language"] or project_language or "unknown",
|
| 1263 |
+
prob or project_language_probability or 0.0,
|
| 1264 |
+
res["inference_seconds"],
|
| 1265 |
+
)
|
| 1266 |
|
| 1267 |
+
elif status == "skipped":
|
| 1268 |
+
skipped += 1
|
| 1269 |
+
if res["language"]:
|
| 1270 |
+
language_votes.append((str(res["language"]), res["probability"]))
|
| 1271 |
+
LOGGER.info(
|
| 1272 |
+
"[%d/%d] chunk=%s speaker=%s -> SKIP",
|
| 1273 |
+
index,
|
| 1274 |
+
len(chunks),
|
| 1275 |
+
chunk_id,
|
| 1276 |
+
res["speaker"],
|
| 1277 |
+
)
|
| 1278 |
|
| 1279 |
+
else:
|
| 1280 |
+
failed += 1
|
| 1281 |
+
LOGGER.error(
|
| 1282 |
+
"[%d/%d] chunk=%s -> %s",
|
| 1283 |
+
index,
|
| 1284 |
+
len(chunks),
|
| 1285 |
+
chunk_id,
|
| 1286 |
+
res.get("error", "Unknown error"),
|
| 1287 |
+
)
|
| 1288 |
+
|
| 1289 |
+
if completed_counter % CHECKPOINT_EVERY_CHUNKS == 0:
|
| 1290 |
+
manifest["step_004_progress"] = {
|
| 1291 |
+
"processed": completed_counter,
|
| 1292 |
+
"total": len(chunks),
|
| 1293 |
+
"successful": successful,
|
| 1294 |
+
"failed": failed,
|
| 1295 |
+
"skipped": skipped,
|
| 1296 |
+
}
|
| 1297 |
+
_checkpoint_manifest(paths.segments_manifest, manifest)
|
| 1298 |
+
|
| 1299 |
+
if completed_counter % MEMORY_LOG_INTERVAL == 0:
|
| 1300 |
+
_log_memory(f"after chunk {completed_counter}/{len(chunks)}")
|
| 1301 |
+
|
| 1302 |
+
gc.collect()
|
| 1303 |
|
| 1304 |
# =========================================================================
|
| 1305 |
# FINAL LANGUAGE
|
|
|
|
| 1398 |
),
|
| 1399 |
}
|
| 1400 |
|
|
|
|
| 1401 |
manifest.pop(
|
| 1402 |
"step_004_progress",
|
| 1403 |
None,
|
|
|
|
| 1725 |
LOGGER.exception(
|
| 1726 |
"Step 004 failed."
|
| 1727 |
)
|
| 1728 |
+
raise SystemExit(1)
|