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
@@ -2,15 +2,19 @@ | |||
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 | |||
install: ## Install the dependencies to build the website | |||
pip3 install --user -r requirements.txt | |||
python -m pip install -r requirements.txt | |||
.PHONY: build | |||
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 | |||
clean: ## Clean output files | |||
@@ -18,11 +22,12 @@ clean: ## Clean output files | |||
.PHONY: serve | |||
serve: build ## Serve the website (development) | |||
cd output && python3 -m http.server 8080 | |||
cd output && python -m http.server 8080 | |||
.PHONY: publish | |||
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 | |||
help: |
@@ -0,0 +1,100 @@ | |||
# 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 |
@@ -1,278 +0,0 @@ | |||
#!/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() |
@@ -1,4 +1,14 @@ | |||
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), | |||
] |
@@ -0,0 +1,240 @@ | |||
#!/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() |