Files
guara/scripts/make-photo-fixture.py
T
Lucas RayanandClaude Sonnet 5 6328220c60
CI / Format and clippy (push) Successful in 1m57s
CI / Dependency audit (push) Successful in 13s
CI / Check desktop app (Linux) (push) Successful in 3m54s
CI / Tests (Linux) (push) Failing after 7m25s
Release / Create release (push) Successful in 6s
Release / CLI (linux-aarch64) (push) Successful in 1m48s
Release / CLI (linux-x86_64) (push) Successful in 2m39s
Release / Desktop app (linux-aarch64) (push) Successful in 10m9s
Release / Desktop app (linux-x86_64) (push) Successful in 12m20s
Release / Desktop app (windows) (push) Successful in 7m32s
Release / Build latest.json (push) Successful in 5s
chore(i18n): finish translating code comments and scripts to English
Completes the English-only standardization for code, comments, scripts
and CI templates (UI/CLI-facing strings stay bilingual pt-BR/en,
untouched). Covers the remaining crates/, apps/gui/ui/ and
apps/gui/src-tauri/ comments, .gitea/ workflow and template comments,
.githooks/ hooks, and the scripts/ source comments for the files
already renamed in a prior commit (check.sh, generate-logo.py,
install-hooks.sh).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VAyr8d7HuAdUGB6aA5xmDD
2026-10-30 20:43:02 -03:00

113 lines
4.9 KiB
Python
Executable File

#!/usr/bin/env python3
"""Generates a FAT32 disk image containing deleted JPEG photos with camera/date/GPS EXIF tags.
Usage: make-photo-fixture.py <out.img> [count=300] [copies=1]
(`copies` duplicates each photo N times under a different filename to quickly generate thousands of files.)
Used to test date/camera/folder browsing and thumbnail generation.
"""
import os, random, shutil, struct, subprocess, sys, tempfile
out = sys.argv[1]
count = int(sys.argv[2]) if len(sys.argv) > 2 else 300
copies = int(sys.argv[3]) if len(sys.argv) > 3 else 1
rnd = random.Random(42)
def ifd_bytes(entries, start):
n = len(entries)
table = struct.pack('<H', n)
data = b''
data_off = start + 2 + 12 * n + 4
for tag, typ, cnt, val in entries:
if isinstance(val, int):
vb = struct.pack('<I', val) if typ == 4 else struct.pack('<H', val) + b'\0\0'
else:
vb = val
if len(vb) <= 4:
table += struct.pack('<HHI', tag, typ, cnt) + vb.ljust(4, b'\0')
else:
if len(data) % 2:
data += b'\0'
table += struct.pack('<HHII', tag, typ, cnt, data_off + len(data))
data += vb
return table + struct.pack('<I', 0) + data
def rat(*pairs):
return b''.join(struct.pack('<II', a, b) for a, b in pairs)
def make_exif(make, model, dt, gps, orient):
def build(exif_off, gps_off):
e0 = [(0x010F, 2, len(make) + 1, make.encode() + b'\0'), (0x0110, 2, len(model) + 1, model.encode() + b'\0'),
(0x0112, 3, 1, orient), (0x8769, 4, 1, exif_off)]
if gps:
e0.append((0x8825, 4, 1, gps_off))
return ifd_bytes(e0, 8)
ex = [(0x9003, 2, 20, dt.encode() + b'\0')]
first = build(0, 0)
exif_off = 8 + len(first)
exif_ifd = ifd_bytes(ex, exif_off)
gps_off = exif_off + len(exif_ifd)
gps_ifd = b''
if gps:
lat, lon = gps
def dms(v):
v = abs(v); d = int(v); m = int((v - d) * 60); s = round(((v - d) * 60 - m) * 60 * 100)
return rat((d, 1), (m, 1), (s, 100))
g = [(1, 2, 2, b'S\0' if lat < 0 else b'N\0'), (2, 5, 3, dms(lat)), (3, 2, 2, b'W\0' if lon < 0 else b'E\0'), (4, 5, 3, dms(lon))]
gps_ifd = ifd_bytes(g, gps_off)
body = build(exif_off, gps_off) + exif_ifd + gps_ifd
tiff = b'II*\0' + struct.pack('<I', 8) + body
return b'Exif\0\0' + tiff
def add_exif(jpg_bytes, exif):
assert jpg_bytes[:2] == b'\xff\xd8'
seg = b'\xff\xe1' + struct.pack('>H', len(exif) + 2) + exif
return jpg_bytes[:2] + seg + jpg_bytes[2:]
cams = [('Canon', 'Canon EOS 80D'), ('Apple', 'iPhone 13'), ('samsung', 'SM-G991B'), ('NIKON CORPORATION', 'NIKON D3500')]
places = [(-23.5505, -46.6333), (-8.0476, -34.8770), (-22.9068, -43.1729), (38.7223, -9.1393)]
folders = ['DCIM/100CANON', 'DCIM/Camera', 'WhatsApp/Images', 'Viagens/Recife', 'Viagens/Lisboa', 'Familia']
tmp = tempfile.mkdtemp()
files = []
for i in range(count):
cam = cams[i % 4]
y = rnd.choice([2021, 2022, 2023, 2024]); mo = rnd.randint(1, 12); d = rnd.randint(1, 28)
dt = f'{y}:{mo:02d}:{d:02d} {rnd.randint(7, 22):02d}:{rnd.randint(0,59):02d}:{rnd.randint(0,59):02d}'
folder = folders[i % len(folders)]
name = f'IMG_{i:04d}.jpg'
p = os.path.join(tmp, name)
w, h = rnd.choice([(480, 320), (320, 480), (640, 360), (400, 400)])
subprocess.run(['convert', '-seed', str(i), '-size', f'{w}x{h}', 'plasma:fractal', '-gravity', 'center', '-pointsize', '34', '-fill', 'white',
'-stroke', 'black', '-strokewidth', '1', '-annotate', '0', f'{y}-{mo:02d}-{d:02d}\n#{i}', '-quality', '82', p], check=True, stderr=subprocess.DEVNULL)
raw = open(p, 'rb').read()
if i % 9 != 0: # leave some images without EXIF metadata
gps = places[i % 4] if i % 3 == 0 else None
raw = add_exif(raw, make_exif(cam[0], cam[1], dt, gps, rnd.choice([1, 1, 1, 6, 8])))
open(p, 'wb').write(raw)
files.append((folder, name, p))
if copies > 1:
base = list(files)
for c in range(1, copies):
for folder, name, p in base:
files.append((folder, name.replace('IMG_', f'IMG{c:03d}_'), p))
mb = max(64, len(files) * 40 // 1024 + 48)
if os.path.exists(out):
os.remove(out)
subprocess.run(['truncate', '-s', f'{mb}M', out], check=True)
subprocess.run(['mkfs.vfat', '-F', '32', out], check=True, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
made = set()
for folder, name, p in files:
cur = ''
for part in folder.split('/'):
cur += '/' + part
if cur not in made:
subprocess.run(['mmd', '-i', out, '::' + cur], check=True, stderr=subprocess.DEVNULL)
made.add(cur)
for folder, name, p in files:
subprocess.run(['mcopy', '-i', out, p, f'::/{folder}/{name}'], check=True)
for folder, name, p in files:
subprocess.run(['mdel', '-i', out, f'::/{folder}/{name}'], check=True)
shutil.rmtree(tmp)
print(f'ok: {out} ({len(files)} deleted photos)')