Browse Source

🔥 This is the main rewrite from boop to révélateur

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
David Larlet 2 years ago
parent
commit
bcf20404e5
5 changed files with 364 additions and 287 deletions
  1. 10
    5
      Makefile
  2. 100
    0
      README.md
  3. 0
    278
      boop.py
  4. 14
    4
      configuration.py
  5. 240
    0
      revelateur.py

+ 10
- 5
Makefile View File

@@ -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:

+ 100
- 0
README.md View File

@@ -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

+ 0
- 278
boop.py View File

@@ -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()

+ 14
- 4
configuration.py View File

@@ -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),
]

+ 240
- 0
revelateur.py View File

@@ -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()

Loading…
Cancel
Save