tester343 commited on
Commit
4507661
·
verified ·
1 Parent(s): c7a0992

Update app_enhanced.py

Browse files
Files changed (1) hide show
  1. app_enhanced.py +148 -36
app_enhanced.py CHANGED
@@ -24,7 +24,7 @@ def gpu_warmup():
24
  return True
25
 
26
  # ======================================================
27
- # 💾 STORAGE SETUP
28
  # ======================================================
29
  if os.path.exists('/data'):
30
  BASE_STORAGE_PATH = '/data'
@@ -39,8 +39,11 @@ SAVED_COMICS_DIR = os.path.join(BASE_STORAGE_PATH, "saved_comics")
39
  os.makedirs(BASE_USER_DIR, exist_ok=True)
40
  os.makedirs(SAVED_COMICS_DIR, exist_ok=True)
41
 
 
 
 
42
  app = Flask(__name__)
43
- app.config['MAX_CONTENT_LENGTH'] = 500 * 1024 * 1024
44
 
45
  def generate_save_code(length=8):
46
  chars = string.ascii_uppercase + string.digits
@@ -50,37 +53,52 @@ def generate_save_code(length=8):
50
  return code
51
 
52
  # ======================================================
53
- # 🧠 DATA & GPU LOGIC
54
  # ======================================================
55
- def bubble(dialog="", x=50, y=20, type='speech'):
56
  return {
57
  'dialog': dialog,
58
- 'bubble_offset_x': int(x),
59
- 'bubble_offset_y': int(y),
 
 
 
60
  'type': type,
61
  'tail_pos': '50%',
 
62
  'colors': {'fill': '#ffffff', 'text': '#000000'},
63
  'font': "'Comic Neue', cursive"
64
  }
65
 
 
 
 
 
 
 
 
 
 
 
 
66
  @spaces.GPU(duration=300)
67
  def generate_comic_gpu(video_path, user_dir, frames_dir, metadata_path, target_pages):
68
- print(f"🚀 Generating HD Comic: {video_path}")
 
69
  import cv2
70
  import srt
71
  import numpy as np
72
  from backend.subtitles.subs_real import get_real_subtitles
73
 
 
74
  cap = cv2.VideoCapture(video_path)
75
- if not cap.isOpened():
76
- raise Exception("Cannot open video")
77
-
78
  fps = cap.get(cv2.CAP_PROP_FPS) or 25
79
  total_frames = int(cap.get(cv2.CAP_PROP_FRAME_COUNT))
80
  duration = total_frames / fps
81
  cap.release()
82
 
83
- # Subtitles logic with FIXED SYNTAX
84
  user_srt = os.path.join(user_dir, 'subs.srt')
85
  try:
86
  get_real_subtitles(video_path)
@@ -99,27 +117,31 @@ def generate_comic_gpu(video_path, user_dir, frames_dir, metadata_path, target_p
99
  except:
100
  all_subs = []
101
 
102
- valid_subs = [s for s in all_subs if s.content.strip()]
103
  if valid_subs:
104
  raw_moments = [{'text': s.content.strip(), 'start': s.start.total_seconds(), 'end': s.end.total_seconds()} for s in valid_subs]
105
  else:
 
106
  raw_moments = []
107
 
108
- # 4 Panels Per Page
109
  panels_per_page = 4
110
  total_panels_needed = int(target_pages) * panels_per_page
111
 
112
  selected_moments = []
113
  if not raw_moments:
 
114
  times = np.linspace(1, max(1, duration-1), total_panels_needed)
115
  for t in times:
116
  selected_moments.append({'text': '', 'start': t, 'end': t+1})
117
  elif len(raw_moments) <= total_panels_needed:
118
  selected_moments = raw_moments
119
  else:
 
120
  indices = np.linspace(0, len(raw_moments) - 1, total_panels_needed, dtype=int)
121
  selected_moments = [raw_moments[i] for i in indices]
122
 
 
123
  frame_metadata = {}
124
  cap = cv2.VideoCapture(video_path)
125
  count = 0
@@ -129,28 +151,49 @@ def generate_comic_gpu(video_path, user_dir, frames_dir, metadata_path, target_p
129
  mid = (moment['start'] + moment['end']) / 2
130
  cap.set(cv2.CAP_PROP_POS_MSEC, mid * 1000)
131
  ret, frame = cap.read()
 
132
  if ret:
133
- # 🎯 EXTRACT FULL HD (1280x720) - NO CROP
134
- frame = cv2.resize(frame, (1280, 720))
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
135
  fname = f"frame_{count:04d}.png"
136
  p = os.path.join(frames_dir, fname)
137
- cv2.imwrite(p, frame)
 
138
  frame_metadata[fname] = {'dialogue': moment['text'], 'time': mid}
139
  frame_files_ordered.append(fname)
140
  count += 1
 
141
  cap.release()
142
-
143
  with open(metadata_path, 'w') as f:
144
  json.dump(frame_metadata, f, indent=2)
145
 
 
146
  bubbles_list = []
147
  for f in frame_files_ordered:
148
  dialogue = frame_metadata.get(f, {}).get('dialogue', '')
149
  b_type = 'speech'
150
  if '(' in dialogue: b_type = 'narration'
151
  elif '!' in dialogue: b_type = 'reaction'
152
- bubbles_list.append(bubble(dialog=dialogue, x=50, y=30, type=b_type))
 
 
153
 
 
154
  pages = []
155
  for i in range(int(target_pages)):
156
  start_idx = i * 4
@@ -160,17 +203,24 @@ def generate_comic_gpu(video_path, user_dir, frames_dir, metadata_path, target_p
160
 
161
  while len(p_frames) < 4:
162
  fname = f"empty_{i}_{len(p_frames)}.png"
163
- img = np.zeros((720, 1280, 3), dtype=np.uint8)
164
  img[:] = (30,30,30)
165
  cv2.imwrite(os.path.join(frames_dir, fname), img)
166
  p_frames.append(fname)
167
  p_bubbles.append(bubble(dialog="", type='speech'))
168
 
169
  if p_frames:
170
- pg_panels = [{'image': f} for f in p_frames]
171
- pages.append({'panels': pg_panels, 'bubbles': p_bubbles})
172
-
173
- return pages
 
 
 
 
 
 
 
174
 
175
  @spaces.GPU
176
  def regen_frame_gpu(video_path, frames_dir, metadata_path, fname, direction):
@@ -178,7 +228,6 @@ def regen_frame_gpu(video_path, frames_dir, metadata_path, fname, direction):
178
  import json
179
  if not os.path.exists(metadata_path):
180
  return {"success": False, "message": "No metadata"}
181
-
182
  with open(metadata_path, 'r') as f:
183
  meta = json.load(f)
184
 
@@ -193,8 +242,17 @@ def regen_frame_gpu(video_path, frames_dir, metadata_path, fname, direction):
193
  cap.release()
194
 
195
  if ret:
196
- frame = cv2.resize(frame, (1280, 720))
197
- cv2.imwrite(os.path.join(frames_dir, fname), frame)
 
 
 
 
 
 
 
 
 
198
  if isinstance(meta[fname], dict):
199
  meta[fname]['time'] = new_t
200
  else:
@@ -202,7 +260,39 @@ def regen_frame_gpu(video_path, frames_dir, metadata_path, fname, direction):
202
  with open(metadata_path, 'w') as f:
203
  json.dump(meta, f, indent=2)
204
  return {"success": True, "message": f"Time: {new_t:.2f}s"}
205
- return {"success": False}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
206
 
207
  # ======================================================
208
  # 💻 BACKEND CLASS
@@ -228,7 +318,7 @@ class EnhancedComicGenerator:
228
 
229
  def run(self, target_pages):
230
  try:
231
- self.write_status("Generating...", 10)
232
  data = generate_comic_gpu(self.video_path, self.user_dir, self.frames_dir, self.metadata_path, int(target_pages))
233
  with open(os.path.join(self.output_dir, 'pages.json'), 'w') as f:
234
  json.dump(data, f, indent=2)
@@ -242,10 +332,10 @@ class EnhancedComicGenerator:
242
  json.dump({'message': msg, 'progress': prog}, f)
243
 
244
  # ======================================================
245
- # 🌐 FRONTEND
246
  # ======================================================
247
  INDEX_HTML = '''
248
- <!DOCTYPE html><html lang="en"> <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <title>Ultimate Square 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&family=Gloria+Hallelujah&family=Lato&display=swap" rel="stylesheet"> <style> * { box-sizing: border-box; } body { background-color: #2c3e50; font-family: 'Lato', sans-serif; color: #eee; margin: 0; min-height: 100vh; }
249
 
250
  #upload-container { display: flex; flex-direction:column; justify-content: center; align-items: center; min-height: 100vh; width: 100%; padding: 20px; }
251
  .upload-box { max-width: 500px; width: 100%; padding: 40px; background: #34495e; border-radius: 12px; box-shadow: 0 10px 30px rgba(0,0,0,0.5); text-align: center; }
@@ -285,7 +375,15 @@ INDEX_HTML = '''
285
  }
286
 
287
  .panel { position: absolute; top: 0; left: 0; width: 100%; height: 100%; overflow: hidden; background: #1a1a1a; cursor: grab; }
288
- .panel img { width: 100%; height: 100%; object-fit: cover; transform-origin: center; transition: transform 0.05s ease-out; display: block; }
 
 
 
 
 
 
 
 
289
  .panel img.panning { cursor: grabbing; transition: none; }
290
  .panel.selected { outline: 4px solid #3498db; z-index: 5; }
291
 
@@ -297,6 +395,7 @@ INDEX_HTML = '''
297
 
298
  /* Handles */
299
  .handle { position: absolute; width: 26px; height: 26px; border: 3px solid white; border-radius: 50%; transform: translate(-50%, -50%); z-index: 101; cursor: ew-resize; box-shadow: 0 2px 5px rgba(0,0,0,0.8); }
 
300
  .h-t1 { background: #3498db; left: var(--t1); top: 0%; margin-top: 15px; }
301
  .h-t2 { background: #3498db; left: var(--t2); top: 50%; margin-top: -15px; }
302
  .h-b1 { background: #2ecc71; left: var(--b1); top: 50%; margin-top: 15px; }
@@ -312,15 +411,16 @@ INDEX_HTML = '''
312
  .bubble-text { padding: 0.8em; width: 100%; height: 100%; display: flex; align-items: center; justify-content: center; overflow: hidden; white-space: pre-wrap; pointer-events: none; border-radius: inherit; }
313
  .speech-bubble.selected { outline: 2px dashed #4CAF50; z-index: 100; }
314
 
315
- .speech-bubble.speech { background: var(--bubble-fill, #fff); color: var(--bubble-text, #000); border: 2px solid #000; border-radius: 50%; width: 180px; height: 100px; }
 
316
  .speech-bubble.speech::after { content: ''; position: absolute; bottom: -12px; left: var(--tail-pos); border: 12px solid transparent; border-top-color: #000; border-bottom: 0; margin-left: -12px; }
317
 
318
- .speech-bubble.thought { background: var(--bubble-fill, #fff); color: var(--bubble-text, #000); border: 2px dashed #555; border-radius: 50%; width: 180px; height: 100px; }
319
  .speech-bubble.thought .dots { position: absolute; bottom:-20px; left:20px; width:15px; height:15px; background:#fff; border:2px solid #555; border-radius:50%; }
320
 
321
- .speech-bubble.reaction { background: #ff0; border: 3px solid red; color: red; font-family: 'Bangers'; text-transform: uppercase; width: 200px; height: 120px; clip-path: polygon(0% 25%, 17% 21%, 17% 0%, 31% 16%, 50% 4%, 69% 16%, 83% 0%, 83% 21%, 100% 25%, 85% 45%, 95% 62%, 82% 79%, 100% 97%, 79% 89%, 60% 98%, 46% 82%, 27% 95%, 15% 78%, 5% 62%, 15% 45%); }
322
 
323
- .speech-bubble.narration { background: #eee; border: 2px solid #000; color: #000; border-radius: 0; width: 300px; height: 60px; font-family: 'Lato'; bottom: 10px; left: 50%; transform: translateX(-50%); }
324
 
325
  .resize-handle { position: absolute; bottom:-5px; right:-5px; width:15px; height:15px; background:#3498db; border:1px solid white; cursor:se-resize; display:none; }
326
  .speech-bubble.selected .resize-handle { display:block; }
@@ -721,6 +821,18 @@ INDEX_HTML = '''
721
  if(d.success) img.src = `/frames/${fname}?sid=${sid}&t=${Date.now()}`;
722
  img.style.opacity='1'; saveState();
723
  }
 
 
 
 
 
 
 
 
 
 
 
 
724
 
725
  async function exportComic() {
726
  const pgs = document.querySelectorAll('.comic-page');
@@ -751,7 +863,7 @@ INDEX_HTML = '''
751
  const layout = { t1: grid.style.getPropertyValue('--t1')||'100%', t2: grid.style.getPropertyValue('--t2')||'100%', b1: grid.style.getPropertyValue('--b1')||'100%', b2: grid.style.getPropertyValue('--b2')||'100%' };
752
  const bubbles = [];
753
  grid.querySelectorAll('.speech-bubble').forEach(b => {
754
- bubbles.push({ text: b.querySelector('.bubble-text').textContent, left: b.style.left, top: b.style.top, width: b.style.width, height: b.style.height, type: b.dataset.type, colors: { fill: b.style.getPropertyValue('--bubble-fill'), text: b.style.getPropertyValue('--bubble-text') }, tailPos: b.style.getPropertyValue('--tail-pos') });
755
  });
756
  const panels = [];
757
  grid.querySelectorAll('.panel').forEach(pan => {
 
24
  return True
25
 
26
  # ======================================================
27
+ # 💾 PERSISTENT STORAGE
28
  # ======================================================
29
  if os.path.exists('/data'):
30
  BASE_STORAGE_PATH = '/data'
 
39
  os.makedirs(BASE_USER_DIR, exist_ok=True)
40
  os.makedirs(SAVED_COMICS_DIR, exist_ok=True)
41
 
42
+ # ======================================================
43
+ # 🔧 APP CONFIG
44
+ # ======================================================
45
  app = Flask(__name__)
46
+ app.config['MAX_CONTENT_LENGTH'] = 500 * 1024 * 1024 # 500MB Limit
47
 
48
  def generate_save_code(length=8):
49
  chars = string.ascii_uppercase + string.digits
 
53
  return code
54
 
55
  # ======================================================
56
+ # 🧱 DATA CLASSES (Restored from safwe.py)
57
  # ======================================================
58
+ def bubble(dialog="", bubble_offset_x=50, bubble_offset_y=20, lip_x=-1, lip_y=-1, emotion='normal', type='speech'):
59
  return {
60
  'dialog': dialog,
61
+ 'bubble_offset_x': int(bubble_offset_x),
62
+ 'bubble_offset_y': int(bubble_offset_y),
63
+ 'lip_x': int(lip_x),
64
+ 'lip_y': int(lip_y),
65
+ 'emotion': emotion,
66
  'type': type,
67
  'tail_pos': '50%',
68
+ 'classes': f'speech-bubble {type} tail-bottom',
69
  'colors': {'fill': '#ffffff', 'text': '#000000'},
70
  'font': "'Comic Neue', cursive"
71
  }
72
 
73
+ def panel(image=""):
74
+ return {'image': image}
75
+
76
+ class Page:
77
+ def __init__(self, panels, bubbles):
78
+ self.panels = panels
79
+ self.bubbles = bubbles
80
+
81
+ # ======================================================
82
+ # 🧠 GPU GENERATION (SQUARE PADDING LOGIC)
83
+ # ======================================================
84
  @spaces.GPU(duration=300)
85
  def generate_comic_gpu(video_path, user_dir, frames_dir, metadata_path, target_pages):
86
+ print(f"🚀 Generating Square Comic: {video_path}")
87
+
88
  import cv2
89
  import srt
90
  import numpy as np
91
  from backend.subtitles.subs_real import get_real_subtitles
92
 
93
+ # 1. Video Setup
94
  cap = cv2.VideoCapture(video_path)
95
+ if not cap.isOpened(): raise Exception("Cannot open video")
 
 
96
  fps = cap.get(cv2.CAP_PROP_FPS) or 25
97
  total_frames = int(cap.get(cv2.CAP_PROP_FRAME_COUNT))
98
  duration = total_frames / fps
99
  cap.release()
100
 
101
+ # 2. SUBTITLES
102
  user_srt = os.path.join(user_dir, 'subs.srt')
103
  try:
104
  get_real_subtitles(video_path)
 
117
  except:
118
  all_subs = []
119
 
120
+ valid_subs = [s for s in all_subs if s.content and s.content.strip()]
121
  if valid_subs:
122
  raw_moments = [{'text': s.content.strip(), 'start': s.start.total_seconds(), 'end': s.end.total_seconds()} for s in valid_subs]
123
  else:
124
+ # Fallback moments if no speech
125
  raw_moments = []
126
 
127
+ # 3. Frame Selection (4 per page)
128
  panels_per_page = 4
129
  total_panels_needed = int(target_pages) * panels_per_page
130
 
131
  selected_moments = []
132
  if not raw_moments:
133
+ # Generate time-based moments
134
  times = np.linspace(1, max(1, duration-1), total_panels_needed)
135
  for t in times:
136
  selected_moments.append({'text': '', 'start': t, 'end': t+1})
137
  elif len(raw_moments) <= total_panels_needed:
138
  selected_moments = raw_moments
139
  else:
140
+ # Subsample moments
141
  indices = np.linspace(0, len(raw_moments) - 1, total_panels_needed, dtype=int)
142
  selected_moments = [raw_moments[i] for i in indices]
143
 
144
+ # 4. Extract & PAD TO SQUARE
145
  frame_metadata = {}
146
  cap = cv2.VideoCapture(video_path)
147
  count = 0
 
151
  mid = (moment['start'] + moment['end']) / 2
152
  cap.set(cv2.CAP_PROP_POS_MSEC, mid * 1000)
153
  ret, frame = cap.read()
154
+
155
  if ret:
156
+ # ----------------------------------------------------
157
+ # 🎯 SQUARE PADDING LOGIC (0% Cut)
158
+ # ----------------------------------------------------
159
+ h, w = frame.shape[:2]
160
+ sq_dim = max(h, w)
161
+
162
+ # Create black square canvas
163
+ square_img = np.zeros((sq_dim, sq_dim, 3), dtype=np.uint8)
164
+
165
+ # Calculate centering offsets
166
+ x_off = (sq_dim - w) // 2
167
+ y_off = (sq_dim - h) // 2
168
+
169
+ # Paste original frame in center
170
+ square_img[y_off:y_off+h, x_off:x_off+w] = frame
171
+
172
+ # Save
173
  fname = f"frame_{count:04d}.png"
174
  p = os.path.join(frames_dir, fname)
175
+ cv2.imwrite(p, square_img)
176
+
177
  frame_metadata[fname] = {'dialogue': moment['text'], 'time': mid}
178
  frame_files_ordered.append(fname)
179
  count += 1
180
+
181
  cap.release()
 
182
  with open(metadata_path, 'w') as f:
183
  json.dump(frame_metadata, f, indent=2)
184
 
185
+ # 5. Bubbles
186
  bubbles_list = []
187
  for f in frame_files_ordered:
188
  dialogue = frame_metadata.get(f, {}).get('dialogue', '')
189
  b_type = 'speech'
190
  if '(' in dialogue: b_type = 'narration'
191
  elif '!' in dialogue: b_type = 'reaction'
192
+
193
+ # Use full bubble function structure
194
+ bubbles_list.append(bubble(dialog=dialogue, bubble_offset_x=50, bubble_offset_y=20, type=b_type))
195
 
196
+ # 6. Pages
197
  pages = []
198
  for i in range(int(target_pages)):
199
  start_idx = i * 4
 
203
 
204
  while len(p_frames) < 4:
205
  fname = f"empty_{i}_{len(p_frames)}.png"
206
+ img = np.zeros((800, 800, 3), dtype=np.uint8)
207
  img[:] = (30,30,30)
208
  cv2.imwrite(os.path.join(frames_dir, fname), img)
209
  p_frames.append(fname)
210
  p_bubbles.append(bubble(dialog="", type='speech'))
211
 
212
  if p_frames:
213
+ pg_panels = [panel(image=f) for f in p_frames]
214
+ pages.append(Page(panels=pg_panels, bubbles=p_bubbles))
215
+
216
+ # 7. Convert to Dict
217
+ result = []
218
+ for pg in pages:
219
+ p_data = [p if isinstance(p, dict) else p.__dict__ for p in pg.panels]
220
+ b_data = [b if isinstance(b, dict) else b.__dict__ for b in pg.bubbles]
221
+ result.append({'panels': p_data, 'bubbles': b_data})
222
+
223
+ return result
224
 
225
  @spaces.GPU
226
  def regen_frame_gpu(video_path, frames_dir, metadata_path, fname, direction):
 
228
  import json
229
  if not os.path.exists(metadata_path):
230
  return {"success": False, "message": "No metadata"}
 
231
  with open(metadata_path, 'r') as f:
232
  meta = json.load(f)
233
 
 
242
  cap.release()
243
 
244
  if ret:
245
+ # Re-apply Square Padding
246
+ h, w = frame.shape[:2]
247
+ sq_dim = max(h, w)
248
+ square_img = np.zeros((sq_dim, sq_dim, 3), dtype=np.uint8)
249
+ x_off = (sq_dim - w) // 2
250
+ y_off = (sq_dim - h) // 2
251
+ square_img[y_off:y_off+h, x_off:x_off+w] = frame
252
+
253
+ p = os.path.join(frames_dir, fname)
254
+ cv2.imwrite(p, square_img)
255
+
256
  if isinstance(meta[fname], dict):
257
  meta[fname]['time'] = new_t
258
  else:
 
260
  with open(metadata_path, 'w') as f:
261
  json.dump(meta, f, indent=2)
262
  return {"success": True, "message": f"Time: {new_t:.2f}s"}
263
+ return {"success": False, "message": "End of video"}
264
+
265
+ @spaces.GPU
266
+ def get_frame_at_ts_gpu(video_path, frames_dir, metadata_path, fname, ts):
267
+ import cv2
268
+ import json
269
+ cap = cv2.VideoCapture(video_path)
270
+ cap.set(cv2.CAP_PROP_POS_MSEC, float(ts) * 1000)
271
+ ret, frame = cap.read()
272
+ cap.release()
273
+
274
+ if ret:
275
+ h, w = frame.shape[:2]
276
+ sq_dim = max(h, w)
277
+ square_img = np.zeros((sq_dim, sq_dim, 3), dtype=np.uint8)
278
+ x_off = (sq_dim - w) // 2
279
+ y_off = (sq_dim - h) // 2
280
+ square_img[y_off:y_off+h, x_off:x_off+w] = frame
281
+
282
+ p = os.path.join(frames_dir, fname)
283
+ cv2.imwrite(p, square_img)
284
+ if os.path.exists(metadata_path):
285
+ with open(metadata_path, 'r') as f:
286
+ meta = json.load(f)
287
+ if fname in meta:
288
+ if isinstance(meta[fname], dict):
289
+ meta[fname]['time'] = float(ts)
290
+ else:
291
+ meta[fname] = float(ts)
292
+ with open(metadata_path, 'w') as f:
293
+ json.dump(meta, f, indent=2)
294
+ return {"success": True, "message": f"Jumped to {ts}s"}
295
+ return {"success": False, "message": "Invalid timestamp"}
296
 
297
  # ======================================================
298
  # 💻 BACKEND CLASS
 
318
 
319
  def run(self, target_pages):
320
  try:
321
+ self.write_status("Generating...", 5)
322
  data = generate_comic_gpu(self.video_path, self.user_dir, self.frames_dir, self.metadata_path, int(target_pages))
323
  with open(os.path.join(self.output_dir, 'pages.json'), 'w') as f:
324
  json.dump(data, f, indent=2)
 
332
  json.dump({'message': msg, 'progress': prog}, f)
333
 
334
  # ======================================================
335
+ # 🌐 ROUTES & FRONTEND
336
  # ======================================================
337
  INDEX_HTML = '''
338
+ <!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: #2c3e50; font-family: 'Comic Neue', sans-serif; color: #eee; margin: 0; min-height: 100vh; }
339
 
340
  #upload-container { display: flex; flex-direction:column; justify-content: center; align-items: center; min-height: 100vh; width: 100%; padding: 20px; }
341
  .upload-box { max-width: 500px; width: 100%; padding: 40px; background: #34495e; border-radius: 12px; box-shadow: 0 10px 30px rgba(0,0,0,0.5); text-align: center; }
 
375
  }
376
 
377
  .panel { position: absolute; top: 0; left: 0; width: 100%; height: 100%; overflow: hidden; background: #1a1a1a; cursor: grab; }
378
+
379
+ /* IMAGE: Cover ensures it fills. Zoom allows control. */
380
+ .panel img {
381
+ width: 100%; height: 100%;
382
+ object-fit: cover;
383
+ transform-origin: center;
384
+ transition: transform 0.05s ease-out;
385
+ display: block;
386
+ }
387
  .panel img.panning { cursor: grabbing; transition: none; }
388
  .panel.selected { outline: 4px solid #3498db; z-index: 5; }
389
 
 
395
 
396
  /* Handles */
397
  .handle { position: absolute; width: 26px; height: 26px; border: 3px solid white; border-radius: 50%; transform: translate(-50%, -50%); z-index: 101; cursor: ew-resize; box-shadow: 0 2px 5px rgba(0,0,0,0.8); }
398
+ .handle:hover { transform: translate(-50%, -50%) scale(1.3); }
399
  .h-t1 { background: #3498db; left: var(--t1); top: 0%; margin-top: 15px; }
400
  .h-t2 { background: #3498db; left: var(--t2); top: 50%; margin-top: -15px; }
401
  .h-b1 { background: #2ecc71; left: var(--b1); top: 50%; margin-top: 15px; }
 
411
  .bubble-text { padding: 0.8em; width: 100%; height: 100%; display: flex; align-items: center; justify-content: center; overflow: hidden; white-space: pre-wrap; pointer-events: none; border-radius: inherit; }
412
  .speech-bubble.selected { outline: 2px dashed #4CAF50; z-index: 100; }
413
 
414
+ /* Bubble Styles */
415
+ .speech-bubble.speech { background: var(--bubble-fill, #fff); color: var(--bubble-text, #000); border: 3px solid #000; border-radius: 50%; }
416
  .speech-bubble.speech::after { content: ''; position: absolute; bottom: -12px; left: var(--tail-pos); border: 12px solid transparent; border-top-color: #000; border-bottom: 0; margin-left: -12px; }
417
 
418
+ .speech-bubble.thought { background: var(--bubble-fill, #fff); color: var(--bubble-text, #000); border: 2px dashed #555; border-radius: 50%; }
419
  .speech-bubble.thought .dots { position: absolute; bottom:-20px; left:20px; width:15px; height:15px; background:#fff; border:2px solid #555; border-radius:50%; }
420
 
421
+ .speech-bubble.reaction { background: #ff0; border: 3px solid red; color: red; font-family: 'Bangers'; text-transform: uppercase; clip-path: polygon(0% 25%, 17% 21%, 17% 0%, 31% 16%, 50% 4%, 69% 16%, 83% 0%, 83% 21%, 100% 25%, 85% 45%, 95% 62%, 82% 79%, 100% 97%, 79% 89%, 60% 98%, 46% 82%, 27% 95%, 15% 78%, 5% 62%, 15% 45%); }
422
 
423
+ .speech-bubble.narration { background: #eee; border: 2px solid #000; color: #000; border-radius: 0; font-family: 'Lato'; bottom: 10px; left: 50%; transform: translateX(-50%); }
424
 
425
  .resize-handle { position: absolute; bottom:-5px; right:-5px; width:15px; height:15px; background:#3498db; border:1px solid white; cursor:se-resize; display:none; }
426
  .speech-bubble.selected .resize-handle { display:block; }
 
821
  if(d.success) img.src = `/frames/${fname}?sid=${sid}&t=${Date.now()}`;
822
  img.style.opacity='1'; saveState();
823
  }
824
+ async function gotoTimestamp() {
825
+ if(!selectedPanel) return alert("Select a panel");
826
+ let v = document.getElementById('timestamp-input').value.trim();
827
+ if(!v) return;
828
+ if(v.includes(':')) { let p = v.split(':'); v = parseInt(p[0]) * 60 + parseFloat(p[1]); } else { v = parseFloat(v); }
829
+ const img = selectedPanel.querySelector('img'); let fname = img.src.split('/').pop().split('?')[0];
830
+ img.style.opacity = '0.5';
831
+ const r = await fetch(`/goto_timestamp?sid=${sid}`, { method:'POST', headers:{'Content-Type':'application/json'}, body:JSON.stringify({filename:fname, timestamp:v}) });
832
+ const d = await r.json();
833
+ if(d.success) img.src = `/frames/${fname}?sid=${sid}&t=${Date.now()}`;
834
+ img.style.opacity='1'; saveState();
835
+ }
836
 
837
  async function exportComic() {
838
  const pgs = document.querySelectorAll('.comic-page');
 
863
  const layout = { t1: grid.style.getPropertyValue('--t1')||'100%', t2: grid.style.getPropertyValue('--t2')||'100%', b1: grid.style.getPropertyValue('--b1')||'100%', b2: grid.style.getPropertyValue('--b2')||'100%' };
864
  const bubbles = [];
865
  grid.querySelectorAll('.speech-bubble').forEach(b => {
866
+ bubbles.push({ text: b.querySelector('.bubble-text').textContent, left: b.style.left, top: b.style.top, width: b.style.width, height: b.style.height, colors: { fill: b.style.getPropertyValue('--bubble-fill'), text: b.style.getPropertyValue('--bubble-text') } });
867
  });
868
  const panels = [];
869
  grid.querySelectorAll('.panel').forEach(pan => {