babaTEEpe commited on
Commit
035f051
·
verified ·
1 Parent(s): 7a62ee7

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +220 -198
app.py CHANGED
@@ -1,198 +1,220 @@
1
- """
2
- AI Story-to-Video Generator — FastAPI Backend
3
- CPU-safe: orchestration only. Heavy models routed to external APIs.
4
- """
5
-
6
- # Load .env FIRST before any other imports that read os.getenv()
7
- from dotenv import load_dotenv
8
- load_dotenv()
9
-
10
- import asyncio
11
- import os
12
- import sys
13
-
14
- # Windows-specific: ensure ProactorEventLoop is used for subprocess support
15
- if sys.platform == 'win32':
16
- asyncio.set_event_loop_policy(asyncio.WindowsProactorEventLoopPolicy())
17
- from contextlib import asynccontextmanager
18
- from fastapi import FastAPI, BackgroundTasks, HTTPException
19
- from fastapi.middleware.cors import CORSMiddleware
20
- from fastapi.staticfiles import StaticFiles
21
- from pydantic import BaseModel, Field
22
- from typing import Optional
23
-
24
- from utils.job_manager import JobManager
25
- from utils.storage import ensure_dirs
26
- from pipeline.script_engine import ScriptEngine
27
- from pipeline.image_engine import ImageEngine
28
- from pipeline.video_engine import VideoEngine
29
- from pipeline.tts_engine import TTSEngine
30
- from pipeline.merge_engine import MergeEngine
31
-
32
- # ---------------------------------------------------------------------------
33
- # Lifespan
34
- # ---------------------------------------------------------------------------
35
- @asynccontextmanager
36
- async def lifespan(app: FastAPI):
37
- ensure_dirs()
38
- yield
39
-
40
- app = FastAPI(
41
- title="AI Story-to-Video Generator",
42
- description="CPU-safe pipeline: orchestrates LLM, image, video, and TTS APIs.",
43
- version="1.0.0",
44
- lifespan=lifespan,
45
- )
46
-
47
- app.add_middleware(
48
- CORSMiddleware,
49
- allow_origins=["*"],
50
- allow_credentials=True,
51
- allow_methods=["*"],
52
- allow_headers=["*"],
53
- )
54
-
55
- # Serve finished videos
56
- os.makedirs("outputs", exist_ok=True)
57
- app.mount("/outputs", StaticFiles(directory="outputs"), name="outputs")
58
-
59
- # ---------------------------------------------------------------------------
60
- # Shared state
61
- # ---------------------------------------------------------------------------
62
- job_manager = JobManager()
63
-
64
- # ---------------------------------------------------------------------------
65
- # Request / Response schemas
66
- # ---------------------------------------------------------------------------
67
- class GenerateRequest(BaseModel):
68
- story: str = Field(..., min_length=10, max_length=4000, description="User story or prompt")
69
- style: str = Field(default="cinematic", description="Visual style (cinematic, anime, watercolor, …)")
70
- duration: int = Field(default=30, ge=10, le=60, description="Target video duration in seconds")
71
- aspect_ratio: str = Field(default="16:9", description="Output aspect ratio: 16:9 or 9:16")
72
-
73
- class GenerateResponse(BaseModel):
74
- job_id: str
75
- message: str
76
-
77
- class StatusResponse(BaseModel):
78
- job_id: str
79
- status: str # queued | running | completed | failed
80
- progress: int # 0-100
81
- stage: str
82
- video_url: Optional[str] = None
83
- script: Optional[dict] = None
84
- error: Optional[str] = None
85
-
86
- # ---------------------------------------------------------------------------
87
- # Background pipeline
88
- # ---------------------------------------------------------------------------
89
- async def run_pipeline(job_id: str, req: GenerateRequest):
90
- """Full async pipeline executed in the background."""
91
- def update(progress: int, stage: str):
92
- job_manager.update(job_id, progress=progress, stage=stage)
93
-
94
- try:
95
- job_manager.update(job_id, status="running", progress=5, stage="Generating screenplay…")
96
-
97
- # STEP 1 — Script
98
- script = await ScriptEngine().generate(
99
- story=req.story,
100
- style=req.style,
101
- duration=req.duration,
102
- )
103
- job_manager.update(job_id, progress=15, stage="Screenplay ready", script=script)
104
-
105
- scenes = script.get("scenes", [])
106
- if not scenes:
107
- raise ValueError("Script engine returned no scenes.")
108
-
109
- # Clamp to 6-8 scenes
110
- scenes = scenes[:8]
111
- script["scenes"] = scenes
112
-
113
- # STEP 2 Character images
114
- update(20, "Generating character references…")
115
- image_engine = ImageEngine()
116
- char_images = await image_engine.generate_characters(
117
- characters=script.get("characters", []),
118
- style=req.style,
119
- )
120
-
121
- # STEP 3 — Storyboard keyframes (parallel)
122
- update(35, "Generating storyboard frames…")
123
- frame_paths = await image_engine.generate_storyboard(
124
- scenes=scenes,
125
- char_images=char_images,
126
- style=req.style,
127
- job_id=job_id,
128
- )
129
-
130
- # STEP 4 — Video clips
131
- update(55, "Animating scenes…")
132
- video_engine = VideoEngine()
133
- clip_paths = await video_engine.animate_scenes(
134
- frame_paths=frame_paths,
135
- scenes=scenes,
136
- job_id=job_id,
137
- )
138
-
139
- # STEP 5 — TTS narration
140
- update(75, "Generating narration audio…")
141
- tts_engine = TTSEngine()
142
- audio_path = await tts_engine.generate(
143
- script=script,
144
- job_id=job_id,
145
- )
146
-
147
- # STEP 6 — Merge
148
- update(88, "Merging final video…")
149
- merge_engine = MergeEngine()
150
- output_path = await merge_engine.merge(
151
- clip_paths=clip_paths,
152
- audio_path=audio_path,
153
- aspect_ratio=req.aspect_ratio,
154
- job_id=job_id,
155
- )
156
-
157
- video_url = f"/outputs/{os.path.basename(output_path)}"
158
- job_manager.update(
159
- job_id,
160
- status="completed",
161
- progress=100,
162
- stage="Done!",
163
- video_url=video_url,
164
- )
165
-
166
- except Exception as exc:
167
- import traceback
168
- tb = traceback.format_exc()
169
- error_msg = str(exc) or tb.split("\n")[-2] if tb else "Unknown error"
170
- print(f"\n❌ PIPELINE ERROR (job {job_id}):\n{tb}")
171
- job_manager.update(job_id, status="failed", stage="Pipeline error", error=error_msg)
172
-
173
- # ---------------------------------------------------------------------------
174
- # Routes
175
- # ---------------------------------------------------------------------------
176
- @app.post("/generate", response_model=GenerateResponse, status_code=202)
177
- async def generate(req: GenerateRequest, background_tasks: BackgroundTasks):
178
- """Start a video generation job. Returns job_id immediately."""
179
- if job_manager.active_count() >= int(os.getenv("MAX_CONCURRENT_JOBS", "2")):
180
- raise HTTPException(status_code=429, detail="Server busy. Try again shortly.")
181
-
182
- job_id = job_manager.create()
183
- background_tasks.add_task(run_pipeline, job_id, req)
184
- return GenerateResponse(job_id=job_id, message="Job queued successfully.")
185
-
186
-
187
- @app.get("/status/{job_id}", response_model=StatusResponse)
188
- async def status(job_id: str):
189
- """Poll job status."""
190
- job = job_manager.get(job_id)
191
- if not job:
192
- raise HTTPException(status_code=404, detail="Job not found.")
193
- return StatusResponse(job_id=job_id, **job)
194
-
195
-
196
- @app.get("/health")
197
- async def health():
198
- return {"status": "ok", "active_jobs": job_manager.active_count()}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ AI Story-to-Video Generator — FastAPI Backend
3
+ CPU-safe: orchestration only. Heavy models routed to external APIs.
4
+ """
5
+
6
+ # Load .env FIRST before any other imports that read os.getenv()
7
+ from dotenv import load_dotenv
8
+ load_dotenv()
9
+
10
+ import asyncio
11
+ import os
12
+ import sys
13
+
14
+ # Windows-specific: ensure ProactorEventLoop is used for subprocess support
15
+ if sys.platform == 'win32':
16
+ asyncio.set_event_loop_policy(asyncio.WindowsProactorEventLoopPolicy())
17
+ from contextlib import asynccontextmanager
18
+ from fastapi import FastAPI, BackgroundTasks, HTTPException
19
+ from fastapi.middleware.cors import CORSMiddleware
20
+ from fastapi.staticfiles import StaticFiles
21
+ from pydantic import BaseModel, Field
22
+ from typing import Optional
23
+
24
+ from utils.job_manager import JobManager
25
+ from utils.storage import ensure_dirs
26
+ from pipeline.script_engine import ScriptEngine
27
+ from pipeline.image_engine import ImageEngine
28
+ from pipeline.video_engine import VideoEngine
29
+ from pipeline.tts_engine import TTSEngine
30
+ from pipeline.merge_engine import MergeEngine
31
+
32
+ # ---------------------------------------------------------------------------
33
+ # Lifespan
34
+ # ---------------------------------------------------------------------------
35
+ async def cleanup_task():
36
+ """Background task to delete old temp files and outputs every hour."""
37
+ import shutil
38
+ import time
39
+ while True:
40
+ try:
41
+ now = time.time()
42
+ # Clean /temp and /outputs older than 2 hours
43
+ for folder in ["temp", "outputs"]:
44
+ if os.path.exists(folder):
45
+ for item in os.listdir(folder):
46
+ path = os.path.join(folder, item)
47
+ if os.path.getmtime(path) < now - (2 * 3600):
48
+ if os.path.isdir(path):
49
+ shutil.rmtree(path)
50
+ else:
51
+ os.remove(path)
52
+ except Exception as e:
53
+ print(f"🧹 Cleanup error: {e}")
54
+ await asyncio.sleep(3600) # Sleep 1 hour
55
+
56
+ @asynccontextmanager
57
+ async def lifespan(app: FastAPI):
58
+ ensure_dirs()
59
+ asyncio.create_task(cleanup_task())
60
+ yield
61
+
62
+ app = FastAPI(
63
+ title="AI Story-to-Video Generator",
64
+ description="CPU-safe pipeline: orchestrates LLM, image, video, and TTS APIs.",
65
+ version="1.0.0",
66
+ lifespan=lifespan,
67
+ )
68
+
69
+ app.add_middleware(
70
+ CORSMiddleware,
71
+ allow_origins=["*"],
72
+ allow_credentials=True,
73
+ allow_methods=["*"],
74
+ allow_headers=["*"],
75
+ )
76
+
77
+ # Serve finished videos
78
+ os.makedirs("outputs", exist_ok=True)
79
+ app.mount("/outputs", StaticFiles(directory="outputs"), name="outputs")
80
+
81
+ # ---------------------------------------------------------------------------
82
+ # Shared state
83
+ # ---------------------------------------------------------------------------
84
+ job_manager = JobManager()
85
+
86
+ # ---------------------------------------------------------------------------
87
+ # Request / Response schemas
88
+ # ---------------------------------------------------------------------------
89
+ class GenerateRequest(BaseModel):
90
+ story: str = Field(..., min_length=10, max_length=4000, description="User story or prompt")
91
+ style: str = Field(default="cinematic", description="Visual style (cinematic, anime, watercolor, …)")
92
+ duration: int = Field(default=30, ge=10, le=60, description="Target video duration in seconds")
93
+ aspect_ratio: str = Field(default="16:9", description="Output aspect ratio: 16:9 or 9:16")
94
+
95
+ class GenerateResponse(BaseModel):
96
+ job_id: str
97
+ message: str
98
+
99
+ class StatusResponse(BaseModel):
100
+ job_id: str
101
+ status: str # queued | running | completed | failed
102
+ progress: int # 0-100
103
+ stage: str
104
+ video_url: Optional[str] = None
105
+ script: Optional[dict] = None
106
+ error: Optional[str] = None
107
+
108
+ # ---------------------------------------------------------------------------
109
+ # Background pipeline
110
+ # ---------------------------------------------------------------------------
111
+ async def run_pipeline(job_id: str, req: GenerateRequest):
112
+ """Full async pipeline executed in the background."""
113
+ def update(progress: int, stage: str):
114
+ job_manager.update(job_id, progress=progress, stage=stage)
115
+
116
+ try:
117
+ job_manager.update(job_id, status="running", progress=5, stage="Generating screenplay…")
118
+
119
+ # STEP 1 — Script
120
+ script = await ScriptEngine().generate(
121
+ story=req.story,
122
+ style=req.style,
123
+ duration=req.duration,
124
+ )
125
+ job_manager.update(job_id, progress=15, stage="Screenplay ready", script=script)
126
+
127
+ scenes = script.get("scenes", [])
128
+ if not scenes:
129
+ raise ValueError("Script engine returned no scenes.")
130
+
131
+ # Clamp to 6-8 scenes
132
+ scenes = scenes[:8]
133
+ script["scenes"] = scenes
134
+
135
+ # STEP 2 — Character images
136
+ update(20, "Generating character references…")
137
+ image_engine = ImageEngine()
138
+ char_images = await image_engine.generate_characters(
139
+ characters=script.get("characters", []),
140
+ style=req.style,
141
+ )
142
+
143
+ # STEP 3 — Storyboard keyframes (parallel)
144
+ update(35, "Generating storyboard frames…")
145
+ frame_paths = await image_engine.generate_storyboard(
146
+ scenes=scenes,
147
+ char_images=char_images,
148
+ style=req.style,
149
+ job_id=job_id,
150
+ )
151
+
152
+ # STEP 4 — Video clips
153
+ update(55, "Animating scenes…")
154
+ video_engine = VideoEngine()
155
+ clip_paths = await video_engine.animate_scenes(
156
+ frame_paths=frame_paths,
157
+ scenes=scenes,
158
+ job_id=job_id,
159
+ )
160
+
161
+ # STEP 5 — TTS narration
162
+ update(75, "Generating narration audio…")
163
+ tts_engine = TTSEngine()
164
+ audio_path = await tts_engine.generate(
165
+ script=script,
166
+ job_id=job_id,
167
+ )
168
+
169
+ # STEP 6 Merge
170
+ update(88, "Merging final video…")
171
+ merge_engine = MergeEngine()
172
+ output_path = await merge_engine.merge(
173
+ clip_paths=clip_paths,
174
+ audio_path=audio_path,
175
+ aspect_ratio=req.aspect_ratio,
176
+ job_id=job_id,
177
+ )
178
+
179
+ video_url = f"/outputs/{os.path.basename(output_path)}"
180
+ job_manager.update(
181
+ job_id,
182
+ status="completed",
183
+ progress=100,
184
+ stage="Done!",
185
+ video_url=video_url,
186
+ )
187
+
188
+ except Exception as exc:
189
+ import traceback
190
+ tb = traceback.format_exc()
191
+ error_msg = str(exc) or tb.split("\n")[-2] if tb else "Unknown error"
192
+ print(f"\n❌ PIPELINE ERROR (job {job_id}):\n{tb}")
193
+ job_manager.update(job_id, status="failed", stage="Pipeline error", error=error_msg)
194
+
195
+ # ---------------------------------------------------------------------------
196
+ # Routes
197
+ # ---------------------------------------------------------------------------
198
+ @app.post("/generate", response_model=GenerateResponse, status_code=202)
199
+ async def generate(req: GenerateRequest, background_tasks: BackgroundTasks):
200
+ """Start a video generation job. Returns job_id immediately."""
201
+ if job_manager.active_count() >= int(os.getenv("MAX_CONCURRENT_JOBS", "2")):
202
+ raise HTTPException(status_code=429, detail="Server busy. Try again shortly.")
203
+
204
+ job_id = job_manager.create()
205
+ background_tasks.add_task(run_pipeline, job_id, req)
206
+ return GenerateResponse(job_id=job_id, message="Job queued successfully.")
207
+
208
+
209
+ @app.get("/status/{job_id}", response_model=StatusResponse)
210
+ async def status(job_id: str):
211
+ """Poll job status."""
212
+ job = job_manager.get(job_id)
213
+ if not job:
214
+ raise HTTPException(status_code=404, detail="Job not found.")
215
+ return StatusResponse(job_id=job_id, **job)
216
+
217
+
218
+ @app.get("/health")
219
+ async def health():
220
+ return {"status": "ok", "active_jobs": job_manager.active_count()}