TypeScript SDK

@playhead/sdk is a typed TypeScript client over the same REST API. It handles the parts you would otherwise write twice: waiting on long jobs, sending a local file up without loading it into memory, retrying only the failures a retry can fix, and turning a refusal into a typed error that carries the call to send instead.

Last updated

Install

One package, no peer dependencies, Node 20 or newer.

Installbash
npm install @playhead/sdk
Ask about a whole videots
import { Playhead } from "@playhead/sdk";

const playhead = new Playhead();          // reads PLAYHEAD_API_KEY

// No question means the whole video, second zero to the end.
const video = await playhead.ask({
  url: "https://www.tiktok.com/@user/video/7300000000000000000",
});

console.log(video.answer);            // the reading, with the second on every moment
console.log(video.seen_at);           // [0, 1.2, 2.9, 4.0, …] the seconds it read
console.log(video.unanswered);        // what the video does not show, named
console.log(video.credits_charged);

// The next question about this video is the cheap one.
const more = await playhead.ask({
  session_id: video.session_id,
  question: "What does the price on screen say?",
});

Methods

Everything is typed, including the fields that are optional for a reason.

ask(params)Promise<AskResult>
A question about a video, answered in words. Keep the session_id and pass it with the next one. Start here.
inspect(params)Promise<AskResult>
One second of the video, read at full resolution and answered in words.
watch(params)Promise<AskResult>
ask() under the name the first version of this client used. Kept because code in the wild calls it.
transcript(params)Promise<TranscriptResult | QueuedJob>
The words, with the times they were said. No pictures are read.
batches.create(params)Promise<BatchStarted>
Ask about several videos with one request. Use this instead of a loop over ask(). Every video is read on its own, so the answers are comparable.
batches.wait(batchId, options?)Promise<BatchResult>
Wait for a batch and return every answer together. Each call holds the connection for up to 60 seconds, so short videos are usually one round trip.
batches.get(batchId, { wait })Promise<BatchResult>
The batch as it stands. wait holds the connection for up to 60 seconds. Each item carries the ref you gave it.
batches.list()Promise<{ batches: BatchSummary[] }>
Your recent batches, without the answers in them.
batches.cancel(batchId)Promise<{ batch_id, status }>
Stop what has not started. What is already running finishes, and is charged.
uploads.create(pathOrBlob)Promise<UploadResult>
Send a file from your own machine. The bytes stream off disk, so a large file does not become memory. Returns an upl_… id you can use anywhere a url goes.
uploads.ticket()Promise<UploadTicket>
A signed upload address somebody else can send bytes to, with no credential of yours.
jobs.get(jobId)Promise<Job>
One read of a queued job.
waitFor(jobId, options?)Promise<Job>
Wait until the job settles. Throws JobFailed carrying the job's own error body if it does not.
credits(options?)Promise<CreditsResult>
Balance, plan and the ledger behind both.
videos.delete(videoId)Promise<DeleteResult>
Erase a video and everything read from it.

Configuration

Every option has a default that is right for a server-side integration.

Client optionsts
new Playhead({
  apiKey: process.env.PLAYHEAD_API_KEY,       // required; falls back to the env var
  baseUrl: "https://api.tryplayhead.com",     // or PLAYHEAD_API_URL
  timeout: 15 * 60 * 1000,                    // per attempt
  maxRetries: 2,                              // retryable failures only
  headers: { "x-team": "growth" },            // merged into every request
});

Errors

Every failure is a PlayheadError or a subclass, so a catch branches on a type rather than on a string.

Error classes exported by the SDK
ClassWhenRetried automatically
WindowTooLongThe seconds you asked for are more than this call allows. Carries suggestion and maxWindowSeconds.No
SourceUnavailableThe video could not be reached: private, deleted, or behind a sign-in. Upload the file instead.No
AuthenticationErrorMissing, wrong or expired credential.No
PermissionErrorAuthenticated, but not allowed to do this.No
InsufficientCreditsOut of credits.No
RateLimitedToo many requests or too many at once. Carries retryAfter.Yes
NotFoundA job, video or upload id that names nothing.No
InvalidRequestMalformed: a bad timestamp, or neither a url nor an upload id.No
ConnectionErrorThe request never completed.Yes
JobFailedA queued job settled as failed. Carries the job's own error body.No
Recovering from a refusalts
import { WindowTooLong } from "@playhead/sdk";

try {
  await playhead.ask({ url, question, window: { from: 0, to: 900 } });
} catch (error) {
  if (error instanceof WindowTooLong && error.suggestion) {
    // The refusal carries the call that would have worked.
    await playhead.ask({ url, question, ...error.suggestion });
  } else {
    throw error;
  }
}

All 13 types that arrive over the wire are on the errors page, with what to do about each one.

Recipes

The three patterns that come up in almost every integration.

Your own footage, without publishing itts
const upload = await playhead.uploads.create("./exports/cut-v4.mov");

const video = await playhead.ask({
  url: upload.upload_id,
  question: "Is there a black frame anywhere, and does the logo hold to the end?",
});

console.log(video.answer);
Twenty videos, one question, one waitts
const started = await playhead.batches.create({
  question: "What is the hook, and when does the call to action appear?",
  items: ads.map((ad) => ({ url: ad.url, ref: ad.id })),
});

const batch = await playhead.batches.wait(started.batch_id);

for (const item of batch.items) {
  // Match by ref, never by position: a video that failed keeps its place.
  console.log(item.ref, item.error ?? item.answer);
}
Only the words, when the question is about what was saidts
const { text, segments, speech } = await playhead.transcript({ url, words: true });

if (!speech?.is_speech) {
  // Music comes back as fluent sentences nobody said. This is the field
  // that tells you so before you act on them.
  throw new Error("this audio is not speech");
}

More at Examples, each one a real question with the call that answers it.

Questions

Is there a Python SDK?
Not yet. The REST API is plain JSON over HTTP, so Python, Go, Rust or anything else works with the HTTP client you already use. There is nothing this package can do that a plain request cannot.
Does the SDK need Node?
It targets Node 20 and above. Everything else it does is a plain fetch and would run anywhere, but sending a file off your disk is what sets the version floor.
What is the difference between ask() and watch()?
Nothing. watch() is the name the first version of this client used, and it still works so that code in the wild keeps running. New code should call ask().
Which failures does the SDK retry?
A connection that never landed, a 429, and any 5xx. It backs off between attempts and honours Retry-After when the server sends one. Nothing else is retried: a 402 or a malformed request would only burn the rate limit to arrive at the same refusal.