Log inGet started

av

Updated October 3, 2026

Audio/video encode + mux control — live streaming, recording, encoder/muxer primitives.

require("@builtin::modules.api.engine.av") -- av (also available as global 'av')

The av namespace: 24 functions.

globals/av/is_live

av.is_live() -> boolean

True if a live-stream session is currently active.

Returns boolean — Whether the live encoder is running.

if av.is_live() then av.stop_live() end

globals/av/is_recording

av.is_recording() -> boolean

True if a recording session is currently active.

Returns boolean — Whether a recording is in progress.

print("recording:", av.is_recording())

globals/av/live

av.live(opts: LiveOpts?) -> (string?, string?)

Start the live-stream encoder. The stream is served at /engine/live.stream and reverse-proxied at /stream/<instance>/live.stream as a binary length-prefixed protocol consumed by the multiviewer UI's WebCodecs decoder. A browser engine encodes it with the browser's WebCodecs encoder, and the proxy reads it over the ZeroMind bridge and serves it at the same /stream/<instance>/live.stream. When texture_handle is set, the encoder reads from that GPU texture's guid (a Camera pointed at it via setTargetTexture) instead of the scene's viewport: that's how spectator cameras work. A stream runs beside an av.record take. Returns the stream URL once the encoder is open and streaming, or nil and the reason it is not: no encoder on this engine that streams H.264, a stream already running, an odd dimension, a session the encoder refuses (a size it will not encode, a spectator texture with no size to stream at). The engine opens the encoder, so the call waits for it: run it where it can yield, wrapping it in task.spawn from a callback that cannot. The reason also stands on av.status().liveError, which is where a stream that ends on a failure later says why.

Parameters

  • opts LiveOpts (optional) — Encoder options.

Returns (string?, string?) — Stream URL, or nil. Why the stream was refused, when it was.

local url, why = av.live({ width = 1280, height = 720, fps = 60 })

globals/av/record

av.record(path: string, opts: RecordOpts?) -> (string?, string?, Durability?)

Start recording the engine output to a VFS path. Default dir is /zero/runtime/recordings/ when path is not absolute: that directory is writable in every mode and stays out of the world's sync, so a take named relative to it is kept for the session and read back with vfs.read. A destination under /zero/source/ is world content, which the world's sync uploads when the take is filed. The take runs until av.stop_recording() unless opts bounds it with max_duration_sec (seconds of the take's own clock) or frames (captured frames); max_duration_sec wins when both are given, and the bound in force reads back as av.status().recordingBound. With no chroma/range opts the format defaults to full-range 4:4:4 HEVC where the GPU supports it, else 4:2:0. In the browser the browser's WebCodecs encoder converts the picture itself, so the take is 4:2:0 in the code range that encoder chooses, which an H.264 or AV1 file states, and grain above 0, chroma = "444" and any stated range are refused by name, and the master mix is encoded by the browser's WebCodecs AudioEncoder. On the "software" backend the take is H.264 encoded on the CPU, which costs the run it records: read av.status().recordingAchievedFps against recordingRequestedFps to see the rate it reached. What the take did with the master mix reads back as av.status().recordingAudio. cadence picks the clock the take stamps its frames from. "realtime" (the default) stamps each frame with the wall-clock slot it was captured in, so a recorded session is watched back at the speed it happened and an engine ticking under fps leaves slots empty. "frame" stamps every rendered frame one fixed slot after the last, so a timeline whose own clock advances a step per rendered frame — a cutscene, a scripted demo, anything on a fixed timestep — is delivered at the length that timeline runs to, however slowly the engine drew it: a take of frames = n at fps is n / fps seconds of film, and a max_duration_sec bound counts that film's seconds. A "frame" take records silent, because the master mix plays in wall-clock seconds and cannot share a file with a fixed-step picture; recordingAudio says so, and an explicit audio = true beside it is refused. Record the sound as a second "realtime" take. camera names the camera the take draws its film from — an entity proxy, an entity id, or an entity name. That camera holds the viewport for as long as the take runs, above the priority contest and above camera.setEditorOverride, so the film is its view and the frames carry everything the presented frame carries. Only frames that camera drew go into the film, and a camera that never draws the viewport ends the take with the reason on av.status().recordingError — 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. It reads back as av.status().recordingCamera while the take runs. Omitted, the take records whichever camera holds the viewport, which in an engine on the editor profile is the editor's own fly camera rather than the scene's. renderLayers is the render-layer include spec the viewport is drawn under for as long as the take runs — the same token string a capture takes: all seeds every layer, name adds one and !name drops one, so "all !EditorUI !debug" films the scene without the editor's chrome or the authoring overlays (gizmos, light and probe icons, frustums, collider wireframes) over it, and "all !ui !EditorUI !debug" drops the authored HUD as well. The viewport admits geometry and screens by that one spec, so it states the whole picture. It belongs to the take: nothing is written to the camera it is stated against, and the moment the take ends — its bound reached, stopped, or refused — the viewport is back under the camera's own spec. While a take states layers, the window shows what the film holds, and av.status().recordingLayers reads the spec back. Omitted, the take records the engine output as presented. width / height are the film's size: the whole of the presented frame (in an editor layout, the rect the scene is drawn into) is resampled to it, and a side left out takes the presented frame's. Returns the destination path of a session that is open and recording, or nil and the reason it is not: an adapter that cannot encode, a take already running, an option the encoder rejects, a resolution the device refuses, a destination the VFS cannot write (a read-only path, a directory, a host filesystem path). The engine opens the session, so the call waits for it: run it where it can yield, wrapping it in task.spawn from a callback that cannot. How a take finished reads back as av.status().recordingEnd; a refused request puts its reason there and on recordingError and leaves no take report behind, while a request refused because a take is already running leaves that take's report as it is.

Parameters

  • path string — VFS destination path, returned in its /zero form; a relative name records under /zero/runtime/recordings/.
  • opts RecordOpts (optional) — Encoder options (optional).

Returns (string?, string?, Durability?) — Destination VFS path of the open recording, or nil. Why the recording was refused, when it was. Where the film will land, as a THIRD return value: { durable = true } when the take is filed where this 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. The take is filed when it ENDS, so this is the verdict a write landing there gets on the terms standing at THIS call, answered here because here is where a caller can still act on it: a film recorded during play is kept with vfs.promotePlayShadow(path) and play keeps running. durable is present whatever the answer is, so its absence is never a reading. It is the same answer, in the same words, that asset.create returns and the MCP write_file / capture tools attach to their own results. A take opened in edit that runs on into play is filed on play's terms, so av.status().recordingDurability — which answers for the take still running — is where a take spanning a mode change reads.

local clip = av.record("intro.mp4", { fps = 60 })
local film = av.record("cut.mp4", { fps = 24, frames = 24 * 181, cadence = "frame" })
local clean = av.record("take.mp4", { fps = 24, renderLayers = "all !EditorUI !debug" })
local shot = av.record("film.mp4", { fps = 24, frames = 240, camera = "FilmCamera" })
local take, why, d = av.record("take.mp4", { fps = 24 })
if d and d.playShadow then log.warn(d.warning .. " " .. d.playShadow) end

globals/av/save

av.save(path: string, opts: SaveOpts?) -> (boolean, string, SaveReport?)

Save a finished take to the device of the person at the screen, so the recording outlives the session: /zero/runtime/recordings/ is gone when the engine stops (and, in the browser, when the tab closes). path names the take the way av.record did: a relative name is the take of that name under /zero/runtime/recordings/, and any VFS path works too. 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 call it from a button's click handler; 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 they save with a click of their own, because a browser starts a download only for a person who just acted. A native engine opens the host's save dialog in their downloads folder, with 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. The call waits for the first answer, so run it where it can yield (task.spawn from a callback). Returns true, a sentence saying where the take went, and the report { state, detail, name, size, path } with state "saved" (on the device) or "offered" (in front of the person, as the page's offer or the save dialog); or false and why it was refused: a take still recording (it is filed when it ends), no file at the path, a host path, a name with a folder in it, no window, or a take too large for the browser to hold, which the page shows the person as well. The saved file is the take byte for byte. userfile.save saves any other file the engine holds.

Parameters

  • path string — The take: its name under /zero/runtime/recordings/, or a VFS path.
  • opts SaveOpts (optional) — { name }, the file name the device gets (the take's own when omitted).

Returns (boolean, string, SaveReport?) — Whether the device took the take. Where it went, or why it was refused. The save report, when the device took the take.

local ok, where = av.save("take.mp4")
local ok, where, report = av.save("take.mp4", { name = "boss-fight.mp4" })

globals/av/status

av.status() -> AvStatus

Report the encoder subsystem's state. Always available regardless of GPU support. backend is the encode backend in use: "vulkan" or "vaapi" on an adapter with a media engine, "software" where a native engine encodes on the CPU, and "webcodecs" in the browser, where the browser's own WebCodecs encoder takes the frames. hardware is true for the first two, and in the browser when the browser reports a hardware encoder for the default codec, so a caller that pays for the take in engine time knows which it is getting. codecs lists what the backend encodes with the recording default first (h264 in the browser, which every engine plays back with video.create). reason is "supported", "not_probed" before the engine has asked its encoder, "unsupported_adapter" for a native adapter with no encoder, or "unsupported_browser" for a browser whose WebCodecs encodes none of the codecs the engine offers. live is true while the av.live stream is running, and liveError is why the most recent stream was refused or ended on a failure: nil while one runs and after av.stop_live() ended one that had opened, and the reason that names av.stop_live() after it ended one still opening. A take of its own reads back on the recording fields: recording is the destination of the take in flight, recordingBound what will end it, recordingEnd how the most recent one ended, and recordingError why one produced no file. recordingAudio is the codec the take is writing the master mix with ("opus"), or the reason the file carries no audio track — read it to tell a film with a soundtrack from a silent one. recordingLayers is the render-layer include spec the armed take is drawing the viewport under, in the words its caller wrote, and nil for a take that stated none — the reading that answers what is in the picture rather than how much of it there is. recordingCamera is the entity id of 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 a film holds whether or not the take named a camera. recordingCadence is the clock the take stamps its frames from, "realtime" or "frame", and so what the tally below is a reading against. What the take produced reads off recordingFrames, recordingBytes (every byte the take has produced so far, climbing while it runs and ending equal to the size of the file), recordingSeconds (the timeline those frames cover), recordingAchievedFps (the rate they arrived at) and recordingRequestedFps (the rate asked for) — live while a take runs, and its final tally once it ends. On "realtime" the requested rate is a ceiling and an engine ticking under it reaches less; on "frame" every rendered frame is a slot of the recorded timeline, so recordingSeconds is that timeline's length and recordingAchievedFps is the rate it plays back at. recordingDurability is where the film in flight lands on the terms standing now — the report av.record returns as its third value, asked again for the take still running, so a caller polling status learns the play shadow is taking the film in time to keep it, a take that entered play after it opened included. It is present while a take names a destination.

Returns AvStatus — Encoder status table.

local s = av.status(); print(s.backend, s.hardware, s.recordingAudio)

globals/av/stop_live

av.stop_live() -> boolean

Stop any active live-stream session.

Returns boolean — True if a session was stopped, false if none was active.

av.stop_live()

globals/av/stop_recording

av.stop_recording(handle: string?) -> (boolean, string?)

Stop the active recording (or the one for the given promise handle). Returns true when a recording was armed at call time. A false return carries a second value naming how the most recent recording already ended (the bound it reached, or the failure that cut it short), and nil when no recording has run at all. The engine finalizes the take once the call returns. A take stopped before its first frame is captured runs on until that frame lands and ends holding it, and one whose first frame has not landed 120 frames after the stop ends with none. Wait for av.is_recording() to go false, then read what it produced off av.status().

Parameters

  • handle string (optional) — Promise handle of a specific recording (optional).

Returns (boolean, string?) — True if a recording was stopped. How the most recent recording ended, when nothing was armed.

local stopped, ended = av.stop_recording()

modules/av/is_live

is_live(): boolean

True if a live-stream session is currently active.

if av.is_live() then av.stop_live() end

modules/av/is_recording

is_recording(): boolean

True if a recording session is currently active.

print("recording:", av.is_recording())

modules/av/live

live(opts: LiveOpts?): (string?, string?)

Start the live-stream encoder. The stream is served at /engine/live.stream and reverse-proxied at /stream/<instance>/live.stream as a binary length-prefixed protocol consumed by the multiviewer UI's WebCodecs decoder. A browser engine encodes it with the browser's WebCodecs encoder, and the proxy reads it over the ZeroMind bridge and serves it at the same /stream/<instance>/live.stream. When texture_handle is set, the encoder reads from that GPU texture's guid (a Camera pointed at it via setTargetTexture) instead of the scene's viewport: that's how spectator cameras work. A stream runs beside an av.record take. Returns the stream URL once the encoder is open and streaming, or nil and the reason it is not: no encoder on this engine that streams H.264, a stream already running, an odd dimension, a session the encoder refuses (a size it will not encode, a spectator texture with no size to stream at). The engine opens the encoder, so the call waits for it: run it where it can yield, wrapping it in task.spawn from a callback that cannot. The reason also stands on av.status().liveError, which is where a stream that ends on a failure later says why.

Parameters

  • opts LiveOpts? (optional) — Encoder options.
local url, why = av.live({ width = 1280, height = 720, fps = 60 })

modules/av/record

record(path: string, opts: RecordOpts?): (string?, string?, Durability?)

Start recording the engine output to a VFS path. Default dir is /zero/runtime/recordings/ when path is not absolute: that directory is writable in every mode and stays out of the world's sync, so a take named relative to it is kept for the session and read back with vfs.read. A destination under /zero/source/ is world content, which the world's sync uploads when the take is filed. The take runs until av.stop_recording() unless opts bounds it with max_duration_sec (seconds of the take's own clock) or frames (captured frames); max_duration_sec wins when both are given, and the bound in force reads back as av.status().recordingBound. With no chroma/range opts the format defaults to full-range 4:4:4 HEVC where the GPU supports it, else 4:2:0. In the browser the browser's WebCodecs encoder converts the picture itself, so the take is 4:2:0 in the code range that encoder chooses, which an H.264 or AV1 file states, and grain above 0, chroma = "444" and any stated range are refused by name, and the master mix is encoded by the browser's WebCodecs AudioEncoder. On the "software" backend the take is H.264 encoded on the CPU, which costs the run it records: read av.status().recordingAchievedFps against recordingRequestedFps to see the rate it reached. What the take did with the master mix reads back as av.status().recordingAudio. cadence picks the clock the take stamps its frames from. "realtime" (the default) stamps each frame with the wall-clock slot it was captured in, so a recorded session is watched back at the speed it happened and an engine ticking under fps leaves slots empty. "frame" stamps every rendered frame one fixed slot after the last, so a timeline whose own clock advances a step per rendered frame — a cutscene, a scripted demo, anything on a fixed timestep — is delivered at the length that timeline runs to, however slowly the engine drew it: a take of frames = n at fps is n / fps seconds of film, and a max_duration_sec bound counts that film's seconds. A "frame" take records silent, because the master mix plays in wall-clock seconds and cannot share a file with a fixed-step picture; recordingAudio says so, and an explicit audio = true beside it is refused. Record the sound as a second "realtime" take. camera names the camera the take draws its film from — an entity proxy, an entity id, or an entity name. That camera holds the viewport for as long as the take runs, above the priority contest and above camera.setEditorOverride, so the film is its view and the frames carry everything the presented frame carries. Only frames that camera drew go into the film, and a camera that never draws the viewport ends the take with the reason on av.status().recordingError — 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. It reads back as av.status().recordingCamera while the take runs. Omitted, the take records whichever camera holds the viewport, which in an engine on the editor profile is the editor's own fly camera rather than the scene's. renderLayers is the render-layer include spec the viewport is drawn under for as long as the take runs — the same token string a capture takes: all seeds every layer, name adds one and !name drops one, so "all !EditorUI !debug" films the scene without the editor's chrome or the authoring overlays (gizmos, light and probe icons, frustums, collider wireframes) over it, and "all !ui !EditorUI !debug" drops the authored HUD as well. The viewport admits geometry and screens by that one spec, so it states the whole picture. It belongs to the take: nothing is written to the camera it is stated against, and the moment the take ends — its bound reached, stopped, or refused — the viewport is back under the camera's own spec. While a take states layers, the window shows what the film holds, and av.status().recordingLayers reads the spec back. Omitted, the take records the engine output as presented. width / height are the film's size: the whole of the presented frame (in an editor layout, the rect the scene is drawn into) is resampled to it, and a side left out takes the presented frame's. Returns the destination path of a session that is open and recording, or nil and the reason it is not: an adapter that cannot encode, a take already running, an option the encoder rejects, a resolution the device refuses, a destination the VFS cannot write (a read-only path, a directory, a host filesystem path). The engine opens the session, so the call waits for it: run it where it can yield, wrapping it in task.spawn from a callback that cannot. How a take finished reads back as av.status().recordingEnd; a refused request puts its reason there and on recordingError and leaves no take report behind, while a request refused because a take is already running leaves that take's report as it is.

Parameters

  • path string — VFS destination path, returned in its /zero form; a relative name records under /zero/runtime/recordings/.
  • opts RecordOpts? (optional) — Encoder options (optional).
local clip = av.record("intro.mp4", { fps = 60 })
local film = av.record("cut.mp4", { fps = 24, frames = 24 * 181, cadence = "frame" })
local clean = av.record("take.mp4", { fps = 24, renderLayers = "all !EditorUI !debug" })
local shot = av.record("film.mp4", { fps = 24, frames = 240, camera = "FilmCamera" })
local take, why, d = av.record("take.mp4", { fps = 24 })
if d and d.playShadow then log.warn(d.warning .. " " .. d.playShadow) end

modules/av/save

save(path: string, opts: SaveOpts?): (boolean, string, SaveReport?)

Save a finished take to the device of the person at the screen, so the recording outlives the session: /zero/runtime/recordings/ is gone when the engine stops (and, in the browser, when the tab closes). path names the take the way av.record did: a relative name is the take of that name under /zero/runtime/recordings/, and any VFS path works too. 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 call it from a button's click handler; 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 they save with a click of their own, because a browser starts a download only for a person who just acted. A native engine opens the host's save dialog in their downloads folder, with 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. The call waits for the first answer, so run it where it can yield (task.spawn from a callback). Returns true, a sentence saying where the take went, and the report { state, detail, name, size, path } with state "saved" (on the device) or "offered" (in front of the person, as the page's offer or the save dialog); or false and why it was refused: a take still recording (it is filed when it ends), no file at the path, a host path, a name with a folder in it, no window, or a take too large for the browser to hold, which the page shows the person as well. The saved file is the take byte for byte. userfile.save saves any other file the engine holds.

Parameters

  • path string — The take: its name under /zero/runtime/recordings/, or a VFS path.
  • opts SaveOpts? (optional) — { name }, the file name the device gets (the take's own when omitted).
local ok, where = av.save("take.mp4")
local ok, where, report = av.save("take.mp4", { name = "boss-fight.mp4" })

modules/av/status

status(): AvStatus

Report the encoder subsystem's state. Always available regardless of GPU support. backend is the encode backend in use: "vulkan" or "vaapi" on an adapter with a media engine, "software" where a native engine encodes on the CPU, and "webcodecs" in the browser, where the browser's own WebCodecs encoder takes the frames. hardware is true for the first two, and in the browser when the browser reports a hardware encoder for the default codec, so a caller that pays for the take in engine time knows which it is getting. codecs lists what the backend encodes with the recording default first (h264 in the browser, which every engine plays back with video.create). reason is "supported", "not_probed" before the engine has asked its encoder, "unsupported_adapter" for a native adapter with no encoder, or "unsupported_browser" for a browser whose WebCodecs encodes none of the codecs the engine offers. live is true while the av.live stream is running, and liveError is why the most recent stream was refused or ended on a failure: nil while one runs and after av.stop_live() ended one that had opened, and the reason that names av.stop_live() after it ended one still opening. A take of its own reads back on the recording fields: recording is the destination of the take in flight, recordingBound what will end it, recordingEnd how the most recent one ended, and recordingError why one produced no file. recordingAudio is the codec the take is writing the master mix with ("opus"), or the reason the file carries no audio track — read it to tell a film with a soundtrack from a silent one. recordingLayers is the render-layer include spec the armed take is drawing the viewport under, in the words its caller wrote, and nil for a take that stated none — the reading that answers what is in the picture rather than how much of it there is. recordingCamera is the entity id of 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 a film holds whether or not the take named a camera. recordingCadence is the clock the take stamps its frames from, "realtime" or "frame", and so what the tally below is a reading against. What the take produced reads off recordingFrames, recordingBytes (every byte the take has produced so far, climbing while it runs and ending equal to the size of the file), recordingSeconds (the timeline those frames cover), recordingAchievedFps (the rate they arrived at) and recordingRequestedFps (the rate asked for) — live while a take runs, and its final tally once it ends. On "realtime" the requested rate is a ceiling and an engine ticking under it reaches less; on "frame" every rendered frame is a slot of the recorded timeline, so recordingSeconds is that timeline's length and recordingAchievedFps is the rate it plays back at. recordingDurability is where the film in flight lands on the terms standing now — the report av.record returns as its third value, asked again for the take still running, so a caller polling status learns the play shadow is taking the film in time to keep it, a take that entered play after it opened included. It is present while a take names a destination.

local s = av.status(); print(s.backend, s.hardware, s.recordingAudio)

modules/av/stop_live

stop_live(): boolean

Stop any active live-stream session.

av.stop_live()

modules/av/stop_recording

stop_recording(handle: string?): (boolean, string?)

Stop the active recording (or the one for the given promise handle). Returns true when a recording was armed at call time. A false return carries a second value naming how the most recent recording already ended (the bound it reached, or the failure that cut it short), and nil when no recording has run at all. The engine finalizes the take once the call returns. A take stopped before its first frame is captured runs on until that frame lands and ends holding it, and one whose first frame has not landed 120 frames after the stop ends with none. Wait for av.is_recording() to go false, then read what it produced off av.status().

Parameters

  • handle string? (optional) — Promise handle of a specific recording (optional).
local stopped, ended = av.stop_recording()

typed/builtin//modules/api/engine/av/av/is_live

av.is_live() -> boolean

True if a live-stream session is currently active.

Returns boolean — Whether the live encoder is running.

if av.is_live() then av.stop_live() end

typed/builtin//modules/api/engine/av/av/is_recording

av.is_recording() -> boolean

True if a recording session is currently active.

Returns boolean — Whether a recording is in progress.

print("recording:", av.is_recording())

typed/builtin//modules/api/engine/av/av/live

av.live(opts: LiveOpts?) -> (string?, string?)

Start the live-stream encoder. The stream is served at /engine/live.stream and reverse-proxied at /stream/<instance>/live.stream as a binary length-prefixed protocol consumed by the multiviewer UI's WebCodecs decoder. A browser engine encodes it with the browser's WebCodecs encoder, and the proxy reads it over the ZeroMind bridge and serves it at the same /stream/<instance>/live.stream. When texture_handle is set, the encoder reads from that GPU texture's guid (a Camera pointed at it via setTargetTexture) instead of the scene's viewport: that's how spectator cameras work. A stream runs beside an av.record take. Returns the stream URL once the encoder is open and streaming, or nil and the reason it is not: no encoder on this engine that streams H.264, a stream already running, an odd dimension, a session the encoder refuses (a size it will not encode, a spectator texture with no size to stream at). The engine opens the encoder, so the call waits for it: run it where it can yield, wrapping it in task.spawn from a callback that cannot. The reason also stands on av.status().liveError, which is where a stream that ends on a failure later says why.

Parameters

  • opts LiveOpts (optional) — Encoder options.

Returns (string?, string?) — Stream URL, or nil. Why the stream was refused, when it was.

local url, why = av.live({ width = 1280, height = 720, fps = 60 })

typed/builtin//modules/api/engine/av/av/record

av.record(path: string, opts: RecordOpts?) -> (string?, string?, Durability?)

Start recording the engine output to a VFS path. Default dir is /zero/runtime/recordings/ when path is not absolute: that directory is writable in every mode and stays out of the world's sync, so a take named relative to it is kept for the session and read back with vfs.read. A destination under /zero/source/ is world content, which the world's sync uploads when the take is filed. The take runs until av.stop_recording() unless opts bounds it with max_duration_sec (seconds of the take's own clock) or frames (captured frames); max_duration_sec wins when both are given, and the bound in force reads back as av.status().recordingBound. With no chroma/range opts the format defaults to full-range 4:4:4 HEVC where the GPU supports it, else 4:2:0. In the browser the browser's WebCodecs encoder converts the picture itself, so the take is 4:2:0 in the code range that encoder chooses, which an H.264 or AV1 file states, and grain above 0, chroma = "444" and any stated range are refused by name, and the master mix is encoded by the browser's WebCodecs AudioEncoder. On the "software" backend the take is H.264 encoded on the CPU, which costs the run it records: read av.status().recordingAchievedFps against recordingRequestedFps to see the rate it reached. What the take did with the master mix reads back as av.status().recordingAudio. cadence picks the clock the take stamps its frames from. "realtime" (the default) stamps each frame with the wall-clock slot it was captured in, so a recorded session is watched back at the speed it happened and an engine ticking under fps leaves slots empty. "frame" stamps every rendered frame one fixed slot after the last, so a timeline whose own clock advances a step per rendered frame — a cutscene, a scripted demo, anything on a fixed timestep — is delivered at the length that timeline runs to, however slowly the engine drew it: a take of frames = n at fps is n / fps seconds of film, and a max_duration_sec bound counts that film's seconds. A "frame" take records silent, because the master mix plays in wall-clock seconds and cannot share a file with a fixed-step picture; recordingAudio says so, and an explicit audio = true beside it is refused. Record the sound as a second "realtime" take. camera names the camera the take draws its film from — an entity proxy, an entity id, or an entity name. That camera holds the viewport for as long as the take runs, above the priority contest and above camera.setEditorOverride, so the film is its view and the frames carry everything the presented frame carries. Only frames that camera drew go into the film, and a camera that never draws the viewport ends the take with the reason on av.status().recordingError — 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. It reads back as av.status().recordingCamera while the take runs. Omitted, the take records whichever camera holds the viewport, which in an engine on the editor profile is the editor's own fly camera rather than the scene's. renderLayers is the render-layer include spec the viewport is drawn under for as long as the take runs — the same token string a capture takes: all seeds every layer, name adds one and !name drops one, so "all !EditorUI !debug" films the scene without the editor's chrome or the authoring overlays (gizmos, light and probe icons, frustums, collider wireframes) over it, and "all !ui !EditorUI !debug" drops the authored HUD as well. The viewport admits geometry and screens by that one spec, so it states the whole picture. It belongs to the take: nothing is written to the camera it is stated against, and the moment the take ends — its bound reached, stopped, or refused — the viewport is back under the camera's own spec. While a take states layers, the window shows what the film holds, and av.status().recordingLayers reads the spec back. Omitted, the take records the engine output as presented. width / height are the film's size: the whole of the presented frame (in an editor layout, the rect the scene is drawn into) is resampled to it, and a side left out takes the presented frame's. Returns the destination path of a session that is open and recording, or nil and the reason it is not: an adapter that cannot encode, a take already running, an option the encoder rejects, a resolution the device refuses, a destination the VFS cannot write (a read-only path, a directory, a host filesystem path). The engine opens the session, so the call waits for it: run it where it can yield, wrapping it in task.spawn from a callback that cannot. How a take finished reads back as av.status().recordingEnd; a refused request puts its reason there and on recordingError and leaves no take report behind, while a request refused because a take is already running leaves that take's report as it is.

Parameters

  • path string — VFS destination path, returned in its /zero form; a relative name records under /zero/runtime/recordings/.
  • opts RecordOpts (optional) — Encoder options (optional).

Returns (string?, string?, Durability?) — Destination VFS path of the open recording, or nil. Why the recording was refused, when it was. Where the film will land, as a THIRD return value: { durable = true } when the take is filed where this 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. The take is filed when it ENDS, so this is the verdict a write landing there gets on the terms standing at THIS call, answered here because here is where a caller can still act on it: a film recorded during play is kept with vfs.promotePlayShadow(path) and play keeps running. durable is present whatever the answer is, so its absence is never a reading. It is the same answer, in the same words, that asset.create returns and the MCP write_file / capture tools attach to their own results. A take opened in edit that runs on into play is filed on play's terms, so av.status().recordingDurability — which answers for the take still running — is where a take spanning a mode change reads.

local clip = av.record("intro.mp4", { fps = 60 })
local film = av.record("cut.mp4", { fps = 24, frames = 24 * 181, cadence = "frame" })
local clean = av.record("take.mp4", { fps = 24, renderLayers = "all !EditorUI !debug" })
local shot = av.record("film.mp4", { fps = 24, frames = 240, camera = "FilmCamera" })
local take, why, d = av.record("take.mp4", { fps = 24 })
if d and d.playShadow then log.warn(d.warning .. " " .. d.playShadow) end

typed/builtin//modules/api/engine/av/av/save

av.save(path: string, opts: SaveOpts?) -> (boolean, string, SaveReport?)

Save a finished take to the device of the person at the screen, so the recording outlives the session: /zero/runtime/recordings/ is gone when the engine stops (and, in the browser, when the tab closes). path names the take the way av.record did: a relative name is the take of that name under /zero/runtime/recordings/, and any VFS path works too. 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 call it from a button's click handler; 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 they save with a click of their own, because a browser starts a download only for a person who just acted. A native engine opens the host's save dialog in their downloads folder, with 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. The call waits for the first answer, so run it where it can yield (task.spawn from a callback). Returns true, a sentence saying where the take went, and the report { state, detail, name, size, path } with state "saved" (on the device) or "offered" (in front of the person, as the page's offer or the save dialog); or false and why it was refused: a take still recording (it is filed when it ends), no file at the path, a host path, a name with a folder in it, no window, or a take too large for the browser to hold, which the page shows the person as well. The saved file is the take byte for byte. userfile.save saves any other file the engine holds.

Parameters

  • path string — The take: its name under /zero/runtime/recordings/, or a VFS path.
  • opts SaveOpts (optional) — { name }, the file name the device gets (the take's own when omitted).

Returns (boolean, string, SaveReport?) — Whether the device took the take. Where it went, or why it was refused. The save report, when the device took the take.

local ok, where = av.save("take.mp4")
local ok, where, report = av.save("take.mp4", { name = "boss-fight.mp4" })

typed/builtin//modules/api/engine/av/av/status

av.status() -> AvStatus

Report the encoder subsystem's state. Always available regardless of GPU support. backend is the encode backend in use: "vulkan" or "vaapi" on an adapter with a media engine, "software" where a native engine encodes on the CPU, and "webcodecs" in the browser, where the browser's own WebCodecs encoder takes the frames. hardware is true for the first two, and in the browser when the browser reports a hardware encoder for the default codec, so a caller that pays for the take in engine time knows which it is getting. codecs lists what the backend encodes with the recording default first (h264 in the browser, which every engine plays back with video.create). reason is "supported", "not_probed" before the engine has asked its encoder, "unsupported_adapter" for a native adapter with no encoder, or "unsupported_browser" for a browser whose WebCodecs encodes none of the codecs the engine offers. live is true while the av.live stream is running, and liveError is why the most recent stream was refused or ended on a failure: nil while one runs and after av.stop_live() ended one that had opened, and the reason that names av.stop_live() after it ended one still opening. A take of its own reads back on the recording fields: recording is the destination of the take in flight, recordingBound what will end it, recordingEnd how the most recent one ended, and recordingError why one produced no file. recordingAudio is the codec the take is writing the master mix with ("opus"), or the reason the file carries no audio track — read it to tell a film with a soundtrack from a silent one. recordingLayers is the render-layer include spec the armed take is drawing the viewport under, in the words its caller wrote, and nil for a take that stated none — the reading that answers what is in the picture rather than how much of it there is. recordingCamera is the entity id of 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 a film holds whether or not the take named a camera. recordingCadence is the clock the take stamps its frames from, "realtime" or "frame", and so what the tally below is a reading against. What the take produced reads off recordingFrames, recordingBytes (every byte the take has produced so far, climbing while it runs and ending equal to the size of the file), recordingSeconds (the timeline those frames cover), recordingAchievedFps (the rate they arrived at) and recordingRequestedFps (the rate asked for) — live while a take runs, and its final tally once it ends. On "realtime" the requested rate is a ceiling and an engine ticking under it reaches less; on "frame" every rendered frame is a slot of the recorded timeline, so recordingSeconds is that timeline's length and recordingAchievedFps is the rate it plays back at. recordingDurability is where the film in flight lands on the terms standing now — the report av.record returns as its third value, asked again for the take still running, so a caller polling status learns the play shadow is taking the film in time to keep it, a take that entered play after it opened included. It is present while a take names a destination.

Returns AvStatus — Encoder status table.

local s = av.status(); print(s.backend, s.hardware, s.recordingAudio)

typed/builtin//modules/api/engine/av/av/stop_live

av.stop_live() -> boolean

Stop any active live-stream session.

Returns boolean — True if a session was stopped, false if none was active.

av.stop_live()

typed/builtin//modules/api/engine/av/av/stop_recording

av.stop_recording(handle: string?) -> (boolean, string?)

Stop the active recording (or the one for the given promise handle). Returns true when a recording was armed at call time. A false return carries a second value naming how the most recent recording already ended (the bound it reached, or the failure that cut it short), and nil when no recording has run at all. The engine finalizes the take once the call returns. A take stopped before its first frame is captured runs on until that frame lands and ends holding it, and one whose first frame has not landed 120 frames after the stop ends with none. Wait for av.is_recording() to go false, then read what it produced off av.status().

Parameters

  • handle string (optional) — Promise handle of a specific recording (optional).

Returns (boolean, string?) — True if a recording was stopped. How the most recent recording ended, when nothing was armed.

local stopped, ended = av.stop_recording()
  • api
  • reference