A little over 6 months ago, a fellow photographer asked this seemingly simple question: “Do you have a portfolio? I mean, something other than an Instagram account”. Oh boy, did he even knew what he was getting me into with this question!
Link to section Why make a custom portfolio in the first place?
Most people would have replied either “Oh, I have an account on Flickr/500px/some other photo sharing platform” or “No, but now that you’re talking about it, I might create one using Squarespace/Wix/Wordpress with a ton of plugins”, which are all valid answers (well, except the Wordpress one: tons of plugins is never a good idea).
But you know us, developers: we are eager to build everything ourselves, so the obvious answer was “Not yet, but I will build one tailored to my exact needs”.
Link to section The inspiration
Before this conversation, I already had an itch to completely redo my personal website for something more streamlined (as I explained in A Clean Slate) and had a vision for a photo gallery that would look a lot like Google Photo’s handsome looking gallery.

At some point I even toyed with using its API to fetch and display pictures from Google Photos on the web, as it already has all my pictures (I might talk about this in a future post). I finally scrapped this idea as Google Photo’s API doesn’t fit my use-case: it’s really intended for Android OEMs to integrate Google Photo’s features into their galleries.
Link to section The architecture
Back to this conversation back in September: as we talk, I start thinking of a simple solution. I already knew I wanted the next iteration of my website to be static for obvious security, cost and performance reasons.
I ended using a static site generator for this exact website you’re browsing, but not for my gallery project, for the following reasons:
- Each time I would add an album, the site generator would generate a ton of new pages that I would have to upload.
- Browsing from a picture to the next would mean changing pages, which I did not want in order to maintain a pleasant experience for the user.
- I wanted to display various information (aperture, shutter speed, focal length) from the picture’s EXIF. It might have been cumbersome to do it using a static site generator.
My second option was then to develop a webapp: I could embed it in one of my site’s page and it would live its life happily, fetching the data it needs from… Oh right, how do I manage the data representation of my albums?
Since I wanted this gallery to be cost-efficient, there was no way I would build, host and maintain a back-end. Instead I chose to store the data as JSON files alongside the pictures, in AWS S3.
Link to section The development
With the basics figured out, I went on to develop my webapp. I picked Angular because of my familiarity with the framework and as a way to learn features I have not used yet.
Once the core feature set done (list the albums, the pictures within an album and display a single picture) the next feature to tackle was displaying a picture’s information.

Hopefully someone already tackled the problem and developed a JS library to extract EXIF data
from an <img>
element. The hardest thing was eventually getting it to work with Angular.
In a nutshell:
-
I had to pass the
ElementRef
of the<img>
from a component to another, and then callEXIF.getData()
with a reference to the component’s class.const self = this; EXIF.getData(img, function() { self.exifTags = EXIF.getAllTags(this); });
-
Because exif-js “caches” the parsed EXIF tags in the
ElementRef
’sexifTags
, when the<img>
’s source changed it would just read the cached value instead of parsing the EXIF from the new picture. To fix this I had to reset the content ofexifTags
.

And then there was the Google Photos look. It first seemed difficult after reading Google’s
article describing the design process behind their now iconic grid,
but after a few searches, I found a simple CSS and JS solution that gave
me the exact look I wanted. The final touch was adding the height
and width
attributes so that the thumbnails
have the correct ratio and size before loading on supported browsers.
Obviously, to make my life easier, I built a script to upload and create albums. I chose Python as it is really well suited for handling JSON and has libraries for interacting with AWS’s services and ImageMagick, the go-to cross-platform image conversion software.
Link to section Efficiency
As I stated earlier, I wanted this gallery to be fast and cost-efficient. In order to achieve this, I worked on caching and filesize.
So in order to make the app feel fast and to not have to fetch the JSON from S3 every time the user would navigate to the albums list or an album page, I used Dexie.js −an IndexedDB wrapper I already used on my previous project Comme Chez Soi− to store the content of the JSONs and then act as an offline cache.
I then set to reduce the size of the pictures and their thumbnails with ImageMagick, which is incredibly easy to do using Wand, a Python binding for ImageMagick.
While I’m talking of Wand, here is a little tip: always use Image.auto_orient()
so that ImageMagick will orient
the picture according to the data from the EXIF, as web browsers do not always to it properly.
Finally, I used the incredible versatility of CloudFront to speed up the delivery of the pictures using its CDN
feature, and I also added a Cache-Control
header using a very simple Lambda@Edge function
to tell the browser to keep the pictures in cache for 14 days.
Link to section What’s next?
Functionnaly, I’m pretty pleased with the result, but I think I can
still squeeze some megabytes off my monthly CloudFront bill by using responsive images
(srcset
) to serve smaller thumbnails to small screens and using the WebP
image format to serve pictures with even smaller file sizes.
These two will definitely be challenges as I will have to rewrite a good portion of the app to handle them!
Link to section April 28th’s update about the responsive images and WebP update
Well, handling responsive images and WebP was indeed a challenge and it was much more difficult than I originaly anticipated.
The first hurdle was generating WebP pictures: some builds of ImageMagick do not handle WebP conversion, which is the case of the version of ImageMagick in Ubuntu 18.04 LTS. To get arround this problem, I had to refactor the upload script to use Pillow instead of Wand, which was quite painful since Pillow’s API is quite different from Wand and has some dubious default behaviors:
-
By default, Pillow does not keep the EXIF when saving a picture! If you want to keep the EXIF, you have to do the following:
with Image.open(picture_path) as img: exif = img.info['exif'] # Process the picture img.save(save_path, format=file_format, exif=exif)
-
Unless you tell it otherwise, Pillow will save your JPEGs with agressive and very lossy compression by using a quality factor of 75 and chroma subsampling.
img.save(in_mem_file, format=file_format, subsampling=0, quality=100)
After modifying the picture processing, I then had to change my data model to handle different sizes and format, which for each picture looks like this:
{
"id": 1,
"thumbnail": {
// Default size, used when the browser does not handle <picture>
"default": {
"url": "https://www.zzz/album/thumbnail/picture.jpg",
"with": 200,
"height": 133
},
"sizes": {
// For each media type, an array with the different thumbnail sizes
"jpeg": [
{
"url": "https://www.zzz/album/thumbnail/picture-200.jpg",
"with": 200,
"height": 133
},
{
"url": "https://www.zzz/album/thumbnail/picture-400.jpg",
"with": 400,
"height": 267
},
// ...
],
"webp": [
{
"url": "https://www.zzz/album/thumbnail/picture-200.jpg",
"with": 200,
"height": 133
},
{
"url": "https://www.zzz/album/thumbnail/picture-400.jpg",
"with": 400,
"height": 267
},
// ...
],
}
},
// For fullsize pictures, same deal as thumbnails
"fullsize": {
// Default size, used when the browser does not handle <picture>
"default": {
"url": "https://www.zzz/album/picture.jpg",
"with": 6000,
"height": 4000
},
"sizes": {
// For each media type, an array with the different thumbnail sizes
"jpeg": [
{
"url": "https://www.zzz/album/thumbnail/picture-200.jpg",
"with": 200,
"height": 133
},
// ...
{
"url": "https://www.zzz/album/thumbnail/picture-1920.jpg",
"with": 1920,
"height": 1280
}
],
"webp": [
// ...
],
}
}
}
Changing the datamodel also meant migrating the existing data:
For the data initially stored in the front-end’s IndexedDB, I ended up ditching Dexie as the browser already caches
the JSON following the Cache-Control
header.
Using the browser cache is also more stable as some vendors are imposing constraints on IndexedDB: Apple for example
announced that Safari will clear local storage if the website has not been visited in the last 7 days.
I also had to migrate my exisiting albums’ JSONs, so I wrote a migration script that, given an album ID:
- downloads the album’s JSON,
- downloads each picture described in it, makes new thumbnails and intermediate full-sizes pictures and uploads them,
- updates the JSON with the new structure and uploads it
Finally, I had to let go of parsing EXIF on the front-end as neither exif-js nor the more recent ExifReader and exifr are currently able to parse EXIF from WebP pictures, forcing me to parse the EXIF in the upload script using Pillow. At least it allowed me to shave a few dozen kilobytes off the generated Javascript!
It was frustrating at times, especially having to rewrite major parts of the app twice, but in the end it was worth it as thumbnails now load a lot faster on limited bandwith!