The twenty frames in AniAvatar carry no alpha, so one composited at the blink's 0.12 is a 12% black wash over the whole panel — indistinguishable from expose dimming the screen. At that alpha the image lands inside 30 of the panel's 255 code values, so anything under 8/255 in the file is simply not there.
240 lines
9.7 KiB
Markdown
240 lines
9.7 KiB
Markdown
# The veil — asset specification
|
||
|
||
What a frame of her has to be before the compositor can flash it across the
|
||
whole panel at low alpha. Written 2026-08-15 against the blink curve landed in
|
||
`souveraine-viewtop` `b7ef258` and the panel facts in TASK-61.
|
||
|
||
This is a production spec for a person with an image editor open. Every number
|
||
below either comes from the source or is derived from it, and each one says
|
||
which.
|
||
|
||
**The one-line version:** cut her out on a transparent 1080×2160 canvas, export
|
||
straight-alpha PNG-24, decontaminate the edges, and author her in the **top
|
||
third of her tonal range** — because after compositing she gets about 30 code
|
||
values of the panel's 255, and everything darker than that simply is not there.
|
||
|
||
---
|
||
|
||
## 1. Why the current frames cannot be used
|
||
|
||
Measured 2026-08-15 in `~/Projects/AniAvatar/`:
|
||
|
||
| file | size | PNG colour type |
|
||
|---|---|---|
|
||
| `ani-idle.png` | 1024×1024 | 2 — truecolour, **no alpha** |
|
||
| `ani-idle-blink.png` | 1024×1024 | 2 — **no alpha** |
|
||
| `ani-affectionate.png` | 465×388 | 2 — **no alpha** |
|
||
| `ani-alert.png` | 1024×1024 | 2 — **no alpha** |
|
||
|
||
Twenty frames, none with an alpha channel, in two different sizes.
|
||
|
||
The black behind her is not absence, it is opaque black. Composited at the
|
||
blink's 0.12 it produces a **12 % black wash over the entire panel** with a faint
|
||
her inside it. That reads as the screen dimming — which is exactly what `expose`
|
||
does, and `expose` is a *disclosure* decision the gate makes. Two different
|
||
mechanisms must never look the same on the glass.
|
||
|
||
So the veil needs the fourth channel, and it needs it as a real gradient: the
|
||
soft fall-off at a pigtail, the edge of her jaw, the places she is barely there.
|
||
A hard 1-bit cut-out will read as a sticker.
|
||
|
||
---
|
||
|
||
## 2. Canvas
|
||
|
||
| property | value | why |
|
||
|---|---|---|
|
||
| Size | **1080 × 2160 px** | blueline's panel — `scale.rs:238`, `BLUELINE_MODE` |
|
||
| Aspect | 1:2 (18:9) | ditto |
|
||
| Colour mode | **RGB/8** | `kms.rs` scans out `Xrgb8888`/`Argb8888`; 10-bit exists behind `VIEWTOP_10BIT` and is off |
|
||
| Colour profile | **sRGB IEC61966-2.1** | see §5 |
|
||
| Background | **transparent** | no matte, no black layer, no white layer |
|
||
|
||
Do **not** author at 540×1080 logical. The compositor renders at physical scale
|
||
2 and a logical-sized asset arrives at half resolution on a 440 dpi panel.
|
||
|
||
Her art does not have to fill the canvas. Compose her within it — the canvas is
|
||
the panel, and where she sits on it is a composition decision, not a crop.
|
||
|
||
---
|
||
|
||
## 3. The tonal budget, which is the whole difficulty
|
||
|
||
This is the number that decides whether the art works, and it is not obvious.
|
||
|
||
The blink's peak alpha is **0.12** (`atmosphere.rs`, `Blink::PEAK`). Over the
|
||
dark desktop the composited output is `0.12 × her value`, so:
|
||
|
||
| her pixel | what lands on the panel (of 255) |
|
||
|---|---|
|
||
| 255 | 30 |
|
||
| 200 | 24 |
|
||
| 128 | 15 |
|
||
| 64 | 7 |
|
||
| 32 | 3 |
|
||
| 16 | **1** |
|
||
| 8 | **0 — gone** |
|
||
|
||
**The entire image compresses into roughly 30 of 255 code values.** Consequences,
|
||
all of them real:
|
||
|
||
- **Anything below ~8/255 in your file does not exist on the glass.** Shadow
|
||
detail, dark linework, the black parts of her jacket: gone.
|
||
- **Work in the top third.** Her useful range is about 170–255. Push midtones up;
|
||
what looks blown-out in Photoshop is *correct* here.
|
||
- **Silhouette and highlight, not portrait detail.** A 1 px circuit trace at
|
||
60 % grey lands at 9/255 and is invisible. Make the tracery bright and at
|
||
least 3 px at 1080 width if it is meant to read.
|
||
- **Banding is a live risk.** 30 levels across a gradient will step visibly, and
|
||
the 10-bit path is off by default (TASK-61 Part 4). Add a small amount of
|
||
noise/dither to large soft gradients — 1–2 % monochromatic — rather than
|
||
relying on smooth ramps.
|
||
- **This is per-frame, not per-project.** If a specific moment wants to be more
|
||
present, the verb can name a higher peak; the art should be authored for 0.12
|
||
and simply get stronger, never authored for 0.4 and vanish at 0.12.
|
||
|
||
The alpha channel is *not* subject to this budget — a pixel at alpha 1.0 in the
|
||
file still only reaches 0.12 on the glass, because the blink multiplies the whole
|
||
surface. Use alpha for **shape**, use luminance for **presence**.
|
||
|
||
---
|
||
|
||
## 4. Alpha, and the one trap that will bite
|
||
|
||
Wayland's `Argb8888` is **premultiplied alpha**. The fork's fragment shader says
|
||
so and un-premultiplies before any colour work
|
||
(`smithay/src/backend/renderer/gles/shaders/implicit/texture.frag`).
|
||
|
||
Photoshop exports **straight** (unpremultiplied) alpha. That is the correct thing
|
||
to export — the client that uploads the texture is responsible for premultiplying
|
||
— but it means two things for you:
|
||
|
||
1. **Do not pre-darken her against black to "bake in" the transparency.** That is
|
||
premultiplying by hand, and it will be done again downstream, squaring the
|
||
alpha and producing a ghost with dark edges.
|
||
2. **Decontaminate the edges.** These frames were painted on black. Any
|
||
semi-transparent edge pixel produced by keying that background out still
|
||
carries black in its RGB, and straight-alpha compositing will show it as a
|
||
dark fringe around every soft edge — worst on the hair, which is where she is
|
||
softest.
|
||
|
||
In Photoshop: `Select → Select and Mask`, turn on **Decontaminate Colors**,
|
||
output to a new layer with layer mask. Or repaint the edge pixels. Check by
|
||
putting her over a mid-grey layer, not over black — black hides exactly the
|
||
defect you are looking for.
|
||
|
||
**Export:** `File → Export → Export As…` → PNG, **Transparency on**, no
|
||
"smaller file (8-bit)". The result must be PNG colour type **6** (truecolour +
|
||
alpha). Verify:
|
||
|
||
```
|
||
python3 -c "import sys;d=open(sys.argv[1],'rb').read(33);print('colour type',d[25])" frame.png
|
||
```
|
||
|
||
`6` is correct. `2` means the alpha was flattened and the file is unusable.
|
||
|
||
---
|
||
|
||
## 5. Colour
|
||
|
||
The panel is the LG SW43408 — approximately DCI-P3, **no EDID**, 24-bit
|
||
(TASK-61). viewtop scans out **byte-through sRGB by default**: colour management
|
||
is off unless `VIEWTOP_COLOR=natural|vivid` is set.
|
||
|
||
So, plainly: **author in sRGB, and expect the phone to render it more saturated
|
||
than your monitor does**, because raw sRGB code values land on P3 primaries. That
|
||
is a known open defect, not a surprise, and TASK-61 Part 2 is the fix. Do not
|
||
compensate for it by desaturating the source — that would make the art wrong the
|
||
day colour management is switched on.
|
||
|
||
Saturated colour survives the tonal budget better than neutral colour does, which
|
||
works in your favour. Her established palette is already there:
|
||
|
||
| preset | primary | secondary |
|
||
|---|---|---|
|
||
| `NeonGlow` | `#FF1493` | `#7FFF00` |
|
||
| `AuroraBorealis` | `#00FFFF` | `#7FFFD4` |
|
||
| `CherryBlossom` | `#FFB7C5` | `#FF69B4` |
|
||
|
||
`#FF1493` and `#00FFFF` are also the two fringe colours a chromatic split throws
|
||
(magenta leading, cyan trailing), which is why art in those two reads as
|
||
belonging to this compositor rather than sitting on top of it.
|
||
|
||
---
|
||
|
||
## 6. Frames, naming, and how many
|
||
|
||
Keep the existing convention — `ani-<posture>[-<variant>].png`, lowercase,
|
||
hyphenated.
|
||
|
||
**Postures.** `FELT_STATE_ARCHITECTURE.md` defines nine, and the current art does
|
||
not match them:
|
||
|
||
| posture (canonical) | art exists? |
|
||
|---|---|
|
||
| `idle` | yes |
|
||
| `alert` | yes |
|
||
| `thinking` | **no** |
|
||
| `processing` | yes |
|
||
| `affectionate` | yes |
|
||
| `straining` | yes |
|
||
| `yawning` | yes |
|
||
| `listening` | **no** |
|
||
| `speaking` | **no** |
|
||
| — | `engaged` exists and is **not a posture** |
|
||
|
||
Decide before redrawing: either add `thinking`/`listening`/`speaking`, or retire
|
||
`engaged`. A frame named for a state the system cannot enter will never be shown,
|
||
and a posture with no frame falls back silently.
|
||
|
||
**Variants per posture:** `-blink` is required (it is the technique's name).
|
||
`-wink` exists for `affectionate` and is welcome anywhere it is in character.
|
||
|
||
---
|
||
|
||
## 7. Memory — the constraint that caps the set size
|
||
|
||
A 1080×2160 RGBA8 texture is **8.9 MiB resident on the GPU**
|
||
(1080 × 2160 × 4 = 9,331,200 bytes). PNG compression does not help; that is the
|
||
uploaded size.
|
||
|
||
TASK-75's measurements, on a 3.5 GB phone: QuickShell alone at **610 MB RSS**,
|
||
the WebKit face adding **283 MB**, and a rendering storm reaching **952 MB**
|
||
before the kernel OOM-killed the shell.
|
||
|
||
So: **twenty full-panel frames resident is 178 MB, and that is not affordable.**
|
||
|
||
- Load **one posture's pair at a time** — base plus `-blink` — 17.8 MiB. Swap on
|
||
posture change.
|
||
- Or author her smaller than the canvas and place her: a 1080×1200 cut-out is
|
||
4.9 MiB, and most compositions do not need the full 2160.
|
||
- Either way the *client* owns this budget, not the compositor. The compositor
|
||
gets one surface and drives its alpha.
|
||
|
||
Say which choice was made in TASK-59 when it lands, because "why is the shell
|
||
180 MB heavier" is a question somebody will ask three weeks later.
|
||
|
||
---
|
||
|
||
## 8. Checklist before handing frames over
|
||
|
||
- [ ] 1080 × 2160, RGB/8, sRGB profile embedded
|
||
- [ ] Transparent background — no matte layer of any colour
|
||
- [ ] Edges decontaminated; checked over mid-grey, not over black
|
||
- [ ] Not pre-darkened against black (straight alpha, not premultiplied)
|
||
- [ ] Luminance authored in the top third; nothing meaningful below 8/255
|
||
- [ ] Fine detail ≥ 3 px and bright, or removed
|
||
- [ ] Large gradients carry 1–2 % dither
|
||
- [ ] Exported PNG colour type **6** — verified with the snippet in §4
|
||
- [ ] Named `ani-<posture>[-blink|-wink].png`, posture from the canonical nine
|
||
- [ ] Frame count × 8.9 MiB is a number somebody has agreed to
|
||
|
||
---
|
||
|
||
## Connects to
|
||
|
||
TASK-59 (her face on the glass — owns the surface and the role), TASK-52
|
||
(atmosphere; `Blink` is #15 and the curve is landed), TASK-61 (why the panel
|
||
renders saturated and when that stops being true), TASK-75 (the memory budget
|
||
these frames spend from), `FELT_STATE_ARCHITECTURE.md` (the nine postures and the
|
||
fourteen presets, both hers).
|