Taste profiles
Palette, type, motion, pacing and texture — and what stingo decides for you.
A taste profile is the other half of a film. The script says what you are talking about; the taste says what it looks and feels like. Neither mentions the other.
stingo render script.yaml --taste dusk
stingo render script.yaml --taste ./ember.jsonSame script, different film.
#What a taste sets, and what it does not
This is the distinction the whole system rests on.
A taste declares intent: a brand colour, light or dark, a mood, a density. Everything a designer would call a relationship — type ratios, spacing rhythm, contrast floors, the lightness ramp of the neutrals, the order things animate in — lives in the house style and is not the taste's to set.
That is why two different profiles still look like stingo videos, and why a badly chosen palette still produces a readable one.
#Deriving one
You should almost never write a palette by hand:
stingo taste "#ff7a18" --name Ember --mood bouncy --texture film --save ember.json ████ bg #130905 ground
████ text #fef1ea 17.8:1
████ muted #a9826e 5.7:1
████ accent #ef7e39 7.2:1
████ accent2 #2ecaf5 10.2:1What it actually does with your hex:
- Places every neutral on a measured OKLab lightness ramp, taken from the reference theme rather than guessed, so derived palettes feel the same.
- Bleeds a trace of your hue into those neutrals. That is what makes a palette read as authored rather than as grey with an accent bolted on.
- Normalises the accent to a target lightness and chroma, so any brand hue lands with comparable punch instead of whatever you happened to type.
- Derives a support hue by rotation, unless you give one with
--support. - Locks semantic colours to fixed hues, so green reads as success in every taste.
- Lifts anything failing the contrast floors, and tells you what it moved.
The colours on this documentation site are the output of that command.
#palette
| Key | |
|---|---|
bg |
the ground everything sits on |
surface · surfaceAlt |
raised panels — code windows, cards |
border |
hairlines and outlines |
text · muted |
body and secondary text |
accent · accent2 |
the brand hue and its support |
ok · warn · danger |
hue-locked semantics |
#type
type:
display: { family: JetBrains Mono, weight: 800, tracking: -0.03, lineHeight: 1.05 }
body: { family: Inter, weight: 400, lineHeight: 1.45 }
mono: { family: JetBrains Mono, weight: 400, lineHeight: 1.55 }
scale: 1transform (none · upper · lower) is available per face. scale
multiplies every size at once.
Sizes themselves are not yours to set: they come from one modular ratio applied consistently, which is most of what separates designed typography from arbitrary font sizes.
Fonts are read from assets/fonts as static .ttf/.otf. Variable fonts are
skipped with a warning — satori's parser cannot read an fvar table.
#motion
The single knob that most changes how a video feels.
| Field | Default | |
|---|---|---|
personality |
snappy · smooth · bouncy · mechanical |
snappy |
enter / exit |
seconds for a standard entrance | 0.45 / 0.3 |
stagger |
seconds between staggered siblings | 0.07 |
ease |
easing curve name | expo.out |
travel |
entrance distance in px at 1080 wide | 48 |
spring |
stiffness, damping, mass — used when ease: spring |
Personality maps to a curve and a travel distance, and the mapping is the
house's, so snappy means the same thing in every profile.
#pacing
| Field | Default | |
|---|---|---|
sceneMin / sceneMax |
seconds; the planner clamps and warns | 3 / 14 |
wordsPerMinute |
narration speed, for estimating from say |
155 |
cutOn |
free · beat · bar |
bar |
breath |
dead air held after a line lands | 0.35 |
cutOn: bar is what makes every cut land on a downbeat. Camera scenes ignore
it by default — see talking head.
#texture
Cheap to render, enormous effect on whether a video looks made.
| Field | Range | Default |
|---|---|---|
grain |
0–1 | 0.05 |
scanlines |
0–1 | 0 |
vignette |
0–1 | 0.35 |
glow |
0–1 | 0.4 |
grid |
0–1 | 0.08 |
cornerRadius |
px | 20 |
None of these are SVG filters. A feGaussianBlur over two megapixels costs
about 9.5 seconds a frame. Grain, vignette and scanlines are a lookup table
built once and applied to the RGBA buffer in a tight loop — a few milliseconds.
#transition and music
transition: { kind: fade, duration: 0.25 }
music: { energy: medium, duckDb: -12, targetLufs: -14 }kind is cut · fade · wipe · whip · glitch · slide, and duration
is half the move.
cut |
nothing; the scenes simply change |
fade |
a brief dip toward the background colour |
wipe |
a bar sweeps across, accent edge leading |
whip |
the scene is thrown sideways under a directional streak |
slide |
the scene is pushed sideways |
glitch |
channel split and torn bands |
stingo renders one scene per frame, so a true crossfade — two scenes alive at
once — would double the cost of every boundary. Each move is split instead: the
outgoing scene plays the first half over its last duration seconds, the
incoming scene plays the second half over its first, and both push the same way
so the pair reads as one gesture. That is why duration: 0.25 gives a half-second
transition.
glitch is the exception that runs on pixels rather than markup, because SVG
cannot express a channel split cheaply. It is seeded from the scene index and
the frame, so every worker in a parallel render tears the same frame identically.
One interaction to know about: a block's own elements already fade out over
motion.exit at the end of a scene. With a short scene and a long motion.exit,
the type can be gone before a slide or whip has travelled far, and the move
reads as weaker than it is. Shorten motion.exit if you want the push to carry
the content rather than an empty frame.
targetLufs is the loudness the final mix is normalised to; −14 is what the
platforms want.
#Checking one
stingo doctor ./ember.jsonAudits against the house floors — body 7:1, muted 4.5:1, accents 4.5:1 — and reports which failures are repaired automatically at render time.
Body text sits well above the WCAG 4.5 minimum on purpose: video is watched small, on phones, often in daylight.