Claude
Skills
Sign in
Back

recording

Included with Lifetime
$97 forever

Server-side room recording with LiveKit Egress — composite and track-based recording, S3 storage, recording metadata in Postgres. Use this skill when the user says "add recording", "record room", "record video", "record meeting", "egress recording", or "save recording".

Image & Video

What this skill does


# Recording (LiveKit Egress)

Server-side room recording using [LiveKit Egress](https://docs.livekit.io/egress/). Supports both composite recording (all participants in a single layout) and track-based recording (individual participant tracks). Recordings are stored to S3-compatible storage via `EncodedFileOutput` and tracked in Postgres with full metadata.

## Prerequisites

- Next.js app with `src/` directory and App Router
- `video-room` skill applied (provides `livekit-server-sdk`, LiveKit server configuration)
- `storage` skill applied (provides S3-compatible storage with bucket)
- `queue` skill applied (provides Inngest for async job tracking)
- `env-config` skill applied (provides Zod env validation)

## Installation

No additional packages required. Uses `livekit-server-sdk` from the `video-room` skill and `@aws-sdk/client-s3` from the `storage` skill.

## Environment Variables

Uses existing environment variables from dependencies:

```env
# LiveKit (from video-room)
LIVEKIT_API_KEY=your_api_key
LIVEKIT_API_SECRET=your_api_secret
NEXT_PUBLIC_LIVEKIT_URL=wss://your-project.livekit.cloud

# S3 Storage (from storage)
S3_ENDPOINT=http://localhost:9000
S3_ACCESS_KEY=rustfsadmin
S3_SECRET_KEY=rustfsadmin
S3_BUCKET=uploads
S3_REGION=us-east-1
```

Add to `src/env.ts` server schema (if not already present from `video-room`):

```typescript
LIVEKIT_API_KEY: z.string().min(1).optional(),
LIVEKIT_API_SECRET: z.string().min(1).optional(),
```

And client schema:

```typescript
NEXT_PUBLIC_LIVEKIT_URL: z.string().url().optional(),
```

## What Gets Created

```
src/
├── lib/
│   ├── video/
│   │   ├── recording.ts               # Server functions: start, stop, list recordings
│   │   └── types-recording.ts         # RecordingConfig, RecordingResult, RecordingStatus
│   └── db/
│       └── schema/
│           └── recordings.ts          # Drizzle schema: recordings table
└── app/
    └── api/
        └── video/
            └── recordings/
                ├── route.ts           # POST start recording, GET list recordings
                └── [id]/
                    └── route.ts       # GET recording status/download URL, PATCH stop, DELETE
```

## Setup Steps

### Step 1: Create `src/lib/video/types-recording.ts`

```typescript
export type RecordingLayout = "speaker" | "grid" | "single-speaker";

export type RecordingResolution = {
  width: number;
  height: number;
};

export const RECORDING_PRESETS = {
  "720p": { width: 1280, height: 720 },
  "1080p": { width: 1920, height: 1080 },
  "480p": { width: 854, height: 480 },
} as const;

export type RecordingPreset = keyof typeof RECORDING_PRESETS;

export type RecordingCodec = "h264" | "vp8";

export type RecordingConfig = {
  roomName: string;
  layout?: RecordingLayout;
  resolution?: RecordingPreset;
  codec?: RecordingCodec;
  audioBitrate?: number;
  videoBitrate?: number;
  /** If true, records individual tracks instead of composite */
  trackBased?: boolean;
  /** Specific track SID to record (for track-based recording) */
  trackSid?: string;
};

export type RecordingStatusValue =
  | "starting"
  | "active"
  | "stopping"
  | "completed"
  | "failed";

export type RecordingResult = {
  id: string;
  roomName: string;
  egressId: string;
  status: RecordingStatusValue;
  startedAt: Date;
  stoppedAt: Date | null;
  fileUrl: string | null;
  duration: number | null;
  fileSize: number | null;
};

export type StartRecordingResponse = {
  recordingId: string;
  egressId: string;
  status: RecordingStatusValue;
};

export type StopRecordingResponse = {
  recordingId: string;
  egressId: string;
  status: RecordingStatusValue;
  fileUrl: string | null;
  duration: number | null;
};
```

### Step 2: Create `src/lib/db/schema/recordings.ts`

```typescript
import {
  pgTable,
  text,
  timestamp,
  uuid,
  integer,
  real,
} from "drizzle-orm/pg-core";

export const recordings = pgTable("recordings", {
  id: uuid("id").primaryKey().defaultRandom(),
  roomName: text("room_name").notNull(),
  roomSid: text("room_sid"),
  egressId: text("egress_id").notNull(),
  status: text("status", {
    enum: ["starting", "active", "stopping", "completed", "failed"],
  })
    .notNull()
    .default("starting"),
  layout: text("layout", {
    enum: ["speaker", "grid", "single-speaker"],
  }).default("grid"),
  codec: text("codec").default("h264"),
  resolution: text("resolution").default("1080p"),
  startedAt: timestamp("started_at", { withTimezone: true })
    .notNull()
    .defaultNow(),
  stoppedAt: timestamp("stopped_at", { withTimezone: true }),
  fileUrl: text("file_url"),
  storageKey: text("storage_key"),
  fileSize: integer("file_size"),
  duration: real("duration"),
  userId: text("user_id").notNull(),
  createdAt: timestamp("created_at", { withTimezone: true })
    .notNull()
    .defaultNow(),
  updatedAt: timestamp("updated_at", { withTimezone: true })
    .notNull()
    .defaultNow(),
});

export type Recording = typeof recordings.$inferSelect;
export type NewRecording = typeof recordings.$inferInsert;
```

### Step 3: Add export to `src/lib/db/schema/index.ts`

```typescript
export * from "./recordings";
```

### Step 4: Create `src/lib/video/recording.ts`

> **Important**: Use `EncodingOptionsPreset` (enum values) for encoding options instead of constructing partial `EncodingOptions` objects. The `EncodingOptions` interface requires many fields (depth, framerate, audioCodec, etc.) and partial objects will fail type-checking. Use `DirectFileOutput` (not `EncodedFileOutput`) for track-based egress — `startTrackEgress` requires `DirectFileOutput | string`.

```typescript
import {
  EgressClient,
  EncodedFileOutput,
  EncodedFileType,
  DirectFileOutput,
  EncodingOptionsPreset,
} from "livekit-server-sdk";
import { db } from "@/lib/db";
import { recordings } from "@/lib/db/schema/recordings";
import { eq, desc, and } from "drizzle-orm";
import type {
  RecordingConfig,
  RecordingStatusValue,
  StartRecordingResponse,
  StopRecordingResponse,
} from "./types-recording";
import type { Recording } from "@/lib/db/schema/recordings";
import type { RecordingPreset } from "./types-recording";

function getEgressClient(): EgressClient {
  const livekitUrl = process.env.NEXT_PUBLIC_LIVEKIT_URL;
  const apiKey = process.env.LIVEKIT_API_KEY;
  const apiSecret = process.env.LIVEKIT_API_SECRET;

  if (!livekitUrl || !apiKey || !apiSecret) {
    throw new Error(
      "Missing NEXT_PUBLIC_LIVEKIT_URL, LIVEKIT_API_KEY, or LIVEKIT_API_SECRET"
    );
  }

  // Convert wss:// to https:// for REST API
  const httpUrl = livekitUrl.replace("wss://", "https://").replace("ws://", "http://");
  return new EgressClient(httpUrl, apiKey, apiSecret);
}

function getEncodingPreset(resolution: RecordingPreset | undefined): EncodingOptionsPreset {
  switch (resolution) {
    case "480p":
      return EncodingOptionsPreset.H264_720P_30; // closest available preset
    case "720p":
      return EncodingOptionsPreset.H264_720P_30;
    case "1080p":
      return EncodingOptionsPreset.H264_1080P_30;
    default:
      return EncodingOptionsPreset.H264_1080P_30;
  }
}

function buildFileOutput(roomName: string, fileType: EncodedFileType): EncodedFileOutput {
  const bucket = process.env.S3_BUCKET ?? "uploads";
  const timestamp = Date.now();
  const filepath = `recordings/${roomName}/${timestamp}`;

  const output = new EncodedFileOutput({
    fileType,
    filepath,
    output: {
      case: "s3",
      value: {
        accessKey: process.env.S3_ACCESS_KEY ?? "",
        secret: process.env.S3_SECRET_KEY ?? "",
        region: process.env.S3_REGION ?? "us-east-1",
        bucket,
        endpoint: process.env.S3_ENDPOINT ?? "",
        forcePathStyle: true,
      },
    },
  });

  return output;
}

function buildDirectFileOutput(roomName: string): DirectFileOutput {
  const bucket = process.env.S3_BUCKET ?? "uploads";
  const timestamp = Date.now();
  const filepath = `recordings/${roomName}/${timestamp}`;

  return new DirectFileOutput({
Files: 1
Size: 26.6 KB
Complexity: 36/100
Category: Image & Video

Related in Image & Video