Skip to content
Conduit

mcp server

open-videocore

Open-source, OSC-native media asset management API — ingest, transcode, package, search, and deliver video

Eyevinn/open-videocorecommunitylistedunreviewed

Set up

Runs on your computer. Pick your agent and operating system.

This project does not publish an install method we can turn into a config. Follow the README instead.

README

synced Oct 25, 2026

open-videocore


Quick Demo: Open Source Cloud

Run this service in the cloud with a single click.

Badge OSC


Headless, API-first media asset management (MAM) middleware that runs entirely on Open Source Cloud. A single API call provisions the full backing infrastructure — object storage, document store, transcoder, packager, and queue — and the middleware routes each workspace's requests to its own stack.

📖 Documentation — guides, data model, and full API reference.

Features

  • Ingest — URL pull, direct upload, and watch-folder from object storage
  • Transcoding — ABR ladder generation via Encore
  • Scalable transcoding — an Encore auto-scaler spins up Encore instances on demand and tears down idle ones, paired with a dedicated callback listener per instance
  • Packaging — HLS/DASH output via Encore Packager
  • Technical metadata — codec, resolution, duration, bitrate extracted on ingest
  • Thumbnails — poster frame extraction at arbitrary timecodes
  • Clip and trim — sub-segment extraction into new child assets
  • Export / re-wrap — container remux (MP4, MKV, MOV, MXF, TS) without re-encode
  • Flexible metadata — free-form key-value fields with tag support and search
  • Multi-language — per-asset audio track and subtitle track management
  • Collections — named groups for organising assets
  • Search — full-text and metadata field filtering
  • Delivery — playback URLs (HLS/DASH manifests proxy-streamed through the API, or presigned source download); see ADR-003 for the full /delivery + /stream/* contract
  • Webhooks — HTTP event notifications for asset and job lifecycle events
  • Ops UI — built-in dashboard at /ui for managing assets, jobs, and buckets

A dedicated Transcoders tab in the ops UI for observing and tuning the Encore auto-scaler is planned (issue #86).

Requirements

  • An Open Source Cloud account and a Personal Access Token
  • Node.js 20 or later (for local development)

Security and deployment

The service must run behind an authenticating boundary. Do not expose it directly.

Open Videocore has no in-app authentication fallback. Its request gate, requireAuth() in src/auth/workspace.ts, is a presence gate only: it checks that a bearer token is present on the request and rejects anonymous traffic, but it does not verify the token's identity, validity, or authorisation. Real authentication is performed upstream by the OSC auth wall, which authenticates every caller before the request reaches the process (see issue #59 for the authoritative decision that removed in-app tenant resolution in favour of the wall). The presence gate exists only as a last-ditch guard so an accidentally-exposed deployment rejects anonymous requests — it is not a substitute for the wall.

Because of this, the service MUST run in one of the following ways:

  • On OSC — behind the OSC platform auth wall, which is the default when the service is deployed as an OSC instance. This is the supported configuration.
  • Off OSC — behind an equivalent authenticating reverse proxy that authenticates every request before forwarding it, mirroring what the wall does on OSC.

Publishing the container port directly, or otherwise bypassing the auth wall, exposes the full API with no meaningful authentication. Any caller that attaches an arbitrary non-empty bearer token would pass the presence gate and reach every endpoint — asset management, provisioning, storage, and tear-down included. Do not map the container port to a public interface unless an authenticating proxy sits in front of it.

Quick start

The easiest way to get Open Videocore running is through an AI agent connected to OSC via MCP. The agent handles provisioning through natural language — no CLI, no copy-pasting resource IDs.

1. Connect your agent to OSC

For Claude Code or Claude Desktop:

claude mcp add --transport http osc https://mcp.osaas.io/mcp

For Cursor, VS Code, or other MCP-compatible tools, add https://mcp.osaas.io/mcp as an MCP server with your OSC Personal Access Token (from app.osaas.io/settings) as the Bearer token. Full setup guides at osaas.io/mcp.

2. Set up a parameter store

Open Videocore uses a parameter store to track the backing services it provisions. Ask your agent:

Set up an app-config parameter store called ovcconfig for my Open Videocore deployment.

The agent provisions Valkey and the config service, then returns a config API key.

3. Deploy Open Videocore

Create a Personal Access Token for the Open Videocore instance, then create an Open Videocore instance called ovctest. Connect it to the parameter store named ovcconfig using the API key from the previous step. Use the Personal Access Token as the OSC access token. Generate strong passwords for MinioRootPassword and CouchdbAdminPassword.

The agent provisions the instance and returns its public URL — https://<your-instance> in all examples below.

4. Provision a media stack

A single API call stands up the backing infrastructure for a workspace — MinIO, CouchDB, and Valkey. Neither Encore nor the packager is created here; the Encore auto-scaler spins Encore instances up on demand when the first transcode jobs arrive, and the packager is provisioned on demand the first time a packaging job runs (see below).

curl -X POST https://<your-instance>/api/v1/provision \
  -H "Content-Type: application/json" \
  -d '{"name": "mystack"}'

Provisioning is asynchronous. Poll the returned operationId until status reaches "done":

curl https://<your-instance>/api/v1/provision/operations/<operationId>

By default the stack provisions its own MinIO instance and buckets — no storage configuration is required. To point the source and/or packaged-output roles at an existing AWS-region or S3-compatible bucket instead, pass the optional sourceStorage / packagedStorage blocks. See Provisioning with external S3-compatible storage for every field, worked examples, and the CDN-origin pattern.

List, inspect, and tear down stacks:

curl https://<your-instance>/api/v1/provision
curl https://<your-instance>/api/v1/provision/mystack
curl -X DELETE https://<your-instance>/api/v1/provision/mystack

On-demand packaging provisioning

The packaging service (eyevinn-encore-packager) is not provisioned when you provision a stack. It is provisioned lazily, on the first pipeline execution that includes a packaging step, wired to the stack's shared Valkey queue and packaged-output storage, and reused by every subsequent packaging execution. It is torn down automatically when you deprovision the stack (DELETE /api/v1/provision/:name).

Operator-visible trade-offs:

  • Lower provision-time footprint. A freshly provisioned stack creates three OSC instances (MinIO, CouchDB, Valkey) instead of four, and mints no packager secrets or tokens until packaging is actually used. Stacks that never package never pay for a packager.
  • First-run cold start. The first packaging execution on a stack incurs additional latency while the packager instance is created and becomes ready (the create call plus a wait-for-ready gate — the packaging job is only enqueued once the packager reports healthy). Every subsequent packaging execution reuses the running instance and does not pay this cost.
  • No pre-warm option. There is currently no API or configuration flag to pre-warm the packager ahead of the first packaging job — the cold start happens on first use by design. If you need to absorb the cold start before a time-sensitive workload, run one throwaway packaging execution first to leave the packager running for the stack. (Idle teardown of the packager is a noted follow-up and is not yet implemented, so a warmed packager stays up until the stack is deprovisioned.)

5. Bootstrap transcoding profiles

curl -X POST https://<your-instance>/api/v1/profiles/bootstrap

Seeds the profile store from the default Encore test profiles. The ops dashboard is at https://<your-instance>/ui.

Environment variables

VariableRequiredDescription
OSC_ACCESS_TOKENYesPersonal Access Token from app.osaas.io/settings. Injected automatically at deploy time on OSC.
PARAMETER_STORE_API_KEYYesConfigApiKey of the eyevinn-app-config-svc instance.
PARAMETER_STORE_INSTANCE_NAMEYesName of the eyevinn-app-config-svc instance (default ovcconfig).
MINIO_ROOT_PASSWORDYesDeployment-level object-store credential material. Each stack provisioned from here gets its own object-store access key id and secret, derived from this value, so a credential issued for one stack cannot read or write another stack's buckets. Use a high-entropy value (at least 32 characters, which is what reliably reaches the ~112-bit floor the API enforces — generated, not chosen, e.g. openssl rand -base64 32): the derivation key is scrypt of this value, and that per-stack isolation holds only while the value is expensive to guess, because anyone holding one stack's leaked credential can otherwise search for it offline. Below that strength the API logs an error at startup and for each affected stack, and newly provisioned stacks keep the deployment-wide credential instead; set ALLOW_SHARED_OBJECT_STORE_CREDENTIAL=true to acknowledge that deliberately and downgrade the startup line to a warning. Treat it as a root secret: it is never used as a stack credential itself, but it can derive every stack's credential. Rotating it invalidates the derived credentials of already-provisioned stacks (the live instances keep the credential they were created with), exactly as rotating it did before. A stack provisioned by an older build keeps its original deployment-wide credential until it is migrated — see Moving a legacy stack to a per-stack object-store credential for how to identify one, what re-provisioning costs, and what rotating this value breaks.
COUCHDB_ADMIN_PASSWORDYesAdmin password used when provisioning CouchDB instances.
PORTNoHTTP port (default 3000).
PROVISION_READY_TIMEOUT_MSNoHow long provisioning waits for each backing instance to report healthy, in milliseconds (default 300000, i.e. 5 minutes). The wait polls instance health on its own loop and treats a dropped poll as "not ready yet", so a single network blip no longer aborts the stack; on timeout the error names the service and the last probe error, and the usual rollback tears down what that run created.
PROVISION_READY_POLL_INTERVAL_MSNoHow often that readiness wait re-checks instance health, in milliseconds (default 1000).
ENCORE_MAX_INSTANCESNoMaximum Encore instances the auto-scaler may run per workspace (default 3). Sets the boot-time value; it can be overridden at runtime without a restart via PATCH /api/v1/scaler/config (maxInstances). That change is persisted to the parameter store and reloaded at boot (#1078), so after a restart the persisted value wins and this variable applies only until the first PATCH (or when the store cannot be read at boot, which is warn-logged) — see Runtime scaler config: where it lives, what wins.
ENCORE_MIN_INSTANCESNoMinimum Encore instances the auto-scaler keeps warm per workspace even when idle (default 0 — scale to zero). Set to 1 or more to keep a warm floor for production / latency-sensitive shared pools. A minInstances sent to PATCH /api/v1/scaler/config is not applied to the already-running scaler, but it is persisted and, since #1078, loaded at boot with precedence over this variable — so changing the enforced floor through the API takes one restart. See Auto-scaler warm floor for the cost-vs-reliability trade-off.
ENCORE_IDLE_TIMEOUT_MSNoIdle time before an Encore instance is torn down, in milliseconds (default 300000, i.e. 5 minutes). Sets the boot-time value; it can be overridden at runtime without a restart via PATCH /api/v1/scaler/config (idleTimeoutMs, minimum 10000). That change is persisted to the parameter store and reloaded at boot (#1078), so after a restart the persisted value wins; this variable is the value for a deployment never configured through the API.
ENCORE_S3_ENDPOINTNoStatic object-store endpoint URL passed to Encore instances so they can read source media. Normally leave this unset on OSC — the endpoint is read per stack from the provisioned stack's own config in the parameter store. Set it only for local development, or as a fallback for deployments with no provisioned stack config. It is a fallback, not an override: when a stack does have a stored endpoint, that stored endpoint is what the transcoder gets, and this value is not used for that stack. When it is unset and no endpoint can be resolved from a stack's stored config, transcode submission fails loudly with an error naming the cause, rather than silently spawning a transcoder with no endpoint (which used to surface as an unexplained 404 on the s3:// input).
ENCORE_S3_INTERNAL_ENDPOINTNoSet to off (also false/0/no/disabled) to stop handing spawned Encore instances the in-cluster object-store Service address (http://<instance>.minio-minio.svc.cluster.local:8080) and use the stack's public endpoint instead. Default is on: the address is looked up from the platform itself (the getInternalEndpoint API, which returns the instance's serviceDns), falling back to deriving it from the stack's stored public endpoint when the platform cannot answer — and it is used only after a live health probe from this API succeeds. Otherwise the public endpoint is used and a warning is logged, so a cross-cluster deployment keeps working unchanged. This exists because one long single-connection read of a large source through the public ingress is severed mid-stream (see docs/investigations/294-minio-ingress-longlived-connections.md); routing the transcoder's reads/writes in-cluster takes the ingress out of that data path. Client-facing URLs (presigned uploads, playback, API responses) always stay public. This applies to the endpoint read from a provisioned stack's config; a static ENCORE_S3_ENDPOINT is used verbatim on its own path and never passes through this resolver. Full audit: docs/investigations/991-internal-object-store-endpoint-paths.md.
ENCORE_S3_INTERNAL_PORTNoService port used for the in-cluster object-store endpoint (default 8080, the S3 API port). An invalid value falls back to the default rather than failing startup. The platform's getInternalEndpoint currently reports no ports for object-store instances (ports: []), so this default is what is actually used; if the platform starts reporting ports, the reported one is preferred over this.
ENCORE_S3_INTERNAL_PROBE_TIMEOUT_MSNoTimeout for the in-cluster endpoint health probe, in milliseconds (default 3000). On timeout the public endpoint is used.
ENCORE_INPUT_RECONNECT_ENABLEDNoSet to false to submit transcode job inputs with no input params at all, the pre-#1222 behaviour. Default is on: every network input (s3:/http:/https:) carries reconnect=1, reconnect_on_network_error=1, reconnect_delay_max and rw_timeout, so a source connection that dies mid-read times out and resumes from the same byte offset instead of hanging the read or failing the job with no output. Only the exact string false (case-insensitive, surrounding whitespace ignored) disables it — any other value, including unset, keeps the recovering behaviour, so the safe setting is the default. This is a diagnostic escape hatch for ruling these params out while investigating something else, not a tuning knob. Note the params cover the encode's own source reads; the analyze step that runs before it takes no input params service-side, so that window is still a single unprotected read (docs/osc-feedback/incoming-issue1222-mediainfo-no-input-params.md).
ENCORE_INPUT_RECONNECT_DELAY_MAX_SECONDSNoCeiling on the exponential backoff between a network input's reconnect attempts, in seconds (default 30). Emitted as ffmpeg's reconnect_delay_max input option. Unset, unparseable, non-positive, or fractional-below-1 (which would truncate to 0 and remove the ceiling) falls back to the default rather than disabling the bound; a larger fractional value is truncated to whole seconds. Ignored when ENCORE_INPUT_RECONNECT_ENABLED=false.
ENCORE_INPUT_RW_TIMEOUT_MICROSECONDSNoRead deadline for a network input, in microseconds (default 10000000, i.e. 10 seconds) — the unit is ffmpeg's, not this API's, so this is seconds-scale and not ~3 hours. Emitted as ffmpeg's rw_timeout input option: a read that stalls longer than this fails, and the reconnect logic above takes over. Unset, unparseable, non-positive, or fractional-below-1 (which would truncate to 0, and ffmpeg reads rw_timeout=0 as no timeout) falls back to the default rather than disabling the bound. Raise it for a source that is legitimately slow to first byte; lower it to detect a dead source sooner. Ignored when ENCORE_INPUT_RECONNECT_ENABLED=false.
ENCORE_PROFILES_URLNoDefault Encore profile index used to seed the profile store on first startup / bootstrap (default: the Eyevinn encore-test-profiles index).
PUBLIC_BASE_URLNoPublicly-reachable base URL of this API (e.g. https://ovc.example.com). Used to build the profilesUrl handed to each Encore instance the auto-scaler spawns, pointing at GET /api/v1/profiles/index.yml so Encore loads the operator-managed profiles from CouchDB. If unset, Encore instances fall back to ENCORE_PROFILES_URL.
OVC_TRUST_ROLE_HEADERNoOpt in to per-caller roles by trusting the X-OVC-Role request header (viewer / editor / admin) as the caller's authenticated role. Default off: only the exact string true enables it, so a typo (TRUE, 1, yes, true) fails safe and leaves the deployment untrusted. Only enable it behind a fronting layer — a reverse proxy or self-deployed identity provider — that strips any client-supplied X-OVC-Role and injects the authenticated caller's role itself. Enabled without such a layer, the header is attacker-controlled: any caller can send X-OVC-Role: admin, and a caller who sends no header at all still resolves to admin (the single-operator default), so every caller is effectively an admin and the role gate stops protecting anything. Left off, the header is deleted at the trust boundary before it is read, every caller resolves to admin, and behaviour is identical to the existing authenticated-⇒-full-access model. The planned OSC catalog option for this is TrustRoleHeader (string "true" / "false", default "false"); it is not in the service manifest yet, so for now set the environment variable directly.

API reference

Task-oriented guides, the data model, and the full endpoint reference are published at videocore.pages.osaas.io — no running instance required, and kept in sync with openapi.json by CI (see Development below).

A generated openapi.json is committed to the repo and kept up to date — no running instance required.

Interactive documentation is also at /api-docs when the service is running.

Collection paths and the trailing slash. Each collection root below (for example POST /api/v1/assets to create an asset record, or GET /api/v1/assets to list) is served both with and without a trailing slash — POST /api/v1/assets and POST /api/v1/assets/ reach the same handler. The generated openapi.json keys these collection-root operations with a trailing slash (/api/v1/assets/), because that is the path the framework emits for a route mounted at a router's prefix root. The tables below use the shorter, slash-free form for readability. Both forms are valid at runtime; neither returns a 404.

Key endpoints:

Health

MethodPathDescription
GET/healthLiveness probe with service identity, build identity, resolver and ingest status
GET/healthzMinimal liveness probe

Which build is a deployment running?

GET /health is the one endpoint that answers this, and it needs no authentication:

$ curl -s https://<your-instance>/health | jq .build
{
  "version": "v1.5.0-56-g92a13cc",
  "commit": "92a13cc4f0e1b2a3d5c7890fab12cd34ef567890",
  "sourceDigest": "11cb8d5651d972cc",
  "builtAt": "2026-09-25T06:11:02Z",
  "packageVersion": "1.5.0"
}

packageVersion is the release line, not the build — it only moves when a release is cut, so two instances on different images share it. The other fields identify the build:

  • version and commit come from git describe --tags --always --dirty and git rev-parse HEAD at image build time, passed in as --build-arg BUILD_VERSION / BUILD_COMMIT (see the Dockerfile). A builder with no git metadata to pass reports "unknown" for both rather than guessing.

  • sourceDigest is always present. The image build computes it from the files it ships, using scripts/source-digest.mjs. To turn a digest back into the commit it was built from:

    $ scripts/find-build-commit.sh 11cb8d5651d972cc
    

The full build string is served unauthenticated on purpose: this project is open source, so a commit identifier discloses nothing that is not already public, and the people who need it — an operator checking a stack after a channel switch, support asking what a customer is running, a probe asserting the expected build — are the least likely to hold a token.

The OpenAPI document's info.version deliberately stays pinned to packageVersion, so the docs badge keeps tracking releases.

Assets

MethodPathDescription
POST/api/v1/assetsCreate an asset record
GET/api/v1/assetsList workspace assets
GET/api/v1/assets/:idGet an asset
PATCH/api/v1/assets/:idUpdate asset fields
DELETE/api/v1/assets/:idDelete an asset
POST/api/v1/assets/ingest-urlIngest a video from a public URL
PUT/api/v1/assets/:id/uploadDirect upload of source media
POST/api/v1/assets/:id/upload-urlGet a presigned single-part upload URL
POST/api/v1/assets/:id/multipart/initiateInitiate a multipart upload
GET/api/v1/assets/:id/multipart/:uploadId/part-urlGet a presigned URL for a part
POST/api/v1/assets/:id/multipart/:uploadId/completeComplete a multipart upload
DELETE/api/v1/assets/:id/multipart/:uploadIdAbort a multipart upload
POST/api/v1/assets/:id/upload-completeFinalize a completed upload
POST/api/v1/assets/:id/transcodeSubmit an ABR transcoding job
POST/api/v1/assets/:id/packageSubmit an HLS/DASH packaging job
POST/api/v1/assets/:id/executeRun a pipeline execution
GET/api/v1/assets/:id/executionsList pipeline executions for an asset
GET/api/v1/assets/:id/executions/:execIdGet a pipeline execution
POST/api/v1/assets/:id/extract-metadataExtract technical metadata
POST/api/v1/assets/:id/thumbnailsExtract poster frames
GET/api/v1/assets/:id/thumbnailsList extracted thumbnails
GET/api/v1/assets/:id/thumbnails/:indexGet a single thumbnail
POST/api/v1/assets/:id/clipClip a time segment into a new asset
POST/api/v1/assets/:id/exportRe-wrap into a different container format
POST/api/v1/assets/:id/deliverDeliver the asset's source object to a registered export destination
GET/api/v1/assets/:id/deliveryGet playback URLs (see ADR-003)
GET/api/v1/assets/:id/stream/*Proxy-stream packaged HLS/DASH manifests and segments (see ADR-003)
PUT/api/v1/assets/:id/metadataReplace free-form metadata
GET/api/v1/assets/:id/tracksList audio and subtitle tracks
POST/api/v1/assets/:id/audio-tracksAdd an audio track
DELETE/api/v1/assets/:id/audio-tracks/:trackIdRemove an audio track
POST/api/v1/assets/:id/subtitle-tracksAdd a subtitle track
DELETE/api/v1/assets/:id/subtitle-tracks/:trackIdRemove a subtitle track
POST/api/v1/assets/:id/tagsAdd a tag
DELETE/api/v1/assets/:id/tags/:tagRemove a tag

Upload errors on the routes above return a machine-readable failure cause alongside the HTTP status (body-size limit, network/connection error, storage backend error, …) — see Upload failure causes.

Jobs

MethodPathDescription
GET/api/v1/jobsList background jobs
GET/api/v1/jobs/:idGet a job
DELETE/api/v1/jobs/:idCancel or delete a job

Profiles

Transcoding profiles are persisted in CouchDB (seeded from ENCORE_PROFILES_URL on first startup) and served to Encore via the public, unauthenticated index.yml endpoint. Operators manage them through the API or the Profiles tab in the ops UI.

MethodPathDescription
GET/api/v1/profilesList profiles (names for the picker + full items)
POST/api/v1/profilesCreate a profile ({ name, yaml })
GET/api/v1/profiles/:nameGet a single profile
PUT/api/v1/profiles/:nameReplace a profile's YAML ({ yaml })
DELETE/api/v1/profiles/:nameDelete a profile
POST/api/v1/profiles/bootstrapSeed profiles from the default Encore index (?force=true to re-seed)
GET/api/v1/profiles/index.ymlPublic Encore-format profile index (no auth)

Search

The single canonical search endpoint. It combines an exact-filter tier (status, tags, mimeType, metadata.<key>, tamsFlowId, tamsTimerange, from/to) with a free-text tier (q, over name and description) behind one contract; all filters are ANDed and results are paginated (page/pageSize, returned as { assets, total, page }).

mimeType filters on the container format extracted from the media and takes either a container token (mp4, webm) or a common media MIME type (video/mp4), which is resolved onto the container family it names. A MIME type that neither resolves to a container family nor names a content type this API accepts on upload can never match anything, so it is rejected with 400 unsupported_mime_type rather than returning an empty page.

status takes the same values and has the same exact-match semantics as GET /api/v1/assets?status=, and is applied independently of q — the same status answers the same asset set with or without a free-text term. It is asset-only: supplying it excludes collection hits, since a collection has no lifecycle status.

from and to bound the asset creation timestamp and are accepted by both GET /api/v1/search and GET /api/v1/assets. Each takes either a calendar date (YYYY-MM-DD) or a full ISO 8601 date-time, and both bounds are inclusive: a bare from date means the first instant of that UTC day and a bare to date the last, so ?from=2026-03-01&to=2026-03-01 returns everything created during 1 March. The range is applied to the whole result set before pagination, so it narrows total and every page rather than only the page returned. A from later than to is a 400, not an empty page.

Search results are always current — there is no reindex step. Search is a read-through projection over the stored documents, not a separately maintained index: every request reads the assets and collections back and filters them, so an edit is visible to the next search immediately. Rename an asset with PATCH /api/v1/assets/{id} (or a collection with PATCH /api/v1/collections/{id}) and the following GET /api/v1/search already answers with the new name, while the old name stops matching. Nothing has to be called in between, by a client or an operator, and nothing needs rebuilding after a restart.

MethodPathDescription
GET/api/v1/searchFull-text and metadata search (canonical)

Deprecated: GET /api/v1/assets/search?q=<term> is a legacy free-text-only alias that returns { items }. It resolves the same assets as GET /api/v1/search/?q=<term> and is retained for backward compatibility; new integrations should use the canonical /api/v1/search endpoint above. The alias is marked deprecated in the OpenAPI spec and sends a Deprecation response header.

Auto-scaler

MethodPathDescription
GET/api/v1/scaler/statusCurrent Encore instance pool status (reports the effective maxInstances, jobsPerInstance — concurrent jobs one instance can take — and idleTimeoutMs; each instance carries draining: true while it is being drained ahead of teardown). Returns scalerActive: false until a stack is provisioned; the auto-scaler activates against the provisioned stack's Valkey immediately after POST /api/v1/provision completes, with no restart. Each provisioned stack has its own Valkey, so the read fans out over all of them: every entry in workspaces[] carries the connectionId its queueDepth/inflightDepth/instances were read from, and connections[] lists every store the read covered. A pool that could not be queried is reported with observed: false and an unobservedReason — its state is unknown, not empty — rather than being left out of the response. connectionId is a stable non-secret label; no connection string, host, port or credential is ever returned.
GET/api/v1/scaler/configGet the auto-scaler configuration in force in the serving process (the runtime value if a PATCH has been accepted since startup, otherwise the boot-time environment values)
PATCH/api/v1/scaler/configUpdate auto-scaler configuration (maxInstances, minInstances, idleTimeoutMs) at runtime; idleTimeoutMs must be at least 10000 ms. Written to the parameter store before it is applied — 501 if no parameter store is configured, 503 if the write fails, and in both cases nothing is applied. The stored value is reloaded at boot (#1078); minInstances is recorded and applied at the next boot, not to the already-running scaler. See Runtime scaler config: where it lives, what wins

Runtime scaler config: where it lives, what wins

PATCH /api/v1/scaler/config changes the auto-scaler's settings without a restart. It is worth being precise about what that does and does not mean:

  • Where the value is stored. Two places, in this order. The request is first written to the parameter store as one complete snapshot of all three fields, under a single deployment-wide key; only then is it applied to the serving process, where maxInstances / idleTimeoutMs are pushed into the running scaler loops. A 200 OK therefore means the change is both saved and applied. If no parameter store is configured you get a 501 and nothing is applied; if the store rejects the write or cannot be reached you get a 503 and nothing is applied, so GET /api/v1/scaler/config still reports the previous values.
  • Precedence. Three layers, in order: the most recent PATCH this process accepted, then the persisted snapshot loaded at boot, then the environment. Since #1078 boot reads the stored record first, so ENCORE_MAX_INSTANCES, ENCORE_MIN_INSTANCES and ENCORE_IDLE_TIMEOUT_MS are the values for a deployment that has never been configured through the API — or for a boot where the parameter store could not be read, which is logged as a warning naming the reason. GET /api/v1/scaler/config and GET /api/v1/scaler/status report whichever is in force, but do not say which of the two it came from, and neither reads the stored record — they report the live copy only.
  • Resetting to the environment default. There is no reset endpoint and no sentinel value. PATCH the environment values back explicitly, which overwrites the stored snapshot. Since #1078 that is the only way back: restarting the process no longer undoes a PATCH, because boot reads the stored snapshot and serves it again (source: "runtime"). A field reports source: "env" again only on a process that booted with no usable stored record.
  • Restarts and replicas. The stored snapshot survives a restart and is read back at boot (#1078): a restarted or redeployed process serves the persisted values, for the config endpoint and for the scaler loops it creates, with no need to re-send the PATCH. GET /api/v1/scaler/config reports a restored field as source: "runtime" (with no updatedAt, because the stored record carries no timestamp). If the store is unreachable at boot the process starts on its environment values and logs a warning with the reason. The applied value of a live PATCH is still local to one process: if you run more than one replica, a PATCH is applied by whichever one answered the request, not by all of them, even though all of them share the one stored record. For a change that must take effect everywhere after a restart, set the environment variable and redeploy.
  • minInstances is the exception. It is validated, persisted, and echoed back, but it is not fanned out to the already-running scaler loops: they keep enforcing the floor they were created with. Since #1078 the persisted value is applied at the next boot, where it takes precedence over ENCORE_MIN_INSTANCES, so a PATCH to the floor takes effect on the loops after a restart rather than never.
  • Patching while the scaler is inactive. While GET /api/v1/scaler/status reports scalerActive: false (no stack provisioned yet) a PATCH is accepted and reported back, but it is not carried into the scaler that activates later against a newly provisioned stack — that activation uses the environment values. Re-send the PATCH once the stack is up.

Auto-scaler warm floor (cost vs. reliability)

The auto-scaler keeps a per-workspace pool of Encore instances and scales it on demand. ENCORE_MIN_INSTANCES sets the warm floor — the minimum number of instances the scaler keeps running even when the pool is idle. It defaults to 0 (scale to zero). The floor is set by that environment variable at boot and changing it takes a restart: PATCH /api/v1/scaler/config accepts a minInstances, persists it and echoes it back, but the running scaler keeps the boot floor (see Runtime scaler config).

The default of 0 and any value >= 1 behave differently, and the right choice depends on whether you are optimising for idle cost or for responsiveness:

  • ENCORE_MIN_INSTANCES=0 (default — scale to zero). When a pool goes idle past ENCORE_IDLE_TIMEOUT_MS, the scaler tears down its last instance, so an idle workspace runs no Encore instances and reclaims the Encore compute spend entirely. The cost is scale-up cold-start latency: the first transcode submitted to a cold pool must wait for the scaler to create an Encore instance and for that instance to become ready before the job is dispatched (instance spawn takes on the order of 60–120 seconds). A scale-to-zero pool also has no warm headroom — every burst starts from an empty pool.

  • ENCORE_MIN_INSTANCES >= 1 (warm floor — recommended for production). The scaler pre-warms up to this many instances regardless of pending work and never scales the pool below the floor on idle teardown. The first job of a burst lands on an already-running instance instead of paying the cold-start wait, and the floor provides standing headroom for latency-sensitive or shared pools. The trade-off is standing cost: the floor instances keep consuming Encore compute while idle, so scale-to-zero savings no longer apply to that workspace.

Recommendation. For production and any latency-sensitive or shared pool, set ENCORE_MIN_INSTANCES to at least 1 so the first request never eats a cold-start spawn. Leave it at the 0 default for development, bursty batch workloads, or cost-sensitive deployments where occasional first-job latency is acceptable in exchange for zero idle spend. Tune ENCORE_MAX_INSTANCES (default 3) and ENCORE_IDLE_TIMEOUT_MS (default 5 minutes) alongside the floor to shape the pool's upper bound and how aggressively it scales back down. Those two can also be moved at runtime via PATCH /api/v1/scaler/config, with the in-memory / lost-on-restart caveats above; set them in the environment for anything you need to persist.

Warm floor vs. drain on scale-down. The warm floor governs how many instances stay up when idle; it is independent of how an instance is removed when the pool does scale down. Whatever the floor, scale-down only ever removes an instance once it reports zero active jobs, so raising the floor is a latency/headroom decision, not a fix for in-flight work — it does not change which instances are eligible for teardown, only the floor they stop at.

What the idle clock measures

An instance's idle age is measured from the last job it finished. An instance that has never been given a job has no such timestamp, so its clock runs from the moment it entered the pool ready for work — a spawned-but-never-dispatched instance is torn down within ENCORE_IDLE_TIMEOUT_MS of becoming ready, exactly like one that has gone idle after a transcode. If a pool record turns up without a usable timestamp at all, the instance is treated as eligible for teardown rather than kept: unknown age must not mean "run forever" for something that bills by the hour. Eligible only means the instance is considered — it is still checked against Encore's own in-flight job list and any pending packaging handoff, and is drained rather than destroyed if either says it is still needed.

The scaler also sweeps, every few minutes, for Encore instances (and their paired callback listeners) that are running on OSC with no pool record at all — the residue of a spawn interrupted part-way through, a wiped Valkey, or a deleted deployment. Nothing else can see those instances, since every other teardown path works from the pool. Because a deployment's cleanup sweep sees every instance in the OSC subscription, not just its own, four things must all hold before anything is removed:

  • the instance name proves it belongs to this deployment (each name carries a fingerprint of the deployment identity, so two deployments with similar names — dev and dev-2, say — can never reclaim each other's instances),
  • no pool, on any workspace, is tracking it,
  • it has been seen unaccounted-for across the whole grace window (20 minutes by default), so a spawn still waiting for its instance to become ready is never cut off mid-flight, and
  • the instance itself confirms it has no queued or in-progress job and no pending packaging handoff. An instance that reports work, or that cannot be reached to answer, is never destroyed by the sweep: it is taken back into the pool and drained by the normal path instead.

Anything the sweep declines to remove is logged by name, so an instance that cannot be reclaimed automatically is at least visible.

The fingerprint is derived from the deployment's workspace identity, which assumes what the rest of the scaler already assumes: one workspace identity means one deployment within a subscription. Two deployments configured with the same workspace identity but pointing at different Valkeys would produce the same fingerprint and each would treat the other's untracked instances as its own to reclaim. Give each deployment its own workspace identity.

Collections

MethodPathDescription
POST/api/v1/collectionsCreate a collection
GET/api/v1/collectionsList collections
GET/api/v1/collections/:idGet a collection
DELETE/api/v1/collections/:idDelete a collection (confirm the member count to delete a non-empty one)
PUT/api/v1/collections/:id/assets/:assetIdAdd an asset to a collection
DELETE/api/v1/collections/:id/assets/:assetIdRemove an asset from a collection

Deleting a collection never deletes its member assets — it only unlinks them. An empty collection deletes outright. A non-empty one is refused with 409, error: "delete_blocked", reason: "member_of_collection", and a memberCount telling you how many assets are in it. Echo that number back to confirm:

DELETE /api/v1/collections/:id?confirmMemberCount=7   ->  204

The count must match the collection's current membership. If it changed in between, you get the same 409 member_of_collection carrying the new authoritative memberCount rather than a silent delete — re-confirm with that value and retry. ?force=true is the blind alternative that skips the check.

A 409 with reason: "delete_protected" is a different condition: the collection carries an explicit delete lock, it has no memberCount, and neither ?confirmMemberCount= nor ?force=true gets past it. Clear the lock with DELETE /api/v1/collections/:id/lock first. Branch on reason, not on the status code.

Webhooks

MethodPathDescription
POST/api/v1/webhooksRegister a webhook
GET/api/v1/webhooksList webhooks
DELETE/api/v1/webhooks/:idDelete a webhook

Storage

MethodPathDescription
GET/api/v1/storage/bucketsList object storage buckets
POST/api/v1/storage/bucketsCreate a bucket
GET/api/v1/storage/buckets/:bucket/watch-folderGet watch-folder status for a bucket
POST/api/v1/storage/buckets/:bucket/watch-folder/toggleToggle watch-folder ingest
GET/api/v1/storage/buckets/:bucket/objectsList objects in a bucket
DELETE/api/v1/storage/buckets/:bucket/objects/*Delete an object

Provisioning

MethodPathDescription
POST/api/v1/provisionProvision a full OSC media stack
GET/api/v1/provisionList provisioned stacks
GET/api/v1/provision/:nameGet a provisioned stack
DELETE/api/v1/provision/:nameDeprovision (tear down) a stack
GET/api/v1/provision/operationsList provisioning operations
GET/api/v1/provision/operations/:idGet a provisioning operation

Admin

MethodPathDescription
GET/api/v1/admin/watch-folder/statusWatch-folder poller status
POST/api/v1/admin/watch-folder/startStart the watch-folder poller
POST/api/v1/admin/watch-folder/stopStop the watch-folder poller

Watch-folder ingest runs one poller per provisioned stack, each watching that stack's own source bucket, so a file dropped into any stack's bucket is ingested into that stack. WATCH_FOLDER_ENABLED and WATCH_FOLDER_POLL_INTERVAL_SECONDS are deployment-wide; the bucket each poller watches comes from the stack's own provisioning record, so a newly provisioned stack is picked up with no restart. The three admin endpoints above report and control the default stack's poller (the stack a request with no X-Stack-Name resolves to).

Internal (called by OSC services, not for direct client use)

MethodPathDescription
POST/api/v1/internal/encore-callbackEncore job callback
POST/api/v1/internal/packagerCallback/successPackager success callback
POST/api/v1/internal/packagerCallback/failurePackager failure callback

Architecture

Open Videocore runs as an OSC service and composes other OSC services at runtime:

OSC ServiceRole
encoreABR transcoding. Instances are pooled and scaled on demand by the built-in Encore auto-scaler (spins up under load, tears down when idle)
eyevinn-encore-callback-listenerBridges Encore callbacks onto the queue — one dedicated listener is paired with each Encore instance the auto-scaler starts
eyevinn-encore-packagerHLS/DASH packaging. Provisioned on demand on the first packaging execution (not at stack-provision time) and torn down on stack deprovision
valkey-io-valkeyQueue and coordination backbone
minio-minioS3-compatible object storage
apache-couchdbAsset metadata document store
eyevinn-ffmpeg-s3Ephemeral FFmpeg jobs (probing, thumbnails, clip, remux)
eyevinn-app-config-svcParameter store for provisioned stack coordinates

Each workspace provisions and owns its own stack. The middleware resolves the right backing services per request using the parameter store — no static connection strings required.

Development

pnpm install
pnpm dev          # starts with tsx watch + .env auto-load
pnpm build        # compile TypeScript
pnpm test         # run test suite

The ops UI is at http://localhost:3000/ui and the interactive API docs are at http://localhost:3000/api-docs. To regenerate openapi.json after adding routes, run pnpm generate:openapi.

The static, task-oriented docs site at public/docs/ (served at http://localhost:3000/ui/docs/) is generated from openapi.json by pnpm generate:docs (see scripts/generate-docs.ts) — don't hand-edit files under public/docs/. A GitHub Actions workflow (.github/workflows/update-openapi.yml) runs both generate:openapi and generate:docs on every push to main and commits the results, so the reference section never drifts from the real contract.

For local development against real OSC services, set your OSC_ACCESS_TOKEN, then provision a stack via the Provision tab in the ops UI.

Contributing

See CONTRIBUTING

Support

Join our community on Slack where you can post any questions regarding any of our open source projects. Eyevinn's consulting business can also offer you:

  • Further development of this component
  • Customization and integration of this component into your platform
  • Support and maintenance agreement

Contact sales@eyevinn.se if you are interested.

About Eyevinn Technology

Eyevinn Technology is an independent consultant firm specialized in video and streaming. Independent in a way that we are not commercially tied to any platform or technology vendor. As our way to innovate and push the industry forward we develop proof-of-concepts and tools. The things we learn and the code we write we share with the industry in blogs and by open sourcing the code we have written.

Want to know more about Eyevinn and how it is to work here. Contact us at work@eyevinn.se!

↑↓ navigate openesc close