There are still a couple of things I want to change but I don't want to have a bigger commit (it's already unusable as a diff… shame on me)master
include .env | include .env | ||||
SERVER_OUTPUT = $(MF_GALLERY_OUTPUT) # user@server.url:/path/to/website | |||||
SERVER_OUTPUT = $(TARGET_SERVER_OUTPUT) # user@server.url:/path/to/website | |||||
.PHONY: install | .PHONY: install | ||||
install: ## Install the dependencies to build the website | install: ## Install the dependencies to build the website | ||||
pip3 install --user -r requirements.txt | |||||
python -m pip install -r requirements.txt | |||||
.PHONY: build | .PHONY: build | ||||
build: ## Build the website | build: ## Build the website | ||||
python3 boop.py | |||||
python revelateur.py | |||||
.PHONY: ulid | |||||
ulid: ## Generate a ULID (useful for passwords) | |||||
@python -c 'from ulid import ULID;print(str(ULID()))' | |||||
.PHONY: clean | .PHONY: clean | ||||
clean: ## Clean output files | clean: ## Clean output files | ||||
.PHONY: serve | .PHONY: serve | ||||
serve: build ## Serve the website (development) | serve: build ## Serve the website (development) | ||||
cd output && python3 -m http.server 8080 | |||||
cd output && python -m http.server 8080 | |||||
.PHONY: publish | .PHONY: publish | ||||
publish: build ## Publish the website online (rsync) | publish: build ## Publish the website online (rsync) | ||||
rsync -P -rvzc --cvs-exclude --delete ./output/ $(SERVER_OUTPUT) | |||||
# With rsync 3.1.0 you can use `--info=progress2` | |||||
rsync -P -rzc --stats --cvs-exclude --delete ./output/ $(SERVER_OUTPUT) | |||||
.PHONY: help | .PHONY: help | ||||
help: | help: |
# Révélateur | |||||
> [révélateur](https://fr.wiktionary.org/wiki/r%C3%A9v%C3%A9lateur#Nom_commun_2) \ʁe.ve.la.tœʁ\ masculin : (Photographie) Bain chimique où l’on trempe le cliché pour faire apparaître l’image encore invisible. (Anglais : [developer](https://fr.wiktionary.org/wiki/developer#en)) | |||||
## History | |||||
This is a fork from [boop](https://framagit.org/marienfressinaud/photos.marienfressinaud.fr) by Marien Fressinaud (thank you!). | |||||
I used that code as a base to setup my own photos & videos website: | |||||
[https://media.larlet.fr/](https://media.larlet.fr/) | |||||
This adaptation tries to generate different images sizes and to serve | |||||
the correct resolution to the client in order to minimize the weight of | |||||
the pages. You can set new sizes in `configuration.py`. | |||||
Note that it might also be interesting to check out the original repo, | |||||
it depends on your requirements. | |||||
## Installation | |||||
Requirements: Python 3.7+ (First released: 2018-06-27) | |||||
You have to install a webp converter, there are [binaries here](https://developers.google.com/speed/webp/download), | |||||
or you can use your OS bundler (for instance homebrew for macOS): | |||||
$ brew install webp | |||||
Then create and activate a Python virtual env: | |||||
$ python -m venv venv | |||||
$ source venv/bin/activate | |||||
Lastly, install Python dependencies: | |||||
$ make install | |||||
## Gallery creation | |||||
Put a folder full of photographies in the `photos` folder. | |||||
You can create a `metadata.yml` file in it with the title of | |||||
the album (`name`) and the name of the cover photography (`cover`): | |||||
``` | |||||
--- | |||||
name: "Title of the album" | |||||
cover: "filename-of-the-cover-image-without-extension" | |||||
``` | |||||
### Private galleries | |||||
If you set a `password` key in your `metadata.yml` file, the gallery | |||||
will turn into a private one. It will not be listed in the default index | |||||
and only people with the correct URL will be able to see your photos. | |||||
⚠️ This is not a very secured way to hide something from the internets! | |||||
If you want to share confidential images, find another tool. | |||||
There is a command (`make ulid`) to help you get a password that is | |||||
readable and spellable across a phone call for instance. | |||||
Also note that you must not commit/push your `metadata.yml` file | |||||
with a password in it to a public repository, obviously. | |||||
## Generation | |||||
Running `make` will display the help for all existing commands. | |||||
Most of the time you want to run: | |||||
$ make build serve | |||||
And then go to: [http://localhost:8080](http://localhost:8080) | |||||
⚠️ Note that the default theme contains fonts that I am not allowed to | |||||
distribute. You have to put your own files there or you can rely on a | |||||
[system font stack](https://css-tricks.com/snippets/css/system-font-stack/). | |||||
## Deployment | |||||
Set a `TARGET_SERVER_OUTPUT` variable in your `.env` file: | |||||
$ echo TARGET_SERVER_OUTPUT=user@server.url:/path/to/website > .env | |||||
Then `make publish` will rsync the content of the `output` folder | |||||
to your server. And hopefully not send existing files twice. | |||||
## Development | |||||
You must install the development dependencies to respect the current | |||||
code style: | |||||
$ python -m pip install -r requirements-dev.txt |
#!/usr/bin/env python3 | |||||
import os | |||||
import shutil | |||||
import re | |||||
import yaml | |||||
from operator import itemgetter | |||||
from jinja2 import Environment, PackageLoader, select_autoescape | |||||
from PIL import Image, ExifTags | |||||
from dotenv import load_dotenv | |||||
from configuration import SITE_TITLE, SITE_AUTHOR, SITE_AUTHOR_WEBSITE, THEME | |||||
# Load env var from .env file | |||||
load_dotenv(dotenv_path=".env") | |||||
PICTURES_DIR_NAME = "photos" | |||||
OUTPUT_DIR_NAME = "output" | |||||
# Configure YAML to accept environment variables in metadata files | |||||
# Based on https://stackoverflow.com/a/27232341 solution | |||||
pattern = re.compile(r"^ENV\[\'(.*)\'\]$") | |||||
yaml.add_implicit_resolver("!pathex", pattern, Loader=yaml.SafeLoader) | |||||
def pathex_constructor(loader, node): | |||||
value = loader.construct_scalar(node) | |||||
env_var, = pattern.match(value).groups() | |||||
return os.getenv(env_var) | |||||
yaml.add_constructor("!pathex", pathex_constructor, yaml.SafeLoader) | |||||
def list_galleries_in(path): | |||||
gallery_dirs = [f for f in os.scandir(path) if f.is_dir()] | |||||
for gallery_dir in gallery_dirs: | |||||
metadata = {} | |||||
metadata_path = os.path.join(gallery_dir.path, "metadata.yml") | |||||
if os.path.exists(metadata_path): | |||||
with open(metadata_path, "r") as metadata_file: | |||||
metadata = yaml.safe_load(metadata_file) | |||||
private = False | |||||
url = f"{gallery_dir.name}.html" | |||||
output_path = gallery_dir.name | |||||
password = metadata.get("password", None) | |||||
if password: | |||||
private = True | |||||
url = f"{gallery_dir.name}-{password}.html" | |||||
output_path = f"{output_path}-{password}" | |||||
photos = list(list_photos_in(gallery_dir, output_path)) | |||||
if len(photos) == 0: | |||||
continue | |||||
photos.sort(key=itemgetter("name")) | |||||
# Try to get cover from metadata, if it doesn't exist, take one by | |||||
# default. | |||||
cover_photo = None | |||||
cover_name = metadata.get("cover") | |||||
if cover_name: | |||||
cover_photo = find_photo(photos, cover_name) | |||||
if cover_photo is None: | |||||
cover_index = (len(gallery_dir.name) + 42) % len(photos) | |||||
cover_photo = photos[cover_index] | |||||
gallery = { | |||||
"name": metadata.get("name", gallery_dir.name), | |||||
"path": gallery_dir.path, | |||||
"output_path": output_path, | |||||
"url": url, | |||||
"num_photos": len(photos), | |||||
"photos": photos, | |||||
"cover_photo": cover_photo, | |||||
"private": private, | |||||
} | |||||
yield gallery | |||||
def find_photo(photos, photo_name): | |||||
for photo in photos: | |||||
if photo["name"] == photo_name: | |||||
return photo | |||||
return None | |||||
def list_photos_in(gallery_dir, gallery_output_path): | |||||
photo_files = [ | |||||
f for f in os.scandir(gallery_dir) if re.match(".+\.jpg", f.name, re.I) | |||||
] | |||||
for photo_file in photo_files: | |||||
url = os.path.join(gallery_output_path, photo_file.name) | |||||
thumb_url = os.path.join(gallery_output_path, f"thumb_{photo_file.name}") | |||||
photo = { | |||||
"name": photo_file.name, | |||||
"path": photo_file.path, | |||||
"url": url, | |||||
"thumb_url": thumb_url, | |||||
} | |||||
yield photo | |||||
def generate_output_dir(): | |||||
output_path = os.path.join(os.curdir, OUTPUT_DIR_NAME) | |||||
if not os.path.isdir(output_path): | |||||
os.mkdir(output_path) | |||||
return output_path | |||||
def generate_style(output_path): | |||||
style_path = os.path.join(os.curdir, THEME, "style") | |||||
style_output_path = os.path.join(output_path, "style") | |||||
if os.path.isdir(style_output_path): | |||||
shutil.rmtree(style_output_path) | |||||
shutil.copytree(style_path, style_output_path) | |||||
def generate_scripts(output_path): | |||||
scripts_path = os.path.join(os.curdir, THEME, "scripts") | |||||
scripts_output_path = os.path.join(output_path, "scripts") | |||||
if os.path.isdir(scripts_output_path): | |||||
shutil.rmtree(scripts_output_path) | |||||
shutil.copytree(scripts_path, scripts_output_path) | |||||
def generate_index(output_path, galleries): | |||||
index_path = os.path.join(output_path, "index.html") | |||||
theme_path = os.path.join(os.curdir, THEME) | |||||
jinja_env = Environment( | |||||
loader=PackageLoader("boop", theme_path), autoescape=select_autoescape(["html"]) | |||||
) | |||||
index_template = jinja_env.get_template("index.html.j2") | |||||
with open(index_path, "w") as index_file: | |||||
index_file.write( | |||||
index_template.render( | |||||
galleries=galleries, | |||||
site_title=SITE_TITLE, | |||||
site_author=SITE_AUTHOR, | |||||
site_author_website=SITE_AUTHOR_WEBSITE, | |||||
) | |||||
) | |||||
def generate_gallery(output_path, gallery): | |||||
generate_gallery_index(output_path, gallery) | |||||
generate_gallery_photos(output_path, gallery) | |||||
def generate_gallery_index(output_path, gallery): | |||||
gallery_index_path = os.path.join(output_path, gallery["url"]) | |||||
theme_path = os.path.join(os.curdir, THEME) | |||||
jinja_env = Environment( | |||||
loader=PackageLoader("boop", theme_path), autoescape=select_autoescape(["html"]) | |||||
) | |||||
gallery_template = jinja_env.get_template("gallery.html.j2") | |||||
with open(gallery_index_path, "w") as gallery_file: | |||||
gallery_file.write( | |||||
gallery_template.render( | |||||
gallery=gallery, | |||||
site_title=SITE_TITLE, | |||||
site_author=SITE_AUTHOR, | |||||
site_author_website=SITE_AUTHOR_WEBSITE, | |||||
) | |||||
) | |||||
def generate_gallery_photos(output_path, gallery): | |||||
gallery_output_path = os.path.join(output_path, gallery["output_path"]) | |||||
if not os.path.isdir(gallery_output_path): | |||||
os.mkdir(gallery_output_path) | |||||
for photo in gallery["photos"]: | |||||
photo_output_path = os.path.join(output_path, photo["url"]) | |||||
if not os.path.exists(photo_output_path): | |||||
shutil.copyfile(photo["path"], photo_output_path) | |||||
thumb_output_path = os.path.join(output_path, photo["thumb_url"]) | |||||
if not os.path.exists(thumb_output_path): | |||||
generate_thumb_file(thumb_output_path, photo) | |||||
def generate_thumb_file(output_path, photo): | |||||
orientation_key = get_orientation_exif_key() | |||||
size = (440, 264) | |||||
with Image.open(photo["path"]) as image: | |||||
# First, make sure image is correctly oriented | |||||
exif = image._getexif() | |||||
if exif[orientation_key] == 3: | |||||
image = image.rotate(180, expand=True) | |||||
elif exif[orientation_key] == 6: | |||||
image = image.rotate(270, expand=True) | |||||
elif exif[orientation_key] == 8: | |||||
image = image.rotate(90, expand=True) | |||||
w, h = image.size | |||||
if w > size[0] and h > size[1]: | |||||
# If the original file is larger in width AND height, we resize | |||||
# first the image to the lowest size accepted (both width and | |||||
# height stays greater or equal to requested size). | |||||
# E.g. 1200x900 is resized to 440x330 | |||||
# 1200x600 is resized to 528x264 | |||||
if size[0] / size[1] <= w / h: | |||||
w = int(max(size[1] * w / h, 1)) | |||||
h = 264 | |||||
else: | |||||
h = int(max(size[0] * h / w, 1)) | |||||
w = 440 | |||||
new_size = (w, h) | |||||
image.draft(None, new_size) | |||||
image = image.resize(new_size, Image.BICUBIC) | |||||
# We now have an image with at least w = 440 OR h = 264 (unless one of | |||||
# the size is smaller). But the image can still be larger than | |||||
# requested size, so we have to crop the image in the middle. | |||||
crop_box = None | |||||
if w > size[0]: | |||||
left = (w - size[0]) / 2 | |||||
right = left + size[0] | |||||
crop_box = (left, 0, right, h) | |||||
elif h > size[1]: | |||||
upper = (h - size[1]) / 2 | |||||
lower = upper + size[1] | |||||
crop_box = (0, upper, w, lower) | |||||
if crop_box is not None: | |||||
image = image.crop(crop_box) | |||||
# And we save the final image. | |||||
image.save(output_path) | |||||
def get_orientation_exif_key(): | |||||
for (key, tag) in ExifTags.TAGS.items(): | |||||
if tag == "Orientation": | |||||
return key | |||||
def main(): | |||||
print("Loading galleries... ", end="") | |||||
pictures_folder = os.path.join(os.curdir, PICTURES_DIR_NAME) | |||||
galleries = list(list_galleries_in(pictures_folder)) | |||||
if len(galleries) == 0: | |||||
print(f"No galleries found in {pictures_folder}") | |||||
return | |||||
galleries.sort(key=itemgetter("name")) | |||||
print(f"{len(galleries)} galleries found.") | |||||
print("Generating output folder... ", end="") | |||||
output_path = generate_output_dir() | |||||
print("✔️") | |||||
print("Generating style folder... ", end="") | |||||
generate_style(output_path) | |||||
print("✔️") | |||||
print("Generating scripts folder... ", end="") | |||||
generate_scripts(output_path) | |||||
print("✔️") | |||||
print("Generating index file... ", end="") | |||||
generate_index(output_path, galleries) | |||||
print("✔️") | |||||
for gallery in galleries: | |||||
print(f"Generating {gallery['name']} gallery ({gallery['url']})... ", end="") | |||||
generate_gallery(output_path, gallery) | |||||
print("✔️") | |||||
print("Galleries generated 🎉") | |||||
if __name__ == "__main__": | |||||
main() |
SITE_TITLE = "En passant par là…" | |||||
SITE_AUTHOR = "@marien" | |||||
SITE_AUTHOR_WEBSITE = "https://marienfressinaud.fr" | |||||
THEME = "Herisson" | |||||
from pathlib import Path | |||||
HERE = Path(".") | |||||
PICTURES_DIR = HERE / "photos" | |||||
OUTPUT_DIR = HERE / "output" | |||||
THEME_DIR = HERE / "theme" | |||||
SIZES = [ | |||||
(660, 440), | |||||
(990, 660), | |||||
(1320, 880), | |||||
(1980, 1320), | |||||
(2640, 1760), | |||||
# (3300, 2200), | |||||
] |
#!/usr/bin/env python3 | |||||
import os | |||||
import shutil | |||||
import re | |||||
import subprocess | |||||
import yaml | |||||
from operator import attrgetter, itemgetter | |||||
from pathlib import Path | |||||
from jinja2 import Environment, PackageLoader, select_autoescape | |||||
from PIL import Image | |||||
from configuration import OUTPUT_DIR, PICTURES_DIR, SIZES, THEME_DIR | |||||
FNULL = open(os.devnull, "w") | |||||
jinja_env = Environment( | |||||
loader=PackageLoader("revelateur", THEME_DIR), | |||||
autoescape=select_autoescape(["html"]), | |||||
) | |||||
def neighborhood(iterable, first=None, last=None): | |||||
""" | |||||
Yield the (previous, current, next) items given an iterable. | |||||
You can specify a `first` and/or `last` item for bounds. | |||||
""" | |||||
iterator = iter(iterable) | |||||
previous = first | |||||
current = next(iterator) # Throws StopIteration if empty. | |||||
for next_ in iterator: | |||||
yield (previous, current, next_) | |||||
previous = current | |||||
current = next_ | |||||
yield (previous, current, last) | |||||
def list_galleries_in(path): | |||||
gallery_dirs = [f for f in os.scandir(path) if f.is_dir()] | |||||
for gallery_dir in gallery_dirs: | |||||
gallery_path = Path(gallery_dir.path) | |||||
metadata = {} | |||||
metadata_path = gallery_path / "metadata.yml" | |||||
if metadata_path.exists(): | |||||
metadata = yaml.safe_load(metadata_path.read_text()) | |||||
private = False | |||||
slug = gallery_dir.name | |||||
password = metadata.get("password", None) | |||||
if password: | |||||
private = True | |||||
slug = f"{slug}-{password}" | |||||
photos = list(list_photos_in(gallery_dir, slug)) | |||||
if not photos: | |||||
continue | |||||
photos.sort(key=itemgetter("name")) | |||||
cover_photo = find_photo(photos, metadata.get("cover", "01")) | |||||
gallery = { | |||||
"name": metadata.get("name", gallery_dir.name), | |||||
"type": metadata.get("type", "photo"), | |||||
"path": gallery_path, | |||||
"slug": slug, | |||||
"photos": photos, | |||||
"cover_photo": cover_photo, | |||||
"private": private, | |||||
} | |||||
yield gallery | |||||
def find_photo(photos, photo_name): | |||||
for photo in photos: | |||||
if photo["name"] == photo_name: | |||||
return photo | |||||
return None | |||||
def list_photos_in(gallery_dir, gallery_slug): | |||||
photo_files = [ | |||||
f for f in os.scandir(gallery_dir) if re.match(".+\.jpg", f.name, re.I) | |||||
] | |||||
for photo_file in sorted(photo_files, key=attrgetter("name")): | |||||
with Image.open(photo_file.path) as image: | |||||
width, height = image.size | |||||
name = Path(photo_file.name).stem | |||||
page_url = f"{gallery_slug}-{name}.html" | |||||
photo = { | |||||
"gallery_slug": gallery_slug, | |||||
"name": name, | |||||
"path": photo_file.path, | |||||
"url": Path(gallery_slug) / photo_file.name, | |||||
"page_url": page_url, | |||||
"width": width, | |||||
"height": height, | |||||
"narrow": height > width, | |||||
"wide": height * 2 < width, | |||||
} | |||||
yield photo | |||||
def create_output_dir(): | |||||
if not OUTPUT_DIR.exists(): | |||||
os.mkdir(OUTPUT_DIR) | |||||
def copy_theme_folder(name, target=None): | |||||
local_path = THEME_DIR / name | |||||
output_path = OUTPUT_DIR / (target if target is not None else name) | |||||
if output_path.exists() and target is None: | |||||
shutil.rmtree(output_path) | |||||
shutil.copytree(local_path, output_path, dirs_exist_ok=True) | |||||
def generate_index(output_path, galleries): | |||||
index_template = jinja_env.get_template("index.html.j2") | |||||
content = index_template.render(galleries=galleries, sizes=SIZES) | |||||
(OUTPUT_DIR / "index.html").write_text(content) | |||||
def generate_gallery(output_path, gallery): | |||||
generate_gallery_photos(output_path, gallery) | |||||
generate_gallery_pages(output_path, gallery) | |||||
def generate_gallery_pages(output_path, gallery): | |||||
photo_template = jinja_env.get_template("media.html.j2") | |||||
for previous, photo, next_ in neighborhood(gallery["photos"]): | |||||
content = photo_template.render( | |||||
photo=photo, previous=previous, next=next_, gallery=gallery, sizes=SIZES | |||||
) | |||||
(OUTPUT_DIR / photo["page_url"]).write_text(content) | |||||
def generate_gallery_photos(output_path, gallery): | |||||
gallery_output_path_jpg = output_path / gallery["slug"] / "jpg" | |||||
gallery_output_path_webp = output_path / gallery["slug"] / "webp" | |||||
os.makedirs(gallery_output_path_jpg, exist_ok=True) | |||||
os.makedirs(gallery_output_path_webp, exist_ok=True) | |||||
for photo in gallery["photos"]: | |||||
# Let's not share the original file for now. | |||||
# photo_output_path = output_path / photo["url"] | |||||
# if not photo_output_path.exists(): | |||||
# shutil.copyfile(photo["path"], photo_output_path) | |||||
for width, height in SIZES: | |||||
jpg_output_path = generate_jpg_photo_for_size( | |||||
gallery_output_path_jpg, photo, width, height | |||||
) | |||||
generate_webp_photo_for_size( | |||||
gallery_output_path_webp, jpg_output_path, photo, width, height | |||||
) | |||||
def generate_webp_photo_for_size( | |||||
gallery_output_path, jpg_output_path, photo, width, height | |||||
): | |||||
output_path = gallery_output_path / f"{photo['name']}_{width}x{height}.webp" | |||||
if output_path.exists(): | |||||
return | |||||
command = ["cwebp", "-q", "80", jpg_output_path, "-o", output_path] | |||||
subprocess.Popen(command, stdout=FNULL, stderr=subprocess.STDOUT).communicate() | |||||
def generate_jpg_photo_for_size(gallery_output_path, photo, width, height): | |||||
output_path = gallery_output_path / f"{photo['name']}_{width}x{height}.jpg" | |||||
if output_path.exists(): | |||||
return output_path | |||||
with Image.open(photo["path"]) as image: | |||||
w, h = image.size | |||||
if w > width and h > height: | |||||
# If the original file is larger in width AND height, we resize | |||||
# first the image to the lowest size accepted (both width and | |||||
# height stays greater or equal to requested size). | |||||
# E.g. in case of (440, 264): | |||||
# 1200x900 is resized to 440x330 | |||||
# 1200x600 is resized to 528x264 | |||||
if width / height <= w / h: | |||||
w = int(max(height * w / h, 1)) | |||||
h = height | |||||
else: | |||||
h = int(max(width * h / w, 1)) | |||||
w = width | |||||
new_size = (w, h) | |||||
image.draft(None, new_size) | |||||
image = image.resize(new_size, Image.BICUBIC) | |||||
image.save(output_path) | |||||
return output_path | |||||
def main(): | |||||
print("Loading galleries... ", end="") | |||||
galleries = list(list_galleries_in(PICTURES_DIR)) | |||||
if not galleries: | |||||
print(f"No galleries found in {PICTURES_DIR}") | |||||
return | |||||
galleries.sort(key=itemgetter("path"), reverse=True) | |||||
print(f"{len(galleries)} galleries found.") | |||||
print("Creating output folder... ", end="") | |||||
create_output_dir() | |||||
print("✔️") | |||||
print("Copying style folder... ", end="") | |||||
copy_theme_folder("style") | |||||
print("✔️") | |||||
print("Copying scripts folder... ", end="") | |||||
copy_theme_folder("scripts") | |||||
print("✔️") | |||||
print("Copying root (favicon, etc) folder... ", end="") | |||||
copy_theme_folder("root", "") | |||||
print("✔️") | |||||
print("Generating index file... ", end="") | |||||
generate_index(OUTPUT_DIR, galleries) | |||||
print("✔️") | |||||
for gallery in galleries: | |||||
print( | |||||
f"Generating {gallery['name']} gallery ({gallery['cover_photo']['page_url']})... ", | |||||
end="", | |||||
) | |||||
generate_gallery(OUTPUT_DIR, gallery) | |||||
print("✔️") | |||||
print("Galleries generated 🎉") | |||||
if __name__ == "__main__": | |||||
main() |