HTTP-level load driver simulating ~100 guests uploading ~1000 images in bursts over a window, plus SSE viewers and one real browser on /diashow. Correlates upload→upload-processed (pipeline latency), waits for the compression backlog to drain against DB ground truth, and emits per-status/latency metrics with pass/fail flags. Includes a realistic-JPEG generator and a diashow-SSE regression check (confirm-diashow-fix.mjs). Run artifacts are gitignored. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
147 lines
5.3 KiB
Python
147 lines
5.3 KiB
Python
#!/usr/bin/env python3
|
|
"""
|
|
Generate a pool of realistic phone-sized JPEGs for the EventSnap load test.
|
|
|
|
The stress test uploads ~1000 images, but they don't need to be 1000 unique
|
|
files — real load comes from realistic *byte size* and *decode cost*, which
|
|
drive bandwidth, the compression/preview pipeline (decode + 800x800 resize),
|
|
disk usage and the dynamic storage quota. So we generate a modest POOL of
|
|
distinct, high-entropy images (~2-4 MB, 12 MP, like a phone camera) and the
|
|
driver reuses them at random across the 1000 uploads.
|
|
|
|
High entropy matters: a flat gradient compresses to almost nothing and would
|
|
under-stress both the network and the JPEG decoder. We blend random noise over
|
|
a colorful gradient + big shapes so the files land in a realistic size band,
|
|
then tune JPEG quality per-image to hit the target size.
|
|
|
|
Output: $LT_PHOTOS_DIR (default /tmp/eventsnap-loadtest/photos)
|
|
Usage: python3 gen-images.py [COUNT] (default 40)
|
|
"""
|
|
|
|
import os
|
|
import sys
|
|
import math
|
|
import random
|
|
from PIL import Image, ImageDraw, ImageFont
|
|
|
|
OUT_DIR = os.environ.get("LT_PHOTOS_DIR", "/tmp/eventsnap-loadtest/photos")
|
|
COUNT = int(sys.argv[1]) if len(sys.argv) > 1 else 40
|
|
|
|
# Target JPEG size band (bytes). Typical modern phone photo.
|
|
TARGET_MIN = 2_000_000
|
|
TARGET_MAX = 4_500_000
|
|
|
|
# 12 MP-ish, both orientations (phones shoot portrait and landscape).
|
|
SIZES = [(4032, 3024), (3024, 4032)]
|
|
|
|
PALETTES = [
|
|
[(250, 245, 235), (212, 175, 55), (120, 90, 30)], # champagne / gold
|
|
[(245, 244, 242), (190, 190, 198), (90, 92, 100)], # silver / pearl
|
|
[(255, 250, 240), (240, 200, 160), (170, 110, 70)], # warm sunset
|
|
[(235, 240, 248), (140, 170, 210), (40, 70, 120)], # cool blue hour
|
|
[(248, 240, 245), (210, 150, 180), (110, 50, 90)], # rose dusk
|
|
[(240, 248, 242), (150, 200, 170), (40, 110, 80)], # garden green
|
|
]
|
|
|
|
|
|
def lerp(a, b, t):
|
|
return tuple(int(a[i] + (b[i] - a[i]) * t) for i in range(3))
|
|
|
|
|
|
def gradient(w, h, palette, rng):
|
|
"""Diagonal 3-stop gradient base."""
|
|
base = Image.new("RGB", (w, h))
|
|
px = base.load()
|
|
ang = rng.uniform(0, math.pi)
|
|
dx, dy = math.cos(ang), math.sin(ang)
|
|
# precompute per-column/row projection for speed
|
|
maxproj = abs(dx) * w + abs(dy) * h
|
|
for y in range(h):
|
|
for x in range(0, w, 4): # step 4 then fill — good enough, much faster
|
|
t = (dx * x + dy * y) / maxproj
|
|
t = min(1.0, max(0.0, t + rng.uniform(-0.02, 0.02)))
|
|
if t < 0.5:
|
|
c = lerp(palette[0], palette[1], t * 2)
|
|
else:
|
|
c = lerp(palette[1], palette[2], (t - 0.5) * 2)
|
|
for k in range(4):
|
|
if x + k < w:
|
|
px[x + k, y] = c
|
|
return base
|
|
|
|
|
|
def add_shapes(img, rng):
|
|
d = ImageDraw.Draw(img, "RGBA")
|
|
w, h = img.size
|
|
for _ in range(rng.randint(6, 14)):
|
|
x0 = rng.randint(-w // 5, w)
|
|
y0 = rng.randint(-h // 5, h)
|
|
r = rng.randint(w // 12, w // 3)
|
|
col = (rng.randint(0, 255), rng.randint(0, 255), rng.randint(0, 255), rng.randint(20, 90))
|
|
if rng.random() < 0.5:
|
|
d.ellipse([x0, y0, x0 + r, y0 + r], fill=col)
|
|
else:
|
|
d.rectangle([x0, y0, x0 + r, y0 + int(r * rng.uniform(0.4, 1.6))], fill=col)
|
|
return img
|
|
|
|
|
|
def noisy(w, h, rng):
|
|
"""Full-resolution RGB noise from urandom — maximum entropy."""
|
|
return Image.frombytes("RGB", (w, h), os.urandom(w * h * 3))
|
|
|
|
|
|
def label(img, idx, rng):
|
|
d = ImageDraw.Draw(img)
|
|
txt = f"EventSnap load #{idx:03d}"
|
|
try:
|
|
font = ImageFont.load_default(size=64)
|
|
except TypeError:
|
|
font = ImageFont.load_default()
|
|
d.text((60, 60), txt, fill=(255, 255, 255), font=font)
|
|
d.text((62, 62), txt, fill=(0, 0, 0), font=font) # cheap shadow offset
|
|
|
|
|
|
def encode_to_band(img, path, rng):
|
|
"""Try qualities high→low until the file lands under TARGET_MAX; keep the
|
|
first that also clears TARGET_MIN if possible."""
|
|
best = None
|
|
for q in (92, 88, 84, 80, 76, 72):
|
|
img.save(path, "JPEG", quality=q, optimize=False)
|
|
size = os.path.getsize(path)
|
|
best = (q, size)
|
|
if size <= TARGET_MAX:
|
|
if size >= TARGET_MIN:
|
|
return q, size
|
|
# under the band — noise alpha likely too low; accept anyway at high q
|
|
return q, size
|
|
return best # even q72 too big; accept the smallest we made
|
|
|
|
|
|
def main():
|
|
os.makedirs(OUT_DIR, exist_ok=True)
|
|
rng = random.Random(20260718) # deterministic pool
|
|
total_bytes = 0
|
|
print(f"[gen] writing {COUNT} images to {OUT_DIR}")
|
|
for i in range(COUNT):
|
|
w, h = rng.choice(SIZES)
|
|
palette = rng.choice(PALETTES)
|
|
base = gradient(w, h, palette, rng)
|
|
base = add_shapes(base, rng)
|
|
# blend noise to inject entropy -> realistic JPEG size
|
|
alpha = rng.uniform(0.28, 0.42)
|
|
base = Image.blend(base, noisy(w, h, rng), alpha)
|
|
label(base, i, rng)
|
|
path = os.path.join(OUT_DIR, f"photo_{i:03d}.jpg")
|
|
q, size = encode_to_band(base, path, rng)
|
|
total_bytes += size
|
|
print(f" photo_{i:03d}.jpg {w}x{h} q{q} {size/1_000_000:.2f} MB")
|
|
avg = total_bytes / COUNT
|
|
print(f"[gen] done. {COUNT} images, avg {avg/1_000_000:.2f} MB, "
|
|
f"pool total {total_bytes/1_000_000:.1f} MB")
|
|
print(f"[gen] projected for 1000 uploads (originals only): "
|
|
f"~{avg*1000/1_000_000_000:.1f} GB")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|