Record your custom MediaSFU interface
Custom recording scenes let your finished recording match the interface your users experienced. You describe the composition once—main video, self-view, labels, status badges, and other visual layers—then send small ordered changes as the UI evolves.
The same scene format works from web, mobile, native, and game clients because it describes the result rather than a DOM, widget tree, or platform-specific view hierarchy. MediaSFU resolves the trusted room media on the server and publishes a standard H.264/AAC MP4.
How it works
- Your client creates a bounded
mediasfu.custom-ui-scene/v1document. - Your authenticated backend starts recording with that scene.
- The client sends ordered mutations when visible state changes.
- Pause, resume, and stop continue through the normal recording controls.
- Your backend waits for terminal completion before offering the MP4.
This is a server-enabled recording feature. Confirm that custom-scene recording is enabled for your MediaSFU deployment before adding the client integration.
Before you start
You need:
- a MediaSFU room with recording enabled;
- an authenticated application backend that can authorize the room host;
- a participant-consent policy for recording;
- custom-scene processing enabled on your MediaSFU server; and
- a plan for missing media, failed updates, and room teardown.
Keep reusable MediaSFU credentials, storage access, source binding, and output
authority on the server. The client sends logical source names such as
guest-camera or slides, never producer IDs, filesystem paths, or storage
credentials.
1. Describe the recording scene
This example creates a full-width remote camera with a mirrored local preview and a connection label:
export const scene = {
schemaVersion: "mediasfu.custom-ui-scene/v1",
sceneId: "support-call-v1",
canvas: {
width: 1280,
height: 720,
fps: 30,
durationMs: 3_600_000,
background: "#071713",
},
sources: [
{id: "guest-camera", kind: "video", role: "remote-camera", label: "Guest"},
{id: "my-camera", kind: "video", role: "local-camera", label: "You"},
],
layers: [
{
id: "guest-video",
type: "video",
zIndex: 0,
frame: {x: 0, y: 0, width: 1280, height: 720},
visible: true,
sourceId: "guest-camera",
fit: "cover",
cornerRadius: 0,
mirror: false,
tint: "#00000000",
},
{
id: "my-video",
type: "video",
zIndex: 10,
frame: {x: 976, y: 40, width: 264, height: 156},
visible: true,
sourceId: "my-camera",
fit: "cover",
cornerRadius: 20,
mirror: true,
tint: "#00000000",
},
{
id: "status",
type: "text",
zIndex: 20,
frame: {x: 40, y: 648, width: 420, height: 40},
visible: true,
content: "CONNECTED",
color: "#FFFFFF",
fontSize: 24,
weight: 800,
align: "left",
maxLines: 1,
},
],
timeline: [],
};
Only the local preview is mirrored. Remote cameras and screen content should remain unmirrored so text and gestures are recorded correctly.
The v1 format supports video, shape, and text layers. Frames must remain inside the canvas. Live mutations can change layer visibility, text content, shape fill, and shape opacity.
2. Start through your backend
The browser or app calls your own authenticated route:
import {scene} from "./recording-scene.mjs";
async function post(path, body) {
const response = await fetch(path, {
method: "POST",
credentials: "include",
headers: {"content-type": "application/json"},
body: JSON.stringify(body),
});
if (!response.ok) {
throw new Error(`Recording request failed (${response.status})`);
}
return response.json();
}
export function startCustomRecording(roomName) {
return post(`/api/rooms/${encodeURIComponent(roomName)}/recordings`, {
customUiRecording: {enabled: true, scene},
});
}
Your backend must authenticate the user, verify recording authority, validate the scene, and bind each logical source to media owned by the current room. It must reject client-supplied producer IDs, output paths, filenames, arbitrary URLs, HTML/CSS, commands, and compositor arguments.
3. Keep visible state synchronized
Send only the properties that changed. Every event has a contiguous sequence and a stable idempotency key:
let sequence = 1;
let pending;
export async function updateRecordingScene(roomName, mutations) {
if (pending) throw new Error("Retry the pending scene change first.");
pending = {
sequence,
idempotencyKey: `support-call-${String(sequence).padStart(6, "0")}`,
clientAtMs: Date.now(),
mutations,
};
return retryRecordingSceneUpdate(roomName);
}
export async function retryRecordingSceneUpdate(roomName) {
if (!pending) return null;
const accepted = await post(
`/api/rooms/${encodeURIComponent(roomName)}/recording-scene`,
pending,
);
pending = undefined;
sequence += 1;
return accepted;
}
await updateRecordingScene(roomName, [
{layerId: "status", property: "content", value: "ON HOLD"},
]);
If a request times out after it may have reached your backend, resend the exact pending event with the same sequence and idempotency key. Do not create a new event until the previous event is accepted or definitively rejected.
4. Pause, resume, stop, and publish
Use the normal authorized recording controls. MediaSFU uses server receipt time for the scene timeline, so time spent paused is excluded from the finished recording.
Stopping capture begins finalization; it does not mean the file is ready. Keep the UI in a Finishing recording state until your backend receives terminal completion. Then expose an authorized recording reference—not a server path or storage credential.
A completed recording uses the standard filename convention:
Recording_<roomName>_<outputStyle>.mp4
Before making it downloadable or playable, confirm that the file is non-empty and contains H.264 video and AAC audio. Raw recording parts may be retained by your server's recovery policy, but they are not the user-facing result.
What success looks like
In a two-participant test room:
- the main and self-view layers show the intended participants;
- local preview mirroring does not affect remote video or shared screens;
- visible UI changes appear once and in order;
- paused time is absent from the final duration;
- the client remains in a finishing state until terminal completion; and
- the published MP4 plays with both video and audible audio.
Troubleshooting
The scene is rejected
Show the rejected field and keep recording stopped. Check canvas bounds, source IDs, layer limits, text length, colors, and unknown properties.
A source disappears
Apply your product's declared fallback—hide the layer, show a placeholder, or stop recording. Never replace it with a path or producer ID supplied by the client.
A scene update times out
Retry the same pending event. Reusing the payload and idempotency key prevents a single visual change from being applied twice.
The user pressed stop but no file is available
Continue showing the finishing state and read terminal status through your backend. Surface an explicit failure if processing cannot publish the final artifact.
The room ends unexpectedly
Stop capture, release source bindings, close completion subscriptions, and finish or discard the partial artifact according to your retention policy.
Release checklist
- Validate scenes and mutations on both the client and backend.
- Authorize every recording action through the signed-in backend.
- Resolve room media and recording consent on the server.
- Test an uncertain request by retrying the same idempotency key.
- Verify pause-aware timing.
- Exercise participant leave, host end, and room cleanup.
- Wait for terminal completion before exposing the MP4.
- Check H.264 video and AAC audio independently.
- Review a real two-participant recording on every client platform you ship.
For a reusable validator, schema, and JavaScript/Flutter examples, see the
mediasfu-custom-ui-recording reference project.