Concepts.
How interpolation, transforms, audio, rendering, and the sandbox actually work — enough to predict the output. Every concept kyfram has, nothing skipped.
Interpolation
Interpolate(kfs, t) returns the property set at time t. Numbers lerp linearly, hex colors lerp per channel, same-length numeric arrays morph element-wise, everything else snaps to the earlier keyframe. Out-of-range time holds the nearest end — never extrapolates. Empty input returns an empty map.
The earlier key owns the segment's curve, so easing reads like intent: leave here gently rather than arrive here gently. Duplicate times hold the earlier value instead of dividing by zero. Declaration order inside the table doesn't matter — times sort numerically.
shape("ball", "circle", table{
"0": table{x: 100, color: "coral"},
"1": table{x: 320, color: "sky"},
"2": table{x: 540} -- color holds "sky" from t=1 (missing prop keeps earlier value)
})
-- At t=0.5: x lerps 100→320, color lerps coral→sky per channel.
-- At t=3: x holds 540, color holds sky. Never extrapolates.
Easing curves
Declare ease on the earlier key of a segment. Eight curves; unknown names fail fast at build time.
| name | formula | feels like |
|---|---|---|
| linear | f | default — no easing |
| easeIn | f² | starts slow, accelerates |
| easeOut | 1 − (1−f)² | starts fast, settles |
| easeInOut | 2f² / 1−2(1−f)² | slow start and slow end |
| cubicIn | f³ | heavier ease-in |
| cubicOut | 1−(1−f)³ | heavier ease-out |
| cubicInOut | 4f³ / 1−4(1−f)³ | heaviest InOut |
| sine | 0.5−0.5·cos(πf) | sinusoidal, softest |
shape("a", "circle", table{"0": table{x: 0, ease: "easeOut"}, "1": table{x: 320}}) -- easeOut lives on the 0 key
Colors
Color names resolve to hex before blending, so they lerp exactly like hex. mixHex lerps each channel and rounds — red→blue passes through purple, never through a meaningless string midpoint. Case-insensitive; unknown strings are not colors and just snap.
| name | hex | name | hex |
|---|---|---|---|
| black | #000000 | white | #ffffff |
| red | #ff0000 | coral | #e64c40 |
| gold | #e6c040 | sky | #40a0e6 |
| mint | #40e680 | lavender | #b080e6 |
| slate | #404040 | silver | #888888 |
| dark | #14141c | navy | #000080 |
Short hex #rgb expands to #rrggbb. Full list: black, white, red, green, blue, yellow, cyan, magenta, orange, purple, pink, teal, navy, gray/grey, dark, gold, coral, sky, mint, lavender, slate, silver.
Timeline + frame budget
A Timeline holds FPS, optional Width/Height (0→640×360), Font (TTF path, default bundled Archivo Black), Transparent flag, then a list of Scenes — each with its own Duration, entity lists, camera, and optional Transition / duration. scene() opens one; Duration is the sum of every scene's duration.
| field | set by | default |
|---|---|---|
| Scenes | scene(d, fps[, transition[, secs]]) × N | one required |
| Total Duration | sum of scenes | sum |
| FPS | scene(_, fps) / --fps | scene value |
| Width / Height | --width / --height | 640×360 |
| Font | --font | bundled Archivo Black |
| Transparent | transparent(b) | false |
| Entities | shape() / text() | [] |
| Groups | group() | [] |
| Camera | camera() | identity |
| Audio | audio() | [] |
Frame count is int(round(totalDuration · FPS)), min 1 — all scenes share the scene() fps. Frame i sits at t=i/FPS. PNG output renders only t=0. Scene boundaries land on frames: dispatch picks the active scene by absolute time with half-open windows, so the exact boundary frame belongs to the previous scene.
Scenes & transitions
Declarations before the first scene() are global — they render in every scene (a watermark, a fixed backdrop). After a scene starts, its entities attach to that scene and their keyframe times are offset by the sum of earlier scenes' durations at bind time, so scene-local times stay readable.
Each scene past the first can open on a transition. The previous scene's last frame is frozen on screen, the new scene is rendered onto an offscreen layer, and one per-pixel pass combines them by progress p = (t − start) ÷ transitionSeconds:
| transition | what p means | default length |
|---|---|---|
| cut | instant switch (no window) | — |
| fade | source-over dissolve of the two frames | 0.5s |
| black / white | dip out to the color, hold a beat, dip in | 0.5s |
| flash | short dip to white — chapter pop | 0.2s |
| push-left|right|up|down | new scene slides in, old slides out beside it | 0.5s |
| wipe-left|right|up|down | reveal edge sweeps across | 0.5s |
| iris | circle grows from center | 0.5s |
| zoom | new scene scales up from a dot | 0.5s |
All of these are the same one-pass reveal: which source pixel shows where is all that differs. The 4th scene() arg overrides the default length. Unknown transition names fail fast at build with a suggestion.
Rendering pipeline
Timeline → scene dispatch → Interpolate(t) per entity → gg canvas → ffmpeg or PNG
0. sceneAt: pick active scene by absolute time (keyframes pre-offset per scene at bind)
1. transition window: previous scene frozen at its end + current on a layer, combined
2. frameCamera: push x/y/zoom onto the context (pop after)
3. resolve Groups → map name→props, Groups sorted by layer
4. partition Entities into root vs grouped (build rejects unknown groups; hand-built timelines fall back to root)
5. interleave Groups + root by layer, stable sort
6. for each: spin(cx,cy, rotate+scale) → drawEntity(kind, props)
7. unknown kind / bad values / unloadable font+image → build error first (hand-built timelines still skip one bad entity, never kill the frame)
8. mp4: pipe RGBA → ffmpeg (-pix_fmt rgba → yuv420p); png: SavePNG(t=0)
Background clears to rgba(0.1,0.1,0.12) or transparent(0,0,0,0) when transparent(true) is set. Images use a process-lifetime imgCache — one decode per file, not per frame.
Transforms
Groups: the world transform is group × child, applied once per member. Child alpha multiplies group alpha. Children keep local coordinates — a group orbits while its members keep local offsets. Group draw order interleaves with root entities by layer. One flat level; nesting is not supported.
spin(): every entity can rotate (degrees) and scale (uniform) around its anchor. If both are identity, the fast path skips the canvas push entirely.
Camera: world point (x,y) lands on the screen center, scaled by zoom. Implementation: translate to center, scale, translate back by -x,-y. Empty camera interpolates to nothing (identity) and costs one matrix push/pop.
Layer: entities and groups sort by layer, stable, at draw time. Same layer keeps declaration order. Higher on top.
Blend modes
| mode | math per channel | use for |
|---|---|---|
| multiply | s · d | shadows, tinted overlays |
| screen | 1 − (1−s)(1−d) | glows, halos |
| add | s + d, capped 1 | light accumulation |
A blend entity renders onto an offscreen layer first, then composites pixel-wise onto the scene. Transparent source pixels are skipped outright. Bytes are read as straight RGBA — exact for opaque art, approximate on translucent edges. See wave.lgv glow.
Gradients
Filled kinds (circle, rect, roundRect, ellipse, polygon, star) accept gradFrom gradTo gradAngle. Linear gradient across the shape bbox at gradAngle°, alpha honoured. Strokes (line/arc/wave), images, and text stay flat — set a fill kind instead.
Text on a path
text(..., points: [x1,y1,…]) lays glyphs along a polyline: total path length is walked, each character centered on the arc-length offset and rotated to its segment. Characters past the path end are dropped, not wrapped. Empty strings and bad point lists draw nothing.
Audio pipeline
Audio inputs get -itsoffset (shift forward), -stream_loop -1 when looping, then apad stretches short tracks to the video end and -shortest caps exactly at the last frame. Multiple tracks mix with amix+apad. Missing files fail inside ffmpeg with its stderr attached — no pre-check. Offset must be ≥0.
TTS fallback chain
| path | quality | needs |
|---|---|---|
| Edge neural (edge-tts) | exact word boundaries | network + python3 + edge-tts; 15s timeout |
| espeak-ng | estimated timing | local espeak-ng binary |
| beeps | word-duration beeps | nothing — pure Go sine waves |
Whatever path succeeds returns the same shape: {audio, duration, words:[{word,start,end}]}. Words drive caption timing via str(w.start) quoted keys — see voice.lgv and intro_voice.lgv. Shorthand tts(text, out, "en-US-JennyNeural") or full table table{voice:..., rate:...+0%, pitch:...+0Hz, volume:...+0%}. tts_voices() lists neural voices. Default voice en-US-AriaNeural.
Use imports
use "name" inlines name.lgv looked up beside the script file (std/ imports stay for Logos itself). Dependencies inline first, use-lines stripped, each file once. Cycles error, not infinite recursion. Go-side loading so Logos is never forked for multi-file scripts.
Sandbox
Logos runs with file IO, network, shell, and exit blocked. kyfram re-opens file access deliberately through its own builtins: text_file(path) (capped at 1 MB) and tts(text, out, opts). Scripts compute and branch freely — the Timeline builder simply ignores everything except declaration calls, so loops and if are fine; they just generate more declarations.
Language reference: logos-lang.vercel.app → — variables, stdlib, use rules, and grammar.