Ava2lon commited on
Commit
6268049
·
verified ·
1 Parent(s): 3e3d7b1

Upload 5 files

Browse files
Files changed (5) hide show
  1. Dockerfile +31 -0
  2. README.md +199 -0
  3. api.py +124 -0
  4. app.py +118 -0
  5. requirements.txt +9 -0
Dockerfile ADDED
@@ -0,0 +1,31 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ FROM python:3.11-slim
2
+
3
+ ENV PYTHONUNBUFFERED=1 \
4
+ TEMP_DIR=/app/temp \
5
+ EXPORTS_DIR=/app/exports \
6
+ JOBS_DIR=/app/jobs \
7
+ BASYX_BASE_DIR=/app \
8
+ MAX_RENDER_WORKERS=1 \
9
+ FFMPEG_TIMEOUT_SECONDS=900 \
10
+ MAX_RETRIES=3
11
+
12
+ RUN apt-get update && apt-get install -y --no-install-recommends \
13
+ ffmpeg \
14
+ fonts-dejavu-core \
15
+ libmagic1 \
16
+ ca-certificates \
17
+ && rm -rf /var/lib/apt/lists/*
18
+
19
+ WORKDIR /app
20
+
21
+ COPY requirements.txt .
22
+ RUN pip install --no-cache-dir -r requirements.txt
23
+
24
+ COPY . .
25
+
26
+ RUN mkdir -p /app/temp /app/exports /app/jobs \
27
+ && chmod -R 777 /app/temp /app/exports /app/jobs
28
+
29
+ EXPOSE 7860
30
+
31
+ CMD ["python", "app.py"]
README.md ADDED
@@ -0,0 +1,199 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ title: Basyx FFmpeg Rendering Engine
3
+ emoji: 🎬
4
+ colorFrom: green
5
+ colorTo: yellow
6
+ sdk: docker
7
+ app_port: 7860
8
+ pinned: false
9
+ license: mit
10
+ ---
11
+
12
+ # Basyx FFmpeg Rendering Engine
13
+
14
+ Basyx FFmpeg is a CPU-first rendering backend for short-form video automation. It is designed for Hugging Face Docker Spaces running on 2-8 CPU cores with 8-16 GB RAM and no GPU. The engine uses FFmpeg and FFprobe subprocess pipelines and never loads complete videos into Python memory.
15
+
16
+ ## Architecture
17
+
18
+ ```mermaid
19
+ flowchart TD
20
+ API["FastAPI REST API"] --> Jobs["Job Manager"]
21
+ Dashboard["Gradio Operator Dashboard"] --> Jobs
22
+ Jobs --> Engine["Render Engine"]
23
+ Engine --> Assets["Asset Probe + Metadata Cache"]
24
+ Engine --> Normalize["Normalization"]
25
+ Engine --> Scenes["Scene Timeline"]
26
+ Engine --> Subtitles["SRT / ASS Subtitle Engine"]
27
+ Engine --> Transitions["Transition Filter Builder"]
28
+ Engine --> Audio["Voiceover + Ducking Mixer"]
29
+ Engine --> Exports["Export Manager"]
30
+ Normalize --> FFmpeg["FFmpeg / FFprobe"]
31
+ Scenes --> FFmpeg
32
+ Subtitles --> FFmpeg
33
+ Transitions --> FFmpeg
34
+ Audio --> FFmpeg
35
+ ```
36
+
37
+ Package layout:
38
+
39
+ ```text
40
+ renderer/
41
+ core/ settings, models, orchestration
42
+ ffmpeg/ command builder, runner, probing, normalization
43
+ subtitles/ SRT and ASS generation
44
+ transitions/ xfade graph generation
45
+ audio/ voiceover and music ducking
46
+ scenes/ timeline validation
47
+ templates/ caption templates
48
+ exports/ final deliverables
49
+ jobs/ durable job records and retries
50
+ ```
51
+
52
+ ## REST API
53
+
54
+ All API endpoints are served at the Space root.
55
+
56
+ ### `POST /render`
57
+
58
+ Submit one render job.
59
+
60
+ ```json
61
+ {
62
+ "template": "tiktok_classic",
63
+ "output_name": "campaign_clip.mp4",
64
+ "voiceover": "/app/uploads/voiceover.wav",
65
+ "background_music": "/app/uploads/music.mp3",
66
+ "subtitle_format": "ass",
67
+ "normalize": true,
68
+ "scenes": [
69
+ {
70
+ "start": 0,
71
+ "duration": 5,
72
+ "media": "/app/uploads/scene1.mp4",
73
+ "caption": "Launch faster with automated rendering",
74
+ "transition": "fade"
75
+ }
76
+ ]
77
+ }
78
+ ```
79
+
80
+ Response:
81
+
82
+ ```json
83
+ {
84
+ "job_id": "job_abc123",
85
+ "status_url": "/status/job_abc123",
86
+ "download_url": "/download/job_abc123"
87
+ }
88
+ ```
89
+
90
+ ### `POST /render/ai-reels`
91
+
92
+ Submit a single-call AI Reels job using an existing voiceover and asset list. TTS is intentionally provider-pluggable in v1; the production path requires a supplied voiceover.
93
+
94
+ ```json
95
+ {
96
+ "script": "Launch faster with automated rendering.",
97
+ "voiceover": "/app/uploads/voiceover.wav",
98
+ "assets": ["/app/uploads/scene1.jpg", "/app/uploads/scene2.mp4"],
99
+ "template": "youtube_shorts",
100
+ "output_name": "ai_reel.mp4"
101
+ }
102
+ ```
103
+
104
+ ### `POST /render/batch`
105
+
106
+ Submit multiple render jobs. The worker pool defaults to one active render to avoid RAM exhaustion.
107
+
108
+ ```json
109
+ {
110
+ "jobs": [
111
+ {
112
+ "template": "modern_minimal",
113
+ "output_name": "clip_a.mp4",
114
+ "scenes": [{"start": 0, "duration": 3, "media": "/app/uploads/a.mp4"}]
115
+ }
116
+ ]
117
+ }
118
+ ```
119
+
120
+ ### `GET /status/{job_id}`
121
+
122
+ Returns job state, logs, FFmpeg commands, metrics, output path, and failure reason.
123
+
124
+ States: `PENDING`, `RUNNING`, `FAILED`, `COMPLETED`.
125
+
126
+ ### `GET /download/{job_id}`
127
+
128
+ Returns the completed MP4 deliverable. If the job is still running, the endpoint returns `409`.
129
+
130
+ ### `POST /inspect`
131
+
132
+ Probe one asset path and return MIME type, duration, codecs, bitrate, resolution, FPS, stream list, and cache metadata.
133
+
134
+ ## Operator Dashboard
135
+
136
+ The dashboard is available at `/dashboard`. It provides production operator tools for render submission, batch submission, AI Reels submission, job status, logs, downloads, and asset inspection.
137
+
138
+ ## Caption Templates
139
+
140
+ Built-in templates:
141
+
142
+ - `tiktok_classic`
143
+ - `tiktok_zoom`
144
+ - `alex_hormozi`
145
+ - `modern_minimal`
146
+ - `youtube_shorts`
147
+ - `podcast_style`
148
+ - `news_style`
149
+
150
+ Templates are selected per request and do not require code changes.
151
+
152
+ ## CPU And RAM Tuning
153
+
154
+ - Keep `MAX_RENDER_WORKERS=1` for 8 GB RAM deployments.
155
+ - Use `OUTPUT_PRESET=veryfast` or `ultrafast` for faster CPU rendering.
156
+ - Use `OUTPUT_CRF=23-28` to balance quality and file size.
157
+ - Keep source assets near the target duration to reduce normalization work.
158
+ - Prefer pre-trimmed voiceovers and assets for batch workloads.
159
+
160
+ ## Reliability
161
+
162
+ - FFmpeg subprocesses are killed after `FFMPEG_TIMEOUT_SECONDS`.
163
+ - Jobs retry up to `MAX_RETRIES`.
164
+ - Intermediate files are created under `TEMP_DIR` and removed after export.
165
+ - Job records retain command history, logs, render time, output size, and failure reasons.
166
+
167
+ ## Docker Deployment
168
+
169
+ Build locally:
170
+
171
+ ```bash
172
+ docker build -t basyx-ffmpeg .
173
+ docker run --rm -p 7860:7860 basyx-ffmpeg
174
+ ```
175
+
176
+ Open:
177
+
178
+ - API: `http://localhost:7860/health`
179
+ - Dashboard: `http://localhost:7860/dashboard`
180
+
181
+ ## Hugging Face Spaces Deployment
182
+
183
+ Create a Docker Space, push this repository, and keep the README metadata above. The container listens on port `7860`, and the Space exposes the FastAPI service plus the dashboard.
184
+
185
+ ## Testing
186
+
187
+ Run:
188
+
189
+ ```bash
190
+ pytest --cov=renderer --cov-report=term-missing
191
+ ```
192
+
193
+ Integration tests use mocked FFmpeg/FFprobe where possible and small generated media where necessary.
194
+
195
+ ## Known v1 Limits
196
+
197
+ - Bundled TTS is not included. The AI Reels endpoint requires a supplied voiceover and keeps narration generation behind a provider interface for future integration.
198
+ - Advanced caption animation is implemented through ASS effects and FFmpeg-compatible filter behavior, not GPU animation layers.
199
+ - Batch rendering is intentionally sequential by default for constrained CPU/RAM Spaces.
api.py ADDED
@@ -0,0 +1,124 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ from pathlib import Path
4
+ from typing import Any
5
+
6
+ from fastapi import FastAPI, HTTPException
7
+ from fastapi.responses import FileResponse
8
+ from pydantic import BaseModel, Field
9
+
10
+ from renderer.core.config import Settings
11
+ from renderer.core.models import AIReelsRequest, RenderRequest, Scene
12
+ from renderer.jobs import JobManager
13
+ from renderer.scenes import Timeline
14
+
15
+ settings = Settings()
16
+ settings.ensure_dirs()
17
+ job_manager = JobManager(settings)
18
+ api = FastAPI(title="Basyx FFmpeg Rendering Engine", version="1.0.0")
19
+
20
+
21
+ class ScenePayload(BaseModel):
22
+ start: float = Field(ge=0)
23
+ duration: float = Field(gt=0)
24
+ media: str
25
+ caption: str = ""
26
+ transition: str = "fade"
27
+ background: str = "blur"
28
+
29
+
30
+ class RenderPayload(BaseModel):
31
+ scenes: list[ScenePayload]
32
+ template: str = "tiktok_classic"
33
+ output_name: str = "render.mp4"
34
+ voiceover: str | None = None
35
+ background_music: str | None = None
36
+ subtitle_format: str = "ass"
37
+ normalize: bool = True
38
+ metadata: dict[str, Any] = Field(default_factory=dict)
39
+
40
+
41
+ class AIReelsPayload(BaseModel):
42
+ script: str
43
+ voiceover: str
44
+ assets: list[str]
45
+ template: str = "tiktok_classic"
46
+ output_name: str = "ai_reel.mp4"
47
+ background_music: str | None = None
48
+
49
+
50
+ class BatchPayload(BaseModel):
51
+ jobs: list[RenderPayload]
52
+
53
+
54
+ @api.get("/health")
55
+ def health() -> dict[str, str]:
56
+ return {"status": "ok"}
57
+
58
+
59
+ @api.post("/render")
60
+ def render(payload: RenderPayload | AIReelsPayload) -> dict[str, str]:
61
+ if isinstance(payload, AIReelsPayload):
62
+ job_id = job_manager.submit_ai_reels(AIReelsRequest(**payload.model_dump()))
63
+ else:
64
+ job_id = job_manager.submit_render(_render_request(payload))
65
+ return {"job_id": job_id, "status_url": f"/status/{job_id}", "download_url": f"/download/{job_id}"}
66
+
67
+
68
+ @api.post("/render/ai-reels")
69
+ def render_ai_reels(payload: AIReelsPayload) -> dict[str, str]:
70
+ job_id = job_manager.submit_ai_reels(AIReelsRequest(**payload.model_dump()))
71
+ return {"job_id": job_id, "status_url": f"/status/{job_id}", "download_url": f"/download/{job_id}"}
72
+
73
+
74
+ @api.post("/render/batch")
75
+ def render_batch(payload: BatchPayload) -> dict[str, list[str]]:
76
+ job_ids = job_manager.submit_batch([_render_request(job) for job in payload.jobs])
77
+ return {"job_ids": job_ids}
78
+
79
+
80
+ @api.get("/status/{job_id}")
81
+ def status(job_id: str) -> dict:
82
+ try:
83
+ return job_manager.get(job_id).__dict__
84
+ except KeyError as exc:
85
+ raise HTTPException(status_code=404, detail="Job not found") from exc
86
+
87
+
88
+ @api.get("/download/{job_id}")
89
+ def download(job_id: str) -> FileResponse:
90
+ try:
91
+ record = job_manager.get(job_id)
92
+ except KeyError as exc:
93
+ raise HTTPException(status_code=404, detail="Job not found") from exc
94
+ if record.state != "COMPLETED" or not record.output_path:
95
+ raise HTTPException(status_code=409, detail=f"Job is {record.state}")
96
+ path = Path(record.output_path)
97
+ if not path.exists():
98
+ raise HTTPException(status_code=404, detail="Output file is missing")
99
+ return FileResponse(path, media_type="video/mp4", filename=path.name)
100
+
101
+
102
+ @api.post("/inspect")
103
+ def inspect_asset(path: str) -> dict:
104
+ from renderer import RenderEngine
105
+
106
+ try:
107
+ return RenderEngine(settings).inspect_asset(path)
108
+ except Exception as exc:
109
+ raise HTTPException(status_code=400, detail=str(exc)) from exc
110
+
111
+
112
+ def _render_request(payload: RenderPayload) -> RenderRequest:
113
+ request = RenderRequest(
114
+ scenes=[Scene(**scene.model_dump()) for scene in payload.scenes],
115
+ template=payload.template,
116
+ output_name=payload.output_name,
117
+ voiceover=payload.voiceover,
118
+ background_music=payload.background_music,
119
+ subtitle_format=payload.subtitle_format, # type: ignore[arg-type]
120
+ normalize=payload.normalize,
121
+ metadata=payload.metadata,
122
+ )
123
+ Timeline(request.scenes)
124
+ return request
app.py ADDED
@@ -0,0 +1,118 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ from pathlib import Path
5
+ from typing import Any
6
+
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 list_templates
15
+
16
+
17
+ def create_dashboard() -> gr.Blocks:
18
+ with gr.Blocks(title="Basyx FFmpeg Rendering Engine") as demo:
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,
27
+ value="",
28
+ placeholder="Paste a production render request JSON object with absolute or uploaded asset paths.",
29
+ )
30
+ render_button = gr.Button("Submit Render", variant="primary")
31
+ render_output = gr.JSON(label="Submission")
32
+ render_button.click(fn=_submit_render_json, inputs=render_json, outputs=render_output)
33
+
34
+ with gr.Tab("AI Reels"):
35
+ script = gr.Textbox(label="Script", lines=6)
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
+ ai_button = gr.Button("Submit AI Reel", variant="primary")
40
+ ai_output = gr.JSON(label="Submission")
41
+ ai_button.click(fn=_submit_ai_reel, inputs=[script, voiceover, assets, template], outputs=ai_output)
42
+
43
+ with gr.Tab("Batch Render"):
44
+ batch_json = gr.Textbox(label="Batch JSON", lines=14, value=json.dumps({"jobs": []}, indent=2))
45
+ batch_button = gr.Button("Submit Batch", variant="primary")
46
+ batch_output = gr.JSON(label="Batch Submission")
47
+ batch_button.click(fn=_submit_batch_json, inputs=batch_json, outputs=batch_output)
48
+
49
+ with gr.Tab("Job Status"):
50
+ status_job_id = gr.Textbox(label="Job ID")
51
+ status_button = gr.Button("Refresh")
52
+ status_output = gr.JSON(label="Status")
53
+ status_button.click(fn=_job_status, inputs=status_job_id, outputs=status_output)
54
+
55
+ with gr.Tab("Logs"):
56
+ logs_job_id = gr.Textbox(label="Job ID")
57
+ logs_button = gr.Button("Load Logs")
58
+ logs_output = gr.Textbox(label="Logs", lines=20)
59
+ logs_button.click(fn=_job_logs, inputs=logs_job_id, outputs=logs_output)
60
+
61
+ with gr.Tab("Downloads"):
62
+ download_job_id = gr.Textbox(label="Job ID")
63
+ download_button = gr.Button("Get Output")
64
+ download_output = gr.File(label="Rendered Video")
65
+ download_button.click(fn=_download_path, inputs=download_job_id, outputs=download_output)
66
+
67
+ with gr.Tab("Asset Inspector"):
68
+ asset_path = gr.Textbox(label="Asset path")
69
+ inspect_button = gr.Button("Inspect")
70
+ inspect_output = gr.JSON(label="Metadata")
71
+ inspect_button.click(fn=_inspect_asset, inputs=asset_path, outputs=inspect_output)
72
+
73
+ return demo
74
+
75
+
76
+ def _submit_render_json(payload: str) -> dict[str, Any]:
77
+ data = json.loads(payload)
78
+ request = Timeline.request_from_payload(data)
79
+ job_id = job_manager.submit_render(request)
80
+ return {"job_id": job_id, "status": f"/status/{job_id}", "download": f"/download/{job_id}"}
81
+
82
+
83
+ def _submit_batch_json(payload: str) -> dict[str, Any]:
84
+ data = json.loads(payload)
85
+ requests = [Timeline.request_from_payload(job) for job in data.get("jobs", [])]
86
+ return {"job_ids": job_manager.submit_batch(requests)}
87
+
88
+
89
+ def _submit_ai_reel(script: str, voiceover: str, assets: list[str], template: str) -> dict[str, Any]:
90
+ request = AIReelsRequest(script=script, voiceover=voiceover, assets=assets or [], template=template)
91
+ job_id = job_manager.submit_ai_reels(request)
92
+ return {"job_id": job_id, "status": f"/status/{job_id}", "download": f"/download/{job_id}"}
93
+
94
+
95
+ def _job_status(job_id: str) -> dict[str, Any]:
96
+ return job_manager.get(job_id).__dict__
97
+
98
+
99
+ def _job_logs(job_id: str) -> str:
100
+ return "\n\n".join(job_manager.get(job_id).logs)
101
+
102
+
103
+ def _download_path(job_id: str) -> str | None:
104
+ record = job_manager.get(job_id)
105
+ if record.state != "COMPLETED":
106
+ return None
107
+ return record.output_path
108
+
109
+
110
+ def _inspect_asset(path: str) -> dict[str, Any]:
111
+ return RenderEngine(settings).inspect_asset(path)
112
+
113
+
114
+ app = gr.mount_gradio_app(api, create_dashboard(), path="/dashboard")
115
+
116
+
117
+ if __name__ == "__main__":
118
+ uvicorn.run(app, host="0.0.0.0", port=7860)
requirements.txt ADDED
@@ -0,0 +1,9 @@
 
 
 
 
 
 
 
 
 
 
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
7
+ pytest>=8.2.0
8
+ pytest-cov>=5.0.0
9
+ httpx>=0.27.0