tester343 commited on
Commit
ff994fe
ยท
verified ยท
1 Parent(s): 32a6ed4

Update app_enhanced.py

Browse files
Files changed (1) hide show
  1. app_enhanced.py +54 -46
app_enhanced.py CHANGED
@@ -42,11 +42,8 @@ os.makedirs(SAVED_COMICS_DIR, exist_ok=True)
42
  # ======================================================
43
  # ๐Ÿ”ง APP CONFIG
44
  # ======================================================
45
- logging.basicConfig(level=logging.INFO)
46
  app = Flask(__name__)
47
-
48
- # ALLOW LARGE UPLOADS (500MB)
49
- app.config['MAX_CONTENT_LENGTH'] = 500 * 1024 * 1024
50
 
51
  def generate_save_code(length=8):
52
  chars = string.ascii_uppercase + string.digits
@@ -81,13 +78,12 @@ class Page:
81
  # ======================================================
82
  @spaces.GPU(duration=120)
83
  def generate_comic_gpu(video_path, user_dir, frames_dir, metadata_path, target_pages):
84
- print(f"๐Ÿš€ Generation Started for {video_path}...")
85
 
86
  import cv2
87
  import srt
88
  import numpy as np
89
 
90
- # 1. Video Prep
91
  cap = cv2.VideoCapture(video_path)
92
  if not cap.isOpened(): raise Exception("Cannot open video")
93
  fps = cap.get(cv2.CAP_PROP_FPS) or 25
@@ -95,7 +91,7 @@ def generate_comic_gpu(video_path, user_dir, frames_dir, metadata_path, target_p
95
  duration = total_frames / fps
96
  cap.release()
97
 
98
- # 2. Subtitles (Dummy if missing)
99
  user_srt = os.path.join(user_dir, 'subs.srt')
100
  if not os.path.exists(user_srt):
101
  with open(user_srt, 'w') as f: f.write("1\n00:00:01,000 --> 00:00:04,000\n...\n")
@@ -107,7 +103,6 @@ def generate_comic_gpu(video_path, user_dir, frames_dir, metadata_path, target_p
107
  valid_subs = [s for s in all_subs if s.content.strip()]
108
  raw_moments = [{'text': s.content, 'start': s.start.total_seconds(), 'end': s.end.total_seconds()} for s in valid_subs]
109
 
110
- # 3. Calculate Intervals
111
  if target_pages <= 0: target_pages = 1
112
  panels_per_page = 4
113
  total_panels_needed = target_pages * panels_per_page
@@ -122,7 +117,6 @@ def generate_comic_gpu(video_path, user_dir, frames_dir, metadata_path, target_p
122
  indices = np.linspace(0, len(raw_moments) - 1, total_panels_needed, dtype=int)
123
  selected_moments = [raw_moments[i] for i in indices]
124
 
125
- # 4. Extract Frames (1280x720 16:9)
126
  frame_metadata = {}
127
  cap = cv2.VideoCapture(video_path)
128
  count = 0
@@ -135,8 +129,16 @@ def generate_comic_gpu(video_path, user_dir, frames_dir, metadata_path, target_p
135
  ret, frame = cap.read()
136
 
137
  if ret:
138
- # FORCE LANDSCAPE 16:9 for Wide View
139
- frame = cv2.resize(frame, (1280, 720))
 
 
 
 
 
 
 
 
140
 
141
  fname = f"frame_{count:04d}.png"
142
  p = os.path.join(frames_dir, fname)
@@ -149,7 +151,6 @@ def generate_comic_gpu(video_path, user_dir, frames_dir, metadata_path, target_p
149
  cap.release()
150
  with open(metadata_path, 'w') as f: json.dump(frame_metadata, f, indent=2)
151
 
152
- # 5. Bubbles
153
  bubbles_list = []
154
  for f in frame_files_ordered:
155
  dialogue = frame_metadata.get(f, {}).get('dialogue', '')
@@ -158,7 +159,6 @@ def generate_comic_gpu(video_path, user_dir, frames_dir, metadata_path, target_p
158
  elif '!' in dialogue: b_type = 'reaction'
159
  bubbles_list.append(bubble(dialog=dialogue, bubble_offset_x=50, bubble_offset_y=50, type=b_type))
160
 
161
- # 6. Pages
162
  pages = []
163
  for i in range(target_pages):
164
  start_idx = i * 4
@@ -166,7 +166,6 @@ def generate_comic_gpu(video_path, user_dir, frames_dir, metadata_path, target_p
166
  p_frames = frame_files_ordered[start_idx:end_idx]
167
  p_bubbles = bubbles_list[start_idx:end_idx]
168
 
169
- # Fill empty
170
  while len(p_frames) < 4:
171
  fname = f"empty_{i}_{len(p_frames)}.png"
172
  img = np.zeros((720, 1280, 3), dtype=np.uint8); img[:] = (40,40,40)
@@ -206,7 +205,12 @@ def regen_frame_gpu(video_path, frames_dir, metadata_path, fname, direction):
206
  cap.release()
207
 
208
  if ret:
209
- frame = cv2.resize(frame, (1280, 720)) # Keep 16:9
 
 
 
 
 
210
  p = os.path.join(frames_dir, fname)
211
  cv2.imwrite(p, frame)
212
 
@@ -238,7 +242,7 @@ class EnhancedComicGenerator:
238
 
239
  def run(self, target_pages):
240
  try:
241
- self.write_status("Processing...", 10)
242
  data = generate_comic_gpu(self.video_path, self.user_dir, self.frames_dir, self.metadata_path, int(target_pages))
243
  with open(os.path.join(self.output_dir, 'pages.json'), 'w') as f:
244
  json.dump(data, f, indent=2)
@@ -252,10 +256,10 @@ class EnhancedComicGenerator:
252
  json.dump({'message': msg, 'progress': prog}, f)
253
 
254
  # ======================================================
255
- # ๐ŸŒ FRONTEND
256
  # ======================================================
257
  INDEX_HTML = '''
258
- <!DOCTYPE html><html lang="en"> <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <title>Comic Generator (16:9)</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&display=swap" rel="stylesheet"> <style> * { box-sizing: border-box; } body { background-color: #222; font-family: 'Lato', sans-serif; color: #eee; margin: 0; min-height: 100vh; }
259
 
260
  #upload-container { display: flex; flex-direction:column; justify-content: center; align-items: center; min-height: 100vh; width: 100%; padding: 20px; }
261
  .upload-box { max-width: 500px; width: 100%; padding: 40px; background: #333; border-radius: 12px; box-shadow: 0 8px 30px rgba(0,0,0,0.5); text-align: center; }
@@ -277,14 +281,14 @@ INDEX_HTML = '''
277
  .loader { width: 120px; height: 20px; background: radial-gradient(circle 10px, #e67e22 100%, transparent 0); background-size: 20px 20px; animation: ball 1s infinite linear; margin: 20px auto; }
278
  @keyframes ball { 0%{background-position:0 50%} 100%{background-position:100px 50%} }
279
 
280
- /* === 400x600 COMIC LAYOUT === */
281
  .comic-wrapper { max-width: 1000px; margin: 0 auto; }
282
  .page-wrapper { margin: 30px auto; display: flex; flex-direction: column; align-items: center; }
283
  .page-title { text-align: center; color: #eee; margin-bottom: 10px; font-size: 18px; font-weight: bold; }
284
 
285
  .comic-page {
286
- width: 400px;
287
- height: 600px;
288
  background: white;
289
  box-shadow: 0 4px 20px rgba(0,0,0,0.5);
290
  position: relative;
@@ -302,7 +306,7 @@ INDEX_HTML = '''
302
  --y: 50%;
303
  --t1: 100%; --t2: 100%; /* Hidden by default */
304
  --b1: 100%; --b2: 100%; /* Hidden by default */
305
- --gap: 2px;
306
  }
307
 
308
  .panel {
@@ -312,13 +316,13 @@ INDEX_HTML = '''
312
 
313
  .panel img {
314
  width: 100%; height: 100%;
315
- object-fit: cover; /* Key for 16:9 in Portrait */
316
  transform-origin: center;
317
- transition: transform 0.05s ease-out; /* Faster response */
318
  display: block;
319
  }
320
  .panel img.panning { cursor: grabbing; transition: none; }
321
- .panel.selected { outline: 3px solid #2196F3; z-index: 5; }
322
 
323
  /* === CLIP PATHS === */
324
  .panel:nth-child(1) { clip-path: polygon(0 0, calc(var(--t1) - var(--gap)) 0, calc(var(--t2) - var(--gap)) calc(var(--y) - var(--gap)), 0 calc(var(--y) - var(--gap))); z-index: 1; }
@@ -328,7 +332,7 @@ INDEX_HTML = '''
328
 
329
  /* === HANDLES === */
330
  .handle {
331
- position: absolute; width: 22px; height: 22px;
332
  border: 2px solid white; border-radius: 50%;
333
  transform: translate(-50%, -50%);
334
  z-index: 101; cursor: ew-resize;
@@ -336,17 +340,17 @@ INDEX_HTML = '''
336
  }
337
  .handle:hover { transform: scale(1.3); }
338
 
339
- .h-t1 { background: #3498db; left: var(--t1); top: 0%; margin-top: 12px; }
340
- .h-t2 { background: #3498db; left: var(--t2); top: 50%; margin-top: -12px; }
341
- .h-b1 { background: #2ecc71; left: var(--b1); top: 50%; margin-top: 12px; }
342
- .h-b2 { background: #2ecc71; left: var(--b2); top: 100%; margin-top: -12px; }
343
 
344
  /* SPEECH BUBBLES */
345
  .speech-bubble {
346
  position: absolute; display: flex; justify-content: center; align-items: center;
347
  width: 150px; height: 80px; min-width: 50px; min-height: 30px; box-sizing: border-box;
348
  z-index: 10; cursor: move; font-family: 'Comic Neue', cursive; font-weight: bold;
349
- font-size: 14px; text-align: center;
350
  overflow: visible; line-height: 1.2; --tail-pos: 50%;
351
  }
352
  .bubble-text { padding: 0.5em; width: 100%; height: 100%; display: flex; align-items: center; justify-content: center; overflow: hidden; white-space: pre-wrap; pointer-events: none; }
@@ -354,7 +358,7 @@ INDEX_HTML = '''
354
 
355
  .speech-bubble.speech {
356
  background: #fff; color: #000; border: 2px solid #000;
357
- border-radius: 50%; /* Oval default */
358
  }
359
  .speech-bubble.speech::after {
360
  content: ''; position: absolute; bottom: -10px; left: var(--tail-pos);
@@ -377,7 +381,7 @@ INDEX_HTML = '''
377
 
378
  <div id="upload-container">
379
  <div class="upload-box">
380
- <h1>โšก Fast 16:9 Comic Gen</h1>
381
  <input type="file" id="file-upload" class="file-input" onchange="document.getElementById('fn').innerText=this.files[0].name">
382
  <label for="file-upload" class="file-label">๐Ÿ“ Choose Video</label>
383
  <span id="fn" style="margin-bottom:10px; display:block; color:#aaa;">No file selected</span>
@@ -387,7 +391,7 @@ INDEX_HTML = '''
387
  <input type="number" id="page-count" value="4" min="1" max="15">
388
  </div>
389
 
390
- <button class="submit-btn" onclick="upload()">๐Ÿš€ Generate Fast</button>
391
 
392
  <div class="loading-view" id="loading-view" style="display:none; margin-top:20px;">
393
  <div class="loader" style="margin:0 auto;"></div>
@@ -420,8 +424,8 @@ INDEX_HTML = '''
420
  </div>
421
 
422
  <div class="control-group">
423
- <label>๐Ÿ” Zoom/Pan:</label>
424
- <input type="range" id="zoom-slider" min="100" max="300" value="100" step="5" oninput="handleZoom(this)" disabled>
425
  <button onclick="resetPanelTransform()" class="secondary-btn">Reset View</button>
426
  </div>
427
 
@@ -497,7 +501,7 @@ INDEX_HTML = '''
497
  // Init transform data
498
  img.dataset.zoom = 100; img.dataset.translateX = 0; img.dataset.translateY = 0;
499
 
500
- // Pan Interaction
501
  img.onmousedown = (e) => {
502
  e.preventDefault(); e.stopPropagation();
503
  selectPanel(pDiv);
@@ -506,6 +510,17 @@ INDEX_HTML = '''
506
  img.classList.add('panning');
507
  };
508
 
 
 
 
 
 
 
 
 
 
 
 
509
  pDiv.appendChild(img);
510
  grid.appendChild(pDiv);
511
  });
@@ -559,13 +574,10 @@ INDEX_HTML = '''
559
  else if(dragType === 'pan') {
560
  const dx = e.clientX - dragStart.x; const dy = e.clientY - dragStart.y;
561
  const img = activeObj;
562
- // Get current vals
563
  let cx = parseFloat(img.dataset.translateX);
564
  let cy = parseFloat(img.dataset.translateY);
565
-
566
  img.dataset.translateX = cx + dx;
567
  img.dataset.translateY = cy + dy;
568
-
569
  updateImageTransform(img);
570
  dragStart = {x: e.clientX, y: e.clientY};
571
  }
@@ -581,7 +593,6 @@ INDEX_HTML = '''
581
  dragType = null; activeObj = null;
582
  });
583
 
584
- // HELPERS
585
  function selectBubble(el) { if(selectedBubble) selectedBubble.classList.remove('selected'); selectedBubble = el; el.classList.add('selected'); }
586
  function selectPanel(el) {
587
  if(selectedPanel) selectedPanel.classList.remove('selected');
@@ -596,10 +607,10 @@ INDEX_HTML = '''
596
  }
597
  function deleteBubble() { if(selectedBubble) { selectedBubble.remove(); selectedBubble=null; } }
598
 
599
- function handleZoom(el) {
600
  if(selectedPanel) {
601
  const img = selectedPanel.querySelector('img');
602
- img.dataset.zoom = el.value;
603
  updateImageTransform(img);
604
  }
605
  }
@@ -642,7 +653,6 @@ def index():
642
 
643
  @app.route('/uploader', methods=['POST'])
644
  def upload():
645
- # Robust checks
646
  sid = request.args.get('sid') or request.form.get('sid')
647
  if not sid: return jsonify({'success': False, 'message': 'Missing session ID'}), 400
648
 
@@ -651,8 +661,6 @@ def upload():
651
  file = request.files['file']
652
 
653
  if not file or file.filename == '':
654
- # Debugging aid
655
- print(f"Files keys: {list(request.files.keys())}")
656
  return jsonify({'success': False, 'message': 'No file uploaded'}), 400
657
 
658
  target_pages = request.form.get('target_pages', 4)
 
42
  # ======================================================
43
  # ๐Ÿ”ง APP CONFIG
44
  # ======================================================
 
45
  app = Flask(__name__)
46
+ app.config['MAX_CONTENT_LENGTH'] = 500 * 1024 * 1024 # 500MB Upload Limit
 
 
47
 
48
  def generate_save_code(length=8):
49
  chars = string.ascii_uppercase + string.digits
 
78
  # ======================================================
79
  @spaces.GPU(duration=120)
80
  def generate_comic_gpu(video_path, user_dir, frames_dir, metadata_path, target_pages):
81
+ print(f"๐Ÿš€ Generating: {video_path}")
82
 
83
  import cv2
84
  import srt
85
  import numpy as np
86
 
 
87
  cap = cv2.VideoCapture(video_path)
88
  if not cap.isOpened(): raise Exception("Cannot open video")
89
  fps = cap.get(cv2.CAP_PROP_FPS) or 25
 
91
  duration = total_frames / fps
92
  cap.release()
93
 
94
+ # Subtitles
95
  user_srt = os.path.join(user_dir, 'subs.srt')
96
  if not os.path.exists(user_srt):
97
  with open(user_srt, 'w') as f: f.write("1\n00:00:01,000 --> 00:00:04,000\n...\n")
 
103
  valid_subs = [s for s in all_subs if s.content.strip()]
104
  raw_moments = [{'text': s.content, 'start': s.start.total_seconds(), 'end': s.end.total_seconds()} for s in valid_subs]
105
 
 
106
  if target_pages <= 0: target_pages = 1
107
  panels_per_page = 4
108
  total_panels_needed = target_pages * panels_per_page
 
117
  indices = np.linspace(0, len(raw_moments) - 1, total_panels_needed, dtype=int)
118
  selected_moments = [raw_moments[i] for i in indices]
119
 
 
120
  frame_metadata = {}
121
  cap = cv2.VideoCapture(video_path)
122
  count = 0
 
129
  ret, frame = cap.read()
130
 
131
  if ret:
132
+ # ----------------------------------------------------
133
+ # ๐ŸŽฏ QUALITY FIX: EXTRACT AT HD (1280px width)
134
+ # Do NOT crop to square here. Keep 16:9 Aspect Ratio.
135
+ # This allows the frontend "Zoom" to reveal details.
136
+ # ----------------------------------------------------
137
+ h, w = frame.shape[:2]
138
+ aspect = w / h
139
+ new_w = 1280
140
+ new_h = int(new_w / aspect)
141
+ frame = cv2.resize(frame, (new_w, new_h))
142
 
143
  fname = f"frame_{count:04d}.png"
144
  p = os.path.join(frames_dir, fname)
 
151
  cap.release()
152
  with open(metadata_path, 'w') as f: json.dump(frame_metadata, f, indent=2)
153
 
 
154
  bubbles_list = []
155
  for f in frame_files_ordered:
156
  dialogue = frame_metadata.get(f, {}).get('dialogue', '')
 
159
  elif '!' in dialogue: b_type = 'reaction'
160
  bubbles_list.append(bubble(dialog=dialogue, bubble_offset_x=50, bubble_offset_y=50, type=b_type))
161
 
 
162
  pages = []
163
  for i in range(target_pages):
164
  start_idx = i * 4
 
166
  p_frames = frame_files_ordered[start_idx:end_idx]
167
  p_bubbles = bubbles_list[start_idx:end_idx]
168
 
 
169
  while len(p_frames) < 4:
170
  fname = f"empty_{i}_{len(p_frames)}.png"
171
  img = np.zeros((720, 1280, 3), dtype=np.uint8); img[:] = (40,40,40)
 
205
  cap.release()
206
 
207
  if ret:
208
+ h, w = frame.shape[:2]
209
+ aspect = w / h
210
+ new_w = 1280
211
+ new_h = int(new_w / aspect)
212
+ frame = cv2.resize(frame, (new_w, new_h))
213
+
214
  p = os.path.join(frames_dir, fname)
215
  cv2.imwrite(p, frame)
216
 
 
242
 
243
  def run(self, target_pages):
244
  try:
245
+ self.write_status("Waiting for GPU...", 5)
246
  data = generate_comic_gpu(self.video_path, self.user_dir, self.frames_dir, self.metadata_path, int(target_pages))
247
  with open(os.path.join(self.output_dir, 'pages.json'), 'w') as f:
248
  json.dump(data, f, indent=2)
 
256
  json.dump({'message': msg, 'progress': prog}, f)
257
 
258
  # ======================================================
259
+ # ๐ŸŒ ROUTES & FRONTEND
260
  # ======================================================
261
  INDEX_HTML = '''
262
+ <!DOCTYPE html><html lang="en"> <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <title>Square HD Comic</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&display=swap" rel="stylesheet"> <style> * { box-sizing: border-box; } body { background-color: #222; font-family: 'Lato', sans-serif; color: #eee; margin: 0; min-height: 100vh; }
263
 
264
  #upload-container { display: flex; flex-direction:column; justify-content: center; align-items: center; min-height: 100vh; width: 100%; padding: 20px; }
265
  .upload-box { max-width: 500px; width: 100%; padding: 40px; background: #333; border-radius: 12px; box-shadow: 0 8px 30px rgba(0,0,0,0.5); text-align: center; }
 
281
  .loader { width: 120px; height: 20px; background: radial-gradient(circle 10px, #e67e22 100%, transparent 0); background-size: 20px 20px; animation: ball 1s infinite linear; margin: 20px auto; }
282
  @keyframes ball { 0%{background-position:0 50%} 100%{background-position:100px 50%} }
283
 
284
+ /* === SQUARE COMIC LAYOUT (800x800) === */
285
  .comic-wrapper { max-width: 1000px; margin: 0 auto; }
286
  .page-wrapper { margin: 30px auto; display: flex; flex-direction: column; align-items: center; }
287
  .page-title { text-align: center; color: #eee; margin-bottom: 10px; font-size: 18px; font-weight: bold; }
288
 
289
  .comic-page {
290
+ width: 800px;
291
+ height: 800px;
292
  background: white;
293
  box-shadow: 0 4px 20px rgba(0,0,0,0.5);
294
  position: relative;
 
306
  --y: 50%;
307
  --t1: 100%; --t2: 100%; /* Hidden by default */
308
  --b1: 100%; --b2: 100%; /* Hidden by default */
309
+ --gap: 3px;
310
  }
311
 
312
  .panel {
 
316
 
317
  .panel img {
318
  width: 100%; height: 100%;
319
+ object-fit: cover; /* Keeps 16:9 aspect inside square panel */
320
  transform-origin: center;
321
+ transition: transform 0.05s ease-out;
322
  display: block;
323
  }
324
  .panel img.panning { cursor: grabbing; transition: none; }
325
+ .panel.selected { outline: 4px solid #2196F3; z-index: 5; }
326
 
327
  /* === CLIP PATHS === */
328
  .panel:nth-child(1) { clip-path: polygon(0 0, calc(var(--t1) - var(--gap)) 0, calc(var(--t2) - var(--gap)) calc(var(--y) - var(--gap)), 0 calc(var(--y) - var(--gap))); z-index: 1; }
 
332
 
333
  /* === HANDLES === */
334
  .handle {
335
+ position: absolute; width: 24px; height: 24px;
336
  border: 2px solid white; border-radius: 50%;
337
  transform: translate(-50%, -50%);
338
  z-index: 101; cursor: ew-resize;
 
340
  }
341
  .handle:hover { transform: scale(1.3); }
342
 
343
+ .h-t1 { background: #3498db; left: var(--t1); top: 0%; margin-top: 15px; }
344
+ .h-t2 { background: #3498db; left: var(--t2); top: 50%; margin-top: -15px; }
345
+ .h-b1 { background: #2ecc71; left: var(--b1); top: 50%; margin-top: 15px; }
346
+ .h-b2 { background: #2ecc71; left: var(--b2); top: 100%; margin-top: -15px; }
347
 
348
  /* SPEECH BUBBLES */
349
  .speech-bubble {
350
  position: absolute; display: flex; justify-content: center; align-items: center;
351
  width: 150px; height: 80px; min-width: 50px; min-height: 30px; box-sizing: border-box;
352
  z-index: 10; cursor: move; font-family: 'Comic Neue', cursive; font-weight: bold;
353
+ font-size: 16px; text-align: center;
354
  overflow: visible; line-height: 1.2; --tail-pos: 50%;
355
  }
356
  .bubble-text { padding: 0.5em; width: 100%; height: 100%; display: flex; align-items: center; justify-content: center; overflow: hidden; white-space: pre-wrap; pointer-events: none; }
 
358
 
359
  .speech-bubble.speech {
360
  background: #fff; color: #000; border: 2px solid #000;
361
+ border-radius: 50%;
362
  }
363
  .speech-bubble.speech::after {
364
  content: ''; position: absolute; bottom: -10px; left: var(--tail-pos);
 
381
 
382
  <div id="upload-container">
383
  <div class="upload-box">
384
+ <h1>โšก Square HD Comic Gen</h1>
385
  <input type="file" id="file-upload" class="file-input" onchange="document.getElementById('fn').innerText=this.files[0].name">
386
  <label for="file-upload" class="file-label">๐Ÿ“ Choose Video</label>
387
  <span id="fn" style="margin-bottom:10px; display:block; color:#aaa;">No file selected</span>
 
391
  <input type="number" id="page-count" value="4" min="1" max="15">
392
  </div>
393
 
394
+ <button class="submit-btn" onclick="upload()">๐Ÿš€ Generate</button>
395
 
396
  <div class="loading-view" id="loading-view" style="display:none; margin-top:20px;">
397
  <div class="loader" style="margin:0 auto;"></div>
 
424
  </div>
425
 
426
  <div class="control-group">
427
+ <label>๐Ÿ” Zoom (Mouse Wheel):</label>
428
+ <input type="range" id="zoom-slider" min="100" max="300" value="100" step="5" oninput="handleZoom(this.value)" disabled>
429
  <button onclick="resetPanelTransform()" class="secondary-btn">Reset View</button>
430
  </div>
431
 
 
501
  // Init transform data
502
  img.dataset.zoom = 100; img.dataset.translateX = 0; img.dataset.translateY = 0;
503
 
504
+ // MOUSE EVENTS
505
  img.onmousedown = (e) => {
506
  e.preventDefault(); e.stopPropagation();
507
  selectPanel(pDiv);
 
510
  img.classList.add('panning');
511
  };
512
 
513
+ // ๐Ÿš€ ZOOM WITH WHEEL
514
+ img.onwheel = (e) => {
515
+ e.preventDefault();
516
+ let zoom = parseFloat(img.dataset.zoom);
517
+ zoom += e.deltaY * -0.1;
518
+ zoom = Math.min(Math.max(100, zoom), 300);
519
+ img.dataset.zoom = zoom;
520
+ updateImageTransform(img);
521
+ if(selectedPanel === pDiv) document.getElementById('zoom-slider').value = zoom;
522
+ };
523
+
524
  pDiv.appendChild(img);
525
  grid.appendChild(pDiv);
526
  });
 
574
  else if(dragType === 'pan') {
575
  const dx = e.clientX - dragStart.x; const dy = e.clientY - dragStart.y;
576
  const img = activeObj;
 
577
  let cx = parseFloat(img.dataset.translateX);
578
  let cy = parseFloat(img.dataset.translateY);
 
579
  img.dataset.translateX = cx + dx;
580
  img.dataset.translateY = cy + dy;
 
581
  updateImageTransform(img);
582
  dragStart = {x: e.clientX, y: e.clientY};
583
  }
 
593
  dragType = null; activeObj = null;
594
  });
595
 
 
596
  function selectBubble(el) { if(selectedBubble) selectedBubble.classList.remove('selected'); selectedBubble = el; el.classList.add('selected'); }
597
  function selectPanel(el) {
598
  if(selectedPanel) selectedPanel.classList.remove('selected');
 
607
  }
608
  function deleteBubble() { if(selectedBubble) { selectedBubble.remove(); selectedBubble=null; } }
609
 
610
+ function handleZoom(val) {
611
  if(selectedPanel) {
612
  const img = selectedPanel.querySelector('img');
613
+ img.dataset.zoom = val;
614
  updateImageTransform(img);
615
  }
616
  }
 
653
 
654
  @app.route('/uploader', methods=['POST'])
655
  def upload():
 
656
  sid = request.args.get('sid') or request.form.get('sid')
657
  if not sid: return jsonify({'success': False, 'message': 'Missing session ID'}), 400
658
 
 
661
  file = request.files['file']
662
 
663
  if not file or file.filename == '':
 
 
664
  return jsonify({'success': False, 'message': 'No file uploaded'}), 400
665
 
666
  target_pages = request.form.get('target_pages', 4)