tester343 commited on
Commit
93270c1
·
verified ·
1 Parent(s): fa2d7e4

Update app_enhanced.py

Browse files
Files changed (1) hide show
  1. app_enhanced.py +270 -199
app_enhanced.py CHANGED
@@ -1,14 +1,20 @@
1
- import spaces
2
  import os
3
- import json
4
  import threading
 
 
 
 
 
 
5
  import cv2
6
  import numpy as np
7
  import srt
8
- from flask import Flask, request, jsonify, send_from_directory
9
 
10
  # ======================================================
11
- # GPU
12
  # ======================================================
13
  @spaces.GPU
14
  def gpu_warmup():
@@ -16,241 +22,306 @@ def gpu_warmup():
16
  return torch.cuda.is_available()
17
 
18
  # ======================================================
19
- # PATHS
20
  # ======================================================
21
- BASE = "/data" if os.path.exists("/data") else "."
22
- USERDATA = os.path.join(BASE, "userdata")
23
- os.makedirs(USERDATA, exist_ok=True)
24
 
25
  # ======================================================
26
- # SAFE JSON
27
  # ======================================================
28
- def sanitize(obj):
29
  if isinstance(obj, dict):
30
- return {k: sanitize(v) for k, v in obj.items()}
31
- if isinstance(obj, list):
32
- return [sanitize(v) for v in obj]
33
- if isinstance(obj, np.integer):
34
  return int(obj)
 
 
35
  return obj
36
 
37
  # ======================================================
38
- # COMIC GENERATOR (5 PANEL TEMPLATE)
39
  # ======================================================
40
  @spaces.GPU(duration=300)
41
- def generate_comic(video, user_dir, frames_dir, pages):
42
- cap = cv2.VideoCapture(video)
 
 
 
 
 
 
43
  fps = cap.get(cv2.CAP_PROP_FPS) or 25
44
- total = int(cap.get(cv2.CAP_PROP_FRAME_COUNT))
45
- duration = total / fps
46
  cap.release()
47
 
48
- panels_per_page = 5
49
- pages = int(pages)
50
- needed = pages * panels_per_page
 
 
 
 
51
 
52
- # Simple timestamps
53
- times = np.linspace(1, max(1, duration - 1), needed)
 
54
 
55
- cap = cv2.VideoCapture(video)
56
- frames = []
57
- for i, t in enumerate(times):
 
 
 
 
 
 
 
 
 
 
58
  cap.set(cv2.CAP_PROP_POS_MSEC, t * 1000)
59
- ok, frame = cap.read()
60
- if ok:
61
- name = f"frame_{i}.png"
62
- cv2.imwrite(os.path.join(frames_dir, name), frame)
63
- frames.append(name)
 
 
64
  cap.release()
65
 
66
- # Fixed bubble positions per panel
67
- bubble_slots = [
68
- {"x": 60, "y": 320}, # panel 1
69
- {"x": 40, "y": 60}, # panel 2
70
- {"x": 30, "y": 240}, # panel 3
71
- {"x": 30, "y": 60}, # panel 4
72
- {"x": 30, "y": 240}, # panel 5
73
- ]
74
 
 
 
75
  pages_data = []
76
- for p in range(pages):
77
- start = p * 5
78
- page = []
79
- for i in range(5):
80
- idx = start + i
81
- if idx >= len(frames):
82
- continue
83
- page.append({
84
- "image": frames[idx],
85
- "bubble": {
86
- "text": "...",
87
- **bubble_slots[i]
88
- }
89
- })
90
- pages_data.append(page)
91
-
92
- return sanitize(pages_data)
 
 
 
 
 
 
 
93
 
94
  # ======================================================
95
- # APP
96
  # ======================================================
97
  app = Flask(__name__)
98
 
99
- @app.route("/")
100
- def index():
101
- return INDEX_HTML
102
 
103
- @app.route("/upload", methods=["POST"])
104
- def upload():
105
- sid = request.args.get("sid")
106
- user = os.path.join(USERDATA, sid)
107
- frames = os.path.join(user, "frames")
108
- out = os.path.join(user, "output")
109
- os.makedirs(frames, exist_ok=True)
110
- os.makedirs(out, exist_ok=True)
111
-
112
- video = os.path.join(user, "video.mp4")
113
- request.files["file"].save(video)
114
- pages = request.form.get("pages", 1)
115
 
116
  def task():
117
- data = generate_comic(video, user, frames, pages)
118
- with open(os.path.join(out, "pages.json"), "w") as f:
119
- json.dump(data, f)
 
 
 
 
 
 
 
120
 
121
  threading.Thread(target=task).start()
122
- return jsonify({"ok": True})
 
 
 
 
 
 
123
 
124
- @app.route("/frames/<sid>/<name>")
125
- def frames(sid, name):
126
- return send_from_directory(os.path.join(USERDATA, sid, "frames"), name)
127
 
128
- @app.route("/output/<sid>/<name>")
129
- def output(sid, name):
130
- return send_from_directory(os.path.join(USERDATA, sid, "output"), name)
131
 
132
  # ======================================================
133
- # FRONTEND
134
  # ======================================================
135
- INDEX_HTML = """
136
- <!DOCTYPE html>
137
- <html lang="hi">
138
  <head>
139
- <meta charset="UTF-8">
140
- <title>Comic Generator</title>
141
-
142
- <style>
143
- body { background:#111; margin:0; font-family:sans-serif; }
144
-
145
- .comic-page {
146
- width:1000px;
147
- height:1400px;
148
- margin:40px auto;
149
- background:#fff;
150
- border:10px solid #000;
151
- padding:10px;
152
- display:grid;
153
- grid-template-columns:2fr 1fr;
154
- grid-template-rows:1fr 1fr;
155
- gap:10px;
156
- }
157
-
158
- .panel { position:relative; border:5px solid #000; overflow:hidden; }
159
- .panel img { width:100%; height:100%; object-fit:cover; }
160
-
161
- .bottom {
162
- grid-column:1 / span 2;
163
- display:grid;
164
- grid-template-columns:1fr 1fr 1fr;
165
- gap:10px;
166
- }
167
-
168
- .title {
169
- position:absolute;
170
- top:20px; left:20px; right:20px;
171
- padding:20px;
172
- border:4px solid #000;
173
- background:#fff;
174
- font-size:32px;
175
- font-weight:900;
176
- }
177
-
178
- .bubble {
179
- position:absolute;
180
- background:#fff;
181
- border:4px solid #000;
182
- border-radius:30px;
183
- padding:12px 20px;
184
- font-weight:bold;
185
- }
186
- </style>
 
 
 
 
 
 
 
 
 
 
 
 
187
  </head>
188
-
189
  <body>
190
- <input type="file" id="f">
191
- <button onclick="go()">Generate</button>
192
- <div id="out"></div>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
193
 
194
  <script>
195
- let sid = "s" + Math.random().toString(36).slice(2);
196
-
197
- async function go(){
198
- let fd = new FormData();
199
- fd.append("file", f.files[0]);
200
- fd.append("pages", 1);
201
- await fetch(`/upload?sid=${sid}`, {method:"POST", body:fd});
202
- setTimeout(load, 3000);
203
- }
204
-
205
- async function load(){
206
- let r = await fetch(`/output/${sid}/pages.json`);
207
- let pages = await r.json();
208
- pages.forEach(p=>{
209
- let page = document.createElement("div");
210
- page.className="comic-page";
211
-
212
- // panel 1
213
- let p1 = mk(p[0], true); page.appendChild(p1);
214
- let p2 = mk(p[1]); page.appendChild(p2);
215
-
216
- let bottom = document.createElement("div");
217
- bottom.className="bottom";
218
- bottom.appendChild(mk(p[2]));
219
- bottom.appendChild(mk(p[3]));
220
- bottom.appendChild(mk(p[4]));
221
-
222
- page.appendChild(bottom);
223
- out.appendChild(page);
224
- });
225
- }
226
-
227
- function mk(d, title=false){
228
- let p = document.createElement("div");
229
- p.className="panel";
230
- let img = document.createElement("img");
231
- img.src = `/frames/${sid}/${d.image}`;
232
- p.appendChild(img);
233
-
234
- if(title){
235
- let t = document.createElement("div");
236
- t.className="title";
237
- t.innerText="गट्टू चिंकी और मलतियों का रहस्य";
238
- p.appendChild(t);
239
- }
240
-
241
- let b = document.createElement("div");
242
- b.className="bubble";
243
- b.style.left=d.bubble.x+"px";
244
- b.style.top=d.bubble.y+"px";
245
- b.innerText=d.bubble.text;
246
- p.appendChild(b);
247
- return p;
248
- }
 
 
 
249
  </script>
250
- </body>
251
- </html>
252
- """
253
 
254
- if __name__ == "__main__":
255
- gpu_warmup()
256
- app.run(host="0.0.0.0", port=7860)
 
 
1
+ import spaces # <--- CRITICAL: MUST BE THE FIRST IMPORT
2
  import os
3
+ import time
4
  import threading
5
+ import json
6
+ import traceback
7
+ import logging
8
+ import string
9
+ import random
10
+ import shutil
11
  import cv2
12
  import numpy as np
13
  import srt
14
+ from flask import Flask, jsonify, request, send_from_directory, send_file
15
 
16
  # ======================================================
17
+ # 🚀 ZEROGPU CONFIGURATION
18
  # ======================================================
19
  @spaces.GPU
20
  def gpu_warmup():
 
22
  return torch.cuda.is_available()
23
 
24
  # ======================================================
25
+ # 💾 STORAGE SETUP
26
  # ======================================================
27
+ BASE_STORAGE_PATH = '/data' if os.path.exists('/data') else '.'
28
+ BASE_USER_DIR = os.path.join(BASE_STORAGE_PATH, "userdata")
29
+ os.makedirs(BASE_USER_DIR, exist_ok=True)
30
 
31
  # ======================================================
32
+ # 🔧 JSON SANITIZER
33
  # ======================================================
34
+ def sanitize_json(obj):
35
  if isinstance(obj, dict):
36
+ return {k: sanitize_json(v) for k, v in obj.items()}
37
+ elif isinstance(obj, list):
38
+ return [sanitize_json(v) for v in obj]
39
+ elif isinstance(obj, (np.int64, np.int32, np.int16)):
40
  return int(obj)
41
+ elif isinstance(obj, (np.float64, np.float32)):
42
+ return float(obj)
43
  return obj
44
 
45
  # ======================================================
46
+ # 🧠 CORE GPU GENERATOR
47
  # ======================================================
48
  @spaces.GPU(duration=300)
49
+ def generate_comic_gpu(video_path, user_dir, frames_dir, metadata_path, target_pages):
50
+ from backend.keyframes.keyframes import black_bar_crop
51
+ from backend.simple_color_enhancer import SimpleColorEnhancer
52
+ from backend.subtitles.subs_real import get_real_subtitles
53
+ from backend.ai_bubble_placement import ai_bubble_placer
54
+ from backend.ai_enhanced_core import face_detector
55
+
56
+ cap = cv2.VideoCapture(video_path)
57
  fps = cap.get(cv2.CAP_PROP_FPS) or 25
58
+ total_frames = int(cap.get(cv2.CAP_PROP_FRAME_COUNT))
59
+ duration = total_frames / fps
60
  cap.release()
61
 
62
+ # 1. Narrative Extraction (Timed to match your description)
63
+ user_srt = os.path.join(user_dir, 'subs.srt')
64
+ try:
65
+ get_real_subtitles(video_path)
66
+ if os.path.exists('test1.srt'): shutil.move('test1.srt', user_srt)
67
+ except:
68
+ with open(user_srt, 'w') as f: f.write("1\n00:00:01,000 --> 00:00:05,000\n...\n")
69
 
70
+ with open(user_srt, 'r', encoding='utf-8') as f:
71
+ try: all_subs = list(srt.parse(f.read()))
72
+ except: all_subs = []
73
 
74
+ # Mapping to 5 Panels (1 Wide + 1 Narrow Top | 3 Equal Bottom)
75
+ panels_per_page = 5
76
+ target_pages = int(target_pages)
77
+ total_needed = target_pages * panels_per_page
78
+
79
+ indices = np.linspace(0, len(all_subs) - 1, total_needed, dtype=int) if all_subs else range(total_needed)
80
+
81
+ frame_metadata = {}
82
+ cap = cv2.VideoCapture(video_path)
83
+ frame_files = []
84
+
85
+ for i, idx in enumerate(indices):
86
+ t = all_subs[idx].start.total_seconds() if all_subs else (i * (duration/total_needed))
87
  cap.set(cv2.CAP_PROP_POS_MSEC, t * 1000)
88
+ ret, frame = cap.read()
89
+ if ret:
90
+ fname = f"frame_{i:04d}.png"
91
+ p = os.path.join(frames_dir, fname)
92
+ cv2.imwrite(p, frame)
93
+ frame_metadata[fname] = {'dialogue': all_subs[idx].content if all_subs else "", 'time': t}
94
+ frame_files.append(fname)
95
  cap.release()
96
 
97
+ with open(metadata_path, 'w') as f: json.dump(sanitize_json(frame_metadata), f)
98
+ try: black_bar_crop()
99
+ except: pass
 
 
 
 
 
100
 
101
+ # 2. Compositional Enhancement
102
+ se = SimpleColorEnhancer()
103
  pages_data = []
104
+ for p_idx in range(target_pages):
105
+ p_p = []
106
+ p_b = []
107
+ start = p_idx * 5
108
+ for i in range(start, start + 5):
109
+ if i >= len(frame_files): break
110
+ f_name = frame_files[i]
111
+ img_p = os.path.join(frames_dir, f_name)
112
+ try: se.enhance_single(img_p, img_p)
113
+ except: pass
114
+
115
+ txt = frame_metadata[f_name]['dialogue']
116
+ try:
117
+ faces = face_detector.detect_faces(img_p)
118
+ lip = face_detector.get_lip_position(img_p, faces[0]) if faces else (-1, -1)
119
+ bx, by = ai_bubble_placer.place_bubble_ai(img_p, lip)
120
+ item = {'dialog': txt, 'x': bx, 'y': by}
121
+ except:
122
+ item = {'dialog': txt, 'x': 50, 'y': 25}
123
+ p_p.append({'image': f_name})
124
+ p_b.append(item)
125
+ pages_data.append({'panels': p_p, 'bubbles': p_b})
126
+
127
+ return sanitize_json(pages_data)
128
 
129
  # ======================================================
130
+ # 🔧 APP ENGINE
131
  # ======================================================
132
  app = Flask(__name__)
133
 
134
+ @app.route('/')
135
+ def index(): return INDEX_HTML
 
136
 
137
+ @app.route('/uploader', methods=['POST'])
138
+ def uploader():
139
+ sid = request.args.get('sid')
140
+ u_dir = os.path.join(BASE_USER_DIR, sid)
141
+ f_dir = os.path.join(u_dir, 'frames'); o_dir = os.path.join(u_dir, 'output')
142
+ os.makedirs(f_dir, exist_ok=True); os.makedirs(o_dir, exist_ok=True)
143
+ vid_p = os.path.join(u_dir, 'video.mp4')
144
+ request.files['file'].save(vid_p)
145
+ pages = request.form.get('pages', 2)
 
 
 
146
 
147
  def task():
148
+ try:
149
+ with open(os.path.join(o_dir, 'status.json'), 'w') as f:
150
+ json.dump({'message': 'Executing Geometric Description...', 'progress': 30}, f)
151
+ data = generate_comic_gpu(vid_p, u_dir, f_dir, os.path.join(f_dir, 'meta.json'), pages)
152
+ with open(os.path.join(o_dir, 'pages.json'), 'w') as f: json.dump(data, f)
153
+ with open(os.path.join(o_dir, 'status.json'), 'w') as f:
154
+ json.dump({'message': 'Complete', 'progress': 100}, f)
155
+ except Exception as e:
156
+ with open(os.path.join(o_dir, 'status.json'), 'w') as f:
157
+ json.dump({'message': f'Error: {str(e)}', 'progress': -1}, f)
158
 
159
  threading.Thread(target=task).start()
160
+ return jsonify({'success': True})
161
+
162
+ @app.route('/status')
163
+ def status():
164
+ sid = request.args.get('sid')
165
+ p = os.path.join(BASE_USER_DIR, sid, 'output', 'status.json')
166
+ return send_file(p) if os.path.exists(p) else jsonify({'progress': 0})
167
 
168
+ @app.route('/frames/<sid>/<path:filename>')
169
+ def get_frame(sid, filename): return send_from_directory(os.path.join(BASE_USER_DIR, sid, 'frames'), filename)
 
170
 
171
+ @app.route('/output/<sid>/<path:filename>')
172
+ def get_output(sid, filename): return send_from_directory(os.path.join(BASE_USER_DIR, sid, 'output'), filename)
 
173
 
174
  # ======================================================
175
+ # 🖼️ HTML (0.1px RAZOR-THIN TILTED PERFECTION)
176
  # ======================================================
177
+ INDEX_HTML = '''
178
+ <!DOCTYPE html><html lang="en">
 
179
  <head>
180
+ <meta charset="UTF-8"><title>Elite 5-Panel Generator</title>
181
+ <script src="https://cdnjs.cloudflare.com/ajax/libs/html-to-image/1.11.11/html-to-image.min.js"></script>
182
+ <link href="https://fonts.googleapis.com/css2?family=Comic+Neue:wght@700&family=Lato:wght@400;900&display=swap" rel="stylesheet">
183
+ <style>
184
+ :root {
185
+ --slant: 40px; /* Precise Tilt */
186
+ --gutter: 0.1px; /* RAZOR THIN AS REQUESTED */
187
+ --border-page: 12px;
188
+ }
189
+ body { background: #000; font-family: 'Lato', sans-serif; margin: 0; padding: 20px; color: white; }
190
+ .setup-box { max-width: 450px; margin: 80px auto; background: white; padding: 40px; border-radius: 12px; color: black; text-align: center; }
191
+
192
+ /* ⚡ THE PERFECT ASYMMETRICAL TRAPEZOIDAL TEMPLATE ⚡ */
193
+ .comic-page {
194
+ background: white; width: 1000px; height: 750px; margin: 40px auto;
195
+ border: var(--border-page) solid black; padding: 5px; box-sizing: border-box;
196
+ display: grid; gap: var(--gutter);
197
+ grid-template-columns: repeat(6, 1fr);
198
+ grid-template-rows: 1.35fr 1fr; /* Top row matches wide establishment shot */
199
+ position: relative; overflow: hidden;
200
+ }
201
+
202
+ .panel { position: relative; background: #000; overflow: hidden; cursor: pointer; border: 1.5px solid black; }
203
+ .panel img { width: 118%; height: 118%; object-fit: cover; position: absolute; top: -9%; left: -9%; object-position: center 15%; pointer-events: none; }
204
+
205
+ /* ROW 1: Wide Establish + Narrow Close-up (Tilt Left \) */
206
+ .panel:nth-child(1) { grid-column: span 4; clip-path: polygon(0 0, 100% 0, calc(100% - var(--slant)) 100%, 0 100%); }
207
+ .panel:nth-child(2) { grid-column: span 2; clip-path: polygon(var(--slant) 0, 100% 0, 100% 100%, 0 100%); }
208
+
209
+ /* ROW 2: Three Equal Action/Resolution Panels (Tilt Right /) */
210
+ .panel:nth-child(3) { grid-column: span 2; clip-path: polygon(0 0, calc(100% - var(--slant)) 0, 100% 100%, 0 100%); }
211
+ .panel:nth-child(4) { grid-column: span 2; clip-path: polygon(var(--slant) 0, calc(100% - var(--slant)) 0, 100% 100%, var(--slant) 100%); }
212
+ .panel:nth-child(5) { grid-column: span 2; clip-path: polygon(var(--slant) 0, 100% 0, 100% 100%, var(--slant) 100%); }
213
+
214
+ .panel.selected { outline: 8px solid #00d2ff; z-index: 5; filter: brightness(1.1); }
215
+
216
+ /* Bubble Styling - Precision Pro Capsule */
217
+ .bubble {
218
+ position: absolute; background: white; border: 2.5px solid black; border-radius: 25px;
219
+ padding: 10px 20px; font-family: 'Comic Neue'; font-weight: bold; font-size: 15px;
220
+ color: black; min-width: 110px; text-align: center; cursor: move; z-index: 10;
221
+ }
222
+ .bubble::after {
223
+ content: ""; position: absolute; bottom: -18px; left: 30px;
224
+ width: 0; height: 0; border-left: 10px solid transparent;
225
+ border-right: 10px solid transparent; border-top: 20px solid black;
226
+ }
227
+ .bubble::before {
228
+ content: ""; position: absolute; bottom: -13px; left: 31px;
229
+ width: 0; height: 0; border-left: 9px solid transparent;
230
+ border-right: 9px solid transparent; border-top: 17px solid white;
231
+ z-index: 2;
232
+ }
233
+
234
+ .controls { position: fixed; bottom: 20px; right: 20px; background: #000; padding: 25px; border-radius: 12px; width: 240px; border: 2px solid #333; }
235
+ button { width: 100%; padding: 12px; margin-top: 10px; cursor: pointer; font-weight: bold; border-radius: 6px; border: none; }
236
+ .hidden { display: none; }
237
+ .loader { border: 5px solid #333; border-top: 5px solid #00d2ff; border-radius: 50%; width: 40px; height: 40px; animation: spin 1s linear infinite; margin: 20px auto; }
238
+ @keyframes spin { 0% { transform: rotate(0deg); } 100% { transform: rotate(360deg); } }
239
+ </style>
240
  </head>
 
241
  <body>
242
+ <div id="upload-zone" class="setup-box">
243
+ <h1>🎬 Elite Comic Maker</h1>
244
+ <p>100% Accurate Geometric Description Applied</p>
245
+ <input type="file" id="vid" accept="video/mp4"><br><br>
246
+ <label>Total Pages: </label><input type="number" id="pg" value="2" style="width:50px">
247
+ <br><br>
248
+ <button onclick="start()" style="background:#00d2ff; color:black;">🚀 GENERATE PERFECT COMIC</button>
249
+ <div id="loading" class="hidden"><div class="loader"></div><p id="st">Processing Composition...</p></div>
250
+ </div>
251
+
252
+ <div id="editor-zone" class="hidden">
253
+ <div id="output"></div>
254
+ <div class="controls">
255
+ <h4 style="margin:0; color:#00d2ff;">EDITOR TOOLS</h4>
256
+ <button onclick="addB()" style="background:#2ecc71; color:white;">💬 Add Text Capsule</button>
257
+ <button onclick="exportPNG()" style="background:#3498db; color:white;">📥 Download Pages</button>
258
+ <button onclick="location.reload()" style="background:#e74c3c; color:white;">🏠 Start New</button>
259
+ </div>
260
+ </div>
261
 
262
  <script>
263
+ let sid = 's' + Math.random().toString(36).substr(2,9);
264
+ let selP = null;
265
+
266
+ async function start() {
267
+ const f = document.getElementById('vid').files[0];
268
+ if(!f) return alert("Select a video!");
269
+ document.getElementById('loading').classList.remove('hidden');
270
+ const fd = new FormData(); fd.append('file', f); fd.append('pages', document.getElementById('pg').value);
271
+ await fetch(`/uploader?sid=${sid}`, {method: 'POST', body: fd});
272
+ const itv = setInterval(async () => {
273
+ const r = await fetch(`/status?sid=${sid}`); const d = await r.json();
274
+ document.getElementById('st').innerText = d.message || "Working...";
275
+ if(d.progress >= 100) { clearInterval(itv); load(); }
276
+ }, 2000);
277
+ }
278
+
279
+ async function load() {
280
+ const r = await fetch(`/output/${sid}/pages.json`); const pages = await r.json();
281
+ document.getElementById('upload-zone').classList.add('hidden');
282
+ document.getElementById('editor-zone').classList.remove('hidden');
283
+ const out = document.getElementById('output');
284
+ pages.forEach(p => {
285
+ const pgDiv = document.createElement('div'); pgDiv.className = 'comic-page';
286
+ p.panels.forEach((pan, i) => {
287
+ const pDiv = document.createElement('div'); pDiv.className = 'panel';
288
+ pDiv.onclick = (e) => { e.stopPropagation(); if(selP) selP.classList.remove('selected'); selP=pDiv; pDiv.classList.add('selected'); };
289
+ const img = document.createElement('img'); img.src = `/frames/${sid}/${pan.image}`;
290
+ pDiv.appendChild(img);
291
+ if(p.bubbles[i]) pDiv.appendChild(createB(p.bubbles[i].dialog, p.bubbles[i].x, p.bubbles[i].y));
292
+ pgDiv.appendChild(pDiv);
293
+ });
294
+ out.appendChild(pgDiv);
295
+ });
296
+ }
297
+
298
+ function createB(txt, x, y) {
299
+ const b = document.createElement('div'); b.className = 'bubble';
300
+ b.innerText = txt || '...'; b.style.left = (x || 50) + 'px'; b.style.top = (y || 20) + 'px';
301
+ b.onmousedown = (e) => {
302
+ e.stopPropagation();
303
+ let ox = e.clientX - b.offsetLeft, oy = e.clientY - b.offsetTop;
304
+ document.onmousemove = (ev) => { b.style.left=(ev.clientX-ox)+'px'; b.style.top=(ev.clientY-oy)+'px'; };
305
+ document.onmouseup = () => { document.onmousemove = null; };
306
+ };
307
+ b.ondblclick = () => { let n = prompt("Edit Text:", b.innerText); if(n) b.innerText = n; };
308
+ return b;
309
+ }
310
+
311
+ function addB() { if(selP) selP.appendChild(createB("Enter Dialogue", 60, 60)); else alert("Select a panel first!"); }
312
+
313
+ async function exportPNG() {
314
+ const pgs = document.querySelectorAll('.comic-page');
315
+ for(let pg of pgs) {
316
+ const url = await htmlToImage.toPng(pg, {pixelRatio: 2});
317
+ const l = document.createElement('a'); l.download='Elite_Comic_Page.png'; l.href=url; l.click();
318
+ }
319
+ }
320
  </script>
321
+ </body></html>
322
+ '''
 
323
 
324
+ if __name__ == '__main__':
325
+ try: gpu_warmup()
326
+ except: pass
327
+ app.run(host='0.0.0.0', port=7860)