Web avatars from any image
Drop a picture into Avatars/ and push. It comes back in Web/avatars/ at three sizes, 64, 256 and 512 pixels square, as WebP, ready for a website. The hello world of actions: one folder in, another out, one free tool (vips, LGPL) doing the work.
1. A project
mkdir my-site
ifile init my-site
ifile link my-site
2. The action: two files
Create these two files in my-site.
.interlinked/actions/avatars.yml
# Images dropped into Avatars/ come back in Web/avatars/ at three sizes, as WebP, for the web.
#
# runs-on: hosted runs it on a machine we start, which carries vips. Change it to maker to run it on the
# machine that pushed (once it is switched on for the project: `ifile runner start`, and vips installed
# there: https://www.libvips.org/install.html).
name: Web avatars
on:
push:
paths: ["Avatars/**/*.png", "Avatars/**/*.jpg", "Avatars/**/*.jpeg", "Avatars/**/*.webp"]
kinds: [added, changed]
runs-on: hosted
needs:
read: ["Avatars/**"]
write: ["Web/avatars/**"]
tools: [node, vips]
steps:
- name: Three sizes, WebP
run: node .interlinked/actions/avatars.mjs
each: changed
save:
message: "Web avatars for {count} new images ({sizes})"
.interlinked/actions/avatars.mjs
// One image in, three out (the step runs this once for each image that changed, its path in FILE).
//
// In: Avatars/<any folders>/<name>.png, .jpg, .jpeg or .webp.
// Out: Web/avatars/<the same folders>/<name>-64.webp, -256.webp and -512.webp: square, cropped to the middle,
// quality 82. vips does the work (vipsthumbnail: it decodes only what the size needs, so a large photo
// costs little). It writes `sizes=64, 256, 512` to IFILE_OUTPUT for the save's message.
import { execFileSync } from "node:child_process"
import { mkdirSync, writeFileSync, statSync } from "node:fs"
import path from "node:path"
const SIZES = [64, 256, 512]
const file = process.env.FILE
if (!file) { console.error("FILE is not set: this script is a step with `each: changed`"); process.exit(2) }
const rel = path.relative("Avatars", file)
if (rel.startsWith("..")) { console.log(`${file}: not under Avatars/, left alone`); process.exit(0) }
const dir = path.join("Web", "avatars", path.dirname(rel))
const stem = path.basename(rel, path.extname(rel))
mkdirSync(dir, { recursive: true })
const made = []
for (const size of SIZES) {
const out = path.join(dir, `${stem}-${size}.webp`)
execFileSync("vipsthumbnail", [file, "--size", `${size}x${size}`, "--smartcrop", "centre", "-o", `${path.resolve(out)}[Q=82,strip]`], { stdio: "inherit" })
made.push(`${out} ${(statSync(out).size / 1024).toFixed(1)} KB`)
}
console.log(`${file} -> ${made.join(", ")}`)
if (process.env.IFILE_OUTPUT) writeFileSync(process.env.IFILE_OUTPUT, `sizes=${SIZES.join(", ")}\n`)
3. Where it runs
runs-on: hosted runs it on a machine we start for the run, which carries vips, so no machine of yours has to be on. It costs Cloudflare's container rates plus 25%: a run like this one is a few seconds of a 2 CPU machine, under a cent. To run it on your own machine instead, install vips there, change the line to runs-on: maker and switch the machine on for the project with ifile runner start --in my-site.
4. An image
Save a .png, .jpg or .webp into my-site/Avatars/ (folders inside it are kept), then:
ifile save my-site -m "a new avatar"
ifile push my-site
5. What came back
ifile runs --in my-site
ifile sync my-site
After the sync, Web/avatars/ holds <name>-64.webp, <name>-256.webp and <name>-512.webp for every image, and the save's message says how many. ifile runs log 1 --in my-site prints what vips made, file by file.
Make it yours
- Sizes.
SIZESinavatars.mjs. - Not square. Drop
--smartcrop centreand give a width alone (--size 512) to keep each picture's shape. - A second format. Add an
-oline with.avifor.jpg; vips writes either.