Skip to content

Recordings, Clips & VOD

Always-on venues, sports, conferences, classrooms, public meetings, monitoring feeds: anywhere the live moment and the long archive both matter. A stream that records broadcasts keeps one recording running per broadcast for as long as the stream is live, and saves it as chapters you can replay after the broadcast ends. You find chapters by listing a UTC time range and replay each one by its playback ID. This page covers recordings, clips from the buffer, and direct VOD uploads.

flowchart LR
    LIVE[Live stream] --> DVR[Live rewind window]
    DVR --> CH[Saved recording chapters]
    DVR --> CLIP[Clip export]
    UPLOAD[Direct upload] --> VOD[VOD asset]
    CH --> PLAY[Playback ID]
    CLIP --> PLAY
    VOD --> PLAY

A stream with Record broadcasts on (record: true) records every broadcast. Two things come out of one recording:

  • Live rewind window: while the broadcast is live, viewers can pause, rewind up to the window’s length, and jump back to live. The window’s length comes from your tier and cluster, so live playlists stay small.
  • Saved recording: the broadcast is kept after it ends, split into chapters. Each chapter is a finalized VOD-shaped artifact with its own playback ID, replayable until retention removes it.

By default a saved recording is split into parts as long as the live rewind window, starting at the broadcast’s start (WINDOW_SIZED). You can instead split it into clock-aligned UTC parts every N hours (FIXED_INTERVAL). A stream set to NONE keeps live rewind only: viewers can rewind while live, and nothing is kept once media leaves the window.

ConceptWhat it controls
Live rewind windowHow far back live viewers can seek while the broadcast is live. Tier- and cluster-bounded.
Saved recordingThe broadcast after it ends, as chapters. One recording can span hours, days, or months if the ingest stays live.
ChaptersThe saved parts. Each is linked to its recording and source stream, and stays out of the tenant-wide VOD library by default.
RetentionHow long a saved recording is kept after the broadcast ends. Retention is not the live rewind window.

A stream’s chapter setting is snapshotted when a recording starts, so a change applies from the next broadcast; a recording in progress keeps its setting.

When creating a stream:

Set record: true in the create mutation:

mutation {
createStream(input: { name: "My Stream", record: true }) {
__typename
... on Stream {
id
streamKey
playbackId
record
}
... on ValidationError {
message
field
}
}
}

On an existing stream:

Toggle recording in the dashboard on the stream detail page, or update via the API:

mutation {
updateStream(input: { record: true }, id: "stream-global-id") {
__typename
... on Stream {
id
record
}
}
}

Recording begins automatically when a stream with record: true goes live. You can also start and stop recording manually during a broadcast:

# Start recording
mutation StartRecording {
startDVR(streamId: "stream-global-id") {
__typename
... on DVRRequest {
dvrHash
playbackId
createdAt
}
... on ValidationError {
message
}
}
}
# Stop recording
mutation StopRecording {
stopDVR(dvrHash: "dvr-hash") {
__typename
... on DeleteSuccess {
success
}
}
}

Recordings get their own playbackId, separate from the live stream’s. This means the same stream supports three viewing modes:

  • Live (streamPlaybackId) — real-time playback at the live edge.
  • Live rewind window (dvrPlaybackId) — seekable playback while the broadcast is live, bounded by the tier/cluster window.
  • Saved recording — the recording’s chapters, each a finalized VOD artifact with its own playbackId, for replay after the broadcast.

After the broadcast ends, the recording’s own dvrPlaybackId plays its most recent finalized chapter. Until the first chapter has finalized (or for a live-rewind-only recording, which has no chapters) /play/{dvrPlaybackId} answers 409 with code DVR_CHAPTERS_PENDING; list the recording’s chapters with dvrChapters to play a specific one.

DVR playback URLs follow the same /play/{playbackId} pattern:

HLS: https://foghorn.frameworks.network/play/{dvrPlaybackId}/hls/index.m3u8
DASH: https://foghorn.frameworks.network/play/{dvrPlaybackId}/cmaf/index.mpd
MP4: https://foghorn.frameworks.network/play/{dvrPlaybackId}.mp4

With the Player SDK, use contentType="dvr" for the live rewind window. The player enables seeking, skip, and speed controls within the active window:

<Player contentType="dvr" contentId="a1b2c3d4e5f6g7h8" />

While a stream is live, the recording grows continuously. Viewers using the recording’s playback ID can:

  • Seek back through the tier-bounded live rewind window
  • Pause and resume without losing their position
  • Jump to live to catch up to the real-time edge

The trade-off is latency — DVR playback goes through HLS or DASH segments, so it’s higher latency than a direct live WebRTC connection. But for live events, conferences, and always-on streams this is a feature: viewers can rewind a goal, replay a slide, or start watching late and catch up within the live rewind window.

For replay after the broadcast, FrameWorks saves the recording as chapter VOD artifacts. Each chapter is its own canonical .mkv produced by the chapter finalization pipeline at boundary close and addressed by a Commodore-minted, shareable playbackId — pass it to resolveViewerEndpoint like any other VOD. The parent DVR’s playback policy is snapshotted onto the chapter and still applies.

For the full chapter-policy story (modes, defaults, resolution semantics) see DVR Chapters. Storage cost behavior is covered in Storage & retention.

A new stream saves window-sized chapters (WINDOW_SIZED). Chapter shapes:

ShapeUse
Window-sized chaptersDefault. Sequential parts as long as the recording’s live rewind window, from the broadcast’s start (WINDOW_SIZED).
Fixed UTC intervalsRegular UTC buckets such as 6-hour or 24-hour spans. Minimum interval is 1 hour (enforced by Commodore + DB).
Live rewind onlyNONE: nothing is saved. Viewers can rewind while live; no chapters are produced and recording.ready never fires.

All chapter time fields are UTC epoch milliseconds. The API does not store timezone names or offsets; clients that care about civil-day boundaries should convert them to a UTC range and list the chapters in that range with dvrChapters(rangeStartMs:, rangeEndMs:).

Chapters become playable at boundary close once finalization produces the .mkv. Retrieve a chapter to get its playbackId; pass that to resolveViewerEndpoint like any other VOD and supply whatever authorization the parent DVR policy requires.

dvrChapter finds a chapter only when startMs and endMs are exactly its boundaries; it does not search for the chapter covering a range. Take the boundaries from dvrChapters, or compute them from the configured mode. With the 6-hour FIXED_INTERVAL configuration shown below, chapters start on UTC multiples of 6 hours and last 21,600,000 ms:

query {
dvrChapter(dvrId: "dvr-hash", startMs: 1715126400000, endMs: 1715148000000) {
chapterId
state
playbackId
isCurrent
hasGaps
segmentCount
wallClockStartUnixMs
wallClockEndUnixMs
playableNow
lastFailureReason
}
}

playbackId is non-null once state reaches FINALIZED. Use wallClockStartUnixMs / wallClockEndUnixMs for player timeline mapping — they reflect the chapter’s actual MKV span (set at finalization to the first/last owned segment’s media times) so video.currentTime maps to wall-clock without drift.

Build a chapter index for player navigation with paginated listing:

query {
dvrChapters(
dvrId: "dvr-hash"
rangeStartMs: 1715040000000
rangeEndMs: 1715212800000
pageSize: 50
) {
chapters {
chapterId
state
playbackId
startMs
endMs
isCurrent
hasGaps
segmentCount
lastFailureReason
}
nextPageToken
}
}

Chapter artifacts also surface through storageArtifactsConnection with kind CHAPTER. Pass a streamId for one stream’s uploads and chapters. Omitting streamId does not hide chapters: kinds: [VOD, CHAPTER] then returns the chapters of every stream in your account along with your uploads. Request kinds: [VOD] for the upload library only.

query StreamVodArtifacts {
storageArtifactsConnection(
input: { kinds: [VOD, CHAPTER], streamId: "stream-global-id", first: 20, offset: 0 }
) {
nodes {
id
kind
playbackId
streamId
title
status
createdAt
}
totalCount
hasNextPage
}
}

The chapter mode is configured on the Stream and snapshotted onto each recording when it starts. Changes apply from the next broadcast, not to a recording in progress. Send dvrChapterIntervalSeconds: 0 with WINDOW_SIZED or NONE to clear a stored interval. To split into 6-hour UTC parts:

mutation {
updateStream(
id: "stream-id"
input: { dvrChapterMode: FIXED_INTERVAL, dvrChapterIntervalSeconds: 21600 }
) {
... on Stream {
id
dvrChapterMode
dvrChapterIntervalSeconds
}
}
}

DVR retention starts after the recording finalizes; an active 24/7 recording is not deleted while live, regardless of age. The resolved horizon is snapshotted onto the artifact at StartDVR — the tier policy in force at that moment is what applies, even if the tenant’s plan changes mid-stream.

The DVR system default is 30 days. A tenant can lift or shorten it per-class via setMediaRetentionPolicy(targetType: DVR), per-stream via setStreamRetentionOverrides, or per-recording via updateMediaRetention once the artifact is finalized. The full cascade and the tier cap behavior are covered in Storage & retention.

query {
storageArtifactsConnection(
input: { kinds: [DVR], streamId: "stream-global-id", first: 10, offset: 0 }
) {
nodes {
hash
playbackId
title
createdAt
expiresAt
durationSeconds
sizeBytes
status
}
totalCount
hasNextPage
}
}

A clip is a time range from the live stream, its DVR window, or a finalized chapter. There are two kinds:

  • Stored clips: create one in the dashboard (the Clips section of a stream’s page) or with createClip. It is processed, saved to your account with its own playbackId, and counts toward storage and retention.
  • Ad-hoc cuts: add startunix, stopunix, or duration to a progressive playback URL such as /play/{dvrPlaybackId}.mp4. The edge returns the range as a file and nothing is saved.

Clips covers both: the four createClip time modes, which source a range is read from, events, the URL parameters, and how far back each playback ID reaches.

The tenantEvents subscription with types: ["recording.ready", "recording.failed"] reports saved and failed recordings. A live-rewind-only (NONE) recording ends with recording.stopped and no recording.ready, because nothing replayable is kept. See Events for every event type.


Upload pre-recorded video files for on-demand playback. VOD uploads use S3-compatible multipart uploads for reliable handling of large files.

  1. Initiate — Create an upload session to get presigned URLs
  2. Upload parts — PUT each file chunk to its presigned URL
  3. Complete — Finalize the upload with part ETags

The SDKs run all three steps with one call, including part retries and aborting the session on failure. The raw GraphQL flow:

# Step 1: Create upload session
mutation {
createVodUpload(
input: { filename: "keynote.mp4", sizeBytes: 524288000, title: "Keynote Recording" }
) {
__typename
... on VodUploadSession {
id
playbackId
partSize
expiresAt
parts {
partNumber
presignedUrl
}
}
... on ValidationError {
message
}
}
}

Upload each part with an HTTP PUT to the presigned URL, then complete:

# Step 3: Complete upload
mutation {
completeVodUpload(
input: {
uploadId: "upload-session-id"
parts: [{ partNumber: 1, etag: "\"abc123\"" }, { partNumber: 2, etag: "\"def456\"" }]
}
) {
__typename
... on VodAsset {
id
playbackId
status
}
}
}

When the file already sits on a web server, a CDN, or an S3 bucket behind a presigned link, let FrameWorks read it from there instead of uploading it yourself:

mutation {
importVodAsset(input: { url: "https://media.example.com/talks/keynote.mp4", title: "Keynote" }) {
__typename
... on VodAsset {
id
playbackId
status
}
... on ValidationError {
message
}
}
}

The asset comes back at once with its playbackId and status PROCESSING. The processing node reads the file straight from the URL, with no separate upload step, and processes it like an upload: upload.created fires on acceptance, upload.completed once the node has fetched the source (with its size), then upload.ready or upload.failed. A source that cannot be fetched emits upload.failed without upload.completed (see Events).

  • Sources: https:// and http:// URLs. For content on IPFS or Arweave, pass a public gateway’s https link.
  • Range requests: the server must support HTTP range requests, as S3, CDNs, and IPFS gateways do. The file is read in blocks, not in one download.
  • Filename: the extension tells FrameWorks how to read the file: mp4, mov, mkv, webm, or ts. When the URL path does not end in one, pass filename. Formats that must be read from a local file (avi, flv, m4v) are not importable; upload them instead.
  • Reachability: the URL must be publicly reachable. Private, loopback, and cloud-metadata addresses are refused on every connection, including after a redirect. Up to 5 http(s) redirects are followed.
  • Failures: a source that cannot be read or processed fails the asset with upload.failed; the asset’s errorMessage has the detail.

After upload completion, the asset goes through validation:

  • UPLOADING → PROCESSING → READY
  • If validation fails: FAILED

Poll vodUploadStatus(uploadId) for the state of an upload, or subscribe to tenantEvents with types: ["upload.completed", "upload.ready", "upload.failed"] to learn when processing finishes. Events carry no progress percentage; see Events.

Same URL pattern as all other content:

<Player contentType="vod" contentId="a1b2c3d4e5f6g7h8" />
# List all library VOD uploads (kinds: [VOD] excludes DVR chapters)
query LibraryUploads {
storageArtifactsConnection(input: { kinds: [VOD], first: 20, offset: 0 }) {
nodes {
id
playbackId
title
status
sizeBytes
durationSeconds
createdAt
}
totalCount
hasNextPage
}
}
# List VOD-shaped artifacts derived from a stream, including DVR chapters
query StreamArtifacts {
storageArtifactsConnection(
input: { kinds: [VOD, CHAPTER], streamId: "stream-global-id", first: 20, offset: 0 }
) {
nodes {
id
kind
playbackId
streamId
title
status
createdAt
}
totalCount
hasNextPage
}
}
# Delete a VOD asset
mutation DeleteUpload {
deleteVodAsset(id: "vod-global-id") {
__typename
... on DeleteSuccess {
success
}
}
}
# Abort an in-progress upload
mutation AbortUpload {
abortVodUpload(uploadId: "upload-session-id") {
__typename
... on DeleteSuccess {
success
}
}
}