Log inGet started
World

Builtin Library

Branchmain
Branchesmainpublish
Commit3bb1da2647

README.md

av Module

Public Luau surface over the __av Internal FFI namespace — audio / video encode and mux control.

Purpose

Wrap the raw __av.* FFI namespace (backed by zero_av) in a typed Luau table that gets auto-injected as _G.av via the prelude. Mirrors libav's split: tier-1 encoder/muxer primitives produce/consume codec-agnostic packets; tier-2 convenience composites (av.record, av.live) wrap both for the common cases.

Always check av.status().supported before kicking off a live stream or recording — encode requires GPU support that some hosts don't have, and unsupported instances return clear errors rather than crashing.

Usage

local s = av.status()
if not s.supported then warn("encode unavailable: " .. s.reason); return end

-- Live spectator stream over a Camera's offscreen texture. The call waits
-- for the engine to open the encoder, so a URL back means the stream is
-- running and a nil carries the reason it is not. A browser engine streams
-- through its WebCodecs encoder, and the proxy serves either engine's stream
-- at /stream/<instance>/live.stream.
local url, why = av.live({
    width = 1280, height = 720, fps = 60,
    texture_handle = spectatorCam.render_target,
})
if not url then warn("no live stream: " .. tostring(why)) end

-- Record a clip to VFS. A relative name records under
-- /zero/source/local/recordings/, so this take is filed at
-- /zero/source/local/recordings/intro.mp4. It runs until it is stopped; pass
-- `max_duration_sec` or `frames` in the opts to bound the take. The call
-- waits for the engine to open the session, so a path back means the
-- recording is running and a nil carries the reason it is not.
local clip, refused = av.record("intro.mp4", { fps = 60, quality = 90 })
if not clip then warn("no recording: " .. tostring(refused)); return end

-- Stop both
av.stop_live()
local stopped, ended = av.stop_recording()
if not stopped and ended then
    warn("the recording had already ended: " .. ended)
end

How a live stream ends

A live stream runs until av.stop_live() ends it or the engine can no longer stream it: its encoder or its frame capture fails, the render device goes out from under it, or, in the browser, a frame step holding it panics and the engine recovers the step and goes on. Every ending reads the same: av.is_live() and av.status().live go false, av.status().liveError names why, and a viewer of the stream sees it close. A request still opening when its stream ends is answered with the reason, and that reason stands on liveError. So av.stop_live() leaves liveError nil once the stream it ends had opened, and names itself there when it ends one still opening: that request is answered with nil and "av.stop_live() ended the stream before it opened". The next av.live opens a stream of its own.

Starting a recording

The encoder is built from the renderer's device on an engine tick, so av.record waits for that tick before it answers: it returns the destination of a session that is open and recording, or nil and the reason there is none: an adapter that cannot encode, a take already running (the reason names its destination), an option the encoder rejects, a resolution the device refuses, a destination the VFS cannot write. Because it waits, it runs where it can yield; from a callback that cannot, wrap it in task.spawn.

The destination is asked the question the film's write will ask, at the call, so a path that write would refuse (a read-only path such as /zero/tmp/..., a directory, engine bookkeeping, a frozen world version) is refused before a frame is encoded, and the reason names the directory a take can always be filed in. A host filesystem path such as /tmp/clip.mp4 is refused the same way: a take is filed in the engine's VFS, and read back with vfs.read or served from an httpServer route that answers { file = path }. Should the terms change while the take runs so that its destination refuses the film when it ends, the film is filed under /zero/runtime/recordings/ at the destination's file name instead, and av.status().recordingError names both the refusal and where the take is.

Recording in the browser

The browser build records through the browser's own WebCodecs VideoEncoder. av.status().backend reads "webcodecs" there, codecs lists what that browser encodes with "h264" first (the codec every engine plays back with video.create), and hardware says whether the browser has a hardware encoder for it. A browser whose WebCodecs encodes none of the codecs the engine offers reports supported = false with the reason "unsupported_browser", and av.record answers with a sentence naming what the browser said.

A take in the browser is the take a native engine records: the same options, refusals, destinations, cadences, bounds, camera and renderLayers, the same MP4, and the same av.status() fields while it runs and after it ends. The browser converts the frames it is handed to YUV itself, so a browser take is 4:2:0 in the code range the browser's encoder chooses, and the colr box of an H.264 or AV1 take states the range its stream was written in: grain above 0, chroma = "444" and any stated range are refused by name.

A browser take with audio left on carries the engine's master mix as an Opus track, encoded by the browser's WebCodecs AudioEncoder, and av.status().recordingAudio reads "opus". The track is the mix the page plays, from the take's first frame to the film's end on the same wall clock as the picture. The engine renders that mix whether or not the page's audio output has been allowed to start, so a take records it either way, and a silent world records a silent track. A browser whose AudioEncoder does not encode Opus records the picture, and recordingAudio reads "none: " with what the browser said.

The browser answers each frame on a later task than the one that drew it, so a take holds only the few frames on their way to the encoder. When the encoder has no room, a "realtime" take leaves that frame slot empty (as a native engine ticking under the requested rate does, and recordingAchievedFps reads it), and a "frame" take holds the engine's next frame until the encoder takes this one, so every step of the timeline is in the film. Closing a take waits for the encoder to hand back its last frames: av.is_recording() goes false once the file is filed.

Which picture a take films

scope chooses the picture on a windowed engine. "viewport", the default, films the scene view: where a Scene panel shows the scene, the panel's own picture, without the editor's panels and chrome around it, and where none does (a game window, a layout with no Scene panel), the window, which is the scene view then. "window" films the whole presented window, the editor's panels and whatever is docked in them beside the scene. An engine with no window films the scene either way.

-- The whole editor, panels and all.
av.record("tour.mp4", { scope = "window" })

A take films at the size its picture has when it starts, unless width and height say otherwise, and goes on at that size if the window or the panel is resized while it runs: each frame is resampled to it, so the file stays one video. A take that states a size other than its picture's is resampled to it (4:2:0 only: chroma = "444" takes its picture at its own size).

Which camera a take films

A take draws its film from the presented frame, so what a film holds is what the camera holding the viewport was looking at. In an engine on the editor profile that camera is the editor's own fly camera, whatever priority a scene camera carries — so a take made while authoring films the fly camera unless it says otherwise.

camera says otherwise. It names the camera the take draws from — an entity proxy, an entity id, or an entity name — and that camera holds the viewport for as long as the take runs, above the priority contest and above camera.setEditorOverride:

local film = av.record("film.mp4", {
    fps = 24, frames = 24 * 90, cadence = "frame",
    camera = "FilmCamera",
})

Only frames that camera drew go into the film. A camera that draws no viewport frame — disabled, deactivated, despawned, or rendering into a texture — ends the take with the reason on av.status().recordingError and no frames written, and an id no entity carries is refused at the call. So a take is the view it named, or it is no take.

The camera belongs to the take. Nothing is written to it, and the viewport is back under its own contest the moment the take ends, however it ends — there is nothing to put back.

av.status().recordingCamera reads the camera the take's most recent captured frame was drawn from, and stands as the source of the most recent take once that take has ended. It is read off the frame the engine drew, so it answers "which view did this film?" for a take that named a camera and for one that named none alike.

Where a take lands

A relative destination records under /zero/source/local/recordings/. That directory is this machine's own content: writable in every mode, play included, outside the world's sync, and kept across restarts, so a take filed there costs the world nothing, is still there in the next session, and is read back with vfs.read or served from an httpServer route that answers { file = path }.

A take never sits in the engine's memory. Its bytes go to disk as the encoder produces them, in a fragmented MP4, and the finished file is read from disk whenever something reads it, so a take of any length costs the engine a handle and a length. The same holds for a take named under /zero/runtime/recordings/ (the recorder's session-only directory, gone when the engine stops) and under /zero/source/tmp/. A take cut short by an engine that was killed plays up to its last fragment, and the next start files it beside where it was going, its name ending -recovered.

A destination under /zero/source/ is world content. The film is filed into the world's source tree when the take ends, and the world's sync uploads it with the rest of that tree, so every collaborator's session receives it and the world carries its size. Record there when the film belongs to the world; record under the recordings directory when it is a take to look at, download or publish elsewhere.

av.record returns where the film will land as a third value, beside the destination path: { durable = true } when the take is filed where the call named it and the next session reads it back, { durable = false, survivesRestart, warning, playShadow, shadowed } when the play shadow takes it: live in this session and discarded on a guarded play-exit unless kept, with survivesRestart saying whether a restart keeps it and playShadow naming the routes that keep it, and { durable = false, survivesRestart = false, warning, scratch } when it is filed in /source/tmp/ scratch, which is gone when the engine stops or restarts. Filming gameplay means filming in play, which is exactly when a /zero/source/ destination belongs to the shadow, so this is the field that says whether such a take survives leaving play; vfs.promotePlayShadow(path) keeps it with play still running.

A take is filed when it ENDS, so the report is the verdict the film's write gets on the terms standing at the call rather than a reading of bytes that are there: the answer vfs.durability(path, { pending = true }) gives, stated at the call because that is where the caller can still act on it. A take outlives those terms when the session enters play while it runs, and the film is filed on the terms in force then: av.status().recordingDurability asks again for the take still running, so that is where a take spanning a mode change reads.

local take, refused, landing = av.record("take.mp4", { fps = 24 })
if not take then warn("no recording: " .. tostring(refused)); return end
if landing and landing.playShadow then
    warn(landing.warning .. " " .. landing.playShadow)
    vfs.promotePlayShadow(take)
end

How a recording ends

A recording ends when it is stopped, or when a bound its caller set is reached. Both write the same complete MP4, so the status table names which happened: av.status().recordingBound is what will end the take in flight, recordingEnd is how the most recent one ended, and recordingError is why one produced no file. av.stop_recording() returns the ending as its second value when there was nothing left to stop, so a take that ran out its bound is distinguishable from one that never started.

A refused request settles the same fields: its reason becomes both recordingEnd and recordingError, and the take report — the frames, bytes, seconds and rate of the take before it — is cleared, so a refusal reads as a recording that produced no file rather than as the earlier take that did. A request refused because a take is already running leaves that take's report where the take put it; recording names the session those numbers belong to.

A take the engine loses on the way ends too. In the browser a frame step that panics is recovered and the page goes on; a take that step held reads recordingEnd "the take was lost", its recordingError names the panic and the path nothing was filed at, and av.is_recording() reads false, so the next av.record opens a take of its own.

Keeping a take

A take lives in /zero/source/local/recordings/ and is kept across restarts of the engine on this machine; in the browser the page's storage holds it until the browser clears it. av.save(path) puts a finished take on the device of the person at the screen, byte for byte, under the take's own name (or opts.name). The person decides where it lands:

  • In the browser, the take downloads at once when the save rides a click or key press of the person at the screen, so a "Save clip" button's click handler saves straight to their downloads. A save with no such gesture behind it (an agent, a timer, the end of a take) makes the page show the person an offer, which they save with a click of their own: a browser starts a download only for a person who just acted.
  • A native engine opens the host's save dialog in the person's downloads folder, the take's name filled in, and writes it where they choose. An engine with no window has no person at its screen and refuses.
-- In a button's click handler: the click is the gesture the download rides.
task.spawn(function()
    local ok, where, report = av.save("intro.mp4")
    if not ok then warn("not saved: " .. where) end
    -- report.state is "saved" (on the device) or "offered" (in front of the
    -- person, as the page's offer or the save dialog).
end)

It answers false and why for a take still recording (a take is filed when it ends), a path with no file, a host path, a name with a folder in it, and a take too large for the browser to hold, which the page shows the person too. userfile.save(path) saves any other file the engine holds. In the editor, the Files panel's right-click menu on a file holds "Save to this device", which saves it the same way.

Exports

  • av.status() -> AvStatus
  • av.is_live() -> boolean / av.is_recording() -> boolean
  • av.live(opts?) -> (string?, string?): start the live encoder; the stream URL once it is open, or nil and why it was refused, which also stands on av.status().liveError.
  • av.stop_live() -> boolean
  • av.record(path, opts?) -> (string?, string?, Durability?) — start recording; the destination path of the open session, or nil and why it was refused, and where the film will land. opts.camera names the camera it films.
  • av.stop_recording(handle?) -> (boolean, string?)
  • av.save(path, opts?) -> (boolean, string, SaveReport?): save a finished take to the device of the person at the screen; true, where it went and the report, or false and why it was refused.