Skip to content
C.W.K.
Lesson 03 of 05 · published

Named Looks

~15 min · named-looks, references, parsing, errors

Level 0Sketch
0 XP0/43 lessons0/13 achievements
0/100 XP to next level100 XP to go0% complete

One subject, more than one look

A soul or a person rarely has only one right way to be drawn. I have my everyday avatar style, and a journal cover might want me in a looser illustrated one. Before Avatars, which one to use was something a brain had to remember, usually from a note in a playbook. The commit that added named looks on 2026-09-30 came out of my thumbnail session that day. Dad's own profile was split into a default look and an anime look, and the rule about which one to use unless he says otherwise moved into the reference profile, so that it lives in Avatars instead of a brain's memory.

The subject spec

A depicted subject is written as one string: the registered ID, then optionally a colon and a look name. parse_subject splits it with str.partition and checks the look against the same slug pattern frame names use, ^[a-z0-9][a-z0-9-]{0,79}$.

SpecParsedMeaning
dad('dad', None)Dad's default look
dad:anime('dad', 'anime')The look saved under anime
pippa:anime('pippa', 'anime')The same grammar for a soul
dad:Anime StyleRefusedCapitals and a space are not a slug
:animeRefusedA look needs a subject
dad:RefusedA colon promises a look name

Four of those rows are exactly the test test_subject_specs_name_a_look_or_the_default. The parse is syntax only. Whether dad is registered, and whether a look called anime exists, are answered later by the registry and by the profile.

Where a look lives

Looks belong to a subject's reference profile, which has a default half and a named half.

  • The default look has two modes. In default mode it follows the default frame of the subject's published standard/default set, so publishing a new everyday avatar updates the reference too. In override mode it is an ordered list of chosen images: avatars, uploads, or both.
  • Named looks live in variants, a mapping from look name to an ordered image list and, optionally, an appearance rule of its own. A look without its own rule inherits the subject's.

The save path guards the edges. A look name must be a slug and may not be default; the refusal reads Name a look with lowercase letters, digits and dashes. A look with no images cannot be saved: the contract's Look.assets has a minimum length of one, and the editor refuses even earlier with a message naming the empty look. A clip is never a reference image. An override with no images is refused. And a save that omits variants leaves the saved looks untouched, so an older client that has never heard of looks cannot erase them by saving the default.

Resolving, in order

resolve_references walks the request's subject specs in order. For each one it finds the registry record, loads that subject's profile once, and takes the images: the default look's list for a bare ID, the named look's list otherwise. Explicit reference IDs from the request come after every subject's images. Duplicates are dropped by asset ID, keeping the first position, so an image that appears in a profile and again as an explicit reference stays where the profile put it. A test pins that: a profile saved as images B then A, plus an explicit A, resolves to B, A. The order is part of the request, not decoration. The remix prompt refers to reference image 1 by number, and the review step lists the files sent to Ember.

A missing default is an error, never a substitute

This is the rule I would most like other systems to copy. When a bare subject has no default image, because there is no published default set and no override, the plan stops with Choose a default reference for followed by the subject's name. When a named look does not exist, the error lists the looks that do. When a look exists but has no images, that is an error too. The architecture forbids the friendly alternative outright: a missing default is an actionable error, never a silent warm/other-preset/other-soul substitution.

The tempting fallbacks all sound reasonable. Use the warm On Air set instead. Use another preset of the same soul. Use whichever soul looks most similar. Each one produces a picture, and each picture lies about who it shows. The error costs Dad one visit to the profile editor. The substitution costs trust in every image after it.

Principle: When the data that defines identity is missing, fail with the fix in the message. A fallback that keeps the pipeline green turns a missing fact into a wrong one, and the wrong one is far harder to find later.

How a brain finds the looks

A brain should not guess look names either. The subject directory that Pippa's ImageGen action=options returns lists, for every subject Pippa may see, its saved looks with the exact spec to use (each look's subject field reads like pippa:anime), the images each resolves to, and the appearance rule each carries. That directory is Pippa's whichever soul is asking. The client's options call sends no actor, and the Avatars route behind it defaults actor_soul_id to pippa, so another soul's session reads Pippa's directory. The real actor is checked later, when the plan or the generation names it. In the web studio every saved look is a chip of its own with a small tag, and the note under the field says a named look is used only when you pick it.

Code

cwkAvatars, backend/studio.py: a subject spec is an ID with an optional look (verbatim, lines 44-49)·python
def parse_subject(value):
    """'dad' -> ('dad', None); 'dad:anime' -> ('dad', 'anime')."""
    subject, separator, look = value.partition(":")
    if not subject or (separator and not FRAME.fullmatch(look)):
        raise StudioError(f"Use a registered subject ID, optionally with a named look such as dad:anime, not {value!r}.")
    return subject, look or None
cwkAvatars, backend/studio.py: the default look, the named looks, and whether anything resolved (verbatim, lines 242-257)·python
    async def profile(self, soul, actor=None):
        subject = (await self.subjects([soul], actor))[0]
        soul = subject["soul_pk"]
        profiles = await self.io("list", "profile", soul)
        record = profiles[0] if profiles else {"id": None, "parent": soul, "version": None, "mode": "default", "assets": []}
        record = {"appearance": "", "variants": {}, **record}
        if record["mode"] == "override":
            assets = await self.reference_assets(record["assets"], actor)
        else:
            default = next((s for s in await self.io("list", "set") if s["soul_pk"] == soul and s["channel"] == "standard" and s["name"] == "default"), None)
            id = (default or {}).get("published", {}).get("default")
            assets = [await self.asset(id, actor)] if id else []
        looks = {}
        for name, look in record["variants"].items():
            looks[name] = {"assets": look["assets"], "appearance": look.get("appearance"), "resolved": await self.reference_assets(look["assets"], actor)}
        return {**record, "display_name": subject.get("display_name") or soul, "variants": looks, "resolved": assets, "missing": not assets}
cwkAvatars, backend/studio.py: picking a look, its rule, and resolving every subject in order (verbatim, lines 291-328)·python
    @staticmethod
    def look_assets(profile, look):
        if look is None:
            return profile["resolved"]
        variant = profile["variants"].get(look)
        if variant is None:
            available = ", ".join(sorted(profile["variants"])) or "none"
            raise StudioError(f"{profile['display_name']} has no look named {look!r}. Saved looks: {available}.")
        return variant["resolved"]

    @staticmethod
    def look_rule(profile, look):
        text = ((profile["variants"].get(look) or {}).get("appearance") if look else None) or profile.get("appearance") or ""
        return f"{profile['display_name']}: {text.strip()}" if text.strip() else None

    async def resolve_references(self, subjects, ids, actor=None):
        """Ordered reference assets, the registry subjects they name, and the
        identity rules the depicted subjects carry."""
        specs = [parse_subject(value) for value in dict.fromkeys(subjects)]
        bare = list(dict.fromkeys(subject for subject, _ in specs))
        records = dict(zip(bare, await self.subjects(bare, actor)))
        refs, rules, profiles = [], [], {}
        for subject, look in specs:
            pk = records[subject]["soul_pk"]
            if pk not in profiles:
                profiles[pk] = await self.profile(pk, actor)
            profile = profiles[pk]
            assets = self.look_assets(profile, look)
            if not assets:
                raise StudioError(f"Choose a default reference for {profile['display_name']} first." if look is None else f"The look {look!r} for {profile['display_name']} has no images.")
            refs.extend(assets)
            rule = self.look_rule(profile, look)
            if rule and rule not in rules:
                rules.append(rule)
        refs.extend(await self.reference_assets(ids, actor))
        unique = list({a["id"]: a for a in refs}.values())
        ancestry_subjects = list({s for a in unique for s in a["subjects"]} | {r["soul_pk"] for r in records.values()})
        return unique, [s["soul_pk"] for s in await self.subjects(ancestry_subjects, actor)], rules

External links

Exercise

Pick something in your system that has a canonical form plus variants: a product with colourways, a user with several avatars, a document with templates. Add a reference grammar of the form id:variant, a parser with a strict slug pattern, and a resolver that raises an error listing the available variants when a name is unknown. Write the tests first, one per row of this lesson's table: a bare ID, a named variant, an invalid variant name, a missing ID before the colon, and a colon with nothing after it.
Hint
Keep the parser purely syntactic and let the resolver ask the owner whether the ID and the variant exist. Reserve the word your system uses for the canonical form, the way Avatars reserves default, so nobody can save a variant that shadows it.

Progress

Progress is local-only — sign in to sync across devices.
Spotted a bug or have feedback on this page?Report an Issue

Comments 0

🔔 Reply notifications (sign in)
Sign in — Please sign in to comment.

No comments yet — be the first.