Upload 6 files
Browse files
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
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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,
|
| 143 |
-
return {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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(
|
| 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(
|
| 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(
|
| 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,
|
| 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(
|
| 244 |
per_scene = total_duration / len(payload.assets)
|
| 245 |
-
|
| 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=
|
| 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(
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
|