Skip to main content

Install

npm i @lavendly/sdk
import { Lavendly } from '@lavendly/sdk';

const vs = new Lavendly({
  apiKey: process.env.LAVENDLY_API_KEY,
  // optional, defaults to https://api.lavendly.ai
  baseURL: process.env.LAVENDLY_API,
});

A render in 10 lines

const wf = await vs.workflows.create({
  name: "Fox in a bookshop",
  shots: [{ prompt: "a sleepy fox in a bookshop discovering an old map",
            duration: 5 }],
});

// .run() creates a render and polls until done. Returns the result.
const { video_url } = await vs.renders.run(wf.id);
console.log(video_url);

Narrated short with music

const wf = await vs.workflows.create({
  name: "Barista at dawn",
  shots: [{ prompt: "a barista pulling an espresso shot at dawn",
            duration: 6 }],
});

await vs.audio.attachTrack(wf.id, "shot_1", {
  kind: "voiceover",
  script: "Forty seconds. The whole café holds its breath.",
  subtitleStyle: "tiktok",
  idempotencyKey: `vo-${wf.id}`,
});

await vs.audio.attachTrack(wf.id, "shot_1", {
  kind: "music",
  mood: "warm low jazz piano",
  ducking: true,
  volume: 0.4,
  idempotencyKey: `music-${wf.id}`,
});

await vs.audio.setClipNativeAudio(wf.id, "shot_1", {
  mode: "mix",
  volume: 0.6,
});

const { video_url } = await vs.renders.run(wf.id, {
  idempotencyKey: `render-${wf.id}`,
});

Multi-clip sequence with shared anchors

const character = "A weathered explorer, mid-40s, dusty leather jacket, sunburnt skin.";
const palette   = "Amber and deep teal, golden-hour cinematic lighting.";

const wf = await vs.workflows.create({
  name: "Cave sequence",
  shots: [
    { prompt: `${character}\n${palette}\nEnters a cave mouth, torch in hand.`,
      duration: 3 },
    { prompt: `${character}\n${palette}\nFinds a glowing crystal embedded in rock.`,
      duration: 3 },
    { prompt: `${character}\n${palette}\nSteps back in awe, eyes wide.`,
      duration: 3 },
  ],
});

// One voiceover bridging the whole sequence
await vs.audio.attachTrack(wf.id, "shot_1", {
  kind: "voiceover",
  script: "He had been searching for years. He did not expect to find it here.",
  subtitleStyle: "cinematic",
  idempotencyKey: `vo-${wf.id}`,
});

const { video_url } = await vs.renders.run(wf.id);

Polling manually

renders.run is a convenience that polls for you. To control polling yourself:
const job = await vs.renders.create(wf.id, {
  idempotencyKey: `render-${wf.id}`,
});

for (;;) {
  const status = await vs.renders.get(wf.id, job.job_id);
  console.log(`${status.status}, ${Math.round(status.progress * 100)}%`);
  if (status.status === "done")   return status.result.video_url;
  if (status.status === "failed") throw new Error(status.error);
  await new Promise((r) => setTimeout(r, 4000));
}

Budget discipline

Before a paid action, check the ledger:
const { available, plan_pool } = await vs.ledger.get();
if (available < 20) throw new Error(`Budget too low: ${available} credits`);
And cap monthly spend:
const { fraction_used } = await vs.usage.monthly();
if (fraction_used > 0.85) {
  console.warn("Monthly budget over 85% used, pausing");
  return;
}

Error handling

Every SDK error carries the same envelope as the API:
try {
  await vs.workflows.create({ name: "huge", shots: bigArray });
} catch (err) {
  console.error(err.code);    // 'workflow_too_large'
  console.error(err.message); // human-readable
  console.error(err.status);  // 413
}
Standard codes: unauthenticated, invalid_args, workflow_not_found, clip_not_found, track_not_found, job_not_found, insufficient_credits, monthly_cost_cap, workflow_too_large. See Errors.

Idempotency in one line

Every paid mutation on the SDK accepts idempotencyKey. Generate it deterministically per logical action, same retry, same response:
const key = `render-${wf.id}-${dateBucket()}`;
await vs.renders.create(wf.id, { idempotencyKey: key });
Two calls with the same key within 5 minutes return the same job ID. The user is billed once.