tester343 commited on
Commit
49ec187
·
verified ·
1 Parent(s): aad63bb

Update app_enhanced.py

Browse files
Files changed (1) hide show
  1. app_enhanced.py +92 -94
app_enhanced.py CHANGED
@@ -29,7 +29,7 @@ BASE_USER_DIR = os.path.join(BASE_STORAGE_PATH, "userdata")
29
  os.makedirs(BASE_USER_DIR, exist_ok=True)
30
 
31
  # ======================================================
32
- # 🔧 UTILS
33
  # ======================================================
34
  def sanitize_json(obj):
35
  if isinstance(obj, dict):
@@ -49,6 +49,7 @@ def sanitize_json(obj):
49
  # ======================================================
50
  @spaces.GPU(duration=300)
51
  def generate_comic_gpu(video_path, user_dir, frames_dir, metadata_path, target_pages):
 
52
  import cv2
53
  import srt
54
  import numpy as np
@@ -60,9 +61,11 @@ def generate_comic_gpu(video_path, user_dir, frames_dir, metadata_path, target_p
60
 
61
  cap = cv2.VideoCapture(video_path)
62
  fps = cap.get(cv2.CAP_PROP_FPS) or 25
63
- duration = int(cap.get(cv2.CAP_PROP_FRAME_COUNT)) / fps
 
64
  cap.release()
65
 
 
66
  user_srt = os.path.join(user_dir, 'subs.srt')
67
  try:
68
  get_real_subtitles(video_path)
@@ -74,6 +77,7 @@ def generate_comic_gpu(video_path, user_dir, frames_dir, metadata_path, target_p
74
  try: all_subs = list(srt.parse(f.read()))
75
  except: all_subs = []
76
 
 
77
  panels_per_page = 5
78
  target_pages = int(target_pages)
79
  total_needed = target_pages * panels_per_page
@@ -88,6 +92,7 @@ def generate_comic_gpu(video_path, user_dir, frames_dir, metadata_path, target_p
88
  indices = np.linspace(0, len(all_subs) - 1, total_needed, dtype=int)
89
  moments = [{'text': all_subs[i].content, 'start': all_subs[i].start.total_seconds()} for i in indices]
90
 
 
91
  frame_metadata = {}
92
  cap = cv2.VideoCapture(video_path)
93
  frame_files = []
@@ -102,10 +107,13 @@ def generate_comic_gpu(video_path, user_dir, frames_dir, metadata_path, target_p
102
  frame_files.append(fname)
103
  cap.release()
104
 
105
- with open(metadata_path, 'w') as f: json.dump(sanitize_json(frame_metadata), f)
 
 
106
  try: black_bar_crop()
107
  except: pass
108
 
 
109
  se = SimpleColorEnhancer()
110
  pages_data = []
111
  for p_idx in range(target_pages):
@@ -154,7 +162,7 @@ def uploader():
154
  def task():
155
  try:
156
  with open(os.path.join(o_dir, 'status.json'), 'w') as f:
157
- json.dump({'message': 'Generating Geometry...', 'progress': 30}, f)
158
  data = generate_comic_gpu(vid_p, u_dir, f_dir, os.path.join(f_dir, 'meta.json'), pages)
159
  with open(os.path.join(o_dir, 'pages.json'), 'w') as f: json.dump(data, f)
160
  with open(os.path.join(o_dir, 'status.json'), 'w') as f:
@@ -174,126 +182,116 @@ def status():
174
  return jsonify({'progress': 0})
175
 
176
  @app.route('/frames/<sid>/<path:filename>')
177
- def get_frame(sid, filename): return send_from_directory(os.path.join(BASE_USER_DIR, sid, 'frames'), filename)
 
178
 
179
  @app.route('/output/<sid>/<path:filename>')
180
- def get_output(sid, filename): return send_from_directory(os.path.join(BASE_USER_DIR, sid, 'output'), filename)
 
181
 
182
  # ======================================================
183
- # 🌐 UI TEMPLATE (English + Minimal Gutter)
184
  # ======================================================
185
  INDEX_HTML = '''
186
  <!DOCTYPE html><html lang="en">
187
  <head>
188
- <meta charset="UTF-8"><meta name="viewport" content="width=device-width, initial-scale=1" />
189
- <title>AI Comic Generator — Pro Minimalist Style</title>
190
  <script src="https://cdnjs.cloudflare.com/ajax/libs/html-to-image/1.11.11/html-to-image.min.js"></script>
191
- <link href="https://fonts.googleapis.com/css2?family=Lato:wght@400;700;900&display=swap" rel="stylesheet">
192
  <style>
193
- :root {
194
- --bg: #ffffff;
195
- --ink: #111;
196
- --border: #111;
197
- --panel-bg: #000;
198
- --textbox-bg: #f8f9fa;
199
  --slant: 40px;
200
- --gutter: 4px; /* MINIMAL GAP AS REQUESTED */
201
- }
202
-
203
- * { box-sizing: border-box; }
204
- body {
205
- margin: 0; background: var(--bg); color: var(--ink);
206
- font-family: "Lato", sans-serif;
207
- line-height: 1.5;
208
- }
209
-
210
- /* Header Style */
211
- .comic-header {
212
- display: grid; grid-template-columns: 64px 1fr; gap: 12px;
213
- align-items: center; padding: 16px; border-bottom: 3px solid var(--border);
214
- background: #fff; position: sticky; top: 0; z-index: 1000;
215
  }
216
- .logo {
217
- width: 64px; height: 64px; border: 3px solid var(--border);
218
- border-radius: 8px; display: grid; place-items: center;
219
- font-weight: 900; font-size: 12px; text-transform: uppercase; text-align: center;
220
- }
221
- .title { font-weight: 900; font-size: clamp(20px, 4vw, 32px); letter-spacing: -0.5px; }
222
- .subtitle { font-size: 14px; color: #666; font-weight: 700; margin-top: 2px; }
223
-
224
- /* Setup View */
225
- .setup-container { max-width: 500px; margin: 60px auto; padding: 40px; border: 3px solid var(--border); border-radius: 15px; text-align: center; }
226
- .btn-main { background: var(--ink); color: #fff; border: none; padding: 14px 28px; font-weight: 900; border-radius: 8px; cursor: pointer; font-size: 16px; transition: 0.2s; }
227
- .btn-main:hover { background: #333; transform: translateY(-2px); }
228
-
229
- /* ⚡ THE PERFECT MINIMAL GAP CANVAS ⚡ */
230
- .comic-canvas { max-width: 1100px; margin: 40px auto; padding: 10px; }
231
 
 
 
 
 
232
  .comic-page {
233
- background: white; width: 1000px; height: 750px; margin: 40px auto;
234
- border: 10px solid var(--border); padding: 5px; box-sizing: border-box;
235
  display: grid; gap: var(--gutter);
236
  grid-template-columns: repeat(6, 1fr);
237
  grid-template-rows: 1.35fr 1fr;
238
  position: relative; overflow: hidden;
239
- box-shadow: 15px 15px 0 rgba(0,0,0,0.05);
240
  }
241
 
242
- .panel { position: relative; background: #000; overflow: hidden; cursor: pointer; border: 2px solid var(--border); }
243
- .panel img { width: 115%; height: 115%; object-fit: cover; position: absolute; top: -7.5%; left: -7.5%; object-position: center 15%; pointer-events: none; }
244
 
245
- /* High-Precision Slants */
246
- .panel:nth-child(1) { grid-column: span 4; clip-path: polygon(0 0, 100% 0, calc(100% - var(--slant)) 100%, 0 100%); }
247
- .panel:nth-child(2) { grid-column: span 2; clip-path: polygon(var(--slant) 0, 100% 0, 100% 100%, 0 100%); }
248
- .panel:nth-child(3) { grid-column: span 2; clip-path: polygon(0 0, calc(100% - var(--slant)) 0, 100% 100%, 0 100%); }
249
- .panel:nth-child(4) { grid-column: span 2; clip-path: polygon(var(--slant) 0, calc(100% - var(--slant)) 0, 100% 100%, var(--slant) 100%); }
250
- .panel:nth-child(5) { grid-column: span 2; clip-path: polygon(var(--slant) 0, 100% 0, 100% 100%, var(--slant) 100%); }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
251
 
252
- .panel.selected { outline: 8px solid #3498db; z-index: 5; }
253
 
254
- /* Bubble Style - Pro Textbox */
255
  .bubble {
256
- position: absolute; background: #fff; border: 2.5px solid var(--border); border-radius: 4px;
257
- padding: 10px 14px; font-weight: 700; font-size: 14px;
258
- color: var(--ink); min-width: 120px; text-align: center; cursor: move; z-index: 10;
259
- box-shadow: 4px 4px 0 rgba(0,0,0,1);
 
 
 
 
 
 
 
 
 
 
 
260
  }
261
 
262
- .controls { position: fixed; bottom: 20px; right: 20px; background: #fff; border: 4px solid var(--border); padding: 25px; border-radius: 15px; width: 260px; z-index: 2000; box-shadow: 0 10px 30px rgba(0,0,0,0.2); }
 
263
  .hidden { display: none; }
264
- .loader { border: 5px solid #f3f3f3; border-top: 5px solid var(--ink); border-radius: 50%; width: 40px; height: 40px; animation: spin 1s linear infinite; margin: 15px auto; }
265
  @keyframes spin { 0% { transform: rotate(0deg); } 100% { transform: rotate(360deg); } }
266
-
267
- label { font-weight: 900; display: block; margin-bottom: 5px; font-size: 12px; text-transform: uppercase; }
268
- input[type="number"] { padding: 8px; border: 2px solid #ddd; border-radius: 6px; width: 60px; font-weight: 700; }
269
  </style>
270
  </head>
271
  <body>
272
- <header class="comic-header">
273
- <div class="logo">AI<br>COMIC</div>
274
- <div>
275
- <div class="title">AI Comic Generator</div>
276
- <div class="subtitle">Geometric Perfection — English Pro Template</div>
277
- </div>
278
- </header>
279
-
280
- <div id="upload-zone" class="setup-container">
281
- <h2>Generate Your Comic</h2>
282
- <p style="color:#666">Upload MP4 video to start extraction</p>
283
  <input type="file" id="vid" accept="video/mp4"><br><br>
284
- <label>Number of Pages</label>
285
- <input type="number" id="pg" value="2" min="1" max="10"><br><br>
286
- <button class="btn-main" onclick="start()">🚀 Generate Now</button>
287
- <div id="loading" class="hidden"><div class="loader"></div><p id="st" style="font-weight:700">Warming up GPU...</p></div>
288
  </div>
289
 
290
  <div id="editor-zone" class="hidden">
291
- <main class="comic-canvas" id="output"></main>
292
  <div class="controls">
293
- <h4 style="margin:0 0 15px 0; text-transform:uppercase; letter-spacing:1px;">Editor Tools</h4>
294
- <button class="btn-main" style="width:100%; margin-bottom:10px" onclick="addB()">➕ Add Text Box</button>
295
- <button class="btn-main" style="width:100%; background:#2980b9" onclick="exportPNG()">📥 Download PNG</button>
296
- <button class="btn-main" style="width:100%; background:#e74c3c; margin-top:15px; font-size:12px;" onclick="location.reload()">🏠 Start Over</button>
297
  </div>
298
  </div>
299
 
@@ -303,7 +301,7 @@ INDEX_HTML = '''
303
 
304
  async function start() {
305
  const f = document.getElementById('vid').files[0];
306
- if(!f) return alert("Please select a video file.");
307
  document.getElementById('loading').classList.remove('hidden');
308
  const fd = new FormData(); fd.append('file', f); fd.append('pages', document.getElementById('pg').value);
309
  await fetch(`/uploader?sid=${sid}`, {method: 'POST', body: fd});
@@ -319,7 +317,7 @@ INDEX_HTML = '''
319
  document.getElementById('upload-zone').classList.add('hidden');
320
  document.getElementById('editor-zone').classList.remove('hidden');
321
  const out = document.getElementById('output');
322
- pages.forEach((p) => {
323
  const pgDiv = document.createElement('div'); pgDiv.className = 'comic-page';
324
  p.panels.forEach((pan, i) => {
325
  const pDiv = document.createElement('div'); pDiv.className = 'panel';
@@ -335,24 +333,24 @@ INDEX_HTML = '''
335
 
336
  function createB(txt, x, y) {
337
  const b = document.createElement('div'); b.className = 'bubble';
338
- b.innerText = txt || '...'; b.style.left = x+'px'; b.style.top = y+'px';
339
  b.onmousedown = (e) => {
340
  e.stopPropagation();
341
  let ox = e.clientX - b.offsetLeft, oy = e.clientY - b.offsetTop;
342
  document.onmousemove = (ev) => { b.style.left=(ev.clientX-ox)+'px'; b.style.top=(ev.clientY-oy)+'px'; };
343
  document.onmouseup = () => { document.onmousemove = null; };
344
  };
345
- b.ondblclick = () => { let n = prompt("Edit Text:", b.innerText); if(n) b.innerText = n; };
346
  return b;
347
  }
348
 
349
- function addB() { if(selP) selP.appendChild(createB("Enter Dialogue", 60, 60)); else alert("Please select a panel first!"); }
350
 
351
  async function exportPNG() {
352
  const pgs = document.querySelectorAll('.comic-page');
353
  for(let pg of pgs) {
354
  const url = await htmlToImage.toPng(pg, {pixelRatio: 2});
355
- const l = document.createElement('a'); l.download='Comic_Page.png'; l.href=url; l.click();
356
  }
357
  }
358
  </script>
 
29
  os.makedirs(BASE_USER_DIR, exist_ok=True)
30
 
31
  # ======================================================
32
+ # 🔧 JSON SANITIZER (FIX FOR int64 ERROR)
33
  # ======================================================
34
  def sanitize_json(obj):
35
  if isinstance(obj, dict):
 
49
  # ======================================================
50
  @spaces.GPU(duration=300)
51
  def generate_comic_gpu(video_path, user_dir, frames_dir, metadata_path, target_pages):
52
+ # Heavy AI imports inside function to avoid startup timeout
53
  import cv2
54
  import srt
55
  import numpy as np
 
61
 
62
  cap = cv2.VideoCapture(video_path)
63
  fps = cap.get(cv2.CAP_PROP_FPS) or 25
64
+ total_frames = int(cap.get(cv2.CAP_PROP_FRAME_COUNT))
65
+ duration = total_frames / fps
66
  cap.release()
67
 
68
+ # 1. Subtitles
69
  user_srt = os.path.join(user_dir, 'subs.srt')
70
  try:
71
  get_real_subtitles(video_path)
 
77
  try: all_subs = list(srt.parse(f.read()))
78
  except: all_subs = []
79
 
80
+ # 2. Logic for 5 Panels Per Page
81
  panels_per_page = 5
82
  target_pages = int(target_pages)
83
  total_needed = target_pages * panels_per_page
 
92
  indices = np.linspace(0, len(all_subs) - 1, total_needed, dtype=int)
93
  moments = [{'text': all_subs[i].content, 'start': all_subs[i].start.total_seconds()} for i in indices]
94
 
95
+ # 3. Frame Extraction
96
  frame_metadata = {}
97
  cap = cv2.VideoCapture(video_path)
98
  frame_files = []
 
107
  frame_files.append(fname)
108
  cap.release()
109
 
110
+ with open(metadata_path, 'w') as f:
111
+ json.dump(sanitize_json(frame_metadata), f)
112
+
113
  try: black_bar_crop()
114
  except: pass
115
 
116
+ # 4. Enhance & Assemble
117
  se = SimpleColorEnhancer()
118
  pages_data = []
119
  for p_idx in range(target_pages):
 
162
  def task():
163
  try:
164
  with open(os.path.join(o_dir, 'status.json'), 'w') as f:
165
+ json.dump({'message': 'Drafting Perfect Geometry...', 'progress': 30}, f)
166
  data = generate_comic_gpu(vid_p, u_dir, f_dir, os.path.join(f_dir, 'meta.json'), pages)
167
  with open(os.path.join(o_dir, 'pages.json'), 'w') as f: json.dump(data, f)
168
  with open(os.path.join(o_dir, 'status.json'), 'w') as f:
 
182
  return jsonify({'progress': 0})
183
 
184
  @app.route('/frames/<sid>/<path:filename>')
185
+ def get_frame(sid, filename):
186
+ return send_from_directory(os.path.join(BASE_USER_DIR, sid, 'frames'), filename)
187
 
188
  @app.route('/output/<sid>/<path:filename>')
189
+ def get_output(sid, filename):
190
+ return send_from_directory(os.path.join(BASE_USER_DIR, sid, 'output'), filename)
191
 
192
  # ======================================================
193
+ # 🌐 UI HTML (MINIMAL GAP + ENGLISH UI)
194
  # ======================================================
195
  INDEX_HTML = '''
196
  <!DOCTYPE html><html lang="en">
197
  <head>
198
+ <meta charset="UTF-8"><title>Pro AI Comic Generator</title>
 
199
  <script src="https://cdnjs.cloudflare.com/ajax/libs/html-to-image/1.11.11/html-to-image.min.js"></script>
200
+ <link href="https://fonts.googleapis.com/css2?family=Comic+Neue:wght@700&family=Lato:wght@400;900&display=swap" rel="stylesheet">
201
  <style>
202
+ :root {
 
 
 
 
 
203
  --slant: 40px;
204
+ --gutter: 2px; /* RAZOR THIN GAP AS REQUESTED */
205
+ --border-panel: 3px;
206
+ --border-page: 10px;
 
 
 
 
 
 
 
 
 
 
 
 
207
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
208
 
209
+ body { background: #111; font-family: 'Lato', sans-serif; margin: 0; padding: 20px; color: white; }
210
+ .setup-box { max-width: 450px; margin: 80px auto; background: white; padding: 40px; border-radius: 12px; color: black; text-align: center; }
211
+
212
+ /* ⚡ THE PIXEL-PERFECT ASYMMETRICAL TEMPLATE ⚡ */
213
  .comic-page {
214
+ background: white; width: 1000px; height: 720px; margin: 40px auto;
215
+ border: var(--border-page) solid black; padding: 5px; box-sizing: border-box;
216
  display: grid; gap: var(--gutter);
217
  grid-template-columns: repeat(6, 1fr);
218
  grid-template-rows: 1.35fr 1fr;
219
  position: relative; overflow: hidden;
 
220
  }
221
 
222
+ .panel { position: relative; background: #000; overflow: hidden; cursor: pointer; border: var(--border-panel) solid black; }
223
+ .panel img { width: 118%; height: 118%; object-fit: cover; position: absolute; top: -9%; left: -9%; object-position: center 15%; pointer-events: none; }
224
 
225
+ /* ROW 1: Slant LEFT \ (Panel 1 Wide, Panel 2 Narrow) */
226
+ .panel:nth-child(1) {
227
+ grid-column: span 4;
228
+ clip-path: polygon(0 0, 100% 0, calc(100% - var(--slant)) 100%, 0 100%);
229
+ }
230
+ .panel:nth-child(2) {
231
+ grid-column: span 2;
232
+ clip-path: polygon(var(--slant) 0, 100% 0, 100% 100%, 0 100%);
233
+ }
234
+
235
+ /* ROW 2: Slant RIGHT / (Three equal action panels) */
236
+ .panel:nth-child(3) {
237
+ grid-column: span 2;
238
+ clip-path: polygon(0 0, calc(100% - var(--slant)) 0, 100% 100%, 0 100%);
239
+ }
240
+ .panel:nth-child(4) {
241
+ grid-column: span 2;
242
+ clip-path: polygon(var(--slant) 0, calc(100% - var(--slant)) 0, 100% 100%, var(--slant) 100%);
243
+ }
244
+ .panel:nth-child(5) {
245
+ grid-column: span 2;
246
+ clip-path: polygon(var(--slant) 0, 100% 0, 100% 100%, var(--slant) 100%);
247
+ }
248
 
249
+ .panel.selected { outline: 8px solid #3498db; z-index: 5; filter: brightness(1.1); }
250
 
251
+ /* BUBBLE STYLING - PRECISE WHITE CAPSULE */
252
  .bubble {
253
+ position: absolute; background: white; border: 2.5px solid black; border-radius: 25px;
254
+ padding: 10px 20px; font-family: 'Comic Neue'; font-weight: bold; font-size: 15px;
255
+ color: black; min-width: 110px; text-align: center; cursor: move; z-index: 10;
256
+ box-shadow: 4px 4px 0 rgba(0,0,0,0.1);
257
+ }
258
+ .bubble::after {
259
+ content: ""; position: absolute; bottom: -18px; left: 30px;
260
+ width: 0; height: 0; border-left: 10px solid transparent;
261
+ border-right: 10px solid transparent; border-top: 20px solid black;
262
+ }
263
+ .bubble::before {
264
+ content: ""; position: absolute; bottom: -13px; left: 31px;
265
+ width: 0; height: 0; border-left: 9px solid transparent;
266
+ border-right: 9px solid transparent; border-top: 17px solid white;
267
+ z-index: 2;
268
  }
269
 
270
+ .controls { position: fixed; bottom: 20px; right: 20px; background: #000; padding: 25px; border-radius: 12px; width: 240px; border: 2px solid #333; }
271
+ button { width: 100%; padding: 12px; margin-top: 10px; cursor: pointer; font-weight: bold; border-radius: 6px; border: none; }
272
  .hidden { display: none; }
273
+ .loader { border: 5px solid #333; border-top: 5px solid #e67e22; border-radius: 50%; width: 40px; height: 40px; animation: spin 1s linear infinite; margin: 20px auto; }
274
  @keyframes spin { 0% { transform: rotate(0deg); } 100% { transform: rotate(360deg); } }
 
 
 
275
  </style>
276
  </head>
277
  <body>
278
+ <div id="upload-zone" class="setup-box">
279
+ <h1>🎬 AI Comic Maker</h1>
280
+ <p>Ultra-Minimal Gap Geometry</p>
 
 
 
 
 
 
 
 
281
  <input type="file" id="vid" accept="video/mp4"><br><br>
282
+ <label>Total Pages: </label><input type="number" id="pg" value="2" style="width:50px">
283
+ <br><br>
284
+ <button onclick="start()" style="background:#e67e22; color:white;">Generate Comic</button>
285
+ <div id="loading" class="hidden"><div class="loader"></div><p id="st">Acquiring GPU...</p></div>
286
  </div>
287
 
288
  <div id="editor-zone" class="hidden">
289
+ <div id="output"></div>
290
  <div class="controls">
291
+ <h4 style="margin:0; color:white;">Interactive Editor</h4>
292
+ <button onclick="addB()" style="background:#2ecc71; color:white;">💬 Add Bubble</button>
293
+ <button onclick="exportPNG()" style="background:#3498db; color:white;">📥 Download PNGs</button>
294
+ <button onclick="location.reload()" style="background:#555; color:white;">🏠 Reset</button>
295
  </div>
296
  </div>
297
 
 
301
 
302
  async function start() {
303
  const f = document.getElementById('vid').files[0];
304
+ if(!f) return alert("Select a video!");
305
  document.getElementById('loading').classList.remove('hidden');
306
  const fd = new FormData(); fd.append('file', f); fd.append('pages', document.getElementById('pg').value);
307
  await fetch(`/uploader?sid=${sid}`, {method: 'POST', body: fd});
 
317
  document.getElementById('upload-zone').classList.add('hidden');
318
  document.getElementById('editor-zone').classList.remove('hidden');
319
  const out = document.getElementById('output');
320
+ pages.forEach(p => {
321
  const pgDiv = document.createElement('div'); pgDiv.className = 'comic-page';
322
  p.panels.forEach((pan, i) => {
323
  const pDiv = document.createElement('div'); pDiv.className = 'panel';
 
333
 
334
  function createB(txt, x, y) {
335
  const b = document.createElement('div'); b.className = 'bubble';
336
+ b.innerText = txt || '...'; b.style.left = (x || 50) + 'px'; b.style.top = (y || 20) + 'px';
337
  b.onmousedown = (e) => {
338
  e.stopPropagation();
339
  let ox = e.clientX - b.offsetLeft, oy = e.clientY - b.offsetTop;
340
  document.onmousemove = (ev) => { b.style.left=(ev.clientX-ox)+'px'; b.style.top=(ev.clientY-oy)+'px'; };
341
  document.onmouseup = () => { document.onmousemove = null; };
342
  };
343
+ b.ondblclick = () => { let n = prompt("Edit text:", b.innerText); if(n) b.innerText = n; };
344
  return b;
345
  }
346
 
347
+ function addB() { if(selP) selP.appendChild(createB("New Text", 60, 60)); else alert("Select a panel first!"); }
348
 
349
  async function exportPNG() {
350
  const pgs = document.querySelectorAll('.comic-page');
351
  for(let pg of pgs) {
352
  const url = await htmlToImage.toPng(pg, {pixelRatio: 2});
353
+ const l = document.createElement('a'); l.download='ComicPage.png'; l.href=url; l.click();
354
  }
355
  }
356
  </script>