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

@effing/ffs

v0.26.0

Published

FFmpeg-based effie rendering service

Downloads

3,820

Readme

@effing/ffs

FFmpeg-based video renderer for Effie compositions.

Part of the Effing family — programmatic video creation with TypeScript.

Takes an EffieData composition and renders it to an MP4 video using FFmpeg. Use as a library or run as a standalone HTTP server.

Installation

npm install @effing/ffs

FFmpeg is bundled via @effing/ffmpeg — no system installation required.

Quick Start

As a Library

import { EffieRenderer } from "@effing/ffs";

const renderer = new EffieRenderer(effieData);
const videoStream = await renderer.render();

// Pipe to file
videoStream.pipe(fs.createWriteStream("output.mp4"));

// Or pipe to HTTP response
videoStream.pipe(res);

// Clean up when done
renderer.close();

As an HTTP Server

# Run the server
npx @effing/ffs

# Or with custom port
FFS_PORT=8080 npx @effing/ffs

Rendering is a two-step process: POST to create a job, then connect to the SSE progress stream to track warmup and rendering. The video URL is revealed in the ready event.

# 1. Create a render job
curl -X POST http://localhost:2000/render \
  -H "Content-Type: application/json" \
  -d '{"effie": ...}'
# Returns: { "id": "...", "progressUrl": "http://localhost:2000/render/.../progress" }

# 2. Connect to SSE progress stream (or use EventSource in browser)
curl http://localhost:2000/render/.../progress
# SSE events: warmup:start, warmup:progress, warmup:complete, ready (with videoUrl)

# 3. Fetch the video
curl http://localhost:2000/render/.../video -o output.mp4

[!NOTE] The server uses an internal HTTP proxy for video/audio URLs to ensure reliable DNS resolution in containerized environments (e.g., Alpine Linux). This is why you might see another server running on a random port.

Environment Variables

| Variable | Description | | -------------------------------- | ---------------------------------------------------- | | FFS_PORT | Server port (default: 2000, falls back to PORT) | | FFS_BASE_URL | Base URL for returned URLs | | FFS_API_KEY | API key for authentication (optional) | | FFS_TRANSIENT_STORE_BUCKET | S3 bucket for transient store (enables S3 mode) | | FFS_TRANSIENT_STORE_ENDPOINT | S3-compatible endpoint (for e.g. R2 or MinIO) | | FFS_TRANSIENT_STORE_REGION | AWS region (default: "auto") | | FFS_TRANSIENT_STORE_PREFIX | Key prefix for stored objects | | FFS_TRANSIENT_STORE_ACCESS_KEY | S3 access key ID | | FFS_TRANSIENT_STORE_SECRET_KEY | S3 secret access key | | FFS_TRANSIENT_STORE_LOCAL_DIR | Local storage directory (when not using S3) | | FFS_TRANSIENT_STORE_TTL_MS | TTL for all transient data in ms (default: 60 min) | | FFS_WARMUP_CONCURRENCY | Concurrent source fetches during warmup (default: 4) |

When FFS_TRANSIENT_STORE_BUCKET is not set, FFS uses the local filesystem for storage (default: system temp directory). Local files are automatically cleaned up after the TTL expires.

For S3 storage, the TTL is set as the Expires header on objects. Note that this is metadata only. To enable automatic deletion, configure S3 lifecycle rules on your bucket to delete expired objects.

API Overview

EffieRenderer

class EffieRenderer {
  constructor(effieData: EffieData<EffieSources>);

  // Render composition
  render(scaleFactor?: number): Promise<Readable>;

  // Clean up FFmpeg process
  close(): void;
}

FFmpegCommand & FFmpegRunner

Lower-level classes for building and executing FFmpeg commands:

import { FFmpegCommand, FFmpegRunner } from "@effing/ffs";

const cmd = new FFmpegCommand(globalArgs, inputs, filterComplex, outputArgs);
const runner = new FFmpegRunner(cmd);
const output = await runner.run(fetchSource, transformImage);

Processing Functions

import { processMotion, processEffects, processTransition } from "@effing/ffs";

// Convert motion config to FFmpeg overlay expression
const overlayExpr = processMotion(delay, motionConfig);

// Build effect filter chain
const filters = processEffects(effects, fps, width, height);

// Get FFmpeg transition name
const xfadeName = processTransition(transition);

Server Endpoints

When running as an HTTP server, FFS provides endpoints for rendering, cache warmup, and cache purging.

POST /render

Creates a render job that includes warmup and render phases. Supports optional cache purging.

Request:

type RenderOptions = {
  effie: EffieData | string; // EffieData object or URL to fetch from
  scale?: number; // Scale factor (default: 1)
  purge?: boolean; // Purge cached sources before warmup
  upload?: {
    videoUrl: string; // Pre-signed URL to upload rendered video
    coverUrl?: string; // Pre-signed URL to upload cover image
  };
};

Alternatively, raw EffieData can be sent directly as the request body. When using the raw format, scale and purge can be passed as query parameters: ?scale=0.5&purge=true.

When effie is a URL, the fetch is deferred to the progress stream (GET /render/:id/progress). The POST returns immediately, and the effie:fetching/effie:fetched SSE events report fetch progress. Any fetch or validation errors are reported as SSE error events with phase: "effie".

Response:

{
  "id": "550e8400-e29b-41d4-a716-446655440000",
  "progressUrl": "http://localhost:2000/render/550e8400-e29b-41d4-a716-446655440000/progress"
}

GET /render/:id/progress

Streams warmup and render progress via SSE. All warmup events are prefixed with warmup:, render events with render:.

Events:

| Event | Phase | Data | | -------------------- | ------ | ---------------------------------------------------------------------------------------------------- | | effie:fetching | effie | { url } — sent when fetching a deferred Effie URL | | effie:fetched | effie | { url } — sent after the Effie URL has been fetched and validated | | purge:complete | purge | { purged: number, total: number } | | warmup:start | warmup | { total: number } | | warmup:progress | warmup | { url, status: "skipped", reason: "http-video-audio-passthrough", cached, failed, skipped, total } | | | | { url, status: "hit", cached, failed, skipped, total } | | | | { url, status: "cached", cached, failed, skipped, total, ms } | | | | { url, status: "error", error, cached, failed, skipped, total, ms } | | warmup:downloading | warmup | { url, status: "started", bytesReceived: 0 } — sent once when download begins | | | | { url, status: "downloading", bytesReceived } — sent every ~10 s during download | | warmup:keepalive | warmup | { cached, failed, skipped, total } — sent every ~25 s during source fetching | | warmup:summary | warmup | { cached, failed, skipped, total } | | warmup:complete | warmup | { status: "ready" } | | keepalive | all | { phase: "effie" \| "warmup" \| "render" \| "upload" } — sent every ~25 s | | render:complete | render | { renderTime?, fetchCoverTime?, uploadCoverTime?, uploadTime } (upload mode; all values in ms) | | ready | — | { videoUrl } (non-upload mode) | | complete | — | { status: "done" } (upload mode) | | error | any | { phase: "effie" \| "warmup" \| "render" \| "upload", message, code } |

Without upload — The ready event provides a videoUrl pointing to /render/:id/video. The actual rendering happens when you fetch that URL:

const events = new EventSource(progressUrl);
events.addEventListener("ready", (e) => {
  const { videoUrl } = JSON.parse(e.data);
  // Fetch videoUrl to stream the rendered video
  events.close();
});

With upload — Uploads directly and streams progress:

const events = new EventSource(progressUrl);
events.addEventListener("render:complete", (e) => {
  const { timings } = JSON.parse(e.data);
  console.log("Uploaded!", timings);
});
events.addEventListener("complete", () => {
  events.close();
});

GET /render/:id/video

Streams the rendered MP4 video (non-upload mode only). Returns 404 until the warmup phase completes and the video sub-job is created.

curl http://localhost:2000/render/550e8400-.../video -o output.mp4

POST /warmup

Creates a standalone warmup job for pre-fetching and caching the sources from an Effie composition.

Request: Same format as /render — wrapped EffieData with effie field, or raw EffieData directly as the body.

Response:

{
  "id": "550e8400-e29b-41d4-a716-446655440000",
  "progressUrl": "http://localhost:2000/warmup/550e8400-e29b-41d4-a716-446655440000/progress"
}

GET /warmup/:id/progress

Runs the cache warmup job and streams the progress via Server-Sent Events (SSE). Connect with EventSource for real-time updates.

Events:

| Event | Data | | ------------- | ---------------------------------------------------------------------------------------------------- | | start | { total: number } | | progress | { url, status: "skipped", reason: "http-video-audio-passthrough", cached, failed, skipped, total } | | | { url, status: "hit", cached, failed, skipped, total } | | | { url, status: "cached", cached, failed, skipped, total, ms } | | | { url, status: "error", error, cached, failed, skipped, total, ms } | | downloading | { url, status: "started", bytesReceived: 0 } — sent once when download begins | | | { url, status: "downloading", bytesReceived } — sent every ~10 s during download | | keepalive | { cached, failed, skipped, total } — sent every ~25 s during source fetching | | summary | { cached, failed, skipped, total } | | complete | { status: "ready" } | | error | { message, code } |

Example:

// Create warmup job
const { progressUrl } = await fetch("/warmup", {
  method: "POST",
  headers: { "Content-Type": "application/json" },
  body: JSON.stringify({ effie: effieData }),
}).then((r) => r.json());

// Stream progress
const events = new EventSource(progressUrl);
events.addEventListener("complete", () => {
  events.close();
  // Now safe to call /render
});

POST /purge

Purges cached sources for a given Effie composition.

Request: Same format as /render — wrapped EffieData with effie field, or raw EffieData directly as the body.

Response:

{ "purged": 3, "total": 5 }

Error Responses

All HTTP error responses share a unified JSON shape:

type ApiError = {
  error: string; // Human-readable message
  code: ErrorCode; // Machine-readable code
  issues?: Array<{ path: string; message: string }>; // Validation details (Zod failures only)
};

| Code | Status | Description | | ---------------- | ------ | ----------------------------------------- | | UNAUTHORIZED | 401 | Missing or invalid API key | | INVALID_EFFIE | 400 | Effie data validation or structural error | | NOT_FOUND | 404 | Job or video not found | | BACKEND_FAILED | varies | Remote render backend returned an error | | FETCH_FAILED | 422 | A source URL could not be fetched | | INTERNAL_ERROR | 500 | Catch-all for unhandled exceptions |

For INVALID_EFFIE errors caused by schema validation, the issues array contains the specific validation failures:

{
  "error": "Invalid effie data",
  "code": "INVALID_EFFIE",
  "issues": [{ "path": "segments.0.layers.0.x", "message": "Required" }]
}

Backend Separation

FFS supports running warmup and render on separate backends via resolver callbacks. When backends are configured, the transient storage must be shared between services (e.g., using S3).

Setup

Pass resolvers to createServerContext:

import { createServerContext } from "@effing/ffs/handlers";
import type {
  RenderBackendResolver,
  WarmupBackendResolver,
} from "@effing/ffs/handlers";

const renderBackendResolver: RenderBackendResolver = (effie, metadata) => ({
  baseUrl: "https://render.your.app",
  apiKey: "secret",
});

const warmupBackendResolver: WarmupBackendResolver = (sources, metadata) => ({
  baseUrl: "https://warmup.your.app",
  apiKey: "secret",
});

const ctx = await createServerContext({
  renderBackendResolver,
  warmupBackendResolver,
});

The warmupBackendResolver determines where warmup work happens — used by /warmup/:id/progress and the warmup phase within /render/:id/progress. The renderBackendResolver determines where video rendering work happens — used by /render/:id/video and the render+upload phase in upload mode.

Both resolvers receive optional metadata (passed via handler options). Return null to handle locally.

Job metadata

Pass server-side metadata to be stored with the job and forwarded to the resolver:

createRenderJob(req, res, ctx, { metadata: { tenantId: "abc" } });
createWarmupJob(req, res, ctx, { metadata: { tenantId: "abc" } });

Examples

Scale Factor for Previews

Render at reduced resolution for faster previews:

const renderer = new EffieRenderer(video);

// Render at 50% resolution
const previewStream = await renderer.render(0.5);

Distributed Rendering

For videos with many segments, you can render in parallel using the partitioning helpers from @effing/effie:

import { EffieRenderer } from "@effing/ffs";
import { effieDataForSegment, effieDataForJoin } from "@effing/effie";

const effieData = /* ... */;

// 1. Render each segment (can be parallelized across workers/servers)
const segmentUrls = await Promise.all(
  effieData.segments.map(async (_, i) => {
    const segEffie = effieDataForSegment(effieData, i);
    const renderer = new EffieRenderer(segEffie);
    const stream = await renderer.render();
    // Upload to storage and get URL
    const url = await uploadToStorage(stream, `segment_${i}.mp4`);
    renderer.close();
    return url;
  })
);

// 2. Join segments with transitions and global audio
const joinEffie = effieDataForJoin(effieData, segmentUrls);
const joinRenderer = new EffieRenderer(joinEffie);
const finalStream = await joinRenderer.render();

Server API Examples

Create render job and stream video:

// Create render job
const { progressUrl } = await fetch("http://localhost:2000/render", {
  method: "POST",
  headers: { "Content-Type": "application/json" },
  body: JSON.stringify({ effie: effieData, scale: 0.5 }),
}).then((r) => r.json());

// Connect to SSE progress
const events = new EventSource(progressUrl);
events.addEventListener("ready", (e) => {
  const { videoUrl } = JSON.parse(e.data);
  // Fetch the video (rendering happens on-demand)
  const video = await fetch(videoUrl).then((r) => r.blob());
  events.close();
});

Render with cache purge:

const { progressUrl } = await fetch("http://localhost:2000/render", {
  method: "POST",
  headers: { "Content-Type": "application/json" },
  body: JSON.stringify({ effie: effieData, scale: 0.5, purge: true }),
}).then((r) => r.json());

Render and upload to S3 (SSE progress):

const { progressUrl } = await fetch("http://localhost:2000/render", {
  method: "POST",
  headers: { "Content-Type": "application/json" },
  body: JSON.stringify({
    effie: effieData,
    upload: {
      videoUrl: "https://s3.../presigned-video-url",
      coverUrl: "https://s3.../presigned-cover-url",
    },
  }),
}).then((r) => r.json());

// Connect to SSE for progress
const events = new EventSource(progressUrl);
events.addEventListener("complete", () => {
  console.log("Done!");
  events.close();
});

Related Packages