av
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
optsLiveOpts(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
pathstring— VFS destination path, returned in its/zeroform; a relative name records under/zero/runtime/recordings/.optsRecordOpts(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
pathstring— The take: its name under/zero/runtime/recordings/, or a VFS path.optsSaveOpts(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
handlestring(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
optsLiveOpts?(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
pathstring— VFS destination path, returned in its/zeroform; a relative name records under/zero/runtime/recordings/.optsRecordOpts?(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
pathstring— The take: its name under/zero/runtime/recordings/, or a VFS path.optsSaveOpts?(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
handlestring?(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
optsLiveOpts(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
pathstring— VFS destination path, returned in its/zeroform; a relative name records under/zero/runtime/recordings/.optsRecordOpts(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
pathstring— The take: its name under/zero/runtime/recordings/, or a VFS path.optsSaveOpts(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
handlestring(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()