#!/usr/bin/env python3 import jinja2 import PIL.Image import subprocess import PIL.ImageOps import argparse import yaml import os import re import hashlib import glob from datetime import datetime imagereg = re.compile("([a-z0-9]*)\\..*") def hashfile(fname): md5 = hashlib.md5() print(fname) with open(fname, "rb") as f: for chunk in iter(lambda: f.read(4096), b""): md5.update(chunk) return md5 def parse_res(res): return int(res) class FileLoader(jinja2.BaseLoader): def get_source(self, environment, template): if not os.path.exists(template): raise jinja2.TemplateNotFound(template) mtime = os.path.getmtime(template) with open(template, "r") as f: source = f.read() return source, template, lambda: mtime == os.path.getmtime(template) class ImageLocation: def __init__(self, folder): self.folder = folder self.stuff = {} self.unused = set() self.existing = {} if not os.path.isdir(folder): os.mkdir(folder) self.look() self.tmpname = os.path.join(folder, "tmp") def look(self): for name in os.listdir(self.folder): m = imagereg.match(name) if m is not None: fhash = m.group(1) self.unused.add(fhash) self.existing[fhash] = name def convert(self, imgname, settings): # First determine the hash from settings thash = hashlib.md5( bytes( f"{settings['res']}+{settings['ext']}" + imgname, encoding="utf8") ).hexdigest() # Now check if it exists if thash in self.existing: print(f"Skipping file {imgname}[{thash}]") return self.existing[thash], thash # Okay convert it fname = f"{thash}.{settings['ext']}" print(f"Converting file {imgname} to {fname}") tmpname = f"{self.tmpname}.{settings['ext']}" with PIL.Image.open(imgname) as im: target_height = settings["res"] im = PIL.ImageOps.exif_transpose(im) if im.size[0] > target_height: res = (int((im.size[0] / im.size[1]) * target_height), target_height) im = im.resize(res, PIL.Image.ANTIALIAS) im.save(tmpname) os.rename(tmpname, os.path.join(self.folder, fname)) self.existing[thash] = fname self.unused.add(thash) return fname, thash def fetch(self, imgname, settings, relto=None): # Check if we already have it fname, hash = self.convert(imgname, settings) # Mark the hash as used self.unused.remove(hash) if relto is not None: full = os.path.join(self.folder, fname) return os.path.relpath(full, start=relto) return fname def clean(self): print("Running cleanup") for uhash in self.unused: fname = os.path.join(self.folder, self.existing[uhash]) print(f"Removing file {fname}") os.remove(fname) class Image: def __init__(self, filename): self.name = os.path.basename(filename) self.filename = filename self.description = "" self.scaledpath = "" self.fullurl = "" self.taken = None self.load_metadata() def load_metadata(self): result = subprocess.run(["exiftool", "-s", "-s", "-s", "-DateTimeOriginal", self.filename], stdout = subprocess.PIPE) taken_str = result.stdout.decode("utf-8").strip() try: self.taken = datetime.strptime(taken_str, "%Y:%m:%d %H:%M:%S") except ValueError: print(f"Could not load date from image {self.filename}") return def value_or_default(val, default=None): if val is None: return default return val class Loader: def __init__(self, config_file, loadpath): self.config = Loader.__load_config(config_file) self.loadpath = loadpath self.images = [] def __load_config(config_file): config = {} with open(config_file, "r") as f: config = yaml.safe_load(f) print(f"Loaded config: {config}") return config def load(self, imagehook=None, clear=False): # Clear if neccesary if clear: self.images = [] # First find all images from glob for image in glob.iglob(os.path.join(self.loadpath, self.config["imageglob"])): img = Image(image) img.description = self.config["info"].get(img.name, "") if imagehook is not None: img = imagehook(img) self.images.append(img) self.images = sorted(self.images, key=lambda img: value_or_default(img.taken, datetime.min), reverse=True) class Renderer: def __init__(self, loader, resolution, extension): self.loader = loader self.scaled = None self.settings = { "res": parse_res(resolution), "ext": extension, } def build_to(self, dest, template, context, loc=None, clean=False): # Load images if loc is None: loc = ImageLocation(os.path.join(dest, "imgs")) def imgproc(img): if context["cgit"] is not None: img.fullurl = os.path.join(context["cgit"], "plain", img.name) img.scaledpath = loc.fetch(img.filename, self.settings, relto=dest) return img self.loader.load(imgproc, clear=True) jenv = jinja2.Environment( loader=FileLoader(), autoescape=jinja2.select_autoescape(['html', 'xml']) ) tmpl = jenv.get_template(template) with open(os.path.join(dest, "index.html"), "w") as f: f.write(tmpl.render({ "ctx": context, "images": self.loader.images })) if clean: loc.clean() parser = argparse.ArgumentParser() parser.add_argument("--dest", "-d", default="build", help="where to put resulting files") parser.add_argument("--size", "-s", default="1080", help="size to scale web images to") parser.add_argument("--commit", "-g", help="git commit hash to announce") parser.add_argument("--clean", help="clean unused image files", action="store_true") parser.add_argument("--config", "-c", default="imginfo.yml", help="where to load image definitions from") parser.add_argument("--template", "-t", default="index.html.j2", help="html template to use") parser.add_argument("--cgit", "-w", help="cgit repo base url") parser.add_argument("--load", "-l", default=".", help="where to load full size images from") parser.add_argument("--ext", "-e", default="png", help="image extension to use") args = parser.parse_args() context = { "cgit": args.cgit, "git": args.commit } loader = Loader(args.config, args.load) rend = Renderer(loader, args.size, args.ext) rend.build_to(args.dest, args.template, context, clean=args.clean)