VeuReu commited on
Commit
d69fb4f
·
verified ·
1 Parent(s): dd44c30

Upload api.py

Browse files
Files changed (1) hide show
  1. api.py +252 -221
api.py CHANGED
@@ -1,224 +1,226 @@
1
- from __future__ import annotations
2
- from fastapi import FastAPI, UploadFile, File, Form, BackgroundTasks, HTTPException
3
- from fastapi.responses import JSONResponse, FileResponse
4
- from fastapi.middleware.cors import CORSMiddleware
5
- from pathlib import Path
6
- import shutil
7
- import uvicorn
8
- import json
9
- import uuid
10
- from datetime import datetime
11
- from typing import Dict
12
- from enum import Enum
13
- import os
14
-
15
- from video_processing import process_video_pipeline
16
- from casting_loader import ensure_chroma, build_faces_index, build_voices_index
17
- from narration_system import NarrationSystem
18
- from llm_router import load_yaml, LLMRouter
19
- from character_detection import detect_characters_from_video
20
-
21
- app = FastAPI(title="Veureu Engine API", version="0.2.0")
22
- app.add_middleware(
23
- CORSMiddleware,
24
- allow_origins=["*"],
25
- allow_credentials=True,
26
- allow_methods=["*"],
27
- allow_headers=["*"],
28
- )
29
-
30
- ROOT = Path("/tmp/veureu")
31
- ROOT.mkdir(parents=True, exist_ok=True)
32
- TEMP_ROOT = Path("/tmp/temp")
33
- TEMP_ROOT.mkdir(parents=True, exist_ok=True)
34
- VIDEOS_ROOT = Path("/tmp/data/videos")
35
- VIDEOS_ROOT.mkdir(parents=True, exist_ok=True)
36
-
37
- # Sistema de jobs asíncronos
38
- class JobStatus(str, Enum):
39
- QUEUED = "queued"
40
- PROCESSING = "processing"
41
- DONE = "done"
42
- FAILED = "failed"
43
-
44
- jobs: Dict[str, dict] = {}
45
-
46
- @app.get("/")
47
- def root():
48
- return {"ok": True, "service": "veureu-engine"}
49
-
50
- @app.post("/process_video")
51
- async def process_video(
52
- video_file: UploadFile = File(...),
53
- config_path: str = Form("config.yaml"),
54
- out_root: str = Form("results"),
55
- db_dir: str = Form("chroma_db"),
56
- ):
57
- tmp_video = ROOT / video_file.filename
58
- with tmp_video.open("wb") as f:
59
- shutil.copyfileobj(video_file.file, f)
60
- result = process_video_pipeline(str(tmp_video), config_path=config_path, out_root=out_root, db_dir=db_dir)
61
- return JSONResponse(result)
62
-
63
- @app.post("/create_initial_casting")
64
- async def create_initial_casting(
65
- background_tasks: BackgroundTasks,
66
- video: UploadFile = File(...),
67
- epsilon: float = Form(...),
68
- min_cluster_size: int = Form(...),
69
- ):
70
- """
71
- Crea un job para procesar el vídeo de forma asíncrona.
72
- Devuelve un job_id inmediatamente.
73
- """
74
- # Guardar vídeo en carpeta de datos
75
- video_name = Path(video.filename).stem
76
- dst_video = VIDEOS_ROOT / f"{video_name}.mp4"
77
- with dst_video.open("wb") as f:
78
- shutil.copyfileobj(video.file, f)
79
-
80
- # Crear job_id único
81
- job_id = str(uuid.uuid4())
82
-
83
- # Inicializar el job
84
- jobs[job_id] = {
85
- "id": job_id,
86
- "status": JobStatus.QUEUED,
87
- "video_path": str(dst_video),
88
- "video_name": video_name,
89
- "epsilon": float(epsilon),
90
- "min_cluster_size": int(min_cluster_size),
91
- "created_at": datetime.now().isoformat(),
92
- "results": None,
93
- "error": None
94
- }
95
-
96
- print(f"[{job_id}] Job creado para vídeo: {video_name}")
97
-
98
- # Iniciar procesamiento en background
99
- background_tasks.add_task(process_video_job, job_id)
100
-
101
- # Devolver job_id inmediatamente
102
- return {"job_id": job_id}
103
-
104
- @app.get("/jobs/{job_id}/status")
105
- def get_job_status(job_id: str):
106
- """
107
- Devuelve el estado actual de un job.
108
- El UI hace polling de este endpoint cada 5 segundos.
109
- """
110
- if job_id not in jobs:
111
- raise HTTPException(status_code=404, detail="Job not found")
112
-
113
- job = jobs[job_id]
114
-
115
- # Normalizar el estado a string
116
- status_value = job["status"].value if isinstance(job["status"], JobStatus) else str(job["status"])
117
- response = {"status": status_value}
118
-
119
- # Incluir resultados si existen (evita condiciones de carrera)
120
- if job.get("results") is not None:
121
- response["results"] = job["results"]
122
-
123
- # Incluir error si existe
124
- if job.get("error"):
125
- response["error"] = job["error"]
126
-
127
- return response
128
-
129
- @app.get("/files/{video_name}/{char_id}/{filename}")
130
- def serve_character_file(video_name: str, char_id: str, filename: str):
131
- """
132
- Sirve archivos estáticos de personajes (imágenes).
133
- Ejemplo: /files/dif_catala_1/char1/representative.jpg
134
- """
135
- file_path = TEMP_ROOT / video_name / char_id / filename
136
-
137
- if not file_path.exists():
138
- raise HTTPException(status_code=404, detail="File not found")
139
-
140
- return FileResponse(file_path)
141
-
142
- def process_video_job(job_id: str):
143
- """
144
- Procesa el vídeo de forma asíncrona.
145
- Esta función se ejecuta en background.
146
- """
147
- try:
148
- job = jobs[job_id]
149
- print(f"[{job_id}] Iniciando procesamiento...")
150
-
151
- # Cambiar estado a processing
152
- job["status"] = JobStatus.PROCESSING
153
-
154
- video_path = job["video_path"]
155
- video_name = job["video_name"]
156
- epsilon = job["epsilon"]
157
- min_cluster_size = job["min_cluster_size"]
158
-
159
- # Crear estructura de carpetas
160
- base = TEMP_ROOT / video_name
161
- base.mkdir(parents=True, exist_ok=True)
162
-
163
- print(f"[{job_id}] Directorio base: {base}")
164
-
165
- # Detección real de personajes usando el código de Ana
166
- try:
167
- print(f"[{job_id}] Iniciando detección de personajes...")
168
- result = detect_characters_from_video(
169
- video_path=video_path,
170
- output_base=str(base),
171
- epsilon=epsilon,
172
- min_cluster_size=min_cluster_size,
173
- video_name=video_name
174
- )
175
-
176
- print(f"[{job_id}] DEBUG - result completo: {result}")
177
-
178
- characters = result.get("characters", [])
179
- analysis_path = result.get("analysis_path", "")
180
-
181
- print(f"[{job_id}] Personajes detectados: {len(characters)}")
182
- for char in characters:
183
- print(f"[{job_id}] - {char['name']}: {char['num_faces']} caras")
184
-
185
- # Enriquecer info de personajes con listado real de imágenes disponibles
186
- try:
187
- import glob, os
188
- for ch in characters:
189
- folder = ch.get("folder")
190
- face_files = []
191
- if folder and os.path.isdir(folder):
192
- # soportar patrones face_* y extensiones jpg/png
193
- patterns = ["face_*.jpg", "face_*.png"]
194
- files = []
195
- for pat in patterns:
196
- files.extend(glob.glob(os.path.join(folder, pat)))
197
- # si no hay face_*, tomar cualquier jpg/png para no dejar vacío
198
- if not files:
199
- files.extend(glob.glob(os.path.join(folder, "*.jpg")))
200
- files.extend(glob.glob(os.path.join(folder, "*.png")))
201
- # normalizar nombres de fichero relativos
202
- face_files = sorted({os.path.basename(p) for p in files})
203
- # Garantizar que representative.(jpg|png) esté el primero si existe
204
- for rep_name in ("representative.jpg", "representative.png"):
205
- rep_path = os.path.join(folder, rep_name)
206
- if os.path.exists(rep_path):
207
- if rep_name in face_files:
208
- face_files.remove(rep_name)
209
- face_files.insert(0, rep_name)
210
- ch["face_files"] = face_files
211
- # Ajustar num_faces si hay discrepancia
212
- if face_files:
213
- ch["num_faces"] = len(face_files)
214
- except Exception as _e:
215
- print(f"[{job_id}] WARN - No se pudo enumerar face_files: {_e}")
216
-
217
- # Guardar resultados primero y luego marcar como completado (evita carreras)
218
- job["results"] = {
219
- "characters": characters,
220
- "num_characters": len(characters),
221
- "analysis_path": analysis_path,
 
 
222
  "base_dir": str(base)
223
  }
224
  job["status"] = JobStatus.DONE
@@ -257,6 +259,35 @@ def process_video_job(job_id: str):
257
  jobs[job_id]["status"] = JobStatus.FAILED
258
  jobs[job_id]["error"] = str(e)
259
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
260
  @app.post("/load_casting")
261
  async def load_casting(
262
  faces_dir: str = Form("identities/faces"),
 
1
+ from pipelines.audiodescription import generate as ad_generate
2
+
3
+ from __future__ import annotations
4
+ from fastapi import FastAPI, UploadFile, File, Form, BackgroundTasks, HTTPException
5
+ from fastapi.responses import JSONResponse, FileResponse
6
+ from fastapi.middleware.cors import CORSMiddleware
7
+ from pathlib import Path
8
+ import shutil
9
+ import uvicorn
10
+ import json
11
+ import uuid
12
+ from datetime import datetime
13
+ from typing import Dict
14
+ from enum import Enum
15
+ import os
16
+
17
+ from video_processing import process_video_pipeline
18
+ from casting_loader import ensure_chroma, build_faces_index, build_voices_index
19
+ from narration_system import NarrationSystem
20
+ from llm_router import load_yaml, LLMRouter
21
+ from character_detection import detect_characters_from_video
22
+
23
+ app = FastAPI(title="Veureu Engine API", version="0.2.0")
24
+ app.add_middleware(
25
+ CORSMiddleware,
26
+ allow_origins=["*"],
27
+ allow_credentials=True,
28
+ allow_methods=["*"],
29
+ allow_headers=["*"],
30
+ )
31
+
32
+ ROOT = Path("/tmp/veureu")
33
+ ROOT.mkdir(parents=True, exist_ok=True)
34
+ TEMP_ROOT = Path("/tmp/temp")
35
+ TEMP_ROOT.mkdir(parents=True, exist_ok=True)
36
+ VIDEOS_ROOT = Path("/tmp/data/videos")
37
+ VIDEOS_ROOT.mkdir(parents=True, exist_ok=True)
38
+
39
+ # Sistema de jobs asíncronos
40
+ class JobStatus(str, Enum):
41
+ QUEUED = "queued"
42
+ PROCESSING = "processing"
43
+ DONE = "done"
44
+ FAILED = "failed"
45
+
46
+ jobs: Dict[str, dict] = {}
47
+
48
+ @app.get("/")
49
+ def root():
50
+ return {"ok": True, "service": "veureu-engine"}
51
+
52
+ @app.post("/process_video")
53
+ async def process_video(
54
+ video_file: UploadFile = File(...),
55
+ config_path: str = Form("config.yaml"),
56
+ out_root: str = Form("results"),
57
+ db_dir: str = Form("chroma_db"),
58
+ ):
59
+ tmp_video = ROOT / video_file.filename
60
+ with tmp_video.open("wb") as f:
61
+ shutil.copyfileobj(video_file.file, f)
62
+ result = process_video_pipeline(str(tmp_video), config_path=config_path, out_root=out_root, db_dir=db_dir)
63
+ return JSONResponse(result)
64
+
65
+ @app.post("/create_initial_casting")
66
+ async def create_initial_casting(
67
+ background_tasks: BackgroundTasks,
68
+ video: UploadFile = File(...),
69
+ epsilon: float = Form(...),
70
+ min_cluster_size: int = Form(...),
71
+ ):
72
+ """
73
+ Crea un job para procesar el vídeo de forma asíncrona.
74
+ Devuelve un job_id inmediatamente.
75
+ """
76
+ # Guardar vídeo en carpeta de datos
77
+ video_name = Path(video.filename).stem
78
+ dst_video = VIDEOS_ROOT / f"{video_name}.mp4"
79
+ with dst_video.open("wb") as f:
80
+ shutil.copyfileobj(video.file, f)
81
+
82
+ # Crear job_id único
83
+ job_id = str(uuid.uuid4())
84
+
85
+ # Inicializar el job
86
+ jobs[job_id] = {
87
+ "id": job_id,
88
+ "status": JobStatus.QUEUED,
89
+ "video_path": str(dst_video),
90
+ "video_name": video_name,
91
+ "epsilon": float(epsilon),
92
+ "min_cluster_size": int(min_cluster_size),
93
+ "created_at": datetime.now().isoformat(),
94
+ "results": None,
95
+ "error": None
96
+ }
97
+
98
+ print(f"[{job_id}] Job creado para vídeo: {video_name}")
99
+
100
+ # Iniciar procesamiento en background
101
+ background_tasks.add_task(process_video_job, job_id)
102
+
103
+ # Devolver job_id inmediatamente
104
+ return {"job_id": job_id}
105
+
106
+ @app.get("/jobs/{job_id}/status")
107
+ def get_job_status(job_id: str):
108
+ """
109
+ Devuelve el estado actual de un job.
110
+ El UI hace polling de este endpoint cada 5 segundos.
111
+ """
112
+ if job_id not in jobs:
113
+ raise HTTPException(status_code=404, detail="Job not found")
114
+
115
+ job = jobs[job_id]
116
+
117
+ # Normalizar el estado a string
118
+ status_value = job["status"].value if isinstance(job["status"], JobStatus) else str(job["status"])
119
+ response = {"status": status_value}
120
+
121
+ # Incluir resultados si existen (evita condiciones de carrera)
122
+ if job.get("results") is not None:
123
+ response["results"] = job["results"]
124
+
125
+ # Incluir error si existe
126
+ if job.get("error"):
127
+ response["error"] = job["error"]
128
+
129
+ return response
130
+
131
+ @app.get("/files/{video_name}/{char_id}/{filename}")
132
+ def serve_character_file(video_name: str, char_id: str, filename: str):
133
+ """
134
+ Sirve archivos estáticos de personajes (imágenes).
135
+ Ejemplo: /files/dif_catala_1/char1/representative.jpg
136
+ """
137
+ file_path = TEMP_ROOT / video_name / char_id / filename
138
+
139
+ if not file_path.exists():
140
+ raise HTTPException(status_code=404, detail="File not found")
141
+
142
+ return FileResponse(file_path)
143
+
144
+ def process_video_job(job_id: str):
145
+ """
146
+ Procesa el vídeo de forma asíncrona.
147
+ Esta función se ejecuta en background.
148
+ """
149
+ try:
150
+ job = jobs[job_id]
151
+ print(f"[{job_id}] Iniciando procesamiento...")
152
+
153
+ # Cambiar estado a processing
154
+ job["status"] = JobStatus.PROCESSING
155
+
156
+ video_path = job["video_path"]
157
+ video_name = job["video_name"]
158
+ epsilon = job["epsilon"]
159
+ min_cluster_size = job["min_cluster_size"]
160
+
161
+ # Crear estructura de carpetas
162
+ base = TEMP_ROOT / video_name
163
+ base.mkdir(parents=True, exist_ok=True)
164
+
165
+ print(f"[{job_id}] Directorio base: {base}")
166
+
167
+ # Detección real de personajes usando el código de Ana
168
+ try:
169
+ print(f"[{job_id}] Iniciando detección de personajes...")
170
+ result = detect_characters_from_video(
171
+ video_path=video_path,
172
+ output_base=str(base),
173
+ epsilon=epsilon,
174
+ min_cluster_size=min_cluster_size,
175
+ video_name=video_name
176
+ )
177
+
178
+ print(f"[{job_id}] DEBUG - result completo: {result}")
179
+
180
+ characters = result.get("characters", [])
181
+ analysis_path = result.get("analysis_path", "")
182
+
183
+ print(f"[{job_id}] Personajes detectados: {len(characters)}")
184
+ for char in characters:
185
+ print(f"[{job_id}] - {char['name']}: {char['num_faces']} caras")
186
+
187
+ # Enriquecer info de personajes con listado real de imágenes disponibles
188
+ try:
189
+ import glob, os
190
+ for ch in characters:
191
+ folder = ch.get("folder")
192
+ face_files = []
193
+ if folder and os.path.isdir(folder):
194
+ # soportar patrones face_* y extensiones jpg/png
195
+ patterns = ["face_*.jpg", "face_*.png"]
196
+ files = []
197
+ for pat in patterns:
198
+ files.extend(glob.glob(os.path.join(folder, pat)))
199
+ # si no hay face_*, tomar cualquier jpg/png para no dejar vacío
200
+ if not files:
201
+ files.extend(glob.glob(os.path.join(folder, "*.jpg")))
202
+ files.extend(glob.glob(os.path.join(folder, "*.png")))
203
+ # normalizar nombres de fichero relativos
204
+ face_files = sorted({os.path.basename(p) for p in files})
205
+ # Garantizar que representative.(jpg|png) esté el primero si existe
206
+ for rep_name in ("representative.jpg", "representative.png"):
207
+ rep_path = os.path.join(folder, rep_name)
208
+ if os.path.exists(rep_path):
209
+ if rep_name in face_files:
210
+ face_files.remove(rep_name)
211
+ face_files.insert(0, rep_name)
212
+ ch["face_files"] = face_files
213
+ # Ajustar num_faces si hay discrepancia
214
+ if face_files:
215
+ ch["num_faces"] = len(face_files)
216
+ except Exception as _e:
217
+ print(f"[{job_id}] WARN - No se pudo enumerar face_files: {_e}")
218
+
219
+ # Guardar resultados primero y luego marcar como completado (evita carreras)
220
+ job["results"] = {
221
+ "characters": characters,
222
+ "num_characters": len(characters),
223
+ "analysis_path": analysis_path,
224
  "base_dir": str(base)
225
  }
226
  job["status"] = JobStatus.DONE
 
259
  jobs[job_id]["status"] = JobStatus.FAILED
260
  jobs[job_id]["error"] = str(e)
261
 
262
+ @app.post("/generate_audiodescription")
263
+ async def generate_audiodescription(video: UploadFile = File(...)):
264
+ try:
265
+ import uuid
266
+ job_id = str(uuid.uuid4())
267
+ vid_name = video.filename or f"video_{job_id}.mp4"
268
+ base = BASE_TEMP_DIR / Path(vid_name).stem
269
+ base.mkdir(parents=True, exist_ok=True)
270
+ # Save temp mp4
271
+ video_path = base / vid_name
272
+ with open(video_path, "wb") as f:
273
+ f.write(await video.read())
274
+
275
+ # Run MVP pipeline
276
+ result = ad_generate(str(video_path), base)
277
+
278
+ return {
279
+ "status": "done",
280
+ "results": {
281
+ "une_srt": result.get("une_srt", ""),
282
+ "free_text": result.get("free_text", ""),
283
+ "artifacts": result.get("artifacts", {}),
284
+ },
285
+ }
286
+ except Exception as e:
287
+ import traceback
288
+ print(f"/generate_audiodescription error: {e}\n{traceback.format_exc()}")
289
+ raise HTTPException(status_code=500, detail=str(e))
290
+
291
  @app.post("/load_casting")
292
  async def load_casting(
293
  faces_dir: str = Form("identities/faces"),