Ava2lon commited on
Commit
39035d1
·
verified ·
1 Parent(s): 4c5a10d

Upload 6 files

Browse files
Files changed (2) hide show
  1. api.py +418 -16
  2. app.py +90 -5
api.py CHANGED
@@ -3,10 +3,11 @@
3
  import json
4
  import shutil
5
  import uuid
 
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
 
@@ -16,16 +17,38 @@ 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_platform_profiles, list_presets, list_templates, platform_profile_metadata
 
 
 
 
 
 
 
 
 
 
 
 
 
23
 
24
  settings = Settings()
25
  settings.ensure_dirs()
26
  job_manager = JobManager(settings)
27
  api = FastAPI(title="Basyx FFmpeg Rendering Engine", version="1.0.0")
28
 
 
 
 
 
 
 
 
 
29
  def _submission_response(job_id: str) -> dict[str, str]:
30
  download_url = f"/download/{job_id}"
31
  try:
@@ -54,6 +77,7 @@ class RenderPayload(BaseModel):
54
  scenes: list[ScenePayload]
55
  template: str = "tiktok_classic"
56
  preset: str | None = None
 
57
  platform: str | None = None
58
  output_name: str = "render.mp4"
59
  voiceover: str | None = None
@@ -88,6 +112,7 @@ class AIReelsPayload(BaseModel):
88
  voiceover: str
89
  assets: list[str]
90
  template: str = "tiktok_classic"
 
91
  platform: str | None = None
92
  output_name: str = "ai_reel.mp4"
93
  background_music: str | None = None
@@ -108,6 +133,8 @@ class UploadedAsset(BaseModel):
108
  filename: str
109
  path: str
110
  reference: str
 
 
111
 
112
 
113
  class TranscribePayload(BaseModel):
@@ -131,6 +158,74 @@ class SceneBuildPayload(BaseModel):
131
  assets: list[str]
132
  duration: float | None = None
133
  transition: str = "fade"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
134
 
135
 
136
  @api.get("/health")
@@ -138,9 +233,40 @@ def health() -> dict[str, str]:
138
  return {"status": "ok"}
139
 
140
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
141
  @api.get("/presets")
142
- def presets() -> dict[str, list[str]]:
143
- return {"presets": list_presets(), "caption_templates": list_templates(), "platforms": list_platform_profiles()}
 
 
 
 
 
 
 
 
 
 
144
 
145
 
146
  @api.get("/platforms")
@@ -148,10 +274,43 @@ def platforms() -> dict[str, dict[str, Any]]:
148
  return {"platforms": platform_profile_metadata()}
149
 
150
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
151
  @api.post("/render")
152
  def render(payload: RenderPayload | AIReelsPayload) -> dict[str, str]:
153
  if isinstance(payload, AIReelsPayload):
154
- job_id = job_manager.submit_ai_reels(AIReelsRequest(**payload.model_dump()))
155
  else:
156
  job_id = job_manager.submit_render(_render_request(payload))
157
  return _submission_response(job_id)
@@ -160,7 +319,7 @@ def render(payload: RenderPayload | AIReelsPayload) -> dict[str, str]:
160
 
161
  @api.post("/render/ai-reels")
162
  def render_ai_reels(payload: AIReelsPayload) -> dict[str, str]:
163
- job_id = job_manager.submit_ai_reels(AIReelsRequest(**payload.model_dump()))
164
  return _submission_response(job_id)
165
 
166
 
@@ -184,7 +343,7 @@ async def render_ai_reels_upload(request_json: str = Form(...), files: list[Uplo
184
  uploads = await _stage_uploads(files)
185
  payload_data = _replace_upload_refs(json.loads(request_json), uploads)
186
  payload = AIReelsPayload.model_validate(payload_data)
187
- job_id = job_manager.submit_ai_reels(AIReelsRequest(**payload.model_dump()))
188
  return _submission_response(job_id)
189
 
190
 
@@ -192,11 +351,115 @@ async def render_ai_reels_upload(request_json: str = Form(...), files: list[Uplo
192
  async def upload_assets(files: list[UploadFile] = File(...)) -> dict[str, list[UploadedAsset]]:
193
  uploads = await _stage_uploads(files)
194
  assets = [
195
- UploadedAsset(filename=filename, path=path, reference=f"upload://{filename}") for filename, path in uploads.items()
196
  ]
197
  return {"assets": [asset.model_dump() for asset in assets]}
198
 
199
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
200
  @api.post("/transcribe")
201
  def transcribe(payload: TranscribePayload) -> dict:
202
  from renderer.core.ingest import AssetIngestor
@@ -236,25 +499,28 @@ def subtitles(payload: SubtitlePayload) -> FileResponse:
236
 
237
 
238
  @api.post("/scene-builder")
239
- def scene_builder(payload: SceneBuildPayload) -> dict[str, list[dict[str, Any]]]:
240
  if not payload.assets:
241
  raise HTTPException(status_code=400, detail="At least one asset is required")
 
242
  words = payload.script.split()
243
- total_duration = payload.duration or max(3.0, len(words) * 0.35)
244
  per_scene = total_duration / len(payload.assets)
245
- chunk_size = max(1, round(len(words) / len(payload.assets)))
246
- captions = [" ".join(words[index : index + chunk_size]) for index in range(0, len(words), chunk_size)]
247
  scenes = [
248
  {
249
  "start": round(index * per_scene, 3),
250
  "duration": round(per_scene, 3),
251
  "media": asset,
252
  "caption": captions[index] if index < len(captions) else "",
253
- "transition": payload.transition,
 
 
 
254
  }
255
  for index, asset in enumerate(payload.assets)
256
  ]
257
- return {"scenes": scenes}
258
 
259
 
260
  @api.post("/transcribe/upload")
@@ -317,7 +583,7 @@ def download(job_id: str, token: str | None = Query(default=None)) -> FileRespon
317
  path = Path(record.output_path)
318
  if not path.exists():
319
  raise HTTPException(status_code=404, detail="Output file is missing")
320
- return FileResponse(path, media_type="video/mp4", filename=path.name)
321
 
322
 
323
  @api.post("/inspect")
@@ -329,11 +595,12 @@ def inspect_asset(path: str) -> dict:
329
 
330
 
331
  def _render_request(payload: RenderPayload) -> RenderRequest:
332
- payload = RenderPayload.model_validate(apply_preset(payload.model_dump()))
333
  request = RenderRequest(
334
  scenes=[Scene(**scene.model_dump()) for scene in payload.scenes],
335
  template=payload.template,
336
  preset=payload.preset,
 
337
  platform=payload.platform,
338
  output_name=payload.output_name,
339
  voiceover=payload.voiceover,
@@ -366,12 +633,19 @@ def _render_request(payload: RenderPayload) -> RenderRequest:
366
  return request
367
 
368
 
 
 
 
 
 
369
  async def _stage_uploads(files: list[UploadFile]) -> dict[str, str]:
370
  upload_dir = settings.temp_dir / "uploads" / uuid.uuid4().hex
371
  staged: dict[str, str] = {}
372
  total_bytes = 0
373
  for upload in files:
374
  filename = safe_filename(upload.filename or f"asset_{len(staged)}")
 
 
375
  target = upload_dir / filename
376
  target.parent.mkdir(parents=True, exist_ok=True)
377
  with target.open("wb") as output:
@@ -388,6 +662,112 @@ async def _stage_uploads(files: list[UploadFile]) -> dict[str, str]:
388
  return staged
389
 
390
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
391
  def _replace_upload_refs(value: Any, uploads: dict[str, str]) -> Any:
392
  if isinstance(value, dict):
393
  return {key: _replace_upload_refs(item, uploads) for key, item in value.items()}
@@ -399,3 +779,25 @@ def _replace_upload_refs(value: Any, uploads: dict[str, str]) -> Any:
399
  raise HTTPException(status_code=400, detail=f"Missing uploaded file for reference: upload://{name}")
400
  return uploads[name]
401
  return value
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3
  import json
4
  import shutil
5
  import uuid
6
+ import zipfile
7
  from pathlib import Path
8
  from typing import Any
9
 
10
+ from fastapi import Depends, FastAPI, File, Form, Header, HTTPException, Query, UploadFile
11
  from fastapi.responses import FileResponse
12
  from pydantic import BaseModel, Field
13
 
 
17
  from renderer.core.security import verify_download_token
18
  from renderer.core.utils import safe_filename
19
  from renderer.jobs import JobManager
20
+ from renderer.platform import PlatformProcessor, supported_toolkit_tasks
21
  from renderer.scenes import Timeline
22
  from renderer import RenderEngine
23
  from renderer.subtitles import SubtitleEvent, SubtitleGenerator
24
+ from renderer.templates import (
25
+ apply_creative_style,
26
+ apply_preset,
27
+ creative_style_metadata,
28
+ get_creative_style,
29
+ list_creative_styles,
30
+ list_platform_profiles,
31
+ list_presets,
32
+ list_scene_effects,
33
+ list_templates,
34
+ platform_profile_metadata,
35
+ scene_effect_metadata,
36
+ )
37
+ from renderer.transitions import TransitionBuilder
38
 
39
  settings = Settings()
40
  settings.ensure_dirs()
41
  job_manager = JobManager(settings)
42
  api = FastAPI(title="Basyx FFmpeg Rendering Engine", version="1.0.0")
43
 
44
+ API_KEY_HEADER = "X-API-Key"
45
+
46
+
47
+ def _verify_api_key(api_key: str | None = Header(default=None, alias=API_KEY_HEADER)) -> None:
48
+ configured = getattr(settings, "api_key", "")
49
+ if configured and api_key != configured:
50
+ raise HTTPException(status_code=401, detail="Invalid or missing API key")
51
+
52
  def _submission_response(job_id: str) -> dict[str, str]:
53
  download_url = f"/download/{job_id}"
54
  try:
 
77
  scenes: list[ScenePayload]
78
  template: str = "tiktok_classic"
79
  preset: str | None = None
80
+ creative_style: str | None = None
81
  platform: str | None = None
82
  output_name: str = "render.mp4"
83
  voiceover: str | None = None
 
112
  voiceover: str
113
  assets: list[str]
114
  template: str = "tiktok_classic"
115
+ creative_style: str | None = None
116
  platform: str | None = None
117
  output_name: str = "ai_reel.mp4"
118
  background_music: str | None = None
 
133
  filename: str
134
  path: str
135
  reference: str
136
+ kind: str = "other"
137
+ metadata: dict[str, Any] | None = None
138
 
139
 
140
  class TranscribePayload(BaseModel):
 
158
  assets: list[str]
159
  duration: float | None = None
160
  transition: str = "fade"
161
+ creative_style: str | None = None
162
+
163
+
164
+ class IngestSourcePayload(BaseModel):
165
+ url: str
166
+ type: str | None = None
167
+ name: str | None = None
168
+
169
+
170
+ class IngestPayload(BaseModel):
171
+ sources: list[IngestSourcePayload]
172
+ callback_url: str | None = None
173
+
174
+
175
+ class AnalyzePayload(BaseModel):
176
+ media: str
177
+ transcript: str = ""
178
+ platform: str | None = None
179
+ callback_url: str | None = None
180
+
181
+
182
+ class ClipsPayload(BaseModel):
183
+ media: str
184
+ clips: list[dict[str, Any]] | None = None
185
+ callback_url: str | None = None
186
+
187
+
188
+ class ToolkitPayload(BaseModel):
189
+ task: str
190
+ input: str | None = None
191
+ media: str | None = None
192
+ output_name: str | None = None
193
+ params: dict[str, Any] = Field(default_factory=dict)
194
+ callback_url: str | None = None
195
+ export_target: str | None = None
196
+ model_config = {"extra": "allow"}
197
+
198
+
199
+ class ThumbnailPayload(BaseModel):
200
+ media: str
201
+ text: str = ""
202
+ timestamp: float | None = None
203
+ template: str = "bold"
204
+ callback_url: str | None = None
205
+
206
+
207
+ class MetadataPayload(BaseModel):
208
+ topic: str = ""
209
+ transcript: str = ""
210
+ platform: str | None = None
211
+ callback_url: str | None = None
212
+
213
+
214
+ class PublishPayload(BaseModel):
215
+ media: str | None = None
216
+ asset: str | None = None
217
+ title: str | None = None
218
+ description: str | None = None
219
+ platforms: list[str] = Field(default_factory=list)
220
+ platform: str | None = None
221
+ scheduled_at: str | None = None
222
+ draft: bool = True
223
+ callback_url: str | None = None
224
+
225
+
226
+ class ProjectPayload(BaseModel):
227
+ name: str
228
+ metadata: dict[str, Any] = Field(default_factory=dict)
229
 
230
 
231
  @api.get("/health")
 
233
  return {"status": "ok"}
234
 
235
 
236
+ @api.get("/monitor")
237
+ def monitor() -> dict[str, Any]:
238
+ return _monitor_payload()
239
+
240
+
241
+ @api.get("/queue")
242
+ def queue() -> dict[str, Any]:
243
+ return job_manager.summary()
244
+
245
+
246
+ @api.get("/workers")
247
+ def workers() -> dict[str, Any]:
248
+ return {
249
+ "max_workers": settings.max_workers,
250
+ "ffmpeg_timeout_seconds": settings.ffmpeg_timeout_seconds,
251
+ "whisper_device": settings.whisper_device,
252
+ "whisper_model_size": settings.whisper_model_size,
253
+ "toolkit_tasks": supported_toolkit_tasks(),
254
+ }
255
+
256
+
257
  @api.get("/presets")
258
+ def presets() -> dict[str, Any]:
259
+ return {
260
+ "presets": list_presets(),
261
+ "caption_templates": list_templates(),
262
+ "platforms": list_platform_profiles(),
263
+ "creative_styles": list_creative_styles(),
264
+ "scene_effects": list_scene_effects(),
265
+ "transitions": TransitionBuilder().list_transitions(),
266
+ "creative_style_metadata": creative_style_metadata(),
267
+ "scene_effect_metadata": scene_effect_metadata(),
268
+ "toolkit_tasks": supported_toolkit_tasks(),
269
+ }
270
 
271
 
272
  @api.get("/platforms")
 
274
  return {"platforms": platform_profile_metadata()}
275
 
276
 
277
+ @api.get("/toolkit/tasks")
278
+ def toolkit_tasks() -> dict[str, list[str]]:
279
+ return {"tasks": supported_toolkit_tasks()}
280
+
281
+
282
+ @api.get("/projects")
283
+ def list_projects() -> dict[str, list[dict[str, Any]]]:
284
+ projects_dir = settings.storage_dir / "projects"
285
+ projects_dir.mkdir(parents=True, exist_ok=True)
286
+ projects: list[dict[str, Any]] = []
287
+ for manifest in sorted(projects_dir.glob("*/project.json")):
288
+ try:
289
+ projects.append(json.loads(manifest.read_text(encoding="utf-8")))
290
+ except Exception:
291
+ continue
292
+ return {"projects": projects}
293
+
294
+
295
+ @api.post("/projects", dependencies=[Depends(_verify_api_key)])
296
+ def create_project(payload: ProjectPayload) -> dict[str, Any]:
297
+ slug = safe_filename(payload.name)
298
+ project_dir = settings.storage_dir / "projects" / slug
299
+ project_dir.mkdir(parents=True, exist_ok=True)
300
+ manifest = {
301
+ "name": payload.name,
302
+ "slug": slug,
303
+ "path": str(project_dir),
304
+ "metadata": payload.metadata,
305
+ }
306
+ (project_dir / "project.json").write_text(json.dumps(manifest, indent=2), encoding="utf-8")
307
+ return {"project": manifest}
308
+
309
+
310
  @api.post("/render")
311
  def render(payload: RenderPayload | AIReelsPayload) -> dict[str, str]:
312
  if isinstance(payload, AIReelsPayload):
313
+ job_id = job_manager.submit_ai_reels(_ai_reels_request(payload))
314
  else:
315
  job_id = job_manager.submit_render(_render_request(payload))
316
  return _submission_response(job_id)
 
319
 
320
  @api.post("/render/ai-reels")
321
  def render_ai_reels(payload: AIReelsPayload) -> dict[str, str]:
322
+ job_id = job_manager.submit_ai_reels(_ai_reels_request(payload))
323
  return _submission_response(job_id)
324
 
325
 
 
343
  uploads = await _stage_uploads(files)
344
  payload_data = _replace_upload_refs(json.loads(request_json), uploads)
345
  payload = AIReelsPayload.model_validate(payload_data)
346
+ job_id = job_manager.submit_ai_reels(_ai_reels_request(payload))
347
  return _submission_response(job_id)
348
 
349
 
 
351
  async def upload_assets(files: list[UploadFile] = File(...)) -> dict[str, list[UploadedAsset]]:
352
  uploads = await _stage_uploads(files)
353
  assets = [
354
+ UploadedAsset(filename=filename, path=path, reference=f"upload://{filename}", kind=_asset_kind(filename)) for filename, path in uploads.items()
355
  ]
356
  return {"assets": [asset.model_dump() for asset in assets]}
357
 
358
 
359
+ @api.post("/upload")
360
+ async def upload(files: list[UploadFile] = File(...), expand_zip: bool = Form(default=True)) -> dict[str, list[UploadedAsset]]:
361
+ uploads = await _stage_uploads(files)
362
+ expanded: dict[str, str] = {}
363
+ for filename, path in uploads.items():
364
+ if expand_zip and filename.lower().endswith(".zip"):
365
+ expanded.update(_extract_zip(Path(path)))
366
+ else:
367
+ expanded[filename] = path
368
+ assets = [
369
+ UploadedAsset(filename=filename, path=path, reference=f"upload://{filename}", kind=_asset_kind(filename))
370
+ for filename, path in expanded.items()
371
+ ]
372
+ return {"assets": [asset.model_dump() for asset in assets]}
373
+
374
+
375
+ @api.post("/ingest", dependencies=[Depends(_verify_api_key)])
376
+ def ingest(payload: IngestPayload) -> dict[str, str]:
377
+ job_id = job_manager.submit_task(
378
+ lambda task_id, log: PlatformProcessor(settings, log=log).ingest_sources([source.model_dump() for source in payload.sources], task_id),
379
+ callback_url=payload.callback_url,
380
+ )
381
+ return _submission_response(job_id)
382
+
383
+
384
+ @api.post("/analyze", dependencies=[Depends(_verify_api_key)])
385
+ def analyze(payload: AnalyzePayload) -> dict[str, str]:
386
+ job_id = job_manager.submit_task(
387
+ lambda task_id, log: PlatformProcessor(settings, log=log).analyze(
388
+ payload.media,
389
+ task_id,
390
+ transcript=payload.transcript,
391
+ platform=payload.platform,
392
+ ),
393
+ callback_url=payload.callback_url,
394
+ )
395
+ return _submission_response(job_id)
396
+
397
+
398
+ @api.post("/clips", dependencies=[Depends(_verify_api_key)])
399
+ def clips(payload: ClipsPayload) -> dict[str, str]:
400
+ job_id = job_manager.submit_task(
401
+ lambda task_id, log: PlatformProcessor(settings, log=log).clips(payload.media, task_id, payload.clips),
402
+ callback_url=payload.callback_url,
403
+ )
404
+ return _submission_response(job_id)
405
+
406
+
407
+ @api.post("/thumbnail", dependencies=[Depends(_verify_api_key)])
408
+ def thumbnail(payload: ThumbnailPayload) -> dict[str, str]:
409
+ job_id = job_manager.submit_task(
410
+ lambda task_id, log: PlatformProcessor(settings, log=log).thumbnail(
411
+ payload.media,
412
+ task_id,
413
+ text=payload.text,
414
+ timestamp=payload.timestamp,
415
+ template=payload.template,
416
+ ),
417
+ callback_url=payload.callback_url,
418
+ )
419
+ return _submission_response(job_id)
420
+
421
+
422
+ @api.post("/metadata", dependencies=[Depends(_verify_api_key)])
423
+ def metadata(payload: MetadataPayload) -> dict[str, str]:
424
+ job_id = job_manager.submit_task(
425
+ lambda task_id, log: PlatformProcessor(settings, log=log).metadata(
426
+ task_id,
427
+ topic=payload.topic,
428
+ transcript=payload.transcript,
429
+ platform=payload.platform,
430
+ ),
431
+ callback_url=payload.callback_url,
432
+ )
433
+ return _submission_response(job_id)
434
+
435
+
436
+ @api.post("/publish", dependencies=[Depends(_verify_api_key)])
437
+ def publish(payload: PublishPayload) -> dict[str, str]:
438
+ job_id = job_manager.submit_task(
439
+ lambda task_id, log: PlatformProcessor(settings, log=log).publish(payload.model_dump(), task_id),
440
+ callback_url=payload.callback_url,
441
+ )
442
+ return _submission_response(job_id)
443
+
444
+
445
+ @api.post("/toolkit", dependencies=[Depends(_verify_api_key)])
446
+ def toolkit(payload: ToolkitPayload) -> dict[str, str]:
447
+ data = payload.model_dump()
448
+ if payload.model_extra:
449
+ data.update(payload.model_extra)
450
+ job_id = job_manager.submit_task(
451
+ lambda task_id, log: PlatformProcessor(settings, log=log).toolkit(data, task_id),
452
+ callback_url=payload.callback_url,
453
+ export_target=payload.export_target,
454
+ )
455
+ return _submission_response(job_id)
456
+
457
+
458
+ @api.post("/edit", dependencies=[Depends(_verify_api_key)])
459
+ def edit(payload: ToolkitPayload) -> dict[str, str]:
460
+ return toolkit(payload)
461
+
462
+
463
  @api.post("/transcribe")
464
  def transcribe(payload: TranscribePayload) -> dict:
465
  from renderer.core.ingest import AssetIngestor
 
499
 
500
 
501
  @api.post("/scene-builder")
502
+ def scene_builder(payload: SceneBuildPayload) -> dict[str, Any]:
503
  if not payload.assets:
504
  raise HTTPException(status_code=400, detail="At least one asset is required")
505
+ style = get_creative_style(payload.creative_style)
506
  words = payload.script.split()
507
+ total_duration = payload.duration or max(style.scene_duration * len(payload.assets), len(words) * 0.35, 3.0)
508
  per_scene = total_duration / len(payload.assets)
509
+ captions = _split_words_for_assets(words, len(payload.assets))
 
510
  scenes = [
511
  {
512
  "start": round(index * per_scene, 3),
513
  "duration": round(per_scene, 3),
514
  "media": asset,
515
  "caption": captions[index] if index < len(captions) else "",
516
+ "transition": _style_transition(payload.transition, style.transition_sequence, index),
517
+ "effect": style.scene_effect_sequence[index % len(style.scene_effect_sequence)],
518
+ "background": "blur",
519
+ "layout": "fill",
520
  }
521
  for index, asset in enumerate(payload.assets)
522
  ]
523
+ return {"scenes": scenes, "creative_style": style.metadata_payload()}
524
 
525
 
526
  @api.post("/transcribe/upload")
 
583
  path = Path(record.output_path)
584
  if not path.exists():
585
  raise HTTPException(status_code=404, detail="Output file is missing")
586
+ return FileResponse(path, media_type=_media_type(path), filename=path.name)
587
 
588
 
589
  @api.post("/inspect")
 
595
 
596
 
597
  def _render_request(payload: RenderPayload) -> RenderRequest:
598
+ payload = RenderPayload.model_validate(apply_creative_style(apply_preset(payload.model_dump(exclude_unset=True))))
599
  request = RenderRequest(
600
  scenes=[Scene(**scene.model_dump()) for scene in payload.scenes],
601
  template=payload.template,
602
  preset=payload.preset,
603
+ creative_style=payload.creative_style,
604
  platform=payload.platform,
605
  output_name=payload.output_name,
606
  voiceover=payload.voiceover,
 
633
  return request
634
 
635
 
636
+ def _ai_reels_request(payload: AIReelsPayload) -> AIReelsRequest:
637
+ payload = AIReelsPayload.model_validate(apply_creative_style(apply_preset(payload.model_dump(exclude_unset=True))))
638
+ return AIReelsRequest(**payload.model_dump())
639
+
640
+
641
  async def _stage_uploads(files: list[UploadFile]) -> dict[str, str]:
642
  upload_dir = settings.temp_dir / "uploads" / uuid.uuid4().hex
643
  staged: dict[str, str] = {}
644
  total_bytes = 0
645
  for upload in files:
646
  filename = safe_filename(upload.filename or f"asset_{len(staged)}")
647
+ if not _allowed_upload(filename):
648
+ raise HTTPException(status_code=415, detail=f"Unsupported asset type: {filename}")
649
  target = upload_dir / filename
650
  target.parent.mkdir(parents=True, exist_ok=True)
651
  with target.open("wb") as output:
 
662
  return staged
663
 
664
 
665
+ def _monitor_payload() -> dict[str, Any]:
666
+ disk = shutil.disk_usage(settings.base_dir)
667
+ return {
668
+ "health": "ok",
669
+ "queue": job_manager.summary(),
670
+ "workers": {
671
+ "max_workers": settings.max_workers,
672
+ "ffmpeg_timeout_seconds": settings.ffmpeg_timeout_seconds,
673
+ "whisper_model_size": settings.whisper_model_size,
674
+ "whisper_device": settings.whisper_device,
675
+ },
676
+ "disk": {
677
+ "total_bytes": disk.total,
678
+ "used_bytes": disk.used,
679
+ "free_bytes": disk.free,
680
+ },
681
+ "directories": {
682
+ "temp": str(settings.temp_dir),
683
+ "exports": str(settings.exports_dir),
684
+ "jobs": str(settings.jobs_dir),
685
+ "storage": str(settings.storage_dir),
686
+ },
687
+ }
688
+
689
+
690
+ def _extract_zip(path: Path) -> dict[str, str]:
691
+ output_dir = settings.temp_dir / "uploads" / f"zip_{uuid.uuid4().hex}"
692
+ output_dir.mkdir(parents=True, exist_ok=True)
693
+ extracted: dict[str, str] = {}
694
+ with zipfile.ZipFile(path) as archive:
695
+ for member in archive.infolist():
696
+ if member.is_dir():
697
+ continue
698
+ name = safe_filename(Path(member.filename).name)
699
+ if not _allowed_upload(name):
700
+ continue
701
+ target = output_dir / name
702
+ resolved = target.resolve()
703
+ if output_dir.resolve() not in resolved.parents and resolved != output_dir.resolve():
704
+ raise HTTPException(status_code=400, detail="Unsafe ZIP member path")
705
+ with archive.open(member) as source, target.open("wb") as destination:
706
+ shutil.copyfileobj(source, destination)
707
+ extracted[name] = str(target)
708
+ return extracted
709
+
710
+
711
+ def _asset_kind(filename: str) -> str:
712
+ suffix = Path(filename).suffix.lower()
713
+ if suffix in {".mp4", ".mov", ".m4v", ".webm", ".mkv", ".avi", ".gif"}:
714
+ return "video"
715
+ if suffix in {".mp3", ".wav", ".m4a", ".aac", ".flac", ".ogg"}:
716
+ return "audio"
717
+ if suffix in {".jpg", ".jpeg", ".png", ".webp", ".avif"}:
718
+ return "image"
719
+ if suffix in {".srt", ".ass", ".vtt"}:
720
+ return "subtitle"
721
+ return "other"
722
+
723
+
724
+ def _allowed_upload(filename: str) -> bool:
725
+ suffix = Path(filename).suffix.lower()
726
+ return suffix in {
727
+ ".mp4",
728
+ ".mov",
729
+ ".m4v",
730
+ ".webm",
731
+ ".mkv",
732
+ ".avi",
733
+ ".gif",
734
+ ".mp3",
735
+ ".wav",
736
+ ".m4a",
737
+ ".aac",
738
+ ".flac",
739
+ ".ogg",
740
+ ".jpg",
741
+ ".jpeg",
742
+ ".png",
743
+ ".webp",
744
+ ".avif",
745
+ ".srt",
746
+ ".ass",
747
+ ".vtt",
748
+ ".zip",
749
+ }
750
+
751
+
752
+ def _media_type(path: Path) -> str:
753
+ suffix = path.suffix.lower()
754
+ if suffix == ".json":
755
+ return "application/json"
756
+ if suffix == ".zip":
757
+ return "application/zip"
758
+ if suffix == ".jpg" or suffix == ".jpeg":
759
+ return "image/jpeg"
760
+ if suffix == ".png":
761
+ return "image/png"
762
+ if suffix == ".gif":
763
+ return "image/gif"
764
+ if suffix == ".mp3":
765
+ return "audio/mpeg"
766
+ if suffix in {".srt", ".vtt", ".ass"}:
767
+ return "text/plain"
768
+ return "video/mp4"
769
+
770
+
771
  def _replace_upload_refs(value: Any, uploads: dict[str, str]) -> Any:
772
  if isinstance(value, dict):
773
  return {key: _replace_upload_refs(item, uploads) for key, item in value.items()}
 
779
  raise HTTPException(status_code=400, detail=f"Missing uploaded file for reference: upload://{name}")
780
  return uploads[name]
781
  return value
782
+
783
+
784
+ def _split_words_for_assets(words: list[str], count: int) -> list[str]:
785
+ if count <= 0:
786
+ return []
787
+ if not words:
788
+ return [""] * count
789
+ base, remainder = divmod(len(words), count)
790
+ captions: list[str] = []
791
+ cursor = 0
792
+ for index in range(count):
793
+ size = base + (1 if index < remainder else 0)
794
+ size = max(1, size)
795
+ captions.append(" ".join(words[cursor : cursor + size]))
796
+ cursor += size
797
+ return captions
798
+
799
+
800
+ def _style_transition(requested: str, sequence: tuple[str, ...], index: int) -> str:
801
+ if requested and requested != "fade":
802
+ return requested
803
+ return sequence[index % len(sequence)] if sequence else "fade"
app.py CHANGED
@@ -11,7 +11,7 @@ from api import api, job_manager, settings
11
  from renderer import RenderEngine
12
  from renderer.core.models import AIReelsRequest
13
  from renderer.scenes import Timeline
14
- from renderer.templates import apply_preset, list_platform_profiles, list_templates
15
 
16
 
17
  def create_dashboard() -> gr.Blocks:
@@ -36,11 +36,12 @@ def create_dashboard() -> gr.Blocks:
36
  voiceover = gr.File(label="Voiceover", file_types=["audio"], type="filepath")
37
  assets = gr.File(label="Assets", file_count="multiple", type="filepath")
38
  template = gr.Dropdown(choices=list_templates(), value="tiktok_classic", label="Caption Template")
 
39
  platform = gr.Dropdown(choices=list_platform_profiles(), value="tiktok", label="Platform")
40
  music = gr.File(label="Background Music", file_types=["audio"], type="filepath")
41
  ai_button = gr.Button("Submit AI Reel", variant="primary")
42
  ai_output = gr.JSON(label="Submission")
43
- ai_button.click(fn=_submit_ai_reel, inputs=[script, voiceover, assets, template, platform, music], outputs=ai_output)
44
 
45
  with gr.Tab("Batch Render"):
46
  batch_json = gr.Textbox(label="Batch JSON", lines=14, value=json.dumps({"jobs": []}, indent=2))
@@ -88,11 +89,50 @@ def create_dashboard() -> gr.Blocks:
88
  inspect_output = gr.JSON(label="Metadata")
89
  inspect_button.click(fn=_inspect_asset, inputs=asset_path, outputs=inspect_output)
90
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
91
  return dashboard
92
 
93
 
94
  def _submit_render_json(payload: str) -> dict[str, Any]:
95
- data = apply_preset(json.loads(payload))
96
  request = Timeline.request_from_payload(data)
97
  job_id = job_manager.submit_render(request)
98
  return {"job_id": job_id, "status": f"/status/{job_id}", "download": f"/download/{job_id}"}
@@ -100,16 +140,25 @@ def _submit_render_json(payload: str) -> dict[str, Any]:
100
 
101
  def _submit_batch_json(payload: str) -> dict[str, Any]:
102
  data = json.loads(payload)
103
- requests = [Timeline.request_from_payload(apply_preset(job)) for job in data.get("jobs", [])]
104
  return {"job_ids": job_manager.submit_batch(requests)}
105
 
106
 
107
- def _submit_ai_reel(script: str, voiceover: str, assets: list[str], template: str, platform: str, music: str | None) -> dict[str, Any]:
 
 
 
 
 
 
 
 
108
  request = AIReelsRequest(
109
  script=script,
110
  voiceover=voiceover,
111
  assets=assets or [],
112
  template=template,
 
113
  platform=platform,
114
  background_music=music,
115
  )
@@ -145,6 +194,42 @@ def _transcribe_file(path: str, model_size: str, language: str) -> dict[str, Any
145
  )
146
 
147
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
148
  app = gr.mount_gradio_app(api, create_dashboard(), path="/dashboard")
149
 
150
 
 
11
  from renderer import RenderEngine
12
  from renderer.core.models import AIReelsRequest
13
  from renderer.scenes import Timeline
14
+ from renderer.templates import apply_creative_style, apply_preset, list_creative_styles, list_platform_profiles, list_templates
15
 
16
 
17
  def create_dashboard() -> gr.Blocks:
 
36
  voiceover = gr.File(label="Voiceover", file_types=["audio"], type="filepath")
37
  assets = gr.File(label="Assets", file_count="multiple", type="filepath")
38
  template = gr.Dropdown(choices=list_templates(), value="tiktok_classic", label="Caption Template")
39
+ creative_style = gr.Dropdown(choices=list_creative_styles(), value="viral_shorts", label="Creative Style")
40
  platform = gr.Dropdown(choices=list_platform_profiles(), value="tiktok", label="Platform")
41
  music = gr.File(label="Background Music", file_types=["audio"], type="filepath")
42
  ai_button = gr.Button("Submit AI Reel", variant="primary")
43
  ai_output = gr.JSON(label="Submission")
44
+ ai_button.click(fn=_submit_ai_reel, inputs=[script, voiceover, assets, template, creative_style, platform, music], outputs=ai_output)
45
 
46
  with gr.Tab("Batch Render"):
47
  batch_json = gr.Textbox(label="Batch JSON", lines=14, value=json.dumps({"jobs": []}, indent=2))
 
89
  inspect_output = gr.JSON(label="Metadata")
90
  inspect_button.click(fn=_inspect_asset, inputs=asset_path, outputs=inspect_output)
91
 
92
+ with gr.Tab("AI Analysis"):
93
+ analysis_media = gr.Textbox(label="Media URL or path")
94
+ analysis_transcript = gr.Textbox(label="Transcript", lines=5)
95
+ analysis_platform = gr.Dropdown(choices=list_platform_profiles(), value="tiktok", label="Target Platform")
96
+ analysis_button = gr.Button("Submit Analysis", variant="primary")
97
+ analysis_output = gr.JSON(label="Analysis Job")
98
+ analysis_button.click(
99
+ fn=_submit_analysis,
100
+ inputs=[analysis_media, analysis_transcript, analysis_platform],
101
+ outputs=analysis_output,
102
+ )
103
+
104
+ with gr.Tab("Clip Generator"):
105
+ clip_media = gr.Textbox(label="Media URL or path")
106
+ clip_json = gr.Textbox(label="Clip JSON", lines=6, value=json.dumps([{"start": 0, "end": 8}], indent=2))
107
+ clip_button = gr.Button("Generate Clips", variant="primary")
108
+ clip_output = gr.JSON(label="Clip Job")
109
+ clip_button.click(fn=_submit_clips, inputs=[clip_media, clip_json], outputs=clip_output)
110
+
111
+ with gr.Tab("Metadata"):
112
+ metadata_topic = gr.Textbox(label="Topic or transcript", lines=5)
113
+ metadata_platform = gr.Dropdown(choices=list_platform_profiles(), value="tiktok", label="Platform")
114
+ metadata_button = gr.Button("Generate Metadata", variant="primary")
115
+ metadata_output = gr.JSON(label="Metadata Job")
116
+ metadata_button.click(fn=_submit_metadata, inputs=[metadata_topic, metadata_platform], outputs=metadata_output)
117
+
118
+ with gr.Tab("Publishing"):
119
+ publish_media = gr.Textbox(label="Media URL or rendered output path")
120
+ publish_title = gr.Textbox(label="Title")
121
+ publish_platforms = gr.Textbox(label="Platforms", value="youtube,tiktok,instagram")
122
+ publish_button = gr.Button("Create Publish Draft", variant="primary")
123
+ publish_output = gr.JSON(label="Publish Job")
124
+ publish_button.click(fn=_submit_publish, inputs=[publish_media, publish_title, publish_platforms], outputs=publish_output)
125
+
126
+ with gr.Tab("Queue Monitor"):
127
+ queue_button = gr.Button("Refresh Queue")
128
+ queue_output = gr.JSON(label="Queue")
129
+ queue_button.click(fn=_queue_status, outputs=queue_output)
130
+
131
  return dashboard
132
 
133
 
134
  def _submit_render_json(payload: str) -> dict[str, Any]:
135
+ data = apply_creative_style(apply_preset(json.loads(payload)))
136
  request = Timeline.request_from_payload(data)
137
  job_id = job_manager.submit_render(request)
138
  return {"job_id": job_id, "status": f"/status/{job_id}", "download": f"/download/{job_id}"}
 
140
 
141
  def _submit_batch_json(payload: str) -> dict[str, Any]:
142
  data = json.loads(payload)
143
+ requests = [Timeline.request_from_payload(apply_creative_style(apply_preset(job))) for job in data.get("jobs", [])]
144
  return {"job_ids": job_manager.submit_batch(requests)}
145
 
146
 
147
+ def _submit_ai_reel(
148
+ script: str,
149
+ voiceover: str,
150
+ assets: list[str],
151
+ template: str,
152
+ creative_style: str,
153
+ platform: str,
154
+ music: str | None,
155
+ ) -> dict[str, Any]:
156
  request = AIReelsRequest(
157
  script=script,
158
  voiceover=voiceover,
159
  assets=assets or [],
160
  template=template,
161
+ creative_style=creative_style,
162
  platform=platform,
163
  background_music=music,
164
  )
 
194
  )
195
 
196
 
197
+ def _submit_analysis(media: str, transcript: str, platform: str) -> dict[str, Any]:
198
+ from renderer.platform import PlatformProcessor
199
+
200
+ job_id = job_manager.submit_task(
201
+ lambda task_id, log: PlatformProcessor(settings, log=log).analyze(media, task_id, transcript=transcript, platform=platform)
202
+ )
203
+ return {"job_id": job_id, "status": f"/status/{job_id}", "download": f"/download/{job_id}"}
204
+
205
+
206
+ def _submit_clips(media: str, clips_json: str) -> dict[str, Any]:
207
+ from renderer.platform import PlatformProcessor
208
+
209
+ clips = json.loads(clips_json)
210
+ job_id = job_manager.submit_task(lambda task_id, log: PlatformProcessor(settings, log=log).clips(media, task_id, clips))
211
+ return {"job_id": job_id, "status": f"/status/{job_id}", "download": f"/download/{job_id}"}
212
+
213
+
214
+ def _submit_metadata(topic: str, platform: str) -> dict[str, Any]:
215
+ from renderer.platform import PlatformProcessor
216
+
217
+ job_id = job_manager.submit_task(lambda task_id, log: PlatformProcessor(settings, log=log).metadata(task_id, topic=topic, platform=platform))
218
+ return {"job_id": job_id, "status": f"/status/{job_id}", "download": f"/download/{job_id}"}
219
+
220
+
221
+ def _submit_publish(media: str, title: str, platforms: str) -> dict[str, Any]:
222
+ from renderer.platform import PlatformProcessor
223
+
224
+ payload = {"media": media, "title": title, "platforms": [item.strip() for item in platforms.split(",") if item.strip()], "draft": True}
225
+ job_id = job_manager.submit_task(lambda task_id, log: PlatformProcessor(settings, log=log).publish(payload, task_id))
226
+ return {"job_id": job_id, "status": f"/status/{job_id}", "download": f"/download/{job_id}"}
227
+
228
+
229
+ def _queue_status() -> dict[str, Any]:
230
+ return job_manager.summary()
231
+
232
+
233
  app = gr.mount_gradio_app(api, create_dashboard(), path="/dashboard")
234
 
235