Ava2lon commited on
Commit
6c45e6d
·
verified ·
1 Parent(s): 9845310

Upload 6 files

Browse files
Files changed (4) hide show
  1. Dockerfile +2 -0
  2. api.py +532 -21
  3. app.py +144 -5
  4. requirements.txt +1 -0
Dockerfile CHANGED
@@ -5,7 +5,9 @@ ENV PYTHONUNBUFFERED=1 \
5
  EXPORTS_DIR=/app/exports \
6
  JOBS_DIR=/app/jobs \
7
  STORAGE_DIR=/app/storage \
 
8
  BASYX_BASE_DIR=/app \
 
9
  BASYX_SIGNING_SECRET=change-me-in-space-secrets \
10
  MAX_RENDER_WORKERS=1 \
11
  FFMPEG_TIMEOUT_SECONDS=900 \
 
5
  EXPORTS_DIR=/app/exports \
6
  JOBS_DIR=/app/jobs \
7
  STORAGE_DIR=/app/storage \
8
+ AVA2LON_BASE_DIR=/app \
9
  BASYX_BASE_DIR=/app \
10
+ AVA2LON_SIGNING_SECRET=change-me-in-space-secrets \
11
  BASYX_SIGNING_SECRET=change-me-in-space-secrets \
12
  MAX_RENDER_WORKERS=1 \
13
  FFMPEG_TIMEOUT_SECONDS=900 \
api.py CHANGED
@@ -19,6 +19,15 @@ 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 (
@@ -39,7 +48,8 @@ from renderer.transitions import TransitionBuilder
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
 
@@ -226,6 +236,124 @@ class PublishPayload(BaseModel):
226
  class ProjectPayload(BaseModel):
227
  name: str
228
  metadata: dict[str, Any] = Field(default_factory=dict)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
229
 
230
 
231
  @api.get("/health")
@@ -279,32 +407,307 @@ 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")
@@ -329,6 +732,24 @@ def render_batch(payload: BatchPayload) -> dict[str, list[str]]:
329
  return {"job_ids": job_ids}
330
 
331
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
332
  @api.post("/render/upload")
333
  async def render_upload(request_json: str = Form(...), files: list[UploadFile] = File(default=[])) -> dict[str, str]:
334
  uploads = await _stage_uploads(files)
@@ -419,6 +840,11 @@ def thumbnail(payload: ThumbnailPayload) -> dict[str, str]:
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(
@@ -549,6 +975,11 @@ async def transcribe_upload(
549
  raise HTTPException(status_code=400, detail=str(exc)) from exc
550
 
551
 
 
 
 
 
 
552
  @api.get("/status/{job_id}")
553
  def status(job_id: str) -> dict:
554
  try:
@@ -570,6 +1001,11 @@ def cleanup(older_than_seconds: int | None = None) -> dict[str, int]:
570
  return job_manager.cleanup(older_than_seconds)
571
 
572
 
 
 
 
 
 
573
  @api.get("/download/{job_id}")
574
  def download(job_id: str, token: str | None = Query(default=None)) -> FileResponse:
575
  try:
@@ -633,11 +1069,86 @@ def _render_request(payload: RenderPayload) -> RenderRequest:
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] = {}
 
19
  from renderer.jobs import JobManager
20
  from renderer.platform import PlatformProcessor, supported_toolkit_tasks
21
  from renderer.scenes import Timeline
22
+ from renderer.studio import (
23
+ ProjectStore,
24
+ StudioTaskProcessor,
25
+ add_effect,
26
+ add_filter,
27
+ add_keyframe,
28
+ add_transition,
29
+ capability_catalog,
30
+ )
31
  from renderer import RenderEngine
32
  from renderer.subtitles import SubtitleEvent, SubtitleGenerator
33
  from renderer.templates import (
 
48
  settings = Settings()
49
  settings.ensure_dirs()
50
  job_manager = JobManager(settings)
51
+ project_manager = ProjectStore(settings)
52
+ api = FastAPI(title="Ava2lon Studio AI", version="2.0.0")
53
 
54
  API_KEY_HEADER = "X-API-Key"
55
 
 
236
  class ProjectPayload(BaseModel):
237
  name: str
238
  metadata: dict[str, Any] = Field(default_factory=dict)
239
+ export_settings: dict[str, Any] = Field(default_factory=dict)
240
+ template: dict[str, Any] | None = None
241
+
242
+
243
+ class ProjectSavePayload(BaseModel):
244
+ project_id: str | None = None
245
+ project: dict[str, Any]
246
+
247
+
248
+ class ProjectAssetPayload(BaseModel):
249
+ project_id: str
250
+ asset: dict[str, Any]
251
+
252
+
253
+ class TimelineAddPayload(BaseModel):
254
+ project_id: str
255
+ track_type: str = "video"
256
+ track_id: str | None = None
257
+ item: dict[str, Any] = Field(default_factory=dict)
258
+
259
+
260
+ class TimelineOperationPayload(BaseModel):
261
+ project_id: str
262
+ operation: str = "drag"
263
+ item_id: str | None = None
264
+ params: dict[str, Any] = Field(default_factory=dict)
265
+
266
+
267
+ class EffectApplyPayload(BaseModel):
268
+ project_id: str | None = None
269
+ target_id: str | None = None
270
+ item_id: str | None = None
271
+ effect: str
272
+ params: dict[str, Any] = Field(default_factory=dict)
273
+ callback_url: str | None = None
274
+
275
+
276
+ class FilterApplyPayload(BaseModel):
277
+ project_id: str | None = None
278
+ target_id: str | None = None
279
+ item_id: str | None = None
280
+ filter: str
281
+ params: dict[str, Any] = Field(default_factory=dict)
282
+ lut: str | None = None
283
+ callback_url: str | None = None
284
+
285
+
286
+ class TransitionAddPayload(BaseModel):
287
+ project_id: str | None = None
288
+ from_item_id: str | None = None
289
+ to_item_id: str | None = None
290
+ target_id: str | None = None
291
+ transition: str
292
+ duration: float = Field(default=0.45, gt=0)
293
+ params: dict[str, Any] = Field(default_factory=dict)
294
+ callback_url: str | None = None
295
+
296
+
297
+ class KeyframePayload(BaseModel):
298
+ project_id: str
299
+ target_id: str
300
+ property: str
301
+ time: float = Field(ge=0)
302
+ value: Any
303
+ easing: str = "linear"
304
+
305
+
306
+ class GenerationPayload(BaseModel):
307
+ prompt: str | None = None
308
+ text: str | None = None
309
+ media: str | None = None
310
+ provider: str | None = None
311
+ callback_url: str | None = None
312
+ export_target: str | None = None
313
+ params: dict[str, Any] = Field(default_factory=dict)
314
+ model_config = {"extra": "allow"}
315
+
316
+
317
+ class CaptionGeneratePayload(BaseModel):
318
+ media: str | None = None
319
+ audio: str | None = None
320
+ text: str | None = None
321
+ transcript: str | None = None
322
+ events: list[dict[str, Any]] | None = None
323
+ template: str = "capcut"
324
+ language: str | None = None
325
+ engine: str = "whisper"
326
+ word_timestamps: bool = True
327
+ emoji_insertion: bool = False
328
+ speaker_detection: bool = False
329
+ karaoke: bool = True
330
+ animated: bool = True
331
+ callback_url: str | None = None
332
+ model_config = {"extra": "allow"}
333
+
334
+
335
+ class AIToolPayload(BaseModel):
336
+ project_id: str | None = None
337
+ media: str | None = None
338
+ transcript: str | None = None
339
+ text: str | None = None
340
+ platform: str | None = None
341
+ callback_url: str | None = None
342
+ params: dict[str, Any] = Field(default_factory=dict)
343
+ model_config = {"extra": "allow"}
344
+
345
+
346
+ class ProjectRenderPayload(BaseModel):
347
+ output_name: str = "project_render.mp4"
348
+ template: str | None = None
349
+ preset: str | None = None
350
+ creative_style: str | None = None
351
+ platform: str | None = None
352
+ callback_url: str | None = None
353
+ export_target: str | None = None
354
+ preview: bool = False
355
+ normalize: bool = True
356
+ metadata: dict[str, Any] = Field(default_factory=dict)
357
 
358
 
359
  @api.get("/health")
 
407
  return {"tasks": supported_toolkit_tasks()}
408
 
409
 
410
+ @api.get("/capabilities")
411
+ def capabilities() -> dict[str, Any]:
412
+ catalog = capability_catalog()
413
+ catalog["runtime"] = {
414
+ "toolkit_tasks": supported_toolkit_tasks(),
415
+ "caption_templates": list_templates(),
416
+ "creative_styles": list_creative_styles(),
417
+ "scene_effects": list_scene_effects(),
418
+ "platforms": list_platform_profiles(),
419
+ "transitions": TransitionBuilder().list_transitions(),
420
+ }
421
+ return catalog
422
+
423
+
424
+ @api.get("/effects")
425
+ def effects() -> dict[str, Any]:
426
+ catalog = capability_catalog()
427
+ return {"effects": catalog["effects"], "scene_effects": scene_effect_metadata()}
428
+
429
+
430
+ @api.get("/filters")
431
+ def filters() -> dict[str, Any]:
432
+ return {"filters": capability_catalog()["filters"]}
433
+
434
+
435
+ @api.get("/transitions")
436
+ def transitions() -> dict[str, Any]:
437
+ return {"families": capability_catalog()["transitions"], "ffmpeg": TransitionBuilder().list_transitions()}
438
+
439
+
440
+ @api.get("/templates/catalog")
441
+ def templates_catalog() -> dict[str, Any]:
442
+ return {
443
+ "categories": capability_catalog()["templates"],
444
+ "caption_templates": list_templates(),
445
+ "render_presets": list_presets(),
446
+ "creative_styles": creative_style_metadata(),
447
+ }
448
+
449
+
450
  @api.get("/projects")
451
  def list_projects() -> dict[str, list[dict[str, Any]]]:
452
+ return {"projects": project_manager.list()}
 
 
 
 
 
 
 
 
453
 
454
 
455
  @api.post("/projects", dependencies=[Depends(_verify_api_key)])
456
  def create_project(payload: ProjectPayload) -> dict[str, Any]:
457
+ project = project_manager.create(payload.name, metadata=payload.metadata, template=payload.template)
458
+ if payload.export_settings:
459
+ project["export_settings"].update(payload.export_settings)
460
+ project = project_manager.save(project["id"], project)
461
+ return {"project": project}
462
+
463
+
464
+ @api.post("/project/create", dependencies=[Depends(_verify_api_key)])
465
+ def project_create(payload: ProjectPayload) -> dict[str, Any]:
466
+ return create_project(payload)
467
+
468
+
469
+ @api.get("/project/{project_id}")
470
+ def project_get(project_id: str) -> dict[str, Any]:
471
+ try:
472
+ return {"project": project_manager.get(project_id)}
473
+ except KeyError as exc:
474
+ raise HTTPException(status_code=404, detail="Project not found") from exc
475
+
476
+
477
+ @api.post("/project/save", dependencies=[Depends(_verify_api_key)])
478
+ def project_save(payload: ProjectSavePayload) -> dict[str, Any]:
479
+ project_id = payload.project_id or str(payload.project.get("id") or safe_filename(payload.project.get("name", "project")))
480
+ return {"project": project_manager.save(project_id, payload.project)}
481
+
482
+
483
+ @api.post("/project/assets/add", dependencies=[Depends(_verify_api_key)])
484
+ def project_asset_add(payload: ProjectAssetPayload) -> dict[str, Any]:
485
+ try:
486
+ return {"project": project_manager.add_asset(payload.project_id, payload.asset)}
487
+ except KeyError as exc:
488
+ raise HTTPException(status_code=404, detail="Project not found") from exc
489
+
490
+
491
+ @api.post("/timeline/add", dependencies=[Depends(_verify_api_key)])
492
+ def timeline_add(payload: TimelineAddPayload) -> dict[str, Any]:
493
+ try:
494
+ project = project_manager.add_to_timeline(payload.project_id, payload.item, payload.track_type, payload.track_id)
495
+ return {"project": project}
496
+ except KeyError as exc:
497
+ raise HTTPException(status_code=404, detail="Project not found") from exc
498
+ except ValueError as exc:
499
+ raise HTTPException(status_code=400, detail=str(exc)) from exc
500
+
501
+
502
+ @api.post("/timeline/operation", dependencies=[Depends(_verify_api_key)])
503
+ def timeline_operation(payload: TimelineOperationPayload) -> dict[str, Any]:
504
+ try:
505
+ project = project_manager.timeline_operation(payload.project_id, payload.operation, payload.item_id, payload.params)
506
+ return {"project": project}
507
+ except KeyError as exc:
508
+ raise HTTPException(status_code=404, detail="Timeline item or project not found") from exc
509
+ except ValueError as exc:
510
+ raise HTTPException(status_code=400, detail=str(exc)) from exc
511
+
512
+
513
+ @api.post("/timeline/split", dependencies=[Depends(_verify_api_key)])
514
+ def timeline_split(payload: TimelineOperationPayload) -> dict[str, Any]:
515
+ payload.operation = "split"
516
+ return timeline_operation(payload)
517
+
518
+
519
+ @api.post("/timeline/trim", dependencies=[Depends(_verify_api_key)])
520
+ def timeline_trim(payload: TimelineOperationPayload) -> dict[str, Any]:
521
+ payload.operation = "trim"
522
+ return timeline_operation(payload)
523
+
524
+
525
+ @api.post("/timeline/ripple-delete", dependencies=[Depends(_verify_api_key)])
526
+ def timeline_ripple_delete(payload: TimelineOperationPayload) -> dict[str, Any]:
527
+ payload.operation = "ripple_delete"
528
+ return timeline_operation(payload)
529
+
530
+
531
+ @api.post("/timeline/insert", dependencies=[Depends(_verify_api_key)])
532
+ def timeline_insert(payload: TimelineOperationPayload) -> dict[str, Any]:
533
+ payload.operation = "insert"
534
+ return timeline_operation(payload)
535
+
536
+
537
+ @api.post("/timeline/replace", dependencies=[Depends(_verify_api_key)])
538
+ def timeline_replace(payload: TimelineOperationPayload) -> dict[str, Any]:
539
+ payload.operation = "replace"
540
+ return timeline_operation(payload)
541
+
542
+
543
+ @api.post("/timeline/group", dependencies=[Depends(_verify_api_key)])
544
+ def timeline_group(payload: TimelineOperationPayload) -> dict[str, Any]:
545
+ payload.operation = "group"
546
+ return timeline_operation(payload)
547
+
548
+
549
+ @api.post("/timeline/lock", dependencies=[Depends(_verify_api_key)])
550
+ def timeline_lock(payload: TimelineOperationPayload) -> dict[str, Any]:
551
+ payload.operation = "lock"
552
+ return timeline_operation(payload)
553
+
554
+
555
+ @api.post("/timeline/hide", dependencies=[Depends(_verify_api_key)])
556
+ def timeline_hide(payload: TimelineOperationPayload) -> dict[str, Any]:
557
+ payload.operation = "hide"
558
+ return timeline_operation(payload)
559
+
560
+
561
+ @api.post("/timeline/duplicate", dependencies=[Depends(_verify_api_key)])
562
+ def timeline_duplicate(payload: TimelineOperationPayload) -> dict[str, Any]:
563
+ payload.operation = "duplicate"
564
+ return timeline_operation(payload)
565
+
566
+
567
+ @api.post("/effect/apply", dependencies=[Depends(_verify_api_key)])
568
+ def effect_apply(payload: EffectApplyPayload) -> dict[str, Any]:
569
+ target_id = payload.target_id or payload.item_id
570
+ if payload.project_id and target_id:
571
+ try:
572
+ project = project_manager.get(payload.project_id)
573
+ record = add_effect(project, target_id, payload.effect, payload.params)
574
+ project = project_manager.save(payload.project_id, project)
575
+ return {"effect": record, "project": project}
576
+ except KeyError as exc:
577
+ raise HTTPException(status_code=404, detail="Project or target item not found") from exc
578
+ data = {"task": payload.effect, "input": target_id, "params": payload.params, "callback_url": payload.callback_url}
579
+ job_id = job_manager.submit_task(
580
+ lambda task_id, log: StudioTaskProcessor(settings, log=log).ai_tool("auto_edit", data, task_id),
581
+ callback_url=payload.callback_url,
582
+ )
583
+ return _submission_response(job_id)
584
+
585
+
586
+ @api.post("/filter/apply", dependencies=[Depends(_verify_api_key)])
587
+ def filter_apply(payload: FilterApplyPayload) -> dict[str, Any]:
588
+ target_id = payload.target_id or payload.item_id
589
+ params = dict(payload.params)
590
+ if payload.lut:
591
+ params["lut"] = payload.lut
592
+ if payload.project_id and target_id:
593
+ try:
594
+ project = project_manager.get(payload.project_id)
595
+ record = add_filter(project, target_id, payload.filter, params)
596
+ project = project_manager.save(payload.project_id, project)
597
+ return {"filter": record, "project": project}
598
+ except KeyError as exc:
599
+ raise HTTPException(status_code=404, detail="Project or target item not found") from exc
600
+ job_id = job_manager.submit_task(
601
+ lambda task_id, log: StudioTaskProcessor(settings, log=log).ai_tool("auto_color_match", payload.model_dump(), task_id),
602
+ callback_url=payload.callback_url,
603
+ )
604
+ return _submission_response(job_id)
605
+
606
+
607
+ @api.post("/transition/add", dependencies=[Depends(_verify_api_key)])
608
+ def transition_add(payload: TransitionAddPayload) -> dict[str, Any]:
609
+ if payload.project_id and payload.from_item_id and payload.to_item_id:
610
+ try:
611
+ project = project_manager.get(payload.project_id)
612
+ record = add_transition(project, payload.from_item_id, payload.to_item_id, payload.transition, payload.duration)
613
+ project = project_manager.save(payload.project_id, project)
614
+ return {"transition": record, "project": project}
615
+ except KeyError as exc:
616
+ raise HTTPException(status_code=404, detail="Project not found") from exc
617
+ job_id = job_manager.submit_task(
618
+ lambda task_id, log: StudioTaskProcessor(settings, log=log).ai_tool("auto_edit", payload.model_dump(), task_id),
619
+ callback_url=payload.callback_url,
620
+ )
621
+ return _submission_response(job_id)
622
+
623
+
624
+ @api.post("/keyframe/add", dependencies=[Depends(_verify_api_key)])
625
+ def keyframe_add(payload: KeyframePayload) -> dict[str, Any]:
626
+ try:
627
+ project = project_manager.get(payload.project_id)
628
+ record = add_keyframe(project, payload.target_id, payload.property, payload.time, payload.value, payload.easing)
629
+ project = project_manager.save(payload.project_id, project)
630
+ return {"keyframe": record, "project": project}
631
+ except KeyError as exc:
632
+ raise HTTPException(status_code=404, detail="Project or target item not found") from exc
633
+
634
+
635
+ @api.post("/caption/generate", dependencies=[Depends(_verify_api_key)])
636
+ def caption_generate(payload: CaptionGeneratePayload) -> dict[str, str]:
637
+ data = payload.model_dump()
638
+ job_id = job_manager.submit_task(
639
+ lambda task_id, log: StudioTaskProcessor(settings, log=log).caption_generate(data, task_id),
640
+ callback_url=payload.callback_url,
641
+ )
642
+ return _submission_response(job_id)
643
+
644
+
645
+ @api.post("/music/generate", dependencies=[Depends(_verify_api_key)])
646
+ def music_generate(payload: GenerationPayload) -> dict[str, str]:
647
+ data = _generation_data(payload)
648
+ job_id = job_manager.submit_task(
649
+ lambda task_id, log: StudioTaskProcessor(settings, log=log).music_generate(data, task_id),
650
+ callback_url=payload.callback_url,
651
+ export_target=payload.export_target,
652
+ )
653
+ return _submission_response(job_id)
654
+
655
+
656
+ @api.post("/voice/generate", dependencies=[Depends(_verify_api_key)])
657
+ def voice_generate(payload: GenerationPayload) -> dict[str, str]:
658
+ data = _generation_data(payload)
659
+ job_id = job_manager.submit_task(
660
+ lambda task_id, log: StudioTaskProcessor(settings, log=log).voice_generate(data, task_id),
661
+ callback_url=payload.callback_url,
662
+ export_target=payload.export_target,
663
+ )
664
+ return _submission_response(job_id)
665
+
666
+
667
+ @api.post("/image/generate", dependencies=[Depends(_verify_api_key)])
668
+ def image_generate(payload: GenerationPayload) -> dict[str, str]:
669
+ data = _generation_data(payload)
670
+ job_id = job_manager.submit_task(
671
+ lambda task_id, log: StudioTaskProcessor(settings, log=log).image_generate(data, task_id),
672
+ callback_url=payload.callback_url,
673
+ export_target=payload.export_target,
674
+ )
675
+ return _submission_response(job_id)
676
+
677
+
678
+ @api.post("/video/generate", dependencies=[Depends(_verify_api_key)])
679
+ def video_generate(payload: GenerationPayload) -> dict[str, str]:
680
+ data = _generation_data(payload)
681
+ job_id = job_manager.submit_task(
682
+ lambda task_id, log: StudioTaskProcessor(settings, log=log).video_generate(data, task_id),
683
+ callback_url=payload.callback_url,
684
+ export_target=payload.export_target,
685
+ )
686
+ return _submission_response(job_id)
687
+
688
+
689
+ @api.post("/ai/{tool}", dependencies=[Depends(_verify_api_key)])
690
+ def ai_tool(tool: str, payload: AIToolPayload) -> dict[str, str]:
691
+ data = payload.model_dump()
692
+ if payload.model_extra:
693
+ data.update(payload.model_extra)
694
+ job_id = job_manager.submit_task(
695
+ lambda task_id, log: StudioTaskProcessor(settings, log=log).ai_tool(tool, data, task_id),
696
+ callback_url=payload.callback_url,
697
+ )
698
+ return _submission_response(job_id)
699
+
700
+
701
+ @api.post("/assistant/{tool}", dependencies=[Depends(_verify_api_key)])
702
+ def assistant_tool(tool: str, payload: AIToolPayload) -> dict[str, str]:
703
+ data = payload.model_dump()
704
+ if payload.model_extra:
705
+ data.update(payload.model_extra)
706
+ job_id = job_manager.submit_task(
707
+ lambda task_id, log: StudioTaskProcessor(settings, log=log).assistant_tool(tool, data, task_id),
708
+ callback_url=payload.callback_url,
709
+ )
710
+ return _submission_response(job_id)
711
 
712
 
713
  @api.post("/render")
 
732
  return {"job_ids": job_ids}
733
 
734
 
735
+ @api.post("/automation/batch", dependencies=[Depends(_verify_api_key)])
736
+ def automation_batch(payload: BatchPayload) -> dict[str, list[str]]:
737
+ return render_batch(payload)
738
+
739
+
740
+ @api.post("/project/{project_id}/render", dependencies=[Depends(_verify_api_key)])
741
+ def project_render(project_id: str, payload: ProjectRenderPayload) -> dict[str, str]:
742
+ try:
743
+ project = project_manager.get(project_id)
744
+ request = _project_render_request(project, payload)
745
+ job_id = job_manager.submit_render(request)
746
+ return _submission_response(job_id)
747
+ except KeyError as exc:
748
+ raise HTTPException(status_code=404, detail="Project not found") from exc
749
+ except ValueError as exc:
750
+ raise HTTPException(status_code=400, detail=str(exc)) from exc
751
+
752
+
753
  @api.post("/render/upload")
754
  async def render_upload(request_json: str = Form(...), files: list[UploadFile] = File(default=[])) -> dict[str, str]:
755
  uploads = await _stage_uploads(files)
 
840
  return _submission_response(job_id)
841
 
842
 
843
+ @api.post("/thumbnail/create", dependencies=[Depends(_verify_api_key)])
844
+ def thumbnail_create(payload: ThumbnailPayload) -> dict[str, str]:
845
+ return thumbnail(payload)
846
+
847
+
848
  @api.post("/metadata", dependencies=[Depends(_verify_api_key)])
849
  def metadata(payload: MetadataPayload) -> dict[str, str]:
850
  job_id = job_manager.submit_task(
 
975
  raise HTTPException(status_code=400, detail=str(exc)) from exc
976
 
977
 
978
+ @api.get("/status")
979
+ def status_query(job_id: str = Query(...)) -> dict:
980
+ return status(job_id)
981
+
982
+
983
  @api.get("/status/{job_id}")
984
  def status(job_id: str) -> dict:
985
  try:
 
1001
  return job_manager.cleanup(older_than_seconds)
1002
 
1003
 
1004
+ @api.get("/download")
1005
+ def download_query(job_id: str = Query(...), token: str | None = Query(default=None)) -> FileResponse:
1006
+ return download(job_id, token)
1007
+
1008
+
1009
  @api.get("/download/{job_id}")
1010
  def download(job_id: str, token: str | None = Query(default=None)) -> FileResponse:
1011
  try:
 
1069
  return request
1070
 
1071
 
1072
+ def _project_render_request(project: dict[str, Any], payload: ProjectRenderPayload) -> RenderRequest:
1073
+ scenes: list[dict[str, Any]] = []
1074
+ for tracks in project.get("timeline", {}).get("tracks", {}).values():
1075
+ for track in tracks:
1076
+ if track.get("hidden") or track.get("type") not in {"video", "overlay"}:
1077
+ continue
1078
+ for item in track.get("items", []):
1079
+ if item.get("hidden"):
1080
+ continue
1081
+ media = _item_media(project, item)
1082
+ if not media:
1083
+ continue
1084
+ scenes.append(
1085
+ {
1086
+ "start": float(item.get("start", 0.0)),
1087
+ "duration": float(item.get("duration", 1.0)),
1088
+ "media": media,
1089
+ "caption": item.get("caption") or item.get("text") or "",
1090
+ "transition": item.get("transition", "fade"),
1091
+ "background": item.get("background", "blur"),
1092
+ "layout": item.get("layout", "fill"),
1093
+ "effect": _first_named(item.get("effects")),
1094
+ }
1095
+ )
1096
+ if not scenes:
1097
+ raise ValueError("Project has no renderable video or overlay timeline items")
1098
+ export_settings = project.get("export_settings", {})
1099
+ data = {
1100
+ "scenes": sorted(scenes, key=lambda scene: scene["start"]),
1101
+ "template": payload.template or export_settings.get("template", "tiktok_classic"),
1102
+ "preset": payload.preset,
1103
+ "creative_style": payload.creative_style or project.get("metadata", {}).get("creative_style"),
1104
+ "platform": payload.platform or export_settings.get("platform"),
1105
+ "output_name": payload.output_name,
1106
+ "callback_url": payload.callback_url,
1107
+ "export_target": payload.export_target,
1108
+ "preview": payload.preview,
1109
+ "normalize": payload.normalize,
1110
+ "metadata": project.get("metadata", {}) | payload.metadata | {"project_id": project.get("id"), "project_name": project.get("name")},
1111
+ }
1112
+ return _render_request(RenderPayload.model_validate(data))
1113
+
1114
+
1115
  def _ai_reels_request(payload: AIReelsPayload) -> AIReelsRequest:
1116
  payload = AIReelsPayload.model_validate(apply_creative_style(apply_preset(payload.model_dump(exclude_unset=True))))
1117
  return AIReelsRequest(**payload.model_dump())
1118
 
1119
 
1120
+ def _generation_data(payload: GenerationPayload) -> dict[str, Any]:
1121
+ data = payload.model_dump()
1122
+ if payload.model_extra:
1123
+ data.update(payload.model_extra)
1124
+ params = data.pop("params", {}) or {}
1125
+ if isinstance(params, dict):
1126
+ data.update(params)
1127
+ return data
1128
+
1129
+
1130
+ def _item_media(project: dict[str, Any], item: dict[str, Any]) -> str | None:
1131
+ direct = item.get("media") or item.get("path") or item.get("source")
1132
+ if direct:
1133
+ return str(direct)
1134
+ asset_id = item.get("asset_id")
1135
+ if not asset_id:
1136
+ return None
1137
+ for asset in project.get("assets", []):
1138
+ if asset.get("id") == asset_id:
1139
+ return str(asset.get("path") or asset.get("url") or asset.get("source") or "")
1140
+ return None
1141
+
1142
+
1143
+ def _first_named(records: Any) -> str | None:
1144
+ if not isinstance(records, list) or not records:
1145
+ return None
1146
+ first = records[0]
1147
+ if isinstance(first, dict):
1148
+ return first.get("effect") or first.get("name")
1149
+ return str(first)
1150
+
1151
+
1152
  async def _stage_uploads(files: list[UploadFile]) -> dict[str, str]:
1153
  upload_dir = settings.temp_dir / "uploads" / uuid.uuid4().hex
1154
  staged: dict[str, str] = {}
app.py CHANGED
@@ -7,20 +7,91 @@ from typing import Any
7
  import gradio as gr
8
  import uvicorn
9
 
10
- 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_creative_style, apply_preset, list_creative_styles, list_platform_profiles, list_templates
15
 
16
 
17
  def create_dashboard() -> gr.Blocks:
18
- with gr.Blocks(title="Basyx FFmpeg Rendering Engine") as dashboard:
19
  gr.Markdown(
20
- "# Basyx FFmpeg Rendering Engine\n"
21
- "CPU-first rendering backend for reels, shorts, audiograms, captions, slideshows, and batch jobs."
22
  )
23
- with gr.Tab("Render"):
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
24
  render_json = gr.Textbox(
25
  label="Render JSON",
26
  lines=14,
@@ -123,6 +194,11 @@ def create_dashboard() -> gr.Blocks:
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")
@@ -138,6 +214,55 @@ def _submit_render_json(payload: str) -> dict[str, Any]:
138
  return {"job_id": job_id, "status": f"/status/{job_id}", "download": f"/download/{job_id}"}
139
 
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", [])]
@@ -230,6 +355,20 @@ 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
 
 
7
  import gradio as gr
8
  import uvicorn
9
 
10
+ from api import api, job_manager, project_manager, settings
11
  from renderer import RenderEngine
12
  from renderer.core.models import AIReelsRequest
13
  from renderer.scenes import Timeline
14
+ from renderer.studio import capability_catalog
15
  from renderer.templates import apply_creative_style, apply_preset, list_creative_styles, list_platform_profiles, list_templates
16
 
17
 
18
  def create_dashboard() -> gr.Blocks:
19
+ with gr.Blocks(title="Ava2lon Studio AI") as dashboard:
20
  gr.Markdown(
21
+ "# Ava2lon Studio AI\n"
22
+ "CPU-first CapCut-class video automation studio with REST API parity, async jobs, webhooks, and project JSON."
23
  )
24
+ with gr.Tab("Projects"):
25
+ project_name = gr.Textbox(label="Project name", value="Untitled Ava2lon Project")
26
+ project_metadata = gr.Textbox(label="Metadata JSON", lines=5, value=json.dumps({"platform": "tiktok"}, indent=2))
27
+ project_create = gr.Button("Create Project", variant="primary")
28
+ project_list = gr.Button("Refresh Projects")
29
+ project_output = gr.JSON(label="Projects")
30
+ project_create.click(fn=_create_project, inputs=[project_name, project_metadata], outputs=project_output)
31
+ project_list.click(fn=_list_projects, outputs=project_output)
32
+
33
+ with gr.Tab("Assets"):
34
+ gr.Markdown("Use `/upload` or `/assets/upload` for multipart assets, then attach them to a project with `/project/assets/add`.")
35
+ asset_project_id = gr.Textbox(label="Project ID")
36
+ asset_json = gr.Textbox(label="Asset JSON", lines=6, value=json.dumps({"path": "clip.mp4", "kind": "video"}, indent=2))
37
+ asset_button = gr.Button("Attach Asset", variant="primary")
38
+ asset_output = gr.JSON(label="Project")
39
+ asset_button.click(fn=_add_project_asset, inputs=[asset_project_id, asset_json], outputs=asset_output)
40
+
41
+ with gr.Tab("Timeline"):
42
+ timeline_project_id = gr.Textbox(label="Project ID")
43
+ timeline_track_type = gr.Dropdown(choices=["video", "audio", "text", "overlay", "sticker", "subtitle"], value="video", label="Track type")
44
+ timeline_item = gr.Textbox(
45
+ label="Timeline item JSON",
46
+ lines=8,
47
+ value=json.dumps({"media": "clip.mp4", "start": 0, "duration": 5, "caption": "Hook"}, indent=2),
48
+ )
49
+ timeline_add_button = gr.Button("Add To Timeline", variant="primary")
50
+ timeline_output = gr.JSON(label="Project")
51
+ timeline_add_button.click(fn=_timeline_add, inputs=[timeline_project_id, timeline_track_type, timeline_item], outputs=timeline_output)
52
+
53
+ timeline_operation_json = gr.Textbox(
54
+ label="Operation JSON",
55
+ lines=8,
56
+ value=json.dumps({"operation": "split", "item_id": "clip_123", "params": {"offset": 2.5}}, indent=2),
57
+ )
58
+ timeline_operation_button = gr.Button("Apply Operation")
59
+ timeline_operation_button.click(fn=_timeline_operation, inputs=[timeline_project_id, timeline_operation_json], outputs=timeline_output)
60
+
61
+ with gr.Tab("Templates"):
62
+ template_button = gr.Button("Load Template Catalog")
63
+ template_output = gr.JSON(label="Templates")
64
+ template_button.click(fn=lambda: _catalog_section("templates"), outputs=template_output)
65
+
66
+ with gr.Tab("Effects"):
67
+ effect_button = gr.Button("Load Effect Catalog")
68
+ effect_output = gr.JSON(label="Effects")
69
+ effect_button.click(fn=lambda: _catalog_section("effects"), outputs=effect_output)
70
+
71
+ with gr.Tab("Filters"):
72
+ filter_button = gr.Button("Load Filter Catalog")
73
+ filter_output = gr.JSON(label="Filters")
74
+ filter_button.click(fn=lambda: _catalog_section("filters"), outputs=filter_output)
75
+
76
+ with gr.Tab("Captions"):
77
+ caption_text = gr.Textbox(label="Caption source text", lines=6)
78
+ caption_button = gr.Button("Submit Caption Job", variant="primary")
79
+ caption_output = gr.JSON(label="Caption Job")
80
+ caption_button.click(fn=_submit_caption_generation, inputs=caption_text, outputs=caption_output)
81
+
82
+ with gr.Tab("Audio"):
83
+ audio_button = gr.Button("Load Audio Catalog")
84
+ audio_output = gr.JSON(label="Audio")
85
+ audio_button.click(fn=lambda: {"audio": capability_catalog()["audio"], "music": capability_catalog()["music_generator"]}, outputs=audio_output)
86
+
87
+ with gr.Tab("AI Tools"):
88
+ ai_tool = gr.Dropdown(choices=capability_catalog()["ai_editing"], value="auto_viral_score", label="AI tool")
89
+ ai_payload = gr.Textbox(label="AI payload JSON", lines=8, value=json.dumps({"platform": "tiktok", "text": "A strong opening hook"}, indent=2))
90
+ ai_button = gr.Button("Submit AI Tool", variant="primary")
91
+ ai_output = gr.JSON(label="AI Job")
92
+ ai_button.click(fn=_submit_ai_tool, inputs=[ai_tool, ai_payload], outputs=ai_output)
93
+
94
+ with gr.Tab("Rendering"):
95
  render_json = gr.Textbox(
96
  label="Render JSON",
97
  lines=14,
 
194
  publish_output = gr.JSON(label="Publish Job")
195
  publish_button.click(fn=_submit_publish, inputs=[publish_media, publish_title, publish_platforms], outputs=publish_output)
196
 
197
+ with gr.Tab("Settings"):
198
+ settings_button = gr.Button("Load Settings")
199
+ settings_output = gr.JSON(label="Settings")
200
+ settings_button.click(fn=_settings_payload, outputs=settings_output)
201
+
202
  with gr.Tab("Queue Monitor"):
203
  queue_button = gr.Button("Refresh Queue")
204
  queue_output = gr.JSON(label="Queue")
 
214
  return {"job_id": job_id, "status": f"/status/{job_id}", "download": f"/download/{job_id}"}
215
 
216
 
217
+ def _create_project(name: str, metadata_json: str) -> dict[str, Any]:
218
+ metadata = json.loads(metadata_json or "{}")
219
+ return {"project": project_manager.create(name, metadata=metadata)}
220
+
221
+
222
+ def _list_projects() -> dict[str, Any]:
223
+ return {"projects": project_manager.list()}
224
+
225
+
226
+ def _add_project_asset(project_id: str, asset_json: str) -> dict[str, Any]:
227
+ return {"project": project_manager.add_asset(project_id, json.loads(asset_json or "{}"))}
228
+
229
+
230
+ def _timeline_add(project_id: str, track_type: str, item_json: str) -> dict[str, Any]:
231
+ return {"project": project_manager.add_to_timeline(project_id, json.loads(item_json or "{}"), track_type=track_type)}
232
+
233
+
234
+ def _timeline_operation(project_id: str, operation_json: str) -> dict[str, Any]:
235
+ data = json.loads(operation_json or "{}")
236
+ return {
237
+ "project": project_manager.timeline_operation(
238
+ project_id,
239
+ data.get("operation", "drag"),
240
+ item_id=data.get("item_id"),
241
+ params=data.get("params", {}),
242
+ )
243
+ }
244
+
245
+
246
+ def _catalog_section(section: str) -> dict[str, Any]:
247
+ catalog = capability_catalog()
248
+ return {section: catalog.get(section)}
249
+
250
+
251
+ def _submit_caption_generation(text: str) -> dict[str, Any]:
252
+ from renderer.studio import StudioTaskProcessor
253
+
254
+ job_id = job_manager.submit_task(lambda task_id, log: StudioTaskProcessor(settings, log=log).caption_generate({"text": text}, task_id))
255
+ return {"job_id": job_id, "status": f"/status/{job_id}", "download": f"/download/{job_id}"}
256
+
257
+
258
+ def _submit_ai_tool(tool: str, payload: str) -> dict[str, Any]:
259
+ from renderer.studio import StudioTaskProcessor
260
+
261
+ data = json.loads(payload or "{}")
262
+ job_id = job_manager.submit_task(lambda task_id, log: StudioTaskProcessor(settings, log=log).ai_tool(tool, data, task_id))
263
+ return {"job_id": job_id, "status": f"/status/{job_id}", "download": f"/download/{job_id}"}
264
+
265
+
266
  def _submit_batch_json(payload: str) -> dict[str, Any]:
267
  data = json.loads(payload)
268
  requests = [Timeline.request_from_payload(apply_creative_style(apply_preset(job))) for job in data.get("jobs", [])]
 
355
  return job_manager.summary()
356
 
357
 
358
+ def _settings_payload() -> dict[str, Any]:
359
+ return {
360
+ "product": "Ava2lon Studio AI",
361
+ "base_dir": str(settings.base_dir),
362
+ "temp_dir": str(settings.temp_dir),
363
+ "exports_dir": str(settings.exports_dir),
364
+ "storage_dir": str(settings.storage_dir),
365
+ "max_workers": settings.max_workers,
366
+ "whisper_model_size": settings.whisper_model_size,
367
+ "whisper_device": settings.whisper_device,
368
+ "capabilities": capability_catalog()["principles"],
369
+ }
370
+
371
+
372
  app = gr.mount_gradio_app(api, create_dashboard(), path="/dashboard")
373
 
374
 
requirements.txt CHANGED
@@ -1,6 +1,7 @@
1
  fastapi>=0.115.0
2
  uvicorn[standard]>=0.30.0
3
  gradio>=5.0.0
 
4
  pydantic>=2.7.0
5
  python-multipart>=0.0.9
6
  psutil>=5.9.8
 
1
  fastapi>=0.115.0
2
  uvicorn[standard]>=0.30.0
3
  gradio>=5.0.0
4
+ huggingface_hub<1.0
5
  pydantic>=2.7.0
6
  python-multipart>=0.0.9
7
  psutil>=5.9.8