kyfram docs

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.

what holds, what moves
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.

nameformulafeels like
linearfdefault — no easing
easeInf²starts slow, accelerates
easeOut1 − (1−f)²starts fast, settles
easeInOut2f² / 1−2(1−f)²slow start and slow end
cubicInf³heavier ease-in
cubicOut1−(1−f)³heavier ease-out
cubicInOut4f³ / 1−4(1−f)³heaviest InOut
sine0.5−0.5·cos(πf)sinusoidal, softest
ease ownership
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.

namehexnamehex
black#000000white#ffffff
red#ff0000coral#e64c40
gold#e6c040sky#40a0e6
mint#40e680lavender#b080e6
slate#404040silver#888888
dark#14141cnavy#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.

fieldset bydefault
Scenesscene(d, fps[, transition[, secs]]) × None required
Total Durationsum of scenessum
FPSscene(_, fps) / --fpsscene value
Width / Height--width / --height640×360
Font--fontbundled Archivo Black
Transparenttransparent(b)false
Entitiesshape() / text()[]
Groupsgroup()[]
Cameracamera()identity
Audioaudio()[]

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:

transitionwhat p meansdefault length
cutinstant switch (no window)—
fadesource-over dissolve of the two frames0.5s
black / whitedip out to the color, hold a beat, dip in0.5s
flashshort dip to white — chapter pop0.2s
push-left|right|up|downnew scene slides in, old slides out beside it0.5s
wipe-left|right|up|downreveal edge sweeps across0.5s
iriscircle grows from center0.5s
zoomnew scene scales up from a dot0.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

frame
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

modemath per channeluse for
multiplys · dshadows, tinted overlays
screen1 − (1−s)(1−d)glows, halos
adds + d, capped 1light 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

pathqualityneeds
Edge neural (edge-tts)exact word boundariesnetwork + python3 + edge-tts; 15s timeout
espeak-ngestimated timinglocal espeak-ng binary
beepsword-duration beepsnothing — 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.