Scripting.
Every declaration kyfram understands, and the rules that govern how values move between keyframes.
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.
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(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.
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).
// 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("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
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 type | Behaviour |
|---|---|
| numbers (x, y, r, size, alpha…) | linear interpolation between keyframes |
| hex / named colors | per-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.
| ease | shape |
|---|---|
| linear | default; no easing |
| easeIn | starts slow, accelerates |
| easeOut | starts fast, settles |
| easeInOut | slow start and slow end |
| cubicIn / cubicOut / cubicInOut | cubic variants, stronger curves |
| sine | sinusoidal, softest |
Colors
Hex #rrggbb or #rgb, plus named colors. Unknown strings just aren't colors — they snap like any other string.
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
| prop | applies to | default | behaves |
|---|---|---|---|
| x, y | all | varies by kind | position; rect/roundRect use top-left, others use center |
| alpha | all but image | 1 | real transparency; multiplies through groups |
| rotate | all | 0 | degrees around the entity anchor |
| scale | all | 1 | uniform scale around the anchor |
| layer | all | 0 | draw order — higher on top |
| blend | primitive fills | — | "multiply" | "screen" | "add" compositing |
| gradFrom, gradTo, gradAngle | filled kinds | — | linear gradient inside the shape bbox |
text()
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("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(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("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.
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) -- 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
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"},
})
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()
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
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.