npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@transcodely/sdk

v0.3.10

Published

Official TypeScript/Node SDK for Transcodely — encode video to HLS, DASH and MP4 into your own S3, GCS or R2 bucket, with DRM and signed playback.

Readme

@transcodely/sdk

Official TypeScript / Node SDK for Transcodely — encode video into HLS, DASH and MP4 and write it to your own S3, GCS or R2 bucket, with DRM, signed playback and deterministic output paths. Or let Transcodely host and deliver it.

npm install @transcodely/sdk

Quick start

import { Transcodely, OutputFormat, VideoCodec, Resolution } from "@transcodely/sdk";

const client = new Transcodely({ apiKey: process.env.TRANSCODELY_API_KEY! });

// Create a job
const job = await client.jobs.create({
  inputUrl: "https://example.com/source.mp4",
  // Write outputs to Transcodely-managed storage. Drop `managed` and set
  // `outputOriginId: "ori_..."` to write to your own configured origin.
  managed: true,
  outputs: [{
    type: OutputFormat.HLS,
    video: [
      { codec: VideoCodec.H264, resolution: Resolution.RESOLUTION_1080P },
      { codec: VideoCodec.H264, resolution: Resolution.RESOLUTION_720P },
    ],
  }],
  // Optional: encode only a sub-range of the input. Applies job-wide and
  // reduces cost (billing keys off the produced output duration). Omit
  // `endSeconds` (or leave 0) to encode through to the end of the input.
  clip: { startSeconds: 2, endSeconds: 7 },
});

console.log(job.id); // "job_a1b2c3d4e5f6"

// Watch progress in real time
for await (const event of client.jobs.watch(job.id)) {
  console.log(event.job?.status, event.job?.progress);
  if (event.job?.status === 4 /* COMPLETED */) break;
}

Upload a file

client.uploads.putFile takes a file from your disk to a hosted, playable video in one call. It opens the upload, pushes the bytes straight to object storage, and tells the API the bytes landed — the API never proxies the media.

const video = await client.uploads.putFile("./talk.mp4", {
  appId: "app_k1l2m3n4o5",
  title: "Conference talk",
  onProgress: (p) => process.stdout.write(`\r${p.percent}%`),
});

console.log(video.id, video.status); // "vid_a1b2c3d4e5f6g7" "processing"

The transcode starts on its own as soon as the upload completes, so the video comes back already processing. Follow it with client.videos.watch(video.id) until it reaches ready (or error, or deleted), or subscribe to the video.ready webhook.

The app does not need managed hosting turned on first. An app that has never hosted anything is provisioned by the create call itself — bucket, managed origin and CDN pull zone — which is why a first upload takes a few seconds longer than the ones after it. Two things can still refuse it, and they are worth telling apart:

  • hosting_provisioning_failed — provisioning did not complete. Nothing was created, so the identical call is safe to retry.
  • a billing or admission code — billing_past_due, outstanding_balance_exceeded, limit_exceeded, intake_paused, app_suspended. These are about the account, not about hosting, and retrying will not clear them.

Sources. A filesystem path, a Blob/File, or a ReadableStream. A path and a File supply their own name and size; a bare Blob needs filename, and a stream needs both filename and sizeBytes because the API has to know the total before the first byte moves.

await client.uploads.putFile(blob, { appId, filename: "talk.mp4" });
await client.uploads.putFile(stream, { appId, filename: "talk.mp4", sizeBytes: 734003200 });

How it moves the bytes. A file that fits in one part goes up as a single PUT. Anything larger becomes an S3 multipart upload: parts are uploaded concurrency at a time (default 4), a part that fails on a 5xx, 429, 408 or a network error is retried with jittered backoff, and a presigned URL that has expired is re-signed and retried. If the upload cannot finish, the multipart upload is aborted server-side before the error is thrown — nothing sweeps orphaned multipart uploads, so an abandoned one would cost storage forever.

Knobs.

| Option | Default | Notes | | --- | --- | --- | | partSize | 25 MiB | Raised to the 5 MiB S3 minimum, and further if 10,000 parts would not cover the file. | | concurrency | 4 | Parts in flight at once. For a stream source this also caps how much is buffered. | | maxRetries | 4 | Retries per part, after the first attempt. | | signal | — | Aborts the upload and the multipart upload behind it. | | onProgress | — | Called once per completed part, not per byte. |

Limits and errors. The platform ceiling is 5 GB; an empty file or one over the ceiling is refused before the first request. Transfer failures throw UploadError (UploadAbortedError when your signal fired); everything the API itself refuses keeps its usual error class.

import { UploadAbortedError, UploadError } from "@transcodely/sdk";

Need finer control? The individual RPCs are still there on client.videos (createUpload, createMultipartUpload, getUploadPartUrls, completeMultipartUpload, abortMultipartUpload).

Command line

The same flows from a shell, with no code:

npx transcodely ./talk.mp4
npx transcodely https://example.com/talk.mp4 --wait

Not published yet. The transcodely CLI lives in this repository under packages/cli and ships once this SDK releases the version it pins. Until then the commands above do not resolve on npm; build it from the repo instead (pnpm install && pnpm build, then node packages/cli/dist/index.mjs --help).

Read the output report

Every completed output carries a report of what the produced file actually turned out to be — measured from the encoded file rather than copied from the request — plus the verdict of comparing those measurements against what was asked for.

import type { OutputReport } from "@transcodely/sdk";

const done = await client.jobs.get(job.id);

for (const output of done.outputs) {
  const report: OutputReport | undefined = output.report;
  if (!report) continue; // not measured — never "nothing wrong"

  console.log(
    output.id,
    report.video?.codec,
    `${report.video?.width}x${report.video?.height}`,
    `${report.durationSeconds}s`,
  );

  if (report.verdict && !report.verdict.matchesRequest) {
    for (const m of report.verdict.mismatches) {
      console.log(`  ${m.field}: asked for ${m.expected}, got ${m.actual}`);
    }
  }
}

Branch on m.field — it comes from a fixed vocabulary (video.codec, video.resolution, duration_seconds, …) — rather than on the values beside it. For an ABR ladder the facts describe the highest-resolution rendition, the same one the verdict judges; per-rendition detail stays in variantResults.

An output encoded with per-title content-aware analysis also carries report.contentAware: the VMAF target the search aimed at, the score it reached on its samples, and the CRF it chose. It describes the SEARCH, not the delivered file — vmafAchieved scores short samples taken before the real encode, which is never scored itself. It is undefined on every ordinary output.

It also carries the whole curve the search measured: seedCrf (the CRF the rung would have used without per-title — compare it with crfChosen to see what changed), metTarget (whether the search reached vmafTarget; false means crfChosen is only the best-scoring point it measured), and probes[] (OutputReportContentAwareProbe[], one entry per sample the search measured, in order — each with crf, vmaf, and bitrateKbps). Two probes' bitrates compare directly since every sample is the same cut of the source, so the ratio between the seedCrf probe and the crfChosen probe is the honest per-title saving on that sample. bitrateKbps is the sample's VIDEO-ONLY bitrate — the search's cuts drop audio, subtitles and data — so don't compare it to a delivered output's muxed averageBitrateKbps. All three fields are undefined/empty on reports from workers older than 1.29.0.

AI captions

Add auto-generated captions to any output with a generate subtitle track. Leave language empty (or set "auto") to auto-detect the spoken language, or pass an ISO 639-2 code to force one. A per-job fee is metered by source minute and surfaced on job.fees; the produced captions show up on job.subtitleResults with autoGenerated: true.

import { Transcodely, OutputFormat, VideoCodec, Resolution, SubtitleOperation } from "@transcodely/sdk";

const client = new Transcodely({ apiKey: process.env.TRANSCODELY_API_KEY! });

// Generate captions while transcoding a new source.
await client.jobs.create({
  inputUrl: "https://example.com/source.mp4",
  managed: true,
  outputs: [{
    type: OutputFormat.HLS,
    video: [{ codec: VideoCodec.H264, resolution: Resolution.RESOLUTION_1080P }],
    subtitleTracks: [{ operation: SubtitleOperation.GENERATE, language: "auto" }],
  }],
});

// Retro-caption a video you've already hosted: reference it by inputVideoId and
// request a single captions-only output (no video encode).
const job = await client.jobs.create({
  inputVideoId: "vid_a1b2c3d4e5f6g7",
  outputs: [{ subtitleTracks: [{ operation: SubtitleOperation.GENERATE }] }],
});

for (const result of job.subtitleResults) {
  console.log(result.language, result.autoGenerated, result.url);
}
for (const fee of job.fees) {
  console.log(fee.feeType, fee.amount, fee.currency); // "captions" 0.51 "eur"
}

Authentication

Pass your API key in the constructor:

const client = new Transcodely({ apiKey: process.env.TRANSCODELY_API_KEY! });

Resources

client.jobs            // create / get / list / cancel / confirm / watch
client.uploads         // putFile — the whole create / PUT / complete upload, for a path, Blob or stream
client.videos          // upload RPCs, multipart, createFromUrl, get / list / update / delete / watch / getStats / listTopVideos
client.presets         // create / get / getBySlug / list / update / duplicate / archive
client.origins         // create / get / list / update / validate / archive
client.ingestRules     // create / get / list / update / delete / listEvents / test / replayEvent
client.apps            // create / get / list / update / archive / enableHosting
client.apiKeys         // create / get / list / revoke
client.organizations   // create / get / list / update / checkSlug
client.memberships     // list / get / updateRole / remove
client.users           // getMe / get / list / updateMe
client.health          // check

Every enum in the API schema is exported by name from the package root, so you can branch on one without reaching into the generated code:

import { HealthStatus } from "@transcodely/sdk";

const health = await client.health.check();
for (const component of health.components) {
  if (component.status !== HealthStatus.HEALTHY) {
    console.warn(component.name, component.message);
  }
}

Origins

An origin tells Transcodely where to read source media from and where to write outputs. Every origin belongs to a single provider; pass exactly one provider-config field (s3, gcs, http, or r2) on create.

Create an S3 origin

import { Transcodely, OriginPermission } from "@transcodely/sdk";

const origin = await client.origins.create({
  name: "Production S3",
  permissions: [OriginPermission.READ, OriginPermission.WRITE],
  s3: {
    bucket: "my-bucket",
    region: "us-east-1",
    credentials: {
      accessKeyId: process.env.S3_ACCESS_KEY!,
      secretAccessKey: process.env.S3_SECRET_KEY!,
    },
    // endpoint: "https://s3.custom.example.com", // for MinIO, Wasabi, etc.
  },
});

Create a GCS origin

import { Transcodely, OriginPermission } from "@transcodely/sdk";

const origin = await client.origins.create({
  name: "Production GCS",
  permissions: [OriginPermission.READ, OriginPermission.WRITE],
  gcs: {
    bucket: "my-gcs-bucket",
    credentials: {
      serviceAccountJson: process.env.GCS_SERVICE_ACCOUNT_JSON!,
    },
  },
});

Create an HTTP origin

import { Transcodely, OriginPermission } from "@transcodely/sdk";

const origin = await client.origins.create({
  name: "Public CDN",
  permissions: [OriginPermission.READ], // HTTP origins are read-only
  http: {
    baseUrl: "https://media.example.com",
    credentials: {
      headers: { Authorization: `Bearer ${process.env.MEDIA_TOKEN!}` },
    },
  },
});

Create an R2 origin

R2 supports two forms. With accountId (32-char hex) the endpoint is derived for you, optionally with a data-residency jurisdiction:

import { Transcodely, OriginPermission, R2Jurisdiction } from "@transcodely/sdk";

const origin = await client.origins.create({
  name: "Production R2",
  permissions: [OriginPermission.READ, OriginPermission.WRITE],
  r2: {
    bucket: "media",
    accountId: process.env.R2_ACCOUNT_ID!,
    jurisdiction: R2Jurisdiction.DEFAULT, // or .EU, .FEDRAMP
    credentials: {
      accessKeyId: process.env.R2_ACCESS_KEY!,
      secretAccessKey: process.env.R2_SECRET_KEY!,
    },
  },
});

Or, with an explicit endpoint (custom domain bound to a bucket, or a jurisdiction not yet enumerated):

r2: {
  bucket: "media",
  endpoint: "https://media.example.com",
  credentials: {
    accessKeyId: process.env.R2_ACCESS_KEY!,
    secretAccessKey: process.env.R2_SECRET_KEY!,
  },
},

Provide either accountId or endpoint, never both. jurisdiction only applies when accountId is set.

Ingest rules

An ingest rule is a standing instruction on one readable origin: when an object matching these filters lands, create this job for it. Your storage provider posts its object-created events to the rule's endpoint, and no server of yours is in the path. Amazon S3 via SNS, Google Cloud Storage via a Pub/Sub push subscription, Supabase Storage via a database webhook, and a generic shape for anything else are all recognised from the payload.

const { rule, secret } = await client.ingestRules.create({
  originId: "ori_a1b2c3d4e5f6",
  name: "Watch uploads/",
  filters: {
    prefix: "uploads/",
    suffixes: [".mp4", ".mov"],
    minBytes: 1024n, // ignore the zero-byte placeholder some clients write first
  },
  action: {
    outputs: [{ preset: "web_1080p_standard" }],
    managed: true, // host and deliver the result; set outputOriginId for your own bucket
    priority: JobPriority.STANDARD,
  },
});

console.log("point your bucket notifications at", rule!.endpointUrl);
console.log("secret (shown once):", secret);

secret is the only time the inbound secret is readable — store it wherever the event sender will read it from. A later get returns just secretPrefix and secretHint. Lost it? update with rotateSecret: true issues a new one, and the previous one keeps working for 24 hours so the sender can be changed without dropping an event.

Every delivery is recorded, whether or not it became a job:

for await (const event of client.ingestRules
  .listEvents({ ruleId: rule!.id })
  .autoPage()) {
  console.log(event.id, event.source, event.objectKey, event.status, event.reason);
}

A SKIPPED event names why in reason — filter_prefix, filter_suffix, filter_content_type, filter_size, bucket_mismatch, rule_disabled, or duplicate. A FAILED one carries the API error code that refused the job, such as limit_exceeded.

Deduplication is permanent: an object is identified by (rule, bucket, key, etag), so re-sending the event or re-uploading the same bytes produces nothing. To give an object another pass — one that arrived while the rule was paused, or was refused while the account was over its cap — replay it:

const replayed = await client.ingestRules.replayEvent("sev_a1b2c3d4e5f6g7");
console.log(replayed.id, "is back in", StorageEventStatus[replayed.status]);

Only SKIPPED and FAILED events can be replayed. When an update switches a paused rule back on, the response reports how large that backlog is in eventsSkippedWhileDisabled.

Before wiring the provider up, dry-run a key against the rule with client.ingestRules.test(...): it reports whether the filters match and, when they do, the exact job request the rule would submit. Nothing is stored.

Webhooks

Transcodely signs every webhook delivery with HMAC-SHA-256 using your endpoint's whsec_… secret. Verify the signature before trusting the body — client.webhooks.constructEvent validates the signature, parses the envelope, and returns a typed event:

import express from "express";
import { Transcodely, WebhookSignatureError, WebhookTimestampError } from "@transcodely/sdk";

const client = new Transcodely({ apiKey: process.env.TRANSCODELY_API_KEY! });
const app = express();

app.post(
  "/webhooks/transcodely",
  express.raw({ type: "application/json" }),
  (req, res) => {
    try {
      const event = client.webhooks.constructEvent(
        req.body,
        req.header("transcodely-signature")!,
        process.env.WEBHOOK_SECRET!,
      );

      // `isKnownEvent` narrows `event` to the events this SDK types precisely,
      // so inside the switch `event.data` is the exact resource — no casts.
      if (client.webhooks.isKnownEvent(event)) {
        switch (event.type) {
          case "job.succeeded":
            console.log("Job done:", event.data.id); // event.data is a fully-typed Job
            break;
          case "video.uploaded":
            console.log("Video uploaded:", event.data.id); // event.data is a Video
            break;
          // ...handle the other known types you care about
        }
      } else {
        // Forward-compat: an event type added to the API after this SDK
        // release still verifies and parses; `event.data` is raw `unknown`.
        console.log("Unhandled future type:", event.type);
      }
      res.sendStatus(200);
    } catch (err) {
      if (err instanceof WebhookSignatureError || err instanceof WebhookTimestampError) {
        res.sendStatus(400);
        return;
      }
      throw err;
    }
  },
);

The signed payload is the raw HTTP body — use express.raw() (or the equivalent in your framework) to receive a Buffer, never express.json().

Narrowing the event

constructEvent (and client.events.retrieve / .list) return a WebhookEvent: the closed, precisely-typed KnownWebhookEvent union plus an open UnknownWebhookEvent arm (type: string; data: unknown) so a payload for an event type added after this SDK release still verifies and parses instead of being dropped. That open arm means a bare switch (event.type) can't narrow event.data on its own — funnel through one of two guards first:

  • isKnownEvent(event) narrows to KnownWebhookEvent, re-enabling a switch (event.type) where every case narrows event.data to the right resource (and TypeScript flags a case you left out if you keep the switch exhaustive). The else branch is your forward-compat handler.
  • isEventType(event, "job.succeeded") narrows to a single event type — ideal for a receiver that only cares about one or two:
if (client.webhooks.isEventType(event, "output.ready")) {
  console.log(event.data.outputUrl); // event.data is a fully-typed JobOutput
}

Both are also exported standalone (import { isKnownEvent, isEventType } from "@transcodely/sdk") for code that doesn't hold a client. WebhookEvent, KnownWebhookEvent, and UnknownWebhookEvent are all exported for annotating your own handler signatures.

Every event carries a request object. Events emitted inside an API request scope (e.g. job.created, from your jobs.create call) set request.id to the originating req_* ID. Events emitted outside a request scope — every worker-driven job.* / output.* event (job.succeeded, job.failed, job.canceled, job.progress, output.ready, …) — set request.id to null. request.idempotencyKey is null whenever the originating request didn't supply one.

constructEvent accepts request.id: null from v0.1.3 onward. v0.1.2 and earlier rejected such deliveries with a WebhookPayloadError — upgrade if you validate worker-driven events.

Multi-secret rotation

Pass an array to verify against both your previous and current secrets during a rotation window:

client.webhooks.constructEvent(body, sig, [process.env.PREVIOUS_SECRET!, process.env.CURRENT_SECRET!]);

Manage endpoints

const endpoint = await client.webhookEndpoints.create({
  appId: "app_xyz",
  url: "https://example.com/webhooks/transcodely",
  enabledEvents: ["job.succeeded", "job.failed", "video.uploaded"],
});
console.log("Store this:", endpoint.secret); // only present on create + rotate

const rotated = await client.webhookEndpoints.rotateSecret(endpoint.id);
console.log("New secret:", rotated.secret);

for await (const ep of client.webhookEndpoints.list({ appId: "app_xyz" }).autoPage()) {
  console.log(ep.id, ep.url);
}

await client.webhookEndpoints.sendTest(endpoint.id, "job.succeeded");

Replay an event

// Fetch a stored event (same shape as constructEvent returns)
const event = await client.events.retrieve("evt_…");
console.log(event.type, event.data);

// Requeue delivery — defaults to every subscribed endpoint, or pass
// `endpointIds` to target a subset.
await client.events.resend("evt_…");

Errors

All SDK errors extend TranscodelyError:

import { TranscodelyError, InvalidRequestError, RateLimitError } from "@transcodely/sdk";

try {
  await client.jobs.create(params);
} catch (err) {
  if (err instanceof InvalidRequestError) {
    for (const v of err.errors) console.warn(`${v.field}: ${v.description}`);
  } else if (err instanceof RateLimitError) {
    await new Promise((r) => setTimeout(r, err.retryAfterMs ?? 1000));
  } else if (err instanceof TranscodelyError) {
    console.error(err.code, err.message, err.requestId);
  } else {
    throw err;
  }
}

The hierarchy:

| Class | Status | When | |---|---|---| | APIConnectionError | — | Network / DNS / TLS failure | | APIError | 5xx | Server-side error | | AuthenticationError | 401 | Bad / missing / revoked key | | PermissionError | 403 | Authenticated but forbidden | | NotFoundError | 404 | Resource doesn't exist | | ConflictError | 409 | Idempotency conflict, slug taken | | RateLimitError | 429 | Carries retryAfterMs | | InvalidRequestError | 400 | Carries errors (FieldViolation[]) | | PreconditionError | 412 | Wrong state (e.g. job not cancelable) |

Every error carries requestId, code, httpStatus, and raw for debugging.

Pagination

Every list method returns a Page you can either await for one page or auto-iterate:

// One page
const page = await client.jobs.list({ pagination: { limit: 50 } });
console.log(page.items, page.nextCursor);

// All items, automatically across pages
for await (const job of client.jobs.list({ pagination: { limit: 50 } }).autoPage()) {
  console.log(job.id);
}

Idempotency

jobs.create accepts an idempotencyKey field. The SDK auto-generates a UUID if you don't pass one, so retries are always safe. For cross-process safety, pass your own:

await client.jobs.create({
  inputUrl: "...",
  outputs: [...],
  idempotencyKey: "create-job-for-asset-12345",
});

For all other write methods, the SDK ships Idempotency-Key HTTP header automatically.

Streaming watch

const ac = new AbortController();
setTimeout(() => ac.abort(), 30_000); // give up after 30s

for await (const event of client.jobs.watch(job.id, { signal: ac.signal })) {
  console.log(event.event, event.job?.status, event.job?.progress);
}

The SDK auto-reconnects on transient network failures (Watch is read-only, so resumption is idempotent — every reconnect emits a fresh SNAPSHOT event). Heartbeat events are filtered by default; pass includeHeartbeats: true to see them.

Configuration

new Transcodely({
  apiKey: string,                          // required
  baseUrl?: string,                        // default: https://api.transcodely.com
  timeoutMs?: number,                      // unary-call timeout, default 30s
  maxRetries?: number,                     // default 3
  apiVersion?: string,                     // override the pinned API version
  defaultHeaders?: Record<string, string>, // sent on every request
  fetchImpl?: typeof fetch,                // for browser DI / testing
  logger?: (event: LogEvent) => void,      // structured request logger
});

Request IDs

Each response carries X-Request-Id. Stripe-style:

console.log(client.lastRequestId); // "req_*"

try { await client.jobs.create(...); }
catch (err) {
  if (err instanceof TranscodelyError) console.error("failed:", err.requestId);
}

Wire format

The SDK uses Connect-RPC over HTTP+JSON with snake_case field names and lowercase simplified enum values (e.g. "pending" instead of "JOB_STATUS_PENDING"). A custom codec handles the transformation transparently — the surface you write against is fully typed.

A few in-memory vs. on-the-wire representations are worth knowing:

  • 64-bit integers (byte sizes, millisecond durations — e.g. sizeBytes) are bigint in memory and serialize as decimal strings on the wire.
  • Enums are numeric in memory but serialize to their lowercase string names via toJSON() (JobStatus.COMPLETED ⇄ "completed").
  • output_url and thumbnail URL values are storage URLs (e.g. s3://bucket/key, gs://bucket/key), not HTTP URLs — resolve them through your origin or CDN to fetch bytes.

Measured output & input metadata

When an output completes, its JobOutput reports the real encoded geometry measured from the produced media — width, height, and averageBitrateKbps. For multi-variant outputs (ABR ladders) those aggregate the ladder, and variantResults (an OutputVariantResult[], indexed like variantPricing) carries per-variant width / height / averageBitrateKbps / sizeBytes. Thumbnail results (ThumbnailResult) likewise carry their real rendered width / height.

On the input side, VideoStreamInfo.rotation exposes container rotation in degrees clockwise (0 / 90 / 180 / 270, absent when the stream has no rotation metadata), and width / height / displayAspectRatio are display-oriented — rotation is already applied, so they match what a player shows.

Versioning

The SDK is versioned independently with semver, starting at 0.1.0. Breaking changes are allowed on minor bumps until 1.0.0. Each release pins a specific calendar-versioned API (Transcodely.API_VERSION) and sends Transcodely-Version on every request.

License

MIT.