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

@fotovid/sdk

v1.0.0

Published

Typed Node.js / TypeScript SDK for the Fotovid serverless ffmpeg API — video watermarking, trim, thumbnails, audio extraction, probe

Readme

@fotovid/sdk

npm

Thin, typed Node.js / TypeScript SDK for the Fotovid media API — a serverless ffmpeg API for watermarking video and images, trimming video and audio, extracting audio from video, generating video thumbnails, and probing video metadata. POST a source URL, await the finished file over one HTTPS call. No ffmpeg binary, no native dependencies, nothing to install beyond this package.

Full docs, guides, and API reference: fotovid.co/docs

Why Fotovid

  • No ffmpeg to install or maintain. No binary in your container/Lambda, no native build step, no version drift across machines — this package has zero runtime dependencies.
  • One call, typed end to end. Parameters and results match the API 1:1; autocomplete works, nothing to guess.
  • Sync for quick jobs, async for large ones. Small/short media returns in the same call; video over ~720p or 15s goes through the async task API instead of failing outright.
  • Idempotent by default. Every billed call gets a fresh idempotency key automatically — retry safely without a double charge.
  • Hosted output. Every operation returns a URL to the finished file; no storage bucket to provision or clean up yourself.

Install

npm install @fotovid/sdk

Usage

import Fotovid from "@fotovid/sdk";

const fotovid = new Fotovid({ apiKey: process.env.FOTOVID_API_KEY });

const res = await fotovid.video.watermark({
	source_url: "https://cdn.example.com/clip.mp4",
	watermark_type: "image",
	watermark_image_url: "https://cdn.example.com/logo.png",
	position: "bottom-right",
	opacity: 0.8,
});

console.log(res.url); // URL to the finished file — hosted, time-limited, opaque; see expires_at, store your own copy

Operations

| Method | Endpoint | | --- | --- | | fotovid.video.watermark(input) | POST /v1/video/watermark | | fotovid.image.watermark(input) | POST /v1/image/watermark | | fotovid.video.trim(input) | POST /v1/video/trim | | fotovid.video.extractAudio(input) | POST /v1/video/extract-audio | | fotovid.audio.trim(input) | POST /v1/audio/trim | | fotovid.video.thumbnail(input) | POST /v1/video/extract-cover | | fotovid.video.probe(input) | POST /v1/video/probe |

Parameter names match the API 1:1 (source_url, watermark_image_url, …). Every media operation returns { id, type, url, expires_at, duration? }; probe returns video metadata.

Examples

Video

// Text watermark, bottom-right corner.
await fotovid.video.watermark({
	source_url: "https://cdn.example.com/clip.mp4",
	watermark_type: "text",
	text: "© Acme Inc.",
	position: "bottom-right",
});

// Cut a 10s clip.
await fotovid.video.trim({
	source_url: "https://cdn.example.com/clip.mp4",
	start: 5,
	end: 15,
});

// Pull out the audio track as an MP3.
await fotovid.video.extractAudio({
	source_url: "https://cdn.example.com/clip.mp4",
});

// Grab a frame at 2.5s as a thumbnail.
await fotovid.video.thumbnail({
	source_url: "https://cdn.example.com/clip.mp4",
	at: 2.5,
});

// Metadata only — no file produced.
const meta = await fotovid.video.probe({
	source_url: "https://cdn.example.com/clip.mp4",
});
console.log(meta.width, meta.height, meta.durationSec, meta.fps, meta.codec);

Image

// Logo watermark, scaled to 20% of the source width.
await fotovid.image.watermark({
	source_url: "https://cdn.example.com/photo.jpg",
	watermark_type: "image",
	watermark_image_url: "https://cdn.example.com/logo.png",
	scale: 0.2,
});

Audio

await fotovid.audio.trim({
	source_url: "https://cdn.example.com/track.mp3",
	start: 0,
	end: 30,
});

Async (large or long video)

The sync methods above reject video over ~720p or 15s with a 400 asking you to use the async endpoint (a hard limit — the sync API has a short time budget). For a large video watermark, a long trim, or anything you don't need back in a couple of seconds, submit a task instead and poll for the result:

let task = await fotovid.tasks.video.watermark({
	source_url: "https://cdn.example.com/1080x1920.mp4", // vertical / large video, rejected by sync
	watermark_type: "image",
	watermark_image_url: "https://cdn.example.com/logo.png",
});

while (task.status === "starting" || task.status === "processing") {
	await new Promise((r) => setTimeout(r, 2000));
	task = await fotovid.tasks.get(task.id);
}

if (task.status === "succeeded") {
	console.log(task.outputs); // [{ kind: "video", url: "..." }, ...] — read by kind, order not guaranteed
} else {
	console.error(task.error); // { code, message, request_id, detail? } — a failed task, not an exception
}

tasks.* mirrors the sync methods 1:1 (tasks.video.watermark, tasks.video.trim, tasks.video.extractAudio, tasks.video.thumbnail, tasks.image.watermark, tasks.audio.trim) plus the low-level tasks.create/tasks.get. There's no built-in polling helper — the loop above is the whole pattern. probe has no async form; it's sync-only.

Your own metadata

Pass metadata in the options argument to tag a task with your own labels. They come back on the Task and in the webhook payload, so a callback can be matched against your records without a second lookup:

const task = await fotovid.tasks.video.trim(
	{ source_url: "https://cdn.example.com/clip.mp4", start: 0, end: 30 },
	{
		metadata: { order_id: "A-1001", tenant: "acme" },
		webhook: "https://example.com/hooks/fotovid",
	},
);

task.metadata; // { order_id: "A-1001", tenant: "acme" }

The platform never interprets metadata — it takes no part in routing, auth, billing or idempotency. At most 50 keys, keys ≤40 chars (no square brackets), string values ≤500 chars. Don't put secrets in it: it is returned to anyone who can read the task and delivered to your webhook endpoint. On an idempotent replay the stored task's metadata comes back and the one you sent is ignored.

Task also echoes input back exactly as submitted — the source_url plus that task type's params, flattened into one object.

Breaking in 1.0.0: the input envelope

The async wire moved to an input envelope. The typed helpers above are unchangedtasks.video.watermark({ source_url, ...params }) still takes one flat object. Only two things moved:

// tasks.create — the low-level escape hatch
await fotovid.tasks.create({ type: "video.trim", source_url, params: { start, end } }); // ❌ 0.x
await fotovid.tasks.create({ type: "video.trim", input: { source_url, start, end } });  // ✅ 1.0

// Task — the two echo fields collapsed into one
task.source_url; task.params; // ❌ 0.x
task.input;                   // ✅ 1.0

The server rejects the old request shape with 400 and an errors[].field of body.input.source_url. The response change is silent — task.source_url and task.params simply become undefined — so grep for them when you upgrade. Sync endpoints (fotovid.video.*, fotovid.image.*, fotovid.audio.*) are untouched.

Sync vs async

| | Sync (fotovid.video.*, …) | Async (fotovid.tasks.*) | | --- | --- | --- | | Returns | Finished result, same call | A Task — poll tasks.get until terminal | | Limits | ~720p / 15s | 4K / 600s | | Use for | Small/short media, need the result now | Large video, long clips, batch/background jobs |

Config

new Fotovid({
	apiKey: "p6_<key_id>:<secret>", // or set FOTOVID_API_KEY
	baseUrl: "https://api.fotovid.co", // optional
	fetch: customFetch, // optional, defaults to global fetch (Node 20+)
});

Idempotency

Every operation is billed, so the API requires an Idempotency-Key header. The SDK sends a fresh key per call automatically — you don't have to do anything. To safely retry a request without being charged twice, pass the same key both times:

const idempotencyKey = crypto.randomUUID();
const opts = { idempotencyKey };

await fotovid.video.watermark(input, opts);
// A retry with the same key replays the original result instead of re-charging.
await fotovid.video.watermark(input, opts);

The same applies to fotovid.tasks.* — pass idempotencyKey there too (it travels in the request body, not a header, but the SDK handles that difference for you).

Errors

A non-2xx response throws FotovidError (status, detail, retryAfter). For fotovid.tasks.*, that's the only thing that throws — a task that finishes as "failed" is a normal return value, not an exception; check task.error.

Documentation

License

MIT