movy v0.4.0 - The Glyph Layer & VideoExport
Text joins the pixel scene. Until now, text in movy lived inside the pixel
buffer: a character took its colors from the two pixels it covered and replaced
them. The new GlyphLayer keeps text separate - characters at
terminal-cell resolution, resolved over the half-block pixels only when the
frame is written to the terminal. Glow, gradients and trails now show through
behind text, and text can light up the scene in return.
It works with DiffOutput, toAnsi() and the Frame post-fx stack, costs
nothing for programs that don't use it, and ships with the glyph-decrypt
example, a full guide in doc/GlyphLayer.md, and a
headless ANSI-to-PNG tool.
And movy programs can now record themselves. VideoExport renders
every frame on a fixed clock into a PNG - pixels, text and glyphs, with a baked
font - and tools/makevideo.sh turns the frames into a 1080p60 mp4 with sound.
No screen capture, no dropped frames. The new glyph-reel showcase uses
both - and its video was made exactly that way.
The full version history lives in CHANGELOG.md.
GlyphLayer - text inside the scene
A GlyphLayer is a grid of cells, one per terminal cell. Each cell holds a
character, a text color and a background mode:
.pixels- the background is the average of the two pixels under the
cell, resolved every frame. Text you place once sits inside an animated scene
without being redrawn..solid- the cell uses its own background color.
Empty cells stay half-block pixels, exactly as before.
const glyphs = try movy.GlyphLayer.init(allocator, screen.w, screen.h / 2);
defer glyphs.deinit();
screen.output_surface.setGlyphs(glyphs);
glyphs.put(10, 5, 'A', .{ .r = 255, .g = 120, .b = 220 }); // over the pixels
_ = glyphs.putStr(10, 7, "text in the scene", .{ .r = 200, .g = 240, .b = 255 });
_ = glyphs.putStrSolid(10, 9, " SCORE 1200 ", .{}, .{ .r = 255, .g = 120, .b = 220 });Drawing calls: put, putSolid, putStr, putStrSolid (UTF-8, \n
returns to the start column), setFg, erase, clear. They all clip at the
edges. Double-width codepoints become ◉, the same rule putUtf8XY uses, so a
row never shifts.
Where it lives
The layer is attached to the surface that gets written to the terminal -
screen.output_surface - through the new RenderSurface.glyphs field and
setGlyphs(). It is resolved at output, not composited by the RenderEngine:
- Precedence, highest first:
char_maptext (UI / HUD) > glyphs > pixels.
Existing text keeps working and always stays on top. - One layer per screen. Draw several text "layers" into it in order; the
last write wins. - Pixels cannot cover glyphs - a sprite flying over text passes behind it.
Erase the cells it covers if it should be in front. - Debug builds catch mistakes:
Screen.render()panics with a clear
message if a glyph layer is attached to an input surface, where it would
otherwise be dropped silently.
DiffOutput and toAnsi() resolve glyphs
Both encoders resolve glyph cells over the final composited pixels.
- Changed-row detection includes glyphs. A row is re-sent only when its
pixels or its glyphs changed, so static text over a static background costs
zero bytes, and static text over an animated background costs the same as the
background alone. - Color codes that are already active are not re-sent, so a run of
same-colored text costs little more than its characters. - No layer, no cost.
DiffOutputis compiled in two versions; surfaces
without a glyph layer run the unchanged pixel loop with no per-cell glyph
test. At 200x50 cells with every row changing, the pixel-only path measures
the same as in 0.3.1 (98.0 us/frame on both). DiffOutput.initSize(allocator, w, h, mode)- new: a DiffOutput for a
surface size, without aScreen.
Frame integration - grading and glyph light
Tell the Frame about the layer, and glyphs become part of the neon look:
try frame.setGlyphs(glyphs); // grade glyphs in composite()
screen.output_surface.setGlyphs(glyphs); // resolve them at output
frame.beginFrame();
// ... draw pixels, put glyphs ...
frame.glyphGlow(0.025); // text blooms into the glow buffer
frame.gcell(x, y, movy.color.v3(0.5, 0.9, 1.0)); // one cell flares up
frame.composite();- Grading:
composite()runs glyph colors through vignette, warmth, flash
and tint, like the pixels (no scanline: a glyph is a whole cell). It writes
into the layer's separate output colors (fg_out/bg_out) and never into
the colors you set, so text you keep across frames is not graded again every
frame. A white flash now flashes the text too. - Glyph light:
glyphGlow(strength)adds every glyph's color to the
persistent glow buffer; its blur and decay turn that into a bloom halo, and
moving text leaves a light trail.gcell(x, y, color)lights up a single
cell. Keep strengths low (around0.02 - 0.05): the glow accumulates.
VideoExport - a movy program records itself
Drive your program on a fixed clock and hand each frame's surface to
writeFrame():
const ve = try movy.VideoExport.init(allocator, cols, rows, .{ .out_dir = "export" });
defer ve.deinit();
while (ve.frame_n < total_frames) {
const t = ve.time(); // frame_n / 60 - instead of the wall clock
drawMyScene(t);
try ve.writeFrame(surface); // -> export/frame_000000.png, ...
}tools/makevideo.sh export out.mp4 --audio music.wav # 1920x1080 @ 60fps H.264 + AAC- Terminal-true: the cell rules are DiffOutput's -
char_maptext >
glyphs (graded,.pixels/.solidbackgrounds) > half-block pixels. Block
elements and braille are drawn procedurally to fill the cell. - Fonts: JetBrains Mono (default) or DejaVu Sans Mono (the Menlo
look-alike), baked into cell-sized alpha bitmaps at 19x38 and 10x20 px.
tools/bakefont.pybakes any other monospace TTF, on any OS. - Pixel-pure 1080p: at 19x38 px per cell, half-block pixels are exact
19x19 squares; a 100x28 cell screen becomes 1900x1064 and pads to
1920x1080 with no rescaling. - Audio: frame 0 is t = 0, so a track starting at 0 is in sync by
construction;--offset MSnudges it, sample-accurate. - Stale frames from a longer previous export are removed by
init(). - No new dependencies - movy's bundled lodepng; the video step needs
ffmpeg. About 38 fps export at 1900x1064 (ReleaseFast).
Full guide: doc/VideoExport.md.
New example: glyph-reel
The v0.4.0 showcase, a ~40 second timeline: the news decrypts behind a scanner
beam, the movy logo rises from below the frame, a .solid bar fades in, a
glyph storm throws text off the wave crests, VideoExport decrypts behind a
reverse beam with a REC timecode, a v0.3.0 recap, and a closing card.
zig build run-glyph-reel # live, loops (needs 100x28)
zig build run-glyph-reel -- once # play once, hold the card
zig build run-glyph-reel -- pal ember # palettes: aurora, decrypt, ember
zig build -Doptimize=ReleaseFast && zig-out/bin/glyph-reel export
tools/makevideo.sh export glyph-reel.mp4 # the videoNew example: glyph-decrypt
A decrypt-style text reveal, TerminalTextEffects-style, in movy's neon look:
characters scramble through random glyphs, lock in left to right with a flash
of light, hold, and dissolve, while a scanner beam and a slowly drifting color
field show through behind them. A status bar shows the .solid background
mode.
zig build run-glyph-decrypt # ESC / q quits (needs 100x26)
zig build run-glyph-decrypt -- shot 0.3 out.ans # headless: write one frame's ANSINew tool: tools/ansi2html.py
Frame.savePng() has no font, so it cannot show glyphs. tools/ansi2html.py
renders movy's terminal output - toAnsi() and DiffOutput streams alike - to
HTML, and with --png to a screenshot via headless Chrome:
tools/ansi2html.py out.ans out.html --png out.pngDocs
- New guide: doc/VideoExport.md - the workflow,
options and fonts, cell rules, audio sync, and a quick reference. - New guide: doc/GlyphLayer.md - the workflow,
background modes, persistence and precedence, Frame grading and glyph glow,
performance, the headless dev loop, and a quick reference. - README: a Glyph Layer section with a code sample, and the glyph-decrypt
screenshot.
Behavior changes
None that should affect existing code:
RenderSurfacehas a new field,glyphs: ?*GlyphLayer, defaulting to
nulland set tonullbyinit(). Code that never attaches a layer
renders byte-for-byte as before.RenderSurface.isDoubleWidth()is now public (the GlyphLayer uses it).- New top-level exports
movy.VideoExportandmovy.video_export
(fonts and options) - nothing is compiled in unless you use them. Frame.composite()moved its grading into a shared helper; the math and
the output are unchanged.
The compositing path, the RenderEngine, char_map text, sprites, input and the
existing demos are untouched.
