Echoes is a storytelling NFT project on Solana: 750 generative portraits across three factions — the Cartographer's Guild, the Lantern Choir and the Verdant Order — with the community voting weekly on where the story goes next. The narrative framework sets the scene: "Six centuries after the Silent Collapse erased most records, the survivors of the orbital platform Kestrel's Veil live among ruins that remember only in fragments — \"Echoes\": images, songs, and personalities left in old machines."

The art direction is approved. The production mint has not started. What follows is the part that is just engineering: how the collection is generated.

Why parametric SVG instead of an image model

An image model was the obvious first instinct. We went another way for boring, specific reasons.

The collection needs 750 pieces that are related — one world, three visual identities — and individually distinguishable. A diffusion-style pipeline gets that through a large, hard-to-audit generation pass plus heavy curation, and the trait list ends up describing the output rather than producing it. If the metadata says "7 hair options, 6 armour options", you want that to be literally true.

A parametric builder inverts the relationship. Traits are real functions; the manifest is generated from the same data the artwork is. Every portrait is a text file — diffable, hashable, reviewable line by line. There is no model to version and no sampling temperature to explain, because determinism is the default behaviour of the thing, not a feature bolted on.

The trade is explicit: we gave up the visual range of an image model in exchange for a collection whose claims are mechanically checkable.

How the builder works

Every portrait uses the same fixed layer stack — fifteen groups, from background and faction sigil through attire, hair-back, head, face, hair-front and accessory — in a 1024×1024 viewBox. Only the trait functions plugged into each layer change.

Each trait is a plain Python function returning SVG string fragments. A surveyor's cap draws a domed crown, an asymmetric visor, a seam and a brass compass pin, and scales horizontally to the head it is drawn on, so one implementation works across every head shape.

Trait library and compatibility rules

Traits are keyed by faction. Each of the three factions has 7 hair/headwear options, 6 armour options and 5 real accessories, plus an optional none. Shared across all three: 5 head shapes, 6 skins, 6 eye styles, 7 brow styles, 8 mouths, 12 hair colours and 5 facial details.

Fourteen rules (R1–R14) encode pairs that cannot coexist — a goggle strap and a cap band occupy the same forehead line; a bob's side panel covers the ear a pencil tucks behind; a wide-brim hat would hide a circlet. Rules apply while picking and are checked again afterwards by validate_traits, which also rejects unknown or cross-faction values.

Uniqueness is enforced too: every visible combination is unique within its faction, and any two portraits in a faction differ in at least 3 of 7 salient slots — head, eyes, brows, mouth, hair, armour, accessory.

def _salient_diff(a, b): return sum(a[s] != b[s] for s in SALIENT) 

Determinism, mechanically

Randomness is hash-seeded: a SHA-256 of seed, faction, item index and attempt number becomes that candidate's RNG. No global random state, so the entire 750-piece set reproduces from one string — currently echoes-gen0-svg-local-test-v1.

def _rng(seed, *parts): h = hashlib.sha256("|".join((str(seed),) + tuple(map(str, parts))).encode()).hexdigest() return random.Random(int(h, 16)) 

Selection is least-used-first: among the values allowed at that point, only those within the current slack of the least-used count stay eligible, so no base trait quietly becomes the rare one. Slots fill in a deliberate order — hair, armour, accessory, eyes, mouth, brows, head, detail, skin, hair colour — so constrained choices prune the search before free ones.

for s in SLOT_ORDER: allowed = [v for v in values[s] if _rule_ok(dict(t, **{s: v}))] lo = min(counts[s][v] for v in allowed) pool = [v for v in allowed if counts[s][v] <= lo + slack] t[s] = pool[rng.randrange(len(pool))] 

The one filter pass

Flat vector work can read as sterile, so each faction carries procedural grain: a single feTurbulence chain in fractal-noise mode, tinted through feColorMatrix and applied as a low-opacity overlay. Parameters differ per faction — the Guild a tight high-frequency grain at baseFrequency="0.85", the Choir a soft low-frequency bloom at 0.012, the Verdant Order an anisotropic fibre at 0.04 0.5 — each with a fixed noise seed, so texture is as reproducible as geometry. The overlay is clipped inside the attire silhouette rather than sitting over it, so grain reads as material. This is the one place the pipeline leans on the renderer; turbulence rendering is the most plausible source of rasteriser-to-rasteriser differences, and that has not been tested across renderers.

Validation

Two stdlib unittest suites sit around the generator: 21 tests over the generator and trait library, and 33 over the metadata-preparation package built on top of it. What they assert matters more than the count:

  • exactly 750 files, 250 per faction, every file's SHA-256 matching the collection manifest;
  • every SVG well-formed, no duplicate ids, no unresolved internal references;
  • no combination violating a compatibility rule;
  • every visible combination within a faction unique;
  • per-slot frequencies balanced — every option used, max−min never above 2;
  • regenerating from scratch byte-identical to the committed set;
  • refusal to write into a non-empty portraits/ directory;
  • an impossible constraint raising rather than silently degrading.

A separate structural check covers the trait library: each new design is rendered in a fixed reference context, all colour literals are stripped, and the resulting geometry compared. Each design must have its own code object, its own colour-blind geometry, and pairwise element overlap below 0.5. The measured maximum is 0.182.

To be explicit: that is structural evidence no trait is a recolour of another. It is not a perceptual or aesthetic judgement.

The export pipeline

SVG is the master; PNG at 2048×2048 is the intended display format. Rather than pull in a headless browser, the pipeline uses ffmpeg's librsvg decoder — ffmpeg 8.0.1 against librsvg 2.61.3 here:

ffmpeg -hide_banner -loglevel error -y \ -width 2048 -height 2048 -i portrait.svg \ -frames:v 1 -c:v png portrait.png 

Fixed -width/-height rather than auto-scaling keeps dimensions deterministic, and the script refuses to overwrite an existing PNG.

Scope, stated plainly: the variation study rendered four portraits per faction, and the export path itself has been verified on sample renders — one clean render per faction, plus labelled reference renders for the pixel check below.

What the samples did buy is a real check. A clean render and a labelled reference render of the same portrait were compared pixel by pixel: outside the label region they are identical (0 changed pixels), inside it roughly 61,300 pixels differ — exactly the label graphic. That proves the clean path preserves the artwork and drops only what it should.

The tooling also labels itself: every portrait in the current set carries a visible NOT PRODUCTION marker, and the tests assert it.

What's next

A production pipeline is being prepared — clean-art output mode, draft metadata, and the remaining polish ahead of launch. When the set is rendered and actually looked at, that will be the interesting follow-up post.