├── .gitignore ├── requirements.in ├── tests └── test_truth.py ├── screenshot.jpg ├── screenshot.png ├── dev_requirements.in ├── .github ├── dependabot.yml └── workflows │ └── test.yml ├── pyproject.toml ├── README.md ├── src └── masonry_viewer │ ├── __init__.py │ ├── templates │ └── index.html │ └── image_info.py ├── requirements.txt ├── LICENSE └── dev_requirements.txt /.gitignore: -------------------------------------------------------------------------------- 1 | image_info.db 2 | *.pyc 3 | *.egg-info 4 | -------------------------------------------------------------------------------- /requirements.in: -------------------------------------------------------------------------------- 1 | flask 2 | Pillow 3 | sqlite_utils 4 | tqdm 5 | -------------------------------------------------------------------------------- /tests/test_truth.py: -------------------------------------------------------------------------------- 1 | def test_truth() -> None: 2 | assert True 3 | -------------------------------------------------------------------------------- /screenshot.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/alexwlchan/masonry-viewer/main/screenshot.jpg -------------------------------------------------------------------------------- /screenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/alexwlchan/masonry-viewer/main/screenshot.png -------------------------------------------------------------------------------- /dev_requirements.in: -------------------------------------------------------------------------------- 1 | -r requirements.txt 2 | 3 | mypy 4 | pytest-cov 5 | ruff 6 | types-tqdm 7 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: "github-actions" 4 | directory: "/" 5 | schedule: 6 | interval: "daily" 7 | time: "09:00" 8 | - package-ecosystem: "pip" 9 | directory: "/" 10 | schedule: 11 | interval: "daily" 12 | time: "09:00" 13 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [project] 2 | name = "masonry_viewer" 3 | version = "1.0" 4 | dynamic = ["dependencies"] 5 | 6 | [tool.setuptools.dynamic] 7 | dependencies = {file = ["requirements.txt"]} 8 | 9 | [tool.setuptools.packages.find] 10 | where = ["src"] 11 | 12 | [tool.setuptools.package-data] 13 | analytics = ["static/*", "templates/*"] 14 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # masonry-viewer 2 | 3 | This is a small Python web app that takes a local folder of images, and renders them in a "Masonry" layout. 4 | 5 | ![](screenshot.jpg) 6 | 7 | I made it after reading about the proposal for CSS Grid Level 3, aka "Masonry" layout, on [the WebKit blog](https://webkit.org/blog/15269/help-us-invent-masonry-layouts-for-css-grid-level-3/). 8 | I wanted to experiment with the new layout options. 9 | 10 | ## Installation 11 | 12 | ```console 13 | $ git clone https://github.com/alexwlchan/masonry-viewer.git 14 | $ cd masonry-viewer 15 | $ python -m venv .venv 16 | $ source .venv/bin/activate 17 | $ pip install -e . 18 | ``` 19 | 20 | ## Usage 21 | 22 | ```console 23 | $ pip install -e . 24 | $ ROOT=/path/to/images/ python3 -m flask --app "masonry_viewer:app" run 25 | ``` 26 | 27 | ## License 28 | 29 | MIT. 30 | -------------------------------------------------------------------------------- /src/masonry_viewer/__init__.py: -------------------------------------------------------------------------------- 1 | import os 2 | import random 3 | 4 | from flask import Flask, current_app, render_template, request, send_file 5 | 6 | from .image_info import get_image_info 7 | 8 | 9 | root = os.environ["ROOT"] 10 | 11 | app = Flask(__name__) 12 | app.config["ROOT"] = root 13 | 14 | get_image_info(root, show_progress=True) 15 | 16 | 17 | @app.route("/") 18 | def index() -> str: 19 | root = current_app.config["ROOT"] 20 | 21 | image_info = sorted(get_image_info(root), key=lambda info: random.random()) 22 | 23 | return render_template("index.html", image_info=image_info, root=root) 24 | 25 | 26 | @app.route("/image") 27 | def send_image(): 28 | path = request.args["path"] 29 | resp = send_file(os.path.abspath(path)) 30 | resp.cache_control.max_age = 3153600 31 | del resp.cache_control.no_cache 32 | resp.cache_control.public = True 33 | return resp 34 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Test 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | 8 | pull_request: 9 | branches: 10 | - main 11 | 12 | jobs: 13 | test: 14 | runs-on: ubuntu-latest 15 | 16 | steps: 17 | - uses: actions/checkout@v4 18 | 19 | - name: Set up Python 20 | uses: actions/setup-python@v5 21 | with: 22 | python-version: "3.12" 23 | cache: 'pip' 24 | cache-dependency-path: 'dev_requirements.txt' 25 | 26 | - name: Install dependencies 27 | run: | 28 | pip install -r dev_requirements.txt 29 | pip install -e . 30 | 31 | - name: Run linting 32 | run: | 33 | ruff check . 34 | ruff format --check . 35 | 36 | - name: Check types 37 | run: mypy src tests 38 | 39 | - name: Run tests 40 | run: | 41 | coverage run -m pytest tests 42 | coverage report 43 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | # This file was autogenerated by uv via the following command: 2 | # uv pip compile requirements.in --output-file requirements.txt 3 | blinker==1.8.1 4 | # via flask 5 | click==8.1.7 6 | # via 7 | # click-default-group 8 | # flask 9 | # sqlite-utils 10 | click-default-group==1.2.4 11 | # via sqlite-utils 12 | flask==3.0.3 13 | # via -r requirements.in 14 | itsdangerous==2.2.0 15 | # via flask 16 | jinja2==3.1.4 17 | # via flask 18 | markupsafe==2.1.5 19 | # via 20 | # jinja2 21 | # werkzeug 22 | pillow==10.4.0 23 | # via -r requirements.in 24 | pluggy==1.5.0 25 | # via sqlite-utils 26 | python-dateutil==2.9.0.post0 27 | # via sqlite-utils 28 | six==1.16.0 29 | # via python-dateutil 30 | sqlite-fts4==1.0.3 31 | # via sqlite-utils 32 | sqlite-utils==3.37 33 | # via -r requirements.in 34 | tabulate==0.9.0 35 | # via sqlite-utils 36 | tqdm==4.66.5 37 | # via -r requirements.in 38 | werkzeug==3.0.3 39 | # via flask 40 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2024 Alex Chan 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a 4 | copy of this software and associated documentation files (the "Software"), 5 | to deal in the Software without restriction, including without limitation 6 | the rights to use, copy, modify, merge, publish, distribute, sublicense, 7 | and/or sell copies of the Software, and to permit persons to whom the Software 8 | is furnished to do so, subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in 11 | all copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL 16 | THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR 17 | OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, 18 | ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR 19 | OTHER DEALINGS IN THE SOFTWARE. 20 | -------------------------------------------------------------------------------- /src/masonry_viewer/templates/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | {{ root }} 10 | 11 | 54 | 55 | 56 | 57 |

58 | {{ image_info|length }} images in {{ root }} 59 |

60 | 61 |
62 | {% for img in image_info %} 63 | 64 | 65 | {% endfor %} 66 |
67 | 68 | 69 | 70 | -------------------------------------------------------------------------------- /dev_requirements.txt: -------------------------------------------------------------------------------- 1 | # This file was autogenerated by uv via the following command: 2 | # uv pip compile dev_requirements.in --output-file dev_requirements.txt 3 | blinker==1.8.1 4 | # via 5 | # -r requirements.txt 6 | # flask 7 | click==8.1.7 8 | # via 9 | # -r requirements.txt 10 | # click-default-group 11 | # flask 12 | # sqlite-utils 13 | click-default-group==1.2.4 14 | # via 15 | # -r requirements.txt 16 | # sqlite-utils 17 | coverage[toml]==7.5.1 18 | # via pytest-cov 19 | flask==3.0.3 20 | # via -r requirements.txt 21 | iniconfig==2.0.0 22 | # via pytest 23 | itsdangerous==2.2.0 24 | # via 25 | # -r requirements.txt 26 | # flask 27 | jinja2==3.1.4 28 | # via 29 | # -r requirements.txt 30 | # flask 31 | markupsafe==2.1.5 32 | # via 33 | # -r requirements.txt 34 | # jinja2 35 | # werkzeug 36 | mypy==1.11.2 37 | # via -r dev_requirements.in 38 | mypy-extensions==1.0.0 39 | # via mypy 40 | packaging==24.0 41 | # via pytest 42 | pillow==10.4.0 43 | # via -r requirements.txt 44 | pluggy==1.5.0 45 | # via 46 | # -r requirements.txt 47 | # pytest 48 | # sqlite-utils 49 | pytest==8.2.0 50 | # via pytest-cov 51 | pytest-cov==5.0.0 52 | # via -r dev_requirements.in 53 | python-dateutil==2.9.0.post0 54 | # via 55 | # -r requirements.txt 56 | # sqlite-utils 57 | ruff==0.6.7 58 | # via -r dev_requirements.in 59 | six==1.16.0 60 | # via 61 | # -r requirements.txt 62 | # python-dateutil 63 | sqlite-fts4==1.0.3 64 | # via 65 | # -r requirements.txt 66 | # sqlite-utils 67 | sqlite-utils==3.37 68 | # via -r requirements.txt 69 | tabulate==0.9.0 70 | # via 71 | # -r requirements.txt 72 | # sqlite-utils 73 | tqdm==4.66.5 74 | # via -r requirements.txt 75 | types-tqdm==4.66.0.20240417 76 | # via -r dev_requirements.in 77 | typing-extensions==4.11.0 78 | # via mypy 79 | werkzeug==3.0.3 80 | # via 81 | # -r requirements.txt 82 | # flask 83 | -------------------------------------------------------------------------------- /src/masonry_viewer/image_info.py: -------------------------------------------------------------------------------- 1 | from collections.abc import Iterator 2 | import os 3 | import pathlib 4 | import subprocess 5 | import typing 6 | 7 | from PIL import Image, UnidentifiedImageError 8 | from sqlite_utils import Database 9 | from sqlite_utils.db import Table 10 | import tqdm 11 | 12 | 13 | def choose_tint_color(path: pathlib.Path) -> str: 14 | """ 15 | Given an image, choose a single color based on the colors in the 16 | image that will look good against a black background. 17 | """ 18 | result = subprocess.check_output( 19 | [ 20 | "dominant_colours", 21 | str(path), 22 | "--max-colours=8", 23 | "--best-against-bg=#222", 24 | "--no-palette", 25 | ] 26 | ) 27 | 28 | return result.strip().decode("utf8") 29 | 30 | 31 | def get_file_paths_under(root=".") -> Iterator[pathlib.Path]: 32 | """ 33 | Generates the absolute paths to every matching file under ``root``. 34 | """ 35 | root = pathlib.Path(root) 36 | 37 | if root.exists() and not root.is_dir(): 38 | raise ValueError(f"Cannot find files under file: {root!r}") 39 | 40 | if not root.is_dir(): 41 | raise FileNotFoundError(root) 42 | 43 | for dirpath, _, filenames in root.walk(): 44 | for f in filenames: 45 | if f == ".DS_Store": 46 | continue 47 | 48 | yield dirpath / f 49 | 50 | 51 | class ImageInfo(typing.TypedDict): 52 | path: pathlib.Path 53 | width: int 54 | height: int 55 | mtime: float 56 | tint_color: str 57 | 58 | 59 | def get_info(path: pathlib.Path, mtime: float) -> ImageInfo | None: 60 | """ 61 | Get information about a single image. 62 | """ 63 | try: 64 | im = Image.open(path) 65 | except UnidentifiedImageError: 66 | return None 67 | 68 | tint_color = choose_tint_color(path) 69 | 70 | info = ImageInfo( 71 | path=path, 72 | width=im.width, 73 | height=im.height, 74 | mtime=mtime, 75 | tint_color=tint_color, 76 | ) 77 | 78 | db = Database("image_info.db") 79 | Table(db, "images").upsert( 80 | { 81 | "path": str(path), 82 | "mtime": mtime, 83 | "width": info["width"], 84 | "height": info["height"], 85 | "tint_color": info["tint_color"], 86 | }, 87 | pk="path", 88 | ) 89 | 90 | return info 91 | 92 | 93 | def get_image_info(root: str, *, show_progress: bool = False) -> list[ImageInfo]: 94 | """ 95 | Get info about all the images under ``root``. 96 | """ 97 | db = Database("image_info.db") 98 | 99 | known_images: dict[tuple[pathlib.Path, float], ImageInfo] = {} 100 | 101 | for row in db["images"].rows: 102 | row["path"] = pathlib.Path(row["path"]) 103 | known_images[(row["path"], row["mtime"])] = typing.cast(ImageInfo, row) 104 | 105 | result: list[ImageInfo] = [] 106 | 107 | paths = list(get_file_paths_under(root)) 108 | 109 | if show_progress: 110 | paths = tqdm.tqdm(paths) # type: ignore 111 | 112 | for p in paths: 113 | mtime = os.path.getmtime(p) 114 | 115 | info: ImageInfo | None 116 | 117 | try: 118 | info = known_images[(p, mtime)] 119 | except KeyError: 120 | info = get_info(p, mtime) 121 | 122 | if info is not None: 123 | result.append(info) 124 | 125 | return result 126 | --------------------------------------------------------------------------------