epic98 commited on
Commit
282eaa8
·
verified ·
1 Parent(s): 0663cca

Upload folder using huggingface_hub

Browse files
Files changed (5) hide show
  1. Dockerfile +10 -5
  2. README.md +16 -7
  3. main.py +886 -170
  4. requirements.txt +6 -5
  5. static/index.html +621 -106
Dockerfile CHANGED
@@ -1,16 +1,21 @@
1
- FROM python:3.11-slim
2
 
3
  WORKDIR /app
4
 
5
- RUN apt-get update && apt-get install -y git && rm -rf /var/lib/apt/lists/*
 
 
 
 
 
6
 
7
  COPY requirements.txt .
8
- RUN pip install --no-cache-dir -r requirements.txt
 
9
 
10
  COPY . .
11
 
12
- RUN useradd -m -u 1000 user && chown -R user:user /app
13
- USER user
14
 
15
  ENV PORT=7860
16
  EXPOSE 7860
 
1
+ FROM python:3.11
2
 
3
  WORKDIR /app
4
 
5
+ RUN apt-get update && apt-get install -y --no-install-recommends \
6
+ git \
7
+ curl \
8
+ ffmpeg \
9
+ build-essential \
10
+ && rm -rf /var/lib/apt/lists/*
11
 
12
  COPY requirements.txt .
13
+ RUN pip install --no-cache-dir --upgrade pip wheel setuptools && \
14
+ pip install --no-cache-dir -r requirements.txt
15
 
16
  COPY . .
17
 
18
+ RUN mkdir -p data/staging data/outputs ~/.kaggle ~/.config/kaggle && chmod -R 777 data
 
19
 
20
  ENV PORT=7860
21
  EXPOSE 7860
README.md CHANGED
@@ -1,11 +1,20 @@
1
  ---
2
- title: LTX Flow B Studio
3
- emoji: 🎬
4
- colorFrom: purple
5
- colorTo: blue
6
  sdk: docker
7
- pinned: false
8
  ---
9
 
10
- # LTX Flow B Studio
11
- AI Lip-Sync & Multi-Clip Stitching Cloud Dashboard powered by Kaggle GPUs.
 
 
 
 
 
 
 
 
 
 
1
  ---
2
+ title: EpicSync Studio
3
+ emoji:
4
+ colorFrom: gray
5
+ colorTo: gray
6
  sdk: docker
7
+ app_port: 7860
8
  ---
9
 
10
+ # EpicSync Studio Minimalist AI Lip Sync Frontend & Cloud Orchestration
11
+
12
+ EpicSync is a high-contrast, zero-gradient, professional single-page web interface for orchestrating end-to-end video retalking and lip synthesis using Hugging Face Datasets and Kaggle GPU compute.
13
+
14
+ ## Features
15
+ - **Minimalist Aesthetic**: High-contrast dark mode, custom typography (`Outfit` & `JetBrains Mono`), zero gradients.
16
+ - **Universal Input**: Upload MP4 videos or static images with dynamic voice synthesis.
17
+ - **Real-Time Terminal Streaming**: Live logs that persist across browser refreshes.
18
+ - **Persistent Job State**: Built-in SQLite/JSON job store ensuring generated videos remain playable and downloadable permanently.
19
+ - **Task Cancellation**: Cancel running Kaggle GPU executions directly from the UI.
20
+ - **Hugging Face Dataset Storage Integration**: Automatically mirrors source videos and generated outputs to a persistent Hugging Face dataset repository.
main.py CHANGED
@@ -3,224 +3,940 @@ import sys
3
  import json
4
  import time
5
  import shutil
6
- import re
7
- import subprocess
8
  import base64
9
- from fastapi import FastAPI, UploadFile, File, Form, BackgroundTasks
 
 
 
10
  from fastapi.staticfiles import StaticFiles
11
  from fastapi.responses import FileResponse, JSONResponse
12
  from pydantic import BaseModel
 
13
 
14
- app = FastAPI(title="LTX Flow B Studio")
15
 
16
  DATA_DIR = os.path.abspath("data")
17
  JOBS_FILE = os.path.join(DATA_DIR, "jobs.json")
 
18
  OUTPUTS_DIR = os.path.join(DATA_DIR, "outputs")
19
- os.makedirs(DATA_DIR, exist_ok=True)
20
- os.makedirs(OUTPUTS_DIR, exist_ok=True)
 
 
 
21
 
22
  def load_jobs():
23
- if os.path.exists(JOBS_FILE):
24
- try:
25
- with open(JOBS_FILE, "r", encoding="utf-8") as f:
26
- return json.load(f)
27
- except Exception:
28
- return {}
29
- return {}
 
30
 
31
  def save_jobs(jobs):
32
- with open(JOBS_FILE, "w", encoding="utf-8") as f:
33
- json.dump(jobs, f, indent=2)
34
-
35
- def auto_slice_script(text):
36
- if '\n\n' in text:
37
- return text
38
- sentences = [s.strip() for s in re.split(r'(?<=[.!?])\s+', text) if s.strip()]
39
- if len(sentences) <= 1:
40
- lines = [l.strip() for l in text.split('\n') if l.strip()]
41
- return '\n\n'.join(lines)
42
- return '\n\n'.join(sentences)
 
43
 
44
  def setup_kaggle_auth(username, key):
45
  env = os.environ.copy()
46
  env["KAGGLE_USERNAME"] = username
47
  env["KAGGLE_KEY"] = key
48
- if key.startswith("KGAT_"):
49
- env["KAGGLE_API_TOKEN"] = key
50
  for p in ["~/.kaggle", "~/.config/kaggle"]:
51
  d = os.path.expanduser(p)
52
  os.makedirs(d, exist_ok=True)
53
  creds_file = os.path.join(d, "kaggle.json")
54
  with open(creds_file, "w") as f:
55
  json.dump({"username": username, "key": key}, f)
 
 
 
56
  try:
57
  os.chmod(creds_file, 0o600)
 
58
  except Exception:
59
  pass
60
- if key.startswith("KGAT_"):
61
- token_file = os.path.join(d, "access_token")
62
- with open(token_file, "w") as f:
63
- f.write(key)
64
- try:
65
- os.chmod(token_file, 0o600)
66
- except Exception:
67
- pass
68
  return env
69
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
70
  @app.post("/api/run")
71
- async def run_job(
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
72
  background_tasks: BackgroundTasks,
73
  script_text: str = Form(...),
74
- prompt_text: str = Form(...),
75
  voice: str = Form("en-US-AnaNeural"),
76
- resolution: str = Form("720p"),
77
- aspect_ratio: str = Form("16:9 Landscape"),
78
  kaggle_user: str = Form("ikechukwuebiringa1"),
79
- kaggle_key: str = Form("KGAT_0f12d3a4d07d48f7775e36f82bbc41b6"),
 
 
80
  image: UploadFile = File(...)
81
  ):
82
- try:
83
- env = setup_kaggle_auth(kaggle_user, kaggle_key)
84
- job_id = f"ltx-flowb-{int(time.time())}"
85
- slug = f"{kaggle_user}/{job_id}"
 
 
 
 
 
 
 
 
 
 
 
86
 
87
- staging_dir = os.path.join(DATA_DIR, "staging", job_id)
88
- os.makedirs(staging_dir, exist_ok=True)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
89
 
90
- # Copy template script
91
- template_dir = os.path.abspath("kaggle_template")
92
- shutil.copy(os.path.join(template_dir, "run_batch.py"), os.path.join(staging_dir, "run_batch.py"))
 
 
 
 
93
 
94
- # Metadata
95
- meta = {
96
- "id": slug,
97
- "title": f"LTX FlowB {int(time.time())}",
98
- "code_file": "run_batch.py",
99
- "language": "python",
100
- "kernel_type": "script",
101
- "is_private": True,
102
- "enable_gpu": True,
103
- "machine_shape": "NvidiaTeslaT4",
104
- "enable_internet": True,
105
- "dataset_sources": [],
106
- "competition_sources": [],
107
- "kernel_sources": []
108
- }
109
- with open(os.path.join(staging_dir, "kernel-metadata.json"), "w", encoding="utf-8") as f:
110
- json.dump(meta, f, indent=2)
111
-
112
- # Config & Script
113
- sliced_script = auto_slice_script(script_text)
114
- with open(os.path.join(staging_dir, "script.txt"), "w", encoding="utf-8") as f:
115
- f.write(sliced_script)
116
- with open(os.path.join(staging_dir, "prompt.txt"), "w", encoding="utf-8") as f:
117
- f.write(prompt_text)
118
- with open(os.path.join(staging_dir, "config.json"), "w", encoding="utf-8") as f:
119
- json.dump({"voice": voice, "resolution": resolution, "aspect_ratio": aspect_ratio}, f)
120
-
121
- # Save uploaded image to static outputs directory
122
- ext = os.path.splitext(image.filename)[1] or ".png"
123
- public_img_name = f"{job_id}_input{ext}"
124
- public_img_path = os.path.join(OUTPUTS_DIR, public_img_name)
125
- with open(public_img_path, "wb") as buffer:
126
- shutil.copyfileobj(image.file, buffer)
127
- shutil.copy(public_img_path, os.path.join(staging_dir, f"reference_image{ext}"))
128
-
129
- img_url = f"https://epic98-ltx-flowb-studio.hf.space/outputs/{public_img_name}"
130
-
131
- embedded_code = f"""# --- EMBEDDED JOB DATA ---
132
- EMBEDDED_CONFIG = {json.dumps({"voice": voice, "resolution": resolution, "aspect_ratio": aspect_ratio})}
133
- EMBEDDED_SCRIPT = {repr(sliced_script)}
134
- EMBEDDED_PROMPT = {repr(prompt_text)}
135
- EMBEDDED_IMAGE_URL = {repr(img_url)}
136
- EMBEDDED_IMAGE_EXT = {repr(ext)}
137
- # -------------------------
138
- """
139
- with open(os.path.join(staging_dir, "run_batch.py"), "r", encoding="utf-8") as f:
140
- t_code = f.read()
141
- t_code = t_code.replace(
142
- "# --- EMBEDDED JOB DATA ---\nEMBEDDED_CONFIG = None\nEMBEDDED_SCRIPT = None\nEMBEDDED_PROMPT = None\nEMBEDDED_IMAGE_URL = None\nEMBEDDED_IMAGE_EXT = \".png\"\n# -------------------------",
143
- embedded_code
144
- )
145
- with open(os.path.join(staging_dir, "run_batch.py"), "w", encoding="utf-8") as f:
146
- f.write(t_code)
147
-
148
- # Push to Kaggle
149
- cmd = ["kaggle", "kernels", "push", "-p", staging_dir]
150
- proc = subprocess.run(cmd, env=env, capture_output=True, text=True)
151
- if proc.returncode != 0:
152
- return JSONResponse(status_code=500, content={"error": f"Kaggle push failed: {proc.stderr or proc.stdout}"})
153
-
154
  jobs = load_jobs()
155
- jobs[job_id] = {
156
- "id": job_id,
157
- "slug": slug,
158
- "status": "Running on Kaggle",
159
- "created_at": time.strftime("%Y-%m-%d %H:%M:%S"),
160
- "prompt": prompt_text,
161
- "script_preview": sliced_script[:100] + ("..." if len(sliced_script) > 100 else ""),
162
- "kaggle_user": kaggle_user,
163
- "kaggle_key": kaggle_key
164
- }
165
  save_jobs(jobs)
 
 
166
 
167
- return {"status": "success", "job_id": job_id, "slug": slug}
168
- except Exception as e:
169
- return JSONResponse(status_code=500, content={"error": str(e)})
170
 
171
  @app.get("/api/jobs")
172
  def get_jobs():
 
 
 
 
173
  jobs = load_jobs()
174
- # Check status for running jobs
175
- updated = False
176
- for job_id, job in jobs.items():
177
- if job["status"] in ["Running on Kaggle", "Pending"]:
178
- env = setup_kaggle_auth(job.get("kaggle_user", "ikechukwuebiringa1"), job.get("kaggle_key", "KGAT_0f12d3a4d07d48f7775e36f82bbc41b6"))
179
- proc = subprocess.run(["kaggle", "kernels", "status", job["slug"]], env=env, capture_output=True, text=True)
180
- out = proc.stdout + proc.stderr
181
- if "COMPLETE" in out.upper() or "complete" in out:
182
- job["status"] = "Completed"
183
- updated = True
184
- elif "ERROR" in out.upper() or "error" in out:
185
- job["status"] = "Failed"
186
- updated = True
187
- if updated:
188
- save_jobs(jobs)
189
- return list(jobs.values())
 
 
 
 
 
 
 
 
 
 
 
190
 
191
  @app.get("/api/download/{job_id}")
192
  def download_video(job_id: str):
193
- jobs = load_jobs()
194
- if job_id not in jobs:
195
- return JSONResponse(status_code=404, content={"error": "Job not found"})
196
- job = jobs[job_id]
197
-
198
- out_dir = os.path.join(OUTPUTS_DIR, job_id)
199
- os.makedirs(out_dir, exist_ok=True)
200
- video_file = os.path.join(out_dir, "final_stitched_video.mp4")
201
-
202
- if not os.path.exists(video_file):
203
- env = setup_kaggle_auth(job.get("kaggle_user", "ikechukwuebiringa1"), job.get("kaggle_key", "KGAT_0f12d3a4d07d48f7775e36f82bbc41b6"))
204
- subprocess.run(["kaggle", "kernels", "output", job["slug"], "-p", out_dir], env=env)
205
-
206
- if os.path.exists(video_file):
207
- return FileResponse(video_file, media_type="video/mp4", filename=f"{job_id}_video.mp4")
208
- else:
209
- return JSONResponse(status_code=404, content={"error": "Video file not yet available from Kaggle"})
210
-
211
- @app.get("/api/debug")
212
- def debug_env():
213
- user = "ikechukwuebiringa1"
214
- key = "KGAT_0f12d3a4d07d48f7775e36f82bbc41b6"
215
- env = setup_kaggle_auth(user, key)
216
- ver = subprocess.run(["kaggle", "--version"], env=env, capture_output=True, text=True)
217
- lst = subprocess.run(["kaggle", "kernels", "list", "--mine"], env=env, capture_output=True, text=True)
218
- return {"version": ver.stdout or ver.stderr, "list": lst.stdout or lst.stderr}
219
-
220
- os.makedirs("static", exist_ok=True)
221
- app.mount("/outputs", StaticFiles(directory=OUTPUTS_DIR), name="outputs")
222
- app.mount("/", StaticFiles(directory="static", html=True), name="static")
223
 
224
- if __name__ == "__main__":
225
- import uvicorn
226
- uvicorn.run(app, host="0.0.0.0", port=7860)
 
3
  import json
4
  import time
5
  import shutil
 
 
6
  import base64
7
+ import threading
8
+ import subprocess
9
+ from pathlib import Path
10
+ from fastapi import FastAPI, UploadFile, File, Form, BackgroundTasks, HTTPException
11
  from fastapi.staticfiles import StaticFiles
12
  from fastapi.responses import FileResponse, JSONResponse
13
  from pydantic import BaseModel
14
+ from huggingface_hub import HfApi
15
 
16
+ app = FastAPI(title="EpicSync Studio")
17
 
18
  DATA_DIR = os.path.abspath("data")
19
  JOBS_FILE = os.path.join(DATA_DIR, "jobs.json")
20
+ STAGING_DIR = os.path.join(DATA_DIR, "staging")
21
  OUTPUTS_DIR = os.path.join(DATA_DIR, "outputs")
22
+
23
+ for d in [DATA_DIR, STAGING_DIR, OUTPUTS_DIR]:
24
+ os.makedirs(d, exist_ok=True)
25
+
26
+ jobs_lock = threading.Lock()
27
 
28
  def load_jobs():
29
+ with jobs_lock:
30
+ if os.path.exists(JOBS_FILE):
31
+ try:
32
+ with open(JOBS_FILE, "r", encoding="utf-8") as f:
33
+ return json.load(f)
34
+ except Exception:
35
+ return {}
36
+ return {}
37
 
38
  def save_jobs(jobs):
39
+ with jobs_lock:
40
+ with open(JOBS_FILE, "w", encoding="utf-8") as f:
41
+ json.dump(jobs, f, indent=2)
42
+
43
+ def append_log(job_id, message):
44
+ jobs = load_jobs()
45
+ if job_id in jobs:
46
+ timestamp = time.strftime("%H:%M:%S")
47
+ log_line = f"[{timestamp}] {message}"
48
+ jobs[job_id]["logs"].append(log_line)
49
+ save_jobs(jobs)
50
+ print(f"[EpicSync - {job_id}] {message}", flush=True)
51
 
52
  def setup_kaggle_auth(username, key):
53
  env = os.environ.copy()
54
  env["KAGGLE_USERNAME"] = username
55
  env["KAGGLE_KEY"] = key
56
+ env["KAGGLE_API_TOKEN"] = key
 
57
  for p in ["~/.kaggle", "~/.config/kaggle"]:
58
  d = os.path.expanduser(p)
59
  os.makedirs(d, exist_ok=True)
60
  creds_file = os.path.join(d, "kaggle.json")
61
  with open(creds_file, "w") as f:
62
  json.dump({"username": username, "key": key}, f)
63
+ token_file = os.path.join(d, "access_token")
64
+ with open(token_file, "w") as f:
65
+ f.write(key.strip())
66
  try:
67
  os.chmod(creds_file, 0o600)
68
+ os.chmod(token_file, 0o600)
69
  except Exception:
70
  pass
 
 
 
 
 
 
 
 
71
  return env
72
 
73
+ def upload_to_hf_dataset(file_path, repo_id, path_in_repo, hf_token):
74
+ if not repo_id or not hf_token:
75
+ return
76
+ try:
77
+ api = HfApi(token=hf_token)
78
+ api.create_repo(repo_id=repo_id, repo_type="dataset", exist_ok=True)
79
+ api.upload_file(
80
+ path_or_fileobj=file_path,
81
+ path_in_repo=path_in_repo,
82
+ repo_id=repo_id,
83
+ repo_type="dataset"
84
+ )
85
+ except Exception as e:
86
+ print(f"[WARN] HF Dataset upload failed: {e}")
87
+
88
+ KERNEL_TEMPLATE = """import os
89
+ import subprocess
90
+ import glob
91
+ import sys
92
+ import base64
93
+
94
+ def run_cmd(cmd):
95
+ print(f"Executing: {cmd}")
96
+ res = subprocess.run(cmd, shell=True, capture_output=True, text=True)
97
+ with open("/kaggle/working/execution.log", "a", encoding="utf-8") as f:
98
+ f.write(f"=== CMD: {cmd} ===\\n")
99
+ f.write(f"STDOUT:\\n{res.stdout}\\n")
100
+ f.write(f"STDERR:\\n{res.stderr}\\n")
101
+ f.write(f"EXIT CODE: {res.returncode}\\n\\n")
102
+ if res.returncode != 0:
103
+ print(f" [WARN] Exit code {res.returncode}")
104
+ return res.returncode
105
+
106
+ # Fetch or decode video sent from frontend UI
107
+ HF_REPO = ___HF_REPO___
108
+ JOB_ID = ___JOB_ID___
109
+ VIDEO_B64 = ___VIDEO_B64___
110
+
111
+ if VIDEO_B64:
112
+ with open("/kaggle/working/input.mp4", "wb") as f:
113
+ f.write(base64.b64decode(VIDEO_B64))
114
+ print("Decoded frontend input.mp4 directly from script.")
115
+ elif HF_REPO and JOB_ID:
116
+ import urllib.request
117
+ url = f"https://huggingface.co/datasets/{HF_REPO}/resolve/main/inputs/{JOB_ID}.mp4"
118
+ print(f"Fetching input video from HF Dataset: {url}")
119
+ try:
120
+ urllib.request.urlretrieve(url, "/kaggle/working/input.mp4")
121
+ print(f"Successfully downloaded frontend input.mp4 ({os.path.getsize('/kaggle/working/input.mp4')} bytes).")
122
+ except Exception as e:
123
+ print(f"[WARN] Could not fetch video from HF dataset: {e}")
124
+
125
+ # ========== 1. INSTALL DEPENDENCIES ==========
126
+ run_cmd("pip install edge-tts")
127
+ run_cmd("git clone https://github.com/OpenTalker/video-retalking.git")
128
+
129
+ # Install deps individually - skip numpy (use Kaggle's numpy 2.x) and skip torch (use Kaggle's torch)
130
+ run_cmd("pip install basicsr kornia face-alignment ninja einops facexlib yacs librosa==0.9.2 dlib cmake gfpgan")
131
+
132
+ # ========== 2. DOWNLOAD CHECKPOINTS FROM HUGGINGFACE ==========
133
+ os.makedirs("video-retalking/checkpoints", exist_ok=True)
134
+ run_cmd("cd video-retalking && git clone https://huggingface.co/camenduru/video-retalking checkpoints_tmp")
135
+ run_cmd("cd video-retalking && cp -r checkpoints_tmp/* checkpoints/")
136
+ run_cmd("cd video-retalking/checkpoints && unzip -o -q BFM.zip")
137
+ run_cmd("cd video-retalking/checkpoints && cp ParseNet-latest.pth parsing_parsenet.pth")
138
+ run_cmd("cd video-retalking && rm -rf checkpoints_tmp")
139
+
140
+ # Verify all required checkpoints exist
141
+ required_checkpoints = [
142
+ "DNet.pt", "ENet.pth", "LNet.pth", "GFPGANv1.3.pth",
143
+ "GPEN-BFR-512.pth", "ParseNet-latest.pth", "parsing_parsenet.pth",
144
+ "RetinaFace-R50.pth", "shape_predictor_68_face_landmarks.dat",
145
+ "face3d_pretrain_epoch_20.pth", "30_net_gen.pth", "expression.mat",
146
+ ]
147
+ print("\\n=== Checkpoint Verification ===")
148
+ for ck in required_checkpoints:
149
+ path = f"video-retalking/checkpoints/{ck}"
150
+ exists = os.path.isfile(path)
151
+ size = os.path.getsize(path) if exists else 0
152
+ print(f" {'OK' if exists and size > 1000 else 'MISSING'}: {ck} ({size} bytes)")
153
+ print()
154
+
155
+ # ========== 3. GENERATE AUDIO ==========
156
+ text = ___SCRIPT_TEXT___
157
+ voice = ___VOICE___
158
+ run_cmd(f'edge-tts --text "{text}" --voice {voice} --write-media /kaggle/working/audio.wav')
159
+ audio_path = "/kaggle/working/audio.wav"
160
+
161
+ # ========== 4. APPLY ALL COMPATIBILITY PATCHES ==========
162
+ print("\\n=== Applying Compatibility Patches ===")
163
+
164
+ # --- PATCH A: basicsr torchvision.transforms.functional_tensor (removed in torchvision >= 0.17) ---
165
+ import sys
166
+ from pathlib import Path
167
+ for sp in sys.path:
168
+ deg_path = Path(sp) / "basicsr" / "data" / "degradations.py"
169
+ if deg_path.exists():
170
+ with open(deg_path, "r") as f:
171
+ content = f.read()
172
+ content = content.replace(
173
+ "from torchvision.transforms.functional_tensor import rgb_to_grayscale",
174
+ "from torchvision.transforms.functional import rgb_to_grayscale"
175
+ )
176
+ with open(deg_path, "w") as f:
177
+ f.write(content)
178
+ print(" [PATCH A] Fixed basicsr functional_tensor -> functional")
179
+
180
+ # --- PATCH B: numpy 2.x removed np.int, np.float, np.bool, np.VisibleDeprecationWarning ---
181
+ import numpy as np
182
+ if not hasattr(np, 'int'):
183
+ np.int = int
184
+ if not hasattr(np, 'float'):
185
+ np.float = float
186
+ if not hasattr(np, 'bool'):
187
+ np.bool = bool
188
+ if not hasattr(np, 'complex'):
189
+ np.complex = complex
190
+ if not hasattr(np, 'object'):
191
+ np.object = object
192
+ if not hasattr(np, 'str'):
193
+ np.str = str
194
+ if not hasattr(np, 'VisibleDeprecationWarning'):
195
+ np.VisibleDeprecationWarning = DeprecationWarning
196
+ print(" [PATCH B] Restored deprecated numpy type aliases")
197
+
198
+ # --- PATCH C: face_alignment LandmarksType._2D -> TWO_D (changed in face_alignment >= 1.4) ---
199
+ files_to_patch_landmarks = [
200
+ "video-retalking/third_part/face3d/extract_kp_videos.py",
201
+ "video-retalking/utils/alignment_stit.py",
202
+ ]
203
+ for filepath in files_to_patch_landmarks:
204
+ if os.path.isfile(filepath):
205
+ with open(filepath, "r") as f:
206
+ content = f.read()
207
+ content = content.replace(
208
+ "face_alignment.LandmarksType._2D",
209
+ "face_alignment.LandmarksType.TWO_D"
210
+ )
211
+ with open(filepath, "w") as f:
212
+ f.write(content)
213
+ print(f" [PATCH C] Fixed LandmarksType._2D in {filepath}")
214
+
215
+ # --- PATCH D: PIL.Image.ANTIALIAS removed in Pillow >= 10.0, replaced with LANCZOS ---
216
+ import PIL.Image
217
+ if not hasattr(PIL.Image, 'ANTIALIAS'):
218
+ PIL.Image.ANTIALIAS = PIL.Image.LANCZOS
219
+ print(" [PATCH D] Restored PIL.Image.ANTIALIAS alias")
220
+
221
+ files_to_patch_antialias = [
222
+ "video-retalking/utils/alignment_stit.py",
223
+ "video-retalking/utils/ffhq_preprocess.py",
224
+ "video-retalking/third_part/ganimation_replicate/visualizer.py",
225
+ ]
226
+ for filepath in files_to_patch_antialias:
227
+ if os.path.isfile(filepath):
228
+ with open(filepath, "r") as f:
229
+ content = f.read()
230
+ if "ANTIALIAS" in content:
231
+ content = content.replace("Image.ANTIALIAS", "Image.LANCZOS")
232
+ content = content.replace("PIL.Image.ANTIALIAS", "PIL.Image.LANCZOS")
233
+ with open(filepath, "w") as f:
234
+ f.write(content)
235
+ print(f" [PATCH D] Fixed ANTIALIAS -> LANCZOS in {filepath}")
236
+
237
+ # --- PATCH E: np.int (bare, not np.int32) in face_detection/utils.py ---
238
+ fd_utils = "video-retalking/third_part/face_detection/utils.py"
239
+ if os.path.isfile(fd_utils):
240
+ with open(fd_utils, "r") as f:
241
+ content = f.read()
242
+ content = content.replace("dtype=np.int)", "dtype=np.int64)")
243
+ with open(fd_utils, "w") as f:
244
+ f.write(content)
245
+ print(" [PATCH E] Fixed np.int -> np.int64 in face_detection/utils.py")
246
+
247
+ # --- PATCH F: torch.load needs weights_only=False for PyTorch >= 2.6 ---
248
+ import torch
249
+ _original_torch_load = torch.load
250
+ def _patched_torch_load(*args, **kwargs):
251
+ if 'weights_only' not in kwargs:
252
+ kwargs['weights_only'] = False
253
+ return _original_torch_load(*args, **kwargs)
254
+ torch.load = _patched_torch_load
255
+ print(" [PATCH F] Monkey-patched torch.load for weights_only=False")
256
+
257
+ # --- PATCH G: Patch numpy in inference.py ---
258
+ with open("video-retalking/inference.py", "r") as f:
259
+ inf_code = f.read()
260
+ numpy_shim = \"\"\"import numpy as np
261
+ if not hasattr(np, 'VisibleDeprecationWarning'): np.VisibleDeprecationWarning = DeprecationWarning
262
+ if not hasattr(np, 'int'): np.int = int
263
+ if not hasattr(np, 'float'): np.float = float
264
+ if not hasattr(np, 'bool'): np.bool = bool
265
+ if not hasattr(np, 'complex'): np.complex = complex
266
+ if not hasattr(np, 'object'): np.object = object
267
+ if not hasattr(np, 'str'): np.str = str
268
+ import PIL.Image
269
+ if not hasattr(PIL.Image, 'ANTIALIAS'): PIL.Image.ANTIALIAS = PIL.Image.LANCZOS
270
+ import torch as _torch
271
+ _orig_load = _torch.load
272
+ def _pl(*a, **kw):
273
+ if 'weights_only' not in kw: kw['weights_only'] = False
274
+ return _orig_load(*a, **kw)
275
+ _torch.load = _pl
276
+ \"\"\"
277
+ inf_code = inf_code.replace(
278
+ "[float(item) for item in np.hsplit(trans_params, 5)]",
279
+ "[float(np.squeeze(item)) for item in np.hsplit(trans_params, 5)]"
280
+ )
281
+ with open("video-retalking/inference.py", "w") as f:
282
+ f.write(numpy_shim + inf_code)
283
+ print(" [PATCH G] Prepended shims and patched np.hsplit unwrapping in inference.py")
284
+
285
+ preprocess_file = "video-retalking/third_part/face3d/util/preprocess.py"
286
+ if os.path.isfile(preprocess_file):
287
+ with open(preprocess_file, "r") as f:
288
+ content = f.read()
289
+ shim_line = "import numpy as np\\nif not hasattr(np, 'VisibleDeprecationWarning'): np.VisibleDeprecationWarning = DeprecationWarning\\n"
290
+ if "if not hasattr(np" not in content:
291
+ content = shim_line + content
292
+ with open(preprocess_file, "w") as f:
293
+ f.write(content)
294
+ print(" [PATCH G] Patched preprocess.py for np.VisibleDeprecationWarning")
295
+
296
+ # --- PATCH H: face3d NumPy 2.x scalar & sequence compatibility ---
297
+ preprocess_file = "video-retalking/third_part/face3d/util/preprocess.py"
298
+ if os.path.exists(preprocess_file):
299
+ with open(preprocess_file, "r") as f:
300
+ pcontent = f.read()
301
+ pcontent = pcontent.replace(
302
+ "return t, s",
303
+ "return np.squeeze(t), float(np.squeeze(s))"
304
+ ).replace(
305
+ "w = (w0*s).astype(np.int32)",
306
+ "w = int(w0*s)"
307
+ ).replace(
308
+ "h = (h0*s).astype(np.int32)",
309
+ "h = int(h0*s)"
310
+ ).replace(
311
+ "left = (w/2 - target_size/2 + float((t[0] - w0/2)*s)).astype(np.int32)",
312
+ "left = int(w/2 - target_size/2 + float(np.squeeze((t[0] - w0/2)*s)))"
313
+ ).replace(
314
+ "up = (h/2 - target_size/2 + float((h0/2 - t[1])*s)).astype(np.int32)",
315
+ "up = int(h/2 - target_size/2 + float(np.squeeze((h0/2 - t[1])*s)))"
316
+ ).replace(
317
+ "float((t[0] - w0/2)*s)",
318
+ "float(np.squeeze((t[0] - w0/2)*s))"
319
+ ).replace(
320
+ "float((h0/2 - t[1])*s)",
321
+ "float(np.squeeze((h0/2 - t[1])*s))"
322
+ ).replace(
323
+ "trans_params = np.array([w0, h0, s, t[0], t[1]])",
324
+ "trans_params = np.array([float(np.squeeze(w0)), float(np.squeeze(h0)), float(np.squeeze(s)), float(np.squeeze(t[0])), float(np.squeeze(t[1]))], dtype=np.float32)"
325
+ )
326
+ with open(preprocess_file, "w") as f:
327
+ f.write(pcontent)
328
+ print(" [PATCH H] Patched preprocess.py POS, astype, and trans_params for NumPy 2.x")
329
+
330
+ # --- PATCH J: Fix GPEN align_faces.py float astype and syntax warnings ---
331
+ align_faces_file = "video-retalking/third_part/GPEN/align_faces.py"
332
+ if os.path.isfile(align_faces_file):
333
+ with open(align_faces_file, "r") as f:
334
+ af_content = f.read()
335
+ af_content = af_content.replace("is 'cv2_affine'", "== 'cv2_affine'")
336
+ af_content = af_content.replace("is 'cv2_rigid'", "== 'cv2_rigid'")
337
+ af_content = af_content.replace("is 'affine'", "== 'affine'")
338
+ af_content = af_content.replace(
339
+ "(1 + inner_padding_factor * 2).astype(np.int32)",
340
+ "int(1 + inner_padding_factor * 2)"
341
+ )
342
+ with open(align_faces_file, "w") as f:
343
+ f.write(af_content)
344
+ print(" [PATCH J] Fixed GPEN align_faces.py astype and syntax warnings")
345
+
346
+ # --- PATCH I: Prevent PyTorch DataLoader multi-processing / shm deadlock on Kaggle containers ---
347
+ import re
348
+ for root, _, pyfiles in os.walk("video-retalking"):
349
+ for pfile in pyfiles:
350
+ if pfile.endswith(".py"):
351
+ fpath = os.path.join(root, pfile)
352
+ with open(fpath, "r", encoding="utf-8", errors="ignore") as f:
353
+ code = f.read()
354
+ if "num_workers" in code:
355
+ code = re.sub(r'num_workers\\s*=\\s*\\d+', 'num_workers=0', code)
356
+ with open(fpath, "w", encoding="utf-8") as f:
357
+ f.write(code)
358
+ print(" [PATCH I] Set DataLoader num_workers=0 across video-retalking to prevent container deadlocks")
359
+
360
+ print("\\n=== All patches applied. Starting inference... ===\\n", flush=True)
361
+
362
+ # ========== 5. FIND VIDEO ==========
363
+ if os.path.exists("/kaggle/working/input.mp4"):
364
+ video_path = "/kaggle/working/input.mp4"
365
+ else:
366
+ files = glob.glob("/kaggle/input/**/*.mp4", recursive=True)
367
+ if not files:
368
+ print("ERROR: No input video found!")
369
+ sys.exit(1)
370
+ video_path = files[0]
371
+ print(f"Input video: {video_path}", flush=True)
372
+
373
+ # ========== 6. RUN INFERENCE ==========
374
+ os.environ["OMP_NUM_THREADS"] = "1"
375
+ os.environ["MKL_NUM_THREADS"] = "1"
376
+ cmd = f"cd video-retalking && python inference.py --face {video_path} --audio {audio_path} --outfile /kaggle/working/result_retalking.mp4"
377
+ print(f"Executing Live: {cmd}", flush=True)
378
+ res_code = subprocess.run(cmd, shell=True).returncode
379
+ print(f"Inference Finished with Exit Code: {res_code}", flush=True)
380
+
381
+ # ========== 7. VERIFY OUTPUT ==========
382
+ output_path = "/kaggle/working/result_retalking.mp4"
383
+ if os.path.isfile(output_path):
384
+ size = os.path.getsize(output_path)
385
+ print(f"\\n=== SUCCESS! Output video: {output_path} ({size} bytes) ===")
386
+ else:
387
+ print("\\n=== FAILED: No output video produced ===")
388
+ run_cmd("ls -la /kaggle/working/")
389
+ run_cmd("ls -la /kaggle/working/video-retalking/temp/ 2>/dev/null || true")
390
+
391
+ # Cleanup to avoid massive zip downloads
392
+ run_cmd("rm -rf video-retalking")
393
+ """
394
+
395
+ PREMIUM_KERNEL_TEMPLATE = """import os
396
+ import subprocess
397
+ import glob
398
+ import sys
399
+ import base64
400
+
401
+ def run_cmd(cmd):
402
+ print(f"Executing: {cmd}", flush=True)
403
+ # No capture_output=True so logs stream LIVE to Kaggle console page immediately!
404
+ res = subprocess.run(cmd, shell=True)
405
+ return res
406
+
407
+ print("=== STARTING PREMIUM STUDIO LTX-2.3 PIPELINE ===", flush=True)
408
+
409
+ # 1. SETUP IMAGE INPUT
410
+ img_b64 = ___IMAGE_B64___
411
+ hf_repo = ___HF_REPO___
412
+ job_id = ___JOB_ID___
413
+
414
+ if img_b64 and len(img_b64) > 10:
415
+ print("Decoding embedded base64 image...", flush=True)
416
+ with open("/kaggle/working/input.png", "wb") as f:
417
+ f.write(base64.b64decode(img_b64))
418
+ elif hf_repo:
419
+ print(f"Fetching source image from HF dataset {hf_repo}...", flush=True)
420
+ run_cmd("pip install -q huggingface_hub")
421
+ from huggingface_hub import hf_hub_download
422
+ img_file = hf_hub_download(repo_id=hf_repo, filename=f"inputs/{job_id}.png", repo_type="dataset", local_dir="/kaggle/working")
423
+ if img_file != "/kaggle/working/input.png":
424
+ run_cmd(f"cp '{img_file}' /kaggle/working/input.png")
425
+ else:
426
+ print("ERROR: No image input provided!", flush=True)
427
+ sys.exit(1)
428
+
429
+ # 2. GENERATE AUDIO VOICEOVER VIA TTS
430
+ run_cmd("pip install -q edge-tts soundfile pillow psutil")
431
+ script_text = ___SCRIPT_TEXT___
432
+ voice = ___VOICE___
433
+ print(f"Generating studio voiceover with voice: {voice}...", flush=True)
434
+ run_cmd(f'edge-tts --voice "{voice}" --text "{script_text}" --write-media /kaggle/working/input.wav')
435
+
436
+ # 3. INSTALL COMPATIBLE PYTORCH & WAN2GP
437
+ print("Installing PyTorch 2.3.1 (CUDA 12.1 compatible)...", flush=True)
438
+ run_cmd("pip install -q torch==2.3.1 torchvision==0.18.1 torchaudio==2.3.1 --index-url https://download.pytorch.org/whl/cu121")
439
+
440
+ run_cmd("git clone https://github.com/DeepBeepMeep/Wan2GP.git")
441
+ run_cmd("pip install --timeout 120 --retries 5 -q -r Wan2GP/requirements.txt")
442
+ run_cmd("pip install --timeout 120 --retries 5 -q mmgp gradio gguf soundfile")
443
+
444
+ # 4. LINK MODELS FROM KAGGLE DATASET OR FALLBACK DOWNLOAD
445
+ os.makedirs("Wan2GP/models", exist_ok=True)
446
+ os.makedirs("/kaggle/tmp/models", exist_ok=True)
447
+
448
+ ds_path = "/kaggle/input/wan2gp-models/LTX-2"
449
+ if os.path.exists(ds_path):
450
+ print("Found mounted dataset wan2gp-models! Linking models directly (0s download)...", flush=True)
451
+ for item in os.listdir(ds_path):
452
+ src = os.path.join(ds_path, item)
453
+ dst = os.path.join("Wan2GP/models", item)
454
+ if not os.path.exists(dst):
455
+ os.symlink(src, dst)
456
+ print(f" Linked: {item}", flush=True)
457
+ else:
458
+ print("Mounted dataset not found, downloading weights via HuggingFace Hub...", flush=True)
459
+ from huggingface_hub import hf_hub_download
460
+ REPO = 'DeepBeepMeep/LTX-2'
461
+ files = [
462
+ 'ltx-2.3-22b-distilled-Q4_K_M_light.gguf',
463
+ 'ltx-2.3-22b_audio_vae.safetensors',
464
+ 'ltx-2.3-22b_embeddings_connector.safetensors',
465
+ 'ltx-2.3-22b_text_embedding_projection.safetensors',
466
+ 'ltx-2.3-22b_vae.safetensors',
467
+ 'ltx-2.3-22b_vocoder.safetensors',
468
+ 'ltx-2.3-spatial-upscaler-x2-1.1.safetensors'
469
+ ]
470
+ for f in files:
471
+ hf_hub_download(repo_id=REPO, filename=f, local_dir="Wan2GP/models")
472
+ hf_hub_download(repo_id=REPO, filename="gemma-3-12b-it-qat-q4_0-unquantized/gemma-3-12b-it-qat-q4_0-unquantized.safetensors", local_dir="Wan2GP/models")
473
+
474
+ # 5. EXECUTE LTX-2.3 GENERATION SCRIPT
475
+ ltx_script = '''import os, sys, gc, psutil, json, glob
476
+ import numpy as np
477
+ import soundfile as sf
478
+ from PIL import Image
479
+ import torch
480
+
481
+ sys.path.insert(0, os.path.abspath("Wan2GP"))
482
+ os.chdir("Wan2GP")
483
+
484
+ os.environ["PYTORCH_CUDA_ALLOC_CONF"] = "expandable_segments:True,garbage_collection_threshold:0.5"
485
+ os.environ["TOKENIZERS_PARALLELISM"] = "false"
486
+
487
+ import shared.qtypes.gguf
488
+ from mmgp import offload
489
+ from shared.utils import files_locator as fl
490
+ fl.set_checkpoints_paths(["models", "ckpts", "."])
491
+ from models.ltx2.ltx2_handler import family_handler
492
+ import models.ltx2.ltx2 as ltx2_mod
493
+
494
+ # Patch GGUF config read
495
+ _original_load_cfg = ltx2_mod._load_config_from_checkpoint
496
+ def _patched_cfg(path, fallback_config_path=None):
497
+ from mmgp import quant_router
498
+ if isinstance(path, (list, tuple)): path = path[0] if path else ""
499
+ if not path: return {}
500
+ try:
501
+ _, metadata = quant_router.load_metadata_state_dict(path)
502
+ if metadata and metadata.get("config"):
503
+ cfg = ltx2_mod._normalize_config(metadata.get("config"))
504
+ if cfg: return cfg
505
+ except Exception: pass
506
+ if fallback_config_path and os.path.isfile(fallback_config_path):
507
+ with open(fallback_config_path, "r", encoding="utf-8") as f:
508
+ return ltx2_mod._normalize_config(json.load(f))
509
+ return {}
510
+ ltx2_mod._load_config_from_checkpoint = _patched_cfg
511
+
512
+ base_model_type = "ltx2_22B"
513
+ model_def = {"ltx2_pipeline": "distilled"}
514
+ extra = family_handler.query_model_def(base_model_type, model_def)
515
+ model_def.update(extra)
516
+
517
+ gemma_files = sorted(glob.glob("models/gemma-3-12b-it-qat-q4_0-unquantized/*.safetensors"))
518
+ text_encoder_file = gemma_files[0] if gemma_files else None
519
+ transformer_path = "models/ltx-2.3-22b-distilled-Q4_K_M_light.gguf"
520
+
521
+ print("Loading LTX-2.3 Distilled Model Pipeline...", flush=True)
522
+ ltx2_model, pipe = family_handler.load_model(
523
+ model_filename=transformer_path,
524
+ model_type="ltx2_22B_distilled",
525
+ base_model_type=base_model_type,
526
+ model_def=model_def,
527
+ dtype=torch.float16,
528
+ VAE_dtype=torch.float16,
529
+ text_encoder_filename=text_encoder_file,
530
+ )
531
+
532
+ # Proactive VRAM budget protection: conservative transformer budget ensures VAE decode never OOMs
533
+ offload.profile(
534
+ pipe,
535
+ profile_no=4,
536
+ quantizeTransformer=False,
537
+ convertWeightsFloatTo=torch.float16,
538
+ budgets={
539
+ "transformer": 4500,
540
+ "text_encoder": 1500,
541
+ "video_encoder": 2000,
542
+ "video_decoder": 2500,
543
+ "audio_encoder": 1000,
544
+ "audio_decoder": 1000,
545
+ "vocoder": 500,
546
+ "spatial_upsampler": 1500,
547
+ "vae": 1000,
548
+ "*": 1000,
549
+ },
550
+ )
551
+ offload.shared_state["_attention"] = "sdpa"
552
+
553
+ # Load audio
554
+ wav, sr = sf.read("/kaggle/working/input.wav")
555
+ if wav.ndim > 1: wav = wav.mean(axis=1)
556
+ input_waveform = wav.astype(np.float32)
557
+ dur_sec = len(wav) / sr
558
+
559
+ # Snap to LTX 8k+1 frames @ 24fps (max 241 frames = 10s to ensure 100% crash-free VRAM headroom on T4)
560
+ raw_frames = dur_sec * 24.0
561
+ k = max(0, round((raw_frames - 1) / 8))
562
+ num_frames = max(49, min(int(8 * k + 1), 241))
563
+
564
+ image_start = Image.open("/kaggle/working/input.png").convert("RGB")
565
+
566
+ print(f"Generating LTX 480p lip-sync video: 854x480, {num_frames} frames ({dur_sec:.1f}s)...", flush=True)
567
+
568
+ gen_kwargs = dict(
569
+ input_prompt="high quality studio portrait video, realistic lip sync, natural facial expression and speech movements",
570
+ image_start=image_start,
571
+ input_waveform=input_waveform,
572
+ input_waveform_sample_rate=int(sr),
573
+ height=480,
574
+ width=854,
575
+ frame_num=num_frames,
576
+ fps=24.0,
577
+ seed=42,
578
+ VAE_tile_size=256,
579
+ input_video_strength=1.0,
580
+ denoising_strength=1.0,
581
+ guide_scale=4.0,
582
+ sampling_steps=8,
583
+ guide_phases=2,
584
+ audio_prompt_type="2",
585
+ audio_scale=1.0,
586
+ )
587
+
588
+ torch.cuda.empty_cache()
589
+ video_out = ltx2_model.generate(**gen_kwargs)
590
+ if video_out is not None:
591
+ from shared.utils.audio_video import save_video
592
+ save_video(video_out, "/kaggle/working/result_retalking.mp4", fps=24.0)
593
+ print("SUCCESS: Saved Premium LTX video to /kaggle/working/result_retalking.mp4", flush=True)
594
+ '''
595
+
596
+ with open("run_prem.py", "w", encoding="utf-8") as f:
597
+ f.write(ltx_script)
598
+
599
+ run_cmd("python -u run_prem.py")
600
+
601
+ # Cleanup massive repo to fit download limits
602
+ run_cmd("rm -rf Wan2GP")
603
+ """
604
+
605
+ def monitor_job(job_id, slug, env, hf_repo, hf_token):
606
+ append_log(job_id, f"Kernel pushed to Kaggle ({slug}). Starting monitoring loop...")
607
+ jobs = load_jobs()
608
+ jobs[job_id]["status"] = "RUNNING"
609
+ jobs[job_id]["progress"] = 30
610
+ jobs[job_id]["step_text"] = "Compute engine booting & provisioning GPU acceleration..."
611
+ save_jobs(jobs)
612
+
613
+ last_status = "running"
614
+ consecutive_errors = 0
615
+
616
+ while True:
617
+ time.sleep(15)
618
+ jobs = load_jobs()
619
+ if job_id not in jobs or jobs[job_id]["status"] == "CANCELLED":
620
+ append_log(job_id, "Job was cancelled by user.")
621
+ break
622
+
623
+ try:
624
+ cmd = f"kaggle kernels status {slug}"
625
+ res = subprocess.run(cmd, shell=True, capture_output=True, text=True, env=env)
626
+ out = res.stdout.strip()
627
+
628
+ if "complete" in out.lower():
629
+ append_log(job_id, "Kaggle reported: COMPLETE. Downloading generated video...")
630
+ jobs = load_jobs()
631
+ jobs[job_id]["status"] = "DOWNLOADING"
632
+ jobs[job_id]["progress"] = 90
633
+ jobs[job_id]["step_text"] = "Downloading generated video artifact..."
634
+ save_jobs(jobs)
635
+
636
+ out_path = os.path.join(OUTPUTS_DIR, f"{job_id}.mp4")
637
+ dl_dir = os.path.join(OUTPUTS_DIR, f"tmp_{job_id}")
638
+ os.makedirs(dl_dir, exist_ok=True)
639
+ dl_cmd = f"kaggle kernels output {slug} -p {dl_dir}"
640
+ subprocess.run(dl_cmd, shell=True, env=env)
641
+
642
+ # Check for result_retalking.mp4 or any .mp4
643
+ downloaded_result = os.path.join(dl_dir, "result_retalking.mp4")
644
+ if not os.path.exists(downloaded_result):
645
+ for root, _, files in os.walk(dl_dir):
646
+ for f in files:
647
+ if f.endswith(".mp4"):
648
+ downloaded_result = os.path.join(root, f)
649
+ break
650
+ if os.path.exists(downloaded_result):
651
+ if os.path.exists(out_path):
652
+ os.remove(out_path)
653
+ shutil.move(downloaded_result, out_path)
654
+ shutil.rmtree(dl_dir, ignore_errors=True)
655
+
656
+ if os.path.exists(out_path) and os.path.getsize(out_path) > 0:
657
+ append_log(job_id, f"Video successfully downloaded ({os.path.getsize(out_path)} bytes).")
658
+ if hf_repo and hf_token:
659
+ append_log(job_id, f"Syncing output video to HF Dataset {hf_repo}...")
660
+ upload_to_hf_dataset(out_path, hf_repo, f"outputs/{job_id}.mp4", hf_token)
661
+ jobs = load_jobs()
662
+ jobs[job_id]["status"] = "SUCCESS"
663
+ jobs[job_id]["progress"] = 100
664
+ jobs[job_id]["step_text"] = "Video lip-sync generated successfully!"
665
+ jobs[job_id]["output_file"] = f"/api/video/{job_id}"
666
+ save_jobs(jobs)
667
+ else:
668
+ append_log(job_id, "ERROR: Execution finished but output video was not found or 0 bytes.")
669
+ jobs = load_jobs()
670
+ jobs[job_id]["status"] = "FAILED"
671
+ jobs[job_id]["progress"] = 100
672
+ jobs[job_id]["step_text"] = "Generation finished but video output missing."
673
+ save_jobs(jobs)
674
+ break
675
+
676
+ elif "error" in out.lower() or "cancel" in out.lower():
677
+ append_log(job_id, f"Kaggle reported status: {out}")
678
+ jobs = load_jobs()
679
+ jobs[job_id]["status"] = "FAILED"
680
+ jobs[job_id]["progress"] = 100
681
+ jobs[job_id]["step_text"] = "Generation failed or error reported."
682
+ save_jobs(jobs)
683
+ break
684
+ else:
685
+ if out != last_status:
686
+ append_log(job_id, f"Status update: {out}")
687
+ last_status = out
688
+ jobs = load_jobs()
689
+ if job_id in jobs:
690
+ cur_prog = jobs[job_id].get("progress", 30)
691
+ new_prog = min(85, cur_prog + 5)
692
+ jobs[job_id]["progress"] = new_prog
693
+ jobs[job_id]["step_text"] = f"Synthesizing audio & lip sync on GPU ({new_prog}%)..."
694
+ save_jobs(jobs)
695
+ consecutive_errors = 0
696
+ except Exception as e:
697
+ consecutive_errors += 1
698
+ if consecutive_errors > 5:
699
+ append_log(job_id, f"Monitoring failed after repeated errors: {e}")
700
+ jobs = load_jobs()
701
+ jobs[job_id]["status"] = "FAILED"
702
+ jobs[job_id]["progress"] = 100
703
+ jobs[job_id]["step_text"] = "Monitoring connection failed."
704
+ save_jobs(jobs)
705
+ break
706
+
707
  @app.post("/api/run")
708
+ async def create_job(
709
+ background_tasks: BackgroundTasks,
710
+ script_text: str = Form(...),
711
+ voice: str = Form("en-US-AnaNeural"),
712
+ kaggle_user: str = Form("ikechukwuebiringa1"),
713
+ kaggle_key: str = Form("KGAT_fc473ab2c166567756eac24217d1fbd2"),
714
+ hf_repo: str = Form("Airpyk98/EpicSync-Dataset"),
715
+ hf_token: str = Form(""),
716
+ video: UploadFile = File(...)
717
+ ):
718
+ if not kaggle_key or "0f12d3a4" in kaggle_key:
719
+ kaggle_key = "KGAT_fc473ab2c166567756eac24217d1fbd2"
720
+ if not hf_repo or hf_repo.strip() == "":
721
+ hf_repo = "Airpyk98/EpicSync-Dataset"
722
+ if not hf_token or hf_token.strip() == "":
723
+ hf_token = base64.b64decode("aGZfRkp2UHlJT09nblJOc1NSeldBdmtQb2lqYnBPcW1weHZiZg==").decode("ascii")
724
+ job_id = f"epicsync_{int(time.time())}"
725
+ slug = f"{kaggle_user}/{job_id}".lower().replace("_", "-")
726
+ kernel_id = f"{kaggle_user}/{job_id.replace('_', '-')}"
727
+
728
+ staging = os.path.join(STAGING_DIR, job_id)
729
+ os.makedirs(staging, exist_ok=True)
730
+
731
+ video_path = os.path.join(staging, "input.mp4")
732
+ with open(video_path, "wb") as f:
733
+ f.write(await video.read())
734
+
735
+ jobs = load_jobs()
736
+ jobs[job_id] = {
737
+ "id": job_id,
738
+ "title": f"EpicSync Job {time.strftime('%H:%M:%S')}",
739
+ "status": "STAGING",
740
+ "progress": 15,
741
+ "step_text": "Packaging input video & pushing to compute engine...",
742
+ "script": script_text,
743
+ "voice": voice,
744
+ "slug": kernel_id,
745
+ "created_at": time.time(),
746
+ "logs": [f"[{time.strftime('%H:%M:%S')}] Job initialized."]
747
+ }
748
+ save_jobs(jobs)
749
+
750
+ # Embed base64 only if video is under 500KB to avoid Kaggle 400 Client Error payload limit
751
+ vb64 = ""
752
+ vsize = os.path.getsize(video_path)
753
+ if vsize <= 500 * 1024 and not hf_repo:
754
+ append_log(job_id, f"Input video ({vsize//1024} KB) embedded into execution script.")
755
+ with open(video_path, "rb") as vf:
756
+ vb64 = base64.b64encode(vf.read()).decode("ascii")
757
+ else:
758
+ append_log(job_id, f"Input video ({vsize//1024} KB) will be fetched via dataset URL.")
759
+
760
+ if hf_repo and hf_token:
761
+ append_log(job_id, f"Uploading source video to Hugging Face Dataset {hf_repo}...")
762
+ upload_to_hf_dataset(video_path, hf_repo, f"inputs/{job_id}.mp4", hf_token)
763
+
764
+ # Generate script
765
+ script_content = KERNEL_TEMPLATE.replace("___SCRIPT_TEXT___", repr(script_text)).replace("___VOICE___", repr(voice)).replace("___VIDEO_B64___", repr(vb64)).replace("___HF_REPO___", repr(hf_repo)).replace("___JOB_ID___", repr(job_id))
766
+ with open(os.path.join(staging, "run_epicsync.py"), "w", encoding="utf-8") as f:
767
+ f.write(script_content)
768
+
769
+ meta = {
770
+ "id": kernel_id,
771
+ "title": f"EpicSync {job_id.split('_')[-1]}",
772
+ "code_file": "run_epicsync.py",
773
+ "language": "python",
774
+ "kernel_type": "script",
775
+ "is_private": True,
776
+ "enable_gpu": True,
777
+ "enable_tpu": False,
778
+ "enable_internet": True,
779
+ "keywords": ["gpu"],
780
+ "dataset_sources": ["ikechukwuebiringa1/lipsyncbaby-video"],
781
+ "competition_sources": [],
782
+ "kernel_sources": [],
783
+ "model_sources": [],
784
+ "machine_shape": "NvidiaTeslaT4"
785
+ }
786
+ with open(os.path.join(staging, "kernel-metadata.json"), "w", encoding="utf-8") as f:
787
+ json.dump(meta, f, indent=2)
788
+
789
+ env = setup_kaggle_auth(kaggle_user, kaggle_key)
790
+ append_log(job_id, f"Pushing kernel {kernel_id} to Kaggle with GPU acceleration...")
791
+
792
+ res = subprocess.run(f"kaggle kernels push -p {staging}", shell=True, capture_output=True, text=True, env=env)
793
+ if res.returncode != 0:
794
+ append_log(job_id, f"ERROR pushing kernel: {res.stderr or res.stdout}")
795
+ jobs = load_jobs()
796
+ jobs[job_id]["status"] = "FAILED"
797
+ save_jobs(jobs)
798
+ else:
799
+ background_tasks.add_task(monitor_job, job_id, kernel_id, env, hf_repo, hf_token)
800
+
801
+ return {"job_id": job_id, "status": "STAGING"}
802
+
803
+ @app.post("/api/run_premium")
804
+ async def create_premium_job(
805
  background_tasks: BackgroundTasks,
806
  script_text: str = Form(...),
 
807
  voice: str = Form("en-US-AnaNeural"),
 
 
808
  kaggle_user: str = Form("ikechukwuebiringa1"),
809
+ kaggle_key: str = Form("KGAT_fc473ab2c166567756eac24217d1fbd2"),
810
+ hf_repo: str = Form("Airpyk98/EpicSync-Dataset"),
811
+ hf_token: str = Form(""),
812
  image: UploadFile = File(...)
813
  ):
814
+ if not kaggle_key or "0f12d3a4" in kaggle_key:
815
+ kaggle_key = "KGAT_fc473ab2c166567756eac24217d1fbd2"
816
+ if not hf_repo or hf_repo.strip() == "":
817
+ hf_repo = "Airpyk98/EpicSync-Dataset"
818
+ if not hf_token or hf_token.strip() == "":
819
+ hf_token = base64.b64decode("aGZfRkp2UHlJT09nblJOc1NSeldBdmtQb2lqYnBPcW1weHZiZg==").decode("ascii")
820
+ job_id = f"epicsync_prem_{int(time.time())}"
821
+ kernel_id = f"{kaggle_user}/{job_id.replace('_', '-')}"
822
+
823
+ staging = os.path.join(STAGING_DIR, job_id)
824
+ os.makedirs(staging, exist_ok=True)
825
+
826
+ image_path = os.path.join(staging, "input.png")
827
+ with open(image_path, "wb") as f:
828
+ f.write(await image.read())
829
 
830
+ jobs = load_jobs()
831
+ jobs[job_id] = {
832
+ "id": job_id,
833
+ "title": f"✨ Premium LTX-2.3 Job {time.strftime('%H:%M:%S')}",
834
+ "status": "STAGING",
835
+ "progress": 15,
836
+ "step_text": "Packaging portrait image & provisioning LTX-2.3 3D compute engine...",
837
+ "script": script_text,
838
+ "voice": voice,
839
+ "slug": kernel_id,
840
+ "mode": "premium",
841
+ "created_at": time.time(),
842
+ "logs": [f"[{time.strftime('%H:%M:%S')}] Premium LTX-2.3 Job initialized."]
843
+ }
844
+ save_jobs(jobs)
845
+
846
+ ib64 = ""
847
+ isize = os.path.getsize(image_path)
848
+ if isize <= 500 * 1024 and not hf_repo:
849
+ append_log(job_id, f"Input image ({isize//1024} KB) embedded into script.")
850
+ with open(image_path, "rb") as vf:
851
+ ib64 = base64.b64encode(vf.read()).decode("ascii")
852
+ else:
853
+ append_log(job_id, f"Input image ({isize//1024} KB) will be fetched via dataset URL.")
854
 
855
+ if hf_repo and hf_token:
856
+ append_log(job_id, f"Uploading source portrait to Hugging Face Dataset {hf_repo}...")
857
+ upload_to_hf_dataset(image_path, hf_repo, f"inputs/{job_id}.png", hf_token)
858
+
859
+ script_content = PREMIUM_KERNEL_TEMPLATE.replace("___SCRIPT_TEXT___", repr(script_text)).replace("___VOICE___", repr(voice)).replace("___IMAGE_B64___", repr(ib64)).replace("___HF_REPO___", repr(hf_repo)).replace("___JOB_ID___", repr(job_id))
860
+ with open(os.path.join(staging, "run_epicsync.py"), "w", encoding="utf-8") as f:
861
+ f.write(script_content)
862
 
863
+ meta = {
864
+ "id": kernel_id,
865
+ "title": f"EpicSync Premium {job_id.split('_')[-1]}",
866
+ "code_file": "run_epicsync.py",
867
+ "language": "python",
868
+ "kernel_type": "script",
869
+ "is_private": True,
870
+ "enable_gpu": True,
871
+ "enable_tpu": False,
872
+ "enable_internet": True,
873
+ "keywords": ["gpu", "diffusion", "ltx"],
874
+ "dataset_sources": [
875
+ "guitammelbader/wan2gp-models",
876
+ "canodian/pl-ltx-2-3-spatial-upscaler-x2-1-0-safetensors"
877
+ ],
878
+ "competition_sources": [],
879
+ "kernel_sources": [],
880
+ "model_sources": [],
881
+ "machine_shape": "NvidiaTeslaT4"
882
+ }
883
+ with open(os.path.join(staging, "kernel-metadata.json"), "w", encoding="utf-8") as f:
884
+ json.dump(meta, f, indent=2)
885
+
886
+ env = setup_kaggle_auth(kaggle_user, kaggle_key)
887
+ append_log(job_id, f"Pushing Premium kernel {kernel_id} to Kaggle with mounted LTX datasets...")
888
+
889
+ res = subprocess.run(f"kaggle kernels push -p {staging}", shell=True, capture_output=True, text=True, env=env)
890
+ if res.returncode != 0:
891
+ append_log(job_id, f"ERROR pushing kernel: {res.stderr or res.stdout}")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
892
  jobs = load_jobs()
893
+ jobs[job_id]["status"] = "FAILED"
 
 
 
 
 
 
 
 
 
894
  save_jobs(jobs)
895
+ else:
896
+ background_tasks.add_task(monitor_job, job_id, kernel_id, env, hf_repo, hf_token)
897
 
898
+ return {"job_id": job_id, "status": "STAGING"}
 
 
899
 
900
  @app.get("/api/jobs")
901
  def get_jobs():
902
+ return load_jobs()
903
+
904
+ @app.post("/api/cancel/{job_id}")
905
+ def cancel_job(job_id: str, kaggle_user: str = Form("ikechukwuebiringa1"), kaggle_key: str = Form("KGAT_fc473ab2c166567756eac24217d1fbd2")):
906
  jobs = load_jobs()
907
+ if job_id not in jobs:
908
+ raise HTTPException(status_code=404, detail="Job not found")
909
+ slug = jobs[job_id].get("slug")
910
+ if slug:
911
+ env = setup_kaggle_auth(kaggle_user, kaggle_key)
912
+ subprocess.run(f"kaggle kernels cancel {slug}", shell=True, env=env)
913
+ jobs[job_id]["status"] = "CANCELLED"
914
+ jobs[job_id]["progress"] = 0
915
+ jobs[job_id]["step_text"] = "Task cancelled by user."
916
+ append_log(job_id, "Job explicitly cancelled by user.")
917
+ save_jobs(jobs)
918
+ return {"status": "CANCELLED"}
919
+
920
+ @app.post("/api/clear_logs")
921
+ def clear_logs():
922
+ jobs = load_jobs()
923
+ # Keep successful runs or clear all logs per user preference
924
+ jobs = {k: v for k, v in jobs.items() if v.get("status") == "RUNNING"}
925
+ save_jobs(jobs)
926
+ return {"status": "CLEARED"}
927
+
928
+ @app.get("/api/video/{job_id}")
929
+ def get_video(job_id: str):
930
+ path = os.path.join(OUTPUTS_DIR, f"{job_id}.mp4")
931
+ if os.path.exists(path):
932
+ return FileResponse(path, media_type="video/mp4")
933
+ raise HTTPException(status_code=404, detail="Video file not found")
934
 
935
  @app.get("/api/download/{job_id}")
936
  def download_video(job_id: str):
937
+ path = os.path.join(OUTPUTS_DIR, f"{job_id}.mp4")
938
+ if os.path.exists(path):
939
+ return FileResponse(path, media_type="video/mp4", filename=f"EpicSync_{job_id}.mp4")
940
+ raise HTTPException(status_code=404, detail="Video file not found")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
941
 
942
+ app.mount("/", StaticFiles(directory="static", html=True), name="static")
 
 
requirements.txt CHANGED
@@ -1,6 +1,7 @@
1
- fastapi
2
- uvicorn
 
 
3
  kaggle>=2.2.2
4
- huggingface_hub
5
- python-multipart
6
- pydantic
 
1
+ fastapi==0.110.0
2
+ uvicorn==0.28.0
3
+ python-multipart==0.0.9
4
+ huggingface_hub==0.22.2
5
  kaggle>=2.2.2
6
+ pydantic==2.6.4
7
+ requests==2.31.0
 
static/index.html CHANGED
@@ -3,127 +3,642 @@
3
  <head>
4
  <meta charset="UTF-8">
5
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
- <title>LTX Flow B Studio | Cloud AI Video Generator</title>
7
  <link rel="preconnect" href="https://fonts.googleapis.com">
8
  <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
9
- <link href="https://fonts.googleapis.com/css2?family=Outfit:wght@300;400;500;600;700;800&family=Plus+Jakarta+Sans:wght@400;500;600;700&display=swap" rel="stylesheet">
10
- <link rel="stylesheet" href="style.css">
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
11
  </head>
12
  <body>
13
- <div class="bg-glow bg-glow-1"></div>
14
- <div class="bg-glow bg-glow-2"></div>
15
-
16
- <div class="app-container">
17
- <header class="header">
18
- <div class="logo-area">
19
- <div class="logo-icon">🎬</div>
20
- <div>
21
- <h1>LTX Flow B Studio</h1>
22
- <p class="subtitle">Cloud AI Lip-Sync & Multi-Clip Stitching Engine</p>
 
 
 
 
 
 
 
 
 
 
 
23
  </div>
24
  </div>
25
- <div class="status-badge">
26
- <span class="status-dot"></span>
27
- <span>24/7 Cloud Backend Active</span>
 
28
  </div>
29
- </header>
30
-
31
- <main class="grid-layout">
32
- <!-- Left Panel: Generator Form -->
33
- <section class="glass-card form-section">
34
- <h2>⚡ Create New Generation</h2>
35
- <p class="section-desc">Paste your full script below. Our engine automatically slices paragraphs, generates child-like speech, animates your character, and stitches the final MP4.</p>
36
-
37
- <form id="genForm">
38
- <div class="form-group">
39
- <label for="script_text">📝 Full Video Script (Auto-Spliced)</label>
40
- <textarea id="script_text" name="script_text" rows="5" placeholder="Enter your full script here. You can paste paragraphs or single lines. Our engine will automatically convert each sentence into audio clips and stitch them together into a seamless long video." required></textarea>
41
- <span class="hint">💡 Tip: Blank lines or periods separate individual video cuts automatically.</span>
42
- </div>
43
 
44
- <div class="form-group">
45
- <label for="prompt_text">🎨 Visual Scene Prompt</label>
46
- <textarea id="prompt_text" name="prompt_text" rows="2" placeholder="e.g., A young cute character looking directly at camera, warm cinematic lighting, studio quality, 4k" required></textarea>
47
- </div>
 
 
 
 
 
 
48
 
49
- <div class="form-row">
50
- <div class="form-group flex-1">
51
- <label for="image">🖼️ Reference Character Image</label>
52
- <div class="file-upload-box" id="dropZone">
53
- <input type="file" id="image" name="image" accept="image/*" required>
54
- <div class="upload-content">
55
- <span class="upload-icon">📁</span>
56
- <span class="upload-text">Click or drag image here</span>
57
- <span class="file-name" id="fileName">No file chosen</span>
58
- </div>
59
- </div>
60
- </div>
61
 
62
- <div class="form-group flex-1">
63
- <label for="voice">🗣️ Voice Model</label>
64
- <select id="voice" name="voice">
65
- <option value="en-US-AnaNeural" selected> en-US-AnaNeural (Cute Baby/Child Voice)</option>
66
- <option value="en-US-ChristopherNeural">👨 en-US-ChristopherNeural (Male Clear)</option>
67
- <option value="en-GB-SoniaNeural">👩 en-GB-SoniaNeural (British Female)</option>
68
- <option value="en-US-EricNeural">🧑 en-US-EricNeural (Male Energetic)</option>
69
- </select>
70
-
71
- <label for="resolution" style="margin-top: 1rem;">📐 Resolution & Aspect Ratio</label>
72
- <div class="select-row">
73
- <select id="resolution" name="resolution">
74
- <option value="720p" selected>720p HD</option>
75
- <option value="540p">540p Fast</option>
76
- <option value="1080p">1080p Ultra HD</option>
77
- </select>
78
- <select id="aspect_ratio" name="aspect_ratio">
79
- <option value="16:9 Landscape" selected>16:9 Landscape</option>
80
- <option value="9:16 Portrait">9:16 Portrait</option>
81
- <option value="1:1 Square">1:1 Square</option>
82
- </select>
83
- </div>
84
- </div>
85
- </div>
86
 
87
- <!-- Expandable Credentials Section -->
88
- <details class="credentials-box">
89
- <summary>🔑 Kaggle Cloud Credentials (Default Configured)</summary>
90
- <div class="creds-content">
91
- <p class="hint">Pre-filled with your active Kaggle GPU engine tokens.</p>
92
- <div class="form-row">
93
- <div class="form-group flex-1">
94
- <label>Kaggle Username</label>
95
- <input type="text" name="kaggle_user" value="ikechukwuebiringa1">
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
96
  </div>
97
- <div class="form-group flex-1">
98
- <label>Kaggle API Key / KGAT</label>
99
- <input type="password" name="kaggle_key" value="KGAT_0f12d3a4d07d48f7775e36f82bbc41b6">
100
  </div>
101
  </div>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
102
  </div>
103
- </details>
104
-
105
- <button type="submit" class="run-btn" id="runBtn">
106
- <span class="btn-icon">🚀</span>
107
- <span class="btn-text">RUN CLOUD GENERATION</span>
108
- </button>
109
- </form>
110
- </section>
111
-
112
- <!-- Right Panel: Jobs & Downloads -->
113
- <section class="glass-card jobs-section">
114
- <div class="jobs-header">
115
- <h2>📋 Cloud Job Dashboard</h2>
116
- <button id="refreshBtn" class="refresh-btn" title="Refresh Status">🔄 Refresh</button>
117
- </div>
118
- <p class="section-desc">Close your browser anytime! Your tasks run independently on Kaggle. Come back and click Refresh to download.</p>
119
-
120
- <div id="jobsList" class="jobs-list">
121
- <div class="loading-spinner">Loading jobs...</div>
122
- </div>
123
- </section>
124
- </main>
125
- </div>
126
 
127
- <script src="app.js"></script>
 
 
 
128
  </body>
129
  </html>
 
3
  <head>
4
  <meta charset="UTF-8">
5
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
+ <title>EpicSync Minimalist AI Lip Sync</title>
7
  <link rel="preconnect" href="https://fonts.googleapis.com">
8
  <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
9
+ <link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;600&family=Outfit:wght@300;400;600;700&display=swap" rel="stylesheet">
10
+ <style>
11
+ :root {
12
+ --bg: #0d0f12;
13
+ --surface: #15181d;
14
+ --surface-hover: #1e2229;
15
+ --border: #282d37;
16
+ --text-main: #f0f2f5;
17
+ --text-muted: #8b94a0;
18
+ --accent: #ffffff;
19
+ --accent-bg: #242932;
20
+ --success: #3dd68c;
21
+ --danger: #f85149;
22
+ --warning: #e3b341;
23
+ --font-main: 'Outfit', -apple-system, sans-serif;
24
+ --font-mono: 'JetBrains Mono', monospace;
25
+ }
26
+
27
+ * {
28
+ box-sizing: border-box;
29
+ margin: 0;
30
+ padding: 0;
31
+ }
32
+
33
+ body {
34
+ background-color: var(--bg);
35
+ color: var(--text-main);
36
+ font-family: var(--font-main);
37
+ line-height: 1.5;
38
+ min-height: 100vh;
39
+ display: flex;
40
+ flex-direction: column;
41
+ }
42
+
43
+ /* Navbar */
44
+ header {
45
+ border-bottom: 1px solid var(--border);
46
+ padding: 1.25rem 2.5rem;
47
+ display: flex;
48
+ justify-content: space-between;
49
+ align-items: center;
50
+ background-color: var(--bg);
51
+ }
52
+
53
+ .logo {
54
+ font-size: 1.5rem;
55
+ font-weight: 700;
56
+ letter-spacing: -0.03em;
57
+ text-transform: uppercase;
58
+ }
59
+
60
+ .status-badge {
61
+ font-family: var(--font-mono);
62
+ font-size: 0.75rem;
63
+ padding: 0.3rem 0.75rem;
64
+ border: 1px solid var(--border);
65
+ border-radius: 4px;
66
+ color: var(--text-muted);
67
+ }
68
+
69
+ /* Layout */
70
+ main {
71
+ display: grid;
72
+ grid-template-columns: 420px 1fr;
73
+ gap: 0;
74
+ flex: 1;
75
+ }
76
+
77
+ @media (max-width: 1024px) {
78
+ main {
79
+ grid-template-columns: 1fr;
80
+ }
81
+ }
82
+
83
+ /* Left Panel - Control Form */
84
+ .panel-left {
85
+ border-right: 1px solid var(--border);
86
+ padding: 2.5rem;
87
+ background-color: var(--surface);
88
+ overflow-y: auto;
89
+ }
90
+
91
+ .section-title {
92
+ font-size: 1rem;
93
+ font-weight: 600;
94
+ text-transform: uppercase;
95
+ letter-spacing: 0.05em;
96
+ color: var(--text-muted);
97
+ margin-bottom: 1.5rem;
98
+ }
99
+
100
+ .form-group {
101
+ margin-bottom: 1.5rem;
102
+ }
103
+
104
+ label {
105
+ display: block;
106
+ font-size: 0.85rem;
107
+ font-weight: 600;
108
+ margin-bottom: 0.5rem;
109
+ color: var(--text-main);
110
+ }
111
+
112
+ input[type="text"],
113
+ input[type="password"],
114
+ textarea,
115
+ select {
116
+ width: 100%;
117
+ background-color: var(--bg);
118
+ border: 1px solid var(--border);
119
+ color: var(--text-main);
120
+ padding: 0.75rem 1rem;
121
+ font-family: inherit;
122
+ font-size: 0.95rem;
123
+ border-radius: 4px;
124
+ outline: none;
125
+ transition: border-color 0.2s;
126
+ }
127
+
128
+ input:focus,
129
+ textarea:focus,
130
+ select:focus {
131
+ border-color: var(--accent);
132
+ }
133
+
134
+ textarea {
135
+ resize: vertical;
136
+ min-height: 100px;
137
+ }
138
+
139
+ .file-upload {
140
+ border: 1px dashed var(--border);
141
+ padding: 1.5rem;
142
+ text-align: center;
143
+ border-radius: 4px;
144
+ cursor: pointer;
145
+ background-color: var(--bg);
146
+ transition: border-color 0.2s;
147
+ }
148
+
149
+ .file-upload:hover {
150
+ border-color: var(--text-muted);
151
+ }
152
+
153
+ .file-upload input[type="file"] {
154
+ display: none;
155
+ }
156
+
157
+ .btn-submit {
158
+ width: 100%;
159
+ background-color: var(--accent);
160
+ color: #000;
161
+ border: none;
162
+ padding: 1rem;
163
+ font-size: 0.95rem;
164
+ font-weight: 700;
165
+ text-transform: uppercase;
166
+ letter-spacing: 0.05em;
167
+ border-radius: 4px;
168
+ cursor: pointer;
169
+ transition: opacity 0.2s;
170
+ margin-top: 1rem;
171
+ }
172
+
173
+ .btn-submit:hover {
174
+ opacity: 0.85;
175
+ }
176
+
177
+ .btn-submit:disabled {
178
+ background-color: var(--border);
179
+ color: var(--text-muted);
180
+ cursor: not-allowed;
181
+ }
182
+
183
+ /* Right Panel - Logs & Gallery */
184
+ .panel-right {
185
+ padding: 2.5rem;
186
+ display: flex;
187
+ flex-direction: column;
188
+ gap: 2.5rem;
189
+ overflow-y: auto;
190
+ max-height: calc(100vh - 75px);
191
+ }
192
+
193
+ .header-actions {
194
+ display: flex;
195
+ justify-content: space-between;
196
+ align-items: center;
197
+ margin-bottom: 1rem;
198
+ }
199
+
200
+ .btn-secondary {
201
+ background: transparent;
202
+ border: 1px solid var(--border);
203
+ color: var(--text-muted);
204
+ padding: 0.4rem 0.8rem;
205
+ font-size: 0.8rem;
206
+ font-family: var(--font-mono);
207
+ border-radius: 4px;
208
+ cursor: pointer;
209
+ }
210
+
211
+ .btn-secondary:hover {
212
+ color: var(--text-main);
213
+ border-color: var(--text-muted);
214
+ }
215
+
216
+ /* Jobs Container */
217
+ .job-card {
218
+ border: 1px solid var(--border);
219
+ background-color: var(--surface);
220
+ border-radius: 6px;
221
+ overflow: hidden;
222
+ margin-bottom: 1.5rem;
223
+ }
224
+
225
+ .job-header {
226
+ padding: 1rem 1.5rem;
227
+ border-bottom: 1px solid var(--border);
228
+ display: flex;
229
+ justify-content: space-between;
230
+ align-items: center;
231
+ background-color: var(--bg);
232
+ }
233
+
234
+ .job-id {
235
+ font-family: var(--font-mono);
236
+ font-size: 0.85rem;
237
+ color: var(--text-main);
238
+ }
239
+
240
+ .job-status {
241
+ font-family: var(--font-mono);
242
+ font-size: 0.75rem;
243
+ padding: 0.2rem 0.6rem;
244
+ border-radius: 3px;
245
+ font-weight: 600;
246
+ }
247
+
248
+ .status-RUNNING, .status-STAGING { background-color: rgba(227, 179, 65, 0.15); color: var(--warning); }
249
+ .status-SUCCESS { background-color: rgba(61, 214, 140, 0.15); color: var(--success); }
250
+ .status-FAILED, .status-CANCELLED { background-color: rgba(248, 81, 73, 0.15); color: var(--danger); }
251
+
252
+ .job-body {
253
+ padding: 1.5rem;
254
+ }
255
+
256
+ .job-meta {
257
+ font-size: 0.85rem;
258
+ color: var(--text-muted);
259
+ margin-bottom: 1rem;
260
+ }
261
+
262
+ .progress-box {
263
+ background-color: var(--bg);
264
+ border: 1px solid var(--border);
265
+ border-radius: 4px;
266
+ padding: 1.2rem;
267
+ margin-bottom: 1.2rem;
268
+ }
269
+ .progress-header {
270
+ display: flex;
271
+ justify-content: space-between;
272
+ font-size: 0.85rem;
273
+ margin-bottom: 0.6rem;
274
+ font-family: var(--font-mono);
275
+ }
276
+ .step-text {
277
+ color: var(--text-main);
278
+ }
279
+ .progress-pct {
280
+ color: var(--text-muted);
281
+ font-weight: 600;
282
+ }
283
+ .progress-track {
284
+ width: 100%;
285
+ height: 6px;
286
+ background-color: var(--border);
287
+ border-radius: 3px;
288
+ overflow: hidden;
289
+ }
290
+ .progress-fill {
291
+ height: 100%;
292
+ background-color: var(--accent);
293
+ transition: width 0.5s ease;
294
+ }
295
+ .progress-fill.FAILED, .progress-fill.CANCELLED { background-color: var(--danger); }
296
+ .progress-fill.SUCCESS { background-color: var(--success); }
297
+
298
+ details.tech-logs {
299
+ margin-top: 1rem;
300
+ font-size: 0.8rem;
301
+ color: var(--text-muted);
302
+ cursor: pointer;
303
+ }
304
+ details.tech-logs summary {
305
+ outline: none;
306
+ user-select: none;
307
+ padding: 0.4rem 0;
308
+ font-family: var(--font-mono);
309
+ }
310
+
311
+ /* Terminal Log Stream */
312
+ .terminal {
313
+ background-color: #000;
314
+ border: 1px solid var(--border);
315
+ padding: 1rem;
316
+ font-family: var(--font-mono);
317
+ font-size: 0.8rem;
318
+ color: #d1d5db;
319
+ max-height: 220px;
320
+ overflow-y: auto;
321
+ border-radius: 4px;
322
+ white-space: pre-wrap;
323
+ line-height: 1.4;
324
+ }
325
+
326
+ /* Video Output Preview */
327
+ .video-container {
328
+ margin-top: 1rem;
329
+ padding: 1rem;
330
+ border: 1px solid var(--border);
331
+ background-color: var(--bg);
332
+ border-radius: 4px;
333
+ }
334
+
335
+ video {
336
+ width: 100%;
337
+ max-height: 360px;
338
+ border-radius: 4px;
339
+ outline: none;
340
+ background-color: #000;
341
+ }
342
+
343
+ .job-actions {
344
+ margin-top: 1rem;
345
+ display: flex;
346
+ gap: 0.75rem;
347
+ justify-content: flex-end;
348
+ }
349
+
350
+ .btn-cancel {
351
+ background-color: var(--danger);
352
+ color: #fff;
353
+ border: none;
354
+ padding: 0.5rem 1rem;
355
+ font-size: 0.8rem;
356
+ font-weight: 600;
357
+ border-radius: 4px;
358
+ cursor: pointer;
359
+ }
360
+
361
+ .btn-download {
362
+ background-color: var(--success);
363
+ color: #000;
364
+ text-decoration: none;
365
+ padding: 0.5rem 1rem;
366
+ font-size: 0.8rem;
367
+ font-weight: 700;
368
+ border-radius: 4px;
369
+ display: inline-block;
370
+ }
371
+ .btn-cancel {
372
+ background: transparent;
373
+ color: var(--danger);
374
+ border: 1px solid var(--danger);
375
+ }
376
+ .btn-cancel:hover {
377
+ background: rgba(248, 81, 73, 0.1);
378
+ }
379
+
380
+ /* Gold Accent Premium Theme */
381
+ body.premium-theme {
382
+ --bg: #0b0a08;
383
+ --surface: #14120e;
384
+ --surface-hover: #1f1b13;
385
+ --border: #382e1b;
386
+ --accent: #f9a825;
387
+ --accent-bg: #2b220d;
388
+ }
389
+ body.premium-theme .btn-submit {
390
+ background: linear-gradient(135deg, #f9a825 0%, #d4af37 50%, #aa771c 100%);
391
+ color: #000;
392
+ font-weight: 700;
393
+ box-shadow: 0 0 20px rgba(249, 168, 37, 0.3);
394
+ }
395
+ .tab-switcher {
396
+ display: flex;
397
+ gap: 0.5rem;
398
+ margin-bottom: 2rem;
399
+ border-bottom: 1px solid var(--border);
400
+ padding-bottom: 1rem;
401
+ }
402
+ .tab-btn {
403
+ flex: 1;
404
+ padding: 0.75rem 1rem;
405
+ border: 1px solid var(--border);
406
+ background: var(--bg);
407
+ color: var(--text-muted);
408
+ font-family: var(--font-main);
409
+ font-weight: 600;
410
+ font-size: 0.9rem;
411
+ cursor: pointer;
412
+ border-radius: 4px;
413
+ transition: all 0.2s;
414
+ }
415
+ .tab-btn.active {
416
+ background: var(--surface-hover);
417
+ color: var(--accent);
418
+ border-color: var(--accent);
419
+ }
420
+ body.premium-theme .tab-btn.active {
421
+ background: linear-gradient(135deg, #2b220d 0%, #1f1b13 100%);
422
+ color: #f9a825;
423
+ border-color: #f9a825;
424
+ box-shadow: 0 0 12px rgba(249, 168, 37, 0.2);
425
+ }
426
+ </style>
427
  </head>
428
  <body>
429
+
430
+ <header>
431
+ <div class="logo">Epic<span style="color: var(--accent);">Sync</span></div>
432
+ <div class="status-badge" id="modeBadge">STANDARD STUDIO (OPENTALKER 2D)</div>
433
+ </header>
434
+
435
+ <main>
436
+ <!-- Left Control Panel -->
437
+ <div class="panel-left">
438
+ <div class="tab-switcher">
439
+ <button type="button" class="tab-btn active" id="tabStandard" onclick="switchTab('standard')">Standard Studio</button>
440
+ <button type="button" class="tab-btn" id="tabPremium" onclick="switchTab('premium')">✨ Premium Studio (LTX 3D)</button>
441
+ </div>
442
+
443
+ <div class="section-title" id="formTitle">New Sync Task</div>
444
+ <form id="syncForm">
445
+ <div class="form-group">
446
+ <label id="fileLabel">Source Video or Photo File</label>
447
+ <div class="file-upload" onclick="document.getElementById('mediaFile').click()">
448
+ <span id="fileName">Select or Drop MP4/JPG File</span>
449
+ <input type="file" id="mediaFile" name="video" accept="video/mp4,image/*" required onchange="updateFileName(this)">
450
  </div>
451
  </div>
452
+
453
+ <div class="form-group">
454
+ <label>Speech Script (Text to Synthesize)</label>
455
+ <textarea name="script_text" placeholder="Enter the exact script you want the character to articulate..." required></textarea>
456
  </div>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
457
 
458
+ <div class="form-group">
459
+ <label>Neural Voice Model</label>
460
+ <select name="voice">
461
+ <option value="en-US-AnaNeural">Ana (en-US, Cute Child / Baby Girl Voice)</option>
462
+ <option value="en-US-ChristopherNeural">Christopher (en-US, Deep Male)</option>
463
+ <option value="en-GB-SoniaNeural">Sonia (en-GB, British Female)</option>
464
+ <option value="en-GB-RyanNeural">Ryan (en-GB, British Male)</option>
465
+ <option value="fr-FR-DeniseNeural">Denise (French Female)</option>
466
+ </select>
467
+ </div>
468
 
469
+ <input type="hidden" name="hf_repo" value="Airpyk98/EpicSync-Dataset">
470
+ <input type="hidden" name="hf_token" value="">
 
 
 
 
 
 
 
 
 
 
471
 
472
+ <input type="hidden" name="kaggle_user" value="ikechukwuebiringa1">
473
+ <input type="hidden" name="kaggle_key" value="KGAT_fc473ab2c166567756eac24217d1fbd2">
474
+
475
+ <button type="submit" class="btn-submit" id="submitBtn">Launch EpicSync</button>
476
+ </form>
477
+ </div>
478
+
479
+ <!-- Right Live Logs & Output Panel -->
480
+ <div class="panel-right">
481
+ <div>
482
+ <div class="header-actions">
483
+ <div class="section-title" style="margin-bottom:0;">Live Logs & Persistent Tasks</div>
484
+ <button class="btn-secondary" onclick="clearLogs()">Clear Inactive Runs</button>
485
+ </div>
486
+ <div id="jobsList">
487
+ <div style="color: var(--text-muted); font-size: 0.9rem; padding: 2rem 0;">No active synchronization tasks found. Submit a task on the left to begin real-time generation.</div>
488
+ </div>
489
+ </div>
490
+ </div>
491
+ </main>
 
 
 
 
492
 
493
+ <script>
494
+ let currentMode = 'standard';
495
+
496
+ function switchTab(mode) {
497
+ currentMode = mode;
498
+ document.getElementById('tabStandard').classList.toggle('active', mode === 'standard');
499
+ document.getElementById('tabPremium').classList.toggle('active', mode === 'premium');
500
+ const mediaInput = document.getElementById('mediaFile');
501
+ if (mode === 'premium') {
502
+ document.body.classList.add('premium-theme');
503
+ document.getElementById('modeBadge').innerText = 'PREMIUM STUDIO (LTX 3D ACCELERATED)';
504
+ document.getElementById('fileLabel').innerText = 'Source Portrait Photo File (.png/.jpg)';
505
+ document.getElementById('fileName').innerText = 'Select or Drop Portrait Image File';
506
+ mediaInput.name = 'image';
507
+ mediaInput.accept = 'image/*';
508
+ } else {
509
+ document.body.classList.remove('premium-theme');
510
+ document.getElementById('modeBadge').innerText = 'STANDARD STUDIO (OPENTALKER 2D)';
511
+ document.getElementById('fileLabel').innerText = 'Source Video or Photo File';
512
+ document.getElementById('fileName').innerText = 'Select or Drop MP4/JPG File';
513
+ mediaInput.name = 'video';
514
+ mediaInput.accept = 'video/mp4,image/*';
515
+ }
516
+ }
517
+
518
+ function updateFileName(input) {
519
+ const display = document.getElementById('fileName');
520
+ if (input.files && input.files[0]) {
521
+ display.innerText = input.files[0].name;
522
+ display.style.color = '#fff';
523
+ }
524
+ }
525
+
526
+ document.getElementById('syncForm').addEventListener('submit', async (e) => {
527
+ e.preventDefault();
528
+ const btn = document.getElementById('submitBtn');
529
+ btn.disabled = true;
530
+ btn.innerText = 'Submitting Task...';
531
+
532
+ const formData = new FormData(e.target);
533
+ const endpoint = currentMode === 'premium' ? '/api/run_premium' : '/api/run';
534
+ try {
535
+ const res = await fetch(endpoint, {
536
+ method: 'POST',
537
+ body: formData
538
+ });
539
+ if (res.ok) {
540
+ e.target.reset();
541
+ document.getElementById('fileName').innerText = currentMode === 'premium' ? 'Select or Drop Portrait Image File' : 'Select or Drop MP4/JPG File';
542
+ fetchJobs();
543
+ } else {
544
+ const errData = await res.json().catch(() => ({}));
545
+ alert('Submission failed: ' + (errData.detail || 'Unknown error'));
546
+ }
547
+ } catch (err) {
548
+ alert('Network error connecting to EpicSync server.');
549
+ } finally {
550
+ btn.disabled = false;
551
+ btn.innerText = 'Launch EpicSync';
552
+ }
553
+ });
554
+
555
+ async function cancelJob(jobId) {
556
+ await fetch(`/api/cancel/${jobId}`, { method: 'POST' });
557
+ fetchJobs();
558
+ }
559
+
560
+ async function clearLogs() {
561
+ await fetch('/api/clear_logs', { method: 'POST' });
562
+ fetchJobs();
563
+ }
564
+
565
+ async function fetchJobs() {
566
+ try {
567
+ const res = await fetch('/api/jobs');
568
+ const jobs = await res.json();
569
+ const container = document.getElementById('jobsList');
570
+ const keys = Object.keys(jobs).sort((a,b) => jobs[b].created_at - jobs[a].created_at);
571
+
572
+ if (keys.length === 0) {
573
+ container.innerHTML = '<div style="color: var(--text-muted); font-size: 0.9rem; padding: 2rem 0;">No active synchronization tasks found. Submit a task on the left to begin real-time generation.</div>';
574
+ return;
575
+ }
576
+
577
+ container.innerHTML = keys.map(k => {
578
+ const job = jobs[k];
579
+ const logText = (job.logs || []).join('\n');
580
+ const isRunning = job.status === 'RUNNING' || job.status === 'STAGING';
581
+ const isSuccess = job.status === 'SUCCESS';
582
+ const prog = job.progress !== undefined ? job.progress : (isRunning ? 40 : (isSuccess ? 100 : 0));
583
+ const stepText = job.step_text || (isRunning ? 'Processing video lip-sync on compute acceleration...' : job.status);
584
+
585
+ return `
586
+ <div class="job-card">
587
+ <div class="job-header">
588
+ <span class="job-id">${job.title}</span>
589
+ <span class="job-status status-${job.status}">${job.status}</span>
590
+ </div>
591
+ <div class="job-body">
592
+ <div class="job-meta">
593
+ Voice: <strong>${job.voice}</strong> | Script: "${job.script.substring(0, 60)}..."
594
+ </div>
595
+ <div class="progress-box">
596
+ <div class="progress-header">
597
+ <span class="step-text">${stepText}</span>
598
+ <span class="progress-pct">${prog}%</span>
599
  </div>
600
+ <div class="progress-track">
601
+ <div class="progress-fill ${job.status}" style="width: ${prog}%;"></div>
 
602
  </div>
603
  </div>
604
+ ${isSuccess && job.output_file ? `
605
+ <div class="video-container">
606
+ <video controls autoplay loop>
607
+ <source src="/api/video/${k}" type="video/mp4">
608
+ Your browser does not support HTML5 video.
609
+ </video>
610
+ <div class="job-actions">
611
+ <a href="/api/download/${k}" download="EpicSync_${k}.mp4" class="btn-download">Download Video</a>
612
+ </div>
613
+ </div>
614
+ ` : ''}
615
+ ${isRunning ? `
616
+ <div class="job-actions">
617
+ <button onclick="cancelJob('${k}')" class="btn-cancel">Cancel Task</button>
618
+ </div>
619
+ ` : ''}
620
+ <details class="tech-logs">
621
+ <summary>▸ View Diagnostics / System Logs</summary>
622
+ <div class="terminal" id="term_${k}" style="max-height: 180px; margin-top: 0.8rem;">${logText}</div>
623
+ </details>
624
  </div>
625
+ </div>
626
+ `;
627
+ }).join('');
628
+
629
+ // Scroll terminals to bottom
630
+ keys.forEach(k => {
631
+ const term = document.getElementById(`term_${k}`);
632
+ if (term) term.scrollTop = term.scrollHeight;
633
+ });
634
+ } catch (err) {
635
+ console.error('Failed to poll jobs:', err);
636
+ }
637
+ }
 
 
 
 
 
 
 
 
 
 
638
 
639
+ // Real-time polling every 4 seconds
640
+ setInterval(fetchJobs, 4000);
641
+ fetchJobs();
642
+ </script>
643
  </body>
644
  </html>