tester343 commited on
Commit
140bc0c
·
verified ·
1 Parent(s): 2ccb001

Update app_enhanced.py

Browse files
Files changed (1) hide show
  1. app_enhanced.py +230 -165
app_enhanced.py CHANGED
@@ -1,271 +1,336 @@
1
- import spaces # <--- CRITICAL: MUST BE LINE 1
2
  import os
3
- import torch
 
 
 
 
 
 
 
 
 
 
 
 
4
 
5
  # ======================================================
6
- # 🧠 ZEROGPU FUNCTIONS (Must be at top-level)
7
  # ======================================================
 
 
 
 
 
8
 
9
- @spaces.GPU(duration=120)
10
  def generate_comic_gpu(video_path, user_dir, frames_dir, metadata_path, target_pages, panels_per_page_req):
11
- import cv2
12
- import numpy as np
13
- import json
14
 
15
  cap = cv2.VideoCapture(video_path)
16
  if not cap.isOpened(): raise Exception("Cannot open video")
17
  fps = cap.get(cv2.CAP_PROP_FPS) or 25
18
  total_frames = int(cap.get(cv2.CAP_PROP_FRAME_COUNT))
19
  duration = total_frames / fps
20
-
21
- total_panels = int(target_pages) * int(panels_per_page_req)
22
- # Grab frames at even intervals
23
- timestamps = np.linspace(1, max(1, duration - 1), total_panels)
24
-
 
 
 
25
  frame_metadata = {}
26
- frame_files = []
27
 
28
- for i, ts in enumerate(timestamps):
29
- cap.set(cv2.CAP_PROP_POS_MSEC, ts * 1000)
 
30
  ret, frame = cap.read()
31
  if ret:
32
  fname = f"frame_{i:04d}.png"
33
- cv2.imwrite(os.path.join(frames_dir, fname), frame)
34
- frame_metadata[fname] = {'time': ts, 'dialogue': f"Dialogue {i+1}"}
35
- frame_files.append(fname)
 
36
  cap.release()
37
 
38
- with open(metadata_path, 'w') as f:
39
- json.dump(frame_metadata, f)
40
-
41
  pages = []
42
- for i in range(int(target_pages)):
43
- start = i * int(panels_per_page_req)
44
- subset = frame_files[start:start + int(panels_per_page_req)]
45
- if subset:
46
- pages.append({
47
- 'panels': [{'image': f} for f in subset],
48
- 'bubbles': [{'dialog': frame_metadata[f]['dialogue'], 'type': 'speech'} for f in subset]
49
- })
 
 
50
  return pages
51
 
52
  @spaces.GPU
53
  def regen_frame_gpu(video_path, frames_dir, metadata_path, fname, direction):
54
  import cv2
55
  import json
 
56
  with open(metadata_path, 'r') as f: meta = json.load(f)
57
  t = meta[fname]['time']
58
  cap = cv2.VideoCapture(video_path)
59
  fps = cap.get(cv2.CAP_PROP_FPS) or 25
60
- new_t = max(0, t + (1.0/fps * (1 if direction == 'forward' else -1)))
 
61
  cap.set(cv2.CAP_PROP_POS_MSEC, new_t * 1000)
62
  ret, frame = cap.read()
63
  cap.release()
64
  if ret:
65
  cv2.imwrite(os.path.join(frames_dir, fname), frame)
66
  meta[fname]['time'] = new_t
67
- with open(metadata_path, 'w') as f: json.dump(meta, f)
68
- return True
69
- return False
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
70
 
71
  # ======================================================
72
- # ⚙️ APP & STORAGE SETUP
73
  # ======================================================
74
- import time
75
- import threading
76
- import json
77
- import string
78
- import random
79
- import shutil
80
- from flask import Flask, jsonify, request, send_from_directory, send_file
81
-
82
  BASE_STORAGE_PATH = '/data' if os.path.exists('/data') else '.'
83
  BASE_USER_DIR = os.path.join(BASE_STORAGE_PATH, "userdata")
 
84
  os.makedirs(BASE_USER_DIR, exist_ok=True)
 
85
 
86
- app = Flask(__name__)
87
-
88
- class ComicBackend:
 
89
  def __init__(self, sid):
90
  self.sid = sid
91
- self.dir = os.path.join(BASE_USER_DIR, sid)
92
- self.v_path = os.path.join(self.dir, 'video.mp4')
93
- self.f_dir = os.path.join(self.dir, 'frames')
94
- self.o_dir = os.path.join(self.dir, 'output')
95
- os.makedirs(self.f_dir, exist_ok=True)
96
- os.makedirs(self.o_dir, exist_ok=True)
97
- self.meta_p = os.path.join(self.f_dir, 'metadata.json')
98
 
99
- def write_status(self, msg, prog):
100
- with open(os.path.join(self.o_dir, 'status.json'), 'w') as f:
101
- json.dump({'message': msg, 'progress': prog}, f)
102
-
103
- def run(self, pgs, ppp):
104
  try:
105
- self.write_status("Running ZeroGPU Task...", 25)
106
- data = generate_comic_gpu(self.v_path, self.dir, self.f_dir, self.meta_p, pgs, ppp)
107
- with open(os.path.join(self.o_dir, 'pages.json'), 'w') as f:
108
- json.dump(data, f)
109
  self.write_status("Complete!", 100)
110
  except Exception as e:
 
111
  self.write_status(f"Error: {str(e)}", -1)
112
 
 
 
 
 
113
  # ======================================================
114
- # 🌐 UI TEMPLATE (VERTICAL TILT)
115
  # ======================================================
 
 
116
  INDEX_HTML = '''
117
- <!DOCTYPE html><html><head><title>Vertical Tilt Comic</title>
118
- <script src="https://cdnjs.cloudflare.com/ajax/libs/html-to-image/1.11.11/html-to-image.min.js"></script>
119
- <style>
120
- body { font-family: 'Segoe UI', sans-serif; background: #1a1a1a; color: white; margin: 0; padding: 20px; }
121
- .page {
122
- width: 800px; height: 1000px; background: white; margin: 20px auto;
123
- position: relative; border: 8px solid black; overflow: hidden;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
124
  }
125
- .grid { width: 100%; height: 100%; position: relative; background: #000; }
126
- .panel { position: absolute; top:0; left:0; width: 100%; height: 100%; overflow: hidden; }
127
 
128
- /* VERTICAL SPLIT CLIP-PATH (Left/Right) */
129
- .layout-vertical .panel:nth-child(1) {
130
- z-index: 2;
131
- clip-path: polygon(0 0, var(--v-top, 45%) 0, var(--v-bottom, 55%) 100%, 0 100%);
132
  }
133
- .layout-vertical .panel:nth-child(2) {
134
- z-index: 1;
135
- clip-path: polygon(var(--v-top, 45%) 0, 100% 0, 100% 100%, var(--v-bottom, 55%) 100%);
 
 
 
136
  }
 
 
137
 
138
- .panel img { width: 100%; height: 100%; object-fit: cover; }
139
 
140
- /* HANDLES ON TOP AND BOTTOM */
141
- .handle {
142
- position: absolute; width: 32px; height: 32px; background: #ffdf00;
143
- border-radius: 50%; z-index: 100; cursor: ew-resize; border: 4px solid #000;
144
- box-shadow: 0 0 10px rgba(0,0,0,0.5);
145
- }
146
- .handle.top { top: -16px; left: var(--v-top, 45%); transform: translateX(-50%); }
147
- .handle.bottom { bottom: -16px; left: var(--v-bottom, 55%); transform: translateX(-50%); }
148
-
149
- .controls { position: fixed; left: 20px; top: 20px; background: #333; padding: 20px; border-radius: 12px; width: 220px; }
150
- button { width: 100%; padding: 10px; margin-top: 10px; cursor: pointer; font-weight: bold; border-radius: 5px; border: none; }
151
- .btn-gen { background: #00ff88; color: #000; }
152
- #status { color: #00ff88; font-family: monospace; margin-top: 10px; }
153
- </style></head><body>
154
-
155
- <div id="upload-ui">
156
- <div style="text-align:center; margin-top: 100px;">
157
- <h1>🎬 Vertical Tilt Comic Generator</h1>
158
- <input type="file" id="vid-file"><br><br>
159
- <button class="btn-gen" onclick="upload()">Generate Comic</button>
160
- <div id="status">Ready.</div>
161
  </div>
162
  </div>
163
 
164
- <div id="editor-ui" style="display:none">
165
- <div id="page-list"></div>
166
- <div class="controls">
167
- <h3>Editor</h3>
168
- <p style="font-size: 11px;">Drag the <b style="color:#ffdf00">yellow circles</b> on the TOP and BOTTOM edges to tilt the divider.</p>
169
- <button onclick="download()">📥 Download PNG</button>
170
- <button onclick="location.reload()" style="background:#ff4444; color:white;">Reset</button>
171
  </div>
172
  </div>
173
 
174
  <script>
175
- let sid = "S" + Math.random().toString(36).substr(2, 9);
176
- let dragging = null;
 
 
177
 
178
  async function upload() {
179
- const file = document.getElementById('vid-file').files[0];
180
- if(!file) return alert("Select video");
181
- const fd = new FormData(); fd.append('file', file);
182
- document.getElementById('status').innerText = "Uploading...";
183
-
184
  await fetch(`/uploader?sid=${sid}`, {method:'POST', body:fd});
185
- const check = setInterval(async () => {
186
  const r = await fetch(`/status?sid=${sid}`);
187
  const d = await r.json();
188
- document.getElementById('status').innerText = d.message;
189
- if(d.progress >= 100) { clearInterval(check); initEditor(); }
190
  }, 2000);
191
  }
192
 
193
- async function initEditor() {
194
  const r = await fetch(`/output/pages.json?sid=${sid}`);
195
- const data = await r.json();
196
- const list = document.getElementById('page-list');
197
- data.forEach(p => {
 
 
198
  const pageDiv = document.createElement('div');
199
- pageDiv.className = 'page';
200
  const grid = document.createElement('div');
201
- grid.className = 'grid layout-vertical';
202
- grid.style.setProperty('--v-top', '45%');
203
- grid.style.setProperty('--v-bottom', '55%');
204
 
205
- const hTop = document.createElement('div'); hTop.className = 'handle top';
206
- const hBot = document.createElement('div'); hBot.className = 'handle bottom';
 
 
207
 
208
- hTop.onmousedown = () => dragging = { grid, key: '--v-top' };
209
- hBot.onmousedown = () => dragging = { grid, key: '--v-bottom' };
210
-
211
  p.panels.forEach(pan => {
212
- const div = document.createElement('div');
213
- div.className = 'panel';
214
- div.innerHTML = `<img src="/frames/${pan.image}?sid=${sid}">`;
215
- grid.appendChild(div);
216
  });
217
 
218
- grid.appendChild(hTop); grid.appendChild(hBot);
219
  pageDiv.appendChild(grid);
220
- list.appendChild(pageDiv);
221
  });
222
- document.getElementById('upload-ui').style.display='none';
223
- document.getElementById('editor-ui').style.display='block';
224
  }
225
 
226
- window.onmousemove = (e) => {
227
- if(!dragging) return;
228
- const rect = dragging.grid.getBoundingClientRect();
229
- let x = ((e.clientX - rect.left) / rect.width) * 100;
230
- x = Math.max(5, Math.min(95, x)); // Constraints
231
- dragging.grid.style.setProperty(dragging.key, x + '%');
232
- };
233
- window.onmouseup = () => dragging = null;
234
-
235
- async function download() {
236
- const pages = document.querySelectorAll('.page');
237
- for(let i=0; i<pages.length; i++){
238
- const url = await htmlToImage.toPng(pages[i]);
239
- const a = document.createElement('a');
240
- a.download = `comic-page-${i+1}.png`; a.href = url; a.click();
241
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
242
  }
243
- </script></body></html>
244
- '''
245
 
246
- # ======================================================
247
- # 🚀 ROUTES
248
- # ======================================================
 
 
 
 
 
 
249
 
250
  @app.route('/')
251
  def index(): return INDEX_HTML
252
 
253
  @app.route('/uploader', methods=['POST'])
254
- def uploader():
255
  sid = request.args.get('sid')
256
  f = request.files['file']
257
- gen = ComicBackend(sid)
258
- f.save(gen.v_path)
259
  threading.Thread(target=gen.run, args=(2, 2)).start()
260
  return jsonify({'ok': True})
261
 
262
  @app.route('/status')
263
  def get_status():
264
  sid = request.args.get('sid')
265
- try:
266
- with open(os.path.join(BASE_USER_DIR, sid, 'output', 'status.json'), 'r') as f:
267
- return f.read()
268
- except: return jsonify({'message': 'Starting...', 'progress': 0})
269
 
270
  @app.route('/output/<path:filename>')
271
  def get_output(filename):
 
1
+ import spaces # <--- CRITICAL: MUST BE THE FIRST IMPORT
2
  import os
3
+ import time
4
+ import threading
5
+ import json
6
+ import traceback
7
+ import logging
8
+ import string
9
+ import random
10
+ import shutil
11
+ import cv2
12
+ import math
13
+ import numpy as np
14
+ import srt
15
+ from flask import Flask, jsonify, request, send_from_directory, send_file
16
 
17
  # ======================================================
18
+ # 🧠 GLOBAL GPU FUNCTIONS (Must be at top level for detection)
19
  # ======================================================
20
+ @spaces.GPU
21
+ def gpu_warmup():
22
+ import torch
23
+ print(f"✅ ZeroGPU Warmup: CUDA Available: {torch.cuda.is_available()}")
24
+ return True
25
 
26
+ @spaces.GPU(duration=300)
27
  def generate_comic_gpu(video_path, user_dir, frames_dir, metadata_path, target_pages, panels_per_page_req):
28
+ print(f"🚀 GPU Task Started: {video_path} | Pages: {target_pages} | Panels/Page: {panels_per_page_req}")
 
 
29
 
30
  cap = cv2.VideoCapture(video_path)
31
  if not cap.isOpened(): raise Exception("Cannot open video")
32
  fps = cap.get(cv2.CAP_PROP_FPS) or 25
33
  total_frames = int(cap.get(cv2.CAP_PROP_FRAME_COUNT))
34
  duration = total_frames / fps
35
+ cap.release()
36
+
37
+ # Simple frame extraction logic
38
+ target_pages = int(target_pages)
39
+ panels_per_page = int(panels_per_page_req)
40
+ total_panels_needed = target_pages * panels_per_page
41
+
42
+ times = np.linspace(1, max(1, duration-1), total_panels_needed)
43
  frame_metadata = {}
44
+ frame_files_ordered = []
45
 
46
+ cap = cv2.VideoCapture(video_path)
47
+ for i, t in enumerate(times):
48
+ cap.set(cv2.CAP_PROP_POS_MSEC, t * 1000)
49
  ret, frame = cap.read()
50
  if ret:
51
  fname = f"frame_{i:04d}.png"
52
+ p = os.path.join(frames_dir, fname)
53
+ cv2.imwrite(p, frame)
54
+ frame_metadata[fname] = {'dialogue': f"Dialogue {i+1}", 'time': t}
55
+ frame_files_ordered.append(fname)
56
  cap.release()
57
 
58
+ with open(metadata_path, 'w') as f: json.dump(frame_metadata, f, indent=2)
59
+
 
60
  pages = []
61
+ for i in range(target_pages):
62
+ start_idx = i * panels_per_page
63
+ end_idx = start_idx + panels_per_page
64
+ p_frames = frame_files_ordered[start_idx:end_idx]
65
+ if p_frames:
66
+ pg_panels = [{'image': f} for f in p_frames]
67
+ # Create a simple bubble for each
68
+ pg_bubbles = [{'dialog': frame_metadata[f]['dialogue'], 'type': 'speech', 'classes': 'speech-bubble speech tail-bottom'} for f in p_frames]
69
+ pages.append({'panels': pg_panels, 'bubbles': pg_bubbles})
70
+
71
  return pages
72
 
73
  @spaces.GPU
74
  def regen_frame_gpu(video_path, frames_dir, metadata_path, fname, direction):
75
  import cv2
76
  import json
77
+ if not os.path.exists(metadata_path): return {"success": False}
78
  with open(metadata_path, 'r') as f: meta = json.load(f)
79
  t = meta[fname]['time']
80
  cap = cv2.VideoCapture(video_path)
81
  fps = cap.get(cv2.CAP_PROP_FPS) or 25
82
+ offset = (1.0/fps) * (1 if direction == 'forward' else -1)
83
+ new_t = max(0, t + offset)
84
  cap.set(cv2.CAP_PROP_POS_MSEC, new_t * 1000)
85
  ret, frame = cap.read()
86
  cap.release()
87
  if ret:
88
  cv2.imwrite(os.path.join(frames_dir, fname), frame)
89
  meta[fname]['time'] = new_t
90
+ with open(metadata_path, 'w') as f: json.dump(meta, f, indent=2)
91
+ return {"success": True}
92
+ return {"success": False}
93
+
94
+ @spaces.GPU
95
+ def get_frame_at_ts_gpu(video_path, frames_dir, metadata_path, fname, ts):
96
+ import cv2
97
+ import json
98
+ cap = cv2.VideoCapture(video_path)
99
+ cap.set(cv2.CAP_PROP_POS_MSEC, float(ts) * 1000)
100
+ ret, frame = cap.read()
101
+ cap.release()
102
+ if ret:
103
+ cv2.imwrite(os.path.join(frames_dir, fname), frame)
104
+ if os.path.exists(metadata_path):
105
+ with open(metadata_path, 'r') as f: meta = json.load(f)
106
+ meta[fname]['time'] = float(ts)
107
+ with open(metadata_path, 'w') as f: json.dump(meta, f, indent=2)
108
+ return {"success": True}
109
+ return {"success": False}
110
 
111
  # ======================================================
112
+ # 💾 PERSISTENT STORAGE CONFIGURATION
113
  # ======================================================
 
 
 
 
 
 
 
 
114
  BASE_STORAGE_PATH = '/data' if os.path.exists('/data') else '.'
115
  BASE_USER_DIR = os.path.join(BASE_STORAGE_PATH, "userdata")
116
+ SAVED_COMICS_DIR = os.path.join(BASE_STORAGE_PATH, "saved_comics")
117
  os.makedirs(BASE_USER_DIR, exist_ok=True)
118
+ os.makedirs(SAVED_COMICS_DIR, exist_ok=True)
119
 
120
+ # ======================================================
121
+ # 💻 BACKEND CLASS
122
+ # ======================================================
123
+ class EnhancedComicGenerator:
124
  def __init__(self, sid):
125
  self.sid = sid
126
+ self.user_dir = os.path.join(BASE_USER_DIR, sid)
127
+ self.video_path = os.path.join(self.user_dir, 'uploaded.mp4')
128
+ self.frames_dir = os.path.join(self.user_dir, 'frames')
129
+ self.output_dir = os.path.join(self.user_dir, 'output')
130
+ os.makedirs(self.frames_dir, exist_ok=True)
131
+ os.makedirs(self.output_dir, exist_ok=True)
132
+ self.metadata_path = os.path.join(self.frames_dir, 'frame_metadata.json')
133
 
134
+ def run(self, target_pages, panels_per_page):
 
 
 
 
135
  try:
136
+ self.write_status("GPU Extracting...", 10)
137
+ data = generate_comic_gpu(self.video_path, self.user_dir, self.frames_dir, self.metadata_path, int(target_pages), int(panels_per_page))
138
+ with open(os.path.join(self.output_dir, 'pages.json'), 'w') as f: json.dump(data, f, indent=2)
 
139
  self.write_status("Complete!", 100)
140
  except Exception as e:
141
+ traceback.print_exc()
142
  self.write_status(f"Error: {str(e)}", -1)
143
 
144
+ def write_status(self, msg, prog):
145
+ with open(os.path.join(self.output_dir, 'status.json'), 'w') as f:
146
+ json.dump({'message': msg, 'progress': prog}, f)
147
+
148
  # ======================================================
149
+ # 🌐 ROUTES & FULL UI
150
  # ======================================================
151
+ app = Flask(__name__)
152
+
153
  INDEX_HTML = '''
154
+ <!DOCTYPE html><html lang="en"> <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <title>🎬 Vertical Tilt Comic Generator</title> <script src="https://cdnjs.cloudflare.com/ajax/libs/html-to-image/1.11.11/html-to-image.min.js"></script> <link href="https://fonts.googleapis.com/css2?family=Bangers&family=Comic+Neue:wght@700&family=Gloria+Hallelujah&family=Lato&display=swap" rel="stylesheet"> <style> * { box-sizing: border-box; } body { background-color: #fdf6e3; font-family: 'Lato', sans-serif; color: #3d3d3d; margin: 0; min-height: 100vh; }
155
+
156
+ #upload-container { display: flex; flex-direction:column; justify-content: center; align-items: center; min-height: 100vh; width: 100%; padding: 20px; }
157
+ .upload-box { max-width: 500px; width: 100%; padding: 40px; background: white; border-radius: 12px; box-shadow: 0 8px 30px rgba(0,0,0,0.12); text-align: center; }
158
+ #editor-container { display: none; padding: 20px; width: 100%; box-sizing: border-box; padding-bottom: 100px; }
159
+
160
+ h1 { color: #2c3e50; margin-bottom: 20px; font-weight: 600; }
161
+ .file-label { display: block; padding: 15px; background: #2c3e50; color: white; border-radius: 8px; cursor: pointer; font-weight: bold; margin-bottom: 10px; }
162
+ .submit-btn { width: 100%; padding: 15px; background: #e67e22; color: white; border: none; border-radius: 8px; font-size: 18px; font-weight: bold; cursor: pointer; }
163
+
164
+ /* COMIC LAYOUT */
165
+ .comic-page { background: white; width: 864px; height: 1080px; box-shadow: 0 4px 10px rgba(0,0,0,0.1); position: relative; overflow: hidden; border: 2px solid #000; padding: 10px; margin: 0 auto; }
166
+ .comic-grid { width: 100%; height: 100%; position: relative; background: #000; display: block; }
167
+ .panel { position: absolute; overflow: hidden; background: #000; cursor: pointer; }
168
+
169
+ /* === VERTICAL TILT LAYOUT (SIDE BY SIDE) === */
170
+ .comic-grid.layout-custom-slant .panel { border: none; background: transparent; width: 100%; height: 100%; top:0; left:0; }
171
+
172
+ /* Left Panel (1) */
173
+ .comic-grid.layout-custom-slant .panel:nth-child(1) {
174
+ z-index:2;
175
+ clip-path: polygon(0 0, var(--split-t, 45%) 0, var(--split-b, 55%) 100%, 0 100%);
176
  }
 
 
177
 
178
+ /* Right Panel (2) */
179
+ .comic-grid.layout-custom-slant .panel:nth-child(2) {
180
+ z-index:1;
181
+ clip-path: polygon(var(--split-t, 45%) 0, 100% 0, 100% 100%, var(--split-b, 55%) 100%);
182
  }
183
+
184
+ /* DRAG HANDLES ON TOP AND BOTTOM */
185
+ .split-handle {
186
+ position: absolute; width: 26px; height: 26px;
187
+ background: #FF9800; border: 3px solid white; border-radius: 50%;
188
+ cursor: ew-resize; z-index: 1000; box-shadow: 0 2px 5px rgba(0,0,0,0.4);
189
  }
190
+ .split-handle.top { top: -13px; left: var(--split-t, 45%); transform: translateX(-50%); }
191
+ .split-handle.bottom { bottom: -13px; left: var(--split-b, 55%); transform: translateX(-50%); }
192
 
193
+ .panel img { width: 100%; height: 100%; object-fit: cover; transform-origin: center center; pointer-events: auto; }
194
 
195
+ /* BUBBLES */
196
+ .speech-bubble { position: absolute; display: flex; justify-content: center; align-items: center; width: 150px; height: 80px; z-index: 10; cursor: move; font-family: 'Comic Neue', cursive; font-weight: bold; font-size: 13px; text-align: center; transform: translate(-50%, -50%); }
197
+ .bubble-text { padding: 0.5em; width: 100%; height: 100%; display: flex; align-items: center; justify-content: center; background: #4ECDC4; color: white; border-radius: 1.2em; border: 2px solid black; }
198
+
199
+ .edit-controls { position: fixed; bottom: 20px; right: 20px; width: 260px; background: rgba(44, 62, 80, 0.95); color: white; padding: 15px; border-radius: 8px; z-index: 900; }
200
+ button { width: 100%; margin-top: 5px; padding: 8px; border-radius: 4px; border: none; cursor: pointer; font-weight: bold; }
201
+ </style>
202
+ </head> <body>
203
+ <div id="upload-container">
204
+ <div class="upload-box">
205
+ <h1>🎬 Vertical Tilt Comic Gen</h1>
206
+ <input type="file" id="file-upload" style="display:none" onchange="document.getElementById('fn').innerText=this.files[0].name">
207
+ <label for="file-upload" class="file-label">📁 Choose Video</label>
208
+ <span id="fn">No file selected</span>
209
+ <button class="submit-btn" onclick="upload()">🚀 Generate</button>
210
+ <div id="loading" style="display:none; margin-top:15px;">
211
+ <p id="status-text">Processing...</p>
212
+ </div>
 
 
 
213
  </div>
214
  </div>
215
 
216
+ <div id="editor-container">
217
+ <div id="comic-container"></div>
218
+ <div class="edit-controls">
219
+ <h4>✏️ Vertical Editor</h4>
220
+ <button onclick="addBubble()" style="background:#4CAF50; color:white;">Add Bubble</button>
221
+ <button onclick="exportComic()" style="background:#2196F3; color:white;">Export PNG</button>
222
+ <button onclick="location.reload()" style="background:#e74c3c; color:white;">Reset</button>
223
  </div>
224
  </div>
225
 
226
  <script>
227
+ let sid = 'S' + Math.random().toString(36).substr(2, 9);
228
+ let isDraggingSplit = false;
229
+ let selectedSplitHandle = null;
230
+ let isDraggingBubble = false, selectedBubble = null;
231
 
232
  async function upload() {
233
+ const f = document.getElementById('file-upload').files[0];
234
+ if(!f) return alert("Select video");
235
+ document.getElementById('loading').style.display='block';
236
+ const fd = new FormData(); fd.append('file', f);
 
237
  await fetch(`/uploader?sid=${sid}`, {method:'POST', body:fd});
238
+ const timer = setInterval(async () => {
239
  const r = await fetch(`/status?sid=${sid}`);
240
  const d = await r.json();
241
+ document.getElementById('status-text').innerText = d.message;
242
+ if(d.progress >= 100) { clearInterval(timer); loadEditor(); }
243
  }, 2000);
244
  }
245
 
246
+ async function loadEditor() {
247
  const r = await fetch(`/output/pages.json?sid=${sid}`);
248
+ const pages = await r.json();
249
+ const container = document.getElementById('comic-container');
250
+ container.innerHTML = '';
251
+
252
+ pages.forEach(p => {
253
  const pageDiv = document.createElement('div');
254
+ pageDiv.className = 'comic-page';
255
  const grid = document.createElement('div');
256
+ grid.className = 'comic-grid layout-custom-slant';
257
+ grid.style.setProperty('--split-t', '45%');
258
+ grid.style.setProperty('--split-b', '55%');
259
 
260
+ const hT = document.createElement('div'); hT.className = 'split-handle top';
261
+ hT.onmousedown = (e) => { isDraggingSplit = true; selectedSplitHandle = { grid, side: 'top' }; };
262
+ const hB = document.createElement('div'); hB.className = 'split-handle bottom';
263
+ hB.onmousedown = (e) => { isDraggingSplit = true; selectedSplitHandle = { grid, side: 'bottom' }; };
264
 
 
 
 
265
  p.panels.forEach(pan => {
266
+ const pnl = document.createElement('div');
267
+ pnl.className = 'panel';
268
+ pnl.innerHTML = `<img src="/frames/${pan.image}?sid=${sid}">`;
269
+ grid.appendChild(pnl);
270
  });
271
 
272
+ grid.appendChild(hT); grid.appendChild(hB);
273
  pageDiv.appendChild(grid);
274
+ container.appendChild(pageDiv);
275
  });
276
+ document.getElementById('upload-container').style.display = 'none';
277
+ document.getElementById('editor-container').style.display = 'block';
278
  }
279
 
280
+ document.addEventListener('mousemove', (e) => {
281
+ if(isDraggingSplit && selectedSplitHandle) {
282
+ const rect = selectedSplitHandle.grid.getBoundingClientRect();
283
+ let percent = ((e.clientX - rect.left) / rect.width) * 100;
284
+ percent = Math.max(10, Math.min(90, percent));
285
+ if(selectedSplitHandle.side === 'top') selectedSplitHandle.grid.style.setProperty('--split-t', percent + '%');
286
+ else selectedSplitHandle.grid.style.setProperty('--split-b', percent + '%');
 
 
 
 
 
 
 
 
287
  }
288
+ if(isDraggingBubble && selectedBubble) {
289
+ const rect = selectedBubble.parentElement.getBoundingClientRect();
290
+ selectedBubble.style.left = ((e.clientX - rect.left) / rect.width * 100) + '%';
291
+ selectedBubble.style.top = ((e.clientY - rect.top) / rect.height * 100) + '%';
292
+ }
293
+ });
294
+
295
+ document.addEventListener('mouseup', () => { isDraggingSplit = false; isDraggingBubble = false; });
296
+
297
+ function addBubble() {
298
+ const pg = document.querySelector('.panel'); // add to first panel found
299
+ if(!pg) return;
300
+ const b = document.createElement('div');
301
+ b.className = 'speech-bubble';
302
+ b.innerHTML = '<div class="bubble-text">New Text</div>';
303
+ b.style.left = '50%'; b.style.top = '50%';
304
+ b.onmousedown = (e) => { e.stopPropagation(); selectedBubble = b; isDraggingBubble = true; };
305
+ pg.appendChild(b);
306
  }
 
 
307
 
308
+ async function exportComic() {
309
+ const node = document.querySelector('.comic-page');
310
+ const dataUrl = await htmlToImage.toPng(node);
311
+ const link = document.createElement('a');
312
+ link.download = 'comic.png'; link.href = dataUrl; link.click();
313
+ }
314
+ </script>
315
+ </body> </html>
316
+ '''
317
 
318
  @app.route('/')
319
  def index(): return INDEX_HTML
320
 
321
  @app.route('/uploader', methods=['POST'])
322
+ def upload():
323
  sid = request.args.get('sid')
324
  f = request.files['file']
325
+ gen = EnhancedComicGenerator(sid)
326
+ f.save(gen.video_path)
327
  threading.Thread(target=gen.run, args=(2, 2)).start()
328
  return jsonify({'ok': True})
329
 
330
  @app.route('/status')
331
  def get_status():
332
  sid = request.args.get('sid')
333
+ return send_from_directory(os.path.join(BASE_USER_DIR, sid, 'output'), 'status.json')
 
 
 
334
 
335
  @app.route('/output/<path:filename>')
336
  def get_output(filename):