dogsheep / dogsheep-photos

Upload your photos to S3 and import metadata about them into a SQLite database
Apache License 2.0
172 stars 15 forks source link

Ability to serve thumbnailed Apple Photo from its place on disk #20

Closed simonw closed 4 years ago

simonw commented 4 years ago

A custom Datasette plugin that can be run locally on a Mac laptop which knows how to serve photos such that they can be seen in the browser.

Originally posted by @simonw in https://github.com/dogsheep/photos-to-sqlite/issues/19#issuecomment-624406285

simonw commented 4 years ago

The apple_photos table has an indexed uuid column and a path column which stores the full path to that photo file on disk.

I can write a custom Datasette plugin which takes the uuid from the URL, looks up the path, then serves up a thumbnail of the jpeg or heic image file.

I'll prototype this is a one-off plugin first, then package it on PyPI for other people to install.

simonw commented 4 years ago

The plugin can be generalized: it can be configured to know how to take the URL path, look it up in ANY table (via a custom SQL query) to get a path on disk and then serve that.

simonw commented 4 years ago

Here's rendering code from my hacked-together not-yet-released S3 image proxy:

from starlette.responses import Response
from PIL import Image, ExifTags
import pyheif

for ORIENTATION_TAG in ExifTags.TAGS.keys():
    if ExifTags.TAGS[ORIENTATION_TAG] == "Orientation":
        break
    ...
    # Load it into Pillow
    if ext == "heic":
        heic = pyheif.read_heif(image_response.content)
        image = Image.frombytes(mode=heic.mode, size=heic.size, data=heic.data)
    else:
        image = Image.open(io.BytesIO(image_response.content))

    # Does EXIF tell us to rotate it?
    try:
        exif = dict(image._getexif().items())
        if exif[ORIENTATION_TAG] == 3:
            image = image.rotate(180, expand=True)
        elif exif[ORIENTATION_TAG] == 6:
            image = image.rotate(270, expand=True)
        elif exif[ORIENTATION_TAG] == 8:
            image = image.rotate(90, expand=True)
    except (AttributeError, KeyError, IndexError):
        pass

    # Resize based on ?w= and ?h=, if set
    width, height = image.size
    w = request.query_params.get("w")
    h = request.query_params.get("h")
    if w is not None or h is not None:
        if h is None:
            # Set h based on w
            w = int(w)
            h = int((float(height) / width) * w)
        elif w is None:
            h = int(h)
            # Set w based on h
            w = int((float(width) / height) * h)
        w = int(w)
        h = int(h)
        image.thumbnail((w, h))

    # ?bw= converts to black and white
    if request.query_params.get("bw"):
        image = image.convert("L")

    # ?q= sets the quality - defaults to 75
    quality = 75
    q = request.query_params.get("q")
    if q and q.isdigit() and 1 <= int(q) <= 100:
        quality = int(q)

    # Output as JPEG or PNG
    output_image = io.BytesIO()
    image_type = "JPEG"
    kwargs = {"quality": quality}
    if image.format == "PNG":
        image_type = "PNG"
        kwargs = {}

    image.save(output_image, image_type, **kwargs)
    return Response(
        output_image.getvalue(),
        media_type="image/jpeg",
        headers={"cache-control": "s-maxage={}, public".format(365 * 24 * 60 * 60)},
    )
simonw commented 4 years ago

datasette-media will be able to handle this once I implement https://github.com/simonw/datasette-media/issues/3

dmd commented 4 years ago

As that seems to be closed, can you give a hint on how to make this work?

simonw commented 4 years ago

Sure, I should absolutely document this!

simonw commented 4 years ago

I'll add a proper section to the README, but for the moment here's how I do this.

First, install datasette and the datasette-media plugin.

Create a metadata.yaml file with the following content:

plugins:
  datasette-media:
    photo:
      sql: |-
        select path as filepath, 200 as resize_height from apple_photos where uuid = :key
    photo-big:
      sql: |-
        select path as filepath, 1024 as resize_height from apple_photos where uuid = :key

Now run datasette -m metadata.yaml photos.db - thumbnails will be served at http://127.0.0.1:8001/-/media/photo/F4469918-13F3-43D8-9EC1-734C0E6B60AD and larger sizes of the image at http://127.0.0.1:8001/-/media/photo-big/A8B02C7D-365E-448B-9510-69F80C26304D

I also made myself two custom pages, one showing recent images and one showing random images.

To do this, install the datasette-template-sql plugin and then create a templates/pages directory and add these files:

recent-photos.html

<h1>Recent photos</h1>

<div>
{% for photo in sql("select * from apple_photos order by date desc limit 100") %}
    <img src="/-/media/photo/{{ photo['uuid'] }}">
{% endfor %}
</div>

random-photos.html

<h1>Random photos</h1>

<div>
{% for photo in sql("with foo as (select * from apple_photos order by date desc limit 5000) select * from foo order by random() limit 100") %}
    <img src="/-/media/photo/{{ photo['uuid'] }}">
{% endfor %}
</div>

Now run datasette -m metadata.yaml photos.db --template-dir=templates/

Visit http://127.0.0.1:8001/random-photos to see some random photos or http://127.0.0.1:8002/recent-photos for recent photos.

This is using this mechanism: https://datasette.readthedocs.io/en/stable/custom_templates.html#custom-pages

simonw commented 4 years ago

https://github.com/dogsheep/dogsheep-photos/blob/dc43fa8653cb9c7238a36f52239b91d1ec916d5c/README.md#serving-photos-locally-with-datasette-media

simonw commented 4 years ago

I'll add docs on using datasette-json-html too.

simonw commented 4 years ago

https://github.com/dogsheep/dogsheep-photos/blob/0.4.1/README.md#serving-photos-locally-with-datasette-media