tester343 commited on
Commit
5b5836d
·
verified ·
1 Parent(s): 704febe

Update app_enhanced.py

Browse files
Files changed (1) hide show
  1. app_enhanced.py +455 -195
app_enhanced.py CHANGED
@@ -9,6 +9,7 @@ import string
9
  import random
10
  import shutil
11
  import cv2
 
12
  import numpy as np
13
  import srt
14
  from flask import Flask, jsonify, request, send_from_directory, send_file
@@ -23,273 +24,532 @@ def gpu_warmup():
23
  return True
24
 
25
  # ======================================================
26
- # 🧠 GLOBAL GPU FUNCTIONS
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
27
  # ======================================================
28
  @spaces.GPU(duration=300)
29
- def generate_comic_gpu(video_path, user_dir, frames_dir, metadata_path, target_pages):
30
  import cv2
31
  import numpy as np
32
 
 
 
 
 
 
33
  cap = cv2.VideoCapture(video_path)
34
- if not cap.isOpened(): raise Exception("Cannot open video")
35
  fps = cap.get(cv2.CAP_PROP_FPS) or 25
36
- duration = int(cap.get(cv2.CAP_PROP_FRAME_COUNT)) / fps
37
- cap.release()
38
-
39
- # We force 2 panels per page for the vertical slant layout
40
  panels_per_page = 2
41
- total_panels_needed = int(target_pages) * panels_per_page
42
- times = np.linspace(1, max(1, duration - 1), total_panels_needed)
 
 
43
 
44
- frame_metadata = {}
45
  frame_files_ordered = []
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"Moment {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)
59
 
 
 
 
60
  pages = []
61
- for i in range(int(target_pages)):
62
- start_idx = i * 2
63
- p_frames = frame_files_ordered[start_idx:start_idx+2]
64
- if len(p_frames) == 2:
65
- pg_panels = [{'image': f} for f in p_frames]
66
- pg_bubbles = [{'dialog': frame_metadata[f]['dialogue']} for f in p_frames]
67
- pages.append({'panels': pg_panels, 'bubbles': pg_bubbles})
68
- return pages
 
 
 
 
 
 
 
 
 
 
 
 
 
69
 
70
- @spaces.GPU
71
- def regen_frame_gpu(video_path, frames_dir, metadata_path, fname, direction):
72
- import cv2
73
- import json
74
- with open(metadata_path, 'r') as f: meta = json.load(f)
75
- t = meta[fname]['time']
76
- cap = cv2.VideoCapture(video_path)
77
- fps = cap.get(cv2.CAP_PROP_FPS) or 25
78
- new_t = max(0, t + (1.0/fps * (1 if direction == 'forward' else -1)))
79
- cap.set(cv2.CAP_PROP_POS_MSEC, new_t * 1000)
80
- ret, frame = cap.read()
81
- cap.release()
82
- if ret:
83
- cv2.imwrite(os.path.join(frames_dir, fname), frame)
84
- meta[fname]['time'] = new_t
85
- with open(metadata_path, 'w') as f: json.dump(meta, f)
86
- return {"success": True}
87
- return {"success": False}
88
 
89
  # ======================================================
90
- # 💾 STORAGE SETUP
91
  # ======================================================
92
- BASE_STORAGE_PATH = '/data' if os.path.exists('/data') else '.'
93
- BASE_USER_DIR = os.path.join(BASE_STORAGE_PATH, "userdata")
94
- os.makedirs(BASE_USER_DIR, exist_ok=True)
95
-
96
- app = Flask(__name__)
97
-
98
- class ComicBackend:
99
  def __init__(self, sid):
100
  self.sid = sid
101
- self.dir = os.path.join(BASE_USER_DIR, sid)
102
- self.v_path = os.path.join(self.dir, 'uploaded.mp4')
103
- self.f_dir = os.path.join(self.dir, 'frames')
104
- self.o_dir = os.path.join(self.dir, 'output')
105
- for d in [self.f_dir, self.o_dir]: os.makedirs(d, exist_ok=True)
106
- self.meta_p = os.path.join(self.f_dir, 'metadata.json')
107
-
108
- def write_status(self, msg, prog):
109
- with open(os.path.join(self.o_dir, 'status.json'), 'w') as f:
110
- json.dump({'message': msg, 'progress': prog}, f)
111
 
112
- def run(self, target_pages):
113
  try:
114
- self.write_status("Running ZeroGPU Extraction...", 20)
115
- data = generate_comic_gpu(self.v_path, self.dir, self.f_dir, self.meta_p, target_pages)
116
- with open(os.path.join(self.o_dir, 'pages.json'), 'w') as f: json.dump(data, f)
 
 
117
  self.write_status("Complete!", 100)
118
  except Exception as e:
 
119
  self.write_status(f"Error: {str(e)}", -1)
120
 
 
 
 
 
121
  # ======================================================
122
- # 🌐 UI & TEMPLATE (Vertical Tilt Feature)
123
  # ======================================================
124
  INDEX_HTML = '''
125
- <!DOCTYPE html><html lang="en"> <head> <meta charset="UTF-8"> <title>🎬 Slanted Divider Comic Gen</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=Comic+Neue:wght@700&family=Lato&display=swap" rel="stylesheet"> <style>
126
- body { background-color: #fdf6e3; font-family: 'Lato', sans-serif; margin: 0; padding: 20px; }
127
- #upload-container { display: flex; justify-content: center; align-items: center; min-height: 80vh; }
128
- .upload-box { background: white; padding: 40px; border-radius: 12px; box-shadow: 0 8px 30px rgba(0,0,0,0.1); text-align: center; width: 400px; }
129
-
130
- /* COMIC PAGE - VERTICAL SLANT */
131
- .comic-page {
132
- background: white; width: 864px; height: 1080px; margin: 20px auto;
133
- position: relative; overflow: hidden; border: 8px solid #000; box-shadow: 0 10px 30px rgba(0,0,0,0.2);
134
- }
135
- .comic-grid { width: 100%; height: 100%; position: relative; background: #000; }
136
- .panel { position: absolute; top:0; left:0; width:100%; height:100%; overflow:hidden; }
137
-
138
- /* THE TILT LOGIC */
139
- /* Panel 1 is clipped to show the left side */
140
- .panel:nth-child(1) {
141
- z-index: 2;
142
- clip-path: polygon(0 0, var(--split-t, 45%) 0, var(--split-b, 55%) 100%, 0 100%);
143
- }
144
- /* Panel 2 is behind and clipped to show the right side */
145
- .panel:nth-child(2) {
146
- z-index: 1;
147
- clip-path: polygon(var(--split-t, 45%) 0, 100% 0, 100% 100%, var(--split-b, 55%) 100%);
148
- }
149
-
150
- .panel img { width: 100%; height: 100%; object-fit: cover; pointer-events: none; }
151
-
152
- /* DRAGGABLE HANDLES ON THE EDGES */
153
- .split-handle {
154
- position: absolute; width: 34px; height: 34px;
155
- background: #FF9800; border: 4px solid white; border-radius: 50%;
156
- cursor: ew-resize; z-index: 100; box-shadow: 0 4px 10px rgba(0,0,0,0.4);
157
- }
158
- /* Top edge handle */
159
- .split-handle.top { top: -17px; left: var(--split-t, 45%); transform: translateX(-50%); }
160
- /* Bottom edge handle */
161
- .split-handle.bottom { bottom: -17px; left: var(--split-b, 55%); transform: translateX(-50%); }
162
-
163
- /* CONTROLS */
164
- .edit-controls { position: fixed; right: 20px; top: 20px; width: 220px; background: #2c3e50; color: white; padding: 15px; border-radius: 10px; z-index: 999; }
165
- button { width: 100%; padding: 10px; margin: 5px 0; border: none; border-radius: 5px; cursor: pointer; font-weight: bold; }
166
- .btn-gen { background: #27ae60; color: white; }
167
- .btn-exp { background: #2980b9; color: white; }
168
- #status { color: #f1c40f; font-family: monospace; font-size: 12px; margin-top: 10px; }
169
- </style> </head> <body>
 
 
 
 
 
 
170
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
171
  <div id="upload-container">
172
- <div class="upload-box">
173
- <h1>🎬 Vertical Slant Gen</h1>
174
- <input type="file" id="vid-file" style="display:none" onchange="document.getElementById('fn').innerText=this.files[0].name">
175
- <button onclick="document.getElementById('vid-file').click()" class="btn-gen">📁 Choose Video</button>
176
- <p id="fn">No file selected</p>
177
- <button class="btn-gen" onclick="upload()" style="background:#e67e22">🚀 Generate Comic</button>
178
- <div id="status">Ready.</div>
179
  </div>
180
  </div>
181
 
182
- <div id="editor-container" style="display:none">
183
- <div id="comic-list"></div>
184
- <div class="edit-controls">
185
- <h4>Panel Editor</h4>
186
- <p style="font-size: 11px; opacity: 0.8;">Drag the <b style="color:#FF9800">orange circles</b> at the top and bottom to tilt the middle line.</p>
187
- <button class="btn-exp" onclick="exportPNG()">📥 Export PNG</button>
188
- <button onclick="location.reload()" style="background:#c0392b; color:white;">Reset</button>
 
 
189
  </div>
190
  </div>
191
 
192
  <script>
193
- let sid = 'S' + Math.floor(Math.random()*1000000);
194
- let isDragging = false, currentHandle = null;
195
-
196
- async function upload() {
197
- const file = document.getElementById('vid-file').files[0];
198
- if(!file) return alert("Select a video");
199
- const fd = new FormData(); fd.append('file', file);
200
- document.getElementById('status').innerText = "Uploading to ZeroGPU...";
201
- await fetch(`/uploader?sid=${sid}`, {method:'POST', body:fd});
 
 
 
 
 
 
 
 
 
202
 
203
- const timer = setInterval(async () => {
204
- const r = await fetch(`/status?sid=${sid}`);
205
- const d = await r.json();
206
- document.getElementById('status').innerText = d.message;
207
- if(d.progress >= 100) { clearInterval(timer); loadEditor(); }
208
- }, 2000);
 
209
  }
210
 
211
- async function loadEditor() {
212
- const r = await fetch(`/output/pages.json?sid=${sid}`);
213
- const pages = await r.json();
214
- const list = document.getElementById('comic-list');
215
- pages.forEach(p => {
216
- const pgDiv = document.createElement('div');
217
- pgDiv.className = 'comic-page';
218
- const grid = document.createElement('div');
219
- grid.className = 'comic-grid';
220
- grid.style.setProperty('--split-t', '45%');
221
- grid.style.setProperty('--split-b', '55%');
222
-
223
- const hTop = document.createElement('div'); hTop.className = 'split-handle top';
224
- const hBot = document.createElement('div'); hBot.className = 'split-handle bottom';
225
 
226
- hTop.onmousedown = (e) => { isDragging = true; currentHandle = { grid, key: '--split-t' }; };
227
- hBot.onmousedown = (e) => { isDragging = true; currentHandle = { grid, key: '--split-b' }; };
228
-
229
- p.panels.forEach(pan => {
230
- const div = document.createElement('div');
231
- div.className = 'panel';
232
- div.innerHTML = `<img src="/frames/${pan.image}?sid=${sid}">`;
233
- grid.appendChild(div);
234
- });
235
-
236
- grid.appendChild(hTop); grid.appendChild(hBot);
237
- pgDiv.appendChild(grid);
238
- list.appendChild(pgDiv);
 
 
 
 
 
 
 
239
  });
240
- document.getElementById('upload-container').style.display='none';
241
- document.getElementById('editor-container').style.display='block';
242
  }
243
 
244
- window.onmousemove = (e) => {
245
- if(!isDragging || !currentHandle) return;
246
- const rect = currentHandle.grid.getBoundingClientRect();
247
- let x = ((e.clientX - rect.left) / rect.width) * 100;
248
- x = Math.max(5, Math.min(95, x)); // Constraints
249
- currentHandle.grid.style.setProperty(currentHandle.key, x + '%');
250
- };
251
- window.onmouseup = () => isDragging = false;
252
-
253
- async function exportPNG() {
254
- const pgs = document.querySelectorAll('.comic-page');
255
- for(let i=0; i<pgs.length; i++){
256
- const url = await htmlToImage.toPng(pgs[i]);
257
- const a = document.createElement('a');
258
- a.download = `comic-page-${i+1}.png`; a.href = url; a.click();
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
259
  }
260
  }
261
- </script> </body> </html>
 
 
262
  '''
263
 
 
 
 
264
  @app.route('/')
265
- def index(): return INDEX_HTML
 
266
 
267
  @app.route('/uploader', methods=['POST'])
268
- def uploader():
269
  sid = request.args.get('sid')
 
 
 
270
  f = request.files['file']
271
- gen = ComicBackend(sid)
272
- f.save(gen.v_path)
273
- threading.Thread(target=gen.run, args=(2,)).start()
274
- return jsonify({'ok': True})
 
 
275
 
276
  @app.route('/status')
277
  def get_status():
278
  sid = request.args.get('sid')
279
- try:
280
- with open(os.path.join(BASE_USER_DIR, sid, 'output', 'status.json'), 'r') as f:
281
- return f.read()
282
- except: return jsonify({'message': 'Starting...', 'progress': 0})
283
 
284
- @app.route('/output/<path:filename>')
285
  def get_output(filename):
286
  sid = request.args.get('sid')
287
  return send_from_directory(os.path.join(BASE_USER_DIR, sid, 'output'), filename)
288
 
289
- @app.route('/frames/<path:filename>')
290
  def get_frame(filename):
291
  sid = request.args.get('sid')
292
  return send_from_directory(os.path.join(BASE_USER_DIR, sid, 'frames'), filename)
293
 
294
  if __name__ == '__main__':
 
 
295
  app.run(host='0.0.0.0', port=7860)
 
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
 
24
  return True
25
 
26
  # ======================================================
27
+ # 💾 PERSISTENT STORAGE CONFIGURATION
28
+ # ======================================================
29
+ if os.path.exists('/data'):
30
+ BASE_STORAGE_PATH = '/data'
31
+ print("✅ Using Persistent Storage at /data")
32
+ else:
33
+ BASE_STORAGE_PATH = '.'
34
+ print("⚠️ Using Ephemeral Storage")
35
+
36
+ BASE_USER_DIR = os.path.join(BASE_STORAGE_PATH, "userdata")
37
+ os.makedirs(BASE_USER_DIR, exist_ok=True)
38
+
39
+ # ======================================================
40
+ # 🔧 APP CONFIG
41
+ # ======================================================
42
+ app = Flask(__name__)
43
+
44
+ # ======================================================
45
+ # 🧠 CORE LOGIC (SIMPLIFIED FOR ROBUSTNESS)
46
  # ======================================================
47
  @spaces.GPU(duration=300)
48
+ def generate_comic_gpu(video_path, user_dir, frames_dir, metadata_path, target_pages, panels_per_page_req):
49
  import cv2
50
  import numpy as np
51
 
52
+ # 1. Setup
53
+ if os.path.exists(frames_dir): shutil.rmtree(frames_dir)
54
+ os.makedirs(frames_dir, exist_ok=True)
55
+
56
+ # 2. Extract Frames
57
  cap = cv2.VideoCapture(video_path)
 
58
  fps = cap.get(cv2.CAP_PROP_FPS) or 25
59
+ total_frames = int(cap.get(cv2.CAP_PROP_FRAME_COUNT))
60
+ duration = total_frames / fps
61
+
62
+ # Default to 2 panels per page (Split layout)
63
  panels_per_page = 2
64
+ total_panels = target_pages * panels_per_page
65
+
66
+ # Pick timestamps
67
+ times = np.linspace(1, duration - 1, total_panels)
68
 
 
69
  frame_files_ordered = []
70
+ frame_metadata = {}
71
+
72
  for i, t in enumerate(times):
73
  cap.set(cv2.CAP_PROP_POS_MSEC, t * 1000)
74
  ret, frame = cap.read()
75
  if ret:
76
  fname = f"frame_{i:04d}.png"
77
+ # Resize for vertical quality
78
+ h, w = frame.shape[:2]
79
+ if w > h: # If landscape, crop center
80
+ center = w // 2
81
+ start = center - (h // 2)
82
+ frame = frame[:, start:start+h]
83
+
84
  p = os.path.join(frames_dir, fname)
85
  cv2.imwrite(p, frame)
86
+
87
+ frame_metadata[fname] = {'dialogue': "Edit Text", 'time': t}
88
  frame_files_ordered.append(fname)
 
 
 
89
 
90
+ cap.release()
91
+
92
+ # 3. Build Page Structure
93
  pages = []
94
+ for i in range(target_pages):
95
+ start_idx = i * panels_per_page
96
+ current_frames = frame_files_ordered[start_idx : start_idx + panels_per_page]
97
+
98
+ # Create Page Object
99
+ pg_panels = [{'image': f} for f in current_frames]
100
+ # Add a default bubble to the first panel of each page
101
+ pg_bubbles = []
102
+ if len(pg_panels) > 0:
103
+ pg_bubbles.append({
104
+ 'dialog': "Your text here...",
105
+ 'type': 'speech',
106
+ 'layout_idx': 0 # Belongs to first panel
107
+ })
108
+
109
+ pages.append({
110
+ 'panels': pg_panels,
111
+ 'bubbles': pg_bubbles,
112
+ 'splitT': '45%', # Default top split position
113
+ 'splitB': '55%' # Default bottom split position
114
+ })
115
 
116
+ return pages
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
117
 
118
  # ======================================================
119
+ # 💻 BACKEND CLASS
120
  # ======================================================
121
+ class EnhancedComicGenerator:
 
 
 
 
 
 
122
  def __init__(self, sid):
123
  self.sid = sid
124
+ self.user_dir = os.path.join(BASE_USER_DIR, sid)
125
+ self.video_path = os.path.join(self.user_dir, 'uploaded.mp4')
126
+ self.frames_dir = os.path.join(self.user_dir, 'frames')
127
+ self.output_dir = os.path.join(self.user_dir, 'output')
128
+ os.makedirs(self.frames_dir, exist_ok=True)
129
+ os.makedirs(self.output_dir, exist_ok=True)
130
+ self.metadata_path = os.path.join(self.frames_dir, 'frame_metadata.json')
 
 
 
131
 
132
+ def run(self, target_pages, panels_per_page):
133
  try:
134
+ self.write_status("Generating Frames...", 10)
135
+ data = generate_comic_gpu(self.video_path, self.user_dir, self.frames_dir, self.metadata_path, int(target_pages), int(panels_per_page))
136
+
137
+ with open(os.path.join(self.output_dir, 'pages.json'), 'w') as f:
138
+ 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
+ # 🌐 HTML FRONTEND
150
  # ======================================================
151
  INDEX_HTML = '''
152
+ <!DOCTYPE html>
153
+ <html lang="en">
154
+ <head>
155
+ <meta charset="UTF-8">
156
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
157
+ <title>Vertical Split Comic Generator</title>
158
+ <script src="https://cdnjs.cloudflare.com/ajax/libs/html-to-image/1.11.11/html-to-image.min.js"></script>
159
+ <link href="https://fonts.googleapis.com/css2?family=Bangers&family=Comic+Neue:wght@700&display=swap" rel="stylesheet">
160
+ <style>
161
+ body { background: #222; font-family: 'Comic Neue', cursive; margin: 0; color: white; }
162
+
163
+ /* UPLOAD SCREEN */
164
+ #upload-container { display: flex; flex-direction: column; align-items: center; justify-content: center; height: 100vh; }
165
+ .box { background: #333; padding: 40px; border-radius: 10px; text-align: center; }
166
+ .btn { padding: 10px 20px; background: #e67e22; border: none; color: white; cursor: pointer; font-size: 18px; border-radius: 5px; margin-top: 10px; }
167
+ .btn:hover { background: #d35400; }
168
+ input[type=file] { margin-bottom: 20px; }
169
+
170
+ /* EDITOR SCREEN */
171
+ #editor-container { display: none; padding: 20px; text-align: center; }
172
+
173
+ .comic-wrapper {
174
+ display: flex;
175
+ flex-direction: row;
176
+ flex-wrap: wrap;
177
+ justify-content: center;
178
+ gap: 40px;
179
+ padding-bottom: 100px;
180
+ }
181
+
182
+ /* PAGE CONTAINER */
183
+ .comic-page {
184
+ width: 400px; /* Reduced width for preview */
185
+ height: 600px;
186
+ background: black;
187
+ position: relative;
188
+ border: 4px solid white;
189
+ box-shadow: 0 0 20px rgba(0,0,0,0.5);
190
+ overflow: visible; /* Allow handles to stick out */
191
+ user-select: none;
192
+ }
193
+
194
+ /* THE GRID HOLDER */
195
+ .comic-grid {
196
+ width: 100%;
197
+ height: 100%;
198
+ position: relative;
199
+ /* Default split variables */
200
+ --split-t: 45%;
201
+ --split-b: 55%;
202
+ }
203
 
204
+ /* PANELS - CLIPPED */
205
+ .panel {
206
+ position: absolute;
207
+ top: 0; left: 0;
208
+ width: 100%; height: 100%;
209
+ overflow: hidden;
210
+ pointer-events: none; /* Let clicks pass through to bubbles/handles if needed */
211
+ }
212
+
213
+ .panel img {
214
+ width: 100%; height: 100%;
215
+ object-fit: cover;
216
+ pointer-events: auto; /* Re-enable pointer events for dragging image if needed */
217
+ }
218
+
219
+ /* Left/Top Panel */
220
+ .panel:nth-child(1) {
221
+ z-index: 2;
222
+ clip-path: polygon(0 0, var(--split-t) 0, var(--split-b) 100%, 0 100%);
223
+ border-right: 2px solid black; /* Fake border */
224
+ }
225
+
226
+ /* Right/Bottom Panel */
227
+ .panel:nth-child(2) {
228
+ z-index: 1;
229
+ clip-path: polygon(var(--split-t) 0, 100% 0, 100% 100%, var(--split-b) 100%);
230
+ }
231
+
232
+ /* DRAG HANDLES FOR SPLIT LINE */
233
+ .split-handle {
234
+ position: absolute;
235
+ width: 24px; height: 24px;
236
+ background: #2196F3;
237
+ border: 2px solid white;
238
+ border-radius: 50%;
239
+ transform: translate(-50%, -50%);
240
+ cursor: col-resize;
241
+ z-index: 999;
242
+ box-shadow: 0 2px 5px rgba(0,0,0,0.5);
243
+ pointer-events: auto;
244
+ }
245
+
246
+ /* Top Handle */
247
+ .split-handle.top { top: 0%; left: var(--split-t); margin-top: -12px; }
248
+ /* Bottom Handle */
249
+ .split-handle.bottom { top: 100%; left: var(--split-b); margin-top: 12px; }
250
+
251
+ /* LINE VISUALIZATION */
252
+ .split-line-visual {
253
+ position: absolute; top:0; left:0; width:100%; height:100%;
254
+ pointer-events: none; z-index: 10;
255
+ background: transparent;
256
+ }
257
+
258
+ /* BUBBLES */
259
+ .speech-bubble {
260
+ position: absolute;
261
+ background: white; color: black;
262
+ padding: 10px; border-radius: 10px;
263
+ min-width: 80px; text-align: center;
264
+ font-family: 'Comic Neue'; font-weight: bold;
265
+ cursor: move; z-index: 100;
266
+ border: 2px solid black;
267
+ transform: translate(-50%, -50%);
268
+ }
269
+
270
+ .speech-bubble:after {
271
+ content: ''; position: absolute; bottom: -10px; left: 50%;
272
+ border-width: 10px 10px 0; border-style: solid;
273
+ border-color: black transparent; display: block; width: 0;
274
+ }
275
+
276
+ .speech-bubble:before {
277
+ content: ''; position: absolute; bottom: -6px; left: 50%;
278
+ border-width: 10px 10px 0; border-style: solid;
279
+ border-color: white transparent; display: block; width: 0; z-index: 1;
280
+ }
281
+
282
+ /* CONTROLS */
283
+ .controls {
284
+ position: fixed; bottom: 0; left: 0; width: 100%;
285
+ background: #333; padding: 15px; display: flex;
286
+ justify-content: center; gap: 20px; z-index: 1000;
287
+ }
288
+ </style>
289
+ </head>
290
+ <body>
291
+
292
+ <!-- UPLOAD -->
293
  <div id="upload-container">
294
+ <div class="box">
295
+ <h1>🎬 Vertical Split Comic</h1>
296
+ <input type="file" id="file-upload" accept="video/*"><br>
297
+ <label>Pages:</label> <input type="number" id="page-count" value="2" min="1" max="5" style="width:50px;">
298
+ <button class="btn" onclick="startUpload()">Generate Comic</button>
299
+ <p id="status" style="margin-top:10px; color:#aaa;"></p>
 
300
  </div>
301
  </div>
302
 
303
+ <!-- EDITOR -->
304
+ <div id="editor-container">
305
+ <h2>Adjust the Split Line by Dragging the Blue Dots</h2>
306
+ <div class="comic-wrapper" id="comic-container"></div>
307
+
308
+ <div class="controls">
309
+ <button class="btn" onclick="addBubble()">+ Text Bubble</button>
310
+ <button class="btn" onclick="downloadAll()">Download Images</button>
311
+ <button class="btn" style="background:#c0392b" onclick="location.reload()">Reset</button>
312
  </div>
313
  </div>
314
 
315
  <script>
316
+ let sid = 'S' + Math.floor(Math.random()*100000);
317
+ let isDraggingHandle = false;
318
+ let activeHandle = null; // { element: domNode, type: 'top'|'bottom', grid: domNode }
319
+
320
+ let isDraggingBubble = false;
321
+ let activeBubble = null;
322
+ let offset = {x:0, y:0};
323
+
324
+ // 1. UPLOAD LOGIC
325
+ async function startUpload() {
326
+ const file = document.getElementById('file-upload').files[0];
327
+ if(!file) return alert("Please select a file");
328
+
329
+ const pages = document.getElementById('page-count').value;
330
+ const fd = new FormData();
331
+ fd.append('file', file);
332
+ fd.append('target_pages', pages);
333
+ fd.append('panels_per_page', 2); // FORCE 2 PANELS
334
 
335
+ document.querySelector('.box').style.display = 'none';
336
+ document.getElementById('status').innerText = "Uploading...";
337
+
338
+ const res = await fetch(`/uploader?sid=${sid}`, { method: 'POST', body: fd });
339
+ if(res.ok) {
340
+ checkStatus();
341
+ }
342
  }
343
 
344
+ async function checkStatus() {
345
+ const interval = setInterval(async () => {
346
+ const res = await fetch(`/status?sid=${sid}`);
347
+ const data = await res.json();
348
+ document.getElementById('status').innerText = data.message;
 
 
 
 
 
 
 
 
 
349
 
350
+ if(data.progress >= 100) {
351
+ clearInterval(interval);
352
+ loadComic();
353
+ }
354
+ }, 1000);
355
+ }
356
+
357
+ // 2. RENDER COMIC
358
+ async function loadComic() {
359
+ document.getElementById('upload-container').style.display = 'none';
360
+ document.getElementById('editor-container').style.display = 'block';
361
+
362
+ const res = await fetch(`/output/pages.json?sid=${sid}`);
363
+ const pages = await res.json();
364
+
365
+ const container = document.getElementById('comic-container');
366
+ container.innerHTML = '';
367
+
368
+ pages.forEach((pageData, idx) => {
369
+ renderPage(pageData, idx, container);
370
  });
 
 
371
  }
372
 
373
+ function renderPage(data, idx, container) {
374
+ const pageDiv = document.createElement('div');
375
+ pageDiv.className = 'comic-page';
376
+
377
+ // Grid Container
378
+ const grid = document.createElement('div');
379
+ grid.className = 'comic-grid';
380
+ grid.style.setProperty('--split-t', data.splitT || '45%');
381
+ grid.style.setProperty('--split-b', data.splitB || '55%');
382
+
383
+ // Panels (Assuming 2 panels always)
384
+ if(data.panels.length >= 2) {
385
+ // Panel 1 (Left)
386
+ const p1 = document.createElement('div');
387
+ p1.className = 'panel';
388
+ p1.innerHTML = `<img src="/frames/${data.panels[0].image}?sid=${sid}">`;
389
+ grid.appendChild(p1);
390
+
391
+ // Panel 2 (Right)
392
+ const p2 = document.createElement('div');
393
+ p2.className = 'panel';
394
+ p2.innerHTML = `<img src="/frames/${data.panels[1].image}?sid=${sid}">`;
395
+ grid.appendChild(p2);
396
+ }
397
+
398
+ // HANDLES
399
+ const hTop = document.createElement('div');
400
+ hTop.className = 'split-handle top';
401
+ hTop.onmousedown = (e) => startDragHandle(e, hTop, 'top', grid);
402
+
403
+ const hBot = document.createElement('div');
404
+ hBot.className = 'split-handle bottom';
405
+ hBot.onmousedown = (e) => startDragHandle(e, hBot, 'bottom', grid);
406
+
407
+ grid.appendChild(hTop);
408
+ grid.appendChild(hBot);
409
+
410
+ // BUBBLES
411
+ if(data.bubbles) {
412
+ data.bubbles.forEach(b => createBubble(b.dialog, grid));
413
+ }
414
+
415
+ pageDiv.appendChild(grid);
416
+ container.appendChild(pageDiv);
417
+ }
418
+
419
+ // 3. INTERACTION LOGIC (DRAG HANDLES)
420
+ function startDragHandle(e, el, type, grid) {
421
+ e.preventDefault();
422
+ e.stopPropagation();
423
+ isDraggingHandle = true;
424
+ activeHandle = { el, type, grid };
425
+ }
426
+
427
+ // 4. INTERACTION LOGIC (BUBBLES)
428
+ function createBubble(text, parent) {
429
+ const b = document.createElement('div');
430
+ b.className = 'speech-bubble';
431
+ b.contentEditable = true;
432
+ b.innerText = text;
433
+ b.style.left = '50%';
434
+ b.style.top = '50%';
435
+
436
+ b.onmousedown = (e) => {
437
+ if(e.target !== b) return; // Allow text selection
438
+ e.preventDefault();
439
+ isDraggingBubble = true;
440
+ activeBubble = b;
441
+ offset.x = e.clientX - b.getBoundingClientRect().left - (b.offsetWidth/2);
442
+ offset.y = e.clientY - b.getBoundingClientRect().top - (b.offsetHeight/2);
443
+ };
444
+
445
+ parent.appendChild(b);
446
+ }
447
+
448
+ function addBubble() {
449
+ const grids = document.querySelectorAll('.comic-grid');
450
+ if(grids.length > 0) createBubble("New Text", grids[0]);
451
+ }
452
+
453
+ // GLOBAL MOUSE EVENTS
454
+ document.addEventListener('mousemove', (e) => {
455
+ // Handle Dragging
456
+ if(isDraggingHandle && activeHandle) {
457
+ const rect = activeHandle.grid.getBoundingClientRect();
458
+ let relativeX = e.clientX - rect.left;
459
+
460
+ // Constrain
461
+ if(relativeX < 0) relativeX = 0;
462
+ if(relativeX > rect.width) relativeX = rect.width;
463
+
464
+ const pct = (relativeX / rect.width) * 100;
465
+
466
+ if(activeHandle.type === 'top') {
467
+ activeHandle.grid.style.setProperty('--split-t', pct + '%');
468
+ } else {
469
+ activeHandle.grid.style.setProperty('--split-b', pct + '%');
470
+ }
471
+ }
472
+
473
+ // Bubble Dragging
474
+ if(isDraggingBubble && activeBubble) {
475
+ const rect = activeBubble.parentElement.getBoundingClientRect();
476
+ let x = e.clientX - rect.left; // - offset.x;
477
+ let y = e.clientY - rect.top; // - offset.y;
478
+
479
+ activeBubble.style.left = x + 'px';
480
+ activeBubble.style.top = y + 'px';
481
+ }
482
+ });
483
+
484
+ document.addEventListener('mouseup', () => {
485
+ isDraggingHandle = false;
486
+ activeHandle = null;
487
+ isDraggingBubble = false;
488
+ activeBubble = null;
489
+ });
490
+
491
+ // 5. EXPORT
492
+ async function downloadAll() {
493
+ const pages = document.querySelectorAll('.comic-page');
494
+ for(let i=0; i<pages.length; i++) {
495
+ // Hide handles for screenshot
496
+ const handles = pages[i].querySelectorAll('.split-handle');
497
+ handles.forEach(h => h.style.display = 'none');
498
+
499
+ const dataUrl = await htmlToImage.toPng(pages[i]);
500
+ const link = document.createElement('a');
501
+ link.download = `comic_page_${i+1}.png`;
502
+ link.href = dataUrl;
503
+ link.click();
504
+
505
+ // Show handles again
506
+ handles.forEach(h => h.style.display = 'block');
507
  }
508
  }
509
+ </script>
510
+ </body>
511
+ </html>
512
  '''
513
 
514
+ # ======================================================
515
+ # 🚀 FLASK ROUTES
516
+ # ======================================================
517
  @app.route('/')
518
+ def index():
519
+ return INDEX_HTML
520
 
521
  @app.route('/uploader', methods=['POST'])
522
+ def upload():
523
  sid = request.args.get('sid')
524
+ target_pages = request.form.get('target_pages', 2)
525
+ panels_per_page = 2 # Fixed for this specific layout req
526
+
527
  f = request.files['file']
528
+ gen = EnhancedComicGenerator(sid)
529
+ f.save(gen.video_path)
530
+
531
+ # Run in background
532
+ threading.Thread(target=gen.run, args=(target_pages, panels_per_page)).start()
533
+ return jsonify({'success': True})
534
 
535
  @app.route('/status')
536
  def get_status():
537
  sid = request.args.get('sid')
538
+ path = os.path.join(BASE_USER_DIR, sid, 'output', 'status.json')
539
+ if os.path.exists(path): return send_file(path)
540
+ return jsonify({'progress': 0, 'message': "Waiting..."})
 
541
 
542
+ @app.route('/output/path:filename')
543
  def get_output(filename):
544
  sid = request.args.get('sid')
545
  return send_from_directory(os.path.join(BASE_USER_DIR, sid, 'output'), filename)
546
 
547
+ @app.route('/frames/path:filename')
548
  def get_frame(filename):
549
  sid = request.args.get('sid')
550
  return send_from_directory(os.path.join(BASE_USER_DIR, sid, 'frames'), filename)
551
 
552
  if __name__ == '__main__':
553
+ try: gpu_warmup()
554
+ except: pass
555
  app.run(host='0.0.0.0', port=7860)