The Scenet language#
Status: this is the specification. It is not a report of what is implemented — see implementation status, which is authoritative on what actually runs. Panels and sequences compile end to end today, from either frontend; page composition and the style layer do not exist yet.
A panel source is a YAML document describing what is in a panel, never where things are drawn. Coordinates do not appear anywhere in the language; producing them is the compiler’s entire job.
The layers#
A panel description has five authored layers. A sixth — resolution — is computed, and a seventh — rendering — is emitted.
Layer |
Block |
What it says |
|---|---|---|
Frame |
|
Size and margins of the panel |
Camera |
|
How the scene is framed |
Setting |
|
Where and when it happens |
Cast and staging |
|
Who is present, and how they relate |
Narrative |
|
What is said, and in what order |
Layers are separable on purpose. Changing camera.shot re-frames the same scene without touching
anything else, exactly as transposing a score changes its key without rewriting the melody.
A complete example#
panel:
size: [1000, 1000]
camera:
shot: medium_shot
angle: eye_level
setting:
place: docks
time: night
weather: rain
cast:
alice: {reference: alice, pose: pointing, at: left_third, facing: right}
bob: {reference: bob, pose: arms_crossed, at: right_third, facing: left}
staging:
- alice left_of bob
- alice looking_at bob
- alice ground_shared_with bob
script:
- say: {by: alice, text: "You forgot your umbrella!", prefer: top_left}
- say: {by: bob, text: "I know."}
panel#
Key |
Type |
Default |
Meaning |
|---|---|---|---|
|
|
required |
Panel dimensions in panel units |
|
number |
|
Inset all content by this much |
Panel units are arbitrary and internally consistent; they become SVG user units.
camera#
Key |
Values |
Default |
|---|---|---|
|
see shot types |
|
|
|
|
shot determines the scale of every actor, by naming where the frame cuts the body rather than
what fraction of the panel a figure fills. This is the single most consequential value in a panel.
setting#
Where and when the panel happens, drawn as tonal masses rather than as geometry.
setting:
place: docks # a named place, which expands into masses
horizon: mid # high | mid | low
time: night # dawn | day | dusk | night
weather: rain # clear | rain | fog | snow
A panel with no setting renders exactly as it always did: figures on white.
masses#
A place is a convenience. Underneath it is a list of masses, which stays authorable whenever you want control — and which is exactly what a place expands into:
setting:
horizon: mid
masses:
- {kind: building, plane: far, spans: full}
- {kind: plant, plane: mid, spans: left}
- {kind: ground, plane: near, spans: full}
Key |
Values |
Default |
Meaning |
|---|---|---|---|
|
see below |
required |
What the mass is made of, which decides its silhouette |
|
|
|
How far back it sits |
|
|
|
How much of the width it covers |
kind is subsetted from the supercategories of
COCO-Stuff, the canonical taxonomy of stuff — “amorphous
background regions” as opposed to things with a well-defined shape. Taken from an existing
vocabulary for the same reason the predicates were taken from Visual Genome. Twelve of them, seven
outdoor and five indoor:
Kinds |
|
|---|---|
Outdoor |
|
Indoor |
|
Deliberately not COCO-Stuff’s leaf names. Its actual classes are building-other, sky-other,
wall-brick, water-other; the -other suffix marks the catch-all inside a supercategory, and
building-other is not a word anyone should have to type. Its textile, food and rawmaterial
supercategories are left out: drapery and objects, not scene-defining masses.
plane decides two things at once, and neither is a new mechanism. It maps onto the same
integer painter’s order in_front_of already uses — the three backdrop planes take negative
depths, and foreground takes one above the frontmost actor, so a foreground mass draws over the
cast the way a silhouetted doorway does. And it decides value: reading front to back, a mass
never gets darker.
Value comes from the plane and from nothing else — not from the kind. That is what keeps the
notan reading literal: masses at one distance read as one mass, and their arrangement is what
carries the place. Two kinds sit off their own plane’s rung, and both stay on the ladder rather
than beside it: sky is at infinite distance so it always takes the atmosphere’s value, and
window is a hole showing a more distant plane so it takes the rung one step farther back.
A nearer plane is also drawn larger, which is size perspective alongside aerial perspective: a near hill is not merely darker than a far one, it is bigger.
spans is an absolute extent, and that is not cosmetic. Any construct that would reintroduce a
left/right disjunction has to resolve it before the solver — see
why ordering must be explicit. A span is an extent rather than a
relation, which is what stops masses becoming an unordered beside. left and right overlap
slightly in the middle so that using both leaves no seam down the centre of the panel.
One authored mass may resolve to several polygons: furniture is a few separate blocks, a wall holds several windows. Joining them into one comb with a zero-height baseline would be a lie about the shape.
place#
The headline surface, because the thing an author wants to write is where the scene is, not a list of shapes.
Place |
Expands into |
|---|---|
|
sky·far, building·near·left, building·near·right, ground·near, building·foreground·left |
|
sky·far, solid·far·right, ground·mid, ground·near |
|
sky·far, building·far·left, water·mid, structural·mid·right, ground·near |
|
sky·far, plant·far, ground·mid, ground·near |
|
sky·far, plant·far, plant·near·left, plant·near·right, ground·near |
|
sky·far, solid·far, solid·mid·left, plant·mid·right, ground·near |
|
wall·far, window·far·center, ceiling·mid, floor·near, furniture·near |
|
wall·far, window·far·right, ceiling·mid, floor·near, furniture·near·left |
|
sky·far, water·mid, ground·near |
|
sky·far, building·far, building·mid·left, building·mid·right, ground·near |
The rule that keeps place: honest: a preset expands into a mass list the author could have
written themselves, and is never a second opaque format. A library for convenience, not a parallel
language. The expansion happens in the frontend, exactly as alice left_of bob is expanded into a
relation — so by the time anything downstream sees a backdrop, there is one representation of it.
place and masses are mutually exclusive. A place is a mass list; writing both asks two
questions at once, and the compiler will not guess which was meant.
Free prose is deliberately not offered. setting: "a rainy street corner at midnight" needs
language understanding, and the comic-script frontend already refuses to interpret prose on the
grounds that guessing produces panels that are confidently wrong. A named place is the honest
middle: it reads like a description and resolves deterministically. scenet check reports an
unmatched name as unknown-place, listing the ones that exist.
horizon#
One line for the whole panel, which every mass is composed against: masses of the ground sort start at it and run down, masses that stand in the world rise from it. A high horizon sits nearer the top of the frame, so more ground is in view.
Ground, floor and water all run to the bottom edge, so a near quayside drawn from the horizon would bury the water behind it. Each starts lower than the plane behind it, and that stack of receding bands is the depth cue. The exception is the farthest one in a panel, which meets the horizon itself: there is nothing behind it to reveal.
time and weather#
time does not tint a daytime panel. It supplies the two ends of the value ladder — the value of
the foreground and the value of the atmosphere — and the planes are spaced evenly between them in
OKLab lightness, which predicts perceived lightness
well. So night is a darker, narrower ladder, which is what night does to a drawn scene, and the
ladder stays monotonic in depth at every hour by construction rather than by tuning.
weather adds a layer over that. clouds and fog are first-class stuff in COCO-Stuff, so this
vocabulary did not have to be invented either:
|
What it does |
|---|---|
|
Nothing. The panel has no atmosphere layer at all |
|
A dense, low-frequency noise veil, tinted with the atmosphere itself |
|
The same veil as cloud — nearer, so darker than the sky — plus slanted streaks |
|
The same veil, plus flecks |
The veil sits over the backdrop and under the cast: fog between the reader and the figures would be the more literal reading and would bury them. Falling weather goes over everything, because it is between the reader and the panel — which is why it crosses the figures.
Rain flips to ink over a bright sky and to paper over a dark one, as inkers do, because a white streak over noon is invisible and a black one over midnight is too. Snow never flips: snow is white, and the overcast veil is what gives it something to read against.
cast#
A mapping of actor id to properties. Ids are chosen by the author and referenced everywhere else.
Key |
Type |
Meaning |
|---|---|---|
|
asset name |
Which puppet to pull from the library |
|
pose name |
A named joint configuration declared by that puppet |
|
expression name |
A named face declared by that puppet |
|
list of marks |
Emanata drawn around the character: |
|
anchor |
Horizontal placement preference |
|
|
Which way the figure is turned |
at accepts left_third, center, right_third, left_edge, right_edge. It is a
preference, not a command — see conflicts.
expression is selected by name exactly as pose is, because a face is the same kind of thing as a
body: a small closed set of arrangements a character can be in. The shipped puppets declare ten —
neutral, happy, laughing, coy, bored, scared, sad, angry, shouting, surprise —
and it defaults to neutral. They are a drawing convention, the small closed set of faces comics
actually draw, and not a claim about what a person feeling anger looks like. What a face is made of,
and how to give your own puppet one, is in the asset contract.
A character’s pupils follow whoever they are looking_at. Nothing else about the face depends on the
rest of the panel, and nothing about the face changes the layout: to the solver a face is still one
disc that balloons may not cover.
marks#
cast:
alice: {reference: alice, expression: angry, marks: [grawlixes, plewds]}
marks are what a comic draws around a character rather than on them. The vocabulary is Mort
Walker’s, from The Lexicon of Comicana, and it is closed:
Mark |
What is drawn |
What it says |
|---|---|---|
|
Droplets flying off the head, mostly off the back |
sweating: effort, heat, nerves |
|
Little starbursts and circles in an arc over the head |
dizzy, drunk, or sick |
|
A spiral, a star, a bolt and a |
swearing |
|
Puffs of dust at the feet, behind the figure |
gone, fast |
They are a list, not a second expression:, because they compose: a character can be angry and
sweating. Order does not matter, and a mark listed twice is an error. They need nothing from the
puppet — every mark is placed from the face circle, the facing and the feet — so every character can
have every mark. In a scene, marks: [] under over: clears the ones a panel inherited, because
lists replace rather than merge.
Emanata are drawn outside the head, in the space balloons are placed in, so unlike a face they matter to the layout — but only softly:
They never move anybody. They stay out of the hull, so staging, the camera and every figure are exactly where they would be without them.
A balloon prefers not to cover them. Each mark has a zone the solver reads as a cost, weighted above covering a body, with no forgiveness for the speaker. A crowded panel still compiles: a balloon covers a mark before it fails.
The camera makes no room for them. It frames by body landmarks, so a tight shot — or the head of the tallest character, which the frame is fitted to — can crop marks over the head, and a shot that cuts at the waist leaves briffits below the frame.
scenet buildreports any mark that runs off the panel.
At a wide framing a plewd is a dot: below a head size, each mark collapses to a dot where it would have been drawn, and a character too small to have a face has no marks at all. See the asset contract.
An oath written in a balloon — "@#$%!" — is dialogue, and already works. grawlixes is the other
convention, the symbols over a head.
reference, pose and expression are validated against the puppet library, not just against the
language’s own grammar — a misspelled pose is a perfectly good string as far as the grammar is
concerned, so nothing at that level can tell pointing from smirking. scenet check resolves the
library and reports an unmatched name as unknown-puppet, unknown-pose or unknown-expression,
each naming the field and, for pose and expression, listing the names that puppet does declare.
See scenet check.
staging#
A list of relations between actors, written subject predicate object. This is a scene graph: the
cast are nodes, these are the edges. Predicates come from the spatial subset of the Visual Genome
vocabulary rather than being invented here, so a Scenet scene remains convertible to and from the
scene-graph representations used elsewhere in computer vision.
Predicate |
Effect |
|---|---|
|
Fixes horizontal ordering |
|
Fixes draw order and occlusion |
|
Sets the subject’s gaze vector toward the object |
|
Places both actors on the same ground line |
Why ordering must be explicit#
left_of looks redundant next to at: left_third, but it is not. The layout engine is a linear
constraint solver, and “A and B must not overlap” is a disjunction: A is left of B, or B is
left of A. Linear solvers cannot express that choice.
So the language resolves it instead. By the time the solver runs, ordering is already decided — by
at, or by an explicit left_of — and what reaches the solver is a linear system it can always
solve. This is the reason there is no unordered beside predicate, and why any future construct
must resolve its own ordering in the frontend.
script#
An ordered list of narrative events, each tagged by a verb. Order is meaningful: it is the order the reader reads them, and it constrains where the boxes may be placed.
There are two verbs. say puts a line in a balloon; caption puts one in a box.
script:
- caption: {text: "Midnight. The docks.", kind: locale, prefer: top_left}
- say: {by: alice, text: "You forgot your umbrella!", prefer: top_left}
- say: {by: bob, text: "I know.", kind: whisper}
say#
Key |
Type |
Meaning |
|---|---|---|
|
actor id |
Who speaks; the tail points at this actor’s mouth |
|
string |
The dialogue. Line breaking is computed, not authored |
|
zone |
A hint about placement, honoured when possible |
|
|
Balloon styling |
text is never pre-wrapped by the author. Wrapping is computed from real font metrics during
compilation, because a balloon’s size determines whether it fits where it is wanted — so the
compiler must decide the line breaks before it can place anything.
caption#
A caption is the panel speaking in its own voice. It is what lets a panel say where and when it happens without a character having to explain it out loud — which is the thing writers are told not to do. Comics solved this long before they had reliable backgrounds.
Key |
Type |
Meaning |
|---|---|---|
|
string |
What the box says. Line breaking is computed, as for dialogue |
|
|
What the box is doing |
|
|
What the box is filled with. Defaults to |
|
zone |
Where it would like to sit. Defaults to |
|
any name |
Who is speaking, for a |
The four kinds are the letterers’ own vocabulary, taken from Blambot’s Comic Book Grammar & Tradition rather than invented — for the same reason the predicates were taken from Visual Genome. Note that “narration”, the obvious guess, is not one of them.
Kind |
What it is |
How it is set |
|---|---|---|
|
Location and time — “Midnight. The docks.” |
Italic |
|
A character’s inner voice |
Italic |
|
Off-panel dialogue |
Roman, in quotation marks |
|
The voice of the writer or editor |
Italic |
monologue has largely replaced the thought balloon in modern comics, so a panel has two ways to
render an inner voice: a monologue caption and a thought balloon. Both are correct. They are
different eras of the same convention, not a duplication.
Quotation marks are applied by the compiler, not by you. In a run of consecutive spoken
captions, each opens with a quote and only the last one closes — the run is one continuous line of
off-panel speech, and closing every box would read as a series of interruptions. Write the words;
the marks are lettering.
by is the one place an actor id is allowed not to resolve. Everywhere else, naming somebody
who is not in cast is an error. A spoken caption’s speaker is off panel by definition, so
requiring them to be cast would defeat the purpose. It is accepted only on spoken; on any other
kind it is a mistake and is rejected.
tone#
A caption box is opaque, so its lettering is never the thing at risk — the text sits on the fill
whatever is behind it. What a tone changes is whether the box reads. Against the value ladder
the setting block produces, a white box on a noon sky is 1.16:1: legible, and invisible.
Tone |
Fill |
Where it comes from |
Lettered in |
|---|---|---|---|
|
|
the paper the panel is printed on — the default |
ink |
|
|
the |
ink |
|
|
the |
paper |
Two of the three are rungs of the ladder in solve/backdrop.py, taken by index rather than restated
as literals, so lettering and backdrop cannot drift apart as either is tuned. A tone is fixed, not
a function of the panel’s time: a caption’s value is a property of the caption, and letting it
follow the hour would make the table below a function of the panel.
ink produces what letterers call reversed type. The inversion is decided by the compiler, by
contrast, and travels in Panel Core as a resolved value — the same rule and the same reason as
falling rain, which is inked over a bright sky and papered over a dark one.
Two floors, and only the first is a rule every tone must meet:
Lettering, 4.5:1. A caption’s text against its own fill, WCAG AA for body text. This is the contrast a reader actually gets, and every tone in the palette clears it with room — 18.9:1, 8.4:1 and 19.9:1 respectively.
Separation, 3:1. The box against the plane behind it. No tone is required to clear this on every rung, and the default does not: white on a noon sky is the case that motivated the feature. What the palette owes you is an escape from every background the compiler can produce — at least one tone above 3:1 for every rung of every hour — and that is what the test suite checks.
There is no free-form fill:, and no yellow. An open colour field would be this language’s one open
vocabulary and would let you produce an unreadable box; the classic yellow locale caption would be
the first non-neutral value in the codebase, and there is no colour policy to put it under yet.
Text is set flush left. This is a convention, not a rule the way reading order is: the
lettering references describe left alignment as the norm while calling it a house preference. It is
the default because it is what letterers do, and it is recorded here as a choice rather than a law —
unlike shot_types.md, which is normative. Balloons, by contrast, centre their text.
Captions are placed by the same machinery as balloons — the same face avoidance, the same silhouette occlusion cost, the same hard reading-order rule — because the placement principles for floating text are the ones the balloon solver already implements. What differs is the pull toward the frame: a balloon mildly dislikes hugging the panel edge, and a caption is looking for exactly that corner.
Reading order is enforced, not suggested#
Balloons and captions are placed in script order, in one pass, and a box may never sit above-and-left of the one before it. Violating this is not a cosmetic flaw: it makes the panel read in the wrong order, which is a correctness bug in a comic. The constraint is therefore hard, and a panel whose boxes cannot be placed without breaking it is rejected rather than rendered wrongly.
Captions take their turn in that sequence rather than being placed first as a layer. A caption written between two lines of dialogue is read between them; one written last is read last.
When constraints conflict#
Placement values are preferences of differing strength, and the solver resolves conflicts by priority rather than by failing:
Strength |
Examples |
|---|---|
Required |
Actors stay inside the panel; balloons never cover a face; reading order holds |
Strong |
Declared |
Weak |
|
So two actors both asked to stand at center will be pushed apart rather than overlapping: the
required non-overlap wins and the weak anchors yield. This is why at is documented as a
preference. If you need a guarantee, express it as a relation in staging.
Determinism#
The same source always compiles to byte-identical output. No wall-clock time, no unseeded randomness, no dependence on mapping iteration order. This is what makes a panel description a durable artifact rather than a prompt: it will render the same in five years as it does today.
Backdrop silhouettes are generated, so they are seeded — from the declared setting and the panel
size, through a content hash. Never a clock, and never Python’s hash(), which is salted per
process and would agree with itself all day while disagreeing with tomorrow’s build.
The contract is on the SVG text, not on pixels#
Worth stating explicitly, because it is exactly the kind of assumption that rots silently.
The determinism contract is on the emitted SVG text, which stays byte-identical. It has never been on pixels.
The feTurbulence filter that draws fog and cloud is reproducible by definition — SVG has Perlin
noise built in, the specification includes reference code, and the seed is fixed by the compiler
— so the emitted document is identical every time. In practice browsers agree only
approximately
on what to paint from it. That is fine, and it was already true of every glyph outline and every
antialiased edge in the file.
Golden-file tests therefore target Panel Core and SVG text, never a raster. If a raster check is
ever wanted, resvg is the candidate: it supports
feTurbulence, aims at the whole specification rather than the common cases, and ships around 1600
SVG-to-PNG regression tests — which matters because the W3C SVG test suite was abandoned long ago,
making resvg’s the practical conformance reference.