kyfram docs

Scripting.

Every declaration kyfram understands, and the rules that govern how values move between keyframes.

Note: Your scripts are Logos — a Lua-like language with tables, loops, functions, and use "name" imports. The Logos docs → cover the language itself (variables, for/while, fn, string ops, table). This page covers what kyfram adds on top.

Anatomy of a script

A script is Logos code. Only the declaration calls — scene, shape, text, group, camera, audio, transparent — populate the Timeline; everything else (variables, loops, arithmetic, helper functions) is free and ignored by the builder. The sandbox blocks file IO, network, shell, and exit.

examples/intro.lgv
scene(2, 30)

shape("ball", "circle", table{
    "0": table{x: 100, y: 180, r: 40, color: "#e64c40"},
    "2": table{x: 540, y: 180, r: 40, color: "#e64c40"},
})

scene()

scene signature
scene(duration, fps[, transition[, seconds]])

Required — at least one. Opens a scene of duration seconds at fps frames per second; declarations that follow belong to it. scene(2, 30) is 2 seconds at 30fps = 60 frames. Calling scene() again starts the next scene: keyframes in later scenes auto-shift forward so they stay scene-local, and the total video length is the sum of all scenes.

The optional 3rd arg is a transition that runs at the scene's start: "cut" (default) switches instantly, "fade" cross-dissolves, "black" / "white" dip to a solid color, "flash" pops white, "push-left|right|up|down" slides the new scene in, "wipe-left|right|up|down" reveals it like a curtain, "iris" grows a circle from center, "zoom" scales it up from a dot. The 4th arg sets how long the transition takes — defaults: flash 0.2s, everything else 0.5s.

two scenes, one fade
scene(4, 30)                       -- scene 1: 4s
shape("t1", "text", table{"0": table{content: "part one", x: 320, y: 180, size: 44}})
scene(4, 30, "fade", 1)            -- scene 2: 4s, 1s crossfade in
shape("t2", "text", table{"0": table{content: "part two", x: 320, y: 260, size: 44}})
-- total 8s; the fade blends scene 1's frozen last frame into scene 2

The engine also holds Width / Height (default 640×360, overridable via --width / --height), Font (default bundled typeface, overridable via --font), and Transparent (see below).

timeline header
// Go struct for reference — your script just calls scene(), shape(), etc.
Timeline{ Duration: 2, FPS: 30, Width: 0, Height: 0, Font: "", Transparent: false }
// Width/Height 0 → default 640×360; CLI flags win when set.

Powered by Logos — every variable, loop, table, and function you write is Logos; only the declaration calls below produce video.

Keyframes

Times are quoted strings — "0", "1.5" — because Logos has no duration literals and quoted strings read back unambiguously. Each time maps to a table of property values.

shape(name, kind, keyframes)
shape("ball", "circle", table{
    "0": table{x: 100, y: 180, r: 40, color: "#e64c40", ease: "easeOut"},
    "2": table{x: 540, y: 180, r: 40, color: "#e64c40"},
})

Times sort by value — declaration order doesn't matter. Duplicate times hold the earlier value instead of dividing by zero.

stamp() — reuse one motion

stamp(base, overrides)
let slide = table{
  "0": table{x: 60,  y: 180, r: 50},
  "2": table{x: 580, y: 180, r: 50},
}
shape("a", "circle", stamp(slide, table{color: "coral"}))
shape("b", "circle", stamp(slide, table{y: 260, color: "sky"}))
shape("c", "circle", stamp(slide, table{y: 100, color: "gold"}))
-- slide itself is untouched: stamp() hands back a new table each call

Copies a keyframe table and merges the override fields into every keyframe — three variants of one motion without retyping it. The base never mutates, so you can stamp any number of shapes off it. Override keys replace whole values (they're flat), and the shape() below sees the result as if you'd typed it out.

How values move

Value typeBehaviour
numbers (x, y, r, size, alpha…)linear interpolation between keyframes
hex / named colorsper-channel lerp — red→blue passes through purple, never a garbage string midpoint
numeric arrays (points, gradFrom…)element-wise morph when lengths match
strings (content, font, path…)snap to the earlier keyframe — text never renders halfway

Past the last keyframe, the value holds — no extrapolation. Before the first, it holds too.

Easing

Declare the curve on the earlier keyframe of the segment — it reads as intent, "leave here gently", rather than "arrive here". Omit it for linear.

easeshape
lineardefault; no easing
easeInstarts slow, accelerates
easeOutstarts fast, settles
easeInOutslow start and slow end
cubicIn / cubicOut / cubicInOutcubic variants, stronger curves
sinesinusoidal, softest

Colors

Hex #rrggbb or #rgb, plus named colors. Unknown strings just aren't colors — they snap like any other string.

named colors
color: "coral"   -- #e64c40
color: "gold"    -- #e6c040
color: "sky"     -- #40a0e6
color: "mint"    -- #40e680
color: "lavender" -- #b080e6
color: "slate"   -- #404040
color: "silver"  -- #888888
color: "dark"    -- #14141c
color: "white" / "black" / "red" / "green" / "blue" / "orange" / "purple" / "pink" / "teal" / "navy" / "gray"/"grey" / "yellow" / "cyan" / "magenta"

Per-entity style

propapplies todefaultbehaves
x, yallvaries by kindposition; rect/roundRect use top-left, others use center
alphaall but image1real transparency; multiplies through groups
rotateall0degrees around the entity anchor
scaleall1uniform scale around the anchor
layerall0draw order — higher on top
blendprimitive fills—"multiply" | "screen" | "add" compositing
gradFrom, gradTo, gradAnglefilled kinds—linear gradient inside the shape bbox

text()

text(name, keyframes)
text("caption", table{
    "0": table{content: "hello kyfram", x: 320, y: 300, size: 28, color: "#ffffff"},
    "2": table{content: "hello kyfram", x: 320, y: 300, size: 28, color: "#ffffff"},
})

Props: content (snaps), x y (anchor center), size, font (path to a TTF; defaults to the bundled face), and points — a flat [x1,y1,x2,y2…] list that turns the line into a path the glyphs ride, each rotated to its segment.

group()

group(name, keyframes)
group("g", table{
    "0":  table{x: 320, y: 180, scale: 1, rotate: 0},
    "3":  table{x: 320, y: 180, scale: 1.4, rotate: 90},
})
shape("c", "circle", table{"0": table{x: 0, y: 0, r: 30, color: "coral"}}, "g")

A group holds x y scale rotate alpha layer transforms. Members pass the group name as the 4th argument (or 3rd for text) and keep local x/y that animate independently. World transform = group × child. Group alpha multiplies child alpha. Nested groups are flat — one level only.

camera()

camera(keyframes)
camera(table{
    "0": table{x: 200, y: 180, zoom: 1},
    "2": table{x: 320, y: 180, zoom: 1.6, ease: "sine"},
    "4": table{x: 440, y: 180, zoom: 1},
})

The x/y world point lands on the screen center, scaled by zoom. The background clear stays put while entities travel. Empty camera is identity — costs one matrix.

audio()

audio(path[, offset[, loop]])
audio("examples/assets/beep.mp3", 1.0)      -- beep at t=1s
audio("music.mp3", 0.0, true)                -- loop to the last frame

Lays a sound file under the video. Offset shifts it forward in seconds so a beep lands on a visual beat. Loop repeats short sounds to the last frame instead of going silent. Multiple audio() calls mix; short tracks are padded to the video end.

Logos — the language underneath

Every script is Logos. You get: let / table / for / while / fn / if / str() / arithmetic and string ops. The Logos language reference → is the full spec — variables, stdlib, and use "name" import rules.

Logos in a kyfram script
let colors = ["coral", "gold", "mint"]
for i, c in colors {
  shape("dot"+str(i), "circle", table{
    "0": table{x: 80 + i*120, y: 180, r: 30, color: c},
    "2": table{x: 80 + i*120, y: 180, r: 50, color: c},
  })
}
// Logos runs it. kyfram only sees the three shape() calls.

transparent()

transparent(true)
transparent(true)   -- clear with RGBA(0,0,0,0), for stills/overlays

Only meaningful with --format png — mp4 encoding re-adds yuv420p, which has no alpha.

text_file() and --var

one script, many videos
text("title", table{
    "0": table{content: title, x: 320, y: 150, size: 44, color: "gold"},
    "2": table{content: title, x: 320, y: 150, size: 44, color: "gold"},
})
text("note", table{
    "0": table{content: text_file("examples/assets/note.txt"), x: 320, y: 220, size: 18, color: "silver"},
})
render with variables
kyfram render --var title=kyfram --var title=Cube examples/vars.lgv

CLI flags set template variables via --var k=v (repeatable) for non-programmers. text_file(path) reads a text file (capped at 1MB) so long copy stays out of the script.

tts()

tts(text, outPath[, opts])
let v = tts("hey my name is uthman welcome to kyfram", "/tmp/voice.wav")
scene(v.duration + 0.5, 30)
audio(v.audio)
for i, w in v.words {
  text("w" + str(i), table{
    str(w.start): table{content: w.word, x: 320, y: 180, size: 56, color: "white", alpha: 0},
    str(w.start + 0.08): table{content: w.word, x: 320, y: 180, size: 56, color: "white", alpha: 1},
  })
}

Synthesizes speech and returns a table: audio (path), duration (seconds), words — an array of {word, start, end} spans for word-synced captions. Fallback chain: Edge neural TTS (needs network + python3 + edge-tts) → espeak-ng (local) → beeps (pure Go).

Options: pass a voice string shorthand ("en-US-JennyNeural") or a table of voice/rate/pitch/volume. tts_voices() lists available neural voices.

use imports and helpers

helpers/spike.lgv
use "helpers/spike"
use "helpers/cube"
scene(10, 30)

use "name" inlines name.lgv beside the script (std/ imports stay for Logos itself), each file once, cycles rejected. helper files are just functions that call shape() — like spikeAt(name, arrival, color) and addJump(keys, t, rot) used by dash.lgv.

Note: Declarations-only is a convention, not a sandbox rule. The builder ignores everything except declaration calls, so loops and ifs are fine — they just generate more declarations.