|
| 1 | +#!/usr/bin/env python3 |
| 2 | +"""Aggregate per-task image metadata into output.jsonl + gallery.html. |
| 3 | +
|
| 4 | +Reads OutputDir/output/metadata/image_NNNN.json sidecars produced by |
| 5 | +run_task.py and builds: |
| 6 | + - OutputDir/output/output.jsonl — one line per generated image (metadata) |
| 7 | + - OutputDir/output/gallery.html — self-contained gallery viewer that |
| 8 | + references images/image_NNNN.png by |
| 9 | + relative path |
| 10 | +
|
| 11 | +The gallery.html is a single static HTML file; open it from the downloaded |
| 12 | +OutputDir/output/ directory in any browser, no server needed. |
| 13 | +""" |
| 14 | +import argparse |
| 15 | +import json |
| 16 | +import os |
| 17 | +import re |
| 18 | +import sys |
| 19 | + |
| 20 | + |
| 21 | +HTML_TEMPLATE = r"""<!DOCTYPE html> |
| 22 | +<html lang="en"> |
| 23 | +<head> |
| 24 | +<meta charset="UTF-8"> |
| 25 | +<meta name="viewport" content="width=device-width, initial-scale=1.0"> |
| 26 | +<title>text_to_image_batch — Gallery</title> |
| 27 | +<style> |
| 28 | + * { box-sizing: border-box; margin: 0; padding: 0; } |
| 29 | + body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; background: #f5f7fa; color: #1a1a2e; padding: 2rem; } |
| 30 | + .container { max-width: 1200px; margin: 0 auto; } |
| 31 | + h1 { font-size: 1.5rem; margin-bottom: 0.25rem; } |
| 32 | + .subtitle { color: #666; margin-bottom: 1.5rem; font-size: 0.9rem; } |
| 33 | + .stats { display: flex; gap: 1rem; flex-wrap: wrap; margin-bottom: 1.5rem; } |
| 34 | + .stat { background: #fff; border-radius: 8px; padding: 1rem 1.25rem; box-shadow: 0 1px 3px rgba(0,0,0,0.1); } |
| 35 | + .stat-value { font-size: 1.4rem; font-weight: 600; color: #4a90d9; } |
| 36 | + .stat-label { font-size: 0.75rem; color: #888; margin-top: 0.2rem; } |
| 37 | + .controls { margin-bottom: 1rem; display: flex; gap: 0.75rem; align-items: center; flex-wrap: wrap; } |
| 38 | + .search { padding: 0.5rem 0.75rem; border: 1px solid #ddd; border-radius: 6px; font-size: 0.9rem; width: 280px; } |
| 39 | + .search:focus { outline: none; border-color: #4a90d9; } |
| 40 | + .btn { padding: 0.5rem 1rem; border: none; border-radius: 6px; font-size: 0.85rem; cursor: pointer; font-weight: 500; background: #e8ecf0; color: #333; } |
| 41 | + .btn:hover { background: #dde2e8; } |
| 42 | + .count { font-size: 0.85rem; color: #666; } |
| 43 | + .grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); gap: 1rem; } |
| 44 | + .card { background: #fff; border-radius: 8px; overflow: hidden; box-shadow: 0 1px 3px rgba(0,0,0,0.1); cursor: pointer; transition: transform 0.15s, box-shadow 0.15s; } |
| 45 | + .card:hover { transform: translateY(-2px); box-shadow: 0 4px 12px rgba(0,0,0,0.15); } |
| 46 | + .card img { width: 100%; height: auto; display: block; background: #f0f0f0; } |
| 47 | + .card-info { padding: 0.75rem; } |
| 48 | + .card-id { font-size: 0.7rem; color: #999; font-family: monospace; margin-bottom: 0.25rem; } |
| 49 | + .card-prompt { font-size: 0.8rem; color: #333; line-height: 1.4; max-height: 4.2em; overflow: hidden; } |
| 50 | + .modal { position: fixed; inset: 0; background: rgba(0,0,0,0.85); display: flex; align-items: center; justify-content: center; z-index: 100; padding: 2rem; } |
| 51 | + .modal.hidden { display: none; } |
| 52 | + .modal-content { max-width: 90vw; max-height: 95vh; display: flex; flex-direction: column; gap: 0.75rem; align-items: center; } |
| 53 | + .modal-content img { max-width: 100%; max-height: 75vh; border-radius: 4px; box-shadow: 0 8px 24px rgba(0,0,0,0.5); } |
| 54 | + .modal-info { background: rgba(255,255,255,0.95); border-radius: 6px; padding: 0.75rem 1rem; font-size: 0.85rem; max-width: 800px; } |
| 55 | + .modal-info-row { margin-bottom: 0.4rem; } |
| 56 | + .modal-info-row:last-child { margin-bottom: 0; } |
| 57 | + .modal-info-label { font-size: 0.7rem; font-weight: 600; color: #888; text-transform: uppercase; letter-spacing: 0.5px; margin-right: 0.5rem; } |
| 58 | + .modal-info-value { white-space: pre-wrap; word-break: break-word; } |
| 59 | + .modal-meta { display: flex; gap: 1rem; font-size: 0.75rem; color: #666; } |
| 60 | + .close-hint { color: rgba(255,255,255,0.6); font-size: 0.8rem; } |
| 61 | +</style> |
| 62 | +</head> |
| 63 | +<body> |
| 64 | +<div class="container"> |
| 65 | + <h1>text_to_image_batch — Gallery</h1> |
| 66 | + <p class="subtitle">Generated with AWS Deadline Cloud</p> |
| 67 | +
|
| 68 | + <div class="stats"> |
| 69 | + <div class="stat"><div class="stat-value" id="totalCount">0</div><div class="stat-label">Total Images</div></div> |
| 70 | + <div class="stat"><div class="stat-value" id="totalTime">0s</div><div class="stat-label">Total Generation Time</div></div> |
| 71 | + <div class="stat"><div class="stat-value" id="avgTime">0s</div><div class="stat-label">Avg per Image</div></div> |
| 72 | + <div class="stat"><div class="stat-value" id="resolution">—</div><div class="stat-label">Resolution</div></div> |
| 73 | + </div> |
| 74 | +
|
| 75 | + <div class="controls"> |
| 76 | + <input type="text" class="search" id="search" placeholder="Search prompts..."> |
| 77 | + <button class="btn" onclick="exportCsv()">Export CSV</button> |
| 78 | + <span class="count" id="showing"></span> |
| 79 | + </div> |
| 80 | +
|
| 81 | + <div class="grid" id="grid"></div> |
| 82 | +</div> |
| 83 | +
|
| 84 | +<div class="modal hidden" id="modal" onclick="closeModal(event)"> |
| 85 | + <div class="modal-content" onclick="event.stopPropagation()"> |
| 86 | + <img id="modalImg" src="" alt=""> |
| 87 | + <div class="modal-info"> |
| 88 | + <div class="modal-info-row"> |
| 89 | + <span class="modal-info-label">ID</span> |
| 90 | + <span class="modal-info-value" id="modalId"></span> |
| 91 | + </div> |
| 92 | + <div class="modal-info-row"> |
| 93 | + <span class="modal-info-label">Final prompt</span> |
| 94 | + <span class="modal-info-value" id="modalPrompt"></span> |
| 95 | + </div> |
| 96 | + <div class="modal-info-row modal-meta"> |
| 97 | + <span><strong id="modalSize"></strong> px</span> |
| 98 | + <span><strong id="modalSteps"></strong> steps</span> |
| 99 | + <span>guidance <strong id="modalGuidance"></strong></span> |
| 100 | + <span>seed <strong id="modalSeed"></strong></span> |
| 101 | + <span><strong id="modalTime"></strong></span> |
| 102 | + </div> |
| 103 | + </div> |
| 104 | + <div class="close-hint">click anywhere outside to close</div> |
| 105 | + </div> |
| 106 | +</div> |
| 107 | +
|
| 108 | +<script> |
| 109 | +const DATA = __DATA_PLACEHOLDER__; |
| 110 | +
|
| 111 | +function esc(s) { |
| 112 | + const el = document.createElement('span'); |
| 113 | + el.textContent = s == null ? '' : String(s); |
| 114 | + return el.innerHTML.replace(/"/g, '"').replace(/'/g, '''); |
| 115 | +} |
| 116 | +
|
| 117 | +function render(filter) { |
| 118 | + const lower = (filter || '').toLowerCase(); |
| 119 | + const filtered = lower |
| 120 | + ? DATA.filter(d => |
| 121 | + (d.final_prompt || '').toLowerCase().includes(lower) || |
| 122 | + (d.prompt || '').toLowerCase().includes(lower) || |
| 123 | + (d.generated_text || '').toLowerCase().includes(lower) || |
| 124 | + (d.id || '').toLowerCase().includes(lower)) |
| 125 | + : DATA; |
| 126 | + document.getElementById('showing').textContent = lower ? `Showing ${filtered.length} of ${DATA.length}` : ''; |
| 127 | + document.getElementById('grid').innerHTML = filtered.map(d => { |
| 128 | + const i = DATA.indexOf(d); |
| 129 | + const caption = d.final_prompt || d.prompt || d.generated_text || ''; |
| 130 | + return ` |
| 131 | + <div class="card" onclick="openModal(${i})"> |
| 132 | + <img src="images/${esc(d.image)}" alt="${esc(caption.slice(0, 80))}" loading="lazy"> |
| 133 | + <div class="card-info"> |
| 134 | + <div class="card-id">${esc(d.id || '#' + (d.index || i + 1))}</div> |
| 135 | + <div class="card-prompt">${esc(caption)}</div> |
| 136 | + </div> |
| 137 | + </div>`; |
| 138 | + }).join(''); |
| 139 | +} |
| 140 | +
|
| 141 | +function openModal(i) { |
| 142 | + const d = DATA[i]; |
| 143 | + document.getElementById('modalImg').src = 'images/' + d.image; |
| 144 | + document.getElementById('modalId').textContent = d.id || '#' + (d.index || i + 1); |
| 145 | + document.getElementById('modalPrompt').textContent = d.final_prompt || d.prompt || d.generated_text || '(no prompt)'; |
| 146 | + document.getElementById('modalSize').textContent = `${d.width || '?'}×${d.height || '?'}`; |
| 147 | + document.getElementById('modalSteps').textContent = d.inference_steps || '?'; |
| 148 | + document.getElementById('modalGuidance').textContent = d.guidance_scale != null ? d.guidance_scale : '?'; |
| 149 | + document.getElementById('modalSeed').textContent = d.seed != null ? d.seed : '?'; |
| 150 | + document.getElementById('modalTime').textContent = d.elapsed_seconds != null ? d.elapsed_seconds.toFixed(1) + 's' : ''; |
| 151 | + document.getElementById('modal').classList.remove('hidden'); |
| 152 | +} |
| 153 | +
|
| 154 | +function closeModal(e) { |
| 155 | + // Only close on background click; clicks on the inner content stop propagation. |
| 156 | + document.getElementById('modal').classList.add('hidden'); |
| 157 | +} |
| 158 | +
|
| 159 | +function csvSafe(c) { const s = String(c); return /^[=+\-@]/.test(s) ? "\t" + s : s; } |
| 160 | +function exportCsv() { |
| 161 | + const rows = [['id', 'image', 'final_prompt', 'width', 'height', 'inference_steps', 'guidance_scale', 'seed', 'elapsed_seconds']]; |
| 162 | + DATA.forEach(d => rows.push([ |
| 163 | + d.id || '', d.image || '', d.final_prompt || d.prompt || '', |
| 164 | + d.width || '', d.height || '', d.inference_steps || '', |
| 165 | + d.guidance_scale == null ? '' : d.guidance_scale, |
| 166 | + d.seed == null ? '' : d.seed, |
| 167 | + d.elapsed_seconds == null ? '' : d.elapsed_seconds, |
| 168 | + ])); |
| 169 | + const csv = rows.map(r => r.map(c => '"' + csvSafe(c).replace(/"/g, '""') + '"').join(',')).join('\n'); |
| 170 | + const blob = new Blob([csv], { type: 'text/csv' }); |
| 171 | + const a = document.createElement('a'); |
| 172 | + a.href = URL.createObjectURL(blob); |
| 173 | + a.download = 'gallery.csv'; |
| 174 | + a.click(); |
| 175 | +} |
| 176 | +
|
| 177 | +// Initial stats |
| 178 | +document.getElementById('totalCount').textContent = DATA.length; |
| 179 | +const totalSeconds = DATA.reduce((s, d) => s + (d.elapsed_seconds || 0), 0); |
| 180 | +document.getElementById('totalTime').textContent = totalSeconds.toFixed(1) + 's'; |
| 181 | +document.getElementById('avgTime').textContent = (DATA.length ? (totalSeconds / DATA.length).toFixed(1) : '0') + 's'; |
| 182 | +const sizes = new Set(DATA.map(d => `${d.width}×${d.height}`)); |
| 183 | +document.getElementById('resolution').textContent = sizes.size === 1 ? [...sizes][0] : (sizes.size + ' sizes'); |
| 184 | +
|
| 185 | +document.getElementById('search').addEventListener('input', e => render(e.target.value)); |
| 186 | +document.addEventListener('keydown', e => { if (e.key === 'Escape') closeModal(); }); |
| 187 | +render(''); |
| 188 | +</script> |
| 189 | +</body> |
| 190 | +</html> |
| 191 | +""" |
| 192 | + |
| 193 | + |
| 194 | +def _index_from_filename(name): |
| 195 | + m = re.search(r"\d+", name) |
| 196 | + return int(m.group()) if m else 0 |
| 197 | + |
| 198 | + |
| 199 | +def main(): |
| 200 | + parser = argparse.ArgumentParser() |
| 201 | + parser.add_argument( |
| 202 | + "--output-dir", |
| 203 | + required=True, |
| 204 | + help="Job's OutputDir; aggregate reads from OutputDir/output/metadata/ " |
| 205 | + "and writes OutputDir/output/{output.jsonl,gallery.html}", |
| 206 | + ) |
| 207 | + args = parser.parse_args() |
| 208 | + |
| 209 | + # All artifacts live under OutputDir/output/ — keeps each job's results |
| 210 | + # grouped so multiple runs can share an OutputDir without clobbering. |
| 211 | + output_root = os.path.join(args.output_dir, "output") |
| 212 | + metadata_dir = os.path.join(output_root, "metadata") |
| 213 | + images_dir = os.path.join(output_root, "images") |
| 214 | + |
| 215 | + if not os.path.isdir(metadata_dir): |
| 216 | + print(f"ERROR: metadata dir does not exist: {metadata_dir}", file=sys.stderr) |
| 217 | + sys.exit(1) |
| 218 | + |
| 219 | + items = [] |
| 220 | + for filename in sorted(os.listdir(metadata_dir), key=_index_from_filename): |
| 221 | + if not filename.endswith(".json"): |
| 222 | + continue |
| 223 | + path = os.path.join(metadata_dir, filename) |
| 224 | + with open(path) as f: |
| 225 | + try: |
| 226 | + items.append(json.load(f)) |
| 227 | + except json.JSONDecodeError as e: |
| 228 | + print(f"WARN: skipping malformed {filename}: {e}", file=sys.stderr) |
| 229 | + |
| 230 | + if not items: |
| 231 | + print( |
| 232 | + f"ERROR: no metadata files found in {metadata_dir} — did the Generate step succeed?", |
| 233 | + file=sys.stderr, |
| 234 | + ) |
| 235 | + sys.exit(1) |
| 236 | + |
| 237 | + # output.jsonl — one line per image, easy to grep / pipe into other tools. |
| 238 | + output_jsonl = os.path.join(output_root, "output.jsonl") |
| 239 | + with open(output_jsonl, "w") as f: |
| 240 | + for item in items: |
| 241 | + f.write(json.dumps(item) + "\n") |
| 242 | + print(f"Aggregated {len(items)} items into {output_jsonl}") |
| 243 | + |
| 244 | + # gallery.html — the human-friendly viewer. References images/ relatively |
| 245 | + # (sibling directory inside output/). |
| 246 | + gallery_path = os.path.join(output_root, "gallery.html") |
| 247 | + data_json = json.dumps(items, ensure_ascii=False) |
| 248 | + html = HTML_TEMPLATE.replace("__DATA_PLACEHOLDER__", data_json) |
| 249 | + with open(gallery_path, "w") as f: |
| 250 | + f.write(html) |
| 251 | + print(f"Generated gallery: {gallery_path}") |
| 252 | + |
| 253 | + # Sanity check that referenced images exist (warn, don't fail). |
| 254 | + missing = [i["image"] for i in items if not os.path.exists(os.path.join(images_dir, i.get("image", "")))] |
| 255 | + if missing: |
| 256 | + print( |
| 257 | + f"WARN: {len(missing)} referenced image(s) missing from {images_dir}: " |
| 258 | + f"{missing[:5]}{'...' if len(missing) > 5 else ''}", |
| 259 | + file=sys.stderr, |
| 260 | + ) |
| 261 | + |
| 262 | + |
| 263 | +if __name__ == "__main__": |
| 264 | + main() |
0 commit comments