Update app_enhanced.py
Browse files- app_enhanced.py +77 -44
app_enhanced.py
CHANGED
|
@@ -24,7 +24,7 @@ def gpu_warmup():
|
|
| 24 |
return True
|
| 25 |
|
| 26 |
# ======================================================
|
| 27 |
-
# 💾
|
| 28 |
# ======================================================
|
| 29 |
if os.path.exists('/data'):
|
| 30 |
BASE_STORAGE_PATH = '/data'
|
|
@@ -39,6 +39,19 @@ 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 |
# ======================================================
|
| 43 |
# 🧱 DATA CLASSES
|
| 44 |
# ======================================================
|
|
@@ -60,32 +73,17 @@ class Page:
|
|
| 60 |
self.panels = panels
|
| 61 |
self.bubbles = bubbles
|
| 62 |
|
| 63 |
-
# ======================================================
|
| 64 |
-
# 🔧 APP CONFIG
|
| 65 |
-
# ======================================================
|
| 66 |
-
app = Flask(__name__)
|
| 67 |
-
app.config['MAX_CONTENT_LENGTH'] = 500 * 1024 * 1024
|
| 68 |
-
|
| 69 |
-
def generate_save_code(length=8):
|
| 70 |
-
chars = string.ascii_uppercase + string.digits
|
| 71 |
-
while True:
|
| 72 |
-
code = ''.join(random.choices(chars, k=length))
|
| 73 |
-
if not os.path.exists(os.path.join(SAVED_COMICS_DIR, code)):
|
| 74 |
-
return code
|
| 75 |
-
|
| 76 |
# ======================================================
|
| 77 |
# 🧠 GPU GENERATION (FULL TEXT + HD IMAGE)
|
| 78 |
# ======================================================
|
| 79 |
@spaces.GPU(duration=300)
|
| 80 |
def generate_comic_gpu(video_path, user_dir, frames_dir, metadata_path, target_pages):
|
| 81 |
-
print(f"🚀 Generating Comic with TEXT: {video_path}")
|
| 82 |
|
| 83 |
import cv2
|
| 84 |
import srt
|
| 85 |
import numpy as np
|
| 86 |
-
#
|
| 87 |
-
from backend.subtitles.subs_real import get_real_subtitles
|
| 88 |
-
# Optional: AI placement (can be slow, using center fallback for speed/visibility)
|
| 89 |
|
| 90 |
# 1. Video Setup
|
| 91 |
cap = cv2.VideoCapture(video_path)
|
|
@@ -95,16 +93,14 @@ 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. GENERATE SUBTITLES
|
| 99 |
user_srt = os.path.join(user_dir, 'subs.srt')
|
| 100 |
try:
|
| 101 |
print("🎙️ Extracting subtitles...")
|
| 102 |
get_real_subtitles(video_path)
|
| 103 |
-
# Move the generated SRT to user dir
|
| 104 |
if os.path.exists('test1.srt'):
|
| 105 |
shutil.move('test1.srt', user_srt)
|
| 106 |
elif not os.path.exists(user_srt):
|
| 107 |
-
# Fallback if extractor failed silently
|
| 108 |
with open(user_srt, 'w') as f: f.write("1\n00:00:01,000 --> 00:00:04,000\n(No Text)\n")
|
| 109 |
except Exception as e:
|
| 110 |
print(f"⚠️ Subtitle error: {e}")
|
|
@@ -117,30 +113,26 @@ def generate_comic_gpu(video_path, user_dir, frames_dir, metadata_path, target_p
|
|
| 117 |
|
| 118 |
valid_subs = [s for s in all_subs if s.content and s.content.strip()]
|
| 119 |
|
| 120 |
-
# Create "Moments" from subtitles
|
| 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 |
-
# If no speech, create time-based moments
|
| 125 |
raw_moments = []
|
| 126 |
|
| 127 |
-
# 4. Determine Frames needed
|
| 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 |
-
# Time based distribution if no text
|
| 134 |
times = np.linspace(1, max(1, duration-1), total_panels_needed)
|
| 135 |
for t in times: selected_moments.append({'text': '', 'start': t, 'end': t+1})
|
| 136 |
elif len(raw_moments) <= total_panels_needed:
|
| 137 |
selected_moments = raw_moments
|
| 138 |
else:
|
| 139 |
-
# Sample moments evenly
|
| 140 |
indices = np.linspace(0, len(raw_moments) - 1, total_panels_needed, dtype=int)
|
| 141 |
selected_moments = [raw_moments[i] for i in indices]
|
| 142 |
|
| 143 |
-
# 5. Extract Frames (HD)
|
| 144 |
frame_metadata = {}
|
| 145 |
cap = cv2.VideoCapture(video_path)
|
| 146 |
count = 0
|
|
@@ -153,9 +145,8 @@ def generate_comic_gpu(video_path, user_dir, frames_dir, metadata_path, target_p
|
|
| 153 |
ret, frame = cap.read()
|
| 154 |
|
| 155 |
if ret:
|
| 156 |
-
#
|
| 157 |
-
#
|
| 158 |
-
# ----------------------------------------------------
|
| 159 |
frame = cv2.resize(frame, (1280, 720))
|
| 160 |
|
| 161 |
fname = f"frame_{count:04d}.png"
|
|
@@ -169,20 +160,16 @@ def generate_comic_gpu(video_path, user_dir, frames_dir, metadata_path, target_p
|
|
| 169 |
cap.release()
|
| 170 |
with open(metadata_path, 'w') as f: json.dump(frame_metadata, f, indent=2)
|
| 171 |
|
| 172 |
-
# 6. Generate Bubbles
|
| 173 |
bubbles_list = []
|
| 174 |
for f in frame_files_ordered:
|
| 175 |
dialogue = frame_metadata.get(f, {}).get('dialogue', '')
|
| 176 |
-
|
| 177 |
-
# Determine Type
|
| 178 |
b_type = 'speech'
|
| 179 |
if '(' in dialogue: b_type = 'narration'
|
| 180 |
-
elif '!' in dialogue
|
| 181 |
-
elif '?' in dialogue: b_type = 'speech'
|
| 182 |
|
| 183 |
-
#
|
| 184 |
-
|
| 185 |
-
bubbles_list.append(bubble(dialog=dialogue, x=50, y=50, type=b_type))
|
| 186 |
|
| 187 |
# 7. Construct Pages
|
| 188 |
pages = []
|
|
@@ -192,7 +179,7 @@ def generate_comic_gpu(video_path, user_dir, frames_dir, metadata_path, target_p
|
|
| 192 |
p_frames = frame_files_ordered[start_idx:end_idx]
|
| 193 |
p_bubbles = bubbles_list[start_idx:end_idx]
|
| 194 |
|
| 195 |
-
# Pad with empty
|
| 196 |
while len(p_frames) < 4:
|
| 197 |
fname = f"empty_{i}_{len(p_frames)}.png"
|
| 198 |
img = np.zeros((720, 1280, 3), dtype=np.uint8); img[:] = (30,30,30)
|
|
@@ -217,7 +204,6 @@ def generate_comic_gpu(video_path, user_dir, frames_dir, metadata_path, target_p
|
|
| 217 |
def regen_frame_gpu(video_path, frames_dir, metadata_path, fname, direction):
|
| 218 |
import cv2
|
| 219 |
import json
|
| 220 |
-
# (Same fast regen logic)
|
| 221 |
if not os.path.exists(metadata_path): return {"success": False, "message": "No metadata"}
|
| 222 |
with open(metadata_path, 'r') as f: meta = json.load(f)
|
| 223 |
if fname not in meta: return {"success": False, "message": "Frame not found"}
|
|
@@ -243,6 +229,28 @@ def regen_frame_gpu(video_path, frames_dir, metadata_path, fname, direction):
|
|
| 243 |
return {"success": True, "message": f"Time: {new_t:.2f}s"}
|
| 244 |
return {"success": False, "message": "End of video"}
|
| 245 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 246 |
# ======================================================
|
| 247 |
# 💻 BACKEND CLASS
|
| 248 |
# ======================================================
|
|
@@ -324,6 +332,8 @@ INDEX_HTML = '''
|
|
| 324 |
width: 100%; height: 100%;
|
| 325 |
position: relative;
|
| 326 |
background: #000;
|
|
|
|
|
|
|
| 327 |
--y: 50%;
|
| 328 |
--t1: 100%; --t2: 100%; /* Hidden Right by default */
|
| 329 |
--b1: 100%; --b2: 100%; /* Hidden Right by default */
|
|
@@ -331,7 +341,15 @@ INDEX_HTML = '''
|
|
| 331 |
}
|
| 332 |
|
| 333 |
.panel { position: absolute; top: 0; left: 0; width: 100%; height: 100%; overflow: hidden; background: #1a1a1a; cursor: grab; }
|
| 334 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 335 |
.panel img.panning { cursor: grabbing; transition: none; }
|
| 336 |
.panel.selected { outline: 4px solid #2196F3; z-index: 5; }
|
| 337 |
|
|
@@ -445,7 +463,8 @@ INDEX_HTML = '''
|
|
| 445 |
|
| 446 |
<div class="control-group">
|
| 447 |
<label>🔍 Zoom (Mouse Wheel):</label>
|
| 448 |
-
|
|
|
|
| 449 |
<button onclick="resetPanelTransform()" class="secondary-btn">Reset View</button>
|
| 450 |
</div>
|
| 451 |
|
|
@@ -489,7 +508,9 @@ INDEX_HTML = '''
|
|
| 489 |
const panels = [];
|
| 490 |
grid.querySelectorAll('.panel').forEach(pan => {
|
| 491 |
const img = pan.querySelector('img');
|
| 492 |
-
|
|
|
|
|
|
|
| 493 |
});
|
| 494 |
state.push({ layout, bubbles, panels });
|
| 495 |
});
|
|
@@ -584,7 +605,19 @@ INDEX_HTML = '''
|
|
| 584 |
img.src = pan.src.includes('?') ? pan.src : pan.src + `?sid=${sid}`;
|
| 585 |
img.dataset.zoom = 100; img.dataset.translateX = 0; img.dataset.translateY = 0;
|
| 586 |
img.onmousedown = (e) => { e.preventDefault(); e.stopPropagation(); selectPanel(pDiv); dragType = 'pan'; activeObj = img; dragStart = {x:e.clientX, y:e.clientY}; img.classList.add('panning'); };
|
| 587 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 588 |
pDiv.appendChild(img); grid.appendChild(pDiv);
|
| 589 |
});
|
| 590 |
|
|
|
|
| 24 |
return True
|
| 25 |
|
| 26 |
# ======================================================
|
| 27 |
+
# 💾 STORAGE SETUP
|
| 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
|
| 50 |
+
while True:
|
| 51 |
+
code = ''.join(random.choices(chars, k=length))
|
| 52 |
+
if not os.path.exists(os.path.join(SAVED_COMICS_DIR, code)):
|
| 53 |
+
return code
|
| 54 |
+
|
| 55 |
# ======================================================
|
| 56 |
# 🧱 DATA CLASSES
|
| 57 |
# ======================================================
|
|
|
|
| 73 |
self.panels = panels
|
| 74 |
self.bubbles = bubbles
|
| 75 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 76 |
# ======================================================
|
| 77 |
# 🧠 GPU GENERATION (FULL TEXT + HD IMAGE)
|
| 78 |
# ======================================================
|
| 79 |
@spaces.GPU(duration=300)
|
| 80 |
def generate_comic_gpu(video_path, user_dir, frames_dir, metadata_path, target_pages):
|
| 81 |
+
print(f"🚀 Generating HD Comic with TEXT: {video_path}")
|
| 82 |
|
| 83 |
import cv2
|
| 84 |
import srt
|
| 85 |
import numpy as np
|
| 86 |
+
from backend.subtitles.subs_real import get_real_subtitles # Ensure this backend file exists
|
|
|
|
|
|
|
| 87 |
|
| 88 |
# 1. Video Setup
|
| 89 |
cap = cv2.VideoCapture(video_path)
|
|
|
|
| 93 |
duration = total_frames / fps
|
| 94 |
cap.release()
|
| 95 |
|
| 96 |
+
# 2. GENERATE SUBTITLES
|
| 97 |
user_srt = os.path.join(user_dir, 'subs.srt')
|
| 98 |
try:
|
| 99 |
print("🎙️ Extracting subtitles...")
|
| 100 |
get_real_subtitles(video_path)
|
|
|
|
| 101 |
if os.path.exists('test1.srt'):
|
| 102 |
shutil.move('test1.srt', user_srt)
|
| 103 |
elif not os.path.exists(user_srt):
|
|
|
|
| 104 |
with open(user_srt, 'w') as f: f.write("1\n00:00:01,000 --> 00:00:04,000\n(No Text)\n")
|
| 105 |
except Exception as e:
|
| 106 |
print(f"⚠️ Subtitle error: {e}")
|
|
|
|
| 113 |
|
| 114 |
valid_subs = [s for s in all_subs if s.content and s.content.strip()]
|
| 115 |
|
|
|
|
| 116 |
if valid_subs:
|
| 117 |
raw_moments = [{'text': s.content.strip(), 'start': s.start.total_seconds(), 'end': s.end.total_seconds()} for s in valid_subs]
|
| 118 |
else:
|
|
|
|
| 119 |
raw_moments = []
|
| 120 |
|
| 121 |
+
# 4. Determine Frames needed (4 per page)
|
| 122 |
panels_per_page = 4
|
| 123 |
total_panels_needed = int(target_pages) * panels_per_page
|
| 124 |
|
| 125 |
selected_moments = []
|
| 126 |
if not raw_moments:
|
|
|
|
| 127 |
times = np.linspace(1, max(1, duration-1), total_panels_needed)
|
| 128 |
for t in times: selected_moments.append({'text': '', 'start': t, 'end': t+1})
|
| 129 |
elif len(raw_moments) <= total_panels_needed:
|
| 130 |
selected_moments = raw_moments
|
| 131 |
else:
|
|
|
|
| 132 |
indices = np.linspace(0, len(raw_moments) - 1, total_panels_needed, dtype=int)
|
| 133 |
selected_moments = [raw_moments[i] for i in indices]
|
| 134 |
|
| 135 |
+
# 5. Extract Frames (HD 1280x720)
|
| 136 |
frame_metadata = {}
|
| 137 |
cap = cv2.VideoCapture(video_path)
|
| 138 |
count = 0
|
|
|
|
| 145 |
ret, frame = cap.read()
|
| 146 |
|
| 147 |
if ret:
|
| 148 |
+
# 🎯 KEEP 16:9 ASPECT RATIO (1280x720)
|
| 149 |
+
# This ensures no data is lost. Frontend controls crop/zoom.
|
|
|
|
| 150 |
frame = cv2.resize(frame, (1280, 720))
|
| 151 |
|
| 152 |
fname = f"frame_{count:04d}.png"
|
|
|
|
| 160 |
cap.release()
|
| 161 |
with open(metadata_path, 'w') as f: json.dump(frame_metadata, f, indent=2)
|
| 162 |
|
| 163 |
+
# 6. Generate Bubbles
|
| 164 |
bubbles_list = []
|
| 165 |
for f in frame_files_ordered:
|
| 166 |
dialogue = frame_metadata.get(f, {}).get('dialogue', '')
|
|
|
|
|
|
|
| 167 |
b_type = 'speech'
|
| 168 |
if '(' in dialogue: b_type = 'narration'
|
| 169 |
+
elif '!' in dialogue: b_type = 'reaction'
|
|
|
|
| 170 |
|
| 171 |
+
# Center bubbles initially
|
| 172 |
+
bubbles_list.append(bubble(dialog=dialogue, x=50, y=20, type=b_type))
|
|
|
|
| 173 |
|
| 174 |
# 7. Construct Pages
|
| 175 |
pages = []
|
|
|
|
| 179 |
p_frames = frame_files_ordered[start_idx:end_idx]
|
| 180 |
p_bubbles = bubbles_list[start_idx:end_idx]
|
| 181 |
|
| 182 |
+
# Pad with empty frames if not enough
|
| 183 |
while len(p_frames) < 4:
|
| 184 |
fname = f"empty_{i}_{len(p_frames)}.png"
|
| 185 |
img = np.zeros((720, 1280, 3), dtype=np.uint8); img[:] = (30,30,30)
|
|
|
|
| 204 |
def regen_frame_gpu(video_path, frames_dir, metadata_path, fname, direction):
|
| 205 |
import cv2
|
| 206 |
import json
|
|
|
|
| 207 |
if not os.path.exists(metadata_path): return {"success": False, "message": "No metadata"}
|
| 208 |
with open(metadata_path, 'r') as f: meta = json.load(f)
|
| 209 |
if fname not in meta: return {"success": False, "message": "Frame not found"}
|
|
|
|
| 229 |
return {"success": True, "message": f"Time: {new_t:.2f}s"}
|
| 230 |
return {"success": False, "message": "End of video"}
|
| 231 |
|
| 232 |
+
@spaces.GPU
|
| 233 |
+
def get_frame_at_ts_gpu(video_path, frames_dir, metadata_path, fname, ts):
|
| 234 |
+
import cv2
|
| 235 |
+
import json
|
| 236 |
+
cap = cv2.VideoCapture(video_path)
|
| 237 |
+
cap.set(cv2.CAP_PROP_POS_MSEC, float(ts) * 1000)
|
| 238 |
+
ret, frame = cap.read()
|
| 239 |
+
cap.release()
|
| 240 |
+
|
| 241 |
+
if ret:
|
| 242 |
+
frame = cv2.resize(frame, (1280, 720)) # Keep HD
|
| 243 |
+
p = os.path.join(frames_dir, fname)
|
| 244 |
+
cv2.imwrite(p, frame)
|
| 245 |
+
if os.path.exists(metadata_path):
|
| 246 |
+
with open(metadata_path, 'r') as f: meta = json.load(f)
|
| 247 |
+
if fname in meta:
|
| 248 |
+
if isinstance(meta[fname], dict): meta[fname]['time'] = float(ts)
|
| 249 |
+
else: meta[fname] = float(ts)
|
| 250 |
+
with open(metadata_path, 'w') as f: json.dump(meta, f, indent=2)
|
| 251 |
+
return {"success": True, "message": f"Jumped to {ts}s"}
|
| 252 |
+
return {"success": False, "message": "Invalid timestamp"}
|
| 253 |
+
|
| 254 |
# ======================================================
|
| 255 |
# 💻 BACKEND CLASS
|
| 256 |
# ======================================================
|
|
|
|
| 332 |
width: 100%; height: 100%;
|
| 333 |
position: relative;
|
| 334 |
background: #000;
|
| 335 |
+
|
| 336 |
+
/* Grid Variables */
|
| 337 |
--y: 50%;
|
| 338 |
--t1: 100%; --t2: 100%; /* Hidden Right by default */
|
| 339 |
--b1: 100%; --b2: 100%; /* Hidden Right by default */
|
|
|
|
| 341 |
}
|
| 342 |
|
| 343 |
.panel { position: absolute; top: 0; left: 0; width: 100%; height: 100%; overflow: hidden; background: #1a1a1a; cursor: grab; }
|
| 344 |
+
|
| 345 |
+
/* IMAGE HANDLING: Object-fit cover fills the square. Pan/Zoom reveals hidden parts. */
|
| 346 |
+
.panel img {
|
| 347 |
+
width: 100%; height: 100%;
|
| 348 |
+
object-fit: cover;
|
| 349 |
+
transform-origin: center;
|
| 350 |
+
transition: transform 0.05s ease-out;
|
| 351 |
+
display: block;
|
| 352 |
+
}
|
| 353 |
.panel img.panning { cursor: grabbing; transition: none; }
|
| 354 |
.panel.selected { outline: 4px solid #2196F3; z-index: 5; }
|
| 355 |
|
|
|
|
| 463 |
|
| 464 |
<div class="control-group">
|
| 465 |
<label>🔍 Zoom (Mouse Wheel):</label>
|
| 466 |
+
<!-- Min zoom 20 allowed to zoom OUT -->
|
| 467 |
+
<input type="range" id="zoom-slider" min="20" max="300" value="100" step="5" oninput="handleZoom(this.value)" disabled>
|
| 468 |
<button onclick="resetPanelTransform()" class="secondary-btn">Reset View</button>
|
| 469 |
</div>
|
| 470 |
|
|
|
|
| 508 |
const panels = [];
|
| 509 |
grid.querySelectorAll('.panel').forEach(pan => {
|
| 510 |
const img = pan.querySelector('img');
|
| 511 |
+
const srcParts = img.src.split('frames/');
|
| 512 |
+
const fname = srcParts.length > 1 ? srcParts[1].split('?')[0] : '';
|
| 513 |
+
panels.push({ image: fname, zoom: img.dataset.zoom, tx: img.dataset.translateX, ty: img.dataset.translateY });
|
| 514 |
});
|
| 515 |
state.push({ layout, bubbles, panels });
|
| 516 |
});
|
|
|
|
| 605 |
img.src = pan.src.includes('?') ? pan.src : pan.src + `?sid=${sid}`;
|
| 606 |
img.dataset.zoom = 100; img.dataset.translateX = 0; img.dataset.translateY = 0;
|
| 607 |
img.onmousedown = (e) => { e.preventDefault(); e.stopPropagation(); selectPanel(pDiv); dragType = 'pan'; activeObj = img; dragStart = {x:e.clientX, y:e.clientY}; img.classList.add('panning'); };
|
| 608 |
+
|
| 609 |
+
// 🚀 ZOOM WHEEL LOGIC (Min zoom 20%)
|
| 610 |
+
img.onwheel = (e) => {
|
| 611 |
+
e.preventDefault();
|
| 612 |
+
let zoom = parseFloat(img.dataset.zoom);
|
| 613 |
+
zoom += e.deltaY * -0.1;
|
| 614 |
+
zoom = Math.min(Math.max(20, zoom), 300); // Allow zoom out to 20%
|
| 615 |
+
img.dataset.zoom = zoom;
|
| 616 |
+
updateImageTransform(img);
|
| 617 |
+
if(selectedPanel === pDiv) document.getElementById('zoom-slider').value = zoom;
|
| 618 |
+
saveState();
|
| 619 |
+
};
|
| 620 |
+
|
| 621 |
pDiv.appendChild(img); grid.appendChild(pDiv);
|
| 622 |
});
|
| 623 |
|