Compare commits
3 Commits
2ceb0c147c
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6900101506 | ||
|
|
396eeb175e | ||
|
|
36d8e514d4 |
36
content/assets/code/01-actionscript-class.as
Normal file
36
content/assets/code/01-actionscript-class.as
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
package thebeer {
|
||||||
|
import utils.Bg;
|
||||||
|
|
||||||
|
import com.greensock.TweenMax;
|
||||||
|
|
||||||
|
import flash.display.MovieClip;
|
||||||
|
import flash.events.Event;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @author samrolfe
|
||||||
|
*/
|
||||||
|
public class Siren extends MovieClip {
|
||||||
|
private var targ : Bg;
|
||||||
|
private var delays : Number;
|
||||||
|
private var wide: Number;
|
||||||
|
private var high: Number;
|
||||||
|
|
||||||
|
public function Siren(col:Number,t:Bg,d:Number) {
|
||||||
|
delays=d;
|
||||||
|
targ=t;
|
||||||
|
x=targ.x;
|
||||||
|
y=targ.y;
|
||||||
|
wide=this.width;
|
||||||
|
high=this.high;
|
||||||
|
TweenMax.to(this,0,{autoAlpha:0,colorMatrixFilter:{colorize:col}});
|
||||||
|
}
|
||||||
|
|
||||||
|
private function setPos(e:Event) : void {
|
||||||
|
x=parent[targ].x;
|
||||||
|
y=parent[targ].y;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function flash() : void {
|
||||||
|
TweenMax.to(this,0,{autoAlpha:1,width:wide,height:high});
|
||||||
|
TweenMax.to(this,.1,{width:0,height:0,repeat:5,repeatDelay:delays,yoyo:true,onComplete:finish});
|
||||||
|
}
|
||||||
53
content/assets/code/02-js-easing-core.js
Normal file
53
content/assets/code/02-js-easing-core.js
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
// Excerpt from js/utils/text_effects.js (2,156 lines) in the WebsiteBuilder
|
||||||
|
// source. Two regions are reproduced verbatim: the timeline loop that derives
|
||||||
|
// each letter's delay from an easing function (lines 1111-1147), and the
|
||||||
|
// hand-written easing functions themselves (lines 1340-1349).
|
||||||
|
$(mySplitTextTarg).each(function(i){
|
||||||
|
|
||||||
|
tweenData.letter=$(this);
|
||||||
|
tweenData.text_static.letter=$(this);
|
||||||
|
tweenData.text_text_to.letter=$(this);
|
||||||
|
tweenData.text_text_from.letter=$(this);
|
||||||
|
|
||||||
|
if(text_effect_type_animation=="standard"){
|
||||||
|
if(tweenData.ignoreItems=="on" && Math.random()<tweenData.ignore_amount)return;
|
||||||
|
|
||||||
|
if(!keepHide(tweenData,i,$(this).text()))return;
|
||||||
|
|
||||||
|
var transforms=getTransforms(tweenData);
|
||||||
|
|
||||||
|
|
||||||
|
//currenttime,start,change,duration
|
||||||
|
if(tweenData.staggerTimeRnd=="random")stagger=min_max_random(0,tweenData.staggerTime);
|
||||||
|
// stagger=positionsCubic(tweenData.alternatecount, 0, tweenData.duration, tweenData.total)
|
||||||
|
var ease=tweenData.easeFunction;
|
||||||
|
// var ease="easeInCubic";
|
||||||
|
stime=tweenData.duration;
|
||||||
|
if(tweenData.direc=="in"){
|
||||||
|
stagger=jQuery.easing[ease](null,tweenData.alternatecount, 0, tweenData.duration, tweenData.total)+tweenData.startWait
|
||||||
|
transforms.delay=stagger;
|
||||||
|
TweenMax.from($(this), tweenData.duration, transforms);
|
||||||
|
tt=tweenData.duration+stagger;
|
||||||
|
}
|
||||||
|
if(tweenData.direc=="out"){
|
||||||
|
stagger=jQuery.easing[ease](null,tweenData.alternatecount, 0, tweenData.duration, tweenData.total)
|
||||||
|
transforms.delay=stagger;
|
||||||
|
transforms.onComplete=tweenRemove($(this),stagger+stime+tweenData.removeWait)
|
||||||
|
TweenMax.to($(this), tweenData.duration, transforms);
|
||||||
|
tt=tweenData.duration+stagger+tweenData.removeWait+1;
|
||||||
|
}
|
||||||
|
|
||||||
|
setCssTweens(stime,text_effect_type_animation,tweenData,tweenData.text_static,tweenData.text_text_to,tweenData.text_text_from,$(this),stagger);
|
||||||
|
|
||||||
|
|
||||||
|
// --- easing functions, lines 1340-1349 ---
|
||||||
|
//t,b,c,d
|
||||||
|
//currenttime,start,change,duration
|
||||||
|
//t and do can be time or frames
|
||||||
|
function positionsCubic(t, b, c, d){
|
||||||
|
t /= d;
|
||||||
|
return c*t*t*t + b;
|
||||||
|
}
|
||||||
|
function positionSine(t, b, c, d) {
|
||||||
|
return -c * Math.cos(t/d * (Math.PI/2)) + c + b;
|
||||||
|
}
|
||||||
54
content/assets/code/03-php-encode-op.php
Normal file
54
content/assets/code/03-php-encode-op.php
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
// Excerpt from php/setMediaEncoder.php (3,402 lines) in the WebsiteBuilder
|
||||||
|
// source: the create_gif encode operation, lines 614-665, reproduced verbatim.
|
||||||
|
if($output_options->encode_media=="create_gif") {
|
||||||
|
$output_gif= ".." . $settings->directory . $output_file .$output_options->format .".gif";
|
||||||
|
|
||||||
|
$docmime= checkFinfo($input_file);
|
||||||
|
$doctype=explode("/",$docmime)[0];
|
||||||
|
if ($doctype!="video"){
|
||||||
|
$output_options->error_message= "We only create gifs from a video - it must be the first video in the media list.";
|
||||||
|
$returnFiles = returnFiles ( $output_options, $output_file .$output_options->format . '.mp4', "video/mp4", '', null );
|
||||||
|
return $returnFiles;
|
||||||
|
}
|
||||||
|
|
||||||
|
$cmd = "ffmpeg -i ".$input_file." -y -vf fps=".$settings->settings->movie_frame_rate.",scale=".$settings->settings->video_width.":".$settings->settings->video_height.",palettegen palette.png 2>&1";
|
||||||
|
// $cmd = "ffmpeg -i ".$input_file." -y -vf fps=10,scale=".$settings->settings->video_width.":".$settings->settings->video_height.",palettegen palette.png 2>&1";
|
||||||
|
$result=shell_exec($cmd);
|
||||||
|
$first_media=json_decode($settings->add_media)[0];
|
||||||
|
|
||||||
|
// $cmd = "ffmpeg -ss ".$first_media->add_media_start." -t ".$first_media->add_media_duration." -i ".$input_file." -i palette.png -y -filter_complex \"fps=10[x];[x][1:v]paletteuse\" ".$output_gif." ";
|
||||||
|
$cmd = "ffmpeg -ss ".$first_media->add_media_start." -t ".$first_media->add_media_duration." -i ".$input_file." -i palette.png -y -filter_complex \"fps=".$settings->settings->movie_frame_rate.",scale=".$settings->settings->video_width.":".$settings->settings->video_height.":flags=lanczos[x];[x][1:v]paletteuse\" ".$output_gif." ";
|
||||||
|
ffmpegMsg("ffmpeg","Creating Gif ", 0,$cmd);
|
||||||
|
|
||||||
|
// $cmd_2='mogrify -delay '.$_POST['compress_gif_delay'].' -alpha remove '.$matte.' -depth '.$_POST['compress_gif_depth'].' '.$deconstruct.' '.$dither.' -colors '.$_POST['compress_gif_colors'].' -fuzz '.$_POST['compress_gif_fuzz'].'% '.$loop.' '.$optimize.' '.$path.' 2>&1';
|
||||||
|
|
||||||
|
$_POST['loop_gif']="true";
|
||||||
|
$_POST['compress_gif_matte']="false";
|
||||||
|
$_POST['compress_gif_deconstruct']="false";
|
||||||
|
$_POST['compress_gif_dither']="true";
|
||||||
|
$_POST['compress_gif_optimize']="true";
|
||||||
|
|
||||||
|
$loop=($_POST['loop_gif']=="false")?"-loop 1":"";
|
||||||
|
$matte=($_POST['compress_gif_matte']=="true")?"-matte":"";
|
||||||
|
$deconstruct=($_POST['compress_gif_deconstruct']=="true")?"-deconstruct":"";
|
||||||
|
$dither=($_POST['compress_gif_dither']=="true")?"-dither none ":"";
|
||||||
|
$optimize=($_POST['compress_gif_optimize']=="true")?"-layers optimize ":"";
|
||||||
|
// $optimize=($_POST['compress_gif_optimize']=="true")?" -coalesce -layers RemoveDups ":"";
|
||||||
|
|
||||||
|
$_POST['compress_gif_depth']=16;
|
||||||
|
$_POST['compress_gif_delay']=0;
|
||||||
|
$_POST['compress_gif_fuzz']="4";
|
||||||
|
$_POST['compress_gif_colors']="16";
|
||||||
|
|
||||||
|
$cmd_2='mogrify -delay '.$_POST['compress_gif_delay'].' -alpha remove '.$matte.' -depth '.$_POST['compress_gif_depth'].' '.$deconstruct.' '.$dither.' -colors '.$_POST['compress_gif_colors'].' -fuzz '.$_POST['compress_gif_fuzz'].'% '.$loop.' '.$optimize.' '.$output_gif.' 2>&1';
|
||||||
|
$result=shell_exec($cmd_2);
|
||||||
|
|
||||||
|
|
||||||
|
$output_options->edited_url= $output_options->options->directory.$output_file .$output_options->format . '.gif';
|
||||||
|
$output_options->edited_name= $output_file .$output_options->format . '.gif';
|
||||||
|
$output_options->edited_type = "image/gif";
|
||||||
|
$output_options->edited_filetype = explode ( "/", "image/gif");
|
||||||
|
insert_document($output_options->edited_url,"image/gif",$output_options->edited_name,"image","edited");
|
||||||
|
$returnFiles = returnFiles ( $output_options, $output_file .$output_options->format . '.mp4', "video/mp4", '', null );
|
||||||
|
return $returnFiles;
|
||||||
|
}
|
||||||
38
content/assets/code/04-typescript-service-layer.ts
Normal file
38
content/assets/code/04-typescript-service-layer.ts
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
// In file: src/server/api/routers/commandTemplate.ts
|
||||||
|
import { z } from "zod";
|
||||||
|
import { createTRPCRouter, publicProcedure } from "~/server/api/trpc";
|
||||||
|
|
||||||
|
export const commandTemplateRouter = createTRPCRouter({
|
||||||
|
// Procedure to get all templates
|
||||||
|
getAll: publicProcedure.query(({ ctx }) => {
|
||||||
|
return ctx.db.commandTemplate.findMany({
|
||||||
|
orderBy: { createdAt: "desc" },
|
||||||
|
});
|
||||||
|
}),
|
||||||
|
|
||||||
|
// Procedure to create a new template
|
||||||
|
create: publicProcedure
|
||||||
|
.input(z.object({
|
||||||
|
name: z.string().min(1),
|
||||||
|
type: z.string().min(1),
|
||||||
|
payload: z.record(z.unknown()), // Accepts any JSON object
|
||||||
|
}))
|
||||||
|
.mutation(async ({ ctx, input }) => {
|
||||||
|
return ctx.db.commandTemplate.create({
|
||||||
|
data: {
|
||||||
|
name: input.name,
|
||||||
|
type: input.type,
|
||||||
|
payload: input.payload,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}),
|
||||||
|
|
||||||
|
// Procedure to delete a template
|
||||||
|
delete: publicProcedure
|
||||||
|
.input(z.object({ id: z.number() }))
|
||||||
|
.mutation(async ({ ctx, input }) => {
|
||||||
|
return ctx.db.commandTemplate.delete({
|
||||||
|
where: { id: input.id },
|
||||||
|
});
|
||||||
|
}),
|
||||||
|
});
|
||||||
31
content/assets/code/05-embedded-cpp-interface.hpp
Normal file
31
content/assets/code/05-embedded-cpp-interface.hpp
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
// Two files from firmware/src/hw, reproduced verbatim: WiFiMgr.hpp (the
|
||||||
|
// interface) and WiFiMgr.cpp (the implementation). The boundary is marked.
|
||||||
|
// WiFiMgr.hpp — the interface the rest of the firmware depends on
|
||||||
|
// WiFiMgr.hpp
|
||||||
|
#pragma once
|
||||||
|
#include <Arduino.h>
|
||||||
|
|
||||||
|
namespace WiFiMgr {
|
||||||
|
bool connectHome(const char* ssid, const char* psk, uint32_t timeoutMs = 15000);
|
||||||
|
void disconnect();
|
||||||
|
}
|
||||||
|
// WiFiMgr.cpp — the only translation unit that includes <WiFi.h>
|
||||||
|
// WiFiMgr.cpp
|
||||||
|
#include "WiFiMgr.hpp"
|
||||||
|
#include <WiFi.h> // ESP32 WiFi
|
||||||
|
|
||||||
|
namespace WiFiMgr {
|
||||||
|
bool connectHome(const char* ssid, const char* psk, uint32_t timeoutMs) {
|
||||||
|
WiFi.mode(WIFI_STA);
|
||||||
|
WiFi.begin(ssid, psk);
|
||||||
|
uint32_t t0 = millis();
|
||||||
|
while (WiFi.status() != WL_CONNECTED && millis() - t0 < timeoutMs) {
|
||||||
|
delay(200);
|
||||||
|
}
|
||||||
|
return WiFi.status() == WL_CONNECTED;
|
||||||
|
}
|
||||||
|
void disconnect() {
|
||||||
|
WiFi.disconnect(true, true);
|
||||||
|
WiFi.mode(WIFI_OFF);
|
||||||
|
}
|
||||||
|
}
|
||||||
33
content/assets/code/06-nix-derivation.nix
Normal file
33
content/assets/code/06-nix-derivation.nix
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
{ lib, stdenv, fetchurl }:
|
||||||
|
|
||||||
|
# tuios — terminal window manager (https://github.com/Gaurav-Gosain/tuios)
|
||||||
|
# OVERRIDE: nixpkgs ships tuios 0.7.0; the multi-machine `hosts` UX needs 0.9+.
|
||||||
|
# Official pure-Go prebuilt release binary (no runtime deps, runs bare on NixOS).
|
||||||
|
# To return to nixpkgs packaging: use plain `tuios` in home.packages again and
|
||||||
|
# delete this file (do this once nixpkgs ships tuios >= 0.10).
|
||||||
|
stdenv.mkDerivation {
|
||||||
|
pname = "tuios";
|
||||||
|
version = "0.10.1";
|
||||||
|
|
||||||
|
src = fetchurl {
|
||||||
|
url = "https://github.com/Gaurav-Gosain/tuios/releases/download/v0.10.1/tuios_0.10.1_Linux_x86_64.tar.gz";
|
||||||
|
sha256 = "85a4bf339a9a6793e0b3d15abdccd9a96ca8d2ff76072007ec99be31f1a16a5f";
|
||||||
|
};
|
||||||
|
|
||||||
|
# Tarball has no wrapping directory (tuios / LICENSE / README.md at root)
|
||||||
|
sourceRoot = ".";
|
||||||
|
|
||||||
|
installPhase = ''
|
||||||
|
runHook preInstall
|
||||||
|
install -Dm755 tuios $out/bin/tuios
|
||||||
|
runHook postInstall
|
||||||
|
'';
|
||||||
|
|
||||||
|
meta = with lib; {
|
||||||
|
description = "Terminal window manager — tiling panes, sessions that survive restarts (multi-machine daemon)";
|
||||||
|
homepage = "https://github.com/Gaurav-Gosain/tuios";
|
||||||
|
license = licenses.mit;
|
||||||
|
mainProgram = "tuios";
|
||||||
|
platforms = [ "x86_64-linux" ];
|
||||||
|
};
|
||||||
|
}
|
||||||
BIN
content/assets/images/sim7080g-module.jpg
Normal file
BIN
content/assets/images/sim7080g-module.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 29 KiB |
@@ -8,9 +8,9 @@ confidence: running
|
|||||||
period: "2020–"
|
period: "2020–"
|
||||||
tech: [ESP32, LoRa, Zigbee, MQTT, Home Assistant, Snapcast, Pi, PIR, mmWave]
|
tech: [ESP32, LoRa, Zigbee, MQTT, Home Assistant, Snapcast, Pi, PIR, mmWave]
|
||||||
metrics: [{label: "edge nodes", value: 20}, {label: "lighting zones", value: 9}, {label: "automations", value: 20}, {label: "hand-built Zigbee routers", value: 2}]
|
metrics: [{label: "edge nodes", value: 20}, {label: "lighting zones", value: 9}, {label: "automations", value: 20}, {label: "hand-built Zigbee routers", value: 2}]
|
||||||
artefacts: [{type: doc, label: "Edge architecture — power · radio · deep sleep", url: "content/edge/l3/edge-architecture.md"}, {type: doc, label: "LoRa irrigation under solar power", url: "content/edge/l3/edge-lora-sprinkler.md"}, {type: doc, label: "Hand-built Zigbee routers and lighting", url: "content/edge/l3/edge-zigbee.md"}, {type: doc, label: "Distributed audio — eight endpoints with ducking", url: "content/edge/l3/edge-snapcast.md"}, {type: doc, label: "Front door — vision · voice · and a rebuild", url: "content/edge/l3/edge-front-door.md"}, {type: doc, label: "Sensors and actuation", url: "content/edge/l3/edge-sensors.md"}, {type: doc, label: "The failures, kept in the record", url: "content/edge/l3/edge-failures.md"}]
|
artefacts: [{type: doc, label: "Edge architecture — power · radio · deep sleep", url: "content/edge/l3/edge-architecture.md"}, {type: doc, label: "LoRa irrigation under solar power", url: "content/edge/l3/edge-lora-sprinkler.md"}, {type: doc, label: "Hand-built Zigbee routers and lighting", url: "content/edge/l3/edge-zigbee.md"}, {type: doc, label: "Distributed audio — eight endpoints with ducking", url: "content/edge/l3/edge-snapcast.md"}, {type: doc, label: "Front door — vision · voice · and a rebuild", url: "content/edge/l3/edge-front-door.md"}, {type: doc, label: "Sensors and actuation", url: "content/edge/l3/edge-sensors.md"}, {type: doc, label: "The failures, kept in the record", url: "content/edge/l3/edge-failures.md"}, {type: repo, label: "Embedded C++ — the Wi-Fi manager interface and implementation", url: "content/assets/code/05-embedded-cpp-interface.hpp"}]
|
||||||
public: true
|
public: true
|
||||||
last_verified: 2026-10-26
|
last_verified: 2026-10-28
|
||||||
tags: [edge, iot, esp32, lora, zigbee, audio]
|
tags: [edge, iot, esp32, lora, zigbee, audio]
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -30,6 +30,10 @@ tags: [edge, iot, esp32, lora, zigbee, audio]
|
|||||||
|
|
||||||
**Cameras and vision.** The front door takes an image, logs it, has the image described, and speaks the result into the house before deciding what to do next. RFID tags on boxes and on trees resolve to records — inventory with prices for the boxes, care notes for the trees — written by an automation rather than typed by hand.
|
**Cameras and vision.** The front door takes an image, logs it, has the image described, and speaks the result into the house before deciding what to do next. RFID tags on boxes and on trees resolve to records — inventory with prices for the boxes, care notes for the trees — written by an automation rather than typed by hand.
|
||||||
|
|
||||||
|
## Why this exhibit is good code
|
||||||
|
|
||||||
|
The header declares the interface and includes only `Arduino.h`, so a caller compiles without pulling the ESP32 Wi-Fi stack into its translation unit. Only the implementation file knows about station mode, polling and millisecond timeouts, and it returns a boolean so the caller decides what a failed join means. The two files are shown together because the point is the split, not either file alone.
|
||||||
|
|
||||||
## Evidence
|
## Evidence
|
||||||
|
|
||||||
| Claim | Evidence | Artefact |
|
| Claim | Evidence | Artefact |
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ confidence: running
|
|||||||
period: "2020–"
|
period: "2020–"
|
||||||
tech: [ESP32, LoRa, Zigbee, MQTT, Home Assistant, Snapcast, Raspberry Pi]
|
tech: [ESP32, LoRa, Zigbee, MQTT, Home Assistant, Snapcast, Raspberry Pi]
|
||||||
metrics: [{label: "edge nodes", value: 20}, {label: "lighting zones", value: 9}, {label: "automations", value: 20}]
|
metrics: [{label: "edge nodes", value: 20}, {label: "lighting zones", value: 9}, {label: "automations", value: 20}]
|
||||||
artefacts: [{type: doc, label: "Edge architecture — power · radio · deep sleep", url: "content/edge/l3/edge-architecture.md"}, {type: doc, label: "The failures, kept in the record", url: "content/edge/l3/edge-failures.md"}, {type: doc, label: "Distributed audio — eight endpoints with ducking", url: "content/edge/l3/edge-snapcast.md"}]
|
artefacts: [{type: doc, label: "Edge architecture — power · radio · deep sleep", url: "content/edge/l3/edge-architecture.md"}, {type: doc, label: "The failures, kept in the record", url: "content/edge/l3/edge-failures.md"}, {type: doc, label: "Distributed audio — eight endpoints with ducking", url: "content/edge/l3/edge-snapcast.md"}, {type: doc, label: "Vehicle tracker on LTE Cat-M", url: "content/edge/l3/edge-car-tracker.md"}]
|
||||||
public: true
|
public: true
|
||||||
last_verified: 2026-10-26
|
last_verified: 2026-10-26
|
||||||
tags: [edge, iot, esp32, lora, zigbee]
|
tags: [edge, iot, esp32, lora, zigbee]
|
||||||
|
|||||||
51
content/edge/l3/edge-car-tracker.md
Normal file
51
content/edge/l3/edge-car-tracker.md
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
---
|
||||||
|
id: edge-car-tracker
|
||||||
|
area: edge
|
||||||
|
level: 3
|
||||||
|
title: "Artefact — Vehicle tracker on LTE Cat-M"
|
||||||
|
claim: "A tracker that sleeps between reports · wakes on a schedule or when the car moves · and reports when it leaves a boundary set from the web interface."
|
||||||
|
confidence: in-progress
|
||||||
|
period: "2024–"
|
||||||
|
tech: [ESP32, LTE Cat-M, NB-IoT, SIM7080G, GPS, LiFePO4]
|
||||||
|
metrics: [{label: "battery packs", value: 2}]
|
||||||
|
artefacts: []
|
||||||
|
public: true
|
||||||
|
last_verified: 2026-10-28
|
||||||
|
tags: [edge, tracker, lte, low-power, artefact]
|
||||||
|
---
|
||||||
|
|
||||||
|
The build is on the bench: two LiFePO batteries, a buck converter, an ESP32 with GPS, and a
|
||||||
|
cellular module for the uplink. The car keeps the packs charged while it is running.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
## What it does
|
||||||
|
|
||||||
|
Two behaviours, chosen by the settings rather than hard-coded. When the car is parked the
|
||||||
|
ESP32 sits in deep sleep, wakes on a timer, checks its own configuration, and reports only
|
||||||
|
what the settings ask for. When the car is moving it stays awake and reports on a shorter
|
||||||
|
interval. Either way the interval and the boundary can be changed from the web interface,
|
||||||
|
and a change can force an immediate update rather than waiting for the next wake.
|
||||||
|
|
||||||
|
The geofence is the part that matters. A boundary is drawn from the web interface, and the
|
||||||
|
device is responsible for deciding whether it has left it — which is why the settings have
|
||||||
|
to travel to the device rather than live only in the server.
|
||||||
|
|
||||||
|
## Why LTE Cat-M rather than WiFi
|
||||||
|
|
||||||
|
The tracker lives in a car. WiFi coverage is a driveway, not a road. Cat-M and NB-IoT were
|
||||||
|
chosen because they are designed for exactly this shape of traffic — small payloads, long
|
||||||
|
sleeps, coverage beyond what 4G handsets need — and because the modules are cheap enough to
|
||||||
|
leave installed.
|
||||||
|
|
||||||
|
## What it proves
|
||||||
|
|
||||||
|
A complete path rather than a demo: constrained hardware, a power budget that dictates the
|
||||||
|
schedule, a device that holds enough state to act on its own, and a web application that
|
||||||
|
configures it and shows where it has been.
|
||||||
|
|
||||||
|
## Still open
|
||||||
|
|
||||||
|
Field testing across the full battery cycle. The sleep interval that is right for a parked
|
||||||
|
car and the interval that is right for a moving one are guesses until the discharge curve
|
||||||
|
has been measured over a week rather than a day.
|
||||||
@@ -8,26 +8,34 @@ confidence: historic
|
|||||||
period: "2006–2026"
|
period: "2006–2026"
|
||||||
tech: [ActionScript, JavaScript, PHP, TypeScript, C++, Nix]
|
tech: [ActionScript, JavaScript, PHP, TypeScript, C++, Nix]
|
||||||
metrics: [{label: "code exhibits", value: 6}, {label: "technology eras spanned", value: 5}]
|
metrics: [{label: "code exhibits", value: 6}, {label: "technology eras spanned", value: 5}]
|
||||||
artefacts: []
|
artefacts: [{type: repo, label: "ActionScript 3 — the Siren class", url: "content/assets/code/01-actionscript-class.as"}]
|
||||||
public: true
|
public: true
|
||||||
last_verified: 2026-10-26
|
last_verified: 2026-10-28
|
||||||
tags: [history, code, exhibits, artefact]
|
tags: [history, code, exhibits, artefact]
|
||||||
---
|
---
|
||||||
|
|
||||||
The excerpts are selected from source that exists on disk; each one lands in Phase 7 with two sentences on why it is good code.
|
**Status: published.** This page shows the ActionScript class and indexes the other five, each of which is exhibited on the page where it belongs.
|
||||||
|
|
||||||
## The six
|
## The six
|
||||||
|
|
||||||
| # | Era | Source | What the excerpt shows |
|
| # | Era | Exhibit | Where it is shown |
|
||||||
|---|---|---|---|
|
|---|---|---|---|
|
||||||
| 1 | Object-oriented ActionScript | the brief-case builder and the national beer campaign | class design, imports, encapsulation |
|
| 1 | Object-oriented ActionScript | `01-actionscript-class.as` — class, imports, encapsulation | this page |
|
||||||
| 2 | Hand-written JavaScript engine | the text-effects engine from the site builder (about 2 200 lines) | easing and a timeline core |
|
| 2 | Hand-written JavaScript engine | `02-js-easing-core.js` — easing functions and the timeline loop | [the site builder](../../products/l3/products-websitebuilder.md) |
|
||||||
| 3 | PHP media pipeline | the media encoder from the site builder (about 3 400 lines) | one encode operation and the FFmpeg piping around it |
|
| 3 | PHP media pipeline | `03-php-encode-op.php` — one GIF encode operation and its FFmpeg commands | [the site builder](../../products/l3/products-websitebuilder.md) |
|
||||||
| 4 | TypeScript | the vehicle-tracking application's schema and typed service layer | types, interfaces, a router |
|
| 4 | TypeScript | `04-typescript-service-layer.ts` — a typed tRPC boundary | [the estate](../../platform/platform-estate.md) |
|
||||||
| 5 | Embedded C++ | the Wi-Fi manager interface and implementation | interface versus implementation in firmware |
|
| 5 | Embedded C++ | `05-embedded-cpp-interface.hpp` — interface split from implementation | [the edge fleet](../../edge/edge-fleet.md) |
|
||||||
| 6 | Declarative infrastructure | the 25-line Nix package definition | pinned version, verified hash, written exit condition |
|
| 6 | Declarative infrastructure | `06-nix-derivation.nix` — pinned version, verified hash, exit condition | [the Nix package](../../platform/l3/platform-tuios-package.md) |
|
||||||
|
|
||||||
Total is roughly 225 lines across twenty years, which is the point: each excerpt is chosen to show judgement rather than volume.
|
Total is 243 lines across twenty years, which is the point: each excerpt is chosen to show judgement rather than volume.
|
||||||
|
|
||||||
|
## Why this exhibit is good code
|
||||||
|
|
||||||
|
Every field is private and explicitly typed, and the public surface is two methods, so a caller cannot push the object into a state it does not understand. The constructor takes what it needs as parameters instead of reaching for it, which is why the same class can drive every siren on the screen without knowing where any of them are.
|
||||||
|
|
||||||
|
## Limits stated on the page
|
||||||
|
|
||||||
|
The excerpt is verbatim, including a latent typo (`this.high` rather than `this.height`). It is left as found: the excerpt is evidence, not a portfolio piece.
|
||||||
|
|
||||||
## What it proves
|
## What it proves
|
||||||
|
|
||||||
@@ -35,4 +43,4 @@ That the stack changed six times and the discipline did not. Exhibit 6 is the le
|
|||||||
|
|
||||||
## When it is delivered
|
## When it is delivered
|
||||||
|
|
||||||
Phase 7. Source paths are recorded in the planning notes; nothing is published from a path that has not been read at capture time.
|
Published. The six files are in this repository under `content/assets/code/`; the archive path is withheld. The other five are linked above from the pages that carry their claims.
|
||||||
|
|||||||
@@ -8,13 +8,13 @@ confidence: running
|
|||||||
period: "2026–"
|
period: "2026–"
|
||||||
tech: [Nix, NixOS]
|
tech: [Nix, NixOS]
|
||||||
metrics: [{label: "pinned version", value: "0.10.1"}, {label: "nixpkgs version it overrides", value: "0.7.0"}]
|
metrics: [{label: "pinned version", value: "0.10.1"}, {label: "nixpkgs version it overrides", value: "0.7.0"}]
|
||||||
artefacts: []
|
artefacts: [{type: repo, label: "Nix derivation — tuios 0.10.1 override with a removal condition", url: "content/assets/code/06-nix-derivation.nix"}]
|
||||||
public: true
|
public: true
|
||||||
last_verified: 2026-10-26
|
last_verified: 2026-10-28
|
||||||
tags: [platform, nix, packaging, artefact]
|
tags: [platform, nix, packaging, artefact]
|
||||||
---
|
---
|
||||||
|
|
||||||
The whole file is short enough to publish verbatim; it lands in Phase 7.
|
**Status: published.** The whole file is shown verbatim.
|
||||||
|
|
||||||
## What the artefact is
|
## What the artefact is
|
||||||
|
|
||||||
@@ -24,6 +24,10 @@ A package definition for the agent operations console: `fetchurl` against a rele
|
|||||||
|
|
||||||
More than knowing Nix exists. The signals are that someone noticed a version gap, fixed it locally instead of waiting, pinned it so the fleet is reproducible, and wrote down when to remove their own patch. Knowing when to delete your own override is the senior part.
|
More than knowing Nix exists. The signals are that someone noticed a version gap, fixed it locally instead of waiting, pinned it so the fleet is reproducible, and wrote down when to remove their own patch. Knowing when to delete your own override is the senior part.
|
||||||
|
|
||||||
|
## Why this exhibit is good code
|
||||||
|
|
||||||
|
The override records why it exists and the exact condition under which it should be deleted, which is the part of a local patch that usually goes missing. Pinning a released binary by `fetchurl` with a verified hash keeps the result reproducible without waiting for nixpkgs.
|
||||||
|
|
||||||
## When it is delivered
|
## When it is delivered
|
||||||
|
|
||||||
Phase 7. The file is in the infrastructure repository; source fact: content spine section 4.2.
|
Published. The file is in this repository at `content/assets/code/06-nix-derivation.nix`; the infrastructure repository remains the source. Source fact: content spine section 4.2.
|
||||||
|
|||||||
@@ -8,9 +8,9 @@ confidence: running
|
|||||||
period: "2020–"
|
period: "2020–"
|
||||||
tech: [NixOS, Proxmox, Docker, Caddy, Tailscale, Borg, Kopia, AWS S3, HDMI-CEC]
|
tech: [NixOS, Proxmox, Docker, Caddy, Tailscale, Borg, Kopia, AWS S3, HDMI-CEC]
|
||||||
metrics: [{label: "containers in the estate", value: 50}, {label: "hosts", value: 4}, {label: "backup tiers", value: "Borg · Kopia · AWS"}, {label: "public listeners", value: 1}]
|
metrics: [{label: "containers in the estate", value: 50}, {label: "hosts", value: 4}, {label: "backup tiers", value: "Borg · Kopia · AWS"}, {label: "public listeners", value: 1}]
|
||||||
artefacts: [{type: doc, label: "Declarative multi-host configuration", url: "content/platform/l3/platform-nixos.md"}, {type: doc, label: "A hand-written Nix package with an exit plan", url: "content/platform/l3/platform-tuios-package.md"}, {type: doc, label: "Container inventory", url: "content/platform/l3/platform-containers.md"}, {type: doc, label: "Backups and restore runbook", url: "content/platform/l3/platform-backups.md"}, {type: doc, label: "Network layout and remote access", url: "content/platform/l3/platform-network.md"}, {type: doc, label: "Signage infrastructure — screens as a managed fleet", url: "content/platform/l3/platform-signage.md"}]
|
artefacts: [{type: doc, label: "Declarative multi-host configuration", url: "content/platform/l3/platform-nixos.md"}, {type: doc, label: "A hand-written Nix package with an exit plan", url: "content/platform/l3/platform-tuios-package.md"}, {type: doc, label: "Container inventory", url: "content/platform/l3/platform-containers.md"}, {type: doc, label: "Backups and restore runbook", url: "content/platform/l3/platform-backups.md"}, {type: doc, label: "Network layout and remote access", url: "content/platform/l3/platform-network.md"}, {type: doc, label: "Signage infrastructure — screens as a managed fleet", url: "content/platform/l3/platform-signage.md"}, {type: repo, label: "TypeScript — a typed tRPC service boundary", url: "content/assets/code/04-typescript-service-layer.ts"}]
|
||||||
public: true
|
public: true
|
||||||
last_verified: 2026-10-26
|
last_verified: 2026-10-28
|
||||||
tags: [platform, devops, nixos, docker, backups, networking]
|
tags: [platform, devops, nixos, docker, backups, networking]
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -32,6 +32,10 @@ tags: [platform, devops, nixos, docker, backups, networking]
|
|||||||
|
|
||||||
**Signage infrastructure.** The display work is treated as a fleet to be managed: HDMI-CEC control for power, rotation, mirroring and multi-screen layout, so screens are driven by the platform rather than by a person with a remote control.
|
**Signage infrastructure.** The display work is treated as a fleet to be managed: HDMI-CEC control for power, rotation, mirroring and multi-screen layout, so screens are driven by the platform rather than by a person with a remote control.
|
||||||
|
|
||||||
|
## Why this exhibit is good code
|
||||||
|
|
||||||
|
The Prisma schema defines the stored types, and the tRPC router validates input with Zod at the boundary, so the data layer receives a shape it can trust rather than one it hopes for. Each procedure is a thin call on the database context, which keeps the router a description of the API rather than a second copy of the queries.
|
||||||
|
|
||||||
## Evidence
|
## Evidence
|
||||||
|
|
||||||
| Claim | Evidence | Artefact |
|
| Claim | Evidence | Artefact |
|
||||||
|
|||||||
@@ -8,13 +8,13 @@ confidence: delivered
|
|||||||
period: "2014–"
|
period: "2014–"
|
||||||
tech: [PHP, JavaScript, three.js, FFmpeg, ImageMagick, MySQL]
|
tech: [PHP, JavaScript, three.js, FFmpeg, ImageMagick, MySQL]
|
||||||
metrics: [{label: "media editors in the product", value: "drawing · image · video · text animation · transitions · 3D import"}]
|
metrics: [{label: "media editors in the product", value: "drawing · image · video · text animation · transitions · 3D import"}]
|
||||||
artefacts: []
|
artefacts: [{type: repo, label: "Text-effects engine — easing functions and the timeline loop", url: "content/assets/code/02-js-easing-core.js"}, {type: repo, label: "Media encoder — one GIF encode operation and its FFmpeg commands", url: "content/assets/code/03-php-encode-op.php"}]
|
||||||
public: true
|
public: true
|
||||||
last_verified: 2026-10-26
|
last_verified: 2026-10-28
|
||||||
tags: [products, cms, media, artefact]
|
tags: [products, cms, media, artefact]
|
||||||
---
|
---
|
||||||
|
|
||||||
Retired as a general-purpose product; kept as the content engine for the signage.
|
**Status: delivered, retained.** Retired as a general-purpose product; kept as the content engine for the signage.
|
||||||
|
|
||||||
## What the artefact is
|
## What the artefact is
|
||||||
|
|
||||||
@@ -24,10 +24,16 @@ The templated builder: pick a template, build the site, publish it — with edit
|
|||||||
|
|
||||||
Two things at once — depth in media pipelines (FFmpeg, ImageMagick, PDF and office formats, three.js) and the discipline of shipping a general product that strangers could operate without help.
|
Two things at once — depth in media pipelines (FFmpeg, ImageMagick, PDF and office formats, three.js) and the discipline of shipping a general product that strangers could operate without help.
|
||||||
|
|
||||||
|
## Why these exhibits are good code
|
||||||
|
|
||||||
|
The animation engine evaluates a named easing function to give each letter its delay, so the spacing of an animation follows the same curve as its motion instead of a guessed linear stagger. Both directions build from one transform object, which is what stops the two halves of the same effect drifting apart over years of edits.
|
||||||
|
|
||||||
|
The media pipeline checks the real MIME type before it builds a command, so a wrong input returns a message instead of a half-written file. The GIF encode takes two passes — a palette from the clip, then frames mapped onto it — which is the difference between a GIF that holds its colour and one that does not.
|
||||||
|
|
||||||
## Limits stated on the page
|
## Limits stated on the page
|
||||||
|
|
||||||
The surface was too large for one person to support indefinitely. The plan recorded here is to strip it back to signage plus the content engine and stop selling the rest, which is a decision about focus rather than a claim of failure.
|
The PHP is 2014-era procedural code: global state, interpolated shell commands, and long functions. The exhibit is here for the operational judgement, not as a model of structure. The surface was too large for one person to support indefinitely; the plan recorded here is to strip it back to signage plus the content engine and stop selling the rest, which is a decision about focus rather than a claim of failure.
|
||||||
|
|
||||||
## When it is delivered
|
## When it is delivered
|
||||||
|
|
||||||
Phase 7 for a screen recording of the editors. Source: résumé (2014–2020 freelance) and evidence notes section 3.
|
Phase 7. Both code exhibits are published at `content/assets/code/`; a screen recording of the editors is still to come. Source: résumé (2014–2020 freelance) and evidence notes section 3.
|
||||||
|
|||||||
80
docs/image-brief.md
Normal file
80
docs/image-brief.md
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
# Image brief
|
||||||
|
|
||||||
|
Every image on this site gets the same treatment. Consistency is what makes a set look
|
||||||
|
designed; six images treated six ways look worse than six treated identically.
|
||||||
|
|
||||||
|
The palette is fixed by `internal/web/static/site.css`:
|
||||||
|
|
||||||
|
| Token | Value | Use |
|
||||||
|
|---|---|---|
|
||||||
|
| `--bg` | `#0b0d10` | canvas and fade colour. Never pure black |
|
||||||
|
| `--surface` | `#12161b` | screenshot backgrounds |
|
||||||
|
| `--line` | `#242c35` | screenshot borders |
|
||||||
|
| `--line-strong` | `#33404d` | diagram strokes |
|
||||||
|
| `--text-dim` | `#b3bcc8` | diagram labels |
|
||||||
|
| `--link` | `#8ecbff` | rim glow |
|
||||||
|
| `--accent` | `#6ee7c6` | sparing emphasis only |
|
||||||
|
|
||||||
|
## Why white-background product shots fail here
|
||||||
|
|
||||||
|
`#FFFFFF` on a `#0b0d10` page is roughly twenty times the brightness of everything around
|
||||||
|
it. The brightest thing in the frame becomes the backdrop rather than the object, and the
|
||||||
|
eye lands on the backdrop first. One reads as an accident; six read as a scrapbook.
|
||||||
|
|
||||||
|
## Photographs
|
||||||
|
|
||||||
|
- **Canvas** 1600×1000 for hero images, 1200×1200 for detail. Landscape unless the object demands otherwise.
|
||||||
|
- **Background** `#0b0d10`, the page colour. Not black — pure black always looks like a void.
|
||||||
|
- **Subject** cut out, placed right of centre, occupying 55–70% of the canvas height. Off-centre because the fade comes from the left.
|
||||||
|
- **Fade** a `#0b0d10` gradient, 100% opacity at the left edge to 0% at about 55% across. The object emerges from the page and dissolves to the right. This is the move that stops an image looking pasted on.
|
||||||
|
- **Rim glow** `#8ecbff` at 25–40% opacity, 8–14px blur, derived from the cut-out's own alpha channel so it follows real edges. Enough to lift the object off the background. Not enough to look like a gaming mouse.
|
||||||
|
- **Shadow lift** 5–8% on the subject itself. Cut-outs on dark backgrounds lose their dark detail and read as flat stickers otherwise.
|
||||||
|
- **Format** WebP, quality 82, plus a JPEG fallback. Target under 120 KB for a hero.
|
||||||
|
|
||||||
|
## Screenshots
|
||||||
|
|
||||||
|
Never cut out. Frame them like the site's own panels:
|
||||||
|
|
||||||
|
- corner radius `10px` (`--radius`)
|
||||||
|
- 1px border in `--line`
|
||||||
|
- edges darkened 8%
|
||||||
|
- soft drop shadow
|
||||||
|
- crop out browser chrome where possible
|
||||||
|
|
||||||
|
## Diagrams
|
||||||
|
|
||||||
|
Stay SVG and stay in the palette: `--line-strong` strokes, `--text-dim` labels, `--accent`
|
||||||
|
and `--link` for emphasis. No white fills, no default Mermaid blues.
|
||||||
|
|
||||||
|
## Logos
|
||||||
|
|
||||||
|
One colour, inheriting `currentColor` from the surrounding text. Forty-six client logos in
|
||||||
|
full colour would shriek. If one deserves emphasis, give it `--accent`; the rest stay muted.
|
||||||
|
|
||||||
|
## Prohibited
|
||||||
|
|
||||||
|
White boxes · stock photography · lens flares · 3D renders of objects not owned ·
|
||||||
|
glow strong enough to be the point · any image containing a family member, an address,
|
||||||
|
a credential, or a screen showing personal data.
|
||||||
|
|
||||||
|
## Capture guidance
|
||||||
|
|
||||||
|
Treatment rescues a product shot; it cannot rescue a photograph of a white wall in daylight.
|
||||||
|
|
||||||
|
- dark surface, one light source from one side
|
||||||
|
- shoot slightly above the object
|
||||||
|
- keep the object off-centre in frame
|
||||||
|
- no overhead lighting, no visible background clutter
|
||||||
|
- a phone is fine
|
||||||
|
|
||||||
|
Rack and cabinet shots are the exception: keep them whole and wide, framed like
|
||||||
|
screenshots rather than cut out.
|
||||||
|
|
||||||
|
## Output contract
|
||||||
|
|
||||||
|
Every image lands in `content/assets/images/`, named lowercase and hyphenated with no
|
||||||
|
spaces or dates. Every image used on a page carries a caption naming the object; a product
|
||||||
|
photograph additionally states that it is a product image and where it came from. The
|
||||||
|
original, if it is Sam's own photograph or a sourced product image, is also filed in the
|
||||||
|
repository of the project it belongs to, under `docs/images/`, with provenance recorded in
|
||||||
|
that folder's `README.md`.
|
||||||
35
tools/images/README.md
Normal file
35
tools/images/README.md
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
# Image treatment
|
||||||
|
|
||||||
|
Implements `docs/image-brief.md`. Two steps, in this order.
|
||||||
|
|
||||||
|
## 1. Cut the subject out
|
||||||
|
|
||||||
|
Needs `rembg`. It is not installed on the host, and installing Python packages on NixOS
|
||||||
|
into the system interpreter is a bad idea. Use a container:
|
||||||
|
|
||||||
|
```
|
||||||
|
docker run --rm -v "$PWD/raw:/in" -v "$PWD/build/cut:/out" \
|
||||||
|
-v "$PWD/tools/images/treat.py:/treat.py" \
|
||||||
|
python:3.12-slim sh -c "pip install -q rembg[cpu] pillow onnxruntime && \
|
||||||
|
python /treat.py cutout /in/*.jpg --out /out"
|
||||||
|
```
|
||||||
|
|
||||||
|
Or cut out by hand in GIMP and skip to step 2. For fewer than ten images, hand-cutting is
|
||||||
|
usually better — automatic segmentation struggles with reflective or thin edges.
|
||||||
|
|
||||||
|
## 2. Apply the treatment
|
||||||
|
|
||||||
|
```
|
||||||
|
pip install pillow # or the same container, without rembg
|
||||||
|
python3 tools/images/treat.py treat build/cut/*.png --size hero
|
||||||
|
```
|
||||||
|
|
||||||
|
Output lands in `content/assets/images/` as WebP at quality 82, canvas `#0b0d10`, subject
|
||||||
|
right of centre, left-to-right fade, rim glow from the alpha channel.
|
||||||
|
|
||||||
|
## Then
|
||||||
|
|
||||||
|
1. Add the page reference: ``
|
||||||
|
2. File the original in the project's own repo under `docs/images/`, with a note in that
|
||||||
|
folder's `README.md` saying where it came from.
|
||||||
|
3. Deploy and check it on the live site at 1440×900 and on a phone.
|
||||||
97
tools/images/treat.py
Executable file
97
tools/images/treat.py
Executable file
@@ -0,0 +1,97 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Apply the site image treatment described in docs/image-brief.md.
|
||||||
|
|
||||||
|
Two jobs, both idempotent and re-runnable:
|
||||||
|
|
||||||
|
cutout remove the background from a product photograph (needs rembg)
|
||||||
|
treat compose onto the dark canvas with the fade and glow (needs Pillow)
|
||||||
|
|
||||||
|
Typical use:
|
||||||
|
|
||||||
|
python3 tools/images/treat.py cutout raw/*.jpg -> build/cut/*.png
|
||||||
|
python3 tools/images/treat.py treat build/cut/*.png -> content/assets/images/*.webp
|
||||||
|
|
||||||
|
If rembg is not available, cut the subject out by hand in GIMP, save as PNG with
|
||||||
|
transparency, and run only the `treat` step.
|
||||||
|
"""
|
||||||
|
import argparse, pathlib, sys
|
||||||
|
|
||||||
|
BG = (0x0b, 0x0d, 0x10) # --bg
|
||||||
|
GLOW = (0x8e, 0xcb, 0xff) # --link
|
||||||
|
SIZES = {"hero": (1600, 1000), "detail": (1200, 1200)}
|
||||||
|
FADE_END = 0.55 # gradient reaches 0 opacity here
|
||||||
|
GLOW_A = 0.32
|
||||||
|
GLOW_BLUR = 11
|
||||||
|
LIFT = 1.06 # shadow lift on the subject
|
||||||
|
|
||||||
|
|
||||||
|
def cutout(paths, outdir):
|
||||||
|
try:
|
||||||
|
from rembg import remove
|
||||||
|
except ImportError:
|
||||||
|
sys.exit("rembg not installed. See tools/images/README.md, or cut out by hand and skip this step.")
|
||||||
|
from PIL import Image
|
||||||
|
outdir.mkdir(parents=True, exist_ok=True)
|
||||||
|
for p in paths:
|
||||||
|
src = Image.open(p).convert("RGBA")
|
||||||
|
dst = remove(src)
|
||||||
|
target = outdir / (pathlib.Path(p).stem + ".png")
|
||||||
|
dst.save(target)
|
||||||
|
print(f"cut {p} -> {target}")
|
||||||
|
|
||||||
|
|
||||||
|
def treat(paths, outdir, size="hero"):
|
||||||
|
from PIL import Image, ImageFilter, ImageEnhance
|
||||||
|
W, H = SIZES[size]
|
||||||
|
outdir.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
for p in paths:
|
||||||
|
subject = Image.open(p).convert("RGBA")
|
||||||
|
|
||||||
|
# fit to 68% of canvas height, keep aspect
|
||||||
|
scale = (H * 0.68) / subject.height
|
||||||
|
subject = subject.resize((max(1, int(subject.width * scale)), int(H * 0.68)), Image.LANCZOS)
|
||||||
|
|
||||||
|
# shadow lift
|
||||||
|
rgb = ImageEnhance.Brightness(subject.convert("RGB")).enhance(LIFT)
|
||||||
|
subject = Image.merge("RGBA", (*rgb.split(), subject.split()[3]))
|
||||||
|
|
||||||
|
canvas = Image.new("RGB", (W, H), BG)
|
||||||
|
|
||||||
|
# place right of centre
|
||||||
|
x = int(W * 0.52)
|
||||||
|
y = (H - subject.height) // 2
|
||||||
|
canvas.paste(subject, (x, y), subject)
|
||||||
|
|
||||||
|
# rim glow from the subject's own alpha
|
||||||
|
alpha = subject.split()[3].filter(ImageFilter.GaussianBlur(GLOW_BLUR))
|
||||||
|
glow = Image.new("RGB", subject.size, GLOW)
|
||||||
|
canvas.paste(glow, (x, y), alpha.point(lambda a: int(a * GLOW_A / 255)))
|
||||||
|
|
||||||
|
# left-to-right fade back to the page colour
|
||||||
|
fade = Image.new("L", (W, 1))
|
||||||
|
end = int(W * FADE_END)
|
||||||
|
for px in range(W):
|
||||||
|
fade.putpixel((px, 0), 255 if px < end else max(0, int(255 * (1 - (px - end) / max(1, W - end)))))
|
||||||
|
fade = fade.resize((W, H))
|
||||||
|
canvas = Image.composite(Image.new("RGB", (W, H), BG), canvas, fade)
|
||||||
|
|
||||||
|
target = outdir / (pathlib.Path(p).stem + ".webp")
|
||||||
|
canvas.save(target, "WEBP", quality=82, method=6)
|
||||||
|
kb = target.stat().st_size / 1024
|
||||||
|
print(f"treat {p} -> {target} ({kb:.0f} KB)")
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
ap = argparse.ArgumentParser()
|
||||||
|
ap.add_argument("step", choices=["cutout", "treat"])
|
||||||
|
ap.add_argument("paths", nargs="+", type=pathlib.Path)
|
||||||
|
ap.add_argument("--out", type=pathlib.Path, default=None)
|
||||||
|
ap.add_argument("--size", choices=list(SIZES), default="hero")
|
||||||
|
a = ap.parse_args()
|
||||||
|
out = a.out or pathlib.Path("build" if a.step == "cutout" else "content/assets/images")
|
||||||
|
(cutout if a.step == "cutout" else treat)(a.paths, out) if a.step == "cutout" else treat(a.paths, out, a.size)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
Reference in New Issue
Block a user