Ava2lon commited on
Commit
ee22b52
·
verified ·
1 Parent(s): 29676f4

Upload 2 files

Browse files
Files changed (2) hide show
  1. api.py +178 -8
  2. app.py +25 -0
api.py CHANGED
@@ -6,16 +6,20 @@ import uuid
6
  from pathlib import Path
7
  from typing import Any
8
 
9
- from fastapi import FastAPI, File, Form, HTTPException, UploadFile
10
  from fastapi.responses import FileResponse
11
  from pydantic import BaseModel, Field
12
 
13
  from renderer.core.config import Settings
14
  from renderer.core.ingest import stage_upload
15
  from renderer.core.models import AIReelsRequest, RenderRequest, Scene
 
16
  from renderer.core.utils import safe_filename
17
  from renderer.jobs import JobManager
18
  from renderer.scenes import Timeline
 
 
 
19
 
20
  settings = Settings()
21
  settings.ensure_dirs()
@@ -30,15 +34,31 @@ class ScenePayload(BaseModel):
30
  caption: str = ""
31
  transition: str = "fade"
32
  background: str = "blur"
 
 
33
 
34
 
35
  class RenderPayload(BaseModel):
36
  scenes: list[ScenePayload]
37
  template: str = "tiktok_classic"
 
38
  output_name: str = "render.mp4"
39
  voiceover: str | None = None
40
  background_music: str | None = None
41
  subtitle_format: str = "ass"
 
 
 
 
 
 
 
 
 
 
 
 
 
42
  normalize: bool = True
43
  metadata: dict[str, Any] = Field(default_factory=dict)
44
 
@@ -62,24 +82,58 @@ class UploadedAsset(BaseModel):
62
  reference: str
63
 
64
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
65
  @api.get("/health")
66
  def health() -> dict[str, str]:
67
  return {"status": "ok"}
68
 
69
 
 
 
 
 
 
70
  @api.post("/render")
71
  def render(payload: RenderPayload | AIReelsPayload) -> dict[str, str]:
72
  if isinstance(payload, AIReelsPayload):
73
  job_id = job_manager.submit_ai_reels(AIReelsRequest(**payload.model_dump()))
74
  else:
75
  job_id = job_manager.submit_render(_render_request(payload))
76
- return {"job_id": job_id, "status_url": f"/status/{job_id}", "download_url": f"/download/{job_id}"}
 
 
 
 
 
77
 
78
 
79
  @api.post("/render/ai-reels")
80
  def render_ai_reels(payload: AIReelsPayload) -> dict[str, str]:
81
  job_id = job_manager.submit_ai_reels(AIReelsRequest(**payload.model_dump()))
82
- return {"job_id": job_id, "status_url": f"/status/{job_id}", "download_url": f"/download/{job_id}"}
 
83
 
84
 
85
  @api.post("/render/batch")
@@ -94,7 +148,8 @@ async def render_upload(request_json: str = Form(...), files: list[UploadFile] =
94
  payload_data = _replace_upload_refs(json.loads(request_json), uploads)
95
  payload = RenderPayload.model_validate(payload_data)
96
  job_id = job_manager.submit_render(_render_request(payload))
97
- return {"job_id": job_id, "status_url": f"/status/{job_id}", "download_url": f"/download/{job_id}"}
 
98
 
99
 
100
  @api.post("/render/ai-reels/upload")
@@ -103,7 +158,8 @@ async def render_ai_reels_upload(request_json: str = Form(...), files: list[Uplo
103
  payload_data = _replace_upload_refs(json.loads(request_json), uploads)
104
  payload = AIReelsPayload.model_validate(payload_data)
105
  job_id = job_manager.submit_ai_reels(AIReelsRequest(**payload.model_dump()))
106
- return {"job_id": job_id, "status_url": f"/status/{job_id}", "download_url": f"/download/{job_id}"}
 
107
 
108
 
109
  @api.post("/assets/upload")
@@ -115,6 +171,92 @@ async def upload_assets(files: list[UploadFile] = File(...)) -> dict[str, list[U
115
  return {"assets": [asset.model_dump() for asset in assets]}
116
 
117
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
118
  @api.get("/status/{job_id}")
119
  def status(job_id: str) -> dict:
120
  try:
@@ -123,14 +265,29 @@ def status(job_id: str) -> dict:
123
  raise HTTPException(status_code=404, detail="Job not found") from exc
124
 
125
 
 
 
 
 
 
 
 
 
 
 
 
 
 
126
  @api.get("/download/{job_id}")
127
- def download(job_id: str) -> FileResponse:
128
  try:
129
  record = job_manager.get(job_id)
130
  except KeyError as exc:
131
  raise HTTPException(status_code=404, detail="Job not found") from exc
132
  if record.state != "COMPLETED" or not record.output_path:
133
  raise HTTPException(status_code=409, detail=f"Job is {record.state}")
 
 
134
  path = Path(record.output_path)
135
  if not path.exists():
136
  raise HTTPException(status_code=404, detail="Output file is missing")
@@ -139,8 +296,6 @@ def download(job_id: str) -> FileResponse:
139
 
140
  @api.post("/inspect")
141
  def inspect_asset(path: str) -> dict:
142
- from renderer import RenderEngine
143
-
144
  try:
145
  return RenderEngine(settings).inspect_asset(path)
146
  except Exception as exc:
@@ -148,13 +303,28 @@ def inspect_asset(path: str) -> dict:
148
 
149
 
150
  def _render_request(payload: RenderPayload) -> RenderRequest:
 
151
  request = RenderRequest(
152
  scenes=[Scene(**scene.model_dump()) for scene in payload.scenes],
153
  template=payload.template,
 
154
  output_name=payload.output_name,
155
  voiceover=payload.voiceover,
156
  background_music=payload.background_music,
157
  subtitle_format=payload.subtitle_format, # type: ignore[arg-type]
 
 
 
 
 
 
 
 
 
 
 
 
 
158
  normalize=payload.normalize,
159
  metadata=payload.metadata,
160
  )
 
6
  from pathlib import Path
7
  from typing import Any
8
 
9
+ from fastapi import FastAPI, File, Form, HTTPException, Query, UploadFile
10
  from fastapi.responses import FileResponse
11
  from pydantic import BaseModel, Field
12
 
13
  from renderer.core.config import Settings
14
  from renderer.core.ingest import stage_upload
15
  from renderer.core.models import AIReelsRequest, RenderRequest, Scene
16
+ from renderer.core.security import verify_download_token
17
  from renderer.core.utils import safe_filename
18
  from renderer.jobs import JobManager
19
  from renderer.scenes import Timeline
20
+ from renderer import RenderEngine
21
+ from renderer.subtitles import SubtitleEvent, SubtitleGenerator
22
+ from renderer.templates import apply_preset, list_presets, list_templates
23
 
24
  settings = Settings()
25
  settings.ensure_dirs()
 
34
  caption: str = ""
35
  transition: str = "fade"
36
  background: str = "blur"
37
+ layout: str = "fill"
38
+ effect: str | None = None
39
 
40
 
41
  class RenderPayload(BaseModel):
42
  scenes: list[ScenePayload]
43
  template: str = "tiktok_classic"
44
+ preset: str | None = None
45
  output_name: str = "render.mp4"
46
  voiceover: str | None = None
47
  background_music: str | None = None
48
  subtitle_format: str = "ass"
49
+ auto_subtitles: bool = False
50
+ subtitle_language: str | None = None
51
+ whisper_model_size: str | None = None
52
+ preview: bool = False
53
+ audio_normalize: bool = False
54
+ watermark: str | None = None
55
+ watermark_position: str = "bottom-right"
56
+ intro: str | None = None
57
+ outro: str | None = None
58
+ callback_url: str | None = None
59
+ export_target: str | None = None
60
+ priority: int = 0
61
+ scheduled_at: float | None = None
62
  normalize: bool = True
63
  metadata: dict[str, Any] = Field(default_factory=dict)
64
 
 
82
  reference: str
83
 
84
 
85
+ class TranscribePayload(BaseModel):
86
+ audio: str
87
+ model_size: str | None = None
88
+ language: str | None = None
89
+ task: str = "transcribe"
90
+ beam_size: int = Field(default=5, ge=1, le=10)
91
+ vad_filter: bool = True
92
+ word_timestamps: bool = True
93
+
94
+
95
+ class SubtitlePayload(BaseModel):
96
+ events: list[dict[str, Any]]
97
+ format: str = "srt"
98
+ template: str = "tiktok_classic"
99
+
100
+
101
+ class SceneBuildPayload(BaseModel):
102
+ script: str
103
+ assets: list[str]
104
+ duration: float | None = None
105
+ transition: str = "fade"
106
+
107
+
108
  @api.get("/health")
109
  def health() -> dict[str, str]:
110
  return {"status": "ok"}
111
 
112
 
113
+ @api.get("/presets")
114
+ def presets() -> dict[str, list[str]]:
115
+ return {"presets": list_presets(), "caption_templates": list_templates()}
116
+
117
+
118
  @api.post("/render")
119
  def render(payload: RenderPayload | AIReelsPayload) -> dict[str, str]:
120
  if isinstance(payload, AIReelsPayload):
121
  job_id = job_manager.submit_ai_reels(AIReelsRequest(**payload.model_dump()))
122
  else:
123
  job_id = job_manager.submit_render(_render_request(payload))
124
+ record = job_manager.get(job_id)
125
+ return {
126
+ "job_id": job_id,
127
+ "status_url": f"/status/{job_id}",
128
+ "download_url": f"/download/{job_id}?token={record.download_token}",
129
+ }
130
 
131
 
132
  @api.post("/render/ai-reels")
133
  def render_ai_reels(payload: AIReelsPayload) -> dict[str, str]:
134
  job_id = job_manager.submit_ai_reels(AIReelsRequest(**payload.model_dump()))
135
+ record = job_manager.get(job_id)
136
+ return {"job_id": job_id, "status_url": f"/status/{job_id}", "download_url": f"/download/{job_id}?token={record.download_token}"}
137
 
138
 
139
  @api.post("/render/batch")
 
148
  payload_data = _replace_upload_refs(json.loads(request_json), uploads)
149
  payload = RenderPayload.model_validate(payload_data)
150
  job_id = job_manager.submit_render(_render_request(payload))
151
+ record = job_manager.get(job_id)
152
+ return {"job_id": job_id, "status_url": f"/status/{job_id}", "download_url": f"/download/{job_id}?token={record.download_token}"}
153
 
154
 
155
  @api.post("/render/ai-reels/upload")
 
158
  payload_data = _replace_upload_refs(json.loads(request_json), uploads)
159
  payload = AIReelsPayload.model_validate(payload_data)
160
  job_id = job_manager.submit_ai_reels(AIReelsRequest(**payload.model_dump()))
161
+ record = job_manager.get(job_id)
162
+ return {"job_id": job_id, "status_url": f"/status/{job_id}", "download_url": f"/download/{job_id}?token={record.download_token}"}
163
 
164
 
165
  @api.post("/assets/upload")
 
171
  return {"assets": [asset.model_dump() for asset in assets]}
172
 
173
 
174
+ @api.post("/transcribe")
175
+ def transcribe(payload: TranscribePayload) -> dict:
176
+ from renderer.core.ingest import AssetIngestor
177
+ from renderer.core.utils import temp_workdir
178
+
179
+ try:
180
+ with temp_workdir(settings.temp_dir, "transcribe") as work:
181
+ audio = AssetIngestor(settings).resolve(payload.audio, Path(work) / "inputs", "audio")
182
+ return RenderEngine(settings).transcribe(
183
+ audio,
184
+ model_size=payload.model_size,
185
+ language=payload.language,
186
+ task=payload.task,
187
+ beam_size=payload.beam_size,
188
+ vad_filter=payload.vad_filter,
189
+ word_timestamps=payload.word_timestamps,
190
+ )
191
+ except Exception as exc:
192
+ raise HTTPException(status_code=400, detail=str(exc)) from exc
193
+
194
+
195
+ @api.post("/subtitles")
196
+ def subtitles(payload: SubtitlePayload) -> FileResponse:
197
+ try:
198
+ events = [SubtitleEvent(float(event["start"]), float(event["end"]), str(event["text"])) for event in payload.events]
199
+ path = settings.temp_dir / f"subtitles_{uuid.uuid4().hex}.{payload.format}"
200
+ generator = SubtitleGenerator()
201
+ if payload.format == "ass":
202
+ generator.write_ass(events, path, payload.template)
203
+ media_type = "text/x-ssa"
204
+ else:
205
+ generator.write_srt(events, path)
206
+ media_type = "application/x-subrip"
207
+ return FileResponse(path, media_type=media_type, filename=path.name)
208
+ except Exception as exc:
209
+ raise HTTPException(status_code=400, detail=str(exc)) from exc
210
+
211
+
212
+ @api.post("/scene-builder")
213
+ def scene_builder(payload: SceneBuildPayload) -> dict[str, list[dict[str, Any]]]:
214
+ if not payload.assets:
215
+ raise HTTPException(status_code=400, detail="At least one asset is required")
216
+ words = payload.script.split()
217
+ total_duration = payload.duration or max(3.0, len(words) * 0.35)
218
+ per_scene = total_duration / len(payload.assets)
219
+ chunk_size = max(1, round(len(words) / len(payload.assets)))
220
+ captions = [" ".join(words[index : index + chunk_size]) for index in range(0, len(words), chunk_size)]
221
+ scenes = [
222
+ {
223
+ "start": round(index * per_scene, 3),
224
+ "duration": round(per_scene, 3),
225
+ "media": asset,
226
+ "caption": captions[index] if index < len(captions) else "",
227
+ "transition": payload.transition,
228
+ }
229
+ for index, asset in enumerate(payload.assets)
230
+ ]
231
+ return {"scenes": scenes}
232
+
233
+
234
+ @api.post("/transcribe/upload")
235
+ async def transcribe_upload(
236
+ file: UploadFile = File(...),
237
+ model_size: str | None = Form(default=None),
238
+ language: str | None = Form(default=None),
239
+ task: str = Form(default="transcribe"),
240
+ beam_size: int = Form(default=5),
241
+ vad_filter: bool = Form(default=True),
242
+ word_timestamps: bool = Form(default=True),
243
+ ) -> dict:
244
+ try:
245
+ uploads = await _stage_uploads([file])
246
+ audio = next(iter(uploads.values()))
247
+ return RenderEngine(settings).transcribe(
248
+ audio,
249
+ model_size=model_size,
250
+ language=language,
251
+ task=task,
252
+ beam_size=beam_size,
253
+ vad_filter=vad_filter,
254
+ word_timestamps=word_timestamps,
255
+ )
256
+ except Exception as exc:
257
+ raise HTTPException(status_code=400, detail=str(exc)) from exc
258
+
259
+
260
  @api.get("/status/{job_id}")
261
  def status(job_id: str) -> dict:
262
  try:
 
265
  raise HTTPException(status_code=404, detail="Job not found") from exc
266
 
267
 
268
+ @api.post("/cancel/{job_id}")
269
+ def cancel(job_id: str) -> dict:
270
+ try:
271
+ return job_manager.cancel(job_id).__dict__
272
+ except KeyError as exc:
273
+ raise HTTPException(status_code=404, detail="Job not found") from exc
274
+
275
+
276
+ @api.post("/admin/cleanup")
277
+ def cleanup(older_than_seconds: int | None = None) -> dict[str, int]:
278
+ return job_manager.cleanup(older_than_seconds)
279
+
280
+
281
  @api.get("/download/{job_id}")
282
+ def download(job_id: str, token: str | None = Query(default=None)) -> FileResponse:
283
  try:
284
  record = job_manager.get(job_id)
285
  except KeyError as exc:
286
  raise HTTPException(status_code=404, detail="Job not found") from exc
287
  if record.state != "COMPLETED" or not record.output_path:
288
  raise HTTPException(status_code=409, detail=f"Job is {record.state}")
289
+ if record.download_token and not verify_download_token(settings.signing_secret, job_id, token):
290
+ raise HTTPException(status_code=403, detail="Invalid or missing download token")
291
  path = Path(record.output_path)
292
  if not path.exists():
293
  raise HTTPException(status_code=404, detail="Output file is missing")
 
296
 
297
  @api.post("/inspect")
298
  def inspect_asset(path: str) -> dict:
 
 
299
  try:
300
  return RenderEngine(settings).inspect_asset(path)
301
  except Exception as exc:
 
303
 
304
 
305
  def _render_request(payload: RenderPayload) -> RenderRequest:
306
+ payload = RenderPayload.model_validate(apply_preset(payload.model_dump()))
307
  request = RenderRequest(
308
  scenes=[Scene(**scene.model_dump()) for scene in payload.scenes],
309
  template=payload.template,
310
+ preset=payload.preset,
311
  output_name=payload.output_name,
312
  voiceover=payload.voiceover,
313
  background_music=payload.background_music,
314
  subtitle_format=payload.subtitle_format, # type: ignore[arg-type]
315
+ auto_subtitles=payload.auto_subtitles,
316
+ subtitle_language=payload.subtitle_language,
317
+ whisper_model_size=payload.whisper_model_size,
318
+ preview=payload.preview,
319
+ audio_normalize=payload.audio_normalize,
320
+ watermark=payload.watermark,
321
+ watermark_position=payload.watermark_position,
322
+ intro=payload.intro,
323
+ outro=payload.outro,
324
+ callback_url=payload.callback_url,
325
+ export_target=payload.export_target,
326
+ priority=payload.priority,
327
+ scheduled_at=payload.scheduled_at,
328
  normalize=payload.normalize,
329
  metadata=payload.metadata,
330
  )
app.py CHANGED
@@ -64,6 +64,22 @@ def create_dashboard() -> gr.Blocks:
64
  download_output = gr.File(label="Rendered Video")
65
  download_button.click(fn=_download_path, inputs=download_job_id, outputs=download_output)
66
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
67
  with gr.Tab("Asset Inspector"):
68
  asset_path = gr.Textbox(label="Asset path")
69
  inspect_button = gr.Button("Inspect")
@@ -111,6 +127,15 @@ def _inspect_asset(path: str) -> dict[str, Any]:
111
  return RenderEngine(settings).inspect_asset(path)
112
 
113
 
 
 
 
 
 
 
 
 
 
114
  app = gr.mount_gradio_app(api, create_dashboard(), path="/dashboard")
115
 
116
 
 
64
  download_output = gr.File(label="Rendered Video")
65
  download_button.click(fn=_download_path, inputs=download_job_id, outputs=download_output)
66
 
67
+ with gr.Tab("Transcribe"):
68
+ transcribe_audio = gr.File(label="Audio or Video", file_types=["audio", "video"], type="filepath")
69
+ transcribe_model = gr.Dropdown(
70
+ choices=["tiny", "base", "small", "medium", "large-v3"],
71
+ value=settings.whisper_model_size,
72
+ label="Whisper Model",
73
+ )
74
+ transcribe_language = gr.Textbox(label="Language", placeholder="Optional ISO code, e.g. en")
75
+ transcribe_button = gr.Button("Transcribe", variant="primary")
76
+ transcribe_output = gr.JSON(label="Transcript")
77
+ transcribe_button.click(
78
+ fn=_transcribe_file,
79
+ inputs=[transcribe_audio, transcribe_model, transcribe_language],
80
+ outputs=transcribe_output,
81
+ )
82
+
83
  with gr.Tab("Asset Inspector"):
84
  asset_path = gr.Textbox(label="Asset path")
85
  inspect_button = gr.Button("Inspect")
 
127
  return RenderEngine(settings).inspect_asset(path)
128
 
129
 
130
+ def _transcribe_file(path: str, model_size: str, language: str) -> dict[str, Any]:
131
+ return RenderEngine(settings).transcribe(
132
+ path,
133
+ model_size=model_size,
134
+ language=language.strip() or None,
135
+ word_timestamps=True,
136
+ )
137
+
138
+
139
  app = gr.mount_gradio_app(api, create_dashboard(), path="/dashboard")
140
 
141