ZENLLC commited on
Commit
82ba512
·
verified ·
1 Parent(s): 7ed3083

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +96 -64
app.py CHANGED
@@ -1,25 +1,24 @@
1
- import os
2
- import time
3
- import base64
4
- import mimetypes
5
- import requests
6
  from dataclasses import dataclass
7
  from typing import Optional, Tuple, Dict, Any
8
 
9
  import gradio as gr
10
- from tenacity import retry, stop_after_attempt, wait_exponential, retry_if_exception_type
11
  from dotenv import load_dotenv
12
- from openai import OpenAI
13
- from openai import RateLimitError, APIConnectionError, APIStatusError
 
 
 
 
 
 
 
14
 
15
- # ---- Env (optional) ----
16
- # You can still set OPENAI_API_KEY as a Space Secret; the UI key overrides it per-run.
17
  load_dotenv()
18
- ENV_FALLBACK_KEY = os.getenv("OPENAI_API_KEY") # optional fallback
19
 
20
- # ---- Constants / Validation ----
21
- ALLOWED_MODELS = ["sora-2", "sora-2-pro", "sora"] # sora kept as legacy/optional
22
- ALLOWED_SIZES = ["1280x720", "720x1280", "1792x1024", "1024x1792", "1920x1080", "1080x1920"]
23
 
24
  @dataclass
25
  class JobStatus:
@@ -28,6 +27,14 @@ class JobStatus:
28
  output_url: Optional[str] = None
29
  output_b64: Optional[str] = None
30
 
 
 
 
 
 
 
 
 
31
  def _sanitize_prompt(p: str) -> str:
32
  p = (p or "").strip()
33
  if not p:
@@ -37,17 +44,13 @@ def _sanitize_prompt(p: str) -> str:
37
  return p
38
 
39
  def _validate_duration(d: int) -> int:
40
- try:
41
- d = int(d)
42
- except Exception:
43
- d = 10
44
  return max(1, min(d, 30))
45
 
46
  def _validate_guidance(g: float) -> float:
47
- try:
48
- g = float(g)
49
- except Exception:
50
- g = 7.5
51
  return max(0.0, min(g, 20.0))
52
 
53
  def _validate_model(m: str) -> str:
@@ -56,24 +59,18 @@ def _validate_model(m: str) -> str:
56
  def _validate_size(s: str) -> str:
57
  return s if s in ALLOWED_SIZES else "1280x720"
58
 
59
- def _file_to_b64(path: str) -> str:
60
- with open(path, "rb") as f:
61
- return base64.b64encode(f.read()).decode("utf-8")
62
-
63
- def _safe_video_path() -> str:
64
- return "/tmp/sora_output.mp4"
65
-
66
- # ---- OpenAI client factories (per-request) ----
67
  def _make_client(user_key: Optional[str]) -> OpenAI:
68
- key = (user_key or "").strip() or (ENV_FALLBACK_KEY or "").strip()
 
 
69
  if not key:
70
- # Dummy client will fail predictably with friendly message
71
  raise ValueError("Missing API key. Paste a valid OpenAI API key.")
72
  return OpenAI(api_key=key)
73
 
74
- # ---- Robust network helpers ----
 
75
  @retry(
76
- retry=retry_if_exception_type((APIConnectionError, RateLimitError, APIStatusError, requests.RequestException)),
77
  wait=wait_exponential(multiplier=1, min=1, max=10),
78
  stop=stop_after_attempt(5),
79
  reraise=True,
@@ -82,34 +79,54 @@ def _download_stream(url: str, out_path: str) -> str:
82
  with requests.get(url, stream=True, timeout=60) as r:
83
  r.raise_for_status()
84
  with open(out_path, "wb") as f:
85
- for chunk in r.iter_content(chunk_size=1024 * 256):
86
  if chunk:
87
  f.write(chunk)
88
  return out_path
89
 
 
 
 
90
  @retry(
91
- retry=retry_if_exception_type((APIConnectionError, RateLimitError, APIStatusError)),
92
  wait=wait_exponential(multiplier=1, min=1, max=8),
93
  stop=stop_after_attempt(5),
94
  reraise=True,
95
  )
96
  def _videos_generate(client: OpenAI, **kwargs) -> Any:
97
- return client.videos.generate(**kwargs)
 
 
 
 
 
 
 
 
 
 
 
 
 
98
 
99
  @retry(
100
- retry=retry_if_exception_type((APIConnectionError, RateLimitError, APIStatusError)),
101
  wait=wait_exponential(multiplier=1, min=1, max=8),
102
- stop=stop_after_attempt(60), # generous; API jobs can be long
103
  reraise=True,
104
  )
105
  def _videos_retrieve(client: OpenAI, job_id: str) -> Any:
106
- return client.videos.retrieve(job_id)
 
 
 
 
 
 
107
 
108
  def _extract_status(resp: Any) -> JobStatus:
109
  status = getattr(resp, "status", None) or getattr(resp, "state", None) or "unknown"
110
- err = None
111
- out_url = None
112
- out_b64 = None
113
 
114
  output = getattr(resp, "output", None) or getattr(resp, "result", None)
115
  if output:
@@ -118,10 +135,8 @@ def _extract_status(resp: Any) -> JobStatus:
118
  artifacts = getattr(output, "artifacts", None)
119
  if artifacts and isinstance(artifacts, list):
120
  for a in artifacts:
121
- if not out_b64:
122
- out_b64 = getattr(a, "b64_mp4", None) or getattr(a, "b64_video", None)
123
- if not out_url:
124
- out_url = getattr(a, "url", None)
125
 
126
  err_obj = getattr(resp, "error", None)
127
  if err_obj:
@@ -129,7 +144,7 @@ def _extract_status(resp: Any) -> JobStatus:
129
 
130
  return JobStatus(status=status, error=err, output_url=out_url, output_b64=out_b64)
131
 
132
- # ---- Core generation ----
133
  def generate_video(api_key: str,
134
  prompt: str,
135
  model: str,
@@ -138,11 +153,18 @@ def generate_video(api_key: str,
138
  seed: int,
139
  audio: str,
140
  guidance: float,
141
- init_image: Optional[str]) -> Tuple[Optional[str], str]:
142
-
 
 
 
 
143
  try:
144
  client = _make_client(api_key)
 
 
145
 
 
146
  prompt = _sanitize_prompt(prompt)
147
  model = _validate_model(model)
148
  duration = _validate_duration(duration)
@@ -168,13 +190,16 @@ def generate_video(api_key: str,
168
  if init_image:
169
  req["image"] = {"b64": _file_to_b64(init_image)}
170
 
 
171
  job = _videos_generate(client, **req)
172
  job_id = getattr(job, "id", None) or getattr(job, "job_id", None)
173
  if not job_id:
174
  return None, "Could not get a job id from the API response."
175
 
176
  start = time.time()
 
177
  while True:
 
178
  status_obj = _videos_retrieve(client, job_id)
179
  js = _extract_status(status_obj)
180
 
@@ -190,7 +215,7 @@ def generate_video(api_key: str,
190
  return out_path, f"Downloaded from URL. Done with {model}."
191
  except Exception as dl_err:
192
  return js.output_url, f"Ready (URL) — local download failed: {dl_err}"
193
- return None, "Job succeeded but no payload was returned. Try a shorter duration or different size."
194
 
195
  if js.status in ("failed", "error", "canceled", "cancelled"):
196
  detail = f"Status: {js.status}."
@@ -198,23 +223,29 @@ def generate_video(api_key: str,
198
  detail += f" Error: {js.error}"
199
  return None, detail
200
 
201
- if time.time() - start > 1200: # 20 min safety timeout
202
- return None, "Timed out waiting for the video. Try a shorter duration."
 
 
 
 
203
 
204
  time.sleep(2)
205
 
206
- except (APIConnectionError, RateLimitError, APIStatusError) as oe:
207
  return None, f"OpenAI API issue: {oe}. Retried with backoff; try shorter duration/resolution."
208
  except Exception as e:
209
- return None, f"Error: {e}"
 
 
210
 
211
- # ---- UI ----
212
  def build_ui():
213
  with gr.Blocks(title="ZEN — Sora / Sora-2 / Sora-2-Pro", theme=gr.themes.Soft()) as demo:
214
  gr.Markdown("## ZEN — Sora / Sora-2 / Sora-2-Pro (OpenAI Videos API)")
215
  gr.Markdown(
216
- "Paste an OpenAI API key (never stored). Provide a detailed prompt. "
217
- "This app retries automatically and never crashes the UI."
218
  )
219
 
220
  with gr.Row():
@@ -226,7 +257,7 @@ def build_ui():
226
  duration = gr.Slider(1, 30, value=10, step=1, label="Duration (seconds)")
227
  seed = gr.Number(value=0, precision=0, label="Seed (0 = random)")
228
  guidance = gr.Slider(0.0, 20.0, value=7.5, step=0.5, label="Guidance")
229
- audio = gr.Dropdown(["on", "off"], value="on", label="Audio")
230
 
231
  prompt = gr.Textbox(
232
  label="Prompt",
@@ -237,17 +268,18 @@ def build_ui():
237
 
238
  go = gr.Button("Generate", variant="primary")
239
  video = gr.Video(label="Result", autoplay=True)
240
- status = gr.Textbox(label="Status", interactive=False)
241
 
242
  go.click(
243
  fn=generate_video,
244
  inputs=[api_key, prompt, model, duration, size, seed, audio, guidance, init_image],
245
  outputs=[video, status],
246
- queue=True,
247
- concurrency_limit=2
248
  )
249
-
250
  return demo
251
 
 
 
 
252
  if __name__ == "__main__":
253
- build_ui().launch()
 
1
+ import os, time, base64, mimetypes, requests, traceback
 
 
 
 
2
  from dataclasses import dataclass
3
  from typing import Optional, Tuple, Dict, Any
4
 
5
  import gradio as gr
 
6
  from dotenv import load_dotenv
7
+ from tenacity import retry, stop_after_attempt, wait_exponential, retry_if_exception_type
8
+
9
+ # OpenAI SDK exceptions are defined lazily; guard imports
10
+ try:
11
+ from openai import OpenAI
12
+ from openai import RateLimitError, APIConnectionError, APIStatusError
13
+ except Exception: # pragma: no cover
14
+ OpenAI = None
15
+ RateLimitError = APIConnectionError = APIStatusError = Exception # fallback
16
 
 
 
17
  load_dotenv()
18
+ ENV_FALLBACK_KEY = (os.getenv("OPENAI_API_KEY") or "").strip()
19
 
20
+ ALLOWED_MODELS = ["sora-2", "sora-2-pro", "sora"]
21
+ ALLOWED_SIZES = ["1280x720","720x1280","1792x1024","1024x1792","1920x1080","1080x1920"]
 
22
 
23
  @dataclass
24
  class JobStatus:
 
27
  output_url: Optional[str] = None
28
  output_b64: Optional[str] = None
29
 
30
+ # ---------- utilities ----------
31
+ def _safe_video_path() -> str:
32
+ return "/tmp/sora_output.mp4"
33
+
34
+ def _file_to_b64(path: str) -> str:
35
+ with open(path, "rb") as f:
36
+ return base64.b64encode(f.read()).decode("utf-8")
37
+
38
  def _sanitize_prompt(p: str) -> str:
39
  p = (p or "").strip()
40
  if not p:
 
44
  return p
45
 
46
  def _validate_duration(d: int) -> int:
47
+ try: d = int(d)
48
+ except: d = 10
 
 
49
  return max(1, min(d, 30))
50
 
51
  def _validate_guidance(g: float) -> float:
52
+ try: g = float(g)
53
+ except: g = 7.5
 
 
54
  return max(0.0, min(g, 20.0))
55
 
56
  def _validate_model(m: str) -> str:
 
59
  def _validate_size(s: str) -> str:
60
  return s if s in ALLOWED_SIZES else "1280x720"
61
 
 
 
 
 
 
 
 
 
62
  def _make_client(user_key: Optional[str]) -> OpenAI:
63
+ if OpenAI is None:
64
+ raise RuntimeError("OpenAI SDK failed to import. Check requirements.txt and rebuild.")
65
+ key = (user_key or "").strip() or ENV_FALLBACK_KEY
66
  if not key:
 
67
  raise ValueError("Missing API key. Paste a valid OpenAI API key.")
68
  return OpenAI(api_key=key)
69
 
70
+ # ---------- networking ----------
71
+ from requests import RequestException as ReqErr
72
  @retry(
73
+ retry=retry_if_exception_type((ReqErr,)),
74
  wait=wait_exponential(multiplier=1, min=1, max=10),
75
  stop=stop_after_attempt(5),
76
  reraise=True,
 
79
  with requests.get(url, stream=True, timeout=60) as r:
80
  r.raise_for_status()
81
  with open(out_path, "wb") as f:
82
+ for chunk in r.iter_content(1024 * 256):
83
  if chunk:
84
  f.write(chunk)
85
  return out_path
86
 
87
+ # Some envs expose different exception classes; normalize
88
+ _OAI_EXC = tuple(e for e in [RateLimitError, APIConnectionError, APIStatusError] if isinstance(e, type)) or (Exception,)
89
+
90
  @retry(
91
+ retry=retry_if_exception_type(_OAI_EXC),
92
  wait=wait_exponential(multiplier=1, min=1, max=8),
93
  stop=stop_after_attempt(5),
94
  reraise=True,
95
  )
96
  def _videos_generate(client: OpenAI, **kwargs) -> Any:
97
+ """
98
+ Dual-path: try new Videos API; if not available, try jobs.create
99
+ """
100
+ # Path A: videos.generate(...)
101
+ try:
102
+ return client.videos.generate(**kwargs)
103
+ except Exception as e_a:
104
+ # Path B: videos.jobs.create(...)
105
+ try:
106
+ # Map kwargs as-is; most fields are identical in rollouts
107
+ return client.videos.jobs.create(**kwargs)
108
+ except Exception as e_b:
109
+ # Bubble both errors for debugging in status panel
110
+ raise RuntimeError(f"videos.generate failed: {e_a}\njobs.create failed: {e_b}")
111
 
112
  @retry(
113
+ retry=retry_if_exception_type(_OAI_EXC),
114
  wait=wait_exponential(multiplier=1, min=1, max=8),
115
+ stop=stop_after_attempt(120), # generous polling
116
  reraise=True,
117
  )
118
  def _videos_retrieve(client: OpenAI, job_id: str) -> Any:
119
+ """
120
+ Dual-path retrieve: videos.retrieve and videos.jobs.retrieve
121
+ """
122
+ try:
123
+ return client.videos.retrieve(job_id)
124
+ except Exception:
125
+ return client.videos.jobs.retrieve(job_id)
126
 
127
  def _extract_status(resp: Any) -> JobStatus:
128
  status = getattr(resp, "status", None) or getattr(resp, "state", None) or "unknown"
129
+ err = None; out_url = None; out_b64 = None
 
 
130
 
131
  output = getattr(resp, "output", None) or getattr(resp, "result", None)
132
  if output:
 
135
  artifacts = getattr(output, "artifacts", None)
136
  if artifacts and isinstance(artifacts, list):
137
  for a in artifacts:
138
+ out_b64 = out_b64 or getattr(a, "b64_mp4", None) or getattr(a, "b64_video", None)
139
+ out_url = out_url or getattr(a, "url", None)
 
 
140
 
141
  err_obj = getattr(resp, "error", None)
142
  if err_obj:
 
144
 
145
  return JobStatus(status=status, error=err, output_url=out_url, output_b64=out_b64)
146
 
147
+ # ---------- core ----------
148
  def generate_video(api_key: str,
149
  prompt: str,
150
  model: str,
 
153
  seed: int,
154
  audio: str,
155
  guidance: float,
156
+ init_image: Optional[str],
157
+ progress=gr.Progress(track_tqdm=False)) -> Tuple[Optional[str], str]:
158
+ """
159
+ Always returns (video_path_or_url, status_text)
160
+ Never raises to Gradio.
161
+ """
162
  try:
163
  client = _make_client(api_key)
164
+ except Exception as e_init:
165
+ return None, f"Setup error: {e_init}"
166
 
167
+ try:
168
  prompt = _sanitize_prompt(prompt)
169
  model = _validate_model(model)
170
  duration = _validate_duration(duration)
 
190
  if init_image:
191
  req["image"] = {"b64": _file_to_b64(init_image)}
192
 
193
+ progress(0.02, desc="Submitting job...")
194
  job = _videos_generate(client, **req)
195
  job_id = getattr(job, "id", None) or getattr(job, "job_id", None)
196
  if not job_id:
197
  return None, "Could not get a job id from the API response."
198
 
199
  start = time.time()
200
+ last_tick = start
201
  while True:
202
+ progress(min(0.95, 0.05 + (time.time() - start) / 600.0), desc="Rendering...")
203
  status_obj = _videos_retrieve(client, job_id)
204
  js = _extract_status(status_obj)
205
 
 
215
  return out_path, f"Downloaded from URL. Done with {model}."
216
  except Exception as dl_err:
217
  return js.output_url, f"Ready (URL) — local download failed: {dl_err}"
218
+ return None, "Job succeeded but no video payload was returned."
219
 
220
  if js.status in ("failed", "error", "canceled", "cancelled"):
221
  detail = f"Status: {js.status}."
 
223
  detail += f" Error: {js.error}"
224
  return None, detail
225
 
226
+ # heartbeat so Spaces doesn’t think nothing’s happening
227
+ if time.time() - last_tick > 15:
228
+ last_tick = time.time()
229
+
230
+ if time.time() - start > 1800: # 30 min cap
231
+ return None, "Timed out waiting for the video. Try shorter duration or lower resolution."
232
 
233
  time.sleep(2)
234
 
235
+ except _OAI_EXC as oe:
236
  return None, f"OpenAI API issue: {oe}. Retried with backoff; try shorter duration/resolution."
237
  except Exception as e:
238
+ # full trace into status (so it never looks like “nothing happened”)
239
+ msg = f"Error: {e}\n\n{traceback.format_exc(limit=3)}"
240
+ return None, msg
241
 
242
+ # ---------- UI ----------
243
  def build_ui():
244
  with gr.Blocks(title="ZEN — Sora / Sora-2 / Sora-2-Pro", theme=gr.themes.Soft()) as demo:
245
  gr.Markdown("## ZEN — Sora / Sora-2 / Sora-2-Pro (OpenAI Videos API)")
246
  gr.Markdown(
247
+ "Paste an OpenAI API key (not stored). Provide a detailed prompt. "
248
+ "This app uses retries, progress, and robust polling—no silent resets."
249
  )
250
 
251
  with gr.Row():
 
257
  duration = gr.Slider(1, 30, value=10, step=1, label="Duration (seconds)")
258
  seed = gr.Number(value=0, precision=0, label="Seed (0 = random)")
259
  guidance = gr.Slider(0.0, 20.0, value=7.5, step=0.5, label="Guidance")
260
+ audio = gr.Dropdown(["on","off"], value="on", label="Audio")
261
 
262
  prompt = gr.Textbox(
263
  label="Prompt",
 
268
 
269
  go = gr.Button("Generate", variant="primary")
270
  video = gr.Video(label="Result", autoplay=True)
271
+ status = gr.Textbox(label="Status / Logs", interactive=False)
272
 
273
  go.click(
274
  fn=generate_video,
275
  inputs=[api_key, prompt, model, duration, size, seed, audio, guidance, init_image],
276
  outputs=[video, status],
277
+ queue=True
 
278
  )
 
279
  return demo
280
 
281
+ demo = build_ui()
282
+ # Global queue so clicks are sticky; status tracker keeps a panel in Spaces
283
+ demo.queue(concurrency_count=2, status_tracker=True)
284
  if __name__ == "__main__":
285
+ demo.launch()